创建线程为什么被称为代价高昂的操作?

203
Java教程中提到创建线程是昂贵的,但为什么会这样呢?在创建Java线程时发生了什么使得其成本高昂?虽然我相信这个说法是正确的,但我对JVM中线程创建机制很感兴趣。

线程生命周期开销。线程创建和销毁并不是免费的。实际开销因平台而异,但是线程创建需要时间,会引入请求处理的延迟,并且需要JVM和操作系统进行一些处理活动。如果请求频繁且轻量级(如大多数服务器应用程序),为每个请求创建一个新线程可能会消耗大量计算资源。

《Java并发编程实践》
作者:Brian Goetz、Tim Peierls、Joshua Bloch、Joseph Bowbeer、David Holmes、Doug Lea
印刷版 ISBN-10: 0-321-34960-1

我不知道你所阅读的教程中的上下文是什么:它们是否暗示创建本身很昂贵,还是说“创建线程”很昂贵。我试图展示的区别在于制作线程的纯动作(让我们称其为实例化或其他什么),或者你拥有一个线程的事实(因此使用线程:显然具有开销)。哪一个被声称//你想问哪一个? - Nanne
9
相比于不创建新线程,这是一种昂贵的方法。 - willcodejavaforfood
可能是Java线程创建开销的重复问题。 - Paul Draper
3
线程池大获成功。执行任务时不需要总是创建新线程。 - Alexander Mills
另外,Java通过“Project Loom”引入的虚拟线程功能(也称为纤程)并不昂贵。Loom将许多虚拟线程映射到一个实际的平台/主机线程,以极大地提高在线程经常阻塞的情况下的性能。有关更多信息,请参阅Oracle的Ron Pressler的最新演示和采访。现在可以提前访问启用了Loom的JVM。 - Basil Bourque
6个回答

173
为什么创建一个线程被认为是昂贵的?
因为它确实是昂贵的。
Java线程的创建是昂贵的,因为涉及到大量的工作:
- 必须为线程堆栈分配和初始化一大块内存。 - 需要通过系统调用来创建/注册与主机操作系统相关的本地线程。 - 需要创建、初始化并将描述符添加到JVM内部数据结构中。
从另一个角度来看,线程的存在会占用资源,例如线程堆栈、堆栈中可访问的任何对象、JVM线程描述符以及操作系统本地线程描述符。
这些成本在不同平台上可能有所不同,但在我接触过的任何Java平台上都不便宜。
一次谷歌搜索给我找到了一个旧的基准测试,它报告了在2002年的双处理器Xeon上运行2002年版Linux的Sun Java 1.4.1上每秒大约创建4000个线程的速率。更现代化的平台将会有更好的数据...至于方法论,我不能发表评论...但至少它给出了一个关于线程创建的开销可能有多大的大致范围。
Peter Lawrey的基准测试表明,从绝对意义上讲,如今线程创建速度显著提高,但目前尚不清楚这其中有多少是由于Java和/或操作系统的改进...或者更高的处理器速度所致。但他的数据仍然表明,如果使用线程池而不是每次都创建/启动新线程,性能将提高150倍以上。(他还指出这只是相对的...)
上述假设使用本地线程而不是绿色线程,但现代JVM出于性能原因都使用本地线程。绿色线程可能更便宜创建,但在其他方面付出了代价。
更新:OpenJDK的Loom项目旨在提供标准Java线程的轻量级替代方案,以及其他功能。他们提出了“虚拟线程”,它是本地线程和绿色线程的混合体。简单来说,虚拟线程就像是一个绿色线程实现,在需要并行执行时使用本地线程作为底层支持。
截至目前(2023年7月),Loom项目已成为JEP 444。自Java 19预览版以来,该项目一直处于预览状态,并计划在Java 21中正式发布。

我进行了一些调查,以了解Java线程的堆栈是如何分配的。在Linux上的OpenJDK 6中,线程堆栈是通过创建本地线程的pthread_create调用来分配的。(JVM不会传递预先分配的堆栈给pthread_create。)

然后,在pthread_create内部,堆栈通过以下调用mmap进行分配:

mmap(0, attr.__stacksize, 
     PROT_READ|PROT_WRITE|PROT_EXEC, 
     MAP_PRIVATE|MAP_ANONYMOUS, -1, 0)

