JAVA多线程并发
JAVA多线程并发
进程与线程
进程线程
1,what
进程是指一个应用程序在内存中的一次执行流程, 是资源分配的基本单位
每个进程都有自己独立的一块内存空间。
线程是cpu调度的最小单位(CPU分配时间轮片是通过线程来实现的),一个进程可以启动多个线程
2.diff
1.线程是cpu调度的单位,而进程是操作系统分配空间资源的单位;
2.一个进程由一个或多个线程组成
3.进程之间的空间资源分配相互独立,但同一进程下的各个线程之间共享程序的内存空间(包括代码段、数据集、堆等)及一些进程级的资源(如打开文件和信号),某进程内的线程在其它进程不可见;
4.调度和切换:线程上下文切换比进程上下文切换要快得多。
5.首先线程可以分为内核不可感知的用户态的线程和内核可感知的内核态线程,linux用task_struct数据结构来描述进程和内核线程,内核线程就是操作系统内的线程, 内核是通过是否有独立物理内存地址 来区别内核线程和进程的,内核线程是用fork共享父进程的物理内存页, 操作系统只有父进程的task_struct是PCB,内核线程的是TCB;
6.进程从用户的视角来看是应用程序的一次执行过程,应用程序通过调用操作系统创建一个进程开始此次执行过程,进程从用户视角和内核视角的语义是不变的,都是操作系统内核的一个进程。
至于内核线程和用户线程的对应模型,是内核中cpu调度内核线程 如何反馈到用户态的 方式,跟进程没关系, 用户态线程的出现是为了替代重量级的内核线程调度,线程调度在用户态就完成了,从而获得了相比于内核线程调度更轻量级的线程调度(不需要管cpu时间片调度之类的了,只需要在不同时刻把不同的用户线程对应上这个内核线程就行);
3.协程和线程:
协程是一种用户态的轻量级线程。协程的调度完全由用户控制,内核线程调度切换有用户态和内核态的切换消耗,而协程没有内核切换消耗
协程是用户态线程
写时复制
在 Linux 系统中,调用 fork
系统调用创建子进程时,并不会把父进程所有占用的内存页复制一份,而是与父进程共用相同的内存页,而当子进程或者父进程对内存页进行修改时才会进行复制 —— 这就是著名的 写时复制
机制。
进程是指一个应用程序在内存中的一次执行流程 运行一个程序时,操作系统首先要创建一个进程,为进程分配内存等资源,然后加入进程队列中执行。对单个进程的某个时刻而言,进程与程序之间可以形成一对一,多对一,一对多,多对多的关系。
2.5 多核与多线程
多核(心)处理器是指在一个处理器上集成多个运算核心,也就是有多个真正并行计算的处理核心,每一个处理核心对应一个内核线程。内核线程(Kernel Thread, KLT)就是直接由操作系统内核支持的线程,这种线程由内核来完成线程切换,内核通过操作调度器对线程进行调度,并负责将线程的任务映射到各个处理器上。一般一个处理核心对应一个内核线程,比如单核处理器对应一个内核线程,双核处理器对应两个内核线程,四核处理器对应四个内核线程。
现在的电脑一般是双核四线程、四核八线程,是采用超线程技术将一个物理处理核心模拟成两个逻辑处理核心,对应两个内核线程,所以在操作系统中看到的CPU数量是实际物理CPU数量的两倍,如你的电脑是双核四线程,打开“任务管理器\性能”可以看到4个CPU的监视器,四核八线程可以看到8个CPU的监视器。
3.用户线程与内核线程
在内核线程的支持下,LWP是独立的调度单元,用户线程通过LWP调度内核线程
内核线程(Kernel Thread, KLT)就是直接由操作系统内核支持的线程
用户级线程:
用户级线程的管理都由应用程序完成,应用程序通过线程库控制用户线程,内核压根对这不感兴趣。
用户线程在进程内的切换不需要用户态/内核态的切换。
一个内核线程可以被多个用户线程对应,同一时刻只执行一个用户线程的调度,用户线程的切换,生成与删除对内核线程没影响。
用户线程与内核线程的对应关系:一对一模型、多对一模型、多对多模型
一对一
对于一对一模型来说,一个用户线程就唯一地对应一个内核线程(反过来不一定成立,一个内核线程不一定有对应的用户线程(不一定被使用))
程序员直接使用操作系统中已经实现的线程,而线程的创建、销毁、调度和维护,都是靠操作系统(准确的说是内核)来实现,程序员只需要使用系统调用,而不需要自己设计线程的调度算法和线程对CPU资源的抢占使用 也就是说,线程全部交给内核管理
多对一
多对一模型将多个用户线程映射到一个内核线程上,线程之间的切换由用户态的代码来进行(决定谁来使用该内核线程),因此相对一对一模型,多对一模型的线程切换速度要快许多(线程的调度只是在用户态,减少了操作系统从内核态到用户态的切换开销);此外,多对一模型对用户线程的数量几乎无限制。但多对一模型也有两个缺点:1.如果其中一个用户线程阻塞,那么其它所有线程都将无法执行,因为此时内核线程也随之阻塞了
(这种情况一个内核线程对应一个进程,线程调度(调度线程占用cpu)依赖于用户态线程切换实现,不会切换内核线程来完成任务,所以阻塞了就真阻塞了,一个内核线程阻塞,它对应的进程的所有线程一起阻塞);
多对多
多对多模型结合了一对一模型和多对一模型的优点,将多个用户线程映射到多个内核线程上。多对多模型的优点有:1.一个用户线程的阻塞不会导致所有线程的阻塞,因为此时还有别的内核线程被调度来执行;2.多对多模型对用户线程的数量没有限制;
在这种混合实现下,即存在用户线程,也存在轻量级进程。用户线程还是完全建立在用户空间中,因此用户线程的创建、切换、析构等操作依然廉价,并且可以支持大规模的用户线程并发。而操作系统提供支持的轻量级进程则作为用户线程和内核线程之间的桥梁,这样可以使用内核提供的线程调度功能及处理器映射,并且用户线程的系统调用要通过轻量级进程来完成,大大降低了整个进程被完全阻塞的风险。在这种混合模式中,用户线程与轻量级进程的数量比是不定的,即为N:M的关系:
Java线程和操作系统线程的关系_CringKong的博客-CSDN博客
PS:我认为线程是对于cpu调度单位来说的,谁是cpu调度的最小单位谁就算线程
比如一对一模型中,内核线程是线程,多对一中用户线程是线程(轮流映射到内核线程中,借由内核线程获取cpu资源),多对多中是内核线程
JVM线程属于用户态还是内核态
java使用操作系统的原生线程。 ‘
在目前的jdk版本中,操作系统支持怎样的线程模型,很大程度上决定了java虚拟机的线程是怎样映射的,这点在不同的平台上都没有办法达成一致。
例如,Java SE最常用的JVM是Oracle/Sun研发的HotSpot VM。在这个JVM所支持的所有平台上都是采用一对一的线程模型的(也就是一个java线程对应一个内核线程)
线程的生命周期和状态
//更正!以前的理解是错的
NEW: 初始状态,线程对象被创建出来但没有被调用 start()
。JVM为其分配了内存
RUNNABLE: 线程被调用了 start()
,jvm为其分配虚拟机栈和程序计数器等资源进入就绪状态。
处于就绪状态的线程位于线程队列中等待cpu调度运行,进入运行状态,获得cpu时间片(.run)
BLOCKED:线程让出cpu时间片,由运行进入阻塞状态
一般在尝试获取某个资源对象的同步锁时阻塞,该锁被其他线程持有,当前线程阻塞等待。
等到锁被释放时,原本拥有锁的线程notify唤起阻塞等待锁的线程,被唤起的线程进入就绪状态竞争获锁,如果获锁成功就运行获得时间片执行代码,获锁失败重新进入阻塞状态重试/放弃获锁
或者可能是线程调用了一个阻塞式的I/O,需要等待IO操作完毕返回才能继续运行
WAITING:等待状态,表示该线程需要等待其他线程做出一些特定动作(通知或中断)。
调用wait,调用join等待另一线程执行结束
TIME_WAITING:超时等待状态,可以在指定的时间后自行返回而不是像 WAITING 那样一直等待。
调用sleep等待睡眠时间结束,带时间的调用wait,join
TERMINATED:终止状态,表示该线程已经运行完毕/ 异常结束(stop/抛异常)
线程方法:
JMM
操作系统通过 内存模型解决内存缓存不一致性问题
指令重排序 :为了提升执行速度/性能,计算机在执行程序代码的时候,会对指令进行重排序。
什么是指令重排序?
简单来说就是系统在执行代码的时候并不一定是按照你写的代码的顺序依次执行。
Java 源代码会经历 编译器优化重排 —> 指令并行重排 —> 内存系统重排 的过程,最终才变成操作系统可执行的指令序列。
指令重排序可以保证串行语义一致,但是没有义务保证多线程间的语义也一致 ,所以在多线程下,指令重排序可能会导致一些问题。
处理指令重排序:
编译器:禁止特定类型的编译器重排序
处理器:插入内存屏障,禁止特定类型的处理器重排序
什么是 JMM?
是 Java 定义的并发编程相关的一组规范,为了屏蔽 不同操作系统 对内存模型的实现差异,满足跨平台特性(不管在哪个平台,Java程序多线程并发访问内存的特性没变),Java提供了一套自己的内存模型来屏蔽系统差异。
JMM做了什么
规定了共享变量的访问方式(定义结构规范),解决了并发编程中可见性,有序性,原子性的线程安全问题
1.可见性:
一个线程修改共享变量后,其他线程能立刻看到修改
为什么会有可见性问题:举例:多个线程都对同一共享变量读写操作,一个线程读了后在本地内存修改了,但是其他线程不能看到他的修改,因为不同线程间本地内存是相互独立隔离的。
如何解决的?
1.sychronized关键字,Lock
synchronized和Lock保证可见性:它们可以保证任一时刻只有一个线程能访问共享资源,在加锁时先从主存读取最新值,并在其释放锁之前将修改的变量刷新到内存中。
2.volatile保证可见性
VOLATILE保证可见性
声明共享变量是volatile,可以做到:
1.一旦线程对共享变量副本做修改,立刻刷新到主内存
2,一旦线程对共享变量副本做修改,其他线程的副本会失效,如果其他线程想读写在他们工作内存中的该变量,得重新从主内存加载
volatile通过内存屏障(Lock屏障和Store屏障)和缓存一致性协议 实现可见性
1.Lock屏障保证读操作从主内存读取最新数据;Store屏障 保证 对副本写操作时 把数据写入主存中
2.缓存一致性协议(MESI),其他线程如果工作内存中存了该共享变量的值,就会失效;失效后想要再读取就只能再去主内存获取最新值
PS:该缓存一致性思路:当CPU写数据时,如果发现操作的变量时共享变量,即其他线程的工作内存也存在该变量,于是会发信号通知其他CPU该变量的内存地址无效。当其他线程需要使用这个变量时,如内存地址失效,那么它们会在主存中重新读取该值。
volatile修饰的变量,在*每个读操作(load操作)之前*都加上*Load屏障*,强制*从主内存读取最新的数据*。每次在*assign赋值后面*,加上*Store屏障*,强制*将数据刷新到主内存*。
2.有序性
程序按照代码的先后顺序执行
为什么会有有序性问题:编译器和处理器会指令重排序,在多线程下回出现一致性问题,这时候可以禁止指令重排序保证有效性
如何解决:
1.加锁 synchronized和Lock :通过让线程串行执行保证
2.volatile 基于内存屏障实现有序性
volatile是通过内存屏障来保证有序性的:
内存屏障(英语:Memory barrier) 是一类同步屏障指令,是CPU或编译器在对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作。
指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到 volatile 关键字的时候,在它前面的操作已经全部完成
3.原子性
一个或多个操作,要么全部执行,要么全部不执行
为什么会出现原子性问题:多线程并发执行时出现线程切换,cpu执行程序到一半切换执行其他线程的程序,原子性被破坏(活干了一半意外中断,前面已经干了一部分生效了,后面的却还没干呢,这时就是原子性被破坏)
如何解决:
锁或者CAS
JMM的结构规范
JMM抽象了线程与主内存的关系,
线程本地内存:Java中,每个线程都有一个私有的本地内存来存储共享变量的副本。本地内存存储了主内存中的共享变量副本。
主内存:线程之间的共享变量必须存储在主内存中。
JMM结构规范
1.线程之间的共享变量必须存储在主内存中, 如果线程想要读写共享变量,得先在本地内存保存副本,然后在本地内存进行响应读/写操作,不能直接读/写主内存,
2.线程每次要修改共享变量副本时,操作完毕后,得把修改值同步到主内存中。
happens-before 原则
禁止会改变执行结果的重排序
对编译器和处理器约束尽可能小
用来在保证可见性的前提下进行重排序让性能最大化
happens-before保证前一个操作对于后一个操作可见,且多线程下依然可见
一个http请求就是一个线程吗?Java的服务是每收到一个请求就新开一个线程来处理吗?java一个请求是一个线程吗麦田里的POLO桔的博客-CSDN博客
多线程和单线程相比,能提升处理速度吗?
对于单核CPU来说,多线程主要是提高了cpu利用效率
锁应用
线程同步:当多线程读写同一个共享资源时,需要让各个线程轮流操作该资源,同一时刻只有一个线程操作该资源,叫做同步
(多线程排队操作共享变量,同一时刻只有一个线程能操作)
线程同步一般都是通过加锁实现的
活锁和死锁区别
考虑一个场景:进程P1占有A请求B,进程P1占有B请求A。
如果是等待式的请求,两者都会陷入无尽的等待中。这是死锁。
如果请求不是等待式的,而是一旦发现资源被占有就失败,整个请求取消(回滚)并重新开始。此时P1放弃占有A重新开始,P2放弃占有B重新开始。则P1、P2可能会出现重复不断的开始-回滚循环。这种情况我们称之为活锁。
相比死锁,活锁更难检测,也更浪费资源(重复不断的开始-回滚循环)。
死锁:.lock() 阻塞等待不重试
获锁:.tryLock()超时后重试获取
乐观锁和悲观锁
乐观锁和悲观锁是两种思想,一个是假设永远都有并发问题,一个是认为不存在很多并发更新请求
什么是悲观锁?
悲观锁总是假设最坏的情况,认为共享资源每次被访问的时候就会出现问题(比如共享数据被修改),所以每次在获取资源操作的时候都会上锁,这样其他线程想拿到这个资源就会阻塞直到锁被上一个持有者释放。也就是说,共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程。
这样可以确保在同一时间只有一个线程可以访问共享资源,从而保证线程安全。但是这种做法会带来一定的性能损失,因为大部分时间锁并没有被竞争,但是所有线程都需要等待。
像 Java 中synchronized
和ReentrantLock
等独占锁就是悲观锁思想的实现。
使用场景:竞争激烈的场景,
悲观锁不适合竞争不激烈的场景,因为悲观锁使其他线程无法同时访问读取数据,只能串行,并发少,还有线程切换的额外消耗
悲观锁的实现:synchronized,redis分布式锁,select for update
什么是乐观锁?
一种概念
乐观锁总是假设最好的情况,认为共享资源每次被访问的时候不会出现问题,线程可以不停地执行,无需加锁也无需等待,只是在提交修改的时候去验证对应的资源(也就是数据)是否被其它线程修改了(具体方法可以使用版本号机制或 CAS 算法)。
在 Java 中java.util.concurrent.atomic
包下面的原子变量类(比如AtomicInteger
、LongAdder
)就是使用了乐观锁的一种实现方式 CAS 实现的。
使用场景:读多少写的场景,
乐观锁不适用于写多的场景(冲突时乐观锁一半都会自旋重试,此时处于忙等待状态),不断的重试反而浪费了cpu资源(多个线程竞争一个乐观锁,一个在执行时其他的线程都在忙等待,自选重试时不会调度其他线程,而是该线程一直占用cpu时间片。
CAS
CAS 的全称是 Compare And Swap(比较与交换)
用一个预期值和要更新的变量值进行比较,两值相等才会进行更新。
CAS 是一个原子操作,底层依赖于一条 CPU 的原子指令。
原子操作 即最小不可拆分的操作,也就是说操作一旦开始,就不能被打断,直到操作完成。
当多个线程同时使用 CAS 操作一个变量时,只有一个会胜出,并成功更新,其余均会失败,但失败的线程并不会被挂起,仅是被告知失败,并且允许再次尝试,当然也允许失败的线程放弃操作。
Java 语言并没有直接实现 CAS,CAS 相关的实现是通过 C++ 内联汇编的形式实现的(JNI 调用)。因此, CAS 的具体实现和操作系统以及 CPU 都有关系
问题:
ABA 问题
如果一个变量 V 初次读取的时候是 A 值,并且在准备赋值的时候检查到它仍然是 A 值,那我们就能说明它的值没有被其他线程修改过了吗?很明显是不能的,因为在这段时间它的值可能被改为其他值,然后又改回 A,那 CAS 操作就会误认为它从来没有被修改过。这个问题被称为 CAS 操作的 "ABA"问题。
ABA 问题的解决思路是在变量前面追加上**版本号或者时间戳
循环时间长开销大
CAS 经常会用到自旋操作来进行重试,也就是不成功就一直循环执行直到成功。如果长时间不成功,会给 CPU 带来非常大的执行开销。
只能保证一个共享变量的原子操作
CAS 只对单个共享变量有效,当操作涉及跨多个共享变量时 CAS 无效。但是从 JDK 1.5 开始,提供了AtomicReference
类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行 CAS 操作.所以我们可以使用锁或者利用AtomicReference
类把多个共享变量合并成一个共享变量来操
synchronized 关键字
synchronized 是什么?有什么用?
synchronized
是 Java 中的一个关键字,翻译成中文是同步的意思,主要解决的是多个线程之间访问资源的同步性,可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。,可以解决并发编程中的可见性,有序性,原子性问题
在 Java 早期版本中,synchronized
属于 重量级锁,效率低下。这是因为监视器锁(monitor)是依赖于底层的操作系统的 Mutex Lock
来实现的,而且Java 的线程是映射到操作系统的原生线程(一个java线程对应一个内核线程)之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高。
不过,在 Java 6 之后, synchronized
引入了大量的优化如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销,这些优化让 synchronized
锁的效率提升了很多。因此, synchronized
还是可以在实际项目中使用的,像 JDK 源码、很多开源框架都大量使用了 synchronized
如何使用 synchronized?
synchronized
关键字的使用方式主要有下面 3 种:
修饰实例方法
修饰静态方法
修饰代码块
1、修饰实例方法 (锁当前对象实例)
被修饰的方法称为同步方法,其作用的范围是整个方法,作用的对象是调用这个方法的对象;
2、修饰静态方法 (锁当前类)
作用的范围是整个静态方法,作用的对象是这个类的所有对象
3、修饰代码块
被修饰的代码块称为同步语句块,其作用的范围是大括号{}括起来的代码,作用的对象是调用这个代码块的对象;
一个线程访问一个对象中的synchronized(this)同步代码块时,其他试图访问该对象的线程将被阻塞
synchronized原理
synchronized关键字的实现
synchronized不论是修饰代码块还是修饰方法都是通过持有对象锁来实现同步的。而这个对象的markword就指向了一个Monitor(锁/监视器)
1、java对象头的markword结构:
对象大致可以分为三个部分,分别是对象头,实例变量和填充字节,对象头分成两个部分:mark word和 klass word
锁的类型和状态在对象头Mark Word中都有记录。在申请锁、锁升级等过程中JVM都需要读取对象的Mark Word数据。对于重量级锁对象的markword包含两个部分:指向重量级锁的指针和标志位
由此看来,monitor锁对象地址存在于每个Java对象的对象头中
2、Monitor结构:
每一个锁都对应一个monitor对象,在HotSpot虚拟机中它是由ObjectMonitor实现的(C++实现)
ObjectMonitor() {
_header = NULL;
_count = 0; //记录个数
_waiters = 0,
_recursions = 0;
_object = NULL;
_owner = NULL;
_WaitSet = NULL; //处于wait状态的线程,会被加入到_WaitSet
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
_EntryList = NULL ; //处于等待锁block状态的线程,会被加入到该列表
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}
3、synchronized底层原理 = java对象头markword + 操作系统对象monitor:
每个 Java 对象都可以关联一个 Monitor 对象,如果使用 synchronized 给对象上锁(重量级)之后,该对象头的Mark Word 中就被设置指向 Monitor 对象的指针
synchronized无论是加在同步代码块还是方法上,效果都是加在对象上,其原理都是对一个对象上锁
如何给这个obj上锁呢?当一个线程Thread-1要执行临界区的代码时,首先会通过obj对象的markword指向一个monitor锁对象
当Thread-1线程持有monitor对象后,就会把monitor中的owner变量设置为当前线程Thread-1,同时计数器count+1表示当前对象锁被一个线程获取。
当另一个线程Thread-2想要执行临界区的代码时,要判断monitor对象的属性Owner是否为null,如果为null,Thread-2线程就持有了对象锁可以执行临界区的代码,如果不为null,Thread-2线程就会放入monitor的EntryList阻塞队列中,处于阻塞状态Blocked。
当Thread-0将临界区的代码执行完毕,将释放monitor(锁)并将owner变量置为null,同时计算器count-1,并通知EntryList阻塞队列中的线程,唤醒里面的线程
Synchronized 上重量级锁原理
实现原理: JVM 是通过进入、退出 对象监视器(Monitor) 来实现对方法、同步块的同步的,而对象监视器的本质依赖于底层操作系统的 互斥锁(Mutex Lock) 实现。
具体实现是在编译之后在同步方法调用前加入一个monitor.enter
指令,在退出方法和异常处插入monitor.exit
的指令。
对于没有获取到锁的线程将会阻塞到方法入口处,直到获取锁的线程monitor.exit
之后才能尝试继续获取锁。
monitorenter 指令:
每个对象有一个监视器锁(monitor)。当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权,过程如下:
如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者
如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1.(可重入锁的原因)
如果其他线程已经占用了monitor,则该线程进入阻塞状态等待锁释放,直到monitor的进入数为0,再重新尝试获取monitor的所有权。
monitorexit指令:
执行monitorexit的线程必须是持有obj锁对象的线程
指令执行时,monitor的进入数减1,如果减1后进入数为0,那线程释放monitor,不再是这个monitor的所有者。其他被这个monitor阻塞的线程可以尝试去获取这个 monitor 的所有权。
Synchronized的语义底层是通过一个monitor的对象来完成,其实wait/notify等方法也依赖于monitor对象,这就是为什么只有在同步的块或者方法中才能调用wait/notify等方法,否则会抛出IllegalMonitorStateException的异常的原因。
synchronized关键字加锁的状态:
无锁状态、偏向锁、轻量级锁、重量级锁
锁的类型和状态在对象头Mark Word
中都有记录,在申请锁、锁升级等过程中JVM都需要读取对象的Mark Word
数据。
每一个锁都对应一个monitor对象,在HotSpot虚拟机中它是由ObjectMonitor实现的(C++实现)。每个对象都存在着一个monitor与之关联
jdk1.6优化
jdl1.6 为什么对synchronized进行优化
因为Java虚拟机是通过进入和退出Monitor对象来实现代码块同步和方法同步的,而Monitor是依靠底层操作系统的Mutex Lock
来实现的,操作系统实现线程之间的切换需要从用户态转换到内核态,这个切换成本比较高,对性能影响较大。
优化了哪些?
1.锁升级:
方向是:无锁——>偏向锁——>轻量级锁——>重量级锁,并且升级方向不可逆。
无锁
没有开启偏向锁时的状态,jdk1.6后线程进入临界区(synchronized锁住的代码块)默认开启偏向锁
偏向锁
目的:减少单个线程下的不必要的CAS操作(因为轻量级锁的获取及释放依赖多次CAS原子指令,偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令)
获取流程:
判断Mark Work中的线程ID是否指向当前线程,如果是,则执行同步代码块。
如果不是,则进行CAS操作竞争锁,如果竞争到锁,则将Mark Work中的线程ID设为当前线程ID,执行同步代码块。
如果竞争失败,撤销原有偏向锁,升级为轻量级锁。(有竞争,导致竞争失败)
eg:上一个线程执行完,mark word里面是111, 222线程来了,发现线程id不对应,开始cas竞争获锁,读取了mark word是111,要改成222, 在执行更改的过程中,如果有线程进来更改为333,那么222的cas就失败了,说明有竞争情况, 撤销原有偏向锁,升级成轻量级锁
偏向锁原理:使用CAS操作将当前线程的ID记录到对象的Mark Word中
轻量级锁
目的:在多线程交替执行同步代码块时(未发生竞争),避免使用互斥量(重量锁)带来的性能消耗。但多个线程同时进入临界区(发生竞争)则会使得轻量级锁膨胀为重量级锁。
轻量级锁的获取:CAS自旋获取锁(CAS将对象的Mark Word更新为指向Lock Record的指针),自选超过一定次数,升级为重量级锁(锁竞争激烈,升级为互斥量)
解锁:CAS将之前复制在栈桢中的 Displaced Mard Word 替换回 Mark Word 中。如果替换失败,则说明该锁已经升级成重量级锁(markword的标志位和指针都变了,指针指向monitor),cas失败时,那就释放锁并且唤醒阻塞的线程,若是cas成功,就只释放锁即可。
PS:自旋锁是一种机制,指的是线程占有cpu时间片,周期的采用cas去获取锁,而轻量级锁是jdk实现的锁的一种状态
轻量级锁的原理:将对象的Mark Word复制到当前线程的栈帧中创建的Lock Record,并CAS将对象的Mark Word更新为指向Lock Record的指针(加锁)。
重量级锁
不自旋,在激烈竞争场景下,可以避免没必要的自旋操作消耗cpu
但是使用mutex lock实现,每次线程调度都会带来用户态和内核态的切换消耗
重量级锁竞争获锁,获锁不到的话直接挂起线程(线程上下文切换),等待锁释放notify唤起
锁消除
消除锁是虚拟机另外一种锁的优化,这种优化更彻底,在JIT编译时,对运行上下文进行扫描,去除不可能存在竞争的锁。
锁粗化
锁粗化是虚拟机对另一种极端情况的优化处理,通过扩大锁的范围,避免反复加锁和释放锁。
自适应自旋锁:
这种相当于是对上面自旋锁机制的一种优化,它的自旋的次数不再固定,其自旋的次数由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定,减少了自旋锁带来不必要cpu消耗
synchronized 和 volatile 区别?
1.volatile不需要加锁,不会阻塞线程,synchronized加锁,轻量级锁会自旋重试阻塞线程,重量级锁会将线程阻塞挂起
2.volatile
关键字能保证数据的可见性有序性,但不能保证数据的原子性
3.volatile
关键字只能用于变量而 synchronized
关键字可以修饰方法以及代码块
ReentrantLock
ReentrantLock 是什么?
ReentrantLock
实现了 Lock
接口,是一个可重入且独占式的锁,和 synchronized
关键字类似。不过,ReentrantLock
更灵活、更强大,增加了轮询、超时、中断、公平锁和非公平锁等高级功能。
public class ReentrantLock implements Lock, java.io.Serializable {}
ReentrantLock
里面有一个内部类 Sync
,Sync
继承 AQS(AbstractQueuedSynchronizer
),添加锁和释放锁的大部分操作实际上都是在 Sync
中实现的。Sync
有公平锁 FairSync
和非公平锁 NonfairSync
两个子类。
ReentrantLock
默认使用非公平锁,也可以通过构造器来显式的指定使用公平锁。
ReentrantLock
的底层就是由 AQS 来实现的
公平锁和非公平锁有什么区别?
公平锁 : 锁被释放之后,先申请的线程先得到锁。性能较差一些,因为公平锁为了保证时间上的绝对顺序,上下文切换更频繁。
非公平锁:锁被释放之后,后申请的线程可能会先获取到锁,是随机或者按照其他优先级排序的。性能更好,但可能会导致某些线程永远无法获取到锁
synchronized 和 ReentrantLock 有什么区别?
# 两者都是可重入锁
可重入锁 也叫递归锁,指的是线程可以再次获取自己的内部锁。比如一个线程获得了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的,如果是不可重入锁的话,就会造成死锁。
JDK 提供的所有现成的 Lock
实现类,包括 synchronized
关键字锁都是可重入的。
synchronized 依赖于 JVM 而 ReentrantLock 依赖于 API
synchronized
是依赖于 JVM 实现的,前面我们也讲到了 虚拟机团队在 JDK1.6 为 synchronized
关键字进行了很多优化,但是这些优化都是在虚拟机层面实现的,并没有直接暴露给我们。
ReentrantLock
是 JDK 层面实现的(也就是 API 层面,需要 lock() 和 unlock() 方法配合 try/finally 语句块来完成),所以我们可以通过查看它的源代码,来看它是如何实现的。
ReentrantLock 比 synchronized 增加了一些高级功能
相比synchronized
,ReentrantLock
增加了一些高级功能。主要来说主要有三点:
等待可中断 :
ReentrantLock
提供了一种能够中断等待锁的线程的机制,通过lock.lockInterruptibly()
来实现这个机制。也就是说正在等待的线程可以选择放弃等待,改为处理其他事情。可实现公平锁 :
ReentrantLock
可以指定是公平锁还是非公平锁。而synchronized
只能是非公平锁。所谓的公平锁就是先等待的线程先获得锁。ReentrantLock
默认情况是非公平的,可以通过ReentrantLock
类的ReentrantLock(boolean fair)
构造方法来制定是否是公平的。可实现选择性通知(锁可以绑定多个条件):
synchronized
关键字与wait()
和notify()
/notifyAll()
方法相结合可以实现等待/通知机制。ReentrantLock
类当然也可以实现,但是需要借助于Condition
接口与newCondition()
方法。
如果你想使用上述功能,那么选择 ReentrantLock
是一个不错的选择(synchronized 和ReentrantLock在1.6后没有太大的性能差别,ReentrantLock能提供更多功能,synchronized 更方便,看情况使用)
关于 Condition
接口的补充:
Condition
是 JDK1.5 之后才有的,它具有很好的灵活性,比如可以实现多路通知功能也就是在一个Lock
对象中可以创建多个Condition
实例(即对象监视器),线程对象可以注册在指定的Condition
中,从而可以有选择性的进行线程通知,在调度线程上更加灵活。 在使用notify()/notifyAll()
方法进行通知时,被通知的线程是由 JVM 选择的,用ReentrantLock
类结合Condition
实例可以实现“选择性通知” ,这个功能非常重要,而且是Condition
接口默认提供的。而synchronized
关键字就相当于整个Lock
对象中只有一个Condition
实例,所有的线程都注册在它一个身上。如果执行notifyAll()
方法的话就会通知所有处于等待状态的线程,这样会造成很大的效率问题。而Condition
实例的signalAll()
方法,只会唤醒注册在该Condition
实例中的所有等待线程。
可中断锁和不可中断锁有什么区别?
可中断锁:获取锁的过程中可以被中断,不需要一直等到获取锁之后 才能进行其他逻辑处理。
ReentrantLock
就属于是可中断锁。不可中断锁:一旦线程申请了锁,就只能等到拿到锁以后才能进行其他的逻辑处理。
synchronized
就属于是不可中断锁
原理
AQS
ReentrantLock这种东西只是一个外层的API,内核中的锁机制实现都是依赖AQS组件的。
ReentrantReadWriteLock
ReentrantReadWriteLock
在实际项目中使用的并不多,面试中也问的比较少,简单了解即可。JDK 1.8 引入了性能更好的读写锁 StampedLock
。
# ReentrantReadWriteLock 是什么?
ReentrantReadWriteLock
实现了 ReadWriteLock
,是一个可重入的读写锁,既可以保证多个线程同时读的效率,同时又可以保证有写入操作时的线程安全
一般锁进行并发控制的规则:读读互斥、读写互斥、写写互斥。
读写锁进行并发控制的规则:读读不互斥、读写互斥、写写互斥(只有读读不互斥)。
ReentrantReadWriteLock
其实是两把锁,一把是 WriteLock
(写锁),一把是 ReadLock
(读锁) 。读锁是共享锁,写锁是独占锁。读锁可以被同时读,可以同时被多个线程持有,而写锁最多只能同时被一个线程持有。(上了写锁,其他线程就上不了读锁,上了读锁时,上不了写锁)
和 ReentrantLock
一样,ReentrantReadWriteLock
底层也是基于 AQS 实现的。
ReentrantReadWriteLock 适合什么场景?
由于 ReentrantReadWriteLock
既可以保证多个线程同时读的效率,同时又可以保证有写入操作时的线程安全。因此,在读多写少的情况下,使用 ReentrantReadWriteLock
能够明显提升系统性能。
共享锁和独占锁有什么区别?
共享锁:一把锁可以被多个线程同时获得。
独占锁:一把锁只能被一个线程获得。
线程持有读锁还能获取写锁吗?
在线程持有读锁的情况下,该线程不能取得写锁(因为获取写锁的时候,如果发现当前的读锁被占用,就马上获取失败,不管读锁是不是被当前线程持有)。
在线程持有写锁的情况下,该线程可以继续获取读锁(获取读锁时如果发现写锁被占用,只有写锁没有被当前线程占用的情况才会获取失败)。 (也就是说,当当前线程已经获取写锁时,再获取读锁,是可以的,但是如果写锁是别人的,你就读不了)
读锁为什么不能升级为写锁?
引起锁竞争影响性能,可能死锁
ThreadLocal
ThreadLocal 有什么用?
通常情况下,我们创建的变量是可以被任何一个线程访问并修改的。如果想实现每一个线程都有自己的专属本地变量该如何解决呢?
JDK 中自带的ThreadLocal
类正是为了解决这样的问题。 ThreadLocal
类主要解决的就是让每个线程绑定自己的值,可以将ThreadLocal
类形象的比喻成存放数据的盒子,盒子中可以存储每个线程的私有数据。
再举个简单的例子:两个人去宝屋收集宝物,这两个共用一个袋子的话肯定会产生争执,但是给他们两个人每个人分配一个袋子的话就不会出现这样的问题。如果把这两个人比作线程的话,那么 ThreadLocal 就是用来避免这两个线程竞争的。
一句话理解ThreadLocal,threadlocl是作为当前线程中属性ThreadLocalMap集合中的某一个Entry的key值Entry(threadlocl,value),虽然不同的线程之间threadlocal这个key值是一样,但是不同的线程所拥有的ThreadLocalMap是独一无二的,也就是不同的线程间同一个ThreadLocal(key)对应存储的值(value)不一样,从而到达了线程间变量隔离的目的,但是在同一个线程中这个value变量地址是一样的。
ThreadLocal 原理
变量是放在了当前线程的 ThreadLocalMap
中,并不是存在 ThreadLocal
上,ThreadLocal
可以理解为只是ThreadLocalMap
的封装,传递了变量值。 ThrealLocal
类中可以通过Thread.currentThread()
获取到当前线程对象后,直接通过getMap(Thread t)
可以访问到该线程的ThreadLocalMap
对象。
**每个**Thread**
(不是threadLocal,而是thread)中都具备一个ThreadLocalMap
,而ThreadLocalMap
可以存储以ThreadLocal
为 key ,Object 对象为 value 的键值对
ThreadLocal 内存泄露问题
项目里一般设置为static final ,此时ThreadLocal是线程共享的强引用,此时Map里面的key不会被gc回收,需要我们手动remove,不然会内存泄漏
如果ThreadLocal是弱引用,有机制确保不内存泄露,每次gc会回收key,在get和set时会触发探测式清理,把null,value清除掉,不过如果长期没调用getset,就会内存泄漏
Java 常见并发容器总结
JDK 提供的这些容器大部分在 java.util.concurrent
包中。
ConcurrentHashMap
: 线程安全的HashMap
(Node
数组+链表+红黑树的数据结构来实现,并发控制使用synchronized
和 CAS 来操作)CopyOnWriteArrayList
: 线程安全的List
,在读多写少的场合性能非常好,远远好于Vector
。 线程安全的核心在于其采用了 写时复制(Copy-On-Write) 的策略 ,只有写写才会互斥ConcurrentLinkedQueue
: 高效的并发队列,使用链表实现。可以看做一个线程安全的LinkedList
,这是一个非阻塞队列。主要使用 CAS 非阻塞算法来实现线程安全BlockingQueue
: 这是一个接口,JDK 内部通过链表、数组等方式实现了这个接口。表示阻塞队列,非常适合用于作为数据共享的通道。ConcurrentSkipListMap
: 跳表的实现。这是一个 Map,使用跳表的数据结构进行快速查找。
ConcurrentHashMap
我们知道 HashMap
不是线程安全的,在并发场景下如果要保证一种可行的方式是使用 Collections.synchronizedMap()
方法来包装我们的 HashMap
。但这是通过使用一个全局的锁来同步不同线程间的并发访问,因此会带来不可忽视的性能问题。
所以就有了 HashMap
的线程安全版本—— ConcurrentHashMap
的诞生。
在 JDK1.7 的时候,ConcurrentHashMap
对整个桶数组进行了分割分段(Segment
,分段锁),每一把锁只锁容器其中一部分数据(下面有示意图),多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。
到了 JDK1.8 的时候,ConcurrentHashMap
已经摒弃了 Segment
的概念,而是直接用 Node
数组+链表+红黑树的数据结构来实现,并发控制使用 synchronized
和 CAS 来操作。(JDK1.6 以后 synchronized
锁做了很多优化) 整个看起来就像是优化过且线程安全的 HashMap
,虽然在 JDK1.8 中还能看到 Segment
的数据结构,但是已经简化了属性,只是为了兼容旧版本。
//其他:再说
AQS 详解
AQS 介绍
AQS 的全称为 AbstractQueuedSynchronizer
,是一个抽象类 ,翻译过来的意思就是抽象队列同步器。这个类在 java.util.concurrent.locks
包下面。
AQS 为构建锁和同步器提供了一些通用功能的实现,只需要继承它并重写指定方法即可
ReentrantLock,
Semaphore,其他的诸如
ReentrantReadWriteLock,
SynchronousQueue`等等皆是基于 AQS 的
AQS
通过一个FIFO的同步等待队列维护线程同步状态,
还有一个基于Condition
结构实现的条件等待队列,提供了wait/signal
等待唤醒机制。
AQS
根据资源互斥级别提供了独占和共享两种资源访问模式;
(线程同步:当多线程读写同一个共享资源时,需要让各个线程轮流操作该资源,同一时刻只有一个线程操作该资源,叫做同步)
AQS 原理
AQS
的原理并不复杂,AQS
维护了一个volatile int state
变量和两个队列:一个同步等待队列(CLH),队列中的节点持有线程引用(每个节点关联一个线程)用于维护获取锁失败的入队线程,还有一个条件等待队列:调用await()方法释放锁后,加入条件队列,等待条件唤醒再次争抢锁。
AQS内部还有一个关键变量,用来记录当前加锁的是哪个线程,初始化状态下,这个变量是null。
AQS核心思想是,如果被请求的共享资源空闲,那么就将当前请求资源的线程设置为有效的工作线程,将共享资源设置为锁定状态;如果共享资源被占用,就需要一定的阻塞等待唤醒机制来保证锁分配。这个机制主要用的是CLH队列的变体实现的,将暂时获取不到锁的线程加入到队列中。如果获锁成功了,就设为持有者node放到CLH中
当线程获取锁时,即试图对state
变量做修改,修改时对stateCAS修改,若失败看看自己是不是持有者线程,如果不是就包装该线程为Node挂载到CLH尾部,等待持有锁的线程释放锁并唤醒队列中的节点。
(锁释放时,如果是独占锁,则唤醒持有者节点的下一个后继阻塞节点。共享锁则唤醒所有后继节点)
AQS核心结构
Node
前文提到,在AQS
中如果线程获取资源失败,会包装成一个节点挂载到CLH
队列上,AQS
中定义了Node
类用于包装线程。
Node
主要包含5个核心字段:
waitStatus
:当前节点状态,该字段共有5种取值:
prev
:前驱节点。next
:后继节点。**thread**
:引用线程,头节点不包含线程。**nextWaiter**
:condition
条件队列。(见ConditionObject
)
AQS
内部封装了队列维护逻辑,采用模版方法的模式提供实现类以下方法:
tryAcquire(int); // 尝试获取独占锁,可获取返回true,否则false
tryRelease(int); // 尝试释放独占锁,可释放返回true,否则false
tryAcquireShared(int); // 尝试以共享方式获取锁,失败返回负数,只能获取一次返回0,否则返回个数
tryReleaseShared(int); // 尝试释放共享锁,可获取返回true,否则false
isHeldExclusively(); // 判断线程是否独占资源
AQS 定义两种资源共享方式:Exclusive
(独占,只有一个线程能执行,如ReentrantLock
)和Share
(共享,多个线程可同时执行,如Semaphore
/CountDownLatch
)。
一般来说,自定义同步器的共享方式要么是独占,要么是共享,他们也只需实现tryAcquire-tryRelease
、tryAcquireShared-tryReleaseShared
中的一种即可。但 AQS 也支持自定义同步器同时实现独占和共享两种方式,如`ReentrantReadWriteLock
ConditionObject
ConditionObject
通过Node
也构成了一个FIFO
的队列,那么ConditionObject
为AQS
提供了怎样的功能呢?
在Synchronized详解中笔者曾对ObjectMonitor
做过简单介绍,其中ObjectMonitor
包含_WaitSet
和_EntryList
两个队列,分别用于存储wait调用
和sychronized锁竞争
时挂起的线程,而AQS
通过ConditionObject
同样也提供了wait/notify
机制的阻塞队列。
原本的CLH是阻塞队列(获锁失败放这里),同时ConditionObject(nextwaiter条件队列) 存放了本来持有锁却因为调用await阻塞的线程的队列,,类似于sychronized的waitSet
ConditionObject
机制如上图,在条件队列中,Node
采用nextWaiter
组成单向链表,当持有锁的线程发起condition.await
调用后,会包装为Node
挂载到Condition条件阻塞队列中;当对应condition.signal
被触发后,条件阻塞队列中的节点将被唤醒并挂载到锁阻塞队列中。
常见同步工具类
Semaphore(信号量)
synchronized
和 ReentrantLock
都是一次只允许一个线程访问某个资源,而Semaphore
(信号量)可以用来控制同时访问特定资源的线程数量。
Semaphore
的使用简单,我们这里假设有 N(N>5)
个线程来获取 Semaphore
中的共享资源,下面的代码表示同一时刻 N 个线程中只有 5 个线程能获取到共享资源,其他线程都会阻塞,只有获取到共享资源的线程才能执行。等到有线程释放了共享资源,其他阻塞的线程才能获取到。
// 初始共享资源数量
final Semaphore semaphore = new Semaphore(5);
// 获取1个许可
semaphore.acquire();
// 释放1个许可
semaphore.release();
当初始的资源个数为 1 的时候,Semaphore
退化为排他锁。
Semaphore
有两种模式:。
公平模式: 调用
acquire()
方法的顺序就是获取许可证的顺序,遵循 FIFO;非公平模式: 抢占式的。
Semaphore
通常用于那些资源有明确访问数量限制的场景比如限流(仅限于单机模式,实际项目中推荐使用 Redis +Lua 来做限流)。
原理
Semaphore
是共享锁的一种实现,它默认构造 AQS 的 state
值为 permits
,你可以将 permits
的值理解为许可证的数量,只有拿到许可证的线程才能执行。
以无参 acquire
方法为例,调用semaphore.acquire()
,线程尝试获取许可证,如果 state > 0
的话,则表示可以获取成功,如果 state <= 0
的话,则表示许可证数量不足,获取失败。
如果可以获取成功的话(state > 0
),会尝试使用 CAS 操作去修改 state
的值 state=state-1
。如果获取失败则会创建一个 Node 节点加入等待队列,挂起当前线程。
release
方法为例,调用semaphore.release();
,线程尝试释放许可证,并使用 CAS 操作去修改 state
的值 state=state+1
。释放许可证成功之后,同时会唤醒等待队列中的一个线程。被唤醒的线程会重新尝试去修改 state
的值 state=state-1
,如果 state > 0
则获取令牌成功,否则重新进入等待队列,挂起线程。
CountDownLatch (倒计时器)
CountDownLatch
其他count个线程都到达之后该线程再执行
CountDownLatch
是一次性的,计数器的值只能在构造方法中初始化一次,之后没有任何机制再次对其设置值,当 CountDownLatch
使用完毕后,它不能再次被使用
原理
CountDownLatch` 是共享锁的一种实现,它默认构造 AQS 的 `state` 值为 `count
当线程调用 countDown()
时,其实使用了tryReleaseShared
方法以 CAS 的操作来减少 state
,直至 state
为 0 。当 state
为 0 时,表示所有的线程都调用了 countDown
方法,那么在 CountDownLatch
上等待的线程就会被唤醒并继续执行。
CountDownLatch 的两种典型用法:
某一线程在开始运行前等待 n 个线程执行完毕 : 将
CountDownLatch
的计数器初始化为 n (new CountDownLatch(n)
),每当一个任务线程执行完毕,就将计数器减 1 (countdownlatch.countDown()
),当计数器的值变为 0 时,在CountDownLatch 上 await()
的线程就会被唤醒。一个典型应用场景就是启动一个服务时,主线程需要等待多个组件加载完毕,之后再继续执行。实现多个线程开始执行任务的最大并行性:注意是并行性,不是并发,强调的是多个线程在某一时刻同时开始执行。类似于赛跑,将多个线程放到起点,等待发令枪响,然后同时开跑。做法是初始化一个共享的
CountDownLatch
对象,将其计数器初始化为 1 (new CountDownLatch(1)
),多个线程在开始执行任务前首先coundownlatch.await()
,当主线程调用countDown()
时,计数器变为 0,多个线程同时被唤醒。
CyclicBarrier(循环栅栏)
CyclicBarrier
的字面意思是可循环使用(Cyclic)的屏障(Barrier)。它要做的事情是:让一组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续干活。
原理
CyclicBarrier
内部通过一个 count
变量作为计数器,count
的初始值为 parties
属性的初始化值,每当一个线程到了栅栏这里了,那么就将计数器减 1。如果 count 值为 0 了,表示这是这一代最后一个线程到达栅栏,就尝试执行我们构造方法中输入的任务。
其中,parties
就代表了有拦截的线程的数量,当拦截的线程数量达到这个值的时候就打开栅栏,让所有线程通过。
Atomic 原子类介绍
原子类说简单点就是具有原子/原子操作特征的类。
原子类用来保证某个共享变量在多线程下的原子性
unsafe的compareAndSwapInt具有原子性且,变量由volatile修饰具有可见性,因此实现了线程安全
AtomicInteger
类主要利用 CAS (compare and swap) + volatile 和 native 方法来保证原子操作,从而避免 synchronized 的高开销,执行效率大为提升。
CAS 的原理是拿期望的值和原本的一个值作比较,如果相同则更新成新的值。UnSafe 类的 objectFieldOffset()
方法是一个本地方法,这个方法是用来拿到“原来的值”的内存地址。另外 value 是一个 volatile 变量,在内存中可见,因此 JVM 可以保证任何时刻任何线程总能拿到该变量的最新值。
线程池
什么是线程池?
顾名思义,线程池就是管理一系列线程的资源池。当有任务要处理时,直接从线程池中获取线程来处理,处理完之后线程并不会立即被销毁,而是等待下一个任务。 是一种基于池化思想管理线程的工具
为什么要用线程池?
池化技术想必大家已经屡见不鲜了,线程池、数据库连接池、Http 连接池等等都是对这个思想的应用。池化技术的思想主要是为了减少每次获取资源的消耗,提高对资源的利用率。
降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立即执行。
提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。
线程池解决的问题是什么
线程池解决的核心问题就是资源管理问题。在并发环境下,系统不能够确定在任意时刻中,有多少任务需要执行,有多少资源需要投入。这种不确定性将带来以下若干问题:
频繁申请/销毁资源和调度资源,将带来额外的消耗,可能会非常巨大。
对资源无限申请缺少抑制手段,易引发系统资源耗尽的风险。
系统无法合理管理内部的资源分布,会降低系统的稳定性。
如何创建线程池?
方式一:通过ThreadPoolExecutor
构造函数来创建(推荐)。
方式二:通过 Executor
框架的工具类 Executors
来创建。
我们可以创建多种类型的 ThreadPoolExecutor
:
FixedThreadPool
:该方法返回一个固定线程数量的线程池。该线程池中的线程数量始终不变。当有一个新的任务提交时,线程池中若有空闲线程,则立即执行。若没有,则新的任务会被暂存在一个任务队列中,待有线程空闲时,便处理在任务队列中的任务。SingleThreadExecutor
: 该方法返回一个只有一个线程的线程池。若多余一个任务被提交到该线程池,任务会被保存在一个任务队列中,待线程空闲,按先入先出的顺序执行队列中的任务。CachedThreadPool
: 该方法返回一个可根据实际情况调整线程数量的线程池。初始大小为0。当有新任务提交时,如果当前线程池中没有线程可用,它会创建一个新的线程来处理该任务。如果在一段时间内(默认为60秒)没有新任务提交,核心线程会超时并被销毁,从而缩小线程池的大小。ScheduledThreadPool
:该方法返回一个用来在给定的延迟后运行任务或者定期执行任务的线程池。
、在《阿里巴巴 Java 开发手册》“并发处理”这一章节,明确指出线程资源必须通过线程池提供,不允许在应用中自行显式创建线程。
为什么呢?
使用线程池的好处是减少在创建和销毁线程上所消耗的时间以及系统资源开销,解决资源不足的问题。如果不使用线程池,有可能会造成系统创建大量同类线程而导致消耗完内存或者“过度切换”的问题。
另外,《阿里巴巴 Java 开发手册》中强制线程池不允许使用 Executors
去创建,而是通过 ThreadPoolExecutor
构造函数的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险
Executors
返回线程池对象的弊端如下(后文会详细介绍到):
FixedThreadPool
和SingleThreadExecutor
:使用的是无界的LinkedBlockingQueue
,任务队列最大长度为Integer.MAX_VALUE
,可能堆积大量的请求,从而导致 OOM。CachedThreadPool
:使用的是同步队列SynchronousQueue
, 允许创建的线程数量为Integer.MAX_VALUE
,如果任务数量过多且执行速度较慢,可能会创建大量的线程,从而导致 OOM。ScheduledThreadPool
和SingleThreadScheduledExecutor
: 使用的无界的延迟阻塞队列DelayedWorkQueue
,任务队列最大长度为Integer.MAX_VALUE
,可能堆积大量的请求,从而导致 OOM。
线程池常见参数有哪些?如何解释?
ThreadPoolExecutor
3 个最重要的参数:
corePoolSize
: 任务队列未达到队列容量时,最大可以同时运行的线程数量。(核心线程数)maximumPoolSize
: 任务队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数。(最大线程数)workQueue
: 新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。
ThreadPoolExecutor
其他常见参数 :
keepAliveTime
:线程池中的线程数量大于corePoolSize
的时候,如果这时没有新的任务提交,多余的空闲线程不会立即销毁,而是会等待,直到等待的时间超过了keepAliveTime
才会被回收销毁,线程池回收线程时,会对核心线程和非核心线程一视同仁,直到线程池中线程的数量等于corePoolSize
,回收过程才会停止。unit
:keepAliveTime
参数的时间单位。threadFactory
:创建线程的工厂handler
:饱和策略。关于饱和策略下面单独介绍一下
PS:任务队列就是阻塞队列
PS:当核心线程被占满,且任务队列未满时,先把任务往任务队列塞,任务队列满了再来任务,那么就要创建非核心线程开始执行,
直到线程数等于最大线程数时,再来任务,那就要触发拒绝策略了
如何给线程池命名?
初始化线程池的时候需要显示命名(设置线程池名称前缀),有利于定位问题。
给线程池里的线程命名通常有下面两种方式:
如何设定线程池的大小?
线程数量过多的影响也是和我们分配多少人做事情一样,对于多线程这个场景来说主要是增加了上下文切换成本
· 如果我们设置的线程池数量太小的话,如果同一时间有大量任务/请求需要处理,可能会导致大量的请求/任务在任务队列中排队等待执行,甚至会出现任务队列满了之后任务/请求无法处理的情况,或者大量任务堆积在任务队列导致 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 密集任务还是 IO 密集任务?
CPU 密集型简单理解就是利用 CPU 计算能力的任务比如你在内存中对大量数据进行排序。但凡涉及到网络读取,文件读取这类都是 IO 密集型,这类任务的特点是 CPU 计算耗费时间相比于等待 IO 操作完成的时间来说很少,大部分时间都花在了等待 IO 操作完成上
动态调整线程池参数
如何设置线程池参数?美团给出了一个让面试官虎躯一震的回答。 (qq.com)
nacos实现动态线程池
基于Nacos实现动态线程池(简版+DynamicTp) - 掘金 (juejin.cn)
Excutor
Executor框架主要由3大部分组成如下。
任务。包括被执行任务需要实现的接口:Runnable接口或Callable接口。
任务的执行。包括任务执行机制的核心接口Executor,以及继承自Executor的ExecutorService接口。Executor框架有两个关键类实现了ExecutorService接口(ThreadPoolExecutor和ScheduledThreadPoolExecutor)。
异步计算的结果。包括接口Future和实现Future接口的FutureTask类。
主线程首先要创建实现Runnable或者Callable接口的任务对象。工具类Executors可以把一个Runnable对象封装为一个Callable对象(Executors.callable(Runnable task)或Executors.callable(Runnable task,Object resule))。
然后可以把Runnable对象直接交给ExecutorService执行(没有返回)(ExecutorService.execute(Runnable command));或者也可以把Runnable对象或Callable对象提交给ExecutorService执行(Executor-Service.submit(Runnable task)或ExecutorService.submit(Callabletask))。
如果执行ExecutorService.submit(…),ExecutorService将返回一个实现Future接口的对象(到目前为止的JDK中,返回的是FutureTask对象)。由于FutureTask实现了Runnable,程序员也可以创建FutureTask,然后直接交给ExecutorService执行。
最后,主线程可以执行FutureTask.get()方法来等待任务执行完成。主线程也可以执行FutureTask.cancel(boolean mayInterruptIfRunning)来取消此任务的执行。
PS: runnable没有返回值,而实现callable接口的任务线程能返回执行结果
Future 接口有什么用?
在 Java 中,Future
接口 只是一个泛型接口,位于 java.util.concurrent
包下,其中定义了 5 个方法,主要包括下面这 4 个功能:
取消任务;
判断任务是否被取消;
判断任务是否已经执行完成;
获取任务执行结果。
简单理解就是:我有一个任务,提交给了 Future
来处理。任务执行期间我自己可以去做任何想做的事情。并且,在这期间我还可以取消任务以及获取任务的执行状态。一段时间之后,我就可以 Future
那里直接取出任务执行结果。
FutureTask
提供了 Future
接口的基本实现,常用来封装 Callable
和 Runnable
,具有取消任务、查看任务是否执行完成以及获取任务执行结果的方法。ExecutorService.submit()
方法返回的其实就是 Future
的实现类 FutureTask
。
FutureTask
不光实现了 Future
接口,还实现了Runnable
接口,因此可以作为任务直接被线程执行。
FutureTask
有两个构造函数,可传入 Callable
或者 Runnable
对象。实际上,传入 Runnable
对象也会在方法内部转换为Callable
对象。
FutureTask
相当于对Callable
进行了封装,管理着任务执行的情况,存储了 Callable
的 call
方法的任务执行结果
多线程实战使用
线程lamda表达式方式启动(简单、常用)
java使用多线程的三种方式:
继承Thread类,并重写run方法。
实现Runnable接口,重写run方法
实现Callable<返回值>接口,重写call方法
线程池的使用
项目里面是怎么使用多线程的?
首先新建任务(实现runnable或callable重写run/call方法,或者继承Tread重写run方法(不太好,因为java只能单继承, 有局限))
然后new Tread把任务传进去直接start // 或者使用线程池把任务直接传入ThreadPoolTaskExecutor或者把任务包装成FutureTask再传入ThreadPoolTaskExecutor中执行excute或submit,若此可以实现 用线程池实现异步调用(在主线程内异步调用)
execute()
,执行一个任务,没有返回值submit()
,提交一个线程任务,有返回值
Spring
通过任务执行器(TaskExecutor
)来实现多线程和并发编程,使用ThreadPoolTaskExecutor
实现一个基于线程池的TaskExecutor
,
Spring 通过任务执行器(TaskExecutor)来实现多线程和并发编程,使用ThreadPoolTaskExecutor实现一个基于线程池的TaskExecutor, 还得需要使用@EnableAsync开启异步,并通过在需要的异步方法那里使用注解@Async声明是一个异步任务
还有一种应用是大量插入时,多线程插入,可以借助countdown?
线程池为什么要使用阻塞队列而不使用非阻塞队列
阻塞队列可以保证任务队列中没有任务时阻塞获取任务的线程,使得线程进入wait状态,释放cpu资源,当队列中有任务时才唤醒对应线程从队列中取出消息进行执行。 使得在线程不至于一直占用cpu资源。(线程执行完任务后通过循环再次从任务队列中取出任务进行执行,代码片段如:while (task != null || (task = getTask()) != null) {})。
线程池的声明周期
Java线程池实现原理及其在美团业务中的实践 - 美团技术团队 (meituan.com)
任务缓冲
任务缓冲模块是线程池能够管理任务的核心部分。线程池的本质是对任务和线程的管理,而做到这一点最关键的思想就是将任务和线程两者解耦,不让两者直接关联,才可以做后续的分配工作。线程池中是以生产者消费者模式,通过一个阻塞队列来实现的。阻塞队列缓存任务,工作线程从阻塞队列中获取任务。
Worker线程:TODO
业务实践
直接改变bean线程池的参数,执行完之后再改回来
追求参数设置合理性?
动态化线程池的核心设计包括以下三个方面:
简化线程池配置:线程池构造参数有8个,但是最核心的是3个:corePoolSize、maximumPoolSize,workQueue,它们最大程度地决定了线程池的任务分配和线程分配策略。考虑到在实际应用中我们获取并发性的场景主要是两种:(1)并行执行子任务,提高响应速度。这种情况下,应该使用同步队列,没有什么任务应该被缓存下来,而是应该立即执行。(2)并行执行大批次任务,提升吞吐量。这种情况下,应该使用有界队列,使用队列去缓冲大批量的任务,队列容量必须声明,防止任务无限制堆积。所以线程池只需要提供这三个关键参数的配置,并且提供两种队列的选择,就可以满足绝大多数的业务需求,Less is More。
参数可动态修改:为了解决参数不好配,修改参数成本高等问题。在Java线程池留有高扩展性的基础上,封装线程池,允许线程池监听同步外部的消息,根据消息进行修改配置。将线程池的配置放置在平台侧,允许开发同学简单的查看、修改线程池配置。
增加线程池监控:对某事物缺乏状态的观测,就对其改进无从下手。在线程池执行任务的生命周期添加监控能力,帮助开发同学了解线程池状态。
线程池应用
线程池
Java线程池并发执行多个任务_micro_hz的博客-CSDN博客**
项目线程池使用
线程池一般根据业务选择不同的线程池,实际测一测看哪个性能比较好。
1.直接在方法内使用ThreadPoolTaskExecutor Spring
任务执行器 异步
Spring
通过任务执行器(TaskExecutor
)来实现多线程和并发编程,使用ThreadPoolTaskExecutor
实现一个基于线程池的TaskExecutor
,
Spring 通过任务执行器(TaskExecutor)来实现多线程和并发编程,使用ThreadPoolTaskExecutor实现一个基于线程池的TaskExecutor,
步骤:
使用线程池把任务直接传入ThreadPoolTaskExecutor或者把任务包装成FutureTask再传入ThreadPoolTaskExecutor中执行excute或submit(有返回值)
需要使用@EnableAsync开启异步
2.**@Async标注该方法会被异步调用**
1.写个配置类自定义线程池ThreadPoolTaskExecutor
@Configuration
public class ThreadPoolConfig {
@Bean(name = "taskPool01Executor")
public ThreadPoolTaskExecutor getTaskPool01Executor() {
ThreadPoolTaskExecutor taskExecutor = new ThreadPoolTaskExecutor();
//核心线程数
taskExecutor.setCorePoolSize(4);
//线程池维护线程的最大数量,只有在缓冲队列满了之后才会申请超过核心线程数的线程
taskExecutor.setMaxPoolSize(10);
//缓存队列
taskExecutor.setQueueCapacity(1000);
//许的空闲时间,当超过了核心线程出之外的线程在空闲时间到达之后会被销毁
taskExecutor.setKeepAliveSeconds(200);
//异步方法内部线程名称
taskExecutor.setThreadNamePrefix("TaskPool-01-");
/**
* 当线程池的任务缓存队列已满并且线程池中的线程数目达到maximumPoolSize,如果还有任务到来就会采取任务拒绝策略
* 通常有以下四种策略:
* ThreadPoolExecutor.AbortPolicy:丢弃任务并抛出RejectedExecutionException异常。
* ThreadPoolExecutor.DiscardPolicy:也是丢弃任务,但是不抛出异常。
* ThreadPoolExecutor.DiscardOldestPolicy:丢弃队列最前面的任务,然后重新尝试执行任务(重复此过程)
* ThreadPoolExecutor.CallerRunsPolicy:重试添加当前的任务,自动重复调用 execute() 方法,直到成功
*/
taskExecutor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());
taskExecutor.setWaitForTasksToCompleteOnShutdown(true);
taskExecutor.initialize();
return taskExecutor;
}
@Bean(name = "taskPool02Executor")
public ThreadPoolTaskExecutor getTaskPool02Executor() {
ThreadPoolTaskExecutor taskExecutor = new ThreadPoolTaskExecutor();
//核心线程数
taskExecutor.setCorePoolSize(10);
//线程池维护线程的最大数量,只有在缓冲队列满了之后才会申请超过核心线程数的线程
taskExecutor.setMaxPoolSize(100);
//缓存队列
taskExecutor.setQueueCapacity(50);
//许的空闲时间,当超过了核心线程出之外的线程在空闲时间到达之后会被销毁
taskExecutor.setKeepAliveSeconds(200);
//异步方法内部线程名称
taskExecutor.setThreadNamePrefix("TaskPool-02-");
/**
* 当线程池的任务缓存队列已满并且线程池中的线程数目达到maximumPoolSize,如果还有任务到来就会采取任务拒绝策略
* 通常有以下四种策略:
* ThreadPoolExecutor.AbortPolicy:丢弃任务并抛出RejectedExecutionException异常。
* ThreadPoolExecutor.DiscardPolicy:也是丢弃任务,但是不抛出异常。
* ThreadPoolExecutor.DiscardOldestPolicy:丢弃队列最前面的任务,然后重新尝试执行任务(重复此过程)
* ThreadPoolExecutor.CallerRunsPolicy:重试添加当前的任务,自动重复调用 execute() 方法,直到成功
*/
taskExecutor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());
taskExecutor.setWaitForTasksToCompleteOnShutdown(true);
taskExecutor.initialize();
return taskExecutor;
}
}
2.在需要异步的方法上使用 @Async(“线程池名称”),指定value使用自己定义的线程池:
@Service
public class TaskDemo {
private static Logger logger = LoggerFactory.getLogger(TaskDemo.class);
@Async
public Future<String> execute1() {
logger.info("处理耗时任务1......开始");
try {
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
logger.info("处理耗时任务1......结束");
return new AsyncResult<>("任务1 ok");
}
@Async
public Future<String> execute2() {
logger.info("处理耗时任务2......开始");
try {
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
logger.info("处理耗时任务2......结束");
return new AsyncResult<>("任务2 ok");
}
@Async
public Future<String> execute3() {
logger.info("处理耗时任务3......开始");
try {
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
logger.info("处理耗时任务3......结束");
return new AsyncResult<>("任务3 ok");
}
}
@RestController
public class TaskController {
private static Logger logger = LoggerFactory.getLogger(TaskController.class);
@Autowired
private TaskDemo taskDemo;
@GetMapping("/task/test")
public String testTask() throws InterruptedException {
Future<String> task1 = taskDemo.execute1();
Future<String> task2 = taskDemo.execute2();
Future<String> task3 = taskDemo.execute3();
while (true){
if (task1.isDone() && task2.isDone() && task3.isDone()){
break;
}
TimeUnit.SECONDS.sleep(1);
}
logger.info(">>>>>>3个任务都处理完成");
return "ok";
}
}
3.CompletableFuture
// 带返回值异步请求,默认线程池
public static <U> CompletableFuture<U> supplyAsync(Supplier<U> supplier)
// 带返回值的异步请求,可以自定义线程池
public static <U> CompletableFuture<U> supplyAsync(Supplier<U> supplier, Executor executor)
可以传入任务和线程池
CompletableFuture<String> cf = CompletableFuture.supplyAsync(() -> {
System.out.println("do something....");
return "result";
}, executorService);
非阻塞回调处理异步结果
CompletableFuture<String> cfuture =
CompletableFuture.supplyAsync(() -> "Krishna").thenApply(data -> "Shri "+ data);
可以thenapply把future结果set进map中
Map<String, Future<Object>> futureTaskMap = new LinkedHashMap<>();
线程池的关闭
在代码中声明临时*线程池一定要shutdown***,如果是结合spring定义的全局公用的线程池,还是不要随便shutdown**。因为你不知道哪个任务因为你的shutdown而无法执行,保留几个核心线程还是很有必要的。
如果局部使用线程池,用完后不再使用它,一定记得手动关闭线程池,否则跑着跑着就内存爆炸崩溃
Future与CompletableFuture
CompletableFuture回调机制的设计与实现_completablefuture等待回调结果-CSDN博客
Java中的Future是一种异步编程的技术,它允许我们在另一个线程中执行任务,并在主线程中等待任务完成后获取结果。Future的实现原理可以通过Java中的两个接口来理解:Future和FutureTask。
Future接口是Java中用于表示异步操作结果的一个接口,它定义了获取异步操作结果的方法get(),并且可以通过isDone()方法查询操作是否已经完成。
在Java 5中引入了FutureTask类,它是一个实现了Future和Runnable接口的类,它可以将一个任务(Runnable或Callable)封装成一个异步操作,通过FutureTask的get()方法可以获取任务执行的结果。
在FutureTask的实现中,主要包括以下几个步骤:
创建一个FutureTask对象,并传入一个任务(Runnable或Callable)。
在另一个线程中执行任务,并将任务执行结果保存在FutureTask中。
在主线程中调用FutureTask的get()方法,如果任务还没有完成,则阻塞当前线程,直到任务完成并返回结果。
FutureTask的get()方法是一个阻塞方法,如果任务还没有完成,则会一直阻塞当前线程,直到任务完成。这个阻塞的过程可以通过一个volatile类型的变量来实现。在任务执行完成后,会调用done()方法通知FutureTask任务已经完成,并且设置执行结果。done()方法会调用FutureTask的回调函数,完成后将执行结果设置到FutureTask中。
需要注意的是,FutureTask并不能保证任务的执行顺序和执行结果,因为任务的执行是由线程池来控制的。如果需要保证任务的执行顺序和结果,可以使用CompletionService和ExecutorCompletionService。
综上所述,Future的实现原理就是通过Future和FutureTask接口,将任务封装成一个异步操作,并在主线程中等待任务完成后获取执行结果。FutureTask是Future的一个具体实现,通过阻塞方法和回调函数来实现异步操作的结果获取。
局限性
虽然Future在Java中提供了一种简单的异步编程技术,但它也存在一些局限性,包括以下几个方面:
阻塞问题:Future的get()方法是一个阻塞方法,如果任务没有完成,会一直阻塞当前线程,这会导致整个应用程序的响应性下降。
无法取消任务:Future的cancel()方法可以用于取消任务的执行,但如果任务已经开始执行,则无法取消。此时只能等待任务执行完毕,这会导致一定的性能损失。
缺少异常处理:Future的get()方法会抛出异常,但是如果任务执行过程中抛出异常,Future无法处理异常,只能将异常抛给调用者处理。
缺少组合操作:Future只能处理单个异步操作,无法支持多个操作的组合,例如需要等待多个任务全部完成后再执行下一步操作。
综上所述,Future虽然提供了一种简单的异步编程技术,但它的局限性也是比较明显的。在实际应用中,我们需要根据具体的业务需求和性能要求,选择合适的异步编程技术。例如,可以使用CompletableFuture来解决Future的一些问题,它可以避免阻塞、支持异常处理和组合操作等功能。
CompletableFuture
CompletableFuture使用详解(全网看这一篇就行)-CSDN博客
JDK1.8新特性CompletableFuture总结-CSDN博客
CompletableFuture是jdk8的新特性。CompletableFuture实现了CompletionStage接口和Future接口,前者是对后者的一个扩展,增加了异步会点、流式处理、多个Future组合处理的能力,使Java在处理多任务的协同工作时更加顺畅便利
原理:
CompletableFuture的原理是基于Java的Future接口和内部的状态机实现的
优点:
CompletableFuture的优势在于它支持链式调用和组合操作。通过CompletableFuture的then系列方法,我们可以创建多个CompletableFuture对象,并将它们串联起来形成一个链式的操作流。在这个操作流中,每个CompletableFuture对象都可以依赖于之前的CompletableFuture对象,以实现更加复杂的异步操作。
用它也可以非阻塞的获取异步操作结果
使用建议
在实际开发中,使用CompletableFuture需要注意以下几个方面:
异常处理:在CompletableFuture中,可以使用exceptionally()方法或handle()方法来处理任务执行过程中的异常,避免异常导致整个应用程序崩溃。异常处理的方式可以根据具体的业务需求来选择。
避免阻塞:CompletableFuture提供了一系列非阻塞的方法,例如thenApplyAsync()、thenAcceptAsync()、thenRunAsync()等,可以在任务执行的过程中不阻塞当前线程,从而提高整个应用程序的响应性能。
组合操作:CompletableFuture支持多个操作的组合,可以使用thenCompose()方法和thenCombine()方法将多个异步操作组合在一起,形成一个任务链,从而避免任务之间的阻塞和顺序问题。
慎用join()方法:在CompletableFuture中,join()方法是一个阻塞方法,如果任务没有完成,则会一直阻塞当前线程。因此,在使用join()方法时需要慎重考虑,避免造成整个应用程序的阻塞。
合理使用线程池:CompletableFuture默认使用ForkJoinPool线程池执行异步任务,如果任务过多或者任务执行时间过长,可能会造成线程池耗尽和任务等待的问题。因此,在使用CompletableFuture时需要根据具体的业务需求和性能要求,选择合适的线程池,并对线程池进行调优和管理。
应用场景
线程池一般在
1.批量任务处理
如导入导出任务,总任务耗时时间长,且各个部分之间相互独立没有依赖关系
2.非核心功能异步化处理
方案:1.mq 2.多线程
3.单接口RT高
尽量把接口内的不相互依赖的任务异步处理,用CompletableFuture 组合编排
比如调用很多外部接口,就可以用CompletableFuture异步并thenapply异步获取结果