本文主要目的是以synchronized为切入点来讨论相关的一些知识点。如下:

  • synchronized介绍
  • synchronized锁升级

1. synchronized

1.1 synchronized的作用

synchronized通过使用互斥锁来锁定共享资源,使得同一时刻,只有一个线程可以访问和修改它,其他线程必须等待,当前线程修改完毕,释放互斥锁后,其他线程才能访问。其作用总结如下:

  1. 原子性:确保线程互斥的访问同步代码;
  2. 可见性:保证共享变量的修改能够及时可见,其实是通过Java内存模型中的 “对一个变量unlock操作之前,必须要同步到主内存中;如果对一个变量进行lock操作,则将会清空工作内存中此变量的值,在其他线程使用此变量前,需要重新从主内存中load” 来保证的;
  3. 有序性:有效解决重排序问题,即 “一个unlock操作先行发生于后面对同一个锁的lock操作”。

注意:这里的有序性是由于synchronized保证了原子性和可见性,在同一时刻只有一个线程可以执行同步代码块,而指令重排序在单线程的情况下,无论如何重排执行结果都不能被改变。因此,当以其他线程的视角来看,这个操作是一个原子操作,也可以理解为有序。但实际上代码块内的代码也会指令重排序,只是指令重排序不影响结果,无需关心罢了。

不管怎么重排序(编译器和处理器为了提高并行度),单线程程序的执行结果都不能被改变。编译器和处理器无论如何优化,都必须遵守as-if-serial语义。

1.2 synchronized的使用

在应用Sychronized关键字时需要把握如下注意点:

  • 一把锁只能同时被一个线程获取,没有获得锁的线程只能等待;
  • 每个实例都对应有自己的一把锁(this),不同实例之间互不影响;例外:锁对象是.class以及synchronized修饰的是static方法的时候,所有对象公用同一把锁*
  • synchronized修饰的方法,无论方法正常执行完毕还是抛出异常,都会释放锁。

其加锁有以下几种形式:

  1. 手动对对象加锁;
  2. 修饰普通方法;
  3. 修饰静态方法

代码如下:

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
@Slf4j  
public class Demo11 {
private static Object obj = new Object();

/**
* 手动对obj对象加锁
*/
public void demo1() {
synchronized (obj){
log.info("demo1");
}
}

/**
* 对当前对象加锁,等价于 synchronized (this)
*/
public synchronized void demo2() {
log.info("demo2");
}

/**
* 对当前类对象加锁,等价于 synchronized (Demo11.class)
*/
public static synchronized void demo3() {
log.info("demo3");
}
}

1.3 synchronized的原理

分析一下代码的字节码

1
2
3
4
5
6
7
8
9
public class Demo12 {  
private static Object obj = new Object();

public static void main(String[] args) {
synchronized (obj){
System.out.println("hello world");
}
}
}

字节码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
 0 getstatic #7 <com/yang/juc/Demo12.obj : Ljava/lang/Object;>
3 dup
4 astore_1
5 monitorenter
6 getstatic #13 <java/lang/System.out : Ljava/io/PrintStream;>
9 ldc #19 <hello world>
11 invokevirtual #21 <java/io/PrintStream.println : (Ljava/lang/String;)V>
14 aload_1
15 monitorexit
16 goto 24 (+8)
19 astore_2
20 aload_1
21 monitorexit
22 aload_2
23 athrow
24 return

MonitorenterMonitorexit指令,会让对象在执行,使其锁计数器加1或者减1。每一个对象在同一时间只与一个monitor(锁)相关联,而一个monitor在同一时间只能被一个线程获得,一个对象在尝试获得与这个对象相关联的Monitor锁的所有权的时候,monitorenter指令会发生如下3中情况之一:

  • monitor计数器为0,意味着目前还没有被获得,那这个线程就会立刻获得然后把锁计数器+1,一旦+1,别的线程再想获取,就需要等待
  • 如果这个monitor已经拿到了这个锁的所有权,又重入了这把锁,那锁计数器就会累加,变成2,并且随着重入的次数,会一直累加
  • 这把锁已经被别的线程获取了,等待锁释放

monitorexit指令:释放对于monitor的所有权,释放过程很简单,就是讲monitor的计数器减1,如果减完以后,计数器不是0,则代表刚才是重入进来的,当前线程还继续持有这把锁的所有权,如果计数器变成0,则代表当前线程不再拥有该monitor的所有权,即释放锁。

