Synchronized你以为你真的懂?

Synchronized是个啥

在多线程并发编程中synchronized一直是元老级角色,很多人都会称呼它为重量级锁。但是,随着Java SE 1.6对 synchronized进行了各种优化之后,有些情况下它就并不那么重了,Java SE 1.6中为了减少获得锁和释放锁带来的 性能消耗而引入的偏向锁和轻量级锁,以及锁的存储结构和升级过程。

如果你对于synchronized的基础知识和用法不了解,自行百度,我们只带你从底层揭开你不了解的synchronized的面纱。关于synchronized我想最重要的就是锁升级的过程了。这也是面试中面试官最爱问,最能考验你对于锁的理解的方面。

一图看清锁的升级路线

从内而外带你看本质,锁升级其实很简单

在这之前给大家安利一个非常实用的功具,它会帮助我们揭开锁的庐山真面目,不知道大家有没有用过JOL,全称是JAVA Objct Layout,字面意思,Java对象的布局,什么意思呢?当我们new一个对象的时候,这个对象在内存中一定是有个布局的。布局是什么样子呢?我简单画了一下,如下图,应该很直观了。

需要关注的是我们new一个对象会有一个对象头markword,类型指针就是指向你到底是哪个类的对象,实例数据顾名思义存的就是实例对象,比如T m = new T(),m就是实例数据,对齐是干嘛的呢,你记住一句话,JVM处理数据是一块一块处理的,他希望这些数据可以被8整除,所以对齐的作用就是前面的几个哥们如果不能被8整除,对齐就会补上缺少的字节数。

当你了解了这个内存布局之后,我们来继续探究所谓的对象上锁到底是个什么东西。下面的测试代码我用到的工具是JOL。首先,我们来看第一段测试代码。

//例1
public class HolleJol {

    public static void main(String[] args) {
        Object o = new Object();
        String s = ClassLayout.parseInstance(o).toPrintable();

        System.out.println(s);
    }
}

短短三句话,我们new一个Object,JOL为我们提供了一个类方法ClassLayout.parseInstanc把对象o的内存布局打印出来,运行上面的代码,运行结果如下:

这一堆的数据让你跟内存布局(结合上面内存布局的图示)联系起来,你能发现什么呢?大家可以思考一下这些值代表的含义。思考过后看下面这张图,加上注释之后,与你想的差了多少?

扫描二维码关注公众号,回复: 10615400 查看本文章

说了这么多,根锁到底有什么关系呢?上面我们看的是一个普通对象,接下来我们给对象加锁,来看一下内存布局的差异。测试代码如下,来看前后的对比。

//例2
public class HolleJol {

    public static void main(String[] args) {
        Object o = new Object();
        String s = ClassLayout.parseInstance(o).toPrintable();

        System.out.println(s);

        System.out.println("=====================================");
        
        synchronized (o){
            System.out.println(ClassLayout.parseInstance(o).toPrintable());
        }
    }
}

话不多说,来看结果:

对比前后的内存的变化,我想区别很明显了吧,没错,只有markword的值变化了。到这我们可以下一个结论:所谓给对象加锁,实际就是对象头发生了变化,锁信息存在了markword里面。加锁就是修改markword。

所以我们要看锁升级的过程,我们只要观察markword就可以了,我画了一张对比图,网上的图大多是32位的,这个图大概是全网第一张64位的了,拿走不谢,忘了锁之间的区别拿出来看看就想起来了。上图:

简单的介绍怎么看这张图,怎么区分不同的锁状态呢?首先看末尾的锁标志位,11表示GC标记信息,10表示重量级锁,00表示轻量级锁,01表示无锁和偏向锁,那么怎么区分无锁和偏向锁呢,就看1bit的偏向锁位,0表示无锁,1表示偏向锁。当然我相信你是记不住的。会看图查就行了。

回到最初锁的升级路线的图上,我们先走通那条红线,也是锁升级的主要路线。提一点,学习重要的是有一个学习的脉络,我们抓住主要脉络,主要脉络理解通透了,其他的分支就很好理解了,你打通了任督二脉,修炼武功那还不是小菜一碟?千万不要从刚学习就一头闷在细节里,这样你对整个知识形成不了体系,不能很好的举一反三。

从上厕所悟出锁升级的过程

