飞道的博客

synchronized底层原理

420人阅读  评论(0)

概述

synchronized能够实现线程同步。无论怎么使用,最终都是对对象加锁。

  • 锁class对象、静态方法 都是锁类对象
  • 锁普通对象、普通方法,都是锁实例对象

为什么synchronized最终都是作用在对象上呢? 因为对象在堆中除了除了有字段属性外,还有固定的对象头,通过对象头最终可以得知这个对象是否被加过锁,以及持有锁的线程是谁。(第2节 对象结构)

仔细研究对象头后,发现其中记录了多种锁的状态。锁的升级与其息息相关。(第3节 锁优化策略)

许多线程长时间阻塞,说明锁已经升级到了重量级锁,JVM使用Monitor处理阻塞线程,如wait、notify等。(第1节 Monitor阻塞机制)

AQS也是参照jvm底层Monitor处理方式实现的,但是性能上更优一些。(第4节 与AQS的对比)

一、理解

synchronized是非公平、可重入、独占锁

wait和notify使用时线程必须持有锁

1. synchronized对MESA管程模型的实现


底层逻辑:

  • cxq、EntryList都是先进后出队列FILO
  • 争抢锁失败的线程会进入cxq
  • 获取锁的线程调用wait后进入waitSet
  • waitSet中被notify唤醒的线程会进入cxq
  • 持有锁的线程释放锁后
    • 唤醒EntryList最后入队的线程
    • 如果EntryList没有节点,则会将cxq的节点移动过来,再唤醒最后入队的线程

下面是代码证明。

import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;

/**
 * <p> Description:Application</p>
 * <p> CreationTime: 2022/1/20 10:39
 *
 * @author dreambyday
 * @since 1.0
 */
public class Application {
   
    public synchronized static void show(int seconds) {
   
        try {
   
            TimeUnit.MILLISECONDS.sleep(seconds);
        } catch (InterruptedException e) {
   
            e.printStackTrace();
        }
        System.out.println(Thread.currentThread().getName());
    }

    public static void testSync() throws InterruptedException {
   
        // 0->200ms 1获取锁 234blocked  -> 234在cxq
        new Thread(()->Application.show(200),"1").start();
        // 保证1线程先获取锁
        TimeUnit.MILLISECONDS.sleep(10);
        // 200->300ms 1释放锁 234从cxq进入EntryList,先进后出,4获取锁,23继续blocked
        new Thread(()->Application.show(400),"2").start();
        new Thread(()->Application.show(400),"3").start();
        new Thread(()->Application.show(400),"4").start();
        // 300->(200+400)ms
        // 4继续获取锁,23blocked,处于EntryList。
        // 567线程启动,获取锁失败,进入cxq
        TimeUnit.MILLISECONDS.sleep(300);
        new Thread(()->Application.show(400),"5").start();
        new Thread(()->Application.show(400),"6").start();
        new Thread(()->Application.show(400),"7").start();
        // EntryList先被唤醒,EntryList空了后,将cxq整体移动到EntryList继续唤醒
        // 200->(200+400*3)ms 处于EntryList的线程依次被唤醒,顺序为432
        // (200+400*3)ms 时,EntryList为空,cxq移动到EntryList,内容为567
        // (200+400*3)ms到结束,处于EntryList的线程依次被唤醒,顺序为765
    }
    public static void main(String[] args) throws InterruptedException {
   
    	// 最终顺序为1432765
        testSync();
    }
}



 

2. 为什么用cxq和EntryList两个队列存放线程

为了降低cxq尾部并发竞争

假如只有cxq先进后出队列,队列尾部面临的操作有

  • 增加新的竞争锁失败的线程
  • 尾部线程被唤醒
  • 增加从waitSet中被notify的线程

拆分两个队列后,唤醒操作发生在EntryList上

二、对象结构

new Object()在内存中的字节数为16 = 8(Mark Word)+4(开启压缩指针的klass pointer) + 0(不是数组,不占字节) + 0(对象体,无属性,不占字节) + 4(对齐填充,保证整体是8的倍数)

1. Mark Word

占1个字宽大小(32位机则为32位长,64位机为64位长)。是实现轻量级锁和偏向锁的关键。

包括: hashcode、分代年龄、是否偏向、锁的标志、GC标记、指向monitor的指针、指向持有锁线程的lockRecord的指针、偏向锁线程ID、epoch(第几代偏向锁)

Mark Word存在5种状态。如下图。

图片来源

轻量级锁的MarkWord指向栈中lockRecord的指针

虚拟机栈保存lockRecord: 记录了持有锁的线程、无锁状态下的Mark Word信息(为了备份还原,以及记录hashcode等信息)

锁重入情况: 栈新压入Mark Word记录为null的lockRecord,释放一次锁就从栈弹出一次lockRecord

重量级锁的MarkWord指向堆中Monitor的指针


monitor对象和java对象同生共死,存储在堆中。重量级锁状态下,monitor同样保存了无锁状态的MarkWord信息。

2. Klass Pointer

  • 32位机则为32位长,64位机默认指针压缩开启,也占32位。不开启则64位。
  • 指向元空间(jdk8)或方法区(jdk7)中的类的元数据,用于标识当前实例对象属于哪个类

为什么指向元数据而不是类对象,我只能靠一个例子理解:如果锁的对象是类对象,那么klass pointer相当于自己指向自己,会令人奇怪吧

3. 数组长度(可选)

