《深入理解Java虚拟机》读书笔记9--线程安全与锁优化

线程安全

线程安全,耳熟能详,但想准确的描述并不容易。这里借用《Java Concurrency In Practice》作者Brian Goetz对其的一个定义:“当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要额外的同步,或者再调用方法进行任何其他的协调操作,调用这个对象的行为就可以获得正确的结果,那这个对象是线程安全的”

这个定义比较完整,它要求线程安全的代码需要具备一个特征:代码本身封装了所有必要的正确性保障手段,令调用者无需关心多线程问题,更无须调用者自行采取任何措施来保证多线程环境下的正确使用

Java语言中的线程安全

上面是关于线程安全的一个抽象定义,下面来看一下在Java语言中,线程安全是如何体现的。严格来说,线程安全并非一个非真即假的一个命题。按照线程安全的程度由强至弱,可以分为5类:

(1)不可变

在上一章(Java内存模型与线程)中,我们在讨论final关键字时提到过这点,只要一个不可变的对象被正确构建出来,那其外部的可见状态就不会改变,因此无论是对象方法的实现还是方法的调用者,都不需要再采取任何线程安全的保障措施

Java语言中,如果共享数据是一个基本数据类型,那么只要通过final来修饰就可以保证它不可变。如果共享数据是一个对象,那就需要保证对象的行为不会对其状态产生任何影响。保证对象的行为不会对其状态产生影响的途径有很多种,最简单的是将其内部的状态变量全部定义为final,这样在构造方法结束之后,它就是不可变的。Java中最典型的不可变对象就是String,还有Integer等基本数据的包装类型

(2)绝对线程安全

绝对线程安全完全满足本篇开头Brian Goetz给出的线程安全的定义。这种定义十分严格,要达到这个要求通常需要付出很大代价,有些时候甚至不切实际。Java中线程安全的类,大多数都不是绝对线程安全的,比如下面这个例子:

Vector大家都不陌生,是一个线程安全的容器,它的add()、remove()、size()和get()方法都是synchronized的。即便如此,并不等同于在任何适用场景下都不需要额外的同步手段了

上例中我们初始化一个Vector,之后通过不同的线程从Vector中移除元素并遍历打印剩余的元素,运行结果会抛出ArrayIndexOutOfBoundsException。其中原因相信大家已经看出,其中一种修复办法就是分别将整个移除元素及整个打印剩余元素的动作标记为synchronized

(3)相对线程安全

相对线程安全就是我们“最常见”的线程安全,Java中大多数线程安全的类都属于这种类型,它可以保证这个对象单独的操作是线程安全的。但是如果对于一些特定的操作组合,仍然有可能需要调用方通过额外的同步手段进行保障(比如上例)

(4)线程兼容

线程兼容是指对象本身并不是线程安全的,但是调用方通过额外的同步手段进行保障,Java中大多数类都属于这种类型。我们平常说的一个类不是线程安全的,绝大多数情况都属于这种情况

(5)线程对立

线程对立是指无论调用方是否采取了同步措施,都无法在多线程环境中并发使用的情况。Java中这种情况并不常见,比较典型的一个例子是Thread类的suspend()和resume()方法。这两个方法早已被标记为Deprecated,原因是会导致死锁

线程安全的实现方法

(1)互斥同步(Mutual Exclusion & Synchronization)

同步只是多个线程并发访问共享数据时,保证共享数据在同一时刻只被一个或者是一些(使用信号量时)线程使用。互斥是实现同步的手段之一

Java中最基本的互斥同步手段是synchronized,经过编译后,synchronized会被转变为monitorenter和monitorexit字节码指令,这两条指令分别位于同步块的前后

在执行monitorenter指令时,会尝试获取对象的锁,如果对象没有被锁定或者当前线程已经拥有了这个对象的锁,那么就会把对应锁的计数器加1。如果获取锁失败,那么这个线程会被阻塞,直到锁被释放。相对应的,在执行monitorexit时,会将计数器减1,当计数器值为0时,锁会被释放

synchronized同步块对于同一个线程是可重入的,另外在锁释放之前,会阻塞其他尝试获取相同锁的线程。Java线程是映射到操作系统原生线程之上的,阻塞和唤醒线程都需要在用户态和内核态之间切换,这个过程需要耗费处理器时间。对于代码简单的同步块,状态切换消耗的时间很可能比同步块内代码执行时间还要长。针对这种情况,虚拟机会做一些优化,比如自旋等待

