Unsafe与ByteBuffer那些事

上一篇文章《聊聊Unsafe的一些使用技巧》写作之后,阅读量很快超过了 1500,Kirito 在这里感谢大家的阅读啦,所以我又来更新了。如果你还没有阅读上一篇文章,我建议你先去看下,闲话不多说,开始今天的话题。

无论是日常开发还是竞赛,Unsafe 不常有而 ByteBuffer 常有,只介绍 Unsafe,让我的博文显得很“炫技”,为了证明“Kirito的技术分享”它可是一个正经的公众号,所以这篇文章会说到另一个比较贴地气的主角 ByteBuffer。我会把我这么多年打比赛的经验传授给你,只求你的一个三连。

从 DirectBuffer 的构造器说起

书接上文,我提到过 DirectBuffer 开辟的堆外内存其实就是通过 Unsafe 分配的,但没有详细介绍,今天就给他补上。看一眼 DirectBuffer 的构造函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
DirectByteBuffer(int cap) {                   // package-private
super(-1, 0, cap, cap);
boolean pa = VM.isDirectMemoryPageAligned();
int ps = Bits.pageSize();
long size = Math.max(1L, (long)cap + (pa ? ps : 0));
Bits.reserveMemory(size, cap);
long base = 0;
try {
base = unsafe.allocateMemory(size);
} catch (OutOfMemoryError x) {
Bits.unreserveMemory(size, cap);
throw x;
}
unsafe.setMemory(base, size, (byte) 0);
if (pa && (base % ps != 0)) {
// Round up to page boundary
address = base + ps - (base & (ps - 1));
} else {
address = base;
}
cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
att = null;
}

短短的十几行代码,蕴含了非常大的信息量,先说关键点

  • 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public ByteBuffer put(byte[] src, int offset, int length) {
if (((long)length << 0) > Bits.JNI_COPY_FROM_ARRAY_THRESHOLD) {
checkBounds(offset, length, src.length);
int pos = position();
int lim = limit();
assert (pos <= lim);
int rem = (pos <= lim ? lim - pos : 0);
if (length > rem)
throw new BufferOverflowException();
Bits.copyFromArray(src, arrayBaseOffset,
(long)offset << 0,
ix(pos),
(long)length << 0);
position(pos + length);
} else {
super.put(src, offset, length);
}
return this;
}

你不用关心上述这段代码在 DirectBuffer 中充当着什么作用,我想展示给你的仅仅是它的 checkBounds 和 一堆 if/else,尤其是追求极致性能的场景,极客们看到 if/else 会神经敏感地意识到分支预测的性能下降,第二意识是这坨代码能不能去掉。

如果你不希望有一堆边界检查,完全可以借助 Unsafe 实现一个自定义的 ByteBuffer,就像下面这样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
public class UnsafeByteBuffer {

private final long address;
private final int capacity;
private int position;
private int limit;

public UnsafeByteBuffer(int capacity) {
this.capacity = capacity;
this.address = Util.unsafe.allocateMemory(capacity);
this.position = 0;
this.limit = capacity;
}

public int remaining() {
return limit - position;
}

public void put(ByteBuffer heapBuffer) {
int remaining = heapBuffer.remaining();
Util.unsafe.copyMemory(heapBuffer.array(), 16, null, address + position, remaining);
position += remaining;
}

public void put(byte b) {
Util.unsafe.putByte(address + position, b);
position++;
}

public void putInt(int i) {
Util.unsafe.putInt(address + position, i);
position += 4;
}

public byte get() {
byte b = Util.unsafe.getByte(address + position);
position++;
return b;
}

public int getInt() {
int i = Util.unsafe.getInt(address + position);
position += 4;
return i;
}

public int position() {
return position;
}

public void position(int position) {
this.position = position;
}

public void limit(int limit) {
this.limit = limit;
}

public void flip() {
limit = position;
position = 0;
}

public void clear() {
position = 0;
limit = capacity;
}

}

在一些比赛中,为了避免选手进入无止境的内卷,Unsafe 通常是禁用的,但是也有一些比赛,允许使用 Unsafe 的一部分能力,让选手们放飞自我,探索可能性。例如 Unsafe#allocateMemory 是不会受到 -XX:MaxDirectMemory-Xms 限制的,在这次第二届云原生编程挑战赛遭到了禁用,但 Unsafe#putUnsafe#getUnsafe#copyMemory 允许被使用。 如果你一定希望使用 Unsafe 操作堆外内存,可以写出这样的代码,它跟示例代码一完成的是同样的操作。

