深入理解synchronized

synchronized的基础运用

​ synchronized 同步块是 Java 提供的一种原子性内置锁,Java 中的每个对象都可以把它当作一个同步锁来使用,这些 Java 内置的使用者看不到的锁被称为内置锁,也叫作监视器锁。下面表格就是刚毕业的时候会被问到的一些面试题。

image-20230926104413339

​ 当然,还有一个常问的面试题就是jdk对于synchronized的优化,synchronized是JVM内置锁,基于Monitor机制实现,依赖底层操作系统的互斥原语Mutex(互斥量),它是一个重量级锁,性能较低。当然,JVM内置锁在1.5之后版本做了重大的优化,如锁粗化(Lock Coarsening)、锁消除(Lock Elimination)、轻量级锁(Lightweight Locking)、偏向锁(Biased Locking)、自适应自旋(Adaptive Spinning)等技术来减少锁操作的开销,内置锁的并发性能已经基本与Lock持平。

理解synchronized涉及的一些知识

MESA模型

​ 在管程的发展史上,先后出现过三种不同的管程模型,分别是Hasen模型、Hoare模型和MESA模型。现在正在广泛使用的是MESA模型。

image-20230926112649067

​ 管程内部只能允许一个线程进入,这个是互斥,所以有入口等待队列。管程内部有一个条件变量和条件变量等待队列,这个是为了解决线程同步的问题。线程之间的同步其实就是线程之间如何通信、协作。增加条件变量就可以通知对应的等待线程,但是等待线程被唤醒后,不是直接执行,而是重新到入口等待队列,再次排队。现在对于Java中wait(),notify(),notifyAll()这些方法是不是理解了为什么存在?

​ Java中对于上述模型做了一部分修改。条件变量减少为一个。具体实现也有一部分差异。看一下流程图。

image-20230926113912830

​ 上诉的流程图可能不能很明显的说明具体的唤醒流程,JDK的实现会根据不同的策略来决定唤醒的流程,这里仅讲解默认策略,其他的策略大家下去可以自己做了解。默认的策略:在获取锁时,是将当前线程插入到cxq的头部,而释放锁时,如果EntryList为空,则将cxq中的元素按原有顺序插入到EntryList,并唤醒第一个线程,也就是当EntryList为空时,是后来的线程先获取锁。EntryList不为空,直接从EntryList中唤醒线程。

​ 看一下下面的代码,就能了解这段话。代码如下:

public class SyncQModeDemo {
    public static void main(String[] args) throws InterruptedException {
        SyncQModeDemo demo = new SyncQModeDemo();
        demo.startThreadA();
        //控制线程执行时间
        Thread.sleep(100);
        demo.startThreadB();
        Thread.sleep(100);
        demo.startThreadC();
    }
  
    final Object lock = new Object();
  
