十一、Java线程池刨根问底

概述

Java线程的创建,以及线程之间切换上下文时,引入了轻量级锁,偏向锁等技术。目的就是:减少用户态和核心态的切换频率。

但是创建和销毁线程同样也 非常损耗性能,因为java的线程都会影射到 操作系统底层的实际线程。 为了解决线程重复创建的问题,java中还提供了线程池,以达成线程的复用。

回顾一下,Java的 虚拟机栈,本地方法栈,程序计数器 都是线程私有的。(所有线程共享的是 方法区和堆)当线程创建时,这些东西要创建出来,当线程销毁时,这些东西又要逐个销毁。

通过复用线程,可以解决2个问题:

  1. 当异步执行大量任务时,线程池能提供很好的性能。
  2. 线程池提供了资源的限制和管理手段,比如限制线程的个数,动态新增线程等。

线程池的体系

核心代码在 ExcutorService 中。

线程池的使用

单线程线程池

为了更方便地创建线程池,JUC提供了一个 Executors 类,它内部提供了多个静态方法让我们快速创建适应当前业务场景的线程池。

比如如下场景,单线程池,需要任务逐个执行。

java 复制代码
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class Example {
    public static void main(String[] args) throws InterruptedException {

        ExecutorService executorService = Executors.newSingleThreadExecutor();

        for (int i = 0; i < 6; i++) {
            final int taskId = i;
            executorService.submit(new Runnable() {

                @Override
                public void run() {
                    System.out.println(Thread.currentThread().getName() +"->task:" + taskId);
                }
                
            });
            Thread.sleep(1000);
        }

    }
}

打印结果如下:

java 复制代码
pool-1-thread-1->task:0
pool-1-thread-1->task:1
pool-1-thread-1->task:2
pool-1-thread-1->task:3
pool-1-thread-1->task:4
pool-1-thread-1->task:5
pool-1-thread-1->task:6

可以看出,所有的任务都是由 线程池 pool-1 中 的 thread-1 这一个线程去执行的。

带动态缓存的线程池

另一个场景:创建一个可缓存线程池,如果线程池长度超过 当前场景需要(冗余),可灵活回收不需要的线程。

java 复制代码
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class Example {
    public static void main(String[] args) throws InterruptedException {

        ExecutorService executorService = Executors.newCachedThreadPool();

        for (int i = 0; i < 6; i++) {
            final int taskId = i;
            executorService.execute(new Runnable() {

                @Override
                public void run() {
                    try {
                        System.out.println(Thread.currentThread().getName() + "->task:" + taskId);
                        Thread.sleep(500);
                    } catch (InterruptedException e) {
                    }
                }

            });

        }
        
        executorService.shutdown();
    }
}

打印结果为:

java 复制代码
pool-1-thread-3->task:2
pool-1-thread-6->task:5
pool-1-thread-5->task:4
pool-1-thread-1->task:0
pool-1-thread-4->task:3
pool-1-thread-2->task:1

线程池 pool-1中,123456号线程全部出动执行任务,任务的打印顺序也不是之前的123456,每次运行顺序都有可能会变化,这就是多线程执行任务时争夺CPU时间片导致的不确定结果。

可是如果代码改一下,在提交任务时,先休眠1秒。

java 复制代码
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class Example {
    public static void main(String[] args) throws InterruptedException {

        ExecutorService executorService = Executors.newCachedThreadPool();

        for (int i = 0; i < 6; i++) {
            final int taskId = i;

            System.out.println(Thread.currentThread().getName()+" will sleep 1s.");
            Thread.sleep(1000);

            executorService.execute(new Runnable() {

                @Override
                public void run() {
                    System.out.println(Thread.currentThread().getName() + "->task:" + taskId);

                }

            });

        }

        executorService.shutdown();
    }
}

那么打印结果又和单线程线程池一样了。

java 复制代码
main will sleep 1s.
main will sleep 1s.
pool-1-thread-1->task:0
main will sleep 1s.
pool-1-thread-1->task:1
main will sleep 1s.
pool-1-thread-1->task:2
main will sleep 1s.
pool-1-thread-1->task:3
main will sleep 1s.
pool-1-thread-1->task:4
pool-1-thread-1->task:5

