Java19虚拟线程都来了,我正在写的线程代码会被淘汰掉吗?

时间:2022-10-08 07:10:40

Java19中引入了虚拟线程,虽然默认是关闭的,但是可以以Preview模式启用,这绝对是一个重大的更新,今天Java架构杂谈带大家开箱验货,看看这家伙实现了什么了不起的功能。

小张贪小便宜,在路边摊花一块钱买了一笼热气腾腾的小笼包,下肚之后肚子疼得不行,于是在公司找坑位。扫了几层楼,没找到一个坑位,坑里面的人要么在抽烟,要么在外放刷视频、要么肠道不是很顺畅,蹲了半天没拉出来。小张很鄙视在坑位里面不干正事的行为,此刻,与小张一同排队等坑位的还有几个同事...

Java19虚拟线程都来了,我正在写的线程代码会被淘汰掉吗?

小张突然感受到了从菊花传来的一股无法压制的推力,像极了JVM发生OOM前一刻的症状。在这千钧一发的时刻,小张爆发了。

Java19虚拟线程都来了,我正在写的线程代码会被淘汰掉吗?

他把在厕所里面抽烟刷视频拉不出来的人全部都赶出来了,急着释放内存的同事立刻进行解决了,然后趁味道还没消散,立刻再让出坑位把抽烟的人赶进去接着抽。

Java19虚拟线程都来了,我正在写的线程代码会被淘汰掉吗?

坑位就是操作系统的线程,以前一个同事蹲坑之后,就占了坑位了,其他同事无法使用。而用了虚拟线程后,谁要是在厕所里面刷视频、抽烟就会被赶出来,避免占用资源,这就是虚拟线程的好处。

虚拟线程在Project Loom项目中已经孵化很久了,现在 Project Loom 的JEP 425: 虚拟线程可以在Java 19中以预览的方式使用了,接下来Java架构杂谈就带大家深入地了解一下它。


在一个高并发系统中,给每个请求创建一个线程来处理,是很不可取的,Java线程对应一个操作系统线程,而操作系统线程资源是比较宝贵的。但是如果没有开启这么多线程,又无法处理这么多请求,特别是遇到一些锁、IO、系统调用等操作时,需要更长的时间来处理请求。我们一般的的做法是引入线程池,但是线程池也是有限制的,假设以下场景:

线程池设置为100个线程,一个请求需要花费两秒,而大部分时间都花在了IO上,那么每秒最多可以响应50个请求。此时,CPU可能利用率很低,因为系统需要花费大部分时间来执行IO等待。

我们以往只能通过各种响应式框架来克服这个问题。但是引入了响应式编程框架后,代码将会变得越来越复杂,看看以下SpringCloud Gateway的源码,当你想调试它时,你会感到抓狂:

@Override
public Mono<Void> handle(ServerWebExchange exchange) {
  if (this.handlerMappings == null) {
    return createNotFoundError();
  }
  if (CorsUtils.isPreFlightRequest(exchange.getRequest())) {
    return handlePreFlight(exchange);
  }
  return Flux.fromIterable(this.handlerMappings)
    .concatMap(mapping -> mapping.getHandler(exchange))
    .next()
    .switchIfEmpty(createNotFoundError())
    .flatMap(handler -> invokeHandler(exchange, handler))
    .flatMap(result -> handleResult(exchange, result));
}

为了使用响应式编程,你不仅需要试图接受这些难以阅读的代码,而且数据库程序和其他外部服务的驱动程序也必须支持响应式模式。

现在,有了虚拟线程,你可以不用写这种反人类的代码了。

接下来,Java架构杂谈带您深入浅出彻底弄懂虚拟线程是怎么回事。

2 什么是虚拟线程?

通过使用虚拟线程,可以让我们继续编写易于阅读和可维护性好的高性能代码。从Java的角度来看,虚拟线程跟普通的线程没有什么区别,但是虚拟线程与操作系统线程并不是一对一的关系。

2.1 虚拟线程模型

虚拟线程有一个所谓的载体线程池,虚拟线程临时映射到该线程池上,一旦虚拟线程遇到阻塞操作,虚拟线程就从载体线程中移除,然后载体线程就可以执行其他新的虚拟线程或者之前阻塞后恢复的虚拟线程了。

