JAVA高并发——线程池


多线程的软件设计方法确实可以最大限度地发挥现代多核处理器的计算能力,提高生产系统的吞吐量和性能。但是,若不加控制和管理,随意使用线程,则反而会对系统的性能产生不利的影响。

一种最简单的线程创建和回收的方法类似如下代码:

        new Thread(() -> {
            
        }).start();

以上代码创建了一个线程,并在run()方法结束后自动回收该线程。在简单的应用系统中,这段代码并没有太多问题,但是在真实的生产环境中,系统由于真实环境的需要,可能会开启很多线程来支撑其应用。当线程数量过多时,反而会耗尽CPU和内存资源。

首先,虽然与进程相比,线程是一种轻量级的工具,但其创建和关闭依然需要花费时间。如果为每一个小任务都创建一个线程,则很有可能出现创建和销毁线程所占用的时间大于该线程真实工作所消耗的时间的情况,反而会得不偿失。

其次,线程本身也是要占用内存空间的,大量的线程会抢占宝贵的内存资源,如果处理不当,则可能会导致Out of Memory异常。即便没有出现异常,大量的线程回收也会给GC带来很大的压力,延长GC的停顿时间。

因此,对线程的使用必须掌握一个度,在有限的范围内增加线程的数量可以明显提高系统的吞吐量,一旦超出了这个范围,大量的线程只会拖垮应用系统。因此,在生产环境中使用线程必须对其加以控制和管理。

**注意:**在实际生产环境中,线程的数量必须得到控制。盲目创建大量线程对系统性能是有伤害的。

1、什么是线程池

为了避免系统频繁地创建和销毁线程,我们可以让创建的线程复用。如果大家进行过数据库开发,那么对数据库连接池应该不会感到陌生。为了避免每次数据库查询都重新建立和销毁数据库连接,我们可以使用数据库连接池维护一些数据库连接,让它们长期保持激活状态。当系统需要使用数据库时,并不是创建一个新的连接,而是从连接池中获得一个可用的连接。反之,当需要关闭连接时,并不会真的把连接关闭,而是将这个连接“还”给连接池。这种方式可以节约不少创建和销毁对象的时间。

线程池也是类似的概念。在线程池中,总有几个活跃线程。当你需要使用线程时,可以从池子中随便拿一个空闲线程,当完成工作时,不要急着关闭线程,而是将这个线程退回到线程池中,方便其他人使用。

简而言之,在使用线程池后,创建线程变成了从线程池获得空闲线程,关闭线程变成了向线程池归还线程,如下图所示:
在这里插入图片描述

2、不要重复发明轮子:JDK对线程池的支持

为了能够更好地控制多线程,JDK提供了一套Executor框架,帮助开发人员有效地进行线程控制。其本质就是一个线程池,框架结构图如下图所示:
在这里插入图片描述
图中的核心成员均位于java.util.concurrent包中,是JDK并发包的核心类。其中,ThreadPoolExecutor表示一个线程池。Executors类则扮演着线程池工厂的角色,通过Executors可以取得一个拥有特定功能的线程池。从上图中亦可知,ThreadPoolExecutor类实现了Executor接口,因此通过这个接口,任何Runnable的对象都可以被ThreadPoolExecutor线程池调度。

Executor框架提供了各种类型的线程池,主要包含以下工厂方法:
在这里插入图片描述
以上工厂方法分别返回具有不同工作特性的线程池。这些线程池工厂方法的具体说明如下:

  • newFixedThreadPool()方法:该方法返回一个固定线程数量的线程池。该线程池中的线程数量始终不变,当有一个新的任务提交时,线程池中若有空闲线程,则立即执行。若没有,则新的任务会被暂存在一个任务队列中,待有线程空闲时,便处理任务队列中的任务。
  • newSingleThreadExecutor()方法:该方法返回一个只有一个线程的线程池。若多于一个的任务被提交到该线程池,则任务会被保存在一个任务队列中,待线程空闲,按先入先出的顺序执行队列中的任务。
  • newCachedThreadPool()方法:该方法返回一个可根据实际情况调整线程数量的线程池。线程池的线程数量不确定,但若有空闲线程可以复用,则会优先使用可复用的线程。若所有线程均在工作,这时又有新的任务提交,则会创建新的线程处理任务。所有线程在当前任务执行完毕后,将返回线程池,以便复用。
  • newSingleThreadScheduledExecutor()方法:该方法返回一个ScheduledExecutorService对象,线程池大小为1。ScheduledExecutorService接口在ExecutorService接口之上扩展了在给定时间内执行某任务的功能,如在某个固定的延时之后执行,或者周期性执行某个任务。
  • newScheduledThreadPool()方法:该方法也返回一个ScheduledExecutorService对象,但该线程池可以指定线程数量。

