【并发篇】深入理解Java线程池的运作原理

线程池详解

首先回顾一下单个线程的创建方式

1、继承 Thread 类

这是一种比较传统的创建线程的方式。你可以创建一个类,继承自 Thread 类,并重写 run 方法来定义线程的执行逻辑。

class MyThread extends Thread {
    @Override
    public void run() {
        // 线程的执行逻辑
    }
}

// 创建并启动线程
MyThread thread = new MyThread();
thread.start();

2、实现 Runnable 接口

这种方式更常用,它避免了 Java 的单继承限制,你可以实现 Runnable 接口,然后将其实例作为参数传递给 Thread 构造函数。

class MyRunnable implements Runnable {
    @Override
    public void run() {
        // 线程的执行逻辑
    }
}

// 创建并启动线程
Thread thread = new Thread(new MyRunnable());
thread.start();

3、使用匿名内部类

你可以在创建线程时使用匿名内部类,实现 Runnable 接口的 run 方法。

Thread thread = new Thread(new Runnable() {
    @Override
    public void run() {
        // 线程的执行逻辑
    }
});
thread.start();

4、使用 Java 8 的 Lambda 表达式

如果 Runnable 接口只有一个抽象方法,你可以使用 Lambda 表达式简化代码。

Thread thread = new Thread(() -> {
    // 线程的执行逻辑
});
thread.start();

5、实现 Callable 接口

Callable 接口允许线程返回结果或抛出异常。需要通过 ExecutorService 来执行。

class MyCallable implements Callable<String> {
    @Override
    public String call() throws Exception {
        // 线程的执行逻辑
        return "Hello from Callable";
    }
}

ExecutorService executor = Executors.newFixedThreadPool(1);
Future<String> future = executor.submit(new MyCallable());
String result = future.get(); // 获取线程执行结果

实现 Runnable 接口和 Callable 接口的区别?

Java 中的 Runnable 接口和 Callable 接口都是用来创建多线程的接口,它们的区别如下:

  1. 方法名不同

    • Runnable 接口只有一个 run() 方法
    • 而 Callable 接口只有一个 call() 方法
  2. 返回值不同

    • Runnable 的 run() 方法没有返回值

    • 而 Callable 的 call() 方法可以返回执行结果

  3. 异常处理不同

    • Runnable 的 run() 方法不能抛出异常
    • 而 Callable 的 call() 方法可以抛出异常,并且需要在调用 Future.get() 方法时进行异常处理。
  4. 调用方式不同

    • Runnable 接口可以通过 Thread 类的构造方法来创建一个新的线程并启动它
    • 而 Callable 接口则需要借助 Executor 框架来执行
  5. 用途不同

    • Runnable 接口通常用于需要执行一些简单的任务的场景
    • Callable 接口通常用于需要返回结果、或者需要抛出异常、或者需要在执行任务前进行一些初始化操作的场景

什么是线程池?

线程池就是管理一系列线程的资源池。

当有任务要处理时,直接从线程池中获取线程来处理,处理完之后线程并不会立即被销毁,而是等待下一个任务。

为什么要用线程池?

简单来说,是因为使用线程池可以提高资源的利用率

线程池可以帮我们管理线程,避免增加创建线程和销毁线程的资源损耗。

我们写代码的过程中,学会池化思想,最直接相关的就是使用线程池而不是去new一个线程。

使用线程池有三大好处:

  1. 提高响应速度。通过线程池创建一系列线程,使用时直接通过线程池获取,不再需要手动创建线程,响应速度自然就大大提高了。
  2. 降低资源消耗。由于线程池被池化管理了,我们无需为了某些功能去手动创建和销毁线程,资源消耗自然降低。
  3. 提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。

