并发编程理论基础

什么是线程同步?

线程同步是指在多线程环境下,为了避免多个线程对共享资源进行同时访问,从而引发数据不一致或其他问题的一种机制。

它通过对关键代码段加锁,使得同一时刻只有一个线程能够访问共享资源当多个线程共享同一资源(如变量、对象或文件)时,若没有同步机制,可能会导致竞态条件,即线程对共享资源的操作是非原子性的,多个线程之间可能会同时修改数据,导致结果不符合预期。

线程安全是什么意思?

线程安全是指多个线程访问某一共享资源时,能够保证一致性和正确性,即无论线程如何交替执行,程序都能够产生预期的结果,且不会出现数据竞争或内存冲突。

在Java 中,线程安全的实现通常依赖于同步机制和线程隔离技术

聊聊JMM内存模型?

相关内容看这篇文章并发编程理论基础,整篇文章内容都很重要

java内存模型(即 java Memory Model,简称JMM),不存在的东西,是一个概念,约定

主要分成两部分来看,一部分叫做主内存,另一部分叫做工作内存。

  • java当中的共享变量;都放在主内存当中,如类的成员变量(实例变量),还有静态的成员变量(类变量),都是存储在主内存中的。每一个线程都可以访问主内存;

  • 每一个线程都有其自己的工作内存,当线程要执行代码的时候,就必须在工作内存中完成。比如线程操作共享变量,它是不能直接在主内存中操作共享变量的,只能够将共享变量先复制一份,放到线程自己的工作内存当中,线程在其工作内存对该复制过来的共享变量处理完后,再将结果同步回主内存中去。

主内存是 所有线程都共享的,都能访问的。所有的共享变量都存储于主内存;共享变量主要包括类当中的成员变量,以及一些静态变量等。局部变量是不会出现在主内存当中的,因为局部变量只能线程自己使用;工作内存每一个线程都有自己的工作内存,工作内存只存储 该线程对共享变量的副本。线程对变量的所有读写操作都必须在工作内存中完成,而不能直接读写主内存中的变量,不同线程之间也不能直接访问 对方工作内存中的 变量;线程对共享变量的操作都是对其副本进行操作,操作完成之后再同步回主内存当中去;

JMM的同步约定:

  • 线程解锁前,必须把共享变量立刻刷回主存

  • 线程加锁前,必须读取主存中的最新值到工作内存中

  • 加锁和解锁是同一把锁

什么是 Java 的 happens-before 规则?

happens-before 规则是Java 内存模型 (Java Memory Model,JMM)中的核心概念

用于定义多线程程序中操作的可见性和顺序性。它通过指定系列操作之间的顺序关系确保线程间的操作是有序的,避免由于重排序或线程间数据不可见导致的并发问题。

happens-before 规则的主要规则:

  • 程序次序规则:在一个线程中,代码的执行顺序是按照程序中的书写顺序执行的,即一个线程内,前面的操作 happens-before 后面的操作。

  • 监视器锁规则:一个锁的解锁(unlock)操作 happens-before 后续对这个锁的加锁(lock)操作。也就是说,在释放锁之前的所有修改在加锁后对其他线程可见。

  • volatile变量规则:对一个 volatile变量的写操作happens-before后续对这个 volatile 变量的读操作。它保证 volatile 变量的可见性,确保一个线程修改volatile变量后,其他线程能立即看到最新值。

  • 线程启动规则:线程A执行 Thread.start()操作后,线程B中的所有操作 happens-before 线程A的 Thread.start()调用。

  • 线程终止规则:线程A执行 Thread.join()操作后,线程B中的所有操作 happens-before 线程A从 Thread.join()返回。

  • 线程中断规则:对线程的 interrupt()调用 happens-before 线程检测到中断事件(通过 Thread.interrupted()或 Thread.isInterrupted()。

  • 对象的构造规则:对象的构造完成(即构造函数执行完毕)happens-before 该对象的 finalize()方法调用。

如何理解Java并发中的原子性?

原子性(Atomicity): 在一次或多次操作中,要么所有的操作都执行,并且不会受其他因素干扰而中断,要么所有的操作都不执行;

原子性问题是由CPU的分时复用引起的。

这里需要注意的是:i += 1需要三条 CPU 指令

  1. 将变量 i 从内存读取到 CPU寄存器;

  2. 在CPU寄存器中执行 i + 1 操作;

  3. 将最后的结果i写入内存(缓存机制导致可能写入的是 CPU 缓存而不是内存)。

由于CPU分时复用(线程切换)的存在,线程1执行了第一条指令后,就切换到线程2执行,假如线程2执行了这三条指令后,再切换会线程1执行后续两条指令,将造成最后写到内存中的i值是2而不是3。

解决原子性:Java内存模型只保证了基本读取和赋值是原子性操作,如果要实现更大范围操作的原子性,可以通过synchronized和Lock来实现。由于synchronized和Lock能够保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了,从而保证了原子性。

CPU分时复用: 微观上轮流独占:操作系统将CPU的运行时间划分为极短的间隔,称为时间片。每个时间片通常只有几十毫秒,在单个时间片内,CPU核心被一个线程独占。 宏观上并发运行:操作系统通过调度器,让多个线程在一个稍长时间内段内轮流使用CPU。由于CPU速度极快,时间片切换频繁,从用户角度看,就好像多个线程在同时运行

如何理解Java并发中的可见性?

可见性:是指当一个线程对共享变量进行了修改,那么另外的线程可以立即看到修改后的最新值。

可见性问题是由CPU缓存引起的

解决可见性:

在共享变量前面加上volatile关键字修饰;volatile 的底层实现原理是内存屏障(Memory Barrier),保证了对 volatile 变量的写指令后会加入写屏障,对 volatile 变量的读指令前会加入读屏障。

  • 写屏障(sfence)保证在写屏障之前的,对共享变量的改动,都同步到主存当中;

  • 读屏障(lfence)保证在读屏障之后,对共享变量的读取,加载的是主存中最新数据;

1.,通过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缓存一致性决定)。这种重复的缓存行无效化和重新加载的现象就是伪共享。

它的本质问题在于:虽然多个线程操作的是物理上不同的数据,但由于它们共享了同一个缓存行,造成不必要的缓存同步,导致性能下降。

代码示例:

ValuePadding 类的设计:

  • 这里使用了ValuePadding类来存储每个线程要操作的共享变量value。

  • 通过在value字段的前后添加几个填充字段(p1到p7和p9到p15),每个ValuePadding对象会占据多个缓存行,从而使value与相邻的对象实际分离至不同的缓存行。

  • 这可以有效防止多个线程不必要地共享同一缓存行(即避免伪共享),从而提升程序的并发性能。

主程序的执行流程:

  • 主线程启动了多个工作线程,每个线程负责增加自己的ValuePadding实例中的value。

  • 每个线程访问和更新values数组中的一个ValuePadding实例,由于填充字段的存在,每个线程访问的value字段分布在不同缓存行上,尽可能减少缓存争用。

代码运行效率:

  • 如果不采用填充策略,不同线程可能会因共享同一缓存行而导致频繁的缓存无效化和重新加载,造成性能的严重下降。

  • 通过填充确保每个value字段在不同缓存行上,甚至在高并发下也能高效执行,从而减少伪共享。

伪共享问题的解决尤其适合在性能敏感的大规模并发应用中。通过了解缓存行的基本原理并采取适当的填充策略,可以有效减少缓存行争用带来的性能开销,从而在不改变逻辑操作的前提下实现更佳的性能。注意,Java8还提供@Contended注解用于更高级的填充,但使用它需要相应的JVM参数配置支持。

如何优化 Java 中的锁的使用?

主要有以下两种常见的优化方法:

减小锁的粒度(使用的时间)

  1. 尽量缩小加锁的范围,减少锁的持有时间。即在必要的最小代码块内使用锁,避免对整个方法或过多代码块加锁。

  2. 使用更细粒度的锁,比如将一个大对象锁拆分为多个小对象锁,以提高并行度(参考 HashTable和concurrentHashap的区别)。

  3. 对于读多写少的场景,可以使用读写锁(ReentrantReadwriteLock)代替独占锁。

减少锁的使用:

  1. 通过无锁编程、CAS(Compare-And-Swap)操作和原子类(如AtomicInteger、AtomicReference )来避免使用锁,从而减少锁带来的性能损耗。

  2. 通过减少共享资源的使用,避免线程对同一个资源的竞争。例如,使用局部变量或线程本地变量(Threadloca1)来减少多个线程对同一资源的访问,

进程线程

详细内容请查看:线程基础

什么是线程和进程?

何为进程?

进程是程序的一次执行过程,是系统运行程序的基本单位,因此进程是动态的。系统运行一个程序即是一个进程从创建,运行到消亡的过程。

在 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() 方法。

实现 Runnable 接口

需要实现 run() 方法。

通过 Thread 调用 start() 方法来启动线程。

实现Runnable接口比继承Thread类所具有的优势:

  1. 可以避免java中的单继承的限制

  2. 线程池只能放入实现Runable或Callable类线程,不能直接放入继承Thread的类

实现 Callable 接口

与 Runnable 相比,Callable 可以有返回值,返回值通过 FutureTask 进行封装。

或者可以使用线程池进行执行

使用 Executor 创建线程代码

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

判断目标线程是否被中断,会清除中断标记。

可以直接调用 run 方法吗?

  • 当程序调用start()方法,将会创建一个新线程去执行run()方法中的代码。run()就像一个普通方法一样,直接调用run()的话,不会创建新线程,会把 run() 方法当成一个 main 线程下的普通方法去执行,并不会在某个线程中执行它,所以这并不是多线程工作。。

  • 一个线程的 start() 方法只能调用一次,多次调用会抛出 java.lang.IllegalThreadStateException 异常。run() 方法则没有限制。

线程调用2次start会怎样?

会抛出IllegalThreadStateException()异常

虽然Java创建线程的方式有3种、4种,或者更多种,但实际上底层都是new Thread() ;当创建完一个Thread,这时线程处于NEW状态,这时调用start()方法,会让线程进入到RUNNABLE状态。

如果在RUNNABLE的线程再次调用start呢?其实就会造成线程状态的异常。

其实是底层源码在调用start()方法的时候 针对状态做了判断, 如果不是NEW状态就会报线程状态的异常。

