速来报名!AICon北京站鸿蒙专场~ 了解详情
写点什么

sun.misc.Unsafe 的后启示录

Java after the Unsafe dust settles.

  • 2016-01-25
  • 本文字数:7810 字

    阅读完需:约 26 分钟

Java 语言和 JVM 平台已经度过了 20 岁的生日。它最初起源于机顶盒、移动设备和 Java-Card,同时也应用在了各种服务器系统中,Java 已成为物联网(Internet of Things)的通用语言。我们显然可以看到 Java 已经无处不在!

但是不那么为人所知的是,Java 也广泛应用于各种低延迟的应用中,如游戏服务器和高频率的交易应用。这只所以能够实现要归功于 Java 的类和包在可见性规则中有一个恰到好处的漏洞,让我们能够使用一个很便利的类,这个类就是 _sun.misc.Unsafe_。这个类从过去到现在一直都有着很大的分歧,有些人喜欢它,而有些人则强烈地讨厌它——但关键的一点在于,它帮助 JVM 和 Java 生态系统演化成了今天的样子。基本上可以说,Unsafe 类为了速度,在 Java 严格的安全标准方面做了一些妥协。

如果在 Java 世界中移除了 _sun.misc.Unsafe_(和一些较小的私有 API),并且没有足够的 API 来替代的话,那 Java 世界将会发生什么呢,针对这一点引发了热烈的讨论,包括在 JCrete 上、“ sun.misc.Unsafe 会发生什么”论文以及在 DripStat 像这样的博客文章。Oracle 的最终提议( JEP260 )解决了这个问题,它提供了一个很好的迁移路径。但问题依然存在——在 Unsafe 真的消失后,Java 世界将会是什么样子呢?

组织

乍看上去,_sun.misc.Unsafe_ 的特性集合可能会让我们觉得有些混乱,它一站式地提供了各种特性。

我试图将这些特性进行分类,可以得到如下 5 种使用场景:

  • 对变量和数组内容的原子访问,自定义内存屏障
  • 对序列化的支持
  • 自定义内存管理 / 高效的内存布局
  • 与原生代码和其他 JVM 进行互操作
  • 对高级锁的支持

在我们试图为这些功能寻找替代实现时,至少在最后一点上可以宣告胜利。Java 早就有了强大(坦白说也很漂亮)的官方 API,这就是 _java.util.concurrent.LockSupport_。

原子访问

原子访问是 _sun.misc.Unsafe_ 被广泛应用的特性之一,特性包括简单的“put”和“get”操作(带有 volatile 语义或不带有 volatile 语义)以及比较并交换(compare and swap,CAS)操作。

复制代码
public long update() {
for(;;) {
long version = this.version;
long newVersion = version + 1;
if (UNSAFE.compareAndSwapLong(this, VERSION_OFFSET, version, newVersion)) {
return newVersion;
}
}
}

但是,请稍等,Java 不是已经通过官方 API 为该功能提供了支持吗?绝对是这样的,借助 Atomic 类确实能够做到,但是它会像基于 _sun.misc.Unsafe_ 的 API 一样丑陋,在某些方面甚至更糟糕,让我们看一下到底为什么。

AtomicX 类实际上是真正的对象。假设我们要维护一个存储系统中的某条记录,并且希望能够跟踪一些特定的统计数据或元数据,比如版本的计数:

复制代码
public class Record {
private final AtomicLong version = new AtomicLong(0);
public long update() {
return version.incrementAndGet();
}
}

尽管这段代码非常易读,但是它却污染到了我们的堆,因为每条数据记录都对应两个不同的对象,而不是一个对象,具体来讲,这两个对象也就是 Atomic 实例以及实际的记录本身。它所导致的问题不仅仅是产生无关的垃圾,而且会导致额外的内存占用以及 Atomic 实例的解引用(dereference)操作。

但是,我们可以做的更好一点——还有另外一个 API,那就是 _java.util.concurrent.atomic.AtomicXFieldUpdater 类 _。