载体线程与虚拟线程的关系如下图所示,一个载体线程上面可以运行很多虚拟线程,每当虚拟线程变为非Runnable状态时,就从载体线程上卸载:

Java19虚拟线程都来了,我正在写的线程代码会被淘汰掉吗?

可以看到,虚拟线程中的阻塞操作不在阻塞正在执行的线程,这允许我们使用少量的载体线程并行处理大量的请求。

虚拟线程的载体线程是ForkJoinPool在 FIFO 模式下运行的线程。此池的大小默认为可用处理器的数量,可以使用系统属性jdk.virtualThreadScheduler.parallelism 进行调整。将来,可能会有更多选项来创建自定义调度程序。

请注意:此 ForkJoinPool 不同于 [common pool](https://docs.oracle.com/en/java/javase/18/docs/api/java.base/java/util/concurrent/ForkJoinPool.html #commonPool()),在并行流的实现中就使用到了common pool,此pool是在 LIFO 模式下运行的。

2.2 线程

线程是Java的基础。当我们运行一个Java程序时,它的main方法作为第一个栈帧被调用。当一个方法调用另一个方法时,被调用者与调用者在同一个线程上运行,返回信息记录到线程堆栈上。方方法使用局部变量时,它们存储在线程堆栈的方法调用栈帧中。

当程序出现问题时,我们可以通过遍历当前线程堆栈来进行跟踪。

线程是Java程序调度的基本单位。当线程阻塞等待磁盘IO、网络IO或者锁时,该线程被挂起,以便另一个线程可以在CPU上运行。构建在线程之上的异常处理、单步调试和分析、顺序控制流和局部变量等已经成为了编码中使用率非常高的东西。线程是Java并发模型的基础。

2.2.1 平台线程

在进入虚拟线程的世界之前,我需要重新审视经典线程,我们可以将之称为平台线程

常见的Java线程模式实现方式有:

  • 使用内核线程实现;
  • 使用用户线程实现;
  • 使用用线程+轻量级进程混合实现;

JVM规范并没有限定Java线程需要那种模型,对于Windows和Linux版本使用的是1:1的线程,映射到轻量级进程中(每个轻量级进程都需要有一个内核线程支持)。

详细阅读:Java架构杂谈的另一篇文章:一文带你彻底理解同步和锁的本质(干货)

由于大多数操作系统的实现方式决定了创建线程的代价比较昂贵,因此系统能够创建的线程数量收到了限制,最终导致我们在程序中使用线程的方式产生了影响。

线程栈大小的限制

操作系统通常在创建线程时将线程堆栈分配为单片内存块,一旦分配完成,无法调整大小。为此我们需要根据实际情况手动设置一个固定的线程栈大小。

如果线程栈被配置的过大,我们将会需要使用更多的内存,如果配置的太小,很容易就触发*Exception。为了避免*Exception,一般的我们倾向于预留多点线程栈,因为消耗多点内存总比程序报错要好。但这样会限制我们在给定的内存量的情况下可以拥有的并发线程数量。

而限制可以创建的线程数量又会带来并发的问题。因为服务器应用程序一般的处理方法是每个请求分配一个线程( thread-per-request)。这种处理将应用程序的并发单元(任务)与平台(线程)对齐,可以最大限度的简化开发、调试和维护的复杂度,同时无形的带来很多好处,如程序执行的顺序错觉(比起异步框架,好处太明显了)。在这种模式下,开发人员需要很少的并发意识,因为大多数请求是相互独立的。

也许这种模式可以轻松的为1000个并发请求提供服务,但是却无法支撑100万并发的请求,即使具有足够好的CPU和足够大的IO带宽。

扩展阅读,如何让服务器支持更高的并发:网络编程范式:高性能服务器就这么回事 | C10K,Event Loop,Reactor,Proactor

为了让服务器支持更大的并发请求,Java开发人员只能选择以下几个比较糟糕的选择:

  • 限制代码的编写方式,使其可以使用更小的堆栈大小,这迫使我们方式大多数第三方库;
  • 投入更多的硬件,或者切换到Reactor或者Proactor编程风格。虽然异步模型最近几年有些流行,前几年就听说有同学的公司在项目里面推异步编程框架,但是这样意味着我们必须以高度受限的风格进行编码,这要求我们放弃线程给我们带来的许多好处,例如断点调试,堆栈跟踪等。最终会导致牺牲了Java编程语言原本具有的一些优势。

2.2.2 虚拟线程

虚拟线程则是一种线程的替代实现。在这种实现中,线程栈帧存储在Java的堆内存中,而不是存储在操作系统分配到单片内存块中。我们再也不需要去猜测一个线程可能需要多少栈空间,虚拟线程占用的内存开始时只有几百字节,随着调用堆栈的扩展和收缩而自动扩展和收缩,这使得系统具有了更好的可伸缩性。

对于操作系统来说,仍然只知道平台线程,它是基本的调度单元,虚拟线程是在JVM中实现的,Java运行虚拟线程时通过将其安装在某个平台线程(称为载体线程)上来运行它。挂载虚拟线程到平台线程的时候,JVM会将所需的线程栈从堆中临时复制到载体线程堆栈中,并在挂载时借用载体堆栈。

当在虚拟线程中运行的代码因为IO、锁或者其他资源可用性而阻塞时,虚拟线程可以从载体线程中卸载,并且复制的任何线程栈改动信息都将会存回到堆中,从而释放载体线程,以使其继续运行其他虚拟线程。

JDK中几乎所有的阻塞掉都已经调整过了,因此当在虚拟线程上遇到阻塞操作时,虚拟线程会从其载体上卸载而不是阻塞。

例如,在LockSupport中,要park线程的时候,做了虚拟线程的兼容处理:

Java19虚拟线程都来了,我正在写的线程代码会被淘汰掉吗?

在Thread的sleep方法中,也做了兼容处理:

Java19虚拟线程都来了,我正在写的线程代码会被淘汰掉吗?

JDK中几乎所有的阻塞点,都做了虚拟线程判断,并且会卸载虚拟线程而不是阻塞它。

虚拟线程对编写多线程程序有影响吗?

在载体线程上挂载和卸载虚拟现在是JVM内部的处理逻辑,在Java代码层面是完全不可见的。Java代码无法观察到当天载体的身份,也就是说,调用Thread.currentThtread总是返回虚拟线程。

载体线程的ThreadLocal值对已挂载的虚拟线程不可见,载体线程的线程栈帧不会出现在虚拟线程的异常或者线程转储中。

在虚拟线程的生命周期中,可能在许多不同的载体线程上运行。

如何创建虚拟线程

虚拟线程具有相对较少的新的API,创建完虚拟线程后,他们是普通的Thread对象,并且表现得向我们已经所了解的线程。例如,Thread.currentThread、ThreadLocal、中断、堆栈遍历等,在虚拟线程上的工作方式与在平台线程上的工作方式完全相同,这意味着我们可以自信地在虚拟线程上运行我们现有的代码。

Java19虚拟线程都来了,我正在写的线程代码会被淘汰掉吗?

虚拟线程不就是绿色线程吗?

在计算机程序设计中,绿色线程是一种由运行环境或虚拟机调度,而不是由本地底层操作系统调度的线程。绿色线程并不依赖底层的系统功能,模拟实现了多线程的运行,这种线程的管理调配发生在用户空间而不是内核空间,所以它们可以在没有原生线程支持的环境中工作。

绿色线程名字由来:绿色线程的名称来源于最初的Java线程库。这是因为甲骨文公司的“绿色团队”最初设计了Java 的线程库。

在Java 1.0时代,一些JVM使用绿色线程来实现线程。虚拟线程与绿色线程表面上有许多相似之处,因为他们都是由JVM而不是操作系统管理的。

但是Java 1.0的绿色线程仍然有大的单一的堆栈,绿色线程是那个时代的产物,当时系统是单核的,操作系统没有支持线层。而虚拟线程与其他语言中的用户模式线程有更多的共同点(例如Go中的goroutine或者Erlang中的进程),而Java的虚拟线程的语义同时还拥有与已有的线程语义相同的优势。

3 性能对比

3.1 虚拟线程与平台线程区别?

下面通过一个案例来对比虚拟线程和平台线程。

在本例子中,开启2000个线程,每个线程休眠1秒,观察执行情况。通过同样的环境和硬件配置执行以下代码。

3.1.1 平台线程

代码如下:

package com.itzhai.demo.jdk19;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.*;

public class ThreadTest implements Callable<Boolean> {

    private final int number;

    public ThreadTest(int number) {
        this.number = number;
    }

    @Override
    public Boolean call() {
        System.out.printf("线程:%s - 任务:%d sleep...%n", Thread.currentThread().getName(), number);
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            System.out.printf("线程:%s - 任务:%d canceled...%n", Thread.currentThread().getName(), number);
            return false;
        }
        System.out.printf("线程:%s - 任务:%d finished....%n", Thread.currentThread().getName(), number);
        return true;
    }

    public static void main(String[] args) throws InterruptedException, ExecutionException {
        Thread.sleep(10000L);
        System.out.println("开始执行任务");
        ExecutorService executor = Executors.newFixedThreadPool(2_000);

        List<ThreadTest> tasks = new ArrayList<>();
        for (int i = 0; i < 2_000; i++) {
            tasks.add(new ThreadTest(i));
        }

        long start = System.currentTimeMillis();

        List<Future<Boolean>> futures = executor.invokeAll(tasks);

        long successCount = 0;
        for (Future<Boolean> future : futures) {
            if (future.get()) {
                successCount ++;
            }
        }
        long end = System.currentTimeMillis();
        System.out.println("总共耗时: " + (end - start) + " ms,成功数量:" + successCount);
        executor.shutdown();
        Thread.sleep(10000L);
    }
}

