欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

java内存模型学习

程序员文章站 2022-06-15 11:21:09
...

之前内部培训整理的有关java内存模型的材料,贴出来记录下

什么是Java内存模型

        Java 内存模型 (JMM)描述的是程序中各变量(实例域、静态域和数组元素)之间的关系,以及在实际计算机系统中将变量存储到内存和从内存取出变量这样的低层细节。对象最终存储在内存中,但编译器、运行库、处理器或缓存可以有特权定时地在变量的指定内存位置存入或取出变量值。

       例如,编译器为了优化一个循环索引变量,可能会选择把它存储到一个寄存器中,或者缓存会延迟到一个更适合的时间,才把一个新的变量值存入主存。所有的这些优化是为了帮助实现更高的性能,通常这对于用户来说是透明的,但是对多处理系统来说,这些复杂的事情可能有时会完全显现出来。 

为什么需要一个内存模型

       Java 平台把线程和多处理技术集成到了语言中,这种集成程度比以前的大多数编程语言都要强很多。该语言对于平*立的并发及多线程技术的支持是野心勃勃并且是具有开拓性的,或许并不奇怪,这个问题要比 Java 体系结构设计者的原始构想要稍微困难些。关于同步和线程安全的许多底层混淆是 Java 内存模型的一些难以直觉到的细微差别

原始 JMM 的缺点

      旧的 JMM (jdk5之前)允许一些奇怪而混乱的事情发生,如果您阅读了关于双重检查锁定问题(double-checked locking problem)的任何文章,您将会记得内存操作重新排序是多么的混乱,以及当您没有正确地同步(或者没有积极地试图避免同步)时,细微却严重的问题会如何暗藏在您的代码中。更糟糕的是,许多没有正确同步的程序在某些情况下似乎工作得很好,例如在轻微的负载下、在单处理器系统上,或者在具有比 JMM 所要求的更强的内存模型的处理器上。 

重新排序

“重新排序”这个术语用于描述几种对内存操作的真实明显的重新排序的类型:
1.当编译器不会改变程序的语义时,作为一种优化它可以随意地重新排序某些指令。
2.在某些情况下,可以允许处理器以颠倒的次序执行一些操作。
3.通常允许缓存以与程序写入变量时所不相同的次序把变量存入主存。 
总结:编译器为了进行代码优化,会改变程序的顺序

 重新排序举例

 public class Test{

private int m1;

 

private int m2;

 

//构造函数

 

public Test(){

 

  this.m1 = 1;

 

  this.m2 = 2;

 

}

 

public static void main(){

 

  Test test = new Test();

 

}

 

}

假设在Test对象创建过程中需要初始化两个值域m1和m2,正常的过程应该是:开始对象创建,得到一个对象引用,m1初始化,m2初始化,把这个对象句柄赋值给变量a。 但是由于重排序的存在,可能实际的执行过程变为:开始对象创建,得到一个对象引用,m1初始化,把这个对象引用赋值给变量a,m2初始化。而另一个线程在这个对象引用赋值给变量a后,m2初始化前来访问变量a,并通过a访问到这个创建中的对象,问题出来了,m2初始化还没有完成呢... 所以这个时候就会出现线程安全的问题,需要做出同步的操作

Java线程安全

编写Java多线程程序一直以来都是一件十分困难的事,多线程程序的bug很难测试,DCL(Double Check Lock)就是一个典型,

因此对多线程安全的理论分析就显得十分重要,当然这决不是说对多线程程序的测试就是不必要的。传统上,对多线程程序的分析是通过分析操作之间可能的执行先后顺序,

然而程序执行顺序十分复杂,它与硬件系统架构,编译器,缓存以及虚拟机的实现都有着很大的关系。仅仅为了分析多线程程序就需要了解这么多底层知识确实不值得,

况且当年选择学Java就是因为不用理会烦人的硬件和操作系统,这导致了许多Java程序员不愿也不能从理论上分析多线程程序的正确性

幸运的是,现在有另外一种方法我们只需要利用几个基本的happen-before(JLS 17.4.5)规则就能从理论上分析Java多线程程序的正确性,而且不需要涉及到硬件和编译器的知识。

一个操作happen-before另一个操作

当说操作A happen-before操作B时,我们其实是在说在发生操作B之前,操作A对内存施加的影响能够被观测到。所谓“对内存施加的影响”就是指对变量的写入,

“被观测到”指当读取这个变量时能够得到刚才写入的值。

 

举例:

•线程Ⅰ执行操作A:x=3

•线程Ⅱ执行操作B:y=x。

•如果操作Ahappen-before操作B,线程Ⅱ在执行操作B之前就确定操作"x=3"被执行了,它能够确定,是因为如果这两个操作之间没有任何对x的写入的话,它读取x的值将得到3,

这意味着线程Ⅱ执行操作B会写入y的值为3。

•如果两个操作之间还有对x的写入会怎样呢?假设线程Ⅲ在操作A和B之间执行了操作C: x=5,并且操作C和操作B之前并没有happen-before关系