此时,不难理解,synchronized可重入的原理。即在同一锁程中,每个对象拥有一个monitor计数器,当线程获取该对象锁后,monitor计数器就会加一,释放锁后就会将monitor计数器减一,线程不需要再次获取同一把锁。

可重入锁:又名递归锁,是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁对象得是同一个对象或者class),不会因为之前已经获取过还没释放而阻塞。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Slf4j  
public class Demo13 {
public static void main(String[] args) {
Demo13 demo = new Demo13();
demo.method1();
}

private synchronized void method1() {
log.info( "method1()");
method2();
}

private synchronized void method2() {
log.info( "method2()");
method3();
}

private synchronized void method3() {
log.info( "method3()");
}
}

输出:

1
2
3
[main] INFO  com.yang.juc.Demo13 - method1()
[main] INFO com.yang.juc.Demo13 - method2()
[main] INFO com.yang.juc.Demo13 - method3()

2. synchronized锁类型

在JDK1.6之前,synchronized锁被称为重量级锁,其原理是通过加锁对象的Monitor来实现的,而Monitor是依赖于操作系统的MutexLock(互斥锁)实现的。会导致用户态和内核态之间频繁地切换,严重影响到锁的性能。在JDK1.6之后,对synchronized做了一些优化,使其相对来说不再那么“重量级”。

在Java SE 1.6里Synchronied同步锁,一共有四种状态:无锁偏向锁轻量级锁重量级锁, 锁的类型会随着竞争情况逐渐升级,但是不可以降级,目的是为了提供获取锁和释放锁的效率。

锁的类型存储于对象头的MarkWord中,详细内容见[[Java的对象头结构]]一文。

接下来将按照逆序的方式来进行介绍和讨论每种锁。

2.1 重量级锁

前文已提到过,每个对象都有一个Monitor,synchronized重量级锁是通过加锁对象的Monitor来实现。Monitor被翻译为“监视器”或“管程”。其数据结构如下所示(还包含其他属性,图中只展示了讨论的重点部分):

Pasted image 20240508170445

  • 最初时,Monitor中的Owner位null;
  • 当Thread-5执行到synchronized(obj)时,Monitor会将Owner指向Thread-5,代表了Thread-5抢占到了锁。(只能有一个Owner);
  • 当Thread-5还在执行过程中,若Thread-2、Thread-4也执行了synchronized(obj),则会进入EntryList,被阻塞(Block);
  • WaitSet中的线程则为之前已经获得过了锁,但是由于某些条件不满足,进入了WAITING状态;
  • 当Thread-5的同步代码块执行完成后,释放锁,即Owner置为null,此时,EntryList中的线程则继续争抢锁。

由于重量级锁会依赖于操作系统的MutexLock(互斥锁)实现的。会导致用户态和内核态之间频繁地切换,严重影响到锁的性能。思考以下场景:
若一个对象虽然有多线程,要加锁,但是加锁的时间是错开的,并没有产生实际的争抢情况。那么如果继续使用重量级锁,进行频繁的用户态与内核态转换,显然是增加了很多无意义的时间开销。此时就可以考虑实现一种用户态的锁来优化这种场景,那就是轻量级锁

2.2 轻量级锁

轻量级锁的目的并不是替代重量级锁的,而是对在大多数情况下同步块并不会有竞争出现提出的一种优化,减少传统的重量级锁使用操作系统互斥量产生的性能消耗,从而提高并发性能。

每个线程的栈帧中都包含一个锁记录结构,内部可以存储锁定对象的MarkWord。

锁记录中存储MarkWord是为了在解锁之后恢复对象的MarkWord。

以下是轻量级锁加锁的流程:

  1. 首先,在栈帧中创建一个LockRecord对象(锁记录),如下:
    Pasted image 20240508174408

  2. 让锁记录中的Object Reference指向目标锁对象,并且尝试CAS替换Object的MarkWord,并将原MarkWord值存入锁记录对象,如下:
    Pasted image 20240508174421

  3. 若CAS成功,则说明已经由此线程Thread-1给对象加锁。如下所示:
    Pasted image 20240508174618

  4. 如果CAS失败的话,只能有两种情况:

    • 其他线程已经持有该对象的轻量级锁,那么进入锁膨胀过程;
    • 自己已经执行了synchronized,那么再在锁记录中加一条锁记录用于计数(此时可以不用新加的锁记录中重复记录MarkWord)。如下:
      Pasted image 20240508175333
  5. 当退出synchronized代码块时,即解锁时,若锁记录中的displaced_markword为null时,则表示有重入,删除此锁记录,表示重入数减一。如下所示:
    Pasted image 20240508181003

  6. 当退出synchronized代码块时,即解锁时,若锁记录中的displaced_markword不为null时,使用CAS将MarkWord值恢复给对象头。

    • 若成功,则表示成功解锁;
    • 若失败,则说明轻量级锁已经锁膨胀,升级为了重量级锁,进入重量级锁解锁流程。

