Java并发面试题
一、Java并发相关知识(上)
java并发使用场景:https://blog.csdn.net/qq_38470315/article/details/130611255
(对于一些复杂任务、需要在后台异步执行的任务都可以考虑使用多线程实现)
1.什么是线程和进程?
进程:
进程是程序的一次执行过程,是系统运行程序的基本单位,因此进程是动态的。系统运行一个程序即是一个进程从创建,运行到消亡的过程。
在 Java 中,当我们启动 main 函数时其实就是启动了一个 JVM 的进程,而 main 函数所在的线程就是这个进程中的一个线程,也称主线程;
线程:
线程是一个比进程更小的执行单位。一个进程在其执行的过程中可以产生多个线程。与进程不同的是同类的多个线程共享进程的堆和方法区资源,但每个线程有自己的程序计数器、虚拟机栈和本地方法栈,
所以系统在产生一个线程,或是在各个线程之间作切换工作时,负担要比进程小得多,也正因为如此,线程也被称为轻量级进程;
为什么程序计数器线程私有?——为了线程切换后能恢复到正确的执行位置;
为什么虚拟机栈和本地方法栈私有?——为了保证线程中的局部变量不被别的线程访问到;
一句话简单了解堆和方法区——堆:主要用于存放新创建的对象 ,方法区:主要用于存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据;
2.并发&并行、同步&异步
①并发&并行
- 并发:两个及两个以上的作业在同一 时间段 内执行。
- 并行:两个及两个以上的作业在同一 时刻 执行。
最关键的点是:是否是 同时 执行;
②同步&异步
- 同步:发出一个调用之后,在没有得到结果之前, 该调用就不可以返回,一直等待。
- 异步:调用在发出之后,不用等待返回结果,该调用直接返回;
3.多线程
①为什么要使用多线程?
提高系统的并发能力和性能,提高程序执行的效率,充分利用多核CPU的性能;
②使用多线程可能会带来哪些问题?
比如:内存泄漏、死锁、线程不安全等等;
③何为线程安全?
线程安全指的是在多线程环境下,对于同一份数据,不管有多少个线程同时访问,都能保证这份数据的正确性和一致性;
④单核 CPU 上运行多个线程效率一定会高吗?
单核 CPU 同时运行多个线程的效率是否会高,取决于线程的类型和任务的性质;
如果任务是 CPU 密集型的,则多线程会导致频繁的线程切换,增加了系统的开销,反而降低了效率;
如果任务是 IO 密集型的,那么多线程可以利用CPU在一个线程等待IO的时间执行其他线程,提高程序执行效率;
4.线程相关知识
①线程的生命周期和状态
Java线程在运行的生命周期中有6种状态:(比操作系统中的线程状态少了一个READY就绪状态,多了等待、超时等待两个状态)
NEW初始状态:线程被创建之后,被调用start()之前;
RUNNABLE可运行状态:线程被调用start()等待运行的状态,和正在运行的状态;
BLOACKED阻塞状态:等待锁释放;
WAITITNG等待状态:线程调用了wait()或sleep()方法,需等待其它线程做出指定动作方可返回RUNNABLE状态,例如通知或中断;
TIME_WAITING超时等待状态:等待指定时间,超过时间后自动返回;
TERMINATED终止状态:线程运行完毕;
为何JVM将RUNNING运行状态和READY就绪状态合并为RUNNABLE?
因为在现在的时分多任务操作系统架构下,通常采用时间分片的方式对线程进行调度,一个线程一次最多在CPU上运行10-20ms的时间(此时处于RUNNING状态),
之后就会退回READY就绪状态,线程切换如此之快,就没有区分两种状态的必要了;
②什么是线程上下文切换?
首先,上下文指的是线程在执行过程中的运行条件和状态,包括程序计数器、JVM栈和本地方法栈中的信息;
其次,线程在下列几种情况下会发生上下文切换:
通过调用sleep()、wait()方法等主动让出CPU;——RUNABLE变为TIME-WAITING、RUNABLE变为WAITING
时间片用完;——都属于RUNABLE
调用了阻塞类型的系统中断,例如请求IO和线程被阻塞;——RUNABLE变为BLOACKED
③线程死锁
死锁的四个必要条件:
互斥:资源同一时刻只允许一个线程占用;
不可剥夺:线程在获取资源之后,在使用完资源之前无法强行剥夺;
请求与保持:线程在因请求资源而阻塞时,会继续保持已有的资源不释放;
循环等待:多个线程之间的请求资源形成了循环;
如何预防和避免死锁?
破坏互斥条件:将互斥资源改造为共享资源;
破坏请求保持条件:一次性请求所有的资源;
破坏不可剥夺条件:允许优先级高的线程剥夺优先级低的线程占用的资源;
破坏循环等待条件:可以使用资源有序分配法,按照顺序申请资源,反序释放资源;——最实用
④wait()和sleep()对比
参考文章:wait和sleep的区别
相同点:都可以暂停线程的执行,都需要使用try-catch捕获异常;
不同点:
Ⅰ. wait方法必须配合synchronized一起调用,单独使用会抛出IllegalMonitorStateException异常,而sleep方法可以在任何地方调用;
Ⅱ. wait()方法可以通过别的线程调用同一对象上的notify()或notifyAll()方法来唤醒,或是通过wait(long timeout)带参数等时间到了苏醒,
而sleep()方法只能等待时间到了才会苏醒;
Ⅲ. wait()是Object类的本地方法,sleep()是Thread类的静态本地方法;
Ⅳ. wait()会释放当前线程占有的对象锁,需要重新获取锁才能唤醒,sleep()不会释放对象锁;
示例:
public class SleepAndWaitTest {
public static void main(String[] args) {
Object object = new Object();
new Thread(() -> {
synchronized (object){
try {
System.out.println("释放锁并等待");
object.wait();
// object.wait(10);
System.out.println("恢复执行");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}).start();
new Thread(() -> {
synchronized (object){
object.notify();
System.out.println("重新获得锁,并终止等待");
}
}).start();
new Thread(() -> {
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
}
}
结果如下:
释放锁并等待
重新获得锁,并终止等待
恢复执行
⑤可以直接调用 Thread 类的 run 方法吗?
new 一个 Thread
,线程进入了新建状态,调用 start()
方法方可启动线程并使线程进入就绪状态,start()
会执行线程的相应准备工作,然后自动执行 run()
方法的内容,
如果手动直接执行 run()
方法的话,会把 run()
方法当成一个 main 线程下的普通方法去执行,而不会以多线程的方式执行;
5. 关于多线程的一些思考与实践
①为什么给类/对象加锁之前,各线程的total值不同步,即使给total加了volatile也不行?
public class MyThread extends Thread{
private int total = 10;
private static Object object = new Object();
@Override
public void run() {
while(total > 0){
// 不加锁
System.out.println(Thread.currentThread().getName() + "剩余数量为: " + total);
total--;
}
}
}
Thread-1剩余数量为: 10
Thread-1剩余数量为: 9
Thread-2剩余数量为: 10
Thread-2剩余数量为: 7
Thread-2剩余数量为: 6
Thread-2剩余数量为: 5
Thread-2剩余数量为: 4
Thread-2剩余数量为: 3
Thread-2剩余数量为: 2
Thread-2剩余数量为: 1
Thread-1剩余数量为: 8
首先,total是类的属性,是所有线程共享的(线程私有的变量只包括方法内的局部变量、传入参数、返回值);
其次,在不加锁的情况下,多个线程在执行run()方法时完全是无序的,可能一个线程执行了打印语句,另一个线程又执行了自减语句,因此导致结果无序;
②为什么给类/对象加锁之后,各线程之间total的值就同步了?
public class MyThread extends Thread{
private int total = 100;
private static Object object = new Object();
@Override
public void run() {
while(total > 0){
// 给类加锁
synchronized (MyThread.class){
System.out.println(Thread.currentThread().getName() + "剩余数量为: " + total);
total--;
}
// 给实例加锁
// synchronized (object){
// System.out.println(Thread.currentThread().getName() + "剩余数量为: " + total);
// total--;
// }
}
}
}
Thread-1剩余数量为: 10
Thread-3剩余数量为: 9
Thread-3剩余数量为: 8
Thread-3剩余数量为: 7
Thread-2剩余数量为: 6
Thread-2剩余数量为: 5
Thread-2剩余数量为: 4
Thread-2剩余数量为: 3
Thread-2剩余数量为: 2
Thread-2剩余数量为: 1
Thread-3剩余数量为: 0
Thread-1剩余数量为: -1
因为在给打印语句和自减语句加锁(类/实例锁都行)之后,只有一个线程在执行完这两条语句后,另一个线程才能开始执行这两条语句,因此结果有序;
③下面创建多线程的方式有什么不同?——synchronized锁的使用
Ⅰ. 无效的多线程
public class MyThread extends Thread{
private int total = 10;
private Object object = new Object();
@Override
public void run() {
while(total > 0){
// 给实例加锁
synchronized (object){
System.out.println(Thread.currentThread().getName() + "剩余数量为: " + total);
total--;
}
}
}
}
public static void main(String[] args) {
MyThread myThread1 = new MyThread();
MyThread myThread2 = new MyThread();
MyThread myThread3 = new MyThread();
myThread1.start();
myThread2.start();
myThread3.start();
}
上面是创建了三个对象,每个对象都有各自的total属性和object属性,
因为只给实例加锁,所以对这三个线程无效,仍可同时进入代码块,无法保证线程安全,
但因为total属性也是每个对象各有一个,所以即使线程不安全也不会不会互相干扰;
当对三个对象都调用start方法时,结果会如下:
Thread-0剩余数量为: 10
Thread-2剩余数量为: 10
Thread-2剩余数量为: 9
Thread-2剩余数量为: 8
Thread-2剩余数量为: 7
Thread-2剩余数量为: 6
Thread-2剩余数量为: 5
Thread-2剩余数量为: 4
Thread-2剩余数量为: 3
Thread-2剩余数量为: 2
Thread-1剩余数量为: 10
Thread-1剩余数量为: 9
Thread-1剩余数量为: 8
Thread-1剩余数量为: 7
Thread-1剩余数量为: 6
Thread-1剩余数量为: 5
Thread-1剩余数量为: 4
Thread-1剩余数量为: 3
Thread-1剩余数量为: 2
Thread-1剩余数量为: 1
Thread-2剩余数量为: 1
Thread-0剩余数量为: 9
Thread-0剩余数量为: 8
Thread-0剩余数量为: 7
Thread-0剩余数量为: 6
Thread-0剩余数量为: 5
Thread-0剩余数量为: 4
Thread-0剩余数量为: 3
Thread-0剩余数量为: 2
Thread-0剩余数量为: 1
此时这三个线程各不相干,完全独立,也就不存在线程安全的问题;
Ⅱ. 有效但不安全的多线程
要想体现出多线程的作用,应当让其共同执行同一个方法,
那么在Ⅰ的基础上,可以将MyThread中的total属性改为静态变量,这样创建的三个对象就有了共同的total属性,执行run()方法也是对同一个total变量进行处理,
private static int total = 10;
其他代码不变,再次执行main()方法,结果如下:
Thread-1剩余数量为: 10
Thread-1剩余数量为: 9
Thread-1剩余数量为: 8
Thread-1剩余数量为: 7
Thread-1剩余数量为: 6
Thread-1剩余数量为: 5
Thread-1剩余数量为: 4
Thread-1剩余数量为: 3
Thread-1剩余数量为: 2
Thread-1剩余数量为: 1
Thread-0剩余数量为: 10
Thread-2剩余数量为: 8
因为只给实例加锁,所以对这三个线程无效,仍可同时进入代码块,无法保证线程安全,所以结果混乱无序;
Ⅲ. 有效且安全的多线程
因为三个线程都有各自的object实例,实例锁失效,为了让锁生效,
可以将object也变成静态变量,所有实例共用这一个object,同一时刻只能有一个拥有此object实例的线程进入代码块:
private static Object object = new Object();
也可以将实例锁改为类锁,同一时刻只能有一个拥有MyThread类的线程进入代码块(此时不需要object了,直接去掉):
public class MyThread extends Thread{
private static int total = 10;
@Override
public void run() {
while(total > 0){
// 给类加锁
synchronized (MyThread.class){
System.out.println(Thread.currentThread().getName() + "剩余数量为: " + total);
total--;
}
}
}
}
其他代码不变,再次执行main()方法,结果如下:
Thread-0剩余数量为: 10
Thread-0剩余数量为: 9
Thread-0剩余数量为: 8
Thread-0剩余数量为: 7
Thread-0剩余数量为: 6
Thread-0剩余数量为: 5
Thread-0剩余数量为: 4
Thread-0剩余数量为: 3
Thread-0剩余数量为: 2
Thread-2剩余数量为: 1
Thread-1剩余数量为: 0
Thread-0剩余数量为: -1
可以看到锁生效了,多线程运行情况下结果严格有序;
Ⅳ. 共用一个对象的多线程
public class MyThread extends Thread{
private int total = 10;
private Object object = new Object();
@Override
public void run() {
while(total > 0){
// 给实例加锁
synchronized (object){
System.out.println(Thread.currentThread().getName() + "剩余数量为: " + total);
total--;
}
// 给类加锁
// synchronized (MyThread.class){
// System.out.println(Thread.currentThread().getName() + "剩余数量为: " + total);
// total--;
// }
}
}
}
public static void main(String[] args) {
MyThread myThread = new MyThread();
Thread thread = new Thread(myThread);
Thread thread1 = new Thread(myThread);
Thread thread2 = new Thread(myThread);
thread.start();
thread1.start();
thread2.start();
}
这里只创建了一个MyThread,然后以其为入参创建了三个Thread,
因此即使这里total、object不是静态变量,但这三个线程依旧是共享同一个total和object的,
故这里即使total不是静态变量,依旧是有效的多线程,
即使只给object加实例锁,也不会导致线程不安全,多线程运行的结果依旧是有序的;
结果如下:
Thread-1剩余数量为: 10
Thread-1剩余数量为: 9
Thread-1剩余数量为: 8
Thread-3剩余数量为: 7
Thread-3剩余数量为: 6
Thread-3剩余数量为: 5
Thread-3剩余数量为: 4
Thread-3剩余数量为: 3
Thread-3剩余数量为: 2
Thread-3剩余数量为: 1
Thread-2剩余数量为: 0
Thread-1剩余数量为: -1
④ReentrantLock实践
参考文章:ReentrantLock并发锁使用详解
Ⅰ. 基本使用:
public class ReentrantLockDemo {
private static int total = 10;
private static ReentrantLock reentrantLock = new ReentrantLock();
public static void main(String[] args) {
for (int i = 0; i < 3; i++) {
new Thread(){
@Override
public void run() {
while (total > 0){
// 加锁
reentrantLock.lock();
try{
System.out.println(Thread.currentThread().getName() + "剩余数量为: " + total);
total--;
} finally {
// 解锁--一定要在finally中解锁,防止业务代码异常,无法释放锁
reentrantLock.unlock();
}
}
}
}.start();
}
}
}
基本功能和synchronized一样,对代码块进行加锁,同一时刻只允许一个线程访问,在三个线程并发执行的情况下,结果如下:
Thread-0剩余数量为: 10
Thread-0剩余数量为: 9
Thread-0剩余数量为: 8
Thread-0剩余数量为: 7
Thread-0剩余数量为: 6
Thread-0剩余数量为: 5
Thread-0剩余数量为: 4
Thread-0剩余数量为: 3
Thread-0剩余数量为: 2
Thread-0剩余数量为: 1
Thread-2剩余数量为: 0
Thread-1剩余数量为: -1
可以看到结果有序;
不过使用上的区别在于ReentranLock需要new出对象来,然后在需要加锁的代码段前后分别执行.lock()和.unlock()方法,实获得锁和释放锁,
需要注意的是,lock()方法之后紧跟try代码块,unlock()方法必须则必须在finally中,确保一定会释放锁;
Ⅱ. 可重入锁(ReentranLock和synchronized都是可重入锁)
可重入锁也叫递归锁,指当一个线程获得锁时,其内部可以再次获得这个锁;
public class ReentrantLockDemo1 {
private static ReentrantLock reentrantLock = new ReentrantLock();
public static void method1(){
reentrantLock.lock();
try {
method2();
}finally {
reentrantLock.unlock();
}
}
public static void method2(){
reentrantLock.lock();
try {
method3();
}finally {
reentrantLock.unlock();
}
}
public static void method3(){
reentrantLock.lock();
try {
System.out.println("这是方法三");
}finally {
reentrantLock.unlock();
}
}
public static void main(String[] args) {
method1();
}
}
执行结果:
这是方法三
可以看到,主线程多次获得了锁reentranLock,执行了method3();
Ⅲ. 锁中断
ReebtranLock提供了一种能够中断等待锁的线程的机制:
当一个线程使用.lockInterruptibly()方法来获得锁,并且处于等待锁的过程中时,其他线程可以通过.interrupt()方法来中断其等待锁的过程;
(线程在等待synchronized锁过程中则不可被其他线程中断)
public class ReentrantLockDemo2 {
public static void main(String[] args) {
ReentrantLock reentrantLock = new ReentrantLock();
Thread thread = new Thread(() -> {
System.out.println("thread线程启动");
try {
reentrantLock.lockInterruptibly();
try {
System.out.println("thread线程成功获得锁");
}finally {
reentrantLock.unlock();
}
} catch (InterruptedException e) {
System.out.println("thread线程等待锁的过程被其他线程中断");
e.printStackTrace();
}
});
reentrantLock.lock();
try {
System.out.println("main线程成功获得锁");
thread.start();
Thread.sleep(1000);
thread.interrupt();
System.out.println("主线程中断了thread线程的等待锁过程");
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
reentrantLock.unlock();
}
}
}
结果如下:
main线程成功获得锁
thread线程启动
主线程中断了thread线程的等待锁过程
thread线程等待锁的过程被其他线程中断
java.lang.InterruptedException
at java.util.concurrent.locks.AbstractQueuedSynchronizer.doAcquireInterruptibly(AbstractQueuedSynchronizer.java:898)
at java.util.concurrent.locks.AbstractQueuedSynchronizer.acquireInterruptibly(AbstractQueuedSynchronizer.java:1222)
at java.util.concurrent.locks.ReentrantLock.lockInterruptibly(ReentrantLock.java:335)
at com.tzc.multithread.reentranlock.ReentrantLockDemo2.lambda$main$0(ReentrantLockDemo2.java:19)
at java.lang.Thread.run(Thread.java:748)
可以看到,试图获得reentranLock锁的thread线程在等待锁过程中,被主线程使用interrupt()方法中断了,最终未能获得锁;
(如果将thread.interrupt();注释掉,则可以看到thread线程在主线程释放锁之后,是可以获得锁的)
Ⅳ. 获得锁超时失败
当一个线程使用.tryLock()方法来获得锁并设定最长等待时间时,若其等待锁时间超过设定时间,该方法就会返回false,此时可以进行超时后的操作;
public class ReentrantLockDemo3 {
public static void main(String[] args) {
ReentrantLock reentrantLock = new ReentrantLock();
Thread thread = new Thread(() -> {
System.out.println("thread线程启动");
try {
if(!reentrantLock.tryLock(1, TimeUnit.SECONDS)){
System.out.println("thread线程等待超过1s,获取锁失败,返回");
return;
}
} catch (InterruptedException e) {
e.printStackTrace();
return;
}
try {
System.out.println("thread线程成功获得锁");
}finally {
reentrantLock.unlock();
}
});
reentrantLock.lock();
try {
System.out.println("main线程成功获得锁");
thread.start();
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}finally {
reentrantLock.unlock();
}
}
}
结果如下:
main线程成功获得锁
thread线程启动
thread线程等待超过1s,获取锁失败,返回
可以看到,我们设定thread线程在获得锁过程中,等待锁时间最长为1s,超时就返回,
然后让main线程先获得锁并等待2s,果然thread线程获得锁超时失败,执行了返回操作;
(如果等待时间少于1s,则thread线程可以正常获得锁,不会超时失败返回)
Ⅴ. 公平锁
公平锁:严格按照线程申请的顺序获取锁(会专门为此维护一个队列),缺点是性能差一点;
非公平锁:随机或按照优先级获取锁,后申请的线程可能先获得锁,缺点是可能会导致某些低优先级线程永远无法获得锁;
ReentranLock默认是非公平锁,如果需要公平锁可以在创建实例时传入参数true;
public class ReentrantLockDemo4 {
public static void main(String[] args) {
ReentrantLock reentrantLock = new ReentrantLock(true);
// ReentrantLock reentrantLock = new ReentrantLock();
for (int i = 0; i < 10; i++) {
new Thread(() -> {
reentrantLock.lock();
try {
System.out.println(Thread.currentThread().getName() + " is running");
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
}finally {
reentrantLock.unlock();
}
}).start();
}
for (int i = 0; i < 10; i++) {
new Thread(() -> {
reentrantLock.lock();
try {
System.out.println(Thread.currentThread().getName() + "抢先获得了锁");
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
}finally {
reentrantLock.unlock();
}
}).start();
}
}
}
结果如下:
Thread-0 is running
Thread-2 is running
Thread-4 is running
Thread-6 is running
Thread-1 is running
Thread-5 is running
Thread-3 is running
Thread-7 is running
Thread-9 is running
Thread-8 is running
Thread-10抢先获得了锁
Thread-11抢先获得了锁
Thread-12抢先获得了锁
Thread-15抢先获得了锁
Thread-14抢先获得了锁
Thread-13抢先获得了锁
Thread-16抢先获得了锁
Thread-17抢先获得了锁
Thread-18抢先获得了锁
Thread-19抢先获得了锁
⑤ThreadLocal的使用场景
参考文章:浅谈 ThreadLocal 的实际运用
Ⅰ. 有些场景可以通过让每个线程都有一份数据副本,来解决线程同步的问题(以空间换时间的思想)
例如下面的代码,让20个线程分别访问NormalThread类中的date方法,各自根据i生成0到19s的时间,并以"mm:ss"格式打印出来;
public class NormalThread {
// SimpleDateFormat是线程不安全的,允许多个线程同时访问
private static SimpleDateFormat simpleDateFormat = new SimpleDateFormat("mm:ss");
public static void main(String[] args) {
for (int i = 0; i < 20; i++) {
final int finalI = i;
new Thread(() -> {
String date = new NormalThread().date(finalI);
System.out.println(date);
}).start();
}
}
public String date(int seconds){
Date date = new Date(1000 * seconds);
return simpleDateFormat.format(date);
// synchronized (NormalThread.class){
// Date date = new Date(1000 * seconds);
// return simpleDateFormat.format(date);
// }
}
}
结果如下:
00:13
00:07
00:04
00:03
00:08
00:08
00:08
00:00
00:11
00:08
00:12
00:09
00:14
00:15
00:17
00:18
00:19
00:05
00:10
00:16
可以看到,由于SimpleDateFormat并非线程安全的类,故在多线程情况下线程同步产生了问题,多个线程打印出了相同的时间;
要解决这一问题,可以使用锁来解决线程同步问题,例如直接给date方法内的代码块加上类锁(date是实例方法,如果给方法加锁只能是对象锁,这里每个线程都会生成一个NormalThread实例对象,对象锁没用);
但加锁会影响效率,有没有不加锁的解决方案呢?
有,因为这里的线程同步问题出在多个线程一起访问静态对象simpleDateFormat,
那么可以通过将simpleDateFormat变为非静态对象来解决,但这样改变了变量的状态,假使simpleDateFormat必须是静态变量,或是换一种创建线程的方式(共用一个)就不行了;
合理的做法是用ThreadLocal包住,让每个线程都生成一个数据副本,这样在单线程情况下对这个对象的访问也可以互不干扰;(非静态对象是每个实例中有一份,ThreadLocal是每个线程中有一份)
使用ThreadLocal版本如下:
(注意:
ThreadLocal有两种创建方式,一种是带初始值的,使用ThreadLocal.withInitial()来创建,一种不带初始值,直接用空参构造器;
ThreadLocal类型的变量要用final修饰,因为ThreadLocal和其中泛型所存的值的关系是key和value的关系,Map存在于每个线程中,一个ThreadLocal对应一个值,如果ThreadLocal可变,那在改变之后通过就找不到原来的value了;
给ThreadLocal对象赋值和获取值用set()、get()方法即可;
为防止内存泄漏,在使用完ThreadLocal后,应手动调用remove()进行清除;)
public class LocalThread {
/**
* ThreadLocal有两种创建方式,一种是带初始值的,使用ThreadLocal.withInitial()来创建,一种不带初始值,直接用空参构造器
*
* */
public static final ThreadLocal<SimpleDateFormat> threadLocal =
ThreadLocal.withInitial(() -> new SimpleDateFormat("mm:ss"));
// public static final ThreadLocal<SimpleDateFormat> threadLocal = new ThreadLocal<>();
public static void main(String[] args) {
for (int i = 0; i < 20; i++) {
final int finalI = i;
new Thread(() -> {
String date = new LocalThread().date(finalI);
System.out.println(date);
}).start();
// 为防止内存泄漏,在使用完ThreadLocal后,应手动调用remove()进行清除
threadLocal.remove();
}
}
public String date(int seconds){
// 给ThreadLocal对象赋值和获取值用set()、get()方法即可
Date date = new Date(1000 * seconds);
// threadLocal.set(new SimpleDateFormat("mm:ss"));
return threadLocal.get().format(date);
}
}
结果如下:
00:13
00:14
00:15
00:03
00:16
00:06
00:12
00:19
00:18
00:11
00:00
00:09
00:07
00:17
00:08
00:05
00:04
00:10
00:01
00:02
可以看到结果是没有重复时间的;
Ⅱ. 有些需要static全局变量,但又不能被多线程共享的变量(比如用户信息,不同线程中信息是不一样的)——Ⅰ其实也符合这一条件
此时就可以考虑用ThreadLocal包裹这类变量;
二、Java并发相关知识(中)
1.volatile
①如何保证变量的可见性?
volatile
关键字可以保证变量的可见性,如果我们将变量声明为 volatile
,这就指示 JVM,这个变量是共享且不稳定的,每次使用它都到主存中进行读取;
读volatile:每当子线程某一语句要用到volatile变量时,都会从主线程重新拷贝一份,这样就保证子线程的会跟主线程的一致。
写volatile: 每当子线程某一语句要写volatile变量时,都会在读完后同步到主线程去,这样就保证主线程的变量及时更新;
②如何禁止指令重排?——双重检验锁方式实现多线程情况下的单例模式
指令重排指的是JVM为了提高程序的性能,可能会改变源代码中指令执行的顺序;
将变量声明为 volatile
,在对这个变量进行读写操作的时候,就会通过插入特定的 内存屏障 的方式来禁止指令重排序;
案例——双重检验锁方式实现单例模式
public class Singleton {
private volatile static Singleton uniqueInstance;
private Singleton() {
}
public static Singleton getUniqueInstance() {
//先判断对象是否已经实例过,没有实例化过才进入加锁代码
if (uniqueInstance == null) {
//类对象加锁
synchronized (Singleton.class) {
if (uniqueInstance == null) {
uniqueInstance = new Singleton();
}
}
}
return uniqueInstance;
}
}
原理分析:(参考文章:https://blog.csdn.net/weixin_50005657/article/details/115803998)
Ⅰ.将uniqueInstance声明为volatile类的作用:
一方面保证了在多线程情况下所有线程都能获得uniqueInstance的最新状态;
另一方面,通过禁止对象创建时指令的重新排序,避免了一个线程访问到另一个线程未初始化的对象;
Ⅱ.两次判空的原因:
第一次判空:防止在uniqueInstance已经初始化的情况下还继续调用synchronized()同步锁,造成不必要的性能损耗;
第二次判空:保证只有在uniqueInstance为null的情况下才会创建实例;(因为在对类对象加锁之前是不能保证只有一个线程在对uniqueInstance进行操作的,第一次判空不能保证两次判空之间其他线程没有完成实例化)
Ⅲ.对类对象加锁的作用:防止多线程同时进入,创建多个实例;
③volatile可以保证原子性吗?
volatile
关键字能保证数据的可见性,但不能保证数据的原子性。synchronized
关键字两者都能保证;
2.乐观锁和悲观锁
①悲观锁
悲观锁每次在获取资源操作的时候都会上锁,这样其他线程想拿到这个资源就会阻塞直到锁被上一个持有者释放;
像 Java 中synchronized
和ReentrantLock
等独占锁就是悲观锁思想的实现
public void performSynchronisedTask() {
synchronized (this) {
// 需要同步的操作
}
}
private Lock lock = new ReentrantLock();
lock.lock();
try {
// 需要同步的操作
} finally {
lock.unlock();
}
缺点:高并发的场景下,激烈的锁竞争会造成线程阻塞,大量阻塞线程会导致系统的上下文切换,增加系统的性能开销。并且,悲观锁还可能会存在死锁问题,影响代码的正常运行;
②乐观锁
乐观锁在获取资源时不会上锁,只会在提交修改的时候去验证对应的资源是否被其它线程修改了(具体方法可以使用版本号机制或 CAS 算法);
适用场景:
- 悲观锁通常多用于写比较多的情况(多写场景,竞争激烈),这样可以避免频繁失败和重试影响性能,悲观锁的开销是固定的。不过,如果乐观锁解决了频繁失败和重试这个问题的话(比如
LongAdder
),也是可以考虑使用乐观锁的,要视实际情况而定。 - 乐观锁通常多用于写比较少的情况(多读场景,竞争较少),这样可以避免频繁加锁影响性能。不过,乐观锁主要针对的对象是单个共享变量(参考
java.util.concurrent.atomic
包下面的原子变量类);
③如何实现乐观锁——CAS算法
CAS 的全称是 Compare And Swap(比较与交换) ,CAS 的思想很简单,就是用一个预期值和要更新的变量值进行比较,两值相等才会进行更新
CAS 是一个原子操作,底层依赖于一条 CPU 的原子指令,
CAS 涉及到三个操作数:
- V:要更新的变量值(Var)
- E:预期值(Expected)
- N:拟写入的新值(New)
当且仅当 V 的值等于 E 时,CAS 通过原子方式用新值 N 来更新 V 的值。如果不等,说明已经有其它线程更新了 V,则当前线程放弃更新;
④乐观锁存在哪些问题?
Ⅰ.ABA问题——最常见
如果一个变量 V 初次读取的时候是 A 值,并且在准备赋值的时候检查到它仍然是 A 值,那我们就能说明它的值没有被其他线程修改过了吗?很明显是不能的,因为在这段时间它的值可能被改为其他值,然后又改回 A,那 CAS 操作就会误认为它从来没有被修改过。这个问题被称为 CAS 操作的 "ABA"问题;
ABA 问题的解决思路是在变量前面追加上版本号或者时间戳;
3.Synchronized
①什么是synchronized
synchronized
是 Java 中的一个关键字,主要解决的是多个线程之间访问资源的同步性,可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行;
②如何使用 synchronized?
Ⅰ.修饰实例方法 (锁当前对象实例)
给当前对象实例加锁,进入同步代码前要获得 当前对象实例的锁
synchronized void method() {
//业务代码
}
Ⅱ.修饰静态方法 (锁当前类)
给当前类加锁,会作用于类的所有对象实例 ,进入同步代码前要获得 当前 class 的锁
synchronized static void method() {
//业务代码
}
类锁和实例锁会互斥吗?/静态synchronized方法和非静态synchronized方法互斥吗?
不互斥,可以同时访问;
Ⅲ.修饰代码块 (锁指定对象/类)
直接对括号里指定的对象/类加锁:
synchronized(object)
表示进入同步代码库前要获得 给定对象的锁。synchronized(类.class)
表示进入同步代码前要获得 给定 Class 的锁;
synchronized(this) {
//业务代码
}
构造方法可以用synchronized修饰吗?
不能,因为构造方法本身就属于线程安全的,不存在同步的构造方法一说;
③synchronized底层原理
synchronized
同步代码块的实现使用的是 monitorenter
和 monitorexit
指令,其中 monitorenter
指令指向同步代码块的开始位置,monitorexit
指令则指明同步代码块的结束位置;
synchronized
修饰方法时通过ACC_SYNCHRONIZED
标识来实现,该标识指明了该方法是一个同步方法。JVM 通过该 ACC_SYNCHRONIZED
访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用;
两者的本质都是对对象监视器 monitor 的获取;
④JDK1.6 之后的 synchronized 底层做了哪些优化?
JDK1.6 对锁的实现引入了大量的优化,如偏向锁、轻量级锁、自旋锁、适应性自旋锁、锁消除、锁粗化等技术来减少锁操作的开销。
锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。注意锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率;
⑤synchronized 和 volatile 有什么区别?
volatile
关键字是线程同步的轻量级实现,所以volatile
性能肯定比synchronized
关键字要好 。但是volatile
关键字只能用于变量而synchronized
关键字可以修饰方法以及代码块 。volatile
关键字能保证数据的可见性,但不能保证数据的原子性。synchronized
关键字两者都能保证。volatile
关键字主要用于解决变量在多个线程之间的可见性,而synchronized
关键字解决的是多个线程之间访问资源的同步性;
4.ReentrantLock
①ReentrantLock 是什么?
ReentrantLock
实现了 Lock
接口,是一个可重入且独占式的锁,和 synchronized
关键字类似。不过,ReentrantLock
更灵活、更强大,增加了轮询、超时、中断、公平锁和非公平锁等高级功能;
ReentrantLock
里面有一个内部类 Sync
,Sync
继承 AQS(AbstractQueuedSynchronizer
),添加锁和释放锁的大部分操作实际上都是在 Sync
中实现的。
Sync
有公平锁 FairSync
和非公平锁 NonfairSync
两个子类,ReentrantLock
默认使用非公平锁,也可以通过构造器来显式的指定使用公平锁;
// 传入一个 boolean 值,true 时为公平锁,false 时为非公平锁
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
②公平锁和非公平锁有什么区别?
- 公平锁 : 锁被释放之后,先申请的线程先得到锁。性能较差一些,因为公平锁为了保证时间上的绝对顺序,上下文切换更频繁。
- 非公平锁:锁被释放之后,后申请的线程可能会先获取到锁,是随机或者按照其他优先级排序的。性能更好,但可能会导致某些线程永远无法获取到锁;
③synchronized 和 ReentrantLock 有什么异同?
相同点:
两者都是可重入锁;(可重入锁 也叫递归锁,指的是线程可以再次获取自己的内部锁。)
不同点:
synchronized
是依赖于 JVM 实现的,而ReentrantLock
是 JDK 层面实现的;
在发生异常时synchronized会自动释放锁,而ReentrantLock需要开发者在finally块中显示释放锁;
ReentrantLock 比 synchronized 增加了一些高级功能,主要来说主要有三点:
- 等待可中断 : 获取锁的过程中可以被中断,不需要一直等到获取锁之后 才能进行其他逻辑处理;
- 可实现公平锁 :
ReentrantLock
可以指定是公平锁还是非公平锁。而synchronized
只能是非公平锁; - 可实现选择性通知:在一个
Lock
对象中可以创建多个Condition
实例(即对象监视器),线程对象可以注册在指定的Condition
中,从而可以有选择性的进行线程通知;(synchronized
关键字就相当于整个Lock
对象中只有一个Condition
实例,所有的线程都注册在它一个身上,如果执行notifyAll()
方法的话就会通知所有处于等待状态的线程)
④可中断锁和不可中断锁有什么区别?
- 可中断锁:获取锁的过程中可以被中断,不需要一直等到获取锁之后 才能进行其他逻辑处理。
ReentrantLock
就属于是可中断锁。 - 不可中断锁:一旦线程申请了锁,就只能等到拿到锁以后才能进行其他的逻辑处理。
synchronized
就属于是不可中断锁;
5.AQS原理
参考文章:https://mp.weixin.qq.com/s/jEx-4XhNGOFdCo4Nou5tqg
https://javaguide.cn/java/concurrent/reentrantlock.html#_2-aqs
①什么是AQS?有什么用?
Java 中常用的锁主要有两类,一种是 Synchronized 修饰的锁,被称为 Java 内置锁或监视器锁。
另一种就是在 J2SE 1.5版本之后的 java.util.concurrent包(下称j.u.c包)中的各类同步器,包括 ReentrantLock(可重入锁),ReentrantReadWriteLock(可重入读写锁),Semaphore(信号量),CountDownLatch 等;
这些同步器都是基于 AbstractQueuedSynchronizer(下称 AQS)这个简单的框架来构建的,而 AQS 类的核心数据结构是一种名为 Craig, Landin, and Hagersten locks(下称 CLH 锁)的变体;
②AQS原理
AQS(AbstractQueuedSynchronizer)是一个抽象类,底层是通过Java中的Lock和Condition接口实现的。
在AQS的底层实现中,主要使用了一个双向链表来维护等待获取锁的线程队列。
当一个线程请求获取锁时,如果锁已经被其他线程占用,则该线程会被加入到队列中,并进入等待状态。当持有锁的线程释放锁时,AQS会从队列中选择一个线程唤醒并允许其获取锁。
它提供了两种同步模式:独占模式(Exclusive)和共享模式(Shared)。
AQS还提供了一些核心的方法,如acquire、release和tryAcquire等,用于实现具体的同步操作。这些方法通过内置的状态变量(state)来记录锁的状态,并根据不同的情况进行相应的操作
③自旋锁——CLH 锁是对自旋锁的一种改良
什么是自旋锁?
当一个线程尝试去获取某一把锁的时候,如果这个锁此时已经被别人获取(占用),那么此线程就无法获取到这把锁,该线程将会等待,间隔一段时间后会再次尝试获取。
这种采用循环加锁 -> 等待的机制被称为自旋锁(spinlock)
,自旋锁是互斥锁的一种实现;
Java实现自旋锁:
public class SpinLock {
private AtomicReference<Thread> owner = new AtomicReference<Thread>();
public void lock() {
Thread currentThread = Thread.currentThread();
// 如果锁未被占用,则设置当前线程为锁的拥有者
while (!owner.compareAndSet(null, currentThread)) {
}
}
public void unlock() {
Thread currentThread = Thread.currentThread();
// 只有锁的拥有者才能释放锁
owner.compareAndSet(currentThread, null);
}
}
如代码所示,获取锁时,线程会对一个原子变量循环执行 compareAndSet 方法,直到该方法返回成功时即为成功获取锁。
compareAndSet 方法底层是通用 compare-and-swap (下称 CAS)实现的。该操作通过将内存中的值与指定数据进行比较,当数值一样时将内存中的数据替换为新的值。
该操作是原子操作。原子性保证了根据最新信息计算出新值,如果与此同时值已由另一个线程更新,则写入将失败。因此,这段代码可以实现互斥锁的功能;
自旋锁的优点:
实现简单,同时避免了操作系统进程调度和线程上下文切换的开销;
自旋锁的缺点:
第一个是锁饥饿问题。在锁竞争激烈的情况下,可能存在一个线程一直被其他线程”插队“而一直获取不到锁的情况;
第二是性能问题。在实际的多处理上运行的自旋锁在锁竞争激烈时性能较差;
自旋锁适用于锁竞争不激烈、锁持有时间短的场景;
④经AQS改进后的CLH锁
AQS 将每条请求共享资源的线程封装成一个 CLH 队列锁的一个结点(Node)来实现锁的分配。
在 CLH 队列锁中,一个节点表示一个线程,它保存着线程的引用(thread)、 当前节点在队列中的状态(waitStatus)、前驱节点(prev)、后继节点(next);
AQS 使用 int 成员变量 state
表示同步状态,通过内置的 FIFO 线程等待/等待队列 来完成获取资源线程的排队工作。
state
变量由 volatile
修饰,用于展示当前临界资源的获锁情况;
状态信息 state
可以通过 protected
类型的getState()
、setState()
和compareAndSetState()
进行操作。并且,这几个方法都是 final
修饰的,在子类中无法被重写:
//返回同步状态的当前值
protected final int getState() {
return state;
}
// 设置同步状态的值
protected final void setState(int newState) {
state = newState;
}
//原子地(CAS操作)将同步状态值设置为给定值update如果当前同步状态的值等于expect(期望值)
protected final boolean compareAndSetState(int expect, int update) {
return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}
三、Java并发相关知识(下)
1.ThreadLocal
①ThreadLocal是什么?
通常情况下,我们创建的变量是可以被任何一个线程访问并修改的;
ThreadLocal
类就是让每个线程绑定自己的值,访问ThreadLocal
变量的每个线程都会有这个变量的本地副本,读和写都是对各自的副本进行操作,互不干扰;
可以使用 get()
和 set()
方法来对当前线程的变量副本进行读、写操作;
使用示例:
import java.text.SimpleDateFormat;
import java.util.Random;
public class ThreadLocalExample implements Runnable{
// SimpleDateFormat 不是线程安全的,所以每个线程都要有自己独立的副本
private static final ThreadLocal<SimpleDateFormat> formatter = ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyyMMdd HHmm"));
public static void main(String[] args) throws InterruptedException {
ThreadLocalExample obj = new ThreadLocalExample();
for(int i=0 ; i<10; i++){
Thread t = new Thread(obj, ""+i);
Thread.sleep(new Random().nextInt(1000));
t.start();
}
}
@Override
public void run() {
System.out.println("Thread Name= "+Thread.currentThread().getName()+" default Formatter = "+formatter.get().toPattern());
try {
Thread.sleep(new Random().nextInt(1000));
} catch (InterruptedException e) {
e.printStackTrace();
}
//formatter pattern is changed here by thread, but it won't reflect to other threads
formatter.set(new SimpleDateFormat());
System.out.println("Thread Name= "+Thread.currentThread().getName()+" formatter = "+formatter.get().toPattern());
}
}
为什么ThreadLocal类型的变量要用final修饰?
因为ThreadLocal和其中泛型所存的值的关系是key和value的关系,Map存在于每个线程中,一个ThreadLocal对应一个值,如果ThreadLocal可变,那在改变之后通过就找不到原来的value了;
②ThreadLocal 原理
每个Thread
中都具备一个ThreadLocalMap
,而ThreadLocalMap
可以存储以ThreadLocal
为 key ,Object 对象为 value 的键值对;
ThreadLocal
可以理解为只是ThreadLocalMap
的封装,
当线程对ThreadLocal变量使用set()方法时,实际是以此ThreadLocal变量为key,以变量所赋的值为value,将键值对存入当前线程的ThreadLocalMap中;
当线程对ThreadLocal变量使用get()方法时,实际是以此ThreadLocal变量为key,取当前线程的ThreadLocalMap中获取value;
③ThreadLocal 内存泄露问题是怎么导致的?
ThreadLocalMap
中使用的 key 为 ThreadLocal
的弱引用,而 value 是强引用。所以,如果 ThreadLocal
没有被外部强引用的情况下,在垃圾回收的时候,key 会被清理掉,而 value 不会被清理掉。
这样一来,ThreadLocalMap
中就会出现 key 为 null 的 Entry。假如我们不做任何措施的话,value 永远无法被 GC 回收,这个时候就可能会产生内存泄露;
ThreadLocalMap
实现中已经考虑了这种情况,在调用 set()
、get()
、remove()
方法的时候,会清理掉 key 为 null 的记录。使用完 ThreadLocal
方法后最好手动调用remove()
方法;
2.线程池
①什么是线程池?使用线程池有什么好处?
线程池就是管理一系列线程的资源池。当有任务要处理时,直接从线程池中获取线程来处理,处理完之后线程并不会立即被销毁,而是等待下一个任务;
使用线程池的好处:
- 降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
- 提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立即执行。
- 提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控;
②如何创建线程池?
方式一:通过ThreadPoolExecutor
构造函数来创建(推荐)
方式二:通过 Executor
框架的工具类 Executors
来创建(了解即可,不推荐使用)
通过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。
*/
public ThreadPoolExecutor(int corePoolSize,//线程池的核心线程数量
int maximumPoolSize,//线程池的最大线程数
long keepAliveTime,//当线程数大于核心线程数时,多余的空闲线程存活的最长时间
TimeUnit unit,//时间单位
BlockingQueue<Runnable> workQueue,//任务队列,用来储存等待执行任务的队列
ThreadFactory threadFactory,//线程工厂,用来创建线程,一般默认即可
RejectedExecutionHandler handler//拒绝策略,当提交的任务过多而不能及时处理时,我们可以定制策略来处理任务
) {
if (corePoolSize < 0 ||
maximumPoolSize <= 0 ||
maximumPoolSize < corePoolSize ||
keepAliveTime < 0)
throw new IllegalArgumentException();
if (workQueue == null || threadFactory == null || handler == null)
throw new NullPointerException();
this.corePoolSize = corePoolSize;
this.maximumPoolSize = maximumPoolSize;
this.workQueue = workQueue;
this.keepAliveTime = unit.toNanos(keepAliveTime);
this.threadFactory = threadFactory;
this.handler = handler;
}
ThreadPoolExecutor
3 个最重要的参数:
corePoolSize
: 任务队列未达到队列容量时,最大可以同时运行的线程数量。maximumPoolSize
: 任务队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数。workQueue
: 新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。
ThreadPoolExecutor
其他常见参数 :
keepAliveTime
:线程池中的线程数量大于corePoolSize
的时候,如果这时没有新的任务提交,多余的空闲线程不会立即销毁,而是会等待,直到等待的时间超过了keepAliveTime
才会被回收销毁,线程池回收线程时,会对核心线程和非核心线程一视同仁,直到线程池中线程的数量等于corePoolSize
,回收过程才会停止。unit
:keepAliveTime
参数的时间单位。threadFactory
:executor 创建新线程的时候会用到。handler
:饱和策略。关于饱和策略下面单独介绍一下;
核心线程数会被回收吗?需要什么设置?
核心线程数默认是不会被回收的,如果需要回收核心线程数,需要调用下面的方法:
allowCoreThreadTimeOut 该值默认为 false,修改为true之后,空闲的核心线程在等待超过keepAliveTime之后也会被销毁回收;
⑤线程池的饱和策略有哪些?
如果当前同时运行的线程数量达到最大线程数量并且队列也已经被放满了任务时,ThreadPoolTaskExecutor
定义一些策略:
ThreadPoolExecutor.AbortPolicy
: 抛出RejectedExecutionException
来拒绝新任务的处理。ThreadPoolExecutor.CallerRunsPolicy
: 调用执行自己的线程运行任务,也就是直接在调用execute
方法的线程中运行(run
)被拒绝的任务,如果执行程序已关闭,则会丢弃该任务。因此这种策略会降低对于新任务提交速度,影响程序的整体性能。如果您的应用程序可以承受此延迟并且你要求任何一个任务请求都要被执行的话,你可以选择这个策略。ThreadPoolExecutor.DiscardPolicy
: 不处理新任务,直接丢弃掉。ThreadPoolExecutor.DiscardOldestPolicy
: 此策略将丢弃最早的未处理的任务请求;
举个例子:Spring 通过 ThreadPoolTaskExecutor
或者我们直接通过 ThreadPoolExecutor
的构造函数创建线程池的时候,当我们不指定 RejectedExecutionHandler
饱和策略来配置线程池的时候,默认使用的是 AbortPolicy
。在这种饱和策略下,如果队列满了,ThreadPoolExecutor
将抛出 RejectedExecutionException
异常来拒绝新来的任务 ,这代表你将丢失对这个任务的处理。如果不想丢弃任务的话,可以使用CallerRunsPolicy
。CallerRunsPolicy
和其他的几个策略不同,它既不会抛弃任务,也不会抛出异常,而是将任务回退给调用者,使用调用者的线程来执行任务;
⑥线程池常用的阻塞队列有哪些?
新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。
不同的线程池会选用不同的阻塞队列,我们可以结合内置线程池来分析:
- 容量为
Integer.MAX_VALUE
的LinkedBlockingQueue
(无界队列):FixedThreadPool
和SingleThreadExector
。FixedThreadPool
最多只能创建核心线程数的线程(核心线程数和最大线程数相等),SingleThreadExector
只能创建一个线程(核心线程数和最大线程数都是 1),二者的任务队列永远不会被放满。 SynchronousQueue
(同步队列):CachedThreadPool
。SynchronousQueue
没有容量,不存储元素,目的是保证对于提交的任务,如果有空闲线程,则使用空闲线程来处理;否则新建一个线程来处理任务。也就是说,CachedThreadPool
的最大线程数是Integer.MAX_VALUE
,可以理解为线程数是可以无限扩展的,可能会创建大量线程,从而导致 OOM。DelayedWorkQueue
(延迟阻塞队列):ScheduledThreadPool
和SingleThreadScheduledExecutor
。DelayedWorkQueue
的内部元素并不是按照放入的时间排序,而是会按照延迟的时间长短对任务进行排序,内部采用的是“堆”的数据结构,可以保证每次出队的任务都是当前队列中执行时间最靠前的。DelayedWorkQueue
添加元素满了之后会自动扩容原来容量的 1/2,即永远不会阻塞,最大扩容可达Integer.MAX_VALUE
,所以最多只能创建核心线程数的线程;
⑦线程池处理任务的流程
Ⅰ.如果当前运行的线程数小于核心线程数,那么就会新建一个线程来执行任务。
Ⅱ.如果当前运行的线程数等于或大于核心线程数,但是小于最大线程数,那么就把该任务放入到任务队列里等待执行。
Ⅲ.如果向任务队列投放任务失败(任务队列已经满了),但是当前运行的线程数是小于最大线程数的,就新建一个线程来执行任务。
Ⅳ.如果当前运行的线程数已经等同于最大线程数了,新建线程将会使当前运行的线程超出最大线程数,那么当前任务会被拒绝,饱和策略会调用RejectedExecutionHandler.rejectedExecution()
方法;
⑧如何给线程池命名?
初始化线程池的时候需要显示命名(设置线程池名称前缀),有利于定位问题。
默认情况下创建的线程名字类似 pool-1-thread-n
这样的,没有业务含义,不利于我们定位问题。
给线程池里的线程命名通常有下面两种方式:
1、利用 guava 的 ThreadFactoryBuilder
ThreadFactory threadFactory = new ThreadFactoryBuilder()
.setNameFormat(threadNamePrefix + "-%d")
.setDaemon(true).build();
ExecutorService threadPool = new ThreadPoolExecutor(corePoolSize, maximumPoolSize, keepAliveTime, TimeUnit.MINUTES, workQueue, threadFactory);
2、自己实现 ThreadFactory
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.atomic.AtomicInteger;
/**
* 线程工厂,它设置线程名称,有利于我们定位问题。
*/
public final class NamingThreadFactory implements ThreadFactory {
private final AtomicInteger threadNum = new AtomicInteger();
private final ThreadFactory delegate;
private final String name;
/**
* 创建一个带名字的线程池生产工厂
*/
public NamingThreadFactory(ThreadFactory delegate, String name) {
this.delegate = delegate;
this.name = name; // TODO consider uniquifying this
}
@Override
public Thread newThread(Runnable r) {
Thread t = delegate.newThread(r);
t.setName(name + " [#" + threadNum.incrementAndGet() + "]");
return t;
}
}
⑨如何设定线程池的大小?
有一个简单并且适用面比较广的公式:
- CPU 密集型任务(N+1): 这种任务消耗的主要是 CPU 资源,可以将线程数设置为 N(CPU 核心数)+1。比 CPU 核心数多出来的一个线程是为了防止线程偶发的缺页中断,或者其它原因导致的任务暂停而带来的影响。一旦任务暂停,CPU 就会处于空闲状态,而在这种情况下多出来的一个线程就可以充分利用 CPU 的空闲时间。
- I/O 密集型任务(2N): 这种任务应用起来,系统会用大部分的时间来处理 I/O 交互,而线程在处理 I/O 的时间段内不会占用 CPU 来处理,这时就可以将 CPU 交出给其它线程使用。因此在 I/O 密集型任务的应用中,我们可以多配置一些线程,具体的计算方法是 2N;
线程数更严谨的计算的方法应该是:最佳线程数= CPU核数∗[1+(I/O耗时/CPU耗时)],线程计算时间所占比例越高,需要越少线程;
如何判断是 CPU 密集任务还是 IO 密集任务?
CPU 密集型简单理解就是利用 CPU 计算能力的任务比如你在内存中对大量数据进行排序。
但凡涉及到网络读取,文件读取这类都是 IO 密集型,这类任务的特点是 CPU 计算耗费时间相比于等待 IO 操作完成的时间来说很少,大部分时间都花在了等待 IO 操作完成上;
⑩如何动态修改线程池的参数?
https://tech.meituan.com/2020/04/02/java-pooling-pratice-in-meituan.html
可以对ThreadPoolExecutor的三个核心参数:corePoolSize
、maximumPoolSize
、workQueue
进行动态配置;
首先,通过ThreadPoolExecutor
提供的setCorePoolSize(int)、setMaximumPollSize(int)方法可以对corePoolSize
、maximumPoolSize
两个参数进行动态修改,
而对于workQueue,因为其队列容量capacity被final修饰无法修改,我们可以自定义队列:把 LinkedBlockingQueue 粘贴一份出来,修改个名字,然后把 Capacity 参数的 final 修饰符去掉,并提供其对应的 get/set 方法,
然后用我们自定义的队列作为workQueue来创建线程池,之后就可以使用set方法对workQueue的容量进行修改了;
修改corePoolSize会造成哪些影响:
在运行期线程池使用方调用此方法设置corePoolSize之后,线程池会直接覆盖原来的corePoolSize值,并且基于当前值和原始值的比较结果采取不同的处理策略:
对于当前值小于当前工作线程数的情况,说明有多余的worker线程,此时会向当前idle的worker线程发起中断请求以实现回收,多余的worker在下次idle的时候也会被回收;
对于当前值大于原始值且当前队列中有待执行任务,则线程池会创建新的worker线程来执行队列任务;
修改maximumPoolSize会造成哪些影响:
覆盖原来的maximumPoolSize值,然后判断工作线程是否是大于最大线程数,如果大于,则对空闲线程发起中断请求;
11.如何设计一个能够根据任务的优先级来执行的线程池?
可以考虑使用 PriorityBlockingQueue
(优先级阻塞队列)作为任务队列,
PriorityBlockingQueue
是一个支持优先级的无界阻塞队列,可以看作是线程安全的 PriorityQueue
,两者底层都是使用小顶堆形式的二叉堆,即值最小的元素优先出队。不过,PriorityQueue
不支持阻塞操作。
要想让 PriorityBlockingQueue
实现对任务的排序,传入其中的任务必须是具备排序能力的,方式有两种:
- 提交到线程池的任务实现
Comparable
接口,并重写compareTo
方法来指定任务之间的优先级比较规则。 - 创建
PriorityBlockingQueue
时传入一个Comparator
对象来指定任务之间的排序规则(推荐);
不过,这存在一些风险和问题,比如:
PriorityBlockingQueue
是无界的,可能堆积大量的请求,从而导致 OOM。- 可能会导致饥饿问题,即低优先级的任务长时间得不到执行。
- 由于需要对队列中的元素进行排序操作以及保证线程安全(并发控制采用的是可重入锁
ReentrantLock
),因此会降低性能。
对于 OOM 这个问题的解决比较简单粗暴,就是继承PriorityBlockingQueue
并重写一下 offer
方法(入队)的逻辑,当插入的元素数量超过指定值就返回 false 。
饥饿问题这个可以通过优化设计来解决(比较麻烦),比如等待时间过长的任务会被移除并重新添加到队列中,但是优先级会被提升。
对于性能方面的影响,是没办法避免的,毕竟需要对任务进行排序操作。并且,对于大部分业务场景来说,这点性能影响是可以接受的;
12.线程池使用注意事项
Ⅰ.正确声明线程池:
线程池必须手动通过 ThreadPoolExecutor
的构造函数来声明,避免使用Executors
类创建线程池,会有 OOM 风险;
Ⅱ.监测线程池运行状态:
可以通过一些手段来检测线程池的运行状态比如 SpringBoot 中的 Actuator 组件,
还可以利用 ThreadPoolExecutor
的相关 API 做一个简陋的监控;
Ⅲ.建议不同类别的业务用不同的线程池
共用线程池的话,可能会出现父进程占用了线程池中的所有核心线程,等待子线程执行完毕,而子线程等待父线程释放线程资源无法执行,造成死锁;
Ⅳ.别忘记给线程池命名
初始化线程池的时候需要显示命名(设置线程池名称前缀),有利于定位问题;
Ⅴ.正确配置线程池参数
推荐将线程池设计为核心参数(corePoolSize、maximumPoolSize、workQueue)可动态修改的形式;
Ⅵ.别忘记关闭线程池
当线程池不再需要使用时,应该显式地关闭线程池,释放线程资源;
调用完 shutdownNow
和 shuwdown
方法后,并不代表线程池已经完成关闭操作,它只是异步的通知线程池进行关闭处理。如果要同步等待线程池彻底关闭后才继续往下执行,需要调用awaitTermination
方法进行同步等待。
在调用 awaitTermination()
方法时,应该设置合理的超时时间,以避免程序长时间阻塞而导致性能问题。另外。由于线程池中的任务可能会被取消或抛出异常,因此在使用 awaitTermination()
方法时还需要进行异常处理。awaitTermination()
方法会抛出 InterruptedException
异常,需要捕获并处理该异常,以避免程序崩溃或者无法正常退出;
// ...
// 关闭线程池
executor.shutdown();
try {
// 等待线程池关闭,最多等待5分钟
if (!executor.awaitTermination(5, TimeUnit.MINUTES)) {
// 如果等待超时,则打印日志
System.err.println("线程池未能在5分钟内完全关闭");
}
} catch (InterruptedException e) {
// 异常处理
}
Ⅶ.线程池尽量不要放耗时任务
线程池本身的目的是为了提高任务执行效率,避免因频繁创建和销毁线程而带来的性能开销。如果将耗时任务提交到线程池中执行,可能会导致线程池中的线程被长时间占用,无法及时响应其他任务,甚至会导致线程池崩溃或者程序假死。
因此,在使用线程池时,我们应该尽量避免将耗时任务提交到线程池中执行。对于一些比较耗时的操作,如网络请求、文件读写等,可以采用异步操作的方式来处理,以避免阻塞线程池中的线程;
3.Future
①Future 类有什么用?
Future
类是异步思想的典型运用,主要用在一些需要执行耗时任务的场景,避免程序一直原地等待耗时任务执行完成,执行效率太低,
简单理解就是:我有一个任务,提交给了 Future
来处理。任务执行期间我自己可以去做任何想做的事情。并且,在这期间我还可以取消任务以及获取任务的执行状态。一段时间之后,我就可以 Future
那里直接取出任务执行结果;
在 Java 中,Future
类只是一个泛型接口,位于 java.util.concurrent
包下,其中定义了 5 个方法,主要包括下面这 4 个功能:
- 取消任务;
- 判断任务是否被取消;
- 判断任务是否已经执行完成;
- 获取任务执行结果;;
// V 代表了Future执行的任务返回值的类型
public interface Future<V> {
// 取消任务执行
// 成功取消返回 true,否则返回 false
boolean cancel(boolean mayInterruptIfRunning);
// 判断任务是否被取消
boolean isCancelled();
// 判断任务是否已经执行完成
boolean isDone();
// 获取任务执行结果
V get() throws InterruptedException, ExecutionException;
// 指定时间内没有返回计算结果就抛出 TimeOutException 异常
V get(long timeout, TimeUnit unit)
throws InterruptedException, ExecutionException, TimeoutExceptio
}
②CompletableFuture 类有什么用?
Future
在实际使用过程中存在一些局限性比如不支持异步任务的编排组合、获取计算结果的 get()
方法为阻塞调用;
Java 8 才被引入CompletableFuture
类可以解决Future
的这些缺陷。CompletableFuture
除了提供了更为好用和强大的 Future
特性之外,还提供了函数式编程、异步任务编排组合(可以将多个异步任务串联起来,组成一个完整的链式调用)等能力;
③创建 CompletableFuture
常见的创建 CompletableFuture
对象的方法如下:
- 通过 new 关键字。
- 基于
CompletableFuture
自带的静态工厂方法:runAsync()
、supplyAsync()
通过new 关键字:
CompletableFuture<RpcResponse<Object>> resultFuture = new CompletableFuture<>();
如果你已经知道计算的结果的话,可以使用静态方法 completedFuture()
来创建 CompletableFuture
:
CompletableFuture<String> future = CompletableFuture.completedFuture("hello!");
assertEquals("hello!", future.get());
runAsync()
方法接受的参数是 Runnable
,这是一个函数式接口,不允许返回值。当你需要异步操作且不关心返回结果的时候可以使用 runAsync()
方法:
supplyAsync()
方法接受的参数是 Supplier<U>
,这也是一个函数式接口,U
是返回结果值的类型;
CompletableFuture<Void> future = CompletableFuture.runAsync(() -> System.out.println("hello!"));
future.get();// 输出 "hello!"
CompletableFuture<String> future2 = CompletableFuture.supplyAsync(() -> "hello!");
assertEquals("hello!", future2.get());
④CompletableFuture处理异步结算结果的方法
当我们获取到异步计算的结果之后,还可以对其进行进一步的处理,比较常用的方法有下面几个:
thenApply()
thenAccept()
thenRun()
whenComplete()
thenApply()
方法接受一个 Function
实例,用它来处理结果:
CompletableFuture<String> future = CompletableFuture.completedFuture("hello!")
.thenApply(s -> s + "world!").thenApply(s -> s + "nice!");
assertEquals("hello!world!nice!", future.get());
如果你不需要从回调函数中获取返回结果,可以使用 thenAccept()
或者 thenRun()
。这两个方法的区别在于 thenRun()
不能访问异步计算的结果。
thenAccept()
方法的参数是 Consumer<? super T>
:
thenRun()
的方法是的参数是 Runnable
:
CompletableFuture.completedFuture("hello!")
.thenApply(s -> s + "world!").thenApply(s -> s + "nice!").thenAccept(System.out::println);//hello!world!nice!
CompletableFuture.completedFuture("hello!")
.thenApply(s -> s + "world!").thenApply(s -> s + "nice!").thenRun(() -> System.out.println("hello!"));//hello!
whenComplete()
的方法的参数是 BiConsumer<? super T, ? super Throwable>
,相对于 Consumer
, BiConsumer
可以接收 2 个输入对象然后进行“消费”:
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> "hello!")
.whenComplete((res, ex) -> {
// res 代表返回的结果
// ex 的类型为 Throwable ,代表抛出的异常
System.out.println(res);
// 这里没有抛出异常所有为 null
assertNull(ex);
});
assertEquals("hello!", future.get());
⑤CompletableFuture进行异常处理
可以通过 handle()
方法来处理任务执行过程中可能出现的抛出异常的情况:
CompletableFuture<String> future
= CompletableFuture.supplyAsync(() -> {
if (true) {
throw new RuntimeException("Computation error!");
}
return "hello!";
}).handle((res, ex) -> {
// res 代表返回的结果
// ex 的类型为 Throwable ,代表抛出的异常
return res != null ? res : "world!";
});
assertEquals("world!", future.get());
还可以通过 exceptionally()
方法来处理异常情况:
CompletableFuture<String> future
= CompletableFuture.supplyAsync(() -> {
if (true) {
throw new RuntimeException("Computation error!");
}
return "hello!";
}).exceptionally(ex -> {
System.out.println(ex.toString());// CompletionException
return "world!";
});
assertEquals("world!", future.get());
如果你想让 CompletableFuture
的结果就是异常的话,可以使用 completeExceptionally()
方法为其赋值:
CompletableFuture<String> completableFuture = new CompletableFuture<>();
// ...
completableFuture.completeExceptionally(
new RuntimeException("Calculation failed!"));
// ...
completableFuture.get(); // ExecutionException
⑥组合 CompletableFuture实现异步任务编排
可以使用 thenCompose()
按顺序链接两个 CompletableFuture
对象,实现异步的任务链。
它的作用是将前一个任务的返回结果作为下一个任务的输入参数,它们之间存在着先后顺序:
CompletableFuture<String> future
= CompletableFuture.supplyAsync(() -> "hello!")
.thenCompose(s -> CompletableFuture.supplyAsync(() -> s + "world!"));
assertEquals("hello!world!", future.get());
thenCombine()
会在两个任务都执行完成后,把两个任务的结果合并。两个任务是并行执行的,它们之间并没有先后依赖顺序:
CompletableFuture<String> future
= CompletableFuture.supplyAsync(() -> "hello!")
.thenCombine(s -> CompletableFuture.supplyAsync(() -> s + "world!"));
assertEquals("hello!world!", future.get());
如果我们想要实现 task1 和 task2 中的任意一个任务执行完后就执行 task3 的话,可以使用 acceptEither()
:
CompletableFuture<String> task = CompletableFuture.supplyAsync(() -> {
System.out.println("任务1开始执行,当前时间:" + System.currentTimeMillis());
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("任务1执行完毕,当前时间:" + System.currentTimeMillis());
return "task1";
});
CompletableFuture<String> task2 = CompletableFuture.supplyAsync(() -> {
System.out.println("任务2开始执行,当前时间:" + System.currentTimeMillis());
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("任务2执行完毕,当前时间:" + System.currentTimeMillis());
return "task2";
});
task.acceptEitherAsync(task2, (res) -> {
System.out.println("任务3开始执行,当前时间:" + System.currentTimeMillis());
System.out.println("上一个任务的结果为:" + res);
});
// 增加一些延迟时间,确保异步任务有足够的时间完成
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
任务组合操作acceptEitherAsync()
会在异步任务 1 和异步任务 2 中的任意一个完成时触发执行任务 3,但是需要注意,这个触发时机是不确定的。如果任务 1 和任务 2 都还未完成,那么任务 3 就不能被执行;
⑦并行运行多个 CompletableFuture
你可以通过 CompletableFuture
的 allOf()
和是anyOf()
方法来并行运行多个 CompletableFuture
,
allOf()
方法会等到所有的 CompletableFuture
都运行完成之后再返回:
Random rand = new Random();
CompletableFuture<String> future1 = CompletableFuture.supplyAsync(() -> {
try {
Thread.sleep(1000 + rand.nextInt(1000));
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
System.out.println("future1 done...");
}
return "abc";
});
CompletableFuture<String> future2 = CompletableFuture.supplyAsync(() -> {
try {
Thread.sleep(1000 + rand.nextInt(1000));
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
System.out.println("future2 done...");
}
return "efg";
});
调用 join()
可以让程序等future1
和 future2
都运行完了之后再继续执行:
CompletableFuture<Void> completableFuture = CompletableFuture.allOf(future1, future2);
completableFuture.join();
assertTrue(completableFuture.isDone());
System.out.println("all futures done...");
输出:
future1 done...
future2 done...
all futures done...
anyOf()
方法不会等待所有的 CompletableFuture
都运行完成之后再返回,只要有一个执行完成即可:
CompletableFuture<Object> f = CompletableFuture.anyOf(future1, future2);
System.out.println(f.get());
输出结果可能是:
future2 done...
efg
也可能是:
future1 done...
abc
⑧CompletableFuture 使用建议
Ⅰ.使用自定义线程池
我们上面的代码示例中,为了方便,都没有选择自定义线程池。实际项目中,这是不可取的。
CompletableFuture
默认使用ForkJoinPool.commonPool()
作为执行器,这个线程池是全局共享的,可能会被其他任务占用,导致性能下降或者饥饿。因此,建议使用自定义的线程池来执行 CompletableFuture
的异步任务,可以提高并发度和灵活性:
private ThreadPoolExecutor executor = new ThreadPoolExecutor(10, 10,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>());
CompletableFuture.runAsync(() -> {
//...
}, executor);
Ⅱ.尽量避免使用 get()
CompletableFuture
的get()
方法是阻塞的,尽量避免使用。如果必须要使用的话,需要添加超时时间,否则可能会导致主线程一直等待,无法执行其他任务:
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
try {
Thread.sleep(10_000);
} catch (InterruptedException e) {
e.printStackTrace();
}
return "Hello, world!";
});
// 获取异步任务的返回值,设置超时时间为 5 秒
try {
String result = future.get(5, TimeUnit.SECONDS);
System.out.println(result);
} catch (InterruptedException | ExecutionException | TimeoutException e) {
// 处理异常
e.printStackTrace();
}
}
上面这段代码在调用 get()
时抛出了 TimeoutException
异常。这样我们就可以在异常处理中进行相应的操作,比如取消任务、重试任务、记录日志等;
Ⅲ.正确进行异常处理
用 CompletableFuture
的时候一定要以正确的方式进行异常处理,避免异常丢失或者出现不可控问题。
下面是一些建议:
- 使用
whenComplete
方法可以在任务完成时触发回调函数,并正确地处理异常,而不是让异常被吞噬或丢失。 - 使用
exceptionally
方法可以处理异常并重新抛出,以便异常能够传播到后续阶段,而不是让异常被忽略或终止。 - 使用
handle
方法可以处理正常的返回结果和异常,并返回一个新的结果,而不是让异常影响正常的业务逻辑。 - 使用
CompletableFuture.allOf
方法可以组合多个CompletableFuture
,并统一处理所有任务的异常,而不是让异常处理过于冗长或重复;;
Ⅳ.合理组合多个异步任务
正确使用 thenCompose()
、 thenCombine()
、acceptEither()
、allOf()
、anyOf()
等方法来组合多个异步任务,以满足实际业务的需求,提高程序执行效率。
实际使用中,我们还可以利用或者参考现成的异步任务编排框架,比如京东的 asyncTool;
4.常见的基于AQS的同步工具类
①Semaphore(信号量)
synchronized
和 ReentrantLock
都是一次只允许一个线程访问某个资源,而Semaphore
(信号量)可以用来控制同时访问特定资源的线程数量;
Semaphore
与 CountDownLatch
一样,也是共享锁的一种实现。它默认构造 AQS 的 state
为 permits
。当执行任务的线程数量超出 permits
,那么多余的线程将会被放入等待队列 Park
,并自旋判断 state
是否大于 0。只有当 state
大于 0 的时候,阻塞的线程才能继续执行,此时先前执行任务的线程继续执行 release()
方法,release()
方法使得 state 的变量会加 1,那么自旋的线程便会判断成功。 如此,每次只有最多不超过 permits
数量的线程能自旋成功,便限制了执行任务线程的数量;
Semaphore
有两种模式:
- 公平模式: 调用
acquire()
方法的顺序就是获取许可证的顺序,遵循 FIFO; - 非公平模式: 抢占式的。
Semaphore
对应的两个构造方法如下:
public Semaphore(int permits) {
sync = new NonfairSync(permits);
}
public Semaphore(int permits, boolean fair) {
sync = fair ? new FairSync(permits) : new NonfairSync(permits);
}
这两个构造方法,都必须提供许可的数量,第二个构造方法可以指定是公平模式还是非公平模式,默认非公平模式。
Semaphore
通常用于那些资源有明确访问数量限制的场景比如限流(仅限于单机模式,实际项目中推荐使用 Redis +Lua 来做限流);
基本方法:
// 初始共享资源数量
final Semaphore semaphore = new Semaphore(5);
// 获取1个许可
semaphore.acquire();
// 释放1个许可
semaphore.release();
使用示例:
public class SemaphoreExample {
// 请求的数量
private static final int threadCount = 550;
public static void main(String[] args) throws InterruptedException {
// 创建一个具有固定线程数量的线程池对象(如果这里线程池的线程数量给太少的话你会发现执行的很慢)
ExecutorService threadPool = Executors.newFixedThreadPool(300);
// 初始许可证数量
final Semaphore semaphore = new Semaphore(20);
for (int i = 0; i < threadCount; i++) {
final int threadnum = i;
threadPool.execute(() -> {// Lambda 表达式的运用
try {
semaphore.acquire();// 获取一个许可,所以可运行线程数量为20/1=20
test(threadnum);
semaphore.release();// 释放一个许可
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
});
}
threadPool.shutdown();
System.out.println("finish");
}
public static void test(int threadnum) throws InterruptedException {
Thread.sleep(1000);// 模拟请求的耗时操作
System.out.println("threadnum:" + threadnum);
Thread.sleep(1000);// 模拟请求的耗时操作
}
}
一次也可以一次拿取和释放多个许可,不过一般没有必要这样做:
semaphore.acquire(5);// 获取5个许可,所以可运行线程数量为20/5=4
test(threadnum);
semaphore.release(5);// 释放5个许可
除了 acquire()
方法之外,另一个比较常用的与之对应的方法是 tryAcquire()
方法,该方法如果获取不到许可就立即返回 false;
②CountDownLatch (倒计时器)
CountDownLatch
允许 count
个线程阻塞在一个地方,直至所有线程的任务都执行完毕。
CountDownLatch
是一次性的,计数器的值只能在构造方法中初始化一次,之后没有任何机制再次对其设置值,当 CountDownLatch
使用完毕后,它不能再次被使用;
CountDownLatch
是共享锁的一种实现,它默认构造 AQS 的 state
值为 count
;
构造方法:
public CountDownLatch(int count) {
if (count < 0) throw new IllegalArgumentException("count < 0");
this.sync = new Sync(count);
}
基本方法:
countDown():令state减一;
await():启动倒计时器,自选CAS对count进行判断,若state>0则await()之后的语句全部阻塞,直至state==0方可继续执行之后的语句;
两种使用场景:
- 某一线程在开始运行前等待 n 个线程执行完毕 : 将
CountDownLatch
的计数器初始化为 n (new CountDownLatch(n)
),每当一个任务线程执行完毕,就将计数器减 1 (countdownlatch.countDown()
),当计数器的值变为 0 时,在CountDownLatch 上 await()
的线程就会被唤醒。一个典型应用场景就是启动一个服务时,主线程需要等待多个组件加载完毕,之后再继续执行。 - 实现多个线程开始执行任务的最大并行性:注意是并行性,不是并发,强调的是多个线程在某一时刻同时开始执行。类似于赛跑,将多个线程放到起点,等待发令枪响,然后同时开跑。做法是初始化一个共享的
CountDownLatch
对象,将其计数器初始化为 1 (new CountDownLatch(1)
),多个线程在开始执行任务前首先coundownlatch.await()
,当主线程调用countDown()
时,计数器变为 0,多个线程同时被唤醒
使用示例——某线程在运行前需等待n个线程执行完毕:
public class CountDownLatchExample {
// 请求的数量
private static final int THREAD_COUNT = 550;
public static void main(String[] args) throws InterruptedException {
// 创建一个具有固定线程数量的线程池对象(如果这里线程池的线程数量给太少的话你会发现执行的很慢)
// 只是测试使用,实际场景请手动赋值线程池参数
ExecutorService threadPool = Executors.newFixedThreadPool(300);
final CountDownLatch countDownLatch = new CountDownLatch(THREAD_COUNT);
for (int i = 0; i < THREAD_COUNT; i++) {
final int threadNum = i;
threadPool.execute(() -> {
try {
test(threadNum);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
// 表示一个请求已经被完成
countDownLatch.countDown();
}
});
}
countDownLatch.await();
threadPool.shutdown();
System.out.println("finish");
}
public static void test(int threadnum) throws InterruptedException {
Thread.sleep(1000);
System.out.println("threadNum:" + threadnum);
Thread.sleep(1000);
}
}
上面的代码中,我们定义了请求的数量为 550,当这 550 个请求被处理完成之后,才会执行System.out.println("finish");
。
与 CountDownLatch
的第一次交互是主线程等待其他线程。主线程必须在启动其他线程后立即调用 CountDownLatch.await()
方法。这样主线程的操作就会在这个方法上阻塞,直到其他线程完成各自的任务。
其他 N 个线程必须引用闭锁对象,因为他们需要通知 CountDownLatch
对象,他们已经完成了各自的任务。这种通知机制是通过 CountDownLatch.countDown()
方法来完成的;每调用一次这个方法,在构造函数中初始化的 count 值就减 1。所以当 N 个线程都调 用了这个方法,count 的值等于 0,然后主线程就能通过 await()
方法,恢复执行自己的任务;
需要注意的是:
CountDownLatch
的 await()
方法使用不当很容易产生死锁,比如我们上面代码中的 for 循环改为:
for (int i = 0; i < threadCount-1; i++) {
.......
}
这样就导致 count
的值没办法等于 0,然后就会导致一直等待;
③CyclicBarrier(循环栅栏)
CyclicBarrier
的字面意思是可循环使用(Cyclic)的屏障(Barrier)。
它要做的事情是:让一组线程到达一个屏障时被阻塞,直到指定数量的线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续干活
构造方法:
public CyclicBarrier(int parties) {
this(parties, null);
}
public CyclicBarrier(int parties, Runnable barrierAction) {
if (parties <= 0) throw new IllegalArgumentException();
this.parties = parties;
this.count = parties;
this.barrierCommand = barrierAction;
}
默认的构造方法是 CyclicBarrier(int parties)
,其参数表示屏障拦截的线程数量;
另外,CyclicBarrier
还提供一个更高级的构造函数 CyclicBarrier(int parties, Runnable barrierAction)
,用于在线程到达屏障时,优先执行 barrierAction
,方便处理更复杂的业务场景;
基本方法:
await():当前线程在此阻塞,可以额外设置令其等待指定时间,同时count减一(初始化为parties),待count==0时方可执行之后的语句;
CountDownLatch和CyclicBarrier对比:
前者的await()是单纯让当前线程在此阻塞,解除阻塞需要其他线程执行countDown()方可实现;
后者的await()也有让当前线程在此阻塞的功能,但同时本身也会让count减一;
使用示例——barrierAction非必须:
public class CyclicBarrierExample2 {
// 请求的数量
private static final int threadCount = 550;
// 需要同步的线程数量
private static final CyclicBarrier cyclicBarrier = new CyclicBarrier(5, () -> {
System.out.println("------当线程数达到之后,优先执行------");
});
public static void main(String[] args) throws InterruptedException {
// 创建线程池
ExecutorService threadPool = Executors.newFixedThreadPool(10);
for (int i = 0; i < threadCount; i++) {
final int threadNum = i;
Thread.sleep(1000);
threadPool.execute(() -> {
try {
test(threadNum);
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
} catch (BrokenBarrierException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
});
}
threadPool.shutdown();
}
public static void test(int threadnum) throws InterruptedException, BrokenBarrierException {
System.out.println("threadnum:" + threadnum + "is ready");
cyclicBarrier.await();
System.out.println("threadnum:" + threadnum + "is finish");
}
}
输出:
threadnum:0is ready
threadnum:1is ready
threadnum:2is ready
threadnum:3is ready
threadnum:4is ready
------当线程数达到之后,优先执行------
threadnum:4is finish
threadnum:0is finish
threadnum:2is finish
threadnum:1is finish
threadnum:3is finish
threadnum:5is ready
threadnum:6is ready
threadnum:7is ready
threadnum:8is ready
threadnum:9is ready
------当线程数达到之后,优先执行------
threadnum:9is finish
threadnum:5is finish
threadnum:6is finish
threadnum:8is finish
threadnum:7is finish
......
可以看到当线程数量也就是请求数量达到我们定义的 5 个的时候, await()
方法之后的方法才被执行,同时构造方法中的barrierAction会在此之前执行;
5.创建线程的几种方式
①继承于Thread类
Ⅰ. 创建一个继承于Thread类的子类,重写Thread类的run()方法,将此线程需要执行的操作写在run()方法中;
Ⅱ. 创建Thread类的子类的对象;
Ⅲ. 通过此对象调用Thread类中的start()方法;————主线程通过调用start()方法,①创建并启动新的线程;②调用该线程的run()方法
注意:
Ⅰ. 在第Ⅳ步中,若通过创建的对象直接调用run()方法,则不会创建新的线程,而是仍在主线程下执行;
Ⅱ. 同一个对象只能调用一次start()方法,若要再新建一个线程,则需再新建一个对象;
示例:
查看代码
package com.tzc.java;
/**
* 多线程的创建方式一:继承于Thread类
* @author AvavaAva
* @create 2022/5/27-12:22
*/
//①创建一个继承于Thread类的子类;
class MyThread extends Thread{
//②重写Thread类的run()方法,将此线程需要执行的操作写在run()方法中;
@Override
public void run() {
for (int i = 0; i < 10; i++) {
if(i % 2 == 0){
System.out.println(Thread.currentThread().getName() + ":" + i);
}
}
}
}
public class ThreadTest {
public static void main(String[] args) {
//③创建Thread类的子类的对象;
MyThread t1 = new MyThread();
//④通过此对象调用Thread类中的start()方法;————主线程通过调用start()方法,①创建并启动新的线程;②调用该线程的run()方法
t1.start();
//注意:同一个对象只能调用一次start()方法,若要再新建一个线程,则需再新建一个对象;
MyThread t2 = new MyThread();
t2.start();
//下面的操作则是在main线程中进行的
for (int i = 0; i < 10; i++) {
if (i % 2 != 0) {
System.out.println(Thread.currentThread().getName() + ":" + i + "********************");
}
}
}
}
上述四步的简略写法:创建Thread类的匿名子类的方式(在子类只使用一次的情况下推荐使用)
查看代码
package com.tzc.exer;
/**
* @author AvavaAva
* @create 2022/5/27-13:10
*/
public class ThreadDemo2 {
public static void main(String[] args) {
//创建Thread类的匿名子类的方式
Thread thread = new Thread(() -> {
for (int i = 0; i < 10; i++) {
System.out.println(i);
}
});
thread.start();
}
}
②实现Runnable接口
Ⅰ. 创建一个实现了Runnable接口的类,实现类中实现Runnable中的抽象方法:run();
Ⅱ. 创建实现类的对象;
Ⅲ. 将此对象作为参数传递到Thread类的构造器中,创建Thread类的对象;
Ⅳ. 通过Thread类的对象调用start():①创建并启动新的线程;②调用当前线程的run()--->调用了Runnable类型的target的run()方法;
查看代码
package com.tzc.java;
/**
* 多线程创建的方式二:实现Runnable接口
* @author AvavaAva
* @create 2022/5/27-17:27
*/
//①创建一个实现了Runnable接口的类;
class MThread implements Runnable{
//②实现类去实现Runnable中的抽象方法:run();
@Override
public void run() {
for (int i = 0; i < 10; i++) {
if(i % 2 == 0){
System.out.println(Thread.currentThread().getName() + ":" + i);
}
}
}
}
public class ThreadTest2 {
public static void main(String[] args) {
//③创建实现类的对象;
MThread m1 = new MThread();
//④将此对象作为参数传递到Thread类的构造器中,创建Thread类的对象;
Thread t1 = new Thread(m1);
//⑤通过Thread类的对象调用start():①创建并启动新的线程;②调用当前线程的run()--->调用了Runnable类型的target的run()方法;
t1.start();
//再启动一个线程
Thread t2 = new Thread(m1);
t2.start();
}
}
③实现Callable接口
Ⅰ. 创建一个实现了Callable接口的类,实现类中实现Callable中的抽象方法:call();
Ⅱ. 创建实现类的对象;
Ⅲ. 将实现类对象作为参数传递到FutureTask类的构造器中,创建FutureTask类的对象;
Ⅳ. 将FutureTask对象作为参数传递到Thread类的构造器中,创建Thread类的对象;
Ⅴ. 通过Thread类的对象调用start():①创建并启动新的线程;②调用当前线程的run()--->调用了Runnable类型的target的run()方法;
示例:
查看代码
package com.tzc.java3;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
/**
* 线程创建方式三:实现Callable接口
* @author AvavaAva
* @create 2022/6/4-18:07
*/
//①创建一个实现了Callable接口的类;
class NumThread implements Callable {
//②实现类去实现Callable中的抽象方法:call(),可以有返回值,没有则返回null;
@Override
public Object call() throws Exception {
int sum = 0;
for (int i = 1; i <= 100; i++) {
if(i % 2 == 0){
System.out.println(i);
sum += i;
}
}
return sum;
}
}
public class ThreadNew {
public static void main(String[] args){
//③创建实现类的对象;
NumThread n1 = new NumThread();
//④将此对象作为参数传递到FutureTask类的构造器中,创建FutureTask类的对象;
FutureTask f1 = new FutureTask(n1);
//⑤要让线程运行,需将FutureTask类的对象作为参数传递到Thread类的构造器中,创建Thread类的对象并调用start()方法
Thread t1 = new Thread(f1);
t1.start();
//⑥要获取返回值,则需调用FutureTask类的对象的get()方法
try {
//get()方法返回值即为FutureTask构造器参数Callable实现类重写的call()方法的返回值
Object sum = f1.get();
System.out.println("总和为:" + sum);
} catch (InterruptedException e) {
e.printStackTrace();
} catch (ExecutionException e) {
e.printStackTrace();
}
}
}
④线程池创建线程——实际项目中只推荐这一种方式来创建线程(详见前面对线程池的详细分析)
Ⅰ.创建线程池——通过ThreadPoolExecutor
构造函数来创建线程池对象;
Ⅱ.创建任务——创建一个类实现Runnable接口,重写run()方法,之后创建该类的实例对象;
——也可以实现Callable接口,区别是Runnable的run()方法没有返回值,Callable的call()方法有返回值,视情况选择
Ⅲ.提交任务到线程池中——通过线程池对象调用execute()方法,传入Runnable对象;
——也可以调用submit方法,区别是execute()方法没有返回值,无法获知任务执行情况,submit()方法会返回一个Future对象,可以进行进一步处理获知任务执行情况;
Ⅳ,关闭线程池——线程池使用完毕后,线程池对象调用shutdown()方法关闭线程池;
——也可以使用shutdownNow()方法,区别是shutdown()方法将线程池的状态变为 SHUTDOWN
。线程池不再接受新任务了,但是队列里的任务得执行完毕,
而shutdownNow()方法是让线程池的状态变为 STOP
,线程池会立即终止当前正在运行的任务,并停止处理排队的任务并返回正在等待执行的 List;
示例:
import java.util.Date;
/**
* 这是一个简单的Runnable类,需要大约5秒钟来执行其任务。
* @author shuang.kou
*/
public class MyRunnable implements Runnable {
private String command;
public MyRunnable(String s) {
this.command = s;
}
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + " Start. Time = " + new Date());
processCommand();
System.out.println(Thread.currentThread().getName() + " End. Time = " + new Date());
}
private void processCommand() {
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
@Override
public String toString() {
return this.command;
}
}
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
public class ThreadPoolExecutorDemo {
private static final int CORE_POOL_SIZE = 5;
private static final int MAX_POOL_SIZE = 10;
private static final int QUEUE_CAPACITY = 100;
private static final Long KEEP_ALIVE_TIME = 1L;
public static void main(String[] args) {
//使用阿里巴巴推荐的创建线程池的方式
//通过ThreadPoolExecutor构造函数自定义参数创建
ThreadPoolExecutor executor = new ThreadPoolExecutor(
CORE_POOL_SIZE,
MAX_POOL_SIZE,
KEEP_ALIVE_TIME,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(QUEUE_CAPACITY),
new ThreadPoolExecutor.CallerRunsPolicy());
for (int i = 0; i < 10; i++) {
//创建WorkerThread对象(WorkerThread类实现了Runnable 接口)
Runnable worker = new MyRunnable("" + i);
//执行Runnable
executor.execute(worker);
}
//终止线程池
executor.shutdown();
while (!executor.isTerminated()) {
}
System.out.println("Finished all threads");
}
}
使用submit()向线程池提交需要返回值的任务,并使用get(long timeout,TimeUnit unit)
方法对返回值进行处理:
(get()
方法是阻塞的,尽量避免使用。如果必须要使用的话,需要添加超时时间,否则可能会导致主线程一直等待,无法执行其他任务)
ExecutorService executorService = Executors.newFixedThreadPool(3);
Future<String> submit = executorService.submit(() -> {
try {
Thread.sleep(5000L);
} catch (InterruptedException e) {
e.printStackTrace();
}
return "abc";
});
String s = submit.get(3, TimeUnit.SECONDS);
System.out.println(s);
executorService.shutdown();
6.Java 常见并发容器
①ConcurrentHashMap
线程安全的 HashMap
,详细介绍见Java集合面试题;
②CopyOnWriteArrayList
线程安全的 List
,在读多写少的场合性能非常好,远远好于 Vector
为了将读操作性能发挥到极致,CopyOnWriteArrayList
中的读取操作是完全无需加锁的。更加厉害的是,写入操作也不会阻塞读取操作,只有写写才会互斥。这样一来,读操作的性能就可以大幅度提升;
CopyOnWriteArrayList
线程安全的核心在于其采用了 写时复制(Copy-On-Write) 的策略:
当需要修改( add
,set
、remove
等操作) CopyOnWriteArrayList
的内容时,不会直接修改原数组,而是会先创建底层数组的副本,对副本数组进行修改,修改完之后再将修改后的数组赋值回去,这样就可以保证写操作不会影响读操作了;
③ConcurrentLinkedQueue
Java 提供的线程安全的 Queue
可以分为阻塞队列和非阻塞队列,其中阻塞队列的典型例子是 BlockingQueue
,非阻塞队列的典型例子是 ConcurrentLinkedQueue
(阻塞队列:当队列满时,队列会阻塞插入元素的线程,直到队列不满,在队列为空时,获取元素的线程阻塞,直到队列变为非空;
非阻塞队列:即使在队满和队空的情况下,也不会对线程进行阻塞,只会操作失败)
ConcurrentLinkedQueue
主要使用 CAS 非阻塞算法来实现线程安全;
ConcurrentLinkedQueue
适合在对性能要求相对较高,同时对队列的读写存在多个线程同时进行的场景,即如果对队列加锁的成本较高则适合使用无锁的 ConcurrentLinkedQueue
来替代;
④BlockingQueue
阻塞队列(BlockingQueue
)被广泛使用在“生产者-消费者”问题中,其原因是 BlockingQueue
提供了可阻塞的插入和移除的方法。当队列容器已满,生产者线程会被阻塞,直到队列未满;当队列容器为空时,消费者线程会被阻塞,直至队列非空时为止;
下面主要介绍一下 3 个常见的 BlockingQueue
的实现类:ArrayBlockingQueue
、LinkedBlockingQueue
、PriorityBlockingQueue
Ⅰ.ArrayBlockingQueue
ArrayBlockingQueue
是 BlockingQueue
接口的有界队列实现类,底层采用数组来实现;
ArrayBlockingQueue
一旦创建,容量不能改变。其并发控制采用可重入锁 ReentrantLock
,不管是插入操作还是读取操作,都需要获取到锁才能进行操作。当队列容量满时,尝试将元素放入队列将导致操作阻塞;尝试从一个空队列中取一个元素也会同样阻塞;
ArrayBlockingQueue
默认情况下不能保证线程访问队列的公平性;
Ⅱ.LinkedBlockingQueue
LinkedBlockingQueue
底层基于单向链表实现的阻塞队列,可以当做无界队列也可以当做有界队列来使用,同样满足 FIFO 的特性,
与 ArrayBlockingQueue
相比起来具有更高的吞吐量,为了防止 LinkedBlockingQueue
容量迅速增,损耗大量内存。通常在创建 LinkedBlockingQueue
对象时,会指定其大小,如果未指定,容量等于 Integer.MAX_VALUE
Ⅲ.PriorityBlockingQueue
PriorityBlockingQueue
是一个支持优先级的无界阻塞队列。
默认情况下元素采用自然顺序进行排序,也可以通过自定义类实现 compareTo()
方法来指定元素排序规则,或者初始化时通过构造器参数 Comparator
来指定排序规则;
简单地说,它就是 PriorityQueue
的线程安全版本。不可以插入 null 值,同时,插入队列的对象必须是可比较大小的(comparable),否则报 ClassCastException
异常。它的插入操作 put 方法不会 block,因为它是无界队列(take 方法在队列为空的时候会阻塞);
⑤ConcurrentSkipListMap
使用跳表实现的Map;
使用跳表实现 Map
和使用哈希算法实现 Map
的另外一个不同之处是:哈希并不会保存元素的顺序,而跳表内所有的元素都是排序的。因此在对跳表进行遍历时,你会得到一个有序的结果。所以,如果你的应用需要有序性,那么跳表就是你不二的选择。JDK 中实现这一数据结构的类是 ConcurrentSkipListMap