深入理解 synchronized

synchronized 作为使用得最多的多线程同步方案,可以说适用于绝大多数需要保证线程安全的场景。

但是关于 synchronized 是如何实现线程安全,背后使用了什么锁机制呢?以及使用synchronized 有什么别的注意事项?


基本使用

synchronized :这是用得最多的同步方式。使用 synchronized 的方式有两种。

  1. 使用同步代码块的方式,这时候需要手动传入一个锁对象:

1
2
3
synchronized (obj) {
// code ...
}

  1. 直接将 synchronized 加在方法上,这时候其实和使用同步代码块的方式是一样的。只是由 JVM 在编译阶段帮我们将其转化成同步代码块的形式,那么问题来了,这时候我们并没有指定锁对象,JVM 会使用哪个对象来作为同步代码块中的锁对象呢?
  • 如果是实例方法,使用的是 this 当做锁对象:

1
2
3
4
5
class Counter {
public synchronized void syncMethod() {
// code ...
}
}

  • 如果是 static 方法,使用的是 class 对象当做锁对象:

1
2
3
4
5
class Counter {
static public void syncStaticMethod() {
// code ...
}
}

关于锁对象还需要注意:不要使用字符串和基本数据类型作为锁对象

因为字符串是不可变对象,你能用的这个字符串作为锁对象,别人也能使用。想象一下如果你使用 “object” 这个字符串作为锁对象,然后在某个 framework 里也有段 synchronized 代码也使用了这个字符串作为锁对象,并且有个常驻线程循环执行这段 synchronized 代码,那么我们自己写的代码将永远获取不到锁对象,一直处于阻塞状态。

而基本数据类型,由于存在缓存机制,某些情况下也是属于不可变对象,也不适合作为锁对象使用。

最佳的实践是专门新建一个对象作为锁对象,并且为了保证锁变量不会被重新指向,使用 final 修饰。同时保证锁的最低可见性(禁止使用 public 修饰,最好该锁只对使用到锁的文件可见)。


synchronized 的实现原理

编写一个最简单的同步代码块:

1
2
3
4
5
6
7
public class Main {
void n() {
synchronized (this) { }
}

public static void main(String[] args) { }
}

运行 main 方法,确保 Main 类编译完成。查看编译得到的 Bytecode:

1
2
3
4
5
6
7
8
9
10
11
12
13
 0 aload_0
1 dup
2 astore_1
3 monitorenter
4 aload_1
5 monitorexit
6 goto 14 (+8)
9 astore_2
10 aload_1
11 monitorexit
12 aload_2
13 athrow
14 return

synchronized 相关的有 monitorentermonitorexit

奇怪的是一个 synchronized 居然由一个 monitorenter 和两个 monitorexit 与之对应。这是 synchronized 为了确保能够成功释放锁而有意为之,因为在执行 synchronized 代码的时候,可以会抛出异常, 第二个 monitorexit 正是为了执行 synchronized 代码时哪怕抛出异常仍能释放锁资源而设计的。


synchronized 的锁升级过程

再来谈谈 synchronized 的加锁原理,synchronized 其实是一个锁升级的过程。

Mark Word 详解

在深入锁升级过程之前,首先要知道锁对象有以下几种状态,这几种状态可以通过锁对象的对象头中的 Mark Word 的后三位确定(Mark Word 共 8 字节,32 位):

  • 普通对象:Mark Word 的后两位为 01,第三位代表是否偏向,此时为 0。即普通对象状态下的锁对象 Mark Word 后三位为 010;之后的 4 位记录的是对象分代年龄;再之后的 25 位用于记录 identityHashCode ,只有调用了 System.identityHashCode(o) 或者未被重写的 hashCode() 才会被缓存起来,如果自身重写了 hashCode() 方法,计算结果不会被缓存到 Mark Word 中(重写 hashCode() 方法通常是使用实例变量的值计算哈希值,确保两个不同的的对象在逻辑上是相同的,也就是对象的哈希值随时会随着实例变量的值变化,这时候 Mark Word 也没有将哈希值缓存起来的必要)
  • 偏向锁:Mark Word 的后两位为 01,第三位代表是否偏向,此时为 1。即偏向锁状态时锁对象 Mark Word 的后三位为 101;之后的 4 位记录的是对象分代年龄;之后 25 位中有 23 位用于记录当前偏向的线程 id ,有 2 位是记录线程重入次数
  • 轻量级锁:Mark Word 的后两位为 00;之后的空间用于记录栈中锁记录指针。轻量级锁又称为“自旋锁”,底层是使用 CAS 锁(lock compareExchange 指令),当有大量的线程试图获取轻量级锁的时候,会很占用 CPU 资源
  • 重量级锁:Mark Word 的后两位为 10;之后的空间用于记录“重量级互斥锁”内容(ObjectMonitor)。所谓的重量级是指要向内核态申请执行指令,而之前的几种锁状态都是在用户态完成;重量级锁的底层是使用等待队列装载正在阻塞的线程,当锁释放后由操作系统从等待对象中取出线程进行调度
  • 待 GC 回收:Mark Word 的后两位为 11

