synchronized解析及锁膨胀过程,面试再也不怕了

790 阅读21分钟

「这是我参与2022首次更文挑战的第3天,活动详情查看:2022首次更文挑战


  • J3 - 白起
  • 技术(零碎拾遗)

synchronized这个关键字相信大家都不陌生,在多线程并发的情况下使用它来保障共享资源的安全应该是最简单的方式。但是为了高效不应该因为其使用简单而放肆的滥用它,我们应该知其然而知其所以然,这样才能更好的高效开发并且提升自己的知识深度。

那我废话不多说了,咱们开始吧!

一、简单概述

synchronized是Java为多线程并发情况提供的一种保障共享资源安全的一个关键字,它需要作用于对象上。

1.1 synchronized特性

1.1.1 原子性

原子性是指一个操作或者一组操作,要么全部执行且执行过程不会被任何因素打断,要么就都不执行。

synchronized 对临界资源加锁时,保证了临界资源对于线程间的原子性。比如高清无码资源被线程A拿到,并且因为资源非常好所以线程 A 多次进入资源进行使用(可重入),但是此时线程 B 也想来观摩观摩,但不好意思资源已被 A 占用,即使 A 退出资源一次但还没有全部退出时,线程 B 只能甘等。

1.1.2 有序性

有序性值程序执行的顺序按照代码先后执行

Java允许编译器和处理器对指令进行重排,但是指令重排并不会影响单线程的顺序,它影响的是多线程并发执行的顺序性。synchronized保证了每个时刻都只有一个线程访问同步代码块,也就确定了线程执行同步代码块是分先后顺序的,保证了有序性。

1.1.3 可见性

可见性是指多个线程访问一个资源时,该资源的状态、值信息等对于其他线程都是可见的

synchronized对一个类或对象加锁时,一个线程如果要访问该类或对象必须先获得它的锁,而这个锁的状态对于其他任何线程都是可见的,并且在释放锁之前会将对变量的修改刷新到主存当中,保证资源变量的可见性,如果某个线程占用了该锁,其他线程就必须在锁池中等待锁的释放。

1.1.4 重入性

当一个线程试图操作一个由其他线程持有的对象锁的临界资源时,将会处于阻塞状态,但当一个线程再次请求自己持有对象锁的临界资源时,这种情况属于重入锁。通俗一点讲就是说一个线程拥有了锁仍然还可以重复申请锁。

1.2 用法

synchronized 一共有三种使用方法并且锁的对象各不相同:

1.2.1 直接修饰实例方法

在这种情况下多线程并发访问实例方法时,如果其他线程调用同一个对象的被 synchronized 修饰的方法,就会被阻塞。

相当于把记录在这个方法对应的实例对象上。

public synchronized void set(){
    i++;
}

1.2.2 直接修饰静态方法

在这种情况下进行多线程并发访问时,如果其他线程也是调用属于同一类的被 synchronized 修饰的静态方法,就会被阻塞。

相当于把信息记录在这个方法对应的上。

public synchronized static  void set(){
    i++;
}

1.2.3 直接修饰代码块

如果此时有别的线程也想访问某个被 synchronized(对象A) 修饰的同步代码块时,也会被阻塞。

同步代码块会把记录在对象A

public void set(){
    synchronized(对象A){
        i++
    }
}

synchronized的三种锁示意图:

在这里插入图片描述

二、原理实现

在理解锁实现原理之前先了解一下Java的对象头和Monitor

2.1 对象头

首先我们来看看对象的内存布局

在这里插入图片描述

内存中的对象一般由三部分组成,分别是对象头、对象实际数据和对齐填充。大家可以看看我的这篇博客详细了解👉《链接》

对象头包含 Mark Word(对象头)、Class Pointer(类型指针)和 Length(数组长度) 三部分。

  • Mark Word:记录了对象关于锁的信息,垃圾回收信息等。
  • Class Pointer:用于指向对象对应的 Class 对象(其对应的元数据对象)的内存地址。
  • Length:只适用于对象是数组时,它保存了该数组的长度信息。

我们刚才讲的锁 synchronized 锁使用的就是对象头的 Mark Word 字段中的一部分,Mark Word 中的某些字段发生变化,就可以代表锁不同的状态。

由于锁的信息是记录在对象里的,有的开发者也往往会说锁住对象这种表述。

在32位和64位操作系统上 Mark Word 的结构图