此时再假设如下场景,若一个对象没有线程竞争时,每次重入依然需要进行CAS操作,那么还是会有一定的性能损耗。那么就引入了偏向锁。

2.3 偏向锁

只有第⼀次使⽤ CAS 将线程 ID 设置到对象的 Mark Word 头,之后发现这个线程 ID 是⾃⼰的就表示没有竞争,不⽤重新 CAS,也不用生成锁记录,只需要简单的测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。如果成功,表示线程已经获取到了锁。以后只要不发⽣竞争,这个对象就归该线程所有。

偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态。撤销偏向锁后恢复到无锁(标志位为“01”)或轻量级锁(标志位为“00”)的状态(见批量重偏向、批量撤销)。

批量重偏向于批量撤销

从上文偏向锁的加锁解锁过程中可以看出,当只有一个线程反复进入同步块时,偏向锁带来的性能开销基本可以忽略,但是当有其他线程尝试获得锁时,就需要等到safe point时将偏向锁撤销为无锁状态或升级为轻量级/重量级锁。safe point这个词我们在GC中经常会提到,其代表了一个状态,在该状态下所有线程都是暂停的(大概这么个意思),详细可以看这篇文章。总之,偏向锁的撤销是有一定成本的,如果说运行时的场景本身存在多线程竞争的,那偏向锁的存在不仅不能提高性能,而且会导致性能下降。因此,JVM中增加了一种批量重偏向/撤销的机制。

存在如下两种情况:(见官方论文第4小节):

  1. 一个线程创建了大量对象并执行了初始的同步操作,之后在另一个线程中将这些对象作为锁进行之后的操作。这种case下,会导致大量的偏向锁撤销操作。
  2. 存在明显多线程竞争的场景下使用偏向锁是不合适的,例如生产者/消费者队列。

批量重偏向(bulk rebias)机制是为了解决第一种场景。批量撤销(bulk revoke)则是为了解决第二种场景。

其做法是:以class为单位,为每个class维护一个偏向锁撤销计数器,每一次该class的对象发生偏向撤销操作时,该计数器+1,当这个值达到重偏向阈值(默认20)时,JVM就认为该class的偏向锁有问题,因此会进行批量重偏向。每个class对象会有一个对应的epoch字段,每个处于偏向锁状态对象的mark word中也有该字段,其初始值为创建该对象时,class中的epoch的值。每次发生批量重偏向时,就将该值+1,同时遍历JVM中所有线程的栈,找到该class所有正处于加锁状态的偏向锁,将其epoch字段改为新值。下次获得锁时,发现当前对象的epoch值和class的epoch不相等,那就算当前已经偏向了其他线程,也不会执行撤销操作,而是直接通过CAS操作将其mark word的Thread Id 改成当前线程Id。

当达到重偏向阈值后,假设该class计数器继续增长,当其达到批量撤销的阈值后(默认40),JVM就认为该class的使用场景存在多线程竞争,会标记该class为不可偏向,之后,对于该class的锁,直接走轻量级锁的逻辑。

总结如下:

  1. 批量重偏向和批量撤销是针对类的优化,和对象无关;
  2. 偏向锁重偏向一次之后不可再次重偏向;
  3. 当某个类已经触发批量撤销机制后,JVM会默认当前类产生了严重的问题,剥夺了该类的新实例对象使用偏向锁的权利

3. synchronized优化中的其他细节

自旋优化

由上文可知,当重量级锁竞争的时候,进入阻塞较耗费性能。因此可以使用自旋来进行优化以减少线程进入阻塞的可能性。但是由于自旋会占用CPU,因此若在单核CPU上运行,那么自旋并不能发挥其优势。

还有一个问题是,自旋的次数应该定为多少?在JDK6之后,自旋锁是自适应的,会预估自旋成功的可能性来确定自旋的次数。且在JDK7之后,无法控制是否开启自旋的功能。

4. 引用

  1. https://github.com/farmerjohngit/myblog/issues/12
  2. Eliminating Synchronization-Related Atomic Operations with Biased Locking and Bulk Rebiasing