使用Java虚拟线程时要避免的陷阱


Java 虚拟线程是 JDK 19 提供的一项新功能。它有可能在减少内存消耗的基础上提高应用程序的可用性、吞吐量和代码质量。

在本文中,让我们了解从 Java 平台线程切换到虚拟线程时应避免的陷阱:

  1. 避免同步块/方法
  2. 避免线程池限制资源访问
  3. 减少ThreadLocal的使用

1.避免同步块/方法
 当一个方法在 Java 中被同步时,一次只允许一个线程进入该方法。让我们考虑以下示例:

 public synchronized void getData() {
   makeDBCall();
 }

在上面的代码片段中,'getData()'方法是'synchronized'。假设线程 1 尝试首先进入“ getData()”方法。当此线程 1 正在执行 getData() 方法时,线程 2 尝试执行此方法。由于“thread-1”当前正在执行“getData()”方法,因此“thread-2”将不允许执行。它将被置于 BLOCKED 状态。如果您在这种情况下使用虚拟线程,当线程移动到 BLOCKED 状态时,理想情况下它应该放弃其对底层 OS 线程的控制并移回堆内存。然而,由于当前虚拟线程实现的限制,当虚拟线程由于同步方法(或块)而被阻塞时,它不会放弃对底层操作系统线程的控制。因此,您不会获得切换到虚拟线程的好处。

在这种情况下,您应该考虑用“ ReentrantLock ”替换同步方法/块。可以使用“ReentrantLock”重写上面的示例代码同步的 getData()方法:

private ReentrantLock myLock = new ReentrantLock();

public void getData() {

 myLock.lock(); // acquire lock

   try {


      makeDBCall();

   } finally {


     myLock.unlock();
// release lock

   }

}

当你用 ReentrantLock 替换 synchronized 方法时,虚拟线程将放弃底层 OS 线程的控制,你可以享受虚拟线程的好处。

注意:虚拟线程在同步方法上工作时不释放底层操作系统线程,是 JDK 19 中的当前限制。它可以在未来的 Java 版本中解决。


2.避免线程池限制资源访问
有时,在我们的编程结构中,我们可能会使用线程池来限制对某些资源的访问。假设我们只想对后端系统进行 10 个并发调用,它可能已经使用线程池进行编程,如下所示:

 private ExecutorService BACKEND_THREAD_POOL = Executors.newFixedThreadPool(10);
 
   public <T> Future<T> queryBackend(Callable<T> query) {


  return BACKEND_THREAD_POOL.submit(query);

 }

在第 1 行中,您会注意到创建了一个包含 10 个线程的“BACKEND_THREAD_POOL” 。此线程池用于“queryBackend()”方法中以进行后端调用。该线程池将确保对后端系统的并发调用不超过 10 个。

在撰写本文时(2023 年 1 月),JDK 中没有可用的 API 来创建具有固定数量虚拟线程的执行器(即线程池)。下面是创建虚拟线程的所有 API 的列表。当您使用 Executor 时,您只能创建无限数量的虚拟线程。为了解决这个问题,您可以考虑将 Executor 替换为Semaphore。在上面的示例中,  可以使用“Semaphore”重写“queryBackend()”方法,如下所示:

 private static Semaphore BACKEND_SEMAPHORE = new Semaphore(10);

    public static <T> T queryBackend(Callable<T> query) throws Exception {

      BACKEND_SEMAPHORE.acquire(); // allow only 10 concurrent calls

        try {

           return query.call();

      } finally {

         BACKEND_SEMAPHORE.release();

      }

 }

如果您不熟悉信号量,您可以阅读这篇“ Java 信号量 - 简单介绍”帖子。如果您注意到第 1 行,我们正在创建一个具有 10 个许可的“BACKEND_SEMAPHORE”。

semaphore将只允许对后端系统进行 10 次并发调用。这是 Executor 的一个很好的替代品。 

3.减少ThreadLocal的使用
很少有应用程序倾向于使用ThreadLocal变量。如果你不熟悉ThreadLocal变量,你可以阅读这篇 "Java ThreadLocal--简单介绍 "的文章。但简而言之,Java ThreadLocal变量是在一个特定的线程范围内创建和存储的变量,它不能被其他线程访问。如果你的应用程序创建了数以百万计的虚拟线程,并且每个虚拟线程都有自己的ThreadLocal变量,那么它就会迅速消耗java堆的内存空间。因此,你要对存储为ThreadLocal变量的数据的大小保持谨慎。

你可能想知道为什么ThreadLocal变量在平台线程中没有问题。区别在于,在平台线程中,我们不会创建数以百万计的线程,而在虚拟线程中,我们会创建。数以百万计的线程,每个都有自己的ThreadLocal变量副本,可以迅速填满内存。俗话说,小水滴汇成大海。这句话在这里非常正确。

一般来说,Java ThreadLocal变量的管理和维护很棘手。它也可能导致讨厌的生产问题。因此,限制ThreadLocal变量的使用范围,可以使你的应用程序受益,特别是在使用虚拟线程时。