接上一篇《java并发系列(1)——基本概念与Thread API》
2.3 线程共享
多个线程并发访问共享资源时,有可能产生线程安全问题。
线程安全包括三个方面:原子性、可见性、有序性,也可以说是并发三大特性。
2.3.1 cpu 缓存模型
2.3.1.1 背景
cpu 的运算是在一个容量较小但读写速度很快的寄存器上进行的,而数据是存储在计算机物理内存(主存)的。所以 cpu 需要从内存读取数据到寄存器进行运算,然后再把数据更新到内存。
随着技术发展,cpu 运算速度飞速提升,而内存访问速度一直没有太大的突破。于是内存访问速度就成了一个瓶颈。
怎么办?上缓存。(就跟 mysql 访问速度很慢,我们通常会一次性把数据全都拉到内存操作一个道理。)
2.3.1.2 cpu 缓存模型
上了缓存之后,就有了这样一个模型:
每个 cpu 核心有自己的寄存器、L1 缓存、L2 缓存,而 L3 缓存是共享的,然后通过总线连接其它组件。
如果数据在缓存里面,就直接使用缓存数据,缓存里面没有才去内存里面找,这样就解决了内存访问速度太慢的问题。
然而,又引入了新的问题:缓存一致性。
2.3.1.3 cpu 缓存一致性问题
每个 cup 核心都缓存了一份自己的数据副本,这样就会出现一个问题,比如:
- core1,core2 都从 memory 加载了数据到 L1;
- core1 经过运算改动了数据,写到了 L1,但还没有刷新到 memory;
- 或者 core1 已经刷新数据到 memory,但 core2 还没有从 memory 重新加载数据;
- 那么此时,core1 和 core2 在各自缓存里的数据就不一样。
解决办法有两个:
- 总线锁:总线在同一时间只能被一个 cpu 核心使用;
- 缓存一致性协议:对于共享变量,读操作不做特殊处理;写操作发信号通知其它 cpu 共享变量缓存失效。
2.3.2 java 内存模型(JMM)
如果程序员直接面向操作系统编程,不同操作系统很多特性存在差异,那么代码移植性就会很差。所以 java 对底层操作系统做了一层封装,也就是 JVM。同时 java 定义了自己的内存规范,也就是 JMM。这样,对 java 程序员来说,就只需要关注 JMM 即可,而 JMM 的实现对 java 程序员来说则是透明的。
java 内存模型与 cpu 缓存模型类似,它规定了若主存由多个线程更新时,对主存的读操作能看到哪些值。
具体如下:
- 主内存(堆内存)线程共享;
- 每个线程有自己的工作内存(本地内存、栈内存);
- 线程必须先把需要的数据(基本类型、对象引用)从堆加载到栈;
- 用完之后把数据刷新到堆,而什么时候刷新数据是不确定的。
2.3.3 并发三大特性
现在借助于 JMM 来看并发的三大特性。
2.3.1.1 原子性
指一个操作要么还没执行,要么已经执行完,执行到一半的中间状态不可能被其它线程看到。
java 原生的原子性操作:
- 所有引用类型的读写操作;
- 除 long/double 以外的基本类型的读写操作。
对于 long 和 double 类型, java 不强制要求 JVM 保证其读写操作的原子性。
例:
(1)x=1 是原子性操作
某线程先在本地内存写入 x=1,再把 x=1 写入主内存。其它线程要么看到 1 被写入之前的值,要么 1 被写入之后的值,也就是 1,不可能看到其它值。
(2)x=1L 非原子性操作
某线程可能刚写入了高位 32 位,这时切换到了其它线程,其它线程可能会看到写到一半的值。
(3)x++ 非原子操作
分三步:先从主内存把 x 读取到线程本地内存,在本地内存中把 x + 1,再把 x 的值写到主内存。
未保证原子性导致的意外结果,代码示例:
package per.lvjc.concurrent.sync;
public class AtomicityTest {
private static int sum = 0;
public static void main(String[] args) throws InterruptedException {
Runnable runnable = () -> {
for (int i = 0; i < 10000; i++) {
sum++;
}
};
int size = 10;
Thread[] threads = new Thread[size];
for (int i = 0; i < size; i++) {
Thread thread = new Thread(runnable);
thread.start();
threads[i] = thread;
}
for (int i = 0; i < size; i++) {
threads[i].join();
}
System.out.println("sum = " + sum);
}
}
10 个线程个做 1万次自增,执行结果经常不等于 10 万。
2.3.1.2 可见性
指一个线程对共享变量进行了修改,其它线程能立刻看到最新值。
java 原生不保障可见性。一个线程修改了共享变量的值之后,最新的值什么时候会被写入主内存,什么时候其它线程会从主内存重新读取最新的值都是不确定的。
当然,java 提供了一些措施来保证可见性。
未保证可见性导致的意外结果,代码示例:
package per.lvjc.concurrent.sync;
import java.util.concurrent.TimeUnit;
public class VisibilityTest {
private static int i = 0;
public static void main(String[] args) throws InterruptedException {
Thread write = new Thread(() -> {
while (i < 5) {
i++;
System.out.println("write: i =" + i);