Java19虚拟线程都来了,我正在写的线程代码会被淘汰掉吗?

开启了2016个系统线程。

Java19虚拟线程都来了,我正在写的线程代码会被淘汰掉吗?

内存使用最多达到了接近60M,CPU使用率最多超过了4%。

3.1.2 虚拟线程

代码如下:

package com.itzhai.demo.jdk19;

import java.time.Duration;
import java.util.concurrent.Executors;
import java.util.stream.IntStream;

public class VirtualThreadTest {

    public static void main(String[] args) throws InterruptedException {
        Thread.sleep(10000L);
        long start = System.currentTimeMillis();
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            IntStream.range(0, 2_000).forEach(i -> {
                executor.submit(() -> {
                    Thread.sleep(Duration.ofSeconds(1));
                    return i;
                });
            });
        }
        long end = System.currentTimeMillis();
        System.out.println("总共耗时: " + (end - start));
        Thread.sleep(10000L);
    }
}

开启了25个系统线程:

Java19虚拟线程都来了,我正在写的线程代码会被淘汰掉吗?

Java19虚拟线程都来了,我正在写的线程代码会被淘汰掉吗?

内存使用最多不到25M,CPU使用率最多1%左右。

对比之下,高下立断。单单是看这几个指标差距就已经很明显了。

