第三には、同時話 - なぜJavaの並行処理は、Javaメモリモデルを理解する必要があります

序文

以前の私たちは、主に共有変数の可視性の並行プログラミングで不安スレッドの原因は、原子、また、わずかに隆起ビットメモリモデル、メモリモデル、それが何であるかを並べ替えることを言いましたか?なぜそれがJavaのメモリモデルにそれを理解することが重要なのですか?その後、我々は、Javaメモリモデルのチャットこの記事に来ます

Javaのメモリモデルとは何ですか

メモリに記憶された各変数の定義されたJavaメモリ・モデル・プログラム・アクセスルールの主な目的、すなわち、基礎となる詳細及び変数は、仮想マシンの変数にメモリから削除されるように。変数は、オブジェクトの配列を作るだけインスタンスフィールド、静的フィールドや要素を含む、同じではありません言ったとき、変数は、ここでは、Javaプログラミングと、実際には、我々は、Javaインスタンス変数とメンバ変数に呼んだが、ローカル変数が含まれていません。そして、メソッドのパラメータ。

また、メモリモデルもマルチスレッド読み取りおよび書き込み共有変数のためのメカニズム、我々は再びここに長いったらしいでそれを言及した以前の記事を説明しています。Javaのメモリモデルが指定するすべての変数がメインメモリに格納され、各スレッドは、独自のワーキングメモリを持っています。スレッドで使用される変数のコピーのメインメモリコピーに保存されたワーキングメモリのスレッドは、共有変数のすべての操作はワーキングメモリ内にある必要があり、スレッドは、あなたが直接メインメモリ変数を操作することはできませんが、変数はローカルメモリにコピーされます、操作が完了した後、結果が異なるスレッド間のメインメモリの同期にバックアップメモリ​​ローカル直接ワーキングメモリ内の他の変数にアクセスすることができません。

ここでは、およそここで話している、説明するために、メインメモリで、ワーキングメモリ、ない分割レベルで、分裂のレベルやJVMのヒープ、スタック、メソッド領域で実行するためのメモリは、基本的に関係がないと述べたから、ビューのより基本的なポイントは、メインメモリに直接作業メモリは、仮想マシン(またはハードウェア、オペレーティングシステム)であり、物理的なハードウェアのメモリに対応して、ワーキングメモリとバッファ・キャッシュ内のレジスタに格納された優先順位をすることができるプログラムが実行されていますメインアクセスはメモリを進めています。

UTOOLS1577690490404.png

同時実行の問題にJMMソリューション

Javaのメモリモデルの定義は、アトミック変数の周りのすべての可視性であり、すべての原子性が秩序変数、可視性を、共有を確実にするために、制定された規則の一部を拡大して再命じました。

  • アトミックは:私たちはあなたが原子性を持っていない場合、そのような状況の下で、マルチスレッドので、共有変数の操作は、自然の中でアトミックでなければならないと言うことは、他の追加の同期動作がない場合、データの不整合が生成されます。

  • 可視性:私たちは通常、メモリの可視性と言います。二つのスレッドA、Bは、共有変数D運転しながら、将来の結果を操作するスレッドのスレッドB A Dを取得する時間がない、があると仮定し、我々は、スレッドA、Bの操作のための操作スレッドが表示されていないと言います。

  • 並べ替え:マルチスレッド環境では、共有変数の非アトミック操作ならば、それは秩序の外の現象を示すことができます。

Javaの並べ替え、アトミック、可視性の問題を解決するために、2つのレベルの言語のキーワードは、同期と揮発性。

ここでは、静的なキーワードは、問題を解決するために偽装することができますが、静的なキーワードを言及しませんが、関係、およびメモリモデルを持っていませんが、静的なキーワード特殊なローディング機構を使用します。

volatileキーワードは、Javaメモリモデルによって提供される最も軽量な同期機構であり、それは共有変数をマルチスレッドメモリの順序と可視性で物事を保つことができます。synchronizedキーワードは、共有メモリ変数の可視性を保証することができますが、重要な領域に入るための唯一の方法、実行時に同じ時間を保証することができ、コードのミューテックス、メソッドまたはブロックです。同時に一つのスレッドだけのアクセスで保護された領域なので、その原子の問題は、保護するために変更されました。当社は、下記の同期画像の実施を通じて過程を見ることができます。

