JVM-2:Java内存模型

一、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,还是锁,都使用禁止重排序的方法来实现相关功能。

参考

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 215,723评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,003评论 3 391
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 161,512评论 0 351
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,825评论 1 290
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,874评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,841评论 1 295
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,812评论 3 416
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,582评论 0 271
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,033评论 1 308
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,309评论 2 331
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,450评论 1 345
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,158评论 5 341
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,789评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,409评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,609评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,440评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,357评论 2 352

推荐阅读更多精彩内容