3.2 如何创建虚拟线程

你可以使用Executors.newVirtualThreadPerTaskExecutor()为每个任务创建一个新的虚拟线程:

ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();

使用Thread.startVirtualThread()or Thread.ofVirtual().start(),我们也可以显式启动虚拟线程:

Thread.startVirtualThread(() -> {
  // code to run in thread
});

Thread.ofVirtual().start(() -> {
  // code to run in thread
});

4 虚拟线程优势

4.1 更好的系统伸缩性

一般的服务器程序中,会存在大量的非活动线程,服务器程序花在网络、文件、或者数据IO上的时间要比实际执行运算要多得多。如果我们在平台线程中运行每个任务,大多数时候,线程将在IO获取其他资源的可用性上被阻塞。而虚拟线程运行 IO-bound thread-per-task应用程序更好的摆脱最大线程数限制的瓶颈,从而提高硬件的利用率。

虚拟线程是我们可以即实现硬件的最佳利用率,又可以继续与平台协调的编程风格继续编码,而不是类似异步编程框架那种与意外编程风格格格不入的方式重写代码。不得不说,Java的虚拟线程真的是实现的很不错。

虚拟线程擅长IO密集型任务的扩展性

对于一般的CPU密集型的任务,我们一般会通过fork-join框架和并行流来获取最佳的CPU利用率,可以很轻松的扩展受CPU限制的工作负载。

而对于IO密集型的任务,虚拟线程则提供了对应的应对方案,为IO密集型的任务工作提供了扩展性优势。

虚拟线程不是万能的,与fork-join是互补的关系。