讲讲线程池的工作流程

  1. 线程池刚创建时,里面没有一个线程。任务队列是作为参数传进来的。不过,就算队列里面有任务,线程池也不会马上执行它们。
  2. 当调用 execute() 方法添加一个任务时,线程池会做如下判断:
    • 如果正在运行的线程数量小于 corePoolSize,那么马上创建线程运行这个任务;
    • 如果正在运行的线程数量大于或等于 corePoolSize,那么将这个任务放入队列;
    • 如果这时候队列满了,而且正在运行的线程数量小于 maximumPoolSize,那么还是要创建非核心线程立刻运行这个任务;
    • 如果队列满了,而且正在运行的线程数量大于或等于 maximumPoolSize,那么线程池会根据拒绝策略来对应处理。
  3. 当一个线程完成任务时,它会从队列中取下一个任务来执行。
  4. 当一个线程无事可做,超过一定的时间(keepAliveTime)时,线程池会判断,如果当前运行的线程数大于 corePoolSize,那么这个线程就被停掉。所以线程池的所有任务完成后,它最终会收缩到 corePoolSize 的大小。

图解:

【并发篇】深入理解Java线程池的运作原理_第1张图片

线程池使用入门

  1. 首先创建一个含有 3 个线程的线程,
  2. 然后提交 3 个任务到线程池中,让线程池中的线程池执行,
  3. 完成后通过 shutdown 停止线程池,线程池收到通知后会将手头的任务都执行完,再将线程池停止。

这里使用 isTerminated 判断线程池是否完全停止了。只有状态为 terminated 才能说明线程池关闭了,结束循环,退出方法。

 @Test
    void contextLoads() {
        //创建含有3个线程的线程池
        ExecutorService threadPool = Executors.newFixedThreadPool(3);

        //提交3个任务到线程池中
        for (int i = 0; i < 3; i++) {
            final int taskNo = i;
            threadPool.execute(() -> {
                logger.info("执行任务{}", taskNo);
            });
        }

        //关闭线程池
        threadPool.shutdown();
        //如果线程池还没达到Terminated状态,说明线程池中还有任务没有执行完,则继续循环等待线程池执行完任务
        while (!threadPool.isTerminated()) {

        }
    }

输出结果

2023-03-21 23:01:16.198  INFO 40176 --- [pool-4-thread-1] .j.JavaCommonMistakes100ApplicationTests : 执行任务0
2023-03-21 23:01:16.198  INFO 40176 --- [pool-4-thread-2] .j.JavaCommonMistakes100ApplicationTests : 执行任务1
2023-03-21 23:01:16.225  INFO 40176 --- [pool-4-thread-3] .j.JavaCommonMistakes100ApplicationTests : 执行任务2

Executor 框架介绍

概述

在 Java 5 之后,通过 Executor 来启动线程比使用 Threadstart 方法更好,除了更易管理,效率更好(用线程池实现,节约开销)外,还有关键的一点:有助于避免 this 逃逸问题。

这是因为线程池的执行过程会等待构造完成后再进行任务的执行,从而避免了 this 逃逸问题的发生。

什么是 this 逃逸问题?

在 Java 中,对象的构造过程可能会涉及到多个线程,而当一个对象尚未完全构造完成但已经被其他线程引用时,就可能产生 this 逃逸问题。

具体来说,当一个对象正在构造过程中,它的引用就被发布到了其他线程,这时其他线程可能会使用这个尚未完全构造的对象,从而导致意料之外的行为和错误。这可能会因为对象的状态不稳定而引发线程安全问题。

结构

Executor 框架结构主要由三大部分组成:

  1. 任务。包括被执行任务需要实现的接口:Runnable 接口或 Callable 接口。
  2. 任务的执行。包括任务执行机制的核心接口 Executor,以及继承自 Executor 的 ExecutorService 接口。Executor 框架有两个关键类实现了 ExecutorService 接口(ThreadPoolExecutor 和 ScheduleThreadPoolExecutor)。
  3. 异步计算的结果。包括接口 Future 和实现 Future 接口的 FutureTask 类。

