CH13-线程安全与锁优化
13.1 概述
在软件业发展的初期,程序的编写都是以算法为核心,程序员会把数据和过程分别作为独立的部分来考虑,数据代表问题空间中的客体,程序代码则用于处理这些数据,这种思维方式是直接站在计算机的角度去抽象和解决问题,称为面向过程的编程思想。于此相对,面向对象的思想则是站在现实世界的角度去抽象和解决问题,它把数据和行为都看作是对象的一部分,这样可以让程序员以符合现实世界的思维方式来编写和组织程序。
面向过程的编程思想极大的提升了现代软件开发的生产效率和软件可以达到的规模,但是现实世界与计算机世界之间不可避免的存在一些差异。例如,人们很难想象现实中的对象在一项工作进行期间,会被不停的中断可切换,对象的属性可能会在中断期间被修改和变脏,而这些事件在计算机世界中则是很正常的现象。有时候,良好的设计原则不得不向现实做出一些让步,我们必须让程序在计算机中正确无误的运行,然后在考虑如何将代码组织的更好,让程序运行的更快。对于这部分的主题“高效并发”来将,首先需要保证并发的正确性,然后在此基础上实现高效。
13.2 线程安全
“线程安全”这个名词,相信稍有经验的开发者都有听过,甚至在编写和走查代码的时候还会经常挂在嘴边,但是如何找到一个不太拗口的概念来定义线程安全却不是一件容易的事情。
笔者认为 “Java Concurrency In Practice” 的作者 Brian Goetz 对“线程安全”有一个比较恰当的定义:“当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外同步,或者在调用方进行任务其他的协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象就是线程安全的”。
这个定义很严谨,它要求了线程安全的代码必须都具备一个特征:代码本身封装了所有必要的正确性保障手段(如互斥同步等),令调用者无需关心多线程问题,更无须自己实现任何措施来保证多线程的正确调用。这点并不容易做到,在大多数场景中,我们都会将这个定义弱化一些,如果把“调用这个对象的行为”限定为“单次调用”,这个定义的其他描述也能成立的话,我们就可以称之为线程安全了。
13.2.1 Java 中的线程安全
为了更深入理解线程安全,我们可以不把线程安全当做一个非真即假的二元排他选项来看待,安装线程安全的“安全程度”由强至弱来排序,我们可以将 Java 语言中各种操作共享的数据分为以下五类:
1. 不可变
在 Java 语言里面,不可变对象一定是线程安全的,无论是对象的方法实现还是方法的调用者,都不再需要进行任何的线程安全保障措施,在上一章中我们谈到的 final 关键字带来的可见性时曾提到过这一点,只要一个不可变的对象被正确的构建出来(没有发生 this 引用逸出的情况),则其外部的可见状态永远都不会改变,永远也不会看到它在多个线程之中处于不一致的状态。“不可变”带来的安全性也是最简单最纯粹的。
Java 语言中,如果共享的数据是一个基本数据类型,那么只要在定义时使用 final 关键字修饰它就可以保证它是不可变的。如果共享数据是一个对象,那就需要保证对象的行为不会对其状态产生任何影响才行,比如 String 类。
保证对象行为不影响自身状态的途径有很多种,其中最简单的就是把对象中带有状态的变量都声明为 final,这样在构造函数结束之后它就是不可变的。
在 Java API 中符合不可变要求的类型,除了上面提到的 String 之外,常用的还有枚举类型,以及 java.lang.Number 的部分子类,如 Long 和 Double 等数值包装类型,BigInteger 和 BigDecimal 等大数据类型;但同为 Number 子类的原子类 AtomicInteger 和 AtomicLong 则是可变的。
2. 绝对线程安全
绝对的线程安全完全满足 Brian Goetz 对线程安全的定义,一个类要达到“不管运行时环境如何,调用者都不需要任何额外的同步措施”通常需要付出很大的代价。在 Java API 中标注为线程安全的类中,大多数都不是绝对的线程安全。我们可以通过 Java API 中一个不是“绝对线程安全”的线程安全类来看看这类的“绝对”是什么意思。
java.util.Vector 是一个线程安全的容器,因为它所有类似 add、get 的方法都被 synchronized 修饰,尽管这样效率很低,但是确实是安全的。但是,即使它所有的方法都被修饰为同步,也不意味着调用它的时候就无需考虑同步了,如下代码示例:
private static Vector<Integer> vector = new Vector<>();
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);
}
}
运行后将会抛出数组索引越界异常。很明显,尽管这里使用到的 get、remove、size 方法都是同步的,但是在多线程环境中,如果不再方法调用端做任何同步措施,使用这段代码仍然不安全,因为如果另一个线程恰好的时间删除了一个元素,导致序号 i 已经不再可用的话,get(i) 将会抛出数组越界异常。如果要保证这段代码能够正确运行,则要进行如下修改:
Thread removeThread = new Thread(new Runnable() {
@Override
public void run() {
synchronized(vector) {
for(int i=0; i<vector.size(); i++) {
vector.remove(i);
}
}
}
});
Thread printThread = new Thread(new Runnable() {
@Override
public void run() {
synchronized(vector) {
for(int i=0; i<vector.size(); i++) {
System.out.println(vector.get(i));
}
}
}
});
3. 相对线程安全
相对线程安全就是我们通常意义上所说的线程安全,它需要保证对这个对象单独的操作是线程安全的,我们在调用的时候不需要做额外的保障措施,但是对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性。上面的例子可以作为说明。
在 Java 语言中,大部分的线程安全类都属于这种类型,例如 Vector、HashTable、Collections 的 synchronizedCollection() 方法包装的集合等。
4. 线程兼容
线程兼容指的是对象本身并不是线程安全的,但是可以通过在调用端正确的使用同步手段来保证对象在并发环境中安全的使用,我们平常说一个类不是线程安全的,绝大多数指的是这种情况。Java API 中大部分类都是线程兼容的,如与前面的 Vector 和 HashTable 相对应的集合类 ArrayList 和 HashMap。
4. 线程对立
线程对立这得是不管调用段是否采取了同步措施,都无法在多线程环境中并发使用的代码。由于 Java 语言天生就具有多线程特性,线程对立这种排斥多线程的代码是很少出现的,而且通常是有害的,应当尽量避免。
一个线程对立的例子是 Thread 类的 suspend 和 resume 方法,如果有两个线程同时持有一个 Thread 对象,一个尝试去中断线程、一个尝试去恢复线程,如果并发执行这种操作,无论调用时是否进行了同步,目标线程都会存在死锁风险。如果 suspend 中断的线程就是即将要执行 resume 的那个线程,那就肯定要产生死锁了。常见的线程对立的操作还有 System.setIn()、System.setOut()、System.runFinalizersOnExit() 等。
13.2.2 实现线程安全
1. 互斥同步
互斥同步是最常见的一种并发正确性保障手段,同步是指在多个线程并发访问共享数据时,保证共享数据在同一时刻只被一条(或者是一些,使用信号量的时候)线程使用。而互斥是实现同步的一种手段,临界区、互斥量、信号量都是主要的互斥实现方式。因此在这四个字面中,互斥是因,通过是果,互斥是方法,同步是目的。
在 Java 中,最基本的互斥同步手段就是 synchronized 关键字,经过编译之后,会在同步块的前后分别形成 monitorenter 和 monitorexit 这两个字节码指令,这两个指令都需要一个 reference 类型的参数来指明要锁定和解锁的对象。如果 Java 程序中的 synchronized 明确指定了对象参数,那就是这个对象的 reference;如果没有明确指定,那就根据 synchronized 修饰的是实例方法还是类方法,来取对应的对象实例或 Class 对象作为对象锁。
根据 JVM 规范的要求,在执行 monitorenter 指令时,首先要尝试获取对象的锁。如果这个对象没有被锁定,或者当前线程已经持有了该对象的锁,把锁的计数器加 1;相应的,在执行 monitorexit 指令时会将锁的计数器减 1,当计数器为 0 时,所就被释放了。如果获取对象锁失败,那当前线程就要阻塞等待,直到对象锁被另一个线程释放为止。
在 JVM 规范对 monitorenter 和 monitorexit 的行为描述中,有两点是需要特别关注的。首先,synchronized 同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题。其次,同步块在已进入的线程执行完之前,会阻塞后面其他线程的进入。上一章讲过,Java 线程是映射到操作系统原生线程上的,如果要阻塞或唤醒一条线程,都需要依赖操作系统调用,这就需要从用户态切换到内核态,因此状态转换需要耗费很多处理器时间。对于代码中简单的同步块(如被 synchronized 修饰的 getter/setter 方法),状态切换消耗的时间可能比用户代码执行的时间还要长。所以 synchronized 是 Java 语言中一个重量级操作,有经验的程序员都会在确实必要的情况才使用这种操作。而虚拟机本身也会进行一些优化,比如在通知操作系统阻塞线程之前先加入一段自旋等待过程,避免频繁的切入内核态。
除了 synchronized 之外,我们还可以使用 JUC 中的重入锁来实现同步,在基本用法上,重入锁与 synchronized 很相似,它们都具备一样的线程重入特性,只是代码写法上有些区别,一个表现为 API 层面的互斥锁(lock 和 unlock 方法配合 try/finally 语句块来完成),一个表现为原生语法层面的互斥锁。不过重入锁比 synchronized 增加了一些高级功能,主要有以下三项:
- “等待可中断”是指当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理器其他任务,可中断特性对处理的执行时间非常长的同步块很有帮助。
- “公平锁”是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁;而非公平锁则不保证这一点,在锁被释放时,任何一个等待锁的线程都有机会获得锁。synchronized 中的锁是非公平的,重入锁默认情况下也是非公平的,但可以通过构造参数设置。
- “锁绑定多个条件”是指一个重入锁对象可以同时绑定多个 Condition 对象,而在 synchronized 中,锁对象的 wait 和 notify、notifyAll 方法可以实现一个隐含的条件,如果要和多于一个的条件关联的时候,就不得不额外的添加一个锁,而重入锁无需这么做,只需要多次调用 newCondition 方法即可。
如果需要使用到上述功能,选用重入锁是一个很好的选择,那如果是基于性能考虑呢?关于 synchronized 和重入锁的性能问题,Brian Goetz 对这两种锁在 JDK 1.5、单核处理器及双 Xeon 处理器环境做了一组吞吐量对比试验,如下图:
从上面两组试验结构可以看出,多线程环境下 synchronized 的吞吐量下降的非常严重,而重入锁则能基本保持在同一个比较稳定的水平上。与其说重入锁性能好,倒不如说 synchronized 还有非常大的优化空间。后续的技术发展也证明了这一点,JDK 1.6 中加入了很多针对锁的优化措施,这时两者的性能基本持平。因此性能因素不再是选择重入锁的理由了,JVM 在未来的性能改进中肯定也会更加偏向于原生的 synchronized,所以还是提倡在 synchronized 能够实现需求的情况下有限选择。
2. 非阻塞同步
互斥同步最主要的问题就是进行线程阻塞和换新所带来的性能问题,因此这种同步也被称为阻塞同步。另外,它属于一种悲观的并发策略,总是认为只要不去做正确的同步措施(即加锁),那就肯定会出问题,无论共享数据是否真的会出现竞争,它都要求进行加锁(这里说的是概念模型,实际上虚拟机会优化掉很大一部分不必要的加锁)、用户态与内核态切换、维护锁计数器和检查是否有被阻塞的线程需要被唤醒等操作。随着硬件指令集的发展,我们有了另一种选择:基于冲突检测的乐观并发策略,通俗的说就是先进行操作,如果没有其他线程争用共享数据,那操作就成功了;如果共享数据被争用,这时就产生了冲突,然后再进行其他补偿措施(最常见的补偿措施就是不断重试直到成功),这种乐观的并发策略的许多实现都不需要把线程挂起,因此这种同步操作被称为非阻塞同步。
为什么说使用乐观并发策略需要“硬件指令集的发展”才能进行呢?因为我们需要操作和冲突检测这两个步骤具有原子性,靠什么来保证呢?如果这里再使用互斥同步来保证就是去意义了,所以我们只能靠硬件来完成这件事情,硬件保证一个从语义上看起来需要多次操作的行为只通过一条处理器指令就能完成,这类指令常用的有:
- 测试并设置,TAS
- 获取并增加,FAI
- 交换,Swap
- 比较并交换,CAS
- 加载链接/条件存储,LL/SC
其中,前面的三条是上个世纪就存在于大多数指令集中的处理器指令,后面的两条是现代处理器新增的,而且这两条指令的目的和功能是类似的。在 IA64、x86 指令集中通过 cmpxchg 指令完成 CAS 功能,在 sparc-STO 中也有 casa 指令实现,而在 ARM 和 PowerPC 架构下,则需要使用一对 ldrex/strex 指令来完成 LL/SC 功能。
CAS 指令需要有三个操作数,分别是内存位置(即 Java 中变量的内存地址,V)、旧的预期值(A)、新值(B)。CAS 指令执行时,当且仅当 V 符合旧的预期值 A 时,处理器用新值 B 更新 V 的值,否知它就不执行更新,但是不管是否更新了 V 的值,都会返回 旧 V 的值,上述的处理过程是一个原子操作。
在 JDK 1.5 之后,Java 程序中才可以使用 CAS 操作,该操作由 sun.misc.Unsafe 类中的 compareAndSwap 和 compareAndSwapLong 等几个方法提供,JVM 内部对这些方法做了特殊处理,即时编译出来的结果就是一条平台相关的处理器 CAS 指令,没有方法调用的过程,或者可以认为是无条件内联进去了。
由于 Unsafe 类不是提供给用户程序调用的类(Unsafe.getUnsafe 的代码中限制了只有启动类加载器加载的类才可以访问它),如果不采用反射手段,我们只能通过其他 Java API 来间接访问它,如 JUC 包中的整数原子类。
尽管 CAS 看起来很美,但显然这种操作无法涵盖互斥同步的所有使用场景,并且 CAS 从语义上来说并不完美,存在这样一个逻辑漏洞:如果一个变量 V 初次读取的时候是 A 值,并且在准备赋值的时候检查它仍然是 A 值,那我们就能说它的值没有被其他线程改变过吗?如果在这段时间内它的值曾经被改为了 B,后来又被改回了 A,那 CAS 操作就会误认为它从来没有被改变过。该漏洞被称为 CAS 操作的 ABA 问题。JUC 为了解决该问题,提供了一个带有标记的原子引用类 AtomicStampedReference,它可以通过控制变量值的版本来保证 CAS 的正确性。不过目前来说这个类比较鸡肋,大部分情况下 ABA 问题不会影响程序并发的正确性,如果需要解决 ABA 问题,改用传统的互斥同步可能会比原子类更有效。
3. 无同步方案
要保证线程安全,并不是一定就要进行同步,两者没有因果关系。同步只是保障共享数据争用时的正确性手段,如果一个方法本来就不涉及共享数据,那它自然就无需任何同步措施来保证正确性,因此会有一些代码天生就是线程安全的。
“可重入代码”:这种代码也叫纯代码,可以在代码执行的任何时刻中断它,转而去执行另外一段代码(包括递归调用它自身),而在控制权返回后,原来的程序不会出现任何错误。相对线程安全来说,可重入性是更基本的特性,它可以保证线程安全,即所有的可重入代码都是线程安全的,但是并非所有的线程安全的代码都是可重入的。
可重入代码有一些共同的特征:比如不依赖存储在堆上的数据和公共的系统资源、用到的状态都由参数传入、内部不调用非可重入的方法等。我们可以通过一个简单的原则来判断代码是否具有可重入性:如果一个方法,它的返回结果是可以预测的,只要输入了相同的数据,就能返回相同的结果,那它就满足可重入性的要求,当然它就是线程安全的。
“线程本地存储”:如果一段代码中所需要的数据必须与其他代码共享,那就看看这些共享数据的代码是否能保证在同一个线程中执行?如果能保证,我们就可以把共享数据的可见范围限制在同一个线程内,这样,无需同步也能保证线程间不出现数据争用的问题。
符合这种特点的应用很常见,大部分使用消费队列的架构模式(如生产者——消费者)都会将产品的消费过程尽量在一个线程中消费完,其中最重要的一个应用实例就是经典 Web 交互模式中的“一个请求对应一个服务器线程(Thread-per-Request)”的处理方式,这种处理方式的广泛应用使得 Web 服务器的很多应用都可以使用线程本地存储来解决线程安全问题。
Java 语言中,如果一个变量要被多线程访问,可以使用 volatile 关键字声明它为“易变的”;如果一个变量要被某个线程独享,因为 Java 中没有类似 C++ 中 __declspec(thread)
这样的关键字,不过可以通过 java.lang.ThreadLocal 类来实现线程本地存储的功能。每一个线程的 Thread 对象中都有一个 ThreadLocalMap 对象,这个对象存储了一组以 ThreadLocal.threadLocalHashCode 为键,以本地线程变量为值的 K-V 键值对,ThreadLocal 对象就是当前线程的 ThreadLocalMap 的访问入口,每一个 ThreadLocal 对象都包含了一个独一无二的 threadLocalHashCode 值,使用这个值就可以在线程 K-V 键值对中找回对应的本地线程变量。
13.3 锁优化
HotSpot JVM 开发团队在 JDK 1.6 版本上花费了大量精力来实现各种锁的优化技术,所有这些优化都是为了在线程间更加高效的共享数据,以解决竞争问题,从而提高程序的执行效率。
13.3.1 自旋锁与自适应自旋
前面我们讨论同步的时候,提到了互斥同步对性能影响最大的是阻塞式的实现,挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作给系统并发性带来了很大的压力。同时,虚拟机开发团队也注意到在很多应用上,共享数据的锁定状态一般会持续很短一段时间,为了这段很短的时间去挂起和恢复线程很不值得。如果物理机有一个以上的处理器,能让两个或两个以上的线程同时并行执行,我们就可以让后面请求锁的线程“稍等一会”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就能释放锁。为了让线程等待,我们只需让线程执行一个忙循环(自旋),这就是所谓的自旋锁。
自旋锁自 JDK 1.4.2 引入,只不过是默认关闭的,可以使用 -XX:UseSpinning 参数来开启,到了 1.6 就已经改为默认开启了。自旋锁不能代替阻塞,且先不说对处理器数量的要求,自旋等待本身虽然避免了线程切换的开销,但它要是要占用处理器执行时间的,所以如果被占用的时间很短,自旋等待的效果会很好,反之,自旋的线程只会白白消耗掉处理器资源,造成性能浪费。因此自旋等待的时间必须要有一个限度,如果自旋超过了限定次数仍然没有获得锁,那就应当使用传统的方式去挂起线程。自旋次数的默认值是 10,用户可以使用参数 -XX:PreBlockSpin 来修改。
在 JDK 1.6 中引入了自适应自旋锁。自适应意味着自旋的次数不再固定,而是由前一次在同一个锁上自旋的时间及锁的拥有者线程的状态来决定的。如果在同一个锁对象上,自旋等待刚刚成功获得锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋很有可能会再次成功,进而它将允许自旋等待持续相对更长的时间,比如 100 个循环。另一方面,如果对于某个锁,自旋很少获得成功,那在以后要获取这个锁时将可能省略掉自旋过程,以避免浪费处理器时间。有了自适应自旋,随着程序运行和性能监控信息的不断完善,虚拟机对程序锁的状况监控就会越来越准确,虚拟机就会变得越来越聪明了。
13.3.2 锁消除
锁消除指的是虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在数据竞争的锁进行消除。锁消除的主要判断依据来自逃逸分析和数据支持,如果判断到一段代码中,在堆上的所有数据都不会逃逸出去被其他线程访问到,那就可以把它们当做栈上数据对待,认为它们是线程私有的,同步加锁自然也就无需运行。
也许读者会有疑问,变量是否逃逸,对于虚拟机来说需要使用数据流分析来确定,但是程序员自己应该是很清楚的,怎么会在明知道不存在数据竞争的情况下要求同步呢?答案是有许多同步措施并非由程序员加入,同步代码在 Java 程序中的普遍程度也许超过了大部分读者的想象。比如下面一段代码:
public String concatString(String s1, String s2, String s3) {
return s1 + s2 + s3;
}
由于 String 是一个不可变类,对字符串的连接操作总是通过生成新的 String 对象进行的,因此 javac 编译器会对 String 连接做自动优化。在 JDK 1.5 之前,会转换为 StringBuffer 对象的连续 append 操作,在 JDK 1.5 及之后的版本中,会转换为 StringBuilder 对象的连续 append 操作。
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 方法中都有一个同步块,锁就是 sb 对象。虚拟机观察变量 sb,很快就发现它的动态作用域被限制在 concatString 方法内部。即 sb 的所有引用永远不会逃逸到 concatString 方法的外部,其他线程也就无法访问它,所以这里虽然有锁,但是可以被安全的消除掉,在即时编译之后,这段代码就会忽略掉所有的同步而直接执行了。
13.3.3 锁粗化
原则上,我们在编写代码的时候,总是推荐将同步块的作用范围限制的尽量小——只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变小,如果存在锁竞争,那等待锁的线程也可以尽快得到锁。
大部分情况下,上面的原则都是正确的,但是如果一系列的连续读操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体中,那即使没有线程竞争,频繁的进行同步操作也会带来不必要的性能损耗。
上述代码中连续 append 方法就属于这类情况。如果虚拟机探测到有这样一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部,以上述字符串连接为例,就是扩展到第一个 append 操作之前直至最后一个 append 操作之后,这样一来仅需加锁一次就够了。
13.3.4 轻量级锁
轻量级锁是 JDK 1.6 中加入的新型锁机制,“轻量级”是相对于使用操作系统互斥量来实现的传统锁而言的,因此后者也可以被称为“重量级锁”。首先需要强调的一点是,轻量级锁并非用来替代重量级锁,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用系统互斥量带来的性能损耗。
要理解轻量级锁,以及后面会讲到的偏向锁的原理和运作过程,必须从 HotSpot 虚拟机的对象(对象头部分)的内存布局开始介绍。HotSpot 虚拟机的对象头分为两部分信息,第一部分用于存储对象自身的运行时数据,如哈希码、GC 分代年龄等,这部分数据的长度在 32 位和 64 位的虚拟机中分别为 32 个和 64 个 Bits,官方称它为“Mark Word”,它是实现轻量级锁和偏向锁的关键。另外一部分用于存储指向方法区对象的类型数据的指针,如果是数组对象的话,还会有一个额外的部分用于存储数组长度。
对象头信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效率,Mark Word 被设计成一个非固定数据及饿哦股以便在极小的空间内存储尽可能多的信息,它会根据对象的状态复用自己的存储空间。例如在 32 位的 HotSpot 虚拟机中对象未被锁定的状态下,Mark Word 的 32 个 Bits 空间中的 25 Bits 用于存储对象哈希码,4 Bits 用于存储对象的分代年龄,2 Bits 用于存储锁标志位,1 Bit 固定为 0,在其他状态(轻量级锁定、重量级锁定、GC 标记、可偏向)下对象的存储内容如下表:
在代码进入同步块时,如果此同步对象没有被锁定(锁标志位为 01 状态),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的 Mark Word 的拷贝(官方把这分拷贝加了一个 Displaced 前缀,即 Displaced Mark Word),这时候线程堆栈与对象头的状态如下图所示。
然后,虚拟机将使用 CAS 操作尝试将对象的 Mark Word 更新为指向 Lock Record 的指针。如果该更新动作成功,那么这个线程就拥有了该对象的锁,并且对象 Mark Word 的锁标志位(Mark Word 的最后两 Bits)将转变为 “00”,即表示此对象处于轻量级锁定状态,这时候线程堆栈及对象头的状态如下图所示。
如果这个更新操作失败了,虚拟机首先会检查对象的 Mark Word 是否指向当前线程的栈帧,如果是,就说明当前线程已经拥有了这个对象的锁,可以直接进入同步块继续执行,否则说明这个锁对象已经被其他线程抢占了。如果有两个以上的线程争用同一个锁,那轻量级锁就不再有效,要膨胀为重量级锁,锁标志的状态变为“10”,Mark Word 中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。
上面买描述的是轻量级锁的加锁过程,它的解锁过程也是通过 CAS 操作来进行的,如果对象的 Mark Word 仍然指向着线程的锁记录,那就用 CAS 操作把对象当前的 Mark Word 和线程中复制的 Displaced Mark Word 替换回来,如果替换成功,整个同步过程就完成了。如果替换失败,说明其他线程尝试过获取该锁,那就要在释放锁的同时,唤醒被挂起的线程。
轻量级锁能够提高程序同步性能的依据是,“对于绝大部分的锁,在整个同步周期内都是不存在竞争的”,这是一条经验数据。如果没有竞争,轻量级锁使用 CAS 操作就避免了使用互斥量的开销,但如果存在锁竞争,除了互斥量的开销外,还额外发生了 CAS 操作,因此在有竞争的情况下,轻量级锁会比传统的重量级锁更慢。
13.3.5 偏向锁
偏向锁也是 JDK 1.6 引入的一项锁优化,它的目的是消除数据在无竞争情况下的同步原语,进一步提高程序的执行性能。如果说轻量级锁是在无竞争情况下使用 CAS 操作来消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步都消除掉,连 CAS 操作都不做了。
偏向锁的“偏”类似于“偏心”、“偏袒”中的“偏”。它的意思是这个锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁没有被其他线程获取,则持有偏向锁的线程将永远不需要再次进行同步。
如果读者读懂了前面轻量级锁中关于对象头 Mark Word 与线程之前的操作过程,那偏向锁的原理理解起来就会很简单。假设当前虚拟机启用了偏向锁,那么,当锁对象第一次被线程获取时,虚拟机就会把对象头中的标志位设置为 “01”,即偏向模式。同时使用 CAS 操作把获取到该锁的线程 ID 记录在对象头 Mark Word 中,如果 CAS 操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作(如 Locking、Unlocking 及对 Mark Word 的 Update 等)。
当另外一个线程去尝试获取这个锁时,偏向模式就宣告结束。根据锁定对象目前是否处于被锁定的状态,撤销偏向后恢复到未锁定(标志位为“01”)或轻量级锁定状态(标志位为“00”)的状态,后续的同步操作就如上面介绍的轻量级锁那样执行。偏向锁、轻量级锁的状态转换及对象 Mark Word 的关系如下图所示。
偏向锁可以提高带有同步但无竞争的程序性能。它同样是一个带有效益权衡性质的优化,即并不一定总是对程序的运行有利,如果程序中大多数的锁总是被多个不同的线程访问,那偏向模式就是多余的。在具体问题具体分析的前提下,有时候使用参数 -XX:UseBiasedLocking 来禁止偏向锁优化反而可以提升性能。
13.4 本章小结
本章介绍了线程安全所涉及的概念和分类、同步实现的方式及虚拟机的底层运作原理,并且介绍了虚拟机为了实现高效并发所采取的一系列锁优化措施。
许多资深的程序员都说过,能够写出高伸缩性的并发程序是一门艺术,而了解并发在系统底层是如何实现的,则是掌握这门艺术的前提条件,也是成长为高级程序员的必备知识之一。
Feedback
Was this page helpful?
Glad to hear it! Please tell us how we can improve.
Sorry to hear that. Please tell us how we can improve.