2.1、固定大小的线程池

这里,我们以newFixedThreadPool()方法为例,简单地展示线程池的使用方法:

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

/**
 * @title ThreadPoolDemo
 * @description 线程池
 * @author: yangyongbing
 * @date: 2024/2/19 9:00
 */
public class ThreadPoolDemo {

    public static class MyTask implements Runnable {
        @Override
        public void run() {
            System.out.println(System.currentTimeMillis() + ":ThreadID:" + Thread.currentThread().getId());
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    public static void main(String[] args) {
        MyTask myTask = new MyTask();
        ExecutorService executorService = Executors.newFixedThreadPool(5);
        for (int i = 0; i < 10; i++) {
            executorService.submit(myTask);
        }
    }

}

在这里插入图片描述
上述代码中,第17行创建了固定大小的线程池,内有5个线程。在第19行,依次向线程池提交了10个任务。此后,线程池就会安排调度这10个任务。每个任务都会将自己的时间戳和执行这个线程的ID打印出来,这里安排每个任务要执行1秒。

执行上述代码,可以得到类似如下输出:
在这里插入图片描述

这个输出表示这10个线程的执行情况。很显然,前5个任务和后5个任务的执行时间正好相差1秒(注意时间戳的单位是毫秒),并且前5个任务的线程ID和后5个任务的线程ID也是完全一致的(都是10、11、12、13、14)。这说明这10个任务是分成两个批次执行的。这也完全符合一个只有5个线程的线程池的行为。

有兴趣的可以将其改造成newCachedThreadPool()方法,看看任务的分配情况会有何变化。

2.2、计划任务

另一个值得注意的方法是newScheduledThreadPool()方法。它返回一个Scheduled-ExecutorService对象,可以根据时间需要调度线程。它的一些主要方法如下:
在这里插入图片描述
与其他几个线程池不同,ScheduledExecutorService并不一定会立即安排执行任务。它其实起到了计划任务的作用。它会在指定的时间对任务进行调度。如果大家使用过Linux下的crontab工具,应该就能很容易地理解它。

作为说明,这里给出了3个方法。schedule()方法会在给定时间对任务进行一次调度。scheduleAtFixedRate()方法和scheduleWithFixedDelay()方法会对任务进行周期性的调度,但是两者有一点小小的区别。FixedRate和FixedDelay的区别如下图所示:
在这里插入图片描述
由于担心我的解释不够周全,下面我将官方文档中的描述贴出来供大家参考,以便大家更准确地理解两者的差别。

