文档结构  
可译网翻译有奖活动正在进行中,查看详情 现在前往 注册?
原作者:未知 (2016-11-07)    来源:SitePoint [英文]
CY2    计算机    2016-11-10    0评/878阅
翻译进度:已翻译   参与翻译: KeYIKeYI (18), dahakawang (9)

Java 9在模块化之外提供了许多新功能:新的语言特性,众多新的改进过的API,GNU风格的命令行参数,多版本JAR包,优化的日志机制等等。让我们来看看这个“等等”,看看性能方面的提升。多亏了字符串技术,编译器,垃圾回收技术以及JavaDoc。

作者其他的文章

性能改进

随着版本的更新,Java的性能变得越来越好,Java 9亦不例外。Java 9中有几个有趣的改进,旨在减少CPU负载以及节省内存。

第 1 段(可获 1.25 积分)

紧凑字符串

当我们查看Java应用程序的对象堆,刨去我们赖以组织程序状态的对象头以及指针,就只剩下原始数据了。这些原始数据包含了那些东西?当然是原始数据类型——大部分都是char类型,这些数据聚集在字符数组中,用以实现一个String对象实例。事实证明这些数组平均占据了应用程序所有数据(包含对象头和指针)的20%至30%左右。对大部分的Java程序来说,在这个领域的任何一个改进都将有巨大的回报。并且,这里确实有改进的空间。

第 2 段(可获 1.24 积分)

因为char能表示整个UTF-16编码,一个char会占用了两个字节,但事实证明绝大多数的字符串仅仅需要ISO-8859-1,这个编码仅需要一个字节。浪费太大了!在新的字符串实现机制中,我们尽可能只用一个字节,字符串带来的内存使用量将降低将近一半。这将为应用程序平均降低10%至15%的内存用量,并且垃圾回收也更加省时,从而减少整体运行时间。

当然上面的断言只有在没有引入其他开销的前提下才成立。对每个人都是免费的午餐么?JEP 254试着做到这一点....

第 3 段(可获 1.24 积分)

实现

在Java 8里,String类有一个成员char[] value —— 这是我们刚才提到过的用于保存字符串的字符的数组。我们用一个byte数组,并且根据字符编码的要求来为某个字符分配一个或者两个字节。

这听起来像是类似于UTF-8一样的变长编码技术,在这个技术中,我们根据不同的字符决定其占用一个还是两个字节。但是这将让我们不能预测一个字符将占据哪几个数组位置,因此下标访问(例charAt(int) )需要进行线性查找。将下标访问由常数时间变为线性时间是不可接受的开倒车行为。

第 4 段(可获 1.35 积分)

因此,如果每个字符都能用一个字节表示,那么我们就选择单字节编码方案,而若字符串中有一个字符需要两个字节,则整个字符串用双字节编码。一个新的成员coder将用于表示字符是怎样编码的,并指导String对象中各种成员方法选择一个正确的代码段来处理该编码下的字符。

在Java 8中,当一个新的String对象被构造时,往往会新建一个char数组并从构造函数的参数中拷贝数值。例如,当new String(myChars)被调用时,Arrays.copyOf被用来将myChars的拷贝赋给成员变量value。之所以这样做是为了避免和用户的代码共享同一个数组。仅有有限的几种情形下Java不会拷贝数组,例如一个String对象由另一个String创建而来。所以由于value数组从来没有和String之外的代码共享过,将其重构为一个byte数组是安全的(封装真棒!)。又因为构造函数参数本来就是拷贝的,同时对它进行转换不会引入过大的开销。

第 5 段(可获 2.03 积分)

下面是该技术实现的示意代码:

// 这是String构造函数的一个简化版本,
// 其参数是`char[] value`
if (COMPACT_STRINGS) {
    byte[] val = StringUTF16.compress(value);
    if (val != null) {
        this.value = val;
        this.coder = LATIN1;
        return;
    }
}
this.coder = UTF16;
this.value = StringUTF16.toBytes(value);