根据man mmapMAP_ANONYMOUS标志会导致内存被初始化为零。
因此,即使根据JVM规范来说,新的Java线程堆栈不一定需要被清零,但实际上(至少在Linux上使用OpenJDK 6时),它们是被清零的。

2
@Raedwald - 昂贵的是初始化部分。在某个地方,某些东西(例如GC或OS)将在块转换为线程堆栈之前将字节清零。这需要典型硬件上的物理内存周期。 - Stephen C
3
"在某个地方,某些东西(例如GC或操作系统)将会将这些字节清零。是的,如果需要分配新的内存页并且出于安全原因,操作系统将会清零这些字节。但这种情况比较少见。而操作系统可能会维护一个已经清零的页面缓存(我记得Linux就是这么做的)。鉴于JVM会防止任何Java程序读取其内容,为什么GC要费心去清零呢?请注意,标准C函数'malloc()'不保证分配的内存被清零(可能是为了避免性能问题)。" - Raedwald
1
同意“一个主要因素是为每个线程分配的堆栈内存”。 - Raedwald
2
@Raedwald - 请查看更新的答案,了解堆栈实际分配的信息。 - Stephen C
5
mmap() 调用分配的内存页很可能(甚至是可能性更大)被写时复制映射到一个零页面,因此它们的初始化不是在 mmap() 内部发生,而是当页面首次被写入时,每次只有一页被初始化。也就是说,当线程开始执行时,这个代价由创建的线程承担而非创建线程。 - Raedwald
显示剩余5条评论

91

其他人已经讨论了线程成本的来源。这个答案涵盖了为什么创建一个线程不像许多操作那样昂贵,但与执行任务的替代方案相比,它是相对昂贵的。

在另一个线程中运行任务的最明显的替代方案是在同一线程中运行任务。对于那些认为越多线程总是更好的人来说,这很难理解。逻辑是如果将任务添加到另一个线程的开销大于节省的时间,则在当前线程中执行任务可能更快。

另一个选择是使用线程池。线程池可以更有效率,原因如下:1)复用已创建的线程。2)您可以调整/控制线程数以确保获得最佳性能。

以下程序打印....

Time for a task to complete in a new Thread 71.3 us
Time for a task to complete in a thread pool 0.39 us
Time for a task to complete in the same thread 0.08 us
Time for a task to complete in a new Thread 65.4 us
Time for a task to complete in a thread pool 0.37 us
Time for a task to complete in the same thread 0.08 us
Time for a task to complete in a new Thread 61.4 us
Time for a task to complete in a thread pool 0.38 us
Time for a task to complete in the same thread 0.08 us

这是一个测试微不足道的任务,它展示了每个线程选项的开销。(实际上,在当前线程中执行此类任务最佳。)

final BlockingQueue<Integer> queue = new LinkedBlockingQueue<Integer>();
Runnable task = new Runnable() {
    @Override
    public void run() {
        queue.add(1);
    }
};

for (int t = 0; t < 3; t++) {
    {
        long start = System.nanoTime();
        int runs = 20000;
        for (int i = 0; i < runs; i++)
            new Thread(task).start();
        for (int i = 0; i < runs; i++)
            queue.take();
        long time = System.nanoTime() - start;
        System.out.printf("Time for a task to complete in a new Thread %.1f us%n", time / runs / 1000.0);
    }
    {
        int threads = Runtime.getRuntime().availableProcessors();
        ExecutorService es = Executors.newFixedThreadPool(threads);
        long start = System.nanoTime();
        int runs = 200000;
        for (int i = 0; i < runs; i++)
            es.execute(task);
        for (int i = 0; i < runs; i++)
            queue.take();
        long time = System.nanoTime() - start;
        System.out.printf("Time for a task to complete in a thread pool %.2f us%n", time / runs / 1000.0);
        es.shutdown();
    }
    {
        long start = System.nanoTime();
        int runs = 200000;
        for (int i = 0; i < runs; i++)
            task.run();
        for (int i = 0; i < runs; i++)
            queue.take();
        long time = System.nanoTime() - start;
        System.out.printf("Time for a task to complete in the same thread %.2f us%n", time / runs / 1000.0);
    }
}
}

如您所见,创建一个新线程只需大约70微秒。在许多情况下,这可能被视为琐事。相对而言,它比其他选择更昂贵,在某些情况下,线程池或根本不使用线程是更好的解决方案。

