java内存模型 (JMM)
这种模型和 jvm 中的堆不同, JMM 是抽象概念,不真实存在。它是一种规范,指定了程序中的各变量的访问方式
主内存
JMM 规定所有变量都存放在主内存,主内存是所有线程共享的,但是线程的操作在线程的工作内存中进行,先从主内存读取到线程的工作内存中,然后执行操作,再将工作内存中的值写入主内存中。线程不能直接操作主内存中的数据
工作内存
工作内存是线程独有的,不同的线程无法访问到对方的工作内存,线程间的通信必须通过主内存传值进行
JMM 描述的是变量在共享区域和私有区域的访问方式,变量的访问在多线程下会有 可见性,原子性,可见性三大问题
可见性问题
因为有工作内存的划分,一个线程操作修改某变量的值,没有同步到主内存前,其他线程是无法读取到该变量最新的值,就导致了变量在另外的线程不可见。
示例:
public class CodeVisibility {
private static boolean initFlag = false;
// private volatile static boolean initFlag = false;
private static int counter = 0;
public static void refresh() {
System.out.println("refresh data.......");
initFlag = true;
System.out.println("refresh data success.......");
}
public static void main(String[] args) {
Thread threadA = new Thread(() -> {
while (!initFlag) {
counter++;
}
System.out.println("线程:" + Thread.currentThread().getName()
+ "当前线程嗅探到initFlag的状态的改变, counter: " + counter);
}, "threadA");
threadA.start();
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
Thread threadB = new Thread(() -> {
refresh();
}, "threadB");
threadB.start();
}
}
结果:
可见看到线程A久久不能结束,虽然线程B此时已经修改了 initFlag 的值,但是线程A无法读取到最新值,因为一直没有和主内存同步
volatile
这个关键词可以让变量被修改后立刻使其他线程中的副本可见。在上面的示例代码中,把第3行注释,第4行取消注释后再运行:
可以看到线程A可以立即结束
volatile 原理
- 使用 volatile 关键字会强制将在某个线程中修改的共享变量的值立即写入主内存。
- 使用 volatile 关键字, 当线程 2 进行修改时, 会导致线程 1 的工作内存中变量的缓存行无效(反映到硬件层的话, 就是 CPU 的 L1或者 L2 缓存中对应的缓存行无效);
- 由于线程 1 的工作内存中变量的缓存行无效,所以线程1再次读取变量的值时会去主存读取。
特性
- 只能控制变量的可见性
- 不能解决原子性问题
- 还可以禁止CPU指令重排(见下)
volatile 番外
如果不对 initFlag 添加 volatile 标识,线程A就永远无法读取到initFlag的最新值吗?
不一定, 在判断 initFlag 的值时,CPU 先从缓存中取值,只要缓存失效,就会重新在从内存中加载。那么什么时候缓存会失效呢? 对于CPU缓存来说,分为 L1 L2 L3 三级缓存,也就是离CPU最近的那些寄存器,他们的速度依次递减,容量依次递增。而每次CPU缓存的最小单位不是某个变量所占的空间大小,而是固定的字节 ,这样就能减少CPU和内存交互的次数,更好的利用空间局部原理和时间局部性原理。具体细节可以搜索 CPU缓存相关信息
因为CPU一次会让一批缓存失效,有可能 initFlag 的缓存会随着其他值失效而重新从内存加载最新值。如下例子:
public class CodeVisibility {
// initFlag 不再用 volatile 修饰
private static boolean initFlag = false;
// 这里 counter 类型从 int 修改成 Integer
private static Integer counter = 0;
public static void refresh() {
System.out.println("refresh data.......");
initFlag = true;
System.out.println("refresh data success.......");
}
public static void main(String[] args) {
Thread threadA = new Thread(() -> {
while (!initFlag) {
counter++;
}
// 线程仍然可以很快的结束,因为 counter 会导致 cpu 缓存失效,重新从主内存加载最新数据
System.out.println("线程:" + Thread.currentThread().getName()
+ "当前线程嗅探到initFlag的状态的改变, counter: " + counter);
}, "threadA");
threadA.start();
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
Thread threadB = new Thread(() -> {
refresh();
}, "threadB");
threadB.start();
}
}
结果:
那么问题又来了,为什么用 int 不会 导致 cpu 缓存失效呢?
个人推测可能使因为 int 比 Integer 所占用的内存更小,CPU缓存放得下,一直没有触发缓存失效。
有序性问题
先看一个例子:
public class VolatileReOrderSample {
//定义四个静态变量
private static int x=0,y=0;
private static int a=0,b=0;
public static void main(String[] args) throws InterruptedException {
int i=0;
while (true){
i++;
x=0;y=0;a=0;b=0;
//开两个线程,第一个线程执行a=1;x=b;第二个线程执行b=1;y=a
Thread thread1=new Thread(new Runnable() {
@Override
public void run() {
//线程1会比线程2先执行,因此用nanoTime让线程1等待线程2 0.01毫秒
shortWait(10000);
a=1;
x=b;
}
});
Thread thread2=new Thread(new Runnable() {
@Override
public void run() {
b=1;
y=a;
}
});
thread1.start();
thread2.start();
thread1.join();
thread2.join();
//等两个线程都执行完毕后拼接结果
String result="第"+i+"次执行x="+x+"y="+y;
//如果x=0且y=0,则跳出循环
if (x==0&&y==0){
System.out.println(result);
break;
}else{
System.out.println(result);
}
}
}
//等待interval纳秒
private static void shortWait(long interval) {
long start=System.nanoTime();
long end;
do {
end=System.nanoTime();
}while (start+interval>=end);
}
}
复制代码
按照正常思维,永远不会发生 x=0 y=0的场景,但事实并非如此:
下面是线程A B 可能的正常执行情况
- 线程A执行完 线程B执行
- 线程B执行完 线程A执行:
- 线程A B 交叉执行
发生指令重排的情况
指令重排
处理器为了程序的性能可以对程序的执行顺序进行重排,但是,必须满足重排后的执行结果在单线程下结果不能发生改变 这就是 as-if-serial 语义
为了遵守 as-if-serial 语义,编译器和处理器不会对存在数据依赖的操作进行重排,因为会改变执行结果,如果两个操作不存在依赖关系,就有可能会被重排,就入上面的代码,在线程A中a=1;x=b
这两各操作没有依赖关系,就有可能会重新排序成x=b;a=1
, 线程B同理。
这个执行重排的操作在单线程下没有关系,因为没有影响到最终的执行的结果,但是如果是多线程的场景,就像上面的那个例子,就会发生错误
如何禁止指令重排
volatile
volatile 另一个作用是禁止指令重排,避免多线程下出现乱序执行的情况
重排规则表:
从上面的规则可以看出:
- 当第二个操作是 volatile 写时,不管第一个操作是什么,都不能发生重排,
- 当第一个操作是 volatile 读时,不管第二个操作是什么,都不能发生重排
- 当第一个操作时 volatile 写,第二个操作是 volatile 读时,不能发生重排
加锁保证有序性
另外还可以使用 synchronize 和 lock 来保证有序性,因为加锁后,每时每刻只有一个线程执行代码,指令重排对单线程没有影响
禁止指令重排的经典应用
看下懒汉模式的单例的问题:
// 懒汉模式 + synchronized 同步锁 + double-check
public final class Singleton {
private static Singleton instance= null;// 不实例化
private Singleton(){}// 构造函数
public static Singleton getInstance(){// 加同步锁,通过该函数向整个系统提供实例
if(null == instance){// 第一次判断,当 instance 为 null 时,则实例化对象,否则直接返回对象
synchronized (Singleton.class){// 同步锁
if(null == instance){// 第二次判断
instance = new Singleton();// 实例化对象
}
}
}
return instance;// 返回已存在的对象
}
}
为了在多线程并发场景下单例仍然有效,加了锁以及双重检测,但是就万无一失了吗?
在第一个判断 if(null == instance)
中,会出先变量instance有值,但是内存区域是空的(没有初始化 ),从而导致程序出现问题。造成这个问题的原因在于 instance = new Singleton()
,事实上初始化对象操作不是原子性的,它包含下面两个动作:
- 给对象分配内存空间,
- 内存空间的初始化
- 将内存地址赋值给变量
其中2,3没有依赖关系,经过 编译器或者cpu指令重排后,可能会导致 2,3顺序发生变化:
- 给对象分配内存空间,
- 将内存地址赋值给变量 //此时变量已经不等于 null, 但是变量指向的内存区域还没有初始化
- 内存空间的初始化
假设线程A按照第二种顺序执行,在执行完步骤3时,还没有执行步骤2,线程B执行到第一个if(null == instance)
判断,就会直接返回 instance。 这样对于线程B来说 getInstance()
方法返回的是一个没有经过初始化的对象,导致程序出现问题
解决问题的方法很简单: private volatile static Singleton instance= null;
使用 volatile 关键词禁止 instance 变量被执行指令重排优化即可
原子性问题
指的使一个操作是不可中断的,即使在多线程环境下,一旦操作开始就不会被其他线程影响
java 中可以通过 synchronize 和 lock 保证原子性,它们能保证任意时刻只有一个线程访问代码