Java 内存模型(JMM)
JMM 规定 Java 每个线程都有自己的工作内存 (Working Memory), 线程的工作内存中有共享变量的副本, 共享变量则存放在主存(Main Memory) 中. 工作内存是线程私有的, 而主存则是所有线程共享的. 工作内存用于存放线程私有的数据. 而 Java 内存模型中规定所有变量都存储在主内存, 主内存是共享内存区域, 所有线程都可以访问, 但线程对变量的操作 (读取赋值等) 必须在工作内存中进行. 首先要将变量从主内存拷贝的自己的工作内存空间, 然后对变量进行操作, 操作完成后再将变量写回主内存, 不能直接操作主内存中的变量, 工作内存中存储着主内存中的变量副本拷贝, 前面说过, 工作内存是每个线程的私有数据区域, 因此不同的线程间无法访问对方的工作内存, 线程间的通信 (传值) 必须通过主内存来完成, 其简要访问过程如下图:
主内存
主要存储的是 Java 实例对象, 所有线程创建的实例对象都存放在主内存中, 不管该实例对象是成员变量还是方法中的本地变量(也称局部变量), 当然也包括了共享的类信息, 常量, 静态变量. 由于是共享数据区域, 多条线程对同一个变量进行访问可能会发现线程安全问题.
工作内存
主要存放主内存变量的副本, 工作内存是线程私有的. 线程读写一个变量时首先会在工作内存中进行操作, 然后再同步到主内存中. 两个线程的工作内存互不影响. 工作内存其实相当于线程对主内存的一个高速缓存.
区分主内存和工作内存后, 了解一下主内存与工作内存的数据存储类型以及操作方式, 根据虚拟机规范, 对于一个实例对象中的成员方法而言, 如果方法中包含本地变量是基本数据类型 (boolean,byte,short,char,int,long,float,double), 将直接存储在工作内存的帧栈结构中, 但倘若本地变量是引用类型, 那么该变量的引用会存储在功能内存的帧栈中, 而对象实例将存储在主内存(共享数据区域, 堆) 中. 但对于实例对象的成员变量, 不管它是基本数据类型或者包装类型 (Integer,Double 等) 还是引用类型, 都会被存储到堆区. 至于 static 变量以及类本身相关信息将会存储在主内存中. 需要注意的是, 在主内存中的实例对象可以被多线程共享, 倘若两个线程同时调用了同一个对象的同一个方法, 那么两条线程会将要操作的数据拷贝一份到自己的工作内存中, 执行完成操作后才刷新到主内存, 简单示意图如下所示:
一个本地变量可能是原始类型, 在这种情况下, 它总是 "呆在" 线程栈上.
一个本地变量也可能是指向一个对象的一个引用. 在这种情况下, 引用 (这个本地变量) 存放在线程栈上, 但是对象本身存放在堆上.
一个对象可能包含方法, 这些方法可能包含本地变量. 这些本地变量仍然存放在线程栈上, 即使这些方法所属的对象存放在堆上.
一个对象的成员变量可能随着这个对象自身存放在堆上. 不管这个成员变量是原始类型还是引用类型. 静态成员变量跟随着类定义一起也存放在堆上.
存放在堆上的对象可以被所有持有对这个对象引用的线程访问. 当一个线程可以访问一个对象时, 它也可以访问这个对象的成员变量. 如果两个线程同时调用同一个对象上的同一个方法, 它们将会都访问这个对象的成员变量, 但是每一个线程都拥有这个成员变量的私有拷贝.
JMM 下的线程通信
线程间通信必须要经过主内存. 如下, 如果线程 A 与线程 B 之间要通信的话, 必须要经历下面 2 个步骤:
1)线程 A 把本地内存 A 中更新过的共享变量刷新到主内存中去.
2)线程 B 到主内存中去读取线程 A 之前已更新过的共享变量.
关于主内存与工作内存之间的具体交互协议, 即一个变量如何从主内存拷贝到工作内存, 如何从工作内存同步到主内存之间的实现细节, Java 内存模型定义了以下八种操作来完成:
lock(锁定): 作用于主内存的变量, 把一个变量标识为一条线程独占状态.
unlock(解锁): 作用于主内存变量, 把一个处于锁定状态的变量释放出来, 释放后的变量才可以被其他线程锁定.
read(读取): 作用于主内存变量, 把一个变量值从主内存传输到线程的工作内存中, 以便随后的 load 动作使用
load(载入): 作用于工作内存的变量, 它把 read 操作从主内存中得到的变量值放入工作内存的变量副本中.
use(使用): 作用于工作内存的变量, 把工作内存中的一个变量值传递给执行引擎, 每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作.
assign(赋值): 作用于工作内存的变量, 它把一个从执行引擎接收到的值赋值给工作内存的变量, 每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作.
store(存储): 作用于工作内存的变量, 把工作内存中的一个变量的值传送到主内存中, 以便随后的 write 的操作.
write(写入): 作用于主内存的变量, 它把 store 操作从工作内存中一个变量的值传送到主内存的变量中.
Java 内存模型还规定了在执行上述八种基本操作时, 必须满足如下规则:
如果要把一个变量从主内存中复制到工作内存, 就需要按顺寻地执行 read 和 load 操作, 如果把变量从工作内存中同步回主内存中, 就要按顺序地执行 store 和 write 操作. 但 Java 内存模型只要求上述操作必须按顺序执行, 而没有保证必须是连续执行.
不允许 read 和 load,store 和 write 操作之一单独出现
不允许一个线程丢弃它的最近 assign 的操作, 即变量在工作内存中改变了之后必须同步到主内存中.
不允许一个线程无原因地 (没有发生过任何 assign 操作) 把数据从工作内存同步回主内存中.
一个新的变量只能在主内存中诞生, 不允许在工作内存中直接使用一个未被初始化 (load 或 assign) 的变量. 即就是对一个变量实施 use 和 store 操作之前, 必须先执行过了 assign 和 load 操作.
一个变量在同一时刻只允许一条线程对其进行 lock 操作, 但 lock 操作可以被同一条线程重复执行多次, 多次执行 lock 后, 只有执行相同次数的 unlock 操作, 变量才会被解锁. lock 和 unlock 必须成对出现
如果对一个变量执行 lock 操作, 将会清空工作内存中此变量的值, 在执行引擎使用这个变量前需要重新执行 load 或 assign 操作初始化变量的值
如果一个变量事先没有被 lock 操作锁定, 则不允许对它执行 unlock 操作; 也不允许去 unlock 一个被其他线程锁定的变量.
对一个变量执行 unlock 操作之前, 必须先把此变量同步到主内存中(执行 store 和 write 操作).
JMM 下的可见性和有序性
可见性是指多线程环境下, 一个线程对一个共享变量的修改能否及时地被其他线程观察到. 有序性是指有指令重排序导致的多线程数据安全问题.
解决可见性问题
Java 中的 volatile 关键字: volatile 关键字可以保证直接从主存中读取一个变量, 如果这个变量被修改后, 总是会被写回到主存中去. Java 内存模型是通过在变量修改后将新值同步回主内存, 在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的, 无论是普通变量还是 volatile 变量都是如此, 普通变量与 volatile 变量的区别是: volatile 的特殊规则保证了新值能立即同步到主内存, 以及每个线程在每次使用 volatile 变量前都立即从主内存刷新. 因此我们可以说 volatile 保证了多线程操作时变量的可见性, 而普通变量则不能保证这一点.
Java 中的 synchronized 关键字: 同步快的可见性是由 "如果对一个变量执行 lock 操作, 将会清空工作内存中此变量的值, 在执行引擎使用这个变量前需要重新执行 load 或 assign 操作初始化变量的值","对一个变量执行 unlock 操作之前, 必须先把此变量同步回主内存中(执行 store 和 write 操作)" 这两条规则获得的.
Java 中的 final 关键字: final 关键字的可见性是指, 被 final 修饰的字段在构造器中一旦被初始化完成, 并且构造器没有把 "this" 的引用传递出去(this 引用逃逸是一件很危险的事情, 其他线程有可能通过这个引用访问到 "初始化了一半" 的对象), 那么在其他线程就能看见 final 字段的值(无须同步).
解决有序性问题
Java 程序中天然的有序性可以总结为一句话: 如果在本地线程内观察, 所有操作都是有序的("线程内表现为串行"(Within-Thread As-If-Serial Semantics)); 如果在一个线程中观察另一个线程, 所有操作都是无序的("指令重排序" 现象和 "线程工作内存与主内存同步延迟" 现象).
Java 语言提供了 volatile 和 synchronized 两个关键字来保证线程之间操作的有序性:
volatile 关键字本身就包含了禁止指令重排序的语义 synchronized 则是由 "一个变量在同一个时刻只允许一条线程对其进行 lock 操作" 这条规则获得的, 这个规则决定了持有同一个锁的两个同步块只能串行地进入.
指令序列的重排序:
1)编译器优化的重排序. 编译器在不改变单线程程序语义的前提下, 可以重新安排语句的执行顺序.
2)指令级并行的重排序. 现代处理器采用了指令级并行技术 (Instruction-LevelParallelism,ILP) 来将多条指令重叠执行. 如果不存在数据依赖性, 处理器可以改变语句对应机器指令的执行顺序.
3)内存系统的重排序. 由于处理器使用缓存和读 / 写缓冲区, 这使得加载和存储操作看上去可能是在乱序执行.
每个处理器上的写缓冲区, 仅仅对它所在的处理器可见. 这会导致处理器执行内存操作的顺序可能会与内存实际的操作执行顺序不一致. 由于现代的处理器都会使用写缓冲区, 因此现代的处理器都会允许对写 - 读操作进行重排序:
数据依赖:
编译器和处理器在重排序时, 会遵守数据依赖性, 编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序.(这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作, 不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑)
指令重排序对内存可见性的影响:
当 1 和 2 之间没有数据依赖关系时, 1 和 2 之间就可能被重排序(3 和 4 类似). 这样的结果就是: 读线程 B 执行 4 时, 不一定能看到写线程 A 在执行 1 时对共享变量的修改.
as-if-serial 语义:
不管怎么重排序 (编译器和处理器为了提高并行度),(单线程) 程序的执行结果不能被改变.(编译器, runtime 和处理器都必须遵守 as-if-serial 语义)
happens before:
从 JDK 5 开始, Java 使用新的 JSR-133 内存模型, JSR-133 使用 happens-before 的概念来阐述操作之间的内存可见性: 在 JMM 中, 如果一个操作执行的结果需要对另一个操作可见(两个操作既可以是在一个线程之内, 也可以是在不同线程之间), 那么这两个操作之间必须要存在 happens-before 关系:
程序顺序规则: 一个线程中的每个操作, happens-before 于该线程中的任意后续操作. 监视器锁规则: 对一个锁的解锁, happens-before 于随后对这个锁的加锁. volatile 变量规则: 对一个 volatile 域的写, happens-before 于任意后续对这个 volatile 域的读. 传递性: 如果 A happens-before B, 且 B happens-before C, 那么 A happens-before C. 一个 happens-before 规则对应于一个或多个编译器和处理器重排序规则.
内存屏障禁止特定类型的处理器重排序:
重排序可能会导致多线程程序出现内存可见性问题. 对于处理器重排序, JMM 的处理器重排序规则会要求 Java 编译器在生成指令序列时, 插入特定类型的内存屏障 (Memory Barriers,Intel 称之为 Memory Fence) 指令, 通过内存屏障指令来禁止特定类型的处理器重排序. 通过禁止特定类型的编译器重排序和处理器重排序, 为程序员提供一致的内存可见性保证.
为了保证内存可见性, Java 编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序.
StoreLoad Barriers 是一个 "全能型" 的屏障, 它同时具有其他 3 个屏障的效果. 现代的多处理器大多支持该屏障(其他类型的屏障不一定被所有处理器支持). 执行该屏障开销会很昂贵, 因为当前处理器通常要把写缓冲区中的数据全部刷新到内存中(Buffer Fully Flush).
参考资料
《Java 并发编程的艺术》
来源: https://juejin.im/post/5c7262e46fb9a049f06ae34f