第13章 线程安全与锁优化
13.1 概述
-
面向过程的编程思想
将数据和过程独立分开,数据是问题空间中的客体,程序代码是用来处理数据的,这种站在计算机角度来抽象和解决问题的思维方式,称为面向对象的编程思想。
-
面向对象的编程思想
将数据和行为看作是对象的一部分,这种站在现实世界的角度去抽象和解决问题的思维方式,称为面向对象的编程思想。
13.2 线程安全
首先我们先来看下在《Java Concurrency In Practice》中,作者Brian Goetz是如何来定义线程安全的:
当多个线程访问一个对象的时候,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都能获得正确的结果,那这个对象是线程安全的。
通过以上的定义从中梳理出线程安全的代码所具备的一个必须特征是:代码本身封装了所有必要的正确性保障手段(例如:互斥同步),调用者无需考虑多线程的问题,更无须自己采取任何措施来保证多线程的正确调用。
13.2.1 Java语言中的线程安全
了解了什么是现成安全之后,让我们来基于Java语言说下,线程安全是如何实现的?哪些操作是线程安全的。
按着线程安全的“安全程度”由强至弱来排序,Java语言中操作共享的数据可以分为5类:不可变、绝对线程安全、相对线程安全、线程兼容和线程对立。
-
不可变
-
不可变的对象一定是线程安全的。
-
如果共享数据是基本数据类型,那么它被final关键字修饰的话就可以保证它是不可变的。
-
如果共享数据是对象,那就需要保证对象的行为不会对它自身的状态产生影响。拿java.lang.String为例,它是一个典型的不可变对象,我们调用它的substring()、replace()和contract()这些方法都不会影响它原来的值,只会返回一个新构造的字符串对象。
-
-
绝对线程安全
绝对线程安全要完全满足Brian Goetz给出的对线程安全的定义,即无论任何运行时环境,调用者都不需要进行额外的同步措施。
Java API中标注自己为线程安全的类,实际上都不是绝对线程安全的,我们拿java.lang.Vector为例,众所周知它的add()、size()、remove()和get()方法都是被synchronized修饰的,即使这样也不能保证任何时候调用它的都不需要同步手段了。以下面的代码段为例:
private static Vector<Integer> vector = new Vector<Integer>(); public static void main(String[] args) { while (true) { for (int i = 0; i < 10; i++) { vector.add(i); } Thread removeThread = new Thread(new Runnable() { @Override public void run() { for (int i = 0; i < vector.size(); i++) { vector.remove(i); } } }); Thread printThread = new Thread(new Runnable() { @Override public void run() { for (int i = 0; i < vector.size(); i++) { System.out.println((vector.get(i))); } } }); removeThread.start(); printThread.start() //不要同时产生过多的线程,否则会导致操作系统假死 while (Thread.activeCount() > 20); } }
运行结果可能如下:
Exception in thread "Thread-59775" java.lang.ArrayIndexOutOfBoundsException: Array index out of range: 18 at java.base/java.util.Vector.get(Vector.java:750) at com.datapix.dao.platform.mapper.VectorTest$2.run(VectorTest.java:32) at java.base/java.lang.Thread.run(Thread.java:842)
通过运行结果,我们可以看到,如果不在调用端加上额外的同步措施,这段代码仍然是不安全的。想象下在removeThread线程中删除一条元素,导致序号i不在可用的话,printThread线程通过序号i再去访问就会抛出ArrayIndexOutOfBoundsException异常。
要想保证代码段正确执行下去,我们需要在调用端进行一些同步处理,如下:
-
相对线程安全
-
我们通常所讲的线程安全说的就是相对线程安全。
-
相对线程安全需要保证对象的单独操作是现成安全的,我们在调用的时候不需要进行额外的保障措施。
而对于一些特定的顺序的连续性调用,就需要调用端采用一些额外的同步手段来保证调用的正确性,例如:上边绝对线程安全中提到的那个代码段。
-
-
线程兼容
- 我们通常说的线程不安全指的就是这类情况。
- 线程兼容是指对象本身是现成不安全的,但是可以通过在调用端采取一些同步手段来保证对象在并发的环境下可以安全的使用。
- 在Java中的ArrayList、HashMap都属于线程兼容。
-
线程对立
线程对立是指无论调用端是否采用额外的同步措施,都无法在多线程的环境中并发使用的代码。
13.2 线程安全的实现方法
13.2.1 同步
同步是指多个线程并发访问共享数据的时候,保证共享数据在同一时刻只被一个线程使用。
13.2.2 互斥同步
-
互斥作为实现同步的一个手段。
-
互斥同步又称为“阻塞同步”,使用的是悲观并发策略。
-
互斥的实现方式分为:临界区(Critical Section)、互斥量(Mutex)和信号量(Semaphore)。
-
Java中互斥同步手段有:关键字synchronized和J.U.C(java.util.concurrent)的重入锁(ReentrantLock)。
-
关键字synchronized
synchronized编译后,会在同步块的前后增加monitorenter和monitorexit两个字节码指令,这两个字节码需要reference类型的参数作为锁定和解锁的对象。
monitorenter和monitorexit字节码指令执行过程描述如下:
当执行monitorenter指令的时候,当前线程首先会去尝试获取对象锁。如果对象没有被锁定,或者当前线程已持有该对象锁,则锁的计数器+1。相应的,当执行monitorexit指令的时候,锁的计数器-1,直到计数器为0,锁被释放。如果对象锁获取失败,则当前线程会阻塞等待,直到其他线程将锁释放掉为止。
synchronized同步块对于同一条线程来说是可重入的,不会出现自己把自己锁死的情况出现。
通过下面的代码段进一步说明不会出现自己把自己锁死的情况:
public class ReentrantTest { public synchronized void reentrant() { // 标记① synchronized(this) { // 标记② // do something .... } } }
让我们来试想下reentrant()方法在一个线程中(假设这个线程的名字是【线程A】)的调用:【线程A】调用reentrant()方法(即「标记①」)的时候,【线程A】获取了this对象锁(即对象锁中会标记已被【线程A】占用),在执行到「标记②」的时候,需要再次获取this对象锁,但是由于此时this对象锁已被【线程A】所占用(且「标记②」又是【线程A】中的一个步骤)。
这里岂不是出现了自己把自己锁死了的情况了嘛?!
但是基于synchronized是可重入的特性,即获取锁的线程(即占用锁的线程)与此时正要获取锁的线程是同一个,那么就不需要阻塞等待了。事实上在获取this对象锁的线程与「标记②」所在的线程也是同一个线程(都是【线程A】),所以这里也就不需要阻塞等待,也就不会出现死锁了!
-
J.U.C(java.util.concurrent)的重入锁(ReentrantLock)
在基本语法上,ReentrantLock与synchronized很相似,它俩都是线程可重入的。只是在代码写法上有所不同,ReentrantLock需要显性的编写lock()和unlock()方法,而synchronized则不需要。
相比synchronized,ReentrantLock还提供了一些高级功能,主要有:等待可中断、可实现公平锁(ReentrantLock默认是非公平锁)、锁可以绑定多个条件。
-
13.2.3 非阻塞同步
-
非租塞同步是使用了乐观并发策略。
-
简单说来,非租塞同步就是先进行操作,如果操作期间没有其他线程使用共享数据,则操作成功;如果操作期间有其他线程也使用了共享数据,出现了共享数据争用的情况,那就需要采取其他补救措施了(例如:重试直到成功为止),通常这个过程不需要将线程挂起。
-
比较并交换(Compare-and-Swap,下文称CAS),CAS指令有三个操作数:变量的内存地址、旧值和新值。当CAS指令执行时,比较旧值与变量的内存地址中的值,如果相同,则将变量的内存地址的值更新为新值。如果不相同就不更新。无论更新与否,都将返回旧值。
CAS指令虽然有两个动作(比较和更新),但这个指令是一个原子操作(靠硬件来实现的)。
CAS语义上并不完美,存在一个逻辑漏洞(即“ABA”问题):在CAS进行更新前的比较操作时,我们发现此时变量的内存地址上的值与旧值相同,就此我们能断定变量的内存地址上的值没有变动过吗?如果这期间有其他线程将变量的内存地址上的值先变为C,又变回了A。CAS是无法感知这个变化过程的,它会认为变量的内存地址上的值没有发生过变化。
针对这个逻辑漏洞(即“ABA”问题),我们该如何解决呢?下面介绍三个方法:
- 使用J.U.C提供的原子引用类(AtomicStampedReference),它通过变量值的版本来确保CAS的正确性。
- 直接采用互斥同步
- 直接无视(根据具体的情况),大部分情况ABA问题不会影响程序并发的正确性。
13.2.4 无同步方案
-
要保证线程安全,并不一定要同步,两者并无因果关系。
-
同步只是为了确保共享数据在被争用时的正确性。
-
如果一段代码根本不涉及共享数据,也就无需靠同步措施来保证正确性,这样的代码天生就是线程安全的。下面简单介绍下两种天生线程安全的代码:
-
可重入代码(Reentrant Code)
这种代码又叫做纯代码(Pure Code),可以在代码执行的任何时候中断它,转而去执行另外一段代码,而在控制权返回后,原来的程序不会出现任何错误。
-
线程本地存储(Thread Local Storage)
如果一段代码中的数据必须与其他代码共享,那就要看看使用共享数据的代码是否能在一条线程中执行,如果可以,就无需考虑同步问题了。
举个下边的例子再来对照着去理解下,如下图:
从图中我们看到,【代码段1】中的「操作人」是【代码段2】中也要用到的,所以「操作人」是两段代码(代码段1和代码段2)的共享数据,【代码段1】和【代码段2】又是在一条线程当中执行,所以共享数据(即「操作人」)无需同步。
-
13.3 锁优化
锁优化技术的目的是为了在线程之间更高效的共享数据,以及解决冲突问题,从而提高程序的执行效率。
13.3.1 自旋锁与自适应自旋
-
自旋锁
通过上文【13.2.2】中关于互斥同步的介绍,我们可以知道互斥同步最大的性能消耗在与对阻塞的实现,挂起线程和恢复线程的操作都需要转入内核态去完成,这样会给系统的并发性能带来很大的压力。实际上,通常共享数据的锁定状态只会持续很短的时间,为了避免这段时间的开销而引进挂起和恢复的开销,是有些得不偿失的。
为了解决上诉的问题,自旋锁这项技术就出现了。它使后面(时间概念上)请求锁的线程忙循环(即自旋)一段时间(也就是等待一段时间),而不是直接放弃处理器的执行时间去挂起,看看持有锁的线程能否很快的释放锁。
试想一下,如果持有锁的线程一直不释放锁,那另外一个线程就要一直忙循环(即自旋)下去,这样该线程也将一直占用处理器的执行时间,被占用的处理器也就无法去处理其他线程了,这样肯定是不适合的。所以对于自旋的时间是要有限制的,如果超过了这个时间限制(实际上是自旋的次数)还没有获得锁,那就还是采用传统的方式将线程挂起。
默认情况下自旋次数为10,我们可以通过参数-XX:PreBlockSpin来修改自旋次数。
-
自适应的自旋锁
在JDK1.6中还引入了自适应的自旋锁。
所谓自适应就是自旋时间不再固定,自旋时间是通过上一次获取该锁的自旋时间以及该锁拥有者的状态来决定。
我们假设【线程A】想去获取一个锁对象,该锁刚刚被【线程B】通过自旋等待获得,且【线程B】正在执行中,那么虚拟机就会认为【线程A】通过自旋等待也可以成功获得该锁,进而虚拟机会允许【线程A】的自旋等待时间更长,比如100次循环。
我们再假设另外一个场景:某个锁,通过自旋很少成功获得,结果将如何呢?答案是:再之后获取锁的时候会直接省去自旋的过程,避免造成处理器资源的浪费。
13.3.2 锁消除
在之前的《深入理解JAVA虚拟机(第2版)》- 第11章 - 学习笔记》笔记中在总结基于逃逸分析结果会有哪些优化的时候,提到的同步消除就是锁消除。
锁消除是指虚拟机的即时编译器在运行时,对那些代码上要求同步,但被检测到不存在共享数据竞争的锁进行消除。
13.3.3 锁粗化
虚拟机如果发现一串操作都是对同一个对象进行加锁,会将加锁同步的范围到扩展(粗化)至整个操作序列的外部,这样加一次锁就可以了。
以下边的代码段为例:
public String concatString(String s1, String s2, String s3) {
StringBuffer sb = new StringBuffer();
sb.append(s1); // 标注①
sb.append(s2); // 标注②
sb.append(s3); // 标注③
return sb.toString();
}
StringBuffer的append()方法是被关键字synchronized修饰的。代码段中的标注①、标注②、标注③,这一串操作都是第对象sb加锁,所以最终会将加锁同步的范围扩展为sb.append(s1)操作之前直到sb.append(s3)操作之后。
13.3.4 轻量级锁
-
JDK1.6中引入的一项锁优化。
-
轻量级锁不是为了替代重量级锁而出现的,它是为了在不存在多线程竞争的情况下,减少重量级锁使用系统互斥量所带来的性能消耗。
-
谈轻量级锁和后边的偏向锁的时候,我们是绕不开实现它们的关键——对象头(Object Head)。HotSpot虚拟机的对象头(Object Head)有两部分组成:
- 存储对象自身的运行时数据,例如:对象的HashCode、GC分代年龄,这部分数据官方称为Mark Word,它的长度在32位和64位虚拟机中分别为32bit和64bit。
- 存储指向方法区的对象的类型数据指针。
- 如果对象是数组的话,还需要有额外的部分用来存储数组的长度。
-
考虑虚拟机到空间效率问题,Mark Word被设计成一个非固定的数据结构,即根据对象当前所处的不同状态,存储的数据也不同,如下:
状态 锁标志位 存储的数据 未锁定 01 对象HashCode、GC分代年龄 轻量级锁定 00 指向锁记录(Lock Record)的指针 重量级锁定(膨胀) 10 指向重量级锁(互斥量)的指针 GC标记 11 空(不需要记录信息) 可偏向 01 偏向线程ID、偏向时间戳、GC分代年龄 让我们来看下一个处于未锁定状态的对象在32位虚拟机中的它的Mark Word内存布局是什么样的,如下:
-
轻量级锁的加锁过程,如下图:
我们再具体的看下CAS操作前后Lock Record和对象的状态变化,如下图: -
轻量级锁的解锁过程(解锁过程和加锁过程一样都是通过CAS操作来进行的):如果对象的Mark Word仍然指向Lock Record,那就使用CAS操作将Lock Record中的Displace Mark Word替换掉此时对象的Mark Word。如果替换成功,则整个同步过程完成。如果失败,则说明有其他线程尝试获取锁,那么后续就要在释放锁的同时唤醒被挂起的线程。
13.3.5 偏向锁
-
JDK1.6引入的一项锁优化机制。
-
偏向锁会偏向于获取该锁的第一个线程,如果后续没有其他线程来获取该锁,则持有偏向锁的线程将永远不需要再进行同步。
-
偏向锁的原理是:
假设当前虚拟机启用了偏向锁(启用参数-XX:+UseBiasedLocking,这是JDK 1.6的默认值)。
当对象第一次被线程获取的时候,将对象头中的锁标志位设置为“01”,即偏向模式。同时将获取该对象锁的线程ID通过CAS操作记录在对象的Mark Word中。如果CAS操作成功,则持有该锁的线程以后每次进入该锁相关的同步块时,虚拟机都不会进行任何同步措施(例如:Locking、Unlocking以及Mark Word的Update操作)。
当有另外的线程获取该锁时,则偏向模式结束。根据锁对象当前是否处于被锁定状态,撤销偏向锁后恢复到未锁定状态或轻量级锁的状态。
13.3.6 锁升级
对于一个重量级锁,通过锁优化我们了解到,它其实是一个升级的过程:偏向锁 -> 轻量级锁 -> 重量级锁,而不是不管什么情况都直接采用重量级锁(互斥同步)。
锁的升级过程:对象锁只有一个线程持有的时候,这个锁是偏向锁。当有两个以上的线程交替持有该锁的时候,此时锁是轻量级锁。当发生两个以上的线程同时要持有锁的时候(即并发获取锁),此时才会升级为重量级锁。
上一篇:《深入理解JAVA虚拟机(第2版)》- 第12章 - 学习笔记
下一篇:无(本篇为最终章)