    public void startThreadA() {
        new Thread(() -> {
            synchronized (lock) {
                System.out.println("A get lock");
                try {
                    Thread.sleep(500);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("A release lock");
            }
        }, "thread-A").start();
    }

    public void startThreadB() {
        new Thread(() -> {
            synchronized (lock) {
                try {
                    System.out.println("B get lock");
                    Thread.sleep(500);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("B release lock");
            }
        }, "thread-B").start();
    }

    public void startThreadC() {
        new Thread(() -> {
            synchronized (lock) {
                System.out.println("C get lock");
            }
        }, "thread-C").start();
    }
}

执行的结果可能和大家最开始想的不一样,我最开始任务b先获取到锁,毕竟先到先得。但是实际的结果相反,执行很多次,都是C先获取到的锁。执行结果如下:

image-20231019175519555

对象头信息

​ 在讲synchronized前,也需要了解一下对象在内存中的布局。一个小问题。new Object()占用多少字节的内存?

​ Hotspot虚拟机中,对象在内存中存储的布局可以分为三块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。

  • 对象头:比如 hash码,对象所属的年代,对象锁,锁状态标志,偏向锁(线程)ID,偏向时间,数组长度(数组对象才有)等。
  • 实例数据:存放类的属性数据信息,包括父类的属性信息。
  • 对齐填充:由于虚拟机要求 对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐。

​ 回到上面的问题,new Object()在内存中占用的字节就是16个字节。大家感兴趣也可以使用一些工具查看具体的对象大小。引入依赖

        <dependency>
            <groupId>org.openjdk.jol</groupId>
            <artifactId>jol-core</artifactId>
            <version>0.10</version>
        </dependency>

编写代码,就可以查看对象具体的信息:System.out.println(ClassLayout.parseInstance(new Object()).toPrintable());,信息如图:

image-20231114110136650

​ 可以看到上面的信息只有对象头,而虚拟机中对象头的信息包含了三个部分。

  • Mark Word

用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在32位和64位的虚拟机中分别为32bit和64bit,官方称它为“Mark Word”。

  • Klass Pointer

对象头的另外一部分是klass类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。 32位4字节,64位开启指针压缩或最大堆内存<32g时4字节,否则8字节。jdk1.8默认开启指针压缩后为4字节,当在JVM参数中关闭指针压缩(-XX:-UseCompressedOops)后,长度为8字节。

  • 数组长度(只有数组对象有)

如果对象是一个数组, 那在对象头中还必须有一块数据用于记录数组长度。 4字节

​ 我们本次分享主要涉及Mark Word,所以主要也是讨论这一部分内容。Mark Word的结构有两种形式,32位和64位,我们本次主要探讨64位的结构。

image-20231114113206586

​ 下面就来按照上面的结构去分析一下最开始我们打印的对象头信息。其实主要是Mark Word的信息,Klass Pointer不涉及本次分享内容。首先,因为计算机底层存储存在大小端问题,所以上面的打印是字节是倒序的。同时存在一个知识点,对象没有调用hashCode方法前,是不会计算对象的HashCode。所以这里的打印也就是1.还有一个小知识点,这个大家应该都清楚。HotSpot 虚拟机在启动后有个 4s 的延迟才会对每个新建的对象开启偏向锁模式。JVM启动时会进行一系列的复杂活动,比如装载配置,系统类初始化等等。在这个过程中会使用大量synchronized关键字对对象加锁,且这些锁大多数都不是偏向锁。为了减少初始化时间,JVM默认延时加载偏向锁。所以JVM开始时,创建的对象都是无锁状态,对应的也就是01.下面的图更加详细的介绍了锁的各种枚举值。

image-20231114152535062

​ 我们可以看到下面的代码。

 				//偏向所延迟偏向
        Object tmp = new Object();
        System.out.println(ClassLayout.parseInstance(tmp).toPrintable());
        System.err.println(tmp.hashCode());
        System.out.println(ClassLayout.parseInstance(tmp).toPrintable());
        Thread.sleep(6000);
        System.out.println(ClassLayout.parseInstance(new Object()).toPrintable());

打印结果:

image-20231114153715157

​ 00010010111101000000110000100101转换为10进制就是317983781。和我们最开始的结构描述一致。最后一个打印则可以看出,新创建的对象都默认添加了偏向锁。

​ 说完了一些前置知识,下面就可以聊聊我们本次的重点synchronized了。

synchronized锁分析

锁对象的转换

​ 针对 Synchronized,java中有四种锁状态。分别为无锁、偏向锁、轻量级锁、重量级锁。下面一图形象的说明各种锁状态之间的转换。

锁升级流程图

​ 下面的代码也可以体现出上面流程之间的转换。

        Thread.sleep(5000);
        //偏向锁撤销
        System.out.println("偏向锁撤销");
        Object o = new Object();
        System.out.println(ClassLayout.parseInstance(o).toPrintable());
        System.err.println(o.hashCode());
        System.out.println(ClassLayout.parseInstance(o).toPrintable());
        Thread.sleep(3000);

        //偏向锁转换为轻量级锁
        System.out.println("偏向锁转换为轻量级锁");
        Object o2 = new Object();
        System.out.println(ClassLayout.parseInstance(o2).toPrintable());
        new Thread(() -> {
            System.out.println("线程1开始执行" + ClassLayout.parseInstance(o2).toPrintable());
            synchronized (o2) {
                System.out.println("线程1拿到锁" + ClassLayout.parseInstance(o2).toPrintable());
            }
            System.out.println("线程1释放锁" + ClassLayout.parseInstance(o2).toPrintable());
        }).start();
        Thread.sleep(1);
        new Thread(() -> {
            System.out.println("线程2开始执行" + ClassLayout.parseInstance(o2).toPrintable());
            synchronized (o2) {
                System.out.println("线程2拿到锁" + ClassLayout.parseInstance(o2).toPrintable());
            }
            System.out.println("线程2释放锁" + ClassLayout.parseInstance(o2).toPrintable());
        }).start();
        Thread.sleep(3000);

        //偏向锁转换为重量级锁
        System.out.println("偏向锁转换为重量级锁");
        Object o3 = new Object();
        System.out.println(ClassLayout.parseInstance(o3).toPrintable());
        new Thread(() -> {
            System.out.println("线程3开始执行" + ClassLayout.parseInstance(o3).toPrintable());
            synchronized (o3) {
                o3.hashCode();
                System.out.println("线程3拿到锁" + ClassLayout.parseInstance(o3).toPrintable());
            }
            System.out.println("线程3释放锁" + ClassLayout.parseInstance(o3).toPrintable());
        }).start();
        Thread.sleep(3000);
        System.out.println("主线程拿到锁" + ClassLayout.parseInstance(o3).toPrintable());

​ 执行的结果实际展示。

有几点需要注意:也是网上很多博客会错误的地方。

  • 无锁——>偏向锁——>轻量级锁——>重量级锁。这个观点是错误的,上面的代码其实已经看出来了,对象创建后要么是无锁状态,要么是偏向锁状态,不存在无锁到偏向锁的转换过程。
  • 轻量级锁自旋获取锁失败,会膨胀升级为重量级锁。这个观点也是错误的,轻量级锁不存在自旋。
  • 重量级锁不存在自旋。这个观点也是错误的,重量级锁在获取失败后会尝试自旋。

后两个知识点可能本次分享没有细致的讲,因为涉及到了源码的分析,再去讲时间有点过长了。可以参考这篇博客,上面详细的介绍了源码的执行步骤。synchronized轻量级锁是否会自旋

锁的批量操作

​ 上面的文档中可以看到,当只有一个线程反复进入同步块时,偏向锁带来的性能开销基本可以忽略,但是当有其他线程尝试获得锁时,就需要等到safe point时,再将偏向锁撤销为无锁状态或升级为轻量级,会消耗一定的性能,所以在多线程竞争频繁的情况下,偏向锁不仅不能提高性能,还会导致性能下降。于是,就有了批量重偏向与批量撤销的机制。

​ 具体流程如下:以class为单位,为每个class维护一个偏向锁撤销计数器,每一次该class的对象发生偏向撤销操作时,该计数器+1,当这个值达到重偏向阈值(默认20)时,JVM就认为该class的偏向锁有问题,因此会进行批量重偏向。

​ 每个class对象会有一个对应的epoch字段,每个处于偏向锁状态对象的Mark Word中也有该字段,其初始值为创建该对象时class中的epoch的值。每次发生批量重偏向时,就将该值+1,同时遍历JVM中所有线程的栈,找到该class所有正处于加锁状态的偏向锁,将其epoch字段改为新值。下次获得锁时,发现当前对象的epoch值和class的epoch不相等,那就算当前已经偏向了其他线程,也不会执行撤销操作,而是直接通过CAS操作将其Mark Word的Thread Id 改成当前线程Id。

​ 当达到重偏向阈值(默认20)后,假设该class计数器继续增长,当其达到批量撤销的阈值后(默认40),JVM就认为该class的使用场景存在多线程竞争,会标记该class为不可偏向,之后,对于该class的锁,直接走轻量级锁的逻辑。

​ 这两个批量操作主要是为了解决一些场景下的问题。例如:批量重偏向机制是为了解决:一个线程创建了大量对象并执行了初始的同步操作,后来另一个线程也来将这些对象作为锁对象进行操作,这样会导致大量的偏向锁撤销操作。批量撤销机制是为了解决:在明显多线程竞争剧烈的场景下使用偏向锁是不合适的。

​ 下面的代码可以看出来这两种操作具体是如何实施的。

        //延时产生可偏向对象
        Thread.sleep(5000);
        int size = 30;
        // 创建一个list,来存放锁对象
        List<Object> list = new ArrayList<>(size);

        // 线程1
        new Thread(() -> {
            for (int i = 0; i < size; i++) {
                // 新建锁对象
                Object lock = new Object();
                synchronized (lock) {
                    list.add(lock);
                }
            }
            try {
                //为了防止JVM线程复用,在创建完对象后,保持线程thead1状态为存活
                Thread.sleep(100000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }, "thead1").start();

        //睡眠3s钟保证线程thead1创建对象完成
        Thread.sleep(3000);
        System.out.println("打印thead1,list中第1个对象的对象头:");
        System.out.println(ClassLayout.parseInstance(list.get(0)).toPrintable());

        // 线程2
        new Thread(() -> {
            for (int i = 0; i < size; i++) {
                Object obj = list.get(i);
                if ((i >= 17 && i <= 21) || i >= 23) {
                    System.out.println("thread2-第" + (i + 1) + "次加锁执行前\t" +
                            ClassLayout.parseInstance(obj).toPrintable());
                }
                synchronized (obj) {
                    if ((i >= 17 && i <= 21) || i >= 23) {
                        System.out.println("thread2-第" + (i + 1) + "次加锁执行中\t" +
                                ClassLayout.parseInstance(obj).toPrintable());
                    }
                }
            }
            try {
                Thread.sleep(100000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }, "thead2").start();
        System.out.println("代码执行完成");
        try {
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        //查看最终对象锁状态
        System.out.println("最终打印,list中第18个对象的对象头:");
        System.out.println(ClassLayout.parseInstance(list.get(17)).toPrintable());
        System.out.println("最终打印,list中第19个对象的对象头:");
        System.out.println(ClassLayout.parseInstance(list.get(18)).toPrintable());
        System.out.println("最终打印,list中第20个对象的对象头:");
        System.out.println(ClassLayout.parseInstance(list.get(19)).toPrintable());
        LockSupport.park();

可以看到具体的截图:

image-20231204152153776

​ 可以看到第18次还是偏向锁转换为轻量级锁,因为发现有线程在竞争。但是19次之后就发现,锁直接偏向到新的线程了。这个就是偏向锁重偏向。可以看到最后的日志。从第19个开始(包含19,并不是参数设置的20)。锁对象释放后,仍然是偏向锁,不是无锁状态。

image-20231204152935864

​ 讲完了偏向锁重偏向,再看看偏向锁撤销。还是相同的代码,只不过size变为50次。同时增加一个JVM配置,防止计数器归零。-XX:BiasedLockingDecayTime=25000ms。代码不再展示,下面直接看具体的执行结果。

image-20231205160456838

​ 可以看到,最后创建锁对象的时候,不再是偏向锁,而是直接无锁状态,按照之前的逻辑,这个对象加锁就会变为轻量级锁。

我们结合上面的代码和结果可以得出:

  • 批量重偏向和批量撤销是针对类的优化,和对象无关。
  • 偏向锁重偏向一次之后不可再次重偏向。
  • 当某个类已经触发批量撤销机制后,JVM会默认当前类产生了严重的问题,剥夺了该类的新实例对象使用偏向锁的权利。
锁的其他优化

​ 当然,锁还有一些其他的优化,这些问题在各类面试中问的也比较多,这里就不再赘述。例如:自旋优化、锁粗化、锁消除、编译器基于逃逸分析对代码做优化。

总结

​ 在写这篇文章的时候,有一个问题其实一直存在,写这样的文章有什么用?但是写完后,我有了一个给自己的答案。

​ 程序员一生或许在技术上很难成为一座高山,但是站在高山上看看远处的风景也是挺好的。

​ 从这些底层的逻辑中,最起码我得到两点可以运用在项目中的技术点。

  • 项目中编写带代码时,如果遇到启动不需要的数据,可以开启懒加载,第一次调用的时候再去执行对应的逻辑。
  • 项目中可以使用同一个字段的不同状态来表示不同的属性,达到节省空间的目的。

​ 就这样吧,结束。

  • 7
    点赞
  • 28
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值