首先感谢尚硅谷录制的Java11视频教程,可以免费下载观看

之前在ytb上看了个java9的新特性,里面提到了两个主要更新,jshell和module

现在java11又发布很久了,再不学真要过时了

java8是个官方长期支持版本,java11也是,9,10只是过渡版本,所以完全可以路过9,10直接学习11,这一篇博客也是根据视频内容整理出来的,我觉得文字看的方便些,如果你觉得视频好看,也可以去尚硅谷官方下载视频查看

jshell

jshell在java9里被提出来的,就是可以直接在终端里写java程序了,回车就可以执行,不用先创建java文件,然后编译成class文件,最后再执行了,它把这些步骤都省了,打开终端,输入jshell,回车之后会进入jshell的环境,然后就可以直接写你要测试的代码了

类似于nodejs中直接在终端里输入 node 和python中直接在终端里输入 python 回车是一样的

如果不会用可以通过 /help 查看里面都有哪些命令

jshell默认会导入一些包,如下

import java.lang.*;
import java.io.*;
import java.math.*;
import java.net.*;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.*;
import java.util.function.*;
import java.util.prefs.*;
import java.util.regex.*;
import java.util.stream.*;

默认导入了有这些包,也就是说在jshell环境里可以默认使用这些包里的东西

Module

Module也是在Java9里就加入的功能,这里说一下用法,我这里用的是idea操作的

新建一个java项目,然后将项目下的src删掉,然后对项目右键,选择 New -> Module 填上Module的名字

我这创建了两个Module,分别是 core, main

在core下有个包 co.yiiu.core 包里有个类,如下

package co.yiiu.core;

/**
 * Created by tomoya at 2019-02-22
 */
public class User {

  private String username;
  private Integer age;

  public String getUsername() {
    return username;
  }

  public void setUsername(String username) {
    this.username = username;
  }

  public Integer getAge() {
    return age;
  }

  public void setAge(Integer age) {
    this.age = age;
  }

  @Override
  public String toString() {
    return "User{" +
        "username='" + username + '\'' +
        ", age=" + age +
        '}';
  }
}

然后在core里的src目录下新建一个 module-info.java 里面内容如下

module core {
  exports co.yiiu.core;
}

可以看到这里用到了 exports 关键字,意思是将 co.yiiu.core 包暴露出去,别的模块在引入core模块的时候就可以使用core模块里这个包下的类了

然后是main模块,也是一样,创建一个包 co.yiiu.main ,包下有个类 Main.java 内容如下

package co.yiiu.main;

import co.yiiu.core.User;

public class Main {
  public static void main(String[] args) {
    User user = new User();
    user.setUsername("tomoya");
    user.setAge(11);
    System.out.println(user.toString());
  }
}

如果你跟着操作,到这一步Main.java里会报错,说找不到User类,这时在main模块的src里新建一个 module-info.java 文件,引入core模块即可

module main {
  requires core;
}

这里用到了 requires 关键字,意思是引入其它模块的意思,再看看Main.java文件,就没有错误信息了,然后运行 Main.java 的main方法,可以看到输出内容 User{username='tomoya', age=11}


当然也可以引入jdk暴露的模块,比如 java.sql

在自己需要用到java.sql模块的 module-info.java 里引入,比如我在 main模块下引入

module main {
  requires core;
  requires java.sql;
}

然后就可以在Main.java类中使用java.sql里的类了,比如获取数据库连接

package co.yiiu.main;

import co.yiiu.core.User;

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;

public class Main {
  public static void main(String[] args) throws SQLException, ClassNotFoundException {
    User user = new User();
    user.setUsername("tomoya");
    user.setAge(11);
    System.out.println(user.toString());

    // 获取数据库连接,这没写完,做个测试
    Connection connection = DriverManager.getConnection("jdbc:mysql://localhost:3306/pybbs");

  }
}

这里只介绍了简单使用,如果想了解更多,可以查看这篇文章 https://www.baeldung.com/java-9-modularity

var关键字

这个关键字是java10里增加的,类似于javascript里的var,什么变量都可以用它来定义,至于变量是什么类型的,java在编译的时候会根据变量的值来推断

其实在java7里就有了对泛型推断的功能,比如定义一个集合

List<Integer> list = new ArrayList<>();