在这里插入图片描述

2.2 Monitor

Monitor是一种用来实现同步的工具,与每个 Java 对象相关联,所有的 Java 对象是天生携带 Monitor。

Monitor是实现 Synchronized(内置锁) 的基础。

对象的监视器(Monitor)由 ObjectMonitor 对象实现(C++),其跟同步相关的数据结构如下:

ObjectMonitor() { 
    _header = NULL; 
    _count = 0; 		//用来记录该对象被线程获取锁的次数
    _waiters = 0;
    _recursions = 0; 	// 线程的重入次数
    _object = NULL; 	// 存储该Monitor的对象 
    _owner = NULL; 		// 标识拥有该Monitor的线程 
    _WaitSet = NULL; 	// 处于wait状态的线程,会被加入到_WaitSet 
    _WaitSetLock = 0 ; 
    _Responsible = NULL; 
    _succ = NULL; 
    _cxq = NULL; 		// 多线程竞争锁时的单向列表 
    FreeNext = NULL; 
    _EntryList = NULL;	// 处于等待锁block状态的线程,会被加入到该列表 
    _SpinFreq = 0; 
    _SpinClock = 0; 
    OwnerIsThread = 0; 
}

每一个Java对象都可以与一个监视器Monitor关联,我们可以把它理解成为一把锁(Monitor是重量级锁),当一个线程想要执行一段被synchronized圈起来的同步方法或者代码块时,该线程得先获取到synchronized修饰的对象对应的Monitor。

我们的Java代码里不会显示地去创造这么一个Monitor对象,我们也无需创建,事实上可以这么理解:Monitor并不是随着对象创建而创建的。我们是通过synchronized修饰符告诉JVM需要为我们的某个对象创建关联的Monitor对象。

每个线程都存在两个ObjectMonitor对象列表,分别为free和used列表。同时JVM中也维护着global locklist。当线程需要ObjectMonitor对象时,首先从线程自身的free表中申请,若存在则使用,若不存在则从global list中申请。

ObjectMonitor的数据结构中包含:_ owner、 _ WaitSet 和_ EntryList,它们之间的关系转换可以用下图表示:

在这里插入图片描述

2.3 实现原理

我们通过一个列子来反编译一下代码:

public class SynchronizedDemo {

    public void synchronizedTest(){
        synchronized (this){
            System.out.println("测试同步代码块");
        }
    }
}

反编译结果

在这里插入图片描述

Monitorenter:

每个对象有一个监视器锁(Monitor)。当Monitor被占用时就会处于锁定状态,线程执行Monitorenter指令时尝试获取Monitor的所有权(获得到的 Monitor会放在对象头中),过程:

  1. 如果Monitor的进入数为0,则该线程进入Monitor,然后将进入数设置为1,该线程即为Monitor的所有者。
  2. 如果线程已经占有该Monitor,只是重新进入,则进入Monitor的进入数加1。
  3. 如果其他线程已经占用了Monitor,则该线程进入阻塞状态,直到Monitor的进入数为0,再重新尝试获取Monitor的所有权。

Monitorexit:

  1. 执行Monitorexit的线程必须是objectref所对应的Monitor的所有者。

  2. 指令执行时,Monitor的进入数减1,如果减1后进入数为0,那线程退出Monitor,不再是这个Monitor的所有者。其他被这个Monitor阻塞的线程可以尝试去获取这个 Monitor 的所有权。

细心的小伙伴注意到,这里有两个 Monitorexit 指令,通常在正常退出的时候,要用一个Monitorexit,如果中间出现异常,锁会一直无法释放。所以编译器会为同步代码块添加了一个隐式的 try-finally 异常处理,在 finally 中会调用 Monitorexit 命令最终释放锁。

再来看看另一种加锁方式

public class SynchronizedDemo {
    public synchronized void synchronizedTest() {
        System.out.println("测试同步方法");
    }
}

反编译

在这里插入图片描述

从反编译的结果来看,其常量池中多了ACC_SYNCHRONIZED标示符,JVM就是根据该标示符来实现方法的同步的:

当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先获取Monitor,获取成功之后才能执行方法体,方法执行完后再释放Monitor。

总结一下:

Synchronized的底层是通过一个Monitor的对象来完成,wait/notify等方法也依赖于Monitor对象,这就是为什么只有在同步的块或者方法中才能调用wait/notify等方法,否则会抛出java.lang.IllegalMonitorStateException的异常的原因。

