之所以把 Java 19 与 20 放一块是因为这两个版本都没有一个算得上正式的特性。都是些预览的,孵化中的,唯有一个支持 Linux 下 RISC-V 指令集与我们基本无关。所以 Java 19 和 Java 20 纯粹的过度版本,根本不该被正式项目采用,在 IntelliJ IDEA 中也是标它们为 No new language features。在我们的实践中正式项目只用 LTS 版。
还是分别从 https://openjdk.org/projects/jdk/19/ 和 https://openjdk.org/projects/jdk/20/ 抓关注点
Java 19 新特性 | Java 20 新特性 |
从上面可以挑几个稍加了解,详细的介绍应该在学习 Java 21 时。它们是
记录模式(Record Patterns)
Java 19 的 Record Patterns 还不能应用于 switch/case 表达式中,只是在 if instanceof 时除了判断类型外,还能捕获其中的字段值,演示代码如下
1 2 3 4 5 6 7 8 9 10 11 12 13 |
public class Test { record Point(double x, double y) { } public static void main(String[] args) { Object obj = new Point(1.1, 25); if (obj instanceof Point(double x, double y)) { System.out.println("x: " + x + ", y: " + y); } } } |
编译
javac --enable-preview --release 19 Test.java
执行
java --enable-preview Test
结果
x: 1.1, y: 25.0
对于预览或孵化中的特性都必须用 javac --enable-preview --release xx, java --enable-preview 的方式编译执行,回到 IntelliJ IDEA 或 Maven 项目中测试非正式属性还更麻烦些。
虚拟线程(Virtual Threads)
说到虚拟线程,就会联想到纤程(Fiber), 协程(Coroutine),而脱胎于 Project Loom 的虚拟线程本质上是一种纤程实现。为什么要虚拟线程呢,那就要对比一下线程,协程,以及纤程的主要区别。
简单描述
- 线程由操作系统调用,有独立的,较大的栈空间,内核调度,切换开销大,可使用多核执行 CPU 密集型任务
- 协程在单个线程内执行,共享线程栈空间或独立小空间,用户态调度,切换开销极小,但无法使用多核,擅长 IO 密集型任务
- 纤程(像这里的虚拟线程), 介于线程与协程之间,有很小的独立栈,用户态调度,切换开销较小,结合线程池,纤程可在线程间转移,所以 CPU 密集或 IO 密集型任务都可以考虑
在 Java 中有线程为什么要虚拟线程,从线程所需栈空间就知道. 以 Java 19 为例
1234 java -XX:+PrintFlagsFinal -version | grep ThreadStackSizeintx CompilerThreadStackSize = 2048 {pd product} {default}intx ThreadStackSize = 2048 {pd product} {default}intx VMThreadStackSize = 2048 {pd product} {default}
从中看到每一个线程默认需要 2M 的内存空间(可用 -Xss1m 参数修改),假设启动 2000 个线程,光线程栈就要消耗掉 4G 的内存,再加上操作系统打开文件句柄的限制,而使用虚拟线程话,可以开高几个数量级的虚拟线程。
像 Fiber, Corouting 那样的用户态的线程又被称作绿色线程,像环保组织的绿色能源,绿色新政那样的称呼。接下来直接体验一下虚拟线程的威力
Test.java 代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
public class Test { public static void main(String[] args) { String type = args[0]; int numberOfThreads = Integer.parseInt(args[1]); if (type.equals("thread")) { runThreads(numberOfThreads); } else if (type.equals("virtual")) { runVirtualThreads(numberOfThreads); } } public static void runThreads(int numberOfThreads) { AtomicInteger count = new AtomicInteger(); try (var executor = Executors.newCachedThreadPool()) { IntStream.range(0, numberOfThreads).forEach(i -> executor.submit(() -> { System.out.println(Thread.currentThread() + ":" + count.incrementAndGet()); Thread.sleep(Duration.ofMinutes(10)); return i; })); } } public static void runVirtualThreads(int numberOfThreads) { AtomicInteger count = new AtomicInteger(); try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { IntStream.range(0, numberOfThreads).forEach(i -> executor.submit(() -> { System.out.println(Thread.currentThread() + ":" + count.incrementAndGet()); Thread.sleep(Duration.ofMinutes(10)); return i; })); } } } |
每个任务休眠 10 分钟是确保计算到达 numberOfThreads 时仍没有结束,促使创建的相应数量的线程或虚拟线程同时存在。
编译
javac --enable-preview --release 19 Test.java
本机内存 36 G,不指定 xmx 的情况下默认堆内存为 9G,
java --enable-preview Test thread 9000
// 或
java --enable-preview Test virtual 9000
都能把计算器走完,它们各自在控制台下打印内容如下
Thread[#9028,pool-1-thread-8999,5,main]:8999
Thread[#9029,pool-1-thread-9000,5,main]:9000
和
VirtualThread[#8968]/runnable@ForkJoinPool-1-worker-8:8998
VirtualThread[#9022]/runnable@ForkJoinPool-1-worker-10:9000
注意到虚拟线程在 Java 中默认用 ForkJoinPool 调度的,也就是虚拟线程与系统线程的关联关系。
如果创建 10000 个线程
java --enable-preview Test thread 10000
就看到受不了
Thread[#9213,pool-1-thread-9184,5,main]:9184
[1.150s][warning][os,thread] Failed to start thread "Unknown thread" - pthread_create failed (EAGAIN) for attributes: stacksize: 2048k, guardsize: 16k, detached.
[1.150s][warning][os,thread] Failed to start the native thread for java.lang.Thread "pool-1-thread-9185"
若不是用 Executors.newCachedThreadPool() 创建线程,而是用 Executors.newFixedThreadPool(10000) 直接就会报 OutOfMemoryError
而如果用虚拟线程,直接上一百万 1000000
java --enable-preview Test virtual 1000000
照样能得到正确的结果,恐怖吧,都没必要往下试了
VirtualThread[#1000050]/runnable@ForkJoinPool-1-worker-8:999994
VirtualThread[#1000006]/runnable@ForkJoinPool-1-worker-5:999998
VirtualThread[#1000003]/runnable@ForkJoinPool-1-worker-10:999999
VirtualThread[#1000005]/runnable@ForkJoinPool-1-worker-2:1000000
当然并不是说虚拟线程总是比系统线程强,总要看什么时候,对于 CPU 密集型的任务,开再多不管是什么线程也改善不了性能,反而引起性能下降。
虚拟线程使用很小的栈,节约内存,切换开销小,在多是 IO 等待的情况下适合使用。虚拟线程与系统线程的对应关系可能是 M:1, M:N。虚拟线程便宜,所以不需要池化,应选择用 Semaphore 控制并发。
除了 Executors.newVirtualThreadPerTaskExecutor() 创建虚拟线程外,还能用 java.langThread.Builder 或结构化并发的方式。
结构化并发(Structured Concurrency)
在多线程编程中,当我们把任务分解之后,子任务之间,或主任务与子任务之间就缺少了关联,结构化并发就是要把运行在不同线程中的多个任务视作单 个工作单元。
用 JEP 428 中的例子来说明
1 2 3 4 5 6 7 |
Response handle() throws ExecutionException, InterruptedException { Future<String> user = esvc.submit(() -> findUser()); Future<Integer> order = esvc.submit(() -> fetchOrder()); String theUser = user.get(); // Join findUser int theOrder = order.get(); // Join fetchOrder return new Response(theUser, theOrder); } |
findUser() 和 fetchOrder() 两不同线程执行,等它们都执行完毕,聚合结果返回。目前这里有下面一些问题
- 当任何其中的一个子任务抛出异常,执行失败,另一个正在执行的子任务仍然继续执行,它的结果最终不会被采用,浪费计算资源
- 主要主线程被中断了,所有的子任务依然继续执行,同样是浪费
结构化并发就是用来解决这个问题的,当前主要类 StructuredTaskScope 的 fork() 会自动让任务在虚拟线程中执行,还不易用代码进行对行演示,放到以后再学习吧。
作用域值(Scoped Values)
这是 Java 20 开始引入的,在线程及子线程间共享不可变数据。以前所用的 ThreadLocal 和 InheritableThreadLocal 在线程间共享的数据是可变的。Scoped Values 和结构化并发一样也是对虚拟线程友好的,看来虚拟线程在将来还会得到更广泛的应用。
除了数据的可变性之外,为什么有 ThreadLocal 的情况还还需发明 Scoped Values 呢?因为 ThreadLocal 中的数据若忘记及时 remove() 掉的话,会造成线程重用时得到赃数据,也造成不必要的内存占用,在往子线程传递线程绑定值代价高昂。
特别是在使用大规模的虚拟线程时就更需要作用域值那样更轻量级的数据共享方式,虚拟线程同样可以使用 ThreadLocal, 记得虚拟线程是没必要池化后重用的,所以在使用虚拟线程的情况下,也没不必调用 ThreadLocal.remove() 方法。
所以在平台线程时期创建的 ThreadLocal 虽然适配了虚拟线程,但在虚拟线程时代已经不合时宜了,也就有了 ScopedValue, 至于是不是可变数据,严格意义上只是个约定,即使不被重新赋值,内部状态总是能变的。
该特性在 Java 25 中转为正式。大致使用了一下,ScopedValue 需要用
ScopedValue.where(NAME, <value>).run(() -> { ... NAME.get() ... call methods ... });
的方式使用,只能在 run(...) 这个范围中才能取到所需的值。
下面是一个简单的代码
1 2 3 4 5 6 7 8 9 10 11 12 |
public class Test { final static ScopedValue<String[]> scopedValue = ScopedValue.newInstance(); public static void main(String[] args) { ScopedValue.where(scopedValue, new String[]{"aa", "bb"}) .run(() ->{ System.out.println(scopedValue.get()); // [Ljava.lang.String;@4f023edb }); System.out.println(scopedValue.get()); // 异常: NoSuchElementException: ScopedValue not bound } } |
ScopedValue 只能限制允许绑定一次值,如果遵循 Immutable 的规则,在线程间传递值需要创建许多专用的 ScopedValue。假如绑定的值是可变的,这一规则就被轻易突破。
实际应用时,ScopedValue 还没有 ThreadLocal 方便,因为它只限定在 where...run 范围中才能使用绑定的数据,可能要被迫使用嵌套的 where...run 语句。使用 ThreadLocal 时对数据存取进行约定就行,还要记得线程任务结束后清理掉绑定的数据。ScopedValue 可能以后结合结构化并发(Strucutred Concurrency), 虚拟线程在第三方的框架中会被广泛采用。
本文链接 https://yanbin.blog/java-19-20-new-features/, 来自 隔叶黄莺 Yanbin Blog
[版权声明] 本文采用 署名-非商业性使用-相同方式共享 4.0 国际 (CC BY-NC-SA 4.0) 进行许可。