Java并发编程深度解析与实战
上QQ阅读APP看本书,新人免费读10天
设备和账号都新为新人


2.7 锁升级的实现流程

在synchronized中引入偏向锁、轻量级锁、重量级锁之后,当前具体会用到synchronized中的哪种类型锁,是根据线程的竞争激烈程度来决定的,这个过程我们称之为锁的升级,具体的升级流程如图2-18所示。

图2-18 synchronized锁的升级流程

当一个线程访问增加了synchronized关键字的代码块时,如果偏向锁是开启状态,则先尝试通过偏向锁来获得锁资源,这个过程仅仅通过CAS来完成。如果当前已经有其他线程获得了偏向锁,那么抢占锁资源的线程由于无法获得锁,所以会尝试升级到轻量级锁来进行锁资源抢占,轻量级锁就是通过多次CAS(也就是自旋锁)来完成的。如果这个线程通过多次自旋仍然无法获得锁资源,那么最终只能升级到重量级锁来实现线程的等待。

为了更清晰地理解锁的升级流程,下面针对锁升级的过程及锁资源竞争的原理做一个更详细的分析。

注意:本章内容是基于Hotspot 1.8中的bytecodeInterpreter.cpp、biasedLocking.cpp、synchronizer.cpp、objectMonitor.hpp、markOop.hpp等源码的实现分析。

2.7.1 偏向锁的实现原理

偏向锁的实现原理比较简单,就是使用CAS机制来替换对象头中的Thread Id,如果成功,则获得偏向锁,否则,就会升级到轻量级锁。它的具体实现流程如图2-19所示,图比较长,建议读者结合文字解析一起看,方便理解。

2.7.1.1 获取偏向锁的流程

图2-19仅代表获取偏向锁的粗粒度流程图,整体的流程是基于Hotspot 1.8版本的源码实现来建立的,主要针对核心节点做了相对详细的说明,便于大家解读,以下是获取偏向锁的过程讲解。

注意:偏向锁是在没有线程竞争的情况下实现的一种锁,不能排除存在锁竞争的情况,所以偏向锁的获取有两种情况。

没有锁竞争

在没有锁竞争并且开启了偏向锁的情况下,当线程1访问synchronized(lock)修饰的代码块时:

• 从当前线程的栈中找到一个空闲的BasicObjectLock(在图2-19中称为Lock Record),它是一个基础的锁对象,在后续的轻量级锁和重量级锁中都会用到,BasicObjectLock包含以下两个属性。

○ BasicLock,该属性中有一个字段markOop,用于保存指向lock锁对象的对象头数据。

○ oop,指向lock锁对象的指针。

注意:Lock Record是线程私有的数据结构,每个线程都有一个可用的Lock Record列表,并且每一个LockRecord都会关联到锁对象lock的Mark Word。

• 将BasicObjectLock中的oop指针指向当前的锁对象lock。

• 获得当前锁对象lock的对象头,通过对象头来判断是否可偏向,也就是说锁标记为101,并且Thread Id为空。

○ 如果为可偏向状态,那么判断当前偏向的线程是不是线程1,如果偏向的是自己,则不需要再抢占锁,直接有资格运行同步代码块。

○ 如果为不可偏向状态,则需要通过轻量级锁来完成锁的抢占过程。

图2-19 偏向锁的获取流程

• 如果对象锁lock偏向其他线程或者当前是匿名偏向状态(也就是没有偏向任何一个线程),则先构建一个匿名偏向的Mark Word,然后通过CAS方法,把一个匿名偏向的Mark Word修改为偏向线程1。如果当前锁对象lock已经偏向了其他线程,那么CAS一定会失败。

存在锁竞争

假设线程1获得了偏向锁,此时线程2去执行synchronized(lock)同步代码块,如果访问到同一个对象锁则会触发锁竞争并触发偏向锁撤销,撤销流程如下。

第一步,线程2调用撤销偏向锁方法,尝试撤销lock锁对象的偏向锁。

第二步,撤销偏向锁需要到达全局安全点(SafePoint)才会执行,全局安全点就是当前线程运行到的这个位置,线程的状态可以被确定,堆对象的状态也是确定的,在这个位置JVM可以安全地进行GC、偏向锁撤销等动作。当到达全局安全点后,会暂停获得偏向锁的线程1。

第三步,检查获得偏向锁的线程1的状态,这里存在两种状态。

• 线程1已经执行完同步代码块或者处于非存活状态。在这种情况下,直接把偏向锁撤销恢复成无锁状态,然后线程2升级到轻量级锁,通过轻量级锁抢占锁资源(轻量级锁的逻辑在后面会分析)。