如果线程执行完,再调用一次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状态。

Thread.sleep和 Thread.yield 的区别?

Thread.sleep()和 Thread.yield()都是用于控制线程行为的两个方法

Thread.sleep():使当前线程进入休眠状态(TIME_WAITING状态),暂停执行指定的时间(以毫秒为单位)。在休眠期间,线程不会占用 CPU 时间片,休眠结束后,线程会尝过重新获取CPU 时间片,进入可运行状态·,休眠时间取决于操作系统的计时器精度,可能会有轻微的误差。

Thread.yield():提示当前线程原意让出 CPU时间片给其他线程,调用 yield() 后,线程会进入 RUNNABLE状态,但没有阻塞、调度器会尝试将 CPU时间片分可给相同优先级的其他线程,如果没有其他合适的线程,当前线程可能会继续执行。yield()只是一个提示,操作系统的线程调度器可以选择忽略它。它并不会使线程进入阻塞状态,线程依然处于RUNNABLE状态。

sleep(0) 的作用是什么?

在线程中,调用sleep(0)可以释放cpu时间,让线程马上重新回到就绪队列而非等待队列,sleep(0)释放当前线程所剩余的时间片(如果有剩余的话),这样可以让操作系统切换其他线程来执行,提升效率。

Thread.Sleep(0) 并非是真的要线程挂起0毫秒,意义在于这次调用Thread.Sleep(0)的当前线程确实的被冻结了一下,让其他线程有机会优先执行。Thread.Sleep(0) 是你的线程暂时放弃cpu,也就是释放一些未用的时间片给其他线程或进程使用,就相当于一个让位动作。其实就等同于yield的用法

但是sleep()不会释放线程已持有的任何锁(如 synchronized 同步代码块或方法中获取的锁)。因此,如果有其他线程试图获取同一把锁,它们仍会被阻塞,直到原线程退出同步代码块。

Thread.sleep()和Object.wait()的区别?

相同点

  1. 它们都可以使当前线程暂停运行,把机会交给其他线程

  2. 任何线程在调用wait()和sleep()之后,在等待期间被中断都会抛出InterruptedException

不同点

  1. wait()是Object超类中的方法;而sleep()是线程Thread类中的方法

  2. 对锁的持有不同,wait()会释放锁,而sleep()并不释放锁

  3. 唤醒方法不完全相同,wait()依靠notify或者notifyAll 、中断、达到指定时间来唤醒;而sleep()到达指定时间被唤醒

  4. 调用wait()需要先获取对象的锁,而Thread.sleep()不用

如果在wait()之前执行了notify()会怎样?

如果当前的线程不是此对象锁的所有者,却调用该对象的notify()或wait()方法时抛出IllegalMonitorStateException异常;

如果当前线程是此对象锁的所有者,wait()将一直阻塞,因为后续将没有其它notify()唤醒它。

为什么 wait() 方法不定义在 Thread 中?

wait() 是让获得对象锁的线程实现等待,会自动释放当前线程占有的对象锁。每个对象(Object)都拥有对象锁,既然要释放当前线程占有的对象锁并让其进入 WAITING 状态,自然是要操作对应的对象(Object)而非当前的线程(Thread)。

类似的问题:为什么 sleep() 方法定义在 Thread 中?

因为 sleep() 是让当前线程暂停执行,不涉及到对象类,也不需要获得对象锁。

为什么wait()、notify()等方法在Object类中,而不在Thread类中?

