Java 并发编程

Java 并发编程

1. Java 内存模型

1.1 Java 内存模型设计

1.1.1 硬件内存架构

现代计算机硬件架构的简单图示:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-OvvGk4sZ-1670252429629)(C:\Users\802612523\Pictures\thread\缓存.png)]

引入缓存的原因是因为 CPU 与内存的速度差异很大,需要靠预读数据至缓存来提升效率。

从 cpu 到大约需要的时钟周期
寄存器1 cycle
L13~4 cycle
L210~20 cycle
L340~45 cycle
内存120~240 cycle

在这里插入图片描述

1.1.2 缓存行伪共享

缓存以缓存行为单位,每个缓存行对应着一块内存,一般是 64 byte(8 个 long)

缓存的加入会造成数据副本的产生,即同一份数据会缓存在不同核心的缓存行中

CPU 要保证数据的一致性,如果某个 CPU 核心更改了数据,其它 CPU 核心对应的整个缓存行必须失效,缓存一致性协议有MSI、MESI(IllinoisProtocol)、MOSI、Synapse、Firefly 及 DragonProtocol 等等

在这里插入图片描述

如上两个 CPU 分别将 Cell 数组读到缓存行中,一个 CPU 中同一缓存行中的数组元素发生变化,则会强制使另外一个 CPU 对应缓存行失效,Java中可通过注解 @sun.misc.Contended 解决缓存行伪共享问题,原理是在使用此注解的对象或字段的前后各增加128字节大小的padding,使用2倍于大多数硬件缓存行的大小来避免相邻扇区预取导致的伪共享冲突。

在这里插入图片描述

LongAddr 正是使用了该技术解决缓存行伪共享问题,提升性能

1.1.3 LongAddr

LongAddr 核心思想是在多线程累加时,创建不同累加单元累加,不同线程对象不同累加单元,整体计数时,将 base 以及各个累加单元相加即可。

累加单元使用了 @sun.misc.Contended 注解防止缓存伪共享问题,进一步提升了累加性能。

@sun.misc.Contended 
static final class Cell {
    volatile long value;
    Cell(long x) { value = x; }
    final boolean cas(long cmp, long val) {
        return UNSAFE.compareAndSwapLong(this, valueOffset, cmp, val);
    }

    // Unsafe mechanics
    private static final sun.misc.Unsafe UNSAFE;
    private static final long valueOffset;
    static {
        try {
            UNSAFE = sun.misc.Unsafe.getUnsafe();
            Class<?> ak = Cell.class;
            valueOffset = UNSAFE.objectFieldOffset
                (ak.getDeclaredField("value"));
        } catch (Exception e) {
            throw new Error(e);
        }
    }
}
1.1.4 JMM

Java内存模型与硬件内存架构之间存在差异。硬件内存架构没有区分线程栈和堆。对于硬件,所有的线程栈和堆都分布在主内存中。部分线程栈和堆可能有时候会出现在CPU缓存中和CPU内部的寄存器中。如下图所示:

在这里插入图片描述

从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:

  • 线程之间的共享变量存储在主内存(Main Memory)中
  • 每个线程都有一个私有的本地内存(Local Memory),本地内存是JMM的一个抽象概念,并不真实存在,它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。本地内存中存储了该线程读/写共享变量的拷贝副本。
  • 从更低的层次来说,主内存就是硬件的内存,而为了获取更好的运行速度,虚拟机及硬件系统可能会让工作内存优先存储于寄存器和高速缓存中。
  • Java内存模型中的线程的工作内存(working memory)是cpu的寄存器和高速缓存的抽象描述

请添加图片描述
在这里插入图片描述

1.2 JMM 面临的问题

当对象和变量被存放在计算机中各种不同的内存区域中时,就可能会出现一些具体的问题。Java内存模型建立所围绕的问题:在多线程并发过程中,如何处理多线程读同步问题与可见性(多线程缓存与指令重排序)、多线程写同步问题与原子性(多线程竞争race condition)。

1.2.1 可见性问题
static boolean flag = true;
//static volatile boolean flag = true;

public static void main(String[] args) {
    new Thread(() -> {
        while (flag) {

        }
        log.debug("done");
    }).start();

    ThreadUtils.sleep(1, TimeUnit.SECONDS);
    log.debug("done");
    flag = false;
}
1.2.2 有序性问题
public class Singleton {
    private Singleton(){}

    private static Singleton instance;
    //private static volatile Singleton instance;

    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

很常见的基于 DCL 的单例实现,这里想着重说明一下语句 instance = new Singleton();,该语句在执行时会分为三个步骤进行:

  1. 在内存中开辟一块地址
  2. 对象初始化
  3. 将指针指向这块内存地址

如果我们在一个线程中观察代码,代码都是顺序串行执行的,但是如果我们在一个线程中观察其他线程,其他线程中的执行都是乱序的。这句话说的是Java中的指令重排序现象。如果在新建Singleton对象的时候第2步和第3步发生了重排序,线程1将singleton指针指向了内存中的地址,但是此时我们的对象还没有初始化。这个时候线程2进来,看到singleton不是null,于是直接返回。这个时候错误就发生了:线程2拿到了一个没有经过初始化的对象。

使用 volatile 修饰变量可以解决可见性和有序性问题,可见性是由读写屏障解决的,读写屏障保证每次读取或者对 volatile 变量的赋值操作都会同步到主存,从而保证可见性。

1.2.3 原子性

原子性问题就很常见了,对线程并发访问临界区导致结果出错。通过同步可解决(乐观锁或者悲观锁)

2. 线程共享问题

2.1 线程共享带来的问题

线程出现问题的根本原因是因为线程上下文切换,导致线程里的指令没有执行完就切换执行其它线程了。

2.2 临界区