三、锁膨胀升级过程

在 Java 的早期版本中,synchronized 会直接去获取 Monitor 的所有权而 Monitor 又属于重量级锁,这需要让线程从用户态转变为内核态而这个状态转换需要花费很多的处理器时间,甚至可能比用户代码执行的时间还要长。

所以由于种效率太低,在JDK1.6之后对 synchronized 做了优化。

Linux操作系统的体系架构分为:用户空间(应用程序的活动空间)和内核。

在这里插入图片描述

内核:本质上可以理解为一种软件,控制计算机的硬件资源,并提供上层应用程序运行的环境。

用户空间:上层应用程序活动的空间。应用程序的执行必须依托于内核提供的资源,包括CPU资源、存储资源、I/O资源等。

系统调用:为了使上层应用能够访问到这些资源,内核必须为上层应用提供访问的接口:即系统调用。

所有进程初始都运行于用户空间,此时即为用户运行状态(简称:用户态);但是当它调用系统调用执行某些操作时,例如 I/O调用,此时需要陷入内核中运行,我们就称进程处于内核运行态(或简称为内核态)。 系统调用的过程可以简单理解为:

  1. 用户态程序将一些数据值放在寄存器中, 或者使用参数创建一个堆栈, 以此表明需要操作系统提供的服务。
  2. 用户态程序执行系统调用。
  3. CPU切换到内核态,并跳到位于内存指定位置的指令。
  4. 系统调用处理器(system call handler)会读取程序放入内存的数据参数,并执行程序请求的服务。
  5. 系统调用完成后,操作系统会重置CPU为用户态并返回系统调用的结果。

由此可见用户态切换至内核态需要传递许多变量,同时内核还需要保护好用户态在切换时的一些寄存器值、变量等,以备内核态切换回用户态。这种切换就带来了大量的系统资源消耗,这就是在synchronized未优化之前,效率低的原因。

虛拟机对 synchronized 的优化技术:偏向锁( Biased Locking )、轻量级锁( Lightweight Locking )和如适应性自旋(Adaptive Spinning)、锁消除( Lock Elimination)、锁粗化( Lock Coarsening )等

那我们来看看 synchronized 的锁膨胀升级的过程

无锁--》偏向锁--》轻量级锁–》重量级锁

3.1 偏向锁(Biased Locking)

偏向锁时Mark Word字段结构图(32位操作系统):

在这里插入图片描述

有研究表明,其实在大部分场景都不会发生锁资源竞争,并且锁资源往往都是由一个线程获得的。如果这种情况下,同一个线程获取这个锁都需要进行一系列操作,比如说CAS自旋,那这个操作很明显是多余的。偏向锁就解决了这个问题。其核心思想就是:一个线程获取到了锁,那么锁就会进入偏向模式,当同一个线程再次请求该锁的时候,无需做任何同步,直接进行同步区域执行。这样就省去了大量有关锁申请的操作。所以,对于没有锁竞争的场合,偏向锁有很好的优化效果。

偏向锁的申请流程:

  1. 访问Mark Word中偏向锁的标识是否设置成1,锁标志位是否为01,确认为可偏向状态。
  2. 如果为可偏向状态,则判断线程ID是否指向当前线程,如果是,进入步骤5,否则进入步骤3。
  3. 如果线程ID并未指向当前线程,则通过CAS操作竞争锁。如果竞争成功,则将Mark Word中线程ID设置为当前线程ID,然后执行5;如果竞争失败,执行4。
  4. 如果CAS获取偏向锁失败,则表示有竞争。当到达全局安全点(safepoint)时获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码。
  5. 执行同步代码。

在这里插入图片描述

偏向锁的撤销在上述第四步骤中有提到。偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动去释放偏向锁。

偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态,撤销偏向锁后恢复到未锁定(标志位为“01”)或轻量级锁(标志位为“00”)的状态。

偏向锁的适用场景

始终只有一个线程在执行同步块,在它没有执行完释放锁之前,没有其它线程去执行同步块,在锁无竞争的情况下使用,一旦有了竞争就升级为轻量级锁,升级为轻量级锁的时候需要撤销偏向锁。在有锁的竞争时,偏向锁会多做很多额外操作,尤其是撤销偏向锁的时候会导致进入安全点,安全点会导致STW,导致性能下降,这种情况下应当禁用。所以一般JVM并不是一开始就开启偏向锁的,而是有一定的延迟,这也就是为什么会有无锁态的原因。

