并发编程 之 再看 synchronized

使用场景
在多个线程对同一个共享资源进行读写操作时,由于代码的执行序列的不确定会导致结果不可预测;为了避免这情况的发生,java 提供了多种解决方案来达到目的;

  • 阻塞式:synchronized、reentrantLock
  • 非阻塞:基于CAS算法+自旋 的乐观锁

synchronized的使用

  • 静态方法上加锁 (被锁的的是 类对象

public synchronized static void doSomething(){ System.out.println("静态方法上锁"); } 复制代码

  • 非静态方法上加锁 (被锁的是 类实例对象

public synchronized void doSomething(){ System.out.println("非静态方法上锁"); } 复制代码

  • this对象加锁 (被锁的是 类实例对象

public void doSomething(){ synchronized (this){ System.out.println("代码块加锁"); } } 复制代码

  • 对象加锁 (被锁的是 object 对象

Object lock = new Object(); public void doSomething(){ synchronized (lock){ System.out.println("对象加锁"); } } 复制代码
synchronized 的原理
synchronized 是JVM内置锁,它通过Monitor机制实现;
方法上的 synchronized 是通过 jvm 指令 ACC_synchronized 实现的(没找到。。网上这么说来着);
代码块的 synchronized 是通过 jvm 指令 Monitorenter 和 Monitorexit 实现的;
这个可以通过 idea 的 jclasslib 插件查看 jvm 指令


Monitor机制
管程是指管理共享变量以及对共享变量操作的过程,让它们支持并发。这样说可能有点抽象,来看看一个模型吧,下面这个模型是所有管程模型中使用最为广泛的一种 MESA 模型
MESA 模型(重点理解什么是MESA 模型)

  1. 在多个线程竞争同一把锁时,MESA 规定所有线程会被添加到一个入口等待队列中,然后再依次唤醒,判断当前线程是否满足执行条件,如果不满足,线程会被添加到条件等待队列中,等着唤醒;
  2. 管程中引入了条件变量的概念,而且每个条件变量都对应有一个等待队列。条件变量和等待队列的作用是解决线程之间的同步问题。
  3. 这里可能有一个疑问:既然条件不满足为什么还要唤醒这个线程呢?
    原因:线程被唤醒 和 真正执行的时间是不一致的,线程再被唤醒的时候可能是满足条件的,但是在真正执行的时候,可能因为这样或者那样的原因导致条件不满足


MESA 模型(java 中的实现)
java 中的 Object 对象 继承与 jvm 定义的 ObjectMonitor 对象,在 hotspot 的 ObjectMonitor.hpp 中,有以下 ObjectMonitor 定义:
ObjectMonitor() { _header = NULL; _count = 0; _waiters = 0, _recursions = 0; // 重入次数_object = NULL; _owner = NULL; // 锁拥有者 _WaitSet = NULL; // 等待队列 _WaitSetLock = 0 ; _Responsible = NULL ; _succ = NULL ; _cxq = NULL ; // 多线程竞争优先进入CXQ队列(先进后出) FreeNext = NULL ; _EntryList = NULL ; // 锁竞争失败线程进入EntryList队列,如果该队列为空,则将cxq队列数据移到该队列 _SpinFreq = 0 ; _SpinClock = 0 ; OwnerIsThread = 0 ; _previous_owner_tid = 0; } 复制代码
对比 ObjectMonitor 和 MESA 模型,我们不难发现,_CXQ队列对应的MESA的入口等待队列,_WaitSet对应的MESA的条件等待队列
在获取锁时,是将当前线程插入到_cxq的头部,而释放锁时,默认策略是:如果_EntryList为空,则将_cxq中的元素按原有顺序插入到_EntryList,并唤醒第一个线程,也就是当_EntryList为空时,是后来的线程先获取锁(非公平锁)。_EntryList不为空,直接从_EntryList中唤醒线程。
锁在对象中的存储方式
既然锁是加在java的对象上的,那么Object又是如何存储锁对象的呢?
java 对象的存储分为三个部分:对象头、实例数据、对齐填充。

  • 对象头:保存对象的hash值,对象年龄,对象锁状态、数组长度(不一定有,数据对象才会有)
    • markword:8个字节,64个Byte
    • 元数据指针: 4个字节,32个Byte
    • 数组长度:4个字节,32个Byte

  • 实例数据:对象属性数据,父对象属性信息等
  • 对齐填充:jvm虚拟机,要求对象必须是8字节的整数倍,这块长度只是为了满足该要求

(一个小面试题:Object obj = new Object() 占用多少字节?答案是16 字节【markword 8 + 元数据指针 4 + 对其填充 4】)
64 位机器下的 MarkWord 结构


锁状态模拟
我们通过以下代码模拟锁竞争的场景,运行以下代码需要一个依赖
<dependency> <groupId>org.openjdk.jol</groupId> <artifactId>jol-core</artifactId> <version>0.9</version> </dependency> 复制代码
我们 new 两个线程来模拟线程竞争的场景,在 Thread1 解锁后,让 Thread1 再 sleep 一秒钟,模拟轻微锁竞争场景;
public static void main(String[] args) throws InterruptedException { Object lockBefore = new Object(); System.out.println("JVM 初始化完成前初始化的对象:"); System.out.println(ClassLayout.parseInstance(lockBefore).toPrintable()); Thread.sleep(5000); Object lock = new Object(); System.out.println("JVM 初始化完成后初始化的对象:"); System.out.println(ClassLayout.parseInstance(lock).toPrintable()); System.out.println("=============================================================================================="); new Thread(()->{ synchronized (lock){ System.out.println(Thread.currentThread().getName() + "=============> 加锁的情况下:"); System.out.println(ClassLayout.parseInstance(lock).toPrintable()); System.out.println("=============================================================================================="); } try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } },"Thread1").start(); Thread.sleep(1); new Thread(()->{ synchronized (lock){ System.out.println(Thread.currentThread().getName() + "=============> 加锁的情况下:"); System.out.println(ClassLayout.parseInstance(lock).toPrintable()); System.out.println("=============================================================================================="); } },"Thread2").start(); Thread.sleep(8000); System.out.println("全部解锁之后"); System.out.println(ClassLayout.parseInstance(lock).toPrintable()); System.out.println("=============================================================================================="); } 复制代码
运行后我们可以得到以下输出:(我们可以通过前8位 或者 最后8位,来判断锁状态,前缀输出还是后缀输出是你的系统决定的)

  1. 第一个输出的 lockBefore ,我们可以看到它的锁状态是无锁状态(001 表示无锁);而第二个输出的 lock ,却是偏向锁状态(101 表示偏向锁);jdk6之后,java 新建对象默认开启偏向锁,那为什么第一个 lockBefore 是无锁状态呢?原因是 jvm 虚拟机启动后有接近4s的延迟,这个延迟后才会开启偏向锁;

2. 我们初始化的 Lock 对象默认是偏向锁,但是他的锁拥有者并没有被赋予对应的值,所以它是处于可偏向状态,还没有偏向;可以和 下面的【Thread1=============> 加锁的情况下:】的状态进行比较;

3. 由于我们在 Thread1 synchronized代码块结束后,仍然将线程睡眠了一秒,来模拟轻微锁竞争的场景; 在 Thread2 加锁时,锁的状态已经升级为轻量级锁(00 表示轻量级锁)了;

4. 当所有线程都解锁后,锁状态变为无锁状态,而不是偏向状态;且锁拥有者的也被删除了,恢复到0000的状态

5. 最后可以将 Thread1 中的 sleep 移到synchronized代码块中,这样接口模拟重量级锁加锁过程了;
JVM 初始化完成前初始化的对象: java.lang.Object object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1) 4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0) 8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243) 12 4 (loss due to the next object alignment) Instance size: 16 bytes Space losses: 0 bytes internal + 4 bytes external = 4 bytes total JVM 初始化完成后初始化的对象: java.lang.Object object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header) 05 00 00 00 (00000101 00000000 00000000 00000000) (5) 4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0) 8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243) 12 4 (loss due to the next object alignment) Instance size: 16 bytes Space losses: 0 bytes internal + 4 bytes external = 4 bytes total ============================================================================================== Thread1=============> 加锁的情况下: java.lang.Object object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header) 05 f0 5f 47 (00000101 11110000 01011111 01000111) (1197469701) 4 4 (object header) 3a 02 00 00 (00111010 00000010 00000000 00000000) (570) 8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243) 12 4 (loss due to the next object alignment) Instance size: 16 bytes Space losses: 0 bytes internal + 4 bytes external = 4 bytes total ============================================================================================== Thread2=============> 加锁的情况下: java.lang.Object object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header) 8a c1 08 46 (10001010 11000001 00001000 01000110) (1174978954) 4 4 (object header) 3a 02 00 00 (00111010 00000010 00000000 00000000) (570) 8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243) 12 4 (loss due to the next object alignment) Instance size: 16 bytes Space losses: 0 bytes internal + 4 bytes external = 4 bytes total ============================================================================================== 全部解锁之后 java.lang.Object object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1) 4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0) 8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243) 12 4 (loss due to the next object alignment) Instance size: 16 bytes Space losses: 0 bytes internal + 4 bytes external = 4 bytes total ============================================================================================== 复制代码
由以上我们可以得出锁状态转移情况:
锁状态转移总结


