02 多线程带来哪些问题
Diego38 79 1

既然多线程能让我们充分发挥多处理器优势,提升性能,那是不是启用线程越多越好呢?在多线程编程过程中都会遇到哪些问题呢?通过本节的学习,相信你就得到答案。

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. 活跃性问题 (死锁、饥饿)

    当某些操作迟迟得不到执行时,就被认为是产生了活跃性问题,活跃性分为两类,一类是死锁,一类是饥饿。

死锁是最常见的活跃性问题,除此之外还有饥饿、活锁。当线程由于无法访它所需的资源而不能继续执行时,就发生了饥饿。

在多线程开发中,我们要避免线程安全问题,势必要对共享的数据资源进行加锁,而加锁处理不当即会带来死锁。

我们以死锁为例,看看死锁是如何发生的: image

我们看上面这张图,线程 A 和线程 B 都拥有一份锁,而线程 A 和线程 B 恰好同时去获取对方拥有的那把锁,导致两个线程永远无法执行,要避免死锁有一个方法即获取锁的顺序是固定的,比如只能先获取锁 X 再获取锁 Y,不允许出现相反的顺序。

4. 总结

多线程能给我们带来很多好处,比如充分利用多核处理能力,建模简单,异步事件简化处理。

但在多线程在运行过程中,会带来三类问题,分别是线程安全性、上下文切换和活跃性问题,接下来章节的我们就从起因到解决再分析原理一起攻克这三座大山。

下图是本小节的脑图整理 image

参考资料

  1. 《Java 并发编程实战》
  2. 维基百科
预览图
评论区

索引目录