  • 一个程序运行多个线程本身是没有问题的
  • 问题出在多个线程访问共享资源
    • 多个线程读共享资源其实也没有问题
    • 在多个线程对共享资源读写操作时发生指令交错,就会出现问题
  • 一段代码块内如果存在对共享资源的多线程读写操作,称这段代码块为临界区
static int counter = 0;
static void increment()
    // 临界区
{
    counter++;
}
static void decrement()
    // 临界区
{
    counter--;
}

2.3 竞态条件

多个线程在临界区内执行,由于代码的执行序列不同而导致结果无法预测,称之为发生了竞态条件

2.4 解决并发的方法

为了避免临界区中的竞态条件发生,由多种手段可以达到。

  • 阻塞式解决方案:synchronized ,Lock
  • 非阻塞式解决方案:原子变量

3. Monitor

3.1 对象头

32 位虚拟机为例

普通对象的对象头结构如下,其中的 Klass Word 为指针,指向对应的 Class 对象;
普通对象

在这里插入图片描述

数组对象

在这里插入图片描述

Mark word结构

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-x7KWOEXV-1670252429641)(C:\Users\802612523\Pictures\thread\对象头结构.png)]

对象结构

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-05uzLzUS-1670252429642)(C:\Users\802612523\Pictures\thread\对象结构.png)]

3.2 Monitor 原理

Monitor 被翻译为监视器或者说管程
每个 Java 对象都可以关联一个 Monitor ,如果使用 synchronized 给对象上锁(重量级),该对象头的 Mark Word 中就被设置为指向 Monitor 对象的指针。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-XxiznAlz-1670252429643)(C:\Users\802612523\Pictures\thread\Monitor原理1.png)]

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-xvTpH1FY-1670252429643)(C:\Users\802612523\Pictures\thread\Monitor原理.png)]

  • 刚开始时 Monitor 中的 Owner 为 null
  • 当 Thread-2 执行 synchronized(obj){} 代码时就会将 Monitor 的所有者Owner 设置为 Thread-2,上锁成功,Monitor 中同一时刻只能有一个 Owner
  • 当 Thread-2 占据锁时,如果线程 Thread-3 ,Thread-4 也来执行synchronized(obj){} 代码,就会进入 EntryList(阻塞队列) 中变成BLOCKED(阻塞) 状态
  • Thread-2 执行完同步代码块的内容,然后唤醒 EntryList 中等待的线程来竞争锁,竞争时是非公平的
  • 图中 WaitSet 中的 Thread-0,Thread-1 是之前获得过锁,但条件不满足进入 WAITING 状态的线程

注意:synchronized 必须是进入同一个对象的 monitor 才有上述的效果不加 synchronized 的对象不会关联监视器,不遵从以上规则

3.3 Synchronized 原理

static final Object lock = new Object();
static int counter = 0;

public static void main (String[] args) {
    synchronized (lock) {
        counter++;
    }
}
public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=3, args_size=1
         0: getstatic     #2                  // Field lock:Ljava/lang/Object;
         3: dup
         4: astore_1
         5: monitorenter
         6: getstatic     #3                  // Field counter:I
         9: iconst_1
        10: iadd
        11: putstatic     #3                  // Field counter: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:
         from    to  target type
             6    16    19   any
            19    22    19   any

3.4 锁优化

3.4.1 轻量级锁

轻量级锁的使用场景是:如果一个对象虽然有多个线程要对它进行加锁,但是加锁的时间是错开的(也就是没有人可以竞争的),那么可以使用轻量级锁来进行优化。轻量级锁对使用者是透明的,即语法仍然是 synchronized ,假设有两个方法同步块,利用同一个对象加锁

static final Object obj = new Object();
public static void method1() {
     synchronized( obj ) {
         // 同步块 A
         method2();
     }
}
public static void method2() {
     synchronized( obj ) {
         // 同步块 B
     }
}
  1. 每次指向到 synchronized 代码块时,都会创建锁记录(Lock Record)对象,每个线程都会包括一个锁记录的结构,锁记录内部可以储存对象的 Mark Word 和对象引用 reference
    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-h8mcBH5M-1670252429644)(C:\Users\802612523\Pictures\thread\轻量级锁.png)]

  2. 让锁记录中的 Object reference 指向对象,并且尝试用 cas(compare and sweep) 替换 Object 对象的 Mark Word ,将 Mark Word 的值存入锁记录中。
    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ITs8rEDR-1670252429645)(C:\Users\802612523\Pictures\thread\轻量级锁2.png)]

  3. 如果 cas 替换成功,那么对象的对象头储存的就是锁记录的地址和状态 00 表示轻量级锁,如下所示
    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-A78o5V0q-1670252429645)(C:\Users\802612523\Pictures\thread\轻量级锁3.png)]

  4. 如果cas失败,有两种情况

    1. 如果是其它线程已经持有了该 Object 的轻量级锁,那么表示有竞争,首先会进行自旋锁,自旋一定次数后,如果还是失败就进入锁膨胀阶段。
    2. 如果是自己的线程已经执行了 synchronized 进行加锁,那么再添加一条 Lock Record 作为重入的计数。

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-aP5d2Ctt-1670252429646)(C:\Users\802612523\Pictures\thread\轻量级锁4.png)]

  5. 当线程退出 synchronized 代码块的时候,如果获取的是取值为 null 的锁记录,表示有重入,这时重置锁记录,表示重入计数减一
    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Y3wOQvAf-1670252429647)(C:\Users\802612523\Pictures\thread\轻量级锁5.png)]

  6. 当线程退出 synchronized 代码块的时候,如果获取的锁记录取值不为 null,那么使用 CAS 将 Mark Word 的值恢复给对象

    1. 成功则解锁成功
    2. 失败,则说明轻量级锁进行了锁膨胀或已经升级为重量级锁,进入重量级锁解锁流程