这里有几件事需要指出:

  • 布尔变量COMPACT_STRINGS是由命令行参数XX:-CompactStrings定义的,并且也能用该参数禁用掉
  • 工具类StringUTF16被首先用于尝试将value数组压缩为单字节,若该操作失败并返回null,则转换为双字节编码
  • coder成员变量则赋值为相应的编码器,标记是哪种情况
第 6 段(可获 0.93 积分)

如果你觉得这个话题有趣而你看到现在也还没睡着,我十分推荐看看Aleksey Shipilev关于紧凑字符串的讲座

为什么这些[哔~][哔~][哔~]没能在一个月内实现这些特性,而是花了一年?!

性能

在我们真正开始谈到性能之前,我们需要观察一个非常漂亮的小细节。JVM会将内存中的对象对其到8个字节,这意味着大小不是8字节整数倍的对象,其余的空间都被浪费了。在JVM最普遍的配置中,一个64位的VM提供压缩引用,一个String需要20个字节(12字节是对象头,4字节是value数组,然后最后的4个字节则是缓存下来的哈希值)—— 刚好留下4个字节能够让coder成员挤进去而不会增加内存用量。很好。

第 7 段(可获 1.81 积分)

压缩字符串总体上说来是一种内存优化所以我们需要观察垃圾回收器。理解G1的日志不是本文的主题,所以我仅会关注在运行时的性能。这里的道理在于如果String需要更少的内存,创建它们则会更快。

为了测量性能,我运行了以下代码:

long launchTime = System.currentTimeMillis();
List<String> strings = IntStream.rangeClosed(1, 10_000_000)
        .mapToObj(Integer::toString)
        .collect(toList());
long runTime = System.currentTimeMillis() - launchTime;
System.out.println("Generated " + strings.size() + " strings in " + runTime + " ms.");

launchTime = System.currentTimeMillis();
String appended = strings.stream()
        .limit(100_000)
        .reduce("", (left, right) -> left + right);
runTime = System.currentTimeMillis() - launchTime;
System.out.println("Created string of length " + appended.length() + " in " + runTime + " ms.");
第 8 段(可获 0.78 积分)

一开始我们创建了一个有一千万个字符串的列表,尔后我们将前十万通过一个十分拙劣的方法连接起来。然后启用紧凑字符串(Java 9默认行为)来运行这段代码,并禁用后(用 -XX:-CompactStrings)再运行。我观察到了巨大的差异:

# 启用紧凑字符串
Generated 10000000 strings in 1044 ms.
Created string of length 488895 in 3244 ms.
# 禁用紧凑字符串
Generated 10000000 strings in 1075 ms.
Created string of length 488895 in 7005 ms.

无论何时有人像这里一样聊到了微基准测试,如果他们没有使用JMH你应该立马不相信他们。但这里我不想遇到用Java 9运行JMH可能遭遇的麻烦,所以我选择了一个简单的方式。这意味着这里的结果可能整个就不靠谱,这是因为某些JVM的优化或者其他一些原因。故而不要过于相信这里的结果,仅仅将它看做某种昭示而不是性能真正提升的证据。

 

第 9 段(可获 1.69 积分)

但你不一定要相信我.在上面链接的演讲视频中,Aleksye展示了他的测量,从(视频的)36:30开始,证实是1.36倍的吞吐量提升并减少了45%的垃圾回收.

识别字符串连接

快速复现字符串连接是如何工作的...假设你写了下面的代码:

String s = greeting + ", "  + place + "!"

然后编译器会生成使用StringBuilder创建s(通过先添加分散的字符串部分然后调用toString 获取结果)的字节码.在运行时,JIT编译器可能会识别到这些添加链,如果它真的识别到了,那么它可以大大提高程序性能.JIT编译器将会生成检查参数长度,创建正确大小的数组,将字符直接复制到这个数组以及包装成字符串的代码.

 

第 10 段(可获 1.48 积分)

