Java 并发 常见面试问题

本文最后更新于 2024年1月4日 下午

Java 并发常见面试问题

并发基础

什么是线程和进程?

何为进程?

进程是程序的一次执行过程,是系统运行程序的基本单位,因此进程是动态的。系统运行一个程序即是一个进程从创建,运行到消亡的过程。

在 Java 中,当我们启动 main 函数时其实就是启动了一个 JVM 的进程,而 main 函数所在的线程就是这个进程中的一个线程,也称主线程。

如下图所示,在 Windows 中通过查看任务管理器的方式,我们就可以清楚看到 Windows 当前运行的进程(.exe 文件的运行)。

进程示例图片-Windows

何为线程?

线程与进程相似,但线程是一个比进程更小的执行单位。一个进程在其执行的过程中可以产生多个线程。与进程不同的是同类的多个线程共享进程的方法区资源,但每个线程有自己的程序计数器虚拟机栈本地方法栈,所以系统在产生一个线程,或是在各个线程之间作切换工作时,负担要比进程小得多,也正因为如此,线程也被称为轻量级进程。

Java 程序天生就是多线程程序,我们可以通过 JMX 来看看一个普通的 Java 程序有哪些线程,代码如下。

1
2
3
4
5
6
7
8
9
10
11
12
public class MultiThread {
public static void main(String[] args) {
// 获取 Java 线程管理 MXBean
ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean();
// 不需要获取同步的 monitor 和 synchronizer 信息,仅获取线程和线程堆栈信息
ThreadInfo[] threadInfos = threadMXBean.dumpAllThreads(false, false);
// 遍历线程信息,仅打印线程 ID 和线程名称信息
for (ThreadInfo threadInfo : threadInfos) {
System.out.println("[" + threadInfo.getThreadId() + "] " + threadInfo.getThreadName());
}
}
}

上述程序输出如下(输出内容可能不同,不用太纠结下面每个线程的作用,只用知道 main 线程执行 main 方法即可):

1
2
3
4
5
[5] Attach Listener //添加事件
[4] Signal Dispatcher // 分发处理给 JVM 信号的线程
[3] Finalizer //调用对象 finalize 方法的线程
[2] Reference Handler //清除 reference 线程
[1] main //main 线程,程序入口

从上面的输出内容可以看出:一个 Java 程序的运行是 main 线程和多个其他线程同时运行

Java 线程和操作系统的线程有啥区别?

JDK 1.2 之前,Java 线程是基于绿色线程(Green Threads) 实现的,这是一种用户级线程(用户线程),也就是说 JVM 自己模拟了多线程的运行,而不依赖于操作系统。由于绿色线程和原生线程比起来在使用时有一些限制(比如绿色线程不能直接使用操作系统提供的功能如异步 I/O、只能在一个内核线程上运行无法利用多核),在 JDK 1.2 及以后,Java 线程改为基于原生线程(Native Threads) 实现,也就是说 JVM 直接使用操作系统原生的内核级线程(内核线程) 来实现 Java 线程,由操作系统内核进行线程的调度和管理。

我们上面提到了用户线程和内核线程,考虑到很多读者不太了解二者的区别,这里简单介绍一下:

  • 用户线程:由用户空间程序管理和调度的线程,运行在用户空间(专门给应用程序使用)。
  • 内核线程:由操作系统内核管理和调度的线程,运行在内核空间(只有内核程序可以访问)。

顺便简单总结一下用户线程和内核线程的区别和特点:用户线程创建和切换成本低,但不可以利用多核。内核态线程,创建和切换成本高,可以利用多核。

一句话概括 Java 线程和操作系统线程的关系:现在的 Java 线程的本质其实就是操作系统的线程

线程模型是用户线程和内核线程之间的关联方式,常见的线程模型有这三种:

  1. 一对一(一个用户线程对应一个内核线程)
  2. 多对一(多个用户线程映射到一个内核线程)
  3. 多对多(多个用户线程映射到多个内核线程)

常见的三种线程模型

在 Windows 和 Linux 等主流操作系统中,Java 线程采用的是一对一的线程模型,也就是一个 Java 线程对应一个系统内核线程。Solaris 系统是一个特例(Solaris 系统本身就支持多对多的线程模型),HotSpot VM 在 Solaris 上支持多对多和一对一。具体可以参考 R 大的回答: JVM 中的线程模型是用户级的么?

虚拟线程在 JDK 21 顺利转正,关于虚拟线程、平台线程(也就是我们上面提到的 Java 线程)和内核线程三者的关系可以阅读我写的这篇文章:Java 20 新特性概览

请简要描述线程与进程的关系, 区别及优缺点?

从 JVM 角度说进程和线程之间的关系。

图解进程和线程的关系

下图是 Java 内存区域,通过下图我们从 JVM 的角度来说一下线程和进程之间的关系。

Java 运行时数据区域(JDK1.8 之后)

从上图可以看出:一个进程中可以有多个线程,多个线程共享进程的方法区 (JDK 1.8 之后的元空间) 资源,但是每个线程有自己的程序计数器虚拟机栈本地方法栈

总结: 线程是进程划分成的更小的运行单位。线程和进程最大的不同在于基本上各进程是独立的,而各线程则不一定,因为同一进程中的线程极有可能会相互影响。线程执行开销小,但不利于资源的管理和保护;而进程正相反。

程序计数器为什么是私有的?

程序计数器主要有下面两个作用:

  1. 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
  2. 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。

需要注意的是,如果执行的是 native 方法,那么程序计数器记录的是 undefined 地址,只有执行的是 Java 代码时程序计数器记录的才是下一条指令的地址。

所以,程序计数器私有主要是为了线程切换后能恢复到正确的执行位置

虚拟机栈和本地方法栈为什么是私有的?

  • 虚拟机栈: 每个 Java 方法在执行之前会创建一个栈帧用于存储局部变量表、操作数栈、常量池引用等信息。从方法调用直至执行完成的过程,就对应着一个栈帧在 Java 虚拟机栈中入栈和出栈的过程。
  • 本地方法栈: 和虚拟机栈所发挥的作用非常相似,区别是:虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。

所以,为了保证线程中的局部变量不被别的线程访问到,虚拟机栈和本地方法栈是线程私有的。

一句话简单了解堆和方法区

堆和方法区是所有线程共享的资源,其中堆是进程中最大的一块内存,主要用于存放新创建的对象 (几乎所有对象都在这里分配内存),方法区主要用于存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

并发与并行的区别

  • 并发:两个及两个以上的作业在同一 时间段 内执行。
  • 并行:两个及两个以上的作业在同一 时刻 执行。

最关键的点是:是否是 同时 执行。

同步和异步的区别

  • 同步:发出一个调用之后,在没有得到结果之前,该调用就不可以返回,一直等待。
  • 异步:调用在发出之后,不用等待返回结果,该调用直接返回。

为什么要使用多线程?

先从总体上来说:

  • 从计算机底层来说: 线程可以比作是轻量级的进程,是程序执行的最小单位, 线程间的切换和调度的成本远远小于进程。另外,多核 CPU 时代意味着多个线程可以同时运行,这减少了线程上下文切换的开销。
  • 从当代互联网发展趋势来说: 现在的系统动不动就要求百万级甚至千万级的并发量,而多线程并发编程正是开发高并发系统的基础,利用好多线程机制可以大大提高系统整体的并发能力以及性能。

再深入到计算机底层来探讨:

  • 单核时代:在单核时代多线程主要是为了提高单进程利用 CPU 和 IO 系统的效率。假设只运行了一个 Java 进程的情况,当我们请求 IO 的时候,如果 Java 进程中只有一个线程,此线程被 IO 阻塞则整个进程被阻塞。CPU 和 IO 设备只有一个在运行,那么可以简单地说系统整体效率只有 50%。当使用多线程的时候,一个线程被 IO 阻塞,其他线程还可以继续使用 CPU。从而提高了 Java 进程利用系统资源的整体效率。
  • 多核时代: 多核时代多线程主要是为了提高进程利用多核 CPU 的能力。举个例子:假如我们要计算一个复杂的任务,我们只用一个线程的话,不论系统有几个 CPU 核心,都只会有一个 CPU 核心被利用到。而创建多个线程,这些线程可以被映射到底层多个 CPU 上执行,在任务中的多个线程没有资源竞争的情况下,任务执行的效率会有显著性的提高,约等于(单核时执行时间/CPU 核心数)。

使用多线程可能带来什么问题?

并发编程的目的就是为了能提高程序的执行效率提高程序运行速度,但是并发编程并不总是能提高程序运行速度的,而且并发编程可能会遇到很多问题,比如:内存泄漏、死锁、线程不安全等等。

如何理解线程安全和不安全?

线程安全和不安全是在多线程环境下对于同一份数据的访问是否能够保证其正确性和一致性的描述。

  • 线程安全指的是在多线程环境下,对于同一份数据,不管有多少个线程同时访问,都能保证这份数据的正确性和一致性。
  • 线程不安全则表示在多线程环境下,对于同一份数据,多个线程同时访问时可能会导致数据混乱、错误或者丢失。

单核 CPU 上运行多个线程效率一定会高吗?

单核 CPU 同时运行多个线程的效率是否会高,取决于线程的类型和任务的性质。一般来说,有两种类型的线程:CPU 密集型和 IO 密集型。CPU 密集型的线程主要进行计算和逻辑处理,需要占用大量的 CPU 资源。IO 密集型的线程主要进行输入输出操作,如读写文件、网络通信等,需要等待 IO 设备的响应,而不占用太多的 CPU 资源。

在单核 CPU 上,同一时刻只能有一个线程在运行,其他线程需要等待 CPU 的时间片分配。如果线程是 CPU 密集型的,那么多个线程同时运行会导致频繁的线程切换,增加了系统的开销,降低了效率。如果线程是 IO 密集型的,那么多个线程同时运行可以利用 CPU 在等待 IO 时的空闲时间,提高了效率。

因此,对于单核 CPU 来说,如果任务是 CPU 密集型的,那么开很多线程会影响效率;如果任务是 IO 密集型的,那么开很多线程会提高效率。 当然,这里的“很多”也要适度,不能超过系统能够承受的上限。

说说线程的生命周期和状态?

Java 线程在运行的生命周期中的指定时刻只可能处于下面 6 种不同状态的其中一个状态:

  • NEW: 初始状态,线程被创建出来但没有被调用 start()
  • RUNNABLE: 运行状态,线程被调用了 start() 等待运行的状态。
  • BLOCKED:阻塞状态,需要等待锁释放。
  • WAITING:等待状态,表示该线程需要等待其他线程做出一些特定动作(通知或中断)。
  • TIME_WAITING:超时等待状态,可以在指定的时间后自行返回而不是像 WAITING 那样一直等待。
  • TERMINATED:终止状态,表示该线程已经运行完毕。

线程在生命周期中并不是固定处于某一个状态而是随着代码的执行在不同状态之间切换。

Java 线程状态变迁图 (图源:挑错 |《Java 并发编程的艺术》中关于线程状态的三处错误):

Java 线程状态变迁图

由上图可以看出:线程创建之后它将处于 NEW(新建) 状态,调用 start() 方法后开始运行,线程这时候处于 READY(可运行) 状态。可运行状态的线程获得了 CPU 时间片(timeslice)后就处于 RUNNING(运行) 状态。

在操作系统层面,线程有 READY 和 RUNNING 状态;而在 JVM 层面,只能看到 RUNNABLE 状态(图源:HowToDoInJavaJava Thread Life Cycle and Thread States),所以 Java 系统一般将这两个状态统称为 RUNNABLE(运行中) 状态。

为什么 JVM 没有区分这两种状态呢? (摘自:Java 线程运行怎么有第六种状态? - Dawell 的回答 ) 现在的时分(time-sharing)多任务(multi-task)操作系统架构通常都是用所谓的“时间分片(time quantum or time slice)”方式进行抢占式(preemptive)轮转调度(round-robin 式)。这个时间分片通常是很小的,一个线程一次最多只能在 CPU 上运行比如 10-20 ms 的时间(此时处于 running 状态),也即大概只有 0.01 秒这一量级,时间片用后就要被切换下来放入调度队列的末尾等待再次调度。(也即回到 ready 状态)。线程切换的如此之快,区分这两种状态就没什么意义了。

RUNNABLE-VS-RUNNING

  • 当线程执行 wait() 方法之后,线程进入 WAITING(等待) 状态。进入等待状态的线程需要依靠其他线程的通知才能够返回到运行状态。
  • TIMED_WAITING (超时等待) 状态相当于在等待状态的基础上增加了超时限制,比如通过 sleep(long millis) 方法或 wait(long millis) 方法可以将线程置于 TIMED_WAITING 状态。当超时时间结束后,线程将会返回到 RUNNABLE 状态。
  • 当线程进入 synchronized 方法/块或者调用 wait 后(被 notify)重新进入 synchronized 方法/块,但是锁被其它线程占有,这个时候线程就会进入 BLOCKED(阻塞) 状态。
  • 线程在执行完了 run() 方法之后将会进入到 TERMINATED(终止) 状态。

相关阅读:线程的几种状态你真的了解么?

什么是线程上下文切换?

线程在执行过程中会有自己的运行条件和状态(也称上下文),比如上文所说到过的程序计数器,栈信息等。当出现如下情况的时候,线程会从占用 CPU 状态中退出。

  • 主动让出 CPU,比如调用了 sleep(), wait() 等。
  • 时间片用完,因为操作系统要防止一个线程或者进程长时间占用 CPU 导致其他线程或者进程饿死。
  • 调用了阻塞类型的系统中断,比如请求 IO,线程被阻塞。
  • 被终止或结束运行

这其中前三种都会发生线程切换,线程切换意味着需要保存当前线程的上下文,留待线程下次占用 CPU 的时候恢复现场。并加载下一个将要占用 CPU 的线程上下文。这就是所谓的 上下文切换

上下文切换是现代操作系统的基本功能,因其每次需要保存信息恢复信息,这将会占用 CPU,内存等系统资源进行处理,也就意味着效率会有一定损耗,如果频繁切换就会造成整体效率低下。

什么是线程死锁? 如何避免死锁?

认识线程死锁

线程死锁描述的是这样一种情况:多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止。

如下图所示,线程 A 持有资源 2,线程 B 持有资源 1,他们同时都想申请对方的资源,所以这两个线程就会互相等待而进入死锁状态。

线程死锁示意图

下面通过一个例子来说明线程死锁, 代码模拟了上图的死锁的情况 (代码来源于《并发编程之美》):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public class DeadLockDemo {
private static Object resource1 = new Object();//资源 1
private static Object resource2 = new Object();//资源 2

public static void main(String[] args) {
new Thread(() -> {
synchronized (resource1) {
System.out.println(Thread.currentThread() + "get resource1");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread() + "waiting get resource2");
synchronized (resource2) {
System.out.println(Thread.currentThread() + "get resource2");
}
}
}, "线程 1").start();

new Thread(() -> {
synchronized (resource2) {
System.out.println(Thread.currentThread() + "get resource2");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread() + "waiting get resource1");
synchronized (resource1) {
System.out.println(Thread.currentThread() + "get resource1");
}
}
}, "线程 2").start();
}
}

Output

1
2
3
4
Thread[线程 1,5,main]get resource1
Thread[线程 2,5,main]get resource2
Thread[线程 1,5,main]waiting get resource2
Thread[线程 2,5,main]waiting get resource1

线程 A 通过 synchronized (resource1) 获得 resource1 的监视器锁,然后通过 Thread.sleep(1000); 让线程 A 休眠 1 s 为的是让线程 B 得到执行然后获取到 resource 2 的监视器锁。线程 A 和线程 B 休眠结束了都开始企图请求获取对方的资源,然后这两个线程就会陷入互相等待的状态,这也就产生了死锁。

上面的例子符合产生死锁的四个必要条件:

  1. 互斥条件:该资源任意一个时刻只由一个线程占用。
  2. 请求与保持条件:一个线程因请求资源而阻塞时,对已获得的资源保持不放。
  3. 不剥夺条件: 线程已获得的资源在未使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源。
  4. 循环等待条件: 若干线程之间形成一种头尾相接的循环等待资源关系。

如何预防和避免线程死锁?

如何预防死锁? 破坏死锁的产生的必要条件即可:

  1. 破坏请求与保持条件:一次性申请所有的资源。
  2. 破坏不剥夺条件:占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。
  3. 破坏循环等待条件:靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。破坏循环等待条件。

如何避免死锁?

避免死锁就是在资源分配时,借助于算法(比如银行家算法)对资源分配进行计算评估,使其进入安全状态。

安全状态 指的是系统能够按照某种线程推进顺序(P 1、P 2、P 3……Pn)来为每个线程分配所需资源,直到满足每个线程对资源的最大需求,使每个线程都可顺利完成。称 <P1、P2、P3.....Pn> 序列为安全序列。

我们对线程 2 的代码修改成下面这样就不会产生死锁了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
new Thread(() -> {
synchronized (resource1) {
System.out.println(Thread.currentThread() + "get resource1");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread() + "waiting get resource2");
synchronized (resource2) {
System.out.println(Thread.currentThread() + "get resource2");
}
}
}, "线程 2").start();

输出:

1
2
3
4
5
6
7
8
Thread[线程 1,5,main]get resource1
Thread[线程 1,5,main]waiting get resource2
Thread[线程 1,5,main]get resource2
Thread[线程 2,5,main]get resource1
Thread[线程 2,5,main]waiting get resource2
Thread[线程 2,5,main]get resource2

Process finished with exit code 0

我们分析一下上面的代码为什么避免了死锁的发生?

线程 1 首先获得到 resource 1 的监视器锁, 这时候线程 2 就获取不到了。然后线程 1 再去获取 resource 2 的监视器锁,可以获取到。然后线程 1 释放了对 resource 1、resource 2 的监视器锁的占用,线程 2 获取到就可以执行了。这样就破坏了破坏循环等待条件,因此避免了死锁。

Sleep () 方法和 wait () 方法对比

共同点:两者都可以暂停线程的执行。

区别

  • sleep() 方法没有释放锁,而 wait() 方法释放了锁
  • wait() 通常被用于线程间交互/通信,sleep() 通常被用于暂停执行。
  • wait() 方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的 notify() 或者 notifyAll() 方法。sleep() 方法执行完成后,线程会自动苏醒,或者也可以使用 wait(long timeout) 超时后线程会自动苏醒。
  • sleep()Thread 类的静态本地方法,wait() 则是 Object 类的本地方法。为什么这样设计呢?下一个问题就会聊到。

为什么 wait () 方法不定义在 Thread 中?

wait() 是让获得对象锁的线程实现等待,会自动释放当前线程占有的对象锁。每个对象(Object)都拥有对象锁,既然要释放当前线程占有的对象锁并让其进入 WAITING 状态,自然是要操作对应的对象(Object)而非当前的线程(Thread)。

类似的问题:为什么 sleep() 方法定义在 Thread 中?

因为 sleep() 是让当前线程暂停执行,不涉及到对象类,也不需要获得对象锁。

可以直接调用 Thread 类的 run 方法吗?

new 一个 Thread,线程进入了新建状态。调用 start() 方法,会启动一个线程并使线程进入了就绪状态,当分配到时间片后就可以开始运行了。 start() 会执行线程的相应准备工作,然后自动执行 run() 方法的内容,这是真正的多线程工作。但是,直接执行 run() 方法,会把 run() 方法当成一个 main 线程下的普通方法去执行,并不会在某个线程中执行它,所以这并不是多线程工作。

总结:调用 start() 方法方可启动线程并使线程进入就绪状态,直接执行 run() 方法的话不会以多线程的方式执行。

JMM (Java 内存模型)

JMM(Java 内存模型)相关的问题比较多,也比较重要,于是我单独抽了一篇文章来总结 JMM 相关的知识点和问题:JMM(Java 内存模型)详解

Volatile 关键字

如何保证变量的可见性?

在 Java 中,volatile 关键字可以保证变量的可见性,如果我们将变量声明为 volatile ,这就指示 JVM,这个变量是共享且不稳定的,每次使用它都到主存中进行读取

JMM(Java 内存模型)

JMM(Java 内存模型)强制在主存中进行读取

volatile 关键字其实并非是 Java 语言特有的,在 C 语言里也有,它最原始的意义就是禁用 CPU 缓存。如果我们将一个变量使用 volatile 修饰,这就指示编译器,这个变量是共享且不稳定的,每次使用它都到主存中进行读取

volatile 关键字能保证数据的可见性,但不能保证数据的原子性。synchronized 关键字两者都能保证。

如何禁止指令重排序?

在 Java 中,volatile 关键字除了可以保证变量的可见性,还有一个重要的作用就是防止 JVM 的指令重排序。 如果我们将变量声明为 volatile ,在对这个变量进行读写操作的时候,会通过插入特定的 内存屏障 的方式来禁止指令重排序。

在 Java 中,Unsafe 类提供了三个开箱即用的内存屏障相关的方法,屏蔽了操作系统底层的差异:

1
2
3
public native void loadFence();
public native void storeFence();
public native void fullFence();

理论上来说,你通过这个三个方法也可以实现和 volatile 禁止重排序一样的效果,只是会麻烦一些。

下面我以一个常见的面试题为例讲解一下 volatile 关键字禁止指令重排序的效果。

面试中面试官经常会说:“单例模式了解吗?来给我手写一下!给我解释一下双重检验锁方式实现单例模式的原理呗!”

双重校验锁实现对象单例(线程安全)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Singleton {
private volatile static Singleton uniqueInstance;
private Singleton() {
}
public static Singleton getUniqueInstance() {
//先判断对象是否已经实例过,没有实例化过才进入加锁代码
if (uniqueInstance == null) {
//类对象加锁
synchronized (Singleton.class) {
if (uniqueInstance == null) {
uniqueInstance = new Singleton();
}
}
}
return uniqueInstance;
}
}

uniqueInstance 采用 volatile 关键字修饰也是很有必要的, uniqueInstance = new Singleton(); 这段代码其实是分为三步执行:

  1. uniqueInstance 分配内存空间
  2. 初始化 uniqueInstance
  3. uniqueInstance 指向分配的内存地址

但是由于 JVM 具有指令重排的特性,执行顺序有可能变成 1->3->2。指令重排在单线程环境下不会出现问题,但是在多线程环境下会导致一个线程获得还没有初始化的实例。例如,线程 T 1 执行了 1 和 3,此时 T 2 调用 getUniqueInstance () 后发现 uniqueInstance 不为空,因此返回 uniqueInstance,但此时 uniqueInstance 还未被初始化。

Volatile 可以保证原子性么?

volatile 关键字能保证变量的可见性,但不能保证对变量的操作是原子性的。

我们通过下面的代码即可证明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/**
* 微信搜 JavaGuide 回复"面试突击"即可免费领取个人原创的 Java 面试手册
*
* @author Guide哥
* @date 2022/08/03 13:40
**/
public class VolatoleAtomicityDemo {
public volatile static int inc = 0;

public void increase() {
inc++;
}

public static void main(String[] args) throws InterruptedException {
ExecutorService threadPool = Executors.newFixedThreadPool(5);
VolatoleAtomicityDemo volatoleAtomicityDemo = new VolatoleAtomicityDemo();
for (int i = 0; i < 5; i++) {
threadPool.execute(() -> {
for (int j = 0; j < 500; j++) {
volatoleAtomicityDemo.increase();
}
});
}
// 等待1.5秒,保证上面程序执行完成
Thread.sleep(1500);
System.out.println(inc);
threadPool.shutdown();
}
}

正常情况下,运行上面的代码理应输出 2500。但你真正运行了上面的代码之后,你会发现每次输出结果都小于 2500

为什么会出现这种情况呢?不是说好了,volatile 可以保证变量的可见性嘛!

也就是说,如果 volatile 能保证 inc++ 操作的原子性的话。每个线程中对 inc 变量自增完之后,其他线程可以立即看到修改后的值。5 个线程分别进行了 500 次操作,那么最终 inc 的值应该是 5*500=2500。

很多人会误认为自增操作 inc++ 是原子性的,实际上,inc++ 其实是一个复合操作,包括三步:

  1. 读取 inc 的值。
  2. 对 inc 加 1。
  3. 将 inc 的值写回内存。

volatile 是无法保证这三个操作是具有原子性的,有可能导致下面这种情况出现:

  1. 线程 1 对 inc 进行读取操作之后,还未对其进行修改。线程 2 又读取了 inc 的值并对其进行修改(+1),再将 inc 的值写回内存。
  2. 线程 2 操作完毕后,线程 1 对 inc 的值进行修改(+1),再将 inc 的值写回内存。

这也就导致两个线程分别对 inc 进行了一次自增操作后,inc 实际上只增加了 1。

其实,如果想要保证上面的代码运行正确也非常简单,利用 synchronizedLock 或者 AtomicInteger 都可以。

使用 synchronized 改进:

1
2
3
public synchronized void increase() {
inc++;
}

使用 AtomicInteger 改进:

1
2
3
4
5
public AtomicInteger inc = new AtomicInteger();

public void increase() {
inc.getAndIncrement();
}

使用 ReentrantLock 改进:

1
2
3
4
5
6
7
8
9
Lock lock = new ReentrantLock();
public void increase() {
lock.lock();
try {
inc++;
} finally {
lock.unlock();
}
}

乐观锁和悲观锁

什么是悲观锁?

悲观锁总是假设最坏的情况,认为共享资源每次被访问的时候就会出现问题 (比如共享数据被修改),所以每次在获取资源操作的时候都会上锁,这样其他线程想拿到这个资源就会阻塞直到锁被上一个持有者释放。也就是说,共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程

像 Java 中 synchronizedReentrantLock 等独占锁就是悲观锁思想的实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
public void performSynchronisedTask() {
synchronized (this) {
// 需要同步的操作
}
}

private Lock lock = new ReentrantLock();
lock.lock();
try {
// 需要同步的操作
} finally {
lock.unlock();
}

高并发的场景下,激烈的锁竞争会造成线程阻塞,大量阻塞线程会导致系统的上下文切换,增加系统的性能开销。并且,悲观锁还可能会存在死锁问题,影响代码的正常运行。

什么是乐观锁?

乐观锁总是假设最好的情况,认为共享资源每次被访问的时候不会出现问题,线程可以不停地执行,无需加锁也无需等待,只是在提交修改的时候去验证对应的资源(也就是数据)是否被其它线程修改了(具体方法可以使用版本号机制或 CAS 算法)。

在 Java 中 java.util.concurrent.atomic 包下面的原子变量类(比如 AtomicIntegerLongAdder)就是使用了乐观锁的一种实现方式 CAS 实现的。

JUC原子类概览

JUC 原子类概览

1
2
3
4
// LongAdder 在高并发场景下会比 AtomicInteger 和 AtomicLong 的性能更好
// 代价就是会消耗更多的内存空间(空间换时间)
LongAdder sum = new LongAdder();
sum.increment();

高并发的场景下,乐观锁相比悲观锁来说,不存在锁竞争造成线程阻塞,也不会有死锁的问题,在性能上往往会更胜一筹。但是,如果冲突频繁发生(写占比非常多的情况),会频繁失败和重试,这样同样会非常影响性能,导致 CPU 飙升。

不过,大量失败重试的问题也是可以解决的,像我们前面提到的 LongAdder 以空间换时间的方式就解决了这个问题。

理论上来说:

  • 悲观锁通常多用于写比较多的情况(多写场景,竞争激烈),这样可以避免频繁失败和重试影响性能,悲观锁的开销是固定的。不过,如果乐观锁解决了频繁失败和重试这个问题的话(比如 LongAdder),也是可以考虑使用乐观锁的,要视实际情况而定。
  • 乐观锁通常多用于写比较少的情况(多读场景,竞争较少),这样可以避免频繁加锁影响性能。不过,乐观锁主要针对的对象是单个共享变量(参考 java.util.concurrent.atomic 包下面的原子变量类)。

如何实现乐观锁?

乐观锁一般会使用版本号机制或 CAS 算法实现,CAS 算法相对来说更多一些,这里需要格外注意。

版本号机制

一般是在数据表中加上一个数据版本号 version 字段,表示数据被修改的次数。当数据被修改时,version 值会加一。当线程 A 要更新数据值时,在读取数据的同时也会读取 version 值,在提交更新时,若刚才读取到的 version 值为当前数据库中的 version 值相等时才更新,否则重试更新操作,直到更新成功。

举一个简单的例子:假设数据库中帐户信息表中有一个 version 字段,当前值为 1 ;而当前帐户余额字段( balance )为 $100 。

  1. 操作员 A 此时将其读出( version =1 ),并从其帐户余额中扣除 $50( $100-$50 )。
  2. 在操作员 A 操作的过程中,操作员 B 也读入此用户信息( version =1 ),并从其帐户余额中扣除 $20 ( $100-$20 )。
  3. 操作员 A 完成了修改工作,将数据版本号( version =1 ),连同帐户扣除后余额( balance =$50 ),提交至数据库更新,此时由于提交数据版本等于数据库记录当前版本,数据被更新,数据库记录 version 更新为 2 。
  4. 操作员 B 完成了操作,也将版本号( version =1 )试图向数据库提交数据( balance =$80 ),但此时比对数据库记录版本时发现,操作员 B 提交的数据版本号为 1 ,数据库记录当前版本也为 2 ,不满足 “ 提交版本必须等于当前版本才能执行更新 “ 的乐观锁策略,因此,操作员 B 的提交被驳回。

这样就避免了操作员 B 用基于 version =1 的旧数据修改的结果覆盖操作员 A 的操作结果的可能。

CAS 算法

CAS 的全称是 Compare And Swap(比较与交换) ,用于实现乐观锁,被广泛应用于各大框架中。CAS 的思想很简单,就是用一个预期值和要更新的变量值进行比较,两值相等才会进行更新。

CAS 是一个原子操作,底层依赖于一条 CPU 的原子指令。

原子操作 即最小不可拆分的操作,也就是说操作一旦开始,就不能被打断,直到操作完成。

CAS 涉及到三个操作数:

  • V:要更新的变量值 (Var)
  • E:预期值 (Expected)
  • N:拟写入的新值 (New)

当且仅当 V 的值等于 E 时,CAS 通过原子方式用新值 N 来更新 V 的值。如果不等,说明已经有其它线程更新了 V,则当前线程放弃更新。

举一个简单的例子:线程 A 要修改变量 i 的值为 6,i 原值为 1(V = 1,E=1,N=6,假设不存在 ABA 问题)。

  1. I 与 1 进行比较,如果相等,则说明没被其他线程修改,可以被设置为 6 。
  2. I 与 1 进行比较,如果不相等,则说明被其他线程修改,当前线程放弃更新,CAS 操作失败。

当多个线程同时使用 CAS 操作一个变量时,只有一个会胜出,并成功更新,其余均会失败,但失败的线程并不会被挂起,仅是被告知失败,并且允许再次尝试,当然也允许失败的线程放弃操作。

Java 语言并没有直接实现 CAS,CAS 相关的实现是通过 C++ 内联汇编的形式实现的(JNI 调用)。因此, CAS 的具体实现和操作系统以及 CPU 都有关系。

sun.misc 包下的 Unsafe 类提供了 compareAndSwapObjectcompareAndSwapIntcompareAndSwapLong 方法来实现的对 Objectintlong 类型的 CAS 操作

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* CAS
* @param o 包含要修改field的对象
* @param offset 对象中某field的偏移量
* @param expected 期望值
* @param update 更新值
* @return true | false
*/
public final native boolean compareAndSwapObject(Object o, long offset, Object expected, Object update);

public final native boolean compareAndSwapInt(Object o, long offset, int expected,int update);

public final native boolean compareAndSwapLong(Object o, long offset, long expected, long update);

关于 Unsafe 类的详细介绍可以看这篇文章:Java 魔法类 Unsafe 详解 - JavaGuide - 2022

乐观锁存在哪些问题?

ABA 问题是乐观锁最常见的问题。

ABA 问题

如果一个变量 V 初次读取的时候是 A 值,并且在准备赋值的时候检查到它仍然是 A 值,那我们就能说明它的值没有被其他线程修改过了吗?很明显是不能的,因为在这段时间它的值可能被改为其他值,然后又改回 A,那 CAS 操作就会误认为它从来没有被修改过。这个问题被称为 CAS 操作的 “ABA”问题。

ABA 问题的解决思路是在变量前面追加上版本号或者时间戳。JDK 1.5 以后的 AtomicStampedReference 类就是用来解决 ABA 问题的,其中的 compareAndSet() 方法就是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。

1
2
3
4
5
6
7
8
9
10
11
12
public boolean compareAndSet(V   expectedReference,
V newReference,
int expectedStamp,
int newStamp) {
Pair<V> current = pair;
return
expectedReference == current.reference &&
expectedStamp == current.stamp &&
((newReference == current.reference &&
newStamp == current.stamp) ||
casPair(current, Pair.of(newReference, newStamp)));
}

循环时间长开销大

CAS 经常会用到自旋操作来进行重试,也就是不成功就一直循环执行直到成功。如果长时间不成功,会给 CPU 带来非常大的执行开销。

如果 JVM 能支持处理器提供的 pause 指令那么效率会有一定的提升,pause 指令有两个作用:

  1. 可以延迟流水线执行指令,使 CPU 不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零。
  2. 可以避免在退出循环的时候因内存顺序冲而引起 CPU 流水线被清空,从而提高 CPU 的执行效率。

只能保证一个共享变量的原子操作

CAS 只对单个共享变量有效,当操作涉及跨多个共享变量时 CAS 无效。但是从 JDK 1.5 开始,提供了 AtomicReference 类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行 CAS 操作. 所以我们可以使用锁或者利用 AtomicReference 类把多个共享变量合并成一个共享变量来操作。

