|
| 1 | +# 从一个关闭超时问题看Panama 内存管理API |
| 2 | + |
| 3 | +>> 本文由排查问题时与codex聊天记录生成,并添加后期人工修正 感谢GPT-5.2-codex-high模型的支持 |
| 4 | +
|
| 5 | +## 1. 问题 |
| 6 | + |
| 7 | +原始讨论issue: https://github.com/netty/netty/issues/16174 |
| 8 | + |
| 9 | +问题背景是 Java 25 + io_uring 关闭 EventLoopGroup 变慢。提问者附上了一个火焰图,其中绝大部分时间都花费在 jdk.internal.misc.ScopedMemoryAccess.closeScope0(MemorySessionImpl, ScopedMemoryAccess$ScopedAccessError) 中,这是由 io.netty.channel.uring.IoUringIoHandler.destroy() 引起的 |
| 10 | + |
| 11 | +其中核心简化调用链如下 |
| 12 | + |
| 13 | +``` |
| 14 | +IoUringIoHandler.destroy |
| 15 | + -> MsgHdrMemoryArray.release |
| 16 | + -> MsgHdrMemory.release |
| 17 | + -> CleanableDirectBuffer.clean |
| 18 | + -> Arena.close (SharedSession.justClose) |
| 19 | + -> ScopedMemoryAccess.closeScope0 |
| 20 | + -> Handshake::execute(CPP代码) |
| 21 | +``` |
| 22 | + |
| 23 | +这里的关键是 `shared Arena close` 会触发 thread-local handshake。 |
| 24 | +而 Netty 这条链里,`IoUringIoHandler` 初始化 `MsgHdrMemoryArray(1024)`, |
| 25 | +每个 `MsgHdrMemory` 构造时会分配 4 个 `CleanableDirectBuffer`。 |
| 26 | +在 Java 25 且 CleanerJava25 生效时,每次 `clean()` 都会 close 一个 `shared Arena`, |
| 27 | +所以 shutdown 时会出现 `1024 * 4` 次 closeScope0。 |
| 28 | + |
| 29 | +这里导致关闭变慢的热点不是 io_uring 本身,而是 shared Arena close 的 handshake 成本。 |
| 30 | + |
| 31 | +## 2. Netty 的 Cleaner 分配方式与选择 |
| 32 | + |
| 33 | +我们先关注为什么会使用shared arean,对于netty 4.2版本来说存在四种内存分配方式: |
| 34 | + |
| 35 | +- CleanerJava6: `ByteBuffer.allocateDirect`,通过 `sun.misc.Cleaner` 或 `DirectBuffer.cleaner` 清理。 |
| 36 | +- CleanerJava9: `Unsafe.invokeCleaner(ByteBuffer)` 清理 direct buffer。 |
| 37 | +- CleanerJava24Linker: 用 ffm api 直接调用libc的 `malloc/free`,`MemorySegment.ofAddress` 包装成 `ByteBuffer`,释放时直接 `free`。 |
| 38 | +- CleanerJava25: `Arena.ofShared` + `MemorySegment.allocate`,`clean()` 时 `Arena.close()`,也就是说一个ByteBuf对应一个shared Arena. |
| 39 | + |
| 40 | +Netty 在 `PlatformDependent` 的选择顺序(Java 9+): |
| 41 | + |
| 42 | +1. CleanerJava9 |
| 43 | +2. CleanerJava24Linker |
| 44 | +3. CleanerJava25 |
| 45 | +4. 否则 NOOP/CleanerJava6 使用gc托管的ByteBuffer |
| 46 | + |
| 47 | +而且在jdk25上netty会强制关闭unsafe的相关路径,除非使用 `-Dio.netty.noUnsafe=false` 打开 |
| 48 | + |
| 49 | +``` java |
| 50 | + // See JDK 23 JEP 471 https://openjdk.org/jeps/471 and sun.misc.Unsafe.beforeMemoryAccess() on JDK 23+. |
| 51 | + // And JDK 24 JEP 498 https://openjdk.org/jeps/498, that enable warnings by default. |
| 52 | + // Due to JDK bugs, we only actually disable Unsafe by default on Java 25+, where we have memory segment APIs |
| 53 | + // available, and working. |
| 54 | + String reason = "io.netty.noUnsafe"; |
| 55 | + String unspecified = "<unspecified>"; |
| 56 | + String unsafeMemoryAccess = SystemPropertyUtil.get("sun.misc.unsafe.memory.access", unspecified); |
| 57 | + if (!explicitProperty && unspecified.equals(unsafeMemoryAccess) && javaVersion() >= 25) { |
| 58 | + reason = "io.netty.noUnsafe=true by default on Java 25+"; |
| 59 | + noUnsafe = true; |
| 60 | + } else if (!("allow".equals(unsafeMemoryAccess) || unspecified.equals(unsafeMemoryAccess))) { |
| 61 | + reason = "--sun-misc-unsafe-memory-access=" + unsafeMemoryAccess; |
| 62 | + noUnsafe = true; |
| 63 | + } |
| 64 | +``` |
| 65 | + |
| 66 | +所以在 Java 25 且 native access 未开启(--enable-native-access=ALL-UNNAMED)、Unsafe 路径不可用时,会落到 CleanerJava25。 |
| 67 | +这正是问题里触发 shared Arena close 的直接原因。 |
| 68 | + |
| 69 | +## 3. 修复方案 |
| 70 | + |
| 71 | +可以直接进行绕开 |
| 72 | + |
| 73 | +issue comment 的 workaround: |
| 74 | + |
| 75 | +```xml |
| 76 | +<argLine> |
| 77 | + --enable-native-access=ALL-UNNAMED |
| 78 | +</argLine> |
| 79 | +``` |
| 80 | + |
| 81 | +这会让 CleanerJava24Linker 可用,走 `malloc/free`,不再触发 shared Arena close 和 handshake。 |
| 82 | + |
| 83 | +而对于Netty侧由于我们想要不进行大改来修复这个问题 所以选择了批量化处理 |
| 84 | + |
| 85 | +`MsgHdrMemoryArray` 改成“一次分配大段 MemorySegment,然后 slice 给每个 MsgHdrMemory”。 |
| 86 | +把 4096 次 close 压到 1 次,handshake 成本直接下降。 |
| 87 | + |
| 88 | +所以这里建议大家在使用shared arena的时候一定要尽可能在arena上挂多个MemorySegment或者一口气分配大段数据以避免shared arena过多导致的握手过多问题 |
| 89 | + |
| 90 | +## 4. shared Arenas是如何触发thread-local handshake的? |
| 91 | + |
| 92 | +关键路径在 JDK 源码: |
| 93 | + |
| 94 | +- Java 侧:`SharedSession.justClose()` -> `ScopedMemoryAccess.closeScope(...)` |
| 95 | + 文件:`src/java.base/share/classes/jdk/internal/foreign/SharedSession.java` |
| 96 | +- Native 侧:`ScopedMemoryAccess_closeScope` 直接调用 `Handshake::execute` |
| 97 | + 文件:`src/hotspot/share/prims/scopedMemoryAccess.cpp` |
| 98 | + |
| 99 | +精简代码(Native 侧): |
| 100 | + |
| 101 | +```cpp |
| 102 | +JVM_ENTRY(void, ScopedMemoryAccess_closeScope(JNIEnv *env, jobject receiver, |
| 103 | + jobject session, jobject error)) |
| 104 | + CloseScopedMemoryHandshakeClosure cl(session, error); |
| 105 | + Handshake::execute(&cl); |
| 106 | +JVM_END |
| 107 | +``` |
| 108 | +
|
| 109 | +而对于实际的握手代码——`CloseScopedMemoryHandshakeClosure` 简化代码(去掉日志和细节): |
| 110 | +
|
| 111 | +```cpp |
| 112 | +class CloseScopedMemoryHandshakeClosure : public HandshakeClosure { |
| 113 | + void do_thread(Thread* thread) { |
| 114 | + JavaThread* jt = JavaThread::cast(thread); |
| 115 | + if (!jt->has_last_Java_frame()) return; |
| 116 | + if (jt->has_async_exception_condition()) return; |
| 117 | +
|
| 118 | + bool in_scoped = false; |
| 119 | + // 遍历栈帧看看有没有session的oop |
| 120 | + if (is_accessing_session(jt, session, in_scoped)) { |
| 121 | + // 发现正在用这个 session,注入 async exception |
| 122 | + jt->install_async_exception(new ScopedAsyncExceptionHandshakeClosure(session, error)); |
| 123 | + } else if (!in_scoped) { |
| 124 | + // 否则 去优化 scope帧 看下是不是真的有对应的session |
| 125 | + frame last = get_last_frame(jt); |
| 126 | + if (last.is_compiled_frame() && last.can_be_deoptimized()) { |
| 127 | + nmethod* code = last.cb()->as_nmethod(); |
| 128 | + // 当一个函数存在Scoped注解时就会有这个标志 |
| 129 | + if (code->has_scoped_access()) { |
| 130 | + Deoptimization::deoptimize(jt, last); |
| 131 | + } |
| 132 | + } |
| 133 | + } |
| 134 | + } |
| 135 | +}; |
| 136 | +``` |
| 137 | + |
| 138 | +`CloseScopedMemoryHandshakeClosure` 的核心行为: |
| 139 | + |
| 140 | +- 逐个扫描 JavaThread 的 vframe |
| 141 | +- 只关心 `@Scoped` 方法(定义在 `ScopedMemoryAccess`) |
| 142 | +- 如果发现 thread 正在用同一个 MemorySession,注入 async exception,逼它退出 scoped access |
| 143 | +- 如果没发现但在 compiled frame 里可能做过 scoped access,就 `deoptimize` |
| 144 | + |
| 145 | +`@Scoped` 注解在这里: |
| 146 | +`src/java.base/share/classes/jdk/internal/misc/X-ScopedMemoryAccess.java.template` |
| 147 | + |
| 148 | +一个真实的 JDK 代码例子(来自 `ScopedMemoryAccess`,省略无关行): |
| 149 | + |
| 150 | +当你调用这个 |
| 151 | + |
| 152 | +```java |
| 153 | + @ForceInline @Scoped |
| 154 | + private byte getByteVolatileInternal(MemorySessionImpl session, Object base, long offset) { |
| 155 | + try { |
| 156 | + if (session != null) { |
| 157 | + session.checkValidStateRaw(); |
| 158 | + } |
| 159 | + return UNSAFE.getByteVolatile(base, offset); |
| 160 | + } finally { |
| 161 | + Reference.reachabilityFence(session); |
| 162 | + } |
| 163 | + } |
| 164 | +``` |
| 165 | + |
| 166 | +这里的 `@Scoped` 是 VM 识别的标记,握手时只看这类方法,避免把所有 Java 方法都扫一遍。 |
| 167 | + |
| 168 | +### async exception 是什么 |
| 169 | + |
| 170 | +`async exception` 可以理解成“给另一个线程安排一个待抛出的异常”。 |
| 171 | +线程在握手点或 safepoint poll 时抛出这个异常,然后从当前栈上展开 强制退出某一段函数。 |
| 172 | + |
| 173 | + |
| 174 | +### thread-local handshake vs 全局 safepoint |
| 175 | + |
| 176 | +thread-local handshake 是“逐个线程做小手术”。请求方发起后,要等所有目标线程完成,但目标线程只执行自己的 closure,不需要等其他线程: |
| 177 | + |
| 178 | +``` |
| 179 | +requester: 发起 -> 等待全部完成 |
| 180 | +T1: 到握手点 -> 执行 closure -> 继续 |
| 181 | +T2: 到握手点 -> 执行 closure -> 继续 |
| 182 | +... |
| 183 | +``` |
| 184 | + |
| 185 | +全局 safepoint 是“所有线程一起停下”。VMThread 等待所有线程进入 safepoint,等全局任务完成后再一起放行: |
| 186 | + |
| 187 | +``` |
| 188 | +VMThread: 发起 -> 等待所有线程停下 -> 执行全局任务 -> 全部放行 |
| 189 | +T1/T2/...: 到 safepoint -> 停住 -> 等放行 |
| 190 | +``` |
| 191 | + |
| 192 | +区别要点: |
| 193 | + |
| 194 | +- handshake 可以只针对部分线程,safepoint 必须是全量线程停顿 |
| 195 | +- handshake 的目标线程执行完 closure 就能继续,不需要等其他线程 |
| 196 | + |
| 197 | +所以thread-local handshake对全局影响相比于全局safepoint更小,但是并不会缩短发起握手线程的等待时间 |
| 198 | + |
| 199 | +Handshake 机制在这里: |
| 200 | +`src/hotspot/share/runtime/handshake.cpp` |
| 201 | + |
0 commit comments