0%

第1章:多线程原理与实战

进程和线程

进程是操作系统资源分配的最小单位,线程是 CPU 调度的最小单位。

一个标准的线程主要由3部分构成:线程描述信息(线程ID、线程名称、线程状态、线程优先级、其他)、程序计数器 和 栈内存。其中,程序计数器它记录着线程下一条指令的代码段内存地址;每个线程在创建的时候默认被分配 1M 大小的栈内存大小,和堆内存不同,栈内存不受垃圾回收器管理

当线程执行流程进入方法时,JVM 就会为方法分配一个对应的栈帧压入栈内存;当线程流程跳出方法时,JVM 就从栈内存弹出该方法的栈帧,此时方法帧的局部变量的内存空间就会被回收。

创建线程的4种方法

Thread 类中有个属性, private boolean deamon = false; 该属性保存 Thread 线程实例的守护状态,默认为false ,表示普通用户线程,而不是守护线程。 守护线程是在进程运行提供某种后台服务的线程,比如GC 线程。

  • 继承Thead 类创建线程类(Thread 本身就实现了 Runnable )

  • 实现 Runnable 接口创建线程目标类(传入 Thread 中时,Thread将其以 target 字段保存)

  • 使用 Callable 和 FutureTask 创建线程

  • 通过线程池创建

前面2种创建方法有一个共同的缺陷:不能获取异步执行的结果。在此基础上,才有了使用 Callable 和 FutureTask 创建线程的方法。

Callable 接口

Callable 是个泛型接口,它的源码如下:

1
2
3
public interface Callable<V> {
V call() throws Exception;
}

Callable 接口类似与 Runnable ,不同的是 Runnable 的run 方法没有返回值,也没有接收异常的异常声明,因此,感觉 Callable 更强大一些,只是 Callable 暂时还没能和 Thread 产生关系。不过,一个在 Callable 接口与 Thread 线程之间搭桥的重要接口 RunnableFuture 接口就要登场了。

RunnableFuture 接口

RunnableFuture 接口与 Runnable 接口、Thread 类紧密相关,源码如下:

1
2
3
public interface RunnableFuture<V> extends Runnable, Future<V> {
void run();
}

可以看出,它继承了 Runnable 接口,从而可以作为 Thread 的 target ,同时还继承了 Future 接口,保证了可以获取未来的异步执行结果。

Future 接口

Future 接口至少提供了三大功能:

  • 能够取消异步执行中的任务

  • 判断异步任务是否执行完成

  • 获取有任务完成后的执行结果

它的源码如下:

1
2
3
4
5
6
public interface Future<V> {
boolean isCancelled();
boolean isDone();
V get();
V get(long timeout, TimeUnit unit) throws InterruptedException
}

虽然它提供了这么多功能,但它终归只是个接口,通过它没法直接完成对异步任务的操作,为此,JDK 提供了一个默认实现类: FutureTask 。

FutureTask 类

FutureTask类实现了RunnableFuture接口,相当于既实现了 Runnable 接口,又实现了 Future 接口。所以FutureTask既能作为一个 Runnable 类型的 target 被Thread执行,又能作为Future异步任务来获取Callable的计算结果。

使用Callable和FutureTask创建线程的步骤

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public static void main(String args[]) throws InterruptedException, ExecutionException {
FutureTask<Long> futureTask = new FutureTask<Long>(new Callable<Long>() {

@Override
public Long call() throws Exception {
long startTime = System.currentTimeMillis();
Thread.sleep(1000);
return System.currentTimeMillis() - startTime;
}
});
Thread thread = new Thread(futureTask, "returnableThread");
thread.start();

System.out.println(thread.getName() + "线程占用时间:" + futureTask.get());
}

returnableThread线程首先执行 thread.run() 方法,然后在其中执行其target(futureTask任务)的run()方法;接着在futureTask.run()方法中会执行 callable 成员的 call()方法。Callable的call()方法执行完成后,会将结果保存在FutureTask内部的outcome实例属性中。这里有两种情况:

  • futureTask的结果outcome不为空,callable.call()执行完成,futureTast.get会直接取回outcome结果返回给结果获取线程。

  • futureTask的结果outcome为空,callable.call()还没有执行完。则结果获取线程会被阻塞住直到callable.call()执行完成。当执行完后,最终结果会保存到outcome中,futureTask会唤醒结果获取线程。

1.4 线程的核心原理

1.4.1 线程的调度与时间片

