【Java】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规则
,用好 volatile
、synchronized
等关键词,就能写出可靠的多线程应用。
不过,说起来容易,但具体该怎么做呢?
我们在前面提到,一旦有多个线程操作同一个变量时,这些线程只顾做自己的事,完全不管对方在做什么,最后却错得一塌糊涂。那这样行不行?
我想想办法,让线程之间有心灵感应,一个线程做了些什么,另一个线程马上就能知道。
这种像是心灵感应的东西,Java 已经做到了,叫 happen-before
, 如果一个操作先发生,另一个操作后发生,那么前一个操作的结果对后续操作可见。
简单来说,计算机可以用缓存,可以线程切换,可以编译优化,但 Java 一定会遵守happen-before规则
,从而保证线程之间的 happen-before
。
这样一来,像是 volatile
、synchronized
等等关键词,语义被大大增强。对 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
禁用编译优化。编译优化会带来一些意想不到的问题,我们来看一个经典案例—利用双重检查创建单例对象,你看下面这段代码。
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规则
,用好 volatile
、synchronized
等关键词,就能写出可靠的多线程应用。
其中,volatile
可以解决可见性、有序性问题;互斥锁可以解决原子性问题。在 Java 中,synchronized
就是互斥锁的一种实现。
以上是 【Java】Java并发编程-解决并发问题:多线程应用没那么难写 的全部内容, 来源链接: utcz.com/a/101663.html