第二个泛型没有写,程序在编译的时候会根据第一个泛型的类型来推断这个集合的泛型是啥,java10里引入的var定义变量也是一样的原理,只不过var声明的变量是根据值来推断变量的类型

注意

  • var a; 这样是不允许的因为它要根据后面的值来推断变量的类型,这里没有值所以不行,不过也不是所有的这种写法都不行,在lambda表达式中就可以这样来写,比如
    • Consumer<String> consumer = (var t) -> System.out.println(t.toUpperCase()); 这里的 var t 就是根据前面定义 consumer 的时候指定的泛型来推断的
    • 在lambda表达式中给变量加上var就相当于给这个变量加上了个注解 @Nonnull ,意思就是这个变量不能为空
  • 类的属性的数据类型不可以使用var

增强API

集合of() - since 9

这个方法是在java9的时候加入的,我记得springboot2.x就有of方法了

看看用法吧

// 之前定义一个集合
List<String> list = new ArrayList<>();
list.add("aa");
list.add("bb");
list.add("cc");
System.out.println(list);

// 之前简写的方法
List<String> list = Arrays.asList("aa", "bb", "cc");
System.out.println(list);

// of写法
List<String> list = List.of("aa", "bb", "cc");
System.out.println(list);

其实跟 asList() 方法类似

注意

asList()of() 两种方法创建的集合都是只读的集合,不可新增元素


Set也有of方法,跟List一样用法,只是如果用传统的方式 Set<Integer> set = new HashSet<>(); 的话,当添加重复元素的时候,会覆盖之前的元素,但如果用of方法的创建set集合的话,出现重复元素会直接抛异常

原链文接:https://tomoya92.github.io/2019/02/21/java11/

Stream - since 8

Stream在java8的时候就引入了

流的处理三个步骤

  1. 创建
  2. 中间操作
  3. 终止操作

通过of()创建一个流

Stream<Integer> stream = Stream.of(1,2,4,5,6,7);
stream.forEach(System.out::println);

// -------------------

Stream<Integer> stream = Stream.of(); // 创建一个空流
stream.forEach(System.out::println);
stream.forEach(System.out::println);

// -------------------

// 这种创建会抛异常,因为传入一个null,会被解析成一个数组对象,然后进一步访问null的长度,自然就报错了
Stream<Integer> stream = Stream.of(null);
stream.forEach(System.out::println);

// -------------------

// 上一个例子中展示了不能通过 of() 创建一个空流,但可以通过 ofNullable() 函数创建一个空流,下面例子不会抛出异常
Stream<Integer> stream = Stream.ofNullable(null);
stream.forEach(System.out::println);

takeWhile() 和 dropWhile()

take是拿的意思,drop是丢弃的意思,这两个方法意思是判断方法参数里的条件,如果为true就拿/丢然后生成一个新流,看下面例子

Stream<Integer> stream = Stream.of(3,9,20,22,40,7);
Stream<Integer> stream1 = stream.takeWhile(t -> t % 2 != 0);
stream1.forEach(System.out::println); // 3,9

可以看到上面例子打印出来结果只有3,9,说明takeWhile是一直从流中获取到判定器判断为true的元素,只要碰到false了就终止了,不管后面还有没有符合条件的元素

dropWhile() 方法例子

Stream<Integer> stream = Stream.of(3,9,20,22,40,7);
Stream<Integer> stream1 = stream.dropWhile(t -> t % 2 != 0);
stream1.forEach(System.out::println); // 20,22,40,7

dropWhile() 方法跟takeWhile()方法正好相反,dropWhile()是判定器判断为true的舍弃,直到找到为false的时候,就不再判断了,直接把后面所有的元素全拿到然后生成一个新流,所以也就有了结果为 20,22,40,7


流的迭代,创建流

// java8里的方法 iterate(final T seed, final UnaryOperator<T> f)

// 创建一个无限流,起始值是1,后面每一项都在前一项的基础上 * 2 + 1
Stream<Integer> stream = Stream.iterate(1, t -> 2 * t + 1);
// 因为这是一个无限流,如果不加limit限制一下的话,它就会一直打印,所以这里限制一下只打印10个,结果如下
stream.limit(10).forEach(System.out::println);
//1,3,7,15,31,63,127,255,511,1023

// ------------------------------------------------------------