在不同的操作系统、不同的CPU上,线程的 CPU 时间片长度都不同,windows xp 的时间片长度为 20 毫秒,线程调度模型主要分为 2 种:

  • 分时调度。系统平均分配 CPU 的时间片,人人平等

  • 抢占式调度。按照线程的优先级分配时间片,如果大家优先级都相同,就随机选择一个,目前大部分操作系统选择的是这种调度方式

1.4.2 线程优先级

对于优先级,有以下结论:

  • 整体而言,高优先级的线程获得的执行机会更多

  • 执行机会的获取具有随机性,优先级高的不一定获得机会多(文中的例子10级的优先级比9级的优先级获得的机会还少,个人认为可能的一个因素是:Java的线程映射到系统线程时,10级与 9 级并没有区分)

1.4.3 线程的生命周期

Java 的线程有6种状态:

  • NEW: 新建,创建成功,但是没有调用 start() 方法

  • RUNNABLE:可执行,包含操作系统的就绪、运行2种状态

  • BLOCKED:阻塞

  • WAITTING:等待

  • TIMED_WAITTING:限时等待,包括Thread.sleep(n)、Object.wait(n)、Thread.join(n)、LockSupport.parkNanos(n)、LockSupport.parkUntil(n) 等

  • TERMINATED:终止。线程的 run 方法执行完毕(或者执行run方法被异常终止)

1.4.5 使用 Jstack 工具查看线程状态

Jstack 是Java 虚拟机自带的 堆栈跟踪工具,用于生成或者导出(Dump)JVM 运行实例当前时刻的线程快照。命令的语法格式如下:

jstack //pid表示 Java 进程的 id,可以用 jps 命令查看

1.5 线程的基本操作

1.5.2 线程的sleep 操作

sleep 的作用是让线程休眠,让 CPU 执行其他的任务,从状态来讲就是从执行状态变成限时阻塞状态。当睡眠时间满后,线程不一定立即得到执行,因为此时CPU可能正在执行其他任务,所以还需要等待分配时间片。

1.5.3 线程的 interrupt 操作

Java 语言提供了 stop() 方法终止正在运行的方法,但后来不建议使用了,因为这像突然关闭计算机电源一样,无法知道这个线程在处于什么状态,它可能持有某把锁,强行停止可能会导致锁无法释放;或者可能正在操作数据库,强行停止导致数据不一致。

一个线程什么时候可以退出只有线程自己直到,所以,这里介绍的 interrupt 方法本质不是用来中断一个线程,而是将线程设置为中断状态。当我们调用线程的 interrupt 方法时,它有2个作用:

  • 如果此线程处于阻塞状态,就会立马退出阻塞,并抛出 InterruptedException 异常,线程可以通过捕获 InterruptedException 异常来做一定处理,从而提早终结被阻塞状态。

  • 如果线程正在运行,就不受任何影响,仅仅只是中断标记被置为 true 了。

如果 interrupt()方法先被调用,然后线程开始调用阻塞方

法进入阻塞状态,InterruptedException异常依旧会抛出;如果线程

捕获InterruptedException异常后,继续调用阻塞方法,将不再触发

InterruptedException异常。

1.5.4 线程的join操作

也就是线程的合并操作,可以用一个例子来说明: 假设线程 A 和 B,现在 A 执行过程中对 B 的执行有依赖:A 需要将 B 的执行流程合并到自己执行的流程中,这就是线程合并,伪代码大概如下:

1
2
3
4
5
6
class ThreadA extends Thread {
public void run() {
Thread threadb = new Thread("thread-b");
threadb.join();
}
}

上述代码中,执行 threadb.join() 这行代码的当前线程为合并线程(线程 A)会进入 TIMED_WAITING 等待状态,让出 CPU。所以上述过程应该是等 B线程执行完成之后,A 线程再继续执行

1.5.5 线程的 yield 操作

线程的 yield(让步)操作的作用是让目前正在执行的线程放弃当前的执行,让出 CPU执行权限,之后变为 RUNNABLE 状态,从操作系统层面来讲就是进入了就绪状态(而不是阻塞)。在线程 yield 时,线程放弃和重占 CPU 的时间是不确定的,可能刚放弃了 CPU ,马上又获得了 CPU 的执行权限,接着开始执行了。

1.5.6 线程的daemon操作

守护线程也称为后台线程,专门指在程序进程运行过程中,在后台提供某种通用服务的线程。比如,每启动一个JVM进程,都会在后台运行一系列的 GC 线程,这些 GC 线程就是守护线程。

只要 JVM 中尚存任何一个用户线程没有结束,守护线程就能执行自己的工作,只有当最后一个用户线程结束,守护线程随同 JVM 一同结束工作。

守护线程的要点