除了synchronized,ReentrantLock也可以实现同步,他们都具备可重入性,但是ReentrantLock增加了一些高级特性:

等待可中断:是指当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待改为处理其他事情。这个特性对于执行时间非常长的同步块很有帮助

公平锁:是指多个线程在等待同一个锁时,必须按照按照申请锁的时间顺序依次获得锁。而非公平锁在被释放时,任何一个线程都有机会获得该锁。ReentrantLock默认提供非公平方式,但是可以通过带布尔值的构造方法要求使用公平锁

锁可绑定多个条件:是指一个ReentrantLock对象可以同时绑定多个Condition对象。而synchronized仅有一个隐含的条件,当需要多条件时,则需要额外添加锁

如果需要使用到上述这些高级功能,ReentrantLock是很好的选择。很多人一直以来对synchronized的性能嗤之以鼻,但是在JDK 1.6之后,synchronized得到了相当多的针对性优化,性能得到了可观的提升。因此,对于JDK 1.6及其之后的使用环境,性能因素不再是选择ReentrantLock的理由了。对于基本使用场景,相对于ReentrantLock在Java语法层面提供的锁定语义,synchronized在字节码层面的支持显然更易于使用(ReentrantLock需要使用者结合try finally显式加解锁,synchronized则无需这么做)

(2)非阻塞同步(Non-Blocking Synchronization)

互斥同步实际上是阻塞同步(Blocking Synchronization),属于一种悲观并发策略,认为只要不做同步就一定会出问题,它最大的问题就是线程阻塞和唤醒所带来的性能问题。但是随着硬件的发展(操作和冲突检测这两个操作需要依赖底层硬件提供原子性),我们有了另一种选择:基于冲突检测的乐观并发策略。通俗来讲,就是先进行操作,如果没有发生数据争用,那么操作就成功了;如果有其他线程发生了数据争用,产生了冲突,那么就采取其他补救措施(最常见的一种方式就是不断重试,直到成功为止),这种乐观并发策略通常都不需要把线程挂起,因此称为非阻塞同步

CAS(Compare-And-Swap)指令就是非阻塞同步的一种实现,它需要三个操作数,分别是内存地址V、旧的预期值A、新值B。当且仅当V地址上的值等于A时,才将该地址上的值设置为B,否则不进行更新。上述过程是一个原子操作。在Java中,CAS操作被封装在sun.misc.Unsafe类中,该类仅允许启动类加载器(Bootstrap ClassLoader)加载的类才能使用,不过却提供了更高级的API供开发者使用,这些原子类位于java.util.concurrent.atomic包中

上一篇系列文章(Java内存模型与线程)中提到的并发下的a++操作,就可以通过原子类很容易的实现

但是CAS操作有一个明显的缺陷,就是著名的ABA问题。所谓ABA问题,就是当变量V初始值为A,并且在准备赋值时检查当前值依然是A,那么我们并不能得出“在此期间V的值没有被改变过”这样的结论,因为在此期间V的值可能从A变成了B,之后又变回了A。为了解决这个问题,JDK提供了AtomicStampedReference,它可以通过控制变量值的版本来解决ABA问题。不过实际场景中,大多数ABA问题,可能并不会影响程序的正确性

锁优化

针对高效并发,HotSpot虚拟机实现了很多锁优化技术,下面对其中一些做简单介绍

(1)自旋锁与自适应自旋

前面提到的互斥同步,其性能最大的影响在于阻塞。挂起和恢复线程需要在用户态和内核态之间切换。但是在大多数情形下,共享数据的锁定状态通常只会持续很短一段时间,为了这很短暂的锁定时间去挂起和恢复线程并不值当。因此,可以尝试让后面请求锁的线程稍等一下,但是并不放弃CPU时间,观察持有锁的线程是否很快会释放锁,这项技术就是自旋锁

自旋锁的局限性也显而易见,就是当持有锁的线程需要执行很长一段时间的时候,后续尝试获取锁的线程将陷入长时间自旋,而自旋依然需要占用CPU时间。因此,对于这种场景并不适用

针对这种情况,引入了自适应自旋技术。自适应自旋意味着自旋时间不再是固定不变的,而是由历史数据来决定:如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就认为这次自旋有很大概率再次成功,因此会适当增加自旋等待时间。相反,如果对于某个锁,自旋很少成功,那么后续的自旋过程将被去除。有了这项技术,随着程序运行时间的积累,性能监控数据将会不断完善,预测也将越来越准确

(2)锁消除

