【面试必看】Java并发

news2024/9/21 2:48:21

并发

1. 线程

1. 线程vs进程

进程是程序的一次执行过程,是系统运行程序的基本单位,因此进程是动态的。 系统运行一个程序即是一个进程从创建,运行到消亡的过程。在 Java 中,当我们启动 main 函数时其实就是启动了一个 JVM 的进程,而 main 函数所在的线程就是这个进程中的一个线程,也称主线程。

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

比较项目进程线程
定义程序的一次执行过程,是系统运行程序的基本单位,动态的。比进程更小的执行单位,多个线程共享进程的资源。
系统中的作用系统运行一个程序即是一个进程从创建、运行到消亡的过程。一个进程在执行过程中可以产生多个线程。
资源共享各进程独立,不共享内存资源。线程共享进程的堆和方法区资源,但有自己的程序计数器、虚拟机栈和本地方法栈。
创建和切换负担系统创建和切换进程的负担较大。系统创建和切换线程的负担较小,因此线程被称为轻量级进程。
Java 中的体现启动 main 函数时启动 JVM 进程,main 函数所在的线程为主线程。线程在进程内产生,主线程和其他线程共享进程资源。

Java 程序天生就是多线程程序,一个 Java 程序的运行是 main 线程和多个其他线程同时运行

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

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

一个进程中可以有多个线程。 多个线程共享进程的方法区 (元空间)。 但是每个线程有自己的程序计数器虚拟机栈本地方法栈

堆和方法区(共享)

  1. 堆是进程中最大的一块内存,主要用于存放新创建的对象 (几乎所有对象都在这里分配内存),

  2. 方法区主要用于存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

程序计数器(私有)

为了线程切换后能恢复到正确的执行位置

  1. 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。

  2. 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。

虚拟机栈和本地方法栈(私有)

为了保证线程中的局部变量不被别的线程访问到

  1. 虚拟机栈: 每个 Java 方法在执行之前会创建一个栈帧用于存储局部变量表、操作数栈、常量池引用等信息。从方法调用直至执行完成的过程,就对应着一个栈帧在 Java 虚拟机栈中入栈和出栈的过程。

  2. 本地方法栈: 和虚拟机栈所发挥的作用非常相似,区别是:虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。

2. Java线程 vs OS线程

  • 用户线程:由用户空间程序管理和调度的线程,运行在用户空间(专门给应用程序使用)。

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

用户线程创建和切换成本低,但不可以利用多核。内核态线程,创建和切换成本高,可以利用多核。

常见的三种线程模型

现在的 Java 线程的本质其实就是操作系统的线程。

3. 创建线程

使用多线程的方法:继承Thread类、实现Runnable接口、实现Callable接口、使用线程池、使用CompletableFuture类等等。

真正的:new Thread().start()

4. 线程的生命周期和状态

Java 线程状态变迁图

  • NEW: 初始状态,线程被创建出来但没有被调用 start()

  • RUNNABLE: 运行状态,线程被调用了 start()等待运行的状态。

  • BLOCKED:阻塞状态,需要等待锁释放。

  • WAITING:等待状态,表示该线程需要等待其他线程做出一些特定动作(通知或中断)。

  • TIMED_WAITING:超时等待状态,可以在指定的时间后自行返回而不是像 WAITING 那样一直等待。

  • TERMINATED:终止状态,表示该线程已经运行完毕。

  1. 当线程执行 wait()方法之后,线程进入 WAITING(等待) 状态。进入等待状态的线程需要依靠其他线程的通知才能够返回到运行状态。

  2. TIMED_WAITING(超时等待) 状态相当于在等待状态的基础上增加了超时限制,比如通过 sleep(long millis)方法或 wait(long millis)方法可以将线程置于 TIMED_WAITING 状态。当超时时间结束后,线程将会返回到 RUNNABLE 状态。

  3. 当线程进入 synchronized 方法/块或者调用 wait 后(被 notify)重新进入 synchronized 方法/块,但是锁被其它线程占有,这个时候线程就会进入 BLOCKED(阻塞) 状态。

  4. 线程在执行完了 run()方法之后将会进入到 TERMINATED(终止) 状态。

随着代码的执行在不同状态之间切换。