锁升级过程

  • jdk1.6之后 默认开启偏向锁,但是 jvm 初始化需要时间,所以会在项目启动4秒后,生成的对象才会是偏向锁,之前的对象 是 无锁状态的;
  • 偏向锁解锁之后还是偏向锁
  • 轻量级锁 和 重量级锁 解锁后,都回到无锁状态;
  • 对象初始化时(jvm初始化 4s后),对象是偏向锁状态,但是他的所属线程为null,也就是处于可偏向状态;
  • 第一个线程对锁对象进行加锁后,锁对象的所属线程变成的该线程,锁状态还是偏向锁,状态属于偏向锁状态
  • 如果第一个线程还没有结束(锁已经释放),且第二个线程再次加锁,那么锁对象会进入轻量级锁状态;
  • 如果第一个线程还没有释放锁,那么第二个线程会进入等待状态,且第二个线程得到锁时为重量级锁;
    • 分两个过程
      • 过程一:线程二尝试获取锁(此时还是轻量级锁);
      • 过程二:没有获取到,那么判断是否时线程二自己加的锁,如果不是,那么锁膨胀,变成重量级锁;

  • 重量级锁的加锁过程:
    • 通过 cas 尝试加锁一次(说不定上一个线程已经释放锁了,这样可以减少后续的自旋,线程挂起的性能损耗)
    • 再通过自适应自旋(和 上次获取锁的自旋的次数有关,上次自选次数越少,说明越有机会得到锁,自旋次数越多,否则反之)尝试加锁,
    • 如果还是没有加锁成功,那么再进行一次 CAS 尝试加锁
    • 还是失败,再次自适应自旋等待;
    • 如果还是失败,线程被挂起,然后添加到等待队列中,等待被唤醒