锁消除是指一些代码虽然要求同步,但是实际上不可能存在共享数据争用,针对这种情况对锁进行消除。锁消除的判定依据主要来源于逃逸分析(关于逃逸分析,可以参看本系列文章:运行期优化

那么为什么会存在明明没有共享数据争用却又使用同步的情况呢?原因在于很多同步并非开发者自己加入,比较典型的一个事例出现在JDK 1.5之前:对于字符串拼接操作,编译器会转化为StringBuffer的append操作,而append方法显然是synchronized的

(3)锁粗化

通常情况下,我们在编程过程中,会尽可能把加锁的范围缩小,目的是让其他线程同步等待时间尽可能短。但是如果一系列的操作都是对同一个对象反复加锁解锁,甚至同步操作位于循环体中,那么在同一个对象上如此频繁的同步操作也会带来显著的性能影响

锁粗化就是为了解决这个问题,虚拟机在检测到上述这种情况时,会将加锁同步的范围扩大(粗化)到整个操作序列之外,这样只需要加一次锁就可以了

(4)轻量级锁

传统使用互斥量实现的锁称为“重量级锁”,在JDK 1.6中引入另一种优化机制,它能够在没有多线程竞争的时候,通过CAS操作降低传统重量级锁带来的性能损耗,这种优化机制称为“轻量级锁”

轻量级锁、偏向锁,乃至重量级锁都依赖对象头(Object Header)实现,因此有必要对对象头的内存布局做下简单介绍。Hot Spot虚拟机的对象头主要分为两部分:第一部分用于存储对象自身的运行时数据,如HashCode、GC分代年龄、锁相关信息等,这部分称为“Mark Word”,它是实现锁的关键;另一部分存储对象类型的指针,如果是数组,还会存储数组长度

而对于Mark Word,会根据对象的不同状态,复用空间存储相应的数据,如下图:

下面介绍一下轻量级锁的执行过程:

在进入同步块的时候,如果此时同步对象没有被锁定,虚拟机首先会在当前线程的栈帧中建立一个锁记录(Lock Record)用于存储对象目前的Mark Word拷贝(称为Displaced Mark Word)

随后,通过CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针。如果更新成功,那么线程就拥有了该对象的锁,并且该对象的Mark Word标志位被更新为00,此时即表示对象处于轻量级锁定状态

如果更新失败,会首先检查Mark Word是否指向当前线程的栈帧,如果是则说明当前线程已经拥有了该对象的锁,那么就直接进入同步块继续执行,否则说明锁已经被其他线程占有。如果存在多个线程争用同一个对象的锁,那么轻量级锁就不再适用,需要升级为重量级锁,标志位将更新为10,Mark Word中存储的就是重量级锁的指针,其他等待锁的线程将被阻塞

上面是加锁过程,解锁过程也是通过CAS完成的:如果对象的Mark Word仍然指向线程的Lock Record,那就通过CAS把对象当前的Mark Word和线程中的Displaced Mark Word替换回来,如果成功,同步过程就完成了,如果失败,说明有其他线程尝试过获取该锁,那么在释放锁的同时,需要唤醒其他被挂起的线程

(5)偏向锁

在数据无争用的情况下,轻量级锁通过CAS操作去除同步所使用的互斥量,而偏向锁则更进一步,就是在数据无争用的情况下把整个同步都消除掉。简单来说,偏向锁,就是会偏向第一个获取它的线程,如果在后续的执行中,该锁一直没有被其他线程获取,那么持有偏向锁的这个线程将不再需要进行同步

偏向锁同样依赖Mark Word实现,大致原理是:

当锁对象第一次被线程获取的时候,对象头中标志位将被设为01,同时尝试把获取到这个锁的线程ID通过CAS操作记录在Mark Word中,如果成功,持有偏向锁的这个线程在后续进入被该锁保护的同步块时将不再需要任何同步操作

当有另外的线程尝试获取这个锁时,偏向模式宣告结束。根据锁对象是否处于被锁定状态,状态将被恢复到未锁定或者轻量级锁

偏向锁和轻量级锁都有助于提高带有同步但实际无争用时程序的性能,它们有适用的场景,但也都有局限性,不一定总是能够起到正面作用

思维导图:

笔记9结束

至此,本系列告一段落,按照计划在2018春节前坎坷完成。好脑子不如烂笔头,帮助到自己的同时,也希望可以对大家有所帮助。后续还有其他读书计划,届时再开新篇。祝大家新年快乐,谢谢大家🙏

推荐阅读更多精彩内容