Skip to content

Commit

Permalink
Panama-memorysegment
Browse files Browse the repository at this point in the history
  • Loading branch information
dreamlike-ocean committed Oct 1, 2024
1 parent 98f94bd commit e48b5f1
Show file tree
Hide file tree
Showing 2 changed files with 378 additions and 0 deletions.
1 change: 1 addition & 0 deletions SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@

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

Expand Down
377 changes: 377 additions & 0 deletions panama/panama-tutorial-1-MemorySegment.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,377 @@
# Panama教程-1-MemorySegment介绍

## 前言

对于Panama来说其分为两部分,一部分是FFM,即一些内存操作的API,一部分是FFI,即如何调用符合当前平台的ABI的动态库代码

本文是对于FFM部分的介绍

## MemorySegment基础介绍

粗看MemorySegemnt与NIO包下的Bytebuffer差不多,都是支持将byte[]和堆外指针封装为一个对象,实现在统一视图下的操作

### 使用

其并不提供任何分配内存的api,而是专注于包装已有的内存指针

其堆外内存的分配来于SegmentAllocator接口的实现类,至于为什么需要Arena类我们下面再展开

```java
// 包装一个Java原始类型数组
MemorySegment.ofArray(...);

//分配一段堆外内存
Arena arena = Arena.ofConfined();
MemorySegment allocate = arena.allocate(8);

//包装一个堆外指针
MemorySegment.ofAddress(...);

//包装一个nio bytebuffer
MemorySegment.ofBuffer(ByteBuffer.allocate(8))
```

对于内存读写操作也很简单

就像是指针操作一样直接从某个偏移量获取值即可,这里是跟ByteBuffer一个很大的不同,ByteBuffer会维护一组“内部状态”,你需要在各种get/put/flip方法之间辗转腾挪比较麻烦,而MemorySegment的api则很干净,只支持简单的基于偏移量的取值

```java
memorySegment.get(ValueLayout.JAVA_INT, /*offset*/0);
memorySegment.set(ValueLayout.JAVA_INT, /*offset*/0, /*value*/1);
memorySegment.asSlice(/*offset*/0, /*newSize*/12);
```

### 与ByteBuffer区别

#### 堆外内存生命周期

> 当我们谈论这里的生命周期的时候一般是谈论他的malloc和free
>
> 而且若无提及,均指的是public api
ByteBuffer的堆外内存的生命周期,分配是交由用户手动分配的,但是释放却是交由GC释放,我们并不能手动干涉将其立刻释放

而MemorySegment则是将其生命周期与Arena相绑定,Arena这个词也是个懂得都懂的词,不懂的还是看不懂的词,他实际上可以理解为一个Scope,将MemorySegment的释放回调挂载到这个Scope上,当这个Scope关闭时,释放掉由它分配的全部MemorySegment

```java
try (Arena scope = Arena.ofConfined()) {
MemorySegment memorySegment = scope.allocate(1024);
//do something
} // 在这里面被释放
```

#### 字节序

对于ByteBuffer而言其分配出来的堆外内存,在使用ByteBuffer API进行访问的时候均为大段序

而MemorySegment为native order。

以我当前使用的intel x86_64bit的Linux主机为例,当前平台的native order为小端序

当我指定对应byte order访问的时候 直接getLong的值才与MemorySegment一致

```java
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(8);
assertEquals(byteBuffer.order(), ByteOrder.BIG_ENDIAN);

MemorySegment memorySegment = MemorySegment.ofBuffer(byteBuffer);
memorySegment.set(ValueLayout.JAVA_LONG, 0, 1);
assertEquals((long) 1 << 56, byteBuffer.getLong(0));
assertEquals(memorySegment.get(ValueLayout.JAVA_LONG, 0), byteBuffer.order(ByteOrder.LITTLE_ENDIAN).getLong(0));
```

## Arena和MemorySegment

> 若无特意提及则默认均为堆外内存
这里我们简单介绍下我们刚才草草省略过的Arena,也就是MemorySegment的生命周期管理

### 统一概念

Arena这里本意是场馆的意思,实际上就是暗示它管理了全部由其分配的内存段的生命周期。

注意虽然Golang之类的语言中也有Arena但是Java这里的Arena并不保证每次分配出来的内存是连续的,对于某些语言Arena api只是将一大块内存拿出来切块分配,最后统一回收,但是Java仅仅是强调统一回收这一概念。

除了我们在上面展示的通过close回收分配的堆外内存之外,还会提供在Arena close后限制对应内存段的读写操作。如下代码,当我们进行操作一个关联了scope的内存端时,其scope被关闭后,会直接抛出异常,避免出现UAF的高危行为

```java
public static void main(String[] args) throws Throwable {
Arena localScope = Arena.ofConfined();
MemorySegment segment = localScope.allocate(1024);
localScope.close();
//Exception in thread "main" java.lang.IllegalStateException: Already closed
segment.get(ValueLayout.JAVA_INT, 0);

}
```

