美文网首页
JVM-2:Java内存模型

JVM-2:Java内存模型

作者: 厨房里的工程师 | 来源:发表于2018-06-03 12:33 被阅读0次

    一、JMM的必要性

    众所周知,数据竞争(Data Racing)在并发编程中是个重要问题。操作系统的很大一部分任务就是在协调资源的分配,尤其是内存资源的分配。例如,线程A和线程B同时获取一个共享内存中的int变量,谁应该优先获取这个变量呢?从数据竞争衍生出的一个新问题则是线程间的通信问题,即内存可见性问题。线程间需要通信则是由线程共享处理器产生的,通常线程在Ready、Running、Blocked三个状态中不断切换,直到线程结束。

    States of a thread 因此,每个线程都无法保证使用内存资源时的“原子操作”,也就是会产生内存可见性问题。线程在更新内存时的状态: 线程更新内存

    不仅线程状态切换可以导致内存可见性问题。为了提升处理器性能,编译器在生成可执行指令以及处理器在执行指令时会对指令进行重排序。关于重排序,请参阅:

    重排序改变了程序编写时应有的顺序,因此产生了内存可见性问题。为了解决由线程切换和指令重排序产生的内存可见性问题,Java语言层面的内存模型提供了相应的解决方法,即Java内存模型(JMM)。

    二、JMM的内存可见性解决方法

    1. 重排序规则限制

    JMM在编译期间遵循了相关的指令重排序限制,以保证内存对相关线程可见。

    • 遵守数据依赖性: 在重排序过程中,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。
      数据依赖性:如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。
    • 遵从as-if-serial原则: 不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器,runtime和处理器都必须遵守as-if-serial语义。

    也就是说,没有数据依赖关系的操作有可能会被编译器或处理器重排序。下面是一个计算长方形周长的例子:

    int width = 10; // a
    int length = 15; // b 
    int perimeter = (width + length) * 2; // c
    

    a, b, c的依赖关系有:

    • a ---> c
    • b ---> c

    也就是c依赖于a操作和b操作,但是a操作和b操作不存在依赖关系。那么程序执行顺序有如下可能:

    • a ---> b ---> c 按顺序执行,结果为50
    • b ---> a ---> c 重排序执行,结果为50

    从上述结果可以得知:as-if-serial语义保证了程序的单线程执行结果不会被改变。而程序员在编写时并不知道编译后的操作顺序和处理器执行操纵的顺序,但也不用担心重排序会对我们想要的结果产生干扰。

    2. 关键字保护

    在JSR133中,JMM分别增强了final, volatile, synchronized这三个关键字的内存语义。在编译期和处理器运行指令时,有这三个关键字的指令将受到重排序保护,相关的指令不会被重排序。一起来看看JMM是如何实现这些保护的。

    三、 关键字保护

    1. Volatile

    1.1 Volatile语义

    当一个共享变量声明为volatile后,该变量的读/写将会很特别。被volatile保护的变量相当于改变量的读/写操作被锁保护起来了。来看下面两段代码(改自程晓明文章):

    class VolatileProtection {
        volatile long varOne = 0L;  // 使用volatile声明64位的long型变量
        public voiid set(long l) {
            varOne = l;             // volatile变量的单个写操作
        }
        public void increase() {
            varOne++;               // volatile变量的复合(多个)读/写操作
        }
        public long get(){
            return varOne;          // volatile变量的单个读操作
        }
    }
    

    假设有多个线程分别调用VolatileProtection中的setincreaseget方法,那么上述程序将有和以下程序相同的效果:

    class SynchronizedProtection {
        long varOne = 0L;          // 64位的long型普通变量
        public synchronized void set(long l) {    // 用锁同步普通变量的单个写操作
            varOne = l;             
        }
        public void increase() {   // 普通方法调用
            long temp = get();     // 调用已同步的读方法
            temp += 1L;            // 普通写操作
            set(temp);             // 调用已同步的写方法
        } 
        public synchronized long get() {         // 用锁同步普通变量的单个读操作
            return varOne;
        }
    }
    

    锁的语义决定了get()方法和set()方法的操作具有原子性。同样,受volatile保护的变量在读/写操作上也具有原子性。volatile的特性可以总结为:

    • 可见性:一个volatile变量的读,总是能看到任意线程对这个volatile变量最后的写入
    • 原子性:volatile变量的单个读/写句有原子性,但类似于volatile++这种复合操作不具原子性。

    1.2 Volatile的内存语义

    我们已经知道volatile变量的写/读具有原子性,那么volatile变量是如何在内存中实现这些语义的呢?来看看volatile写和读的内存语义。

    • Volatile写:当我们往共享内存中写入一个volatile变量时,JMM会把对应线程中的本地内存中的贡献变量值写入主内存(即共享内存)。
    • Volatile读:当我们读取一个volatile变量时,JMM会把对应线程的本地内存中现有的变量重置为无效,紧接着会从主内存中读取共享变量值。

    1.3 Volatile内存语义的实现

    前面说到JMM会在读volatile变量时重置本地内存,并在写volatile变量时将线程本地内存中的值刷入共享内存。在线程不断切换状态让出处理器的情况下,JMM如何保证这些操作的原子性呢? 这就涉及到JMM实现volatile读/写的内存语义的方法。

    JMM对编译器制定了有关volatile重排序的规则表:

    是否能重排序 第二个操作
    第一个操作 普通读/写 volatile读 volatile写
    普通读/写 NO
    volatile读 NO NO NO
    volatile写 NO NO

    由上表我们可以得知,JMM通过禁止与volatile读/写相关的重排序来保证volatile变量操作的原子性。为了实现相关指令的重排序保护,编译器会在volatile读/写操作的指令前后添加相关屏障(Barrier),因此处理器无法越过屏障进行重排序。

    2. Final

    2.1 Final的语义

    对于final域,编译器和处理器遵循以下两个重排序规则:

    • 在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
    • 初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作不能重排序。
    public class FinalExample {
        int i;                   // 普通变量
        final int j;             // final 变量
        static FinalExample obj;
        
        public void FinalExample() {    // 构造函数
            i = 1;                      // 写普通域  (可能被重排序到构造函数之外)
            j = 2;                      // 写final域 (不会被重排序到构造函数之外)
        }
        
        public static void writer() {   // 写线程A执行
            obj = new FinalExample(); 
        }
        
        public static void reader() {   // 读线程B执行
            FinalExample object = obj;  // 初次读对象引用  a
            int a = object.i;           // 初次读普通域    b
            int b = object.j;           // 初次读final域   c (a与c被禁止重排序)
        }
    }
    

    2.2 Final域的重排序规则

    • 写Final域:

      • JMM禁止编译器把final域的写重排序到构造函数之外。编译器通过在final域的写操作之后,构造函数return之前,插入一个StoreStore屏障来达到紧致重排序的目的。
    • 读Final域:

      • 在一个线程中,JMM禁止处理器重排序以下两个操作:

        • 初次读对象引用
        • 初次读该对象包含的final

        编译器通过在读final域操作的前面插入一个LoadLoad屏障来实现禁止重排序。

    个人认为写final域的重排序规则比较晦涩,因为每个构造函数中的操作都应该禁止被重排序到构造函数结束之外。假设有操作被重排序到构造函数结束后,那么这个对象算是初始化完成了还是未完成呢?按理说构造函数完成了,对象初始化完成;可是构造函数里边的操作并没有结束,相关域还没被初始化,对象不能算完成构建。所以对我而言,写Final域不需要重排序,换而言之,构造函数里的所有操作都必须被禁止重排序到构造函数结束之后。

    读Final域的重排序规则比较容易理解:因为初次读对象引用的操作a相当于初始化FinalExample类型的引用变量object,而初次读object.j操作c必须要基于object已经被初始化了的基础之上,显然不能重排序。

    2.3 final引用不能从构造函数逸出

    • 写Final域的另一个重排序规则:
      • 在引用变量为任意线程可见之前,该引用变量指向的对象的final域已经在构造函数中被正确初始化了。也就是不能让这个被构造对象的引用为其他线程可见。

    四、锁

    除了相关重排序规则和关键字保护以外,Java锁也提供了内存可见性问题的解决方法。

    锁可以保证临界区内的操作具有原子性,从而解决内存可见性问题。Java的用volatile来实对state的保护,即保证每次获取锁和释放锁都具有原子操作。

    五、总结

    JMM主要通过禁止相关指令的重排序来解决内存可见性问题。不管是关键字volatile,final,还是锁,都使用禁止重排序的方法来实现相关功能。

    参考

    相关文章

      网友评论

          本文标题:JVM-2:Java内存模型

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