JMM
感谢此视频:JMM
(一)认识JMM
Java线程内存模型跟CPU缓存模型类似,是基于cpu缓存模型来建立的,Java线程内存模型是标准化的,屏蔽掉了底层不同计算机的区别。
理解: 如上图,线程A和线程B都要操作同一个共享变量时,并不是直接和主内存交互,而是将共享变量副本读取到各自的工作内存中,线程和工作内存交互。但是,当其他线程把工作内存中的共享变量改了之后,其他线程不一定能感知到,如下边的代码,第二个线程更改了 initFlag 的值,但是第一个线程依然感觉不到,所以一直在死循环当中。
public class VolatileVisibilityTest {
private static boolean initFlag = false;
public static void main(String[] args) throws InterruptedException {
/**
* 第一个线程,执行循环
*/
new Thread(() -> {
System.out.println("waiting data...");
while (!initFlag){
}
System.out.println("=======================success");
}).start();
Thread.sleep(2000);
/**
* 第二个线程,更改共享变量 initFlag 的值
*/
new Thread(() -> {
prepareData();
}).start();
}
public static void prepareData(){
System.out.println("preparing data...");
initFlag = true;
System.out.println("preparing data end... ");
}
}
优化 要想在一个线程中修改了共享变量对其他线程有可见,只需在共享变量前用volatile修饰,如下代码:
public class VolatileVisibilityTest {
private static volatile boolean initFlag = false;
public static void main(String[] args) throws InterruptedException {
/**
* 第一个线程,执行循环
*/
new Thread(() -> {
System.out.println("waiting data...");
while (!initFlag){
}
System.out.println("=======================success");
}).start();
Thread.sleep(2000);
/**
* 第二个线程,更改共享变量 initFlag 的值
*/
new Thread(() -> {
prepareData();
}).start();
}
public static void prepareData(){
System.out.println("preparing data...");
initFlag = true;
System.out.println("preparing data end... ");
}
}
volatile在这里保证了内存可见性,更深入的理解之后慢慢道来。
(二)JMM数据原子操作
- read(读取):从主内存中读取数据。
- load(载入):将主内存读取到的数据写入工作内存。
- use(使用):从工作内存读取数据来计算。
- assign(赋值):将计算好的值重新赋值到工作内存中。
- store(存储):将工作内存写入主内存。
- write(写入):将store过去的变量赋值给主内存中的变量。
- lock(锁定):将主内存变量加锁,标识为线程独占状态。
- unlock(解锁):将主内存变量解锁,解锁后其他线程可以锁定该变量。
图解
每个原子操作的工作示例图
JMM缓存不一致问题解决方案
-
总线加锁(性能太低)
cpu从主内存读取数据到高速缓存,会在总线对这个数据加锁,这样其他cpu没法去读或写这个数据,直到这个cpu使用完数据释放锁之后其他cpu才能读取该数据。如下图:
-
MESI缓存一致性协议
多个cpu从主内存读取同一个数据到各自的告诉缓存,当其中某个cpu修改了缓存里的数据,该数据马上同步回主内存,其他cpu通过总线嗅探机制可以感知到数据的变化从而将自己缓存里的数据失效。
Volatile可见性底层实现原理
底层实现主要是通过汇编lock前缀指令,他会锁定这块内存区域的缓存(缓存行锁定)并回写到主内存。
IA-32架构软件开发者手册对lock指令的解释:
1)会将当前处理器缓存行的数据立即写回到系统内存。
2)这个写回内存的操作会引起其他CPU缓存了该内存地址的数据无效(MESI协议)。
一个问题: MESI缓存一致性协议下,当两个以上的线程同时需要向主内存同步数据的时候,是怎么操作的?
加锁的位置放在store操作之前,力度变小,当多个线程想往主内存中写数据的时候,通过争抢“锁”的方式,谁抢到锁谁先执行。同时,锁住的只是store和write两个内存级的原子操作,内存操作速度非常快,锁能影响的性能可以忽略不记。同时,将锁移出总线处,不影响每个线程执行read和load操作。
(三)可见性、原子性与一致性
- 并发编程三大特性:可见性,原子性,有序性。
- volatile保证可见性与有序性,但是不保证原子性,保证原子性需要借助synchronized这样的锁机制。 下边这段代码执行的结果可能小于10000.
public class VolatileAtomicTest {
public static volatile int num =0;
public static void increase(){
num ++;
}
public static void main(String[] args) throws InterruptedException {
Thread[] threads = new Thread[10];
for (int i = 0; i < threads.length; i++) {
threads[i] = new Thread(() ->{
for (int j = 0; j < 1000; j++) {
increase();
}
});
threads[i].start();
}
// join 操作保证以上线程都被执行完之后才会继续执行下边的代码
for(Thread t : threads){
t.join();
}
System.out.println(num); // 1000*10
}
}
原因: 如下图,当线程1和线程2同时执行use、assign操作进行num++时,如果线程1store回主内存的时候抢到“锁”,成功把值修改为了1,此时回触发cpu总线嗅探机制,使得线程2的这次num++操作失效,重新去主内存read、load数据。这就是volitile不能保证线程的原子性。
上述问题的解决: 使用synchronized修饰方法.
- 有序性理解:CPU出于性能的优化,会对代码执行顺序进行重排序(如下边代码注释处),我们可以在共享变量前加上volatile修饰保证有序性。原理是:volatile关键字修饰的变量在汇编语言中会加上lock指令,从而具备内存屏障功能,CPU看到lock指令不会对程序进行重排序。
public class VoliatileSerialTest {
// volatile关键字保证有序性
volatile static int x = 0, y = 0;
public static void main(String[] args) throws InterruptedException {
Set<String> resultSet = new HashSet<>();
Map<String,Integer> resultMap = new HashMap<>();
for (int i = 0; i < 1000000; i++) {
x = 0; y = 0;
resultMap.clear();
Thread one = new Thread(() ->{
// CPU可能会对一下两行代码的执行顺序重排序,也就是说,x =1 可能先于 int a = y 执行
int a = y;
x = 1;
resultMap.put("a",a);
});
Thread other = new Thread(() -> {
int b = x;
y = 1;
resultMap.put("b",b);
});
one.start();
other.start();
one.join();
other.join();
resultSet.add("a="+resultMap.get("a")+","+"b="+resultMap.get("b"));
System.out.println(resultSet);
}
}
}