RUNNING vs READY

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

RUNNABLE-VS-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,他们同时都想申请对方的资源,所以这两个线程就会互相等待而进入死锁状态。

线程死锁示意图

  1. 互斥条件:该资源任意一个时刻只由一个线程占用。

  2. 请求与保持条件:一个线程因请求资源而阻塞时,对已获得的资源保持不放。

  3. 不剥夺条件:线程已获得的资源在未使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源。

  4. 循环等待条件:若干线程之间形成一种头尾相接的循环等待资源关系。

2. 预防避免

预防:破坏死锁的产生的必要条件

  1. 破坏请求与保持条件:一次性申请所有的资源。

  2. 破坏不剥夺条件:占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。

  3. 破坏循环等待条件:靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。破坏循环等待条件。

互斥不成立则,死锁必然不发生。(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

描述了 线程和主内存之间的关系,为共享变量提供了可见性的保障。

JMM(Java 内存模型)

线程 1 与线程 2 之间如果要进行通信:

  1. 线程 1 把本地内存中修改过的共享变量副本的值同步到主内存中去。

  2. 线程 2 到主存中读取对应的共享变量的值。

4. 并发编程三大特性

性质描述实现方式
原子性一次操作或者多次操作,要么所有的操作全部都得到执行并且不会受到任何因素的干扰而中断,要么都不执行。synchronized、各种 Lock 以及各种原子类。 synchronized 和各种 Lock 可以保证任一时刻只有一个线程访问该代码块。 各种原子类是利用 CAS (compare and swap) 操作(可能也会用到 volatile或者final关键字)来保证原子操作。
可见性当一个线程对共享变量进行了修改,那么另外的线程都是立即可以看到修改后的最新值synchronizedvolatile 以及各种 Lock。将变量声明为 volatile ,指示 JVM 这个变量是共享且不稳定的,每次使用它都到主存中进行读取
有序性由于指令重排序问题,代码的执行顺序未必就是编写代码时候的顺序volatile 关键字可以禁止指令进行重排序优化。

5.  volatile 关键字

1. 保证变量的可见性

修饰变量后,表示这个变量是共享且不稳定的,每次使用它都到主存中进行读取。

JMM(Java 内存模型)

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

2. 禁止指令重排序

防止 JVM 的指令重排序,对这个变量进行读写操作的时候,会通过插入特定的 内存屏障 的方式来禁止指令重排序。

指令重排:编译器和处理器为优化执行效率而调整指令顺序的技术。它在多线程环境中可能导致并发问题,因为不同线程可能看到不一致的内存状态。通过使用volatile关键字或内存屏障,可以防止这种重排,确保程序按预期运行。

3. 不能保证原子性

利用 synchronizedLock或者AtomicInteger都可以。

6. 乐观锁和悲观锁

1. What?

悲观锁:

共享资源每次只一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程。(synchronizedReentrantLock独占锁)。 高并发-锁竞争-线程阻塞-上下文切换-系统开销 (可能 死锁)。

乐观锁:

认为共享资源每次被访问的时候不会出现问题,线程可以不停地执行,无需加锁也无需等待,只是在提交修改的时候去验证对应的资源(也就是数据)是否被其它线程修改了(具体方法可以使用版本号机制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 指令

  1. 可以延迟流水线执行指令,使 CPU 不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零。

  2. 可以避免在退出循环的时候因内存顺序冲突而引起 CPU 流水线被清空,从而提高 CPU 的执行效率。

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

当操作涉及跨多个共享变量时 CAS 无效。

解决:AtomicReference来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行 CAS 操作。

7. synchronized 关键字

1. what

解决的是多个线程之间访问资源的同步性,可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行

2. 使用

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

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

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

  1. 修饰静态方法(当前类)

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

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

因为访问静态 synchronized 方法占用的锁是当前类的锁,而访问非静态 synchronized 方法占用的锁是当前实例对象锁。所以静态 synchronized 方法和非静态 synchronized 方法之间的调用不互斥

  1. 修饰代码块(指定类/对象)

  • 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

  1. 两者都是可重入锁

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

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

  1. synchronized -> JVM, ReentrantLock -> API

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

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

  1. 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为 keyObject 对象为 value 的键值对。

ThreadLocal 数据结构

3. 内存泄漏

弱引用与强引用相对,是指不能确保其引用的对象不会被垃圾回收器回收的引用。一个对象若被弱引用所引用,则被认为是不可访问(或弱可访问)的,并因此可能在任何时刻被回收。

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

就会出现 key 为 null 的 Entry。假如我们不做任何措施的话,value 永远无法被 GC 回收,这个时候就可能会产生内存泄露。

在调用 set()get()remove() 方法的时候,会清理掉 key 为 null 的记录。使用完 ThreadLocal方法后最好手动调用remove()方法。

12. 线程池

1. what

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

2. why

为了减少每次获取资源的消耗,提高对资源的利用率。

线程池提供了一种限制和管理资源(包括执行一个任务)的方式。 每个线程池还维护一些基本统计信息,例如已完成任务的数量。

好处:

  • 降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗

  • 提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立即执行。

  • 提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。

3. 创建

  1. ✅通过ThreadPoolExecutor构造函数来创建。

  2. ❌通过 Executor 框架的工具类 Executors 来创建。

  • FixedThreadPool固定线程数量的线程池。该线程池中的线程数量始终不变。当有一个新的任务提交时,线程池中若有空闲线程,则立即执行。若没有,则新的任务会被暂存在一个任务队列中,待有线程空闲时,便处理在任务队列中的任务。

  • SingleThreadExecutor: 只有一个线程的线程池。若多余一个任务被提交到该线程池,任务会被保存在一个任务队列中,待线程空闲,按先入先出的顺序执行队列中的任务。

  • CachedThreadPool: 可根据实际情况调整线程数量的线程池。线程池的线程数量不确定,但若有空闲线程可以复用,则会优先使用可复用的线程。若所有线程均在工作,又有新的任务提交,则会创建新的线程处理任务。所有线程在当前任务执行完毕后,将返回线程池进行复用。

  • ScheduledThreadPool:给定的延迟后运行任务或者定期执行任务的线程池。

4. 线程池的拒绝策略

当前同时运行的线程数量达到最大线程数量并且队列也已经被放满了任务时。

  • AbortPolicy: 抛出 RejectedExecutionException拒绝新任务的处理。

  • CallerRunsPolicy: 调用执行自己的线程运行任务。(承受此延迟并且你要求任何一个任务请求都要被执行)

  • DiscardPolicy:不处理新任务,直接丢弃掉。

  • DiscardOldestPolicy:将丢弃最早的未处理的任务请求。

5. CallerRunsPolicy 拒绝策略有什么风险?如何解决?

如果想要保证任何一个任务请求都要被执行的话,那选择 CallerRunsPolicy 拒绝策略更合适一些。

非常耗时的任务,且处理提交任务的线程是主线程,可能会导致主线程阻塞,影响程序的正常运行,可能会内存溢出(OOM)。

解决:

  1. 暂时无法处理的任务又被保存在阻塞队列BlockingQueue中。

  2. 调整线程池的maximumPoolSize最大线程数)参数。

  3. 任务持久化

6. 线程池常见的阻塞队列

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

  • LinkedBlockingQueue无界队列):FixedThreadPoolSingleThreadExectorFixedThreadPool最多只能创建核心线程数的线程(核心线程数和最大线程数相等),SingleThreadExector只能创建一个线程(核心线程数和最大线程数都是 1),二者的任务队列永远不会被放满,容量为 Integer.MAX_VALUE 的 。

  • SynchronousQueue(同步队列)CachedThreadPoolSynchronousQueue 没有容量,不存储元素,目的是保证对于提交的任务,如果有空闲线程,则使用空闲线程来处理;否则新建一个线程来处理任务。也就是说,CachedThreadPool 的最大线程数是 Integer.MAX_VALUE ,可以理解为线程数是可以无限扩展的,可能会创建大量线程,从而导致 OOM。

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