3.4.2 锁膨胀

如果在尝试加轻量级锁的过程中,cas 操作无法成功,这是有一种情况就是其它线程已经为这个对象加上了轻量级锁,这是就要进行锁膨胀,将轻量级锁变成重量级锁。

  1. 当 Thread-1 进行轻量级加锁时,Thread-0 已经对该对象加了轻量级锁
    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-H5equhBK-1670252429648)(C:\Users\802612523\Pictures\thread\锁膨胀1.png)]

  2. 这时 Thread-1 加轻量级锁失败,进入锁膨胀流程,

    • 即为对象申请Monitor锁,让Object指向重量级锁地址
    • 然后自己进入Monitor 的EntryList 变成BLOCKED状态

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-lOgghBiX-1670252429648)(C:\Users\802612523\Pictures\thread\锁膨胀2.png)]

  3. 当 Thread-0 退出 synchronized 同步块时,使用 CAS 将 Mark Word 的值恢复给对象头,对象的对象头指向 Monitor,那么会进入重量级锁的解锁过程,即按照 Monitor 的地址找到 Monitor 对象,先将 Mark Word 存放在 Monitor 中,然后将 Owner 设置为 null ,唤醒 EntryList 中的 Thread-1 线程

3.4.3 自旋锁

重量级锁竞争的时候,还可以使用自旋(循环尝试获取重量级锁)来进行优化,如果当前线程自旋成功(即这时候持锁线程已经退出了同步块,释放了锁),这时当前线程就可以避免阻塞。 (进入阻塞再恢复,会发生上下文切换,比较耗费性能)

  • 自旋会占用 CPU 时间,单核 CPU 自旋就是浪费,多核 CPU 自旋才能发挥优势。
  • 在 Java 6 之后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,总之,比较智能。
  • Java 7 之后不能控制是否开启自旋功能
3.4.4 偏向锁

在轻量级的锁中,我们可以发现,如果同一个线程对同一个对象进行重入锁时,也需要执行 CAS 操作,这是有点耗时滴,那么 java6 开始引入了偏向锁,只有第一次使用 CAS 时将对象的 Mark Word 头设置为偏向线程 ID,之后这个入锁线程再进行重入锁时,发现线程 ID 是自己的,那么就不用再进行CAS了。
分析代码,比较轻量级锁与偏向锁

static final Object obj = new Object();
public static void m1() {
	synchronized(obj) {
		// 同步块 A
		m2();
	}
}
public static void m2() {
	synchronized(obj) {
		// 同步块 B
		m3();
	}
}
public static void m3() {
	synchronized(obj) {
		// 同步块 C
	}
}

分析如图:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-F7MxLwUD-1670252429649)(C:\Users\802612523\Pictures\thread\轻量级锁与偏向锁1.png)]

偏向状态

对象头格式如下:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-7u2DlzQp-1670252429650)(C:\Users\802612523\Pictures\thread\偏向锁mark word.png)]

一个对象的创建过程

  • 如果开启了偏向锁(默认是开启的),那么对象刚创建之后,Mark Word 最后三位的值101,并且这是它的 Thread,epoch,age 都是 0 ,在加锁的时候进行设置这些的值.
  • 偏向锁默认延迟的,不会在程序启动的时候立刻生效,如果想避免延迟,可以添加虚拟机参数来禁用延迟:
    -XX:BiasedLockingStartupDelay=0 来禁用延迟
  • 偏向锁适用于竞争不激烈场景,若竞争激烈,反而影响性能,可以通过添加虚拟机参数来禁止使用偏向锁: -XX:-UseBiasedLocking
  • 注意:处于偏向锁的对象解锁后,线程 id 仍存储于对象头中

适用场景:对象竞争不激烈场景,一旦竞争激烈,要么膨胀为为轻量级锁(没有直接竞争),要么直接膨胀为重量级锁(直接竞争),锁膨胀反而消耗更多性能,因此如果判定某个类对象作为锁,会被激烈竞争,那么应该禁用偏向锁功能

撤销偏向

以下几种情况会使对象的偏向锁失效

  • 调用对象的 hashCode 方法
  • 多个线程使用该对象(synchronized)
  • 调用了 wait/notify 方法(调用wait方法会导致锁膨胀而使用重量级锁)
批量重偏向
  • 如果对象虽然被多个线程访问,但是线程间不存在竞争,这时偏向 t1 的对象仍有机会重新偏向 t2
    • 重偏向会重置Thread ID
  • 当撤销超过20次后(超过阈值),JVM 会觉得是不是偏向错了,这时会在给对象加锁时,重新偏向至加锁线程。
批量撤销(当撤销超过40次,就会将整个类置为不可偏向状态)

当撤销偏向锁的阈值超过 40 以后,就会将整个类的对象都改为不可偏向的

3.5 wait-notify

3.5.1 原理

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-F6isVlvf-1670252429650)(C:\Users\802612523\Pictures\thread\wait-notify原理.png)]

  • 锁对象调用wait方法(obj.wait),就会使当前线程进入 WaitSet 中,变为 WAITING 状态。
  • 处于BLOCKED和 WAITING 状态的线程都为阻塞状态,CPU 都不会分给他们时间片。但是有所区别:
    • BLOCKED 状态的线程是在竞争对象时,发现 Monitor 的 Owner 已经是别的线程了,此时就会进入 EntryList 中,并处于 BLOCKED 状态
    • WAITING 状态的线程是获得了对象的锁,但是自身因为某些原因需要进入阻塞状态时,锁对象调用了 wait 方法而进入了 WaitSet 中,处于 WAITING 状态
  • BLOCKED 状态的线程会在锁被释放的时候被唤醒,但是处于 WAITING 状态的线程只有被锁对象调用了 notify 方法(obj.notify/obj.notifyAll),才会被唤醒。