了解这些并不能让我们得到更好的结果但认识到这些添加链和证明它们可以被优化后的代码替代是有意义的,也便于我们快速进行分析.显然当你在连接中所有需要的仅仅是long或者double时,JIT编译器不能进行优化.

然而为什么要做这么多事情?为什么不直接提供一个字节码调用的String.concat(String... args)方法?因为在性能关键路径上创建一个可变参数的数组不是一个好主意.另外,除非你事先把所有toString(相应地这会阻止它们直接字符串化到目标数组),否则基本类型也不能很好与这个方案相配.而且你必须考虑到String.concat(Object... args会对每一个基本类型进行自动装箱.

第 11 段(可获 1.44 积分)

所以我们需要另一个解决方案以期得到更好的性能。另一个Java9中最好的改变是使得javac产生更好的字节码,不过也有缺陷:

  • 每次实现一个新的优化都必须再次改变字节码。
  • 为了使用户能得益于这些优化,用户不得不重新编译他们的代码-如果有可能,Java一般都会避免这种做法。
  • 由于所有的JVM能即时编译各种变种,测试矩阵会爆炸。

那么我们还有什么可以做的呢?或许,这儿漏掉了一层抽象?字节码难道不能只声明“连接这些事物”的意图然后交给JVM来处理剩下的事情?

第 12 段(可获 1.28 积分)

是的,这差不多就是JEP 280使用的解决方案——至少有前面讨论的部分。多亏了invokedynamic的魔力,字节码可以表示出意图和参数(不需要对基本类型自动装箱)而JVM也不必提供那个功能并可以将该功能重新放到JDK中实现。这非常棒,因为在JDK中各种各样的私有API可以被用于各种技巧(javac只能使用公开的API)。

我再次推荐你去参考Aleksey的演讲(视频)——第二部分的一半,从37:58开始,包含了我们刚讨论的部分。这个视频还提到了一些数字,这些数字展示了2.6倍的速度提升和减少高达70%的垃圾——而且这还是在没有压缩字符串的情况下!

第 13 段(可获 1.51 积分)

又一个好坏混合的改进

Java 9还有另一个与字符串相关的改进但是我不是很懂这个改进。从我对它的理解来看,不同的JVM进程可以通过类数据共享(CDS)存档共享加载的类。在这些存档中,类数据中(更准确地说,是在常量池中)的字符串以UTF-8字符串的形式表示并按需转化为String 实例。我们可以通过在不同JVM间共享实例而不是总是创建新的实例来减少内存占用。要使垃圾回收器能与这个机制共同协作,则要提供一个名叫固定区域的特性,目前只有G1有这个特性。我的理解似乎与JEP的标题在CDS存档中存储内部字符串(Store Interned Strings in CDS Archives)冲突,所以如果你对这个改进有兴趣,你可以自己来看看JEP 250

 

 

 

第 14 段(可获 1.59 积分)

Java并发的基本构件是监视器——每个对象有一个监视器并且监视器在同一时刻只能被最多一个线程拥有。一个线程要获得某个对象监视器的所有权,那么它必须调用那个对象声明的synchronized 方法或进入以那个对象进行同步的synchronized 块。如果多个线程同时尝试获取监视器所有权,除一个线程以后的其他所有线程将被放在一个wait 集合,该情况下的监视器被称作线程争用——线程争用会引起性能瓶颈。一方面,应用自己浪费时间等待,除此之外,JVM必须做一些组织锁争用(lock contention)的工作并在一旦监视器可再次获得时选择一个新的线程。JVM的这种组织在高争用代码中是可以改进的,以提高性能。JEP 143)

第 15 段(可获 1.7 积分)

在Java 2D中,所有的抗锯齿(除了字体)都是通过所谓的光栅器进行的.这是一个对Java开发者没有开放可访问的API的内部子系统.但这个子系统依赖于热路径,其性能对很多图形敏感的应用至关重要.OpenJDK使用Pisces,Oracle JDK使用Ductus,前者比后者表现出的性能差很多.Pisces现在要替换为许诺在质量和精度同事具有优越性能的Marlin graphics renderer.很有可能Marlin在质量,精度和单线程性能方面与Dustus相当,在多线程情景下甚至超过它.(JEP 265, 一些历史和情景 )

