Synchronized 关键字

Synchronized 是 Java 中的一种锁的方式,是在 JVM 层面一种锁。在 jdk 1.6以前是一种重量级锁,在经历过优化后 Synchronized 锁已经没有那么“重”了。

Synchronized 有 3 种使用方式:

  1. 普通同步方法,锁是当前实例对象
  2. 静态同步方法,锁是当前类的Class对象
  3. 同步代码块,锁是Synchonized括号里配置的对象
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
private static int i;
//1. 修饰同步方法
public synchronized void add() {
i++;
}
//2. 修饰同步静态方法
public static synchronized void addStatic() {
i++;
}
//3. 修饰同步代码块,此处锁的方式和 1 加锁方式相同
public void addCodePieceInstance() {
synchronized (this) {
i++;
}
}
//4. 修饰同步代码块,此处锁的方式和 2 加锁方式相同
public void addCodePiece() {
synchronized (SynDemo.class) {
i++;
}
}

//5. 锁定同步代码块
public void addSyncCode(){
synchronized (obj) {

}
}

当一个线程试图访问同步代码块时,首先必须获得锁,那么这个锁的位置到底在哪呢?锁到底包含了那些信息?

要想弄清楚上面两个问题,需要引入一个对象头的概念。

1. java 对象头

首先看下Java 的对象结构,如图 1:

图 1

Java 对象分为 3 个部分,对象头实例数据对齐空间。我们重点关注的对象头,其他的两项对于我们 synchronized 没有影响。

synchronized 用的锁是存在 Java 对象头里的。如果对象是数组类型,则虚拟机用 3 个字宽(Word)存储对象头,如果对象是非数组类型,则用2字宽存储对象头。在32位虚拟机中,1字宽等于 4 字节,即32bit。

虚拟机位数 头对象结构 说明
32/64bit Mark Word 存储对象的hashCode、锁信息或分代年龄或GC标志等信息
32/64bit Class Metadata Address 类型指针指向对象的类元数据,JVM通过这个指针确定该对象是哪个类的实例。
32/32bit ArrayLength 这个标记一般没有,除非锁定的对象是数组,这个表示是数组的长度

其中 Mark Word 在默认情况下存储着对象的 HashCode、分代年龄、锁标记位等以下是32位 JVM 的 Mark Word 默认存储结构

锁状态 25bit 4bit 1bit是否是偏向锁 2bit 锁标志位
无锁状态 对象HashCode 对象分代年龄 0 01

在运行期间,Mark Word 里存储的数据会随着锁标志位的变化而变化。Mark Word 可能变化为存储以下 4 种数据,如图2 :

图2

2. 锁膨胀

上面讲到锁有四种状态,并且会因实际情况进行膨胀升级,其膨胀方向是:

无锁可偏向——>偏向锁——>轻量级锁——>重量级锁

无锁可偏向——>无锁不可偏向——>轻量级锁——>重量级锁

并且膨胀方向不可逆(某些苛刻的条件是可逆的)

2.1 偏向锁

如果想升级偏向锁,需要有两个条件:

  1. 打开偏向锁开关(-XX:BiasedLockingStartupDelay=0 ),1.8 版本是默认 JVM 启动 40s 后开启
  2. 不能运行 hashcode 方法,因为偏向锁需要 java 对象头存储线程的 ID ,如果计算了 hashcode 对象头的空间会被 hashcode 占用。

一句话总结它的作用:减少统一线程获取锁的代价。在大多数情况下,锁不存在多线程竞争,总是由同一线程多次获得,那么此时就是偏向锁。

核心思想:

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

2.2 轻量级锁

轻量级锁是由偏向锁升级而来,当存在第二个线程申请同一个锁对象时,偏向锁就会立即升级为轻量级锁。注意这里的第二个线程只是申请锁,不存在两个线程同时竞争锁,可以是一前一后地交替执行同步块。

实现逻辑

轻量级锁的实现是在每一个线程中产生一个 lock record,在竞争锁的时候,采用 CAS 的方式把 Mark Word 的指针指向当前 Lock Record 的地址。

    轻量级锁 01
轻量级锁 02

2.3 重量级锁