注:只有当对象加锁以后,才能调用 wait 和 notify 方法

3.5.2 Wait 与 Sleep 的区别
  • Sleep 是 Thread 类的静态方法,Wait 是 Object 的方法,Object 又是所有类的父类,所以所有类都有Wait方法。
  • Sleep 在阻塞的时候不会释放锁,而 Wait 在阻塞的时候会释放锁,它们都会释放 CPU 资源。
  • Sleep 不需要与 synchronized 一起使用,而 Wait 需要与 synchronized 一起使用(对象被锁以后才能使用)
    使用 wait 一般需要搭配 notify 或者 notifyAll 来使用,不然会让线程一直等待。
3.5.3 优雅使用wait-notify
  • 当线程不满足某些条件,需要暂停运行时,可以使用 wait 。这样会将对象的锁释放,让其他线程能够继续运行。如果此时使用 sleep,会导致所有线程都进入阻塞,导致所有线程都没法运行,直到当前线程 sleep 结束后,运行完毕,才能得到执行。
    使用wait/notify需要注意什么(需要在synchronized中使用这些方法,因为只有持有锁对象才能调用这些方法)
  • 当有多个线程在运行时,对象调用了 wait 方法,此时这些线程都会进入 WaitSet 中等待。如果这时使用了 notify 方法,可能会造成虚假唤醒(唤醒的不是满足条件的等待线程),这时就需要使用 notifyAll 方法
synchronized (lock) {
	while(//不满足条件,一直等待,避免虚假唤醒) {
		lock.wait();
	}
	//满足条件后再运行
}

synchronized (lock) {
	//唤醒所有等待线程
	lock.notifyAll();
}

3.5.4 park和unpark
3.5.4.1 基本使用

park & unpark 是 LockSupport 线程通信工具类的静态方法。

// 暂停当前线程
LockSupport.park();
// 恢复某个线程的运行
LockSupport.unpark;

3.5.4.2 原理

每个线程都有自己的一个 Parker 对象,由三部分组成 _counter, _cond 和 _mutex

  • 打个比喻线程就像一个旅人,Parker 就像他随身携带的背包,条件变量 _ cond 就好比背包中的帐篷。_counter 就好比背包中的备用干粮(0 为耗尽,1 为充足)
  • 调用 park 就是要看需不需要停下来歇息
    • 如果备用干粮耗尽,那么钻进帐篷歇息
    • 如果备用干粮充足,那么不需停留,继续前进
  • 调用 unpark,就好比令干粮充足
    • 如果这时线程还在帐篷,就唤醒让他继续前进
    • 如果这时线程还在运行,那么下次他调用 park 时,仅是消耗掉备用干粮,不需停留继续前进
      • 因为背包空间有限,多次调用 unpark 仅会补充一份备用干粮

先调用park再调用upark的过程

先调用 park

  1. 当前线程调用 Unsafe.park() 方法
  2. 检查 _counter ,本情况为 0,这时,获得 _mutex 互斥锁(mutex对象有个等待队列 _cond)
  3. 线程进入 _cond 条件变量阻塞
  4. 设置 _counter = 0

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-2Kay447Q-1670252429651)(C:\Users\802612523\Pictures\thread\park1.png)]

调用 upark

  1. 调用 Unsafe.unpark(Thread_0) 方法,设置 _counter 为 1
  2. 唤醒 _cond 条件变量中的 Thread_0
  3. Thread_0 恢复运行
  4. 设置 _counter 为 0

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-RQn3PX3N-1670252429651)(C:\Users\802612523\Pictures\thread\park2.png)]

先调用upark再调用park的过程

  1. 调用 Unsafe.unpark(Thread_0) 方法,设置 _counter 为 1
  2. 当前线程调用 Unsafe.park() 方法
  3. 检查 _counter ,本情况为 1,这时线程无需阻塞,继续运行
  4. 设置 _counter 为 0

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-j9NeKvwS-1670252429652)(C:\Users\802612523\Pictures\thread\park3.png)]

3.6 CAS

3.6.1 volatile

JMM 决定了静态变量或者实例变量存在可见性问题,即主存和工作线程变量值不同步问题,要解决该问题,可以使用 volatile 解决,被 volatile 修饰的变量,通过读写屏障,可以实现每次读取 volatile 类型变量,都必须要从主存中读取,每次写入时也必须要同步回主存中。

3.6.2 CAS(Compare and set/swap)

CAS 必须借助 volatile 才能读取到共享变量的最新值来实现【比较并交换】的效果

static class Account {
    private volatile int balance;
    //private int balance;
    private static final Unsafe unsafe = UnsafeUtils.createUnsafe();

    private static final long valueOffset;

    static {
        try {
            assert unsafe != null;
            valueOffset = unsafe.objectFieldOffset(Account.class.getDeclaredField("balance"));
        } catch (Exception e) {
            throw new Error(e);
        }
    }

    public Account(int balance) {
        this.balance = balance;
    }

    public void withdraw(int amount) {
        for (; ; ) {
            int pre = balance;
            int next = pre - amount;
            if (unsafe.compareAndSwapInt(this, valueOffset, pre, next)) {
                break;
            }
        }
    }

    public void saveBalance(int amount) {
        for (; ; ) {
            int pre = balance;
            int next = pre + amount;
            if (unsafe.compareAndSwapInt(this, valueOffset, pre, next)) {
                break;
            }
        }
    }
    //...
}		

