并发——Synchronized原理

1. Monitor 介绍

Monitor 被成为锁或者是管程,是操作系统底层为了控制线程,进程同步时使用的一个对象。在Monitor中有三个管家的属性见下图:
在这里插入图片描述
分别是:

  • waitSet —— 条件等待队列
  • EntryList —— 锁等待队列
  • Owner —— 线程的持有者

这里只需要知道Monitor 是操作系统提供的一个用于进程线程控制的一个对象,以及对象中的三个属性就好了。

2. MarkWord

说到MarkWord,就不得不提到java中一个对象的组成,java中 一个对象包含三部分分别是: 对象头,实例数据,对齐补充

  • 对齐补充的作用就是,java中规定每个对象的大小必须是8整数倍,对齐补充就是用来补充的。
  • 实例数据就是类中实际定义的属性。
  • 对象头则比较特殊,其中两部分:MarkWord 和 类型指针, 类型指针指向的就是当前对象的class对象,大小为4 字节。MarkWord 在32为和 64 为的虚拟机下是不同的。32 位的虚拟机下大小位 32b,64 位下 大小为64 b。
    以下是32 位虚拟机下的markWord
    在这里插入图片描述
    32 比特下的markWord 中,在默认的情况下是有25 比特用于存储对象的哈希码,4 比特用于存储对象的分代年龄,2 比特用于存储锁标志位 1 比特固定位0.注意在其他不同的状态下时,MarkWord 中记录的信息也会改变:具体看下图:
    在这里插入图片描述
    从上到下依次是: 最后三位是
  • 正常状态 001
  • 偏向锁状态 101
  • 轻量级锁状态 000
  • 重量级锁状态 010
  • GC状态 011

这里重点注意三种不同锁状态记录线程的方式:
* 偏向锁: 使用 23b 的threadId 来记录当前对象的偏向状态,是偏向哪一个线程(不懂的可以看完后面的再来看这里)
* 轻量级锁: 使用 30B 大小的lock record 对象来记录线程信息(该对象是有每个线程的栈帧中创建)
* 重量级锁: 使用Monitor 来管理线程

2. Synchronized 原理 (重量级锁)

Thread2 进入synchronized 代码块,可以看到这里使用的obj对象作为锁。
在这里插入图片描述
本质上在执行到Synchronized (obj) 的时候,jvm会将Obj
对象和操作系统提供的Monitor对象进行关联,并使将MarkWord中前30 B 大小的数据保存起来,替换位30B 大小的一个引用,该引用执行Monitor对象,然后剩下的两位 锁标志位 设置为10 表示重量级锁。结果如下图:
在这里插入图片描述
此时的MarkWord 如下图:
在这里插入图片描述
由于此时Monitor的ower 为空,所以Thread2 可以加锁成功,就线程中的一个所记录对象Lock record 对象 指向 owner 。
在这里插入图片描述
接着又来了一个线程Thread 1
在这里插入图片描述
在进入synchronized 代码块的时候也会尝试获取锁,但是由于此时Monitor的Onwer 对象 已被占有,所以就会被加入到Monitor 的 EntryList 链表上进行等待,
在这里插入图片描述
继续由来了一个线程,过程也是类似的:
在这里插入图片描述
最后当Thread2 在离开synchronized 代码块时,操作系统就会唤醒在EntyrList 中的一个线程,注意这里唤醒是随机的,和进入链表的顺序没有关系。比如这里唤醒了Thread1:结果如下图
在这里插入图片描述
到这里Synchronized 的基础原理就介绍完了,这里还需要注意的一个点是,一个obj对象就可以关联一个Monitor,所以如果使用不同的obj对象作为锁,那么是不会相互阻塞的。如下图:
在这里插入图片描述
其次是关于 另一个等待集合WaitSet ,该集合中存储的是使用了Object.wait()方法等待的线程.wait / notify 的和synchronized 类似,只不过它使用的是WaitSet 集合来存放等待线程。

2.1 synchronized 在字节码中 的体现

在这里插入图片描述
在这里插入图片描述
一道面试题:
在使用synchronized 关键字时,什么时候会执行三条Monitor指令? 什么时候会执行两条Monitor指令?
答: 正常执行时,执行两条指令分别是monitorenter和monitorexit。 出现异常时,可能执行三条指令,monitorenter,两条monitorexit指令

3. Synchronized进阶原理

3.1 轻量级锁

轻量级锁的使用场景:就是一个对象是多个线程可以共享的,但是访问的时间却是错开的,也就是没有冲突。 我比较好奇的是既然不会冲突为什么还要加锁?本质上轻量级锁synchronized 的优化,实现,对使用者来说是透明的,语法依然是synchronized。我们知道重量级锁的加锁和释放锁机制是和操作系统提供的Monitor对象相关的,就是说每一次的线程加锁,线程释放锁,都是由操作系统底层来实现的,并不是java 内部实现,所以整个的调用过程是比较慢的。

所以JDK6 开始 jvm采取了使用轻量级锁的优化策略:
轻量级锁在加锁的时候,部需要使用到Monitor,而是和线程中的Lock record 对想关联。
分析:
在这里插入图片描述
在线程即将进入synchronized代码块时,jvm会检擦该obj对象的markword的最后两位,如果时01 表示未加锁状态,那么jvm就会在当前的栈帧中创建一个lock record 对象,用于存储Markword的拷贝。Lock Record 包含两部分:

  • lock record 00 轻量级锁状态
  • owner 指向Obj 对象