32位机和64位机都是32位长,因此数组长度理论上不能超过 2 32 2^{32} 232 。实际上,根据JVM对对象头的处理,目前数组的最大长度是 2 31 − 1 2^{31}-1 2311 ,即Integer最大值

三、锁优化策略

锁级别: 无锁->偏向锁->轻量级锁->重量级锁

锁只能升级不能降级。

1. 偏向锁

偏向锁是加锁操作的优化手段。为了消除数据无竞争下的锁重入开销引入偏向锁。在无锁竞争场合,偏向锁性能较好。

偏向锁使用了一种等到竞争出现才释放锁的机制,消除偏向锁的开销比较大

自JDK6,JVM默认启动偏向锁模式,但是会在虚拟机启动后延迟4s左右才会开启

在启动偏向锁后创建的对象,对象头的Mark Word的锁状态默认变成偏向锁状态,并且Thread ID为0 。此时处于 可偏向但未偏向任何线程 ,也叫匿名偏向状态

为什么要有延迟偏向: 虚拟机启动过程中,许多后台线程可能会争抢锁,导致对象头的锁状态从偏向锁撤销,再升级到 轻量级锁或重量级锁。

  • 无锁时在MarkWord存储hashcode。
  • 偏向锁无位置存储hashcode。
  • 轻量级锁在栈的锁记录中记录hashcode
  • 重量级锁在Monitor中记录hashcode

对象可偏向或已偏向时,调用hashcode会使对象无法偏向。

  • 可偏向时,调hashcode,偏向锁撤销,并只能升级为轻量级锁
  • 已偏向时,调hashcode,偏向锁撤销并升级为重量级锁

几种情况:

  • 创建对象默认无锁->synchronized加锁->无锁变轻量级锁
  • 创建对象默认偏向锁->调用hashcode->撤销偏向锁变为无锁->synchronized加锁->无锁变轻量级锁
  • 创建对象默认偏向锁->synchronized加锁->调用hashcode->偏向锁撤销变重量级锁

偏向锁状态,执行

  • notify会升级为轻量级锁
  • wait会升级为重量级锁

2. 轻量级锁

轻量级锁适用于锁竞争不激烈的场景。 如两个线程交替运行

多线程竞争同一把锁(偏向锁或轻量级锁)时,竞争失败的线程若在短暂的自旋后获取到锁,则不会导致锁膨胀为重量级锁。

3. 重量级锁

重量级锁适用于锁竞争激烈或同步块执行时间长的场景

重量级锁基于Monitor实现,用户态转内核态耗时较长。

4. 锁升级过程


上图为锁竞争对于锁状态变化的影响。
参考

5. 几种锁状态的总结

优点 缺点 适用场景
偏向锁 加解锁无额外消耗,单线程下加锁基本无消耗 线程竞争产生额外的锁撤销成本 单线程访问同步代码块
轻量级锁 竞争线程不阻塞,基于cas自旋在用户态实现 长时间锁自旋获取不到锁还是会锁膨胀,并且消耗CPU 同步块执行速度快、锁竞争不激烈
重量级锁 锁不自旋,不消耗cpu 线程阻塞,用户态转内核态效率低 追求吞吐量、竞争激烈、同步代码块执行时间长

6. 其他的锁优化

自旋锁和适应性自旋锁

  • 用于避免轻量级锁直接升级为重量级锁,用户态转内核态带来的损耗。 轻量级锁发生竞争时会先进行一段时间自旋, 超过一定自旋次数后才会升级
  • 适应性自旋锁是为了动态调节锁升级自旋次数的阈值。 虚拟机认为上次自旋成功了,这次也可能成功,允许的自旋次数会增加。反之亦然。

锁消除

锁消除的依据是逃逸分析的数据支持。如果JVM检测同步内容不会逃逸,则会直接消除加锁操作

锁粗化

连续的加锁、解锁操作合并为一个加锁解锁操作可能提升性能,因此有了锁粗化的概念。

JVM检测到对同一个对象连续的加解锁,并且可以合并时,会将加解锁操作移动到循环之外。

for x : xx
	synchronized(obj) {
   }

四、与AQS体系锁的对比

1. LockSupport.park和synchronized的重量级锁一样吗

结论: 是。LockSupport也是基于mutex实现的,有用户态和内核态的切换

ReentrantLock最终会使用LockSupport.park阻塞线程,因此最终和synchronized的重量级锁一样

2. 为什么ReentrantLock比synchronized性能高

参考

ReentrantLock基于AQS实现。AQS在Java代码层面管理阻塞队列,synchronized由jvm层面管理阻塞队列。AQS使用CAS较多,阻塞队列操作逻辑比jvm实现的好,因此性能高一些。

性能比较1
性能比较2

3. 和ReentrantLock的对比

区别 synchronized ReentrantLock
修饰位置不同 静态方法、普通方法、代码块 代码块
自动与非自动释放锁 自动释放锁 手动释放锁
锁类型不同 非公平锁 默认非公平锁,也可以创建公平锁
响应中断不同 不可以响应中断 能够响应中断
底层实现不同 基于JVM的Monitor 基于AQS
阻塞后线程状态不同 阻塞进入BLOCKED状态 阻塞进入WAITING状态
同步队列实现方式不同 类似栈,有两个,先进后出 队列且只有一个,先进先出

参考


转载:https://blog.csdn.net/dreambyday/article/details/128767442
查看评论
* 以上用户言论只代表其个人观点,不代表本网站的观点或立场