深入解析 synchronized 关键字

x33g5p2x  于2022-04-10 转载在 其他  
字(5.8k)|赞(0)|评价(0)|浏览(634)

最近看了几篇 synchronized 关键字的相关文章,收获很大,想着总结一下该关键字的相关内容。

1、synchronized 的作用

  1. 原子性所谓原子性就是指一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。synchronized 修饰的类或对象的所有操作都是原子的,因为在执行操作之前必须先获得类或对象的锁,直到执行完才能释放。
  2. 可见性可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。 通过 synchronizedLock 也能够保证可见性,synchronizedLock 能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。
  3. 有序性即程序执行的顺序按照代码的先后顺序执行。 可以通过 synchronizedLock 来保证有序性,很显然,synchronizedLock 保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。

2、synchronized 的用法

  • 修饰普通方法:作用于当前对象实例,进入同步代码前要获得当前对象实例的锁。
synchronized void f() {
  //业务逻辑
}
  • 修饰静态方法:作用于当前类,进入同步代码前要获得当前类对象的锁,synchronized 关键字加到 static 静态方法和 synchronized(class) 代码块上都是是给 Class 类上锁。
synchronized void staic f() {
  //业务逻辑
}
  • 修饰代码块:指定加锁对象,对给定对象加锁,进入同步代码块前要获得给定对象的锁。
synchronized(this) {
  //业务逻辑
}

3、synchronized 的底层实现原理

当为重量锁的时候,对象头中会存在一个监视器对象,也就是 Monitor 对象。这个 Monitor 对象就是实现 synchronized 的一个关键。线程如果想要进入 synchronized 修饰的语句块的话,线程需要获得对应的 Monitor 对象。如果要退出的话,其实就是对 Monitor 对象的持有权的释放。

3.1、synchronized 修饰方法的底层实现原理

public synchronized void f(){
     System.out.println("synchronized....");
 }

synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法。JVM 通过该 ACC_SYNCHRONIZED 访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。

3.2、synchronized 修饰代码块的底层实现原理

public class Main {
	 public void f1(){
	     synchronized (Main.class){
	         System.out.println("f1 synchronized...");
	     }
	 }
}

通过 JDK 自带的 javap 命令查看 Main 类的相关字节码信息:首先切换到类的对应目录执行 javac Main.java 命令生成编译后的 .class 文件,然后执行 javap -c -s -v -l Main.class

从图中可以看出:

synchronized 同步语句块的实现使用的是 monitorentermonitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置, monitorexit 指令则指明同步代码块的结束位置。

当执行 monitorenter 指令时,线程试图获取锁也就是获取 对象监视器 monitor 的持有权,也就是获得对应的锁🔒。

  • 小结一下

synchronized 同步语句块的实现使用的是 monitorentermonitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。

synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法。

不过两者的本质都是对对象监视器 monitor 的获取。

4、synchronized 锁相关概念及优化原因

4.1、为什么优化 synchronized ?

重量级锁底层依赖于系统的同步函数来实现,在 linux 中使用 pthread_mutex_t(互斥锁)来实现。

这些底层的同步函数操作会涉及到:操作系统用户态和内核态的切换、进程的上下文切换,而这些操作都是比较耗时的,因此重量级锁操作的开销比较大。

而在很多情况下,可能获取锁时只有一个线程,或者是多个线程交替获取锁,在这种情况下,使用重量级锁就不划算了,因此引入了偏向锁和轻量级锁来降低没有并发竞争时的锁开销。

4.2、Java 对象头

在JVM中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充

Hotspot 有两种对象头:

  • 数组类型
  • 非数组类型

对象头由两部分组成

  • Mark Word:存储自身的运行时数据,例如 HashCode、GC 年龄、锁相关信息等内容。
  • Klass Pointer:类型指针指向它的类元数据的指针。

64 位虚拟机 Mark Word 是 64bit,在运行期间,Mark Word 里存储的数据会随着锁标志位的变化而变化。

5、synchronized 的锁升级过程

5.1、偏向锁

  • 偏向锁位、锁标志位的值为:1 01

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

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

适用于单线程适用锁的情况、没有锁竞争的场景,如果线程争用激烈,那么应该禁用偏向锁。

5.2、轻量级锁(自旋锁)

  • 当有另外一个线程竞争获取这个锁时,由于该锁已经是偏向锁,当发现对象头 Mark Word 中的线程 ID 不是自己的线程 ID,销偏向锁状态,将锁对象 markWord 中62位修改成指向自己线程栈中 Lock Record 的指针( CAS 抢)执行在用户态,消耗 CPU 的资源(自旋锁不适合锁定时间长的场景、等待线程特别多的场景),此时锁标志位为:00
  • 自旋锁:JVM 提供了一种自旋锁,可以通过自旋方式不断尝试获取锁,从而避免线程被挂起阻塞。这是基于大多数情况下,线程持有锁的时间都不会太长,毕竟线程被挂起阻塞可能会得不偿失。
  • 自适应自旋锁自适应自旋锁的自旋次数是会变的,我用大白话来讲一下,就是线程如果上次自旋成功了,那么这次自旋的次数会更加多,因为虚拟机认为既然上次成功了,那么这次自旋也很有可能会再次成功。反之,如果某个锁很少有自旋成功,那么以后的自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源。
  • 轻量级锁的加锁过程
  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 ,线程被挂起,后面来的线程也会直接被挂起。
    轻量级锁的释放
  • 释放锁线程视角