可以看出,在休眠中的始终是main线程。由于main线程休眠了,导致提交任务出现1s空档,上一个子线程只需要执行500MS,在下一次提交时,本次任务早已经执行完毕,所以打印结果就是按照提交的顺序来了。并且可以看到,这里只出现了一个线程thread-1,这也是因为,cachedThreadPool是可以动态创建和销毁线程的,这种场景下,只需要一个线程就足够了。

固定线程数量的可重用线程池

在这个线程中,最多只有3个线程,所以下面的代码中提交了6个任务,最终打印结果也只会出现3个线程名。

java 复制代码
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class Example {
    public static void main(String[] args) throws InterruptedException {

        ExecutorService executorService = Executors.newFixedThreadPool(3);

        for (int i = 0; i < 6; i++) {
            final int taskId = i;

            executorService.submit(new Runnable() {

                @Override
                public void run() {
                    System.out.println(Thread.currentThread().getName() + "->task:" + taskId);
                }

            });

        }

        executorService.shutdown();
    }
}

打印结果:

java 复制代码
pool-1-thread-3->task:2
pool-1-thread-2->task:1
pool-1-thread-1->task:0
pool-1-thread-3->task:4
pool-1-thread-2->task:3
pool-1-thread-1->task:5

最多出现3个线程,并且线程之间针对CPU时间片,导致打印结果并不是按照提交任务的顺序。

定时线程池

下面的代码,创建了一个线程数量为2的定时任务线程池。每隔1000MS执行一次任务,首次执行时的延迟时间为500MS,并且主线程在5S内会关闭线程池,所以最多打印了5次。

java 复制代码
package com.example;

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

public class Example {
   public static void main(String[] args) throws InterruptedException {

       ScheduledExecutorService executorService = Executors.newScheduledThreadPool(2);

       executorService.scheduleAtFixedRate(new Runnable() {

           @Override
           public void run() {
               Date date = new Date();

               System.out.println("线程:" + Thread.currentThread().getName() + "报时:" + date);

           }

       }, 500, 1000, TimeUnit.MILLISECONDS);

       Thread.sleep(5000);

       executorService.shutdown();
   }
}

执行结果如下

java 复制代码
线程:pool-1-thread-1报时:Wed Oct 11 20:11:48 CST 2023
线程:pool-1-thread-1报时:Wed Oct 11 20:11:49 CST 2023
线程:pool-1-thread-1报时:Wed Oct 11 20:11:50 CST 2023
线程:pool-1-thread-2报时:Wed Oct 11 20:11:51 CST 2023
线程:pool-1-thread-2报时:Wed Oct 11 20:11:52 CST 2023

线程池工作原理

场景类比

举个现实生活中的例子:

这是一个工艺品加工厂,机器三台,订单任务数量目前有4个,那么工厂3台机器都在工作,那么多出来的订单只能在 仓库放着。除非有空闲机器。

此时,工厂为了容纳更多订单,可能会考虑新增机器。

如果订单持续增多,比如双十一,订单爆仓了,机器满载了,仓库也堆不下了,那么此时,工厂就只能拒绝多出来的订单。

线程池的工作原理也类似

  • 我们可以在创建线程池时指定默认有多少个工作线程
  • 如果任务数量太多,首先会等待现有的机器空闲出来,此时多余的订单放在仓库中
  • 如果仓库都满了,那么多出来的任务就要创建新的线程来执行
  • 可是如果新的线程数量也超出了最大值,那么再多的任务也只能拒绝执行了。

上面提到的这些场景,就是 如下图所示的几个结构:

核心线程 (默认机器)

works集合,本质是一个hashSet

等待队列 (仓库)

当核心线程都满负荷之后,也就是说正在工作的核心线程数量超过了 corePoolSize 时,新提交的任务会保存在等待队列中。

它的本质是一个阻塞队列,

线程池构造函数参数分析

  • corePoolSize 核心线程数
  • maximumPoolSize 线程池可容纳的最大线程数
  • keepAliveTime 线程池中线程的等待时间,如果有非核心线程闲置超过此时长,则会销毁。
  • unit 空闲时间的单位
  • workQueue 任务等待队列,阻塞队列类型,当请求任务数量大于corePoolSize时,任务会优先放在这个队列中,
  • threadFactofy 线程工厂,如果传入的是null,则会使用默认的DefaultThreadFactory
  • handler 执行拒绝策略的对象,当阻塞队列满了,并且 线程数量已经达到了 maximumPoolSize ,就会执行这里的拒绝逻辑。

