sun.misc.Unsafe 的后启示录

Java after the Unsafe dust settles.

阅读数:8948 2016 年 1 月 25 日

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

收藏

评论

微博

发表评论

注册/登录 InfoQ 发表评论