深入理解java虚拟机——内存模型与线程

时间:2022-12-28 07:59:53

1. Java 内存模型

Java 虚拟机规范中试图定义一种 Java 内存模型来屏蔽各种硬件和操作系统的内存访问差异。Java内存模型的主要目标是定义程序中各个变量的访问规则。Java 内存模型规定了所有的变量都存储在主内存中。每条线程有自己的工作内存,线程的工作内存中保存了被该线程使用到的变量的主内存副本拷贝。线程不能读写主内存中的变量,不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。

1.1 内存间交互操作

Java 内存模型中定义了以下 8 种操作:

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

如果要把一个变量从主内存复制到工作内存,那就要顺序地执行 read 和 load 操作,如果把变量从工作内存中同步回主内存中,就要按顺序地执行 store 和 write 操作。必须按顺序执行,但不需要连续执行,也就是说,在 read 与 load 之间、store 与 write 之间可以插入其它的指令。但必须满足以下规则:

  • 不允许 read 和 load、store 和 write 操作之一单独出现。
  • 不允许一个线程丢弃它最近的 assign 操作,即变量在工作内存中改变了以后必须把该变化同步回主内存。
  • 不允许一个线程无原因地(没有发生过任何 assign 操作)把数据从线程的工作内存同步回主内存中。
  • 一个新的变量只能在主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load 或 assign)的变量。
  • 一个变量在同一时刻只允许一条线程对其进行 lock 操作,但 lock 操作可以被同一条线程重复执行多次,多次执行 lock 后,只有执行相同次数的 unlock 操作,变量才会被解锁。
  • 如果对一个变量执行 lock 操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行 load 或 assign 操作初始化变量的值。
  • 如果一个变量事先没有被 lock 操作锁定,那就不允许对它执行 unlock 操作,也不允许去 unlock 一个被其他线程锁定住的变量。
  • 对一个变量执行 unlock 操作之前,必须先把此变量同步回主内存中(执行 store、write操作)。

1.2 对于 volatile 型变量的特殊规则

关键字 volatile 是 Java 虚拟机提供的最轻量级的同步机制。

1.2.1 volatile 特性

当一个变量定义为 volatile 之后,它将具备两种特性:

第一:保证此变量对所有线程的可见性。这里的“可见性”是指当一条线程修改了这个变量的值,新值对于其它线程来说是可以立即得知的。普通变量的值在线程间传递需要通过主内存来完成。但 Java 里面的运算并非原子操作,导致 volatile 变量的运算在并发下一样是不安全的。

// volatile变量自增运算测试
public class VolatileTest {

    public static volatile int race = 0;

    public static void increase() {
        race++;
    }

    private static final int THREADS_COUNT = 20;

    public static void main(String[] args) {
        Thread[] threads = new Thread[THREADS_COUNT];
        for (int i = 0; i < THREADS_COUNT; i++) {
            threads[i] = new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int i = 0; i < 10000; i++) {
                        increase();
                    }
                }
            });
            threads[i].start();
        }

        // 等待所有累加线程都结束
        while (Thread.activeCount() > 1)
            Thread.yield();

        System.out.println(race);
    }
}
// 我们发现每次运行程序,输出的结果都是小于 200000 的数字。

原因:当指令把 race 的值取到操作栈顶时,volatile 关键字保证了 race 的值在此时是正确的,但在执行接下来的指令时,其它线程可能已经把 race 的值加大了,而在操作栈顶的值就变成了过期的数据,所以就可能把较小的 race 值同步回主内存中。

由于 volatile 变量只能保证可见性,在不符合以下两条规则的运算场景中,我们依然要通过加锁(使用 synchronized 或 java.util.concurrent 中的原子类)来保证原子性。
a、 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值。
b、 变量不需要与其它的状态变量共同参与不变约束。

第二:禁止指令重排序优化。普通的变量仅仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中执行顺序一致。

1.2.1 volatile 特殊规则

Java 内存模型中对 volatile 变量定义了特殊规则。假定 T 表示一个线程,V 和 W 分别表示两个 volatile 型变量,那么在进行 read、load、use、assign、store 和 write 操作时需要满足如下规则:

  • 只有当线程 T 对变量 V 执行的前一个动作是 load 的时候,线程 T 才能对变量 V 执行 use 动作;并且,只有当线程 T 对变量 V 执行的后一个动作是 use 的时候,线程 T 才能对变量 V 执行 load 操作。线程 T 对变量 V 的 use 操作可以认为是与线程 T 对变量 V 的 load 和 read 操作相关联的,必须一起连续出现。这条规则要求在工作内存中,每次使用变量 V 之前都必须先从主内存刷新最新值,用于保证能看到其它线程对变量 V 所作的修改后的值。
  • 只有当线程 T 对变量 V 执行的前一个动是 assign 的时候,线程 T 才能对变量 V 执行 store 操作;并且,只有当线程 T 对变量 V 执行的后一个动作是 store 操作的时候,线程 T 才能对变量 V 执行 assign 操作。线程 T 对变量 V 的 assign 操作可以认为是与线程 T 对变量 V 的 store 和 write 操作相关联的,必须一起连续出现。这一条规则要求在工作内存中,每次修改 V 后都必须立即同步回主内存中,用于保证其它线程可以看到自己对变量V的修改。
  • 假定操作 A 是线程 T 对变量 V 实施的 use 或 assign 动作,假定操作 F 是操作 A 相关联的 load 或 store 操作,假定操作 P 是与操作 F 相应的对变量 V 的 read 或 write 操作;类似的,假定动作 B 是线程 T 对变量 W 实施的 use 或 assign 动作,假定操作 G 是操作 B 相关联的 load 或 store 操作,假定操作 Q 是与操作 G 相应的对变量 V 的 read 或 write 操作。如果 A 先于 B,那么 P 先于 Q。这条规则要求 valitile 修改的变量不会被指令重排序优化,保证代码的执行顺序与程序的顺序相同。