无锁(乐观锁(cas + 失败重试))和有锁(悲观锁)效率比较:

  • CAS 是基于乐观锁的思想:最乐观的估计,不怕别的线程来修改共享变量,就算改了也没关系,重试即可。
  • synchronized 是基于悲观锁的思想:最悲观的估计,得防着其它线程来修改共享变量,我上了锁你们都别想
    改,我改完了解开锁,你们才有机会。
  • CAS 体现的是无锁并发、无阻塞并发,请仔细体会这两句话的意思
    • 因为没有使用 synchronized,所以线程不会陷入阻塞,这是效率提升的因素之一
    • 但如果竞争激烈,可以想到重试必然频繁发生,反而效率会受影响

3.7 AQS

AQS:Abstract Queued Synchronizer(抽象队列同步器)

作用是提供一个获取以及释放锁的框架,AQS完成了大部分内容,我们只需要按需重写:

tryAcquire(int i) // 尝试从 state 获取 i

tryRelease(int i) // 尝试从 state 返还 i

tryAcquireShared(int i) // 尝试以共享方式从 state 获取 i

tryReleaseShared(int i) // 尝试以共享方式从 state 返还 i

isHeldExclusive() // 判断当前线程是否是独占线程

AQS 的核心是 state 变量的维护,通过修改该变量的值,我们可以利用CAS实现锁的功能,其实本质就是将state的值通过 CAS 从一个状态转换为另外一个状态是否成功,成功就表示获取锁成功,否则失败,因此 AQS 的基础是 CAS,而我们经常使用的 ReentrantLock、ReentrantReadWriteLock、CountdownLach、CyclicBarrier、Semaphore 等都是基于 AQS 实现的。

3.7.1 加锁解锁流程

以 ReentrantLock 为例介绍如何使用 AQS 进行加锁或者解锁

初始状态

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-8E1N1PYj-1670252429653)(C:\Users\802612523\Pictures\thread\aqs1.png)]

没有竞争时,Thread-1 尝试获取锁成功(CAS 修改 state 为 1)

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-b6Zz1RaX-1670252429654)(C:\Users\802612523\Pictures\thread\aqs2.png)]

当 Thread-1 拥有锁时,其余线程竞争锁,比如 Thread-2 竞争锁,其流程为:

  • 首先尝试通过 cas 修改 state 为 1,失败
  • 进入 tryAcquire 逻辑,此时 state 仍旧为 1,失败
  • 进入 addWaiter 逻辑,构造 Node 队列
    • 队首为 dummy 节点,不和任何线程相关联
    • 队列是双向链表
    • 0 表示默认状态,-1 为唤醒状态,表示其有义务唤醒它的后继节点

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-lTuJwOHW-1670252429655)(C:\Users\802612523\Pictures\thread\aqs3.png)]

当前线程进入 acquireQueued 逻辑

  1. acquireQueued 会在一个死循环中不断尝试获得锁,失败后进入 park 阻塞
  2. 如果自己是紧邻着 head(排第二位),那么再次 tryAcquire 尝试获取锁,当然这时 state 仍为 1,失败
  3. 进入 shouldParkAfterFailedAcquire 逻辑,将前驱 node,即 head 的 waitStatus 改为 -1,这次返回 false

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-HRicBMEq-1670252429655)(C:\Users\802612523\Pictures\thread\aqs4.png)]

  1. shouldParkAfterFailedAcquire 执行完毕回到 acquireQueued ,再次 tryAcquire 尝试获取锁,当然这时
    state 仍为 1,失败
  2. 当再次进入 shouldParkAfterFailedAcquire 时,这时因为其前驱 node 的 waitStatus 已经是 -1,这次返回
    true
  3. 进入 parkAndCheckInterrupt, Thread-1 park(灰色表示)

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-W3bj5qH6-1670252429656)(C:\Users\802612523\Pictures\thread\aqs5.png)]

再次有多个线程经历上述过程竞争失败,变成这个样子

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-aFxT0HUg-1670252429656)(C:\Users\802612523\Pictures\thread\aqs6.png)]

Thread-0 释放锁,进入 tryRelease 流程,如果成功

  • 设置 exclusiveOwnerThread 为 null
  • state = 0

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-3JlY4cir-1670252429657)(C:\Users\802612523\Pictures\thread\aqs7.png)]

当前队列不为 null,并且 head 的 waitStatus = -1,进入 unparkSuccessor 流程
找到队列中离 head 最近的一个 Node(没取消的),unpark 恢复其运行,本例中即为 Thread-1
回到 Thread-1 的 acquireQueued 流程

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-xpFkZJHD-1670252429657)(C:\Users\802612523\Pictures\thread\aqs8.png)]

如果加锁成功(没有竞争),会设置

  • exclusiveOwnerThread 为 Thread-1,state = 1
  • head 指向刚刚 Thread-1 所在的 Node,该 Node 清空 Thread
  • 原本的 head 因为从链表断开,而可被垃圾回收

如果这时候有其它线程来竞争(非公平的体现),例如这时有 Thread-4 来了

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-z5K3yqf8-1670252429658)(C:\Users\802612523\Pictures\thread\aqs9.png)]

如果不巧又被 Thread-4 占了先

  • Thread-4 被设置为 exclusiveOwnerThread,state = 1
  • Thread-1 再次进入 acquireQueued 流程,获取锁失败,重新进入 park 阻塞
