1|内存模型与Volatile
一、Java 内存模型
Java虚拟机规范中定义了一种Java内存模型(Java Memory Model,JMM)用来屏蔽各种硬件和操作系统的内存访问差异,这也是Java语言跨平台的一个支撑。
1、Java内存模型主要目标是定义 实例字段、静态字段 和 构成数组对象的元素 这种“全局”(或者说共享)变量的访问规则。像 局部变量 和 方法参数 是属于线程私有,不会被共享,不存在竞争问题。
2、Java内存模型规定了所有的变量(指的是全局/共享)都存在主内存(Main Memory)中,每条线程的工作内存(Working Memory)不能直接读写他们,线程需要先拷贝变量的副本到自己工作内存中,对这个副本进行操作(读取、赋值等)。不同线程之间也无法直接访问对方工作内存中的变量,需要通过主内存来完成。
线程、主内存、工作内存三者之间的关系图如下:

主内存主要对应于Java堆中的对象实例;
工作内存对应于虚拟机栈中的部分区域。
3、主内存与工作内存之间是如何进行变量拷贝、赋值的?
Java内存模型中定义了以下8种操作,这些操作是原子的、不可再分的
- lock(锁定):作用于主内存变量,标识这个变量为一条线程独占
- unlock(解锁):作用于主内存变量,把处于锁定状态的变量释放出来
- read(读取):作用于主内存变量,把变量的值从主内存中传输到线程的工作内存中,方便随后的load操作
- load(载入):作用于工作内存变量,把read读取到的变量值放入工作内存的变量副本中
- use(使用):作用于工作内存变量,把工作内存中的变量值传递给执行引擎
- assign(赋值):作用于工作内存变量,把执行引擎接收到的值赋给工作内存变量
- store(存储):作用于工作内存变量,把工作内存变量传输到主内存中,方便随后的write操作
- write(写入):作用于主内存变量,把store传输来的变量值放入主内存的变量中
8种操作步骤图示如下(描述了线程A执行“a+1”的操作):

当然Java内存模型对以上8种操作还做了一系列的要求,比较繁琐,有兴趣的可以去了解。
二、关键字volatile
关键字volatile是Java虚拟机提供的轻量级同步机制,和关键字 synchronized有所区别。
通常我们会说volatile保证内存可见性、禁止指令重排,但是不保证操作原子性。
那么什么是内存可见性,什么是指令重排,什么是操作原子性?volatile是怎么保证和做不到的?
1、内存可见性
当一个线程修改了共享变量的值,这个变量的新值能立即反映到其他线程中。volatile是通过要求线程在使用这个变量的时候先去主内存同步这个变量的值来保证内存可见性。
那为什么要保证内存可见性?我们通过一个例子来说明:
假设场景:线程A进行配置初始化,线程B等线程A初始化好后做一些其他操作,代码如下:
public class Test01 {
/* 配置是否已初始化 */
static boolean isConfigInit = false;
public static void main(String[] args) {
Test01 example = new Test01();
// 线程A
new Thread(() -> {
try {
// 模拟程序加载一段时间
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
example.configInit();
}).start();
// 线程B
new Thread(() -> {
try {
example.doSomething();
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
}
/**
* 配置初始化
*/
public void configInit() {
// 初始化逻辑代码 ...
// 初始化完成之后,标识改为true
isConfigInit = true;
System.out.println("初始化完成");
}
/**
* 做一些事
*/
public void doSomething() throws InterruptedException {
while (true) {
if (isConfigInit) {
System.out.println("开始做一些事");
// 做一些事代码 ...
break;
}
}
}
}
执行上面的代码,你会发现 isConfigInit 明明已经修改为 true 了,但是线程B执行的 doSomething() 方法中 “开始做一些事” 一直未打印。
这是因为线程A对变量值的修改并未实时反映到线程B中。
但是如果给变量 isConfigInit 加上volatile 修饰符,立马就会不一样。
2、操作原子性
指在一次操作或多次操作中,要么所有的操作全部都得到了执行并且不会受到任何因素的干扰,要么全都不执行。
Java内存模型中的8种操作步骤在内存模型层面都保证了操作原子性,但是步骤与步骤之间可能会插入其他操作。
volatile 不保证对数据操作的原子性,因此是线程不安全的,如果要解决这个问题,可以使用锁机制或者使用原子类。
下面我通过一个例子来说明:
假设场景:有多个线程对同一个变量进行 +1 操作,我们看看volatile能不能保证获得正确结果
public class Test02 {
public static volatile int race = 0;
public static void increase() {
race++;
}
private static final int THREADS_COUNT = 20;
public static void main(String[] args) throws InterruptedException {
Thread[] threads = new Thread[THREADS_COUNT];
for (int i = 0; i < THREADS_COUNT; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < 1000; j++) {
increase();
}
});
threads[i].start();
}
// 主线程等其他线程都执行完
for (int i = 0; i < THREADS_COUNT; i++) {
threads[i].join();
}
System.out.println(race);
}
}
上述程序启动了20个线程,循环执行 1000 次 increase() 方法,正常结果应该是 race = 20000。但是执行上述程序后发现每次结果都会不一样,且大多数会小于 20000 这个结果。
这是因为一个线程从获取值到执行再到赋值时(对应8种操作步骤中的3~8),其他线程可能已经改变了 race 的值。
那么 volatile 在多线程下是不安全的,就不要使用了吗?这个要看场景的:
- 运算结果并不依赖变量的当前值,或者能够确保只有单一线程修改变量的值,其他线程只能查看。比如例1中的场景
- 变量不需要与其他的状态变量共同参与不变约束
在不符合以上两种场景中,我们需要通过锁来保证原子性。比如例2中,我们给方法 increase() 加上 synchronized,去掉 volatile 关键字,就能得到正确结果。
3、指令重排序
为了提高性能,编译器和处理器常常会对指令做重排序优化。但是会遵守“线程内表现为串行的语义”(Within-Thread As-If-Serial Semantics),也就是单线程的执行结果不能被改变。