并发
1. 线程
1. 线程vs进程
进程是程序的一次执行过程,是系统运行程序的基本单位,因此进程是动态的。 系统运行一个程序即是一个进程从创建,运行到消亡的过程。在 Java 中,当我们启动 main 函数时其实就是启动了一个 JVM 的进程,而 main 函数所在的线程就是这个进程中的一个线程,也称主线程。
线程是一个比进程更小的执行单位。 一个进程在其执行的过程中可以产生多个线程。与进程不同的是同类的多个线程共享进程的堆和方法区资源,但每个线程有自己的程序计数器、虚拟机栈和本地方法栈,所以系统在产生一个线程,或是在各个线程之间做切换工作时,负担要比进程小得多,也正因为如此,线程也被称为轻量级进程。
比较项目 | 进程 | 线程 |
---|---|---|
定义 | 程序的一次执行过程,是系统运行程序的基本单位,动态的。 | 比进程更小的执行单位,多个线程共享进程的资源。 |
系统中的作用 | 系统运行一个程序即是一个进程从创建、运行到消亡的过程。 | 一个进程在执行过程中可以产生多个线程。 |
资源共享 | 各进程独立,不共享内存资源。 | 线程共享进程的堆和方法区资源,但有自己的程序计数器、虚拟机栈和本地方法栈。 |
创建和切换负担 | 系统创建和切换进程的负担较大。 | 系统创建和切换线程的负担较小,因此线程被称为轻量级进程。 |
Java 中的体现 | 启动 main 函数时启动 JVM 进程,main 函数所在的线程为主线程。 | 线程在进程内产生,主线程和其他线程共享进程资源。 |
Java 程序天生就是多线程程序,一个 Java 程序的运行是 main 线程和多个其他线程同时运行。
总结:线程是进程划分成的更小的运行单位。线程和进程最大的不同在于基本上各进程是独立的,而各线程则不一定,因为同一进程中的线程极有可能会相互影响。线程执行开销小,但不利于资源的管理和保护;而进程正相反。
一个进程中可以有多个线程。 多个线程共享进程的堆和方法区 (元空间)。 但是每个线程有自己的程序计数器、虚拟机栈 和 本地方法栈。
堆和方法区(共享):
-
堆是进程中最大的一块内存,主要用于存放新创建的对象 (几乎所有对象都在这里分配内存),
-
方法区主要用于存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
程序计数器(私有):
为了线程切换后能恢复到正确的执行位置。
-
字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
-
在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。
虚拟机栈和本地方法栈(私有):
为了保证线程中的局部变量不被别的线程访问到。
-
虚拟机栈: 每个 Java 方法在执行之前会创建一个栈帧用于存储局部变量表、操作数栈、常量池引用等信息。从方法调用直至执行完成的过程,就对应着一个栈帧在 Java 虚拟机栈中入栈和出栈的过程。
-
本地方法栈: 和虚拟机栈所发挥的作用非常相似,区别是:虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。
2. Java线程 vs OS线程
-
用户线程:由用户空间程序管理和调度的线程,运行在用户空间(专门给应用程序使用)。
-
内核线程:由操作系统内核管理和调度的线程,运行在内核空间(只有内核程序可以访问)。
用户线程创建和切换成本低,但不可以利用多核。内核态线程,创建和切换成本高,可以利用多核。
现在的 Java 线程的本质其实就是操作系统的线程。
3. 创建线程
使用多线程的方法:继承Thread
类、实现Runnable
接口、实现Callable
接口、使用线程池、使用CompletableFuture
类等等。
真正的:new Thread().start()
4. 线程的生命周期和状态
-
NEW: 初始状态,线程被创建出来但没有被调用
start()
。 -
RUNNABLE: 运行状态,线程被调用了
start()
等待运行的状态。 -
BLOCKED:阻塞状态,需要等待锁释放。
-
WAITING:等待状态,表示该线程需要等待其他线程做出一些特定动作(通知或中断)。
-
TIMED_WAITING:超时等待状态,可以在指定的时间后自行返回而不是像 WAITING 那样一直等待。
-
TERMINATED:终止状态,表示该线程已经运行完毕。
-
当线程执行
wait()
方法之后,线程进入 WAITING(等待) 状态。进入等待状态的线程需要依靠其他线程的通知才能够返回到运行状态。 -
TIMED_WAITING(超时等待) 状态相当于在等待状态的基础上增加了超时限制,比如通过
sleep(long millis)
方法或wait(long millis)
方法可以将线程置于 TIMED_WAITING 状态。当超时时间结束后,线程将会返回到 RUNNABLE 状态。 -
当线程进入
synchronized
方法/块或者调用wait
后(被notify
)重新进入synchronized
方法/块,但是锁被其它线程占有,这个时候线程就会进入 BLOCKED(阻塞) 状态。 -
线程在执行完了
run()
方法之后将会进入到 TERMINATED(终止) 状态。
随着代码的执行在不同状态之间切换。
RUNNING vs READY:
线程创建之后它将处于 NEW(新建) 状态,调用 start()
方法后开始运行,线程这时候处于 READY(可运行) 状态。可运行状态的线程获得了 CPU 时间片(timeslice)后就处于 RUNNING(运行) 状态。
5. 线程上下文切换
多线程编程中一般线程的个数都大于 CPU 核心的个数,而一个 CPU 核心在任意时刻只能被一个线程使用,为了让这些线程都能得到有效执行,CPU 采取的策略是为每个线程分配时间片并轮转的形式。
线程在执行过程中会有自己的运行条件和状态(也称上下文),比如上文所说到过的程序计数器,栈信息等。当出现如下情况的时候,线程会从占用 CPU 状态中退出。
-
主动让出 CPU,比如调用了
sleep()
,wait()
等。 -
时间片用完,因为操作系统要防止一个线程或者进程长时间占用 CPU 导致其他线程或者进程饿死。
-
调用了阻塞类型的系统中断,比如请求 IO,线程被阻塞。
-
(不会切换)被终止或结束运行
这其中前三种都会发生线程切换,线程切换意味着需要保存当前线程的上下文,留待线程下次占用 CPU 的时候恢复现场。并加载下一个将要占用 CPU 的线程上下文。这就是所谓的 上下文切换。
上下文切换是现代操作系统的基本功能,因其每次需要保存信息恢复信息,这将会占用 CPU,内存等系统资源进行处理,也就意味着效率会有一定损耗,如果频繁切换就会造成整体效率低下。
6. Thread#sleep()
vs Object#wait()
比较项目 | sleep() 方法 | wait() 方法 |
---|---|---|
锁的释放 | 没有释放锁 | 释放了锁 |
用途 | 通常用于暂停执行 | 通常用于线程间交互/通信 |
苏醒方式 | 执行完成后自动苏醒 | 需要其他线程调用同一个对象上的 notify() 或 notifyAll() 方法 |
超时自动苏醒 | 是 | 是(使用 wait(long timeout)) |
所属类 | Thread 类的静态本地方法 | Object 类的本地方法 |
wait()
让获得对象锁的线程实现等待,会自动释放当前线程占有的对象锁,每个对象(Object
)都拥有对象锁。
sleep()
是让当前线程暂停执行,不涉及到对象类
7. 可以直接调用Thread类的run方法吗?
new 一个 Thread
,线程进入了新建状态。调用 start()
方法,会启动一个线程并使线程进入了就绪状态,当分配到时间片后就可以开始运行了。
start()
会执行线程的相应准备工作,然后自动执行 run()
方法的内容,这是真正的多线程工作。
直接执行 run()
方法,会把 run()
方法当成一个 main 线程下的普通方法去执行,并不会在某个线程中执行它,所以这并不是多线程工作。
2. 多线程
1. 并发vs并行
-
并发:两个及两个以上的作业在 时间段,交替,单核CPU。
-
并行:两个及两个以上的作业在 时刻,多核CPU 。
最关键的点是:是否是 同时 执行。
2. 同步vs异步
-
同步:发出一个调用之后,在没有得到结果之前, 该调用就不可以返回,一直等待。
-
异步:调用在发出之后,不用等待返回结果,该调用直接返回。
是否需要等待方法执行的结果。
3. Why?
算机底层: 线程可以比作是轻量级的进程,是程序执行的最小单位,线程间的切换和调度的成本远远小于进程。另外,多核 CPU 时代意味着多个线程可以同时运行,这减少了线程上下文切换的开销。
当代互联网发展趋势: 现在的系统动不动就要求百万级甚至千万级的并发量,而多线程并发编程正是开发高并发系统的基础,利用好多线程机制可以大大提高系统整体的并发能力以及性能。
单核时代:多线程通过让一个线程在IO阻塞时,其他线程继续使用CPU,从而提高了单进程对CPU和IO系统的整体利用效率。
多核时代:多线程通过让多个线程并行执行在多个CPU核心上,从而显著提高了任务的执行效率。(单核时执行时间/CPU 核心数)
4. Problem?
并发编程是为了能提高程序的执行效率进而提高程序的运行速度。内存泄漏、死锁、线程不安全等等。
内存泄漏是指程序未能释放不再使用的内存,导致内存资源逐渐减少的问题。
死锁是指两个或多个线程互相等待对方释放资源,从而导致所有线程都无法继续执行的情况。
5. 什么是线程安全和不安全
在多线程环境下对于同一份数据的访问是否能够保证其正确性和一致性的描述。
6. 单核CPU上运行多个线程效率一定会更高吗?
取决于线程类型和任务性质。
CPU 密集型和 IO 密集型。 CPU 密集型的线程主要进行计算和逻辑处理,需要占用大量的 CPU 资源。 IO 密集型的线程主要进行输入输出操作,如读写文件、网络通信等,需要等待 IO 设备的响应,而不占用太多的 CPU 资源。
任务是 CPU 密集型的,那么开很多线程会影响效率(增加了系统的开销);如果任务是 IO 密集型的,那么开很多线程会提高效率(利用 CPU 在等待 IO 时的空闲时间)。
3. 死锁
1. What
多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止。
线程 A 持有资源 2,线程 B 持有资源 1,他们同时都想申请对方的资源,所以这两个线程就会互相等待而进入死锁状态。
-
互斥条件:该资源任意一个时刻只由一个线程占用。
-
请求与保持条件:一个线程因请求资源而阻塞时,对已获得的资源保持不放。
-
不剥夺条件:线程已获得的资源在未使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源。
-
循环等待条件:若干线程之间形成一种头尾相接的循环等待资源关系。
2. 预防避免
预防:破坏死锁的产生的必要条件
-
破坏请求与保持条件:一次性申请所有的资源。
-
破坏不剥夺条件:占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。
-
破坏循环等待条件:靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。破坏循环等待条件。
互斥不成立则,死锁必然不发生。(spooling假脱机技术:外围设备联机并行操作,使独占的设备变成可共享的设备)
避免:在资源分配时,借助于算法(比如银行家算法)对资源分配进行计算评估,使其进入安全状态。
系统能够按照某种线程推进顺序(P1、P2、P3……Pn)来为每个线程分配所需资源,直到满足每个线程对资源的最大需求,使每个线程都可顺利完成。称 <P1、P2、P3.....Pn>
序列为安全序列。
4. JMM(Java 内存模型)
对于一个共享变量,当另一个线程对这个共享变量执行写操作后,这个线程对这个共享变量的可见性。
1. CPU缓存模型
CPU 缓存则是为了解决 CPU 和内存处理速度不对等的问题。
为了解决内存缓存不一致性问题可以通过制定缓存一致协议。
2. 指令重排序
为了提升执行速度/性能,计算机在执行程序代码的时候,会对指令进行重排序,在执行代码的时候并不一定是按照你写的代码的顺序依次执行。
-
编译器优化重排:编译器(包括 JVM、JIT 编译器等)在不改变单线程程序语义的前提下,重新安排语句的执行顺序。
-
指令并行重排:现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
Java 源代码会经历 编译器优化重排 —> 指令并行重排 —> 内存系统重排 的过程,最终才变成操作系统可执行的指令序列。
可以保证串行语义一致,但是没有义务保证多线程间的语义也一致。
3. JMM
描述了 线程和主内存之间的关系,为共享变量提供了可见性的保障。
线程 1 与线程 2 之间如果要进行通信:
-
线程 1 把本地内存中修改过的共享变量副本的值同步到主内存中去。
-
线程 2 到主存中读取对应的共享变量的值。
4. 并发编程三大特性
性质 | 描述 | 实现方式 |
---|---|---|
原子性 | 一次操作或者多次操作,要么所有的操作全部都得到执行并且不会受到任何因素的干扰而中断,要么都不执行。 | synchronized 、各种 Lock 以及各种原子类。 synchronized 和各种 Lock 可以保证任一时刻只有一个线程访问该代码块。 各种原子类是利用 CAS (compare and swap) 操作(可能也会用到 volatile 或者final 关键字)来保证原子操作。 |
可见性 | 当一个线程对共享变量进行了修改,那么另外的线程都是立即可以看到修改后的最新值。 | synchronized 、volatile 以及各种 Lock 。将变量声明为 volatile ,指示 JVM 这个变量是共享且不稳定的,每次使用它都到主存中进行读取。 |
有序性 | 由于指令重排序问题,代码的执行顺序未必就是编写代码时候的顺序。 | volatile 关键字可以禁止指令进行重排序优化。 |
5. volatile 关键字
1. 保证变量的可见性
修饰变量后,表示这个变量是共享且不稳定的,每次使用它都到主存中进行读取。
保证数据的可见性,但不能保证数据的原子性。synchronized
关键字两者都能保证。
2. 禁止指令重排序
防止 JVM 的指令重排序,对这个变量进行读写操作的时候,会通过插入特定的 内存屏障 的方式来禁止指令重排序。
指令重排:编译器和处理器为优化执行效率而调整指令顺序的技术。它在多线程环境中可能导致并发问题,因为不同线程可能看到不一致的内存状态。通过使用volatile
关键字或内存屏障,可以防止这种重排,确保程序按预期运行。
3. 不能保证原子性
利用 synchronized
、Lock
或者AtomicInteger
都可以。
6. 乐观锁和悲观锁
1. What?
悲观锁:
共享资源每次只一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程。(synchronized
,ReentrantLock
等独占锁)。 高并发-锁竞争-线程阻塞-上下文切换-系统开销 (可能 死锁)。
乐观锁:
认为共享资源每次被访问的时候不会出现问题,线程可以不停地执行,无需加锁也无需等待,只是在提交修改的时候去验证对应的资源(也就是数据)是否被其它线程修改了(具体方法可以使用版本号机制或 CAS 算法)。 java.util.concurrent.atomic
包下面的原子变量类。
特性 | 使用场景 | 优点 | 缺点 | 备注 |
---|---|---|---|---|
悲观锁 | 写操作多(多写场景,竞争激烈) | 避免频繁失败和重试影响性能 | 固定的开销 | |
乐观锁 | 写操作少(多读场景,竞争较少) | 避免频繁加锁影响性能 | 频繁失败和重试可能影响性能 | 主要用于单个共享变量(参考java.util.concurrent.atomic包中的原子变量类) |
2. 实现乐观锁
版本号机制 或 CAS 算法(多)
-
版本号机制:
一般是在数据表中加上一个数据版本号 version 字段,表示数据被修改的次数。当数据被修改时,version 值会加一。当线程 A 要更新数据值时,在读取数据的同时也会读取 version 值,在提交更新时,若刚才读取到的 version 值为当前数据库中的 version 值相等时才更新,否则重试更新操作,直到更新成功。
-
CAS:
Compare And Swap(比较与交换)用于实现乐观锁。 是一个原子操作,底层依赖于一条 CPU 的原子指令。用一个预期值和要更新的变量值进行比较,两值相等才会进行更新。
-
V:要更新的变量值(Var)
-
E:预期值(Expected)
-
N:拟写入的新值(New)
当且仅当 V == E ,CAS 通过原子方式用新值 N 更新 V 的值。如果不等,说明已经有其它线程更新了 V,则当前线程放弃更新。
3. CAS存在的问题
1. ABA问题
一个变量 V 初次读取的时候是 A 值,并且在准备赋值的时候检查到它仍然是 A 值,在这段时间它的值可能被改为其他值,然后又改回 A。那 CAS 操作就会误认为从来没有被修改过。
解决:在变量前面追加上版本号或者时间戳。
2. 循环时间长开销大
CAS 经常会用到自旋操作来进行重试,也就是不成功就一直循环执行直到成功。如果长时间不成功,CPU 大执行开销。
解决:JVM 能支持处理器提供的 pause 指令
-
可以延迟流水线执行指令,使 CPU 不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零。
-
可以避免在退出循环的时候因内存顺序冲突而引起 CPU 流水线被清空,从而提高 CPU 的执行效率。
3. 只能保证一个共享变量的原子操作
当操作涉及跨多个共享变量时 CAS 无效。
解决:AtomicReference
类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行 CAS 操作。
7. synchronized 关键字
1. what
解决的是多个线程之间访问资源的同步性,可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。
2. 使用
-
修饰实例方法(当前对象实例)
给当前对象实例加锁,进入同步代码前要获得 当前对象实例的锁 。
synchronized void method() {//业务代码 }
-
修饰静态方法(当前类)
会作用于类的所有对象实例 ,进入同步代码前要获得 当前 class 的锁。
synchronized static void method() {//业务代码 }
因为访问静态 synchronized
方法占用的锁是当前类的锁,而访问非静态 synchronized
方法占用的锁是当前实例对象锁。所以静态 synchronized
方法和非静态 synchronized
方法之间的调用不互斥。
-
修饰代码块(指定类/对象)
-
synchronized(object)
表示进入同步代码库前要获得 给定对象的锁。 -
synchronized(类.class)
表示进入同步代码前要获得 给定 Class 的锁
synchronized(this) {//业务代码 }
3. 构造方法可以用 synchronized 修饰么?
不能,构造方法本身是线程安全的。
如果在构造方法内部涉及到共享资源的操作,可以使用 synchronized 代码块。
4. synchronized vs. volatile
两个互补的存在。
比较维度 | volatile关键字 | synchronized关键字 |
---|---|---|
性能 | 较好 | 较差 |
适用范围 | 变量 | 修饰方法以及代码块 |
数据可见性 | 能 | 能 |
数据原子性 | 不能 | 能 |
主要用途 | 解决变量在多个线程之间的可见性 | 解决多个线程之间访问资源的同步性 |
8. ReentrantLock
1. what
实现了 Lock
接口,是一个可重入且独占式的锁,和 synchronized
关键字类似。不过,ReentrantLock
更灵活、更强大,增加了轮询、超时、中断、公平锁和非公平锁等高级功能。底层就是由 AQS 来实现的。
2. 公平锁vs非公平锁
-
公平锁 : 锁被释放之后,先申请的线程先得到锁。性能较差一些,因为公平锁为了保证时间上的绝对顺序,上下文切换更频繁。
-
非公平锁:锁被释放之后,后申请的线程可能会先获取到锁,是随机或者按照其他优先级排序的。性能更好,但可能会导致某些线程永远无法获取到锁。
3. synchronized vs. ReentrantLock
-
两者都是可重入锁
可重入锁 也叫递归锁,指的是线程可以再次获取自己的内部锁。比如一个线程获得了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的,如果是不可重入锁的话,就会造成死锁。
JDK 提供的所有现成的 Lock
实现类,包括 synchronized
关键字锁都是可重入的。
-
synchronized -> JVM, ReentrantLock -> API
synchronized
是依赖于 JVM 实现的,前面我们也讲到了 虚拟机团队在 JDK1.6 为 synchronized
关键字进行了很多优化,但是这些优化都是在虚拟机层面实现的,并没有直接暴露给我们。
ReentrantLock
是 JDK 层面实现的(也就是 API 层面,需要 lock() 和 unlock() 方法配合 try/finally 语句块来完成),所以我们可以通过查看它的源代码,来看它是如何实现的。
-
ReentrantLock高级特性
等待可中断 : ReentrantLock
提供了一种能够中断等待锁的线程的机制,通过 lock.lockInterruptibly()
来实现这个机制。也就是说正在等待的线程可以选择放弃等待,改为处理其他事情。
可实现公平锁 : ReentrantLock
可以指定是公平锁还是非公平锁。而synchronized
只能是非公平锁。所谓的公平锁就是先等待的线程先获得锁。ReentrantLock
默认情况是非公平的,可以通过 ReentrantLock
类的ReentrantLock(boolean fair)
构造方法来指定是否是公平的。
可实现选择性通知(锁可以绑定多个条件): synchronized
关键字与wait()
和notify()
/notifyAll()
方法相结合可以实现等待/通知机制。ReentrantLock
类当然也可以实现,但是需要借助于Condition
接口与newCondition()
方法。
4. 可中断锁vs不可中断锁
-
可中断锁:获取锁的过程中可以被中断,不需要一直等到获取锁之后才能进行其他逻辑处理。
ReentrantLock
就属于是可中断锁。 -
不可中断锁:一旦线程申请了锁,就只能等到拿到锁以后才能进行其他的逻辑处理。
synchronized
就属于是不可中断锁。
9. ReentrantReadWriteLock
1. what
ReentrantReadWriteLock
其实是两把锁,一把是 WriteLock
(写锁),一把是 ReadLock
(读锁) 。 读锁是共享锁,写锁是独占锁。 读锁可以被同时读,可以同时被多个线程持有,而写锁最多只能同时被一个线程持有。
由于 ReentrantReadWriteLock
既可以保证多个线程同时读的效率,同时又可以保证有写入操作时的线程安全。因此,在读多写少的情况下,使用 ReentrantReadWriteLock
能够明显提升系统性能。
-
一般锁进行并发控制的规则:读读互斥、读写互斥、写写互斥。
-
读写锁进行并发控制的规则:读读不互斥、读写互斥、写写互斥(只有读读不互斥)。
2. 共享锁vs独占锁
-
共享锁:一把锁可以被多个线程同时获得。
-
独占锁:一把锁只能被一个线程获得。
在线程持有读锁的情况下,该线程不能取得写锁。( 死锁 -> 两个或以上的线程持有读锁,想获取写锁) 在线程持有写锁的情况下,该线程可以继续获取读锁。( 读锁共享 )
读锁不能升级为写锁,会导致死锁。
10. Atomic 原子类
具有原子/原子操作特征的类。即使是在多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程干扰。
基本、数组、引用、对象属性修改 类型。
更轻量级且高效,适用于需要频繁更新共享变量的场景。
11. ThreadLocal
1. what
通常情况下,我们创建的变量是可以被任何一个线程访问并修改的。
而ThreadLocal
让每一个线程都有自己的专属本地变量。(盒子中可以存储每个线程的私有数据。)
如果你创建了一个ThreadLocal
变量,那么访问这个变量的每个线程都会有这个变量的本地副本,这也是ThreadLocal
变量名的由来。他们可以使用 get()
和 set()
方法来获取默认值或将其值更改为当前线程所存的副本的值, 从而避免了线程安全问题。
2. 原理
每个Thread
中都具备一个ThreadLocalMap
,而ThreadLocalMap
可以存储以ThreadLocal
为 key ,Object 对象为 value 的键值对。
3. 内存泄漏
弱引用与强引用相对,是指不能确保其引用的对象不会被垃圾回收器回收的引用。一个对象若只被弱引用所引用,则被认为是不可访问(或弱可访问)的,并因此可能在任何时刻被回收。
ThreadLocalMap
中使用的 key 为 ThreadLocal
的弱引用,而 value 强引用。所以,如果 ThreadLocal
没有被外部强引用的情况下,在垃圾回收的时候,key 会被清理掉,而 value 不会被清理掉。
就会出现 key 为 null 的 Entry。假如我们不做任何措施的话,value 永远无法被 GC 回收,这个时候就可能会产生内存泄露。
在调用 set()
、get()
、remove()
方法的时候,会清理掉 key 为 null 的记录。使用完 ThreadLocal
方法后最好手动调用remove()
方法。
12. 线程池
1. what
管理一系列线程的资源池。当有任务要处理时,直接从线程池中获取线程来处理,处理完之后线程并不会立即被销毁,而是等待下一个任务。
2. why
为了减少每次获取资源的消耗,提高对资源的利用率。
线程池提供了一种限制和管理资源(包括执行一个任务)的方式。 每个线程池还维护一些基本统计信息,例如已完成任务的数量。
好处:
-
降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
-
提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立即执行。
-
提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。
3. 创建
-
✅通过
ThreadPoolExecutor
构造函数来创建。 -
❌通过
Executor
框架的工具类Executors
来创建。
-
FixedThreadPool
:固定线程数量的线程池。该线程池中的线程数量始终不变。当有一个新的任务提交时,线程池中若有空闲线程,则立即执行。若没有,则新的任务会被暂存在一个任务队列中,待有线程空闲时,便处理在任务队列中的任务。 -
SingleThreadExecutor
: 只有一个线程的线程池。若多余一个任务被提交到该线程池,任务会被保存在一个任务队列中,待线程空闲,按先入先出的顺序执行队列中的任务。 -
CachedThreadPool
: 可根据实际情况调整线程数量的线程池。线程池的线程数量不确定,但若有空闲线程可以复用,则会优先使用可复用的线程。若所有线程均在工作,又有新的任务提交,则会创建新的线程处理任务。所有线程在当前任务执行完毕后,将返回线程池进行复用。 -
ScheduledThreadPool
:给定的延迟后运行任务或者定期执行任务的线程池。
4. 线程池的拒绝策略
当前同时运行的线程数量达到最大线程数量并且队列也已经被放满了任务时。
-
AbortPolicy
: 抛出RejectedExecutionException
来拒绝新任务的处理。 -
CallerRunsPolicy
: 调用执行自己的线程运行任务。(承受此延迟并且你要求任何一个任务请求都要被执行) -
DiscardPolicy
:不处理新任务,直接丢弃掉。 -
DiscardOldestPolicy
:将丢弃最早的未处理的任务请求。
5. CallerRunsPolicy 拒绝策略有什么风险?如何解决?
如果想要保证任何一个任务请求都要被执行的话,那选择 CallerRunsPolicy
拒绝策略更合适一些。
非常耗时的任务,且处理提交任务的线程是主线程,可能会导致主线程阻塞,影响程序的正常运行,可能会内存溢出(OOM)。
解决:
-
暂时无法处理的任务又被保存在阻塞队列
BlockingQueue
中。 -
调整线程池的
maximumPoolSize
(最大线程数)参数。 -
任务持久化
6. 线程池常见的阻塞队列
新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。
-
LinkedBlockingQueue
(无界队列):FixedThreadPool
和SingleThreadExector
。FixedThreadPool
最多只能创建核心线程数的线程(核心线程数和最大线程数相等),SingleThreadExector
只能创建一个线程(核心线程数和最大线程数都是 1),二者的任务队列永远不会被放满,容量为Integer.MAX_VALUE
的 。 -
SynchronousQueue
(同步队列):CachedThreadPool
。SynchronousQueue
没有容量,不存储元素,目的是保证对于提交的任务,如果有空闲线程,则使用空闲线程来处理;否则新建一个线程来处理任务。也就是说,CachedThreadPool
的最大线程数是Integer.MAX_VALUE
,可以理解为线程数是可以无限扩展的,可能会创建大量线程,从而导致 OOM。 -
DelayedWorkQueue
(延迟阻塞队列):ScheduledThreadPool
和SingleThreadScheduledExecutor
。DelayedWorkQueue
的内部元素并不是按照放入的时间排序,而是会按照延迟的时间长短对任务进行排序,内部采用的是“堆”的数据结构,可以保证每次出队的任务都是当前队列中执行时间最靠前的。DelayedWorkQueue
添加元素满了之后会自动扩容原来容量的 1/2,即永远不会阻塞,最大扩容可达Integer.MAX_VALUE
,所以最多只能创建核心线程数的线程。
7. 线程池处理任务流程
-
如果当前运行的线程数小于核心线程数,那么就会新建一个线程来执行任务。
-
如果当前运行的线程数等于或大于核心线程数,但是小于最大线程数,那么就把该任务放入到任务队列里等待执行。
-
如果向任务队列投放任务失败(任务队列已经满了),但是当前运行的线程数是小于最大线程数的,就新建一个线程来执行任务。
-
如果当前运行的线程数已经等同于最大线程数了,新建线程将会使当前运行的线程超出最大线程数,那么当前任务会被拒绝,拒绝策略会调用
RejectedExecutionHandler.rejectedExecution()
方法。
8. 线程池中的线程异常后,销毁还是复用?
-
execute()
提交任务:当任务通过execute()
提交到线程池并在执行过程中抛出异常时,如果这个异常没有在任务内被捕获,那么该异常会导致当前线程终止,并且异常会被打印到控制台或日志文件中。线程池会检测到这种线程终止,并创建一个新线程来替换它,从而保持配置的线程数不变。 -
submit()
提交任务:对于通过submit()
提交的任务,如果在任务执行中发生异常,这个异常不会直接打印出来。相反,异常会被封装在由submit()
返回的Future
对象中。当调用Future.get()
方法时,可以捕获到一个ExecutionException
。在这种情况下,线程不会因为异常而终止,它会继续存在于线程池中,准备执行后续的任务。
使用execute()
时,未捕获异常导致线程终止,线程池创建新线程替代;(不需要关注执行结果) 使用submit()
时,异常被封装在Future
中,线程继续复用。(更灵活的错误处理机制)
9. 其他
1. 命名
设置线程池名称前缀,有利于定位问题。
ThreadFactoryBuilder
,或者自己实现 ThreadFactory
。
2. 线程池大小
-
过小,如果同一时间有大量任务/请求需要处理,可能会导致大量的请求/任务在任务队列中排队等待执行,甚至会出现任务队列满了之后任务/请求无法处理的情况,或者大量任务堆积在任务队列导致 OOM。(CPU利用不充分)
-
过大,大量线程可能会同时在争取 CPU 资源,这样会导致大量的上下文切换,从而增加线程的执行时间,影响了整体执行效率。
公式:
-
CPU 密集型任务(N+1): 这种任务消耗的主要是 CPU 资源,可以将线程数设置为 N(CPU 核心数)+1。比 CPU 核心数多出来的一个线程是为了防止线程偶发的缺页中断,或者其它原因导致的任务暂停而带来的影响。一旦任务暂停,CPU 就会处于空闲状态,而在这种情况下多出来的一个线程就可以充分利用 CPU 的空闲时间。
-
I/O 密集型任务(2N): 这种任务应用起来,系统会用大部分的时间来处理 I/O 交互,而线程在处理 I/O 的时间段内不会占用 CPU 来处理,这时就可以将 CPU 交出给其它线程使用。因此在 I/O 密集型任务的应用中,我们可以多配置一些线程,具体的计算方法是 2N。
CPU 密集型:利用 CPU 计算能力的任务比如你在内存中对大量数据进行排序。
IO 密集型:但凡涉及到网络读取,文件读取。这类任务的特点是 CPU 计算耗费时间相比于等待 IO 操作完成的时间来说很少,大部分时间都花在了等待 IO 操作完成上。
3. 动态修改线程池参数
三个核心参数:
-
corePoolSize
: 核心线程数线程数定义了最小可以同时运行的线程数量。 -
maximumPoolSize
: 当队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数。 -
workQueue
: 当新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。
借助开源工具。
4. 设计一个根据任务优先级来执行的线程池
不同的线程池会选用不同的阻塞队列作为任务队列。
使用 PriorityBlockingQueue
(优先级阻塞队列)作为任务队列。
风险与问题:
-
PriorityBlockingQueue
是无界的,可能堆积大量的请求,从而导致 OOM。 -
可能会导致饥饿问题,即低优先级的任务长时间得不到执行。
-
由于需要对队列中的元素进行排序操作以及保证线程安全(并发控制采用的是可重入锁
ReentrantLock
),因此会降低性能。
13. Future
异步思想的典型运用,主要用在一些需要执行耗时任务的场景,避免程序一直原地等待耗时任务执行完成,执行效率太低。
将这个耗时任务交给一个子线程去异步执行,同时我们可以干点其他事情,不用傻傻等待耗时任务执行完成。等我们的事情干完后,我们再通过 Future
类获取到耗时任务的执行结果。
14. AQS
AbstractQueuedSynchronizer
抽象队列同步器,用来构建锁和同步器。
15. 常见并发容器
-
ConcurrentHashMap
: 线程安全的HashMap
-
CopyOnWriteArrayList
: 线程安全的List
,在读多写少的场合性能非常好,远远好于Vector
。 -
ConcurrentLinkedQueue
: 高效的并发队列,使用链表实现。可以看做一个线程安全的LinkedList
,这是一个非阻塞队列。 -
BlockingQueue
: 这是一个接口,JDK 内部通过链表、数组等方式实现了这个接口。表示阻塞队列,非常适合用于作为数据共享的通道。 -
ConcurrentSkipListMap
: 跳表的实现。这是一个 Map,使用跳表的数据结构进行快速查找。
##