好,我们先来说一下偏向锁,StringBuffer我想大家都用过,字符串拼接大家也都用过,append append,而且使用StringBuffer基本都用在单线程的情况下,你知道StringBuffer内部是使用了synchronized吗,我们为了一个字符串,竟然上了一把重量级锁,你每次字符串的操作都要惊动操作系统老大,就好比你家的马桶,百分之八九十的情况就你一个人用,为什么你一个人用?因为你单身嘛。为了优化这种情况,JDK团队优化了这个锁,引入了偏向锁。偏向锁的意思是,凡是有线程第一次得到这把锁的时候,我们就认为这个线程会偏向于它,不必要去惊动操作系统老大。只需要把线程的ID记录到markword里面就行了。张三第一个到wc,把自己的名字贴在了门上。

那什么是自旋锁呢?我们接着说,目前锁是偏向张三的,但是李四,王五来了,想张三你不能这么干啊,我们也要上厕所啊,你不能让我们憋着啊,所以,他们三个就开始竞争,这个竞争我也不说是一直试探看看锁有没有释放,你可以想象成他们三个在抢厕所,谁先把名字贴在厕所上,谁就能上厕所了。意思就是谁先把自己的线程ID贴到markdown上谁就得到了这把锁,这其实是个CAS的过程,对于CAS不了解的直接跳转《CAS你以为你真的懂》。这个过程没有惊动操作系统,所以自旋锁和轻量级锁的效率远远比重量级锁高。但是细想,假如现在有9999个线程,都在抢这把锁,自旋的就是While,得不到锁的线程会一直循环去争抢,这个错所我永远上不去,就是很多的线程会存在空转的现象,一推人在厕所外面跑来跑去。所以说并不是自旋锁效率就一定高。那么这种情况我们要怎么办呢,就要锁升级,升级成重量级锁。

重量级锁到底有什么区别呢,最重要的区别就是重量级锁就是经过操作系统调度之后,如果这是操作系统提供给你的一把锁,这把锁还会带一个队列,这个队列我们通常叫做waitSet。

所以总结一下就是,我们new一个普通对象,首先上偏向锁,有轻度竞争,升级成轻量级锁(无锁,自旋,自适应自旋),如果竞争太激烈,就升级成重量级锁

脉络在前细节在后

  • 细节一:什么情况下,轻量级锁会升级成重量级锁?

这是一个相当复杂的过程,在JDK1.6之前,有两种情况,某个线程的自选次数超过10次(JVM调优可调),等待的自旋的线程数(JVM调优)超过了CPU核数的二分之一。满足这两个条件的任意一个,操作系统会把这些线程丢到等待队列,你们给我等着去。在JDK1.6之后,出现了自适应自旋。什么意思呢?JDK根据运行的情况和每个线程运行的情况决定要不要升级。

  • 细节二:偏向锁

从锁升级的图中能看到两个路线,“偏向锁未启动-->普通对象”和“偏向锁已启动-->匿名偏向对象”什么意思呢?原来我们是可以配置偏向锁未启动和偏向锁已启动的。  操作系统有个参数-XX:BiasedLockingStartupDelay=0从字面意思,偏向锁的启动延迟。题外话,你如果会JVM调优,写在简历上会非常值钱。下面是偏向锁的相关参数:

---BiasedLockingBulkRebiasThreshold:批量重偏向。

---BiasedLockingBulkRevokeThreshold:批量撤销。

---BiasedLockingDecayTime:腐化的时间。

---BiasedLockingStartupDelay:启动延迟。

---UseBiasedLocking:偏向锁开关。

从上图看出来,偏向锁默认是打开的,但是它有4s的延迟,这到底是为什么呢,想象一下JVM刚启动的时候,一定是有很多的线程在运行,操作系统也是知道的,所以明明知道有高并发的场景,所以就延迟了4s。

下面我们来看一下启动偏向锁和不启动偏向锁的区别。

这是第一段代码的结果,我们关注的是Value的markwork的值001,我们知道是无锁态是一个普通对象,因为我们偏向锁有一个启动延迟,这生成的是一个偏向锁未启动的对象。

这样,我就不设置系统参数了,我们用一个延迟来生成一个偏向锁的对象来看一下前后的区别,测试代码和运行结果如下:

//例3
public class HolleJol {

    public static void main(String[] args) throws InterruptedException {
        //延迟5s
        TimeUnit.SECONDS.sleep(5);
        Object o = new Object();
        String s = ClassLayout.parseInstance(o).toPrintable();

        System.out.println(s);
    }
}