总结

  • 高并发的场景下,激烈的锁竞争会造成线程阻塞,大量阻塞线程会导致系统的上下文切换,增加系统的性能开销。并且,悲观锁还可能会存在死锁问题,影响代码的正常运行。乐观锁相比悲观锁来说,不存在锁竞争造成线程阻塞,也不会有死锁的问题,在性能上往往会更胜一筹。不过,如果冲突频繁发生(写占比非常多的情况),会频繁失败和重试,这样同样会非常影响性能,导致 CPU 飙升。
  • 乐观锁一般会使用版本号机制或 CAS 算法实现,CAS 算法相对来说更多一些,这里需要格外注意。
  • CAS 的全称是 Compare And Swap(比较与交换) ,用于实现乐观锁,被广泛应用于各大框架中。CAS 的思想很简单,就是用一个预期值和要更新的变量值进行比较,两值相等才会进行更新。
  • 乐观锁的问题:ABA 问题、循环时间长开销大、只能保证一个共享变量的原子操作。

JMM (Java 内存模型)主要定义了对于一个共享变量,当另一个线程对这个共享变量执行写操作后,这个线程对这个共享变量的可见性。

要想理解透彻 JMM(Java 内存模型),我们先要从 CPU 缓存模型和指令重排序 说起!

Synchronized 关键字

Synchronized 是什么?有什么用?

synchronized 是 Java 中的一个关键字,翻译成中文是同步的意思,主要解决的是多个线程之间访问资源的同步性,可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。

在 Java 早期版本中,synchronized 属于 重量级锁,效率低下。这是因为监视器锁(monitor)是依赖于底层的操作系统的 Mutex Lock 来实现的,Java 的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高。

不过,在 Java 6 之后, synchronized 引入了大量的优化如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销,这些优化让 synchronized 锁的效率提升了很多。因此, synchronized 还是可以在实际项目中使用的,像 JDK 源码、很多开源框架都大量使用了 synchronized

如何使用 synchronized?

synchronized 关键字的使用方式主要有下面 3 种:

  1. 修饰实例方法
  2. 修饰静态方法
  3. 修饰代码块

1、修饰实例方法 (锁当前对象实例)

给当前对象实例加锁,进入同步代码前要获得 当前对象实例的锁

1
2
3
synchronized void method() {
//业务代码
}

2、修饰静态方法 (锁当前类)

给当前类加锁,会作用于类的所有对象实例,进入同步代码前要获得 当前 class 的锁

这是因为静态成员不属于任何一个实例对象,归整个类所有,不依赖于类的特定实例,被类的所有实例共享。

1
2
3
synchronized static void method() {
//业务代码
}

静态 synchronized 方法和非静态 synchronized 方法之间的调用互斥么?不互斥!如果一个线程 A 调用一个实例对象的非静态 synchronized 方法,而线程 B 需要调用这个实例对象所属类的静态 synchronized 方法,是允许的,不会发生互斥现象,因为访问静态 synchronized 方法占用的锁是当前类的锁,而访问非静态 synchronized 方法占用的锁是当前实例对象锁。

3、修饰代码块 (锁指定对象/类)

对括号里指定的对象/类加锁:

  • synchronized (object) 表示进入同步代码库前要获得 给定对象的锁
  • synchronized (类. Class) 表示进入同步代码前要获得 给定 Class 的锁
1
2
3
Synchronized (this) {
//业务代码
}

总结:

  • synchronized 关键字加到 static 静态方法和 synchronized (class) 代码块上都是是给 Class 类上锁;
  • synchronized 关键字加到实例方法上是给对象实例上锁;
  • 尽量不要使用 synchronized (String a) 因为 JVM 中,字符串常量池具有缓存功能。

构造方法可以用 synchronized 修饰么?

先说结论:构造方法不能使用 synchronized 关键字修饰。

构造方法本身就属于线程安全的,不存在同步的构造方法一说。

Synchronized 底层原理了解吗?

Synchronized 关键字底层原理属于 JVM 层面的东西。

Synchronized 同步语句块的情况

1
2
3
4
5
6
7
Public class SynchronizedDemo {
Public void method () {
Synchronized (this) {
System.Out.Println ("synchronized 代码块");
}
}
}

通过 JDK 自带的 javap 命令查看 SynchronizedDemo 类的相关字节码信息:首先切换到类的对应目录执行 javac SynchronizedDemo. Java 命令生成编译后的 .class 文件,然后执行 javap -c -s -v -l SynchronizedDemo. Class

synchronized关键字原理

从上面我们可以看出:**synchronized 同步语句块的实现使用的是 monitorentermonitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。**

上面的字节码中包含一个 monitorenter 指令以及两个 monitorexit 指令,这是为了保证锁在同步代码块代码正常执行以及出现异常的这两种情况下都能被正确释放。

当执行 monitorenter 指令时,线程试图获取锁也就是获取 对象监视器 monitor 的持有权。

在 Java 虚拟机 (HotSpot)中,Monitor 是基于 C++实现的,由 ObjectMonitor 实现的。每个对象中都内置了一个 ObjectMonitor 对象。

另外,wait/notify 等方法也依赖于 monitor 对象,这就是为什么只有在同步的块或者方法中才能调用 wait/notify 等方法,否则会抛出 java. Lang. IllegalMonitorStateException 的异常的原因。

在执行 monitorenter 时,会尝试获取对象的锁,如果锁的计数器为 0 则表示锁可以被获取,获取后将锁计数器设为 1 也就是加 1。

执行 monitorenter 获取锁

对象锁的的拥有者线程才可以执行 monitorexit 指令来释放锁。在执行 monitorexit 指令后,将锁计数器设为 0,表明锁被释放,其他线程可以尝试获取锁。

执行 monitorexit 释放锁

如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止。

Synchronized 修饰方法的的情况

1
2
3
4
5
6
Public class SynchronizedDemo 2 {
Public synchronized void method () {
System.Out.Println ("synchronized 方法");
}
}

synchronized关键字原理

synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法。JVM 通过该 ACC_SYNCHRONIZED 访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。

如果是实例方法,JVM 会尝试获取实例对象的锁。如果是静态方法,JVM 会尝试获取当前 class 的锁。

总结

synchronized 同步语句块的实现使用的是 monitorentermonitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。

synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法。

不过两者的本质都是对对象监视器 monitor 的获取。

相关推荐:Java 锁与线程的那些事 - 有赞技术团队

JDK 1.6 之后的 synchronized 底层做了哪些优化?

JDK 1.6 对锁的实现引入了大量的优化,如偏向锁、轻量级锁、自旋锁、适应性自旋锁、锁消除、锁粗化等技术来减少锁操作的开销。

锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。注意锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率。

关于这几种优化的详细信息可以查看下面这篇文章:Java6 及以上版本对 synchronized 的优化

Synchronized 和 volatile 有什么区别?

synchronized 关键字和 volatile 关键字是两个互补的存在,而不是对立的存在!

  • volatile 关键字是线程同步的轻量级实现,所以 volatile 性能肯定比 synchronized 关键字要好。但是 volatile 关键字只能用于变量而 synchronized 关键字可以修饰方法以及代码块。
  • volatile 关键字能保证数据的可见性,但不能保证数据的原子性。synchronized 关键字两者都能保证。
  • volatile 关键字主要用于解决变量在多个线程之间的可见性,而 synchronized 关键字解决的是多个线程之间访问资源的同步性。

ReentrantLock 类

ReentrantLock 是什么?

ReentrantLock 实现了 Lock 接口,是一个可重入且独占式的锁,和 synchronized 关键字类似。不过,ReentrantLock 更灵活、更强大,增加了轮询、超时、中断、公平锁和非公平锁等高级功能。

1
Public class ReentrantLock implements Lock, java. Io. Serializable {}

ReentrantLock 里面有一个内部类 SyncSync 继承 AQS(AbstractQueuedSynchronizer),添加锁和释放锁的大部分操作实际上都是在 Sync 中实现的。Sync 有公平锁 FairSync 和非公平锁 NonfairSync 两个子类。

ReentrantLock 默认使用非公平锁,也可以通过构造器来显式的指定使用公平锁。

1
2
3
4
// 传入一个 boolean 值,true 时为公平锁,false 时为非公平锁
Public ReentrantLock (boolean fair) {
Sync = fair ? New FairSync () : new NonfairSync ();
}

从上面的内容可以看出, ReentrantLock 的底层就是由 AQS 来实现的。关于 AQS 的相关内容推荐阅读 AQS 详解 这篇文章。

公平锁和非公平锁有什么区别?

  • 公平锁 : 锁被释放之后,先申请的线程先得到锁。性能较差一些,因为公平锁为了保证时间上的绝对顺序,上下文切换更频繁。
  • 非公平锁:锁被释放之后,后申请的线程可能会先获取到锁,是随机或者按照其他优先级排序的。性能更好,但可能会导致某些线程永远无法获取到锁。

Synchronized 和 ReentrantLock 有什么区别?

两者都是可重入锁

可重入锁 也叫递归锁,指的是线程可以再次获取自己的内部锁。比如一个线程获得了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的,如果是不可重入锁的话,就会造成死锁。

JDK 提供的所有现成的 Lock 实现类,包括 synchronized 关键字锁都是可重入的。

在下面的代码中,method 1 ()method 2 () 都被 synchronized 关键字修饰,method 1 () 调用了 method 2 ()

1
2
3
4
5
6
7
8
9
10
Public class SynchronizedDemo {
Public synchronized void method 1 () {
System.Out.Println ("方法 1");
Method 2 ();
}

Public synchronized void method 2 () {
System.Out.Println ("方法 2");
}
}

由于 synchronized 锁是可重入的,同一个线程在调用 method 1 () 时可以直接获得当前对象的锁,执行 method 2 () 的时候可以再次获取这个对象的锁,不会产生死锁问题。假如 synchronized 是不可重入锁的话,由于该对象的锁已被当前线程所持有且无法释放,这就导致线程在执行 method 2 () 时获取锁失败,会出现死锁问题。

Synchronized 依赖于 JVM 而 ReentrantLock 依赖于 API

synchronized 是依赖于 JVM 实现的,前面我们也讲到了虚拟机团队在 JDK 1.6 为 synchronized 关键字进行了很多优化,但是这些优化都是在虚拟机层面实现的,并没有直接暴露给我们。

ReentrantLock 是 JDK 层面实现的(也就是 API 层面,需要 lock () 和 unlock () 方法配合 try/finally 语句块来完成),所以我们可以通过查看它的源代码,来看它是如何实现的。

ReentrantLock 比 synchronized 增加了一些高级功能

相比 synchronizedReentrantLock 增加了一些高级功能。主要来说主要有三点:

  • 等待可中断 : ReentrantLock 提供了一种能够中断等待锁的线程的机制,通过 lock.LockInterruptibly () 来实现这个机制。也就是说正在等待的线程可以选择放弃等待,改为处理其他事情。
  • 可实现公平锁 : ReentrantLock 可以指定是公平锁还是非公平锁。而 synchronized 只能是非公平锁。所谓的公平锁就是先等待的线程先获得锁。ReentrantLock 默认情况是非公平的,可以通过 ReentrantLock 类的 ReentrantLock (boolean fair) 构造方法来指定是否是公平的。
  • 可实现选择性通知(锁可以绑定多个条件): synchronized 关键字与 wait ()notify () / notifyAll () 方法相结合可以实现等待/通知机制。ReentrantLock 类当然也可以实现,但是需要借助于 Condition 接口与 newCondition () 方法。

如果你想使用上述功能,那么选择 ReentrantLock 是一个不错的选择。

关于 Condition 接口的补充:

Condition 是 JDK 1.5 之后才有的,它具有很好的灵活性,比如可以实现多路通知功能也就是在一个 Lock 对象中可以创建多个 Condition 实例(即对象监视器),线程对象可以注册在指定的 Condition 中,从而可以有选择性的进行线程通知,在调度线程上更加灵活。在使用 notify ()/notifyAll () 方法进行通知时,被通知的线程是由 JVM 选择的,用 ReentrantLock 类结合 Condition 实例可以实现“选择性通知” ,这个功能非常重要,而且是 Condition 接口默认提供的。而 synchronized 关键字就相当于整个 Lock 对象中只有一个 Condition 实例,所有的线程都注册在它一个身上。如果执行 notifyAll () 方法的话就会通知所有处于等待状态的线程,这样会造成很大的效率问题。而 Condition 实例的 signalAll () 方法,只会唤醒注册在该 Condition 实例中的所有等待线程。

可中断锁和不可中断锁有什么区别?

  • 可中断锁:获取锁的过程中可以被中断,不需要一直等到获取锁之后才能进行其他逻辑处理。ReentrantLock 就属于是可中断锁。
  • 不可中断锁:一旦线程申请了锁,就只能等到拿到锁以后才能进行其他的逻辑处理。 synchronized 就属于是不可中断锁。

ReentrantReadWriteLock 类

ReentrantReadWriteLock 在实际项目中使用的并不多,面试中也问的比较少,简单了解即可。JDK 1.8 引入了性能更好的读写锁 StampedLock

ReentrantReadWriteLock 是什么?

ReentrantReadWriteLock 实现了 ReadWriteLock ,是一个可重入的读写锁,既可以保证多个线程同时读的效率,同时又可以保证有写入操作时的线程安全。

1
2
3
4
5
6
7
Public class ReentrantReadWriteLock
Implements ReadWriteLock, java. Io. Serializable{
}
Public interface ReadWriteLock {
Lock readLock ();
Lock writeLock ();
}
  • 一般锁进行并发控制的规则:读读互斥、读写互斥、写写互斥。
  • 读写锁进行并发控制的规则:读读不互斥、读写互斥、写写互斥(只有读读不互斥)。

ReentrantReadWriteLock 其实是两把锁,一把是 WriteLock (写锁),一把是 ReadLock(读锁) 。读锁是共享锁,写锁是独占锁。读锁可以被同时读,可以同时被多个线程持有,而写锁最多只能同时被一个线程持有。

ReentrantLock 一样,ReentrantReadWriteLock 底层也是基于 AQS 实现的。

ReentrantReadWriteLock 也支持公平锁和非公平锁,默认使用非公平锁,可以通过构造器来显示的指定。

1
2
3
4
5
6
// 传入一个 boolean 值,true 时为公平锁,false 时为非公平锁
Public ReentrantReadWriteLock (boolean fair) {
Sync = fair ? New FairSync () : new NonfairSync ();
ReaderLock = new ReadLock (this);
WriterLock = new WriteLock (this);
}

ReentrantReadWriteLock 适合什么场景?

由于 ReentrantReadWriteLock 既可以保证多个线程同时读的效率,同时又可以保证有写入操作时的线程安全。因此,在读多写少的情况下,使用 ReentrantReadWriteLock 能够明显提升系统性能。

共享锁和独占锁有什么区别?

  • 共享锁:一把锁可以被多个线程同时获得。
  • 独占锁:一把锁只能被一个线程获得。

线程持有读锁还能获取写锁吗?

  • 在线程持有读锁的情况下,该线程不能取得写锁 (因为获取写锁的时候,如果发现当前的读锁被占用,就马上获取失败,不管读锁是不是被当前线程持有)。
  • 在线程持有写锁的情况下,该线程可以继续获取读锁(获取读锁时如果发现写锁被占用,只有写锁没有被当前线程占用的情况才会获取失败)。

读写锁的源码分析,推荐阅读 聊聊 Java 的几把 JVM 级锁 - 阿里巴巴中间件 这篇文章,写的很不错。

读锁为什么不能升级为写锁?

写锁可以降级为读锁,但是读锁却不能升级为写锁。这是因为读锁升级为写锁会引起线程的争夺,毕竟写锁属于是独占锁,这样的话,会影响性能。

另外,还可能会有死锁问题发生。举个例子:假设两个线程的读锁都想升级写锁,则需要对方都释放自己锁,而双方都不释放,就会产生死锁。

StampedLock 类

StampedLock 面试中问的比较少,不是很重要,简单了解即可。

StampedLock 是什么?

StampedLock 是 JDK 1.8 引入的性能更好的读写锁,不可重入且不支持条件变量 Conditon

不同于一般的 Lock 类,StampedLock 并不是直接实现 LockReadWriteLock 接口,而是基于 CLH 锁 独立实现的(AQS 也是基于这玩意)。

1
2
Public class StampedLock implements java. Io. Serializable {
}

StampedLock 提供了三种模式的读写控制模式:读锁、写锁和乐观读。

  • 写锁:独占锁,一把锁只能被一个线程获得。当一个线程获取写锁后,其他请求读锁和写锁的线程必须等待。类似于 ReentrantReadWriteLock 的写锁,不过这里的写锁是不可重入的。
  • 读锁 (悲观读):共享锁,没有线程获取写锁的情况下,多个线程可以同时持有读锁。如果己经有线程持有写锁,则其他线程请求获取该读锁会被阻塞。类似于 ReentrantReadWriteLock 的读锁,不过这里的读锁是不可重入的。
  • 乐观读:允许多个线程获取乐观读以及读锁。同时允许一个写线程获取写锁。

另外,StampedLock 还支持这三种锁在一定条件下进行相互转换。

1
2
3
Long tryConvertToWriteLock (long stamp){}
Long tryConvertToReadLock (long stamp){}
Long tryConvertToOptimisticRead (long stamp){}

StampedLock 在获取锁的时候会返回一个 long 型的数据戳,该数据戳用于稍后的锁释放参数,如果返回的数据戳为 0 则表示锁获取失败。当前线程持有了锁再次获取锁还是会返回一个新的数据戳,这也是 StampedLock 不可重入的原因。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 写锁
Public long writeLock () {
Long s, next; // bypass acquireWrite in fully unlocked case only
Return ((((s = state) & ABITS) == 0 L &&
U.compareAndSwapLong (this, STATE, s, next = s + WBIT)) ?
Next : acquireWrite (false, 0 L));
}
// 读锁
Public long readLock () {
Long s = state, next; // bypass acquireRead on common uncontended case
Return ((whead == wtail && (s & ABITS) < RFULL &&
U.compareAndSwapLong (this, STATE, s, next = s + RUNIT)) ?
Next : acquireRead (false, 0 L));
}
// 乐观读
Public long tryOptimisticRead () {
Long s;
Return (((s = state) & WBIT) == 0 L) ? (s & SBITS) : 0 L;
}

StampedLock 的性能为什么更好?

相比于传统读写锁多出来的乐观读是 StampedLockReadWriteLock 性能更好的关键原因。StampedLock 的乐观读允许一个写线程获取写锁,所以不会导致所有写线程阻塞,也就是当读多写少的时候,写线程有机会获取写锁,减少了线程饥饿的问题,吞吐量大大提高。

StampedLock 适合什么场景?

ReentrantReadWriteLock 一样,StampedLock 同样适合读多写少的业务场景,可以作为 ReentrantReadWriteLock 的替代品,性能更好。

不过,需要注意的是 StampedLock 不可重入,不支持条件变量 Conditon,对中断操作支持也不友好(使用不当容易导致 CPU 飙升)。如果你需要用到 ReentrantLock 的一些高级性能,就不太建议使用 StampedLock 了。

另外,StampedLock 性能虽好,但使用起来相对比较麻烦,一旦使用不当,就会出现生产问题。强烈建议你在使用 StampedLock 之前,看看 StampedLock 官方文档中的案例

StampedLock 的底层原理了解吗?

StampedLock 不是直接实现 LockReadWriteLock 接口,而是基于 CLH 锁 实现的(AQS 也是基于这玩意),CLH 锁是对自旋锁的一种改良,是一种隐式的链表队列。StampedLock 通过 CLH 队列进行线程的管理,通过同步状态值 state 来表示锁的状态和类型。

StampedLock 的原理和 AQS 原理比较类似,这里就不详细介绍了,感兴趣的可以看看下面这两篇文章:

如果你只是准备面试的话,建议多花点精力搞懂 AQS 原理即可,StampedLock 底层原理在面试中遇到的概率非常小。

Atomic 原子类

Atomic 原子类介绍

Atomic 翻译成中文是原子的意思。在化学上,我们知道原子是构成一般物质的最小单位,在化学反应中是不可分割的。在我们这里 Atomic 是指一个操作是不可中断的。即使是在多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程干扰。

所以,所谓原子类说简单点就是具有原子/原子操作特征的类。

并发包 java. Util. Concurrent 的原子类都存放在 java. Util. Concurrent. Atomic 下, 如下图所示。

JUC原子类概览

根据操作的数据类型,可以将 JUC 包中的原子类分为 4 类

基本类型

使用原子的方式更新基本类型

  • AtomicInteger:整型原子类
  • AtomicLong:长整型原子类
  • AtomicBoolean:布尔型原子类

数组类型

使用原子的方式更新数组里的某个元素

  • AtomicIntegerArray:整型数组原子类
  • AtomicLongArray:长整型数组原子类
  • AtomicReferenceArray:引用类型数组原子类

引用类型

  • AtomicReference:引用类型原子类
  • AtomicMarkableReference:原子更新带有标记的引用类型。
  • AtomicStampedReference:原子更新带有版本号的引用类型。该类将整数值与引用关联起来,可用于解决原子的更新数据和数据的版本号,可以解决使用 CAS 进行原子更新时可能出现的 ABA 问题。

对象的属性修改类型

  • AtomicIntegerFieldUpdater: 原子更新整型字段的更新器
  • AtomicLongFieldUpdater:原子更新长整型字段的更新器
  • AtomicReferenceFieldUpdater:原子更新引用类型里的字段

基本类型原子类

使用原子的方式更新基本类型

  • AtomicInteger:整型原子类
  • AtomicLong:长整型原子类
  • AtomicBoolean:布尔型原子类

上面三个类提供的方法几乎相同,所以我们这里以 AtomicInteger 为例子来介绍。

AtomicInteger 类常用方法

1
2
3
4
5
6
7
Public final int get () //获取当前的值
Public final int getAndSet (int newValue)//获取当前的值,并设置新的值
Public final int getAndIncrement ()//获取当前的值,并自增
Public final int getAndDecrement () //获取当前的值,并自减
Public final int getAndAdd (int delta) //获取当前的值,并加上预期的值
Boolean compareAndSet (int expect, int update) //如果输入的数值等于预期值,则以原子方式将该值设置为输入值(update)
Public final void lazySet (int newValue)//最终设置为 newValue, 使用 lazySet 设置之后可能导致其他线程在之后的一小段时间内还是可以读到旧的值。

AtomicInteger 类使用示例 :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Import java. Util. Concurrent. Atomic. AtomicInteger;

Public class AtomicIntegerTest {

Public static void main (String[] args) {
Int temvalue = 0;
AtomicInteger i = new AtomicInteger (0);
temvalue = i.getAndSet (3);
System.Out.Println ("temvalue: " + temvalue + "; i: " + i); //temvalue: 0; i:3
temvalue = i.getAndIncrement ();
System.Out.Println ("temvalue: " + temvalue + "; i: " + i); //temvalue: 3; i:4
temvalue = i.getAndAdd (5);
System.Out.Println ("temvalue: " + temvalue + "; i: " + i); //temvalue: 4; i:9
}

}

基本数据类型原子类的优势

通过一个简单例子带大家看一下基本数据类型原子类的优势

1、多线程环境不使用原子类保证线程安全(基本数据类型)

1
2
3
4
5
6
7
8
9
10
11
Class Test {
Private volatile int count = 0;
//若要线程安全执行执行 count++,需要加锁
Public synchronized void increment () {
Count++;
}

Public int getCount () {
Return count;
}
}

2、多线程环境使用原子类保证线程安全(基本数据类型)

1
2
3
4
5
6
7
8
9
10
11
12
Class Test 2 {
Private AtomicInteger count = new AtomicInteger ();

Public void increment () {
Count.IncrementAndGet ();
}
//使用 AtomicInteger 之后,不需要加锁,也可以实现线程安全。
Public int getCount () {
Return count.Get ();
}
}

AtomicInteger 线程安全原理简单分析

AtomicInteger 类的部分源码:

1
2
3
4
5
6
7
8
9
10
11
12
// setup to use Unsafe. CompareAndSwapInt for updates(更新操作时提供“比较并替换”的作用)
Private static final Unsafe unsafe = Unsafe.GetUnsafe ();
Private static final long valueOffset;

Static {
Try {
ValueOffset = unsafe. ObjectFieldOffset
(AtomicInteger.Class.GetDeclaredField ("value"));
} catch (Exception ex) { throw new Error (ex); }
}

Private volatile int value;

AtomicInteger 类主要利用 CAS (compare and swap) + volatile 和 native 方法来保证原子操作,从而避免 synchronized 的高开销,执行效率大为提升。

CAS 的原理是拿期望的值和原本的一个值作比较,如果相同则更新成新的值。UnSafe 类的 objectFieldOffset () 方法是一个本地方法,这个方法是用来拿到“原来的值”的内存地址。另外 value 是一个 volatile 变量,在内存中可见,因此 JVM 可以保证任何时刻任何线程总能拿到该变量的最新值。

数组类型原子类

使用原子的方式更新数组里的某个元素

  • AtomicIntegerArray:整形数组原子类
  • AtomicLongArray:长整形数组原子类
  • AtomicReferenceArray:引用类型数组原子类

上面三个类提供的方法几乎相同,所以我们这里以 AtomicIntegerArray 为例子来介绍。

AtomicIntegerArray 类常用方法

1
2
3
4
5
6
7
Public final int get (int i) //获取 index=i 位置元素的值
Public final int getAndSet (int i, int newValue)//返回 index=i 位置的当前的值,并将其设置为新值:newValue
Public final int getAndIncrement (int i)//获取 index=i 位置元素的值,并让该位置的元素自增
Public final int getAndDecrement (int i) //获取 index=i 位置元素的值,并让该位置的元素自减
Public final int getAndAdd (int i, int delta) //获取 index=i 位置元素的值,并加上预期的值
Boolean compareAndSet (int i, int expect, int update) //如果输入的数值等于预期值,则以原子方式将 index=i 位置的元素值设置为输入值(update)
Public final void lazySet (int i, int newValue)//最终将 index=i 位置的元素设置为 newValue, 使用 lazySet 设置之后可能导致其他线程在之后的一小段时间内还是可以读到旧的值。

AtomicIntegerArray 类使用示例 :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Import java. Util. Concurrent. Atomic. AtomicIntegerArray;

Public class AtomicIntegerArrayTest {

Public static void main (String[] args) {
Int temvalue = 0;
Int[] nums = { 1, 2, 3, 4, 5, 6 };
AtomicIntegerArray i = new AtomicIntegerArray (nums);
For (int j = 0; j < nums. Length; j++) {
System.Out.Println (i.get (j));
}
temvalue = i.getAndSet (0, 2);
System.Out.Println ("temvalue: " + temvalue + "; i: " + i);
temvalue = i.getAndIncrement (0);
System.Out.Println ("temvalue: " + temvalue + "; i: " + i);
temvalue = i.getAndAdd (0, 5);
System.Out.Println ("temvalue: " + temvalue + "; i: " + i);
}

}

引用类型原子类

基本类型原子类只能更新一个变量,如果需要原子更新多个变量,需要使用引用类型原子类。

  • AtomicReference:引用类型原子类
  • AtomicStampedReference:原子更新带有版本号的引用类型。该类将整数值与引用关联起来,可用于解决原子的更新数据和数据的版本号,可以解决使用 CAS 进行原子更新时可能出现的 ABA 问题。
  • AtomicMarkableReference:原子更新带有标记的引用类型。该类将 boolean 标记与引用关联起来,也可以解决使用 CAS 进行原子更新时可能出现的 ABA 问题。

上面三个类提供的方法几乎相同,所以我们这里以 AtomicReference 为例子来介绍。

AtomicReference 类使用示例 :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
Import java. Util. Concurrent. Atomic. AtomicReference;

Public class AtomicReferenceTest {

Public static void main (String[] args) {
AtomicReference < Person > ar = new AtomicReference < Person > ();
Person person = new Person ("SnailClimb", 22);
Ar.Set (person);
Person updatePerson = new Person ("Daisy", 20);
Ar.CompareAndSet (person, updatePerson);

System.Out.Println (ar.Get (). GetName ());
System.Out.Println (ar.Get (). GetAge ());
}
}

Class Person {
Private String name;
Private int age;

Public Person (String name, int age) {
Super ();
This. Name = name;
This. Age = age;
}

Public String getName () {
Return name;
}

Public void setName (String name) {
This. Name = name;
}

Public int getAge () {
Return age;
}

Public void setAge (int age) {
This. Age = age;
}

}

上述代码首先创建了一个 Person 对象,然后把 Person 对象设置进 AtomicReference 对象中,然后调用 compareAndSet 方法,该方法就是通过 CAS 操作设置 ar。如果 ar 的值为 person 的话,则将其设置为 updatePerson。实现原理与 AtomicInteger 类中的 compareAndSet 方法相同。运行上面的代码后的输出结果如下:

AtomicStampedReference 类使用示例 :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
Import java. Util. Concurrent. Atomic. AtomicStampedReference;

Public class AtomicStampedReferenceDemo {
Public static void main (String[] args) {
// 实例化、取当前值和 stamp 值
Final Integer initialRef = 0, initialStamp = 0;
final AtomicStampedReference<Integer> asr = new AtomicStampedReference<>(initialRef, initialStamp);
System.Out.Println ("currentValue=" + asr.GetReference () + ", currentStamp=" + asr.GetStamp ());

// compare and set
Final Integer newReference = 666, newStamp = 999;
Final boolean casResult = asr.CompareAndSet (initialRef, newReference, initialStamp, newStamp);
System.Out.Println ("currentValue=" + asr.GetReference ()
+ ", currentStamp=" + asr.GetStamp ()
+ ", casResult=" + casResult);

// 获取当前的值和当前的 stamp 值
Int[] arr = new int[1];
Final Integer currentValue = asr.Get (arr);
Final int currentStamp = arr[0];
System.Out.Println ("currentValue=" + currentValue + ", currentStamp=" + currentStamp);

// 单独设置 stamp 值
Final boolean attemptStampResult = asr.AttemptStamp (newReference, 88);
System.Out.Println ("currentValue=" + asr.GetReference ()
+ ", currentStamp=" + asr.GetStamp ()
+ ", attemptStampResult=" + attemptStampResult);

// 重新设置当前值和 stamp 值
Asr.Set (initialRef, initialStamp);
System.Out.Println ("currentValue=" + asr.GetReference () + ", currentStamp=" + asr.GetStamp ());

// [不推荐使用,除非搞清楚注释的意思了] weak compare and set
// 困惑!WeakCompareAndSet 这个方法最终还是调用 compareAndSet 方法。[版本: jdk-8 u 191]
// 但是注释上写着 "May fail spuriously and does not provide ordering guarantees,
// so is only rarely an appropriate alternative to compareAndSet."
// todo 感觉有可能是 jvm 通过方法名在 native 方法里面做了转发
Final boolean wCasResult = asr.WeakCompareAndSet (initialRef, newReference, initialStamp, newStamp);
System.Out.Println ("currentValue=" + asr.GetReference ()
+ ", currentStamp=" + asr.GetStamp ()
+ ", wCasResult=" + wCasResult);
}
}

输出结果如下:

1
2
3
4
5
6
CurrentValue=0, currentStamp=0
CurrentValue=666, currentStamp=999, casResult=true
CurrentValue=666, currentStamp=999
CurrentValue=666, currentStamp=88, attemptStampResult=true
CurrentValue=0, currentStamp=0
CurrentValue=666, currentStamp=999, wCasResult=true

AtomicMarkableReference 类使用示例 :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
Import java. Util. Concurrent. Atomic. AtomicMarkableReference;

Public class AtomicMarkableReferenceDemo {
Public static void main (String[] args) {
// 实例化、取当前值和 mark 值
Final Boolean initialRef = null, initialMark = false;
final AtomicMarkableReference<Boolean> amr = new AtomicMarkableReference<>(initialRef, initialMark);
System.Out.Println ("currentValue=" + amr.GetReference () + ", currentMark=" + amr.IsMarked ());

// compare and set
Final Boolean newReference 1 = true, newMark 1 = true;
Final boolean casResult = amr.CompareAndSet (initialRef, newReference 1, initialMark, newMark 1);
System.Out.Println ("currentValue=" + amr.GetReference ()
+ ", currentMark=" + amr.IsMarked ()
+ ", casResult=" + casResult);

// 获取当前的值和当前的 mark 值
Boolean[] arr = new boolean[1];
Final Boolean currentValue = amr.Get (arr);
Final boolean currentMark = arr[0];
System.Out.Println ("currentValue=" + currentValue + ", currentMark=" + currentMark);

// 单独设置 mark 值
Final boolean attemptMarkResult = amr.AttemptMark (newReference 1, false);
System.Out.Println ("currentValue=" + amr.GetReference ()
+ ", currentMark=" + amr.IsMarked ()
+ ", attemptMarkResult=" + attemptMarkResult);

// 重新设置当前值和 mark 值
Amr.Set (initialRef, initialMark);
System.Out.Println ("currentValue=" + amr.GetReference () + ", currentMark=" + amr.IsMarked ());

// [不推荐使用,除非搞清楚注释的意思了] weak compare and set
// 困惑!WeakCompareAndSet 这个方法最终还是调用 compareAndSet 方法。[版本: jdk-8 u 191]
// 但是注释上写着 "May fail spuriously and does not provide ordering guarantees,
// so is only rarely an appropriate alternative to compareAndSet."
// todo 感觉有可能是 jvm 通过方法名在 native 方法里面做了转发
Final boolean wCasResult = amr.WeakCompareAndSet (initialRef, newReference 1, initialMark, newMark 1);
System.Out.Println ("currentValue=" + amr.GetReference ()
+ ", currentMark=" + amr.IsMarked ()
+ ", wCasResult=" + wCasResult);
}
}

输出结果如下:

1
2
3
4
5
6
CurrentValue=null, currentMark=false
CurrentValue=true, currentMark=true, casResult=true
CurrentValue=true, currentMark=true
CurrentValue=true, currentMark=false, attemptMarkResult=true
CurrentValue=null, currentMark=false
CurrentValue=true, currentMark=true, wCasResult=true

对象的属性修改类型原子类

