大约 6 年前,我开始使用一个类,到那时它只是出于好奇 sun.misc.Unsafe。我曾将它用于反序列化和重新抛出异常,但没有使用它的所有功能或公开谈论它。
我看到的第一个认真使用 Unsafe 的开源库是 Disruptor。这鼓励我可以在稳定的库中使用它。大约一年后,我发布了我的第一个开源库,SharedHashMap(后来的 Chronicle Map)和 Chronicle(后来的 Chronicle Queue)。这使用 Unsafe 访问 Java 6 中的堆外内存。这对堆外内存的性能产生了真正的影响,但更重要的是我可以使用共享内存做什么。即跨 JVM 共享的数据结构。
但是今天它有多大的不同呢?使用 Unsafe 总是更快吗?
我们正在寻找的是引人注目的性能差异。如果差异不是很明显,则使用尽可能简单的代码更有意义。即使用自然Java。
测试
在这些测试中,我对源自堆外内存的数据进行了简单的积累。这是一个简单的测试,它模拟源自堆之外的解析数据(或散列数据),例如来自 TCP 连接或文件系统。数据大小为 128 字节。下面的结果可能会受到数据大小的影响,但假设这是具有代表性的。
我查看了不同大小的访问,一次是一个字节、一个 int 或一个 long。我还研究了使用 ByteBuffer,或者在堆上复制数据并使用自然 Java(我假设大多数程序都是这样做的)
我还比较了使用 Java 6 update 45、Java 7 update 79、Java 8 update 51 以了解使用不同方法在不同版本之间有何变化。
逐字节处理
处理器设计真正改进的地方在于它可以多快地复制大块数据。这意味着复制大块数据以便更有效地处理它是有意义的。即冗余副本可以足够便宜,从而可以产生更快的解决方案。
这是逐字节处理的情况。在这个例子中,“在堆上”包括在处理数据之前将数据复制到堆上的副本。这些数字是 i7-3790X 上每微秒的操作数。
Java 6 | Java 7 | Java 8 | |
字节缓冲区 | 15.8 | 16.9 | 16.4 |
不安全 | 17.2 | 17.5 | 16.9 |
在堆上 | 20.9 | 22.0 | 21.9 |
重要的是,“在堆上”不仅使用自然 Java,它也是所有三个 Java 版本中 最快的 。最可能的解释是 JIT 有一个优化,它可以在堆上的情况下进行如果您直接或间接使用 Unsafe,则不会。
由 Int 处理的 Int
解析冗长的线路协议的一种更快的方法是一次读取一个 int。例如,您可以通过一次读取一个 int 而不是单独查看每个字节来为已知格式编写 XML 解析器。这可以将解析速度提高 2 - 3 倍。这种方法最适合已知结构的内容。
Java 6 | Java 7 | Java 8 | |
字节缓冲区 | 12.6 | 36.2 | 35.1 |
不安全 | 44.5 | 52.7 | 54.7 |
在堆上 | 46.0 | 49.5 | 56.2 |
同样,这是 i7-3790X 上每微秒的操作数。有趣的是,在复制后使用自然 Java 与使用 Unsafe 一样快。对于此用例,也没有令人信服的理由使用 Unsafe。
长按长加工
虽然您可以编写一个一次读取 64 位 long 值的解析器,但我发现这比使用 32 位 int 值进行解析要难得多。我也没有发现结果要快得多。然而,散列数据结构可以从读取长值中获益,前提是散列算法在设计时考虑到了这一点。
Java 6 | Java 7 | Java 8 | |
字节缓冲区 | 12.1 | 56.7 | 53.3 |
不安全 | 66.7 | 83.0 | 94.9 |
在堆上 | 60.9 | 61.2 | 70.0 |
有趣的是,看看使用 ByteBuffer 的速度有多快。最可能的解释是在 ByteBuffer 中添加了将小端交换为默认大端的优化。 x86 有一条交换字节的指令,但我怀疑 Java 6 没有使用它,而是使用了更昂贵的移位操作。为了能够确认这一点,将需要更多测试和检查生成的汇编代码。
在这种情况下,使用 Unsafe 始终更快,您是否认为这种改进值得与直接使用 Unsafe 相关的风险,则是另一回事。
补充笔记
这些测试假定了统一的数据类型,如字节、整数或长整数。
在大多数实际情况下,存在这些数据类型的组合,这就是堆上困难的地方。例如,如果您需要解析字节、短裤、整数、长整数、浮点数、双精度数的任意组合。 ByteBuffer 是执行此操作的好方法,但在其他情况下它是最慢的选项。只有 Unsafe 可以让你在没有开销的情况下灵活地混合和匹配类型。
很难对这些混合类型在堆上进行公平测试,因为自然 Java 不直接支持这些操作。
结论
即使性能是您最关心的问题,在某些情况下,自然 Java 的性能会更好,或者与使用 Unsafe 一样快。它通常比 ByteBuffer 表现更好,因为 JIT 更擅长优化开销,例如自然 Java 代码的边界检查。
自然的 Java 代码依赖于我们可以将数据建模为 byte[]、int[] 或 long[] 的事实。没有数组或基本类型混合的选项。
自然 Java 挣扎的地方在于它对任何一个的支持范围
- 不同原始类型的任意组合,例如 byte、int、long、double。
- 共享/本机内存上的线程安全操作。
不幸的是,由于缺乏对自然 Java 的支持,因此很难创建一个公平的基准来比较性能。
总之,如果你能用自然的 Java 实现一个算法,它可能是最快的,也是最简单的。如果您需要分析混合数据类型的数据或线程安全的堆外数据,仍然没有从自然 Java 中执行此操作的好方法。
注意:这是 Java 9 中的 VarHandles 应该能够提供帮助的领域,因此请关注此空间以获取有关 VarHandles 的更新。