这篇是为下一篇高性能 map 实现做铺垫, 因为大 map == 大内存, 在这个的前提下, gc 的开销会非常明显, 手动控制内存申请和释放, 以及尽可能的压缩不必要的(对象)空间占用, 是最容易想到的, 而在 Java 中, 它们都需要依赖
Unsafe这个特殊的包来进行操作, 一起来看看吧
0x00. 前置知识
首先源码版本基于 JDK 8/11, 本质没有太多差别, Unsafe 可以理解为一个官方提供原生操作内存为主要目的的库, 不用觉得很神奇或者特殊, 在 C 语言里调用 OS 库是家常便饭, 不过因为 Java 这里调用系统库有几个方式, 所以需要先分清一下区别, 避免后续概念混淆: (==重要==)
1. Unsafe 与 JNI 的关系
可以理解为大多数 Unsafe 的 API 是 JNI 中常用调用的 C 语言的官方封装, 官方把它认为最需要的一些”内存/cas/对象操作”封装到了一个工具类中, 这个类就叫 Unsafe, 你完全可以通过 JNI 自行封装一个 UnsafeDIY 的类达到类似的效果, 所以你可以看到 Unsafe 中基本都是native修饰的方法, 也就是这个原因了 (例如分配内存其实就是调用的 C 中的 malloc)
不直接使用 JNI 的原因:
- 很多人写 Java 就不会写 C/C++, 也不方便调试
- JNI 需要写大量胶水代码, 用起来不人性化
所以简单说 JNI 就是一个转换器, 让 Java 程序能调用你写的 C/C++ 代码, 你但凡搞不清楚关系的时候, 就想一下 C 是如何和 OS 打交道就行了, 所以很多文章里写 Unsafe/JNI 可以操作 “物理内存”, 可以避免”系统调用”开销, 你站 C/OS 的角度就知道这些肯定是错误的了..
PS: 至于 JNI/JNA/JNR/FFI 的关系, 则与本文不相关, 感兴趣可参考此篇
2. Unsafe 和堆外内存的关系
首先, Unsafe 大多数时候是操作堆外内存, 但也可以操作堆内, 并非与堆外绑死. Off-Heap memory (堆外内存) 是位于 Java 进程用户态内存的一部分, 与之对应的是堆内内存 (JVM 自动管理的部分)也在用户态, 只不过前者需要你像使用 C 一样手动分配/回收, 后者是 JVM 自动管理分配 + 回收罢了,
你可以理解为 C 系语言默认用户态操作的都是”堆外内存” (也就是 OS 进程概念中的堆内存概念), 不少人听到 “堆外” 以为是”进程外/内核态”的内存, 是完全不对的.
其次, 因为 Java 描述堆外内存可能用到多个术语, 容易让人有点混淆, 一般来说 DirectMemory/OffHeap/NaitveMemory 在 Java 中都代表堆外内存, 另外还有一个很容易踩坑的点是 —- 手动调用 Unsafe 申请的内存, 是不受 JVM 参数限制的: (怎么观察它的占用呢?)
JVM 里MaxDirectMemorySize 参数默认只能控制 ByteBuffer 间接申请的堆外内存, 你直接 Unsafe.allocateMemory() 近乎等同于 C 进程向 OS 申请内存, 默认只会受到 OS 的可用内存限制, 所以这也是为何 JNI 写的函数/代码使用的内存会无视 JVM 的最大堆/堆外内存限制的原因
当前已有的访问堆外内存的方案一般有 3 种:
- JNI 方式: 最不易用, 可控性最强, 主要是写 C 系代码
- Unsafe 访问: 不太易用, 可控性一般, 用 Java 模拟 C 系写法
- ByteBuffer 方式: 易用, 可控性最弱, 完全的 Java 风格 (它有两个常见实现类对应堆内 + 堆外)
- DirectByteBuffer: 堆内保留一个指针指向堆外, 底层就是封装的 Unsafe + 有个单独的跟踪对象(cleaner)来自动回收堆外内存 (最常见)
- HeapByteBuffer: 堆内内存开辟的 buffer 空间, 需要额外 1~2 次拷贝到堆外内存 (见参考)
其实 1/ 2 / 3 本质都是一种 => 就是JNI, 只不过 2 是对 1 的封装(官方给大家默认提供), 其他的实现都需要开发者自己写 “胶水 + C” 代码, 而 3 则是对 2 的封装, 递进的关系. 所以通常来说, 在 Unsafe 够用的情况下, 它是一个比较折中的实现高性能操作内存/non-blocking struct 的方案
3. Unsafe 和 DirectByteBuffer 的选择
上面我们已经知道 DirectByteBuffer(DBB) 其实就是再一次对 Unsafe 的封装, 并且还顺带提供了一个自动化机制来帮忙回收 Unsafe 申请的内存, 岂不美哉, 为何我们还要去用裸的 Unsafe API 呢? 主要有两个原因:
- DBB 不够灵活, 比如想任意的拷贝数组到 buffer 中的某个位置, 没有提供直接方法, 只能先设置 position 再 put(xx), 并发访问下 DBB 中许多方法也非原子操作 (比如上面的 position 和 put)
- 更好的性能, 自动化的观察/回收也都是有开销代价的 (若只 FullGC 时回收会迟钝得多, 又称为冰山对象)
另外在 Q2 中提到, DDB 申请的内存会受 JVM 参数限制, 而 Unsafe 自己用则不会(不过这是优点么:), 最后上面这两类选择并非二选一, Netty 就既使用 Unsafe 管理内存(避免太依赖 DDB), 也使用 “内存池 + DirectByteBuffer” 来提高小对象内存复用率 (这就是理论和实践的区别, 绝知此事要躬行~
4. 堆外内存和 PageCache / Direct IO 的关系
在 Q2 提到过 “堆外内存 ≠ pagecache“, 不少人误以为操作堆外内存就是操作内核态的 pagecache, 则能避免系统调用啥的…怎么可能呢? (JVM 也不过是一个普通的用户进程而已), 从另一个角度思考, 如果堆外内存在 pagecache 里, 那 Java 进程每次访问用户态都必触发 syscall, 效率该多低~
然后, Direct IO 和 PageCache 的关系倒是紧密, 前者就可理解为不走 PageCache 的 IO, 对比其他普通 I/O 不论是否落盘, 都需经过 PageCache (详见图), 也就是说你不想它缓存某些数据, OS 也不会听你的, 而使用 Direct IO 则可以在用户态自己控制缓存管理 [注: Linus对此持否定态度:)]
5. 使用堆外内存的优劣
首先要想一想什么时候使用堆外内存:
- 生命周期较长的对象, 不需要频繁回收 (堆外内存走 JNI 分配, 开销比堆内大)
- 生命周期较短, 但对 I/O 性能要求高的, 一般是用”内存池 + 堆外“的方式复用 (例如
Netty) - 占用内存较大的场景, 可以自定义的一次性申请/释放内存, 并且避免 gc (尽可能复用)
- 追求高性能文件I/O操作 (省去 (额外的) 堆内 -> 堆外内存拷贝开销, 见 Q2.3)
- 数据结构简单的对象 (因为堆外默认都是操作字节数组, 复杂对象还需要高昂的序列化开销)
至于劣势, 最主要还是 JNI 的易用性 + 性能问题, 这个详见 Q1 的引申扩展.
把这些基本的概念和问题分清楚对我们思考”何时应该使用 Unsafe/JNI 开发” 是很有帮助的, 另外如果熟悉常见 OS 的 syscall 也会大有裨益, 下面重点介绍一下 Unsafe 中最主要的两个模块, 一个是操作内存/数组地址的, 另一个是 CAS 来实现无阻塞(non-blocking)编程的.
0x01. 内存和数组
这两个结合也挺紧密, 也是 Unsafe 中最常见的使用 API, 先简单接触下 (下面假设我们已经初始化了 (Unsafe) root 对象):
1. 申请/访问/释放内存
同样, 因为大部分内存操作都是 C 系的模拟, 强烈建议不熟悉 C 的同学先简单回顾一下 C 的内存分配/回收, 且 Unsafe 底层也用的一样的 API:
1 | // C 中最常见的分配内存 malloc 函数 |
先把 C 的基本内存管理回顾清楚了, 然后再来看看封装了 C 的 Unsafe 是如何使用的, 会简单许多:
1 | /* (字节为单位) 分配内存很直观简单, 但是需要思考后面的几个问题 |
简单理解和实践后可知:
- 申请内存返回一个随机的内存地址, 正常情况是正数, 至于为何是 long, 我想可能是为了兼容不同 C 的内存指针大小吧 (补源码)
- 在 C 中, 单纯
malloc()是不会对内存区域赋(初)值的 (需memset(xx)), 也就是说可能会读到那块内存的旧值, 在 Unsafe 里呢同理并不保证初始值 - 申请不返回的情况同 C, 会一直占用造成内存泄露, 直到进程结束 OS 会强制回收
访问内存
1 | long base = root.allocateMemory(1); |
获取一个非法的内存地址, 可以轻松使得 jvm crash, 获得一个hs_err_pidXXX.log礼包,
2. 内存拷贝
同样源自 C 中的 void* memcpy(void* to, void* from, size_t n), 可见也很简单, 提供一个”待拷贝地址和新地址 + 拷贝字节数”(有 restrict 限制新旧内存空间不重叠), 简单的三个参数就能轻松完成内存的局部/完全拷贝 (而且原理也很简单, 模拟双指针++复制就行)
但是在 Java 中, 不借助 Unsafe 几乎就很难实现, 所以 Java 里完成”深拷贝“才会非常麻烦~ (DirectByteBuffer 也不支持这种拷贝), 下面就一起看看其他几个 memory 相关的方法:
1 | // 1. 内存拷贝 (入口) |
另外要打开思维的是, 因为对 OS 来说堆内/堆外都是堆内存, 所以自然你可以通过这样的方式在”堆内/堆外“之间进行任意的互相拷贝, 而并不是说它只能在堆外内存倒腾 (详见后文实践章)
由此就覆盖了 Unsafe 提供的所有内存操作 API, 还有一些 C 中内存比较/覆盖拷贝的方法并没有提供, 所以如果熟悉 C 的内存操作, Unsafe 也就不在话下了.
3. 内存地址 (获取)
上面提供了分配/释放/访问内存的方式, 但有个很关键的点, Java 开发者怎么知道除了 base 地址之外的中间地址呢, 比如 Student 对象的某个属性对应的地址是多少, 对象数组的偏移量是多少, 这里 Unsafe 直接提供了这些地址获取函数, 不然你直接看很多方法需要传入一个 offset 会觉得很懵:
objectFieldOffset(): 这个方法就是最常见的获取某个类属性在 class 中的偏移, 方式也很简单, 传入属性名即可, 并不用你去自己手算
Object getObject(Object o, long offset): 根据一个偏移获取一个对象的地址 (getObjectVolatile 是它的线程安全版)
TODO 待完善
0x02. CAS/FAA
在说 Java 的 CAS 之前, 我觉得最好是看看 OS 中的 CAS/FAA 原语, 因为最为直接清晰(估计很多人还不太熟悉 FAA), 其实它们比理解 Java 的 CAS 还简单不少, 首先 CAS 这个方法最初并不是什么原子指令, 更不是什么汇编指令, 别人只是一个普通的函数缩写
1. 用 C 实现 CAS/FAA
我们用 C 为例子, 模拟实现最简的 cas() + faa() 函数, 旨在让大家从最原生去理解函数到底在做啥, 入参 + 返回值是什么, 然后假设 cas 能提供原子性后, 试着用它来实现两个最经典的 Lock (🔒), 你会发现不论是 cas 还是锁, 还是排号, 最核心的原理都很简单.
1 | int cas(int *cur, int expectCur, int new) { |
然后把上面的代码换成汇编辅助保证原子性, 就是最后提供给上层使用的 cas 的真实面貌了.(这部分可自行查阅即可, 需熟悉内联汇编语法) 看完上面, 你可能觉得, 缘来 cas 是这么朴素的东西呐.
小结:
CAS/FAA ≠ 乐观锁, 也不是什么汇编指令 (别人是一/多个函数呀), 严谨说它们只是提供原子语义/操作的一个实现罢了, 虽然现在大家习惯把 CAS 叫做无锁, 但你看下它的汇编实现就知道是不对的, (严谨说应该是 non-blocking — 无挂起/阻塞), 但不管叫法如何, 知道它背后的来龙去脉也是好的, 弄清楚背景, 再去看 Java/Go/.. 的实现会通透得多. (绝大部分也只是简单的调用一下 C 的库函数罢了)
Q: 为何有了 CAS 还有 FAA?
首先 FAA 语义更简单, 如果我只是想简单的让变量 ±x, 那么只需要传入一个 x 的值即可. 而 CAS 除了可以模拟实现 FAA 外, 灵活性更高, 且是先比较再看是否需要赋值. 所以在 Java 中, 你可以看到它的 FAA 方法底层调用都是 atomic_cas 实现的, 没有单独调 atomic_faa()这样的 C 函数
2. Java 里的 CAS/FAA 实现
在理解了 C 实现 CAS 的基础上, 再来看 Java 的实现就会容易许多. 首先 Unsafe 中提供三个 CAS –-> Object/int/long, 以最简单的 int 为例来说一下方法参数/返回值的含义:
1 | public final native boolean compareAndSetInt(Object o, // 当前要修改的对象, 为啥不能直接传 *num 呢? |
对比 C 中的 CAS, 我们可以看到方法多了一个 offset入参, 然后第一个参数不再是 int* 指针而是一个对象, 似乎有点莫名其妙. 但简单想一下就明白了, 因为 Java 没有指针, 所以它不好去传递一个 int* cur, 那比较简单 + 通用的办法就是传入一个”对象地址 + 要改的 int 属性的相对偏移量”, 从而代替直接传入指针寻址, 然后调用 C 函数的时候再转换成一个指针地址传入 C 的 atomic_cas() (这样想是不是易懂些~)
例如你要修改 student 对象的 age, 你怎么找到 age 在内存中的地址呢? 没办法用指针的话, 那就通过获取 stu 的引用 + age 在 stu 中的一个偏移来实现, 所以如果不理解 CAS 的底层原理, 很多人首先就很难理解为啥改一个 int 需要传一个对象 + 一个奇怪的偏移..
还有一个和 C 中 cas 的区别是 java 里 cas 默认返回的是 bool 值, 这其实也是一个简化的语法糖罢了, 为了让用户更好理解/调用 cas, 所以布尔值提供一个更清晰的语义, true 代表修改成功, 否则代表没有, 之前自旋锁的循环就可以简化为 while(cas(lock, 0, 1)) 即可了. 你也可在 Unsafe 类中找到类似 getAndSet() 之类的 API, 这种就会返回 int 和原生 cas 保持一致了.
至于 FAA, 在 Java 里其实就是个语法糖, 底层还是使用的 CAS, 就不单独说了, 这也是为何 Java 极少有人知道 FAA 但是都对 CAS 印象深的原因.. 不再重述
0x03. 实践
那么上面铺垫了基本的知识, 包括 Unsafe/JNI/堆外内存/CAS 等, 下面就来上手实操一下 Unsafe 的核心两类 API 的使用, 以及看看它们的优点在哪, 首先我们尝试模拟 C 的方式去访问数组中某个位置的元素的值
- 在 Java 里, 假设是一个普通的
int[] a, 你直接通过a[0]就能访问第一个元素,a[3]就能访问第三个元素
1 |
TODO 待完善
参考资料:
- Unsafe 应用解析 - Meituan Tech
- 文件 IO 相关文章 - Kirito’s Blog
- Unsafe 不 safe, JDK17的 safe 实现访问堆外内存
- OS: Theory & Practice (Lock Refer)