可以使用-XX:BiasedLockingStartupDelay=0来关闭偏向锁的启动延迟, 也可以使用-XX:-UseBiasedLocking=false来关闭偏向锁。👉偏向锁撤销导致的STW

通过加偏向锁的方式可以看到,对象中记录了获取到对象锁的线程ID,这就意味如果短时间同一个线程再次访问这个加锁的同步代码或方法时,该线程只需要对对象头Mark Word中去判断一下是否有偏向锁指向它的ID,有的话就继续执行逻辑了,没有的话,会CAS尝试获得锁,如果持有锁的线程在全局安全点检查时,不需要再使用该锁了则获取成功,程序继续执行,反之则获取锁失败,撤销偏向状态,升级为轻量级锁,即自旋锁。

3.2 轻量级锁(Biased Locking)即自旋锁

当有另外一个线程竞争获取这个锁时,由于该锁已经是偏向锁,当发现对象头 Mark Word 中的线程 ID 不是自己的线程 ID,销偏向锁状态,将锁对象Mark Word中30位修改成指向自己线程栈中Lock Record的指针(CAS抢)执行在用户态,消耗CPU的资源(自旋锁不适合锁定时间长的场景、等待线程特别多的场景)。

轻量级锁时Mark Word字段结构图(32位操作系统):

在这里插入图片描述

自旋策略 JVM 提供了一种自旋锁,可以通过自旋方式不断尝试获取锁,从而避免线程被挂起阻塞。这是基于大多数情况下,线程持有锁的时间都不会太长,毕竟线程被挂起阻塞可能会得不偿失。

自适应自旋锁

JDK 1.6引入了更加聪明的自旋锁,叫做自适应自旋锁。他的自旋次数是会变的,我用大白话来讲一下,就是线程如果上次自旋成功了,那么这次自旋的次数会更加多,因为虚拟机认为既然上次成功了,那么这次自旋也很有可能会再次成功。反之,如果某个锁很少有自旋成功,那么以后的自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源。 大家现在觉得没这么low了吧。

轻量级锁的加锁过程:

  1. 在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,官方称之为 Displaced Mark Word。
  2. 拷贝对象头中的Mark Word复制到锁记录中;
  3. 拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word中的62位更新为指向Lock Record的指针,并将Lock record里的owner指针指向object mark word。如果更新成功,则执行步骤4,否则执行步骤5。
  4. 如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,即表示此对象处于轻量级锁定状态。
  5. 如果这个更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。此时为了提高获取锁的效率,线程会不断地循环去获取锁,这个循环是有次数限制的, 如果在循环结束之前CAS操作成功,那么线程就获取到锁,如果循环结束依然获取不到锁,则获取锁失败,对象的Mark Word中的记录会被修改为指向互斥量(重量级锁)的指针,锁标志的状态值变为10,线程被挂起,后面来的线程也会直接被挂起。

在这里插入图片描述

轻量级锁的释放

释放锁线程视角(类比线程一):由轻量锁切换到重量锁,是发生在轻量锁释放锁的期间,之前在获取锁的时候它拷贝了锁对象头的Mark Word,在释放锁的时候如果它发现在它持有锁的期间有其他线程来尝试获取锁了,并且该线程对Mark Word做了修改,两者比对发现不一致,则切换到重量锁。因为重量级锁被修改了,所以display mark word和原来的Mark Word不一样了。 怎么补救,就是进入mutex前,compare一下obj的Mark Word状态。确认该Mark Word是否被其他线程持有。此时如果线程已经释放了Mark Word,那么通过CAS后就可以直接进入线程,无需进入mutex,就这个作用。

尝试获取锁线程视角(类比线程二):如果线程尝试获取锁的时候,轻量锁正被其他线程占有,那么它就会修改Mark Word,修改重量级锁,表示该进入重量锁了。

从 JDK1.7 开始,自旋锁默认启用,自旋次数由 JVM 设置决定,这里不建议大家设置的重试次数过多,因为 CAS 重试操作意味着长时间地占用 CPU。自旋锁重试之后如果抢锁依然失败,同步锁就会升级至重量级锁,锁标志位改为 10。在这个状态下,未抢到锁的线程都会进入 Monitor,之后会被阻塞在 _WaitSet 队列中。