重量级锁是由轻量级锁升级而来,当同一时间有多个线程竞争锁时,锁就会被升级成重量级锁,此时其申请锁带来的开销也就变大。

重量级锁一般使用场景会在追求吞吐量,同步块或者同步方法执行时间较长的场景。

实现逻辑:

从上面的分析中,我们可以得知,Synchronized 关键字的锁存在 Java 对象头中,通过判断对象头的标记位来判断当前锁的状态,如果线程拿不到锁会一直等待,这个是怎么实现的呢?

在查询 JVM 规范可以得到 Synchronized 执行对应了两个 JVM 指令,MonitorEnter 和 MonitorExit 两个指令。同步方法是由方法调用指令读取运行时常量池中方法的 ACC_SYNCHRONIZED 标志来隐式实现的

首先我们先了解下 monitor (管程) 这个对象,在 Java 中所有对象都有一个 monitor 与之关联,对象与其 monitor 之间的关系有存在多种实现方式,如monitor可以与对象一起创建销毁或当线程试图获取对象锁时自动生成,但当一个 monitor 被某个线程持有后,它便处于锁定状态。

在Java虚拟机 (HotSpot) 中,monitor 是由 ObjectMonitor 实现的,其主要数据结构如下(位于 HotSpot 虚拟机源码 ObjectMonitor.hpp 文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
ObjectMonitor() {
_header = NULL;
_count = 0; //记录个数
_waiters = 0,
_recursions = 0;
_object = NULL;
_owner = NULL;
_WaitSet = NULL; //处于wait状态的线程,会被加入到_WaitSet
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
_EntryList = NULL ; //处于等待锁block状态的线程,会被加入到该列表
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}

ObjectMonitor中有两个队列,_WaitSet_EntryList,用来保存 ObjectWaiter 对象列表( 每个等待锁的线程都会被封装成ObjectWaiter对象),_owner 指向持有 ObjectMonitor 对象的线程,

  1. 当多个线程同时访问一段同步代码时,首先会进入_EntryList 集合

  2. 当线程获取到对象的 monitor 后进入_Owner区域并把 monitor 中的 owner 变量设置为当前线程,同时monitor 中的计数器 count 加 1

  3. 若线程调用 wait() 方法,将释放当前持有的 monitor,owner 变量恢复为 null,count 自减 1,同时该线程进入 WaitSet 集合中等待被唤醒。

  4. 若当前线程执行完毕也将释放 monitor(锁) 并复位变量的值,以便其他线程进入获取 monitor(锁) 。

    img

2.4 总结下锁升级的过程

image-20210303231256957

3. 其他特性

3.1 锁消除

消除锁是虚拟机另外一种锁的优化,这种优化更彻底,在JIT编译时,对运行上下文进行扫描,去除不可能存在竞争的锁。比如下面代码的method1和method2的执行效率是一样的,因为object锁是私有变量,不存在所得竞争关系。

1
2
3
4
5
6
7
private void syncMethod1() {
Object obj = new Object();
//无效的锁,会自动消除
synchronized (obj) {
//....
}
}

3.2 锁粗化

锁粗化是虚拟机对另一种极端情况的优化处理,通过扩大锁的范围,避免反复加锁和释放锁。比如下面method3经过锁粗化优化之后就和method4执行效率一样了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
  private void syncMethod2() {
for (int i = 0; i < 100; i++) {
synchronized (Object.class) {
//....
}
}

//锁粗化会自动改成这种方式,提高性能
synchronized (Object.class) {
for (int i = 0; i < 100; i++) {
//....
}
}
}

3.3 适应性自旋(Adaptive Spinning)

从轻量级锁获取的流程中我们知道当线程在获取轻量级锁的过程中执行CAS操作失败时,是要通过自旋来获取重量级锁的。问题在于,自旋是需要消耗CPU的,如果一直获取不到锁的话,那该线程就一直处在自旋状态,白白浪费CPU资源。解决这个问题最简单的办法就是指定自旋的次数,例如让其循环10次,如果还没获取到锁就进入阻塞状态。但是JDK采用了更聪明的方式——适应性自旋,简单来说就是线程如果自旋成功了,则下次自旋的次数会更多,如果自旋失败了,则自旋的次数就会减少。