注意:ThreadPoolExecutor 中 allowCoreThreadTimeOut 为true时,如果核心线程超时,它也会被销毁。只不过默认值是false,会超时销毁的只有非核心线程。

工作流程

当线程池收到一个任务时:

  • 如果核心线程数没有达到 corePoolSize ,不管其他核心线程是不是空闲,都会创建出一个核心线程执行任务。
  • 当前线程池中线程数量已经达到了 corePoolSize时,线程池会把任务加入到等待队列中,直到某一个线程空闲,线程池会根据设置的等待队列规则,从队列中取出一个新的任务执行。

效果如下:

java 复制代码
package com.example;

import java.util.Date;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadPoolExecutor;

public class Example {
    public static void main(String[] args) throws InterruptedException {

        ThreadPoolExecutor executorService = (ThreadPoolExecutor) Executors.newFixedThreadPool(2);

        for (int i = 0; i < 5; i++) {
            executorService.submit(new Runnable() {

                @Override
                public void run() {
                    try {
                        Date date = new Date();
                        System.out.println("线程:" + Thread.currentThread().getName() + "报时:" + date);
                        Thread.sleep(4000);
                    } catch (InterruptedException e) {
                    }

                }

            });

            System.out.println("等待队列中现在有" + executorService.getQueue().size() + "个任务");
            Thread.sleep(500);
        }

    }
}

执行结果:

java 复制代码
等待队列中现在有0个任务
线程:pool-1-thread-1报时:Wed Oct 11 20:43:25 CST 2023
等待队列中现在有0个任务
线程:pool-1-thread-2报时:Wed Oct 11 20:43:26 CST 2023
等待队列中现在有1个任务
等待队列中现在有2个任务
等待队列中现在有3个任务
线程:pool-1-thread-1报时:Wed Oct 11 20:43:29 CST 2023
线程:pool-1-thread-2报时:Wed Oct 11 20:43:30 CST 2023
线程:pool-1-thread-1报时:Wed Oct 11 20:43:33 CST 2023
  • 线程数已经达到了corePoolSize数量但是还没有达到maximunPoolSize并且等待队列已满的时候,会创建非核心线程来执行任务
java 复制代码
package com.example;

import java.util.Date;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

public class Example {
    public static void main(String[] args) throws InterruptedException {

        ThreadPoolExecutor executorService = new ThreadPoolExecutor(2,
                10,
                0, TimeUnit.MILLISECONDS,
                new LinkedBlockingQueue<Runnable>(2));

        for (int i = 0; i < 5; i++) {
            executorService.execute(new Runnable() {

                @Override
                public void run() {
                    try {
                        Date date = new Date();
                        System.out.println("线程:" + Thread.currentThread().getName() + "报时:" + date);
                        Thread.sleep(2000);
                    } catch (InterruptedException e) {
                    }

                }

            });

            System.out.println("等待队列中现在有" + executorService.getQueue().size() + "个任务");
            Thread.sleep(500);
        }

    }
}

执行结果:

java 复制代码
等待队列中现在有0个任务
线程:pool-1-thread-1报时:Wed Oct 11 20:49:29 CST 2023
等待队列中现在有0个任务
线程:pool-1-thread-2报时:Wed Oct 11 20:49:30 CST 2023
等待队列中现在有1个任务
等待队列中现在有2个任务
等待队列中现在有2个任务
线程:pool-1-thread-3报时:Wed Oct 11 20:49:31 CST 2023
线程:pool-1-thread-1报时:Wed Oct 11 20:49:31 CST 2023
线程:pool-1-thread-2报时:Wed Oct 11 20:49:32 CST 2023
  • 最后如果提交到任务,无法被核心线程执行,也不能加入等待队列,也不能创建新的非核心线程来执行,线程池将会根据拒绝处理策略来处理它。

将最大线程数改为3,核心线程数仍然为2,下面的代码中,执行一次任务需要5000MS,而6个任务是一次性提交进去的,其中第四个任务就会因为 无法被核心线程执行,无法加入等待队列,无法创建新的非核心线程执行,而执行拒绝策略。

java 复制代码
package com.example;

import java.util.Date;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

