文章目录
1、并行和并发
- 并行:多个线程同时执行。线程 A 在执行某个操作的同时,线程 B 也在执行另一个操作,就像生活中,你可以一边听音乐,一边打游戏一样。
- 并发:多个线程去访问同一个资源。典型的像电商秒杀系统,线程 A 抢订单,线程 B 支付订单,都对订单(同一个资源)进行操作。
2、JMM 的内存模型
需要强调的是,JMM 和 JVM 是两个不同的概念。
- JVM:Java 虚拟机
- JMM: Java 内存模型
不像 JVM 一样将内存划分为堆、栈等多个区域进行管理,JMM 是一种抽象的概念,本身并不真实存在。
这是一张来自网络的 JMM 抽象结构示意图:
每个线程在创建时,JVM 都会为其创建一个工作内存。这个 工作内存 属于线程,是每个线程格子的私有数据区域,其他的线程是访问不到的。例如上图中的本地内存 A 和本地内存 B。
所有的变量实际上都存储在主内存。这个 主内存 是公共区域,所有线程都可以访问这个内存区域。
2.1 关于工作内存和主内存
变量存储在主内存中。主内存其实就是电脑的物理内存。理所当然的,主内存只会有一份,它是一个公共区域,所有线程都可以去访问。
但是线程对变量的操作,例如取值、赋值,都是在线程自己的工作内存中进行的。
首先,线程会将主内存的变量拷贝回自己的工作内存,然后,在自己的这个内存区域对变量进行操作,这个操作完成后,再将自己的工作内存中的变量写回到主内存中。
2.2 JMM 特性
JMM 是大多数多线程开发需要遵守的规范。JMM 内存模型有三大特性:
- 可见性
- 原子性
- 有序性
2.2.1 可见性
当一个线程修改了主内存的值,并写回到了主内存,其他的线程必须马上知道这种变化,以便重新读取主内存最新的值,这种机制就是 可见性。也就是说,只要主内存的值获得了修改,其他的线程必须第一时间知晓。
2.2.2 原子性
原子性,指的就是不可分割、完整性,也即某个线程正在做某个具体业务时,中间不可以被加塞、分割或者被其他线程覆盖。需要整体完整。
线程在完成一个可能含有多个步骤的操作时,要么同时成功,要么同时失败。
2.2.3 有序性
程序在执行时,为了提高性能,编译器和处理器常常会对指令做一个重排操作:
大概就是,假设源代码的指令执行顺序是①②③④⑤,底层为了保证执行的性能效果更好,可能会让编译器或处理器做一个优化,最后按③④⑤①②的顺序来执行。
数据的依赖性:
// 语句1
int a = 1;
// 语句2
int b = 2;
// 语句3
a = a + 3;
// 语句4
b = a + 4;
在多线程并发的场景下,由于线程间存在竞争,底层为了保证执行的性能效果更好,指令在重排后可能会按①②③④、①③②④的顺序执行,但是,像②④①③这样的顺序,就不行,因为 b 依赖于 a,这就是上图中的处理器在进行指令重排序时,必须考虑数据的依赖性
3、volatile 保证可见性
以代码说明:
/**
* 验证 volatile 的可见性
* 在主内存中 int num = 0; num变量没有添加volatile关键字修饰,控制台结果
* 添加了volatile后,控制台结果
*/
public class VolatileDemo {
public static void main(String[] args) {
// 访问资源类
MyData myData = new MyData();
// 第一个线程----“A”
// 修改主内存中的共享变量num
new Thread(() -> {
System.out.println(Thread.currentThread().getName() + "\t come in");
// 暂停一会儿线程
try {
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 第一个线程对主内存中的变量值num进行修改
myData.addTo60();
System.out.println(Thread.currentThread().getName() + "\t update num value: " + myData.num);
}, "A").start();
// 3s后,第一个线程将num从0改变为60
// 第二个线程----main
// 第一个线程 A 修改了主内存的num值为60,此时验证第二个线程 main线程 是否知道主内存的值发生了变化
// 如果 myData.num == 0,说明main线程感知不到主内存的值发生了变化,可见性没有被触发
// 如果 myData.num != 0,说明main线程感知到了num已经从0变为了60,可见性被触发
while (myData.num == 0) {
// main线程一直在这里等待循环,直到num的值不再等于0
}
// 如果下面这条输出语句能打印出来,说明main线程感知到了num已经从0变为了60,可见性被触发
// 控制台结果:main线程一直处于等待状态,说明main线程感知不到另一个线程已经修改了变量并写回了主内存,没有触发可见性
System.out.println(Thread.currentThread().getName() + "\t mission is over, main is get num value: " + myData.num);
}
}
/**
* 主物理内存
*/
class MyData {
/**
* 共享变量
*/
int num = 0;
public void addTo60() {
this.num = 60;
}
}
现在将 num 变量添加 volatile 关键字修饰:
/**
* 共享变量
*/
// int num = 0;
volatile int num = 0;
// 在num变量前面加了关键字volatile后,控制台的结果:main线程输出了60,保证了可见性
// 由此说明,volatile 能保证线程的可见性
System.out.println(Thread.currentThread().getName() + "\t mission is over, main is get num value: " + myData.num);
4、volatile 不保证原子性
/**
* 验证 volatile 不保证原子性
*/
public class VolatileDemo {
public static void main(String[] args) {
// 访问资源类
MyData myData = new MyData();
// 创建20个线程,每个线程进行num自增1000次的操作
for (int i = 1; i <= 20; i++) {
new Thread(() -> {
for (int j = 1; j <= 1000; j++) {
// 不保证原子性
myData.addPlusPlus();
}
}, String.valueOf(i)).start();
}
// 等待上面20个线程都全部计算完成后,再用main线程取得最终的结果值是多少
// Thread.activeCount() > 2 说明线程还没有算完(后台默认两个线程:main和GC)
while (Thread.activeCount() > 2) {
Thread.yield();
}
// 如果最终结果是20000,则符合原子性,否则,不符合
// 控制台结果:输出的计算结果每次都不同 -- 说明volatile不保证原子性
System.out.println(Thread.currentThread().getName() + "\t finally num value: " + myData.num);
}
}
/**
* 主物理内存
*/
class MyData {
/**
* 共享变量
*/
volatile int num = 0;
/**
* 注意,此时num前面是加了volatile关键字修饰的,为了验证volatile不保证原子性
*/
public void addPlusPlus() {
num ++;
}
}
4.1 为什么 volatile 不保证原子性(num++)?
参考图示,一步步分析:
- 假设主内存中有个 volatile 修饰的变量 num,
volatile num = 0
- 在并发的情况下,线程 A、B、C 都要来执行
addPlusPlus()
中的num++
操作 - 由于线程在自己的工作场合操作完成后,都要将自己的修改产物写回给主内存。当 A、B、C 同时读到了num = 0:
- 正常情况下:
① A、B、C 读取到了num = 0
,保存在各自的工作内存;
② A 在执行完num ++
后,写回到主内存,此时主内存中num = 1
,然后,A 通知其它线程主内存的值发生了变化(num 被 volatile修饰,volatile 保证可见性);
③ B 及时接收到通知,再次读取到主内存的num = 1
,执行完num ++
后,写回主内存,此时主内存中num = 2
。然后,B 通知其它线程主内存的值发生了变化;
④ C 及时接收到通知,再次读取到主内存num = 2
,执行完num ++
后,写回到主内存,此时主内存中num = 3
。然后,C 通知其它线程主内存的值发生了变化。
- 但是,由于多线程之间竞争的调度:
① A 在准备将num = 1
写回到主内存的时候,被挂起了
② B 顺利竞争到了执行权,执行完num ++
操作后,将num = 1
写回到了主内存,此时,主内存num = 1
③ 然后,B 要通知其它线程主内存的num值已经发生了变化
④ 但是,由于线程操作太快,在 B 还没有来得及通知到位的时候,A 获得了主动权,然后 A 将num = 1
写回到了主内存,此时,主内存中,A 的num = 1
覆盖了之前 B 的num = 1
,也就是说,数据发生了丢失。 - 分析到上一步时,问题已经出现了。再进一步分析底层的汇编字节码:
- 执行
javap -c
,num ++ 在底层汇编被拆分成 3 个指令:
也就是说,num ++ 并不是一条指令,实际上是3条指令,这三条指令在执行过程中,任何一个步骤都可以被打断或加塞(如果没有加同步代码块的话),也就造成了第5步分析中出现的问题。getField # 获取原始 num 值 iadd # 执行加1操作 PutField # 将累加后的值写回
4.2 解决 原子性 问题
- 使用同步代码块
synchronized {}
- 使用 j.u.c 包下的 AtomicXXX 类
/**
* 解决原子性问题
* 加synchronized ---- 杀鸡用牛刀
* 直接使用j.u.c下面的AtomicInteger
*/
public class VolatileDemo {
public static void main(String[] args) {
// 访问资源类
MyData myData = new MyData();
// 创建20个线程,每个线程进行num自增1000次的操作
for (int i = 1; i <= 20; i++) {
new Thread(() -> {
for (int j = 1; j <= 1000; j++) {
// 保证原子性
myData.addByAtomic();
}
}, String.valueOf(i)).start();
}
// 需要等待上面20个线程都全部计算完成后,再用main线程取得最终的结果值是多少
// Thread.activeCount() > 2 说明线程还没有算完(后台默认两个线程:main和GC)
while (Thread.activeCount() > 2) {
Thread.yield();
}
// 如果最终结果是20000,则符合原子性,否则,不符合
// 控制台结果:输出的计算结果每次都不同 -- 说明volatile不保证原子性
System.out.println(Thread.currentThread().getName() + "\t AtomicInteger type, finally num value: " + myData.ai);
}
}
/**
* 主物理内存
*/
class MyData {
/**
* 共享变量
*/
volatile int num = 0;
/**
* 使用原子类
*/
AtomicInteger ai = new AtomicInteger();
public void addByAtomic() {
ai.getAndIncrement();
}
5、volatile 禁止指令重排
由于编译器或处理器存在指令重排,程序执行的顺序和数据可能会被打乱,底层认为这是一种优化,但在多线程环境下,却可能会导致数据的最终一致性无法保证。
volatile 实现禁止指令重排优化,从而避免多线程环境下出现乱序执行的现象。
5.1 内存屏障
内存屏障是一个 CPU 指令,它有两个作用:
- 保证特定操作的执行顺序
- 保证某些变量的内存可见性
由于编译器和处理器都能进行指令重排优化,如果在指令间插入一条内存屏障(它其实就是一条指令),就会告诉 编译器和 CPU ,不管什么指令,都不能和这条 内存屏障 指令重排序,也就是说,通过插入 内存屏障 禁止在 内存屏障 前后的指令进行重排优化。内存屏障的另外一个作用,就是强制刷出各种 CPU 的缓存数据,因此,任何 CPU 上的线程都能读取到这些数据的最新版本。
JMM 规定 多线程开发 必须遵守 可见性、原子性、有序性,来保证线程的安全性。而 volatile 只满足了 可见性 和 有序性,volatile不保证原子性,因此,也就是常说的,volatile 是一种轻量级的同步机制。
更新记录
无
喜欢就点个赞呗~~(*/ω\*)