synchronized 的优化
在jdk1.6前,synchronized 做为一个重量锁,频繁的切换用户态和内核态,非常的消耗性能,在jdk1.6中对synchronized 进行了优化,引入了偏向锁、轻量级锁的概念,同时提供了不同场景下的虚拟机对synchronized的优化
偏向锁批量重偏向&批量撤销

  1. 偏向锁批量重偏向:一个线程A创建了大量对象并执行了初始的同步操作,后来另一个线程B也来将这些对象作为锁对象进行操作,这样会导致大量的偏向锁撤销操作。而这个操作相对于锁重偏向来说是比较消耗性能的;【一开始由线程A创建并执行同步操作 synchronized(object),此时这些对象是偏向线程A的;当线程B对这些对象进行加锁操作时,且数量超过一定的阈值,JVM 会认为一开始对象的偏向是错误的,JVM会将剩余所有的对象的偏向状态设置为线程B】
  2. 批量撤销:一个线程A创建了大量对象并执行了初始的同步操作,后来由多个线程竞争这些对象做为加锁对象进行操作,这种情况下由于多个线程同时竞争这些对象,JVM也不知道该将这些对象怎么偏向,所以JVM干脆将这些对象的偏向进行撤销,这样可以消除偏向锁消除的消耗【偏向锁撤销:是指偏向锁恢复到无锁状态或者膨胀到轻量级锁状态】

自适应自旋
如果 synchronized 升级为重量级锁,如果不能获取到锁,那么线程会挂起,而挂起操作涉及到系统调用,那么就会从用户态切换到内核态执行,这个操作非常耗时,也是重量级锁之所以重量级的原因;
为了防止这个情况的频繁发生,jdk1.6后引入自适应自旋,synchronized 升级为重量级锁后,会反复自旋尝试获取锁,如果能在自旋过程中获取到锁,就可以避免线程阻塞,减少消耗;
自适应自旋:JVM 上一次自旋获取锁成功过,那么就会多自旋几次,如果jvm认为此次获取锁的概率不高,则会少自旋几次;
锁粗化
在一系列操作中,同一个线程对同一个对象反复加锁,JVM 会扩大加锁范围;
例子:
public static void main(String[] args) { StringBuffer buffer = new StringBuffer(); buffer.append("a").append("b").append("c"); } 复制代码
我们知道 StringBuffer 是线程安全的,因为StringBuffer的操作都是加锁后操作的,JVM检测到一连串的操作都是同一个线程对同一个对象进行加锁,那么他会扩大加锁范围,从每一个append操作,扩大到第一个到最后一个,以此来减少反复加锁的消耗;


锁消除
锁消除就是JVM在编译期间通过逃逸分析认为代码中的加锁操作完全是多余的,那么jvm会去除这些不必要的加锁操作;
最后的最后
在线蹲赞环节 老板!点赞! 复制代码

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值