使用守护线程时,有几点需要特别注意:

  • 线程必须在start() 方法调用前设置其为守护线程(即调用setDaemon(true)方法) ,否则会抛出 InterruptedException异常

  • 在守护线程中创建的线程,新的线程都是守护线程。如果要创建用户线程,需要显式调用 setDaemon(false)

1.6 线程池原理与实战

Java 线程的创建非常昂贵,需要 JVM 和 OS 配合完成大量工作:

  • 为线程堆栈分配大量内存快,包括至少 1M 的栈内存
  • 需要进行系统调用,以便在OS中创建和注册本地线程

而线程池的出现主要解决了以下问题:

  • 提升性能:最大限度地复用已经创建的线程,避免创建和销毁,提升性能

  • 线程管理:线程池可以保持对线程的统计信息,例如完成任务数量、空闲时间等,能对异步任务进行高效调度(我个人觉得还有可能根据cpu的核心数之类的确定线程数量)。

1.6.1 JUC 的线程池架构

JUC 是 java.util.concrrent 工具包的简称,是用于完成高并发、处理多线程的一个工具包。其中的线程池类与接口的架构图大致如下:

线程池类与接口的架构图

1.6.2 Executors 的4种快捷创建线程池的方法

Java 通过 Excutors 工厂类提供了4种快捷创建线程池的方法,如下表所示:

方法名 功能简介
newSingleThreadExcutor 创建只有一个线程的线程池
newFixedThreadPool 创建固定大小的线程池
newCachedThreadPool 创建一个不限制线程数量的线程池,任何提交的任务都立即执行,但是空闲线程会得到及时回收
newScheduledThreadPool 创建一个可定期或者延时执行的任务的线程池

总的来说,newSingleThreadExcutor 线程池有以下特点:

  • 线程池中的任务是按照提交的顺序执行的

  • 池中的唯一线程的存活时间是无限的

  • 当池中唯一线程繁忙时,新提交的任务会进入内部的阻塞队列,这个队列是无界队列

线程池使用完后,要调用 shutDown 来关闭线程池,此时线程池将变为 SHUTDOWN 状态,将拒绝新任务,添加新任务会抛出 RejectedExecutionException 异常,此时,线程池不会立刻退出,直到线程池中的任务都执行完成。还有另一个方法 shutdownNow ,执行之后线程状态会立刻变成 STOP ,并且不再处理了还在阻塞队列中等待的任务,会返回哪些未执行的任务。

newFixedThreadPool用于创建一个“固定数量的线程池”,它的特点如下:

  • 提交任务时,如果显成熟没有达到固定数量,线程池内就会创建一个新线程,直到达到固定数量
  • 提交新任务时,如果所有的线程都在繁忙,则新任务会进入阻塞队列,该队列是无界的阻塞队列

“固定数量的线程池”适合场景: 需要任务长期执行的场景,比如处理CPU密集型任务。线程数能够比较稳定地维持在某个数量,避免频繁回收和创建线程。

newCachedThreadPool 创建“可缓存线程池”,如果线程池内某些线程成为空闲线程,“可缓存线程池”能灵活回收这些线程。它的特点大致如下:

  • 接收新任务时,如果池内所有线程繁忙,则添加新线程来处理
  • 对线程池大小没有限制,完全依赖于JVM能够创建的最大线程大小
  • 如果线程空闲(60s不执行任务),就会回收

它的适合场景:需要快速处理突发性强,耗时短的任务场景,如 Netty 的 NIO 处理场景、REST API 接口瞬时削峰场景。

newScheduledThreadPool 创建“可调度线程池”,它提供一个“延时”和“周期性”任务调度功能的ScheduledExecutorService类型的线程池。使用方法如下:

1
2
3
ScheduledExecutorService scheduled = Executors.newScheduledThreadPool(2);
// 参数中:0表示首次执行任务的延迟时间,500表示每次执行任务的间隔时间
scheduled.scheduleAtFixedRate(new TargetTask(), 0, 500, TimeUnit.MILLISECONDS);

“可调度线程池”的适用场景:周期性地执行任务的场景。

1.6.3 线程池的标准创建方式

大部分企业的开发规范会禁止时候用上述Java 提供的4种创建线程池的方式。,要求通过标准创建方式自行创建。标准创建的一个构造方法如下:

1
2
3
4
5
6
7
8
// 使用标准构造器构造一个普通的线程池
public ThreadPoolExecutor(
int corePoolSize, // 核心线程数,即使线程空闲(Idle),也不会回收
int maximumPoolSize, // 线程数的上限
long keepAliveTime, TimeUnit unit, // 线程最大空闲(Idle)时长
BlockingQueue<Runnable> workQueue, // 任务的排队队列
ThreadFactory threadFactory, // 新线程的产生方式
RejectedExecutionHandler handler) // 拒绝策略
corePoolSize 与 maximumPoolSize

