1.1 volatile的理解
volitile 是 Java 虚拟机提供的轻量级的同步机制,三大特性:
- 保证可见性。
- 不保证原子性。
- 禁止指令重排。
1.2 JMM基本概念
JMM 本身是一种抽象的概念,并不真实存在,它描述的是一组规则或者规范。
JMM 同步的规定:
- 线程解锁前,必须把共享变量的值刷新回主内存。
- 线程加锁前,必须读取主内存的最新值到自己的工作内存。
- 加锁解锁是同一把锁。
JMM即为JAVA 内存模型(java memory model)。因为在不同的硬件生产商和不同的操作系统下,内存的访问逻辑有一定的差异,结果就是当你的代码在某个系统环境下运行良好,并且线程安全,但是换了个系统就出现各种问题。Java内存模型,就是为了屏蔽系统和硬件的差异,让一套代码在不同平台下能到达相同的访问结果。
1.2.1 JMM内存模型
JMM规定了内存主要划分为主内存和工作内存两种。此处的主内存和工作内存跟JVM内存划分(堆、栈、方法区)是在不同的层次上进行的,如果非要对应起来,主内存对应的是Java堆中的对象实例部分,工作内存对应的是栈中的部分区域,从更底层的来说,主内存对应的是硬件的物理内存,工作内存对应的是寄存器和高速缓存。
代码示例
package cn.guardwhy.volatile01;
import java.util.concurrent.TimeUnit;
/*
* JMM内存模型
*/
public class JMMDemo01 {
private static int num = 0;
public static void main(String[] args) { // 线程1(main线程)
new Thread(()->{ // 线程2
while (num == 0){
}
}).start();
try {
// 休眠1s
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
num = 1;
System.out.println("num的值:" + num);
}
}
线程1感知不到线程2操作了值的变化!如何能够保证线程间可以同步感知这个问题呢?只需要使用Volatile关键字即可!volatile 保证线程间变量的可见性,简单地说就是当线程A对变量X进行了修改后,在线程A后面执行的其他线程能看到变量X的变动,更详细地说是要符合以下两个规则 :
- 线程对变量进行修改之后,要立刻回写到主内存。
- 线程对变量读取的时候,要从主内存中读,而不是缓存
- 各线程的工作内存间彼此独立,互不可见,在线程启动的时候,虚拟机为每个内存分配一块工作内存,不仅包含了线程内部定义的局部变量,也包含了线程所需要使用的共享变量(非线程内构造的对象)的副本,即,为了提高执行效率。
![](https://i-blog.csdnimg.cn/blog_migrate/484d901ec3e2e15a0ef383ae3b09b7eb.png)
1.2.2 内存交互操作
内存交互操作有8种,虚拟机实现必须保证每一个操作都是原子的,不可在分的。
- lock (锁定):作用于主内存的变量,把一个变量标识为线程独占状态。
- unlock (解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
- read (读取):作用于主内存变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。
- load (载入):作用于工作内存的变量,它把read操作从主存中变量放入工作内存中。
- use (使用):作用于工作内存中的变量,它把工作内存中的变量传输给执行引擎,每当虚拟机遇到一个需要使用到变量的值,就会使用到这个指令。
- assign (赋值):作用于工作内存中的变量,它把一个从执行引擎中接受到的值放入工作内存的变量副本中。
- store (存储):作用于主内存中的变量,它把一个从工作内存中一个变量的值传送到主内存中,以便后续的write使用。
- write (写入):作用于主内存中的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中
JMM对这八种指令的使用,制定了如下规则
- 不允许read和load、store和write操作之一单独出现。即使用了read必须load,使用了store必须write。
- 不允许线程丢弃他最近的assign操作,即工作变量的数据改变了之后,必须告知主存。
- 不允许一个线程将没有assign的数据从工作内存同步回主内存。
- 一个新的变量必须在主内存中诞生,不允许工作内存直接使用一个未被初始化的变量。就是怼变量实施use、store操作之前,必须经过assign和load操作。
- 一个变量同一时间只有一个线程能对其进行lock。多次lock后,必须执行相同次数的unlock才能解锁。
- 如果对一个变量进行lock操作,会清空所有工作内存中此变量的值,在执行引擎使用这个变量前,必须重新load或assign操作初始化变量的值。
- 如果一个变量没有被lock,就不能对其进行unlock操作。也不能unlock一个被其他线程锁住的变量。
- 对一个变量进行unlock操作之前,必须把此变量同步回主内存。
1.2.3 结论
JMM对这八种操作规则和对volatile的一些特殊规则就能确定哪里操作是线程安全,哪些操作是线程不安全的了。但是这些规则实在复杂,很难在实践中直接分析,更多的时候,使用java的happen-before规则来进行分析。
happen-before规则
happens-before就是先行发生,在Java内存模型中,happens-before 应该翻译成:前一个操作的结果可以被后续的操作获取。通俗点说就是前面一个操作把变量赋值为3,那后面一个操作肯定知道变量已经变成了3。所以为了解决多线程的可见性问题,就搞出了happens-before原则,让线程之间遵守这些原则。编译器还会优化java语句,所以等于是给了编译器优化的约束。
1.3 保证可见性
代码示例
package cn.guardwhy.volatile01;
import java.util.concurrent.TimeUnit;
/*
保证可见性
*/
public class VolatileDemo01 {
// 不加 volatile程序会出现死循环,加上volatile可以保证可见性。
private volatile static int num = 0;
public static void main(String[] args) {
new Thread(()->{ // 线程2对主内存的变化不知道
while (num == 0){
}
}).start();
try {
// 休眠1s
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
num = 1;
System.out.println("num的值:" + num);
}
}
1.4 不保证原子性
原子性理解:不可分割,完整性,也就是某个线程正在做某个具体的业务的时候,中间不可以被加塞或者被分割,要整体完整,要么同时成功,要么同时失败。
代码示例
package cn.guardwhy.volatile01;
/*
验证volatile 不保证原子性
*/
public class VolatileDemo02 {
private volatile static int num = 0;
public static void add(){
num++;
}
public static void main(String[] args) {
// 1.理论上num的值应该为2w
for (int i = 1; i <= 20 ; i++) {
new Thread(()->{
for (int j = 0; j < 1000; j++) {
// 2.调用方法
add();
}
}).start();
}
while (Thread.activeCount()>2){
// 礼让
Thread.yield();
}
System.out.println(Thread.currentThread().getName() + " " + num);
}
}
执行结果
命令行查看底层字节码代码实现:javap -c VolatileDemo02.class
num++ 在多线程下是非线程安全的
不加Lock锁和synchronized,如何保证原子性
使用原子类, 解决原子性的问题。
![](https://i-blog.csdnimg.cn/blog_migrate/b11ceaf261abd2d2b27fb6fa2925d865.png)
代码示例
package cn.guardwhy.volatile01;
import java.util.concurrent.atomic.AtomicInteger;
// volatile 不保证原子性
public class VolatileDemo02 {
// 原子类的Integer
private volatile static AtomicInteger num = new AtomicInteger();
public static void add(){
num.getAndIncrement(); // AtomicInteger + 1 方法, CAS
}
public static void main(String[] args) {
// 1.理论上num的值应该为2w
for (int i = 1; i <= 20 ; i++) {
new Thread(()->{
for (int j = 0; j < 1000; j++) {
// 2.调用方法
add();
}
}).start();
}
while (Thread.activeCount()>2){
// 礼让
Thread.yield();
}
System.out.println(Thread.currentThread().getName() + " " + num);
}
}
执行结果
源码分析:
这些原子类的底层都直接和操作系统挂钩!!!允许在内存中修改值!!unsafe类是个很特殊的存在。
1.5 指令重排
计算机在执行程序时,为了提高性能,编译器和处理器的常常会对指令做重排,一般分以下3种
- 单线程环境里面确保程序最终执行结果和代码顺序执行的结果一致。
- 处理器在进行重排序时必须要考虑指令之间的数据依赖性。
- 多线程环境中线程交替执行,由于编译器优化重排的存在,两个线程中使用的变量能否保证一致性是无法确定的。
int x = 1; // 1
int y = 2; // 2
x = x + 5; // 3
y = X * x; // 4
期待结果是: 1234 但是可能执行的时候会变成 2134 1324, 但是不可能是 4123!!!
可能造成影响的结果: a b x y 这四个值默认都是 0
线程A | 线程B |
---|---|
x = a | y = b |
b = 1 | a = 2 |
正常的结果:x = 0; y = 0; 但是可能由于指令重排
线程A | 线程B |
---|---|
b = 1 | a = 2 |
x = a | y = b |
指令重排导致的诡异结果: x = 2; y = 1;
volitile可以避免指令重排:
内存屏障, CPU指令。基本作用:
1、保证特定的操作。
2、可以保证某些变量的内存可见性(利用这些特性volitile实现了可见性)。
Volitile 是可以保持可见性,不能保证原子性,但是由于内存屏障,可以保证避免指令重排的现象产生!!!