9
那是一段很棒的代码,简洁明了,重点突出,清晰地展现了它的要点。 - Nicholas
在最后一个块中,我认为结果是有偏差的,因为在前两个块中,主线程正在并行删除,而工作线程正在放置。然而,在最后一个块中,取操作全部是串行执行的,因此它正在扩大值。您可以使用queue.clear()并使用CountDownLatch来等待线程完成。 - Victor Grazi
@VictorGrazi 我假设您想集中收集结果。 在每种情况下都要执行相同数量的排队工作。 CountDownLatch 会略微快一些。 - Peter Lawrey
其实,为什么不让它做一些一直快速的事情,比如递增一个计数器;放弃整个BlockingQueue的东西。在最后检查计数器,以防止编译器优化掉递增操作。 - Victor Grazi
@grazi 在这种情况下你可以那样做,但在大多数实际情况下等待计数器可能是低效的。如果你这样做,两个例子之间的差距将会更大。 - Peter Lawrey
我认为这是一个不好的例子。更糟糕的性能很有可能与争用阻塞队列有关,而与线程创建成本/开销无关。 - dagnelies

33

理论上,这取决于JVM。实际上,每个线程都有相对较大的堆栈内存(默认为256 KB)。此外,线程是作为操作系统线程实现的,因此创建它们涉及到一个操作系统调用,即上下文切换。

请注意,在计算机中,“昂贵”是非常相对的概念。相对于大多数对象的创建而言,线程创建非常昂贵,但相对于随机硬盘寻道来说就不是很昂贵了。您不必一定要避免创建线程,但每秒创建数百个线程并不是一个明智的选择。在大多数情况下,如果你的设计需要大量线程,你应该使用有限大小的线程池。


9
顺便提一下,kb代表千位,kB代表千字节。Gb代表十亿位,GB代表十亿字节。 - Peter Lawrey
@PeterLawrey,我们在“kb”和“kB”中是否要大写“k”,以便与“Gb”和“GB”对称?这些事情让我感到困扰。 - Jack
3
@Jack,这里有一个概念:K等于1024,而k等于1000。;) 参考维基百科上的“Kibibyte”词条。 - Peter Lawrey

11

有两种类型的线程:

  1. 真正的线程:这是对底层操作系统线程设施的抽象。因此,线程的创建费用与系统的费用一样昂贵,存在开销。

  2. “绿色”线程:由JVM创建和调度,这些线程更便宜,但不会出现真正的并行性。它们像线程一样运行,但在OS中的JVM线程中执行。据我所知,它们并不经常被使用。

我能想到的影响线程创建开销的最大因素是您为线程定义的堆栈大小。当运行VM时,可以将线程堆栈大小作为参数传递。

除此之外,线程创建主要取决于操作系统,并且甚至取决于VM实现。

现在,让我指出一些事情:如果您计划每秒启动2000个线程,持续运行,那么创建线程是很昂贵的。JVM没有设计来处理这样的情况。如果您只需要一些稳定的工作线程,它们不会一遍又一遍地启动和关闭,请放心使用。


22
“…一些稳定的工人,他们不会被解雇和杀害…” 为什么我开始思考工作场所的条件? :-) - Stephen C

6

创建Threads需要分配相当数量的内存,因为它不仅需要为Java代码,还需要为本地代码(native code)创建两个新的堆栈。通过使用Executors/线程池,可以避免这种开销,通过将线程重用于多个任务而不是为每个任务都创建一个新线程来实现Executor


@Raedwald,使用单独堆栈的JVM是什么? - bestsss
据我所知,所有的JVM都为每个线程分配了两个堆栈。这对于垃圾回收有所帮助,即使是JIT编译后的Java代码,也会与free-casting c不同。 - Philip JF
@Philip JF,您能否详细说明一下?您所说的两个堆栈是指Java代码和本地代码吗?它们有什么作用? - Gurinder
据我所知,所有的JVM都会为每个线程分配两个堆栈。但我从未见过任何支持这一说法的证据。也许你误解了JVM规范中opstack的真正本质。(它是一种模拟字节码行为的方式,而不是需要在运行时执行它们的东西。) - Stephen C

2

显然问题的关键在于“昂贵”的含义是什么。

一个线程需要创建一个堆栈并基于运行方法初始化该堆栈。

它需要设置控制状态结构,即,它所处的状态(可运行、等待等)。

在设置这些内容时可能需要进行大量的同步。


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