1
2
3
4
5
byte[] src = ...;

ByteBuffer byteBuffer = ByteBuffer.allocateDirect(src.length);
long address = ((DirectBuffer)byteBuffer).address();
Util.unsafe.copyMemory(src, 16, null, address, src.length);

这便是我想介绍的第一个关键点:DirectByteBuffer 可以借助 Unsafe 完成内存级别细粒度的操作,从而绕开边界检查

DirectByteBuffer 的内存初始化

注意到 DirectByteBuffer 构造器中有另一个涉及到 Unsafe 的操作: unsafe.setMemory(base, size, (byte) 0);。这段代码主要是为了给内存初始化 0。说实话,我是没有太懂这里的初始化操作,因为按照我的认知,默认值也是 0。在某些场景或者硬件下,内存操作是非常昂贵的,尤其是大片的内存被开辟时,这段代码可能会成为 DirectByteBuffer 的瓶颈。

如果希望分配内存时,不进行这段初始化逻辑,可以借助于 Unsafe 分配内存,再对 DirectByteBuffer 进行魔改。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class AllocateDemo {

private Field addressField;
private Field capacityField;

public AllocateDemo() throws NoSuchFieldException {
Field capacityField = Buffer.class.getDeclaredField("capacity");
capacityField.setAccessible(true);
Field addressField = Buffer.class.getDeclaredField("address");
addressField.setAccessible(true);
}

public ByteBuffer allocateDirect(int cap) throws IllegalAccessException {
long address = Util.unsafe.allocateMemory(cap);

ByteBuffer byteBuffer = ByteBuffer.allocateDirect(1);
Util.unsafe.freeMemory(((DirectBuffer) byteBuffer).address());

addressField.setLong(byteBuffer, address);
capacityField.setInt(byteBuffer, cap);

byteBuffer.clear();
return byteBuffer;
}

}

经过这么一顿操作,我们便得到了一份没有初始化的 DirectByteBuffer,不过不用担心,一切都在正常工作,并且 setMemory for free!

聊聊 ByteBuffer 的零拷贝

算作是题外话了,主要是跟 ByteBuffer 相关的一个话题:零拷贝。 ByteBuffer 在作为读缓冲区时被使用时,有一部分小伙伴会选择使用加锁的方式访问内存,但其实这是非常错误的做法,应当使用 ByteBuffer 提供的 duplicate 和 slice 这两个方法。

并发读取缓冲的方案:

1
2
3
4
5
6
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(1024);
ByteBuffer duplicate = byteBuffer.duplicate();
duplicate.limit(512);
duplicate.position(256);
ByteBuffer slice = duplicate.slice();
// use slice

这样便可以在不改变原始 ByteBuffer 指针的前提下,任意对 slice 后的 ByteBuffer 进行并发读取了。

总结

最近时间有限,白天工作,晚上还要抽时间打比赛,先分享这么多。更多性能优化小技巧,可以期待一下 1~2 个星期云原生比赛结束,我就开始继续发总结和其他调优方案。

本文阅读求个 1000,不过分吧!

一键三连,这次一定。


聊聊Unsafe的一些使用技巧

前言

记得初学 Java 那会,刚学完语法基础,就接触到了反射这个 Java 提供的特性,尽管在现在看来,这是非常基础的知识点,但那时候无疑是兴奋的,瞬间觉得自己脱离了“Java 初学者”的队伍。随着工作经验的积累,我也逐渐学习到了很多类似的让我为之而兴奋的知识点,Unsafe 的使用技巧无疑便是其中一个。

sun.misc.Unsafe 是 JDK 原生提供的一个工具类,包含了很多在 Java 语言看来很 cool 的操作,例如内存分配与回收、CAS 操作、类实例化、内存屏障等。正如其命名一样,由于其可以直接操作内存,执行底层系统调用,其提供的操作也是比较危险的。Unsafe 在扩展 Java 语言表达能力、便于在更高层(Java层)代码里实现原本要在更低层(C层)实现的核心库功能上起到了很大的作用。