7. 线程池处理任务流程

图解线程池实现原理

  1. 如果当前运行的线程数小于核心线程数,那么就会新建一个线程来执行任务。

  2. 如果当前运行的线程数等于或大于核心线程数,但是小于最大线程数,那么就把该任务放入到任务队列里等待执行。

  3. 如果向任务队列投放任务失败(任务队列已经满了),但是当前运行的线程数是小于最大线程数的,就新建一个线程来执行任务。

  4. 如果当前运行的线程数已经等同于最大线程数了,新建线程将会使当前运行的线程超出最大线程数,那么当前任务会被拒绝,拒绝策略会调用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,使用跳表的数据结构进行快速查找。

##

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1704030.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

CLIP源码详解:clip.py 文件

前言 这是关于 CLIP 源码中的 clip.py 文件中的代码带注释版本。 clip.py 文件的作用&#xff1a;封装了 clip 项目的相关 API&#xff0c;通过这些 API &#xff0c;我们可以轻松使用 CLIP 项目预训练好的模型进行自己项目的应用。 另外不太容易懂的地方都使用了二级标题强…

echart扩展插件词云echarts-wordcloud

echart扩展插件词云echarts-wordcloud 一、效果图二、主要代码 一、效果图 二、主要代码 // 安装插件 npm i echarts-wordcloud -Simport * as echarts from echarts; import echarts-wordcloud; //下载插件echarts-wordcloud import wordcloudBg from /components/wordcloudB…

