Java并发编程-解决并发问题:多线程应用没那么难写

我们前面提到过,为了提高计算机的性能,大神们只能破坏程序的可见性、原子性、有序性,从而带来了并发问题。

这三者是编程领域的共同问题,所有编程语言都会遇到。Java 作为排名第一的编程语言,自然也有一套领先的技术方案—Java 内存模型

我们要写出可靠的程序,自然要对 Java 内存模型有所了解。

破除一个错误的观念

说起 Java 内存模型,你可能感到不明觉厉,然后立马放弃。

的确,网上的各种资料都特别深奥,像是多级缓存、流水线、执行单元等等,各种名词满天飞。这些东西虽然很酷,但都是计算机的底层知识,复杂程度远超你的想象。如果你硬要一头扎进去,不但增加了学习难度,也找不到实践价值,最后只能放弃。

然而,你不用管计算机的底层知识,工程师之间是一个分工合作的关系,你可以看下面这幅图。

工程师分工体系

处理器工程师负责解决 CPU 体系结构的问题;编译器、JVM 工程师则利用内存屏障等技术,保证 Java 内存模型的正确性。

我们作为 Java 应用工程师,最重要的是了解 Java 内存模型,然后利用 Java 的语法和规则,写出可靠的多线程应用。

说了这么多,无非就是一点:我们可是站在食物链顶端的人,大可放下执念、恐惧等情绪,好好看下去。

什么是 Java 内存模型

在早期的编程语言中,并没有内存模型的概念。要保证程序的可见性、原子性、有序性,只能靠处理器自身的内存一致性模型。

然而,问题来了。

不同的处理器差异很大。比如,一段 C 程序在一个处理器上运行正常,但在另一个处理器上却得出不一样的结果。

Java 的口号是“书写一次,到处执行”,但这显然是低估了事情的难度。当时,Java 的语言规范还有各种缺陷,在不同的处理器上没法保证运行正常。比如,在一些情况下,volatile 没法保证可见性。

在不同的平台下,程序怎么保证正确?

谁都没有底。随着运行 Java 的平台越来越多,这个问题也越发重要。在 2004 年,Java 推出了 5.0 版本。这是个大招,上面明确定义了 Java 内存模型,从此问题得到了解决。

Java 内存模型是一套复杂的规范。我们作为 Java 应用工程师,只需要利用其中的 happen-before规则,用好 volatilesynchronized 等关键词,就能写出可靠的多线程应用。

不过,说起来容易,但具体该怎么做呢?

我们在前面提到,一旦有多个线程操作同一个变量时,这些线程只顾做自己的事,完全不管对方在做什么,最后却错得一塌糊涂。那这样行不行?

我想想办法,让线程之间有心灵感应,一个线程做了些什么,另一个线程马上就能知道。

这种像是心灵感应的东西,Java 已经做到了,叫 happen-before如果一个操作先发生,另一个操作后发生,那么前一个操作的结果对后续操作可见。

简单来说,计算机可以用缓存,可以线程切换,可以编译优化,但 Java 一定会遵守happen-before规则,从而保证线程之间的 happen-before

这样一来,像是 volatilesynchronized 等等关键词,语义被大大增强。对 Java 应用程序员来说,解决方案就十分清楚了,用好关键词就行。

volatile 解决可见性、有序性

我们前面提过,缓存导致了可见性问题,编译优化导致了有序性问题。那解决方案显而易见,禁用缓存、禁用编译优化。

可这样一来,程序的性能又堪忧。比如,用户很少修改个人信息,不可能一秒钟修改几百次,这就没必要考虑并发问题了。

因此,合理的解决方案是按需禁用缓存和编译优化,我们要用到 volatile

在 Java 中,volatile 有两层意思:

禁用 CPU 缓存,直接读写内存的数据,保证线程的可见性; 禁用编译优化,保证程序的有序性;

先来看第一点,volatile 禁用 CPU 缓存,你看下面这段代码。

public class VolatileExample {

    // volatile 禁用 CPU 缓存
    private static int number;
    // private volatile static int number;
    private static boolean isStopReader;