从 JDK9 开始,Java 模块化设计的限制,使得非标准库的模块都无法访问到 sun.misc.Unsafe。但在 JDK8 中,我们仍然可以直接操作 Unsafe,再不学习,后面可能就没机会了。


使用堆内内存HeapByteBuffer的注意事项

前言

国庆假期一眨眼就过去了,本来在家躺平的很舒服,没怎么肝云原生编程挑战赛,传送门:https://tianchi.aliyun.com/s/8bf1fe4ae2aea736e692c31c6952042d ,偏偏对手们假期开始卷起来了,眼看就要被人反超了,吓得我赶紧继续优化了。比赛大概还有一个月才结束,Kirito 的详细方案也会在比赛结束后分享,这期间我会分享一些比赛中的一些通用优化或者细节知识点,例如本文就是这么一个例子。

趁着假期最后一天,分享一个很多人容易踩得一个坑:HeapByteBuffer 的使用问题。我们都知道 NIO 分装了 ByteBuffer 接口,使得 filechannel 的文件 IO API 变得非常的简单。ByteBuffer 主要有两个实现类

  • HeapByteBuffer 堆内内存
  • DirectByteBuffer 堆外内存

按我的个人经验,大多数情况,无论是读操作还是写操作,我都倾向于使用 DirectByteBuffer,主要是因为 HeapByteBuffer 在和 FileChannel 交互时,可能会有一些出乎大家意料的内部操作,也就是这篇文章的标题中提到的注意事项,这里先卖个关子。

先来看看这次比赛为什么要用到 HeapByteBuffer 呢?

原因一:赛题需要设计分级存储,并且提供了 6G 堆内内存 + 2G 堆外内存,一个最直接的思路便是使用内存来存储热点数据,而内存存储数据最方便的数据结构便是 ByteBuffer 了。

原因二:由于堆内 6G 远大于堆外 2G,且 JVM 参数不能调整,所以要想利用好堆内富余的内存去做缓存,非 HeapByteBuffer 莫属了。

可能有一些读者并没有关注赛题,我这里简化一下前言,可以直接理解为:有一块 2G 的 HeapByteBuffer 用于文件 IO,我们该如何利用。


文件 IO 中如何保证掉电不丢失数据

前言

好久没有分享文件 IO 的小技巧了,依稀记得上次分享还是在上次。

第二届云原生编程挑战赛正在火热进行中,Kirito 也在做《针对冷热读写场景的RocketMQ存储系统设计》这个题目,不过参与的是内部赛道,没法跟外部的小伙伴们一起排名了。

众所周知,存储设计离不开文件 IO,将数据存储到文件中进行持久化,是大多数消息队列、数据库系统的常规操作。在比赛中,为了更贴近实际的生产场景,往往也会引入正确性检测阶段,以避免让选手设计一些仅仅支持内存行为的代码逻辑。试想一下,RocketMQ 或者 Mysql 在宕机之后因为索引丢失,而导致数据无法查询,这该是多么可怕的一件事!

正确性检测要求我们写入的数据能够被查询出来,没有丢失,按照我个人的参赛经验,通常分为三种级别

  • 进程正常退出或者进程被 kill -15 中断
  • 进程被 kill -9 中断
  • 系统掉电

“字节序”是个什么鬼

转载自:https://zhuanlan.zhihu.com/p/21388517

论顺序的重要性

做饭的故事

今天女朋友加班,机智的她早已在昨晚准备好食材,回家只需下锅便可。谁知开会就是个无底洞,到了B1,还有B2,无穷匮也。

辛苦如她,为了能让她一回家就吃上热腾腾的饭菜,我准备亲自下厨,奉献出我的第一次。食材都已备好,我相信没有那么难,估摸着应该和我习以为常的流程处理差不多,开火 | 加食材 | 上配料 | 翻炒 | 出锅,啊哈,想想还有点小激动。

今天的晚饭是西红柿炒鸡蛋和胡萝卜炒肉,实际操作才发现,又遇到了一个大坑……

食材是这样的:

1
2
案板1号(西红柿炒鸡蛋的食材),从左向右依次放着:西红柿、鸡蛋、葱
案板2号(胡萝卜炒肉的食材),从左向右依次放着:蒜、胡萝卜丝、肉

食材在案板上整齐划一依次排开,我是先放西红柿呢,还是先放鸡蛋呢,还是先放葱呢?简单沟通后得知,案板上的食材是按顺序放好的,我只需要按顺序下锅即可。听着电视哼着90年代的老歌,三下五除二,两道菜如期完成。

