Java 线程 – 内存效率可能不太高?

Java 应用程序中往往会包含数百(有时是数千)个线程。此类线程会有大部分处于 WAITING、TIMED_WAITING(即:休眠)状态,只有一小部分线程会处于执行代码行的活动状态。因此,我们很想知道休眠线程是否会比活动线程所消耗的内存更少。

为了弄清楚这一问题的答案,我们进行一项小小的调查。调查的结果十分有趣,我们也将在本文中与您分享。

线程的栈中存储的是什么?

在读下去之前,您首先应该知道线程的栈中存储了哪些信息。可参阅这片文章来充分了解存储在线程栈中的信息。简而言之,这些信息会存储在线程的栈中:

  1. 方法中创建的局部变量。
  2. 线程当前正在执行的代码路径。

研究

为了便于研究的进行,我们编写了两个简单的程序。让我们来看看这两个程序及其性能特点。

1. 带有空栈帧的线程

我们编写了一个会创建 1000 个线程的简单 Java 程序。程序的所有线程栈帧都会几乎为空,这样就不会消耗任何内存了。

public class EmptyStackFrameProgram {

   public void start() {
      
      // 创建 1000 个线程
      for (int counter = 0; counter < 1000; ++counter) {
      new EmptyStackFrameThread().start();
      }
   }
}

public class EmptyStackFrameThread extends Thread {

   public void run() {
   
      try {
      // 永久休眠
         while (true) {
         Thread.sleep(10000);
         }
      } catch (Exception e) {
}
   }
}

此 Java 程序在“EmptyStackFrameProgram”中创建了 1000 个线程。所有“EmptyStackFrameProgram”线程都处于无限期的休眠中,所以其不会进行什么操作。因此,这些线程的栈帧几乎都是空的,因为其没有执行任何新的代码行或创建任何新的局部变量。

备注:我们将线程置于无限期的休眠状态,这样线程就不会消失。这对于我们研究其内存使用情况至关重要。

2. 具有已加载栈帧的线程

这里是另一个会创建 1000 个线程的简单 Java 程序。程序中的所有线程都会在其栈帧中完全加载数据。这样线程就会比之前的程序占用更多内存了。

public class FullStackFrameProgram {

   public void start() {
      
      // 创建 1000 个完全填充数据的线程
      for (int counter = 0; counter < 1000; ++counter) {
         new FullStackFrameThread().start();
      }
   }
}

public class FullStackFrameThread extends Thread {

   public void run() {
   
      try {
         int x = 0;
         simpleMethod(x);
      } catch (Exception e) {}
   }

   /**
   * 重复 10,000 次然后休眠。这样栈就会被填满。
   *
   * @param counter
   * @throws Exception
   */

   private void simpleMethod(int x) throws Exception {

      // 创建局部变量以填充栈。
      float y = 1.2f * x;
      double z = 1.289898d * x;
      
      // 循环 10,000 次迭代以填充栈。
      if (x < 10000) {
         simpleMethod(++x);
      }
      
      // 10,000 次迭代永久休眠
      while (true) {
         Thread.sleep(10000);
      }
   }
}

此 Java 程序在“FullStackFrameProgram”中创建了 1000 个线程。所有“FullStackFrameThread”线程都会重复调用“simpleMethod(int counter)”10,000次。调用 10,000 次后,线程将进入无限期的休眠状态。由于线程调用了“simpleMethod(int counter)”10,000次,所以每个线程都会有 10,000 个栈帧,且每个线程都会被局部变量“x”、“y”、“z”填充。

图:EmptyStackFrameThread 与 FullStackFrameThread 栈

以上是 EmptyStackFrameThread 栈与 FullStackFrameThread 栈的可视化图形。您应该能注意到,“EmptyStackFrameThread”中仅包含 2 个栈帧。另一边,“FullStackFrameThread”中则包含 10,000+ 栈帧。除此之外,“FullStackFrameThread”中的每个栈帧都会包含局部变量“x”、“y”、“z”。这就使得“FullStackFrameThread”栈完全加载。因此,大家应该会认为“FullStackFrameThread”栈将消耗更多内存。

内存消耗

在执行上述两个程序时我们采用了以下设置:

a.将线程栈内存大小配置为 2 MB(即:向两个程序传递 -Xss2m JVM 参数)。

b.使用了 OpenJDK 1.8.0_265, 64-Bit 服务器 VM

c.两个程序同时在 AWS ‘t3a.medium’ EC2 实例上运行。

您可在下方看到系统监控工具“top”报告的程序内存消耗情况。

图:两个程序的内存消耗相同

可以看到,两个程序所消耗的内存都是 4686 MB。这表明两个程序的线程都消耗了相同的内存,即使是 FullStackFrameThread 非常活跃,EmptyStackFrameThread 几乎休眠。

为了验证这一理论,我们使用了 JVM 根本原因分析工具 yCrash 来进一步对两个程序进行分析。下面是由 yCrash 工具生成的线程分析报告。

图:yCrash工具报告 EmptyStackFrameThread 栈几乎为空

图:yCrash工具报告 FullStackFrameThread 栈几乎全满

yCrash 工具还明确指出,EmptyStackFrameProgram 包含 1000 个线程和 2 个栈帧,FullStackFrameProgram 则包含 1000 个线程和 10,000 个栈帧。

结论

调查清楚表明,内存是在线程创建时分配的,而不是根据线程的运行时需求分配的。非常活跃的线程和几乎休眠的线程所消耗的内存几乎相同。现代 Java 应用程序往往会创建数百(有时是数千)个线程。但其中的大部分线程都处于 WAITING 或 TIMED_WAITING 状态,不会做什么工作。考虑到线程会在创建时就占用最大的分配内存量,作为程序开发人员,您可以执行以下操作来优化程序的内存消耗:

  1. 仅为程序创建*必要的*线程。
  2. 试着确定程序线程的最佳栈大小(即:-Xss)。假设您将线程的栈大小(即:-Xss)配置为 2 MB,并且在运行时如果程序仅使用了 512 KB,那么程序中的每个线程都会浪费 1.5 MB 的内存。如果程序中有 500 个线程,则每个 JVM 实例都将浪费 750 MB(即:500 个线程 x 1.5 MB)的内存,这对于当今的云计算时代而言不“便宜”。

您可使用 yCrash 等工具来了解有多少线程处于活动状态,有多少线程处于休眠状态。同时此类工具还可以告诉您各个线程的栈使用情况。根据这些信息,您就能为程序确定最优线程数和线程栈大小。

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

Blog at WordPress.com.

Up ↑

%d bloggers like this: