并发编程—如何解决可见性和有序性问题

在上一篇并发编程之BUG源头我们介绍了导致并发编程出现诡异问题的三大源头,即:缓存导致了可见性问题,线程切换带来了原子性问题,编译优带来了有序性问题,这三个Bug源头在所有的编程语言中都会遇到,那么今天就聊聊 Java是通过什么技术解决的。

Java中解决可见性和有序性问题的主角当属 Java内存模型了。说到Java内存模型,在很多面试中都会问到,是一个热门考点,也是一个程序员并发水平的具体体现。只有掌握了Java内存模型,才能在解决问题时慧眼如炬。

什么是Java内存模型

我们知道,导致可见性的原因是缓存,导致有序性的原因是指令重排序,那么解决可见性、有序性对直接的办法就是禁用缓存和指令重排序,但是如果解决了这些问题,可能又会引发性能问题。那么合理的方案就是按需禁用缓存和编译优化。如何才能做到“按需禁用”呢?由于并发程序也是程序员写的,程序员是知道程序该怎么运行才是正确的。所以,为了解决可见性和有序性,只要提供给程序员按需禁用缓存和编译优化的方法即可。Java内存模型就是规范了JVM如何提供按需禁用缓存和编译优化的方法。Java中提供的方法包括 volatilesynchronizedfinal 三个关键字,以及六项 Happens-Before规则。接下来就介绍以下这几种方法

一、volatile

1、volatile内存写-读的内存语义

** volatile写的内存语义:**

当写一个volatile变量是,JMM会把该线程对应的CPU缓存中的共享变量值刷新到主内存中。

** volatile读的内存语义:**

当读一个volatile变量时,JMM会把该线程对应的CPU缓存中的值值为无效,线程接下来将从主内存中读取共享变量。

2、如何解决可见性

volatile是它在处理器开发中保证了共享变量的“可见性”。在X86处理器下,可以看到在Java中被volatile修饰的变量,在转变为汇编指令后会添加一个 lock前缀,lock前缀的指令在多核处理器下面会引发两件事。

1)、将当前处理器缓存行的数据写回到系统内存。

2)、这个写回内存的操作会使在其他CPU里缓存了该内存地址的数据无效。

那么如何理解这两件事呢?

写回缓存:也就是说,只要某个线程修改了volatile修饰的共享变量,就会引发修改后的值立即回写到主内存中。

数据失效:也就是说,线程A修改了volatile修饰的变量x,后在线程B中缓存的变量x的值就会失效,如果线程B在操作变量x就必须重新到主内存读取x的值。

由上一章《并发编程——可见性、原子性、有序性 BUG源头》介绍的可见性的问题就是由CPU的缓存导致的,而使用volatile修饰的变量,会引发写内存,使其他CPU缓存失效,所以volatile修饰的共享变量保证了线程间的可见性。

当一个变量被volatile修饰后,它表达的语义就是:告诉编译器,对这个变量的读写,不能使用CPU缓存,必须从内存中读取或写入

3、如何解决有序性

JMM是通过限制指令重排序来保证程序的有序性的。下表是JMM针对编译器指定的volatile重排序规则。

| | 第二个操作 |
| 第一个操作 | 普通读/写 | volatile读 | volatile写 |
| 普通读/写 | | | NO |
| volatile读 | NO | NO | NO |
| volatile写 | | NO | NO |

从表中可以看出:

  • 当第二个操作时volatile写时,不管第一个操作是什么,都不能重排序。这个确保volatile写之前的操作不会被编译器重排序到volatile写之后。
  • 当第一个操作时volatile读时,不管第二个操作是什么,都不能重排序。这个确保volatile读之后的操作不会被编译器重排序到volatile读之前。
  • 当第一个操作时volatile写时,第二个操作是volatile读时,不能重排序。

由此可知,volatile禁止了编译器在编译时对指令的重排序,之前说过,指令重排序除了编译器,处理器也会对指令进行重排序,那么volatile又是怎么做到的呢?这个是通过在编译器生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器进行重排序的。

除了提到的禁止指令重排序之外,Java内存模型还定制了Happens-before规则,在happens-before规则中的volatile规则是这样描述的。

对一个 volatile 变量的写操作, Happens-Before 于后续对这个 volatile 变量的读操作。

结合指令重排序和happens-before规则即可保证线程之间的有序性。

二、synchronized

