JVM synchronized关键字相关原理分析

众所周知啊,谈到synchronized 关键字就避免不了 谈到这个锁升级
谈到这个锁升级,就避免不了 谈到什么 偏向锁啊,轻量级锁啊,自旋锁啊,重量级锁啊

希望读完这篇文章,读者可以理解为什么synchronized使用时需要关联一个对象,当synchronized加在方法,代码段,静态方法,静态代码段的不同以及锁升级的过程,锁实现的原理

在介绍锁之前,我们先来聊聊对象在jvm里面的布局
一个对象在jvm里面主要是由三个部分组成,对象头,实例数据,对齐填充。
实例数据就是对象真正的有效信息,无论是继承下来的或者是自己定义的字段都需要记录下来,
而对齐填充是因为jvm里面的对象的大小都需要八字节的整数倍,当大小不符合的时候 对其填充就起到了作用
而对象头是由两部分组成,第一部分是存储对象运行时的数据,比如分代年龄,哈希码,锁状态等等,官方称为"Mark Word"。
第二部分则是类型指针,就是这个对象的类型元数据所在的位置
而我们锁的实现,主要奥秘就是在这个 “Mark Word” 里面。
我们先来看看他的布局
mark word

在32位虚拟机里面,“Mark Word” 是由4个字节 32比特组成,在不同状态下,存储的数据如上图
下面就一个一个来讲锁升级的过程和mark word的关系

偏向锁

由名字可以看得出来他是有"偏见",没错,他就是偏向第一次获取这个锁的线程,在偏向锁启用之后,mark word 的后三位是 101。

偏向锁的获取: 当线程获取锁时发现可偏向, 并且此时线程ID没有值 会使用cas去尝试将线程ID替换为当前这个线程的ID,如果cas替换成功则获取锁成功,如果失败则表明发生了锁竞争,则会发生偏向锁的撤销,并升级成轻量级锁。如果线程ID已经有值了并且是当前线程的线程ID则可以直接获得锁继续运行
而如果此时的线程ID已经有值了并且线程ID不等于当前线程的ID并且不会发生重偏向(这个条件我觉得比较苛刻,下面会讲到),即使偏向的线程已经不需要这个锁或者已经消亡了,那么他依旧会将可偏向的位置为0,并且把偏向锁的状态改成无锁状态,以轻量级锁的加锁流程去获取锁,如果获取成功则是轻量级锁的状态

我们可以来验证一下
实验之前 先使用: -XX:BiasedLockingStartupDelay=0 来关闭对偏向锁开启的延迟
添加依赖 这个可以获取类运行时的对象头

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

测试代码:

public static void main(String[] args) throws Exception {
        Object o = new Object();
        Thread thread = new Thread(() -> {
            synchronized (o) {
                System.out.println(ClassLayout.parseInstance(o).toPrintable());
            }
        }, "A");
        thread.start();
        thread.join();
        synchronized (o) {
            System.out.println(ClassLayout.parseInstance(o).toPrintable());
        }
    }

这个代码也很简单,就是先让A线程去第一次获取偏向锁,然后主等待A线程运行结束,主线程再去获取锁,来看看对象头的变化
结果如下:
在这里插入图片描述
我电脑的jvm是32位的
由结果可知,我们对象的大小为8个字节,也就是个对象头的大小。
由于采用的是小端存储,我们主要看第一个字节的内容,这个字节对应的就是Mark Word的后8比特
00001101 这是A线程获得锁的情况,我们可以看到他目前是偏向锁
主线程等待A线程运行完之后,主线程再去竞争锁
拿到锁之后此时 后八位为11010000 可偏向的位已经被置为0,并且此时是轻量级锁的状态。
并且主线程释放锁之后 后三位是001 也就是 无锁并且不可偏向的状态(这边我没有打印出来读者可以自行打印验证)
但是 是不是说偏向锁不能再次偏向另外一个线程了呢? 答案是可以的。但是我觉得这个条件非常苛刻,也就是重偏向机制