使用流程

  1. 主线程首先要创建实现 Runnable 或者 Callable 接口的任务对象。
  2. 把创建完成的实现 Runnable/Callable 接口的【对象】直接交给 ExecutorService 执行: ExecutorService.execute(Runnable command)或者也可以把 Runnable 对象或Callable 对象提交给 ExecutorService 执行(ExecutorService.submit(Runnable task)ExecutorService.submit(Callable task))。
  3. 如果执行 ExecutorService.submit(…)ExecutorService 将返回一个实现Future接口的对象(我们刚刚也提到过了执行 execute()方法和 submit()方法的区别,submit()会返回一个 FutureTask 对象)。由于 FutureTask 实现了 Runnable,我们也可以创建 FutureTask,然后直接交给 ExecutorService 执行。
  4. 最后,主线程可以执行 FutureTask.get()方法来等待任务执行完成。主线程也可以执行 FutureTask.cancel(boolean mayInterruptIfRunning)来取消此任务的执行。

代码示例:

import java.util.concurrent.*;

public class ThreadPoolExample {
    public static void main(String[] args) {
        // 创建一个线程池
        ExecutorService executorService = Executors.newFixedThreadPool(2);

        // 创建实现Runnable接口的任务
        Runnable task1 = () -> {
            System.out.println("Task 1 is running on thread: " + Thread.currentThread().getName());
        };

        // 创建实现Callable接口的任务
        Callable<String> task2 = () -> {
            System.out.println("Task 2 is running on thread: " + Thread.currentThread().getName());
            return "Task 2 Result";
        };

        try {
            // 执行Runnable任务
            executorService.execute(task1);

            // 提交Callable任务,并获取Future对象
            Future<String> future = executorService.submit(task2);

            // 主线程等待Callable任务执行完成,并获取结果
            String result = future.get();
            System.out.println("Task 2 Result: " + result);

        } catch (InterruptedException | ExecutionException e) {
            e.printStackTrace();
        } finally {
            // 关闭线程池
            executorService.shutdown();
        }
    }
}

线程池原理解析

线程池有哪些参数?

