本文主要是介绍并发编程(已整理,已废弃),希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!
这一块知识,那真是有的啃了。
直接先看速成基础,再直接吃掉高频考点。
每个小知识点,直接看短视频,浅浅了解,在写下来就是自己的资料。
# 基础
一个进程有多个线程,多个线程共享进程的堆和方法区,每个线程独有PC、VM Stack、NM Stack
## 为什么程序计数器是线程私有的?
程序计数器主要有俩个作用:
- 字节码解释器通过改变程序计数器来依次读取指令从而实现代码的流程控制;
- 记录当前线程执行的位置。
所以程序计数器私有主要是为了线程切换后能正确恢复到原来·的执行位置。
## 虚拟机栈和本地方法栈为什么是线程私有的?
- 虚拟机栈:每个Java方法在执行前会创建一个栈帧用来存储局部变量、操作数栈、常量池引用等信息。方法调用-完成对应着一个栈帧在Java虚拟机栈中出栈入栈。
- 本地方法栈:与虚拟机栈作用类似,虚拟机栈为虚拟机执行Java方法服务,而本地方法栈为虚拟机使用到的Native方法服务。在HotSpot中虚拟机栈与本地方法栈合二为一。
所以虚拟机栈与本地方法栈私有是为了保证线程中的局部变量不被其他线程访问到。
一个线程可以调用多个方法,而一个方法又可以被多个线程调用
- 堆:进程中最大的一块内存,主要用于存放新创建的对象
- 方法区:主要用于存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码。
- 进程:操作系统分配资源的最小单元。
- 线程:操作系统调度的最小单元
- 并发:两个及以上任务在同一时间段执行
- 并行:两个及以上任务在同一时刻执行
- 同步:发出一个调用,在没有得到结果之前,该调用就不可以返回,一直等待
- 异步:调用发出后,不用等待返回结果,该调用直接返回
- 线程安全与非线程安全:多线程环境下对于同一份数据的读写是否能保证其正确性和一致性。
- 内核线程:由操作系统内核管理和调度的线程,运行在内核空间(仅内核程序可访问)
- 用户线程:由用户空间程序管理和调度的线程,运行在用户空间(专门app使用)
- 区别:用户线程创建切换成本低,但不支持多核;内核线程创建切换成本高,可支持多核。
## 单核CPU运行多个线程效率一定会高吗?
CPU密集型线程主要进行计算和逻辑处理,需要占用大量CPU资源;IO密集型线程主要进行大量输入输出如读写文件、网络通信等,需等待IO设备相应,而不用一直占用CPU。
因此对于CPU密集型任务,那么开多线程需要频繁线程切换影响效率;对于IO密集型任务,开多线程会提高效率,当然也不能超过系统上限。
## 线程的生命周期和状态?
- NEW: 初始状态,线程被创建出来但没有被调用
start()
。 - RUNNABLE: 运行状态,线程被调用了
start()
等待运行的状态。 - BLOCKED:阻塞状态,需要等待锁释放。
- WAITING:等待状态,表示该线程需要等待其他线程做出一些特定动作(通知或中断)。
- TIME_WAITING:超时等待状态,可以在指定的时间后自行返回而不是像 WAITING 那样一直等待。
- TERMINATED:终止状态,表示该线程已经运行完毕。
正常横向过程:线程创建之后处于NEW状态,调用start()方法开始运行,这时处于READY状态,当此线程获得了CPU时间片后就处于RUNNING状态,执行完run()方法后就终止。
进入等待状态:线程执行wait()方法后,线程进入WAITING状态,依靠其他线程通知返回运行状态
进入超时等待状态:在等待基础上增加超市限制,sleep()、wait()执行后进入,超时时间结束后线程将返回RUNNING状态。
进入阻塞状态:进入synchronized方法或调用wait(),但锁被其他线程占有,线程就进入阻塞状态
## 简述一下线程的上下文切换?
上下文:线程在执行过程中的运行条件和状态信息。(比如程序计数器、栈信息等)
上下文切换:(保存 -> 加载) 当发生线程切换时,需要保存当前线程的上下文,留待下次线程占用CPU时恢复现场,并加载下一个将要占用CPU的线程的上下文。
上下文切换条件:
- 主动让出CPU(如调用了sleep()、wait()等)
- 时间片用完(防止长时间占用CPU导致其他线程或进程饿死)
- 调用了阻塞类型的系统中断(如请求IO、线程被阻塞)
- 被终止或结束运行
## 什么是线程死锁?如何避免线程死锁?
线程死锁:两个及以上线程在执行过程中,因争夺资源而造成互相等待的现象,无外力作用下这些线程将一直相互等待无法继续运行。
线程死锁四个条件:
- 互斥条件:该资源任意时刻只由一个线程占用
- 请求与保持条件:一个线程因请求资源而阻塞时,不释放已占有的资源
- 不剥夺条件:线程已获得的资源未使用完前不能被其他线程剥夺,只有自己使用完后才释放
- 循环等待条件:若干线程之间形成一种头尾相接的循环等待资源关系
如何避免线程死锁? -- 至少破坏死锁发生的一个条件
- 互斥条件:无法破坏,因为使用锁的目的就是互斥
- 请求与保持条件:可以一次性请求所有资源
- 不可剥夺:设置超时。已占有资源的线程请求其他资源若长时间请求不到,超时释放已占有的资源环路等待:注意加锁顺序,保证每个线程按同样的顺序进行加锁。
## sleep()方法与wait()方法区别?
共同点:都是让线程阻塞等待
- wait()会释放对象的锁,sleep()不会释放对象的锁
- wait()通常被用于线程间交互/通信,sleep()用于使当前线程暂停执行
- wait()是Object类的本地方法;sleep()是Thread类中的静态本地方法
- wait()方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的 notify()或者
- notifyAll()方法。sleep()执行完成后,线程会自动苏醒,或者也可以使用 wait(long timeout)超时后线程会自动苏醒。
## 为啥wait()定义Object类中sleep()定义在Thread中?
wait()是让获得对象锁的线程实现等待,自动释放当前线程占有的对象锁。每个Object类的对象都有对象锁,既然要释放当前线程占有的对象锁并让其进入WAITING状态,自然要操作对应的对象(Object)而不是当前的线程(Thread)
sleep()方法是让当前线程暂停执行,不涉及对象类,也不需要获得对象锁。
## 启动线程为啥用start()不是用run()?
基本知识:start()方法会在新的线程中执行run() 方法对应的内容,run()方法只在当前线程中执行
NEW一个线程,线程进入了新建状态。调用start()方法,会启动该线程进入就绪状态,当分配到时间片后就可以执行。start()执行线程相应的准备工作,然后自动执行run()对应的内容,这是多线程工作。
但直接执行run()会把run()方法当成一个main线程下的普通方法执行,并不会在新建的线程中执行
# 高阶
## 锁机制
### 什么是锁?
并发带来的数据不一致问题:在并发环境下,多个线程会对同一个资源进行争抢,就会导致数据不一致的问题。
为了解决此问题,就引入锁机制。通过一种抽象的锁来对资源进行锁定。
对于线程私有的程序计数器、虚拟机栈、本地方法栈不存在数据竞争,数据能够保证正确性唯一性,是线程安全的。
但对于堆、方法区是线程共享的,就会存在线程安全问题,因此引入锁机制。
锁三大类型:
锁大致可以分为互斥锁、共享锁、读写锁(在读读下是共享锁,读写、写写下是互斥锁)
## JMM
### 讲讲什么是JMM?
Java内存模型规定
- 所有变量都存储在主内存中,包括实例变量、静态变量,但不含局部变量和方法参数。
- 每个线程都有自己的工作内存,工作内存保存了该线程用到的变量和主内存的副本,线程对变量的操作都在工作内存中进行。线程不能直接读写主内存的变量。
- 不同线程之间也无法访问对方工作内存中的变量。线程之间变量值的传递均需要通过主内存来完成。
### 为什么需要JMM?
在不同硬件厂商和不同操作系统下,内存的访问有一定的差异,会造成同一套代码运行在不同系统上会出现各种问题。所以JMM屏蔽掉各种硬件和操作系统的内存访问差异,以实现Java程序在各种平台下都能达到一致的并发效果。
### 重排序-内存屏障概念
指令重排序
- 定义:出于优化的目的,对实际执行的指令的顺序进行调整。
指令重排序三种情况:
- 编译器重排序:在不改变单线程程序语义情况下,对代码语句顺序进行调整;
- 处理器重排序:CPU采用指令级并行技术将多条指令重叠执行,若不存在数据依赖性,CPU可改变语句对应的机器指令执行顺序;
- 内存重排序:并不是严格意义上的重排序。CPU缓存使用缓存区进行延迟写入,这个过程造成多个CPU缓存可见性问题,可见性问题导致对于指令的先后执行显示不一致,从结果表面看起来好像指令的顺序被改变了。
禁止重排序方式:
- 编译器重排序 -> 通过禁止特定类型编译器重排序来禁止重排序
- 处理器重排序 -> 通过插入内存屏障来禁止特定类型处理器重排序
内存屏障:一种CPU指令。用来禁止处理器指令发生重排序,从而保障指令执行的有序性;在处理器写入、读取值之前将主内存的值写入高速缓存,清空无效队列,从而保障变量的可见性。
四大类内存屏障:
- LoadLoad 屏障:对于这样的语句Load1,LoadLoad,Load2。在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
- StoreStore屏障:对于这样的语句Store1, StoreStore, Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
- LoadStore 屏障:对于这样的语句Load1, LoadStore,Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
- StoreLoad 屏障:对于这样的语句Store1, StoreLoad,Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。
### 谈谈volatile关键字?
volatile关键字主要有两个作用:
- 一是保证多线程环境下共享变量的可见性
- 二是通过插入内存屏障来禁止多个指令之间的重排序
### Java并发三特性
1、原子性
一个或多个操作,要么全部执行且执行过程中不会被任何因素打断,要么就都不执行。
经典案例:银行转账问题,涉及A账户减少,B账户增加,要么同时执行,要么都不执行,整个转账过程算做一个原子操作。
实现原子性方式:
synchronized、Lock以及各种原子类。
synchronized和Lock可以保证任意时刻只有一个线程访问该代码块,因此可保证原子性;各种原子类是利用CAS操作来保证原子操作。
2、可见性
当一个线程对主内存中的共享变量进行了修改,其他线程可立即看到修改后的最新值。
实现可见性方式:
借助synchronized、volatile以及各种Lock。
volatile修饰的变量,当一个线程改变了该变量的值,其他线程是立即可见的。普通变量则需要重新读取才能获得最新值。
3、有序性
Java内存模型中,允许编译器和处理器对指令进行重排序。重排序不会影响单线程程序的执行,却会影响多线程并发执行的正确性。
实现有序性方式:
synchronized、Lock、volatile关键字。
synchronized和Lock保证每个时刻只有一个线程执行同步代码,相当于是让线程顺序执行同步代码。
### JMM八种内存交互操作
- lock(锁定),作用于主内存中的变量,把变量标识为线程独占的状态。
- read(读取),作用于主内存的变量,把变量的值从主内存传输到线程的工作内存中,以便下一步的load操作使用。
- load(加载),作用于工作内存的变量,把read操作主存的变量放入到工作内存的变量副本中。
- use(使用),作用于工作内存的变量,把工作内存中的变量传输到执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作。
- assign(赋值),作用于工作内存的变量,它把一个从执行引擎中接受到的值赋值给工作内存的变量副本中,每当虚拟机遇到一个给变量赋值的字节码指令时将会执行这个操作。
- store(存储),作用于工作内存的变量,它把一个从工作内存中一个变量的值传送到主内存中,以便后续的write使用。
- write(写入):作用于主内存中的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。
- unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
read后必须load;store后必须write
### Java内存结构和JMM区别?
Java内存结构和Java虚拟机运行时区域有关,定义了JVM运行时如何分区存储程序数据。
Java内存模型与Java并发编程相关,抽象了线程和主内存的关系。目的是简化多线程编程增强程序可移植性。
### happens-before 原则
意义:前一个操作的结果对后一个操作是可见的,无论俩个操作是否在同一个线程。
定义:
- 如果一个操作 happens-before 另一个操作,那么第一个操作的执行结果将对第二个操作可见,并且第一个操作的执行顺序排在第二个操作之前。
- 两个操作之间存在 happens-before 关系,并不意味着 Java 平台的具体实现必须要按照 happens-before 关系指定的顺序来执行。如果重排序之后的执行结果,与按 happens-before 关系来执行的结果一致,那么 JMM 也允许这样的重排序。
常见规则:
- 程序顺序规则:在一个线程中,按照代码的顺序,前面的操作Happens-Before于后面的任意操作。
- 解锁规则:对一个锁的解锁操作 Happens-Before于后续对这个锁的加锁操作。
- volatile 变量规则:对一个 volatile 变量的写操作 happens-before 于后面对这个 volatile 变量的读操作。说白了就是对 volatile 变量的写操作的结果对于发生于其后的任何操作都是可见的。
- 传递规则:如果 A happens-before B,且 B happens-before C,那么 A happens-before C;
- 线程启动规则:Thread 对象的
start()
方法 happens-before 于此线程的每一个动作。
如果两个操作不满足上述任意一个 happens-before 规则,那么这两个操作就没有顺序的保障,JVM 可以对这两个操作进行重排序。
## Synchronized
synchronized理解为加锁,而不是锁。这样更好理解线程同步。
### synchronized是什么?作用?
synchronized就是同步的意思,是Java中一个关键字。主要用于解决多线程之间访问资源的同步性,保证被他修饰的方法或代码块在任意时刻只有一个线程执行。
- Synchronized使用了内置锁monitor(也称为监视器锁)来实现同步。每个Java对象都有一个内置锁,当该对象作为锁被获取时,其他试图获取该锁的线程会被阻塞,直到该锁被释放。
- Synchronized的锁是与对象相关联的。当一个线程进入Synchronized代码块时,它必须先获取该对象的锁才能执行代码,否则就会被阻塞。当该线程退出Synchronized代码块时,它会自动释放该对象的锁。
- Synchronized具有可重入性。如果当前线程已经获得了某个对象的锁,那么它可以继续访问该对象的其他Synchronized代码块,而不会被自己持有的锁所阻塞。
- Synchronized还具有volatile变量的读写语义。在使用Synchronized关键字时,内存屏障会确保本地线程中修改过的变量值被刷新回主内存,从而保证了多个线程之间对变量修改的可见性。
Synchronized通过使用内置锁、与对象关联的锁、可重入性以及内存屏障等机制来实现线程的同步和锁的管理,以保证对共享资源的访问具有互斥性和可见性。
synchronize会根据锁竞争情况,从偏向锁-->轻量级锁-->重量级锁升级
### 如何使用 synchronized?
普通方法 :锁对象是this,所谓的方法锁(本质上属于对象锁)
也就是多个线程访问方法say()会有锁的限制
public synchronized void say(){ //对方法say()加锁System.out.println("Hello,everyone...");
}
同步代码块(方法中):锁对象是synchronized(obj)的对象,所谓的对象锁
public void say(boolean isYou){ //对对象obj加锁synchronized (obj){System.out.println("Hello");}}
同步静态方法:锁对象是当前类的Class对象,即(XXX.class),所谓的类锁
public static synchronized void work(){ //对类work加锁System.out.println("Work hard...");}
## synchronized与Lock区别?
- 实现方式:Synchronized是Java语言内置的关键字,而Lock是一个Java接口。
- 锁的获取和释放:Synchronized是隐式获取和释放锁,由Java虚拟机自动完成;而Lock需要显式地调用lock()方法获取锁,并且必须在finally块中调用unlock()方法来释放锁。
- 可中断性:在获取锁的过程中,如果线程被中断,synchronized会抛出InterruptedException异常并且自动释放锁,而Lock则需要手动捕获InterruptedException异常并处理,同时也支持非阻塞、可轮询以及定时获取锁的方式。
- 公平性:Synchronized不保证线程获取锁的公平性,而Lock可以通过构造函数指定公平或非公平锁。
- 锁状态:Synchronized无法判断锁的状态,而Lock可以通过tryLock()、isLocked()来判断锁的状态(线程是否可能取到锁、锁是否被占用等)。
- 粒度:Synchronized锁的粒度较粗,只能锁住整个方法或代码块,而Lock可以细粒度地控制锁的范围,比如锁某个对象的部分属性。
- 场景:如果在简单的并发场景下,推荐使用Synchronized;而在需要更高级的锁控制时,可以考虑使用Lock。
乐观锁和悲观锁是两种思想,用于解决并发场景下的数据竞争问题。
悲观锁的实现方式是加锁,加锁既可以是对代码块加锁(如Java的synchronized关键字),也可以是对数据加锁(如MySQL中的排它锁)
## 悲观锁
悲观锁:悲观锁在操作数据时比较悲观,认为别人会同时修改数据。因此操作数据时直接把数据锁住,直到操作完成后才会释放锁;上锁期间其他人不能修改数据。
Java 中synchronized
和ReentrantLock
等独占锁就是悲观锁思想的实现。
public void performSynchronisedTask() {synchronized (this) {// 需要同步的操作}
}
private Lock lock = new ReentrantLock();
lock.lock();
try {// 需要同步的操作
} finally {lock.unlock();
}
### 对象结构
对象结构:对象头 + 实例数据 + 对齐填充字节
- 对象头:对象本身运行时信息(包含俩部分:Mark Word + Class Pointer)
- 实例数据:初始化对象设置的属性、状态等信息
- 对齐填充字节:为满足“Java对象大小是8字节的倍数”而设计
- Mark Word存储当前对象运行时状态信息,如HashCode、锁状态标志、指向锁记录的指针、偏向线程ID、锁标志位等
- Class Pointer是一个指针,指向当前对象类型所在方法区中的Class信息
## 乐观锁
乐观锁总是假设最好的情况,认为共享资源每次被访问的时候不会出现问题,线程可以不停地执行,无需加锁也无需等待,只是在提交修改的时候去验证对应的资源(也就是数据)是否被其它线程修改了。
### 实现乐观锁
版本号机制
读取version -> 操作 -> 验证当前version ->新旧同则更新,不同则重试
一般是在数据表中加上一个数据版本号 version
字段,表示数据被修改的次数。当数据被修改时,version
值会加一。
当线程 A 要更新数据值时,在读取数据的同时也会读取 version
值,在提交更新时,若刚才读取到的 version 值为当前数据库中的 version
值相等时才更新,否则重试更新操作,直到更新成功
#### CAS算法
Compare And Swap本质:用一个预期值与要更新的变量值比较,两值相等才会更新。
CAS是一个原子操作,底层依赖于一条CPU的原子指令。
(原子操作:最小不可拆分操作,操作一旦开始,就不能被打断,直到操作完成)
CAS涉及到三个操作数:
- V: 要更新的变量值(Var)
- E: 预期值
- N: 拟写入的新值
当且仅当V的值等于E时,CAS通过原子方式用新值N来更新变量值V。如果不等,说明已经有其他线程更新了变量值V,则当前线程放弃更新。
### 乐观锁存在的问题
ABA问题
如果一个变量V初次读取值为A,在准备赋值时检查到仍然是A,在这期间不能保证他的值是否被修改过最后又修改成了A。
解决ABA方式:在变量前追加版本号或时间戳。AtomicStampedReference类的compareAndSet()方法就是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志。全部相等则以原子方式将该变量和标志的值设置为给定的更新值。
循环时间开销大
CAS使用自旋操作来重试,若长时间不成功会给CPU带来很大的执行开销。
解决循环时间开销大:若JVM能支持处理器提供的pause指令则效率会有一定的提升。
pause作用:
- 延迟流水线执行指令,使CPU不会消耗过多的执行资源
- 避免在退出循环时因内存顺序冲突引起CPU流水线被清空,从而提高CPU执行效率
只能保证一个共享变量的原子操作
CAS只对单个共享变量有效,当操作涉及跨多个共享变量时CAS无效。
解决方式:引入AtomicReference类保证引用对象之间的原子性
使用锁或利用AtomicReference类把多个共享变量合并成一个共享变量来操作。
## 线程池
池化技术:通过复用对象、连接等资源,减少创建对象/连接,降低垃圾回收(GC)的开销,适当使用池化相关技术能够显著提高系统效率,优化性能。
### 为什么要使用线程池?
线程池管理线程好处:
- 降低资源消耗。通过复用已存在的线程和降低线程关闭的次数来尽可能降低系统性能损耗;
- 提升系统响应速度。通过复用线程,省去创建线程的过程,因此整体提升了系统响应速度;
- 提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,因此,需要使用线程池来管理线程。
### 线程池工作原理?
当一个并发任务提交给线程池,线程池分配线程去执行任务:(三阶段)
1、先判断线程池中核心线程池所有线程是否都在执行任务。没有则新创建一个线程执行刚提交的任务,否则,核心线程池中所有线程都在执行任务,进行下一步判断;
2、判断当前阻塞队列是否已满,未满则将提交的任务添加到阻塞队列中,否则进行下一步判断;
3、判断线程池中所有线程是否都在执行任务,没有则创建一个新的线程执行任务,否则交给饱和策略处理。
源码流程:
- 如果当前运行的线程少于corePoolSize,则会创建新的线程来执行新的任务;
- 如果运行的线程个数大于等于corePoolSize,则将提交的任务存放阻塞队列workQueue中;
- 如果当前workQueue队列已满的话,则会创建新的线程来执行任务;
- 如果线程个数已经超过了maximumPoolSize,则会使用饱和策略RejectedExecutionHandler来进行处理。
### 线程池参数有哪些?
创建线程池主要是ThreadPoolExecutor类完成,ThreadPoolExecutor的构造方法为:
ThreadPoolExecutor(int corePoolSize,int maximumPoolSize,long keepAliveTime,TimeUnit unit,BlockingQueue<Runnable> workQueue,ThreadFactory threadFactory,RejectedExecutionHandler handler)
- corePoolSize:表示核心线程池的大小。当提交一个任务时,如果当前核心线程池的线程个数没有达到corePoolSize,则会创建新的线程来执行所提交的任务,即使当前核心线程池有空闲的线程。如果当前核心线程池的线程个数已经达到了corePoolSize,则不再重新创建线程。如果调用了
prestartCoreThread()
或者prestartAllCoreThreads()
,线程池创建的时候所有的核心线程都会被创建并且启动。- workQueue:阻塞队列。用于保存任务的阻塞队列,可以使用ArrayBlockingQueue, LinkedBlockingQueue, SynchronousQueue, PriorityBlockingQueue。
- maximumPoolSize:表示线程池能创建线程的最大个数。如果当阻塞队列已满时,并且当前线程池线程个数没有超过maximumPoolSize的话,就会创建新的线程来执行任务。
- keepAliveTime:空闲线程存活时间。如果当前线程池的线程个数已经超过了corePoolSize,并且线程空闲时间超过了keepAliveTime的话,就会将这些空闲线程销毁,这样可以尽可能降低系统资源消耗。
- unit:时间单位。为keepAliveTime指定时间单位。
- threadFactory:创建线程的工程类。可以通过指定线程工厂为每个创建出来的线程设置更有意义的名字,如果出现并发问题,也方便查找问题原因。
- handler:饱和策略。当线程池的阻塞队列已满和指定的线程都已经开启,说明当前线程池已经处于饱和状态了,那么就需要采用一种策略来处理这种情况。
### 如何动态修改线程池参数?
ThreadPollExecutors三个重要的参数为corePoolSize、workQueue、maximumSize,这三个参数基本决定了线程池对于任务的处理策略。
ThredPoolExecutors提供了Set方法动态修改线程池参数
没有动态指定队列长度的方法,美团的方式是自定义了一个叫做 ResizableCapacityLinkedBlockIngQueue
的队列(主要就是把LinkedBlockingQueue
的 capacity 字段的 final 关键字修饰给去掉了,让它变为可变的)。
### 线程池常用的阻塞队列谈谈?
不同的线程池会选用不同的阻塞队列,主要有无界队列、同步队列、延迟阻塞队列
### 线程池饱和策略有哪些?
ThreadPoolExecutor主要有以下四种方法处理线程饱和:
- AbortPolicy: 直接拒绝所提交的任务,并抛出RejectedExecutionException异常;
- CallerRunsPolicy:只用调用者所在的线程来执行任务;
- DiscardPolicy:不处理直接丢弃掉任务;
- DiscardOldestPolicy:丢弃掉阻塞队列中存放时间最久的任务,执行当前任务
### 如何合理分配线程池参数?
- 任务的性质:CPU密集型任务,IO密集型任务和混合型任务。
- 任务的优先级:高,中和低。
- 任务的执行时间:长,中和短。
- 任务的依赖性:是否依赖其他系统资源,如数据库连接。
任务性质不同的任务可以用不同规模的线程池分开处理。CPU密集型任务配置尽可能少的线程数量(N+1)。IO密集型任务则由于需要等待IO操作,线程并不是一直在执行任务,则配置尽可能多的线程(默认2N,N为CPU 核心数)。混合型的任务,如果可以拆分,则将其拆分成一个CPU密集型任务和一个IO密集型任务,只要这两个任务执行的时间相差不是太大,那么分解后执行的吞吐率要高于串行执行的吞吐率,如果这两个任务执行时间相差太大,则没必要进行分解。我们可以通过Runtime.getRuntime().availableProcessors()
方法获得当前设备的CPU个数。
优先级不同的任务可以使用优先级队列PriorityBlockingQueue来处理。它可以让优先级高的任务先得到执行,需要注意的是如果一直有优先级高的任务提交到队列里,那么优先级低的任务可能永远不能执行。
执行时间不同的任务可以交给不同规模的线程池来处理,或者也可以使用优先级队列,让执行时间短的任务先执行。
依赖数据库连接池的任务,因为线程提交SQL后需要等待数据库返回结果,如果等待的时间越长CPU空闲时间就越长,那么线程数应该设置越大,这样才能更好的利用CPU。
并且,阻塞队列最好是使用有界队列,如果采用无界队列的话,一旦任务积压在阻塞队列中的话就会占用过多的内存资源,甚至会使得系统崩溃。
### 为什么不推荐使用内置线程池?
使用jdk自带的Executors创建线程池,存在资源耗尽的风险。
FixedThreadPool
和SingleThreadExecutor
:使用的是无界的LinkedBlockingQueue
,任务队列最大长度为Integer.MAX_VALUE
,可能堆积大量的请求,从而导致 OOM。CachedThreadPool
:使用的是同步队列SynchronousQueue
, 允许创建的线程数量为Integer.MAX_VALUE
,如果任务数量过多且执行速度较慢,可能会创建大量的线程,从而导致 OOM。ScheduledThreadPool
和SingleThreadScheduledExecutor
: 使用的无界的延迟阻塞队列DelayedWorkQueue
,任务队列最大长度为Integer.MAX_VALUE
,可能堆积大量的请求,从而导致 OOM。
### 创建线程池方式?
主要有俩种:
一、ThreadPoolExecutor构造函数:最原始的创建线程池的⽅式,它包含了 7 个参数可供设置
二、Execute框架的Executors工具类
- Executors.newFixedThreadPool:创建⼀个固定⼤⼩的线程池,可控制并发的线程数,超出的线程会在队列中等待;
- Executors.newCachedThreadPool:创建⼀个可缓存的线程池,若线程数超过处理所需,缓存⼀段时间后会回收,若线程数不够,则新建线程;
- Executors.newSingleThreadExecutor:创建单个线程数的线程池,它可以保证先进先出的执⾏顺序;
- Executors.newScheduledThreadPool:创建⼀个可以执⾏延迟任务的线程池;
- Executors.newSingleThreadScheduledExecutor:创建⼀个单线程的可以执⾏延迟任务的线程池;
- Executors.newWorkStealingPool:创建⼀个抢占式执⾏的线程池(任务执⾏顺序不确定)
### 线程池关闭
俩个方法:shutdown和shutdownNow
共同点:遍历线程池中所有线程,依次中断线程
不同点:
- shutdown将线程池的状态设为SHUTDOWN,中断所有空闲线程,将正在执行的任务继续执行完
- shutdownNow将线程池状态设为STOP,停止所有执行任务或空闲线程,返回等待执行任务的列表
- 调用俩方法任意一个,isShutdown均返回true,所有线程都关闭成功调用isTerminated才返回true
想要真正搞懂线程池->
绝对的好文章
如何设置线程池参数?美团给出了一个让面试官虎躯一震的回答。
Java线程池实现原理及其在美团业务中的实践 - 美团技术团队
## AQS
### AQS是什么?
AQS 就是一个抽象类,java.util.concurrent.locks
包下,主要用来构建锁和同步器
public abstract class AbstractQueuedSynchronizer extends AbstractOwnableSynchronizer implements java.io.Serializable {}
### 谈谈AQS工作原理?
定义了一套多线程访问共享资源的同步器框架
AQS维护一个volatile int state变量 + CLH虚拟双向队列
state变量:
- 表示同步状态;
- 使用volatile关键字修饰state保证线程可见性;
- 状态信息
state
可以通过protected
类型的getState()
、setState()
和compareAndSetState()
进行操作。并且,这几个方法都是final
修饰的,在子类中无法被重写。
CLH虚拟双向队列:
- 仅存在节点之间关联关系,不存在队列实例;
- AQS 是将每条请求共享资源的线程封装成一个 CLH 锁队列的一个结点(Node)来实现锁的分配;
- CLH 同步队列中,一个节点表示一个线程,它保存着线程的引用(thread)、 当前节点在队列中的状态(waitStatus)、前驱节点(prev)、后继节点(next);
举例:
- 以
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提供的俩种锁机制
排他锁:ReentrantLock重入锁
共享锁:CountDownLatch、Semaphore
也就是资源共享方式:独占(Exclusive)、共享(Share)
面试:谈谈对AQS的理解?
AQS 是多线程同步器,它是 J.U.C 包中多个组件的底层实现,如 Lock、 CountDownLatch、Semaphore 等都用到了 AQS. 从本质上来说,AQS 提供了两种锁机制,分别是排它锁和共享锁。 排它锁,就是存在多线程竞争同一共享资源时,同一时刻只允许一个线程访问该 共享资源,也就是多个线程中只能有一个线程获得锁资源,比如 Lock 中的 ReentrantLock 重入锁实现就是用到了 AQS 中的排它锁功能。 共享锁也称为读锁,就是在同一时刻允许多个线程同时获得锁资源,比如 CountDownLatch 和 Semaphore 都是用到了 AQS 中的共享锁功能。
设计AQS整个体系需要解决的三个核心的问题:①互斥变量的设计以及多线程同时更新互斥变量时的安全性②未竞争到锁资源的线程的等待以及竞争到锁资源的线程释放锁之后的唤醒③锁竞争的公平性和非公平性。
AQS采用了一个int类型的互斥变量state用来记录锁竞争的一个状态,0表示当前没有任何线程竞争锁资源,而大于等于1表示已经有线程正在持有锁资源。一个线程来获取锁资源的时候,首先判断state是否等于0,如果是(无锁状态),则把这个state更新成1,表示占用到锁。此时如果多个线程进行同样的操作,会造成线程安全问题。AQS采用了CAS机制来保证互斥变量state的原子性。未获取到锁资源的线程通过Unsafe类中的park方法对线程进行阻塞,把阻塞的线程按照先进先出的原则加入到一个双向链表的结构中,当获得锁资源的线程释放锁之后,会从双向链表的头部去唤醒下一个等待的线程再去竞争锁;另外关于公平性和非公平性问题,AQS的处理方式是,在竞争锁资源的时候,公平锁需要判断双向链表中是否有阻塞的线程,如果有,则需要去排队等待;而非公平锁的处理方式是,不管双向链表中是否存在等待锁的线程,都会直接尝试更改互斥变量state去竞争锁。
Synchronized、ReentrantLock均一次只允许一个线程访问某个资源。使用比较单一。主要讨论的就是这三个同步工具类/共享锁:Semaphore、CountDownLatch、CyclicBarrier。
### Semaphore作用?
作用:控制同时访问特定资源的线程数量。
//初始共享资源的线程数量为5
final Semaphore semaphore = new Semaphore(5);
//获得1个许可
semaphore.acquire();
//释放1个许可
semaphore.release();
假设有N个线程来获取Semaphore中的共享资源,上面代码中只有5个线程能获取到共享资源,其他线程都会阻塞,只有获取到共享资源的线程才能继续执行。等到有线程释放了共享资源,其他阻塞的线程才能获取到。
当初始的线程资源个数为1时,Semaphore退化为排他锁。
使用场景:
Semaphore
通常用于那些资源有明确访问数量限制的场景比如限流(仅限于单机模式,实际项目中推荐使用 Redis +Lua 来做限流)。
### Semaphore原理?
Semaphore有俩种模式:
- 公平模式:调用acquire()方法的顺序就是获取许可证的顺序,遵循FIFO;
- 非公平模式:抢占式的
Semaphore是共享锁的一种实现。默认构造AQS的state值为permits,可将permits值理解为许可证数量,只有获得许可证的线程才能执行。
调用semaphore.acquire(),线程尝试获取许可证:
- 如果state >= 0,表示获取成功。然后使用CAS操作修改state的值state = state - 1
- 如果state < 0,表示许可证数量不足。然后创建一个Node节点加入阻塞队列,挂起当前线程
调用semaphore.release(),线程尝试释放许可证:
- 使用CAS操作修改state = state + 1.释放许可证成功后,同时会唤醒同步队列中的一个线程。被唤醒的线程会重新尝试修改state值,state = state - 1。如果state >= 0则获取许可证成功,否则重新进入阻塞队列,挂起线程。
面试:
Semaphore
是共享锁的一种实现。它默认构造 AQS 的 state
为 permits
。当执行任务的线程数量超出 permits
,那么多余的线程将会被放入等待队列 Park
,并自旋判断 state
是否大于 0。只有当 state
大于 0 的时候,阻塞的线程才能继续执行,此时先前执行任务的线程继续执行 release()
方法,release()
方法使得 state 的变量加 1,那么自旋的线程便会判断成功。 如此,每次只有最多不超过 permits
数量的线程能自旋成功,便限制了执行任务线程的数量。
### CountDownLatch作用?
实现一个或多个线程等待其他线程完成某个操作后再继续执行。
CountDownLatch
允许 count
个线程阻塞在一个地方,直至所有线程的任务都执行完毕。
CountDownLatch
是一次性的,计数器的值只能在构造方法中初始化一次,之后没有任何机制再次对其设置值,当 CountDownLatch
使用完毕后,它不能再次被使用。
### CountDownLatch原理?
共享锁的一种实现。默认构造AQS的state值为count。
当线程使用countDown()方法时,本质使用了tryReleaseShared()方法以CAS的操作来减少state,直至state为0.
当调用await()方法时,如果state不为0,说明任务还没有执行完毕,await()会一直阻塞,即await()方法后的语句都不会被执行。
直到count个线程调用了countDown()是state值减为0,或者await()线程被中断,该线程才会从阻塞中被唤醒,await()之后的语句得到执行。
### CountDownLatch俩种实例
- 某一线程在开始运行前等待 n 个线程执行完毕 : 将
CountDownLatch
的计数器初始化为 n (new CountDownLatch(n)
),每当一个任务线程执行完毕,就将计数器减 1 (countdownlatch.countDown()
),当计数器的值变为 0 时,在CountDownLatch 上 await()
的线程就会被唤醒。一个典型应用场景就是启动一个服务时,主线程需要等待多个组件加载完毕,之后再继续执行。 - 实现多个线程开始执行任务的最大并行性:注意是并行性,不是并发,强调的是多个线程在某一时刻同时开始执行。类似于赛跑,将多个线程放到起点,等待发令枪响,然后同时开跑。做法是初始化一个共享的
CountDownLatch
对象,将其计数器初始化为 1 (new CountDownLatch(1)
),多个线程在开始执行任务前首先coundownlatch.await()
,当主线程调用countDown()
时,计数器变为 0,多个线程同时被唤醒。
### CyclicBarrier 有什么用?
当计数器的值还未到达时,线程都会阻塞等待,直到达到设定的数值,所有的线程都会被释放
让一组线程都到达屏障(同步点)之后,屏障才会打开,所有被拦截的线程才会工作。
### CyclicBarrier 的原理是什么?
基于ReentrantLock和Condition实现
CyclicBarrier
内部通过一个 count
变量作为计数器,count
的初始值为 parties
属性的初始化值,每当一个线程到了栅栏这里了,那么就将计数器减 1。如果 count 值为 0 了,表示这是这一代最后一个线程到达栅栏,就尝试执行我们构造方法中输入的任务。
### CycliBarriar和CountdownLatch有什么区别?
1、触发条件不同
CyclicBarrier在等待的线程数量达到指定值时,会触发一个屏障操作,所有的线程都会被释放
CountDownLatch通过计数器来触发等待操作,计数器的初始值为等待的线程数量,每当一个线程完成任务后,计数器减1,直到计数器为0时,所有等到的线程将被释放
2、重用性不同
CyclicBarrier可以被重用。可以通过reset()方法重置CyclicBarrier的状态
CountDownLatch不能被重用。一旦计数器减为0就不能再使用
3、线程协作方式不同
CyclicBarrier适合在一组线程相互等待达到共同的状态然后同时开始或继续执行后续操作,并且可以额外设置一个Runnable参数,当一组线程达到屏障点后可以优先触发。
CountDownLatch适用于一个或多个线程等待其他线程执行完某个操作后再继续执行。
## ThreadLocal
ThreadLocal:是一种隔离机制方法
Thread:是类名
ThreadLocalMap:是一种数据结构,定制化HashMap
### ThreadLocal原理
ThreadLocal是一种基于共享变量副本的隔离机制,保证多线程环境下对共享变量修改的安全性。
在多线程访问共享变量场景中,一般解决办法是对共享变量加锁,从而保证在同一时刻只有一个线程能对共享变量进行更新。弊端:加锁会造成性能下降。
ThreadLocal使用了空间换时间的思想:也就是在每个线程内都有一个容器来存储共享变量的副本,每个线程只对自己的共享变量副本做更新操作。
优点:1、解决了线程安全问题;2、避免了多线程竞争加锁的开销
共享变量的副本存储在Thread类里面的成员变量ThreadLocalMap(可看做定制的HashMap)
Thread类源码
public class Thread implements Runnable {//......//与此线程有关的ThreadLocal值。由ThreadLocal类维护ThreadLocal.ThreadLocalMap threadLocals = null;//与此线程有关的InheritableThreadLocal值。由InheritableThreadLocal类维护ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;//......
}
ThreadLocal数据结构:
### ThreadLocal使用场景
1、线程上下文传递:在跨线程调用的场景中,可使用ThreadLocal在不修改方法签名的情况下传递线程上下文信息。比如框架和中间件的请求头中的用户信息、请求ID等存储在ThreadLocal中,在后续的请求链路中,都可以方便的访问这些信息。
2、数据库连接管理:在使用数据库连接池的情况下,可以将数据库的连接信息存储在ThreadLocal中,每个线程可以独立管理自己的数据库连接,避免了线程之间的竞争和冲突。比如MyBatis中的sqlSession对象使用了ThreadLocal存储当前现成的数据库会话信息。
3、事务管理:在需要手动管理事务的场景中,可使用ThreadLocal存储事务的上下文,每个线程独立的控制自己的事务,保证事务的隔离性
### ThreadLocal内存泄漏及避免策略
内存泄漏:对象或变量占用的内存不会再被使用也不能被回收
强引用:一个对象具有强引用,不会被垃圾回收器回收。当内存不足,JVM抛出OOM也不会回收强引用对象。显式的将引用赋值为null,JVM合适时间可以回收。
弱引用:JVM垃圾回收时,无论内存是否充足,都会回收被弱引用关联的对象。
static class Entry extends WeakReference{}
GC垃圾回收机制-JVM如何找到需要回收的对象:
1、引用计数法:每个对象有一个引用计数属性,新增引用+1,释放引用-1,计数为0可以回收
2、可达性分析法:从GC Roots开始向下搜索,搜索所走过的路径称为引用链。当一个对象到GC Roots没有任何引用链相连时,证明此对象是不可用的,JVM则判断是可回收对象。
引用计数法存在两个对象互相引用永远无法回收的问题。
ThreadLocal内存泄漏原因:
ThreadLocalMap使用ThreadLocal的弱引用作为Entry数组的Key,如果ThreadLocal不存在外部强引用时,Key就会被GC回收,这样就会导致ThreadLocalMap的Key为null,而value还存在强引用。只有thread线程退出后,value的强引用链条才会断掉。如果当前线程一直不结束,Key为null的Entry的value就会一直存在一条强引用连无法回收,造成内存泄漏。
Thread Ref -> Thread -> ThreaLocalMap -> Entry -> value
如何避免内存泄漏?
1、每次使用完ThreadLocal,调用remove方法清除数据
2、ThreadLocal变量设置为static final,共用一个ThreadLocal,保证强引用
3、ThreadLocal内部优化:在我们调用set方法时,进行全量清理,清理出Key为null的值,扩容也会继续检查。
## Future
### Future类作用?
作用:获取异步任务执行后的结果。
大白话:有一个任务不需要立刻获得结果,因此采用异步方式让子线程去执行该任务,继续做主要的任务,之后直接通过future获取最后执行的结果。
Future接口包含五个方法:
// V 代表了Future执行的任务返回值的类型
public interface Future<V> {// 取消任务执行// 成功取消返回 true,否则返回 falseboolean cancel(boolean mayInterruptIfRunning);// 判断任务是否被取消boolean isCancelled();// 判断任务是否已经执行完成boolean isDone();// 获取任务执行结果V get() throws InterruptedException, ExecutionException;// 指定时间内没有返回计算结果就抛出 TimeOutException 异常V get(long timeout, TimeUnit unit)throws InterruptedException, ExecutionException, TimeoutExceptio}
### Callable 和 Future 有什么关系?
FutureTask实现Future接口,封装了Callable、Runnable,具有任务取消、查看、任务是否执行完成以及获取任务执行结果的方法。
FutureTask
实现类有两个构造函数,可传入 Callable 或者 Runnable
对象。实际上,传入 Runnable
对象也会在方法内部转换为Callable 对象。
封装Callable,管理着任务执行的情况,存储了 Callable 的 call
方法的任务执行结果。
## CompletableFuture 有什么用?
Future局限性如:不支持异步任务的编排组合、获取结果的get()为阻塞调用
CompletableFuture实现俩接口:Future、CompletionStage
public class CompletableFuture<T> implements Future<T>, CompletionStage<T> {}
面试满分回答: 定义 + 五种异步回调方式
CompletableFuture是JDK1.8里面引入的一个基于事件驱动的异步回调类,CompletableFuture弥补了原本Future的不足,使得程序可以在非阻塞的状态下完成异步的回调机制。
简单来说,就是当使用异步线程去执行一个任务时,希望在任务结束以后触发一个后续的动作。
举个简单的例子,比如在一个批量支付的业务逻辑里面,涉及到查询订单、支付、发送邮件通知这三个逻辑。
使用Future的话,这三个逻辑是按照顺序同步去实现的,也就是先查询到订单以后,再针对这个订单发起支付,支付成功以后再发送邮件通知。
这种设计方式导致这个方法的执行性能比较慢。
可以直接使用CompletableFuture,(如图),也就是说把查询订单的逻辑放在一个异步线程池里面去处理。然后基于CompletableFuture的事件回调机制的特性,可以配置查询订单结束后自动触发支付,支付结束后自动触发邮件通知。
CompletableFuture提供了5种不同的方式,把多个异步任务组成一个具有先后关系的处理链,然后基于事件驱动任务链的执行。
- thenCombine,把两个任务组合在一起,当两个任务都执行结束以后触发事件回调。
- thenCompose,把两个任务组合在一起,这两个任务串行执行,也就是第一个任务执行完以后自动触发执行第二个任务。
- thenAccept,第一个任务执行结束后触发第二个任务,并且第一个任务的执行结果作为第二个任务的参数,这个方法是纯粹接受上一个任务的结果,不返回新的计算值。
- thenApply,和thenAccept一样,但是它有返回值。
- thenRun,就是第一个任务执行完成后触发执行一个实现了Runnable接口的任务。
## 原子类
原子类:具有原子操作特征的类。
作用:和锁类似,是为了保证并发情况下的线程安全。
原子操作:不可拆分的操作。一旦执行不可中断,要么全部执行要么不执行。
根据操作的类型,JUC包中共有4种类型原子类:
1、基本类型
使用原子的方式更新基本类型
AtomicInteger
:整型原子类AtomicLong
:长整型原子类AtomicBoolean
:布尔型原子类
2、数组类型
使用原子的方式更新数组里的某个元素
AtomicIntegerArray
:整型数组原子类AtomicLongArray
:长整型数组原子类AtomicReferenceArray
:引用类型数组原子类
3、引用类型
AtomicReference
:引用类型原子类AtomicMarkableReference
:原子更新带有标记的引用类型。该类将 boolean 标记与引用关联起来AtomicStampedReference
:原子更新带有版本号的引用类型。该类将整数值与引用关联起来,可用于解决原子的更新数据和数据的版本号,可以解决使用 CAS 进行原子更新时可能出现的 ABA 问题。
4、对象的属性修改类型
AtomicIntegerFieldUpdater
:原子更新整型字段的更新器AtomicLongFieldUpdater
:原子更新长整型字段的更新器AtomicReferenceFieldUpdater
:原子更新引用类型里的字段
### 原子类与锁对比
-
粒度更细
原子变量可以把竞争范围缩小到变量级别,通常情况下锁的粒度大于原子变量的粒度 -
效率更高
除了在高并发之外,使用原子类的效率往往比使用同步互斥锁的效率更高,因为原子类底层利用了CAS,不会阻塞线程。
这篇关于并发编程(已整理,已废弃)的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!