1、并行跟并发有什么区别?
- 并行是多核 CPU 上的多任务处理,多个任务在同一时间真正地同时执行。
- 并发是单核 CPU 上的多任务处理,多个任务在同一时间段内交替执行,通过时间片轮转实现交替执行,用于解决 IO 密集型任务的瓶颈。
1.1 你是如何理解线程安全的?
如果一个方法或一个代码块被多个线程同时执行,还能正确的处理共享变量的状态,那么这个方法或这个代码块就是线程安全的。可以从原子性、可见性、有序性来保证线程安全。
- 原子性:一个操作要么完全执行,要么完全不执行。可以通过 synchronized 或原子操作类来保证。
- 可见性:当一个线程修改了共享变量,其他线程立即能看到变化。可以通过 volatile 来保证。
- 有序性:确保资源的分配是有序的,不会出现死锁问题。
2、说说进程和线程的区别?
进程是我们在电脑上启动的一个个应用。是操作系统分配资源的最小单位。线程是进程中的独立执行单元。多个线程可以共享同一个进程的资源,如内存;每个线程都有自己独立的栈和寄存器。
2.1 如何理解协程?
协程被视为比线程更轻量级的并发单元,可以在单线程中实现并发执行,由我们开发者显式调度。
2.2 线程间是如何进行通信的?
原则上可以通过消息传递和内存共享两种方法来实现。Java 采用的是内存共享的并发模型。也就是 Java 内存模型,简写为 JMM,共享变量存储在主内存中,每个线程的本地内存存储共享变量的副本。两个线程通信前,线程 A 把本地内存 A 中的共享变量副本刷新到主内存中。随后线程 B 到主内存中读取线程 A 刷新过的共享变量,同步到 B 的共享变量副本中。
3、说说线程有几种创建方式?
有三种,分别是继承 Thread 类、实现 Runnable 接口、实现 Callable 接口。
- 继承 Thread 类:需要重写父类 Thread 的 run() 方法,调用 start() 方法启动线程。存在多继承问题。
- 实现 Runnable 接口:需要重写 Runnable 接口的 run() 方法,然后创建 Thread 对象,参数为 Runnable 对象,最后调用 start() 方法启动线程。避免了多继承问题。
- 实现 Callable 接口:需要重写 Callable 接口的 call() 方法,然后创建 FutureTask 对象,参数为 Callable 对象;接着创建 Thread 对象,参数为 FutureTask 对象,最后调用 start() 方法启动线程。优点是可以获取线程的执行结果。
4、调用 start 方法时会执行 run 方法,那怎么不直接调用 run方法?
调用 start() 会创建一个新的线程,并异步执行 run() 方法中的代码。直接调用 run() 方法是同步方法调用,所有代码都在当前线程中执行,不会创建新线程。没有新线程创建,也达不到多线程并发的目的。
5、线程有哪些常用的调度方法?
- start 方法用于启动线程。
- stop 方法用于停止线程。
- sleep 方法用于让线程休眠。
- wait 方法用于让线程等待。
- notify 会唤醒一个等待的线程。
- yield 方法让线程让出 CPU 使用权,回到就绪态。
- interrupt 方法用于通知线程停止,但不会直接终止线程。
6、线程有几种状态?
6 种。new 代表线程被 new 出来但还没有开始执行;runnable 代表调用了 start 方法后线程处于就绪或运行状态,由操作系统调度;blocked 代表线程被阻塞,等待获取锁;waiting 代表线程正等待其他线程的通知;timed_waiting 代表线程会等待一段时间,超时后自动恢复;terminated 代表线程执行完毕,生命周期结束。
7、什么是线程上下文切换?
线程上下文切换是指 CPU 从一个线程切换到另一个线程执行时的过程。在线程上下文切换的过程中,CPU 需要保存当前线程的执行状态,并加载下一个线程的上下文。之所以要这样,是因为 CPU 在同一时刻只能执行一个线程,为了实现多线程并发执行,需要不断地在多个线程之间切换。CPU 资源的分配采用了时间片轮转的方式。
7.1 线程可以被多核调度吗?
多核处理器提供了并行执行多个线程的能力。每个核心可以独立执行一个或多个线程,操作系统的任务调度器会根据策略和算法,如优先级调度、轮转调度等,决定哪个线程何时在哪个核心上运行。
8、守护线程了解吗?
守护线程是一种特殊的线程,它的作用是为其他线程提供服务。JVM 启动时会调用 main 方法,main 方法所在的线程就是一个用户线程。在 JVM 内部,同时还启动了很多守护线程,比如垃圾回收线程。
8.1 守护线程和用户线程有什么区别?
守护线程是否结束并不影响 JVM 退出,而只要一个用户线程还没结束,JVM 就不会退出。
9、线程间有哪些通信方式?
线程之间传递信息的方式有多种,比如说使用 volatile 和 synchronized 关键字共享对象、使用 wait() 和 notify() 方法实现生产者-消费者模式、使用 Exchanger 进行数据交换、使用 Condition 实现线程间的协调等。
9.1 简单说说 volatile 和 synchronized 的使用方式?
关键字 volatile:用于修饰成员变量,任何对该成员变量的访问都需要从共享内存中获取,并同步刷回共享内存,保证所有线程对共享变量的可见性。
关键字 synchronized:用于修饰方法或代码块,确保多线程环境下同一时刻只有一个线程在执行方法或代码块。
9.2 wait() 和 notify() 方法的使用方式了解吗?
一个线程调用共享对象的 wait() 方法时,它会进入该对象的等待池,释放已经持有的锁,进入等待状态。
一个线程调用共享对象的 notify() 方法时,它会唤醒在该对象等待池中等待的一个线程,使其进入锁池,等待获取锁。
Condition 也提供了类似的方法,await() 负责阻塞、signal() 和 signalAll() 负责通知。
通常与锁 ReentrantLock 一起使用,为线程提供了一种等待某个条件成真的机制,并允许其他线程在该条件变化时通知等待线程。
9.3 Exchanger 的使用方式了解吗?
Exchanger 是一个同步点,可以在两个线程之间交换数据。一个线程调用 exchange() 方法,将数据传递给另一个线程,同时接收另一个线程的数据。
9.4 CompletableFuture 的使用方式了解吗?
CompletableFuture 是 Java 8 引入的一个类,支持异步编程,允许线程在完成计算后将结果传递给其他线程。
10、请说说 sleep 和 wait 的区别?
sleep 会让当前线程休眠,不需要获取对象锁,属于 Thread 类的方法;wait 会让提前获得对象锁的线程等待,调用后释放锁,属于 Object 类的方法。
- 所属类不同:sleep() 方法属于 Thread 类,wait() 方法属于 Object 类。
- 锁行为不同:如果一个线程在持有某个对象锁时调用了 sleep() 方法,它在睡眠期间仍然会持有这个锁。而线程执行 wait() 方法时,它会释放持有的对象锁,因此其他线程也有机会获取该对象的锁。
- 使用条件不同:sleep() 方法可以在任何地方被调用。wait() 方法必须在同步代码块或同步方法中被调用,这是因为调用 wait() 方法的前提是当前线程必须持有对象的锁。
- 唤醒方式不同:调用 sleep 方法后,线程会进入 TIMED_WAITING 状态,即在指定的时间内暂停执行。而调用 wait 方法后,线程会进入 WAITING 状态,直到有其他线程在同一对象上调用 notify 或 notifyAll 方法。
11、怎么保证线程安全?
线程安全是指在并发环境下,多个线程访问共享资源时,程序能够正确地执行,而不会出现数据不一致的问题。为了保证线程安全,可以使用 synchronized 关键字对方法加锁,对代码块加锁。线程在执行同步方法、同步代码块时,会获取类锁或者对象锁,其他线程就会阻塞并等待锁。
- 如果需要更细粒度的锁,可以使用 ReentrantLock 并发重入锁等。
- 如果需要保证变量的内存可见性,可以使用 volatile 关键字。
- 对于简单的原子变量操作,还可以使用 Atomic 原子类。
- 对于线程独立的数据,可以使用 ThreadLocal 来为每个线程提供专属的变量副本。
- 对于需要并发容器的地方,可以使用 ConcurrentHashMap、CopyOnWriteArrayList 等。
11.1 有个int的变量为0,十个线程轮流对其进行 ++ 操作(循环10000次),结果大于 10 万还是小于等于 10 万,为什么?
在这个场景中,最终的结果会小于 100000,原因是多线程环境下,++ 操作并不是一个原子操作,而是分为三步:
- 读取到变量的值。
- 将读取到的值加 1。
- 将结果写回变量。
这样的话,就会有多个线程读取到相同的值,然后对这个值进行加 1 操作,最终导致结果小于 100000。可以通过 synchronized 关键字为 ++ 操作加锁。或者使用 AtomicInteger 的 incrementAndGet() 方法来替代 ++ 操作,保证变量的原子性。
11.2 场景:有一个 key 对应的 value 是一个json 结构,json 当中有好几个子任务,这些子任务如果对 key 进行修改的话,会不会存在线程安全的问题?
会,在单节点环境中,可以使用 synchronized 关键字或 ReentrantLock 来保证对 key 的修改操作是原子的。在多节点环境中,可以使用分布式锁 Redisson 来保证对 key 的修改操作是原子的。
11.3 说一个线程安全的使用场景?
单例模式。在多线程环境下,如果多个线程同时尝试创建实例,单例类确保只创建一个实例,并提供全局访问点。
12、能说一下 Hashtable 的底层数据结构吗?
与 HashMap 类似,Hashtable 的底层数据结构也是一个数组加上链表的方式,然后通过 synchronized 加锁来保证线程安全。
13、ThreadLocal 是什么?
ThreadLocal 是一种用于实现线程局部变量的工具类。它允许每个线程都拥有自己的独立副本,从而实现线程隔离。
使用 ThreadLocal 通常分为四步:创建、设置值、获取值和删除值。在 Web 应用中,可以使用 ThreadLocal 存储用户会话信息,这样每个线程在处理用户请求时都能方便地访问当前用户的会话信息。
13.1 ThreadLocal 有哪些优点?
每个线程访问的变量副本都是独立的,避免了共享变量引起的线程安全问题。
14、ThreadLocal 怎么实现的呢?
当我们创建一个 ThreadLocal 对象并调用 set 方法时,其实是在当前线程中初始化了一个 ThreadLocalMap。ThreadLocalMap 是 ThreadLocal 的一个静态内部类,它内部维护了一个 Entry 数组,key 是 ThreadLocal 对象,value 是线程的局部变量,这样就相当于为每个线程维护了一个变量副本。Entry 继承了 WeakReference,它限定了 key 是一个弱引用,弱引用的好处是当内存不足时,JVM 会回收 ThreadLocal 对象,并且将其对应的 Entry.value 设置为 null,这样可以在很大程度上避免内存泄漏。
15、ThreadLocal 内存泄露是怎么回事?
ThreadLocalMap 的 Key 是 弱引用,但 Value 是强引用。如果一个线程一直在运行,并且 value 一直指向某个强引用对象,那么这个对象就不会被回收,从而导致内存泄漏。解决内存泄漏问题很简单,使用完 ThreadLocal 后,及时调用 remove() 方法释放内存空间,remove() 方法会将当前线程的 ThreadLocalMap 中的所有 key 为 null 的 Entry 全部清除,这样就能避免内存泄漏问题。总结一下,在 ThreadLocal 被垃圾收集后,下一次访问 ThreadLocalMap 时,Java 会自动清理那些键为 null 的 entry,这个过程会在执行 get()、set()、remove()时触发。
15.1 你了解哪些 ThreadLocal 的改进方案?
阿里的 TransmittableThreadLocal,不仅实现了子线程可以继承父线程 ThreadLocal 的功能,并且还可以跨线程池传递值。
16、ThreadLocalMap 的源码看过吗?
有研究过,ThreadLocalMap 虽然被叫做 Map,但它并没有实现 Map 接口,是一个简单的线性探测哈希表。
static class ThreadLocalMap {
static class Entry extends WeakReference<ThreadLocal<?>> {
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k); // 这里的 Key 是 WeakReference
value = v;
}
}
private Entry[] table; // 存储 ThreadLocal 变量的数组
private int size; // 当前 Entry 数量
private int threshold; // 触发扩容的阈值
}
底层的数据结构也是数组,数组中的每个元素是一个 Entry 对象,Entry 对象继承了 WeakReference,key 是 ThreadLocal 对象,value 是线程局部变量。
17、ThreadLocalMap 怎么解决 Hash 冲突的?
开放寻址法。如果计算得到的槽位 i 已经被占用,ThreadLocalMap 会采用开放地址法中的线性探测来寻找下一个空闲槽位:如果 i 位置被占用,尝试 i+1。如果 i+1 也被占用,继续探测 i+2,直到找到一个空位。如果到达数组末尾,则回到数组头部,继续寻找空位。
17.1 为什么要用线性探测法而不是 HashMap 的拉链法来解决哈希冲突?
ThreadLocalMap 设计的目的是存储线程私有数据,不会有大量的 Key,所以采用线性探测更节省空间。链地址法还需要单独维护一个链表,甚至红黑树,不适合 ThreadLocal 这种场景。
18、ThreadLocalMap 扩容机制了解吗?
与 HashMap 不同,ThreadLocalMap 并不会直接在元素数量达到阈值(默认:2/3 数组长度)时立即扩容,而是先清理被 GC 回收的 key,然后在填充率达到 3/4 时进行扩容。清理过程会遍历整个数组,将 key 为 null 的 Entry 清除。扩容时,会将数组长度翻倍,然后重新计算每个 Entry 的位置,采用线性探测法来寻找新的空位,然后将 Entry 放入新的数组中。
19、父线程能用 ThreadLocal 给子线程传值吗?
不能。因为 ThreadLocal 变量存储在每个线程的 ThreadLocalMap 中,而子线程不会继承父线程的 ThreadLocalMap。可以使用 InheritableThreadLocal 来解决这个问题。
19.1 InheritableThreadLocal的原理了解吗?
在 Thread 类的定义中,每个线程都有两个 ThreadLocalMap:
普通 ThreadLocal 变量存储在 threadLocals 中,不会被子线程继承。
InheritableThreadLocal 变量存储在 inheritableThreadLocals 中,当 new Thread() 创建一个子线程时,Thread 的 init() 方法会检查父线程是否有 inheritableThreadLocals,如果有,就会拷贝 InheritableThreadLocal 变量到子线程:
20、说一下你对 Java 内存模型的理解?
Java 内存模型是 Java 虚拟机规范中定义的一个抽象模型,描述了多线程环境中共享变量的内存可见性。
每个线程都有自己的共享变量副本,可以避免多个线程同时修改共享变量导致的数据冲突。
21、说说什么是指令重排?
指令重排是指 CPU 或编译器为了提高程序的执行效率,改变代码执行顺序的一种优化技术。从 Java 源代码到最终执行的指令序列,会经历 3 种重排序:编译器重排序、指令并行重排序、内存系统重排序。
22、happens-before 了解吗?
Happens-Before 是 Java 内存模型定义的一种保证线程间可见性和有序性的规则。
如果操作 A Happens-Before 操作 B,那么:
- 操作 A 的结果对操作 B 可见。
- 操作 A 在时间上先于操作 B 执行。
换句话说,如果 A Happens-Before B,那么 A 的修改必须对 B 可见,并且 B 不能重排序到 A 之前。
22.1 你知道哪些 Happens-Before 规则?
- 程序顺序规则:单线程内,代码按顺序执行。
- 监视器锁定规则:比如 synchronized 释放锁后,获取锁的线程能够看到最新的数据。
- volatile 变量规则:写 volatile 变量 Happens-Before 读 volatile。
- 传递性规则:A Happens-Before B 且 B Happens-Before C,则 A Happens-Before C。
- 线程启动规则:线程 A 执行操作 ThreadB.start(),该线程启动操作 happens-before 于线程 B 中的任意操作。
- 线程终止规则:线程的所有操作 Happens-Before Thread.join()。
23、as-if-serial 了解吗?
As-If-Serial 规则允许 CPU 和编译器优化代码顺序,但不会改变单线程的执行结果。它只适用于单线程,多线程环境仍然可能发生指令重排,需要 volatile 和 synchronized 等机制来保证有序性。
24、volatile 了解吗?
有两个作用。第一,保证可见性,线程修改 volatile 变量后,其他线程能够立即看到最新值;第二,防止指令重排,volatile 变量的写入不会被重排序到它之前的代码。
24.1 volatile 怎么保证可见性的?
当线程对 volatile 变量进行写操作时,JVM 会在这个变量写入之后插入一个写屏障指令,这个指令会强制将本地内存中的变量值刷新到主内存中。
当线程对 volatile 变量进行读操作时,JVM 会插入一个读屏障指令,这个指令会强制让本地内存中的变量值失效,从而重新从主内存中读取最新的值。
24.1 volatile 怎么保证有序性的?
确保资源的分配是有序的,不会出现死锁问题。可以通过 volatile、synchronized 来保证。
24.2 volatile 和 synchronized 的区别?
作用域。
24.3 volatile 加在基本类型和对象上的区别?
- 用于基本数据类型时,确保对变量的操作直接作用于共享内存。
- 用于引用类型时,确保对该引用指向的对象地址是最新的。但不能保证对象内部状态的线程安全,需要使用 synchronized 或 ReentrantLock 等锁机制。
25、synchronized 用过吗?
用过,频率还很高。synchronized 在 JDK 1.6 之后,进行了锁优化,增加了偏向锁、轻量级锁,大大提升了 synchronized 的性能。
25.1 synchronized 上锁的对象是什么?
- synchronized 用在普通方法上时,上锁的是执行这个方法的对象。
- synchronized 用在静态方法上时,上锁的是这个类的 Class 对象。
- synchronized 用在代码块上时,上锁的是括号中指定的对象,比如说当前对象 this。
26、synchronized 的实现原理了解吗?
synchronized 依赖 JVM 内部的 Monitor 对象来实现线程同步。使用的时候不用手动去 lock 和 unlock,JVM 会自动加锁和解锁。synchronized 加锁代码块时,JVM 会通过 monitorenter、monitorexit 两个指令来实现同步:前者表示线程正在尝试获取 lock 对象的 Monitor;后者表示线程执行完了同步代码块,正在释放锁。
26.1 你对 Monitor 了解多少?
Monitor 是 JVM 内置的同步机制,每个对象在内存中都有一个对象头:Mark Word,用于存储锁的状态,以及 Monitor 对象的指针。synchronized 依赖对象头的 Mark Word 进行状态管理,支持无锁、偏向锁、轻量级锁,以及重量级锁。
26.2 会不会牵扯到 os 层面呢?
会,synchronized 升级为重量级锁时,依赖于操作系统的互斥量——mutex 来实现,mutex 用于保证任何给定时间内,只有一个线程可以执行某一段特定的代码段。
27、synchronized 怎么保证可见性?
通过在上锁期间使线程的方法或代码块的操作都作用于共享内存来保证可见性。
27.1 synchronized 怎么保证有序性?
通过 JVM 指令 monitorenter 和 monitorexit,来禁止加锁代码块的指令重排,保证了有序性。
27.2 synchronized 怎么实现可重入的呢?
可重入意味着同一个线程可以多次获得同一个锁,而不会被阻塞。synchronized 之所以支持可重入,是因为 Java 的对象头包含了一个 Mark Word,用于存储对象的状态,包括锁信息。
28、synchronized 锁升级了解吗?
JDK 1.6 的时候,为了提升 synchronized 的性能,引入了锁升级机制,从低开销的锁逐步升级到高开销的锁,以最大程度减少锁的竞争。没有线程竞争时,就使用低开销的“偏向锁”,此时没有额外的 CAS 操作;轻度竞争时,使用轻量级锁,采用 CAS 自旋,避免线程阻塞;只有在重度竞争时,才使用重量级锁,由 Monitor 机制实现,需要线程阻塞。
28.1 了解 synchronized 四种锁状态吗?
- 无锁状态,对象未被锁定,Mark Word 存储对象的哈希码等信息。
- 偏向锁,当线程第一次获取锁时,会进入偏向模式。Mark Word 会记录线程 ID,后续同一线程再次获取锁时,可以直接进入 synchronized 加锁的代码,无需额外加锁。
- 轻量级锁,多个线程在不同时段获取同一把锁,即不存在锁竞争的情况时,JVM 会采用轻量级锁来避免线程阻塞。未持有锁的线程通过CAS 自旋等待锁释放。
- 重量级锁,如果自旋超过一定的次数,或者一个线程持有锁,一个自旋,又有第三个线程进入 synchronized 加锁的代码时,轻量级锁就会升级为重量级锁。此时,对象头的锁类型会更新为“10”,Mark Word 会存储指向 Monitor 对象的指针,其他等待锁的线程都会进入阻塞状态。
28.2 synchronized 做了哪些优化?
- 偏向锁:同一个线程可以多次获取同一把锁,无需重复加锁。
- 轻量级锁:当没有线程竞争时,通过 CAS 自旋等待锁,避免直接进入阻塞。
- 锁消除:JIT 可以在运行时进行代码分析,如果发现某些锁操作不可能被多个线程同时访问,就会对这些锁进行消除,从而减少上锁开销。
28.3 请详细说说锁升级的过程?
当一个线程首次访问带有 synchronized 关键字的同步代码时,如果 Mark Word 中的锁信息为 01 表明该对象处于无锁状态,JVM 会将 Mark Word 修改为 10 偏向锁状态并记录线程 ID。如果另一个线程尝试获取偏向锁,JVM 会检查持有偏向锁的线程是否活跃,不活跃的话可以将锁偏向给新的线程,活跃的话撤销偏向锁,将 Mark Word 修改为 00 轻量锁状态。轻量级锁通过自旋等待锁释放,如果 JVM 发现自旋超过设定次数表明竞争激烈,会将 Mark Word 修改为 10 重量级锁,创建一个操作系统层面的互斥锁 mutex,所有尝试获取重量级锁的线程都会被阻塞。
29、synchronized 和 ReentrantLock 的区别了解吗?
synchronized 由 JVM 内部的 Monitor 机制实现,ReentrantLock基于 AQS 实现。synchronized 可以自动加锁和解锁,ReentrantLock 需要手动 lock() 和 unlock()。
- ReentrantLock 可以实现多路选择通知,绑定多个 Condition,而 synchronized 只能通过 wait 和 notify 唤醒,属于单路通知。
- synchronized 可以在方法和代码块上加锁,ReentrantLock 只能在代码块上加锁,但可以指定是公平或非公平锁。
- ReentrantLock 提供了一种能够中断等待锁的线程机制,通过 lock.lockInterruptibly() 来实现。
29.1 并发量大的情况下,使用 synchronized 还是 ReentrantLock?
我更倾向于 ReentrantLock,因为:
- ReentrantLock 提供了超时和公平锁等特性,可以应对更复杂的并发场景。
- ReentrantLock 允许更细粒度的锁控制,能有效减少锁竞争。
- ReentrantLock 支持条件变量 Condition,可以实现比 synchronized 更友好的线程间通信机制。
29.2 Lock 了解吗?
Lock 是 JUC 中的一个接口,最常用的实现类包括可重入锁 ReentrantLock、读写锁 ReentrantReadWriteLock 等。
29.3 ReentrantLock 的 lock() 方法实现逻辑了解吗?
lock 方法的具体实现由 ReentrantLock 内部的 Sync 类来实现,涉及到线程的自旋、阻塞队列、CAS、AQS 等。lock 方法会首先尝试通过 CAS 来获取锁。如果当前锁没有被持有,会将锁状态设置为 1,表示锁已被占用。否则,会将当前线程加入到 AQS 的等待队列中。
30、AQS 了解多少?
AQS 是一个抽象类,它维护了一个共享变量 state 和一个线程等待队列,为 ReentrantLock 等类提供底层支持。
AQS 的思想是,如果被请求的共享资源处于空闲状态,则当前线程成功获取锁;否则,将当前线程加入到等待队列中,当其他线程释放锁时,从等待队列中挑选一个线程,把锁分配给它。
30.1 AQS 的源码阅读过吗?
有研究过。AQS 使用一个 CLH 队列来维护等待线程,是一种基于链表的自旋锁。状态 state 由 volatile 变量修饰,用于保证多线程之间的可见性。同步队列由内部定义的 Node 类实现,也就是 CLH 队列,每个 Node 包含了等待状态、前后节点、线程的引用等,是一个先进先出的双向链表。在 CLH 中,当一个线程尝试获取锁失败后,会被添加到队列的尾部并自旋,等待前一个节点的线程释放锁。CLH 的优点是,假设有 100 个线程在等待锁,锁释放之后,只会通知队列中的第一个线程去竞争锁。避免同时唤醒大量线程浪费 CPU 资源。
31、说说 ReentrantLock 的实现原理?
可重入锁,其底层实现是 AQS,通过 AQS 中 volatile 修饰的计数器成员变量 state 跟踪锁状态。当线程调用 lock() 方法获取锁时,ReentrantLock 会检查 state,值为 0 标识锁未被任何线程持有,则通过 CAS 修改为 1,表示当前线程的重入次数为 1,否则将当前线程加入等待队列的尾部。当获取线程再次调用 lock() 方法时,state 加 1;当线程调用 unlock() 方法时,state 减 1,当 state = 0 时,释放锁,唤醒等待队列中的线程来竞争锁。非公平锁策略下所有线程直接尝试 CAS 获取锁,公平锁策略下所有线程会首先检查队列中是否有前驱线程,没有再尝试获取锁。new ReentrantLock() 默认创建的是吞吐量更高的非公平锁,但是存在饥饿问题。
32、ReentrantLock 怎么创建公平锁?
很简单,创建 ReentrantLock 的时候,传递参数 true 就可以了。
ReentrantLock lock = new ReentrantLock(true);
// true 代表公平锁,false 代表非公平锁
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
32.1 非公平锁和公平锁有什么不同?
公平锁意味着在多个线程竞争锁时,获取锁的顺序与线程请求锁的顺序相同,即先来先服务。非公平锁不保证线程获取锁的顺序,当锁被释放时,任何请求锁的线程都有机会获取锁,而不是按照请求的顺序。
32.2 公平锁的实现逻辑了解吗?
公平锁的核心逻辑在 AQS 的 hasQueuedPredecessors() 方法中,该方法用于判断当前线程前面是否有等待的线程。如果有等待线程,当前线程就不能抢占锁,必须按照队列顺序排队。如果队列前面没有线程,或者当前线程是队列头部的线程,就可以获取锁。
33、CAS 了解多少?
CAS 是一种乐观锁,用于比较一个变量的当前值是否等于预期值,如果相等,则更新值,否则重试。
在 CAS 中,有三个值:
V:内存值。
E:期待值。
N:新值。
先判断 V 是否等于 E,如果等于,将 V 的值设置为 N;如果不等,说明已经有其它线程更新了 V,当前线程就放弃更新。这个比较和替换的操作需要是原子的,不可中断的。AtomicInteger 类的 compareAndSet 就是一个 CAS 方法。
33.1 怎么保证 CAS 的原子性?
CPU 会发出一个 LOCK 指令进行总线锁定,阻止其他处理器对内存地址进行操作,直到当前指令执行完成。
34、CAS 有什么问题?
CAS 存在三个经典问题,ABA 问题、自旋开销大、只能操作一个变量等。
- ABA 问题:指的是,一个值原来是 A,后来被改为 B,之后又被改回 A,这时 CAS 会误认为这个值没有发生变化。可以使用版本号/时间戳的方式来解决 ABA 问题。比如说,每次变量更新时,不仅更新变量的值,还更新一个版本号。CAS 操作时,不仅比较变量的值,还比较版本号。
- 自旋开销大:CAS 失败时会不断自旋重试,如果一直不成功,会给 CPU 带来非常大的执行开销。可以加一个自旋次数的限制,超过一定次数,就切换到 synchronized 挂起线程。
- 只能操作一个变量:可以将多个变量封装为一个对象,使用 AtomicReference 进行 CAS 更新。
35、Java 有哪些保证原子性的方法?
36、原子操作类了解多少?
原子操作类是基于 CAS + volatile 实现的,底层依赖于 Unsafe 类,最常用的有 AtomicInteger、AtomicLong、AtomicReference 等。
像 AtomicIntegerArray 这种以 Array 结尾的,还可以原子更新数组里的元素。
像 AtomicStampedReference 还可以通过版本号的方式解决 CAS 中的 ABA 问题。
37、AtomicInteger 的源码读过吗?
有读过。AtomicInteger 是基于 volatile 和 CAS 实现的,底层依赖于 Unsafe 类。核心方法包括 getAndIncrement、compareAndSet 等。
38、线程死锁了解吗?
死锁发生在多个线程相互等待对方释放锁时。比如说线程 1 持有锁 R1,等待锁 R2;线程 2 持有锁 R2,等待锁 R1。
38.1 死锁发生的四个条件了解吗?
- 互斥:资源不能被多个线程共享,一次只能由一个线程使用。
- 持有并等待:一个线程已经持有一个资源,并且在等待获取其他线程持有的资源。
- 不可抢占:资源不能被强制从线程中夺走,必须等线程自己释放。
- 循环等待:存在一种线程等待链,线程 A 等待线程 B 持有的资源,线程 B 等待线程 C 持有的资源等等。
38.2 该如何避免死锁呢?
- 所有线程都按照固定的顺序来申请资源。例如,先申请 R1 再申请 R2。
- 如果线程发现无法获取某个资源,可以先释放已经持有的资源,重新尝试申请。
39、死锁问题怎么排查呢?
首先从系统级别上排查,比如说在 Linux 生产环境中,可以先使用 top ps 等命令查看进程状态,看看是否有进程占用了过多的资源。接着,使用 JDK 自带的一些性能监控工具进行排查,比如说 使用 jps -l 查看当前进程,然后使用 jstack 进程号 查看当前进程的线程堆栈信息,看看是否有线程在等待锁资源。也可以用一些可视化的性能监控工具,比如说 JConsole、VisualVM 等,查看线程的运行状态、锁的竞争情况等。
40、聊聊线程同步和互斥?(补充)
- 同步,意味着线程之间要密切合作,按照一定的顺序来执行任务。比如说,线程 A 先执行,线程 B 再执行。
- 互斥,意味着线程之间要抢占资源,同一时间只能有一个线程访问共享资源。比如说,线程 A 在访问共享资源时,线程 B 不能访问。
同步关注的是线程之间的协作,互斥关注的是线程之间的竞争。
40.1 如何实现同步和互斥?
可以使用 synchronized 关键字或者 Lock 接口的实现类,如 ReentrantLock 来给资源加锁。锁在操作系统层面的意思是 Mutex,某个线程进入临界区后,也就是获取到锁后,其他线程不能再进入临界区,要阻塞等待持有锁的线程离开临界区。
40.2 锁要解决哪些问题?
- 谁能拿到锁,可以是类对象,可以是当前的 this 对象,也可以是任何其他新建的对象。
- 抢占锁的规则,能不能抢占多次,自己能不能反复抢。
- 抢不到怎么办,自旋?阻塞?或者超时放弃?
- 锁被释放了还在等待锁的线程怎么办?是通知所有线程一起抢或者只告诉一个线程抢?
40.3 说说自旋锁?
自旋锁是指线程尝试获取锁时,如果锁已经被占用,线程不会立即阻塞,而是通过自旋,也就是循环等待的方式不断尝试获取锁。适用于锁持有时间短的场景,ReentrantLock 的 tryLock 方法就用到了自旋锁。自旋锁的优点是可以避免线程切换带来的开销,缺点是如果锁被占用时间过长,会导致线程空转,浪费 CPU 资源。默认情况下,自旋锁会一直等待,直到获取到锁为止。在实际开发中,需要设置自旋次数或者超时时间。如果超过阈值,线程可以放弃锁或进入阻塞状态。
40.4 互斥和同步在时间上有要求吗?
有要求,互斥的核心是保证同一时刻只有一个线程能访问共享资源。同步强调的是线程之间的执行顺序,特别是在多个线程需要依赖于彼此的执行结果时。
41、聊聊悲观锁和乐观锁?
- 悲观锁认为每次访问共享资源时都会发生冲突,所以在操作前先加锁,防止其他线程修改数据。
- 乐观锁认为每次访问共享资源时不会发生冲突,所以在操作前不加锁,而是在更新数据时检查是否有其他线程修改了数据。如果发现数据被修改了,就会重试,直到成功为止或达到最大重试次数。
42、CountDownLatch 了解吗?
CountDownLatch 是 JUC 中的一个同步工具类,用于协调多个线程之间的同步,确保主线程在多个子线程完成任务后继续执行。它的核心思想是通过一个倒计时计数器来控制多个线程的执行顺序。
42.1 场景题:假如要查10万条数据,用线程池分成20个线程去执行,怎么做到等所有的线程都查找完之后,即最后一条结果查找结束了,才输出结果?
很简单,可以使用 CountDownLatch 来实现。CountDownLatch 非常适合这个场景。
- 创建 CountDownLatch 对象,初始值设定为 20,表示 20 个线程需要完成任务。
- 创建线程池,每个线程执行查询操作,查询完毕后调用 countDown() 方法,计数器减 1。
- 主线程调用 await() 方法,等待所有线程执行完毕。
43、CyclicBarrier 了解吗?
CyclicBarrier 的字面意思是可循环使用的屏障,用于多个线程相互等待,直到所有线程都到达屏障后再同时执行。
44、CyclicBarrier 和 CountDownLatch 有什么区别?
CyclicBarrier 让所有线程相互等待,全部到达后再继续;CountDownLatch 让主线程等待所有子线程执行完再继续。
45、Semaphore 了解吗?
Semaphore——信号量,用于控制同时访问某个资源的线程数量,类似限流器,确保最多只有指定数量的线程能访问某个资源,超过的必须等待。在使用 Semaphore 时,首先需要初始化一个 Semaphore 对象,指定许可证数量,表示最多允许多少个线程同时访问资源。然后在每个线程访问资源前,调用 acquire() 方法获取许可证,如果没有可用许可证,则阻塞等待。需要注意的是,访问完资源后,要调用 release() 方法释放许可证。
Semaphore 可以用于流量控制,比如数据库连接池、网络连接池等。假如有一个需求要读取几万个文件的数据,因为都是 IO 密集型任务,我们可以启动几十个线程并发地读取。但是在读到内存后,需要存储到数据库,而数据库连接数是有限的,比如说只有 10 个,那我们就必须控制线程的数量,保证同时只有 10 个线程在使用数据库连接。这时候,就可以使用 Semaphore 来做流量控制。
46、Exchanger 了解吗?
Exchanger——交换者,用于在两个线程之间进行数据交换。支持双向数据交换,比如说线程 A 调用 exchange(dataA),线程 B 调用 exchange(dataB),那么 AB 会在同步点交换数据,即 A 得到 B 的数据,B 得到 A 的数据。如果一个线程先调用 exchange(),它会阻塞等待,直到另一个线程也调用 exchange()。使用 Exchanger 的时候,需要先创建一个 Exchanger 对象,然后在两个线程中调用 exchange() 方法,就可以进行数据交换了。Exchanger 可以用于遗传算法,也可以用于校对工作,比如我们将纸制银行流水通过人工的方式录入到银行时,为了避免错误,可以录入两遍,然后通过 Exchanger 来校验两次录入的结果。
47、能说一下 ConcurrentHashMap 的实现吗?
好的。ConcurrentHashMap 是 HashMap 的线程安全版本。JDK 7 采用的是分段锁,整个 Map 会被分为若干段,每个段都可以独立加锁。不同的线程可以同时操作不同的段,从而实现并发。JDK 8 使用了更加细粒度的锁——桶锁,再配合 CAS + synchronized 代码块控制并发写入,以最大程度减少锁的竞争。读操作中,ConcurrentHashMap 使用了 volatile 变量来保证内存可见性。写操作中,ConcurrentHashMap 优先使用 CAS 尝试插入,如果成功就直接返回;否则使用 synchronized 代码块进行加锁处理。
47.1 说一下 JDK 7 中 ConcurrentHashMap 的实现原理?
JDK 7 的 ConcurrentHashMap 采用的是分段锁,整个 Map 会被分为若干段,每个段都可以独立加锁,每个段类似一个 Hashtable。
每个段维护一个键值对数组 HashEntry<K, V>[] table,HashEntry 是一个单项链表。段继承了 ReentrantLock,所以每个段都是一个可重入锁,不同的线程可以同时操作不同的段,从而实现并发。
47.2 说一下 JDK 7 中 ConcurrentHashMap 的 put 流程?
put 流程和 HashMap 非常类似,只不过是先定位到具体的段,再通过 ReentrantLock 去操作。可以分为 4 个步骤:
- 计算 key 的 hash,定位到段,段如果是空就先初始化。
- 使用 ReentrantLock 进行加锁,如果加锁失败就自旋,自旋超过次数就阻塞,保证一定能获取到锁。
- 遍历段中的键值对 HashEntry,key 相同直接替换,key 不存在就插入。
- 释放锁。
47.3 说一下 JDK 7 中 ConcurrentHashMap 的 get 流程?
- 首先计算键的哈希值找到段,再遍历段中的键值对,key 相同就直接返回 value。
- get 不用加锁,因为是 value 是 volatile 的,所以线程读取 value 时不会出现可见性问题。
47.4 说一下 JDK 8 中 ConcurrentHashMap 的实现原理?
JDK 8 中的 ConcurrentHashMap 取消了分段锁,采用 CAS + synchronized 来实现更细粒度的桶锁,并且使用红黑树来优化链表以提高哈希冲突时的查询效率,性能比 JDK 7 有了很大的提升。
47.5 说一下 JDK 8 中 ConcurrentHashMap 的 put 流程?
- 计算 key 的哈希值,以确定桶在数组中的位置。如果数组为空,采用 CAS 的方式初始化,以确保只有一个线程在初始化数组。
- 如果桶为空,直接 CAS 插入节点。如果 CAS 操作失败,会退化为 synchronized 代码块来插入节点。插入的过程中会判断桶的哈希是否小于 0,小于 0 说明是红黑树,大于等于 0 说明是链表。
- 如果链表长度超过 8,转换为红黑树。
- 在插入新节点后,会调用 addCount() 方法检查是否需要扩容。
47.6 说一下 JDK 8 中 ConcurrentHashMap 的 get 流程?
get 也是通过 key 的哈希值进行定位,如果该位置节点的哈希匹配且 key 相同,则直接返回值。如果节点的哈希为负数,说明是个特殊节点,比如说如树节点或者正在迁移的节点,就调用 find 方法查找。否则,遍历链表查找匹配的键。如果都没找到,返回 null。
47.7 说一下 HashMap 和 ConcurrentHashMap 的区别?
HashMap 是非线程安全的,多线程环境下应该使用 ConcurrentHashMap。
47.8 你项目中怎么使用 ConcurrentHashMap 的?
47.9 说一下 ConcurrentHashMap 对 HashMap 的改进?
首先是 hash 的计算方法上,ConcurrentHashMap 的 spread 方法接收一个已经计算好的 hashCode,然后将这个哈希码的高 16 位与自身进行异或运算。比 HashMap 的 hash 计算多了一个 & HASH_BITS 的操作。这里的 HASH_BITS 是一个常数,值为 0x7fffffff,从而确保结果是一个非负整数。另外,ConcurrentHashMap 对节点 Node 做了进一步的封装,比如说用 Forwarding Node 来表示正在进行扩容的节点。最后就是 put 方法,通过 CAS + synchronized 代码块来进行并发写入。
47.10 为什么 ConcurrentHashMap 在 JDK 1.7 中要用 ReentrantLock,而在 JDK 1.8 要用 synchronized?
JDK 1.7 中的 ConcurrentHashMap 使用了分段锁机制,每个 Segment 都继承了 ReentrantLock,这样可以保证每个 Segment 都可以独立地加锁。
而在 JDK 1.8 中,ConcurrentHashMap 取消了 Segment 分段锁,采用了更精细的锁——桶锁,以及 CAS 无锁算法,每个桶都可以独立地加锁,只有在 CAS 失败时才会使用 synchronized 代码块加锁,这样可以减少锁的竞争,提高并发性能。
48、ConcurrentHashMap 怎么保证可见性?
ConcurrentHashMap 中的 Node 节点中,value 和 next 都是 volatile 的,这样就可以保证对 value 或 next 的更新会被其他线程立即看到。
49、为什么 ConcurrentHashMap 比 Hashtable 效率高?
Hashtable 在任何时刻只允许一个线程访问整个 Map,是通过对整个 Map 加锁来实现线程安全的。比如:get 和 put 方法,是直接在方法上加的 synchronized 关键字。而 ConcurrentHashMap 在 JDK 8 中是采用 CAS + synchronized 实现的,仅在必要时加锁。比如说 put 的时候优先使用 CAS 尝试插入,如果失败再使用 synchronized 代码块加锁。get 的时候是完全无锁的,因为 value 是 volatile 变量修饰的,保证了内存可见性。
50、能说一下 CopyOnWriteArrayList 的实现原理吗?
CopyOnWriteArrayList 是 ArrayList 的线程安全版本,适用于读多写少的场景。它的核心思想是写操作时创建一个新数组,修改后再替换原数组,这样就能够确保读操作无锁,从而提高并发性能。内部使用 volatile 变量来修饰数组 array,以确保读操作的内存可见性。写操作的时候使用 ReentrantLock 来保证线程安全。缺点就是写操作时会复制一个新数组,如果数组很大,写操作的性能会受到影响。
51、能说一下 BlockingQueue 吗?
BlockingQueue 是 JUC 包下的一个线程安全队列,支持阻塞式的“生产者-消费者”模型。当队列容器已满,生产者线程会被阻塞,直到消费者线程取走元素后为止;当队列容器为空时,消费者线程会被阻塞,直至队列非空时为止。BlockingQueue 的实现类有很多,比如说 ArrayBlockingQueue、PriorityBlockingQueue 等。
51.1 阻塞队列是如何实现的?
阻塞队列使用 ReentrantLock + Condition 来确保并发安全。以 ArrayBlockingQueue 为例,它内部维护了一个数组,使用两个指针分别指向队头和队尾。put 的时候先用 ReentrantLock 加锁,然后判断队列是否已满,如果已满就阻塞等待,否则插入元素。
52、什么是线程池?
线程池是用来管理和复用线程的工具,它可以减少线程的创建和销毁开销。在 Java 中,ThreadPoolExecutor 是线程池的核心实现,它通过核心线程数、最大线程数、任务队列和拒绝策略来控制线程的创建和执行。
53、你在项目中有用到线程池吗?
54、说一下线程池的工作流程?
任务提交 → 核心线程执行 → 任务队列缓存 → 非核心线程执行 → 拒绝策略处理。
- 线程池通过 submit() 提交任务。
- 线程池会先创建核心线程来执行任务。
- 如果核心线程都在忙,任务会被放入任务队列中。
- 如果任务队列已满,且当前线程数量小于最大线程数,线程池会创建新的线程来处理任务。
- 如果线程池中的线程数量已经达到最大线程数,且任务队列已满,线程池会执行拒绝策略。
class ThreadPoolDemo {
public static void main(String[] args) {
// 创建一个线程池
ExecutorService threadPool = new ThreadPoolExecutor(
3, // 核心线程数
6, // 最大线程数
0, // 线程空闲时间
TimeUnit.SECONDS, // 时间单位
new LinkedBlockingQueue<>(10), // 等待队列
Executors.defaultThreadFactory(), // 线程工厂
new ThreadPoolExecutor.AbortPolicy() // 拒绝策略
);
// 模拟 10 个顾客来银行办理业务
try {
for (int i = 1; i <= 10; i++) {
final int tempInt = i;
threadPool.execute(() -> {
System.out.println(Thread.currentThread().getName() + "\t" + "办理业务" + tempInt);
});
}
} catch (Exception e) {
e.printStackTrace();
} finally {
threadPool.shutdown();
}
}
}
55、线程池的主要参数有哪些?
- corePoolSize:核心线程数.
- maximumPoolSize:最大线程数。
- keepAliveTime:非核心线程的存活时间。
- unit:时间单位。
- workQueue:任务队列。
- threadFactory:线程工厂。
- handler:拒绝策略,任务超载时的处理方式。也就是线程数达到最大线程数,任务队列也满了的时候,会触发拒绝策略。
55.1 能简单说一下参数之间的关系吗?
任务优先使用核心线程执行,满了进入等待队列,队列满启用非核心线程备用,线程池达到最大线程数量后触发拒绝策略,非核心线程的空闲时间超过存活时间就被回收。
55.2 核心线程数不够会怎么进行处理?
当提交任务数超过了核心线程数,但是小于最大线程数时,等待队列已满时会创建新的线程来处理任务。当提交任务数超过了最大线程数时,线程池会根据拒绝策略来处理任务。
55.3 举个例子说一下这些参数的变化?
假设一个场景,线程池的配置如下:
corePoolSize = 5
maximumPoolSize = 10
keepAliveTime = 60秒
workQueue = LinkedBlockingQueue(容量为100)
handler = ThreadPoolExecutor.AbortPolicy()
场景一:当系统启动后,有 10 个任务提交到线程池。前 5 个任务会立即执行,因为核心线程数足够容纳它们。
随后的 5 个任务会被放入等待队列。
场景二:如果此时再有 100 个任务提交到线程池。工作队列已满,线程池会创建额外的线程来执行这些任务,直到线程总数达到 10。如果任务继续增加,超过了工作队列 + 最大线程数的限制,新来的任务会被拒绝,抛异常。
场景三:如果任务突然减少:核心线程会一直运行,而超出核心线程数的线程,会在 60 秒后回收。
56、线程池的拒绝策略有哪些?
- AbortPolicy:默认的拒绝策略,会抛 RejectedExecutionException 异常。
- CallerRunsPolicy:让提交任务的线程自己来执行这个任务,也就是调用 execute 方法的线程。
- DiscardOldestPolicy:等待队列会丢弃队列中最老的一个任务,也就是队列中等待最久的任务,然后尝试重新提交被拒绝的任务。
- DiscardPolicy:丢弃被拒绝的任务,不做任何处理也不抛出异常。
57、线程池有哪几种阻塞队列?
- ArrayBlockingQueue:一个有界的先进先出的阻塞队列,底层是一个数组,适合固定大小的线程池。
- LinkedBlockingQueue:底层是链表,默认大小是 Integer.MAX_VALUE,几乎相当于一个无界队列。
- PriorityBlockingQueue:一个支持优先级排序的无界阻塞队列。任务按照其自然顺序或 Comparator 来排序。适用于需要按照给定优先级处理任务的场景,比如优先处理紧急任务。
- DelayQueue:类似于 PriorityBlockingQueue,由二叉堆实现的无界优先级阻塞队列。
- SynchronousQueue:每个插入操作必须等待另一个线程的移除操作,同样,任何一个移除操作都必须等待另一个线程的插入操作。
58、线程池提交 execute 和 submit 有什么区别?
execute 方法没有返回值,适用于不关心结果和异常的简单任务。submit 有返回值,适用于需要获取结果或处理异常的场景。
59、线程池怎么关闭知道吗?
可以调用线程池的shutdown或shutdownNow方法来关闭线程池。shutdown 不会立即停止线程池,而是等待所有任务执行完毕后再关闭线程池。
60、线程池的线程数应该怎么配置?
首先,我会分析线程池中执行的任务类型是 CPU 密集型还是 IO 密集型?
- 对于 CPU 密集型任务,我的目标是尽量减少线程上下文切换,以优化 CPU 使用率。一般来说,核心线程数设置为处理器的核心数是较理想的选择。
- 对于 IO 密集型任务,由于线程经常处于等待状态,等待 IO 操作完成,所以可以设置更多的线程来提高并发,比如说 CPU 核心数的两倍。
最后,我会根据业务需求和系统资源来调整线程池的其他参数,比如最大线程数、任务队列容量、非核心线程的空闲存活时间等。
60.1 如何知道你设置的线程数多了还是少了?
可以通过监控和调试来判断线程数是多还是少。比如说通过 top 命令观察 CPU 的使用率,如果 CPU 使用率较低,可能是线程数过少;如果 CPU 使用率接近 100%,但吞吐量未提升,可能是线程数过多。
61、有哪几种常见的线程池?
- 固定大小的线程池 Executors.newFixedThreadPool(int nThreads),适合用于任务数量确定,且对线程数有明确要求的场景。例如,IO 密集型任务、数据库连接池等。
- 缓存线程池 Executors.newCachedThreadPool(),适用于短时间内任务量波动较大的场景。例如,短时间内有大量的文件处理任务或网络请求。
- 定时任务线程池 Executors.newScheduledThreadPool(int corePoolSize),适用于需要定时执行任务的场景。例如,定时发送邮件、定时备份数据等。
- 单线程线程池 Executors.newSingleThreadExecutor(),适用于需要按顺序执行任务的场景。例如,日志记录、文件处理等。
62、能说一下四种常见线程池的原理吗?
- 线程池大小是固定的,corePoolSize == maximumPoolSize,默认使用 LinkedBlockingQueue 作为阻塞队列,适用于任务量稳定的场景,如数据库连接池、RPC 处理等。新任务提交时,如果线程池有空闲线程,直接执行;如果没有,任务进入 LinkedBlockingQueue 等待。缺点是任务队列默认无界,可能导致任务堆积,甚至 OOM。
- 线程池大小不固定,corePoolSize = 0,maximumPoolSize = Integer.MAX_VALUE。空闲线程超过 60 秒会被销毁,使用 SynchronousQueue 作为阻塞队列,适用于短时间内有大量任务的场景。提交任务时,如果线程池没有空闲线程,直接新建线程执行任务;如果有,复用线程执行任务。线程空闲 60 秒后销毁,减少资源占用。缺点是线程数没有上限,在高并发情况下可能导致 OOM。
- 线程池只有 1 个线程,保证任务按提交顺序执行,使用 LinkedBlockingQueue 作为阻塞队列,适用于需要按顺序执行任务的场景。始终只创建 1 个线程,新任务必须等待前一个任务完成后才能执行,其他任务都被放入 LinkedBlockingQueue 排队执行。缺点是无法并行处理任务。
- 定时任务线程池的大小可配置,支持定时 & 周期性任务执行,使用 DelayedWorkQueue 作为阻塞队列,适用于周期性执行任务的场景。执行定时任务时,schedule() 方法可以将任务延迟一定时间后执行一次;scheduleAtFixedRate() 方法可以将任务延迟一定时间后以固定频率执行;scheduleWithFixedDelay() 方法可以将任务延迟一定时间后以固定延迟执行。缺点是如果任务执行时间 > 设定时间间隔,scheduleAtFixedRate 可能会导致任务堆积。
62.1 使用无界队列的线程池会出现什么问题?
如果线程获取一个任务后,任务的执行时间比较长,会导致队列的任务越积越多,导致内存使用不断飙升,最终出现 OOM。
63、线程池异常怎么处理知道吗?
- try-catch 是最简单的方法。
- 使用 Future 获取异常。
- 自定义 ThreadPoolExecutor 重写 afterExecute 方法。
- 使用 UncaughtExceptionHandler 捕获异常。
如果项目使用 execute(),不关心任务的返回值,建议使用 UncaughtExceptionHandler,如果项目使用 submit(),关心任务的返回值,建议使用 Future,如果想要全局捕获所有任务异常,建议重写 afterExecute 方法:
64、能说一下线程池有几种状态吗?
有 5 种状态,它们的转换遵循严格的状态流转规则,不同状态控制着线程池的任务调度和关闭行为。
状态由 RUNNING → SHUTDOWN / STOP → TIDYING → TERMINATED 依次流转。
65、线程池如何实现参数的动态修改?
线程池提供的 setter 方法就可以在运行时动态修改参数,比如说 setCorePoolSize 可以用来修改核心线程数、setMaximumPoolSize 可以用来修改最大线程数。需要注意的是,调用 setCorePoolSize() 时如果新的核心线程数比原来的大,线程池会创建新的线程;如果更小,线程池不会立即销毁多余的线程,除非有空闲线程超过 keepAliveTime。当然了,还可以利用 Nacos 配置中心,或者实现自定义的线程池,监听参数变化去动态调整参数。
66、线程池调优了解吗?
首先我会根据任务类型设置核心线程数参数,比如 IO 密集型任务会设置为 CPU 核心数 * 2 的经验值。
其次我会结合线程池动态调整的能力,在流量波动时通过 setCorePoolSize 平滑扩容。
最后我会通过内置的监控指标建立容量预警机制。比如通过 JMX 监控线程池的运行状态,设置阈值,当线程池的任务队列长度超过阈值时,触发告警。
67、线程池在使用的时候需要注意什么?
- 选择合适的线程池大小。过小的线程池可能会导致任务一直在排队;过大的线程池可能会导致都在竞争 CPU 资源,增加上下文切换的开销。
- 选择合适的任务队列。使用有界队列可以避免资源耗尽的风险,但是可能会导致任务被拒绝;使用无界队列虽然可以避免任务被拒绝,但是可能会导致内存耗尽。
- 尽量使用自定义的线程池,而不是使用 Executors 创建的线程池。因为 newFixedThreadPool 线程池由于使用了 LinkedBlockingQueue,队列的容量默认无限大,任务过多时会导致内存溢出;newCachedThreadPool 线程池由于核心线程数无限大,当任务过多的时候会导致创建大量的线程,导致服务器负载过高宕机。
68、你能设计实现一个线程池吗?
线程池的主要目的是为了避免频繁地创建和销毁线程。
我会把线程池看作一个工厂,里面有一群“工人”,也就是线程了,专门用来做任务。当任务来了,需要先判断有没有空闲的工人,如果有就把任务交给他们;如果没有,就把任务暂存到一个任务队列里,等工人忙完了再去处理。如果队列满了,还没有空闲的工人,就要考虑扩容,让预备的工人过来干活,但不能超过预定的最大值,防止工厂被挤爆。如果连扩容也没法解决,就需要一个拒绝策略,可能直接拒绝任务或者报个错。
68.1 手写一个数据库连接池,可以吗?
可以的,我的思路是这样的:数据库连接池主要是为了避免每次操作数据库时都去创建连接,因为那样很浪费资源。所以我打算在初始化时预先创建好固定数量的连接,然后把它们放到一个线程安全的容器里,后续有请求的时候就从队列里拿,使用完后再归还到队列中。
69、线程池执行中断电了应该怎么处理?
线程池本身只能在内存中进行任务调度,并不会持久化,一旦断电,线程池里的所有任务和状态都会丢失。我会考虑以下几个方面:
- 持久化任务。可以将任务持久化到数据库或者消息队列中,等电恢复后再重新执行。
- 任务幂等性,需要保证任务是幂等的,也就是无论执行多少次,结果都一致。
- 恢复策略。当系统重启时,应该有一个恢复流程:检测上次是否有未完成的任务,将这些任务重新加载到线程池中执行,确保断电前的工作能够恢复。