_AtomixXFieldUpdater_ 是正常 Atomic 类的内存优化版本,它牺牲了 API 的简洁性来换取内存占用的优化。通过该组件的单个实例就能支持某个类的多个实例,在我们的 Record 场景中,可以用它来更新 volatile 域。

复制代码
public class Record {
private static final AtomicLongFieldUpdater<Record> VERSION =
AtomicLongFieldUpdater.newUpdater(Record.class, "version");
private volatile long version = 0;
public long update() {
return VERSION.incrementAndGet(this);
}
}

在对象创建方面,这种方式能够生成更为高效的代码。同时,这个 updater 是一个静态的 final 域,对于任意数量的 record,只需要有一个 updater 就可以了,并且最重要的是,它现在就是可用的。除此之外,它还是一个受支持的公开 API,它始终应该是优选的策略。不过,另一方面,我们看一下 updater 的创建和使用方式,它依然非常丑陋,不是非常易读,坦白说,凭直觉看不出来它是个计数器。

那么,我们能更好一点吗?是的,变量句柄(Variable Handles)(或者简洁地称之为“VarHandles”)目前正处于设计阶段,它提供了一种更有吸引力的API。

VarHandles 是对数据行为(data-behavior)的一种抽象。它们提供了类似 volatile 的访问方式,不仅能够用在域上,还能用于数组或 buffers 中的元素上。

乍看上去,下面的样例可能显得有些诡异,所以我们看一下它是如何实现的。

复制代码
public class Record {
private static final VarHandle VERSION;
static {
try {
VERSION = MethodHandles.lookup().findFieldVarHandle
(Record.class, "version", long.class);
} catch (Exception e) {
throw new Error(e);
}
}
private volatile long version = 0;
public long update() {
return (long) VERSION.addAndGet(this, 1);
}
}

VarHandles 是通过使用 MethodHandles API 创建的,它是到 JVM 内部链接(linkage)行为的直接入口点。我们使用了 MethodHandles-Lookup 方法,将包含域的类、域的名称以及域的类型传递进来,或者也可以说我们对 _java.lang.reflect.Field_ 进行了“反射的反操作(unreflect)”。

那么,你可能会问它为什么会比 AtomicXFieldUpdater API 更好呢?如前所述,VarHandles 是对所有变量类型的通用抽象,包括数组甚至 ByteBuffer。也就是说,我们能够通过它抽象所有不同的类型。在理论上,这听起来非常棒,但是在当前的原型中依然存在一定的不足。对返回值的显式类型转换是必要的,因为编译器还不能自动将类型判断出来。另外,因为这个实现依然处于早期的原型阶段,所以它还有一些其他的怪异之处。随着有更多的人参与 VarHandles,我希望这些问题将来能够消失掉,在 Valhalla 项目中所提议的一些相关的语言增强已经逐渐成形了。

序列化

在当前,另外一个重要的使用场景就是序列化。不管你是在设计分布式系统,还是将序列化的元素存储到数据库中,或者实现非堆的功能,Java 对象都要以某种方式进行快速序列化和反序列化。这方面的座右铭是“越快越好”。因此,很多的序列化框架都会使用 Unsafe::allocateInstance,它在初始化对象的时候,能够避免调用构造器方法,在反序列化的时候,这是很有用的。这样做会节省很多时间并且能够保证安全性,因为对象的状态是通过反序列化过程重建的。

复制代码
public String deserializeString() throws Exception {
char[] chars = readCharsFromStream();
String allocated = (String) UNSAFE.allocateInstance(String.class);
UNSAFE.putObjectVolatile(allocated, VALUE_OFFSET, chars);
return allocated;
}

请注意,即便在 Java 9 中 _sun.misc.Unsafe_ 依然可用,上述的代码片段也可能会出现问题,因为有一项工作是优化 String 的内存占用的。在 Java 9 中将会移除 char[] 值,并将其替换为 byte[]。请参考提升 String 内存效率的 JEP 草案来了解更多细节。

