当设计到并发编程的时候,通常要考虑的三个问题就是可见性、原子性、有序性这个问题。
可见性
一个线程对共享变量的修改,另一个线程能够立刻看到,称为可见性
为了合理利用CPU资源,CPU增加了缓存,用来均衡和内存速度的差异。正是由于CPU缓存的存在才导致了可见性问题。所以要理解可见性问题,我们需要先了解CPU的结构,下图是一个2核4线程的CPU结构图,每个物理核都会有自己的L1 Cache、L2 Cache。所有的物理核共用L3 Cache。
- 在Windows电脑上可以在任务管理器界面查看L1、L2、L3的大小
- Linux下查看L1、L2、L3大小
cat /sys/devices/system/cpu/cpu0/cache/index1/size cat /sys/devices/system/cpu/cpu0/cache/index2/size cat /sys/devices/system/cpu/cpu0/cache/index3/size
那么现在问题来了,如下代码。两个线程对共享变量count各累加1000次,我们需要的是count经过共2000次累加之后变成2000,但是代码执行的结果是小于等于2000的。
public class Problem {
private static int count = 0;
private static void add() {
count++;
}
public static void main(String[] args) throws InterruptedException {
Thread threadA = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
add();
}
});
Thread threadB = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
add();
}
});
threadA.start();
threadB.start();
threadA.join();
threadB.join();
System.out.println(count);
}
}
其实造成这种结果的原因就是每个线程可能在不同CPU核上执行,然后从内存中读取了变量count到CPU缓存,累加之后,CPU缓存不会立即更改过后的值刷回内存,这样就导致其他其他线程读不到最新的值
原子性
一个或多个操作在CPU执行的过程中不被中断的特性称为原子性
由于操作系统层面增加了线程、分时复用CPU,所以一个线程执行过程中,CPU时间片用完之后发生了线程切换,CPU交由其他线程来使用,这个时候就会存在原子性的问题。
比如count += 1
编译成CPU指令:
- 指令1:将变量count从内存加载到CPU的寄存器
- 指令2:寄存器中执行+1操作
- 指令3:将结果写入内存(缓存机制可能写入的是CPU缓存)
有序性
代码按你写的顺序执行叫有序性
但是往往为了提高执行程序的性能,编译器和处理器会对指令进行重排序。
- 编译器重排序:不改变单线程程序语义的前提下重新安排语句的执行顺序(编译器)
- 指令级重排序:如果指令之间不存在数据前后关系的依赖,处理器可以语句对应的机器指令的执行顺序(处理器)
- 内存系统重排序:因为处理器使用了缓存和读写缓冲区,导致加载和存储看上去可能是乱序的(处理器)
综上,为了平衡CPU和内存速度的差异,CPU引入了缓存,提高了CPU利用率,也带来了可见性问题;多核CPU架构,线程的引入和CPU分时复用技术,导致线程切换,带来了原子性的问题;编译优化,指令并行执行优化带来了有序性的问题。
下一篇文章将会介绍JMM针对三种问题的解决方法。