Synchronized 与锁。

实现原理

Java 中的每一个对象都可以作为锁,其具体表现为以下 3 种形式:

  • 对于普通同步方法,锁是当前实例对象。
  • 对于静态同步方法,锁是当前类的 Class 对象。
  • 对于同步方法块,锁是 Synchronized 括号里配置的对象。

当一个线程访问同步代码块时,它首先需要得到锁才能继续执行代码块,当退出或抛出异常时必须释放锁,那么它是如何实现这个机制的呢?JVM 基于进入和退出监视器 Monitor 对象来实现方法同步和代码块同步,但两者的实现细节不一样:

  • 代码块同步:基于 monitorentermonitorexit 指令实现的。在 Java 源代码被编译成字节码的时候,会将 monitorenter 指令插入到同步代码块的开始位置,将 monitorexit 指令插入到结束位置。任何对象都有一个 monitor 对象与之关联,当一个 monitor 被持有后,它将处于锁定状态。线程执行到 monitorenter 指令时,会尝试获取对象所对应的 monitor 的所有权,即尝试获取对象的锁;执行 monitorexit 指令则会释放锁。
  • 方法同步:依靠方法修饰符上的 ACC_SYNCHRONIZED 实现的。方法级的同步是隐式的,synchronized 方法会被编译成普通的方法调用和返回指令,只是会在 Class 文件的方法表中将该方法的 access_flags 字段中的 synchronized 标志位置 1,即同步方法的常量池中会有一个 ACC_SYNCHRONIZED 标志;表示该方法是同步方法,并使用调用该方法的对象或该方法所属类的 Class 对象做为锁对象。当某个线程要访问某个方法的时候,会检查该方法是否有 ACC_SYNCHRONIZED 标志。如果有设置,则需要先尝试获得监视器 Monitor 锁,然后开始执行方法,方法执行之后再释放监视器锁。

Java 对象头、Monitor

64位JVM中对象头的MarkWord

锁的优化

在 JDK 1.6 中,对锁的实现引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。

自旋锁

线程的阻塞和唤醒需要 CPU 从用户态转为核心态,频繁的阻塞和唤醒大大的加重了 CPU 的开销和负担,也会给系统的并发性能带来很大的压力,于是便引入了自旋锁。

所谓自旋锁,就是让当前线程通过「执行一段无意义的循环(自旋)」来等待一段时间,而不会立即被挂起,等待看持有锁的线程是否会很快释放锁。

自旋等待不能替代阻塞,虽然它可以避免线程切换带来的开销,但却占用了处理器的时间。如果持有锁的线程很快就释放了锁,那么自旋的效率就非常好;反之,自旋的线程就会白白消耗掉处理器资源,而不会做任何有意义的工作,带来性能上的浪费。所以自旋等待的时间(自旋的次数)必须要有一个限度,如果自旋超过了限度仍然没有获取到锁,则当前线程应该被挂起。

自旋锁在 JDK 1.4.2 中引入,默认关闭,可以通过参数 -XX:+UseSpinning 来开启,在 JDK 1.6 中默认开启。同时自旋的默认次数为 10 次,可以通过参数 -XX:PreBlockSpin 来调整。但是如果手动设定自旋锁的自旋次数,会带来诸多不便。例如将参数调整为 20,但是系统中很多线程都是等你刚刚退出自旋的时候就释放了锁,假如再多自旋一两次就可以获取锁,效果就比较差。于是 JDK 1.6 引入了自适应的自旋锁,让虚拟机会变得越来越智能。

所谓自适应自旋锁,意味着自旋的次数不再是固定的,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。它是怎么做的呢?线程如果自旋成功了,则下次自旋的次数就会更多,因为虚拟机认为既然上次成功了,那么此次自旋也很有可能会再次成功,就会允许自旋等待持续的次数更多。反之,如果对于某个锁,很少有自旋能够成功的,那么在以后申请该锁的时候,自旋的次数就会减少甚至省略掉自旋过程,以免浪费处理器资源。 有了自适应自旋锁,随着程序运行和性能监控信息的不断完善,虚拟机对程序锁的状况预测会越来越准确,虚拟机会变得越来越智能。

锁消除

在某一同步块中,如果 JVM 通过逃逸分析检测到不可能存在共享数据竞争,这时 JVM 会对这些同步锁进行锁消除以节省申请锁的时间、提高性能。例如以下代码:

1
2
3
4
5
6
7
8
public void vectorTest(){
Vector<String> vector = new Vector<String>();
for(int i = 0 ; i < 10 ; i++){
vector.add(i + "");
}

System.out.println(vector);
}