【Linux】升级make(版本4.4.1)、升级gdb(版本14.1)、升级autoconf(版本2.71)

centos7升级make&#xff08;版本4.4.1&#xff09;&#xff1a; make&#xff1a;编译和构建工具。Linux中很多软件包需要make编译构建。官网&#xff1a;Make - GNU Project - Free Software Foundation 本次升级前的make版本是3.82&#xff0c;准备安装的版本是4.4.1。make…

很耐看的Go快速开发后台系统框架

序言 秉承Go语言设计思路&#xff0c;我们集成框架简单易用、扩展性好、性能优异、兼顾安全稳定&#xff0c;适合企业及初学者用来开发项目、学习。我们框架和市面上其他家设计的不同&#xff0c;简单一步做到的我们不会两步&#xff0c;框架能自动处理&#xff0c;绝不手动处…

MySQL8.0新特性join lateral 派生子查询关联

在 MySQL 8.0 及更高版本中&#xff0c;LATERAL 是一个用于派生表&#xff08;derived tables&#xff09;的关键字&#xff0c;它允许派生表中的查询引用包含该派生表的 FROM 子句中的表。这在执行某些复杂的查询时特别有用&#xff0c;尤其是当需要在子查询中引用外部查询的列…

服了这群人!已举报!

文章首发于公众号&#xff1a;X小鹿AI副业 大家好&#xff0c;我是程序员X小鹿&#xff0c;前互联网大厂程序员&#xff0c;自由职业2年&#xff0c;也一名 AIGC 爱好者&#xff0c;持续分享更多前沿的「AI 工具」和「AI副业玩法」&#xff0c;欢迎一起交流~ 服了这群人&#x…

生产订单工序新增BAPI:CO_SE_PRODORD_OPR_CREATE增强

背景&#xff1a; 创建生产订单工序时需要通过BAPI来维护圈起来的字段&#xff0c;但是BAPI不包含这些字段&#xff0c;所以对BAPI进行一些增强处理。 实现过程&#xff1a; 1.拷贝标准BAPI:CO_SE_PRODORD_OPR_CREATE至ZCO_SE_PRODORD_OPR_CREATE&#xff08;最好放在新的自定…

结合Django和Vue.js构建现代Web应用

文章目录 1. 创建Django项目2. 配置Django后端3. 创建Vue.js前端4. 连接Django和Vue.js5. 构建和部署 在现代Web开发中&#xff0c;结合后端框架和前端框架是非常常见的&#xff0c;其中Django作为一种流行的Python后端框架&#xff0c;而Vue.js则是一种灵活强大的前端框架。本…

使用DoraCloud搭建研发办公云桌面,保障信息安全

一、背景 在信息化全面推进的今天&#xff0c;小型公司的数据安全和员工远程办公已成为亟待解决的重要问题。为了提高工作效率和数据安全性&#xff0c;公司决定引入云桌面技术&#xff0c;实现员工远程办公和数据安全保障。 云桌面&#xff08;VDI&#xff09;&#xff0c;也…