3.7.1 获取锁
public final void acquire(int arg) {
    if (!tryAcquire(arg) &&
        acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        selfInterrupt();
}

首先尝试获取锁,此处的 tryAcquire 方法就是我们需要重写的方法,我们只需要在这个方法中明确写出 state 状态从一个状态转换为另一个状态是否成功即可,以 ReentrantLock 的非公平锁为例:

static final class NonfairSync extends Sync {
    private static final long serialVersionUID = 7316153563782823691L;
    protected final boolean tryAcquire(int acquires) {
        return nonfairTryAcquire(acquires);
    }
}

final boolean nonfairTryAcquire(int acquires) {
    // 获取当前线程
    final Thread current = Thread.currentThread();
    // 获取当前 state 的值
    int c = getState();
    // 如果 state 为 0,则尝试将之从 0 变为非 0,非 0 表示加锁成功
    if (c == 0) {
        // 尝试将 state 修改为 acquires
        if (compareAndSetState(0, acquires)) {
            // 如果修改成功,则表示获取锁成功,将线程设置为当前 ExclusiveOwnerThread
            setExclusiveOwnerThread(current);
            return true;
        }
    }
    // 如果 state 不为 0,则表示已经有线程已经加锁成功,判断该线程是否是当前线程
    else if (current == getExclusiveOwnerThread()) {
        // 如果是当前线程,锁重入,将 state 的值增加
        int nextc = c + acquires;
        // 如果重入次数过大溢出,则抛Error
        if (nextc < 0) // overflow
            throw new Error("Maximum lock count exceeded");
        // 修改 state 的值并返回 true
        setState(nextc);
        return true;
    }
    // 否则返回 0
    return false;
}

从 ReentrantLock 的 acquire 方法,我们可以知道 ReentrantLock 的中 AQS 的使用理念,state 为 0 表示未加锁,非 0 表示加锁并且数值表示重入次数

当获取锁失败,进入 acquireQueued(addWaiter(Node.EXCLUSIVE), arg),该方法作用很简单,请求入队并阻塞等待锁,请求入队代码如下:

private Node addWaiter(Node mode) {
    // 创建 node
    Node node = new Node(mode);

    // 将节点加入链表尾部
    for (;;) {
        // 获取尾节点
        Node oldTail = tail;
        if (oldTail != null) {
            // 设置节点前驱节点为尾节点
            node.setPrevRelaxed(oldTail);
            // 将该节点设置为新的尾节点
            if (compareAndSetTail(oldTail, node)) {
                // 设置成功,老的尾节点的 next 指向新的尾节点
                oldTail.next = node;
                // 返回节点
                return node;
            }
        } else {
            // 尾节点位空,则创建队列
            initializeSyncQueue();
        }
    }
}

//初始化队列很简单,创建一个 node,head 和 tail 都指向该 node(该 node 称之为 dummy 哨兵节点)
private final void initializeSyncQueue() {
    Node h;
    if (HEAD.compareAndSet(this, null, (h = new Node())))
        tail = h;
}

阻塞等待代码如下:

final boolean acquireQueued(final Node node, int arg) {
    boolean interrupted = false;
    try {
        for (;;) {
            // 找新建节点的前驱节点
            final Node p = node.predecessor();
            // 如果当前节点前驱节点为头节点(当前节点为老二节点),尝试获取锁(此时队列中只有该线程在等待)
            if (p == head && tryAcquire(arg)) {
                // 如果获取成功,将当前节点设为头节点,并且将旧的头节点 next 置为 null
                setHead(node);
                p.next = null; // help GC
                return interrupted;
            }
            // 判断当前节点是否应该 park,只有当当前节点的前驱节点 waitStatus 为 -1 时才为 true
            if (shouldParkAfterFailedAcquire(p, node))
                // park 被解除以后重新尝试获取锁
                interrupted |= parkAndCheckInterrupt();
        }
    } catch (Throwable t) {
        cancelAcquire(node);
        if (interrupted)
            selfInterrupt();
        throw t;
    }
}

private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
    int ws = pred.waitStatus;
    if (ws == Node.SIGNAL)
        /*
             * This node has already set status asking a release
             * to signal it, so it can safely park.
             */
        return true;
    // 节点前驱节点被取消,从前往后找到第一个未被取消的节点并返回 false
    if (ws > 0) {
        /*
             * Predecessor was cancelled. Skip over predecessors and
             * indicate retry.
             */
        do {
            node.prev = pred = pred.prev;
        } while (pred.waitStatus > 0);
        pred.next = node;
    } else {
        // waitStatus 为 0 或者 PROPAGATE,尝试将其修改为 -1,返回 false
        /*
             * waitStatus must be 0 or PROPAGATE.  Indicate that we
             * need a signal, but don't park yet.  Caller will need to
             * retry to make sure it cannot acquire before parking.
             */
        pred.compareAndSetWaitStatus(ws, Node.SIGNAL);
    }
    return false;
}

// park 当前线程,当被 unpark 时,查看当前线程是否被 interrupt
private final boolean parkAndCheckInterrupt() {
    LockSupport.park(this);
    // 会重置 interrupt 为 false
    return Thread.interrupted();
}

AQS 中还有 acquireNanos,acquireInterrupted 等方法,这些方法本质不同就在于 park 时调用什么方法以及 interrupt 时怎么反应,如下:

private void doAcquireInterruptibly(int arg)
    throws InterruptedException {
    final Node node = addWaiter(Node.EXCLUSIVE);
    try {
        for (;;) {
            final Node p = node.predecessor();
            if (p == head && tryAcquire(arg)) {
                setHead(node);
                p.next = null; // help GC
                return;
            }
            if (shouldParkAfterFailedAcquire(p, node) &&
                parkAndCheckInterrupt())
                // 基本跟 acquireQueued 方法一样,只不过这里如果 interrupt 为 true,直接抛异常
                throw new InterruptedException();
        }
    } catch (Throwable t) {
        cancelAcquire(node);
        throw t;
    }
}