批量重偏向与批量锁撤销

这个重偏向也就是
当同一个类型的类发生了20次的偏向锁的撤销(默认是20,jvm有参数可以更改),那么jvm会认为可能不应该撤销,应该重新偏向一个新的线程,那么他会给剩下的这个类型的类的锁一次重偏向的机会,但是可能重偏向之后还是会发生偏向锁的撤销,那么当达到了40次(默认),jvm会认为这个类型的锁不应该应用偏向锁,那么之后这个类型的锁都将取消偏向锁,直接以轻量级锁开始,包括新建的这个类型的锁

上述也就是批量重偏向与批量锁撤销这个机制,所以我觉得这个重偏向的条件还是比较苛刻的

个人心得: 偏向锁在只有偏向的那个线程来访问的话效率比较高的,因为只有在第一次会使用cas,其他的情况直接判断线程ID是不是本线程的ID就可以继续运行了,比轻量级锁更加高效,因为轻量级锁每次重入和获得锁都需要cas,效率没有偏向锁效率高,偏向锁的出现就是为了优化只有一个线程的情况下的轻量级锁

偏向锁其他一点细节: 如果在偏向锁的的情况下,调用Object的hashCode方法,因为对象头已经没有位置存放了,那么他会直接升级到重量级锁,然后在锁对象关联的Monitor里面存储无锁状态下的Mark Word的信息

注:偏向锁默认是延迟开启的 -XX:BiasedLockingStartupDelay=0可以解除延迟

轻量级锁:

加锁流程:jvm会在当前栈帧中创建一个名为锁记录的空间(Lock record),用来存储当前锁对象的Mark Word,然后使用cas将锁对象Mark Word中对应指向栈中锁记录的指针替换成当前这个锁记录的地址。如果替换成功。则说明加锁成功,如果失败,则检查是否当前线程拥有这把锁,如果拥有,则说明发生了锁重入,这边要注意,即使锁重入,那么此次获取锁依旧是有创建一个锁记录,只不过这个锁记录的的没有存对象的Mark Word信息,代表的是一次的锁重入的情况,如果没有拥有这把锁并且获取轻量级锁失败,那么则表示发生了锁竞争,那么就要从轻量级锁升级到重量级锁

文字理解起来比较抽象下面放两幅图
在这里插入图片描述
上面这副图是轻量级锁加锁之前
在这里插入图片描述上面这幅图是轻量级锁加锁成功之后

讲完加锁,就要讲释放锁的流程了

释放锁的流程:如果是锁记录没有存锁对象的Mark Word信息则是代表可重入不释放锁,直到释放到是记录了锁对象的Mark Word信息的锁记录,然后使用cas将Mark Word的信息替换回去,并且置为无锁状态,如果释放锁失败了,则代表已经升级到重量级锁。则执行重量级锁的释放锁的流程

个人心得:我觉得这个轻量级锁倒是挺尴尬的,在没有锁竞争的情况下没有偏向锁的效率高,在有竞争的情况下马上就会升级到重量级锁,个人觉得就是在偏向锁的情况下再给一次机会不升级到重量级锁

自旋锁

我认为这并不是一把锁,这只是一种机制,自旋也就是循环的意思,在重量级锁的情况下,线程过来并不会直接阻塞,而是先通过自旋重试去尝试获取锁,当然这也是在多核CPU的情况下才有用的,如果是单核也没啥用,但是我们现在的电脑几乎都是多核,所以还是有用的。当然任何一个技术都不是凭空产生,他的出现都是有原因的,我们就来聊聊为什么要自旋而不直接阻塞。

