锁膨胀就是所谓的 锁从 无锁-> 偏向锁 -> 轻量锁 -> 重量锁 的过程。锁膨胀没什么太多细节需要讲,主要说下上一讲中的一些疑问。再讲一下批量冲偏向和批量撤销的原理。
目录
一、前情回顾
二、批量重偏向
三、批量撤销
我们看下面代码
package com.hubin.lock;
import org.openjdk.jol.info.ClassLayout;
import static java.lang.System.out;
public class JolExample3 {
static A a;
public static void main(String[] args) throws NoSuchFieldException, IllegalAccessException {
a = new A();
out.println("before lock:");
out.println(ClassLayout.parseInstance(a).toPrintable());
synchronized (a){
out.println("locking......");
out.println(ClassLayout.parseInstance(a).toPrintable());
}
out.println("after lock:");
out.println(ClassLayout.parseInstance(a).toPrintable());
}
}
上一节在讲偏向锁的时候,上述代码执行结果如下,我们把多余的输出都去掉好对比。当不关延迟偏向的时候输出的是无锁--轻量级锁--无锁。 但是当关掉延迟偏向的时候结果就如下所示了,一般程序里面都是这种情况,因为tomcat启动怎么也得启动个4s以上吧。那时候jvm的延迟偏向早已经过了。我们直接关掉锁偏向来模拟,因为我们就是一个main,启动很快。
before lock:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 05 00 00 00 (00000101 00000000 00000000 00000000) (5)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 43 c1 00 20 (01000011 11000001 00000000 00100000) (536920387)
locking......
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 05 e0 7f 02 (00000101 11100000 01111111 00000010) (41934853)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 43 c1 00 20 (01000011 11000001 00000000 00100000) (536920387)
after lock:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 05 e0 7f 02 (00000101 11100000 01111111 00000010) (41934853)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 43 c1 00 20 (01000011 11000001 00000000 00100000) (536920387)
可以看到都是偏向锁,但是三个对象头仔细看后面3个字节不一致,分别为:
00000000 00000000 00000000
11100000 01111111 00000010
11100000 01111111 00000010
我们看jvm源码说明得知,这些字节应该存储的是线程信息。为0就是代码,没有线程持有。
但是为什么在进入同步块之前是偏向锁呢而不是无锁呢?
因为线程在进入一个同步代码块的时候并不知道到底要对对象加什么锁。偏向锁的加锁过程就是,线程在进入同步块的时候根据对象是否可偏向来进行加锁(偏向位是0还是1),如果对象没有进行hashcode运算的话就是可以偏向的。如果对象是无锁状态(001)的话那么对象是不可偏向的进入同步块的时候就不能够加偏向锁了。所以加偏向锁的对象肯定是具有可偏向状态的,这个也就是为什么before lock 的偏向状态是1,但是线程信息是0,也就是没有偏向任何线程。偏向位是0就是不可偏向,为1就是可偏向的。进入同步块的时候告诉线程这个对象是可偏向的(101),可以加偏向锁。
如果在最前面在一行代码 a.hashcode(),这样a就不可偏向了,在进入同步块的时候,JVM会检测a是否能偏向,但是a的锁状态是无锁(001),不可偏向。所以就会加轻量级锁。 因为无锁和偏向锁的两位都是01,只能靠是否偏向去区分。
先解释一波概念:批量重偏向
Thread1实例化了多个对象(同一个类)并且 同步了这些对象,同时Thread2也同步了这些对象。因为锁要升级,所以要多次撤销偏向锁。JVM会认为接下来的对象需要批量重偏向,那么接下来的对象都是偏向不再是轻量。
偏向锁是不能重新偏向的。如果2个线程交叉运行,第一个线程获取了偏向锁,那么第二个线程一定是轻量级锁。但是有一种情况是可以让偏量锁能够批量撤销然后重偏向。我测试了下,发现阈值是20。当然 -XX:+PrintFlagsInitial加上这个启动参数,可以看JVM启动的各种初始值。大家就可以发现其中有如下2个参数。
#偏向锁批量重偏向阈值
intx BiasedLockingBulkRebiasThreshold = 20
#偏向锁批量撤销阈值
intx BiasedLockingBulkRevokeThreshold = 40
先看代码: -XX:BiasedLockingStartupDelay=0
开启一个子线程,进入同步块,让list中对象从偏量锁都变成轻量级锁。然后子线程结束后,主线程同意遍历list,里面在进入同步块。
package com.hubin.lock;
import org.openjdk.jol.info.ClassLayout;
import java.util.ArrayList;
import java.util.List;
import static java.lang.System.out;
/**
* 偏向锁重偏向特殊场景
* 本程序测试结果是第20个以后就会全部变成了偏向
*/
public class JolExample10 {
public static void main(String[] args) throws InterruptedException {
List list = new ArrayList<>();
Thread thread1 = new Thread(){
public void run(){
for(int i=0;i<25;i++){
A a = new A();
synchronized (a){
list.add(a);
}
}
}
};
thread1.start();
thread1.join();
out.println(ClassLayout.parseInstance(list.get(0)).toPrintable());
int count = 1;
for(A a : list){
synchronized (a){
if(count==20){
out.println(ClassLayout.parseInstance(a).toPrintable());
}
}
count++;
}
}
}
运行结果:
com.hubin.lock.A object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 05 98 57 1a (00000101 10011000 01010111 00011010) (441948165)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) a0 c1 00 20 (10100000 11000001 00000000 00100000) (536920480)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
com.hubin.lock.A object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 05 e1 56 01 (00000101 11100001 01010110 00000001) (22470917)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) a0 c1 00 20 (10100000 11000001 00000000 00100000) (536920480)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
如果是list中都是偏向锁,因为偏向延迟关闭了,对象都是可偏向的,所以list里面的对象头里面都是偏向锁。我们主线程模拟的是多线程交替加锁,不存在竞争,所以主线程里面再次遍历list。并且加锁的时候应该都会偏向膨胀为轻量级锁。但是当我们输出第20个以及以后的时候就会发现,结果对象头又变成了偏向锁。重新偏向主线程了。这种特殊场景在官方文档里面也给了一些说明:我翻译过来大概如下
以class为单位 (不是以对象实例为单位,别搞混),为每个class维护一个偏向锁撤销计数器,每一次该class的对象发送偏向撤销操作时,该计数器+1,当这个值达到重偏向阈值(默认20)的时候,JVM就认为该class的偏向锁有问题。因此会进行批量重偏向。将epoch改为01,原来值为00。
下面具体解释下。
每个Class对象会有一个对应的epoch字段,每个偏向锁状态的对象实例MarkWord里面也有该字段。实例的初始值为创建该实例时Class中的epoch的值。每次发生锁膨胀,偏向锁撤销,就将计数器值+1。当达到阈值20的时候,epoch就变成01。同时遍历JVM中所有线程的栈,找到该class所对应的实例对象,将其epoch字段改为01。这里分为2中情况。 第一种是:不在同步块中了,因为for执行完sync后就该对象就不在同步块中了,这个时候对象MarkWord中的epoch 不做修改,此时就不等于 类中的epoch。 这种实例对象就是可以重偏向的。第二种:该对象还在同步块中。 这种情况就会将MarkWord中的epoch改成类中的epoch。这样就不会失效,该锁还是偏向线程1。个人认为是为了锁安全的,因为线程1还在用的时候,不能修改偏向成线程2。又因为阈值20已经达到了,还不能升级轻量锁,所以同步块中的实例还是偏向线程1。而退出同步块的实例,且进入线程2同步块的就会重偏向线程2了。
下次获得锁时,发现当前对象头MarkWord的epoch值和class里面的epoch不相等,则表示当前已经偏向的线程已经失效了,所以不会在执行撤销偏量操作,而是通过CAS将其MarkWord的Thread id 改成当前线程ID。从而达到了批量重偏向。
批量重偏向只会修改20之后的实例对象,之前的状态不做修改还是轻量锁,使用完后释放成为无锁。
这个大家了解下就行了,在实际应用中不多见。一般是多线程交替加锁就是轻量级锁。
我上面的例子是开了一个子线程,然后跟主线程配合输出,如果你开了2个子线程有可能会看到下面结果,都是偏向锁,但是仔细看就会发现,threadId都是一样的,证明是一个线程输出的,因为第一个线程结束第二个线程才开始,会存在线程复用情况。如果你在两个线程中间在写一个线程,里面什么都不干,则线程号就不同,阈值就会起作用了,在20之前输出都是轻量锁,在20之后都是偏向锁了。
public class JolExample10 {
public static void main(String[] args) throws InterruptedException {
List list = new ArrayList<>();
Thread thread1 = new Thread() {
public void run() {
for (int i = 0; i < 25; i++) {
A a = new A();
synchronized (a) {
if(i==1){
out.println(ClassLayout.parseInstance(a).toPrintable());
}
list.add(a);
}
}
}
};
thread1.start();
thread1.join();
Thread thread2 = new Thread() {
public void run() {
int count = 1;
for (A a : list) {
synchronized (a) {
if (count == 2) {
out.println(ClassLayout.parseInstance(a).toPrintable());
}
}
count++;
}
}
};
thread2.start();
}
}
com.hubin.lock.A object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 05 c8 ff 19 (00000101 11001000 11111111 00011001) (436193285)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 05 c2 00 20 (00000101 11000010 00000000 00100000) (536920581)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
com.hubin.lock.A object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 05 c8 ff 19 (00000101 11001000 11111111 00011001) (436193285)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 05 c2 00 20 (00000101 11000010 00000000 00100000) (536920581)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
但是为什么在线程交替获取资源的时候不能单个重偏向呢?
如果可以单个重偏向,那么每个线程的操作步骤就如下所示了:
线程A -> CAS操作 -> 偏向锁 -> 线程A结束
线程B -> CAS操作 -> 偏向锁 -> 线程B结束 (假设CAS操作可以成功,threadid能够改成B的)
线程C -> CAS操作 -> 偏向锁 -> 线程C结束 .....
这样操作效率一点也不比轻量级锁高,所以JVM直接让B和C变成轻量级锁。偏向锁效率高,是因为A获取之后没有结束,而是继续来获取资源。这时候只需要if()判断下就可以了,不需要在进行操作系统层级的一些操作了。CAS操作都会设计到操作系统的函数了。
轻量级锁就是当A结束后,B来获取资源,CAS操作后,将B变成轻量级锁。这就是为什么单个重偏向就没有意义了。
第二章,里面批量重偏向阈值是20,还有一个叫批量撤销,阈值是40。这个批量撤销是什么意思呢? 仔细看我下面描述,如果上面看懂了,这个就不难了。
线程1 : new 出来 100个对象a。
线程2 : 循环调用synchronized(a) 30次。 这期间会撤销偏向锁,然后变成轻量锁。
当执行到第20次撤销的时候,会进行重偏向成线程2.
剩余10次循环就不会在撤销了,而是直接if判断线程ID即可。
此时剩余的70个对象都是偏向锁,偏向线程2.
线程3: 线程2执行完后线程3开始。线程3有循环调用synchronized(a) 30次。
此时前20次依然是撤销偏向锁,变成轻量锁。
当执行20次撤销时,计数器统计的类A的撤销次数达到了40次了。
JVM则不再执行批量重偏向线程3,而是直接将剩余的实例对象的锁都升级成轻量锁
此时剩余的50个实例对象就会批量撤销,然后膨胀成轻量锁。也可以理解成批量膨胀成轻量锁。
至此,锁的对象头和膨胀大概就讲完了。如果想还要了解更深的,就需要深入到jvm源码,看C++的代码了。至此Synchronized相关的知识才算是讲述差不多了。下一节我们来讲下面试中经常问道的另一个关键字:Volatile。