From 14c65824966dd7afc44958a9e4c2ca15bb92af1f Mon Sep 17 00:00:00 2001 From: kaminn Date: Sun, 29 Dec 2019 22:49:21 +0800 Subject: [PATCH] 65-week3 --- week_03/65/AQS+ReentrantLock.md | 377 ++++++++++++++++++ week_03/65/JMM.md | 202 ++++++++++ week_03/65/Semaphore.md | 233 +++++++++++ ...12\351\224\201\344\274\230\345\214\226.md" | 272 +++++++++++++ week_03/65/volatile.md | 261 ++++++++++++ ...06\345\270\203\345\274\217\351\224\201.md" | 241 +++++++++++ 6 files changed, 1586 insertions(+) create mode 100644 week_03/65/AQS+ReentrantLock.md create mode 100644 week_03/65/JMM.md create mode 100644 week_03/65/Semaphore.md create mode 100644 "week_03/65/synchronized\345\216\237\347\220\206\345\217\212\351\224\201\344\274\230\345\214\226.md" create mode 100644 week_03/65/volatile.md create mode 100644 "week_03/65/\345\210\206\345\270\203\345\274\217\351\224\201.md" diff --git a/week_03/65/AQS+ReentrantLock.md b/week_03/65/AQS+ReentrantLock.md new file mode 100644 index 0000000..125c758 --- /dev/null +++ b/week_03/65/AQS+ReentrantLock.md @@ -0,0 +1,377 @@ +# AQS(AbstractQueuedSynchronizer) + +## 简介 + +通过使用 JUC 中的同步组件,可以比较简洁地进行并发编程,而在很多同步组件的实现中都出现了`Sync extends AbstractQueuedSynchronizer`的身影,通过对 AQS 的一些方法的重写,实现了相应的组件的功能。AQS 是实现锁的关键,其中锁是面向锁的使用者的,定义了锁的使用方式,而 AQS 是面向锁的实现者的,简化了锁的实现方式,屏蔽了同步状态管理、线程的排队、等待与唤醒等底层操作。 + +AQS 采用了模板方法设计模式,支持通过子类重写相应的方法实现不同的同步器。在 AQS 中,有一个 state 变量,表示同步状态 (这里的同步状态就可以看作是一种资源,对同步状态的获取可以看作是对同步资源的竞争),AQS 提供了多种获取同步状态的方式,包括独占式获取、共享式获取以及超时获取等,下面会进行具体的介绍。 + +## 原理分析 + +下面将结合源码从模板方法、同步状态管理、CLH 锁队列、独占式获取方式、共享式获取方式、超时获取方式等方面分析 AQS 的原理及实现 + +### 模板方法 + +可以通过子类重写的方法列表如下 + +| 方法名称 | 用途 | +| --- | --- | +| tryAcquire(int arg) | 主要用于实现独占式获取同步状态,实现该方法需要查询当前状态是否符合预期,然后进行相应的状态更新实现控制 (获取成功返回 true,否则返回 false,成功通常是可以更新同步状态,失败则是不符合更新同步状态的条件),其中 arg 表示需要获取的同步状态数 | +| tryRelease(int arg) | 主要用于实现独占式释放同步状态,同时更新同步状态 (通常在同步状态 state 更新为 0 才会返回 true,表示已经彻底释放同步资源),其中 arg 表示需要释放的同步状态数 | +| tryAcquireShared(int arg) | 主要用于实现共享式获取同步状态,同时更新同步状态 | +| tryReleaseShared(int arg) | 主要用于实现共享式释放同步状态,同时更新同步状态 | +| isHeldExclusively() | 一般用于判断同步器是否被当前线程独占 | + +### 同步状态管理 + +对线程进行加锁在 AQS 中体现为对同步状态的操作,通过的同步状态地管理,可以实现不同的同步任务,同步状态`state`是 AQS 很关键的一个域 + +```java +// 因为state是volatile的,所以get、set方法均为原子操作,而compareAndSetState方法 +// 使用了Unsafe类的CAS操作,所以也是原子的 +// 同步状态 +private volatile int state; +// 同步状态的操作包括 +// 获取同步状态 +protected final int getState() { return state;} +// 设置同步状态 +protected final void setState(int newState) { state = newState;} +// CAS操作更新同步状态 +protected final boolean compareAndSetState(int expect, int update) { + return unsafe.compareAndSwapInt(this, stateOffset, expect, update); +} + + +``` + +### CLH 锁队列 + +CLH(Craig, Landin, and Hagersten) 锁,是自旋锁的一种。AQS 中使用了 CLH 锁的一个变种,实现了一个双向队列,并使用其实现阻塞的功能,通过将请求共享资源的线程封装为队列中的一个结点实现锁的分配。 + +双向队列的头结点记录工作状态下的线程,后继结点若获取不了同步状态则会进入阻塞状态,新的结点会从队尾加入队列,竞争同步状态 + +```java +// 队列的数据结构如下 +// 结点的数据结构 +static final class Node { + // 表示该节点等待模式为共享式,通常记录于nextWaiter, + // 通过判断nextWaiter的值可以判断当前结点是否处于共享模式 + static final Node SHARED = new Node(); + // 表示节点处于独占式模式,与SHARED相对 + static final Node EXCLUSIVE = null; + // waitStatus的不同状态,具体内容见下文的表格 + static final int CANCELLED = 1; + static final int SIGNAL = -1; + static final int CONDITION = -2; + static final int PROPAGATE = -3; + volatile int waitStatus; + // 记录前置结点 + volatile Node prev; + // 记录后置结点 + volatile Node next; + // 记录当前的线程 + volatile Thread thread; + // 用于记录共享模式(SHARED), 也可以用来记录CONDITION队列(见扩展分析) + Node nextWaiter; + // 通过nextWaiter的记录值判断当前结点的模式是否为共享模式 + final boolean isShared() { return nextWaiter == SHARED;} + // 获取当前结点的前置结点 + final Node predecessor() throws NullPointerException { ... } + // 用于初始化时创建head结点或者创建SHARED结点 + Node() {} + // 在addWaiter方法中使用,用于创建一个新的结点 + Node(Thread thread, Node mode) { + this.nextWaiter = mode; + this.thread = thread; + } + // 在CONDITION队列中使用该构造函数新建结点 + Node(Thread thread, int waitStatus) { + this.waitStatus = waitStatus; + this.thread = thread; + } +} +// 记录头结点 +private transient volatile Node head; +// 记录尾结点 +private transient volatile Node tail; + + +``` + +Node 状态表 (waitStatus,初始化时默认为 0) + +| 状态名称 | 状态值 | 状态描述 | +| --- | --- | --- | +| CANCELLED | 1 | 说明当前结点 (即相应的线程) 是因为超时或者中断取消的,进入该状态后将无法恢复 | +| SIGNAL | -1 | 说明当前结点的后继结点是 (或者将要) 由 park 导致阻塞的,当结点被释放或者取消时,需要通过 unpark 唤醒后继结点 (表现为 unparkSuccessor() 方法) | +| CONDITION | -2 | 该状态是用于 condition 队列结点的,表明结点在等待队列中,结点线程等待在 Condition 上,当其他线程对 Condition 调用了 signal() 方法时,会将其加入到同步队列中去,关于这一部分的内容会在扩展中提及。 | +| PROPAGATE | -3 | 说明下一次共享式同步状态的获取将会无条件地向后继结点传播 | + +下图展示该队列的基本结构 + +![](https://user-gold-cdn.xitu.io/2019/2/25/169239abad7a61bb?imageView2/0/w/1280/h/960/format/webp/ignore-error/1) + +### 独占式获取方式 + +独占式 (EXCLUSIVE) 获取需重写`tryAcquire`、`tryRelease`方法,并访问`acquire`、`release`方法实现相应的功能。 + +acquire 的流程图如下: + +![](https://user-gold-cdn.xitu.io/2019/2/25/169239abe3dd6c9d?imageView2/0/w/1280/h/960/format/webp/ignore-error/1) + +上述流程图比较复杂,这里简单概述一下其中的过程 + +* 线程尝试获取同步状态,如果成功获取则继续执行,如果获取失败则加入到同步队列自旋 +* 自旋过程中若立即获取到同步状态 (前置结点为 head 并且尝试获取同步状态成功) 则可以直接执行 +* 若无法立即获取到同步状态则会将前置结点置为 SIGNAL 状态同时自身通过 park() 方法进入阻塞状态,等待 unpark() 方法唤醒 +* 若线程被 unpark()方法 (此时说明前置结点在执行 release 操作) 唤醒后,前置结点是头结点并且被唤醒的线程获取到了同步状态,则恢复工作 + +主要代码如下: + +```java +// 这里不去看tryAcquire、tryRelease方法的具体实现,只知道它们的作用分别为尝试获取同步状态、 +// 尝试释放同步状态 + +public final void acquire(int arg) { + // 如果线程直接获取成功,或者再尝试获取成功后都是直接工作, + // 如果是从阻塞状态中唤醒开始工作的线程,将当前的线程中断 + if (!tryAcquire(arg) && + acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) + selfInterrupt(); +} +// 包装线程,新建结点并加入到同步队列中 +private Node addWaiter(Node mode) { + Node node = new Node(Thread.currentThread(), mode); + Node pred = tail; + // 尝试入队, 成功返回 + if (pred != null) { + node.prev = pred; + // CAS操作设置队尾 + if (compareAndSetTail(pred, node)) { + pred.next = node; + return node; + } + } + // 通过CAS操作自旋完成node入队操作 + enq(node); + return node; +} +// 在同步队列中等待获取同步状态 +final boolean acquireQueued(final Node node, int arg) { + boolean failed = true; + try { + boolean interrupted = false; + // 自旋 + for (;;) { + final Node p = node.predecessor(); + // 检查是否符合开始工作的条件 + if (p == head && tryAcquire(arg)) { + setHead(node); + p.next = null; + failed = false; + return interrupted; + } + // 获取不到同步状态,将前置结点标为SIGNAL状态并且通过park操作将node包装的线程阻塞 + if (shouldParkAfterFailedAcquire(p, node) && + parkAndCheckInterrupt()) + interrupted = true; + } + } finally { + // 如果获取失败,将node标记为CANCELLED + if (failed) + cancelAcquire(node); + } +} + +``` + +release 流程图如下 + +![](https://user-gold-cdn.xitu.io/2019/2/25/169239abad515edf?imageView2/0/w/1280/h/960/format/webp/ignore-error/1) + +release 的过程比较简单,主要就是通过 tryRelease 更新同步状态,然后如果需要,唤醒后置结点中被阻塞的线程 + +主要代码如下 + +```java +// release +public final boolean release(int arg) { + // 首先尝试释放并更新同步状态 + if (tryRelease(arg)) { + Node h = head; + // 检查是否需要唤醒后置结点 + if (h != null && h.waitStatus != 0) + // 唤醒后置结点 + unparkSuccessor(h); + return true; + } + return false; +} +// 唤醒后置结点 +private void unparkSuccessor(Node node) { + int ws = node.waitStatus; + // 通过CAS操作将waitStatus更新为0 + if (ws < 0) + compareAndSetWaitStatus(node, ws, 0); + Node s = node.next; + // 检查后置结点,若为空或者状态为CANCELLED,找到后置非CANCELLED结点 + if (s == null || s.waitStatus > 0) { + s = null; + for (Node t = tail; t != null && t != node; t = t.prev) + if (t.waitStatus <= 0) + s = t; + } + // 唤醒后置结点 + if (s != null) + LockSupport.unpark(s.thread); +} + + +``` + +### 共享式获取方式 + +共享式 (SHARED) 获取需重写`tryAcquireShared`、`tryReleaseShared`方法,并访问`acquireShared`、`releaseShared`方法实现相应的功能。与独占式相对,共享式支持多个线程同时获取到同步状态并进行工作 + +#### acquireShared + +acquireShared 过程和 acquire 非常相似,流程大致相同,下面简单概括一下 + +* 线程获取同步状态,若能获取到,则直接执行,如获取不到,新建共享式结点进入同步队列 +* 由于获取不到同步状态,线程将被 park 方法阻塞,等待被唤醒 +* 被唤醒后,满足获取同步状态的条件,会向后传播,唤醒后继结点 + +```java +public final void acquireShared(int arg) { + // 尝试共享式获取同步状态,如果成功获取则可以继续执行,否则执行doAcquireShared + if (tryAcquireShared(arg) < 0) + // 以共享式不停得尝试获取同步状态 + doAcquireShared(arg); +} +// Acquires in shared uninterruptible mode. +private void doAcquireShared(int arg) { + // 向同步队列中新增一个共享式的结点 + final Node node = addWaiter(Node.SHARED); + // 标记获取失败状态 + boolean failed = true; + try { + // 标记中断状态(若在该过程中被中断是不会响应的,需要手动中断) + boolean interrupted = false; + // 自旋 + for (;;) { + // 获取前置结点 + final Node p = node.predecessor(); + // 若前置结点为头结点 + if (p == head) { + // 尝试获取同步状态 + int r = tryAcquireShared(arg); + // 若获取到同步状态。 + if (r >= 0) { + // 此时,当前结点存储的线程恢复执行,需要将当前结点设置为头结点并且向后传播, + // 通知符合唤醒条件的结点一起恢复执行 + setHeadAndPropagate(node, r); + p.next = null; // help GC + // 需要中断,中断当前线程 + if (interrupted) + selfInterrupt(); + // 获取成功 + failed = false; + return; + } + } + // 获取同步状态失败,需要进入阻塞状态 + if (shouldParkAfterFailedAcquire(p, node) && + parkAndCheckInterrupt()) + interrupted = true; + } + } finally { + // 获取失败,CANCELL node + if (failed) + cancelAcquire(node); + } +} +// 将node设置为同步队列的头结点,并且向后通知当前结点的后置结点,完成传播 +private void setHeadAndPropagate(Node node, int propagate) { + Node h = head; + setHead(node); + // 向后传播 + if (propagate > 0 || h == null || h.waitStatus < 0 || + (h = head) == null || h.waitStatus < 0) { + Node s = node.next; + if(s == null || s.isShared()) + doReleaseShared(); + } +} + +``` + +#### releasShared + +releaseShared 在尝试释放同步状态成功后,会唤醒后置结点,并且保证传播性 + +```java +public final boolean releaseShared(int arg) { + // 尝试释放同步状态 + if (tryReleaseShared(arg)) { + // 成功后唤醒后置结点 + doReleaseShared(); + return true; + } + return false; +} +// 唤醒后置结点 +private void doReleaseShared() { + // 循环的目的是为了防止新结点在该过程中进入同步队列产生的影响,同时要保证CAS操作的完成 + for (;;) { + Node h = head; + if (h != null && h != tail) { + int ws = h.waitStatus; + if (ws == Node.SIGNAL) { + if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0)) + continue; + unparkSuccessor(h); + } + else if (ws == 0 && + !compareAndSetWaitStatus(h, 0, Node.PROPAGATE)) + continue; + } + if (h == head) + break; + } +} + + +``` + +### 超时获取方式 + +超时获取使通过 AQS 实现的锁支持超时获取锁,这是 synchronized 关键字所不具备的,关于其具体的实现,和上述实现方式相似,只是在独占式、共享式获取的基础上增加了时间的约束,同时通过 parkNanos() 方法为阻塞定时,这里不再过多展开。 + +# ReetrantLock重入锁 + +ReentrantLock,重入锁。通过 AQS 独占式实现加锁、解锁操作,支持同一线程重复获取锁。主要操作为 lock,unlock,其实现分别依赖 acquire 和 release + +```java +private final Sync sync; +// 继承AQS,重写相应方法 +abstract static class Sync extends AbstractQueuedSynchronizer { + abstract void lock(); + final boolean nonfairTryAcquire(int acquires) { ... } + protected final boolean tryRelease(int releases) { ... } + // ...略 +} +static final class NonfairSync extends Sync { + final void lock() { ... } + protected final boolean tryAcquire(int acquires) { ... } +} +static final class FairSync extends Sync { + final void lock() { ... } + protected final boolean tryAcquire(int acquires) { ... } +} + + +``` + +* 重入锁支持公平获取、非公平 (默认) 获取两种方式,通过构造函数 fair 来决定用 NonfairSync 还是用 FairSync 完成 sync 的实例化,两种方式的区别在于公平式要求锁的获取顺序应该符合申请的时间顺序,即严格按照同步队列 FIFO,而非公平式则不考虑(公平式通过对当前结点的前置结点进行判断来保证公平性) +* 重入锁的加锁逻辑是,若锁尚未被获取 (state = 0),说明可以直接获取到锁并且更新同步状态 (此时需要 CAS 更新保证原子性),若锁已经被获取,判断获取锁的线程是否为当前线程,若是,则更新同步状态 (state + acquires,此时直接更新即可,因为只有该线程可以访问该段代码),说明同样可以获取到锁。否则,当前获取不到锁,线程会被阻塞 +* 重入锁的解锁逻辑是,更新同步状态 (state - releases),若 state 为 0,说明该线程完全释放锁,返回 true,否则返回 false diff --git a/week_03/65/JMM.md b/week_03/65/JMM.md new file mode 100644 index 0000000..02daad9 --- /dev/null +++ b/week_03/65/JMM.md @@ -0,0 +1,202 @@ +# Java 内存模型理解 + +Java 内存模型 (Java Memory Model,JMM) 是 Java 虚拟机规范定义的,用来屏蔽掉 Java 程序在各种不同的硬件和操作系统对内存的访问的差异,这样就可以实现 Java 程序在各种不同的平台上都能达到内存访问的一致性。可以避免像 c++ 等直接使用物理硬件和操作系统的内存模型在不同操作系统和硬件平台下表现不同,比如有些 c/c++ 程序可能在 windows 平台运行正常,而在 linux 平台却运行有问题。 + +## 物理硬件和内存 + +首先,在单核电脑中,处理问题要简单的多。对内存和硬件的要求,各种方面的考虑没有在多核的情况下复杂。电脑中,CPU 的运行计算速度是非常快的,而其他硬件比如 IO,网络、内存读取等等,跟 cpu 的速度比起来是差几个数量级的。而不管任何操作,几乎是不可能都在 cpu 中完成而不借助于任何其他硬件操作。所以协调 cpu 和各个硬件之间的速度差异是非常重要的,要不然 cpu 就一直在等待,浪费资源。而在多核中,不仅面临如上问题,还有如果多个核用到了同一个数据,如何保证数据的一致性、正确性等问题,也是必须要解决的。 +目前基于高速缓存的存储交互很好的解决了 cpu 和内存等其他硬件之间的速度矛盾,多核情况下各个处理器 (核) 都要遵循一定的诸如 MSI、MESI 等协议来保证内存的各个处理器高速缓存和主内存的数据的一致性。 + +![](http://upload-images.jianshu.io/upload_images/4899162-4ef24c0bc6373591.png) + +除了增加高速缓存,为了使处理器内部运算单元尽可能被充分利用,处理器还会对输入的代码进行乱序执行 (Out-Of-Order Execution) 优化,处理器会在乱序执行之后的结果进行重组,保证结果的正确性,也就是保证结果与顺序执行的结果一致。但是在真正的执行过程中,代码执行的顺序并不一定按照代码的书写顺序来执行,可能和代码的书写顺序不同。 + +## Java 内存模型 + +虽然 Java 程序所有的运行都是在虚拟机中,涉及到的内存等信息都是虚拟机的一部分,但实际也是物理机的,只不过是虚拟机作为最外层的容器统一做了处理。虚拟机的内存模型,以及多线程的场景下与物理机的情况是很相似的,可以类比参考。 +Java 内存模型的主要目标是定义程序中变量的访问规则。即在虚拟机中将变量存储到主内存或者将变量从主内存取出这样的底层细节。需要注意的是这里的变量跟我们写 Java 程序中的变量不是完全等同的。这里的变量是指实例字段,静态字段,构成数组对象的元素,但是不包括局部变量和方法参数 (因为这是线程私有的)。这里可以简单的认为主内存是 Java 虚拟机内存区域中的堆,局部变量和方法参数是在虚拟机栈中定义的。但是在堆中的变量如果在多线程中都使用,就涉及到了堆和不同虚拟机栈中变量的值的一致性问题了。 +Java 内存模型中涉及到的概念有: + +* 主内存:Java 虚拟机规定所有的变量 (不是程序中的变量) 都必须在主内存中产生,为了方便理解,可以认为是堆区。可以与前面说的物理机的主内存相比,只不过物理机的主内存是整个机器的内存,而虚拟机的主内存是虚拟机内存中的一部分。 +* 工作内存:Java 虚拟机中每个线程都有自己的工作内存,该内存是线程私有的为了方便理解,可以认为是虚拟机栈。可以与前面说的高速缓存相比。线程的工作内存保存了线程需要的变量在主内存中的副本。虚拟机规定,线程对主内存变量的修改必须在线程的工作内存中进行,不能直接读写主内存中的变量。不同的线程之间也不能相互访问对方的工作内存。如果线程之间需要传递变量的值,必须通过主内存来作为中介进行传递。 +**_这里需要说明一下:主内存、工作内存与 Java 内存区域中的 Java 堆、虚拟机栈、方法区并不是一个层次的内存划分。这两者是基本上是没有关系的,上文只是为了便于理解,做的类比_** + +![](http://upload-images.jianshu.io/upload_images/4899162-66736384361f6b8b.png) + +## 工作内存与主内存交互 + +物理机高速缓存和主内存之间的交互有协议,同样的,Java 内存中线程的工作内存和主内存的交互是由 Java 虚拟机定义了如下的 8 种操作来完成的,每种操作必须是原子性的 (double 和 long 类型在某些平台有例外。 +Java 虚拟机中主内存和工作内存交互,就是一个变量如何从主内存传输到工作内存中,如何把修改后的变量从工作内存同步回主内存。 + +* **lock(锁定)**: 作用于主内存的变量,一个变量在同一时间只能一个线程锁定,该操作表示这条线成独占这个变量 +* **unlock(解锁)**: 作用于主内存的变量,表示这个变量的状态由处于锁定状态被释放,这样其他线程才能对该变量进行锁定 +* **read(读取)**: 作用于主内存变量,表示把一个主内存变量的值传输到线程的工作内存,以便随后的 load 操作使用 +* **load(载入)**: 作用于线程的工作内存的变量,表示把 read 操作从主内存中读取的变量的值放到工作内存的变量副本中 (副本是相对于主内存的变量而言的) +* **use(使用)**: 作用于线程的工作内存中的变量,表示把工作内存中的一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时就会执行该操作 +* **assign(赋值)**: 作用于线程的工作内存的变量,表示把执行引擎返回的结果赋值给工作内存中的变量,每当虚拟机遇到一个给变量赋值的字节码指令时就会执行该操作 +* **store(存储)**: 作用于线程的工作内存中的变量,把工作内存中的一个变量的值传递给主内存,以便随后的 write 操作使用 +* **write(写入)**: 作用于主内存的变量,把 store 操作从工作内存中得到的变量的值放入主内存的变量中 + +如果要把一个变量从主内存传输到工作内存,那就要顺序的执行 read 和 load 操作,如果要把一个变量从工作内存回写到主内存,就要顺序的执行 store 和 write 操作。对于普通变量,虚拟机只是要求顺序的执行,并没有要求连续的执行,所以如下也是正确的。对于两个线程,分别从主内存中读取变量 a 和 b 的值,并不一样要 read a; load a; read b; load b; 也会出现如下执行顺序:read a; read b; load b; load a; (对于 volatile 修饰的变量会有一些其他规则, 后边会详细列出),对于这 8 中操作,虚拟机也规定了一系列规则,在执行这 8 中操作的时候必须遵循如下的规则: + +* **不允许 read 和 load、store 和 write 操作之一单独出现**,也就是不允许从主内存读取了变量的值但是工作内存不接收的情况,或者不允许从工作内存将变量的值回写到主内存但是主内存不接收的情况 +* **不允许一个线程丢弃最近的 assign 操作**,也就是不允许线程在自己的工作线程中修改了变量的值却不同步 / 回写到主内存 +* **不允许一个线程回写没有修改的变量到主内存**,也就是如果线程工作内存中变量没有发生过任何 assign 操作,是不允许将该变量的值回写到主内存 +* **变量只能在主内存中产生**,不允许在工作内存中直接使用一个未被初始化的变量,也就是没有执行 load 或者 assign 操作。也就是说在执行 use、store 之前必须对相同的变量执行了 load、assign 操作 +* **一个变量在同一时刻只能被一个线程对其进行 lock 操作**,也就是说一个线程一旦对一个变量加锁后,在该线程没有释放掉锁之前,其他线程是不能对其加锁的,但是同一个线程对一个变量加锁后,可以继续加锁,同时在释放锁的时候释放锁次数必须和加锁次数相同。 +* **对变量执行 lock 操作,就会清空工作空间该变量的值**,执行引擎使用这个变量之前,需要重新 load 或者 assign 操作初始化变量的值 +* **不允许对没有 lock 的变量执行 unlock 操作**,如果一个变量没有被 lock 操作,那也不能对其执行 unlock 操作,当然一个线程也不能对被其他线程 lock 的变量执行 unlock 操作 +* **对一个变量执行 unlock 之前,必须先把变量同步回主内存中**,也就是执行 store 和 write 操作 + +当然,最重要的还是如开始所说,这 8 个动作必须是原子的,不可分割的。 +针对 volatile 修饰的变量,会有一些特殊规定。 + +## volatile 修饰的变量的特殊规则 + +关键字 volatile 可以说是 Java 虚拟机中提供的最轻量级的同步机制。Java 内存模型对 volatile 专门定义了一些特殊的访问规则。这些规则有些晦涩拗口,先列出规则,然后用更加通俗易懂的语言来解释: +假定 T 表示一个线程,V 和 W 分别表示两个 volatile 修饰的变量,那么在进行 read、load、use、assign、store 和 write 操作的时候需要满足如下规则: + +* **只有当线程 T 对变量 V 执行的前一个动作是 load,线程 T 对变量 V 才能执行 use 动作;同时只有当线程 T 对变量 V 执行的后一个动作是 use 的时候线程 T 对变量 V 才能执行 load 操作。** 所以,线程 T 对变量 V 的 use 动作和线程 T 对变量 V 的 read、load 动作相关联,必须是连续一起出现。也就是在线程 T 的工作内存中,每次使用变量 V 之前必须从主内存去重新获取最新的值,用于保证线程 T 能看得见其他线程对变量 V 的最新的修改后的值。 +* **只有当线程 T 对变量 V 执行的前一个动作是 assign 的时候,线程 T 对变量 V 才能执行 store 动作;同时只有当线程 T 对变量 V 执行的后一个动作是 store 的时候,线程 T 对变量 V 才能执行 assign 动作。** 所以,线程 T 对变量 V 的 assign 操作和线程 T 对变量 V 的 store、write 动作相关联,必须一起连续出现。也即是在线程 T 的工作内存中,每次修改变量 V 之后必须立刻同步回主内存,用于保证线程 T 对变量 V 的修改能立刻被其他线程看到。 +* **假定动作 A 是线程 T 对变量 V 实施的 use 或 assign 动作,动作 F 是和动作 A 相关联的 load 或 store 动作,动作 P 是和动作 F 相对应的对变量 V 的 read 或 write 动作;类似的,假定动作 B 是线程 T 对变量 W 实施的 use 或 assign 动作,动作 G 是和动作 B 相关联的 load 或 store 动作,动作 Q 是和动作 G 相对应的对变量 W 的 read 或 write 动作。如果动作 A 先于 B,那么 P 先于 Q。** 也就是说在同一个线程内部,被 volatile 修饰的变量不会被指令重排序,保证代码的执行顺序和程序的顺序相同。 + +总结上面三条规则,前面两条可以概括为:**volatile 类型的变量保证对所有线程的可见性**。第三条为:**volatile 类型的变量禁止指令重排序优化**。 + +* **_valatile 类型的变量保证对所有线程的可见性_** + 可见性是指当一个线程修改了这个变量的值,新值(修改后的值)对于其他线程来说是立即可以得知的。正如上面的前两条规则规定,volatile 类型的变量每次值被修改了就立即同步回主内存,每次使用时就需要从主内存重新读取值。返回到前面对普通变量的规则中,并没有要求这一点,所以普通变量的值是不会立即对所有线程可见的。 + 误解:volatile 变量对所有线程是立即可见的,所以对 volatile 变量的所有修改 (写操作) 都立刻能反应到其他线程中。或者换句话说:volatile 变量在各个线程中是一致的,所以基于 volatile 变量的运算在并发下是线程安全的。 + 这个观点的论据是正确的,但是根据论据得出的结论是错误的,并不能得出这样的结论。volatile 的规则,保证了 read、load、use 的顺序和连续行,同理 assign、store、write 也是顺序和连续的。也就是这几个动作是原子性的,但是对变量的修改,或者对变量的运算,却不能保证是原子性的。如果对变量的修改是分为多个步骤的,那么多个线程同时从主内存拿到的值是最新的,但是经过多步运算后回写到主内存的值是有可能存在覆盖情况发生的。如下代码的例子: + +```java +public class VolatileTest { + public static volatile int race = 0; + public static void increase() { + race++ + } + + private static final int THREADS_COUNT = 20; + + public void static main(String[] args) { + Thread[] threads = new Thread[THREADS_COUNT); + for (int = 0; i < THREADS_COUNT; i++) { + threads[i] = new Thread(new Runnable(){ + @Override + public void run() { + for (int j = 0; j < 10000; j++) { + increase(); + } + } + }); + threads[i].start(); + } + while (Thread.activeCount() > 1) { + Thread.yield(); + } + System.out.println(race); + } +} + +``` + +代码就是对 volatile 类型的变量启动了 20 个线程,每个线程对变量执行 1w 次加 1 操作,如果 volatile 变量并发操作没有问题的话,那么结果应该是输出 20w,但是结果运行的时候每次都是小于 20w,这就是因为`race++`操作不是原子性的,是分多个步骤完成的。假设两个线程 a、b 同时取到了主内存的值,是 0,这是没有问题的,在进行`++`操作的时候假设线程 a 执行到一半,线程 b 执行完了,这时线程 b 立即同步给了主内存,主内存的值为 1,而线程 a 此时也执行完了,同步给了主内存,此时的值仍然是 1,线程 b 的结果被覆盖掉了。 + +* **_volatile 变量禁止指令重排序优化_** + 普通的变量仅仅会保证在该方法执行的过程中,所有依赖赋值结果的地方都能获取到正确的结果,但不能保证变量赋值的操作顺序和程序代码的顺序一致。因为在一个线程的方法执行过程中无法感知到这一点,这也就是 Java 内存模型中描述的所谓的 “线程内部表现为串行的语义”。 + 也就是在单线程内部,我们看到的或者感知到的结果和代码顺序是一致的,即使代码的执行顺序和代码顺序不一致,但是在需要赋值的时候结果也是正确的,所以看起来就是串行的。但实际结果有可能代码的执行顺序和代码顺序是不一致的。这在多线程中就会出现问题。 + 看下面的伪代码举例: + +```java +Map configOptions; +char[] configText; + +volatile boolean initialized = false; + +configOptions = new HashMap(); +configText = readConfigFile(fileName); +processConfigOptions(configText, configOptions); +initialized = true; + +while ( !initialized) { + sleep(); +} +doSomethingWithConfig(); + +``` + +如果 initialiezd 是普通变量,没有被 volatile 修饰,那么线程 A 执行的代码的修改初始化完成的结果`initialized = true`就有可能先于之前的三行代码执行,而此时线程 B 发现 initialized 为 true 了,就执行`doSomethingWithConfig()`方法,但是里面的配置信息都是 null 的,就会出现问题了。 +现在 initialized 是 volatile 类型变量,保证禁止代码重排序优化,那么就可以保证`initialized = true`执行的时候,前边的三行代码一定执行完成了,那么线程 B 读取的配置文件信息就是正确的。 + +跟其他保证并发安全的工具相比,volatile 的性能确实会好一些。在某些情况下,volatile 的同步机制性能要优于锁 (使用 synchronized 关键字或者 Java.util.concurrent 包中的锁)。但是现在由于虚拟机对锁的不断优化和实行的许多消除动作,很难有一个量化的比较。 +与自己相比,就可以确定一个原则:volatile 变量的读操作和普通变量的读操作几乎没有差异,但是写操作会性能差一些,慢一些,因为要在本地代码中插入许多内存屏障指令来禁止指令重排序,保证处理器不发生代码乱序执行行为。 + +## long 和 double 变量的特殊规则 + +Java 内存模型要求对主内存和工作内存交换的八个动作是原子的,正如章节开头所讲,对 long 和 double 有一些特殊规则。八个动作中 lock、unlock、read、load、use、assign、store、write 对待 32 位的基本数据类型都是原子操作,对待 long 和 double 这两个 64 位的数据,Java 虚拟机规范对 Java 内存模型的规定中特别定义了一条相对宽松的规则:允许虚拟机将没有被 volatile 修饰的 64 位数据的读写操作划分为两次 32 位的操作来进行,也就是允许虚拟机不保证对 64 位数据的 read、load、store 和 write 这 4 个动作的操作是原子的。这也就是我们常说的 long 和 double 的非原子性协定 (Nonautomic Treatment of double and long Variables)。 + +## 并发内存模型的实质 + +Java 内存模型围绕着并发过程中如何处理原子性、可见性和顺序性这三个特征来设计的。 + +### 原子性 (Automicity) + +由 Java 内存模型来直接保证原子性的变量操作包括 read、load、use、assign、store、write 这 6 个动作,虽然存在 long 和 double 的特例,但基本可以忽律不计,目前虚拟机基本都对其实现了原子性。如果需要更大范围的控制,lock 和 unlock 也可以满足需求。lock 和 unlock 虽然没有被虚拟机直接开给用户使用,但是提供了字节码层次的指令 monitorenter 和 monitorexit 对应这两个操作,对应到 Java 代码就是 synchronized 关键字,因此在 synchronized 块之间的代码都具有原子性。 + +### 可见性 + +可见性是指一个线程修改了一个变量的值后,其他线程立即可以感知到这个值的修改。正如前面所说,volatile 类型的变量在修改后会立即同步给主内存,在使用的时候会从主内存重新读取,是依赖主内存为中介来保证多线程下变量对其他线程的可见性的。 +除了 volatile,synchronized 和 final 也可以实现可见性。synchronized 关键字是通过 unlock 之前必须把变量同步回主内存来实现的,final 则是在初始化后就不会更改,所以只要在初始化过程中没有把 this 指针传递出去也能保证对其他线程的可见性。 + +### 有序性 + +有序性从不同的角度来看是不同的。单纯单线程来看都是有序的,但到了多线程就会跟我们预想的不一样。可以这么说:如果在本线程内部观察,所有操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。前半句说的就是 “线程内表现为串行的语义”,后半句值得是“指令重排序” 现象和主内存与工作内存之间同步存在延迟的现象。 +保证有序性的关键字有 volatile 和 synchronized,volatile 禁止了指令重排序,而 synchronized 则由 “一个变量在同一时刻只能被一个线程对其进行 lock 操作” 来保证。 + +总体来看,synchronized 对三种特性都有支持,虽然简单,但是如果无控制的滥用对性能就会产生较大影响。 + +## 先行发生原则 + +如果 Java 内存模型中所有的有序性都要依靠 volatile 和 synchronized 来实现,那是不是非常繁琐。Java 语言中有一个 “先行发生原则”,是判断数据是否存在竞争、线程是否安全的主要依据。 + +### 什么是先行发生原则 + +先行发生原则是 Java 内存模型中定义的两个操作之间的偏序关系。比如说操作 A 先行发生于操作 B,那么在 B 操作发生之前,A 操作产生的 “影响” 都会被操作 B 感知到。这里的影响是指修改了内存中的共享变量、发送了消息、调用了方法等。个人觉得更直白一些就是有可能对操作 B 的结果有影响的都会被 B 感知到,对 B 操作的结果没有影响的是否感知到没有太大关系。 + +### Java 内存模型自带先行发生原则有哪些 + +* 程序次序原则 + 在一个线程内部,按照代码的顺序,书写在前面的先行发生与后边的。或者更准确的说是在控制流顺序前面的先行发生与控制流后面的,而不是代码顺序,因为会有分支、跳转、循环等。 +* 管程锁定规则 + 一个 unlock 操作先行发生于后面对同一个锁的 lock 操作。这里必须注意的是对同一个锁,后面是指时间上的后面 +* volatile 变量规则 + 对一个 volatile 变量的写操作先行发生与后面对这个变量的读操作,这里的后面是指时间上的先后顺序 +* 线程启动规则 + Thread 对象的 start() 方法先行发生与该线程的每个动作。当然如果你错误的使用了线程,创建线程后没有执行 start 方法,而是执行 run 方法,那此句话是不成立的,但是如果这样其实也不是线程了 +* 线程终止规则 + 线程中的所有操作都先行发生与对此线程的终止检测,可以通过 Thread.join() 和 Thread.isAlive() 的返回值等手段检测线程是否已经终止执行 +* 线程中断规则 + 对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过 Thread.interrupted() 方法检测到是否有中断发生。 +* 对象终结规则 + 一个对象的初始化完成先行发生于他的 finalize 方法的执行,也就是初始化方法先行发生于 finalize 方法 +* 传递性 + 如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,那么操作 A 先行发生于操作 C。 + +看一个例子: + +```java +private int value = 0; +public void setValue(int value) { + this.value = value; +} +public int getValue() { + return this.value; +} + + +``` + +如果有两个线程 A 和 B,A 先调用 setValue 方法,然后 B 调用 getValue 方法,那么 B 线程执行方法返回的结果是什么? +我们去对照先行发生原则一个一个对比。首先是**程序次序规则**,这里是多线程,不在一个线程中,不适用;然后是**管程锁定规则**,这里没有 synchronized,自然不会发生 lock 和 unlock,不适用;后面对于**线程启动规则**、**线程终止规则**、**线程中断规则**也不适用,这里与**对象终结规则**、**传递性规则**也没有关系。所以说 B 返回的结果是不确定的,也就是说在多线程环境下该操作不是线程安全的。 +如何修改呢,一个是对 get/set 方法加入 synchronized 关键字,可以使用**管程锁定规则**;要么对 value 加 volatile 修饰,可以使用 **volatile 变量规则**。 +通过上面的例子可知,一个操作时间上先发生并不代表这个操作先行发生,那么一个操作先行发生是不是代表这个操作在时间上先发生?也不是,如下面的例子: +在同一个线程内,对 i 的赋值先行发生于对 j 赋值的操作,但是代码重排序优化,也有可能是 j 的赋值先发生,我们无法感知到这一变化。 +所以,综上所述,时间先后顺序与先行发生原则之间基本没有太大关系。我们衡量并发安全的问题的时候不要受到时间先后顺序的干扰,一切以先行发生原则为准。 diff --git a/week_03/65/Semaphore.md b/week_03/65/Semaphore.md new file mode 100644 index 0000000..91d96fb --- /dev/null +++ b/week_03/65/Semaphore.md @@ -0,0 +1,233 @@ +# Semaphore + +## 简介 + +Semaphore 是计数信号量。Semaphore 管理一系列许可证。每个 acquire 方法阻塞,直到有一个许可证可以获得然后拿走一个许可证;每个 release 方法增加一个许可证,这可能会释放一个阻塞的 acquire 方法。然而,其实并没有实际的许可证这个对象,Semaphore 只是维持了一个可获得许可证的数量。 +Semaphore 经常用于限制获取某种资源的线程数量。实现 + +## 源码解析 + +Semaphore 有两种模式,公平模式和非公平模式。公平模式就是调用 acquire 的顺序就是获取许可证的顺序,遵循 FIFO;而非公平模式是抢占式的,也就是有可能一个新的获取线程恰好在一个许可证释放时得到了这个许可证,而前面还有等待的线程。 + +### 构造方法 + +Semaphore 有两个构造方法,如下: + +```java +public Semaphore(int permits) { + sync = new NonfairSync(permits); +} + +public Semaphore(int permits, boolean fair) { + sync = fair ? new FairSync(permits) : new NonfairSync(permits); +} + +``` + +从上面可以看到两个构造方法,都必须提供许可的数量,第二个构造方法可以指定是公平模式还是非公平模式,默认非公平模式。 +Semaphore 内部基于 AQS 的共享模式,所以实现都委托给了 Sync 类。 +这里就看一下 NonfairSync 的构造方法: + +```java + NonfairSync(int permits) { + super(permits); +} + +``` + +可以看到直接调用了父类的构造方法,Sync 的构造方法如下: + +```java +Sync(int permits) { + setState(permits); +} + +``` + +可以看到调用了 setState 方法,也就是说 AQS 中的资源就是许可证的数量。 + +### 获取许可 + +先从获取一个许可看起,并且先看非公平模式下的实现。首先看 acquire 方法,acquire 方法有几个重载,但主要是下面这个方法 + +```java +public void acquire(int permits) throws InterruptedException { + if (permits < 0) throw new IllegalArgumentException(); + sync.acquireSharedInterruptibly(permits); +} + +``` + +从上面可以看到,调用了 Sync 的 acquireSharedInterruptibly 方法,该方法在父类 AQS 中,如下: + +```java +public final void acquireSharedInterruptibly(int arg) + throws InterruptedException { + + if (Thread.interrupted()) + throw new InterruptedException(); + + if (tryAcquireShared(arg) < 0) + doAcquireSharedInterruptibly(arg); +} + +``` + +AQS 子类如果要使用共享模式的话,需要实现 tryAcquireShared 方法,下面看 NonfairSync 的该方法实现: + +```java + protected int tryAcquireShared(int acquires) { + return nonfairTryAcquireShared(acquires); +} + + +``` + +该方法调用了父类中的 nonfairTyAcquireShared 方法,如下: + +```java +final int nonfairTryAcquireShared(int acquires) { + for (;;) { + + int available = getState(); + int remaining = available - acquires; + if (remaining < 0 || + compareAndSetState(available, remaining)) + return remaining; + } +} + +``` + +从上面可以看到,只有在许可不够时返回值才会小于 0,其余返回的都是剩余许可数量,这也就解释了,一旦许可不够,后面的线程将会阻塞。看完了非公平的获取,再看下公平的获取,代码如下: + +```java + protected int tryAcquireShared(int acquires) { + for (;;) { + + if (hasQueuedPredecessors()) + return -1; + + int available = getState(); + int remaining = available - acquires; + if (remaining < 0 || + compareAndSetState(available, remaining)) + return remaining; + } +} + +``` + +从上面可以看到,FairSync 与 NonFairSync 的区别就在于会首先判断当前队列中有没有线程在等待,如果有,就老老实实进入到等待队列;而不像 NonfairSync 一样首先试一把,说不定就恰好获得了一个许可,这样就可以插队了。 +看完了获取许可后,再看一下释放许可。 + +### 释放许可 + +释放许可也有几个重载方法,但都会调用下面这个带参数的方法, + +```java +public void release(int permits) { + if (permits < 0) throw new IllegalArgumentException(); + sync.releaseShared(permits); +} + +``` + +releaseShared 方法在 AQS 中,如下: + +```java +public final boolean releaseShared(int arg) { + + if (tryReleaseShared(arg)) { + doReleaseShared(); + return true; + } + return false; +} + + +``` + +AQS 子类实现共享模式的类需要实现 tryReleaseShared 类来判断是否释放成功,实现如下: + +```java +protected final boolean tryReleaseShared(int releases) { + for (;;) { + + int current = getState(); + + int next = current + releases; + if (next < current) + throw new Error("Maximum permit count exceeded"); + + if (compareAndSetState(current, next)) + return true; + } +} + + +``` + +从上面可以看到,一旦 CAS 改变许可数量成功,那么就会调用 doReleaseShared() 方法释放阻塞的线程。 + +### 减小许可数量 + +Semaphore 还有减小许可数量的方法,该方法可以用于用于当资源用完不能再用时,这时就可以减小许可证。代码如下: + +```java +protected void reducePermits(int reduction) { + if (reduction < 0) throw new IllegalArgumentException(); + sync.reducePermits(reduction); +} + +``` + +可以看到,委托给了 Sync,Sync 的 reducePermits 方法如下: + +```java + final void reducePermits(int reductions) { + for (;;) { + + int current = getState(); + + int next = current - reductions; + if (next > current) + throw new Error("Permit count underflow"); + + if (compareAndSetState(current, next)) + return; + } +} + + +``` + +从上面可以看到,就是 CAS 改变 AQS 中的 state 变量,因为该变量代表许可证的数量。 + +### 获取剩余许可数量 + +Semaphore 还可以一次将剩余的许可数量全部取走,该方法是 drain 方法,如下: + +```java +public int drainPermits() { + return sync.drainPermits(); +} + +``` + +Sync 的实现如下: + +```java + final int drainPermits() { + for (;;) { + int current = getState(); + if (current == 0 || compareAndSetState(current, 0)) + return current; + } +} + +``` + +可以看到,就是 CAS 将许可数量置为 0。 + +Semaphore 是信号量,用于管理一组资源。其内部是基于 AQS 的共享模式,AQS 的状态表示许可证的数量,在许可证数量不够时,线程将会被挂起;而一旦有一个线程释放一个资源,那么就有可能重新唤醒等待队列中的线程继续执行。 diff --git "a/week_03/65/synchronized\345\216\237\347\220\206\345\217\212\351\224\201\344\274\230\345\214\226.md" "b/week_03/65/synchronized\345\216\237\347\220\206\345\217\212\351\224\201\344\274\230\345\214\226.md" new file mode 100644 index 0000000..004d45b --- /dev/null +++ "b/week_03/65/synchronized\345\216\237\347\220\206\345\217\212\351\224\201\344\274\230\345\214\226.md" @@ -0,0 +1,272 @@ +# synchronized原理及锁优化 + +线程安全,是 Java 并发编程中的重要关注点,应该注意到的是,造成线程安全问题的主要原因有两点: +1,存在共享数据(也称临界资源) +2,存在多条线程,共同操作共享数据。 + +## 应用方式 + +synchronized 是解决 Java 并发最常见的一种方法,也是最简单的一种方法。关键字 synchronized 可以保证在同一时刻,只有一个线程可以访问某个方法或者某个代码块。同时 synchronized 也可以保证一个线程的变化,被另一个线程看到(保证了可见性) +这里要注意:synchronized 是一个互斥的 重量级锁 (细节部分后续会讲) + +synchronized 的作用主要有三个: + +1. 确保线程互斥的访问代码 +2. 保证共享变量的修改能够及时可见(可见性) +3. 可以阻止 JVM 的指令重排序 + +在 Java 中所有对象都可以作为锁,这是 synchronized 实现同步的基础。 +synchronized 主要有三种应用方式: + +1. 普通同步方法,锁的是当前实例的对象 +2. 静态同步方法,锁的是静态方法所在的类对象 +3. 同步代码块,锁的是括号里的对象。(此处的可以是实例对象,也可以是类的 class 对象。) + +## 原理概要 + +Java 虚拟机中的同步(Synchronization)都是基于进入和退出 Monitor 对象实现,无论是显示同步(同步代码块)还是隐式同步(同步方法)都是如此。 + +* **同步代码块** + `monitorenter`指令插入到同步代码块的开始位置。`monitorexit`指令插入到同步代码块结束的位置。JVM 需要保证每一个`monitorenter`都有一个`monitorexit`与之对应。 + 任何对象,都有一个 monitor 与之相关联,当 monitor 被持有以后,它将处于锁定状态。线程执行到 monitorenter 指令时,会尝试获得 monitor 对象的所有权,即尝试获取锁。 + +> 虚拟机规范对 monitorenter 和 monitorexit 的行为描述中,有两点需要注意。首先 synchronized 同步快对于同一条线程来说是可重入的,也就是说,不会出现把自己锁死的问题。其次,同步快在已进入的线程执行完之前,会阻塞后面其他线程的进入。(摘自《深入理解 JAVA 虚拟机》) + +* **同步方法** + synchronized 方法则会被翻译成普通的方法调用和返回指令如: invokevirtual、areturn 指令,在 VM 字节码层面并没有任何特别的指令来实现被 synchronized 修饰的方法,而是在 Class 文件的方法表中将该方法的 access_flags 字段中的 synchronized 标志位置 1,表示该方法是同步方法并使用调用该方法的对象或该方法所属的 Class 在 JVM 的内部对象表示 Klass 做为锁对象。 + +## 原理详解 + +要理解低层实现,就需要理解两个重要的概念 **Monitor** 和 **Mark Word** + +* **Java 对象头** + +synchronized 用到的锁,是存储在对象头中的。(这也是 Java 所有对象都可以上锁的根本原因) +HotSpot 虚拟机中,对象头包括两部分信息: +Mark Word(对象头)和 Klass Pointer(类型指针) + +* 其中类型指针,是对象指向它的类元素的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。 +* 对象头又分为两部分:第一部分存储对象自身的运行时数据,例如哈希码,GC 分代年龄,线程持有的锁,偏向时间戳等。这一部分的长度是不固定的。第二部分是末尾两位,存储锁标志位,表示当前锁的级别。 + +对象头的长度一般占用两个机器码(32 位 JVM 中,一个机器码等于 4 个字节,也就是 32bit),但如果对象是数组类型,则需要三个机器码(多出的一块记录数组长度)。 + +**下图是对象头运行时的变化状态**: +**锁标志位** 和 **是否偏向锁** 确定唯一的锁状态 +其中 轻量锁 和 偏向锁 是 JDK1.6 之后新加的,用于对 synchronized 优化。 + +![](http://upload-images.jianshu.io/upload_images/1053629-f8f8480f0269681d.png) + +java 对象头 + +* **Monitor** + +Monitor 是 synchronized **重量级** 锁的实现关键。锁的标识位为 **10** 。当然 synchronized 作为一个重量锁是非常消耗性能的,所以在 JDK1.6 以后做了部分优化,接下来的部分是讲作为重量锁的实现。 + +Monitor 是线程私有的数据结构,每一个对象都有一个 monitor 与之关联。每一个线程都有一个可用 monitor record 列表(当前线程中所有对象的 monitor),同时还有一个全局可用列表(全局对象 monitor)。每一个被锁住的对象,都会和一个 monitor 关联。 + +当一个 monitor 被某个线程持有后,它便处于锁定状态。此时,对象头中 MarkWord 的 **指向互斥量的指针**,就是**指向锁对象的 monitor 起始地址**。 +monitor 是由 ObjectMonitor 实现的,其主要数据结构如下:(位于 HotSpot 虚拟机源码 ObjectMonitor.hpp 文件,C++ 实现的) + +```cpp +ObjectMonitor() { + _header = NULL; + _count = 0; + _waiters = 0, + _recursions = 0; + _object = NULL; + _owner = NULL; + _WaitSet = NULL; + _WaitSetLock = 0 ; + _Responsible = NULL ; + _succ = NULL ; + _cxq = NULL ; + FreeNext = NULL ; + _EntryList = NULL ; + _SpinFreq = 0 ; + _SpinClock = 0 ; + OwnerIsThread = 0 ; + } + + +``` + +object monitor 有两个队列 `_EntryList` 和 `_WaitSet` ,用来保存 ObjectWaiter 对象列表(每个等待锁的线程都会被封装成 ObjectWaiter 对象)`_owner` 指向持有 objectMonitor 的线程。 + +当多个线程同时访问一个同步代码时,首先会进入 `_EntryList` 集合,当线程获取到对象的 monitor 后,会进入_owner 区域,然后把 monitor 中的 `_owner` 变量修改为当前线程,同时 monitor 中的计数器`_count` 会加 1。 + +> 根据虚拟机规范的要求,在执行 monitorenter 指令时,会尝试获取对象的锁。如果对象没有被锁定(获取锁),获取对象已经被该线程锁定(锁重入)。则把计数器加 1(`_count` 加 1)。相应的,在执行 monitorexit 指令时,会讲计数器减 1。当计数器为 0 时,_owner 指向 Null,锁就被释放。(摘自《深入理解 JAVA 虚拟机》) + +如果线程调用 `wait()` 方法,将释放当前持有的 monitor,`_owner`变量恢复为 null,`_count`变量减 1,同时该线程进入`_WaitSet` 等待被唤醒。 + +## 底层实现 + +* synchronized 代码块低层原理 + +从 Javac 编译成的字节码可以看出(具体编译文件看参考链接),同步代码块使用的是`monitorenter`和`monitorexit`指令,其中`monitorenter`指向同步代码块的开始位置,`monitorexit`指向同步代码块的结束位置。 + +在线程执行到`monitorenter`指令时,当前线程将尝试获取锁,即尝试获取锁对象对应的 monitor 的持有权。当 monitor 的 count 计数器为 0,或者 monitor 的 owner 已经是该线程,则获取锁,count 计数器 + 1。 +如果其他线程已经持有该对象的锁,则该线程被阻塞,直到其他线程执行完毕释放锁。 + +线程执行完毕时,count 归零,owner 指向 Null,锁释放。 + +值得注意的是,编译器将会确保,无论通过何种方法完成,方法中的每一条`monitorenter`指令,最终都会有`monitorexit`指令对应,不论这个方法正常结束还是异常结束,最终都会配对执行。 +编译器会自动产生一个异常处理器,这个处理器声明可以处理所有的异常,它的目的就是为了确保`monitorexit`指令最终执行。 + +* synchronized 方法低层原理 + +方法级的同步是隐式,即无需通过字节码来控制的,它实现在方法调用和返回操作中。 +在 Class 文件方法常量池中的方法表结构(method_info Structure)中, **ACC_SYNCHRONIZED** 访问标志区分一个方法是否为同步方法。在方法被调用时,会检查方法的 ACC_SYNCHRONIZED 标记是否被设置。如果被设置了,则线程将持有该方法对应对象的 monitor(调用方法的实例对象 or 静态方法的类对象),然后再执行该方法。 +最后在方法执行完成时,释放 monitor。 +在方法执行期间,执行线程持有了 monitor,其他任何线程都无法再获得同一个 monitor。 +以下是字节码实现: + +```java +public class SyncMethod { + + public int i; + + public synchronized void syncTask(){ + i++; + } +} + +``` + +使用 javap 反编译后的字节码如下: + +```java +Classfile /Users/zejian/Downloads/Java8_Action/src/main/java/com/zejian/concurrencys/SyncMethod.class + Last modified 2017-6-2; size 308 bytes + MD5 checksum f34075a8c059ea65e4cc2fa610e0cd94 + Compiled from "SyncMethod.java" +public class com.zejian.concurrencys.SyncMethod + minor version: 0 + major version: 52 + flags: ACC_PUBLIC, ACC_SUPER +Constant pool; + + + public synchronized void syncTask(); + descriptor: ()V + flags: ACC_PUBLIC, ACC_SYNCHRONIZED + Code: + stack=3, locals=1, args_size=1 + 0: aload_0 + 1: dup + 2: getfield #2 + 5: iconst_1 + 6: iadd + 7: putfield #2 + 10: return + LineNumberTable: + line 12: 0 + line 13: 10 +} +SourceFile: "SyncMethod.java" + +``` + +从字节码可以看出,synchronized 修饰的方法并没有`monitorenter`和`monitorexit`指令。而是用`ACC_SYNCHRONIZED`的 flag 标记该方法是否是同步方法,从而执行相应的同步调用。 + +## 锁的状态和优化 + +在早期的 Java 版本中,synchronized 属于重量级锁,效率低下,因为监视器锁(Monitor)是依赖于低层的操作系统的 Mutex Lock 来实现的。 +而操作系统实现线程中的切换时,需要用用户态切换到核心态,这是一个非常重的操作,时间成本较高。这也是早期 synchronized 效率低下的原因。 + +JDK1.6 之后 JVM 官方对锁做了较大优化: +引入了: + +* 锁粗化(Lock Coarsening) +* 锁消除(Lock Elimination) +* 适应性自旋(Adaptive Spinning) + +同时增加了两种锁的状态: + +* 偏向锁(Biased Locking) +* 轻量锁(Lightweight Locking) + +### 锁的状态 + +锁的状态共有四种:无锁,偏向锁,轻量锁,重量锁。随着锁的竞争,锁会从偏向锁升级为轻量锁,然后升级为重量锁。**锁的升级是单向的**,JDK1.6 中默认开启偏向锁和轻量锁。 + +* **偏向锁** + +引入偏向锁的目的是:为了在无多线程竞争的情况下,尽量减少不必要的**轻量锁执行路径**。 +因为经过研究发现,在大部分情况下,锁并不存在多线程竞争,而且总是由一个线程多次获得锁。因此为了减少同一线程获取锁(会涉及到一些耗时的 CAS 操作)的代价而引入。 +如果一个线程获取到了锁,那么该锁就进入偏向锁模式,当这个线程再次请求锁时无需做任何同步操作,直接获取到锁。这样就省去了大量有关锁申请的操作,提升了程序性能。 + +**获取偏向锁**: + +1. 检查 Mark Word 是否为**可偏向状态**,即是否为偏向锁 = 1,锁标志位 = 01. +2. 若为可偏向状态,则检查 **线程 ID** 是否为当前对象头中的线程 ID,如果是,则获取锁,执行同步代码块。如果不是,进入第 3 步。 +3. 如果线程 ID 不是当前线程 ID,则通过 CAS 操作竞争锁,如果竞争成功。则将 Mark Word 中的线程 ID 替换为当前线程 ID,获取锁,执行同步代码块。如果没成功,进入第 4 步。 +4. 通过 CAS 竞争失败,则说明当前存在锁竞争。当执行到达全局安全点时,获得偏向锁的进程会被挂起,**偏向锁膨胀为轻量级锁**(重要),被阻塞在安全点的线程继续往下执行同步代码块。 + +**释放偏向锁**: +偏向锁的释放,采取了一种只有竞争才会释放锁的机制,线程不会主动去释放锁,需要等待其他线程来竞争。偏向锁的撤销需要等到全局安全点(这个时间点没有正在执行的代码),步骤如下: + +1. 暂停拥有偏向锁的线程,判断对象是否还处于被锁定的状态。 +2. 撤销偏向锁。恢复到无锁状态(01)或者 **膨胀为轻量级锁**。 + +![](http://upload-images.jianshu.io/upload_images/1053629-2acdc6a8cb6a3b77.png) + +偏向锁的获取和释放流程 + + +* **轻量级锁** + +轻量锁能够提升性能的依据,是基于如下假设:**即在真实情况下,程序中的大部分代码一般都处于一种无锁竞争的状态(即单线程环境)**,而在无锁竞争下完全可以避免调用操作系统层面的操作来实现重量锁。如果打破这个依据,除了互斥的开销外,还有额外的 CAS 操作,因此在有线程竞争的情况下,轻量锁比重量锁更慢。 +为了减少传统重量锁造成的性能不必要的消耗,才引入了轻量锁。 + +当关闭偏向锁功能 或者 多个线程竞争偏向锁导致升级为轻量锁,则会尝试获取轻量锁。 + +**获取轻量锁**: + +1. 判断当前对象是否处于无锁状态(偏向锁标记 = 0,无锁状态 = 01),如果是,则 JVM 会首先将当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储当前对象的 Mark Word 拷贝。(官方称为 Displaced Mark Word)。接下来执行第 2 步。如果对象处于有锁状态,则执行第 3 步 +2. JVM 利用 CAS 操作,尝试将对象的 Mark Word 更新为指向 Lock Record 的指针。如果成功,则表示竞争到锁。将锁标志位变为 00(表示此对象处于轻量级锁的状态),执行同步代码块。如果 CAS 操作失败,则执行第 3 步。 +3. 判断当前对象的 Mark Word 是否指向当前线程的栈帧,如果是,则表示当前线程已经持有当前对象的锁,直接执行同步代码块。否则,说明该锁对象已经被其他对象抢占,**此后为了不让线程阻塞,还会进入一个自旋锁的状态,如在一定的自旋周期内尝试重新获取锁,如果自旋失败,则轻量锁需要膨胀为重量锁(重点)**,锁标志位变为 10,后面等待的线程将会进入阻塞状态。 + +**释放轻量锁**: +轻量级锁的释放操作,也是通过 CAS 操作来执行的,步骤如下: + +1. 取出在获取轻量级锁时,存储在栈帧中的 Displaced Mard Word 数据。 +2. 用 CAS 操作,将取出的数据替换到对象的 Mark Word 中,如果成功,则说明释放锁成功,如果失败,则执行第 3 步。 +3. 如果 CAS 操作失败,说明有其他线程在尝试获取该锁,则要在释放锁的同时唤醒被挂起的线程。 + +![](http://upload-images.jianshu.io/upload_images/1053629-61b9a92344f768d6.png) + +轻量锁的获取和释放 + +* **重量级锁** + +重量级锁通过对象内部的监视器(Monitor)来实现,而其中 monitor 本质上是依赖于低层操作系统的 Mutex Lock 实现。 +操作系统实现线程切换,需要从用户态切换到内核态,切换成本非常高。 + +* **适应性自旋** + +在轻量级锁获取失败时,为了避免线程真实的在系统层面被挂起,还会进行一项称为自旋锁的优化手段。 + +这是基于以下假设: +大多数情况下,线程持有锁的时间不会太长,将线程挂起在系统层面耗费的成本较高。 +而 “适应性” 则表示,该自学的周期更加聪明。自旋的周期是不固定的,它是由上一次在同一个锁上的自旋时间 以及 锁拥有者的状态 共同决定。 + +具体方式是:如果自旋成功了,那么下次的自旋最大次数会更多,因为 JVM 认为既然上次成功了,那么这一次也有很大概率会成功,那么允许等待的最大自旋时间也相应增加。反之,如果对于某一个锁,很少有自旋成功的,那么就会相应的减少下次自旋时间,或者干脆放弃自旋,直接升级为重量锁,以免浪费系统资源。 + +有了适应性自旋,随着程序的运行信息不断完善,JVM 会对锁的状态预测更加精准,虚拟机会变得越来越聪明。 + +### 锁的优化 + +* **锁粗化** + +我们知道,在使用锁的时候,需要让同步的作用范围尽可能的小——仅在共享数据的操作中才进行。这样做的目的,是为了让同步操作的数量尽可能小,如果村子锁竞争,那么也能尽快的拿到锁。 +在大多数的情况下,上面的原则是正确的。 +但是如果存在一系列连续的 lock unlock 操作,也会导致性能的不必要消耗. +粗化锁就是将连续的同步操作连在一起,粗化为一个范围更大的锁。 +例如,对 Vector 的循环 add 操作,每次 add 都需要加锁,那么 JVM 会检测到这一系列操作,然后将锁移到循环外。 + +* **锁消除** + +锁消除是 JVM 进行的另外一项锁优化,该优化更彻底。 +JVM 在进行 JIT 编译时,通过对上下文的扫描,JVM 检测到不可能存在共享数据的竞争,如果这些资源有锁,那么会消除这些资源的锁。这样可以节省毫无意义的锁请求时间。 +虽然大部分程序员可以判断哪些操作是单线程的不必要加锁,但我们在使用 Java 的内置 API 时,部分操作会隐性的包含锁操作。例如 StringBuffer 的操作,HashTable 的操作。 +锁消除的依据,是逃逸分析的数据支持。 diff --git a/week_03/65/volatile.md b/week_03/65/volatile.md new file mode 100644 index 0000000..95122b7 --- /dev/null +++ b/week_03/65/volatile.md @@ -0,0 +1,261 @@ +# volatile 关键字 + +## volatile 的特性 + +volatile 是一个类型修饰符。volatile 的作用是作为指令关键字,确保本条指令不会因编译器的优化而省略。 + +* 保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。(实现可见性) +* 禁止进行指令重排序。(实现有序性) +* volatile 只能保证对单次读 / 写的原子性。i++ 这种操作不能保证原子性。 + +## volatile 的实现原理 + +### volatile 可见性实现 + +* volatile 变量的内存可见性是基于内存屏障(Memory Barrier)实现。 + + * 内存屏障,又称内存栅栏,是一个 CPU 指令。 + * 在程序运行时,为了提高执行性能,编译器和处理器会对指令进行重排序,JMM 为了保证在不同的编译器和 CPU 上有相同的结果,通过插入特定类型的内存屏障来禁止特定类型的编译器重排序和处理器重排序,插入一条内存屏障会告诉编译器和 CPU:不管什么指令都不能和这条 Memory Barrier 指令重排序。 + +* lock 前缀的指令在多核处理器下会引发两件事情。 + * 1)将当前处理器缓存行的数据写回到系统内存。 + * 2)写回内存的操作会使在其他 CPU 里缓存了该内存地址的额数据无效。 +* 为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的数据读到内部缓存(L1,L2 或其他)后再进行操作,但操作完不知道何时会写到内存。 +* 如果对声明了 volatile 的变量进行写操作,JVM 就会向处理器发送一条 lock 前缀的指令,将这个变量所在缓存行的数据写回到系统内存。 +* 为了保证各个处理器的缓存是一致的,实现了缓存一致性协议(MESI),每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。 +* 所有多核处理器下还会完成:当处理器发现本地缓存失效后,就会从内存中重读该变量数据,即可以获取当前最新值。 +* volatile 变量通过这样的机制就使得每个线程都能获得该变量的最新值。 + +#### lock 指令 + +* 在 Pentium 和早期的 IA-32 处理器中,lock 前缀会使处理器执行当前指令时产生一个 LOCK# 信号,会对总线进行锁定,其它 CPU 对内存的读写请求都会被阻塞,直到锁释放。 +* 后来的处理器,加锁操作是由高速缓存锁代替总线锁来处理。 + * 因为锁总线的开销比较大,锁总线期间其他 CPU 没法访问内存。 + * 这种场景多缓存的数据一致通过缓存一致性协议(MESI)来保证。 + +#### 缓存一致性 + +* 缓存是分段(line)的,一个段对应一块存储空间,称之为缓存行,它是 CPU 缓存中可分配的最小存储单元,大小 32 字节、64 字节、128 字节不等,这与 CPU 架构有关,通常来说是 64 字节。 +* LOCK# 因为锁总线效率太低,因此使用了多组缓存。 + * 为了使其行为看起来如同一组缓存那样。因而设计了 **缓存一致性协议**。 + * 缓存一致性协议有多种,但是日常处理的大多数计算机设备都属于 " **嗅探**(snooping)" 协议。 +* 所有内存的传输都发生在一条共享的总线上,而所有的处理器都能看到这条总线。 +* 缓存本身是独立的,但是内存是共享资源,所有的内存访问都要经过仲裁(同一个指令周期中,只有一个 CPU 缓存可以读写内存)。 +* CPU 缓存不仅仅在做内存传输的时候才与总线打交道,而是不停在嗅探总线上发生的数据交换,跟踪其他缓存在做什么。 +* 当一个缓存代表它所属的处理器去读写内存时,其它处理器都会得到通知,它们以此来使自己的缓存保持同步。 + * 只要某个处理器写内存,其它处理器马上知道这块内存在它们的缓存段中已经失效。 + +### volatile 有序性实现 + +#### volatile 的 happens-before 关系 + +* happens-before 规则中有一条是 **volatile 变量规则:对一个 volatile 域的写,happens-before 于任意后续对这个 volatile 域的读。** + +```java +class VolatileExample { + int a = 0; + volatile boolean flag = false; + + public void writer() { + a = 1; + flag = true; + } + + public void reader() { + if (flag) { + int i = a; + …… + } + } +} + +``` + +* 根据 happens-before 规则,上面过程会建立 3 类 happens-before 关系。 +* 根据程序次序规则:1 happens-before 2 且 3 happens-before 4。 +* 根据 volatile 规则:2 happens-before 3。 +* 根据 happens-before 的传递性规则:1 happens-before 4。 + +![](http://upload-images.jianshu.io/upload_images/5714666-04e61c5e5c4e91c3.png) + +* 因为以上规则,当线程 A 将 volatile 变量 flag 更改为 true 后,线程 B 能够迅速感知。 + +#### volatile 禁止重排序 + +* 为了性能优化,JMM 在不改变正确语义的前提下,会允许编译器和处理器对指令序列进行重排序。JMM 提供了内存屏障阻止这种重排序。 +* Java 编译器会在生成指令系列时在适当的位置会插入内存屏障指令来禁止特定类型的处理器重排序。 +* JMM 会针对编译器制定 volatile 重排序规则表。 + +![](http://upload-images.jianshu.io/upload_images/5714666-390c861ed043ef94.png) + +* "NO" 表示禁止重排序。 +* 为了实现 volatile 内存语义时,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。 +* 对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎是不可能的,为此,JMM 采取了保守的策略。 + * 在每个 volatile 写操作的前面插入一个 StoreStore 屏障。 + * 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障。 + * 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障。 + * 在每个 volatile 读操作的后面插入一个 LoadStore 屏障。 +* volatile 写是在前面和后面分别插入内存屏障,而 volatile 读操作是在后面插入两个内存屏障。 + +| 内存屏障 | 说明 | +| --- | --- | +| StoreStore 屏障 | 禁止上面的普通写和下面的 volatile 写重排序。 | +| StoreLoad 屏障 | 防止上面的 volatile 写与下面可能有的 volatile 读 / 写重排序。 | +| LoadLoad 屏障 | 禁止下面所有的普通读操作和上面的 volatile 读重排序。 | +| LoadStore 屏障 | 禁止下面所有的普通写操作和上面的 volatile 读重排序。 | + +![](http://upload-images.jianshu.io/upload_images/5714666-42b7250449160dc2.png) + +volatile 写插入内存屏障 + +![](http://upload-images.jianshu.io/upload_images/5714666-cce9ccf139acf1a4.png) + +volatile 读插入内存屏障 + +## volatile 的应用场景 + +* 使用 volatile 必须具备的条件 +* 对变量的写操作不依赖于当前值。 +* 该变量没有包含在具有其他变量的不变式中。 +* 只有在状态真正独立于程序内其他内容时才能使用 volatile。 + +* _模式 #1 状态标志_ + + * 也许实现 volatile 变量的规范使用仅仅是使用一个布尔状态标志,用于指示发生了一个重要的一次性事件,例如完成初始化或请求停机。 + +```java +volatile boolean shutdownRequested; +...... +public void shutdown() { shutdownRequested = true; } +public void doWork() { + while (!shutdownRequested) { + + } +} +``` + +* _模式 #2 一次性安全发布(one-time safe publication)_ + * 缺乏同步会导致无法实现可见性,这使得确定何时写入对象引用而不是原始值变得更加困难。在缺乏同步的情况下,可能会遇到某个对象引用的更新值(由另一个线程写入)和该对象状态的旧值同时存在。(这就是造成著名的双重检查锁定(double-checked-locking)问题的根源,其中对象引用在没有同步的情况下进行读操作,产生的问题是您可能会看到一个更新的引用,但是仍然会通过该引用看到不完全构造的对象)。 + +```java +public class BackgroundFloobleLoader { + public volatile Flooble theFlooble; + + public void initInBackground() { + + theFlooble = new Flooble(); + } +} + +public class SomeOtherClass { + public void doWork() { + while (true) { + + + if (floobleLoader.theFlooble != null) + doSomething(floobleLoader.theFlooble); + } + } +} +``` + +* _模式 #3:独立观察(independent observation)_ + * 安全使用 volatile 的另一种简单模式是定期 发布 观察结果供程序内部使用。例如,假设有一种环境传感器能够感觉环境温度。一个后台线程可能会每隔几秒读取一次该传感器,并更新包含当前文档的 volatile 变量。然后,其他线程可以读取这个变量,从而随时能够看到最新的温度值。 + +```java +public class UserManager { + public volatile String lastUser; + + public boolean authenticate(String user, String password) { + boolean valid = passwordIsValid(user, password); + if (valid) { + User u = new User(); + activeUsers.add(u); + lastUser = user; + } + return valid; + } +} +``` + +* _模式 #4 volatile bean 模式_ + * 在 volatile bean 模式中,JavaBean 的所有数据成员都是 volatile 类型的,并且 getter 和 setter 方法必须非常普通 —— 除了获取或设置相应的属性外,不能包含任何逻辑。此外,对于对象引用的数据成员,引用的对象必须是有效不可变的。(这将禁止具有数组值的属性,因为当数组引用被声明为 volatile 时,只有引用而不是数组本身具有 volatile 语义)。对于任何 volatile 变量,不变式或约束都不能包含 JavaBean 属性。 + +```java +@ThreadSafe +public class Person { + private volatile String firstName; + private volatile String lastName; + private volatile int age; + + public String getFirstName() { return firstName; } + public String getLastName() { return lastName; } + public int getAge() { return age; } + + public void setFirstName(String firstName) { + this.firstName = firstName; + } + + public void setLastName(String lastName) { + this.lastName = lastName; + } + + public void setAge(int age) { + this.age = age; + } +} +``` + +* _模式 #5 开销较低的读-写锁策略_ + * volatile 的功能还不足以实现计数器。因为 ++x 实际上是三种操作(读、添加、存储)的简单组合,如果多个线程凑巧试图同时对 volatile 计数器执行增量操作,那么它的更新值有可能会丢失。 + * 如果读操作远远超过写操作,可以结合使用内部锁和 volatile 变量来减少公共代码路径的开销。 + * 安全的计数器使用 synchronized 确保增量操作是原子的,并使用 volatile 保证当前结果的可见性。如果更新不频繁的话,该方法可实现更好的性能,因为读路径的开销仅仅涉及 volatile 读操作,这通常要优于一个无竞争的锁获取的开销。 + +```java +@ThreadSafe +public class CheesyCounter { + + + @GuardedBy("this") private volatile int value; + + public int getValue() { return value; } + + public synchronized int increment() { + return value++; + } +} +``` + +* _模式 #6 双重检查(double-checked)_ + + * 单例模式的一种实现方式,但很多人会忽略 volatile 关键字,因为没有该关键字,程序也可以很好的运行,只不过代码的稳定性总不是 100%,说不定在未来的某个时刻,隐藏的 bug 就出来了。 + +```java +class Singleton { + private volatile static Singleton instance; + public static Singleton getInstance() { + if (instance == null) { + syschronized(Singleton.class) { + if (instance == null) { + instance = new Singleton(); + } + } + } + return instance; + } +} +``` + +* 推荐懒加载优雅写法 Initialization on Demand Holder(IODH)。 + +```java +public class Singleton { + static class SingletonHolder { + static Singleton instance = new Singleton(); + } + + public static Singleton getInstance(){ + return SingletonHolder.instance; + } +} +``` diff --git "a/week_03/65/\345\210\206\345\270\203\345\274\217\351\224\201.md" "b/week_03/65/\345\210\206\345\270\203\345\274\217\351\224\201.md" new file mode 100644 index 0000000..d7f606c --- /dev/null +++ "b/week_03/65/\345\210\206\345\270\203\345\274\217\351\224\201.md" @@ -0,0 +1,241 @@ +# 分布式锁 + +## 1 为何需要分布式锁 + +Martin Kleppmann 是英国剑桥大学的分布式系统的研究员,之前和 Redis 之父 Antirez 进行过关于 RedLock(红锁,后续有讲到) 是否安全的激烈讨论。Martin 认为一般我们使用分布式锁有两个场景: + +* 效率: 使用分布式锁可以避免不同节点重复相同的工作,这些工作会浪费资源。比如用户付了钱之后有可能不同节点会发出多封短信。 +* 正确性: 加分布式锁同样可以避免破坏正确性的发生,如果两个节点在同一条数据上面操作,比如多个节点机器对同一个订单操作不同的流程有可能会导致该笔订单最后状态出现错误,造成损失。 + +### 1.1 分布式锁的一些特点 + +当我们确定了在不同节点上需要分布式锁,那么我们需要了解分布式锁到底应该有哪些特点: + +* 互斥性: 和我们本地锁一样互斥性是最基本,但是分布式锁需要保证在不同节点的不同线程的互斥。 +* 可重入性: 同一个节点上的同一个线程如果获取了锁之后那么也可以再次获取这个锁。 +* 锁超时: 和本地锁一样支持锁超时,防止死锁。 +* 高效,高可用: 加锁和解锁需要高效,同时也需要保证高可用防止分布式锁失效,可以增加降级。 +* 支持阻塞和非阻塞: 和 ReentrantLock 一样支持 lock 和 trylock 以及 tryLock(long timeOut)。 +* 支持公平锁和非公平锁 (可选): 公平锁的意思是按照请求加锁的顺序获得锁,非公平锁就相反是无序的。这个一般来说实现的比较少。 + +## 2 常见的分布式锁 + +我们了解了一些特点之后,我们一般实现分布式锁有以下几个方式: + +* MySql +* Zk +* Redis +* 自研分布式锁: 如谷歌的 Chubby。 + +下面分开介绍一下这些分布式锁的实现原理。 + +### 2.1 Mysql 分布式锁 + +首先来说一下 Mysql 分布式锁的实现原理,相对来说这个比较容易理解,毕竟数据库和我们开发人员在平时的开发中息息相关。对于分布式锁我们可以创建一个锁表: + +![](https://user-gold-cdn.xitu.io/2018/10/7/1664ec7d7339a8ef?imageView2/0/w/1280/h/960/format/webp/ignore-error/1) + +前面我们所说的 lock(),trylock(long timeout),trylock() 这几个方法可以用下面的伪代码实现。 + +#### 2.1.1 lock() + +lock 一般是阻塞式的获取锁,意思就是不获取到锁誓不罢休,那么我们可以写一个死循环来执行其操作: + +![](https://user-gold-cdn.xitu.io/2018/10/7/1664ed1b12d0012f?imageView2/0/w/1280/h/960/format/webp/ignore-error/1) + +mysqlLock.lcok 内部是一个 sql, 为了达到可重入锁的效果那么我们应该先进行查询,如果有值,那么需要比较 node_info 是否一致,这里的 node_info 可以用机器 IP 和线程名字来表示,如果一致那么就加可重入锁 count 的值,如果不一致那么就返回 false。如果没有值那么直接插入一条数据。伪代码如下: + +![](https://user-gold-cdn.xitu.io/2018/10/7/1664ed8f16d2277b?imageView2/0/w/1280/h/960/format/webp/ignore-error/1) + +需要注意的是这一段代码需要加事务,必须要保证这一系列操作的原子性。 + +#### 2.1.2 tryLock() 和 tryLock(long timeout) + +tryLock() 是非阻塞获取锁,如果获取不到那么就会马上返回,代码可以如下: + +![](https://user-gold-cdn.xitu.io/2018/10/7/1664edf0a83859bc?imageView2/0/w/1280/h/960/format/webp/ignore-error/1) + +tryLock(long timeout) 实现如下: + +![](https://user-gold-cdn.xitu.io/2018/10/7/1664ee2f0bf0a7ae?imageView2/0/w/1280/h/960/format/webp/ignore-error/1) + +mysqlLock.lock 和上面一样,但是要注意的是 select ... for update 这个是阻塞的获取行锁,如果同一个资源并发量较大还是有可能会退化成阻塞的获取锁。 + +#### 2.1.3 unlock() + +unlock 的话如果这里的 count 为 1 那么可以删除,如果大于 1 那么需要减去 1。 + +![](https://user-gold-cdn.xitu.io/2018/10/7/1664eea84d69a387?imageView2/0/w/1280/h/960/format/webp/ignore-error/1) + +#### 2.1.4 锁超时 + +我们有可能会遇到我们的机器节点挂了,那么这个锁就不会得到释放,我们可以启动一个定时任务,通过计算一般我们处理任务的一般的时间,比如是 5ms,那么我们可以稍微扩大一点,当这个锁超过 20ms 没有被释放我们就可以认定是节点挂了然后将其直接释放。 + +#### 2.1.5 Mysql 小结 + +* 适用场景: Mysql 分布式锁一般适用于资源不存在数据库,如果数据库存在比如订单,那么可以直接对这条数据加行锁,不需要我们上面多的繁琐的步骤,比如一个订单,那么我们可以用 select * from order_table where id = 'xxx' for update 进行加行锁,那么其他的事务就不能对其进行修改。 +* 优点: 理解起来简单,不需要维护额外的第三方中间件 (比如 Redis,Zk)。 +* 缺点: 虽然容易理解但是实现起来较为繁琐,需要自己考虑锁超时,加事务等等。性能局限于数据库,一般对比缓存来说性能较低。对于高并发的场景并不是很适合。 + +#### 2.1.6 乐观锁 + +前面我们介绍的都是悲观锁,这里想额外提一下乐观锁,在我们实际项目中也是经常实现乐观锁,因为我们加行锁的性能消耗比较大,通常我们会对于一些竞争不是那么激烈,但是其又需要保证我们并发的顺序执行使用乐观锁进行处理,我们可以对我们的表加一个版本号字段,那么我们查询出来一个版本号之后,update 或者 delete 的时候需要依赖我们查询出来的版本号,判断当前数据库和查询出来的版本号是否相等,如果相等那么就可以执行,如果不等那么就不能执行。这样的一个策略很像我们的 CAS(Compare And Swap), 比较并交换是一个原子操作。这样我们就能避免加 select * for update 行锁的开销。 + +### 2.2 ZooKeeper + +ZooKeeper 也是我们常见的实现分布式锁方法,相比于数据库如果没了解过 ZooKeeper 可能上手比较难一些。ZooKeeper 是以 Paxos 算法为基础分布式应用程序协调服务。Zk 的数据节点和文件目录类似,所以我们可以用此特性实现分布式锁。我们以某个资源为目录,然后这个目录下面的节点就是我们需要获取锁的客户端,未获取到锁的客户端注册需要注册 Watcher 到上一个客户端,可以用下图表示。 + +![](https://user-gold-cdn.xitu.io/2018/10/8/166513ddf9f2bd87?imageView2/0/w/1280/h/960/format/webp/ignore-error/1) + +/lock 是我们用于加锁的目录,/resource_name 是我们锁定的资源,其下面的节点按照我们加锁的顺序排列。 + +#### 2.2.1 Curator + +Curator 封装了 Zookeeper 底层的 Api,使我们更加容易方便的对 Zookeeper 进行操作,并且它封装了分布式锁的功能,这样我们就不需要再自己实现了。 + +Curator 实现了可重入锁 (InterProcessMutex), 也实现了不可重入锁 (InterProcessSemaphoreMutex)。在可重入锁中还实现了读写锁。 + +#### 2.2.2 InterProcessMutex + +InterProcessMutex 是 Curator 实现的可重入锁,我们可以通过下面的一段代码实现我们的可重入锁: + +![](https://user-gold-cdn.xitu.io/2018/10/8/166514a7db01ca4a?imageView2/0/w/1280/h/960/format/webp/ignore-error/1) + +我们利用 acuire 进行加锁,release 进行解锁。 + +加锁的流程具体如下: + +1. 首先进行可重入的判定: 这里的可重入锁记录在 ConcurrentMap threadData 这个 Map 里面,如果 threadData.get(currentThread) 是有值的那么就证明是可重入锁,然后记录就会加 1。我们之前的 Mysql 其实也可以通过这种方法去优化,可以不需要 count 字段的值,将这个维护在本地可以提高性能。 +2. 然后在我们的资源目录下创建一个节点: 比如这里创建一个 / 0000000002 这个节点,这个节点需要设置为 EPHEMERAL_SEQUENTIAL 也就是临时节点并且有序。 +3. 获取当前目录下所有子节点,判断自己的节点是否位于子节点第一个。 +4. 如果是第一个,则获取到锁,那么可以返回。 +5. 如果不是第一个,则证明前面已经有人获取到锁了,那么需要获取自己节点的前一个节点。/0000000002 的前一个节点是 / 0000000001,我们获取到这个节点之后,再上面注册 Watcher(这里的 watcher 其实调用的是 object.notifyAll(), 用来解除阻塞)。 +6. object.wait(timeout) 或 object.wait(): 进行阻塞等待这里和我们第 5 步的 watcher 相对应。 + +解锁的具体流程: + +1. 首先进行可重入锁的判定: 如果有可重入锁只需要次数减 1 即可,减 1 之后加锁次数为 0 的话继续下面步骤,不为 0 直接返回。 +2. 删除当前节点。 +3. 删除 threadDataMap 里面的可重入锁的数据。 + +#### 2.2.3 读写锁 + +Curator 提供了读写锁,其实现类是 InterProcessReadWriteLock,这里的每个节点都会加上前缀: + +```java +private static final String READ_LOCK_NAME = "__READ__"; +private static final String WRITE_LOCK_NAME = "__WRIT__"; +复制代码 + +``` + +根据不同的前缀区分是读锁还是写锁,对于读锁,如果发现前面有写锁,那么需要将 watcher 注册到和自己最近的写锁。写锁的逻辑和我们之前 4.2 分析的依然保持不变。 + +#### 2.2.4 锁超时 + +Zookeeper 不需要配置锁超时,由于我们设置节点是临时节点,我们的每个机器维护着一个 ZK 的 session,通过这个 session,ZK 可以判断机器是否宕机。如果我们的机器挂掉的话,那么这个临时节点对应的就会被删除,所以我们不需要关心锁超时。 + +#### 2.2.5 ZK小结 + +* 优点: ZK 可以不需要关心锁超时时间,实现起来有现成的第三方包,比较方便,并且支持读写锁,ZK 获取锁会按照加锁的顺序,所以其是公平锁。对于高可用利用 ZK 集群进行保证。 +* 缺点: ZK 需要额外维护,增加维护成本,性能和 Mysql 相差不大,依然比较差。并且需要开发人员了解 ZK 是什么。 + +### 2.3 Redis + +大家在网上搜索分布式锁,恐怕最多的实现就是 Redis 了,Redis 因为其性能好,实现起来简单所以让很多人都对其十分青睐。 + +#### 2.3.1 Redis 分布式锁简单实现 + +熟悉 Redis 的同学那么肯定对 setNx(set if not exist) 方法不陌生,如果不存在则更新,其可以很好的用来实现我们的分布式锁。对于某个资源加锁我们只需要 + +```redis +setNx resourceName value + +``` + +这里有个问题,加锁了之后如果机器宕机那么这个锁就不会得到释放所以会加入过期时间,加入过期时间需要和 setNx 同一个原子操作,在 Redis2.8 之前我们需要使用 Lua 脚本达到我们的目的,但是 redis2.8 之后 redis 支持 nx 和 ex 操作是同一原子操作。 + +```redis +set resourceName value ex 5 nx +复制代码 + +``` + +#### 2.3.2 Redission + +Javaer 都知道 Jedis,Jedis 是 Redis 的 Java 实现的客户端,其 API 提供了比较全面的 Redis 命令的支持。Redission 也是 Redis 的客户端,相比于 Jedis 功能简单。Jedis 简单使用阻塞的 I/O 和 redis 交互,Redission 通过 Netty 支持非阻塞 I/O。Jedis 最新版本 2.9.0 是 2016 年的快 3 年了没有更新,而 Redission 最新版本是 2018.10 月更新。 + +Redission 封装了锁的实现,其继承了 java.util.concurrent.locks.Lock 的接口,让我们像操作我们的本地 Lock 一样去操作 Redission 的 Lock,下面介绍一下其如何实现分布式锁。 + +![](https://user-gold-cdn.xitu.io/2018/10/8/16652989862ce5af?imageView2/0/w/1280/h/960/format/webp/ignore-error/1) + +Redission 不仅提供了 Java 自带的一些方法 (lock,tryLock),还提供了异步加锁,对于异步编程更加方便。 由于内部源码较多,就不贴源码了,这里用文字叙述来分析他是如何加锁的,这里分析一下 tryLock 方法: + +1. 尝试加锁: 首先会尝试进行加锁,由于保证操作是原子性,那么就只能使用 lua 脚本,相关的 lua 脚本如下: +2. ![](https://user-gold-cdn.xitu.io/2018/10/8/166529eb8139751a?imageView2/0/w/1280/h/960/format/webp/ignore-error/1) + 可以看见他并没有使用我们的 sexNx 来进行操作,而是使用的 hash 结构,我们的每一个需要锁定的资源都可以看做是一个 HashMap,锁定资源的节点信息是 Key, 锁定次数是 value。通过这种方式可以很好的实现可重入的效果,只需要对 value 进行加 1 操作,就能进行可重入锁。当然这里也可以用之前我们说的本地计数进行优化。 +3. 如果尝试加锁失败,判断是否超时,如果超时则返回 false。 +4. 如果加锁失败之后,没有超时,那么需要在名字为 redisson_lock__channel+lockName 的 channel 上进行订阅,用于订阅解锁消息,然后一直阻塞直到超时,或者有解锁消息。 +5. 重试步骤 1,2,3,直到最后获取到锁,或者某一步获取锁超时。 + +对于我们的 unlock 方法比较简单也是通过 lua 脚本进行解锁,如果是可重入锁,只是减 1。如果是非加锁线程解锁,那么解锁失败。 + +![](https://user-gold-cdn.xitu.io/2018/10/8/16652acd8c664482?imageView2/0/w/1280/h/960/format/webp/ignore-error/1) + +Redission 还有公平锁的实现,对于公平锁其利用了 list 结构和 hashset 结构分别用来保存我们排队的节点,和我们节点的过期时间,用这两个数据结构帮助我们实现公平锁,这里就不展开介绍了,有兴趣可以参考源码。 + +#### 2.3.3 RedLock + +我们想象一个这样的场景当机器 A 申请到一把锁之后,如果 Redis 主宕机了,这个时候从机并没有同步到这一把锁,那么机器 B 再次申请的时候就会再次申请到这把锁,为了解决这个问题 Redis 作者提出了 RedLock 红锁的算法, 在 Redission 中也对 RedLock 进行了实现。 + +![](https://user-gold-cdn.xitu.io/2018/10/8/16652bd95e11a8b3?imageView2/0/w/1280/h/960/format/webp/ignore-error/1) + +通过上面的代码,我们需要实现多个 Redis 集群,然后进行红锁的加锁,解锁。具体的步骤如下: + +1. 首先生成多个 Redis 集群的 Rlock,并将其构造成 RedLock。 +2. 依次循环对三个集群进行加锁,加锁的过程和 5.2 里面一致。 +3. 如果循环加锁的过程中加锁失败,那么需要判断加锁失败的次数是否超出了最大值,这里的最大值是根据集群的个数,比如三个那么只允许失败一个,五个的话只允许失败两个,要保证多数成功。 +4. 加锁的过程中需要判断是否加锁超时,有可能我们设置加锁只能用 3ms,第一个集群加锁已经消耗了 3ms 了。那么也算加锁失败。 +5. 3,4 步里面加锁失败的话,那么就会进行解锁操作,解锁会对所有的集群在请求一次解锁。 + +可以看见 RedLock 基本原理是利用多个 Redis 集群,用多数的集群加锁成功,减少 Redis 某个集群出故障,造成分布式锁出现问题的概率。 + +#### 2.3.4 Redis 小结 + +* 优点: 对于 Redis 实现简单,性能对比 ZK 和 Mysql 较好。如果不需要特别复杂的要求,那么自己就可以利用 setNx 进行实现,如果自己需要复杂的需求的话那么可以利用或者借鉴 Redission。对于一些要求比较严格的场景来说的话可以使用 RedLock。 +* 缺点: 需要维护 Redis 集群,如果要实现 RedLock 那么需要维护更多的集群。 + +## 3 分布式锁的安全问题 + +上面我们介绍过红锁,但是 Martin Kleppmann 认为其依然不安全。有关于 Martin 反驳的几点,我认为其实不仅仅局限于 RedLock, 前面说的算法基本都有这个问题,下面我们来讨论一下这些问题: + +* 长时间的 GC pause: 熟悉 Java 的同学肯定对 GC 不陌生,在 GC 的时候会发生 STW(stop-the-world), 例如 CMS 垃圾回收器,他会有两个阶段进行 STW 防止引用继续进行变化。那么有可能会出现下面图 (引用至 Martin 反驳 Redlock 的文章) 中这个情况: + ![](https://user-gold-cdn.xitu.io/2018/10/8/16653450d45a2a96?imageView2/0/w/1280/h/960/format/webp/ignore-error/1) + client1 获取了锁并且设置了锁的超时时间,但是 client1 之后出现了 STW,这个 STW 时间比较长,导致分布式锁进行了释放,client2 获取到了锁,这个时候 client1 恢复了锁,那么就会出现 client1,2 同时获取到锁,这个时候分布式锁不安全问题就出现了。这个其实不仅仅局限于 RedLock, 对于我们的 ZK,Mysql 一样的有同样的问题。 +* 时钟发生跳跃: 对于 Redis 服务器如果其时间发生了向跳跃,那么肯定会影响我们锁的过期时间,那么我们的锁过期时间就不是我们预期的了,也会出现 client1 和 client2 获取到同一把锁,那么也会出现不安全,这个对于 Mysql 也会出现。但是 ZK 由于没有设置过期时间,那么发生跳跃也不会受影响。 +* 长时间的网络 I/O: 这个问题和我们的 GC 的 STW 很像,也就是我们这个获取了锁之后我们进行网络调用,其调用时间由可能比我们锁的过期时间都还长,那么也会出现不安全的问题,这个 Mysql 也会有,ZK 也不会出现这个问题。 + +对于这三个问题,在网上包括 Redis 作者在内发起了很多讨论。 + +### 3.1 GC 的 STW + +对于这个问题可以看见基本所有的都会出现问题,Martin 给出了一个解法,对于 ZK 这种他会生成一个自增的序列,那么我们真正进行对资源操作的时候,需要判断当前序列是否是最新,有点类似于我们乐观锁。当然这个解法 Redis 作者进行了反驳,你既然都能生成一个自增的序列了那么你完全不需要加锁了,也就是可以按照类似于 Mysql 乐观锁的解法去做。 + +我自己认为这种解法增加了复杂性,当我们对资源操作的时候需要增加判断序列号是否是最新,无论用什么判断方法都会增加复杂度,后面会介绍谷歌的 Chubby 提出了一个更好的方案。 + +### 3.2 时钟发生跳跃 + +Martin 觉得 RedLock 不安全很大的原因也是因为时钟的跳跃,因为锁过期强依赖于时间,但是 ZK 不需要依赖时间,依赖每个节点的 Session。Redis 作者也给出了解答: 对于时间跳跃分为人为调整和 NTP 自动调整。 + +* 人为调整: 人为调整影响的那么完全可以人为不调整,这个是处于可控的。 +* NTP 自动调整: 这个可以通过一定的优化,把跳跃时间控制的可控范围内,虽然会跳跃,但是是完全可以接受的。 + +### 3.3 长时间的网络 I/O + +## 7 Chubby 的一些优化 + +大家搜索 ZK 的时候,会发现他们都写了 ZK 是 Chubby 的开源实现,Chubby 内部工作原理和 ZK 类似。但是 Chubby 的定位是分布式锁和 ZK 有点不同。Chubby 也是使用上面自增序列的方案用来解决分布式不安全的问题,但是他提供了多种校验方法: + +* CheckSequencer():调用 Chubby 的 API 检查此时这个序列号是否有效。 +* 访问资源服务器检查,判断当前资源服务器最新的序列号和我们的序列号的大小。 +* lock-delay: 为了防止我们校验的逻辑入侵我们的资源服务器,其提供了一种方法当客户端失联的时候,并不会立即释放锁,而是在一定的时间内 (默认 1min) 阻止其他客户端拿去这个锁,那么也就是给予了一定的 buffer 等待 STW 恢复,而我们的 GC 的 STW 时间如果比 1min 还长那么你应该检查你的程序,而不是怀疑你的分布式锁了。 -- Gitee