使用“不安全”真的是关于速度还是功能?

一则或许对你有用的小广告

欢迎加入小哈的星球 ,你将获得:专属的项目实战 / 1v1 提问 / Java 学习路线 / 学习打卡 / 每月赠书 / 社群讨论

  • 新项目:《从零手撸:仿小红书(微服务架构)》 正在持续爆肝中,基于 Spring Cloud Alibaba + Spring Boot 3.x + JDK 17...点击查看项目介绍 ;
  • 《从零手撸:前后端分离博客项目(全栈开发)》 2 期已完结,演示链接: http://116.62.199.48/ ;

截止目前, 星球 内专栏累计输出 63w+ 字,讲解图 2808+ 张,还在持续爆肝中.. 后续还会上新更多项目,目标是将 Java 领域典型的项目都整一波,如秒杀系统, 在线商城, IM 即时通讯,权限管理,Spring Cloud Alibaba 微服务等等,已有 2200+ 小伙伴加入学习 ,欢迎点击围观

大约 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 的更新。

相关文章