synchronized锁升级详细讲解案例:代码+流程图

上一篇:对象内存布局详解
我们在上一篇讲解了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线程外有无其他线程,若没有,则重量级锁会直接降级为无锁,重量级锁的降级不是降级为轻量级锁、偏向锁,而是垃圾回收器将它降级为无锁。

上面的例子理解起来比较困难的话,看下图,尝试理解.

四、synchronized锁实现与升级过程

在这里插入图片描述

  • 8
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

@来杯咖啡

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值