一. 多线程概述
1. 并发编程的优缺点
优点:充分利用多核CPU的计算能力,方便进行业务拆分,提升系统并发能力和性能
缺点:发编程可能会遇到内存泄漏、上下文切换、线程安全、死锁等问题。
¥2. 并行和并发的区别
并发:多个任务在同一个 CPU 核上,按细分的时间片轮流(交替)执行,从逻辑上来看那些任务是同时执行。
并行:单位时间内,多个处理器或多核处理器同时处理多个任务,是真正意义上的“同时进行”。
¥3. 什么是线程和进程
进程:一个在内存中运行的应用程序。每个进程都有自己独立的一块内存空间,一个进程可以有多个线程。
线程:进程中的一个执行任务(控制单元),负责当前进程中程序的执行。一个进程至少有一个线程,一个进程可以运行多个线程,多个线程可共享数据。
¥4. 进程和线程的区别
进程是操作系统资源分配的基本单位,而线程是处理器任务调度和执行的基本单位
与进程不同的是同类的多个线程共享进程的堆和方法区资源,但每个线程有自己的程序计数器、虚拟机栈和本地方法栈,所以系统在产生一个线程,或是在各个线程之间作切换工作时,负担要比进程小得多,也正因为如此,线程也被称为轻量级进程。
5. 什么是上下文切换
个线程分配时间片并轮转的形式。当一个线程的时间片用完的时候就会重新处于就绪状态让给其他线程使用,这个过程就属于一次上下文切换。
概括来说就是:当前任务在执行完 CPU 时间片切换到另一个任务之前会先保存自己的状态,以便下次再切换回这个任务时,可以再加载这个任务的状态。任务从保存到再加载的过程就是一次上下文切换。
6. 什么是线程死锁
死锁是指两个或两个以上的进程(线程)在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁,这些永远在互相等待的进程(线程)称为死锁进程(线程)。
7. 形成死锁的四个必要条件
- 互斥条件:线程(进程)对于所分配到的资源具有排它性,即一个资源只能被一个线程(进程)占用,直到被该线程(进程)释放
- 请求与保持条件:一个线程(进程)因请求被占用资源而发生阻塞时,对已获得的资源保持不放。
- 不剥夺条件:线程(进程)已获得的资源在末使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源。
- 循环等待条件:当发生死锁时,所等待的线程(进程)必定会形成一个环路(类似于死循环),造成永久阻塞
8. 如何避免线程死锁
只要破坏产生死锁的四个条件中的其中一个就可以了。
- 破坏互斥条件:这个条件我们没有办法破坏,因为我们用锁本来就是想让他们互斥的(临界资源需要互斥访问)。
- 破坏请求与保持条件:占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。
- 破坏不剥夺条件:抢夺其他线程的锁,太暴力了 ,一般不会使用
- 破坏循环等待条件:靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。破坏循环等待条件。
9. Java中如何避免线程死锁
- 尽量使用 tryLock(long timeout, TimeUnit unit)的方法(ReentrantLock、ReentrantReadWriteLock),设置超时时间,超时可以退出防止死锁。
- 以固定的顺序获取锁
- 尽量避免一个线程获得多把锁
- 尽量降低锁的使用粒度,尽量不要几个功能用同一把锁。
- 尽量减少同步的代码块。
10. 死锁与活锁的区别,死锁与饥饿的区别?
- 死锁:是指两个或两个以上的进程(或线程)在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力作用,它们都将无法推进下去。
- 活锁:任务或者执行者没有被阻塞,由于某些条件没有满足,导致一直重复尝试,失败,尝试,失败。
- 饥饿:一个或者多个线程因为种种原因无法获得所需要的资源,导致一直无法执行的状态。
Java 中导致饥饿的原因:
- 高优先级线程吞噬所有的低优先级线程的 CPU 时间。
- 线程被永久堵塞在一个等待进入同步块的状态,因为其他线程总是能在它之前持续地对该同步块进行访问。
- 线程在等待一个本身也处于永久等待完成的对象(比如调用这个对象的 wait 方法),因为其他线程总是被持续地获得唤醒。
¥11. 乐观锁和悲观锁及其使用场景
- 乐观锁采用乐观的思想处理数据,在每次读取数据时都认为别人不会修改该数据,所以不会上锁。但在更新时会判断在此期间别人有没有更新该数据。Java中的乐观锁是基于CAS操作实现的,在对数据更新之前先比较当前值和传入的值是否一样,一样则更新否则直接返回失败状态。
- 悲观锁采用悲观的思想处理数据,每次读取数据时都认为别人会修改数据,所以每次都会上锁,其他线程将被阻塞。
- 乐观锁适用于写比较少的情况下(多读场景),即冲突真的很少发生的时候,这样可以省去了锁的开销,加大了系统的整个吞吐量。但如果是多写的情况,一般会经常产生冲突,这就会导致上层应用会不断的进行retry,这样反倒是降低了性能,所以一般多写的场景下用悲观锁就比较合适。
¥12. CAS
CAS即compare and swap(比较与交换),是一种有名的无锁算法。无锁编程,即不使用锁的情况下实现多线程之间的变量同步,也就是在没有线程被阻塞的情况下实现变量的同步,所以也叫非阻塞同步(Non-blocking Synchronization)。CAS算法涉及到三个操作数:
- 需要读写的内存值 V
- 进行比较的值 A
- 拟写入的新值 B
当且仅当 V 的值等于 A时,CAS通过原子方式用新值B来更新V的值,否则不会执行任何操作(比较和替换是一个原子操作)。一般情况下是一个自旋操作,即不断的重试。
¥13. CAS有什么问题?(什么是ABA问题?)如何解决?
- ABA问题
- 如果一个变量V初次读取的时候是A值,并且在准备赋值的时候检查到它仍然是A值,那我们就能说明它的值没有被其他线程修改过了吗?很明显是不能的,因为在这段时间它的值可能被改为其他值,然后又改回A,那CAS操作就会误认为它从来没有被修改过。这个问题被称为CAS操作的 "ABA"问题。
- 乐观锁通过版本号来解决ABA问题,具体的操作是每次执行数据修改操作时都会带上一个版本号,如果预期版本号和数据版本号一致就进行操作,并将版本号加1,否则执行失败。(如JUC中的AtomicStampReference)
- 大部分情况下ABA不会影响程序并发的正确性,因此AtomicStampReference用的很少,如果要用还不如直接用传统互斥
- 循环时间长开销大
- 对于资源竞争严重(线程冲突严重)的情况,CAS 自旋的概率会比较大,从而浪费更多的 CPU 资源,效率低于 synchronized。
- 只能保证一个共享变量的原子操作
- 当对一个共享变量执行操作时,我们可以使用循环 CAS 的方式来保证原子操作,但是对多个共享变量操作时,循环 CAS 就无法保证操作的原子性,这个时候就可以用锁。
14. 如何进行锁优化?
①减少锁持有的时间
②减小锁粒度:例如ConcurrentHashMap中的分段锁。
③读写分离:如读写锁,这样既保证了线程安全又提高了性能。
④锁粗化:将关联性强的锁操作集中处理。
⑤锁消除:注意代码规范,消除不必要的锁来提高性能。
15. servlet 是线程安全吗?
- Servlet 不是线程安全的,servlet 是单实例多线程的,当多个线程同时访问同一个方法,是不能保证共享变量的线程安全性的。
- SpringMVC 的 Controller Servlet 类似的处理流程,也不是线程安全的。
- 但是使用servlet时,一般使用的是线程的局部变量,而局部变量不存在线程安全问题
16. 什么是不可变对象,它对写并发应用有什么帮助
- 不可变对象(Immutable Objects)即对象一旦被创建它的状态(对象的数据,也即对象属性值)就不能改变。
- 不可变对象的类即为不可变类(Immutable Class)。Java 平台类库中包含许多不可变类,如 String、基本类型的包装类、BigInteger 和 BigDecimal 等。
- 由于不可变对象不会被改变,因此其一定是线程安全的,使用不可变对象无需采用额外的线程安全手段,因此能大大提升效率
17. 如何实现不可变对象
- 所有域都采用private final 修饰
- 不提供set方法
- 定义为final类,不允许重写其方法
- 引入可变对象则不允许该对象被外部修改(如存在容器域,必须保证外界不能修改该容器中的内容,外界获取时采用Collections.unmodified()包装)
18. 使用信号量实现生产者消费者模式
public class Cache {
private int cacheSize = 0;
public Semaphore mutex;
public Semaphore empty; //保证了容器空的时候(empty的信号量<=0), 消费者等待
public Semaphore full; //保证了容器满的时候(full的信号量 <= 0),生产者等待
public Cache(int size) {
mutex = new Semaphore(1); //二进制信号量,表示互斥锁
empty = new Semaphore(size);
full = new Semaphore(0);
}
public int getCacheSize()throws InterruptedException{
return cacheSize;
}
public void produce() throws InterruptedException{
empty.acquire(); // 消耗一个空位
mutex.acquire();
cacheSize++;
System.out.println("生产了一个产品, 当前产品数为" + cacheSize);
mutex.release();
full.release(); // 增加了一个产品
}
public void consume() throws InterruptedException{
full.acquire(); // 消耗了一个产品
mutex.acquire();
cacheSize--;
System.out.println("消费了一个产品, 当前产品数为" + cacheSize);
mutex.release();
empty.release(); // 增加了一个空位
}
}
二. Java多线程基础
1. 创建线程有哪几种实现方式?分别有什么优缺点?
- 继承Thread类,重写run()方法即可。优点是编码简单,缺点是不能继承其他类,功能单一。
- 实现Runnable接口,重写run()方法,并将该实现类作为参数传入Thread构造器。优点是可以继承其他类,避免了单继承的局限性;适合多个相同程序代码的线程共享一个资源(同一个线程任务对象可被包装成多个线程对象),实现解耦操作,代码和线程独立。缺点是实现相对复杂。
- 实现Callable接口,重写call()方法,并包装成FutureTask对象,再作为参数传入Thread构造器。优点是相比方式二可以获取返回值,缺点是实现复杂。
FutureTask<Integer> futureTask = new FutureTask<Integer>(new MyCallable());
Thread thread = new Thread(futureTask);
thread.start();
- 可以通过线程池创建。
¥2. start和run方法的区别
- start方法用于启动线程,真正实现了多线程,调用了start方法后,会在后台创建一个新的线程来执行,不需要等待run方法执行完毕就可以继续执行其他代码。
- run方法没有创建新的线程,而是在当前线程直接运行run方法中的代码。
¥3. 线程有哪些状态(线程的生命周期)?
- New:用new操作创建一个新线程,此时程序还未开始运行线程中的代码。
- Runnable:调用start()方法后进入可运行状态。
- Blocked:阻塞状态,内部锁(不是juc中的锁)获取失败时进入阻塞状态。
- Waiting:等待其他线程唤醒时进入等待状态。
- Timed Waiting:计时等待,带超时参数的方法,例如sleep(long time)。
- Terminated:终止状态,线程正常运行完毕或被未捕获异常终止。
¥4. 线程中断
- interrupt方法用于向线程发送一个终止信号,调用interrupt方法不会中断一个正在运行的线程,只会改变内部的中断标识位的值为true。
- 可以通过此标识位安全终止线程。比如想终止某个线程时,先调用interrupt方法然后在run方法中根据该线程isInterrupted方法的返回值安全终止线程。(isInterrupted不会清除中断标识位)
- 当调用wait、sleep、join方法使线程处于TIMED-WAITING状态使,调用interrupt方法会抛出InterruptedException,使线程提前结束TIMED-WAITING状态。在抛出该异常前将清除中断标识位。
5. 如何优雅的中断线程
- 异常法:对有可能被中断的代码块时用try…catch,并不断轮询线程是否被中断(thread.isInterupted()),若发现被中断,则抛出InterruptedException,并在catch中进行异常处理,释放资源
- volatile标志位:设置一volatile修饰的属性作为线程开关,并轮询该属性状态,一旦其他线程改变该属性状态,其他线程可感知到并及时中断
6. 守护线程
- 守护线程是运行在后台的一种特殊线程,独立于控制终端并且周期性地执行某种任务或等待处理某些已发生的事件。主线程运行结束后,守护线程会自动销毁。
- 通过setDaemon方法定义一个守护线程,守护线程的优先级较低,将一个用户线程设置为守护线程必须要在启动守护线程之前。
注意事项:
- setDaemon(true)必须在start()方法前执行,否则会抛出 IllegalThreadStateException 异常
- 在守护线程中产生的新线程也是守护线程
- 不是所有的任务都可以分配给守护线程来执行,比如读写操作或者计算逻辑
- 守护 (Daemon) 线程中不能依靠 finally 块的内容来确保执行关闭或清理资源的逻辑。因为一旦所有用户线程都结束运行,守护线程会随 JVM 一起结束工作,所以守护 (Daemon) 线程中的 finally 语句块可能无法被执行。
7. 线程优先级
每一个线程都是有优先级的,一般来说,高优先级的线程在运行时会具有优先权,但这依赖于线程调度的实现,这个实现是和操作系统相关的(OS dependent)。我们可以定义线程的优先级,但是这并不能保证高优先级的线程会在低优先级的线程前执行。线程优先级是一个 int 变量(从 1-10),1 代表最低优先级,10 代表最高优先级。
Java 的线程优先级调度会委托给操作系统去处理,所以与具体的操作系统优先级有关,如非特别需要,一般无需设置线程优先级。
8. 什么是线程组,为什么在 Java 中不推荐使用?
ThreadGroup 类,可以把线程归属到某一个线程组中,线程组中可以有线程对象,也可以有线程组,组中还可以有线程,这样的组织结构有点类似于树的形式。
线程组和线程池是两个不同的概念,他们的作用完全不同,前者是为了方便线程的管理,后者是为了管理线程的生命周期,复用线程,减少创建销毁线程的开销。
为什么不推荐使用线程组?因为使用有很多的安全隐患吧,没有具体追究,如果需要使用,推荐使用线程池。
¥9. wait、sleep、yield、join方法的区别
- wait是Object类的方法,调用wait方法的线程会进入WAITING状态,只有等待其他线程的通知或被中断后才会解除阻塞,调用wait方法会释放锁资源。
- sleep是Thread类的方法,调用sleep方法会导致当前线程进入休眠状态,与wait不同的是该方法不会释放锁资源,进入的是TIMED-WAITING状态。
- yiled方法会使当前线程让出CPU时间片给优先级相同或更高的线程,回到RUNNABLE状态,与其他线程一起重新竞争CPU时间片。
- join方法用于等待其他线程运行终止,如果当前线程调用了另一个线程的join方法,则当前线程进入阻塞状态,当另一个线程结束时当前线程才能从阻塞状态转为就绪态,等待获取CPU时间片。底层使用的是wait,也会释放锁。
10. 你是如何调用 wait() 方法的?使用 if 块还是循环?为什么?
处于等待状态的线程可能会收到错误警报和伪唤醒,如果不在循环中检查等待条件,程序就会在没有满足结束条件的情况下退出。
wait() 方法应该在循环调用,因为当线程获取到 CPU 开始执行的时候,其他条件可能还没有满足,所以在处理前,循环检测条件是否满足会更好。
如有多个生产者,多个消费者情况,一个消费者被唤醒后库存可能又变为空,不能通过if判断后就离开。
11. 为什么线程通信的方法 wait(), notify()和 notifyAll()被定义在 Object 类里?
Java中,任何对象都可以作为锁,并且 wait(),notify()等方法用于等待对象的锁或者唤醒线程,在 Java 的线程中并没有可供任何对象使用的锁,所以任意对象调用方法一定定义在Object类中。
12. 为什么 Thread 类的 sleep()和 yield ()方法是静态的?
Thread 类的 sleep()和 yield()方法将在当前正在执行的线程上运行。所以在其他处于等待状态的线程上调用这些方法是没有意义的。这就是为什么这些方法是静态的。它们可以在当前正在执行的线程中工作,并避免程序员错误的认为可以在其他非运行线程调用这些方法。
13. Java 如何实现多线程之间的通讯和协作
Java中线程通信协作的最常见的两种方式:
- syncrhoized加锁的线程的Object类的wait()/notify()/notifyAll()
- ReentrantLock类加锁的线程的Condition类的await()/signal()/signalAll()
线程间直接的数据交换:
- 通过管道进行线程间通信:1)字节流;2)字符流
14. Callable 和 Future
Callable 接口类似于 Runnable,但是 Runnable 不会返回结果,并且无法抛出返回结果的异常,而 Callable 功能更强大一些,被线程执行后,可以返回值,这个返回值可以被 Future 拿到,也就是说,Future 可以拿到异步执行任务的返回值。
Future 接口表示异步任务,是一个可能还没有完成的异步任务的结果。所以说 Callable用于产生结果,Future 用于获取结果。
¥15. Runnable 和 Callable 的区别
- Runnable 接口 run 方法无返回值;Callable 接口 call 方法有返回值,是个泛型,和Future、FutureTask配合可以用来获取异步执行的结果
- Runnable 接口 run 方法只能抛出运行时异常,且无法捕获处理;Callable 接口 call 方法允许抛出异常,可以获取异常信息
工具类 Executors 可以实现 Runnable 对象和 Callable 对象之间的相互转换。(Executors.callable(Runnable task)
或 Executors.callable(Runnable task,Object resule)
)。
16. 什么是 FutureTask
-
FutureTask 表示一个异步运算的任务。FutureTask 里面可以传入一个 Callable 的具体实现类,可以对这个异步运算的任务的结果进行等待获取、判断是否已经完成、取消任务等操作。只有当运算完成的时候结果才能取回,如果运算尚未完成 get 方法将会阻塞。
-
一个 FutureTask 对象可以对调用了 Callable 和 Runnable 的对象进行包装,由于 FutureTask 也是Runnable 接口的实现类,所以 FutureTask 也可以放入线程池中。
17. 什么是阻塞式方法?
阻塞式方法是指程序会一直等待该方法完成期间不做其他事情,ServerSocket 的accept()方法就是一直等待客户端连接。这里的阻塞是指调用结果返回之前,当前线程会被挂起,直到得到结果之后才会返回。此外,还有异步和非阻塞式方法在任务完成前就返回。
18. 线程类的构造方法、静态块是被哪个线程调用的
线程类的构造方法、静态块是被 new这个线程类所在的线程所调用的
19. 线程的异常处理?
-
Thread的run方法是不允许不抛出任何检查型异常的,如果存在检查型异常必须采用try…catch进行异常的捕获。但是run方法内部可能会抛出运行时异常。
-
如果异常没有被捕获该线程将会停止执行。
-
如果想让创建线程的父线程感知到该线程发生的异常,可使用UncaughtExceptionHandler
-
Thread.UncaughtExceptionHandler是用于处理未捕获异常造成线程突然中断情况的一个内嵌接口。当一个未捕获异常将造成线程中断的时候,JVM 会使用 Thread.getUncaughtExceptionHandler()来查询线程的 UncaughtExceptionHandler 并将线程和异常作为参数传递给 handler 的 uncaughtException()方法进行处理。
public static void main(String[] args) {
Thread thread = new Thread(){
@Override
public void run(){
try {
Thread.sleep(100);
int a = 1/0; //运行时异常
} catch (InterruptedException e) {
e.printStackTrace();
}
}
};
//给线程设置异常捕获器来在外部捕获运行时异常
thread.setUncaughtExceptionHandler(new Thread.UncaughtExceptionHandler() {
@Override
public void uncaughtException(Thread t, Throwable e) {
System.out.println(t.getName()+":");
e.printStackTrace();
}
});
thread.start();
20. Java 线程数过多会造成什么异常?
-
消耗过多的 CPU。资源如果可运行的线程数量多于可用处理器的数量,那么有线程将会被闲置。大量空闲的线程会占用许多内存,给垃圾回收器带来压力,而且大量的线程在竞争 CPU资源时还将产生其他性能的开销。
-
降低稳定性JVM。在可创建线程的数量上存在一个限制,这个限制值将随着平台的不同而不同,并且承受着多个因素制约,包括 JVM 的启动参数、Thread 构造函数中请求栈的大小,以及底层操作系统对线程的限制等。如果破坏了这些限制,那么可能抛出OutOfMemoryError 异常。
21. Java 中用到的线程调度算法是什么?
有两种调度模型:分时调度模型和抢占式调度模型。
分时调度模型是指让所有的线程轮流获得 cpu 的使用权,并且平均分配每个线程占用的 CPU 的时间片这个也比较好理解。
Java虚拟机采用抢占式调度模型,是指优先让可运行池中优先级高的线程占用CPU,如果可运行池中的线程优先级相同,那么就随机选择一个线程,使其占用CPU。处于运行状态的线程会一直运行,直至它不得不放弃 CPU。
22. 在监视器(Monitor)内部,是如何做线程同步的?程序应该做哪种级别的同步?
在 java 虚拟机中,每个对象( Object 和 class )通过某种逻辑关联监视器,每个监视器和一个对象引用相关联,为了实现监视器的互斥功能,每个对象都关联着一把锁。
一旦方法或者代码块被 synchronized 修饰,那么这个部分就放入了监视器的监视区域,确保一次只能有一个线程执行该部分的代码,线程在获取锁之前不允许执行该部分的代码
¥23. ThreadLocal 的实现原理及使用场景?
- 每个Thread中有一个ThreadLocalMap实例变量,该Map的key为ThreadLocal,value存储值,每个Thread中的ThreadLocalMap仅对当前线程可见。
- 调用ThreadLocal.get方法时,实际上是从当前线程中获取ThreadLocalMap<ThreadLocal, Object>,然后根据当前ThreadLocal获取当前线程共享变量Object。ThreadLocal.set,ThreadLocal.remove实际上是同样的道理。
- 使用场景:为每个线程分配一个 JDBC 连接 Connection。这样就可以保证每个线程的都在各自的 Connection 上进行数据库的操作,不会出现 A 线程关了 B线程正在使用的 Connection; 还有 Session 管理 等问题。
¥24. ThreadLocal内存泄漏问题及解决方案
在管理环境下(如 web 服务器)使用线程局部变量的时候要特别小心,在这种情况下,工作线程的生命周期比任何应用变量的生命周期都要长。任何线程局部变量一旦在工作完成后没有释放,Java 应用就存在内存泄露的风险。
原因:ThreadLocalMap 中使用的 key 为 ThreadLocal 的弱引用,而 value 是强引用。所以,如果 ThreadLocal 没有被外部强引用的情况下,在垃圾回收的时候,key 会被清理掉,而 value 不会被清理掉。这样一来,ThreadLocalMap 中就会出现key为null的Entry。假如我们不做任何措施的话,value 永远无法被GC 回收,这个时候就可能会产生内存泄露。
解决方案:
ThreadLocalMap实现中已经考虑了这种情况,在调用 set()、get()、remove() 方法的时候,会清理掉 key 为 null 的记录。使用完 ThreadLocal方法后最好手动调用remove()方法
- 每次使用完ThreadLocal,都调用它的remove()方法,清除数据。
- 在使用线程池的情况下,没有及时清理ThreadLocal,不仅是内存泄漏的问题,更严重的是可能导致业务逻辑出现问题。所以,使用ThreadLocal就跟加锁完要解锁一样,用完就清理。
三. JMM与关键字
¥1. 并发编程三要素
- 原子性:指的是一个或多个操作要么全部执行成功要么全部执行失败。
- 可见性:一个线程对共享变量的修改,另一个线程能够立刻看到。
- 有序性:程序执行的顺序按照代码的先后顺序执行。(处理器可能会对指令进行重排序)
¥2. 多线程不可见问题的原因和解决方式?
-
不可见的原因是每个线程有自己的工作内存,线程都是从主内存拷贝共享变量的副本值。每个线程都是在自己的工作内存操作共享变量的。
-
解决方式:
- 加锁:获得锁后线程会清空工作内存,从主内存拷贝共享变量最新的值成为副本,修改后刷新回主内存,再释放锁;
- 使用volatile关键字:被volatile修饰的变量会通知其他线程之前读取到的值已失效,线程会加载最新值到自己的工作内存。
3. 代码重排序和as-if-serial规则
在执行程序时,为了提高性能,处理器和编译器常常会对指令进行重排序,但是不能随意重排序,它需要满足以下两个条件(as-if-serial规则):
- 在单线程环境下不能改变程序运行的结果;
- 存在数据依赖关系的不允许重排序
需要注意的是:重排序不会影响单线程环境的执行结果,但是会破坏多线程的执行语义。
¥4. happens-before规则
- happens-before规则是Java内存模型中定义的两个操作之间的偏序关系,屏蔽了硬件上编译器重排序和处理器重排序的规则。
- happens-before规定如果操作A先行发生于操作B,那么在B操作发生之前,A操作产生的“影响”都会被操作B感知到。
- 如果满足happens-before规则,则无需通过额外的同步手段即可保证多线程程序的有序性
- 此外,如果重排序的结果与按happens-before顺序执行的结果一致,则该重排序是允许的
happens-before规则的具体内容:
- 程序次序原则:在一个线程内部,按照代码的顺序,书写在前面的先行发生与后边的。
- 管程锁定规则:一个unlock操作先行发生于后面对同一个锁的lock操作。这里必须注意的是对同一个锁,后面是指时间上的后面
- volatile变量规则:对一个volatile变量的写操作先行发生与后面对这个变量的读操作,这里的后面是指时间上的先后顺序
- 线程启动规则:Thread对象的start()方法先行发生与该线程的每个动作。
- 线程终止规则:线程中的所有操作都先行发生与对此线程的终止检测,可以通过Thread.join()和Thread.isAlive()的返回值等手段检测线程是否已经终止执行
- 线程中断规则“对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测到是否有中断发生。
- 对象终结规则:一个对象的初始化完成先行发生于他的finalize方法的执行,也就是初始化方法先行发生于finalize方法
- 传递性:如果操作A先行发生于操作B,操作B先行发生于操作C,那么操作A先行发生于操作C。
5. as-if-serial规则和happens-before规则的区别
- as-if-serial语义保证单线程内程序的执行结果不被改变,happens-before关系保证正确同步的多线程程序的执行结果不被改变。
¥6. volatile关键字的作用
- 保证被修饰的变量对所有线程可见(可见性),在一个线程修改了变量的值后,新的值对于其他线程是可以立即获取的。 volatile 提供 happens-before 的保证,确保一个线程的修改能对其他线程是可见的。JMM规定:当一个共享变量被 volatile 修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。
- 禁止指令重排序(有序性)
适用场景:
- 运算结果不依赖于当前值,如状态量标记
- 作为内存屏障,禁止重排序
6.5 volatile的底层原理
volatile的底层实现是通过插入内存屏障,但是对于编译器来说,发现一个最优布置来最小化插入内存屏障的总数几乎是不可能的,所以,JMM采用保守策略。如下:
在每一个volatile写操作前面插入一个StoreStore屏障
在每一个volatile写操作后面插入一个StoreLoad屏障
在每一个volatile读操作前面插入一个LoadLoad屏障
在每一个volatile读操作后面插入一个LoadStore屏障
StoreStore屏障可以保证在volatile写之前,其前面的所有普通写操作都已经刷新到主内存中;
StoreLoad屏障的作用是避免volatile写与后面可能有的volatile读/写操作重排序
LoadLoad屏障用来禁止处理器把上面的volatile读与下面的普通读重排序
LoadStore屏障用来禁止处理器把上面的volatile读与下面的普通写重排序
¥7. 单例模式中的instance为什么需要用volatile修饰
uniqueInstance = new Singleton(); 这段代码其实是分为三步执行:
- 为 uniqueInstance 分配内存空间
- 初始化 uniqueInstance
- 将 uniqueInstance 指向分配的内存地址
但是由于 JVM 具有指令重排的特性,执行顺序有可能变成 1->3->2。指令重排在单线程环境下不会出现问题,但是在多线程环境下会导致一个线程获得还没有初始化的实例。
例如,线程 T1 执行了 1 和 3,此时 T2 调用 getUniqueInstance() 后发现 uniqueInstance 不为空,因此返回 uniqueInstance,但此时 uniqueInstance 还未被初始化。
使用 volatile 可以禁止 JVM 的指令重排,保证在多线程环境下也能正常运行。
8. volatile 变量和 atomic 变量有什么不同?
volatile 变量可以确保先行关系,即写操作会发生在后续的读操作之前, 但它并不能保证原子性。例如用 volatile 修饰 count 变量,那么 count++ 操作就不是原子性的。
而 AtomicInteger 类提供的 atomic 方法可以让这种操作具有原子性如getAndIncrement()方法会原子性的进行增量操作把当前值加一,其它数据类型和引用变量也可以进行相似操作。
9. Java 中能创建 volatile 数组吗?
能,Java 中可以创建 volatile 类型数组,不过只是一个指向数组的引用,而不是整个数组。意思是,如果改变引用指向的数组,将会受到 volatile 的保护,但是如果多个线程同时改变数组的元素,volatile 标示符就不能起到之前的保护作用了。
¥10. synchronized关键字的作用
synchronized关键字可以保证程序的原子性,可见性,有序性
- 原子性:只有单个线程能访问同步代码块,整个过程无法被其他线程干扰,可看作是一原子过程
- 可见性:JMM中synchronized的内存语义规定unlock之前必须把变量同步回主内存
- 有序性:synchronized的代码块可以看成是单线程执行,as-if-serial规则保证重排序不会影响结果
11. synchronized的使用方式
- 修饰实例方法: 作用于当前对象实例加锁,进入同步代码前要获得 当前对象实例的锁
- 修饰静态方法: 也就是给当前类加锁,会作用于类的所有对象实例 ,进入同步代码前要获得 当前 class 的锁。
- 修饰代码块 :指定加锁对象,对给定对象/类加锁。
¥12. synchronized实现原理
Synchronized是通过对象内部的一个叫做监视器锁(monitor)来实现的,监视器锁本质又是依赖于底层的操作系统的Mutex Lock(互斥锁)来实现的。Java中的每个对象都有一个monitor监视器对象,加锁就是在竞争monitor。
- synchronized 同步语句块:在代码块前后分别加上monitorenter和monitorexit指令实现的
-
当执行 monitorenter 指令时,线程试图获取锁也就是获取 对象监视器 monitor 的持有权,如果锁的计数器为 0 (在对象头中)则表示锁可以被获取,获取后将锁计数器设为 1 也就是加 1。
-
在执行 monitorexit 指令后,将锁计数器设为 0,表明锁被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止。
在 Java 虚拟机(HotSpot)中,Monitor 是基于 C++实现的,由ObjectMonitor实现的。每个对象中都内置了一个 ObjectMonitor对象。
- synchronized 修饰方法:通过一个标记位(方法常量池中的方法表结构中的 ACC_SYNCHRONIZED 访问标志来判断的。如果设置了,执行线程将先持有monitor, 然后再执行方法,最后再方法完成(无论是正常完成还是非正常完成)时释放monitor。
12.5 重量级锁,wait,notify底层原理
重量级锁模式时对象头是一个指向互斥量的指针,实际上互斥量就是一个监视器锁(ObjectMonitor)的数据结构,此时对象的hashCode、分代年龄等信息都会保存到对应的ObjectMonitor中,ObjectMonitor还有一些属性如recursion记录本锁被重入的次数,EntrySet记录想获取本锁的线程集合,WaitSet记录等待本锁的线程,TheOwner记录拥有本锁的线程对象。如下:
几个线程一起竞争对象的锁(EntrySet),只有一个能成功(acquire),成功的线程记录在The Owner中。调用wait、notify运行流程如下:
- 现有一个对象o,锁正在被线程 t1 持有,调用wait()方法后,线程 t1 将会被"晾到" (实际上仅仅是记录到) Wait Set 结构中。
- 然后将会有另一个线程 t2 获取到锁,The Owner记录的变成了 t2 线程。
- t2 线程不需要 o的锁时,调用o.notify()/o.notifyAll()方法,对象o就会告诉 Wait Set结构中记录的线程们:你们又可以来竞争我啦,我的锁现在没被人持有。Wait Set的线程被移动到EntrySet。
¥13. synchronized锁优化
在 Java 早期版本中,synchronized属于重量级锁,效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的 Mutex Lock 来实现的,Java 的线程是映射到操作系统的原生线程之上的。
如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态。JDK1.6对锁进行了优化(不需要每次都切换到内核态)
锁的优化借助对象头中的Markword,Markword在无锁时存放对象信息,有锁时记录锁的状态
无锁时的Markword:
①偏向锁:
- 判断是否为可偏向状态
- 如果为可偏向状态,则判断线程ID是否是当前线程,如果是进入同步块;如果不是可偏向状态,使用CAS竞争锁,将Mark Word中线程ID更新为当前线程ID,并修改锁标志位为可偏向状态
- 如果线程ID并未指向当前线程,利用CAS操作竞争锁,如果竞争成功,将Mark Word中线程ID更新为当前线程ID,进入同步块
- 如果竞争失败,等待全局安全点,准备撤销偏向锁,根据线程是否处于活动状态,决定是转换为无锁状态还是升级为轻量级锁。(如果线程还活着,则升级为轻量级锁,否则,将锁设置为无锁状态。)
偏向锁是针对于一个线程而言的, 线程获得锁之后就不会再有解锁等操作了, 这样可以省略很多开销. 假如有两个线程来竞争该锁话, 那么偏向锁就失效了, 进而升级成轻量级锁了.
偏向锁时的Markword
②轻量级锁:
加锁过程:
- 在代码进入同步块的时候,如果此对象没有被锁定(锁标志位为“01”状态),虚拟机首先在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储对象目前Mark Word的拷贝。
- 虚拟机使用CAS操作尝试将对象的Mark Word更新为指向锁记录(Lock Record)的指针。如果更新成功,那么这个线程就拥有了该对象的锁,并且对象的Mark Word标志位转变为“00”,即表示此对象处于轻量级锁定状态;
- 如果更新失败,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块中执行,否则说明这个锁对象已经被其他线程占有了。
- 如果有两条以上的线程竞争同一个锁,自旋CAS一定次数如果还未能获取到轻量级锁,则要膨胀为重量级锁,把Mark Word修改为指向重量级锁的指针,锁标志变为“10”,线程挂起进入阻塞状态,后面等待的线程也要进入阻塞状态。
解锁过程:
- 如果对象的Mark Word仍然指向线程的锁记录,那就用CAS操作将对象当前的Mark Word与线程栈帧中的Displaced Mark Word交换回来,如果替换成功,整个同步过程就完成了。
- 如果替换失败,说明有其他线程尝试过获取该锁(指针已指向重量级锁),那就要在释放锁的同时,唤醒被挂起的线程。
轻量级锁与重量级锁时的Markword
总结:
- 锁级别从低到高分别为:1.无锁状态 2.偏向锁状态 3.轻量级锁状态 4.重量级锁状态
- 偏向锁适用于总是只有一个线程竞争锁的情况,一旦两个线程同时竞争锁则偏向锁失效
- 轻量级锁适用于锁竞争一般的情况,争取不到锁则自旋争取一会儿,如果自旋一会儿争取到了,那么就避免了切换到内核态阻塞,提高了效率。但是如果自旋了很久还没争取到,则白白浪费CPU,还不如挂起
- 重量级锁适用与锁竞争激烈的情况,老老实实挂起等唤醒
14. 锁粗化与锁消除
- 锁消除:指虚拟机即时编译器(JIT)在运行时,对一些代码上要求同步,但是检测到不可能发生数据竞争的锁进行消除。
- 锁粗化:如果虚拟机检测到有这样一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部。
15. synchronized可重入的原理
重入锁是指一个线程获取到该锁之后,该线程可以继续获得该锁。
底层原理维护一个计数器,当线程获取该锁时,计数器加一,再次获得该锁时继续加一,释放锁时,计数器减一,当计数器值为0时,表明该锁未被任何线程所持有,其它线程可以竞争获取锁。
16. 构造方法可以使用 synchronized 关键字修饰么?
构造方法不能使用 synchronized 关键字修饰。构造方法本身就属于线程安全的,不存在同步的构造方法一说。
¥17. volatile和synchronized的区别?
- volatile只能修饰实例变量和类变量,而synchronized可以修饰方法以及代码块。
- volatile只能保证数据的可见性,但是不保证原子性,synchronized是一种排它机制,可以保证原子性。只有在特殊情况下才适合取代synchronized:对变量的写操作不依赖于当前值(例如i++),或者是单纯的变量赋值;
- volatile是一种轻量级的同步机制,在访问volatile修饰的变量时并不会执行加锁操作,线程不会阻塞,使用synchronized加锁会阻塞线程。
¥18. final的内存语义
- 在构造函数对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作不能重排序
- 初次读一个包含final域的对象的引用,与随后初次读这个final域,不能重排序
final的内存语义保证:在引用变量为任意线程可见之前,该引用变量指向的对象final域已在构造函数中被正确的初始化过
四. JUC包(一)——原子类,AQS,工具类
1. JUC 包中的原子类分类
- 基本类型类:
- AtomicInteger:整形原子类
- AtomicLong:长整型原子类
- AtomicBoolean:布尔型原子类
- 数组类型类(使用原子的方式更新数组里的某个元素):
- AtomicIntegerArray:整形数组原子类
- AtomicLongArray:长整形数组原子类
- AtomicReferenceArray:引用类型数组原子类
- 引用类型类:
- AtomicReference:引用类型原子类
- AtomicStampedReference:原子更新带有版本号的引用类型。该类将整数值与引用关联起来,可用于解决原子的更新数据和数据的版本号,可以解决使用 CAS 进行原子更新时可能出现的 ABA 问题。
- AtomicMarkableReference :原子更新带有标记位的引用类型
- 对象的属性修改类型:
- AtomicIntegerFieldUpdater:原子更新整形字段的更新器
- AtomicLongFieldUpdater:原子更新长整形字段的更新器
- AtomicReferenceFieldUpdater:原子更新引用类型字段的更新器
¥2. Atomic 类的原理
// setup to use Unsafe.compareAndSwapInt for updates(更新操作时提供“比较并替换”的作用)
private static final Unsafe unsafe = Unsafe.getUnsafe();
private static final long valueOffset;
static {
try {
valueOffset = unsafe.objectFieldOffset
(AtomicInteger.class.getDeclaredField("value"));
} catch (Exception ex) { throw new Error(ex); }
}
private volatile int value;
AtomicInteger 类主要利用 CAS (compare and swap) + volatile 和 - native 方法来保证原子操作,从而避免 synchronized 的高开销,执行效率大为提升。
- CAS 的原理是拿期望的值和原本的一个值作比较,如果相同则更新成新的值。
- UnSafe 类的 objectFieldOffset() 方法是一个本地方法,这个方法是用来拿到“原来的值”的内存地址,返回值是 valueOffset。
- value 是一个 volatile 变量,在内存中可见,因此 JVM 可以保证任何时刻任何线程总能拿到该变量的最新值。
源码如下,可以看到本质就是循环CAS
public final int getAndIncrement() {
for (;;) {
int current = get();
int next = current + 1;
if (compareAndSet(current, next))
return current;
}
}
public final boolean compareAndSet(int expect, int update) {
return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}
2.5 Unsafe提供的原子操作
unsafe提供了3种CAS操作(乐观锁),来对原子类进行修改
public final native boolean compareAndSwapObject(Object var1, long var2, Object var4, Object var5);
public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);
public final native boolean compareAndSwapLong(Object var1, long var2, long var4, long var6);
在Java的基本类型中除了Atomic包中提供原子更新的基本类型外,还有char、float和double。那么这些在Atomic包中没有提供原子更新的基本类型怎么保证其原子更新呢?
从AtomicBoolean源码中我们可以得到答案:首先将Boolean转换为整型,然后使用comareAndSwapInt进行CAS,所以原子更新char、float、double同样可以以此实现。
¥3. 什么是AQS,如何使用AQS构建同步器
- AQS是一个用来构建锁和同步器的框架,使用AQS能简单且高效地构造出应用广泛的大量的同步器,如ReentrantLock,Semaphore,ReentrantReadWriteLock,SynchronousQueue,FutureTask等等皆是基于AQS实现的。
- AQS中提供了基本的模版方法(如acquire()),模版方法中调用了一些抽象方法(如tryAcquire()),而抽象方法需要在具体的实现类中进行重写实现(这些重写方法很简单,无非是对于共享资源 state 的获取和释放)。
- 因此使用时会在同步组件内部实现一个继承自AQS的静态内部类来实现AQS的抽象方法,然后在对提供对外公共方法中调用AQS的模板方法来实现对应的功能。(模板方法设计模式)
- 需重写的方法:
isHeldExclusively() //该线程是否正在独占资源。只有用到condition才需要去实现它。
tryAcquire(int) //独占方式。尝试获取资源,成功则返回true,失败则返回false。
tryRelease(int) //独占方式。尝试释放资源,成功则返回true,失败则返回false。
tryAcquireShared(int) //共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
tryReleaseShared(int) //共享方式。尝试释放资源,成功则返回true,失败则返回false。
¥4. AQS原理概述
- 核心原理:AQS核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制AQS是用CLH队列锁实现的,即将暂时获取不到锁的线程加入到队列中。
- AQS使用一个int成员变量来表示同步状态,通过内置的FIFO队列来完成获取资源线程的排队工作。AQS使用CAS对该同步状态进行原子操作实现对其值的修改。
private volatile int state;//共享变量,使用volatile修饰保证线程可见性
- 状态信息通过protected类型的getState,setState,compareAndSetState进行操作
5. AQS 对资源的共享方式
AQS定义两种资源共享方式:
- Exclusive(独占):只有一个线程能执行,如ReentrantLock。又可分为公平锁和非公平锁:
- Share(共享):多个线程可同时执行,如Semaphore、CountDownLatch、 CyclicBarrier、ReadWriteLock 我们都会在后面讲到。
不同的自定义同步器争用共享资源的方式不同。自定义同步器在实现时只需要实现共享资源 state 的获取与释放方式即可,至于具体线程等待队列的维护(如获取资源失败入队/唤醒出队等),AQS已经在顶层实现好了。
一般来说,自定义同步器要么是独占方法,要么是共享方式,他们也只需实现tryAcquire-tryRelease
、tryAcquireShared-tryReleaseShared
中的一种即可。但 AQS 也支持自定义同步器同时实现独占和共享两种方式,如ReentrantReadWriteLock。
5.5 AQS共享模式和独占模式的区别
两者意思上看起来就一个区别:一个是排它锁,一个是共享锁
- acquire:排它锁,忽略中断,调用期间可能会不断的阻塞然后解除阻塞,直到调用tryAcquire成功
- acquireShared:共享锁,忽略中断,除非调用tryAcquireShared成功,其它表现和acquire相同。
代码上来看:
- 一个添加共享节点,另外一个添加排它节点
- acquireShared获取成功后,会向下传播,acquire则只设置head节点;在acquireShared获取成功后,后面的节点也会继续执行acquireShared,而acquire则不会
¥6. AQS的入队细节(acquire方法的实现)
AQS acquire方法代码如下:
public final void acquire(int arg) {
// 1.首先调用tryAcquire()一下试一试能否直接抢到锁,如果抢到了就不需要进队列排队了
// 这里的tryAcquire()需要由具体的实现类规定抢锁规则
if (!tryAcquire(arg) &&
// 2.tryAcquire(arg)没有成功,这个时候需要把当前线程挂起,放到阻塞队列中。
acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) {
selfInterrupt();
}
}
- tryAcquire()先尝试获取锁,以ReentrantLock公平锁为例,如果发现AQS的State为0,且没有其他线程在等待,则CAS将State设为1,成功则代表抢到锁。没抢到则返回false;
- 如果tryAcquire()失败,则调用addWaiter(),该方法就是把当前线程构造成一个Node节点,然后自旋CAS地插到AQS同步队列队尾
- 插入完成后调用acquireQueued()方法
final boolean acquireQueued(final Node node, int arg) {
boolean failed = true;
try {
boolean interrupted = false;
for (;;) {
final Node p = node.predecessor();
// 如果其前驱节点是头节点,则再去尝试抢一下锁
if (p == head && tryAcquire(arg)) {
// 如果抢到了,则把当前节点设为头节点
setHead(node);
p.next = null; // help GC
failed = false;
// 返回后acquire方法结束,抢到锁了执行同步代码
return interrupted;
}
// 到这里,说明上面的if分支没有成功,要么当前node本来就不是队头,
// 要么就是tryAcquire(arg)没有抢赢别人
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}
- 如果前驱节点不是头节点,或者没有抢到锁,则调用shouldParkAfterFailedAcquire(p, node) ,该方法就是把前驱节点的waitStatus设为-1然后返回true;(其实这里执行了两次,第一次设为-1,返回false;第二次发现是-1后返回true)(waitStatus:SIGNAL(-1):表示后继结点在等待当前结点唤醒。后继结点入队时,会将前继结点的状态更新为SIGNAL。)
- 返回true后调用parkAndCheckInterrupt(),该方法就是将该线程阻塞
private final boolean parkAndCheckInterrupt() {
// 通过LockSupport.park实现,后面在此处被唤醒或被中断
// 如果是被中断则返回true,interrupted 会被设为true,但也仅仅只是做了个标记罢了,在这个方法中并没有实现响应中断
LockSupport.park(this);
return Thread.interrupted();
}
¥7. AQS的出队细节(release方法)
AQS release方法代码如下:
public final boolean release(int arg) {
if (tryRelease(arg)) {
Node h = head;
if (h != null && h.waitStatus != 0)
unparkSuccessor(h);
return true;
}
return false;
}
- tryRelease由具体实现类实现,ReentrantLock就是把AQS的Status设为0
- unparkSuccessor()方法则会唤醒后继节点
private final boolean parkAndCheckInterrupt() {
// 唤醒后从这返回,重新进入acquireQueued的for循环
LockSupport.park(this);
return Thread.interrupted();
}
// 这一次走到这就会发现前驱节点是头节点,且抢锁也会成功了
if (p == head && tryAcquire(arg)) {
setHead(node);
p.next = null;
failed = false;
return interrupted;
}
- 以此类推,队列中阻塞的线程被一个个唤醒
¥8. synchronized和ReentrantLock有哪些区别?
Lock 接口比同步方法和同步块提供了更具扩展性的锁操作。他们允许更灵活的结构,可以具有完全不同的性质,并且可以支持多个相关类的条件对象。
- ReentrantLock支持公平锁与非公平锁,synchronized是非公平锁
- ReentrantLock支持可响应中断锁,synchronized不响应中断
- ReentrantLock支持非阻塞地获取锁,如嗅探锁和超时锁(tryLock)
- ReentrantLock支持多个条件队列(Condition)
¥10. ReentrantLock及其可重入的实现原理
- ReentrantLock借助AQS实现:state 初始化为 0,表示未锁定状态。A 线程 lock()时,会调用 tryAcquire()CAS地独占该锁并将 state+1。此后,其他线程再 tryAcquire()时就会失败,直到 A 线程 unlock()到 state=0(即释放锁)为止,其它线程才有机会获取该锁。
- 可重入性实现原理:1. 在线程获取锁的时候,判断已经获取锁的线程是否是当前线程,如果是则直接再次获取成功;2. 由于锁会被获取n次,那么只有锁在被释放同样的n次之后,该锁才算是完全释放成功。
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0) throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
¥11. ReentrantLock公平锁非公平锁区别及实现原理
- ReentrantLock内部有两个AQS的实现类FairSync,NonFairSync;分别对应公平锁与非公平锁,它们对tryAcquire()有不同的实现
- 公平锁实现原理:公平锁在实现tryAcquire时需要先判断是否有线程在等待,没有线程在等待时才尝试去抢锁
if (c == 0) {
if (!hasQueuedPredecessors() &&
compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
- 非公平锁实现原理:在tryAcquire()的实现中无需去判断是否有线程在等待,如果发现能抢占则立即去CAS抢占。(如果没有抢到,还是需要老老实实地进入等待队列中,且前驱节点是head时才能获得锁)
if (c == 0) {
if (compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
- 非公平锁开销小,吞吐量大,性能好于公平锁;但公平锁能保证FIFO
11. Condition与wait()/notify()的区别
- Condition支持多个等待队列,可有针对性的唤醒对应线程
- 支持在等待状态下不响应中断(awaitUninterruptibly())
¥12. ReentrantLock的Condition实现原理
- ReentrantLock的每个Condition对应一个等待队列
- await()方法:将线程构造成Node节点放入等待队列的队尾进入阻塞状态,并释放锁(该过程不需要CAS保证,因为await()前已获得锁)
- singal()方法:将等待队列的头节点自旋CAS地插入到同步队列的队尾,然后用LockSupport唤醒该节点的线程,唤醒后的线程又会去调用AQS的acquireQueued()参与到获取锁的竞争中
- singalAll()方法:对等待队列中的每一个节点执行一次singal()方法,即将等待队列中的每个节点逐个移动到同步队列中
¥13. 常用的并发工具类有哪些?
- Semaphore(信号量):允许多个线程同时访问。synchronized 和 ReentrantLock 都是一次只允许一个线程访问某个资源,Semaphore(信号量)可以指定多个线程同时访问某个资源。
- CountDownLatch(倒计时器): CountDownLatch是一个同步工具类,用来协调多个线程之间的同步。这个工具通常用来控制线程等待,它可以让某几个线程等待其他多个线程执行完毕后再开始执行。
- CyclicBarrier(循环栅栏): 实现线程间的技术等待。让一组线程到达一个屏障时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续干活。
¥14. CycliBarriar 和 CountdownLatch 的区别
- CountDownLatch一般用于某个线程A等待若干个其他线程执行完任务之后,它才执行;而CyclicBarrier一般用于一组线程互相等待至某个状态,然后这一组线程再同时执行;CountDownLatch强调一个线程等多个线程完成某件事情。CyclicBarrier是多个线程互等,等大家都完成,再携手共进。
- CountDownLatch方法比较少,操作比较简单,而CyclicBarrier提供的方法更多,比如能够通过getNumberWaiting(),isBroken()这些方法获取当前多个线程的状态,并且CyclicBarrier的构造方法可以传入barrierAction,指定当所有线程都到达时执行的业务功能;
- CountDownLatch是不能复用的,而CyclicLatch是可以复用的。
¥15. CountdownLatch实现原理
- 任务分为 N 个子线程去执行,state 初始化为 N。
- 所有await() 的线程先被暂时挂起
- N 个子线程是并行执行的,每个子线程执行完后 countDown() 一次,state 会 CAS(Compare and Swap)减 1。
- 等到所有子线程都执行完后(即 state=0),会 unpark()主调用线程,然后主调用线程就会从 await() 函数返回,继续后余动作。
源码实现:
- 重写tryAcquireShared(),只要state不为0,则获取不到,之后进入等待队列被阻塞
//
protected int tryAcquireShared(int acquires) {
return (getState() == 0) ? 1 : -1;
}
- 重写tryReleaseShared()方法,只有state为0,releaseShared()才会成功,并唤醒阻塞的节点
for (;;) {
int c = getState();
if (c == 0)
return false;
int nextc = c - 1;
if (compareAndSetState(c, nextc))
return nextc == 0;
}
- 阻塞的节点被唤醒后又重新去调用tryAcquireShared(),此时state为0,可以成功返回
16. CyclicBarrier的实现原理
- 基于ReentrantLock的Condition,用count值记录还未到达的线程数
- 如果count>0,则阻塞在Condition上
- 如果count == 0,则唤醒Condition上等待的线程,并开启新的一代
17. Semaphore实现原理
- 基于AQS的共享模式,将state设置为规定的许可证数目
- 如果acquire()时许可证数目不足则阻塞
- 释放时将state加回去,然后唤醒阻塞的节点
18. Exchanger的作用
- Exchanger是一个用于线程间协作的工具类,用于两个线程间交换数据。
- 它提供了一个交换的同步点,在这个同步点两个线程能够交换数据。交换数据是通过exchange方法来实现的,如果一个线程先执行exchange方法,那么它会同步等待另一个线程也执行exchange方法,这个时候两个线程就都达到了同步点,两个线程就可以交换数据。
19. ¥ReentrantReadWriteLock实现原理
- 读锁和写锁分别对应两个内部类ReadLock和WriteLock,且同用一个AQS实现类Sync。但读锁使用AQS的共享模式,写锁使用AQS的独占模式。
- AQS状态量state的前16位用作读锁获取次数,后16位用作写锁重入次数
- 写锁的释放与获取要求没有读锁被获取;读锁的获取与释放要求写锁没有被获取
20. StampedLock的作用
在读多写少的场景,为避免写线程一直抢不到锁导致更新缓慢;该锁允许在读的过程中让写线程进行操作。如果确实写了则读到的值需手动作废
五. JUC包(二)——并发容器
¥1. CopyOnWriteArrayList特点,实现原理及应用场景
- CopyOnWriteArrayList 是线程安全的列表,其读取操作完全不用加锁,写入也不会阻塞读取操作,只有写入和写入之间需要进行同步等待。从而使得读操作的性能就会大幅度提升。
- 实现原理:CopyOnWriteArrayList 类的所有可变操作(add,set 等等)都是通过创建底层数组的新副本来实现的。当 List 需要被修改的时候,我并不修改原有内容,而是对原有数据进行一次复制,将修改的内容写入副本。写完之后,再将修改完的副本替换原来的数据,这样就可以保证写操作不会影响读操作了。
- 思想:读写分离,写操作在新容器,读操作在原容器
- 使用场景:读多写少的场景。
2. CopyOnWriteArrayList 的局限性
- 占用内存。由于写操作的时候,需要拷贝数组,会消耗内存,如果原数组的内容比较多的情况下,可能导致 young gc 或者 full gc。
- 不能用于实时读的场景。像拷贝数组、新增元素都需要时间,所以调用一个 set 操作后,读取到数据可能还是旧的,虽然CopyOnWriteArrayList 能做到最终一致性,但是还是没法满足实时性要求。
- 复制代价高。由于实际使用中可能没法保证 CopyOnWriteArrayList 到底要放置多少数据,万一数据稍微有点多,每次 add/set 都要重新复制数组,这个代价实在太高昂了。在高性能的互联网应用中,这种操作分分钟引起故障。
3. CopyOnWriteArrayList的源码实现
- 内部存储数组用volatile修饰保证可见性
private transient volatile Object[] array;
- 写入操作通过ReentrantLock保证线程同步,内部进行数组拷贝和重定向
public boolean add(E e) {
final ReentrantLock lock = this.lock;
lock.lock();//加锁
try {
Object[] elements = getArray();
int len = elements.length;
Object[] newElements = Arrays.copyOf(elements, len + 1);//拷贝新数组
newElements[len] = e;
setArray(newElements);
return true;
} finally {
lock.unlock();//释放锁
}
}
4. ConcurrentLinkedQueue的特点,实现原理及应用场景
- ConcurrentLinkedQueue使用链表作为其数据结构,是在高并发环境中性能最好的队列,因为其采用无锁的方式保证队列的线程安全。
- 实现原理:入队时以循环CAS的方式将元素插入队尾(但为了提升效率,不是每次都把入队元素设为队尾,而是隔一次设置一次)
- 适用场景:ConcurrentLinkedQueue 适合在对性能要求相对较高,同时对队列的读写存在多个线程同时进行的场景,即如果对队列加锁的成本较高则适合使用无锁的 ConcurrentLinkedQueue 来替代。
¥5. ConcurrentSkipListMap的特点,实现原理及应用场景
- 跳表内所有的元素都是排序的。因此在对跳表进行遍历时会得到一个有序的结果(TreeMap在并发场景下的替代品)。
- 跳表是一种可以用来快速查找的数据结构(时间复杂度为 O(logn)),有点类似于平衡树。它们都可以对元素进行快速的查找。但对平衡树的插入和删除往往很可能导致平衡树进行一次全局的调整。而对跳表的插入和删除只需要对整个数据结构的局部进行操作即可。
- 因此在高并发的情况下,需要一个全局锁来保证整个平衡树的线程安全。而对于跳表,只需要部分锁即可。这样,在高并发环境下,你就可以拥有更好的性能。
- 跳表的结果:本质带索引的多级链表
- 应用场景:高并发场景下需要保证Map的Key的有序性
6. ConcurrentSkipListMap的底层实现
- 为了提升效率,ConcurrentSkipListMap采用了CAS+自旋的无锁方式保证的线程安全
- 删除节点时在目标节点后添加一个marker节点标记节点已被删除,再采用CAS自旋的方式将前一个节点指向后一个节点
¥7. 七种阻塞队列
BlockingQueue 提供了可阻塞的插入和移除的方法。当队列容器已满,生产者线程会被阻塞,直到队列未满;当队列容器为空时,消费者线程会被阻塞,直至队列非空时为止。
JUC包提供了阻塞队列的7种实现:
- ArrayBlockingQueue:底层用数组实现的有界阻塞队列,一旦创建,其容量不能改变。(源码实现借助ReentrantLock的Condition+典型的生产者消费者代码)
- LinkedBlockingQueue:底层用单向链表实现的阻塞队列,可以当做无界队列也可以当做有界队列来使用。如果创建时未指定其容量,则容量等于 Integer.MAX_VALUE
- LinkedBlockingDueue:底层用双向链表实现的阻塞队列
- PriorityBlockingQueue:支持优先级的无界阻塞队列。默认情况下元素采用自然顺序进行排序,也可以元素排序规则。(PriorityQueue 的线程安全阻塞版本)
- SynchronousQueue:队列内部仅允许容纳一个元素,适用于传递性场景。
- LinkedTransferQueue:底层用链表实现的无界阻塞队列。但是有特有的transfer(),tryTransfer()方法。适用于传递性场景
- DelayQueue:支持延时的无界阻塞队列,队内元素到期时才能取出。可用于缓存到期,定时任务等场景。
8. 阻塞队列的各类方法
- 抛异常的方法:add(),remove(),element()
- 返回特殊值的方法:offer(),poll(),peek()
- 阻塞方法:put(),take()
9. 什么是ConcurrentHashMap?
ConcurrentHashMap是Java中的一个线程安全且高效的HashMap实现,其利用了锁分段的思想提高了并发度。
10. SynchronizedMap 和 ConcurrentHashMap 有什么区别?
- SynchronizedMap 一次锁住整张表来保证线程安全,所以每次只能有一个线程来访为 map。
- ConcurrentHashMap 进行写操作时使用分段锁来保证在多线程下的性能。一次锁住一个桶(即ConcurrentHashMap数组中的每个元素)。
- 另外 ConcurrentHashMap 使用了一种不同的迭代方式。在这种迭代方式中,当iterator 被创建后集合再发生改变就不再是抛出ConcurrentModificationException,取而代之的是在改变时 new 新的数据从而不影响原有的数据,iterator 完成后再将头指针替换为新的数据 ,这样 iterator线程可以使用原来老的数据,而写线程也可以并发的完成改变。
¥10. ConcurrentHashMap是如何实现线程安全的?
- 底层数据结构:
- JDK1.7 的 ConcurrentHashMap 底层采用 分段的数组+链表 实现
- JDK1.8 采用的数据结构跟 HashMap1.8 的结构一样,数组+链表/红黑二叉树。
- 实现线程安全的方式:
- 在 JDK1.7 的时候,ConcurrentHashMap(分段锁) 对整个桶数组进行了分割分段(Segment,Segment 继承自 ReentrantLock),每一把锁只锁容器的该分段,多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。
- JDK1.8 的时候已经摒弃了 Segment 的概念,并发控制使用 synchronized 和 CAS 来操作。(JDK1.6 以后 对 synchronized 锁做了很多优化)
- synchronized 只锁定当前链表或红黑二叉树的首节点,这样只要 hash 不冲突,就不会产生并发冲突。
- CAS场景:如初始化时只能有一个线程去进行初始化,此时需要CAS地去修改sizeCtl,修改成功的线程去进行初始化。协助扩容时,需要保证每个线程协助的范围不冲突,需要CAS地分配迁移范围。
JDK1.7:
JDK1.8:
11. ConcurrentHashMap的初始化操作
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
while ((tab = table) == null || tab.length == 0) {
// 如果 sizeCtl < 0 ,说明另外的线程执行CAS 成功,正在进行初始化。
if ((sc = sizeCtl) < 0)
// 让出 CPU 使用权
Thread.yield(); // lost initialization race; just spin
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
try {
if ((tab = table) == null || tab.length == 0) {
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
@SuppressWarnings("unchecked")
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
table = tab = nt;
sc = n - (n >>> 2);
}
} finally {
sizeCtl = sc;
}
break;
}
}
return tab;
}
- ConcurrentHashMap 的初始化是通过自旋和 CAS 操作完成的。为了控制只有一个线程进行初始化,CAS的去修改sizeCtl为-1,成功修改的线程才能进行初始化。
11.5 ConcurrentHashMap的sizeCtl作用,大致说下协助扩容和标志位
- 其中sizeCtl为控制标志符:-1 说明正在初始化,-N 说明有N-1个线程正在进行扩容,>0表示扩容阈值。
- ForwardingNode(代表迁移完成);如果发现某位置是ForwardingNode,说明该部分已被其他线程迁移过,则去重新更新迁移范围
¥12. ConcurrentHashMap的put操作
- 数组未初始化则先初始化
- 根据hash找到对应元素桶的位置
- 如果该位置为空,则CAS将新节点放入
- 如果正在进行扩容,则帮助扩容
- 该位置有元素则用synchronized锁住头节点后进行插入操作
- 插入完成后检查是否需要转换为红黑树
- 让Map的size加1,并检查是否需要扩容
13. ConcurrentHashMap的get操作
- 如果是链表,则遍历获取即可。(注意,该过程无需加锁:因为key,value均用volatile修饰,即使其他线程进行修改也能立即可见)
- 如果是ForwardingNode,表明正在进行迁移,则通过ForwardingNode的newTable属性获取新表,在新表中进行查找
- 如果是红黑树节点,则使用读写锁,通过waiter属性维护当前使用数的线程,防止其他线程进入(树节点需要加锁是因为红黑树在旋转时可能导致根节点被替换,导致线程不安全问题)
¥14. ConcurrentHashMap的扩容机制
- 当插入元素后发现元素个数超过阈值,或把链表转换为红黑树时发现数组长度<64时触发扩容机制
- 与HashMap不同的是,ConcurrentHashMap扩容时采用多线程并发扩容,每个线程负责迁移原数组其中的一部分
具体过程如下:
- 第一个扩容线程创建好新的扩容数组(原数组2倍大小)
- 分配该线程的迁移范围(CAS分配,保证线程安全!)
- 如果发现某位置为空,则在该位置放入ForwardingNode(代表迁移完成);如果发现某位置是ForwardingNode,说明该部分已被其他线程迁移过,则去重新更新迁移范围
- 否则开始迁移该位置处的链表,跟HashMap一样把该链表拆为两条,拆分规则为(hash&n,即第X位是0还是1)
- 在新数组i位置上插入一个链表,i+n位置处插入另一个链表
- 在原数组该位置处插入ForwardingNode,表示该位置已处理过
- 重新去获取下一段迁移范围,开始下一段迁移
- 需要特别注意,其他线程在对ConcurrentHashMap进行其他操作时(如put,remove),如果发现正在进行扩容操作,则会调用helpTransfer方法获取任务帮助迁移,提升扩容的效率
- 而读取线程发现正在扩容,且其读取位置已迁移完成(发现是ForwardingNode),则会去新的table读取值
15. ConcurrentHashMap如何统计size
- 内部维护两个变量baseCount和CounterCell[],CounterCell数组用于分摊累加压力
- 在需要修改size时,会先去CAS更新baseCount,若失败则去CounterCell数组中选一个桶进行CAS更新
- 在统计size时,会将baseCount再去加上CounterCell数组中每个桶的值,最终得到元素数量。
五. JUC包(三)——线程池
¥1. 线程池是什么?为什么需要线程池?
- 线程池是管理一组同构工作线程的资源池,通过重用现有的线程,可以在处理多个请求时分摊线程在创建和撤销过程中的开销。
- 通过调整线程池的大小,可以创建足够多的线程保持处理器处于忙碌状态,同时还可以防止线程过多导致内存资源耗尽
- 另一个好处是当请求到达时工作线程通常已经存在,不会出现等待线程而延迟的任务的执行,提高了响应性。
¥2. 如何创建线程池
- 通过ThreadPoolExecutor自行创建
- 通过 Executor 框架的工具类 Executors 来实现
¥3. Executors提供的各种线程池
- newFixedThreadPool:固定长度的线程池,每当提交一个任务就创建一个线程,直到达到线程池的最大数量,这时线程池的规模将不再变化(如果某个线程由于发生了未预期的异常而结束,那么线程池会补充一个新的线程)。将线程池的核心大小和最大大小都设置为参数中指定的值,创建的线程不会超时,使用LinkedBlockingQueue。(适用于负载较重服务器)
- newCachedThreadPool:线程池的最大大小设置为Integer.MAX_VALUE,而将核心大小设置为0,线程空闲超时设为1分钟,使用SynchronousQueue,这种方法创建出的线程池可被无限扩展,并当需求降低时自动收缩。(适用于负载较轻服务器)
该方法返回一个可根据实际情况调整线程数量的线程池。线程池的线程数量不确定,但若有空闲线程可以复用,则会优先使用可复用的线程。若所有线程均在工作,又有新的任务提交,则会创建新的线程处理任务。所有线程在当前任务执行完毕后,将返回线程池进行复用。
- newSingleThreadExecutor:一个单线程的Executor,创建单个工作者线程来执行任务,如果这个线程异常结束,会创建另一个线程来代替。确保依照任务在队列中的顺序来串行执行。将核心线程和最大线程数都设置为1,使用LinkedBlockingQueue。
- newScheduledThreadPool:以延迟或定时的方式来执行任务的线程池,类似于Timer,使用DelayedWorkQueue。
¥4. 为什么《阿里巴巴Java开发手册》中强制线程池不允许使用 Executors 去创建
《阿里巴巴Java开发手册》规定线程池的创建应通过 ThreadPoolExecutor 的方式
Executors 各个方法的弊端:
-
newFixedThreadPool 和 newSingleThreadExecutor:
允许请求的队列长度为 Integer.MAX_VALUE ,可能堆积大量的请求,从而导致 OOM。 -
newCachedThreadPool 和 newScheduledThreadPool:
允许创建的线程数量为 Integer.MAX_VALUE ,可能会创建大量线程,从而导致 OOM。
¥5. ThreadPoolExecutor构造器参数
- corePoolSize: 线程池核心大小,即在没有任务执行时线程池的大小,并且只有在工作队列满了的情况下才会创建超出这个数量的线程。
- maximumPoolSize: 线程池最大大小,表示可同时活动的线程数量的上限。
- keepAliveTime:存活时间,允许超过核心大小的线程的存活时间
- unit: 存活时间的单位
- workQueue: 线程池所使用的阻塞队列
- threadFactory:线程池使用的创建线程工厂方法。
- handler:所用的拒绝执行处理策略。
¥6. 线程池的四大内置拒绝策略
- AbortPolicy: 线程池默认的拒绝策略,抛出RejectedExecutionException异常。
- DiscardPolicy: 直接抛弃当前的任务。
- DiscardOldestPolicy: 抛弃旧的任务,加入新的任务。
- CallerRunsPolicy: 调用执行自己的线程运行任务,也就是直接在调用execute方法的线程中运行(run)被拒绝的任务,如果执行程序已关闭,则会丢弃该任务。因此这种策略会降低对于新任务提交速度,影响程序的整体性能。如果您的应用程序可以承受此延迟并且你要求任何一个任务请求都要被执行的话,你可以选择这个策略。
¥7. 如何合理地设置线程池的线程数
- 如果设置的线程池数量太小,如果同一时间有大量任务/请求需要处理,可能会导致大量的请求/任务在任务队列中排队等待执行,甚至会出现任务队列满了之后任务/请求无法处理的情况,或者大量任务堆积在任务队列导致 OOM, CPU 根本得到充分利用。
- 如果我们设置线程数量太大,大量线程可能会同时在争取 CPU 资源,这样会导致大量的上下文切换,从而增加线程的执行时间,影响了整体执行效率。
有一个简单并且适用面比较广的公式:
- 对于处理CPU密集型任务,将线程数设置为CPU数量+1
比 CPU 核心数多出来的一个线程是为了防止线程偶发的缺页中断,或者其它原因导致的任务暂停而带来的影响。一旦任务暂停,CPU 就会处于空闲状态,而在这种情况下多出来的一个线程就可以充分利用 CPU 的空闲时间。
- 对于处理IO密集型任务,将线程数设置为CPU数量*2
这种任务应用起来,系统会用大部分的时间来处理 I/O 交互,而线程在处理 I/O 的时间段内不会占用 CPU 来处理,这时就可以将 CPU 交出给其它线程使用。
- 如何判断是 CPU 密集任务还是 IO 密集任务?
CPU 密集型简单理解就是利用 CPU 计算能力的任务比如你在内存中对大量数据进行排序。单凡涉及到网络读取,文件读取这类都是 IO 密集型,这类任务的特点是 CPU 计算耗费时间相比于等待 IO 操作完成的时间来说很少,大部分时间都花在了等待 IO 操作完成上。
8. 线程池都有哪些状态?
- RUNNING:这是最正常的状态,接受新的任务,处理等待队列中的任务。
- SHUTDOWN:不接受新的任务提交,但是会继续处理等待队列中的任务。
- STOP:不接受新的任务提交,不再处理等待队列中的任务,中断正在执行任务的线程。
- TIDYING:所有的任务都销毁了,workCount 为 0,线程池的状态在转换为 TIDYING 状态时,会执行钩子方法 terminated()。
- TERMINATED:线程池关闭后,所有已提交的任务执行完毕,线程池的状态就会变成这个。
¥9. 线程池中 submit() 和 execute() 方法有什么区别?
- 接收参数:execute()只能执行 Runnable 类型的任务。submit()可以执行 Runnable 和 Callable 类型的任务。
- 返回值:submit()方法可以返回持有计算结果的 Future 对象,而execute()没有,且通过这个 Future 对象可以判断任务是否执行成功
¥10. 线程池的工作原理
- 线程池刚创建时,里面没有一个线程。
- 任务通过 execute(Runnable command)方法被添加到线程池,任务就是一个 Runnable类型的对象,任务的执行方法就是Runnable类型对象的run()方法。
- 如果workerCount<corePoolSize,那么创建并启动一个线程执行新提交的任务。
- 如果workerCount>=corePoolSize,且线程池内的阻塞队列未满,那么将这个任务放入队列。
- 如果workerCount>=corePoolSize,且阻塞队列已满,若满足workerCount<maximumPoolSize,那么还是要创建并启动一个线程执行新提交的任务。
- 若阻塞队列已满,并且workerCount>=maximumPoolSize,则根据 handler所指定的策略来处理此任务,默认的处理方式直接抛出异常。
- 当一个线程完成任务时,它会从队列中取下一个任务来执行。
- 当一个线程没有任务可执行,超过一定的时间(keepAliveTime)时,线程池会判断,如果当前运行的线程数大于corePoolSize时,那么这个线程会被停用掉,所以线程池的所有任务完成后,它最终会收缩到corePoolSize的大小。
11. 线程池的关闭(shutdown()和shutdownNow()的区别)
- shutdown方法将执行平缓的关闭过程:不再接受新的任务,同时等待已经提交的任务执行完成——包括那些还未开始执行的任务。
- shutdownNow方法将执行粗暴的关闭过程:先返回任务队列中没有执行的任务,然后尝试取消(中断)所有运行中的任务,并且不再接收新任务。
- 等所有任务都完成后,ExecutorService将转入终止状态。可以调用awaitTermination来等待ExecutorService到达终止状态,或者通过调用isTerminated来轮询ExecutorService是否已终止。通常在调用awaitTermination后会理解调用shutdown,从而产生同步地关闭ExecutorService的效果。
12. isTerminated()和isShutdown()的区别
- isShutDown 当调用 shutdown() 方法后返回为 true。
- isTerminated 当调用 shutdown() 方法后,并且所有提交的任务完成后返回为 true
13. Future和FutureTask
- Future 接口以及 Future 接口的实现类 FutureTask 类都可以代表异步计算的结果。
- 当我们把 Runnable接口 或 Callable 接口 的实现类提交给 ThreadPoolExecutor 或 ScheduledThreadPoolExecutor 执行。调用 submit() 方法时会返回一个 FutureTask 对象
- 主线程可以执行
FutureTask.get()
方法来等待任务执行完成。主线程也可以执行FutureTask.cancel(boolean mayInterruptIfRunning)
来取消此任务的执行。 - 注意:
FutureTask.cancel
本质是尝试去打断执行任务的线程,如果任务已执行完,则cancel失效。cancel之后不能再获取结果。
14. 线程池使用示例
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");
}
}
Callable使用示例:
List<Future<String>> futureList = new ArrayList<>();
Callable<String> callable = new MyCallable();
for (int i = 0; i < 10; i++) {
//提交任务到线程池
Future<String> future = executor.submit(callable);
//将返回值 future 添加到 list,我们可以通过 future 获得 执行 Callable 得到的返回值
futureList.add(future);
}
for (Future<String> fut : futureList) {
try {
System.out.println(new Date() + "::" + fut.get());
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
}
}
15. 手写一个线程池
public class 手写线程池 implements ThreadPool {
private static final int DEFAULT_THREAD_SIZE = 5;
private ArrayList<Worker> workers;
private LinkedBlockingQueue<Runnable> taskQueue;
private int threadSize;
private int taskSize;
public 手写线程池() {
this(DEFAULT_THREAD_SIZE, Integer.MAX_VALUE);
}
public 手写线程池(int threadSize, int taskSize) {
this.threadSize = threadSize;
this.taskSize = taskSize;
init();
}
private void init() {
workers = new ArrayList<>();
taskQueue = new LinkedBlockingQueue<>(taskSize);
for (int i=0; i<threadSize; i++){
Worker worker = new Worker();
worker.start();
workers.add(worker);
}
}
@Override
public void submit(Runnable runnable) throws InterruptedException {
taskQueue.put(runnable);
}
@Override
public void shutDown() {
for (Worker worker: workers){
worker.interrupt();
}
}
private class Worker extends Thread{
@Override
public void run() {
while (!isInterrupted()){
try {
Runnable task = taskQueue.take();
if (task != null){
task.run();
}
} catch (InterruptedException e) {
System.out.println("isInterupted....");
interrupt();
}
}
}
}
public static void main(String[] args) throws InterruptedException {
手写线程池 simpleThreadPool = new 手写线程池();
for (int i=0; i<10; i++){
simpleThreadPool.submit(new Runnable() {
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + "正在执行任务.....");
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
System.out.println("isInterupted....");
Thread.currentThread().interrupt();
}
}
});
}
TimeUnit.SECONDS.sleep(3);
simpleThreadPool.shutDown();
}
}
16. 线程池里的线程是怎么回收的
超过corePoolSize的空闲线程由线程池回收,线程池Worker启动跑第一个任务之后就一直循环遍历线程池任务队列,超过指定超时时间获取不到任务就remove Worker,最后由垃圾回收器回收。
源码层面:
- Worker不断去任务队列里获取Task,如过task为null则会退出循环,移除该线程
- 那什么时候task为null呢,看getTask()方法:①处表示是否允许核心线程超时,或者线程数是否大于核心线程数。(这里说一下一个非常细的点:线程池中如果线程数低于核心线程数,就一定不会回收线程了吗?答案显然不是,allowCoreThreadTimeOut参数不就可以实现回收了么!)
- 如果不是,则用take()方法阻塞获取;如果是,则用poll()去等待获取,如果在规定空闲时间内都没有获取到,则返回null,返回到上面那一步进行线程都回收
16.5 具体回收步骤
可以看到进入回收方法后,先获取全局锁,然后在 workers(一个存放Workers的HashSet)将该多余worker移除完成线程的回收,如果是意外删除的(出现异常),则会重新创建一个线程进行补充
private void processWorkerExit(Worker w, boolean completedAbruptly) {
if (completedAbruptly) // If abrupt, then workerCount wasn't adjusted
decrementWorkerCount();
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
completedTaskCount += w.completedTasks;
workers.remove(w);
} finally {
mainLock.unlock();
}
tryTerminate();
int c = ctl.get();
if (runStateLessThan(c, STOP)) {
if (!completedAbruptly) {
int min = allowCoreThreadTimeOut ? 0 : corePoolSize;
if (min == 0 && ! workQueue.isEmpty())
min = 1;
if (workerCountOf(c) >= min)
return; // replacement not needed
}
addWorker(null, false);
}
}
17. 线程池参数中的线程工厂
可以自己定义线程工厂,实现ThreadFactory接口,并重写newThread (Runnable)方法。从而对线程池中的线程作自定义设置,如设置为守护线程,设置线程名称等
class DaemonThreadFactory implements ThreadFactory {
@Override
public Thread newThread(Runnable r) {
// TODO Auto-generated method stub
Thread t = new Thread(r);
t.setDaemon(true);
return t;
}
}
18. 关于线程池源码的一点理解
- 线程池里的线程被封装在一个Worker内部类里,其中Worker继承自AQS,实现了tryAcquire,tryRelease方法(独占地获取线程的执行权)。
- Worker的构造方法就是用线程工厂创建新的线程
Worker(Runnable firstTask) {
setState(-1); // inhibit interrupts until runWorker
this.firstTask = firstTask;
this.thread = getThreadFactory().newThread(this);
}
- 如果提交任务时发现当前线程数少于核心线程数,则调用addWorker方法增加Worker;
- 增加成功后,t.start()启动Worker中封装的线程,进而调用runWorker方法
- runWorker则是尝试去任务队列中获取任务getTask(),如果没有超过核心线程数,则是以阻塞方法获取;如果超过核心线程数切超过存活时间,则以规定时间的阻塞方法获取
Runnable r = timed ?
workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) :
workQueue.take();
- 在规定时间内获取不到,则返回null,并将多余的线程回收。
19. Worker为什么要继承AQS
在runWorker()方法中,Worker成功获取任务后,会先调用lock方法(其实就是AQS的acquire()方法),任务执行完后,再释放锁。
这么做主要是为了对正在执行任务的线程造成影响。
final void runWorker(Worker w) {
....
//对应构造Worker是的setState(-1)
w.unlock(); // allow interrupts
boolean completedAbruptly = true;
....
w.lock(); //加锁同步
....
try {
...
task.run();
afterExecute(task, null);
} finally {
....
w.unlock(); //释放锁
}
20. 线程池任务队列大小怎么设置
具体情况具体分析
- 如Tomcat核心线程数和最大线程数相同,其线程池队列是无限长度的
- Dubbo 提供3种线程池模型即:FixedThreadPool、CachedThreadPool(客户端默认的)、LimitedThreadPool(服务端默认的),从源码可以看出,其默认的队列长度都是0,当队列长度为0 ,其使用是无缓冲的队列SynchronousQueue,当运行线程超过maximumPoolSize则拒绝请求。
总的来讲,建议采用tomcat的处理方式,core与max一致,先扩容到max再放队列,不过队列长度要根据使用场景设置一个上限值,如果响应时间要求较高的系统可以设置为0。