八股(三)Java并发

目录

😺线程

什么是线程和进程?

[Java 线程和操作系统的线程有什么区别?](#Java 线程和操作系统的线程有什么区别?)

线程与进程的关系、区别、优缺点

线程怎么创建?

线程的生命周期和状态

线程上下文切换

[Thread#sleep() 方法和 Object#wait() 方法](#sleep() 方法和 Object#wait() 方法)

[为什么 wait() 定义在 Object,不定义在 Thread 中?](#为什么 wait() 定义在 Object,不定义在 Thread 中?)

[可以直接调用 Thread 的 run() 方法吗?](#可以直接调用 Thread 的 run() 方法吗?)

😺多线程

并发与并行的区别

同步与异步的区别

为什么要使用多线程?

[单核 CPU 支持 Java 多线程吗?](#单核 CPU 支持 Java 多线程吗?)

[单核 CPU 上运行多个线程效率一定会高吗?](#单核 CPU 上运行多个线程效率一定会高吗?)

使用多线程可能带来什么问题?

如何理解线程安全和不安全?

😺死锁

[😺JMM(Java 内存模型 Java Memory Model)](#😺JMM(Java 内存模型 Java Memory Model))

[volatile 如何保证可见性](#volatile 如何保证可见性)

如何禁止指令重排序

[volatile 读写操作的内存屏障插入策略](#volatile 读写操作的内存屏障插入策略)

[volatile 与 happens-before 的关系](#volatile 与 happens-before 的关系)

[volatile 可以保证原子性么?](#volatile 可以保证原子性么?)

😺乐观锁和悲观锁

什么是悲观锁?

什么是乐观锁?

如何实现乐观锁?

[CAS 算法存在哪些问题?](#CAS 算法存在哪些问题?)

[😺synchronized 关键字](#😺synchronized 关键字)

[synchronized 底层原理](#synchronized 底层原理)

[synchronized vs volatile](#synchronized vs volatile)

😺ReentrantLock

公平锁和非公平锁有什么区别?

[synchronized vs ReentrantLock](#synchronized vs ReentrantLock)

可中断锁和不可中断锁有什么区别?

[😺Atomic 原子类](#😺Atomic 原子类)

😺ThreadLocal

[ThreadLocal 原理](#ThreadLocal 原理)

[ThreadLocal 内存泄露问题](#ThreadLocal 内存泄露问题)

[JDK 为什么把 ThreadLocalMap.Entry 的 key 设计成弱引用?](#JDK 为什么把 ThreadLocalMap.Entry 的 key 设计成弱引用?)

[如何跨线程传递 ThreadLocal 的值?](#如何跨线程传递 ThreadLocal 的值?)

😺线程池

创建方式:ThreadPoolExecutor

工作流程:先核心,后排队,再扩容,最后拒绝

线程池里的核心线程corePoolSize是否会被销毁回收?

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

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

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

优先级线程池如何设计

😺Future

😺AQS


😺线程

什么是线程和进程?

进程是程序的一次执行过程,是系统资源分配的基本单位,具有独立的内存空间,因此进程之间资源相互隔离。

线程是进程内部的执行单元,是 CPU 调度的基本单位,一个进程可以包含多个线程,这些线程共享进程的堆和方法区资源,但每个线程拥有独立的程序计数器、虚拟机栈和本地方法栈。由于线程共享进程资源,所以线程之间切换的成本远小于进程切换,因此线程也被称为轻量级进程。

在 Java 中,启动 main 方法实际上就是启动了一个 JVM 进程,main 线程是该进程的主线程,同时 JVM 还会创建一些辅助线程,例如 GC 线程、引用处理线程等,因此 Java 天生就是多线程的。

Q1:线程为什么比进程切换快?

A:因为线程共享进程的堆和方法区,只需要切换栈、寄存器和程序计数器,不需要切换整个地址空间,因此开销更小。

Q2:为什么说 Java 天生是多线程?

A:因为 JVM 启动后,不仅有 main 线程,还会自动启动 GC 线程、Finalizer 线程等后台线程,因此即使没有显式创建线程,程序也是多线程运行的。

Q3:Java 线程有哪些方式创建?

A:主要有三种:继承 Thread 类、实现 Runnable 接口、实现 Callable 接口 + FutureTask(可以返回结果)

Java 线程和操作系统的线程有什么区别?

现代 Java 线程的本质就是操作系统线程。

早期 Java 线程并没有直接依赖操作系统,而是自己实现了一套线程机制,基于绿色线程(Green Threads)实现的,由 JVM 在用户态自行调度线程,不依赖操作系统内核,因此无法利用多核 CPU。

从 JDK 1.2 开始,Java 线程改为基于原生线程(Native Threads)实现,即 JVM 将 Java 线程直接映射为操作系统的内核线程,由操作系统负责线程的调度和管理。

目前主流的 HotSpot JVM 在 Windows 和 Linux 上采用的是一对一线程模型,即一个 Java 线程对应一个操作系统线程,从而可以充分利用多核 CPU 提升并发性能。

Q1:为什么 Java 要放弃绿色线程?

A:因为绿色线程由 JVM 调度,无法利用多核 CPU,同时无法使用操作系统级能力(如异步 I/O),性能和扩展性较差,因此改为原生线程模型。

Q2:Java 为什么选择一对一模型?

A:因为一对一模型可以直接利用操作系统线程调度,支持多核并行执行,性能更好,且实现简单稳定。

Q3:Java 线程切换是谁负责的?

A:在现代 JVM 中,线程切换由操作系统内核负责,JVM 不再参与线程调度。

1. 多核 CPU?

多核 CPU 指的是:一个 CPU 芯片内部集成了多个"计算核心(Core)",每个核心都可以独立执行指令。

  • 并发(Concurrency):多个任务交替执行(单核也可以)
  • 并行(Parallelism):多个任务真正同时执行(多核支持)

Java 线程本质上是 OS 线程:JVM 创建线程 → OS 调度 → 分配到 CPU 核心执行。

2. 内核

内核是操作系统的核心部分,负责管理系统的硬件资源,包括CPU、内存、磁盘和设备等,同时负责进程调度和内存分配。

操作系统通常分为用户态和内核态,用户程序运行在用户态,权限较低,不能直接访问硬件资源;而内核运行在内核态,拥有最高权限,可以直接操作硬件。

当用户程序需要访问硬件资源时,需要通过系统调用进入内核态,由内核统一进行资源管理和调度。

因此,内核的作用可以概括为:资源管理者 + 系统调度者 + 硬件抽象层。

线程与进程的关系、区别、优缺点

一个进程中可以包含多个线程,多个线程共享进程的堆和方法区资源,但每个线程拥有独立的程序计数器、虚拟机栈和本地方法栈。线程是进程中更小的执行单位,多个线程共享进程资源的同时也相互独立,因此线程执行开销更小,但也更容易产生并发问题。

程序计数器之所以是线程私有的,是因为它用于记录当前线程的执行位置,线程切换后需要通过它恢复执行状态,如果共享会导致执行顺序混乱。

虚拟机栈和本地方法栈也是线程私有的,因为它们用于存储线程执行方法时的局部变量、方法栈帧等信息,如果共享会导致线程之间数据互相干扰。

堆和方法区是线程共享的,其中堆用于存储对象实例,方法区用于存储类信息、常量和静态变量,这些资源需要被所有线程访问。

线程怎么创建?

创建线程有很多种方式,比如继承 Thread 类、实现 Runnable、实现 Callable、线程池...不过,这些方式其实并没有真正创建出线程,这些都属于是在 Java 代码中使用多线程的方法。

真正创建线程的方式只有一种:new Thread().start()。

Q:为什么实际开发不推荐 new Thread()?

A:因为频繁创建和销毁线程成本高,容易浪费系统资源,因此实际开发推荐使用线程池统一管理线程。

线程的生命周期和状态

1)NEW(新建):线程对象已经创建,但还没启动。eg:Thread t = new Thread();

2)RUNNABLE(可运行 / 运行中):调用 start() 后进入该状态。eg:t.start();

3)BLOCKED(阻塞等锁):等待锁的时候进入。eg:synchronized(lock) {}

4)WAITING(无限等待):线程主动等待别人唤醒。eg:obj.wait();thread.join(); 此时必须依赖:notify()、notifyAll()、interrupt()才能恢复。

5)TIMED_WAITING(超时等待):和 WAITING 类似,但是带时间。eg:Thread.sleep(1000)、wait(1000)、join(1000),时间到了自动恢复。

6)TERMINATED(终止):线程执行完毕。eg:run() 执行结束

线程状态会随着代码执行不断切换,例如 wait() 会进入 WAITING,sleep() 会进入 TIMED_WAITING,获取不到锁会进入 BLOCKED。

Q:为什么 Java 只有 6 种状态?

A:因为 JVM 对操作系统状态做了抽象,尤其将 READY 和 RUNNING 合并为 RUNNABLE,因此只有 6 种。JVM 层面将 READY 和 RUNNING 状态统一都属于 RUNNABLE,也就是 Java 不区分"正在抢 CPU"和"真正执行"。因为 CPU 时间片切换极快,一个线程可能只执行 10ms,刚拿到 CPU,马上又被切走,区分意义不大。

线程上下文切换

线程上下文切换指的是 CPU 从当前执行线程切换到另一个线程执行的过程。

由于线程在执行过程中需要保存自己的运行现场,例如程序计数器、虚拟机栈、CPU 寄存器和线程状态等信息,因此在切换线程时需要先保存当前线程的上下文,再恢复目标线程之前保存的上下文,这个过程就称为线程上下文切换。

常见触发场景包括:

  1. 时间片用完
  2. 调用 sleep()、wait() 等主动让出 CPU
  3. 调用了阻塞类型的系统中断,例如 IO 阻塞
  4. 线程被终止或执行结束

上下文切换会带来一定性能开销,因此线程数量过多会导致频繁切换,反而降低系统整体性能。

Q:为什么线程多了反而变慢?

A:因为线程过多会导致频繁上下文切换,CPU 大量时间消耗在保存和恢复线程现场,而不是执行业务逻辑。

Q:如何减少上下文切换?

A:合理设置线程池大小、减少锁竞争、避免创建过多线程

Thread#sleep() 方法和 Object#wait() 方法

  1. 是否释放锁(最重要)

Thread.sleep() → 让当前线程休眠一段时间,不释放锁。

Object.wait() → 让线程进入等待,并释放锁,等待其他线程唤醒,目的是线程通信。

  1. 唤醒方式不同

sleep 自动恢复,时间到了自动醒。

wait 必须通知,否则一直等,需要 notify() 或 notifyAll() 唤醒。

  1. 所属类不同

sleep 属于 Thread,Thread.sleep(),因为它控制的是当前线程暂停。

wait 属于 Object,obj.wait(),因为它控制的是某个对象锁上的线程等待队列,是锁级别操作。

实际开发中怎么用?

sleep 常见场景:重试等待、定时轮询、限流测试。

wait 常见场景:生产者消费者、线程协作、阻塞队列底层思想。

Q:为什么 wait 必须释放锁?

A:因为 wait 的核心目的是线程通信。如果不释放锁,其他线程无法进入同步代码块执行 notify(),等待线程将永远无法被唤醒。

为什么 wait() 定义在 Object,不定义在 Thread 中?

**wait 是锁级别操作,不是线程级别操作。**Java 中每个对象都可以作为锁。

java 复制代码
Object lock = new Object();
synchronized(lock) {
    lock.wait();
}

当前线程在 lock 这把锁上等待,所以 wait 必须属于Object。

那为什么 sleep() 在 Thread 中?

因为 sleep() 操作的是当前执行线程暂停,和对象锁无关。

java 复制代码
Thread.sleep(1000);

它不需要知道哪个对象,所以定义在Thread。

可以直接调用 Thread 的 run() 方法吗?

run() 方法可以直接调用,但它只是普通方法调用,不会创建新线程。只有调用 start() 方法,才会真正启动线程并由 JVM 调度执行 run() 方法,实现多线程。

start() 和 run() 本质区别是:

start() 真正启动线程,会启动一个线程并使线程进入了就绪状态,当分配到时间片后就可以开始运行了,NEW → RUNNABLE → CPU调度 → run()。

run() 只是普通方法调用,例如 main 线程调用 run() 那么仍然是 main 线程执行,不会创建新线程。

😺多线程

并发与并行的区别

并发看时间段,并行看同一时刻。

并发:两个或多个任务在同一时间段内交替执行。本质是 CPU 时间片轮转调度,切换速度非常快,看起来像同时执行。

并行:两个或多个任务在同一时刻真正同时执行。例如:双核 CPU、四核 CPU,两个线程分别跑在两个核心上,这才叫真正的同时执行。

Q:单核 CPU 能并行吗?

A:不能。单核 CPU 只能并发执行,通过时间片轮转实现"看起来同时执行"。

同步与异步的区别

同步:调用一个方法后,必须等待结果返回。

异步:调用后立刻返回,不等结果。

为什么要使用多线程?

1)提高 CPU 和 IO 资源利用率(单核时代)

单线程时,如果线程在等 IO,CPU 就空闲了,如果多线程,CPU 不会闲着。

2)充分利用多核 CPU 实现并行计算(现代核心点)

现在服务器基本都是4核、8核、16核,如果只有一个线程,只用1个CPU核心,浪费资源。

3)支撑高并发系统,提高整体吞吐量和响应速度

互联网系统本质都依赖多线程,比如 Tomcat 一个请求一个工作线程,提升响应速度。

Q:多线程一定能提升性能吗?

A:不一定。如果线程之间锁竞争严重,或者线程数过多导致频繁上下文切换,性能反而下降。

Q:多线程为什么适合 IO 密集型任务?

A:因为一个线程阻塞等待 IO 时,其他线程可以继续执行,提高 CPU 利用率。

单核 CPU 支持 Java 多线程吗?

!!!多线程 ≠ 多核并行,单核支持并发,不支持真正并行 !!!

**单核 CPU 是支持 Java 多线程的。操作系统通过时间片轮转的方式,将 CPU 的时间分配给不同的线程。**尽管单核 CPU 一次只能执行一个任务,但通过快速在多个线程之间切换,可以让用户感觉多个任务是同时进行的。

Java 线程本质是操作系统线程,操作系统主要通过两种线程调度方式来管理多线程的执行:

  • 抢占式调度: 操作系统决定何时暂停当前正在运行的线程,并切换到另一个线程执行。这种切换通常是由系统时钟中断(时间片轮转)或其他高优先级事件(如 I/O 操作完成)触发的。
    优点:公平性和 CPU 资源利用率较好,不易阻塞。
    缺点:存在上下文切换开销。
  • 协同式调度: 线程执行完毕后,主动通知系统切换到另一个线程。
    优点:可以减少上下文切换带来的性能开销。
    缺点:公平性较差,容易阻塞,如果一个线程不主动释放,其他线程全卡住。现在几乎不用。

Java 使用的线程调度是抢占式的。也就是说,JVM 本身不负责线程的调度,而是将线程的调度委托给操作系统。操作系统通常会基于线程优先级和时间片来调度线程的执行,高优先级的线程通常获得 CPU 时间片的机会更多。

Q:为什么线程执行顺序不确定?

A:因为 Java 采用抢占式调度,由操作系统根据时间片和优先级动态分配 CPU。

I/O 完成中断是指输入输出设备在完成数据读写后,通过硬件中断机制主动通知 CPU 和操作系统的一种方式。

例如磁盘读取、网络数据返回后,设备控制器会向 CPU 发送中断信号,CPU 暂停当前任务处理中断,操作系统再唤醒等待该 I/O 的线程继续执行。

这种机制避免了 CPU 持续轮询设备状态,提高了系统整体资源利用率。

单核 CPU 上运行多个线程效率一定会高吗?

不一定。单核 CPU 同时运行多个线程的效率是否会高,取决于线程的类型和任务的性质。

一般来说,有两种类型的线程:

  1. CPU 密集型:CPU 密集型的线程主要进行计算和逻辑处理,需要占用大量的 CPU 资源。
  2. IO 密集型:IO 密集型的线程主要进行输入输出操作,如读写文件、网络通信等,需要等待 IO 设备的响应,而不占用太多的 CPU 资源。

在单核 CPU 上,同一时刻只能有一个线程在运行,其他线程需要等待 CPU 的时间片分配。如果线程是 CPU 密集型的,那么多个线程同时运行会导致频繁的线程切换,增加了系统的开销,降低了效率。如果线程是 IO 密集型的,那么多个线程同时运行可以利用 CPU 在等待 IO 时的空闲时间,提高了效率。

因此,对于单核 CPU 来说,如果任务是 CPU 密集型 的,那么开很多线程会影响效率 ;如果任务是IO 密集型 的,那么开很多线程会提高效率。当然,这里的"很多"也要适度,线程过多会导致内存占用和上下文切换开销增加,因此通常通过线程池控制线程数量。

实际开发怎么用?

Redis / MySQL 属于 IO 密集型,适合多线程。

大数据计算 / 推荐算法属于CPU 密集型,线程不能乱开。

线程池核心线程数怎么设置?

线程池核心线程数是指线程池中"长期存活、不被回收"的线程数量,它决定了系统并发能力基础、CPU利用率、任务处理速度。

核心线程数的本质是系统稳定并发能力的上限(基础承载能力)。

CPU 密集型(CPU 一直在忙):core = CPU核心数 + 1,CPU 已经满负载,多一个线程是为了防止 CPU 空闲。

IO 密集型(CPU 经常空闲):core = CPU核心数 × 2 ~ CPU核心数 × 10

  • 太少:浪费性能
  • 太多:上下文切换严重
  • 本质:平衡 CPU利用率 vs 切换成本

使用多线程可能带来什么问题?

并发编程的目的就是为了能提高程序的执行效率进而提高程序的运行速度,但是并发编程并不总是能提高程序运行速度的,而且并发编程可能会遇到很多问题,比如:线程不安全、死锁、内存泄漏等等。

  • 线程安全问题:数据不一致 / 数据丢失。
  • 死锁:多个线程互相等待对方释放资源,导致谁都无法继续执行。
  • 内存泄漏 / 资源泄漏:在 Java 中更常见的是线程资源泄漏,比如线程池线程没有正确释放。
  • 上下文切换开销:线程太多时,保存线程状态、恢复线程状态会消耗 CPU,可能导致性能反而下降。
  • 活锁 / 饥饿:活锁是线程都在让步,但谁也做不成事。饥饿是某些线程长期抢不到 CPU 或锁。

如何理解线程安全和不安全?

**线程安全和不安全是在多线程环境下对于同一份数据的访问是否能够保证其正确性和一致性的描述。**线程安全指的是在多线程环境下,对于同一份数据,不管有多少个线程同时访问,都能保证这份数据的正确性和一致性。线程不安全则表示在多线程环境下,对于同一份数据,多个线程同时访问时可能会导致数据混乱、错误或者丢失。

😺死锁

线程死锁描述的是这样一种情况:多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止。

产生死锁的四个必要条件:

  1. 互斥条件:该资源任意一个时刻只由一个线程占用。
  2. 请求与保持条件:一个线程因请求资源而阻塞时,对已获得的资源保持不放。
  3. 不剥夺条件:线程已获得的资源在未使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源。
  4. 循环等待条件:若干线程之间形成一种头尾相接的循环等待资源关系。

如何预防死锁? 破坏死锁的产生的必要条件即可:

  1. 破坏请求与保持条件:一次性申请所有的资源。
  2. 破坏不剥夺条件:占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。
  3. 破坏循环等待条件:靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。破坏循环等待条件。

如何避免死锁?

避免死锁就是在资源分配时,借助于算法(比如银行家算法:每次分配资源前,先模拟一下未来是否安全)对资源分配进行计算评估,使其进入安全状态。

😺JMM(Java 内存模型 Java Memory Model)

它不是"真实的物理内存",它本质上是一套规范,用来规定多线程之间如何读写共享变量。

**为什么要有 JMM?**因为在多线程环境下,多个线程都可能访问同一个变量。比如线程 A 修改,线程 B 读取,如果没有统一规则,就会出现 A 改了 B 却看不到,这就是经典的可见性问题。

JMM 的核心结构:

主内存(共享区域,所有线程共享)

工作内存(每个线程自己的"本地副本")

线程不会直接频繁操作主内存,而是先拷贝一份到自己的工作内存,正是因为如此,才会出现可见性问题。

volatile 如何保证可见性

volatile 关键字主要用于保证变量的可见性。被 volatile 修饰的变量,每次写操作都会立即刷新到主内存,每次读操作都会直接从主内存读取最新值。其底层通过内存屏障实现,禁止线程使用本地缓存中的旧值。

java 复制代码
volatile boolean flag = true;

volatile 强制规定:每次读都必须从主内存读,每次写都必须立即刷新回主内存。

volatile 不能保证原子性,volatile 只能保证看得见,不能保证操作不可分割。

实际开发最常见:状态标志位、双重检查单例。

synchronized 为什么都能保证?synchronized 能保证可见性,也能保证原子性,因为它加锁后同一时刻只有一个线程执行。

Q:volatile 为什么不能保证 count++ 安全?

A:因为 count++ 不是原子操作,包含读、改、写三个步骤。

Q:volatile 和 synchronized 区别?

A:volatile 只保证可见性。synchronized 同时保证可见性和原子性。

如何禁止指令重排序

CPU 和 JVM 为了提高执行效率,会对代码执行顺序做优化,即不影响单线程结果的前提下,调整代码执行顺序,这就叫指令重排序,目的是提高吞吐量。

为什么多线程下会出问题?

volatile 除了保证可见性,还能禁止特定的指令重排序

四种内存屏障:

  1. LoadLoad:读 -> 屏障 -> 读,保证前面的读先完成。
  2. StoreStore:写 -> 屏障 -> 写,保证前面的写先完成。
  3. LoadStore:读 -> 屏障 -> 写,防止后面的写跑到前面。
  4. StoreLoad:写 -> 屏障 -> 读,StoreLoad 是全能屏障,这是开销最大,也是最关键的。

volatile 读写操作的内存屏障插入策略

volatile 写的屏障策略:

写前插入 StoreStore(保证普通写必须先完成)。

写后插入 StoreLoad(禁止后续读写重排到前面)。

volatile 读的屏障策略:

读后插入 LoadLoad、LoadStore(保证后续操作不能跑到 volatile 读前面)。

这些内存屏障可以保证特定顺序的指令不会发生重排序。

应用一:状态标志位。

java 复制代码
volatile boolean stop = false;
// 线程停止控制:
while(!stop){}

应用二:在双重检查锁单例模式中,volatile 保证对象引用发布前一定初始化完成。

java 复制代码
private volatile static Singleton instance;

Q:volatile 底层怎么实现?

A:通过插入内存屏障。

volatile 与 happens-before 的关系

对一个 volatile 变量的写操作 happens-before 于后续对该 volatile 变量的读操作。

也就是说,如果线程 A 写入了一个 volatile 变量,线程 B 随后读取了同一个 volatile 变量,那么线程 A 在写入 volatile 变量之前所做的所有修改(包括对非 volatile 变量的修改),对线程 B 都是可见的。

java 复制代码
public class VolatileHappensBeforeDemo {
    private int a = 0;
    private int b = 0;
    private volatile boolean flag = false;

    // 线程 A 执行
    public void writer() {
        a = 1;           // 操作1:普通写
        b = 2;           // 操作2:普通写
        flag = true;     // 操作3:volatile 写
    }

    // 线程 B 执行
    public void reader() {
        if (flag) {      // 操作4:volatile 读
            int x = a;   // 操作5:普通读,x 一定等于 1
            int y = b;   // 操作6:普通读,y 一定等于 2
            System.out.println("x=" + x + ", y=" + y);
        }
    }
}

上面代码中,happens-before 关系链如下:

  1. 操作1、操作2 happens-before 操作3(程序顺序规则:同一线程中,前面的操作 happens-before 后面的操作)
  2. 操作3 happens-before 操作4(volatile 变量规则:volatile 写 happens-before volatile 读)
  3. 操作4 happens-before 操作5、操作6(程序顺序规则

根据 传递性 :操作1、操作2 happens-before 操作5、操作6。因此,当线程 B 在操作4 读取到 flag == true 时,线程 A 在操作3 之前对 a 和 b 的修改对线程 B 一定是可见的。这里的关键在于:volatile 变量的写-读操作,不仅保证了 volatile 变量本身的可见性,还通过 happens-before 的传递性"顺带"保证了其前后普通变量的可见性。

这也解释了为什么在实际开发中,volatile 经常被用作 状态标志位(如上面例子中的 flag),它可以在不使用锁的情况下,安全地在线程间传递状态信息,同时保证相关数据的可见性。

volatile 可以保证原子性么?

volatile 关键字能保证变量的可见性,但不能保证对变量的操作是原子性的。

很多人会误认为自增操作 inc++ 是原子性的,实际上,inc++ 其实是一个复合操作,包括三步:

  • 读取 inc 的值。
  • 对 inc 加 1。
  • 将 inc 的值写回内存。

volatile 是无法保证这三个操作是具有原子性的。

使用 synchronized 改进:

java 复制代码
public synchronized void increase() {
    inc++;
}

使用 AtomicInteger 改进:

java 复制代码
public AtomicInteger inc = new AtomicInteger();

public void increase() {
    inc.getAndIncrement();
}

使用 ReentrantLock 改进:

java 复制代码
Lock lock = new ReentrantLock();
public void increase() {
    lock.lock();
    try {
        inc++;
    } finally {
        lock.unlock();
    }
}

😺乐观锁和悲观锁

什么是悲观锁?

悲观锁总是假设最坏的情况,认为共享资源每次被访问的时候就会出现问题(比如共享数据被修改),所以每次在获取资源操作的时候都会上锁,这样其他线程想拿到这个资源就会阻塞直到锁被上一个持有者释放。也就是说,共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程。像 Java 中synchronized和ReentrantLock等独占锁就是悲观锁思想的实现。

java 复制代码
public void performSynchronisedTask() {
    synchronized (this) {
        // 需要同步的操作
    }
}

private Lock lock = new ReentrantLock();
lock.lock();
try {
   // 需要同步的操作
} finally {
    lock.unlock();
}

高并发的场景下,激烈的锁竞争会造成线程阻塞,大量阻塞线程会导致系统的上下文切换,增加系统的性能开销。并且,悲观锁还可能会存在死锁问题,影响代码的正常运行。

什么是乐观锁?

乐观锁总是假设最好的情况,认为共享资源每次被访问的时候不会出现问题,线程可以不停地执行,无需加锁也无需等待,只是在提交修改的时候去验证对应的资源(也就是数据)是否被其它线程修改了(具体方法可以使用版本号机制或 CAS 算法)

在 Java 中 java.util.concurrent.atomic 包下面的原子变量类(比如AtomicInteger、LongAdder)就是使用了乐观锁的一种实现方式 CAS 实现的。

java 复制代码
// LongAdder 在高并发场景下会比 AtomicInteger 和 AtomicLong 的性能更好
// 代价就是会消耗更多的内存空间(空间换时间)
LongAdder sum = new LongAdder();
sum.increment();

高并发的场景下,乐观锁相比悲观锁来说,不存在锁竞争造成线程阻塞,也不会有死锁的问题,在性能上往往会更胜一筹。但是如果冲突频繁发生(写占比非常多的情况),会频繁失败和重试,这样同样会非常影响性能,导致 CPU 飙升(LongAdder 以空间换时间的方式就解决了这个问题)。

  • 悲观锁通常多用于写比较多的情况(多写场景,竞争激烈),这样可以避免频繁失败和重试影响性能,悲观锁的开销是固定的。不过,如果乐观锁解决了频繁失败和重试这个问题的话(比如LongAdder),也是可以考虑使用乐观锁的,要视实际情况而定。
  • 乐观锁通常多用于写比较少的情况(多读场景,竞争较少),这样可以避免频繁加锁影响性能。不过,乐观锁主要针对的对象是单个共享变量(参考java.util.concurrent.atomic包下面的原子变量类)。

如何实现乐观锁?

版本号机制:一般是在数据表中加上一个数据版本号 version 字段,表示数据被修改的次数。当数据被修改时,version 值会加一。当线程 A 要更新数据值时,在读取数据的同时也会读取 version 值,在提交更新时,若刚才读取到的 version 值为当前数据库中的 version 值相等时才更新,否则重试更新操作,直到更新成功。

CAS 算法 :Compare And Swap(比较与交换),CAS 的思想很简单,就是用一个预期值和要更新的变量值进行比较,两值相等才会进行更新。

CAS 是一个原子操作,底层依赖于一条 CPU 的原子指令。

CAS 涉及到三个操作数:
-- V :要更新的变量值(Var)
-- E :预期值(Expected)
-- N :拟写入的新值(New)

当且仅当 V 的值等于 E 时,CAS 通过原子方式用新值 N 来更新 V 的值。如果不等,说明已经有其它线程更新了 V,则当前线程放弃更新。

Q:CAS 为什么性能高?

A:因为不涉及线程阻塞和上下文切换,属于无锁非阻塞方案。

Unsafe类中的 CAS 方法是native方法。native关键字表明这些方法是用本地代码(通常是 C 或 C++)实现的,而不是用 Java 实现的。这些方法直接调用底层的硬件指令来实现原子操作。也就是说,Java 语言并没有直接用 Java 实现 CAS,而是通过 C++ 内联汇编的形式实现的(通过 JNI 调用)。因此,CAS 的具体实现与操作系统以及 CPU 密切相关。

CAS 算法存在哪些问题?

ABA 问题 :如果一个变量 V 初次读取的时候是 A 值,在这段时间它的值可能被改为其他值,然后又改回 A,那 CAS 操作就会误认为它从来没有被修改过,这个问题被称为 CAS 操作的 "ABA"问题。 ABA 问题的解决思路是在变量前面追加上版本号或者时间戳

JDK 1.5 以后的 AtomicStampedReference 类就是用来解决 ABA 问题的,其中的 compareAndSet() 方法就是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。

循环时间长开销大:CAS 经常会用到自旋操作来进行重试,也就是不成功就一直循环执行直到成功。如果长时间不成功,会给 CPU 带来非常大的执行开销。

只能保证一个共享变量的原子操作:CAS 操作仅能对单个共享变量有效。当需要操作多个共享变量时,CAS 就显得无能为力。

|--------------|----------------------------------------|-------------------------------|
| | 乐观锁 (Optimistic Locking) | 悲观锁 (Pessimistic Locking) |
| 核心假设 | 假设冲突很少发生,提交时才验证。 | 假设冲突必然发生,读取时就加锁。 |
| 底层原理 | CAS (Compare And Swap) 或版本号机制。 | 操作系统互斥锁,涉及内核态切换。 |
| 阻塞情况 | 非阻塞。失败后由业务逻辑决定是否重试。 | 阻塞。其他线程必须排队等待锁释放。 |
| 并发开销 | CPU 消耗(高并发写时频繁自旋重试)。 | 上下文切换开销(线程挂起与唤醒)。 |
| 死锁风险 | 无死锁(因为不涉及持有锁的等待)。 | 有死锁风险(多个锁相互等待)。 |
| 数据库实现 | UPDATE ... SET version = version + 1 | SELECT ... FOR UPDATE |
| Java 代表类 | AtomicInteger、LongAdder、StampedLock | synchronized、ReentrantLock |
| 适用场景 | 多读少写、并发冲突概率低的业务。 | 多写少读、数据一致性要求极高的核心业务。 |

😺synchronized 关键字

synchronized 是 Java 中的一个关键字,翻译成中文是同步的意思,主要解决的是多个线程之间访问资源的同步性,可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。

synchronized 关键字的使用方式主要有下面 3 种:

  1. 修饰实例方法
  2. 修饰静态方法
  3. 修饰代码块

1、修饰实例方法 (锁当前对象实例)

给当前对象实例加锁,进入同步代码前要获得 当前对象实例的锁

java 复制代码
synchronized void method() {
    //业务代码
}

2、修饰静态方法 (锁当前类)

给当前类加锁,会作用于类的所有对象实例 ,进入同步代码前要获得 当前 class 的锁。这是因为静态成员不属于任何一个实例对象,归整个类所有,不依赖于类的特定实例,被类的所有实例共享。

java 复制代码
synchronized static void method() {
    //业务代码
}

3、修饰代码块 (锁指定对象/类)

对括号里指定的对象/类加锁:

  • synchronized(object) 表示进入同步代码块前要获得 给定对象的锁
  • synchronized(类.class) 表示进入同步代码块前要获得 给定 Class 的锁
java 复制代码
synchronized(this) {
    //业务代码
}
  • synchronized 关键字加到 static 静态方法和 synchronized(class) 代码块上都是是给 Class 类上锁;
  • synchronized 关键字加到实例方法上是给对象实例上锁;
  • 尽量不要使用 synchronized(String a) 因为 JVM 中,字符串常量池具有缓存功能。

synchronized 底层原理

同步代码块 底层本质是对象监视器 monitor 机制

java 复制代码
monitorenter
业务代码
monitorexit

monitor = 对象监视器,每个 Java 对象都关联一个 Monitor,线程进入 monitorenter 就是抢这个 monitor 锁,执行结束 monitorexit 释放锁。

同步方法 底层使用方法标记 ACC_SYNCHRONIZED,JVM看到这个标志,就知道这是同步方法,然后自动加锁。

synchronized vs volatile

|------------|-------------------------------------------------|------------------------------------------|
| | volatile | synchronized |
| 实现层面 | 通过插入内存屏障指令实现,不涉及线程阻塞和上下文切换 | 依赖操作系统的互斥锁(Mutex Lock),涉及用户态与内核态的切换 |
| 读操作开销 | 与普通变量几乎相同 | 需要获取 monitor 锁,即使无竞争也有一定开销(偏向锁/轻量级锁 CAS) |
| 写操作开销 | 需要插入 StoreStore + StoreLoad 内存屏障,有一定开销但不会导致线程阻塞 | 需要获取和释放 monitor 锁,有竞争时会导致线程阻塞和上下文切换 |
| 竞争时的表现 | 不会导致线程阻塞,始终是非阻塞的 | 线程竞争激烈时,会频繁发生阻塞和唤醒,上下文切换开销大 |
| 功能范围 | 只能修饰变量,只保证可见性和有序性 | 可以修饰方法和代码块,同时保证可见性、有序性和原子性 |

😺ReentrantLock

ReentrantLock 实现了 Lock 接口,是一个可重入且独占式的锁,和 synchronized 关键字类似。不过,ReentrantLock 更灵活、更强大,增加了轮询、超时、中断、公平锁和非公平锁等高级功能。

java 复制代码
Lock lock = new ReentrantLock();

lock.lock();
try {
    // 业务代码
} finally {
    lock.unlock();
}

公平锁和非公平锁有什么区别?

公平锁 : 锁被释放之后,先申请的线程先得到锁。性能较差一些,因为公平锁为了保证时间上的绝对顺序,上下文切换更频繁。

非公平锁:锁被释放之后,后申请的线程可能会先获取到锁,是随机或者按照其他优先级排序的。性能更好,但可能会导致某些线程永远无法获取到锁。

synchronized vs ReentrantLock

synchronized 和 ReentrantLock 都是 Java 中用于解决多线程同步问题的锁,两者本质上都是可重入锁。

可重入锁 = 递归锁:同一个线程已经拿到锁后,可以再次获取同一把锁,不会死锁。

java 复制代码
public synchronized void method1() {
    method2();
}

public synchronized void method2() {
}

区别:

  • 实现层级:synchronized 属于 JVM 关键字,是 JVM 层面的锁。ReentrantLock 是 Java 类,是 API 层面的锁。
  • 自动释放和手动释放:synchronized 是自动释放锁,ReentrantLock 必须手动释放
  • 是否支持公平锁:synchronized 只能非公平锁,抢到算谁的。ReentrantLock 支持公平 / 非公平。
  • 是否可中断:synchronized 等待锁时不能中断,线程只能一直等。ReentrantLock 支持可中断等待。
  • 是否支持超时:synchronized 不支持,只能死等。ReentrantLock支持超时,lock.tryLock(3, TimeUnit.SECONDS),超时失败直接返回。

可中断锁和不可中断锁有什么区别?

它们的区别在于:线程在获取锁的过程中被阻塞时,是否能够因为中断而提前放弃等待。

不可中断锁:线程在等待锁期间即使收到中断信号,也不会退出阻塞状态,而是一直等待直到获得锁。中断状态会被保留,但不会影响锁的获取过程。

synchronized 属于典型的不可中断锁,ReentrantLock # lock() 也是不可中断的。

可中断锁:线程在等待锁的过程中如果收到中断信号,会立即停止等待并抛出 InterruptedException,从而有机会进行取消或错误处理。

ReentrantLock # lockInterruptibly() 实现了可中断锁。

ReentrantLock # tryLock(long time, TimeUnit unit) (带超时的尝试获取)也是可中断的。

😺Atomic 原子类

Atomic 原子类是 JUC 包中提供的一组线程安全工具类,位于 java.util.concurrent.atomic 包下。它们主要用于对单个变量进行原子操作 ,底层基于 CAS 和 volatile实现,无需使用传统锁机制。

Atomic 原子类,即该操作不可分割、不可中断。即使在多个线程同时执行时,该操作要么全部执行完成,要么不执行,不会被其他线程看到部分完成的状态。

JUC 包中的原子类分为 4 类:

  • 基本类型:AtomicInteger 整型原子类、AtomicLong 长整型原子类、AtomicBoolean 布尔型原子类。
  • 数组类型:AtomicIntegerArray 整型数组原子类、AtomicLongArray 长整型数组原子类、AtomicReferenceArray 引用类型数组原子类。
  • 引用类型:AtomicReference引用类型原子类、AtomicMarkableReference 原子更新带有标记的引用类型、AtomicStampedReference 原子更新带有版本号的引用类型。
  • 对象的属性修改类型:AtomicIntegerFieldUpdater 原子更新整型字段的更新器、AtomicLongFieldUpdater 原子更新长整型字段的更新器、AtomicReferenceFieldUpdater 原子更新引用类型里的字段。

😺ThreadLocal

ThreadLocal 的作用是为每个线程提供独立的变量副本,从而避免多线程之间共享变量导致的线程安全问题。

ThreadLocal 原理

每个 Thread 中都具备一个 ThreadLocalMap,而 **ThreadLocalMap 可以存储以 ThreadLocal 为 key ,Object 对象为 value 的键值对。**最终的变量是放在了当前线程的 ThreadLocalMap 中,并不是存在 ThreadLocal 上,ThreadLocal 可以理解为只是ThreadLocalMap的封装,传递了变量值。ThrealLocal 类中可以通过 Thread.currentThread() 获取到当前线程对象后,直接通过 getMap(Thread t) 可以访问到该线程的 ThreadLocalMap 对象。

比如我们在同一个线程中声明了两个 ThreadLocal 对象的话, Thread内部都是使用仅有的那个ThreadLocalMap 存放数据的,ThreadLocalMap的 key 就是 ThreadLocal对象,value 就是 ThreadLocal 对象调用set方法设置的值。

Q:ThreadLocal 为什么线程安全?

A:因为每个线程内部都有独立的 ThreadLocalMap,线程之间互不共享数据,因此天然线程安全。

Q:ThreadLocal 和 synchronized 区别?

A:synchronized 是通过加锁实现线程同步,ThreadLocal 是通过线程隔离避免共享。一个是"共享后加锁",一个是"根本不共享"。

ThreadLocal 内存泄露问题

java 复制代码
Thread
   └── map
       └── Entry
            key   -> ThreadLocal对象(弱引用)
            value -> "hello"(强引用)

当 ThreadLocal 对象失去外部强引用后,在下一次 GC 时 key 会被回收,变为 null。

但是由于 value 仍然被当前线程内部的 ThreadLocalMap.Entry 强引用,因此无法被垃圾回收。

但是普通线程执行完会销毁:线程结束 → map销毁 → value释放,问题不大。

线程池线程最危险。

java 复制代码
ExecutorService pool = Executors.newFixedThreadPool(10);

线程可能一直活着,于是 Thread 一直活,Map 一直活,value 一直活,久而久之:大量脏数据堆积 → 内存泄漏。

也就是说,内存泄漏的发生需要同时满足两个条件:

  1. ThreadLocal 实例不再被强引用;
  2. 线程持续存活,导致 ThreadLocalMap 长期存在。

如何避免?用完必须 remove()。remove() 方法会从 ThreadLocalMap 中显式地移除对应的 entry,彻底解决内存泄漏的风险。

java 复制代码
ThreadLocal<User> tl = new ThreadLocal<>();

try {
    tl.set(user);
    // 业务逻辑
} finally {
    tl.remove();
}

JDK 为什么把 ThreadLocalMap.Entry 的 key 设计成弱引用?

第一层:弱引用(尽快释放 key)

弱引用不是为了解决内存泄漏,而是为了降低泄漏风险。如果 key 是强引用,ThreadLocal 对象本身都回收不了,结果就是:key 和 value 一起泄漏

这就是设计初衷------先保证 ThreadLocal 对象本身可以释放。

弱引用能彻底解决泄漏吗?不能,value 仍可能泄漏。弱引用只是兜底,不是根治方案。

第二层:被动清理(顺带清理 value)

如何补救?JDK 做了第二层防御,在 get()、set()、remove() 时会顺带清理 key == null 的过期 Entry,从而释放对应的 value。

第三层:remove彻底清理(清理 value)

为什么 remove 仍然必须调用?因为清理机制是被动触发,如果线程长期不再调用 get/set,脏数据可能一直存在,尤其线程池。

所以完整设计思路是:弱引用降低风险,remove 彻底解决问题。

Q:为什么 key 要设计成弱引用?

A:为了防止 ThreadLocal 对象本身无法被回收。如果 key 是强引用,那么即使业务代码不用了,ThreadLocal 也无法释放。

Q:既然弱引用不能根治,为什么还要这么设计?

A:这是双层防御设计。弱引用先保证 key 可释放,再配合被动清理机制尽量回收 value,降低开发者忘记 remove 的风险。弱引用设计是为了尽可能减少 ThreadLocal 对象本身的泄漏风险。真正的问题不在 key,而在 value 需要开发者主动 remove。

Q:线程池为什么更容易泄漏?

A:因为线程池线程会长期复用,不会销毁,导致 ThreadLocalMap 长期存在,脏 value 无法释放。

如何跨线程传递 ThreadLocal 的值?

ThreadLocal 的值本质上存储在当前线程内部的 ThreadLocalMap 中,而不是存储在 ThreadLocal 对象本身中。因此当任务从当前线程切换到异步线程执行时,由于不同线程拥有各自独立的 ThreadLocalMap,默认情况下无法获取父线程中的 ThreadLocal 值。

解决方案:一套是 JDK 原生的,另一套是阿里巴巴开源的。

InheritableThreadLocal :JDK提供的一个类,继承自 ThreadLocal。使用 InheritableThreadLocal 时,会在创建子线程时,令子线程继承父线程中的 ThreadLocal 值。

这个方案的缺陷在于它的一次性,它只在线程创建时发生一次复制,无法支持线程池场景下的 ThreadLocal 值传递,现在的开发中我们会大量使用线程池,但线程池里的线程是被复用的,会导致了数据污染和上下文丢失。

TransmittableThreadLocal : TransmittableThreadLocal (简称 TTL) 是阿里巴巴开源的工具类,继承并加强了InheritableThreadLocal类,可以在线程池的场景下支持 ThreadLocal 值传递。

TTL 就像一个"快递员",它专门负责把主线程里的值送到线程池线程里。其核心原理是在任务提交到线程池时,先捕获父线程中的 ThreadLocal 值,并将其与任务绑定。当线程池中的工作线程执行任务前,再将这些上下文值恢复到当前线程。任务执行结束后,再自动清理上下文,避免线程复用导致的数据污染。因此 TTL 本质上是通过"提交时复制,执行时恢复,结束后清理"的方式实现跨线程上下文传递。

Q:TTL 底层怎么实现?

A:在任务提交时捕获父线程上下文,执行前设置到目标线程,执行结束后恢复现场。

黑马点评项目中,ThreadLocal 主要用于 登录用户信息的上下文传递

具体是在 RefreshTokenInterceptor 中,从请求头获取 token,查询 Redis 中的用户信息后,将用户对象保存到 UserHolder 中,而 UserHolder 底层就是 ThreadLocal。

这样在后续的 Controller、Service 层中,就可以随时通过 UserHolder.getUser() 获取当前登录用户,而不需要层层传递 userId。

项目中的点赞、关注、发布笔记、秒杀下单等功能,都依赖 ThreadLocal 获取当前用户信息。

😺线程池

线程池本质上是一个管理线程资源的容器,它会提前创建一定数量的线程并进行统一管理。任务提交后,线程池会复用已有线程执行任务,线程执行完成后不会销毁,而是返回线程池等待下一个任务。

优点:

  • 降低线程频繁创建和销毁带来的资源消耗
  • 提高任务响应速度
  • 统一管理线程数量和任务队列,提升系统稳定性

创建方式:ThreadPoolExecutor

java 复制代码
ThreadPoolExecutor executor = new ThreadPoolExecutor(
    corePoolSize,
    maximumPoolSize,
    keepAliveTime,
    TimeUnit.SECONDS,
    new LinkedBlockingQueue<>()
);

线程池常见参数:

  1. int corePoolSize 核心线程数:线程池长期保留的线程数量。
  2. int maximumPoolSize 最大线程数:线程池允许创建的最大线程数量。
  3. long keepAliveTime 空闲存活时间:非核心线程空闲多久后销毁。
  4. TimeUnit unit 时间单位:给 keepAliveTime 配套使用。
  5. BlockingQueue<Runnable> workQueue 任务队列:等待执行任务的队列。核心线程满后,任务先进入队列,而不是立刻创建新线程。
  6. ThreadFactory threadFactory 线程工厂:线程怎么创建。
  7. RejectedExecutionHandler handler 拒绝策略:线程池满了之后怎么办。

工作流程:先核心,后排队,再扩容,最后拒绝

  1. 第一步:线程数 < core → 创建核心线程。如果当前运行的线程数小于核心线程数,那么就会新建一个线程来执行任务。
  2. 第二步:线程数 ≥ core → 任务入队。如果当前运行的线程数等于或大于核心线程数,但是小于最大线程数,那么就把该任务放入到任务队列里等待执行。
  3. 第三步:队列满 → 创建非核心线程。如果向任务队列投放任务失败(任务队列已经满了),但是当前运行的线程数是小于最大线程数的,就新建一个线程来执行任务。
  4. 第四步:线程数达到 max → 执行拒绝策略。如果当前运行的线程数已经等同于最大线程数了,新建线程将会使当前运行的线程超出最大线程数,那么当前任务会被拒绝。

Q:为什么不是先扩容再排队?

A:因为队列比线程更轻量,优先利用队列可以减少线程创建开销。

线程池里的核心线程corePoolSize是否会被销毁回收?

默认情况下,ThreadPoolExecutor 的核心线程不会被回收,即使线程已经处于空闲状态。这样设计的目的是减少线程重复创建和销毁带来的性能开销,提高线程复用效率。线程池默认只会回收非核心线程,回收时间由 keepAliveTime 决定。如果希望核心线程也支持超时回收,可以调用:

java 复制代码
executor.allowCoreThreadTimeOut(true);

开启后,核心线程在空闲超过 keepAliveTime 后也会被销毁。高频服务一般不开启,低频周期任务可以考虑开启。

核心线程空闲时处于什么状态?

  • 设置了核心线程的存活时间 :核心线程在空闲时,会处于 TIMED_WAITING 状态,等待获取任务。如果阻塞等待的时间超过了核心线程存活时间,则该线程会退出工作,将该线程从线程池的工作线程集合中移除,线程状态变为 TERMINATED 状态。
  • 没有设置核心线程的存活时间 :核心线程在空闲时,会一直处于 WAITING 状态,等待获取任务,核心线程会一直存活在线程池中。

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

  • ThreadPoolExecutor.AbortPolicy(默认策略,任务无法执行时直接抛异常):抛出 RejectedExecutionException来拒绝新任务的处理。
  • ThreadPoolExecutor.CallerRunsPolicy:调用执行者自己的线程运行任务,也就是直接在调用execute方法的线程中运行(run)被拒绝的任务,如果执行程序已关闭,则会丢弃该任务。因此这种策略会降低对于新任务提交速度,影响程序的整体性能。如果你的应用程序可以承受此延迟并且你要求任何一个任务请求都要被执行的话,你可以选择这个策略。
  • ThreadPoolExecutor.DiscardPolicy:不处理新任务,直接丢弃掉。
  • ThreadPoolExecutor.DiscardOldestPolicy:此策略将丢弃最早的未处理的任务请求。

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

CallerRunsPolicy,由提交任务的线程自己执行任务,降低线程池提交速度,相当于"自然限流",实现"降速保护"。

缺点:

  • 阻塞调用线程:线程池满 → main线程执行任务,主线程卡住。
  • 吞吐量下降:因为调用线程被"拉去干活",原本应该异步 → 变同步,系统性能下降。
  • 严重情况下导致 OOM:任务很重 + 不断堆积,执行变慢 → 任务堆积 → 内存上涨,最终OOM。

但该策略的缺点是可能阻塞调用线程,例如主线程或请求线程,从而降低系统吞吐量,严重情况下可能导致请求阻塞甚至内存压力上升。

Q:有没有比 CallerRunsPolicy 更好的方案?

A:有,比如 MQ / 任务持久化,实现异步削峰。

因此在实际生产中,除了扩大线程池和队列容量外,还可以采用任务持久化方案,例如将任务存入数据库或消息队列,实现削峰填谷,保证系统稳定性。

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

如果使用 execute() 提交任务,当任务在执行过程中发生未捕获异常时,该线程会因为异常而终止,线程池会创建一个新的线程来补充,保证线程数量不变。

如果使用 submit() 提交任务,任务中的异常不会直接抛出,而是被封装到 Future 对象中,只有在调用 Future.get() 时才会抛出 ExecutionException,因此线程不会因为异常而终止,仍然可以被线程池复用。

Q:submit 为什么要封装异常?

A:为了支持异步获取结果和统一异常处理。

Q:execute 更适合什么场景?

A:不需要返回值、不关心结果的任务。

Q:线程池真的会无限补线程吗?

A:不会,补线程受 maximumPoolSize 限制。

优先级线程池如何设计

线程池 = 线程 + 阻塞队列 + 任务调度策略

⭐ 阻塞队列决定任务执行顺序,所以问题变为实现 PriorityBlockingQueue。

如何让任务具备优先级?使用 Comparator

java 复制代码
new PriorityBlockingQueue<>(100, (a, b) -> b.priority - a.priority);
// 使用方式
ThreadPoolExecutor executor =
    new ThreadPoolExecutor(
        2,
        4,
        60,
        TimeUnit.SECONDS,
        new PriorityBlockingQueue<>()
    );

😺Future

我先把任务丢出去 → Future帮我盯着 → 我之后再来拿结果

Future 是 Java 提供的一个用于表示异步计算结果的接口,用来封装一个可能还未完成的任务。通过 Future,可以将耗时任务提交给线程池异步执行,主线程无需阻塞等待,可以继续执行其他逻辑,之后再通过 Future 获取执行结果。

Future 提供了获取结果、取消任务、判断任务是否被取消、是否完成等能力,其中 get 方法会阻塞当前线程直到任务完成。

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

😺AQS

同步器底层框架(锁的"地基"),Java并发"统一底层框架"。

AQS(AbstractQueuedSynchronizer)是 Java 并发包中的抽象队列同步器,是实现各种同步器的底层框架,例如 ReentrantLock、Semaphore 和 CountDownLatch 等。

AQS 的核心思想是,如果共享资源可用,则当前线程直接获取资源;如果资源不可用,则将当前线程封装成节点加入等待队列中,进入阻塞状态,等待被唤醒后重新竞争资源。

AQS 内部主要包含两个核心部分:一个是表示同步状态的 state 变量,使用 volatile 修饰,通过 CAS 操作进行修改;另一个是基于 CLH 变体的双向等待队列,用于管理未获取到锁的线程。

相关推荐
云烟成雨TD2 小时前
Spring AI Alibaba 1.x 系列【10】ReactAgent 工具加载和执行流程
java·人工智能·spring
lee_curry2 小时前
JUC第一章 java中基础概念和CompletableFuture
java·多线程·并发·juc
一晌小贪欢2 小时前
PyQt5 开发一个 PDF 批量合并工具
开发语言·qt·pdf
神仙别闹2 小时前
基于 MATLAB 实现的图像信号处理
开发语言·matlab·信号处理
迷藏4942 小时前
**超融合架构下的Go语言实践:从零搭建高性能容器化微服务集群**在现代云原生时代,*
java·python·云原生·架构·golang
swift192212 小时前
Qt多语言问题 —— 静态成员变量
开发语言·c++·qt
それども2 小时前
Spring Bean @Autowired自注入空指针问题
java·开发语言·spring
如来神掌十八式2 小时前
Java所有的锁:从基础到进阶
java·
硅基诗人2 小时前
Java后端高并发核心瓶颈突破(JVM+并发+分布式底层实战)
java·jvm·分布式