第 16 段(可获 1.4 积分)

非官方证据表明运行一个激活了安全管理器的应用会降低10%到15%的性能.我们努力通过各种小优化去减小这个缺口.(JEP 232)

最近SPARC和Intel的CPU引入了非常适合加解密操作的指令.它们被用于提高GHASH和RSA计算的性能. (JEP 246)

car-repair-inside-java-9-performance-compiler

垃圾回收

一个Java 9中仅次于模块化项目Jigsaw最有争议的改变是Garbage First (G1)将成为新的默认垃圾收集器(JEP 248). 对我来说幸运的是,从Java 8它就是可用于生产环境的,尽管这个改变很大,所以我现在也不一定非得真正讨论它.

第 17 段(可获 1.36 积分)

快速总结:G1限制了停顿时间并为了达到这个目标放弃了部分吞吐量.在实现上它很聪明——它没有将堆分为像 Eden、youngold一样的连续区域而是分为许多固定大小的区域。在G1开始使用某区域的时候,它会给这个区域赋一个角色,而一旦当它回收了这个区域的整个内容时,它又会重置这角色。谈到回收,回收会根据名字重点关注那些垃圾最多的区域,因为这种方式有很可能带来最少的工作。

G1是一头有趣的野兽,我建议你花点时间研究它。如果你不想自己去研究,请持续关注这个频道,因为这个频道很快会讨论G1.一个我发现的有趣的细节是字符串重复数据删除(在8u20版本中通过JEP 192引入),在重复数据删除中,G1会识别出那些有相同值数组的字符串实例然后让它们共享相同的数组实例.显然,重复的字符串非常普遍,这项优化将节省大约10%的堆空间——不过这是对于没有压缩字符串的情况,所以实际可能接近5%。

第 18 段(可获 2.18 积分)

最后,JEP 214去掉了一些JEP 173声明过期的GC选项。

编译器

编译为先前版本的Java

你曾使用过-source和-target 选项来编译你的代码使得你的代码可以在老的JRE上运行,结果却因为一些方法调用由于令人费解的错误失败而只看到运行时崩溃?一个可能的原因是你忘记了指定 -bootclasspath。因为没有这个选项,编译器会链接当前版本的核心库API,这就使得编译后的字节码与老版本不兼容。为了修复这个普遍的操作错误,Java 9的编译器带来了--release标志,该标志会将上述所有其他三个选择设置为正确的值。

第 19 段(可获 1.36 积分)

JVM编译器接口

我对这个非常感兴趣!JEP 243产生了Java虚拟机编译器接口(JVMCI)-一些列Java接口,JVM可以使用这些接口的实现进行即时编译,并由此替代C1/C2编译器.这仍然是一个实验性的特性,必须显示在命令行中激活.但它的发展轨迹是清晰的:得到一个用Java实现的JIT编译器.一个很有可能的候选实现是  由raal project开发并已经实现了JVMCI的那个.

假设你要问自己"然而我们为什么要这么做?",下面是JEP关于这个疑问述说的答案:

第 20 段(可获 1.3 积分)

优化编译器是一个复杂的软件,它可以从Java提供的诸如自动内存管理,异常处理,同步控制,优秀(并且免费)的IDEs,优秀的单元测试支持及借助服务加载器带来的运行时可扩展性等特性中得到巨大的帮助(这里仅仅举了部分特性).此外,编译器真的不需要很多其他JVM子系统(例如字节码翻译器和垃圾收集器)需要的底层语言特性.这些观察结果有力的表明用Java写JVM编译器可以给产品带来高质量的编译器----它非常容易维护并且改进了现存的用C或C++开发的编译器.

 

第 21 段(可获 1.31 积分)

言之有理,对吧?

预编译

