Java SE 多线程并发:锁策略、JUC 与原理
本文介绍了 Java SE 多线程的核心概念,涵盖常见锁策略(乐观/悲观、轻量/重量级、自旋、公平/非公平、可重入、读写锁)、死锁成因及避免方法(如哲学家就餐问题、锁排序)、JUC 包常用类(Callable、ReentrantLock、原子类、信号量、CountDownLatch),以及 synchronized 锁升级原理和 CAS 机制及其 ABA 问题。旨在帮助开发者理解并发编程基础,掌握线程安全控制手段。

本文介绍了 Java SE 多线程的核心概念,涵盖常见锁策略(乐观/悲观、轻量/重量级、自旋、公平/非公平、可重入、读写锁)、死锁成因及避免方法(如哲学家就餐问题、锁排序)、JUC 包常用类(Callable、ReentrantLock、原子类、信号量、CountDownLatch),以及 synchronized 锁升级原理和 CAS 机制及其 ABA 问题。旨在帮助开发者理解并发编程基础,掌握线程安全控制手段。

以下介绍的锁策略不仅局限于 Java,这些性质通常也是给锁的实现者参考的。了解它们对使用锁也有帮助。
悲观锁: 总是假设最坏的情况,每次拿数据时都认为别人会修改,所以每次都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。
乐观锁: 假设数据一般情况下不会产生并发冲突,所以在数据进行提交更新的时候,才会正式对数据是否产生并发冲突进行检测。如果发现并发冲突了,则返回错误信息,让用户决定如何去做。
示例:同学 A 和 B 想请教老师问题。
Synchronized 初始使用乐观锁策略。当发现锁竞争比较频繁的时候,就会自动切换成悲观锁策略。
轻量级锁: 当一个线程尝试获取某个对象的锁时,如果该对象没有被其他线程锁定,则当前线程会将对象头中的 Mark Word 设置为指向当前线程栈帧的一个指针,这个过程称为'偏向锁'。如果多个线程同时竞争同一个锁,那么 JVM 会升级锁的状态,从偏向锁升级到轻量级锁。此时,每个线程都会尝试使用 CAS 操作来获取锁,如果成功则获得锁并进入临界区;如果失败,则自旋等待一段时间后再次尝试。
特点:
重量级锁: 传统的 Java 锁机制,如 synchronized 关键字所实现的锁,通常被称为重量级锁。当一个线程获取了某个对象的锁后,其他试图获取同一对象锁的线程会被阻塞,直到第一个线程释放锁为止。被阻塞的线程将进入等待队列,由操作系统负责管理这些线程的调度。
特点:
按之前的方式,线程在抢锁失败后进入阻塞状态,放弃 CPU,需要过很久才能再次被调度。但实际上,大部分情况下,虽然当前抢锁失败,但过不了很久,锁就会被释放。没必要就放弃 CPU。这个时候就可以使用自旋锁来处理这样的问题,如果获取锁失败,立即再尝试获取锁,无限循环,直到获取到锁为止。
优点:没有放弃 CPU,不涉及线程阻塞和调度,一旦锁被释放,就能第一时间获取到锁。 缺点:如果锁被其他线程持有的时间比较久,那么就会持续的消耗 CPU 资源。(而挂起等待的时候是不消耗 CPU 的)。
假设三个线程 A, B, C。A 先尝试获取锁,获取成功。然后 B 再尝试获取锁,获取失败,阻塞等待;然后 C 也尝试获取锁,C 也获取失败,也阻塞等待。当线程 A 释放锁的时候,会发生什么呢?
公平锁: 遵守'先来后到'。B 比 C 先来的。当 A 释放锁之后,B 就能先于 C 获取到锁。
非公平锁: 不遵守'先来后到'。B 和 C 都有可能获取到锁。
注意:
可重入锁: 简单来说就是一个线程如果抢占到了互斥锁资源,在锁释放之前再去该竞争同一把锁的时候,不需要等待,只需要记录重入次数。在多线程并发编程里面,绝大部分锁都是可重入的,比如 Synchronized、ReentrantLock 等,但是也有不支持重入的锁,比如 JDK8 里面提供的读写锁 StampedLock。锁的可重入性,主要解决的问题是避免线程死锁的问题。
多线程之间,数据的读取方之间不会产生线程安全问题,但数据的写入方互相之间以及和读者之间都需要进行互斥。如果两种场景下都用同一个锁,就会产生极大的性能损耗。所以读写锁因此而产生。
读写锁(readers-writer lock),在执行加锁操作时需要额外表明读写意图,复数读者之间并不互斥,而写者则要求与任何人互斥。
一个线程对于数据的访问,主要存在两种操作:读数据和写数据。
读写锁就是把读操作和写操作区分对待。Java 标准库提供了 ReentrantReadWriteLock 类,实现了读写锁。
ReentrantReadWriteLock.ReadLock 类表示一个读锁。这个对象提供了 lock / unlock 方法进行加锁解锁。 ReentrantReadWriteLock.WriteLock 类表示一个写锁。这个对象也提供了 lock / unlock 方法进行加锁解锁。 其中:
读写锁特别适合于'频繁读,不频繁写'的场景中。
什么是死锁? 死锁是这样一种情形:多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止。
示例:吃饺子需要酱油和醋。我拿起酱油瓶,对象拿起醋瓶。我让你先把醋给我,你用完了把酱油给我;对象让你先把酱油给我,你用完了把醋给我。如果我们俩彼此之间互不相让,就构成了死锁。 酱油和醋相当于是两把锁,我们两个人就是两个线程。
有个桌子,围着一圈哲学家,桌子中间放着一盘意大利面。每个哲学家两两之间,放着一根筷子。
每个哲学家只做两件事:思考人生或者吃面条。思考人生的时候就会放下筷子。吃面条就会拿起左右两边的筷子(先拿起左边,再拿起右边)。
如果哲学家发现筷子拿不起来了,就会阻塞等待。 关键点:如果 5 位哲学家同时拿起左手边的筷子时,然后再尝试拿右手的筷子,就会发现右手的筷子都被占用了。由于哲学家们互不相让,这个时候就形成了死锁。
死锁是一种严重的 BUG,导致一个程序的线程'卡死',无法正常工作!
死锁产生的四个必要条件:
当上述四个条件都成立的时候,便形成死锁。当然,死锁的情况下如果打破上述任何一个条件,便可让死锁消失。
破坏循环等待 最常用的一种死锁阻止技术就是锁排序。假设有 N 个线程尝试获取 M 把锁,就可以针对 M 把锁进行编号 (1, 2, 3…M)。 N 个线程尝试获取锁的时候,都按照固定的按编号由小到大的顺序来获取锁。这样就可以避免环路等待。
可能产生循环等待死锁的代码:
public class Test {
private static Object locker1 = new Object();
private static Object locker2 = new Object();
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
synchronized (locker1) {
synchronized (locker2) {
System.out.println("this is thread t1");
}
}
});
Thread t2 = new Thread(() -> {
synchronized (locker2) {
synchronized (locker1) {
System.out.println("this is thread t2");
}
}
});
t1.start();
t2.start();
}
}
不会产生循环等待的代码:
public class Test {
private static Object locker1 = new Object();
private static Object locker2 = new Object();
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
synchronized (locker1) {
synchronized (locker2) {
System.out.println("this is thread t1");
}
}
});
Thread t2 = new Thread(() -> {
synchronized (locker1) {
synchronized (locker2) {
System.out.println("this is thread t2");
}
}
});
t1.start();
t2.start();
}
}
Callable 是一个 interface。 相当于把线程封装了一个'返回值'。方便我们借助多线程的方式计算结果。
代码示例:创建线程计算 1 + 2 + 3 + … + 1000,使用 Callable 版本。
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
public class Test {
public static void main(String[] args) throws ExecutionException, InterruptedException {
Callable<Integer> callable = new Callable<Integer>() {
@Override
public Integer call() throws Exception {
int result = 0;
for (int i = 0; i <= 1_000; i++) {
result += i;
}
return result;
}
};
FutureTask<Integer> futureTask = new FutureTask<>(callable);
Thread t = new Thread(futureTask);
t.start();
System.out.println(futureTask.get());
}
}
// 输出:500500
理解 Callable Callable 和 Runnable 相对,都是描述一个'任务'。Callable 描述的是带有返回值的任务,Runnable 描述的是不带返回值的任务。 Callable 通常需要搭配 FutureTask 来使用。FutureTask 用来保存 Callable 的返回结果。因为 Callable 往往是在另一个线程中执行的,啥时候执行完并不确定。FutureTask 就可以负责这个等待结果出来的工作。
理解 FutureTask 想象去吃麻辣烫。当餐点好后,后厨就开始做了。同时前台会给你一张'小票'。这个小票就是 FutureTask。后面我们可以随时凭这张小票去查看自己的这份麻辣烫做出来了没。
可重入互斥锁。 和 synchronized 定位类似,都是用来实现互斥效果,保证线程安全。
ReentrantLock 的用法:
ReentrantLock 和 synchronized 的区别:
如何选择使用哪个锁?
原子类内部用的是 CAS 实现,所以性能要比加锁实现 i++ 高很多。原子类有以下几个:
以 AtomicInteger 举例,常见方法有:
addAndGet(int delta); // i += delta;
decrementAndGet(); // --i;
getAndDecrement(); // i--;
incrementAndGet(); // ++i;
getAndIncrement(); // i++;
信号量,用来表示'可用资源的个数'。本质上就是一个计数器。
示例:可以把信号量想象成是停车场的展示牌:当前有车位 100 个。表示有 100 个可用资源。 当有车开进去的时候,就相当于申请一个可用资源,可用车位就 -1 (这个称为信号量的 P 操作,P 是荷兰语单词首字母)。 当有车开出来的时候,就相当于释放一个可用资源,可用车位就 +1 (这个称为信号量的 V 操作,V 是荷兰语单词首字母)。 如果计数器的值已经为 0 了,还尝试申请资源,就会阻塞等待,直到有其他线程释放资源。
Semaphore 的 PV 操作中的加减计数器操作都是原子的,可以在多线程环境下直接使用。
代码示例:
import java.util.concurrent.Semaphore;
public class Test {
public static void main(String[] args) {
// 创建 Semaphore 示例,初始化为 4,表示有 4 个可用资源。
// acquire 方法表示申请资源 (P 操作), release 方法表示释放资源 (V 操作)
// 创建 20 个线程,每个线程都尝试申请资源,sleep 1 秒之后,释放资源。观察程序的执行效果。
Semaphore semaphore = new Semaphore(4);
Runnable runnable = new Runnable() {
@Override
public void run() {
System.out.println("apply");
try {
semaphore.acquire();
System.out.println("successful");
Thread.sleep(10000);
} catch (InterruptedException e) {
e.printStackTrace();
}
semaphore.release();
System.out.println("release");
}
};
for (int i = 0; i < 20; i++) {
Thread t = new Thread(runnable);
t.start();
}
}
}
同时等待 N 个任务执行结束
例如:好像跑步比赛,10 个选手依次就位,哨声响才同时出发;所有选手都通过终点,才能公布成绩。
代码示例:
import java.util.Random;
import java.util.concurrent.CountDownLatch;
public class Test {
public static void main(String[] args) throws InterruptedException {
CountDownLatch count = new CountDownLatch(10);
Random random = new Random();
Runnable runnable = new Runnable() {
@Override
public void run() {
try {
Thread.sleep(random.nextInt(5000));
count.countDown();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
};
for (int i = 0; i < 10; i++) {
Thread t = new Thread(runnable);
t.start();
}
// 必须等到 10 人全部回来
count.await();
System.out.println("game is over");
}
}
首先,我们总结一下 synchronized 锁的基本特性:
JVM 将 synchronized 锁分为无锁、偏向锁、轻量级锁、重量级锁 状态。会根据情况,进行依次升级。
偏向锁 第一个尝试加锁的线程,优先进入偏向锁状态。 偏向锁不是真的'加锁',只是给对象头中做一个'偏向锁的标记',记录这个锁属于哪个线程。如果后续没有其他线程来竞争该锁,那么就不用进行其他同步操作了 (避免了加锁解锁的开销),如果后续有其他线程来竞争该锁 (刚才已经在锁对象中记录了当前锁属于哪个线程了,很容易识别当前申请锁的线程是不是之前记录的线程),那就取消原来的偏向锁状态,进入一般的轻量级锁状态。 偏向锁本质上相当于'延迟加锁'。能不加锁就不加锁,尽量来避免不必要的加锁开销。但是该做的标记还是得做的,否则无法区分何时需要真正加锁。
轻量级锁 随着其他线程进入竞争,偏向锁状态被消除,进入轻量级锁状态 (自适应的自旋锁)。
重量级锁 如果竞争进一步激烈,自旋不能快速获取到锁状态,就会膨胀为重量级锁。
锁消除 有些应用程序的代码中,用到了 synchronized,但其实没有在多线程环境下,编译器+JVM 会判断锁是否可消除。如果可以,就直接消除,避免白白浪费了一些资源开销。
锁粗化 一段逻辑中如果出现多次加锁解锁,编译器+JVM 会自动进行锁的粗化。
示例:滑稽老哥当了领导,给下属交代工作任务。 方式一:打电话,交代任务 1,挂电话。打电话,交代任务 2,挂电话。打电话,交代任务 3,挂电话。 方式二:打电话,交代任务 1,任务 2,任务 3,挂电话。 明显方式二的效率更高。
CAS: 全称 Compare and swap,字面意思:'比较并交换',一个 CAS 涉及到以下操作:
我们假设内存中的原数据 V,旧的预期值 A,需要修改的新值 B。
ABA 问题就好比,我们买一个手机,无法判定这个手机是刚出厂的新手机,还是别人用旧了,又翻新过的手机。

微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
查找任何按下的键的javascript键代码、代码、位置和修饰符。 在线工具,Keycode 信息在线工具,online
JavaScript 字符串转义/反转义;Java 风格 \uXXXX(Native2Ascii)编码与解码。 在线工具,Escape 与 Native 编解码在线工具,online
使用 Prettier 在浏览器内格式化 JavaScript 或 HTML 片段。 在线工具,JavaScript / HTML 格式化在线工具,online
Terser 压缩、变量名混淆,或 javascript-obfuscator 高强度混淆(体积会增大)。 在线工具,JavaScript 压缩与混淆在线工具,online
使用加密算法(如AES、TripleDES、Rabbit或RC4)加密和解密文本明文。 在线工具,加密/解密文本在线工具,online
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online