  • scheduleAtFixedRate()方法:Creates and executes a periodic action that becomes enabled first after the given initial delay,and subsequently with the given period;that is executions will commence after initialDelay then initialDelay+period,then initialDelay+2period,and so on. 翻译:创建并执行一个周期性任务。任务开始于给定的初始延时时间,后续的任务按照给定的周期进行:后续第一个任务将会在initialDelay+period时执行,后续第二个任务将在initialDelay+2period时执行,以此类推。
  • scheduleWithFixedDelay()方法:Creates and executes a periodic action that becomes enabled first after the given initial delay,and subsequently with the given delay between the termination of one execution and the commencement of the next. 翻译:创建并执行一个周期性任务。任务开始于初始延时时间,后续任务将会按照给定的延时进行:即上一个任务的结束时间到下一个任务的开始时间的时间差。

下面的例子使用scheduleAtFixedRate()方法调度一个任务。这个任务会执行1秒,调度周期是2秒。也就是说,每2秒任务就会被执行一次:

import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;

/**
 * @title ScheduledExecutorServiceDemo
 * @description 计划任务
 * @author: yangyongbing
 * @date: 2024/2/19 11:58
 */
public class ScheduledExecutorServiceDemo {
    public static void main(String[] args) {
        ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(10);
        scheduledExecutorService.scheduleAtFixedRate(() -> {
            try {
                Thread.sleep(1000);
                System.out.println(System.currentTimeMillis()/1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        },0,2,TimeUnit.SECONDS);
    }
}

在这里插入图片描述
执行上述代码,输出可能如下:
在这里插入图片描述
上述输出的单位是秒。可以看到,时间间隔是2秒。

这里我还想说一件有意思的事情,如果任务的执行时间超过调度时间会发生什么呢?比如,这里调度周期是2秒,如果任务的执行时间是8秒,那么会不会出现多个任务堆叠在一起的情况呢?

实际上,ScheduledExecutorService不会让任务堆叠出现。我们将上述代码第9行改为:

Thread.sleep(8000);

再次执行上述代码,你就会发现任务的执行周期不再是2秒,而是变成了8秒。输出可能如下:
在这里插入图片描述
也就是说,如果周期太短,那么任务就会在上一个任务结束后立即被调用。可以想象,如果采用scheduleWithFixedDelay()方法,并且按照修眠8秒、调度周期2秒来算,那么任务的实际间隔将是10秒,大家可以自行尝试。

import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;

/**
 * @title ScheduledExecutorServiceDemo
 * @description 计划任务
 * @author: yangyongbing
 * @date: 2024/2/19 11:58
 */
public class ScheduledExecutorServiceDemo {
    public static void main(String[] args) {
        ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(10);
        scheduledExecutorService.scheduleWithFixedDelay(() -> {
            try {
                Thread.sleep(8000);
                System.out.println(System.currentTimeMillis()/1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        },0,2,TimeUnit.SECONDS);
    }
}

在这里插入图片描述
另一个值得注意的问题是,调度程序实际上并不保证任务会无限期地持续调用。如果任务本身抛出了异常,那么后续的所有执行都会被中断,因此,如果你想让你的任务持续稳定地执行,那么做好异常处理非常重要,否则你很有可能观察到调度程序无疾而终。

注意:如果任务遇到异常,那么后续的所有子任务都会停止调度,因此,必须保证异常被及时处理,为周期性任务的稳定调度提供条件。
举例,实现一下个异步任务监听器:

    /**
     * 监听异步任务
     *
     * @param asyncTask 异步任务
     * @param taskId    异步任务ID
     */
    private void listenAsyncTask(AtomicReference<AsyncTask> asyncTask, String taskId) {
        CountDownLatch countDownLatch = new CountDownLatch(1);
        ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(4);
        ScheduledFuture<?> scheduledFuture = scheduledExecutorService.scheduleAtFixedRate(() -> {
            try {
                asyncTask.set(asyncTaskManager.queryTask(taskId));
                if (asyncTask.get().getState() == 1 || asyncTask.get().getState() == -1) {
                    countDownLatch.countDown();
                }
            } catch (JsonProcessingException e) {
                logger.error(PUBLISH_SCHEME_SPI + "执行监听异步任务异常:" + e.getMessage());
                e.printStackTrace();
                countDownLatch.countDown();
            }
        }, 0, 1, TimeUnit.SECONDS);

        try {
            countDownLatch.await();
            scheduledFuture.cancel(true);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        scheduledExecutorService.shutdown();
    }

3、核心线程池的内部实现

对于核心的几个线程池,无论是newFixedThreadPool、newSingleThreadExecutor,还是newCachedThreadPool,虽然创建的线程看起来有着完全不同的功能特点,但其内部实现均使用了ThreadPoolExecutor类。下面给出了这3个线程池的实现方式:
在这里插入图片描述
由以上线程池的实现代码可以看到,它们都只是ThreadPoolExecutor类的封装。为何ThreadPoolExecutor类有如此强大的功能呢?来看一下ThreadPoolExecutor类最重要的构造函数:
在这里插入图片描述
函数参数的含义如下:

  • corePoolSize:指定了线程池中的常驻线程数量。
  • maximumPoolSize:指定了线程池允许的最大线程数量。
  • keepAliveTime:指当线程池中的线程数量超过corePoolSize时,多余的空闲线程的存活时间,即超过corePoolSize的空闲线程在多长时间内会被销毁。
  • unit:keepAliveTime的单位。
  • workQueue:任务队列。其中的任务是被提交但尚未被执行的任务。
  • threadFactory:线程工厂——用于创建线程,一般用默认的即可。
  • handler:拒绝策略。当任务太多来不及处理时,如何拒绝任务。

以上大部分参数都很简单,只有参数workQueue和handler需要详细说明。

参数workQueue指被提交但未执行的任务队列,它是一个BlockingQueue接口的对象,仅用于存放Runnable对象。根据队列功能分类,在ThreadPoolExecutor类的构造函数中可使用以下几种BlockingQueue接口:

  • 直接提交的队列:该功能由SynchronousQueue对象提供。SynchronousQueue是一个特殊的BlockingQueue。SynchronousQueue没有容量,每一个插入操作都要等待一个相应的删除操作,反之,每一个删除操作都要等待对应的插入操作。如果使用SynchronousQueue,则提交的任务不会被真实地保存,而总是将新任务提交给线程执行。如果没有空闲的线程,则尝试创建新的线程,如果线程数量已经达到最大值,则执行拒绝策略。因此,使用SynchronousQueue队列时,通常要设置很大的maximumPoolSize值,否则很容易执行拒绝策略。
  • 有界的任务队列:有界的任务队列可以使用ArrayBlockingQueue类实现。ArrayBlockingQueue类的构造函数必须带一个容量参数,表示该队列的最大容量:public ArrayBlockingQueue(int capacity);使用有界的任务队列时,当有新的任务到来,如果线程池的实际线程数小于corePoolSize,则会优先创建新的线程;如果大于corePoolSize,则会将新任务加入等待队列;若等待队列已满,无法加入,则在总线程数不大于maximumPoolSize的前提下,创建新的线程执行任务;若大于maximumPoolSize,则执行拒绝策略。可见,有界队列仅在任务队列装满时,才可能将线程数提升到corePoolSize以上,换言之,除非系统非常繁忙,否则要确保核心线程数维持在corePoolSize。
  • 无界的任务队列:无界的任务队列可以通过LinkedBlockingQueue类实现。与有界队列相比,除非系统资源耗尽,否则无界的任务队列不存在任务入队失败的情况。当有新的任务到来,系统的线程数小于corePoolSize时,线程池会生成新的线程执行任务。但当系统的线程数达到corePoolSize后,就不会继续增加了。若后续仍有新的任务加入,而又没有空闲的线程资源,则任务直接进入队列等待。若任务创建和处理的速度差异很大,无界队列就会保持快速增长,直到耗尽系统内存。
  • 优先任务队列:优先任务队列是任务带有执行优先级的队列。它通过PriorityBlockingQueue类实现,可以控制任务的执行顺序。它是一个特殊的无界队列。无论是有界队列ArrayBlockingQueue类,还是未指定大小的无界队列LinkedBlockingQueue类,都是按照先进先出算法处理任务的。而PriorityBlockingQueue类则可以根据任务自身的优先级顺序执行,在保证系统性能的同时,也能有很好的质量保证(总是确保高优先级的任务先执行)。

回顾newFixedThreadPool的实现,它返回了一个corePoolSize和maximumPoolSize相等的、使用了LinkedBlockingQueue任务队列的线程池。因为对于固定大小的线程池而言,不存在线程数量的动态变化,因此corePoolSize和maximumPoolSize可以相等。同时,它使用无界队列存放无法立即执行的任务,当任务提交非常频繁的时候,该队列可能迅速膨胀,进而耗尽系统资源。

newSingleThreadExecutor返回的单线程线程池是newFixedThreadPool的一种退化,只是简单地将线程池的线程数量设置为1。

newCachedThreadPool返回corePoolSize为0、maximumPoolSize为无穷大的线程池,这意味着在没有任务时,该线程池内无线程,而当任务被提交时,该线程池会使用空闲的线程执行任务。若无空闲线程,则将任务加入SynchronousQueue队列,而SynchronousQueue队列是一种直接提交的队列,它总会迫使线程池增加新的线程执行任务。当任务执行完毕后,由于corePoolSize为0,因此空闲线程又会在指定的时间内(60秒)被回收。

对于newCachedThreadPool,如果同时有大量任务被提交,而任务执行得又不那么快时,那么系统便会开启等量的线程处理,这样做可能会很快耗尽系统的资源。

注意:使用自定义线程池时,要根据应用的具体情况,选择合适的并发队列为任务做缓冲。当线程资源紧张时,不同的并发队列对系统行为和性能的影响均不同。

这里给出ThreadPoolExecutor线程池的核心调度代码,这段代码充分体现了上述线程池的工作逻辑:
在这里插入图片描述
上述代码第5行的workerCountOf()方法取得了当前线程池的线程总数。当线程总数小于corePoolSize时,会将任务通过addWorker()方法直接调度执行。否则,在第10行代码处(workQueue.offer()方法)进入等待队列。如果进入等待队列失败(比如有界队列到达了上限,或者使用了SynchronousQueue类),则会执行第17行代码,将任务直接提交给线程池。如果当前线程总数已经达到maximumPoolSize,则提交失败,这时会执行第18行的拒绝策略代码。

ThreadPoolExecutor类的任务调度逻辑如下图所示:
在这里插入图片描述

4、超负载了怎么办:拒绝策略

ThreadPoolExecutor类的最后一个参数指定了拒绝策略,也就是当任务数量超过系统实际承载能力时,就要用到拒绝策略了。拒绝策略可以说是系统超负荷运行时的补救措施,通常是由于压力太大而引起的,也就是线程池中的线程已经用完了,无法继续为新任务服务。同时,等待队列中也已经排满了,再也放不下新任务。这时,我们就需要有一套机制合理地处理这种情况了。

JDK内置了4种拒绝策略,如下图所示:
在这里插入图片描述
JDK内置拒绝策略的含义如下:

  • AbortPolicy:该策略会直接抛出异常,阻止系统正常工作。
  • CallerRunsPolicy:只要线程池未关闭,该策略就会直接在调用者线程中运行当前被丢弃的任务。显然这样做不会真的丢弃任务,但是任务提交线程的性能极有可能会急剧下降。
  • DiscardOldestPolicy:该策略将丢弃最老的一个请求,也就是即将被执行的任务,并尝试再次提交当前任务。
  • DiscardPolicy:该策略默默地丢弃无法处理的任务,不予任何处理。如果允许任务丢失,我觉得这可能是最好的一种方案了吧!

以上拒绝策略均实现了RejectedExecutionHandler接口,若以上策略仍无法满足实际应用的需要,则我们完全可以自己扩展RejectedExecutionHandler接口。RejectedExecutionHandler接口的定义如下:
在这里插入图片描述
其中r为请求执行的任务,executor为当前的线程池。

下面的代码简单地演示了自定义线程池和拒绝策略的使用方法:

package com.example.demo.juc.pool;


import java.util.concurrent.*;

/**
 * @title RejectThreadPoolDemo
 * @description 自定义线程池和拒绝策略
 * @author: yangyongbing
 * @date: 2024/2/19 12:50
 */
public class RejectThreadPoolDemo {

    public static class MyTask implements Runnable {

        @Override
        public void run() {
            System.out.println(System.currentTimeMillis() + ":Thread ID:" + Thread.currentThread().getId());
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        MyTask myTask = new MyTask();
        ExecutorService executorService = new ThreadPoolExecutor(5, 5,
                0L, TimeUnit.SECONDS,
                new LinkedBlockingDeque<>(10),
                Executors.defaultThreadFactory(),
                (r, executor) -> System.out.println(r.toString() + " is discard"));

        for (int i = 0; i < Integer.MAX_VALUE; i++) {
            executorService.submit(myTask);
            Thread.sleep(10);
        }

    }

}

在这里插入图片描述
上述代码的第17~27行自定义了一个线程池。该线程池有5个常驻线程,并且最大线程数量也是5。这和固定大小的线程池是一样的,但是它却拥有一个容量为10的等待队列。使用无界队列很可能并不是最佳解决方案,因为如果任务量极大,则很有可能会把内存“撑死”。给出一个合理的队列大小,也是合乎常理的选择。同时,这里自定义了拒绝策略,我们不抛出异常,因为万一在任务提交端没有进行异常处理,则有可能使整个系统崩溃,这不是我们希望遇到的。但作为必要的信息记录,我们打印任务丢弃的信息,当然,这只比内置的DiscardPolicy策略高级那么一点点。

由于在这个案例中MyTask的执行需要花费100毫秒,因此,必然会导致大量的任务被直接丢弃。执行上述代码,输出如下:
在这里插入图片描述
可以看到,在执行几个任务后,拒绝策略就开始生效了。在实际应用中,我们可以将更详细的信息记录到日志中,分析系统负载和任务丢失的情况。

5、自定义线程创建:ThreadFactory

看了那么多有关线程池的介绍,不知道大家有没有思考过一个基本的问题:线程池中的线程是从哪里来的呢?

之前我们介绍过,线程池的主要作用是线程复用,也就是避免了线程的频繁创建。但是,最开始的那些线程从何而来呢?答案就是ThreadFactory。

ThreadFactory是一个接口,它只有一个用来创建线程的方法:

Thread newThread(Runnable r);

当线程池需要新建线程时,就会调用这个方法。

自定义线程池可以帮助我们做不少事。比如,我们可以跟踪线程池,知道它究竟在何时创建了多少个线程,也可以自定义线程的名称、组及优先级等信息,甚至可以任性地将所有的线程设置为守护线程。总之,使用自定义线程池可以让我们更加自由地设置线程池中所有线程的状态。下面的案例使用自定义的ThreadFactory,一方面记录了线程的创建,另一方面将所有的线程都设置为守护线程,这样,当主线程退出后,将会强制销毁线程池:
在这里插入图片描述

6、我的应用我做主:扩展线程池

虽然JDK已经帮我们实现了这个稳定的高性能线程池,但如果我们需要对这个线程池做一些扩展,比如,监控执行每个任务的开始时间和结束时间,或者其他一些自定义的增强功能,这时候应该怎么办呢?

一个好消息是:ThreadPoolExecutor是一个可以扩展的线程池,它提供了beforeExecute()、afterExecute()和terminated()三个接口用来对线程池进行控制。

以beforeExecute()、afterExecute()两个接口为例,它们在ThreadPoolExecutor.Worker.runWorker()方法内部提供了这样的实现:
在这里插入图片描述
ThreadPoolExecutor.Worker是ThreadPoolExecutor的内部类,它是一个实现了Runnable接口的类。ThreadPoolExecutor线程池中的工作线程也正是Worker实例。Worker.run()方法会调用上述ThreadPoolExecutor.runWorker(Worker w)实现每一个工作线程的固有工作。

在默认的ThreadPoolExecutor实现中,提供了beforeExecute()和afterExecute()这两个空的接口实现。在实际应用中,可以对其进行扩展来实现对线程池运行状态的跟踪,输出一些有用的调试信息以帮助系统诊断故障,这对于多线程程序错误排查是很有帮助的。下面演示了对线程池的扩展,在这个扩展中我们将记录每一个任务的执行日志:
在这里插入图片描述
在这里插入图片描述
上述代码的第23~40行扩展了原有的线程池,实现了beforeExecute()、afterExecute()和terminated()三个方法。这三个方法分别用于记录一个任务的开始、结束和线程池的退出。第42、43行向线程池提交了5个任务,为了有更清晰的日志,我们为每个任务都取了名字。第43行使用execute()方法提交任务,细心的读者一定能发现,在之前的代码中,我们都使用了submit()方法提交。有关两者的区别,我们将在“Future模式”中详细介绍。

在提交任务完成后,调用shutdown()方法关闭线程池。这是一个比较安全的方法,如果当前正有线程在执行,那么shutdown()方法并不会立即暴力地终止所有任务,它会等待所有任务执行完成后再关闭线程池,但它也不会等待所有线程执行完成后再返回,因此,可以简单地理解成shutdown()方法只是发送了一个关闭信号而已。但在shutdown()方法执行后,这个线程池就不能再接受其他新的任务了。

执行上述代码,可以得到如下输出:
在这里插入图片描述
可以看到,所有任务执行前、执行后的时间点及任务的名字都可以捕获。这对于应用程序的调试和诊断是非常有帮助的。

7、合理的选择:优化线程池线程数量

线程池的大小对系统的性能有一定的影响,过多或者过少的线程数量都无法达到最优的系统性能,但是线程池的大小也不需要做得非常精确,因为只要避免极大和极小两种情况,线程池的大小对系统的性能就不会有太大影响。一般来说,确定线程池的大小需要考虑CPU数量、内存大小等因素。Java Concurrency in Practice一书给出了估算线程池大小的公式:

Ncpu=CPU的数量
Ucpu=目标CPU的使用率,0≤Ucpu≤1
W/C=等待时间与计算时间的比率

在Java中,可以通过如下代码取得可用的CPU数量:

Runtime.getRuntime().availableProcessors();

8、堆栈去哪里了:在线程池中寻找堆栈

下面来看一个简单的案例。首先,我们有一个Runnable接口,它用于计算两个数的商:
在这里插入图片描述
现在我们构造了几个这样的任务,希望程序可以计算给定数组的商:
在这里插入图片描述
上述代码将DivTask提交到线程池,从for循环来看,我们应该会得到5个结果,分别是100除以给定的i后的商。但如果你真的运行程序,得到的结果是:
在这里插入图片描述
只有4个结果,也就是说程序漏算了一组数据,更不幸的是,程序没有任何日志记录,也没有任何错误提示,就好像一切正常。在这个简单的案例中,只要你稍有经验就能发现,作为除数的i取到了0值,缺失的结果很可能是由于除以0导致的。在稍复杂的业务场景中,这种错误足以让你几天萎靡不振。

因此,使用线程池虽然是件好事,但是还是得处处留意这些“坑”。线程池很有可能会“吃”掉程序抛出的异常,导致我们对程序的错误一无所知。

异常堆栈对于程序员的重要性就好像指南针对于航行在茫茫大海上的轮船。没有指南针,轮船只能更艰难地寻找方向;没有异常堆栈,排查问题时程序员也只能慢慢琢磨。我的一个领导曾经说过:“最鄙视那些出错不打印异常堆栈的行为!”我相信,任何一个得益于异常堆栈而快速定位问题的程序员,一定都对这句话深有体会。下面将和大家讨论向线程池讨回异常堆栈的方法。

最简单的方法就是放弃submit()方法而改用execute()方法。将上述任务提交代码改成:
在这里插入图片描述
或者使用下面的方法改造submit()方法:
在这里插入图片描述
上面两种方法都可以得到部分堆栈信息,如下所示:
在这里插入图片描述
注意,我这里说的是部分,这是因为从这两个异常堆栈中,我们只能知道异常是在哪里抛出的(这里是DivTask的第11行)。其实我们还希望得到另一个更重要的信息,那就是这个任务到底是在哪里提交的。而任务的具体提交位置已经被线程池完全淹没了。顺着堆栈查找,我们最多只能找到线程池中的调度流程,而这对于我们来说几乎是没有价值的。

既然这样,我们只能自己动手丰衣足食啦!为了今后少加几天班,非常有必要将堆栈的信息彻底挖出来,扩展我们的ThreadPoolExecutor线程池,让它在调度任务之前保存一下提交任务线程的堆栈信息:
在这里插入图片描述
在第23行代码中,wrap()方法的第2个参数为一个异常,里面保存着提交任务的线程的堆栈信息。该方法将我们传入的Runnable任务进行一层包装,使之能处理异常信息。当任务发生异常时,异常会被打印。

好了,现在可以使用我们的新成员(TraceThreadPoolExecutor)来尝试执行这段代码了:
在这里插入图片描述
执行上述代码,就可以得到以下信息:
在这里插入图片描述
熟悉的异常又回来了!现在,我们不仅可以得到异常发生的Runnable实现内的信息,也知道了这个任务是在哪里提交的。如此丰富的信息,可以帮助我们瞬间定位问题!

9、分而治之:Fork/Join框架

“分而治之”一直是一个非常有效地处理大量数据的方法。著名的MapReduce也采取了分而治之的思想。简单地说,就是如果你要处理1000个数据,但是你并不具备处理1000个数据的能力,那么你可以只处理其中的10个,然后分阶段处理100次,将100次的结果进行合成,得到的就是最终想要的对原始1000个数据的处理结果。

Fork一词的原始含义是吃饭用的叉子,也有分叉的意思。在Linux平台上,fork()方法用来创建子进程,使得系统进程可以多一个执行分支。在Java中也沿用了类似的命名方式。

join()方法的含义在之前已经解释过,这里表示等待。也就是使用fork()方法后系统多了一个执行分支(线程),所以需要等待这个执行分支执行完毕,才有可能得到最终的结果,因此join()方法就表示等待。

在实际使用中,如果毫无顾忌地使用fork()方法开启线程,那么很可能严重影响系统的性能。所以,JDK中给出了一个ForkJoinPool线程池,对于fork()方法不急着开启线程,而是提交给ForkJoinPool线程池进行处理,以节省系统资源。使用Fork/Join框架进行数据处理时的执行逻辑如下图所示:
在这里插入图片描述
由于线程池的优化,提交的任务和线程数量并不是一对一的关系。在绝大多数情况下,一个物理线程实际上是需要处理多个逻辑任务的,每个线程必然需要拥有一个任务队列。因此,在实际执行过程中,可能遇到这么一种情况:线程A已经把自己的任务都执行完了,而线程B还有一堆任务等着处理,此时,线程A就会“帮助”线程B,从线程B的任务队列中拿一个任务过来处理。下图显示了这种互相帮助的情况:
在这里插入图片描述
下面我们来看ForkJoinPool线程池的一个重要的接口:
在这里插入图片描述
你可以向ForkJoinPool线程池提交一个ForkJoinTask任务。所谓ForkJoinTask任务就是支持fork()方法及join()方法的任务。ForkJoinTask任务有两个重要的子类——RecursiveAction类和RecursiveTask类,它们分别表示没有返回值的任务和可以携带返回值的任务。下图显示了这两个类的作用和区别:
在这里插入图片描述
下面我们简单地展示一下Fork/Join框架的使用方法:
在这里插入图片描述
在这里插入图片描述
以上代码主要用来计算数列和,由于计算数列和必然需要函数返回值,因此选择RecursiveTask作为任务的模型。代码的第39行建立了ForkJoinPool线程池。在第40行构造了一个求从1到200000的和的任务。在第41行将任务提交给线程池,线程池会返回一个携带结果的任务,通过get()方法得到最终结果(第43行)。如果在执行get()方法时任务没有结束,那么主线程就会在执行get()方法时等待。

下面来看一下CountTask的实现。首先CountTask继承自RecursiveTask类,可以携带返回值,这里将返回值类型设置为long。第2行定义的THRESHOLD设置了任务分解的规模,如果需要求和的总数大于THRESHOLD个,那么任务就需要再次分解,否则可以直接执行。这个判断逻辑在第14行有体现。如果任务可以直接执行,那么直接求和,返回结果。否则,就再次分解任务。每次分解任务时,简单地将原有任务划分成100个等规模的小任务,并使用fork()方法提交子任务。之后,等待所有的子任务结束,再将结果再次求和(第31~33行)。

下面来看一下CountTask的实现。首先CountTask继承自RecursiveTask类,可以携带返回值,这里将返回值类型设置为long。第2行定义的THRESHOLD设置了任务分解的规模,如果需要求和的总数大于THRESHOLD个,那么任务就需要再次分解,否则可以直接执行。这个判断逻辑在第14行有体现。如果任务可以直接执行,那么直接求和,返回结果。否则,就再次分解任务。每次分解任务时,简单地将原有任务划分成100个等规模的小任务,并使用fork()方法提交子任务。之后,等待所有的子任务结束,再将结果再次求和(第31~33行)。

下面的StackOverflowError异常就是加深本例的调用层次后在JDK 8上得到的错误:
在这里插入图片描述
此外,ForkJoinPool线程池使用一个无锁的栈来管理空闲线程。如果一个工作线程暂时没有任务,则可能会被挂起,挂起的线程将会被压入由线程池维护的栈中,待将来有任务时,再从栈中唤醒。

10、Guava中对线程池的扩展

除JDK内置的线程池以外,Guava对线程池也进行了一定的扩展,主要体现在MoreExecutors工具类中。

10.1、特殊的DirectExecutor线程池

在MoreExecutors工具类中,提供了一个简单但非常重要的线程池实现,即DirectExecutor线程池。DirectExecutor线程池很简单,它并没有真的创建或者使用额外线程,它总是在当前线程中直接执行任务。你也许会觉得很奇怪,为什么需要这么一个线程池呢?其实这满足了软件设计上的需要。

从软件设计的角度说,抽象是软件设计的根本和精髓。将不同业务的共同属性提取并抽象成模型,非常有利于对不同业务的统一处理。我们总是希望并倾向于使用通用的代码来处理不同场景的问题,因此,就需要对不同场景的问题进行统一的抽象和建模。

对于线程池来说,其技术目的是复用线程以提高运行效率,但其业务需求却是异步执行一段业务指令。但是有时候,异步并不是必要的。当我们剥去线程池的技术细节,仅关注其使用场景时,便不难发现,任何一个可以运行Runnable实例的模块都可以被视为线程池,即便它没有真正创建线程。这样就可以将异步执行和同步执行进行统一,使用统一的编码风格来处理同步和异步调用,进而简化设计。
在这里插入图片描述
上述代码在线程池中执行一个Runnable接口,并打印Runnable接口所在的线程,其输出如下:
在这里插入图片描述
可以看到,这个Runnable接口在主线程中执行。

注入不同的exceutor的实现,例如使用固定大小线程池替代DirectExecutor,无须修改代码便可以使程序拥有不同的行为,这也正是DirectExecutor的用意所在。

10.2、Daemon线程池

此外,在MoreExecutors工具类中,还提供了将普通线程池转为Daemon线程池的方法。在很多场合下,我们并不希望后台线程池阻止程序退出,当程序执行完成后,即便有线程池存在,我们依然希望线程结束执行。此时,就可以使用MoreExecutors.getExitingExecutorService()方法:
在这里插入图片描述
上述代码输出“I am running in pool-1-thread-1”后,立即退出程序,若不使用MoreExecutors.getExitingExecutorService()方法对exceutor线程池进行设置,则该程序无法正常退出,除非手动关闭exceutor线程池。

10.3、对Future模式的扩展

在MoreExecutors中还提供了对Future模式的扩展。

  • 43
    点赞
  • 21
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
Java线程池是一种用于管理和复用线程的机制,它可以帮助我们更好地管理线程,防止线程过多导致系统资源的浪费和性能问题。线程池通过一个池子来缓存和复用线程,让线程可以被重复利用,从而减少线程的创建和销毁的开销,提高系统的性能。 在Java中,线程池是通过java.util.concurrent包下面的Executor框架来实现的。Executor框架提供了一种将任务提交与执行分离开来的机制,它将任务的提交和执行分离开来,从而使得任务的执行更加高效和灵活。 Java线程池的主要特点包括: 1. 线程复用:线程池中的线程可以被重复利用,从而减少线程的创建和销毁的开销,提高系统的性能。 2. 控制线程数量:通过控制线程池中的线程数量,可以避免线程过多导致系统资源的浪费和性能问题。 3. 线程池大小自适应:线程池的大小可以根据需要自适应调整,以适应不同的任务负载。 4. 任务队列:线程池中通常会设置一个任务队列,用于存放等待执行的任务。 5. 线程池管理:线程池通常会提供一些管理方法,用于监控线程池的状态和执行情况。 Java线程池的使用步骤如下: 1. 创建一个线程池对象。 2. 向线程池中提交任务。 3. 线程池会自动分配线程来执行任务。 4. 等待任务执行完成。 5. 关闭线程池线程池的具体实现可以通过Java提供的ThreadPoolExecutor类来完成。ThreadPoolExecutor类提供了一些构造方法和方法,可以用来设置线程池的参数和管理线程池。同时,Java还提供了一些其他类型的线程池,例如FixedThreadPool、CachedThreadPool和ScheduledThreadPool等,可以根据需要选择不同类型的线程池来处理任务。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值