在多线程并发编程中synchronized一直是元老级角色,很多人都会称呼它为重量级锁。众所周知,锁可以让临界区互斥执行。所有通过synchronized加锁的临界区同一时间只能有一个线程执行。

1、锁的释放和获取内存语义

当释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中。

当获取锁时,JMM会把该线程对应的本地内存置为无效,从而使得被监视其保护的临界区代码必须从主内存中读取共享变量。

2、如何解决可见性

由锁的释放和获取内存语义,保证了共享变量在线程间的可见性。

3、如果解决有序性

在Java内存模型的Happens-before规则中有一条规则为监视器规则,描述如下:

对一个锁的解锁,happens-before于随后对这个锁的加锁

综上所述,通过锁的特性、锁的释放获取内存语义和happens-before规则中的监视器规则,synchronized可以解决可见性和有序性。

三、final

我们都知道,在Java中使用final修饰的变量为常量,即赋值后,就不允许在修改了。

1、如何解决可见性

由于final修饰的变量是常量,也就是说不可变的,因为final在类加载或者类初始化的时候已经确定了final修饰的变量的值,所以final可以保证可见性。

2、如果解决有序性

final的有序性也是通过重排序规则来保证的,对于final域,编译器和处理器要遵守以下两个重排序规则。

1)、在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。

2)、初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序。

写final域重排序规则

写final域的重排序规则禁止把final域的写重排序到构造函数之外。这个规则包含两方面

1、JMM禁止编译器吧final域的写重排序到构造函数之外。

2、编译器会在final域的写之后,构造函数return之前,插入一个StoreStore屏障。这个屏障禁止处理器把final域的写重排序到构造函数之外

读final域重排序规则

读final域的重排序规则是,在一个线程中,初次读对象引用与初次读该对象包含的final域,JMM禁止处理器重排序这两个操作(注意,这个规则仅仅针对处理器)。编译器会在读final域操作的前面插入一个LoadLoad屏障。

final引用不能从构造函数内“溢出”

前面我们提到过,写final域的重排序规则可以确保:在引用变量为任意线程可见之前,该引用变量指向的对象的final域已经在构造函数中被正确初始化过了。其实,要得到这个效果,还需要一个保证:在构造函数内部,不能让这个被构造对象的引用为其他线程所见,也就是对象引用不能在构造函数中“逸出”。

如下代码所示: this就存在“溢出”情况。

public class FinalExample {
     final int i;
     static FinalExample obj;
    public FinalExample(){
        i = 0;
        obj = this; // this 此处就存在着 “移除”
    }
}

四、Happens-Before规则

如何理解Happens-Before呢?如果望文生义,那就南辕北辙了,Happens-Before并不是说前面一个操作发生在后续操作的前面,它真正要表达的是:前面一个操作的结果对后续操作是可见的。所以比较正式的说法就是:Happens-Before约束了编译器的优化行为,虽允许编译器优化,但是要求编译器优化后一定遵循Happens-Before规则。

Happens-Before规则一共涉及六项,

1、程序顺序性规则:这条规则是指在一个线程中,按照程序顺序,前面的操作 Happens-Before于后续的任意操作。

2、监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。

3、volatile变量规则:对一个volatile域的写,Happens-Before于任意后续对这个volatile域的读。

4、传递性规则:如果A Happens-Before B,且 B Happens-Before C,那么 A happens-before C。

5、start()规则:如果线程A执行操作 ThreadB.start()(启动线程B),那么A线程的ThreadB.start() 操作Happens-Before 于线程B中的人员操作。

6、join()规则:如果线程A执行操作 ThreadB.join() 并成功返回,那么线程B中的任意操作 happens-before于线程A从ThreadB.join()操作成功返回。

7、线程中断规则:读线程interrupt()方法的调用happens-before 于被中断线程的代码检测到中断事件的发生,

8、对象终结规则:一个对象的初始化完成(构造函数执行结束) happens-before 于它的 finalize()方法的开始。

五、总结

Java内存模型是并发编程领域的一次重要创新,在Java语言里面,Happens-Before 的语义本质上是一种可见性,A Happens-Before B,意味着 A事件 对B事件可见。Java内存模型重要分为两部分,一部分是面向写并发编程的应用开发人员,一部分是面向JVM的实现人员。掌握了 Happens-Before规则,对Java并发编程就会有更深入的认识。


参考资料:

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

推荐阅读更多精彩内容