// java9 里新增的方法 iterate(T seed, Predicate<? super T> hasNext, UnaryOperator<T> next)
// 这个视频里说错了,这个方法是java9里就加入了,不是java11里新增的,新增了一个判定器,这样就不会出现无限流了,如下例子
Stream<Integer> stream = Stream.iterate(1, t -> t < 1000, t -> 2 * t + 1);
stream.forEach(System.out::println);
//1,3,7,15,31,63,127,255,511

字符串

判断字符器是否都是空白 isBlank() 注意不是判断是否为空,这个是java11里新增的方法

String str = "\t \r\n ";
System.out.println(str.isBlank()); // 结果 true

去掉字符串首尾空白 - since 11

String str = "\t \r\n ";
String str1 = str.strip(); // 去掉字符串首尾的空白(不是空格)
System.out.println(str1); //
System.out.println(str1.length()); // 0

strip() 跟 trim() 都是去掉首尾的空白字符,但trim()只能去掉特定范围内的空白字符,比如中文输入法下的空格,trim()就没办法去掉

但strip()就可以去掉英文以及其它所有语言的空白字符

相应的方法还有

  • stripLeading() 去掉头部空白字符
  • stripTrailing() 去掉尾部空白字符
  • repeat(int count) 复制字符串几次
  • lines() 将字符串按照换行符分割成多个字符串,返回值是Stream,可以通过 .count() 来统计有多少行

IO

InputStream 增加了一个非常有用的方法 transferTo() 可以将数据直接传输到OutputStream,这是在处理原始数据流时非常常见的一种用法

var classLoader = ClassLoader.getSystemClassLoader();
// 使用类加载器加载文件,将其读成一个InputStream
var is = classLoader.getResourceAsStream("javastack.txt");
try {
  // 创建一个临时文件
  var javastack = File.createTempFile("javatack2", "txt");
  try (var os = new FileOutputStream(javastack)) {
    is.transferTo(os);
  }
  is.close();
} catch (IOException e) {
  e.printStackTrace();
}

HTTP Client API

简单使用案例(同步请求)

HttpClient client = HttpClient.newHttpClient();
// 构建请求体
HttpRequest request = HttpRequest.newBuilder(URI.create("https://tomoya92.github.io/")).build();
// 构建响应处理器
HttpResponse.BodyHandler<String> bodyHandler = HttpResponse.BodyHandlers.ofString();
// 发送同步请求并获取响应,这一步会阻塞
HttpResponse<String> response = client.send(request, bodyHandler);
System.out.println(response.body());

异步请求

HttpClient client = HttpClient.newHttpClient();
// 构建请求体
HttpRequest request = HttpRequest.newBuilder(URI.create("https://tomoya92.github.io/")).build();
// 构建响应处理器
HttpResponse.BodyHandler<String> bodyHandler = HttpResponse.BodyHandlers.ofString();
// 发送异步请求,这里会立即获取返回值
CompletableFuture<HttpResponse<String>> sendAsync = client.sendAsync(request, bodyHandler);
// 从返回值里调用get()方法拿服务器响应内容,这一步会阻塞
HttpResponse<String> response = sendAsync.get();
System.out.println(response.body());

移除内容

移除了

  • com.sun.awt.AWTUtilities
  • sun.miss.Unsafe.defineClass
  • Thread.destroy() 以及 Thread.stop(Throwable) 方法
  • sun.nio.ch.disableSystemWideOverlappingFileLockCheck 属性
  • sun.locale.formatasdefault 属性
  • jdk snmp 模块
  • javafx openjdk 估计是从java10版本就移除了,oracle java10还尚未移除javafx 而java11版本将javafx也移除了
  • java Mission Control 从jdk中移除之后,需要自己单独下载
  • Root Certificates: Baltimore Cybertrust Code Signing CA, SECOM Root Certificate, AOL and Swisscom Root Certificates

替代项

  • 使用 java.lang.invoke.MethodHandles.Lookup.defineClass 来替代移除的 sun.miss.Unsafe.defineClass

废弃项

  • -XX+AggressiveOpts 过时
  • -XX:+UnlockCommercialFeatures 废弃
  • -XX:+LogCommercialFeatures 废弃
  • VM.unlock_commercial_features 警告
  • VM.check_commercial_features 警告

直接运行Java文件

