FunTester 【连载 03】Java 线程池(上)

FunTester · 2024年12月02日 · 最后由 FunTester 回复于 2024年12月03日 · 1613 次阅读

1.3 Java 线程池

Java 线程池(Thread Pool)是一种线程的使用模式,是一种 Java 并发编程机制。Java 线程池能够有效地管理线程,通过线程复用提升使用效率。当我们使用的线程一旦变多,特别在进行高性能测试时,线程池就是我们唯一的选择。

使用线程池在以下几个方面有着巨大优势:

  • (1)线程创建和销毁:创建和销毁是非常昂贵的操作,线程池通过复用已经创建的线程,减少线程的创建和销毁的次数,提升了 Java 程序的性能,减少资源消耗。
  • (2)线程管理:使用线程池可以处理不同的负载的任务,需要一种灵活且可靠的线程管理策略。首先我们需要把线程总数限制在一个合理的范围内,其次要根据负载搞低、任务特性,及时增加或减少使用的线程,并且能够及时回收不用的线程。线程池提供线程管理策略的模板,用以满足不同的需求场景。
  • (3)提升可维护性:使用线程池可以将线程创建和管理细节隐藏,只暴露提交任务的 API,是代码更加便于维护。让测试工程师更加关注用例的细节,而不用过多关注多线程实现的细节。

Java 线程池提供了一种高效的方式实现多线程编程,解决了多线程使用中的各种问题,从而让性能测试更加稳定、可靠。通过使用线程池,性能测试人员可以更加利用多核机器 CPU 性能,编写更加高性能且安全稳定的测试用例。

1.3.1 Executors 创建线程池

java.util.concurrent.Executors 是 Java 标准库中非常重要的工具类,经常会用来创建几种常用类型的线程池。它提供了简单的创建方法,对于刚刚入门 Java 多线程编程的读者来说,通过 java.util.concurrent.Executors 提供的模板创建线程池是非常合适的选择。

在性能测试中,我们通常会用到 2 类 Java 线程池:固定线程线程池(Fixed Thread Pool)和缓存线程池(Cached Thread Pool)。

下面让我们来了解这两种线程池使用以及简单应用。

1.固定线程线程池

固定线程线程池调用创建的方法如下:

public static ExecutorService newFixedThreadPool(int nThreads) {

        return new ThreadPoolExecutor(nThreads, nThreads,

                                      0L, TimeUnit.MILLISECONDS,

                                      new LinkedBlockingQueue<Runnable>());

    }

根据 Java doc 描述:改方法创建一个线程池,拥有固定的线程数、无边界(实际为 integer 最大值)共享任务队列。改线程池限制了最大运行的线程数,如果某个线程运行过程中因为异常导致终止,当有新的任务需要执行时,会有创建新的线程。关闭该线程池需要调用java.util.concurrent.ExecutorService#shutdown方法。

这个方法只有一个 int 参数 nThread,也就是线程池线程数。如果我们想使用该方法创建线程池,只需要考虑使用场景到底需要多少线程即可。使用方法如下:

ExecutorService executorService = Executors.newFixedThreadPool(3);// 创建线程数为3的线程池

然后就是往线程池中提交任务,java.util.concurrent.ExecutorService 提供了 2 个方法用于往线程池提交任务。其中性能测试中最常用的是 java.util.concurrent.Executor#execute,参数类型 java.lang.Runnable。还有 java.util.concurrent.ExecutorService#submit() 方法,有三个重载方法,在性能测试中极少用到,通常自动化测试项目用的比较多,本章不再展开讲解。

固定线程线程池演示代码如下:


package org.funtester.performance.books.chapter01.section3;

import java.util.concurrent.ExecutorService;

import java.util.concurrent.Executors;

/**

 * 使用{@link ExecutorService}实现Java 固定线程线程池

 */

public class FixedThreadPoolDemo {

    public static void main(String[] args) {

        ExecutorService executorService = Executors.newFixedThreadPool(3);// 创建线程数为3的线程池

        for (int i = 0; i < 4; i++) {// 循环4次

            executorService.execute(new Runnable() {// 提交任务

                @Override

                public void run() {

                    try {

                        Thread.sleep(100);// 睡眠100毫秒

                    } catch (InterruptedException e) {

                        e.printStackTrace();

                    }

                    System.out.println(System.currentTimeMillis() + "  Hello FunTester!  " + Thread.currentThread().getName());// 打印当前时间、线程名称

                }

            });

        }

        executorService.shutdown();// 关闭线程池

    }

}

控制台输出:

1695555879091  Hello FunTester!  pool-1-thread-3

1695555879091  Hello FunTester!  pool-1-thread-1