值得说明的是,由于分代年龄使用 4 位进行记录,最大值为 1111 ,转换为十进制为 15 ,也就是一个对象的最大年龄就是 15 ,分代年龄达到 15 之后会从 Young 区进入 Old 区。


identityHashCode 引发的锁膨胀

以及当一个对象处于“普通对象”状态并计算过 identityHashCode ,记录进了 Mark Word,那么该对象将无法进入“偏向锁”状态;如果对象本身处于“偏向锁”状态,但需要计算 identityHashCode,这时候“偏向锁”状态会被撤销,膨胀为“重量级锁”状态,并在“重量级锁”状态使用到的 ObjectMonitor 类中的字段记录非加锁状态下的 Mark Word,该 Mark Word 中就能记录下 identityHashCode 。


默认启动的偏向锁

最后, JVM 默认是启动偏向锁的。意思是当一个对象被创建,默认应该是偏向锁状态,但是这个偏向锁的启动有延时,默认是 4 秒。换句话,在 JVM 启动的前 4 秒所创建的对象都是普通状态,4 秒后创建的对象都是偏向锁状态。

可以通过命令 -XX:+UseBiasedLocking 启动或关闭偏向锁(JDK 1.8)和 -XX:BiasedLockingStartupDelay (JDK 1.8)设置偏向锁的延时时长。

为什么 JVM 要默认延时 4 秒开启偏向锁。这是因为在 JVM 刚启动的时候,会执行很多 synchronized 方法

  1. 当第一个线程访问 synchronize 同步代码的时候,会先去查看同步代码锁对象的对象头中的 Mark Word ,是否已经有写入的线程信息(判断是否有线程正在执行该同步代码)

    • 如果锁对象的 Mark Word 中没有线程相关信息,代表当前没有线程正在执行同步代码,将自身的线程信息写入锁对象的 Mark Word 中

    • 如果锁对象的 Mark Word 中已经有线程相关信息,代表有线程正在执行同步代码,这时候当前线程会升级为”自旋锁“

  1. 当锁升级为”自旋锁“之后,没有获取到锁的线程会循环重新获取锁对象的动作十次
  1. 如果循环重新获取锁对象的动作十次以后还没有获取到锁对象,将会升级为”OS 锁“

也就是整个锁升级过程为:偏向锁 -> 自旋锁 -> OS 锁 ,随着无锁到轻量级锁再到重量级锁的升级,synchronized 的性能也开始下降。而且在 HotSpot 的实现中 synchronized 不存在锁降级操作。

由于获得锁的线程的信息会被写入到锁对象的 Mark Word 中,所以 synchronized 是可重入的,最简单的场景就是一个子类的 synchronized 方法里是可以调用父类的 synchronized 方法,而不会引发死锁。


synchronized 和 volatile 的对比

synchronized 的作用主要是为了加锁,实现线程安全。而 volatile 的作用是保证线程可见性和禁止指令重排序。

synchronized 的实现细节

  1. 字节码层面

    在字节码层面上, synchronized 实现同步代码块,会添加一个 monitorenter 和 两个 monitorexit

  1. JVM 层面

    使用了 C/C++ 的同步机制

  1. OS 层面

    在不同的 OS 和 CPU 硬件上有不同的实现,例如使用 Lock comxchg 。


