Java 内存模型

Java 内存模型(Java Memory Model, JMM) 是 Java 为了屏蔽不同硬件和操作系统的内存访问差异, 让 Java 程序在各种平台下都有一致的内存访问效果.

Java 内存模型的主要目标是定义程序中共享变量的访问规则

由于处理器的速度跟主内存的速度不是一个数量级的, 因此处理器读取内存中的数据时, 会读取一段数据并缓存, 处理器不会直接跟主内存交互而是直接使用并修改缓存上的数据.

当同一块主内存的数据在多个线程 (多个处理器) 中有多份拷贝时, 其中一个线程对其缓存的变量修改之后, 不会马上写会到主内存, 即使写回了主内存, 其他的线程不知道这个值已经改变了, 而是继续使用缓存的数据, 因此会有数据不一致的问题(线程不安全).

虚拟机保证以下原子操作:

  1. lock(锁定): 作用于主内存变量, 标识为线程独有
  2. unlock(解锁): 作用于主内存变量, 把变量释放
  3. read(读取): 作用于主内存变量, 把变量传输到工作内存
  4. load(载入): 作用于工作内存变量, 把read得到的变量放入到工作内存的变量副本
  5. use(使用): 作用于工作内存变量, 把工作副本的变量传递给执行引擎
  6. assign(赋值): 作用于工作内存变量, 把执行引擎的值赋值到工作内存
  7. store(存储): 作用于工作内存, 把工作内存的值送到主内存中
  8. write(写入): 作用于主内存, 把工作内存中得到的值放入主内存中
Java 内存模型_第1张图片
Java 内存模型

lock, unlock, read, load, store, write, assign 和 use 规则如下:

  1. 不允许 read 和 load, store 和 write 操作之一单独出现, 必须同时出现
  2. 工作内存中的值改变(assign)了必须同步到主内存中
  3. 工作内存中的值没有改变(assign)不允许同步回主内存中
  4. 一个新的变量只能在主内存中创建, 不能使用未初始化(load 或 assign)的变量, 对一个变量执行 use,store 之前, 必须先执行过了 assign和load
  5. 同一个变量, 同一时刻, 只能一个线程对其 lock 操作, lock 操作可以被同一个线程执行多次, 只有执行相同次数的 unlock 变量才能被解锁
  6. 对一个变量执行 lock 操作, 会清空工作内存中此变量的值, 其他执行引擎需要重新 load 或 assign 初始化这个变量
  7. 一个变量没有被 lock 锁定, 不允许 unlock, 不允许 unlock 其他线程锁住的变量
  8. 对变量执行 unlock 之前, 必须先把此变量同步回主内存中(store, write)

volatile 特殊规则

volatile 保证变量的可见性

volatile 修饰的变量, 每次这个值在一个线程中被修改时, 会立即同步到主内存中, 并且使其他换处理器缓存了这个变量的缓存全部失效, 使用这个变量时, 需要重新从主内存中获取, 因此能保证 volatile 修饰的变量在所有线程中是一致的

但是 volatile 的运算不一定是线程安全的, 因为运算不一定是线程安全的.

下面的例子, 开启了20个线程, 每个线程执行 10000 次 race++, 实际运行结果会小于 200000.

volatile static int race = 0;
for (int i=0; i<20; i++) {
    threads[i] = new Thread(() -> {
            for (int i=0; i<10000; i++) race ++;
        });
    threads[i].start();
}

为了方便理解, 这里举一个例子特殊说明为啥最终值会比200000小. 假设有两个线程都缓存了变量 race, 值设为63. 都执行了 race++ 操作, 并且都得到了结果(结果都是64), 当其中一个线程把值(64)写入到主内存后, 另一个线程不会使用新的race值再次计算一遍, 而是直接把64写回了主内存中, 因此经过两次 race++, 值只增加了1.

只有满足下面两个规则才能使用 volatile:

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

volatile 禁止指令的重排优化

对于普通的变量, 虚拟机只保证单线程中运行出来的结果是正确的, 不能保证指令的执行顺序(在单线程中, 感知不到指令发生重排)

给 volatile 变量赋值之后, 会执行 lock addl $0x0, 把数据写入到主内存, 并让其他线程的缓存无效. 对与普通变量, 为了提高效率 CPU 会将指令乱序, 只保正最终结果一样.

大多数情况下 volatile 比锁的开销低, volatile 变量的读取跟普通变量一样, 写入的时候要用到内存屏障(后面的指令无法跑到屏障之前的位置), 需要更多的时间

对于 long 和 double 变量的特殊规则

Java规范允许没有声明为 volatile 的 long, double 型变量分两次操作(不是原子操作), 但是也强烈建议不这么做, 基本上的机器都当成原子操作

原子性, 可见性, 有序性

  • 原子性: 由 Java 内存模型包装原子性 read, load, assign, use, store, write, lock, unlock 都是原子的
  • 可见性: 变量的值被修改之后, 都会同步到主内存, 使用 volatile 保证修改之后马上同步会主内存
  • 有序性: 在一个线程中, 所有的操作都是有序的, 在另一个线程中观察, 所有的操作都是无序的
  1. 内存模型提供了 lock, unlock 来满足用户更大范围的原子操作需求, 使用字节码 monitorenter, monitorexit 完成操作, Java代码中用 synchronized 实现
  2. 同步块(synchronized 和 final)的可见性, 在执行 unlock 之前, 必须把此变量返回到主内存中 (把锁的对象返回到主内存)
  3. final 修饰的字段, 一旦对象把 this 传递出去, 其他线程就能看到 final 的值, 如果没有使用 final 修饰, 可能这个对象传递出去之后, 未初始化完, 其他线程调用可能出问题. (没有使用 final 修饰的字段, 可能对象创建之后, 对象的字段未初始化)

先行发生原则(happens-before)

内存模型中紧靠 volatile 和 synchronized 会使操作变得繁琐, 因此有了先行发生的原则, 先行发生规则无需任何同步就能保障成立.

  1. 程序次序规则: 在一个线程内在前面的代码先行发生于后面的代码
  2. 管程锁定规则: 一个 unlock 先行发生于同一个锁的 lock
  3. volatile 规则: 一个 volatile 的写操作先行发生于这个变量的读操作, 这里指时间顺序
  4. 线程启动规则: Thread 对象的 start() 先行发生于此线程的每一个操作
  5. 线程终止规则: 线程中所有操作都先行于此线程的终止检测(检查到线程已经终止, 肯定操作都已经结束)
  6. 线程中断规则: 对线程 interrupt() 方法的调用先行于被中断线程的代码检测到中断事件的发生
  7. 传递性: A 先行与 B, B 先行于 C, 则 A 先行于 C
  1. 两个线程操作一个普通共享变量, 不符合上面的任何一个规则, 因此是不安全的
  2. 先行发生不等于时间上一定先发生, 同一个线程中的, 可能指令重排序

参考《深入理解 Java 虚拟机》
多线程编程 深入理解DCL的安全性
从DCL的对象安全发布谈起

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