该文章为面试精华版,如果是初学者,建议学习专栏:Java并发专栏
文章目录
1. CountDownLatch
-
用来控制一个线程等待多个线程。
-
维护了一个计数器 cnt,每次调用 countDown() 方***让计数器的值减 1,减到 0 的时候,那些因为调用 await() 方法而在等待的线程就会被唤醒
-
计数器的初始值是线程的数量或者任务的数量
-
每当一个线程执行完毕后,计数器的值就-1,当计数器的值为0时,表示所有线程都执行完毕,然后在闭锁上等待的线程就可以恢复工作了。
-
通过CAS成功置为0的那个线程将会同时承担起唤醒队列中第一个节点线程的任务
不足:CountDownLatch是一次性的,计数器的值只能在构造方法中初始化一次,之后没有任何机制再次对其设置值,当CountDownLatch使用完毕后,它不能再次被使用。
文章地址:https://blog.csdn.net/qq_43040688/article/details/105935307
2. CyclicBarrier
-
用来控制多个线程互相等待,只有当多个线程都到达时,这些线程才会继续执行。
-
和 CountdownLatch 相似,都是通过维护计数器来实现的。线程执行 await() 方法之后计数器会减 1,并进行等待,直到计数器为 0,所有调用 await() 方法而在等待的线程才能继续执行。
-
CyclicBarrier 有两个构造函数,其中 parties 指示计数器的初始值,barrierAction 在所有线程都到达屏障的时候会执行一次。
CyclicBarrier 和 CountdownLatch 的区别?
- CyclicBarrier 的计数器通过调用 reset() 方法可以循环使用,所以它才叫做循环屏障,而CountdownLatch 是一次性的。
- CountdownLatch 用于一个线程等待一组线程执行完任务 ,CyclicBarrier 用于一组线程互相等待
3. Semaphore
Semaphore 类似于操作系统中的信号量,可以控制对互斥资源的访问线程数。
以下代码模拟了对某个服务的并发请求,每次只能有 3 个客户端同时访问,请求总数为 10。
public class SemaphoreExample {
public static void main(String[] args) {
final int clientCount = 3;
final int totalRequestCount = 10;
Semaphore semaphore = new Semaphore(clientCount);
ExecutorService executorService = Executors.newCachedThreadPool();
for (int i = 0; i < totalRequestCount; i++) {
executorService.execute(()->{
try {
semaphore.acquire();
System.out.print(semaphore.availablePermits() + " ");
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
semaphore.release();
}
});
}
executorService.shutdown();
}
}
实现循环打印?
下面的例子有ABC三个线程。A负责输出1 4 7 B负责输出2 5 8 C负责3 6 9。要求通过信号量机制 控制这三个线程按照顺序输出。
思路就是考虑前驱图,利用信号量实现:
public class Test {
public static void main(String[] args) {
new SemaphoreTest().print();
}
}
class SemaphoreTest {
private int i = 1;
private Semaphore s1 = new Semaphore(1);
private Semaphore s2 = new Semaphore(0);
private Semaphore s3 = new Semaphore(0);
private ExecutorService es = Executors.newFixedThreadPool(3);
public void print(){
es.execute(()->{
while (i<7){
try {
s1.acquire();
System.out.println(i);
i++;
} catch (InterruptedException e) {
e.printStackTrace();
}finally {
s2.release();
}
}
});
es.execute(()->{
while (i<8){
try {
s2.acquire();
System.out.println(i);
i++;
} catch (InterruptedException e) {
e.printStackTrace();
}finally {
s3.release();
}
}
});
es.execute(()->{
while (i<9){
try {
s3.acquire();
System.out.println(i);
i++;
} catch (InterruptedException e) {
e.printStackTrace();
}finally {
s1.release();
}
}
});
es.shutdown();
}
}
或者利用两个线程通过加锁,输出完就唤醒,自己等待,来实现循环打印
4. FutureTask
在介绍 Callable 时我们知道它可以有返回值,返回值通过 Future 进行封装。FutureTask 实现了 RunnableFuture 接口,该接口继承自 Runnable 和 Future 接口,这使得 FutureTask 既可以当做一个任务执行,也可以有返回值。
public class FutureTaskExample {
public static void main(String[] args) throws ExecutionException, InterruptedException {
FutureTask<Integer> futureTask = new FutureTask<Integer>(new Callable<Integer>() {
@Override
public Integer call() throws Exception {
int result = 0;for (int i = 0; i < 100; i++) {
Thread.sleep(10);
result += i;
}
return result;
}
});
Thread computeThread = new Thread(futureTask);
computeThread.start();
Thread otherThread = new Thread(() -> {
System.out.println("other task is running...");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
});
otherThread.start();
System.out.println(futureTask.get());//获取返回值
}
}
5. ForkJoin
一个用于并行执行任务的框架, 是一个把大任务分割成若干个小任务,最终汇总每个小任务结果后得到大任务结果的框架,主要用于并行计算中
思想:分治,fork分解任务,join收集数据
Java标准库提供的java.util.Arrays.parallelSort(array)
可以进行并行排序,它的原理就是内部通过Fork/Join对大数组分拆进行并行排序,在多核CPU上就可以大大提高排序的速度。
ForkJoinPool
ForkJoin 使用 ForkJoinPool 来启动,它是一个特殊的线程池,线程数量取决于 CPU 核数
ForkJoinPool 实现了工作窃取算法来提高 CPU 的利用率。每个线程都维护了一个双端队列,用来存储需要执行的任务。工作窃取算法允许空闲的线程从其它线程的双端队列中窃取一个任务来执行。窃取的任务必须是最晚的任务,避免和队列所属线程发生竞争。例如下图中,Thread2 从 Thread1 的队列中拿出最晚的 Task1 任务,Thread1 会拿出Task2 来执行,这样就避免发生竞争。但是如果队列中只有一个任务时还是会发生竞争。
ForkJoinTask
ForkJoinTask就是ForkJoinPool里面的每一个任务。他主要有两个子类:RecursiveAction
和RecursiveTask
。然后通过fork()方法去分配任务执行任务,通过join()方法汇总任务结果,
6. Exchange
- 用于两个工作线程之间交换数据的封装工具类
- 简单说就是一个线程在完成一定的事务后想与另一个线程交换数据,则第一个先拿出数据的线程会一直等待第二个线程,直到第二个线程拿着数据到来时才能彼此交换对应数据
- 如果交换的是引用类型,发送的对象和接收的对象是同一个对象,可能会用严重的线程安全问题
7. Condition
- 提供了类似Object的监视器方法,与Lock配合可以实现等待/通知模式,但是这两者在使用方式以及功能特性上还是有差别的,等待是用await,通知是signal或者signalAll。
- await和Object.wait()类似,都会自动的释放锁,并且在唤起后需要重新获得锁
- 想要await操作必须需要获得到锁
wait和await的区别?
wait | await |
---|---|
是配合synchronized关键字的 | 是配合Lock锁的 |
等待队列的唤醒受到JVM的影响,是随机的唤醒 | 等待队列FIFO的,先阻塞先唤醒 |
不可以被打断 | 可以被打断 |
等待队列只有一个 | 每一个Condition都具有一个等待队列,可以创建多个Condition |
8. StampedLock
如果我们深入分析ReadWriteLock,会发现它有个潜在的问题:如果有线程正在读,写线程需要等待读线程释放锁后才能获取写锁,即读的过程中不允许写,这是一种悲观的读锁。
要进一步提升并发执行效率,Java 8引入了新的读写锁:StampedLock。
出现的问题:如果有999个需要读锁,1个需要写锁,此时,写的线程,很难得到执行。
StampedLock和ReadWriteLock相比,改进之处在于:读的过程中也允许获取写锁后写入
!这样一来,我们读的数据就可能不一致,所以,需要一点额外的代码来判断读的过程中是否有写入,这种读锁是一种乐观锁。
乐观锁的意思就是乐观地估计读的过程中大概率不会有写入,因此被称为乐观锁。反过来,悲观锁则是读的过程中拒绝有写入,也就是写入必须等待。
显然乐观锁的并发效率更高,但一旦有小概率的写入导致读取的数据不一致,需要能检测出来,再读一遍就行。
这是个不可重入锁。
public class Point {
private final StampedLock stampedLock = new StampedLock();
private double x;
private double y;
public void move(double deltaX, double deltaY) {
long stamp = stampedLock.writeLock(); // 获取写锁
try {
x += deltaX;
y += deltaY;
} finally {
stampedLock.unlockWrite(stamp); // 释放写锁
}
}
public double distanceFromOrigin() {
long stamp = stampedLock.tryOptimisticRead(); // 获得一个乐观读锁
// 注意下面两行代码不是原子操作
// 假设x,y = (100,200)
double currentX = x;
// 此处已读取到x=100,但x,y可能被写线程修改为(300,400)
double currentY = y;
// 此处已读取到y,如果没有写入,读取是正确的(100,200)
// 如果有写入,读取是错误的(100,400)
if (!stampedLock.validate(stamp)) { // 检查乐观读锁后是否有其他写锁发生
stamp = stampedLock.readLock(); // 获取一个悲观读锁
try {
currentX = x;
currentY = y;
} finally {
stampedLock.unlockRead(stamp); // 释放悲观读锁
}
}
return Math.sqrt(currentX * currentX + currentY * currentY);
}
}
9. ReentrantReadWriteLock读写锁
一般情况下都不会使用
- 读锁和写锁是分离的
- 一个线程读的时候允许其他线程也可以读
- 一个线程写的时候不允许其他线程写
- 读和写也不允许同时进行
出现的问题是:可能会造成饥饿现象,写的线程迟迟无法执行任务
10. 锁的使用推荐
原文:https://blog.csdn.net/qq_43040688/article/details/106032189
比较
synchronized | StampedLock | Lock |
---|---|---|
是JVM的的内置锁,每个JDK版本都会优化 | 是一个Java类,可以更好的扩展 | 是一个Java类,可以更好的扩展 |
都是悲观锁 | 提供了写的乐观锁 | 都是悲观锁,但是提供了自旋锁,或者不阻塞的获取锁 |
性能一般,因为有一个从用户态到内核态的过程 | 性能最好,可以代替读写锁 | 性能十分不稳定,在复杂的读写环境下,性能十分差 |
不具有公平锁 | 不具有公平锁 | 具有公平锁 |
锁会自动释放 | 锁需要手动释放 | 锁需要手动释放 |
总结
StampedLock
是性能最好的,可以胜任复杂的读写多线程环境- 令人惊奇的是
synchronized
,由于是内置锁,每个JDK版本都会优化,尤其在复杂的读写多线程情况下,表现依然很优秀。 Lock
虽然提供了读写锁,但是性能特别差;而ReentrantLock
性能十分好,同时功能丰富
个人推荐:如果时读写环境,推荐使用StampedLock
;如果是正常的加锁,推荐使用synchronized
;如果需要对锁有更多的控制,推荐使用ReentrantLock