剖析 synchronized 的实现原理

1 synchronized 简介

synchronized 是 Java 中的一个关键字,它是一个重量级锁,用于保证在同一个时刻,只有一个线程可以执行某个方法或者某个代码块,同时也可以保证可见性,即一个线程的变化可以被其他线程所见。

2 synchronized 的三种应用

2.1 修饰实例方法

修饰实例方法即为为当前实例加锁。当一个线程正在访问一个对象的 synchronized 实例方法时,其他线程不能访问该对象的其他 synchronized 方法,因为一个对象只有一把锁,当一个线程获取了该对象的锁之后,其他线程无法获取该对象的锁,所以无法访问该对象的其他 synchronized 实例方法,但是其他线程还是可以访问该实例对象的其他非 synchronized 方法。

2.2 修饰静态方法

修饰静态方法即为当前类对象加锁。因为静态成员不专属于任何一个实例对象,我们可以选择通过 class 对象锁来控制静态成员的并发操作。需要注意,如果线程A调用一个实例对象的非静态 synchronized 方法,而线程B需要调用这个实例对象所属类的静态 synchronized 方法,并不会发生互斥现象,因为访问静态 synchronized 方法占用的锁的是当前类的 class 对象,而访问非静态 synchronized 方法占用的锁是当前实例对象锁。

2.3 修饰代码块

修饰代码块即为指定对象加锁。这个所谓的指定对象可以包括 this 对象(代表当前实例)或者当前类的 class 对象。

3 Java 对象模型

在学习了 synchronized 的基础作用之后,我们再来看看 synchronized 的实现原理。在此之前,我先来介绍一下 Java 的对象模型。在 JVM 中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充。

3.1 实例数据

用于存放类的属性数据信息,包括父类的属性信息,如果是数组的实例部分还包括数组的长度,这部分内存按4字节对齐。

3.2 对齐填充

虚拟机要求对象起始地址必须是8字节的整数倍,仅用于字节对齐。

3.3 对象头

对象头是实现 synchronized 的锁对象的基础。一般来说,synchronized 使用的锁对象是存储在对象头里面的,jvm 中采用2个字来存储对象头(如果对象是数组则会分配3个字,多出来的1个字记录的是数组长度),其主要由 Mark Word 和 Class Metadata Address 组成。

  • Mark Word:存储对象的 hashCode、锁信息或分代年龄或 GC 标志等信息
  • Class Metadata Address:类型指针指向对象的类元数据,JVM 通过这个指针确定该对象是哪个类的实例

在 32位 JVM 中,Mark Word 的默认存储结构(无锁结构)如下:

  1. 25 bit:对象 HashCode
  2. 4 bit:对象分代年龄
  3. 1 bit:是否是偏向锁,默认为0
  4. 2 bit:锁标志位,默认为01

实际上,Mark Word 被设计成为一个非固定的数据结构,它会根据对象本身的状态复用自己的存储空间。除了 Mark Word 默认存储结构外,还有如下可能变化的结构:
在这里插入图片描述
我们这里主要分析一下重量级锁,也就是我们常说的 synchronized 的对象锁,锁标识位为10,其中指针指向的是 monitor 对象的起始地址。

注意,每个对象都存在着一个 monitor 与之关联,对象与其 monitor 之间的关系有存在多种实现方式,如 monitor 可以与对象一起创建销毁或当线程试图获取对象锁时自动生成,但当一个 monitor 被某个线程持有后,它便处于锁定状态。

在 Java 虚拟机中,monitor 是由 ObjectMonitor 实现的。ObjectMonitor 中有两个队列,分别为 _WaitSet 和 _EntryList,用来保存 ObjectWaiter 对象列表( 每个等待锁的线程都会被封装成 ObjectWaiter 对象)。

ObjectWaiter 的几个关键属性如下:

  1. _owner:指向持有 ObjectMonitor 对象的线程
  2. _WaitSet:存放处于 wait 状态的线程队列
  3. _EntryList:存放处于等待锁 block 状态的线程队列
  4. _recursions:锁的重入次数
  5. _count:用来记录该线程获取锁的次数

当多个线程同时访问一段同步代码时,首先会进入 _EntryList 集合,当线程获取到对象的 monitor 后进入 _owner 区域并把 monitor 中的 owner 变量设置为当前线程同时 monitor 中的计数器 count 加1,若线程调用 wait() 方法,将释放当前持有的 monitor,owner 变量恢复为 null,count 自减1,同时该线程进入 _WaitSet 集合中等待被唤醒。若当前线程执行完毕也将释放 monitor 并复位变量的值,以便其他线程进入获取monitor。

