高效并发之 Java 内存模型

Java 内存模型

物理计算机中的并发问题与虚拟机中的情况有不少相似之处,物理机对并发的处理方案对于虚拟机的实现也有相当大的参考意义,所以我们先来了解一下物理计算机中的并发问题。

硬件的效率与一致性

物理计算机中,绝大多数的运算任务都不可能只靠处理器“计算”就能完成,处理器至少要与内存交互,如读取运算数据、存储运算结果等,这个 I/O 操作是很难消除的(无法仅靠寄存器来完成所有运算任务)。由于计算机的存储设备与处理器的运算速度有几个数量级的差距,所以现代计算机系统都不得不加入一层读写速度尽可能接近处理器运算速度的高速缓存(Cache)来作为内存与处理器之间的缓冲:将运算要使用到的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存中,这样处理器就无须等待缓慢的内存读写了。
基于高速缓存的存储交互很好的解决了处理器与内存的速度矛盾,但是也为计算机系统带来更高的复杂度,因为它引入了一个新的问题:缓存一致性(Cache Coherence)。在多处理器系统中,每个处理器都有自己的高速缓存,而他们又共享同一主内存(Main Memory),如图:
在这里插入图片描述
当多个处理器的运算任务都都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致,为解决缓存一致性问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操作,这类协议有 MSI、MESI、MOSI、Synapse 等。这里的内存访问操作与硬件的缓存访问操作具有很高的可比性。
除了增加高速缓存外,为了使得处理器内部的运算单元能尽量被充分利用,处理器可能对输入代码进行乱序执行(Out-Of-Order Execution)优化,处理器会在计算之后将乱序执行的结果重组,保证该结果与顺序执行的结果是一致的,但并不保证程序中各个语句计算的先后顺序与输入代码的顺序一致,因此,如果存在一个计算任务依赖另外一个计算任务的中间结果,那么其顺序性并不能靠代码的先后顺序来保证。Java 虚拟机的即时编译器中也有类似的指令重排序(Instruction Reorder)优化。

Java 内存模型

Java 虚拟机规范中试图定义一种 Java 内存模型(Java Memory Model,JMM)来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让 Java 程序在各种平台下都能达到一致的内存访问效果。

主内存与工作内存

Java 内存模型的目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。此处的变量包括实例字段、静态字段和构成数组对象的元素,但不包括局部变量与方法参数,因为后者是线程私有的,不会被共享,也就不存在竞争问题。
Java 内存模型规定了所有的变量都存储在主内存(Main Memory)中。每条线程都有自己的工作内存(Work Memory,可与前面的高速缓存类比),线程的工作内存中保存了被该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成,线程、主内存、工作内存三者的交互关系如图:
在这里插入图片描述

内存间交互操作

关于变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存之类的实现细节,Java 内存模型定义了以下8种操作来完成,虚拟机实现时必须保证下面提及的每一种操作都是原子的、不可再分的。

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

如果要把一个变量从主内存复制到工作内存,那就要顺序地执行 read 和 load 操作,如果要把变量从工作内存同步到主内存,就要顺序执行 store 和 write 操作。Java 内存模型上只要求上述两个操作必须按顺序执行,而没有保证必须是连续执行。除此之外,Java 内存模型还规定了在执行上述8种基本操作时必须满足如下规则:

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

这8种内存访问操作以及上述规则限定,再加 volatile 的一些特殊规定,就已经完全确定了 Java 程序中哪些内存访问操作在并发下是安全的。这种定义有一个等效判断原则——先行发生原则,用来确定一个访问在并发环境下是否安全。

对于 volatile 型变量的特殊规则

volatile 可以说是 Java 虚拟机提供的最轻量级的同步机制,当一个变量定义为 volatile 之后,它将具备两种特性,第一是保证此变量对所有线程的可见性,这里的“可见性”是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得到通知的。而普通变量的值在线程间传递均需要通过主内存来完成。
虽然 volatile 变量在各个线程中是一致的,但是volatile 型变量的运算不是并发安全的。这是因为 Java 里面运算并非原子操作。可以通过代码测试一下:

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) throws InterruptedException {
        Thread[] threads = new Thread[THREADS_COUNT];
        for (Thread thread : threads) {
            thread = new Thread(() -> {
                for (int i = 0; i < 10000; i++) {
                    increase();
                }
            });
            thread.start();
        }
        // 等待其它线程执行完毕
        Thread.sleep(10000);
        System.out.println(race);
    }
}

这段代码发起了 20 个线程,每个线程对 race 变量进行 10000 次自增操作,如果 volatile 可以保证并发安全的话,那么最终输出结果应该是 200000。结果发现每次执行代码结果都是一个小于 200000 的数字。这是因为 race++ 并不是一个原子操作,当一个线程执行 race++ 时,其它线程可能已经对 race 执行过自增操作,只是没有将自增后的值放入内存,这个线程执行自增时获取的 race 值可能是其它线程自增前的值,这就出现了并发不安全的问题。
由于 volatile 变量只能保证可见性,在不符合以下两条规则的运算场景中,我们仍然要通过加锁(使用 synchronized 或 java.util.concurrent 中的原子类)来保证原子性。

  • 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值。
  • 变量不需要与其他的状态变量共同参与不变约束

