深入理解 synchronized 原理
目录
一. 前言
二. Java对象的内存布局
2.1. 对象头
2.2. Mark Word
2.3. Class Metadata Pointer
2.4. Length
三. 偏向锁
3.1. 偏向锁的工作流程
3.2. 偏向失效
3.2.1. 误区一
3.3. 偏向撤销
3.3.1. 误区一
3.4. 偏向撤销的底层实现
3.5. HashCode与偏向撤销
3.6. 批量撤销是什么
3.7. 批量重偏向是什么
3.8. epoch 的作用
四. 轻量级锁
4.1. 轻量级锁的工作流程
4.2. 轻量级锁是否会自旋?
五. 重量级锁
5.1. Java线程模型(HotSpot实现)
5.2. 上下文切换
5.3. 自旋锁
5.4. 自适应自旋锁
5.5. ObjectMonitor对象
5.5. 重量级锁的加锁过程(ObjectMonitor::enter)
5.6. 重量级锁的解锁过程(ObjectMonitor::exit)
5.7. 重量级锁的_waitSet源码
5.8. 锁消除
5.9. 锁粗化
六. 字节码层面解读synchronized
6.1. 同步方法
6.2. class 文件结构
6.3. 同步代码块
6.4. 异常表(exception_table)
6.5. 字节码指令 monitorenter与monitorexit
一. 前言
synchronized 是JDK为解决同步问题设计的一种锁,synchronized 保证被其修饰的方法或者代码块在任何时候都只能有一个线程访问。但在JDK1.5之前它的效率十分低下,属于重量级的锁。在JDK1.5后对 synchronized 在JVM层面进行了优化,即在JVM层面就将锁的功能实现,而不是依赖操作系统去实现,这里就省去了操作系统内核态和用户态的频繁切换,引入了自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。
关于 synchronized 的深度理解的文章全网不多,并且很多网上广为流传的图和对 synchronized的理解都有很多的错误,所以写了这篇文章,深入解读一下 synchronized,并修正一些书本和网上对 synchronized 的错误理解,希望对各位小伙伴的学习有所帮助。
二. Java对象的内存布局
在JVM中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐补全。synchronized 用的锁是存在Java对象头里的。见下图(注:下图采用64位内存结构):
2.1. 对象头
HotSpot 有两种对象头:
数组类型:如果对象是数组类型,则虚拟机用3个字宽 (Word)存储对象头;
非数组类型:如果对象是非数组类型,则用2字宽存储对象头。
对象头由三部分组成:
Mark Word:存储自身的运行时数据,例如 HashCode、GC 年龄、锁相关信息等内容。
Class Metadata Pointer:类型指针指向它的类元数据的指针。
Length:记录数组长度。如果对象头为数组类型,则有此项。
2.2. Mark Word
源码中的注释(源码位置:src\share\vm\oops\markOop.hpp):
// 64 bits:
// --------
// unused:25 hash:31 -->| unused:1 age:4 biased_lock:1 lock:2 (normal object)
// JavaThread*:54 epoch:2 unused:1 age:4 biased_lock:1 lock:2 (biased object)
// PromotedObject*:61 --------------------->| promo_bits:3 ----->| (CMS promoted object)
// size:64 ----------------------------------------------------->| (CMS free block)
//
// unused:25 hash:31 -->| cms_free:1 age:4 biased_lock:1 lock:2 (COOPs && normal object)
// JavaThread*:54 epoch:2 cms_free:1 age:4 biased_lock:1 lock:2 (COOPs && biased object)
// narrowOop:32 unused:24 cms_free:1 unused:4 promo_bits:3 ----->| (COOPs && CMS promoted object)
// unused:21 size:35 -->| cms_free:1 unused:7 ------------------>| (COOPs && CMS free block)
注:上图 从左到右是 高位 --> 低位,下文我们会JOL进行代码测试,它从左到右是 低位 --> 高位进行打印的与上图刚好相反,要注意一下。
Mark Word 用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄(Generational GC Age)等。这部分数据的长度在32位和64位的Java虚拟机中分别会占用32个或64个比特。这部分是实现轻量级锁和偏向锁的关键。
看到这里我们可以带以下几个疑问继续往下学习:
1. 当锁膨胀为轻量级锁和重量级锁时,记录对象的hashcode和分代年龄的数据去哪了?
2. 偏向锁的 thread ID、epoch 和 hashcode 的位置是冲突的呀?
3. 如果 Mark Word 既要记录偏向线程的信息也要记录 hashcode 时怎么办?
4. 偏向锁的 epoch 又是干嘛的呢?
2.3. Class Metadata Pointer
这一部分用于存储对象的类型指针,该指针指向它的类元数据,JVM通过这个指针确定对象是哪个类的实例。该指针的位长度为JVM的位大小,即32位的JVM为32位,64位的JVM为64位。
如果应用的对象过多,使用64位的指针将浪费大量内存,统计而言,64位的JVM将会比32位的JVM多耗费50%的内存。所以会默认开启指针压缩(如不开启 class pointer 将占用8字节),我们可以通过下面的命令查看:java -XX:+PrintCommandLineFlags -version
上图中:-XX:+UseCompressedClassPointers开启了类型指针压缩;-XX:+UseCompressedOops开启了普通对象指针压缩。
补充: 什么叫普通对象指针压缩?比如对象A中有一个对象B的引用, 这个引用就是一个指针。
2.4. Length
如果对象是一个数组,那么对象头还需要有额外的空间用于存储数组的长度,这部分数据的长度也随着JVM架构的不同而不同:32位的JVM上,长度为32位;64位JVM则为64位。
64位JVM如果开启+UseCompressedOops选项,该区域长度也将由64位压缩至32位。
三. 偏向锁
偏向锁也是 JDK 6 中引入的一项锁优化措施,它的目的是消除数据在无竞争情况下的同步语句,进一步提高程序的运行性能。
“锁”如其名,偏向锁是一个偏心的锁,它会偏向于第一个获得它得线程。如果在接下来的执行过程中,该锁一直没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步。
3.1. 偏向锁的工作流程
注:看到这张图你可能有些疑惑,似乎和你在其他地方或某些书本上看到的偏向锁的流程有些许不同,别急我们慢慢往下来看。
3.2. 偏向失效
偏向锁不一定一直有效,虚拟机开启偏向锁的启动参数为:XX:+UseBiasedLocking,JDK6之后HotSpot会默认开启偏向锁。但这个偏向锁的开启是存在延迟的,大概的延迟时间在4秒左右,当然也可以通过参数 -XX:BiasedLockingStartupDelay=0 将延迟改为0,但并不建议。
// 我们来看下面这段代码
// 为了方便在程序中看到java对象内存布局,我们可以在maven中添加jol-core依赖
// <dependency>
// <groupId>org.openjdk.jol</groupId>
// <artifactId>jol-core</artifactId>
// <version>0.9</version>
// </dependency>
public static void test01(){
Object o = new Object();
System.out.println(ClassLayout.parseInstance(o).toPrintable()); // 打印 mark word
synchronized (o){
System.out.println(ClassLayout.parseInstance(o).toPrintable()); // 打印 mark word
}
}
正如我们所料,当我们执行这段代码的时候我们的偏向锁并未生效,而是直接生成了轻量级锁。
我们使线程睡眠5秒再次测试看看:
public static void test02() throws InterruptedException {
Thread.sleep(5000); // 或开启-XX:BiasedLockingStartupDelay=0
Object o = new Object();
System.out.println(ClassLayout.parseInstance(o).toPrintable()); // 打印markword
synchronized (o){
System.out.println(ClassLayout.parseInstance(o).toPrintable()); // 打印markword
}
}
我们可以发现,在虚拟机成功开启偏向锁之后但未进入同步代码块之前偏向状态已经被设置为1了,但此时并未设置Thread ID,当进入同步代码块之后Thread ID才会被真正设置。
那么问题来了?偏向状态到底是在什么时间被设置的呢?是被线程设置?还是在初始化时被设置的呢?我们用下面这段代码再次验证一下:
public static void test03() throws InterruptedException {
Object o1 = new Object(); // 我们先初始化o1
Thread.sleep(5000); // 等待偏向锁开启
System.out.println("----------------- o1 ------------------");
System.out.println(ClassLayout.parseInstance(o1).toPrintable()); // 此时打印 o1 的对象头我们看到偏向状态为0
synchronized (o1){
System.out.println(ClassLayout.parseInstance(o1).toPrintable()); // 果然此时为轻量级锁
}
new Thread(() -> {
System.out.println(ClassLayout.parseInstance(o1).toPrintable()); // 我们用另一个线程再次打印下 o1发现他的偏向状态仍然为0,并没有被重新设置为1
System.out.println("----------------- o2 ------------------");
Object o2 = new Object(); // 重新初始化对象 o2
System.out.println(ClassLayout.parseInstance(o2).toPrintable()); // 查看 o2 的对象头偏向状态为 1
synchronized (o2){
System.out.println(ClassLayout.parseInstance(o2).toPrintable()); // 我们发现此时为偏向锁
}
}).start();
}
由此我们可以得出结论,当虚拟机开启偏向锁成功之后,被初始化的对象会开启对象的可偏向状态(也有一种说法是第一个获取对象的线程将可偏向状态设置为1的,我没有验证过。如果有大佬验证过此处欢迎指出改正),当线程进入同步代码块时可根据该对象的可偏向状态决定启动偏向锁/轻量级锁。
3.2.1. 误区一
《深入理解java虚拟机》在描述偏向锁时是这么写的:
由上面代码可以看出这样的描述并不准确吧。我们明显看出可偏向标志是在初始化时被设置的,并不是和Thread ID同时被线程所设置。
这句话应该也误导了一些程序员,比如江湖上广为流传的一张图(我截取了片段):
我们测试的代码可以明显看出这个逻辑是错误的。
首先可偏向状态是由初始化对象时就被设置的,并不是由线程去设置,其次可偏向状态为0时会直接采用轻量级锁而不是再尝试去修改Thread ID(因为竞争线程无法知道偏向线程此时的状态)。
3.3. 偏向撤销
偏向撤销顾名思义,就是将对象中的可偏向状态从1设置为0。
撤销偏向在什么时间发生呢?由上文的图中我们可以看出,当线程通过CAS操作替换Thread ID失败时和检查Mark Word对象中的Thread ID并不是本线程时,会执行偏向撤销(当然并不仅这两种情况,后边我们会讲到其他情况导致的偏向撤销)。
讲到这里可能有些小伙伴会质疑了,因为上文中的描述也和《Java并发编程的艺术》书中描述的并不一样,我们来看看这本书是如何描述的。然后针对这样的分歧点进行测试。
3.3.1. 误区一
下文取自《Java并发编程的艺术》:
下面我们来用一段代码测试下:
public static void test03() throws InterruptedException {
Thread.sleep(5000); // 或开启-XX:BiasedLockingStartupDelay=0
Object o = new Object();
System.out.println(ClassLayout.parseInstance(o).toPrintable()); // 偏向状态正常开启
synchronized (o){
// 偏向锁执行
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
// 代码块已退出
System.out.println("代码块已退出");
// 再次打印对象 o 的 markword 可以看出对象依然是偏向状态 Thread ID被设置为主线程
System.out.println(ClassLayout.parseInstance(o).toPrintable());
// 开启子线程
new Thread(()->{
synchronized (o){
System.out.println(ClassLayout.parseInstance(o).toPrintable()); // 我们可以看到偏向被撤销
}
}).start();
Thread.sleep(1000);
// 我们再次查看对象 o 的mark word 偏向被撤销(无锁状态)
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
很明显书上又错了。并且错的很离谱。这里同样又误导了很大一群程序员,我再次拿出江湖上流传很广的一张图:
其实仔细想想就能明白,偏向锁存储Thread ID的位置是在Mark Word中,Mark Word在对象头中,而第二个线程进来时,它除了能看到Mark Word中的Thread ID外,它如何判断持有偏向锁线程的状态呢?(这条线程在同步代码块中?还是已经退出了同步代码块?)既然无法知道它的状态,仅仅通过CAS去修改Thread ID即便是成功了,但这又凭什么等于是竞争锁成功了呢?这仅仅能证明在修改Thread ID的过程中只有他一个人,仅此而已。所以当一个线程检查到Thread ID并不是自己时,他会直接进行偏向撤销而不是再次尝试替换自己的Thread ID到Mark Word中。
3.4. 偏向撤销的底层实现
在并发的环境中,如果想撤销偏向状态,就必须知道被写入Mark Word中的偏向线程的状态以及线程的精确信息。所以此时竞争者会向JVM提交一个STW(stop the word 时间静止)的请求,在偏向线程到达 safepoint 时来获取它的精确状态。如果偏向线程此时还处于同步代码块中,JVM 会将Mark Word的信息转移到偏向线程的栈帧的lock record中(官方命名为displaced mark word)(这里如果听不明白没关系,下面的轻量级锁我们会详细讲解这个过程,这里理解它为将偏向锁膨胀为轻量级锁即可),如果偏向锁不在同步代码块中,则将偏向状态设置为 0 并改为无锁状态。这个过程是不会引起整体的STW的。
3.5. HashCode与偏向撤销
上文中我在讲Mark Word时留下一个小问题,细心的小伙伴应该也能注意到,Mark Word中存储HashCode与Thread ID、epoch的位置是冲突的。那当需要存储HashCode时,偏向锁的Thread ID怎么办呢?
在Java中如果一个对象计算了HashCode,那就应该一直保持该值不变(当然你也可以重写Object的hashcode()方法),这个值是强制保持不变的,它通过在对象头中存储计算结果来保证第一次计算之后,再次调用该方法取到的HashCode永远不会发生改变。因此当对象被计算过HashCode之后,他的偏向状态将被撤销,并且再也无法进入偏向状态。
我们再次用一段代码来验证:
public static void test04() throws InterruptedException {
Thread.sleep(5000);
Object o = new Object();
System.out.println(o.hashCode());
System.out.println(ClassLayout.parseInstance(o).toPrintable()); // 打印 mark word
synchronized (o){
System.out.println(ClassLayout.parseInstance(o).toPrintable()); //打印 mark word
}
}
而当对象正在处于偏向状态锁状态时,又收到需要计算HashCode的请求时,他的偏向状态会被立即撤销,并且锁会直接跳过轻量级锁膨胀为重量级锁。
public static void test05(){
Thread.sleep(5000);
Object o = new Object();
System.out.println(ClassLayout.parseInstance(o).toPrintable()); // 打印 mark word
synchronized (o){
System.out.println(ClassLayout.parseInstance(o).toPrintable()); // 第一次查看 mark word
System.out.println(o.hashCode());
System.out.println(ClassLayout.parseInstance(o).toPrintable()); // 在调 hashCode 之后再次查看 mark word
}
}
以上说的HashCode的计算都来自Object::hashCode()或者System::identityHashCode(Objcet)方法,如果重写了hashCode()方法,计算HashCode时不会产生以上的问题。
3.6. 批量撤销是什么
此时请思考一个问题?当只有一个线程反复进入同步代码块时,偏向锁确实为我们带来了诸多好处(性能开销低到可以忽略不计)。但当其他线程尝试获取锁时,就需要等到safepoint时才能将偏向锁撤销为无锁或升级到轻量级锁/重量级锁。这个过程是要消耗一定的成本的。试想一下,如果该场景本身就存在多线程的竞争,那偏向锁的存在并不能提升性能,反而会使性能下降。
如果你还不明白我再举一个例子:比如一个100次的循环中线程A持有了类N的100个锁对象,此时他们全部偏向线程A,但紧接着此时线程B、线程C、线程D再次走进这个循环...... 如果没有批量撤销机制的话,类N的100个锁对象将会被连续撤销100次。
一个类的对象在一个时间内被撤销了很多次,JVM会认为这个程序的设计是有问题的,JVM会直接将这个类所有的对象都撤销偏向,新实例化的对象也不可以偏向。
3.7. 批量重偏向是什么
我们再思考第二个问题?对象虽然被多线程访问了,但并没有竞争。原偏向线程为T1,但后面均由T2线程访问,但因为原线程已经被T1偏向,所以T2线程在使用synchronized时不得不使用轻量级锁。当这样的撤销次数也打到一定阈值时,JVM会认为自己偏向错了,此时会将锁重新偏向回T2。(批量重偏向并不会将已经升级的轻量级锁或重量级锁对象降级,而是新的偏向对象不再会因为Thread ID不同而被撤销偏向状态)。
3.8. epoch 的作用
首先引入一个概念 epoch,我们可以把它理解为一个时间戳,或者偏向锁的版本。epoch 会存储在可偏向对象的Mark Word中,以及类的class信息中也会保存一个epoch的值。
在新的线程请求MarkWord时,会判断epoch位是否与该对象所属类的class的epoch匹配。如果不匹配,则表明偏向已过期,需要重新偏向。这种情况,偏向线程可以简单地使用原子CAS指令重新偏向于这个锁对象。
为了更深刻的理解,我们现在来解读一下源码:
// 批量重偏向/批量撤销的触发条件:
static HeuristicsResult update_heuristics(oop o, bool allow_rebias) {
markOop mark = o->mark();
// 如果不是偏向模式直接返回
if (!mark->has_bias_pattern()) {
return HR_NOT_BIASED;
}
// 获取锁对象的类元数据
Klass* k = o->klass();
// 当前时间
jlong cur_time = os::javaTimeMillis();
// 该类上一次批量重偏向的时间
jlong last_bulk_revocation_time = k->last_biased_lock_bulk_revocation_time();
// 该类单个偏向撤销的计数
int revocation_count = k->biased_lock_revocation_count();
// 按默认参数来说:
// 如果撤销计数大于等于 20,且小于 40,
// 且距上次批量撤销的时间大于等于 25 秒,就会重置计数。
if ((revocation_count >= BiasedLockingBulkRebiasThreshold) &&
(revocation_count < BiasedLockingBulkRevokeThreshold) &&
(last_bulk_revocation_time != 0) &&
(cur_time - last_bulk_revocation_time >= BiasedLockingDecayTime)) {
// This is the first revocation we've seen in a while of an
// object of this type since the last time we performed a bulk
// rebiasing operation. The application is allocating objects in
// bulk which are biased toward a thread and then handing them
// off to another thread. We can cope with this allocation
// pattern via the bulk rebiasing mechanism so we reset the
// klass's revocation count rather than allow it to increase
// monotonically. If we see the need to perform another bulk
// rebias operation later, we will, and if subsequently we see
// many more revocation operations in a short period of time we
// will completely disable biasing for this type.
k->set_biased_lock_revocation_count(0);
revocation_count = 0;
}
if (revocation_count <= BiasedLockingBulkRevokeThreshold) {
// 自增计数
revocation_count = k->atomic_incr_biased_lock_revocation_count();
}
// 此时,如果达到批量撤销阈值,则进行批量撤销。
if (revocation_count == BiasedLockingBulkRevokeThreshold) {
return HR_BULK_REVOKE;
}
// 此时,如果达到批量重偏向阈值,则进行批量重偏向。
if (revocation_count == BiasedLockingBulkRebiasThreshold) {
return HR_BULK_REBIAS;
}
// 否则,仅进行单个对象的撤销偏向
return HR_SINGLE_REVOKE;
}
简单得出结论:
对于一个类来说,如果距离上次批量重偏向25秒内,
单次偏向撤销次数(revocation_count )到达20次,就会进行批量重偏向。
单次偏向撤销次数(revocation_count)达到40次,则会进行批量撤销。
参数:
-XX:BiasedLockingDecayTime=25000
-XX:BiasedLockingBulkRebiasThreshold=20
-XX:BiasedLockingBulkRevokeThreshold=40
// 批量重偏向执行逻辑:
static BiasedLocking::Condition bulk_revoke_or_rebias_at_safepoint(oop o,
bool bulk_rebias,
bool attempt_rebias_of_object,
JavaThread* requesting_thread) {
assert(SafepointSynchronize::is_at_safepoint(), "must be done at safepoint");
if (TraceBiasedLocking) {
tty->print_cr("* Beginning bulk revocation (kind == %s) because of object "
INTPTR_FORMAT " , mark " INTPTR_FORMAT " , type %s",
(bulk_rebias ? "rebias" : "revoke"),
(void *) o, (intptr_t) o->mark(), o->klass()->external_name());
}
jlong cur_time = os::javaTimeMillis();//当前时间
//将这次重偏向时间写入类元数据,作为下次触发批量重偏向或批量撤销的启发条件之一
o->klass()->set_last_biased_lock_bulk_revocation_time(cur_time);
Klass* k_o = o->klass();
Klass* klass = k_o;
if (bulk_rebias) { //todo 批量重偏向
// Use the epoch in the klass of the object to implicitly revoke
// all biases of objects of this data type and force them to be
// reacquired. However, we also need to walk the stacks of all
// threads and update the headers of lightweight locked objects
// with biases to have the current epoch.
// If the prototype header doesn't have the bias pattern, don't
// try to update the epoch -- assume another VM operation came in
// and reset the header to the unbiased state, which will
// implicitly cause all existing biases to be revoked
//todo 类开启偏向模式 才能批量重偏向 如果不是偏向模式则不能进入一下代码块
if (klass->prototype_header()->has_bias_pattern()) {
int prev_epoch = klass->prototype_header()->bias_epoch();
//todo 自增类的 epoch
klass->set_prototype_header(klass->prototype_header()->incr_bias_epoch());
// 获取类自增后的 epoch
int cur_epoch = klass->prototype_header()->bias_epoch();
// Now walk all threads' stacks and adjust epochs of any biased
// and locked objects of this data type we encounter
// 遍历所有线程
for (JavaThread* thr = Threads::first(); thr != NULL; thr = thr->next()) {
GrowableArray<MonitorInfo*>* cached_monitor_info = get_or_compute_monitor_info(thr);
//遍历所有线程的锁记录
for (int i = 0; i < cached_monitor_info->length(); i++) {
MonitorInfo* mon_info = cached_monitor_info->at(i);
oop owner = mon_info->owner();
markOop mark = owner->mark();
//找到所有当前类的偏向锁对象
if ((owner->klass() == k_o) && mark->has_bias_pattern()) {
// We might have encountered this object already in the case of recursive locking
assert(mark->bias_epoch() == prev_epoch || mark->bias_epoch() == cur_epoch, "error in bias epoch adjustment");
//更新该类的偏向锁对象的 epoch 与 类的epoch 保持一致
owner->set_mark(mark->set_bias_epoch(cur_epoch));
}
}
}
}
// At this point we're done. All we have to do is potentially
// adjust the header of the given object to revoke its bias.
// 这一步调用撤销偏向的方法,在这里将对象设置为匿名偏向状态(101)
revoke_bias(o, attempt_rebias_of_object && klass->prototype_header()->has_bias_pattern(), true, requesting_thread);
}
// .....................略
BiasedLocking::Condition status_code = BiasedLocking::BIAS_REVOKED;
//todo 如果满足条件,则直接将锁重偏向于当前线程
if (attempt_rebias_of_object &&
o->mark()->has_bias_pattern() &&
klass->prototype_header()->has_bias_pattern()) {
markOop new_mark = markOopDesc::encode(requesting_thread, o->mark()->age(),
klass->prototype_header()->bias_epoch());
//将新的 mark word set进去
o->set_mark(new_mark);
status_code = BiasedLocking::BIAS_REVOKED_AND_REBIASED;
if (TraceBiasedLocking) {
tty->print_cr(" Rebiased object toward thread " INTPTR_FORMAT, (intptr_t) requesting_thread);
}
}
assert(!o->mark()->has_bias_pattern() ||
(attempt_rebias_of_object && (o->mark()->biased_locker() == requesting_thread)),
"bug in bulk bias revocation");
return status_code;
}
得出结论:
1. 当满足偏向重偏向条件(revocation_count == BiasedLockingBulkRebiasThreshold)并且class开启了偏向模式时(klass->prototype_header()->has_bias_pattern())会进行批量重偏向,此时会自增 ”类“中的epoch,然后遍历所有线程,并在这些线程中找到所有持有该锁对象的线程(由注释可看出是指轻量级锁),并将这些“对象”中的epoch修改为与“类”中的epoch一致。
2. 然后调用 revoke_bias方法(撤销偏向)关键参数 allow_rebias(是否允许重偏向被设置为true)。在这个方法中会将对象重新设置成匿名偏向状态。最后如果满足条件,将对象重新偏向给新的线程(Rebiased object toward thread)。
我们再来展示revoke_bias(撤销偏向)的部分源码:
static BiasedLocking::Condition revoke_bias(oop obj, bool allow_rebias, bool is_bulk, JavaThread* requesting_thread) {
// .....................略
uint age = mark->age();
//构建一个偏向模式的 mark word (101)
markOop biased_prototype = markOopDesc::biased_locking_prototype()->set_age(age);
//构建一个无锁模式的 mark word (001)
markOop unbiased_prototype = markOopDesc::prototype()->set_age(age);
// .....................略
// 如果线程已经不存活
if (!thread_is_alive) {
// 如果可重偏向则将对象设置为匿名偏向状态
if (allow_rebias) {
obj->set_mark(biased_prototype);
}
// 否则设置为无锁状态
else {
obj->set_mark(unbiased_prototype);
}
if (TraceBiasedLocking && (Verbose || !is_bulk)) {
tty->print_cr(" Revoked bias of object biased toward dead thread");
}
return BiasedLocking::BIAS_REVOKED;
}
// .....................略
if (highest_lock != NULL) {
// Fix up highest lock to contain displaced header and point
// object at it
highest_lock->set_displaced_header(unbiased_prototype);
// Reset object header to point to displaced mark
obj->set_mark(markOopDesc::encode(highest_lock));
assert(!obj->mark()->has_bias_pattern(), "illegal mark state: stack lock used bias bit");
if (TraceBiasedLocking && (Verbose || !is_bulk)) {
tty->print_cr(" Revoked bias of currently-locked object");
}
}
else {
if (TraceBiasedLocking && (Verbose || !is_bulk)) {
tty->print_cr(" Revoked bias of currently-unlocked object");
}
//偏向锁的所有者(线程)没有正在持有锁
if (allow_rebias) {
//设置为匿名偏向状态
obj->set_mark(biased_prototype);
} else {
//否则设置为无锁状态
// Store the unlocked value into the object's header.
obj->set_mark(unbiased_prototype);
}
}
return BiasedLocking::BIAS_REVOKED;
}
注:这里的逻辑比较晦涩,如有误,欢迎指正。
// 批量撤销执行逻辑:
// 本来只要撤销 o 这一个对象的偏向锁
// 但是在这次单个对象的偏向撤销中,触发了批量重偏向或批量撤销
static BiasedLocking::Condition bulk_revoke_or_rebias_at_safepoint(oop o,
bool bulk_rebias,
bool attempt_rebias_of_object,
JavaThread* requesting_thread) {
...
jlong cur_time = os::javaTimeMillis();
o->klass()->set_last_biased_lock_bulk_revocation_time(cur_time);
Klass* k_o = o->klass();
Klass* klass = k_o;
if (bulk_rebias) {
// 批量重偏向的逻辑
...
} else {
...
// 批量撤销的逻辑
// 首先,禁用 类元数据 里的可偏向属性
// markOopDesc::prototype() 返回的是一个关闭偏向模式的 prototype
klass->set_prototype_header(markOopDesc::prototype());
// 其次,遍历所有线程的栈,撤销该类正在被持有的偏向锁为轻量级锁
for (JavaThread* thr = Threads::first(); thr != NULL; thr = thr->next()) {
GrowableArray<MonitorInfo*>* cached_monitor_info = get_or_compute_monitor_info(thr);
for (int i = 0; i < cached_monitor_info->length(); i++) {
MonitorInfo* mon_info = cached_monitor_info->at(i);
oop owner = mon_info->owner();
markOop mark = owner->mark();
if ((owner->klass() == k_o) && mark->has_bias_pattern()) {
// 具体撤销,还是通过调用之前说的 revoke_bias() 方法做的
// 我们可以看到第二个参数 allow_rebias 被设置为false了(代表不可重偏向)
revoke_bias(owner, false, true, requesting_thread);
}
}
}
// 当前锁对象可能未被任何线程持有
// 所以这里单独进行撤销,以确保完成调用方的撤销语义
revoke_bias(o, false, true, requesting_thread);
}
......
}
得出结论:批量撤销的逻辑相对简单的多
对于批量撤销时,正在被线程持有的偏向锁通过 safepoint 遍历所有的 Java 线程栈,将偏向锁升级为轻量级锁,并将未被线程正在持有的偏向锁,直接将锁对象的可偏向状态设置为 0 ,禁用偏向状态。
四. 轻量级锁
4.1. 轻量级锁的工作流程
由上图我们可以看出,在进入同步代码块时,如果对象没有被锁定(锁标志位 01),虚拟机将会在当前线程的栈帧中开辟一个空间 Lock Record(锁记录),用来存储当前Mark Word的拷贝(我们在介绍Mark Word结构时,曾经留下一个问题?那就是轻量级锁中的HashCode等信息去哪了?没错,它现在被存在了栈帧中)被拷贝的Mark Word 官方称为 Displaced Mark Word。
随后使用CAS操作尝试把对象的Mark Word更新指向Lock Record的指针,如果这个动作成功了,更新锁标志位为“00”并进入同步代码块,失败则查看Mark Word中的指针是不是已经指向了自己,如果不是,则代表存在竞争,此时膨胀为重量级锁。
4.2. 轻量级锁是否会自旋?
有些小伙伴看到这里是不是有些疑问?因为《Java 并发编程的艺术》一书与我上文中的描述并不一样,我们来引用它的原文看看:
我再次拿出江湖上流传很广的一张图:
有疑问怎么办?我们先来看看原始论文是怎么说的:
https://www.oracle.com/technetwork/java/biasedlocking-oopsla2006-wp-149958.pdf
翻译一下:
当一个对象通过 monitorenter 字节码被轻量锁定时,锁记录就会隐式或显式地在线程(正在执行锁获取操作的线程)的栈上分配。锁记录(Lock Record)保存了对象 mark word 的原始值,还包含了用于标识被锁对象所需的元数据(元数据就是描述数据的数据)。在获取锁的过程中,将 mark word 复制到锁记录中(这个 mark word 副本叫 displaced mark word),并执行 CAS 操作尝试使对象 mark word 指向锁记录。如果 CAS 成功了,当前线程就拥有了这个锁。如果失败了,因为其它线程获得了锁,则锁膨胀(膨胀过程中,OS 互斥锁和条件变量会与该对象关联)。在锁膨胀的过程中,对象 mark word 用 CAS 更新,以指向包含互斥锁和条件变量指针的数据结构。
铁证如山了吧。还没完我们继续再看看源码:
写到这里真的有些感慨。并不是不允许工具书有错,但有这么多错(并且很多错误是非常低级的,稍加验证就会发现是错的),因为这些错误误导了这么多的人真的有些说不过去,作为一本工具书还是应该更严谨的好。
五. 重量级锁
开讲重量级锁之前,请各位小伙伴思考这样几个问题?重量级锁为什么称之为重量级锁呢?为什么它比轻量级锁或者偏向锁来的更“重”呢?到底“重”在哪里?
5.1. Java线程模型(HotSpot实现)
HotSpot 目前采用的是内核线程实现,也就是我们常说的 1:1 实现(常见的其他实现方式还有 1:N 和 M:N)。在这种模式下所有的用户态(User Mode)线程都是基于内核态(Kernel Mode)线程实现的。比如各种线程的创建、析构、同步,都需要通过OS(操作系统)在内核态环境调用。而这种调用的代价是比较高的,需要用户态和内核态中来回切换。(细心的同学会发现我们的Thread类,他的关键方法基本都被 native 修饰)。
HotSpot 中的每一个Java线程都是直接映射到操作系统(OS)的内核线程中来实现的,而且中间没有额外的间接结构,所以HotSpot不会干涉线程的调度(当然是可以设置线程优先级给操作系统提供调度建议的)。所以冻结或者唤醒线程、该给线程分配多少时间片、线程应该去哪个处理器核心去执行等等,都是由操作系统完成的。
5.2. 上下文切换
如果可运行数大于CPU的数量,那么操作系统最终会将某个正在运行的线程调度出来,从而使其他线程能够使用CPU,这个过程将导致一次上下文切换。(保存当前运行线程的执行上下文,并将新调度进来的线程的执行上下文设置为当前上下文)。
上下文切换需要一定的开销。但上下文切换的开销并不是只包含JVM和操作系统的开销,当一个新的线程被切换进来时,它所需要的数据可能不在当前处理器的缓存中,因此上下文切换将导致一些缓存的缺失,因而线程在首次调度运行时会更加缓慢。这就是为什么调度器会为每个可运行的线程分配一个最小执行时间(时间片),即使有许多其他线程正在等待执行(他将上下文切换的开销分摊到更多不会中断的执行时间上,从而提高整体的吞吐量,当然这会适当牺牲一定的响应性)。
当线程由于等待某个发生竞争的锁而被阻塞时,JVM通常会将这个线程挂起,并允许它被交换出去。如果线程频繁地发生阻塞,那么它们将无法使用完整的时间片。
注:上下文切换的实际开销会随着平台的不同而变化,从经验上判断:大多数通用的处理器中,上下文切换的开销相当于5000 - 10000个时钟周期(几微秒吧)。
看到这里你应该明白,如果想要阻塞或唤醒一条线程,则需要操作系统来帮助完成(synchronized 需要通过向内核申请互斥量(Mutex)来保证互斥同步)。尤其在JDK1.6以前的版本,被互斥的线程会被直接挂起,在这个过程中将包含两次额外的上下文切换,以及所有必要的操作系统操作和缓存操作(被阻塞的线程在其执行时间片还未用完之前就被交换出去,而随后当要获取的锁或者其他资源可用时,又再次被切换回来【当锁被释放时必须要告诉操作系统回复运行阻塞的线程】)。
所以在 synchronized 还没有引入一系列锁优化的时候,他的性能可想而知是很差的。
5.3. 自旋锁
小伙伴们看了上文的描述是不是会觉得锁一旦膨胀到重量级之后性能就会变得很差?那在JDK1.6之后重量级锁还有哪些优化呢?我们这里要讲的自旋锁便是一种对重量级锁的优化。(我猜《Java并发编程的艺术》的作者一定是把它错以为是轻量级锁在CAS操作时的简单自旋了,实际上它并不是)。
虚拟机的开发团队研究发现,大多数的应用上共享数据锁状态只会持续很短的一段时间,为了这段时间去挂起和恢复线程并不值得。与其直接挂起,不如让它“等一会儿”但不放弃处理器的执行时间(分配给线程的时间片)(只要这个自旋的开销小于一次挂起和唤醒,那么它就是值得的)。那如何让线程等待呢?我们只需要让线程自旋一会儿即可,这项技术就是所谓的自旋锁。
自旋并不能代替阻塞,它只适合锁被短暂的占有的情况下。(CAS如果开启自旋与此处提到的自旋锁在这方面是类似的,他们都仅仅当锁被短暂占有或无竞争时才更具有优势,一但竞争十分激烈,不断自旋的开销将远大于将线程直接挂起的开销)。
自旋锁在 JDK 1.4.2 中就已经引入,只不过是默认关闭的。可使用-XX:+UseSpinning参数开启,在以前的版本中自旋的次数默认为10次(一般认为10次自旋的损耗大概刚好小于挂起的开销)。可使用参数-XX:PreBlockSpin来自行修改。
在JDK1.6之后默认开启,并对自旋锁进行了进一步的优化,引入了自适应自旋。
5.4. 自适应自旋锁
自适应自旋,顾名思义,自旋的次数不再是一个固定的值了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的。
如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机会认为这次自旋也很可能成功,进而允许自旋等待相对更长的时间。
而如果这个锁很少成功获得锁,那么以后要获取这个锁时将可能直接省略掉自旋的过程(此时的自旋反而会增加synchronized的开销,所以就没有自旋的必要了)。
5.5. ObjectMonitor对象
下面就是就是我们今天的重头戏,也是synchronized的精华ObjectMonitor。认识它之前我们回顾下我们Mark Word的知识?
这个所谓的互斥量(重量级)指的便是底层的ObjectMonitor对象,但这个对象的作用并不仅仅只是用来存储原Mark Word的信息。除此之外还包含了大量优化操作,以及对synchronized实现互斥同步的底层操作,它是整个synchronized的核心。
首先我们来看看他的构造器:
// initialize the monitor, exception the semaphore, all other fields
// are simple integers or pointers
//构造器
ObjectMonitor() {
_header = NULL; //mark word
_count = 0;
_waiters = 0; //等待线程数
_recursions = 0; // 递归;线程的重入次数,典型的System.out.println
_object = NULL; // 对应synchronized (object)对应里面的object
_owner = NULL; // 标识拥有该monitor的线程
_WaitSet = NULL; // 因为调用object.wait()方法而被阻塞的线程会被放在该队列中
_WaitSetLock = 0 ;
_Responsible = NULL;
_succ = NULL;
_cxq = NULL; // 竞争队列,所有请求锁的线程首先会被放在这个队列中
FreeNext = NULL;
_EntryList = NULL; // 阻塞;第二轮竞争锁仍然没有抢到的线程(在exit之后扔没有竞争到的线程将有可能会被同步至此)
_SpinFreq = 0;
_SpinClock = 0;
OwnerIsThread = 0;
}
为了方便后边的学习我先来简单介绍下构造器中的三个队列:
_cxq队列:单向链表,所有请求锁的线程首先会被放在这个队列中,_cxq是一个临界资源,JVM通过CAS原子指令来将ObjectWaiter写入队列的头部。_cxq队列是个后进先出的栈(stack)。(_cxq队列中的线程在执行动作时会挣扎一下看看能否获得到锁(反正时间片没有用完,那就尝试一下获取锁),并不是直接就挂起)。
_entryList队列:双向链表,当_cxq队列不为空时,Owener会在unlock时根据不同的策略(QMode),将_cxq中的数据移动到_entryList中,并指定_entryList列表头部的第一个线程为OnDeck线程。(记住这里十分重要)。
_WaitSet队列:因为调用wait方法而被阻塞的线程会放在该队列中。
5.5. 重量级锁的加锁过程(ObjectMonitor::enter)
光看图不过瘾,我们接着看看源码:
以上这两段代码是初次尝试获取锁,以及判断是否为重入。如果没有获取锁,进入我们的关键方法EnterⅠ:
关于TryLock的源码:
总结:
根据以上的源码我们可看出重量级锁在将线程挂起之前会不停的“挣扎”。几乎每一步都在尝试通过CAS获取锁,这样做是为了在拥有时间片时尽量尝试获取锁,避免挂起造成的性能损耗。(上文中的上下文切换已经说的非常清楚了)。
5.6. 重量级锁的解锁过程(ObjectMonitor::exit)
这部分看起来有些复杂,我们先看张流程图来梳理思路:
我们来看看源码:
总结:
exit的逻辑看似复杂,其实并不复杂,只要梳理清楚这几种策略就能很好的理解它了。
5.7. 重量级锁的_waitSet源码
wait的逻辑:
这块十分简单,就不展示了源码各位小伙伴可以自己去翻,大概就是包装当前线程ObjectWait,状态会设置为TS_WAIT,然后将它插入到_waitSet中(一个双向链表)。
notify的逻辑:
5.8. 锁消除
锁消除是指虚拟机即时编译器在运行时编译器在运行时检测到某段需要同步的代码块根本不可能存在共享数据竞争而实施的一种对锁进行消除的优化策略。
锁消除的主要判定依据来源于逃逸分析,逃逸分析能够确定一个变量不会逃逸出线程,无法被其他线程访问,那么这个变量的读写就不会有竞争,对这个变量实施的同步措施也就可以安全的消除。
逃逸分析并不是100%准确。
HotSpot在1.6之后才开始支持逃逸分析,至今这项技术还未完全成熟,不成熟的主要原因是逃逸分析的计算成本非常高,甚至无法保证逃逸分析的性能收益会高于他的消耗。(现如今Java语言在服务逐渐小型化的大趋势中已经显得略有笨重,主要的劣势来自于即时编译、提前编译,这种大压力的算法正是即时编译的弱项)。
所以目前虚拟机采用的是并不那么精准,但时间压力相对较小的算法来处理逃逸分析。
5.9. 锁粗化
试想一下,如果有一系列的操作都对同一个对象反复加锁和解锁,甚至在一个循环体中,那此时即使没有线程竞争,频繁的进行互斥操作也会导致不必要的性能开销。
如果虚拟机探测到这样一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围粗化(扩展)到整个操作序列外部。
六. 字节码层面解读synchronized
同步方法与同步代码在字节码层面块略有不同,我们先来看看同步方法(底层的实现逻辑他们都是一样的)。
6.1. 同步方法
// 测试代码:
public class Test6 {
public synchronized void sync() {
// 已这段代码为例
}
}
我们首先使用 javap -v 命令来看看它反编译后的样子:
Last modified 2023-11-20; size 367 bytes
MD5 checksum 498295a538617ae7ecfff526124e4ccf
Compiled from "Test6.java"
public class com.lm.synchonized.Test6
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #3.#14 // java/lang/Object."<init>":()V
#2 = Class #15 // com/lm/synchonized/Test6
#3 = Class #16 // java/lang/Object
#4 = Utf8 <init>
#5 = Utf8 ()V
#6 = Utf8 Code
#7 = Utf8 LineNumberTable
#8 = Utf8 LocalVariableTable
#9 = Utf8 this
#10 = Utf8 Lcom/lm/synchonized/Test6;
#11 = Utf8 sync
#12 = Utf8 SourceFile
#13 = Utf8 Test11.java
#14 = NameAndType #4:#5 // "<init>":()V
#15 = Utf8 com/lm/synchonized/Test6
#16 = Utf8 java/lang/Object
{
public com.lm.synchonized.Test6();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 9: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Lcom/lm/synchonized/Test6;
public synchronized void sync();
descriptor: ()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED // 同步方法的访问标识
Code:
stack=0, locals=1, args_size=1
0: return
LineNumberTable:
line 12: 0
LocalVariableTable:
Start Length Slot Name Signature
0 1 0 this Lcom/lm/synchonized/Test6;
}
SourceFile: "Test6.java"
我们可以看到这样一个flage:ACC_SYNCHRONIZED,没错这个标识便代表这个方法为同步方法。
6.2. class 文件结构
class文件是一组以字节为基础的二进制流,它的各个数据项目严格按照顺序紧凑地排列在文件中,中间没有添加任何分隔符,这使得整个class文件中存储的内容几乎全部是程序运行的必要数据。(class文件自JDK1.2之后就定义好了,并且在后来的十几个大版本小版本中基本没有什么大的变化,它是java技术的基础与基石)。
class文件中只有两种数据类型:
1. 无符号数据: 以u1、u2、u4、u8(字节数)代表无符号数,它的主要作用是来描述数字、索引引用、数量值或者按照UTF-8构成字符串值。
2. 表:由多个无符号数或者其他表组成的复合数据类型,为了区别所有表的命名习惯性以_info结尾。
而我们的 ACC_SYNCHRONIZED 实际上是属于 方法表 中的 access_flags (u2)(作用是为方法定义访问标识)除了方法表还有字段表、属性表等等,而 ACC_SYNCHRONIZED 是仅属于方法表的访问标识。
方法表结构:
类型 | 名称 | 数量 |
---|---|---|
u2 | access_flags | 1 |
u2 | name_index | 1 |
u2 | descriptor_index | 1 |
u2 | attributes_count | 1 |
attribute_info | attributes | attributes_count |
方法表的其他访问标识:
标志名称 | 标志值 | 含义 |
---|---|---|
ACC_PUBLIC | 0x0001 | 方法是否为 public |
ACC_PRIVATE | 0x0002 | 方法是否为 private |
ACC_PROTECTED | 0x0004 | 方法是否为 protected |
ACC_STATIC | 0x0008 | 方法是否为 static |
ACC_FINAL | 0x0010 | 方法是否为 final |
ACC_SYCHRONIZED | 0x0020 | 方法是否为 sychcronized |
ACC_BRIDGE | 0x0040 | 方法是不是由编译器产生的桥接方法 |
ACC_VARARGS | 0x0080 | 方法是否接受不定参数 |
ACC_NATIVE | 0x0100 | 方法是否为 native |
ACC_ABSTRACT | 0x0400 | 方法是否为 abstract |
ACC_STRICT | 0x0800 | 方法是否为 strictfp |
ACC_SYNTHETIC | 0x1000 | 方法是否由编译器自动产生 |
方法表访问标识在class文件中真正的位置:
以下是我对class文件结构的简单整理如果对这块有兴趣的小伙伴可以参照这张表自己研究,在这里就不展开说了 。
同步方法的总结:
上面我们稍微拓展了一些额外的知识,现在我们重新回来总结下这个方法访问标识ACC_SYNCHRONIZED:
1. JVM会解析方法的访问标识,判断方法是不是同步的,其实在底层是去检查了方法的ACC_SYNCHRONIZED标记位是否被设置为1。如果是同步方法,执行线程会先尝试获取锁。在同步方法完成以后,不管是否正常返回(或异常),都会释放锁。
2. 同步方法在实例方法上持有的锁对象为 this,在静态方法上持有的锁对象为 class。
6.3. 同步代码块
// 测试代码
public class Test7 {
public void test01() {
synchronized (this) {
//已这段代码为例
}
}
}
我们反编译一下这段代码并截取这段来看一下:
public void test01();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=3, args_size=1
0: aload_0 //将this入栈(操作数栈,以下均简称栈)
1: dup //复制栈顶元素(this的引用)
2: astore_1 //将栈顶元素存储到局部变量表变量槽 1中(this)
3: monitorenter //将栈顶元素(此时就是this)作为锁,开始同步
4: aload_1 //又从局部变量Slow 1的元素入栈(this)
5: monitorexit //退出同步
6: goto 14 //如果方法正常结束跳转到14
9: astore_2 //这一步就是异常路径,见异常表(将异常对象存储到局部变量表变量槽 2中)
10: aload_1 //和第四步同将局部变量Slow 1的元素入栈(this)(因为上边此时出现了异常)
11: monitorexit //退出同步
12: aload_2 //将局部变量Slow 2的元素(异常对象)入栈
13: athrow //把异常对象抛给方法的调用者
14: return //方法正常返回
Exception table: //以下为异常表
from to target type
4 6 9 any
9 12 9 any
我们可以看到方法的访问标识此时只有ACC_PUBLIC,也就是说这个方法并没有被虚拟机标记为同步方法,而是采用了字节码指令:monitorenter 、monitorexit,并且monitorexit 还出现了两次。这是为什么呢?其实两次monitorexit只执行了一次,其中一个monitorexit指令是在程序发生异常时去保证它的可以安全释放锁。那什么是异常表?我们下面再简单了解下异常表。
6.4. 异常表(exception_table)
他的名字被称为表?难道它和方法表一样都属于表么?没错,异常表和方法表一样都属于表。但在隶属关系上异常表是方法表中Code属性的一部分(且不是必须存在的)。
异常表的属性表结构:
解释下:如果字节码从第start_pc行(这里的行指的是字节码相对于方法体开始的偏移量)到第end_pc行之间(不含end_pc)出现异常,则跳转到handler_pc行进行处理,当catchtype的值为0时,代表任意情况下都需要跳转到handler_pc 处进行处理。
看到这里我们似乎有些明白了上边异常表的执行逻辑了,如还不清楚请看下图:
6.5. 字节码指令 monitorenter与monitorexit
当线程执行到monitorenter指令时,会尝试获取栈顶对象对应监视器(monitor)的所有权,如果此时monitor没有其他线程占有,当前线程成功获取锁,monitor计数器设置为1。如果当前线程已经持有了monitor的所有权,monitorenter也会顺利执行(重入),monitor计数器加1。如果其他线程拥有了monitor的所有权,当前线程会阻塞,直到monitor计数器变为0。
当线程执行monitorexit时,会将monitor计数器减1,计数器值等于0时,释放锁,其他线程可以尝试去获取monitor的所有权。
参考Chapter 6. The Java Virtual Machine Instruction Set