java线程目前主流的虚拟机都是通过使用内核线程(1:1)实现,也就是说每一个用户线程底层都有一个内核线程与之对应,当一个线程被阻塞时不会影响这个进程内其他线程的运行,但是这样子对线程的操作就得涉及到系统调用了,学过操作系统的同学都知道,用户进程是不能直接操作内核或者硬件,只能通过操作系统给我们提供的内核函数来调用,而调用内核函数又涉及到用户态和内核态的转换,用户态和内核态的转换通常又涉及到CPU上下文的切换,所以这样子的一个操作是比较重量级的。而我们的java线程的阻塞和唤醒也需要这样子的一个调用,但是这么重量级的操作我们应该尽量避免,而且有时候我们的同步块内的代码可能很快就执行完成了,所以这个时候引入了自旋操作,先不让线程阻塞,先让他自旋尝试获取,如果获取成功了则继续执行,如果失败了再阻塞,这也是为什么要引入自旋这样子的一个操作吧。也就是说自旋是发生在重量级锁阻塞之前,也就是对重量级锁的优化。

在jdk6引入了适应性自旋,就是自旋的时间不再是固定的了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的,如果在同一个锁对象上,自旋刚刚成功获得到锁,并且锁的拥有者现在正在运行,那么虚拟机就会认为这次也会成功,进而允许更久的自旋,相反,如果同一个锁经常自旋没有成功获得锁,那么可能以后会直接省略自旋的这个操作

重量级锁

重量级锁是锁的最后形态,当锁是重量级锁的时候,锁对象的Mark Word里面就会指向一个Monitor对象,重量级锁的实现底层也主要基于这个Monitor对象,他的定义如下:

ObjectMonitor() {
    _header       = NULL;
    _count        = 0; 
    _waiters      = 0,
    _recursions   = 0;
    _object       = NULL;
    _owner        = NULL;
    _WaitSet      = NULL; 
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;
    FreeNext      = NULL ;
    _EntryList    = NULL ; 
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
  }

我们主要关注 count,waitSet,entryList,owner这几字段
count 代表的是加锁的次数,加锁一次则加一,释放锁则减一,可重入锁需要
waitSet 当调用wait方法线程则会先让出锁,然后进入waitSet等待
entryList 就是存的当前阻塞的线程等待被唤醒竞争锁
owner 就是指向拥有锁的线程
当调用notify方法会将waitSet里面一个线程移到entryList 里面阻塞并且等待唤醒获得锁
notifyAll方法则是将waitSet里面所有线程移到entryList 里面阻塞并且等待唤醒获得锁

wait,notify,notifyAll这三个方法都是Object的方法,并且我们可以知道调用这个三个方法都是需要在synchronized代码块里面并且拥有锁的情况下,为什么必须要在synchronized代码块呢,因为他是基于Monitor对象的,而这个对象也只有在重量级锁的情况下才会被使用到,当然也只有拥有锁的线程也有资格让出锁,有资格将waitSet里面的线程转移到entryList

大家可以基于下图来理解
在这里插入图片描述

重量级锁加锁流程:线程尝试使用cas使得owner字段指向自己当前线程,如果成功则表示加锁成功,那么count字段会加一,如果失败可以检查当前拥有锁的线程是不是自己如果 是的话 count字段加一 继续运行

重量级锁释放锁流程:先count字段减一,如果count字段为0,代表需要释放锁,则将owner字段置为null 然后唤醒线程来竞争锁

简略总结一下:

刚开始是偏向锁,如果出现了锁竞争或者不是偏向锁偏向的线程来获取锁那么就会升级到轻量级锁,在轻量级锁的情况下,如果出现锁竞争那么则会在升级到重量级锁

这里解答一下当synchronized加在方法,代码段,静态方法,静态代码段的情况下对应的锁对象是哪个

方法:就是这个方法里面 this 这个对象
代码段:就是synchronized括号里面的那个对象
静态方法:就是那个类对应的Class对象
静态代码段: 就是synchronized括号里面的那个对象

以上就是我这篇文章的所有内容
如果有不对的地方,欢迎指正

参考资料:
深入理解java虚拟机第三版:周志明著
网上一些其他资料

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值