在知识星球中, 有个小伙伴提了一个问题: 有一个关于 JVM 名词定义的问题, 说 JVM 内存模型, 有人会说是关于 JVM 内存分布 (堆栈, 方法区等) 这些介绍, 也有地方说 (深入理解 JVM 虚拟机) 上说 Java 内存模型是 JVM 的抽象模型 (主内存, 本地内存) 这两个到底怎么区分啊? 有必然关系吗? 比如主内存就是堆, 本地内存就是栈, 这种说法对吗?
时间久了, 我也把内存模型和内存结构给搞混了, 所以抽了时间把 JSR133 规范中关于内存模型的部分重新看了下
后来听了好多人反馈: 在面试的时候, 有面试官会让你解释一下 Java 的内存模型, 有些人解释对了, 结果面试官说不对, 应该是堆啊栈啊方法区什么的(这不是半吊子面试么, 自己概念都不清楚)
JVM 中的堆啊栈啊方法区什么的, 是 Java 虚拟机的内存结构, Java 程序启动后, 会初始化这些内存的数据
内存结构就是上图中内存空间这些东西, 而 Java 内存模型, 完全是另外的一个东西
什么是内存模型
在多 CPU 的系统中, 每个 CPU 都有多级缓存, 一般分为 L1L2L3 缓存, 因为这些缓存的存在, 提供了数据的访问性能, 也减轻了数据总线上数据传输的压力, 同时也带来了很多新的挑战, 比如两个 CPU 同时去操作同一个内存地址, 会发生什么? 在什么条件下, 它们可以看到相同的结果? 这些都是需要解决的
所以在 CPU 的层面, 内存模型定义了一个充分必要条件, 保证其它 CPU 的写入动作对该 CPU 是可见的, 而且该 CPU 的写入动作对其它 CPU 也是可见的, 那这种可见性, 应该如何实现呢?
有些处理器提供了强内存模型, 所有 CPU 在任何时候都能看到内存中任意位置相同的值, 这种完全是硬件提供的支持
其它处理器, 提供了弱内存模型, 需要执行一些特殊指令(就是经常看到或者听到的, memory barriers 内存屏障), 刷新 CPU 缓存的数据到内存中, 保证这个写操作能够被其它 CPU 可见, 或者将 CPU 缓存的数据设置为无效状态, 保证其它 CPU 的写操作对本 CPU 可见通常这些内存屏障的行为由底层实现, 对于上层语言的程序员来说是透明的(不需要太关心具体的内存屏障如何实现)
前面说到的内存屏障, 除了实现 CPU 之前的数据可见性之外, 还有一个重要的职责, 可以禁止指令的重排序
这里说的重排序可以发生在好几个地方: 编译器运行时 JIT 等, 比如编译器会觉得把一个变量的写操作放在最后会更有效率, 编译后, 这个指令就在最后了(前提是只要不改变程序的语义, 编译器执行器就可以这样自由的随意优化), 一旦编译器对某个变量的写操作进行优化(放到最后), 那么在执行之前, 另一个线程将不会看到这个执行结果
当然了, 写入动作可能被移到后面, 那也有可能被挪到了前面, 这样的优化有什么影响呢? 这种情况下, 其它线程可能会在程序实现发生之前, 看到这个写入动作 (这里怎么理解, 指令已经执行了, 但是在代码层面还没执行到) 通过内存屏障的功能, 我们可以禁止一些不必要或者会带来负面影响的重排序优化, 在内存模型的范围内, 实现更高的性能, 同时保证程序的正确性
下面看一个重排序的例子:
- Class Reordering {
- int x = 0,
- y = 0;
- public void writer() {
- x = 1;
- y = 2;
- }
- public void reader() {
- int r1 = y;
- int r2 = x;
- }
- }
假设这段代码有 2 个线程并发执行, 线程 A 执行 writer 方法, 线程 B 执行 reader 方法, 线程 B 看到 y 的值为 2, 因为把 y 设置成 2 发生在变量 x 的写入之后(代码层面), 所以能断定线程 B 这时看到的 x 就是 1 吗?
当然不行! 因为在 writer 方法中, 可能发生了重排序, y 的写入动作可能发在 x 写入之前, 这种情况下, 线程 B 就有可能看到 x 的值还是 0
在 Java 内存模型中, 描述了在多线程代码中, 哪些行为是正确的合法的, 以及多线程之间如何进行通信, 代码中变量的读写行为如何反应到内存 CPU 缓存的底层细节
在 Java 中包含了几个关键字: volatilefinal 和 synchronized, 帮助程序员把代码中的并发需求描述给编译器 Java 内存模型中定义了它们的行为, 确保正确同步的 Java 代码在所有的处理器架构上都能正确执行
synchronization 可以实现什么
Synchronization 有多种语义, 其中最容易理解的是互斥, 对于一个 monitor 对象, 只能够被一个线程持有, 意味着一旦有线程进入了同步代码块, 那么其它线程就不能进入直到第一个进入的线程退出代码块(这因为都能理解)
但是更多的时候, 使用 synchronization 并非单单互斥功能, Synchronization 保证了线程在同步块之前或者期间写入动作, 对于后续进入该代码块的线程是可见的 (又是可见性, 不过这里需要注意是对同一个 monitor 对象而言) 在一个线程退出同步块时, 线程释放 monitor 对象, 它的作用是把 CPU 缓存数据 (本地缓存数据) 刷新到主内存中, 从而实现该线程的行为可以被其它线程看到在其它线程进入到该代码块时, 需要获得 monitor 对象, 它在作用是使 CPU 缓存失效, 从而使变量从主内存中重新加载, 然后就可以看到之前线程对该变量的修改
但从缓存的角度看, 似乎这个问题只会影响多处理器的机器, 对于单核来说没什么问题, 但是别忘了, 它还有一个语义是禁止指令的重排序, 对于编译器来说, 同步块中的代码不会移动到获取和释放 monitor 外面
下面这种代码, 千万不要写, 会让人笑掉大牙:
- synchronized (new Object()) {
- }
这实际上是没有操作的操作, 编译器完成可以删除这个同步语义, 因为编译知道没有其它线程会在同一个 monitor 对象上同步
所以, 请注意: 对于两个线程来说, 在相同的 monitor 对象上同步是很重要的, 以便正确的设置 happens-before 关系
final 可以影响什么
如果一个类包含 final 字段, 且在构造函数中初始化, 那么正确的构造一个对象后, final 字段被设置后对于其它线程是可见的
这里所说的正确构造对象, 意思是在对象的构造过程中, 不允许对该对象进行引用, 不然的话, 可能存在其它线程在对象还没构造完成时就对该对象进行访问, 造成不必要的麻烦
- class FinalFieldExample {
- final int x;
- int y;
- static FinalFieldExample f;
- public FinalFieldExample() {
- x = 3;
- y = 4;
- }
- static void writer() {
- f = new FinalFieldExample();
- }
- static void reader() {
- if (f != null) {
- int i = f.x;
- int j = f.y;
- }
- }
- }
上面这个例子描述了应该如何使用 final 字段, 一个线程 A 执行 reader 方法, 如果 f 已经在线程 B 初始化好, 那么可以确保线程 A 看到 x 值是 3, 因为它是 final 修饰的, 而不能确保看到 y 的值是 4 如果构造函数是下面这样的:
- public FinalFieldExample() { // bad!
- x = 3;
- y = 4;
- // bad construction - allowing this to escape
- global.obj = this;
- }
这样通过 global.obj 拿到对象后, 并不能保证 x 的值是 3.
###volatile 可以做什么 Volatile 字段主要用于线程之间进行通信, volatile 字段的每次读行为都能看到其它线程最后一次对该字段的写行为, 通过它就可以避免拿到缓存中陈旧数据它们必须保证在被写入之后, 会被刷新到主内存中, 这样就可以立即对其它线程可以见类似的, 在读取 volatile 字段之前, 缓存必须是无效的, 以保证每次拿到的都是主内存的值, 都是最新的值 volatile 的内存语义和 sychronize 获取和释放 monitor 的实现目的是差不多的
对于重新排序, volatile 也有额外的限制
下面看一个例子:
- class VolatileExample {
- int x = 0;
- volatile boolean v = false;
- public void writer() {
- x = 42;
- v = true;
- }
- public void reader() {
- if (v == true) {
- //uses x - guaranteed to see 42.
- }
- }
- }
同样的, 假设一个线程 A 执行 writer, 另一个线程 B 执行 reader,writer 中对变量 v 的写入把 x 的写入也刷新到主内存中 reader 方法中会从主内存重新获取 v 的值, 所以如果线程 B 看到 v 的值为 true, 就能保证拿到的 x 是 42.(因为把 x 设置成 42 发生在把 v 设置成 true 之前, volatile 禁止这两个写入行为的重排序)
如果变量 v 不是 volatile, 那么以上的描述就不成立了, 因为执行顺序可能是 v=true, x=42, 或者对于线程 B 来说, 根本看不到 v 被设置成了 true
double-checked locking 的问题
臭名昭著的双重检查(其中一种单例模式), 是一种延迟初始化的实现技巧, 避免了同步的开销, 因为在早期的 JVM, 同步操作性能很差, 所以才出现了这样的小技巧
- private static Something instance = null;
- public Something getInstance() {
- if (instance == null) {
- synchronized (this) {
- if (instance == null)
- instance = new Something();
- }
- }
- return instance;
- }
这个技巧看起来很聪明, 避免了同步的开销, 但是有一个问题, 它可能不起作用, 为什么呢? 因为实例的初始化和实例字段的写入可能被编译器重排序, 这样就可能返回部门构造的对象, 结果就是读到了一个未初始化完成的对象
当然, 这种 bug 可以通过使用 volatile 修饰 instance 字段进行 fix, 但是我觉得这种代码格式实在太丑陋了, 如果真要延迟初始化实例, 不妨使用下面这种方式:
- private static class LazySomethingHolder {
- public static Something something = new Something();
- }
- public static Something getInstance() {
- return LazySomethingHolder.something;
- }
由于是静态字段的初始化, 可以确保对访问该类的所以线程都是可见的
对于这些, 我们需要关心什么
并发产生的 bug 非常难以调试, 通常在测试代码中难以复现, 当系统负载上来之后, 一旦发生, 又很难去捕捉, 为了确保程序能够在任意环境正确的执行, 最好是提前花点时间好好思考, 虽然很难, 但还是比调试一个线上 bug 来得容易的多
知识星球可以干什么? 1 分享高质量的技术文章 2 沉淀战狼群高质量问题 & 解决方案 3 成长项目经验, 生活随笔, 学习心得 4 复盘实战经验, 故障总结 5 面经面试经验分享与总结 6 推荐技术书籍, 岗位招聘
来源: https://juejin.im/post/5aa7d9426fb9a028df224e30