IO操作会发生系统调用,而系统调用会发生用户态和内核态切换,缓存会失效,寄存器就更不用说了,系统调用返回的时候会重新从主存载入数据(有可能,但不可靠、不可预测、不可依赖)。

1. JSR-133 (Java Memory Model) FAQ

其中明确指出:

“The memory model describes what behaviors are legal in multithreaded code. It does not try to specify how those behaviors are implemented — that’s up to the JVM and the underlying hardware.”

“内存模型描述了多线程代码中的合法行为。它并不试图规定这些行为如何实现——这由 JVM 和底层硬件决定。”

2. Java Language Specification (JLS) §17.4

定义了 happens-before、synchronization order 等抽象规则,但

✅ 总结

问题 回答
“主存”和“工作内存”是 Java 还是操作系统的概念? ✅ 是
它们和操作系统内存有联系吗? ✅ ✅
JMM 如何保证可见性? 通过
能否绕过 JMM 直接依赖操作系统? ❌ 不推荐。行为不可移植、不可预测。应使用 JMM 提供的同步机制

我们分情况看:

情况 是否可能重新加载
stop ✅ 有可能
stop ✅ 有可能
stop ❌ 不会,即使缓存刷新也读不到
没有 ❌ 即使缓存刷新,也不会重新读内存

🔍 为什么加 System.out.println() 就能跳出循环?

✅ 正确答案:因为 System.out.println() 是一个同步方法(synchronized),它会触发内存屏障(Memory Barrier),使得 stop 变量的修改对工作线程可见。

详细解释:

1. 内存可见性问题(Visibility)

  • 主线程修改了 stop = true。

  • 但是工作线程可能一直在自己的 CPU 缓存 或 寄存器 中读取 stop 的值。

  • 如果没有正确的同步机制,工作线程 永远看不到 stop 被改为 true,于是陷入无限循环。

2. System.out.println()** 做了什么?**

  • System.out 是一个 PrintStream 对象。

  • println() 方法是 synchronized 的:

1
2
3
4
5
java深色版本public void println() {
synchronized (this) {
newLine();
}
}
  • synchronized 会:

  • 在进入时(monitorenter)插入 Load Barrier,强制从主内存重新加载变量。

  • 在退出时(monitorexit)插入 Store Barrier,强制将修改写回主内存。

所以,System.out.println() 虽然看似只是打印,但它是一个 同步块,会强制线程与主内存同步,从而看到 stop 的最新值。

❌ 为什么去掉 System.out.println() 会卡住?

  • 没有同步操作,JIT 编译器可能会 优化掉对 stop 的重复读取,认为它不会改变(即使它不是 volatile)。

  • 工作线程可能将 stop 缓存在寄存器中,永远读到 false,导致无限循环。

✅ 正确解决方式(不要依赖 println())

你应该使用正确的并发机制来保证可见性:

✅ 方法1:使用 volatile 关键字

1
java深色版本private static volatile boolean stop = false;
  • volatile 保证变量的修改对所有线程 立即可见

  • 禁止指令重排序。

  • 是解决此类问题的标准做法。

✅ 方法2:使用 synchronized

1
2
3
4
5
java深色版本while (true) {
synchronized (Main.class) {
if (stop) break;
}
}

✅ 方法3:使用 AtomicBoolean

1
java深色版本private static AtomicBoolean stop = new AtomicBoolean(false);

🔬 实验验证

你可以尝试以下对比:

情况 是否跳出循环 原因
✅ 会跳出 同步方法触发内存同步
没有 ❌ 可能卡住 缓存 + JIT 优化导致看不到修改
✅ 会跳出 正确的可见性保证
✅ 会跳出 sleep 是 native 方法,也触发同步

🚫 注意:不要依赖 System.out.println() 来“修复”并发问题!

虽然它“碰巧”有效,但这是

🔍 为什么 FileOutputStream.write() 能跳出循环?(根本原因)

虽然

🧱 原因 1:write() 是 native 方法 → 触发 Safe Point 检查

这是最关键的原因!

在 HotSpot JVM 中:

  • 所有 native 方法调用 都是潜在的 Safe Point(安全点)

  • 当线程从 Java 代码进入 native 代码(或返回)时,JVM 有机会检查是否需要:

  • 响应线程中断

  • 执行垃圾回收

  • 重新加载共享变量的最新值(从主内存)