Java是这样的语言“一次编写, 到处运行”,这很棒,但如果你不愿意为此付出代价呢?如果你想启动JVM只调用一个方法(有人说serverless?)?那么JIT不会带给你多少帮助-为了得到最佳性能,你甚至需要在运行前得到机器码.

来试试预编译吧(JEP 295)!通过它,你能够使用Graal编译器搭载你本地的JDK来编译你将要使用的代码,然后告诉java 使用这些构件代替到处使用的字节码.下面是来自JEP的一些编译用户代码和需要的JDK模块并使用它们运行Java的小片段:

 

 

第 22 段(可获 1.64 积分)
jaotc --output libHelloWorld.so HelloWorld.class
jaotc --output libjava.base.so --module java.base
java9 -XX:AOTLibrary=./libHelloWorld.so,./libjava.base.so HelloWorld

这带来了一系列问题:

  • 如果模块的字节码和机器码的版本不一致会怎样?
  • 如果运行时对比编译的代码以不同的VM参数运行会怎样?(例如,压缩对象指针.)
  • 编译的代码是否应该收集分析信息来做深层优化?

显然,这些及其他问题已经被解决,JEP是一个很好的答案来源

第 23 段(可获 0.86 积分)

与JVMCI非常相似,这显然是一个实验性特性(我甚至在最新的构建中都找不到它),并且它还有严重的局限性-最突出的是它只能在64位Linux系统上工作并且只能够编译java.base模块.但是它仍然是Java正在涉猎的有意思的方向.

内在改进

编译器也得到了一定的性能提升.在某些场景下(例如嵌套的lambda)类型推导将会产生指数级运行时-非常不好.Tiered attribution解决了这个问题.(JEP 215, 2分钟视频概览)

由于注解管道是为Java创建的,它必须扩展多次才能满足新特性----像重复注解,类型注解,及由lambda表达式带来的新的语法有效位置. “[它]不能开箱即用地处理这些情况;结果是,原来的设计为了满足新的用例被拉伸,导致一个脆弱而难以维护的实现.”Java 9进行了完全重新设计,创建并实现了注解管道2.0.它没有增加任何特性,只是为以后的扩展提供了更好的基础.(JEP 217, Annotations Pipeline 2.0 Project)

 

第 24 段(可获 2.45 积分)

接下来的改进还有启用可控运行时方法独立的编译器标志,然而我完全不明白这些是什么.(JEP 165)

最后,JEP 237将JDK 9针对Linux/AArch64的端口整合进了OpenJDK.

Java文档注释

你看过Java9的临时文档注释(Java 9’s provisional Javadoc)么?你发现了任何新的东西么?如果没有,马上到这儿,定位到右上角的文本框并开始打出一个JDK类的名字.非常整洁,是吧?(JEP 225)

Javadoc现在可以生成拥有完全的带有“诸如header, footer, nav等等结构化元素”的HTML5页面并改进了可用性(感谢ARIA.).(JEP 224)

第 25 段(可获 1.29 积分)

最好的放到最后(至少对于JavaDoc如此),我想以新的Doclet API作为结束.Doclet是你自己创建来处理你的Java文档注释的JavaDoc插件(你会给你的代码注释,是吧?).以前的API非常难懂并带有很多容易室内犯错的有趣的特性,你必须创建有合适名字的静态方法以便于工具能在你的插件中调用它们.(那是在接口之前还是怎么的?)新的API消除了那种疯狂.新的API还赋予你访问Language Model APIDocTree API的能力以便于你浏览源代码并创建输出.(JEP 224)

第 26 段(可获 1.39 积分)

结束!

现在让我们来结束这次的Java 9之旅.除了一些小的方面,下面三篇文章展示了Java 9带来的所有一切:

然而到目前为止,我们仅仅触及到Java 9的皮毛----我们会通过下个月的课程发布更多关于Java 9的信息,并深入探究各个主题.所以请以各种方式关注我们,例如通过RSS,或订阅我们的报纸.

第 27 段(可获 1.33 积分)

文章评论