1. 介绍

本文是我看了并发编程网“聊聊并发”系列文章的总结。查看完整原文地址点我查看

PS:这里的原理分析,针对的版本是JDK1.6。Java SE1.6中为了减少获得锁和释放锁带来的性能消耗而引入的偏向锁和轻量级锁,以及锁的存储结构和升级过程。本文只讨论synchronized这个关键字(内置锁)在JDK1.6的实现原理,不讨论JAVA中的ReentranLock等

2. 操作系统层面的锁和应用层面的锁

这次我们聊的主题是JAVA的内置锁,属于较高层面的概念,但是为了把问题说清楚,在后面自然会提到操作系统层面的一些锁的概念。为了防止混淆,在文章开头我就希望先把不同层面上讨论的锁先做个区分。我个人将其分为:

  1. 操作系统层面的锁: 涉及操作系统实现、CPU处理原理
  2. 应用层面的锁:涉及程序实现,例如JAVA语言jvm实现锁——本节讨论的内置锁均属于该范畴

应用层面的锁均需要依赖操作系统层面的锁提供的支持来实现。这是两种层面的锁的联系。

2.1 操作系统层面的锁

操作系统层面的锁主要是两类,

  1. 互斥锁: 一般用互斥信号量实现。OS课程上应该有印象。互斥锁往往会导致线程挂起,从而带来一些线程上下文切换的开销。适合锁保持时间较长的情况。
  2. 自旋锁: 获取访问互斥资源的锁失败,不会造成线程挂起和线程上下文切换,适合锁保持时间较短的情形。

关于OS层面互斥锁、自旋锁的实现原理涉及OS和CPU层面的知识,有需要的同学可以自行了解。

2.2 应用层面的锁

编程语言、软件开发层面的锁的实现和使用都可以归结为应用层面的锁。本文也主要讨论应用层面的锁,即JAVA对应用层面的锁的实现。

标题中提到的内置锁即我们要分析的应用层面的锁的实现原理。

JAVA中的内置锁,在JDK1.6之后又细分为:

  1. 偏向锁
  2. 轻量级锁
  3. 重量级锁

这三种都是内置锁。这种内置锁底层的实现自然也依赖OS层面的锁。

3. synchronized与内置锁

3.1 内置锁说明

内置锁使用synchronized关键字实现。在JDK1.6之前,内置锁往往被认为等价于“悲观所”、“重量级锁”、“拿不到锁会线程阻塞”、“线程上下文切换开销”。但是在JDK1.6之后,这些观点就是不正确的了。这个需要注意。网上很多博客把内置锁等价于“重量级锁”在JDK1.6之后是不准确的。正确的说法,应该是:重量级锁等价于“拿不到锁会线程阻塞”、“线程上下文切换开销”。因为JDK1.6之后引入了锁升级的概念,使用内置锁可能使用重量级锁,也可能使用的是偏向锁或者轻量级锁。

3.2 synchronized关键字

内置锁使用synchronized关键字实现,synchronized关键字有两种用法:

  1. 修饰需要进行同步的方法(所有访问状态变量的方法都必须进行同步),此时充当锁的对象为调用同步方法的对象
  2. 同步代码块: 和直接使用synchronized修饰需要同步的方法是一样的,但是锁的粒度可以更细

JAVA的内置锁在不发生大量锁竞争的时候效率还是很高的。
内置锁可以保证原子性(区别volatile)和可见性。其之所以保证了原子性,是因为用了互斥锁锁住了内存。

3.3 锁的信息存放在哪

当一个线程试图访问同步代码块时,它首先必须得到锁,退出或抛出异常时必须释放锁。

锁的信息存放在对象头。在32位和64位下,对象头的三部分内容是:

3.3.1 在32位JVM中Mark word结构

Mark Word为32位长度,结构如下:


3.3.2 在64位JVM中 Mark word结构

Mark Word为64位长度,结构如下:

3.4 锁升级

现在synchronized关键字来使用内置锁,在内部实现的时候引入了不同级别的锁状态(JDK1.6之后引入,之前只有重量级锁)。

锁状态根据竞争情况从弱到强分别是:无锁->偏向锁->轻量级锁->重量级锁

锁不能降级只能升级:这是为了提高获得锁和释放锁的效率

3.5 使用分级锁的原因

之前的内置锁即采用重量级锁(互斥锁、信号量实现)的方式,线程阻塞代价大,挂起线程和恢复线程都设计内核态用户态上下文切换开销过多。因此设计分级的锁和更加细粒度的上锁办法来避免直接使用互斥量来阻塞线程(重量级锁)。关于重量级锁的说明可以看本文3.1节。

