Chapter 13 - 线程安全与锁优化
Created by : Mr Dk.
2020 / 02 / 04 16:08
Ningbo, Zhejiang, China
13.2 线程安全
当多个线程同时访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或协调操作,调用这个对象都可以获得正确的结果。那么这个对象就是线程安全的。
13.2.1 Java 语言中的线程安全
将 Java 语言中各种操作共享的数据分为五类。
13.2.1.1 不可变 (Immutable)
只要一个不可变对象被正确构建出来 (没有发生 this 引用逃逸),其外部的可见状态永远不会改变,不需要再进行任何线程安全保障措施。
13.2.1.2 绝对线程安全
比如,对于 java.util.Vector
,即使其所有函数都被修饰为 synchronized
,这并不意味着调用它的时候就永远不需要同步了。
// Thread A
for (int i = 0; i < 10; i++) {
vector.add(i);
}
// Thread B
for (int i = 0; i < vector.size(); i++) {
vector.remove(i);
}
两个线程交替执行,如果不做额外的同步措施,实际上还是不安全的。
13.2.1.3 相对线程安全
是通常意义上所谓的线程安全,是指保证对象的单次操作时线程安全的。调用的时候不需要进行额外的保障措施;然而对于一些特定顺序的连续调用,就需要在调用时使用额外的同步手段来保证调用的正确性。比如上面的例子里中 Thread B,至少需要保证每次循环中 size()
和 remove()
的同步性。Java 中大部分声称线程安全的类都属于这个类型。
13.2.1.4 线程兼容
对象本身不是线程安全的,但可以通过在调用端正确地使用同步手段,保证对象在并发环境中可以安全地使用。
13.2.1.5 线程对立
不管调用端是否采取了同步措施,都无法在多线程环境中并发使用代码。很少出现,且通常都是有害的,应尽量避免。
13.2.2 线程安全的实现方法
13.2.2.1 互斥同步 (Mutual Exclusion & Synchronization)
保证共享数据在同一时刻只被一条 (或一些) 线程使用,最基本的互斥手段就是 synchronized
关键字:
- 该关键字在经过编译后,会在同步块前后形成
monitorenter
和monitorexit
两个字节码指令 - 两个指令都需要一个 reference 类型的参数指明锁定或解锁的对象
- 如果 Java 源码中明确指定了对象参数,就用这个对象的引用作为 reference
- 如果没有明确指定,就根据修饰的函数类型来决定使用代码所在的对象实例还是类型对应的 Class 对象作为 reference
根据 JVM 规范要求,执行 monitorenter
指令时,首先会尝试获取对象的锁。如果对象没有被锁定,或当前线程已经持有该对象的锁,就把锁的计数器值 +1;执行 monitorexit
时将锁计数器值 -1,计数器值为 0 时锁被释放。
如果获取对象锁失败,则当前线程应当被阻塞,直至对象锁被持有它的线程释放:
synchronized
同步块对于同一条线程来说可重入,同一线程反复进入不会锁死自己synchronized
会无条件阻塞后面其它线程的进入
持有锁是一个重量级的操作,阻塞或唤醒一个线程需要 OS 来帮忙,涉及到用户态和核心态的切换。因此 synchronized
关键字在 Java 中是一个重量级操作。JVM 本身会对 synchronized
进行一些优化。
Java 的 java.util.concurrent
中的 Lock 使用户能够以非块结构来实现互斥同步。重入锁 (ReentrantLock) 是 Lock 最常见的一种实现:
- 等待可中断
- 当持有锁的线程长期不释放锁时,正在等待的线程可以放弃等待
- 公平锁
- 在锁被释放时,严格按照申请锁的时间顺序来依次获得锁
synchronized
是非公平的,任何一个等待锁的线程都有机会获得锁- ReentrantLock 在默认情况下也是非公平的,但也可以设置为公平锁
- 公平锁会导致性能急剧下降、影响吞吐量
- 锁绑定多个条件
- 可以同时绑定多个 Condition 对象
在 JDK 5 之前,多线程环境下,synchronized
的吞吐量下降得非常严重,因为当时的 synchronized
关键字有非常大的优化余地。JDK 6 中针对 synchronized
增加了大量的优化措施,之后 synchronized
与 ReentrantLock 的性能基本持平:
synchronized
是在 Java 语法层面的同步,更为简单- Lock 需要确保在
finally
块中释放锁,这一点需要程序员保证;而synchronized
由 JVM 负责释放 - 从长远来看,JVM 更容易针对
synchronized
进行优化
13.2.2.2 非阻塞同步
互斥同步也被称为 阻塞同步 (Blocking Synchronization),面临的主要问题是线程阻塞和唤醒带来的性能开销。从解决问题的方式上看,互斥同步属于一种 悲观 的并发策略:无论共享的数据是否真的会出现竞争,都会进行加锁。
另外,还有基于冲突检测的 乐观 并发策略:
- 不管风险,先进行操作
- 如果没有别的线程争用共享数据,操作就直接成功了
- 如果共享数据被争用,发生了冲突,再进行其它补偿措施 (比如重试)
这种策略不再需要把线程阻塞挂起,被称为 非阻塞同步。这种策略需要硬件指令的支持:
- 要求操作和冲突检测这两个步骤具备原子性
- 硬件保证这些操作只需要一条指令就能完成
Java 使用的是 CAS (Compare-And-Swap) 操作。CAS 操作需要三个操作数:
- 内存位置 V
- 旧的预期值 A
- 准备设置的新值 B
当且仅当 V 符合 A 时,CPU 才会用 B 更新 V,否则不执行更新。不管是否更新了 V 值,都会返回 V 的旧值,且这个操作是一个原子操作。比如 AtomicInteger
中的自增函数:
public final int incrementAndGet() {
for (;;) {
int current = get();
int next = current + 1;
if (compareAndSet(current, next))
return next;
}
}
在一个死循环中,不断尝试将一个大一些的值赋给自己。如果失败,说明执行 CAS 操作时,旧值发生了改变,有别的线程并发了,于是重新循环进行操作,直到成功为止。CAS 存在一个逻辑漏洞:
- 在初始读取时,变量值为 A
- 在赋值前检查其值仍然为 A
- 能说明该值没有被其它线程修改过吗
这带来了 ABA 问题:在这段期间,A 被修改为 B 后又被修改为 A。java.util.concurrent
提供了带标记的 AtomicStampedReference
来解决这个问题 (控制变量值的版本)。大部分情况下,ABA 问题不会影响程序并发的正确性,要解决该问题,用 synchronized
关键字可能更高效。
13.2.2.3 无同步方案
让一个函数本身就不涉及共享数据。
13.3 锁优化
13.3.1 自旋锁与自适应自旋
挂起线程和恢复线程的操作需要转入内核态完成,而在很多应用上,共享数据锁定只会持续很短的时间,为了这段时间去挂起而恢复很不值当。可以让请求锁的线程 稍等一会儿:
- 不放弃 CPU 时间
- 看看持有锁的线程是否很快就会释放
- 让线程执行一个忙循环 (自旋)
这就是所谓的自旋锁。自旋等待本身避免了线程切换的开销,但是是要占用 CPU 时间。锁被占用的时间越短,自旋等待的效果就越好,否则自旋只会白白占用 CPU 资源。因此自旋等待的时间必须有一定限度,如果自旋超过该限度而锁还没有被释放,就需要挂起线程。
自适应的自旋意味着自旋的时间不再固定,而是由上一次在同一个锁上的自旋时间决定的:
- 在同一个锁对象上,自旋成功获得过锁,那么此次自旋也很有可能成功,进而允许自旋等待更长的时间
- 对于同一个锁,自旋很少成功获得过锁,那很有可能直接忽略自旋过程,以免浪费 CPU 资源
有了自适应自旋,随着性能监控信息的完善,JVM 的程序锁的状况预测会越来越准确。
13.3.2 锁消除
JVM 的即时编译器在运行时,对一些代码要求同步,但检测到不可能存在共享数据竞争的锁进行消除。锁消除的判定依据来自于 逃逸分析。如果堆上所有数据都不会逃逸出去被其它线程访问,就可以将其作为栈上数据对待,认为是线程私有的,加锁就无需进行;虽然有锁,但可以被安全地消除掉。
在解释执行时仍然会加锁,经过即时编译后,同步措施会被消除。
13.3.3 锁粗化
原则上,推荐将同步块的作用范围限制得尽量小,使得需要同步的操作数量尽可能变少;而有时,如果一系列连续操作都对同一个对象反复进行加锁、解锁,甚至加锁操作出现在循环体中,即使没有竞争,频繁互斥同步也有很大的性能损耗。此时可以将锁粗化。
13.3.4 轻量级锁
OS 互斥量实现的锁相对来说是重量级锁。HotSpot 虚拟机的对象头分为两部分:
- 存储对象自身的运行时数据 (Mark Word)
- 指向方法区对象类信息的指针 (如果是数组对象,还有额外部分用于存储数组长度)
轻量级锁:
- 在代码进入同步块时,如果同步对象没有被锁定 (Mark Word 锁标志位为
01
) - JVM 在当前线程栈帧中建立 Lock Record 空间,并存储锁对象 Mark Word 的拷贝
- JVM 使用 CAS 操作尝试把锁对象的 Mark Word 更新为指向 Lock Record 的指针:如果成功,则该线程拥有了这个对象的锁,对象 Mark Word 的锁标志位被更改为
00
(轻量锁定状态)
如果更新操作失败,说明至少存在一个线程竞争该锁,JVM 首先检查对象的 Mark Word 是否指向当前线程栈帧:如果不是,则说明该锁已被其它线程抢占。如果出现两条以上线程争用同一个锁,则膨胀为重量级锁:
- 锁标志状态转换为
10
- Mark Word 中存储的是指向重量级锁 (互斥量) 的指针
解锁过程也是通过 CAS 来进行。如果对象的 Mark Word 指向线程栈中的副本,那么就用 CAS 操作用线程栈中的副本替换 Mark Word:
- 如果成功替换,说明锁释放成功
- 如果失败,说明有其它线程尝试获得该锁
在释放锁的同时,还需要唤醒被挂起的线程。对于大部分锁,在同步周期内是不存在竞争的。如果没有竞争,轻量级锁使用 CAS 操作避免了使用互斥量的开销。而有竞争的情况下,轻量级锁反而比重量级锁更慢。
13.3.5 偏向锁
轻量级锁在无竞争的情况下使用 CAS 操作消除同步使用的互斥量。而偏向锁在无竞争的情况下把整个同步都消除掉,连 CAS 都不去做了。偏向 指的是锁偏向于第一个获得它的线程:在接下来,如果一直没有其它线程获取该锁,持有该锁的线程将永远不需要再进行同步。
当锁对象第一次被线程获取时,JVM 对对象头进行设置,设置为偏向模式。使用 CAS 操作把获取这个锁的线程 ID 记录在对象的 Mark Word 中:如果 CAS 操作成功,持有偏向锁的线程每次进入同步区时,JVM 都不再进行任何同步操作;一旦出现另外一个线程尝试获取该锁的情况,偏向模式立刻宣告结束。后续可能会恢复为 未锁定 状态或 轻量级锁定 状态。
偏向锁可以提高带有同步但无竞争的程序性能。如果大多数锁都会被不同线程访问,那偏向模式就是多余的。