Java 9 内部探索——版本架构,多版本 jar 包及其他
#2016年 OSC 北京源创会年终盛典#
OSC 协作翻译
英文原文:
Inside Java 9 – Version Schema, Multi-Release JARs, and More
译者:边城, leoxu, Tony, 无若, xufuji456, wnull, debugging, Robbie_Zhu
JShell
因为已经有很多人在谈论 Jigsaw,因此在第一部分我们先跳过不去讲它。在这一部分我们将会照本宣科地拿 JShell 做些事情, 这是 Java 的一个全新的 REPL (说到它能做的事情,例如你在一个地方敲入了 Java 代码,有了它就可以马上把代码的运行结果计算出来)。如果你还并不(特别地)了解这个东西但又感觉有点兴趣的话,可以看看 Robert Field 在去年的 Devoxx Belgium 上提供的这份不错的介绍https://www.youtube.com/watch?v=jziVaht480w。(JEP 222 :http://openjdk.java.net/jeps/222)
新的版本字符串
让我们先来个简单的入门介绍: 版本名称。
我尝试过去理解Java的版本命名模式,直到这样做的时候才觉得值得去深究一番。它是从 1.0 和 1.1 版本的 JDK 开始的 – 这俩版本还算是那么回事儿,但这俩版本以后就越来越不那么像话了。版本 1.2 到 1.5 对商标进行了重命名,如 Java 2 这样的,改变比较明显(还记得 J2SE 吗? 其实指的就是 2 这个版本)。到了 JDK 1.5 就很明显可以看出上述的命令模式没有真正起作用,因此Sun就开始将它叫做 Java 5了。围绕 Java 6,整个自 Java 2 开始的命名创意被悄无声息的埋没了,不过这样反而更让人明白——我们简单的叫它“Java X”就可以了。 (你是否知道 Java 版本,包含 Java 7 其实都有一个像 Tiger 和 Mustang 这样很酷的工程名字?)
JVM 所报告的版本字符串并没有做出修改——它们总会是 1.x.... 这样的形式,不过现在有了 JEP 223, 版本字串和命令模式做了对齐。如果检查相关的系统属性(见这里的demo), 输出会是下面这样的内容:
java.version: 9-ea
java.runtime.version: 9-ea+138-jigsaw-nightly-h5561-20161003
java.vm.version: 9-ea+138-jigsaw-nightly-h5561-20161003
java.specification.version: 9
java.vm.specification.version: 9
这并非过分显示的信息,因为它是跑在一个早期可访问的构建本上的。在将来 java.version 会报告像 9.1.2 这样的字符串, 所遵循的是 $MAJOR.$MINOR.$SECURITY 模式:
● $MAJOR 所标识的是 Oracle 计划每两到三年发布的主版本号。
● $MINOR 所标识的是针对Bug修复以及一些其它需要定期跟进的更小一点的版本号——当主版本号发布时,这个标识就会被重置为零。
● $SECURITY 则相当有意思 – 这个标识在每次发布中“含有包括为提升安全性而进行的重要修复”时就会要用到,并且在 $MINOR 变大的时候它并不会被重置。
现在我们已经没必要对这些字符串进行转换了,因为有了 Version , 这是一个能为我们做这些事情的小类,很不错。
Version version = Runtime.version();
System.out.println("Reported by runtime: " + version);
switch (version.major()) {
case 9:
System.out.println("Modularity!");
break;
case 10:
System.out.println("Value Types!");
break;
GNU 风格的命令行选项
当涉及到命令选项的语法时,Java 的各种工具就有点太过于形式各异了:
● 有些会在长版本选项前面使用一个短横线(-classpath),其它则会使用两个(--gzip)
● 有些用短横线分隔单词(--no-gzip),其它则没有这样做(-classpath)
● 有些是单个字母形式的(-d), 其它则是两个字母形式的(-cp, 真不知道那样搞是为了啥?!)
● 有些用等号赋值(-D<name>=<value>), 其它则需要一个空格(我可不会这样做…)
相比之下,在 Linux 以及其它基于 GNU 的系统上几乎所有工具的选项都使用的是相同的语法:
● 长版本选项前面都是使用两个短横线
● 单词都用短横线分隔
● 选项缩写都会使用一个短横线并且只会包含一个字母
在一次不顾一切的鲁莽行动中,Java 9 快刀斩乱麻,修改了所有的命令行选项以匹配上述这些规则!好吧,这个只是跟你开个玩笑… 不过 JEP 293 已经确定了一个准则来反映和适配这些规则,并且新的选项预期也会遵循这个准则。老的选项可能会在某些时候向这种更加干净的语法迁移,但那并不是 Java 9 开发工作的一部分。JEP 包含了许多详细内容以及示例 – 值得一读。
扩展和更新
有很多 JEP(Java Expression Parser,Java表达式分析器)会随 Java 9 发布,它们会带来对已存在功能的提高扩展和更新。下面介绍它们,以主题为序,有些总结,有些细节。
Unicode
Java 自身可以用 UTF-16 (你的代码里可以用 emoji)[译者注:emoji 是 Unicode 的表情符号]来写代码,属性文件则必须使用 ISO-8859-1。来看看,如果有一个像这样的 config.properties 文件:
money = € / \u20AC
在 Java 8 中访问这个文件:
money = â▯¬ / €
JEP 226 终结了那个时代,不再需要进行 Unicode 转义。Java 9 中同样的代码访问这个文件会得到我们期望的结果:
money = € / €
(有一个 完整示例,不过我们的代码高亮不太好用。)
值得注意的是,我们有很多种方法用于访问属性文件,但是只有通过 PropertyResourceBundle 访问的这种方法被更新了。JavaDoc 中的 API 文档说明了如何精确的检测编码以及如何对其进行配置。默认配置是明智的,虽然它只是让 API 在一般情况下 “可以工作”:
try (InputStream propertyFile = new FileInputStream("config.properties")) {
PropertyResourceBundle properties = new PropertyResourceBundle(propertyFile);
properties.getKeys().asIterator().forEachRemaining(key -> {
String value = properties.getString(key);
System.out.println(key + " = " + value);
});} catch (IOException e) {
e.printStackTrace();}
在 示例 中你可以找到使用了 Properties API 的代码。如果你想在 Java 8 中运行它来进行比较,你会发现 Java 9 的 API 中有一个漂亮的小修改。这个小修改是为仍然在使用古老的 Enumeration 的可怜开发者而做。
在其它 Unicode 相关的新闻里,Java 9 支付了 Unicode 8.0。耶!(JEP 227, JEP 267)
图形图像
图像 I/O 框架(在 javax.imageio 包中)现在支持 TIFF 了。Java 高级图像处理(Java Advanced Imaging,JAI) 项目 实现了对 TIFF 的读写。它已经完成标准化并移到 javax.imageio.plugins.tiff 包中。(JEP 262)
视网膜 HiDPI 屏幕为桌面 UI 带来特有的挑战。Java 已经在 Mac 上处理好了这个问题,现在紧跟着开始适配 Linux 和 Windows。“窗口和 GUI 组件会基于平台推荐而拥有一个合适的大小,在任何 HiDPI 设置下,默认的绽放都应该让文本应保持高清,图标和图像在合适的显示密度上应该光滑并尽可能地展现细节。” (JEP 263)
Linux 上 Java 桌面的三剑客(AWT、Swing 和 JavaFX)现在可以使用 GTK 3。一开始JVM 会默认使用 GTK 2,只能通过 jdk.gtk.version 来配置使用 GTK 3。“交互性”需要 GTK 3,这个需求很早就能被探测到”。 (JEP 283)
JavaFX 使用过时的 GStreamer 版本,目前更新到了 1.4.4。新版本会提供 JavaFX 在播放媒体时的稳定性和性能。(JEP 257)
HarfBuzz 是新的 OpenType 布局引擎,取代了已被停用的 ICU。(JEP 258)
安全性
SHA-3 哈希算法已经实现了 SHA3-224、SHA3-256、SHA3-384 和 SHA3-512。可以通过 MessageDigestAPI 使用它们。(JEP 287)
在使用 SecureRandom(在任何 Java 版本中)时,你可以获取操作系统的原生实现,或者 纯 Java 实现的版本。后者是“旧的基于 SHA-1 的 RNG 实现,它的健壮性不如被认可的 DRBG [Deterministic Random Bit Generator] 机制。”既然是旧的,特别是嵌入式系统,它依赖于 Java 变化,它的安全性随 NIST 800-90Ar1 所描述的 DRBG 机制得到了提升。SecureRandom API 已经被改进,可以传递参数以使用 DRBG 和将来的算法。(JEP 273):
Instantiation instantiation = DrbgParameters.instantiation(128, RESEED_ONLY, null);
SecureRandom random = SecureRandom.getInstance("DRBG", instantiation);
byte[] bytes = new byte[20];
random.nextBytes(bytes);
新的 Java 虚拟机特性
机器可以代替我们工作——我想知道是否有一天会实现?它会获得一些新的特性,并被人们所喜爱,但一旦它在我们生活中出现,我们可能又要花上好几年去适应它。如果不能实现的话,我现在先介绍另一个“新机器”—— insect 机器。
包含多个发行版的 JAR 包
可能你有时候会想为不同的 Java 运行版本写代码——在 Java 8 上做一些事情,在 Java 9 上做另外一些事情。到现在为止,这处理起来都很棘手,不过 Java 9 解决了这个问题最重要的部分。现在所有不同的 Java 平台都能创建,也能认识包多个发行版的 JAR 包,它会包含同一类型的不同版本,这些版本对应不同的 Java 版本。
来看个示例:
1、先创建三个类:Main、VersionDependent用于 Java 8, 还有 VersionDependentfor 用于 Java 9。后续的代码提供一个用于打印的结果,内容是 “Java X version”,其中 X 是 8 或 9。
2、然后编译 Main 和 VersionDependent(用于 Java 8 的),结果放在目录 out-mr/java8 上;编译VersionDependent(Java 9 的版本) 放在 out-mr/java9 上。
3、现在开始有趣了,来看看怎么打包。下面的命令会创建一个 mr.jar,包含两个 VersionDependent.class (分别来源于 out-mr/java-x 目录)。因为结构明确,所以 java 能选择正确的类:
jar9 --create --file out-mr/mr.jar -C out-mr/java-8 . \ --release 9 -C out-mr/java-9 .
4、确实,使用 Java 8 运行 java -cp out-mr/mr.jar ...Main 会输出 “Java 8 version” 而 使用 Java 9 运行会输出 “Java 9 version”。
JAR 内部结构看起来像这样:
└ org
└ codefx ... (moar folders)
├ Main.class
└ VersionDependent.class
└ META-INF
└ versions
└ 9
└ org
└ codefx ... (moar folders)
└ VersionDependent.class
Java 8 或更早的版本会在直接使用 org 下面的类,但新版本会使在 META-INFO/versions 子目录中去找合适的内容来代替默认的。干净利落。
统一的日志
调试 JVM,可能要找到应用崩溃或者性能低下的原因,这已经够复杂了。而不同的日志系统有着完全不同的选项,这并不能让事件变得简单。幸好有即将通过的 JEP 158 和 271,它带来了新的命令行参数 -Xlog(现在不应该是 --log?)可以用来定义极尽详细的日志级别。这里有一些可用的设置:
● 每个消息可以有大量的标签,这依赖于创建和使用它的子系统,如 aregc、模块、oros等。可以选中单独的标签并对其应用其它设置。
●当然消息有等级 (error,warning,info,debug,trace,develop) 而且可以按等级进行过滤。
●可以为日志文件转换设置输出到 stdout、stderr 或者文件。
●然后还有 装饰 – 附加在消息上的有用信息(pid、uptime、…、技术标签和等级都是装饰)。它们都可以被输出。
这一些都可以由单独的 -Xlog 选项完成。让我们从简单的记录几个标签开始:
java9 -cp out-mr/mr.jar -Xlog:os,modules,gc ...Main
[0.002s][info][os] SafePoint Polling address: 0x00007feea4c96000
[0.002s][info][os] Memory Serialize Page address: 0x00007feea4c94000
[0.002s][info][os] HotSpot is running with glibc 2.22, NPTL 2.22
[0.009s][info][gc] Using G1
Java 9 version
咦,难道没有模块信息?让我们把那个标签改为 debug(如果没有指定等级,默认是 info):
java9 -cp out-mr/mr.jar -Xlog:os,modules=debug,gc org.codefx.demo.java9.internal.multi_release.Main
[0.002s][info][os] SafePoint Polling address: 0x00007f3054a22000
[0.002s][info][os] Memory Serialize Page address: 0x00007f3054a20000
[0.002s][info][os] HotSpot is running with glibc 2.22, NPTL 2.22
[0.009s][info][gc] Using G1
[0.059s][debug][modules] set_bootloader_unnamed_module(): recording unnamed module for boot loader
[0.063s][debug][modules] define_javabase_module(): Definition of module: java.base, version: 9-ea, location: jrt:/java.base, package #: 159
[... snip ... many, many more module messages ... ]
太多了,不过我们可以看到这样:
[0.079s][info][modules,startuptime] Phase2 initialization, 0.0366552 secs
嘿,这是 info!为什么之前它没有显示出来?!Hey, that’sinfo! Why did it not show up before?! 令人惊异的是它有两个标签,在命令中只匹配其中一个标签是不够的——必须匹配所有标签。我们可以通过 modules+startuptime 或者使用通配符来扩展匹配:
java9 -cp out-mr/mr.jar -Xlog:os,modules*,gc* ...Main
[0.002s][info][os] SafePoint Polling address: 0x00007f9c7f307000
[0.002s][info][os] Memory Serialize Page address: 0x00007f9c7f305000
[0.003s][info][os] HotSpot is running with glibc 2.22, NPTL 2.22
[0.007s][info][gc,heap] Heap region size: 1M
[0.009s][info][gc ] Using G1
[0.009s][info][gc,heap,coops] Heap address: 0x00000006c6200000, size: 3998 MB, Compressed Oops mode: Zero based, Oop shift amount: 3
[0.077s][info][modules,startuptime] Phase2 initialization, 0.0367418 secs
Java 9 version
[0.090s][info][gc,heap,exit ] Heap
[0.090s][info][gc,heap,exit ] garbage-first heap total 256000K, used 2048K [0x00000006c6200000, 0x00000006c63007d0, 0x00000007c0000000)
[0.090s][info][gc,heap,exit ] region size 1024K, 3 young (3072K), 0 survivors (0K)
[0.090s][info][gc,heap,exit ] Metaspace used 4225K, capacity 4532K, committed 4864K, reserved 1056768K
[0.090s][info][gc,heap,exit ] class space used 414K, capacity 428K, committed 512K, reserved 1048576K
瞧,即使是垃圾收集器也有信息显示出来——在这个示例中,是关于退出和堆的。
这些都只是表面上的——还有更多选项可用于调整。JEP 很好的解释了它,还包含示例。
事实上,这个改进并不能解决一切问题。因为 JEP 关注于通过它提供基础设施和改变一些(很多?那一定是所有 GC 消息)已经存在的调用,而不是所有事情。虽然我找不到其它使用新机制的日志,但它可能就在那里。
命令行参数验证
分享一件有趣的事情:Java 8 虚拟机要在使用到命令行参数的值时才会验证它们。不幸的是,这在应用程序的生命周期中可能会显得有些迟了,我可以预见随着潜在的错误而来的崩溃。来看一个例子:
java -cp out-mr/mr.jar -XX:BlockLayoutMinDiamondPercentage=120 ...Main
我不知道 BlockLayoutMinDiamondPercentage 有什么作用(也懒得查),看起来 120不是一个有效的百分比。但 Java 8 没有收到干扰,愉快地执行了 JAR 包中的代码——显然这并不是程序运行所需要的值。也许 120 就是一个有效的值呢?Java 9 并不这么看:
java9 -cp out-mr/mr.jar -XX:BlockLayoutMinDiamondPercentage=120 ...Main
intx BlockLayoutMinDiamondPercentage=120 is outside the allowed range [ 0 ... 100 ]
Improperly specified VM option 'BlockLayoutMinDiamondPercentage=120'
Error: Could not create the Java Virtual Machine.
Error: A fatal exception has occurred. Program will exit.
不错 ... JEP 245 为 Java 9 带来了在开始运行时验证所有输入参数的能力。从上面的例子可以看到,它也输出了解释性的错误消息。
保存栈区域
假如一个线程运行在栈空间外,它会抛出StackOverflowError错误。在用户代码中,这通常表现应用程序的瘫痪,所以没办法进行干预。但是在库里面,尤其是java核心部分,就算会失败也要尽量保持某些不变量。
比如 ReentrantLock,一个StackOverflowError错误会导致锁状态的错误,会中断锁的状态,使它不能再锁。假如应用程序决定捕获异常,那这个锁会使所有依赖的线程发生死锁。这会很糟糕。
为关键区域设置保留页面
JEP 270添加新的注解@ReservedStackAccess。有了它,操作模式会被标记为关键区域,即系统如果处于正常状态,则需要完成运行。JVM的行为由于执行栈而被替代。它保存一些额外栈空间,并且无论何时,一个栈溢出错误发生时,它会在栈的注解方法检查是否有任何@ReservedStackAccess。假如有,会预留一定空闲空间用于执行一些额外工作。它一旦完成,异常就不会再抛出。
这意味着使用注解不会产生大惊喜,异常还是会抛出,并且线程可能会由于失去了它以同样方式瘫痪。只是当区域代码需要完成,并且要确保在预留空间内完成时,这一操作才有意义。
在栈里保存多大空间?直到现在,默认是一个单一存储页,“实验表明,这足以覆盖java.util.concurrent的临界区已注明的关键部分”。
示例
编写例子会有点难,因为我们很难在托管程序产生堆栈溢出的同时,还能有一部分空间来做些其他的事。在演示项目中,我提供了我认为最佳的解决方案,文中我没有做详细阐述,但我展示了一些以无法控制的形式不断溢出堆栈的代码,其对保留空间无任何作用。
int depth;
@ReservedStackAccess
private void determineDepthWithReservedStack() {
determineDepth();
}
private void determineDepth() {
depth = 0;
try {
recurseToDetermineMaxDepth();
} catch (StackOverflowError err) { }
System.out.printf("Depth: %d%n", depth);
}
private void recurseToDetermineMaxDepth() {
depth++;
recurseToDetermineMaxDepth();
}
从下往上看,你会发现这是一个无限递归和递增 depth 字段的方法,只是用另外一种方式进行了包装,它能捕获异常并打印 depth 的值。最后,第三个方法请求访问保留堆栈区域。我并没有使用保留的堆栈区域,而是通过递归调用来将它耗尽。这意味着不管调用 determineDepth 还是 determineDepthWithReservedStack,最终都会导致堆栈的溢出,并打印出调用的次数。但输出结果会有所不同,如下:
DEPTH USING REGULAR STACK
Depth: 21392
DEPTH USING RESERVED STACK
Java HotSpot(TM) 64-Bit Server VM warning: Potentially dangerous stack overflow in ReservedStackAccess annotated method org.codefx.demo.java9.internal.stack.ReservingStackAreas.determineDepthWithReservedStack()V [1]
Depth: 59544
其输出结果非常不稳定。我猜想这里还有其他机制在工作,而这里我只做了简单演示。
内部构造!
如果你现在已经垂涎三尺,请注意这只是内部的 API。想要通过编译器访问注解,你必须进入基础模块--add-exports java.base/jdk.internal.vm.annotation=<module>,其中<module>或者 ALL-UNNAMED是模块的名称(如果你已经进入)。
如果JVM仍然无法读取注解,这时你就需要关闭限制特权的代码 -XX:-RestrictReservedStack。
您可以在 demo 项目 中看看 compile.sh 和 run.sh 是如何结合在一起的。
10 个习惯助你成为一名优秀的程序员