闻着怪味,我知道第一次就这么失败了。

等她回家,一番检讨后,才知道是顺序放错了。每道菜都应该是从右往左依次放食材,即葱->鸡蛋->西红柿。这是逗我的么!?一般人所理解的按默认顺序不应该是从左往右嘛!

朋友们,到底应该是从左往右还是从右往左?

剥鸡蛋的故事

《格列佛游记》中记载了两个征战的强国,你不会想到的是,他们打仗竟然和剥鸡蛋的姿势有关。

很多人认为,剥鸡蛋时应该打破鸡蛋较大的一端,这群人被称作“大端(Big endian)派”。可是当今皇帝的祖父小时候吃鸡蛋的时候碰巧将一个手指弄破了。所以,他的父亲(当时的皇帝)就下令剥鸡蛋必须打破鸡蛋较小的一端,违令者重罚,由此产生了“小端(Little endian)派”。

大端序和小端序

老百姓们对这项命令极其反感,由此引发了6次叛乱,其中一个皇帝送了命,另一个丢了王位。据估计,先后几次有11000人情愿受死也不肯去打破鸡蛋较小的一端!

看到没有,仅仅是剥鸡蛋就能产生这么大的分歧,“大端”和“小端”有这么重要嘛!

字节序

字节

字节(Byte)作为计算机世界的计量单位,和大家手中的人民币多少多少“元”一个意思。反正,到了计算机的世界,说字节就对了,使用人家的基本计量单位,这是入乡随俗。

比如,一个电影是1G个字节(1GB),一首歌是10M个字节(10MB),一张图片是1K个字节(1KB)。

字节序

一元钱可以干嘛?啥也干不了,公交都不够坐的。一个字节可以干嘛?至少可以存一个字符。

当数据太大,一个字节存不下的时候,我们就得使用多个字节了。比如,我有两个分别需要4个字节存储的整数,为了方便说明,使用16进制表示这两个数,即0x12345678和0x11223344。有的人采用以下方式存储这个两个数字:

这个方案看起来不错,但是,又有人采用了以下方式:

蒙圈了吧,到底该用哪一种方式来存!两种方案虽有不同,但也有共识,即依次存储每一个数字,即先存0x12345678,再存0x11223344。大家的分歧在于,对于某一个要表示的值,因为只能一个字节一个字节的存嘛,我是把值的低位存到低地址,还是把值的高位存到低地址。前者使用的是“小端(Little endian)”字节序,即先存低位的那一端(两个数字的最低位分别是0x78、0x44),如上图中的第一个图;后者使用的是“大端(Big endian)”字节序,即先存高位的那一端(两个数字的最高位分别是0x12、0x11),如上图中的第二个图。

由此也引发了计算机界的大端与小端之争,不同的CPU厂商并没有达成一致:

  • x86,MOS Technology 6502,Z80,VAX,PDP-11等处理器为Little endian。
  • Motorola 6800,Motorola 68000,PowerPC 970,System/370,SPARC(除V9外)等处理器为Big endian。
  • ARM, PowerPC (除PowerPC 970外), DEC Alpha, SPARC V9, MIPS, PA-RISC and IA64的字节序是可配置的。

大端也好,小端也罢,就权当是个人爱好吧,只要你不影响别人就行,对不?

网络字节序

前面的大端和小端都是在说计算机自己,也被称作主机字节序。其实,只要自己能够自圆其说是没啥问题的。问题是,网络的出现使得计算机可以通信了。通信,就意味着相处,相处必须得有共同语言啊,得说普通话,要不然就容易会错意,下了一个小时的小电影发现打不开,理解错误了!

但是每个计算机都有自己的主机字节序啊,还都不依不饶,坚持做自己,怎么办?

TCP/IP协议隆重出场,RFC1700规定使用“大端”字节序为网络字节序,其他不使用大端的计算机要注意了,发送数据的时候必须要将自己的主机字节序转换为网络字节序(即“大端”字节序),接收到的数据再转换为自己的主机字节序。这样就与CPU、操作系统无关了,实现了网络通信的标准化。突然觉得,TCP/IP协议好任性啊有木有!

为了程序的兼容,你会看到,程序员们每次发送和接受数据都要进行转换,这样做的目的是保证代码在任何计算机上执行时都能达到预期的效果。