corePoolSize 与 maximumPoolSize 自动维护线程池中的工作线程,规则如下:

  • 收到新任务,并且当前线程数少于 corePoolSize,即使其他线程处于空闲状态,也会创建新线程来处理该请求
  • 核心线程不会被回收,但是如果设置了 allowCoreThreadTimeOut 的话,则会例外,这时候是可以被回收的
  • 如果当前线程数大于 corePoolSize ,但是小于 maximumPoolSize ,那么仅当任务排队队列已满才会创建新线程。
  • 当 maximumPoolSize 被设置为无界值(如Integer.MAX_VALUE,因为这是int的最大值了)时,线程池可以接受任意数量的并发任务
  • corePoolSize 与 maximumPoolSize 的值可以动态更改

根据上述规则可知,给 corePoolSize 和 maximumPoolSize 设置相同的值可以创建一个固定大小的线程池。

BlockingQueue

BlockingQueue 也就是阻塞队列,如果线程池的核心线程都在忙,则收到的任务都缓存在阻塞队列中

keepAliveTime

用于设置线程的最大 Idle 市场,如果非 Core 线程(默认情况下只针对非Core线程,但如果allowCoreThreadTimeOut 设置为true,则也会应用于Core线程)空闲超过这个时常,就会被回收。如果要防止 Idle 线程被回收,可以将 keepAliveTime 设置为 Long.MAX_VALUE。当然,这个值也是能动态调整的。

1.6.4 向线程池提交任务的2种方式

有 submit 和 execute 两种方式,这二者的区别:

  • submit 有返回值 Future ,execute 没有
  • 由于 submit 有返回值Future ,所以可以方便对当前任务处理 Exception
  • 入参不同,submit 可以接收 Callable、Runnable 两种,execute 只能接收 Runnable
1
2
3
4
5
6
7
//submit 结果获取和异常处理
try{
Future<Integer> future = pool.submit(callable);
Interger result = future.get();
} catch(Exception e) {
e.printStack();
}

1.6.5 线程池任务调度流程

任务调度流程大致如下:

  1. 接收任务时,如果当前线程数量小于核心线程数,则创建线程(哪怕其他线程空闲),然后执行任务
  2. 如果线程池中的线程大于核心线程数量,新任务将被加入阻塞队列,直到阻塞队列满了
  3. 完成一个任务后,优先从阻塞队列中获取下一个任务,直到阻塞队列为空
  4. 当核心线程繁忙,并且阻塞队列也已经满了,如果再接收到新任务,将会为新任务创建一个(非核心)线程,并立即开始执行新任务
  5. 当核心线程繁忙,阻塞队列满的情况下,来新任务会一直创建线程,当线程总数超过 maximumPoolSize 时就会执行拒绝策略。

1.6.6 ThreadFactory (线程工厂)

1.6.7 BlockingQueue(任务阻塞队列)

阻塞队列与普通队列相比有一个重要的特点: 一个线程从一个空的阻塞队列获取元素时会被阻塞(怎么实现的?),直到队列中有了元素;当队列中有了元素,被阻塞的线程会被自动唤醒。比较常用的实现类有以下几种:

  • ArrayBlockingQueue:数组实现的有界阻塞队列,队列中元素按照 FIFO 排序。
  • LinkedBlockingQueue:基于链表实现的阻塞队列,按照 FIFO 排序,可以设置容量(有界队列),不设置容量则默认使用 Integer.Max_VALUE 作为容量(即无界队列)
  • PriorityBlockingQueue: 具有优先级的无界队列
  • DelayQueue:无界阻塞延迟队列,底层基于 PriorityBlockingQueue,队列中每个元素都有过期时间,当从队列获取元素时,只有已经过期的元素才会出队,队列头部是做早过期的元素
  • SynchronousQueue :同步队列,不存储元素的阻塞队列,每个插入操作必须等到另一个线程的调用移除操作,否则插入一直处于阻塞状态。

Excutors.newScheduledThreadPool所创建的线程池就是使用 DelayQueue
而,Executors.newCachedThreadPool 所创建的线程池使用的是 SynchronousQueue

1.6.8 调度器钩子方法

  • beforeExecute: 执行目标实例前在工作线程异步执行该方法
  • afterExecute: 执行目标实例后在工作线程异步执行该方法
  • terminated: 线程池终止时的钩子方法