4.2 干掉丑陋的响应式编程

响应式编程框架是定义了很多API来实现异步处理,而Java的虚拟线程是直接改造了JDK,让你可以直接使用Java原生API就可以实现响应式编程框架对性能提升的效果,而且不用编写那些令人头疼的回调代码。

许多响应式框架要求开发人员折中考虑thread-per-request的编程模式,更多的考虑异步IO、回调、线程共享等,来实现更充分的利用硬件资源。在响应式编程模型中,当一个活动要执行IO时,它会启动给一个异步操作,该操作将在完成时调用回调函数。框架将在某个线程上调用回调函数,但不一定是启动操作的同一线程。这意味着开发人员必须将它们的逻辑分解为IO交换和计算步骤,这些步骤被缝合到一个连续的工作流程中。因为请求旨在计算任务中才使用线程,并发请求的数量不受线程数量的限制。

响应式编程框架的这种可伸缩性需要付出巨大的代价:你进程不得不放弃开发平台和生态系统的一些基本特性。

在thread-per-request模型中,如果你想按顺序执行两件事情,只需顺序编写即可,其他的如循环、条件或者try-catch块都是可以直接使用的。但是在异步风格中,通常无法使用编程语言为你提供的顺序组合、迭代或其他功能来构建工作流,必须通过在异步框架使用特定的API来完成模拟循环和条件等,这绝对不会比语言中内置的结构那样灵活或熟悉。如果我们使用的是阻塞操作库,而整个库还没有适配异步工作方式,我们可能也没法使用这些。总结来说,就是我们可以在响应式编程中获得可扩展性,但是我们必须放弃使用部分语言特性和生态系统。

这些框架也是我们放弃类许多便捷的Java运行时特性,如堆栈跟踪、调试器和分析器等,因为请求在每个阶段都可能在不同的线程中执行,并且服务线程可能交错属于不同请求的计算。异步框架的并发单元是异步管道的一个阶段,与平台的并发单元不同。

虚拟线程运行我们在不放弃语言特性和运行时特性的情况活动相同的吞吐量优势,这正是虚拟线程令人着迷的地方。

4.3 阻塞操作将不再挂起内核线程

这就跟虚拟线程的实现有关了,JDK做了大量的改进,以确保应用程序在使用虚拟线程是拥有良好的体验:

  • 新的套接字实现:为了更好的支持虚拟线程,需要让阻塞方法可被中断,为此使用了JEP 353 (重新实现 Legacy Socket API) and JEP 373 (重新实现旧版 DatagramSocket API)替换了Socket、ServerScoket和DatagramSocket的实现。
  • 虚拟线程感知:JDK中几乎所有的阻塞点,都做了虚拟线程判断,并且会卸载虚拟线程而不是阻塞它;
  • 重新审视ThreadLocal:JDK中的许多ThreadLocal用法都根据线程使用模式的预期变化进行了修订;
  • 重新审视锁:当虚拟线程在执行synchronized块时,无法从载体线程中卸载,这会影响系统吞吐量的可伸缩性,如果要避免这种情况,请使用ReentrantLock代替synchronized。有一些跟踪排查方法可以使用,具体阅读:JEP 425: Virtual Threads (Preview)#Executing virtual threads
  • 改进的线程转储:通过使用jcmd,提供了更好的线程转储,可以过滤掉虚拟线程、将相关的虚拟线程组合在一起,或者以机器可读的方式生成转储,这些转储可以进行后处理以获得更好的可观察性。

4.4 虚拟线程会取代掉原有的线程吗?

可能很多朋友都会有这个疑问。

JEP 425: Virtual Threads (Preview)[^1]中,提到了虚拟线程的设计目标,同时也提到了Non-Goals(非目标):

  • 删除传统的线程实现,或静默迁移现有应用程序以使用虚拟线程不是目标;
  • 改变 Java 的基本并发模型不是目标;
  • 在 Java 语言或 Java 库中提供新的数据并行结构不是目标。Stream API仍然是并行处理大型数据集的首选方式。

虚拟线程不替代原有的线程,它们是互补的。但是许多服务器应用程序会选择虚拟线程来实现更大的可扩展性。服务端的程序员们也无需多操心,等使用的框架都支持虚拟线程的时候,理想的情况下,只需要改动一下框架配置,就完成了虚拟线程的切换,也许这个时候,我们可以为开源框架的虚拟线程改造做点贡献。