1.3 原子性、可见性与有序性

Java 内存模型是围绕着在并发过程中如何处理原子性、可见性和有序性这 3 个特征来建立的。

  • 原子性:Java 内存模型直接保证的原子性变量操作包括 read、load、assign、use、store 和 write,基本数据类型的访问读写是具备原子性的。虚拟机中的 lock 和 unlock 操作反映到 Java 代码中就是同步块——synchronized 关键字,因此在 synchronized 块之间的操作也具备原子性。

  • 可见性:当一个线程修改了共享变量的值,其它线程能够立即得知这个修改。Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值实现的。普通变量和 volatile 变量都是这样,不同的是,volatile 保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。所以 volatile 保证了多线程操作时变量的可见性。
    除了 volatile,sychronized 和 final 两个关键字也能实现可见性。同步块的可见性是由“对一个变量执行 unlock 操作之前,必须先把此变量同步回主内存中(执行 store、write 操作)”这条规则获得的。final关键字的可见性是指:被 final 修饰的字段在构造器中一旦初始化完成,并且构造器没有把 “this” 的引用传递出去,那在其他线程中就能看见 final 字段的值。

  • 有序性:在本线程内看,操作都是有序的;如果在一个线程观察另一个线程,所有操作都是无序的(指的是指令重排、主内存和工作内存同步延迟)。Java 提供 volatile 和 synchronized 保证线程之间操作的有序性。volatile 关键字本身就包含了禁止指令重排序的语义,而 synchrobized 则是由“一个变量在同一个时刻只允许一条线程对其进行lock操作”这条规则获得的,这条规则决定了持有同一个锁的两个同步块只能串行地进入。

1.4 先行发生原则

先行发生原则是 Java 内存模型中定义的两项操作之间的偏序关系。如果说操作 A 先行发生于操作 B,其实就是说在发生操作 B 之前,操作 A 产生的影响能被操纵 B 观察到。

Java 内存模型下一些“天然的”先行发生关系:

  • 程序次序规则:在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作。准确地说,应该是控制流顺序而不是程序代码顺序,因为要考虑分支、循环等结构。
  • 管程锁定规则:一个 unlock 操作先行发生于后面对同一个锁的 lock 操作。
  • volatile变量规则:对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作。
  • 线程启动规则:Thread 对象的 start() 方法先行发生于此线程的每一个动作。
  • 线程终止规则:线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过 Thread.join() 方法结束、Thread.isAlive() 的返回值等手段检测到线程已经终止执行。
  • 线程中断规则:对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过 Thread.interrupted() 方法检测到是否有中断发生。
  • 对象终结规则:一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize() 方法的开始。
  • 传递性:如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,那就可以得出操作 A 先行发生于操作 C 的结论。
private int value = 0;

public void setValue(int value) {
    this.value = value;
}

public int getValue() {
    return value;
}

假设线程 A 调用了“setValue(1)”,然后线程 B 调用了同一个对象的“getValue()”,那么线程 B 得到的返回值是什么?

分析:不在同一个线程,程序次序规则不合适;没有同步块,所以没有 lock 和 unlock 操作,管程锁定规则不合适;value 变量没有被 volatile 关键字修饰,volatile变量规则不合适。所以无法确定线程 B 中“getValue()” 方法的返回结果。

解决方法:给getter/setter 方法都定义为 synchronized 方法,使用管程锁定规则;把 value 定义为 volatile 变量,使用 volatile 变量规则。

2. Java 线程

线程是比进程更轻量级的调度执行单位,线程的引入,可以把一个进程的资源分配和执行调度分开,各个线程既可以共享进程资源(内存地址,文件 I/O 等),又可以独立调用(线程是 CPU 调度的基本单位)。

2.1 线程的实现

线程的实现有 3 种方式:使用内核线程实现,使用用户线程实现和使用用户线程加轻量级进程混合实现。

2.1.1 使用内核线程实现

轻量级进程是内核线程的一种高级接口,也就是我们通常意义上所讲的线程。每个轻量级进程都由一个内核线程支持,这种轻量级进程与内核线程之间 1:1 的关系称为一对一的线程模型。

系统调用代价相对较高,需要用户态和内核态中来回切换。

2.1.2 使用用户线程实现

快速且低消耗,但是自己实现调度,管理复杂。进程与用户线程之间是 1:N 的关系。

2.1.3 使用用户线程加轻量级进程混合实现

用户线程与轻量级进程之间多对多的关系。

2.2 Java 线程调度

线程调度是指系统为线程分配处理器使用权的过程,主要调度方式有两种,分别是协同式线程调度和抢占式线程调度。协同式线程调度,线程的执行时间由线程本身控制;抢占式线程调度,线程的执行时间由系统来分配。

2.3 状态转换

Java 语言定义了 5 种线程状态,在任意一个时间点,一个线程只能有且只有其中的一种状态,这 5 种状态分别如下。

新建:创建后尚未启动的线程处于这种状态。

运行:可能正在执行,可能正在等待 CPU 为它分配执行时间。

无限期等待:不会被分配 CPU 执行时间,它们要等待被其他线程显示地唤醒。

限期等待:不会被分配 CPU 执行时间,不过无须等待被其他线程显式地唤醒,在一定时间之后它们会由系统自动唤醒。

阻塞:“阻塞状态”在等待着获取到一个排他锁,这个事件将在另外一个线程放弃这个锁的时候发生。

结束:已终止线程的线程状态,线程已经结束执行。