synchronized.gif

JavaのメモリモデルDCL

私たちは、揮発性と同期の使用の具体的な例を見て渡します

私たちはめったに実際の開発では、この文言を使用していないが、この特定の文言に我々が同期され、揮発性分析することができますが、以下は、シングルトンパターンのダブルチェックを記述する方法です。私たちは、より実際の開発の列挙、静的コードブロック、静的内部シングルトンクラスを使用する必要があります。

public class Singleton {
private static Singleton instance = null;
private int a;
private Singleton() {
a = 4;
}
public static Singleton getInstance() {
if (instance == null) { // 1. 第一次检查
synchronized (Singleton.class) { // 2
if (instance == null) { // 3. 第二次检查
instance = new Singleton(); // 4
}
}
}
return instance;
}
}
复制代码

上記の文言が間違っている、上記の文言は、その一つのケースを保証するために、マルチスレッドの場合、方法はありません、私たちは分析することがあります。

A、Bは、2つのスレッドがされている場合、インスタンス=新しいシングルトン()ので、ステップ4に最初の実行が進行スレッド、アトミックではない、我々は、以下のステップにその新しいオブジェクトを知っています。

。オブジェクトのインスタンスを作成し、メモリ空間を割り当てます。

B。オブジェクトヘッダ、初期化プロパティを作ります。

C。インスタンスに割り当てられたオブジェクト参照。

假如线程A此时已经执行到了步骤4,因为步骤4不是一个原子操作,所以可能b和c操作之间发生了重排序,导致对象还没有完成属性的初始化,直接就将对象的引用赋给instance。而此时线程B到执行到了步骤1,发现此时instance不为null直接就返回了Singlotan,但是此时线程B拿到的是一个不完整的对象。修改的方式则是通过使用volatile来修饰,使用volatile修饰以后,步骤4也就不会发生重排序的情况。

内存模型对于原子性的保证

在这里我想单独的来介绍一下关于原子性的问题。

Java内存模型定义了8种原子操作,来完成内存的操作。

  1. read(读取):它把一个变量的值从主内存传输到线程的工作内存中,以便以后的load。
  2. load(载入):把read操作从主内存中的得到的变量值放入到工作内存中的变量副本中
  3. use(使用):把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值字节码指令时,会使用到这个指令。
  4. assign(赋值):作用于工作内存的变量,它把一个从执行引擎接受到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时,执行此操作。
  5. store(存储):作用于工作内存的变量,它把工作内存中一个变量值传送到主内存中,以便随后的write操作使用。
  6. write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量值放入到主内存变量中。
  7. lock(锁定)把变量标识为一条线程独占的状态
  8. unlock(解锁):把一个处于锁定状态的变量释放出来

上面的那些指令1 - 6,是Java程序和计算机交互的时候使用到的指令,这些指令就涉及到了汇编层面。

int x = 10;             //语句1
int y = x; //语句2
x++; //语句3
x = x + 1; //语句4
Object z = new Object();//语句5
复制代码

上述的几种情况,只有语句1是原子操作。这样来解释一下吧,因为语句1,只使用到了一个assign指令就完成了上述的操作,因为只是将一个常数赋值给了变量x。但是其他的几种情况都是多个指令才能完成。内存模型虽然能保证这8个单独的指令是原子性的, 但是没有办法保证这些指令组合在一起的原子性。如果想保证这些组合指令的原子性,只能通过额外的操作来完成,比如说加锁。

内存模型规定,把一个变量从主内存复制到工作内存中,就要顺序的执行read和load操作,如果要把变量同步回主内存,就要顺序的执行store和write操作。需要注意的是,内存模型只规定了顺序执行,但是没有规定两个操作是连续执行。也就是说read 和 load ,store和write之间可以插入其他的指令,如对主内存中的变量a、b进行访问,可能出现的顺序是read a、read b、load b、load a。这也说明了为什么原子性会导致线程不安全。

内存模型之Happens-befor

