JVM-2:Java内存模型

2018-06-03  本文已影响0人  厨房里的工程师

一、JMM的必要性

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

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

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

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

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

1. 重排序规则限制

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

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

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

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

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

从上述结果可以得知: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的特性可以总结为:

1.2 Volatile的内存语义

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

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域,编译器和处理器遵循以下两个重排序规则:

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

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

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

四、锁

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

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

五、总结

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

参考

上一篇下一篇

猜你喜欢

热点阅读