Java 提供了同步机制,解决并发编程的三个主要问题:

  1. 可见性。CPU缓存可以平衡 CPU 和内存的速度差,但如果线程对共享变量的修改未及时回写内存,其它线程就看不到该修改。
  2. 有序性。编译器、处理器为了提高缓存利用率,会重排指令顺序。
  3. 原子性。分时系统的线程切换导致原子操作失败。

    1 volatile

    关键字 volatile 解决可见性、有序性问题。声明 volatile 可以确保所有线程读到的值一致,并禁止编译器指令重排。(无法禁止处理器重排序、内存重排序归,它们归 OS 管)

    1.1 可见性

    MESI 缓存一致性协议,监听共享总线上消息,修改缓存中数据的状态,从而保证数据的一致性。
    volatile 使用 MESI,标记缓存中修改过的数据为 Invalidated,下次访问该数据会读取内存,从而实现可见性。

    1.2 有序性

    1. Singleton {
    2. static Singleton instance;
    3. public static Singleton getInstance() {
    4. if (instance == null) {
    5. synchronized (Singleton.class) {
    6. if (instance == null) {
    7. instance = new Singleton();
    8. }
    9. }
    10. }
    11. return instance;
    12. }
    13. }

    正常情况下,第7行编译后的指令:

  4. new。分配一块内存M。

  5. invokespecial。在内存M上初始化对象。
  6. astore。将内存M的地址赋给 instance 变量。

如果发生指令重排,astore 可能会排在 invokespecial 之前。假如线程A执行完 astore 后切换,线程B 执行到第4行发现 instance 非空,于是返回一个未经初始化的 instance,产生有序性问题。
内存屏障

2 synchronized

关键字 synchronized 解决原子性问题。
总线锁(声言 LOCK#)、缓存行锁

2.1 原子性

雪花算法的自增序列,可以使用 synchronized、AtomicLong、LongAdder 实现同步自增。先测试3个场景:

  • 单线程场景 synchronized >> LongAdder > AtomicLong
  • 低并发场景 AtomicLong > LongAdder > synchronized
  • 高并发场景 LongAdder > synchronized > AtomicLong

可以发现 synchronized 在单线程场景表现最佳,并发工具 LongAdder 在高并发场景最优。

2.2 锁升级

JDK6 对 synchronized 做了一系列优化:
锁消除 JVM检测到一些同步的代码块,完全不存在数据竞争的场景,也就是不需要加锁,就会进行锁消除
锁粗化 有很多操作都是对同一个对象进行加锁,就会把锁的同步范围扩展到整个操作序列之外
锁升级 根据并发度,提升锁的强度:偏向锁、轻量锁、重量锁

2.2.1 偏向锁

只有一个线程执行同步代码,线程不会主动释放偏向锁。

  1. 线程A 访问 Mark Word,确认锁标志位 01,偏向锁标识位 1,线程ID是自己,执行同步代码
  2. 线程B 访问 Mark Word,线程ID不是自己,CAS修改线程ID失败,挂起线程A
  3. 到达 safepoint,如果:
    3.1 线程A 已退出同步代码,修改线程ID 为 null
    3.2 线程A 未退出同步代码,修改锁标志位 00,升级为轻量锁

结合单线程场景看,synchronized 可能会进行锁消除,即便加了偏向锁也不会主动释放,因此效率最高。

2.2.2 轻量锁

多个线程交替执行同步代码,线程尝试自旋获取锁,失败一定次数后(JVM控制,自适应)把锁升级为重量锁。

  1. 线程A 将 Mark Word 复制到本栈帧的锁记录,CAS 修改 Mark Word 指向自己的锁记录成功
  2. 线程B 将 Mark Word 复制到本栈帧的锁记录,CAS 修改 Mark Word 指向自己的锁记录失败,自旋重试
  3. 线程B 自旋一定次数后,修改锁标志位 10,升级为重量锁

轻量锁通过自旋的方式提升效率,因为 LWP 阻塞、唤醒需要上下文切换,代价很大。

2.2.3 重量锁

多个线程同时执行同步代码,阻塞取锁失败的线程。ObjectMonitor 有3个重要属性:EntryList、WaitSet、Owner。

  1. 线程A、线程B 进入同步代码块,进入 EntryList
  2. EntryList 中的线程通过 CAS 修改 Owner,成功者获取到锁,失败者 BLOCKED
  3. 线程A 调用 wait(),修改 Owner 为 null,同时进入 WaitSet 等待别的线程 notify 后重入 EntryList
  4. 线程A 释放锁,修改 Owner 为 null

    3 Java 内存模型

    640.webp
    JMM 工作内存抽象了 CPU cache,主内存抽象了 memory。为了实现同步,JMM 还制定了 happen-before 规则:

  5. 程序顺序规则。同线程中的代码要按顺序执行。

  6. monitor规则。解锁后才能加锁。
  7. volatile规则。对 volatile 变量的写操作优先于读操作。
  8. 线程start规则。调用线程 start() 之前的操作优先于线程中要执行的操作。
  9. 线程join规则。t1 调用 t2.join(),那么 t2 的操作优先于 t1 从 t2.join() 的成功返回。
  10. 传递规则。如果操作a优先于操作b,操作b优先于操作c,那么操作a优先于操作b。

    4 死锁

    死锁的形成须符合4个条件:

  11. 互斥条件。一个资源每次只能被一个进程使用。

  12. 请求与保持条件。一个进程因请求资源而阻塞时,对已获得的资源保持不放。
  13. 不剥夺条件。进程已获得的资源,在末使用完之前,不能强行剥夺。
  14. 循环等待条件。若干进程之间形成一种头尾相接的循环等待资源关系。

破化任意一个条件就可以避免死锁,最简单是破坏循环等待条件:将系统中所有的资源设置标志位、排序,规定所有的进程申请资源必须以一定的顺序加锁。

参考文献