如果代码的所有有序性都要通过volatile和synchronized来实现,那这样会让使用者感到很繁琐。我们在编写代码时候,不需要时刻考虑自己编写的代码之间是否会发生重排序,那是因为Java语言中有一 个“先行发生”(Happens-Before)的原则,它是判断数据是否存在竞争,依赖这个原则,我们可以通过几条简单规则一揽子解决并发环境下两个操 作之间是否可能存在冲突的所有问题。规则如下:

  1. 程序顺序规则:一个线程内,按照代码顺序,书写在前面的操作,happens-before于书写在后面的操作。
  2. 监视器锁规则:在一个监视器锁上的解锁操作,必须在同一个监视器锁加锁之前执行。
  3. volatile变量规则:对一个volatile变量的写操作,happens-before于对这个变量的读操作。
  4. 传递性:如果操作 A happens-before 操作 B,而操作 B happens-before操作C,则可以得出,操作 A happens-before 操作C
  5. 线程启动规则:在线程上调用start()方法,必须在该线程执行任何操作之前执行。
  6. 线程结束规则:线程中的任何操作都必须在其他线程检测到该线程已结束之前执行。
  7. 终结器规则:对象的构造函数必须在启动该对象终结器之前执行完成。
  8. 中断规则:对线程 interrupt方法的调用,happens-before被中断线程的代码检测到中断事件的发生。

happens-befor是阐述操作之间的内存可见性。==如果一个操作的结果,需要对另外一个操作可见,那么这连个操作之间必须存在happens-befor关系。这两个操作可以在一个线程内,也可以是在不同的线程之间。== 两个操作之间存在happens-before关系,并不意味着一定要按照 happens-before原则制定的顺序来执行。如果重排序之后的执行结果与按照 happens-before关系来执行的结果一致,那么这种重排序并不非法。

内存模型之as-if-serial

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

如何理解上面的话呢,举个例子

int a = 1;
int b = 2;
复制代码

这两个赋值操作之间不存在任何的数据依赖,那么这两个操作,是可以被重排序的。有可能先给b变量赋值,再给a变量赋值。 但是如果操作是

int a = 1;
int b = a + 1;
复制代码

这两个操作是不能够重排序的。因为变量b 的值依赖于变量a。而且只能是在单线程环境下。多线程环境下,是没有办法保证的。

内存模型之volatile

JMM对于volatile读写的规则定义:

  1. 写的内存语义: 当写一个volatile变量时,操作完成以后JMM会把线程对应的本地内存中的共享变量立刻刷新到主内存。
  2. 读的内存语义: 当读一个volatile变量时,JMM会把线程对应的本地内存置为无效,线程接下来从主内存中读取共享变量。

JMM对volatile变量重排序规则定义:

  1. 如果第一个操作为volatile读,则不管第二个操作是什么,都不能重排序。这个操作确保volatile 读之后的操作,不会被编译器重排序到 volatile 读之前;
  2. 如果第二个操作为 volatile 写,则不管第一个操作是什么,都不能重排序。这个操作确保volatile写之前的操作,不会被编译器重排序到 volatile 写之后;
  3. 当第一个操作 volatile 写,第二个操作为 volatile 读时,不能重排序。

JMM是如何实现volatile的语义规则

可见性的实现

如果一个变量被声明volatile,那这个变量进行写操作时,JVM会向处理器发送一条Lock前缀的指令,确保了如果有其他线程对声明了volatile变量进行修改,则立即更新主内存中数据。在多处理器环境下,为了保证各个处理器缓存一致,每个处理会通过嗅探在总线上传播的数据来检查 自己的缓存是否过期,当处理器发现自己缓存行对应的内存地址被修改了,就会将当前处理器的缓存行设置成无效状态,当处理器要对这个数据进行修改操作时,会强制重新从系统内存把数据读到处理器缓存里。 这一步确保了其他线程获得的声明了volatile变量都是从主内存中获取最新的。

有序性的实现

