前言
多线程编程是软件开发中最棘手的问题之一。无论什么时候用多线程来操作同一份数据,都会出现并发问题。这就让写线程安全的代码成为一件非常困难的事情。本文将会从并发问题的本质出发,带你了解问题的根源,然后通过实例阐述保证线程安全的难点在哪里,最后提供常见的并发控制技术的原理和例子,希望能够帮助读者更好地写出线程安全的代码。
术语解释
- 线程安全:指多线程编程时线程访问共享变量时的结果符合预期,不会出现并发问题。例如Java中的HashMap在多线程同时读写时会报错,所以它是线程不安全的,而ConcurrentHashMap能够支持多线程并发读写,所以它是线程安全的。
- 竞争资源:指多线程编程中会被多个线程同时使用到的变量。例如全局变量、文件、数据库连接等。
并发问题的本质
我们平时会遇到的并发问题五花八门,但是归根结底可以归类为两类基本问题:
- 不一致写(更新丢失)
- 不一致读(数据冲突)
不一致写
不一致写也叫做更新丢失,指的是同一个资源被两个操作者A和B分别更新,AB都不知道对方修改了什么,最后无论他们后提交,都会把前一个的数据覆盖,导致另一个的数据丢失。举个例子:如下图所示,线程A和线程B分别从数据库中读取到记录R=“hello”,A先于B提交。他们的操作都是在已有数据的基础上新增自己的英文名称,也就是说预期的结果是R=“hello, A, B”,但实际上他们最后执行的结果却是R=“hello, B”,即A的数据更新丢失了。

不一致读
不一致读也叫做数据冲突,指的是操作者分别读取两份以上正确的数据,在同一个时间内却有矛盾。举个例子:如下图所示,线程A先读取了m=1,接着线程B就更新数据:m=2, n=2,然后线程A继续读取n=2,最后计算的结果是3。从逻辑上说,线程A计算出的结果应该要么是2,要么是4,即m和n之间要保持一致性才对,结果却是3。这就是不一致读的一个典型例子。

