Java的内存模型JMM

什么是JMM内存模型

Java内存模型描述的是一组规则,通过这组规则控制程序中各个变量在共享数据区域和私有数据区域的访问方式,通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式。从而脱离于操作系统的硬件模型,可以看到JMM内存模型与计算机的硬件有很多相似之处;

计算机模型:
在这里插入图片描述

JMM内存模型:
在这里插入图片描述

通过制定这个规则,每个JVM实现都遵循这一规则,从而可以使java实现“一次编译,到处运行”。其大致规则如下:
JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(栈空间),用于存储线程私有的数据,而Java内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问,但线程对变量的操作(读取赋值等)必须在工作内存中进行,首先要将变量从主内存拷贝的自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存,不能直接操作主内存中的变量,工作内存中存储着主内存中的变量副本拷贝,前面说过,工作内存是每个线程的私有数据区域,因此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成。

JMM内存模型定义的8种操作

针对JMM定义的对变量的操作,JMM定义了8中操作,这8中操作的每一个操作JMM保证是原子性的,其8中操作如下:

  1. lock(锁定):作用于主内存的变量,把一个变量标记为一条线程独占状态。
  2. unlock(解锁):作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
  3. read(读取):作用于主内存的变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用 。
  4. load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中 。
  5. use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎。
  6. assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量 。
  7. store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作 。
  8. write(写入):作用于工作内存的变量,它把store操作从工作内存中的一个变量的值传送到主内存的变量中 。

对应流程如下:
在这里插入图片描述
如果要把一个变量从主内存中复制到工作内存中,就需要按顺序地执行read和load操作, 如果把变量从工作内存中同步到主内存中,就需要按顺序地执行store和write操作。但Java内 存模型只要求上述操作必须按顺序执行,而没有保证必须是连续执行。

这里解释一下上述的几个操作:
read(读取)是CPU将共享变量从主内存中读取到CPU缓存中,熟悉Java线程栈空间及运行机制的应该了解,线程运行时会有一个存储变量的局部变量表,那么在CPU读取共享变量到缓存中,则需要将共享变量加载至工作内存中(Java栈空间)的局部变量表中,那么紧接着会进行load(加载)的操作。相反的,在CPU计算出结果后,将结果assign(赋值)给工作内存(Java栈空间)中的局部变量表的某一个局部,如果要刷新主内存中,首先需要将工作内存(Java栈空间)中的共享变量副本加载到CPU缓存中,然后通过write(写入)操作将共享变量的值更新(个人理解,可能有偏差)。

需要注意的是针对这8中操作,JMM可以保证其实按序执行的,但不能保证是连续执行的。针对这8中操作,JMM还定义了如下的规则:

  1. 不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步回主内存中;
  2. 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或者assign)的变量。即就是对一个变量实施use和store操作之前,必须先自行assign和load 操作。
  3. 一个变量在同一时刻只允许一条线程对其进行lock操作,但lock操作可以被同 一线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。lock和unlock必须成对出现。
  4. 如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量之前需要重新执行load或assign操作初始化变量的值。
  5. 如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作;也不允许去unlock一个被其他线程锁定的变量。
  6. 对一个变量执行unlock操作之前,必须先把此变量同步到主内存中(执行store和write操作);

JMM的原子性

原子性是指一个操作是不可中断的,即使是多线程环境下,一个原子操作是不会受其他线程影响的。JMM中对基本数据类型的读取和赋值操作均是原子的,但是对于double和long类型的不可保证。

Int x = 1;// 原子性
y = x;// 不是原子的  读取x的值->给y赋值
x++;  // 不是原子的 读取x的值 ->给x值加1

看如下代码:

public class JMMAtomicDemo {
    public static int count = 0;
    public static void increase() {
        count++;
    }
    public static void main(String[] args) throws Exception{
        Runnable runnable = new Runnable() {
            public void run() {
                for (int i = 0; i < 5000; i++) {
                    increase();
                }
            }
        };
        Thread t1 = new Thread(runnable);
        Thread t2 = new Thread(runnable);
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(count);
    }
}

运行三次结果:

10000
8086
7862

结果是不确定的,因为线程t1和t2两个工作空间的数据相互是不可见的,那么t1修改了数据,t2还是拿的旧数据,那么t1将修改的值刷新到主内存,t2是不知道的,那么t2会将在旧数据的操作刷新到主内存中,从而导致结果不一致。那么使用valitile是否可以避免呢?
答案是不可避免,这是为什么呢?首先,valitile是可以保证内存可见的,因为valitile调用了汇编语言的lock指令从而触发缓存一致性协议,缓存一直性协议通过cpu嗅探总线信息可以保证变量在修改后别的cpu的缓存信息可以即时的修改状态从而实现内存可见性,但是变量的自增操作cpu是不能保证其是原子性的,也就是说可能存在cpuA和cpuB缓存中均存在变量x的缓存,那么两个cpu均对变量值自增,假设cpuA先自增成功并其缓存行状态修改为M,那么cpuB在执行自增操作完后修改缓存行状态时发现已经无效,那么就会丢弃这次操作,接着下一次的操作,所以说在上面的代码即使加上了valitile关键字也不能解决问题,但是我们应该了解不加vatitle和加valitile导致结果不正确的原因是不同的:

不加valitile
不加valitile是因为工作空间的变量对于别的工作空间的变量是不可见的,所以说存在使用旧值进行自增操作并将就职刷新到主内存中。这种情况是对变量count主内存的值进行了10000次的赋值,但是每一赋值不一定对。

加valitile
加上valitile后基于缓存一致性协议,会出现缓存行失效的情况,从而导致当次循环的自增操作丢弃,从而使自增的操作减少,所以结果可能会到不到10000。这种情况是每一次的赋值都保证了在原先的旧值上加1,但是没有保证成功的执行10000次。(这也能解释为什么AtomicInteger中是一直循环赋值直到成功,因为CAS机制与MESI相似)。

当然以上均是个人理解,可能有偏差。

当然解决中这种问题还是需要借助于sychronized关键字或者Lock,这种方法是保证操作自增时只有一个线程可以进行,同时自增完毕后将结果刷新到主内存中,说白了就是在自增时所有线程串行执行,那么这样就没有并发的问题了(AtomicInteger也可以解决,后续再讨论)。

JMM的可见性

可见性就是一个线程修改了共享变量,其他线程可以看到,那么立刻将修改的值刷新到主内存中,其他线程在使用时从主内存中重新读取。当然JMM是无法保证内存可见性的,valitile是可以保证内存可见性的(MESI一致性协议),sychronized和Lock也可以保证内存可见性(对变量加锁,操作完成后刷新到内存,别的线程看到的就是最新的数据)。

JMM的有序性

在Java里面,可以通过volatile关键字来保证一定的“有序性”(volatile有序性原理)。另外可以通过synchronized和Lock来保证有序性,很显然,synchronized 和Lock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。

指令重排序
java语言规范规定JVM线程内部维持顺序化语义。即只要程序的最终结果与它顺序化情况的结果相等,那么指令的执行顺序可以与代码顺序不
一致,此过程叫指令的重排序。指令重排序的意义是什么?JVM能根据处理器特性(CPU多级缓存系统、多核处理器等)适当的对机器指令进行重排序,使机器指令能更符合CPU的执行特性,最大限度的发挥机器性能。
下图为从源码到最终执行的指令序列示意图 :
在这里插入图片描述
as-if-serial语义
as-if-serial语义的意思是:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。

当然指令重排序和 “as-if-serial” 均不是Java所特有的,但是Java中也提供了一些规范来保证JMM的有序性,这样我们在代码开发中就不用了没写几行代码就需要考虑它的有序性问题,那么Java为了保证有序性做了哪些努力呢?

happens-before原则
只靠sychronized和volatile关键字来保证原子性、可见性以及有序性,那么编写并发程序可能会显得十分麻烦,幸运的是,从JDK 5开始,Java使用新的JSR-133内存模型,提供了happens-before 原则来辅助保证程序执行的原子性、可见性以及有序性的问题,它是判断数据是否存在竞争、线程是否安全的依据,happens-before 原则内容如下

  1. 程序顺序原则,即在一个线程内必须保证语义串行性,也就是说按照代码顺序执行。
  2. 锁规则 解锁(unlock)操作必然发生在后续的同一个锁的加锁(lock)之前,也就是说, 如果对于一个锁解锁后,再加锁,那么加锁的动作必须在解锁动作之后(同一个锁)。
  3. volatile规则 volatile变量的写,先发生于读,这保证了volatile变量的可见性,简单的理解就是,volatile变量在每次被线程访问时,都强迫从主内存中读该变量的值,而当该变量发生变化时,又会强迫将最新的值刷新到主内存,任何时刻,不同的线程总是能 够看到该变量的最新值。
  4. 线程启动规则 线程的start()方法先于它的每一个动作,即如果线程A在执行线程B的start方法之前修改了共享变量的值,那么当线程B执行start方法时,线程A对共享变量的修改对线程B可见 。
  5. 传递性 A先于B ,B先于C 那么A必然先于C 。
  6. 线程终止规则 线程的所有操作先于线程的终结,Thread.join()方法的作用是等待当前 执行的线程终止。假设在线程B终止之前,修改了共享变量,线程A从线程B的join方法 成功返回后,线程B对共享变量的修改将对线程A可见。
  7. 线程中断规则 对线程 interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测线程是否中断。
  8. 对象终结规则 对象的构造函数执行,结束先于finalize()方法 。

happens-before原则为代码开发保证有序性做出了很多“约定”,那么开发人员在开发时则就不需要纠结以上罗列的几种问题了,单时还是会有其他情况的有序性问题是需要注意的。

总结

JMM究竟是什么呢?我觉得可以称之为“约定”,或者说定义了底层JVM实现的规范。JMM的规范就是围绕着原子性、有序性和可见性来展开的。JMM中提供了8中原子操作,这是JMM在原子性方面提供的便利。JMM在可见性方面的处理则需要借助volatile、sychronized关键字和Lock等特别语义的关键字或者API来实现。对于有序性,JMM通过提供了happens-before原则来保障,happens-before我觉的可以理解为JVM规范,所有JVM的实现均需实现这些规则,保证这方面的有序。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值