public class Example {
    public static void main(String[] args) throws InterruptedException {

        ThreadPoolExecutor executorService = new ThreadPoolExecutor(2,
                3,
                0, TimeUnit.MILLISECONDS,
                new LinkedBlockingQueue<Runnable>(2));

        for (int i = 0; i < 6; i++) {
            executorService.execute(new Runnable() {

                @Override
                public void run() {
                    try {
                        Date date = new Date();
                        System.out.println("线程:" + Thread.currentThread().getName() + "报时:" + date);
                        Thread.sleep(5000);
                    } catch (InterruptedException e) {
                    }

                }

            });

            System.out.println("等待队列中现在有" + executorService.getQueue().size() + "个任务");
            Thread.sleep(500);
        }

    }
}
java 复制代码
线程:pool-1-thread-1报时:Wed Oct 11 20:52:13 CST 2023
等待队列中现在有0个任务
线程:pool-1-thread-2报时:Wed Oct 11 20:52:13 CST 2023
等待队列中现在有1个任务
等待队列中现在有2个任务
等待队列中现在有2个任务
线程:pool-1-thread-3报时:Wed Oct 11 20:52:15 CST 2023
Exception in thread "main" java.util.concurrent.RejectedExecutionException: Task com.example.Example$1@f6f4d33 rejected from java.util.concurrent.ThreadPoolExecutor@23fc625e[Running, pool size = 3, active threads = 3, queued tasks = 2, completed tasks = 0]
        at java.base/java.util.concurrent.ThreadPoolExecutor$AbortPolicy.rejectedExecution(ThreadPoolExecutor.java:2070)
        at java.base/java.util.concurrent.ThreadPoolExecutor.reject(ThreadPoolExecutor.java:833)
        at java.base/java.util.concurrent.ThreadPoolExecutor.execute(ThreadPoolExecutor.java:1365)
        at com.example.Example.main(Example.java:17)
线程:pool-1-thread-1报时:Wed Oct 11 20:52:18 CST 2023
线程:pool-1-thread-2报时:Wed Oct 11 20:52:18 CST 2023

实际上这种直接抛出异常的策略,只是java提供了4种策略中的一种:

并且可以自定义拒绝策略。

为了阿里明令禁止使用 Executors 工具类 创建线程

虽然线程池如此优雅地为我们管理了 任务的执行,但是如果无节制地使用 Executors 会导致崩溃,内存溢出等问题。

比如说: Executors.newFixedThreadPool(2)创建了一个固定数量为2的线程池,当任务添加超过一定数量时,可能会发生OOM内存溢出,这是因为 newFixedThreadPool 默认会创建无限容量的 阻塞队列来暂存任务,阻塞队列的size是由一个int值表示的,它的最大值是 2^16-1,理论上,如果size超过了这个数,就再也无法插入任务到队列中。

再比如:Exceutors.newCachedThreadPool() 的问题类似,由于它也是使用默认的不指定size的线程池,也就意味着可以无限创建线程,当有无限多任务去提交的时候,它会不加节制地创建线程。而一个操作系统中,对每个进程可使用的线程数量都是有限制的,一旦超过这个数,就无法再继续创建。

所以要正确使用线程池,还是老老实实用

java 复制代码
new ThreadPoolExecutor(2,
                3,
                0, TimeUnit.MILLISECONDS,
                new LinkedBlockingQueue<Runnable>(2));

这种写法指定线程池的各项参数,尤其是 线程数量,等待队列的容量。这样才能保证利用线程池解决业务问题的同时,不给自己埋雷。

相关推荐
为将者,自当识天晓地。10 分钟前
c++多线程
java·开发语言
daqinzl18 分钟前
java获取机器ip、mac
java·mac·ip
激流丶34 分钟前
【Kafka 实战】如何解决Kafka Topic数量过多带来的性能问题?
java·大数据·kafka·topic
Themberfue37 分钟前
Java多线程详解⑤(全程干货!!!)线程安全问题 || 锁 || synchronized
java·开发语言·线程·多线程·synchronized·
让学习成为一种生活方式1 小时前
R包下载太慢安装中止的解决策略-R语言003
java·数据库·r语言
晨曦_子画1 小时前
编程语言之战:AI 之后的 Kotlin 与 Java
android·java·开发语言·人工智能·kotlin
南宫生1 小时前
贪心算法习题其三【力扣】【算法学习day.20】
java·数据结构·学习·算法·leetcode·贪心算法
Heavydrink2 小时前
HTTP动词与状态码
java
ktkiko112 小时前
Java中的远程方法调用——RPC详解
java·开发语言·rpc
计算机-秋大田2 小时前
基于Spring Boot的船舶监造系统的设计与实现,LW+源码+讲解
java·论文阅读·spring boot·后端·vue