在Java11里可以直接使用 java 命令来执行单个的java文件,执行命令 java Hello.java 执行过程中不会生成class文件

有个疑问,执行完后目录下确实没有.class文件,但第一次执行会很慢,当第二次再执行的时候,就会快很多,我感觉它还是生成.class文件了,只是没有在当前目录下,如果有知晓的,求告知

注意

  • 执行源文件中的第一个类,第一个类里必须要有main方法
  • 并且不可以使用别的java文件中定义的类,只能使用当前执行文件里定义的类

所以这个功能还是很鸡肋的

Unicode 10

Unicode 10 增加了8518个字符,总计达到了136690个字符,并且增加了4个类来处理,同时还有56个新的emoji表情符号

新增的四个类在 java.lang 包下,分别是

  • CharacterData00.class
  • CharacterData01.class
  • CharacterData02.class
  • CharacterData0E.class

链原文接:https://tomoya92.github.io/2019/02/21/java11/

移除了Java EE和CORBA Moudles

在java11中移除了不太使用的JavaEE模块和CORBA技术。

CORBA来自于二十世纪九十年代,Oracle说,现在用CORBA开发现代Java应用程序已经没有意义了,维护CORBA的成本已经超过了保留它带来的好处。

但是删除CORBA将使得那些依赖于JDK提供部分CORBAAPI的CORBA实现无法运行。目前还没有第三方CORBA版本,也不确定是否会有第三方愿意接手CORBA API的维护工作。

在java11中将java9标记废弃的Java EE及CORBA模块移除掉,具体如下:

xml 相关:

  • java.xml.ws
  • java.xml.bind
  • java.xml.ws
  • java.xml.ws.annotation
  • jdk.xml.bind
  • jdk.xml.ws被移除

只剩下java.xml, java.xml.crypto.jdk.xml.dom 这几个模块

其它

  • java.corba
  • java.se.ee
  • java.activation
  • java.transaction被移除,但是java11新增-个java.transaction.xa模块

Deprecate(过时) the Nashorn Javascript Engine

将Javascript引擎标记为过时,后续版本会移除,有需要的可以考虑使用GraalVM

Deprecate(过时) the Pack200 Tools and API

Java5中带了一个压缩工具:Pack2o0,这个工具能对普通的jar文件进行高效压缩。其实现原理是根据Java类特有的结构,合并常数池,去掉无用信息等来实现对java类的高效压缩。由于是专门对Java类进行压缩的,所以对普通文件的压缩和普通压缩软件没有什么两样,但是对于Jar文件却能轻 易达到10-40%的压缩率。这在Java应用部署中很有用,尤其对于移动Java计算,能够大大减小代码下载量。

Java5中还提供了这一技术的API接口,你可以将其嵌入到你的程序中使用。使用的方法很简单,下面的短短几行代码即可以实现jar的压缩和解压

压缩

Packer packer = Pack200.newPacker();
OutputStream output = new BufferedOutputStream(new FileOutputStream(outFile));
packer.pack(new JarFile(jarFile), output);
output.close();

解压

Unpacker unpacker = Pack200.newUnpacker();
OutputStream output = new JarOutputStream(new FileOutputStream(jarFile));
unpacker.unpack(pack200File, output);
output.close();

Pack200的压缩和解压缩速度是比较快的,而且压缩率也是很惊人的,在我是使用的包 4.46MB压缩后成了1.44MB (0.322%),而且随着包的越大压缩率会根据明显,据说如果jar包都是class类可以压缩到1/9的大小。 其实JavaWebStart 还有很多功能,例如可以按不同的jar包进行lazy下载和单独更新,设置可以根据jar中的类变动进行class粒度的下载

但是在java11中废除了pack200以及unpack200工具以及java.tiljar中的Pack200 API。因为Pack200主要是用来压缩jar包的工具,由于网络下载速度的提升以及java9引入模块化系统之后不再依赖Pack200,因此这个版本将其移除掉

新的Epsilon垃圾收集器

Experimental(实验性质),生产环境不建议使用

A NoOp Garbage Collectors

启用方法:-XX:+UnlockExperimentalVMOptions -XX:+UseEpsilonGC

