上一篇:对象内存布局详解
我们在上一篇讲解了Java对象中具体存储的内容,接下来我们开始分析在并发场景下,对象头中的状态是如何变化的。
声明:
- 以下是JDK1.8的版本,1.8默认是开启偏向锁延迟的,时间大约4s;
- 为什么默认4s才开启偏向锁?
–jvm启动的时候,自己会创建十几个线程分别去初始化很多带有synchronized同步代码块的类。所有jvm启动的时候内部就存在线程的竞争,Java为了避免对象锁从偏向锁-轻量锁-重量锁的升级带来的开销; - 锁是针对“同步块”的,只有遇到“同步块”才会出现锁,这句话就解释了“无锁可偏向”的语义,因为没有遇到“代码块”,所以即使具备“偏向标识”也是无锁状态;
- 如果是多个线程交替执行:轻量锁;如果是多个线程并发执行:重量锁;
一、锁升级案例演示
1.无锁到偏向锁
场景:
JDK1.8默认开启偏向锁延迟,延迟时间大约4s,然后“偏向锁”才开启。我们故意等偏向锁开启后,再来执行代码。
运行时对象头锁状态分析工具JOL,他是OpenJDK开源工具包,引入下方maven依赖
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.10</version>
</dependency>
public class T0_ObjectSize {
public static void main(String[] args) throws InterruptedException {
//关闭偏向锁延迟
TimeUnit.SECONDS.sleep(5);
Object o = new Object();
//(00000101 00000000 00000000 00000000) (5) 无锁可偏向
System.out.println(ClassLayout.parseInstance(o).toPrintable());
synchronized (o){
//(00000101 01001000 01101110 00000011) (57559045) 偏向锁
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
}
}
2.无锁到轻量锁
场景:
JDK1.8默认开启偏向锁延迟,延迟时间大约4s,然后“偏向锁”才开启。我们就来试试在这4s期间,执行我们的代码。 至于为什么4s后JVM才开启偏向锁,请看我文章开头写的【声明】。
public class T0_ObjectSize {
public static void main(String[] args) throws InterruptedException {
//关闭偏向锁延迟
// TimeUnit.SECONDS.sleep(5);
Object o = new Object();
//(00000101 00000000 00000000 00000000) (5) 无锁可偏向
System.out.println(ClassLayout.parseInstance(o).toPrintable());
synchronized (o){
//(00000101 01001000 01101110 00000011) (57559045) 偏向锁
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
}
}
解释:
因为偏向锁在4s后才会有,因此这在这4s期间凡是遇到“同步块”都是“轻量锁”。
3.偏向锁升级为轻量锁
场景:
两个线程交替获取这把锁执行。
@Slf4j
public class T0_BasicLock {
public static void main(String[] args) {
try {
//休眠5s,JVM启动了偏向锁
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
Object o = new Object();
//(00000101 00000000 00000000 00000000) (5) 无锁可偏向(只有遇到同步块,才会有锁)
System.out.println(ClassLayout.parseInstance(o).toPrintable());
new Thread(()->{
synchronized (o){
//(00000101 10111000 01010110 00011010) (441890821) 偏向锁
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
}).start();
try {
//等上一个线程执行结束,此处在模拟让两个线程交替执行
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
//(00000101 10111000 01010110 00011010) (441890821) 偏向锁
System.out.println(ClassLayout.parseInstance(o).toPrintable());
new Thread(()->{
synchronized (o){
//(11100000 11110110 00101110 00011011) (456062688) 轻量锁,存在两个线程交替执行。
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
}).start();
}
}
4.重量级锁
场景:
两个线程同时去竞争锁,任意一个线程竞争成功,故意让它睡眠2s。
为什么要故意睡眠呢?
结论: 刻意避免“自旋”优化机制生效。
解释: 因为JVM提供了“自旋锁”优化机制,当线程1获取锁的时候,线程2不会立马挂起(1.线程挂起不仅仅涉及cpu线程上下文切换,2.挂起还会使cpu从用户态到内核态转换),而是通过“自旋”100次或者50次(时间查不到为几十、几百毫秒)的方式等线程1释放锁,这样线程2就节省了由于线程挂起造成的时间成本。 此处的睡眠,就是为了避免“自旋锁”的优化机制生效。
public class T0_heavyWeightMonitor {
public static void main(String[] args) throws InterruptedException {
Thread.sleep(5000);
Object a = new Object();
Thread thread1 = new Thread(){
@Override
public void run() {
synchronized (a){
System.out.println("thread1 locking");
//(00001010 10001010 10101011 00000010) (44796426) 10锁标志是:重量级锁
System.out.println(ClassLayout.parseInstance(a).toPrintable());
try {
//让线程晚点儿死亡,造成锁的竞争
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
};
Thread thread2 = new Thread(){
@Override
public void run() {
synchronized (a){
System.out.println("thread2 locking");
//(00001010 10001010 10101011 00000010) (44796426) 10锁标志是:重量级锁
System.out.println(ClassLayout.parseInstance(a).toPrintable());
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
};
thread1.start();
thread2.start();
}
}
至此,锁升级的详细案例写完了。但是我想再添加一个案例,案例的目的是为了让我们更加深刻的理解一个概念- - - 锁状态是记录在对象中的。
二、加深“锁状态记录在对象”中概念
1.不同对象在不同阶段记录的锁状态可能不同
场景:
jdk8,在两个sout中间休眠4s,等待jvm启动偏向锁;第一个sout是001,第二个是101;
注意:
该场景分别new两个对象打印的。
偏向模式在对象初始化的时候就开始作用了,不会因为你睡眠了几秒之后对象头就变的,开头睡眠几秒是为了避开 JVM 默认关闭偏向锁,或者你关闭偏向锁延迟,那么程序开始所有对象都是默认有偏向模式。
2.不遇“同步块”,对象中的锁状态不变
场景:
jdk8,我们使用的都是一个a对象,jvm启动的时候已经给a确定状态为001了,即使我们后面休眠了6s,也改变不了a的锁状态。
注意:
偏向模式在对象初始化的时候就开始作用了,不会因为你睡眠了几秒之后对象头就变的,开头睡眠几秒是为了避开 JVM 默认关闭偏向锁,或者你关闭偏向锁延迟,那么程序开始所有对象都是默认有偏向模式
A a = new A();
System.out.println(ClassLayout.parseInstance(a).toPrintable()); //001无锁可偏向
Thread.sleep(6000);
//001无锁不可偏向,因为此时markword没有记录线程id,只有记录线程id的001才是
System.out.println(ClassLayout.parseInstance(a).toPrintable());
synchronized (a){
System.out.println(ClassLayout.parseInstance(a).toPrintable()); //000轻量锁
}
三、JVM锁的膨胀升级详细图例
来看图中第一个例子:
这里有两个线程,都去操作同一个对象Object,对象头里有MarkWord,刚开始线程1访问对象的时候,线程2未进入到同步代码块,而线程1进入了同步代码块,它先要做一点事情,即检查当前对象头中的ThreadID是否是线程1,如果不是,会使用CAS修改MarkWord,将对象头中的ThreadID指向线程1,然后执行同步代码块,如果是,则直接执行同步代码块。
现在线程2启动,访问同步代码块,也会检查对象头中的ThreadID是否是线程2,尝试使用CAS修改MarkWord,但修改不了(ThreadID是Null才能改,若不是Null则不能改),则CAS失败,会开启偏向锁的撤销,在线程1到达安全点时会暂停它(STW,Stop The World,与GC有关),然后检查线程1是否退出了同步代码块,如果退出了,则解锁,将对象头中的ThreadID置位空,偏向锁状态改为0,恢复为无所状态,如果未退出,则会升级为一个轻量级锁。
这里就反映出了偏向锁的性能问题,它的撤销过程要做的事情非常多,因此少量同步的场景,不要使用偏向锁,偏向锁只适合一个线程使用的场景,而轻量级锁适合竞争不激烈的场景,业务比较简单,很快可以执行完成,线程间顺序交替执行的场景,而大量同步的场景,不要使用重量级锁,有性能问题(那么使用什么呢?)
再来看图中第二个例子:
还是有两个线程,线程1和线程2都会在栈上分配内存空间,拷贝MarkWord到Lock Record中,然后通过CAS去修改对象的MarkWord,此时有可能线程1修改成功,线程2修改失败,若成功,则对象头中的锁记录指针指向当前栈上Lock Record的指针,升级为轻量级锁,然后执行同步代码块,若失败,会发生自旋获取锁(次数可设置,参数-XX:PreBlockSpin,默认是10次,且是自适应自旋),自旋一定次数依然没有成功,则会发生锁膨胀,升级为重量级锁,线程阻塞。
这其中的优化点即是,当线程2修改失败时,并没有让马上阻塞,而是进行自适应自旋,若一直失败,则锁膨胀,升级为重量级锁,线程阻塞。
线程1在执行同步代码块后,会去使用CAS修改Mark Word,若成功,则释放锁,若失败,则释放锁,唤醒阻塞的线程,开始新一轮的锁竞争(重量级锁的撤销)。
再谈一下重量级锁的撤销,即GC线程在垃圾回收时,会看当前的锁对象除了GC线程外有无其他线程,若没有,则重量级锁会直接降级为无锁,重量级锁的降级不是降级为轻量级锁、偏向锁,而是垃圾回收器将它降级为无锁。
上面的例子理解起来比较困难的话,看下图,尝试理解.