•这时线程Ⅱ执行操作B会讲到x的什么值呢?(3还是5?)

 

•答案是两者皆有可能,这是因为happen-before关系保证一定 能够观测到前一个操作施加的内存影响,

只有时间上的先后关系而并没有happen-before关系可能但并不保证能观测前一个操作施加的内存影响。

如果读到了值3,我们就说读到了“陈旧 ”的数据。

正是多种可能性导致了多线程的不确定性和复杂性,但是要分析多线程的安全性,我们只能分析确定性部分,这就要求找出happen-before关系,

这又得利用happen-before规则。

Happens-before规则

•下面列出的三条非常重要的happen-before规则,利用它们可以确定两个操作之间是否存在happen-before关系:

1.同一个线程中,书写在前面的操作happen-before书写在后面的操作。这条规则也称为单线程规则

2.对锁的unlock操作happen-before后续的对同一个锁的lock操作。这里的“后续”指的是时间上的先后关系,这里关键条件是必须对“同一个锁”的lock和unlock。

 

3.如果操作A happen-before操作B,操作B happen-before操作C,那么操作A happen-before操作C。这条规则也称为传递规则。 

•看下面的例子:

  public void setX(int x) {

    this.x = x;               // (1)

  }

  public int getX() {

    return x;                 // (2)

  }

假设线程Ⅰ先执行setX方法,接着线程Ⅱ执行getX方法,在时间上线程Ⅰ的操作A:this.x = x先于线程Ⅱ的操作B:return x。

但是操作A却并不happen-before操作B,让我们逐条检查三条happen-before规则。

第1条规则在这里不适用,因为这时两个不同的线程。

第2条规则也不适用,因为这里没有任何同步块,也就没有任何lock和unlock操作。

第3条规则必须基于已经存在的happen-before关系,现在没有得出任何happen-before关系,因此第三条规则对我们也任何帮助。

通过检查这三条规则,我们就可以得出,操作A和操作B之间没有happen-before关系。这意味着如果线程Ⅰ调用了setX(3),接着线程Ⅱ调用了getX(),其返回值可能不是3,尽管两个操作之间没有任何其它操作对x进行写入,它可能返回任何一个曾经存在的值或者默认值0。

 

“任何曾经存在的值”需要做点解释,假设在线程Ⅰ调用setX(3)之前,还有别的线程或者就是线程Ⅰ还调用过setX(5), setX(8),那么x的曾经可能值为0, 5和8(这里假设setX是唯一能够改变x的方法),其中0是整型的默认值,用在这个例子中,线程Ⅱ调用getX()的返回值可能为0, 3, 5和8,至于到底是哪个值是不确定的。

对上例进行改进

•public synchronized void setX(int x) {  

•  this.x = x;               // (1)  

•}  

• 

•public synchronized int getX() {  

•  return x;                 // (2)  

 

•} 

做同样的假设,线程Ⅰ先执行setX方法,接着线程Ⅱ执行getX方法,这时就可以得出来,线程Ⅰ的操作A happen-before线程Ⅱ的操作B。下面我们来看如何根据happen-before规则来得到这个结论。由于操作A处于同步块中,操作A之后必须定要发生对this锁的unlock操作,操作B也处于同步块中,操作B之前必须要发生对this锁的lock操作,根据假设unlock操作发生lock操作之前,根据第2条happen-before规则,就得到unlock操作happen-before于lock操作;另外根据第1条happen-before规则(单线程规则),操作A happen-before于unlock操作,lock操作happen-before于操作B;最后根据第3条happen-before规则(传递规则),A -> unlock, unlock -> lock, lock -> B(这里我用->表示happen-before关系),有 A -> B,也就是说操作A happen-before操作B。这意味着如果线程Ⅰ调用了setX(3),紧接着线程Ⅱ调用了getX(),如果中间再没有其它线程改变x的值,那么其返回值必定是3。

如果将两个方法的任何一个synchronized关键字去掉又会怎样呢?这时能不能得到线程Ⅰ的操作A happen-before线程Ⅱ的操作B呢?答案是得不到。这里因为第二条happen-before规则的条件已经不成立了,这时因为要么只有线程Ⅰ的unlock操作(如果去掉getX的synchronized),要么只有线程Ⅱ的lock操作(如果去掉setX的synchronized关键字)。

即必须对同一个变量的 所有 读写同步,才能保证不读取到陈旧的数据,仅仅同步读或写是不够的 。

 

利用Happen-Before规则分析DCL 

•public class LazySingleton {  

•    private int someField;  

•    private static LazySingleton instance;  

•    private LazySingleton() {  

•        this.someField = new Random().nextInt(200)+1;  // (1)  

•    }  

•    public static LazySingleton getInstance() {  

•        if (instance == null) {                               // (2)   

•            synchronized(LazySingleton.class) {               // (3)  

•                if (instance == null) {                       // (4)   

•                    instance = new LazySingleton();           // (5)   

•                }  

•            }  

•        }  

•        return instance;            // (6)  

•    }  

•    public int getSomeField() {  

•        return this.someField;     // (7)   

•    }  

 

•}  

       线程Ⅰ是初次调用getInstance()方法,紧接着线程Ⅱ也调用了getInstance()方法和getSomeField()方法

