既然多线程能让我们充分发挥多处理器优势,提升性能,那是不是启用线程越多越好呢?在多线程编程过程中都会遇到哪些问题呢?通过本节的学习,相信你就得到答案。
1. 线程安全问题
我们先看几个多线程的代码栗子
样例 1: 火车票多窗口售票
假设火车站有 10 万张火车票,有三个售票窗口,售完为止,最后输出我们一共售卖了多少张火车票,我们通过多线程代码来实现它。
public class TrainTest {
//剩余的火车票数量
public static Integer leftTicketTotal = 10000;
//售出的火车票数量
public static Integer selledTicketTotal = 0;
public static class TicketWindow implements Runnable {
@Override
public void run() {
while (leftTicketTotal > 0) {
selledTicketTotal++;
leftTicketTotal--;
}
}
}
public static void main(String[] args) throws InterruptedException {
//启动窗口1售票线程
Thread thread1 = new Thread(new TicketWindow());
thread1.start();
//启动窗口2售票线程
Thread thread2 = new Thread(new TicketWindow());
thread2.start();
//启动窗口3售票线程
Thread thread3 = new Thread(new TicketWindow());
thread3.start();
//等待三个线程执行完成
thread1.join();
thread2.join();
thread3.join();
//输出最终火车票数量
System.out.println("售出火车票数量:" + selledTicketTotal + " 剩余火车票数量:" + leftTicketTotal);
}
}
运行后,我们得到的输出结果却是,售出火车票数与总数不一致。
售出火车票数量:9099 剩余火车票数量:-2
而且我们发现每次输出的结果都不一样。接下来我们分析下造成不一致的原因
我们看到代码 selledTicketTotal++
(即 selledTicketTotal = selledTicketTotal + 1) , 实际上包括三个操作,读取 selledTicketTotal 的值,进行加 1 操作,写入新的值;同理 leftTicketTotal--
也包括三个操作,读取 leftTicketTotal 的值,进行减 1 操作,写入新的值。这三个操作组成的 selledTicketTotal++
和 selledTicketTotal--
都是非原子操作的,那什么是原子操作呢?
原子操作是指不可被分割的一系列操作。
对一个变量的非原子操作往往会产生非预期的结果,比如线程 A 和线程 B 都在执行 selledTicketTotal++
,线程 A 读到 selledTicketTotal=10, 由于非原子操作是可被分割的,此时线程 B 不会等待 A 操作完成执行加 1 操作,而是同样读到了 selledTicketTotal=10,线程 A 和 B 以 10 做基数分别做加 1 操作,selledTicketTotal 最终结果为 11,而不是预期的 12,这就是非原子操作带来数据不一致。
样例 2: 水龙头开关
假设我们向蓄水池中放水,一段时间后停止放水
public class WaterTapTest {
public static boolean tapOpen = true;
public static class WaterTapTask implements Runnable {
@Override
public void run() {
while (tapOpen) {
try {
System.out.println("水龙头放水");
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(new WaterTapTask());
thread.start();
Thread.sleep(1000);
tapOpen = false;
System.out.println("水龙头停止放水");
//一定概率下,会继续输出水龙头放水
thread.join();
}
}
在我们将 tapOpen 设置为 false 后,水龙头依然在放水,这就是我们要说的第二个问题,多线程下的可见性问题,即当一个线程更新了一个变量,另一个线程并不能及时得到变量修改后的值。那什么是可见性呢?
可见性:当一个线程修改了对象状态后,其他线程能够看到发生的状态变化。
当读操作和写操作在不同的线程中执行时,我们无法确保执行读操作的线程能实时看到其他线程写入的值。
以上两个例子阐述了非原子和不可见带来的问题,这两类问题均属于线程安全问题。线程安全的定义:
多个线程访问某个类时,这个类始终表现出正确的行为,那么就称这个类是线程安全的。
综上所述,要保证线程安全需要满足两大条件
- 原子性:一系列操作,要么全部完成,要么全部不完成,不可被分割,不会结束在中间某个环节。
- 可见性:当一个线程修改了对象状态后,其他线程能够看到发生的状态变化。
保证原子性的手段有单线程、加锁、CAS (后续章节我们会介绍到),保证可见性的手段是通过插入内存屏障 (后续章节我们会介绍) 来解决。
2. 上下文切换
Java 中的线程与 CPU 单核执行是一对一的,即单个处理器同一时间只能处理一个线程的执行;而 CPU 是通过时间片算法来执行任务的,不同的线程活跃状态不同,CPU 会在多个线程间切换执行,在切换时会保存上一个任务的状态,以便下次切换回这个任务时可以再加载到这个任务的状态,这种任务的保存到加载就是一次上下文切换。线程数越多,带来的上下文切换越严重,上下文切换会带来 CPU 系统态使用率占用,这就是为什么当我们开启大量线程,系统反而更慢的原因。
我们要减少上下文切换,有几种手段:
- 减少锁等待:锁等待意味着,线程频繁在活跃与等待状态之间切换,增加上下文切换,锁等待是由对同一份资源竞争激烈引起的,在一些场景我们可以用一些手段减轻锁竞争,比如数据分片或者数据快照等方式。
- CAS 算法:利用 Compare and Swap, 即比较再交换可以避免加锁。后续章节会介绍 CAS 算法。
- 使用合适的线程数或者协程:使用合适的线程数而不是越多越好,在 CPU 密集的系统中,比如我们倾向于启动最多 2 倍处理器核心数量的线程;协程由于天然在单线程实现多任务的调度,所以协程实际上避免了上下文切换。
3. 活跃性问题 (死锁、饥饿)
当某些操作迟迟得不到执行时,就被认为是产生了活跃性问题,活跃性分为两类,一类是死锁,一类是饥饿。
死锁是最常见的活跃性问题,除此之外还有饥饿、活锁。当线程由于无法访它所需的资源而不能继续执行时,就发生了饥饿。
在多线程开发中,我们要避免线程安全问题,势必要对共享的数据资源进行加锁,而加锁处理不当即会带来死锁。
我们以死锁为例,看看死锁是如何发生的:
我们看上面这张图,线程 A 和线程 B 都拥有一份锁,而线程 A 和线程 B 恰好同时去获取对方拥有的那把锁,导致两个线程永远无法执行,要避免死锁有一个方法即获取锁的顺序是固定的,比如只能先获取锁 X 再获取锁 Y,不允许出现相反的顺序。
4. 总结
多线程能给我们带来很多好处,比如充分利用多核处理能力,建模简单,异步事件简化处理。
但在多线程在运行过程中,会带来三类问题,分别是线程安全性、上下文切换和活跃性问题,接下来章节的我们就从起因到解决再分析原理一起攻克这三座大山。
下图是本小节的脑图整理
参考资料
- 《Java 并发编程实战》
- 维基百科