在 JDK 中,Vector 集合的 add() 方法是一个同步方法,存在隐形的加锁操作,此时 JVM 可以明显检测到变量 vector 没有逃逸出方法 vectorTest() 之外,所以 JVM 可以大胆地将 vector 对象内部的加锁操作消除。

锁优化之锁的升级(膨胀)

JDK 1.6 为了减少 Synchronized 锁的获得和释放的性能消耗,引入了偏向锁和轻量级锁。JDK 1.6 之后锁主要存在四种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈情况而逐渐升级。注意锁可以升级但不能降级,这种策略是为了提高获得锁和释放锁的效率。

一个对象刚刚创建的时候,没有任何线程来竞争它的锁,对象处于无锁状态。此时对象锁是可偏向的,它会偏向第一个来访问它的线程。当有线程来访问同步块时,便开始了获取锁的流程。

偏向锁

HotSpot 的作者经过研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得。所以引入了偏向锁,让线程获取锁的代价更低。偏向锁主要用于无多线程竞争锁对象的环境中,线程通过检查锁对象的 Mark Word 数据和一次 CAS 操作即可获得偏向锁。

当锁对象处于可偏向状态(锁标识位为 01)时,偏向锁的获取步骤如下:

  1. 测试锁对象头的 Mark Word 中是否存储着当前线程的 ID,如果测试成功,表示线程已经获得了偏向锁,直接执行同步代码块即可。
  2. 如果测试失败,则通过 CAS 操作竞争锁:若竞争成功则设置锁对象的偏向锁状态(ThreadID, 0, 01),将对象头的 Mark Word 中的线程 ID 替换为当前线程的 ID;否则执行步骤 (4)。
  3. 若通过 CAS 竞争锁失败,证明当前锁对象存在多线程竞争的情况,此时则执行偏向锁的撤销操作。

偏向锁的撤销: 偏向锁使用了一种等到竞争出现才释放锁的机制,持有偏向锁的线程不会主动去释放偏向锁,而是等到有其他线程来竞争时才被动释放锁。偏向锁的撤销需要等到全局安全点才会执行:

  1. 暂停持有偏向锁的线程,同时检查该线程的状态;

    • 如果该线程不处于活动状态或者已经退出同步代码块,则将锁对象设置为无锁状态,让其可以重新偏向。

    • 若该线程仍然活着,则会遍历该线程栈帧中的锁记录,检查锁记录的使用情况:如果仍然需要持有偏向锁,则撤销偏向锁,升级为轻量级锁;否则将锁对象恢复到无锁状态或者偏向于其他线程。

  2. 最后恢复该暂停的线程。

偏向锁的获取和撤销流程图如下:

点击显示图片

注: 偏向锁在应用程序启动几秒钟之后才激活,可以使用 JVM 参数 -XX:BiasedLockingStartupDelay=0 来关闭延迟。如果确定应用程序里所有的锁通常处于竞争状态,则可以通过 JVM 参数 -XX:-UseBiasedLocking=false 来关闭偏向锁,那么程序默认会使锁对象进入轻量级锁状态。

轻量级锁

引入轻量级锁的主要目的是:因为绝大部分的锁在整个生命周期内都不会存在竞争的,所以当环境中不存在多线程竞争,或者竞争不激烈的时候,多个线程是交替执行同步块的,这时某个线程只需要稍微等待(自旋)一下就可以获取锁,从而减少传统重量级锁使用「操作系统互斥量」以及对线程阻塞带来的开销。

当关闭偏向锁功能或者线程通过 CAS 竞争偏向锁失败时,则会尝试获取轻量级锁。轻量级锁的获取步骤如下:

  1. 判断当前对象是否处于无锁状态(锁标志为 01,是否偏向锁为 0):

    • 若是,则 JVM 首先在当前线程的栈帧中创建一个名为锁记录(Lock Record)的空间,并将目标对象头中的 Mark Word 复制到锁记录中(官方将这份拷贝称为 Displaced Mark Word),继续执行步骤 (2)
    • 若对象不是处于无锁状态,则判断对象的 Mark Word 是否指向当前线程的栈帧:如果是则表示当前线程已持有目标对象的锁,直接执行同步代码块;否则表示目标锁对象已经被其他线程抢占了,此时将锁标志位变成 10,升级为重量级锁,当前线程进入阻塞状态。
  2. 当前线程采用自旋的方式,尝试使用 CAS 操作将目标对象头中的 Mark Word 替换为指向锁记录的指针

    • 如果成功,则表示竞争到锁,将目标对象头的锁标志位变成 00 转为轻量级锁状态,执行同步操作;
    • 如果失败,则表示目标锁对象已经被其他线程抢占了,此时将锁标志位变成 10,升级为重量级锁