3.3 重量级锁

重量级锁时Mark Word字段结构图(32位操作系统):

在这里插入图片描述

前面我们提到的Mark Word,若是重量锁,对象头中还会存在一个监视器对象,也就是Monitor对象。这个Monitor对象就是实现synchronized的一个关键。

在Java虚拟机(HotSpot)中,Monitor对象其实就是ObjectMonitor对象,这个对象是一个C++对象,定义在虚拟机源码中。

ObjectMonitor有比较多的属性,但是比较重要的属性有四个:

  • _count:计数器。用来记录获取锁的次数。该属性主要用来实现重入锁机制。
  • _owner:记录着当前锁对象的持有者线程。
  • _WaitSet:队列。当一个线程调用了wait方法后,它会释放锁资源,进入WaitSet队列等待被唤醒。
  • _EntryList:队列。里面存放着所有申请该锁对象的线程。

所以一个线程获取锁对象的流程如下:

  1. 判断锁对象的锁标志位是重量级锁,于是想要获取Monitor对象锁。
  2. 如果Monitor中的_count属性是0,说明当前锁可用,于是把 _owner 属性设置为本线程,然后把 _count 属性+1。这就成功地完成了锁的获取。
  3. 如果Monitor中的_count属性不为0,再检查 _owner 属性,如果该属性指向了本线程,说明可以重入锁,于是把 _count 属性再加上1,实现锁的冲入。
  4. 如果 _owner 属性指向了其他线程,那么该线程进入 _EntryList 队列中等待锁资源的释放。
  5. 如果线程在持有锁的过程中调用了wait()方法,那么线程释放锁对象,然后进入 _WaitSet 队列中等待被唤醒。

3.4 总结

synchronized的执行过程:

  1. 检测Mark Word里面是不是当前线程的ID,如果是,表示当前线程处于偏向锁
  2. 如果不是,则使用CAS将当前线程的ID替换Mard Word,如果成功则表示当前线程获得偏向锁,置偏向标志位1
  3. 如果失败,则说明发生竞争,撤销偏向锁,进而升级为轻量级锁。
  4. 当前线程使用CAS将对象头的Mark Word替换为锁记录指针,如果成功,当前线程获得锁
  5. 如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。
  6. 如果自旋成功则依然处于轻量级状态。
  7. 如果自旋失败,则升级为重量级锁。

上面几种锁都是JVM自己内部实现,当我们执行synchronized同步块的时候jvm会根据启用的锁和当前线程的争用情况,决定如何执行同步操作;

在所有的锁都启用的情况下线程进入临界区时会先去获取偏向锁,如果已经存在偏向锁了,则会尝试获取轻量级锁,启用自旋锁,如果自旋也没有获取到锁,则使用重量级锁,没有获取到锁的线程阻塞挂起,直到持有锁的线程执行完同步块唤醒他们;

synchronized锁升级实际上是把本来的悲观锁变成了在一定条件下使用无锁(同样线程获取相同资源的偏向锁),以及使用乐观(自旋锁 cas)和一定条件下悲观(重量级锁)的形式。

锁的优缺点对比:

优点缺点使用场景
偏向锁加锁和解锁不需要额外的消耗,和执行非同步方法仅存在纳秒级别的差距如果线程间存在锁竞争,会带来额外的锁撤销的消耗(STW)适用于只有一个线程访问同步代码块
轻量级锁竞争的线程不会阻塞,提高了程序的响应速度如果始终得不到锁竞争的线程,使用自旋会消耗CPU适用于竞争较不激烈的情况,追求响应时间,同步块执行速度非常块
重量级锁线程竞争不适用自旋,不会消耗CPU线程阻塞,想要时间缓慢,用户态转内核态消耗适用于竞争激烈的情况,追求吞吐量,同步块执行速度较长

3.5 锁膨胀升级一览图

图来自鲁班学院公开课资料中

在这里插入图片描述

参考:

《深入理解synchronized锁升级过程》

《synchronized底层原理以及锁升级过程》

《17 张图带你了解 synchronized 关键字》

《并发编程的艺术》方腾飞 魏鹏 程晓明 著

^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

CSDN:J3 - 白起

这是一个技术一般,但热衷于分享;经验尚浅,但脸皮够厚;明明年轻有颜值,但非要靠才华吃饭的程序员。

^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^