3.6 关于CAS

维基百科关于CAS的说明见:CAS-compareAndSet

CAS就是一个compareAndSet的操作,属于原子操作,属于原子操作,属于原子操作。不要和别的概念混淆在一起做等价。这个操作在CPU上有指令实现,在JAVA中也有实现。JAVA中实现依靠JNI来通过C调用了CPU底层指令来实现。

在JAVA中,这个原子操作往往结合自旋锁来实现了JAVA中的内置锁(轻量级锁、并发包里面的锁)。JAVA中用CAS和自旋锁来实现的应用层面锁(例如轻量级锁),都可以避免线程上下文切换的开销。正是因为这个原因,很多人都喜欢把这些概念混淆在一起。针对这些概念问题,我把自己的理解这里再说下,防止混淆:

  1. CAS操作就是代表一个比较交换的原子操作,与是否有线程上下文切换没关系。在访问互斥资源这个问题上,线程上下文切换本质上是与采用的是互斥锁还是自旋锁有关系。
  2. CAS操作不等价于轻量级锁: CAS操作是原子操作,可以用其实现各种锁,但是不要做等价。在实现锁时,CAS原子操作往往用来判断是否有多个线程同时对互斥资源访问(compare结果为false说明存在多个线程同时对互斥资源访问)。

3.7 关于获得锁和释放锁的本质含义

对于锁来讲,对象头部的Mark Word字段是最重要的,该字段存放了锁的信息

Mark word中如果记录了线程ID信息,则认为该线程获得了“锁”。如果将Mark word字段中自己的线程ID清空,则认为释放了自己的锁。

我们讨论的各个分级锁虽然对于获取锁、释放锁有自己的实现方式(CAS,互斥信号量等等),但是其本质都依靠Mark word中值的变化来表示锁的获取和释放。

3. 分级锁分析

接下来我们开始分析下,内置锁内部采用的三种级别的锁:偏向锁、轻量级锁和重量级锁。

3.1 重量级锁

重量级锁,是JDK1.6之前,内置锁的实现方式。简单来说,重量级锁就是采用互斥量来控制对互斥资源的访问。

历史回顾:
在JDK1.6以前的版本,synchronized实现的内置锁都比较重。JVM中monitorenter和monitorexit字节码依赖于底层的操作系统的Mutex Lock来实现的,但是由于使用Mutex Lock需要将当前线程挂起并从用户态切换到内核态来执行,这种切换的代价是非常昂贵的;然而在现实中的大部分情况下,同步方法是运行在单线程环境(无锁竞争环境)如果每次都调用Mutex Lock那么将严重的影响程序的性能。

3.2 轻量级锁

轻量级锁,顾名思义,相比重量级锁,其加锁和解锁的开销会小很多。重量级锁之所以开销大,关键是其存在线程上下文切换的开销。而轻量级锁通过JAVA中CAS的实现方式,避免了这种上下文切换的开销。当compare失败的时候(理解成没有拿到"锁"),线程不会被挂起;当compare成功的时候,可以直接对互斥资源进行修改(就好像拿到了“锁一样”)。重量级锁使用互斥信号量实现,如果没有拿到互斥信号量(理解成没有拿到“锁”),线程会被挂起;如果拿到互斥信号量则可以直接对互斥资源进行访问。

从以上分析可知,其实是否拿到“锁”对于不同的锁实现方式有着不同的含义。 重量级锁基于互斥信号量实现,则认为拿到互斥信号量即为拿到锁。而CAS操作则通过compare是否成功来判断是否拿到“锁”。 这里的“锁”都不是特指某一具体事物,而是一种“条件”,拿到了“锁”,即意味着满足了“条件”,可以对互斥资源进行访问。当然本质上,无论哪种实现方式,拿到锁之后都会去修改Mark Word,来记录自己确实拿到了锁;释放锁则会清空Mark word中自己的线程ID。

轻量级锁和重量级锁的重要区别是: 拿不到“锁”时,是否有线程调度和上下文切换的开销。

轻量级锁加锁:线程在执行同步块之前,JVM会先在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word。然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。

轻量级锁解锁:轻量级解锁时,会使用原子的CAS操作来将Displaced Mark Word替换回到对象头,如果成功,则表示没有竞争发生。如果失败(表示有竞争),表示当前锁存在竞争,锁就会膨胀成重量级锁。

关于轻量级锁的加锁和解锁过程简单来说就是:

  1. 尝试CAS修改mark word:如果这步能直接成功(没有资源的互斥访问发生),则代价较小,可以直接获取锁
  2. 获取锁失败则采用自旋锁来获取锁: CAS修改尝试失败后采取的策略
  3. 自旋锁尝试失败,锁膨胀,成为重量级锁: 自旋锁也尝试失败(应该有超时时间),不得不使用重量级锁,线程也被阻塞。

