Unsafe与ByteBuffer那些事
上一篇文章《聊聊Unsafe的一些使用技巧》写作之后,阅读量很快超过了 1500,Kirito 在这里感谢大家的阅读啦,所以我又来更新了。如果你还没有阅读上一篇文章,我建议你先去看下,闲话不多说,开始今天的话题。
无论是日常开发还是竞赛,Unsafe 不常有而 ByteBuffer 常有,只介绍 Unsafe,让我的博文显得很“炫技”,为了证明“Kirito的技术分享”它可是一个正经的公众号,所以这篇文章会说到另一个比较贴地气的主角 ByteBuffer。我会把我这么多年打比赛的经验传授给你,只求你的一个三连。
从 DirectBuffer 的构造器说起
书接上文,我提到过 DirectBuffer 开辟的堆外内存其实就是通过 Unsafe 分配的,但没有详细介绍,今天就给他补上。看一眼 DirectBuffer 的构造函数
1 | DirectByteBuffer(int cap) { // package-private |
短短的十几行代码,蕴含了非常大的信息量,先说关键点
long base = unsafe.allocateMemory(size);
调用 Unsafe 分配内存,返回内存首地址unsafe.setMemory(base, size, (byte) 0);
初始化内存为 0。这一行我们放在下节做重点介绍。Cleaner.create(this, new Deallocator(base, size, cap));
设置堆外内存的回收器,不详细介绍了,可以参考我之前的文章《一文探讨堆外内存的监控与回收》。
仅构造器中的这一幕,便让 Unsafe 和 ByteBuffer 产生了千丝万缕的关联,发挥想象力的话,可以把 ByteBuffer 看做是 Unsafe 一系列内存操作 API 的 safe 版本。而安全一定有代价,在编程领域,一般都有一个常识,越是接近底层的事物,控制力越强,性能越好;越接近用户的事物,更易操作,但性能会差强人意。ByteBuffer 封装的 limit/position/capacity 等概念,用熟悉了之后我觉得比 Netty 后封装 的 ByteBuf 还要简便,但即使优秀如它,仍然有被人嫌弃的一面:大量的边界检查。
一个最吸引性能挑战赛选手去使用 Unsafe 操作内存,而不是 ByteBuffer 地方,便是边界检查。如示例代码一:
1 | public ByteBuffer put(byte[] src, int offset, int length) { |
你不用关心上述这段代码在 DirectBuffer 中充当着什么作用,我想展示给你的仅仅是它的 checkBounds 和 一堆 if/else,尤其是追求极致性能的场景,极客们看到 if/else 会神经敏感地意识到分支预测的性能下降,第二意识是这坨代码能不能去掉。
如果你不希望有一堆边界检查,完全可以借助 Unsafe 实现一个自定义的 ByteBuffer,就像下面这样。
1 | public class UnsafeByteBuffer { |
在一些比赛中,为了避免选手进入无止境的内卷,Unsafe 通常是禁用的,但是也有一些比赛,允许使用 Unsafe 的一部分能力,让选手们放飞自我,探索可能性。例如 Unsafe#allocateMemory
是不会受到 -XX:MaxDirectMemory
和 -Xms
限制的,在这次第二届云原生编程挑战赛遭到了禁用,但 Unsafe#put
、Unsafe#get
、Unsafe#copyMemory
允许被使用。 如果你一定希望使用 Unsafe 操作堆外内存,可以写出这样的代码,它跟示例代码一完成的是同样的操作。
1 | byte[] src = ...; |
这便是我想介绍的第一个关键点:DirectByteBuffer 可以借助 Unsafe 完成内存级别细粒度的操作,从而绕开边界检查。
DirectByteBuffer 的内存初始化
注意到 DirectByteBuffer 构造器中有另一个涉及到 Unsafe 的操作: unsafe.setMemory(base, size, (byte) 0);
。这段代码主要是为了给内存初始化 0。说实话,我是没有太懂这里的初始化操作,因为按照我的认知,默认值也是 0。在某些场景或者硬件下,内存操作是非常昂贵的,尤其是大片的内存被开辟时,这段代码可能会成为 DirectByteBuffer 的瓶颈。
如果希望分配内存时,不进行这段初始化逻辑,可以借助于 Unsafe 分配内存,再对 DirectByteBuffer 进行魔改。
1 | public class AllocateDemo { |
经过这么一顿操作,我们便得到了一份没有初始化的 DirectByteBuffer,不过不用担心,一切都在正常工作,并且 setMemory for free!
聊聊 ByteBuffer 的零拷贝
算作是题外话了,主要是跟 ByteBuffer 相关的一个话题:零拷贝。 ByteBuffer 在作为读缓冲区时被使用时,有一部分小伙伴会选择使用加锁的方式访问内存,但其实这是非常错误的做法,应当使用 ByteBuffer 提供的 duplicate 和 slice 这两个方法。
并发读取缓冲的方案:
1 | ByteBuffer byteBuffer = ByteBuffer.allocateDirect(1024); |
这样便可以在不改变原始 ByteBuffer 指针的前提下,任意对 slice 后的 ByteBuffer 进行并发读取了。
总结
最近时间有限,白天工作,晚上还要抽时间打比赛,先分享这么多。更多性能优化小技巧,可以期待一下 1~2 个星期云原生比赛结束,我就开始继续发总结和其他调优方案。
本文阅读求个 1000,不过分吧!
一键三连,这次一定。