private boolean doAcquireNanos(int arg, long nanosTimeout)
    throws InterruptedException {
    if (nanosTimeout <= 0L)
        return false;
    final long deadline = System.nanoTime() + nanosTimeout;
    final Node node = addWaiter(Node.EXCLUSIVE);
    try {
        for (;;) {
            final Node p = node.predecessor();
            if (p == head && tryAcquire(arg)) {
                setHead(node);
                p.next = null; // help GC
                return true;
            }
            nanosTimeout = deadline - System.nanoTime();
            if (nanosTimeout <= 0L) {
                cancelAcquire(node);
                return false;
            }
            // 基本上跟 acquireQueued 方法一样,只不过维护了 timeout 属性,并且调用的是
            // LockSupport.parkNanos 限时阻塞方法
            if (shouldParkAfterFailedAcquire(p, node) &&
                nanosTimeout > SPIN_FOR_TIMEOUT_THRESHOLD)
                LockSupport.parkNanos(this, nanosTimeout);
            if (Thread.interrupted())
                throw new InterruptedException();
        }
    } catch (Throwable t) {
        cancelAcquire(node);
        throw t;
    }
}

ReentrantReadWriteLock 中将 state 高16位 用作记录写锁,低 16 位用作记录读锁,这样可以用一个变量表示读写加锁条件

CountdownLatch 中则将 state 为 0 作为 acquire 成功的标志,await 方法调用 acquireSharedInterruptibly 方法,我们重写的 tryAcquire 方法如下:

// 只有当 state 为 0 时,acqire 才为 true,await 才能解除阻塞
protected int tryAcquireShared(int acquires) {
    return (getState() == 0) ? 1 : -1;
}

CyclicBarrier 中则是通过 ReentrantLock 来实现的

Semaphre 中则是将 state 用作可用资源数量记录,当 state 不为 0 时,每次 acquire 会将 state 值减一,直至减为 0,当 state 值为 0 时,阻塞等待

甚至 ThreadPoolExecutor 中的 Worker 也使用 AQS,Worker 新建时 state 为 -1,运行时会先将 state 修改为 0,然后在真正执行 task 时会获取 lock(为什么需要获取 lock 呢?因为在计算 activeCount、taskCount 等时需要先暂停执行 task,统计完成以后在恢复运行)

3.7.2 释放锁
public final boolean release(int arg) {
    if (tryRelease(arg)) {
        // 释放以后检查是否需要唤醒其余节点
        Node h = head;
        // head 不为 null 并且 waiteStatus 不为 0,就尝试唤醒队列中后续节点
        if (h != null && h.waitStatus != 0)
            unparkSuccessor(h);
        return true;
    }
    return false;
}

释放锁时我们需要自己写 tryRelase 的逻辑,以 ReentrantLock 为例:

protected final boolean tryRelease(int releases) {
    int c = getState() - releases;
    if (Thread.currentThread() != getExclusiveOwnerThread())
        throw new IllegalMonitorStateException();
    boolean free = false;
    if (c == 0) {
        free = true;
        setExclusiveOwnerThread(null);
    }
    setState(c);
    return free;
}

private void unparkSuccessor(Node node) {
    /*
         * If status is negative (i.e., possibly needing signal) try
         * to clear in anticipation of signalling.  It is OK if this
         * fails or if status is changed by waiting thread.
         */
    int ws = node.waitStatus;
    // 首先尝试将节点负的 waitStatus 设置为 0
    if (ws < 0)
        compareAndSetWaitStatus(node, ws, 0);

    /*
         * Thread to unpark is held in successor, which is normally
         * just the next node.  But if cancelled or apparently null,
         * traverse backwards from tail to find the actual
         * non-cancelled successor.
         */
    // 找到后继节点
    Node s = node.next;
    // 如果后继节点为 null 或者 waitStatus 大于 0,则从队尾往前找第一个 waitStatus 为负数的节点
    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);
}

3.8 ReentrantLock

和 synchronized 相比具有的的特点

  • 可中断
  • 可以设置超时时间
  • 可以设置为公平锁 (先到先得)
  • 支持多个条件变量( 具有多个 WaitSet)
// 获取ReentrantLock对象
private ReentrantLock lock = new ReentrantLock();
// 加锁
lock.lock();
try {
	// 需要执行的代码
}finally {
	// 释放锁
	lock.unlock();
}

3.8.1 可重入
  • 可重入是指同一个线程如果首次获得了这把锁,那么因为它是这把锁的拥有者,因此有权利再次获取这把锁
  • 如果是不可重入锁,那么第二次获得锁时,自己也会被锁挡住
3.8.2 可打断

如果某个线程处于阻塞状态,可以调用其 interrupt 方法让其停止阻塞,获得锁失败,简而言之就是:处于阻塞状态的线程,被打断了就不用阻塞了,直接停止运行

public static void main(String[] args) {
    ReentrantLock lock = new ReentrantLock();
    Thread t1 = new Thread(() -> {
        try {
            // 加锁,可打断锁
            lock.lockInterruptibly();
        } catch (InterruptedException e) {
            e.printStackTrace();
            // 被打断,返回,不再向下执行
            return;
        }finally {
            // 释放锁
            lock.unlock();
        }

    });

    lock.lock();
    try {
        t1.start();
        Thread.sleep(1000);
        // 打断
        t1.interrupt();
    } catch (InterruptedException e) {
        e.printStackTrace();
    } finally {
        lock.unlock();
    }
}

3.8.3 锁超时

使用 lock.tryLock 方法会返回获取锁是否成功。如果成功则返回 true ,反之则返回 false 。
并且 tryLock 方法可以指定等待时间,参数为:tryLock(long timeout, TimeUnit unit), 其中 timeout 为最长等待时间,TimeUnit 为时间单位
简而言之就是:获取锁失败了、获取超时了或者被打断了,不再阻塞,直接停止运行。
不设置等待时间

public static void main(String[] args) {
    ReentrantLock lock = new ReentrantLock();
    Thread t1 = new Thread(() -> {
        // 未设置等待时间,一旦获取失败,直接返回false
        if(!lock.tryLock()) {
            System.out.println("获取失败");
            // 获取失败,不再向下执行,返回
            return;
        }
        System.out.println("得到了锁");
        lock.unlock();
    });


    lock.lock();
    try{
        t1.start();
        Thread.sleep(3000);
    } catch (InterruptedException e) {
        e.printStackTrace();
    } finally {
        lock.unlock();
    }
}