1695555879091  Hello FunTester!  pool-1-thread-2

1695555879194  Hello FunTester!  pool-1-thread-3

进程已结束,退出代码为 0。

当然我们还可以使用 Lambda 语法使代码更加简洁:


            executorService.execute(() -> {

                try {

                    Thread.sleep(100);// 睡眠100毫秒

                } catch (InterruptedException e) {

                    e.printStackTrace();

                }

                System.out.println(System.currentTimeMillis() + "  Hello FunTester!  " + Thread.currentThread().getName());//

            });

我们看到前 3 个信息是在同一时间打印的,且线程均不相同。第四条信息打印时间戳比前三条多了 103ms,这与代码中 Thread.sleep(100);休眠的 100 毫秒是基本一致的。这里显示我们创建的线程池中 3 个线程,当在执行任务的前 100 毫秒在处理前 3 个任务,等到某个任务结束后,再执行第 4 个任务。

我们可以得出一个结论:当固定线程线程池的线程都在处理别的任务(繁忙状态),剩余的任务实在等待执行的过程中,实际上任务是再等待队列中。在实际使用场景中,限制线程池中最大的线程数会导致线程池有一个处理任务的上限。

固定线程线程池适用于具有固定数量、需要严格控制最大线程数的并发执行场景。固定线程线程池能够提供稳定的线程数量,使任务执行的速率更加均匀。在使用过程中,需要注意任务队列中的等待数量,防止超量积压导致任务从提交到最终执行延迟过大。

在性能测试中,通常使用固定线程线程池来执行线程模型中固定线程、需要最大线程数限制的动态线程场景。

2.缓存线程池

首先我们观察缓存线程池的创建方法:


public static ExecutorService newCachedThreadPool() {

        return new ThreadPoolExecutor(0, Integer.MAX_VALUE,

                                      60L, TimeUnit.SECONDS,

                                      new SynchronousQueue<Runnable>());

}

根据 Java doc 的描述:改线程池根据需要创建线程,但是会复用已经创建好的线程,适合执行大量的短期的任务。如果线程空闲超过设置 60 秒,则会被回收,若是长时间未使用,该线程池会回收所有线程资源。

这是一个无参的方法,但不意味着我们可以无限创建线程,该方法创建的缓存线程池最大可以创建java.lang.Integer#MAX_VALUE个线程,但在实际的使用中,优先于系统限制和资源限制,能够创建的线程数远低于这个值。

在线程池的使用方面,缓存线程池是完全跟固定线程线程池一样的,有两种提交任务的方法,这里我们展示 java.util.concurrent.Executor#execute 方法,下面是演示 Demo:


package org.funtester.performance.books.chapter01.section3;

import java.util.concurrent.ExecutorService;

import java.util.concurrent.Executors;

/**

 * 使用{@link ExecutorService}实现Java 缓存线程池

 */

public class CachedThreadPoolDemo {

    public static void main(String[] args) {

        ExecutorService executorService = Executors.newCachedThreadPool();// 创建一个缓存线程池

        for (int i = 0; i < 8; i++) {// 循环8次

            // 提交任务

            executorService.execute(() -> {

                try {

                    Thread.sleep(100);// 睡眠100毫秒

                } catch (InterruptedException e) {

                    e.printStackTrace();

                }

                System.out.println(System.currentTimeMillis() + "  Hello FunTester!  " + Thread.currentThread().getName());// 输出当前时间和线程名

            });

        }

        executorService.shutdown();// 关闭线程池

    }

}

下面是控制台输出:

1696727545116  Hello FunTester!  pool-1-thread-6

1696727545116  Hello FunTester!  pool-1-thread-1

1696727545116  Hello FunTester!  pool-1-thread-3

1696727545116  Hello FunTester!  pool-1-thread-2

1696727545116  Hello FunTester!  pool-1-thread-7

1696727545116  Hello FunTester!  pool-1-thread-4

1696727545116  Hello FunTester!  pool-1-thread-8

1696727545116  Hello FunTester!  pool-1-thread-5

进程已结束,退出代码为 0。

使用 Lambda 语法简化代码同固定线程线程池,这里不再展示。

可以看出,我们提交了 8 个任务到线程池,几乎在同一时刻(时间戳相同)任务均被执行,且执行的线程都不一样。缓存线程池总计创建了 8 个线程,分别执行不同的任务。

在下面例子中,我们设计了每个线程任务在提交之前均休眠不同的时间,这样可以很清晰展示缓存线程池线程复用的效果。演示代码如下:

package org.funtester.performance.books.chapter01.section3;

import java.util.concurrent.ExecutorService;

import java.util.concurrent.Executors;