• 线程1还在执行同步代码块中的指令,也就是说没有退出同步代码块。在这种情况下,直接把锁对象lock升级成轻量级锁(由于这里是全局安全点,所以不需要通过CAS来实现),并且指向线程1,表示线程1持有轻量级锁,接着线程1继续执行同步代码块中的代码。

至此,偏向锁的抢占逻辑和偏向锁的撤销逻辑就分析完了,读者可以对照图2-19进行梳理,在源码中偏向锁还有很多的逻辑,比如批量撤销、批量重偏向、撤销并重偏向等,有兴趣的读者可以下载相关源码去分析。

2.7.1.2 偏向锁的释放

在偏向锁执行完synchronized同步代码块后,会触发偏向锁释放的流程,需要注意的是,偏向锁本质上并没有释放,因为当前锁对象lock仍然是偏向该线程的。

从源码来看,释放的过程只是把Lock Record释放了,也就是说把Lock Record保存的锁对象的Mark Word设置为空。

2.7.1.3 偏向锁批量重偏向

当一个锁对象lock只被同一个线程访问时,该锁对象的锁状态就是偏向锁,并且一直偏向该线程。当有任何一个线程来访问该锁对象lock时,不管之前获得偏向锁线程的状态是存活还是死亡,lock锁对象都会升级为轻量级锁,并且锁在升级之后是不可逆的。

假设一个线程t1针对大量的锁对象增加了偏向锁,之后线程t2来访问这些锁对象,在不考虑锁竞争的情况下,需要对之前所有偏向线程t1的锁对象进行偏向锁撤销和升级,这个过程比较耗时,而且虚拟机会认为这个锁不适合再偏向于原来的t1线程,于是当偏向锁撤销次数达到20次时,会触发批量重偏向,把所有的锁对象全部偏向线程t2。

偏向锁撤销并批量重偏向的触发阈值可以通过XX:BiasedLockingBulkRebiasThreshold = 20来配置,默认是20。下面的代码演示了批量重偏向的实现。

注意,在这个案例中,偏向锁的延时启动设置为0:XX:BiasedLockingStartupDelay=0。

代码分析如下:

首先,在t1线程中,创建100个锁对象BulkRevokeExample,并且对每个对象都增加了偏向锁,这100个锁对象都偏向t1线程。下面的内容打印了第20个锁对象的对象头,锁标记为[101],表示偏向锁状态。

然后,t2线程尝试竞争锁,对t1线程中加了偏向锁的锁对象触发撤销并重偏向。理论上来说,t2线程需要对每个锁对象的对象头通过CAS升级到轻量级锁,我们先来看一下打印结果。

在运行第19次之前,锁对象的状态都是轻量级锁,但是到了第20次以后,锁对象的状态又变成了偏向锁,而且偏向了线程t2,说明触发了偏向锁的重新偏向。

在JVM中,以class(这里指BulkRevokeExample)为单位,为每个class维护了一个偏向锁撤销的计数器,当这个class的对象发生偏向撤销操作时,计数器会进行累加,当累加的值达到重偏向的阈值时,JVM会认为这个class的偏向锁有问题,需要重新偏向。

2.7.2 轻量级锁的实现原理

如果偏向锁存在竞争或者偏向锁未开启,那么当线程访问synchronized(lock)同步代码块时就会采用轻量级锁来抢占锁资源,获得访问资格,轻量级锁的加锁原理如图2-20所示。

图2-20 轻量级锁的加锁原理

2.7.2.1 获取轻量级锁的实现流程

下面我们详细分析一下图2-20中轻量级锁的获取实现流程。

第一步,在线程2进入同步代码块后,JVM会给当前线程分配一个Lock Record,也就是一个BasicObjectLock对象,在它的成员对象BasicLock中有一个成员属性markOop _displaced_header,这个属性专门用来保存锁对象lock的原始Mark Word。

第二步,构建一个无锁状态的Mark Word(其实就是lock锁对象的Mark Word,但是锁状态是无锁),把这个无锁状态的Mark Word设置到Lock Record中的_displaced_header字段中,如图2-21所示。

图2-21 Displaced Mark Word

第三步,通过CAS将lock锁对象的Mark Word替换为指向Lock Record的指针,如果替换成功,就会得到如图2-22所示的结构,表示轻量级锁抢占成功,此时线程2可以执行同步代码块。

图2-22 CAS成功后的结构

第四步,如果CAS失败,则说明当前lock锁对象不是无锁状态,会触发锁膨胀,升级到重量级锁。

相对偏向锁来说,轻量级锁的原理比较简单,它只是通过CAS来修改锁对象中指向Lock Record的指针。从功能层面来说,偏向锁和轻量级锁最大的不同是:

• 偏向锁只能保证偏向同一个线程,只要有线程获得过偏向锁,那么当其他线程去抢占锁时,只能通过轻量级锁来实现,除非触发了重新偏向(如果获得轻量级锁的线程在后续的20次访问中,发现每次访问锁的线程都是同一个,则会触发重新偏向,20次的定义属性为:XX:BiasedLockingBulkRebiasThreshold =20)。

• 轻量级锁可以灵活释放,也就是说,如果线程1抢占了轻量级锁,那么在锁用完并释放后,线程2可以继续通过轻量级锁来抢占锁资源。

可能有些读者会有疑问,轻量级锁中的CAS操作是先把lock锁对象的Mark Word复制到当前线程栈帧的Lock Record中,然后通过比较lock锁对象的Mark Word和复制到Lock Record中的Mark Word是否相同来决定是否获取锁,那么不是会导致每个线程进来都能CAS成功吗?实际上并非如此,因为每次在CAS之前都会判断锁的状态,只有在无锁状态时才会执行CAS,所以并不会存在多个线程同时获得锁的问题。

2.6.2.2 轻量级锁的释放

偏向锁也有锁释放的逻辑,但是它只是释放Lock Record,原本的偏向关系仍然存在,所以并不是真正意义上的锁释放。而轻量级锁释放之后,其他线程可以继续使用轻量级锁来抢占锁资源,具体的实现流程如下。

第一步,把Lock Record中_displaced_header存储的lock锁对象的Mark Word替换到lock锁对象的Mark Word中,这个过程会采用CAS来完成。

第二步,如果CAS成功,则轻量级锁释放完成。

第三步,如果CAS失败,说明释放锁的时候发生了竞争,就会触发锁膨胀,完成锁膨胀之后,再调用重量级锁的释放锁方法,完成锁的释放过程。

为什么轻量级锁在释放锁的时候会CAS失败呢?读者不妨想想,假设t1线程获得了轻量级锁,那么当t2线程竞争锁的时候,由于无法获得轻量级锁,所以会触发锁膨胀,在锁膨胀的逻辑中,会判断如果当前的锁状态是轻量级锁,那么t2线程会修改锁对象的Mark Word,将其设置为INFLATING状态(这个过程是采用自旋锁来实现的,当存在多个线程触发膨胀时,只有一个线程去修改锁对象的Mark Word)。

如果lock锁对象的Mark Word在锁膨胀的过程中发生了变化,那么持有轻量级锁的线程通过CAS释放时必然会失败,因为存储在当前线程栈帧中的Lock Record的Mark Word和锁对象lock的Mark Word已经不相同了。

并且,持有轻量级锁的线程t1在持有锁期间,如果其他线程因为竞争不到锁而升级到重量级锁并且被阻塞,那么线程t1在释放锁时,还需要唤醒处于重量级锁阻塞状态下的线程。

2.7.2.3 偏向锁和轻量级锁的对比

通过对偏向锁和轻量级锁的原理剖析,大家应该对这两种锁的触发场景的认知更加深刻。

偏向锁,就是在一段时间内只由同一个线程来获得和释放锁,加锁的方式是把Thread Id保存到锁对象的Mark Word中。

轻量级锁,存在锁交替竞争的场景,在同一时刻不会有多个线程同时获得锁,它的实现方式是在每个线程的栈帧中分配一个BasicObjectLock对象(Lock Record),然后把锁对象中的Mark Word拷贝到Lock Record中,最后把锁对象的Mark Word的指针指向Lock Record。轻量级锁之所以这样设计,是因为锁对象在竞争的过程中有可能会发生变化,但是每个线程的Lock Record的Mark Word不会受到影响。因此当触发锁膨胀时,能够通过Lock Record和锁对象的Mark Word进行比较来判定在持有轻量级锁的过程中,锁对象是否被其他线程抢占过,如果有,则需要在轻量级锁释放锁的过程中唤醒被阻塞的其他线程。

2.7.3 重量级锁的实现原理

如果线程在运行synchronized(lock)同步代码块时,发现锁状态是轻量级锁并且有其他线程抢占了锁资源,那么该线程就会触发锁膨胀升级到重量级锁。因此,重量级锁是在存在线程竞争的场景中使用的锁类型。重量级锁的实现流程如图2-23所示。

图2-23 重量级锁的实现流程

在获取重量级锁之前,会先实现锁膨胀,在锁膨胀的方法中首先创建一个ObjectMonitor对象,然后把ObjectMonitor对象的指针保存到锁对象的Mark Word中,锁膨胀分为四种情况,分别如下。