总结一下,monitor 对象存在于每个 Java 对象的对象头中(存储的指针的指向),synchronized 锁便是通过这种方式获取锁的。

4 synchronized 底层原理

4.1 synchronized 代码块的底层原理

在字节码层面上,同步语句块的实现使用的是 monitorenter 和 monitorexit 指令。其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。

当执行 monitorenter 指令时,当前线程将试图获取对象锁所对应的 monitor 的持有权,如果该对象锁对应的 monitor 的进入计数器为0,那线程可以成功取得 monitor,并将计数器值设置为 1,取锁成功。

如果当前线程已经拥有该对象锁的 monitor 的持有权,那它可以重入这个 monitor ,重入时计数器的值也会加1。倘若其他线程已经拥有该对象锁的 monitor 的所有权,那当前线程将被阻塞,直到正在执行线程执行完毕,即 monitorexit 指令被执行,执行线程将释放 monitor 并设置计数器值为0 ,其他线程将有机会持有 monitor 。

4.2 synchronized 方法的底层原理

方法级的同步无需通过字节码指令来控制,它实现在方法调用和返回操作之中。JVM 可以从方法常量池中的方法表结构中的 ACC_SYNCHRONIZED 访问标志区分一个方法是否同步方法。当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先持有 monitor, 然后再执行方法,最后在方法完成时释放 monitor。

在方法执行期间,执行线程持有了 monitor,其他任何线程都无法再获得同一个 monitor。如果一个同步方法执行期间抛出了异常,并且在方法内部无法处理此异常,那这个同步方法所持有的 monitor 将在异常抛到同步方法之外时自动释放。

5 synchronized 如何解决并发问题

我们先来介绍并发编程的三个基本概念:

  1. 原子性:一个操作或者多个操作要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行
  2. 可见性:当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值
  3. 有序性:程序执行的顺序按照代码的先后顺序执行

我们来分析一下 synchronized 是如何实现这三个特性的:

  1. 原子性:可以通过 monitorenter 和 monitorexit 指令,保证被 synchronized 修饰的代码在同一时间只能被一个线程访问,在锁未释放之前,无法被其他线程访问到。即使在执行过程中,由于某种原因,比如 CPU 时间片用完,线程1放弃了 CPU,但是它并没有进行解锁。而由于 synchronized 的锁是可重入的,下一个时间片还是只能被他自己获取到,还是会继续执行代码。直到所有代码执行完
  2. 可见性:对一个 synchronized 修饰的变量解锁之前,必须先把此变量同步回主存中
  3. 有序性:synchronized 修饰的代码,同一时间只能被同一线程访问。(如果在本线程内观察,所有操作都是天然有序的)注意,synchronized 是无法禁止指令重排和处理器优化的,但是同一线程内的执行遵守 as-if-serial 语义。

6 wait/notify

在经过我们上面的学习之后,我们最后来分析一下 wait/notify 的原理。

6.1 wait 和 notify 的原理

调用 wait 方法,首先会获取监视器锁,获得成功以后,会让当前线程进入等待状态进入等待队列并且释放锁。

当其他线程调用 notify 后,会选择从等待队列中唤醒任意一个线程,而执行完 notify 方法以后,并不会立马唤醒线程,原因是当前的线程仍然持有这把锁,处于等待状态的线程无法获得锁。必须要等到当前的线程执行完按 monitorexit 指令以后,也就是锁被释放以后,处于等待队列中的线程就可以开始竞争锁了。

6.2 wait 和 notify 为什么需要在 synchronized 里面?

wait 方法的语义有两个,一个是释放当前的对象锁、另一个是使得当前线程进入阻塞队列,而这些操作都和监视器是相关的,所以 wait 必须要获得一个监视器锁。

而对于 notify 来说也是一样,它是唤醒一个线程,既然要去唤醒,首先得知道它在哪里,所以就必须要找到这个对象获取到这个对象的锁,然后到这个对象的等待队列中去唤醒一个线程。

参考:深入理解Java并发之synchronized实现原理
synchronized实现原理
synchronized原理

发布了113 篇原创文章 · 获赞 206 · 访问量 1万+

猜你喜欢

转载自blog.csdn.net/Geffin/article/details/103504621