让我们回到这个话题:还没有 _Unsafe::allocateInstance_ 的替代提议,但是 jdk9-dev 邮件列表在讨论解决方案。其中一个想法是将私有类 _sun.reflect.ReflectionFactory::newConstructorForSerialization_ 转移到一个受支持的地方,它能够阻止核心的类以非安全的方式进行初始化。另外一个有趣的提议是冻结数组(frozen array),将来它可能也会对序列化框架提供帮助。

看起来效果可能会如下面的代码片段所示,这完全是按照我的想法所形成的,因为这方面还没有提议,但是它基于目前可用的_sun.reflect.ReflectionFactory_ API。

复制代码
public String deserializeString() throws Exception {
char[] chars = readCharsFromStream().freeze();
ReflectionFactory reflectionFactory =
ReflectionFactory.getReflectionFactory();
Constructor<String> constructor = reflectionFactory
.newConstructorForSerialization(String.class, char[].class);
return constructor.newInstance(chars);
}

这里会调用一个特殊的反序列化构造器,它会接受一个冻结的 char[]。String 默认的构造器会创建传入 char[] 的一个副本,从而防止外部变化的影响。而这个特殊的反序列化构造器则不需要复制这个给定的 char[],因为它是一个冻结的数组。稍后还会讨论冻结数组。再次提醒,这只是我个人的理解,真正的草案看起来可能会有所差别。

内存管理

_sun.misc.Unsafe_ 最重要的用途可能就是读取和写入了,这不仅包括第一节所看到的针对堆空间的操作,它还能对 Java 堆之外的区域进行读取和写入。按照这种说法,就需要原生内存(通过地址 / 指针来体现)了,并且偏移量需要手动计算。例如:

复制代码
public long memory() {
long address = UNSAFE.allocateMemory(8);
UNSAFE.putLong(address, Long.MAX_VALUE);
return UNSAFE.getLong(address);
}

有人可能会跳起来说,同样的事情还可以直接使用 ByteBuffers 来实现:

复制代码
public long memory() {
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(8);
byteBuffer.putLong(0, Long.MAX_VALUE);
return byteBuffer.getLong(0);
}

表面上看,这种方式似乎更有吸引力:不过遗憾的是,ByteBuffer 只能用于大约 2GB 的数据,因为 DirectByteBuffer 只能通过一个 int(ByteBuffer::allocateDirect(int))来创建。另外,ByteBuffer API 的所有索引都是 32 位的。比尔·盖茨不是还说过“谁需要超过 32 位的东西呢?”

使用 long 类型改造这个 API 会破坏兼容性,所以 VarHandles 来拯救我们了。

复制代码
public long memory() {
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(8);
VarHandle bufferView =
MethodHandles.byteBufferViewVarHandle(long[].class, true);
bufferView.set(byteBuffer, 0, Long.MAX_VALUE);
return bufferView.get(byteBuffer, 0);
}

在本例中,VarHandle API 真得更好吗?此时,我们受到相同的限制,只能创建大约 2GB 的 _ByteBuffer_,并且针对 _ByteBuffer_ 视图所创建的内部 VarHandle 实现也是基于 int 的,但是这个问题可能也“可以解决”。所以,就目前来讲,这个问题还没有真正的解决方案。不过这里的 API 是与第一个例子相同的 VarHandle API。

有一些其他的可选方案正处于讨论之中。Oracle 的工程师 Paul Sandoz,他同时还是 JEP 193:Variable Handles 项目的负责人,曾经在 twitter 讨论过内存区域(Memory Region)的概念,尽管这个概念还不清晰,但是这种方式看起来很有前景。一个清晰的API_ 可能_ 看起来会如下面的程序片段所示。

复制代码
public long memory() {
MemoryRegion region = MemoryRegion
.allocateNative("myname", MemoryRegion.UNALIGNED, Long.MAX_VALUE);
VarHandle regionView =
MethodHandles.memoryRegionViewVarHandle(long[].class, true);
regionView.set(region, 0, Long.MAX_VALUE);
return regionView.get(region, 0);
}

