文章目录
引子
很多年前,Java 5发布之前,我在网上找到一个Java并发编程的包,大致看了一下源码,不禁拍案叫绝,如获至宝。后来才知道那是Java世界的大神Doug Lea的力作。可惜一直没有太深入钻研它,所以多少年来,我的并发经验停留在new Thread().start()的水平。
先看一个Java 19版本中结构化的并发程序例子:
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var results = IntStream.range(0, 10)
.mapToObj(item -> scope.fork(new Task(item))
).collect(Collectors.toList());
scope.join();
results.forEach(result -> System.out.println(result.resultNow()));
}
catch (InterruptedException e) {
e.printStackTrace();
}
}
record Task(int item) implements Callable<String> {
@Override
public String call() throws Exception {
long wait = (long) (Math.random() * 1000);
try {
sleep(wait);
return Thread.currentThread() +
" item " + item + " waited " + wait + " milliseconds";
}
catch (InterruptedException e) {
e.printStackTrace();
}
return "error";
}
}
任务与线程
一个任务(Task)是一个可被执行(Runnable)的对象。
一个线程(Thread)是操作系统的一个执行单元,或者叫执行流。
线程和任务是一对多的关系。
synchronized
导致线程安全问题的根本原因在于,存在多个线程同时操作一个共享资源,要想解决这个问题,就需要保证对共享资源访问的独占性,因此人们在Java中提供了synchronized关键 字,我们称之为同步锁,它可以保证在同一时刻,只允许一个线程执行某个方法或代码块。
synchronized同步锁具有互斥性,这相当于线程由并行执行变成串行执行,保证了线程的安全性,但是损失了性能。
synchronized的不足之处:
- 如果临界区是只读操作,其实可以多线程一起执行,但使用synchronized的话,同一时间只能有一个线程执行。
- synchronized无法知道线程有没有成功获取到锁
- 使用synchronized,如果临界区因为IO或者sleep方法等原因阻塞了,而当前线程又没有释放锁,就会导致所有线程等待。
volatile
volatile用来解决可见性问题,即一个线程修改的数据要及时被另一个线程见到。
在多线程环境中导致可见性问题的根本原因是CPU的高速缓存及指令重排序。
由于CPU高速缓存的设计,从而导致了缓存一致性问题。为了解决这一问题,开发者在CPU层面提供了总线锁和缓存锁的机制。CPU 层面提供了内存屏障及锁的机制来保证有序性,然而在不同的CPU类型中,又存在不同的内存屏障指令。Java作为一个跨平台语言,必须要针对不同的底层操作系统和硬件提供统一的 线程安全性保障,而Java M emory M ode就是这样一个模型。
被volatile关键字修饰了的属性,Java内存模型会在该属性中插入合适的内存屏障,从而解决可见性问题。
锁
可重入锁和非可重入锁
重入锁,顾名思义,就是支持重新进入的锁,也就是说这个锁支持一个线程对资源重复加锁。synchronized关键字就是使用的重入锁。比如说,你在一个synchronized实例方法里面调用另一个本实例的synchronized实例方法,它可以重新进入这个锁,不会出现任何异常。ReentrantLock就是一种可重入锁。
公平锁与非公平锁
这里的“公平”,其实通俗意义来说就是“先来后到”,也就是FIFO。如果对一个锁来说,先对锁获取请求的线程一定会先被满足,后对锁获取请求的线程后被满足,那这个锁就是公平的。反之,那就是不公平的。
一般情况下,非公平锁能提升一定的效率。但是非公平锁可能会发生线程饥饿(有一些线程长时间得不到锁)的情况。所以要根据实际的需求来选择非公平锁和公平锁。
ReentrantLock支持非公平锁和公平锁两种
读写锁和排它锁
“排它锁”,也就是说,这些锁在同一时刻只允许一个线程进行访问。除了synchronized用的锁和ReentrantLock,其实都是“排它锁”。
而读写锁可以再同一时刻允许多个读线程访问。Java提供了ReentrantReadWriteLock类作为读写锁的默认实现,内部维护了两个锁:一个读锁,一个写锁。通过分离读锁和写锁,使得在“读多写少”的环境下,大大地提高了性能。
乐观锁与悲观锁
悲观锁:
悲观锁就是我们常说的锁。对于悲观锁来说,它总是认为每次访问共享资源时会发生冲突,所以必须对每次数据操作加上锁,以保证临界区的程序同一时间只能有一个线程在执行。
乐观锁:
乐观锁又称为“无锁”,顾名思义,它是乐观派。乐观锁总是假设对共享资源的访问没有冲突,线程可以不停地执行,无需加锁也无需等待。而一旦多个线程发生冲突,乐观锁通常是使用一种称为CAS的技术来保证线程执行的安全性。
由于无锁操作中没有锁的存在,因此不可能出现死锁的情况,也就是说乐观锁天生免疫死锁。
乐观锁多用于“读多写少“的环境,避免频繁加锁影响性能;而悲观锁多用于”写多读少“的环境,避免频繁失败和重试影响性能。
ReentrantLock
重入锁,属于排他锁类型,功能和synchronized相似。
ReentrantReadWriteLock
可重入读写锁,该类中维护了两个锁,一是ReadLock,二是WriteLock,它们分别实现了Lock接口。
CAS介绍
CAS的全称是:比较并交换(Compare And Swap)。在CAS中,有这样三个值:
V:要更新的变量(var)
E:预期值(expected)
N:新值(new)
比较并交换的过程如下:
判断V是否等于E,如果等于,将V的值设置为N;如果不等,说明已经有其它线程更新了V,则当前线程放弃更新,什么都不做。
因为CAS是一种原子操作,它是一种系统原语,是一条CPU的原子指令,从CPU层面保证它的原子性
当多个线程同时使用CAS操作一个变量时,只有一个会胜出,并成功更新,其余均会失败,但失败的线程并不会被挂起,仅是被告知失败,并且允许再次尝试,当然也允许失败的线程放弃操作。
CAS的原理的实现为Unsafe类,它在sun.misc包中。它里面是一些native方法:
boolean compareAndSwapObject(Object o, long offset,Object expected, Object x);
boolean compareAndSwapInt(Object o, long offset,int expected,int x);
boolean compareAndSwapLong(Object o, long offset,long expected,long x);
JAVA并发基石之AQS
AQS全称为 AbstractQueuedSynchronizer 即抽象队列同步器,是java.util.concurrent.locks 包下的基础组件,是用于构建锁和同步器的框架。简单的理解就是多线程环境下,AQS 封装了一揽子对临界资源线程安全的操作,以 ReentrantLock 来看:
获取锁?成功则锁住,否则放入 FIFO 队列等待获取锁(线程挂起或者说阻塞),锁的释放等这些底层操作都由 AQS 封装;
许多同步器都可以通过 AQS 很容易并且高效的构造出来。java.util.concurrent 包下许多可阻塞类的底层实现,如 ReentrantLock, CountDownLatch, Semaphore,
ReentrantReadWriteLock, SynchronousQueue 和 FutureTask 都是基于 AQS 构建。
CAS原理与JUC原子类
JUC显式锁
阻塞队列和非阻塞队列
生产者一直生产资源,消费者一直消费资源,资源存储在一个缓冲池中,生产者将生产的资源存进缓冲池中,消费者从缓冲池中拿到资源进行消费,这就是大名鼎鼎的生产者-消费者模式。
使用阻塞队列(BlockingQueue),你只管往里面存、取就行,而不用担心多线程环境下存、取共享变量的线程安全问题。
BlockingQueue是Java util.concurrent包下重要的数据结构,区别于普通的队列,BlockingQueue提供了线程安全的队列访问方式,并发包下很多高级同步类的实现都是基于BlockingQueue实现的。
ArrayBlockingQueue :一个由数组结构组成的有界阻塞队列。
LinkedBlockingQueue:一个由链表结构组成的有界阻塞队列。
PriorityBlockingQueue :一个支持优先级排序的无界阻塞队列。
DelayQueue:一个使用优先级队列实现的无界阻塞队列。
SynchronousQueue:一个不存储元素的阻塞队列。
LinkedTransferQueue:一个由链表结构组成的无界阻塞队列。
LinkedBlockingDeque:一个由链表结构组成的双向阻塞队列。
同步阻塞队列也是实现生产者消费者模式的首选方式。
offer 添加一个元素并返回true 如果队列已满,则返回false
poll 移除并返问队列头部的元素 如果队列为空,则返回null
peek 返回队列头部的元素 如果队列为空,则返回null
put 添加一个元素 如果队列满,则阻塞
take 移除并返回队列头部的元素 如果队列为空,则阻塞
非阻塞队列: ConcurrentLinkedQueue
线程池的使用
首先要明白一点,线程池的实现还是要基于Thread这个类的,而Thread类是对底层操作系统线程的封装。
有了线程池,我们无需像之前那样new一个个Thread,而是改为向线程池提交Runnable对象,也就是提交任务即可,至于如何维护线程,如何调度任务,这正是线程池要干的事情。
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler)
涉及到5~7个参数:
- int corePoolSize:该线程池中核心线程数最大值
核心线程:线程池中有两类线程,核心线程和非核心线程。核心线程默认情况下会一直存在于线程池中,即使这个核心线程什么都不干(铁饭碗),而非核心线程如果长时间的闲置,就会被销毁(临时工)。 - int maximumPoolSize:该线程池中线程总数最大值 。
该值等于核心线程数量 + 非核心线程数量。 - long keepAliveTime:非核心线程闲置超时时长。
非核心线程如果处于闲置状态超过该值,就会被销毁。如果设置allowCoreThreadTimeOut(true),则会也作用于核心线程。
TimeUnit unit:keepAliveTime的单位。
常见的线程池类
Executors类中提供的几个静态方法来创建线程池。
- ExecutorService.newCachedThreadPool(): 不创建核心线程,线程复用率比较高,会显著的提高性能
- ExecutorService.newFixedThreadPool(): 核心线程数量和总线程数量相等,不能创建非核心线程
- ExecutorService.newSingleThreadExecutor(): 有且仅有一个核心线程
- ExecutorService.newScheduledThreadPool(): 创建一个定长线程池,支持定时及周期性任务执行
提交任务
通常有两个方法:
- submit方法,可以获取到任务返回值或任务异常信息
- execute方法,不能获取任务返回值和异常信息
等待所有线程完成任务
Future.get()可以同步等待线程执行完成,并且可以监听执行结果
List<Future> futures = new ArrayList<>();
for (Task t : tasks) {
/**
* 给元素添加后缀
*/
Future future = executor.submit(()-> {
try {
t.exec();
} catch (Exception e) {
e.printStackTrace();
}
});
futures.add(future);
}
for (Future future : futures) {
try {
future.get();
} catch (InterruptedException e) {
e.printStackTrace();
} catch (ExecutionException e) {
e.printStackTrace();
}
}
也可以使用
CountDownLatch countDownLatch = new CountDownLatch(tasks.size());
for (Task t : tasks) {
/**
* 给元素添加后缀
*/
executor.submit(()-> {
try {
t.exec();
} catch (Exception e) {
e.printStackTrace();
}finally {
countDownLatch.countDown();
}
});
}
try {
countDownLatch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
J.U.C
类 | 作用 |
---|---|
Semaphore | 限制同时工作的线程的数量,往往用于资源有限的场景中 |
Exchanger | 两个线程交换数据 |
CountDownLatch | 线程等待直到计数器减为0时开始工作 |
CyclicBarrier | 作用跟CountDownLatch类似,但是可以重复使用 |
Phaser | 增强的CyclicBarrier |
Fork/Join框架
Fork/Join框架是一个实现了ExecutorService接口的多线程处理器,它专为那些可以通过递归分解成更细小的任务而设计,最大化的利用多核处理器来提高应用程序的性能。
与其他ExecutorService相关的实现相同的是,Fork/Join框架会将任务分配给线程池中的线程。而与之不同的是,Fork/Join框架在执行任务时使用了工作窃取算法。
fork在英文里有分叉的意思,join在英文里连接、结合的意思。顾名思义,fork就是要使一个大任务分解成若干个小任务,而join就是最后将各个小任务的结果结合起来得到大任务的结果。
工作窃取算法指的是在多线程执行不同任务队列的过程中,某个线程执行完自己队列的任务后从其他线程的任务队列里窃取任务来执行。
CompletableFuture
使用Future获得异步执行结果时,要么调用阻塞方法get(),要么轮询看isDone()是否为true,这两种方法都不是很好,因为主线程也会被迫等待。
从Java 8开始引入了CompletableFuture,它针对Future做了改进,可以传入回调对象,当异步任务完成或者发生异常时,自动调用回调对象的回调方法。将Future和回调联合起来使用,无需担心末日金字塔问题。
(new CompletableFuture()).thenCompose((1)->{})
.thenCompose((2)->{})
.thenCompose((3)->{})
.thenCompose((4)->{})
.join()
Set<CompletableFuture> futures = new HashSet();
for () {
CompletableFuture<Void> future = client.sendAsync();
futures.add(future);
}
// 等待所有任务完成
CompletableFuture<Void> combinedFuture = CompletableFuture.allOf(futures.toArray((new CompletableFuture[0])));
combinedFuture.join();
// 如果Future有返回值,则可以:
futures.stream()
.map(CompletableFuture::join)
.filter(result -> result.statusCode() == 200)
.collect(Collectors.toList());
}
关键函数
-
thenAccept(): 无返回值
-
thenApply(): 有返回值
-
run() 执行任务,无返回值
-
supply() 执行任务,有返回值
-
anyOf 任意一个执行完成,就可以进行下一步动作
-
allOf 全部完成所有任务,才可以进行下一步任务
-
以run开头的方法,其入口参数一定是无参的,并且没有返回值,类似于执行Runnable方法。
-
以supply开头的方法,入口也是没有参数的,但是有返回值
-
以Accept开头或者结尾的方法,入口参数是有参数,但是没有返回值
-
以Apply开头或者结尾的方法,入口有参数,有返回值
-
带有either后缀的方法,表示谁先完成就消费谁
指定线程池
public class CustomCompletableFuture<T> extends CompletableFuture<T> {
private static final Executor executor = Executors.newSingleThreadExecutor(
runnable -> new Thread(runnable, "Custom-Single-Thread")
);
@Override
public Executor defaultExecutor() {
return executor;
}
}
Virtual Threads
Java 21提供了虚拟线程。和协程比较类似,是JVM级别的东西。
相同之处:
- 虚拟线程和协程都很轻量级,它们的创建和销毁开销小于传统的操作系统线程。
- 虚拟线程和协程都可以通过暂停和恢复在线程之间切换,从而避免线程上下文切换的开销。
- 虚拟线程和协程都可以以异步和非阻塞的方式处理任务,提高应用程序性能和响应速度。
不同之处: - 虚拟线程在JVM级别实现,而协程在语言级别实现。因此,虚拟线程的实现可以用于任何支持JVM的语言,而协程的实现需要特定编程语言的支持。
- 虚拟线程是协程的基于线程的实现,因此可以使用线程相关的API,如ThreadLocal,Lock和Semaphore。协程不依赖于线程,通常需要特定的异步编程框架和API。
- 虚拟线程的调度由JVM管理,而协程的调度由编程语言或异步编程框架管理。因此,虚拟线程可以更好地与其他线程合作,而协程更适合处理异步任务。
线程问题的诊断
通过jps 和 jstack 命令定位
线程dump: jstack pid 命令,在 Linux 环境下还可以使用 kill -3 pid
相关书籍
- Java并发编程的艺术
- Java并发编程深度解析与实战
- Java高并发核心编程 卷2(加强版):多线程、锁、JMM、JUC、高并发设计模式
- Java高并发编程详解:多线程与架构设计
- 深入理解Java高并发编程
- Java并发实现原理:JDK源码剖析