比如下面的场景使用 volatile 控制并发就很适合:当 shutdown() 方法被调用时,能保证所有线程中执行的 doWork() 方法都立即停下来。

volatile boolean shutdownRequested;
public void shutdown() {
	shutdownRequested = true;
}
public void doWork() {
	while (!shutdownRequested) {
		// do stuff
	}
}

使用 volatile 变量的第二个语义是禁止指令重排序优化,普通的变量仅仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确结果,而不能保证变量赋值操作的顺序与程序代码的执行顺序一致。
通过一个例子来看看指令重排序会如何干扰并发执行:

volatile boolean initialized = false;
// 假设下面代码在线程 A 中执行
// 模拟读取配置信息,读取完成后将 initialized 置为 true 以通知其他线程配置可用
// 读取配置中 ...
initialized = true;


// 假设下面代码在线程 B 中执行
// 等待 initialized 为 true,代表线程 A 已经初始化配置信息完成
while (!initialized) {\
	sleep();
}
// 使用线程 A 中初始化好的配置信息
doSomethingWhthConfig();

假如 initialized 没有用 volatile 修饰,那么 initialized = true; 可能被重排序到读取配置之前,这会导致线程 B 以为配置已读取完成,实际上还没有读取,这样在线程 B 中使用配置信息就会出错,而 volatile 变量可以避免这类情况发生。
当加入 volatile 变量时,会产生一个内存屏障,这个内存屏障可以保证 volatile 变量后的代码不会被重排序到内存屏障前。这就保证了代码顺序的一致性。

原子性、可见性与有序性

Java 内存模型是围绕着在并发过程中如何处理原子性、可见性与有序性这3个特征建立的,下面介绍一下哪些操作实现了这3个特性。

  • 原子性(Atomicity):由 Java 内存模型来直接保证的原子变量操作包括 read、load、assign、use、store 和 write,我们大致可以认为基本数据类型的访问读写是具备原子性的。
    如果需要一个更大范围的原子性保证,Java 内存模型还提供了 lock 和 unlock 操作来满足需求,反映到 Java 代码中就是——synchronized 关键字,因此在 synchronized 块之间的操作也具备原子性。
  • 可见性(Visibility):可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。比如 volatile 变量。Java 内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的,无论是普通变量还是 volatile 变量都是如此,普通变量和 volatile 变量的区别是,volatile 的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。因此,可以说 volatile 保证了多线程操作时变量的可见性,而普通变量则不能保证这一点。
    除了 volatile 之外,Java 还有两个关键字能实现可见性,即 synchronized 和 final。同步块的可见性是由 “对一个变量执行 unlock 操作之前,必须把此变量同步回主内存中(执行 store、write 操作)” 这条规则获得的,而 final 关键字的可见性是指:被 final 修饰的字段在构造器中一旦初始化完成,并且构造器没有把“this”的引用传递出去,那在其它线程中就能看见 final 字段的值。
  • 有序性(Ordering):volatile 变量也可以保证有序性,Java 程序的天然有序性可以总结为一句话:如果在本线程内观察,所有操作都是有序的;如果在一个线程中观察另一个线程,所有操作都是无序的。前半句是指 “线程内表现为串行的语义”,后半句是指 “指令重排序” 现象和 “工作内存与主内存同步延迟” 现象。
    Java 语言提供了 volatile 和 synchronized 两个关键字来保证线程之间操作的有序性,volatile 关键字本身就包含了禁止指令重排序的语义,而 synchronized 则是由 “一个变量在同一时刻只允许一条线程对其进行 lock 操作” 这条规则获得的,这条规则决定了持有同一个锁的两个同步块只能穿行地进入。

先行发生原则

先行发生原则(happens-before)是判断数据是否存在竞争、线程是否安全的主要依据。
先行发生是 Java 内存模型中定义的两项操作之间的偏序关系,如果说操作 A 先行发生于操作 B,那么操作 A 产生的影响能被操作 B 观察到,“影响” 包括了修改了内存中共享变量的值、发送了消息、调用了方法等。
下面是 Java 内存模型下一些“天然的”先行发生关系,这些先行发生关系无需任何同步器协助就已存在,可以在编码中直接使用。如果两个操作之间的关系不在此列,并且无法从下列规则推导出来的话,他们就没有顺序性保障,虚拟机就可以对它们随意地进行重排序。

  • 程序次序规则:在一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作。
  • 管程锁定规则:一个 unlock 操作先行发生于后面对同一个锁的 lock 操作。
  • volatile 变量规则:对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作。
  • 线程启动规则:Thread 对象的 start() 方法先行发生于此线程的每一个动作。
  • 线程终止规则:线程中的所有操作都先行发生于对此线程的终止检测,可以通过 Thread.join() 方法结束、Thread.isAlive() 的返回值等手段检测到线程已经终止执行。
  • 线程中断规则:对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过 Thread.interrupted() 方法检测到是否有中断发生。
  • 对象终结规则:一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize() 方法的开始。
  • 传递性:如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,那就可以得出操作 A 先行发生于操作 C 的结论。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值