通过 Executors 框架创建的线程池,从源码可以看到,它底层是通过 ThreadPoolExecutor 完成线程池的创建,具体参数如下:

 public static ExecutorService newFixedThreadPool(int nThreads) {
        return new ThreadPoolExecutor(nThreads, nThreads,
                                      0L, TimeUnit.MILLISECONDS,
                                      new LinkedBlockingQueue<Runnable>());
    }
  1. corePoolSize:线程池的核心线程数,即线程池中始终保持的线程数。

  2. maximumPoolSize:线程池中最大的线程数,包括核心线程数和非核心线程数。

  3. keepAliveTime:非核心线程的闲置时间,超过该时间后将被回收。

  4. unit:keepAliveTime 非核心线程的闲置时间的单位

  5. workQueue:任务队列,用于存储还未被执行的任务。

  6. threadFactory:线程工厂,用于创建线程。

  7. handler:饱和策略,即当线程池中的线程都在执行任务时,新的任务会如何处理。(也称为拒绝策略

讲讲核心线程数和最大线程数的区别?

核心线程数和最大线程数的区别在于:

在任务数超过核心线程数时,线程池会优先创建核心线程来执行任务,只有当任务队列已满且核心线程都在执行任务时,才会创建非核心线程来执行任务,直到达到最大线程数为止。

讲讲有哪些拒绝策略?

有四种常见的拒绝策略:

  1. AbortPolicy(默认):直接抛出异常,阻止系统正常运行。
  2. CallerRunsPolicy:只用调用者所在线程来执行任务。
  3. DiscardOldestPolicy:丢弃队列中最老的一个任务,尝试再次提交当前任务。
  4. DiscardPolicy:直接丢弃任务,不做任何处理。

阻塞队列有哪些?

Java 中常用的阻塞队列有以下 4 种:

  1. ArrayBlockingQueue:一个由数组结构组成的有界阻塞队列,按照先进先出的原则对元素进行排序。

  2. LinkedBlockingQueue:一个由链表结构组成的可选有界阻塞队列,按照先进先出的原则对元素进行排序。

    如果队列容量没有限制,则为无界阻塞队列

  3. PriorityBlockingQueue:一个支持优先级排序的无界阻塞队列

  4. SynchronousQueue:一个不存储元素的阻塞队列,每个插入操作必须等待另一个线程的移除操作,否则插入操作一直处于阻塞状态。

新线程添加的流程?

新线程的添加有以下 4 个流程:

  1. 如果当前线程池中的线程数小于核心线程数,那么就创建一个新的核心线程来执行这个任务;

  2. 如果当前线程池中的线程数已经达到了核心线程数,那么就将任务添加到任务队列中等待执行;

  3. 如果任务队列已满,但当前线程池中的线程数还没有达到最大线程数,那么就创建一个新的非核心线程来执行这个任务;

    非核心线程在执行完任务之后会被回收,直到线程池中的线程数又重新降至核心线程数。

  4. 如果当前线程池中的线程数已经达到了最大线程数,那么就根据饱和策略来处理这个任务

线程池的两种创建方式

ThreadPoolExecutor

方式一:通过 ThreadPoolExecutor 构造函数来创建(推荐)。

我们可以创建多种类型的 ThreadPoolExecutor

  • FixedThreadPool:该方法返回一个固定线程数量的线程池。该线程池中的线程数量始终不变。当有一个新的任务提交时,线程池中若有空闲线程,则立即执行。若没有,则新的任务会被暂存在一个任务队列中,待有线程空闲时,便处理在任务队列中的任务。
  • SingleThreadExecutor:该方法返回一个只有一个线程的线程池。若多余一个任务被提交到该线程池,任务会被保存在一个任务队列中,待线程空闲,按先入先出的顺序执行队列中的任务。
  • CachedThreadPool: 该方法返回一个可根据实际情况调整线程数量的线程池。线程池的线程数量不确定,但若有空闲线程可以复用,则会优先使用可复用的线程。若所有线程均在工作,又有新的任务提交,则会创建新的线程处理任务。所有线程在当前任务执行完毕后,将返回线程池进行复用。
  • ScheduledThreadPool:该返回一个用来在给定的延迟后运行任务或者定期执行任务的线程池。
Executors

方式二:通过 Executor 框架的工具类 Executors 来创建。

Executors 返回线程池对象的弊端如下:

  • FixedThreadPool 和 SingleThreadExecutor:使用的是无界的 LinkedBlockingQueue,任务队列最大长度为 Integer.MAX_VALUE,可能堆积大量的请求,从而导致 OOM。

  • CachedThreadPool:使用的是同步队列 SynchronousQueue, 允许创建的线程数量为 Integer.MAX_VALUE ,可能会创建大量线程,从而导致 OOM。

  • ScheduledThreadPool 和 SingleThreadScheduledExecutor : 使用的无界的延迟阻塞队列 DelayedWorkQueue,任务队列最大长度为 Integer.MAX_VALUE, 可能堆积大量的请求,从而导致 OOM。

    OOM(Out of Memory)是指内存溢出,即程序在运行过程中申请的内存超过了JVM所能提供的最大内存限制,导致无法继续分配内存,从而抛出内存溢出异常。

线程池提交 execute 和 submit 有什么区别?

  1. execute 用于提交不需要返回值的任务
threadsPool.execute(new Runnable() { 
    @Override public void run() { 
        // TODO Auto-generated method stub } 
    });
  1. submit() 方法用于提交需要返回值的任务

    线程池会返回一个 future 类型的对象,通过这个 future 对象可以判断任务是否执行成功,并且可以通过 future 的 get() 方法来获取返回值。

Future<Object> future = executor.submit(harReturnValuetask); 
try { Object s = future.get(); } catch (InterruptedException e) { 
    // 处理中断异常 
} catch (ExecutionException e) { 
    // 处理无法执行任务异常 
} finally { 
    // 关闭线程池 executor.shutdown();
}

线程池的关闭方式

线程池的停止方式有两种:

  1. shutdown: 使用这个方法之后,我们无法提交新的任务进来,线程池会继续工作,将手头的任务执行完再停止
  2. shutdownNow: 这种停止方式比较粗暴,线程池会直接将手头的任务都强行停止,且不接受新任务进来,线程停止立即生效

学习参考

  • Java 线程池详解 | JavaGuide)
  • Java线程池详解 | Shark Chili
  • 面渣逆袭-Java并发编程
  • Executor框架详解
  • 实战总结!18种接口优化方案的总结
  • Executor框架详解
  • 实战总结!18种接口优化方案的总结

你可能感兴趣的:(Java,java,八股)