当前位置: 首页 > Web前端 > HTML

Java19发布,Loom如何解决Java的并发模型缺陷?丨未来源码

时间:2023-03-28 01:00:10 HTML

本文来自InfoQ中文网,原作者DeepuKSasidharan。推荐语:我们开发的网站,如果访问量过大,请求激增,需要考虑相关的并发问题。异步并发意味着适应更复杂的编程风格。Java中的传统线程非常重,与操作系统线程是一对一绑定的。Loom是jave生态系统中较新的项目,它试图解决传统并发模型的局限性。但是如何实现它在这篇文章中有详细的解释。随着ProjectLoom的加入,或许在未来,java生态的性能会发生一个数量级的提升。——MobTech高级java开发工程师Java19日前发布,最引人注目的特性是虚拟线程。本文介绍Loom项目中虚拟线程和结构化编程的基础知识。并将其与操作系统线程进行了比较。Java在发展初期就有很好的多线程和并发能力,可以高效利用多线程和多核CPU。JavaDevelopmentKit(JDK)1.1为平台线程(或操作系统(OS)线程)提供了基本支持,而JDK1.5提供了更多实用程序和更新以改进并发性和多线程。JDK8带来了异步编程支持和更多的并发改进。尽管在许多不同的版本中得到了改进,但Java在过去的三十多年里除了基于操作系统的并发和多线程支持之外没有任何突破。Java中的并发模型虽然非常强大和灵活,但并不是最容易使用的,开发者体验也不是很好。这主要是因为它默认使用的共享状态并发模型。我们必须使用同步线程来避免数据竞争和线程阻塞等问题。我曾经在一篇名为“现代编程语言中的并发性:Java”的博文中讨论过Java并发性问题。什么是Loom项目?ProjectLoom旨在大幅减少与编写、维护和观察以最佳方式利用现有硬件的高吞吐量并发应用程序相关的工作量。-RonPressler(Loom项目技术主管)操作系统线程是Java并发模型的核心,围绕它们有一个非常成熟的生态系统,但它们也有一些缺点,例如计算成本高。让我们看看两个最常见的并发用例,以及当前Java并发模型在这些用例中的缺点。并发最常见的用例之一是在服务器的帮助下通过网络处理请求。在这种情况下,首选方法是“每个请求一个线程”模型,其中每个请求都由一个单独的线程处理。这样一个系统的吞吐量可以用Little定律来计算,它指出在一个稳定的系统中,平均并发度(服务器并发处理的请求数)L等于吞吐量(平均请求率)λ的乘积通过延迟(处理每个请求的平均时间)W。基于此,我们可以得出结论,吞吐量等于平均并发数除以延迟(λ=L/W)。因此,在“每个请求一个线程”模型中,吞吐量将受到操作系统线程数的限制,这取决于硬件上可用的物理内核/线程数。要解决这个问题,我们不得不使用共享线程池或者异步并发,这两者都有各自的缺点。线程池有很多限制,例如线程泄漏、死锁、资源激增等。异步并发意味着必须适应更复杂的编程风格并谨慎处理数据竞争。它们还有可能出现内存泄漏、线程锁等问题。另一个常见的使用场景是并行处理或多线程,我们可能会将一个任务拆分为跨越多个线程的子任务。此时,我们必须编写避免数据损坏和数据竞争的解决方案。在某些情况下,在执行分布在多个线程上的并行任务时,还需要保证线程同步。这样的实现将非常脆弱,并且让开发人员承担很多责任,以确保不存在线程泄漏和取消延迟等问题。Loom项目旨在通过引入虚拟线程和结构化并发这两个新特性来解决当前并发模型中的这些问题。虚拟线程Java19已于2022年9月20日发布,虚拟线程是预览功能之一。虚拟线程是轻量级线程,不绑定操作系统线程,而是由JVM管理。它们适用于“每个请求一个线程”的编程风格,不受操作系统线程的限制。我们能够在不影响吞吐量的情况下创建数百万个虚拟线程。这与Go编程语言(Golang)协程(如goroutines)非常相似。Java19中新的虚拟线程特性易于使用。这里我将其与Golang的goroutines和Kotlin的coroutines进行对比。虚拟线程Thread.startVirtualThread(()->{System.out.println("Hello,ProjectLoom!");});Goroutinegofunc(){println("Hello,Goroutines!")}()KotlincoroutinerunBlocking{launch{println("Hello,Kotlincoroutines!")}}冷知识:在JDK1.1之前,Java用于支持绿色线程(也称为作为虚拟线程),但是这个特性在JDK1.1中被移除了,因为实现和平台线程没什么两样。在JVM中完成了一种新的虚拟线程实现,它将多个虚拟线程映射到一个或多个操作系统线程,开发者可以根据需要使用虚拟线程或平台线程。这个虚拟线程的实现还有以下几点考虑:在代码、运行时、调试器和探查器(profiler)中,它是一个Thread。它是一个Java实体,而不是本地线程的包装器。创建和阻止它们是廉价的操作。它们不应放置在游泳池中。虚拟线程使用基于工作窃取的ForkJoinPool调度程序。可插拔调度器可用于异步编程。虚拟线程将拥有自己的堆栈内存。虚拟线程的API与平台线程非常相似,因此更易于使用或移植。让我们看几个演示虚拟线程强大功能的示例。线程总数首先,让我们看看一台机器上可以创建多少个平台线程和虚拟线程。我的机器是IntelCorei9-11900H处理器,8核16线程,64GB内存,操作系统是Fedora36。平台线程varcounter=newAtomicInteger();while(true){newThread(()->{intcount=counter.incrementAndGet();System.out.println("Threadcount="+count);LockSupport.park();}).start();在我的机器上,代码在创建32,539个平台线程后崩溃。虚拟线程varcounter=newAtomicInteger();while(true){Thread.startVirtualThread(()->{intcount=counter.incrementAndGet();System.out.println("Threadcount="+count);LockSupport.park();});}在我的机器,进程在创建14,625,956个虚拟线程后挂起,但它没有崩溃,它只是随着内存逐渐可用而继续缓慢运行。您可能想知道为什么会这样。这是因为停放的虚拟线程被垃圾收集,JVM可以创建更多的虚拟线程并将它们分配给底层平台线程。任务吞吐量我们尝试使用平台线程运行100,000个任务。try(varexecutor=Executors.newThreadPerTaskExecutor(Executors.defaultThreadFactory())){IntStream.range(0,100_000).forEach(i->executor.submit(()->{Thread.sleep(Duration.ofSeconds(1));System.out.println(i);returni;}));}这里我们使用了带有默认线程工厂的newThreadPerTaskExecutor方法,因此使用了一个线程组。运行这段代码并计时,我得到以下结果。使用Executors.newCachedThreadPool()线程池时我获得了更好的性能。#'newThreadPerTaskExecutor'with'defaultThreadFactory'0:18.77real,18.15suser,7.19ssys,135%3891pu,0amem,743584mmem#'newCachedThreadPool'with'defaultThreadFactory'0:11.52real,13.24ssys,s13.24,157%6019pu,0amem,2215972mmem看起来不错。现在,让我们用虚拟线程完成同样的任务。try(varexecutor=Executors.newVirtualThreadPerTaskExecutor()){IntStream.range(0,100_000).forEach(i->executor.submit(()->{Thread.sleep(Duration.ofSeconds(1));System.out.println(i);returni;}));}运行这段代码并计时,我得到以下结果:0:02.62real,6.83suser,1.46ssys,316%14840pu,0amem,350268mmem这比基于平台线程的线程池好太多了。当然,这些都是很简单的使用场景,线程池和虚拟线程的实现还可以进一步优化以获得更好的性能,但这不是本文的重点。使用相同的代码运行JavaMicrobenchmarkHarness(JMH),结果如下。可以看出,虚拟线程的性能要比平台线程好很多。#ThroughputBenchmarkModeCntScoreErrorUnitsLoomBenchmark.platformThreadPerTaskthrpt50.362±0.079ops/sLoomBenchmark.platformThreadPoolthrpt50.528±0.067ops/sLoomBenchmark.virtualThreadPerTaskthrpt51.843±0.093ops/s#AveragetimeBenchmarkModeCntScoreErrorUnitsLoomBenchmark.platformThreadPerTaskavgt55.600±0.768s/opLoomBenchmark.platformThreadPoolavgt53.887±0.717s/opLoomBenchmark.virtualThreadPerTaskavgt51.098±0.020s/op您可以在GitHub上找到此基准测试的源代码。以下是其他几个有价值的虚拟线程基准测试:ElliotBarlas在GitHub上使用ApacheBench做了一个有趣的基准测试。AlexanderZakusylo在Medium上使用Akkaactors进行基准测试。GitHub上ColinCachia的I/O和非I/O任务的JMH基准测试。结构化并发结构化并发是Java19中的一个孵化特性。结构化并发的目的是简化多线程和并行编程。它将在不同线程中运行的多个任务视为一个工作单元,简化了错误处理和任务取消,同时提高了可靠性和可观察性。这有助于避免线程泄漏和取消延迟等问题。作为孵化特征,它可能会在稳定过程中发生进一步的变化。让我们考虑以下使用java.util.concurrent.ExecutorService的示例。voidhandleOrder()throwsExecutionException,InterruptedException{voidhandleOrder()throwsExecutionException,InterruptedException{try(varesvc=newScheduledThreadPoolExecutor(8)){Futureinventory=esvc.提交(()->更新);Future(order=esvc.submit(()->updateOrder());inttheInventory=inventory.get();//加入updateInventoryinttheOrder=order.get();//加入updateOrderSystem.out.println("Inventory"+theInventory+"updatedfororder"+theOrder);}}我们希望updateInventory()和updateOrder()两个子任务可以并发执行,每个任务可以独立成功或失败。理想情况下,handleOrder()如果任何子任务失败,方法应该失败。但是,如果子任务失败,事情就会变得不可预测。想象一下updateInventory()失败并抛出异常。然后,handleOrder()方法将在调用invent.get时抛出异常()。到目前为止,没有什么大问题,但是updateOrder()呢?由于它在自己的线程上运行,它很可能会成功完成。但是现在库存和订单之间存在不匹配。假设updateOrder()是一个昂贵的操作.在这种情况下,我们是在浪费资源,不得不编写某种保护逻辑来撤消对订单所做的更新,因为我们的整体操作失败了。假设updateInventory()是一个代价高昂的长时间运行的操作,而updateOrder()抛出一个错误。即使updateOrder()抛出错误,handleOrder()任务仍会阻塞inventory.get()方法。理想情况下,我们希望handleOrder()任务在updateOrder()失败时取消updateInventory(),这样就不会浪费时间。如果执行handleOrder()的线程被中断,则中断不会传播到子任务。在这种情况下,updateInventory()和updateOrder()会泄漏并继续在后台运行。对于这些场景,我们必须仔细编写解决方法和故障保护措施,将所有责任推给开发人员。我们可以使用以下代码来实现具有结构化并发的相同功能。voidhandleOrder()throwsExecutionException,InterruptedException{try(varscope=newStructuredTaskScope.ShutdownOnFailure()){Futureinventory=scope.fork(()->updateInventory());}Futureorder=scope.fork(()->updateOrder());作用域.join();//加入两个分支scope.throwIfFailed();//...并传播错误//这里,两个分支都成功了,所以组合它们的结果System.out.println("库存"+inventory.resultNow()+"为订单更新"+order.resultNow());}}与之前使用ExecutorService的示例不同,我们现在使用StructuredTaskScope来实现相同的结果,并将子任务的生命周期限制在词法范围内,在这种情况下,是try-with-resources语句的主体。这段代码更具可读性,意图也很明确。StructuredTaskScope还自动确保以下行为:基于短路的错误处理:如果updateInventory()或updateOrder()失败,另一个将被取消,除非它已经完成。这是由ShutdownOnFailure()实现的取消策略管理的,我们也可以使用其他策略。取消传播:如果运行handleOrder()的线程在调用join()之前或期间被中断,则当线程退出作用域时,两个分支(分叉)都将自动取消。可观察性:线程转储文件将清楚地显示任务层次结构,运行updateInventory()和updateOrder()的线程显示为范围的子线程。Loom项目的现状Loom项目始于2017年,经历了许多变化和提案。虚拟线程最初称为纤程,但后来为了避免混淆而重新命名。现在随着Java19的发布,该项目已经提供了上面讨论的两个特性。其中一个处于预览状态,另一个处于孵化状态。因此,稳定这些功能的途径应该变得更加清晰。这对普通Java开发人员意味着什么?当这些功能准备好投入生产时,它们应该不会对普通Java开发人员产生太大影响,他们可能正在使用一些库来处理并发场景。但是,在一些罕见的场景中,您可能在不使用库的情况下执行大量多线程,那么这些功能可能很有价值。虚拟线程可以毫不费力地取代您今天使用的线程池。根据现有基准,它们在大多数情况下提高了性能和可扩展性。结构化并发有助于简化多线程或并行处理,使其更健壮且更易于维护。这对Java库开发人员意味着什么?当这些功能准备就绪时,对于使用线程或并行性的库和框架来说将是一件大事。库作者可以实现巨大的性能和可扩展性收益,同时简化代码库并使其更易于维护。大多数使用线程池和平台线程的Java项目都可以从切换到虚拟线程中受益,候选对象包括Tomcat、Undertow和Netty等Java服务器软件,以及Spring和Micronaut等Web框架。我希望大多数JavaWeb技术从线程池迁移到虚拟线程。JavaWeb技术和新兴的反应式编程库,例如RxJava和Akka,也可以有效地使用结构化并发。但这并不意味着虚拟线程将是所有问题的解决方案。异步和反应式编程仍然有其适用的场景和好处。了解更多关于Java、多线程和Loom项目的信息:OnthePerformanceofUser-ModeThreadsandCoroutinesLoomProjectLoom:ModernScalableConcurrencyfortheJavaPlatformThinkingAboutMassiveThroughput?认识虚拟线程!Java18最终有更好的JNI替代品吗?面向Java开发人员的OAuthCloudNativeJavaMicroserviceswithJHipsterandIstio