下图是两个线程同时争夺锁,导致锁膨胀的流程图。关键是注意Mark Word的变化。

因为自旋会消耗CPU,为了避免无用的自旋(比如获得锁的线程被阻塞住了),一旦锁升级成重量级锁,就不会再恢复到轻量级锁状态。当锁处于这个状态下,其他线程试图获取锁时,都会被阻塞住,当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程就会进行新一轮的夺锁之争。

轻量级锁能提升程序同步性能的依据是“对于绝大部分的锁,在整个同步周期内都是不存在竞争的”,这是一个经验数据。如果没有竞争,轻量级锁使用CAS操作避免了使用信号量的开销,但如果存在锁竞争,除了互斥量的开销外,还额外发生了CAS操作,因此在有竞争的情况下,轻量级锁会比传统的重量级锁更慢。

3.3 偏向锁

既然采用了内置锁,只要访问了同步代码,都会涉及获取锁和释放锁的动作。而这种动作都是存在开销的。无论是重量级锁去取得互斥信号量,还是轻量级锁去compare,都会有开销。然后很多时候,被内置锁约束的同步代码段往往只有一个线程去获取“锁”,根本不存在并发访问。那么这时候频繁地加锁和解锁就会有额外的开销。因此偏向锁也应运而生。

在采用偏向锁时,如果一个线程第一次来访问互斥资源,则在对象头和栈帧的锁记录中存储偏向锁的线程ID(可以理解为获取“锁”的动作)。偏向锁在获取锁之后,直到有竞争出现才会释放锁。也就是说,如果长期没有竞争,偏向锁是一直持有锁的。这样,当线程下次再次进入同步块的时候不需要进行任何获取锁的操作,即可访问互斥资源。节约了频繁获取锁和释放锁的开销。

偏向锁获得锁和释放锁的流程如下:可以发现线程2进行CAS原子操作失败的话,会通知线程1释放偏向锁。

3.4 锁消除

为了保证数据的完整性,我们在进行操作时需要对这部分操作进行同步控制,但是在有些情况下,JVM检测到不可能存在共享数据竞争,这是JVM会对这些同步锁进行锁消除。锁消除的依据是逃逸分析的数据支持。

如果不存在竞争,为什么还需要加锁呢?所以锁消除可以节省毫无意义的请求锁的时间。变量是否逃逸,对于虚拟机来说需要使用数据流分析来确定,但是对于我们程序员来说这还不清楚么?我们会在明明知道不存在数据竞争的代码块前加上同步吗?但是有时候程序并不是我们所想的那样?我们虽然没有显示使用锁,但是我们在使用一些JDK的内置API时,如StringBuffer、Vector、HashTable等,这个时候会存在隐形的加锁操作。比如StringBuffer的append()方法,Vector的add()方法:

public void vectorTest(){
     Vector<String> vector = new Vector<String>();
     for(int i = 0 ; i < 10 ; i++){
         vector.add(i + "");
     }
 
     System.out.println(vector);
 }

在运行这段代码时,JVM可以明显检测到变量vector没有逃逸出方法vectorTest()之外,所以JVM可以大胆地将vector内部的加锁操作消除。

3.5 各个锁优缺点比较

优点 缺点 适用场景
偏向锁 加锁和解锁不需要额外的消耗,和执行非同步方法比仅存在纳秒级的差距。 如果线程间存在锁竞争,会带来额外的锁撤销的消耗。 适用于只有一个线程访问同步块场景。
轻量级锁 竞争的线程不会阻塞,提高了程序的响应速度 如果始终得不到锁竞争的线程使用自旋会消耗CPU 追求响应时间。同步块执行速度非常快
重量级锁 线程竞争不使用自旋,不会消耗CPU 线程阻塞,响应时间缓慢 追求吞吐量
锁消除 没有锁的开销 额外的锁消除条件判断 适用于竞争不激烈的场景

4 总结

可见,为了减少内置锁的开销,JVM在实现的时候采用了分级的锁和细粒度的锁控制避免直接使用互斥量阻塞线程

参考资料:

  1. Java并发编程:volatile关键字解析
  2. 虚拟机中的锁优化简介(适应性自旋/锁粗化/锁削除/轻量级锁/偏向锁)
  3. Java偏向锁实现原理(Biased Locking)
  4. 原子操作 信号量 自旋锁 互斥锁
  5. 死磕Java并发:深入分析synchronized的实现原理