等待了5s.....结果如下:

从图中显而易见现在已经是偏向锁(101)了,所以同样你new一个对象在不同的状态下实际上是不一样的,代码中我刚刚new完这个对象o还没有任何线程持有这把锁,那它偏向谁呢,这种的谁也不偏向,叫做匿名偏向,所以结论:我们刚刚new出来的对象,如果偏向锁启动是匿名偏向,没有启动就是普通对象,说这有什么用呢,面试官喜欢啊!

我们看一下路线2:普通对象怎么升级到轻量级锁,我们把延迟去掉,就是一个普通对象到轻量级锁的过程,代码如下:

public class HolleJol {

    public static void main(String[] args) throws InterruptedException {
        //延迟5s
//        TimeUnit.SECONDS.sleep(5);
        Object o = new Object();
        String s = ClassLayout.parseInstance(o).toPrintable();

        System.out.println(s);
        synchronized (o){
            System.out.println(ClassLayout.parseInstance(o).toPrintable());
        }
    }
}

结果如下:

末尾两位是00,对照上面的图,是轻量级锁,所以锁的升级过程你大概了解了吧,其他线路的过程,你可以参照我的例子在多线程场景下模拟,看看锁是怎么升级的。

其他的点,简单提一下量重偏向与批量撤销。

  • 渊源:从偏向锁的加锁解锁过程中可看出,当只有一个线程反复进入同步块时,偏向锁带来的性能开销基本可以忽略,但是当有其他线程尝试获得锁时,就需要等到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。当达到重偏向阈值后,假设该class计数器继续增长,当其达到批量撤销的阈值后(默认40),JVM就认为该class的使用场景存在多线程竞争,会标记该class为不可偏向,之后,对于该class的锁,直接走轻量级锁的逻辑。
  • 解决场景:批量重偏向(bulk rebias)机制是为了解决:一个线程创建了大量对象并执行了初始的同步操作,后来另一个线程也来将这些对象作为锁对象进行操作,这样会导致大量的偏向锁撤销操作。批量撤销(bulk revoke)机制是为了解决:在明显多线程竞争剧烈的场景下使用偏向锁是不合适的。

如果想看源码层面的锁升级,推荐这篇博文:https://www.jianshu.com/p/c5058b6fe8e5

总结,没错,我就是厕所所长

  • 加锁,指的是锁定对象。
  • JDK较早的版本 OS的资源 互斥量 用户态 -> 内核态的转换 重量级 效率比较低,现代版本进行了优化:无锁 - 偏向锁 -轻量级锁(自旋锁)-重量级锁。

  • 偏向锁 - markword 上记录当前线程指针,下次同一个线程加锁的时候,不需要争用,只需要判断线程指针是否同一个,所以,偏向锁,偏向加锁的第一个线程 。hashCode备份在线程栈上 线程销毁,锁降级为无锁。

  • 偏向锁由于有锁撤销的过程revoke,会消耗系统资源,所以,在锁争用特别激烈的时候,用偏向锁未必效率高。还不如直接使用轻量级锁。

  • 有争用 - 锁升级为轻量级锁 - 每个线程有自己的LockRecord在自己的线程栈上,用CAS去争用markword的LR的指针,指针指向哪个线程的LR,哪个线程就拥有锁,自旋超过10次,升级为重量级锁 - 如果太多线程自旋 CPU消耗过大,不如升级为重量级锁,进入等待队列(不消耗CPU)-XX:PreBlockSpin。

  • 自旋锁在 JDK1.4.2 中引入,使用 -XX:+UseSpinning 来开启。JDK 6 中变为默认开启,并且引入了自适应的自旋锁(适应性自旋锁)。自适应自旋锁意味着自旋的时间(次数)不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。

谈点私事

如果你也跟小码一样喜欢钻研,喜欢刨根问底,探究一些底层原理,乐于分享,关注我的原创公众号【小码逆袭】,小码每天都会分享技术干货文章。我还通宵为大家准备了一大波学习资源哦,都是精品,公众号回复:我爱学习   就能免费领取啦。

往期跳转:CAS你以为你真的懂?

关注本专栏,我们一起探究多线程高并发!

发布了69 篇原创文章 · 获赞 260 · 访问量 20万+

猜你喜欢

转载自blog.csdn.net/lyztyycode/article/details/105377173