Arena根据实现不同,还能提供Thread Owner的控制,那么我们接下来介绍下几个常用的Arena

### Confined Arena

这是一个限定比较多的也是比较常用的一个Arena,其返回的Arena是一个只允许单线程使用的且需要手动关闭的Arena

```java
public static void main(String[] args) throws Throwable {
Arena localScope = Arena.ofConfined();
MemorySegment segment = localScope.allocate(1024);

Thread.startVirtualThread(() -> {
try {
localScope.allocate(1024);
} catch (Throwable t) {
System.out.println("其他线程无法分配");
}
try {
segment.get(ValueLayout.JAVA_INT, 0);
} catch (Throwable t) {
System.out.println("其他线程无法访问");
}
}).join();

localScope.close();
try {
segment.get(ValueLayout.JAVA_INT, 0);
} catch (Throwable t) {
System.out.println("关闭后无法使用");
}

}
```

### Shared Arena

这是一个多线程版本的Confined Arena,允许在其余线程中进行访问,但是需要手动关闭,在关闭过程中若仍有线程在使用其分配出来的内存段则会关闭失败,建议与结构化并发API一同使用

```java
public static void main(String[] args) throws Throwable {
MemorySegment uaf = null;
try (StructuredTaskScope<Void> currencyScope = new StructuredTaskScope<Void>();
Arena arena = Arena.ofShared()
) {
StructuredTaskScope.Subtask<Void> task1 = currencyScope.fork(() -> {
MemorySegment _ = arena.allocate(1024);
return null;
});

StructuredTaskScope.Subtask<Void> task2 = currencyScope.fork(() -> {
MemorySegment _ = arena.allocate(1024);
return null;
});
currencyScope.join();
assertEquals(task1.state(), StructuredTaskScope.Subtask.State.SUCCESS);
assertEquals(task2.state(), StructuredTaskScope.Subtask.State.SUCCESS);

uaf = arena.allocate(1024);
}
//抛出异常
uaf.get(ValueLayout.JAVA_BYTE, 0);
}
```

### Auto Arena

这是一个**不允许手动关闭**的Shared Arena,允许在其余线程中进行访问,其分配的内存端均被自动管理——其实就是GC机制+Cleaner管理Auto Arena的生命周期,这里并不是让GC管理每一个内存段而是通过发现Auto Arena不可达之后,触发一个cleaner回调将其分配的全部内存段回收

对于其分配出来的MemorySegment,均会持有对应Auto Arena的引用,即 MemorySegment <=> Arena,这样只有Arena不可达,且其分配出来的每一个MemorySement均不可达 才允许其管理的全部MemorySegment被释放

某种程度上这种管理方案与旧有的ByteBuffer 管理方式一致

```
public static void main(String[] args) throws Throwable {
Arena auto = Arena.ofAuto();
MemorySegment memorySegment = auto.allocate(ValueLayout.JAVA_INT);
}
```

### Global Arena

