上篇的最后,我们以一个简单的计数器代码作为例子谈到了多线程安全问题。本篇我们围绕多线程安全问题,将剩下的并发编程基础内容讲完。
没看过上一篇的,可以先将上一篇的内容过一下《java并发编程(1)-并发编程基础(上)》。
ok,现在我们把上篇最后的例子再拿出来,正式开始本篇的内容。
public class Test {
private static int COUNT = 0;
public static void main(String[] args) throws InterruptedException {
Thread thread1 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
COUNT++;
}
});
Thread thread2 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
COUNT++;
}
});
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println(COUNT);
}
}
线程安全的根本原因
在上一篇文章的最后说过这段代码会发生什么,以及引起安全问题的原因。
实际归根到底,不管是我们写的计数器,亦或是其他任何的代码,引起线程安全问题的本质原因其实有三个原因,原子性,内存可见性以及有序性。顺序性的内容我们放到后面讲完synchronized以及volatile以后再讲。
原子性
原子性指的是在进行一系列操作的时候,要么全部执行,要么全部不执行,不存在只执行一部分的情况。拿COUNT++举例。
COUNT++就是典型的非原子性操作,因为它背后是由读取->修改->写回三个操作组成。会存在只执行一部分的情况,比如说只执行了读取,还没执行修改和写回。
内存可见性
首先我们上一个简图来看一下java在多线程下处理共享变量的内存模型
可以从上面的内存模型图上看出,在java中所有所有变量都放到主内存中,当线程使用变量时,会把变量从主内存复制到自己的私有内存空间或者叫工作内存,线程读写变量时操作的都是自己工作内存中的变量副本。
java内存模型是一个抽象概念,下面我们来看看实际实现中工作内存是什么,继续上图。
上面的图是一个双核CPU系统架构。 每个核都有自己的控制器,运算器以及L1缓存。控制器中包含一组寄存器和操作控制器,运算器执行算术逻辑运算。
每个核的控制器,运算器还有L1缓存都是独立的,彼此不共享,而L2 Cache以及主内存是多核共享的。注意L2 Cache在部分CPU被设计为每个核心独有,不能共享,本篇我们是为了简化流程所以才说L2 Cache是多核共享的,大家千万不要认为所有CPU都是这样。
线程读写流程
当一个线程操作一个变量时,会先从主内存将共享变量复制到自己的工作内存中,然后再对工作内存中对的变量进行处理,处理完以后再写回主内存。正是因为这个过程导致的内存可见性问题的出现。
看到这,或许你已经悟到了导致内存可见性问题出现的原因。当然,没悟到也没关系,我们接下来会更详细的分析这个多线程读写流程,相信看完你就会彻底明白个中缘由。
- 假设线程A需要对COUNT变量进行递增操作,第一步自然是去读取对应的变量,首先会先尝试去L1 Cache中获取,L1 Cache中没有再去L2 Cache,L2 Cache中也没有,再去主内存获取。
- 假设主内存中的COUNT变量的值是0,从主内存获取到COUNT以后,会逐级将COUNT返回,并且会相应的在L2 Cache中和L1 Cache中将COUNT缓存下来,这样下次就不用继续去主内存获取了。
- 线程A对COUNT进行++操作后,将结果分别更新到L1 Cache, L2 Cache以及主内存,这个时候COUNT值为1。
- 这时,线程B也开始对COUNT变量进行递增操作,步骤和线程A的操作差不多,因为线程B会先尝试去L1 Cache中获取COUNT,L1 Cache中没获取到再去L2 Cache中获取。因为L2 Cache是共享的,并且因为线程A先前已经对COUNT进行了操作,所以线程B会在L2 Cache中获取到COUNT,COUNT值为1。
- 线程B对COUNT进行++操作后,将结果分别更新到L1 Cache, L2 Cache以及主内存,这个时候COUNT值为2。
- 注意喽,这个时候问题要来了。线程A这时又要对COUNT变量进行递增操作,因为先前在L1 Cache中缓存了COUNT变量,所以这时线程A在L1 Cache中获取到了COUNT,读取到的值是1。明明前面线程B已经将COUNT修改成2,为什么线程A获取的还是1呢?线程B写入的值对线程A不可见,这就是内存不可见问题。
在java中要内存可见性问题很简单,只要使用volatile关键字修饰或者使用synchronize代码块就可以了,下面会讲解。其实你看到这,应该也会在内心明悟,出现内存可见性问题的根本原因就是因为缓存的关系,所以不妨在此刻思考一下,你觉得它们如何解决内存可见性问题,等讲解到对应内容的时候,印证一下,看看是否和你内心所想一样。
解决线程安全问题
本节我们讲解如何在java中解决线程安全问题。当然,本篇是并发编程基础,所以下面讲的方式都是基础方式,还有一些更高级的方式可以去解决线程安全问题,这些内容留到后续的并发篇章讲解。
synchronized
synchronize关键字是java提供的一种原子性内置锁,我们可以把java中的每个对象都当作一个同步锁来使用。这种java内置的,使用者看不到的锁称为内部锁,也叫监视器锁。
线程在执行到synchronize代码块时,会去自动获取锁。当线程正常退出(包括因为异常退出)synchronize代码块时,会自动释放锁。
synchronize是排他锁,也就是当一个线程获取到锁以后,其他线程必须等待持有锁的线程释放锁以后才能获取锁。
java线程属于轻量级进程(不谈java21引入的虚拟线程,这不在我们的讨论范围),是由内核支持,与内核线程一一对应。所以当阻塞一个线程的时候,需要从用户态切换到内核态执行阻塞操作,会导致上下文切换。
synchronized内存语义
上面说过synchronize可以解决内存可见性的问题。我们来看看synchronize的内存语义,看看它是如何解决的。
进入synchronize代码块的内存语义是把代码块内用到的共享变量从线程工作内存中删除,这样就会使synchronize代码块中访问共享变量时会直接去主内存获取。
退出synchronize代码块的内存语义是把代码内对共享变量的修改更新到主内存。
其实上面提到的内存语义也是加锁和释放锁的语义。即当获取锁时,将锁块内用到的共享变量从工作内存中清除,读取时从主内存读取。当释放锁时,将共享变量的修改更新到主内存。
原子性操作
除了解决内存可见性问题,synchronize还可以解决原子性问题。这点也很好理解,因为synchronize是排他锁,在没有完成锁块内的操作释放掉锁时,其他线程无法进入synchronize代码块,其实就相当于是实现了原子性操作。
volatile
前面说过使用synchronize可以解决内存可见性问题,但是synchronize太过笨重,会引起上下文切换。所以java提供了volatile关键字,使用它也可以去解决内存可见性问题,并且它比synchronize更轻量化,不会像synchronize那样引起上下文切换。
当一个变量被声明为volatile时,修改变量时不会将值缓存到寄存器或者其他地方,直接修改主内存中的变量的值。读取变量时不会从工作内存中读取,而是直接从主内存中读取。
可以看到volatile的内存语义和synchronize有相似之处。修改volatile修饰的变量时相当于退出synchronize块,读取变量时相当于进入synchronize块。
有序性
java内存模型允许编译器和处理器对指令重排序以提高性能,前提是这些指令没有依赖关系。在单线程中这种指令重排序没有问题,但是在多线程中就会存在一些问题。
// (1)
int a = 1;
// (2)
int b = 2;
// (3)
int c = a + b;
以上面的这段代码为例。(3)的操作依赖(1)和(2),所以无论如何都会保证(3)一定会在(1)(2)后面执行。但是(1)和(2)之间没有依赖关系,所以它们的先后顺序就不一定的。在单线程中,指令重排序不会有什么问题。我们来看个多线程的例子,看看多线程下会不会引起线程安全问题。
private static boolean READY = false;
private static int NUM = 0;
public static void main(String[] args) throws InterruptedException {
Thread thread1 = new Thread(() -> {
while (!Thread.currentThread().isInterrupted()) {
// (1)
if (READY) {
// (2)
System.out.println(NUM + NUM);
}
System.out.println("read thread...");
}
});
Thread thread2 = new Thread(() -> {
// (3)
NUM = 2;
// (4)
READY = true;
System.out.println("writerThread set over...");
});
thread1.start();
thread2.start();
Thread.sleep(50);
thread1.interrupt();
System.out.println("main exit");
}
上面这段代码比较简单,整体逻辑就是thread1会在while循环内判断READY是否为true,如果为true,打印NUM+NUM的结果。thread2则是修改NUM以及READY的值。
正常情况来说代码(2)打印的值会是4,但这是不一定的。因为代码(3)和(4)没有依赖关系,所以有可能会被指令重排序导致先执行代码(4),执行代码(4)的时候有可能Thread已经执行到代码(1),并且代码(2)在代码(3)执行前执行,就会导致打印出来的值不是4,而是0。
解决指令重排序问题最简单的办法就是就是使用volatile修饰对应的变量就可以了。
写volatile变量时,可以确保写之前的操作不会被指令重排序到写操作以后。读volatile变量时,可以确保读之后的操作不会被指令重排序到读操作以前。\
总结
感谢大家看到这,我们的并发基础篇章算是告一段落,后面就要开始并发编程的高级内容了。大家可以尝试根据本篇内容,修改本篇开头的那段有线程安全问题的代码,使其变为多线程安全。
本篇依旧参照《java并发编程之美》,并根据自身的理解编写。如果条件允许,还是非常推荐各位可以去看看原书。