以上两个问题都是在并发的情况才会发生,这就是我们为了提升程序性能所付出的代价。多线程并发可以提升性能,但是也同样带来并发问题。
保证线程安全的难点
多线程编程为什么难?相比单线程编程,多线程需要额外考虑并发问题,代码逻辑的复杂度上升了一个数量级。而我们大脑的算力是有限的,单线程编程时都很难将所有情况都考虑周全,更不用说多线程了。下面我列举一些常见的难点:
- 识别竞争资源很难
- 性能优化让分支越来越多
- 并发问题难以复现
识别竞争资源难
有人可能会说多线程编程很简单,把所有的竞争资源都加锁就行。实际上这个策略是非常有效且常用的,但难就难在识别哪些是竞争资源。可以说大部分的并发问题都是因为写代码的时候忘了给变量加锁导致的,即没有识别出所有的竞争资源。
下面我出两道代码题让读者体会一下写线程安全代码的困难之处。
首先是一道比较简单的并发问题,你能在一分钟内看出下面的代码有什么问题吗?
public class LockTest {
public static Integer m = 1;
public static Integer n = 1;
public static void main(String[] args) {
for (; ; ) {
//线程A
CompletableFuture.runAsync(() -> {
m++;
});
//线程B
CompletableFuture.runAsync(() -> {
n++;
});
//线程C
CompletableFuture.runAsync(() -> {
System.out.println(m + n);
});
}
}
}
显然上面的代码没有对m和n加锁,线程C的计算结果会有问题。这里因为文章有限,我把m和n都放到一个代码里面,所以你能一眼就知道这段代码有两个竞争资源,但现实情况往往是m和n分布在项目代码中的不同类中,很容易遗漏。如果是多个人一起开发同一个项目,那么就更乱了,你写代码的时候可能都不知道m被你的同事开了个线程做了修改。
接下来是一道稍微难一点的题目,你在一分钟内看出问题吗?
public class LockTest {
public static Integer m = 1;
public static Integer n = 1;
public static void main(String[] args) {
for (; ; ) {
//线程A
CompletableFuture.runAsync(() -> {
synchronized (m) {
synchronized (n) {
m++;
n++;
}
}
});
//线程B
CompletableFuture.runAsync(() -> {
synchronized (n) {
synchronized (m) {
System.out.println(m + n);
}
}
});
}
}
}
答案是上面的代码会导致死锁,看不出问题的读者可以在电脑上运行一下感受一下死锁。因为线程A和线程B对m和n的加锁顺序不一样,当线程A获取到m的锁且线程B获取到n锁时,它们在互相等待对方持有的锁,结果就是两个线程都无法往下执行了。只需要保证两个线程对m和n的加锁顺序是一致的就不会出现死锁。第一次接触死锁的读者可能怎么都想不到,虽然m和n被我们识别到是竞争资源了,但还是漏了加锁顺序的问题,从某种意义上说加锁顺序也是一种“竞争资源”。同样考虑多人同时开发的情形,你的同事写多线程代码的时候很可能加锁顺序就是跟你反着的,这种加锁顺序的问题即使CR也很难发现,毕竟不是所有人都能把几十万代码的细节都记得清清楚楚。到这里读者应该能稍微感觉到识别竞争资源的困难了。
最后上一道比较难的题目,这个是我自己亲身踩过的坑。
public class LockTest {
public static Integer m = 1;
public static void main(String[] args) throws Exception {
RandomAccessFile file = new RandomAccessFile("test.txt", "rw");
for (; ; ) {
//线程A写入m到文件中
CompletableFuture.runAsync(() -> {
try {
synchronized (file) {
m++;
//seek会改变文件指针,所以要加锁
file.seek(file.length());
file.writeInt(m);
}
} catch (IOException e) {
e.printStackTrace();
}
});
//线程B读取文件最后写入的数字m的值
CompletableFuture.runAsync(() -> {
try {
synchronized (file) {
//seek会改变文件指针,所以要加锁
file.seek(file.length() - 4);
System.out.println(file.readInt());
}
} catch (IOException e) {
e.printStackTrace();
}
});
//线程C打印当前文件的长度
CompletableFuture.runAsync(() -> {
try {
//读取文件长度,没有写操作,为了提高性能所以不加锁
System.out.println(file.length());
} catch (IOException e) {
e.printStackTrace();
}
});
}
}
}
最后一道题需要了解文件的操作API才能理解代码在做什么。我简单解释一下:
- 线程A先对file加锁,然后把文件指针切换到文件尾部,把m的值写入到文件的末尾。
- 线程B先对file加锁,然后把文件指针切到文件尾部前4位,因为int是4个字节,这样就能读取文件最后一个写入的m的值。
- 线程C负责打印文件的长度。它没有对file加锁,因为length方法是读操作,为了提高性能,我们一般不对读操作加锁。
你可以在电脑上面运行一下这段代码,如果你的电脑是用JDK8及以下版本运行的话,会出现EOFException的异常;如果是JDK8以上版本就不会出现报错。很神奇是不是?下面我揭晓一下答案。
RandomAccessFile的length()在JDK8及以下版本是线程不安全的,因为它的代码实现里面调用了三次seek操作(改变文件指针的写操作),代码可以看:http://hg.openjdk.java.net/jdk8u/jdk8u/jdk/file/7fcf35286d52/src/share/native/java/io/RandomAccessFile.c。后来有人给官方提了bug:https://bugs.openjdk.java.net/browse/JDK-4823133,于是在JDK9版本把这个问题修复了。
当时遇到这个问题的时候我就定位到是读取的时候文件指针被修改了,所以以为哪里忘了加锁,于是我把代码各个地方的写操作都翻了个遍,加了各种日志也没有找到原因,最后才怀疑是不是JDK源码有bug。一开始都没有怀疑到RandomAccessFile的length的方法,因为我先入为主的认为length是读操作,不可能去修改文件指针。最后我实在是没办法了,把所有可能性排除之后,最不可能的事情也要怀疑,所以才谷歌找到了原因,发现果然是JDK的bug。找到原因之后就很简单了,JDK8及以下的版本就让线程C给file加锁,JDK8以上版本就不需要修改。
所有的bug在查出来之后我们都会想:这么简单的原因也要查这么久?原因就在于当局者迷,当时我花了一周左右时间来查这个bug,一度放弃过,把多线程改成单线程运行,隔了一段时间后我重整旗鼓才排查出来。上面的例子如果用单线程的方式来实现可以说非常简单,当时我为了支持提高文件读写的性能所以改成了多线程的方式。但天下没有免费的午餐,即使我想尽办法把代码中的竞争资源都识别出来,用锁保证线程安全的访问,也还是避免不了出bug。因为不光你的代码会出bug,你依赖的一切代码包括JDK的库也会出bug。因此识别竞争资源并不是一件简单的事情,有时候光看自己的代码还不够,还需要了解底层依赖的代码。从这个例子中我们可以深切体会到要写出线程安全的代码有多难。
性能优化让分支越来越多
前面我提到的难点都是找齐竞争资源有难度,当你找齐竞争资源并且通过并发控制来避免并发问题之后,往往还想做的更好,进一步优化代码性能,这个时候就经常会出现问题。这一节主要就是想提醒大家:天下没有免费的午餐,性能优化是一件好事,但是性能优化意味着更多的代码逻辑分支,需要考虑的情况越来越多,想要通过人脑直接推演出所有情况基本不可能,所以并发问题的出现就基本不可避免。
上面提到的RandomAccessFile的并发问题就是一个很好的例子,我为了优化性能,对线程C的操作没有进行加锁,结果就踩到了JDK的bug。接下来我还会再列举两个案例,让大家更好的理解本节的观点。
第一个是双重加锁校验的问题,代码的逻辑是:
- 线程A对m进行自增操作。
- 线程B打印m小于100时的值,打印的时候要加锁,防止m有变化。
- 线程C跟线程B做的事情类似,不同的是为了减少线程C对锁的争抢,代码里面做了一个优化:先判断m是不是小于100,然后再加锁。
package com;
import java.util.concurrent.CompletableFuture;
public class LockTest {
public static Integer m = 1;
public static void main(String[] args) throws Exception {
for (; ; ) {
//线程A
CompletableFuture.runAsync(() -> {
synchronized (m) {
m++;
}
});
//线程B
CompletableFuture.runAsync(() -> {
synchronized (m) {
if (m < 100) {
System.out.println(m);
}
}
});
//线程C
CompletableFuture.runAsync(() -> {
if (m < 100) {
synchronized (m) {
System.out.println(m);
}
}
});
}
}
}
代码中的线程C会出现并发问题,如果你看不出来可以尝试到电脑上运行一下代码,多运行几次你会发现线程C会打印出超过100的值,而线程B却不会。这是一个很典型的由于性能优化导致的并发问题。线程C把条件判断放到了加锁之前,这样确实能够避免在m大于100之后去争抢锁,达成性能优化的目的,但是却导致了不一致读:线程C在判断条件之后如果发生线程调度,那么其他线程就会修改m的值,导致m可能大于100。
那么正确的做法是什么呢?答案是双重加锁校验,在加锁之后重新做一次判断,这样就能保证逻辑的正确性。参考下面的代码片段。
//线程C
CompletableFuture.runAsync(() -> {
if (m < 100) {
synchronized (m) {
if (m < 100) {
System.out.println(m);
}
}
}
});
第二个案例是线程池的并发问题。下面的代码模拟了一个常见的使用线程池并发调用远程服务的场景,remoteCall模拟了三种远程调用的情况:成功、失败、超时。你能从中找到多少个问题?
public class LockTest {
public static Integer m = 0;
public static void main(String[] args) throws Exception {
final List<String> results = new ArrayList<>();
for (; ; ) {
CompletableFuture.runAsync(() -> {
m++;
results.add(remoteCall(m));
}).get(100, TimeUnit.MILLISECONDS);
System.out.println(results);
}
}
public static String remoteCall(int m) {
if (m == 1) {
return "SUCCESS";
} else if (m == 2) {
return "FAIL";
} else if (m == 3) {
try {
Thread.sleep(110);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
return null;
}
}
答案是3个问题:
- 线程安全问题:results使用的是ArrayList,它不是线程安全的容器,在线程池并发add的时候会出现异常。
- 超时异常没有处理:get(100, TimeUnit.MILLISECONDS)是会抛出超时异常的,代码中没有进行处理。
- 超时时间设置不合理:remoteCall 的超时时间是110ms,而线程池的超时时间是100ms,如果下游的超时超过了上游,那么上游一定会被下游拖垮。
线程池是我们常用的提升性能的工具,但是想用好线程池并不是简单的事情,这里不仅涉及到多线程变量共享的问题,还涉及异步转同步的处理,稍有不慎就会引入bug。
并发问题难复现
可以发现,引入了多线程之后,需要考虑的情况多了很多,一不小心就会掉进坑里。上面我提供的案例都是简化过的模型,所以能够一眼就看出问题,而实际情况往往是代码发布到了线上才发现问题,而且也非常难复现,这就是我们接下来要讨论的多线程编程的难点:并发问题难复现。
并发问题难复现的原因在于bug的触发条件很苛刻,多线程代码运行的时候不同线程执行代码顺序是不可控的,有了这个随机因子之后,并发问题的复现难度大大增加。比如前面提到的双重加锁校验的问题,复现的概率极低,特别是线上代码有问题的时候,如果用户请求的访问量不大,根本就复现不出来,极大的影响了排查效率。
综上所述,要写线程安全的代码是一件非常困难的事情,程序员不仅要为了无厘头的业务逻辑绞尽脑汁,还要为了提升性能引入多线程掉光头发。
保证线程安全的原则
虽然多线程编程是一只难以征服的恶龙,但是我们也有无数先人传承下来的屠龙技。接来下我会先介绍一些原则和常见的实践:
-
不变性:
-
用复制替代引用
-
尽量使用不可变对象
-
隔离:
-
CAS操作
-
线程变量
-
互斥
不变性
并发问题的根源在于共享的状态是可变的,所以一个非常简单的思路就是让共享的状态不可变。当然系统中的数据不可能都是不可变的,我们要做的就是尽可能减少可变的数据,这样就能够减少出现并发问题的概率。在多线程编程时,我们要时刻提醒自己减少可变状态,下面是两个常用的最佳实践:用复制替代引用和尽量使用不可变对象。
用复制替代引用
在Java函数的对象传参都是引用,这样做的好处是可以节省内存空间,提升性能;坏处是参数是共享的,A线程对参数做了修改,B线程也会受到影响。在单线程的代码中这是一个非常好的特性,但是在多线程代码中这就会经常导致并发问题。
下面我举一个例子来说明:假设我们有一个缓存,线程A和线程B都会往里面放数据,主线程会读取缓存里面的数据,运行代码我们会发现从缓存里面读取出来的A值居然会变化。这是因为cache变量一直再被其他线程修改。
public static void main(String[] args) throws Exception {
Map<String, String> cache = new ConcurrentHashMap<>();
//线程A
CompletableFuture.runAsync(() -> {
for (; ; ) {
cache.put("A", "A");
}
});
//线程B
CompletableFuture.runAsync(() -> {
for (; ; ) {
cache.put("A", "B");
}
});
for (int i = 0; i < 100; i++) {
System.out.println(cache.get("A"));
}
}
那么我们要怎样修改才能保证主线程读取cache的值不会变化?你可能会想到给cache加锁,但这不是最优的做法,因为我们的目的是在一次请求中cache的数据不会变化,用加锁的方式虽然可以解决这个问题,但是性能太差,主线程读取时其他线程都无法正常工作。最优的做法其实是在读取时给缓存做一份快照,即复制一个缓存对象,这样不管原始的变量怎么被修改,都不会影响到复制的对象。具体的修改如下所示:
Map<String, String> cacheCopy = new ConcurrentHashMap<>(cache);
for (int i = 0; i < 100; i++) {
System.out.println(cacheCopy.get("A"));
}
尽量使用不可变对象
使用全局变量经常被人称为代码的坏味道,只有一种例外,那就是不可变的全局变量,即常量。常量是我们经常使用的变量,在Java主要用final进行声明,这样它的引用就不会发生变化。不仅是常量,很多成员变量也可以声明成final。因为不可变对象不会被修改,所以多线程可以安全访问它,不用担心出现并发问题。为了避免出现线程安全问题,写代码的时候尽可能把运行时不会修改的对象声明成不可变的,这种约束可以让其他人放心的使用。
Java中变量默认是可修改的,如果要声明成不可变的要加上final关键字。而在Rust中,变量默认是不可变的,如果需要修改,需要加上mut关键字。从这两种语言的设计哲学来看,相比Rust来说Java给了程序员更多的灵活性,但是也埋下了并发问题的祸根,而Rust则相反,为了尽量避免程序员使用不必要的可变对象,从语言层面就是杜绝了这种情况,程序员需要自己将可变对象声明出来,这样编译器就能帮助程序员排查线程安全问题,但是也让语言的学习门槛提高了很多。这两种语言设计思想没有谁对谁错,还是要看适用场景。不管是用什么语言,对于程序员而言,写代码的时候心里有这个意识就能够显著提升代码质量。
隔离
上面提到的不变性原则主要是通过减少使用可变对象来提升代码的并发安全性,但可变对象总是不可避免要被共享使用的,所以对于可变对象的并发控制主要思路就是隔离。我们知道并发问题的产生源于多线程同时访问同一个对象,让这个共享对象同一时间只有一个线程能够访问就能避免并发问题。基于这个思路,我们可以用隔离来保证线程安全。隔离是将共享数据独立到一个区间中,同一个时间只有一个线程能够访问隔离区。
很多并发控制技术都是基于隔离的思想,例如:
- CAS操作
- 线程变量
- 互斥
CAS操作
思考一个非常简单的并发问题:如何实现一个线程安全的计数器?有人会说对自增操作进行加锁就行,例如下面的代码:
public class Counter {
private volatile int c = 0;
public synchronized void incr() {
c++;
}
public int getCounterNum() {
return c;
}
public static void main(String[] args) throws ExecutionException, InterruptedException {
Counter counter = new Counter();
//线程A
CompletableFuture.runAsync(() -> {
for (int i = 0; i < 10000; i++) {
counter.incr();
}
});
//线程B
CompletableFuture.runAsync(() -> {
for (int i = 0; i < 10000; i++) {
counter.incr();
}
});
Thread.sleep(1000);
System.out.println(counter.getCounterNum());
}
}
上面的代码用synchronized对自增操作进行加锁,相当于在incr方法创造了一个隔离区,同一个时间只有一个线程能够拿到锁进入隔离区,这样就保证了线程安全。
那么有没有性能更优的实现方式?答案是肯定的。计数器的incr方法实际上做了两步操作:

- 读取当前c的值c1然后加1得到新值c2
- 替换当前的旧值c为新值c2
并发的问题就在于在这两步中间如果有其他线程修改了计数值,就会导致不一致读,即我们前面提到的数据冲突问题。本质的问题在于第二步更新的时候我们不知道最新的值是什么,所以会导致数据冲突。那么怎样避免这个问题呢?一个简单的思路就是在更新的时候检查一下最新值跟第一步读取到的旧值是否相等,如果相等说明数据没有冲突可以继续更新;如果数据不相等说明有其他线程更新数据了,就放弃更新。所以新的操作是

- 读取当前c的值然后加1得到新值
- 先判断c是否等于c1旧值,如果是就替换当前的旧值为新值,如果不是就不操作。
经过修改之后,我们能够保证c在数据不冲突的情况能够正常更新,但还有一个问题是当数据冲突了怎么办?毕竟我们的目的是完成一次自增。聪明的读者可能想到了:重试。因为我们已经能够保证一次安全的自增,当有冲突的时候我们就重试一次,总有一次能够重试成功。所以经过再次优化的算法如下:

- 读取当前c的值然后加1得到新值
- 先判断c是否等于c1旧值,如果是就替换当前的旧值为新值,如果不是就从第一步重新开始直到成功。
到这里我们就成功了吗?还有一个问题一直悬而未决:第二步的操作实际上是分为判断和更新两个步骤,如果在判断和更新的中间出现了线程调度怎么办?有一个解决方案就是让第二步的判断和更新操作成为原子操作,也就是让上面图中的蓝色区域成为隔离区。到这里我们终于引入了本节的主角:CAS。
CAS是Compare and swap的简称,CAS有3个操作数,内存值V,旧的预期值A,要修改的新值B。当且仅当预期值A和内存值V相同时,将内存值V修改为B,否则什么都不做。在多核CPU中一般都会支持CAS操作,例如Intel的cmpxchg 指令,这个指令是硬件级别的操作,在硬件层面保证了操作的原子性。Java中的sun.misc.Unsafe类提供了compareAndSwapInt和compareAndSwapLong等几个方法实现CAS。
基于CPU指令的CAS操作实际上是创造了一个指令级别的隔离区,相比其他的加锁实现,CAS的隔离区最小,因此性能也是最好的,因而也成为了很多并发控制技术的基础。
下面我提供一个基于CAS的计数器实现:
public class Counter {
private AtomicInteger c = new AtomicInteger(0);
public void incr() {
c.incrementAndGet();
}
public int getCounterNum() {
return c.get();
}
public static void main(String[] args) throws ExecutionException, InterruptedException {
Counter counter = new Counter();
//线程A
CompletableFuture.runAsync(() -> {
for (int i = 0; i < 10000; i++) {
counter.incr();
}
});
//线程B
CompletableFuture.runAsync(() -> {
for (int i = 0; i < 10000; i++) {
counter.incr();
}
});
Thread.sleep(1000);
System.out.println(counter.getCounterNum());
}
}
上面的代码使用了Java自带的原子类AtomicInteger ,自增操作用的是incrementAndGet方法,底层是Unsafe.getAndAddInt方法,用的正是我们前面提到的算法:尝试CAS操作直到成功。
public final int getAndAddInt(Object o, long offset, int delta) {
int v;
do {
v = getIntVolatile(o, offset);//读取最新的值
} while (!weakCompareAndSetInt(o, offset, v, v + delta)); //循环直到CAS成功
return v;
}
CAS操作本质上是做了比较和替换两个操作,CAS操作是CPU层面保证的隔离,这个原子操作执行的过程中不会被其他线程干扰。相比互斥这种严重影响性能的手段,CAS操作非常快。但CAS的缺点是只能保证一个变量的操作原子性。
线程变量
在了解了最小粒度的隔离实现方式之后,我们再来看一下相对较小的隔离实现方式:线程变量。线程变量是将某些共享的变量隔离到线程中,也就是说每个线程只能访问到自己的线程变量,无法修改其他线程的变量。通过使用线程变量,我们可以安全地在一次请求中利用线程变量进行数据透传,这也是很多分布式追踪框架Tracer用到的底层技术。
下面举个Java中使用线程变量的例子:首先声明一个线程变量m,然后分别用线程A和线程B修改线程的值,然后打印。运行代码你会发现两个线程的打印结果互不干扰。原因就在于m是线程变量,线程A和线程B分别拥有一个m实例,在线程内部是共享的,但是跨线程就无法共享了。
public class LockTest {
public static ThreadLocal<Integer> m = ThreadLocal.withInitial(() -> 1);
public static void main(String[] args) throws Exception {
Map<String, String> cache = new ConcurrentHashMap<>();
//线程A
CompletableFuture.runAsync(() -> {
for (int i = 0; i < 10; i++) {
m.set(i);
System.out.println(m.get());
}
});
//线程B
CompletableFuture.runAsync(() -> {
for (int i = 10; i < 20; i++) {
m.set(i);
System.out.println(m.get());
}
}).get(100, TimeUnit.MILLISECONDS);
}
}
互斥
互斥可以说是最常用到的并发控制手段了,它通过某种方式让一个线程访问共享状态时,其他线程无法访问共享状态的方式来避免并发问题。我们把会访问共享变量的代码区块称为临界区,互斥就是要保证在临界区中,同一个时间只有一个线程能进入临界区(执行访问共享变量的代码)。要实现互斥主要有两种思路:
- 忙等待
- 睡眠与唤醒
忙等待
忙等待实现互斥的思路主要就是当一个线程进入临界区时会将锁变量占用,让其他线程循环检查锁变量直到自己抢到锁为止。忙等待的显著特征就是线程会循环检查锁变量。忙等待的实现方式有好几种,我这里只列举常见的一种:基于自旋锁的互斥。下面是一个简单的Java自旋锁实现:
public class SimpleSpinLock {
private final AtomicInteger lock = new AtomicInteger(0);
public void lock() {
//用CAS操作忙等待
while (!lock.compareAndSet(0, 1)) ;
}
public void unlock() {
lock.set(0);
}
public static Integer m = 1;
public static void main(String[] args) throws ExecutionException, InterruptedException {
SimpleSpinLock lock = new SimpleSpinLock();
//线程A
CompletableFuture.runAsync(() -> {
for (int i = 0; i < 10000; i++) {
lock.lock();
m++;
lock.unlock();
}
});
//线程B
CompletableFuture.runAsync(() -> {
for (int i = 0; i < 10000; i++) {
lock.lock();
m++;
lock.unlock();
}
});
Thread.sleep(1000);
System.out.println(m);
}
}
要实现自旋锁,最关键的就是要使用CAS操作。上面的代码中我使用了AtomicInteger这个Java自带的原子整型类作为锁变量,加锁操作就是调用compareAndSet判断当前lock值是否为0,如果为0就更新lock为1,并且返回true,如果不是就不更新并且返回false。可以看到加锁过程就是一直尝试将lock更新为1,如果没有更新成功代码就会一直循环,所以叫做忙等待。注意这里的比较和更新的两步操作在CPU层面是原子操作,这样才能保证抢锁的过程是原子的,中间不会发生线程调度导致有两个线程同时拿到锁。释放锁的操作就是直接把锁变量更新成0,这样其他在忙等待的线程就有机会compareAndSet成功。
睡眠与唤醒
除了忙等待,睡眠与唤醒的通信机制也是实现互斥的重要手段。睡眠与唤醒的思路是这样的:当一个线程进入临界区时会将锁变量占用,让其他线程睡眠进入等待队列(放弃CPU使用权),等待锁变量被释放时,会唤醒等待队列中的其中一个线程获得锁。可以发现,睡眠与唤醒的机制跟忙等待最大的不同就在于等待锁的方式不一样。忙等待是不停的检查,CPU实际上是在空转;而睡眠与唤醒则是让线程进入睡眠,主动放弃CPU给其他线程,等待唤醒。基于睡眠与唤醒实现的锁我们一般称为互斥锁(mutex)。
在Java中,LockSupport 提供park()和unpark()方法实现阻塞线程(睡眠)和解除线程阻塞(唤醒)。前面的自旋锁可以利用睡眠与唤醒的原语改造一下成为互斥锁,减少CPU的空转。
下面我会基于LockSupport 实现一个先进先出的互斥锁:
- 加锁阶段先将当前线程放到等待队列中,然后先判断当前线程在不在队列头部,如果不在就直接调用
LockSupport.park进入阻塞状态;如果在队列头部就用CAS操作尝试加锁,加锁失败也进入阻塞状态。当加锁成功之后,就从队列中移除当前线程。 - 解锁阶段先将锁释放,然后调用
LockSupport.unpark将队列头部的线程唤醒。
public class FIFOMutex {
private final AtomicInteger lock = new AtomicInteger(0);
private final Queue<Thread> waiters = new ConcurrentLinkedDeque<>();
public void lock() {
Thread current = Thread.currentThread();
waiters.add(current);
//当前线程如果不是在队头就直接阻塞
//如果当前线程在队头就尝试获取锁,如果获取不到也阻塞
while (waiters.peek() != current ||
!lock.compareAndSet(0, 1)) {
LockSupport.park(this);
}
waiters.remove(); //拿到锁之后退出等待队列
}
public void unlock() {
lock.set(0);
LockSupport.unpark(waiters.peek()); //唤醒队头的线程去尝试获取锁
}
public static Integer m = 1;
public static void main(String[] args) throws ExecutionException, InterruptedException {
FIFOMutex lock = new FIFOMutex();
//线程A
CompletableFuture.runAsync(() -> {
for (int i = 0; i < 10000; i++) {
lock.lock();
m++;
lock.unlock();
}
});
//线程B
CompletableFuture.runAsync(() -> {
for (int i = 0; i < 10000; i++) {
lock.lock();
m++;
lock.unlock();
}
});
Thread.sleep(1000);
System.out.println(m);
}
}
上面提到的忙等待和睡眠唤醒原语是实现锁的两种基本方法,采用两种方法实现的锁分别叫自旋锁和互斥锁。自旋锁主要适用于锁占用时间极短的场景,而互斥锁更加使用于锁占用时间较长的场景。为什么?这主要跟两者的机制带来的性能损耗不同有关系。自旋锁在忙等待的期间还会占用CPU,忙等待会浪费CPU时间片;互斥锁的机制是让等待锁的线程让出CPU,成本主要是线程切换的成本。当锁占用时间短时,自旋锁能很快就拿到锁,浪费的CPU时间少而且不会产生线程切换,相比互斥锁来说性能更优;当锁占用长时,自旋锁会白白浪费CPU时间,而互斥锁则能主动让出CPU给其他线程使用,此时互斥锁的性能更优。因此,具体使用自旋锁还是互斥锁取决于具体场景。不过实际上应该过程中,两种锁是结合使用的,例如自适应自旋锁的思路是先尝试忙等待,如果忙等待超过一定次数就让线程进入睡眠,并且忙等待的次数也可以根据不同的情况进行自动调整,具体可以谷歌查询JVM的锁优化,这里不展开了。
隔离技术的对比
| 隔离手段 | 隔离范围 | 性能 | 使用成本 | 是否有锁 |
|---|---|---|---|---|
| CAS操作 | 机器指令级别 | 高 | 高(只能保证单个变量的线程安全) | 有(锁内存总线,对用户透明) |
| 线程变量 | 线程级别 | 高 | 中(线程变量在线程池中使用容易造成线程变量污染) | 无 |
| 互斥 | 用户自定义,在临界区中的代码都无法并发执行 | 低 | 低(在临界区中的代码都是线程安全的) | 有 |
思考题
最后留一道思考题让大家实战一下。下面这段代码有一个线程安全问题,你能够一眼就看出来吗?找到答案的同学可以留言交流。
public class LockTest {
public static Integer m = 1;
public static void main(String[] args) throws ExecutionException, InterruptedException {
//A线程
new Thread(() -> {
synchronized (m) {
try {
m.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(m);
}
}).start();
//B线程
new Thread(() -> {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (m) {
m++;
m.notify();
System.out.println("唤醒A");
}
}).start();
}
}