一文了解sun.misc.Unsafe

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

posted @ 2018-03-03 09:58  伴我前行  阅读(1076)  评论(0编辑  收藏  举报