/**

 * 使用{@link ExecutorService}实现Java 缓存线程池

 */

public class CachedThreadPoolReuseDemo {

    public static void main(String[] args) {

        ExecutorService executorService = Executors.newCachedThreadPool();// 创建一个缓存线程池

        for (int i = 0; i < 8; i++) {//

            try {

                Thread.sleep(i * 100);// 睡眠i*100毫秒

            } catch (InterruptedException e) {

                throw new RuntimeException(e);

            }

            // 提交任务

            executorService.execute(() -> {

                try {

                    Thread.sleep(100);// 睡眠100毫秒

                } catch (InterruptedException e) {

                    e.printStackTrace();

                }

                System.out.println(System.currentTimeMillis() + "  Hello FunTester!  " + Thread.currentThread().getName());// 输出当前时间和线程名

            });

        }

        executorService.shutdown();// 关闭线程池

    }

}

控制台输出:

1696728595770  Hello FunTester!  pool-1-thread-1

1696728595873  Hello FunTester!  pool-1-thread-2

1696728596074  Hello FunTester!  pool-1-thread-2

1696728596376  Hello FunTester!  pool-1-thread-2

1696728596777  Hello FunTester!  pool-1-thread-2

1696728597281  Hello FunTester!  pool-1-thread-2

1696728597880  Hello FunTester!  pool-1-thread-2

1696728598580  Hello FunTester!  pool-1-thread-2

进程已结束,退出代码为 0

可以看到缓存线程池总计创建了 2 个线程用来执行 8 个任务,因为后来的任务到达时,前一个任务已经执行结束,线程已经空闲下来,可以执行新的任务。

接下来通过下面的例子演示缓存线程池使用中,当线程被异常终止时,线程池如何处理。演示代码如下:


package org.funtester.performance.books.chapter01.section3;

import java.util.concurrent.ExecutorService;

import java.util.concurrent.Executors;

/**

 * 使用{@link ExecutorService}实现Java 缓存线程池

 */

public class CachedThreadPoolAbortDemo {

    public static void main(String[] args) {

        ExecutorService executorService = Executors.newCachedThreadPool();// 创建一个缓存线程池

        for (int i = 0; i < 2; i++) {//

            try {

                Thread.sleep(i * 150);// 睡眠i*100毫秒

            } catch (InterruptedException e) {

                throw new RuntimeException(e);

            }

            // 提交任务

            executorService.execute(() -> {

                try {

                    Thread.sleep(100);// 睡眠100毫秒

                } catch (InterruptedException e) {

                    e.printStackTrace();

                }

                System.out.println(System.currentTimeMillis() + "  Hello FunTester!  " + Thread.currentThread().getName());// 输出当前时间和线程名

                throw new RuntimeException("线程异常");

            });

        }

        executorService.shutdown();// 关闭线程池

    }

}

控制台输出:

1696729397014  Hello FunTester!  pool-1-thread-1

Exception in thread "pool-1-thread-1" java.lang.RuntimeException: 线程异常

       at org.funtester.performance.books.chapter01.section3.CachedThreadPoolAbortDemo.lambda$main$0(CachedThreadPoolAbortDemo.java:27)

       at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)

       at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)

       at java.lang.Thread.run(Thread.java:748)

1696729397165  Hello FunTester!  pool-1-thread-2

Exception in thread "pool-1-thread-2" java.lang.RuntimeException: 线程异常

       at org.funtester.performance.books.chapter01.section3.CachedThreadPoolAbortDemo.lambda$main$0(CachedThreadPoolAbortDemo.java:27)

       at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)

       at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)

       at java.lang.Thread.run(Thread.java:748)

进程已结束,退出代码为 0。

我们先忽略这两个报错信息,可以看到线程池总计创建了 2 个线程去执行 2 个任务。根据我们之前的所学内容,如果不报错的话,只需要创建 1 个线程即可以执行这 2 个任务。这也对应了 Java doc 描中,当线程池终的线程被终止时,若仍需要线程,就会创建新的线程执行任务。这个逻辑不仅使用缓存线程池,也适用于固定线程线程池,包括 1.4 节的自定义线程池。

FunTester 原创精华
如果觉得我的文章对您有用,请随意打赏。您的支持将鼓励我继续创作!
共收到 2 条回复 时间 点赞

“创建和销毁是非常昂贵的操作”,建议把创建和销毁线程具体哪里昂贵了说一下,可以从操作系统角度深入展开,比如操作系统线程,内存,调度等成本,希望看到您的深入分析。

小狄子 回复

收到。这块是我的盲区。从概念上有一些了解。

需要 登录 后方可回复, 如果你还没有账号请点击这里 注册