整个线程Ⅱ的操作都是在没有同步的情况下调用 ,这时我们无法利用第1条和第2条happen-before规则得到线程Ⅰ的操作和线程Ⅱ的操作之间的任何有效的happen-before关系,这说明线程Ⅰ的语句(1)和线程Ⅱ的语句(7)之间并不存在happen-before关系,这就意味着线程Ⅱ在执行语句(7)完全有可能观测不到线程Ⅰ在语句(1)处对someFiled写入的值,这就是DCL的问题所在!

       前面我们说了,线程Ⅱ在执行语句(2)时也有可能观察空值,如果是种情况,那么它需要进入同步块,并执行语句(4)。在语句(4)处线程Ⅱ还能够读到instance的空值吗?不可能。这里因为这时对instance的写和读都是发生在同一个锁确定的同步块中,这时读到的数据是最新的数据。为也加深印象,我再用happen-before规则分析一遍。线程Ⅱ在语句(3)处会执行一个lock操作,而线程Ⅰ在语句(5)后会执行一个unlock操作,这两个操作都是针对同一个锁--LazySingleton.class,因此根据第2条happen-before规则,线程Ⅰ的unlock操作happen-before线程Ⅱ的lock操作,再利用单线程规则,线程Ⅰ的语句(5) -> 线程Ⅰ的unlock操作,线程Ⅱ的lock操作 -> 线程Ⅱ的语句(4),再根据传递规则,就有线程Ⅰ的语句(5) -> 线程Ⅱ的语句(4),也就是说线程Ⅱ在执行语句(4)时能够观测到线程Ⅰ在语句(5)时对LazySingleton的写入值。接着对返回的instance调用getSomeField()方法时,我们也能得到线程Ⅰ的语句(1) -> 线程Ⅱ的语句(7),这表明这时getSomeField能够得到正确的值。但是仅仅是这种情况的正确性并不妨碍DCL的不正确性,一个程序的正确性必须在所有的情况下的行为都是正确的,而不能有时正确,有时不正确。

改进:

public synchronized int getSomeField() {  

    return this.someField;                             

}  

这样并不正确,这是因为,第2条happen-before规则的前提条件并不成立。语句(5)所在同步块和语句(7)所在同步块并不是使用同一个锁。

应采用下面的改进方式:

•public int getSomeField() {  

•    synchronized(LazySingleton.class) {  

•        return this.someField;  

•    }  

 

•}  

•这样的修改虽然能保证正确性却不能保证高性能

•private static LazySingleton instance;  

•private static int hasInitialized = 0; 

•public static LazySingleton getInstance() {  

•    if (hasInitialized == 0) {                                          // (4)  

•        synchronized(LazySingleton.class) {                         // (5)  

•            if (instance == null) {                                 // (6)  

•                instance = new LazySingleton();                     // (7)  

•                hasInitialized = 1;  

•            }  

•        }  

•    }  

•    return instance;                                                // (8)  

 

•}

如果你明白我前面所讲的,那么很容易看出这里根本就是一个伪修正,线程Ⅱ仍然完全有可能在非同步状态下返回instance。对int变量的赋值是原子的,但实际上对instance的赋值也是原子的,Java语言规范规定对任何引用变量和基本变量的赋值都是原子的,除了long和double以外。使用hasInitialized==0和instance==null来判断LazySingleton有没有初始化没有任何区别。

Effective Java 2nd提示的方法

•public class Singleton {  

•  private Singleton() {}  

•  // Lazy initialization holder class idiom for static fields   

•  private static class InstanceHolder {  

•   private static final Singleton instance = new Singleton();  

•  }  

•  public static Singleton getSingleton() {   

•    return InstanceHolder.instance;   

•  }  

 

•}  

•利用的原理是:一个类直到被使用时才被初始化,而类初始化的过程是非并行的,这些都由JLS保证。

•在java 5中多增加了一条happen-before规则:

 对volatile字段的写操作happen-before后续的对同一个字段的读操作。

•利用这条规则我们可以将instance声明为volatile,即:

 

 private volatile static LazySingleton instance;  

•根据这条规则,我们可以得到,线程Ⅰ的语句(5) -> 语线程Ⅱ的句(2),根据单线程规则,线程Ⅰ的语句(1) -> 线程Ⅰ的语句(5)和语线程Ⅱ的句(2) -> 语线程Ⅱ的句(7),

再根据传递规则就有线程Ⅰ的语句(1) -> 语线程Ⅱ的句(7),这表示线程Ⅱ能够观察到线程Ⅰ在语句(1)时对someFiled的写入值,程序能够得到正确的行为。

 

如果还有不明白的,可以参考下面这篇文章,详细说明了双重检查锁定及单例模式

http://www.ibm.com/developerworks/cn/java/j-dcl.html

 

•结束语:多线程真的很难!!!