这只是一个理念,希望 Panama 项目,也就是 OpenJDK 的原生代码项目,能够为这些抽象提出一项提议,因为这些内存区域也需要用到原生库,在它的调用中会预期传入内存地址(指针)。

互操作性

最后一个话题是互操作性(interoperability)。这并不限于在不同的 JVM 间高效地传递数据(可能会通过共享内存,它可能是某种类型的内存区域,这样能够避免缓慢的 socket 通信),而且还包含与原生代码的通信和信息交换。

Panama 项目致力于取代 JNI,提供一种更加类似于 Java 并高效的方式。关注 JRuby 的人可能会知道 Charles Nutter,这是因为他为 JNR 所作出的贡献,也就是 Java Native Runtime,尤其是 JNR-FFI 实现。FFI 指的是外部函数接口(Foreign Function Interface),对于使用其他语言(如 Ruby、Python 等等)的人来说,这是一个典型的术语。

基本上来讲,FFI 会为调用 C(以及依赖于特定实现的 C++)构建一个抽象层,这样其他的语言就可以直接进行调用了,而不必像在 Java 中那样创建胶水代码。

举例来讲,假设我们希望通过 Java 获取一个 pid,当前所需要的是如下的 C 代码:

复制代码
extern c {
JNIEXPORT int JNICALL
Java_ProcessIdentifier_getProcessId(JNIEnv *, jobject);
}
JNIEXPORT int JNICALL
Java_ProcessIdentifier_getProcessId(JNIEnv *env, jobject thisObj) {
return getpid();
}
public class ProcessIdentifier {
static {
System.loadLibrary("processidentifier");
}
public native void talk();
}

使用 JNR 我们可以将其简化为一个简单的 Java 接口,它会通过 JNR 实现绑定的原生调用上。

复制代码
interface LibC {
void getpid();
}
public int call() {
LibC c = LibraryLoader.create(LibC.class).load("c");
return c.getpid();
}

JNR 内部会将绑定代码织入进去并将其注入到 JVM 中。因为 Charles Nutter 是 JNR 的主要开发者之一,并且他还参与 Panama 项目,所以我们有理由相信会出现一些非常类似的内容。

通过查看 OpenJDK 的邮件列表,我们似乎很快就会拥有 _MethodHandle_ 的另外一种变种形式,它会绑定原生代码。可能出现的绑定代码如下所示:

复制代码
public void call() {
MethodHandle handle = MethodHandles
.findNative(null, "getpid", MethodType.methodType(int.class));
return (int) handle.invokeExact();
}

如果你之前没有见过 MethodHandles 的话,这看起来可能有些怪异,但是它明显要比 JNI 版本更加简洁和具有表现力。这里最棒的一点在于,与反射得到 Method 实例类似,MethodHandle 可以进行缓存(通常也应该这样做),这样就可以多次调用了。我们还可以将原生调用直接内联到 JIT 后的 Java 代码中。

不过,我依然更喜欢 JNR 接口的版本,因为从设计角度来讲它更加简洁。另外,我确信未来能够拥有直接的接口绑定,它是 _MethodHandle_ API 之上非常好的语言抽象——如果规范不提供的话,那么一些热心的开源提交者也会提供。

还有什么呢?

围绕 Valhalla 和 Panama 项目还有其他的一些事宜。有些与 sun.misc.Unsafe 没有直接的关系,但是值得提及一下。

ValueTypes

在这些讨论中,最热门的话题可能就是 ValueTypes 了。它们是轻量级的包装器(wrapper),其行为类似于 Java 的原始类型。顾名思义,JVM 能够将其视为简单的值,可以对其进行特殊的优化,而这些优化是无法应用到正常的对象上的。我们可以将其理解为可由用户定义的原始类型。

复制代码
value class Point {
final int x;
final int y;
}
// Create a Point instance
Point point = makeValue(1, 2);

这依然是一个草案 API,我们不一定会拥有新的“value”关键字,因为这有可能破坏已经使用该关键字作为标识符的用户代码。