设置等待时间

public static void main(String[] args) {
    ReentrantLock lock = new ReentrantLock();
    Thread t1 = new Thread(() -> {
        try {
            // 判断获取锁是否成功,最多等待1秒
            if(!lock.tryLock(1, TimeUnit.SECONDS)) {
                System.out.println("获取失败");
                // 获取失败,不再向下执行,直接返回
                return;
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
            // 被打断,不再向下执行,直接返回
            return;
        }
        System.out.println("得到了锁");
        // 释放锁
        lock.unlock();
    });


    lock.lock();
    try{
        t1.start();
        // 打断等待
        t1.interrupt();
        Thread.sleep(3000);
    } catch (InterruptedException e) {
        e.printStackTrace();
    } finally {
        lock.unlock();
    }
}

3.8.4 公平锁

在线程获取锁失败,进入阻塞队列时,先进入的会在锁被释放后先获得锁。这样的获取方式就是公平的。

// 默认是不公平锁,需要在创建时指定为公平锁
ReentrantLock lock = new ReentrantLock(true);

3.8.5 条件变量

synchronized 中也有条件变量,就是我们讲原理时那个 waitSet 休息室,当条件不满足时进入waitSet 等待。
ReentrantLock 的条件变量比 synchronized 强大之处在于,它是支持多个条件变量的,这就好比

  • synchronized 是那些不满足条件的线程都在一间休息室等消息
  • 而 ReentrantLock 支持多间休息室,有专门等烟的休息室、专门等早餐的休息室、唤醒时也是按休息室来唤醒

使用要点:

  • await 前需要获得锁
  • await 执行后,会释放锁,进入 conditionObject 等待
  • await 的线程被唤醒(或打断、或超时)取重新竞争 lock 锁
  • 竞争 lock 锁成功后,从 await 后继续执

4. Hashmap 死链演示

public static void main(String[] args) {
    System.out.println("长度为16时,桶下标为1的key");
    for (int i = 0; i < 64; i++) {
        if (hash(i) % 16 == 1) {
            System.out.println(i);
        }
    }

    System.out.println("长度为32时,桶下标为1的key");
    for (int i = 0; i < 64; i++) {
        if (hash(i) % 32 == 1) {
            System.out.println(i);
        }
    }

    final HashMap<Integer, Integer> map = new HashMap<>();
    // 放置 12 个元素
    map.put(2, null);
    map.put(3, null);
    map.put(4, null);
    map.put(5, null);
    map.put(6, null);
    map.put(7, null);
    map.put(8, null);
    map.put(9, null);
    map.put(10, null);
    map.put(16, null);
    map.put(35, null);
    map.put(1, null);

    System.out.println("扩容前大小[main]:"+map.size());

    new Thread() {
        @Override
        public void run() {
            // 放第 13 个元素, 发生扩容
            map.put(50, null);
            System.out.println("扩容后大小[Thread-0]:"+map.size());
        }
    }.start();

    new Thread() {
        @Override
        public void run() {
            // 放第 13 个元素, 发生扩容
            map.put(50, null);
            System.out.println("扩容后大小[Thread-1]:"+map.size());
        }
    }.start();
}

static int hash(Object o) {
    int h = 0;
    if (o instanceof String) {
        return sun.misc.Hashing.stringHash32((String) o);
    }
    h ^= o.hashCode();
    h ^= (h >>> 20) ^ (h >>> 12);
    return h ^ (h >>> 7) ^ (h >>> 4);
}

复现方式:

在 HashMap 源码 590 行加断点

int newCapacity = newTable.length;

断点的条件如下,目的是让 HashMap 在扩容为 32 时,并且线程为 Thread-0 或 Thread-1 时停下来。

newTable.length == 32 && (Thread.currentThread().getName().equals("Thread-0") || Thread.currentThread().getName().equals("Thread-1"))

断点暂停方式选择 Thread,否则在调试 Thread-0 时,Thread-1 无法恢复运行

运行代码,程序在预料的断点位置停了下来,输出

长度为16时,桶下标为1的key
1
16
35
50
长度为32时,桶下标为1的key
1
35
扩容前大小[main]:12

接下来进入扩容流程,在 HashMap 源码 594 行加断点,目的是为了观察 e 节点 和 next 节点状态如下:

e		1->35->16->null
next	35->16->null

切换到 Thread-1 线程,恢复运行并使其直接运行完毕,可以看到控制台输出新内容如下,表示 Thread-1 已经完成 Map 的扩容操作:

newTable[1]	35->1->null

此时线程 Thread-0 停留在 594 行,e 和 next 变为:

e		1->null
next	35->1->null

此时 Thread-0 尝试扩容,使用头插法将 1 放入 newTable[1] 头部,并将 next 赋给 e,如下:

// 计算桶下标
int i = indexFor(e.hash, newCapacity);
// 头插法插入节点
e.next = newTable[i];
newTable[i] = e;
// 节点迁移完毕,处理下一个节点
e = next;

此处将 1 和 35 节点分别设为 Node1 和 Node 35,其循环变化过程如下:

e		Node1
next	Node35->Node1

经过第一次循环
e			Node35
next		Node1
newTable[1]	Node1

经过第二次循环
e			Node1
next		null
newTable[1]	Node35->Node1

第三次循环,当执行 e.next = newTable[i]; 时,成环:Node1->Node35->Node1

如上,第三次循环时成环,形成死链,扩容失败

Jdk 8 及以后版本采用尾插法迁移节点,不再有死链问题

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值