1.6.9 线程池的拒绝策略

当出现如下情况时,新提交的任务会被拒绝:

  1. 线程池已经被关闭
  2. 所有线程繁忙,并且线程数已经达到了 maximunPoolSize

当拒绝的时候,会调用 RejectedExecutionHandler 实例的 rejectedExecution 方法,JUC 提供了以下几种实现:

  • AbortPolicy : 新任务丢掉同时抛出 RejectedExecutionException 异常,这是线程池默认策略
  • DiscardPolicy: 新任务直接丢掉,并且不会抛出异常
  • DiscardOldestPolicy: 抛弃最早的任务,然后让新任务入队
  • CallerRunsPolicy: 调用者执行策略,提交任务线程会自己执行该任务
  • 自定义策略

1.6.10 线程池的优雅关闭

优雅地关闭线程池主要涉及的方法有 3 个:

  • shutDown: 这是有序关闭线程池的方法,调用之后当前线程会立即返回,不会等待线程池关闭完成。会等待当前工作队列中剩余的任务全部执行完毕,才关闭线程池。只要调用了这个方法,线程池会转为 SHUTDOWN 状态,不会再接收任务
  • shutDownNow :立即关闭线程池,调用之后当前线程会立即返回,不会等待线程池关闭完成。
  • awaitTermination: shutDown与shutDownNow调用后会立即返回,不管线程池的关闭。而这个方法就是用于等待线程池完成关闭。

优雅关闭线程池的方法:

  1. 调用 shutDown 方法,拒绝新任务提交
  2. 调用 awaitTermination(long timeOut) 指定超时时间,判断是否已经关闭
  3. 如果 awaitTermination 超时,就可以进入循环关闭,循环一定次数(比如1000次),不断关闭线程池,直到关闭或者结束
  4. 如果 awaitTermination 返回false 或者 被中断,调用 shutDownNow 立即关闭

1.6.11 Executors 快捷创建线程池的潜在问题(系统默认提供的4种方法)

  • newFixedThreadPool创建的固定大小线程池:它的阻塞队列是 LinkedBlockingQueue 类型的无界队列,可能导致大量的任务等待,队列太大还可能导致 OOM
  • 单线程的线程池也是无界队列,可能导致OOM
  • newCachedThreadPool 创建可缓存线程池:因为核心线程数 0 ,最大线程数为 Integer.MAX_VALUE,阻塞队列为 SynchronousQueue 同步队列,不缓存任务,所以理论上任务数量不受限制,相当于来一个任务首先寻找可用线程,没有的话,就创建一个新的。会导致创建的线程过多,可能造成 OOM ,甚至把 CPU资源耗尽
  • newScheduledThreadPool 创建可调度线程池,其最大问题是线程数量不设上限,可能导致CPU资源耗尽

1.7 确定线程池的线程数

1.7.1 按照任务类型分类

  • IO 密集型: IO任务操作时间长,导致 CPU 利用率不高,常处于空闲状态。线程数确定:设置allowCoreThreadTimeOut为true、使用有界队列、corePollSize 和 maximunPoolSize 保持一致,使得接收新任务而没有空闲线程时,直接创建新线程执行,而不是在阻塞队列中等待。
  • CPU 密集型: 主要是计算任务,CPU一直在运行,利用率很高。线程数确定:CPU密集型任务虽然可以并行完成,但是并行任务越多,花在任务切换的时间也越多,效率也就越低。CPU密集型任务并行执行的数量应当等于CPU的核心数
  • 混合性任务:既要逻辑计算,又要IO。线程数确定:业界有一个公式

1.8 ThreadLocal 原理与实战

ThreadLoacal 的应用场景大致有 2 类:

  • 线程隔离
  • 跨函数传递数据

原理:每一个线程在获取本地值时,都会将 ThreadLocal 实例作为 Key 从自己拥有的 ThreadLocalMap 中获取值,别的线程无法访问自己的 ThreadLocalMap ,自己也无法访问别人的。并且,由于 ThreadLocalMap 是线程私有的,当线程销毁时 ThreadLocalMap 也随之销毁,在一定程度上减少内存的消耗。

1.8.8 ThreadLocal 综合使用案例

ThreadLocal 使用不当会造成严重的内存泄露,为了避免这种情况,使用时应遵守以下原则:

  • 尽量使用 private static final 修饰ThreadLocal ,final为了避免他人修改、变更 ThreadLocal 变量的引用,使用static 为了确保全局唯一
  • ThreadLocal 使用完后务必调用 remove 方法,这是简单有效避免引发内存泄露问题的方法
谢谢你的鼓励