如果需要原子更新某个类里的某个字段时,需要用到对象的属性修改类型原子类。

  • AtomicIntegerFieldUpdater: 原子更新整形字段的更新器
  • AtomicLongFieldUpdater:原子更新长整形字段的更新器
  • AtomicReferenceFieldUpdater:原子更新引用类型里的字段的更新器

要想原子地更新对象的属性需要两步。第一步,因为对象的属性修改类型原子类都是抽象类,所以每次使用都必须使用静态方法 newUpdater ()创建一个更新器,并且需要设置想要更新的类和属性。第二步,更新的对象属性必须使用 public volatile 修饰符。

上面三个类提供的方法几乎相同,所以我们这里以 AtomicIntegerFieldUpdater 为例子来介绍。

AtomicIntegerFieldUpdater 类使用示例 :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
Import java. Util. Concurrent. Atomic. AtomicIntegerFieldUpdater;

Public class AtomicIntegerFieldUpdaterTest {
Public static void main (String[] args) {
AtomicIntegerFieldUpdater<User> a = AtomicIntegerFieldUpdater.NewUpdater (User. Class, "age");

User user = new User ("Java", 22);
System.Out.Println (a.getAndIncrement (user));// 22
System.Out.Println (a.get (user));// 23
}
}

Class User {
Private String name;
Public volatile int age;

Public User (String name, int age) {
Super ();
This. Name = name;
This. Age = age;
}

Public String getName () {
Return name;
}

Public void setName (String name) {
This. Name = name;
}

Public int getAge () {
Return age;
}

Public void setAge (int age) {
This. Age = age;
}

}

ThreadLocal 类

ThreadLocal 有什么用?

通常情况下,我们创建的变量是可以被任何一个线程访问并修改的。如果想实现每一个线程都有自己的专属本地变量该如何解决呢?

JDK 中自带的 ThreadLocal 类正是为了解决这样的问题。 ThreadLocal 类主要解决的就是让每个线程绑定自己的值,可以将 ThreadLocal 类形象的比喻成存放数据的盒子,盒子中可以存储每个线程的私有数据。

如果你创建了一个 ThreadLocal 变量,那么访问这个变量的每个线程都会有这个变量的本地副本,这也是 ThreadLocal 变量名的由来。他们可以使用 get ()set () 方法来获取默认值或将其值更改为当前线程所存的副本的值,从而避免了线程安全问题。

再举个简单的例子:两个人去宝屋收集宝物,这两个共用一个袋子的话肯定会产生争执,但是给他们两个人每个人分配一个袋子的话就不会出现这样的问题。如果把这两个人比作线程的话,那么 ThreadLocal 就是用来避免这两个线程竞争的。

如何使用 ThreadLocal?

相信看了上面的解释,大家已经搞懂 ThreadLocal 类是个什么东西了。下面简单演示一下如何在项目中实际使用 ThreadLocal

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
Import java. Text. SimpleDateFormat;
Import java. Util. Random;

Public class ThreadLocalExample implements Runnable{

// SimpleDateFormat 不是线程安全的,所以每个线程都要有自己独立的副本
private static final ThreadLocal<SimpleDateFormat> formatter = ThreadLocal.WithInitial (() -> new SimpleDateFormat ("yyyyMMdd HHmm"));

Public static void main (String[] args) throws InterruptedException {
ThreadLocalExample obj = new ThreadLocalExample ();
For (int i=0 ; i<10; i++){
Thread t = new Thread (obj, ""+i);
Thread.Sleep (new Random (). NextInt (1000));
t.start ();
}
}

@Override
Public void run () {
System.Out.Println ("Thread Name= "+Thread.CurrentThread (). GetName ()+" default Formatter = "+formatter.Get (). ToPattern ());
Try {
Thread.Sleep (new Random (). NextInt (1000));
} catch (InterruptedException e) {
e.printStackTrace ();
}
//formatter pattern is changed here by thread, but it won't reflect to other threads
Formatter.Set (new SimpleDateFormat ());

System.Out.Println ("Thread Name= "+Thread.CurrentThread (). GetName ()+" formatter = "+formatter.Get (). ToPattern ());
}

}

输出结果 :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Thread Name= 0 default Formatter = yyyyMMdd HHmm
Thread Name= 0 formatter = yy-M-d ah:mm
Thread Name= 1 default Formatter = yyyyMMdd HHmm
Thread Name= 2 default Formatter = yyyyMMdd HHmm
Thread Name= 1 formatter = yy-M-d ah:mm
Thread Name= 3 default Formatter = yyyyMMdd HHmm
Thread Name= 2 formatter = yy-M-d ah:mm
Thread Name= 4 default Formatter = yyyyMMdd HHmm
Thread Name= 3 formatter = yy-M-d ah:mm
Thread Name= 4 formatter = yy-M-d ah:mm
Thread Name= 5 default Formatter = yyyyMMdd HHmm
Thread Name= 5 formatter = yy-M-d ah:mm
Thread Name= 6 default Formatter = yyyyMMdd HHmm
Thread Name= 6 formatter = yy-M-d ah:mm
Thread Name= 7 default Formatter = yyyyMMdd HHmm
Thread Name= 7 formatter = yy-M-d ah:mm
Thread Name= 8 default Formatter = yyyyMMdd HHmm
Thread Name= 9 default Formatter = yyyyMMdd HHmm
Thread Name= 8 formatter = yy-M-d ah:mm
Thread Name= 9 formatter = yy-M-d ah:mm

从输出中可以看出,虽然 Thread-0 已经改变了 formatter 的值,但 Thread-1 默认格式化值与初始化值相同,其他线程也一样。

上面有一段代码用到了创建 ThreadLocal 变量的那段代码用到了 Java 8 的知识,它等于下面这段代码,如果你写了下面这段代码的话,IDEA 会提示你转换为 Java 8 的格式 (IDEA 真的不错!)。因为 ThreadLocal 类在 Java 8 中扩展,使用一个新的方法 withInitial (),将 Supplier 功能接口作为参数。

1
2
3
4
5
6
private static final ThreadLocal<SimpleDateFormat> formatter = new ThreadLocal<SimpleDateFormat>(){
@Override
Protected SimpleDateFormat initialValue (){
Return new SimpleDateFormat ("yyyyMMdd HHmm");
}
};

ThreadLocal 原理了解吗?

Thread 类源代码入手。

1
2
3
4
5
6
7
8
9
Public class Thread implements Runnable {
//......
//与此线程有关的 ThreadLocal 值。由 ThreadLocal 类维护
ThreadLocal. ThreadLocalMap threadLocals = null;

//与此线程有关的 InheritableThreadLocal 值。由 InheritableThreadLocal 类维护
ThreadLocal. ThreadLocalMap inheritableThreadLocals = null;
//......
}

从上面 Thread 类源代码可以看出 Thread 类中有一个 threadLocals 和一个 inheritableThreadLocals 变量,它们都是 ThreadLocalMap 类型的变量, 我们可以把 ThreadLocalMap 理解为 ThreadLocal 类实现的定制化的 HashMap。默认情况下这两个变量都是 null,只有当前线程调用 ThreadLocal 类的 setget 方法时才创建它们,实际上调用这两个方法的时候,我们调用的是 ThreadLocalMap 类对应的 get ()set () 方法。

ThreadLocal 类的 set () 方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Public void set (T value) {
//获取当前请求的线程
Thread t = Thread.CurrentThread ();
//取出 Thread 类内部的 threadLocals 变量 (哈希表结构)
ThreadLocalMap map = getMap (t);
If (map != null)
// 将需要存储的值放入到这个哈希表中
Map.Set (this, value);
Else
CreateMap (t, value);
}
ThreadLocalMap getMap (Thread t) {
return t.threadLocals;
}

通过上面这些内容,我们足以通过猜测得出结论:最终的变量是放在了当前线程的 ThreadLocalMap 中,并不是存在 ThreadLocal 上,ThreadLocal 可以理解为只是 ThreadLocalMap 的封装,传递了变量值。 ThrealLocal 类中可以通过 Thread.CurrentThread () 获取到当前线程对象后,直接通过 getMap (Thread t) 可以访问到该线程的 ThreadLocalMap 对象。

每个 Thread 中都具备一个 ThreadLocalMap,而 ThreadLocalMap 可以存储以 ThreadLocal 为 key ,Object 对象为 value 的键值对。

1
2
3
ThreadLocalMap (ThreadLocal<?> firstKey, Object firstValue) {
//......
}

比如我们在同一个线程中声明了两个 ThreadLocal 对象的话, Thread 内部都是使用仅有的那个 ThreadLocalMap 存放数据的,ThreadLocalMap 的 key 就是 ThreadLocal 对象,value 就是 ThreadLocal 对象调用 set 方法设置的值。

ThreadLocal 数据结构如下图所示:

ThreadLocal 数据结构

ThreadLocalMapThreadLocal 的静态内部类。

ThreadLocal内部类

ThreadLocal 内存泄露问题是怎么导致的?

ThreadLocalMap 中使用的 key 为 ThreadLocal 的弱引用,而 value 是强引用。所以,如果 ThreadLocal 没有被外部强引用的情况下,在垃圾回收的时候,key 会被清理掉,而 value 不会被清理掉。

这样一来,ThreadLocalMap 中就会出现 key 为 null 的 Entry。假如我们不做任何措施的话,value 永远无法被 GC 回收,这个时候就可能会产生内存泄露。ThreadLocalMap 实现中已经考虑了这种情况,在调用 set ()get ()remove () 方法的时候,会清理掉 key 为 null 的记录。使用完 ThreadLocal 方法后最好手动调用 remove () 方法

1
2
3
4
5
6
7
8
9
static class Entry extends WeakReference<ThreadLocal<?>> {
/** The value associated with this ThreadLocal. */
Object value;

Entry (ThreadLocal<?> k, Object v) {
Super (k);
Value = v;
}
}

弱引用介绍:

如果一个对象只具有弱引用,那就类似于可有可无的生活用品。弱引用与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。不过,由于垃圾回收器是一个优先级很低的线程,因此不一定会很快发现那些只具有弱引用的对象。

弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被垃圾回收,Java 虚拟机就会把这个弱引用加入到与之关联的引用队列中。

ThreadLocal 代码演示

我们先看下 ThreadLocal 使用示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Public class ThreadLocalTest {
private List<String> messages = Lists.NewArrayList ();

public static final ThreadLocal<ThreadLocalTest> holder = ThreadLocal.WithInitial (ThreadLocalTest::new);

Public static void add (String message) {
Holder.Get (). Messages.Add (message);
}

public static List<String> clear () {
List<String> messages = holder.Get (). Messages;
Holder.Remove ();

System.Out.Println ("size: " + holder.Get (). Messages.Size ());
Return messages;
}

Public static void main (String[] args) {
ThreadLocalTest.Add ("一枝花算不算浪漫");
System.Out.Println (holder.Get (). Messages);
ThreadLocalTest.Clear ();
}
}

打印结果:

ThreadLocal 对象可以提供线程局部变量,每个线程 Thread 拥有一份自己的副本变量,多个线程互不干扰。

ThreadLocal 的数据结构

Thread 类有一个类型为 ThreadLocal. ThreadLocalMap 的实例变量 threadLocals,也就是说每个线程有一个自己的 ThreadLocalMap

ThreadLocalMap 有自己的独立实现,可以简单地将它的 key 视作 ThreadLocalvalue 为代码中放入的值(实际上 key 并不是 ThreadLocal 本身,而是它的一个弱引用)。

每个线程在往 ThreadLocal 里放值的时候,都会往自己的 ThreadLocalMap 里存,读也是以 ThreadLocal 作为引用,在自己的 map 里找对应的 key,从而实现了线程隔离

ThreadLocalMap 有点类似 HashMap 的结构,只是 HashMap 是由数组+链表实现的,而 ThreadLocalMap 中并没有链表结构。

我们还要注意 Entry,它的 keyThreadLocal<?> k ,继承自 WeakReference,也就是我们常说的弱引用类型。

GC 之后 key 是否为 null?

回应开头的那个问题, ThreadLocalkey 是弱引用,那么在 ThreadLocal.Get () 的时候,发生 GC 之后,key 是否是 null

为了搞清楚这个问题,我们需要搞清楚 Java四种引用类型

  • 强引用:我们常常 new 出来的对象就是强引用类型,只要强引用存在,垃圾回收器将永远不会回收被引用的对象,哪怕内存不足的时候
  • 软引用:使用 SoftReference 修饰的对象被称为软引用,软引用指向的对象在内存要溢出的时候被回收
  • 弱引用:使用 WeakReference 修饰的对象被称为弱引用,只要发生垃圾回收,若这个对象只被弱引用指向,那么就会被回收
  • 虚引用:虚引用是最弱的引用,在 Java 中使用 PhantomReference 进行定义。虚引用中唯一的作用就是用队列接收对象即将死亡的通知