轻量级锁的释放过程如下:

  1. 使用 CAS 操作将锁记录中的 Displaced Mark Word 替换到目标对象头中,如果成功,则表示没有竞争发生,直接释放锁;
  2. 如果 CAS 替换失败,表示当前锁存在竞争,锁对象头的 Mark Word 已经被其他线程更改了,锁被升级成了重量级锁,在释放锁的同时需要唤醒被挂起的线程。

轻量级锁的获取和释放流程图如下:

点击显示图片

从上面可以看到,轻量级锁的加锁、解锁操作共需要依赖多次 CAS 原子操作以及自旋,而偏向锁通过对象头的 Mark Word 数据来减少了一些加锁的执行路径和 CAS 操作,使线程获取锁的代价更低。

重量级锁

当存在多个线程竞争同一把锁时,就会升级为重量级锁。

重量级锁也就是 synchronized 关键字的实现原理,通过对象内部的监视器(Monitor)实现,其中 Monitor 的本质是依赖于底层操作系统的互斥量锁(Mutex Lock)实现,操作系统实现线程之间的切换需要从用户态到内核态的切换,成本比较高。而且这对于应用程序来说,会造成线程的阻塞,响应时间相对缓慢。

不同锁状态的对比

从前面可以看出,锁状态变化的大致流程如下:

下表是不同锁状态的优缺点的对比:

优点 缺点 适用场景
偏向锁 加锁和解锁基本不需要额外的消耗,和执行非同步方法相比仅存在纳米级的差距 如果线程之间存在锁竞争,会带来额外的锁撤销的消耗 适用于基本只有一个线程访问同步块的场景
轻量级锁 竞争的线程不会阻塞,提高了程序的响应速度 相对繁琐的加锁执行路径;如果线程经常竞争不到锁,使用自旋会白白消耗 CPU ① 追求响应时间 ② 同步块执行速度非常快
重量级锁 线程竞争不使用自旋,不会消耗 CPU 线程阻塞,响应时间缓慢 ① 追求吞吐量 ② 同步块块执行速度较长。

CAS 原子操作

CAS(Compare And Swap,比较并交换)是一种乐观锁,它可以保证一次的「读-改-写」操作是原子操作,它广泛应用于 Java 多线程的并发操作中,是整个 JUC 的基石。

在 CAS 中有三个参数:内存值 value、旧的预期值 expect、要更新的值 update。当且仅当内存中 value 的值等于旧的预期值 expect 时,才会将内存中 value 的值修改为 update,否则什么都不做。其伪代码如下:

1
2
3
4
5
6
if(this.value == expect){
this.value = update;
return true;
}else{
return false;
}

Java 无法直接访问底层操作系统,而是通过本地(native)方法来访问。不过尽管如此,JVM还是开了一个后门:Unsafe 类,它提供了硬件级别的原子操作,它也是 Java 中 CAS 操作的核心类。

例如 Unsafe 类的 compareAndSwapInt() 方法:

1
public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);

该方法为 native 方法,有四个参数分别代表:对象、对象的偏移地址、预期值、更新值。

CAS 可以保证一次的「读-改-写」操作是原子操作,在单处理器上该操作容易实现,但是在多处理器上实现就有点儿复杂了,主要有「总线加锁」和「缓存加锁」两种机制。

CAS 虽然高效的解决了原子操作,但仍然存在三大问题:

  • 循环时间长开销大:在自旋操作的循环中,如果 CAS 长时间地不成功,则会给 CPU 带来非常大的开销。在 JUC 中有些地方就限制了 CAS 自旋的次数,例如 BlockingQueue 的 SynchronousQueue。
  • ABA 问题:CAS 需要检查操作值有没有发生改变,如果没有发生改变则更新。但是存在这样一种情况:如果一个值原来是 A,变成了 B,然后又变成了 A,那么在 CAS 检查的时候会发现没有改变,但是实质上它已经发生了改变,这就是所谓的 ABA 问题。对于 ABA 问题的解决方案是加上版本号,例如 Java 中提供的 AtomicStampedReference 类。即给每个变量都加上一个版本号,每次改变时加 1,即A —> B —> A,变成 1A —> 2B —> 3A。
  • 只能保证一个共享变量的原子操作:看了 CAS 的实现就知道这只能针对一个共享变量,如果是多个共享变量就只能使用锁了。当然也可以将把多个共享变量合并成一个共享变量来进行 CAS 操作,如 JDK 1.5 之后的 AtomicReference 类。

(完)