独特视角带你走进Java并发编程的世界
全局概括
在正式开始前,我们先从全局角度来看看java并发编程中三个核心问题互斥,协作,分工,分别对应的解决方案分别有哪些:
并发问题产生的三个根本原因
- 多核cpu下,缓存不一致导致数据可见性问题
- 编译器和cpu指令重排导致的有序性问题
- 一条高级语言代码,底层会对应好几条cpu指令,因此线程切换导致的原子性问题
java内存模型: 有序性和可见性的解决方案
java内存模型定义了一套规范,使得JVM按序禁用cpu缓存,禁止编译器和cpu不合时宜的指令重排。这套规范包括对volatile,synchronized,final三个关键字的解析,和7个Happens-Before规则。
Java内存模型底层实现:
- 借助于内存屏障禁止指令重排,对于编译器而言,内存屏障将限制它所能做的重排序优化。而对于cpu而言,内存屏障将会导致缓存的刷新操作,同时也禁止了cpu层面的指令重排。
- 编译器会根据具体的底层体系结构,将这些内存屏障替换为具体的cpu指令,这些特殊的指令,会要求cpu把缓存数据写回主存,这就像在内存中建立了一道屏障,使得后面的代码不能越过屏障,提前执行。
volatile关键字
volatile修饰符作用是: 对某个变量的读写不能使用cpu缓存,必须从内存中读取或者写入。
- jdk 1.5之前,volatile关键字只具备可见性,无法禁止指令重排
- jdk 1.5之后,java内存模型通过happens-before规则对volatile语义进行了增强 – > 此时volatile关键字具有可见性和禁止指令重排两个作用
Happens-Before规则
Happens-Before规则: 前面操作的结果对后续操作可见。
Happens-Before规则约束了编译器的优化行为,虽然允许编译器进行优化,但是要求编译器优化后一定遵守Happens-Before规则。
具体规则如下:
- 程序顺序性规则: 在一个线程中,前面的操作Happens-Before后续的操作
- volatile变量规则: 对一个volatile变量的写操作Happens-Before于对这个变量的读操作
传递性规则: A Happens-Before B,B Happens-Before C,那么A Happens-Before C
- 管程中锁的规则: 对一个锁的解锁Happens-Before于后续对这个锁的加锁操作
- 线程start规则: 主线程启动子线程B后,start操作Happens-Before于子线程中后续任意操作
- 线程join规则: 在线程A中调用线程B的join后,线程B中任意操作Happens-Before于join的返回
- 线程中断规则: 对线程的打断操作Happens-Before被中断线程检测到中断事件发生
- 对象终结操作: 一个对象的初始化操作Happens-Before于它的finalize方法开始
Happens-Before规则本质上一种可见性,A Happens-Before B意味着A事件对B事件来说是可见的,无论A事件和B事件是否发生在同一个线程里。例如A事件发生在线程1上,B事件发生在线程2上,Happens-Before规则保证线程2上也能看到线程1事件的发生。
锁: 原子性的解决方案
解决原子性问题,关键在于保证中间状态对外不可见。
互斥锁
当我们需要使用互斥锁来保护临界区中的某个资源时,我们需要关注以下几点:
- 锁定的对象是哪个
- 当需要保护多个资源时,需要区分这些资源之间是否存在关联关系
- 加锁和解锁的范围—临界区的范围
- 需要确保对共享资源的所有访问路径都加上了锁
注意保护的资源:
- 保护的资源没有关系,每个资源一把锁即可
- 保护的资源有关联关系,先考虑一把大粒度锁覆盖所有相关资源,再看看能不能使用细粒度锁进行优化
死锁
对于有关联关系的资源,使用细粒度锁时,有可能会产生死锁问题, 如下所示:
class Account {
private int balance;
// 转账
void transfer(Account target, int amt) {
// 锁定转出账户
synchronized (this) {
// 锁定转入账户
synchronized (target) {
if (this.balance > amt) {
this.balance -= amt;
target.balance += amt;
}
}
}
}
}
死锁产生需要具备四个条件:
- 互斥
- 占有并等待
- 不可抢占
- 循环等待
解决死锁的最好办法是避免死锁,主要思路就是打破上面条件中一个:
- 破坏占有并等待条件: 一次性申请完所有资源 — > 资源管理员,配合cas重试+同步机制(wait/notify)
- 破坏不可抢占条件: 获取锁超时释放–> Lock接口的提供的tryLock
- 破坏循环等待条件: 给资源进行排序,资源按序申请
wait/notify注意事项
wait等待过程流程通常写成下面这个样子:
while (条件是否满足) {
try {
this.wait();
} catch (InterruptedException e) {
}
}
因为notify只能保证在通知的时间点,条件是满足的,而当被唤醒的线程开始运行时,可能条件已经不满足了。
wait,notify,notifyall这三个方法调用的前提是获取到了相应的互斥锁,所以这个三个方法都是在同步代码块内部调用的,如果在同步代码块外部调用,或者锁定的this,而用target.wait进行调用,jvm会抛出一个运行时异常: IllegalMonitorStateException。
wait和sleep的区别;
- wait阻塞会释放锁,sleep不会
- wait需要被唤醒,sleep不需要
- wait需要获取到监视器,否则抛出异常,sleep不需要
- wait是Object父类方法,sleep是Thread类的静态方法
线程注意点
java线程状态如下:
注意: 线程调用阻塞式API时,在操作系统层面,线程是会切换到休眠状态的,但是在JVM层面,java线程的状态还是运行态,也就是说JVM层面并不关心操作系统调度相关状态,等待CPU使用权,和等待I/O没有区别。
当线程 A 处于 RUNNABLE 状态时,并且阻塞在 java.nio.channels.InterruptibleChannel 上时,如果其他线程调用线程 A 的 interrupt() 方法,线程 A 会触发 java.nio.channels.ClosedByInterruptException 这个异常;而阻塞在 java.nio.channels.Selector 上时,如果其他线程调用线程 A 的 interrupt() 方法,线程 A 的 java.nio.channels.Selector 会立即返回。
重要:本线程抛出InterruptedException后,会把本线程的中断标志位清空,可能已有的中断标志True就消失了,可能会引起本线程失去主动监测中断标志以退出的机会!
所以,对本线程抛出的InterruptedException的异常try-catch后,再主动置标志位为True。Thead.currentTread().interrupt();
本线程可以通过调用isInterrupted()来查看本线程的中断标志位是否被置为true,可以决定退出,也可以忽略它(全看代码逻辑)。
当线程处于BLOCKED状态时,是不会响应中断的,也就是说当线程因为无法获取到synchronized锁而阻塞时,我们无法通过interrupt打断处于阻塞状态的线程。
当线程因为IO阻塞时,只有上面提到的情况才可以通过interrupt打断阻塞状态,其他情况大部分都不可以,比如: System.in.read()
常用的并发工具类
Lock & Condition 实现管程
Java SDK并发包通过Lock 和 Condition两个接口来实现管程,其中Lock用于解决互斥问题,Condition用于解决同步问题。
synchroized锁实现缺陷:
- 当线程处于阻塞状态时,我们无法通过interrupt打断阻塞中的线程,也无法实现获取锁超时释放,从而破坏不可抢占条件,避免死锁发生
Lock锁弥补了synchroized的缺陷:
- 能够响应中断: 可以通过interrupt唤醒因为获取锁失败而阻塞的线程
- 支持超时: 如果线程一段时间内没有获取到锁,那么抛出一个超时异常
- 非阻塞获取锁: 只简单尝试获取一下锁
还有一个区别就是Lock支持非公平和公平锁,synchronized只支持非公平锁实现。
Lock锁如何确保可见性:
- synchronized锁之所以能够确保可见性,是因为Happens-Before规则中有一条关于synchronized的规则: synchronized的解锁Happens-Before于后续对这个锁的加锁。该规则再配合程序顺序性规则和传递性规则,我们就可以推导出,一个线程解锁之前发生的操作,对后续想要对获取锁的线程是可见的。
- Lock锁能够确保可见性,是利用volatile变量相关的Happens-Before规则,因为 Java SDK里面的ReentrantLock内部持有一个volatile的成员变量state,获取锁的时候,会读写该state的值,解锁的时候,也会读锁state的值,由此我们可以推导出:
- 程序顺序性规则: 对于持有锁的线程t而言,解锁之前的操作Happens-Before释放锁的unlock操作
- volatile变量规则: 释放锁时会去读写volatile变量,获取锁时也会去读写volatile变量,所以线程t1的unlock操作 Happens-Before线程t2的lock操作
- 传递性规则: 线程t1解锁前进行的操作Happens-Before线程t2的lock操作
Lock锁使用范式:
class LockExample {
private final Lock rtl = new ReentrantLock();
int value;
public void example() {
rtl.lock();
try {
value += 1;
} finally {
rtl.unlock();
}
}
}
活锁问题
Lock锁使用不当,可能会产生活锁问题,最简单的场景比如说转账案例:我们基于每个账户一把锁,使用细粒度锁实现
class Account {
private int balance;
private final Lock lock
= new ReentrantLock();
// 转账
void transfer(Account tar, int amt){
while (true) {
if(this.lock.tryLock()) {
try {
if (tar.lock.tryLock()) {
try {
this.balance -= amt;
tar.balance += amt;
} finally {
tar.lock.unlock();
}
}
} finally {
this.lock.unlock();
}
}
//本次获取锁失败,随机等待一段时间,避免产生活锁问题
}
}
}
A给B转账,同时B给A转账,此时就会产生活锁。
解决活锁问题最简单的办法就是随机等待一段时间。
多条件变量
Java语言内置的管程里只有一个条件变量,而Lock&Condition实现的管程是支持多个条件变量的。
以阻塞队列的实现来简单看看多条件变量的使用:
/**
* @author 大忽悠
* @create 2023/3/14 10:39
*/
public class CustomArrayBlockingQueue<T> {
private final Integer capacity;
private final LinkedList<T> queue;
private final ReentrantLock lock = new ReentrantLock();
private final Condition notFull = lock.newCondition();
private final Condition notEmpty = lock.newCondition();
public CustomArrayBlockingQueue(Integer capacity) {
this.capacity = capacity;
this.queue = new LinkedList<>();
}
public CustomArrayBlockingQueue() {
capacity=10;
queue = new LinkedList<>();
}
public T take() {
lock.lock();
try {
//阻塞等待队列不为空
while (queue.isEmpty()) {
notEmpty.await();
}
//唤醒等待队列不满条件的线程
notFull.signalAll();
return queue.remove();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
return null;
}
public void put(T t) {
lock.lock();
try{
//阻塞等待队列不满
while(queue.size()>=capacity){
notFull.await();
}
queue.addFirst(t);
notEmpty.signalAll();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
}
特别注意: 只能确保在执行notify的时刻,条件是满足的,而不能确保在被唤醒线程开始执行的时刻,条件依旧满足,所以线程被唤醒后,需要再次检查条件是否满足—while循环
Semaphore信号量实现限流
信号量模型可以简单概括为: 一个计数器,一个等待队列,三个方法。
- init方法负责初始化计数器
- up方法负责累加计数器,如果计数器的值小于或者等于0,则唤醒等待队列中的一个线程
- down方法负责递减计数器,如果计数器的值小于0,则将当前线程进行阻塞处理
/**
* @author 大忽悠
* @create 2023/3/14 11:06
*/
public class CustomSemaphore<T> extends AbstractQueuedSynchronizer {
public CustomSemaphore(int permits) {
this.setState(permits);
}
/**
* 发放许可证
*/
public void acquirePermits() throws InterruptedException {
//先调用tryAcquireShared尝试获取资源,获取不到就阻塞
acquireSharedInterruptibly(1);
}
/**
* 回收许可证
*/
public void releasePermits(){
//先调用tryReleaseShared尝试释放资源,释放成功,唤醒阻塞等待资源的线程
releaseShared(1);
}
/**
* 非公平实现
*/
@Override
protected int tryAcquireShared(int acquires) {
for (;;) {
//公平实现方式
// if(hasQueuedPredecessors()) {
// return -1;
// }
int available = getState();
int remaining = available - acquires;
if (remaining < 0 ||
compareAndSetState(available, remaining)) {
return remaining;
}
}
}
@Override
protected boolean tryReleaseShared(int releases) {
for(;;){
int current=getState();
int next=current+releases;
if(compareAndSetState(current,next)) {
return true;
}
}
}
}
Semaphore使用模板:
Semaphore semaphore = new Semaphore(1);
semaphore.acquire();
try{
//对资源访问
}finally {
semaphore.release();
}
Semaphore的应用场景:
- Semaphore可以限制同一时刻能够访问临界区的线程数量,因此通常使用Semaphore来实现限流,或者对池化资源的管理
/**
* @author 大忽悠
* @create 2023/3/14 12:28
*/
public class CustomObjectPool<T,R> {
private final List<T> objectPool;
private final Semaphore semaphore;
public CustomObjectPool(int poolSize,Class<T> tClass) {
//或者使用vector
this.objectPool = Collections.synchronizedList(new LinkedList<T>());
initObjectPool(poolSize,tClass);
this.semaphore = new Semaphore(poolSize);
}
@SneakyThrows
private void initObjectPool(int poolSize, Class<T> tClass) {
for (int i = 0; i < poolSize; i++) {
objectPool.add(tClass.newInstance());
}
}
@SneakyThrows
public R exec(Function<T,R> func){
semaphore.acquire();
T t=null;
try{
t = objectPool.remove(0);
return func.apply(t);
}finally {
semaphore.release();
objectPool.add(t);
}
}
}
ReadWriteLock读写锁实现高性能缓存读写
读写锁通用三原则:
- 允许多个线程同时读共享变量
- 只运行一个线程写共享变量
- 如果一个线程正在执行写操作,此时禁止读线程读共享变量
读写锁适用于读多写少的环境下,而缓存系统恰好是一个读多写少的最佳实例,下面是用读写锁实现的一个简单的通用缓存模块案例:
/**
* @author 大忽悠
* @create 2023/3/14 19:57
*/
public class CommonCache<K, V> {
private final Map<K, V> CACHE = new HashMap<>();
private final ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock();
private final ReentrantReadWriteLock.ReadLock readLock = readWriteLock.readLock();
private final ReentrantReadWriteLock.WriteLock writeLock = readWriteLock.writeLock();
public void put(K key, V val) {
writeLock.lock();
try {
CACHE.put(key, val);
} finally {
writeLock.unlock();
}
}
public V get(K key) {
readLock.lock();
try {
return CACHE.get(key);
} finally {
readLock.unlock();
}
}
}
缓存加读写锁并不是最优解,还能不能再优化呢? —> map换成并发容器ConcurrentHashMap or mvcc? or 参考caffine实现
缓存淘汰呢? --> LRU ? --> 什么样的数据结构实现呢? —> 哈希链表 --> LinkedHashMap
ReadWriteLock使用注意事项:
- 不允许读锁升级为写锁,但是允许写锁降级为读锁
- 只有写锁支持条件变量,读锁不支持条件变量
读锁的特点是同一时刻可以有多个被线程持有,又因为不可能存在读锁和写锁同时被持有的情况,所以升级写锁的过程中,需要等待所有的读锁全部释放,才能进行锁升级。
条件变量关联的条件属于临界资源,同一时刻只能有一个线程访问,需要配合互斥锁使用
StampedLock在ReadWriteLock基础上新增乐观读锁
StampedLock在ReadWriteLock的基础上新增了乐观读锁,同时,StampedLock里的写锁和悲观读锁加锁成功后,都会返回一个stamp,然后解锁的时候,需要传入这个stamp:
StampedLock stampedLock = new StampedLock();
long stamp = stampedLock.readLock();
try{
//业务代码
}finally {
stampedLock.unlockRead(stamp);
}
乐观读是对乐观锁的一种实现,可以类比数据库中我们常用的乐观锁实现—版本号:
StampedLock stampedLock = new StampedLock();
//返回一个版本号
long version = stampedLock.tryOptimisticRead();
//执行读取相关的业务操作
//....
//判断读取期间是否存在其他写线程对数据进行了修改
if(!stampedLock.validate(version)){
//乐观读期间存在并发修改问题,改为悲观读取
version=stampedLock.readLock();
try{
//重新读取最新的数据
}finally {
stampedLock.unlockRead(version);
}
}
//返回读取到的数据
return xxx;
StampedLock使用注意事项:
- 不支持锁重入
- 悲观读锁和写锁都不支持条件变量
- 如果线程阻塞在StampedLock的ReadLock或者WriteLock上,此时调用该阻塞线程的interrupt方法,会导致CPU飙升。
- 使用 StampedLock 一定不要调用中断操作,如果需要支持中断功能,一定使用可中断的悲观读锁 readLockInterruptibly() 和写锁 writeLockInterruptibly()
- StampedLock支持锁的降级和升级–悲观读锁升级为写锁需要调用tryConvertToWriteLock方法
CountDownLatch计数器和CyclicBarrier循环栅栏
CountDownLatch主要解决一个线程等待多个线程的场景,例如: 大文件分片上传场景等。
CustomCountDownLatch customCountDownLatch = new CustomCountDownLatch(3);
ExecutorService executorService = Executors.newFixedThreadPool(3);
for(int i=0;i<3;i++){
executorService.execute(()->{
//一个耗时的子任务执行
try {
customCountDownLatch.countDown();
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
});
}
//等待所有任务执行完毕
customCountDownLatch.await();
CycleBarrier主要解决一组线程互相等待,CycleBarrier的计数器可以被循环利用,而且具备自动重置的功能,我们还可以传入回调函数在计数器为0时,会执行我们传入的回调函数。
例如: 有一个对账的需求,线程t1需要从订单表查询出一批订单,线程t2需要从派送表查询出一批订单,然后后由线程t3来执行对账逻辑,将差异写入差异库中:
注意: CyclicBarrier是在计数器为0时,才去调用我们传入的回调函数,并且是在最后一个调用await将计数器减为0的线程中,调用的回调函数。
同时CyclicBarrier是在同步调用完回调函数后,才会唤醒等待的线程,如果我们在回调函数中执行调用check方法,那么就意味着执行check的时候,是不能同时执行查询订单和查询派送单的任务的,这样无法起到提升性能的作用。
优化思路:
- 采用生产者消费者模型,将对账逻辑异步化执行
public synchronized static void main(String[] args) throws CloneNotSupportedException, IOException, InterruptedException {
ExecutorService fixedThreadPool = Executors.newFixedThreadPool(2);
//异步执行对账逻辑的线程池
ExecutorService singleThreadExecutor = Executors.newSingleThreadExecutor();
//订单队列
BlockingQueue ordeQueue=new LinkedBlockingDeque();
//派送单队列
BlockingQueue deliverQueue=new LinkedBlockingDeque();
//循环栅栏
CyclicBarrier cyclicBarrier=new CyclicBarrier(2,()->{
//当所有线程都到达栅栏后,执行回调函数
singleThreadExecutor.execute(()->{
try {
Object order = ordeQueue.take();
Object deliver = deliverQueue.take();
//执行对账逻辑
//插入写入数据库
} catch (InterruptedException e) {
e.printStackTrace();
}
});
});
//线程1执行查询订单表任务
fixedThreadPool.execute(()->{
///存在未对账订单
while(existUnCheckOrder){
try {
//查询订单表
ordeQueue.add(getOrders());
//等待派单任务查询完成
cyclicBarrier.await();
} catch (InterruptedException | BrokenBarrierException e) {
e.printStackTrace();
}
}
});
//线程2执行查询派送表任务
fixedThreadPool.execute(()->{
//存在未对账订单
while(existUnCheckDelivery){
try {
//查询派送表
deliverQueue.add(getDelivers());
//阻塞等待查询订单任务完成
cyclicBarrier.await();
} catch (InterruptedException | BrokenBarrierException e) {
e.printStackTrace();
}
}
});
}
因为订单和派送是两个队列,所以只有单线程去两个队列中取消息才不会出现消息不匹配的问题,当然多线程可以考虑加锁解决。
线程安全的容器
jdk 1.5之前,java中提供的线程安全容器,主要是指同步容器,即所有方法都用synchronized来保证互斥,性能太差,这类容器实现主要有:
- Collections. synchronizedxxx构造处理的线程安全容器
- Vecotr,Stack和HastTable
jdk 1.5之后提供了性能更高的容器,我们称之为并发容器:
- List类型的并发容器主要有: CopyOnWriteList,采用写时复制思想,在写的时候会将共享资源复制一份出来进行修改,修改完毕后再替换旧的资源,这样的好处是: 读操作完全无锁。
CopyOnWriteList使用注意事项:
- 仅适用于写操作非常少的场景
- 能够容忍读写的短暂不一致性
- 迭代器是只读的,不支持增删改,因为迭代器只是一个快照,对快照进行增删改没有意义
- Map类型的并发容器主要有: ConcurrentHashMap和ConcurrentSkipListMap,前者的key是无序的,而后者的key是有序的。
- set类型的并发容器主要有: CopyOnWriteArraySet和ConcurrentSkipListSet
- queue这类并发容器可以按照: 阻塞与非阻塞,以及单端和双端两个维度进行划分,然后两两组合。Java并发包中的阻塞队列都用Blocking关键字标识,单端队列使用Queue标识,双端队列使用Deque标识。
有界队列底层是采用什么数据类型作为实现更好? 如果采用实现如何避免移动元素带来的开销?
- ArrayBlockingQueue支持有界队列,底层采用数组作为数据结构实现,采用循环队列思想,避免了数组元素增加删除导致元素移动的开销
- LinkedBlockingQueue支持有界队列,底层采用双向链表作为实现
注意点:在使用迭代器遍历一个集合对象时,比如增强for,如果遍历过程中对集合中的元素进行了增删,会抛出ConcurrentModificationException 异常,该行为也被称为快速失败机制,java.util包下的集合类都是快速失败机制的。
原子类: 无锁工具的典范
原子类底层利用cas确保本次操作的原子性,cas底层依靠硬件层面的lock前缀开头的指令实现一次比较并交换过程的原子性。
CAS在X86的大概写法是: lock cmpxchg a.b.c
lock 前缀的起关键作用的指令,cas的实现用了lock cmpxchg指令,该指令涉及一次内存读和一次内存写,需要lcok前缀保证中间不会有其他cpu写这段内存。
使用CAS来解决问题,通常会伴随着自旋重试,因为不能确保一次cas就能更新成功,在存在竞争的情况下,可能会更新失败。
cas代码模板:
do{
//获取当前值
int currentVal= xxx;
//根据当前值计算新值
int nextCurrentVal=currentVal+xxx;
}while (!compareAndSet(currentVal,nextCurrentVal));
对于对象引用的原子更新,我们更需要重点关注ABA问题,AtomicStampedReference和AtomicMarkableReference这两个原子类可以解决ABA问题。
cas只是进行简单的值比较,对于基本数据类型来说ABA问题大多数场景下是没有问题的,但是对于引用类型来说,cas仅比较地址值是否发生变化,因此对象中属性值的变化,CAS是无法察觉出来的,因此ABA问题在对象引用的原子更新上需要重点注意。
- 原子类引用ABA问题复现:
/**
* @author 大忽悠
* @create 2023/3/3 14:54
*/
public class Main {
public synchronized static void main(String[] args) throws CloneNotSupportedException, IOException, InterruptedException, ExecutionException {
Stu stu = new Stu("大忽悠", 18);
AtomicReference<Stu> atomicReference = new AtomicReference<>(stu);
//-------------------------线程t2改变stu对象中属性值----------------------------------
CompletableFuture<Void> future = CompletableFuture.runAsync(() -> {
stu.setName("小朋友");
});
future.get();
//-------------------------------------------------------------------
boolean res = atomicReference.compareAndSet(
//对于引用来说,默认比较的是引用的地址是否发生变化,所以无法察觉属性层面的变动
stu,
new Stu("大朋友",19));
System.out.println("是否更新成功: "+res+",结果为:"+atomicReference.get());
}
@AllArgsConstructor
@Data
public static class Stu{
private String name;
private Integer age;
}
}
- 使用版本号机制解决ABA问题:
/**
* @author 大忽悠
* @create 2023/3/3 14:54
*/
public class Main {
public synchronized static void main(String[] args) throws CloneNotSupportedException, IOException, InterruptedException, ExecutionException {
Stu stu = new Stu("大忽悠", 18);
AtomicStampedReference<Stu> atomicStampedReference = new AtomicStampedReference<>(stu, 0);
//-------------------------线程t2改变stu对象中属性值----------------------------------
CompletableFuture<Void> future = CompletableFuture.runAsync(() -> {
try {
int currentVersion=atomicStampedReference.getStamp();
Thread.sleep(1000);
int nextVersion=currentVersion+1;
System.out.println("异步线程获取的当前版本号为: "+currentVersion);
stu.setName("小朋友");
boolean res = atomicStampedReference.compareAndSet(stu, stu, currentVersion, nextVersion);
System.out.println("异步是否更新成功: "+res+",结果为:"+atomicStampedReference.getReference());
} catch (InterruptedException e) {
e.printStackTrace();
}
});
//-------------------------------------------------------------------
int currentVersion=atomicStampedReference.getStamp();
Thread.sleep(1000);
int nextVersion=currentVersion+1;
System.out.println("主线程获取的当前版本号为: "+currentVersion);
boolean res = atomicStampedReference.compareAndSet(
//对于引用来说,默认比较的是引用的地址是否发生变化,所以无法察觉属性层面的变动
stu,
new Stu("大朋友",19),currentVersion,nextVersion);
future.get();
System.out.println("主线程是否更新成功: "+res+",结果为:"+atomicStampedReference.getReference());
}
@AllArgsConstructor
@Data
public static class Stu{
private String name;
private Integer age;
}
}
- AtomicMarkableReference将版本号简化为一个Boolean值,使用如下:
/**
* @author 大忽悠
* @create 2023/3/3 14:54
*/
public class Main {
public synchronized static void main(String[] args) throws CloneNotSupportedException, IOException, InterruptedException, ExecutionException {
Stu stu = new Stu("大忽悠", 18);
AtomicMarkableReference<Stu> atomicMarkableReference = new AtomicMarkableReference<>(stu, false);
//-------------------------线程t2改变stu对象中属性值----------------------------------
CompletableFuture<Void> future = CompletableFuture.runAsync(() -> {
try {
boolean currentVersion=atomicMarkableReference.isMarked();
Thread.sleep(1000);
boolean nextVersion=!currentVersion;
System.out.println("异步线程获取的当前版本号为: "+currentVersion);
stu.setName("小朋友");
boolean res = atomicMarkableReference.compareAndSet(stu, stu, currentVersion, nextVersion);
System.out.println("异步是否更新成功: "+res+",结果为:"+stu);
} catch (InterruptedException e) {
e.printStackTrace();
}
});
//-------------------------------------------------------------------
boolean currentVersion=atomicMarkableReference.isMarked();
Thread.sleep(1000);
boolean nextVersion=!currentVersion;
System.out.println("主线程获取的当前版本号为: "+currentVersion);
boolean res = atomicMarkableReference.compareAndSet(
//对于引用来说,默认比较的是引用的地址是否发生变化,所以无法察觉属性层面的变动
stu,
new Stu("大朋友",19),currentVersion,nextVersion);
future.get();
System.out.println("主线程是否更新成功: "+res+",结果为:"+stu);
}
@AllArgsConstructor
@Data
public static class Stu{
private String name;
private Integer age;
}
}
cas无锁小结
cas的无锁方案不会死锁问题,但是可能会出现饥饿和活锁问题,因为自旋会反复重试。
任务编排执行方案
线程池
线程池本质是一种生产者-消费者模式,线程池的使用方式生产者,线程池本身是消费者。
下面给出一个简单的线程池实现,大家可以尝试理解一下下面线程池的实现,再去看看java官方提供的源码实现:
/**
* @author 大忽悠
* @create 2023/3/15 19:33
*/
@Data
public class CustomThreadPoolExecutor implements Executor {
/**
* 非核心线程最大空闲时间
*/
private Integer keepAliveTime;
/**
* 非核心线程数量
*/
private Integer maxPoolSize;
/**
* 核心线程数量
*/
private Integer corePoolSize;
/**
* 阻塞队列
*/
private final BlockingQueue<Runnable> workQueue;
/**
* 工作线程
*/
private final Set<CustomWorker> workers = new HashSet<>();
/**
* 线程工厂
*/
private final ThreadFactory threadFactory;
/**
* 被拒绝的工作任务数量
*/
private int rejectNum;
/**
* 当前线程状态: 1是运行状态,2是停止状态,3是结束状态
*/
private int state;
private static final int RUNNING = 1;
private static final int SHUTDOWN = 2;
private static final int TERMINATED = 3;
public CustomThreadPoolExecutor(Integer keepAliveTime, Integer maxPoolSize, Integer corePoolSize,
BlockingQueue<Runnable> workQueue, ThreadFactory threadFactory) {
this.keepAliveTime = keepAliveTime;
this.maxPoolSize = maxPoolSize;
this.corePoolSize = corePoolSize;
this.workQueue = workQueue;
this.threadFactory = threadFactory;
this.state = RUNNING;
this.rejectNum = 0;
}
@Override
public void execute(Runnable task) {
int currentPoolSize = workers.size();
//1.核心线程数是否已经满了
if (currentPoolSize < corePoolSize) {
addWorker(task);
return;
}
//2.尝试往任务队列里面塞--阻塞队列都是线程安全的
if (workQueue.offer(task)) {
return;
}
//3.尝试创建非核心线程执行任务
if (currentPoolSize > maxPoolSize) {
//拒绝策略
rejectNum++;
throw new RuntimeException("线程池已满!");
} else {
addWorker(task);
}
}
public <R> Future<R> submit(Callable<R> task) {
FutureTask<R> futureTask = new FutureTask<>(task);
execute(futureTask);
return futureTask;
}
public synchronized void shutDown() {
state = SHUTDOWN;
workers.forEach(customWorker -> customWorker.thread.interrupt());
}
/**
* HashSet底层采用HashMap非线程安全,execute方法可能会被多个线程同时调用,需要考虑加锁
*/
private void addWorker(Runnable task) {
CustomWorker customWorker = new CustomWorker(task);
synchronized (this) {
workers.add(customWorker);
}
customWorker.thread.start();
}
/**
* 线程被打断就停止运行--除非当前正在执行用户任务,并且不处于WAITING或者TIMEING_WAITING状态
*/
private void runWorker(CustomWorker worker) {
Thread currentThread = Thread.currentThread();
Runnable task = worker.firstWork;
try {
while (state <= RUNNING && (task != null || (task = getTask()) != null)) {
//如果当前工作线程被打断了,那么停止任务的执行
if (currentThread.isInterrupted()) {
break;
}
//执行任务
try {
task.run();
} catch (Exception e) {
throw e;
} finally {
task = null;
worker.doneNum++;
}
}
} finally {
//将工作线程从线程集合中移除
processWorkerExited(worker);
}
}
/**
* HashSet非线程安全,需要加锁,这里也可以考虑ReentrantLock
*/
private synchronized void processWorkerExited(CustomWorker worker, boolean ex) {
workers.remove(worker);
//如果工作线程因为异常结束,这里考虑是否需要新启动一个线程
if(ex&&workers.size()<corePoolSize){
addWorker(null);
}
//工作线程集合为空,并且状态为停止中
if (workers.isEmpty() && state == SHUTDOWN) {
state = TERMINATED;
this.notifyAll();
}
}
public synchronized void awaitTermination() throws InterruptedException {
while (state != TERMINATED) {
this.wait();
}
}
public class CustomWorker implements Runnable {
private Thread thread;
/**
* 已经完成的工作数量
*/
private Integer doneNum;
/**
* 绑定到当前工人上的任务
*/
private Runnable firstWork;
public CustomWorker(Runnable firstWork) {
this.thread = getThreadFactory().newThread(this);
this.doneNum = 0;
this.firstWork = firstWork;
}
@Override
public void run() {
runWorker(this);
}
}
/**
* 从任务队列中获取任务
*/
private Runnable getTask() {
int currentPoolSize = workers.size();
//如果当前线程数量大于最大线程数量,那么终止当前线程(动态调整线程池内线程数量)
if (currentPoolSize > maxPoolSize) {
return null;
}
//是否存在非核心线程
boolean allowTimeOut = currentPoolSize > corePoolSize;
try {
return allowTimeOut ? workQueue.poll(keepAliveTime, TimeUnit.SECONDS) : workQueue.take();
} catch (InterruptedException e) {
}
return null;
}
}
线程池使用注意事项:
- 避免使用无界队列,容器造成OOM
- 线程池默认拒绝策略为RejectExecutionException,这是个运行时异常,运行时异常编译器不强制catch,所以开发人员很容易忽略。
- ThreadPoolExecutor对象的execute方法提交任务执行出现异常,会导致对应的工作线程终止,并且用户无法感知到
我们最好按照下面的格式手动捕获业务逻辑执行过程中产生的异常:
try {
//业务逻辑
} catch (RuntimeException x) {
//按需处理
} catch (Throwable x) {
//按需处理
}
Java规定: Error及其子类,RuntimeException及其子类类型的异常无需捕获,其他类型的异常都必须手动捕获,或者通过throws向上抛出。
对于需要手动捕获,或者向上抛出的异常被称为checked exception,Error和RuntimeException子类的异常被称为uncheck exception。
我们也可以提前预判某个接口可能会抛出的异常,然后通过在方法上使用throws声明该异常,如果该异常属于checked exception,那么调用该方法的方法,需要手动捕获异常。
- runnable方法上没有声明要抛出的异常,因此如果该接口的实现类中,在方法内部抛出了RuntimeException,那么用户在编程过程中是无法感知该方法是否存在抛出异常的可能,从而忽略了对异常的捕获,造成线程意外终止等问题。
- callable方法上声明了要抛出Exception类型的异常,所以调用该方法的类,需要在自己的调用方法中手动捕获异常,因此,用户是可以感知到异常的,不会造成一些难以察觉的问题。
当我们需要去编写一些底层库供上层调用时,我们需要注意接口层的方法上是否需要抛出某个类型的异常,尽量让用户能够感知到异常,避免产生问题。
如何避免线程死锁
使用线程池过程中,如果提交到相同线程池的任务不是相互独立的,而是有依赖关系的,那么就有可能导致线程死锁。
public class Main {
public static void main(String[] args) throws InterruptedException {
//L1,L2阶段共用的线程池
ExecutorService threadPool = Executors.newFixedThreadPool(2);
CountDownLatch l1 = new CountDownLatch(2);
//L1阶段的闭锁
for(int i=0;i<2;i++){
//L2阶段的闭锁
System.out.println("L1");
//执行L2阶段子任务
threadPool.execute(()->{
CountDownLatch l2 = new CountDownLatch(2);
for(int j=0;j<2;j++){
threadPool.execute(()->{
System.out.println("L2");
l2.countDown();
});
}
});
}
//等待L2阶段任务执行完
l1.await();
System.out.println("end");
}
}
上面这种情况,线程池里所有的线程都在等待L2阶段的任务执行完,又因为线程池中线程都阻塞了,没有空闲线程去执行阻塞队列中L2阶段的任务了,此时就产生了线程死锁。
解决思路:
- 调大线程池的最大线程数
- 为不同的任务创建不同的线程池
提交到相同线程池中的任务一定是相互独立的,否则一定要慎重。还需要注意上面提到的异常处理,避免异常的任务从眼前溜走,从业务角度看,有时没有发现异常的任务后果会很严重。
工厂里只有一个工人,他的工作就是同步地等待工厂里其他人给他提供东西,然而并没有其他人,他将等到天荒地老,海枯石烂!”因此,共享线程池虽然能够提供线程池的使用效率,但一定要保证一个前提,那就是:任务之间没有依赖关系。
ExecutorService pool = Executors
.newSingleThreadExecutor();
//提交主任务
pool.submit(() -> {
try {
//提交子任务并等待其完成,
//会导致线程死锁
String qq=pool.submit(()->"QQ").get();
System.out.println(qq);
} catch (Exception e) {
}
});
Future: 获取异步任务执行结果
Future用于获取异步任务执行结果,在java中最常用的实现类为FutureTask,实现思路就是用FutureTask包装用户传入的runnable对象,内部维护一个任务状态变量,如果任务执行成功,更改状态,设置结果,如果任务执行失败,更改状态,设置异常,最后唤醒等待结果的线程。
用户调用get方法阻塞获取异步任务结果时,首先判断任务执行状态,如果执行中,那么就入队阻塞,否则判断是否产生异常,如果产生抛出,否则正常返回结果。
CompletableFuture: 任务编排
默认情况下CompletableFuture会使用公共ForkJoinPool线程池,这个线程池默认创建的线程数是CPU的核数。如果所有的CompletableFuture共享一个线程池,那么一但有任务执行一些很慢的IO操作,就会导致线程池中所有线程都阻塞在I/O操作上,从而造成线程饥饿,进而影响整个系统的性能,所以,强烈建议你要根据不同的业务类型创建不同的线程池,以避免互相干扰。
CompletionStage接口: 描述任务间的时序关系
任务是有时序关系的,比如: 串行关系,并行关系,汇聚关系等:
1.描述串行关系: thenApply,thenAccept,thenRun,thenCompose
2.描述and汇聚关系: thenCombine,thenAccepttBoth,runAfterBoth
3.描述or汇聚关系: applyToEither,acceptEither,runAfterEither
4.异常处理:
CompletionStage exceptionally(fn);
CompletionStage<R> whenComplete(consumer);
CompletionStage<R> whenCompleteAsync(consumer);
CompletionStage<R> handle(fn);
CompletionStage<R> handleAsync(fn);
whenComplete() 和 handle() 系列方法就类似于 try{}finally{}中的 finally{},无论是否发生异常都会执行 whenComplete() 中的回调函数 consumer 和 handle() 中的回调函数 fn。whenComplete() 和 handle() 的区别在于 whenComplete() 不支持返回结果,而 handle() 是支持返回结果的。
注意:
- 注意异常的处理
- 对于IO任务,考虑专门的线程池做数据库查询,防止线程饥饿
- 对于查询和处理类任务而言,如果处理过程比较耗时,可以考虑采用生产者消费者模型提升处理性能
CompletableFuture进阶篇-外卖商家端API的异步化
CompletionService: 批量执行任务
如何优化下面这个任务执行:
ExecutorService threadPool = Executors.newFixedThreadPool(3);
SearchAndHandleTask task = new SearchAndHandleTask();
Future<Integer> f1 = threadPool.submit(task::search);
Future<Integer> f2 = threadPool.submit(task::search);
Future<Integer> f3 = threadPool.submit(task::search);
Integer r1 = f1.get();
System.out.println(r1);
Integer r2 = f2.get();
System.out.println(r2);
Integer r3 = f3.get();
System.out.println(r3);
消费者生产者解耦: (哪个任务先完成,就先处理哪个任务)
ExecutorService threadPool = Executors.newFixedThreadPool(3);
SearchAndHandleTask task = new SearchAndHandleTask();
Future<Integer> f1 = threadPool.submit(task::search);
Future<Integer> f2 = threadPool.submit(task::search);
Future<Integer> f3 = threadPool.submit(task::search);
ArrayBlockingQueue<Integer> blockingQueue = new ArrayBlockingQueue<>(3);
threadPool.submit(()->blockingQueue.add(f1.get()));
threadPool.submit(()->blockingQueue.add(f2.get()));
threadPool.submit(()->blockingQueue.add(f3.get()));
for(int i=0;i<3;i++){
Integer r = blockingQueue.take();
threadPool.execute(()->task.handle(r));
}
借助CompletionService完成任务批量执行,CompletionService的实现原理是通过内部维护一个阻塞队列,当任务执行结束后就把任务的执行结果加入到阻塞队列中,不同的是CompletionService是把任务执行结果的future对象加入到阻塞队列中:
ExecutorService threadPool = Executors.newFixedThreadPool(3);
SearchAndHandleTask task = new SearchAndHandleTask();
List<Future<Integer>> resList = new ArrayList<>(3);
CompletionService<Integer> cs = new ExecutorCompletionService<>(threadPool);
resList.add(cs.submit(task::search));
resList.add(cs.submit(task::search));
resList.add(cs.submit(task::search));
try {
for (int i = 0; i < 3; i++) {
Future<Integer> f = cs.take();
System.out.println(f.get());
}
} finally {
for (Future<Integer> f : resList) {
f.cancel(true);
}
}
ExecutorCompletionService利用QueueingFuture继承FutureTask,重写其提供的done钩子方法,当任务完成时,该钩子方法被回调,然后将完成的任务加入到ExecutorCompletionService内部的阻塞队列中去。
FutureTask通过给任务切分状态,实现状态通知机制,并且使用链式结构作为等待队列,当任务完成时,设置状态,并通知等待队列中的线程。
当我们调用FutureTask的get方法尝试获取任务返回结果的时候,如果任务还没执行完成,那么当前线程入队阻塞,否则根据任务最终状态,决定正常返回结果,还是抛出异常,提醒用户运行过程中出现了异常。
有关线程的阻塞队列一般采用链式结构实现,因为该场景下更多的是增加和删除操作,而非根据索引快速定位。
Fork/Join: 单机版MapReduce
对于简单的并行任务,可以通过"线程池+Future"的方案来解决;如果任务之间有聚合关系,无论是AND聚合或者OR聚合,都可以通过CompletableFuture来解决;而批量的并行任务,并且需要获取先执行完的任务结果进行处理的,可以通过CompletionService来解决。
对于需要"分治"处理的任务模型而言,需要考虑Fork/Join的并行计算框架进行处理。
分治分为: 递的过程和归的过程,递的过程中不断分解当前任务,归的过程中合并各个子问题的解推出大问题。
Fork/Join并行计算框架中,Fork对应任务的分解,Join对应结果的合并,该框架有两部分组成:
- 分治任务的线程池ForkJoinPool
- 分治任务ForkJoinTask
ForkJoinTask是一个抽象类,核心方法为: Fork和Join , Fork方法会异步执行一个子任务,而Join方法会阻塞当前线程来等待子任务的执行结果。
ForkJoniTask有两个子抽象类:
- RecursiveAction: 负责计算的compute方法无返回值
- RecursiveTask: 负责计算的compute方法有返回值
类比ThreadPoolExecutor:
- ThreadPoolExecutor本质是一个消费者生产者模型,用户作为生产者,工作线程作为消费者,多个工作线程共享一个队列
- ForkJoinPool本质也是一个生产者消费者模型,但是ForkJoinPool内部有多个任务队列,每个工作线程都关联一个任务队列,当我们通过invoke或者submit方法提交任务时,任务会根据路由规则提交到一个任务队列中,如果任务在执行过程中创建出子任务,那么子任务会提交到工作线程对应的任务队列中。
- 如果工作线程对应的任务队列空了,ForkJoinPool通过"任务窃取"机制,让当前工作线程从其他任务队列中“窃取“任务来执行。
简单使用展示:
public class Main {
private static final String[] WORD_DICT = new String[]{
"one two three six seven",
"one two three four",
"five six seven one two"
};
/**
* 使用Fork/Join实现统计一个文件中单词出现的数量
*/
public static void main(String[] args) {
ForkJoinPool forkJoinPool = new ForkJoinPool(3);
WordCountTask task = new WordCountTask(WORD_DICT, 0, WORD_DICT.length - 1);
Map<String, Long> res = forkJoinPool.invoke(task);
res.forEach((key,val)->{
System.out.println("当前单词为: "+key+",单词出现的次数为: "+val);
});
}
public static class WordCountTask extends RecursiveTask<Map<String,Long>> {
private final String[] LINE;
private final int begin;
private final int end;
public WordCountTask(String[] line, int begin, int end) {
this.LINE = line;
this.begin = begin;
this.end = end;
}
@Override
protected Map<String, Long> compute() {
if(end<=begin){
return calc(LINE[end]);
}
int mid=(begin+end)/2;
WordCountTask left = new WordCountTask(LINE, begin, mid);
//前半部分数据fork一个递归任务去处理,后半部分数据则在当前任务中递归处理
left.fork();
WordCountTask right = new WordCountTask(LINE, mid + 1, end);
return merge(right.compute(),left.join());
}
private Map<String, Long> merge(Map<String, Long> compute, Map<String, Long> join) {
join.keySet().forEach(
key->{
compute.put(key,join.get(key)+compute.getOrDefault(key,0L));
}
);
return compute;
}
private Map<String, Long> calc(String line) {
Map<String, Long> res=new HashMap<>();
for (String c : line.split(" ")) {
res.put(c,res.getOrDefault(c,0L)+1);
}
return res;
}
}
}
ForkJoinPool默认的线程数是CPU的核数,如果所有的并行流计算都是CPU密集型的,没有问题,但是如果存在I/O密集型的并行流计算,那么很可能会因为一个很慢的I/O计算而拖慢整个系统的性能,所以建议用不同的ForkJoinPool执行不同类型的计算任务。
并发编程设计模式总结
Immutability模式—不可变模式
“多个线程同时读写同一个共享变量存在并发问题”,如果只有读,没有写时,是没有并发问题的,所以解决并发问题最简单的办法就是让共享变量只有读,而没有写操作。 — 不变性模式: 对象一旦被创建后,状态就不再发生变化。
如何实现不可变性的类:
- 类所有属性设置为final,并且只允许存在只读方法,更严格的做法是把当前类本身也设置为final,因为子类可以覆盖父类的方法,从而改变不可变性
java SDK中提供的不可变类:
- String类,Long类,Interger类,Double类等基础类型的包装类都具备不可变性
- 这些类的线程安全性都是靠不可变性保证的
不可变模式和享元模式擦出爱情的火花
享元模式本质是通过对象池,完成对象的复用,从而减少对象创建数量,减少内存占用。
Java中的基本类型包装类Long,Integer,Short,Byte等都用到了享元模式。
- Interger,Long,Short,Byte默认缓存范围都是-128到127范围内的对象
- Character缓存0-127范围内的对象
- Double和Float没有缓存
不可变对象与锁的冲突性
基础类型的包装类都不适合作为锁对象,因为他们内部用到了享元模式,这会导致看上去私有的锁,其实是共有的。
下面的例子中: A和B其实共用的是一把锁
class A {
Long al=Long.valueOf(1);
public void setAX(){
synchronized (al) {
//省略代码无数
}
}
}
class B {
Long bl=Long.valueOf(1);
public void setBY(){
synchronized (bl) {
//省略代码无数
}
}
}
不可性特例–无状态对象
无状态对象内部没有属性,只有方法,无状态对象没有线程安全问题,无需同步处理。
小结
注意:
- 对象的所有属性都是final,并不能保证不可变性 — > 在使用不可变模型的时候一定要确定不可变性的边界在哪里,是否要求属性对象也具备不可变性。
public final class Account{
private final StringBuffer user;
public Account(String user){
this.user =
new StringBuffer(user);
}
public StringBuffer getUser(){
return this.user;
}
public String toString(){
return "user"+user;
}
}
Copy-On-Write模式–写时复制模式
操作系统中的写时复制:
- 在调用fork生成新进程时,新进程与原进程共享同一个内存区,只有当其中一个进程进行写操作时,系统才会为另外分配内存页面
-
fork创建子进程的时候,只会复制页表,物理地址空间内容暂不复制,并且会将原页表和新页表都设置为只读
-
如果向只读页表管理的物理内存范围区间进行写操作时,会触发缺页中断,申请一块新的内存区域,将页表指向的只读区间数据拷贝到新的内存区域中来,然后将页表管理的物理内存指向新的内存区域。
-
然后将页表设置为可读写
-
下一次进程1要对页表指向的物理内存进行写操作时,发现此时物理空间引用为1了,那么直接将写表改为可读写即可,无需复制页面
-
Java SDK中提供的COW容器有:
- CopyOnWriteArrayList和CopyOnWriteArraySet(底层利用CopyOnWriteArrayList)
- Java提供的COW容器,由于在修改的同时会复制整个容器,所以在提示读操作性能的同时,是以内存复制为代价的
- 写时复制适合对读性能要求高,读多写少,可以忍受弱一致性的场景
为什么Java提供的CopyOnWriteArrayList,而没有提供CopyOnWriteLinkedList呢?
- 数组在内存中连续存储,更有利于CPU加载和缓存,特点是增删满,读取快
- 链表分散存储在内存中,特点是增删快,读取少
- 写时复制场景是读多写少,因此数组数据结构更适合作为实现
- 链表可以将锁粒度细化到每个节点
线程本地存储模式—避免变量被共享
线程本地存储核心思路: 没有共享,就没有伤害。
Java中本地存储使用的是ThreadLocal,关于java中的threadLocal我们需要关注以下几个问题:
- ThreadLoacl作用是什么?
- 避免对象共享,确保并发情况下的安全性。
- 通过线程副本存储执行流中需要的参数,避免通过方法参数逐级传递,减少代码耦合
- 为什么要将存储线程本地副本数据的ThreadLocalMap设置在Thread中,而非ThreadLocal中?
- 基于OOP思想,Thread类应该聚合了当前线程相关信息,如: 线程ID,线程名,线程副本数据存储等信息
- 为什么不直接通过Thread类暴露出接口来访问内部的ThreadLocalMap,而采用ThreadLocal进行间接访问?
- 遵循"最小知道原则",即: 如果两个软件实体无需直接通信,那么就不应该发生直接的相互调用,可以通过第三方转发该调用,目的是降低类之间的耦合度,提高模块的相对独立性。
- 我们只是想查询或者修改线程本地存储中的数据,而不想知道线程对象所有其他细节,因此采用ThreadLocal作为第三方代为转发请求调用
- 为什么ThreadLocal对象单独设计成一个类,而不是以静态内部类的形式出现在Thread类只能够呢?
- 遵循"单一职责原则",线程副本数据并不是线程对象必须具备的属性,类设计的时候只保留本身必须的属性即可
- ThreadLocal内存泄露怎么理解 ?
- ThreadLocalMap本身是由一组Entry组成,每个Entry又包含了key和value两部分,key的类型是ThreadLocal,val就是我们设置到线程的副本数据。Entry的key采用弱引用实现,如果ThreadLocal对象实例失去了外部的强引用,只剩下弱引用,那么该对象会在下次gc扫描到时被回收,此时由于key为null,value依然占据空间,但是无法访问,此种情况我们称之为内存泄露。
- 官方团队建议将ThreadLocal声明为静态常量,此时ThreadLocal的生命周期和应用程序生命周期一致,一般不会出现key已经为null,但是value依然占据空间,但是无法被外界访问的情况。
- 为什么要将Entry的key设置为弱引用类型?
- 如果将ThreadLocalMap中的key设置为强引用类型,那么用户失去了对ThreadLocal对象的强引用后,由于ThreadLocalMap中依然保持对ThreadLocal对象的强引用,所以Key和Value占据的空间都无法被回收掉,同时用户也无法访问该键值对,并且ThreadLocalMap自身也无法区分内部哪些键值对是不再被用户所需要的,这难道不是某种意义上的内存泄露吗?
- 如果将ThreadLocalMap中的key设置为弱引用类型,那么用户失去了对ThreadLocal对象的强引用后,由于此时ThreadLocal对象只剩下弱引用存在,那么下次gc时,ThreadLocal对象会被回收掉,此时Entry的key会被设置为null,Entry不为null。ThreadLocal自身可以通过扫描内部的ThreadLocalMap寻找那些Entry中key为null的键值对,对其进行内存回收。
使用ThreadLocal时,注意手动释放资源,因为线程池会使得线程被复用,那么线程对象内部存储的线程本地副本数据如果不及时清理,会造成一些错误结果。
InheritableThreadLocal: 子线程继承父线程本地副本数据
通过ThreadLocal创建的线程变量,子线程是无法继承的。
通过InheritableThreadLocal创建的线程变量,子线程会继承父线程线程本地副本数据。
注意: 线程池中线程的创建是动态的,很容易导致继承关系错乱,如果你的业务逻辑依赖InheritableThreadLocal,那么狠可能导致业务逻辑计算错误。
Guarded Suspension模式–等待唤醒机制的规范实现
Guarded Suspension直译为"保护性暂停模式",该模式结构如下:
- 一个对象GuardedObject
- 内部有一个成员变量–受保护的对象
- 两个成员方法—get和onChanged方法
get方法通过条件变量的await方法实现等待,onChanged方法通过条件变量的signalAll方法实现唤醒功能。
GuardedObject对象的简单实现如下:
/**
* @author 大忽悠
* @create 2023/3/19 10:55
*/
public class GuardedObject<T> {
private T resource;
private final Lock lock=new ReentrantLock();
private final Condition done=lock.newCondition();
/**
* 获取受保护对象
*/
public T get(Predicate<T> p) throws InterruptedException {
lock.lock();
try{
while(!p.test(resource)){
done.await();
}
} catch (InterruptedException e) {
throw new InterruptedException();
}finally {
lock.unlock();
}
return resource;
}
/**
* 事件通知方法
*/
public void onChanged(T resource){
lock.lock();
try{
this.resource=resource;
done.signalAll();
}finally {
lock.unlock();
}
}
}
使用GuardObject实现一个简易消息消费流程:
/**
* @author 大忽悠
* @create 2023/3/19 11:08
*/
public class CustomMessageQueue<Msg> {
private final GuardedObject<Msg> guardedObject = new GuardedObject<>();
/**
* 发送消息
*/
public Msg sendMsg(Msg msg) throws InterruptedException {
//1.创建消息
//2.发送消息
//3.等待响应消息
return guardedObject.get(Objects::nonNull);
}
/**
* 消费消息
*/
private void consumeMsg(Msg msg) {
//1.消费消息
//2.发出消费成功事件,唤醒等待线程
//问题: 线程1和线程2同时发送消息A和消息B,如果消息A先被消费完,那么此时直接唤醒线程1和线程2
guardedObject.onChanged(msg);
}
}
上面版本问题再于: 没有建立线程与等待消息响应之间的映射关系,会产生错误唤醒问题,解决办法如下:
/**
* @author 大忽悠
* @create 2023/3/19 10:55
*/
public class GuardedObject<T> {
private T resource;
private final Lock lock = new ReentrantLock();
private final Condition done = lock.newCondition();
private static final Map<String, GuardedObject> gos = new HashMap<>();
public static GuardedObject create(String key) {
GuardedObject guardedObject = new GuardedObject<>();
gos.put(key, guardedObject);
return guardedObject;
}
public static void fireEvent(String key, Object msg) {
GuardedObject guardedObject = gos.remove(key);
if (guardedObject != null) {
guardedObject.onChanged(msg);
}
}
/**
* 获取受保护对象
*/
public T get(Predicate<T> p) throws InterruptedException {
lock.lock();
try {
while (!p.test(resource)) {
done.await();
}
} catch (InterruptedException e) {
throw new InterruptedException();
} finally {
lock.unlock();
}
return resource;
}
/**
* 事件通知方法
*/
public void onChanged(T resource) {
lock.lock();
try {
this.resource = resource;
done.signalAll();
} finally {
lock.unlock();
}
}
}
import java.util.Objects;
/**
* @author 大忽悠
* @create 2023/3/19 11:08
*/
public class CustomMessageQueue<Msg> {
/**
* 发送消息
*/
public Msg sendMsg(Msg msg) throws InterruptedException {
//1.创建消息
GuardedObject<Msg> guardedObject = GuardedObject.create("消息ID");
//2.发送消息
//3.等待响应消息
return guardedObject.get(Objects::nonNull);
}
/**
* 消费消息
*/
private void consumeMsg(Msg msg) {
//1.消费消息
//2.发出消费成功事件,唤醒等待线程
GuardedObject.fireEvent("消息ID",msg);
}
}
Guarded Suspension模式本质是一种等待唤醒机制的实现,只不过将其进行了规范化处理。Guarded Suspension模式也被称为"多线程版本的if"。
“多线程版本的if”需要配合while循环取重试等待。
Balking模式
Guarded Suspension模式实现的多线程版本的if是需要等待的,而且还非常执着,必须要等到条件为真,但是某些场景下,我们不需要这样执着,可以快速放弃。
Balking模式的经典实现如下:
private boolean changed=false;
//状态满足执行相关操作,否则直接快速失败返回
void autoSave(){
synchronized (this){
if(!changed) return;
}
//执行相关业务操作
changed=false;
}
//改变状态
void change(){
synchronized (this){
changed=true;
}
}
Balking模式有一个非常典型的应用场景就是单次初始化:
boolean inited = false;
synchronized void init() {
if (inited) {
return;
}
//执行初始化相关操作
inited = true;
}
如果变量在多线程环境下需要保证可见性,需要考虑使用volatile进行修饰,如果需要原子性,那就考虑加锁吧!
线程安全的单例模式本质上其实也是单次初始化,所以可以用 Balking 模式来实现线程安全的单例模式,但是通常会配合双重锁检查优化性能:
class Singleton{
private static volatile Singleton singleton;
//构造方法私有化
private Singleton() {
}
//获取实例(单例)
public static Singleton
getInstance() {
//第一次检查
if(singleton==null){
synchronize(Singleton.class){
//获取锁后二次检查
if(singleton==null){
singleton=new Singleton();
}
}
}
return singleton;
}
}
大家思考volatile 修饰在双重锁检查中起到了什么作用?
- volatile变量作用在jdk 5之前只有可见性,jdk 5之后通过happens-before规则进行了语义增强,同时具有可见性和禁止指令重排两个作用
- 此处更多是利用到了volatile的禁止指令重排作用,因为对象实例化分为三步: 分配内存,在分配的内存上初始化对象,将对象变量指向内存地址,如果第二步和第三步操作重排一下,可能会发生访问空指针异常
Balking模式和Guarded Suspension模式从实现上看似乎没有多大关系,Balking模式只需要用互斥锁就能解决,而Guarded Suspension模式则需要使用管程来实现同步等待唤醒。
从应用角度看,他们解决的都是"线程安全的if语义",不同之处在于,Guarded Suspension模式会等待if条件为真,而Balking模式不会等待。
- Balking 模式的经典实现是使用互斥锁,你可以使用 Java 语言内置 synchronized,也可以使用 SDK 提供 Lock;
- 如果你对互斥锁的性能不满意,可以尝试采用 volatile 方案,不过使用 volatile 方案需要你更加谨慎。
- 当然你也可以尝试使用双重检查方案来优化性能,双重检查中的第一次检查,完全是出于对性能的考量:避免执行加锁操作,因为加锁操作很耗时。而加锁之后的二次检查,则是出于对安全性负责。双重检查方案在优化加锁性能方面经常用到,
问题: init() 方法的本意是—>仅需计算一次 count 的值,采用了 Balking 模式的 volatile 实现方式,你觉得这个实现是否有问题呢?
class Test {
volatile boolean inited = false;
int count = 0;
void init() {
if (inited) {
return;
}
inited = true;
//计算count的值
count = calc();
}
}
- 解决方案
方案一: 双重锁检查
if(inited) return;
synchronize(this){
if(inited) return;
inited=true;
count=calc();
}
方案二: cas
if(inited) return;
if(!compareAndSet(inited,false,true)) return;
count=calc();
两阶段终止模式—优雅终止线程
两阶段终止模式: 将终止过程分成两个阶段,其中第一个阶段主要是线程T1向线程T2发送终止指令,而第二阶段则是线程T2响应终止指令:
终止指令由两方面组成: interrupt方法和线程终止标志位。
线程因为系统IO阻塞,在java层面看来,对应线程的状态依然为runnable,因此我们无法通过interrupt打断处于IO阻塞状态的线程。 对于因为没有获取到jvm层面的synchronized而阻塞的线程,我们也无法通过interrupt打断它。
两种思路:
- 利用中断标志位: 需要注意的是,我们在捕获 Thread.sleep() 的中断异常之后,通过 Thread.currentThread().interrupt() 重新设置了线程的中断状态,因为 JVM 的异常处理会清除线程的中断状态。
while(!Thread.currentThread().isInterrupted()){
try{
Thread.sleep(2000);
}catch (InterruptedException exception){
//重置打断标志位
Thread.currentThread().interrupt();
}
}
- 使用自定义终止标志位: 原因在于我们很可能在线程的 run() 方法中调用第三方类库提供的方法,而我们没有办法保证第三方类库正确处理了线程的中断异常,例如第三方类库在捕获到 Thread.sleep() 方法抛出的中断异常后,没有重新设置线程的中断状态,那么就会导致线程不能够正常终止。所以强烈建议你设置自己的线程终止标志位.
private volatile boolean terminated=false;
private static void start() {
while(!terminated){
try{
Thread.sleep(2000);
}catch (InterruptedException exception){
//重置打断标志位
Thread.currentThread().interrupt();
}
}
}
private void stop(){
//设置中断标记位
terminated=true;
//打断线程
thread.interrupt();
}
自定义的终止标志位需要使用volatile修饰吗?
- 本例中其实是不需要的,因为根据根据程序顺序性规则,interrupt规则和传递性规则,我们可以推出打断之前发生的操作,对于被打断线程来说是可见的。
- 但是,如果线程不调用wait,sleep等方法,是无法响应中断的,这个时候基于interrupt的可见性就不成立了,所以工程上这类变量都需要加volatile
如何优雅终止线程池
线程池提供了两个方法: shutdown和shutdownNow方法来终止线程池,这两个方法区别如下:
- shutdown: 拒绝接受新任务,但是会等待线程池中任务和已经进入阻塞队列的任务都执行完后才最终关闭线程池。
- shudownNow: 拒绝接受新任务,同时还会中断线程池中正在执行的任务,同时阻塞队列中的任务也不会再被执行。阻塞队列中的还未执行的任务会作为方法返回值返回。因为shutdownNow会中断正在执行的线程,所以提交到线程池中的任务,如果需要优雅的结束,就需要正确处理线程中断。
如果提交到线程池的任务不允许取消,那就不能使用 shutdownNow() 方法终止线程池。不过,如果提交到线程池的任务允许后续以补偿的方式重新执行,也是可以使用 shutdownNow() 方法终止线程池的。
shutdown() 和 shutdownNow() 方法,它们实质上使用的也是两阶段终止模式,只是终止指令的范围不同而已,前者只影响阻塞队列接收任务,后者范围扩大到线程池中所有的任务。
生产者消费者模式
生产者消费者模式的核心是一个任务队列,生产者线程生产任务,并将任务添加到任务队列中,而消费者线程从任务队列中获取任务并执行。
生产者消费者模型好处:
- 解耦
- 支持异步,并且通过任务队列能够平衡生产者和消费者的速度差异,从而支持使用适量的线程完成大量的任务
- 适合批量执行场景
- 支持分阶段提交以提高性能
批量执行以提高性能
批量执行的核心在于合并多次网络或者磁盘IO,以此来提供性能。
例如:
- 日志监控系统如果将每条产生的日志信息都直接插入数据库,那么这个效率会很低,可以考虑将日志消息作为任务丢到任务队列,然后由消费者线程负责将任务从任务队列中批量取出执行。
//任务队列
BlockingQueue<Task> bq=new
LinkedBlockingQueue<>(2000);
//启动5个消费者线程
//执行批量任务
void start() {
ExecutorService es=executors
.newFixedThreadPool(5);
for (int i=0; i<5; i++) {
es.execute(()->{
try {
while (true) {
//获取批量任务
List<Task> ts=pollTasks();
//执行批量任务
execTasks(ts);
}
} catch (Exception e) {
e.printStackTrace();
}
});
}
}
//从任务队列中获取批量任务
List<Task> pollTasks()
throws InterruptedException{
List<Task> ts=new LinkedList<>();
//阻塞式获取一条任务
Task t = bq.take();
while (t != null) {
ts.add(t);
//非阻塞式获取一条任务
t = bq.poll();
}
return ts;
}
//批量执行任务
execTasks(List<Task> ts) {
//省略具体代码无数
}
支持分阶段提交以提高性能
写文件同步刷盘性能会很慢,所以对于不重要的数据,通常采用异步刷盘的方式,例如一个日志组件,刷盘的时机为:
- ERROR级别的日志需要立即刷盘
- 数据积累到500条需要立即刷盘
- 存在未刷盘的数据,且五秒内未曾刷盘,需要立即刷盘
解决方案:
- 每当产生一个日志任务,都添加到阻塞队列—> info ,error等方法
- 消费者线程中根据刷盘规则执行刷盘操作
class Logger {
//任务队列
final BlockingQueue<LogMsg> bq
= new BlockingQueue<>();
//flush批量
static final int batchSize=500;
//只需要一个线程写日志
ExecutorService es =
Executors.newFixedThreadPool(1);
//启动写日志线程
void start(){
File file=File.createTempFile(
"foo", ".log");
final FileWriter writer=
new FileWriter(file);
this.es.execute(()->{
try {
//未刷盘日志数量
int curIdx = 0;
long preFT=System.currentTimeMillis();
while (true) {
LogMsg log = bq.poll(
5, TimeUnit.SECONDS);
//写日志
if (log != null) {
writer.write(log.toString());
++curIdx;
}
//如果不存在未刷盘数据,则无需刷盘
if (curIdx <= 0) {
continue;
}
//根据规则刷盘
if (log!=null && log.level==LEVEL.ERROR ||
curIdx == batchSize ||
System.currentTimeMillis()-preFT>5000){
writer.flush();
curIdx = 0;
preFT=System.currentTimeMillis();
}
}
}catch(Exception e){
e.printStackTrace();
} finally {
try {
writer.flush();
writer.close();
}catch(IOException e){
e.printStackTrace();
}
}
});
}
//写INFO级别日志
void info(String msg) {
bq.put(new LogMsg(
LEVEL.INFO, msg));
}
//写ERROR级别日志
void error(String msg) {
bq.put(new LogMsg(
LEVEL.ERROR, msg));
}
}
//日志级别
enum LEVEL {
INFO, ERROR
}
class LogMsg {
LEVEL level;
String msg;
//省略构造函数实现
LogMsg(LEVEL lvl, String msg){
}
//省略toString()实现
String toString(){
}
}
Java 语言提供的线程池本身就是一种生产者 - 消费者模式的实现,但是线程池中的线程每次只能从任务队列中消费一个任务来执行,对于大部分并发场景这种策略都没有问题。但是有些场景还是需要自己来实现,例如需要批量执行以及分阶段提交的场景。
小结
- 避免共享的设计模式: 不可变模式,写时复制模式和线程本地存储模式。
- 不可变模式需要注意对象属性的不可变性,写时复制注意性能问题,线程本地存储需要注意线程复用问题
- 多版本的IF设计模式: 保护性暂停模式和Balking模式,前者需要等待if条件变为真,后者无需等待
- 如何优雅地终止线程?两阶段终止模式是一种通用的解决方案。但其实终止生产者 - 消费者服务还有一种更简单的方案,叫做“毒丸”对象。
- 简单来讲,“毒丸”对象是生产者生产的一条特殊任务,然后当消费者线程读到“毒丸”对象时,会立即终止自身的执行。
下面是用“毒丸”对象终止写日志线程的具体实现,整体的实现过程还是很简单的:类 Logger 中声明了一个“毒丸”对象 poisonPill ,当消费者线程从阻塞队列 bq 中取出一条 LogMsg 后,先判断是否是“毒丸”对象,如果是,则 break while 循环,从而终止自己的执行。
class Logger {
//用于终止日志执行的“毒丸”
final LogMsg poisonPill =
new LogMsg(LEVEL.ERROR, "");
//任务队列
final BlockingQueue<LogMsg> bq
= new BlockingQueue<>();
//只需要一个线程写日志
ExecutorService es =
Executors.newFixedThreadPool(1);
//启动写日志线程
void start(){
File file=File.createTempFile(
"foo", ".log");
final FileWriter writer=
new FileWriter(file);
this.es.execute(()->{
try {
while (true) {
LogMsg log = bq.poll(
5, TimeUnit.SECONDS);
//如果是“毒丸”,终止执行
if(poisonPill.equals(logMsg)){
break;
}
//省略执行逻辑
}
} catch(Exception e){
} finally {
try {
writer.flush();
writer.close();
}catch(IOException e){
}
}
});
}
//终止写日志线程
public void stop() {
//将“毒丸”对象加入阻塞队列
bq.add(poisonPill);
es.shutdown();
}
}