Skip to content

Commit d86bdba

Browse files
新增close arena
1 parent d313952 commit d86bdba

File tree

2 files changed

+204
-1
lines changed

2 files changed

+204
-1
lines changed

SUMMARY.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,9 @@
3030
* [Panama教程-2-MemoryLayout介绍](panama/panama-tutorial-2-MemoryLayout.md)
3131
* [Panama教程-3-FFI介绍](panama/panama-tutorial-3-FFI.md)
3232
* [失去了Unsafe内存操作之后该何去何从](panama/afterUnsafe.md)
33-
* [Panama源码浅析](panama/Panama浅析.md)
33+
* [Panama ffi源码浅析](panama/Panama浅析.md)
34+
* [从一个关闭超时问题看Panama 内存管理API](panama/arena_shared.md)
35+
3436

3537
## 随笔
3638

panama/arena_shared.md

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
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

Comments
 (0)