5 使用虚拟线程,请忘掉这些东西

虚拟线程与之前的线程API没有什么差别,为此,使用虚拟线程,你需要学习的东西比较少。

但是为了更好的使用虚拟线程,你需要忘掉以前的一些东西。

5.1 不再依赖线程池

Java 5 引入了java.util.concurrent包,其中包括了ExecutorService框架,通过使用ExecutorService以策略驱动的方式管理和池化线程池通常比直接创建线程要好得多。

对于平台线程,我们习惯于将它们池化,并且在一些公司的开发规范中,是一种强制措施,以限制资源利用率,否则容易耗尽内存,并将线程启动的成本分摊到多个请求上。但是也引入了其他的问题,流例如ThreadLocal污染导致内存泄露。

但是在虚拟线程面前,池化技术反而成了反模式。因为虚拟线程的初始化占用空间非常小,所以创建虚拟线程在时间和内存上都比创建平台线程开销小得多,甚至数百万个虚拟线程才使用1G内存。如果限制线程本身以外的某些资源的并发度,例如数据库连接,我们可以使用Semaphore来获取稀缺资源的许可。

虚拟线程非常轻量级,即使是短期任务也可以创建虚拟线程,而尝试重用或者回收他们会适得其反。虚拟线程的设计也考虑到了短期任务,如HTTP请求或者JDBC查询。

注意:我们不必放弃使用ExecutorService,依旧可以通过新的工厂方法Executors::newVirtualThreadPerTaskExecutor来获得一个ExecutorService伪每个任务创建一个新的虚拟线程。

5.2 请勿过渡使用ThreadLocal

有时候,使用ThreadLocal来缓存分配内存开销大的资源,或者为了避免重复分配常用对象,当系统有几百个线程的时候,这种模式的资源使用通常不会过多,并且比起每次使用都重新分配更高效。但是当有几百万个线程时,每个线程只执行一个任务,但是可能分配了更多的实例,每个实例被重用的机会要小得多,最终会导致消耗更大的性能开销。


从本文我们可以看到虚拟线程给我带来的诸多好处,它允许我们编写可读且可维护的代码的同事,不会阻塞操作系统线程。

在常见的后端框架支持虚拟线程之前,我们还需要耐心的等待一段时间。到时小张急着上厕所的时候再也不用排长队了。

Java19虚拟线程都来了,我正在写的线程代码会被淘汰掉吗?


我精心整理了一份Redis宝典给大家,涵盖了Redis的方方面面,面试官懂的里面有,面试官不懂的里面也有,有了它,不怕面试官连环问,就怕面试官一上来就问你Redis的Redo Log是干啥的?毕竟这种问题我也不会。

Java19虚拟线程都来了,我正在写的线程代码会被淘汰掉吗?

Java架构杂谈公众号发送Redis关键字获取pdf文件:

Java19虚拟线程都来了,我正在写的线程代码会被淘汰掉吗?

本文作者: arthinking

博客链接: https://www.itzhai.com/articles/virtual-thread-in-java19.html

Java19虚拟线程都来了,我正在写的线程代码会被淘汰掉吗?

版权声明: 版权归作者所有,未经许可不得转载,侵权必究!联系作者请加公众号。

Refrences

[1]: JEP 425: Virtual Threads (Preview). Retrieved from https://openjdk.org/jeps/425
[2]: Virtual Threads: New Foundations for High-Scale Java Applications. Retrieved from https://www.infoq.com/articles/java-virtual-threads/
[3]: Virtual Threads in Java (Project Loom). Retrieved from https://www.happycoders.eu/java/virtual-threads/
[4]: 绿色线程. Retrieved from https://zh.m.wikipedia.org/zh-hans/%E7%BB%BF%E8%89%B2%E7%BA%BF%E7%A8%8B
[5]: Coming to Java 19: Virtual threads and platform threads. Retrieved from https://blogs.oracle.com/javamagazine/post/java-loom-virtual-threads-platform-threads
[6]: Difference Between Thread and Virtual Thread in Java. Retrieved from https://www.baeldung.com/java-virtual-thread-vs-thread