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

一. 基础

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

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

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关系来执行的结果一致,那么这种重排序并不非法。

理解

  • 上述的(1)是JMM对程序员的承诺。如果A happens-before B,那么JMM可以保证A操作的结果对B可见,且A的执行顺序排在B之前。(保证了可见性和有序性)
  • 上述的(2)是JMM对编译器和处理器重排序的约束原则。只要不改变程序的执行结果(单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都可以。

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变量具有如下特性:

  • 可见性:对一个volatile变量的读,总是能够看到(任意线程)对这个volatile变量最后的写入;
  • 原子性:对任意volatile变量的单个读/写操作具有原子性,但是复合操作不具有原子性。

理解:

  • happens-before中的volatile变量规则,保证了volatile变量的可见性;
  • 对volatile变量的单个读/写操作,可以看成是使用同一个锁对单个读/写操作做了同步,保证了volatile变量的单个读/写操作的原子性,但是复合操作并没有做同步。

2. volatile的内存语义

  • volatile写的内存语义:
    当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存。

  • volatile读的内存语义:
    当读一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值置为无效,从主内存中重新读取共享变量。

3. volatile内存语义的实现

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

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

四. 锁

1. 锁的特性

锁具有如下特性:

  • 可见性:当线程获取锁时,总是能够看到(任意线程)对临界区内共享变量的修改;
  • 原子性:整个临界区代码的执行具有原子性。

理解:

  • happens-before中的监视器锁规则,保证了锁的可见性;
  • 锁可以让临界区代码互斥执行,保证了临界区代码执行的原子性。

2. 锁的内存语义

  • 线程释放锁的内存语义:
    和volatile写的内存语义相同。当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中。

  • 线程获取锁的内存语义:
    和volatile读的内存语义相同。当线程获取锁时,JMM会把该线程对应的本地内存中的共享变量置为无效,从主内存中重新读取共享变量。

3. 锁内存语义的实现

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

五. final

1. 写final域的重排序规则

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

实现:

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

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

2. 读final域的重排序规则

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

实现:

  • 编译器在读final域操作的前面插入一个LoadLoad屏障,禁止初次读对象引用与初次读该对象引用包含的final域重排序。

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

3. final域为引用类型

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

  • 禁止把引用类型final域的写重排序到构造函数之外
  • 操作1:在构造函数内对引用类型final域所引用的对象的成员域的写;
    操作2:在构造函数之外把被构造函数构造出来的对象赋值给一个引用变量;
    这两个操作之间不能排序

举例:
[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域。
  • 如果final域是引用类型,那么不仅final域的初始化写入必须在构造函数内完成,而且构造函数内对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域的内存可见性被破坏。

六. 总结

你可能感兴趣的:(Java并发(三):Java内存模型)