JDK上对这个特性的描述是:开发一个处理内存分配但不实现任何实际内存回收机制的GC,一旦可用堆内存用完,JVM就会退出,如果有System.gc()调用,实际上什么也不会发生(这种场景下和-XX:+DisableExplicitGC效果一样), 因为没有内存回收,这个实现可能会警告用户尝试强制GC是徒劳

class Garbage {
  int n = (int) (Math.random() * 100);
  @Override
  public void finalize() {
    System.out.println(this + " : " + n + " is dying");
  }
}
public class EpsilonTest {
  public static void main(String[] args) {
    boolean flag = true;
    List<Garbage> list = new ArrayList<>();
    long count = 0;
    while(flag) {
      list.add(new Garbage());
      if(list.size() == 1000000 && count == 0) {
        list.clear();
        count++;
      }
    }
    System.out.println("程序结束");
  }
}

如果使用选项-XX: +UseEpsilonGC程序很快就因为堆空间不足而退出,但如果不使用EpsilonGC,程序会在生成垃圾和清除垃圾之间犹豫,要等好一会才会因为堆空间不足而退出

使用这个选项的原因:

提供完全被动的GC实现,具有有限的分配限制和尽可能低的延迟开销,但代是内存占用和内存吞吐量, 众所周知,java 实现可广泛选择高度可配置的GC实现.各种可用的收集器最终满足不同的需求,即使它们的可配置性使它们的功能相交.有时更容易维护单独的实现,而不是在现有GC实现上堆积另一个配置选项.