即便如此,那 ValueTypes 到底有什么好处呢?如前所述,JVM 能够将这些类型视为原始值,那么就可以将它的结构扁平化到一个数组中:

复制代码
int[] values = new int[2];
int x = values[0];
int y = values[1];

它们还可能被传递到 CPU 寄存器中,很可能不需要分配在堆上。这实际上能够节省很多的指针解引用,而且会为 CPU 提供更好的方案来预先获取数据并进行逻辑分支的预判。

目前,类似的技术已经得到了应用,它用于分析大型数组中的数据。Cliff Click 的 h2o 架构完全就是这么做的,它为统一的原始数据提供了速度极快的 map-reduce 操作。

另外,ValueTypes 还可以具有构造器、方法和泛型。Oracle 的 Java 语言架构师 Brian Goetz 曾经非常形象的这样描述,我们可以将其理解为“编码像类一样,但是行为像 int 一样”。

另外一个相关的特性就是我们所期待的“specialized generics”,或者更加广泛的“类型具体化”。它的理念非常简单:将泛型系统进行扩展,不仅要支持对象和 ValueTypes,还要支持原始类型。无处不在 String 类将会按照这种方式,成为使用 ValueTypes 进行重写的候选者。

Specialized Generics

为了实现这一点(并保持向后兼容),泛型系统需要进行改造,将会引入一些新的特殊的通配符。

复制代码
class Box<any T> {
void set(T element) { … };
T get() { ... };
}
public void generics() {
Box<int> intBox = new Box<>();
intBox.set(1);
int intValue = intBox.get();
Box<String> stringBox = new Box<>();
stringBox.set("hello");
String stringValue = stringBox.get();
Box<RandomClass> box = new Box<>();
box.set(new RandomClass());
RandomClass value = box.get();
}

在本例中,我们所设计的 Box 接口使用了新的通配符 any,而不是大家所熟知的? 通配符。它为 JVM 内部的类型 specializer 提供描述信息,表明能够接受任意的类型,不管是对象、包装器、值类型还是原始类型均可以。

关于类型具体化在今年的 JVM 语言峰会(JVM Language Summit,JVMLS)上有一个很精彩的讨论,这是由 Brian Goetz 本人所做的。

Arrays 2.0

Arrays 2.0 的提议已经有挺长的时间了,关于这方面可以参考 JVMLS 2012 上 John Rose 的演讲。其中最突出的特性将是移除掉当前数组中 32 位索引的限制。在目前的 Java 中,数组的大小不能超过 _Integer.MAX_VALUE_。新的数组预期能够接受 64 位的索引。

另外一个很棒的特性就是“冻结(freeze)”数组(就像我们在上面的序列化样例中所看到的那样),允许我们创建不可变的数组,这样它就可以到处传递而没有内容发生变化的风险。

而且好事成双,我们期望 Arrays 2.0 能够支持 specialized generics!

ClassDynamic

另外一个相关的更有意思的提议被称之为 ClassDynamic 。相对于到现在为止我们所讨论的其他内容,这个提议目前所处的状态可能是最初级的,所以目前并没有太多可用的信息。不过,我们可以提前估计一下它是什么样子的。

动态类引入了与 specialized generics 相同的泛化(generalization)概念,不过它是在一个更广泛的作用域内。它为典型的编码模式提供了模板机制。假设将 _Collections::synchronizedMap_ 返回的集合视为一种模式,在这里每个方法调用都是初始调用的同步版本:

复制代码
R methodName(ARGS) {
synchronized (this) {
underlying.methodName(ARGS);
}
}

借助动态类以及为 specializer 所提供的模式模板(pattern-template)能够极大地简化循环模式(recurring pattern)的实现。如前所述,当编写本文的时候,还没有更多的信息,我希望在不久的将来能够看到更多的后续信息,它可能会是 Valhalla 项目的一部分。

结论

整体而言,对于 JVM 和 Java 语言的发展方向以及它的加速研发,我感到非常开心。很多有意思和必要的解决方案正在进行当中,Java 变得更加现代化,而 JVM 也提供了高效的方案和功能增强。

