Skip to content

Commit e48b5f1

Browse files
Panama-memorysegment
1 parent 98f94bd commit e48b5f1

File tree

2 files changed

+378
-0
lines changed

2 files changed

+378
-0
lines changed

SUMMARY.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323

2424
* [Panama教程-0-MethodHandle介绍](panama/panama-tutorial-0-A.md)
2525
* [Panama教程-0-Varhandle介绍](panama/panama-tutorial-0-B.md)
26+
* [Panama教程-1-MemorySegment介绍](panama/panama-tutorial-1-MemorySegment.md)
2627
* [失去了Unsafe内存操作之后该何去何从](panama/afterUnsafe.md)
2728
* [Panama源码浅析](panama/Panama浅析.md)
2829

Lines changed: 377 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,377 @@
1+
# Panama教程-1-MemorySegment介绍
2+
3+
## 前言
4+
5+
对于Panama来说其分为两部分,一部分是FFM,即一些内存操作的API,一部分是FFI,即如何调用符合当前平台的ABI的动态库代码
6+
7+
本文是对于FFM部分的介绍
8+
9+
## MemorySegment基础介绍
10+
11+
粗看MemorySegemnt与NIO包下的Bytebuffer差不多,都是支持将byte[]和堆外指针封装为一个对象,实现在统一视图下的操作
12+
13+
### 使用
14+
15+
其并不提供任何分配内存的api,而是专注于包装已有的内存指针
16+
17+
其堆外内存的分配来于SegmentAllocator接口的实现类,至于为什么需要Arena类我们下面再展开
18+
19+
```java
20+
// 包装一个Java原始类型数组
21+
MemorySegment.ofArray(...);
22+
23+
//分配一段堆外内存
24+
Arena arena = Arena.ofConfined();
25+
MemorySegment allocate = arena.allocate(8);
26+
27+
//包装一个堆外指针
28+
MemorySegment.ofAddress(...);
29+
30+
//包装一个nio bytebuffer
31+
MemorySegment.ofBuffer(ByteBuffer.allocate(8))
32+
```
33+
34+
对于内存读写操作也很简单
35+
36+
就像是指针操作一样直接从某个偏移量获取值即可,这里是跟ByteBuffer一个很大的不同,ByteBuffer会维护一组“内部状态”,你需要在各种get/put/flip方法之间辗转腾挪比较麻烦,而MemorySegment的api则很干净,只支持简单的基于偏移量的取值
37+
38+
```java
39+
memorySegment.get(ValueLayout.JAVA_INT, /*offset*/0);
40+
memorySegment.set(ValueLayout.JAVA_INT, /*offset*/0, /*value*/1);
41+
memorySegment.asSlice(/*offset*/0, /*newSize*/12);
42+
```
43+
44+
### 与ByteBuffer区别
45+
46+
#### 堆外内存生命周期
47+
48+
> 当我们谈论这里的生命周期的时候一般是谈论他的malloc和free
49+
>
50+
> 而且若无提及,均指的是public api
51+
52+
ByteBuffer的堆外内存的生命周期,分配是交由用户手动分配的,但是释放却是交由GC释放,我们并不能手动干涉将其立刻释放
53+
54+
而MemorySegment则是将其生命周期与Arena相绑定,Arena这个词也是个懂得都懂的词,不懂的还是看不懂的词,他实际上可以理解为一个Scope,将MemorySegment的释放回调挂载到这个Scope上,当这个Scope关闭时,释放掉由它分配的全部MemorySegment
55+
56+
```java
57+
try (Arena scope = Arena.ofConfined()) {
58+
MemorySegment memorySegment = scope.allocate(1024);
59+
//do something
60+
} // 在这里面被释放
61+
```
62+
63+
#### 字节序
64+
65+
对于ByteBuffer而言其分配出来的堆外内存,在使用ByteBuffer API进行访问的时候均为大段序
66+
67+
而MemorySegment为native order。
68+
69+
以我当前使用的intel x86_64bit的Linux主机为例,当前平台的native order为小端序
70+
71+
当我指定对应byte order访问的时候 直接getLong的值才与MemorySegment一致
72+
73+
```java
74+
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(8);
75+
assertEquals(byteBuffer.order(), ByteOrder.BIG_ENDIAN);
76+
77+
MemorySegment memorySegment = MemorySegment.ofBuffer(byteBuffer);
78+
memorySegment.set(ValueLayout.JAVA_LONG, 0, 1);
79+
assertEquals((long) 1 << 56, byteBuffer.getLong(0));
80+
assertEquals(memorySegment.get(ValueLayout.JAVA_LONG, 0), byteBuffer.order(ByteOrder.LITTLE_ENDIAN).getLong(0));
81+
```
82+
83+
## Arena和MemorySegment
84+
85+
> 若无特意提及则默认均为堆外内存
86+
87+
这里我们简单介绍下我们刚才草草省略过的Arena,也就是MemorySegment的生命周期管理
88+
89+
### 统一概念
90+
91+
Arena这里本意是场馆的意思,实际上就是暗示它管理了全部由其分配的内存段的生命周期。
92+
93+
注意虽然Golang之类的语言中也有Arena但是Java这里的Arena并不保证每次分配出来的内存是连续的,对于某些语言Arena api只是将一大块内存拿出来切块分配,最后统一回收,但是Java仅仅是强调统一回收这一概念。
94+
95+
除了我们在上面展示的通过close回收分配的堆外内存之外,还会提供在Arena close后限制对应内存段的读写操作。如下代码,当我们进行操作一个关联了scope的内存端时,其scope被关闭后,会直接抛出异常,避免出现UAF的高危行为
96+
97+
```java
98+
public static void main(String[] args) throws Throwable {
99+
Arena localScope = Arena.ofConfined();
100+
MemorySegment segment = localScope.allocate(1024);
101+
localScope.close();
102+
//Exception in thread "main" java.lang.IllegalStateException: Already closed
103+
segment.get(ValueLayout.JAVA_INT, 0);
104+
105+
}
106+
```
107+
108+
Arena根据实现不同,还能提供Thread Owner的控制,那么我们接下来介绍下几个常用的Arena
109+
110+
### Confined Arena
111+
112+
这是一个限定比较多的也是比较常用的一个Arena,其返回的Arena是一个只允许单线程使用的且需要手动关闭的Arena
113+
114+
```java
115+
public static void main(String[] args) throws Throwable {
116+
Arena localScope = Arena.ofConfined();
117+
MemorySegment segment = localScope.allocate(1024);
118+
119+
Thread.startVirtualThread(() -> {
120+
try {
121+
localScope.allocate(1024);
122+
} catch (Throwable t) {
123+
System.out.println("其他线程无法分配");
124+
}
125+
try {
126+
segment.get(ValueLayout.JAVA_INT, 0);
127+
} catch (Throwable t) {
128+
System.out.println("其他线程无法访问");
129+
}
130+
}).join();
131+
132+
localScope.close();
133+
try {
134+
segment.get(ValueLayout.JAVA_INT, 0);
135+
} catch (Throwable t) {
136+
System.out.println("关闭后无法使用");
137+
}
138+
139+
}
140+
```
141+
142+
### Shared Arena
143+
144+
这是一个多线程版本的Confined Arena,允许在其余线程中进行访问,但是需要手动关闭,在关闭过程中若仍有线程在使用其分配出来的内存段则会关闭失败,建议与结构化并发API一同使用
145+
146+
```java
147+
public static void main(String[] args) throws Throwable {
148+
MemorySegment uaf = null;
149+
try (StructuredTaskScope<Void> currencyScope = new StructuredTaskScope<Void>();
150+
Arena arena = Arena.ofShared()
151+
) {
152+
StructuredTaskScope.Subtask<Void> task1 = currencyScope.fork(() -> {
153+
MemorySegment _ = arena.allocate(1024);
154+
return null;
155+
});
156+
157+
StructuredTaskScope.Subtask<Void> task2 = currencyScope.fork(() -> {
158+
MemorySegment _ = arena.allocate(1024);
159+
return null;
160+
});
161+
currencyScope.join();
162+
assertEquals(task1.state(), StructuredTaskScope.Subtask.State.SUCCESS);
163+
assertEquals(task2.state(), StructuredTaskScope.Subtask.State.SUCCESS);
164+
165+
uaf = arena.allocate(1024);
166+
}
167+
//抛出异常
168+
uaf.get(ValueLayout.JAVA_BYTE, 0);
169+
}
170+
```
171+
172+
### Auto Arena
173+
174+
这是一个**不允许手动关闭**的Shared Arena,允许在其余线程中进行访问,其分配的内存端均被自动管理——其实就是GC机制+Cleaner管理Auto Arena的生命周期,这里并不是让GC管理每一个内存段而是通过发现Auto Arena不可达之后,触发一个cleaner回调将其分配的全部内存段回收
175+
176+
对于其分配出来的MemorySegment,均会持有对应Auto Arena的引用,即 MemorySegment <=> Arena,这样只有Arena不可达,且其分配出来的每一个MemorySement均不可达 才允许其管理的全部MemorySegment被释放
177+
178+
某种程度上这种管理方案与旧有的ByteBuffer 管理方式一致
179+
180+
```
181+
public static void main(String[] args) throws Throwable {
182+
Arena auto = Arena.ofAuto();
183+
MemorySegment memorySegment = auto.allocate(ValueLayout.JAVA_INT);
184+
}
185+
```
186+
187+
### Global Arena
188+
189+
这个就更简单了,它不允许关闭,只管分配也不进行任何回收。类似于Rust的`static生命周期,其分配出来的内存允许跨线程,允许任意使用,随着进程销毁一起回收
190+
191+
大部分情况下可能没什么用,但是`MemorySegment.ofAddress(0)`中,就默认是Global Arena,对于大部分从native返回的指针均为这个作用域,所以可以通过这个API来 “擦除” 作用域
192+
193+
```java
194+
public static void main(String[] args) throws Throwable {
195+
Arena global = Arena.global();
196+
System.out.println(global.allocate(1024).scope());
197+
System.out.println(MemorySegment.ofAddress(0).scope());
198+
}
199+
```
200+
201+
### 自定义Arena
202+
203+
你可以使用` java.lang.foreign.SegmentAllocator`实现一个将一大块内存拿出来切块分配,最后统一回收的Arena,batch化内存分配操作
204+
205+
```java
206+
class SlicingArena implements Arena {
207+
final Arena arena = Arena.ofConfined();
208+
final SegmentAllocator slicingAllocator;
209+
210+
SlicingArena(long size) {
211+
slicingAllocator = SegmentAllocator.slicingAllocator(arena.allocate(size));
212+
}
213+
214+
public MemorySegment allocate(long byteSize, long byteAlignment) {
215+
return slicingAllocator.allocate(byteSize, byteAlignment);
216+
}
217+
218+
public MemorySegment.Scope scope() {
219+
return arena.scope();
220+
}
221+
222+
public void close() {
223+
arena.close();
224+
}
225+
226+
}
227+
```
228+
229+
### Arena关闭时的内存安全
230+
231+
#### 引用计数法
232+
233+
刚才提到了在Arena关闭后无法通过MemorySegment相关的API进行访问,但是在我们跟操作系统API交互的时候,OS无法感知我们这个检测机制,那么是否意味着我们在使用Java提供的File IO等FFI过程中内存存在安全问题呢?
234+
235+
实际上并不会,因为对于那些允许close的Arena他们还有个引用计数法来在关闭时检测是否活跃
236+
237+
举个例子对于FileChannel来讲 Java通过IOUtil类统一封装了访问方式,统一收口处增加检测代码,用伪代码大概是这样的
238+
239+
```java
240+
segemnt.arena.count ++;
241+
file.read(segment);
242+
segement.arena.count--;
243+
```
244+
245+
在关闭时则直接检测count的值,若不为0说明仍在活跃,不应该关闭
246+
247+
我们可以通过Socket API构造一个永不返回的系统调用,来看看具体的close效果
248+
249+
```java
250+
public static void main(String[] args) throws Throwable {
251+
Thread.startVirtualThread(() -> {
252+
try(ServerSocket socket = new ServerSocket(8300);) {
253+
Socket s = socket.accept();
254+
System.out.println("Accept: " + s);
255+
//只接受不读取
256+
LockSupport.park();
257+
} catch (IOException e) {
258+
throw new RuntimeException(e);
259+
}
260+
});
261+
//wait listen
262+
TimeUnit.SECONDS.sleep(1);
263+
Arena arena = Arena.ofShared();
264+
try (arena) {
265+
MemorySegment segment = arena.allocate(1024);
266+
ByteBuffer byteBuffer = segment.asByteBuffer();
267+
new Thread(() -> {
268+
try {
269+
SocketChannel channel = SocketChannel.open();
270+
boolean connect = channel.connect(new InetSocketAddress("127.0.0.1", 8300));
271+
assertEquals(connect, true);
272+
//wait infinite
273+
channel.read(byteBuffer);
274+
System.out.println("end read");
275+
assertEquals(byteBuffer.get(0), 1);
276+
channel.close();
277+
} catch (Throwable e) {
278+
e.printStackTrace();
279+
}
280+
}).start();
281+
TimeUnit.SECONDS.sleep(1);
282+
}
283+
}
284+
```
285+
286+
最后clonse会抛出这样一个异常
287+
288+
```java
289+
Exception in thread "main" java.lang.IllegalStateException: Session is acquired by 1 clients
290+
```
291+
292+
最后一提new Thread这里不要替换为使用虚拟线程,由于虚拟线程发起read操作是在poller唤醒线程之后而不是在调用read的时候,所以换成虚拟线程你只能在虚拟线程那边拿到一个`Already closed`的异常
293+
294+
#### 线程本地握手
295+
296+
shared与confined的最大不同在与前者允许多线程访问,对于一个普通的访存操作都去增加一个引用计数显然是不现实的,所以shared在检测引用计数之后若发现其引用计数为0也不代表没有线程在访问这块内存了。
297+
298+
我们可以之直接看下ShareSession(这个就是具体的实现)是如何做的
299+
300+
1,第一步cas一下,设置为close这样在close之后的访问操作就无法进行,限制新增
301+
302+
2,发起closeScope处理现存的内存操作
303+
304+
```java
305+
void justClose() {
306+
int prevState = (int) STATE.compareAndExchange(this, OPEN, CLOSED);
307+
if (prevState < 0) {
308+
throw alreadyClosed();
309+
} else if (prevState != OPEN) {
310+
throw alreadyAcquired(prevState);
311+
}
312+
SCOPED_MEMORY_ACCESS.closeScope(this, ALREADY_CLOSED);
313+
}
314+
```
315+
316+
那么如何找出来哪些线程还在访问这段内存呢?Java对其的实现是依次查看JavaThread的栈是否正在调用对应操作,那么这里就有两个问题
317+
318+
1,如何高效且安全枚举JavaThread
319+
320+
2,如何判断某个线程正在访问这段内存
321+
322+
##### 如何高效且安全枚举JavaThread
323+
324+
一个JavaThread的栈帧最稳定的时候是它处于安全点的时候,这个时候我们可以安全地枚举每一帧,处理每一帧里面的oop,但是为了枚举某一个线程就使用**全局安全点**这个操作太重了,所以它其实利用的机制叫做**[线程本地握手](https://openjdk.org/jeps/312)**,依次去停顿对应的线程,相当于某个线程poll的其实是某个ThreadLocal的安全点,然后在终端处理函数中执行对应回调,这样在回调里面可以轻松枚举当前线程栈上的东西
325+
326+
对应代码可以参考[CloseScopedMemoryClosure](https://github.com/openjdk/jdk/blob/7cc7c080b5dbab61914512bf63227944697c0cbe/src/hotspot/share/prims/scopedMemoryAccess.cpp#L131)这个类中的do_thread方法
327+
328+
##### 判断正在访问这段内存
329+
330+
以getInt为例子它其实就是一个Unsafe的封装,只不过这个方法被`Scoped`标注,这个Scope会被JVM特殊处理用于给某一栈帧打上一个特殊标识
331+
332+
```java
333+
@ForceInline @Scoped
334+
private int getIntInternal(MemorySessionImpl session, Object base, long offset) {
335+
try {
336+
if (session != null) {
337+
session.checkValidStateRaw();
338+
}
339+
return UNSAFE.getInt(base, offset);
340+
} finally {
341+
Reference.reachabilityFence(session);
342+
}
343+
}
344+
```
345+
346+
我们可以直接来看看核心的代码(经过部分修改 更容易阅读)
347+
348+
```cpp
349+
static bool is_accessing_session(JavaThread* j, /*sharedsession对象*/oop session) {
350+
ResourceMark rm;
351+
const int max_critical_stack_depth = 10;
352+
int depth = 0;
353+
//遍历每一帧
354+
for (vframeStream stream(jt); !stream.at_end(); stream.next()) {
355+
Method* m = stream.method();
356+
bool is_scoped = m->is_scoped(); /这里就是我们刚才提到的Scoped注解标注
357+
if (is_scoped) {
358+
//获取这一帧上全部的oops 如果有对应的session则认为找到了正在使用的
359+
//此时不应该close
360+
StackValueCollection* locals = stream.asJavaVFrame()->locals();
361+
for (int i = 0; i < locals->size(); i++) {
362+
StackValue* var = locals->at(i);
363+
if (var->type() == T_OBJECT) {
364+
if (var->get_obj() == session) {
365+
return true;
366+
}
367+
}
368+
}
369+
return false;
370+
}
371+
depth++;
372+
}
373+
return false;
374+
}
375+
```
376+
377+
这样就做到了安全关闭shared arena,关闭增量,探测存量

0 commit comments

Comments
 (0)