From 71cd256d00af730bd24b8f5397af7c7aecdedcf3 Mon Sep 17 00:00:00 2001 From: FIve <5332795+qylningque@user.noreply.gitee.com> Date: Sun, 29 Dec 2019 16:59:37 +0800 Subject: [PATCH] 060-week 03 --- week_03/60/AQS-060.md | 561 ++++++++++++++++++ week_03/60/ReentrantLock-060.md | 166 ++++++ week_03/60/Semaphore-060.md | 171 ++++++ week_03/60/sychronized-060.md | 506 ++++++++++++++++ week_03/60/volatile-060.md | 111 ++++ ...45\255\230\346\250\241\345\236\213-060.md" | 239 ++++++++ ...45\270\203\345\274\217\351\224\201-060.md" | 48 ++ 7 files changed, 1802 insertions(+) create mode 100644 week_03/60/AQS-060.md create mode 100644 week_03/60/ReentrantLock-060.md create mode 100644 week_03/60/Semaphore-060.md create mode 100644 week_03/60/sychronized-060.md create mode 100644 week_03/60/volatile-060.md create mode 100644 "week_03/60/\345\206\205\345\255\230\346\250\241\345\236\213-060.md" create mode 100644 "week_03/60/\345\210\206\345\270\203\345\274\217\351\224\201-060.md" diff --git a/week_03/60/AQS-060.md b/week_03/60/AQS-060.md new file mode 100644 index 0000000..4351355 --- /dev/null +++ b/week_03/60/AQS-060.md @@ -0,0 +1,561 @@ +```java +import java.util.concurrent.locks.AbstractQueuedSynchronizer; +``` + +AQS 全称`AbstractQueuedSynchronizer`,即抽象的队列同步器,是一种用来构建锁和同步器的框架。 + +**基于 AQS 构建的同步器:** + +- `ReentrantLock` +- `Semaphore` +- `CountDownLatch` +- `ReentrantReadWriteLock` +- `SynchronusQueue` +- `FutureTask` + +**优势:** + +- AQS 解决了在实现同步器时涉及的大量细节问题,例如自定义标准同步状态、FIFO 同步队列。 +- 基于 AQS 来构建同步器可以带来很多好处。它不仅能够极大地减少实现工作,而且也不必处理在多个位置上发生的竞争问题。 + +# 一、AQS 基础 + +## 1.1 AQS 核心思想 + +如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制 AQS 是用 `CLH` 队列锁实现的,即将暂时获取不到锁的线程加入到队列中。如图所示: + +![img](https://user-gold-cdn.xitu.io/2019/11/13/16e653ec86038010?imageView2/0/w/1280/h/960/format/webp/ignore-error/1) + + + +**`Sync queue`:** 同步队列,是一个双向链表。包括 head 节点和 tail 节点。head 节点主要用作后续的调度。 + +![img](https://user-gold-cdn.xitu.io/2019/11/13/16e655b2ae715f93?imageView2/0/w/1280/h/960/format/webp/ignore-error/1) + + + +**`Condition queue`:**非必须,单向链表。当程序中存在 cindition 的时候才会存在此列表。 + +![img](https://user-gold-cdn.xitu.io/2019/11/13/16e655fe5ff3bad2?imageView2/0/w/1280/h/960/format/webp/ignore-error/1) + +## 1.2 AQS 设计思想 + +- AQS 使用一个 int 成员变量来表示同步状态 +- 使用 Node 实现 FIFO 队列,可以用于构建锁或者其他同步装置 +- AQS 资源共享方式:独占 Exclusive(排它锁模式)和共享 Share(共享锁模式) + +> AQS 它的所有子类中,要么实现并使用了它的独占功能的 api,要么使用了共享锁的功能,而不会同时使用两套 api,即便是最有名的子类 ReentrantReadWriteLock 也是通过两个内部类读锁和写锁分别实现了两套 api 来实现的 + +## 1.3 state 状态 + +state 状态使用 volatile int 类型的变量,表示当前同步状态。state 的访问方式有三种: + +- `getState()` +- `setState()` +- `compareAndSetState()` + +```java + /** + * 返回同步状态的当前值。 + * 此操作具有{@code volatile}读取的内存语义。 + * @return 当前状态值 + */ + protected final int getState() { + return state; + } + + /** + * 设置同步状态的值。 + * 此操作具有{@code volatile}写操作的内存语义。 + * @param newState 新状态值 + */ + protected final void setState(int newState) { + state = newState; + } + + /** + * 如果当前状态值等于期望值,则以原子方式将同步状态设置为给定的更新值。 + * 此操作具有{@code volatile}读取和写入的内存语义。 + * @param expect 期望值 + * @param update 新值 + * @return {@code true}如果成功。错误的返回值表示实际值不等于期望值。 + */ + protected final boolean compareAndSetState(int expect, int update) { + // See below for intrinsics setup to support this + return unsafe.compareAndSwapInt(this, stateOffset, expect, update); + } +``` + +## 1.4 AQS 中 Node 常量含义 + +```java + /** waitStatus 值为 1 时表示该线程节点已释放(超时、中断),已取消的节点不会再阻塞。 */ + static final int CANCELLED = 1; + /** waitStatus 为 - 1 时表示该线程的后续线程需要阻塞,即只要前置节点释放锁, + *就会通知标识为 SIGNAL 状态的后续节点的线程 + */ + static final int SIGNAL = -1; + /** waitStatus 为 - 2 时,表示该线程在 condition 队列中阻塞(Condition 有使用) */ + static final int CONDITION = -2; + /** + * waitStatus 为 - 3 时,表示该线程以及后续线程进行无条件传播(CountDownLatch 中有使用)共享模式下, + * PROPAGATE 状态的线程处于可运行状态 + */ + static final int PROPAGATE = -3; +``` + +## 1.5 同步队列为什么称为 FIFO 呢? + +因为只有前驱节点是 head 节点的节点才能被首先唤醒去进行同步状态的获取。当该节点获取到同步状态时,它会清除自己的值,将自己作为 head 节点,以便唤醒下一个节点。 + +## 1.6 Condition 队列 + +除了同步队列之外,AQS 中还存在 Condition 队列,这是一个单向队列。调用 ConditionObject.await() 方法,能够将当前线程封装成 Node 加入到 Condition 队列的末尾,然后将获取的同步状态释放(即修改同步状态的值,唤醒在同步队列中的线程)。 + +> Condition 队列也是 FIFO。调用 ConditionObject.signal() 方法,能够唤醒 firstWaiter 节点,将其添加到同步队列末尾。 + +## 1.7 7 自定义同步器的实现 + +在构建自定义同步器时,只需要依赖 AQS 底层再实现共享资源 state 的获取与释放操作即可。自定义同步器实现时主要实现以下几种方法: + +```java + // 该线程是否正在独占资源。只有用到 condition 才需要去实现它。 + protected boolean isHeldExclusively() { + throw new UnsupportedOperationException(); + } +``` + +```java + // 独占方式。尝试获取资源,成功则返回 true,失败则返回 false。 + protected boolean tryAcquire(int arg) { + throw new UnsupportedOperationException(); + } +``` + +```java + // 独占方式。尝试释放资源,成功则返回 true,失败则返回 false。 + protected boolean tryRelease(int arg) { + throw new UnsupportedOperationException(); + } +``` + +```java + // 共享方式。尝试获取资源。负数表示失败;0 表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。 + protected int tryAcquireShared(int arg) { + throw new UnsupportedOperationException(); + } +``` + +```java + // 共享方式。尝试释放资源,如果释放后允许唤醒后续等待结点返回 true,否则返回 false。 + protected boolean tryReleaseShared(int arg) { + throw new UnsupportedOperationException(); + } +``` + +# 二、AQS 实现细节 + +线程首先尝试获取锁,如果失败就将当前线程及等待状态等信息包装成一个 node 节点加入到 FIFO 队列中。 接着会不断的循环尝试获取锁,条件是当前节点为 head 的直接后继才会尝试。如果失败就会阻塞自己直到自己被唤醒。而当持有锁的线程释放锁的时候,会唤醒队列中的后继线程。 + +## 2.1 独占模式下的 AQS + +**独占模式**,即只允许一个线程获取同步状态,当这个线程还没有释放同步状态时,其他线程是获取不了的,只能加入到同步队列,进行等待。 + +- 将 state 的初始值设为 0,表示空闲。当一个线程获取到同步状态时,利用 CAS 操作让 state 加 1,表示非空闲,那么其他线程就只能等待了。 +- 释放同步状态时,不需要 CAS 操作,因为独占模式下只有一个线程能获取到同步状态。 +- `ReentrantLock`、`CyclicBarrier` 正是基于此设计的。 + +例如,`ReentrantLock`,state 初始化为 0,表示未锁定状态。A 线程 lock() 时,会调用 `tryAcquire()` 独占该锁并将 `state+1`。 + +![img](https://user-gold-cdn.xitu.io/2019/11/14/16e658338fbbf2bf?imageView2/0/w/1280/h/960/format/webp/ignore-error/1) + +**独占模式下的 AQS 是不响应中断的**,指的是加入到同步队列中的线程,如果因为中断而被唤醒的话,不会立即返回,并且抛出 `InterruptedException`。而是再次去判断其前驱节点是否为 head 节点,决定是否争抢同步状态。如果其前驱节点不是 head 节点或者争抢同步状态失败,那么再次挂起。 + +### 2.1.1 独占模式获取资源 - acquire 方法 + +acquire 以独占 exclusive 方式获取资源。如果获取到资源,线程直接返回,否则进入等待队列,直到获取到资源为止,**且整个过程忽略中断的影响**。 + +```java + public final void acquire(int arg) { + if (!tryAcquire(arg) && + acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) + selfInterrupt(); + } +``` + +**流程图:** + +![img](https://user-gold-cdn.xitu.io/2019/11/14/16e65b5c00dccb3d?imageView2/0/w/1280/h/960/format/webp/ignore-error/1) + +- 调用自定义同步器的`tryAcquire()`尝试直接去获取资源,如果成功则直接返回; +- 没成功,则`addWaiter()`将该线程加入等待队列的尾部,并标记为独占模式; +- `acquireQueued()`使线程在等待队列中休息,有机会时(轮到自己,会被`unpark()`)会去尝试获取资源。获取到资源后才返回。如果在整个等待过程中被中断过,则返回 true,否则返回 false。 +- 如果线程在等待过程中被中断过,它是不响应的。只是获取资源后才再进行自我中断`selfInterrupt()`,将中断补上。 + +### 2.1.2 独占模式获取资源 - tryAcquire 方法 + +`tryAcquire`尝试以独占的方式获取资源,如果获取成功,则直接返回`true`,否则直接返回`false`,且具体实现由自定义 AQS 的同步器实现的。 + +```java + protected boolean tryAcquire(int arg) { + throw new UnsupportedOperationException(); + } +``` + +### 2.1.3 独占模式获取资源 - addWaiter 方法 + +根据不同模式 (`Node.EXCLUSIVE`互斥模式、`Node.SHARED`共享模式) 创建结点并以 CAS 的方式将当前线程节点加入到不为空的等待队列的末尾 (通过`compareAndSetTail()`方法)。如果队列为空,通过`enq(node)`方法初始化一个等待队列,并返回当前节点。 + +```java +/** +* 参数 +* @param mode Node.EXCLUSIVE for exclusive, Node.SHARED for shared +* 返回值 +* @return the new node +*/ +private Node addWaiter(Node mode) { + //将当前线程以指定的模式创建节点node + Node node = new Node(Thread.currentThread(), mode); + // Try the fast path of enq; backup to full enq on failure + // 获取当前同队列的尾节点 + Node pred = tail; + //队列不为空,将新的node加入等待队列中 + if (pred != null) { + node.prev = pred; + //CAS方式将当前节点尾插入队列中 + if (compareAndSetTail(pred, node)) { + pred.next = node; + return node; + } + } + //当队列为empty或者CAS失败时会调用enq方法处理 + enq(node); + return node; +} +``` + +其中,队列为 empty,使用`enq(node)`处理,将当前节点插入等待队列,如果队列为空,则初始化当前队列。所有操作都是 CAS 自旋的方式进行,直到成功加入队尾为止。 + +```java + private Node enq(final Node node) { + //不断自旋 + for (;;) { + Node t = tail; + //当前队列为empty + if (t == null) { // Must initialize + //完成队列初始化操作,头结点中不放数据,只是作为起始标记,lazy-load,在第一次用的时候new + if (compareAndSetHead(new Node())) + tail = head; + } else { + node.prev = t; + //不断将当前节点使用CAS尾插入队列中直到成功为止 + if (compareAndSetTail(t, node)) { + t.next = node; + return t; + } + } + } + } +``` + +### 2.1.4 独占模式获取资源 - acquireQueued 方法 + +`acquireQueued`用于已在队列中的线程以独占且不间断模式获取 state 状态,直到获取锁后返回。主要流程: + +- 结点 node 进入队列尾部后,检查状态; +- 调用 park() 进入 waiting 状态,等待 unpark() 或 interrupt() 唤醒; +- 被唤醒后,是否获取到锁。如果获取到,head 指向当前结点,并返回从入队到获取锁的整个过程中是否被中断过;如果没获取到,继续流程 1 + +```java +final boolean acquireQueued(final Node node, int arg) { + //是否已获取锁的标志,默认为true 即为尚未 + boolean failed = true; + try { + //等待中是否被中断过的标记 + boolean interrupted = false; + for (;;) { + //获取前节点 + final Node p = node.predecessor(); + //如果当前节点已经成为头结点,尝试获取锁(tryAcquire)成功,然后返回 + if (p == head && tryAcquire(arg)) { + setHead(node); + p.next = null; // help GC + failed = false; + return interrupted; + } + //shouldParkAfterFailedAcquire根据对当前节点的前一个节点的状态进行判断,对当前节点做出不同的操作 + //parkAndCheckInterrupt让线程进入等待状态,并检查当前线程是否被可以被中断 + if (shouldParkAfterFailedAcquire(p, node) && + parkAndCheckInterrupt()) + interrupted = true; + } + } finally { + //将当前节点设置为取消状态;取消状态设置为1 + if (failed) + cancelAcquire(node); + } +} +``` + +### 2.1.5 独占模式释放资源 - release 方法 + +release 方法是独占 exclusive 模式下线程释放共享资源的锁。它会调用 tryRelease() 释放同步资源,如果全部释放了同步状态为空闲(即 state=0), 当同步状态为空闲时,它会唤醒等待队列里的其他线程来获取资源。这也正是 unlock() 的语义,当然不仅仅只限于 unlock()。 + +```java + public final boolean release(int arg) { + if (tryRelease(arg)) { + Node h = head; + if (h != null && h.waitStatus != 0) + unparkSuccessor(h); + return true; + } + return false; + } +``` + +### 2.1.6 独占模式释放资源 - tryRelease 方法 + +`tryRelease()`跟`tryAcquire()`一样实现都是由自定义定时器以独占 exclusive 模式实现的。因为其是独占模式,不需要考虑线程安全的问题去释放共享资源,直接减掉相应量的资源即可 (state-=arg)。而且`tryRelease()`的返回值代表着该线程是否已经完成资源的释放,因此在自定义同步器的`tryRelease()`时,需要明确这条件,当已经彻底释放资源 (state=0),要返回 true,否则返回 false。 + +```java + protected boolean tryRelease(int arg) { + throw new UnsupportedOperationException(); + } +``` + +ReentrantReadWriteLock 的实现: + +```java + protected final boolean tryRelease(int releases) { + if (!isHeldExclusively()) + throw new IllegalMonitorStateException(); + //减掉相应量的资源(state-=arg) + int nextc = getState() - releases; + //是否完全释放资源 + boolean free = exclusiveCount(nextc) == 0; + if (free) + setExclusiveOwnerThread(null); + setState(nextc); + return free; + } +``` + +### 2.1.7 独占模式释放资源 - unparkSuccessor + +`unparkSuccessor`用 unpark() 唤醒等待队列中最前驱的那个未放弃线程,此线程并不一定是当前节点的 next 节点,而是下一个可以用来唤醒的线程,如果这个节点存在,调用 unpark() 方法唤醒。 + +```java +private void unparkSuccessor(Node node) { + //当前线程所在的结点node + int ws = node.waitStatus; + //置零当前线程所在的结点状态,允许失败 + if (ws < 0) + compareAndSetWaitStatus(node, ws, 0); + //找到下一个需要唤醒的结点 + Node s = node.next; + if (s == null || s.waitStatus > 0) { + s = null; + // 从后向前找 + for (Node t = tail; t != null && t != node; t = t.prev) + //从这里可以看出,<=0的结点,都是还有效的结点 + if (t.waitStatus <= 0) + s = t; + } + if (s != null) + //唤醒 + LockSupport.unpark(s.thread); +} +``` + +## 2.2 共享模式下的 AQS + +**共享模式**,允许多个线程同时获取到同步状态, 共享模式下的 AQS 也是不响应中断的。 + +- 将 state 的初始值设为 N(N > 0),表示空闲。每当一个线程获取到同步状态时,就利用 CAS 操作让 state 减 1,直到减到 0 表示非空闲,其他线程就只能加入到同步队列,进行等待。 +- 释放同步状态时,需要 CAS 操作,因为共享模式下,有多个线程能获取到同步状态。 +- `CountDownLatch`、`Semaphore` 正是基于此设计的。 + +例如,CountDownLatch,任务分为 N 个子线程去执行,同步状态 state 也初始化为 N(注意 N 要与线程个数一致):   + +![img](https://user-gold-cdn.xitu.io/2019/11/13/16e657242fc7933f?imageView2/0/w/1280/h/960/format/webp/ignore-error/1) + +### 2.2.1 共享模式获取资源 - acquireShared 方法 + +`acquireShared`在共享模式下线程获取共享资源的顶层入口。它会获取指定量的资源,获取成功则直接返回,获取失败则进入等待队列,直到获取到资源为止,整个过程忽略中断。 + +```java +public final void acquireShared(int arg) { + if (tryAcquireShared(arg) < 0) + doAcquireShared(arg); +} +``` + +流程: + +- 先通过 tryAcquireShared() 尝试获取资源,成功则直接返回; +- 失败则通过 doAcquireShared() 中的 park() 进入等待队列,直到被 unpark()/interrupt() 并成功获取到资源才返回 (整个等待过程也是忽略中断响应)。 + +### 2.2.2 共享模式获取资源 - tryAcquireShared 方法 + +`tryAcquireShared()`跟独占模式获取资源方法一样实现都是由自定义同步器去实现。但 AQS 规范中已定义好`tryAcquireShared()`的返回值: + +- 负值代表获取失败; +- 0 代表获取成功,但没有剩余资源; +- 正数表示获取成功,还有剩余资源,其他线程还可以去获取。 + +```java + protected int tryAcquireShared(int arg) { + throw new UnsupportedOperationException(); + } +``` + +### 2.2.3 共享模式获取资源 - doAcquireShared 方法 + +`doAcquireShared()`用于将当前线程加入等待队列尾部休息,直到其他线程释放资源唤醒自己,自己成功拿到相应量的资源后才返回。 + +```java +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) {//如果到head的下一个,因为head是拿到资源的线程,此时node被唤醒,很可能是head用完资源来唤醒自己的 + int r = tryAcquireShared(arg);//尝试获取资源 + if (r >= 0) {//成功 + setHeadAndPropagate(node, r);//将head指向自己,还有剩余资源可以再唤醒之后的线程 + p.next = null; // help GC + if (interrupted)//如果等待过程中被打断过,此时将中断补上。 + selfInterrupt(); + failed = false; + return; + } + } + + //判断状态,队列寻找一个适合位置,进入waiting状态,等着被unpark()或interrupt() + if (shouldParkAfterFailedAcquire(p, node) && + parkAndCheckInterrupt()) + interrupted = true; + } + } finally { + if (failed) + cancelAcquire(node); + } +} +``` + +### 2.2.4 共享模式释放资源 - releaseShared 方法 + +`releaseShared()`用于共享模式下线程释放共享资源,释放指定量的资源,如果成功释放且允许唤醒等待线程,它会唤醒等待队列里的其他线程来获取资源。 + +```java +public final boolean releaseShared(int arg) { + //尝试释放资源 + if (tryReleaseShared(arg)) { + //唤醒后继结点 + doReleaseShared(); + return true; + } + return false; +} +``` + +独占模式下的 tryRelease() 在完全释放掉资源(state=0)后,才会返回 true 去唤醒其他线程,这主要是基于独占下可重入的考量;而共享模式下的 releaseShared() 则没有这种要求,共享模式实质就是控制一定量的线程并发执行,那么拥有资源的线程在释放掉部分资源时就可以唤醒后继等待结点。 + +### 2.2.5 共享模式释放资源 - doReleaseShared 方法 + +`doReleaseShared()`主要用于唤醒后继节点线程, 当 state 为正数,去获取剩余共享资源;当 state=0 时去获取共享资源。 + +```java +private void doReleaseShared() { + 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; + } + // head发生变化 + if (h == head) + break; + } +} +``` + +# 三、Mutex(互斥锁) + +Mutex 是一个不可重入的互斥锁实现。锁资源(AQS 里的 state)只有两种状态:0 表示未锁定,1 表示锁定。下边是 Mutex 的核心源码: + +```java +class Mutex implements Lock, java.io.Serializable { + // 自定义同步器 + private static class Sync extends AbstractQueuedSynchronizer { + // 判断是否锁定状态 + protected boolean isHeldExclusively() { + return getState() == 1; + } + + // 尝试获取资源,立即返回。成功则返回true,否则false。 + public boolean tryAcquire(int acquires) { + assert acquires == 1; // 这里限定只能为1个量 + if (compareAndSetState(0, 1)) {//state为0才设置为1,不可重入! + setExclusiveOwnerThread(Thread.currentThread());//设置为当前线程独占资源 + return true; + } + return false; + } + + // 尝试释放资源,立即返回。成功则为true,否则false。 + protected boolean tryRelease(int releases) { + assert releases == 1; // 限定为1个量 + if (getState() == 0)//既然来释放,那肯定就是已占有状态了。只是为了保险,多层判断! + throw new IllegalMonitorStateException(); + setExclusiveOwnerThread(null); + setState(0);//释放资源,放弃占有状态 + return true; + } + } + + // 真正同步类的实现都依赖继承于AQS的自定义同步器! + private final Sync sync = new Sync(); + + //lock<-->acquire。两者语义一样:获取资源,即便等待,直到成功才返回。 + public void lock() { + sync.acquire(1); + } + + //tryLock<-->tryAcquire。两者语义一样:尝试获取资源,要求立即返回。成功则为true,失败则为false。 + public boolean tryLock() { + return sync.tryAcquire(1); + } + + //unlock<-->release。两者语文一样:释放资源。 + public void unlock() { + sync.release(1); + } + + //锁是否占有状态 + public boolean isLocked() { + return sync.isHeldExclusively(); + } +} +``` + +同步类在实现时一般都将自定义同步器(sync)定义为内部类,供自己使用;而同步类自己(Mutex)则实现某个接口,对外服务。当然,接口的实现要直接依赖sync,它们在语义上也存在某种对应关系!!而sync只用实现资源state的获取-释放方式tryAcquire-tryRelelase,至于线程的排队、等待、唤醒等,上层的AQS都已经实现好了,我们不用关心。 + +# 四、引用 + +[**《提升能力,涨薪可待》-Java 并发之 AQS 全面详解**--**Ccww**](https://juejin.im/post/5dc3db9a5188257bda21cac0#heading-1) + +[Java并发之AQS详解--waterystone](https://www.cnblogs.com/waterystone/p/4920797.html) \ No newline at end of file diff --git a/week_03/60/ReentrantLock-060.md b/week_03/60/ReentrantLock-060.md new file mode 100644 index 0000000..98252c7 --- /dev/null +++ b/week_03/60/ReentrantLock-060.md @@ -0,0 +1,166 @@ +```java +import java.util.concurrent.locks.ReentrantLock; +``` + +ReentrantLock重入锁,是实现Lock接口的一个类,也是在实际编程中使用频率很高的一个锁,**支持重入性,表示能够对共享资源能够重复加锁,即当前线程获取该锁再次获取不会被阻塞**。在java关键字synchronized隐式支持重入性,synchronized通过获取自增,释放自减的方式实现重入。与此同时,ReentrantLock还支持**公平锁和非公平锁**两种方式。 + +# 一、ReentrantLock重入性实现原理 + +要想支持重入性,就要解决两个问题: + +1. 在线程获取锁的时候,如果已经获取锁的线程是当前线程的话则直接再次获取成功; +2. 由于锁会被获取n次,那么只有锁在被释放同样的n次之后,该锁才算是完全释放成功。 + +## 1.1 ReentrantLock实现问题1-获取锁 + +以非公平锁为例,判断当前线程能否获得锁为例,核心方法为`nonfairTryAcquire`: + +```java +final boolean nonfairTryAcquire(int acquires) { + final Thread current = Thread.currentThread(); + int c = getState(); + //1. 如果该锁未被任何线程占有,该锁能被当前线程获取 + if (c == 0) { + if (compareAndSetState(0, acquires)) { + setExclusiveOwnerThread(current); + return true; + } + } + //2.若被占有,检查占有线程是否是当前线程 + else if (current == getExclusiveOwnerThread()) { + // 3. 再次获取,计数加一 + int nextc = c + acquires; + if (nextc < 0) // overflow + throw new Error("Maximum lock count exceeded"); + setState(nextc); + return true; + } + return false; +} +``` + +为了支持重入性,在第二步增加了处理逻辑,如果该锁已经被线程所占有了,会继续检查占有线程是否为当前线程,如果是的话,同步状态加1返回true,表示可以再次获取成功。 + +## 1.2 ReentrantLock实现问题2-释放锁 + +依然还是以非公平锁为例,核心方法为tryRelease: + +```java +protected final boolean tryRelease(int releases) { + //1. 同步状态减1 + int c = getState() - releases; + if (Thread.currentThread() != getExclusiveOwnerThread()) + throw new IllegalMonitorStateException(); + boolean free = false; + if (c == 0) { + //2. 只有当同步状态为0时,锁成功被释放,返回true + free = true; + setExclusiveOwnerThread(null); + } + // 3. 锁未被完全释放,返回false + setState(c); + return free; +} +``` + +重入锁的释放必须得等到同步状态为0时锁才算成功释放,否则锁仍未释放。如果锁被获取n次,释放了n-1次,该锁未完全释放返回false,只有被释放n次才算成功释放,返回true。 + +# 二、公平锁与非公平锁 + +ReentrantLock支持两种锁:**公平锁**和**非公平锁**。 + +**针对获取锁而言的,如果一个锁是公平的,那么锁的获取顺序就应该符合请求上的绝对时间顺序,满足FIFO**。ReentrantLock的构造方法无参时是构造非公平锁,源码为: + +```java + /** + * Creates an instance of {@code ReentrantLock}. + * This is equivalent to using {@code ReentrantLock(false)}. + */ + public ReentrantLock() { + sync = new NonfairSync(); + } +``` + +另外还提供了另外一种方式,可传入一个boolean值,true时为公平锁,false时为非公平锁,源码为: + +```java + /** + * Creates an instance of {@code ReentrantLock} with the + * given fairness policy. + * + * @param fair {@code true} if this lock should use a fair ordering policy + */ + public ReentrantLock(boolean fair) { + sync = fair ? new FairSync() : new NonfairSync(); + } +``` + +那么是`sync`字段的实现逻辑是什么呢?看下`sync`的代码: + +```java +private final Sync sync; + +abstract static class Sync extends AbstractQueuedSynchronizer {......} + +static final class NonfairSync extends Sync {......} + +static final class FairSync extends Sync {......} +``` + +到这里就发现了`AbstractQueuedSynchronizer`类,公平锁和非公平锁其实都是在`AbstractQueuedSynchronizer`的基础上实现的,也就是 AQS。AQS 提供了`ReentrantLock`实现的基础。 + +在上面非公平锁获取时(nonfairTryAcquire方法)只是简单的获取了一下当前状态做了一些逻辑处理,并没有考虑到当前同步队列中线程等待的情况。那公平锁的处理逻辑是怎样的? + +核心方法为: + +```java + /** + * Performs non-fair tryLock. tryAcquire is implemented in + * subclasses, but both need nonfair try for trylock method. + */ + final boolean nonfairTryAcquire(int acquires) { + final Thread current = Thread.currentThread(); + int c = getState(); + if (c == 0) { + if (compareAndSetState(0, acquires)) { + setExclusiveOwnerThread(current); + return true; + } + } + else if (current == getExclusiveOwnerThread()) { + int nextc = c + acquires; + if (nextc < 0) // overflow + throw new Error("Maximum lock count exceeded"); + setState(nextc); + return true; + } + return false; + } +``` + +这段代码的逻辑与`nonfairTryAcquire`基本上一直,唯一的不同在于增加了`hasQueuedPredecessors`的逻辑判断,方法名就可知道该方法用来判断当前节点在同步队列中是否有前驱节点的判断,如果有前驱节点说明有线程比当前线程更早的请求资源,根据公平性,当前线程请求资源失败。如果当前节点没有前驱节点的话,再才有做后面的逻辑判断的必要性。**公平锁每次都是从同步队列中的第一个节点获取到锁,而非公平性锁则不一定,有可能刚释放锁的线程能再次获取到锁**。 + +- 公平锁每次获取到锁为同步队列中的第一个节点,**保证请求资源时间上的绝对顺序**,而非公平锁有可能刚释放锁的线程下次继续获取该锁,则有可能导致其他线程永远无法获取到锁,**造成“饥饿”现象**。 + +- 公平锁为了保证时间上的绝对顺序,需要频繁的上下文切换,而非公平锁会降低一定的上下文切换,降低性能开销。因此,ReentrantLock默认选择的是非公平锁,则是为了减少一部分上下文切换,**保证了系统更大的吞吐量**。 + +# 三、总结 + +`ReentrantLock`的获取锁和释放锁,通过 AQS 的 state 状态来控制锁获取和释放状态,AQS 内部用一个双向链表来维护挂起的线程。在 AQS 和 ReentrantLock 之间通过状态和行为来分离,AQS 用管理各种状态,并内部通过链表管理线程队列,ReentrantLock 则对外提供锁获取和释放的功能,具体实现则在 AQS 中。 + +非公平锁: + +![img](https://user-gold-cdn.xitu.io/2018/8/12/1652ca04475e83d2?imageView2/0/w/1280/h/960/format/webp/ignore-error/1) + + + +公平锁: + +![img](https://user-gold-cdn.xitu.io/2018/8/12/1652ca0416b6035e?imageView2/0/w/1280/h/960/format/webp/ignore-error/1) + +引用: + +[彻底理解ReentrantLock--你听__](https://juejin.im/post/5aeb0a8b518825673a2066f0#heading-0) + +[Java并发(5)- ReentrantLock与AQS--**knock_小新**](https://juejin.im/post/5b7235e951882560ed075893#comment) + diff --git a/week_03/60/Semaphore-060.md b/week_03/60/Semaphore-060.md new file mode 100644 index 0000000..f9a4043 --- /dev/null +++ b/week_03/60/Semaphore-060.md @@ -0,0 +1,171 @@ +```java +import java.util.concurrent.Semaphore; +``` + +Semaphore(信号量)用于管理多线程中控制资源的访问与使用,和 CountDownLatch 一样,其本质上是一个 “共享锁”。 + +Semaphore 就好比停车场的门卫,可以控制车位的使用资源。比如来了 5 辆车,只有2 个车位,门卫可以先放两辆车进去,等有车出来之后,再让后面的车进入。 + +从程序角度看,停车场就相当于信号量 Semaphore,其中许可数为 5,车辆就相对线程。当来一辆车时,许可数就会减 1 ,当停车场没有车位了(许可书 == 0 ),其他来的车辆需要在外面等候着。如果有一辆车开出停车场,许可数 + 1,然后放进来一辆车。 + +Semaphore 示例代码如下: + +``` +Semaphore semaphore = new Semaphore(2); +ThreadPoolExecutor semaphoreThread = new ThreadPoolExecutor(10, 50, 60, TimeUnit.SECONDS, new LinkedBlockingQueue<>()); +for (int i = 0; i < 5; i++) { + semaphoreThread.execute(() -> { + try { + // 堵塞获取许可 + semaphore.acquire(); + System.out.println("Thread:" + Thread.currentThread().getName() + " 时间:" + LocalDateTime.now()); + TimeUnit.SECONDS.sleep(2); + // 释放许可 + semaphore.release(); + } catch (InterruptedException e) { + e.printStackTrace(); + } + }); +} +``` + +以上程序执行结果如下: + +> Thread:pool-1-thread-1 时间:2019-07-10 21:18:42 + +> Thread:pool-1-thread-2 时间:2019-07-10 21:18:42 + +> Thread:pool-1-thread-3 时间:2019-07-10 21:18:44 + +> Thread:pool-1-thread-4 时间:2019-07-10 21:18:44 + +> Thread:pool-1-thread-5 时间:2019-07-10 21:18:46 + +执行流程如下图: + +![enter image description +here](https://images.gitbook.cn/b2050980-d508-11e9-85b2-cd48fc6b5862)信号量 Semaphore 是一个非负整数(>=1)。当一个线程想要访问某个共享资源时,它必须要先获取 Semaphore,当 Semaphore >0 时,获取该资源并使 Semaphore – 1。如果 Semaphore 值 = 0,则表示全部的共享资源已经被其他线程全部占用,线程必须要等待其他线程释放资源。当线程释放资源时,Semaphore 则 + 1 + +**Semaphore 结构如下:** + +![img](https://user-gold-cdn.xitu.io/2018/5/18/1637275be7ff5902?imageView2/0/w/1280/h/960/format/webp/ignore-error/1) + +# 一、Semaphore 构造函数 + +```java + // 创建具有给定的许可数和非公平的公平设置的 Semaphore。 + public Semaphore(int permits) { + sync = new NonfairSync(permits); + } + // 创建具有给定的许可数和给定的公平设置的 Semaphore。 + public Semaphore(int permits, boolean fair) { + sync = fair ? new FairSync(permits) : new NonfairSync(permits); + } +``` + +**Semaphore 默认选择非公平锁。**当信号量 Semaphore = 1 时,它可以当作互斥锁使用。其中 0、1 就相当于它的状态,当 = 1 时表示其他线程可以获取,当 = 0 时,排他,即其他线程必须要等待。 信号量获取 + +# 二、Semaphore中方法 + +```java + // 获取一个许可 + public void acquire() throws InterruptedException { + sync.acquireSharedInterruptibly(1); + } +``` + +内部调用 AQS 的 `acquireSharedInterruptibly(int arg)`,该方法以共享模式获取同步状态: + +```java + /** + * 以共享模式获取,如果中断则中止。通过首先检查中断状态,然后至少调用一次,来实现{@link #tryAcquireShared},并成功返回。 * 否则,线程将排队,有可能反复阻塞和解除阻塞,调用{@link #tryAcquireShared}直到成功或线程被中断。 + */ + public final void acquireSharedInterruptibly(int arg) + throws InterruptedException { + if (Thread.interrupted()) + throw new InterruptedException(); + if (tryAcquireShared(arg) < 0) + doAcquireSharedInterruptibly(arg); + } +``` + +在 `acquireSharedInterruptibly(int arg)` 中,`tryAcquireShared(int arg)` 由子类来实现,对于 Semaphore 而言,如果选择非公平模式,则调用 `NonfairSync` 的 `tryAcquireShared(int arg)` 方法,否则调用 `FairSync` 的 `tryAcquireShared(int arg)` 方法。 + +**公平:** + +```java + protected int tryAcquireShared(int acquires) { + for (;;) { + //判断该线程是否位于CLH队列的列头 + if (hasQueuedPredecessors()) + return -1; + //获取当前的信号量许可 + int available = getState(); + + //设置“获得acquires个信号量许可之后,剩余的信号量许可数” + int remaining = available - acquires; + + //CAS设置信号量 + if (remaining < 0 || + compareAndSetState(available, remaining)) + return remaining; + } + } +``` + +**非公平** + +对于非公平而言,因为它不需要判断当前线程是否位于 `CLH` 同步队列列头,所以相对而言会简单些。 + +```java +protected int tryAcquireShared(int acquires) { + return nonfairTryAcquireShared(acquires); + } + + final int nonfairTryAcquireShared(int acquires) { + for (;;) { + int available = getState(); + int remaining = available - acquires; + if (remaining < 0 || + compareAndSetState(available, remaining)) + return remaining; + } +``` + +**信号量释放** + +```java + public void release() { + sync.releaseShared(1); + } +``` + +内部调用 AQS 的 `releaseShared(int arg)`: + +```java + public final boolean releaseShared(int arg) { + if (tryReleaseShared(arg)) { + doReleaseShared(); + return true; + } + return false; + } +``` + +`releaseShared(int arg)` 调用 Semaphore 内部类 `Sync` 的 `tryReleaseShared(int arg)`: + +```java + protected final boolean tryReleaseShared(int releases) { + for (;;) { + int current = getState(); + //信号量的许可数 = 当前信号许可数 + 待释放的信号许可数 + int next = current + releases; + if (next < current) // overflow + throw new Error("Maximum permit count exceeded"); + //设置可获取的信号许可数为next + if (compareAndSetState(current, next)) + return true; + } + } +``` + diff --git a/week_03/60/sychronized-060.md b/week_03/60/sychronized-060.md new file mode 100644 index 0000000..236129f --- /dev/null +++ b/week_03/60/sychronized-060.md @@ -0,0 +1,506 @@ +sychronized是java提供的同步机制,当一个线程正在操作同步代码块(sychronized修饰的代码块)时,其它线程只能阻塞等待原有线程执行完再执行。 + +也就是说,sychronized关键字解决的是多个线程之间访问资源的同步性,sychronized关键字可以保证被它修饰的方法或代码块在任意时刻只能有一个线程执行。 + +# 一、synchronized 的三种应用方式 + +- 修饰实例方法; +- 修饰静态方法; +- 修饰代码块; + +## 1.1 修饰实例方法 + +**作用于当前对象实例加锁,进入同步代码块前要获得当前对象实例的锁**。 + +```java +public class AccountingSync implements Runnable{ + //共享资源(临界资源) + static int i=0; + + /** + * synchronized 修饰实例方法 + */ + public synchronized void increase(){ + i++; + } + @Override + public void run() { + for(int j=0;j<1000000;j++){ + increase(); + } + } + public static void main(String[] args) throws InterruptedException { + AccountingSync instance=new AccountingSync(); + Thread t1=new Thread(instance); + Thread t2=new Thread(instance); + t1.start(); + t2.start(); + t1.join(); + t2.join(); + System.out.println(i); + } +} +``` + +控制台输出: + +> 2000000 + +上述代码中,开启两个线程操作同一个共享资源即变量 i,由于`i++;`操作并不具备原子性,该操作是先读取值,然后写回一个新值,相当于原来的值加上 1,对于`i++`的操作,其实可以分解为3步: + +1. 从主存中读取a的值; +2. 对a进行+1操作; +3. 将a的新值刷新到主存; + +此时,如果第线程`t2`在线程`t1`读取旧值和写回新值期间读取 i 的域值,那么`t2`就会与`t1`一起读到同一个值,并执行相同值的加 1 操作,这也就造成了线程安全失败,因此对于 `increase()` 方法必须使用 synchronized 修饰,以便保证线程安全。 + +这里 synchronized 修饰的是实例方法 `increase()`,此时,当前线程的锁便是实例对象 instance,注意 Java 中的线程同步锁可以是任意对象。从代码执行结果来看确实是正确的,倘若我们没有使用 synchronized 关键字,其最终输出结果就很可能小于 `2000000`,这便是 synchronized 关键字的作用。 + + + +当一个线程正在访问一个对象的 synchronized 实例方法,那么其它线程不能访问该对象的其它 synchronized 方法,毕竟一个对象只有一把锁,当一个线程获取了该对象的锁之后,其它线程无法获取该对象的锁,所以无法访问该对象的其它 synchronized 实例方法,但是其它线程还是可以访问该实例对象的其它非 synchronized 方法,当然如果是一个`线程 A` 需要访问实例对象 `obj1` 的 synchronized 方法 `f1`(当前对象锁是 `obj1`),另一个`线程 B` 需要访问实例对象 `obj2` 的 synchronized 方法 `f2`(当前对象锁是 `obj2`),这样是允许的,因为两个实例对象锁并不同相同,此时如果两个线程操作数据并非共享的,线程安全是有保障的,遗憾的是如果两个线程操作的是共享数据,那么线程安全就有可能无法保证了,如下: + +```java +public class AccountingSyncBad implements Runnable{ + static int i=0; + public synchronized void increase(){ + i++; + } + @Override + public void run() { + for(int j=0;j<1000000;j++){ + increase(); + } + } + public static void main(String[] args) throws InterruptedException { + //new新实例 + Thread t1=new Thread(new AccountingSyncBad()); + //new新实例 + Thread t2=new Thread(new AccountingSyncBad()); + t1.start(); + t2.start(); + //join含义:当前线程A等待thread线程终止之后才能从thread.join()返回 + t1.join(); + t2.join(); + System.out.println(i); + } +} +``` + +与前面不同的是此处同时创建了两个新实例 `AccountingSyncBad`,然后启动两个不同的线程对共享变量 `i` 进行操作,但很遗憾操作结果是`1452317`而不是期望结果`2000000`,因为上述代码犯了严重的错误,虽然我们使用 synchronized 修饰了 `increase` 方法,但却 `new` 了两个不同的实例对象,这也就意味着存在着两个不同的实例对象锁,因此 `t1` 和 `t2` 都会进入各自的对象锁,也就是说 `t1` 和 `t2` 线程使用的是不同的锁,因此线程安全是无法保证的。解决这种困境的的方式是将 synchronized 作用于**静态**的 `increase` 方法,这样的话,对象锁就当前类对象,由于无论创建多少个实例对象,但对于的类对象拥有只有一个,所有在这样的情况下对象锁就是唯一的。 + +## 1.2 修饰静态方法 + +**修饰静态方法,作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁**。当 synchronized 作用于静态方法时,其锁就是当前类的 class 对象锁。由于静态成员不专属于任何一个实例对象,是类成员(static表明这是该类的一个静态资源,不管new了多少个对象,只有一份,所以对该类的所有对象都加了锁),因此通过 class 对象锁可以控制静态成员的并发操作。需要注意的是如果一个线程 A 调用一个实例对象的非 static synchronized 方法,而线程 B 需要调用这个实例对象所属类的静态 synchronized 方法,是允许的,不会发生互斥现象,因为**访问静态 synchronized 方法占用的锁是当前类的 class 对象的锁,而访问非静态 synchronized 方法占用的锁是当前实例对象锁**。 + +```java +public class AccountingSyncClass implements Runnable{ + static int i=0; + + /** + * 作用于静态方法,锁是当前class对象,也就是 + * AccountingSyncClass类对应的class对象 + */ + public static synchronized void increase(){ + i++; + } + + /** + * 非静态,访问时锁不一样不会发生互斥 + */ + public synchronized void increase4Obj(){ + i++; + } + + @Override + public void run() { + for(int j=0;j<1000000;j++){ + increase(); + } + } + public static void main(String[] args) throws InterruptedException { + //new新实例 + Thread t1=new Thread(new AccountingSyncClass()); + //new新实例 + Thread t2=new Thread(new AccountingSyncClass()); + //启动线程 + t1.start(); + t2.start(); + + t1.join(); + t2.join(); + } +} +``` + +由于 synchronized 关键字修饰的是静态 `increase` 方法,与修饰实例方法不同的是,其锁对象是当前类的 class 对象。注意代码中的 `increase4Obj` 方法是实例方法,其对象锁是当前实例对象,如果别的线程调用该方法,将不会产生互斥现象,毕竟锁对象不同,这种情况下可能会发现线程安全问题 (操作了共享静态变量 `i`)。 + +## 1.3 修饰代码块 + +**指定加锁对象,对给定对象加锁,进入同步代码块前要获得给定对象的锁。**和sychronized方法一样,synchronized(this)代码块也是锁定当前对象的。synchronized关键字加到static静态方法和synchronized(class)代码块上都是给Class类加锁。再提一下,synchronized加到非static静态方法上是给对象实例上锁。需要注意的是,尽量不要使用synchronized(String str),因为JVM中,字符串常量池具有缓冲功能。 + +```java +public class AccountingSync implements Runnable{ + static AccountingSync instance=new AccountingSync(); + static int i=0; + @Override + public void run() { + //省略其他耗时操作.... + //使用同步代码块对变量i进行同步操作,锁对象为instance + synchronized(instance){ + for(int j=0;j<1000000;j++){ + i++; + } + } + } + public static void main(String[] args) throws InterruptedException { + Thread t1=new Thread(instance); + Thread t2=new Thread(instance); + t1.start(); + t2.start(); + + t1.join(); + t2.join(); + } +} +``` + +从代码看出,将 synchronized 作用于一个给定的实例对象 instance,即当前实例对象就是锁对象,每次当线程进入 synchronized 包裹的代码块时就会要求当前线程持有 instance 实例对象锁,如果当前有其他线程正持有该对象锁,那么新到的线程就必须等待,这样也就保证了每次只有一个线程执行`i++;`操作。当然除了 instance 作为对象外,还可以使用 this 对象 (代表当前实例) 或者当前类的 class 对象作为锁,如下: + +```java +//this,当前实例对象锁 +synchronized(this){ + for(int j=0;j<1000000;j++){ + i++; + } +} + +//class对象锁 +synchronized(AccountingSync.class){ + for(int j=0;j<1000000;j++){ + i++; + } +} +``` + +# 二、synchronized 底层语义原理 + +## 2.1 Java 对象头 + +Java 虚拟机中的同步 (`Synchronization`) 基于进入和退出管程 (`Monitor`) 对象实现, 无论是显式同步 (有明确的 `monitorenter` 和 `monitorexit` 指令, 即同步代码块) 还是隐式同步都是如此。在 Java 语言中,同步用的最多的地方可能是被 synchronized 修饰的同步方法。同步方法 并不是由 `monitorenter` 和 `monitorexit` 指令来实现同步的,而是由方法调用指令读取运行时常量池中方法的 `ACC_SYNCHRONIZED` 标志来隐式实现的,关于这点,稍后详细分析。下面先来了解一个概念 Java 对象头,这对深入理解 synchronized 实现原理非常关键。 + +![img](https://img-blog.csdn.net/20170603163237166?watermark/2/text/aHR0cDovL2Jsb2cuY3Nkbi5uZXQvamF2YXplamlhbg==/font/5a6L5L2T/fontsize/400/fill/I0JBQkFCMA==/dissolve/70/gravity/SouthEast) + + + +- 实例变量:存放类的**属性数据**信息,包括父类的属性信息,如果是数组的实例部分还包括数组的长度,这部分内存按 4 字节对齐。 +- 填充数据:由于虚拟机要求对象起始地址必须是 **8** 字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐。 + +- Java 头对象,它实现 synchronized 的锁对象的基础。一般而言,synchronized 使用的锁对象是存储在 Java 对象头里的,jvm 中采用 2 个字节来存储对象头 (如果对象是数组则会分配 3 个字节,多出来的 1 个字节记录的是数组长度),其主要结构是由 `Mark Word` 和 `Class Metadata Address` 组成,其结构说明如下表: + +| 虚拟机位数 | 头对象结构 | 说明 | +| ---------- | ---------------------- | ------------------------------------------------------------ | +| 32/64bit | Mark Word | 存储对象的 hashCode、锁信息或分代年龄或 GC 标志等信息 | +| 32/64bit | Class Metadata Address | 类型指针指向对象的类元数据,JVM 通过这个指针确定该对象是哪个类的实例。 | + +其中 `Mark Word` 在默认情况下存储着对象的 `HashCode`、分代年龄、锁标记位等以下是 32 位 JVM 的 `Mark Word` 默认存储结构 + +| 锁状态 | 25bit | 4bit | 1bit 是否是偏向锁 | 2bit 锁标志位 | +| -------- | ------------- | ------------ | ----------------- | ------------- | +| 无锁状态 | 对象 HashCode | 对象分代年龄 | 0 | 01 | + +由于对象头的信息是与对象自身定义的数据没有关系的额外存储成本,因此考虑到 JVM 的空间效率,`Mark Word` 被设计成为一个非固定的数据结构,以便存储更多有效的数据,它会根据对象本身的状态复用自己的存储空间,如 32 位 JVM 下,除了上述列出的 `Mark Word` 默认存储结构外,还有如下可能变化的结构: + +![img](https://img-blog.csdn.net/20170603172215966?watermark/2/text/aHR0cDovL2Jsb2cuY3Nkbi5uZXQvamF2YXplamlhbg==/font/5a6L5L2T/fontsize/400/fill/I0JBQkFCMA==/dissolve/70/gravity/SouthEast) + +(其中轻量级锁和偏向锁是 Java 6 对 synchronized 锁进行优化后新增加的) + +**重量级锁**也就是通常说 synchronized 的对象锁,锁标识位为 `10`,其中指针指向的是 `monitor` 对象(也称为管程或监视器锁)的起始地址。每个对象都存在着一个 `monitor` 与之关联,对象与其 `monitor` 之间的关系有存在多种实现方式,如 `monitor` 可以与对象一起创建销毁或当线程试图获取对象锁时自动生成,但当一个 `monitor` 被某个线程持有后,它便处于锁定状态。在 Java 虚拟机 (`HotSpot`) 中,`monitor` 是由 `ObjectMonitor` 实现的,其主要数据结构如下(位于 `HotSpot` 虚拟机源码 `ObjectMonitor.hpp` 文件,`C++` 实现的) + +```java +ObjectMonitor() { + _header = NULL; + _count = 0; //记录个数 + _waiters = 0, + _recursions = 0; + _object = NULL; + _owner = NULL; + _WaitSet = NULL; //处于wait状态的线程,会被加入到_WaitSet + _WaitSetLock = 0 ; + _Responsible = NULL ; + _succ = NULL ; + _cxq = NULL ; + FreeNext = NULL ; + _EntryList = NULL ; //处于等待锁block状态的线程,会被加入到该列表 + _SpinFreq = 0 ; + _SpinClock = 0 ; + OwnerIsThread = 0 ; + } +``` + +`ObjectMonitor` 中有两个队列,`_WaitSet` 和 `_EntryList`,用来保存 `ObjectWaiter` 对象列表 (每个等待锁的线程都会被封装成 `ObjectWaiter` 对象),`_owner` 指向持有 `ObjectMonitor` 对象的线程,当多个线程同时访问一段同步代码时,首先会进入 `_EntryList` 集合,当线程获取到对象的 `monitor` 后进入 `_Owner` 区域并把 `monitor` 中的 `owner` 变量设置为当前线程同时 `monitor` 中的计数器 `count` 加 1,若线程调用 `wait()` 方法,将释放当前持有的 `monitor`,`owner` 变量恢复为 `null`,`count` 自减 1,同时该线程进入 `WaitSet` 集合中等待被唤醒。若当前线程执行完毕也将释放 `monitor`(锁) 并复位变量的值,以便其他线程进入获取 `monitor`(锁)。 + +![img](https://img-blog.csdn.net/20170604114223462?watermark/2/text/aHR0cDovL2Jsb2cuY3Nkbi5uZXQvamF2YXplamlhbg==/font/5a6L5L2T/fontsize/400/fill/I0JBQkFCMA==/dissolve/70/gravity/SouthEast) + + + +由此看来,`monitor` 对象存在于每个 Java 对象的对象头中 (存储的指针的指向),synchronized 锁便是通过这种方式获取锁的,也是为什么 Java 中任意对象可以作为锁的原因,同时也是 `notify`/`notifyAll`/`wait` 等方法存在于顶级对象 Object 中的原因。 + +## 2.2 synchronized 代码块底层原理 + +现在重新定义一个 synchronized 修饰的同步代码块,在代码块中操作共享变量 i + +```java +public class SyncCodeBlock { + + public int i; + + public void syncTask(){ + //同步代码块 + synchronized (this){ + i++; + } + } +} +``` + +编译上述代码并使用 `javap` 反编译后得到字节码如下 (此处省略一部分无关信息): + +```java +Classfile /Users/zejian/Downloads/Java8_Action/src/main/java/com/zejian/concurrencys/SyncCodeBlock.class + Last modified 2017-6-2; size 426 bytes + MD5 checksum c80bc322c87b312de760942820b4fed5 + Compiled from "SyncCodeBlock.java" +public class com.zejian.concurrencys.SyncCodeBlock + minor version: 0 + major version: 52 + flags: ACC_PUBLIC, ACC_SUPER +Constant pool: + //........省略常量池中数据 + //构造函数 + public com.zejian.concurrencys.SyncCodeBlock(); + descriptor: ()V + flags: ACC_PUBLIC + Code: + stack=1, locals=1, args_size=1 + 0: aload_0 + 1: invokespecial #1 // Method java/lang/Object."":()V + 4: return + LineNumberTable: + line 7: 0 + //===========主要看看syncTask方法实现================ + public void syncTask(); + descriptor: ()V + flags: ACC_PUBLIC + Code: + stack=3, locals=3, args_size=1 + 0: aload_0 + 1: dup + 2: astore_1 + 3: monitorenter //注意此处,进入同步方法 + 4: aload_0 + 5: dup + 6: getfield #2 // Field i:I + 9: iconst_1 + 10: iadd + 11: putfield #2 // Field i:I + 14: aload_1 + 15: monitorexit //注意此处,退出同步方法 + 16: goto 24 + 19: astore_2 + 20: aload_1 + 21: monitorexit //注意此处,退出同步方法 + 22: aload_2 + 23: athrow + 24: return + Exception table: + //省略其他字节码....... +} +SourceFile: "SyncCodeBlock.java" +``` + +主要关注字节码中的如下代码: + +```java +3: monitorenter //进入同步方法 +//..........省略其他 +15: monitorexit //退出同步方法 +16: goto 24 +//省略其他....... +21: monitorexit //退出同步方法 +``` + +从字节码中可知同步语句块的实现使用的是 `monitorenter` 和 `monitorexit` 指令,其中 `monitorenter` 指令指向同步代码块的开始位置,`monitorexit` 指令则指明同步代码块的结束位置。 + +当执行 `monitorenter` 指令时,当前线程将试图获取 `objectref`(即对象锁) 所对应的 `monitor` 的持有权,当 `objectref` 的 `monitor` 的进入计数器为 `0`,那线程可以成功取得 `monitor`,并将计数器值设置为 `1`,取锁成功。如果当前线程已经拥有 `objectref` 的 `monitor` 的持有权,那它可以重入这个 `monitor` ,重入时计数器的值也会加 `1`。 + +若其他线程已经拥有 `objectref` 的 `monitor` 的所有权,那当前线程将被阻塞,直到正在执行线程执行完毕,即 `monitorexit` 指令被执行,执行线程将释放 `monitor`(锁) 并设置计数器值为 `0` ,其他线程将有机会持有 `monitor` 。 + +注意:编译器将会确保无论方法通过何种方式完成,方法中调用过的每条 `monitorenter` 指令都有执行其对应 `monitorexit` 指令,而无论这个方法是正常结束还是异常结束。为了保证在方法异常完成时 `monitorenter` 和 `monitorexit` 指令依然可以正确配对执行,编译器会自动产生一个异常处理器,这个异常处理器声明可处理所有的异常,它的目的就是用来执行 `monitorexit` 指令。从字节码中也可以看出多了一个 `monitorexit` 指令,它就是异常结束时被执行的释放 monitor 的指令。 + +## 2.3 synchronized 方法底层原理 + +方法级的同步是隐式,即无需通过字节码指令来控制的,它实现在方法调用和返回操作之中。JVM 可以从方法常量池中的方法表结构 (`method_info Structure`) 中的 `ACC_SYNCHRONIZED` 访问标志区分一个方法是否同步方法。 + +当方法调用时,调用指令将会 检查方法的 `ACC_SYNCHRONIZED` 访问标志是否被设置,如果设置了,执行线程将先持有 `monitor`(虚拟机规范中用的是管程一词), 然后再执行方法,最后再方法完成(无论是正常完成还是非正常完成) 时释放 `monitor`。 + +在方法执行期间,执行线程持有了 `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; + + //省略没必要的字节码 + //==================syncTask方法====================== + public synchronized void syncTask(); + descriptor: ()V + //方法标识ACC_PUBLIC代表public修饰,ACC_SYNCHRONIZED指明该方法为同步方法 + flags: ACC_PUBLIC, ACC_SYNCHRONIZED + Code: + stack=3, locals=1, args_size=1 + 0: aload_0 + 1: dup + 2: getfield #2 // Field i:I + 5: iconst_1 + 6: iadd + 7: putfield #2 // Field i:I + 10: return + LineNumberTable: + line 12: 0 + line 13: 10 +} +SourceFile: "SyncMethod.java" +``` + +从字节码中可以看出,synchronized 修饰的方法并没有 `monitorenter` 指令和 `monitorexit` 指令,取得代之的确实是 `ACC_SYNCHRONIZED` 标识,该标识指明了该方法是一个同步方法,JVM 通过该 `ACC_SYNCHRONIZED` 访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。这便是 `synchronized` 锁在同步代码块和同步方法上实现的基本原理。 + +注意: + +- 在 Java 早期版本中,synchronized 属于重量级锁,效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的 Mutex Lock 来实现的,而操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,这也是为什么早期的 synchronized 效率低的原因。 + +- 在 Java 6 之后 Java 官方对从 JVM 层面对 synchronized 较大优化,所以现在的 synchronized 锁效率也优化得很不错了,Java 6 之后,为了减少获得锁和释放锁所带来的性能消耗,引入了轻量级锁和偏向锁 + +## 2.4 Java 虚拟机对 synchronized 的优化 + +sychronized中锁的状态总共有四种,无锁状态、偏向锁、轻量级锁和重量级锁。随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁,但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级。 + +### 偏向锁 + +偏向锁是 Java 6 之后加入的新锁,它是一种针对加锁操作的优化手段,经过研究发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,因此为了减少同一线程获取锁 (会涉及到一些 CAS 操作, 耗时) 的代价而引入偏向锁。 + +偏向锁的核心思想是,如果一个线程获得了锁,那么锁就进入偏向模式,此时 Mark Word 的结构也变为偏向锁结构,当这个线程再次请求锁时,无需再做任何同步操作,即获取锁的过程,这样就省去了大量有关锁申请的操作,从而也就提供程序的性能。 + +所以,对于没有锁竞争的场合,偏向锁有很好的优化效果,毕竟极有可能连续多次是同一个线程申请相同的锁。但是对于锁竞争比较激烈的场合,偏向锁就失效了,因为这样场合极有可能每次申请锁的线程都是不相同的,因此这种场合下不应该使用偏向锁,否则会得不偿失,需要注意的是,偏向锁失败后,并不会立即膨胀为重量级锁,而是先升级为轻量级锁。 + +### 轻量级锁 + +若偏向锁失败,虚拟机并不会立即升级为重量级锁,它还会尝试使用一种称为轻量级锁的优化手段 (1.6 之后加入的),此时 Mark Word 的结构也变为轻量级锁的结构。 + +轻量级锁能够提升程序性能的依据是 “对绝大部分的锁,在整个同步周期内都不存在竞争”,注意这是经验数据。需要了解的是,轻量级锁所适应的场景是线程交替执行同步块的场合,如果存在同一时间访问同一锁的场合,就会导致轻量级锁膨胀为重量级锁。 + +### 自旋锁 + +轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。这是基于在大多数情况下,线程持有锁的时间都不会太长,如果直接挂起操作系统层面的线程可能会得不偿失,毕竟操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,因此自旋锁会假设在不久将来,当前的线程可以获得锁,因此虚拟机会让当前想要获取锁的线程做几个空循环 (这也是称为自旋的原因),一般不会太久,可能是 50 个循环或 100 循环,在经过若干次循环后,如果得到锁,就顺利进入临界区。如果还不能获得锁,那就会将线程在操作系统层面挂起,这就是自旋锁的优化方式,这种方式确实也是可以提升效率的。最后没办法也就只能升级为重量级锁了。 + +### 锁消除 + +消除锁是虚拟机另外一种锁的优化,这种优化更彻底,Java 虚拟机在 JIT 编译(Just In Time)时,通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间。 + +## 2.5 synchronized 的可重入性 + +从互斥锁的设计上来说,当一个线程试图操作一个由其他线程持有的对象锁的临界资源时,将会处于阻塞状态,但当一个线程再次请求自己持有对象锁的临界资源时,这种情况属于重入锁,请求将会成功。 + + java 中 synchronized 是基于原子性的内部锁机制,是可重入的,因此在一个线程调用 synchronized 方法的同时在其方法体内部调用该对象另一个 synchronized 方法,也就是说一个线程得到一个对象锁后再次请求该对象锁,是允许的,这就是 synchronized 的可重入性。 + +```java +public class AccountingSync implements Runnable{ + static AccountingSync instance=new AccountingSync(); + static int i=0; + static int j=0; + @Override + public void run() { + for(int j=0;j<1000000;j++){ + + //this,当前实例对象锁 + synchronized(this){ + i++; + increase();//synchronized的可重入性 + } + } + } + + public synchronized void increase(){ + j++; + } + + + public static void main(String[] args) throws InterruptedException { + Thread t1=new Thread(instance); + Thread t2=new Thread(instance); + t1.start(); + t2.start(); + + t1.join(); + t2.join(); + } +} +``` + +在获取当前实例对象锁后进入 synchronized 代码块执行同步代码,并在代码块中调用了当前实例对象的另外一个 synchronized 方法,再次请求当前实例锁时,将被允许,进而执行方法体代码,这就是重入锁最直接的体现。 + +当子类继承父类时,子类也是可以通过可重入锁调用父类的同步方法。注意由于 synchronized 是基于 monitor 实现的,因此每次重入,monitor 中的计数器仍会加 1。 + +# 三、线程中断与 synchronized + +在 Java 中,提供了以下 3 个有关线程中断的方法: + +```java +//中断线程(实例方法) +public void Thread.interrupt(); + +//判断线程是否被中断(实例方法) +public boolean Thread.isInterrupted(); + +//判断是否被中断并清除当前中断状态(静态方法) +public static boolean Thread.interrupted(); +``` + +线程的中断操作对于正在等待获取的锁对象的 synchronized 方法或者代码块并不起作用,也就是对于 synchronized 来说,如果一个线程在等待锁,那么结果只有两种,要么它获得这把锁继续执行,要么它就保存等待,即使调用中断线程的方法,也不会生效。 + +# 四、等待唤醒机制与 synchronized + +等待唤醒机制本篇主要指的是 notify/notifyAll 和 wait 方法,在使用这 3 个方法时,必须处于 synchronized 代码块或者 synchronized 方法中,否则就会抛出 IllegalMonitorStateException 异常,这是因为调用这几个方法前必须拿到当前对象的监视器 monitor 对象,也就是说 notify/notifyAll 和 wait 方法依赖于 monitor 对象,据前文可知,monitor 存在于对象头的 Mark Word 中 (存储 monitor 引用指针),而 synchronized 关键字可以获取 monitor ,这也就是为什么 notify/notifyAll 和 wait 方法必须在 synchronized 代码块或者 synchronized 方法调用的原因。 + +```java +synchronized (obj) { + obj.wait(); + obj.notify(); + obj.notifyAll(); + } +``` + +与 sleep 方法不同的是 wait 方法调用完成后,线程将被暂停,但 wait 方法将会释放当前持有的监视器锁 (monitor),直到有线程调用 notify/notifyAll 方法后方能继续执行,而 sleep 方法只让线程休眠并不释放锁。同时 notify/notifyAll 方法调用后,并不会马上释放监视器锁,而是在相应的 synchronized(){}/synchronized 方法执行结束后才自动释放锁。 + +# 引用: + +[深入理解Java并发之synchronized实现原理](https://blog.csdn.net/javazejian/article/details/72828483) \ No newline at end of file diff --git a/week_03/60/volatile-060.md b/week_03/60/volatile-060.md new file mode 100644 index 0000000..1858b3c --- /dev/null +++ b/week_03/60/volatile-060.md @@ -0,0 +1,111 @@ +# 一、volatile关键字的作用 + +- **保证变量的可见性**(即保证在多线程操作中某一变量在内存中的可见性); +- **禁止指令重排序**; + +## 1.1 保证变量的可见性 + +保证了不同线程对该变量操作的内存可见性。 + +这里保证可见性是不等同于 volatile 变量并发操作的安全性,保证可见性具体一点解释: + +**线程`写` volatile 变量的过程:**JMM 会把该线程对应的工作内存中的共享变量刷新到主内存 + +- 改变线程工作内存中 volatile 变量副本的值 +- 将改变后的副本的值从工作内存刷新到主内存 + +**线程`读` volatile 变量的过程:**JMM 会把该线程对应的工作内存置为无效,线程接下来将从主内存中读取共享变量到工作内存。 + +- 从主内存中读取 volatile 变量的最新值到线程的工作内存中 +- 从工作内存中读取 volatile 变量的副本 + +但是如果多个线程同时把更新后的变量值同时刷新回主内存,可能导致得到的值不是预期结果: + +*例子*: 定义 volatile int count = 0,2 个线程同时执行 count++ 操作,每个线程都执行 500 次,最终结果小于 1000,原因是每个线程执行 count++ 需要以下 3 个步骤: + +- 步骤 1 线程从主内存读取最新的 count 的值 +- 步骤 2 执行引擎把 count 值加 1,并赋值给线程工作内存 +- 步骤 3 线程工作内存把 count 值保存到主内存 有可能某一时刻 2 个线程在步骤 1 读取到的值都是 100,执行完步骤 2 得到的值都是 101,最后刷新了 2 次 101 保存到主内存。 + +## 1.2 禁止进行指令重排序 + +禁止重排序的规则如下: + +- 当程序执行到 volatile 变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见;在其后面的操作肯定还没有进行; +- 在进行指令优化时,不能将在对 volatile 变量访问的语句放在其后面执行,也不能把 volatile 变量后面的语句放到其前面执行。 + +普通的变量仅仅会保证该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证赋值操作的顺序与程序代码中的执行顺序一致。 + +*例子*: + +```java +volatile boolean initialized = false; + +// 下面代码线程A中执行 +// 读取配置信息,当读取完成后将initialized设置为true以通知其他线程配置可用 +doSomethingReadConfg(); +initialized = true; + +// 下面代码线程B中执行 +// 等待initialized 为true,代表线程A已经把配置信息初始化完成 +while (!initialized) { + sleep(); +} +// 使用线程A初始化好的配置信息 +doSomethingWithConfig(); +``` + +上面代码中如果定义 `initialized` 变量时没有使用 volatile 修饰,就有可能会由于指令重排序的优化,导致线程 A 中最后一句代码 "`initialized = true`" 在 “`doSomethingReadConfg()`” 之前被执行,这样会导致线程 B 中使用配置信息的代码就可能出现错误,而 volatile 关键字就禁止重排序的语义可以避免此类情况发生。 + +# 二、volatile关键字底层实现机制 + +如果把加入 volatile 关键字的代码和未加入 volatile 关键字的代码都生成汇编代码,会发现加入 volatile 关键字的代码会多出一个 lock 前缀指令,lock 前缀指令实际相当于一个内存屏障。从内存语义来说就是在编译期生成字节码时,会在指令序列中增加内存屏障来保证,下面是基于保守策略的 JMM 内存屏障插入策略: + +![img](https://user-gold-cdn.xitu.io/2018/11/19/1672b9fcff24e95a?imageView2/0/w/1280/h/960/format/webp/ignore-error/1) + + + +- 在每个 volatile 写操作的前面插入一个 `StoreStore` 屏障。 该屏障除了保证了屏障之前的写操作和该屏障之后的写操作不能重排序,还会保证了 volatile 写操作之前,任何的读写操作都会先于 volatile 被提交。 +- 在每个 volatile 写操作的后面插入一个 `StoreLoad` 屏障。 该屏障除了使 volatile 写操作不会与之后的读操作重排序外,还会刷新处理器缓存,使 volatile 变量的写更新对其他线程可见。 +- 在每个 volatile 读操作的前面插入一个 `LoadLoad` 屏障。 该屏障除了使 volatile 读操作不会与之前的写操作发生重排序外,还会刷新处理器缓存,使 volatile 变量读取的为最新值。 +- 在每个 volatile 读操作的后面插入一个 `LoadStore` 屏障。 该屏障除了禁止了 volatile 读操作与其之后的任何写操作进行重排序,还会刷新处理器缓存,使其他线程 volatile 变量的写更新对 volatile 读操作的线程可见。 + +# 三、volatile关键字使用场景 + +- 状态量标记(参见上文**1.2 禁止进行指令重排序**中例子); +- 单例模式的实现; + +*例子*:懒汉式(双重检查加锁版本) + +```java +public class Singleton{ + + //volitale关键字保证,当uniqueInstance变量被初始化成Singleton实例时,多个线程可以正确处理uniqueInstance变量 + //底层机制既是利用volatile关键字来禁止初始化时指令重排序 + private volatile static Singleton uniqueInstance; + + public Singleton(){ + } + + public static Singleton getInstance(){ + //检车实例,如果不存在则进入同步代码块 + if(uniqueInstance == null){ + //只有第一次加载才会执行这里的代码 + synchronized(Singleton.class){ + //进入同步代码块后,再一次检查uniqueInstance,如果为null才创建实例 + if(uniqueInstance == null){ + uniqueInstance = new Singleton(); + } + } + } + return uniqueInstance; + } + +} +``` + + + +# 引用: + +[理解Java内存模型--北聊科技](https://juejin.im/post/5bf2977751882505d840321d#comment) \ No newline at end of file diff --git "a/week_03/60/\345\206\205\345\255\230\346\250\241\345\236\213-060.md" "b/week_03/60/\345\206\205\345\255\230\346\250\241\345\236\213-060.md" new file mode 100644 index 0000000..6f7f4d4 --- /dev/null +++ "b/week_03/60/\345\206\205\345\255\230\346\250\241\345\236\213-060.md" @@ -0,0 +1,239 @@ +# 一、内存模型产生背景 + +在介绍 Java 内存模型之前,先了解一下物理计算机中的并发问题,理解这些问题可以搞清楚内存模型产生的背景。物理机遇到的并发问题与虚拟机中的情况有不少相似之处,物理机的解决方案对虚拟机的实现有相当的参考意义。 + +## **1.物理机的并发问题:** + +- **硬件的效率问题** +- **缓存一致性问题** +- **代码乱序执行优化问题** + +**1.1硬件的效率问题** + +计算机处理器处理绝大多数运行任务都不可能只靠处理器 “计算” 就能完成,处理器至少需要与**内存交互**,如读取运算数据、存储运算结果,这个 I/O 操作很难消除 (无法仅靠寄存器完成所有运算任务)。 + +由于计算机的存储设备与处理器的运算速度有几个数量级的差距,为了避免处理器等待缓慢的内存读写操作完成,现代计算机系统通过加入一层读写速度尽可能接近处理器运算速度的高速缓存。缓存作为内存和处理器之间的缓冲:将运算需要使用到的数据复制到缓存中,让运算能快速运行,当运算结束后再从缓存同步回内存之中。 + +![](https://user-gold-cdn.xitu.io/2018/11/19/1672b9fc595c4f0f?imageView2/0/w/1280/h/960/format/webp/ignore-error/1) + +1.2**缓存一致性问题** + +基于高速缓存的存储系统交互很好地解决了处理器与内存速度的矛盾,但是也为计算机系统带来更高的复杂度,因为引入了一个新问题:**缓存一致性。** + +在多处理器的系统中 (或者单处理器多核的系统),每个处理器(每个核) 都有自己的高速缓存,而它们有共享同一主内存(Main Memory)。当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致。 为此,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议进行操作,来维护缓存的一致性。 + +![img](https://user-gold-cdn.xitu.io/2018/11/19/1672b9fc57c248f4?imageView2/0/w/1280/h/960/format/webp/ignore-error/1) + +1.3 **代码乱序执行优化问题** + +为了使得处理器内部的运算单元尽量被充分利用,提高运算效率,处理器可能会对输入的代码进行乱序执行,处理器会在计算之后将乱序执行的结果重组,**乱序优化可以保证在单线程下该执行结果与顺序执行的结果是一致的**,但不保证程序中各个语句计算的先后顺序与输入代码中的顺序一致。 + +![img](https://user-gold-cdn.xitu.io/2018/11/19/1672b9fc59738a21?imageView2/0/w/1280/h/960/format/webp/ignore-error/1) + + + +乱序执行技术是处理器为提高运算速度而做出违背代码原有顺序的优化。在单核时代,处理器保证做出的优化不会导致执行结果远离预期目标,但在多核环境下却并非如此。 + +多核环境下, 如果存在一个核的计算任务依赖另一个核 计的算任务的中间结果,而且对相关数据读写没做任何防护措施,那么其顺序性并不能靠代码的先后顺序来保证,处理器最终得出的结果和我们逻辑得到的结果可能会大不相同。 + +![img](https://user-gold-cdn.xitu.io/2018/11/19/1672b9fc57e69354?imageView2/0/w/1280/h/960/format/webp/ignore-error/1) + + + +以上图为例进行说明:CPU 的 core2 中的逻辑 B 依赖 core1 中的逻辑 A 先执行 + +- 正常情况下,逻辑 A 执行完之后再执行逻辑 B。 +- 在处理器乱序执行优化情况下,有可能导致 flag 提前被设置为 true,导致逻辑 B 先于逻辑 A 执行。 + + + +# 二、Java 内存模型的组成分析 + +## **2.1 内存模型概念** + +为了更好解决上面提到系列问题,内存模型被总结提出,我们可以把内存模型理解为**在特定操作协议下,对特定的内存或高速缓存进行读写访问的过程抽象**。 + +不同架构的物理计算机可以有不一样的内存模型,Java 虚拟机也有自己的内存模型。Java 虚拟机规范中试图定义一种 Java 内存模型(Java Memory Model,简称 JMM)来**屏蔽掉各种硬件和操作系统的内存访问差异**,以实现**让 Java 程序在各种平台下都能达到一致的内存访问效果**,不必因为不同平台上的物理机的内存模型的差异,对各平台定制化开发程序。 + +更具体一点说,Java 内存模型提出目标在于,**定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节**。此处的变量 (Variables) 与 Java 编程中所说的变量有所区别,它包括了实例字段、静态字段和构成数值对象的元素,但不包括局部变量与方法参数,因为后者是线程私有的。(如果局部变量是一个 reference 类型,它引用的对象在 Java 堆中可被各个线程共享,但是 reference 本身在 Java 栈的局部变量表中,它是线程私有的)。 + +## 2.2 Java 内存模型的组成 + +- 主内存 Java 内存模型规定了所有变量都存储在主内存 (Main Memory) 中(此处的主内存与介绍物理硬件的主内存名字一样,两者可以互相类比,但此处仅是虚拟机内存的一部分)。 +- 工作内存 每条线程都有自己的工作内存 (Working Memory,又称本地内存,可与前面介绍的处理器高速缓存类比),线程的工作内存中保存了该线程使用到的变量的主内存中的共享变量的副本拷贝。**工作内存是 JMM 的一个抽象概念,并不真实存在**。它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化。 + +Java 内存模型抽象示意图如下: + +![img](https://user-gold-cdn.xitu.io/2018/11/19/1672b9fc5988f121?imageView2/0/w/1280/h/960/format/webp/ignore-error/1) + +## 2.3 JVM 内存操作的并发问题 + +结合前面介绍的物理机的处理器处理内存的问题,可以类比总结出 JVM 内存操作的问题,下面介绍的 Java 内存模型的执行处理将围绕解决这 2 个问题展开: + +- **工作内存数据一致性** 各个线程操作数据时会保存使用到的主内存中的共享变量副本,当多个线程的运算任务都涉及同一个共享变量时,将导致各自的的共享变量副本不一致,如果真的发生这种情况,数据同步回主内存以谁的副本数据为准? Java 内存模型主要通过一系列的数据同步协议、规则来保证数据的一致性,后面再详细介绍。 +- **指令重排序优化** Java 中重排序通常是编译器或运行时环境为了优化程序性能而采取的对指令进行重新排序执行的一种手段。重排序分为两类:**编译期重排序和运行期重排序**,分别对应编译时和运行时环境。 同样的,指令重排序不是随意重排序,它需要满足以下两个条件: + - 在单线程环境下不能改变程序运行的结果 即时编译器(和处理器)需要保证程序能够遵守 as-if-serial 属性。通俗地说,就是在单线程情况下,要给程序一个顺序执行的假象。即经过重排序的执行结果要与顺序执行的结果保持一致。 + - 存在数据依赖关系的不允许重排序 + +多线程环境下,如果线程处理逻辑之间存在依赖关系,有可能因为指令重排序导致运行结果与预期不同,后面再展开 Java 内存模型如何解决这种情况。 + +# 三、Java 内存间的交互操作 + +在理解 Java 内存模型的系列协议、特殊规则之前,我们先理解 Java 中内存间的交互操作。 + +## 3.1 交互操作流程 + +为了更好理解内存的交互操作,以线程通信为例,我们看看具体如何进行线程间值的同步: + +![img](https://user-gold-cdn.xitu.io/2018/11/19/1672b9fcfe5ffb33?imageView2/0/w/1280/h/960/format/webp/ignore-error/1) + + + +线程 1 和线程 2 都有主内存中共享变量 x 的副本,初始时,这 3 个内存中 x 的值都为 0。线程 1 中更新 x 的值为 1 之后同步到线程 2 主要涉及 2 个步骤: + +- 线程 1 把线程工作内存中更新过的 x 的值刷新到主内存中 +- 线程 2 到主内存中读取线程 1 之前已更新过的 x 变量 + +从整体上看,这 2 个步骤是线程 1 在向线程 2 发消息,这个通信过程必须经过主内存。线程对变量的所有操作(读取,赋值)都必须在**工作内存中**进行。不同线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成,实现各个线程提供共享变量的可见性。 + +## 3.2 内存交互的基本操作 + +关于主内存与工作内存之间的具体交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存之类的实现细节,Java 内存模型中定义了下面介绍 8 种操作来完成。 + +虚拟机实现时必须保证下面介绍的每种操作都是原子的,不可再分的 (对于 double 和 long 型的变量来说,load、store、read、和 write 操作在某些平台上允许有例外,后面会介绍)。 + +**8 种基本操作** + +![img](https://user-gold-cdn.xitu.io/2018/11/19/1672b9fcfecdf9c5?imageView2/0/w/1280/h/960/format/webp/ignore-error/1) + + + +- lock (锁定) 作用于**主内存**的变量,它把一个变量标识为一条线程独占的状态。 +- unlock (解锁) 作用于**主内存**的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。 +- read (读取) 作用于**主内存**的变量,它把一个变量的值从主内存**传输**到线程的工作内存中,以便随后的 load 动作使用。 +- load (载入) 作用于**工作内存**的变量,它把 read 操作从主内存中得到的变量值放入工作内存的变量副本中。 +- use (使用) 作用于**工作内存**的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值得字节码指令时就会执行这个操作。 +- assign (赋值) 作用于**工作内存**的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。 +- store (存储) 作用于**工作内存**的变量,它把工作内存中一个变量的值传送到主内存中,以便随后 write 操作使用。 +- write (写入) 作用于**主内存**的变量,它把 store 操作从工作内存中得到的变量的值放入主内存的变量中。 + +# 四、Java 内存模型运行规则 + +## 4.1 内存交互基本操作的 3 个特性 + +在介绍内存的交互的具体的 8 种基本操作之前,有必要先介绍一下操作的 3 个特性,Java 内存模型是围绕着在并发过程中如何处理这 3 个特性来建立的,这里先给出定义和基本实现的简单介绍,后面会逐步展开分析。 + +- **原子性 (Atomicity)** **即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行**。即使在多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程所干扰。 +- **可见性 (Visibility)** **是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值**。 正如上面 “交互操作流程” 中所说明的一样,JMM 是通过在线程 1 变量工作内存修改后将新值同步回主内存,线程 2 在变量读取前从主内存刷新变量值,这种**依赖主内存作为传递媒介**的方式来实现可见性。 +- **有序性 (Ordering)** 有序性规则表现在以下两种场景: 线程内和线程间 + - 线程内 从某个线程的角度看方法的执行,指令会按照一种叫 “串行”(as-if-serial)的方式执行,此种方式已经应用于顺序编程语言。 + - 线程间 这个线程 “观察” 到其他线程并发地执行非同步的代码时,由于指令重排序优化,任何代码都有可能交叉执行。唯一起作用的约束是:对于同步方法,同步块 (synchronized 关键字修饰) 以及 volatile 字段的操作仍维持相对有序。 + +Java 内存模型的一系列运行规则看起来有点繁琐,但总结起来,是**围绕原子性、可见性、有序性特征建立**。归根究底,是为实现共享变量的在多个线程的工作内存的**数据一致性**,多线程并发,指令重排序优化的环境中程序能如预期运行。 + +## 4.2 happens-before 关系 + +介绍系列规则之前,首先了解一下 `happens-before` 关系--->用于描述下 2 个操作的**`内存可见性`**:**如果操作 A `happens-before` 操作 B,那么 A 的结果对 B 可见**。 + +`happens-before` 关系的分析需要分为**单线程和多线程**的情况: + +- **单线程下的 happens-before** 字节码的先后顺序天然包含 happens-before 关系:因为单线程内共享一份工作内存,不存在数据一致性的问题。 在程序控制流路径中靠前的字节码 happens-before 靠后的字节码,即靠前的字节码执行完之后操作结果对靠后的字节码可见。然而,这并不意味着前者一定在后者之前执行。实际上,如果后者不依赖前者的运行结果,那么它们可能会被重排序。 +- **多线程下的 happens-before** 多线程由于每个线程有共享变量的副本,如果没有对共享变量做同步处理,线程 1 更新执行操作 A 共享变量的值之后,线程 2 开始执行操作 B,此时操作 A 产生的结果对操作 B 不一定可见。 + +为了方便程序开发,Java 内存模型实现了下述支持 happens-before 关系的操作: + +- **程序次序规则:** 一个线程内,按照代码顺序,书写在前面的操作 happens-before 书写在后面的操作。 +- **锁定规则:** 一个 unLock 操作 happens-before 后面对同一个锁的 lock 操作。 +- **volatile 变量规则:** 对一个变量的写操作 happens-before 后面对这个变量的读操作。 +- **传递规则:** 如果操作 A happens-before 操作 B,而操作 B 又 happens-before 操作 C,则可以得出操作 A happens-before 操作 C。 +- **线程启动规则:** Thread 对象的 start() 方法 happens-before 此线程的每个一个动作。 +- **线程中断规则:** 对线程 interrupt() 方法的调用 happens-before 被中断线程的代码检测到中断事件的发生。 +- **线程终结规则:** 线程中所有的操作都 happens-before 线程的终止检测,我们可以通过 Thread.join() 方法结束、Thread.isAlive() 的返回值手段检测到线程已经终止执行。 +- **对象终结规则:** 一个对象的初始化完成 happens-before 他的 finalize() 方法的开始 + +## 4.3 内存屏障 + +Java 中如何保证底层操作的有序性和可见性?可以通过内存屏障。 + +内存屏障是被插入两个 CPU 指令之间的一种指令,用来禁止处理器指令发生重排序(像屏障一样),从而保障**有序性**的。另外,为了达到屏障的效果,它也会使处理器写入、读取值之前,将主内存的值写入高速缓存,清空无效队列,从而保障**可见性**。 + +举个例子: + +```java +Store1; +Store2; +Load1; +StoreLoad; //内存屏障 +Store3; +Load2; +Load3; +复制代码 +``` + +对于上面的一组 CPU 指令(Store 表示写入指令,Load 表示读取指令),StoreLoad 屏障之前的 Store 指令无法与 StoreLoad 屏障之后的 Load 指令进行交换位置,即**重排序**。但是 StoreLoad 屏障之前和之后的指令是可以互换位置的,即 Store1 可以和 Store2 互换,Load2 可以和 Load3 互换。 + +常见有 4 种屏障 + +- `LoadLoad` 屏障: 对于这样的语句 `Load1->LoadLoad-> Load2`,在 Load2 及后续读取操作要读取的数据被访问前,保证 Load1 要读取的数据被读取完毕。 +- `StoreStore` 屏障: 对于这样的语句 `Store1->StoreStore->Store2`,在 Store2 及后续写入操作执行前,保证 Store1 的写入操作对其它处理器可见。 +- `LoadStore` 屏障: 对于这样的语句 `Load1->LoadStore->Store2`,在 Store2 及后续写入操作被执行前,保证 Load1 要读取的数据被读取完毕。 +- `StoreLoad` 屏障: 对于这样的语句 `Store1->StoreLoad->Load2`,在 Load2 及后续所有读取操作执行前,保证 Store1 的写入对所有处理器可见。它的**开销是四种屏障中最大的(冲刷写缓冲器,清空无效化队列)**。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能。 + +Java 中对内存屏障的使用在一般的代码中不太容易见到,常见的有 `volatile` 和 `synchronized` 关键字修饰的代码块 (后面再展开介绍),还可以通过 `Unsafe` 这个类来使用内存屏障。 + +## 4.4 8 种操作同步的规则 + +JMM 在执行前面介绍 8 种基本操作时,为了保证内存间数据一致性,JMM 中规定需要满足以下规则: + +- 规则 1:如果要把一个变量从主内存中复制到工作内存,就需要按顺序的执行 read 和 load 操作,如果把变量从工作内存中同步回主内存中,就要按顺序的执行 store 和 write 操作。但 Java 内存模型只要求上述操作必须按顺序执行,而没有保证必须是连续执行。 +- 规则 2:不允许 read 和 load、store 和 write 操作之一单独出现。 +- 规则 3:不允许一个线程丢弃它的最近 assign 的操作,即变量在工作内存中改变了之后必须同步到主内存中。 +- 规则 4:不允许一个线程无原因的(没有发生过任何 assign 操作)把数据从工作内存同步回主内存中。 +- 规则 5:一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load 或 assign )的变量。即就是对一个变量实施 use 和 store 操作之前,必须先执行过了 load 或 assign 操作。 +- 规则 6:一个变量在同一个时刻只允许一条线程对其进行 lock 操作,但 lock 操作可以被同一条线程重复执行多次,多次执行 lock 后,只有执行相同次数的 unlock 操作,变量才会被解锁。所以 lock 和 unlock 必须成对出现。 +- 规则 7:如果对一个变量执行 lock 操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行 load 或 assign 操作初始化变量的值。 +- 规则 8:如果一个变量事先没有被 lock 操作锁定,则不允许对它执行 unlock 操作;也不允许去 unlock 一个被其他线程锁定的变量。 +- 规则 9:对一个变量执行 unlock 操作之前,必须先把此变量同步到主内存中(执行 store 和 write 操作) + +看起来这些规则有些繁琐,其实也不难理解: + +- 规则 1、规则 2 工作内存中的共享变量作为主内存的副本,主内存变量的值同步到工作内存需要 read 和 load 一起使用,工作内存中的变量的值同步回主内存需要 store 和 write 一起使用,这 2 组操作各自都是是一个固定的**有序**搭配,不允许单独出现。 +- 规则 3、规则 4 由于工作内存中的共享变量是主内存的副本,为**保证数据一致性**,当工作内存中的变量被字节码引擎重新赋值,必须同步回主内存。如果工作内存的变量没有被更新,不允许无原因同步回主内存。 +- 规则 5 由于工作内存中的共享变量是主内存的副本,必须从主内存诞生。 +- 规则 6、7、8、9 为了并发情况下安全使用变量,线程可以基于 lock 操作独占主内存中的变量,其他线程不允许使用或 unlock 该变量,直到变量被线程 unlock。 + +## 4.5 volatile 型变量的特殊规则 + +volatile 的中文意思是不稳定的,易变的,用 volatile 修饰变量是为了保证变量的可见性。 + +### volatile 的语义 + +- 保证可见性 +- 禁止进行指令重排序 + +### volatile 型变量使用场景 + +总结起来,就是 “一次写入,到处读取”,某一线程负责更新变量,其他线程只读取变量 (不更新变量),并根据变量的新值执行相应逻辑。例如状态标志位更新,观察者模型变量值发布。 + +## 4.6 final 型变量的特殊规则 + +我们知道,final 成员变量必须在声明的时候初始化或者在构造器中初始化,否则就会报编译错误。 final 关键字的可见性是指:被 final 修饰的字段在声明时或者构造器中,一旦初始化完成,那么在其他线程无须同步就能正确看见 final 字段的值。这是因为一旦初始化完成,final 变量的值立刻回写到主内存。 + +## 4.7 synchronized 的特殊规则 + +通过 synchronized 关键字包住的代码区域,对数据的读写进行控制: + +- 读数据 当线程进入到该区域读取变量信息时,对数据的读取也不能从工作内存读取,只能从内存中读取,保证读到的是最新的值。 +- 写数据 在同步区内对变量的写入操作,在离开同步区时就将当前线程内的数据刷新到内存中,保证更新的数据对其他线程的可见性。 + +## 4.8 long 和 double 型变量的特殊规则 + +Java 内存模型要求 lock、unlock、read、load、assign、use、store、write 这 8 种操作都具有原子性,但是对于 64 位的数据类型 (long 和 double),在模型中特别定义相对宽松的规定:允许虚拟机将没有被 volatile 修饰的 64 位数据的读写操作分为 2 次 32 位的操作来进行。也就是说虚拟机可选择不保证 64 位数据类型的 load、store、read 和 write 这 4 个操作的原子性。由于这种非原子性,有可能导致其他线程读到同步未完成的“32 位的半个变量” 的值。 + +不过实际开发中,Java 内存模型强烈建议虚拟机把 64 位数据的读写实现为具有原子性,目前各种平台下的商用虚拟机都选择把 64 位数据的读写操作作为原子操作来对待,因此我们在编写代码时一般不需要把用到的 long 和 double 变量专门声明为 volatile。 + + + +# 引用: + +[理解Java内存模型--北聊科技](https://juejin.im/post/5bf2977751882505d840321d#comment) \ No newline at end of file diff --git "a/week_03/60/\345\210\206\345\270\203\345\274\217\351\224\201-060.md" "b/week_03/60/\345\210\206\345\270\203\345\274\217\351\224\201-060.md" new file mode 100644 index 0000000..db2c0d7 --- /dev/null +++ "b/week_03/60/\345\210\206\345\270\203\345\274\217\351\224\201-060.md" @@ -0,0 +1,48 @@ +**分布式锁**,是指在分布式的部署环境下,通过锁机制来让多客户端互斥的对共享资源进行访问,可以**防止**分布式系统中的**多个进程之间相互干扰**。 + +# 一、为什么要使用分布式锁 + +![img](https://imgconvert.csdnimg.cn/aHR0cHM6Ly93d3cuZnVudGwuY29tL2Fzc2V0cy9MdXNpZmVyMjAxODEwMTcwMDAxLnBuZw?x-oss-process=image/format,png) + +- 成员变量 A 存在 JVM1、JVM2、JVM3 三个 JVM 内存中; +- 成员变量 A 同时都会在 JVM 分配一块内存,三个请求发过来同时对这个变量操作,显然结果是不对的 +- 不是同时发过来,三个请求分别操作三个不同 JVM 内存区域的数据,变量 A 之间不存在共享,也不具有可见性,处理的结果也是不对的 注:该成员变量 A 是一个有状态的对象 + +如果业务中确实存在这个场景的话,我们就需要一种方法解决这个问题,**这就是分布式锁要解决的问题** + +# 二、分布式锁应该具备哪些条件 + +- 在分布式系统环境下,一个方法在同一时间只能被一个机器的一个线程执行 +- 高可用的获取锁与释放锁 +- 高性能的获取锁与释放锁 +- 具备可重入特性(可理解为重新进入,由多于一个任务并发使用,而不必担心数据错误) +- 具备锁失效机制,防止死锁 +- 具备非阻塞锁特性,即没有获取到锁将直接返回获取锁失败 + +# **三、分布式锁的实现有哪些?** + +- **Memcached 分布式锁** + +利用 Memcached 的 **add 命令**。此命令是原子性操作,只有在 key 不存在的情况下,才能 add 成功,也就意味着线程得到了锁。 + +- **Redis 分布式锁** + +和 Memcached 的方式类似,利用 Redis 的 **setnx 命令**。此命令同样是原子性操作,只有在 key 不存在的情况下,才能 set 成功。(setnx 命令并不完善,后续会介绍替代方案) + +- **Zookeeper 分布式锁** + +利用 Zookeeper 的**顺序临时节点**,来实现分布式锁和等待队列。Zookeeper 设计的初衷,就是为了实现分布式锁服务的。 + +- **Chubby** + +Google 公司实现的粗粒度分布式锁服务,底层利用了 Paxos 一致性算法。 + + + + + +引用: + +[什么是分布式锁?--斗士(Carroll)](https://blog.csdn.net/qq_40722827/article/details/102993655) + +[**再有人问你分布式锁是什么,就把这篇文章发给他**--中华石衫](https://mp.weixin.qq.com/s/32lWC4PA7nF13_2wRo6i3Q) \ No newline at end of file -- Gitee