volatile 的实现细节

  1. 字节码层面

    在字节码层面上,使用 volatile 修饰的变量,会在 class 文件中在 Access flags 添加了 [volatile]。

  1. JVM 层面

    JVM 规定被 volatile 修饰的变量的读写前后要添加内存屏障:

    • volatile 读操作前添加 LoadLoadBarrier,确保在之前的读操作和当前读操作不能重排序;在 volatile 读操作后添加 LoadStoreBarrier ,确保当前的读操作和之后的写操作不能重排序。
    • volatile 写操作前添加 StoreStoreBarrier,确保之前的写操作和当前的写操作不能重排序;在 volatile 写操作之后添加 StoreLoadBarrier ,确保当前写操作和之后的读操作不能重排序。
  1. OS 层面

    内存屏障在 OS 层面上是通过 LOCK 前缀指令实现的,但在不同的 OS 和 CPU 硬件上有不同的实现:

    • 在 Pentium 和早期的 IA-32 处理器中,LOCK 前缀会使处理器执行当前指令时产生一个 LOCK# 信号,这种总是引起显式总线锁定出现;
    • 在 Pentium4、Inter Xeon 和 P6 系列处理器中,加锁操作是由高速缓存锁或总线锁来处理。如果内存访问有高速缓存且只影响一个单独的高速缓存行,那么操作中就会调用高速缓存锁,而系统总线和系统内存中的实际区域内不会被锁定。同时,这条总线上的其它 Pentium4、Intel Xeon 或者 P6 系列处理器就回写所有已修改的数据并使它们的高速缓存失效,以保证系统内存的一致性。如果内存访问没有高速缓存且/或它跨越了高速缓存行的边界,那么这个处理器就会产生 LOCK# 信号,并在锁定操作期间不会响应总线控制请求。

但是值得注意的是 volatile 是保证可见性,不保证原子性。而 synchronized 是仅能保证可见性也能保证原子性。


synchronized 的选择问题

虽然 synchronized 能够很好的解决线程安全问题,但是在了解了 synchronized 的锁升级过程之后,就会发现在某些特定的并发场景,应该尽量避免使用 synchronized ,选择其他线程安全方案。

对于并发不高(线程数不多),而且任务执行时间短的场景,应该避免使用 synchronized 。不是说 synchronized 会出现什么问题,而是这时候选择一些基于 CAS 的锁(例如 ReentrantLock)是更好的方案。是、因为 synchronized 不存在锁降级操作,一旦升级到了重量锁之后,将来带来一部分性能损失。

对于并发高的场景,则推荐选择 synchronized,而不是 CAS。因为 CAS 本质是循环轮询获取锁,当并发高了,线程数变多,大量线程同时 CAS,会占用大量 CPU 资源。抢锁的线程消耗掉了大量的资源,执行任务的线程所得到的调度时间片就变少了,程序会进一步变得“拥堵”。而使用 synchronized 可以让大量线程升级为重量级锁,进入等待队列,对 CPU 友好,执行任务的线程能够确保正常执行。

至于无法确定并发高低的时候,可以使用 CAS 搭配 synchronized 。对于轻量级的操作使用 CAS 。而对于一些重量级的操作使用 synchronized。JDK 1.8 的 ConcurrentHashMap 就是这么做的:对于一些成员变量的修改使用 Unsafe(CAS),而对于真正要插入或者迁移的节点,则使用 synchronized 进行锁定。


synchronized 的有序性

很多人认为 synchronized 既然是帮助实现线程安全,自然是保证有序的。

其实不是的,synchronized 只保证多个同步块之间有序,但不保证同步块内部有序。也就是出于优化执行的目的,使用 synchronized 包裹的代码是有可能出现指令重排的,synchronized 并不保证内部的有序性。但这没有关系,因为 synchronized 保证了每次只有一个线程在执行,所以重排对执行结果没有影响,对语义也没有影响,仅仅会优化执行流程,不会带来什么不良后果。

Java Class 加载过程 为什么不要用异或来交换两个数

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×