文章目录
- Java多线程的两级调度模型
- Executor 框架
- Executor 框架的组成概念
- Executor 框架中任务执行的两个阶段:任务提交和任务执行
在 Java1.5 以前,开发者必须手动实现自己的线程池;从 Java1.5 开始,Java 内部提供了线程池。
在Java 异步编程——既然可以手动创建线程,为什么还要使用线程池这篇文章提到过Java线程会映射到操作系统本地线程上,使得 Java 线程能够在操作系统层面上得到执行,在大多数情况下,每个 Java 线程都会对应一个本地操作系统线程,但具体的映射方式和数量由 JVM 和操作系统的实现决定。这里Java线程与操作系统本地线程两个层次的线程模型,存在两个层次的调度机制。所以在介绍Java内置线程池之前,先一起了解Java多线程的两级调度模型。
Java多线程的两级调度模型
Java 多线程的两级调度模型是指在 Java 多线程程序中,存在两个层次的调度机制:用户级调度和操作系统级调度。
用户级调度:(用户级线程 ULT)
- 用户级线程由 Java 虚拟机(JVM)自身管理,不需要操作系统内核的介入。
- 用户级调度是指由应用程序自身实现的线程调度机制。在这一级别上,应用程序可以使用 Java 提供的并发编程工具(例如 Executor 框架、线程池等用户级的调度器)来管理和调度线程。应用程序根据自身的需求,将任务分配给可用的Java线程,决定线程的执行顺序和优先级等。Java 线程是由 JVM 提供的线程管理机制来调度执行的,所以用户级调度是在 Java 层面上进行的,不涉及操作系统的线程调度。
操作系统级调度:(内核级线程 KLT)
- 内核级线程是由操作系统内核管理的真正的系统级线程。
- 操作系统级调度是指操作系统内核对线程进行的调度和执行。在这一级别上,操作系统负责将Java线程映射到底层的操作系统线程(本地操作系统线程),并分配 CPU 进行实际的计算。操作系统线程模型可以利用操作系统提供的并发机制和资源管理功能,如线程调度器、线程优先级、时间片轮转等,以确保公平性和资源利用的最大化。
两级调度模型的优势:
Java 多线程的两级调度模型的优势在于结合了用户级调度和操作系统级调度的特点:
- 用户级调度使应用程序具有灵活性和可控性,可以根据具体需求自主管理和调度线程。Java 线程模型提供了高级的并发编程抽象,使得开发者可以更方便地编写并发程序,从而可以根据任务的特点和优先级,自主决定线程的创建、启动、休眠、唤醒等操作,实现更细粒度的线程调度。
- 操作系统级调度利用了底层操作系统的调度机制和资源管理功能,确保线程的公平性和高效利用。操作系统级调度可以根据底层硬件的特点和系统负载情况,智能地分配线程执行的资源,提高性能和资源利用率。
- 用户级线程的创建、切换和调度都发生在 JVM 内部,效率较高;内核级线程的创建、切换和调度需要操作系统内核的参与,开销相对较大。
- 当一个用户级线程阻塞(如进行 I/O 操作)时,如果是单线程,整个进程也会被阻塞,无法利用其他可用的 CPU 资源;在多线程情况下,JVM 会将其挂起,并将另一个可运行的用户级线程映射到内核级线程上继续执行,避免了进程级的阻塞,提高整体并发性能。
Executor 框架
上面讲述了 Java 多线程的两级调度模型,对于Java编程,我们只需关注用户级调度(Java线程)。Java 多线程管理是由在 Java 内置的 Executor 框架下完成的。Executor 框架是 Java 5 引入的一套用于异步任务执行的API,提供了一种简化线程管理和任务执行的方式,将任务的提交和执行分离开来。
Executor 框架的组成概念
首先简单介绍一下 Executor 框架的组成概念。
- 任务:任务指的是被异步执行的工作单元(工作代码),任务需要实现接口:Runable 接口 or Callable 接口;
- Runable 接口:只定义了一个没有返回值的 run() 方法,用于封装需要执行的任务代码。
- Callable 接口:只定义了一个带返回值的 call() 方法,用于封装需要执行的任务代码并返回结果。
- 任务执行器:负责实际执行任务的组件,包括任务执行机制的核心接口 Executor,以及继承自 Executor 的 ExecutorService 接口。Executor 框架有几个关键类实现了 ExecutorService 接口:ThreadPoolExecutor 和 ScheduledThreadPoolExecutor、ForkJoinPool;
- Executor 接口:任务执行器的核心接口,定义了一个简单的 execute(Runnable command) 方法用于异步执行任务。
- ExecutorService 接口:继承自 Executor 接口,添加了更丰富的任务提交和生命周期管理方法,如 submit(Callable task)、submit(Runnable task) 、shutdown() 等。
- submit(Callable task)方法返回一个Future对象,可以通过Future对象的get()方法获取任务的返回值。
- submit(Runnable task)方法返回一个Future对象,但该Future对象的get()方法返回null。
- ThreadPoolExecutor 类:是 Executor 框架中最重要的任务执行器实现,实现了 ExecutorService 接口。提供了一个可配置的线程池,用于执行异步任务。
- ScheduledThreadPoolExecutor 类:继承自 ThreadPoolExecutor,额外提供了定时和周期性任务执行的功能。
- ForkJoinPool 类:提供了一个支持工作窃取算法的线程池实现,适用于分治类型的并行计算。
- 获取任务执行结果:包括 Future 接口和实现 Future 接口的 FutureTask 类、ForkJoinTask 类。
- Future 接口:获取任务执行结果核心接口,定义获取异步任务执行结果的相关方法。当提交 Callable 任务时,会返回一个 Future 对象。通过这个 Future 对象对应方法可以查询任务的执行状态、获取执行结果、取消任务等。还提供了丰富的 API,如 get()、isDone()、cancel() 等,用于管理异步任务的生命周期。
- FutureTask 类:Future 接口的一个具体实现类。
- ForkJoinTask 抽象类:继承自 Future 接口,用于支持 ForkJoinPool 中任务的异步计算。ForkJoinTask 提供了 fork() 和 join() 方法,用于将任务拆分并行执行,然后合并结果。
Executor框架的使用示意图:
Executor 框架中任务执行的两个阶段:任务提交和任务执行
前面分析 Executor 框架时,Executor 接口只有一个执行任务的方法 execute(),ExecutorService 接口增加了一个提交任务的方法 submit()。从系统层面来讲,这两个方法都是任务提交方法,如果任务只是简单的异步执行,不需要返回值或处理异常,可以直接使用 execute() 进行任务提交;如果任务需要返回值或处理异常,则使用 submit() 方法进行提交。再深入代码研究,submit() 方法最终还是会调到 execute() 实现最终的任务提交,可以理解 submit() 在任务提交前进行了一系列处理,使其能够在任务执行完后获得返回值或处理异常。submit() 方法在 AbstractExecutorService 抽象类中由默认实现,execute() 在 ThreadPoolExecutor 类中实现。最后,Executor 根据线程池的配置和调度策略分配线程执行任务。
ThreadPoolExecutor 继承 AbstractExecutorService 实现 ExecutorService。
两个阶段:
-
任务提交阶段:在任务提交阶段,应用程序通过调用 Executor 类的 execute(Runnable command) 或 ExecutorService 的 submit(Callable task) 方法将任务提交给 Executor。在这个阶段,任务被封装成一个任务对象,可以是 Runnable 对象或 Callable 对象,并被添加到任务队列中等待执行。当任务被提交后,Executor 实现类会对任务进行排队和调度。
-
任务执行阶段:在任务执行阶段,Executor 根据线程池的配置和调度策略,从它管理的线程池中分配一个空闲线程来执行任务。线程从任务队列中取出任务,并调用任务的 run() 或 call() 方法来执行任务。任务执行完成后,线程会被返回到线程池中,等待被再次分配执行新的任务。
任务提交阶段可以根据应用程序的需要灵活地控制任务的产生和提交,任务执行阶段则由 Executor 框架负责管理和调度线程的执行,从而实现更高的并发性能和更好地利用系统资源。这种将任务的提交和执行解耦,开发者只需关注任务的定义和提交,而无需过多地关注任务的执行细节。可以根据实际需求来调整任务队列的容量、任务队列的类型、拒绝策略以及线程池的配置(线程池的大小)等参数。
new Thread 创建线程与任务执行:
在学习 Java 线程时,都曾看到过Java创建多线程的三种方式:继承Thread类、实现Runnable接口以及实现Callable接口。经过前面的分析,Runnable接口和Callable接口都是代表任务接口,封装要执行的任务代码。实现Runnable接口以及实现Callable接口两种方式严格来说不是创建线程,而真正创建多线程的方式只有一种:继承Thread类,只有通过 new Thread().start() 这种方式才能真正的创建Java线程并映射到操作系统的内核线程上。
其实上述三种方式更好的理解是创建并提交任务的三种方式,相比之下:
- 解耦任务逻辑:直接创建 Thread 对象会将任务逻辑和线程管理耦合在一起,不利于代码的维护和扩展;而使用 Runnable 或 Callable 接口可以将任务的逻辑与线程的管理分离开来,使得代码更加模块化和可复用。
- 线程池管理:经过前面的分析,使用 Runnable 或 Callable 接口可以将任务提交给线程池(ExecutorService)进行管理和执行,多个任务可以方便复用多个线程执行处理,从而避免频繁创建和销毁线程的开销;而直接创建 Thread 对象,需要自己管理线程的生命周期,比较麻烦。
- 任务结果和异常处理:Callable接口可以返回任务的执行结果和异常处理,Runnable接口虽然不能返回结果,但也可以通过Future对象获取任务的执行状态和异常信息,而直接创建Thread对象,需要自己处理任务的返回值和异常。