👉

这意味着:即使

🔗 参考 HotSpot 源码:src/hotspot/share/runtime/safepoint.hpp 和 sharedRuntime.cpp

🧱 原因 2:JIT 编译器不敢过度优化包含 native 调用的循环

JIT 编译器(C2)在优化循环时,会对“纯 Java 代码”做激进优化,比如:

1
2
3
java深色版本// JIT 可能优化为:
boolean cached = stop;
while (!cached) { ... }

但一旦循环中包含

“这个方法可能产生副作用(side effect)—— 它可能修改了内存、文件、网络等。”

因此,JVM

这就相当于

🧱 原因 3:系统调用本身可能间接影响内存一致性

虽然

  • write() 系统调用会触发用户态 → 内核态切换

  • 内核执行期间,CPU 缓存状态可能被刷新(如 TLB invalidation)

  • 多核 CPU 通过 MESI 缓存一致性协议 检测到内存修改

  • 主线程修改 stop = true 后,该变量所在的缓存行可能被标记为“已修改”

  • 当工作线程下一次访问该内存区域时(即使不是直接读 stop),也可能被动更新

虽然这不是“保证”,但在现代 x86 架构上,

✅ 对比:System.out.println() vs FileOutputStream.write()

特性 System.out.println() FileOutputStream.write()
是否 ✅ 是 ❌ 否
是否 native 调用 ✅ 是 ( ✅ 是
是否触发 Safe Point ✅ 是 ✅ 是
是否有副作用(I/O) ✅ 是 ✅ 是
JIT 是否敢优化掉 ❌ 不敢(双重保险) ❌ 不敢(至少不敢太激进)
跳出循环的根本原因 synchronized Safe Point + JIT 保守优化

👉 所以:

**native 方法调用 → 触发 Safe Point + 阻止 JIT 过度优化 → 被迫重新读取内存 → 看到 **stop = true

📌 官方态度:这仍然是“未定义行为”

尽管它“能工作”,但

📚 来自 JSR-133 FAQ 的原话:

“Just because a thread performs an I/O operation does not mean that it will see the latest value of a non-volatile variable written by another thread.”

“仅仅因为一个线程执行了 I/O 操作,并不意味着它能看到另一个线程对非 volatile 变量的最新写入。”

🔗

✅ 实验验证:什么时候它会“失效”?

你可以尝试以下情况,

实验 1:关闭 JIT 优化

1
bash深色版本java -Xint YourClass  # 解释执行,无 JIT

→ 可能仍然跳出(Safe Point 仍在)

实验 2:使用 ByteBuffer + FileChannel 写入(减少系统调用频率)

1
2
3
4
5
6
7
8
9
10
11
java深色版本FileChannel channel = fos.getChannel();
ByteBuffer buf = ByteBuffer.allocate(1024);
while (!stop) {
count++;
buf.put((byte)65);
if (!buf.hasRemaining()) {
buf.flip();
channel.write(buf);
buf.clear();
}
}

→ 如果

实验 3:在嵌入式或实时系统上运行

→ 缓存一致性、Safe Point 行为可能不同,行为不可预测。

✅ 结论

FileOutputStream.write()** 能跳出循环,是因为:**它是 native 方法,触发 Safe Point,迫使 JVM 重新检查内存状态;它有副作用,阻止 JIT 编译器将 stop 缓存在寄存器中;系统调用和缓存一致性协议可能间接帮助传播变量更新。但它不是由 Java 内存模型(JMM)保证的,属于“实现相关”的副作用,不可依赖。

✅ 正确做法(再次强调)

1
java深色版本private static volatile boolean stop = false; // ✅ 正确

或:

1
java深色版本private static final AtomicBoolean stop = new AtomicBoolean(false);

这才是

💡 记住:“它能运行” ≠ “它是正确的”“它在你的机器上有效” ≠ “它在所有 JVM 上都有效”

不要用 I/O 当“魔法同步工具” ✨🚫

IO模型

当调用一次channel.read,stream.read后,会切换置操作系统内核态来完成真正的数据读取,而读取又分为两个阶段,等待数据阶段和复制数据阶段

阻塞IO同步()

当用户空间发起一次read,就会切换到操作系统的内核空间,但是网络可能没有数据发送过来,所以线程停下来等待数据,不光是用户线程 等,内核空间也会等待,当有数据时,就会从网卡复制数据到内存当中去,复制完后,就会从内核空间切换为用户空间,这种模式下用户现成就被阻塞住,在读取期间什么事情都干不了

非阻塞IO(同步)

非阻塞模式下,一般是在while true循环,在等待数据阶段,调一次read方法,如果没有数据,会立刻返回结果,有数据后才会进入复制数据的阶段, 而在复制数据阶段,用户线程实际还是阻塞住的,等待阶段完成,用户线程再继续往下运行,所以这个模型是在等待数据阶段非阻塞的,并且在等待数据阶段他会多次进行用户空间和内核空间的切换,可能会影响性能。

多路复用(同步)

阻塞io的问题是,比如在一个单线程中的循环中,不光要处理read还要处理accept,channel1处理完后,在等待channel2建立连接时,阻塞IO下就一直等待着,期间就处理不了channel1发送的数据,必须得等到channel2建立完之后才能去处理channel1

多路复用:一个selector就能去监测多个channel的事件,一次性的把多个channel上的事件进行处理

同步

线程自己去获取结果(一个线程)

异步

线程自己不去获取结果,而是由其他线程送结果(至少两个线程),没有异步阻塞的说法,如果异步还阻塞住了,为什么不同步做这件事?

零拷贝

传统IO问题

传统的IO将一个文件通过socket写出:

流程:

1,java本身并不具备IO读写能力,因此read方法调用后,要从java程序的用户态切换至内核态,去调用操作系统的读能力,将数据读入内核缓冲区,这期间用户线程阻塞,操作系统使用DMA来实现文件读,期间不会使用cpu,DMA可以理解为硬件单元,用来释放cpu完成文件IO

2,从内核态切换回用户态,将数据从内核缓冲区读入用户缓冲区(即byte[] buf),这期间cpu会参与拷贝,无法利用DMA

3,调用wirte方法,这时将数据从用户缓冲区即(byte[] buf)写入socket缓冲区,cpu会参与拷贝

4,接下来要向网卡写数据,这项能力java又不具备,因此又得从用户态切换至内核态,代用操作系统的写能力,使用DMA像socket缓冲区的数据写入网卡,不会使用cpu

用户态和内核态切换发生了3次,这个操作比较重量级

数据拷贝了共4次

NIO优化

通过DirectByteBuf

ByteBUffer.allocate(10) HeapByteBuffer使用的还是java内存

ByteBUffer.allocateDirect(10) DirectByteBuffer使用的是操作系统内存

java可以使用DIrectByteBuf将堆外内存映射到jvm内存中来直接访问:

这块内存不受jvm垃圾回收的影响,因此内存地址固定,有助于IO读写

java中的DirectByteBuf对象仅维护了此内存的虚引用,内存回收分成两步,1 DirectByteBuf对象被垃圾回收,将虚引用加入引用队列,2 通过专门线程访问引用队列,根据虚引用释放堆外内存

减少了一次数据拷贝,用户态和内核态的切换次数没有减少

进一步优化(底层采用了linux2.1后提供的sendFile方法),java中对应两个channel调用transferTo/transferFrom方法拷贝数据

1,java调用transferTo方法后,要从java程序的用户态切换至内核态,使用DMA将数据读入内核缓冲区,不会使用cpu

2,数据从内核缓冲区传输到socket缓冲区,cpu会参与拷贝

3,最后使用DMA将socket缓冲区的数据写入网卡,不会使用cpu

可以看到:

只发生了一次用户态和内核态的切换

数据拷贝了3次

进一步优化(linux2.4)

1,java使用transferTo方法后,要从java程序的用户态切换至内核态,使用DMA级昂数据读入内核缓冲区,不会使用cpu

2,只会将一些offset和length信息考入到socket缓冲区,几乎无消耗

3,使用DMA将内核缓冲区的数据写入网卡,不会使用cpu

只发生一次用户态和内核态的切换,数据拷贝了2次,所谓的零拷贝,并不是真正无拷贝,而是不会拷贝重复数据到jvm内存中,零拷贝的优点:

更少的用户态和内核态的切换

不利用cpu的计算,减少cpu缓存伪共享

零拷贝适合小文件传输(缓存区的东西如果大了就无意义)