接着再来看下代码,我们使用反射的方式来看看 GCThreadLocal 中的数据情况:(下面代码来源自: https://blog.csdn.net/thewindkee/article/details/103726942 本地运行演示 GC 回收场景)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
Public class ThreadLocalDemo {

Public static void main (String[] args) throws NoSuchFieldException, IllegalAccessException, InterruptedException {
Thread t = new Thread (()->test ("abc", false));
t.start ();
t.join ();
System.Out.Println ("--gc 后--");
Thread t 2 = new Thread (() -> test ("def", true));
T 2.Start ();
T 2.Join ();
}

Private static void test (String s, boolean isGC) {
Try {
new ThreadLocal<>(). Set (s);
If (isGC) {
System.Gc ();
}
Thread t = Thread.CurrentThread ();
Class<? extends Thread> clz = t.getClass ();
Field field = clz.GetDeclaredField ("threadLocals");
Field.SetAccessible (true);
Object ThreadLocalMap = field.Get (t);
Class<?> tlmClass = ThreadLocalMap.GetClass ();
Field tableField = tlmClass.GetDeclaredField ("table");
TableField.SetAccessible (true);
Object[] arr = (Object[]) tableField.Get (ThreadLocalMap);
For (Object o : arr) {
If (o != null) {
Class<?> entryClass = o.getClass ();
Field valueField = entryClass.GetDeclaredField ("value");
Field referenceField = entryClass.GetSuperclass (). GetSuperclass (). GetDeclaredField ("referent");
ValueField.SetAccessible (true);
ReferenceField.SetAccessible (true);
System.Out.Println (String.Format ("弱引用 key:%s, 值:%s", referenceField.Get (o), valueField.Get (o)));
}
}
} catch (Exception e) {
e.printStackTrace ();
}
}
}

结果如下:

1
2
3
4
弱引用 key: java.lang.ThreadLocal@433619b6 , 值:abc
弱引用 key: java.lang.ThreadLocal@418a15e3 , 值: java.lang.ref.SoftReference@bf97a12
--gc 后--
弱引用 key: null, 值:def

如图所示,因为这里创建的 ThreadLocal 并没有指向任何值,也就是没有任何引用:

1
new ThreadLocal<>(). Set (s);

所以这里在 GC 之后,key 就会被回收,我们看到上面 debug 中的 referent=null, 如果改动一下代码:

这个问题刚开始看,如果没有过多思考,弱引用,还有垃圾回收,那么肯定会觉得是 null

其实是不对的,因为题目说的是在做 ThreadLocal.Get () 操作,证明其实还是有强引用存在的,所以 key 并不为 null,如下图所示,ThreadLocal强引用仍然是存在的。

如果我们的强引用不存在的话,那么 key 就会被回收,也就是会出现我们 value 没被回收,key 被回收,导致 value 永远存在,出现内存泄漏。

ThreadLocal.Set () 方法源码详解

ThreadLocal 中的 set 方法原理如上图所示,很简单,主要是判断 ThreadLocalMap 是否存在,然后使用 ThreadLocal 中的 set 方法进行数据处理。

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
Public void set (T value) {
Thread t = Thread.CurrentThread ();
ThreadLocalMap map = getMap (t);
If (map != null)
Map.Set (this, value);
Else
CreateMap (t, value);
}

Void createMap (Thread t, T firstValue) {
t.threadLocals = new ThreadLocalMap (this, firstValue);
}

主要的核心逻辑还是在 ThreadLocalMap 中的,一步步往下看,后面还有更详细的剖析。

ThreadLocalMap Hash 算法

既然是 Map 结构,那么 ThreadLocalMap 当然也要实现自己的 hash 算法来解决散列表数组冲突问题。

1
Int i = key. ThreadLocalHashCode & (len-1);

ThreadLocalMaphash 算法很简单,这里 i 就是当前 key 在散列表中对应的数组下标位置。

这里最关键的就是 threadLocalHashCode 值的计算,ThreadLocal 中有一个属性为 HASH_INCREMENT = 0 x 61 c 88647

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class ThreadLocal<T> {
Private final int threadLocalHashCode = nextHashCode ();

Private static AtomicInteger nextHashCode = new AtomicInteger ();

Private static final int HASH_INCREMENT = 0 x 61 c 88647;

Private static int nextHashCode () {
Return nextHashCode.GetAndAdd (HASH_INCREMENT);
}

Static class ThreadLocalMap {
ThreadLocalMap (ThreadLocal<?> firstKey, Object firstValue) {
Table = new Entry[INITIAL_CAPACITY];
Int i = firstKey. ThreadLocalHashCode & (INITIAL_CAPACITY - 1);

Table[i] = new Entry (firstKey, firstValue);
Size = 1;
SetThreshold (INITIAL_CAPACITY);
}
}
}

每当创建一个 ThreadLocal 对象,这个 ThreadLocal. NextHashCode 这个值就会增长 0 x 61 c 88647

这个值很特殊,它是斐波那契数 也叫 黄金分割数hash 增量为这个数字,带来的好处就是 hash 分布非常均匀

我们自己可以尝试下:

可以看到产生的哈希码分布很均匀,这里不去细纠斐波那契具体算法,感兴趣的可以自行查阅相关资料。

ThreadLocalMap Hash 冲突

注明: 下面所有示例图中,绿色块Entry 代表正常数据灰色块代表 Entrykey 值为 null已被垃圾回收白色块表示 Entrynull

虽然 ThreadLocalMap 中使用了黄金分割数来作为 hash 计算因子,大大减少了 Hash 冲突的概率,但是仍然会存在冲突。

HashMap 中解决冲突的方法是在数组上构造一个链表结构,冲突的数据挂载到链表上,如果链表长度超过一定数量则会转化成红黑树

ThreadLocalMap 中并没有链表结构,所以这里不能使用 HashMap 解决冲突的方式了。

如上图所示,如果我们插入一个 value=27 的数据,通过 hash 计算后应该落入槽位 4 中,而槽位 4 已经有了 Entry 数据。

此时就会线性向后查找,一直找到 Entrynull 的槽位才会停止查找,将当前元素放入此槽位中。当然迭代过程中还有其他的情况,比如遇到了 Entry 不为 nullkey 值相等的情况,还有 Entry 中的 key 值为 null 的情况等等都会有不同的处理,后面会一一详细讲解。

这里还画了一个 Entry 中的 keynull 的数据(Entry=2 的灰色块数据),因为 key 值是弱引用类型,所以会有这种数据存在。在 set 过程中,如果遇到了 key 过期的 Entry 数据,实际上是会进行一轮探测式清理操作的,具体操作方式后面会讲到。

ThreadLocalMap.Set () 详解

ThreadLocalMap.Set () 原理图解

看完了 ThreadLocal hash 算法后,我们再来看 set 是如何实现的。

ThreadLocalMapset 数据(新增或者更新数据)分为好几种情况,针对不同的情况我们画图来说明。

第一种情况: 通过 hash 计算后的槽位对应的 Entry 数据为空:

这里直接将数据放到该槽位即可。

第二种情况: 槽位数据不为空,key 值与当前 ThreadLocal 通过 hash 计算获取的 key 值一致:

这里直接更新该槽位的数据。

第三种情况: 槽位数据不为空,往后遍历过程中,在找到 Entrynull 的槽位之前,没有遇到 key 过期的 Entry

遍历散列数组,线性往后查找,如果找到 Entrynull 的槽位,则将数据放入该槽位中,或者往后遍历过程中,遇到了key 值相等的数据,直接更新即可。

第四种情况: 槽位数据不为空,往后遍历过程中,在找到 Entrynull 的槽位之前,遇到 key 过期的 Entry,如下图,往后遍历过程中,遇到了 index=7 的槽位数据 Entrykey=null

散列数组下标为 7 位置对应的 Entry 数据 keynull,表明此数据 key 值已经被垃圾回收掉了,此时就会执行 replaceStaleEntry () 方法,该方法含义是替换过期数据的逻辑,以index=7位起点开始遍历,进行探测式数据清理工作。

初始化探测式清理过期数据扫描的开始位置:slotToExpunge = staleSlot = 7

以当前 staleSlot 开始向前迭代查找,找其他过期的数据,然后更新过期数据起始扫描下标 slotToExpungefor 循环迭代,直到碰到 Entrynull 结束。

如果找到了过期的数据,继续向前迭代,直到遇到 Entry=null 的槽位才停止迭代,如下图所示,slotToExpunge 被更新为 0

以当前节点 (index=7)向前迭代,检测是否有过期的 Entry 数据,如果有则更新 slotToExpunge 值。碰到 null 则结束探测。以上图为例 slotToExpunge 被更新为 0。

上面向前迭代的操作是为了更新探测清理过期数据的起始下标 slotToExpunge 的值,这个值在后面会讲解,它是用来判断当前过期槽位 staleSlot 之前是否还有过期元素。

接着开始以 staleSlot 位置 (index=7)向后迭代,如果找到了相同 key 值的 Entry 数据:

从当前节点 staleSlot 向后查找 key 值相等的 Entry 元素,找到后更新 Entry 的值并交换 staleSlot 元素的位置 (staleSlot 位置为过期元素),更新 Entry 数据,然后开始进行过期 Entry 的清理工作,如下图所示:

向后遍历过程中,如果没有找到相同 key 值的 Entry 数据:

从当前节点 staleSlot 向后查找 key 值相等的 Entry 元素,直到 Entrynull 则停止寻找。通过上图可知,此时 table 中没有 key 值相同的 Entry

创建新的 Entry,替换 table[stableSlot] 位置:

替换完成后也是进行过期元素清理工作,清理工作主要是有两个方法:expungeStaleEntry ()cleanSomeSlots (),具体细节后面会讲到,请继续往后看。

ThreadLocalMap.Set () 源码详解

上面已经用图的方式解析了 set () 实现的原理,其实已经很清晰了,我们接着再看下源码:

java. Lang. ThreadLocal. ThreadLocalMap.Set ():

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
private void set (ThreadLocal<?> key, Object value) {
Entry[] tab = table;
Int len = tab. Length;
Int i = key. ThreadLocalHashCode & (len-1);

For (Entry e = tab[i];
E != null;
E = tab[i = nextIndex (i, len)]) {
ThreadLocal<?> k = e.get ();

If (k == key) {
e.value = value;
Return;
}

If (k == null) {
ReplaceStaleEntry (key, value, i);
Return;
}
}

Tab[i] = new Entry (key, value);
Int sz = ++size;
If (! CleanSomeSlots (i, sz) && sz >= threshold)
Rehash ();
}

这里会通过 key 来计算在散列表中的对应位置,然后以当前 key 对应的桶的位置向后查找,找到可以使用的桶。

1
2
3
Entry[] tab = table;
Int len = tab. Length;
Int i = key. ThreadLocalHashCode & (len-1);

什么情况下桶才是可以使用的呢?

  1. k = key 说明是替换操作,可以使用
  2. 碰到一个过期的桶,执行替换逻辑,占用过期桶
  3. 查找过程中,碰到桶中 Entry=null 的情况,直接使用

接着就是执行 for 循环遍历,向后查找,我们先看下 nextIndex ()prevIndex () 方法实现:

1
2
3
4
5
6
7
Private static int nextIndex (int i, int len) {
Return ((i + 1 < len) ? I + 1 : 0);
}

Private static int prevIndex (int i, int len) {
Return ((i - 1 >= 0) ? I - 1 : len - 1);
}

接着看剩下 for 循环中的逻辑:

  1. 遍历当前 key 值对应的桶中 Entry 数据为空,这说明散列数组这里没有数据冲突,跳出 for 循环,直接 set 数据到对应的桶中
  2. 如果 key 值对应的桶中 Entry 数据不为空
    2.1 如果 k = key,说明当前 set 操作是一个替换操作,做替换逻辑,直接返回
    2.2 如果 key = null,说明当前桶位置的 Entry 是过期数据,执行 replaceStaleEntry () 方法 (核心方法),然后返回
  3. for 循环执行完毕,继续往下执行说明向后迭代的过程中遇到了 entrynull 的情况
    3.1 在 Entrynull 的桶中创建一个新的 Entry 对象
    3.2 执行 ++size 操作
  4. 调用 cleanSomeSlots () 做一次启发式清理工作,清理散列数组中 Entrykey 过期的数据
    4.1 如果清理工作完成后,未清理到任何数据,且 size 超过了阈值 (数组长度的 2/3),进行 rehash () 操作
    4.2 rehash () 中会先进行一轮探测式清理,清理过期 key,清理完成后如果size >= threshold - threshold / 4,就会执行真正的扩容逻辑 (扩容逻辑往后看)

接着重点看下 replaceStaleEntry () 方法,replaceStaleEntry () 方法提供替换过期数据的功能,我们可以对应上面第四种情况的原理图来再回顾下,具体代码如下:

java.Lang.ThreadLocal.ThreadLocalMap.ReplaceStaleEntry ():

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
private void replaceStaleEntry (ThreadLocal<?> key, Object value,
Int staleSlot) {
Entry[] tab = table;
Int len = tab. Length;
Entry e;

Int slotToExpunge = staleSlot;
For (int i = prevIndex (staleSlot, len);
(e = tab[i]) != null;
I = prevIndex (i, len))

if (e.get () == null)
SlotToExpunge = i;

For (int i = nextIndex (staleSlot, len);
(e = tab[i]) != null;
I = nextIndex (i, len)) {

ThreadLocal<?> k = e.get ();

If (k == key) {
e.value = value;

Tab[i] = tab[staleSlot];
Tab[staleSlot] = e;

If (slotToExpunge == staleSlot)
SlotToExpunge = i;
CleanSomeSlots (expungeStaleEntry (slotToExpunge), len);
Return;
}

If (k == null && slotToExpunge == staleSlot)
SlotToExpunge = i;
}

Tab[staleSlot]. Value = null;
Tab[staleSlot] = new Entry (key, value);

If (slotToExpunge != staleSlot)
CleanSomeSlots (expungeStaleEntry (slotToExpunge), len);
}

slotToExpunge 表示开始探测式清理过期数据的开始下标,默认从当前的 staleSlot 开始。以当前的 staleSlot 开始,向前迭代查找,找到没有过期的数据,for 循环一直碰到 Entrynull 才会结束。如果向前找到了过期数据,更新探测清理过期数据的开始下标为 i,即 slotToExpunge=i

1
2
3
4
5
6
7
8
For (int i = prevIndex (staleSlot, len);
(e = tab[i]) != null;
I = prevIndex (i, len)){

if (e.get () == null){
SlotToExpunge = i;
}
}

接着开始从 staleSlot 向后查找,也是碰到 Entrynull 的桶结束。如果迭代过程中,碰到 k == key,这说明这里是替换逻辑,替换新数据并且交换当前 staleSlot 位置。如果 slotToExpunge == staleSlot,这说明 replaceStaleEntry () 一开始向前查找过期数据时并未找到过期的 Entry 数据,接着向后查找过程中也未发现过期数据,修改开始探测式清理过期数据的下标为当前循环的 index,即 slotToExpunge = i。最后调用 cleanSomeSlots (expungeStaleEntry (slotToExpunge), len); 进行启发式过期数据清理。

1
2
3
4
5
6
7
8
9
10
11
12
If (k == key) {
e.value = value;

Tab[i] = tab[staleSlot];
Tab[staleSlot] = e;

If (slotToExpunge == staleSlot)
SlotToExpunge = i;

CleanSomeSlots (expungeStaleEntry (slotToExpunge), len);
Return;
}

cleanSomeSlots ()expungeStaleEntry () 方法后面都会细讲,这两个是和清理相关的方法,一个是过期 key 相关 Entry 的启发式清理 (Heuristically scan),另一个是过期 key 相关 Entry 的探测式清理。

如果 k != key则会接着往下走,k == null 说明当前遍历的 Entry 是一个过期数据,slotToExpunge == staleSlot 说明,一开始的向前查找数据并未找到过期的 Entry。如果条件成立,则更新 slotToExpunge 为当前位置,这个前提是前驱节点扫描时未发现过期数据。

1
2
If (k == null && slotToExpunge == staleSlot)
SlotToExpunge = i;

往后迭代的过程中如果没有找到 k == key 的数据,且碰到 Entrynull 的数据,则结束当前的迭代操作。此时说明这里是一个添加的逻辑,将新的数据添加到 table[staleSlot] 对应的 slot 中。

1
2
Tab[staleSlot]. Value = null;
Tab[staleSlot] = new Entry (key, value);

最后判断除了 staleSlot 以外,还发现了其他过期的 slot 数据,就要开启清理数据的逻辑:

1
2
If (slotToExpunge != staleSlot)
CleanSomeSlots (expungeStaleEntry (slotToExpunge), len);

ThreadLocalMap 过期 key 的探测式清理流程

上面我们有提及 ThreadLocalMap 的两种过期 key 数据清理方式:探测式清理启发式清理

我们先讲下探测式清理,也就是 expungeStaleEntry 方法,遍历散列数组,从开始位置向后探测清理过期数据,将过期数据的 Entry 设置为 null,沿途中碰到未过期的数据则将此数据 rehash 后重新在 table 数组中定位,如果定位的位置已经有了数据,则会将未过期的数据放到最靠近此位置的 Entry=null 的桶中,使 rehash 后的 Entry 数据距离正确的桶的位置更近一些。操作逻辑如下:

如上图,set (27) 经过 hash 计算后应该落到 index=4 的桶中,由于 index=4 桶已经有了数据,所以往后迭代最终数据放入到 index=7 的桶中,放入后一段时间后 index=5 中的 Entry 数据 key 变为了 null

如果再有其他数据 setmap 中,就会触发探测式清理操作。

如上图,执行探测式清理后,index=5 的数据被清理掉,继续往后迭代,到 index=7 的元素时,经过 rehash 后发现该元素正确的 index=4,而此位置已经有了数据,往后查找离 index=4 最近的 Entry=null 的节点 (刚被探测式清理掉的数据:index=5),找到后移动 index= 7 的数据到 index=5 中,此时桶的位置离正确的位置 index=4 更近了。

经过一轮探测式清理后,key 过期的数据会被清理掉,没过期的数据经过 rehash 重定位后所处的桶位置理论上更接近 i= key. HashCode & (tab. Len - 1) 的位置。这种优化会提高整个散列表查询性能。

接着看下 expungeStaleEntry () 具体流程,我们还是以先原理图后源码讲解的方式来一步步梳理:

我们假设 expungeStaleEntry (3) 来调用此方法,如上图所示,我们可以看到 ThreadLocalMaptable 的数据情况,接着执行清理操作:

第一步是清空当前 staleSlot 位置的数据,index=3 位置的 Entry 变成了 null。然后接着往后探测:

执行完第二步后,index=4 的元素挪到 index=3 的槽位中。

继续往后迭代检查,碰到正常数据,计算该数据位置是否偏移,如果被偏移,则重新计算 slot 位置,目的是让正常数据尽可能存放在正确位置或离正确位置更近的位置

在往后迭代的过程中碰到空的槽位,终止探测,这样一轮探测式清理工作就完成了,接着我们继续看看具体实现源代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
Private int expungeStaleEntry (int staleSlot) {
Entry[] tab = table;
Int len = tab. Length;

Tab[staleSlot]. Value = null;
Tab[staleSlot] = null;
Size--;

Entry e;
Int i;
For (i = nextIndex (staleSlot, len);
(e = tab[i]) != null;
I = nextIndex (i, len)) {
ThreadLocal<?> k = e.get ();
If (k == null) {
e.value = null;
Tab[i] = null;
Size--;
} else {
int h = k.threadLocalHashCode & (len - 1);
If (h != i) {
Tab[i] = null;

While (tab[h] != null)
H = nextIndex (h, len);
Tab[h] = e;
}
}
}
Return i;
}

这里我们还是以 staleSlot=3 来做示例说明,首先是将 tab[staleSlot] 槽位的数据清空,然后设置 size-- 接着以 staleSlot 位置往后迭代,如果遇到 k==null 的过期数据,也是清空该槽位数据,然后 size--

1
2
3
4
5
6
7
ThreadLocal<?> k = e.get ();

If (k == null) {
e.value = null;
Tab[i] = null;
Size--;
}

如果 key 没有过期,重新计算当前 key 的下标位置是不是当前槽位下标位置,如果不是,那么说明产生了 hash 冲突,此时以新计算出来正确的槽位位置往后迭代,找到最近一个可以存放 entry 的位置。

1
2
3
4
5
6
7
8
9
int h = k.threadLocalHashCode & (len - 1);
If (h != i) {
Tab[i] = null;

While (tab[h] != null)
H = nextIndex (h, len);

Tab[h] = e;
}

这里是处理正常的产生 Hash 冲突的数据,经过迭代后,有过 Hash 冲突数据的 Entry 位置会更靠近正确位置,这样的话,查询的时候效率才会更高。

ThreadLocalMap 扩容机制

ThreadLocalMap.Set () 方法的最后,如果执行完启发式清理工作后,未清理到任何数据,且当前散列数组中 Entry 的数量已经达到了列表的扩容阈值 (len*2/3),就开始执行 rehash () 逻辑:

1
2
If (! CleanSomeSlots (i, sz) && sz >= threshold)
Rehash ();

接着看下 rehash () 具体实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Private void rehash () {
ExpungeStaleEntries ();

If (size >= threshold - threshold / 4)
Resize ();
}

Private void expungeStaleEntries () {
Entry[] tab = table;
Int len = tab. Length;
For (int j = 0; j < len; j++) {
Entry e = tab[j];
if (e != null && e.get () == null)
ExpungeStaleEntry (j);
}
}

这里首先是会进行探测式清理工作,从 table 的起始位置往后清理,上面有分析清理的详细流程。清理完成之后,table 中可能有一些 keynullEntry 数据被清理掉,所以此时通过判断 size >= threshold - threshold / 4 也就是 size >= threshold * 3/4 来决定是否扩容。

我们还记得上面进行 rehash () 的阈值是 size >= threshold,所以当面试官套路我们 ThreadLocalMap 扩容机制的时候我们一定要说清楚这两个步骤:

接着看看具体的 resize () 方法,为了方便演示,我们以 oldTab. Len=8 来举例:

扩容后的 tab 的大小为 oldLen * 2,然后遍历老的散列表,重新计算 hash 位置,然后放到新的 tab 数组中,如果出现 hash 冲突则往后寻找最近的 entrynull 的槽位,遍历完成之后,oldTab 中所有的 entry 数据都已经放入到新的 tab 中了。重新计算 tab 下次扩容的阈值,具体代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
Private void resize () {
Entry[] oldTab = table;
Int oldLen = oldTab. Length;
Int newLen = oldLen * 2;
Entry[] newTab = new Entry[newLen];
Int count = 0;

For (int j = 0; j < oldLen; ++j) {
Entry e = oldTab[j];
If (e != null) {
ThreadLocal<?> k = e.get ();
If (k == null) {
e.value = null;
} else {
int h = k.threadLocalHashCode & (newLen - 1);
While (newTab[h] != null)
H = nextIndex (h, newLen);
NewTab[h] = e;
Count++;
}
}
}

SetThreshold (newLen);
Size = count;
Table = newTab;
}

ThreadLocalMap.Get () 详解

上面已经看完了 set () 方法的源码,其中包括 set 数据、清理数据、优化数据桶的位置等操作,接着看看 get () 操作的原理。

ThreadLocalMap.Get () 图解

第一种情况: 通过查找 key 值计算出散列表中 slot 位置,然后该 slot 位置中的 Entry. Key 和查找的 key 一致,则直接返回:

第二种情况: slot 位置中的 Entry. Key 和要查找的 key 不一致:

我们以 get (ThreadLocal 1) 为例,通过 hash 计算后,正确的 slot 位置应该是 4,而 index=4 的槽位已经有了数据,且 key 值不等于 ThreadLocal 1,所以需要继续往后迭代查找。

迭代到 index=5 的数据时,此时 Entry. Key=null,触发一次探测式数据回收操作,执行 expungeStaleEntry () 方法,执行完后,index 5,8 的数据都会被回收,而 index 6,7 的数据都会前移。index 6,7 前移之后,继续从 index=5 往后迭代,于是就在 index=5 找到了 key 值相等的 Entry 数据,如下图所示:

ThreadLocalMap.Get () 源码详解

java.Lang.ThreadLocal.ThreadLocalMap.GetEntry ():

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
private Entry getEntry (ThreadLocal<?> key) {
Int i = key. ThreadLocalHashCode & (table. Length - 1);
Entry e = table[i];
if (e != null && e.get () == key)
Return e;
Else
Return getEntryAfterMiss (key, i, e);
}

private Entry getEntryAfterMiss (ThreadLocal<?> key, int i, Entry e) {
Entry[] tab = table;
Int len = tab. Length;

While (e != null) {
ThreadLocal<?> k = e.get ();
If (k == key)
Return e;
If (k == null)
ExpungeStaleEntry (i);
Else
I = nextIndex (i, len);
E = tab[i];
}
Return null;
}

ThreadLocalMap 过期 key 的启发式清理流程

上面多次提及到 ThreadLocalMap 过期 key 的两种清理方式:探测式清理 (expungeStaleEntry ())启发式清理 (cleanSomeSlots ())

探测式清理是以当前 Entry 往后清理,遇到值为 null 则结束清理,属于线性探测清理

而启发式清理被作者定义为:Heuristically scan some cells looking for stale entries.

具体代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Private boolean cleanSomeSlots (int i, int n) {
Boolean removed = false;
Entry[] tab = table;
Int len = tab. Length;
Do {
I = nextIndex (i, len);
Entry e = tab[i];
if (e != null && e.get () == null) {
N = len;
Removed = true;
I = expungeStaleEntry (i);
}
} while ( (n >>>= 1) != 0);
Return removed;
}

InheritableThreadLocal

我们使用 ThreadLocal 的时候,在异步场景下是无法给子线程共享父线程中创建的线程副本数据的。

为了解决这个问题,JDK 中还有一个 InheritableThreadLocal 类,我们来看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Public class InheritableThreadLocalDemo {
Public static void main (String[] args) {
ThreadLocal<String> ThreadLocal = new ThreadLocal<>();
ThreadLocal<String> inheritableThreadLocal = new InheritableThreadLocal<>();
ThreadLocal.Set ("父类数据: threadLocal");
InheritableThreadLocal.Set ("父类数据: inheritableThreadLocal");

New Thread (new Runnable () {
@Override
Public void run () {
System.Out.Println ("子线程获取父类 ThreadLocal 数据:" + ThreadLocal.Get ());
System.Out.Println ("子线程获取父类 inheritableThreadLocal 数据:" + inheritableThreadLocal.Get ());
}
}). Start ();
}
}

打印结果:

1
2
子线程获取父类 ThreadLocal 数据:null
子线程获取父类 inheritableThreadLocal 数据:父类数据:inheritableThreadLocal

实现原理是子线程是通过在父线程中通过调用 new Thread () 方法来创建子线程,Thread #init 方法在 Thread 的构造方法中被调用。在 init 方法中拷贝父线程数据到子线程中:

1
2
3
4
5
6
7
8
9
10
11
12
13
Private void init (ThreadGroup g, Runnable target, String name,
Long stackSize, AccessControlContext acc,
Boolean inheritThreadLocals) {
If (name == null) {
Throw new NullPointerException ("name cannot be null");
}

If (inheritThreadLocals && parent. InheritableThreadLocals != null)
This. InheritableThreadLocals =
ThreadLocal.CreateInheritedMap (parent. InheritableThreadLocals);
This. StackSize = stackSize;
Tid = nextThreadID ();
}

InheritableThreadLocal 仍然有缺陷,一般我们做异步化处理都是使用的线程池,而 InheritableThreadLocal 是在 new Thread 中的 init () 方法给赋值的,而线程池是线程复用的逻辑,所以这里会存在问题。

当然,有问题出现就会有解决问题的方案,阿里巴巴开源了一个 TransmittableThreadLocal 组件就可以解决这个问题,这里就不再延伸,感兴趣的可自行查阅资料。

ThreadLocal 项目中使用实战

ThreadLocal 使用场景

我们现在项目中日志记录用的是 ELK+Logstash,最后在 Kibana 中进行展示和检索。

现在都是分布式系统统一对外提供服务,项目间调用的关系可以通过 traceId 来关联,但是不同项目之间如何传递 traceId 呢?

这里我们使用 org. Slf 4 j. MDC 来实现此功能,内部就是通过 ThreadLocal 来实现的,具体实现如下:

当前端发送请求到服务 A时,服务 A会生成一个类似 UUIDtraceId 字符串,将此字符串放入当前线程的 ThreadLocal 中,在调用服务 B的时候,将 traceId 写入到请求的 Header 中,服务 B在接收请求时会先判断请求的 Header 中是否有 traceId,如果存在则写入自己线程的 ThreadLocal 中。

图中的 requestId 即为我们各个系统链路关联的 traceId,系统间互相调用,通过这个 requestId 即可找到对应链路,这里还有会有一些其他场景:

针对于这些场景,我们都可以有相应的解决方案,如下所示

Feign 远程调用解决方案

服务发送请求:

1
2
3
4
5
6
7
8
9
10
11
12
@Component
@Slf 4 j
Public class FeignInvokeInterceptor implements RequestInterceptor {

@Override
Public void apply (RequestTemplate template) {
String requestId = MDC.Get ("requestId");
If (StringUtils.IsNotBlank (requestId)) {
Template.Header ("requestId", requestId);
}
}
}

服务接收请求:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Slf 4 j
@Component
Public class LogInterceptor extends HandlerInterceptorAdapter {

@Override
Public void afterCompletion (HttpServletRequest arg 0, HttpServletResponse arg 1, Object arg 2, Exception arg 3) {
MDC.Remove ("requestId");
}

@Override
Public void postHandle (HttpServletRequest arg 0, HttpServletResponse arg 1, Object arg 2, ModelAndView arg 3) {
}

@Override
Public boolean preHandle (HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {

String requestId = request.GetHeader (BaseConstant. REQUEST_ID_KEY);
If (StringUtils.IsBlank (requestId)) {
RequestId = UUID.RandomUUID (). ToString (). Replace ("-", "");
}
MDC.Put ("requestId", requestId);
Return true;
}
}

线程池异步调用,requestId 传递

因为 MDC 是基于 ThreadLocal 去实现的,异步过程中,子线程并没有办法获取到父线程 ThreadLocal 存储的数据,所以这里可以自定义线程池执行器,修改其中的 run () 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Public class MyThreadPoolTaskExecutor extends ThreadPoolTaskExecutor {

@Override
Public void execute (Runnable runnable) {
Map<String, String> context = MDC.GetCopyOfContextMap ();
Super.Execute (() -> run (runnable, context));
}

@Override
private void run (Runnable runnable, Map<String, String> context) {
If (context != null) {
MDC.SetContextMap (context);
}
Try {
Runnable.Run ();
} finally {
MDC.Remove ();
}
}
}

使用 MQ 发送消息给第三方系统

在 MQ 发送的消息体中自定义属性 requestId,接收方消费消息后,自己解析 requestId 使用即可。

线程池

线程池介绍

顾名思义,线程池就是管理一系列线程的资源池,其提供了一种限制和管理线程资源的方式。每个线程池还维护一些基本统计信息,例如已完成任务的数量。

为什么要用线程池?

这里借用《Java 并发编程的艺术》书中的部分内容来总结一下使用线程池的好处:

  • 降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
  • 提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立即执行。
  • 提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。

线程池一般用于执行多个不相关联的耗时任务,没有多线程的情况下,任务顺序执行,使用了线程池的话可让多个不相关联的任务同时执行。

Executor 介绍

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

This 逃逸是指在构造函数返回之前其他线程就持有该对象的引用,调用尚未构造完全的对象的方法可能引发令人疑惑的错误。

Executor 框架不仅包括了线程池的管理,还提供了线程工厂、队列以及拒绝策略等,Executor 框架让并发编程变得更加简单。

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

1、任务 (Runnable / Callable)

执行任务需要实现的 Runnable 接口Callable 接口。**Runnable 接口**或 Callable 接口 实现类都可以被 ThreadPoolExecutorScheduledThreadPoolExecutor 执行。

2、任务的执行 (Executor)

如下图所示,包括任务执行机制的核心接口 Executor ,以及继承自 Executor 接口的 ExecutorService 接口。ThreadPoolExecutorScheduledThreadPoolExecutor 这两个关键类实现了 ExecutorService 接口。

这里提了很多底层的类关系,但是,实际上我们需要更多关注的是 ThreadPoolExecutor 这个类,这个类在我们实际使用线程池的过程中,使用频率还是非常高的。

注意: 通过查看 ScheduledThreadPoolExecutor 源代码我们发现 ScheduledThreadPoolExecutor 实际上是继承了 ThreadPoolExecutor 并实现了 ScheduledExecutorService ,而 ScheduledExecutorService 又实现了 ExecutorService,正如我们上面给出的类关系图显示的一样。

ThreadPoolExecutor 类描述:

1
2
//AbstractExecutorService 实现了 ExecutorService 接口
Public class ThreadPoolExecutor extends AbstractExecutorService

ScheduledThreadPoolExecutor 类描述:

1
2
3
4
//ScheduledExecutorService 继承 ExecutorService 接口
Public class ScheduledThreadPoolExecutor
Extends ThreadPoolExecutor
Implements ScheduledExecutorService

3、异步计算的结果 (Future)

Future 接口以及 Future 接口的实现类 FutureTask 类都可以代表异步计算的结果。

当我们把 Runnable 接口Callable 接口 的实现类提交给 ThreadPoolExecutorScheduledThreadPoolExecutor 执行。(调用 submit () 方法时会返回一个 FutureTask 对象)

Executor 框架的使用示意图

Executor 框架的使用示意图

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

ThreadPoolExecutor 介绍(重要)

线程池实现类 ThreadPoolExecutorExecutor 框架最核心的类。

构造方法介绍

ThreadPoolExecutor 类中提供的四个构造方法。我们来看最长的那个,其余三个都是在这个构造方法的基础上产生(其他几个构造方法说白点都是给定某些默认参数的构造方法比如默认制定拒绝策略是什么)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/**
* 用给定的初始参数创建一个新的 ThreadPoolExecutor。
*/
Public ThreadPoolExecutor (int corePoolSize,//线程池的核心线程数量
Int maximumPoolSize,//线程池的最大线程数
Long keepAliveTime,//当线程数大于核心线程数时,多余的空闲线程存活的最长时间
TimeUnit unit,//时间单位
BlockingQueue<Runnable> workQueue,//任务队列,用来储存等待执行任务的队列
ThreadFactory threadFactory,//线程工厂,用来创建线程,一般默认即可
RejectedExecutionHandler handler//拒绝策略,当提交的任务过多而不能及时处理时,我们可以定制策略来处理任务
) {
If (corePoolSize < 0 ||
MaximumPoolSize <= 0 ||
MaximumPoolSize < corePoolSize ||
KeepAliveTime < 0)
Throw new IllegalArgumentException ();
If (workQueue == null || threadFactory == null || handler == null)
Throw new NullPointerException ();
This. CorePoolSize = corePoolSize;
This. MaximumPoolSize = maximumPoolSize;
This. WorkQueue = workQueue;
This. KeepAliveTime = unit.ToNanos (keepAliveTime);
This. ThreadFactory = threadFactory;
This. Handler = handler;
}

下面这些对创建非常重要,在后面使用线程池的过程中你一定会用到!所以,务必拿着小本本记清楚。

ThreadPoolExecutor 3 个最重要的参数:

  • corePoolSize : 任务队列未达到队列容量时,最大可以同时运行的线程数量。
  • maximumPoolSize : 任务队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数。
  • workQueue: 新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。

ThreadPoolExecutor 其他常见参数 :

  • keepAliveTime: 线程池中的线程数量大于 corePoolSize 的时候,如果这时没有新的任务提交,核心线程外的线程不会立即销毁,而是会等待,直到等待的时间超过了 keepAliveTime 才会被回收销毁。
  • unit : keepAliveTime 参数的时间单位。
  • threadFactory : executor 创建新线程的时候会用到。
  • handler : 饱和策略。关于饱和策略下面单独介绍一下。

下面这张图可以加深你对线程池中各个参数的相互关系的理解(图片来源:《Java 性能调优实战》):

线程池各个参数的关系

线程池的饱和策略有哪些?

ThreadPoolExecutor 饱和策略定义:

如果当前同时运行的线程数量达到最大线程数量并且队列也已经被放满了任务时,ThreadPoolTaskExecutor 定义一些策略:

  • ThreadPoolExecutor. AbortPolicy:抛出 RejectedExecutionException 来拒绝新任务的处理。
  • ThreadPoolExecutor. CallerRunsPolicy:调用执行自己的线程运行任务,也就是直接在调用 execute 方法的线程中运行 (run)被拒绝的任务,如果执行程序已关闭,则会丢弃该任务。因此这种策略会降低对于新任务提交速度,影响程序的整体性能。如果您的应用程序可以承受此延迟并且你要求任何一个任务请求都要被执行的话,你可以选择这个策略。
  • ThreadPoolExecutor. DiscardPolicy:不处理新任务,直接丢弃掉。
  • ThreadPoolExecutor. DiscardOldestPolicy:此策略将丢弃最早的未处理的任务请求。

举个例子:

Spring 通过 ThreadPoolTaskExecutor 或者我们直接通过 ThreadPoolExecutor 的构造函数创建线程池的时候,当我们不指定 RejectedExecutionHandler 饱和策略的话来配置线程池的时候默认使用的是 ThreadPoolExecutor. AbortPolicy。在默认情况下,ThreadPoolExecutor 将抛出 RejectedExecutionException 来拒绝新来的任务,这代表你将丢失对这个任务的处理。对于可伸缩的应用程序,建议使用 ThreadPoolExecutor. CallerRunsPolicy。当最大池被填满时,此策略为我们提供可伸缩队列(这个直接查看 ThreadPoolExecutor 的构造函数源码就可以看出,比较简单的原因,这里就不贴代码了)。

线程池创建两种方式

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

通过构造方法实现

通过构造方法实现

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

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

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

对应 Executors 工具类中的方法如图所示:

为什么不推荐使用内置线程池

《阿里巴巴 Java 开发手册》强制线程池不允许使用 Executors 去创建,而是通过 ThreadPoolExecutor 构造函数的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险

Executors 返回线程池对象的弊端如下 (后文会详细介绍到):

  • **FixedThreadPoolSingleThreadExecutor**:使用的是无界的 LinkedBlockingQueue,任务队列最大长度为 Integer. MAX_VALUE, 可能堆积大量的请求,从而导致 OOM。
  • **CachedThreadPool**:使用的是同步队列 SynchronousQueue, 允许创建的线程数量为 Integer. MAX_VALUE ,可能会创建大量线程,从而导致 OOM。
  • ScheduledThreadPoolSingleThreadScheduledExecutor : 使用的无界的延迟阻塞队列 DelayedWorkQueue,任务队列最大长度为 Integer. MAX_VALUE, 可能堆积大量的请求,从而导致 OOM。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 无界队列 LinkedBlockingQueue
Public static ExecutorService newFixedThreadPool (int nThreads) {

return new ThreadPoolExecutor (nThreads, nThreads, 0 L, TimeUnit. MILLISECONDS, new LinkedBlockingQueue<Runnable>());

}

// 无界队列 LinkedBlockingQueue
Public static ExecutorService newSingleThreadExecutor () {

return new FinalizableDelegatedExecutorService (new ThreadPoolExecutor (1, 1,0 L, TimeUnit. MILLISECONDS, new LinkedBlockingQueue<Runnable>()));

}

// 同步队列 SynchronousQueue,没有容量,最大线程数是 Integer. MAX_VALUE`
Public static ExecutorService newCachedThreadPool () {

return new ThreadPoolExecutor (0, Integer. MAX_VALUE, 60 L, TimeUnit. SECONDS, new SynchronousQueue<Runnable>());

}

// DelayedWorkQueue(延迟阻塞队列)
Public static ScheduledExecutorService newScheduledThreadPool (int corePoolSize) {
Return new ScheduledThreadPoolExecutor (corePoolSize);
}
Public ScheduledThreadPoolExecutor (int corePoolSize) {
Super (corePoolSize, Integer. MAX_VALUE, 0, NANOSECONDS,
New DelayedWorkQueue ());
}

线程池常用的阻塞队列总结

新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。

不同的线程池会选用不同的阻塞队列,我们可以结合内置线程池来分析。

  • 容量为 Integer. MAX_VALUELinkedBlockingQueue(无界队列):FixedThreadPoolSingleThreadExectorFixedThreadPool 最多只能创建核心线程数的线程(核心线程数和最大线程数相等),SingleThreadExector 只能创建一个线程(核心线程数和最大线程数都是 1),二者的任务队列永远不会被放满。
  • SynchronousQueue(同步队列):CachedThreadPoolSynchronousQueue 没有容量,不存储元素,目的是保证对于提交的任务,如果有空闲线程,则使用空闲线程来处理;否则新建一个线程来处理任务。也就是说,CachedThreadPool 的最大线程数是 Integer. MAX_VALUE ,可以理解为线程数是可以无限扩展的,可能会创建大量线程,从而导致 OOM。
  • DelayedWorkQueue(延迟阻塞队列):ScheduledThreadPoolSingleThreadScheduledExecutorDelayedWorkQueue 的内部元素并不是按照放入的时间排序,而是会按照延迟的时间长短对任务进行排序,内部采用的是“堆”的数据结构,可以保证每次出队的任务都是当前队列中执行时间最靠前的。DelayedWorkQueue 添加元素满了之后会自动扩容原来容量的 1/2,即永远不会阻塞,最大扩容可达 Integer. MAX_VALUE,所以最多只能创建核心线程数的线程。

线程池原理分析(重要)

我们上面讲解了 Executor 框架以及 ThreadPoolExecutor 类,下面让我们实战一下,来通过写一个 ThreadPoolExecutor 的小 Demo 来回顾上面的内容。

ThreadPoolExecutor 示例代码

首先创建一个 Runnable 接口的实现类(当然也可以是 Callable 接口,我们上面也说了两者的区别。)

MyRunnable. Java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
Import java. Util. Date;

/**
* 这是一个简单的 Runnable 类,需要大约 5 秒钟来执行其任务。
* @author shuang. Kou
*/
Public class MyRunnable implements Runnable {

Private String command;

Public MyRunnable (String s) {
This. Command = s;
}

@Override
Public void run () {
System.Out.Println (Thread.CurrentThread (). GetName () + " Start. Time = " + new Date ());
ProcessCommand ();
System.Out.Println (Thread.CurrentThread (). GetName () + " End. Time = " + new Date ());
}

Private void processCommand () {
Try {
Thread.Sleep (5000);
} catch (InterruptedException e) {
e.printStackTrace ();
}
}

@Override
Public String toString () {
Return this. Command;
}
}

编写测试程序,我们这里以阿里巴巴推荐的使用 ThreadPoolExecutor 构造函数自定义参数的方式来创建线程池。

ThreadPoolExecutorDemo. Java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
Import java. Util. Concurrent. ArrayBlockingQueue;
Import java. Util. Concurrent. ThreadPoolExecutor;
Import java. Util. Concurrent. TimeUnit;

Public class ThreadPoolExecutorDemo {

Private static final int CORE_POOL_SIZE = 5;
Private static final int MAX_POOL_SIZE = 10;
Private static final int QUEUE_CAPACITY = 100;
Private static final Long KEEP_ALIVE_TIME = 1 L;
Public static void main (String[] args) {

//使用阿里巴巴推荐的创建线程池的方式
//通过 ThreadPoolExecutor 构造函数自定义参数创建
ThreadPoolExecutor executor = new ThreadPoolExecutor (
CORE_POOL_SIZE,
MAX_POOL_SIZE,
KEEP_ALIVE_TIME,
TimeUnit. SECONDS,
new ArrayBlockingQueue<>(QUEUE_CAPACITY),
New ThreadPoolExecutor.CallerRunsPolicy ());

For (int i = 0; i < 10; i++) {
//创建 WorkerThread 对象(WorkerThread 类实现了 Runnable 接口)
Runnable worker = new MyRunnable ("" + i);
//执行 Runnable
Executor.Execute (worker);
}
//终止线程池
Executor.Shutdown ();
While (! Executor.IsTerminated ()) {
}
System.Out.Println ("Finished all threads");
}
}

可以看到我们上面的代码指定了:

  • corePoolSize: 核心线程数为 5。
  • maximumPoolSize:最大线程数 10
  • keepAliveTime : 等待时间为 1 L。
  • unit: 等待时间的单位为 TimeUnit. SECONDS。
  • workQueue:任务队列为 ArrayBlockingQueue,并且容量为 100;
  • handler: 饱和策略为 CallerRunsPolicy

输出结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
pool-1-thread-3 Start. Time = Sun Apr 12 11:14:37 CST 2020
pool-1-thread-5 Start. Time = Sun Apr 12 11:14:37 CST 2020
pool-1-thread-2 Start. Time = Sun Apr 12 11:14:37 CST 2020
pool-1-thread-1 Start. Time = Sun Apr 12 11:14:37 CST 2020
pool-1-thread-4 Start. Time = Sun Apr 12 11:14:37 CST 2020
pool-1-thread-3 End. Time = Sun Apr 12 11:14:42 CST 2020
pool-1-thread-4 End. Time = Sun Apr 12 11:14:42 CST 2020
pool-1-thread-1 End. Time = Sun Apr 12 11:14:42 CST 2020
pool-1-thread-5 End. Time = Sun Apr 12 11:14:42 CST 2020
pool-1-thread-1 Start. Time = Sun Apr 12 11:14:42 CST 2020
pool-1-thread-2 End. Time = Sun Apr 12 11:14:42 CST 2020
pool-1-thread-5 Start. Time = Sun Apr 12 11:14:42 CST 2020
pool-1-thread-4 Start. Time = Sun Apr 12 11:14:42 CST 2020
pool-1-thread-3 Start. Time = Sun Apr 12 11:14:42 CST 2020
pool-1-thread-2 Start. Time = Sun Apr 12 11:14:42 CST 2020
pool-1-thread-1 End. Time = Sun Apr 12 11:14:47 CST 2020
pool-1-thread-4 End. Time = Sun Apr 12 11:14:47 CST 2020
pool-1-thread-5 End. Time = Sun Apr 12 11:14:47 CST 2020
pool-1-thread-3 End. Time = Sun Apr 12 11:14:47 CST 2020
pool-1-thread-2 End. Time = Sun Apr 12 11:14:47 CST 2020
Finished all threads // 任务全部执行完了才会跳出来,因为 executor.IsTerminated ()判断为 true 了才会跳出 while 循环,当且仅当调用 shutdown () 方法后,并且所有提交的任务完成后返回为 true

线程池原理分析

我们通过前面的代码输出结果可以看出:线程池首先会先执行 5 个任务,然后这些任务有任务被执行完的话,就会去拿新的任务执行。 大家可以先通过上面讲解的内容,分析一下到底是咋回事?(自己独立思考一会)

现在,我们就分析上面的输出内容来简单分析一下线程池原理。

为了搞懂线程池的原理,我们需要首先分析一下 execute 方法。在示例代码中,我们使用 executor.Execute (worker) 来提交一个任务到线程池中去。

这个方法非常重要,下面我们来看看它的源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// 存放线程池的运行状态 (runState) 和线程池内有效线程的数量 (workerCount)
Private final AtomicInteger ctl = new AtomicInteger (ctlOf (RUNNING, 0));

Private static int workerCountOf (int c) {
Return c & CAPACITY;
}
//任务队列
private final BlockingQueue<Runnable> workQueue;

Public void execute (Runnable command) {
// 如果任务为 null,则抛出异常。
If (command == null)
Throw new NullPointerException ();
// ctl 中保存的线程池当前的一些状态信息
Int c = ctl.Get ();

// 下面会涉及到 3 步操作
// 1. 首先判断当前线程池中执行的任务数量是否小于 corePoolSize
// 如果小于的话,通过 addWorker (command, true)新建一个线程,并将任务 (command)添加到该线程中;然后,启动该线程从而执行任务。
If (workerCountOf (c) < corePoolSize) {
If (addWorker (command, true))
Return;
C = ctl.Get ();
}
// 2. 如果当前执行的任务数量大于等于 corePoolSize 的时候就会走到这里,表明创建新的线程失败。
// 通过 isRunning 方法判断线程池状态,线程池处于 RUNNING 状态并且队列可以加入任务,该任务才会被加入进去
If (isRunning (c) && workQueue.Offer (command)) {
Int recheck = ctl.Get ();
// 再次获取线程池状态,如果线程池状态不是 RUNNING 状态就需要从任务队列中移除任务,并尝试判断线程是否全部执行完毕。同时执行拒绝策略。
If (! IsRunning (recheck) && remove (command))
Reject (command);
// 如果当前工作线程数量为 0,新创建一个线程并执行。
Else if (workerCountOf (recheck) == 0)
AddWorker (null, false);
}
//3. 通过 addWorker (command, false)新建一个线程,并将任务 (command)添加到该线程中;然后,启动该线程从而执行任务。
// 传入 false 代表增加线程时判断当前线程数是否少于 maxPoolSize
//如果 addWorker (command, false)执行失败,则通过 reject ()执行相应的拒绝策略的内容。
Else if (! AddWorker (command, false))
Reject (command);
}

这里简单分析一下整个流程(对整个逻辑进行了简化,方便理解):

  1. 如果当前运行的线程数小于核心线程数,那么就会新建一个线程来执行任务。
  2. 如果当前运行的线程数等于或大于核心线程数,但是小于最大线程数,那么就把该任务放入到任务队列里等待执行。
  3. 如果向任务队列投放任务失败(任务队列已经满了),但是当前运行的线程数是小于最大线程数的,就新建一个线程来执行任务。
  4. 如果当前运行的线程数已经等同于最大线程数了,新建线程将会使当前运行的线程超出最大线程数,那么当前任务会被拒绝,饱和策略会调用 RejectedExecutionHandler.RejectedExecution () 方法。

图解线程池实现原理

execute 方法中,多次调用 addWorker 方法。addWorker 这个方法主要用来创建新的工作线程,如果返回 true 说明创建和启动工作线程成功,否则的话返回的就是 false。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
 // 全局锁,并发操作必备
Private final ReentrantLock mainLock = new ReentrantLock ();
// 跟踪线程池的最大大小,只有在持有全局锁 mainLock 的前提下才能访问此集合
Private int largestPoolSize;
// 工作线程集合,存放线程池中所有的(活跃的)工作线程,只有在持有全局锁 mainLock 的前提下才能访问此集合
private final HashSet<Worker> workers = new HashSet<>();
//获取线程池状态
Private static int runStateOf (int c) { return c & ~CAPACITY; }
//判断线程池的状态是否为 Running
Private static boolean isRunning (int c) {
Return c < SHUTDOWN;
}


/**
* 添加新的工作线程到线程池
* @param firstTask 要执行
* @param core 参数为 true 的话表示使用线程池的基本大小,为 false 使用线程池最大大小
* @return 添加成功就返回 true 否则返回 false
*/
Private boolean addWorker (Runnable firstTask, boolean core) {
Retry:
For (;;) {
//这两句用来获取线程池的状态
Int c = ctl.Get ();
Int rs = runStateOf (c);

// Check if queue empty only if necessary.
If (rs >= SHUTDOWN &&
! (rs == SHUTDOWN &&
FirstTask == null &&
! WorkQueue.IsEmpty ()))
Return false;

For (;;) {
//获取线程池中工作的线程的数量
Int wc = workerCountOf (c);
// core 参数为 false 的话表明队列也满了,线程池大小变为 maximumPoolSize
If (wc >= CAPACITY ||
Wc >= (core ? CorePoolSize : maximumPoolSize))
Return false;
//原子操作将 workcount 的数量加1
If (compareAndIncrementWorkerCount (c))
Break retry;
// 如果线程的状态改变了就再次执行上述操作
C = ctl.Get ();
If (runStateOf (c) != rs)
Continue retry;
// else CAS failed due to workerCount change; retry inner loop
}
}
// 标记工作线程是否启动成功
Boolean workerStarted = false;
// 标记工作线程是否创建成功
Boolean workerAdded = false;
Worker w = null;
Try {

W = new Worker (firstTask);
final Thread t = w.thread;
If (t != null) {
// 加锁
Final ReentrantLock mainLock = this. MainLock;
MainLock.Lock ();
Try {
//获取线程池状态
Int rs = runStateOf (ctl.Get ());
//rs < SHUTDOWN 如果线程池状态依然为 RUNNING, 并且线程的状态是存活的话,就会将工作线程添加到工作线程集合中
//(rs=SHUTDOWN && firstTask == null)如果线程池状态小于 STOP,也就是 RUNNING 或者 SHUTDOWN 状态下,同时传入的任务实例 firstTask 为 null,则需要添加到工作线程集合和启动新的 Worker
// firstTask == null 证明只新建线程而不执行任务
If (rs < SHUTDOWN ||
(rs == SHUTDOWN && firstTask == null)) {
if (t.isAlive ()) // precheck that t is startable
Throw new IllegalThreadStateException ();
Workers.Add (w);
//更新当前工作线程的最大容量
Int s = workers.Size ();
If (s > largestPoolSize)
LargestPoolSize = s;
// 工作线程是否启动成功
WorkerAdded = true;
}
} finally {
// 释放锁
MainLock.Unlock ();
}
//// 如果成功添加工作线程,则调用 Worker 内部的线程实例 t 的 Thread #start ()方法启动真实的线程实例
If (workerAdded) {
t.start ();
/// 标记线程启动成功
WorkerStarted = true;
}
}
} finally {
// 线程启动失败,需要从工作线程中移除对应的 Worker
If (! WorkerStarted)
AddWorkerFailed (w);
}
Return workerStarted;
}

更多关于线程池源码分析的内容推荐这篇文章:硬核干货:4W 字从源码上分析 JUC 线程池 ThreadPoolExecutor 的实现原理

现在,让我们在回到示例代码,现在应该是不是很容易就可以搞懂它的原理了呢?

没搞懂的话,也没关系,可以看看我的分析:

我们在代码中模拟了 10 个任务,我们配置的核心线程数为 5、等待队列容量为 100 ,所以每次只可能存在 5 个任务同时执行,剩下的 5 个任务会被放到等待队列中去。当前的 5 个任务中如果有任务被执行完了,线程池就会去拿新的任务执行。

几个常见的对比

Runnable vs Callable

Runnable 自 Java 1.0 以来一直存在,但 Callable 仅在 Java 1.5 中引入, 目的就是为了来处理 Runnable 不支持的用例。**Runnable 接口**不会返回结果或抛出检查异常,但是 Callable 接口可以。所以,如果任务不需要返回结果或抛出异常推荐使用 Runnable 接口,这样代码看起来会更加简洁。

工具类 Executors 可以实现将 Runnable 对象转换成 Callable 对象。(Executors.Callable (Runnable task)Executors.Callable (Runnable task, Object result))。

Runnable. Java

1
2
3
4
5
6
7
@FunctionalInterface
Public interface Runnable {
/**
* 被线程执行,没有返回值也无法抛出异常
*/
Public abstract void run ();
}

Callable. Java

1
2
3
4
5
6
7
8
9
10
@FunctionalInterface
public interface Callable<V> {
/**
* 计算结果,或在无法这样做时抛出异常。
* @return 计算得出的结果
* @throws 如果无法计算结果,则抛出异常
*/
V call () throws Exception;
}

execute () vs submit ()
  • execute () 方法用于提交不需要返回值的任务,所以无法判断任务是否被线程池执行成功与否;
  • submit () 方法用于提交需要返回值的任务。线程池会返回一个 Future 类型的对象,通过这个 Future 对象可以判断任务是否执行成功,并且可以通过 Futureget () 方法来获取返回值,get () 方法会阻塞当前线程直到任务完成,而使用 get(long timeout,TimeUnit unit) 方法的话,如果在 timeout 时间内任务还没有执行完,就会抛出 java. Util. Concurrent. TimeoutException

这里只是为了演示使用,推荐使用 ThreadPoolExecutor 构造方法来创建线程池。

示例 1:使用 get () 方法获取返回值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
ExecutorService executorService = Executors.NewFixedThreadPool (3);

Future<String> submit = executorService.Submit (() -> {
Try {
Thread.Sleep (5000 L);
} catch (InterruptedException e) {
e.printStackTrace ();
}
Return "abc";
});

String s = submit.Get ();
System.Out.Println (s);
ExecutorService.Shutdown ();

输出:

示例 2:使用 get(long timeout,TimeUnit unit) 方法获取返回值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
ExecutorService executorService = Executors.NewFixedThreadPool (3);

Future<String> submit = executorService.Submit (() -> {
Try {
Thread.Sleep (5000 L);
} catch (InterruptedException e) {
e.printStackTrace ();
}
Return "abc";
});

String s = submit.Get (3, TimeUnit. SECONDS);
System.Out.Println (s);
ExecutorService.Shutdown ();

输出:

1
2
Exception in thread "main" java. Util. Concurrent. TimeoutException
At java.Util.Concurrent.FutureTask.Get (FutureTask. Java:205)
shutdown () VS shutdownNow ()
  • shutdown() : 关闭线程池,线程池的状态变为 SHUTDOWN。线程池不再接受新任务了,但是队列里的任务得执行完毕。
  • shutdownNow() : 关闭线程池,线程池的状态变为 STOP。线程池会终止当前正在运行的任务,并停止处理排队的任务并返回正在等待执行的 List。
isTerminated () VS isShutdown ()
  • isShutDown 当调用 shutdown () 方法后返回为 true。
  • isTerminated 当调用 shutdown () 方法后,并且所有提交的任务完成后返回为 true

几种常见的内置线程池

FixedThreadPool

介绍

FixedThreadPool 被称为可重用固定线程数的线程池。通过 Executors 类中的相关源代码来看一下相关实现:

1
2
3
4
5
6
7
8
9
/**
* 创建一个可重用固定数量线程的线程池
*/
Public static ExecutorService newFixedThreadPool (int nThreads, ThreadFactory threadFactory) {
Return new ThreadPoolExecutor (nThreads, nThreads,
0 L, TimeUnit. MILLISECONDS,
new LinkedBlockingQueue<Runnable>(),
ThreadFactory);
}

另外还有一个 FixedThreadPool 的实现方法,和上面的类似,所以这里不多做阐述:

1
2
3
4
5
Public static ExecutorService newFixedThreadPool (int nThreads) {
Return new ThreadPoolExecutor (nThreads, nThreads,
0 L, TimeUnit. MILLISECONDS,
new LinkedBlockingQueue<Runnable>());
}

从上面源代码可以看出新创建的 FixedThreadPoolcorePoolSizemaximumPoolSize 都被设置为 nThreads,这个 nThreads 参数是我们使用的时候自己传递的。

即使 maximumPoolSize 的值比 corePoolSize 大,也至多只会创建 corePoolSize 个线程。这是因为 FixedThreadPool 使用的是容量为 Integer. MAX_VALUELinkedBlockingQueue(无界队列),队列永远不会被放满。

执行任务过程介绍

FixedThreadPoolexecute () 方法运行示意图(该图片来源:《Java 并发编程的艺术》):

FixedThreadPool的execute()方法运行示意图

FixedThreadPool 的 execute ()方法运行示意图

上图说明:

  1. 如果当前运行的线程数小于 corePoolSize,如果再来新任务的话,就创建新的线程来执行任务;
  2. 当前运行的线程数等于 corePoolSize 后,如果再来新任务的话,会将任务加入 LinkedBlockingQueue
  3. 线程池中的线程执行完手头的任务后,会在循环中反复从 LinkedBlockingQueue 中获取任务来执行;
为什么不推荐使用 FixedThreadPool

FixedThreadPool 使用无界队列 LinkedBlockingQueue(队列的容量为 Integer. MAX_VALUE)作为线程池的工作队列会对线程池带来如下影响:

  1. 当线程池中的线程数达到 corePoolSize 后,新任务将在无界队列中等待,因此线程池中的线程数不会超过 corePoolSize
  2. 由于使用无界队列时 maximumPoolSize 将是一个无效参数,因为不可能存在任务队列满的情况。所以,通过创建 FixedThreadPool 的源码可以看出创建的 FixedThreadPoolcorePoolSizemaximumPoolSize 被设置为同一个值。
  3. 由于 1 和 2,使用无界队列时 keepAliveTime 将是一个无效参数;
  4. 运行中的 FixedThreadPool(未执行 shutdown ()shutdownNow ())不会拒绝任务,在任务比较多的时候会导致 OOM(内存溢出)。

SingleThreadExecutor

介绍

SingleThreadExecutor 是只有一个线程的线程池。下面看看SingleThreadExecutor 的实现:

1
2
3
4
5
6
7
8
9
10
/**
*返回只有一个线程的线程池
*/
Public static ExecutorService newSingleThreadExecutor (ThreadFactory threadFactory) {
Return new FinalizableDelegatedExecutorService
(new ThreadPoolExecutor (1, 1,
0 L, TimeUnit. MILLISECONDS,
new LinkedBlockingQueue<Runnable>(),
ThreadFactory));
}
1
2
3
4
5
6
Public static ExecutorService newSingleThreadExecutor () {
Return new FinalizableDelegatedExecutorService
(new ThreadPoolExecutor (1, 1,
0 L, TimeUnit. MILLISECONDS,
new LinkedBlockingQueue<Runnable>()));
}

从上面源代码可以看出新创建的 SingleThreadExecutorcorePoolSizemaximumPoolSize 都被设置为 1,其他参数和 FixedThreadPool 相同。

执行任务过程介绍

SingleThreadExecutor 的运行示意图(该图片来源:《Java 并发编程的艺术》):

SingleThreadExecutor的运行示意图

SingleThreadExecutor 的运行示意图

上图说明 :

  1. 如果当前运行的线程数少于 corePoolSize,则创建一个新的线程执行任务;
  2. 当前线程池中有一个运行的线程后,将任务加入 LinkedBlockingQueue
  3. 线程执行完当前的任务后,会在循环中反复从 LinkedBlockingQueue 中获取任务来执行;
为什么不推荐使用 SingleThreadExecutor

SingleThreadExecutorFixedThreadPool 一样,使用的都是容量为 Integer. MAX_VALUELinkedBlockingQueue(无界队列)作为线程池的工作队列。SingleThreadExecutor 使用无界队列作为线程池的工作队列会对线程池带来的影响与 FixedThreadPool 相同。说简单点,就是可能会导致 OOM。

CachedThreadPool

介绍

CachedThreadPool 是一个会根据需要创建新线程的线程池。下面通过源码来看看 CachedThreadPool 的实现:

1
2
3
4
5
6
7
8
9
10
/**
* 创建一个线程池,根据需要创建新线程,但会在先前构建的线程可用时重用它。
*/
Public static ExecutorService newCachedThreadPool (ThreadFactory threadFactory) {
Return new ThreadPoolExecutor (0, Integer. MAX_VALUE,
60 L, TimeUnit. SECONDS,
new SynchronousQueue<Runnable>(),
ThreadFactory);
}

1
2
3
4
5
Public static ExecutorService newCachedThreadPool () {
Return new ThreadPoolExecutor (0, Integer. MAX_VALUE,
60 L, TimeUnit. SECONDS,
new SynchronousQueue<Runnable>());
}

CachedThreadPoolcorePoolSize 被设置为空(0),maximumPoolSize 被设置为 Integer. MAX. VALUE,即它是无界的,这也就意味着如果主线程提交任务的速度高于 maximumPool 中线程处理任务的速度时,CachedThreadPool 会不断创建新的线程。极端情况下,这样会导致耗尽 cpu 和内存资源。

执行任务过程介绍

CachedThreadPoolexecute () 方法的执行示意图(该图片来源:《Java 并发编程的艺术》):

CachedThreadPool的execute()方法的执行示意图

CachedThreadPool 的 execute ()方法的执行示意图

上图说明:

  1. 首先执行 SynchronousQueue.Offer (Runnable task) 提交任务到任务队列。如果当前 maximumPool 中有闲线程正在执行 SynchronousQueue.Poll (keepAliveTime, TimeUnit. NANOSECONDS),那么主线程执行 offer 操作与空闲线程执行的 poll 操作配对成功,主线程把任务交给空闲线程执行,execute () 方法执行完成,否则执行下面的步骤 2;
  2. 当初始 maximumPool 为空,或者 maximumPool 中没有空闲线程时,将没有线程执行 SynchronousQueue.Poll (keepAliveTime, TimeUnit. NANOSECONDS)。这种情况下,步骤 1 将失败,此时 CachedThreadPool 会创建新线程执行任务,execute 方法执行完成;
为什么不推荐使用 CachedThreadPool

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

ScheduledThreadPool

介绍

ScheduledThreadPool 用来在给定的延迟后运行任务或者定期执行任务。这个在实际项目中基本不会被用到,也不推荐使用,大家只需要简单了解一下即可。

1
2
3
4
5
6
7
Public static ScheduledExecutorService newScheduledThreadPool (int corePoolSize) {
Return new ScheduledThreadPoolExecutor (corePoolSize);
}
Public ScheduledThreadPoolExecutor (int corePoolSize) {
Super (corePoolSize, Integer. MAX_VALUE, 0, NANOSECONDS,
New DelayedWorkQueue ());
}

ScheduledThreadPool 是通过 ScheduledThreadPoolExecutor 创建的,使用的 DelayedWorkQueue(延迟阻塞队列)作为线程池的任务队列。

DelayedWorkQueue 的内部元素并不是按照放入的时间排序,而是会按照延迟的时间长短对任务进行排序,内部采用的是“堆”的数据结构,可以保证每次出队的任务都是当前队列中执行时间最靠前的。DelayedWorkQueue 添加元素满了之后会自动扩容原来容量的 1/2,即永远不会阻塞,最大扩容可达 Integer. MAX_VALUE,所以最多只能创建核心线程数的线程。

ScheduledThreadPoolExecutor 继承了 ThreadPoolExecutor,所以创建 ScheduledThreadExecutor 本质也是创建一个 ThreadPoolExecutor 线程池,只是传入的参数不相同。

1
2
3
Public class ScheduledThreadPoolExecutor
Extends ThreadPoolExecutor
Implements ScheduledExecutorService
ScheduledThreadPoolExecutor 和 Timer 对比
  • Timer 对系统时钟的变化敏感,ScheduledThreadPoolExecutor 不是;
  • Timer 只有一个执行线程,因此长时间运行的任务可以延迟其他任务。 ScheduledThreadPoolExecutor 可以配置任意数量的线程。此外,如果你想(通过提供 ThreadFactory),你可以完全控制创建的线程;
  • TimerTask 中抛出的运行时异常会杀死一个线程,从而导致 Timer 死机即计划任务将不再运行。ScheduledThreadExecutor 不仅捕获运行时异常,还允许您在需要时处理它们(通过重写 afterExecute 方法 ThreadPoolExecutor)。抛出异常的任务将被取消,但其他任务将继续运行。

关于定时任务的详细介绍,可以看这篇文章:Java 定时任务详解

线程池最佳实践

1、正确声明线程池

线程池必须手动通过 ThreadPoolExecutor 的构造函数来声明,避免使用 Executors 类创建线程池,会有 OOM 风险。

Executors 返回线程池对象的弊端如下 (后文会详细介绍到):

  • **FixedThreadPoolSingleThreadExecutor**:使用的是无界的 LinkedBlockingQueue,任务队列最大长度为 Integer. MAX_VALUE, 可能堆积大量的请求,从而导致 OOM。
  • **CachedThreadPool**:使用的是同步队列 SynchronousQueue, 允许创建的线程数量为 Integer. MAX_VALUE ,可能会创建大量线程,从而导致 OOM。
  • ScheduledThreadPoolSingleThreadScheduledExecutor : 使用的无界的延迟阻塞队列 DelayedWorkQueue,任务队列最大长度为 Integer. MAX_VALUE, 可能堆积大量的请求,从而导致 OOM。

说白了就是:使用有界队列,控制线程创建数量。

除了避免 OOM 的原因之外,不推荐使用 Executors 提供的两种快捷的线程池的原因还有:

  • 实际使用中需要根据自己机器的性能、业务场景来手动配置线程池的参数比如核心线程数、使用的任务队列、饱和策略等等。
  • 我们应该显示地给我们的线程池命名,这样有助于我们定位问题。

2、监测线程池运行状态

你可以通过一些手段来检测线程池的运行状态比如 SpringBoot 中的 Actuator 组件。

除此之外,我们还可以利用 ThreadPoolExecutor 的相关 API 做一个简陋的监控。从下图可以看出, ThreadPoolExecutor 提供了获取线程池当前的线程数和活跃线程数、已经执行完成的任务数、正在排队中的任务数等等。

下面是一个简单的 Demo。printThreadPoolStatus () 会每隔一秒打印出线程池的线程数、活跃线程数、完成的任务数、以及队列中的任务数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* 打印线程池的状态
*
* @param threadPool 线程池对象
*/
Public static void printThreadPoolStatus (ThreadPoolExecutor threadPool) {
ScheduledExecutorService scheduledExecutorService = new ScheduledThreadPoolExecutor (1, createThreadFactory ("print-images/thread-pool-status", false));
ScheduledExecutorService.ScheduleAtFixedRate (() -> {
Log.Info ("=========================");
Log.Info ("ThreadPool Size: [{}]", threadPool.GetPoolSize ());
Log.Info ("Active Threads: {}", threadPool.GetActiveCount ());
Log.Info ("Number of Tasks : {}", threadPool.GetCompletedTaskCount ());
Log.Info ("Number of Tasks in Queue: {}", threadPool.GetQueue (). Size ());
Log.Info ("=========================");
}, 0, 1, TimeUnit. SECONDS);
}

3、建议不同类别的业务用不同的线程池

很多人在实际项目中都会有类似这样的问题:我的项目中多个业务需要用到线程池,是为每个线程池都定义一个还是说定义一个公共的线程池呢?

一般建议是不同的业务使用不同的线程池,配置线程池的时候根据当前业务的情况对当前线程池进行配置,因为不同的业务的并发以及对资源的使用情况都不同,重心优化系统性能瓶颈相关的业务。

我们再来看一个真实的事故案例! (本案例来源自:《线程池运用不当的一次线上事故》 ,很精彩的一个案例)

案例代码概览

上面的代码可能会存在死锁的情况,为什么呢?画个图给大家捋一捋。

试想这样一种极端情况:假如我们线程池的核心线程数为 n,父任务(扣费任务)数量为 n,父任务下面有两个子任务(扣费任务下的子任务),其中一个已经执行完成,另外一个被放在了任务队列中。由于父任务把线程池核心线程资源用完,所以子任务因为无法获取到线程资源无法正常执行,一直被阻塞在队列中。父任务等待子任务执行完成,而子任务等待父任务释放线程池资源,这也就造成了 “死锁”

线程池使用不当导致死锁

解决方法也很简单,就是新增加一个用于执行子任务的线程池专门为其服务。

4、别忘记给线程池命名

初始化线程池的时候需要显示命名(设置线程池名称前缀),有利于定位问题。

默认情况下创建的线程名字类似 pool-1-thread-n 这样的,没有业务含义,不利于我们定位问题。

给线程池里的线程命名通常有下面两种方式:

1、利用 guava 的 ThreadFactoryBuilder

1
2
3
4
ThreadFactory threadFactory = new ThreadFactoryBuilder ()
.setNameFormat (threadNamePrefix + "-%d")
.setDaemon (true). Build ();
ExecutorService threadPool = new ThreadPoolExecutor (corePoolSize, maximumPoolSize, keepAliveTime, TimeUnit. MINUTES, workQueue, threadFactory)

2、自己实现 ThreadFactory

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
Import java. Util. Concurrent. Executors;
Import java. Util. Concurrent. ThreadFactory;
Import java. Util. Concurrent. Atomic. AtomicInteger;
/**
* 线程工厂,它设置线程名称,有利于我们定位问题。
*/
Public final class NamingThreadFactory implements ThreadFactory {

Private final AtomicInteger threadNum = new AtomicInteger ();
Private final ThreadFactory delegate;
Private final String name;

/**
* 创建一个带名字的线程池生产工厂
*/
Public NamingThreadFactory (ThreadFactory delegate, String name) {
This. Delegate = delegate;
This. Name = name; // TODO consider uniquifying this
}

@Override
Public Thread newThread (Runnable r) {
Thread t = delegate.NewThread (r);
t.setName (name + " [#" + threadNum.IncrementAndGet () + "]");
Return t;
}

}

5、正确配置线程池参数

说到如何给线程池配置参数,美团的骚操作至今让我难忘(后面会提到)!

我们先来看一下各种书籍和博客上一般推荐的配置线程池参数的方式,可以作为参考。

常规操作

很多人甚至可能都会觉得把线程池配置过大一点比较好!我觉得这明显是有问题的。就拿我们生活中非常常见的一例子来说:并不是人多就能把事情做好,增加了沟通交流成本。你本来一件事情只需要 3 个人做,你硬是拉来了 6 个人,会提升做事效率嘛?我想并不会。 线程数量过多的影响也是和我们分配多少人做事情一样,对于多线程这个场景来说主要是增加了上下文切换 成本。不清楚什么是上下文切换的话,可以看我下面的介绍。

上下文切换:

多线程编程中一般线程的个数都大于 CPU 核心的个数,而一个 CPU 核心在任意时刻只能被一个线程使用,为了让这些线程都能得到有效执行,CPU 采取的策略是为每个线程分配时间片并轮转的形式。当一个线程的时间片用完的时候就会重新处于就绪状态让给其他线程使用,这个过程就属于一次上下文切换。概括来说就是:当前任务在执行完 CPU 时间片切换到另一个任务之前会先保存自己的状态,以便下次再切换回这个任务时,可以再加载这个任务的状态。任务从保存到再加载的过程就是一次上下文切换

上下文切换通常是计算密集型的。也就是说,它需要相当可观的处理器时间,在每秒几十上百次的切换中,每次切换都需要纳秒量级的时间。所以,上下文切换对系统来说意味着消耗大量的 CPU 时间,事实上,可能是操作系统中时间消耗最大的操作。

Linux 相比与其他操作系统(包括其他类 Unix 系统)有很多的优点,其中有一项就是,其上下文切换和模式切换的时间消耗非常少。

类比于实现世界中的人类通过合作做某件事情,我们可以肯定的一点是线程池大小设置过大或者过小都会有问题,合适的才是最好。

  • 如果我们设置的线程池数量太小的话,如果同一时间有大量任务/请求需要处理,可能会导致大量的请求/任务在任务队列中排队等待执行,甚至会出现任务队列满了之后任务/请求无法处理的情况,或者大量任务堆积在任务队列导致 OOM。这样很明显是有问题的,CPU 根本没有得到充分利用。
  • 如果我们设置线程数量太大,大量线程可能会同时在争取 CPU 资源,这样会导致大量的上下文切换,从而增加线程的执行时间,影响了整体执行效率。

有一个简单并且适用面比较广的公式:

  • CPU 密集型任务 (N+1): 这种任务消耗的主要是 CPU 资源,可以将线程数设置为 N(CPU 核心数)+1。比 CPU 核心数多出来的一个线程是为了防止线程偶发的缺页中断,或者其它原因导致的任务暂停而带来的影响。一旦任务暂停,CPU 就会处于空闲状态,而在这种情况下多出来的一个线程就可以充分利用 CPU 的空闲时间。
  • I/O 密集型任务 (2 N): 这种任务应用起来,系统会用大部分的时间来处理 I/O 交互,而线程在处理 I/O 的时间段内不会占用 CPU 来处理,这时就可以将 CPU 交出给其它线程使用。因此在 I/O 密集型任务的应用中,我们可以多配置一些线程,具体的计算方法是 2 N。

如何判断是 CPU 密集任务还是 IO 密集任务?

CPU 密集型简单理解就是利用 CPU 计算能力的任务比如你在内存中对大量数据进行排序。但凡涉及到网络读取,文件读取这类都是 IO 密集型,这类任务的特点是 CPU 计算耗费时间相比于等待 IO 操作完成的时间来说很少,大部分时间都花在了等待 IO 操作完成上。

🌈 拓展一下(参见:issue#1737):

线程数更严谨的计算的方法应该是:最佳线程数 = N(CPU 核心数)∗(1+WT(线程等待时间)/ST(线程计算时间)),其中 WT(线程等待时间)=线程运行总时间 - ST(线程计算时间)

线程等待时间所占比例越高,需要越多线程。线程计算时间所占比例越高,需要越少线程。

我们可以通过 JDK 自带的工具 VisualVM 来查看 WT/ST 比例。

CPU 密集型任务的 WT/ST 接近或者等于 0,因此,线程数可以设置为 N(CPU 核心数)∗(1+0)= N,和我们上面说的 N(CPU 核心数)+1 差不多。

IO 密集型任务下,几乎全是线程等待时间,从理论上来说,你就可以将线程数设置为 2 N(按道理来说,WT/ST 的结果应该比较大,这里选择 2 N 的原因应该是为了避免创建过多线程吧)。

注意:上面提到的公示也只是参考,实际项目不太可能直接按照公式来设置线程池参数,毕竟不同的业务场景对应的需求不同,具体还是要根据项目实际线上运行情况来动态调整。接下来介绍的美团的线程池参数动态配置这种方案就非常不错,很实用!

美团的骚操作

美团技术团队在 《Java 线程池实现原理及其在美团业务中的实践》 这篇文章中介绍到对线程池参数实现可自定义配置的思路和方法。

美团技术团队的思路是主要对线程池的核心参数实现自定义可配置。这三个核心参数是:

  • corePoolSize : 核心线程数线程数定义了最小可以同时运行的线程数量。
  • maximumPoolSize : 当队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数。
  • workQueue: 当新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。

为什么是这三个参数?

我在这篇 《新手也能看懂的线程池学习总结》 中就说过这三个参数是 ThreadPoolExecutor 最重要的参数,它们基本决定了线程池对于任务的处理策略。

如何支持参数动态配置? 且看 ThreadPoolExecutor 提供的下面这些方法。

格外需要注意的是 corePoolSize,程序运行期间的时候,我们调用 setCorePoolSize() 这个方法的话,线程池会首先判断当前工作线程数是否大于 corePoolSize,如果大于的话就会回收工作线程。

另外,你也看到了上面并没有动态指定队列长度的方法,美团的方式是自定义了一个叫做 ResizableCapacityLinkedBlockIngQueue 的队列(主要就是把 LinkedBlockingQueue 的 capacity 字段的 final 关键字修饰给去掉了,让它变为可变的)。

最终实现的可动态修改线程池参数效果如下。👏👏👏

动态配置线程池参数最终效果

动态配置线程池参数最终效果

如果我们的项目也想要实现这种效果的话,可以借助现成的开源项目:

  • **Hippo4j**:异步线程池框架,支持线程池动态变更&监控&报警,无需修改代码轻松引入。支持多种使用模式,轻松引入,致力于提高系统运行保障能力。
  • **Dynamic TP**:轻量级动态线程池,内置监控告警功能,集成三方中间件线程池管理,基于主流配置中心(已支持 Nacos、Apollo,Zookeeper、Consul、Etcd,可通过 SPI 自定义实现)。

6、别忘记关闭线程池

当线程池不再需要使用时,应该显式地关闭线程池,释放线程资源。

线程池提供了两个关闭方法:

  • shutdown() : 关闭线程池,线程池的状态变为 SHUTDOWN。线程池不再接受新任务了,但是队列里的任务得执行完毕。
  • shutdownNow() : 关闭线程池,线程池的状态变为 STOP。线程池会终止当前正在运行的任务,停止处理排队的任务并返回正在等待执行的 List。

调用完 shutdownNowshuwdown 方法后,并不代表线程池已经完成关闭操作,它只是异步的通知线程池进行关闭处理。如果要同步等待线程池彻底关闭后才继续往下执行,需要调用 awaitTermination 方法进行同步等待。

在调用 awaitTermination () 方法时,应该设置合理的超时时间,以避免程序长时间阻塞而导致性能问题。另外。由于线程池中的任务可能会被取消或抛出异常,因此在使用 awaitTermination () 方法时还需要进行异常处理。awaitTermination () 方法会抛出 InterruptedException 异常,需要捕获并处理该异常,以避免程序崩溃或者无法正常退出。

1
2
3
4
5
6
7
8
9
10
11
12
// ...
// 关闭线程池
Executor.Shutdown ();
Try {
// 等待线程池关闭,最多等待 5 分钟
If (! Executor.AwaitTermination (5, TimeUnit. MINUTES)) {
// 如果等待超时,则打印日志
System.Err.Println ("线程池未能在 5 分钟内完全关闭");
}
} catch (InterruptedException e) {
// 异常处理
}

7、线程池尽量不要放耗时任务

线程池本身的目的是为了提高任务执行效率,避免因频繁创建和销毁线程而带来的性能开销。如果将耗时任务提交到线程池中执行,可能会导致线程池中的线程被长时间占用,无法及时响应其他任务,甚至会导致线程池崩溃或者程序假死。

因此,在使用线程池时,我们应该尽量避免将耗时任务提交到线程池中执行。对于一些比较耗时的操作,如网络请求、文件读写等,可以采用异步操作的方式来处理,以避免阻塞线程池中的线程。

8、线程池使用的一些小坑

重复创建线程池的坑

线程池是可以复用的,一定不要频繁创建线程池比如一个用户请求到了就单独创建一个线程池。

1
2
3
4
5
6
7
8
9
10
11
@GetMapping ("wrong")
Public String wrong () throws InterruptedException {
// 自定义线程池
ThreadPoolExecutor executor = new ThreadPoolExecutor (5,10,1 L, TimeUnit. SECONDS, new ArrayBlockingQueue<>(100), new ThreadPoolExecutor.CallerRunsPolicy ());

// 处理任务
Executor.Execute (() -> {
// ......
}
Return "OK";
}

出现这种问题的原因还是对于线程池认识不够,需要加强线程池的基础知识。

Spring 内部线程池的坑

使用 Spring 内部线程池时,一定要手动自定义线程池,配置合理的参数,不然会出现生产问题(一个请求创建一个线程)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Configuration
@EnableAsync
Public class ThreadPoolExecutorConfig {

@Bean (name="threadPoolExecutor")
Public Executor threadPoolExecutor (){
ThreadPoolTaskExecutor threadPoolExecutor = new ThreadPoolTaskExecutor ();
Int processNum = Runtime.GetRuntime (). AvailableProcessors (); // 返回可用处理器的 Java 虚拟机的数量
Int corePoolSize = (int) (processNum / (1 - 0.2));
Int maxPoolSize = (int) (processNum / (1 - 0.5));
ThreadPoolExecutor.SetCorePoolSize (corePoolSize); // 核心池大小
ThreadPoolExecutor.SetMaxPoolSize (maxPoolSize); // 最大线程数
ThreadPoolExecutor.SetQueueCapacity (maxPoolSize * 1000); // 队列程度
ThreadPoolExecutor.SetThreadPriority (Thread. MAX_PRIORITY);
ThreadPoolExecutor.SetDaemon (false);
ThreadPoolExecutor.SetKeepAliveSeconds (300);// 线程空闲时间
ThreadPoolExecutor.SetThreadNamePrefix ("test-Executor-"); // 线程名字前缀
Return threadPoolExecutor;
}
}
线程池和 ThreadLocal 共用的坑

线程池和 ThreadLocal 共用,可能会导致线程从 ThreadLocal 获取到的是旧值/脏数据。这是因为线程池会复用线程对象,与线程对象绑定的类的静态属性 ThreadLocal 变量也会被重用,这就导致一个线程可能获取到其他线程的 ThreadLocal 值。

不要以为代码中没有显示使用线程池就不存在线程池了,像常用的 Web 服务器 Tomcat 处理任务为了提高并发量,就使用到了线程池,并且使用的是基于原生 Java 线程池改进完善得到的自定义线程池。

当然了,你可以将 Tomcat 设置为单线程处理任务。不过,这并不合适,会严重影响其处理任务的速度。

1
Server. Tomcat. Max-threads=1

解决上述问题比较建议的办法是使用阿里巴巴开源的 TransmittableThreadLocal (TTL)。TransmittableThreadLocal 类继承并加强了 JDK 内置的 InheritableThreadLocal 类,在使用线程池等会池化复用线程的执行组件情况下,提供 ThreadLocal 值的传递功能,解决异步执行时上下文传递的问题。

JDK 提供的这些容器大部分在 java. Util. Concurrent 包中。

  • ConcurrentHashMap : 线程安全的 HashMap
  • CopyOnWriteArrayList : 线程安全的 List,在读多写少的场合性能非常好,远远好于 Vector
  • ConcurrentLinkedQueue : 高效的并发队列,使用链表实现。可以看做一个线程安全的 LinkedList,这是一个非阻塞队列。
  • BlockingQueue : 这是一个接口,JDK 内部通过链表、数组等方式实现了这个接口。表示阻塞队列,非常适合用于作为数据共享的通道。
  • ConcurrentSkipListMap : 跳表的实现。这是一个 Map,使用跳表的数据结构进行快速查找。

线程池处理任务的流程了解吗?

图解线程池实现原理

  1. 如果当前运行的线程数小于核心线程数,那么就会新建一个线程来执行任务。
  2. 如果当前运行的线程数等于或大于核心线程数,但是小于最大线程数,那么就把该任务放入到任务队列里等待执行。
  3. 如果向任务队列投放任务失败(任务队列已经满了),但是当前运行的线程数是小于最大线程数的,就新建一个线程来执行任务。
  4. 如果当前运行的线程数已经等同于最大线程数了,新建线程将会使当前运行的线程超出最大线程数,那么当前任务会被拒绝,饱和策略会调用 RejectedExecutionHandler.RejectedExecution () 方法。

如何给线程池命名?

初始化线程池的时候需要显示命名(设置线程池名称前缀),有利于定位问题。

默认情况下创建的线程名字类似 pool-1-thread-n 这样的,没有业务含义,不利于我们定位问题。

给线程池里的线程命名通常有下面两种方式:

1、利用 guava 的 ThreadFactoryBuilder

1
2
3
4
ThreadFactory threadFactory = new ThreadFactoryBuilder ()
.setNameFormat (threadNamePrefix + "-%d")
.setDaemon (true). Build ();
ExecutorService threadPool = new ThreadPoolExecutor (corePoolSize, maximumPoolSize, keepAliveTime, TimeUnit. MINUTES, workQueue, threadFactory);

2、自己实现 ThreadFactory

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
Import java. Util. Concurrent. Executors;
Import java. Util. Concurrent. ThreadFactory;
Import java. Util. Concurrent. Atomic. AtomicInteger;
/**
* 线程工厂,它设置线程名称,有利于我们定位问题。
*/
Public final class NamingThreadFactory implements ThreadFactory {

Private final AtomicInteger threadNum = new AtomicInteger ();
Private final ThreadFactory delegate;
Private final String name;

/**
* 创建一个带名字的线程池生产工厂
*/
Public NamingThreadFactory (ThreadFactory delegate, String name) {
This. Delegate = delegate;
This. Name = name; // TODO consider uniquifying this
}

@Override
Public Thread newThread (Runnable r) {
Thread t = delegate.NewThread (r);
t.setName (name + " [#" + threadNum.IncrementAndGet () + "]");
Return t;
}

}

如何设定线程池的大小?

很多人甚至可能都会觉得把线程池配置过大一点比较好!我觉得这明显是有问题的。就拿我们生活中非常常见的一例子来说:并不是人多就能把事情做好,增加了沟通交流成本。你本来一件事情只需要 3 个人做,你硬是拉来了 6 个人,会提升做事效率嘛?我想并不会。 线程数量过多的影响也是和我们分配多少人做事情一样,对于多线程这个场景来说主要是增加了上下文切换成本。不清楚什么是上下文切换的话,可以看我下面的介绍。

上下文切换:

多线程编程中一般线程的个数都大于 CPU 核心的个数,而一个 CPU 核心在任意时刻只能被一个线程使用,为了让这些线程都能得到有效执行,CPU 采取的策略是为每个线程分配时间片并轮转的形式。当一个线程的时间片用完的时候就会重新处于就绪状态让给其他线程使用,这个过程就属于一次上下文切换。概括来说就是:当前任务在执行完 CPU 时间片切换到另一个任务之前会先保存自己的状态,以便下次再切换回这个任务时,可以再加载这个任务的状态。任务从保存到再加载的过程就是一次上下文切换

上下文切换通常是计算密集型的。也就是说,它需要相当可观的处理器时间,在每秒几十上百次的切换中,每次切换都需要纳秒量级的时间。所以,上下文切换对系统来说意味着消耗大量的 CPU 时间,事实上,可能是操作系统中时间消耗最大的操作。

Linux 相比与其他操作系统(包括其他类 Unix 系统)有很多的优点,其中有一项就是,其上下文切换和模式切换的时间消耗非常少。

类比于实现世界中的人类通过合作做某件事情,我们可以肯定的一点是线程池大小设置过大或者过小都会有问题,合适的才是最好。

  • 如果我们设置的线程池数量太小的话,如果同一时间有大量任务/请求需要处理,可能会导致大量的请求/任务在任务队列中排队等待执行,甚至会出现任务队列满了之后任务/请求无法处理的情况,或者大量任务堆积在任务队列导致 OOM。这样很明显是有问题的,CPU 根本没有得到充分利用。
  • 如果我们设置线程数量太大,大量线程可能会同时在争取 CPU 资源,这样会导致大量的上下文切换,从而增加线程的执行时间,影响了整体执行效率。

有一个简单并且适用面比较广的公式:

  • CPU 密集型任务 (N+1): 这种任务消耗的主要是 CPU 资源,可以将线程数设置为 N(CPU 核心数)+1。比 CPU 核心数多出来的一个线程是为了防止线程偶发的缺页中断,或者其它原因导致的任务暂停而带来的影响。一旦任务暂停,CPU 就会处于空闲状态,而在这种情况下多出来的一个线程就可以充分利用 CPU 的空闲时间。
  • I/O 密集型任务 (2 N): 这种任务应用起来,系统会用大部分的时间来处理 I/O 交互,而线程在处理 I/O 的时间段内不会占用 CPU 来处理,这时就可以将 CPU 交出给其它线程使用。因此在 I/O 密集型任务的应用中,我们可以多配置一些线程,具体的计算方法是 2 N。

如何判断是 CPU 密集任务还是 IO 密集任务?

CPU 密集型简单理解就是利用 CPU 计算能力的任务比如你在内存中对大量数据进行排序。但凡涉及到网络读取,文件读取这类都是 IO 密集型,这类任务的特点是 CPU 计算耗费时间相比于等待 IO 操作完成的时间来说很少,大部分时间都花在了等待 IO 操作完成上。

🌈 拓展一下(参见:issue#1737):

线程数更严谨的计算的方法应该是:最佳线程数 = N(CPU 核心数)∗(1+WT(线程等待时间)/ST(线程计算时间)),其中 WT(线程等待时间)=线程运行总时间 - ST(线程计算时间)

线程等待时间所占比例越高,需要越多线程。线程计算时间所占比例越高,需要越少线程。

我们可以通过 JDK 自带的工具 VisualVM 来查看 WT/ST 比例。

CPU 密集型任务的 WT/ST 接近或者等于 0,因此,线程数可以设置为 N(CPU 核心数)∗(1+0)= N,和我们上面说的 N(CPU 核心数)+1 差不多。

IO 密集型任务下,几乎全是线程等待时间,从理论上来说,你就可以将线程数设置为 2 N(按道理来说,WT/ST 的结果应该比较大,这里选择 2 N 的原因应该是为了避免创建过多线程吧)。

如何动态修改线程池的参数?

美团技术团队在 《Java 线程池实现原理及其在美团业务中的实践》 这篇文章中介绍到对线程池参数实现可自定义配置的思路和方法。

美团技术团队的思路是主要对线程池的核心参数实现自定义可配置。这三个核心参数是:

  • corePoolSize : 核心线程数线程数定义了最小可以同时运行的线程数量。
  • maximumPoolSize : 当队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数。
  • workQueue: 当新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。

为什么是这三个参数?

我在 Java 线程池详解 这篇文章中就说过这三个参数是 ThreadPoolExecutor 最重要的参数,它们基本决定了线程池对于任务的处理策略。

如何支持参数动态配置? 且看 ThreadPoolExecutor 提供的下面这些方法。

格外需要注意的是 corePoolSize,程序运行期间的时候,我们调用 setCorePoolSize() 这个方法的话,线程池会首先判断当前工作线程数是否大于 corePoolSize,如果大于的话就会回收工作线程。

另外,你也看到了上面并没有动态指定队列长度的方法,美团的方式是自定义了一个叫做 ResizableCapacityLinkedBlockIngQueue 的队列(主要就是把 LinkedBlockingQueue 的 capacity 字段的 final 关键字修饰给去掉了,让它变为可变的)。

最终实现的可动态修改线程池参数效果如下。👏👏👏

动态配置线程池参数最终效果

还没看够?推荐 why 神的如何设置线程池参数?美团给出了一个让面试官虎躯一震的回答。 这篇文章,深度剖析,很不错哦!

如果我们的项目也想要实现这种效果的话,可以借助现成的开源项目:

  • **Hippo4j**:异步线程池框架,支持线程池动态变更&监控&报警,无需修改代码轻松引入。支持多种使用模式,轻松引入,致力于提高系统运行保障能力。
  • **Dynamic TP**:轻量级动态线程池,内置监控告警功能,集成三方中间件线程池管理,基于主流配置中心(已支持 Nacos、Apollo,Zookeeper、Consul、Etcd,可通过 SPI 自定义实现)。

如何设计一个能够根据任务的优先级来执行的线程池?

这是一个常见的面试问题,本质其实还是在考察求职者对于线程池以及阻塞队列的掌握。

我们上面也提到了,不同的线程池会选用不同的阻塞队列作为任务队列,比如 FixedThreadPool 使用的是 LinkedBlockingQueue(无界队列),由于队列永远不会被放满,因此 FixedThreadPool 最多只能创建核心线程数的线程。

假如我们需要实现一个优先级任务线程池的话,那可以考虑使用 PriorityBlockingQueue (优先级阻塞队列)作为任务队列(ThreadPoolExecutor 的构造函数有一个 workQueue 参数可以传入任务队列)。

ThreadPoolExecutor构造函数

PriorityBlockingQueue 是一个支持优先级的无界阻塞队列,可以看作是线程安全的 PriorityQueue,两者底层都是使用小顶堆形式的二叉堆,即值最小的元素优先出队。不过,PriorityQueue 不支持阻塞操作。

要想让 PriorityBlockingQueue 实现对任务的排序,传入其中的任务必须是具备排序能力的,方式有两种:

  1. 提交到线程池的任务实现 Comparable 接口,并重写 compareTo 方法来指定任务之间的优先级比较规则。
  2. 创建 PriorityBlockingQueue 时传入一个 Comparator 对象来指定任务之间的排序规则 (推荐)。

不过,这存在一些风险和问题,比如:

  • PriorityBlockingQueue 是无界的,可能堆积大量的请求,从而导致 OOM。
  • 可能会导致饥饿问题,即低优先级的任务长时间得不到执行。
  • 由于需要对队列中的元素进行排序操作以及保证线程安全(并发控制采用的是可重入锁 ReentrantLock),因此会降低性能。

对于 OOM 这个问题的解决比较简单粗暴,就是继承 PriorityBlockingQueue 并重写一下 offer 方法 (入队)的逻辑,当插入的元素数量超过指定值就返回 false 。

饥饿问题这个可以通过优化设计来解决(比较麻烦),比如等待时间过长的任务会被移除并重新添加到队列中,但是优先级会被提升。

对于性能方面的影响,是没办法避免的,毕竟需要对任务进行排序操作。并且,对于大部分业务场景来说,这点性能影响是可以接受的。

Future 类

Future 介绍

Future 类是异步思想的典型运用,主要用在一些需要执行耗时任务的场景,避免程序一直原地等待耗时任务执行完成,执行效率太低。具体来说是这样的:当我们执行某一耗时的任务时,可以将这个耗时任务交给一个子线程去异步执行,同时我们可以干点其他事情,不用傻傻等待耗时任务执行完成。等我们的事情干完后,我们再通过 Future 类获取到耗时任务的执行结果。这样一来,程序的执行效率就明显提高了。

这其实就是多线程中经典的 Future 模式,你可以将其看作是一种设计模式,核心思想是异步调用,主要用在多线程领域,并非 Java 语言独有。

在 Java 中,Future 类只是一个泛型接口,位于 java. Util. Concurrent 包下,其中定义了 5 个方法,主要包括下面这 4 个功能:

  • 取消任务;
  • 判断任务是否被取消;
  • 判断任务是否已经执行完成;
  • 获取任务执行结果。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// V 代表了 Future 执行的任务返回值的类型
public interface Future<V> {
// 取消任务执行
// 成功取消返回 true,否则返回 false
Boolean cancel (boolean mayInterruptIfRunning);
// 判断任务是否被取消
Boolean isCancelled ();
// 判断任务是否已经执行完成
Boolean isDone ();
// 获取任务执行结果
V get () throws InterruptedException, ExecutionException;
// 指定时间内没有返回计算结果就抛出 TimeOutException 异常
V get (long timeout, TimeUnit unit)

Throws InterruptedException, ExecutionException, TimeoutExceptio

}

简单理解就是:我有一个任务,提交给了 Future 来处理。任务执行期间我自己可以去做任何想做的事情。并且,在这期间我还可以取消任务以及获取任务的执行状态。一段时间之后,我就可以 Future 那里直接取出任务执行结果。

Callable 和 Future 有什么关系?

我们可以通过 FutureTask 来理解 CallableFuture 之间的关系。

FutureTask 提供了 Future 接口的基本实现,常用来封装 CallableRunnable,具有取消任务、查看任务是否执行完成以及获取任务执行结果的方法。ExecutorService.Submit () 方法返回的其实就是 Future 的实现类 FutureTask

1
2
<T> Future<T> submit (Callable<T> task);
Future<?> submit (Runnable task);

FutureTask 不光实现了 Future 接口,还实现了 Runnable 接口,因此可以作为任务直接被线程执行。

FutureTask 有两个构造函数,可传入 Callable 或者 Runnable 对象。实际上,传入 Runnable 对象也会在方法内部转换为 Callable 对象。

1
2
3
4
5
6
7
8
9
10
11
public FutureTask (Callable<V> callable) {
If (callable == null)
Throw new NullPointerException ();
This. Callable = callable;
This. State = NEW;
}
Public FutureTask (Runnable runnable, V result) {
// 通过适配器 RunnableAdapter 来将 Runnable 对象 runnable 转换成 Callable 对象
This. Callable = Executors.Callable (runnable, result);
This. State = NEW;
}

FutureTask 相当于对 Callable 进行了封装,管理着任务执行的情况,存储了 Callablecall 方法的任务执行结果。

CompletableFuture 介绍

Future 在实际使用过程中存在一些局限性比如不支持异步任务的编排组合、获取计算结果的 get () 方法为阻塞调用。

Java 8 才被引入 CompletableFuture 类可以解决 Future 的这些缺陷。CompletableFuture 除了提供了更为好用和强大的 Future 特性之外,还提供了函数式编程、异步任务编排组合(可以将多个异步任务串联起来,组成一个完整的链式调用)等能力。

下面我们来简单看看 CompletableFuture 类的定义。

1
2
public class CompletableFuture<T> implements Future<T>, CompletionStage<T> {
}

可以看到,CompletableFuture 同时实现了 FutureCompletionStage 接口。

CompletionStage 接口描述了一个异步计算的阶段。很多计算可以分成多个阶段或步骤,此时可以通过它将所有步骤组合起来,形成异步计算的流水线。

CompletableFuture 除了提供了更为好用和强大的 Future 特性之外,还提供了函数式编程的能力。

Future 接口有 5 个方法:

  • boolean cancel (boolean mayInterruptIfRunning):尝试取消执行任务。
  • boolean isCancelled ():判断任务是否被取消。
  • boolean isDone ():判断任务是否已经被执行完成。
  • get ():等待任务执行完成并获取运算结果。
  • get (long timeout, TimeUnit unit):多了一个超时时间。

CompletionStage 接口描述了一个异步计算的阶段。很多计算可以分成多个阶段或步骤,此时可以通过它将所有步骤组合起来,形成异步计算的流水线。

CompletionStage 接口中的方法比较多,CompletableFuture 的函数式能力就是这个接口赋予的。从这个接口的方法参数你就可以发现其大量使用了 Java 8 引入的函数式编程。

由于方法众多,所以这里不能一一讲解,下文中我会介绍大部分常见方法的使用。

CompletableFuture 常见操作

创建 CompletableFuture

常见的创建 CompletableFuture 对象的方法如下:

  1. 通过 new 关键字。
  2. 基于 CompletableFuture 自带的静态工厂方法:runAsync ()supplyAsync ()
New 关键字

通过 new 关键字创建 CompletableFuture 对象这种使用方式可以看作是将 CompletableFuture 当做 Future 来使用。

我在我的开源项目 guide-rpc-framework 中就是这种方式创建的 CompletableFuture 对象。

下面咱们来看一个简单的案例。

我们通过创建了一个结果值类型为 RpcResponse<Object>CompletableFuture,你可以把 resultFuture 看作是异步运算结果的载体。

1
CompletableFuture<RpcResponse<Object>> resultFuture = new CompletableFuture<>();

假设在未来的某个时刻,我们得到了最终的结果。这时,我们可以调用 complete () 方法为其传入结果,这表示 resultFuture 已经被完成了。

1
2
// complete () 方法只能调用一次,后续调用将被忽略。
ResultFuture.Complete (rpcResponse);

你可以通过 isDone () 方法来检查是否已经完成。

1
2
3
Public boolean isDone () {
Return result != null;
}

获取异步计算的结果也非常简单,直接调用 get () 方法即可。调用 get () 方法的线程会阻塞直到 CompletableFuture 完成运算。

1
RpcResponse = completableFuture.Get ();

如果你已经知道计算的结果的话,可以使用静态方法 completedFuture () 来创建 CompletableFuture

1
2
CompletableFuture<String> future = CompletableFuture.CompletedFuture ("hello!");
AssertEquals ("hello!", future.Get ());

completedFuture () 方法底层调用的是带参数的 new 方法,只不过,这个方法不对外暴露。

1
2
3
public static <U> CompletableFuture<U> completedFuture (U value) {
return new CompletableFuture<U>((value == null) ? NIL : value);
}
静态工厂方法

这两个方法可以帮助我们封装计算逻辑。

1
2
3
4
5
6
static <U> CompletableFuture<U> supplyAsync (Supplier<U> supplier);
// 使用自定义线程池 (推荐)
static <U> CompletableFuture<U> supplyAsync (Supplier<U> supplier, Executor executor);
static CompletableFuture<Void> runAsync (Runnable runnable);
// 使用自定义线程池 (推荐)
static CompletableFuture<Void> runAsync (Runnable runnable, Executor executor);

runAsync () 方法接受的参数是 Runnable ,这是一个函数式接口,不允许返回值。当你需要异步操作且不关心返回结果的时候可以使用 runAsync () 方法。

1
2
3
4
@FunctionalInterface
Public interface Runnable {
Public abstract void run ();
}

supplyAsync () 方法接受的参数是 Supplier<U> ,这也是一个函数式接口,U 是返回结果值的类型。

1
2
3
4
5
6
7
8
9
10
@FunctionalInterface
public interface Supplier<T> {

/**
* Gets a result.
*
* @return a result
*/
T get ();
}

当你需要异步操作且关心返回结果的时候, 可以使用 supplyAsync () 方法。

1
2
3
4
CompletableFuture<Void> future = CompletableFuture.RunAsync (() -> System.Out.Println ("hello!"));
Future.Get ();// 输出 "hello!"
CompletableFuture<String> future 2 = CompletableFuture.SupplyAsync (() -> "hello!");
AssertEquals ("hello!", future 2.Get ());
处理异步结算的结果

当我们获取到异步计算的结果之后,还可以对其进行进一步的处理,比较常用的方法有下面几个:

  • thenApply ()
  • thenAccept ()
  • thenRun ()
  • whenComplete ()

thenApply () 方法接受一个 Function 实例,用它来处理结果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 沿用上一个任务的线程池
public <U> CompletableFuture<U> thenApply (
Function<? super T,? extends U> fn) {
Return uniApplyStage (null, fn);
}

//使用默认的 ForkJoinPool 线程池(不推荐)
public <U> CompletableFuture<U> thenApplyAsync (
Function<? super T,? extends U> fn) {
Return uniApplyStage (defaultExecutor (), fn);
}
// 使用自定义线程池 (推荐)
public <U> CompletableFuture<U> thenApplyAsync (
Function<? super T,? extends U> fn, Executor executor) {
Return uniApplyStage (screenExecutor (executor), fn);
}

thenApply () 方法使用示例如下:

1
2
3
4
5
6
CompletableFuture<String> future = CompletableFuture.CompletedFuture ("hello!")
.thenApply (s -> s + "world!");
AssertEquals ("hello! World!", future.Get ());
// 这次调用将被忽略。
Future.ThenApply (s -> s + "nice!");
AssertEquals ("hello! World!", future.Get ());

你还可以进行 流式调用

1
2
3
CompletableFuture<String> future = CompletableFuture.CompletedFuture ("hello!")
.thenApply (s -> s + "world!"). ThenApply (s -> s + "nice!");
AssertEquals ("hello! World! Nice!", future.Get ());

如果你不需要从回调函数中获取返回结果,可以使用 thenAccept () 或者 thenRun ()。这两个方法的区别在于 thenRun () 不能访问异步计算的结果。

thenAccept () 方法的参数是 Consumer<? super T>

1
2
3
4
5
6
7
8
9
10
11
12
public CompletableFuture<Void> thenAccept (Consumer<? super T> action) {
Return uniAcceptStage (null, action);
}

public CompletableFuture<Void> thenAcceptAsync (Consumer<? super T> action) {
Return uniAcceptStage (defaultExecutor (), action);
}

public CompletableFuture<Void> thenAcceptAsync (Consumer<? super T> action,
Executor executor) {
Return uniAcceptStage (screenExecutor (executor), action);
}

顾名思义,Consumer 属于消费型接口,它可以接收 1 个输入对象然后进行“消费”。

1
2
3
4
5
6
7
8
9
10
@FunctionalInterface
public interface Consumer<T> {

Void accept (T t);

default Consumer<T> andThen (Consumer<? super T> after) {
Objects.RequireNonNull (after);
Return (T t) -> { accept (t); after.Accept (t); };
}
}

thenRun () 的方法是的参数是 Runnable

1
2
3
4
5
6
7
8
9
10
11
12
public CompletableFuture<Void> thenRun (Runnable action) {
Return uniRunStage (null, action);
}

public CompletableFuture<Void> thenRunAsync (Runnable action) {
Return uniRunStage (defaultExecutor (), action);
}

public CompletableFuture<Void> thenRunAsync (Runnable action,
Executor executor) {
Return uniRunStage (screenExecutor (executor), action);
}

thenAccept ()thenRun () 使用示例如下:

1
2
3
4
5
CompletableFuture.CompletedFuture ("hello!")
.thenApply (s -> s + "world!"). ThenApply (s -> s + "nice!"). ThenAccept (System. Out::println);//hello! World! Nice!

CompletableFuture.CompletedFuture ("hello!")
.thenApply (s -> s + "world!"). ThenApply (s -> s + "nice!"). ThenRun (() -> System.Out.Println ("hello!"));//hello!

whenComplete () 的方法的参数是 BiConsumer<? super T, ? super Throwable>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public CompletableFuture<T> whenComplete (
BiConsumer<? super T, ? super Throwable> action) {
Return uniWhenCompleteStage (null, action);
}


public CompletableFuture<T> whenCompleteAsync (
BiConsumer<? super T, ? super Throwable> action) {
Return uniWhenCompleteStage (defaultExecutor (), action);
}
// 使用自定义线程池 (推荐)
public CompletableFuture<T> whenCompleteAsync (
BiConsumer<? super T, ? super Throwable> action, Executor executor) {
Return uniWhenCompleteStage (screenExecutor (executor), action);
}

相对于 ConsumerBiConsumer 可以接收 2 个输入对象然后进行“消费”。

1
2
3
4
5
6
7
8
9
10
11
12
13
@FunctionalInterface
public interface BiConsumer<T, U> {
Void accept (T t, U u);

default BiConsumer<T, U> andThen (BiConsumer<? super T, ? super U> after) {
Objects.RequireNonNull (after);

Return (l, r) -> {
Accept (l, r);
After.Accept (l, r);
};
}
}

whenComplete () 使用示例如下:

1
2
3
4
5
6
7
8
9
CompletableFuture<String> future = CompletableFuture.SupplyAsync (() -> "hello!")
.whenComplete ((res, ex) -> {
// res 代表返回的结果
// ex 的类型为 Throwable ,代表抛出的异常
System.Out.Println (res);
// 这里没有抛出异常所有为 null
AssertNull (ex);
});
AssertEquals ("hello!", future.Get ());

异常处理

你可以通过 handle () 方法来处理任务执行过程中可能出现的抛出异常的情况。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public <U> CompletableFuture<U> handle (
BiFunction<? super T, Throwable, ? extends U> fn) {
Return uniHandleStage (null, fn);
}

public <U> CompletableFuture<U> handleAsync (
BiFunction<? super T, Throwable, ? extends U> fn) {
Return uniHandleStage (defaultExecutor (), fn);
}

public <U> CompletableFuture<U> handleAsync (
BiFunction<? super T, Throwable, ? extends U> fn, Executor executor) {
Return uniHandleStage (screenExecutor (executor), fn);
}

示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
CompletableFuture<String> future
= CompletableFuture.SupplyAsync (() -> {
If (true) {
Throw new RuntimeException ("Computation error!");
}
Return "hello!";
}). Handle ((res, ex) -> {
// res 代表返回的结果
// ex 的类型为 Throwable ,代表抛出的异常
Return res != null ? Res : "world!";
});
AssertEquals ("world!", future.Get ());

你还可以通过 exceptionally () 方法来处理异常情况。

1
2
3
4
5
6
7
8
9
10
11
CompletableFuture<String> future
= CompletableFuture.SupplyAsync (() -> {
If (true) {
Throw new RuntimeException ("Computation error!");
}
Return "hello!";
}). Exceptionally (ex -> {
System.Out.Println (ex.ToString ());// CompletionException
Return "world!";
});
AssertEquals ("world!", future.Get ());

如果你想让 CompletableFuture 的结果就是异常的话,可以使用 completeExceptionally () 方法为其赋值。

1
2
3
4
5
6
CompletableFuture<String> completableFuture = new CompletableFuture<>();
// ...
CompletableFuture.CompleteExceptionally (
New RuntimeException ("Calculation failed!"));
// ...
CompletableFuture.Get (); // ExecutionException

组合 CompletableFuture

你可以使用 thenCompose () 按顺序链接两个 CompletableFuture 对象,实现异步的任务链。它的作用是将前一个任务的返回结果作为下一个任务的输入参数,从而形成一个依赖关系。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public <U> CompletableFuture<U> thenCompose (
Function<? super T, ? extends CompletionStage<U>> fn) {
Return uniComposeStage (null, fn);
}

public <U> CompletableFuture<U> thenComposeAsync (
Function<? super T, ? extends CompletionStage<U>> fn) {
Return uniComposeStage (defaultExecutor (), fn);
}

public <U> CompletableFuture<U> thenComposeAsync (
Function<? super T, ? extends CompletionStage<U>> fn,
Executor executor) {
Return uniComposeStage (screenExecutor (executor), fn);
}

thenCompose () 方法会使用示例如下:

1
2
3
4
CompletableFuture<String> future
= CompletableFuture.SupplyAsync (() -> "hello!")
.thenCompose (s -> CompletableFuture.SupplyAsync (() -> s + "world!"));
AssertEquals ("hello! World!", future.Get ());

在实际开发中,这个方法还是非常有用的。比如说,task 1 和 task 2 都是异步执行的,但 task 1 必须执行完成后才能开始执行 task 2(task 2 依赖 task 1 的执行结果)。

thenCompose () 方法类似的还有 thenCombine () 方法,它同样可以组合两个 CompletableFuture 对象。

1
2
3
4
5
6
CompletableFuture<String> completableFuture
= CompletableFuture.SupplyAsync (() -> "hello!")
.thenCombine (CompletableFuture.SupplyAsync (
() -> "world!"), (s 1, s 2) -> s 1 + s 2)
.thenCompose (s -> CompletableFuture.SupplyAsync (() -> s + "nice!"));
AssertEquals ("hello! World! Nice!", completableFuture.Get ());

thenCompose ()thenCombine () 有什么区别呢?

  • thenCompose () 可以链接两个 CompletableFuture 对象,并将前一个任务的返回结果作为下一个任务的参数,它们之间存在着先后顺序。
  • thenCombine () 会在两个任务都执行完成后,把两个任务的结果合并。两个任务是并行执行的,它们之间并没有先后依赖顺序。

除了 thenCompose ()thenCombine () 之外,还有一些其他的组合 CompletableFuture 的方法用于实现不同的效果,满足不同的业务需求。

例如,如果我们想要实现 task 1 和 task 2 中的任意一个任务执行完后就执行 task 3 的话,可以使用 acceptEither ()

1
2
3
4
5
6
7
8
9
public CompletableFuture<Void> acceptEither (
CompletionStage<? extends T> other, Consumer<? super T> action) {
Return orAcceptStage (null, other, action);
}

public CompletableFuture<Void> acceptEitherAsync (
CompletionStage<? extends T> other, Consumer<? super T> action) {
Return orAcceptStage (asyncPool, other, action);
}

简单举一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
CompletableFuture<String> task = CompletableFuture.SupplyAsync (() -> {
System.Out.Println ("任务 1 开始执行,当前时间:" + System.CurrentTimeMillis ());
Try {
Thread.Sleep (500);
} catch (InterruptedException e) {
e.printStackTrace ();
}
System.Out.Println ("任务 1 执行完毕,当前时间:" + System.CurrentTimeMillis ());
Return "task 1";
});

CompletableFuture<String> task 2 = CompletableFuture.SupplyAsync (() -> {
System.Out.Println ("任务 2 开始执行,当前时间:" + System.CurrentTimeMillis ());
Try {
Thread.Sleep (1000);
} catch (InterruptedException e) {
e.printStackTrace ();
}
System.Out.Println ("任务 2 执行完毕,当前时间:" + System.CurrentTimeMillis ());
Return "task 2";
});

Task.AcceptEitherAsync (task 2, (res) -> {
System.Out.Println ("任务 3 开始执行,当前时间:" + System.CurrentTimeMillis ());
System.Out.Println ("上一个任务的结果为:" + res);
});

// 增加一些延迟时间,确保异步任务有足够的时间完成
Try {
Thread.Sleep (2000);
} catch (InterruptedException e) {
e.printStackTrace ();
}

输出:

1
2
3
4
5
6
任务 1 开始执行,当前时间:1695088058520
任务 2 开始执行,当前时间:1695088058521
任务 1 执行完毕,当前时间:1695088059023
任务 3 开始执行,当前时间:1695088059023
上一个任务的结果为:task 1
任务 2 执行完毕,当前时间:1695088059523

任务组合操作 acceptEitherAsync () 会在异步任务 1 和异步任务 2 中的任意一个完成时触发执行任务 3,但是需要注意,这个触发时机是不确定的。如果任务 1 和任务 2 都还未完成,那么任务 3 就不能被执行。

并行运行多个 CompletableFuture

你可以通过 CompletableFutureallOf () 这个静态方法来并行运行多个 CompletableFuture

实际项目中,我们经常需要并行运行多个互不相关的任务,这些任务之间没有依赖关系,可以互相独立地运行。

比说我们要读取处理 6 个文件,这 6 个任务都是没有执行顺序依赖的任务,但是我们需要返回给用户的时候将这几个文件的处理的结果进行统计整理。像这种情况我们就可以使用并行运行多个 CompletableFuture 来处理。

示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
CompletableFuture<Void> task 1 =
CompletableFuture.SupplyAsync (()->{
//自定义业务操作
});
......
CompletableFuture<Void> task 6 =
CompletableFuture.SupplyAsync (()->{
//自定义业务操作
});
......
CompletableFuture<Void> headerFuture=CompletableFuture.AllOf (task 1,....., task 6);

Try {
HeaderFuture.Join ();
} catch (Exception ex) {
......
}
System.Out.Println ("all done. ");

经常和 allOf () 方法拿来对比的是 anyOf () 方法。

allOf () 方法会等到所有的 CompletableFuture 都运行完成之后再返回

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Random rand = new Random ();
CompletableFuture<String> future 1 = CompletableFuture.SupplyAsync (() -> {
Try {
Thread.Sleep (1000 + rand.NextInt (1000));
} catch (InterruptedException e) {
e.printStackTrace ();
} finally {
System.Out.Println ("future 1 done...");
}
Return "abc";
});
CompletableFuture<String> future 2 = CompletableFuture.SupplyAsync (() -> {
Try {
Thread.Sleep (1000 + rand.NextInt (1000));
} catch (InterruptedException e) {
e.printStackTrace ();
} finally {
System.Out.Println ("future 2 done...");
}
Return "efg";
});

调用 join () 可以让程序等 future 1future 2 都运行完了之后再继续执行。

1
2
3
4
CompletableFuture<Void> completableFuture = CompletableFuture.AllOf (future 1, future 2);
CompletableFuture.Join ();
AssertTrue (completableFuture.IsDone ());
System.Out.Println ("all futures done...");

输出:

1
2
3
Future 1 done...
Future 2 done...
All futures done...

anyOf () 方法不会等待所有的 CompletableFuture 都运行完成之后再返回,只要有一个执行完成即可!

1
2
CompletableFutureJavaObject> f = CompletableFuture.AnyOf (future 1, future 2);
System.Out.Println (f.get ());

CompletableFuture 使用建议

使用自定义线程池

我们上面的代码示例中,为了方便,都没有选择自定义线程池。实际项目中,这是不可取的。

CompletableFuture 默认使用 ForkJoinPool.CommonPool () 作为执行器,这个线程池是全局共享的,可能会被其他任务占用,导致性能下降或者饥饿。因此,建议使用自定义的线程池来执行 CompletableFuture 的异步任务,可以提高并发度和灵活性。

1
2
3
4
5
6
7
Private ThreadPoolExecutor executor = new ThreadPoolExecutor (10, 10,
0 L, TimeUnit. MILLISECONDS,
new LinkedBlockingQueue<Runnable>());

CompletableFuture.RunAsync (() -> {
//...
}, executor);

尽量避免使用 get ()

CompletableFutureget () 方法是阻塞的,尽量避免使用。如果必须要使用的话,需要添加超时时间,否则可能会导致主线程一直等待,无法执行其他任务。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
    CompletableFuture<String> future = CompletableFuture.SupplyAsync (() -> {
Try {
Thread.Sleep (10_000);
} catch (InterruptedException e) {
e.printStackTrace ();
}
Return "Hello, world!";
});

// 获取异步任务的返回值,设置超时时间为 5 秒
Try {
String result = future.Get (5, TimeUnit. SECONDS);
System.Out.Println (result);
} catch (InterruptedException | ExecutionException | TimeoutException e) {
// 处理异常
e.printStackTrace ();
}
}

上面这段代码在调用 get () 时抛出了 TimeoutException 异常。这样我们就可以在异常处理中进行相应的操作,比如取消任务、重试任务、记录日志等。

正确进行异常处理

使用 CompletableFuture 的时候一定要以正确的方式进行异常处理,避免异常丢失或者出现不可控问题。

下面是一些建议:

  • 使用 whenComplete 方法可以在任务完成时触发回调函数,并正确地处理异常,而不是让异常被吞噬或丢失。
  • 使用 exceptionally 方法可以处理异常并重新抛出,以便异常能够传播到后续阶段,而不是让异常被忽略或终止。
  • 使用 handle 方法可以处理正常的返回结果和异常,并返回一个新的结果,而不是让异常影响正常的业务逻辑。
  • 使用 CompletableFuture. AllOf 方法可以组合多个 CompletableFuture,并统一处理所有任务的异常,而不是让异常处理过于冗长或重复。
  • ……

合理组合多个异步任务

正确使用 thenCompose ()thenCombine ()acceptEither ()allOf ()anyOf () 等方法来组合多个异步任务,以满足实际业务的需求,提高程序执行效率。

实际使用中,我们还可以利用或者参考现成的异步任务编排框架,比如京东的 asyncTool

asyncTool README 文档

AQS 介绍

AQS 是什么?

AQS 的全称为 AbstractQueuedSynchronizer ,翻译过来的意思就是抽象队列同步器。这个类在 java. Util. Concurrent. Locks 包下面。

AQS 就是一个抽象类,主要用来构建锁和同步器。

1
2
Public abstract class AbstractQueuedSynchronizer extends AbstractOwnableSynchronizer implements java. Io. Serializable {
}

AQS 为构建锁和同步器提供了一些通用功能的实现,因此,使用 AQS 能简单且高效地构造出应用广泛的大量的同步器,比如我们提到的 ReentrantLockSemaphore,其他的诸如 ReentrantReadWriteLockSynchronousQueue 等等皆是基于 AQS 的。

AQS 为构建锁和同步器提供了一些通用功能的实现,因此,使用 AQS 能简单且高效地构造出应用广泛的大量的同步器,比如我们提到的 ReentrantLockSemaphore,其他的诸如 ReentrantReadWriteLockSynchronousQueue 等等皆是基于 AQS 的。

AQS 核心思想

AQS 核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制 AQS 是基于 CLH 锁 (Craig, Landin, and Hagersten locks) 实现的。

CLH 锁是对自旋锁的一种改进,是一个虚拟的双向队列(虚拟的双向队列即不存在队列实例,仅存在结点之间的关联关系),暂时获取不到锁的线程将被加入到该队列中。AQS 将每条请求共享资源的线程封装成一个 CLH 队列锁的一个结点(Node)来实现锁的分配。在 CLH 队列锁中,一个节点表示一个线程,它保存着线程的引用(thread)、当前节点在队列中的状态(waitStatus)、前驱节点(prev)、后继节点(next)。

CLH 队列结构如下图所示:

CLH 队列结构

关于 AQS 核心数据结构-CLH 锁的详细解读,强烈推荐阅读 Java AQS 核心数据结构-CLH 锁 - Qunar 技术沙龙 这篇文章。

AQS (AbstractQueuedSynchronizer)的核心原理图:

CLH 队列

AQS 使用 int 成员变量 state 表示同步状态,通过内置的 FIFO 线程等待/等待队列 来完成获取资源线程的排队工作。

state 变量由 volatile 修饰,用于展示当前临界资源的获锁情况。

1
2
// 共享变量,使用 volatile 修饰保证线程可见性
Private volatile int state;

另外,状态信息 state 可以通过 protected 类型的 getState ()setState ()compareAndSetState () 进行操作。并且,这几个方法都是 final 修饰的,在子类中无法被重写。

1
2
3
4
5
6
7
8
9
10
11
12
//返回同步状态的当前值
Protected final int getState () {
Return state;
}
// 设置同步状态的值
Protected final void setState (int newState) {
State = newState;
}
//原子地(CAS 操作)将同步状态值设置为给定值 update 如果当前同步状态的值等于 expect(期望值)
Protected final boolean compareAndSetState (int expect, int update) {
Return unsafe.CompareAndSwapInt (this, stateOffset, expect, update);
}

以可重入的互斥锁 ReentrantLock 为例,它的内部维护了一个 state 变量,用来表示锁的占用状态。state 的初始值为 0,表示锁处于未锁定状态。当线程 A 调用 lock () 方法时,会尝试通过 tryAcquire () 方法独占该锁,并让 state 的值加 1。如果成功了,那么线程 A 就获取到了锁。如果失败了,那么线程 A 就会被加入到一个等待队列(CLH 队列)中,直到其他线程释放该锁。假设线程 A 获取锁成功了,释放锁之前,A 线程自己是可以重复获取此锁的(state 会累加)。这就是可重入性的体现:一个线程可以多次获取同一个锁而不会被阻塞。但是,这也意味着,一个线程必须释放与获取的次数相同的锁,才能让 state 的值回到 0,也就是让锁恢复到未锁定状态。只有这样,其他等待的线程才能有机会获取该锁。

线程 A 尝试获取锁的过程如下图所示(图源从 ReentrantLock 的实现看 AQS 的原理及应用 - 美团技术团队):

AQS 独占模式获取锁

AQS 独占模式获取锁

再以倒计时器 CountDownLatch 以例,任务分为 N 个子线程去执行,state 也初始化为 N(注意 N 要与线程个数一致)。这 N 个子线程开始执行任务,每执行完一个子线程,就调用一次 countDown () 方法。该方法会尝试使用 CAS (Compare and Swap) 操作,让 state 的值减少 1。当所有的子线程都执行完毕后(即 state 的值变为 0),CountDownLatch 会调用 unpark () 方法,唤醒主线程。这时,主线程就可以从 await () 方法(CountDownLatch 中的 await () 方法而非 AQS 中的)返回,继续执行后续的操作。

AQS 资源共享方式

AQS 定义两种资源共享方式:Exclusive(独占,只有一个线程能执行,如 ReentrantLock)和 Share(共享,多个线程可同时执行,如 Semaphore / CountDownLatch)。

一般来说,自定义同步器的共享方式要么是独占,要么是共享,他们也只需实现 tryAcquire-tryReleasetryAcquireShared-tryReleaseShared 中的一种即可。但 AQS 也支持自定义同步器同时实现独占和共享两种方式,如 ReentrantReadWriteLock

自定义同步器

同步器的设计是基于模板方法模式的,如果需要自定义同步器一般的方式是这样(模板方法模式很经典的一个应用):

  1. 使用者继承 AbstractQueuedSynchronizer 并重写指定的方法。
  2. 将 AQS 组合在自定义同步组件的实现中,并调用其模板方法,而这些模板方法会调用使用者重写的方法。

这和我们以往通过实现接口的方式有很大区别,这是模板方法模式很经典的一个运用。

AQS 使用了模板方法模式,自定义同步器时需要重写下面几个 AQS 提供的钩子方法:

1
2
3
4
5
6
7
8
9
10
//独占方式。尝试获取资源,成功则返回 true,失败则返回 false。
Protected boolean tryAcquire (int)
//独占方式。尝试释放资源,成功则返回 true,失败则返回 false。
Protected boolean tryRelease (int)
//共享方式。尝试获取资源。负数表示失败;0 表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
Protected int tryAcquireShared (int)
//共享方式。尝试释放资源,成功则返回 true,失败则返回 false。
Protected boolean tryReleaseShared (int)
//该线程是否正在独占资源。只有用到 condition 才需要去实现它。
Protected boolean isHeldExclusively ()

什么是钩子方法呢? 钩子方法是一种被声明在抽象类中的方法,一般使用 protected 关键字修饰,它可以是空方法(由子类实现),也可以是默认实现的方法。模板设计模式通过钩子方法控制固定步骤的实现。

篇幅问题,这里就不详细介绍模板方法模式了,不太了解的小伙伴可以看看这篇文章:用 Java8 改造后的模板方法模式真的是 yyds!

除了上面提到的钩子方法之外,AQS 类中的其他方法都是 final ,所以无法被其他类重写。

AQS 的原理是什么?

AQS 核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制 AQS 是用 CLH 队列锁 实现的,即将暂时获取不到锁的线程加入到队列中。

CLH (Craig, Landin, and Hagersten) 队列是一个虚拟的双向队列(虚拟的双向队列即不存在队列实例,仅存在结点之间的关联关系)。AQS 是将每条请求共享资源的线程封装成一个 CLH 锁队列的一个结点(Node)来实现锁的分配。在 CLH 同步队列中,一个节点表示一个线程,它保存着线程的引用(thread)、当前节点在队列中的状态(waitStatus)、前驱节点(prev)、后继节点(next)。

CLH 队列结构如下图所示:

AQS (AbstractQueuedSynchronizer)的核心原理图(图源 Java 并发之 AQS 详解)如下:

AQS 使用 int 成员变量 state 表示同步状态,通过内置的 线程等待队列 来完成获取资源线程的排队工作。

state 变量由 volatile 修饰,用于展示当前临界资源的获锁情况。

1
2
// 共享变量,使用 volatile 修饰保证线程可见性
Private volatile int state;

另外,状态信息 state 可以通过 protected 类型的 getState ()setState ()compareAndSetState () 进行操作。并且,这几个方法都是 final 修饰的,在子类中无法被重写。

1
2
3
4
5
6
7
8
9
10
11
12
//返回同步状态的当前值
Protected final int getState () {
Return state;
}
// 设置同步状态的值
Protected final void setState (int newState) {
State = newState;
}
//原子地(CAS 操作)将同步状态值设置为给定值 update 如果当前同步状态的值等于 expect(期望值)
Protected final boolean compareAndSetState (int expect, int update) {
Return unsafe.CompareAndSwapInt (this, stateOffset, expect, update);
}

ReentrantLock 为例,state 初始值为 0,表示未锁定状态。A 线程 lock () 时,会调用 tryAcquire () 独占该锁并将 state+1 。此后,其他线程再 tryAcquire () 时就会失败,直到 A 线程 unlock ()state= 0(即释放锁)为止,其它线程才有机会获取该锁。当然,释放锁之前,A 线程自己是可以重复获取此锁的(state 会累加),这就是可重入的概念。但要注意,获取多少次就要释放多少次,这样才能保证 state 是能回到零态的。

再以 CountDownLatch 以例,任务分为 N 个子线程去执行,state 也初始化为 N(注意 N 要与线程个数一致)。这 N 个子线程是并行执行的,每个子线程执行完后 countDown () 一次,state 会 CAS (Compare and Swap) 减 1。等到所有子线程都执行完后 (即 state=0 ),会 unpark () 主调用线程,然后主调用线程就会从 await () 函数返回,继续后余动作。

常见同步工具类

下面介绍几个基于 AQS 的常见同步工具类。

Semaphore (信号量)

介绍

synchronizedReentrantLock 都是一次只允许一个线程访问某个资源,而 Semaphore (信号量)可以用来控制同时访问特定资源的线程数量。

Semaphore 的使用简单,我们这里假设有 N (N>5) 个线程来获取 Semaphore 中的共享资源,下面的代码表示同一时刻 N 个线程中只有 5 个线程能获取到共享资源,其他线程都会阻塞,只有获取到共享资源的线程才能执行。等到有线程释放了共享资源,其他阻塞的线程才能获取到。

1
2
3
4
5
6
// 初始共享资源数量
Final Semaphore semaphore = new Semaphore (5);
// 获取 1 个许可
Semaphore.Acquire ();
// 释放 1 个许可
Semaphore.Release ();

当初始的资源个数为 1 的时候,Semaphore 退化为排他锁。

Semaphore 有两种模式:。

  • 公平模式: 调用 acquire () 方法的顺序就是获取许可证的顺序,遵循 FIFO;
  • 非公平模式: 抢占式的。

Semaphore 对应的两个构造方法如下:

1
2
3
4
5
6
7
Public Semaphore (int permits) {
Sync = new NonfairSync (permits);
}

Public Semaphore (int permits, boolean fair) {
Sync = fair ? New FairSync (permits) : new NonfairSync (permits);
}

这两个构造方法,都必须提供许可的数量,第二个构造方法可以指定是公平模式还是非公平模式,默认非公平模式。

Semaphore 通常用于那些资源有明确访问数量限制的场景比如限流(仅限于单机模式,实际项目中推荐使用 Redis +Lua 来做限流)。

原理

Semaphore 是共享锁的一种实现,它默认构造 AQS 的 state 值为 permits,你可以将 permits 的值理解为许可证的数量,只有拿到许可证的线程才能执行。

以无参 acquire 方法为例,调用 semaphore.Acquire () ,线程尝试获取许可证,如果 state > 0 的话,则表示可以获取成功,如果 state <= 0 的话,则表示许可证数量不足,获取失败。

如果可以获取成功的话 (state > 0 ),会尝试使用 CAS 操作去修改 state 的值 state=state-1。如果获取失败则会创建一个 Node 节点加入等待队列,挂起当前线程。

1
2
3
4
5
6
7
8
9
10
// 获取 1 个许可证
Public void acquire () throws InterruptedException {
Sync.AcquireSharedInterruptibly (1);
}

// 获取一个或者多个许可证
Public void acquire (int permits) throws InterruptedException {
If (permits < 0) throw new IllegalArgumentException ();
Sync.AcquireSharedInterruptibly (permits);
}

acquireSharedInterruptibly 方法是 AbstractQueuedSynchronizer 中的默认实现。

1
2
3
4
5
6
7
8
9
// 共享模式下获取许可证,获取成功则返回,失败则加入等待队列,挂起线程
Public final void acquireSharedInterruptibly (int arg)
Throws InterruptedException {
If (Thread.Interrupted ())
Throw new InterruptedException ();
// 尝试获取许可证,arg 为获取许可证个数,当获取失败时, 则创建一个节点加入等待队列,挂起当前线程。
If (tryAcquireShared (arg) < 0)
DoAcquireSharedInterruptibly (arg);
}

这里再以非公平模式(NonfairSync)的为例,看看 tryAcquireShared 方法的实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 共享模式下尝试获取资源 (在 Semaphore 中的资源即许可证):
Protected int tryAcquireShared (int acquires) {
Return nonfairTryAcquireShared (acquires);
}

// 非公平的共享模式获取许可证
Final int nonfairTryAcquireShared (int acquires) {
For (;;) {
// 当前可用许可证数量
Int available = getState ();
/*
* 尝试获取许可证,当前可用许可证数量小于等于 0 时,返回负值,表示获取失败,
* 当前可用许可证大于 0 时才可能获取成功,CAS 失败了会循环重新获取最新的值尝试获取
*/
Int remaining = available - acquires;
If (remaining < 0 ||
CompareAndSetState (available, remaining))
Return remaining;
}
}

以无参 release 方法为例,调用 semaphore.Release (); ,线程尝试释放许可证,并使用 CAS 操作去修改 state 的值 state=state+1。释放许可证成功之后,同时会唤醒等待队列中的一个线程。被唤醒的线程会重新尝试去修改 state 的值 state=state-1 ,如果 state > 0 则获取令牌成功,否则重新进入等待队列,挂起线程。

1
2
3
4
5
6
7
8
9
10
// 释放一个许可证
Public void release () {
Sync.ReleaseShared (1);
}

// 释放一个或者多个许可证
Public void release (int permits) {
If (permits < 0) throw new IllegalArgumentException ();
Sync.ReleaseShared (permits);
}

releaseShared 方法是 AbstractQueuedSynchronizer 中的默认实现。

1
2
3
4
5
6
7
8
9
10
11
// 释放共享锁
// 如果 tryReleaseShared 返回 true,就唤醒等待队列中的一个或多个线程。
Public final boolean releaseShared (int arg) {
//释放共享锁
If (tryReleaseShared (arg)) {
//释放当前节点的后置等待节点
DoReleaseShared ();
Return true;
}
Return false;
}

tryReleaseShared 方法是 Semaphore 的内部类 Sync 重写的一个方法, AbstractQueuedSynchronizer 中的默认实现仅仅抛出 UnsupportedOperationException 异常。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 内部类 Sync 中重写的一个方法
// 尝试释放资源
Protected final boolean tryReleaseShared (int releases) {
For (;;) {
Int current = getState ();
// 可用许可证+1
Int next = current + releases;
If (next < current) // overflow
Throw new Error ("Maximum permit count exceeded");
// CAS 修改 state 的值
If (compareAndSetState (current, next))
Return true;
}
}

可以看到,上面提到的几个方法底层基本都是通过同步器 sync 实现的。SyncCountDownLatch 的内部类 , 继承了 AbstractQueuedSynchronizer ,重写了其中的某些方法。并且,Sync 对应的还有两个子类 NonfairSync(对应非公平模式) 和 FairSync(对应公平模式)。

1
2
3
4
5
6
7
8
9
Private static final class Sync extends AbstractQueuedSynchronizer {
// ...
}
Static final class NonfairSync extends Sync {
// ...
}
Static final class FairSync extends Sync {
// ...
}
实战
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
Public class SemaphoreExample {
// 请求的数量
Private static final int threadCount = 550;

Public static void main (String[] args) throws InterruptedException {
// 创建一个具有固定线程数量的线程池对象(如果这里线程池的线程数量给太少的话你会发现执行的很慢)
ExecutorService threadPool = Executors.NewFixedThreadPool (300);
// 初始许可证数量
Final Semaphore semaphore = new Semaphore (20);

For (int i = 0; i < threadCount; i++) {
Final int threadnum = i;
ThreadPool.Execute (() -> {// Lambda 表达式的运用
Try {
Semaphore.Acquire ();// 获取一个许可,所以可运行线程数量为 20/1=20
Test (threadnum);
Semaphore.Release ();// 释放一个许可
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace ();
}

});
}
ThreadPool.Shutdown ();
System.Out.Println ("finish");
}

Public static void test (int threadnum) throws InterruptedException {
Thread.Sleep (1000);// 模拟请求的耗时操作
System.Out.Println ("threadnum: " + threadnum);
Thread.Sleep (1000);// 模拟请求的耗时操作
}
}

执行 acquire () 方法阻塞,直到有一个许可证可以获得然后拿走一个许可证;每个 release 方法增加一个许可证,这可能会释放一个阻塞的 acquire () 方法。然而,其实并没有实际的许可证这个对象,Semaphore 只是维持了一个可获得许可证的数量。 Semaphore 经常用于限制获取某种资源的线程数量。

当然一次也可以一次拿取和释放多个许可,不过一般没有必要这样做:

1
2
3
Semaphore.Acquire (5);// 获取 5 个许可,所以可运行线程数量为 20/5=4
Test (threadnum);
Semaphore.Release (5);// 释放 5 个许可

除了 acquire () 方法之外,另一个比较常用的与之对应的方法是 tryAcquire () 方法,该方法如果获取不到许可就立即返回 false。

issue645 补充内容

SemaphoreCountDownLatch 一样,也是共享锁的一种实现。它默认构造 AQS 的 statepermits。当执行任务的线程数量超出 permits,那么多余的线程将会被放入等待队列 Park, 并自旋判断 state 是否大于 0。只有当 state 大于 0 的时候,阻塞的线程才能继续执行, 此时先前执行任务的线程继续执行 release () 方法,release () 方法使得 state 的变量会加 1,那么自旋的线程便会判断成功。如此,每次只有最多不超过 permits 数量的线程能自旋成功,便限制了执行任务线程的数量。

CountDownLatch (倒计时器)

介绍

CountDownLatch 允许 count 个线程阻塞在一个地方,直至所有线程的任务都执行完毕。

CountDownLatch 是一次性的,计数器的值只能在构造方法中初始化一次,之后没有任何机制再次对其设置值,当 CountDownLatch 使用完毕后,它不能再次被使用。

原理

CountDownLatch 是共享锁的一种实现,它默认构造 AQS 的 state 值为 count。这个我们通过 CountDownLatch 的构造方法即可看出。

1
2
3
4
5
6
7
8
9
10
11
Public CountDownLatch (int count) {
If (count < 0) throw new IllegalArgumentException ("count < 0");
This. Sync = new Sync (count);
}

Private static final class Sync extends AbstractQueuedSynchronizer {
Sync (int count) {
SetState (count);
}
//...
}

当线程调用 countDown () 时,其实使用了 tryReleaseShared 方法以 CAS 的操作来减少 state,直至 state 为 0 。当 state 为 0 时,表示所有的线程都调用了 countDown 方法,那么在 CountDownLatch 上等待的线程就会被唤醒并继续执行。

1
2
3
4
Public void countDown () {
// Sync 是 CountDownLatch 的内部类 , 继承了 AbstractQueuedSynchronizer
Sync.ReleaseShared (1);
}

releaseShared 方法是 AbstractQueuedSynchronizer 中的默认实现。

1
2
3
4
5
6
7
8
9
10
11
// 释放共享锁
// 如果 tryReleaseShared 返回 true,就唤醒等待队列中的一个或多个线程。
Public final boolean releaseShared (int arg) {
//释放共享锁
If (tryReleaseShared (arg)) {
//释放当前节点的后置等待节点
DoReleaseShared ();
Return true;
}
Return false;
}

tryReleaseShared 方法是 CountDownLatch 的内部类 Sync 重写的一个方法, AbstractQueuedSynchronizer 中的默认实现仅仅抛出 UnsupportedOperationException 异常。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 对 state 进行递减,直到 state 变成 0;
// 只有 count 递减到 0 时,countDown 才会返回 true
Protected boolean tryReleaseShared (int releases) {
// 自选检查 state 是否为 0
For (;;) {
Int c = getState ();
// 如果 state 已经是 0 了,直接返回 false
If (c == 0)
Return false;
// 对 state 进行递减
Int nextc = c-1;
// CAS 操作更新 state 的值
If (compareAndSetState (c, nextc))
Return nextc == 0;
}
}

以无参 await 方法为例,当调用 await () 的时候,如果 state 不为 0,那就证明任务还没有执行完毕,await () 就会一直阻塞,也就是说 await () 之后的语句不会被执行(main 线程被加入到等待队列也就是 CLH 队列中了)。然后,CountDownLatch 会自旋 CAS 判断 state == 0,如果 state == 0 的话,就会释放所有等待的线程,await () 方法之后的语句得到执行。

1
2
3
4
5
6
7
8
9
// 等待(也可以叫做加锁)
Public void await () throws InterruptedException {
Sync.AcquireSharedInterruptibly (1);
}
// 带有超时时间的等待
Public boolean await (long timeout, TimeUnit unit)
Throws InterruptedException {
Return sync.TryAcquireSharedNanos (1, unit.ToNanos (timeout));
}

acquireSharedInterruptibly 方法是 AbstractQueuedSynchronizer 中的默认实现。

1
2
3
4
5
6
7
8
9
10
// 尝试获取锁,获取成功则返回,失败则加入等待队列,挂起线程
Public final void acquireSharedInterruptibly (int arg)
Throws InterruptedException {
If (Thread.Interrupted ())
Throw new InterruptedException ();
// 尝试获得锁,获取成功则返回
If (tryAcquireShared (arg) < 0)
// 获取失败加入等待队列,挂起线程
DoAcquireSharedInterruptibly (arg);
}

tryAcquireShared 方法是 CountDownLatch 的内部类 Sync 重写的一个方法,其作用就是判断 state 的值是否为 0,是的话就返回 1,否则返回 -1。

1
2
3
Protected int tryAcquireShared (int acquires) {
Return (getState () == 0) ? 1 : -1;
}
实战

CountDownLatch 的两种典型用法

  1. 某一线程在开始运行前等待 n 个线程执行完毕 : 将 CountDownLatch 的计数器初始化为 n (new CountDownLatch (n)),每当一个任务线程执行完毕,就将计数器减 1 (countdownlatch.CountDown ()),当计数器的值变为 0 时,在 CountDownLatch 上 await () 的线程就会被唤醒。一个典型应用场景就是启动一个服务时,主线程需要等待多个组件加载完毕,之后再继续执行。
  2. 实现多个线程开始执行任务的最大并行性:注意是并行性,不是并发,强调的是多个线程在某一时刻同时开始执行。类似于赛跑,将多个线程放到起点,等待发令枪响,然后同时开跑。做法是初始化一个共享的 CountDownLatch 对象,将其计数器初始化为 1 (new CountDownLatch (1)),多个线程在开始执行任务前首先 coundownlatch.Await (),当主线程调用 countDown () 时,计数器变为 0,多个线程同时被唤醒。

CountDownLatch 代码示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
Public class CountDownLatchExample {
// 请求的数量
Private static final int THREAD_COUNT = 550;

Public static void main (String[] args) throws InterruptedException {
// 创建一个具有固定线程数量的线程池对象(如果这里线程池的线程数量给太少的话你会发现执行的很慢)
// 只是测试使用,实际场景请手动赋值线程池参数
ExecutorService threadPool = Executors.NewFixedThreadPool (300);
Final CountDownLatch countDownLatch = new CountDownLatch (THREAD_COUNT);
For (int i = 0; i < THREAD_COUNT; i++) {
Final int threadNum = i;
ThreadPool.Execute (() -> {
Try {
Test (threadNum);
} catch (InterruptedException e) {
e.printStackTrace ();
} finally {
// 表示一个请求已经被完成
CountDownLatch.CountDown ();
}

});
}
CountDownLatch.Await ();
ThreadPool.Shutdown ();
System.Out.Println ("finish");
}

Public static void test (int threadnum) throws InterruptedException {
Thread.Sleep (1000);
System.Out.Println ("threadNum: " + threadnum);
Thread.Sleep (1000);
}
}

上面的代码中,我们定义了请求的数量为 550,当这 550 个请求被处理完成之后,才会执行 System.Out.Println ("finish");

CountDownLatch 的第一次交互是主线程等待其他线程。主线程必须在启动其他线程后立即调用 CountDownLatch.Await () 方法。这样主线程的操作就会在这个方法上阻塞,直到其他线程完成各自的任务。

其他 N 个线程必须引用闭锁对象,因为他们需要通知 CountDownLatch 对象,他们已经完成了各自的任务。这种通知机制是通过 CountDownLatch.CountDown () 方法来完成的;每调用一次这个方法,在构造函数中初始化的 count 值就减 1。所以当 N 个线程都调用了这个方法,count 的值等于 0,然后主线程就能通过 await () 方法,恢复执行自己的任务。

再插一嘴:CountDownLatchawait () 方法使用不当很容易产生死锁,比如我们上面代码中的 for 循环改为:

1
2
3
For (int i = 0; i < threadCount-1; i++) {
.......
}

这样就导致 count 的值没办法等于 0,然后就会导致一直等待。

CyclicBarrier (循环栅栏)

介绍

CyclicBarrierCountDownLatch 非常类似,它也可以实现线程间的技术等待,但是它的功能比 CountDownLatch 更加复杂和强大。主要应用场景和 CountDownLatch 类似。

CountDownLatch 的实现是基于 AQS 的,而 CycliBarrier 是基于 ReentrantLock (ReentrantLock 也属于 AQS 同步器)和 Condition 的。

CyclicBarrier 的字面意思是可循环使用(Cyclic)的屏障(Barrier)。它要做的事情是:让一组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续干活。

原理

CyclicBarrier 内部通过一个 count 变量作为计数器,count 的初始值为 parties 属性的初始化值,每当一个线程到了栅栏这里了,那么就将计数器减 1。如果 count 值为 0 了,表示这是这一代最后一个线程到达栅栏,就尝试执行我们构造方法中输入的任务。

1
2
3
4
//每次拦截的线程数
Private final int parties;
//计数器
Private int count;

下面我们结合源码来简单看看。

1、CyclicBarrier 默认的构造方法是 CyclicBarrier (int parties),其参数表示屏障拦截的线程数量,每个线程调用 await () 方法告诉 CyclicBarrier 我已经到达了屏障,然后当前线程被阻塞。

1
2
3
4
5
6
7
8
9
10
Public CyclicBarrier (int parties) {
This (parties, null);
}

Public CyclicBarrier (int parties, Runnable barrierAction) {
If (parties <= 0) throw new IllegalArgumentException ();
This. Parties = parties;
This. Count = parties;
This. BarrierCommand = barrierAction;
}

其中,parties 就代表了有拦截的线程的数量,当拦截的线程数量达到这个值的时候就打开栅栏,让所有线程通过。

2、当调用 CyclicBarrier 对象调用 await () 方法时,实际上调用的是 dowait (false, 0 L) 方法。 await () 方法就像树立起一个栅栏的行为一样,将线程挡住了,当拦住的线程数量达到 parties 的值时,栅栏才会打开,线程才得以通过执行。

1
2
3
4
5
6
7
Public int await () throws InterruptedException, BrokenBarrierException {
Try {
Return dowait (false, 0 L);
} catch (TimeoutException toe) {
Throw new Error (toe); // cannot happen
}
}

dowait (false, 0 L) 方法源码分析如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
// 当线程数量或者请求数量达到 count 时 await 之后的方法才会被执行。上面的示例中 count 的值就为 5。
Private int count;
/**
* Main barrier code, covering the various policies.
*/
Private int dowait (boolean timed, long nanos)
Throws InterruptedException, BrokenBarrierException,
TimeoutException {
Final ReentrantLock lock = this. Lock;
// 锁住
Lock.Lock ();
Try {
Final Generation g = generation;

if (g.broken)
Throw new BrokenBarrierException ();

// 如果线程中断了,抛出异常
If (Thread.Interrupted ()) {
BreakBarrier ();
Throw new InterruptedException ();
}
// count 减 1
Int index = --count;
// 当 count 数量减为 0 之后说明最后一个线程已经到达栅栏了,也就是达到了可以执行 await 方法之后的条件
If (index == 0) { // tripped
Boolean ranAction = false;
Try {
Final Runnable command = barrierCommand;
If (command != null)
Command.Run ();
RanAction = true;
// 将 count 重置为 parties 属性的初始化值
// 唤醒之前等待的线程
// 下一波执行开始
NextGeneration ();
Return 0;
} finally {
If (! RanAction)
BreakBarrier ();
}
}

// loop until tripped, broken, interrupted, or timed out
For (;;) {
Try {
If (! Timed)
Trip.Await ();
Else if (nanos > 0 L)
Nanos = trip.AwaitNanos (nanos);
} catch (InterruptedException ie) {
if (g == generation && ! g.broken) {
BreakBarrier ();
Throw ie;
} else {
// We're about to finish waiting even if we had not
// been interrupted, so this interrupt is deemed to
// "belong" to subsequent execution.
Thread.CurrentThread (). Interrupt ();
}
}

if (g.broken)
Throw new BrokenBarrierException ();

If (g != generation)
Return index;

If (timed && nanos <= 0 L) {
BreakBarrier ();
Throw new TimeoutException ();
}
}
} finally {
Lock.Unlock ();
}
}
实战

示例 1:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
Public class CyclicBarrierExample 1 {
// 请求的数量
Private static final int threadCount = 550;
// 需要同步的线程数量
Private static final CyclicBarrier cyclicBarrier = new CyclicBarrier (5);

Public static void main (String[] args) throws InterruptedException {
// 创建线程池
ExecutorService threadPool = Executors.NewFixedThreadPool (10);

For (int i = 0; i < threadCount; i++) {
Final int threadNum = i;
Thread.Sleep (1000);
ThreadPool.Execute (() -> {
Try {
Test (threadNum);
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace ();
} catch (BrokenBarrierException e) {
// TODO Auto-generated catch block
e.printStackTrace ();
}
});
}
ThreadPool.Shutdown ();
}

Public static void test (int threadnum) throws InterruptedException, BrokenBarrierException {
System.Out.Println ("threadnum: " + threadnum + "is ready");
Try {
/**等待 60 秒,保证子线程完全执行结束*/
CyclicBarrier.Await (60, TimeUnit. SECONDS);
} catch (Exception e) {
System.Out.Println ("-----CyclicBarrierException------");
}
System.Out.Println ("threadnum: " + threadnum + "is finish");
}

}

运行结果,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Threadnum: 0 is ready
Threadnum: 1 is ready
Threadnum: 2 is ready
Threadnum: 3 is ready
Threadnum: 4 is ready
Threadnum: 4 is finish
Threadnum: 0 is finish
Threadnum: 1 is finish
Threadnum: 2 is finish
Threadnum: 3 is finish
Threadnum: 5 is ready
Threadnum: 6 is ready
Threadnum: 7 is ready
Threadnum: 8 is ready
Threadnum: 9 is ready
Threadnum: 9 is finish
Threadnum: 5 is finish
Threadnum: 8 is finish
Threadnum: 7 is finish
Threadnum: 6 is finish
......

可以看到当线程数量也就是请求数量达到我们定义的 5 个的时候, await () 方法之后的方法才被执行。

另外,CyclicBarrier 还提供一个更高级的构造函数 CyclicBarrier (int parties, Runnable barrierAction),用于在线程到达屏障时,优先执行 barrierAction,方便处理更复杂的业务场景。

示例 2:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
Public class CyclicBarrierExample 2 {
// 请求的数量
Private static final int threadCount = 550;
// 需要同步的线程数量
Private static final CyclicBarrier cyclicBarrier = new CyclicBarrier (5, () -> {
System.Out.Println ("------当线程数达到之后,优先执行------");
});

Public static void main (String[] args) throws InterruptedException {
// 创建线程池
ExecutorService threadPool = Executors.NewFixedThreadPool (10);

For (int i = 0; i < threadCount; i++) {
Final int threadNum = i;
Thread.Sleep (1000);
ThreadPool.Execute (() -> {
Try {
Test (threadNum);
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace ();
} catch (BrokenBarrierException e) {
// TODO Auto-generated catch block
e.printStackTrace ();
}
});
}
ThreadPool.Shutdown ();
}

Public static void test (int threadnum) throws InterruptedException, BrokenBarrierException {
System.Out.Println ("threadnum: " + threadnum + "is ready");
CyclicBarrier.Await ();
System.Out.Println ("threadnum: " + threadnum + "is finish");
}

}

运行结果,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Threadnum: 0 is ready
Threadnum: 1 is ready
Threadnum: 2 is ready
Threadnum: 3 is ready
Threadnum: 4 is ready
------当线程数达到之后,优先执行------
Threadnum: 4 is finish
Threadnum: 0 is finish
Threadnum: 2 is finish
Threadnum: 1 is finish
Threadnum: 3 is finish
Threadnum: 5 is ready
Threadnum: 6 is ready
Threadnum: 7 is ready
Threadnum: 8 is ready
Threadnum: 9 is ready
------当线程数达到之后,优先执行------
Threadnum: 9 is finish
Threadnum: 5 is finish
Threadnum: 6 is finish
Threadnum: 8 is finish
Threadnum: 7 is finish
......

虚拟线程

什么是虚拟线程?

虚拟线程在 Java 21 正式发布,这是一项重量级的更新。

虚拟线程(Virtual Thread)是 JDK 而不是 OS 实现的轻量级线程 (Lightweight Process,LWP),由 JVM 调度。许多虚拟线程共享同一个操作系统线程,虚拟线程的数量可以远大于操作系统线程的数量。

虚拟线程和平台线程有什么关系?

在引入虚拟线程之前,java. Lang. Thread 包已经支持所谓的平台线程(Platform Thread),也就是没有虚拟线程之前,我们一直使用的线程。JVM 调度程序通过平台线程(载体线程)来管理虚拟线程,一个平台线程可以在不同的时间执行不同的虚拟线程(多个虚拟线程挂载在一个平台线程上),当虚拟线程被阻塞或等待时,平台线程可以切换到执行另一个虚拟线程。

虚拟线程、平台线程和系统内核线程的关系图如下所示(图源:How to Use Java 19 Virtual Threads):

虚拟线程、平台线程和系统内核线程的关系

关于平台线程和系统内核线程的对应关系多提一点:在 Windows 和 Linux 等主流操作系统中,Java 线程采用的是一对一的线程模型,也就是一个平台线程对应一个系统内核线程。Solaris 系统是一个特例,HotSpot VM 在 Solaris 上支持多对多和一对一。具体可以参考 R 大的回答: JVM 中的线程模型是用户级的么?

虚拟线程有什么优点和缺点?

优点

  • 非常轻量级:可以在单个线程中创建成百上千个虚拟线程而不会导致过多的线程创建和上下文切换。
  • 简化异步编程: 虚拟线程可以简化异步编程,使代码更易于理解和维护。它可以将异步代码编写得更像同步代码,避免了回调地狱(Callback Hell)。
  • 减少资源开销: 相比于操作系统线程,虚拟线程的资源开销更小。本质上是提高了线程的执行效率,从而减少线程资源的创建和上下文切换。

缺点

  • 不适用于计算密集型任务: 虚拟线程适用于 I/O 密集型任务,但不适用于计算密集型任务,因为密集型计算始终需要 CPU 资源作为支持。
  • 依赖于语言或库的支持: 协程需要编程语言或库提供支持。不是所有编程语言都原生支持协程。比如 Java 实现的虚拟线程。

四种创建虚拟线程的方法

Java 21 已经正式支持虚拟线程,大家可以在官网下载使用,在使用上官方为了降低使用门槛,尽量复用原有的 Thread 类,让大家可以更加平滑的使用。

官方提供了以下四种方式创建虚拟线程:

  1. 使用 Thread.StartVirtualThread () 创建
  2. 使用 Thread.OfVirtual () 创建
  3. 使用 ThreadFactory 创建

使用 Thread. StartVirtualThread ()创建

1
2
3
4
5
6
7
8
9
10
11
12
13
Public class VirtualThreadTest { 
Public static void main (String[] args) {
CustomThread customThread = new CustomThread ();
Thread.StartVirtualThread (customThread);
}
}

Static class CustomThread implements Runnable {
@Override
Public void run () {
System.Out.Println ("CustomThread run");
}
}

使用 Thread. OfVirtual ()创建

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Public class VirtualThreadTest {  
Public static void main (String[] args) {
CustomThread customThread = new CustomThread ();
// 创建不启动
Thread unStarted = Thread.OfVirtual (). Unstarted (customThread);
UnStarted.Start ();
// 创建直接启动
Thread.OfVirtual (). Start (customThread);
}
}
Static class CustomThread implements Runnable {
@Override
Public void run () {
System.Out.Println ("CustomThread run");
}
}

使用 ThreadFactory 创建

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Public class VirtualThreadTest { 
Public static void main (String[] args) {
CustomThread customThread = new CustomThread ();
ThreadFactory factory = Thread.OfVirtual (). Factory ();
Thread thread = factory.NewThread (customThread);
Thread.Start ();
}
}

Static class CustomThread implements Runnable {
@Override
Public void run () {
System.Out.Println ("CustomThread run");
}
}

使用 Executors. NewVirtualThreadPerTaskExecutor ()创建

1
2
3
4
5
6
7
8
9
10
11
12
13
Public class VirtualThreadTest {
Public static void main (String[] args) {
CustomThread customThread = new CustomThread ();
ExecutorService executor = Executors.NewVirtualThreadPerTaskExecutor ();
Executor.Submit (customThread);
}
}
Static class CustomThread implements Runnable {
@Override
Public void run () {
System.Out.Println ("CustomThread run");
}
}

虚拟线程和平台线程性能对比

通过多线程和虚拟线程的方式处理相同的任务,对比创建的系统线程数和处理耗时。

说明:统计创建的系统线程中部分为后台线程(比如 GC 线程),两种场景下都一样,所以并不影响对比。

测试代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
Public class VirtualThreadTest {
static List<Integer> list = new ArrayList<>();
Public static void main (String[] args) {
// 开启线程统计平台线程数
ScheduledExecutorService scheduledExecutorService = Executors.NewScheduledThreadPool (1);
ScheduledExecutorService.ScheduleAtFixedRate (() -> {
ThreadMXBean threadBean = ManagementFactory.GetThreadMXBean ();
ThreadInfo[] threadInfo = threadBean.DumpAllThreads (false, false);
UpdateMaxThreadNum (threadInfo. Length);
}, 10, 10, TimeUnit. MILLISECONDS);

Long start = System.CurrentTimeMillis ();
// 虚拟线程
ExecutorService executor = Executors.NewVirtualThreadPerTaskExecutor ();
// 使用平台线程
// ExecutorService executor = Executors.NewFixedThreadPool (200);
For (int i = 0; i < 10000; i++) {
Executor.Submit (() -> {
Try {
// 线程睡眠 0.5 s,模拟业务处理
TimeUnit.MILLISECONDS.Sleep (500);
} catch (InterruptedException ignored) {
}
});
}
Executor.Close ();
System.Out.Println ("max:" + list.Get (0) + " platform thread/os thread");
System.Out.Printf ("totalMillis:%dms\n", System.CurrentTimeMillis () - start);


}
// 更新创建的平台最大线程数
Private static void updateMaxThreadNum (int num) {
If (list.IsEmpty ()) {
List.Add (num);
} else {
Integer integer = list.Get (0);
If (num > integer) {
List.Add (0, num);
}
}
}
}

请求数 10000 单请求耗时 1 s

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Virtual Thread
Max:22 platform thread/os thread
TotalMillis:1806 ms

// Platform Thread 线程数 200
Max:209 platform thread/os thread
TotalMillis:50578 ms

// Platform Thread 线程数 500
Max:509 platform thread/os thread
TotalMillis:20254 ms

// Platform Thread 线程数 1000
Max:1009 platform thread/os thread
TotalMillis:10214 ms

// Platform Thread 线程数 2000
Max:2009 platform thread/os thread
TotalMillis:5358 ms

请求数 10000 单请求耗时 0.5 s

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Virtual Thread
Max:22 platform thread/os thread
TotalMillis:1316 ms

// Platform Thread 线程数 200
Max:209 platform thread/os thread
TotalMillis:25619 ms

// Platform Thread 线程数 500
Max:509 platform thread/os thread
TotalMillis:10277 ms

// Platform Thread 线程数 1000
Max:1009 platform thread/os thread
TotalMillis:5197 ms

// Platform Thread 线程数 2000
Max:2009 platform thread/os thread
TotalMillis:2865 ms
  • 可以看到在密集 IO 的场景下,需要创建大量的平台线程异步处理才能达到虚拟线程的处理速度。
  • 因此,在密集 IO 的场景,虚拟线程可以大幅提高线程的执行效率,减少线程资源的创建以及上下文切换。
  • 吐槽:虽然虚拟线程我很想用,但是我 Java 8 有机会升级到 Java 21 吗?呜呜

注意:有段时间 JDK 一直致力于 Reactor 响应式编程来提高 Java 性能,但响应式编程难以理解、调试、使用,最终又回到了同步编程,最终虚拟线程诞生。

Java 内存模型

CPU 缓存模型

为什么要弄一个 CPU 高速缓存呢? 类比我们开发网站后台系统使用的缓存(比如 Redis)是为了解决程序处理速度和访问常规关系型数据库速度不对等的问题。 CPU 缓存则是为了解决 CPU 处理速度和内存处理速度不对等的问题。

我们甚至可以把 内存看作外存的高速缓存,程序运行的时候我们把外存的数据复制到内存,由于内存的处理速度远远高于外存,这样提高了处理速度。

总结:CPU Cache 缓存的是内存数据用于解决 CPU 处理速度和内存不匹配的问题,内存缓存的是硬盘数据用于解决硬盘访问速度过慢的问题。

为了更好地理解,我画了一个简单的 CPU Cache 示意图如下所示。

CPU 缓存模型示意图

现代的 CPU Cache 通常分为三层,分别叫 L 1, L 2, L 3 Cache。有些 CPU 可能还有 L 4 Cache,这里不做讨论,并不常见

CPU Cache 的工作方式: 先复制一份数据到 CPU Cache 中,当 CPU 需要用到的时候就可以直接从 CPU Cache 中读取数据,当运算完成后,再将运算得到的数据写回 Main Memory 中。但是,这样存在 内存缓存不一致性的问题 !比如我执行一个 i++ 操作的话,如果两个线程同时执行的话,假设两个线程从 CPU Cache 中读取的 i=1,两个线程做了 i++ 运算完之后再写回 Main Memory 之后 i=2,而正确结果应该是 i=3。

CPU 为了解决内存缓存不一致性问题可以通过制定缓存一致协议(比如 MESI 协议)或者其他手段来解决。 这个缓存一致性协议指的是在 CPU 高速缓存与主内存交互的时候需要遵守的原则和规范。不同的 CPU 中,使用的缓存一致性协议通常也会有所不同。

缓存一致性协议

我们的程序运行在操作系统之上,操作系统屏蔽了底层硬件的操作细节,将各种硬件资源虚拟化。于是,操作系统也就同样需要解决内存缓存不一致性问题。

操作系统通过 内存模型(Memory Model) 定义一系列规范来解决这个问题。无论是 Windows 系统,还是 Linux 系统,它们都有特定的内存模型。

指令重排序

说完了 CPU 缓存模型,我们再来看看另外一个比较重要的概念 指令重排序

为了提升执行速度/性能,计算机在执行程序代码的时候,会对指令进行重排序。

什么是指令重排序? 简单来说就是系统在执行代码的时候并不一定是按照你写的代码的顺序依次执行。

常见的指令重排序有下面 2 种情况:

  • 编译器优化重排:编译器(包括 JVM、JIT 编译器等)在不改变单线程程序语义的前提下,重新安排语句的执行顺序。
  • 指令并行重排:现代处理器采用了指令级并行技术 (Instruction-Level Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。

另外,内存系统也会有“重排序”,但又不是真正意义上的重排序。在 JMM 里表现为主存和本地内存的内容可能不一致,进而导致程序在多线程下执行可能出现问题。

Java 源代码会经历 编译器优化重排 —> 指令并行重排 —> 内存系统重排 的过程,最终才变成操作系统可执行的指令序列。

指令重排序可以保证串行语义一致,但是没有义务保证多线程间的语义也一致 ,所以在多线程下,指令重排序可能会导致一些问题。

编译器和处理器的指令重排序的处理方式不一样。对于编译器,通过禁止特定类型的编译器重排序的方式来禁止重排序。对于处理器,通过插入内存屏障(Memory Barrier,或有时叫做内存栅栏,Memory Fence)的方式来禁止特定类型的处理器重排序。指令并行重排和内存系统重排都属于是处理器级别的指令重排序。

内存屏障(Memory Barrier,或有时叫做内存栅栏,Memory Fence)是一种 CPU 指令,用来禁止处理器指令发生重排序(像屏障一样),从而保障指令执行的有序性。另外,为了达到屏障的效果,它也会使处理器写入、读取值之前,将主内存的值写入高速缓存,清空无效队列,从而保障变量的可见性。

JMM (Java Memory Model)

什么是 JMM?为什么需要 JMM?

Java 是最早尝试提供内存模型的编程语言。由于早期内存模型存在一些缺陷(比如非常容易削弱编译器的优化能力),从 Java 5 开始,Java 开始使用新的内存模型 《JSR-133:Java Memory Model and Thread Specification》

一般来说,编程语言也可以直接复用操作系统层面的内存模型。不过,不同的操作系统内存模型不同。如果直接复用操作系统层面的内存模型,就可能会导致同样一套代码换了一个操作系统就无法执行了。Java 语言是跨平台的,它需要自己提供一套内存模型以屏蔽系统差异。

这只是 JMM 存在的其中一个原因。实际上,对于 Java 来说,你可以把 JMM 看作是 Java 定义的并发编程相关的一组规范,除了抽象了线程和主内存之间的关系之外,其还规定了从 Java 源代码到 CPU 可执行指令的这个转化过程要遵守哪些和并发相关的原则和规范,其主要目的是为了简化多线程编程,增强程序可移植性的。

为什么要遵守这些并发相关的原则和规范呢? 这是因为并发编程下,像 CPU 多级缓存和指令重排这类设计可能会导致程序运行出现一些问题。就比如说我们上面提到的指令重排序就可能会让多线程程序的执行出现问题,为此,JMM 抽象了 happens-before 原则(后文会详细介绍到)来解决这个指令重排序问题。

JMM 说白了就是定义了一些规范来解决这些问题,开发者可以利用这些规范更方便地开发多线程程序。对于 Java 开发者说,你不需要了解底层原理,直接使用并发相关的一些关键字和类(比如 volatilesynchronized、各种 Lock)即可开发出并发安全的程序。

JMM 是如何抽象线程和主内存之间的关系?

Java 内存模型(JMM) 抽象了线程和主内存之间的关系,就比如说线程之间的共享变量必须存储在主内存中。

在 JDK 1.2 之前,Java 的内存模型实现总是从 主存 (即共享内存)读取变量,是不需要进行特别的注意的。而在当前的 Java 内存模型下,线程可以把变量保存 本地内存 (比如机器的寄存器)中,而不是直接在主存中进行读写。这就可能造成一个线程在主存中修改了一个变量的值,而另外一个线程还继续使用它在寄存器中的变量值的拷贝,造成数据的不一致。

这和我们上面讲到的 CPU 缓存模型非常相似。

什么是主内存?什么是本地内存?

  • 主内存:所有线程创建的实例对象都存放在主内存中,不管该实例对象是成员变量,还是局部变量,类信息、常量、静态变量都是放在主内存中。为了获取更好的运行速度,虚拟机及硬件系统可能会让工作内存优先存储于寄存器和高速缓存中。
  • 本地内存:每个线程都有一个私有的本地内存,本地内存存储了该线程以读 / 写共享变量的副本。每个线程只能操作自己本地内存中的变量,无法直接访问其他线程的本地内存。如果线程间需要通信,必须通过主内存来进行。本地内存是 JMM 抽象出来的一个概念,并不真实存在,它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。

Java 内存模型的抽象示意图如下:

JMM(Java 内存模型)

从上图来看,线程 1 与线程 2 之间如果要进行通信的话,必须要经历下面 2 个步骤:

  1. 线程 1 把本地内存中修改过的共享变量副本的值同步到主内存中去。
  2. 线程 2 到主存中读取对应的共享变量的值。

也就是说,JMM 为共享变量提供了可见性的保障。

不过,多线程下,对主内存中的一个共享变量进行操作有可能诱发线程安全问题。举个例子:

  1. 线程 1 和线程 2 分别对同一个共享变量进行操作,一个执行修改,一个执行读取。
  2. 线程 2 读取到的是线程 1 修改之前的值还是修改后的值并不确定,都有可能,因为线程 1 和线程 2 都是先将共享变量从主内存拷贝到对应线程的工作内存中。

关于主内存与工作内存直接的具体交互协议,即一个变量如何从主内存拷贝到工作内存,如何从工作内存同步到主内存之间的实现细节,Java 内存模型定义来以下八种同步操作(了解即可,无需死记硬背):

  • 锁定(lock): 作用于主内存中的变量,将他标记为一个线程独享变量。
  • 解锁(unlock): 作用于主内存中的变量,解除变量的锁定状态,被解除锁定状态的变量才能被其他线程锁定。
  • read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的 load 动作使用。
  • **load (载入)**:把 read 操作从主内存中得到的变量值放入工作内存的变量的副本中。
  • **use (使用)**:把工作内存中的一个变量的值传给执行引擎,每当虚拟机遇到一个使用到变量的指令时都会使用该指令。
  • assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
  • store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的 write 操作使用。
  • write(写入):作用于主内存的变量,它把 store 操作从工作内存中得到的变量的值放入主内存的变量中。

除了这 8 种同步操作之外,还规定了下面这些同步规则来保证这些同步操作的正确执行(了解即可,无需死记硬背):

  • 不允许一个线程无原因地(没有发生过任何 assign 操作)把数据从线程的工作内存同步回主内存中。
  • 一个新的变量只能在主内存中 “诞生”,不允许在工作内存中直接使用一个未被初始化(load 或 assign)的变量,换句话说就是对一个变量实施 use 和 store 操作之前,必须先执行过了 assign 和 load 操作。
  • 一个变量在同一个时刻只允许一条线程对其进行 lock 操作,但 lock 操作可以被同一条线程重复执行多次,多次执行 lock 后,只有执行相同次数的 unlock 操作,变量才会被解锁。
  • 如果对一个变量执行 lock 操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行 load 或 assign 操作初始化变量的值。
  • 如果一个变量事先没有被 lock 操作锁定,则不允许对它执行 unlock 操作,也不允许去 unlock 一个被其他线程锁定住的变量。
  • ……

Java 内存区域和 JMM 有何区别?

这是一个比较常见的问题,很多初学者非常容易搞混。 Java 内存区域和内存模型是完全不一样的两个东西

  • JVM 内存结构和 Java 虚拟机的运行时区域相关,定义了 JVM 在运行时如何分区存储程序数据,就比如说堆主要用于存放对象实例。
  • Java 内存模型和 Java 的并发编程相关,抽象了线程和主内存之间的关系就比如说线程之间的共享变量必须存储在主内存中,规定了从 Java 源代码到 CPU 可执行指令的这个转化过程要遵守哪些和并发相关的原则和规范,其主要目的是为了简化多线程编程,增强程序可移植性的。

Happens-before 原则是什么?

happens-before 这个概念最早诞生于 Leslie Lamport 于 1978 年发表的论文 《Time,Clocks and the Ordering of Events in a Distributed System》。在这篇论文中,Leslie Lamport 提出了逻辑时钟 的概念,这也成了第一个逻辑时钟算法。在分布式环境中,通过一系列规则来定义逻辑时钟的变化,从而能通过逻辑时钟来对分布式系统中的事件的先后顺序进行判断。逻辑时钟并不度量时间本身,仅区分事件发生的前后顺序,其本质就是定义了一种 happens-before 关系。

上面提到的 happens-before 这个概念诞生的背景并不是重点,简单了解即可。

JSR 133 引入了 happens-before 这个概念来描述两个操作之间的内存可见性。

为什么需要 happens-before 原则? happens-before 原则的诞生是为了程序员和编译器、处理器之间的平衡。程序员追求的是易于理解和编程的强内存模型,遵守既定规则编码即可。编译器和处理器追求的是较少约束的弱内存模型,让它们尽己所能地去优化性能,让性能最大化。Happens-before 原则的设计思想其实非常简单:

  • 为了对编译器和处理器的约束尽可能少,只要不改变程序的执行结果(单线程程序和正确执行的多线程程序),编译器和处理器怎么进行重排序优化都行。
  • 对于会改变程序执行结果的重排序,JMM 要求编译器和处理器必须禁止这种重排序。

下面这张是《Java 并发编程的艺术》这本书中的一张 JMM 设计思想的示意图,非常清晰。

了解了 happens-before 原则的设计思想,我们再来看看 JSR-133 对 happens-before 原则的定义:

  • 如果一个操作 happens-before 另一个操作,那么第一个操作的执行结果将对第二个操作可见,并且第一个操作的执行顺序排在第二个操作之前。
  • 两个操作之间存在 happens-before 关系,并不意味着 Java 平台的具体实现必须要按照 happens-before 关系指定的顺序来执行。如果重排序之后的执行结果,与按 happens-before 关系来执行的结果一致,那么 JMM 也允许这样的重排序。

我们看下面这段代码:

1
2
3
Int userNum = getUserNum (); // 1
Int teacherNum = getTeacherNum (); // 2
Int totalNum = userNum + teacherNum;// 3
  • 1 happens-before 2
  • 2 happens-before 3
  • 1 happens-before 3

虽然 1 happens-before 2,但对 1 和 2 进行重排序不会影响代码的执行结果,所以 JMM 是允许编译器和处理器执行这种重排序的。但 1 和 2 必须是在 3 执行之前,也就是说 1,2 happens-before 3 。

happens-before 原则表达的意义其实并不是一个操作发生在另外一个操作的前面,虽然这从程序员的角度上来说也并无大碍。更准确地来说,它更想表达的意义是前一个操作的结果对于后一个操作是可见的,无论这两个操作是否在同一个线程里。

举个例子:操作 1 happens-before 操作 2,即使操作 1 和操作 2 不在同一个线程内,JMM 也会保证操作 1 的结果对操作 2 是可见的。

Happens-before 常见规则有哪些?谈谈你的理解?

Happens-before 的规则就 8 条,说多不多,重点了解下面列举的 5 条即可。全记是不可能的,很快就忘记了,意义不大,随时查阅即可。

  1. 程序顺序规则:一个线程内,按照代码顺序,书写在前面的操作 happens-before 于书写在后面的操作;
  2. 解锁规则:解锁 happens-before 于加锁;
  3. volatile 变量规则:对一个 volatile 变量的写操作 happens-before 于后面对这个 volatile 变量的读操作。说白了就是对 volatile 变量的写操作的结果对于发生于其后的任何操作都是可见的。
  4. 传递规则:如果 A happens-before B,且 B happens-before C,那么 A happens-before C;
  5. 线程启动规则:Thread 对象的 start () 方法 happens-before 于此线程的每一个动作。

如果两个操作不满足上述任意一个 happens-before 规则,那么这两个操作就没有顺序的保障,JVM 可以对这两个操作进行重排序。

Happens-before 和 JMM 什么关系?

Happens-before 与 JMM 的关系用《Java 并发编程的艺术》这本书中的一张图就可以非常好的解释清楚。

happens-before 与 JMM 的关系

再看并发编程三个重要特性

原子性

一次操作或者多次操作,要么所有的操作全部都得到执行并且不会受到任何因素的干扰而中断,要么都不执行。

在 Java 中,可以借助 synchronized、各种 Lock 以及各种原子类实现原子性。

synchronized 和各种 Lock 可以保证任一时刻只有一个线程访问该代码块,因此可以保障原子性。各种原子类是利用 CAS (compare and swap) 操作(可能也会用到 volatile 或者 final 关键字)来保证原子操作。

可见性

当一个线程对共享变量进行了修改,那么另外的线程都是立即可以看到修改后的最新值。

在 Java 中,可以借助 synchronizedvolatile 以及各种 Lock 实现可见性。

如果我们将变量声明为 volatile ,这就指示 JVM,这个变量是共享且不稳定的,每次使用它都到主存中进行读取。

有序性

由于指令重排序问题,代码的执行顺序未必就是编写代码时候的顺序。

我们上面讲重排序的时候也提到过:

指令重排序可以保证串行语义一致,但是没有义务保证多线程间的语义也一致 ,所以在多线程下,指令重排序可能会导致一些问题。

在 Java 中,volatile 关键字可以禁止指令进行重排序优化。

总结

  • Java 是最早尝试提供内存模型的语言,其主要目的是为了简化多线程编程,增强程序可移植性的。
  • CPU 可以通过制定缓存一致协议(比如 MESI 协议)来解决内存缓存不一致性问题。
  • 为了提升执行速度/性能,计算机在执行程序代码的时候,会对指令进行重排序。简单来说就是系统在执行代码的时候并不一定是按照你写的代码的顺序依次执行。指令重排序可以保证串行语义一致,但是没有义务保证多线程间的语义也一致 ,所以在多线程下,指令重排序可能会导致一些问题。
  • 你可以把 JMM 看作是 Java 定义的并发编程相关的一组规范,除了抽象了线程和主内存之间的关系之外,其还规定了从 Java 源代码到 CPU 可执行指令的这个转化过程要遵守哪些和并发相关的原则和规范,其主要目的是为了简化多线程编程,增强程序可移植性的。
  • JSR 133 引入了 happens-before 这个概念来描述两个操作之间的内存可见性。

池化技术想必大家已经屡见不鲜了,线程池、数据库连接池、HTTP 连接池等等都是对这个思想的应用。池化技术的思想主要是为了减少每次获取资源的消耗,提高对资源的利用率。

这篇文章我会详细介绍一下线程池的基本概念以及核心原理。

常见并发容器总结

ConcurrentHashMap

我们知道 HashMap 不是线程安全的,在并发场景下如果要保证一种可行的方式是使用 Collections.SynchronizedMap () 方法来包装我们的 HashMap。但这是通过使用一个全局的锁来同步不同线程间的并发访问,因此会带来不可忽视的性能问题。

所以就有了 HashMap 的线程安全版本—— ConcurrentHashMap 的诞生。

在 JDK 1.7 的时候,ConcurrentHashMap 对整个桶数组进行了分割分段 (Segment,分段锁),每一把锁只锁容器其中一部分数据(下面有示意图),多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。

到了 JDK 1.8 的时候,ConcurrentHashMap 已经摒弃了 Segment 的概念,而是直接用 Node 数组+链表+红黑树的数据结构来实现,并发控制使用 synchronized 和 CAS 来操作。(JDK 1.6 以后 synchronized 锁做了很多优化) 整个看起来就像是优化过且线程安全的 HashMap,虽然在 JDK 1.8 中还能看到 Segment 的数据结构,但是已经简化了属性,只是为了兼容旧版本。

关于 ConcurrentHashMap 的详细介绍,请看我写的这篇文章:ConcurrentHashMap 源码分析

CopyOnWriteArrayList

在 JDK 1.5 之前,如果想要使用并发安全的 List 只能选择 Vector。而 Vector 是一种老旧的集合,已经被淘汰。Vector 对于增删改查等方法基本都加了 synchronized,这种方式虽然能够保证同步,但这相当于对整个 Vector 加上了一把大锁,使得每个方法执行的时候都要去获得锁,导致性能非常低下。

JDK 1.5 引入了 Java. Util. Concurrent(JUC)包,其中提供了很多线程安全且并发性能良好的容器,其中唯一的线程安全 List 实现就是 CopyOnWriteArrayList

对于大部分业务场景来说,读取操作往往是远大于写入操作的。由于读取操作不会对原有数据进行修改,因此,对于每次读取都进行加锁其实是一种资源浪费。相比之下,我们应该允许多个线程同时访问 List 的内部数据,毕竟对于读取操作来说是安全的。

这种思路与 ReentrantReadWriteLock 读写锁的设计思想非常类似,即读读不互斥、读写互斥、写写互斥(只有读读不互斥)。CopyOnWriteArrayList 更进一步地实现了这一思想。为了将读操作性能发挥到极致,CopyOnWriteArrayList 中的读取操作是完全无需加锁的。更加厉害的是,写入操作也不会阻塞读取操作,只有写写才会互斥。这样一来,读操作的性能就可以大幅度提升。

CopyOnWriteArrayList 线程安全的核心在于其采用了 写时复制(Copy-On-Write) 的策略,从 CopyOnWriteArrayList 的名字就能看出了。

当需要修改( addsetremove 等操作) CopyOnWriteArrayList 的内容时,不会直接修改原数组,而是会先创建底层数组的副本,对副本数组进行修改,修改完之后再将修改后的数组赋值回去,这样就可以保证写操作不会影响读操作了。

关于 CopyOnWriteArrayList 的详细介绍,请看我写的这篇文章:CopyOnWriteArrayList 源码分析

ConcurrentLinkedQueue

Java 提供的线程安全的 Queue 可以分为阻塞队列非阻塞队列,其中阻塞队列的典型例子是 BlockingQueue,非阻塞队列的典型例子是 ConcurrentLinkedQueue,在实际应用中要根据实际需要选用阻塞队列或者非阻塞队列。 阻塞队列可以通过加锁来实现,非阻塞队列可以通过 CAS 操作实现。

从名字可以看出,ConcurrentLinkedQueue 这个队列使用链表作为其数据结构. ConcurrentLinkedQueue 应该算是在高并发环境中性能最好的队列了。它之所有能有很好的性能,是因为其内部复杂的实现。

ConcurrentLinkedQueue 内部代码我们就不分析了,大家知道 ConcurrentLinkedQueue 主要使用 CAS 非阻塞算法来实现线程安全就好了。

ConcurrentLinkedQueue 适合在对性能要求相对较高,同时对队列的读写存在多个线程同时进行的场景,即如果对队列加锁的成本较高则适合使用无锁的 ConcurrentLinkedQueue 来替代。

BlockingQueue

BlockingQueue 简介

上面我们己经提到了 ConcurrentLinkedQueue 作为高性能的非阻塞队列。下面我们要讲到的是阻塞队列—— BlockingQueue。阻塞队列(BlockingQueue)被广泛使用在“生产者-消费者”问题中,其原因是 BlockingQueue 提供了可阻塞的插入和移除的方法。当队列容器已满,生产者线程会被阻塞,直到队列未满;当队列容器为空时,消费者线程会被阻塞,直至队列非空时为止。

BlockingQueue 是一个接口,继承自 Queue,所以其实现类也可以作为 Queue 的实现来使用,而 Queue 又继承自 Collection 接口。下面是 BlockingQueue 的相关实现类:

BlockingQueue 的实现类

下面主要介绍一下 3 个常见的 BlockingQueue 的实现类:ArrayBlockingQueueLinkedBlockingQueuePriorityBlockingQueue

ArrayBlockingQueue

ArrayBlockingQueueBlockingQueue 接口的有界队列实现类,底层采用数组来实现。

1
2
3
public class ArrayBlockingQueue<E>
extends AbstractQueue<E>
implements BlockingQueue<E>, Serializable{}

ArrayBlockingQueue 一旦创建,容量不能改变。其并发控制采用可重入锁 ReentrantLock ,不管是插入操作还是读取操作,都需要获取到锁才能进行操作。当队列容量满时,尝试将元素放入队列将导致操作阻塞; 尝试从一个空队列中取一个元素也会同样阻塞。

ArrayBlockingQueue 默认情况下不能保证线程访问队列的公平性,所谓公平性是指严格按照线程等待的绝对时间顺序,即最先等待的线程能够最先访问到 ArrayBlockingQueue。而非公平性则是指访问 ArrayBlockingQueue 的顺序不是遵守严格的时间顺序,有可能存在,当 ArrayBlockingQueue 可以被访问时,长时间阻塞的线程依然无法访问到 ArrayBlockingQueue。如果保证公平性,通常会降低吞吐量。如果需要获得公平性的 ArrayBlockingQueue,可采用如下代码:

1
private static ArrayBlockingQueue<Integer> blockingQueue = new ArrayBlockingQueue<Integer>(10, true);

LinkedBlockingQueue

LinkedBlockingQueue 底层基于单向链表实现的阻塞队列,可以当做无界队列也可以当做有界队列来使用,同样满足 FIFO 的特性,与 ArrayBlockingQueue 相比起来具有更高的吞吐量,为了防止 LinkedBlockingQueue 容量迅速增,损耗大量内存。通常在创建 LinkedBlockingQueue 对象时,会指定其大小,如果未指定,容量等于 Integer. MAX_VALUE

相关构造方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
*某种意义上的无界队列
* Creates a {@code LinkedBlockingQueue} with a capacity of
* {@link Integer #MAX_VALUE }.
*/
Public LinkedBlockingQueue () {
This (Integer. MAX_VALUE);
}

/**
*有界队列
* Creates a {@code LinkedBlockingQueue} with the given (fixed) capacity.
*
* @param capacity the capacity of this queue
* @throws IllegalArgumentException if {@code capacity} is not greater
* than zero
*/
Public LinkedBlockingQueue (int capacity) {
If (capacity <= 0) throw new IllegalArgumentException ();
This. Capacity = capacity;
last = head = new Node<E>(null);
}

PriorityBlockingQueue

PriorityBlockingQueue 是一个支持优先级的无界阻塞队列。默认情况下元素采用自然顺序进行排序,也可以通过自定义类实现 compareTo () 方法来指定元素排序规则,或者初始化时通过构造器参数 Comparator 来指定排序规则。

PriorityBlockingQueue 并发控制采用的是可重入锁 ReentrantLock,队列为无界队列(ArrayBlockingQueue 是有界队列,LinkedBlockingQueue 也可以通过在构造函数中传入 capacity 指定队列最大的容量,但是 PriorityBlockingQueue 只能指定初始的队列大小,后面插入元素的时候,如果空间不够的话会自动扩容)。

简单地说,它就是 PriorityQueue 的线程安全版本。不可以插入 null 值,同时,插入队列的对象必须是可比较大小的(comparable),否则报 ClassCastException 异常。它的插入操作 put 方法不会 block,因为它是无界队列(take 方法在队列为空的时候会阻塞)。

推荐文章: 《解读 Java 并发队列 BlockingQueue》

ConcurrentSkipListMap

下面这部分内容参考了极客时间专栏《数据结构与算法之美》以及《实战 Java 高并发程序设计》。

为了引出 ConcurrentSkipListMap,先带着大家简单理解一下跳表。

对于一个单链表,即使链表是有序的,如果我们想要在其中查找某个数据,也只能从头到尾遍历链表,这样效率自然就会很低,跳表就不一样了。跳表是一种可以用来快速查找的数据结构,有点类似于平衡树。它们都可以对元素进行快速的查找。但一个重要的区别是:对平衡树的插入和删除往往很可能导致平衡树进行一次全局的调整。而对跳表的插入和删除只需要对整个数据结构的局部进行操作即可。这样带来的好处是:在高并发的情况下,你会需要一个全局锁来保证整个平衡树的线程安全。而对于跳表,你只需要部分锁即可。这样,在高并发环境下,你就可以拥有更好的性能。而就查询的性能而言,跳表的时间复杂度也是 O (logn) 所以在并发数据结构中,JDK 使用跳表来实现一个 Map。

跳表的本质是同时维护了多个链表,并且链表是分层的,

2级索引跳表

最低层的链表维护了跳表内所有的元素,每上面一层链表都是下面一层的子集。

跳表内的所有链表的元素都是排序的。查找时,可以从顶级链表开始找。一旦发现被查找的元素大于当前链表中的取值,就会转入下一层链表继续找。这也就是说在查找过程中,搜索是跳跃式的。如上图所示,在跳表中查找元素 18。

在跳表中查找元素18

查找 18 的时候原来需要遍历 18 次,现在只需要 7 次即可。针对链表长度比较大的时候,构建索引查找效率的提升就会非常明显。

从上面很容易看出,跳表是一种利用空间换时间的算法。

使用跳表实现 Map 和使用哈希算法实现 Map 的另外一个不同之处是:哈希并不会保存元素的顺序,而跳表内所有的元素都是排序的。因此在对跳表进行遍历时,你会得到一个有序的结果。所以,如果你的应用需要有序性,那么跳表就是你不二的选择。JDK 中实现这一数据结构的类是 ConcurrentSkipListMap

一个接口可能需要调用 N 个其他服务的接口,这在项目开发中还是挺常见的。举个例子:用户请求获取订单信息,可能需要调用用户信息、商品详情、物流信息、商品推荐等接口,最后再汇总数据统一返回。

如果是串行(按顺序依次执行每个任务)执行的话,接口的响应速度会非常慢。考虑到这些接口之间有大部分都是 无前后顺序关联 的,可以 并行执行 ,就比如说调用获取商品详情的时候,可以同时调用获取物流信息。通过并行执行多个任务的方式,接口的响应速度会得到大幅优化。

serial-to-parallel

Serial-to-parallel

对于存在前后顺序关系的接口调用,可以进行编排,如下图所示。

  1. 获取用户信息之后,才能调用商品详情和物流信息接口。
  2. 成功获取商品详情和物流信息之后,才能调用商品推荐接口。

对于 Java 程序来说,Java 8 才被引入的 CompletableFuture 可以帮助我们来做多个任务的编排,功能非常强大。

这篇文章是 CompletableFuture 的简单入门,带大家看看 CompletableFuture 常用的 API。


Java 并发 常见面试问题
https://hexo.leelurker.com/posts/47286
作者
LeeLurker
发布于
2024年1月3日
许可协议