Java线程创建开销

47

传统智慧告诉我们,高并发的企业 Java 应用应该使用线程池来代替新建工作线程。使用 java.util.concurrent 可以轻松实现。

然而,存在一些情况线程池并不适合。我目前遇到的特定问题是使用 InheritableThreadLocal 的情况,它允许将 ThreadLocal 变量“传递”给任何派生线程。但是在使用线程池时,这种机制会出现问题,因为工作线程通常不是从请求线程中创建的,而是预先存在的。

现在有一些解决方法(可以显式传入线程本地变量),但这并不总是合适或可行的。最简单的解决方案是按需生成新的工作线程,并让 InheritableThreadLocal 完成其工作。

这就回到了问题所在:如果我有一个高并发的站点,用户请求线程每次都要产生半打工作线程(即不使用线程池),这会给 JVM 带来问题吗?我们可能在每秒钟内创建数百个新线程,每个线程持续时间不到一秒钟。现代 JVM 是否能很好地优化这一问题?我记得 Java 中使用对象池是可取的,因为对象创建很昂贵。但这已经变得不必要了。我想知道线程池是否也是如此。

如果我知道应该测量什么,我会进行基准测试,但我担心问题可能比分析工具所能测量的更微妙。

注意:本文的问题不在于使用线程本地变量是否明智,请不要建议我不要使用它们。


我原本想建议将您的ThreadLocal包装在访问器方法中,这可能会解决您在InheritableThreadLocal方面遇到的问题,但是您似乎不想听取这个建议。此外,看起来您正在将InheritableThreadLocal用作带外调用帧,说实话,这似乎像是一种代码异味。 - kdgregory
就线程池而言,主要的好处是控制:你知道你不会突然在一秒钟内尝试启动10,000个线程。 - kdgregory
2
@kdgregory:针对您的第一点,所涉及的ThreadLocals是由Spring的bean作用域使用的。这是Spring的工作方式,而不是我可以控制的。针对您的第二点,传入请求线程受到Tomcat线程池的限制,因此限制已经内在化了。 - skaffman
Tomcat线程池如何限制您创建的线程数量?您描述了一个应用程序,其中“用户请求线程生成半打工作线程”,我认为您关心的是这些线程。一个错误,您很容易为单个请求启动10,000个线程。 - kdgregory
2
关于你需要ThreadLocal的原因,然而:这是有效的,并且在消息中发布以避免聪明的评论是一件好事 :-) - kdgregory
FYI,Project Loom 试图将“虚拟线程”(纤程)作为 Java 并发工具箱中的另一种工具引入。虚拟线程在快速性能方面非常便宜,在内存中的堆栈会随着需要而增长和缩小,并且在代码块时自动进行线程“停放”(搁置)。我不知道虚拟线程如何与 InheritableThreadLocal 协同工作。如果有人想尝试基于早期访问的 Java 17 的实验构建版本,则 Loom 团队正在征求反馈。 - Basil Bourque
4个回答

38

这里是一个微基准测试的例子:

public class ThreadSpawningPerformanceTest {
static long test(final int threadCount, final int workAmountPerThread) throws InterruptedException {
    Thread[] tt = new Thread[threadCount];
    final int[] aa = new int[tt.length];
    System.out.print("Creating "+tt.length+" Thread objects... ");
    long t0 = System.nanoTime(), t00 = t0;
    for (int i = 0; i < tt.length; i++) { 
        final int j = i;
        tt[i] = new Thread() {
            public void run() {
                int k = j;
                for (int l = 0; l < workAmountPerThread; l++) {
                    k += k*k+l;
                }
                aa[j] = k;
            }
        };
    }
    System.out.println(" Done in "+(System.nanoTime()-t0)*1E-6+" ms.");
    System.out.print("Starting "+tt.length+" threads with "+workAmountPerThread+" steps of work per thread... ");
    t0 = System.nanoTime();
    for (int i = 0; i < tt.length; i++) { 
        tt[i].start();
    }
    System.out.println(" Done in "+(System.nanoTime()-t0)*1E-6+" ms.");
    System.out.print("Joining "+tt.length+" threads... ");
    t0 = System.nanoTime();
    for (int i = 0; i < tt.length; i++) { 
        tt[i].join();
    }
    System.out.println(" Done in "+(System.nanoTime()-t0)*1E-6+" ms.");
    long totalTime = System.nanoTime()-t00;
    int checkSum = 0; //display checksum in order to give the JVM no chance to optimize out the contents of the run() method and possibly even thread creation
    for (int a : aa) {
        checkSum += a;
    }
    System.out.println("Checksum: "+checkSum);
    System.out.println("Total time: "+totalTime*1E-6+" ms");
    System.out.println();
    return totalTime;
}

public static void main(String[] kr) throws InterruptedException {
    int workAmount = 100000000;
    int[] threadCount = new int[]{1, 2, 10, 100, 1000, 10000, 100000};
    int trialCount = 2;
    long[][] time = new long[threadCount.length][trialCount];
    for (int j = 0; j < trialCount; j++) {
        for (int i = 0; i < threadCount.length; i++) {
            time[i][j] = test(threadCount[i], workAmount/threadCount[i]); 
        }
    }
    System.out.print("Number of threads ");
    for (long t : threadCount) {
        System.out.print("\t"+t);
    }
    System.out.println();
    for (int j = 0; j < trialCount; j++) {
        System.out.print((j+1)+". trial time (ms)");
        for (int i = 0; i < threadCount.length; i++) {
            System.out.print("\t"+Math.round(time[i][j]*1E-6));
        }
        System.out.println();
    }
}
}