这么常用的操作,BSD Socket提供了封装好的转换接口,方便程序员使用。包括从主机字节序到网络字节序的转换函数:htons、htonl;从网络字节序到主机字节序的转换函数:ntohs、ntohl。当然,有了上面的理论基础,也可以编写自己的转换函数。

下面的一段代码可以用来判断计算机是大端的还是小端的,判断的思路是确定一个多字节的值(下面使用的是4字节的整数),将其写入内存(即赋值给一个变量),然后用指针取其首地址所对应的字节(即低地址的一个字节),判断该字节存放的是高位还是低位,高位说明是Big endian,低位说明是Little endian。

1
2
3
4
5
6
7
8
9
10
11
12
#include <stdio.h>
int main ()
{
unsigned int x = 0x12345678;
char *c = (char*)&x;
if (*c == 0x78) {
printf("Little endian");
} else {
printf("Big endian");
}
return 0;
}

身边的字节序

字符编码方式UTF-16、UTF-32同样面临字节序的问题,因为他们分别使用2个字节和4个字节编码Unicode字符,一旦某个值用多个字节表示,就必须要考虑存储的顺序了。于是,采用了最简单粗暴的方式,给文件头部写几个字符,用来表示是大端呢还是小端:

头部的字符 编码 字节序 FF FE UTF-16/UCS-2 Little endian FE FF UTF-16/UCS-2 Big endian FF FE 00 00 UTF-32/UCS-4 Little endian 00 00 FE FF UTF-32/UCS-4 Big-endian

这里不得不提一下UTF-8啊,明明人家是单个字节的,不存在什么字节序的问题。微软为了统一UTF-X,硬生生给他的头部也加了几个字符!是的,这几个字符就是BOM(Byte Order Mark),这就是Windows下的UTF-8。

相信很多人都被UTF-8的BOM给坑过,多了这个BOM的UTF-8文件,会导致很多问题啊。比如,写的Shell脚本,内容为#!/usr/bin/env bash,在UTF-8有BOM和UTF-8无BOM的编码下,对应的16进制为:

BOM

所以,有BOM的话,Shell解释器就报错啦。原因在于,解释器希望遇到#!/usr/bin/env bash,而使用UTF-8有BOM进行编码的内容会多了3个字节的EF BB BF。

对于UTF-8和UTF-8无BOM两种编码格式,我们更多的使用UTF-8无BOM。


一文探讨堆外内存的监控与回收

引子

记得那是一个风和日丽的周末,太阳红彤彤,花儿五颜六色,96 年的普哥微信找到我,描述了一个诡异的线上问题:线上程序使用了 NIO FileChannel 的 堆内内存(HeapByteBuffer)作为缓冲区,读写文件,逻辑可以说相当简单,但根据监控,却发现堆外内存(DirectByteBuffer)飙升,导致了 OutOfMemeory 的异常。

由这个线上问题,引出了这篇文章的主题,主要包括:FileChannel 源码分析,堆外内存监控,堆外内存回收。


Java 文件 IO 操作之 DirectIO

在前文《文件 IO 操作的一些最佳实践》中,我介绍了一些 Java 中常见的文件操作的接口,并且就 PageCache 和 DIrect IO 进行了探讨,最近我自己封装了一个 Direct IO 的库,趁着这个机会,本文重点谈谈 Java 中 Direct IO 的意义,以及简单介绍下我自己的轮子。


文件 IO 操作的一些最佳实践

背景

已经过去的中间件性能挑战赛,和正在进行中的 第一届 PolarDB 数据性能大赛 都涉及到了文件操作,合理地设计架构以及正确地压榨机器的读写性能成了比赛中获取较好成绩的关键。正在参赛的我收到了几位公众号读者朋友的反馈,他们大多表达出了这样的烦恼:“对比赛很感兴趣,但不知道怎么入门”,“能跑出成绩,但相比前排的选手,成绩相差 10 倍有余”…为了能让更多的读者参与到之后相类似的比赛中来,我简单整理一些文件 IO 操作的最佳实践,而不涉及整体系统的架构设计,希望通过这篇文章的介绍,让你能够欢快地参与到之后类似的性能挑战赛之中来。


Your browser is out-of-date!

Update your browser to view this website correctly.&npsb;Update my browser now

×