😊 你好,我是小航,一个正在变秃、变强的文艺倾年。
😊 最近春招、暑期实习高峰期,很多小伙伴们都在焦虑背八股文,今天分享一位同学京东的最新面经。
😊 背景:小赵同学 山西某二本在读,零实习经历,有两个小项目,前段时间去面试,项目用到了锁优化,没答上来,感觉基础和八股文很薄弱。现在比较犹豫的是考研还是直接就业,二本学历大厂肯定很难进,中厂应该是有机会冲一下,目前也在学习技术还没有实习经历,担心秋招的时候零实习难度很大。
对于上面同学的经历,大家有什么建议给他呢?欢迎评论留言。
悲观锁
Synchronized
为什么用锁?在并发编程中,多个线程访问同一个共享资源时,我们必须考虑如何
维护数据的原子性
。
JDK1.5 之前,Java 是依靠 Synchronized 关键字实现锁功能来做到这点的。而 Synchronized 是基于底层操作系统的 Mutex Lock 实现的,每次获取和释放锁操作都会带来用户态和内核态的切换,从而增加系统性能开销。
JDK1.5 版本,并发包中新增了 Lock 接口来实现锁功能,Lock 同步锁是基于 Java 实现的,它提供了与 Synchronized 关键字类似的同步功能,只是在使用时需要显示获取和释放锁。
JVM 在 JDK1.6 中引入了分级锁机制
来优化 Synchronized,当一个线程获取锁时:
(1)首先对象锁将成为一个偏向锁,这样做是为了优化同一线程重复获取导致的用户态与内核态的切换问题
;
(2)如果有多个线程竞争锁资源,锁将会升级为轻量级锁,它适用于在短时间内持有锁,且分锁有交替切换的场景
;
(3)偏向锁使用了自旋锁
来避免线程用户态与内核态的频繁切换,大大地提高了系统性能;
(4)但如果锁竞争太激烈了,那么同步锁将会升级为重量级锁。
所以优化 Synchronized 同步锁的关键:减少锁竞争
。
实现方式
Synchronized 实现同步锁的方式有两种,一种是修饰方法
,一种是修饰方法块
:
// 关键字在实例方法上,锁为当前实例
public synchronized void method1() {
// code
}
// 关键字在代码块上,锁为括号里面的对象
public void method2() {
Object o = new Object();
synchronized (o) {
// code
}
}
通过反编译看下具体字节码的实现:
javac -encoding UTF-8 SyncTest.java // 先运行编译 class 文件命令
javap -v SyncTest.class // 再通过 javap 打印出字节文件
Synchronized 在修饰同步代码块
时,是由 monitorenter 和 monitorexit 指令
来实现同步的。进入 monit orenter 指令后,线程将持有 Monitor 对象,退出 monitorenter 指令后,线程将释放该 Monitor 对象。
public void method2(); // 对应方法签名
descriptor: ()V // 方法描述符:返回void(V)
flags: ACC_PUBLIC // 访问权限:公共方法
Code:
stack=2, locals=4, args_size=1
0: new #2 // 创建对象实例(#2指向常量池中的类引用)
3: dup // 复制对象引用到栈顶
4: invokespecial #1 // 调用构造函数(#1指向构造方法引用)
7: astore_1 // 将对象存入局部变量1(this引用)
8: aload_1 // 加载对象引用到栈顶
9: dup // 复制对象引用
10: astore_2 // 存入局部变量2(用于异常恢复)
11: monitorenter // 获取对象锁(进入同步块)
12: aload_2 // 加载局部变量2(备份的this引用)
13: monitorexit // 释放对象锁
14: goto 22 // 跳转到return指令
17: astore_3 // 存储异常对象到局部变量3
18: aload_2 // 加载备份的this引用
19: monitorexit // 强制释放锁
20: aload_3 // 加载异常对象
21: athrow // 抛出异常
22: return // 方法返回
Exception table: // 异常表
from to target type
12 14 17 any // 同步块内的任何异常都会跳转到17行
17 20 17 any // 异常处理中的异常仍需释放锁
LineNumberTable:
line 18: 0
line 19: 8
line 21: 12
line 22: 22
StackMapTable: number_of_entries = 2
frame_type = 255 /* full_frame */ // 完整帧记录
offset_delta = 17 // 偏移量17对应局部变量表索引1(对象实例)
locals = [ class com/demo/io/SyncTest, class java/lang/Object, class java/lang/Object ]
stack = [ class java/lang/Throwable ]
frame_type = 250 /* chop */ // 弹出栈顶元素(异常处理后)
offset_delta = 4 // 对应return前的状态
当 Synchronized 修饰同步方法
时,并没有发现 monitorenter 和 monitorexit 指令,而是出现了一个 ACC_SYNCHRONIZED 标志。
这是因为 JVM 使用了 ACC_SYNCHRONIZED 访问标志来区分一个方法是否是同步方法
。当方法调用时,调用指令将会检查该方法是否被设置 ACC_SYNCHRONIZED 访问标志
。如果设置了该标志,执行线程将先持有 Monitor 对象,然后再执行方法
。在该方法运行期间,其它线程将无法获取到该 Mointor 对象,当方法执行完成后,再释放该 Monitor 对象。
public synchronized void method1();
descriptor: ()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED // ACC_SYNCHRONIZED 标志
Code:
stack=0, locals=1, args_size=1
0: return
LineNumberTable:
line 8: 0
JVM 中的同步是基于进入和退出管程(Monitor)对象
实现的。每个对象实例都会有一个 Monitor,Monitor 可以和对象一起创建、销毁。Monitor 是由 ObjectMonitor 实现,而 ObjectMonitor 是由 C++ 的 ObjectMonitor.hpp
文件实现,如下所示:
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 ;
}
当多个线程同时访问一段同步代码时,多个线程会先被存放在 EntryList 集合中
,处于 block 状态的线程,都会被加入到该列表。接下来当线程获取到对象的 Monitor 时,Monitor 是依靠底层操作系统的 Mutex Lock 来实现互斥的,线程申请 Mutex 成功,则持有该 Mutex,其它线程将无法获取到该 Mutex。
如果线程调用 wait() 方法,就会释放当前持有的 Mutex,并且该线程会进入 WaitSet 集合中,等待下一次被唤醒
。如果当前线程顺利执行完方法,也将释放 Mutex。
对象头
在 JDK1.6 JVM 中,对象实例在堆内存中被分为了三个部分:对象头、实例数据和对齐填充
。其中 Java 对象头由 Mark Word、指向类的指针以及数组长度三部分
组成。
Mark Word 记录了对象和锁
有关的信息。Mark Word 在 64 位 JVM 中的长度是 64bit,如下图所示:
锁升级功能主要依赖于 Mark Word 中的锁标志位和释放偏向锁标志位
,Synchronized 同步锁就是从偏向锁开始的,随着竞争越来越激烈,偏向锁升级到轻量级锁,最终升级到重量级锁。
锁内部优化
偏向锁
目的:优化同一线程多次申请同一个锁
的竞争。当一个线程再次访问这个同步代码或方法时,该线程只需去对象头的 Mark Word 中去判断一下是否有偏向锁指向它的 ID
,无需再进入 Monitor 去竞争对象
了。
流程:当对象被当做同步锁并有一个线程抢到了锁时,锁标志位还是 01,“是否偏向锁”标志位设置为 1,并且记录抢到锁的线程 ID,表示进入偏向锁状态。一旦出现其它线程竞争锁资源时,偏向锁就会被撤销。偏向锁的撤销需要等待全局安全点,暂停持有该锁的线程,同时检查该线程是否还在执行该方法,如果是,则升级锁,反之则被其它线程抢占。
红线流程部分为偏向锁获取和撤销流程:
因此,在高并发场景下,当大量线程同时竞争同一个锁资源时,偏向锁就会被撤销,发生 stop the word
后, 开启偏向锁无疑会带来更大的性能开销,这时我们可以通过添加 JVM 参数关闭偏向锁来调优系统性能,示例代码如下:
-XX:-UseBiasedLocking // 关闭偏向锁(默认打开)
## 或者
-XX:+UseHeavyMonitors // 设置重量级锁
轻量级锁
当有另外一个线程竞争获取这个锁时,由于该锁已经是偏向锁
,当发现对象头 Mark Word 中的线程 ID 不是自己的线程 ID
,就会进行 CAS 操作获取锁:
- 如果获取成功,直接替换 Mark Word 中的线程 ID 为自己的 ID,该锁会保持偏向锁状态;
- 如果获取锁失败,代表当前锁有一定的竞争,偏向锁将升级为轻量级锁。
轻量级锁适用于线程交替执行同步块的场景
,绝大部分的锁在整个同步周期内都不存在长时间的竞争。
红线流程部分为升级轻量级锁及操作流程:
自旋锁与重量级锁
为什么提出:轻量级锁 CAS 抢锁失败
,线程将会被挂起进入阻塞状态
。如果正在持有锁的线程在很短的时间内释放资源
,那么进入阻塞状态的线程无疑又要申请锁资源
。
JVM 提供了一种自旋锁,可以通过自旋方式不断尝试获取锁
,从而避免线程被挂起阻塞
。这是基于大多数情况下,线程持有锁的时间都不会太长,毕竟线程被挂起阻塞可能会得不偿失。
从 JDK1.7 开始,自旋锁默认启用
,自旋次数由 JVM 设置决定,这里我不建议设置的重试次数过多,因为 CAS 重试操作意味着长时间地占用 CPU。
自旋锁重试之后如果抢锁依然失败,同步锁就会升级至重量级锁,锁标志位改为 10。在这个状态下,未抢到锁的线程都会进入 Monitor,之后会被阻塞在 _WaitSet 队列
中。
红线流程部分为自旋后升级为重量级锁的流程:
在锁竞争不激烈且锁占用时间非常短
的场景下,自旋锁可以提高系统性能
。一旦锁竞争激烈或锁占用的时间过长
,自旋锁将会导致大量的线程一直处于 CAS 重试状态
,占用 CPU 资源,反而会增加系统性能开销。
在高负载、高并发的场景下,我们可以通过设置 JVM 参数来关闭自旋锁,优化系统性能,示例代码如下:
-XX:-UseSpinning // 参数关闭自旋锁优化 (默认打开)
-XX:PreBlockSpin // 参数修改默认的自旋次数。JDK1.7 后,去掉此参数,由 jvm 控制
动态编译实现锁消除 / 锁粗化【编译器优化】
-
逃逸分析:JIT 编译器在动态编译同步块的时候,借助了一种被称为
逃逸分析
的技术,来判断同步块使用的锁对象是否只能够被一个线程访问,而没有被发布到其它线程
。 -
锁消除:确认是的话,那么 JIT 编译器在编译这个同步块的时候
不会生成 synchronized 所表示的锁的申请与释放的机器码
,即消除了锁的使用。在 Java7 之后的版本就不需要手动配置了,该操作可以自动实现。 -
锁粗化:JIT 编译器动态编译时,如果
发现几个相邻的同步块使用的是同一个锁实例
,那么 JIT 编译器将会把这几个同步块合并为一个大的同步块
,从而避免一个线程“反复申请、释放同一个锁“所带来的性能开销。
减小锁粒度【代码优化】
通过代码层来实现锁优化,减小锁粒度就是一种惯用的方法。
当我们的锁对象是一个数组或队列
时,集中竞争一个对象的话会非常激烈,锁也会升级为重量级锁
。我们可以考虑将一个数组和队列对象拆成多个小对象,来降低锁竞争
,提升并行度。
案例:JDK1.8 之前实现的 ConcurrentHashMap 版本
- 问题:HashTable 是基于一个数组 + 链表实现的,所以在
并发读写操作集合时,存在激烈的锁资源竞
争,也因此性能会存在瓶颈。 - 解决方案:ConcurrentHashMap 使用分段锁 Segment 来降低锁资源竞争。
Synchronized 同步锁对普通方法和静态方法的修饰有什么区别?
// 修饰普通方法
public synchronized void method1() {
// code
}
// 修饰静态方法
public synchronized static void method2() {
// code
}
解答:非静态方法是对象锁,静态方法是类锁。
Lock
JVM 隐式获取和释放锁的 Synchronized 同步锁,Lock 同步锁需要的是显示获取和释放锁,为获取和释放锁提供了更多的灵活性。
(1)在并发量不高、竞争不激烈的情况下,Synchronized 同步锁由于具有分级锁的优势,性能上与 Lock 锁差不多;
(2)在高负载、高并发的情况下,Synchronized 同步锁由于竞争激烈会升级到重量级锁,性能则没有 Lock 锁稳定。
Lock 锁的基本操作是通过乐观锁来实现
的,但由于 Lock 锁也会在阻塞时被挂起,因此它依然属于悲观锁
。
实现原理
Lock 是一个接口类,常用的实现类有 ReentrantLock、ReentrantReadWriteLock(RRW),它们都是依赖 AbstractQueuedSynchronizer(AQS)类实现的。
AQS 类结构:包含一个基于链表实现的等待队列(CLH 队列),用于存储所有阻塞的线程
,AQS 中还有一个 state 变量
,该变量对 ReentrantLock 来说表示加锁状态。
整个获取锁的过程:
ReentrantLock
ReentrantLock 是一个独占锁,同一时间只允许一个线程访问
读写锁
ReentrantReadWriteLock
适合场景:读多写少。
解决方案:读写锁内部维护了两个锁,一个是用于读操作的 ReadLock,一个是用于写操作的 WriteLock。
新的问题:读写锁如何实现锁分离来保证共享资源的原子性?
解决方案:
(1)RRW 基于 AQS 实现的,所以自定义同步器(继承 AQS)需要在同步状态 state 上维护多个读线程和一个写线程的状态。RRW 使用了高低位
,来实现一个整型控制两种状态的功能
,读写锁将变量切分成了两个部分,高 16 位表示读,低 16 位表示写
。
一个线程尝试获取写锁时:
(1)先判断同步状态 state 是否为 0。如果 state 等于 0,说明暂时没有其它线程获取锁
;如果 state 不等于 0,则说明有其它线程获取了锁。
(2)判断同步状态 state 的低 16 位(w)是否为 0,如果 w 为 0,则说明其它线程获取了读锁
,此时进入 CLH 队列进行阻塞等待;如果 w 不为 0,则说明其它线程获取了写锁,此时要判断获取了写锁的是不是当前线程
,若不是就进入 CLH 队列进行阻塞等待;若是,就应该判断当前线程获取写锁是否超过了最大次数
,若超过,抛异常,反之更新同步状态。
一个线程尝试获取读锁时:
(1)先判断同步状态 state 是否为 0
。如果 state 等于 0,说明暂时没有其它线程获取锁
,此时判断是否需要阻塞
,如果需要阻塞,则进入 CLH 队列进行阻塞等待;如果不需要阻塞,则 CAS 更新同步状态为读状态。
(2)如果 state 不等于 0,会判断同步状态低 16 位,如果存在写锁,则获取读锁失败
,进入 CLH 阻塞队列;反之,判断当前线程是否应该被阻塞,如果不应该阻塞则尝试 CAS 同步状态,获取成功更新同步锁为读状态。
StampedLock
问题:在读取很多、写入很少的情况下,RRW 会使写入线程遭遇饥饿(Starvation)问题
,也就是说写入线程会因迟迟无法竞争到锁而一直处于等待状态
。
解决方案:JDK1.8 中,Java 提供了 StampedLock 类。
(1)StampedLock 不是基于 AQS 实现的
,但实现的原理和 AQS 是一样的,都是基于队列和锁状态实现的。
(2)StampedLock 控制锁有三种模式: 写、悲观读以及乐观读
,并且 StampedLock 在获取锁时会返回一个票据 stamp
,获取的 stamp 除了在释放锁时需要校验
,在乐观读模式下,stamp 还会作为读取共享资源后的二次校验
。
缺点:StampLock不支持重入,如果在一些需要重入的代码中使用StampedLock,会导致死锁、饿死等情况出现。也不支持条件变量,线程被中断时可能导致CPU暴涨。
乐观锁
在操作共享资源时,它总是抱着乐观的态度进行,它认为自己可以成功地完成操作。
(1)乐观锁相比悲观锁来说,不会带来死锁、饥饿等活性故障问题,线程间的相互影响也远远比悲观锁要小。
(2)乐观锁没有因竞争造成的系统开销,所以在性能上也是更胜一筹。
实现原理
CAS
CAS 是实现乐观锁的核心算法,它包含了 3 个参数:V(需要更新的变量)、E(预期值)和 N(最新值)
。
(1)只有当需要更新的变量等于预期值时,需要更新的变量才会被设置为最新值
(2)如果更新值和预期值不同,则说明已经有其它线程更新了需要更新的变量,此时当前线程不做操作
,返回 V 的真实值。
我们在使用 CAS 操作的时候要注意的 ABA 问题指的是什么呢?
- ABA问题:变量的原值为A,当线程T读取后到更新前这段时间,可能被其他线程更新为B值后又更新回A值,到当线程T进行CAS操作时感知不到这个变化,依然可以更新成功;
- 解决方案:StampdLock通过过去锁时返回一个时间戳可以解决该问题。
AtomicInteger
在 JDK 中的 concurrent 包中,atomic 路径下的类都是基于 CAS 实现的。
AtomicInteger 是基于 CAS 实现的一个线程安全的整型类。
// 基于 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);
处理器实现
处理器和物理内存之间的通信速度要远慢于处理器间的处理速度
,所以处理器有自己的内部缓存
。
- 一个单核处理器:能自我保证基本的内存操作是原子性的,当一个线程读取一个字节时,所有进程和线程看到的字节都是同一个缓存里的字节,其它线程不能访问这个字节的内存地址。
- 多个多核处理器:每个处理器维护了一块字节的内存,每个内核维护了一块字节的缓存,这时候多线程并发就会存在缓存不一致的问题,从而导致数据不一致。
解决方案:处理器提供了
总线锁定和缓存锁定
两个机制来保证复杂内存操作的原子性。
(1)当处理器要操作一个共享变量的时候,其在总线上会发出一个 Lock 信号
,这时其它处理器就不能操作共享变量
了,该处理器会独享此共享内存中的变量。
(2)总线锁定在阻塞其它处理器获取该共享变量的操作请求时,也可能会导致大量阻塞,从而增加系统的性能开销
。
(3)于是处理器提供了缓存锁定机制
,也就说当某个处理器对缓存中的共享变量进行了操作
,就会通知其它处理器放弃存储该共享资源
或者重新读取该共享资源
乐观锁优化
在写大于读的操作场景下,CAS 失败的可能性会增大,如果不放弃此次 CAS 操作,就需要循环做 CAS 重试,这无疑会长时间地占用 CPU。
- Java7:AtomicInteger 的 getAndSet 方法中使用了 for 循环不断重试 CAS 操作,如果长时间不成功,就会给 CPU 带来非常大的执行开销。
public final int getAndSet(int newValue) {
for (;;) {
int current = get();
if (compareAndSet(current, newValue))
return current;
}
}
- Java8:for 循环被去掉,但我们反编译 Unsafe 类时就可以发现
该循环其实是被封装在了 Unsafe 类中
,CPU 的执行开销依然存在。
LongAdder
Java 8提供了一个新的原子类 LongAdder。LongAdder 在高并发场景下会比 AtomicInteger 和 AtomicLong 的性能更好
,代价就是会消耗更多的内存空间。
原理:降低操作共享变量的并发数
,也就是将对单一共享变量的操作压力分散到多个变量值上
,将竞争的每个写线程的 value 值分散到一个数组中,不同线程会命中到数组的不同槽中
,各个线程只对自己槽中的 value 值进行 CAS 操作,最后在读取值的时候会将原子操作的共享变量与各个分散在数组的 value 值相加
,返回一个近似准确
的数值。【LongAdder虽然降低了并发竞争,但是却对实时更新的数据不友好
】
组成:LongAdder 内部由一个 base 变量和一个 cell[] 数组
组成。
(1)当只有一个写线程,没有竞争的情况下,LongAdder 会直接使用 base 变量作为原子操作变量
,通过 CAS 操作修改变量;
(2)当有多个写线程竞争的情况下,除了占用 base 变量的一个写线程之外,其它各个线程会将修改的变量写入到自己的槽 cell[] 数组中
。计算公式:
总结
我们对四种模式下的五个锁 Synchronized、ReentrantLock、ReentrantReadWriteLock、StampedLock 以及乐观锁 LongAdder
进行压测
运行环境:4 核的 i7 处理器
结论:
(1)读大于写的场景:读写锁 ReentrantReadWriteLock、StampedLock 以及乐观锁的读写性能是最好的;
(2)写大于读的场景:乐观锁的性能是最好的,其它 4 种锁的性能则相差不多;
(3)读和写差不多的场景:两种读写锁以及乐观锁的性能要优于 Synchronized 和 ReentrantLock。
📌 [ 笔者 ] 文艺倾年
📃 [ 更新 ] 2025.2.17
❌ [ 勘误 ] /* 暂无 */
📜 [ 参考 ] 《极客时间》刘超老师的课堂内容,用于后期复习回忆。