在64位Windows 7上,使用32位Sun的Java 1.6.0_21客户端VM和Intel Core2 Duo E6400 @ 2.13 GHz处理器的结果如下:

Number of threads  1    2    10   100  1000 10000 100000
1. trial time (ms) 346  181  179  191  286  1229  11308
2. trial time (ms) 346  181  187  189  281  1224  10651

结论:如预期,两个线程的工作速度几乎是一个线程的两倍,因为我的计算机有两个核心。我的计算机每秒可以产生近10000个线程,即线程创建开销为0.1毫秒。因此,在这样一台机器上,每秒产生几百个新线程对性能影响可以忽略不计(这也可以通过比较2个和100个线程列中的数字来看出)。

10

首先,这当然非常取决于你使用的JVM版本。操作系统也会起到重要作用。假设使用Sun JVM(哦,我们还叫它那个吗?):

一个主要因素是为每个线程分配的堆栈内存大小,您可以使用-Xssn JVM参数进行调整——您将想要使用尽可能低的值。

而且这只是一个猜测,但我认为“每秒几百个新线程”绝对超出了JVM舒适处理的设计范围。我怀疑简单的基准测试很快就会揭示相当明显的问题。


2
我觉得 new Thread() 的概念很有趣。在现代JVM中,new Object() 不总是分配新的内存,而是重新使用之前被垃圾回收的对象。我想知道为什么JVM不能有一个隐藏的、内部的可重用线程池,这样new Thread()就不一定需要创建一个新的内核线程了。这样你就可以获得有效的线程池,而无需使用API。 - skaffman
2
如果是这样,它应该在某个JSR中找到。可能是133 http://www.cs.umd.edu/~pugh/java/memoryModel/jsr133.pdf - Bozho
1
@skaffman 您的假设似乎与我在至少osx/jdk1.6上观察到的一致。在过去几个月中,我曾经多次将线程池+“新的可运行项”与类似大小的信号量+“新线程”进行比赛,但似乎从来没有任何可测量的差异。信号量方法有时似乎会胜出线程池方法,但差异如此微小且罕见,以至于它只是强调它们的相似之处以及您必须努力才能在它们之间获得任何差异。 - David Blevins
如果我每3分钟都创建一个新的Thread(),那么对于运行24小时的应用程序来说,内存消耗是否仍然很大?@Michael,有什么解决方案吗?因为每3分钟一个线程结束,另一个线程就会被创建……这样是否仍然很耗费资源? :D - gumuruh
@gumuruh:每3分钟创建一个线程绝对没有任何问题。只有当线程不结束且其堆栈内存未被回收时,才可能成为问题。 - Michael Borgwardt
我有大约3-8个不同的对象,每个对象都调用new Thread(),它们之间至少相差3分钟。它们的生命周期也彼此不同。当我尝试在我的Win7上运行应用程序,使用2 GB内存时,速度非常慢...但是当我减少对象调用,变为2-3个时,就不会很慢了。我认为这也可能是线程内存问题...@MichaelBorgwardt - gumuruh

1
  • 对于你的基准测试,你可以使用 JMeter + 一个分析器,这应该能够直接了解在这样一个高负载环境中的行为。只需让其运行一个小时,并监视内存、CPU等。如果没有出现问题或 CPU 没有过热,那就没问题 :)

  • 也许你可以获得一个线程池,或通过添加一些代码来自定义(扩展)正在使用的线程池,以便每次从线程池获取 Thread 时都设置适当的 InheritableThreadLocals。 每个Thread都具有以下包私有属性:

    /* ThreadLocal values pertaining to this thread. This map is maintained
     * by the ThreadLocal class. */
    ThreadLocal.ThreadLocalMap threadLocals = null;
    
    /*
     * InheritableThreadLocal values pertaining to this thread. This map is
     * maintained by the InheritableThreadLocal class.  
     */ 
    ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;
    

    你可以利用这些变量(当然需要反射机制)与 Thread.currentThread() 结合使用,以获得所需的行为。然而,这有点临时抱佛脚,并且我不能确定它(带有反射)是否会比只创建线程更加耗费资源。


线程本地变量的转录是我考虑过的事情。然而,在我的特定情况下,我正在使用Spring 3中的@Async,它将Callable的机制与业务逻辑分离。这非常酷,但意味着您无法访问执行器本身或创建的任务。 - skaffman
1
你有没有检查过Spring是否有一些可插拔的机制来替换执行器的实现?如果没有,那么为了进一步进行修改,你可以尝试创建一个与最终放置自定义代码的类具有相同限定名的类,并让它被加载代替原始类。但这是最后的手段。 - Bozho
嗯,是的,Spring确实允许您指定用于@Async的执行程序,因此,在那里传递线程本地变量是有方法的,尽管如您所说,这仍然会变得非常丑陋。 - skaffman

0

我在思考,如果用户请求的典型生命周期只有一秒钟,是否有必要在每个用户请求上生成新线程。您能否使用某种通知/等待队列,在其中生成给定数量的(守护)线程,它们都等待,直到有任务需要解决。如果任务队列变长,则生成额外的线程,但不是1-1比例。这样做与生成数百个寿命如此短暂的新线程相比,大概率会更好。


1
你所描述的是线程池,这已经在问题中描述过了。 - skaffman
如果每个请求线程充当线程池,我想我不明白为什么你不能有一个private ThreadLocal<T> local;,每次请求线程唤醒时实例化它,并在处理每个工作线程时使用local.set() / local.get(),但很可能我误解了你的问题。 - Terje

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接