王有志,一个分享硬核Java技术的互金摸鱼侠
加入Java人的提桶跑路群:共同富裕的Java人
今天是《面霸的自我修养》的第二弹,内容是 Java 并发编程中关于 Java 内存模型(Java Memory Model)和锁的基础理论相关的问题。这两块内容的八股文倒是不多,但是难度较大,接下来我们就一起一探究竟吧。
以下是“叠甲”声明:
- 八股文通常出现在面试第一二轮,是面试的敲门砖,第三轮的重点是如何讲好做过的项目;
- 八股文的主要来源是各机构(Java 之父和继父,某灵,某泡,某客等)及各个博主的文档;
- 小部分八股文来自我个人及朋友真实面试经历,题目上会有“真”的标注,并写明面试公司;
- 由于本人水平有限,解答过程中难免出现错误,还请大家以批评指正为主,尽量不要喷~~
- 本文已完成 PDF 文档的制作,关键字【面霸的自我修养】。
Java 内存模型篇
关于 Java 内存模型的内容可以说是八股文中最晦涩难懂的部分之一了, 《JSR-133 Java Memory Model and Thread Specification》中是这么介绍这部分内容的:
The discussion and development of this specification has been unusually detailed and technical, involving insights and advances in a number of academic topics.
不过还好,面试中通常不会过分深入的考察 Java 内存模型的部分。
真:描述下 Java 内存模型,说说你对它的理解。
难易程度:❤❤❤❤❤
重要程度:❤❤❤❤❤
面试公司:美团,爱奇艺,阿里巴巴
Java 内存模型(Java Memory Model,JMM)是 Java 语言规范中的一套规则,它描述了多线程环境下的线程与内存(主内存和高速缓存)的交互方式,以保证可见性,有序性和原子性,同时它屏蔽了硬件与操作系统的底层差异,使得 Java 程序在所有平台下的内存访问效果一致。
高速缓存带来的可见性问题
我们知道,CPU 的运算速度是远高于内存读写速度的,为了减少速度间的差异,CPU 为每个核心引入了高速缓存(通常分为 L1,L2 和 L3)。多线程的程序中,线程可能会运行在不同的核心上,这时它们使用自己缓存中从主内存拷贝的数据副本,假设每个 CPU 只有一个高速缓存,画一个简易的模型:
如果线程 T1 和线程 T2 分别从主内存中读取同一个数据的到自己的高速缓存中进行操作,如果线程 T1 是先于线程 T2 发生的,那么此时线程 T2 无法感知到线程 T1 对缓存中数据做出的修改,导致可线程间的可见性问题。
Tips:虽然硬件层面引入了缓存一致性协议,但仍旧存在可见性问题,另外不同的 CPU 架构对缓存一致性协议的实现不同导致出现的问题也不相同,这部分内容大家可以自行探索。
上下文切换带来的原子性问题
Java 中常常会使用count++
的方式来实现计数器的自增操作,直觉上我们认为该操作是“一气呵成”的,但实际上对应的计算机中执行了 3 条指令:
- 指令1:将count读入缓存;
- 指令2:执行自增操作;
- 指令3:将自增后的count写入内存。
如果运行在同一个核心上的线程 T1 和线程 T2 先后执行count++
,可能会存在一种情况:
初始状态下 count 为 0, 我们期望执行结束后线程 T1 的执行结果是 1,线程 T2 的执行结果是 2,但实际上恰恰相反,这就是上下文切换带来的原子性问题。
Tips:上下文切换的内容请参考《面霸的自我修养:Java线程专题》。
指令重排带来的有序性问题
指令重排是 CPU 一项重要的优化手段,在不改变单线程执行结果的前提下,CPU 可以自行选择如何优化指令。指令重排遵循两个基本原则:
- 数据依赖原则:如果两个操作使用的数据存在依赖性,那么不能通过指令重排来优化这两个操作的执行顺序;
- as-if-serial 语义:无论如何重排序,都要保证单线程场景下的语义不能被改变(或者说执行结果不变)。
我们举个 Java 中经典的例子,未正确同步的单例模式:
public static class Singleton {
private Singleton instance;
public Singleton getInstance() {
if (instance == null) {
synchronized(this) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
private Singleton() {
}
}
Java 中通过关键字 new 来创建一个对象要经历 3 步:
分析数据依赖原则,操作 1 是要先于操作 2 和操作 3 执行的,操作 2 和操作 3 之间并没有依赖性,如果操作 2 和操作 3 交换了执行顺序,依旧满足单线程环境下的语义,因此,在实际的执行过程中,无论是 1 -> 2 -> 3 还是 1->3->2 都是可以接受的。
那么在这个例子中,可能出现如下情况:
这种情况下线程 T2 拿到的是未经过初始化的 instance 对象。
JMM 的理解
JMM 提供了一系列 Java 内存交互规范,用于规范不同平台下多线程环境中并发访问共享内存的方式,以保证可见性,原子性和有序性,确保程序的可预测性和可靠性。JMM 中最重要的两个部分是 as-if-serial 语义和 Happens-Before 原则。
Tips: 实际上,JMM 的出现是早于 JSR-133 的,在早期的 Java 语言规范中描述的 JMM 模型存在一定的缺陷,因此在 JSR-133 中对早期的 JMM 模型做了改进,并作为 JSR-176(Java 5.0)的一部分。
Happens-Before原则
难易程度:❤❤❤❤❤
重要程度:❤❤❤
面试公司:无
Happens-Before 原则是 JMM 中的一部分,用于描述多线程环境下操作之间执行结果的顺序,例如:操作 A happens-before 操作 B(记作Ahb→BAunderrightarrow{hb}BAhbB),表示无论在何种情况下,操作 A 的结果对操作 B 来说都是可见的。
JSR-133 中定义了 Happens-Before 原则的 6 项内容:
- 程序顺序规则:线程中的每个操作happens-before该线程中的任意后续操作。
- 监视器锁规则:锁的解锁happens-before随后这个锁的加锁。
- volatile变量规则:volatile变量的写happens-before后续任意对这个volatile变量的读。
- 传递性:如果Ahb→BAunderrightarrow{hb}BAhbB,且Bhb→CBunderrightarrow{hb}CBhbC,那么Ahb→CAunderrightarrow{hb}CAhbC。
Thread#start
规则:如果线程 t1 执行操作启动线程 t2(执行t2.start
),那么 t1 线程的t2.start
操作happens-before于线程 t2 中的任意操作。Thread# join
规则:如果线程 t1 执行操作t2.join
并成功返回,那么线程B中的任意操作happens-before于线程A从t2.join
操作成功返回。
Happens-Before 原则提供了线程间的可见性保证,描述了线程执行结果间的先后顺序,但 Happens-Before 原则并不限制指令执行的顺序,即Happens-Before 原则并不禁止重排序,只是要求重排序后的结果满足Happens-Before 原则的要求。
锁的理论篇
这部分我们来讨论在设计锁的过程中的理论基础,了解各种各样的锁的特性。
Tips: 《一文看懂并发编程中的锁》中涵盖了大部分 Java 中锁的理论知识。
什么是读(共享)锁?什么是写(独占/互斥/排他)锁?
难易程度:❤❤❤
重要程度:❤❤❤
面试公司:无
读写锁之间是对访问共享资源“态度”上的差异。
读锁(Read Lock)
读锁(Read Lock),共享锁(Shared Lock),S 锁,指的是允许多个线程同时读取共享资源的并发控制机制,读锁在读操作之间是共享的,一旦涉及到写操作就会发生互斥。
写锁(Write Lock)
写锁(Write Lock),互斥锁(Mutex Lock),排他锁(Mutex Lock),X 锁,指的是无论读写同一时间只允许一个线程访问共享资源的并发控制机制。
Tips:在并发编程中,你会常常看到临界区这个词,临界区指的是访问共享资源的代码区域,如果不能正确的控制这段代码区域的并发访问,可能会导致很多违背直觉的并发问题。
真:什么是乐观锁?什么是悲观锁?
难易程度:❤❤❤
重要程度:❤❤❤❤❤
面试公司:蚂蚁金服,联储证券,质数金融
悲观锁(Pessimistic Locking)
悲观锁是一种用于多线程环境中保护共享资源一致性的并发控制机制。悲观锁总是假设共享资源会被修改,因此在访问(包含读和写两种)共享资源前,先获取锁来保护共享资源,防止其它线程访问共享资源,避免了并发导致的问题。
悲观锁的工作原理如下:
- 线程尝试获取保护共享资源的锁;
- 获取锁成功,允许线程访问共享资源;
- 获取锁失败,线程阻塞,等待锁的释放;
- 获取到锁的线程操作完毕后释放锁。
乐观锁(Optimistic Locking)
同样的,乐观锁也是一种用于多线程环境中保护共享资源一致性的并发控制机制。与悲观锁不同,乐观锁认为共享资源不会被修改,所以在读取时并不会对资源进行上锁,只有在更新资源时,才会对资源进行冲突检测。
乐观锁的工作原理如下:
- 允许多线程同时读取共享资源;
- 写入共享资源时通过自身持有资源的标识(版本号,时间戳等)与共享资源的标识进行对比;
- 标识未发生改变,允许线程写入资源;
- 标识发生改变,禁止线程陷入资源;
- 写入资源失败的的线程,允许进行重试或直接抛出异常。
Tips:这里我们略过了通过共享资源的值比较环节,直接使用了版本号或时间戳。
真:乐观锁一定优于悲观锁吗?
难易程度:❤❤❤
重要程度:❤❤❤❤❤
面试公司:蚂蚁金服,联储证券,质数金融
乐观锁与悲观锁适用的场景并不相同,无法绝对的说孰优孰劣。乐观锁更适用于读多写少的场景,而悲观锁更适用于写多读少的场景。
乐观锁
乐观锁的特点是假设冲突不会发生,读取共享资源时不会加锁,允许对多线程同时读取共享资源,只在更新数据时检测冲突。
优点:
- 允许多线程同时访问共享资源,不会加锁,性能较好
- 不会引起死锁
缺点:
- 写多读少的场景中,频繁的重试会带来额外的性能开销
悲观锁
悲观锁的特点是假设冲突总会发生,无论读写操作,都会加锁,同一时间只允许一个线程访问共享资源。
优点:
- 只允许一个线程访问共享资源,避免了乐观锁在多线程写入时频繁重试带来的性能消耗
缺点:
- 无论读写都会加锁,实际上相当于单线程访问共享资源,会成为高并发系统的性能瓶颈
- 如果业务中存在多个锁,可能会导致死锁的发生
什么是 CAS?
难易程度:❤❤❤
重要程度:❤❤❤❤❤
面试公司:无
CAS(Compare And Swap),即比较替换。CAS 操作 3 个数:
- 内存值 V,即内存中共享资源的值;
- 旧的预期值 A,即线程从内存中读取到且未进行修改共享资源的值;
- 要修改的值 B,即线程进行修改后的共享资源的值。
只有当 V == A 时,才会将 V 的值更新为 B,否则什么都不做。
其原理是,如果内存中值与线程取出的值相同时,认为在这个期间并没有线程修改共享资源,因此可以对共享数据进行修改。但如果某个线程先将共享资源从 V 修改到 W,再将共享资源从 W修改会回 V,此时就导致了 ABA 问题。
真:乐观锁如何解决ABA问题?
难易程度:❤❤❤
重要程度:❤❤❤❤❤
面试公司:蚂蚁金服
ABA 问题是并发编程中的经典问题,通常在使用 CAS 时会产生 ABA 问题:
- 假设存在共享资源 A;
- 线程 T1 读取 A ;
- 线程 T2 读取 A 后,将其修改为 B,并写入内存;
- 线程 T3 读取 B 后,将其修改为 A,并写入内存;
- 线程 T1 将 A 修改为 C,并通过 CAS 将 C 写入内存。
看起来线程 T1 的修改路径是从 A -> C,实际上是从 A -> B -> A -> C。
为了解决 ABA 的问题,可以通过为共享资源添加版本号(或时间戳),写入操作时比较线程获取到共享资源的版本号与内存中共享资源的版本号是否一致,如果一致则允许更新共享资源,同时需要更新共享资源的版本号,否则不允许更新。
真:什么是公平锁?什么是非公平锁?
难易程度:❤❤❤
重要程度:❤❤❤❤❤
面试公司:美团
公平锁与非公平锁的区别在于获取锁的顺序上。公平锁按照线程申请锁的顺序,依次排队获取锁;非公平锁则不考虑申请顺序,当锁处于空闲状态时,线程可以直接“抢夺”锁的使用权。
公平锁按照申请顺序获取锁的使用权,正常情况下每个线程都能在可预期的时间内获取到锁;非公平锁可能会出现虽然线程申请锁的时间非常早,但始终无法抢占到锁,导致线程饥饿。
非公平锁的优点在于无需维护等待队列,在加锁和解锁的速度上优于公平锁。
Tips:在简单的非公平锁实现中,抢占锁失败的线程调用Object#wait
进入阻塞状态;释放锁后,调用Object#notifyAll
唤醒所有线程继续抢夺,无需引入等待队列。
真:什么是可重入锁?
难易程度:❤❤❤
重要程度:❤❤❤❤❤
面试公司:蚂蚁金服,美团
可重入锁,POSIX 标准中称为递归锁,指的是允许同一个线程多次获取同一个锁。可重入锁可以解决递归调用或嵌套调用中的死锁问题,例如:递归的方式删除指定路径下的所有文件:
private static void deleteFile(File directory) {
synchronized (LOCK) {
File[] files = directory.listFiles();
for (File subFile : files) {
if(subFile.isDirectory()) {
deleteFile(subFile);
} else {
subFile.delete();
}
}
}
}
如果 synchronized 不具备可重入性,那么在遇到第一个子文件夹时程序会被阻塞,导致程序无法继续进行。
可重入锁的设计中,通常会在内部维护一个计数器,每次进入可重入锁时计数器加1,退出时计数器减1,进入和退出的次数需要匹配。
Java 中提供了哪些锁?
难易程度:❤❤❤
重要程度:❤❤❤
面试公司:无
Java 中有 3 个常见的锁,分别是 synchronized,ReentrantLock,ReentrantReadWriteLock,以及一个“小众的的StampedLock。
特点 | synchronized | ReentrantLock | ReentrantReadWriteLock | StampedLock |
---|---|---|---|---|
公平锁 | 非公平锁 | 公平模式/非公平模式 | 公平模式/非公平模式 | 非公平锁 |
可重入性 | 可重入 | 可重入 | 可重入 | 可重入 |
乐观锁 | 悲观锁 | 悲观锁 | 悲观锁 | 乐观锁 |
互斥锁 | 是 | 是 | ReadLock 共享/WriteLock 互斥 | ReadLock 共享/WriteLock 互斥 |
真:如果让你实现锁,你有什么思路?
难易程度:❤❤❤❤
重要程度:❤❤❤❤❤
面试公司:蚂蚁金服,美团
首先考虑使用场景,读多写少可以选择读写锁(读共享,写互斥),写多读少可以直接选择互斥锁;其次添加特性,如果需要支持递归加锁或嵌套加锁就需要添加可重入性,选择公平性或非公平性,最后还可以考虑加锁失败时的设计,如果锁定的临界区非常“小”,锁会在极短时间内释放,可以考虑使用自旋,否则可以选择阻塞。
抛砖引玉,我这里使用 AQS 完成了一个非公平,不可重入的互斥锁:
public class MutexLock {
public void lock() {
sync.acquire(1);
}
public void unlock() {
sync.release(0);
}
private final Sync sync = new Sync();
static class Sync extends AbstractQueuedSynchronizer {
@Override
protected boolean tryAcquire(int arg) {
Thread currentThread = Thread.currentThread();
if (compareAndSetState(0, arg)) {
setExclusiveOwnerThread(currentThread);
return true;
} else {
return false;
}
}
@Override
protected boolean tryRelease(int arg) {
if (getState() != 1) {
return false;
}
setState(arg);
setExclusiveOwnerThread(null);
return true;
}
}
}
Tips:AQS 的问题我们后面还会再讨论。
参考资料
- JSR-133: Java Memory Model and Thread Specification
- JSR 133 (Java Memory Model) FAQ
- 深入理解 Java 内存模型
- 深入理解JMM和Happens-Before
- 一文看懂并发编程中的锁
如果本文对你有帮助的话,还请多多点赞支持。如果文章中出现任何错误,还请批评指正。最后欢迎大家关注分享硬核 Java 技术的金融摸鱼侠王有志,我们下次再见!