编译器在生成字节码时,会在指令序列中插入内存屏障,来禁止特定类型的处理器重排序。JMM采用了保守策略,规则如下:

  • 在每个volatile写操作的前面插入一个StoreStore屏障。保证在volatile写之前,其前面的所有普通写操作,都已经刷新到主内存中。
  • 在每个volatile写操作的后面插入一个StoreLoad屏障。保证在volatile写之前,其前面的所有普通写操作,都已经刷新到主内存中。
  • 在每个volatile读操作的前面插入一个LoadLoad屏障。禁止处理器把上面的volatile读,与下面的普通读重排序。
  • 在每个volatile读操作的后面插入一个LoadStore屏障。禁止处理器把上面的volatile读,与下面的普通写重排序。

内存模型之final

对于final域,编译器和处理器要遵守两个重排序规则。

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

通过例子以及下面的分析,来看一下上面的两个规则。

public class FinalTest {
int i; //普通变量
final int j; //final变量
static FinalTest obj;

public void FinalTest() { //构造函数
i = 1; //写普通域
j = 2; //写final域
}

public static void writer() { //写线程A执行
obj = new FinalTest();
}

public static void reader() { //读线程B执行
FinalTest object = obj; //读对象引用
int a = object.i; //读普通域
int b = object.j; //读final域
}
}
复制代码

final域写重排序规则

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

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

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

    下面这个这个执行时序是可能发生的情况

最終書き込みセマンティクス.PNG

写普通域的操作被编译器重排序到了构造函数之外,读普通域读取了初始化之前的值。而final域则不存在这种情况,final域被"规则"限定在了构造函数中,确保获取值的线程可以得到正确的结果。写final域的重排序规则可以确保:在对象引用为任意线程可见之前,对象的final域已经被 正确初始化过了,而普通域不具有这个保障。

final域读重排序规则

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

初次读对象引用与初次读该对象包含的final域,这两个操作之间存在间接依赖关系。由于编译器遵守间接依赖关系,因此编译器不会重排序这两个操作。大多数处理器也会遵守间接依赖,大多数处理器也不会重排序这两个操作。但有少数处理器允许对存在间接依赖关系的操作做重排序(比如alpha处理器),这个规则就是专门用来针对这种处理器。

最終セマンティック.PNGを読みます

读对象的普通域的操作被处理器重排序到读对象引用之前。读普通域时,该域还没有被写线程A写入,这是一个错误的读取操作。而读final域的重排序规则会把读对象final域的操作“限定”在读对象引用之后,此时该final域已经被A线程初始化过了,这是一个正确的读取操作。

读final域的重排序规则可以确保:在读一个对象的final域之前,一定会先读包含这个final域的对象的引用。在这个示例程序中,如果该引用不为null,那么引用对象的final域一定已经被A线程初始化过了。

如果final域是引用类型的话,写final域的重排序规则对编译器和处理器增加了如下约束:

在构造函数内,对一个final引用对象的成员域的写入,与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量,这个两个操作之间是不准重排序的。通过这个规则我们也相当于反向的验证了为什么在DCL单例模式中,为什么还需要使用volatile来修饰那个对引用对象了。

总结

通过上述所说,我们基本上对Java内存模型有了清晰的概念。知道了Java内存模型到底是什么,可以干什么,然而实际开发中我们只会使用到某些关键字。为啥还要掌握内存模型呢?

開発は私の個人的な見解は、キーワードを記述することですが、これらのキーワードの実現のためのメモリモデルを理解したり、仕様の一部を再書き込み私たちを助けることができるのJavaメモリモデルを理解することが必要である使用しますが、その後、私は、見て並行プログラムは、私たちはコードのセキュリティ上の判断を作ってみよう、いくつかの不要なミスを減らし、コードがスレッドセーフであるか否かを判断します。また、私は、生成されたことにより、複雑な問題を抱えているとき、それは与えられた問題とタイムリーな解決策を見つけすぐにライブ私たちを助けることができます。私は、Javaの並行プログラムを書くのが好きメモリモデルが不可欠なステップである理解するであろう。

参考:

「戦闘でのJava並行処理。」

「Java仮想マシンの深い理解」

「Javaのメモリモデルの深い理解」

おすすめ

転載: juejin.im/post/5e8005bdf265da794e52610b