本文是极客时间 Java 性能调优专栏的笔记(非原创),妈的这个专栏写的太好了。
背景
在并发编程中,多个线程访问同一个共享资源时,我们必须考虑如何维护数据的原子性。
在JDK 1.5 之前,是通过内置锁 synchronized 来实现,锁的释放是由 JVM 隐式实现的,而 synchronized 是基于底层操作系统的 Mutex Lock 实现的,每次获取和释放锁都会带来用户态和内核态的切换,从而增加系统性能开销。而且在锁竞争激烈的情况下,性能表现很糟糕,它也常常被大家成为重量级锁。
所以在 JDK 1.5 增加了 Lock 接口来实现锁功能,使用的时候需要显式获取和释放锁。Lock 同步锁是基于 Java 实现的。特别是在单个线程重复申请锁的情况下,JDK 1.5 版本的 synchronized 锁性能要比 Lock 的性能差很多。
所以在 JDK 1.6 版本之后,对 synchronized 做了优化,甚至在某些场景下性能已经超过了 Lock 同步锁。
synchronized 同步锁实现原理
synchronized 可以修饰代码块和方法,通过反编译后可以看到:当使用同步代码块时采用 monitorenter 和 monitorexit 来实现,进入 monitorenter 指令后,线程将持有 Monitor 对象,执行 monitorexit 指令后,线程释放该 Monitor 对象;
当修饰方法时通过 ACC_SYNCHRONIZED 访问标志来区分一个方法是否是同步方法,调用该方法是检查是否设置了该标志,如果设置了,执行线程先持有 Monitor 对象,执行期间其他线程无法获取,方法执行完后再释放 Monitor 对象。
monitor 概念
管程,监视器。在操作系统中,存在着semaphore和mutex,即信号量和互斥量,使用基本的mutex进行开发时,需要小心的使用mutex的down和up操作,否则容易引发死锁问题。为了更好的编写并发程序,在mutex和semaphore基础上,提出了更高层次的同步原语,实际上,monitor属于编程语言的范畴,C语言不支持 monitor,而 java 支持 monitor 机制。
临界区
被 synchronized 关键字修饰的方法,代码块,就是 monitor 机制的临界区
JVM 中的同步是基于进入和退出管程(Monitor)对象实现的。每个对象实例都会有一个 Monitor 对象,Monitor 可以和对象一起创建,销毁。Monitor 是由 ObjectMonitor 实现,而 ObjectMonitor 是有 C++ 的 ObjectMonitor.cpp 文件实现:
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 ;
}
当多个线程同时访问一段同步代码时,多个线程会先被存放在 ContentionList 和 _EntryList 集合中,处于 block 状态的线程,都会被加入到该列表(在获取到参与锁资源竞争的线程会进入entrylist,线程monitorenter失败后会进入到waitset,此时说明已经有线程获取到锁了,所以需要进入等待。调用wait方法也会进入到waitset)。接下来当线程获取到对象的 Monitor 时,Monitor 是依靠底层操作系统的 Mutex Lock 来实现互斥的,线程申请 Mutex 成功,则持有该 Mutex,其它线程将无法获取到该 Mutex,竞争失败的线程会再次进入 ContentionList 被挂起。
如果线程调用 wait() 方法,就会释放当前持有的 Mutex,并且该线程会进入 WaitSet 集合中,等待下一次被唤醒。如果当前线程顺利执行完方法,也将释放 Mutex。
同步锁在这种实现方式中,因为 Monitor 依赖于底层的操作系统实现,存在用户态与内核态之间的切换,所以增加了性能开销。
锁升级优化
在 JDK 1.6 引入了偏向锁,轻量级锁,重量级锁概念,来减少锁竞争带来的上下文切换,而正是 Java 新增的对象头实现了锁升级功能。
先来看看 Java 对象头,在 JDK 1.6 JVM 中,对象实例在堆中被分割成三个部分:对象头,实例数据和对齐填充。其中 Java 对象头由 Mark Word、指向类的指针以及数组长度三部分组成。
Mark Word 记录了对象和锁有关的信息,在 64 位 JVM 中的长度是 64 Bit,存储结构如下
锁升级功能主要依赖于 Mark Word 中的锁标志位和是否偏向锁标志位,Synchronized 同步锁就是从偏向锁开始的,随着竞争越来越激烈,偏向锁升级到轻量级锁,最终升级到重量级锁。
偏向锁
偏向锁主要用来优化同一线程多次申请同一个锁的竞争。当一个线程再次访问这个同步代码或者方法时,该线程只需去对象头的 Mark Word 中去判断一下是否有偏向锁指向它的 ID,无需再进入 Monitor 去竞争对象了。当对象被当做同步锁并有一个线程抢到了锁时,锁标志位还是 01,“是否偏向锁”标志位设置为 1,并且记录抢到锁的线程 ID,表示进入偏向锁状态。
一旦有其他线程竞争锁资源时,偏向锁就会被撤销。偏向锁的撤销需要等待全局安全点,暂停持有该锁的线程(如果不暂停就不能正确判断线程是否正在持有偏向锁,暂停的目的是保证能正确判断线程持有偏向锁状态以及线程执行代码块的情况),同时检查该线程是否还在执行该方法,如果是,则升级锁,反之则被其它线程抢占。
高并发下大量线程同时竞争同一个锁资源时,偏向锁就会被撤销,发生 stop the word 后,开启偏向锁无疑会带来更大的性能开销,可以通过 JVM 参数关闭。
轻量级锁
当有其他线程竞争获取锁是,由于该锁已经是偏向锁,其他线程就会进行 CAS 操作获取锁,如果获取成功直接替换 Mark Word 中的线程ID,如果获取失败,代表锁有一定的竞争,则升级为轻量级锁。
轻量级锁适用于线程交替执行同步块的场景,绝大部分的锁在整个同步周期内都不存在长时间的竞争。
自旋锁与重量级锁
轻量级锁 CAS 抢锁失败,线程将会被挂起进入阻塞状态,自旋锁的自旋次数可以由 JVM 进行设置,不建议次数过多,因为 CAS 重试操作意味着长时间的占用CPU。
自旋锁重试之后如果依然抢锁失败,同步锁就会升级至重量级锁,锁标志位改为 10。
优化
动态编译实现锁消除/锁粗化
JIT 在动态编译同步块的时候,借助了一种被称为逃逸分析的技术,来判断同步块使用的锁对象是否只能够被同一个线程访问,而没有被发布到其他线程。如果是就进行锁消除。
锁粗化同理,就是在 JIT 编译器动态编译时,如果发现几个相邻的同步块使用的是同一个锁实例,那么 JIT 编译器将会把这几个同步块合并为一个大的同步块,从而避免一个线程“反复申请、释放同一个锁”所带来的性能开销。
减小锁粒度
JDK1.8 之前实现的 ConcurrentHashMap,使用分段锁 Segment。
悲观锁和乐观锁
Synchronized 和 Lock 实现的同步锁都是悲观锁,悲观锁在高并发的场景下,激烈的锁竞争会造成线程阻塞,大量阻塞线程会导致系统的上下文切换,增加系统的性能开销。
乐观锁不会像悲观锁一样在操作系统中挂起,而仅仅是返回,并且系统允许失败的线程重试,也允许自动放弃退出操作。不会带来死锁,饥饿等活性故障问题,线程间的相互影响也远远比悲观锁要小。更为重要的是,乐观锁没有因竞争造成的系统开销,所以在性能上也是更胜一筹。
CAS 是实现乐观锁的核心算法,它包含了 3 个参数:V(需要更新的变量),E(预期值)和 N(最新值)。只有当需要更新的变量等于预期值时,需要更新的变量才会被设置为最新值,如果更新值和预期值不同,则说明已经有其它线程更新了需要更新的变量,此时当前线程不做操作,返回 V 的真实值。
//基于CAS操作更新值
public final boolean compareAndSet(int expect, int update) {
return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}
//基于CAS操作增1
public final int getAndIncrement() {
return unsafe.getAndAddInt(this, valueOffset, 1);
}
//基于CAS操作减1
public final int getAndDecrement() {
return unsafe.getAndAddInt(this, valueOffset, -1);
CAS 是调用处理器底层指令来实现原子操作,处理器底层又是如何实现原子操作的呢?
处理器和物理内存之间的通信速度要远慢于处理器间的处理速度,所以处理器有自己的内部缓存(高速缓存)。在执行过程中,频繁使用的内存数据会缓存在处理器的 L1、L2、L3 高速缓存中,以加快频繁读取的速度。
处理器提供了总线锁定和缓存锁定两个机制来保证复杂内存操作的原子性。
当处理器要操作一个共享变量的时候,其在总线上会发出一个 Lock 信号,这时其它处理器就不能操作共享变量了,该处理器会独享此共享内存中的变量。但总线锁定在阻塞其他处理器获取该共享变量的操作请求时,也可能会导致大量阻塞,从而增加系统的性能开销。
于是,后来的处理器都提供了缓存锁定机制,也就说当某个处理器对缓存中的共享变量进行了操作,就会通知其它处理器放弃存储该共享资源或者重新读取该共享资源。目前最新的处理器都支持缓存锁定机制。
因为 Java 7 中的 AtomicInteger 和 Java 8 中的 Unsafe 在 for 循环不断尝试 CAS 操作,CPU 开销很大。JDK 1.8 中,Java 提供了一个新的原子类 LongAdder。LongAdder 在高并发场景下会比 AtomicInteger 和 AtomicLong 的性能更好,不过会消耗更多空间。
LongAdder 的原理就是降低操作共享变量的并发数,也就是将对单一共享变量的操作压力分散到多个变量值上,将竞争的每个写线程的 value 值分散到一个数组中,不同线程会命中到数组的不同槽中,各个线程只对自己槽中的 value 值进行 CAS 操作,最后在读取值的时候会将原子操作的共享变量与各个分散在数组的 value 值相加,返回一个近似准确的数值。
LongAdder 内部由一个 base 变量和一个 cell[]数组组成。当只有一个写线程,没有竞争的情况下,LongAdder 会直接使用 base 变量作为原子操作变量,通过 CAS 操作修改变量;当有多个写线程竞争的情况下,除了占用 base 变量的一个写线程之外,其它各个线程会将修改的变量写入到自己的槽 cell[]数组中,最终结果可通过以下公式计算得出:
我们可以发现,LongAdder 在操作后的返回值只是一个近似准确的数值,但是 LongAdder 最终返回的是一个准确的数值, 所以在一些对实时性要求比较高的场景下,LongAdder 并不能取代 AtomicInteger 或 AtomicLong(假设操作后立即要获取到值,这个值可能是一个不准确的值。如果我们等待所有线程执行完成之后去获取,这个值肯定是准确的值。一般在做统计时,会经常用到这种操作,实时展现的只要求一个近似值,但最终的统计要求是准确的)。
CAS 乐观锁的限制
CAS 乐观锁在平常使用时比较受限,它只能保证单个变量操作的原子性,当涉及到多个变量时,CAS 就无能为力了,但前两讲讲到的悲观锁可以通过对整个代码块加锁来做到这点。
CAS 乐观锁在高并发写大于读的场景下,大部分线程的原子操作会失败,失败后的线程将会不断重试 CAS 原子操作,这样就会导致大量线程长时间地占用 CPU 资源,给系统带来很大的性能开销。在 JDK1.8 中,Java 新增了一个原子类 LongAdder,它使用了空间换时间的方法,解决了上述问题。
CAS ABA问题,使用版本管理来解决。
性能对比
在读大于写的场景下,读写锁 ReentrantReadWriteLock、StampedLock 以及乐观锁的读写性能是最好的;在写大于读的场景下,乐观锁的性能是最好的,其它 4 种锁的性能则相差不多;在读和写差不多的场景下,两种读写锁以及乐观锁的性能要优于 Synchronized 和 ReentrantLock。
锁重置
在垃圾回收阶段,即STW时,没有Java线程竞争锁的情况下,会将锁状态重置。
流程图(神图)
总结
本文先从 synchronized 的历史背景介绍,然后再分析它的底层实现 Monitor -> Mutex Lock,接下来讲解了 偏向锁/轻量级锁/重量级锁,乐观锁和悲观锁;最后介绍了锁的性能对比和优化。