如何自学制作电子画册,这个秘籍收藏好

随着数字技术的飞速发展&#xff0c;电子画册作为一种新兴的媒体展示形式&#xff0c;以其独特的魅力和丰富的表现手法&#xff0c;受到了越来越多人的喜爱。那么&#xff0c;如何自学制作电子画册呢&#xff1f; 1. 学习基础知识 首先&#xff0c;你需要了解电子画册的基本构…

python探索转义字符的奥秘

新书上架~&#x1f447;全国包邮奥~ python实用小工具开发教程http://pythontoolsteach.com/3 欢迎关注我&#x1f446;&#xff0c;收藏下次不迷路┗|&#xff40;O′|┛ 嗷~~ 目录 一、转义字符的定义与功能 案例解析&#xff1a;换行符与双引号 二、转义字符的应用场景 …

jQuery效果2

jQuery 一、属性操作1.内容2.列子&#xff0c;购物车模块-全选 二、内容文本值1.内容2.列子&#xff0c;增减商品和小记 三、元素操作(遍历&#xff0c;创建&#xff0c;删除&#xff0c;添加&#xff09;1.遍历2.例子&#xff0c;购物车模块&#xff0c;计算总件数和总额3.创建…

什么是访问越界(C语言数组、指针、结构体成员访问越界)

在C语言中&#xff0c;访问越界&#xff08;Access Violation 或 Out-of-Bounds Access&#xff09;是指程序试图访问的内存位置超出了其合法或已分配的范围。这通常发生在数组、指针或其他内存结构的使用中。 案例&#xff1a; #include <stdio.h>//数组 //Visiting b…

您有一份课程日历待查收!2024高通边缘智能创新应用大赛公开课重磅开启

自2024高通边缘智能创新应用大赛启动以来&#xff0c;全国各地的开发者热情如潮&#xff0c;踊跃报名&#xff0c;其中不乏名企名校开发者&#xff0c;共赴这场科技狂欢盛宴&#xff01; 随着初赛赛程过半&#xff0c;我们陆续看到一些精彩的创意与技术构想。同时&#xff0c;…

韩语“再见” 怎么说,柯桥韩语培训

1.1 标准写法及读法 안녕 (annyeong) 音译&#xff1a; 安宁 罗马音&#xff1a; Annyeong 使用情境&#xff1a; 适用于朋友之间或非常熟悉的关系中&#xff0c;不分场合&#xff0c;可以用于打招呼或告别&#xff0c;表示“你好”或“再见”。 안녕히 가세요 (annyeonghi …

【FPGA】Verilog:2-bit 二进制比较器的实现(2-bit binary comparator)

解释 2-bit 二进制比较器仿真结果及过程说明(包括真值表和卡诺图) 真值表和卡洛图如下: 2-bit Binary Comparator A1 A2 B1

宝塔部署纯Vue项目,无后端

1.打包项目 生成一个dist文件夹 2.创建云服务器根目录 3.创建站点 4.上传文件 5.访问

vue测试环境打包文件不添加hash和生产环境打包不一致

npm run build:test npm run build:pro 测试环境打包出来文件和生产包有差异 .env.test-配置文件 打包出来文件有hash值&#xff0c;加上下面的配置&#xff0c;打包就和pro一致 NODE_ENV productionNODE_ENV只能设置production和development两个参数 开发环境是development&a…

Excel 取出每组最后一行

Excel的前两列是两层的分组列&#xff0c;后两列是明细 ABCD1CM11112CM12123CM13134CM14145CM25156CM26167BM11218BM12229BM232310AM113111AM323212AM333313AM3434 现在要取出每小组的最后一行&#xff1a; ABCD1CM14142CM26163BM12224BM23235AM11316AM3434 使用 SPL XLL sp…

产品经理-产品设计规范(六)

1. 设计规范 2. 七大定律 2.1 菲茨定律 2.1.1 概念 2.1.2 理解 2.1.3 启示 按钮等可点击对象需要合理的大小尺寸根据用户使用习惯合理设计按钮的相对和绝对位置屏幕的边和角很适合放置像菜单栏和按钮这样的元素 2.1.4 参考使用手机习惯 2.1.5 案例 2.2 席克定律 2.2.1 概念 …