接着jvm就会尝试使用cas操作 把对象头的MarkWord 更新为执行该Lock Record 对象的指针,以及修改后两位 为00 表示轻量级锁。Lock Record 的 lock record 存储对象头的原来的信息。owner 指向 obj对象。
在这里插入图片描述
如果cas操作失败那么失败的原因有两个:

  • 1.当前obj对象的锁以及被使用
  • 2.但线程已经占有了该obj对象

如果有其他线程已经占有了对象的锁,那么就表示当前有锁的竞争,那么就需要进行锁膨胀,升级为重量级锁。如果是自己占有了当前的对象的锁,那么就再加一条锁记录对象再栈帧中
在这里插入图片描述

  • 轻量级锁线程退出时:
    如果Lock Record 对象的lock record 值为null ,则表示当前线程有锁重入,则清楚掉但钱的这个Lock Record 对象。 如果不为null ,则表示当前已经最后一个锁,jvm再次使用cas操作 减缓lock record 和 mark word 中的信息。
    • 成功 : 退出
    • 失败: 进入重量级锁的解锁流程

3.2 锁膨胀

锁膨胀发生在线程竞争锁的时候,如果当前对象锁使用的是偏向锁,如果出现竞争就会升级为轻量级锁,如果当前为轻量级锁,出现了竞争就会升级为重量级锁。

以轻量级锁升级为重量级锁为例子:
在这里插入图片描述
当thread 0 已经对该对象加了轻量级锁时,线程Thead-1 使用cas操作尝试加锁,但是会失败,所以就会进入锁膨胀。接着Obj对象就会申请一个Monitor对象,然后将自己的markword 中的前30 字节 引用指向 该monitor 对象 后 2b 设置围殴 10 表示重量级锁。注意这里的markword 中的引用已经不是指向战争中的 Lock Record 对象了 ,所以在退出synchronized 代码块时,因为 栈帧中的Lock Record 对象依然存在,那么线程首先就会尝试使用cas来 释放锁,母庸置疑肯定会失败,然后就会进入重量级锁的解锁流程。即按照markword 中指向的monitor 对象 ,设置 owner 引用为null,唤醒entyrList 中等待的线程。
代码测试:

public class Main5 {
    public static void main(String[] args) {
        Dog dog = new Dog();
        Thread t1 = new Thread(()->{
            System.out.println(ClassLayout.parseInstance(dog).toPrintable());
            System.out.println("================== 加锁前");
            synchronized (dog){
//               打印对象头信息
                System.out.println("出现冲突前");
                System.out.println(ClassLayout.parseInstance(dog).toPrintable());
                synchronized (Main5.class){
                    Main5.class.notifyAll();
                }
                try {
                    Thread.sleep(1);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        t1.start();
        Thread t2 = new Thread(()->{
            synchronized (Main5.class){
                try {
                    Main5.class.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            synchronized (dog){
//               打印对象头信息
                System.out.println("出现冲突后");
                System.out.println(ClassLayout.parseInstance(dog).toPrintable());
            }
        });
        t2.start();
    }
}

测试结果:
在这里插入图片描述

   最开始为001 表示为正常状态
   没有竞争时 为 000 表示轻量级锁状态
   最后出现竞争时为  010 表示 重量级锁

3.3 偏向锁

锁偏向是对在对象锁无竞争下的进一步优化,注解把整个的同步都消除掉,连cas都不用做。
当线程第一次获取到锁对象时,尝试把对象头的偏向模式设置为1,表示进入偏向模式,同时仅使用一次cas操作把得到该锁对象的线程的id设置到对象的markword 中。此后持有该锁对象的线程,在进入和该锁有关的同步代码块时,无需做任何的同步操作。

但是一旦当有另外一个线程尝试获取该锁对象时,那么偏向模式就会结束,将升级为轻量级锁,后续的同步操作就按照轻量级锁执行。

3.31 hashcode问题

观察下图可以知道(64 位虚拟机),原来hashcode值占用的位置被 线程id占用了,
在这里插入图片描述
那么原来的的hashcode值怎么处理? 在java中的hashcode 的值如果一经计算就不应该改变,所以如果一个对象计算了hashcode 值就需要永久的保存在markword 中,所以如果计算了hashcode值 就会导致 线程id 没有可以放置的位置,所以jvm中,如果计算了hashcode 值,就直接偏向失效。

4. 自旋优化

自选优化在jdk6 中引入,目的也是为了提高程序并发性能,由于现在计算机基本都是 多核,就可以尝试一种机制,就是如果一个线程请求锁失败,并不立即将该线程挂起等待,而是在一定的时间段内不断的尝试获取锁。
频繁的挂起和恢复工作,涉及到线程的上下文切换,开销比较大,自旋可以有效的减少上下文切换的次数。jdk6中还引入了自适应的自旋,就是每次自旋的时间和上一次自旋的结果相关,如果上一次自旋成功,那么jvm就推测本次自旋成功的几率也比较高,就会运行更长时间的自旋。

5. 锁消除

即时编译器通过逃逸分析,分析出如果数据不会逃离出被其他线程执行,那么就会直接消除当代码中的同步块。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值