每个对象天然具备监视器(Monitor)​​ :Java中所有对象都隐式关联一个监视器锁(通过对象头的Mark Word实现),这是实现同步的基础。当线程调用synchronized方法或代码块时,它实际上是在竞争对象的监视器锁。wait()notify()的操作本质上是与这个对象的锁状态交互:

  • wait():释放当前对象的锁,使线程进入等待队列(WaitSet

  • notify():唤醒在同一个对象监视器上等待的线程

由于锁是对象级别的,这些方法必须与具体对象绑定,而非线程本身

监视器的实现依赖对象​ :在JVM底层(如HotSpot),对象的监视器通过ObjectMonitor结构实现,包含_owner(锁持有者)、_WaitSet(等待队列)等关键字段。线程的阻塞和唤醒操作直接作用于对象的监视器,而非线程实例。

假设wait()notify()Thread类中,会导致以下问题:

  1. 锁与资源解耦​ :线程需直接操作其他线程实例,例如threadA.wait(),这使同步逻辑分散且难以维护

  2. 无法支持多条件变量​ :一个对象可能有多个等待条件(如队列的空和满)。通过不同对象的监视器,可以创建多个条件队列,而Thread类无法实现这种灵活性

notify()和 notifyAll()有什么区别?

在Java中,notify()和notifyAll()都属于Object类的方法,用于实现线程间的通信。

  • notify()方法用于唤醒在当前对象上等待的单个线程。如果有多个线程同时在某个对象上等待(通过调用该对象的wait()方法),则只会唤醒其中一个线程,并使其从等待状态变为可运行状态。具体是哪个线程被唤醒是不确定的,取决于线程调度器的实现。

  • notifyAll()方法用于唤醒在当前对象上等待的所有线程。如果有多个线程在某个对象上等待,调用notifyAll()方法后,所有等待的线程都会被唤醒并竞争该对象的锁。其中一个线程获得锁后继续执行,其他线程则继续等待。

需要注意的是,notify()和notifyAll()方法只能在同步代码块或同步方法内部调用,并且必须拥有与该对象关联的锁。否则会抛出IllegalMonitorStateException异常。

JAVA 中用到的线程调度算法是什么?

Java 线程调度机制主要包括以下几种:

  1. 抢占式调度(Preemptive Scheduling):在这种调度方式下,操作系统会根据优先级和时间片(time slice)来决定哪个线程可以运行。
  • 优先级抢占:高优先级的线程可以抢占低优先级线程的 CPU 时间。

  • 时间片轮转:每个线程会被分配一个时间片,当时间片用完时,线程会被挂起,操作系统会选择另一个线程来运行。

  1. 协作式调度(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、使用 ReentrantLockConditionReentrantLock 提供了比 synchronized 更加灵活的锁机制。结合 Condition 类,可以实现类似 wait()notify() 的功能。

5、使用JUC工具类 CountDownLatch、Semaphore、Exchanger。jdk1.5 之后在java.util.concurrent包下提供了很多并发编程相关的工具类,简化了并发编程开发,CountDownLatch 基于 AQS 框架,相当于也是维护了一个线程间共享变量 state。

6、基于 LockSupport 实现线程间的阻塞和唤醒。LockSupport 是一种非常灵活的实现线程间阻塞和唤醒的工具,使用它不用关注是等待线程先进行还是唤醒线程先运行,但是得知道线程的名字。

主线程如何知晓创建的子线程是否执行成功?

  • 使用 Thread.join():主线程通过调用 join()方法等待子线程执行完毕。子线程正常结束,说明执行成功,若抛出异常则需要捕获处理。

  • 使用 Callable 和 Future:通过 Callable 创建可返回结果的任务,并通过 Future.get()获取子线程的执行结果或捕获异常。Future.get()会阻塞直到任务完成,若任务正常完成,返回结果,否则抛出异常,

  • 使用回调机制:可以通过自定义回调机制,主线程传入一个回调函数,子线程完成后调用该函数并传递执行结果。这样可以非阻塞地通知主线程任务完成情况。

  • 使用 countDownLatch 或其他 JUC 相关类:主线程通过 countDownlatch 来等待子线程完成。当子线程执行完毕后调用 countDownlatch(),主线程通过 await()等待子线程完成任务。

父子线程之间如何共享传递数据

  1. 父线程直接通过方法形参传入给子线程

  2. 使用 InheritableThreadLocal:InheritableThreadLocal是ThreadLocal的一个子类,大部分的实现原理和ThreadLocal是一样的。

  3. 使用Concurrent 容器:Java提供了一些线程安全的集合类,如ConcurrentHashMap、CopyOnWriteArrayList等,可以安全并发地访问和修改,适用于需要较多线程共享数据的场景。

  4. 使用消息队列 :在复杂的多线程环境下,使用Java的阻塞队列(如BlockingQueue)可以在父子线程之间传递数据,并控制线程的执行顺序。

什么是线程死锁?

线程死锁是指两个或两个以上的线程在执行过程中,因争夺资源而造成的一种互相等待的现象。若无外力作用,它们都将无法推进下去。

如下图所示,线程 A 持有资源 2,线程 B 持有资源 1,他们同时都想申请对方持有的资源,所以这两个线程就会互相等待而进入死锁状态。 死锁死锁 下面通过例子说明线程死锁,代码来自并发编程之美。

代码输出如下:

线程 A 通过 synchronized (resource1) 获得 resource1 的监视器锁,然后通过 Thread.sleep(1000)。让线程 A 休眠 1s 为的是让线程 B 得到执行然后获取到 resource2 的监视器锁。线程 A 和线程 B 休眠结束了都开始企图请求获取对方的资源,然后这两个线程就会陷入互相等待的状态,这也就产生了死锁。

线程死锁怎么产生?怎么避免?

死锁产生的四个必要条件:也就是说只要系统发生死锁,这些条件必然成立,而只要上述条件之一不满足,就不会发生死锁。

  • 互斥:一个资源每次只能被一个进程使用

  • 请求与保持:一个进程因请求资源而阻塞时,不释放获得的资源

  • 不可剥夺:进程已获得的资源,在未使用之前,不能强行剥夺

  • 循环等待:进程之间循环等待着资源

避免死锁的方法

  • 互斥条件不能破坏,因为加锁就是为了保证互斥

  • 破坏请求与保持条件:一次性申请所有的资源。

  • 破坏不可剥夺条件:占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。

  • 破坏循环等待条件:靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。破坏循环等待条件。

具体到开发环节,即:

  • 要注意加锁顺序,保证每个线程按同样的顺序进行加锁

  • 要注意加锁时限,可以针对所设置一个超时时间

  • 要注意死锁检查,这是一种预防机制,确保在第一时间发现死锁并进行解决

死锁与活锁,死锁与饥饿的区别

死锁是指多个线程相互等待对方释放资源,导致它们都无法继续执行下去。这是一种静止状态,这种情况会导致所有线程都被永久性地阻塞,没有一个线程能够继续执行。就像交通堵塞一样,没有车辆能够前进。

活锁是指多个线程不断地改变自己的状态以回应对方,但最终无法取得进展,导致线程不断重试相同的操作,却无法成功。这是一种运行时状态,线程在持续地执行,但任务不会向前推进。活锁通常发生在线程在避免冲突时不断改变状态,但却没有成功,就像两个人在狭窄的道路上不断让对方走,却无法通过一样。

饥饿是指一个或多个线程或进程由于某种原因无法获得所需的资源或执行机会,因此无法适时地执行。这是一种动态问题,通常由资源分配不合理或线程优先级设置不当等原因导致。在饥饿中,线程不一定被永久性地阻塞,但是它们可能长时间无法获得所需的资源。就像一个人在繁忙的自助餐厅排队等待很长时间,但一直无法获得食物一样。

了解虚拟线程吗?

Java21 的新特性,面试问的不多,但你懂了别人不懂,也是优势;例如面试官可能会问ThreadLocal,那么这是可以引出虚拟线程的。详细可以查看:虚拟线程详解

线程池

线程池:一个管理线程的池子。

为什么要用线程池创建线程?

池化技术想必大家已经屡见不鲜了,线程池、数据库连接池、HTTP 连接池等等都是对这个思想的应用。池化技术的思想主要是为了减少每次获取资源的消耗,提高对资源的利用率。

手动创建线程有两个缺点:

  1. 不受控风险

  2. 频繁创建开销大

为什么不受控

系统资源有限,每个人针对不同业务都可以手动创建线程,并且创建线程没有统一标准,比如创建的线程有没有名字等。当系统运行起来,所有线程都在抢占资源,毫无规则,混乱场面可想而知,不好管控。

频繁手动创建线程为什么开销会大?跟new Object() 有什么差别?

虽然Java中万物皆对象,但是new Thread() 创建一个线程和 new Object()还是有区别的。

new Object()过程如下:

  1. JVM分配一块内存 M

  2. 在内存 M 上初始化该对象

  3. 将内存 M 的地址赋值给引用变量 obj

创建线程的过程如下:

  1. JVM为一个线程栈分配内存,该栈为每个线程方法调用保存一个栈帧

  2. 每一栈帧由一个局部变量数组、返回值、操作数堆栈和常量池组成

  3. 每个线程获得一个程序计数器,用于记录当前虚拟机正在执行的线程指令地址

  4. 系统创建一个与Java线程对应的本机线程

  5. 将与线程相关的描述符添加到JVM内部数据结构中

  6. 线程共享堆和方法区域

创建一个线程大概需要1M左右的空间(Java8,机器规格2c8G)。可见,频繁手动创建/销毁线程的代价是非常大的。

为什么使用线程池?

  • 降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。

  • 提高响应速度。当任务到达时,可以不需要等到线程创建就能立即执行。

  • 提高线程的可管理性。统一管理线程,避免系统创建大量同类线程而导致消耗完内存。

线程池中线程复用原理

线程池的线程复用原理是指,将线程放入线程池中重复利用,而不是每执行一个任务就创建一个新线程。线程池会对线程进行封装,核心原理在于将线程的创建和管理与任务的执行分离。

线程池通过工作队列(WorkQueue)来存储待执行的任务,队列中可能有多个任务等待被执行。

线程复用的关键是将任务的提交和线程的创建、管理、执行分离,通过线程池来统一管理和调度,减少了创建和销毁线程的开销,提高了系统的效率。同时,由于线程池的复用特性,可以有效控制并发度,避免大量线程的创建和销毁导致的系统负载过大。

线程池执行原理?

线程池执行流程线程池执行流程

  1. 当线程池里存活的线程数小于核心线程数corePoolSize时,这时对于一个新提交的任务,线程池会创建一个线程去处理任务。当线程池里面存活的线程数小于等于核心线程数corePoolSize时,线程池里面的线程会一直存活着,就算空闲时间超过了keepAliveTime,线程也不会被销毁,而是一直阻塞在那里一直等待任务队列的任务来执行。

  2. 当线程池里面存活的线程数已经等于corePoolSize了,这是对于一个新提交的任务,会被放进任务队列workQueue排队等待执行。

  3. 当线程池里面存活的线程数已经等于corePoolSize了,并且任务队列也满了,假设maximumPoolSize>corePoolSize,这时如果再来新的任务,线程池就会继续创建新的线程来处理新的任务,知道线程数达到maximumPoolSize,就不会再创建了。

  4. 如果当前的线程数达到了maximumPoolSize,并且任务队列也满了,如果还有新的任务过来,那就直接采用拒绝策略进行处理。默认的拒绝策略是抛出一个RejectedExecutionException异常。

线程池参数有哪些?

ThreadPoolExecutor 的通用构造函数:

1、corePoolSize:当有新任务时,如果线程池中线程数没有达到线程池的基本大小,则会创建新的线程执行任务,否则将任务放入阻塞队列。当线程池中存活的线程数总是大于 corePoolSize 时,应该考虑调大 corePoolSize。

2、maximumPoolSize:当阻塞队列填满时,如果线程池中线程数没有超过最大线程数,则会创建新的线程运行任务。否则根据拒绝策略处理新任务。非核心线程类似于临时借来的资源,这些线程在空闲时间超过 keepAliveTime 之后,就应该退出,避免资源浪费。

3、BlockingQueue:存储等待运行的任务。

4、keepAliveTime非核心线程空闲后,保持存活的时间,此参数只对非核心线程有效。设置为0,表示多余的空闲线程会被立即终止。

5、TimeUnit:时间单位

6、ThreadFactory:每当线程池创建一个新的线程时,都是通过线程工厂方法来完成的。在 ThreadFactory 中只定义了一个方法 newThread,每当线程池需要创建新线程就会调用它。

7、RejectedExecutionHandler:当队列和线程池都满了的时候,根据拒绝策略处理新任务。

keepalive对coreSize是否起作用?

对coreSize不起作用, 只是对非核心线程部分起作用。如果线程池正常运转后处于空闲状态时,即使当前没有任务,还是有coreSize个线程存活

线程池的拒绝策略有哪些?

如果当前同时运行的线程数量达到最大线程数量并且队列也已经被放满了任务时,ThreadPoolExecutor 定义一些策略:

  • ThreadPoolExecutor.AbortPolicy:抛出 RejectedExecutionException来拒绝新任务的处理。

  • ThreadPoolExecutor.CallerRunsPolicy:调用执行自己的线程运行任务,也就是直接在调用execute方法的线程中运行(run)被拒绝的任务,如果执行程序已关闭,则会丢弃该任务。因此这种策略会降低对于新任务提交速度,影响程序的整体性能。如果你的应用程序可以承受此延迟并且你要求任何一个任务请求都要被执行的话,你可以选择这个策略。

  • ThreadPoolExecutor.DiscardPolicy:不处理新任务,直接丢弃掉。

  • ThreadPoolExecutor.DiscardOldestPolicy:此策略将丢弃最早的未处理的任务请求。

如何自定义拒绝策略

可以实现 RejectedExecutionHandler 接口来定义自定义的拒绝策略。例如,记录日志或将任务重新排队。

如果不允许丢弃任务任务,应该选择哪个拒绝策略?

CallerRunsPolicy :会采用主线程执行任务, 但是如果任务非常耗时会阻塞主线程,在高并发场景慎用!

结合CallerRunsPolicy 的源码来看看:

从源码可以看出,只要当前程序不关闭就会使用执行execute方法的线程执行该任务。

CallerRunsPolicy 拒绝策略有什么风险?如何解决?

如果走到CallerRunsPolicy的任务是个非常耗时的任务,且处理提交任务的线程是主线程,可能会导致主线程阻塞,影响程序的正常运行,严重的情况下很可能导致 OOM。

当然,采用CallerRunsPolicy其实就是希望所有的任务都能够被执行,暂时无法处理的任务又被保存在阻塞队列BlockingQueue中。这样的话,在内存允许的情况下,就可以增加阻塞队列BlockingQueue的大小并调整堆内存以容纳更多的任务,确保任务能够被准确执行。为了充分利用 CPU,还可以调整线程池的maximumPoolSize (最大线程数)参数,这样可以提高任务处理速度,避免累计在 BlockingQueue的任务过多导致内存用完。

但是,如果服务器资源达到可利用的极限了呢?导致主线程卡死的本质就是因为不希望任何一个任务被丢弃。换个思路,有没有办法既能保证任务不被丢弃且在服务器有余力时及时处理呢?

  1. 可以考虑任务持久化的思路,例如可以采用mysql、redis、或者mq异步 等方案持久化, 后续再对任务进行补偿执行。

  2. 可以参考netty:会再创建新的一个异步线程处理任务。

  3. 可以参考ActiveMQ: 再次插入阻塞队列, 会加入等待时间, 尽可能的保证执行。

线程池大小怎么设置?

如果线程池线程数量太小,当有大量请求需要处理,系统响应比较慢,会影响用户体验,甚至会出现任务队列大量堆积任务导致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。

注意:一般需要通过压力测试来进行微调,只有经过压测的检验,才能最终保证的配置大小是准确的。具体关注:执行这个方法的QPS多少?有多少台机器?每台机器分担多少QPS?一个任务执行完需要多久?响应时间要求多久? 通过这些参数才是能计算出线程池数量大小

同时,再结合具体的项目情况,打监控检查线程池健康状态,进行动态调整

1000 个任务,每个任务 0.1s,最大响应时间 1s,线程池参数怎么设置?

题目信息不足,那就顶着不足的信息直接回答,不要强加 CPU 核数、IO密集、CPU 密集等概念限制自己的思考,在直接回答答案后再提出这些概念即可。(还有一种方式就是继续和面试沟通追问,具象化题目再回答

分析题目的意思:每个任务需要花费的时间是 0.1s,线程池要在 1s 内处理完这 1000 个任务,问线程池的参数该如何设置

1个任务需要花费 0.1s,即一个线程在1s能处理10个任务(1s /0.1s =10 )。

所以需要1000(个任务)/10(单线程每秒处理任务数) = 100个线程来处理。因此线程池的核心线程数可以设置为 100。

接着要设置线程池队列数,根据题干,在队列中排队的任务需要在 1s 内响应。

队列数 = 核心线程数 * (响应时间/单任务耗时) =100* (1/0.1) =1000,即队列中最多可以有1000个任务等待,这些任务将在1秒内被处理。如果有再多的任务,那么就需要新建线程(不超过最大线程数)来处理了。

当然,这个值是不考虑实际硬件限制(如 CPU 核数)、其他影响因素(线程上下文切换、IO耗时等)的纯理论配置,在实际生产中,需要考虑机器的硬件面置,设置预期的CPU 利用率、CPU负载等因素,再通过实际的测试不断调整得到合理的线程池配置参数。

注意:这里仅回答核心线程数、队列数就差不多了,不要纠结什么拒绝策略、超时时间、最大线程数等,这是一个偏开放(约束条件很少)的面试题,不要深究那些细节,直接回答面试官想听到的计算,再补充提到这是一个纯理论即可。

你用到了线程池?那你是怎么监控线程池的健康状态的?

线程池的健康状况可以通过以下几类参数来评估。这些参数可以了解线程池的实时负载、资源利用率以及潜在风险 参数类别关键参数监控价值线程资源指标activeCount(活动线程数)反映当前正在执行任务的线程数,直接体现线程池的繁忙程度。corePoolSize& maximumPoolSize核心线程数和最大线程数,是调整的基准和上限。任务队列指标queueSize(队列大小)最重要的指标之一,表示等待执行的任务数量。持续增长表明任务生产速度大于消费速度,是任务堆积的直接信号。饱和与拒绝指标rejectedExecutionCount(拒绝任务数)当线程池和队列都饱和时,被拒绝的任务数量。任何大于0的值都需要警惕,意味着线程池已无法处理当前负载。 调整的核心目标是:在系统不被压垮的前提下,最大限度地利用资源处理任务

何时扩容(Scale Up)

  • 条件queueSize持续超过某个阈值(例如80%的队列容量)并且​ 系统资源(如CPU使用率)仍有余量(例如低于70-80%)。

  • 动作:适当增加 corePoolSizemaximumPoolSize,以便更快地处理积压的任务。

何时缩容(Scale Down)

  • 条件activeCount远低于 poolSize(即大量线程空闲)并且​ 系统资源利用率较低持续一段时间。

  • 动作:减少 corePoolSize,允许空闲线程在 keepAliveTime后回收,以节省资源

除了直接使用以上的参数监控线程池的健康状态外(一般会写个定时任务监控这些数据),还一般会重写拒绝策略的方法,从而达到监控的目的,例如重写 DiscardOldestPolicy 策略

ThreadPoolExecutor提供了动态修改核心参数的方法: API方法作用setCorePoolSize(int corePoolSize)动态调整核心线程数。如果新值小于当前值,多余的线程将在下次空闲时被回收。setMaximumPoolSize(int maximumPoolSize)动态调整线程池允许的最大线程数setKeepAliveTime(long time, TimeUnit unit)调整空闲线程的存活时间 当然了,线程池调整不能只看内部指标,还必须结合CPU使用率内存使用率IO等待时间等系统级指标,避免盲目扩容导致系统资源耗尽

线程池的类型有哪些?适用场景?

常见的线程池有 FixedThreadPoolSingleThreadExecutorCachedThreadPoolScheduledThreadPool。这几个都是 ExecutorService 线程池实例。

FixedThreadPool

固定线程数的线程池。任何时间点,最多只有 nThreads 个线程处于活动状态执行任务。

使用无界队列 LinkedBlockingQueue(队列容量为 Integer.MAX_VALUE),运行中的线程池不会拒绝任务,即不会调用RejectedExecutionHandler.rejectedExecution()方法。

maxThreadPoolSize 是无效参数,故将它的值设置为与 coreThreadPoolSize 一致。

keepAliveTime 也是无效参数,设置为0L,因为此线程池里所有线程都是核心线程,核心线程不会被回收(除非设置了executor.allowCoreThreadTimeOut(true))。

适用场景:适用于处理CPU密集型的任务,确保CPU在长期被工作线程使用的情况下,尽可能的少的分配线程,即适用执行长期的任务。需要注意的是,FixedThreadPool 不会拒绝任务,在任务比较多的时候会导致 OOM。

SingleThreadExecutor

只有一个线程的线程池。

使用无界队列 LinkedBlockingQueue。线程池只有一个运行的线程,新来的任务放入工作队列,线程处理完任务就循环从队列里获取任务执行。保证顺序的执行各个任务。

适用场景:适用于串行执行任务的场景,一个任务一个任务地执行。在任务比较多的时候也是会导致 OOM。

CachedThreadPool

根据需要创建新线程的线程池。

如果主线程提交任务的速度高于线程处理任务的速度时,CachedThreadPool 会不断创建新的线程。极端情况下,这样会导致耗尽 cpu 和内存资源。

使用没有容量的SynchronousQueue作为线程池工作队列,当线程池有空闲线程时,SynchronousQueue.offer(Runnable task)提交的任务会被空闲线程处理,否则会创建新的线程处理任务。

适用场景:用于并发执行大量短期的小任务。CachedThreadPool允许创建的线程数量为 Integer.MAX_VALUE,可能会创建大量线程,从而导致 OOM。

ScheduledThreadPoolExecutor

在给定的延迟后运行任务,或者定期执行任务。在实际项目中基本不会被用到,因为有其他方案选择比如quartz

使用的任务队列 DelayQueue 封装了一个 PriorityQueuePriorityQueue 会对队列中的任务进行排序,时间早的任务先被执行(即ScheduledFutureTasktime 变量小的先执行),如果time相同则先提交的任务会被先执行(ScheduledFutureTasksquenceNumber 变量小的先执行)。

执行周期任务步骤:

  1. 线程从 DelayQueue 中获取已到期的 ScheduledFutureTask(DelayQueue.take())。到期任务是指 ScheduledFutureTask的 time 大于等于当前系统的时间;

  2. 执行这个 ScheduledFutureTask

  3. 修改 ScheduledFutureTask 的 time 变量为下次将要被执行的时间;

  4. 把这个修改 time 之后的 ScheduledFutureTask 放回 DelayQueue 中(DelayQueue.add())。

适用场景:周期性执行任务的场景,需要限制线程数量的场景。

为什么不推荐使用内置线程池?

在《阿里巴巴 Java 开发手册》“并发处理”这一章节,明确指出线程资源必须通过线程池提供,不允许在应用中自行显式创建线程。 Executors 返回线程池对象的弊端如下:

  • FixedThreadPoolSingleThreadExecutor:使用的是无界的 LinkedBlockingQueue,任务队列最大长度为 Integer.MAX_VALUE,可能堆积大量的请求,从而导致 OOM。

  • CachedThreadPool:使用的是同步队列 SynchronousQueue, 允许创建的线程数量为 Integer.MAX_VALUE,如果任务数量过多且执行速度较慢,可能会创建大量的线程,从而导致 OOM。

  • ScheduledThreadPoolSingleThreadScheduledExecutor:使用的无界的延迟阻塞队列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()方法可以知道任务是否执行完成。

线程池中 shutdown与 shutdownNow 的区别是什么?

shutdown()和 shutdownNow()都用于关闭线程池,但工作方式有所不同:

shutdown():启动线程池的平滑关闭。它不再接受新的任务,但会继续执行已经提交的任务(包括在队列中的任务)线程池会进入 shutdown 状态,所有已执行和正在执行的任务都会继续完成,只有所有任务完成后,线程池才会完全终止。

shutdownNow():启动线程池的强制关闭,它会尝试停止所有正在执行的任务,并返回等待执行的任务列表,它会尽力中断正在执行的任务,但不能保证所有任务都能被立即停止。线程池进入 STOP状态,除了尝试中断正在执行的任务外,还会清空任务队列,返回未执行的任务列表

execute和submit的区别

execute只能提交Runnable类型的任务,无返回值。submit既可以提交Runnable类型的任务,也可以提交Callable类型的任务,会有一个类型为Future的返回值,但当任务类型为Runnable时,返回值为null。

execute在执行任务时,如果遇到异常会直接抛出,而submit不会直接抛出,只有在使用Future的get方法获取返回值时,才会抛出异常。

execute所属顶层接口是Executor,submit所属顶层接口是ExecutorService,实现类ThreadPoolExecutor重写了execute方法,抽象类AbstractExecutorService重写了submit方法。

线程池内部任务出异常后,如何知道是哪个线程出了异常?

默认情况下,线程池不会直接报告哪个线程发生了异常,但是可以采取以下几种方法:

  1. 自定义线程池的 ThreadFactory:为每个线程设置一个异常处理器(uncaughtExceptionHandler )在其中记录发生异常的线程信息。

  2. 使用 Future:提交任务时使用 submit()方法,而不是 execute(),这样可以通过 Future对象捕获并检查任务的执行结果和异常,

  3. 任务内部手动捕获异常并记录:在任务的 run()方法内部,使用 try-catch 结构捕获异常,并记录或处理异常,同时记录线程信息。

注意:若使用submit()且不调用Future.get(),异常会被“吞掉”

线程池中线程异常后:销毁还是复用?

  • 当执行方式是execute()时,可以看到堆栈异常的输出,线程池会把这个线程移除掉,并创建一个新的线程放到线程池中。

  • 当执行方式是submit()时,堆栈异常没有输出。但是调用Future.get()方法时,可以捕获到异常,不会把这个线程移除掉,也不会创建新的线程放入到线程池中。

这俩种执行方式,都不会影响线程池里面其他线程的正常执行。

线程池核心线程数在运行过程中能修改吗?如何修改?

可以动态修改的。

Java的ThreadPoolExecutor提供了动态调整核心线程数和最大线程数的方法。

修改核心线程数的方法:使用 ThreadpolExeator.setCorePoolSize(int corePoolSize)方法可以动态修改核心线程数,corePoolSize 参数代表线程池中的核心线程数,当池中线程数量少于核心线程数时,会创建新的线程来处理任务,这个修改可以在线程池运行的过程中进行,立即生效。

注意事项:

  • 核心线程数的修改不会中断现有任务,新的核心线程数会在新任务到来时生效。

  • setCorePoolSize()方法可以减少核心线程数,但如果当前线程池中的线程数量超过了新的核心线程数,多余的线程不会立即被销毁,直到这些线程空闲后被回收。

线程池调整原则:

  • 动态调整线程池大小时,需要确保新的配置不会导致系统资源耗尽。比如,过大的线程池可能会占用过多的 CPU 和内存,反而影响性能。

  • 当系统负载发生变化时,可以使用动态调整来优化线程池的资源使用率,例在系统负载增加时,临时提高核心线程数以应对突发流量,当系统负载下降时,可以减少核心线程数以节省资源。当任务队列长度过长时,可以临时增加核心线程数,以加快任务的处理速度。

allowCoreThreadTimeOut 方法:

  • 默认情况下,核心线程不会被回收,即使它们空闲。通过 allowCoreThreadTimeOut(true)可以允许核心线程在空闲时被回收,从而释放系统资源。

  • 这在负载波动大的应用场景中非常有用,例如在负载高峰时临时增加核心线程数,低负载时通过回收空闲线程释放资源。

线程池监控与调整:

  • 在实际生产环境中,可以通过监控线程池的状态(如当前活跃线程数、队列长度等)来决定是否动态调整线程池大小。

  • 可以使用 JMX (Java Management Extensions)来监控 ThreadpoolExecutor,结合指标来自动调整线程池大小以优化性能.

你使用过哪些阻塞队列?

阳塞队列主要用来阻塞队列的插入和获取操作,当队列满了的时候插入操作会被阻塞,直到队列有空位。当队列为空的时候获取操作会被阻塞,直到队列有值。

常用在实现生产者和消费者场景,在笔试题中比较常见。

常见的阻塞队列包括:

  • ArrayBlockingQueue:一个有界队列,底层基于数组实现。需要在初始化时指定队列的大小,队列满时,生产者会被阻塞,队列空时,消费者会被阻塞。

  • linkedBlockingQueue:基于链表的阻塞队列,允许可选的界限(有界或无界)。无界模式下可以不断添加元素,直到耗尽系统资源。有界模式则类似于ArrayBlockinQueue,但吞吐量通常较高

  • PriorityBlockingQueue:一个无界的优先级队列,元素按照自然顺序或者指定的比较器顺序进行排序。与其他阻塞队列不同的是,PriorityBlockingQueue 不保证元素的 FIFO 顺序。

  • DelayQueue:一个无界队列,队列中的元素必须实现 delayed 接口,只有当元素的延迟时间到期时,才能被取出。常用于延迟任务调度。

  • SynchronousQueue:一个没有内部容量的队列,每个插入操作必须等待对应的移除操作,反之亦然。常用于在线程之间的直接传递任务,而不是存储任务

ArrayBlockingQueue 和LinkedBlockingQueue 区别

常见的有 ArrayBlockingQueue 和 LinkedBlockingQueue,分别是基于数组和链表的有界阻塞队列。两者原理都是基于 ReentrantLock和 Condition

ArrayBlockingQueue 基于数组,内部实现只用了一把锁,可以指定公平或者非公平锁。

LinkedBlockingQueue 基于链表,内部实现用了两把锁,,take一把、put一把,所以入队和出队这两个操作是可以并行的,从这里看并发度应该比 ArravBlockingQueue 高

LinkedTransferQueue是什么?

LinkedTransferQueue,相对于其他阻塞队列从名字来看它有Transfer功能,其实也不是什么神奇功能,一般阻塞队列都是将元素入队,然后消费者从队列中获取元素。

而 LinkedTransferQueue 的 tansfer 是元素入队的时候看看是否已经有消费者在等了,如果有在等了直接给消费者即可,所以就是这里少了一层,没有锁操作。

如何设计一个能够根据任务的优先级来执行的线程池?

这是一个常见的面试问题,本质其实还是在考察求职者对于线程池以及阻塞队列的掌握。

不同的线程池会选用不同的阻塞队列作为任务队列,比如FixedThreadPool 使用的是LinkedBlockingQueue(无界队列),由于队列永远不会被放满,因此FixedThreadPool最多只能创建核心线程数的线程。

假如需要实现一个优先级任务线程池的话,那可以考虑使用 PriorityBlockingQueue (优先级阻塞队列)作为任务队列(ThreadPoolExecutor 的构造函数有一个 workQueue 参数可以传入任务队列)。

PriorityBlockingQueue 是一个支持优先级的无界阻塞队列,可以看作是线程安全的 PriorityQueue,两者底层都是使用小顶堆形式的二叉堆,即值最小的元素优先出队。不过,PriorityQueue 不支持阻塞操作。

要想让 PriorityBlockingQueue 实现对任务的排序,传入其中的任务必须是具备排序能力的,方式有两种:

  1. 提交到线程池的任务实现 Comparable 接口,并重写 compareTo 方法来指定任务之间的优先级比较规则。

  2. 创建 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密集型任务等。

DelayQueue和ScheduledThreadPool有什么区别?

DelayQueue 是一个阻塞队列,而 ScheduledThreadPool 是线程池,不过内部核心原理都是差不多的。

DelayQueue 是利用优先队列存储元素,当从队列中获取任务的时候,如果最老的任务已经到了执行时间,可以从队列中出队一个任务,反之可以获得 null 或者阻塞等待任务

ScheduledThreadPool 内部也使用的一个优先队列 DelayedWorkQueue 且可以内部多线程执行任务,支持定时执行的任务,即每隔一段时间执行一次的任务。

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为例,AtomicIntegergetAndIncrement()方法底层就是CAS实现,关键代码是 compareAndSwapInt(obj, offset, expect, update),其含义就是,如果obj内的valueexpect相等,就证明没有其他线程改变过这个变量,那么就更新它为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操作可以解决并发冲突问题。

你使用过 Java 中的哪些原子类?

Java 中的原子类是通过使用硬件提供的原子操作指令(如 CAS,Compare-And-Swap)来确保操作的原子性,从而避免线程竞争问题。常用的原子类有以下几种:

  1. Atomiclnteger:用于操作整数的原子类,提供了原子性的自增、自减、加法等操作

  2. AtomicLong:与 AtomicInteger 类似,但用于操作 long 型数据

  3. AtomicBoolean:用于操作布尔值的原子类,提供了原子性的布尔值比较和设置操作

  4. AtomicReference:用于操作对象引用的原子类,支持对引用对象的原子更新。

  5. AtomicStampedReference:在 AtomicReference 的基础上,增加了时间戳或版本号的比较,避免了 ABA 问题

  6. AtomiclntegerArray和 AtomicLongArray:分别是 Atomicinteger和 Atomiclong的数组版本,提供了对数组中各个元素的原子操作

你使用过 Java 的累加器吗?

在Java 中累加器(Accumulator )一般指的是 LongAdder 和 DoubleAdder 类,它们在高并发场景下比传统的 Atomiclong更具优势。

  • LongAdder:适用于 long类型的累加操作,提供了高效的累加功能,尤其是在多线程环境中。

  • DoubleAdder:适用于 double类型的累加操作,同样优化了在高并发环境下的性能。

核心特点:

  • 高效性:在多线程环境中,通过减少竞争和锁的使用来提高性能。它们通过内部维护多个计数器(桶)来分摊并发操作的压力,从而减少竞争

  • 线程安全:提供了原子性保证,避免了并发访问中的数据不一致问题。

公平锁与非公平锁

按照线程访问顺序获取对象锁。synchronized是非公平锁,Lock默认是非公平锁,可以设置为公平锁,公平锁会影响性能。

共享式与独占式锁

共享式与独占式的最主要区别在于:同一时刻独占式只能有一个线程获取同步状态,而共享式在同一时刻可以有多个线程获取同步状态。例如读操作可以有多个线程同时进行,而写操作同一时刻只能有一个线程进行写操作,其他操作都会被阻塞。

volatile关键字

详细内容请查看:Volatile详解

说下你对volatile的理解

volatile关键字的两个作用:

  1. 保证了不同线程对共享变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。

  2. 禁止进行指令重排序。volatile通过禁止指令重排来保证顺序性。在多线程环境下,为了提高程序执行效率,编译器和处理器可能会对指令进行重新排序。但是,如果一个变量被volatile修饰,就禁止了指令重排,确保每个线程都能看到正确的操作顺序。

指令重排序是JVM为了优化指令,提高程序运行效率,在不影响单线程程序执行结果的前提下,尽可能地提高并行度。Java编译器会在生成指令系列时在适当的位置会插入内存屏障指令来禁止处理器重排序。插入一个内存屏障,相当于告诉CPU和编译器先于这个命令的必须先执行,后于这个命令的必须后执行。对一个volatile字段进行写操作,Java内存模型将在写操作后插入一个写屏障指令,这个指令会把之前的写入值都刷新到内存。

总的来说,volatile可以确保多个线程对共享变量的操作一致,避免了数据不一致的问题。但是它不能保证原子性,因此对于需要保证原子性的操作,还需要使用其他同步机制,如synchronized关键字或java.util.concurrent.atomic包中的原子类。

如何保证变量的可见性?

volatile是轻量级的同步机制,volatile保证变量对所有线程的可见性,不保证原子性。

而这个LOCK前缀的指令主要实现了两个步骤:

  1. 将当前处理器缓存行的数据写回到系统内存;

  2. 将其他处理器中缓存了该数据的缓存行设置为无效。

原因在于缓存一致性协议,每个处理器通过总线嗅探和MESI协议来检查自己的缓存是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行置为无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存中。

  1. 当volatile修饰的变量进行写操作的时候,JVM就会向CPU发送LOCK#前缀指令,通过缓存一致性机制确保写操作的原子性,然后更新对应的主存地址的数据。

  2. 处理器会使用嗅探技术保证在当前处理器缓存行,主存和其他处理器缓存行的数据的在总线上保持一致。在JVM通过LOCK前缀指令更新了当前处理器的数据之后,其他处理器就会嗅探到数据不一致,从而使当前缓存行失效,当需要用到该数据时直接去内存中读取,保证读取到的数据时修改后的值。

缓存一致性协议:当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,就会从内存重新读取。

如何禁止指令重排序?

为了性能优化,JMM 在不改变正确语义的前提下,会允许编译器和处理器对指令序列进行重排序。JMM 提供了内存屏障阻止这种重排序。

Java 编译器会在生成指令系列时在适当的位置会插入内存屏障指令来禁止特定类型的处理器重排序。

JMM 会针对编译器制定 volatile 重排序规则表。 为了实现 volatile 内存语义时,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。

对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎是不可能的,为此,JMM 采取了保守的策略。

  • 在每个 volatile 写操作的前面插入一个 StoreStore 屏障。

  • 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障。

  • 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障。

  • 在每个 volatile 读操作的后面插入一个 LoadStore 屏障。

volatile 写是在前面和后面分别插入内存屏障,而 volatile 读操作是在后面插入两个内存屏障。 imgimg

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的用法有哪些?

  1. 修饰普通方法:作用于当前对象实例,进入同步代码前要获得当前对象实例的锁

  2. 修饰静态方法:作用于当前类,进入同步代码前要获得当前类对象的锁,synchronized关键字加到static 静态方法和 synchronized(class)代码块上都是是给 Class 类上锁

  3. 修饰代码块:指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁

synchronized的作用有哪些?

  • 原子性:确保线程互斥的访问同步代码;

  • 可见性:保证共享变量的修改能够及时可见;

  • 有序性:有效解决重排序问题。

Synchronized 修饰静态方法和修饰普通方法有什么区别?

  • Synchronized 修饰静态方法:锁的是这个类的 class 对象。也就是说,无论创建了多少个该类的实例,所有的实例共享同一个锁,因为这个锁属于类本身而不是某个对象实例。

  • Synchronized 修饰实例方法:锁的是当前实例(调用该方法的对象),也就是这个对象的内在锁。这也就是说每个对象实例都有自己独立的锁。

构造方法可以用 synchronized 修饰么?

构造方法不能使用 synchronized 关键字修饰。不过,可以在构造方法内部使用 synchronized 代码块。

另外,构造方法本身是线程安全的,但如果在构造方法中涉及到共享资源的操作,就需要采取适当的同步措施来保证整个构造过程的线程安全。

synchronized 底层实现原理?

synchronized 实现原理依赖于JVM 的 Monitor(监视器锁)和对象头(Object Header)

  • synchronized 修饰代码块:会在代码块的前后插入 monitorentermonitorexit 指令。可以把 monitorenter理解为加锁,monitorexit 理解为解锁。(monitor对象存在于每个Java对象的对象头中, synchronized 锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因)。内部包含一个计数器,当计数器为0则可以成功获取,获取后将锁计数器设为1也就是加1。相应的在执行 monitorexit 指令后,将锁计数器设为0,表明锁被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止

  • synchroized修饰方法:synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法,JVM 通过该 ACC_SYNCHRONIZED 访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。

volatile和synchronized的区别是什么?

  1. volatile只能使用在变量上;而synchronized可以在类,变量,方法和代码块上。

  2. volatile至保证可见性;synchronized保证原子性与可见性。

  3. volatile禁用指令重排序;synchronized不会。

  4. volatile不会造成阻塞;synchronized会。

Synchronized 能不能禁止指令重排序?

synchronized 无法完全禁止指令重排序,但能通过内存屏障保证多线程环境下的有序性。对于需要严格禁止重排序的场景,应优先选择 volatile。

这是因为同步块内部的代码仍可能被重排序,只是这种重排序不违反单线程语义

ReentrantLock和synchronized区别

  1. 使用synchronized关键字实现同步,线程执行完同步代码块会自动释放锁,而ReentrantLock需要手动释放锁。

  2. synchronized是非公平锁,ReentrantLock可以设置为公平锁。

  3. ReentrantLock上等待获取锁的线程是可中断的,线程可以放弃等待锁。而synchonized会无限期等待下去。

  4. ReentrantLock 可以设置超时获取锁。在指定的截止时间之前获取锁,如果截止时间到了还没有获取到锁,则返回。

  5. ReentrantLock 的 tryLock() 方法可以尝试非阻塞的获取锁,调用该方法后立刻返回,如果能够获取则返回true,否则返回false。

  6. synchronized 和 ReentrantLock 都是可重入锁

什么是可重入锁

可重入锁是一种特殊的互斥锁,它允许同一个线程在持有锁的情况下再次获取该锁。也就是说,同一个线程可以多次获取同一个可重入锁,而不会发生死锁。

在 Java 中,synchronized关键字就是一种可重入锁。当一个线程使用synchronized修饰的方法或代码块时,它会获得该对象的锁。如果该线程在持有锁的情况下再次调用同一个对象的synchronized方法或代码块,那么它会再次获得该对象的锁,而不会等待其他线程释放锁。

可重入锁的好处是可以避免死锁的发生。因为同一个线程可以多次获取同一个锁,所以当一个线程在持有锁的情况下需要再次获取锁时,它不需要等待其他线程释放锁,从而避免了死锁的发生。

需要注意的是,可重入锁并不是绝对安全的。如果一个线程在持有锁的情况下进行了一些不当的操作,仍然可能导致死锁的发生。因此,在使用可重入锁时,需要注意避免出现这种情况。

锁升级原理了解吗?

在 Java 6 之后, synchronized 引入了大量的优化如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销,这些优化让 synchronized 锁的效率提升了很多(JDK18 中,偏向锁已经被彻底废弃)。

锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。注意锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率。

锁升级相关内容可以查看synchronized底层机制剖析

synchronized 升级到重量级锁后,所有线程都释放锁了,此时它还是重量级锁吗?

当重量级锁释放了之后,锁对象是无锁的。

有新的线程来竞争的话又会从无锁再到轻量级锁开始后续的升级流程。

AQS

详细内容请查看:AQS详解

什么是AQS?

简单来说 AOS 就是起到了一个抽象、封装的作用,将一些排队、入队、加锁、中断等方法提供出来,便于其他相关JUC 锁的使用,具体加锁时机、入队时机等都需要实现类自己控制

它主要通过维护一个共享状态(state)和一个先进先出(FIFO)的等待队列,来管理线程对共享资源的访问。state 用 volatile 修饰,表示当前资源的状态。

例如,在独占锁中,state 为0表示未被占用,为1表示已被占用。当线程尝试获取资源失败时,会被加入到 AOS 的等待队列中。这个队列是一个变体的 CLH 队列,采用双向链表结构,节点包含线程的引用、等待状态以及前驱和后继节点的指针AQS 常见的实现类有 ReentrantLock、countDownLatch、semaphore 等等

然后面试官会引申问你具体 ReentrantLock 的实现原理是怎样的呢?

为什么AQS是双向链表而不是单向的?

双向链表有两个指针,一个指针指向前置节点,一个指针指向后继节点。所以,双向链表可以支持常量 O(1) 时间复杂度的情况下找到前驱节点。因此,双向链表在插入和删除操作的时候,要比单向链表简单、高效。

从双向链表的特性来看,AQS 使用双向链表有2个方面的原因:

  1. 没有竞争到锁的线程加入到阻塞队列,并且阻塞等待的前提是,当前线程所在节点的前置节点是正常状态,这样设计是为了避免链表中存在异常线程导致无法唤醒后续线程的问题。所以,线程阻塞之前需要判断前置节点的状态,如果没有指针指向前置节点,就需要从 Head 节点开始遍历,性能非常低。

  2. 在 Lock 接口里面有一个lockInterruptibly()方法,这个方法表示处于锁阻塞的线程允许被中断。也就是说,没有竞争到锁的线程加入到同步队列等待以后,是允许外部线程通过interrupt()方法触发唤醒并中断的。这个时候,被中断的线程的状态会修改成 CANCELLED。而被标记为 CANCELLED 状态的线程,是不需要去竞争锁的,但是它仍然存在于双向链表里面。这就意味着在后续的锁竞争中,需要把这个节点从链表里面移除,否则会导致锁阻塞的线程无法被正常唤醒。在这种情况下,如果是单向链表,就需要从 Head 节点开始往下逐个遍历,找到并移除异常状态的节点。同样效率也比较低,还会导致锁唤醒的操作和遍历操作之间的竞争。

AQS原理

AQS,AbstractQueuedSynchronizer,抽象队列同步器,定义了一套多线程访问共享资源的同步器框架,许多并发工具的实现都依赖于它,如常用的ReentrantLock/Semaphore/CountDownLatch

AQS使用一个volatile的int类型的成员变量state来表示同步状态,通过CAS修改同步状态的值。当线程调用 lock 方法时,如果 state=0,说明没有任何线程占有共享资源的锁,可以获得锁并将 state加1。如果 state不为0,则说明有线程目前正在使用共享变量,其他线程必须加入同步队列进行等待。

同步器依赖内部的同步队列(一个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与 synchronized 的区别

  • synchronized 会使线程阻塞,线程会进入 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()方法不需要捕获中断异常;

  • native

    • 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是否 一致,如果一致,同步状态state 加1,表示锁被当前线程获取了多次。

源码如下:

ReentrantLock中tryLock()和lock()方法的区别

  1. tryLock()表示尝试加锁,可能加到,也可能加不到,该方法不会阻塞线程,如果加到锁则返回true,没有加到则返回false

  2. lock()表示阻塞加锁,线程会阻塞直到加到锁,方法也没有返回值

ReentrantLock中的公平锁和非公平锁的底层实现

ReentrantLock是Java中提供的一种可重入锁,它支持两种锁的模式:公平锁和非公平锁。这两种锁模式的底层实现略有不同:

  • 公平锁(Fair Lock): 公平锁的特点是按照请求锁的顺序来分配锁,即先到先得。在ReentrantLock中,通过构造函数可以选择创建一个公平锁。公平锁的底层实现使用了一个FIFO队列(First-In-First-Out),即等待队列。当一个线程请求锁时,如果锁已经被其他线程持有,请求线程会被放入等待队列的末尾,按照请求的顺序等待锁的释放。当锁被释放时,等待队列中的第一个线程会被唤醒并获得锁。

  • 非公平锁(Non-Fair Lock): 非公平锁不考虑请求锁的顺序,它允许新的请求线程插队并尝试立即获取锁,而不管其他线程是否在等待。在ReentrantLock中,默认情况下创建的是非公平锁。非公平锁的底层实现中,有一个等待队列,但它不会严格按照请求的顺序来分配锁,而是根据线程竞争锁的情况来判断是否立即分配给新的请求线程。

底层实现中,无论是公平锁还是非公平锁,都使用了类似的同步器(Sync)来管理锁的状态和线程的竞争。不同之处在于如何处理等待队列中的线程,以及是否按照请求的顺序来分配锁。

ReentrantLock实现公平锁非公平锁则主要体现在tryAcquire的实现上:

公平锁中实现的tryAcquire:

非公平锁中实现的tryAcquire:

  • 公平锁中多了一层 !hasQueuedPredecessors() 的判断,这是公平锁加锁时判断等待队列中是否存在有效节点的方法。如果返回False,说明当前线程可以获取共享资源;如果返回True,说明队列中存在有效节点,当前线程必须加入到等待队列中。

  • 而在非公平锁中,没有这个判断,直接尝试获取锁,能获取到锁则不用加入等待队列。

Java中读写锁的应用场景是什么

ReadWriteLock 是 Java 提供的一种用于解决并发读写问题的锁机制,其主要目的在于提高在读多写少的场景下的并发性能。ReadWriteLock 允许多个线程同时读取共享资源,但是对写操作是独占的。这意味着在一个线程写入数据时,其他线程既不能读取也不能写入(读读操作不互斥,读写互斥、写写互斥)。这种机制适合于读多写少的场景,能提高系统的并发性和性能。

ReadwriteLock是通ReentrantReadwriteLock实现的,它提供了以下两种锁模式:

  • 读锁(共享锁):允许多个线程同时获取读锁,只要没有任何线程持有写锁。适合读操作频繁而写操作较少的场景

  • 写锁(独占锁):写锁是独占的,当有线程持有写锁时,其他线程既不能获取写锁,也不能获取读锁。写锁用于保证写操作的独占性,防止数据不一致。

应用场景

  • 缓存系统:在缓存系统中,数据读取频率通常远高于数据写入,因此使用读写锁可以提高读取操作的并发性和效率。

  • 配置管理: 系统配置或应用程序配置的数据通常在初始化后多为读取,只有在特殊情况下才会更新,因此适合使用读写锁。

  • 文档编辑:多人协同编辑文档时,可能会有许多人同时查看文档内容,只有少数人进行编辑,这种场合也可以使用读写锁。

  • 游戏状态:游戏服务器可能需要频繁读取玩家的状态,而状态更新相对较少,因此读写锁可以提高状态读取的性能。

关键点

  • 提升读取性能:ReadWriteLock 允许多个读取线程同时访问共享资源,从而提高了读取性能。

  • 写锁独占:只有在没有其他线程读取或写入时,写锁才能获得,这保障了数据在写入时的一致性和安全性。

  • 适用性:适用于读多写少场景,能显著提高系统在并发读取场景下的性能。

  • 易于使用:ReadWriteLock 提供了清晰的语义分离(读与写),使得代码更具可读性和维护性。

在高并发应用中,通过合理使用 ReadWriteLock 可以在保持数据一致性的同时大幅提高程序的并发性能。

并发工具

线程执行顺序怎么控制?

在 Java 中控制多个线程的执行顺序有很多种方法:

  • Completablefuture,它内部有 thenRun 的方法,假设我们现在有三个任务T1、T2、T3 需要按序执行,那么仅需使用以下伪代码题可

  • synchronized + wait()/notify()),通过对象锁和线程间通信机制来控制线程的执行顺序

  • ReentrantLock + condition

  • Thread 类的 join(),通过调用这个方法,可以使一个线程等待另一个线程执行完毕后再继续执行。

  • CountDownLatch,使一个或多个线程等待其他线程完成各自工作后再继续执行。

  • CyclicBarrier,使多个线程互相等待,直到所有线程都到达某个共同点后再继续执行。

  • Semaphore,控制线程的执行顺序,适用于需要限制同时访问资源的线程数量的场景。

  • 线程池,内部仅设置一个线程来执行任务,按序的将任务提交到线程池中就可以了

假设有T1、T2、T3三个线程,你怎样保证T2在T1执行完后执行,T3在T2执行完后执行?

可以使用join方法解决这个问题。比如在线程A中,调用线程B的join方法表示的意思就是:A等待B线程执行完毕后(释放CPU执行权),在继续执行。

代码如下:

运行结果:

CountDownLatch

CountDownLatch用于某个线程等待其他线程执行完任务再执行,与thread.join()功能类似。

常用于以下场景:

  • 主线程等待多个子线程完成任务:主线程可以使用await()方法等待所有子线程完成,然后进行结果的汇总或其他操作。

  • 多个线程等待外部事件的发生:多个线程可以同时等待某个共同的事件发生,比如等待某个资源准备就绪或者等待某个信号的触发。

  • 控制并发任务的同时开始:在某些并发场景中,需要等待所有线程都准备就绪后才能同时开始执行任务,CountDownLatch提供了一种便捷的方式来实现这一需求。

运行结果:

CyclicBarrier

CyclicBarrier(同步屏障),用于一组线程互相等待到某个状态,然后这组线程再同时执行。

参数parties指让多少个线程或者任务等待至某个状态;参数barrierAction为当这些线程都达到某个状态时会执行的内容。

运行结果如下,可以看出CyclicBarrier是可以重用的:

当四个线程都到达barrier状态后,会从四个线程中选择一个线程去执行Runnable。

CyclicBarrier和CountDownLatch区别

  1. CyclicBarrier 和 CountDownLatch 都能够实现线程之间的等待
  • CountDownLatch简单的说就是一个线程等待,直到他所等待的其他线程都执行完成并且调用countDown()方法发出通知后,当前线程才可以继续执行。

  • cyclicBarrier是所有线程都进行等待,直到所有线程都准备好进入await()方法之后,所有线程同时开始执行!

  1. CountDownLatch减计数,CyclicBarrier加计数。

  2. CountDownLatch是一次性的,CyclicBarrier可以重用。CyclicBarrier的计数器可以使用reset() 方法重置。所以CyclicBarrier能处理更为复杂的业务场景,比如如果计算发生错误,可以重置计数器,并让线程们重新执行一次。

Semaphore

Semaphore类似于锁,它用于控制同时访问特定资源的线程数量,控制并发线程数。

运行结果如下,可以看出并非按照线程访问顺序获取资源的锁,即

应用场景:Semaphore可以用于做流量控制,特别是公共资源有限的应用场景,比如数据库连接。假如有一个需求要读取几万个文件的数据,因为都是IO密集型任务,可以启动几十个线程并发地读取,读到内存中后,还需要存储到数据库中,而数据库的连接数只有10个,那么就可以控制这几十个线程只有10个线程同时获取数据库连接来保存数据。这个时候,就可以使用Semaphore来做流量控制。

semaphore初始化有10个令牌,11个线程同时各调用1次acquire方法,会发生什么?

拿不到令牌的线程阻塞,不会继续往下运行。

semaphore初始化有10个令牌,一个线程重复调用11次acquire方法,会发生什么?

线程阻塞,不会继续往下运行。可能你会考虑类似于锁的重入的问题,很好,但是,令牌没有重入的概念。你只要调用一次acquire方法,就需要有一个令牌才能继续运行。

semaphore初始化有1个令牌,1个线程调用一次acquire方法,然后调用两次release方法,之后另外一个线程调用acquire(2)方法,此线程能够获取到足够的令牌并继续运行吗?

能,原因是release方法会添加令牌,并不会以初始化的大小为准。

semaphore初始化有2个令牌,一个线程调用1次release方法,然后一次性获取3个令牌,会获取到吗?

能,原因是release会添加令牌,并不会以初始化的大小为准。Semaphore中release方法的调用并没有限制要在acquire后调用。

具体示例如下,如果不相信的话,可以运行一下下面的demo。

两个线程如何进行数据交换?

  • 共享变量 + 同步机制:多个线程访问共享变量(如类的成员变量或静态变量),通过synchronizedLock保证线程安全。

  • 使用Exchanger 的exchange() 方法

Exchanger 存在的问题:

  • 仅限两个线程​:Exchanger 严格用于成对线程,多线程需使用多个实例或其他工具(如 BlockingQueue

  • 死锁风险​:若一个线程未调用 exchange(),另一线程会永久阻塞(可通过超时参数避免)

ThreadLocal

为什么要用ThreadLocal

线程本地变量。当使用ThreadLocal维护变量时,ThreadLocal为每个使用该变量的线程提供独立的变量副本,所以每一个线程都可以独立地改变自己的副本,而不会影响其它线程。

在多线程编程中,多个线程可能会同时访问和修改共享变量,导致线程安全问题。 ThreadLocal 提供了一种简单的解决方案,使每个线程都有自己的独立变量副本,与通过加锁、同步块等传统方式来保证线程安全相比。Threadlocal 不需要对变量访问进行同步,减少了上下文切换、锁竞争的性能损耗。避免了多线程间的变量共享和竞争,从而解决了线程安全问题

ThreadLocal是如何实现线程资源隔离的?

ThreadLocal 提供了一种线程内独享的变量机制,使每个线程都能有自己独立的变量副本。每个线程内部维护一个 ThreadLocalMap,这个ThreadLocalMap用于存储线程独立的变量副本。ThreadLocalMap 以ThreadLocal实例为key,以线程独立的变量副本作为值。不同线程通过Threadlocal 获取各自的变量副本,而不会影响其他线程的数据。

工作流程:当线程访问 threadLocal.get()时,当前线程会视据自身的 ThreadlocalMap获取到与调用的 ThreadLocal 对应的值,如果是第一次访问,ThreadLocal 会初始化一个值,并将其存入该线程的 ThreadLocalMap 中。后续访问时,直接从ThreadLocalMap 中获取,确保每个线程都有自己的数据副本。

ThreadLocal 设计思路理解

在每个线程的本地都存一份值,说白了就是每个线程需要有个变量,来存储这些需要本地化资源的值,并且值有可能有多个,所以怎么弄呢?

在线程对象内部搞个 map,把 ThreadLocal 对象自身作为 key,把它的值作为 map 的值。 这样每个线程可以利用同一个对象作为 key,去各自的 map 中找到对应的值。

比如现在有3个ThreadLocal 对象,2 个线程 这样一来就满足了本地化资源的需求,每个线程维护自己的变量,互不干扰,实现了变量的线程隔离,同时也满足存储多个本地变量的需求

从源码层面分析ThreadLocal原理

每个线程都有一个ThreadLocalMapThreadLocal内部类),Map中元素的键为ThreadLocal,而值对应线程的变量副本。 调用threadLocal.set()–>调用getMap(Thread)–>返回当前线程的ThreadLocalMap<ThreadLocal, value>–>map.set(this, value),this是threadLocal本身。源码如下:

调用get()–>调用getMap(Thread)–>返回当前线程的ThreadLocalMap<ThreadLocal, value>–>map.getEntry(this),返回value。源码如下:

threadLocals的类型ThreadLocalMap的键为ThreadLocal对象,因为每个线程中可有多个threadLocal变量,如longLocalstringLocal

ThreadLocal并不是用来解决共享资源的多线程访问问题,因为每个线程中的资源只是副本,不会共享。因此ThreadLocal适合作为线程上下文变量,简化线程内传参。

为什么ThreadLocal 对 key 的引用为弱引用?

使用弱引用作为 ThreadLocal 的键可以防止内存泄漏。

若 ThreadLocal 实例被不再需要的线程持有为强引用,那么当该线程结束时,相关的 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 适用于需要为每个线程维护独立副本的场景,例如:数据库连接、用户会话、事务上下文、临时缓存等。对于能通过参数传递的上下文信息,不应该使用 ThreadLocal 来处理,避免设计不合理和代码可读性差的问题。

  • 避免内存泄漏:ThreadLocal 中的key是弱引用,但 value 是强引用,因此需要在适当的时机调用remove()方法来清除 ThreadLocal 的值,避免内存泄漏,尤其是在使用线程池时,线程对象会被重用,若不手动清理,容易导致内存泄漏。

  • 使用静态变量存放 ThreadLocal:将 ThreadLocal 作为类的静态变量保存,这样可以确保同一个线程的局部变量在线程的生命周期内都可以被访问,避免对象频繁创建

  • 合理的生命周期:确保在线程使用 ThreadLocal 完成后及时释放其关联的对象,避免由于线程未结束导致的资源浪费,尤其在线程池或长时间运行的服务中,建议在任务执行结束时清理ThreadLocal 变量。

  • 合理初始化 ThreadLocal:使用 ThreadLocal 时,可以通过重写 initialvalue()方法来提供默认值,这样可以避免在第一次调用 get()方法时出现空指针异常,例如,下面的例子为每个线程提供一个独立的 simpleDateFormat 对象

  • 适配线程池中的使用:在使用 ThreadLocal 时,要特别注意线程池的使用。线程池中的线程会被重用,因此上一个任务可能会将其 ThreadLocal 的值残留给下一个任务。为此,使用线程池时,应该确保在任务执行结束时,手动调用 remove() 清理 ThreadLocal 中的内容。

    • 隐式线程池:我们常见的项目都是 web 项目,都会使用 Tomcat,此时需要注意隐式线程池的情况,因为用了 tomcat,其实处理用户请求的线程是 tomcat 线程池中的线程,这就是隐式使用,这里有一个问题,关于 withInitial 也就是初始化值的方法。由于tomcat这种隐式线程池的存在,即线程第一次调用执行 Theadloacal之后,如果没有显示调用 remove 方法,则这个 Entry 还是存在的,那么下次这个线程再执行任务的时候,不会再调用withInitial 方法,也就是说会拿到上一次执行的值。

InheritableThreadLocal 是什么?

InheritableThreadLocal是 ThreadLocal 的一个扩展,用于在线程创建时将父线程的ThreadLocal 变量副本传递给子线程,使得子线程可以访问父线程中设置的本地变量。它解决了ThreadLocal 无法在子线程中继承父线程本地变量的问题

工作原理:

  • 当创建子线程时, InheritableThreadLocal 的值会被自动拷贝到子线程中

  • 子线程可以修改自己的副本,但不会影响父线程的值,

ThreadLocal 存在的问题?

面试问的不多,但如果被问ThreadLocal 时,你主动提到ThreadLocal 存在的问题,并说出你对Java21中scoped values 的理解,一定是一个大加分项,详细内容可以查看scoped-values

ThreadLocal 存在的问题:

  1. 内存泄漏:在用完ThreadLocal之后若没有调用remove,这样就会出现内存泄漏。

  2. 增加开销:在具有继承关系的线程中,子线程需要为父线程中ThreadLocal里面的数据分配内存。

  3. 权限问题:任何可以调用ThreadLocal中get方法的代码都可以随时调用set方法,这样就不易辨别哪些方法是按照什么顺序来更新的共享数据,并且这些方法也都有权限给ThreadLocal赋值。

随着虚拟线程的到来,内存泄漏问题就不用担心了,由于虚拟线程会很快的终止,此时会自动删除ThreadLocal中的数据,这样就不用调用remove方法了。但虚拟线程的数量通常是多的,试想下上百万个虚拟线程都要拷贝一份ThreadLocal中的变量,这会使内存承受更大的压力。为了解决这些问题,scoped values就出现了。scoped values 是一个隐藏的方法参数,只有方法可以访问scoped values,它可以让两个方法之间传递参数时无需声明形参。

scoped values 也是Java21的新特性,每个线程都能访问自己的scope value,与ThreadLocal不同的是,它只会被write 1次且仅在线程绑定的期间内有效。

ThreadLocal 还有什么问题?

ThreadLocal 中的 ThreadLocalMap Hash 冲突用的是线性探测法,效率低,看ThreadLocal 的set方法:

假设冲突多了,需要遍历的次数就多了。并且下次 get的时候,hash 直接命中的位置发现不是要找的 Entry,于是就接着遍历向后找,所以说这个效率低。

而像 HashMap是通过链表法来解决冲突,并且为了防止链表过长遍历的开销变大,在一定条件之后又会转变成红黑树来查钱,这样的解决方案在频繁冲突的条件下,肯定是优于线性探测法,所以这是一个优化方向。

为什么 Netty 不使用 ThreadLocal 而是自定义了一个 FastThreadLocal ?

Threadlocal 存在的问题:

  • Threadlocal 在解决 hash 冲突时使用的线性探测法不好;

  • Entry 的弱引用可能会发生内存泄漏

这些都和 ThreadLocalMap有关,所以需要搞个新的 map 来替换 ThreadlocalMap,而这个 ThreadlocalMap 又是 Thread 里面的一个成员变量,这么一看Thread 也得动一动,但是我们又无法修改 Thread 的代码,所以配套的还得弄个新的 Thread,所以我们不仅得弄个新的 ThreadLocal、ThreadLocalMap 还得弄个配套的 Thread 来用上新的 ThreadLocalMap。所以如果想改进 ThreadLocal,就需要动这三个类。

对应到 Netty 的实现就是 FastThreadLocal、InternalThreadLocalMap、FastThreadLocalThread

什么是Future?

在并发编程中,不管是继承thread类还是实现runnable接口,都无法保证获取到之前的执行结果。通过实现Callback接口,并用Future可以来接收多线程的执行结果。

Future表示一个可能还没有完成的异步任务的结果,针对这个结果可以添加Callback以便在任务执行成功或失败后作出相应的操作。

举个例子:比如去吃早点时,点了包子和凉菜,包子需要等3分钟,凉菜只需1分钟,如果是串行的一个执行,在吃上早点的时候需要等待4分钟,但是因为你在等包子的时候,可以同时准备凉菜,所以在准备凉菜的过程中,可以同时准备包子,这样只需要等待3分钟。Future就是后面这种执行模式。

Future接口主要包括5个方法:

  1. get()方法可以当任务结束后返回一个结果,如果调用时,工作还没有结束,则会阻塞线程,直到任务执行完毕

  2. get(long timeout,TimeUnit unit)做多等待timeout的时间就会返回结果

  3. cancel(boolean mayInterruptIfRunning)方法可以用来停止一个任务,如果任务可以停止(通过mayInterruptIfRunning来进行判断),则可以返回true,如果任务已经完成或者已经停止,或者这个任务无法停止,则会返回false。

  4. isDone()方法判断当前方法是否完成

  5. isCancel()方法判断当前方法是否取消

Callable 和 Future 有什么关系?

我们可以通过 FutureTask 来理解 CallableFuture 之间的关系。

FutureTask 提供了 Future 接口的基本实现,常用来封装 CallableRunnable,具有取消任务、查看任务是否执行完成以及获取任务执行结果的方法。ExecutorService.submit() 方法返回的其实就是 Future 的实现类 FutureTask

FutureTask 不光实现了 Future接口,还实现了Runnable 接口,因此可以作为任务直接被线程执行。 FutureTask 有两个构造函数,可传入 Callable 或者 Runnable 对象。实际上,传入 Runnable 对象也会在方法内部转换为Callable 对象。

FutureTask相当于对Callable 进行了封装,管理着任务执行的情况,存储了 Callablecall 方法的任务执行结果。

Future与FutureTask的区别?

  1. Future是一个接口,FutureTask是一个实现类;

  2. 使用Future初始化一个异步任务结果一般需要搭配线程池的submit,且submit方法有返回值;而初始化一个FutureTask对象需要传入一个实现了Callable接口的类的对象,直接将FutureTask对象submit给线程池,无返回值;

  3. Future + Callable获取结果需要Future对象的get,而FutureTask获取结果直接用FutureTask对象的get方法即可。

CompletableFuture 类有什么用?

Future 在实际使用过程中存在一些局限性比如不支持异步任务的编排组合、获取计算结果的 get() 方法为阻塞调用。

Java 8 才被引入CompletableFuture 类可以解决Future 的这些缺陷。CompletableFuture 除了提供了更为好用和强大的 Future 特性之外,还提供了函数式编程、异步任务编排组合(可以将多个异步任务串联起来,组成一个完整的链式调用)等能力。

下面我们来简单看看 CompletableFuture 类的定义。

可以看到,CompletableFuture 同时实现了 FutureCompletionStage 接口。 CompletionStage 接口描述了一个异步计算的阶段。很多计算可以分成多个阶段或步骤,此时可以通过它将所有步骤组合起来,形成异步计算的流水线。

详情可以查看:CompletableFuture详解

主线程如何得知线程池中多个并行任务的完成并收集它们的结果?

常见有以下三种:

  • Future模式:提交任务时保存 Future 列表,主线程遍历调用 future.get()阻塞获取结果(支持超时设置)

  • CompletableFuture:使用 completableFuture.allof(futures).join()等待所有任务完成,通过 thenApply 收集结果,支持异步回调和异常处理

  • CountDownLatch协作:初始化 countDownLatch 并传递给任务,每个任务完成时调用 countDown(),主线程通 1atch.await()阻塞等待。