概述
本文假定读者对JVM、Java多线程有一定的了解。
Java提供了synchronized
关键字用来实现多线程同步机制。在Java语言中,synchronized
关键字可以用来修饰代码块、静态方法和非静态方法:
- 当
synchronized
修饰代码块时,需要传入一个对象实例,表示对这个对象进行加锁。 - 当
synchronized
修饰非静态方法时,表示对当前对象进行加锁。 - 当
synchronized
修饰静态方法时,表示对当前对象所属的Class
对象进行加锁。
无论怎样修饰,synchronized
都需要提供一个对象实例用于加锁,synchronized
加的锁是独享可重入非公平锁。在字节码文件中,synchronized
通过字节码指令monitorenter
和monitorexit
实现,前者表示进入同步代码(加锁),后者表示退出同步代码(释放锁)。
在我们深入了解synchronized实现机制之前,我们需要了解一个重要的概念:对象头。
我们以Oracle的HotSpot虚拟机为例,一个对象在内存中存在三个部分:对象头(Header)、实例数据(Instance Data)和对齐补充(Padding)。
在JDK1.5以前,synchronized
关键字在JVM中主要通过mutex
互斥锁实现,这种锁又称重量级锁,非常耗费CPU资源,而基于Java语言编写的ReentrantLock
反而性能要高于synchronized
很多(所以你会发现java.util.concurrent
包中很多类是通过ReentrantLock
加锁的)。为了优化synchronized
,在JDK1.6以后,synchronized
实现了三种类型的加锁机制,从加锁强度从小到大排列是是:偏向锁、轻量级锁和重量级锁,一般来说一开始默认是加偏向锁,它们会随着竞争的激烈而逐渐升级,并且只会升级不会降级。
那么如何实现这三种类型的锁呢,答案是通过对象头实现。
对象头中的数据还可以分为三个部分:
Mark Word
,用来实现对象的加锁(我们讨论的重点),存放对象的hashCode
,用来标记垃圾回收。Class Poniter
,指向方法区中该对象所属的Class
对象的指针,Object
类的getClass
方法就是基于Class Word
实现的。- 数组长度(如果该对象为数组),我们通过代码
arr.length
获取长度实际上就是通过arraylength
指令读取这个部分。
上述三个部分中,如果JVM为64位,那么每个部分占用8字节(64比特),如果JVM为32位,那么每个部分占用4字节(32比特)。
下面我们讲述Mark Word
的结构
Mark Word结构
Mark Word可以表示对象的五种状态:
- 无锁状态
- 偏向锁状态
- 轻量级加锁状态
- 重量级加锁状态
- GC标记(已经被加锁但还未释放锁的对象不存在被GC)
需要注意的是,偏向锁可以通过JVM参数-XX:-UseBiasedLocking=false
关闭,此外偏向锁也不是在程序运行时立刻就启动的,一般会有几秒的延迟,可以通过JVM参数-XX:BiasedLockingStartupDelay=0
关闭其延迟。偏向锁在打开和关闭状态下,对象头的Mark Word
在无锁状态的结构有所差异:
五种不同的对象状态下,32位JVM的
Mark Word
结构:
64位JVM的Mark Word
大致结构和上面类似,只是长度不同,网上图片很多就不贴了
下面我们简单介绍这几种锁的机制:
偏向锁
偏向锁属于乐观锁的一种,它假定一个锁自始至终只有一个线程获取并释放,不存在其它线程竞争。从名字上也可以看出,偏向锁中的“偏向”指的就是偏向于一个线程。
这种锁在假定状态下的加锁与解锁操作对性能的影响非常小,它存在的意义就是消除轻量级锁CAS的开销。
加锁
- 首先判断偏向锁标记是否为1,如果不为1说明JVM关闭了偏向锁,那么直接加轻量级锁。如果为1,那么进行第2步操作。
- 如果发现
Mark Word
中的加锁线程ID为空,那么当前线程会通过CAS操作尝试将当前线程的ID写入到Mark Word
的前23 bit:
如果写入成功,则表示已经获取到该偏向锁,执行synchronized
修饰的同步代码;
如果写入失败,则说明有其它线程(我们称线程B)抢先获取到了这个锁,这时偏向锁就不再适用了,尝试获取锁的线程(线程A)会等待JVM到一个全局安全点(Safe Point
,此时没有任何线程在执行字节码),然后撤销线程B获取的偏向锁并升级为轻量级锁。 - 如果发现
Mark Word
中的加锁线程ID不为空,那么会检查这个线程ID是不是就是当前线程的ID:
如果不是当前线程,就表示这个偏向锁已经偏向于其它线程了,此时则可能需要按照上述方法撤销偏向锁;
如果是当前线程,那么就直接往下执行同步代码。
偏向锁的撤销
严格上来讲偏向锁不能够被解锁,虽然字节码的退出同步代码块指令都是monitorexit
,但事实上偏向锁的“解锁”可以说什么也不会发生,即并不会将Mark Word
前23 bit的线程ID重置为0。之所以这么设计,是因为偏向锁本身就是假定一个锁自始至终只有一个线程获取并释放,那么也就没有任何必要将其重置为0了。
所以,偏向锁的撤销只会发生在其它线程尝试获取这个锁的时候。
刚才提到过在加锁过程中如果发生了竞争,那么会将偏向锁撤销并将其升级到轻量级锁,那么具体是如何升级的呢?步骤如下:
- 首先尝试获取锁的线程需要等待JVM到达一个安全点;
- 到达安全点后,当前线程通过加锁对象的
Mark Word
找到成功获取偏向锁的线程; - 接着,在这个已经获取到偏向锁的线程的栈帧中的锁记录区域(
Lock Record
),补充轻量级锁加锁过程中会持有的锁记录,然后将加锁对象的Mark Word
更新为指向这条锁记录区域的指针。操作完成,其它线程可以继续执行字节码。
HotSpot对偏向锁的进一步优化
HotSpot虚拟机为了进一步优化锁的性能,提供了偏向锁的批量重偏向机制(Bulk Rebias
)和批量偏向锁撤销机制(Bulk Revocation
),这是针对偏向锁两种完全不同的情况设置的。
还记得Mark Word
中的epoch
部分吗,该部分实际上就是为上述两种机制服务的,它可以理解为一个时间戳(并不是真正意义上的时间戳)。
批量偏向锁撤销机制是什么?
我们在开发Java程序中,某种类型的对象(例如阻塞队列)是经常发生线程之间的竞争的,肯定是不适合采用偏向锁的,采用偏向锁反而会降低它们的性能(因为撤销偏向锁是一个比较大的开销)。所以JVM需要识别出这种情况发生并且禁用这种类型的对象采用偏向锁,这种就是所谓的批量偏向锁撤销机制。当某种类型的对象频繁发生偏向锁的撤销并达到一定的阈值的时候,JVM就会撤销掉该类所有对象的偏向锁功能。
那么如何进行统计一个类所有对象偏向锁撤销的频率?答案在于每个Class
对象实际上也持有一个epoch
变量(我们称为class_epoch
),当一个线程拥有偏向锁时,说明Mark Word
中的加锁线程ID就是当前线程的ID,并且Mark Word
中的epoch
等于class_epoch
。如果epoch
不等于class_epoch
,就可以认为该对象处于一个可偏向但是未偏向的状态。
批量重偏向机制是什么?
与批量偏向锁撤销机制的场景恰恰相反,批量重偏向机制考虑的是在一个同步代码块中,始终是多个线程在不同的时间段执行这一段代码,也就是说不会发生线程的竞争。那么这个时候我们就可以考虑将这个偏向锁重偏到另外一个线程,也就是所谓的批量重偏向机制。
批量重偏向机制的具体实现仍需要在安全点下执行:
- 首先将
class_epoch
加上1 - 遍历当前Java程序中所有的线程中的栈信息,筛选出拥有锁的类的对象,将这些对象的
Mark Word
中的epoch
值更新为该类的class_epoch
。 - 退出安全点后,当有线程尝试获取偏向锁时,就会检查目标加锁对象的
class_epoch
的值是否和该对象的epoch
相等,如果不相等,说明这个偏向锁已经是无效状态了(也就是之前加这个锁的线程已经退出了这个同步代码块),此时就可以对这个锁进行重偏向的操作。
锁的撤销流程总结
如果考虑批量重偏向机制(Bulk Rebias
)和批量偏向锁撤销机制(Bulk Revocation
),那么锁的撤销(在Safe Point
)可以用伪代码表示如下:
if Mark Word is not 可偏向状态:
return
if Thread ID 指向的线程 is DEAD:
if 可以重偏向:
该锁改为可偏向但是未偏向状态
Thread ID = 0
else:
改为无锁状态
else:
if Thread ID指向的线程持有该锁:
进行锁升级操作
else if 该锁可以重偏向:
该锁改为可偏向但未偏向状态
else:
改为无锁状态
轻量级锁
大多数情况下,线程并不会太长时间持有一个锁,所以我们并不需要通过直接阻塞一个线程来等待锁,这时轻量级锁就应运而生了。
轻量级锁属于乐观锁、自旋锁。它假定当前这个锁马上就会被释放,通过循环不停地尝试获取锁,使当前线程依然保留CPU的执行时间,不进入阻塞状态。当然,如果这个锁占用的时间比较长,那么自旋操作反而会浪费CPU资源,所以JVM默认设定了如果自旋了10次依然没有获得到锁,那么就将这个线程挂起直到锁释放。
加锁
在关闭偏向锁的情况下, 加锁的过程可以概括为:
- 当线程执行到
monitorenter
指令时,如果发现加锁的对象为无锁状态(锁标记位为01)并且处于不可偏向锁状态(偏向锁标记为0),那么会在当前栈帧中创建一个锁记录(Lock Record
)区域。 - 将这个对象的
Mark Word
拷贝到这个锁记录区域中,这个Mark Word
拷贝官方称为Displaced Mark Word
。 - 拷贝完毕后,通过CAS操作将这个锁记录区域(
Lock Record
)的指针覆盖到这个对象的Mark Word
:
如果成功覆盖,那么表示加锁成功;
如果覆盖失败,那么说明有其它线程在此期间抢占了这个锁,那么这个线程会通过循环重新执行上述操作,如果在一定的时间内依然无法获取到这个锁,那么这个锁就需要升级到重量级锁,锁状态标志位变为10。
解锁
轻量级解锁过程首先会通过CAS操作将栈中的锁记录(Displaced Mark Word
)覆盖到加锁对象的Mark Word
中。
如果覆盖成功,那么线程退出同步代码。
如果覆盖失败,那么则说明该锁已经膨胀为重量级锁,那么此时就需要在释放锁的同时还需要唤醒等待锁的线程。
重量级锁
重量级锁属于悲观锁,它假定这个锁会被很多线程竞争。JVM中的重量级锁基于操作系统的互斥量(mutex
)实现,相对于其它两种锁它需要消耗更多的CPU资源,这是为什么呢?
因为如果一个线程尝试去获取一个锁但是没有成功,那么这个线程会进入到阻塞状态(BLOCKED
)直到锁释放后,CPU开始调度这个线程才会继续执行。众所周知,CPU切换线程的开销相对于执行指令而言是非常大的。
在锁竞争不是很激烈或者锁粒度很小的情况下,采用重量级锁是十分不妥的,会影响到应用程序的并发性能。如果锁竞争很激烈并且锁粒度比较大,那么采用重量级锁相对来说性能会更好一些(当然在这种情况下可能直接采用单线程性能会好得多,所以我们在编写程序时要尽量避免锁的竞争过于激烈或者锁的粒度太大)。