Java11-和-12-新特性指南-全-
Java11 和 12 新特性指南(全)
原文:
zh.annas-archive.org/md5/ceccff69d2ebf68b979e5c5563cc96be
译者:飞龙
前言
随着 Java 以极快的速度发展,程序员必须了解最新的发展,以便在他们的应用程序和库中充分利用其新特性。
本书将带你了解 Java 语言的发展,从 Java 10 到 Java 11,再到 Java 12。本书深入探讨了语言的最新发展。你将学习这些特性如何帮助你用该语言推进开发,并使你的应用程序更加精简和快速。
你还将发现如何配置虚拟机以减少启动时间,从而解决未来在吞吐量和延迟方面的挑战。借助这本书,你将克服迁移到 Java 新版本时遇到的挑战。
本书面向对象
如果你是一位负责技术选择或 Java 迁移决策的执行者或解决方案架构师,这本书适合你。如果你是一位对最新和即将推出的 Java 特性感兴趣的计算机科学爱好者,你也将从这本书中受益。"Java 11 和 12 – 新特性"将帮助你将解决方案从 Java 8 或更早版本迁移到最新的 Java 版本。
本书涵盖内容
第一章, 类型推断,介绍了 Java 10 中引入的局部变量类型推断。你将学习如何使用var
关键字以及可能遇到的挑战。
第二章,AppCDS,涵盖了应用程序类数据共享(AppCDS),它扩展了类数据共享(CDS)。你将了解两者并看到它们在实际中的应用。
第三章, 垃圾收集器优化,讨论了各种垃圾收集器及其高效实现接口。
第四章, JDK 10 的杂项改进,涵盖了 Java 10 中的特性和改进。
第五章, Lambda 参数的局部变量语法,解释了 Lambda 参数的局部变量语法,并介绍了与 Lambda 参数一起使用var
的用法。本章还涵盖了其语法和用法,以及你可能会遇到的挑战。
第六章, Epsilon GC,探讨了 Java 11 引入的 Epsilon,它降低了垃圾收集的延迟。本章解释了为什么需要它以及其设计考虑。
第七章, HTTP 客户端 API,讨论了 HTTP 客户端 API,它使你的 Java 代码能够通过网络请求 HTTP 资源。
第八章,ZGC,探讨了一种名为 ZGC 的新 GC,它具有低延迟的可扩展性。你将了解其特性和通过示例进行操作。
第九章,飞行记录仪和任务控制,讨论了 JFR 分析器,它有助于记录数据,以及 MC 工具,它有助于分析收集到的数据。
第十章,JDK 11 中的各种改进,涵盖了 Java 11 中的特性和改进。
第十一章,切换表达式,介绍了switch
表达式,这是 Java 12 中增强的基本语言结构。您将学习如何使用这些表达式使您的代码更高效。
第十二章,JDK 12 中的各种改进,涵盖了 Java 12 中的特性和改进。
第十三章,Project Amber 中的增强枚举*,展示了枚举如何为常量引入类型安全。本章还涵盖了每个枚举常量可以拥有其独特的状态和行为。
第十四章,数据类及其用法*,介绍了 Project Amber 中的数据类如何通过定义数据载体类来推动语言变化。
第十五章,原始字符串字面量*,讨论了开发者在将各种类型的多行文本值作为字符串值存储时面临的挑战。原始字符串字面量解决了这些问题,同时也显著提高了多行字符串值的可编写性和可读性。
第十六章,Lambda Leftovers,展示了 lambda leftovers 项目如何改进 Java 中的函数式编程语法和体验。
第十七章,模式匹配*,通过编码示例帮助您理解模式匹配如何改变您编写日常代码的方式。
为了充分利用这本书
一些先前的 Java 知识将有所帮助,并且所有必要的说明都已添加到相应的章节中。
下载示例代码文件
您可以从www.packtpub.com的账户下载这本书的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。
您可以通过以下步骤下载代码文件:
-
在www.packtpub.com登录或注册。
-
选择 SUPPORT 标签。
-
点击代码下载与勘误。
-
在搜索框中输入书籍名称,并遵循屏幕上的说明。
下载文件后,请确保您使用最新版本的软件解压缩或提取文件夹:
-
适用于 Windows 的 WinRAR/7-Zip
-
适用于 Mac 的 Zipeg/iZip/UnRarX
-
适用于 Linux 的 7-Zip/PeaZip
本书代码包也托管在 GitHub 上,网址为 github.com/PacktPublishing/Java-11-and-12-New-Features
。如果代码有更新,它将在现有的 GitHub 仓库中更新。
我们还提供其他代码包,这些代码包来自我们丰富的书籍和视频目录,可在 github.com/PacktPublishing/
上找到。查看它们吧!
下载彩色图像
我们还提供了一份包含本书中使用的截图/图表的彩色图像的 PDF 文件。您可以从这里下载:www.packtpub.com/sites/default/files/downloads/9781789133271_ColorImages.pdf
。
使用的约定
本书使用了多种文本约定。
CodeInText
: 表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“PUT
请求用于在服务器上创建或更新实体,使用 URI。”
代码块设置为如下:
class GarmentFactory {
void createShirts() {
Shirt redShirtS = new Shirt(Size.SMALL, Color.red);
当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:
abstract record JVMLanguage(String name, int year);
record Conference(String name, String venue, DateTime when);
任何命令行输入或输出都写作如下:
java -Xshare:dump
粗体: 表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词在文本中显示如下。以下是一个示例:“如您所注意到的,Lock Instances 选项旁边显示了一个感叹号。”
警告或重要提示看起来像这样。
小技巧和技巧看起来像这样。
联系我们
我们始终欢迎读者的反馈。
一般反馈: 通过电子邮件 feedback@packtpub.com
发送,并在邮件主题中提及书籍标题。如果您对本书的任何方面有疑问,请通过 questions@packtpub.com
发送电子邮件给我们。
勘误: 尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,如果您能向我们报告,我们将不胜感激。请访问 www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入详细信息。
盗版: 如果您在互联网上以任何形式遇到我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过 copyright@packtpub.com
联系我们,并提供材料的链接。
如果您有兴趣成为作者: 如果您在某个主题上具有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问 authors.packtpub.com。
评论
请留下您的评价。一旦您阅读并使用了这本书,为何不在购买它的网站上留下评价呢?潜在读者可以查看并使用您的客观意见来做出购买决定,我们 Packt 可以了解您对我们产品的看法,而我们的作者也可以看到他们对书籍的反馈。谢谢!
如需了解 Packt 的更多信息,请访问 packtpub.com.
第一部分:JDK 10
本节将帮助您开始学习类型推断,这是 Java 10 的主要特性之一。然后我们将学习关于应用程序类数据共享的内容,这有助于在共享归档文件中选择应用程序类。接着,我们将探讨更多关于 GC 接口和 G1 的并行完全 GC 的内容。最后,我们将涵盖 Java 10 的剩余新增或更新内容,其中大部分与 JDK 或其实施的变化有关。
本节将涵盖以下章节:
-
第一章,类型推断
-
第二章,AppCDS
-
第三章,垃圾收集器优化
-
第四章,JDK 10 中的杂项改进
第一章:类型推断
能够使用局部变量(var
)进行类型推断是 Java 10 的明星特性之一。它减少了语言的冗长性,同时没有牺牲 Java 的可靠静态绑定和类型安全。编译器通过使用代码中可用的信息来推断类型,并将其添加到它生成的字节码中。
每个新概念都有自己的优点、局限性和复杂性。使用var
进行类型推断也不例外。当你通过这一章工作时,使用var
会既让你着迷又让你沮丧,但最终你会胜利地出现。
在本章中,我们将涵盖以下主题:
-
什么是类型推断?
-
使用
var
进行类型推断 -
与
var
一起工作的注意事项和禁忌 -
类型推断与动态绑定
什么是类型推断?
想象一下,用以下图像中显示的多个以提示形式存在的约束条件来解决一个谜题。你通过解决约束条件来得出答案。你可以将类型推断与生成约束条件然后解决它们,以确定编程语言中的数据类型进行比较。类型推断是编译器通过使用代码中已存在的信息(如字面值、方法调用及其声明)来确定数据类型的能力。对于开发者来说,类型推断减少了冗长性,如下面的图所示:
为了参考,前面谜题的答案是 87(只需将图像颠倒过来,你就能找到数字的顺序)。
类型推断对 Java 来说并不新鲜。随着 Java 10 中引入var
(局部变量)的概念,它被提升到了一个新的水平。
让我们通过查看一些var
的示例来深入了解这个主题。
使用var
进行类型推断
以下代码行显示了在 Java 10 之前如何定义局部变量(以及所有其他变量):
String name = "Java Everywhere";
LocalDateTime dateTime = new LocalDateTime.now();
从 Java 10 开始,通过使用var
,你可以在局部变量的声明中省略强制显式类型,如下所示:
var name = "Java Everywhere"; // variable 'name' inferred as
// String
var dateTime = new LocalDateTime.now(); // var 'dateTime' inferred as
// LocalDateTime
前面的代码看起来没有提供很多好处吗?想象一下,你可以用以下代码替换它:
HashMap<Integer, String> map = new HashMap<Integer, String>();
并用以下代码替换它:
var map = new HashMap<Integer, String>();
通过将HashMap<Integer, String>
替换为var
,前面的代码行变得更短了。
当你不再明确声明变量的数据类型时,编译器就会接管以确定或推断变量类型。类型推断是编译器评估代码中已存在的信息(如字面值、操作和方法调用或它们的声明)以确定变量类型的能力。它遵循一系列规则来推断变量类型。作为开发者,当你选择使用var
进行类型推断时,你应该了解编译器的推断算法,以免得到意外结果。
每当有新的功能时,您都应该遵守一些规则和限制,并尝试遵循最佳实践以充分利用该功能。让我们从使用 var
定义的变量强制初始化开始。
var
的类型推断不是动态类型;Java 仍然是一种强静态类型语言。使用 var
可以使您的代码更简洁;您可以从局部变量的定义中省略其类型。
强制非空初始化
使用 var
定义的局部变量必须在声明时进行初始化,否则代码将无法编译。编译器无法推断未初始化变量或被赋予 null
值的变量的类型。以下代码将无法编译:
var minAge; // uninitialized variable
var age = null; // variable assigned a null value
以下图像说明了如果未初始化的变量 age
去寻求进入 Mr. Java 编译器的位置会发生什么。编译器不会让 age
进入:
使用 var
定义变量时,必须始终伴随其初始化,否则代码将无法编译。
局部变量
var
的使用仅限于局部变量。这些变量用于存储中间值,与实例和静态变量相比,生命周期最短。局部变量在方法、构造函数或初始化块(实例和静态)内定义。在方法或初始化块内,它们可以在诸如 if..else
循环、switch
语句和 try-with-resources
构造等结构中定义。
以下是一个 Person
类的示例,展示了在初始化块、方法(包括构造函数)、循环、switch
分支内的局部变量或 try with resources
语句中使用 var
定义局部变量的可能用法:
public class Person {
{
var name = "Aqua Blue"; // instance initializer block
}
static {
var anotherLocalVar = 19876; // static initializer block
}
Person() {
var ctr = 10; // constructor
for (var loopCtr = 0; loopCtr < 10; ++loopCtr) { // loop -
// for
switch(loopCtr) {
case 7 :{
var probability = ctr / loopCtr; // switch
System.out.println(probability);
break;
}
}
}
}
public String readFile() throws IOException {
var filePath = "data.txt";
// try with resources
try (var reader = new BufferedReader(new FileReader(filePath))) {
return reader.readLine();
}
}
}
如您从前面的代码中可以看出,局部变量可以在类的不同位置使用 var
进行声明。您还记得大多数吗?如果不记得,让我们为您简化一下。
让我们使用一个应用程序来 查找 所有可以定义使用 var
的局部变量的可能位置,并以图示方式标记:
本章包含一些代码检查练习供您尝试。这些练习使用了两位假设程序员的名称——Pavni 和 Aarav。
代码检查 – 第一部分
我们的程序员 Aarav 通过他的团队成员 Pavni 重构了一些代码。代码不再提供 char
数组存储的值的 char
和相应的 ASCII 数字。你能帮助 Aarav 吗?以下是要使用的代码:
class Foo {
public static void main(String args[]) {
try {
char[] name = new char[]{'S','t','r','i','n','g'};
for (var c : name) {
System.out.println(c + ":" + (c + 1 - 1));
}
}
catch (var e) {
//code
}
}
}
代码检查的答案:var
类型不能用于指定 catch
处理程序中异常的类型,(var e)
。
使用 var
与原始数据类型
使用 var
与原始数据类型似乎是最简单的情况,但外表可能具有欺骗性。尝试执行以下代码:
var counter = 9_009_998_992_887; // code doesn't compile
你可能认为一个不适用于原始int
类型范围的整数字面量(例如9_009_998_992_887
)会被推断为long
类型。然而,事实并非如此。由于整数字面量的默认类型是int
,你必须将前缀L
或l
附加到值的前面,如下所示:
var counter = 9_009_998_992_887L; // code compiles
类似地,要使int
字面量被推断为char
类型,你必须使用显式转换,如下所示:
var aChar = (char)91;
当你将5
除以2
时,结果是什么?你以为它是2.5
吗?但这并不是 Java 中(总是)的工作方式!当整数用作除法的操作数时,结果是整数,而不是小数。小数部分被舍弃,以得到整数结果。虽然这是正常的,但当你期望编译器推断变量类型时,可能会觉得有点奇怪。以下是一个例子:
// type of result inferred as int; 'result' stores 2
var divResult = 5/2;
// result of (5/2), that is 2 casted to a double; divResult stores 2.0
var divResult = (double)(5/ 2);
// operation of a double and int results in a double; divResult stores
// 2.5
var divResult = (double)5/ 2;
虽然这些情况与var
类型没有直接关系,但开发者认为编译器会推断特定类型的假设导致了不匹配。以下是一个快速图解,帮助你记住这一点:
整数字面量的默认类型是int
,浮点数的默认类型是double
。将100
赋值给用var
定义的变量时,其类型会被推断为int
,而不是byte
或short
。
在算术运算中,如果任一操作数是char
、byte
、short
或int
,结果至少会被提升到int
:
byte b1 = 10;
char c1 = 9;
var sum = b1 + c1; // inferred type of sum is int
类似地,对于包含至少一个操作数为long
、float
或double
值的算术运算,结果会被提升到long
、float
或double
类型,分别:
byte cupsOfCoffee = 10;
long population = 10L;
float weight = 79.8f;
double distance = 198654.77;
var total1 = cupsOfCoffee + population; // inferred type of total1
// is long
var total2 = distance + population; // inferred type of total2
// is double
var total3 = weight + population; // inferred type of total3 is
// float
原始变量隐式扩展的规则在理解 Java 编译器如何推断原始值变量方面起着重要作用。
派生类的类型推断
在 JDK 9 和其他早期版本中,你可以定义一个基类变量并将其赋值为其派生类的实例。你可以通过变量访问的成员仅限于在基类中定义的成员。但是,使用var
的情况不再是这样,因为变量的类型是通过为其分配的实例的具体类型来推断的。
想象一个类Child
继承自类Parent
。当你创建一个局部变量并将其赋值为Child
类的实例时,变量的类型被推断为Child
。这看起来很简单。以下是一个例子:
class Parent {
void whistle() {
System.out.println("Parent-Whistle");
}
}
class Child extends Parent {
void whistle() {
System.out.println("Child-Whistle");
}
void stand() {
System.out.println("Child-stand");
}
}
class Test{
public static void main(String[] args) {
var obj = new Child();
obj.whistle();
obj.stand(); // type of obj inferred as Child
}
}
如果你使用可以返回Child
类或Parent
类实例的方法来赋值给obj
变量,会发生什么?以下是修改后的代码:
class Parent {
void whistle() {
System.out.println("Parent-Whistle");
}
}
class Child extends Parent {
void whistle() {
System.out.println("Child-Whistle");
}
void stand() {
System.out.println("Child-stand");
}
}
class Test{
public static Parent getObject(String type) {
if (type.equals("Parent"))
return new Parent();
else
return new Child();
}
public static void main(String[] args) {
var obj = getObject("Child");
obj.whistle();
obj.stand(); // This line doesn't compile
}
}
在上述代码中,getObject()
方法返回的实例类型在代码执行之前无法确定。在编译期间,obj
变量的类型被推断为 Parent
,这是 getObject()
方法的返回类型。由于 Parent
类没有定义 stand()
,因此 main()
方法无法编译。
使用 var
定义的变量类型是在编译时推断的。如果使用 var
定义的方法的返回类型来分配变量,其推断类型是方法的返回类型,而不是在运行时返回的实例类型。
接口的类型推断
让我们将上一节的内容扩展到接口的使用。想象一下,Child
类实现了 MarathonRunner
接口,如下所示:
interface MarathonRunner{
default void run() {
System.out.println("I'm a marathon runner");
}
}
class Child implements MarathonRunner {
void whistle() {
System.out.println("Child-Whistle");
}
void stand() {
System.out.println("Child-stand");
}
}
让我们定义一个 obj
局部变量,将其赋值为 Child
类的实例:
class Test{
public static void main(String[] args) {
var obj = new Child(); // inferred type of var obj
// is Child
obj.whistle();
obj.stand();
obj.run();
}
}
如果使用返回类型为 MarathonRunner
的方法初始化相同的变量,其推断类型为 MarathonRunner
(无论它返回的实例类型如何):
class Test{
public static MarathonRunner getObject() {
return new Child();
}
public static void main(String[] args) {
var obj = getObject(); // inferred type of var obj is
// MarathonRunner
obj.whistle();
obj.stand();
obj.run();
}
}
使用 var
与数组
使用 var
并不意味着只是丢弃局部变量的类型;剩下的应该能够使编译器推断其类型。想象一下一个定义 char
类型数组的函数,如下所示:
char name[] = {'S','t','r','i','n','g'};
你不能在上述代码中将数据类型名称,即 char
,替换为 var
并使用以下任何一种代码示例来定义它:
var name[] = {'S','t','r','i','n','g'};
var[] name = {'S','t','r','i','n','g'};
var name = {'S','t','r','i','n','g'};
这是一种包含相关信息的方法之一,以便编译器可以推断类型:
var name = new char[]{'S','t','r','i','n','g'};
看起来 Java 编译器已经对程序员的这个假设感到困扰,如下面的图像所示:
你不能仅仅为了使用 var
而丢弃数据类型。剩下的应该能够使编译器推断出所赋值的类型。
泛型的类型推断
泛型被引入 Java 中是为了促进类型安全。它使开发者能够指定他们使用具有固定类型或一系列类型的类、接口和集合类的意图。违反这些意图将通过编译错误而不是运行时异常来强制执行,从而提高了合规性标准。
例如,以下展示了如何定义 ArrayList
来存储 String
值(在赋值右侧重复 <String>
是可选的):
List<String> names = new ArrayList<>();
然而,将 List<String>
替换为 var
将会使泛型的类型安全受到威胁:
var names = new ArrayList<>();
names.add(1);
names.add("Mala");
names.add(10.9);
names.add(true);
上述代码允许向 names
添加多个数据类型,这并不是我们的意图。使用泛型时,更推荐的方法是向编译器提供相关信息,以便它可以正确推断其类型:
var names = new ArrayList<String>();
当使用 var
与泛型一起时,确保在赋值右侧的尖括号内传递相关数据类型,这样就不会丢失类型安全。
现在,是我们进行下一项代码检查的时候了。
代码检查 – 第二部分
我们的一位程序员,Pavni,尝试在泛型和集合类中使用var
,但她的代码似乎没有输出排序好的钢笔集合。你能帮忙吗?查看以下代码:
class Pen implements Comparable<Pen> {
String name;
double price;
public Pen(String name, double price) {
this.name = name;
this.price = price;
}
public int compareTo(Pen pen) {
return ((int)(this.price - pen.price));
}
public String toString() {
return name;
}
public static void main(String args[]) {
var pen1 = new Pen("Lateral", 219.9);
var pen2 = new Pen("Pinker", 19.9);
var pen3 = new Pen("Simplie", 159.9);
var penList = List.of(pen1, pen2, pen3);
Collections.sort(penList);
for (var a : penList)
System.out.println(a);
}
}
代码检查的答案:这里的问题是试图通过使用Collections.sort()
来修改不可变集合。这是为了强调并非所有问题都与var
的使用相关。
将推断变量传递给方法
尽管var
的使用仅限于局部变量的声明,但这些变量(包括原始类型和引用类型)可以作为值传递给方法。推断的类型和方法期望的类型必须匹配,以便代码能够编译。
在以下示例代码中,Child
类实现了MarathonRunner
接口。Marathon
类中的start()
方法期望MarathonRunner
对象(实现此接口的类的实例)作为其方法参数。aRunner
变量的推断类型是Child
。由于Child
类实现了MarathonRunner
,aRunner
可以被传递给start()
方法,aRunner
的推断类型(Child
)和start()
期望的类型(MarathonRunner
)匹配,允许代码编译。
代码如下:
interface MarathonRunner {
default void run() {
System.out.println("I'm a marathon runner");
}
}
class Child implements MarathonRunner {
void whistle() {
System.out.println("Child-Whistle");
}
void stand() {
System.out.println("Child-stand");
}
}
class Marathon {
public static void main(String[] args) {
var aRunner = new Child(); // Inferred type is Child
start(aRunner); // ok to pass it to method start
// (param - MarathonRunner)
}
public static void start(MarathonRunner runner) {
runner.run();
}
}
只要变量的推断类型与方法参数的类型相匹配,就可以将其作为参数传递给它。
重新分配推断变量的值
对于所有非最终变量,你可以重新分配值给推断变量。只需确保重新分配的值与其推断类型匹配。在以下代码中,由于age
变量的类型被推断为int
,你不能将其分配为小数10.9
。同样,由于query
变量的类型被推断为StringBuilder
。变量的类型只推断一次,如下所示:
var age = 9; // type of variable age inferred as int
age = 10.9; // can't assign 10.9 to variable of type int
var query = new StringBuilder("SELECT"); // Type of variable
// query is StringBuilder
query = query.toString() + "FROM" + "TABLE"; // won't compile;
// can't assign String
// to variable query
使用var
定义的局部变量的类型只推断一次。
推断变量的显式转换
假设一个合作程序员将29
赋值给推断的局部变量(比如说age
),假设编译器会推断变量age
的类型为byte
:
var age = 29; // inferred type of age is int
然而,编译器会推断变量age
的类型为int
,因为整型字面值的默认类型是int
。为了修复前面的假设,你可以使用显式数据类型或通过使用显式转换覆盖编译器的默认推断机制,如下所示:
byte age = 29; // Option 1 - no type inference
var age = (byte)29; // Option 2 - explicit casting
通过使用显式转换类型推断,你可以覆盖编译器的默认类型推断机制。这可能是为了修复现有代码中的假设。
类似地,你可以使用显式转换与其他原始数据类型,如char
和float
:
var letter = (char)97; // inferred type of letter is char
var debit = (float)17.9; // inferred type of debit is float
在前面的例子中没有使用显式转换的情况下,被分配整型字面值的变量会被推断为int
,而小数会被推断为double
。
以下示例展示了引用变量的显式转换:
class Automobile {}
class Car extends Automobile {
void check() {}
}
class Test{
public static void main(String[] args) {
var obj = (Automobile)new Car();
obj.check(); // Won't compile; type of obj is Automobile
}
}
使用类型推断进行显式类型转换以修复任何现有的假设。我不建议使用显式类型转换来初始化推断变量;这违背了使用var
的目的。
使用显式类型转换赋值 null
再次,虽然使用显式类型转换将null
赋值给var
类型没有意义,但它是一种有效的代码,如下所示:
var name = (String)null; // Code compiles
尽管前面的代码在语法上是正确的,但它是一种不良的编码实践。请避免使用它!
Java 先前版本中的类型推断
虽然var
在 Java 10 中将推断提升到了一个新的水平,但在 Java 的先前版本中已经存在类型推断的概念。让我们看看 Java 先前版本中类型推断的一些例子。
Java 5 中的类型推断
泛型引入了一种类型系统,使开发者能够对类型进行抽象。它限制了一个类、接口或方法只能与指定类型的实例一起工作,提供了编译时的类型安全性。泛型被定义为向集合框架添加编译时的类型安全性。泛型使程序能够在编译期间检测某些错误,因此它们不能渗透到运行时代码中。
Java 5 中使用了类型推断来推断泛型方法类型参数。考虑以下代码:
List<Integer> myListOfIntegers = Collections.<Integer>emptyList(); // 1
而不是前面的代码,你可以使用以下代码:
List<Integer> myListOfIntegers = Collections.emptyList(); // 1
Java 7 中的类型推断
Java 7 引入了对泛型构造函数参数的类型推断。考虑以下代码行:
List<String> myThings = new ArrayList<String>();
在 Java 7 中,前面的代码行可以用以下代码替换:
List<String> myThings = new ArrayList<>();
前面的代码不应与以下代码混淆,后者试图将泛型与原始类型混合:
List<String> myThings = new ArrayList();
Java 7 还允许对泛型方法进行类型推断。对于一个在类中定义的泛型方法(例如,print()
),代码如下:
class MyClass<T> {
public <X> void print(X x) {
System.out.println(x.getClass());
}
}
前面的代码可以用以下两种方式之一调用(第三行代码使用类型推断来推断传递给print()
方法的参数的类型):
MyClass<String> myClass = new MyClass<>();
myClass.<Boolean>deliver(new Boolean("true"));
myClass.deliver(new Boolean("true"));
Java 8 中的类型推断
Java 8 引入了函数式编程,包括 lambda 函数。lambda 表达式可以推断其形式参数的类型。考虑以下代码:
Consumer<String> consumer = (String s) -> System.out.println(s);
而不是前面的代码,你可以输入以下代码:
Consumer<String> consumer = s -> System.out.print(s);
挑战
var
的使用并非没有其挑战,这既包括 Java 语言的开发者,也包括其用户。让我们从var
使用受限的原因开始。
限制失败假设的范围
如你所知,var
类型的用法仅限于 Java 中的局部变量。它们不允许在公共 API 中使用,例如作为方法参数或方法的返回类型。一些语言支持对所有类型变量的类型推断。Java 可能在将来允许我们这样做,但我们不知道具体何时。
然而,有强烈的理由限制推断变量的作用域,以便尽早发现由于假设与实际情况不匹配而产生的错误。公共 API 的合同应该是明确的。使用公共 API 进行类型推断将允许这些错误被捕获和纠正得晚得多。
公共 API 的合同应该是明确的;它们不应该依赖于类型推断。
以下是一个实际示例,说明了假设与实际情况之间的不匹配可能导致错误。
最近,我的女儿随学校出国参加学生交流项目。学校要求我为她签证申请发送一组照片。我联系了一位摄影师,要求他打印签证照片(并指定了国家)。两天后,学校要求我重新提交照片,因为它们不符合规则。
发生了什么问题?学校和我自己都没有明确照片的规格。学校认为我会知道规格;我假设摄影师会知道规格(因为他已经做了很多年)。在这种情况下,至少有一个人假设结果符合特定的输出,而没有明确指定输出。没有明确的合同,总是有可能期望与实际情况不符。
尽管存在困惑,但在应用程序提交给大使馆之前,错误已被发现并纠正。
以下是一个有趣的图像,展示了为什么类型推断的使用仅限于局部变量。局部实例和静态变量在比赛中竞争,只有局部变量才能到达终点线:
破坏现有代码
截至 Java 10,var
是一个受限的局部变量类型,不能用于类型声明。使用 var
作为类、接口、方法、方法参数或变量名称的代码,在 JDK 10 及以后的版本中将无法编译。
以下是一个使用 var
在多个位置且无法编译的代码示例:
class var {} // can't use var as class name
interface var {} // can't use var as interface name
class Demo {
int var = 100; // can't use var as instance variable
// name
static long var = 121; // can't use var as static variable
// name
void var() { // can't use var as method name
int var = 10; // cant use var as the name of a local
// variable
}
void aMethod(String var) {} // can't use var as the name of method parameter
}
即使你计划不将生产代码部署到最新版本的 Java 中,也很重要使用最新的 Java 发布版本测试你的生产代码。这有助于解决与你的生产代码的任何兼容性问题,帮助你将其迁移到 Java 的未来版本。
不可表示类型
你可以在程序中使用的 Java 类型,如 int
、Byte
、Comparable
或 String
,被称为 可表示 类型。编译器内部使用的类型,如匿名类的子类,你无法在程序中编写,被称为 不可表示 类型。
到目前为止,变量的类型推断似乎很容易实现——只需获取传递给方法的价值和从方法返回的价值的信息,并推断类型。然而,当涉及到不可表示类型的推断时——null
类型、交集类型、匿名类类型和捕获类型,事情并不像那样简单。
例如,考虑以下代码并思考推断变量的类型:
// inferred type java.util.ImmutableCollections$ListN
var a = List.of(1, "2", new StringBuilder());
var b = List.of(new ArrayList<String>(), LocalTime.now());
变量a
和b
的类型并不是你之前读过的类型。但这并不妨碍它们被推断。编译器将它们推断为一个不可表示的类型。
有意义的变量名
使用var
进行类型推断应负责任地使用。当你从变量声明中移除显式数据类型时,变量名就成为了焦点。在使用推断类型的情况下,你有责任使用描述性和适当的变量名,以便它们在代码中更有意义。正如你所知,一段代码只编写一次,但会被阅读多次。
例如,以下代码行在一段时间后对你或你的团队成员(尤其是大型或分布式团队)来说可能没有太多意义:
var i = getData(); // what does getData() return? Is 'i' a
// good name?
关键问题是——变量i
用于什么?getData()
方法返回什么?想象一下你离开后将与这段代码一起工作的其他团队成员的困境。
此外,定义与目的不匹配的变量名也没有帮助。例如,创建一个名为database
的连接对象并将其分配给一个URL
实例,或者定义一个名为query
的变量并将其分配给一个Connection
实例,都没有太多意义:
var database = new URL("http://www.eJavaGuru.com/malagupta.html");
var query = con.getConnection();
当使用var
定义变量时,变量名变得更加重要。没有类型,可能难以理解变量的目的,尤其是如果其名称不够表达。仔细且负责任地选择变量名,使其目的明确。
代码重构
使用var
进行类型推断是为了减少 Java 语言的冗长。这将帮助程序员在方法中更加简洁。编译器会推断使用var
声明的变量的类型,并将其插入到字节码中。无需重构现有或遗留代码,将局部变量的显式数据类型替换为var
。
类型推断与动态绑定
使用var
进行类型推断并没有推动 Java 走向动态绑定领域。Java 仍然是一个强类型静态语言。Java 中的类型推断是语法糖。编译器推断类型并将其添加到字节码中。在动态绑定中,变量类型在运行时推断。这可能导致更晚发现更多的错误。
摘要
在本章中,我们介绍了 Java 10 中引入的局部变量推断,或称为var
。var
类型允许你在方法中省略局部变量的显式数据类型。我们讨论了使用var
的各种注意事项。限于局部变量,使用var
定义的变量必须初始化。它们可以与所有类型的变量一起使用——原始类型和对象。使用var
定义的变量也可以传递给方法并从方法返回;方法声明兼容性规则适用。
为了避免在使用泛型时冒着类型安全的风险,确保在使用var
与泛型一起使用时传递相关信息。尽管这样做没有太多意义,但使用显式转换在用var
定义的变量中是允许的。
我们还讨论了类型推断在 Java 的先前版本(5、7 和 8)中存在的方式。在结尾部分,我们讨论了为什么类型推断仅限于局部变量,并且不允许在公共 API 中使用。
使用有意义的变量名一直被推荐,并且这一点非常重要。有了var
,这一点变得更加重要。由于var
提供了语法糖,因此没有必要重构现有的或遗留代码以添加var
的使用。
第二章:AppCDS
应用程序类数据共享(AppCDS)或AppCDS扩展了类数据共享(CDS)的功能。它允许程序员将选定的应用程序类以及核心库类包含在共享归档文件中,以减少 Java 应用程序的启动时间。这也导致了内存占用减少。
使用 AppCDS 创建的共享归档文件可以包括运行时映像中的类、运行时映像中的应用程序类以及类路径中的应用程序类。
在本章中,我们将涵盖以下主题:
-
CDS 简介
-
使用 CDS 创建共享归档
-
AppCDS 简介
-
使用 AppCDS 识别要放入共享归档中的应用程序文件
-
创建和使用共享应用程序归档文件
技术要求
要使用本章中的代码,你应该在你的系统上安装了 JDK 版本 10 或更高版本。
本章中的所有代码都可以在github.com/PacktPublishing/Java-11-and-12-New-Features
找到。
由于 AppCDS 扩展了 CDS 的功能,因此在开始使用 AppCDS 之前了解 CDS 会有所帮助。下一节介绍了 CDS,包括如何找到共享归档文件、如何创建或重新创建它以及相关的命令。如果你有实际使用 CDS 的经验,可以跳过下一节关于 CDS 的内容。
什么是 CDS?
自 Java 8 以来,CDS 一直是 Oracle JVM 的商业特性。CDS 以两种方式帮助——它有助于减少 Java 应用程序的启动时间,并使用多个Java 虚拟机(JVMs)减少其内存占用。
当你启动你的 JVM 时,它会执行多个步骤来为执行准备环境。这包括字节码加载、验证、链接以及核心类和接口的初始化。类和接口被组合到 JVM 的运行时状态中,以便它们可以被执行。它还包括方法区域和常量池。
这些核心类和接口的集合除非你更新了你的 JVM,否则不会改变。所以每次你启动你的 JVM 时,它都会执行相同的步骤来为执行准备环境。想象一下,你可以将结果导出到一个文件中,这个文件可以在 JVM 启动时被读取。随后的启动可以不执行加载、验证、链接和初始化的中间步骤。欢迎来到 CDS 的世界。
当你安装JRE 时,CDS 会从系统jar
文件中的一系列预定义类创建一个共享归档文件。在类可以使用之前,它们会被类加载器验证——这个过程适用于所有类。为了加快这个过程,安装过程将这些类加载到内部表示中,然后将该表示导出到classes.jsa
——一个共享归档文件。当 JVM 启动或重启时,classes.jsa
会被内存映射以节省加载这些类的步骤。
当 JVM 的元数据在多个 JVM 进程之间共享时,它会导致更小的内存占用。从填充的缓存中加载类比从磁盘加载更快;它们也部分经过验证。此功能对启动新 JVM 实例的 Java 应用程序也有益。
共享归档文件的存放位置
默认情况下,JDK 安装过程创建名为classes.jsa
的类数据共享文件。classes.jsa
的默认位置如下:
-
Solaris/Linux/macOS:
/lib/[arch]/server/classes.jsa
-
Windows 平台:
/bin/server/classes.jsa
(如下截图所示):
在 Windows 系统上,共享归档文件的大小,即classes.jsa
,大约为 17.2 MB。
手动创建 classes.jsa
此共享归档文件也可以使用以下运行时命令手动创建(你应该有足够的权限写入目标目录):
java -Xshare:dump
下面是前面命令的示例输出:
如前一个屏幕截图中的输出消息所示,此命令执行了许多操作——它加载类、链接它们、计算包含在共享归档中的类数量、分配读写和只读对象,等等。
如果文件已存在,前面的命令将简单地覆盖现有文件。
使用前面命令创建的共享归档文件不包括所有的系统 API 类或接口。它只包括启动时所需的那些。
CDS 的使用
您可以通过开启、关闭或设置为自动模式来手动控制 CDS 的使用。以下是这样做的命令行选项:
-
java -Xshare:off
: 禁用 CDS -
java -Xshare:on
: 启用 CDS -
java -Xshare:auto
: 默认模式(尽可能启用 CDS)
让我们快速定义一个类如下:
class ConquerWorld {
public static void main(String args[]) {
System.out.println("Go and conquer the world");
}
}
让我们使用共享归档文件classes.jsa
执行前面的类(ConquerWorld
)。要查看从共享归档中加载的系统类,可以使用带有类执行的log
文件,如下所示:
java -Xlog:class+load:file=myCDSlog.log ConquerWorld
前面的命令输出以下内容:
Go and conquer the world
让我们检查myCDSlog.log
文件的内容(我已经突出显示文本以引起您对特定行的注意;突出显示的文本不包括在log
文件中):
classes.jsa
文件也被称为共享对象文件。JVM 从classes.jsa
加载大约 500 个类或接口来设置执行环境。它从系统相关位置加载ConquerWorld
类的字节码。
如果你仔细检查myCDSlog.log
文件,你会注意到有一些类没有从共享对象文件中加载。这是因为它们无法归档;这种情况可能发生在某些情况下。
让我们看看如果你通过声明不使用共享对象文件来执行相同的类(ConquerWorld
)会发生什么。为此,你可以使用 -Xshare:off
命令,如下所示:
java -Xshare:off -Xlog:class+load:file=myCDSshareoff.log
ConquerWorld
以下代码将输出与之前相同的结果。让我们检查 myCDSshareoff.log
文件的内容:
如你所见,由于之前的执行不再使用共享对象文件(使用 Xshare:off
选项已关闭),系统或核心 API 类在运行时从各自的模块中加载。如截图左下角所突出显示的,你还可以看到这次执行花费了更长的时间,即大约 0.110 秒。这个时间超过了使用共享存档(如前一个截图所示)的类似执行的 0.083 秒执行时间。
在了解了 CDS 如何降低代码执行时间的基本信息后,让我们开始使用 AppCDS。
AppCDS
增加的用户和技术使用正在推动每天探索或制定更好的方法来提高性能。JEP 310 提出了将 CDS 扩展到支持应用程序文件。在本节中,你将了解 AppCDS 如何提高 Java 应用程序的性能以及如何创建和使用它。
AppCDS 的好处
AppCDS 将 CDS 的好处扩展到应用程序类,使你能够将应用程序类与核心库类的共享存档一起放置。这消除了类加载、链接和字节码验证的工作,从而减少了应用程序的启动时间。多个 JVM 可以访问共享存档,从而减少了整体内存占用。
在云中,服务器扩展 Java 应用程序是很常见的,多个 JVM 执行相同的应用程序。这是一个非常适合 AppCDS 的用例。此类应用程序将从减少启动时间和减少内存占用中受益巨大。
无服务器云服务在启动时加载成千上万的应用程序类。AppCDS 将显著减少它们的启动时间。
启用应用程序类数据存档
在 Java 10 中,默认配置仅启用了 JVM 的引导类加载器的类数据共享。由于引导类加载器不加载你的应用程序文件,因此你预计需要显式地为应用程序类加载器和其他类加载器使用以下命令启用它:
-XX:+UseAppCDS
然而,在 Java 11 中,AppCDS 在 OpenJDK 64 位系统上自动启用。当包含此选项时,你可能会收到如下错误消息:
如果你正在使用 Java 11 或更高版本,你可以跳过此选项。
Java 运行时选项对大小写敏感。-XX:+UseAppCDS
和 -XX:+useAppCDS
选项并不相同。
哪些应用程序类需要存档
创建共享归档的下一步是指定要包含的应用程序类。检查您在前一节中创建的myCDSlog.log
文件。它不包含在核心 Java API 中定义的每个类或接口。
同样,尽管您的应用程序可能包含很多类,但您不需要将所有它们包含在共享归档文件中,仅仅是因为不是所有类在启动时都是必需的。这也减少了共享归档文件的大小。
这里有一个示例,用于找到应该添加到共享归档中的应用程序类。首先,创建应用程序文件的jar
文件。
让我们在com.ejavaguru.appcds
包中创建四个骨架类文件:
// Contents of Cotton.java
package com.ejavaguru.appcds;
public class Cotton {}
// Contents of Plastic.java
package com.ejavaguru.appcds;
public class Plastic {}
// Contents of PlasticBottle.java
package com.ejavaguru.appcds;
public class PlasticBottle extends Plastic {}
// Contents of Wood.java
package com.ejavaguru.appcds;
public class Wood {}
这里是AppCDS
类的内容,它使用前面提到的其中一个类。它不在同一个包中定义:
// Contents of class AppCDS.java
import com.ejavaguru.appcds.*;
class AppCDS {
public static void main(String args[]) {
System.out.println(new Plastic());
}
}
如果您的目录结构与包结构匹配,您可以使用以下命令创建jar
文件:
要确定应该放入共享归档中的应用程序类,执行以下命令:
java -Xshare:off
-XX:DumpLoadedClassList=myappCDS.lst
-cp appcds.jar
AppCDS
执行前面的命令后,myappCDS.lst
记录了由 JVM 加载的所有类的完全限定名(使用\
分隔,大约有 500 个),包括核心 API 类和您的应用程序类。
以下截图显示了myappCDS.lst
文件中的一些类名。我已经突出显示了列表中包含的两个应用程序文件的名字——AppCDS
和com/ejavaguru/appcds/Plastic
:
如果您重新查看AppCDS
类的代码,您会注意到它只使用了一个类,即com.ejavaguru.appcds
包中的Plastic
类。同一包中的其他类没有加载,因为它们没有被使用。如果您想加载其他特定的类,您应该在应用程序中使用它们。
在访问要包含在共享归档中的应用程序文件列表后,您可以继续并创建它。
创建应用程序共享归档文件
要使用应用程序文件创建共享归档,您可以在命令提示符中执行以下命令:
java -Xshare:dump
-XX:+UseAppCDS
-XX:SharedClassListFile=myappCDS.lst
-XX:SharedArchiveFile=appCDS.jsa
-cp appcds.jar
如在启用应用程序类数据归档部分所述,如果您在系统上使用 Java 11 或更高版本,可以跳过使用-XX:+UseAppCDS
选项(AppCDS 是在 Java 10 中引入的;在 Java 11 中,您不需要显式启用它)。前面的命令使用存储在myappCDS.lst
中的类名列表来创建应用程序共享归档文件。它还指定共享归档文件的名称为appCDS.jsa
。
这里是前面命令的输出截图:
让我们进入最后一步——使用共享应用程序归档文件。
使用共享应用程序归档文件
要使用与 AppCDS 一起的共享应用程序归档文件(appCDS.jsa
),请执行以下命令:
java -Xshare:on
-XX:+UseAppCDS
-XX:SharedArchiveFile=appCDS.jsa
-cp appcds.jar
AppCDS
上一段代码将使用共享应用程序归档文件来加载预定义的核心 API 类和应用程序类到内存中。这导致用户应用程序的启动时间减少。本章中使用的演示应用程序只包含四到五个类来演示这个过程,不会让你感到负担。你应该能够注意到更大用户应用程序启动时间的显著减少。此外,你可以在 JVM 之间共享.jsa
文件,以减少内存占用。
摘要
本章从介绍 AppCDS 开始,它扩展了 CDS 到你的应用程序文件的能力。AppCDS 减少了你应用程序的启动时间和内存占用。
你已经了解了识别要包含在共享应用程序归档文件中的应用程序类、创建文件以及使用它的过程。
AppCDS 只是提高 Java 应用程序性能的多种方法之一。在下一章中,你将了解到垃圾回收优化如何进一步帮助提高 Java 应用程序的性能。
在下一章中,我们将探讨垃圾收集器中引入的各种优化。
第三章:垃圾收集器优化
Java 10 在垃圾收集(GC)领域提供了两大改进。它包括为垃圾优先(G1)GC 提供并行完全 GC,提高了其最坏情况下的延迟。它还改善了 HotSpot 中多个 GC 的源代码隔离,引入了 GC 接口。
G1 被指定为 Java 9 的默认 GC。G1 通过将内存划分为幸存者、伊甸园和老年代区域,并通过执行中间 GC 来释放堆空间,旨在避免完全收集。然而,当对象分配速度很快且内存无法快速回收时,就会发生完全 GC。直到 JDK 9,G1 的完全 GC 使用单个线程执行。Java 10 支持 G1 的并行完全 GC。
GC 接口的创建是对 HotSpot 内部代码的纯重构。它通过引入一个干净的 GC 接口来隔离 GC 的源代码。这将使新的 HotSpot 开发者能够找到 GC 代码,并使 GC 开发者能够开发新的 GC。
在本章中,我们将学习以下主题:
-
GC 接口
-
G1 的并行完全 GC
技术要求
要使用本章中的代码,你应该在你的系统上安装了 JDK 版本 10 或更高版本。
本章中的所有代码都可以通过以下 URL 访问:github.com/PacktPublishing/Java-11-and-12-New-Features
。
让我们从 GC 接口开始吧。
GC 接口
想象一下,如果你是一个正在开发新 GC 的开发者?或者,一个 HotSpot 开发者(不是 GC 开发者)正在修改现有的 GC 代码?在 JEP 304 或 Java 10 之前,你将会有一个艰难的生活,因为 GC 代码散布在 HotSpot 源代码的各个地方。
JDK 增强提案(JEP)304 的目标是通过引入 GC 接口来提高 GC 的源代码隔离。这提供了许多好处。
利益
通过隔离 GC 源代码,HotSpot 内部 GC 代码的组织更好,符合基本的设计原则,即推荐代码模块化和组织。一个干净的 GC 接口将帮助开发者轻松地将新的 GC 添加到 HotSpot。GC 代码的隔离也使得从特定的 JDK 构建中排除 GC 变得更加容易。
它不会添加任何新的 GC 或删除现有的一个。
GC 代码隔离和 GC 接口使得从 JDK 构建中排除 GC 变得更加容易。
驱动因素
想象一下,如果你是一个 GC 开发者,你应该知道所有可以找到 HotSpot 中 GC 代码的地方。如果这还不够可怕,想象一下如果你不知道如何将其扩展到你的特定需求会是什么感觉。或者,想象一下,如果你是一个 HotSpot 开发者(而不是 GC 开发者),你似乎找不到 GC 的特定代码。我们还没有结束——现在想象一下,你必须排除构建时的特定 GC。这些情况在以下图中表示:
上述用例展示了推动代码库变化的主要因素——通过推动创建一个干净的 GC 接口。
尽管 GC 的代码在其各自的目录中定义(例如,src/hotspot/share/gc/g1
存储了 G1 GC 的代码),但一些代码是在这些目录之外定义的。一个典型的例子是大多数 GC 所需的屏障。由于屏障代码是在 C1 和 C2 运行时解释器中实现的,因此这些代码定义在定义 C1 和 C2 代码的目录中。然而,这导致 GC 代码碎片化,难以追踪和定位。
GC 接口引入了一层抽象,将跟踪所有 GC 代码的负担从开发者(包括 GC 和 HotSpot)身上移开。
影响
GC 接口将影响其他 JavaServer Pages (JSPs)。它将有助于弃用 并发标记清除 (CMS) 垃圾收集器 (GC) (JEP 291)。使用 GC 接口,GC 可以在基础代码中隔离。
GC 接口还将有助于降低在 HotSpot 中引入新 GC 的影响。例如,它将有助于降低 Shenandoah (JEP 189) GC 所做的更改的侵入性。
在下一节中,我们将看看 G1 GC 的变化是如何帮助使应用程序更响应的。
G1 的并行完全 GC(JEP 307)
想象一下,你被要求独自打扫你家的每一个角落,没有任何帮助。在这种情况下,你的房子将保持多长时间无法进入(因为你不想在打扫过程中有任何干扰)?
现在将你自己与一个单线程相比,将你的房子与分配给 JVM 的内存相比。如果一个单线程执行完全 GC,你的应用程序将见证最坏情况的延迟。
G1 GC 在 Java 9 中被设置为默认 GC,但完全 GC 使用单个线程。随着 JEP 307,Java 10 使完全 G1 GC 并行化,以改善应用程序的延迟。
让我们快速了解一下 G1 的细节,以便 JEP 307 对您更有意义。
G1 GC 的设计目标
G1 GC 被设计为避免完全 GC 收集。G1 的一个主要设计目标是向 停止世界的 GC 暂停的持续时间和分布添加可预测性和可配置性。
例如,使用 G1 GC,你可以在一个 y 毫秒的时间范围内指定停止世界的暂停不应超过 x 毫秒。一个真实的例子是,指定 G1 GC 的停止世界暂停不应超过每 70 秒 8 毫秒。G1 GC 将尽力达到这个性能目标。
然而,您配置这些值的方式可能与 G1 GC 的实际暂停时间不匹配。
停止世界 GC 暂停指的是 JVM 应用程序在 GC 标记或清理内存时不允许任何更改时变得无响应的状态。
G1 内存
G1 将内存划分为区域——即伊甸、幸存者和老年代区域——这通常在计数上约为 2,048(或尽可能接近这个数字)。区域是用于存储不同代对象的内存空间,不需要它们连续分配。每个区域的大小取决于内存大小。所有伊甸和幸存者区域合在一起被称为年轻代,所有老年代区域合在一起被称为老年代。区域的大小计算为X的2次方,其中X介于 1 MB 和 64 MB 之间。G1 还定义了用于大对象的巨无霸内存区域,其大小大于伊甸区域大小的 50%。由于这些区域不是连续分配的,以下是使用 G1 GC 时内存可能的样子:
新对象分配在伊甸区域。在年轻收集期间,G1 GC 将活动对象从伊甸区域移动到幸存者区域。如果幸存者区域的对象存活时间足够长(这是通过使用XX:MaxTenuringThreshold
指定的),或者直到它们被收集,它们将被移动到老区域。
年轻收集从伊甸区域收集、清除和压缩对象到幸存者区域。混合收集,正如其名所示,将包括一组伊甸和老年代区域。混合收集通过快速频繁地收集对象,以便伊甸和幸存者区域能够尽快释放。
当一个对象的大小超过一个区域大小的 50%时,它会被分配到巨无霸区域——这是老年代区域中一系列连续的区域。巨无霸区域不会分配在伊甸区域中,以保持复制或清除它的成本较低。
当年轻和混合收集无法回收足够的内存时,就会发生完全 GC。这包括标记活动对象、准备压缩、调整指针以及压缩堆。
本节仅概述了 G1 GC 中内存的组织和回收情况,以便您能理解本节的内容。对于更详细的信息,您可以参考www.oracle.com/technetwork/tutorials/tutorials-1876574.html
。
让我们用一个示例代码来工作,这个代码除了触发年轻和混合收集之外,还会触发完全垃圾回收(GC),最终由于堆空间耗尽和无法分配更多对象而退出 JVM。
示例代码
以下示例代码分配了一个大小为999999
的图像数组,并将图像加载到其中。然后,代码检索图像字节并将它们存储到一个大小为999999
的字节数组中。
在 3,044 MB 的堆大小和 1 MB 的区域大小下,此代码将强制执行完全 G1 GC,并最终因OutOfMemoryError
而关闭 JVM:
import java.io.*;
import java.awt.image.BufferedImage;
import javax.imageio.ImageIO;
public class TriggerG1FullGC {
final BufferedImage[] images = new BufferedImage[999999];
final byte[][] imgByte = new byte[999999][];
public TriggerG1FullGC() throws Exception {
for (int i = 0; i < 999999; i++) {
images[i] = ImageIO.read(new File("img.jpg"));
}
System.out.println("Images read");
for (int i = 0; i < 999999; i++) {
ByteArrayOutputStream baos=new ByteArrayOutputStream();
ImageIO.write(images[i], "jpg", baos );
imgByte[i] = baos.toByteArray();
}
System.out.println("Bytes read");
}
public static void main(String... args) throws Exception {
new TriggerG1FullGC();
}
}
您可以使用以下命令执行前面的代码:
> java -Xlog:gc* -Xlog:gc*:myG1log.log TriggerG1FullGC
前面的代码将输出 GC 日志到控制台(由-Xlog:gc*
提供便利)。它还将日志存储到myG1log.log
文件中。代码(正如我们预期的)将以OutOfMemoryError
失败。让我们检查 GC 日志文件的内容。
从 Java 9 开始,G1 是默认的 GC。因此,前面的代码没有使用任何运行时选项来特别使用 G1。
理解 G1 GC 日志
在本节中,我们将详细查看 G1 GC 日志。
在以下截图中的以下功能被标记:
-
每个堆区域的大小为,1M。
-
JVM 正在使用 G1 作为其 GC。
-
G1 收集器在启动应用程序执行后 0.309 秒开始年轻收集。
-
G1 收集器使用多个线程进行年轻收集。
-
G1 收集器将 14 个 Eden 区域中的存活对象移动到 2 个 Survivor 区域:
让我们检查同一 GC 日志的另一部分,如下所示:
前一个截图中的日志是同一 GC 收集的一部分(注意日志中的 GC(5))。它显示了 G1 GC 进行的另一个年轻收集的日志。我已经突出显示了收集器工作的 Eden、Survivor、Old 和 Humongous 区域。箭头左侧的值显示收集前的区域数量,右侧的值是 GC 后的区域数量。
在 JVM 因OutOfMemoryError
退出之前,让我们检查 G1 日志的最后部分,如下所示:
-
收集使用多个线程进行完全收集。
-
完全 GC 开始。
-
完全 GC 包括多个步骤,包括标记存活对象、准备压缩、调整指针和压缩堆。
-
如您将注意到的,没有更多的 Eden 区域和 Survivor 区域可用于分配和压缩(0 -> 0)。Old 和 Humongous 区域包含无法回收的存活对象。因此,JVM 以
OutOfMemoryError
错误关闭。 -
此信息记录了完全 GC 的实际耗时:
前一个截图的底部包括一些最终统计数据,包括总堆大小、已用堆大小、区域大小等。
摘要
在本章中,我们介绍了两个对 GC 领域带来改进的 JEP。JEP 304 通过引入 GC 接口来提高 GC 的源代码隔离性。它组织了 HotSpot 内部 GC 代码,使 GC 开发者能够轻松地将新的 GC 添加到 HotSpot 中,并使得从特定的 JDK 构建中排除 GC 变得更加容易。JEP 307 通过使全 G1 GC 并行化来提高应用程序的最坏情况延迟。
在下一章中,我们将介绍 Java 10 中的多个较小的新增和修改。
第四章:JDK 10 的其他改进
在 Java 9 之前,Java 的新版本每三年(平均)发布一次。Java 9 的发布时间表发生了变化,采用了六个月的发布周期。Java 10 在 Java 9 发布后的六个月内发布。我们已经在前三章中介绍了 Java 10 的主要功能;第一章,类型推断,第二章,AppCDS,和第三章,垃圾收集器优化。
在本章中,我们将涵盖 Java 10 剩余的添加或更新,其中大部分与 JDK 或其实现的变化有关。我们还将涵盖对 Java API 的一些添加和修改。
在本章中,我们将涵盖以下主题:
-
线程局部握手
-
基于时间的发布版本号
-
将 JDK 森林合并为一个单一存储库
-
在替代内存设备上进行堆分配
-
额外的 Unicode 语言标记扩展
-
根证书
-
基于实验性 Java 的 JIT 编译器
-
移除原生头生成工具
技术要求
本章将概述与 JDK 或其实现相关的 JDK 10 功能。本章将不包括任何供您使用的代码。
由于本章涵盖了 Java 10 的多个功能,让我们快速将功能与其JDK 增强提案(JEP)编号和范围进行关联。
将 JDK 10 功能与范围和 JEP 关联
下表列出了本章将涵盖的 JDK 10 功能,功能的对应 JEP 编号以及它们的范围:
JEP No. | Scope | Description |
---|---|---|
296 | 实现 | 将 JDK 森林合并为一个单一存储库 |
312 | JDK | 线程局部握手 |
313 | JDK | 移除原生头生成工具(javah ) |
314 | SE | 额外的 Unicode 语言标记扩展 |
316 | JDK | 在替代内存设备上进行堆分配 |
317 | JDK | 基于实验性 Java 的 JIT 编译器 |
319 | JDK | 根证书 |
322 | SE | 基于时间的发布版本号 |
让我们从第一个功能开始。
将 JDK 森林合并为一个单一存储库
到 Java 9 为止,JDK 的代码库使用了多个存储库(Java 9 中有八个存储库——root
,corba
,hotspot
,jaxp
,jaxws
,jdk
,langtools
和nashorn
)。合并 JDK 森林的目标是将 JDK 使用的多个存储库合并为一个单一存储库。
随着 JDK 代码库多年来的增长,它故意存储在单独的存储库中,以实现关注点的分离。然而,随着 JDK 的发展,代码库在不同存储库之间也发展出了相互依赖性。
这些多个仓库提供的优势已经超过了它们的维护劣势。对于相互依赖的变更集,你无法对仓库执行单个提交。有些情况下,即使是单个(且简单的)错误修复的代码也跨越了多个仓库。在这种情况下,提交无法原子化执行。一种常见的方法是在多个仓库中使用相同的 bug ID。但这并不是强制性的,因为使用相同的 bug ID 不是强制性的,不同仓库中相同 bug 的提交可以使用不同的 bug ID。这可能导致跟踪错误修复的困难。
此外,各个仓库没有独立的发展轨道和发布周期。Java 有一个主要的发布周期,包括所有这些仓库的变化。因此,将 JDK 代码库集成到一个仓库中,以简化其维护,已经势在必行。
这是一个维护功能,不会影响你编写代码的方式。
Thread-local handshakes
假设你需要暂停一个特定的线程,并在其上执行回调。在 thread-local handshakes 之前,没有方法可以做到这一点。通常的做法是执行全局 VM safepoint,这将暂停所有正在执行的线程(如果你只想暂停一个线程,这无疑是一种浪费)。有了 thread-local handshakes,可以停止单个线程。
通过旨在减少全局 VM safepoints,thread-local handshakes 将减少 JVM 延迟并提高其效率。
Thread-local handshakes 是 JVM 实现的一个特性,开发者不能直接使用。
移除 Native-Header 生成工具(javah)
JEP 的这个版本已经从 JDK 附带工具中移除了javah
工具。
假设你需要你的类的实例在 C 的本地代码中被引用。开发者们使用javah
工具从 Java 类生成 C 的头文件和源文件。生成的代码用于使本地代码(比如用 C 编写的代码)能够访问你的 Java 类的实例。javah
工具创建一个.h
文件,它定义了一个struct
,类似于你的类的结构。对于源文件中的多个类,javah
工具会生成单独的.h
文件。
移除javah
并不意味着你的 Java 类在本地代码中的使用量有所下降。
在 Java 8 中,javah
被增强以承担生成 C 头文件和源代码文件的责任。经过两个版本的测试后,javah
将从 Java SE 10 中移除。
javah
工具的移除由javac
中的高级编译选项所补偿,这些选项可以用来生成 C 头文件和源文件。
额外的 Unicode 语言标签扩展
此功能增强了java.util.Locale
及其相关 API,以实现 BCP 47 语言标签的附加 Unicode 扩展。对 BCP 47 语言的支持添加到了 JDK 7。然而,在 JDK 7 中,对 Unicode 区域扩展的支持仅限于日历和数字。此功能允许向区域添加扩展。JDK 9 添加了对ca
和nu
标签的支持,这些标签来自 BCP 47。
JDK 10 添加了对以下扩展的支持:
-
cu
(货币类型) -
fw
(一周的第一天) -
rg
(区域覆盖)
当你指定一个区域扩展,如数字或货币类型时,并不能保证底层平台支持所请求的扩展。Unicode 标签扩展是一种语言特性,开发者可以直接使用。
在替代内存设备上的堆分配
当 JVM 耗尽其堆内存时,你的应用程序会因OutOfMemoryException
而崩溃。想象一下,如果你能配置你的 JVM 使用替代内存设备,比如一个非易失性双列内存模块(NV-DIMM)。
随着处理大量数据的应用程序对内存需求的不断增长,以及低成本 NV-DIMM 内存的可用性,能够使用替代内存设备进行堆分配是一种幸福。这也导致了使用异构内存架构的系统。
此增强针对具有与动态随机存取存储器(DRAM)相同语义的替代内存设备,以便它们可以替代 DRAM 使用,而无需对现有应用程序代码进行任何更改。所有其他内存结构,如栈、代码堆等,将继续使用 DRAM。
在我们继续前进之前的一个快速细节——NV-DIMM 与 DRAM 相比,访问延迟更高。但与 DRAM 相比,NV-DIMM 具有更大的容量且成本更低。因此,低优先级的过程可以使用 NV-DIMM,而高优先级的过程可以使用 DRAM 内存。
堆分配是 JVM 实现细节,开发者不能直接使用。
实验性的基于 Java 的 JIT 编译器
你至今所使用的 Java 编译器通常是用 C/C++编写的。你会怎么想一个用 Java 编写的 Java 编译器?
Graal,一个基于 Java 的即时编译器(JIT),在 Java 9 中引入。Java 10 使 Linux/x64 平台上的 Graal 作为实验性 JIT 编译器可用。最终,Oracle 将探索使用 Graal 作为基于 Java 的 JIT 编译器用于 JDK 的可能性。
Graal 使用 JDK 9 中引入的 JVM 编译器接口。Graal 的目标不是与现有的 JIT 编译器竞争。它是 Metropolis 项目的一部分,该项目探索和孵化 HotSpot(JVM 的开放 JDK 实现)的 Java-on-Java 实现技术。
由于 Graal 是用 Java 编写的,使用 Graal 的担忧与应用程序较低的启动性能和增加的堆使用量有关。
以下命令行编译器选项可用于启用 Graal 作为您的 JIT 编译器:
-XX:+UnlockExperimentalVMOptions -XX:+UseJVMCICompiler
Graal 是一个实验性的 JIT 编译器,可以通过命令行选项进行配置以供使用。
根证书
想象一下在云存储服务提供商上配置一个账户,使用您的系统。云接口可以请求证书及其值存储在您的系统中。当重新连接到云服务时,您的系统会自动使用证书对其进行身份验证。
使用 JDK,此类证书存储在 cacerts
密钥库中。证书文件 cacerts
位于 JDK 安装目录的安全目录中,代表适用于系统级密钥库的 认证机构(CA)证书,如下所示:
-
Windows:
JAVA_HOME\lib\security
-
Linux、Solaris 和 macOS X:
JAVA_HOME/lib/security
根证书用于在各个安全协议中建立对证书链的信任。问题是 cacerts
密钥库在 JDK 源代码中没有任何证书,这对于 OpenJDK 构建中安全组件(如 TLS)的默认功能是必需的。
使用根证书,Oracle 计划弥合 OpenJDK 构建与 OracleJDK 构建之间的差距。用户必须将一组根证书填充到 cacerts
密钥库中,以弥合这一差距。
计划在 JDK 中提供一组默认的根 CA 证书,并将 Oracle 的 Java SE Root CA 程序中的根证书开源。
根证书可以由 Oracle 的 Java SE Root CA 程序的 CA 发布。
根证书是 JDK 的一个功能。
基于时间的发布版本控制
基于时间的发布版本控制功能修订了 JDK SE 平台的版本字符串方案,适用于当前和未来的基于时间的发布版本。
建议的格式如下:
$FEATURE.$INTERIM.$UPDATE.$PATCH
以下是在前述字符串中使用的元素详情:
-
$FEATURE
:由于新的(且严格的)六个月发布节奏,此值每六个月增加一次。2018 年 3 月发布的 JDK 版本是 10,2018 年 9 月发布的版本是 JDK 11。2019 年 3 月发布了 JDK 12,以此类推。 -
$INTERIM
:由于六个月一次的发布节奏,没有临时发布。然而,此元素保留以供未来潜在的使用场景。 -
$UPDATE
:此元素表示更新,用于兼容更新发布,以修复安全漏洞、回归和新功能中的错误。此值在$FEATURE
增量后一个月增加,之后每三个月增加一次。 -
$PATCH
:此元素表示紧急补丁发布计数器,用于针对关键错误的紧急发布。
摘要
在本章中,您浏览了 JDK 10 的各种新增和修改,但排除了其主特性,如类型推断、应用程序类数据共享和垃圾收集器优化。
本章中介绍的大部分功能都与 JDK 的变化有关,包括通过线程局部握手减少全局 VM 安全点、移除 javah
、使用替代内存设备进行堆分配、Graal 和根证书。它包含较少的 SE 功能——额外的 Unicode 语言标签扩展和基于时间的发布版本编号。将 JDK 森林合并为单个存储库更多是一个日常维护细节。
在下一章中,我们将探讨 JDK 11 的新增功能和修改。我非常兴奋,也希望你们也是一样!
第二部分:JDK 11
本节从对 lambda 参数的讨论开始,接着介绍 Epsilon GC 的更多信息,该 GC 在 Java 11 中引入。接下来,我们将讨论 HTTP 客户端 API,通过该 API,您的 Java 代码可以在网络上请求 HTTP 资源。Java 11 中引入的 ZGC 也将被详细讨论。继续前进,我们将了解 Java 飞行记录仪分析器和用于记录和分析数据以进行故障排除的任务控制工具。最后,我们将查看 JDK 11 的剩余功能。
本节将涵盖以下章节:
-
第五章,Lambda 参数的局部变量语法
-
第六章,Epsilon GC
-
第七章,HTTP 客户端 API
-
第八章,ZGC
-
第九章,飞行记录仪和任务控制
-
第十章,JDK 11 中的其他改进
第五章:Lambda 参数的局部变量语法
Java 通过扩展保留类型var
在 lambda 参数中的使用来增强其语言。这种增强的唯一目的是使 lambda 参数的语法与使用var
声明局部变量的语法保持一致。自 lambda 引入以来,Java 8 的编译器已经推断出隐式类型化 lambda 表达式的参数类型。
在本章中,我们将涵盖以下主题:
-
隐式和显式类型化的 lambda 参数
-
如何使用
var
与 lambda 参数一起使用 -
使用 lambda 参数的注解
技术要求
要执行本章中的代码,你必须在你的系统上安装 JDK 11(或更高版本)。本章中的所有代码都可以在github.com/PacktPublishing/Java-11-and-12-New-Features
找到。
Lambda 表达式
lambda 表达式是一个匿名函数,可以接受输入参数并返回一个值。lambda 表达式可以指定所有(或没有)输入参数的类型;lambda 表达式可以是显式类型化的或隐式类型化的。
显式类型化的 lambda 表达式
明确指定所有输入参数类型的 lambda 表达式被称为显式类型化的 lambda 表达式。以下代码展示了几个示例(输入参数以粗体显示):
(Integer age) -> age > 10; // input Integer,
// return Boolean
(Integer age) -> age > 10? "Kid" : "Not a Kid"; // input Integer,
// return String
(Integer age) -> {System.out.println();}; // input Integer,
// return void
() -> {return Math.random() + "Number";}; // input none,
// return String
(String name, List<Person> list) -> {
return (
list.stream()
.filter(e ->
e.getName().startsWith(name))
.map(Person::getAge)
.findFirst()
);
}; // input name,
// List<person>
// return
// Optional<Integer>
在所有前面的示例中,代码明确地定义了传递给它的所有参数的类型。如果一个 lambda 表达式不接受任何参数,它使用一对空圆括号(()
)。
如果这让你想知道 lambda 表达式将被分配给哪些变量的类型,以下是完整的代码供你参考:
Predicate<Integer> predicate = (Integer age) -> age > 10;
Function<Integer, String> function = (Integer age) -> age > 10? "Kid" :
"Not a Kid";
Consumer<Integer> consumer = (Integer age) -> {
System.out.println();
};
Supplier<String> supplier = () -> {
return Math.random() +
"Number";
};
BiFunction<String, List<Person>,
Optional<Integer>> firstElement = (String name, List<Person> list) -> {
return (
list.stream()
.filter(e ->
e.getName().
startsWith(name))
.map(Person::getAge)
.findFirst()
);
};
class Person {
int age;
String name;
String getName() {
return name;
}
Integer getAge() {
return age;
}
}
隐式类型化的 lambda 表达式
没有指定任何输入参数类型的 lambda 表达式被称为隐式类型化的 lambda 表达式。在这种情况下,编译器推断方法参数的类型并将其添加到字节码中。
让我们修改上一节中的 lambda 表达式,去掉输入参数的类型(修改后的代码以粗体显示):
(age) -> age > 10;
(age) -> age > 10? "Kid" : "Not a Kid";
age -> {System.out.println();};
() -> {return Math.random() + "Number";};
(name, list) -> {
return (
list.stream()
.filter(e -> e.getName().startsWith(name))
.map(Person::getAge)
.findFirst()
);
};
你不能在 lambda 表达式中混合隐式类型化和显式类型化的参数。例如,以下代码无法编译,因为它明确指定了x
的类型,但没有指定y
的类型:
(Integer x, y) -> x + y; // won't compile
Lambda 参数和 var 的类型推断
在 JDK 11 中,你将能够使用 var
与 lambda 参数一起使用。然而,这仅仅是一种语法糖。保留类型名 var
在 JDK 10 中被引入,以便开发者可以在不使用显式数据类型的情况下声明局部变量(让编译器在编译期间推断数据类型)。但是,隐式类型 lambda 表达式已经通过仅使用变量名作为它们的参数(而不使用它们的类型)来实现这一点(示例包括在上一节中)。
向 lambda 参数添加 var
Java 允许使用保留词 var
与 lambda 参数一起使用,以使其语法与局部变量的声明对齐,现在这些局部变量可以使用 var
。
让我们修改上一节中的示例,向 lambda 参数添加 var
:
(var age) -> age > 10;
(var age) -> age > 10? "Kid" : "Not a Kid";
(var age) -> {System.out.println();};
() -> {return Math.random() + "Number";};
(var name, var list) -> {
return (
list.stream()
.filter(e ->
e.getName().startsWith(name))
.map(Person::getAge)
.findFirst()
);
};
允许将 var
添加到 lambda 参数中的主要原因是为了使其使用与使用 var
声明的局部变量的语法对齐。
如果你使用 var
与 lambda 参数,你必须与所有 lambda 参数一起使用它。你不能将隐式类型或显式类型参数与使用 var
的参数混合。以下代码示例无法编译:
(var x, y) -> x + y; // won't compile
(var x, Integer y) -> x + y; // won't compile
如果你只使用一个方法参数,你不能使用圆括号(()
)来包围 lambda 表达式的参数。但是,如果你在 lambda 参数中使用 var
,你不能省略 ()
。以下是一些示例代码,以进一步说明这一点:
(int x) -> x > 10; // compiles
(x) -> x > 10; // compiles
x -> x > 10; // compiles
(var x) -> x > 10; // compiles
var x -> x > 10; // Won't compile
你不能将隐式类型或显式类型 lambda 参数与使用 var
的参数混合。
向 lambda 参数添加注解
如果你使用显式数据类型或使用保留类型 var
定义 lambda 参数,你可以使用注解。注解可以用来标记 null 或非 null 的 lambda 参数。以下是一个示例:
(@Nullable var x, @Nonnull Integer y) -> x + y;
摘要
在本章中,我们介绍了使用保留类型 var
与隐式类型 lambda 表达式。我们首先确定了显式类型和隐式类型 lambda 表达式的语法差异。
通过示例,你看到了如何将 var
添加到 lambda 参数中只是语法糖,因为自从它们在 Java 8 中引入以来,你一直能够使用类型推断来使用隐式类型 lambda 参数。使用 var
与 lambda 参数使它们的语法与使用 var
声明的局部变量对齐。使用 var
还使开发者能够使用注解与 lambda 参数。
在下一章中,我们将处理 HTTP 客户端 API,该 API 将作为 Java 11 的核心 API 之一被添加。
第六章:Epsilon GC
想象一下,一个软件组织用不会编码的程序员替换了原来的程序员,以此来计算他们耗尽资金并关闭需要多长时间。在这种情况下,没有产生新的收入,而员工工资仍在继续。以类似的方式,当您使用在 Java 11 中引入的 Epsilon 垃圾回收器(GC)时,软件应用程序用 Epsilon 替换其 GC,它不会释放内存——以计算 Java 虚拟机(JVM)耗尽所有内存并关闭需要多长时间。
Epsilon 是一个 无操作(no-op)GC——也就是说,它不会收集任何垃圾。它只处理内存分配。当可用的 Java 堆耗尽时,JVM 关闭。
如果这个 GC 对您来说看起来很奇怪,请再想想。Epsilon GC 被添加为基准,以测试应用程序的性能、内存使用、延迟和吞吐量改进。
在本章中,我们将涵盖以下主题:
-
为什么需要 Epsilon
-
Epsilon 的特性
-
使用 Epsilon 的示例
-
Epsilon 的用例
技术要求
要使用本章中的代码,您应该在您的系统上安装 JDK 版本 11 或更高版本。
本章中所有的代码都可以通过以下网址访问:github.com/PacktPublishing/Java-11-and-12-New-Features
。
让我们从探讨为什么我们需要一个不收集任何垃圾的 GC 开始。
Epsilon GC 的动机
您可能见过海报声称到 2050 年,我们海洋中的塑料将比鱼还多。 我们还在 2019 年。那么,海洋分析师是如何预测 2050 年的情况的呢?可能有多种方法。也许他们只是假设塑料被添加而没有清理,或者也许他们只是使用当前塑料污染增加的速率,或者他们可能应用了另一种算法。
这句话的本质是,通过得出一个数字(例如 2050 年),他们可以传播关于海洋塑料污染增加的意识。通过声称将有 比鱼更多的塑料,他们可以吸引大众的注意,并鼓励他们现在就开始工作。人们害怕最坏的情况;如果你向他们展示一个令人担忧的情况,他们更有可能做出反应(按照建议的方式)。
同样,您可以使用 Epsilon GC 来预测您应用程序的性能能力。因为 Epsilon GC 可以测量和调整应用程序的性能、内存使用、延迟和吞吐量,而无需回收分配的内存。如果您想对应用程序性能进行基准测试,Epsilon GC 是您的工具。
Epsilon GC 用于将其他 GC 与您的应用程序在多个因素上进行基准测试,例如性能和内存使用,以优化您的应用程序并使用最适合您项目的最佳 GC。
Epsilon 的特性
Epsilon 不会清除未使用对象的堆内存;它只分配内存。当 JVM 内存不足时,它会通过抛出 OutOfMemoryError
错误来关闭。如果启用了堆转储,Epsilon 将在抛出 OutOfMemoryError
错误后执行堆转储。
Epsilon GC 使用简单的、无锁的 线程局部分配缓冲区(TLAB)分配。这是通过以线性方式分配内存的连续部分来实现的。TLAB 分配的主要好处之一是它将进程绑定到已分配给它的内存。
Epsilon GC 使用的屏障集完全为空,因此得名,无操作。由于它不回收内存,因此它不需要担心维护对象图、对象标记、对象压缩或对象复制。
您会使用 Epsilon 产生任何延迟开销吗?是的,这是可能的。当 Epsilon 分配内存时,您的应用程序可能会遇到延迟开销——即如果内存大小太大,并且正在分配的内存块也很大。
延迟与应用程序性能
想象一下,您的应用程序每秒处理数千条消息。在这种情况下,即使是一毫秒或更长时间的延迟也可能对您的系统性能产生影响。最糟糕的是,您甚至不知道何时 GC 会启动并开始收集。
我有一个建议;以不会收集任何垃圾的方式使用 Epsilon GC 执行您的应用程序作为基准。现在使用您选择的 GC 执行您的应用程序,并分析日志。现在您可以过滤出由于 GC(如 GC 工作者调度、GC 屏障成本或 GC 周期)引起的延迟。您的系统性能也可能由于与 GC 无关的问题而受到影响——例如操作系统调度或编译器问题。
GC 引起的开销与系统开销
GC 周期存在延迟开销。对于关键应用,这可能会影响期望的吞吐量。通过仔细选择多个参数的组合,例如堆大小、分配单元、GC 周期持续时间、区域大小以及各种其他参数,您可以在各种 GC(包括 Epsilon)之间比较您应用程序的性能。
然而,开销也可能由系统引起,这与 GC 无关。使用多个 GC 执行您的应用程序将使您能够将 GC 引起的开销与系统开销分开,并选择最适合您应用程序的 GC。
通过使用无操作 GC(如 Epsilon),您可以过滤出由于 OS/编译器问题引起的 GC 引起的性能问题。
极短生命周期的任务
想象一下,您需要创建一个极短生命周期的应用程序。当您退出应用程序时,JVM 关闭,所有堆内存都会被回收。由于执行 GC 周期需要一些时间(尽管尽可能少),您可能会考虑使用 Epsilon GC 与您的应用程序一起使用。
让我们通过使用一些示例代码来使用 Epsilon GC 开始吧。
开始使用 HelloEpsilon GC 类
让我们编写一个 HelloEpsilon
类,并使用 Epsilon GC 执行它。以下是示例代码:
class HelloEpsilonGC {
public static void main(String[] args) {
System.out.println("Hello to Epsilon GC!");
}
}
要使用 Epsilon GC 执行前面的代码,您必须使用 -XX:+UnlockExperimentalVMOptions
选项和 -XX:+UseEpsilonGC
选项,然后是执行类:
>java -XX:+UnlockExperimentalVMOptions -XX:+UseEpsilonGC
HelloEpsilonGC
以下截图突出显示了前面的命令在顶部;其余部分包括 GC 输出:
如前一个截图所示,以下描述了 GC 输出:
-
JVM 使用 Epsilon GC
-
将
Hello to Epsilon GC!
字符串输出到标准输出 -
它只包括一个
792 KB
的分配
Epsilon 的字面意思是任意小的量。这与它的操作码相一致。
最近,我在讲解 Java 11 特性时,一位与会者提出了一个问题。他问道:
我明白 Epsilon GC 不收集 Java 堆,但它是否收集栈内存?
我认为这是一个重要的问题。我们通常只有在需要时才关心细节。
如果你知道答案,这个问题可能对你来说微不足道。如果不知道,让我们在下一节中回答这个简单而重要的问题。
GC 收集哪个内存区域——栈还是堆?
JVM 定义了不同的堆栈和堆内存管理机制。所有 GC 通过回收未使用或未引用的对象占用的空间来清理堆内存区域。GC 不从栈内存中回收空间。
栈内存区域由当前线程用于方法的执行。当当前线程完成当前方法的执行时,栈内存被释放(不涉及 GC 收集)。原始数据存储在栈内存上。
在方法中,局部原始变量和对对象的引用(不是实际的对象)存储在栈上。实际的对象存储在堆上。在方法执行后可访问的局部原始变量和引用变量定义在堆内存区域。本质上,以下情况发生:
-
所有对象都存储在堆上。
-
局部对象引用存储在栈上;实例或静态对象引用存储在堆上。
-
局部原始变量及其值存储在栈上。实例或静态原始变量及其值存储在堆上。
让我们来看一些简单的用例,在这些用例中,Epsilon GC 可以帮助您提高应用程序的性能、内存使用、延迟和吞吐量。
使用 Epsilon 进行内存压力测试
假设你不想你的应用程序,或者说,你应用程序的一部分使用超过一定数量的堆内存,比如 40 MB。你怎么断言这一点?在这种情况下,你可以配置使用-Xmx40m
来执行你的应用程序。以下是一些示例代码,它将相同的字符串值作为键/值对添加到HashMap
中,然后通过多次迭代(精确到1_000_000
)来删除它:
import java.util.*;
class EpMap {
public static void main(String args[]) {
Map<String, String> myMap = new HashMap<String, String>();
int size = 1_000_000;
for(int i = 0; i < size; i++) {
String java = new String("Java");
String eJavaGuru = new String("eJavaGuru.com");
myMap.put(java, eJavaGuru);
myMap.remove(java);
}
}
}
你可以使用以下命令执行它:
> java -XX:+UnlockExperimentalVMOptions -XX:+UseEpsilonGC -Xlog:gc*
-Xmx40M EpMap
前面的代码会因OutOfMemoryError
而退出,如下面的截图所示:
让我们回顾一下生成此输出的代码,并检查我们是否可以对此做些什么。目前,代码使用new
运算符创建相同的字符串值。让我们看看如果我将它修改为使用字符串池会发生什么,如下所示:
import java.util.*;
class EpMap {
public static void main(String args[]) {
Map<String, String> myMap = new HashMap<String, String>();
int size = 1_000_000;
for(int i = 0; i < size; i++) {
String java = "Java";
String eJavaGuru = "eJavaGuru.com";
myMap.put(java, eJavaGuru);
myMap.remove(java);
}
}
}
在执行此修改后的代码时,尝试执行以下命令:
> java -XX:+UnlockExperimentalVMOptions -XX:+UseEpsilonGC -Xlog:gc*
-Xmx40M EpMap
前面的代码不会因为OutOfMemoryError
而退出,并完成执行,如下面的截图所示:
减少垃圾只是解决内存不足错误的一种方法。为了防止或延迟 GC 周期,你也可以通过调整多个参数来调整你的运行时——例如增加堆大小或设置 GC 周期运行前的最小时间。
这让我想到了一个非常有趣的案例;你能设计一个无垃圾应用程序,也就是说,一个可以永久与 Epsilon 一起工作的应用程序吗?我认为我们已经有几个在生产中并且被开发者使用(如下一节所述)。
设计一个无垃圾应用程序
在哪个内存区域 GC 收集——栈还是堆?部分,我提到 GC 回收堆内存——这可能包括(非局部)原始数据类型或对象。在你的 Java 应用程序中,堆内存可以由以下任一变量或对象使用:
-
您应用程序使用的第三方库
-
JDK API
-
您的应用程序类
有多种方法可以减少垃圾生成——通过优先使用原始数据类型而不是对象,重用缓冲区,使用对象池,丢弃临时对象创建,以及其他方法。
这里有一个证明这是可能的例子。最受欢迎的无垃圾应用程序之一是 Log4j,它是 Apache 的一个日志应用程序,默认情况下以所谓的无垃圾模式运行。这意味着它重用对象和缓冲区,并尽可能避免分配临时对象。它还有一个所谓的低垃圾模式;这种模式并不是完全无垃圾,但它也不使用 ThreadLocal 字段。你可以访问logging.apache.org/log4j/2.x/manual/garbagefree.html
了解更多信息。
VM 接口测试
Java 10 添加了 GC 接口——提供一个干净的 GC 开发接口,以便 GC 开发者和 HotSpot 开发者不会在开发新的 GC 时感到困难,并且可以轻松定位现有 GC 的功能。
在一定程度上,Epsilon 验证了 GC 接口。由于它不回收内存,因此实际上不需要实现那些需要它维护对象以回收、删除或复制它们的那些方法。所以,它可以直接继承默认实现(不应该做任何工作)。由于它有效,Epsilon 有助于测试 VM 接口。
摘要
在本章中,我们介绍了 Epsilon,这是一个无操作 GC,它只分配内存而不释放堆内存。
你可以使用 Epsilon 和其他 GC 来执行你的应用程序,以测量你的应用程序的性能、内存使用、延迟和吞吐量——最终使用最佳可能的组合——调整你的运行时环境并优化你的应用程序。
在下一章中,你将有机会使用 Java 最激动人心的特性之一——HTTP 客户端,该客户端使用反应式流以非同步和非阻塞的方式通过网络访问资源。
第七章:HTTP 客户端 API
使用 HTTP 客户端 API,你的 Java 代码可以通过 HTTP/2 协议以非阻塞和异步的方式请求网络上的 HTTP 资源。它对现有的HttpURLConnection
类进行了重大改进,该类在 Java 1.1 版本中添加,并且仅以阻塞和同步的方式工作。
HTTP 客户端在 Java 9 中被孵化,Java 10 中进行了多次修改,并在 Java 11 中标准化。它位于java.net.http
包和模块中。
在本章中,我们将涵盖以下主题:
-
HTTP 客户端简介
-
同步和异步发送请求
-
将响应字节转换为高级格式
-
使用响应式流处理 HTTP 请求和响应
-
BodyHandler
、BodyPublisher
和BodySubscriber
技术要求
本章中的代码将使用 Java 11 的标准 HTTP 客户端 API 类。如果你使用的是之前 Java 版本(如 9 或 10)的孵化 HTTP 客户端,那么本章中的所有代码将无法按指定方式工作。许多方法名称已经更改。
本章中的所有代码都可以在github.com/PacktPublishing/Java-11-and-12-New-Features
找到。
在深入细节之前,让我们了解一下导致引入这个新 API 以请求 HTTP 资源的问题。
快速回顾
HTTP 客户端 API 在 Java 9 中被孵化。本质上,这意味着这个 API 不是 Java SE 的一部分。它定义在jdk.incubator.httpclient
包中。孵化特性应该明确添加到项目的类路径中。Oracle 通过发布孵化特性来允许开发者使用和实验它们,并提供反馈,这决定了 HTTP 客户端 API 的命运。在未来的 Java 版本中,孵化 API 和特性要么作为完整功能包含,要么完全取消。没有部分包含。
如果你需要快速回顾一下 HTTP,我们在这里提供。
你可以用 HTTP 做什么?
HTTP 是一种在万维网(WWW)上传输超文本(记得<html>
吗?)的协议。如果你已经使用网页浏览器访问过任何网站(可能性很大),那么你已经使用了 HTTP。你的网页浏览器作为系统上的客户端工作,通过网络请求访问资源,如网页或文件。你的网页浏览器使用 HTTP 将请求发送到服务器。请求的资源使用 HTTP 协议从服务器传输到客户端。
最常见的 HTTP 操作是GET
、POST
、PUT
和DELETE
。以下是一些快速示例:
-
想象一下在网站上注册的过程;你填写你的详细信息并提交它们。这是一个
POST
请求,其中表单值不会附加到 URI 上。 -
现在,想象一下在在线门户(比如
www.amazon.co.uk/
)中为您最喜欢的书的详情页面设置书签。您会注意到在问号(?
)之后跟随的 URI 后附加了一系列变量名和值(由&
分隔)。在www.amazon.co.uk/s/ref=nb_sb_noss?url=search-alias%3Daps&field-keywords=mala+oca+8
有一个示例。这是一个GET
请求。 -
PUT
请求用于在服务器上创建或更新一个实体,使用 URI。PUT
请求指的是实体,而POST
请求指的是将处理提交数据的资源。 -
DELETE
请求可以通过将标识 ID 添加到 URI 来删除实体。
如果您无法理解所有的 HTTP 操作,例如 GET
、POST
、PUT
或 DELETE
,请不要担心。随着您对本章的深入,您将能够理解它们。
就像您可以使用网页浏览器通过网络访问资源一样,您也可以使用您的 Java 代码以编程方式访问相同的资源。有多种用例;例如,想象一下连接到一个网站,下载最新的新闻,并将其简单地列出给您的应用程序用户。
更多关于 HTTP/2 的信息可以在 tools.ietf.org/html/rfc7540
查找。
HTTP 客户端 API 的需求
到目前为止,Java 开发者一直在使用 HttpURLConnection
类通过网络请求 HTTP 资源。然而,它有多个缺点,这导致了 HTTP 客户端 API 的发展。
在 JDK 1.1 中引入的 HttpURLConnection
类从未被设计为以异步方式工作;它仅以阻塞模式工作。这与今天我们处理的应用程序和数据的变化性质形成对比。世界正在向响应式编程迈进,它涉及处理实时数据,我们无法承担使用阻塞通信或通过连接发送一个请求或响应。
HttpURLConnection
类对于开发者来说也难以使用;其行为的一部分没有文档记录。HttpURLConnection
的基类,即 URLConnection
API,支持多种协议,其中大多数现在不再使用(例如 Gopher)。此 API 不支持 HTTP/2,因为它是在 HTTP/2 制定之前很久就创建的。
此外,还有类似的先进 API 可用,例如 Apache HttpClient
、Eclipse Netty 和 Jetty 等。现在是时候 Oracle 更新其自己的 HTTP 访问 API,以跟上发展并支持其开发者了。HTTP 客户端的主要目标之一是使其内存消耗和性能与 Apache HttpClient
、Netty 和 Jetty 相当。
HttpURLConnection
无法以异步、非阻塞的方式工作,这是创建 HTTP 客户端的主要原因之一。
现在你已经知道了为什么你需要 HTTP 客户端 API,让我们开始使用它。
HTTP 客户端使用
你可以使用 HTTP 客户端通过网络访问 HTTP 资源,使用 HTTP/1.1 或 HTTP/2,异步发送请求并接受响应,以非阻塞方式。它使用响应式流以异步方式与请求和响应一起工作。
它也可以用于同步发送请求并接收响应。
HTTP 客户端 API 由三个主要类或接口组成:
-
HttpClient
类 -
HttpRequest
类 -
HttpResponse
接口
HttpClient
类用于发送请求并检索相应的响应;HttpRequest
封装了请求资源的细节,包括请求 URI。HttpResponse
类封装了来自服务器的响应。
在 Java 11 中,标准化的 HTTP 客户端定义在 java.net.http
模块和包中。
一个基本示例
在深入到 HTTP 客户端各个类的细节之前,我包括了一个基本示例,让你熟悉发送请求到服务器并使用 HTTP 客户端处理响应。随着我们继续前进,我会添加到这个示例中,详细涵盖 HttpClient
、HttpRequest
和 HttpResponse
。这是为了帮助你获得更大的图景,然后深入细节。
以下示例展示了如何创建一个基本的 HttpClient
实例,使用它来访问由 HttpRequest
封装的 URI,并处理响应,该响应作为 HttpResponse
实例可访问:
// basic HttpClient instance
HttpClient client = HttpClient.newHttpClient();
// Using builder pattern to get a basic HttpRequest instance with just //the URI
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("http://www.ejavaguru.com/"))
.build();
// response instance not created using a builder.
// HttpClient sends HttpRequests and makes HttpResponse available
HttpResponse<String> response = client.send(request,
HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
在前面的代码中,newHttpClient()
工厂方法返回一个基本的 HttpClient
实例,该实例可用于发送 HTTP 请求并接收其相应的响应。HttpRequest
通过传递要连接的 URI(这是最低要求)使用构建器模式创建。HttpResponse
实例不是由开发者显式创建的,而是在从 HttpClient
发送请求到服务器后接收到的。
send()
方法以同步方式发送请求并等待响应。当客户端收到响应代码和头信息时,在接收到响应体之前,它将调用 BodyHandler
。在调用时,BodyHandler
创建 BodySubscriber
(一个响应式流订阅者),它接收来自服务器的响应数据流并将它们转换为适当的更高层次的 Java 类型。
如果你没有完全理解前面的解释,不要担心;我将在以下部分中详细说明。
HTTP 客户端使用响应式流(BodyPublisher
和 BodySubscriber
)以异步和非阻塞方式发送和接收数据流。建议对响应式流有基本了解,以便理解 HTTP 客户端如何使用它们发送和接收数据。
让我们深入细节,从 HttpClient
类开始。
HttpClient
类
HttpClient
类用于发送请求并接收响应。它封装了诸如使用哪个版本的 HTTP 协议、是否遵循重定向(如果您尝试连接的资源已移动到另一个位置)、是否使用代理或验证器等细节。HttpClient
类用于配置客户端状态(HttpClient
从客户端发送数据到服务器并接收数据)。HttpClient
实例可以用来发送多个请求并接收它们相应的响应。然而,一旦创建,HttpClient
实例就是不可变的。
创建 HttpClient 实例
您可以通过两种方式创建 HttpClient
实例:使用其静态 getHttpClient()
方法,或使用 newBuilder()
方法(这遵循构建器模式)。
静态 getHttpClient()
方法返回具有基本或默认设置的 HttpClient
实例,如下所示:
HttpClient client = HttpClient.newHttpClient();
要添加自定义设置,您可以使用其 newBuilder()
方法,该方法遵循构建器设计模式并调用相关方法。让我们从一个基本版本开始,然后添加到它。例如,您可以使用以下代码将 HTTP 版本设置为 2
:
HttpClient client = HttpClient.builder().
.version(Version.HTTP_2)
.build();
如果不支持 HTTP/2 协议,HttpClient
实例默认使用 HTTP/1.1。
通常,当您使用网络浏览器访问资源时,您会看到一个消息,表明资源已移动到另一个位置,并且您正在被重定向到新地址。在这种情况下,您的网络浏览器接收新的 URI。您可以通过指定 followRedirects()
方法程序化地完成到新 URI 的重定向。以下是一个示例:
HttpClient client = HttpClient.builder().
.version(Version.HTTP_2)
.followRedirects(Redirect.NORMAL),
.build();
上述代码调用 followRedirects()
,传递 Redirect.NORMAL
。现在,Redirect
是在 HttpClient
类中定义的一个嵌套枚举,具有以下常量值:
枚举值 | 描述 |
---|---|
ALWAYS |
总是重定向 |
NEVER |
永不重定向 |
NORMAL |
总是重定向,除了 HTTPS URL 重定向到 HTTP URL |
许多网站通常通过注册的用户名和密码来验证用户。您可以通过使用 authenticator()
方法将身份验证值添加到 HttpClient
。以下示例使用默认身份验证:
HttpClient client = HttpClient.newBuilder().
.version(Version.HTTP_2)
.followRedirects(redirect.NORMAL),
.authenticator(Authenticator.getDefault())
.build();
以下代码使用自定义值("admin"
和 "adminPassword"
)进行身份验证:
HttpClient client = HttpClient.newBuilder().
.version(Version.HTTP_2)
.followRedirects(redirect.NORMAL),
.authenticator(new Authenticator() {
public PasswordAuthentication
getPasswordAuthentication() {
return new PasswordAuthentication(
"admin", "adminPassword".toCharArray());
})
.build();
本节中的代码片段展示了如何创建 HttpClient
的实例。
HttpClient 类的方法
要通过网络请求 HTTP 资源,您需要在 HttpClient
实例上调用 send()
或 sendAsync()
方法之一。send()
方法同步地发送请求并接收其响应;它将在这些任务完成之前阻塞。sendAsync()
方法异步地与服务器通信;它发送请求并立即返回 CompletableFuture
。
在我包含 send()
和 sendAsync()
方法的示例之前,理解其他两个类(HttpRequest
和 HttpResponse
)非常重要。我将在 HttpResponse
部分介绍这些方法(send()
和 sendAsync()
)。
这里是一个 HttpClient
类的重要方法的快速列表:
方法返回类型 | 方法名称 | 方法描述 |
---|---|---|
抽象 Optional<Authenticator> |
authenticator() |
返回包含在此客户端上设置的 Authenticator 实例的 Optional |
抽象 Optional<Executor> |
executor() |
返回包含此客户端的 Executor 的 Optional |
抽象 HttpClient.Redirect |
followRedirects() |
返回此客户端的 followRedirects 策略 |
静态 HttpClient.Builder |
newBuilder() |
创建一个新的 HttpClient 构建器 |
静态 HttpClient |
newHttpClient() |
返回一个具有默认设置的新的 HttpClient |
WebSocket.Builder |
newWebSocketBuilder() |
创建一个新的 WebSocket 构建器(可选操作) |
抽象 Optional<ProxySelector> |
proxy() |
返回包含此客户端提供的 ProxySelector 实例的 Optional |
抽象 <T> HttpResponse<T> |
send (HttpRequest request, HttpResponse.BodyHandler<T> responseBodyHandler) |
使用此客户端发送给定的请求,如果需要,则阻塞以获取响应 |
抽象 <T> CompletableFuture<HttpResponse<T>> |
sendAsync (HttpRequest request, HttpResponse.BodyHandler<T> responseBodyHandler) |
以异步方式发送给定的请求,使用此客户端和给定的响应体处理器 |
抽象 <T> CompletableFuture<HttpResponse<T>> |
sendAsync (HttpRequest request, HttpResponse.BodyHandler<T> responseBodyHandler, HttpResponse.PushPromiseHandler<T> pushPromiseHandler) |
以异步方式发送给定的请求,使用此客户端和给定的响应体处理器以及推送承诺处理器 |
抽象 SSLContext |
sslContext() |
返回此客户端的 SSLContext |
抽象 SSLParameters |
sslParameters() |
返回此客户端的 SSLParameters 的副本 |
抽象 HttpClient.Version |
version() |
返回此客户端首选的 HTTP 协议版本 |
下一步是使用 HttpRequest
类来定义请求的详细信息。
HttpRequest
HttpRequest
类封装了客户端需要通过网络发送到服务器的信息。它包括连接的 URI、带有一系列变量名及其对应值的头信息、超时值(丢弃请求前等待的时间)以及要调用的 HTTP 方法(PUT
、POST
、GET
或 DELETE
)。
与 HttpClient
类不同,HttpRequest
不提供具有默认值的类实例,这很有意义。想象一下,如果你不指定它,客户端将连接到的 URI。
让我们通过调用其 newBuilder()
方法并传递一个 URI 来创建一个 HttpRequest
实例:
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("http://www.eJavaGuru.com/"))
.build();
你可以通过使用 timeout()
方法将超时添加到你的请求中,如下所示:
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("http://www.eJavaGuru.com/"))
.timeout(Duration.ofSeconds(240))
.build();
一个 request
实例必须包含要使用的 HTTP 方法。如果没有指定方法,则默认执行 GET
请求。在上面的代码中,执行了一个 GET
请求。让我们明确指定 HTTP 方法。最常用的 HTTP 方法是 GET
和 POST
。DELETE
和 PUT
HTTP 方法也被使用。
以下示例指定了方法为 POST
方法:
HttpRequest request = HttpRequest.newBuilder()
.uri(new URI("http://www.eJavaGuru.com/"))
.timeout(Duration.ofSeconds(240))
.POST(HttpRequest.noBody())
.build();
POST
方法要求你传递 BodyProcessor
类的实例。对于不需要正文的 POST
请求,你可以传递 HttpRequest.noBody()
。你可以使用多个来源,例如字符串、InputStream
、字节数组或文件,并将其传递给 POST
方法。以下是一个将文件传递给 POST
方法的示例:
HttpRequest request = HttpRequest.newBuilder()
.uri(new URI("http://www.eJavaGuru.com/"))
.timeout(Duration.ofSeconds(240))
.POST(HttpRequest.BodyProcessor
.fromFile(Paths.get("data.txt")))
.build();
以下示例将一个字符串传递给 POST()
方法:
HttpRequest request = HttpRequest.newBuilder()
.uri(new URI("http://www.eJavaGuru.com/"))
.timeout(Duration.ofSeconds(240))
.POST(HttpRequest.BodyProcessor
.fromString("This is sample data"))
.build();
想象一下,你正在处理一个应用程序,该应用程序在股票价格上升或下降到或低于某个阈值时购买股票。对你来说这是个好消息。BodyProcessor
是一个响应式流发布者;你可以通过使用它来处理实时数据(如股票价格),并通过控制背压来处理。
BodyProcessor
定义了方便的方法,例如 fromFile()
、fromString()
、fromInputStream()
和 fromByteArray()
,以便方便地传递各种值。
另一种常用的方法是 header()
,它用于指定请求的内容。以下是一个示例,它将 request
的内容指定为 text/plain
:
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("http://www.eJavaGuru.com/"))
.header("Content-Type", "text/plain")
.build();
下面是 HttpClient
类的重要方法的列表:
方法 返回类型 | 方法名称 | 方法描述 |
---|---|---|
abstract Optional<HttpRequest.BodyPublisher> |
bodyPublisher() |
返回包含在此请求上设置的 HttpRequest.BodyPublisher 实例的 Optional |
abstract boolean |
expectContinue() |
返回请求继续设置 |
abstract HttpHeaders |
headers() |
返回此请求(或将要发送的)请求头(用户可访问的) |
abstract String |
method() |
返回此请求的请求方法 |
static HttpRequest.Builder |
newBuilder() |
创建 HttpRequest 构建器 |
static HttpRequest.Builder |
newBuilder (URI uri) |
使用给定的 URI 创建 HttpRequest 构建器 |
abstract Optional<Duration> |
timeout() |
返回包含此请求的超时持续时间的 Optional |
abstract URI |
uri() |
返回此请求的 URI |
abstract Optional<HttpClient.Version> |
version() |
返回包含将为此 HttpRequest 请求的 HTTP 协议版本的 Optional |
与 HttpClient
和 HttpRequest
类不同,你不需要创建 HttpResponse
类的实例。让我们在下一节中看看如何实例化它。
HttpResponse
当您使用 HttpClient
实例发送 HttpRequest
实例时,您会收到 HttpResponse
。在发送 HTTP 请求后,服务器通常会返回响应的状态码、响应头和响应正文。
因此,您何时可以访问响应正文?这取决于您在发送请求时指定的 BodyHandler
,使用 HttpClient
的 send()
或 sendAsync()
方法。根据指定的 BodyHandler
,您可能在响应状态码和头信息可用后(在响应正文可用之前)能够访问响应正文。
让我们回顾本章的第一个示例:
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("http://google.com/"))
.build();
HttpResponse<String> response = client.send(request, HttpResponse.
BodyHandlers.ofString());
System.out.println(response.body());
在前面的示例中,send()
方法将 BodyHandler
指定为 BodyHandlers.ofString()
。它将接收到的响应正文字节转换为高级 Java 类型:字符串。您还可以使用 BodyHandlers.ofFile()
、BodyHandlers.ofInputStream()
或 BodyHandlers.discard()
将响应保存到文件、将响应用作输入流或丢弃它。
BodyHandler
是在 HttpResponse
接口中定义的静态接口。HttpResponse
还定义了一个静态类,BodyHandler
,它定义了 BodyHandler
接口的多种有用实现。例如,您可以使用 BodyHandlers.ofFile()
将接收到的响应写入指定的文件。幕后,BodyHandler
使用 BodySubscriber
(一个响应式流),它订阅来自服务器的响应字节。
BodyHandlers
的便捷静态方法(ofFile()
、ofString()
、ofInputStream()
和 discard()
)让您可以与响应式数据流:BodySubscriber
一起工作。
这里是 HttpResponse
接口的重要方法列表:
方法返回类型 | 方法名称 | 方法 描述 |
---|---|---|
T |
body() |
返回正文 |
HttpHeaders |
headers() |
返回接收到的响应头 |
Optional<HttpResponse<T>> |
previousResponse() |
返回包含前一个中间响应的 Optional ,如果已接收 |
HttpRequest |
request() |
返回与该响应对应的 HttpRequest 实例 |
Optional<SSLSession> |
sslSession() |
返回包含此响应中有效 SSLSession 实例的 Optional |
int |
statusCode() |
返回此响应的状态码 |
URI |
uri() |
返回接收响应的 URI |
HttpClient.Version |
version() |
返回用于此响应的 HTTP 协议版本 |
让我们来看一些示例。
一些示例
当您使用 HTTP 连接到网络应用程序或网络服务时会发生什么?服务器可以以多种格式返回文本或数据,包括 HTML、JSON、XML、二进制等。此外,编写服务器端应用程序或服务的语言或框架无关紧要。例如,您连接到的网络应用程序或服务可能使用 PHP、Node、Spring、C#、Ruby on Rails 或其他语言编写。
让我们处理一些简单的用例,例如使用 GET
或 POST
请求连接到网络服务器,同步或异步地提交请求数据,并使用多种格式接收响应并存储。
使用同步 GET 访问 HTML 页面
HttpClient
可以以同步或异步的方式从服务器接收响应。要同步接收响应,请使用 HttpClient
的 send()
方法。此请求将阻塞线程,直到完全接收到响应。
以下代码使用同步发送 GET
请求连接到托管 HttpClient
类 API 文档的 Oracle 网络服务器:
class SyncGetHTML {
public static void main(String args[]) throws Exception {
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://docs.oracle.com/en/java/javase
/11/docs/api/java.net.http/java/net/http/HttpClient.html"))
.build();
HttpResponse<String> response =
client.send(request, BodyHandlers.ofString());
System.out.println(response.body());
}
}
上一段代码生成了大量文本。以下只是输出的一小部分初始行:
<!DOCTYPE HTML>
<!-- NewPage -->
<html lang="en">
<head>
<!-- Generated by javadoc -->
<title>HttpClient (Java SE 11 & JDK 11 )</title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="keywords" content="java.net.http.HttpClient class">
上一段代码通过将 BodyHandlers.ofString()
传递给 send()
方法,将 HTML 数据作为字符串接收。用于接收此响应的变量是 HttpResponse<String>
实例,它与用于处理响应体字节的响应体订阅者 (BodyHandlers.ofString()
) 匹配。
让我们看看如果我们将前一个请求的响应存储为 .html
文件会发生什么。以下是修改后的代码:
class SyncGetHTMLToFile {
public static void main(String args[]) throws Exception {
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://docs.oracle.com/en
/java/javase/11/docs/api/java.net.http/java
/net/http/HttpClient.html"))
.build();
HttpResponse<Path> response =
client.send(request,
BodyHandlers.ofFile(Paths.get("HttpClient.html")));
}
}
在上一段代码中,HttpClient.html
的内容与之前示例中发送到控制台的文字相同。在这个例子中,响应体字节被写入到文件中。
由于文件保存为 .html
格式,您可以在您的网页浏览器中查看它。然而,此文件的显示效果将与托管 HttpClient
类的显示效果不匹配,因为您的本地 .html
文件无法访问 HttpClient.html
所使用的 .css
或其他托管样式。
以下截图比较了本地和托管 HttpClient.html
的渲染效果:
让我们修改前面的示例以异步接收响应。
使用异步 GET 访问 HTML 页面
要异步接收响应,您可以使用 HttpClient
的 sendAsync()
方法。此请求将立即返回 CompletableFuture
。您可以在 CompletableFuture
上调用 get()
方法来检索响应。
让我们修改上一节中使用的示例,以异步方式将响应(HTML 文本)存储到文件中:
class AsyncGetHTMLToFile {
public static void main(String args[]) throws Exception {
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://docs.oracle.com/en
/java/javase/11/docs/api/java.net.http/java/net
/http/HttpClient.html"))
.build();
CompletableFuture<Path> response =
client.sendAsync(request,
BodyHandlers.ofFile(Paths.get("http.html")))
.thenApply(HttpResponse::body);
response.get();
}
}
BodyHandlers.ofFile()
是 BodyHandler
接口的一个实现,它使用 BodySubscriber
(一个响应式流)来订阅响应体字节。在接收到响应体后,它将内容写入指定的文件。
使用 HTTP GET
请求,你还可以将一组参数名称及其值作为 URI 的一部分包含在内。例如,通过将 URI 定义为 http://www.eJavaGuru.com/Java11.html?name="Mala"
,客户端可以将 Mala
值传递给参数名称。
下载多个托管图像文件
假设你想下载多个托管图像文件,而不使用 FTP 客户端(或类似的应用程序)。别担心;你可以通过使用 HTTP 客户端,无论是同步还是异步地做到这一点。
要实现这一功能,代码与上一节中看到的大致相同;只需将响应体字节保存到具有适当文件扩展名的文件中。
以下代码从 eJavaGuru (ejavaguru.com/
) 下载三个托管图像到与你的源代码文件相同的文件夹:
class MultipleImageDownload{
public static void main(String args[]) throws Exception {
List<URI> imageURIs =
List.of(
URI.create("http://ejavaguru.com/images/about/jbcn-actual-2018.jpg"),
URI.create("http://ejavaguru.com/images/about/iit-delhi.jpg"),
URI.create("http://ejavaguru.com/images/about/techfluence.jpg"));
HttpClient client = HttpClient.newHttpClient();
List<HttpRequest> imgDwnldRequests = imageURIs.stream()
.map(HttpRequest::newBuilder)
.map(builder -> builder.build())
.collect(Collectors.toList());
CompletableFuture.allOf(imgDwnldRequests.stream()
.map(request -> client.sendAsync(request,
BodyHandlers.ofFile(
Paths.get(((String)request.uri()
.getPath()).substring(14)
)
)
))
.toArray(CompletableFuture<?>[]::new))
.join();
}
}
上一段代码使用相同的 HttpClient
实例,client
,通过向服务器发送多个异步请求来下载多个托管图像。图像的 URI
实例存储在 URI 列表 imageURIs
中。然后,使用此列表创建多个 HttpRequest
实例:imgDwnldRequests
。然后,代码在客户端上调用 sendAsync()
方法,以异步方式发送请求。
如前例所述,BodyHandlers.ofFile()
创建了一个 BodyHandler
的实现,该实现创建并订阅 BodySubscriber
。BodySubscriber
是一个反应式流订阅者,它以非阻塞背压从服务器接收响应体。
发布表单详细信息
假设你想以编程方式将表单的详细信息发布到 Web 应用程序或 Web 服务。你可以通过发送 POST
请求,使用 HTTP 客户端 API 来做到这一点。以下代码使用 HttpClient
的 send()
方法将一组参数名称和值发布到服务器。参数名称及其值存储为 String
值:
public class HttpReqPost {
public static void main(String uri[]) throws Exception {
String postData = "?
name='Mala'&email='info@ejavaguru
@gmail.com'";
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("http://www.ejavaguru.com/Java11/register.php"))
.POST(BodyPublishers.ofString(postData))
.build();
HttpResponse<?> response = client.send(request,
BodyHandlers.discarding());
System.out.println(response.statusCode());
}
}
在前面的代码中,HttpRequest
构建器包括以下代码:
.POST(BodyPublishers.ofString(postString)
BodyPublishers
类定义了 BodyPublisher
的常见实现,BodyPublisher
是一个反应式流,用于将请求体字节发布到服务器。BodyPublishers
定义了静态方法 ofString
、ofFile
、ofInputStream
和 ofByteArray
,用于从 String
、文件或 InputStream
发布请求体,将高级 Java 类型转换为要作为请求体发送的数据流。
在此示例中,POST
数据存储在字符串 postData
中,该字符串与请求一起发送到服务器。在这种情况下,我不想处理从服务器接收到的响应,因此在访问响应时使用 BodyHandlers.discarding()
。
如果你记得,本章中所有之前的示例都使用反应式流以非阻塞和异步方式从服务器接收响应体字节。因此,HTTP 客户端使你能够发送请求并从服务器接收响应,使用反应式流。
HTTP 客户端使用 BodySubscriber
和 BodyPublishers
以非阻塞方式异步地发送和接收来自服务器的响应。BodyPublisher
接口扩展了 Flow.Publisher
接口。BodySubscriber
接口扩展了 Flow.Subscriber
接口。
当您使用 HTTP 客户端工作时,您还可以以 JSON、XML 或其他数据类型接收响应。同样,您也可以向服务器发送多种数据类型。您可以使用 Java SE 或其他供应商的适当 API 将一种格式转换为另一种格式。
摘要
HTTP 客户端是在 Java 9 中孵化出来的,并在 Java 11 中标准化。本章从介绍 HTTP 客户端 API 开始,包括导致其创建的因素。今天的网络应用程序和服务应该是响应式的,支持异步、非阻塞的数据传输。HTTP 客户端使用反应式流来实现这些目标。
HTTP 客户端可用于通过网络访问 HTTP 资源,使用 HTTP/1.1 或 HTTP/2,以同步和非同步的方式进行。HTTP 客户端 API 由三个主要类或接口组成:HttpClient
类、HttpRequest
类和 HttpResponse
接口。HttpClient
类用于发送请求并检索相应的响应;HttpRequest
封装了请求资源的详细信息,包括请求 URI。HttpResponse
类封装了来自服务器的响应。
在底层,HTTP 客户端使用 BodySubscriber
和 BodyPublishers
以非阻塞方式异步地发送和接收来自服务器的响应。BodyPublisher
接口扩展了 Flow.Publisher
接口。BodySubscriber
接口扩展了 Flow.Subscriber
接口。
本章包含多个示例,以演示常见用例。
在甲骨文公司的 Project Amber 项目中正在进行许多有趣的语言添加和修改。我们将在下一章开始探索这一点。
第八章:ZGC
Java 11 在 GC(垃圾回收)领域包含了许多改进和变化。随着Z 垃圾回收器(ZGC),Java 为您带来了另一个 GC——可扩展的,低延迟。这是一个全新的 GC,从头开始编写。它可以与堆内存一起工作,从 KB 到大型 TB 内存。作为一个并发垃圾回收器,ZGC 承诺不会使应用程序延迟超过 10 毫秒,即使对于更大的堆大小。它也很容易调整。
它作为 Java 11 的一个实验性 GC 发布。在 OpenJDK 上对这种 GC 的工作正在进行中,您可以期待随着时间的推移对其进行更多更改。
在本章中,我们将涵盖以下主题:
-
为什么需要 ZGC
-
ZGC 的特性
-
与 ZGC 一起工作的示例
-
ZGC 的用例
技术要求
您可以使用 ZGC 与 Java 11 和 Linux/x64 系统一起使用。ZGC 是一个实验性 GC。本章中的所有代码都可以通过访问本书的 GitHub 仓库来获取:github.com/PacktPublishing/Java-11-and-12-New-Features
。
让我们从评估为什么我们需要 ZGC 开始。
动机
在 Java 早期崛起的一个特性是其 GC 的自动内存管理,这使开发者免于手动内存管理并降低了内存泄漏。
然而,由于不可预测的时间和持续时间,垃圾回收有时可能对应用程序造成比好处更大的伤害。增加的延迟会直接影响应用程序的吞吐量和性能。随着硬件成本的不断降低和旨在使用较大内存的程序,应用程序对垃圾收集器提出了更低的延迟和更高的吞吐量的要求。
ZGC 承诺的延迟不超过 10 毫秒,这不会随着堆大小或活动集的增加而增加。这是因为它的停止世界暂停仅限于根扫描。
ZGC 是一个可扩展的、低延迟的 GC,承诺即使对于大型堆内存(TB 大小),延迟也不会超过 10 毫秒。
ZGC 的特性
从头开始编写的 ZGC 带来了许多特性,这些特性在其提案、设计和实现中发挥了关键作用。
ZGC 最突出的特性之一是它是一个并发 GC。它可以并发地标记内存、复制和重新定位它。它还有一个并发引用处理器。这本质上意味着您可以添加各种引用,例如弱引用、软引用、虚引用或终结器(这些现在已弃用)。即使如此,ZGC 也不会为您添加更多的 GC 暂停(因为它将并发地清理或回收内存)。
与其他 HotSpot GC 使用的存储屏障不同,ZGC 使用加载屏障。加载屏障用于跟踪堆的使用情况。ZGC 的一个有趣特性是使用带有彩色指针的加载屏障。这使得 ZGC 能够在 Java 线程运行时执行并发操作,例如对象重定位或重定位集选择。
ZGC 是基于区域的垃圾收集器。然而,如果您将其与 G1 垃圾收集器进行比较,ZGC 在配置其大小和方案方面更加灵活。与 G1 相比,ZGC 有更好的方法来处理非常大的对象分配。
ZGC 是单代垃圾收集器。它还支持部分压缩。在回收内存和重新分配内存方面,ZGC 的性能也非常高。
ZGC 是 NUMA 兼容的,这意味着它具有 NUMA 兼容的内存分配器。
作为实验性的垃圾收集器,ZGC 仅适用于 Linux/x64。如果对其有相当大的需求,将来将添加更多平台支持。
开始使用 ZGC
使用 ZGC 涉及多个步骤。您应该安装针对 Linux/x64 的 JDK 二进制文件,并构建和启动它。您可以使用以下命令下载 ZGC 并在您的系统上构建它:
$ hg clone http://hg.openjdk.java.net/jdk/jdk
$ cd zgc
$ sh configure --with-jvm-features=zgc
$ make images
执行前面的命令后,您可以在以下位置找到 JDK 根目录:
g./build/linux-x86_64-normal-server-release/images/jdk
Java 工具,如 java
、javac
等,可以在前面路径的 /bin
子目录中找到(其通常位置)。
提示:除非您有 Linux/x64,否则您将无法使用 ZGC。
让我们创建一个基本的 HelloZGC
类,如下所示:
class HelloZGC {
public static void main(String[] args) {
System.out.println("Say hello to new low pause GC - ZGC!");
}
}
您可以使用以下命令来启用 ZGC 并使用它:
java -XX:+UnlockExperimentalVMOptions -XX:+UseZGC HelloZGC
由于 ZGC 是一个实验性的垃圾收集器,您需要使用运行时选项来解锁它,即 XX:+UnlockExperimentalVMOptions
。
要启用基本的 GC 日志记录,您可以添加 -Xlog:gc
选项。让我们修改前面的代码,如下所示:
java -XX:+UnlockExperimentalVMOptions -XX:+UseZGC -Xlog:gc HelloZGC
详细日志记录在您微调应用程序时非常有帮助。您可以通过使用 -Xlog:gc*
选项来启用它,如下所示:
java -XX:+UnlockExperimentalVMOptions -XX:+UseZGC -Xlog:gc* HelloZGC
前面的命令将输出所有日志到控制台,这可能会使搜索特定内容变得困难。您可以将日志指定写入文件,如下所示:
java -XX:+UnlockExperimentalVMOptions -XX:+UseZGC -Xlog:gc:mylog.log* HelloZGC
与 G1 和并行 GC 相比,ZGC 在降低延迟和提高应用程序吞吐量方面表现更好。
让我们窥视一下 ZGC 如何安排堆以进行对象分配(简而言之,让我们从探索 ZGC 的秘密配方开始)。
ZGC 堆
ZGC 将内存划分为区域,也称为 ZPages。ZPages 可以动态创建和销毁。它们也可以动态调整大小(与 G1 GC 不同),大小是 2 MB 的倍数。以下是堆区域的大小组:
-
小型(2 MB)
-
中等(32 MB)
-
大型 (*N ** 2 MB)
ZGC 堆可以有多个这些堆区域的出现。中等和大型区域是连续分配的,如下面的图所示:
与其他 GC 不同,ZGC 的物理堆区域可以映射到一个更大的堆地址空间(这可能包括虚拟内存)。这对抗内存碎片问题可能至关重要。想象一下,你可以在内存中分配一个非常大的对象,但由于内存中连续空间不可用,你无法这样做。
这通常会导致多个 GC 周期以释放足够的连续空间。如果没有可用空间,即使经过(多个)GC 周期,你的 JVM 也会因为 OutOfMemoryError
而关闭。然而,这个特定的用例与 ZGC 没有问题。由于物理内存映射到一个更大的地址空间,定位更大的连续空间是可行的。
ZPages 是相同数字的倍数,例如,在英特尔机器上为 2 MB。它可能变化,例如,在 S 机器上为 4 MB。
现在,让我们看看 ZGC 如何从其区域回收内存。
ZGC 阶段
ZGC 的一个 GC 周期包括多个阶段:
-
暂停标记开始
-
暂停标记结束
-
暂停重定位开始
在第一阶段,暂停标记开始,ZGC 标记由根指针指向的对象。这包括遍历活动对象集,然后找到并标记它们。这无疑是 ZGC GC 周期中最繁重的工作负载之一。
一旦完成,下一个周期是暂停标记开始,用于同步,并从 1 毫秒的短暂暂停开始。在这个第二阶段,ZGC 从引用处理开始,然后转向弱根清理。它还包括重定位集选择。ZGC 标记它想要压缩的区域。
下一步,暂停重定位开始,触发实际的区域压缩。它从指向位置集的根扫描开始,然后是重定位集中对象的并发重新分配。
第一阶段,即暂停标记开始,还包括重映射活动数据。由于标记和重映射活动数据是最繁重的 GC 操作,它不会作为一个单独的操作执行。重映射在暂停重定位开始后开始,但与下一个 GC 周期的暂停标记开始阶段重叠。
彩色指针
彩色指针是 ZGC 的核心概念之一。它使 ZGC 能够找到、标记、定位和重映射对象。它不支持 x32 平台。彩色指针的实现需要虚拟地址掩码,这可以在硬件、操作系统或软件中完成。以下图表显示了 64 位指针布局:
如前图所示,64 位对象引用如下划分:
-
18 位:未使用位
-
1 位:可终止
-
1 位:重映射
-
1 位:标记 1
-
1 位:标记 0
-
42 位:对象地址
前面的 18 位被保留供将来使用。42 位可以寻址高达 4TB 的地址空间。现在剩下的是剩余的、引人入胜的 4 位。Marked1
和Marked0
位用于标记对象以进行垃圾收集。通过设置单个位为Remapped,对象可以被标记为不指向重定位集。最后的 1 位用于最终化,与并发引用处理相关。它标记了一个对象只能通过最终化器访问。
当你在系统上运行 ZGC 时,你会注意到它使用了大量的虚拟内存空间,正如你所知,这并不等同于物理内存空间。这是由于堆多映射。它指定了带有彩色指针的对象如何在虚拟内存中存储。
例如,对于一个无色的指针,比如0x0000000011111111
,它的彩色指针将是0x0000**10**0011111111
(重映射位设置),0x00000**8**0011111111
(Marked1
位设置),和0x00000**4**0011111111
(Marked0
位设置)。相同的物理堆内存将映射到地址空间中的三个不同位置,每个位置对应一个彩色指针。当映射处理不同时,这将以不同的方式实现。
让我们探索其他重要的 JVM 运行时参数,你可以使用这些参数来调整 ZGC。
调整 ZGC
让我们看看一些调整 ZGC 的选项(本章仅涵盖一些基本选项)。让我们从设置最大堆大小的最基本选项开始。我们可以通过以下 JVM 运行时选项来实现:
-Xmx<size>
为了获得最佳性能,你必须设置一个堆大小,不仅能够存储应用程序的存活集,而且还有足够的空间来服务分配。
ZGC 是一个并发垃圾收集器。通过设置分配给 ZGC 线程的 CPU 时间量,你可以控制 GC 触发的频率。你可以通过以下选项来实现:
-XX:ConcGCThreads=<number>
ConcGCThreads
选项的更高值将留给应用程序更少的 CPU 时间。另一方面,较低的值可能会导致应用程序在内存上挣扎;应用程序可能会生成比 ZGC 收集的更多垃圾。ZGC 也可以使用ConcGCThreads
的默认值。为了在此参数上微调你的应用程序,你可能更喜欢针对测试值执行。
对于高级 ZGC 调整,你还可以为应用程序的性能增强启用大页。你可以通过以下选项来实现:
-XX:+UseLargePages
前面的命令需要 root 权限。请参阅wiki.openjdk.java.net/display/zgc
以获取详细步骤。
除了启用大页之外,你也可以通过以下选项启用透明大页:
-XX:+UseTransparentHugePage
前面的选项还包括额外的设置和配置,这些可以通过使用 ZGC 的官方 wiki 页面访问,该页面托管在wiki.openjdk.java.net/display/zgc
。
ZGC 是一个 NUMA 意识的垃圾回收器。在 NUMA 机器上执行的应用程序可以带来明显的性能提升。默认情况下,ZGC 启用了 NUMA 支持。然而,如果 JVM 认为它绑定在 JVM 的一个子集上,则此功能可以被禁用。要覆盖 JVM 的决定,可以使用以下选项:
-XX:+UseNUMA
摘要
在本章中,我们介绍了适用于 OpenJDK 的一个可扩展、低延迟的垃圾回收器——ZGC。它是一个从头开始编写的实验性垃圾回收器。作为一个并发垃圾回收器,它承诺最大延迟小于 10 毫秒,并且不会随着堆大小或存活数据的增加而增加。
目前,它仅支持 Linux/x64。如果对其有相当大的需求,未来可以支持更多平台。
在下一章中,你将了解到如何使用 Java 飞行记录器(JFR)和 任务控制(MC)将操作系统和 JVM 事件捕获到文件中并进行分析。
第九章:飞行记录器和任务控制
Java Flight Recorder(JFR)是一个内置在 JVM 中的高性能、低开销的剖析器。它是一个数据收集框架,可以记录你可以用来调试你的 Java 应用程序和 HotSpot JVM 的事件。
JFR 记录来自操作系统、HotSpot JVM 和 JDK 二进制事件的二进制数据。这本质上意味着你需要一个解析器,例如Mission Control(MC),来理解这些二进制数据。
MC 是一个高级工具,用于程序开发人员和管理员详细分析 JFR 剖析器收集的数据。它可以用来分析在本地或远程环境中运行的应用程序收集的数据。
在本章中,我们将涵盖以下主题:
-
需要 JFR 和 MC 的原因
-
JFR 和 MC 的特点
-
JFR 和 MC 的使用
技术要求
从 Java 11 开始,JFR 包含在 OpenJDK 发行版中。根据你使用的 JDK 发行版,你可能需要单独下载和安装 MC。Java Mission Control(JMC)不是 OpenJDK 发行版的一部分。它自 JDK 版本 7 更新 40 以来一直是 OracleJDK 的一部分。
如果 JMC 没有包含在你的 JDK 中,你可以从jdk.java.net/jmc/
下载它。
本章中所有的代码都可以从github.com/PacktPublishing/Java-11-and-12-New-Features
访问。
让我们开始探索为什么我们需要 JFR。
JFR 背后的动机
银行的保险库几乎零缺陷地创建,但它们并不是不可战胜的。想象一下银行保险库被破坏后会发生什么。可能的一个步骤包括扫描安全摄像头录像——以检查盗窃发生的时间和方式。这可能导致各种结果——从确定问题的原因和制定预防措施以防止其再次发生。
同样,你永远无法预见你的应用程序在生产中可能遇到的所有挑战。一个剖析器,如 JFR,可以帮助你在应用程序执行时记录事件。当你的应用程序崩溃或未按预期执行时,你可以使用剖析器收集的数据来监控或调试它。这些数据可以为你提供反馈循环。
MC 读取由 JFR 记录的应用程序分析数据,并以视觉化的方式显示,在多个值上(因此可以节省你从大量文本中筛选信息)。
特点
JFR 可以记录大量事件——从你的应用程序到你的 JVM 到操作系统。它是一个高性能但低开销的剖析器。
JFR 扩展了基于事件的 JVM 跟踪(JEP 167)的功能,为 HotSpot 添加了一组初始事件以在 Java 中创建事件。它还提供了一个高性能的后端,将事件数据写入二进制格式。
MC 在可视化环境中显示由 JFR 收集的应用程序分析数据。您可以选择您想要分析的分类——从类加载到 JVM 内部(如垃圾回收),应用程序线程,内存分配,到完整的应用程序数据分析。我们将在本章中介绍一些 MC 功能(对所有功能的完整覆盖超出了本书的范围)。
模块
JFR 定义了以下模块:
-
jdk.jfr
:这定义了 JFR 分析器的 API 和内部结构。您可以使用它来分析在资源受限设备上运行的应用程序,例如 IoT(物联网的简称)或移动设备。Jdk.jfr
只需要java.base
模块。 -
jdk.management.jfr
:要使用 Java 管理扩展(JMX)远程使用飞行记录,您可以使用此模块。它需要jdk.jfr
和jdk.management
模块。
我们不会介绍 JMC 的代码,只会介绍其功能和如何使用它们。
开始使用 JFR
让我们从一个简单的 HelloWorld
示例开始,如下所示:
class HelloWorld {
public static void main(String[] args) {
System.out.println("Hello World - This is being recorded");
}
}
要为前面的应用程序启动飞行记录,请在您的控制台执行以下命令:
> java -XX:StartFlightRecording,filename=hello.jfr
HelloWorld
第一行指示 Java 运行时为您的 HelloWorld
应用程序启动飞行记录并将其保存到 HelloWorldRecording.jfr
文件。
之前的命令有三个部分,如下所示:
-
使用
-XX:StartFlightRecording
JVM 选项启动 JFR -
指定要保存记录的目标文件
hello.jfr
-
指定要运行的
HelloWorld
让我们启动 MC 来查看存储在 hello.jfr
中的分析数据。使用 jmc.exe
文件启动 JMC。您将看到一个类似于以下屏幕截图的窗口:
点击底部的“点击此处开始使用 JDK Mission Control”选项。使用文件 | 打开菜单选项,打开您之前创建的 hello.jfr
文件。以下是它在“自动分析结果”登录页面上的显示内容:
MC 分析应用程序的类别不仅限于进程。根据您的应用程序及其分析方式,还包括其他类别(您可以在前面的屏幕截图中看到其中的一些)。
进一步探索
让我们分析另一个创建大量(500)线程的应用程序;每个线程创建一个包含 1,000,000 个 Double
值的 ArrayList
,并用随机数填充它:
class AThread implements Runnable {
String name = "default";
private Random numGenerator = new Random();
private ArrayList<Double> list = new ArrayList<Double>(10_00_000);
AThread(String name) {
this.name = name;
}
public void run() {
for (int i = 0; i < 10_00_000; i++) {
list.add(numGenerator.nextDouble());
System.out.println("Allocated : " + name + "[" + i + "]");
}
}
}
public class TestFlightRecorder {
public static void main(String... args) throws Exception {
for (int i = 0; i < 500; i++) {
new Thread(new AThread("Thread" + i)).start();
}
}
}
让我们执行前面的 TestFlightRecorder
应用程序,使用 Epsilon GC(以检查我们是否也能获得有关内存分配的数据)进行 10 秒的配置文件分析:
> java
-XX:+UnlockExperimentalVMOptions
-XX:+UseEpsilonGC
-XX:StartFlightRecording,filename=Epsilon.jfr
TestFlightRecorder
当您在 MC 中打开 Epsilon.jfr
时,这是登录页面:
在我们详细讨论 MC 显示的结果之前,让我们快速回顾一下被性能分析的TestFlightRecorder
应用程序。TestFlightRecorder
创建了 500 个AThread
类的实例。AThread
类实现了Runnable
接口。在启动时,每个AThread
实例创建了一个包含 1,000,000 个Double
值的ArrayList
,用随机值填充它们并将它们输出到控制台。
现在,让我们查看前面的截图——MC 显示了关于您的应用程序整体表现的汇总报告。它包括执行 Java 应用程序的机器的环境、JVM 内部信息以及应用程序中线程在锁上的阻塞情况。以下是这些类别的快速列表:
-
Java 应用程序
-
上下文切换(缩进)
-
Java 阻塞(缩进)
-
JVM 内部
-
环境
由于 MC 报告称此应用程序在上下文切换和线程阻塞类别中表现不佳,让我们浏览 MC 左侧面板菜单中 Java 应用程序类别下的选项,找出哪个选项将包含相关信息。正如您将注意到的,锁实例选项旁边显示了一个感叹号。以下截图显示了您点击它时将看到的内容:
前面的截图显示,在TestFlightRecorder
应用程序中创建的所有 500 个线程都在PrintStream
和Object
上阻塞。它甚至显示了总的阻塞时间,即 2 小时 40 分钟(为所有阻塞线程共同计算,针对 20 秒的应用程序性能分析)。
由于 JFR 分析器将性能分析数据以二进制格式记录到文件中,您可以在以后使用 MC 查看这些数据,并找出许多其他问题。例如,如果您点击进程,您将知道您的 CPU 被许多在您的宿主机器上执行的其他进程所使用,这也可以包括自动软件更新。确保您关闭所有这些。假设您正在服务器上调整应用程序的性能。
如果您在 MC 中点击进程(当然,结果将因系统而异):
使用自定义事件
作为一名开发者,您也可以使用 JFR API 创建自己的自定义事件,并使用 MC 查看和分析它们。以下是一个示例;让我们定义一个自定义事件:
class MyEvent extends Event {
@Label("EventMessage")
String message;
}
现在,让我们修改AThread
类以使用事件,而不是打印到控制台:
class AThread implements Runnable {
String name = "default";
private Random numGenerator = new Random();
private ArrayList<Double> list = new ArrayList<Double>(1000000);
AThread(String name) {
this.name = name;
}
public void run() {
MyEvent event;
for (int i = 0; i < 1000000; i++) {
list.add(numGenerator.nextDouble());
event = new MyEvent();
event.message = "Allocated : " + name + "[" + i + "]";
event.commit();
}
}
}
public class WithCustomEvents {
public static void main(String... args) throws Exception {
for (int i = 0; i < 500; i++) {
new Thread(new AThread("Thread" + i)).start();
}
}
}
您可以使用相同的命令行选项来执行应用程序,并使用 JFR 进行性能分析:
> java
-XX:StartFlightRecording,filename=CustomEvents.jfr
WithCustomEvents
现在,您不再使用 MC 来查看这些事件,而是可以创建另一个应用程序,读取从CustomEvents.jfr
记录的事件,如下所示:
class ReadFRData {
public static void main(String args[]) throws Exception {
Path p = Paths.get("CustomEvents.jfr");
for (RecordedEvent e : RecordingFile.readAllEvents(p)) {
System.out.println(e.getStartTime() +
" : " +
e.getValue("message"));
}
}
}
摘要
在本章中,我们学习了 JFR 分析器。使用内置在 JVM 中的高性能、低开销的 JFR 分析器,你无需依赖第三方分析器来调试你的 Java 应用程序和 HotSpot JVM。
我们还介绍了 MC——一个高级工具,供开发人员和管理员详细分析 JFR 收集的数据——在本地和远程环境中进行可视化分析。
在下一章中,我们将介绍 JDK 11 中的多个改进和新增功能。
第十章:JDK 11 中的其他改进
Java 11 包含了许多我们无法在各个章节中单独涵盖的有趣变化。然而,这并不意味着它们不重要或不重要到足以忽略,而是因为它们的细节超出了本书的范围。例如,基于嵌套的访问控制包括对 Java 虚拟机(JVM)规范的修改,动态类文件常量扩展了现有的类文件常量,加密层和 传输层安全性(TLS)的改进,以及更多。
本章包括对与 SE、JDK 和 Java 11 功能实现相关的剩余 JDK 11 功能的概述。
在本章中,我们将涵盖以下主题:
-
基于嵌套的访问控制
-
动态类文件常量
-
改进 AArch64 内联函数
-
移除 Java EE 和 CORBA 模块
-
基于 Curve25519 和 Curve448 的密钥协商
-
Unicode 10
-
ChaCha20 和 Poly1305 密码学算法
-
启动单个文件源代码程序
-
TLS 1.3
-
废弃 Nashorn JavaScript 引擎
-
废弃 pack200 工具和 API
技术要求
要使用本章中包含的代码,您需要在您的系统上安装 JDK 11 或更高版本。
由于本章涵盖了 Java 11 的多个功能,让我们快速将功能与其 JDK 增强提案(JEP)编号和范围进行匹配。
列出本章中使用的 JEP
下表列出了本章中涵盖的 JDK 11 功能,它们的对应 JEP 编号和范围:
JEP | 范围 | 描述 |
---|---|---|
181 | SE | 基于嵌套的访问控制 |
309 | SE | 动态类文件常量 |
315 | 实现 | 改进 AArch64 内联函数 |
320 | SE | 移除 Java EE 和 CORBA 模块 |
324 | SE | 基于 Curve25519 和 Curve448 的密钥协商 |
327 | SE | Unicode 10 |
329 | SE | ChaCha20 和 Poly1305 密码学算法 |
330 | JDK | 启动单个文件源代码程序 |
332 | SE | TLS 1.3 |
335 | JDK | 废弃 Nashorn JavaScript 引擎 |
336 | SE | 废弃 pack200 工具和 API |
让我们从第一个功能开始。
基于嵌套的访问控制
想象一下当你定义嵌套类或接口时会发生什么?例如,如果你定义一个 两层类,比如说 Outer
,和一个 内部类,比如说 Inner
,Inner
能否访问 Outer
的 private
实例变量?以下是一些示例代码:
public class Outer {
private int outerInt = 20;
public class Inner {
int innerInt = outerInt; // Can Inner access outerInt?
}
}
是的,它可以。由于您在同一个源代码文件中定义了这些类,您可能会认为这是显而易见的。然而,事实并非如此。编译器为 Outer
和 Inner
类生成单独的字节码文件(.class
)。对于前面的示例,编译器创建了两个字节码文件:Outer.class
和 Outer$Inner.class
。为了您的快速参考,内部类的字节码文件以外部类的名称和一个美元符号开头。
为了使这些类之间的变量访问成为可能,并保留程序员的期望,编译器将私有成员的访问范围扩展到包中,或者在每一个这些类中添加桥接变量或方法。以下是使用JAD Java Decompiler反编译的Outer
和Inner
类的反编译版本:
// Decompiled class Outer
public class Outer {
public class Inner {
int innerInt;
final Outer this$0;
public Inner() {
this$0 = Outer.this;
super();
innerInt = outerInt;
}
}
public Outer() {
outerInt = 20;
}
private int outerInt;
}
以下是Outer$Inner
反编译类的如下所示:
public class Outer$Inner {
int innerInt;
final Outer this$0;
public Outer$Inner() {
this$0 = Outer.this;
super();
innerInt = outerInt;
}
}
正如您将注意到的,反编译版本验证了编译器定义了一个桥接变量,即在Inner
类中的Outer
类型的this$0
,以便从Inner
访问Outer
的成员。
这些桥接变量和方法可能会破坏封装性,并增加部署应用程序的大小。它们也可能使开发人员和工具感到困惑。
什么是基于巢的访问?
JEP 181 引入了基于巢的访问控制。正如您所知,定义在另一个类或接口内部的类和接口被编译为单独的类文件。为了访问对方的私有成员,编译器要么扩展它们的访问级别,要么插入桥接方法。
基于巢的访问控制允许这样的类和接口相互访问对方的私有成员,而不需要编译器进行任何工作(或桥接代码)。
基于巢的访问控制还导致 JVM 规范的变化。您可以参考以下链接来访问这些变化(删除的内容以红色字体背景突出显示,添加的内容以绿色背景突出显示):cr.openjdk.java.net/~dlsmith/nestmates.html
。
基于巢的控制的 影响
虽然它可能看起来很简单,但基于巢的访问控制会影响所有涉及访问控制或方法调用的规范和 API——无论是隐式还是显式。如什么是基于巢的访问?部分所述,它包括 JVM 规范的变化。它还影响类文件属性、访问控制规则、字节码调用规则、反射、方法调用和字段访问规则。
它还添加了新的类文件属性,修改了MethodHandle
查找规则,导致类转换/重新定义——JVM TI 和java.lang.instrument
API、JDWP 和 JDI(com.sun.jdi.VirtualMachine
)。
动态类文件常量
JEP 309 扩展了现有的 Java 类文件格式,创建CONSTANT_Dynamic
。这是一个 JVM 功能,不依赖于更高层的软件。CONSTANT_Dynamic
的加载将创建引导方法的创建委托给其他地方。当与invokedynamic调用进行比较时,您将看到它是如何将链接委托给引导方法的。
动态类文件常量的一个主要目标是使其能够轻松创建可物化的类文件常量的新形式,这为语言设计者和编译器实现者提供了更广泛的表达性和性能选择。这是通过创建一个新的常量池形式来实现的,该形式可以使用具有静态参数的自举方法进行参数化。
改进 AArch64 内联函数
JEP 315 通过改进 AArch64 处理器上的内联函数来工作。当前的字符串和数组内联函数得到了改进。同时,在 java.lang.Math
中实现了新的内联函数,用于正弦、余弦和对数函数。
为了提高应用程序性能,内联函数使用针对 CPU 架构特定的汇编代码。它不会执行通用的 Java 代码。
注意,您将看到 AArch64 处理器实现了大多数内联函数。然而,JEP 315 在 java.lang.Math
类中实现了以下方法的优化内联函数,但并未达到预期:
-
sin()
-
cos()
-
log()
还值得注意的是,在 AArch64 端口之前实现的一些内联函数可能并不完全优化。这样的内联函数可以利用诸如内存地址对齐或软件预取指令等特性。以下列出了一些这些方法:
-
String::compareTo
-
String::indexOf
-
StringCoding::hasNegatives
-
`Arrays::equals`
-
StringUTF16::compress
-
StringLatin1::inflate
移除 Java EE 和 CORBA 模块
Java EE 在 Java 9 中迁移到了 Eclipse 基金会,并采用了新的名称——Jakarta EE(有趣的是,仍然是 JEE)。在 Java 9 中,特定于 Java EE 的模块和类已被弃用。在 Java 11 中,这些弃用的 API 和模块已被从 Java SE 平台和 JDK 中删除。CORBA 的 API 也在 Java 9 中被弃用,并在 Java 11 中最终被删除。
在 Java SE 6(核心 Java)中,您可以使用以下技术来开发 Web 服务:
-
JAX-WS(Java API for XML-Based Web Services 的缩写)
-
JAXB(Java Architecture for XML Binding 的缩写)
-
JAF(JavaBeans Activation Framework 的缩写)
-
常用注解
当将上述技术堆栈的代码添加到核心 Java 中时,它与 Java 企业版(JEE)的版本相同。然而,随着时间的推移,JEE 版本发生了演变,导致 Java SE 和 JEE 中相同 API 提供的功能不匹配。
JEP 320 在 Java 11 中删除了以下模块,从 OpenJDK 仓库和运行时 JDK 图像中删除了它们的源代码:
-
java.xml.ws
(JAX-WS) -
java.xml.bind
(JAXB) -
java.activation
(JAF) -
java.xml.ws.annotation
(常用注解) -
java.corba
(CORBA) -
java.transaction
(JTA) -
java.se.ee
(前六个模块的聚合模块) -
jdk.xml.ws
(JAX-WS 工具) -
jdk.xml.bind
(JAXB 工具)
除了在 Java 9 中将前面的模块标记为已弃用外,JDK 在编译或执行使用这些模块的代码时并没有解决这些问题。这迫使开发者必须在类路径上使用 Java EE 或 CORBA 的独立版本。
在它们被移除后,wsgen
和wsimport
(来自jdk.xml.ws
)、schemagen
和xjc
(来自jdk.xml.bind
)、idlj
、orbd
、servertool
和tnamesrv
(来自java.corba
)等工具不再可用。开发者无法通过运行时命令行标志来启用它们,如下所示:
--add-modules
CORBA,一个 ORB 实现,于 1998 年被包含在 Java SE 中。随着时间的推移,对 CORBA 的支持超过了其好处。首先,随着更好的技术的可用性,现在几乎没有人使用 CORBA。CORBA 在Java 社区进程(JCP)之外发展,维护 JDK 的 CORBA 实现变得越来越困难。由于 JEE 现在正转向 Eclipse 基金会,将 JDK 中的 ORB 与 Jakarta EE 的 ORB 同步就没有意义了。更不用说,JEE 8 将其指定为建议可选,这实际上意味着 JEE 可能在未来的某个版本中放弃对 CORBA 的支持(将其标记为已弃用)。
正在从 Java 8 迁移到 Java 11 的企业,如果它们的应用程序使用 JEE 或 CORBA API,将面临更高的风险。然而,Oracle 建议使用一个替代 API,这有助于简化迁移过程。
与 Curve25519 和 Curve448 曲线的关键协议
通过 JEP 324,Java SE 在密码学方面取得了进一步进展,提供了安全和性能。此功能实现了使用 Curve25519 和 Curve448 的关键协议。其他密码学库,如 OpenSSL 和 BoringSSL,已经支持使用 Curve25519 和 Curve448 进行密钥交换。
你可以在tools.ietf.org/html/rfc7748
上找到更多关于 Curve25519 和 Curve448 的信息。
Unicode 10
JEP 327 将现有平台 API 升级以支持 Unicode 10 标准(www.unicode.org/standard/standard.html
),主要在以下类中:
-
Character
和String
(位于java.lang
包) -
NumericShaper
(位于java.awt.font
包) -
Bidi
、BreakIterator
和Normalizer
(位于java.text
包)
ChaCha20 和 Poly1305 加密算法
Java 11 在密码工具包和 TLS 实现中包含了多个新增和增强功能。JEP 329 实现了 ChaCha20 和 ChaCha20-Poly1305 密码。作为一个相对较新的流密码,ChaCha20 能够取代 RC4 流密码。
目前,广泛采用的 RC4 流密码并不那么安全。行业正在转向采用更安全的 ChaCha20-Poly1305。这也已被广泛采用在 TLS 实现以及其他加密协议中。
启动单个文件源代码程序
想象一下能够在不进行编译的情况下执行 Java 应用程序;例如,如果你在 HelloNoCompilation.java
文件中定义以下 Java 类:
class HelloNoCompilation {
public static void main(String[] args) {
System.out.println("No compilation! Are you kidding me?");
}
}
使用 Java 11,你可以使用以下命令执行它(不进行编译):
> java HelloNoCompilation.java
注意,上述命令使用 java
启动 JVM,它传递了一个具有 .java
扩展名的源文件名。在这种情况下,类是在 JVM 执行之前在内存中编译的。这适用于在同一源文件中定义的多个类或接口。以下是一个示例(假设它定义在同一个 HelloNoCompilation.java
源文件中):
class HelloNoCompilation {
public static void main(String[] args) {
System.out.println("No compilation! Are you kidding me?");
EstablishedOrg org = new EstablishedOrg();
org.invite();
System.out.println(new Startup().name);
}
}
class Startup {
String name = "CoolAndExciting";
}
interface Employs {
default void invite() {
System.out.println("Want to work with us?");
}
}
class EstablishedOrg implements Employs {
String name = "OldButStable";
}
在执行时,你会看到以下命令:
> java HelloNoCompilation.java
上述代码将输出如下:
No compilation! Are you kidding me?
Want to work with us?
CoolAndExciting
使用 JEP 330,你可以减少编译代码的步骤,直接进入执行 Java 应用程序。然而,这仅适用于单源文件的应用程序。源文件可以定义多个类或接口,如前例代码所示。
启动单文件源代码程序有助于减少简单代码执行所需的仪式。这对刚开始学习 Java 的学生或专业人士来说非常有帮助。然而,当他们开始使用多个源文件工作时,他们需要在执行之前编译他们的代码。
然而,如果你使用 javac
命令编译源类,然后尝试将其作为单文件源代码启动,它将无法执行。例如,按照以下方式编译 HelloNoCompilation
源文件:
> javac HelloNoCompilation.java
然后,尝试执行以下命令:
> java HelloNoCompilation.java
你将收到以下错误:
error: class found on application class path: HelloNoCompilation
TLS 1.3
这里是 Java 中 TLS 实现的另一个补充。JEP 332 实现了 TLS 协议的 1.3 版本。
TLS 1.3 版本取代并过时了其之前的版本,包括 1.2 版本(即 RFC 5246,可以在 tools.ietf.org/html/rfc5246
找到)。它还过时或更改了其他 TLS 功能,例如 OCSP(即 在线证书状态协议)的粘贴扩展(即 RFC 6066,可以在 tools.ietf.org/html/rfc6066
找到;以及 RFC 6961,可以在 tools.ietf.org/html/rfc6961
找到) 和会话哈希以及扩展主密钥扩展(即 RFC 7627;更多信息,请访问 tools.ietf.org/html/rfc7627
)。
弃用 Nashorn JavaScript 引擎
使用 JEP 335,Java 11 弃用了 Nashorn JavaScript 脚本引擎、其 API 和其 jjs
工具。这些将在未来的 Java 版本中删除。
Nashorn JavaScript 引擎首次包含在 JDK 的最新版本中——JDK 8。这样做的原因是为了替换 Rhino 脚本引擎。然而,Java 无法跟上基于 ECMAScript 的 Nashorn JavaScript 引擎的演变步伐。
维护 Nashorn JavaScript 引擎的挑战超过了它所提供的优势,因此为其废弃铺平了道路。
JEP 336 – 废弃 pack200 工具和 API
Java 5 中引入的 pack200 是一种用于 JAR 文件的压缩方案。它用于在打包、传输或交付 Java 程序时减少磁盘空间和带宽需求。开发者使用 pack200 和 unpack200 来压缩和解压缩 Java JAR 文件。
然而,随着今天现代存储和传输技术的改进,这些变得不再相关。JEP 336 废弃了 pack200 和 unpack200 工具,以及相应的 pack200 API。
摘要
在本章中,我们介绍了 Java 11 的各种特性。我们看到了在不同版本中引入了众多变化。
在下一章中,我们将发现 Java 语言在 Project Amber 项目中的新增功能和修改——该项目旨在调整 Java 语言的规模。
第三部分:JDK 12
本节将向您展示switch
表达式如何增强了传统的switch
语句,使您的代码更加简洁,从而避免逻辑错误。下一章将涵盖 Java 12 的其他新增和更新内容。
本节将涵盖以下章节:
-
第十一章,switch 表达式
-
第十二章,JDK 12 中的各种改进
第十一章:switch
表达式
使用switch
表达式,Java 12 正在增强其基本语言结构之一——switch
——以改善开发者的日常编码体验。这种做法的好处是多方面的。与传统的switch
结构相比,switch
表达式(JDK 增强提案JEP 325—openjdk.java.net/jeps/325
)可以返回一个值。通过在switch
分支中定义多个常量并改进代码语义,可以使代码更加简洁。通过移除switch
分支间的默认穿透控制,你不太可能在switch
表达式中引入逻辑错误。
在本章中,你将涵盖以下主题:
-
现有
switch
语句的问题 -
switch
表达式语法 -
在
switch
分支中定义局部变量 -
扩展的
break
语句 -
比较
break
与break <返回值>
-
完全情况
-
预览语言特性
-
在
switch
表达式中使用return
和continue
技术要求
要编译和执行本章包含的代码,请在您的系统上安装 JDK 12。本章中的所有代码都可以通过以下 URL 访问:github.com/PacktPublishing/Java-11-and-12-New-Features
。
让我们从解决现有switch
语句的问题开始。
传统switch
结构的问题
目前,switch
语句的语法受到高度限制。它不如if-else
结构强大。使用if-else
结构,你可以定义和匹配复杂的模式;但使用switch
结构则不行。此外,switch
的语法较为冗长,这使其在视觉上令人烦恼。这可能导致易于出错且难以调试的代码。
让我们通过一个例子来展示所有这些问题。以下示例定义了一个枚举,Size
。Shirt
类定义了一个setSize()
方法,该方法接受Size
并根据需要将一个整数值分配给实例变量length
:
enum Size {XS, S, M, L, XL, XXL};
class Shirt {
private int length;
public void setSize(Size size) {
switch(size) {
case XS : length = 10;
System.out.println(length);
break;
case S : length = 12;
System.out.println(length);
break;
case M : length = 14;
System.out.println(length);
case L : length = 16;
break;
case XL : length = 18;
System.out.println(length);
break;
case XXL: length = 20;
System.out.println(length);
break;
}
}
}
这是调用前面方法的示例:
Shirt s = new Shirt();
s.setSize(Size.XXL);
System.out.println(s.length);
上一段代码输出以下预期结果:
20
20
然而,让我们看看当你尝试执行以下代码时会发生什么:
Shirt s = new Shirt();
s.setSize(Size.M);
System.out.println(s.length);
上一段代码输出一个意外的值不匹配:
14
16
你认为这些不匹配的值的原因是什么?为了回答这个问题,这里有一个快速回顾——现有switch
结构中的switch
分支(即case
标签)必须包含一个break
语句以防止控制流穿透。这本质上意味着,当控制流找到一个匹配的case
值时,它将执行语句直到找到一个break
语句或达到switch
结构的末尾。
仔细检查后,你会发现对应于 M
值的分支没有定义 break
语句。对应于下一个情况的分支(即 L
),缺少了 System.out.println
值语句。因此,当你调用 s.setSize(Size.M)
时,以下情况发生:
-
14
被分配给length
实例变量 -
System.out.println()
输出14
-
控制贯穿与
L
值对应的分支 -
16
被分配给length
实例变量
传统 switch
构造在缺少 break
语句的情况下,会在 case
标签之间贯穿控制,这可能导致意外的错误。
switch
构造作为一个块工作。然而,如果你回顾前面章节中的示例代码,你会同意语言构造将焦点从业务逻辑转移开,并引入了复杂性。
这在以下图中展示:
新的 switch
表达式旨在将焦点重新投向业务逻辑。
使用 switch 表达式
下面是一个修改变量的传统 switch
构造示例,该变量基于传递给方法的枚举值:
enum SingleUsePlastic {STRAW, BAG, SPOON, FORK, KNIFE, PLATE, BOTTLE};
class Planet {
private static long damage;
public void use(SingleUsePlastic plastic) {
switch(plastic) {
case STRAW : damage += 10;
break;
case BAG : damage += 11;
break;
case SPOON : damage += 7;
break;
case FORK : damage += 7;
break;
case KNIFE : damage += 7;
break;
case PLATE : damage += 15;
break;
case BOTTLE: damage = 20;
break;
}
}
}
让我们看看如果我们使用 switch
表达式,前面的代码会如何变化:
damage += switch(plastic) {
case STRAW -> 10;
case BAG -> 11;
case SPOON, FORK, KNIFE -> 7;
case PLATE -> 15;
case BOTTLE -> 20;
};
让我们比较新的 switch
表达式与传统 switch
语句。前面代码块中使用 switch
表达式的代码要简洁得多。你定义在箭头(->
)右侧要执行的操作。此外,你不再需要在每个 switch
分支中使用 break
语句。减少了样板代码,减少了由于缺少 break
语句而导致的意外错误的可能性。
以下图解突出了这些变化:
这些 switch
表达式是传统 switch
构造的补充。它们并不是要取代现有的 switch
构造。
switch
表达式提供了多个优点和特性:
-
与
switch
语句不同,switch
表达式可以返回一个值 -
switch
分支的返回值定义在->
的右侧 -
同一个
switch
分支可以定义多个使用逗号(,
)分隔的标签 -
在
switch
分支之间没有默认的贯穿控制 -
代码更简洁
让我们探讨 switch
表达式的更细微的细节。
在 switch
分支中定义局部变量
可以定义局部于 switch
分支的变量。为此,switch
分支可以定义一个代码块来执行匹配的 case
标签。要从中返回值,它可以在其中包含一个指定要返回的值的 break
语句。
让我们修改前面示例中的代码,以定义一个代码块,如下所示:
class Planet {
private static long damage;
public void use(SingleUsePlastic plastic) {
damage += switch(plastic) {
case STRAW -> 10;
case BAG -> 11;
case SPOON, FORK, KNIFE -> 7;
case PLATE -> {
int radius = 20; // Local variable
break (radius < 10 ? 15 : 20); // Using
// break to return
// a value
}
case BOTTLE -> 20;
};
}
}
局部变量 radius
的作用域和可访问性仅限于定义它的 switch
分支。
switch
表达式的另一种语法
除了使用 ->
来指定返回值之外,switch
表达式还可以使用冒号(:
)来标记要执行的代码的开始,并使用 break
语句来返回一个值。以下是一个示例:
class Planet {
private static long damage;
public void use(SingleUsePlastic plastic) {
damage += switch(plastic) {
case STRAW : break 10; // Use colon (:) to start code,
// Use break to return val
case BAG : break 11;
case SPOON, FORK, KNIFE : break 7;
case PLATE : int radius = 6; // no need
// of using curly brace
break (radius < 10 ? 15 : 20); // Using
// break to
// return a value
case BOTTLE : break 20;
};
}
}
比较 break
与 break <return value>
形式为 break <return value>
的 break
语句被称为扩展的 break 语句。
传统的 switch
构造在 switch
分支中不包含任何返回值的 break
语句,以从 switch
构造中退出控制。这也防止了在多个 switch
分支之间的控制跳过。switch
表达式使用带返回值的 break
语句并从 switch
表达式中退出。
让我们比较 break
语句和 return
语句,后者可以带或不带值使用。在方法中,你可以使用 return
语句返回一个值并退出方法,或者只是退出方法而不返回值。以下是一个快速示例:
int sum(int x, int y) { // return type of method is
// int
int result = x + y;
return result; // returns int value
}
void output(List<Integer> list) { // return type of method is
// void
if (list == null)
return; // exit method without
// returning a value
else {
for (Integer i : list)
System.out.println(i);
}
System.out.println("End of method"); // this doesn't execute if
// list is null
}
switch
表达式使用 break
来返回一个值。传统的 switch
构造使用 break
语句来防止控制流在其 case
标签之间跳过。
预览语言特性
switch
表达式是一个预览语言特性(JEP 12)。这本质上意味着,尽管它是完整的,但它有可能在未来 Java 版本中不被确认为一个永久性特性。这有一个原因。
Java 在数十亿台设备上运行,并被数百万开发者使用。任何新的 Java 语言特性中的错误都存在高风险。在永久性地将语言特性添加到 Java 之前,Java 的架构师会评估开发者对它的反馈——也就是说,它有多好或多坏。根据反馈,预览语言特性可能会在添加到 Java SE 之前进行改进,或者完全取消。因此,如果你对 switch
表达式有任何反馈,请与 amber-dev
分享(mail.openjdk.java.net/mailman/listinfo/amber-dev
)。
穷尽情况
switch
表达式可以用来返回一个值或仅执行一组语句,就像传统的 switch
语句一样。
当你使用 switch
表达式返回一个值并将其用于给变量赋值时,其情况必须是穷尽的。这本质上意味着,无论你传递给 switch
参数的值是什么,它都必须能够找到一个适当的分支来执行。switch
表达式可以接受 byte
、short
、int
、Byte
、Short
、Integer
或 String
类型的参数或枚举。在这些中,只有枚举有穷尽值。
在以下示例中,switch
表达式被用来给 damage
变量赋值。由于没有匹配的分支来执行 PLATE
值,这段代码将无法编译:
class Planet {
private static long damage;
public void use(SingleUsePlastic plastic) {
damage += switch(plastic) {
case SPOON, FORK, KNIFE -> 7;
};
}
}
要编译前面的代码,你可以添加带有case
标签PLATE
的switch
分支,或者添加一个default
分支,如下所示:
class Planet {
private static long damage;
public void use(SingleUsePlastic plastic) {
damage += switch(plastic) {
case SPOON, FORK, KNIFE -> 7;
// Adding (1) or (2), or both will enable the code to
// compile
case PLATE -> 10; // line number (1)
default -> 100; // line number (2)
};
}
}
对于像原始类型、包装类或String
类这样的switch
参数,它们没有穷尽的值列表,你必须定义一个默认的case
标签(如果你从switch
表达式中返回值),如下所示:
String getBook(String name) {
String bookName = switch(name) {
case "Shreya" -> "Harry Potter";
case "Paul" -> "Management tips";
case "Harry" -> "Life of Pi";
default -> "Design Patters - everyone needs this";
};
return bookName;
}
让我们来看第二种情况,即你不使用switch
表达式来返回值。以下代码修改了前面代码中的getBook()
方法。尽管switch
表达式使用了新的语法(使用->
来定义要执行的代码),但它不返回值。在这种情况下,switch
表达式的 case 不需要是穷尽的:
String getBook(String name) {
String bookName = null;
switch(name) { // NOT returning
// a value
case "Shreya" -> bookName = "Harry Potter";
case "Paul" -> bookName = "Management tips";
case "Harry" -> bookName = "Life of Pi"; // default case
// not included
}
return bookName;
}
当你使用switch
表达式返回值时,其 case 必须是穷尽的,否则你的代码将无法编译。当你使用switch
表达式执行一组语句而不返回值时,其 case 可能不是穷尽的。
switch
分支除了返回值之外还能执行什么?
在switch
表达式中,当一个switch
分支不返回值时,它可以执行单个语句、一组语句,甚至抛出异常。在以下示例中,switch
语句不返回值。它为case
标签Sun
执行单个语句(打印出一个值),为case
标签Mon
和Tue
执行一组代码,并为默认情况抛出异常:
String day = // assign a value here
switch(day) {
case "Sun" -> System.out.println("OSS-Jav");
case "Mon", "Tue" -> {
// some simple/complex code
}
default -> throw new RuntimeException("Running out of projects");
}
如何在switch
表达式中不使用标签和continue
你可以在switch
表达式中使用标签和continue
语句。然而,你不能像以下这样通过使用它们来跳过switch
表达式。以下是一个示例:
class Planet {
private static long damage;
public void use(SingleUsePlastic plastic) {
myLabel: // Label
for (...) {
damage += switch(plastic) {
case SPOON, FORK, KNIFE : break 7;
case PLATE : continue myLabel; // NOT
// allowed
// illegal
// jump
// through
// switch
// expression
};
}
}
}
摘要
在本章中,你看到了switch
表达式如何增强了传统的switch
语句。你可以使用switch
表达式返回一个值,该值可以用来初始化变量或将值重新分配给它们。这也使你的代码更简洁。通过移除switch
分支之间的默认穿透,你使用switch
表达式时更不容易引入逻辑错误。
在下一章中,我们将介绍 JDK 12 中的多个改进和新增功能。
第十二章:JDK 12 中的其他改进
Java 12 是 Oracle 发布的最新 短期支持(STS)版本。然而,行业仍在逐渐接受迁移到 Oracle 的最新 长期支持(LTS)Java 版本,即 Java 11。
JDK 12 中的显著特性是 Shenandoah 垃圾回收器和 switch
表达式的添加。我们在第十一章 Switch Expressions 中介绍了 switch
表达式。由于对 Shenandoah 垃圾回收器的详细覆盖超出了本书的范围,我在本章中介绍了它,包括对 Java 12 的剩余添加和更新。
在本章中,我们将涵盖以下主题:
-
Shenandoah – 一种低暂停时间的垃圾回收器
-
微基准测试套件
-
Java 虚拟机(JVM)常量 API
-
一个 AArch64 端口,而不是两个
-
默认 CDS 归档
-
G1 可中止的混合收集
-
G1 及时返回未使用的已提交内存
技术要求
要使用本章中包含的功能,您应该在您的系统上安装 JDK 12 或更高版本。
由于本章涵盖了 Java 12 中的多个功能,让我们快速将功能与其 JDK 增强提案(JEP)编号和范围进行映射。
映射 JDK 12 范围和 JEP 的功能
下表列出了本章中涵盖的 JDK 12 功能、它们对应的 JEP 编号和它们的范围:
JEP | 范围 | 描述 |
---|---|---|
189 | 实现 | Shenandoah – 一种低暂停时间的垃圾回收器 |
230 | JDK | 微基准测试套件 |
334 | SE | JVM 常量 API |
340 | JDK | 一个 AArch64 端口,而不是两个 |
341 | JDK | 默认 CDS 归档 |
344 | 实现 | G1 可中止的混合收集 |
346 | 实现 | G1 及时返回未使用的已提交内存 |
让我们从第一个功能开始。
Shenandoah – 一种低暂停时间的垃圾回收器
由 Red Hat 的工程师提出并开发,Shenandoah 垃圾回收器承诺具有显著低的暂停时间。它是一种基于区域的垃圾回收器,以并行和并发的方式收集垃圾。值得注意的是,暂停时间与应用程序的存活数据无关。
随着硬件工程和成本的降低,服务器比以往任何时候都有更多的内存和计算能力。现代应用程序越来越需要更低的暂停时间——对于保证 10 到 500 毫秒响应时间的 服务级别协议(SLA)应用程序。为了满足这个范围的低端,垃圾回收器应该能够完成多个任务,包括以下内容:
-
使用能够使程序在给定内存上执行的算法
-
保持暂停时间低(即低于 10 毫秒)
这是否可行,比如说,一个使用 200 GB 内存的 Java 应用程序?使用压缩算法是不可能的,即使是压缩 10%的内存,也会超过 10 毫秒的限制。Shenandoah 使用了一种算法,在 Java 线程运行的同时并发压缩内存。在这种情况下,对象在并发 GC 周期中移动,并且所有引用对象都立即访问新的副本。
并发压缩并不简单。当 GC 移动一个活动对象时,它必须原子性地更新所有指向该对象的引用,指向新对象。然而,为了找到所有引用,整个堆都应该被扫描;这听起来不可行。为了解决这个问题,Shenandoah GC 为每个对象添加了一个转发指针,每次使用该对象时都会通过该指针。这简化了移动对象的过程。Shenandoah GC 线程或应用程序线程可以复制一个对象并使用比较和交换来更新转发指针。在竞争的情况下,只有一个比较和交换会成功。通过添加转发指针,Shenandoah GC 比其他 GC 算法使用更多的空间。
每个 Shenandoah GC 周期由四个阶段组成。Shenandoah GC 周期从初始标记开始,此时它会停止世界并扫描根集。在第二阶段,即并发标记阶段,它会并发地标记活动对象并更新引用。在第三阶段,最终标记阶段,它会停止世界并再次扫描根集,复制并更新根集到更新的副本。最后一个阶段,并发压缩,将活动对象从目标区域中移除。
Shenandoah 是基于区域的 GC。它不是一个关注收集最年轻对象的代际 GC。这是基于这样一个假设:大多数对象都是年轻时死亡的。然而,具有缓存的应用程序会长时间保留对象,因此代际 GC 算法无法与它们一起工作。为了解决这个问题,Shenandoah 使用了最近最少使用(LRU)缓存基准测试,这使得它能够保持其暂停时间很低。
Shenandoah 永远不会压缩巨型对象(即无法适应一个区域且需要多个区域的对象)。如果 Shenandoah GC 周期确定一个巨型对象不再活动,其区域将立即被回收。
Shenandoah GC 的主要目标是通过降低 GC 周期的数量和持续时间来提高 JVM 的响应性。
微基准测试套件
基于Java Microbenchmark Harness(JMH),此功能向 JDK 源代码添加了一组基本的微基准测试,以下为建议的目录结构:
jdk/jdk
.../make/test
.../test
.../micro/org/openjdk/bench
.../java
.../vm
由于微基准测试套件将位于 JDK 源代码中,这将使开发者更容易定位和运行现有的微基准测试,并创建新的测试。当现有功能被更新或从 JDK 版本中删除时,更新微基准测试将变得简单。此外,当开发者运行微基准测试时,他们可以使用 JMH 强大的过滤功能来运行选定的基准测试。
尽管微基准测试套件及其构建将与 JDK 及其构建系统集成,但它将有一个单独的目标。开发者需要指定额外的参数来执行它,以保持正常 JDK 的构建时间低。
如其名所示,通过基准测试,你可以比较构建或发布版本。因此,微基准测试支持新 JDK 的 JDK (N) 和旧版本的 JDK (N-1)。基准测试依赖于 JMH,就像单元测试依赖于 TestNG 或jtreg
一样。JMH 在构建过程中使用,并被打包为结果 JAR 文件的一部分。
JVM 常量 API
这个 JEP 引入了一个 API 来标准化类常量的描述和加载。
每个 Java 类都有一个常量池。它存储简单的值,如字符串和整数,或者表示类或方法的值。类常量池值用作ldc(加载常量)字节码指令的操作数值。这些常量也可以由invokedynamic字节码指令使用——在启动方法的静态参数列表中。
当执行 ldc 或 invokedynamic 指令时,它将常量值表示为 Java 数据类型值、一个类、一个整数或一个字符串。到目前为止,建模字节码指令和加载常量的责任在于想要操作类文件的类。这通常会使这些类的业务逻辑焦点转移到如何建模字节码指令和加载类常量的具体细节上。这显然是一个很好的候选者,可以将关注点分离并定义用于处理如何部分的 API。
此外,类本身实现此功能并不容易,因为对于非字符串和非整数值,加载类常量不是一个简单的过程。类加载是一个复杂的过程,有多个故障点。类加载依赖于宿主环境,包括类的存在、访问它们的能力以及它们的相应权限。类加载也可能在链接过程中失败。
缺乏一个标准化的库来处理这些加载常量的功能,也导致了程序之间期望的不匹配。
JDK 12 定义了一个新的包,java.lang.invoke.constant
,它定义了一组基于值的符号引用类型。它可以用来描述所有可加载的常量。符号常量使用名义形式,本质上排除了常量从其加载或可访问性上下文。该包包括如ClassDesc
、MethodTypeDesc
、MethodHandleDesc
和DynamicConstantDes
等类型来描述各种常量。数据类型如String
、Integer
、Long
、Float
和Double
也用于表示简单的类常量。
该包有多种用途。需要解析或生成字节码的库需要以符号方式描述类和方法句柄。invokedynamic 的引导将变得更加简单,因为它们将能够与符号表示一起工作,而不是与活生生的类和方法句柄一起工作。对于编译器和离线转换器来说,描述无法加载到运行虚拟机(VM)中的类及其成员将变得更加简单。编译器插件,如注解处理器,也需要以符号术语描述使用类及其成员。
一个 AArch64 版本,而不是两个
直到版本 12,JDK 有两个 64 位 ARM 版本,尽管两者都产生 AArch64 实现。作为一个维护特性,这个 JEP 删除了与 64 位 ARM 平台相关的所有源代码,并保留了 64 位 ARM AArch64 版本。这将防止维护两个版本的工作重复。在这个过程中,还将从 JDK 中移除构建此版本的选择。它还将验证 32 位 ARM 版本是否按预期继续工作,并且这些更改不会影响它。
默认 CDS 存档
要了解 JDK 12 对 CDS 存档的增强,让我们快速回顾一下 CDS 是什么以及它如何影响您的应用程序。我在第二章,“AppCDS”中简要介绍了这一点。
什么是 CDS?
自 Java 8 以来,CDS 是 Oracle JVM 的一项商业功能,它有助于减少 Java 应用程序的启动时间和内存占用。当您与多个 JVM 一起工作时,这一点尤为明显。
在启动时,JVM 为执行准备环境。这包括字节码加载、验证、链接和核心类和接口的初始化。这些类和接口被整理到 JVM 的运行状态中,以便它们可以执行。它还包括方法区域和常量池。
这些核心类和接口只有在您更新 JVM 时才会改变。因此,每次您启动 JVM,它都会执行相同的步骤来为执行准备环境。想象一下,您可以将结果导出到一个文件中,该文件可以在 JVM 启动时被读取。随后的启动可以跳过加载、验证、链接和初始化的中间步骤;欢迎来到 CDS。
当你安装 JRE 时,CDS 会从系统 JAR 文件中预定义的类集合创建一个共享存档文件。在类可以使用之前,它们会被类加载器验证,这个过程适用于所有类。为了加快这个过程,安装过程将这些类加载到内部表示中,然后将该表示转储到classes.jsa
——共享存档文件。当 JVM 启动或重启时,共享存档文件会被内存映射以节省加载这些类的过程。
当 JVM 的元数据在多个 JVM 进程之间共享时,它会导致更小的内存占用。从已填充的缓存中加载类比从磁盘加载它们更快;它们也部分经过验证。这个特性对启动新 JVM 实例的 Java 应用程序也有益。
使用 CDS 存档据报道在 JDK 11 中使基本程序(如HelloWorld
)的应用程序启动时间减少了 30%以上。在许多 64 位平台上,这个数字甚至更高。
增强 CDS
经常,开发者由于疏忽而未能使用本可以提升其应用程序性能的功能——仅仅是因为遗漏了一步。或者我们应该称之为可用性问题吗?
目前,尽管 JDK 包含默认的类列表,但它可以用以下命令使用:
java -Xshare:dump
尽管这种行为有文档记录,但开发者忽略了阅读文档,因此无法使用这个特性。
JDK 12 修改了构建过程。它在将java -Xshare:dump
命令链接到类列表之后运行。为了确保 CDS 存档文件是 JDK 镜像的一部分,共享存档文件被放置在lib/server
目录中。
在应用程序启动时,共享存档文件会自动使用,因为-Xshare:auto
是 JDK 11 中服务器虚拟机的默认选项。所以,除非使用-Xshare:off
选项将其明确关闭,否则开发者和应用程序将继续使用它,而无需执行任何额外的命令或设置。
CDS 包含来自核心 Java API 的预定义类和接口列表。为了包含特定的 API 或应用程序类,或者为了特定的 GC 行为,开发者可以创建并使用自定义存档文件。
G1 的可中止混合收集
开发者越来越要求 GC 有更明确的行为。例如,你难道不希望使用一个保证其暂停时间上限的 GC 来执行你的应用程序吗?
当在 Java 12 中使用 G1 GC 时,如果混合收集超过了你指定的限制,你可以中止它们。请注意,你不能中止 G1 GC 暂停的所有类别。
混合集合包括 G1 清理的 年轻 和 旧 内存区域。分析系统选择一组区域,统称为 集合集,供 G1 GC 处理。在 JDK 12 之前,当集合集太大、包含太多旧区域或包含具有 陈旧 数据的区域时,G1 GC 可能会超过最大暂停时间。
使用 JDK 12 时,当 G1 从混合集合中收集活动对象时,它可以以增量方式执行,这样就不会超过最大暂停时间。这个过程将集合集分为强制性和可选性部分。在 G1 完成从强制性集合收集活动对象后,如果时间允许,它将收集可选集合中的对象。
从 G1 中及时返回未使用的已提交内存
G1 GC 的一个附加增强功能——在空闲时将 Java 堆内存返回到 操作系统(OS)。这个增强功能最有可能由用于在 JVM 上运行应用程序的容器环境数量的增加而触发。
在 Java 12 之前,G1 在两种情况下从 Java 堆中返回内存——在执行完全 GC 或在并发周期期间。然而,这两种情况都不太常见。实际上,G1 将完全 GC 作为其最后手段来释放内存。并发周期受到 Java 堆分配和占用的影响。
这种 GC 行为具有多个缺点——即使在容器环境中以高效的方式使用内存,组织也需要为内存支付更多费用,并且服务提供商未能充分利用其资源。在这个增强功能中,JVM 确定应用程序的 空闲 时间并将内存返回给操作系统。这很有意义,因为应用程序的使用在每周的每一天或每天的每个小时都是不同的。当组织将应用程序部署到提供资源作为服务的环境中时,这个增强功能可以节省组织大量的费用。
摘要
在本章中,我们浏览了 JDK 12 的各种新增和修改,但排除了其预览语言特性之一的 switch
表达式。
本章中涵盖的功能大多与 JDK 及其实现相关。我们涵盖了增长中的 GC 家族中最新添加的一个——Shenandoah。Shenandoah 是一个并发 GC,它承诺为现代 Java 应用程序提供超低暂停时间,无论其内存大小如何。提到的其他两个 GC 功能——G1 的可中止混合集合和从 G1 中及时返回未使用的已提交内存——也增强了现有的 G1 GC。
JVM 常量 API 引入了一个新的包和类来以符号方式表示类约束。除了简化其在库和类之间的使用外,JVM 常量 API 还将标准化常量。默认 CDS 存档提高了存档文件创建的过程。移除 AArch64 ARM 端口的源代码更多与维护相关。
在下一章中,我们将深入了解 Project Amber 的细节和特性。
第四部分:Project Amber
在本节中,你将首先了解枚举,它允许你定义一种新类型。然后,我们将学习更多关于 Project Amber 中的数据类,并涵盖使用 POJOs 来建模数据的挑战。在此基础上,你将了解一个令人兴奋的语言增强功能——原始字符串字面量。接下来,我们将看到 Java 在帮助克服与 lambda 和方法引用相关的问题方面有哪些计划,最后以模式匹配结束,这将包括向 Java 添加新功能。
本节将涵盖以下章节:
-
第十三章,Project Amber 中的增强型枚举
-
第十四章,数据类及其用法
-
第十五章,原始字符串字面量
-
第十六章,Lambda 剩余部分
-
第十七章,模式匹配
第十三章:Project Amber 中的增强枚举
枚举为有限和预定义的常量集添加了类型安全。枚举使您能够定义一个具有状态和行为的新的类型(例如类或接口)。Project Amber正在增强枚举,通过添加类型变量(泛型)和允许更精确的枚举类型检查,将它们提升到下一个层次。这将使枚举能够定义具有类型信息、状态和行为的常量——仅适用于每个常量。这些增强将减少将枚举重构为类以使用泛型的需求。
在本章中,我们将涵盖以下主题:
-
增强枚举的原因
-
为枚举常量添加状态和行为
-
创建泛型枚举
-
访问特定常量的状态和行为
-
对枚举常量执行更精确的类型检查
快速背景
枚举引入了类型安全到常量的使用中,这些常量之前是通过使用static
和final
变量(如int
)定义的。
示例
想象一下将衬衫的尺寸限制为一些预定义的尺寸(如Small
、Medium
和Large
)。以下代码展示了您如何使用枚举(Size
)来实现这一点:
enum Size {SMALL, MEDIUM, LARGE}
Java 的编码规范建议使用大写字母来定义枚举常量(如SMALL
)。常量中的多个单词可以使用下划线分隔。
以下代码展示了您如何在一个类Shirt
中使用Size
枚举来限制其尺寸为Size
枚举中定义的常量:
class Shirt {
Size size; // instance variable of type Size
Color color;
Shirt(Size size, Color color) { // Size object with Shirt
// instantiation
this.size = size;
this.color = color;
}
}
在Shirt
类中,Size
类型的实例变量限制了分配给它的值只能是Size.SMALL
、Size.MEDIUM
和Size.LARGE
。以下代码是另一个类GarmentFactory
如何使用枚举常量创建Shirt
类实例的示例:
class GarmentFactory {
void createShirts() {
Shirt redShirtS = new Shirt(Size.SMALL, Color.red);
Shirt greenShirtM = new Shirt(Size.MEDIUM, Color.green);
Shirt redShirtL = new Shirt(Size.LARGE, Color.red);
}
}
枚举定义了具有预定义常量集的新类型。枚举为常量值添加了类型安全。
反编译枚举——幕后
每个用户定义的枚举隐式扩展了java.lang.Enum
类。幕后,前面部分定义的单一行的Size
枚举(枚举)被编译成类似以下的内容(我在代码中添加了注释以解释它;当您编译枚举时,您不会得到类似的注释):
final class Size extends Enum // 'enum' converted to final class
{
public static final Size SMALL; // variables to store
public static final Size MEDIUM; // enum constants
public static final Size LARGE; //
private static final Size $VALUES[]; // array of all enum
// constants
static
{ // static initializer
SMALL = new Size("SMALL", 0); // to initialize enum
// constants
MEDIUM = new Size("MEDIUM", 1); //
LARGE = new Size("LARGE", 2); //
$VALUES = (new Size[] { //
SMALL, MEDIUM, LARGE // & populate array of enum
// constants
});
}
public static Size[] values()
{
return (Size[])$VALUES.clone(); // Avoiding any
// modification to
} // $VALUES by calling methods
public static Size valueOf(String s)
{
return (Size)Enum.valueOf(Size, s);
}
private Size(String s, int i)
{
super(s, i);
}
}
枚举是语法糖。编译器将您的枚举构造扩展为java.lang.Enum
以创建一个类。它添加了变量、初始化器和获取所需行为的方法。
枚举常量的状态和行为
枚举常量可以有自己的状态和行为。您可以定义一个对所有枚举常量都通用的状态和行为,或者定义每个枚举常量特有的状态和行为。但是,您能访问特定于枚举常量的状态或行为吗?让我们来看看。
为枚举常量添加状态和行为
您可以通过在枚举中定义实例变量和方法来向枚举常量添加状态和行为。所有这些都可以通过枚举常量访问。让我们通过向之前定义的Size
枚举添加状态和行为来修改它。每个枚举常量都可以定义一个常量特定的类体,定义新的状态和行为,或覆盖其定义的枚举方法的默认行为。以下是一个示例:
enum Size {
SMALL(36, 19),
MEDIUM(32, 20) { // Constant specific class body
int number = 10; // variable specific to
//MEDIUM
int getSize() { // method specific to
//MEDIUM
return length + width;
}
},
LARGE(34, 22) {
@Override
public String toText() { // overriding method toText
//for
return "LARGE"; // constant LARGE
}
};
int length; // instance variable
//accessible
int width; // to all enum constants
Size(int length, int width) { // enum constructor;
//accepts length
this.length = length; // and width
this.width = width;
}
int getLength() { // method accessible to all
//enum
return length; // constants
}
int getWidth() { // method accessible to all
//enum
return width; // constants
}
public String toText() { // method accessible to all
//enum
return length + " X " + width; // constants
}
}
在前面的示例中,Size
枚举定义了三个枚举常量——SMALL
、MEDIUM
和LARGE
。它还定义了实例变量(length
和breadth
)、构造函数以及getLength()
、getWidth
和toText()
方法。
访问枚举常量的状态和行为
目前,枚举常量可以访问以下内容:
-
所有枚举常量共有的状态和行为
-
覆盖的方法
对于在上一节中定义的Size
枚举,您可以访问所有枚举常量共有的状态和行为,如下所示:
System.out.println(Size.SMALL.toText()); // toString is defined for all constants
上述代码将产生以下输出:
36 X 19
您还可以按如下方式访问特定枚举常量覆盖的行为:
System.out.println(Size.LARGE.toText());
上述代码将产生以下输出:
LARGE
然而,您无法访问特定于枚举常量的状态或行为,如下面的代码所示:
System.out.println(Size.MEDIUM.number); // Doesn't compile
System.out.println(Size.MEDIUM.getSize()); // Doesn't compile
使用MEDIUM
常量无法访问getSize()
方法和number
变量。这是因为MEDIUM
创建了一个匿名类并覆盖了Size
枚举的方法。它无法访问常量、特定状态或行为,因为它仍然由Size
类型的变量引用。以下图应有助于您记住这一点:
现有的枚举不允许访问特定于枚举常量的状态或行为,因为这会创建一个匿名类来执行此操作。
访问枚举常量的解决方案
访问特定于枚举常量的成员(如变量和方法)的一种方法是为所有成员定义它们,但仅允许特定成员使用(我知道,这并不推荐)。我已经移除了与此无关的代码,以展示其工作原理,如下所示:
enum Size {
SMALL(36, 19),
MEDIUM(32, 20),
LARGE(34, 22);
int length; // instance variable
//accessible
int width; // to all enum constants
Size(int length, int width) { // enum constructor; accepts
//length
this.length = length; // and width
this.width = width;
}
int getSize() {
if (this == MEDIUM)
return length + width;
else // throws runtime
// exception
throw new UnsupportedOperationException(); // if used with
// constants
} // other than
//MEDIUM
}
让我们尝试使用枚举常量访问getSize()
方法:
System.out.println(MEDIUM.getSize());
System.out.println(LARGE.getSize());
上述代码的输出如下:
52
Exception in thread—java.lang.UnsupportedOperationException
首先,添加不适用于所有枚举常量的代码(getSize()
方法)会破坏封装。在前面的示例中,我在主体中定义了getSize()
,而只有MEDIUM
枚举常量需要getSize()
方法。这既不理想也不推荐。
将其与基类及其派生类的排列进行比较,在基类中添加所有针对不同派生类的特定行为。然而,这并不推荐,因为它没有定义封装的代码。
使用枚举常量进行继承
以下是一个枚举的另一个示例,它通过将子类的实例传递给枚举构造函数与一组子类一起工作。为了说明问题,我已经修改了Size
枚举,这是我们自本章开始以来一直在工作的枚举。以下是被修改的代码:
class Measurement {} // base class
class Small extends Measurement { // derived class
String text = "Small"; // state specific to class
//Small
}
class Medium extends Measurement { // derived class
public int getLength() { // behavior specific to class
//Medium
return 9999;
}
}
class Large extends Measurement {} // derived class
enum Size {
SMALL(new Small()), // constant created using Small
//instance
MEDIUM(new Medium()), // constant created using Medium
//instance
LARGE(new Large()); // constant created using Large
//instance
private Measurement mObj; // Measurement is base class of
// classes Small, Medium & Large
Size(Measurement obj) { // wraps Measurement instance as an
//Enum instance
mObj = obj;
}
Measurement getMeasurement() { // get the wrapped instance
return mObj;
}
}
再次强调,您无法访问特定于枚举常量的代码的状态和行为。以下是一个示例:
class Test1 {
public static void main(String args[]) {
var large = Size.LARGE;
System.out.println(large.getMeasurement()
.getLength()); // doesn't compile
// the type of the
// variable used
// to wrap the value
// of enum
// constant is
// Measurement
}
}
在这里,增强枚举发挥了作用。JEP 301 通过向其中添加类型变量或泛型来引入增强枚举。让我们在下一节看看它是如何工作的。
将泛型添加到枚举中
让我们重写上一例中的枚举代码,给枚举Size
添加一个变量类型。有界类型参数(<T extends Measurement>
)限制了可以传递给Size
枚举的参数类型,仅限于Measurement
类及其派生类。
本节修改了上一节中的代码。要理解示例代码及其目的,请阅读上一节(如果您还没有阅读的话)。
修改后的代码如下:
enum Size <T extends Measurement> { // enum with type parameter
SMALL(new Small()),
MEDIUM(new Medium()),
LARGE(new Large());
private T mObj;
Size(T obj) {
mObj = obj;
}
T getMeasurement() {
return mObj;
}
}
class Measurement {}
class Small extends Measurement {
String text = "Small";
}
class Medium extends Measurement {}
class Large extends Measurement {
public int getLength() {
return 40;
}
}
以下代码可以用来访问特定于常量的行为,例如,getLength()
方法,它只能被LARGE
常量访问,如下所示:
var large = Size.LARGE;
System.out.println(large.getMeasurement().getLength());
在增强枚举(添加了泛型)中,您将能够访问枚举常量的特定状态或行为。
让我们再来看一个泛型枚举的例子,它可以用来限制用户数据到某些类型。
以下示例创建了一个泛型枚举Data
,它可以作为类型参数T
传递:
public enum Data<T> {
NAME<String>, // constants of generic
AGE<Integer>, // enum Data
ADDRESS<Address>;
}
FormData
类定义了一个泛型方法,可以接受Data
枚举的常量以及与枚举常量相同类型的值:
public class FormData {
public <T> void add(Data<T> type, T value) {
//..code
}
}
以下代码展示了如何使用Data
枚举的常量来限制传递给add
方法的值的类型组合:
FormData data = new FormData();
data.add(Data.NAME, "Pavni"); // okay; type of NAME and
// Pavni is String
data.add(Data.AGE, 22); // okay; type of AGE and 22 is
// Integer
data.add(Data.ADDRESS, "California"); // Won't compile. "California"
// is String, not Address
// instance
在不匹配的数据的情况下,代码在编译时失败,这使得开发者更容易纠正它。
编译错误总是比运行时异常要好。使用泛型枚举Data
将使代码在编译时因传递给add()
的值组合不匹配而失败。
枚举常量的更精确类型
增强枚举的两个主要目标之一是执行更精确的类型检查。目前,所有枚举常量的类型是它们定义的枚举。以我们的示例枚举Size
为例,这本质上意味着所有枚举常量(SMALL
、MEDIUM
和LARGE
)的类型是Size
,这是不正确的(如下面的图所示):
虽然枚举常量允许定义一个包含变量和方法的具体类体,但其常量类型不够精确,无法允许访问枚举常量特定的值。即使在泛型枚举的情况下,枚举常量的静态类型也不够精确,无法捕获个别常量的完整类型信息。
摘要
在本章中,你学习了 Java 5 中枚举如何引入类型安全到常量。我们介绍了每个枚举常量都可以拥有其独特的状态和行为,而不仅仅是所有枚举常量共有的内容。然而,使用现有的枚举无法访问特定于枚举常量的状态和行为。
接下来,我们讨论了增强型枚举如何使用泛型和访问特定的状态和行为。通过示例,我们还介绍了类型参数如何促进枚举常量的更精确类型化。
在下一章中,我们将介绍 Project Amber 中的数据类是如何带来语言变化以定义数据载体类的。
第十四章:数据类及其用法
关于 Project Amber 中的数据类,工作正在进行中。它提议为开发者提供一个简化的数据建模方法,引入带有record
关键字的特殊类。数据类的状态可以通过使用类头来捕捉,这与现有的普通 Java 对象(POJOs)形成鲜明对比。
在本章中,我们将涵盖以下主题:
-
数据类的介绍
-
数据类的需求及其局限性
-
数据类的聚合形式和展开形式
-
使用数据类进行模式匹配
-
使用抽象数据类和接口进行继承
-
添加变量和方法
-
覆盖默认行为
数据类的介绍
我们知道有两种数据类版本——POJO(旧的形式)和刚刚提出的新的数据类。为了理解 Project Amber 中正在工作的数据类,你需要了解现有 POJO 类的功能和局限性,以及为什么我们需要新提出的类。
POJO 不是通过语言结构实现的。提出的数据类将包括对编程语言的修改或添加。
什么是数据类?
作为 Java 开发者,你可能已经在你的某些(或所有)项目中使用和创建了 POJOs。POJO 是一个封装了一组数据的类,没有额外的行为来操作其状态。它通常包括构造函数、访问器、修改器以及从对象类中重写的的方法(hashCode()
、equals()
和toString()
)。访问器和修改器允许访问和分配状态变量。此外,修改器可能包括检查分配给实例状态的值范围的代码。以下是一个示例:
final class Emp {
private String name;
private int age;
public Emp(String name, int age) {
this.name = name;
this.age = age;
}
// accessor methods - getName, getAge
public String getName() {
return name;
}
public int getAge() {
return age;
}
// mutator methods - setName, setAge
public void setName() {
this.name = name;
}
public void setAge() {
this.age = age;
}
public boolean equals(Object obj) {
if (obj == null || (!(obj instanceof Emp)))
return false;
else {
if ( ( ((Emp)obj).getName().equals(this.name) &&
( ((Emp)obj).getAge() ) == this.age)) {
return true;
}
else
return false;
}
}
public String toString() {
return name + ":" + age;
}
public int hashCode() {
// ..code
}
}
一种场景是使用Emp
类将员工数据保存到你的数据库中。以下是一个示例:
interface EmpDAO {
Emp read();
void write(Emp emp);
List<Emp> getAllEmp();
}
类似地,你可以使用Emp
类来传递消息,通过网络发送,插入到 JSON 对象中,等等。
所有这些都看起来很好。最重要的是,自从 Java 被引入开发者以来,它一直运行良好。那么,问题是什么?
在语言中添加数据类的需求
想象一下保卫一个国家的边境,这些边境通常由防御部队守卫。安全水平是否会根据与邻国的(友好、中立或紧张)关系而改变?如果边境是渗透性的(例如,西欧的边境,对于申根国家而言),会发生什么?现在,比较保卫一个国家的边境与保卫我们的家园或确保房间内柜子的内容安全。
尽管前一个示例中的每个实例都涉及实体的安全及其对物理攻击的保护,但这些实例有不同的需求。
类似地,到目前为止,Java 中的类被用来模拟广泛的需求。虽然这对于很多情况来说效果很好,但对于某些情况则不适用。如果你想使所有大小都适合,你需要做很多调整,对于大多数情况来说。
将其与以下图中所示的使用相同裤子尺寸来适应不同身高和腰围的人进行比较:
在过去,枚举被添加到 Java 语言中(版本 5)。尽管可以通过编程创建原始类型或对象的枚举,但枚举简化了开发者的过程。
枚举减少了开发者的编码工作。同时,它们使每个枚举的意图对用户来说更加明确。
在上一节中,Emp
POJO 只是一个其数据的载体。然而,要让一个类表现得像数据类,需要开发者定义多个方法——构造函数、访问器、修改器以及来自对象类的其他方法。你可能会争辩说,你可以使用 IDE 轻松地为你的类生成所有这些方法。你说得对!这样做很简单。
然而,这仅仅解决了代码的编写部分。对于类的用户来说,代码的阅读部分会发生什么?我们这些开发者明白,一段代码可能只写一次,但会被阅读多次。这就是为什么经验丰富的程序员强调良好的编码实践,以便理解、阅读和维护代码。
当语言中引入数据类的定义时,代码的读者将知道其作为数据类的明确意图。开发者不需要深入挖掘以找到除了是数据类之外还包含的代码,这样他们就不会错过任何重要信息。
这也将防止开发者使用半成品的类作为数据类。有时,开发者会将这样的类用作数据类,而这些类并不包含所有相关的方法(例如equals()
或hashCode()
)。这无疑会导致在应用程序中插入微小的错误。例如,Map
这样的集合类需要类实现其equals()
和hashCode()
方法才能正常高效地工作。
通过语言的变化引入数据类将减少语言的冗长,将结构的意图广播给所有人。
深入数据类
定义数据类的语法看起来很简单。然而,语法和语义都很重要。让我们通过查看以下章节中的示例来开始。
语法和语义示例
让我们重新定义本章开头使用的Emp
类,将其作为数据类:
record Emp(String name, int age) { } // data class - one liner
// code
上述代码使用record
关键字来定义数据类,接受逗号分隔的变量name
和类型,这些变量是存储状态所必需的。编译器会自动为数据类生成对象方法的默认实现(equals()
、hashCode()
和toString()
)。
代码看起来清晰且紧凑。读者会立即知道这一行代码的意图——携带数据name
(类型String
)和age
(类型int
)。对于读者来说,另一个优点是他们不必阅读构造函数、访问器、修改器或对象类的方法,只需确认他们正在做他们应该做的事情。
在幕后,Java 编译器将记录类Emp
转换为以下代码:
final class Emp extends java.lang.DataClass {
final String name; final int age;
public Emp(String name, int age) {
this.name = name; this.age = age; } // deconstructor // public
// accessor methods // default implementation of equals,
// hashCode, and toString }
上述数据类是一个非抽象数据类的示例。数据类也可以定义为抽象数据类。非抽象数据类隐式地是最终的。在两种情况下,数据类都将获得hashCode()
、equals()
和toString()
以及访问器方法的默认实现。对于抽象数据类,构造函数将是受保护的。
在以下图中,编译器看起来很高兴将数据类的单行代码转换为完整的类:
默认情况下,数据类是final
的;你不能扩展它。
数据类的聚合形式和展开形式
数据类的聚合形式将是数据类的名称。其展开形式将指用于存储其数据的变量。从聚合形式到展开形式的转换被称为解构模式。
以下代码引用了我们在上一节中使用的示例:
record Emp(String name, int age) { }
Emp
是Emp
数据类的聚合形式。其展开形式将是String name
和int age
。语言需要在这两者之间提供简单的转换,以便它们可以与其他语言构造一起使用,例如switch
。
局限性
当你使用record
关键字来定义你的数据类时,你将受到语言允许你做什么的限制。你将不再能够精细控制你的数据类是否可扩展,其状态是否可变,可以分配给字段值的范围,字段的可访问性等等。在添加额外字段或多个构造函数方面,你也可能受到限制。
数据类在 Oracle 中仍在开发中。更详细的内容仍在完善中。2018 年 3 月,datum
关键字被用来定义数据类,但现在已改为record
。
现在,开发者不再局限于使用单一编程语言。Java 程序员通常与或了解在 JVM 上工作的其他编程语言,例如 Scala、Kotlin 或 Groovy。使用不同语言的经验带来了许多对数据类(使用record
定义)的能力和限制的期望和假设。
过去的一些例子——定义枚举的变化
在枚举引入之前,开发者经常使用public
、static
和final
变量来定义常量。以下是一个示例:
class Size {
public final static int SMALL = 1;
public final static int MEDIUM = 2;
public final static int LARGE = 3;
}
使用public
、static
、final
和int
变量的主要缺点是类型安全;任何int
值都可以分配给类型为int
的变量,而不是Size.SMALL
、Size.MEDIUM
或Size.LARGE
常量。
Java 5 引入了枚举,这是一种语言结构的补充,使开发者能够定义常量的枚举。以下是一个快速示例:
enum Size {SMALL, MEDIUM, LARGE}
class SmallTShirt {
Size size = Size.SMALL;
//..other code
}
对于类型为Size
的变量,赋值仅限于Size
中定义的常量。枚举是语言如何简化模型实现的一个完美例子,代价是某些约束。枚举限制了接口的可扩展性。除此之外,枚举是完整的类。作为开发者,你可以向它们添加状态和行为。另一个好处是枚举也可以使用switch
构造,这之前仅限于原始类型和String
类。
新的语言结构就像是一种新的人类关系,无论是生物的还是其他类型的。它有自己的快乐和悲伤。
使用数据类的模式匹配
当你使用record
关键字定义你的数据类时,你将获得转换数据类聚合和展开形式的额外优势。例如,以下代码展示了switch
语句如何展开数据:
interface Garment {}
record Button(float radius, Color color);
record Shirt(Button button, double price);
record Trousers(float length, Button button, double price);
record Cap(..)
switch (garment) {
case Shirt(Button(var a1, var a2), Color a3): ...
case Trousers(float a1, Button(var a2, var a3), double a4): ...
....
}
switch
语句可以使用数据类,而不使用其展开形式。以下代码也是有效的:
switch (garment) {
case Shirt(Button a1, Color a2): ...
case Trousers(float a1, Button a2, double a3): ...
....
}
封装状态
记录类封装字段,提供 JavaBean 风格的访问器方法的默认实现(设置字段值的公共方法)。值可以在数据类实例的初始化期间分配,使用它们的构造函数。
例如,让我们回顾一下前一个部分中的Emp
数据类及其反编译版本:
record Emp(String name, int age) { }
final class Emp extends java.lang.DataClass {
final String name;
final int age;
public Emp(String name, int age) {
this.name = name;
this.age = age;
}
// deconstructor
// public accessor methods
// default implementation of equals, hashCode, and toString
}
抽象和非抽象数据类
数据类可以是抽象的或非抽象的。一个抽象数据类是通过在其声明中使用abstract
关键字来定义的。作为一个抽象类,你不能直接使用抽象数据类。以下是一个抽象数据类JVMLanguage
和一个非抽象数据类Conference
的示例:
abstract record JVMLanguage(String name, int year);
record Conference(String name, String venue, DateTime when);
数据类和继承
目前,建议取消以下继承情况:
-
数据类可以扩展普通类
-
普通类可以扩展数据类
-
数据类扩展了另一个数据类
允许上述任何一种情况都会违反数据类作为数据载体的契约。目前,针对数据类和继承、接口以及抽象数据类,提出了以下限制:
-
非抽象和抽象数据类可以扩展其他抽象数据类
-
抽象或非抽象数据类可以扩展任何接口
以下图总结了这些继承规则:
让我们从扩展一个抽象数据类开始。
扩展一个抽象数据类
在以下示例中,Emp
抽象数据类正被非抽象的 Manager
数据类扩展:
abstract record Emp(String name, int age);
record Manager(String name, int age, String country) extends Emp(name, age);
当非抽象数据类扩展抽象数据类时,它接受其头部的所有数据——那些为自己和其基类所必需的。
数据类可以扩展单个抽象数据类。
实现接口
数据类可以实现接口及其抽象方法,或者只是继承其默认方法。以下是一个示例:
interface Organizer {}
interface Speaker {
abstract void conferenceTalk();
}
abstract record Emp(String name, int age);
record Manager(String name, int age, String country)
extends Emp(name, age) // subclass a record
implements Organizer; // implement one interface
record Programmer(String name, int age, String programmingLang)
extends Emp(name, age) // subclass a record
implements Organizer, Speaker { // implementing multiple
// interfaces
public void conferenceTalk() { // implement abstract
// method
//.. code // from interface Speaker
}
};
上述代码定义了一个标记接口 Organizer
(没有方法)和一个具有抽象方法 conferenceTalk()
的接口。我们有以下两种情况:
-
扩展另一个数据类并实现接口的数据类——
Manager
数据类扩展了抽象的Emp
数据类并实现了Organizer
接口。 -
扩展另一个数据类并实现多个接口的数据类——
Programmer
数据类扩展了抽象的Emp
数据类并实现了两个接口——Organizer
和Speaker
。Programmer
数据类必须实现Speaker
接口中的抽象conferenceTalk()
方法,才能作为非抽象数据类。
数据类可以实现单个或多个接口。
额外变量
虽然这是允许的,但在向数据类添加变量或字段之前,请自问,字段是否来自状态? 不是来自状态的字段会对数据类初始概念的严重违反。以下代码是一个定义额外字段 style
的示例,该字段来自 Emp
数据类的状态:
record Emp(String name, int age) {
private String style;
Emp(String name, int age) {
//.. initialize name and age
if (age => 15 && age =< 30) style = "COOL";
else if (age >= 31 && age <= 50) style = "SAFE";
else if (age >= 51) style = "ELEGANT";
}
public String getStyle() {
return style;
}
}
上述代码运行良好,因为 Emp
数据类的状态仍然来自其状态(name
和 age
字段)。getStyle
方法不会干扰 Emp
的状态;它纯粹是实现细节。
覆盖隐式行为
假设你想要限制在数据类实例化期间可以传递给字段的值。这是可行的;只需覆盖默认构造函数。以下是一个示例:
record Emp(String name, int age) {
// override default constructor
@Override
public Emp(String name, int age) {
// validate age
if (age > 70)
throw new IllegalArgumentException("Not employable above 70
years");
else {
// call default constructor
default.this(name, age);
}
}
}
同样,你可以覆盖对象方法的默认实现,例如 equals()
、hashCode()
和 toString()
,以及其他方法,如访问器方法。
覆盖数据类方法的默认行为并不会削弱其创建的目的。它们仍然作为数据类工作,对它们的功能有更精细的控制。让我们将其与之前用于建模数据类的 POJOs 进行比较。编译器不会为 POJO 自动生成任何方法。因此,用户仍然需要阅读所有代码,寻找不是其方法默认实现的代码。在数据类的情况下,这种覆盖行为非常明确。因此,用户不必担心阅读所有代码;他们可以假设行为有默认实现,而这种实现尚未被开发者覆盖。
明确地覆盖行为说明了数据类偏离其默认行为的地方,从而减少了用户为了理解其行为而必须阅读的代码量。
额外的方法和构造函数
编译器为数据类生成默认构造函数,以及访问器方法和对象类方法的默认实现。开发者可以重载构造函数并向数据类添加更多方法,如下所示:
record Emp(String name, int age) {
// overloading constructor
public Emp(String name, String style) {
this.name = name;
if (style.equals("COOL") age = 20;
else if (style.equals("SAFE") age = 30;
else if (style.equals("ELEGANT") age = 50;
else age = 70;
}
}
public String fancyOutput() { // additional method
return "My style is COOL";
}
}
可变性
关于数据类是否应指定为可变或不可变,工作仍在进行中。两种选项都有优点和缺点。不可变数据在多线程、并行或并发系统中表现良好。另一方面,可变数据在需要频繁修改数据的情况中表现良好。
关于线程安全,由于数据类尚未指定为不可变,使用它们进行线程安全配置是开发者的责任。
摘要
在本章中,我们讨论了使用 POJOs 来建模数据的挑战。我们介绍了数据类如何提供一种简单且简洁的方式来建模数据。数据类的实现将包括语言的变化,引入record
关键字。使用数据类的主要目标是建模数据,而不是减少样板代码。
我们还讨论了数据类的聚合和展开形式。数据类可以与其他语言结构一起使用,例如switch
。默认情况下,数据类是不可变的,包括定义为数据成员的数组。由于这些结构不是不可变的,开发者在处理它们时必须包含代码以确保线程安全。
在下一章中,你将了解更多关于一个令人兴奋的语言增强功能——原始字符串字面量。这难道意味着一个纯净的、未触动的字符串吗?通过继续阅读来找出答案。
第十五章:原始字符串字面量
你是否曾经因为使用字符串类型在 Java 中存储 SQL 查询而感到痛苦,因为需要多次使用开闭引号、单引号和双引号?更糟糕的是,你可能还使用了换行转义序列和连接运算符。类似的痛苦也适用于使用字符串类型与 HTML、XML 或 JSON 代码。如果你像我一样害怕所有这些组合,那么不必再害怕。原始字符串字面量就在这里,帮助你摆脱痛苦。
使用原始字符串字面量,你可以轻松地处理可读的多行字符串值,而不需要包含特殊的新行指示符。由于原始字符串字面量不解释转义序列,因此将转义序列作为字符串值的一部分包含变得非常简单。相关的类还包括对边距的管理。
在本章中,我们将涵盖以下主题:
-
使用转义序列在字符串值中的优点和困难
-
添加到
String
类的新方法 -
边距管理
-
分隔符
-
使用原始字符串与多行文本数据的示例
技术要求
本章中的代码将使用原始字符串字面量,这些字面量是为 JDK 12(2019 年 3 月)设计的。您可以克隆包含原始字符串字面量的存储库来实验它。
本章中所有代码都可以在github.com/PacktPublishing/Java-11-and-12-New-Features
找到。
快速示例
你是否迫不及待地想看到原始字符串的实际应用?我也是。在深入探讨导致原始字符串引入的问题之前,让我们先快速看一下示例。
以下代码展示了如何使用原始字符串字面量编写多行字符串值,使用反引号(`
)作为分隔符,而不使用连接运算符或特殊的新行或制表符指示符:
String html =
`<HTML>
<BODY>
<H1>Meaning of life</H1>
</BODY>
</HTML>
`;
现有多行字符串值的问题
在大多数编程语言中,包括 Java,创建多行字符串值是常见的。你可能使用它们来创建 HTML 代码、JSON 或 XML 值,或者 SQL 查询。但是,使用转义序列(新行或制表符指示符的转义序列)的这个看似简单的任务会变得复杂。
为了使你的多行字符串值可读,你可能会在单独的行上定义字符串值的一部分,使用连接运算符(+
)将它们粘合在一起。然而,随着字符串长度的增加,这可能会变得难以编写和理解。
让我们概述创建多行字符串的简单任务,然后使用多种方法将其存储为字符串值。
一个简单的任务
假设你必须使用字符串类型定义以下多行值,同时保持其缩进位置:
<HTML>
<BODY>
<H1>Meaning of life</H1>
</BODY>
</HTML>
传统字符串字面量的转义序列地狱
要向传统字符串值中添加换行符或制表符,你可以使用转义序列 \n
和 \t
。转义序列是前面带有 \
(反斜杠)的字母,它们在 Java 中有特殊含义。例如,在 Java 字符串中,\n
被用作换行符,而 \t
被用作制表符。
转义序列是用于表示不能直接表示的值的字母组合。例如,为了使用 ASCII 中的换行控制字符,Java 使用 \n
。Java 定义了多个其他转义序列。
以下代码展示了如何使用换行符和制表符转义序列存储多行字符串值。换行符和制表符转义序列将包含在变量 HTML 中的缩进:
String html = "<HTML>\n\t<BODY>\n\t\t<H1>Meaning in life</H1>\n\t</BODY>\n</HTML>";
如你所见,前面的代码可能难以编写。你必须弄清楚新行和计算目标字符串值中的制表符,并将它们作为 \n
或 \t
插入。另一个挑战是阅读此代码。你必须尝试逐行弄清楚代码的意图。
使用传统字符串字面量的连接地狱
以下是一个旨在通过在多行上定义字符串值的部分来使前面的代码可读的替代方案。然而,在这样做的时候,你应该使用多个字符串连接运算符(+
)和字符串分隔符("
):
String html = "<HTML>" +
"\n\t" + "<BODY>" +
"\n\t\t" + "<H1>Meaning of life</H1>" +
"\n\t" + "</BODY>" +
"\n" + "</HTML>";
我更愿意在前面代码中添加空格,使其更易于阅读:
String html = "<HTML>" +
"\n\t" + "<BODY>" +
"\n\t\t" + "<H1>Meaning of life</H1>" +
"\n\t" + "</BODY>" +
"\n" + "</HTML>";
尽管前面的代码在可读性方面看起来更好,但它将大量责任委托给了程序员,在正确的位置插入空格。作为快速提示,空格(双引号之外)不是变量 HTML 的一部分;它们只是使其易于阅读。作为一个开发者,编写这样的代码是一种痛苦。
Java GUI 库不与控制字符(如换行符)一起工作。因此,这种方法可以与 GUI 类一起使用。
将转义序列作为字符串值的一部分包含
想象一下,当由转义序列(比如说,\n
)表示的字母组合必须作为字符串值的一部分包含时会发生什么。让我们修改我们的示例,如下所示:
<HTML>
<BODY>
<H1>\n - new line, \t - tab</H1>
</BODY>
</HTML>
在这种情况下,你可以通过使用另一个反斜杠来转义 \n
序列,如下所示:
String html = "<HTML>" +
"\n\t" + "<BODY>" +
"\n\t\t" + "<H1>\\n - new line, \\t - tab</H1>"
+
"\n\t" + "</BODY>" +
"\n" + "</HTML>";
但是,随着更改,前面的代码变得难以阅读和理解。想象一下,你的团队中的开发者编写了这样的代码。让我们看看你如何可以使它更易于阅读。
作为一种权宜之计,你可以定义 String
常量(比如说,tab
或 newLine
),将 \t
和 \n
的值分配给它们。你可以使用这些常量代替前面代码中 \n
和 \t
的字面值。这种替换将使代码更容易阅读。
字符串和正则表达式模式,另一个地狱
本节将提供另一个转义序列地狱的例子——将正则表达式定义为 Java 字符串。
要移除单词字符和句点(.
)之间的空白(空格或制表符),你需要以下 regex(正则表达式)模式:
(\w)(\s+))[\.]
要使用 Java 字符串存储它,你需要以下代码:
String patternToMatch = "(\\w)(\\s+)([\\.,])"; // Isn't that a lot
// to digest?
// Does the regex
// pattern has
// a single
// backslash, or,
// two of them?
我在这里分享一个快速的秘密——当我开始使用 Java 时,我最可怕的噩梦之一就是将模式定义为字符串值。我承认;我写模式有困难,使用模式中的转义字符\
使我的生活变得痛苦。
这也被称为倾斜牙签综合症(LTS)——通过使用大量的反斜杠使字符串值难以阅读。
让我们通过使用原始字符串字面量来结束这种字符串的痛苦。
欢迎原始字符串字面量
原始字符串字面量是非解释字符串字面量。在实际项目中,你需要以原始方式处理字面量字符串值,而不需要对 Unicode 值、反斜杠或换行符进行特殊处理。原始字符串字面量将 Java 转义和 Java 行终止符规范放在一边,以便使代码可读性和可维护性。
使用原始字符串重写
你可以使用原始字面量来定义多行字符串值,如下所示:
String html =
`<HTML>
<BODY>
<H1>Meaning of life</H1>
</BODY>
</HTML>
`;
通过使用 `
作为开头和结尾分隔符,你可以轻松优雅地定义多行字符串字面量。
上述代码不包含 Java 指示符(连接运算符或转义序列)。
分隔符(反引号)
一个原始字符串字面量定义为如下:
RawStringDelimeter {RawStringCharacters} RawStringDelimeter
一个反引号用作原始字符串字面量的分隔符。我相信使用 `
作为原始字符串字面量的分隔符是一个好决定。`
反引号看起来像 '
(单引号)和 "
(双引号),它们已经被用作字符和字符串字面量的分隔符。`
反引号将帮助 Java 程序员轻松将其视为原始字符串的分隔符。
原始字符串字面量使用 `
作为分隔符。传统的字符串字面量使用 "
作为分隔符,字符使用 '
作为分隔符。
如果你希望将一个反引号作为字符串值的一部分,你可以使用两个反引号 (java ``
) 来定义你的值。这适用于 n 个反引号;只需确保匹配开闭反引号的数量。这听起来很有趣。让我们看看一个包含 `
作为其值一部分的例子:
String html =
``<HTML>
<BODY>
<H1>I think I like ` as a delimiter!</H1>
</BODY>
</HTML>
``;
下面是另一个例子,它使用字符串值中的多个反引号作为分隔符。当然,字符串值中包含的反引号数量不等于用作分隔符的反引号数量:
String html =
````<HTML>
<BODY>
<H1>I believe I would have liked ```java too(!)</H1>
</BODY>
</HTML>
````;
```java
If there is a mismatch between the count of backticks in the opening and closing delimiters, the code won't compile.
# Treating escape values
The Unicode and escape sequences are never interpreted in a raw string literal value.
A lexer is a software program that performs **lexical analysis**. Lexical analysis is the process of tokenizing a stream of characters into words and tokens. As you read this line, you are analyzing this string lexically, using the spaces to separate chunks of letters as words.
This analysis is disabled for the raw string literals at the start of the opening backtick. It is re-enabled at the closing backtick.
Don't replace the backtick in your string value with its Unicode escape (that is, `\u0060 in`), for consistency.
The only exceptions to this rule are `CR` (carriage return—`\u000D`) and `CRLF` (carriage return and line feed—`\u000D\u000A`). Both of these are translated to `LF` (line feed—`\u000A`).
# Raw string literals versus traditional string literals
The introduction of raw string literals will not change the interpretation of the traditional string literal values. This includes their multiline capabilities and how they handle the escape sequences and delimiters. A traditional string can include Unicode escapes (JLS 3.3) or escape sequences (JLS 3.10.6).
A Java class file, that is, the Java bytecode, does not record whether a string constant was created using a traditional string or a raw string. Both traditional and raw string values are stored as instances of the `java.lang.String` class.
# Interpreting escape sequences
To interpret escape sequences in multiline raw string values, Java will add methods to the `String` class—`unescape()` and `escape()`:
public String unescape() {...}
public String escape() {...}
Considering raw string literals at Oracle, work is in progress. Oracle is considering swapping the names of the methods `unescape()` and `escape()`, or even renaming them.
# The unescape() method
By default, the raw string literals don't interpret the escape sequences. However, what if you want them to do so? When used with a raw string, the `unescape()` method will match the sequence of the characters following `\` with the sequence of Unicode escapes and escape sequences, as defined in the **Java Language Specifications** (**JLS**). If a match is found, the escape sequence will not be used as a regular combination of letters; it will be used as an escape sequence.
The following code doesn't interpret `\n` as a newline escape sequence:
System.out.print("eJava");
System.out.print("\n");
System.out.print("Guru");
The output of the preceding code is as follows:
eJava\nGuru
However, the following code will interpret `\n` as a newline escape sequence:
System.out.print("eJava");
System.out.print(\n
.unescape()); // 不要忽略转义字符
System.out.print("Guru");
The output of the preceding code will provide the `eJava` and `Guru` string values on separate lines, as follows:
eJava
Guru
When interpreted as escape sequences, a combination of letters that is used to represent them, is counted as a control character of the length `1`. The output of the following code will be `1`:
System.out.print(\n
.unescape().length());
# The escape() method
The `escape()` method will be used to invert the escapes. The following table shows how it will convert the characters:
| **Original character** | **Converted character** |
| Less than `' '` (space) | Unicode or character escape sequences |
| Above `~` (tilde) | Unicode escape sequences |
| `"` (double quotes) | Escape sequence |
| `'` (single quote) | Escape sequence |
| `\` (backslash) | Escape sequence |
The following example doesn't include a newline in the output:
System.out.println("eJava" + "\n".escape() + "Guru");
The output of the preceding code is as follows:
eJava\nGuru
Consider the following code:
System.out.println("eJava" + •
.escape());
The output of the preceding code is as follows (`•` is converted to its equivalent escape value):
eJava\u2022Guru
# Managing margins
Suppose that you have to import multiline text from a file in your Java code. How would you prefer to treat the margins of the imported text? You may have to align or indent the imported text. The text might use a custom newline delimiter (say, `[`), which you might prefer to strip from the text. To work with these requirements, a set of new methods, such as `align()`, `indent()`, and `transform()`, are being added to the `String` class, which we'll cover in the next section.
# The align() method
When you define a multiline string value, you might choose to format the string against the left margin or align it with the indentation used by the code. The string values are stored with the margin intact. The `align()` method will provide incidental indentation support; it will strip off any leading or trailing blank lines, then justify each line, without losing the indentations.
The following is an example:
String comment =
`one
of
my
favorite
lang
feature
from Amber(!)
`.align();
System.out.println(comment);
The output of the preceding code is as follows:
one
of
我的
最爱
语言
功能
from Amber(!)
# The indent(int) method
The `indent(int)` method will enable developers to specify custom indentations for their multiline string values. You can pass a positive number, `i`, to `indent(int)`, to add `i` spaces (`U+0020`) to your text, or you can pass a negative number, to remove a given number of whitespaces from each line of your multiline text.
An example is as follows:
String comment =
`one
of
我的
最爱
语言
功能
from Amber(!)
`.align().indent(15);
System.out.println(comment);
The output of the preceding code is as follows:
one
of
我的
最爱
语言
功能
from Amber(!)
The `indent(int)` method can be used to add or remove whitespaces from each line in a multitext value. By passing a positive number, you can add whitespaces, and you can remove them by passing negative values.
# The overloaded align(int) method
The `align(int)` method will first align the rows of a multistring value, and will then indent it with the specified spaces. The following is an example:
String comment =
`one
of
我的
最爱
语言
功能
from Amber(!)
`.align(15);
System.out.println(comment);
The output of the preceding code is as follows (the text on each line is preceded by fifteen spaces):
one
of
我的
最爱
语言
功能
from Amber(!)
# The detab(int) and entab methods
A tab (`U+0009`) usually represents four whitespaces (`U+0020`). However, not all of the applications convert between tabs and whitespaces when they use text that includes a mix of whitespaces and tabs. To combat this challenge with the multiline text, the `String` class will include two methods, `detab(int)` and `entab(int)`, which will convert a tab to whitespaces, and vice versa:
public String detab(int)
public String entab(int)
Let's modify the preceding example, so that the content includes tabs instead of whitespaces, as follows:
String comment =
`one
of
我的
最爱
语言
功能
from Amber(!)
`.detab(1);
The output of the preceding code is as follows (each tab is converted to one whitespace):
one
of
我的
最爱
语言
功能
from Amber(!)
# The transform() method
Suppose that a file includes the following text, using `[` at the beginning of a new line:
[ 讲座 - Java11, Amber, CleanCode
[ 海洋 - 塑料污染,人类冷漠
Now, suppose that you must remove the delimiter, `[`, used at the beginning of all lines. You can use the `transform()` method to customize the margin management, adding the `String` class:
The following is an example that uses the method `transform()` to remove the custom margin characters from the multiline text:
String stripped = `
[ 讲座 - Java11, Amber, CleanCode
[ 海洋 - 濒临灭绝,人类冷漠,塑料
污染
`.transform({
multiLineText.stream()
.map(e -> e.map(String::strip)
.map(s -> s.startsWith("[ ") ?
s.substring("[ ".length())
: s)
.collect(Collectors.joining("\n", "", "\n"));
});
The next section will include some common cases where using raw string literals over traditional strings will benefit you tremendously.
# Common examples
If you have used stored JSON, XML data, database queries, or file paths as string literals, you know it is difficult to write and read such code. This section will highlight examples of how a traditional raw string will improve the readability of your code when working with these types of data.
# JSON data
Suppose that you have the following JSON data to be stored as a Java string:
{"plastic": {
"id": "98751",
"singleuse": {
"item": [
{"value": "Water Bottle", "replaceWith": "Steel Bottle()"},
{"value": "Straw", "replaceWith": "Ban Straws"},
{"value": "Spoon", "replaceWith": "Steel Spoon"}
]
}
}}
The following code shows how you would perform this action with the traditional `String` class, escaping the double quotes within the data by using `\` and adding a newline escape sequence:
String data =
"{"plastic": { \n" +
""id": "98751", \n" +
""singleuse": { \n" +
"\"item\": [ \n" +
"{\"value\": \"Water Bottle\", \"replaceWith\": \"Steel
Bottle()\"}, \n" +
"{\"value\": \"Straw\", \"replaceWith\": \"Ban Straws\"}, \n" +
"{\"value\": \"Spoon\", \"replaceWith\": \"Steel Spoon\"} \n" +
"] \n" +
"} \n" +
"}}";
To be honest, it took me quite a while to write the preceding code, escaping the double quotes with the backslash. As you can see, this code is not readable.
The following example shows how you can code the same JSON data using raw string literals, without giving up the code readability:
String data =
"id": "98751",
"singleuse": {
"item": [
{"value": "Water Bottle", "replaceWith": "Steel Bottle()"},
{"value": "Straw", "replaceWith": "Ban Straws"},
{"value": "Spoon", "replaceWith": "Steel Spoon"}
]
}
}}```;
```java
# XML data
The following code is an example of XML data that you might need to store using a Java string:
<item value="Water Bottle" replaceWith="Steel bottle" />
<item value="Straw" replaceWith="Ban Straws" />
<item value="spoon" replaceWith="Steel Spoon" />
The following code shows how you can define the preceding data as a `String` literal by using the appropriate escape sequences:
String data =
"<plastic id="98751">\n" +
"
"<item value=\"Water Bottle\" replaceWith=\"Steel bottle\" />\n" +
"<item value=\"Straw\" replaceWith=\"Ban Straws\" />\n" +
"<item value=\"spoon\" replaceWith=\"Steel Spoon\" />\n" +
"\n" +
"";
Again, the escape sequences added to the preceding code (`\` to escape `"` and `\n` to add newline) make it very difficult to read and understand the code. The following example shows how you can drop the programming-specific details from the data by using raw string literals:
String dataUsingRawStrings =
<plastic id="98751">
<singleuse>
<item value="Water Bottle" replaceWith="Steel bottle" />
<item value="Straw" replaceWith="Ban Straws" />
<item value="spoon" replaceWith="Steel Spoon" />
</singleuse>
</plastic>
```;
```java
# File paths
The file paths on Windows OS use a backslash to separate the directories and their subdirectories. For instance, `C:\Mala\eJavaGuru` refers to the `eJavaGuru` subdirectory in the `Mala` directory in the `C:` drive. Here's how you can store this path by using a traditional string literal:
String filePath = "C:\Mala\eJavaGuru\ocp11.txt";
With raw string literals, you can store the same file path as follows (changes are highlighted in bold):
String rawFilePath = C:\Mala\eJavaGuru\ocp11.txt
;
# Database queries
Suppose that you have to create an SQL query that includes the column names, table names, and literal values. The following code shows how you would typically write it, using traditional strings:
String query = "SELECT talk_title, speaker_name " +
"FROM talks, speakers " +
"WHERE talks.speaker_id = speakers.speaker_id " +
"AND talks.duration > 50 ";
You can also write the code as follows, using quotes around the column or table names, depending on the target database management system:
String query = "SELECT 'talk_title', 'speaker_name' " +
"FROM 'talks', 'speakers' " +
"WHERE 'talks.speaker_id' = 'speakers.speaker_id' " +
"AND 'talks.duration' > 50 ";
The raw string literal values are much more readable, as shown in the following code:
String query =
FROM 'talks', 'speakers'
WHERE 'talks.speaker_id' = 'speakers.speaker_id'
AND 'talks.duration' > 50
```;
摘要
在本章中,我们讨论了开发者在将各种类型的多行文本值作为字符串值存储时面临的挑战。原始字符串字面量解决了这些问题。通过禁用转义字符和转义序列的词法分析,原始字符串字面量显著提高了多行字符串的可写性和可读性。原始字符串字面量将为String
类引入多个方法,例如unescape()
、escape()
、align()
、indent()
和transform()
。这些方法共同使得开发者能够专门处理原始字符串字面量。
在下一章中,我们将介绍 lambda 遗留项目如何改善 Java 中的函数式编程语法和体验。
第十六章:Lambda 剩余
想象一下在方法或 lambda 表达式中标记未使用参数的便利性,并且不需要传递任何任意值以符合语法。此外,当与 lambda 一起工作时,想象一下声明 lambda 参数名称的能力,而不必关心在封装作用域中是否已经使用了相同的变量名。这就是 lambda 剩余(JEP 302)提供的内容,以增强 lambda 和方法引用。除了这些增强功能之外,它还将提供更好的方法中函数表达式的去歧义(这在 JDK 增强提案(JEP)中被标记为可选,截至目前)。
在本章中,我们将涵盖以下主题:
-
使用下划线标记未使用的方法参数
-
隐藏 lambda 参数
-
函数表达式去歧义
技术要求
本章中的代码将使用 lambda 剩余(JEP 302)中定义的功能,该功能尚未针对 JDK 发布版本进行目标化。要实验代码,你可以克隆相关的仓库。
本章中的所有代码都可以在 github.com/PacktPublishing/Java-11-and-12-New-Features
上访问。
让我们从了解为什么需要标记未使用的 lambda 参数开始。
使用下划线标记未使用参数
假设你在餐厅被提供了四种食物,但你没有吃掉其中的三种。如果餐厅有让顾客标记 ort(字面意思是 已使用)和非 ort 食物的协议,那么桌上的食物可以以某种方式使用。例如,餐厅可以将非 ort 项目标记为 可食用,并考虑与需要的人分享。
类似地,在调用方法或 lambda 表达式时,你可能不需要所有的方法参数。在这种情况下,向编译器传达你的意图(即某些参数没有被使用)是一个好主意。这有两个好处——它节省了编译器检查不必要值的类型检查,并且它节省了你传递任何任意值以匹配代码定义和代码调用语法的麻烦。
lambda 参数的示例
以下是一个示例,演示了如何使用下划线 _
来标记未使用的 lambda 参数。以下代码使用了可以接受两个参数(T
和 U
)并返回类型为 R
的值的 BiFunction<T, U, R>
函数式接口:
BiFunction<Integer, String, Boolean> calc = (age, _) -> age > 10;
在前面的示例中,由于分配给 BiFunction
函数式接口的 lambda 表达式只使用了其中一个方法参数(即 age
),JEP 302 建议使用下划线来标记未使用的参数。
以下代码突出了一些用例,以说明在没有标记未使用的 lambda 参数的便利性下,如何使用相同的代码(注释说明了代码传递给未使用参数的值):
// Approach 1
// Pass null to the unused parameter
BiFunction<Boolean, Integer, String> calc = (age, null) -> age > 10;
// Approach 2
// Pass empty string to the unused parameter
BiFunction<Boolean, Integer, String> calc = (age, "") -> age > 10;
// Approach 3
// Pass ANY String value to the unused parameter -
// - doesn't matter, since it is not used
BiFunction<Boolean, Integer, String> calc =
(age, "Ban plastic straws") -> age > 10;
// Approach 4
// Pass any variable (of the same type) to the unused parameter -
// - doesn't matter, since it is not used
BiFunction<Boolean, Integer, String> calc = (age, name) -> age > 10;
许多其他函数式编程语言使用类似的符号来标记未使用的参数。
到达那里的旅程
使用下划线标记未使用参数需要对 Java 语言进行更改。
直到 Java 8,下划线被用作有效的标识符。第一步是拒绝使用下划线作为常规标识符的权限。因此,Java 8 对使用 _
作为 lambda 形参名称的用法发出了编译器警告。这是简单的,并且没有向后兼容性问题,因为 lambda 在 Java 8 中被引入。继续前进,Java 9 将使用下划线作为参数名称的编译器警告消息替换为编译错误。
使用 JEP 302,开发者可以使用 _
来标记未使用的方法参数,用于以下情况:
-
Lambdas
-
方法
-
捕获处理器
在下一节中,你将看到(在将来)你的 lambda 参数将能够遮蔽其封闭作用域中具有相同名称的变量。
Lambda 参数的遮蔽
Java 不允许在多个场景下声明具有相同名称的变量。例如,你无法在具有相同名称的类中定义实例和静态变量。同样,你无法在方法中定义具有相同名称的方法参数和局部变量。
然而,你可以定义与类中的实例或 static
变量具有相同名称的局部变量。这并不意味着你以后不能访问它们。要从方法中访问实例变量,你可以在变量名称前加上 this
关键字。
这些限制允许你访问作用域内的所有变量。
现有的 lambda 参数案例
当你编写 lambda 表达式时,你可以定义多个参数。以下是一些定义单个或多个参数的 lambda 示例:
key -> key.uppercase(); // single lambda parameter
(int x, int y) -> x > y? x : y; // two lambda parameters
(a, b, c, d) -> a + b + c + d; // four lambda parameters
让我们使用前面代码中的一个 lambda 来进行流处理。以下示例将提供 List
中的字符串值作为输出,并转换为大写:
List<String> talks = List.of("Kubernetes", "Docker", "Java 11");
talks.stream()
.map(key -> key.toUpperCase())
.forEach(System.out::prinltn);
到目前为止,一切顺利。但是,如果前面的代码定义在具有局部变量(例如,key
(在第 3 行的代码上))的方法(例如,process()
)中,并且该局部变量与 key
lambda 参数(在第 5 行定义和使用)的名称重叠,会发生什么?请看以下代码:
1\. void process() {
2\. List<String> talks = List.of("Kubernetes", "Docker", "Java 11");
3\. String key = "Docker"; // local variable key
4\. talks.stream()
5\. .map(key -> key.toUpperCase()) // WON'T compile: 'key'
redefined
6\. .forEach(System.out::prinltn);
7\. }
目前,前面的代码无法编译,因为用于 map()
方法的 lambda 表达式中的 key
变量无法遮蔽在 process()
方法中定义的局部 key
变量。
为什么 lambda 参数应该遮蔽封闭变量?
当你编写 lambda 表达式时,你(通常)定义参数名称作为指示如何处理分配给它们的值的指示符。它们不是用来在封闭块中重用由变量引用的现有值的。
让我们回顾一下前面的代码:
1\. String key = "Docker"; // local variable key
2\. talks.stream()
3\. .map(key -> key.toUpperCase()) // WON'T compile : 'key'
// redefined
4\. .forEach(System.out::println);
在前面的代码中,第3
行(以粗体显示)的 lambda 表达式定义了一个 lambda 参数key
,指定当传递一个值给它时,Java 应该在该值上调用toUpperCase()
方法并返回结果值。
从这个例子中可以看出,key
lambda 参数似乎与在封闭块中定义的局部key
变量无关。因此,lambda 参数应该允许覆盖封闭块中具有相同名称的变量。
一些已知的问题
目前尚不清楚 lambda 表达式是否能够访问被 lambda 参数覆盖的封闭变量的值;如果可以,它是如何做到的?
例如,让我们通过将toUppercase()
方法的调用替换为concat()
方法的调用来修改前面的代码(变化以粗体显示):
1\. String key = "Docker"; // local variable key
2\. talks.stream()
3\. .map(key -> key.concat(key))
4\. .forEach(System.out::prinltn);
在前面的代码中,想象一下第3
行的 lambda 表达式需要访问第1
行定义的key
变量的值,因为它想要将其传递给concat()
方法。到目前为止,尚未最终确定这是否会被允许。
如果允许这样做,Java 将需要找到一种方法来标记并清楚地区分 lambda 参数和封闭块中具有相同名称的其他变量。这将对于代码可读性是必要的——正如您所知,这是很重要的。
被覆盖的封闭变量的可访问性是与 lambda 参数覆盖相关的主要问题。
在下一节中,我们将探讨 Java 如何尝试解决重载方法调用,这些调用将函数式接口作为参数。
函数表达式的歧义消除
如果您认为 Java 是通过var
关键字(Java 10)开始其类型推断之旅的,那么您需要重新考虑。类型推断是在 Java 5 中引入的,并且从那时起一直在增加覆盖范围。
在 Java 8 中,重载方法的解析被重构,以便允许使用类型推断。在引入 lambda 表达式和方法引用之前,对方法的调用是通过检查传递给它的参数类型(不考虑返回类型)来解决的。
在 Java 8 中,隐式 lambda 和隐式方法引用无法检查它们接受的值的类型,这导致了编译器能力的限制,以排除对重载方法的模糊调用。然而,编译器仍然可以通过其参数检查显式 lambda 和方法引用。为了您的信息,明确指定其参数类型的 lambda 被称为显式 lambda。
限制编译器的能力和以这种方式放宽规则是有意为之的。这降低了 lambda 类型检查的成本,并避免了脆弱性。
虽然这是一个有趣的功能,但函数表达式的歧义消除被计划为 JEP 302 中的一个可选功能,因为 Oracle 需要评估其对编译器实现的影响。
在深入研究提出的解决方案之前,让我们看看现有的问题,使用代码示例。
解决重载方法解析问题——传递 lambda 表达式
让我们讨论一下当 lambda 表达式作为方法参数传递时解决重载方法解析的现有问题。让我们定义两个接口,Swimmer
和 Diver
,如下所示:
interface Swimmer {
boolean test(String lap);
}
interface Diver {
String dive(int height);
}
在以下代码中,重载的 evaluate
方法接受 Swimmer
和 Diver
接口作为方法参数:
class SwimmingMeet {
static void evaluate(Swimmer swimmer) { // code compiles
System.out.println("evaluate swimmer");
}
static void evaluate(Diver diver) { // code compiles
System.out.println("evaluate diver");
}
}
让我们在以下代码中调用重载的 evaluate()
方法:
class FunctionalDisambiguation {
public static void main(String args[]) {
SwimmingMeet.evaluate(a -> false); // This code WON'T compile
}
}
重新审视先前的代码中的 lambda:
a -> false // this is an implicit lambda
由于先前的 lambda 表达式没有指定其输入参数的类型,它可以是 String
(test()
方法和 Swimmer
接口)或 int
(dive()
方法和 Diver
接口)。由于调用 evaluate()
方法的调用是模糊的,所以无法编译。
让我们在先前的代码中添加方法参数的类型,使其成为一个显式的 lambda:
SwimmingMeet.evaluate((String a) -> false); // This compiles!!
现在先前的调用不再模糊不清;lambda 表达式接受一个 String
类型的输入参数,并返回一个 boolean
值,这映射到接受 Swimmer
作为参数的 evaluate()
方法(Swimmer
接口中的功能 test()
方法接受一个 String
类型的参数)。
让我们看看如果将 Swimmer
接口修改为将 lap
参数的数据类型从 String
改为 int
会发生什么。为了避免混淆,所有代码将重复,修改的地方用粗体标出:
interface Swimmer { // test METHOD IS
// MODIFIED
boolean test(int lap); // String lap changed to int lap
}
interface Diver {
String dive(int height);
}
class SwimmingMeet {
static void evaluate(Swimmer swimmer) { // code compiles
System.out.println("evaluate swimmer");
}
static void evaluate(Diver diver) { // code compiles
System.out.println("evaluate diver");
}
}
考虑以下代码,思考哪一行代码可以编译:
1\. SwimmingMeet.evaluate(a -> false);
2\. SwimmingMeet.evaluate((int a) -> false);
在先前的例子中,两个行号上的代码都无法编译,原因相同——编译器无法确定对重载的 evaluate()
方法的调用。由于两个功能方法(即 Swimmer
接口中的 test()
和 Diver
接口中的 dive()
)都接受一个 int
类型的单个方法参数,编译器无法确定方法调用。
作为一名开发者,你可能会争辩说,由于 test()
和 dive()
方法的返回类型不同,编译器应该能够推断出正确的调用。只是为了重申,方法的返回类型不参与方法重载。重载方法必须在参数的数量或类型上返回。
方法的返回类型不参与方法重载。重载方法必须在参数的数量或类型上返回。
解决重载方法解析问题——传递方法引用
你可以定义具有不同参数类型的重载方法,如下所示:
class Championship {
static boolean reward(Integer lapTime) {
return(lapTime < 60);
}
static boolean reward(String lap) {
return(lap.equalsIgnoreCase("final ");
}
}
然而,以下代码无法编译:
someMethod(Chamionship::reward); // ambiguous call
在先前的代码行中,由于编译器不允许检查方法引用,代码无法编译。这是不幸的,因为重载方法的参数是 Integer
和 String
——没有值可以与两者兼容。
提出的解决方案
对于使用 lambda 表达式或方法引用的重载方法,涉及到的意外编译问题可以通过允许编译器将它们的返回类型视为“也”来解决。然后编译器就能够选择正确的重载方法并消除不匹配的选项。
摘要
对于使用 lambda 和方法引用的 Java 开发者,本章展示了 Java 在管道中有什么可以帮助缓解问题的计划。
Lambda Leftovers (JEP 302) 提出在 lambda、方法和 catch 处理程序中使用下划线来表示未使用的参数。它计划允许开发者定义可以覆盖其封闭块中同名变量的 lambda 参数。功能表达式的歧义消除是一个重要且强大的功能。它将允许编译器考虑 lambda 的返回类型,以确定正确的重载方法。由于它可能影响编译器的工作方式,因此这个特性在这个 JEP 中被标记为可选。
在下一章,关于模式匹配和 switch 表达式的章节中,你将了解正在添加到 Java 语言中的令人兴奋的功能。
本章没有为读者包含任何编码练习。方法的重载不涉及方法的返回类型。重载的方法必须通过其参数的数量或类型来返回。
第十七章:模式匹配
作为一名 Java 程序员,想象一下,如果你有选择跳过使用instanceof
运算符和显式类型转换运算符来从你的对象中检索值。模式匹配(JDK 增强提案(JEP)305)通过添加类型测试模式和常量模式来解决这个痛点。它增强了 Java 编程语言,引入了允许你确定实例和派生类的类型,并访问它们的成员而不使用显式类型转换的功能。
在本章中,我们将涵盖以下主题:
-
模式匹配
-
类型测试模式
-
常量模式
技术要求
本章中的代码使用了模式匹配(JEP 305)中定义的功能,这些功能尚未针对任何 JDK 发布版本进行目标化。要实验代码,你可以克隆相关的仓库。
本章中所有代码都可以在github.com/PacktPublishing/Java-11-and-12-New-Features
找到.
让我们讨论使用instanceof
和显式类型转换运算符的问题。
模式匹配
模式匹配将增强 Java 编程语言。首先,它将添加类型测试模式和常量模式,这些模式将由switch
语句和matches
表达式支持。随后,这个 JEP 可能会扩展支持的模式和语言结构。
模式匹配是一种古老的技巧(大约 65 年历史),它已被各种语言所采用并使用,如面向文本、函数式(Haskell)和面向对象语言(Scala、C#)。
模式是以下组合:
-
谓词
-
一个目标
-
一组绑定变量
当一个谓词成功应用于一个目标时,会从目标中提取一组绑定变量。本章涵盖的模式包括类型测试模式和常量模式。
在详细处理示例之前,让我们了解现有问题和为什么我们需要模式匹配。
类型测试存在的问题
作为一名 Java 开发者,你应该已经与以下块中加粗的高亮代码一起工作过:
Object obj = new Ocean(); // variable type - Object
if (obj instanceof Ocean) { // check instance type
System.out.println(((Ocean)obj).getBottles()); // cast & extract value
}
// A basic class - Ocean
class Ocean {
private long bottles;
public long getBottles() {
return bottles;
}
}
上述代码包括三个步骤来使用bottles
变量的值:
-
obj instanceof Ocean
:测试obj
变量的类型 -
(Ocean)obj
:将引用变量obj
转换为Ocean
-
((Ocean)obj).getBottles()
: 通过销毁实例来获取值
作为开发者,我们长期以来一直在编写类似的代码,但也在秘密地讨厌它。这就像一遍又一遍地重复相同的指令。这些测试、铸造和分解实例以提取值的步骤是不必要的冗长。我们都知道代码重复是错误被忽视的最好方式之一。更糟糕的是,当在一个地方有多个代码重复实例时,它只会变得更糟。以下代码示例就是一个例子:
void dyingFish(Object obj) {
if (obj instanceof Ocean) { // test
System.out.println(((Ocean)obj).getBottles()); // cast &
// destruct
}
else if (obj instanceof Sea) { // test
System.out.println(((Sea)obj).getDeadFish());
}
else if (obj instanceof River) { // test
if ( ((Ocean)obj).getPlasticBags() > 100) { // cast &
// destruct
System.out.println("Say no to plastic bags. Fish are dying!");
}
}
}
class Ocean { .. }
class Sea { .. }
class River { .. }
当你添加更多测试-铸造-实例销毁模式的实例来检索字段值时,你会在由语言引起的复杂性中失去业务逻辑。开发者直接复制并粘贴这样的代码并修改不同的部分是非常常见的——但同样常见的是一些代码部分被保留不变(这要么成为逻辑错误,要么应该被标记为复制粘贴错误)。
这段代码也难以优化;即使底层问题通常是 O(1)
,它的时间复杂度也将是 O(n)
。
类型测试模式
为了解决测试-铸造-实例销毁模式造成的问题,Java 提议采用模式匹配。
这里是一个语言提出的更改示例:
Object obj = new Ocean(); // variable type - Object
if (obj matches Ocean o) { // check & bind
System.out.println(o.getBottles()); // extract
}
上述代码引入了一个新的 Java 关键字 matches
,它包括一个 谓词 (obj
) 和一个 目标 (Ocean o
)。谓词,即 obj
应用到目标,即 Ocean o
,将 o
变量绑定到由 obj
指代的实例。如果匹配成功,你可以使用 bound
变量,即 o
,来访问实例的成员。以下图表比较了使用 instanceof
和 matches
的代码更改。显然,matches
操作符从代码中移除了丑陋的显式转换:
让我们看看类型匹配是否可以简化我们代码中的多次出现:
void dyingFish(Object obj) {
if (obj matches Ocean o) { // check & bind
System.out.println(o.getBottles()); // extract
}
else if (obj matches Sea sea) { // test
System.out.println(sea.getDeadFish());
}
else if (obj matches River riv) { // test
if (riv.getPlasticBags() > 100) { // cast & destruct
System.out.println("Say no to plastic bags. Fish are
dying!");
}
}
}
class Ocean { .. }
class Sea { .. }
class River { .. }
测试模式不仅限于 if
-else
语句。让我们看看它如何与 switch
构造一起使用。
使用模式匹配与 switch
构造
switch
语句似乎是可以使用模式匹配的最好构造之一。目前,switch
构造可以匹配原始字面量值(不包括 long
、float
和 double
)、String
和枚举常量。
如果 case
标签可以指定一个模式,前面部分(使用多个对象检查和值提取的实例)的代码可以修改如下:
void dyingFish(Object obj) {
switch (obj) {
case Ocean o: System.out.println(o.getBottles());
break;
case Sea sea: System.out.println(sea.getDeadFish());
break;
case River riv: if (riv.getPlasticBags() > 100) {
System.out.println("Humans enjoy! Fish die!");
}
break;
}
}
使用模式匹配,业务逻辑成为焦点。它还简化了语法的复杂性,从而提高了代码的可读性。前面的代码也是可优化的,因为我们很可能会在 O(1)
时间内进行调度。
在模式匹配中,也在对解构模式(与实例构造相反)进行工作。
摘要
在本章中,你了解了模式匹配将如何改变你的日常代码。模式匹配引入了一个新的关键字matches
,以简化从实例中检查、转换和检索值的过程。
这本书带你了解了最新的 Java 版本——10、11 和 12,以及 Amber 项目。Java 拥有强大的发展路线图,并且凭借其现有特性和新功能,继续激发开发者和企业的热情。随着新的六个月发布节奏,Java 的发展速度之快是我们之前未曾见过的。作为开发者,你将比以往任何时候都能更早地使用到新的 Java 特性和改进。
我鼓励所有开发者检查新 Java 版本的改进和新增功能,因为它们一旦发布。同时,也不要错过在 Oracle 网站上浏览诸如 Valhalla、Loom、Panama 以及许多其他项目。这些项目将提升 Java 的能力,例如轻量级线程、更简单的外部库访问,以及新的语言候选者,如值类型和泛型特化。敬请期待!