从我的角度来讲,毫无疑问,我认为大家值得在 JVM 这种优秀的技术上进行投资,我期望所有的 JVM 语言都能够从新添加的集成特性中收益。

我强烈推荐 JVMLS 2015 上的演讲,以了解上述大多数话题的更多信息,另外,我建议读者阅读一下 Brian Goetz 针对 Valhalla 项目的概述。

关于作者

Christoph Engelbert是 Hazelcast 的技术布道师。他对 Java 开发充满热情,是开源软件的资深贡献者,主要关注于性能优化以及 JVM 和垃圾收集的底层原理。通过研究软件的 profiler 并查找代码中的问题,他非常乐意将软件的能力发挥到极限。

查看英文原文: A Post-Apocalyptic sun.misc.Unsafe World

2016-01-25 18:3610583

评论

发布
暂无评论
发现更多内容

RPC框架-dubbo:架构及源码分析-初篇

程序员架构进阶

微服务 dubbo 七日更 28天写作 2月春节不断更

AI窥人(三):你想靠AI实现永生吗?

脑极体

Spring Boot(一):入门篇

海鸥云

spring Boot Starter

LeetCode题解:53. 最大子序和,动态规划,JavaScript,详细注释

Lee Chen

算法 大前端 LeetCode

新手如何靠区块链赚钱?

CECBC

区块链

产品经理问我:手动创建线程不香吗,为什么非要用线程池呢?

Java鱼仔

Java 线程池

在gradle中构建java项目

程序那些事

Java maven Gradle 程序那些事 构建工具

数据库的两个好帮手:pagehack和pg_xlogdump

华为云开发者联盟

数据库 故障 GaussDB pagehack pg_xlogdump

安卓软件开发!Android线程池基础入门和简单实践以及使用技巧,面试真题解析

欢喜学安卓

android 程序员 面试 移动开发

构建万物可信互联的基石,带你深度剖析区块链跨链的关键技术,满满是干货!

华为云开发者联盟

区块链 智能合约 云原生 跨链技术 分布式账本技术

区块链在医疗领域应用所要面临哪些挑战

CECBC

区块链 医疗

实习记录:PB协议编写

YUKI0506

都在说云原生,它的技术图谱你真的了解吗?

云原生

工作日志2-19

技术骨干

滴滴内部分享:如何提高代码的可读性,学习笔记

Java架构师迁哥

为什么 Python 的 f-string 可以连接字符串与数字?

Python猫

Python 开源 编程语言 后端 C语言

SICP 习题答案1.1 - 1.5

十元

程序员如何技术划水,Android项目开发如何设计整体架构?Android岗

欢喜学安卓

android 程序员 面试 移动开发

Kafka.02 - Topic 介绍

insight

kafka 2月春节不断更

【得物技术】Keep-alive 原理及业务解决方案

得物技术

大前端 标签 页面 得物技术 keepalive

区块链产品走向普及之不完全指南

CECBC

比特币 区块链

ZEGO全新语音聊天解决方案,4步搭建爆火的语音聊天室

ZEGO即构

《TestNG》源码学习笔记

吴大山

熟练HTML5+CSS3,每天复习一遍

我是哪吒

面试 大前端 28天写作 2月春节不断更

go get下载包失败问题

happlyfox

Proxy 28天写作 Go 语言

场景化面试:在读多写少的情况下,如何优化 MySQL 的数据查询方案

面试官问

MySQL 数据库 面试 主从同步 读写分离

week12 作业

zbest

日记 2021年2月20日(周六)

Changing Lin

2月春节不断更

Linux 多线程详解 —— 什么是线程

赖猫

Linux 线程 Linux内核

我要看 SICP 了!

十元

大厂面试:求解集装箱港口翻箱问题的最短路径

华为云开发者联盟

算法 路径 模型

sun.misc.Unsafe的后启示录_Java_Christoph Engelbert_InfoQ精选文章