|
| 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