美文网首页
Java 内存模型

Java 内存模型

作者: 都是浮云啊 | 来源:发表于2019-02-02 22:36 被阅读0次

    [TOC]

    1.内存模型的相关概念

    1.1 操作系统语义

    计算机在运行程序时,每条指令都是在CPU中执行的,在执行过程会涉及到数据的读写。我们知道程序运行的数据是存储在主存中的,这时候就会有一个问题,读写主存中的数据没有CPU中执行指令的速度快,如果任何的交互都需要与主存打交道会大大影响效率,所有就有了CPU高速缓存,CPU高速缓存为某个CPU独有,只与在该CPU运行的线程有关。有了CPU高速缓存虽然解决了效率问题,但是会带来一个新的问题:数据的一致性。在程序运行中,会将运行所需要的数据复制一份到CPU高速缓存中,在运行计算时CPU不再和主存打交道,而是直接从高速缓存中读写数据,只有当运行结束后,才会将数据刷新到主存中。
    i=i+1;这个操作,当线程执行到这段代码时,首先会从主存中读取 i 的值(假设此时i=1),然后复制一份到CPU高速缓存中,然后CPU执行+1操作(此时i=2),然后将i=2写入高速缓存,然后刷新到主存中。这个过程看起来没有问题,但是仅仅是单线程的情况下。在多线程中,如下:
    假设线程A和线程B同时执行i=i+1这个操作,如果一切正常的话,最终的结果应该是3。如下:
    两个线程从主存中读取i的值(i=1),到各自的高速缓存中,然后线程A执行+1操作并将结果写入高速缓存中,最后写入主存中,此时主存i=2.线程B做同样的操作,主存中的i仍然为=2。所以最终结果是2而不是3,这就是缓存一致性问题
    解决策略:

    1. 通过在总线加lock锁的形式
    2. 通过缓存一致性协议
      第一种方案,存在一个问题,使用独占锁的方式的话,只能有一个CPU运行,其他的都会被堵塞,效率低下
      第二种方案,缓存一致性协议(MESI协议),它确保每个缓存中使用的共享变量的副本是一致的:当某个CPU在写数据时,如果发现操作的变量是共享变量,则会通知其他的CPU告知该变量的缓存行是无效的,因此其他CPU在读取该变量时,发现其无效会重新从主存中加载数据,此时加载的就是刚刚发生通知事件更新后的数据。PS:目前最新的CPU增加了缓存锁来保证原子性


      更新
    1.2 和 JMM 相关的一些名词

    1.1 中了解到如何保证数据一致性的问题,而这个一致性是在 JMM(Java Memory Mode) 完成的,而要完成这个数据一致性问题,就不可避免要碰到三个重要的概念

    1. 原子性: 原子性就是一个或者多个操作,同生共死,在单线程的环境中都是原子性的,但是在多线程环境下就不一样了,需要通过额外的手段保证原子性,比如 synchronized ,锁这种机制,Java只保证了基本数据类型的变量和赋值操作是原子的。
    2. 可见性: 可见性是指多个线程在访问同一个变量时,一个线程修改这个变量,其它线程能够立马看得到,前面的那个图里说过了,线程都是先修改自己局部的值,而不是直接修改主存中的值。而 volatile 是Java提供可见性的手段,当一个变量被它修饰后,表示线程本地内存中的这个值无效,当一个线程修改共享变量后,这个变量会立刻被更新的主存种,其它变量读取的时候也直接从主存中读取,当然 前面的同步机制也能保证可见性的。
    3. 有序性:有序性就是程序执行的顺序按照代码的先后顺序执行,在JMM中,为了效率是允许编译器和处理器对指令进行重排序的,大前提是重排序不会影响操作的结果,volatile 也是Java提供有序性的手段

    2. volatile关键字

    2.1 volatile是个啥

    synchronized是一个重量级锁,虽然JVM进行了很多的优化(锁升级、粗化等)。而 Volatile 则是轻量级的synchronized,它在多线程开发中保证了共享变量的“可见性”。如果一个变量使用Volatile,则它比使用synchronized的成本更加低,因为它不会引起线程上下文的切换和调度。Java允许线程访问共享变量,为了确保共享变量能被准确和一致地更新,线程应该确保通过排它锁单独获得这个变量。简单的说就是一个变量如果用Volatile修饰了,则Java可以确保所有线程看到这个变量的值是一致的。如果某个线程对Volatile修饰的共享变量进行更新,那么其他线程可以立马看到这个更新,这就是线程可见性。volatile 的内存语义就是: 保证可见性与禁止重排序。

    2.2 volatile的原理

    volatile可以保证线程可见性且提供了一定的有序性,但是无法保证原子性。在JVM底层,volatile是采用“内存屏障”来实现的。内存屏障是一类同步屏障指令,是CPU或编译器在对内存随机访问的操作中的一个同步点,使得这个点之前的所有写操作都执行后才开始这个点之后的操作。内存屏障用来控制特定条件下的重排序和内存可见性问题。

    1. 保证可见性、不保证原子性:这很容易理解,volatile就是干这个的
    2. 禁止指令重排序:在执行程序时为了提高性能,编译器和处理器通常会对指令做重排序:
    • 编译器重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
    • 处理器重排序:如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。

    指令重排序对单线程没有什么影响,也不会影响程序的运行结果,但是会影响多线程的正确性。既然指令重排序会影响到多个线程执行的正确性,那么我们就需要禁止重排序。

    2.3 关于volatile的4个内存屏障

    java的内存屏障通常所谓的四种即 LoadLoad,StoreStore,LoadStore,StoreLoad
    LoadLoad:对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
    LoadStore:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
    StoreStore:对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
    StoreLoad:对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能

    java编译器会在生成指令系列时在适当的位置会插入内存屏障指令来禁止特定类型的处理器重排序。为了实现volatile的内存语义,JMM会限制特定类型的编译器和处理器重排序

    1. 在每个volatile写操作的前面插入一个StoreStore屏障
    2. 在每个volatile写操作的后面插入一个StoreLoad屏障
    3. 在每个volatile读操作的后面插入一个LoadLoad屏障
    4. 在每个volatile读操作的后面插入一个LoadStore屏障

    3.Happens before 原则

    3.1 happens-before的定义

    由于存在本地内存和主内存的原因,加上重排序,会导致在多线程环境下存在可见性问题,但是如果线程A修改了变量a的话,这个修改被其他线程的可以看到的时机是不可掌控的(不同场景,可见的时机可能不同,有的可能想让修改的操作写入,有的想让修改时就可见).
    虽然时机对不同的场景不同,但是我们可以制定一些规则,这些规则就是happeds-before.
    从JDK5开始,JMM就采用happeds-before规则来阐述两个线程之间的可见性。
    在JMM中,如果一个操作的结果对另一个操作可见,那么这两个操作之间就必须存在happens-before关系.

    happens-before原则非常重要,它是判断数据是否在多线程之间存在竞争,线程是否安全的重要依据,依靠这个原则,我们可以解决在并发环境下两个操作可能存在冲突的所有问题。

    i = 1;  //线程A执行
    j = i;  //线程B执行
    

    如上面的一段代码,j是否一定等于1呢?假设线程A的操作 happens-before线程B的操作,那么j一定等于1.如果没有这个关系,j不一定为1,其实这就是happens-before的作用。

    定义:

    1. 如果操作A happens-before 操作B,那么操作A执行的结果对操作B可见,并且执行顺序操作A一定在操作B前面。
    2. 两个操作存在 happens-before关系,并不意味着一定要按照happens-before规则制定的顺序执行。如果重排序之后的结果与happens-before的结果一致,这种重排序就是合发的。
    3.2 happens-before 的的规则

    下面8条都是Java原生满足的happens-before规则

    1. 程序次序规则:就是一个线程内,书写在前面的操作,happens-before于书写在后面的操作
    2. 锁定规则:一个锁的unlock操作,一定happens-before于后面的对同一个锁的Lock操作。
    3. volatile 变量规则:对一个变量的写操作,happens-before 于后面对这个变量的读操作。
    4. 传递规则:如果操作 A happens-before 操作 B,而操作 B happens-before 操作C,则可以得出,操作 A happens-before 操作C
    5. 线程启动规则:Thread 对象的 start 方法,happens-before 此线程的每个一个动作。
    6. 线程中断规则:对线程 interrupt 方法的调用,happens-before 被中断线程的代码检测到中断事件的发生。
    7. 线程终结规则:线程中所有的操作,都 happens-before 线程的终止检测,我们可以通过Thread.join() 方法结束、Thread.isAlive() 的返回值手段,检测到线程已经终止执行。
    8. 对象终结规则:一个对象的初始化完成,happens-before 它的 finalize() 方法的开始

    通过上面8条,我们可以自己推出其它的满足happens-before的规则

    1. 将一个元素放入一个线程安全的队列的操作,happens-before 从队列中取出这个元素的操作。(取线程的前提是队列中有线程)
    2. 将一个元素放入一个线程安全容器的操作,happens-before 从容器中取出这个元素的操作。(取元素的前提是容器中有元素)
    3. 在 CountDownLatch 上的 countDown 操作,happens-before CountDownLatch 上的 await 操作。
    4. 释放 Semaphore 上的 release 的操作,happens-before 上的 acquire 操作。
    5. Future 表示的任务的所有操作,happens-before Future 上的 get 操作
    6. 向 Executor 提交一个 Runnable 或 Callable 的操作,happens-before 任务开始执行操作。

    如果两个操作不存在上述(前面8条 + 后面6条)任一一个 happens-before 规则,那么这两个操作就没有顺序的保障,JVM 可以对这两个操作进行重排序。如果操作 A happens-before 操作 B,那么操作A在内存上所做的操作对操作B都是可见的。

    3.4 举一个推导是否满足happens-before的例子
    private int i = 0;
    public void write(int j){
     i = j;
    }
    public int read(){
     return i;
    }
    
    

    假设线程A执行write()方法,线程B执行read()方法,且线程A优先于线程B执行。基于happens-before规则分析如下:

    1. 两个方法是不同线程调用,不满足程序次第规则
    2. 两个方法都没有使用锁,不满足锁定规则
    3. 变量i不是volatile修饰,不满足volatile变量规则
    4. 就俩线程,不存在传递规则
    5. 规则5678和推导的6个和它没关系
      所以,我们无法通过 happens-before 原则,推导出线程 A happens-before 线程 B 。虽然,可以确认在时间上,线程 A 优先于线程 B 执行,但是就是无法确认线程B获得的结果是什么,所以这段代码不是线程安全的。这段代码如果需要保证线程安全,满足上面2 3 其中一个即可。所以所,happens-before原则是JMM中重要的规则,它是判断是否竞争是否需要同步的重要依据

    4. 重排序 as if serial 语义

    在执行程序时,为了提高性能,处理器和编译器常常会对指令进行重排序,但是不能随意重排序,不是你想怎么排序就怎么排序,它需要满足以下两个条件:

    • 在单线程环境下,不能改变程序运行的结果。
    • 存在数据依赖关系的情况下,不允许重排序。
      其实就是无法使用happens-before推导出来的,代表两个操作没有依赖关系,JMM就允许重排序
    4.1 as-if-serial

    as-if-serial 语义的意思是:所有的操作均可以为了优化而被重排序,但是你必须要保证重排序后执行的结果不能被改变,编译器、runtime、处理器都必须遵守 as-if-serial 语义。
    注意,as-if-serial 只保证单线程环境,多线程环境下无效.

    int a = 1 ; // A
    int b = 2 ; // B
    int c = a + b; // C

    A、B、C 三个操作存在如下关系:A、B 不存在数据依赖关系,A和C、B和C存在数据依赖关系。
    因此在进行重排序的时候,A、B 可以随意排序,但是必须位于 C 的前面,执行顺序可以是 A –> B –> C 或者 B –> A –> C 。但是无论是何种执行顺序最终的结果 C 总是等于 3 。
    as-if-serail 语义把单线程程序保护起来了,它可以保证在重排序的前提下程序的最终结果始终都是一致的。
    其实对于上面的代码,它们存在happens-before关系:

    • A happends-before 于B
    • B happends-before 于C
    • A happends-before 于C

    (1、2 是程序顺序次序规则,3 是传递性原则)。
    但是,不是说通过重排序,B 可能会排在 A 之前执行么,为何还会存在存在 A happens-before B 呢?
    这里再次申明 A happens-before B 不是 A 一定会在 B 之前执行,而是 A 的执行结果对 B 可见,但是相对于这个程序 A 的执行结果不需要对 B 可见,且他们重排序后不会影响结果,所以 JMM 不会认为这种重排序非法。
    操作系统的最高目的:在不改变程序执行结果的前提下,尽可能提高程序的运行效率。

    public class RecordExample1 {
        public static void main(String[] args){
            int a = 1;
            int b = 2;
            try {
                a = 3;           // A
                b = 1 / 0;       // B
            } catch (Exception e) {
            } finally {
                System.out.println("a = " + a);
            }
        }
        
    }
    

    按照重排序的规则,操作 A 与操作 B 有可能会进行重排序,如果重排序了,B 会抛出异常( / by zero),此时A语句一定会执行不到,那么 a 还会等于 3 么?如果按照 as-if-serial 原则它就改变了程序的结果。
    其实,JVM对异常做了一种特殊的处理,为了保证 as-if-serial 语义,Java 异常处理机制对重排序做了一种特殊的处理:JIT 在重排序时,会在catch 语句中插入错误代偿代码(a = 3),这样做虽然会导致 catch 里面的逻辑变得复杂,但是 JIT 优化原则是:尽可能地优化程序正常运行下的逻辑,哪怕以 catch 块逻辑变得复杂为代价

    4.2 重排序对多线程的影响

    在单线程环境下,由于 as-if-serial 语义,重排序无法影响最终的结果,但是对于多线程环境呢?如下代码:
    A 线程先执行 #writer(),线程 B 后执行 #read(),线程 B 在执行时能否读到 a = 1 呢?答案是不一定(注:x86 CPU 不支持写写重排序)

    public class RecordExample2 {    
        int a = 0;
        boolean flag = false;
        /**A线程执行*/
        public void writer() {
            a = 1;                    // 1
            flag = true;               // 2
        }
        /**B线程执行*/
        public void read(){
            if (flag) {                 // 3
               int i = a + a;          // 4
            }
        }
    }
    

    由于操作 1 和操作 2 之间没有数据依赖性,所以可以进行重排序处理。
    操作 3 和操作 4 之间也没有数据依赖性,他们亦可以进行重排序,但是操作 3 和操作 2 之间存在控制依赖性。
    假如操作1 和操作2 之间重排序


    image.png

    B 肯定读不到线程 A 设置的 a 值,在这里多线程的语义就已经被重排序破坏了。

    实际上,操作 3 和操作 4 之间也可以重排序,虽然他们之间存在一个控制依赖的关系,只有操作 3 成立操作 4 才会执行。
    当代码中存在控制依赖性时,会影响指令序列的执行的并行度,所以编译器和处理器会采用猜测执行来克服控制依赖对并行度的影响。
    假如操作 3 和操作 4 重排序了,操作 4 先执行,则先会把计算结果临时保存到重排序缓冲中,当操作 3 为真时,才会将计算结果写入变量 i 中。
    通过上面的分析,重排序不会影响单线程环境的执行结果,但是会破坏多线程的执行语义。

    总结

    JMM(Java 内存模型) 规定了线程的工作内存和主内存的交互关系,以及线程之间的可见性和程序的执行顺序。
    一方面,要为程序员提供足够强的内存可见性保证。
    另一方面,对编译器和处理器的限制要尽可能地放松。JMM 对程序员屏蔽了 CPU 以及 OS 内存的使用问题,能够使程序在不同的 CPU 和 OS 内存上都能够达到预期的效果。
    Java 采用内存共享的模式来实现线程之间的通信。编译器和处理器可以对程序进行重排序优化处理,但是需要遵守一些规则,不能随意重排序。
    在并发编程模式中,势必会遇到下面三个概念:

    1. 原子性:一个操作或者多个操作要么全部执行要么全部不执行。
    2. 可见性:当多个线程同时访问一个共享变量时,如果其中某个线程更改了该共享变量,其他线程应该可以立刻看到这个改变。
    3. 有序性:程序的执行要按照代码的先后顺序执行。
      JMM 对原子性并没有提供确切的解决方案,但是 JMM 解决了可见性和有序性,至于原子性则需要通过锁或者 synchronized 来解决了。

    如果一个操作 A 的操作结果需要对操作 B 可见,那么我们就认为操作 A 和操作 B 之间存在happens-before 关系,即 A happens-before B 。
    happens-before 原则,是 JMM 中非常重要的一个原则,它是判断数据是否存在竞争、线程是否安全的主要依据,依靠这个原则,我们可以解决在并发环境下两个操作之间是否存在冲突的所有问题。
    JMM 规定,两个操作存在 happens-before 关系并不一定要 A 操作先于B 操作执行,只要 A 操作的结果对 B 操作可见即可。
    在程序运行过程中,为了执行的效率,编译器和处理器是可以对程序进行一定的重排序,但是他们必须要满足两个条件:

    1. 执行的结果保持不变
    2. 存在数据依赖的不能重排序。重排序是引起多线程不安全的一个重要因素。
      同时,顺序一致性是一个比较理想化的参考模型,它为我们提供了强大而又有力的内存可见性保证,他主要有两个特征:
      一个线程中的所有操作必须按照程序的顺序来执行。
      所有线程都只能看到一个单一的操作执行顺序,在顺序一致性模型中,每个操作都必须原则执行且立刻对所有线程可见。

    相关文章

      网友评论

          本文标题:Java 内存模型

          本文链接:https://www.haomeiwen.com/subject/mguesqtx.html