这个就更简单了,它不允许关闭,只管分配也不进行任何回收。类似于Rust的`static生命周期,其分配出来的内存允许跨线程,允许任意使用,随着进程销毁一起回收

大部分情况下可能没什么用,但是`MemorySegment.ofAddress(0)`中,就默认是Global Arena,对于大部分从native返回的指针均为这个作用域,所以可以通过这个API来 “擦除” 作用域

```java
public static void main(String[] args) throws Throwable {
Arena global = Arena.global();
System.out.println(global.allocate(1024).scope());
System.out.println(MemorySegment.ofAddress(0).scope());
}
```

### 自定义Arena

你可以使用` java.lang.foreign.SegmentAllocator`实现一个将一大块内存拿出来切块分配,最后统一回收的Arena,batch化内存分配操作

```java
class SlicingArena implements Arena {
final Arena arena = Arena.ofConfined();
final SegmentAllocator slicingAllocator;

SlicingArena(long size) {
slicingAllocator = SegmentAllocator.slicingAllocator(arena.allocate(size));
}

public MemorySegment allocate(long byteSize, long byteAlignment) {
return slicingAllocator.allocate(byteSize, byteAlignment);
}

public MemorySegment.Scope scope() {
return arena.scope();
}

public void close() {
arena.close();
}

}
```

### Arena关闭时的内存安全

#### 引用计数法

刚才提到了在Arena关闭后无法通过MemorySegment相关的API进行访问,但是在我们跟操作系统API交互的时候,OS无法感知我们这个检测机制,那么是否意味着我们在使用Java提供的File IO等FFI过程中内存存在安全问题呢?

实际上并不会,因为对于那些允许close的Arena他们还有个引用计数法来在关闭时检测是否活跃

举个例子对于FileChannel来讲 Java通过IOUtil类统一封装了访问方式,统一收口处增加检测代码,用伪代码大概是这样的

```java
segemnt.arena.count ++;
file.read(segment);
segement.arena.count--;
```

在关闭时则直接检测count的值,若不为0说明仍在活跃,不应该关闭

我们可以通过Socket API构造一个永不返回的系统调用,来看看具体的close效果

```java
public static void main(String[] args) throws Throwable {
Thread.startVirtualThread(() -> {
try(ServerSocket socket = new ServerSocket(8300);) {
Socket s = socket.accept();
System.out.println("Accept: " + s);
//只接受不读取
LockSupport.park();
} catch (IOException e) {
throw new RuntimeException(e);
}
});
//wait listen
TimeUnit.SECONDS.sleep(1);
Arena arena = Arena.ofShared();
try (arena) {
MemorySegment segment = arena.allocate(1024);
ByteBuffer byteBuffer = segment.asByteBuffer();
new Thread(() -> {
try {
SocketChannel channel = SocketChannel.open();
boolean connect = channel.connect(new InetSocketAddress("127.0.0.1", 8300));
assertEquals(connect, true);
//wait infinite
channel.read(byteBuffer);
System.out.println("end read");
assertEquals(byteBuffer.get(0), 1);
channel.close();
} catch (Throwable e) {
e.printStackTrace();
}
}).start();
TimeUnit.SECONDS.sleep(1);
}
}
```

最后clonse会抛出这样一个异常

```java
Exception in thread "main" java.lang.IllegalStateException: Session is acquired by 1 clients
```

最后一提new Thread这里不要替换为使用虚拟线程,由于虚拟线程发起read操作是在poller唤醒线程之后而不是在调用read的时候,所以换成虚拟线程你只能在虚拟线程那边拿到一个`Already closed`的异常

#### 线程本地握手

shared与confined的最大不同在与前者允许多线程访问,对于一个普通的访存操作都去增加一个引用计数显然是不现实的,所以shared在检测引用计数之后若发现其引用计数为0也不代表没有线程在访问这块内存了。

我们可以之直接看下ShareSession(这个就是具体的实现)是如何做的

1,第一步cas一下,设置为close这样在close之后的访问操作就无法进行,限制新增

2,发起closeScope处理现存的内存操作

```java
void justClose() {
int prevState = (int) STATE.compareAndExchange(this, OPEN, CLOSED);
if (prevState < 0) {
throw alreadyClosed();
} else if (prevState != OPEN) {
throw alreadyAcquired(prevState);
}
SCOPED_MEMORY_ACCESS.closeScope(this, ALREADY_CLOSED);
}
```

那么如何找出来哪些线程还在访问这段内存呢?Java对其的实现是依次查看JavaThread的栈是否正在调用对应操作,那么这里就有两个问题

1,如何高效且安全枚举JavaThread

2,如何判断某个线程正在访问这段内存

##### 如何高效且安全枚举JavaThread

一个JavaThread的栈帧最稳定的时候是它处于安全点的时候,这个时候我们可以安全地枚举每一帧,处理每一帧里面的oop,但是为了枚举某一个线程就使用**全局安全点**这个操作太重了,所以它其实利用的机制叫做**[线程本地握手](https://openjdk.org/jeps/312)**,依次去停顿对应的线程,相当于某个线程poll的其实是某个ThreadLocal的安全点,然后在终端处理函数中执行对应回调,这样在回调里面可以轻松枚举当前线程栈上的东西

对应代码可以参考[CloseScopedMemoryClosure](https://github.com/openjdk/jdk/blob/7cc7c080b5dbab61914512bf63227944697c0cbe/src/hotspot/share/prims/scopedMemoryAccess.cpp#L131)这个类中的do_thread方法

##### 判断正在访问这段内存

以getInt为例子它其实就是一个Unsafe的封装,只不过这个方法被`Scoped`标注,这个Scope会被JVM特殊处理用于给某一栈帧打上一个特殊标识

```java
@ForceInline @Scoped
private int getIntInternal(MemorySessionImpl session, Object base, long offset) {
try {
if (session != null) {
session.checkValidStateRaw();
}
return UNSAFE.getInt(base, offset);
} finally {
Reference.reachabilityFence(session);
}
}
```

我们可以直接来看看核心的代码(经过部分修改 更容易阅读)

```cpp
static bool is_accessing_session(JavaThread* j, /*sharedsession对象*/oop session) {
ResourceMark rm;
const int max_critical_stack_depth = 10;
int depth = 0;
//遍历每一帧
for (vframeStream stream(jt); !stream.at_end(); stream.next()) {
Method* m = stream.method();
bool is_scoped = m->is_scoped(); /这里就是我们刚才提到的Scoped注解标注
if (is_scoped) {
//获取这一帧上全部的oops 如果有对应的session则认为找到了正在使用的
//此时不应该close
StackValueCollection* locals = stream.asJavaVFrame()->locals();
for (int i = 0; i < locals->size(); i++) {
StackValue* var = locals->at(i);
if (var->type() == T_OBJECT) {
if (var->get_obj() == session) {
return true;
}
}
}
return false;
}
depth++;
}
return false;
}
```
这样就做到了安全关闭shared arena,关闭增量,探测存量

0 comments on commit e48b5f1

Please sign in to comment.