主要用途如下:

  • 性能测试(它可以帮助过滤掉GC引起的性能假象)
  • 内存压力测试(例如,知道测试用例应该分配不超过1GB的内存,我们可以

使用-Xmx1g -XX:+UseEpsilonGC如果程序有问题,则程序会崩溃

  • 非常短的JOB任务(对象这种任务,接受GC清理堆那都是浪费空间)
  • VM接口测试
  • Last-drop延迟&吞吐改进

ZGC

Experimental(实验性质),生产环境不建议使用

启用方法:-XX:+UnlockExperimentalVMOptions -XX:+UseZGC

这应该是java11最为瞩目的特性,没有之一,但是它也是实验性质,生产环境不建议使用

ZGC, A Scalable Low-Latency Garbage collector( Experimental) ZGC一个可伸缩的低延时的垃圾回收器

GC暂停时间不会超过10ms,既能处理几百兆的小堆,也能处理几介T的大堆(OMG)和G1相比,应用吞吐能力不会下降超过15%为未来的GC功能和利用colord指针以及Load barriers 优化奠定基础。初始只支持64位系统

ZGC的设计目标是:支持TB级内存容量,暂停时间低(<10ms),对整个程序吞吐量的影响小于15%。将来还可以扩 展实现机制,以支持不少令人兴奋的功能,例如多层堆(即热对象置于DRAM和冷对象置于NVMe闪存),或压缩堆

GC是java主要优势之一.然而,当GC停顿太长,就会开始影响应用的响应时间.消除或者减少GC停顿时长,java将对更广泛的应用场景是一个更有吸引力的平台.此外,现代系统中可用内存不断增长,用户和程序员希望JVM能够以高效的方式充分利用这些内存,并且无需长时间的GC暂停时间

ZGC是一个并发,基于region,压缩型的垃圾收集器,只有root扫描阶段会STW,因此GC停顿时间不会随着堆的增长和存活对象的增长而变长

垃圾回收机 等待平均值(ms) 等待最大值(ms)
ZGC 1.091 1.681
G1 156.806 543.846

完全支持Linux容器(包括docker)

许多运行在Java虚拟机中的应用程序(包括ApacheSpark和Kafka等数据服务以及传统的企业,应用程序)都可以在Docker容器中运行。但是在Docker容器中运行Java应用程序一直存在一个问题,那就是在容器中运行JVM程序在设置内存大小和CPU使用率后,会导致应用程序的性能下降。这是因为Java应用程序没有意识到它正在容器中运行。随着Java10的发布,这个问题总算得以解诀,JVM现在可以识别由容器控制组(cgroups) 设置的约束。可以在容器中使用内存和CPU约束来直接管理Java应用程序

其中包括:

  • 遵守容器中设置的内存限制
  • 在容器中设置可用的CPU
  • 在容器中设置CPU约束
  • Java 10的这个改进在Docker for Mac、Docker for Windows以及Docker Enterprise Edition等环境均有效

容器的内存限制

在Java9之前,JVM无法识别容器使用标志设置的内存限制和CPU限制。而在Java10中,内存限制会自动被识别并强制执行

Java将服务器类机定义为具有2个CPU和2GB内存,以及默认堆大小为物理内存的1/4.例如,Docker企业版安装设置为2GB内存和4个CPU的环境,我们可以比较在这个Docker容器上运行Java8和Java10的区别

对于支持docker容器是jdk自动完成了,原来docker怎么配置,现在还怎么配置就行

支持G1上的并告完全垃圾收集

对于G1 GC,相比于JDK8,升级到JDK 11即可免费享受到:并行的Full GC,快速的CardTable扫描,自适应的堆占用比例调整(IHOP),在并发标记阶段的类型卸载等等。这些都是针对G1的不断增强,其中串行FullGC等甚至是曾经被广泛诟病的短板,你会发现GC配置和调优在JDK11中越来越方便

Low-Overhead Heap Profiling

免费的低耗能飞行记录仪和堆分析仪

通过JVMTI的SampledObjectAlloc回调提供了一个开销低的heap分析方式提供一个低开销的,为了排错java应用问题,以及JVM问题的数据收集框架

希望达到的目标如下:

  • 提供用于生产和消费数据作为事件的API
  • 提供缓存机制和二进制数据格式
  • 允许事件配置和事件过滤
  • 提供OS,JVM和JDK库的事件

加密算法

实现了RFC7539中指定的ChaCha20和Poly1305两种加密算法,代替RC4

RFC7748定义的密钥协商方案更高效,更安全,JDK增加了两个新的接口XECPublicKey XECPrivateKey

Java Flight Recorder

Flight Recorder 源自飞机的黑盒子。 Flight Recorder 以前是商业版的特性,在javal1当中开源出来,它可以导出事件到文件中,之后可以用Java Mission Control 来分析。

两种启动方式

  1. 可以在应用启动时配置java -XX:StartFlightRecording
  2. 应用启动之后,使用jcmd来录制,如下代码
     $ jcmd <pid> JFR.start  # 启动记录仪
     $ jcmd <pid> JFR.dump.filename=recording.jfr  # 将记录内容保存到文件里
     $ jcmd <pid> JFR.stop  # 停止记录仪
    

查看jfr文件在java11里没有办法,不过在java12里已经加入了jfr命令,可以查看jfr文件

是Oracle刚刚开源的强大特性。我们知道在生产系统进行不同角度的Profiling,有各种工具、框架,但是能力范围、可靠性、开销等,大都差强人意,要么能力不全面,要么开销太大,甚至不可靠可能导致Java 应用进程宕机。

而JFR是一套集成进入JDK、JVM内部的事件机制框架,通过良好架构和设计的框架,硬件层面的极致优化,生产环境的广泛验证,它可以做到极致的可靠和低开销。在SPECjbb2015等基准测试中,JFR的性能开销最大不超过1%,所以,工程师可以基本没有心理负担地在大规模分布式的生产系统使用,这意味着,我们既可以随时主动开启JFR进行特定诊断,也可以让系统长期运行JFR,用以在复杂环境中进行“After-the-fact”分析。还需要苦恼重现随机问题吗? JFR让问题简化了很多

在保证低开销的基础上, JFR提供的能力也令人眼前一亮,例如:我们无需BCI就可以进行Object Allocation Profiling, 终于不用担心BTrace 之类把进程搞挂了。对锁竞争、阻塞、延迟,JVM GC、SafePoint 等领域,进行非常细粒度分析。甚至深入JIT Compiler 内部,全面把握热点方法、内联、逆优化等等。JFR提供了标准的Java.C++ 等扩展API,可以与各种层面的应用进行定制、集成,为复杂的企业应用栈或者复杂的分布式应用,提供All-in-One 解决方案。而这一切都是内建在JDK和JVM内部的,并不需要额外的依赖,开箱即用。

最后

再次感谢尚硅谷录制的这么好的视频教程,还免费供用户下载查看

视频中还提到了对http1.3的支持,只是提了一下就过了,我这里也没有记录

注意:写博客很费时间的,转载请注明原文链接,谢谢

原文链接: https://tomoya92.github.io/2019/02/21/java11/