Java 同步原语 synchronized 剖析和锁优化

时间:2022-12-27 18:50:36

概述

Java 在语法层面提供了 synchronized 关键字来实现多线程同步,虽然 Java 有 ReentrantLock 等高级锁,但是 synchronized 用法简单,不易出错,并且 JDK6 对其进行了诸多优化,性能也不差,故而依然值得我们去使用。

本文,我们将对 synchronized 对实现进行剖析,分析其实现原理以及 JDK6 引入了哪些锁优化对手段。

synchronized 实现

我们先看一段代码:

public class LockTest {

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

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

在这段代码中,分布使用 synchronized 对方法和语句块进行了同步,接下来我们使用 javac 编译后,再用 javap 命令查看其汇编代码:

javap -verbose LockTest.class

  public synchronized void testSync();
descriptor: ()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED
Code:
stack=2, locals=1, args_size=1
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #3 // String testSync
5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
LineNumberTable:
line 9: 0
line 10: 8
LocalVariableTable:
Start Length Slot Name Signature
0 9 0 this Lcom/qunar/fresh2017/LockTest;

public void testSync2();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=3, args_size=1
0: aload_0
1: dup
2: astore_1
3: monitorenter
4: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
7: ldc #5 // String testSync2
9: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
12: aload_1
13: monitorexit
14: goto 22
17: astore_2
18: aload_1
19: monitorexit
20: aload_2
21: athrow
22: return
Exception table:
from to target type
4 14 17 any
17 20 17 any
LineNumberTable:
line 13: 0
line 14: 4
line 15: 12
line 16: 22
LocalVariableTable:
Start Length Slot Name Signature
0 23 0 this Lcom/qunar/fresh2017/LockTest;
StackMapTable: number_of_entries = 2
frame_type = 255 /* full_frame */
offset_delta = 17
locals = [ class com/qunar/fresh2017/LockTest, class java/lang/Object ]
stack = [ class java/lang/Throwable ]
frame_type = 250 /* chop */
offset_delta = 4

这里我们省略了关键方法之外对常量池、构造函数等部分,对于 synchronized 方法,仅仅在其 class 文件的 access_flags 字段中设置了 ACC_SYNCHRONIZED 标志。对于 synchronized 语句块,分布在同步块的入口和出口插入了 monitorenter 和 monitorexit 字节码指令。

注意这里有多个 monitorexit ,除了在正常出口插入了 monitorexit,还在异常处理代码里插入了 monitorexit(请看 Exception table )。

在这篇文章 OpenJDK9 Hotspot : synchronized 浅析里,可以看到 monitorenter 的处理逻辑位于 bytecodeInterpreter.cpp 中,其加锁顺序为偏向锁 -> 轻量级锁 -> 重量级锁。最终重量级锁的代码位于 ObjectMonitor::enter 中,enter 调用了 EnterI 方法,EnterI 方法中,调用了线程拥有的 Parker 实例的 park 方法,这一点和 LockSupport 一致,毕竟都是需要底层操作系统支持的。

// in /vm/runtime/objectMonitor.cpp
void ATTR ObjectMonitor::enter(TRAPS) {
// omit a lot
for (;;) {
jt->set_suspend_equivalent();
// cleared by handle_special_suspend_equivalent_condition()
// or java_suspend_self()

EnterI (THREAD) ;
// omit a lot
}
}
void ATTR ObjectMonitor::EnterI (TRAPS) {
Thread * Self = THREAD ;
// 省略很多
for (;;) {
// omit a lot

// park self
if (_Responsible == Self || (SyncFlags & 1)) {
TEVENT (Inflated enter - park TIMED) ;
Self->_ParkEvent->park ((jlong) RecheckInterval) ;
// Increase the RecheckInterval, but clamp the value.
RecheckInterval *= 8 ;
if (RecheckInterval > 1000) RecheckInterval = 1000 ;
} else {
TEVENT (Inflated enter - park UNTIMED) ;
Self->_ParkEvent->park() ;
}
}
// omit a lot

锁的粒度

锁的粒度是一个很关键的问题,粒度的大小对于线程的并发性能有很大影响,比如数据库中表锁的并发度要比远低于行锁。下面介绍一下 synchronized 几种常见用法中,锁的粒度:
1. 对于同步方法,锁是当前实例对象。
2. 对于静态同步方法,锁是当前对象的Class对象。
3. 对于同步方法块,锁是 synchronized 括号里配置的对象。

锁优化

JVM

JDK6 中为了提升锁的性能,引入了“偏向锁”和“轻量级锁”的概念,所以在 Java 中锁一共有 4 种状态:无锁、偏向锁、轻量级锁、重量级锁。它会随着竞争情况逐渐升级,锁可以升级但不能降级,也就是说不能有重量级锁变为轻量级锁,也不能由轻量级锁变为偏向锁。下面我们介绍一下这几种锁的特点:

场景 优点 缺点
偏向锁 适用于只有一个线程访问同步块的场景 加锁和解锁不存在额外的消耗,和执行非同步方法比仅存在纳秒级的差距 如果线程间存在竞争,会带来额外的锁撤销的消耗
轻量级锁 适用于同步块执行速度非常快的场景 竞争线程不会阻塞,减少了线程切换消耗的时间 如果线程间竞争激烈,会导致过多的自旋,消耗 CPU
重量级锁 使用于同步款执行较慢,锁占用时间较长的场景 竞争激烈时,消耗 CPU 较少 线程阻塞,会引起线程切换

可以说,没有哪一种锁绝对比另一种锁好,各自都有其适合的场景。下面再简单描述一下,这些锁具体是如何实现的:
1. 偏向锁:对象头的锁标志位置为 1 表示当前是偏向锁,另有 23bit 用来记录当前线程 id。如果一个线程发现当前是无锁状态,会将锁状态改为偏向锁。如果已经是偏向锁,并且记录的线程 id 和当前线程一致,则认为是获得了锁;否则升级锁到轻量级锁。
2. 轻量级锁:其本质是自旋锁,也就是轮询去获取锁,实际中功能会高级一点儿,有自适应自旋功能,所谓自适应也就是根据竞争激烈程度适当调整自旋次数,以及决定是否升级为重量级锁。
3. 重量级锁:其底层实现通常就是 POSIX 中的 mutex 和 condition。

代码

前面讲了 JVM 中优化锁的性能的一些方法,这里再扩展一下,在实际的代码编写过程中,使用锁时,有哪些方法可以改进性能?
1. 减少锁占用时间:减少锁占用时间,能过有效的减少竞争激烈程度,减少到一定程度,就可以使用轻量级锁,代替重量级锁来提升性能。如何减少呢?
- 减少不必要的占用锁的代码。
- 降低锁的粒度。
2. 锁分离:该技术能过降低锁占用时间,或者减少竞争激烈程度。
- 将一个大锁分解多个小锁,比如从 HashTable 的对象级别锁到 ConcurrentHashMap 的分段锁的优化。
- 按照同步操作的性质来拆分,比如读写锁大大提升了读操作的性能。
3. 锁粗化:看起来与锁分离相反,但是它们适用的场景不同。在一个间隔性地需要执行同步语句的线程中,如果在不连续的同步块间频繁加锁解锁是很耗性能的,因此把加锁范围扩大,把这些不连续的同步语句进行一次性加锁解锁。虽然线程持有锁的时间增加了,但是总体来说是优化了的。
4. 锁消除:根据代码逃逸技术的分析,如果一段代码中的数据不会逃逸出当前线程,那么可以认为这段代码是线程安全的,不必加锁。

总结

在实际中,锁的各种优化技术是可以一起使用的。比如在减少锁占用时间后,就可以使用自旋锁代替重量级锁提升性能。

参考资料

  1. OpenJDK9 Hotspot : synchronized 浅析
  2. Java SE1.6 中的 Synchronized