Java并发(三):Java内存模型

2019-02-20  本文已影响0人  Jorvi

一. 基础

并发编程中的两个关键问题:线程间如何通信 和 线程间如何同步。

并发模型 通信 同步
共享内存的并发模型 线程间共享公共状态,通过读写公共状态隐式通信 显式指定方法或代码在线程间互斥执行
消息传递的并发模型 线程间通过发送消息来显式通信 消息的发送必须在消息接收前,因此同步是隐式的

Java的并发采用共享内存模型。

1. Java内存模型的抽象结构

Java中,所有实例域(对象)静态域(类)数组元素都存储在堆内存中,堆内存在线程之间共享。
局部变量、方法定义参数和异常处理器参数不在线程之间共享,不存在内存可见性问题。

Java线程之间的通信由Java内存模型(JMM)控制,JMM决定了一个线程对共享变量的写入何时对另一个线程可见。

线程之间的共享变量(实例域、静态域、数组元素)存储在主内存中。
每个线程都有一个私有的本地内存,用于存储该线程读/写的共享变量副本。
本地内存是JMM的一个抽象概念,不真实存在,涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。

2. 重排序

在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。

对于编译器重排序,JMM提供编译器重排序规则,来禁止特定类型的编译器重排序。

对于处理器重排序,JMM提供处理器重排序规则,在Java编译器生成指令序列时,插入特定类型的内存屏障指令,来禁止特定类型的处理器重排序。

JMM属于语言级的内存模型,它保证了在不同编译器和不同处理器平台上,通过禁止特定类型的重排序,可以为程序员提供一致的内存可见性保证。

3. 数据依赖性

如果两个操作访问同一变量,且这两个操作中有一个为写操作,则这两个操作之间存在数据依赖性。

在单线程中,编译器和处理器不会改变存在数据依赖性的操作的执行顺序。
但是如果操作之间不存在数据依赖性,那么这些操作就可以被重排序。

上述规则衍生了 as-if-serial 语义,即不管怎么重排序,单线程的程序执行结果不能被改变。

4. 顺序一致性内存模型

顺序一致性内存模型是一个理论参考模型,处理器和编程语言的内存模型都以顺序一致性内存模型作为参考。

顺序一致性内存模型为程序员提供了极强的内存可见性保证:

  1. 一个线程中的所有操作必须按照程序的顺序来执行;
  2. 所有线程都只能看到一个单一的操作执行顺序;
  3. 每个操作都必须是原子的,且执行结果立刻对所有线程可见。

JMM中,以顺序一致性内存模型作为参考:

  1. 如果程序正确同步(synchronized、volatile和final),虽然临界区内的代码可能会重排序,但是其执行结果和该程序在顺序一致性模型中的执行结果相同。
  2. 如果程序未正确同步,其执行结果不一定和该程序在顺序一致性模型中的执行结果相同。

二. happens-before

JMM的设计意图:

  1. 程序员希望内存模型易于理解、易于编程(强内存模型);
  2. 编译器和处理器希望内存模型的束缚越少也好,易于优化提高性能(弱内存模型)。

因此,JMM的核心目标就是在这两个矛盾间找到平衡点。
而这个平衡点就是happens-before规则。

1. 定义

JMM通过happens-before关系向程序员提供了跨线程的内存可见性保证和有序性保证。

  1. 如果一个操作happens-before另一个操作(即使是在不同的线程中),那么第一个操作的执行结果将对第二个操作可见,且第一个操作的执行顺序在第二个操作之前。
  2. 两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须按照happens-before关系指定的顺序来执行。如果重排序后的执行结果,与按照happens-before关系来执行的结果一致,那么这种重排序并不非法。

理解

happens-before规则相当于是一层接口,程序员基于该接口开发,该接口内部利用特定的规则禁止编译器和处理器的重排序,从而保证足够强的内存可见性和有序性。

程序员按照happens-before规则编程,由于JMM保证了足够强的内存可见性和有序性,因此正确同步的多线程程序似乎是按happens-before指定的顺序在执行。

实际上,JMM只是禁止了特定类型的重排序,在满足不改变程序的执行结果的大条件下,编译器和处理器可以根据需要自行重排序,因此实际的执行顺序不一定和happens-before指定的顺序相同。

2. 具体规则

  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(),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作;
  6. join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。

三. volatile

1. volatile变量的特性

volatile变量具有如下特性:

理解:

2. volatile的内存语义

3. volatile内存语义的实现

为了实现volatile内存语义,JMM会分别限制编译器重排序和处理器重排序。

  1. 当第一个操作是volatile读时,不管第二个操作是普通读/写还是volatile读/写,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前。
  2. 当第二个操作是volatile写时,不管第一个操作是普通读/写还是volatile读/写,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后。
  3. 当第一个操作是volatile写,且第二个操作是volatile读时,不能重排序。
  1. 在每个volatile写操作的前面插入一个StoreStore屏障
  2. 在每个volatile写操作的后面插入一个StoreLoad屏障
  3. 在每个volatile读操作的后面插入一个LoadLoad屏障
  4. 在每个volatile读操作的后面插入一个LoadStore屏障

四. 锁

1. 锁的特性

锁具有如下特性:

理解:

2. 锁的内存语义

3. 锁内存语义的实现

  1. 利用volatile变量的写-读所具有内存语义;
  2. 利用CAS所附带的volatile读和volatile写的内存语义。

五. final

1. 写final域的重排序规则

禁止把final域的写重排序到构造函数之外

实现:

这样可以确保:
在对象引用被任意线程可见之前,对象的final域已经被正确初始化了,而普通域没有这个保障,因此可能会因为重排序导致读到的普通域为未初始化的值。

2. 读final域的重排序规则

在一个线程中,初次读对象引用与初次读该对象引用包含的final域,禁止重排序

实现:

这样可以确保:
在读一个对象的final域之前,一定会先读包含这个final域的对象的引用。

3. final域为引用类型

对于引用类型的final域,写final域的重排序规则:

举例:
[1] 和 [3] 不能重排序;
[2] 和 [3] 也不能重排序;

public class FinalReferenceExample {
    final int[] arr;
    public FinalReferenceExample() {
        arr = new int[1];        // [1]
        arr[0] = 1;                  // [2]
    }
    
    public static void test() {
        FinalReferenceExample obj = new FinalReferenceExample();     // [3]
    }
}

[1] 是对引用类型final域的写;
[2] 是对引用类型final域所引用的对象的成员域的写(操作1);
[3] 把被构造函数构造出来的对象赋值给一个引用变量(操作2);

总结:
其实,final域的重排序规则就是为了确保:

注意点:
虽然final域的写-读重排序规则保证了final域在构造函数内可以被正确初始化,但是还有另一个注意点:
在构造函数内部,不能让这个被构造对象的引用“逸出”,被其他线程所见。

举例:

public class FinalReferenceEscapeExample {
    final int i;
    static FinalReferenceEscapeExample obj;

    public FinalReferenceEscapeExample () {
          i = 1;          // [1] 写final域
          obj = this;    // [2] 被构造对象的引用this“逸出”
    }
}

在构造函数中, obj = this会导致被构造对象的引用this“逸出”,此时其他线程可以利用“逸出”的对象引用读取final域,如果[1]和[2]被重排序,那么读取的final域是未被正确初始化的,final域的内存可见性被破坏。

六. 总结

上一篇 下一篇

猜你喜欢

热点阅读