    // 客服系统
    public static void init() {
        for (int i = 0; i < 5; i++) {
            // 发送上线通知
            number = i;
            System.out.println(number + " 号客服已上线");

            // 分配电话线路
            try {
                Thread.sleep(1000 * 1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }

        // 客服上线完毕
        isStopReader = true;
    }

    // 录音系统
    public static void read() {
        // 已录音的客服
        Set<Integer> workingSet = new HashSet<>();

        // 启动录音设备
        while (isStopReader == false) {
            if (workingSet.add(number)) {
                System.out.println("客服:" + number + " 号,进行电话录音");
            }
        }
    }

    public static void main(String[] args) throws Exception {
        // 客服上班
        Thread th1 = new Thread(
                () -> init()
        );
        // 启动录音系统
        Thread th2 = new Thread(
                () -> read()
        );

        // 启动两个线程
        th1.start();
        th2.start();

        // 等待两个线程执行结束
        th1.join();
        th2.join();
    }
}

原始结果:
1 号客服已上线
客服:1 号,进行电话录音
2 号客服已上线
3 号客服已上线
=========死循环

修改结果:number 加了 volatile 修饰符
1 号客服已上线
客服:1 号,进行电话录音
2 号客服已上线
客服:2 号,进行电话录音
3 号客服已上线
客服:3 号,进行电话录音

每隔一秒,就有一位客服小姐姐上线。在这期间,录音系统会一直循环等待,对上线客服的线路进行录音。等到客服系统执行完 isStopReader = true 后,录音系统就进入休眠。

然而,录音系统只对 1 号客服进行录音,后面就一直在死循环。

这是因为客服系统运行在 CPU-1 上,录音系统运行在 CPU-2 上。录音系统把 number = 1 放到了自己的 CPU 缓存中。所以,客服系统再怎么修改 number 的值,录音系统也完全不知道。

这就是 CPU 缓存带了的可见性问题。要想解决这个问题,你只要加上 volatile 关键词。

在 CPU-1 中,线程一执行了 number = 2,就立刻写到内存,并通知线程二;线程二收到了通知,就把 CPU-2 的执行结果丢掉,重新读取内存的数据。

volatile-读写流程

再来看第二点,volatile 禁用编译优化。编译优化会带来一些意想不到的问题,我们来看一个经典案例—利用双重检查创建单例对象,你看下面这段代码。

public class IdGen {

    // volatile 禁用编译优化
    // private static volatile IdGen instance;
    private static IdGen instance;

    static IdGen getInstance() {
        if (instance == null) {
            synchronized (IdGen.class) {
                if (instance == null) {
                    instance = new IdGen();
                }
            }
        }
        return instance;
    }

}

你留意第 11 行代码 instance = new IdGen(),这有可能造成空指针异常。

编译优化,再加上线程切换,就会报空指针异常

这是由编译优化造成的错误,要解决问题也很容易,加上 volatile 关键词,禁用掉编译优化就行。

看到这儿,对于程序的可见性、有序性问题,相信你已经有了解决方案:在需要的时候,用 volatile 这个关键词,禁用掉 CPU 缓存和编译优化。

互斥锁解决原子性问题

你已经知道,线程切换造成了原子性问题。但你可能不知道,在三个问题中,原子性问题是最复杂的。

好在这个问题已经有了解决思路,你只要保证:在同一时刻,一个资源只能由一个线程操作。程序的原子性就能得到保障,这个条件,我们称之为互斥

互斥有多种实现方式,最直接的就是禁用线程切换。

在单核 CPU 时代,这是行得通的。因为单核 CPU 在同一时刻,只能执行一个线程。这时候,你只要禁用掉线程切换,线程就能一直执行到结束为止,原子性问题就这样解决了。

然而,新问题来了。

首先,线程切换是为了提高计算机的性能,你如果禁用掉线程切换,性能自然也会大大下降。

其次,控制 CPU 的线程切换非常复杂,没几个人能拍胸脯保证搞定。这个工作交给广大的应用开发者,肯定不合适呀。

最后,现在是多核 CPU 时代,禁用线程切换根本没用。

在多核 CPU 中,最少也有两个以上的线程在同时执行。比如,一个线程在 CPU-1 上,另一个线程在 CPU-2 上,可你只能保证线程能连续执行,不能保证同一时刻只有一个线程执行,那最后的结果肯定会错漏百出。

因此,Java 用的是另一种互斥方案—互斥锁,简称:锁。锁是一种通用的技术方案,各种编程语言都有实现。

在 Java 中,synchronized 关键字就是锁的一种实现。它可以用来修饰方法、也可以用来修饰代码块。你看下面这段代码:

public class Counter {

    long count = 0L;

    public synchronized void addOne() {
        count++;
    }
}

我们前面提过,count++ 会被拆成 3 个 CPU 指令,一旦发生线程切换,不一定被正确执行。

然而,加了 synchronized 关键字后,线程执行 addOne() 方法前得先加锁,但锁只有一个。如果一个线程抢先加锁,其它线程就得等着,直到第一个线程解锁后,才能再抢着加锁。

在这个过程中,CPU 可以做线程切换,但其它线程准备执行 addOne() 方法时,如果发现锁还没释放,那就只能在外面等着。

这样一来,不管 CPU 是单核还是多核,只要用对了锁,程序的原子性都能得到保障。而且,由于没有禁止线程切换,计算机的性能不受什么影响。

当然,并发编程是高阶技能,原子性问题又是最复杂的一个,我后面会仔细讲清楚:锁究竟是怎么一回事。拭目以待吧~

写在最后

可见性、原子性、有序性,这三者是编程领域的共同问题,Java 也有一套业界领先的技术方案—Java 内存模型。

Java 内存模型是一套复杂的规范,但我们作为 Java 应用工程师,只需要利用其中的 happen-before规则,用好 volatilesynchronized 等关键词,就能写出可靠的多线程应用。

其中,volatile 可以解决可见性、有序性问题;互斥锁可以解决原子性问题。在 Java 中,synchronized 就是互斥锁的一种实现。