java内存模型(JMM)全称为Java Memory Model,是java虚拟机为了java程序能够正常运行而制定的一套规范,规范中规定了JVM中的数据如何与RAM的数据进行交互。
我们知道,在Java中,实例字段、静态字段和构成数组对象的元素是线程共享的,但局部变量与方法参数是线程私有的,不会被共享。所以以下我们所讲的变量等均指线程共享的数据,而非线程私有的数据。
Java 内存模型中规定了所有的变量都存储在主内存中,每个线程还有自己的工作内存(类比缓存理解),线程的工作内存中保存了该线程使用到主内存中的变量拷贝,线程对变量的所有操作(读取、赋值)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同线程之间无法直接访问对方工作内存中的变量,线程间变量值的传递(通信)均需要在主内存来完成,线程、主内存和工作内存的交互关系如下图所示
这个图和 CPU 与缓存的图非常类似,搞不好 JMM 的构建就是仿照硬件系统来的。同样的道理我们要思考一下在多线程的环境中,JMM 又是如何保证主内存和工作内存中的变量一致性?回忆一下 CPU 是如何保证缓存一致性的,使用 MESI 协议。那在这里呢,Java 内存模型就定义了 8 种操作和 8 个规则。
回头想想,JMM 是一套规则呀,它只会给你定义规范,模型,具体的实现自己玩去!理解这一点很重要。我们来看看它给出了哪些操作和必须满足的规则吧。
如果要把一个变量从主内存中复制到工作内存,就需要按顺序地执行 read 和 load 操作,如果把变量从工作内存同步回主内存中,就要按顺序地执行 store 和 write 操作。Java 内存模型只要求上述操作必须按顺序执行,而没有保证必须是连续执行。也就是 read 和 load 之间,store 和 write 之间是可以插入其他指令的,如对主内存中的变量 a、b 进行访问时,可能的顺序是 read a,read b,load b, load a。Java 内存模型还规定了在执行上述八种基本操作时,必须满足如下规则:
好了,到这里看似我们可以完美的保证多线程情况下主内存和工作内存中数据的一致性了(也就是线程安全),But 醒醒好不好,JMM 只是一套规则呀,请问你实现了么???并没有……
对了,还是要说明一下,我从开始到现在都在介绍 JMM 看清楚这不是 JVM 啊,我的理解啊,JMM 是灵魂,是规范,是一个标准,那我们说的 JVM 其实是一个个的实现,其中比较著名的一个实现就是 HotSpot VM,好了,现在的问题就比较清楚了,接口中已经告诉你了,你只需要满足 8 个操作和 8 条规则,你就是线程安全的。
问题是,我们的 HotSpot VM 怎么实现???先别急,还有一个事请呢!
你难道忘了吗,在 CPU 执行指令的时候会存在乱序执行优化,这里也是一样啊,也会为了提高执行效率而对我们写的代码进行优化,但是呢,这里换一个叫法,改名指令重排。
指令重排可以加快程序的执行效率,但在某些情况下可能引起程序BUG。比如下面这种情况
/**
* 一个简单的展示Happen-Before的例子.
* 这里有两个共享变量:a和flag,初始值分别为0和false.在ThreadA中先给 a=1,然后flag=true.
* 如果按照有序的话,那么在ThreadB中如果if(flag)成功的话,则应该a=1,而a=a*1之后a仍然为1,下方的if(a==0)应该永远不会为
* 真,永远不会打印.
* 但实际情况是:在试验100次的情况下会出现0次或几次的打印结果,而试验1000次结果更明显,有十几次打印.
*/
public class SimpleHappenBefore {
/** 这是一个验证结果的变量 */
private static int a=0;
/** 这是一个标志位 */
private static boolean flag=false;
public static void main(String[] args) throws InterruptedException {
//由于多线程情况下未必会试出重排序的结论,所以多试一些次
for(int i=0;i<1000;i++){
ThreadA threadA=new ThreadA();
ThreadB threadB=new ThreadB();
threadA.start();
threadB.start();
//这里等待线程结束后,重置共享变量,以使验证结果的工作变得简单些.
threadA.join();
threadB.join();
a=0;
flag=false;
}
}
static class ThreadA extends Thread{
public void run(){
a=1;
flag=true;
}
}
static class ThreadB extends Thread{
public void run(){
if(flag){
a=a*1;
}
if(a==0){
System.out.println("ha,a==0");
}
}
}
}
产生上诉问题的原因是因为flag=true在a=1之前执行完毕了。我们假设a=1是一个很耗时的操作,那么CPU就有可能会把相对不耗时的操作提前执行。
那 JMM 又是怎么来处理多线程下的指令重排呢?JMM 提供了一个关键字 volatile 来解决指令重排问题。
关于 volatile 关键字,JMM 专门定义了一套特殊的访问规则,主要为达到两个目的,一是保证此变量对所有变量的可见性。二是禁止指令重排优化。解释一下第一个目的,我们知道在主内存和工作内存中变量交互的时候,假如线程将变量 a + 1,还没有写入主内存的时候,其它线程是不知道 a 的值被修改了。那现在就是希望我在工作内存改了变量之后,其它的线程能看到变量被改了。
我们经常会误用 volatile 关键字,虽然被 volatile 修饰的变量 i 是可见的,我们也保证 i 的值是可以实时从主存中获取,但是这并不代表 i ++ 就是线程安全的,因为 i ++ 不是原子性操作,可以被拆成 geti addi puti 3步。
好了,到这里 JMM 就有了一套解决多线程安全问题的方案,这套方案又有哪些特性呢,或者说,线程安全的特性有哪些呢?
原子性:我们要求一个线程在操作数据的时候,不能被打断。Java 内存模型定义的 8 种操作,就要求虚拟机的实现每一步都必须是原子性的,即不可分割的。
对于基本数据类型的读或写可以看成是原子性操作,但是有例外,对于32位的机器来说,一次只能处理32位,但是 double 和 long 类型的数据长度为 64,理论上会可能会被拆分,但实际运行中没有这种情况,所以可以认为对 double 和 long 来说,读或写也是线程安全的。
可见性:因为在多线程环境下主内存和工作内存中数据不一致可能会导致问题,可见性要求一个线程修改了主内存中的值之后,其它的线程能立即得知这个修改。
有序性:主要体现在在单线程时逻辑上的有序,在定义 8 种操作规则的时候的有序,还有最后的指令重排中的有序。这八种操作规则就是大家常说的happens-before 原则。他包括如下
如果两个操作的执行顺序不能通过 happens-before 原则推导出来,就不能保证他们的执行次序,虚拟机就可以随意的对他们进行重排序。
可以看到,要说清楚Java的内存模型其实并不容易,简单而言就是java有自己的线程,线程可和主内存中的数据进行交互,但往细里说,如何保证多线程交互的过程不出现问题实际上才是JMM的难点。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。