• 当前已经是重量级锁的状态,不需要再膨胀,直接从锁对象的Mark Word中获取ObjectMonitor对象的指针返回即可。

• 如果有其他线程正在进行锁膨胀,那么通过自旋的方式不断重试直到其他线程完成锁膨胀(其实就是创建一个ObjectMonitor对象)。

• 如果当前有其他线程获得了轻量级锁,那么当前线程会完成锁的膨胀。

• 如果当前是无锁状态,也就是说之前获得锁资源的线程正好释放了锁,那么当前线程需完成锁膨胀。

以上这四种情况都是在自旋的方式下完成的,避免了线程竞争导致CAS失败的问题。

在锁膨胀完成之后,锁对象及ObjectMonitor的引用关系如图2-24所示,lock锁对象的Mark Word会保存指向ObjectMonitor的指针,重量级锁的竞争都是在ObjectMonitor中完成的。在ObjectMonitor中有一些比较重要的字段,解释如下。

• _owner,保存当前持有锁的线程。

• _object,保存锁对象的指针。

• _cxq,存储没有获得锁的线程的队列,它是一个链表结构。

• _WaitSet,当调用Object.wait()方法阻塞时,被阻塞的线程会保存到该队列中。

• _recursions,记录重入次数。

图2-24 重量级锁的引用关系

锁膨胀完成之后,就开始在重量级锁中实现锁的竞争,下面分别从重量级锁的获取和释放两个环节进行说明。

2.7.3.1 重量级锁的获取流程

重量级锁的实现是在ObjectMonitor中完成的,所以锁膨胀的意义就是构建一个ObjectMonitor,继续关注图2-24中ObjectMonitor的实现部分,在ObjectMonitor中锁的实现过程如下:

首先,判断当前线程是否是重入,如果是则增加重入次数。

然后,通过自旋锁来实现锁的抢占(这个自旋锁就是前面我们提到的自适应自旋),这里使用CAS机制来判断ObjectMonitor中的_owner字段是否为空,如果为空就表示重量级锁已释放,当前线程可以获得锁,否则就进行自适应自旋重试。

最后,如果通过自旋锁竞争锁失败,则会把当前线程构建成一个ObjectWaiter节点,插入_cxq队列的队首,再使用park方法阻塞当前线程。

很多参考资料上描述的自旋操作是在轻量级锁内完成的,但是笔者在Hotspot 1.8的源码中发现,轻量级锁中并没有使用自旋操作。

2.7.3.2 重量级锁的释放原理

锁的释放是在synchronized同步代码块结束后触发的,释放的逻辑比较简单。

• 把ObjectMonitor中持有锁的对象_owner置为null。

• 从_cxq队列中唤醒一个处于锁阻塞的线程。

• 被唤醒的线程会重新竞争重量级锁,需要注意的是,synchronized是非公平锁,因此被唤醒后不一定能够抢占到锁,如果没抢到,则继续等待。

2.7.3.3 简述内核态和用户态

在重量级锁中,线程的阻塞和唤醒是通过park()方法和unpark()方法来完成的,这是两个与平台相关的方法,对不同的操作系统有不同的实现,比如在os_linux.cpp中,park()方法的实现代码如下。

可以看到,park()方法实际上用到了3个方法。

• pthread_mutex_lock()方法,锁定_mutext指向的互斥锁。如果该互斥锁已经被另外一个线程锁定和拥有,则当前调用该方法的线程会阻塞,直到互斥锁变为可用。

• pthread_cond_wait()方法,条件等待,类似于Java中的Object.wait,与之配对的另外一个唤醒方法是pthread_cond_signal()。

• pthread_mutex_unlock()方法,释放指定_mutex引用的互斥锁对象。

在Linux中,系统的阻塞和唤醒是基于系统调用sys_futex来实现的,而系统调用是在内核态运行的,所以系统需要从用户态切换到内核态。在切换之前,首先要保存用户态的状态,包括寄存器、程序指令等;然后执行内核态的系统指令调用;最后恢复到用户态来执行。这个过程会产生性能损耗,笔者在第1章中做了详细的说明。

用户态(用户空间)和内核态(内核空间)表示的是操作系统中的不同执行权限,两者最大的区别在于,运行在用户空间中的进程不能直接访问操作系统内核的指令和程序,而运行在内核空间的程序可以直接访问系统内核的数据结构和程序。操作系统之所以要做权限划分,是为了避免用户在进程中直接操作一些存在潜在危险的系统指令,从而影响其他进程或者操作系统的稳定性。

park()方法需要通过系统调用来完成,而系统调用只能在内核空间实现,因此就会导致用户态到内核态的切换。