由轻量锁切换到重量锁,是发生在轻量锁释放锁的期间,之前在获取锁的时候它拷贝了锁对象头的 markword,在释放锁的时候如果它发现在它持有锁的期间有其他线程来尝试获取锁了,并且该线程对 markword 做了修改,两者比对发现不一致,则切换到重量锁。因为重量级锁被修改了,所有 display mark word 和原来的 markword 不一样了。

怎么补救,就是进入 mutex 前,compare 一下 objmarkword 状态。确认该 markword 是否被其他线程持有。此时如果线程已经释放了 markword ,那么通过 CAS 后就可以直接进入线程,无需进入 mutex,就这个作用。

  • 尝试获取锁线程视角

如果线程尝试获取锁的时候,轻量锁正被其他线程占有,那么它就会修改 markword ,修改重量级锁,表示该进入重量锁了。

  • 从 JDK1.7 开始,自旋锁默认启用,自旋次数由 JVM 设置决定,这里我不建议设置的重试次数过多,因为 CAS 重试操作意味着长时间地占用 CPU。自旋锁重试之后如果抢锁依然失败,同步锁就会升级至重量级锁,锁标志位改为 10。在这个状态下,未抢到锁的线程都会进入 Monitor,之后会被阻塞在 WaitSet 队列中。
  • 适用场景:适用于竞争较不激烈的情况(这和乐观锁的使用范围类似)

5.3、重量级锁

  • 此时锁标志位为:10
  • 前面我们提到的 markWord若是重量锁,对象头中还会存在一个监视器对象,也就是 Monitor 对象。这个 Monitor 对象就是实现 synchronized 的一个关键。线程如果想要进入 synchronized 修饰的语句块的话,线程需要获得对应的 Monitor 对象。如果要退出的话,其实就是对 Monitor 对象的持有权的释放。

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

  1. count:计数器。用来记录获取锁的次数。该属性主要用来实现重入锁机制。
  2. owner:记录着当前锁对象的持有者线程。
  3. WaitSet:队列。当一个线程调用了 wait 方法后,它会释放锁资源,进入 WaitSet 队列等待被唤醒。
  4. EntryList:队列。里面存放着所有申请该锁对象的线程。
  • 所以一个线程获取锁对象的流程如下
  1. 判断锁对象的锁标志位是重量级锁,于是想要获取 Monitor 对象锁。
  2. 如果 Monitor 中的 count 属性是0,说明当前锁可用,于是把 owner 属性设置为本线程,然后把 count 属性+1。这就成功地完成了锁的获取。
  3. 如果 Monitor 中的 count 属性不为0,再检查 owner 属性,如果该属性指向了本线程,说明可以重入锁,于是把 count 属性再加上1,实现锁的冲入。
  4. 如果 owner 属性指向了其他线程,那么该线程进入 EntryList 队列中等待锁资源的释放。
  5. 如果线程在持有锁的过程中调用了 wait() 方法,那么线程释放锁对象,然后进入 WaitSet 队列中等待被唤醒。
  • 适用场景:适用于竞争激烈的情况

5.4、小结

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

6、synchronized 可重入锁

  • 可重入锁:重入锁,也叫做递归锁,可重入锁指的是在一个线程中可以多次获取同一把锁,比如: 一个线程在执行一个带锁的方法,该方法中又调用了另一个需要相同锁的方法,则该线程可以直接执行调用的方法,而无需重新获得锁, 两者都是同一个线程每进入一次,锁的计数器都自增1,所以要等到锁的计数器下降为0时才能释放锁。
  1. 偏向锁:检查 markWord 中的线程 ID 是否是当前线程,如果是的话就获取锁,继续执行代码;
  2. 轻量级锁:检查 markWord 中指向 lockRecord 的指针是否是指向当前线程的 lockRecord ,是的话继续执行代码;
  3. 重量级锁:检查 owner 属性,如果该属性指向了本线程,count 属性+1,并继续执行代码

7、synchronized 为什么是非公平锁?非公平体现在哪些地方?

synchronized 的非公平其实在源码中应该有不少地方,因为设计者就没按公平锁来设计,核心有以下几个点:

1)当持有锁的线程释放锁时,该线程会执行以下两个重要操作:

  1. 先将锁的持有者 owner 属性赋值为 null
  2. 唤醒等待链表中的一个线程(假定继承者)。

在1和2之间,如果有其他线程刚好在尝试获取锁(例如自旋),则可以马上获取到锁。

2)当线程尝试获取锁失败,进入阻塞时,放入链表的顺序,和最终被唤醒的顺序是不一致的,也就是说你先进入链表,不代表你就会先被唤醒。

相关文章