并发 常见面试题
并发编程理论基础
聊聊JMM内存模型?
相关内容看这篇文章并发编程理论基础,整篇文章内容都很重要
java内存模型(即 java Memory Model,简称JMM),不存在的东西,是一个概念,约定
主要分成两部分来看,一部分叫做主内存,另一部分叫做工作内存。
java当中的共享变量;都放在主内存当中,如类的成员变量(实例变量),还有静态的成员变量(类变量),都是存储在主内存中的。每一个线程都可以访问主内存;
每一个线程都有其自己的工作内存,当线程要执行代码的时候,就必须在工作内存中完成。比如线程操作共享变量,它是不能直接在主内存中操作共享变量的,只能够将共享变量先复制一份,放到线程自己的工作内存当中,线程在其工作内存对该复制过来的共享变量处理完后,再将结果同步回主内存中去。
主内存是 所有线程都共享的,都能访问的。所有的共享变量都存储于主内存;共享变量主要包括类当中的成员变量,以及一些静态变量等。局部变量是不会出现在主内存当中的,因为局部变量只能线程自己使用;工作内存每一个线程都有自己的工作内存,工作内存只存储 该线程对共享变量的副本。线程对变量的所有读写操作都必须在工作内存中完成,而不能直接读写主内存中的变量,不同线程之间也不能直接访问 对方工作内存中的 变量;线程对共享变量的操作都是对其副本进行操作,操作完成之后再同步回主内存当中去;
JMM的同步约定:
线程解锁前,必须把共享变量立刻刷回主存
线程加锁前,必须读取主存中的最新值到工作内存中
加锁和解锁是同一把锁
也就是说,JMM是一种抽象的结构,它提供了合理的禁用缓存和禁止重排序的方案来解决可见性、有序性的问题
作用:主要目的就是在多线程对共享变量进行读写时,来保证共享变量的可见性、有序性、原子性;在编程当中是通过两个关键字 synchronized 和 volatile 来保证共享变量的三个特性的。
如何理解Java并发中的原子性?
原子性(Atomicity): 在一次或多次操作中,要么所有的操作都执行,并且不会受其他因素干扰而中断,要么所有的操作都不执行;
原子性问题是由CPU的分时复用引起的。
int i = 1;
// 线程1执行
i += 1;
// 线程2执行
i += 1;
这里需要注意的是:i += 1需要三条 CPU 指令
- 将变量 i 从内存读取到 CPU寄存器;
- 在CPU寄存器中执行 i + 1 操作;
- 将最后的结果i写入内存(缓存机制导致可能写入的是 CPU 缓存而不是内存)。
由于CPU分时复用(线程切换)的存在,线程1执行了第一条指令后,就切换到线程2执行,假如线程2执行了这三条指令后,再切换会线程1执行后续两条指令,将造成最后写到内存中的i值是2而不是3。
解决原子性:Java内存模型只保证了基本读取和赋值是原子性操作,如果要实现更大范围操作的原子性,可以通过synchronized和Lock来实现。由于synchronized和Lock能够保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了,从而保证了原子性。
如何理解Java并发中的可见性?
可见性:是指当一个线程对共享变量进行了修改,那么另外的线程可以立即看到修改后的最新值。
可见性问题是由CPU缓存引起的
解决可见性:
在共享变量前面加上volatile关键字修饰;volatile 的底层实现原理是内存屏障(Memory Barrier),保证了对 volatile 变量的写指令后会加入写屏障,对 volatile 变量的读指令前会加入读屏障。
- 写屏障(sfence)保证在写屏障之前的,对共享变量的改动,都同步到主存当中;
- 读屏障(lfence)保证在读屏障之后,对共享变量的读取,加载的是主存中最新数据;
,通过synchronized和Lock也能够保证可见性,synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。这是因为synchronized 同步时会对应 JMM 中的 lock 原子操作,lock 操作会刷新工作内存中的变量的值,得到共享内存(主内存)中最新的值,从而保证可见性。
- synchronized 同步的时候会对应8个原子操作当中的 lock 与 unlock 这两个原子操作,lock操作执行时该线程就会去主内存中获取到共享变量最新值,刷新工作内存中的旧值,从而保证可见性。
如何理解Java并发中的有序性?
有序性(Ordering):是指程序代码在执行过程中的先后顺序,由于java在编译器以及运行期的优化,导致了代码的执行顺序未必就是开发者编写代码的顺序。
有序性问题是由 指令重排序引起的
为什么要重排序?一般会认为编写代码的顺序就是代码最终的执行顺序,那么实际上并不一定是这样的,为了提高程序的执行效率,java在编译时和运行时会对代码进行优化(JIT即时编译器),会导致程序最终的执行顺序不一定就是编写代码时的顺序。重排序 是指 编译器 和 处理器 为了优化程序性能 而对 指令序列 进行 重新排序 的一种手段;
解决有序性:
可以使用 synchronized 同步代码块来保证有序性;加了synchronized,依然会发生指令重排序(可以看看DCL单例模式),只不过,由于存在同步代码块,可以保证只有一个线程执行同步代码块当中的代码,也就能保证有序性。
给共享变量加volatile关键字来解决有序性问题。
- 写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后;
- 读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前;
并发与并行的区别
- 并发针对单核 CPU 而言,它指的是 多个任务交替执行,每个任务都会在一段时间内执行一部分,然后切换到另一个任务,因为单核 CPU 一次只能执行一个任务。并发的目的是提高系统的响应性和吞吐量,允许多个任务在同一个处理器上共享时间片。
- 并行针对多核 CPU 而言,它指的是多个任务真正同时执行,每个任务都有自己的处理器核心,它们可以在同一时刻执行不同的指令。并行的目的是提高计算能力和性能,允许多个任务同时处理,以加快任务完成的速度。
单核 CPU 只能并发,无法并行;换句话说,并行只可能发生在多核 CPU 中。在多核 CPU 中,并发和并行通常会同时存在。多个任务可以在不同的核心上并行执行,并且每个任务内部可能也包含并发的逻辑,以处理不同的子任务。这样可以最大程度地提高系统的性能和响应性。
最关键的点是:是否是 同时 执行。
同步和异步的区别
- 同步:发出一个调用之后,在没有得到结果之前, 该调用就不可以返回,一直等待。
- 异步:调用在发出之后,不用等待返回结果,该调用直接返回。
什么是伪共享问题以及如何解决
伪共享(False Sharing)是多线程编程中的一个重要性能问题,尤其在多核处理器中尤为显著。了解如何因共享缓存行引起的无谓性能消耗,从而有效规避,是编写高效并发程序的关键。
在现代处理器中,缓存行(Cache Line)是缓存的最小可分配单位,通常是64字节。当多个线程在不同CPU核心上操作缓存行中的不同变量时,如果这些变量位于同一个缓存行,修改其中一个变量会导致整个缓存行被标记为无效(cpu缓存一致性决定)。这种重复的缓存行无效化和重新加载的现象就是伪共享。
它的本质问题在于:虽然多个线程操作的是物理上不同的数据,但由于它们共享了同一个缓存行,造成不必要的缓存同步,导致性能下降。
代码示例:
public class FalseSharingExample implements Runnable {
// 定义线程数
public static int NUM_THREADS = 4;
public static final long ITERATIONS = 5000000000L; // 每个线程的迭代次数
private final int arrayIndex; // 此线程操作的共享数据索引
private static ValuePadding[] values; // 用于存储共享数据
public FalseSharingExample(int arrayIndex) {
this.arrayIndex = arrayIndex;
}
public static void main(final String[] args) throws InterruptedException {
Thread[] threads = new Thread[NUM_THREADS];
// 初始化共享变量数组
values = new ValuePadding[NUM_THREADS];
for (int i = 0; i < values.length; i++) {
values[i] = new ValuePadding();
}
final long start = System.nanoTime();
for (int i = 0; i < threads.length; i++) {
threads[i] = new Thread(new FalseSharingExample(i));
}
for (Thread t : threads) {
t.start();
}
for (Thread t : threads) {
t.join();
}
System.out.println("Duration = " + (System.nanoTime() - start));
}
@Override
public void run() {
long i = ITERATIONS + 1;
while (0 != --i) {
// 更新当前线程对应的共享变量
values[arrayIndex].value = i;
}
}
// 内部类,带有缓存行填充以避免伪共享
public static class ValuePadding {
protected long p1, p2, p3, p4, p5, p6, p7; // 填充字段
public volatile long value = 0L; // 实际操作的变量
protected long p9, p10, p11, p12, p13, p14, p15; // 填充字段
}
// @Contended注释的使用演示,如果使用该注解,需要加启动参数启用 -XX:-RestrictContended
/*
public static class ValueWithContended {
@Contended
public volatile long value = 0L;
}
*/
}
ValuePadding 类的设计:
- 这里使用了ValuePadding类来存储每个线程要操作的共享变量value。
- 通过在value字段的前后添加几个填充字段(p1到p7和p9到p15),每个ValuePadding对象会占据多个缓存行,从而使value与相邻的对象实际分离至不同的缓存行。
- 这可以有效防止多个线程不必要地共享同一缓存行(即避免伪共享),从而提升程序的并发性能。
主程序的执行流程:
- 主线程启动了多个工作线程,每个线程负责增加自己的ValuePadding实例中的value。
- 每个线程访问和更新values数组中的一个ValuePadding实例,由于填充字段的存在,每个线程访问的value字段分布在不同缓存行上,尽可能减少缓存争用。
代码运行效率:
- 如果不采用填充策略,不同线程可能会因共享同一缓存行而导致频繁的缓存无效化和重新加载,造成性能的严重下降。
- 通过填充确保每个value字段在不同缓存行上,甚至在高并发下也能高效执行,从而减少伪共享。
伪共享问题的解决尤其适合在性能敏感的大规模并发应用中。通过了解缓存行的基本原理并采取适当的填充策略,可以有效减少缓存行争用带来的性能开销,从而在不改变逻辑操作的前提下实现更佳的性能。注意,Java8还提供@Contended注解用于更高级的填充,但使用它需要相应的JVM参数配置支持。
进程线程
详细内容请查看:线程基础
什么是线程和进程?
何为进程?
进程是程序的一次执行过程,是系统运行程序的基本单位,因此进程是动态的。系统运行一个程序即是一个进程从创建,运行到消亡的过程。
在 Java 中,当我们启动 main 函数时其实就是启动了一个 JVM 的进程,而 main 函数所在的线程就是这个进程中的一个线程,也称主线程。
浏览器打开2个页面,会有几个进程?
从目前浏览器的多进程架构设计可以知道,最新的浏览器包括:1个浏览器主进程、1个GPU进程、1个网络进程、多个渲染进程和多个插件进程。
通常情况下打开2个页面会有5个进程,这五个进程分别是:1个浏览器主进程、1个GPU进程、1个网络进程和2个渲染进程(一般几个标签页就几个渲染进程)。
但是往往会有很多其他情况:
- 如果页面中有插件,插件也需要一个单独的进程。
- 如果页面中有 iframe,iframe 也会运行在单独的进程中。
- 如果你装了扩展,扩展也会占用进程。
- 如果两个页面属于同一个站点,并且 B 页面是从 A 页面中打开的,那么他们会共用一个渲染进程。
当然,可以通过任务管理器来更简单更直观的查看浏览器到底打开了几个进程。
何为线程?
线程与进程相似,但线程是一个比进程更小的执行单位。一个进程在其执行的过程中可以产生多个线程。与进程不同的是同类的多个线程共享进程的堆和方法区资源,但每个线程有自己的程序计数器、虚拟机栈和本地方法栈,所以系统在产生一个线程,或是在各个线程之间做切换工作时,负担要比进程小得多,也正因为如此,线程也被称为轻量级进程。
Java 程序天生就是多线程程序
Java 线程和操作系统的线程有啥区别?
在 JDK 1.2 及以后,Java 线程改为基于原生线程(Native Threads)实现,也就是说 JVM 直接使用操作系统原生的内核级线程(内核线程)来实现 Java 线程,由操作系统内核进行线程的调度和管理。
一句话概括 Java 线程和操作系统线程的关系:现在的 Java 线程的本质其实就是操作系统的线程。
线程与进程的关系,区别及优缺点?
- 本质区别:进程是操作系统资源分配的基本单位,而线程是任务调度和执行的基本单位
- 开销方面:每个进程都有独立的代码和数据空间(程序上下文),程序之间的切换会有较大的 开销;线程可以看做轻量级的进程,同一类线程共享代码和数据空间,每个线程都有自己独立的 运行栈和程序计数器(PC),线程之间切换的开销小 稳定性方面:进程中某个线程如果崩溃了,可能会导致整个进程都崩溃。而进程中的子进程崩 溃,并不会影响其他进程。
- 内存分配方面:系统在运行的时候会为每个进程分配不同的内存空间;而对线程而言,除了CPU 外,系统不会为线程分配内存(线程所使用的资源来自其所属进程的资源),线程组之间只能共享资源
- 包含关系:没有线程的进程可以看做是单线程的,如果一个进程内有多个线程,则执行过程不是 一条线的,而是多条
什么是线程上下文切换?
线程在执行过程中会有自己的运行条件和状态(也称上下文),比如上文所说到过的程序计数器,栈信息等。当出现如下情况的时候,线程会从占用 CPU 状态中退出。
- 主动让出 CPU,比如调用了
sleep()
,wait()
等。 - 时间片用完,因为操作系统要防止一个线程或者进程长时间占用 CPU 导致其他线程或者进程饿死。
- 调用了阻塞类型的系统中断,比如请求 IO,线程被阻塞。
- 被终止或结束运行
这其中前三种都会发生线程切换,线程切换意味着需要保存当前线程的上下文,留待线程下次占用 CPU 的时候恢复现场。并加载下一个将要占用 CPU 的线程上下文。这就是所谓的 上下文切换。
上下文切换是现代操作系统的基本功能,因其每次需要保存信息恢复信息,这将会占用 CPU,内存等系统资源进行处理,也就意味着效率会有一定损耗,如果频繁切换就会造成整体效率低下。
为什么要使用多线程?
先从总体上来说:
- 从计算机底层来说: 线程可以比作是轻量级的进程,是程序执行的最小单位,线程间的切换和调度的成本远远小于进程。另外,多核 CPU 时代意味着多个线程可以同时运行,这减少了线程上下文切换的开销。
- 从当代互联网发展趋势来说: 现在的系统动不动就要求百万级甚至千万级的并发量,而多线程并发编程正是开发高并发系统的基础,利用好多线程机制可以大大提高系统整体的并发能力以及性能。
再深入到计算机底层来探讨:
- 单核时代:在单核时代多线程主要是为了提高单进程利用 CPU 和 IO 系统的效率。 假设只运行了一个 Java 进程的情况,当我们请求 IO 的时候,如果 Java 进程中只有一个线程,此线程被 IO 阻塞则整个进程被阻塞。CPU 和 IO 设备只有一个在运行,那么可以简单地说系统整体效率只有 50%。当使用多线程的时候,一个线程被 IO 阻塞,其他线程还可以继续使用 CPU。从而提高了 Java 进程利用系统资源的整体效率。
- 多核时代: 多核时代多线程主要是为了提高进程利用多核 CPU 的能力。举个例子:假如我们要计算一个复杂的任务,我们只用一个线程的话,不论系统有几个 CPU 核心,都只会有一个 CPU 核心被利用到。而创建多个线程,这些线程可以被映射到底层多个 CPU 上执行,在任务中的多个线程没有资源竞争的情况下,任务执行的效率会有显著性的提高,约等于(单核时执行时间/CPU 核心数)。
使用多线程可能带来什么问题?
并发编程的目的就是为了能提高程序的执行效率进而提高程序的运行速度,但是并发编程并不总是能提高程序运行速度的,而且并发编程可能会遇到很多问题,比如:内存泄漏、死锁、线程不安全等等。
如何理解线程安全和不安全?
线程安全和不安全是在多线程环境下对于同一份数据的访问是否能够保证其正确性和一致性的描述。
- 线程安全指的是在多线程环境下,对于同一份数据,不管有多少个线程同时访问,都能保证这份数据的正确性和一致性。
- 线程不安全则表示在多线程环境下,对于同一份数据,多个线程同时访问时可能会导致数据混乱、错误或者丢失。
单核 CPU 上运行多个线程效率一定会高吗?
单核 CPU 同时运行多个线程的效率是否会高,取决于线程的类型和任务的性质。一般来说,有两种类型的线程:CPU 密集型和 IO 密集型。CPU 密集型的线程主要进行计算和逻辑处理,需要占用大量的 CPU 资源。IO 密集型的线程主要进行输入输出操作,如读写文件、网络通信等,需要等待 IO 设备的响应,而不占用太多的 CPU 资源。
在单核 CPU 上,同一时刻只能有一个线程在运行,其他线程需要等待 CPU 的时间片分配。如果线程是 CPU 密集型的,那么多个线程同时运行会导致频繁的线程切换,增加了系统的开销,降低了效率。如果线程是 IO 密集型的,那么多个线程同时运行可以利用 CPU 在等待 IO 时的空闲时间,提高了效率。
因此,对于单核 CPU 来说,如果任务是 CPU 密集型的,那么开很多线程会影响效率;如果任务是 IO 密集型的,那么开很多线程会提高效率。当然,这里的“很多”也要适度,不能超过系统能够承受的上限。
线程的生命周期
- 初始(NEW):初始状态,线程被构建,但是还没有调用start()方法。
- 可运行(RUNNABLE):可运行状态,可运行状态可以包括:运行中状态和就绪状态。也就是 可能正在运行,也可能正在等待 CPU 时间片。包含了操作系统线程状态中的 Running 和 Ready。
- 阻塞(BLOCKED):等待获取一个排它锁,如果其线程释放了锁就会结束此状态。
- 无限期等待(WAITING):等待其它线程显式地唤醒,否则不会被分配 CPU 时间片。
- 限期等待(TIMED_WAITING):无需等待其它线程显式地唤醒,在一定时间之后会被系统自动唤醒。
- 终止(TERMINATED):可以是线程结束任务之后自己结束,或者产生了异常而结束。
什么情况线程会进入 WAITING 状态
线程进入WAITING状态有以下几种情况:
- 调用Object.wait()方法,该方法会使得当前线程进入等待状态,等待其他线程调用同一个对象的notify()或notifyAll()方法唤醒该线程。
- 调用Thread.join()方法,该方法会使得当前线程等待指定线程的结束,当指定线程结束时,当前线程将被唤醒。
- 调用LockSupport.park()方法,该方法会使得当前线程等待,直到获取LockSupport指定的许可或者线程被中断、调度。
怎样唤醒一个阻塞/等待的线程?
在 Java 中,唤醒一个阻塞线程的方式取决于线程被阻塞的具体原因。线程通常会因为某些同步机制(如等待锁、等待信号、等待结果等)而阻塞,不同情况下有不同的唤醒方式。
- 使用 notify() 和 notifyAll() 唤醒等待的线程:
- 线程通过 Object 的 wait() 方法进入等待状态,这时可以通过 notify() 或 notifyAll() 来唤醒它们。这种方法通常与同步块 synchronized 配合使用。
- Thread 的 interrupt() 方法:当线程调用等待操作(如 sleep()、wait()、join() 或 park())时,如果另一个线程调用 interrupt() 方法,可以唤醒该线程。线程会抛出 InterruptedException,从而退出阻塞状态。
- interrupt():中断正在阻塞的线程,并抛出 InterruptedException。
- isInterrupted():检查线程是否被中断。
- 使用 Lock 和 Condition:
- Lock 和 Condition 提供了更灵活的线程间通信机制。可以使用 Condition.await() 让线程等待,并通过 Condition.signal() 或 Condition.signalAll() 唤醒等待的线程。
- signal():唤醒一个等待的线程。
- signalAll():唤醒所有等待的线程。
- 使用 LockSupport 类唤醒阻塞的线程
- LockSupport 类提供了更灵活的工具来阻塞和唤醒线程。可以使用 LockSupport.park() 让线程进入阻塞状态,使用 LockSupport.unpark(Thread t) 唤醒指定的线程。
创建线程有哪几种方式?
- 通过扩展
Thread
类来创建多线程 - 通过实现
Runnable
接口来创建多线程 - 实现
Callable
接口,通过FutureTask
接口创建线程。 - 使用
Executor
框架来创建线程池。
继承 Thread 类
同样也是需要实现 run() 方法,因为 Thread 类也实现了 Runable 接口。
当调用 start() 方法启动一个线程时,虚拟机会将该线程放入就绪队列中等待被调度,当一个线程被调度时会执行该线程的 run() 方法。
public class MyThread extends Thread {
public void run() {
// ...
}
}
public static void main(String[] args) {
MyThread mt = new MyThread();
mt.start();
}
实现 Runnable 接口
需要实现 run() 方法。
通过 Thread 调用 start() 方法来启动线程。
public class MyRunnable implements Runnable {
public void run() {
// ...
}
}
public static void main(String[] args) {
MyRunnable instance = new MyRunnable();
Thread thread = new Thread(instance);
thread.start();
}
实现Runnable接口比继承Thread类所具有的优势:
- 可以避免java中的单继承的限制
- 线程池只能放入实现Runable或Callable类线程,不能直接放入继承Thread的类
实现 Callable 接口
与 Runnable 相比,Callable 可以有返回值,返回值通过 FutureTask 进行封装。
public class MyCallable implements Callable<Integer> {
public Integer call() {
return 123;
}
}
public static void main(String[] args) throws ExecutionException, InterruptedException {
MyCallable mc = new MyCallable();
FutureTask<Integer> ft = new FutureTask<>(mc);
Thread thread = new Thread(ft);
thread.start();
System.out.println(ft.get());
}
或者可以使用线程池进行执行
MyCallable myCallable = new MyCallable();
ExecutorService executorService = Executors.newFixedThreadPool(1);
Future<String> submit = executorService.submit(myCallable);
System.out.println(submit.get());
使用 Executor 创建线程代码
public class ExecutorsTest {
public static void main(String[] args) {
//获取ExecutorService实例,生产禁用,需要手动创建线程池
ExecutorService executorService = Executors.newCachedThreadPool();
//提交任务
executorService.submit(new RunnableDemo());
}
}
class RunnableDemo implements Runnable {
@Override
public void run() {
System.out.println("seven");
}
}
Runnable和Callable有什么区别?
- Callable接口方法是
call()
,Runnable的方法是run()
; - Callable接口call方法有返回值,支持泛型,Runnable接口run方法无返回值。
- Callable接口
call()
方法允许抛出异常;而Runnable接口run()
方法不能继续上抛异常。 - 加入线程池运行,Runnable使用ExecutorService的execute方法,Callable使用ExecutorService的submit方法;
- 运行Callable任务可以拿到一个Future对象,表示异步计算的结果。Future对象封装了检查计算是否完成、检索计算的结果的方法,而Runnable接口没有。
讲讲线程中断?
线程中断即线程运行过程中被其他线程给打断了,它与 stop 最大的区别是:stop 是由系统强制终止线程,而线程中断则是给目标线程发送一个中断信号,如果目标线程没有接收线程中断的信号并结束线程,线程则不会终止,具体是否退出或者执行其他逻辑取决于目标线程。
线程中断三个重要的方法:
1、java.lang.Thread#interrupt
调用目标线程的interrupt()
方法,给目标线程发一个中断信号,线程被打上中断标记。
2、java.lang.Thread#isInterrupted()
判断目标线程是否被中断,不会清除中断标记。
3、java.lang.Thread#interrupted
判断目标线程是否被中断,会清除中断标记。
private static void test2() {
Thread thread = new Thread(() -> {
while (true) {
Thread.yield();
// 响应中断
if (Thread.currentThread().isInterrupted()) {
System.out.println("Java技术栈线程被中断,程序退出。");
return;
}
}
});
thread.start();
thread.interrupt();
}
可以直接调用 run 方法吗?
- 当程序调用
start()
方法,将会创建一个新线程去执行run()
方法中的代码。run()
就像一个普通方法一样,直接调用run()
的话,不会创建新线程,会把run()
方法当成一个 main 线程下的普通方法去执行,并不会在某个线程中执行它,所以这并不是多线程工作。。 - 一个线程的
start()
方法只能调用一次,多次调用会抛出 java.lang.IllegalThreadStateException 异常。run()
方法则没有限制。
线程调用2次start会怎样?
虽然Java创建线程的方式有3种、4种,或者更多种,但实际上底层都是new Thread() ;当创建完一个Thread,这时线程处于NEW状态,这时调用start()方法,会让线程进入到RUNNABLE状态。
如果在RUNNABLE的线程再次调用start呢?其实就会造成线程状态的异常。
其实是底层源码在调用start()方法的时候 针对状态做了判断, 如果不是NEW状态就会报线程状态的异常。
public synchronized void start() {
// 用一个int值标记线程状态
if (threadStatus != 0)
throw new IllegalThreadStateException();
//省略
}
如果线程执行完,再调用一次start又会怎么样?
同上,线程运行完会进入TERMINATED状态, 也不是NEW状态! 所以也是报错
线程都有哪些方法?
- start:用于启动线程。
- getPriority:获取线程优先级,默认是5,线程默认优先级为5,如果不手动指定,那么线程优先级具有继承性,比如线程A启动线程B,那么线程B的优先级和线程A的优先级相同
- setPriority:设置线程优先级。CPU会尽量将执行资源让给优先级比较高的线程。
- interrupt:告诉线程,你应该中断了,具体到底中断还是继续运行,由被通知的线程自己处理。
当对一个线程调用 interrupt() 时,有两种情况:
如果线程处于被阻塞状态(例如处于sleep, wait, join 等状态),那么线程将立即退出被阻塞状态,并抛出一个InterruptedException异常。
如果线程处于正常活动状态,那么会将该线程的中断标志设置为 true。不过,被设置中断标志的线程可以继续正常运行,不受影响。
interrupt() 并不能真正的中断线程,需要被调用的线程自己进行配合才行。
- join:等待其他线程终止。在当前线程中调用另一个线程的join()方法,则当前线程转入阻塞状态,直到另一个进程运行结束,当前线程再由阻塞转为就绪状态。
- yield:暂停当前正在执行的线程对象,把执行机会让给相同或者更高优先级的线程。
- sleep:使线程转到阻塞状态。millis参数设定睡眠的时间,以毫秒为单位。当睡眠结束后,线程自动转为Runnable状态。
sleep(0) 的作用是什么?
在线程中,调用sleep(0)可以释放cpu时间,让线程马上重新回到就绪队列而非等待队列,sleep(0)释放当前线程所剩余的时间片(如果有剩余的话),这样可以让操作系统切换其他线程来执行,提升效率。
Thread.Sleep(0) 并非是真的要线程挂起0毫秒,意义在于这次调用Thread.Sleep(0)的当前线程确实的被冻结了一下,让其他线程有机会优先执行。Thread.Sleep(0) 是你的线程暂时放弃cpu,也就是释放一些未用的时间片给其他线程或进程使用,就相当于一个让位动作。其实就等同于yield的用法
Thread.sleep()和Object.wait()的区别?
相同点:
- 它们都可以使当前线程暂停运行,把机会交给其他线程
- 任何线程在调用wait()和sleep()之后,在等待期间被中断都会抛出
InterruptedException
不同点:
wait()
是Object超类中的方法;而sleep()
是线程Thread类中的方法- 对锁的持有不同,
wait()
会释放锁,而sleep()
并不释放锁 - 唤醒方法不完全相同,
wait()
依靠notify
或者notifyAll
、中断、达到指定时间来唤醒;而sleep()
到达指定时间被唤醒 - 调用
wait()
需要先获取对象的锁,而Thread.sleep()
不用
如果在wait()之前执行了notify()会怎样?
如果当前的线程不是此对象锁的所有者,却调用该对象的notify()或wait()方法时抛出IllegalMonitorStateException异常;
如果当前线程是此对象锁的所有者,wait()将一直阻塞,因为后续将没有其它notify()唤醒它。
为什么 wait() 方法不定义在 Thread 中?
wait()
是让获得对象锁的线程实现等待,会自动释放当前线程占有的对象锁。每个对象(Object
)都拥有对象锁,既然要释放当前线程占有的对象锁并让其进入 WAITING 状态,自然是要操作对应的对象(Object
)而非当前的线程(Thread
)。
类似的问题:为什么 sleep()
方法定义在 Thread
中?
因为 sleep()
是让当前线程暂停执行,不涉及到对象类,也不需要获得对象锁。
notify()和 notifyAll()有什么区别?
在Java中,notify()和notifyAll()都属于Object类的方法,用于实现线程间的通信。
- notify()方法用于唤醒在当前对象上等待的单个线程。如果有多个线程同时在某个对象上等待(通过调用该对象的wait()方法),则只会唤醒其中一个线程,并使其从等待状态变为可运行状态。具体是哪个线程被唤醒是不确定的,取决于线程调度器的实现。
- notifyAll()方法用于唤醒在当前对象上等待的所有线程。如果有多个线程在某个对象上等待,调用notifyAll()方法后,所有等待的线程都会被唤醒并竞争该对象的锁。其中一个线程获得锁后继续执行,其他线程则继续等待。
需要注意的是,notify()和notifyAll()方法只能在同步代码块或同步方法内部调用,并且必须拥有与该对象关联的锁。否则会抛出IllegalMonitorStateException异常。
JAVA 中用到的线程调度算法是什么?
Java 线程调度机制主要包括以下几种:
- 抢占式调度(Preemptive Scheduling):在这种调度方式下,操作系统会根据优先级和时间片(time slice)来决定哪个线程可以运行。
- 优先级抢占:高优先级的线程可以抢占低优先级线程的 CPU 时间。
- 时间片轮转:每个线程会被分配一个时间片,当时间片用完时,线程会被挂起,操作系统会选择另一个线程来运行。
- 协作式调度(Cooperative Scheduling):在这种调度方式下,线程会主动放弃 CPU 控制权,通常是通过调用
Thread.yield()
方法。线程会等待其他线程完成任务后再继续执行。
如何停止一个正在运行的线程?
有几种方式。
1、使用线程的stop方法(已弃用)
使用stop()方法可以强制终止线程。不过stop是一个被废弃掉的方法,不推荐使用。
使用Stop方法,会一直向上传播ThreadDeath异常,从而使得目标线程解锁所有锁住的监视器,即释放掉所有的对象锁。使得之前被锁住的对象得不到同步的处理,因此可能会造成数据不一致的问题。
2、使用interrupt方法中断线程,该方法只是告诉线程要终止,但最终何时终止取决于计算机。调用interrupt方法仅仅是在当前线程中打了一个停止的标记,并不是真的停止线程。
接着调用 Thread.currentThread().isInterrupted()方法,可以用来判断当前线程是否被终止,通过这个判断我们可以做一些业务逻辑处理,通常如果isInterrupted返回true的话,会抛一个中断异常,然后通过try-catch捕获。
3、设置标志位
设置标志位,当标识位为某个值时,使线程正常退出。设置标志位是用到了共享变量的方式,为了保证共享变量在内存中的可见性,可以使用volatile修饰它,这样的话,变量取值始终会从主存中获取最新值。
但是这种volatile标记共享变量的方式,在线程发生阻塞时是无法完成响应的。比如调用Thread.sleep() 方法之后,线程处于不可运行状态,即便是主线程修改了共享变量的值,该线程此时根本无法检查循环标志,所以也就无法实现线程中断。
因此,interrupt() 加上手动抛异常的方式是目前中断一个正在运行的线程最为正确的方式了。
什么是Daemon线程?与普通线程的区别
后台(daemon)线程,是指在程序运行的时候在后台提供一种通用服务的线程,并且这个线程并不属于程序中不可或缺的部分。因此,当所有的非后台线程结束时,程序也就终止了,同时会杀死进程中的所有后台线程。反过来说,只要有任何非后台线程还在运行,程序就不会终止。
与普通线程相比,守护线程有以下几个区别:
- 终止条件:当所有用户线程结束时,守护线程会自动停止。换句话说,守护线程不会阻止程序的终止,即使它们还没有执行完任务。
- 生命周期:守护线程的生命周期与主线程或其他用户线程无关。当所有的非守护线程都结束时,JVM 将会退出并停止守护线程的执行。
- 线程优先级:守护线程的优先级默认与普通线程一样。优先级较高的守护线程也不能够保证在其他线程之前执行。
- 资源回收:守护线程通常被用于执行一些后台任务,例如垃圾回收、日志记录、定时任务等。当只剩下守护线程时,JVM 会自动退出并且不会等待守护线程执行完毕。
需要注意的是,守护线程与普通线程在编写代码时没有太大的区别。但必须在线程启动之前调用setDaemon()方法,才能把它设置为后台线程。
总结起来,守护线程在程序运行过程中提供了一种支持性的服务,会在所有的用户线程结束时自动停止。
比如:JVM的垃圾回收线程就是Daemon线程,Finalizer也是守护线程。
线程间通信方式
1、使用 wait()
和 notify()
/notifyAll()
方法:wait()
和 notify()
/notifyAll()
是最基础的线程通信机制。这些方法是 Object
类的一部分,用于在同步块或同步方法中实现线程的等待和通知。
wait()
:使当前线程等待,直到另一个线程调用notify()
或notifyAll()
方法唤醒它。notify()
:唤醒一个正在等待的线程。notifyAll()
:唤醒所有正在等待的线程。
2、使用 volatile 关键字。基于volatile关键字实现线程间相互通信,其底层使用了共享内存。简单来说,就是多个线程同时监听一个变量,当这个变量发生变化的时候 ,线程能够感知并执行相应的业务。
3、使用 BlockingQueue
:提供了一种线程安全的方式来进行生产者-消费者模式的实现。
4、使用 ReentrantLock
和 Condition
:ReentrantLock
提供了比 synchronized
更加灵活的锁机制。结合 Condition
类,可以实现类似 wait()
和 notify()
的功能。
5、使用JUC工具类 CountDownLatch、Semaphore、Exchanger。jdk1.5 之后在java.util.concurrent
包下提供了很多并发编程相关的工具类,简化了并发编程开发,CountDownLatch
基于 AQS 框架,相当于也是维护了一个线程间共享变量 state。
6、基于 LockSupport 实现线程间的阻塞和唤醒。LockSupport
是一种非常灵活的实现线程间阻塞和唤醒的工具,使用它不用关注是等待线程先进行还是唤醒线程先运行,但是得知道线程的名字。
如何实现线程的同步
线程的同步是为了保证多个线程按照特定的顺序、协调地访问共享资源,避免数据不一致和竞争条件等问题。
在Java中,常见的线程同步方式有以下几种:
- 使用synchronized关键字:通过在方法或代码块前加上synchronized关键字,确保同一时间只有一个线程可以执行标记为同步的代码。这样可以避免多个线程同时访问共享资源造成的数据不一致问题。
- 使用ReentrantLock类:它是一个可重入锁,通过调用lock()和unlock()方法获取和释放锁。与synchronized不同,ReentrantLock提供了更灵活的同步控制,例如可实现公平性和试锁等待时间。
- 使用wait()、notify()和notifyAll()方法:这些方法是Object类的方法,允许线程间进行协作和通信。通过调用wait()方法使线程进入等待状态,然后其他线程可以通过notify()或notifyAll()方法唤醒等待的线程。
- 使用CountDownLatch和CyclicBarrier:它们是并发工具类,用于线程之间的同步和等待。CountDownLatch可用于等待一组线程完成操作,而CyclicBarrier用于等待一组线程互相达到屏障位置。
选择适合的同步方式会根据具体需求和场景而定。在使用任何同步机制时,需要注意避免死锁和性能问题,合理设计同步范围和粒度。
父子线程之间如何共享传递数据
- 父线程直接通过方法形参传入给子线程
- 使用 InheritableThreadLocal:InheritableThreadLocal是ThreadLocal的一个子类,大部分的实现原理和ThreadLocal是一样的。
- 使用Concurrent 容器:Java提供了一些线程安全的集合类,如ConcurrentHashMap、CopyOnWriteArrayList等,可以安全并发地访问和修改,适用于需要较多线程共享数据的场景。
- 使用消息队列 :在复杂的多线程环境下,使用Java的阻塞队列(如BlockingQueue)可以在父子线程之间传递数据,并控制线程的执行顺序。
什么是线程死锁?
线程死锁是指两个或两个以上的线程在执行过程中,因争夺资源而造成的一种互相等待的现象。若无外力作用,它们都将无法推进下去。
如下图所示,线程 A 持有资源 2,线程 B 持有资源 1,他们同时都想申请对方持有的资源,所以这两个线程就会互相等待而进入死锁状态。
下面通过例子说明线程死锁,代码来自并发编程之美。
public class DeadLockDemo {
private static Object resource1 = new Object();//资源 1
private static Object resource2 = new Object();//资源 2
public static void main(String[] args) {
new Thread(() -> {
synchronized (resource1) {
System.out.println(Thread.currentThread() + "get resource1");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread() + "waiting get resource2");
synchronized (resource2) {
System.out.println(Thread.currentThread() + "get resource2");
}
}
}, "线程 1").start();
new Thread(() -> {
synchronized (resource2) {
System.out.println(Thread.currentThread() + "get resource2");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread() + "waiting get resource1");
synchronized (resource1) {
System.out.println(Thread.currentThread() + "get resource1");
}
}
}, "线程 2").start();
}
}
代码输出如下:
Thread[线程 1,5,main]get resource1
Thread[线程 2,5,main]get resource2
Thread[线程 1,5,main]waiting get resource2
Thread[线程 2,5,main]waiting get resource1
线程 A 通过 synchronized
(resource1) 获得 resource1 的监视器锁,然后通过 Thread.sleep(1000)
。让线程 A 休眠 1s 为的是让线程 B 得到执行然后获取到 resource2 的监视器锁。线程 A 和线程 B 休眠结束了都开始企图请求获取对方的资源,然后这两个线程就会陷入互相等待的状态,这也就产生了死锁。
线程死锁怎么产生?怎么避免?
死锁产生的四个必要条件:也就是说只要系统发生死锁,这些条件必然成立,而只要上述条件之一不满足,就不会发生死锁。
- 互斥:一个资源每次只能被一个进程使用
- 请求与保持:一个进程因请求资源而阻塞时,不释放获得的资源
- 不可剥夺:进程已获得的资源,在未使用之前,不能强行剥夺
- 循环等待:进程之间循环等待着资源
避免死锁的方法:
- 互斥条件不能破坏,因为加锁就是为了保证互斥
- 破坏请求与保持条件:一次性申请所有的资源。
- 破坏不可剥夺条件:占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。
- 破坏循环等待条件:靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。破坏循环等待条件。
具体到开发环节,即:
- 要注意加锁顺序,保证每个线程按同样的顺序进行加锁
- 要注意加锁时限,可以针对所设置一个超时时间
- 要注意死锁检查,这是一种预防机制,确保在第一时间发现死锁并进行解决
死锁与活锁,死锁与饥饿的区别
死锁是指多个线程相互等待对方释放资源,导致它们都无法继续执行下去。这是一种静止状态,这种情况会导致所有线程都被永久性地阻塞,没有一个线程能够继续执行。就像交通堵塞一样,没有车辆能够前进。
活锁是指多个线程不断地改变自己的状态以回应对方,但最终无法取得进展,导致线程不断重试相同的操作,却无法成功。这是一种运行时状态,线程在持续地执行,但任务不会向前推进。活锁通常发生在线程在避免冲突时不断改变状态,但却没有成功,就像两个人在狭窄的道路上不断让对方走,却无法通过一样。
饥饿是指一个或多个线程或进程由于某种原因无法获得所需的资源或执行机会,因此无法适时地执行。这是一种动态问题,通常由资源分配不合理或线程优先级设置不当等原因导致。在饥饿中,线程不一定被永久性地阻塞,但是它们可能长时间无法获得所需的资源。就像一个人在繁忙的自助餐厅排队等待很长时间,但一直无法获得食物一样。
了解虚拟线程吗?
Java21 的新特性,面试问的不多,但你懂了别人不懂,也是优势;例如面试官可能会问ThreadLocal,那么这是可以引出虚拟线程的。详细可以查看:虚拟线程详解
线程池
线程池:一个管理线程的池子。
为什么要用线程池创建线程?
池化技术想必大家已经屡见不鲜了,线程池、数据库连接池、HTTP 连接池等等都是对这个思想的应用。池化技术的思想主要是为了减少每次获取资源的消耗,提高对资源的利用率。
手动创建线程有两个缺点:
- 不受控风险
- 频繁创建开销大
为什么不受控?
系统资源有限,每个人针对不同业务都可以手动创建线程,并且创建线程没有统一标准,比如创建的线程有没有名字等。当系统运行起来,所有线程都在抢占资源,毫无规则,混乱场面可想而知,不好管控。
频繁手动创建线程为什么开销会大?跟new Object() 有什么差别?
虽然Java中万物皆对象,但是new Thread() 创建一个线程和 new Object()还是有区别的。
new Object()过程如下:
- JVM分配一块内存 M
- 在内存 M 上初始化该对象
- 将内存 M 的地址赋值给引用变量 obj
创建线程的过程如下:
- JVM为一个线程栈分配内存,该栈为每个线程方法调用保存一个栈帧
- 每一栈帧由一个局部变量数组、返回值、操作数堆栈和常量池组成
- 每个线程获得一个程序计数器,用于记录当前虚拟机正在执行的线程指令地址
- 系统创建一个与Java线程对应的本机线程
- 将与线程相关的描述符添加到JVM内部数据结构中
- 线程共享堆和方法区域
创建一个线程大概需要1M左右的空间(Java8,机器规格2c8G)。可见,频繁手动创建/销毁线程的代价是非常大的。
为什么使用线程池?
- 降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
- 提高响应速度。当任务到达时,可以不需要等到线程创建就能立即执行。
- 提高线程的可管理性。统一管理线程,避免系统创建大量同类线程而导致消耗完内存。
线程池中线程复用原理
线程池的线程复用原理是指,将线程放入线程池中重复利用,而不是每执行一个任务就创建一个新线程。线程池会对线程进行封装,核心原理在于将线程的创建和管理与任务的执行分离。
线程池通过工作队列(WorkQueue)来存储待执行的任务,队列中可能有多个任务等待被执行。
线程复用的关键是将任务的提交和线程的创建、管理、执行分离,通过线程池来统一管理和调度,减少了创建和销毁线程的开销,提高了系统的效率。同时,由于线程池的复用特性,可以有效控制并发度,避免大量线程的创建和销毁导致的系统负载过大。
线程池执行原理?
- 当线程池里存活的线程数小于核心线程数
corePoolSize
时,这时对于一个新提交的任务,线程池会创建一个线程去处理任务。当线程池里面存活的线程数小于等于核心线程数corePoolSize
时,线程池里面的线程会一直存活着,就算空闲时间超过了keepAliveTime
,线程也不会被销毁,而是一直阻塞在那里一直等待任务队列的任务来执行。 - 当线程池里面存活的线程数已经等于corePoolSize了,这是对于一个新提交的任务,会被放进任务队列workQueue排队等待执行。
- 当线程池里面存活的线程数已经等于
corePoolSize
了,并且任务队列也满了,假设maximumPoolSize>corePoolSize
,这时如果再来新的任务,线程池就会继续创建新的线程来处理新的任务,知道线程数达到maximumPoolSize
,就不会再创建了。 - 如果当前的线程数达到了
maximumPoolSize
,并且任务队列也满了,如果还有新的任务过来,那就直接采用拒绝策略进行处理。默认的拒绝策略是抛出一个RejectedExecutionException异常。
线程池参数有哪些?
ThreadPoolExecutor 的通用构造函数:
public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue, ThreadFactory threadFactory, RejectedExecutionHandler handler);
1、corePoolSize
:当有新任务时,如果线程池中线程数没有达到线程池的基本大小,则会创建新的线程执行任务,否则将任务放入阻塞队列。当线程池中存活的线程数总是大于 corePoolSize 时,应该考虑调大 corePoolSize。
2、maximumPoolSize
:当阻塞队列填满时,如果线程池中线程数没有超过最大线程数,则会创建新的线程运行任务。否则根据拒绝策略处理新任务。非核心线程类似于临时借来的资源,这些线程在空闲时间超过 keepAliveTime 之后,就应该退出,避免资源浪费。
3、BlockingQueue
:存储等待运行的任务。
4、keepAliveTime
:非核心线程空闲后,保持存活的时间,此参数只对非核心线程有效。设置为0,表示多余的空闲线程会被立即终止。
5、TimeUnit
:时间单位
TimeUnit.DAYS
TimeUnit.HOURS
TimeUnit.MINUTES
TimeUnit.SECONDS
TimeUnit.MILLISECONDS
TimeUnit.MICROSECONDS
TimeUnit.NANOSECONDS
6、ThreadFactory
:每当线程池创建一个新的线程时,都是通过线程工厂方法来完成的。在 ThreadFactory 中只定义了一个方法 newThread,每当线程池需要创建新线程就会调用它。
public class MyThreadFactory implements ThreadFactory {
private final String poolName;
public MyThreadFactory(String poolName) {
this.poolName = poolName;
}
public Thread newThread(Runnable runnable) {
return new MyAppThread(runnable, poolName);//将线程池名字传递给构造函数,用于区分不同线程池的线程
}
}
7、RejectedExecutionHandler
:当队列和线程池都满了的时候,根据拒绝策略处理新任务。
AbortPolicy:默认的策略,直接抛出RejectedExecutionException
DiscardPolicy:不处理,直接丢弃
DiscardOldestPolicy:将等待队列队首的任务丢弃,并执行当前任务
CallerRunsPolicy:由调用线程处理该任务
keepalive对coreSize是否起作用?
对coreSize不起作用, 只是对非核心线程部分起作用。如果线程池正常运转后处于空闲状态时,即使当前没有任务,还是有coreSize个线程存活
线程池的拒绝策略有哪些?
如果当前同时运行的线程数量达到最大线程数量并且队列也已经被放满了任务时,ThreadPoolExecutor
定义一些策略:
ThreadPoolExecutor.AbortPolicy
:抛出RejectedExecutionException
来拒绝新任务的处理。ThreadPoolExecutor.CallerRunsPolicy
:调用执行自己的线程运行任务,也就是直接在调用execute
方法的线程中运行(run
)被拒绝的任务,如果执行程序已关闭,则会丢弃该任务。因此这种策略会降低对于新任务提交速度,影响程序的整体性能。如果你的应用程序可以承受此延迟并且你要求任何一个任务请求都要被执行的话,你可以选择这个策略。ThreadPoolExecutor.DiscardPolicy
:不处理新任务,直接丢弃掉。ThreadPoolExecutor.DiscardOldestPolicy
:此策略将丢弃最早的未处理的任务请求。
如果不允许丢弃任务任务,应该选择哪个拒绝策略?
CallerRunsPolicy
:会采用主线程执行任务, 但是如果任务非常耗时会阻塞主线程,在高并发场景慎用!
结合CallerRunsPolicy
的源码来看看:
public static class CallerRunsPolicy implements RejectedExecutionHandler {
public CallerRunsPolicy() {}
public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
//只要当前程序没有关闭,就用执行execute方法的线程执行该任务
if (!e.isShutdown()) {
r.run();
}
}
}
从源码可以看出,只要当前程序不关闭就会使用执行execute
方法的线程执行该任务。
CallerRunsPolicy 拒绝策略有什么风险?如何解决?
如果走到CallerRunsPolicy
的任务是个非常耗时的任务,且处理提交任务的线程是主线程,可能会导致主线程阻塞,影响程序的正常运行,严重的情况下很可能导致 OOM。
当然,采用CallerRunsPolicy
其实就是希望所有的任务都能够被执行,暂时无法处理的任务又被保存在阻塞队列BlockingQueue
中。这样的话,在内存允许的情况下,就可以增加阻塞队列BlockingQueue
的大小并调整堆内存以容纳更多的任务,确保任务能够被准确执行。为了充分利用 CPU,还可以调整线程池的maximumPoolSize
(最大线程数)参数,这样可以提高任务处理速度,避免累计在 BlockingQueue
的任务过多导致内存用完。
但是,如果服务器资源达到可利用的极限了呢?导致主线程卡死的本质就是因为不希望任何一个任务被丢弃。换个思路,有没有办法既能保证任务不被丢弃且在服务器有余力时及时处理呢?
- 可以考虑任务持久化的思路,例如可以采用mysql、redis、或者mq异步 等方案持久化, 后续再对任务进行补偿执行。
- 可以参考netty:会再创建新的一个异步线程处理任务。
@Override
public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
try {
//创建一个临时线程处理任务
final Thread t = new Thread(r, "Temporary task executor");
t.start();
} catch (Throwable e) {
throw new RejectedExecutionException( "Failed to start a new thread", e);
}
}
- 可以参考ActiveMQ: 再次插入阻塞队列, 会加入等待时间, 尽可能的保证执行。
@Override
public void rejectedExecution(final Runnable r, final ThreadPoolExecutor executor){
try {
//限时阻塞等待,实现尽可能交付
//如果队列未满,则立即插入任务并返回 true。
//如果队列已满,则会等待指定的时间(这里是 60 秒)。
//如果在 60 秒内队列仍然无法腾出空间(比如没有任务被消费),则插入失败,返回
executor.getQueue().offer(r, 60, TimeUnit.SECONDS);
} catch (InterruptedException e) {
throw new RejectedExecutionException("Interrupted waiting for BrokerService.worker");
}
throw new RejectedExecutionException("Timed Out while attempting to enqueue Task.");
}
线程池大小怎么设置?
如果线程池线程数量太小,当有大量请求需要处理,系统响应比较慢,会影响用户体验,甚至会出现任务队列大量堆积任务导致OOM。
如果线程池线程数量过大,大量线程可能会同时抢占 CPU 资源,这样会导致大量的上下文切换,从而增加线程的执行时间,影响了执行效率。
CPU 密集型任务(N+1): 这种任务消耗的主要是 CPU 资源,可以将线程数设置为 N(CPU 核心数)+1
,多出来的一个线程是为了防止某些原因导致的线程阻塞(如IO操作,线程sleep,等待锁)而带来的影响。一旦某个线程被阻塞,释放了CPU资源,而在这种情况下多出来的一个线程就可以充分利用 CPU 的空闲时间。
I/O 密集型任务(2N): 系统的大部分时间都在处理 IO 操作,此时线程可能会被阻塞,释放CPU资源,这时就可以将 CPU 交出给其它线程使用。因此在 IO 密集型任务的应用中,可以多配置一些线程,具体的计算方法:最佳线程数 = CPU核心数 * (1/CPU利用率) = CPU核心数 * (1 + (IO耗时/CPU耗时))
,一般可设置为2N。
注意:一般需要通过压力测试来进行微调,只有经过压测的检验,才能最终保证的配置大小是准确的。
线程池的类型有哪些?适用场景?
常见的线程池有 FixedThreadPool
、SingleThreadExecutor
、CachedThreadPool
和 ScheduledThreadPool
。这几个都是 ExecutorService
线程池实例。
FixedThreadPool
固定线程数的线程池。任何时间点,最多只有 nThreads 个线程处于活动状态执行任务。
public static ExecutorService newFixedThreadPool(int nThreads) {
return new ThreadPoolExecutor(nThreads, nThreads, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>());
}
使用无界队列 LinkedBlockingQueue(队列容量为 Integer.MAX_VALUE),运行中的线程池不会拒绝任务,即不会调用RejectedExecutionHandler.rejectedExecution()方法。
maxThreadPoolSize 是无效参数,故将它的值设置为与 coreThreadPoolSize 一致。
keepAliveTime 也是无效参数,设置为0L,因为此线程池里所有线程都是核心线程,核心线程不会被回收(除非设置了executor.allowCoreThreadTimeOut(true))。
适用场景:适用于处理CPU密集型的任务,确保CPU在长期被工作线程使用的情况下,尽可能的少的分配线程,即适用执行长期的任务。需要注意的是,FixedThreadPool 不会拒绝任务,在任务比较多的时候会导致 OOM。
SingleThreadExecutor
只有一个线程的线程池。
public static ExecutionService newSingleThreadExecutor() {
return new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>());
}
使用无界队列 LinkedBlockingQueue。线程池只有一个运行的线程,新来的任务放入工作队列,线程处理完任务就循环从队列里获取任务执行。保证顺序的执行各个任务。
适用场景:适用于串行执行任务的场景,一个任务一个任务地执行。在任务比较多的时候也是会导致 OOM。
CachedThreadPool
根据需要创建新线程的线程池。
public static ExecutorService newCachedThreadPool() {
return new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS, new SynchronousQueue<Runnable>());
}
如果主线程提交任务的速度高于线程处理任务的速度时,CachedThreadPool
会不断创建新的线程。极端情况下,这样会导致耗尽 cpu 和内存资源。
使用没有容量的SynchronousQueue作为线程池工作队列,当线程池有空闲线程时,SynchronousQueue.offer(Runnable task)
提交的任务会被空闲线程处理,否则会创建新的线程处理任务。
适用场景:用于并发执行大量短期的小任务。CachedThreadPool
允许创建的线程数量为 Integer.MAX_VALUE ,可能会创建大量线程,从而导致 OOM。
ScheduledThreadPoolExecutor
在给定的延迟后运行任务,或者定期执行任务。在实际项目中基本不会被用到,因为有其他方案选择比如quartz
。
使用的任务队列 DelayQueue
封装了一个 PriorityQueue
,PriorityQueue
会对队列中的任务进行排序,时间早的任务先被执行(即ScheduledFutureTask
的 time
变量小的先执行),如果time相同则先提交的任务会被先执行(ScheduledFutureTask
的 squenceNumber
变量小的先执行)。
执行周期任务步骤:
- 线程从
DelayQueue
中获取已到期的ScheduledFutureTask(DelayQueue.take())
。到期任务是指ScheduledFutureTask
的 time 大于等于当前系统的时间; - 执行这个
ScheduledFutureTask
; - 修改
ScheduledFutureTask
的 time 变量为下次将要被执行的时间; - 把这个修改 time 之后的
ScheduledFutureTask
放回DelayQueue
中(DelayQueue.add()
)。
适用场景:周期性执行任务的场景,需要限制线程数量的场景。
为什么不推荐使用内置线程池?
在《阿里巴巴 Java 开发手册》“并发处理”这一章节,明确指出线程资源必须通过线程池提供,不允许在应用中自行显式创建线程。
Executors
返回线程池对象的弊端如下:
FixedThreadPool
和SingleThreadExecutor
:使用的是无界的LinkedBlockingQueue
,任务队列最大长度为Integer.MAX_VALUE
,可能堆积大量的请求,从而导致 OOM。CachedThreadPool
:使用的是同步队列SynchronousQueue
, 允许创建的线程数量为Integer.MAX_VALUE
,如果任务数量过多且执行速度较慢,可能会创建大量的线程,从而导致 OOM。ScheduledThreadPool
和SingleThreadScheduledExecutor
:使用的无界的延迟阻塞队列DelayedWorkQueue
,任务队列最大长度为Integer.MAX_VALUE
,可能堆积大量的请求,从而导致 OOM。
怎么判断线程池的任务是不是执行完了?
有几种方法:
1、使用线程池的原生函数isTerminated();
executor提供一个原生函数isTerminated()来判断线程池中的任务是否全部完成。如果全部完成返回true,否则返回false。
2、使用重入锁,维持一个公共计数。
所有的普通任务维持一个计数器,当任务完成时计数器加一(这里要加锁),当计数器的值等于任务数时,这时所有的任务已经执行完毕了。
3、使用CountDownLatch。
它的原理跟第二种方法类似,给CountDownLatch一个计数值,任务执行完毕后,调用countDown()执行计数值减一。最后执行的任务在调用方法的开始调用await()方法,这样整个任务会阻塞,直到这个计数值为零,才会继续执行。
这种方式的缺点就是需要提前知道任务的数量。
4、submit向线程池提交任务,使用Future判断任务执行状态。
使用submit向线程池提交任务与execute提交不同,submit会有Future类型的返回值。通过future.isDone()方法可以知道任务是否执行完成。
execute和submit的区别
execute只能提交Runnable类型的任务,无返回值。submit既可以提交Runnable类型的任务,也可以提交Callable类型的任务,会有一个类型为Future的返回值,但当任务类型为Runnable时,返回值为null。
execute在执行任务时,如果遇到异常会直接抛出,而submit不会直接抛出,只有在使用Future的get方法获取返回值时,才会抛出异常。
execute所属顶层接口是Executor,submit所属顶层接口是ExecutorService,实现类ThreadPoolExecutor重写了execute方法,抽象类AbstractExecutorService重写了submit方法。
线程池中线程异常后:销毁还是复用?
- 当执行方式是execute()时,可以看到堆栈异常的输出,线程池会把这个线程移除掉,并创建一个新的线程放到线程池中。
- 当执行方式是submit()时,堆栈异常没有输出。但是调用Future.get()方法时,可以捕获到异常,不会把这个线程移除掉,也不会创建新的线程放入到线程池中。
这俩种执行方式,都不会影响线程池里面其他线程的正常执行。
如何设计一个能够根据任务的优先级来执行的线程池?
这是一个常见的面试问题,本质其实还是在考察求职者对于线程池以及阻塞队列的掌握。
不同的线程池会选用不同的阻塞队列作为任务队列,比如FixedThreadPool
使用的是LinkedBlockingQueue
(无界队列),由于队列永远不会被放满,因此FixedThreadPool
最多只能创建核心线程数的线程。
假如需要实现一个优先级任务线程池的话,那可以考虑使用 PriorityBlockingQueue
(优先级阻塞队列)作为任务队列(ThreadPoolExecutor
的构造函数有一个 workQueue
参数可以传入任务队列)。
PriorityBlockingQueue
是一个支持优先级的无界阻塞队列,可以看作是线程安全的 PriorityQueue
,两者底层都是使用小顶堆形式的二叉堆,即值最小的元素优先出队。不过,PriorityQueue
不支持阻塞操作。
要想让 PriorityBlockingQueue
实现对任务的排序,传入其中的任务必须是具备排序能力的,方式有两种:
- 提交到线程池的任务实现
Comparable
接口,并重写compareTo
方法来指定任务之间的优先级比较规则。 - 创建
PriorityBlockingQueue
时传入一个Comparator
对象来指定任务之间的排序规则(推荐)。
不过,这存在一些风险和问题,比如:
PriorityBlockingQueue
是无界的,可能堆积大量的请求,从而导致 OOM。- 可能会导致饥饿问题,即低优先级的任务长时间得不到执行。
- 由于需要对队列中的元素进行排序操作以及保证线程安全(并发控制采用的是可重入锁
ReentrantLock
),因此会降低性能。
对于 OOM 这个问题的解决比较简单粗暴,就是继承PriorityBlockingQueue
并重写一下 offer
方法(入队)的逻辑,当插入的元素数量超过指定值就返回 false 。
饥饿问题这个可以通过优化设计来解决(比较麻烦),比如等待时间过长的任务会被移除并重新添加到队列中,但是优先级会被提升。
对于性能方面的影响,是没办法避免的,毕竟需要对任务进行排序操作。并且,对于大部分业务场景来说,这点性能影响是可以接受的。
说下Fork/Join框架,与传统线程池有何不同
详情请看Fork/Join框架详解
Fork/Join框架是一个用于并行化执行任务的框架,它是Java 7引入的一个新特性,专门用于方便地利用多核CPU的性能优势,通过分治法的策略来将任务分解为更小的子任务,然后并行执行这些子任务,最后再合并子任务的结果。
Fork/Join框架的核心是ForkJoinPool,它是一种特殊的线程池,它使用工作窃取算法(Work-Stealing)来平衡负载,使得每个线程尽可能均匀地执行任务。ForkJoinPool中维护了一个任务队列(WorkQueue),当一个线程执行完一个任务时,它会尝试从队列中取出一个新的任务来执行。如果队列为空,它会尝试从其他线程的任务队列中“窃取”任务。这种工作窃取算法使得任务能够被高效地重新分配,从而最大限度地利用系统资源。
与传统的线程池相比,Fork/Join框架具有以下不同:
设计目的和策略:
- Fork/Join框架:旨在方便地利用多核CPU的性能优势,通过分治法的策略将大任务分解为更小的子任务,并行执行这些子任务,并最终合并子任务的结果。它特别适用于计算密集型的任务。
- 传统线程池:是一种基于池化思想管理线程的工具,主要目的是降低资源消耗、提高响应速度、提高线程的可管理性,并解决资源管理问题。
任务分配和执行:
- Fork/Join框架:
- 使用ForkJoinPool作为线程池,维护一个任务队列(WorkQueue)。
- 当一个线程执行完一个任务时,会尝试从队列中取出一个新的任务来执行。
- 如果队列为空,会尝试从其他线程的任务队列中“窃取”任务,这种工作窃取算法可以平衡负载并高效利用系统资源。
- 可以自动将任务分解为多个子任务,并并行执行这些子任务。
- 传统线程池:
- 通常按照先来先服务的原则执行任务,没有工作窃取算法。
- 需要程序员手动将任务分解为多个子任务,并管理这些子任务的执行。
集成与扩展性:
- Fork/Join框架:集成了阻塞队列(BlockingQueue),这种队列可以在需要时自动阻塞线程,直到队列中有新的任务可供执行。这种集成使得Fork/Join框架可以更高效地管理任务队列。
- 传统线程池:可能需要程序员自行管理任务队列,或者使用其他第三方库来实现阻塞队列的功能。
适用场景:
- Fork/Join框架:最适合计算密集型的任务,如递归调用函数(如快速排序)等。
- 传统线程池:适用于多种场景,包括计算密集型任务、I/O密集型任务等。
Java中有哪些琐?
详细内容请查看: Java中的锁
乐观锁和悲观锁
不是具体的锁,是指看待并发同步的角度
悲观锁:对于同一个数据的并发操作,悲观锁认为自己在使用数据的时候一定有别的线程来修改数据,因此在获取数据的时候会先加锁,确保数据不会被别的线程修改。Java中,synchronized关键字和Lock的实现类都是悲观锁。
乐观锁:乐观锁不是真的锁,而是一种实现。乐观锁认为自己在使用数据时不会有别的线程修改数据,所以不会添加锁,只是在更新数据的时候去判断之前有没有别的线程更新了这个数据。如果这个数据没有被更新,当前线程将自己修改的数据成功写入。如果数据已经被其他线程更新,则根据不同的实现方式执行不同的操作(例如报错或者自动重试)。
在JAVA中 悲观锁是指利用各种锁机制,而乐观锁是指无锁编程,最常采用的是CAS算法,典型的原子类,通过CAS算法实现原子类操作的更新。
可以发现:
- 悲观锁适合写操作多的场景,先加锁可以保证写操作时数据正确。
- 乐观锁适合读操作多的场景,不加锁的特点能够使其读操作的性能大幅提升
乐观锁有什么问题?
乐观锁避免了悲观锁独占对象的问题,提高了并发性能,但它也有缺点:
- 乐观锁只能保证一个共享变量的原子操作。
- 长时间自旋可能导致开销大。假如CAS长时间不成功而一直自旋,会给CPU带来很大的开销。
- ABA问题。CAS的原理是通过比对内存值与预期值是否一样而判断内存值是否被改过,但是会有以下问题:假如内存值原来是A, 后来被一条线程改为B,最后又被改成了A,则CAS认为此内存值并没有发生改变。可以引入版本号解决这个问题,每次变量更新都把版本号加一。
什么是CAS?
CAS全称Compare And Swap
,比较与交换,是乐观锁的主要实现方式。CAS在不使用锁的情况下实现多线程之间的变量同步。ReentrantLock
内部的AQS和原子类内部都使用了CAS。
CAS算法涉及到三个操作数:
- 需要读写的内存值V。
- 进行比较的值A。
- 要写入的新值B。
只有当V的值等于A时,才会使用原子方式用新值B来更新V的值,否则会继续重试直到成功更新值。
在Java中,CAS操作主要通过java.util.concurrent.atomic包中的类来实现。例如,AtomicInteger、AtomicBoolean、AtomicReference等。通过这些类的操作,Java应用可以在多线程环境下安全地对基本数据类型进行操作,而无需显式锁定。
以AtomicInteger
为例,AtomicInteger
的getAndIncrement()
方法底层就是CAS实现,关键代码是 compareAndSwapInt(obj, offset, expect, update)
,其含义就是,如果obj
内的value
和expect
相等,就证明没有其他线程改变过这个变量,那么就更新它为update
,如果不相等,那就会继续重试直到成功更新值。
CAS的优点?存在的问题?解决方案是什么?
优点:
- 无锁机制:CAS允许许多线程尝试更新同一个变量,而无需锁定,大大减小了锁竞争。
- 性能提升:在无锁的情况下,通常会有更好的性能表现,适合高并发场景。
存在的问题:
ABA问题。CAS需要在操作值的时候检查内存值是否发生变化,没有发生变化才会更新内存值。但是如果内存值原来是A,后来变成了B,然后又变成了A,那么CAS进行检查时会发现值没有发生变化,但是实际上是有变化的。ABA问题的解决思路就是在变量前面添加版本号,每次变量更新的时候都把版本号加一,这样变化过程就从
A-B-A
变成了1A-2B-3A
。解决方案:JDK从1.5开始提供了AtomicStampedReference类来解决ABA问题,原子更新带有版本号的引用类型。
循环时间长开销大。CAS操作如果长时间不成功,会导致其一直自旋,给CPU带来非常大的开销。
解决方案:可以使用java8中的LongAdder,分段CAS和自动分段迁移。只能保证一个共享变量的原子操作。对一个共享变量执行操作时,CAS能够保证原子操作,但是对多个共享变量操作时,CAS是无法保证操作的原子性的。
解决方案:可以用AtomicReference,这个是封装自定义对象的,多个变量可以放一个自定义对象里,然后他会检查这个对象的引用是不是同一个。如果多个线程同时对一个对象变量的引用进行赋值,用AtomicReference的CAS操作可以解决并发冲突问题。
公平锁与非公平锁
按照线程访问顺序获取对象锁。synchronized
是非公平锁,Lock
默认是非公平锁,可以设置为公平锁,公平锁会影响性能。
public ReentrantLock() {
sync = new NonfairSync();
}
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
共享式与独占式锁
共享式与独占式的最主要区别在于:同一时刻独占式只能有一个线程获取同步状态,而共享式在同一时刻可以有多个线程获取同步状态。例如读操作可以有多个线程同时进行,而写操作同一时刻只能有一个线程进行写操作,其他操作都会被阻塞。
volatile关键字
详细内容请查看:Volatile详解
说下你对volatile的理解
volatile是Java虚拟机提供的轻量级的同步机制,具有以下特点:
- 保证可见性:volatile保证了多个线程对共享变量的操作是可见的。当一个线程修改了共享变量的值,其他线程会立即看到这个改变。
- 禁止指令重排:volatile通过禁止指令重排来保证顺序性。在多线程环境下,为了提高程序执行效率,编译器和处理器可能会对指令进行重新排序。但是,如果一个变量被volatile修饰,就禁止了指令重排,确保每个线程都能看到正确的操作顺序。
总的来说,volatile可以确保多个线程对共享变量的操作一致,避免了数据不一致的问题。但是它不能保证原子性,因此对于需要保证原子性的操作,还需要使用其他同步机制,如synchronized关键字或java.util.concurrent.atomic包中的原子类。
如何保证变量的可见性?
volatile
是轻量级的同步机制,volatile
保证变量对所有线程的可见性,不保证原子性。
而这个LOCK前缀的指令主要实现了两个步骤:
- 将当前处理器缓存行的数据写回到系统内存;
- 将其他处理器中缓存了该数据的缓存行设置为无效。
原因在于缓存一致性协议,每个处理器通过总线嗅探和MESI协议来检查自己的缓存是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行置为无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存中。
- 当volatile修饰的变量进行写操作的时候,JVM就会向CPU发送LOCK#前缀指令,通过缓存一致性机制确保写操作的原子性,然后更新对应的主存地址的数据。
- 处理器会使用嗅探技术保证在当前处理器缓存行,主存和其他处理器缓存行的数据的在总线上保持一致。在JVM通过LOCK前缀指令更新了当前处理器的数据之后,其他处理器就会嗅探到数据不一致,从而使当前缓存行失效,当需要用到该数据时直接去内存中读取,保证读取到的数据时修改后的值。
缓存一致性协议:当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,就会从内存重新读取。
volatile
关键字的两个作用:
- 保证了不同线程对共享变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。
- 禁止进行指令重排序。
指令重排序是JVM为了优化指令,提高程序运行效率,在不影响单线程程序执行结果的前提下,尽可能地提高并行度。Java编译器会在生成指令系列时在适当的位置会插入
内存屏障
指令来禁止处理器重排序。插入一个内存屏障,相当于告诉CPU和编译器先于这个命令的必须先执行,后于这个命令的必须后执行。对一个volatile字段进行写操作,Java内存模型将在写操作后插入一个写屏障指令,这个指令会把之前的写入值都刷新到内存。
如何禁止指令重排序?
为了性能优化,JMM 在不改变正确语义的前提下,会允许编译器和处理器对指令序列进行重排序。JMM 提供了内存屏障阻止这种重排序。
Java 编译器会在生成指令系列时在适当的位置会插入内存屏障指令来禁止特定类型的处理器重排序。
JMM 会针对编译器制定 volatile 重排序规则表。
为了实现 volatile 内存语义时,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。
对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎是不可能的,为此,JMM 采取了保守的策略。
- 在每个 volatile 写操作的前面插入一个 StoreStore 屏障。
- 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障。
- 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障。
- 在每个 volatile 读操作的后面插入一个 LoadStore 屏障。
volatile 写是在前面和后面分别插入内存屏障,而 volatile 读操作是在后面插入两个内存屏障。
volatile为什么不能保证原子性?
volatile可以保证可见性和顺序性,但是它不能保证原子性。
举个例子。一个变量i被volatile修饰,两个线程想对这个变量修改,都对其进行自增操作i++,i++的过程可以分为三步,首先获取i的值,其次对i的值进行加1,最后将得到的新值写会到缓存中。
假如i的初始值为100。线程A首先得到了i的初始值100,但是还没来得及修改,就阻塞了,这时线程B开始了,它也去取i的值,由于i的值未被修改,即使是被volatile修饰,主存的变量还没变化,那么线程B得到的值也是100,之后对其进行加1操作,得到101,将新值写入到缓存中,再刷入主存中。根据可见性的原则,这个主存的值可以被其他线程可见。
那么问题来了,线程A之前已经读取到了i的值为100,线程A阻塞结束后,继续将100这个值加1,得到101,再将值写到缓存,最后刷入主存。这样i经过两次自增之后,结果值只加了1,明显是有问题的。所以说即便volatile具有可见性,也不能保证对它修饰的变量具有原子性。
synchronized
详细内容请查看:synchronized详解
synchronized的用法有哪些?
- 修饰普通方法:作用于当前对象实例,进入同步代码前要获得当前对象实例的锁
- 修饰静态方法:作用于当前类,进入同步代码前要获得当前类对象的锁,synchronized关键字加到static 静态方法和 synchronized(class)代码块上都是是给 Class 类上锁
- 修饰代码块:指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁
synchronized的作用有哪些?
- 原子性:确保线程互斥的访问同步代码;
- 可见性:保证共享变量的修改能够及时可见;
- 有序性:有效解决重排序问题。
构造方法可以用 synchronized 修饰么?
构造方法不能使用 synchronized 关键字修饰。不过,可以在构造方法内部使用 synchronized 代码块。
另外,构造方法本身是线程安全的,但如果在构造方法中涉及到共享资源的操作,就需要采取适当的同步措施来保证整个构造过程的线程安全。
synchronized 底层实现原理?
synchronized 同步代码块的实现是通过 monitorenter
和 monitorexit
指令,其中 monitorenter
指令指向同步代码块的开始位置,monitorexit
指令则指明同步代码块的结束位置。当执行 monitorenter
指令时,线程试图获取锁也就是获取 monitor
的持有权(monitor对象存在于每个Java对象的对象头中, synchronized 锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因)。
其内部包含一个计数器,当计数器为0则可以成功获取,获取后将锁计数器设为1也就是加1。相应的在执行 monitorexit
指令后,将锁计数器设为0
,表明锁被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止
synchronized 修饰的方法并没有 monitorenter
指令和 monitorexit
指令,取得代之的确实是ACC_SYNCHRONIZED
标识,该标识指明了该方法是一个同步方法,JVM 通过该 ACC_SYNCHRONIZED
访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。
volatile和synchronized的区别是什么?
volatile
只能使用在变量上;而synchronized
可以在类,变量,方法和代码块上。volatile
至保证可见性;synchronized
保证原子性与可见性。volatile
禁用指令重排序;synchronized
不会。volatile
不会造成阻塞;synchronized
会。
ReentrantLock和synchronized区别
- 使用synchronized关键字实现同步,线程执行完同步代码块会自动释放锁,而ReentrantLock需要手动释放锁。
- synchronized是非公平锁,ReentrantLock可以设置为公平锁。
- ReentrantLock上等待获取锁的线程是可中断的,线程可以放弃等待锁。而synchonized会无限期等待下去。
- ReentrantLock 可以设置超时获取锁。在指定的截止时间之前获取锁,如果截止时间到了还没有获取到锁,则返回。
- ReentrantLock 的 tryLock() 方法可以尝试非阻塞的获取锁,调用该方法后立刻返回,如果能够获取则返回true,否则返回false。
- synchronized 和 ReentrantLock 都是可重入锁
什么是可重入锁
可重入锁是一种特殊的互斥锁,它允许同一个线程在持有锁的情况下再次获取该锁。也就是说,同一个线程可以多次获取同一个可重入锁,而不会发生死锁。
在 Java 中,synchronized关键字就是一种可重入锁。当一个线程使用synchronized修饰的方法或代码块时,它会获得该对象的锁。如果该线程在持有锁的情况下再次调用同一个对象的synchronized方法或代码块,那么它会再次获得该对象的锁,而不会等待其他线程释放锁。
可重入锁的好处是可以避免死锁的发生。因为同一个线程可以多次获取同一个锁,所以当一个线程在持有锁的情况下需要再次获取锁时,它不需要等待其他线程释放锁,从而避免了死锁的发生。
需要注意的是,可重入锁并不是绝对安全的。如果一个线程在持有锁的情况下进行了一些不当的操作,仍然可能导致死锁的发生。因此,在使用可重入锁时,需要注意避免出现这种情况。
锁升级原理了解吗?
在 Java 6 之后, synchronized
引入了大量的优化如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销,这些优化让 synchronized
锁的效率提升了很多(JDK18 中,偏向锁已经被彻底废弃)。
锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。注意锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率。
AQS
详细内容请查看:AQS详解
什么是AQS?
AQS(AbstractQueuedSynchronizer)是java.util.concurrent包下的核心类,我们经常使用的ReentrantLock、CountDownLatch,都是基于AQS抽象同步式队列实现的。
AQS作为一个抽象类,通常是通过继承来使用的。它本身是没有同步接口的,只是定义了同步状态和同步获取和同步释放的方法。
JUC包下面大部分同步类,都是基于AQS的同步状态的获取与释放来实现的,然后AQS是个双向链表。
为什么AQS是双向链表而不是单向的?
双向链表有两个指针,一个指针指向前置节点,一个指针指向后继节点。所以,双向链表可以支持常量 O(1) 时间复杂度的情况下找到前驱节点。因此,双向链表在插入和删除操作的时候,要比单向链表简单、高效。
从双向链表的特性来看,AQS 使用双向链表有2个方面的原因:
- 没有竞争到锁的线程加入到阻塞队列,并且阻塞等待的前提是,当前线程所在节点的前置节点是正常状态,这样设计是为了避免链表中存在异常线程导致无法唤醒后续线程的问题。所以,线程阻塞之前需要判断前置节点的状态,如果没有指针指向前置节点,就需要从 Head 节点开始遍历,性能非常低。
- 在 Lock 接口里面有一个lockInterruptibly()方法,这个方法表示处于锁阻塞的线程允许被中断。也就是说,没有竞争到锁的线程加入到同步队列等待以后,是允许外部线程通过interrupt()方法触发唤醒并中断的。这个时候,被中断的线程的状态会修改成 CANCELLED。而被标记为 CANCELLED 状态的线程,是不需要去竞争锁的,但是它仍然存在于双向链表里面。这就意味着在后续的锁竞争中,需要把这个节点从链表里面移除,否则会导致锁阻塞的线程无法被正常唤醒。在这种情况下,如果是单向链表,就需要从 Head 节点开始往下逐个遍历,找到并移除异常状态的节点。同样效率也比较低,还会导致锁唤醒的操作和遍历操作之间的竞争。
AQS原理
AQS,AbstractQueuedSynchronizer
,抽象队列同步器,定义了一套多线程访问共享资源的同步器框架,许多并发工具的实现都依赖于它,如常用的ReentrantLock/Semaphore/CountDownLatch
。
AQS使用一个volatile
的int类型的成员变量state
来表示同步状态,通过CAS修改同步状态的值。当线程调用 lock 方法时 ,如果 state
=0,说明没有任何线程占有共享资源的锁,可以获得锁并将 state
加1。如果 state
不为0,则说明有线程目前正在使用共享变量,其他线程必须加入同步队列进行等待。
private volatile int state;//共享变量,使用volatile修饰保证线程可见性
同步器依赖内部的同步队列(一个FIFO双向队列)来完成同步状态的管理,当前线程获取同步状态失败时,同步器会将当前线程以及等待状态(独占或共享 )构造成为一个节点(Node)并将其加入同步队列并进行自旋,当同步状态释放时,会把首节点中的后继节点对应的线程唤醒,使其再次尝试获取同步状态。
- 以ReentrantLock为例,ReentrantLock中的state初始值为0表示无锁状态。在线程执行 tryAcquire()获取该锁后ReentrantLock中的state+1,这时该线程独占ReentrantLock锁,其他线程在通过tryAcquire() 获取锁时均会失败,直到该线程释放锁后state再次为0,其他线程才有机会获取该锁。该线程在释放锁之前可以重复获取此锁,每获取一次便会执行一次state+1, 因此ReentrantLock也属于可重入锁。 但获取多少次锁就要释放多少次锁,这样才能保证state最终为0。如果获取锁的次数多于释放锁的次数,则会出现该线程一直持有该锁的情况;如果获取锁的次数少于释放锁的次数,则运行中的程序会报锁异常。
- 以CountDownLatch以例,任务分为N个子线程去执行,state也初始化为N(注意N要与线程个数一致)。这N个子线程是并行执行的,每个子线程执行完后countDown()一次,state会CAS减1。等到所有子线程都执行完后(即state=0),会unpark()主调用线程,然后主调用线程就会从await()函数返回,继续后面的动作。
- 以Semaphore为例,state则代表可以同时访问的线程数量,也可能理解为访问的许可证(permit)数量。每个线程访问(acquire)时需要拿到对应的许可证,否则进行阻塞,访问结束则返还(release)许可证。state只能在Semaphore的构造方法中进行初始化,后续不能进行修改。
了解Locksupport吗?
LockSupport用来创建锁和其他同步类的基本线程阻塞原语。简而言之,当调用LockSupport.park时,表示当前线程将会等待,直至获得许可,当调用LockSupport.unpark时,必须把等待获得许可的线程作为参数进行传递,好让此线程继续运行。在AQS中大量使用,AQS最终都是使用LockSupport来阻塞线程的。
Locksupport与 synchronzed 的区别
synchronzed 会使线程阻塞,线程会进入 BLOCKED 状态,而调用 LockSupprt 方法阻塞线程会使线程进入到 WAITING 状态。
Object.wait()和Condition.await()的区别
Object.wait()和Condition.await()的原理是基本一致的,不同的是Condition.await()底层是调用LockSupport.park()来实现阻塞当前线程的。
实际上,它在阻塞当前线程之前还干了两件事,一是把当前线程添加到条件队列中,二是“完全”释放锁,也就是让state状态变量变为0,然后才是调用LockSupport.park()阻塞当前线程。
Thread.sleep()和LockSupport.park()的区别
LockSupport.park()还有几个兄弟方法——parkNanos()、parkUtil()等,我们这里说的park()方法统称这一类方法。
- 从功能上来说,Thread.sleep()和LockSupport.park()方法类似,都是阻塞当前线程的执行,且都不会释放当前线程占有的锁资源;
- Thread.sleep()没法从外部唤醒,只能自己醒过来;
- LockSupport.park()方法可以被另一个线程调用LockSupport.unpark()方法唤醒;
- Thread.sleep()方法声明上抛出了InterruptedException中断异常,所以调用者需要捕获这个异常或者再抛出;
- LockSupport.park()方法不需要捕获中断异常;
- Thread.sleep()本身就是一个native方法;
- LockSupport.park()底层是调用的Unsafe的native方法;
Object.wait()和LockSupport.park()的区别
二者都会阻塞当前线程的运行,他们有什么区别呢?
- Object.wait()方法需要在synchronized块中执行;
- LockSupport.park()可以在任意地方执行;
- Object.wait()方法声明抛出了中断异常,调用者需要捕获或者再抛出;
- LockSupport.park()不需要捕获中断异常;
- Object.wait()不带超时的,需要另一个线程执行notify()来唤醒,但不一定继续执行后续内容;
- LockSupport.park()不带超时的,需要另一个线程执行unpark()来唤醒,一定会继续执行后续内容;
park()/unpark()底层的原理是“二元信号量”,你可以把它相像成只有一个许可证的Semaphore,只不过这个信号量在重复执行unpark()的时候也不会再增加许可证,最多只有一个许可证。
如果在park()之前执行了unpark()会怎样?
线程不会被阻塞,直接跳过park(),继续执行后续内容
LockSupport.park()会释放锁资源吗?
不会,它只负责阻塞当前线程,释放锁资源实际上是在Condition的await()方法中实现的。
ReentrantLock 是如何实现可重入性的?
ReentrantLock
内部自定义了同步器sync,在加锁的时候通过CAS算法,将线程对象放到一个双向链表中,每次获取锁的时候,检查当前维护的那个线程ID和当前请求的线程ID是否 一致,如果一致,同步状态加1,表示锁被当前线程获取了多次。
源码如下:
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0) // overflow
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
ReentrantLock中tryLock()和lock()方法的区别
- tryLock()表示尝试加锁,可能加到,也可能加不到,该方法不会阻塞线程,如果加到锁则返回true,没有加到则返回false
- lock()表示阻塞加锁,线程会阻塞直到加到锁,方法也没有返回值
ReentrantLock中的公平锁和非公平锁的底层实现
ReentrantLock是Java中提供的一种可重入锁,它支持两种锁的模式:公平锁和非公平锁。这两种锁模式的底层实现略有不同:
- 公平锁(Fair Lock): 公平锁的特点是按照请求锁的顺序来分配锁,即先到先得。在ReentrantLock中,通过构造函数可以选择创建一个公平锁。公平锁的底层实现使用了一个FIFO队列(First-In-First-Out),即等待队列。当一个线程请求锁时,如果锁已经被其他线程持有,请求线程会被放入等待队列的末尾,按照请求的顺序等待锁的释放。当锁被释放时,等待队列中的第一个线程会被唤醒并获得锁。
- 非公平锁(Non-Fair Lock): 非公平锁不考虑请求锁的顺序,它允许新的请求线程插队并尝试立即获取锁,而不管其他线程是否在等待。在ReentrantLock中,默认情况下创建的是非公平锁。非公平锁的底层实现中,有一个等待队列,但它不会严格按照请求的顺序来分配锁,而是根据线程竞争锁的情况来判断是否立即分配给新的请求线程。
底层实现中,无论是公平锁还是非公平锁,都使用了类似的同步器(Sync)来管理锁的状态和线程的竞争。不同之处在于如何处理等待队列中的线程,以及是否按照请求的顺序来分配锁。
ReentrantLock实现公平锁非公平锁则主要体现在tryAcquire的实现上:
公平锁中实现的tryAcquire:
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (!hasQueuedPredecessors() && //公平锁加锁时判断等待队列中是否存在有效节点的方法
compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0)
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
非公平锁中实现的tryAcquire:
protected final boolean tryAcquire(int acquires) {
return nonfairTryAcquire(acquires);
}
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0) // overflow
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
公平锁中多了一层 !hasQueuedPredecessors() 的判断,这是公平锁加锁时判断等待队列中是否存在有效节点的方法。如果返回False,说明当前线程可以获取共享资源;如果返回True,说明队列中存在有效节点,当前线程必须加入到等待队列中。
而在非公平锁中,没有这个判断,直接尝试获取锁,能获取到锁则不用加入等待队列。
Java中读写锁的应用场景是什么
ReadWriteLock 是 Java 提供的一种用于解决并发读写问题的锁机制,其主要目的在于提高在读多写少的场景下的并发性能。ReadWriteLock 允许多个线程同时读取共享资源,但是对写操作是独占的。这意味着在一个线程写入数据时,其他线程既不能读取也不能写入。
应用场景
- 缓存系统:在缓存系统中,数据读取频率通常远高于数据写入,因此使用读写锁可以提高读取操作的并发性和效率。
- 配置管理: 系统配置或应用程序配置的数据通常在初始化后多为读取,只有在特殊情况下才会更新,因此适合使用读写锁。
- 文档编辑:多人协同编辑文档时,可能会有许多人同时查看文档内容,只有少数人进行编辑,这种场合也可以使用读写锁。
- 游戏状态:游戏服务器可能需要频繁读取玩家的状态,而状态更新相对较少,因此读写锁可以提高状态读取的性能。
关键点
- 提升读取性能:ReadWriteLock 允许多个读取线程同时访问共享资源,从而提高了读取性能。
- 写锁独占:只有在没有其他线程读取或写入时,写锁才能获得,这保障了数据在写入时的一致性和安全性。
- 适用性:适用于读多写少场景,能显著提高系统在并发读取场景下的性能。
- 易于使用:ReadWriteLock 提供了清晰的语义分离(读与写),使得代码更具可读性和维护性。
在高并发应用中,通过合理使用 ReadWriteLock 可以在保持数据一致性的同时大幅提高程序的并发性能。
并发工具
线程执行顺序怎么控制?
假设有T1、T2、T3三个线程,你怎样保证T2在T1执行完后执行,T3在T2执行完后执行?
可以使用join方法解决这个问题。比如在线程A中,调用线程B的join方法表示的意思就是**:A等待B线程执行完毕后(释放CPU执行权),在继续执行。**
代码如下:
public class ThreadTest {
public static void main(String[] args) {
Thread spring = new Thread(new SeasonThreadTask("春天"));
Thread summer = new Thread(new SeasonThreadTask("夏天"));
Thread autumn = new Thread(new SeasonThreadTask("秋天"));
try
{
//春天线程先启动
spring.start();
//主线程等待线程spring执行完,再往下执行
spring.join();
//夏天线程再启动
summer.start();
//主线程等待线程summer执行完,再往下执行
summer.join();
//秋天线程最后启动
autumn.start();
//主线程等待线程autumn执行完,再往下执行
autumn.join();
} catch (InterruptedException e)
{
e.printStackTrace();
}
}
}
class SeasonThreadTask implements Runnable{
private String name;
public SeasonThreadTask(String name){
this.name = name;
}
@Override
public void run() {
for (int i = 1; i <4; i++) {
System.out.println(this.name + "来了: " + i + "次");
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
运行结果:
春天来了: 1次
春天来了: 2次
春天来了: 3次
夏天来了: 1次
夏天来了: 2次
夏天来了: 3次
秋天来了: 1次
秋天来了: 2次
秋天来了: 3次
CountDownLatch
CountDownLatch用于某个线程等待其他线程执行完任务再执行,与thread.join()功能类似。
常用于以下场景:
- 主线程等待多个子线程完成任务:主线程可以使用await()方法等待所有子线程完成,然后进行结果的汇总或其他操作。
- 多个线程等待外部事件的发生:多个线程可以同时等待某个共同的事件发生,比如等待某个资源准备就绪或者等待某个信号的触发。
- 控制并发任务的同时开始:在某些并发场景中,需要等待所有线程都准备就绪后才能同时开始执行任务,CountDownLatch提供了一种便捷的方式来实现这一需求。
public class CountDownLatchDemo {
static final int N = 4;
static CountDownLatch latch = new CountDownLatch(N);
public static void main(String[] args) throws InterruptedException {
for(int i = 0; i < N; i++) {
new Thread(new Thread1()).start();
}
latch.await(1000, TimeUnit.MILLISECONDS); //调用await()方法的线程会被挂起,它会等待直到count值为0才继续执行;等待timeout时间后count值还没变为0的话就会继续执行
System.out.println("task finished");
}
static class Thread1 implements Runnable {
@Override
public void run() {
try {
System.out.println(Thread.currentThread().getName() + "starts working");
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
latch.countDown();
}
}
}
}
运行结果:
Thread-0starts working
Thread-1starts working
Thread-2starts working
Thread-3starts working
task finished
CyclicBarrier
CyclicBarrier(同步屏障),用于一组线程互相等待到某个状态,然后这组线程再同时执行。
public CyclicBarrier(int parties, Runnable barrierAction) {
}
public CyclicBarrier(int parties) {
}
参数parties指让多少个线程或者任务等待至某个状态;参数barrierAction为当这些线程都达到某个状态时会执行的内容。
public class CyclicBarrierTest {
// 请求的数量
private static final int threadCount = 10;
// 需要同步的线程数量
private static final CyclicBarrier cyclicBarrier = new CyclicBarrier(5);
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");
try {
/**等待60秒,保证子线程完全执行结束*/
cyclicBarrier.await(60, TimeUnit.SECONDS);
} catch (Exception e) {
System.out.println("-----CyclicBarrierException------");
}
System.out.println("threadnum:" + threadnum + "is finish");
}
}
运行结果如下,可以看出CyclicBarrier是可以重用的:
threadnum:0is ready
threadnum:1is ready
threadnum:2is ready
threadnum:3is ready
threadnum:4is ready
threadnum:4is finish
threadnum:3is finish
threadnum:2is finish
threadnum:1is finish
threadnum:0is finish
threadnum:5is ready
threadnum:6is ready
...
当四个线程都到达barrier状态后,会从四个线程中选择一个线程去执行Runnable。
CyclicBarrier和CountDownLatch区别
- CyclicBarrier 和 CountDownLatch 都能够实现线程之间的等待
- CountDownLatch简单的说就是一个线程等待,直到他所等待的其他线程都执行完成并且调用countDown()方法发出通知后,当前线程才可以继续执行。
- cyclicBarrier是所有线程都进行等待,直到所有线程都准备好进入await()方法之后,所有线程同时开始执行!
- CountDownLatch减计数,CyclicBarrier加计数。
- CountDownLatch是一次性的,CyclicBarrier可以重用。CyclicBarrier的计数器可以使用reset() 方法重置。所以CyclicBarrier能处理更为复杂的业务场景,比如如果计算发生错误,可以重置计数器,并让线程们重新执行一次。
Semaphore
Semaphore类似于锁,它用于控制同时访问特定资源的线程数量,控制并发线程数。
public class SemaphoreDemo {
public static void main(String[] args) {
final int N = 7;
Semaphore s = new Semaphore(3);
for(int i = 0; i < N; i++) {
new Worker(s, i).start();
}
}
static class Worker extends Thread {
private Semaphore s;
private int num;
public Worker(Semaphore s, int num) {
this.s = s;
this.num = num;
}
@Override
public void run() {
try {
s.acquire();
System.out.println("worker" + num + " using the machine");
Thread.sleep(1000);
System.out.println("worker" + num + " finished the task");
s.release();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
运行结果如下,可以看出并非按照线程访问顺序获取资源的锁,即
worker0 using the machine
worker1 using the machine
worker2 using the machine
worker2 finished the task
worker0 finished the task
worker3 using the machine
worker4 using the machine
worker1 finished the task
worker6 using the machine
worker4 finished the task
worker3 finished the task
worker6 finished the task
worker5 using the machine
worker5 finished the task
应用场景:Semaphore可以用于做流量控制,特别是公共资源有限的应用场景,比如数据库连接。假如有一个需求要读取几万个文件的数据,因为都是IO密集型任务,可以启动几十个线程并发地读取,读到内存中后,还需要存储到数据库中,而数据库的连接数只有10个,那么就可以控制这几十个线程只有10个线程同时获取数据库连接来保存数据。这个时候,就可以使用Semaphore来做流量控制。
ThreadLocal
线程本地变量。当使用ThreadLocal
维护变量时,ThreadLocal
为每个使用该变量的线程提供独立的变量副本,所以每一个线程都可以独立地改变自己的副本,而不会影响其它线程。
ThreadLocal原理
每个线程都有一个ThreadLocalMap
(ThreadLocal
内部类),Map中元素的键为ThreadLocal
,而值对应线程的变量副本。
调用threadLocal.set()
-->调用getMap(Thread)
-->返回当前线程的ThreadLocalMap<ThreadLocal, value>
-->map.set(this, value)
,this是threadLocal
本身。源码如下:
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
}
ThreadLocalMap getMap(Thread t) {
return t.threadLocals;
}
void createMap(Thread t, T firstValue) {
t.threadLocals = new ThreadLocalMap(this, firstValue);
}
调用get()
-->调用getMap(Thread)
-->返回当前线程的ThreadLocalMap<ThreadLocal, value>
-->map.getEntry(this)
,返回value
。源码如下:
public T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T) e.value;
return result;
}
}
return setInitialValue();
}
threadLocals
的类型ThreadLocalMap
的键为ThreadLocal
对象,因为每个线程中可有多个threadLocal
变量,如longLocal
和stringLocal
。
public class ThreadLocalDemo {
ThreadLocal<Long> longLocal = new ThreadLocal<>();
public void set() {
longLocal.set(Thread.currentThread().getId());
}
public Long get() {
return longLocal.get();
}
public static void main(String[] args) throws InterruptedException {
ThreadLocalDemo threadLocalDemo = new ThreadLocalDemo();
threadLocalDemo.set();
System.out.println(threadLocalDemo.get());
Thread thread = new Thread(() -> {
threadLocalDemo.set();
System.out.println(threadLocalDemo.get());
}
);
thread.start();
thread.join();
System.out.println(threadLocalDemo.get());
}
}
ThreadLocal
并不是用来解决共享资源的多线程访问问题,因为每个线程中的资源只是副本,不会共享。因此ThreadLocal
适合作为线程上下文变量,简化线程内传参。
ThreadLocal内存泄漏的原因?
每个线程都有⼀个ThreadLocalMap
的内部属性,map的key是ThreaLocal
,定义为弱引用,value是强引用类型。垃圾回收的时候会⾃动回收key,而value的回收取决于Thread对象的生命周期。一般会通过线程池的方式复用线程节省资源,这也就导致了线程对象的生命周期比较长,这样便一直存在一条强引用链的关系:Thread
--> ThreadLocalMap
-->Entry
-->Value
,随着任务的执行,value就有可能越来越多且无法释放,最终导致内存泄漏。
解决⽅法:每次使⽤完ThreadLocal
就调⽤它的remove()
⽅法,手动将对应的键值对删除,从⽽避免内存泄漏。
ThreadLocal使用场景有哪些?
- Web 应用中的请求处理:在 Web 应用中,一个请求通常会被多个线程处理,每个线程需要访问自己的数据,使用 ThreadLocal 可以确保数据在每个线程中的独立性。
- 线程池中的线程对象共享数据:线程池中的线程对象是可以被多个任务共享的,如果线程对象中需要保存任务相关的数据,使用 ThreadLocal 可以保证线程安全。
- 在数据库连接管理中,ThreadLocal可以为每个线程保持独立的数据库连接,提高并发性能。
- 在日志记录中,ThreadLocal可以将日志记录与当前线程关联起来,方便追踪和排查问题。
ThreadLocal 存在的问题?
面试问的不多,但如果被问ThreadLocal 时,你主动提到ThreadLocal 存在的问题,并说出你对Java21中scoped values 的理解,一定是一个大加分项,详细内容可以查看scoped-values
ThreadLocal 存在的问题:
- 内存泄漏:在用完ThreadLocal之后若没有调用remove,这样就会出现内存泄漏。
- 增加开销:在具有继承关系的线程中,子线程需要为父线程中ThreadLocal里面的数据分配内存。
- 权限问题:任何可以调用ThreadLocal中get方法的代码都可以随时调用set方法,这样就不易辨别哪些方法是按照什么顺序来更新的共享数据,并且这些方法也都有权限给
ThreadLocal
赋值。
随着虚拟线程的到来,内存泄漏问题就不用担心了,由于虚拟线程会很快的终止,此时会自动删除ThreadLocal中的数据,这样就不用调用remove方法了。但虚拟线程的数量通常是多的,试想下上百万个虚拟线程都要拷贝一份ThreadLocal中的变量,这会使内存承受更大的压力。为了解决这些问题,scoped values就出现了。scoped values 是一个隐藏的方法参数,只有方法可以访问scoped values,它可以让两个方法之间传递参数时无需声明形参。
scoped values 也是Java21的新特性,每个线程都能访问自己的scope value,与ThreadLocal不同的是,它只会被write 1次且仅在线程绑定的期间内有效。
什么是Future?
在并发编程中,不管是继承thread类还是实现runnable接口,都无法保证获取到之前的执行结果。通过实现Callback接口,并用Future可以来接收多线程的执行结果。
Future表示一个可能还没有完成的异步任务的结果,针对这个结果可以添加Callback以便在任务执行成功或失败后作出相应的操作。
举个例子:比如去吃早点时,点了包子和凉菜,包子需要等3分钟,凉菜只需1分钟,如果是串行的一个执行,在吃上早点的时候需要等待4分钟,但是因为你在等包子的时候,可以同时准备凉菜,所以在准备凉菜的过程中,可以同时准备包子,这样只需要等待3分钟。Future就是后面这种执行模式。
Future接口主要包括5个方法:
- get()方法可以当任务结束后返回一个结果,如果调用时,工作还没有结束,则会阻塞线程,直到任务执行完毕
- get(long timeout,TimeUnit unit)做多等待timeout的时间就会返回结果
- cancel(boolean mayInterruptIfRunning)方法可以用来停止一个任务,如果任务可以停止(通过mayInterruptIfRunning来进行判断),则可以返回true,如果任务已经完成或者已经停止,或者这个任务无法停止,则会返回false。
- isDone()方法判断当前方法是否完成
- isCancel()方法判断当前方法是否取消
Callable 和 Future 有什么关系?
我们可以通过 FutureTask
来理解 Callable
和 Future
之间的关系。
FutureTask
提供了 Future
接口的基本实现,常用来封装 Callable
和 Runnable
,具有取消任务、查看任务是否执行完成以及获取任务执行结果的方法。ExecutorService.submit()
方法返回的其实就是 Future
的实现类 FutureTask
。
<T> Future<T> submit(Callable<T> task);
Future<?> submit(Runnable task);
FutureTask
不光实现了 Future
接口,还实现了Runnable
接口,因此可以作为任务直接被线程执行。
FutureTask
有两个构造函数,可传入 Callable
或者 Runnable
对象。实际上,传入 Runnable
对象也会在方法内部转换为Callable
对象。
public FutureTask(Callable<V> callable) {
if (callable == null)
throw new NullPointerException();
this.callable = callable;
this.state = NEW;
}
public FutureTask(Runnable runnable, V result) {
// 通过适配器RunnableAdapter来将Runnable对象runnable转换成Callable对象
this.callable = Executors.callable(runnable, result);
this.state = NEW;
}
FutureTask
相当于对Callable
进行了封装,管理着任务执行的情况,存储了 Callable
的 call
方法的任务执行结果。
Future与FutureTask的区别?
- Future是一个接口,FutureTask是一个实现类;
- 使用Future初始化一个异步任务结果一般需要搭配线程池的submit,且submit方法有返回值;而初始化一个FutureTask对象需要传入一个实现了Callable接口的类的对象,直接将FutureTask对象submit给线程池,无返回值;
- Future + Callable获取结果需要Future对象的get,而FutureTask获取结果直接用FutureTask对象的get方法即可。
CompletableFuture 类有什么用?
Future
在实际使用过程中存在一些局限性比如不支持异步任务的编排组合、获取计算结果的 get()
方法为阻塞调用。
Java 8 才被引入CompletableFuture
类可以解决Future
的这些缺陷。CompletableFuture
除了提供了更为好用和强大的 Future
特性之外,还提供了函数式编程、异步任务编排组合(可以将多个异步任务串联起来,组成一个完整的链式调用)等能力。
下面我们来简单看看 CompletableFuture
类的定义。
public class CompletableFuture<T> implements Future<T>, CompletionStage<T> {
}
可以看到,CompletableFuture
同时实现了 Future
和 CompletionStage
接口。
CompletionStage
接口描述了一个异步计算的阶段。很多计算可以分成多个阶段或步骤,此时可以通过它将所有步骤组合起来,形成异步计算的流水线。
详情可以查看:CompletableFuture详解