多线程面试题 - 教程

多线程常见面试题

1.线程和进程的区别?

程序由指令和数据组成,但这些指令要运行,数据要读写,就必须将指令加载至 CPU,数据加载至内存。在指令运行过程中还需要用到磁盘、网络等设备。进程就是用来加载指令、管理内存、管理 IO 的。

当一个程序被运行,从磁盘加载这个程序的代码至内存,这时就开启了一个进程。

注:进程分为多实例进程和单实例进程

类型定义示例
多个实例进程程序可同时运行多个独立的进程实例,实例间互不干扰谷歌浏览器、多窗口文档(如 “屌丝逆袭秘诀.txt”)
单实例进程程序仅能运行一个进程实例,再次启动会跳转至已有实例,避免资源冲突等TLAS 客户端、企业微信

image-20251105133026159

一个进程之内可以分为一到多个线程。

一个线程就是一个指令流,将指令流中的一条条指令以一定的顺序交给 CPU 执行

下图展示了CPU 核心调度进程内的线程,线程执行指令序列的层次关系。

image-20251105133438122

Java 中,线程作为最小调度单位,进程作为资源分配的最小单位。在 windows 中进程是不活动的,只是作为线程的容器

二者对比

  • 进程是正在运行程序的实例,进程中包含了线程,每个线程执行不同的任务
  • 不同的进程使用不同的内存空间,在当前进程下的所有线程可以共享内存空间
  • 线程更轻量,线程上下文切换成本一般上要比进程上下文切换低(上下文切换指的是从一个线程切换到另一个线程)

这里再补充一个协程,有兴趣的可以看看。

协程是“轻量级线程”,由程序自己在用户态控制切换,而不是被操作系统强制调度。Java不直接支持协程,但是虚拟线程与协程类似。

协程是为了避免传统异步线程的“回调地狱”,让我们可以采用同步的方法获得异步的性能。

协程(java虚拟内存相似)的核心优势是:能以 “同步代码的线性逻辑”,执行 “异步的非阻塞操作”,从而避免回调嵌套(多层异步依赖)。

协程通过await关键字,让异步操作的代码变成了线性的同步逻辑,既保留了 “异步非阻塞(不浪费 CPU 资源)” 的性能优势,又解决了 “回调地狱” 的嵌套问题。

协程与线程、Java 虚拟线程的关系

  • 与线程的区别:线程由操作系统调度(内核态),切换成本高;协程由程序自己调度(用户态),切换成本极低,是 “轻量级线程”。
  • Java 虚拟线程:Java 原本没有原生协程,但 “虚拟线程” 的设计思路和协程类似 —— 通过用户态调度,实现 “以同步写法写异步逻辑”,解决传统线程池的资源瓶颈和异步代码的可读性问题。

本题参考回答

  • 进程是正在运行程序的实例,进程中包含了线程,每个线程执行不同的任务
  • 不同的进程使用不同的内存空间,在当前进程下的所有线程可以共享内存空间
  • 线程更轻量,线程上下文切换成本一般上要比进程上下文切换低(上下文切换指的是从一个线程切换到另一个线程)

2.并发与并行有什么区别

单核CPU

  • 单核CPU下线程实际还是串行执行的

  • 操作系统中有一个组件叫做任务调度器,将cpu的时间片(windows下时间片最小约为 15 毫秒)分给不同的程序使用,只是由于cpu在线程间(时间片很短)的切换非常快,人类感觉是同时运行的 。

    科普:什么是CPU时间片?

image-20251105135350038

  • 总结为一句话就是: 微观串行,宏观并行

一般会将这种线程轮流使用CPU的做法称为并发(concurrent)

image-20251105135054301

多核CPU

每个核(core)都可以调度运行线程,这时候线程可以是并行的。

image-20251105135109217

并发(concurrent)是同一时间应对(dealing with)多件事情的能力

并行(parallel)是同一时间动手做(doing)多件事情的能力

本题参考回答

现在都是多核CPU,在多核CPU下

并发是同一时间应对多件事情的能力,多个线程轮流使用一个或多个CPU

并行是同一时间动手做多件事情的能力,4核CPU同时执行4个线程

3.创建线程的四种方式

参考回答

在java中一共有四种常见的创建方式,分别是:继承Thread类、实现runnable接口、实现Callable接口、线程池创建线程。通常情况下,我们项目中都会采用线程池的方式创建线程,因为线程池创建线程的主要原因是优化资源利用率、提高性能和简化任务管理。对于大多数实际应用来说,线程池是比直接创建线程更为高效和可靠的解决方案。

4.runable和callble有什么区别

本题参考回答

  1. Runnable 接口run方法没有返回值;Callable接口call方法有返回值,是个泛型,和Future、FutureTask配合可以用来获取异步执行的结果
  2. Callalbe接口支持返回执行结果,需要调用FutureTask.get()得到,此方法会阻塞主进程的继续往下执行,如果不调用不会阻塞。
  3. Callable接口的call()方法允许抛出异常;而Runnable接口的run()方法的异常只能在内部消化,不能继续上抛(向上抛异常就是throws exception,内部消化指的是可以用try catch)

image-20251105140018787

5.线程的run()和start方法有什么区别?

本题参考回答

start(): 用来启动线程,通过该线程调用run方法执行run方法中所定义的逻辑代码。start方法只能被调用一次。

run(): 封装了要被线程执行的代码,可以被调用多次。

调用方式执行效果
t1.start()启动一个新的线程,由JVM调用run()方法。真正实现了多线程并发
t1.run()只是普通方法调用,仍在主线程中执行,没有开启新线程。

start()‌ 通过 JVM 调用本地方法 start0() 向操作系统申请线程资源,使线程进入就绪状态等待调度。 ‌

‌run()‌ 仅作为普通方法执行代码逻辑,不涉及线程调度

6.线程包括哪些状态,状态之间如何变化的

image-20251105141457868

本题参考回答

在JDK中的Thread类中的枚举State里面定义了6中线程的状态分别是:新建、可运行、终结、阻塞、等待和有时限等待六种。

关于线程的状态切换情况比较多。我分别介绍一下

当一个线程对象被创建,但还未调用 start 方法时处于新建状态,调用了 start 方法,就会由新建进入可运行状态。如果线程内代码已经执行完毕,由可运行进入终结状态。当然这些是一个线程正常执行情况。

如果线程获取锁失败后,由可运行进入 Monitor 的阻塞队列阻塞,只有当持锁线程释放锁时,会按照一定规则唤醒阻塞队列中的阻塞线程,唤醒后的线程进入可运行状态

科普: 锁的本质是什么?

锁(Lock 或 synchronized 的 monitor)是一种并发控制机制,本质上保证:

  1. 互斥性:同一时间只能有一个线程访问共享资源;
  2. 可见性:一个线程修改后的数据对其他线程可见;
  3. 有序性:防止指令重排序带来的执行混乱。

如果线程获取锁成功后,但由于条件不满足,调用了 wait() 方法,此时从可运行状态释放锁等待状态,当其它持锁线程调用 notify() 或 notifyAll() 方法,会恢复为可运行状态

还有一种情况是调用 sleep(long) 方法也会从可运行状态进入有时限等待状态,不需要主动唤醒,超时时间到自然恢复为可运行状态

7.新建T1,T2,T3三个线程,如何保证它们按顺序执行

本题参考回答

嗯~~,我思考一下 (适当的思考或想一下属于正常情况,脱口而出反而太假[背诵痕迹])

可以这么做,在多线程中有多种方法让线程按特定顺序执行,可以用线程类的join()方法在一个线程中启动另一个线程,另外一个线程完成该线程继续执行。

比如说:

使用join方法,T3调用T2,T2调用T1,这样就能确保T1就会先完成而T3最后完成

8.notify()和notifyAll()有什么区别

notifyAll:唤醒所有wait的线程

notify:只随机唤醒一个 wait 线程

package com.itheima.basic;
public class WaitNotify {
static boolean flag = false;
/**
*线程是随机执行的,如果没有flag变量,
*那么t1执行完,轮到t3执行,t3执行完
*唤醒t1.此时时间片分给t2,t2永远处在
*等待状态,那么jvm实例一直不会销毁
*/
static Object lock = new Object();
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
synchronized (lock){
//Java的 Object.wait() 文档中明确提到:"线程也可能在没有被通知、中断或超时的情况下唤醒,这就是所谓的虚假唤醒。虽然这在实践中很少发生,但应用程序必须防范这种情况。"为了防止虚假唤醒(并没有notify),所有我们在循环中不停的lock.wait();只有真正的被唤醒lock.notifyAll();flag=true;才会停止wait执行后面的代码。
while (!flag){
System.out.println(Thread.currentThread().getName()+"...wating...");
try {
lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println(Thread.currentThread().getName()+"...flag is true");
}
});
Thread t2 = new Thread(() -> {
synchronized (lock){
while (!flag){
System.out.println(Thread.currentThread().getName()+"...wating...");
try {
lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println(Thread.currentThread().getName()+"...flag is true");
}
});
Thread t3 = new Thread(() -> {
synchronized (lock) {
System.out.println(Thread.currentThread().getName() + " hold lock");
lock.notifyAll();
flag = true;
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
t1.start();
t2.start();
t3.start();
}
}

本题参考回答

notifyAll:唤醒所有wait的线程

notify:只随机唤醒一个 wait 线程

9.在java中wait和sleep方法的不同

public class WaitSleepCase {
static final Object LOCK = new Object();
public static void main(String[] args) throws InterruptedException {
sleeping();
}
private static void illegalWait() throws InterruptedException {
LOCK.wait();
}
private static void waiting() throws InterruptedException {
Thread t1 = new Thread(() -> {
synchronized (LOCK) {
try {
get("t").debug("waiting...");
LOCK.wait(5000L);
} catch (InterruptedException e) {
get("t").debug("interrupted...");
e.printStackTrace();
}
}
}, "t1");
t1.start();
Thread.sleep(100);
synchronized (LOCK) {
main.debug("other...");
}
}
private static void sleeping() throws InterruptedException {
Thread t1 = new Thread(() -> {
synchronized (LOCK) {
try {
get("t").debug("sleeping...");
Thread.sleep(5000L);
} catch (InterruptedException e) {
get("t").debug("interrupted...");
e.printStackTrace();
}
}
}, "t1");
t1.start();
Thread.sleep(100);
synchronized (LOCK) {
main.debug("other...");
}
}
}
方法含义是否通信是否释放锁
sleep()自己睡觉暂停❌ 不通信❌ 不释放锁
wait()等别人通知✅ 通信✅ 释放锁

image-20251105144903268

本题参考回答

共同点

  • wait() ,wait(long) 和 sleep(long) 的效果都是让当前线程暂时放弃 CPU 的使用权,进入阻塞状态(这里的阻塞状态是统称,包含等待和计时等待状态,如果带有时间参数的就是TIME_WAITING,没带时间的基本就是WAITING)

不同点

  • 方法归属不同
    • sleep(long) 是 Thread 的静态方法
    • 而 wait(),wait(long) 都是 Object 的成员方法,每个对象都有
  • 醒来时机不同
    • 执行 sleep(long) 和 wait(long) 的线程都会在等待相应毫秒后醒来
    • wait(long) 和 wait() 还可以被 notify 唤醒,wait() 如果不唤醒就一直等下去
    • 它们都可以被打断唤醒
  • 锁特性不同(重点)
    • wait 方法的调用必须先获取 wait 对象的锁,而 sleep 则无此限制
    • wait 方法执行后会释放对象锁,允许其它线程获得该对象锁(我放弃 cpu,但你们还可以用)
    • 而 sleep 如果在 synchronized 代码块中执行,并不会释放对象锁(我放弃 cpu,你们也用不了)

10.如何停止一个正在运行的线程

本题参考回答

有三种方式可以停止线程

  • 使用退出标志,使线程正常退出,也就是当run方法完成后线程终止
  • 使用stop方法强行终止(不推荐,方法已作废)
  • 使用interrupt方法中断线程

image-20251105145100894

使用interrupt方法中断线程

打断阻塞的线程(sleep,wait,join)的线程,线程会抛出InterruptedException异常

打断正常的线程,可以根据打断状态来标记是否退出线程

image-20251105145155747

11.讲一下Synchronized关键字的底层原理

1.synchronized【对象锁】采用互斥的方式,让同一时刻至多只有一个线程能持有【对象锁】。

public class TicketDemo {
static Object lock = new Object();
int ticketNum = 10;
public synchronized void getTicket() {
synchronized (this) {
if (ticketNum <= 0) {
return;
}
System.out.println(Thread.currentThread().getName() + "抢到一张票,剩余:" + ticketNum);
// 非原子性操作
ticketNum--;
}
}
public static void main(String[] args) {
TicketDemo ticketDemo = new TicketDemo();
for (int i = 0; i < 20; i++) {
new Thread(() -> {
ticketDemo.getTicket();
}).start();
}
}
}

大家可以思考一下,这里的this是什么意思?如果换成TicketDemo.class呢?

答:若把synchronized (this)替换成synchronized (TicketDemo.class),就会把锁的范围从实例级提升到类级。TicketDemo.class是TicketDemo类的 Class 对象,无论创建多少个TicketDemo实例,这个 Class 对象都只有一个。这样一来,所有线程,不管它们操作的是不是同一个TicketDemo实例,在执行synchronized (TicketDemo.class)块中的代码时,都要争夺同一把锁。

在这个例子中,由于main方法只创建了一个TicketDemo实例,所以使用this和TicketDemo.class的效果是一样的,都能保证线程安全。不过,如果创建了多个TicketDemo实例,使用this时,不同实例之间的getTicket()方法可以并行执行;而使用TicketDemo.class时,所有实例的getTicket()方法都只能串行执行。

2.它的底层由 monitor 实现,monitor 是 JVM 级别的对象(C++ 实现),线程获得锁需要使用对象(锁)关联 monitor。

在使用了synchornized代码块时需要指定一个对象,所以synchornized也被称为对象锁

synchronized 需要指定一个对象作为锁,线程在进入同步代码块或同步方法时必须先获得该对象的锁,持有锁的线程独占访问,其他线程必须等待,因此 synchronized 又被称为对象锁,用来控制对同一对象的并发访问。

在 Java 中,每个对象都有一个“内置锁”,我们可以用这个对象作为锁,synchronized(obj) 就是要求线程在进入临界区前先获取这个对象的锁,从而保证同一个对象的临界区在任意时刻只被一个线程执行。

3.在 monitor 内部有三个属性,分别是 owner、entryList、waitSet:

– owner:关联获得锁的线程,并且只能关联一个线程;

– entryList:关联处于阻塞状态的线程;(阻塞线程集合,一旦锁释放,阻塞集合里面的线程会抢夺CPU资源,谁抢到锁,谁就先执行)

– waitSet:关联处于 Waiting 状态的线程

具体的流程:

  • 代码进入synchorized代码块,先让lock(对象锁)关联的monitor,然后判断Owner是否有线程持有
  • 如果没有线程持有,则让当前线程持有,表示该线程获取锁成功
  • 如果有线程持有,则让当前线程进入entryList进行阻塞,如果Owner持有的线程已经释放了锁,在EntryList中的线程去竞争锁的持有权(非公平)
  • 如果代码块中调用了wait()方法,则会进去WaitSet中进行等待

本题参考回答

synchronized 底层使用的JVM级别中的Monitor 来决定当前线程是否获得了锁,如果某一个线程获得了锁,在没有释放锁之前,其他线程是不能或得到锁的。synchronized 属于悲观锁。

synchronized 因为需要依赖于JVM级别的Monitor ,相对性能也比较低。

monitor对象存在于每个Java对象的对象头中,synchronized 锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因

monitor内部维护了三个变量

  • WaitSet:保存处于Waiting状态的线程
  • EntryList:保存处于Blocked状态的线程
  • Owner:持有锁的线程

只有一个线程获取到的标志就是在monitor中设置成功了Owner,一个monitor中只能有一个Owner

在上锁的过程中,如果有其他线程也来抢锁,则进入EntryList 进行阻塞,当获得锁的线程执行完了,释放了锁,就会唤醒EntryList 中等待的线程竞争锁,竞争的时候是非公平的。

(内置锁的竞争是非公平的,因为唤醒 EntryList 中线程后,谁先获得 CPU 时间片谁就先拿到锁,而 JVM 不保证严格按照队列顺序分配锁,这样可以提高吞吐量和性能。)

12.synchronized关键字的底层原理-进阶-Monitor实现的锁属于重量级锁,你了解过锁升级吗?

  • Monitor实现的锁属于重量级锁,里面涉及到了用户态和内核态的切换、进程的上下文切换,成本较高,性能比较低。
  • 在JDK 1.6引入了两种新型锁机制:偏向锁和轻量级锁,它们的引入是为了解决在没有多线程竞争或基本没有竞争的场景下因使用传统锁机制带来的性能开销问题。(轻量级锁是为了解决再非竞争环境下的获取锁过程,用于提升再非竞争环境下重量级锁性能低下的问题)

4Java中的synchronized有偏向锁、轻量级锁、重量级锁三种形式,分别对应了锁只被一个线程持有、不同线程交替持有锁、多线程竞争锁三种情况。一旦锁发生了竞争,都会升级为重量级锁

加锁的流程

每个 Java 对象在内存中都有一个 对象头(Header),里面有一块区域叫 Mark Word,用来保存:

  • 对象的哈希码
  • GC 分代年龄
  • 锁状态标志(无锁、偏向锁、轻量级锁、重量级锁)

当我们加锁时,JVM 就是通过修改 Mark Word 来记录“谁持有锁、锁状态是什么”的。

① 在线程栈中创建 Lock Record

每个线程在自己的栈中,会为当前尝试加锁的对象,创建一个“Lock Record(锁记录)”。

作用:

  • 就像线程在自己的口袋里准备一张“锁凭证”,
  • 其中 obj 字段指向要加锁的对象。

类比:线程准备了一张卡片,上面写着“我要抢这个门(对象)的锁”

image-20251105152549566

② 用 CAS 竞争锁(修改对象头 Mark Word)

  • 线程尝试通过 CAS(Compare And Swap)原子操作
    把对象头中的 Mark Word 从“无锁状态”改成“指向 Lock Record 的地址”。
  • 如果成功:
    • 表示这把锁还没人拿;
    • 当前线程就拿到了锁;
    • 这就是轻量级锁的本质

类比:线程拿着钥匙卡插进门锁,一下就卡上了(没人用这门)。此时门上就贴上了“被这个线程占用”的标记。

image-20251105152554714
③ 如果是同一线程再次加锁(锁重入)

  • 如果当前线程已经拿到锁了,又执行了 synchronized(同一对象):
    • JVM 发现锁的拥有者还是自己;
    • 就在 Lock Record 中记个“重入次数”。
  • 不需要重新抢锁。

类比:同一个人再次进入自己已经开的门,不需要重新开锁,只需在门口记一下“我又进来一次”。

image-20251105152601280

CAS 修改失败 → 发生竞争 → 膨胀为重量级锁

  • 如果多个线程同时来抢锁,
    • 那就会有线程 CAS 修改失败(因为 Mark Word 已经被别人占了);
    • JVM 检测到这种“竞争”;
    • 就会把这把锁升级成 重量级锁(使用 Monitor 对象)
  • 其他没抢到的线程会被挂起(进入阻塞状态)。

类比:现在门口太多人抢进来,门卫(Monitor)上线管理,建立队列,登记排队,竞争就变成重量级操作了。

总结成一句话:

线程加锁时,先在自己栈里建个锁记录(Lock Record),再用 CAS 把对象头的 Mark Word 指向这个记录;成功则获得轻量级锁,自己重入则增加计数,若失败说明有竞争,就升级成重量级锁(Monitor 介入)。

解锁过程

每个对象都有一个 Mark Word,用来记录锁状态

轻量级锁时,Mark Word 指向 线程栈里的 Lock Record

加锁时可能发生 重入,即同一线程多次进入同步块(锁的重入指的是:

同一个线程可以多次进入同一个对象的同步块或同步方法,而不会被自己阻塞。)

解锁的目的就是 把锁释放,让其他线程有机会获取

① 遍历线程栈,找到所有 Lock Record 的 obj 等于当前锁对象

遍历线程栈 -> 找到 Lock Record 的 obj == 当前锁对象
  • JVM 会在线程的栈帧中找到所有跟这个对象相关的锁记录
  • 为什么要找多个?因为同一个线程可能 重入 多次这个锁(在轻量级锁中,每次进入同步块时,线程都会在自己栈里创建一个 Lock Record

类比:线程在口袋里有多张门票(Lock Record),每张票都指向同一个门(对象),要一张张处理。

② 如果 Lock Record 的 Mark Word 为 null,说明是重入

  • 重入锁:同一个线程再次进入同步块,不会再去抢锁
  • 解锁时,如果 Mark Word 为 null,说明这是 重入的一次退出
  • 处理方式:
    • obj 置为 null(表示这一层同步块退出了)
    • 继续遍历栈中其他 Lock Record

类比:你有多张门票进入房间,每次出一层,只把一张票扔掉,房间还被你占着

image-20251105153220609

③ 如果 Lock Record 的 Mark Word 不为 null,恢复无锁状态

  • 说明这是最后一次释放锁(非重入)
  • JVM 会用 CAS 把对象头的 Mark Word 改回 无锁状态
  • 如果 CAS 成功:锁释放
  • 如果 CAS 失败:说明竞争激烈,可能锁被其他线程抢走或发生膨胀
    • JVM 会把锁升级为 重量级锁(Monitor)
    • 让 Monitor 管理线程等待队列

类比:最后一张门票出了门,门被解锁。有人抢先冲进来,门卫(Monitor)就接手管理排队。

image-20251105153236192

解锁流程总结(轻量级锁)

  1. 找到线程栈里所有与该对象相关的 Lock Record
  2. 重入锁 → Mark Word 为 null → 仅释放一层,继续处理
  3. 最后一层 → Mark Word 不为 null → CAS 恢复对象头为无锁
  4. CAS 失败 → 锁膨胀为 Monitor(重量级锁)

轻量级锁在没有竞争时(就自己这个线程),每次重入仍然需要执行 CAS 操作。

Java 6 中引入了偏向锁来做进一步优化:只有第一次使用 CAS 将线程 ID 设置到对象的 Mark Word 头,之后发现

这个线程 ID 是自己的就表示没有竞争,不用重新 CAS。以后只要不发生竞争,这个对象就归该线程所有

它的核心思想是:

“如果一个对象的大部分时间只被一个线程访问,那就把锁偏向这个线程,后续同一线程进入就不用 CAS,性能更高。”

每个对象头(Mark Word)都有一块位用来记录 锁状态

  • 偏向锁标识:101
  • 轻量级锁标识:00(之前讲过)

线程进入同步块时,会在 线程栈里创建 Lock Record

① 在线程栈中创建 Lock Record

Lock Record.obj = 当前锁对象
  • 每个线程在自己的栈里创建一张“锁凭证”,指向要加锁的对象
  • 后续加锁时就用这个记录来检查锁归属

类比:线程在口袋里准备好一张门票,上面写着自己是谁,要进哪扇门

image-20251105153708059

② CAS 修改对象头 + 设置偏向锁标识

  • 偏向锁第一次加锁时:
    1. CAS 把对象头 Mark Word 的 线程ID 改成当前线程的 ID
    2. 设置偏向锁标识位 101
  • 如果对象原本是 无锁状态,CAS 成功 → 当前线程获得偏向锁
  • 这时对象头里直接记录了“锁属于 T1”,不用再每次抢锁时 CAS

类比:线程把自己的名字写在门上,说:“这间房属于我,别人随便来不碰我就行。”

image-20251105153716290

③ 重入锁处理

  • 偏向锁下,如果同一个线程再次进入同步块:
    • JVM 发现 对象头里的线程ID是自己
    • 说明是 锁重入
    • 不需要再做 CAS,直接设置 Lock Record 的 obj 为 null(表示释放一层重入计数)

类比:

  • 你已经在房间里了,再次进房间就不用重新开锁
  • 只需要在门票上记一下自己进了多少次(重入计数器)

关键点:

  • 和轻量级锁不同:轻量级锁每次进入同步块都要用 CAS 抢锁
  • 偏向锁第一次加锁用 CAS,之后同一个线程重入完全不用 CAS
  • 所以偏向锁在单线程重入场景下 性能更高

image-20251105153723125

本题参考回答

Java中的synchronized有偏向锁、轻量级锁、重量级锁三种形式,分别对应了锁只被一个线程持有、不同线程交替持有锁、多线程竞争锁三种情况。

重量级锁:底层使用的Monitor实现,里面涉及到了用户态和内核态的切换、进程的上下文切换,成本较高,性能比较低。

轻量级锁:线程加锁的时间是错开的(也就是没有竞争),可以使用轻量级锁来优化。轻量级修改了对象头的锁标志,相对重量级锁性能提升很多。每次修改都是CAS操作,保证原子性

偏向锁:一段很长的时间内都只被一个线程使用锁,可以使用了偏向锁,在第一次获得锁时,会有一个CAS操作,之后该线程再获取锁,只需要判断mark word中是否是自己的线程id即可,而不是开销相对较大的CAS命令

一旦锁发生了竞争,都会升级为重量级锁

13.你谈谈JMM(Java内存模型)

JMM(Java Memory Model)Java内存模型,是java虚拟机规范中所定义的一种内存模型。

Java内存模型(Java Memory Model)描述了Java程序中各种变量(线程共享变量)的访问规则,以及在JVM中将变量存储到内存和从内存中读取变量这样的底层细节,定义了线程如何与内存交互以及线程之间如何通过内存进行通信,主要是定义了java程序多线程间变量共享的一种规则。

image-20251105154455521

特点:

  1. 所有的共享变量都存储于主内存(计算机的RAM)这里所说的变量指的是实例变量和类变量。不包含局部变量,因为局部变量是线程私有的,因此不存在竞争问题。
  2. 每一个线程还存在自己的工作内存,线程的工作内存,保留了被线程使用的变量的工作副本。
  3. 线程对变量的所有的操作(读,写)都必须在工作内存中完成,而不能直接读写主内存中的变量,不同线程之间也不能直接访问对方工作内存中的变量,线程间变量的值的传递需要通过主内存完成。

本题参考回答

Java内存模型是Java虚拟机规范中定义的一种非常重要的内存模型。它的主要作用是描述Java程序中线程共享变量的访问规则,以及这些变量在JVM中是如何被存储和读取的,涉及到一些底层的细节。

这个模型有几个核心的特点。首先,所有的共享变量,包括实例变量和类变量,都被存储在主内存中,也就是计算机的RAM。需要注意的是,局部变量并不包含在内,因为它们是线程私有的,所以不存在竞争问题。

其次,每个线程都有自己的工作内存,这里保留了线程所使用的变量的工作副本。这意味着,线程对变量的所有操作,无论是读还是写,都必须在自己的工作内存中完成,而不能直接读写主内存中的变量。

最后,不同线程之间不能直接访问对方工作内存中的变量。如果线程间需要传递变量的值,那么这个过程必须通过主内存来完成。

14.CAS你知道吗?

JMM(Java 内存模型):每个线程有自己的 工作内存(寄存器/缓存),共享变量在 主内存

多线程同时操作共享变量时,为了保证安全,需要一种原子操作:CAS

CAS的全称是: Compare And Swap(比较再交换),它体现的一种乐观锁的思想,在无锁情况下保证线程操作共享数据的原子性。

CAS 操作的核心逻辑是:比较内存中的值与预期值是否相等,如果相等则替换为新值。这三个步骤(比较、判断、替换)被封装在一个原子指令中,而不是分开的多个步骤。因此,不会出现中间状态被其他线程修改的情况。

在JUC( java.util.concurrent )包下实现的很多类都用到了CAS操作

  • AbstractQueuedSynchronizer(AQS框架)
  • AtomicXXX类

例子:

我们还是基于刚才学习过的JMM内存模型进行说明

  • 线程1与线程2都从主内存中获取变量int a = 100,同时放到各个线程的工作内存中

操作步骤:

  1. V = 当前主内存的值
  2. A = 线程预期的旧值(想象成线程的工作副本)
  3. B = 想要更新的新值

逻辑:

如果 V == A
   → 成功,把 V 更新为 B,返回 true
否则
   → 失败,不做任何操作,返回 false

如果失败,线程会 自旋(loop retry)直到成功。

3️⃣ 例子拆解

初始化

int a = 100; // 主内存

两个线程同时读入自己的工作内存:

  • 线程1:a = 100
  • 线程2:a = 100

主内存:V = 100

线程1执行 a++

  • 线程1:A = 100,B = 101
  • CAS 比较主内存 V 与 A:100 == 100 ✅
  • CAS 成功,把 V 更新为 101
  • 主内存现在 V = 101
  • image-20251105155215603

线程2执行 a–

  • 线程2:A = 100(自己工作内存的旧值),B = 99
  • CAS 比较主内存 V 与 A:V = 101, A = 100 ❌
  • CAS 失败 → 不更新 → 自旋重试

解释为什么线程2失败:

CAS 检测到“我的旧值 A 已经过时了”,说明别的线程先修改了主内存
为了保证原子性,CAS 拒绝更新,线程必须重新读取最新值再操作

线程2重试

  • 线程2重新读取主内存 V = 101
  • 再计算 B = 101 - 1 = 100
  • 再次 CAS:V == A?101 == 101 ✅
  • 更新成功 → V = 100
  • image-20251105155223687

✅ 总结一句话

CAS 是原子操作:先比较主内存的值是否等于线程预期值,如果相等就更新,否则失败并重试。线程2失败是因为线程1已经修改了主内存,线程2的旧值过期,需要重新读取。

  • 自旋锁操作
    • 因为没有加锁,所以线程不会陷入阻塞,效率较高
    • 如果竞争激烈,重试频繁发生,效率会受影响

img

需要不断尝试获取共享内存V中最新的值,然后再在新的值的基础上进行更新操作,如果失败就继续尝试获取新的值,直到更新成功

1️⃣ CAS 底层原理

  • CAS(Compare-And-Swap):原子操作

    • 比较内存值是否等于预期值,如果相等就更新,否则失败
  • 在 Java 中,CAS 并不是 Java 语言写出来的

    • 它依赖 Unsafe(JVM 提供的底层工具类)

    image-20251105155601611

    都是native修饰的方法,由系统提供的接口执行,并非java代码实现,一般的思路也都是自旋锁实现

    在java中比较常见使用有很多,比如ReentrantLock和Atomic开头的线程安全类,都调用了Unsafe中的方法

    • ReentrantLock中的一段CAS代码-

    image-20251105155633806

    • Unsafe 调用的是 CPU 提供的原子指令(比如 x86 的 CMPXCHG
    • 所以 CAS 速度很快,不涉及内核态切换

核心理解:CAS = CPU 原子操作 + 自旋重试

2️⃣ 乐观锁 vs 悲观锁

① 乐观锁(Optimistic Locking)

  • 代表思想:我很乐观,假设别人不会同时修改共享变量
  • 出现冲突也没关系,我重试就行
  • CAS 就是乐观锁的典型实现
  • 特点:
    • 不阻塞线程
    • 依赖自旋重试
    • 适合写冲突少、读多写少的场景

类比:你在排队买票,你假设没人插队,先填写表格,如果发现有人插队,再重新填写 → 乐观锁


② 悲观锁(Pessimistic Locking)

  • 代表思想:最悲观,担心别人同时修改
  • 使用同步机制(synchronized)或 ReentrantLock
  • 特点:
    • 一旦加锁,其他线程必须等待
    • 阻塞线程,直到锁释放
    • 适合写冲突多的场景

类比:你在排队买票,你怕别人插队,于是把自己锁在窗口前,别人必须等你办完 → 悲观锁

CAS 是乐观锁:假设没人抢锁,失败就自旋重试;synchronized 是悲观锁:假设有人抢锁,上锁阻塞别人,直到自己释放锁。

本题参考回答

CAS的全称是: Compare And Swap(比较再交换);它体现的一种乐观锁的思想,在无锁状态下保证线程操作数据的原子性。

  • CAS使用到的地方很多:AQS框架、AtomicXXX类
  • 在操作共享变量的时候使用的自旋锁,效率上更高一些
  • CAS的底层是调用的Unsafe类中的方法,都是操作系统提供的,其他语言实现

15.请谈谈你对volatile的理解

一旦一个共享变量(类的成员变量、类的静态成员变量)被volatile修饰之后,那么就具备了两层语义:

1.保证线程间的可见性:保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的,volatile关键字会强制将修改的值立即写入主存。

一个典型的例子:永不停止的循环

package com.itheima.basic;
// 可见性例子
// -Xint
public class ForeverLoop {
static boolean stop = false;
public static void main(String[] args) {
new Thread(() -> {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
stop = true;
System.out.println("modify stop to true...");
}).start();
foo();
}
static void foo() {
int i = 0;
while (!stop) {
i++;
}
System.out.println("stopped... c:"+ i);
}
}

当执行上述代码的时候,发现foo()方法中的循环是结束不了的,也就说读取不到共享变量的值结束循环。

主要是因为在JVM虚拟机中有一个JIT(即时编辑器)给代码做了优化。

上述代码

while (!stop) {
i++;
}

在很短的时间内,这个代码执行的次数太多了,当达到了一个阈值,JIT就会优化此代码,如下:

while (true) {
i++;
}

当把代码优化成这样子以后,及时stop变量改变为了false也依然停止不了循环

解决方案:

第一:在程序运行的时候加入vm参数-Xint表示禁用即时编辑器,不推荐,得不偿失(其他程序还要使用)

第二:在修饰stop变量的时候加上volatile,表示当前代码禁用了即时编辑器,问题就可以解决,

代码如下:static volatile boolean stop = false;

2.禁止进行指令重排序

用 volatile 修饰共享变量会在读、写共享变量时加入不同的屏障,阻止其他读写操作越过屏障,从而达到阻止重排序的效果(指令重排序是指编译器和处理器为了优化程序性能,在不改变单线程程序执行结果的前提下,对指令执行顺序进行重新排序的过程。但是可能会影响多线程程序的正确性。)

image-20251105161030344

在去获取上面的结果的时候,有可能会出现4种情况

情况一:先执行actor2获取结果—>0,0(正常)

情况二:先执行actor1中的第一行代码,然后执行actor2获取结果—>0,1(正常)

情况三:先执行actor1中所有代码,然后执行actor2获取结果—>1,1(正常)

情况四:先执行actor1中第二行代码,然后执行actor2获取结果—>1,0(发生了指令重排序,影响结果)

解决方案

在变量上添加volatile,禁止指令重排序,则可以解决问题

image-20251105161258045

屏障添加的示意图

image-20251105161309682

其他补充

我们上面的解决方案是把volatile加在了int y这个变量上,我们能不能把它加在int x这个变量上呢?

下面代码使用volatile修饰了x变量

image-20251105161243727

屏障添加的示意图

image-20251105161404524

这样显然是不行的,主要是因为下面两个原则:

  • 写操作加的屏障是阻止上方其它写操作越过屏障排到volatile变量写之下
  • 读操作加的屏障是阻止下方其它读操作越过屏障排到volatile变量读之上

所以,现在我们就可以总结一个volatile使用的小妙招:

  • 写变量让volatile修饰的变量的在代码最后位置
  • 读变量让volatile修饰的变量的在代码最开始位置

本题参考回答1

volatile 是一个关键字,可以修饰类的成员变量、类的静态成员变量,主要有两个功能

第一:保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的,volatile关键字会强制将修改的值立即写入主存。

第二: 禁止进行指令重排序,可以保证代码执行有序性。底层实现原理是,添加了一个内存屏障,通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化

本题参考回答2

volatile关键字:在Java中,volatile关键字用于修饰共享变量,确保所有线程都从主内存中读取变量的最新值,而不是从线程的局部缓存中读取。它保证了对volatile变量的写入对其他线程是立即可见的,并且禁止了编译器对其进行优化。

16,什么是AQS

AQS 是构建锁和同步工具的底层框架,靠一个共享变量 state 和一个等待队列来实现“抢锁、阻塞、唤醒”的机制。

  • 全称:AbstractQueuedSynchronizer(抽象队列同步器)
  • 是 Java 并发包中所有**阻塞锁(如 ReentrantLock)同步工具(如 Semaphore、CountDownLatch)**的底层实现框架。
组件说明
state 变量(volatile)表示锁的状态(0=无锁,1=有锁)
CAS 操作确保修改 state 的原子性(一次只能一个线程成功)
FIFO 队列存放抢锁失败的线程(类似 synchronized 的 EntryList)
条件队列(Condition)实现等待/唤醒机制(类似 wait()/notify())

线程尝试抢锁(修改 state

  • 如果 state=0 → CAS 成功,抢锁成功。
  • 如果 state=1 → CAS 失败,进入等待队列。

等待线程排队

  • 队列是 FIFO 双向链表结构(头节点 head,尾节点 tail)。

释放锁时唤醒

  • 锁释放后,AQS 唤醒队列中排在最前的线程再次尝试抢锁。

常见实现类

类名作用
ReentrantLock可重入锁
Semaphore信号量(控制并发线程数)
CountDownLatch倒计时锁(等待任务完成)
类型特点举例
公平锁新线程会排队,先到先得(队列头线程优先)new ReentrantLock(true)
非公平锁新线程和队列中线程一起抢锁,谁快谁得new ReentrantLock(false)(默认)

一句通俗理解:
AQS 就像一个“抢座系统”:

  • state 表示座位是否被占;
  • CAS 保证一次只能一个人抢到;
  • 没抢到的人排队(FIFO);
  • 座位空出来时,再按顺序唤醒排队的人。

本题参考回答1

AQS的话,其实就一个jdk提供的类AbstractQueuedSynchronizer,是阻塞式锁和相关的同步器工具的框架。

内部有一个属性 state 属性来表示资源的状态,默认state等于0,表示没有获取锁,state等于1的时候才标明获取到了锁。通过cas 机制设置 state 状态

在它的内部还提供了基于 FIFO 的等待队列,是一个双向列表,其中

  • tail 指向队列最后一个元素
  • head 指向队列中最久的一个元素

其中我们刚刚聊的ReentrantLock底层的实现就是一个AQS。

本题参考回答2

what)AQS的话,其实就一个jdk提供的类AbstractQueuedSynchronizer,

(why)通过模板方法模式定义了同步器的底层机制,可以通过继承 AQS 并实现其关键方法来快速构建自己的同步工具,是阻塞式锁和相关的同步器工具的框架。

(how1)AQS 的核心是一个 FIFO 双向队列(CLH 队列),用于管理等待获取资源的线程。

(how2)它通过一个 volatile int state 变量表示同步状态(如锁是否被占用、信号量的许可数量等),(howw)并通过 CAS(Compare-And-Swap) 操作保证状态的原子性修改。

(feature)AQS 定义了两类同步模式:

独占模式(Exclusive)

同一时刻只有一个线程能获取资源(如 ReentrantLock)。

共享模式(Shared)

允许多个线程同时获取资源(如 Semaphore、CountDownLatch)。

AQS 会自动处理线程的排队、阻塞和唤醒,开发者只需关注如何定义“资源是否可用”的逻辑。

(apply)许多 Java 并发工具基于 AQS 实现:

ReentrantLock:可重入独占锁。

Semaphore:信号量控制并发访问数量。

CountDownLatch:等待多个任务完成。

ReentrantReadWriteLock:读写锁分离。

CyclicBarrier:线程到达屏障后同步执行。

AQS 的工作流程(了解即可)

以独占模式为例:

线程调用 acquire(int arg) 尝试获取资源。

若 tryAcquire 返回 true(资源可用),线程直接执行。

若资源不可用,线程会被包装为 Node 对象加入队列,并进入阻塞状态。

当持有资源的线程调用 release(int arg) 释放资源时,AQS 会唤醒队列中的下一个线程。

17.Reentrantlock的实现原理

ReentrantLock翻译过来是可重入锁,相对于synchronized它具备以下特点:

  • 可中断
  • 可以设置超时时间
  • 可以设置公平锁
  • 支持多个条件变量
  • 与synchronized一样,都支持重入

image-20251105162841670

ReentrantLock主要利用CAS+AQS队列来实现。它支持公平锁和非公平锁,两者的实现类似

构造方法接受一个可选的公平参数(默认非公平锁),当设置为true时,表示公平锁,否则为非公平锁。公平锁的效率往往没有非公平锁的效率高,在许多线程访问的情况下,公平锁表现出较低的吞吐量。

查看ReentrantLock源码中的构造方法:

image-20251105162902631

提供了两个构造方法,不带参数的默认为非公平

如果使用带参数的构造函数,并且传的值为true,则是公平锁

其中NonfairSync和FairSync这两个类父类都是Sync

image-20251105162916416

而Sync的父类是AQS,所以可以得出ReentrantLock底层主要实现就是基于AQS来实现的

img

工作流程

img

  • 线程来抢锁后使用CAS的方式修改state状态,修改状态成功为1,则让exclusiveOwnerThread属性指向当前线程,获取锁成功
  • 假如修改状态失败,则会进入双向队列中等待,head指向双向队列头部,tail指向双向队列尾部
  • 当exclusiveOwnerThread为null的时候,则会唤醒在双向队列中等待的线程
  • 公平锁则体现在按照先后顺序获取锁,非公平体现在不在排队的线程也可以抢锁

1.ReentrantLock 表示支持重新进入的锁,调用 lock 方法获取了锁之后,再次调用 lock,是不会再阻塞。

2.ReentrantLock 主要利用 CAS + AQS 队列 来实现。

3.支持 公平锁 和 非公平锁,在提供的构造器中,无参默认是非公平锁,也可以传入参数指定公平策略

本题参考回答

ReentrantLock是一个可重入锁:,调用 lock 方 法获取了锁之后,再次调用 lock,是不会再阻塞,内部直接增加重入次数 就行了,标识这个线程已经重复获取一把锁而不需要等待锁的释放。

ReentrantLock是属于juc报下的类,属于api层面的锁,跟synchronized一样,都是悲观锁。通过lock()用来获取锁,unlock()释放锁。

它的底层实现原理主要利用CAS+AQS队列来实现。它支持公平锁和非公平锁,两者的实现类似

构造方法接受一个可选的公平参数(默认非公平锁),当设置为true时,表示公平锁,否则为非公平锁。公平锁的效率往往没有非公平锁的效率高。

18.syschronized和Lock有什么区别

本题参考回答

第一,语法层面

  • synchronized 是关键字,源码在 jvm 中,用 c++ 语言实现,退出同步代码块锁会自动释放
  • Lock 是接口,源码由 jdk 提供,用 java 语言实现,需要手动调用 unlock 方法释放锁

第二,功能层面

  • 二者均属于悲观锁、都具备基本的互斥、同步、锁重入功能(锁(Lock)中的同步(synchronization)是指多个线程在访问共享资源时,必须按照一定的顺序进行,防止数据竞争和不一致问题。锁的主要作用就是保证同步,让多个线程按照串行化的方式访问临界区(共享资源)。)
  • Lock 提供了许多 synchronized 不具备的功能,例如获取等待状态、公平锁、可打断(假如其他线程获得了锁 该线程再去获得锁 会进行等待 然而调用 interrupt()能够打断锁的等待)、可超时、多条件变量,同时Lock 可以实现不同的场景,如 ReentrantLock, ReentrantReadWriteLock

第三,性能层面

  • 在没有竞争时,synchronized 做了很多优化,如偏向锁、轻量级锁
  • 在竞争激烈时,Lock 的实现通常会提供更好的性能

统合来看,需要根据不同的场景来选择不同的锁的使用。

19.死锁产生的条件是什么

死锁:一个线程需要同时获取多把锁,这时就容易发生死锁(回答比较笼统,建议通过死锁产生的四个必要条件来回答(互斥条件,不可剥夺条件,请求并保持条件,循环等待))

死锁(Deadlock) 是指两个或多个线程在执行过程中,因为竞争资源而造成的一种互相等待、永远无法继续执行的状态

死锁的发生必须同时满足以下 四个必要条件

  1. 互斥条件
    资源一次只能被一个线程占用。
    某个线程持有了锁,其他线程必须等待它释放。
  2. 请求与保持条件
    线程已经持有一个资源,又去请求新的资源。
    比如线程A拿了锁1,还想拿锁2。
  3. 不可剥夺条件
    已获得的资源在使用完之前,不能被强制剥夺。
    线程拿到锁后,别的线程不能抢,只能等它释放。
  4. 循环等待条件
    存在一个等待环,线程A等线程B的资源,线程B又等线程A的资源。
    A→B→A 的循环等待关系。

总结:死锁是指两个或多个线程在执行过程中,因竞争资源而出现的互相等待、永不释放锁的现象。
它的产生必须同时满足:互斥、请求并保持、不可剥夺、循环等待 四个条件。
只要破坏其中任意一个条件,就能避免死锁。

例如:

t1 线程获得A对象锁,接下来想获取B对象的锁

t2 线程获得B对象锁,接下来想获取A对象的锁

这种现象就是死锁现象…线程t1持有A的锁等待获取B锁,线程t2持有B的锁等待获取A的锁。

本题参考回答

嗯,是这样的,一个线程需要同时获取多把锁,这时就容易发生死锁,举个例子来说:

t1 线程获得A对象锁,接下来想获取B对象的锁

t2 线程获得B对象锁,接下来想获取A对象的锁

这个时候t1线程和t2线程都在互相等待对方的锁,就产生了死锁

20.如何进行死锁诊断

当程序出现了死锁现象,我们可以使用jdk自带的工具:jps和 jstack(一个main方法就是一个jvm进程,同一个jvm下的线程内存共享1)

步骤如下:

第一:查看运行的进程

image-20251105164259159

第二:使用jstack查看线程运行的情况,下图是截图的关键信息

运行命令:jstack -l 46032

image-20251105164421746

其他解决工具,可视化工具

  • jconsole

用于对jvm的内存,线程,类 的监控,是一个基于 jmx 的 GUI 性能监控工具

打开方式:java 安装目录 bin目录下 直接启动 jconsole.exe 就行

  • VisualVM:故障处理工具

能够监控线程,内存情况,查看方法的CPU时间和内存中的对 象,已被GC的对象,反向查看分配的堆栈

打开方式:java 安装目录 bin目录下 直接启动 jvisualvm.exe就行

本题参考回答

我们只需要通过jdk自动的工具就能搞定

我们可以先通过jps来查看当前java程序运行的进程id

然后通过jstack来查看这个进程id,就能展示出来死锁的问题,并且,可以定位代码的具体行号范围,我们再去找到对应的代码进行排查就行了。

21.ConcurrentHashMap

ConcurrentHashMap 是一种线程安全的高效Map集合

1.底层数据结构:

JDK 1.7 底层采用分段的数组 + 链表实现;

JDK 1.8 采用的数据结构跟 HashMap 1.8 的结构一样,数组 + 链表 / 红黑二叉树

2.加锁的方式:

JDK 1.7 采用 Segment 分段锁,底层使用的是 ReentrantLock;

JDK 1.8 采用 CAS 添加新节点,采用 synchronized 锁定链表或红黑二叉树的首节点,相对 Segment 分段锁粒度更细,性能更好

(1) JDK1.7中concurrentHashMap

数据结构

img

  • 提供了一个segment数组,在初始化ConcurrentHashMap 的时候可以指定数组的长度,默认是16,一旦初始化之后中间不可扩容
  • 在每个segment中都可以挂一个HashEntry数组,数组里面可以存储具体的元素,HashEntry数组是可以扩容的
  • 在HashEntry存储的数组中存储的元素,如果发生冲突,则可以挂单向链表

存储流程

img

  • 先去计算key的hash值,然后确定segment数组下标
  • 再通过hash值确定hashEntry数组中的下标存储数据
  • 在进行操作数据的之前,会先判断当前segment对应下标位置是否有线程进行操作,为了线程安全使用的是ReentrantLock进行加锁,如果获取锁失败会使用cas自旋锁进行尝试

(2) JDK1.8中concurrentHashMap

在JDK1.8中,放弃了Segment臃肿的设计,数据结构跟HashMap的数据结构是一样的:数组+红黑树+链表

采用 CAS + Synchronized来保证并发安全进行实现

  • CAS控制数组节点的添加
  • synchronized只锁定当前链表或红黑二叉树的首节点,只要hash不冲突,就不会产生并发的问题 , 效率得到提升

img

本题参考回答

ConcurrentHashMap 是一种线程安全的高效Map集合,jdk1.7和1.8也做了很多调整。

  • JDK1.7的底层采用是分段的数组+链表 实现
  • JDK1.8 采用的数据结构跟HashMap1.8的结构一样,数组+链表/红黑二叉树。

在jdk1.7中 ConcurrentHashMap 里包含一个 Segment 数组。Segment 的结构和HashMap类似,是一 种数组和链表结构,一个 Segment 包含一个 HashEntry 数组,每个 HashEntry 是一个链表结构 的元素,每个 Segment 守护着一个HashEntry数组里的元素,当对 HashEntry 数组的数据进行修 改时,必须首先获得对应的 Segment的锁。

Segment 是一种可重入的锁 ReentrantLock,每个 Segment 守护一个HashEntry 数组里得元 素,当对 HashEntry 数组的数据进行修改时,必须首先获得对应的 Segment 锁

在jdk1.8中的ConcurrentHashMap 做了较大的优化,性能提升了不少。首先是它的数据结构与jdk1.8的hashMap数据结构完全一致。其次是放弃了Segment臃肿的设计,取而代之的是采用Node + CAS + Synchronized来保 证并发安全进行实现,synchronized只锁定当前链表或红黑二叉树的首节点,这样只要hash不冲 突,就不会产生并发 , 效率得到提升

22.导致并发程序出现问题的根本原因是什么

Java并发编程三大特性

  • 原子性
  • 可见性
  • 有序性
(1)原子性

一个线程在CPU中操作不可暂停,也不可中断,要不执行完成,要不不执行

比如,如下代码能保证原子性吗?

image-20251105165638780

以上代码会出现超卖或者是一张票卖给同一个人,执行并不是原子性的

解决方案:

1.synchronized:同步加锁

2.JUC里面的lock:加锁

image-20251105165653712

(3)内存可见性

内存可见性:让一个线程对共享变量的修改对另一个线程可见

比如,以下代码不能保证内存可见性

image-20251105165720400

解决方案:

  • synchronized
  • volatile(推荐)
  • LOCK
(3)有序性

指令重排:处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的

还是之前的例子,如下代码:

image-20251105165748083

解决方案:

  • volatile

本题参考回答

Java并发编程有三大核心特性,分别是原子性、可见性和有序性。

首先,原子性指的是一个线程在CPU中的操作是不可暂停也不可中断的,要么执行完成,要么不执行。比如,一些简单的操作如赋值可能是原子的,但复合操作如自增就不是原子的。为了保证原子性,我们可以使用synchronized关键字或JUC里面的Lock来进行加锁。

其次,可见性是指让一个线程对共享变量的修改对另一个线程可见。由于线程可能在自己的工作内存中缓存共享变量的副本,因此一个线程对共享变量的修改可能不会立即反映在其他线程的工作内存中。为了解决这个问题,我们可以使用synchronized关键字、volatile关键字或Lock来确保可见性。

最后,有序性是指处理器为了提高程序运行效率,可能会对输入代码进行优化,导致程序中各个语句的执行先后顺序与代码中的顺序不一致。虽然处理器会保证程序最终执行结果与代码顺序执行的结果一致,但在某些情况下我们可能需要确保特定的执行顺序。为了解决这个问题,我们可以使用volatile关键字来禁止指令重排。

总结就是:

1.原子性

synchronized、Lock

2.内存可见性

volatile、synchronized、Lock

3.有序性

volatile

注:为什么锁能保证可见性?答:锁能保证可见性,是因为它在加锁时会从主内存读取最新变量值,
在释放锁时会把修改过的变量刷新回主内存。换句话说:锁操作隐含了内存屏障(Memory Barrier)语义,即当线程执行加锁或解锁操作时,JVM 会在底层自动插入合适的内存屏障指令,
从而保证:不发生指令重排序(有序性),对共享变量的修改对其他线程可见(可见性), 从而实现了线程间的可见性。

本题参考回答2

“并发问题的根本原因来自计算机底层的三个特性:

多核CPU并行执行:导致多个线程同时修改数据(原子性问题);

CPU缓存与主内存的分离:导致线程看不到其他线程的最新修改(可见性问题);

编译器/处理器的指令优化:导致代码执行顺序与预期不符(有序性问题)。

Java通过 synchronized、volatile、CAS等机制,从这三个层面分别解决问题:

锁和CAS保证原子性;

volatile和锁保证可见性;

volatile和final保证有序性。

23.说一下线程池的核心参数

线程池核心参数主要参考ThreadPoolExecutor这个类的7个参数的构造函数

img

  • corePoolSize 核心线程数目
  • maximumPoolSize 最大线程数目 = (核心线程+救急线程的最大数目)
  • keepAliveTime 生存时间 - 救急线程的生存时间,生存时间内没有新任务,此线程资源会释放
  • unit 时间单位 - 救急线程的生存时间单位,如秒、毫秒等
  • workQueue - 当没有空闲核心线程时,新来任务会加入到此队列排队,队列满会创建救急线程执行任务
  • threadFactory 线程工厂 - 可以定制线程对象的创建,例如设置线程名字、是否是守护线程等
  • handler 拒绝策略 - 当所有线程都在繁忙,workQueue 也放满时,会触发拒绝策略

工作流程

img

image-20251105183709353

1,任务在提交的时候,首先判断核心线程数是否已满,如果没有满则直接添加到工作线程执行

2,如果核心线程数满了,则判断阻塞队列是否已满,如果没有满,当前任务存入阻塞队列

3,如果阻塞队列也满了,则判断线程数是否小于最大线程数,如果满足条件,则使用临时线程执行任务

如果核心或临时线程执行完成任务后会检查阻塞队列中是否有需要执行的线程,如果有,则使用非核心线程执行任务

4,如果所有线程都在忙着(核心线程+临时线程),则走拒绝策略

拒绝策略:

1.AbortPolicy:直接抛出异常,默认策略;

2.CallerRunsPolicy:用调用者所在的线程来执行任务;

3.DiscardOldestPolicy:丢弃阻塞队列中靠最前的任务,并执行当前任务;

4.DiscardPolicy:直接丢弃任务;

本题参考回答

在线程池中一共有7个核心参数:

  1. corePoolSize 核心线程数目 - 池中会保留的最多线程数
  2. maximumPoolSize 最大线程数目 - 核心线程+救急线程的最大数目
  3. keepAliveTime 生存时间 - 救急线程的生存时间,生存时间内没有新任务,此线程资源会释放
  4. unit 时间单位 - 救急线程的生存时间单位,如秒、毫秒等
  5. workQueue - 当没有空闲核心线程时,新来任务会加入到此队列排队,队列满会创建救急线程执行任务
  6. threadFactory 线程工厂 - 可以定制线程对象的创建,例如设置线程名字、是否是守护线程等
  7. handler 拒绝策略 - 当所有线程都在繁忙,workQueue 也放满时,会触发拒绝策略

拒绝策略有4种,当线程数过多以后,第一种是抛异常、第二种是由调用者执行任务、第三是丢弃当前的任务,第四是丢弃最早排队任务。默认是直接抛异常。

image-20251105183841252

24.线程池中有哪些常见的阻塞队列

workQueue - 当没有空闲核心线程时,新来任务会加入到此队列排队,队列满会创建救急线程执行任务

比较常见的有4个,用的最多是ArrayBlockingQueue和LinkedBlockingQueue

1.ArrayBlockingQueue:基于数组结构的有界阻塞队列,FIFO。

2.LinkedBlockingQueue:基于链表结构的有界阻塞队列(单向链表),FIFO。

3.DelayedWorkQueue :是一个优先级队列,它可以保证每次出队的任务都是当前队列中执行时间最靠前的

4.SynchronousQueue:不存储元素的阻塞队列,每个插入操作都必须等待一个移出操作。

ArrayBlockingQueue的LinkedBlockingQueue区别

LinkedBlockingQueueArrayBlockingQueue
默认无界,支持有界强制有界
底层是链表底层是数组
是懒惰的,创建节点的时候添加数据提前初始化 Node 数组
入队会生成新 NodeNode需要是提前创建好的
两把锁(头尾)一把锁

左边是LinkedBlockingQueue加锁的方式,右边是ArrayBlockingQueue加锁的方式

  • LinkedBlockingQueue读和写各有一把锁,性能相对较好
  • ArrayBlockingQueue只有一把锁,读和写公用,性能相对于LinkedBlockingQueue差一些

img

本题参考回答

Jdk中提供了很多阻塞队列,开发中常见的有两个:ArrayBlockingQueueLinkedBlockingQueue

ArrayBlockingQueueLinkedBlockingQueue是Java中两种常见的阻塞队列,它们在实现和使用上有一些关键的区别。

首先,ArrayBlockingQueue是一个有界队列,它在创建时必须指定容量,并且这个容量不能改变。而LinkedBlockingQueue默认是无界的,但也可以在创建时指定最大容量,使其变为有界队列。

其次,它们在内部数据结构上也有所不同。ArrayBlockingQueue是基于数组实现的,而LinkedBlockingQueue则是基于链表实现的。这意味着ArrayBlockingQueue在访问元素时可能会更快,因为它可以直接通过索引访问数组中的元素。而LinkedBlockingQueue则在添加和删除元素时可能更快,因为它不需要移动其他元素来填充空间。

另外,它们在加锁机制上也有所不同。ArrayBlockingQueue使用一把锁来控制对队列的访问,这意味着读写操作都是互斥的。而LinkedBlockingQueue则使用两把锁,一把用于控制读操作,另一把用于控制写操作,这样可以提高并发性能。

25.如何确定核心线程数

在设置核心线程数之前,需要先熟悉一些执行线程池执行任务的类型

  • IO密集型任务

一般来说:文件读写、DB读写、网络请求等

推荐:核心线程数大小设置为2N+1 (N为计算机的CPU核数)

  • CPU密集型任务

一般来说:计算型代码、Bitmap转换、Gson转换等

推荐:核心线程数大小设置为N+1 (N为计算机的CPU核数)(需要大量的占用cpu,为了减少线程上下文的切换,增加效率,故设置为n+1,竟然要耗费很多cpu,那就不要让cpu总是切换到别的线程去,但cpu执行确实是随机的,那么只要减少线程数目,确实可以让线程获得更多的cpu执行权)

java代码查看CPU核数

image-20251105184715916

参考回答

① 高并发、任务执行时间短 -->( CPU核数+1 ),减少线程上下文的切换

② 并发不高、任务执行时间长

  • IO密集型的任务 --> (CPU核数 * 2 + 1)
  • 计算密集型任务 --> ( CPU核数+1 )

③ 并发高、业务执行时间长,解决这种类型任务的关键不在于线程池而在于整体架构的设计,看看这些业务里面某些数据是否能做缓存是第一步,增加服务器是第二步,至于线程池的设置,设置参考(2)

26.线程池的种类有哪些

image-20251105184937855

在java.util.concurrent.Executors类中提供了大量创建连接池的静态方法,常见就有四种

在java.util.concurrent.Executors类中提供了大量创建连接池的静态方法,常见就有四种

  1. 创建使用固定线程数的线程池
  • 核心线程数与最大线程数一样,没有救急线程
  • 阻塞队列是LinkedBlockingQueue,最大容量为Integer.MAX_VALUE
  • 适用场景:适用于任务量已知,相对耗时的任务
  • 案例:
public class FixedThreadPoolCase {
    static class FixedThreadDemo implements Runnable{
        @Override
        public void run() {
            String name = Thread.currentThread().getName();
            for (int i = 0; i < 2; i++) {
                System.out.println(name + ":" + i);
            }
        }
    }
    public static void main(String[] args) throws InterruptedException {
        //创建一个固定大小的线程池,核心线程数和最大线程数都是3
        ExecutorService executorService = Executors.newFixedThreadPool(3);
        for (int i = 0; i < 5; i++) {
            executorService.submit(new FixedThreadDemo());
            Thread.sleep(10);
        }
        executorService.shutdown();
    }
}

2.单线程化的线程池,它只会用唯一的工作线程来执行任 务,保证所有任务按照指定顺序(FIFO)执行

img

  • 核心线程数和最大线程数都是1
  • 阻塞队列是LinkedBlockingQueue,最大容量为Integer.MAX_VALUE
  • 适用场景:适用于按照顺序执行的任务
  • 案例:
public class NewSingleThreadCase {
    static int count = 0;
    static class Demo implements Runnable {
        @Override
        public void run() {
            count++;
            System.out.println(Thread.currentThread().getName() + ":" + count);
        }
    }
    public static void main(String[] args) throws InterruptedException {
        //单个线程池,核心线程数和最大线程数都是1
        ExecutorService exec = Executors.newSingleThreadExecutor();
        for (int i = 0; i < 10; i++) {
            exec.execute(new Demo());
            Thread.sleep(5);
        }
        exec.shutdown();
    }
}

3.可缓存线程池

img

  • 核心线程数为0
  • 最大线程数是Integer.MAX_VALUE
  • 阻塞队列为SynchronousQueue:不存储元素的阻塞队列,每个插入操作都必须等待一个移出操作。
  • 适用场景:适合任务数比较密集,但每个任务执行时间较短的情况
  • 案例:
public class CachedThreadPoolCase {
    static class Demo implements Runnable {
        @Override
        public void run() {
            String name = Thread.currentThread().getName();
            try {
                //修改睡眠时间,模拟线程执行需要花费的时间
                Thread.sleep(100);
                System.out.println(name + "执行完了");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
    public static void main(String[] args) throws InterruptedException {
        //创建一个缓存的线程,没有核心线程数,最大线程数为Integer.MAX_VALUE
        ExecutorService exec = Executors.newCachedThreadPool();
        for (int i = 0; i < 10; i++) {
            exec.execute(new Demo());
            Thread.sleep(1);
        }
        exec.shutdown();
    }
}

4.提供了“延迟”和“周期执行”功能的ThreadPoolExecutor。

img

  • 适用场景:有定时和延迟执行的任务
  • 案例:
public class ScheduledThreadPoolCase {
    static class Task implements Runnable {
        @Override
        public void run() {
            try {
                String name = Thread.currentThread().getName();
                System.out.println(name + ", 开始:" + new Date());
                Thread.sleep(1000);
                System.out.println(name + ", 结束:" + new Date());
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
    public static void main(String[] args) throws InterruptedException {
        //按照周期执行的线程池,核心线程数为2,最大线程数为Integer.MAX_VALUE
        ScheduledExecutorService scheduledThreadPool = Executors.newScheduledThreadPool(2);
        System.out.println("程序开始:" + new Date());
        /**
         * schedule 提交任务到线程池中
         * 第一个参数:提交的任务
         * 第二个参数:任务执行的延迟时间
         * 第三个参数:时间单位
         */
        scheduledThreadPool.schedule(new Task(), 0, TimeUnit.SECONDS);
        scheduledThreadPool.schedule(new Task(), 1, TimeUnit.SECONDS);
        scheduledThreadPool.schedule(new Task(), 5, TimeUnit.SECONDS);
        Thread.sleep(5000);
        // 关闭线程池
        scheduledThreadPool.shutdown();
    }
}

27.为什么不建议使用Excustors创建线程池

参考回答

其实这个事情在阿里提供的最新开发手册《Java开发手册-嵩山版》中也提到了

主要原因是如果使用Executors创建线程池的话,它允许的请求队列默认长度是Integer.MAX_VALUE,这样的话,有可能导致堆积大量的请求,从而导致OOM(内存溢出)。

所以,我们一般推荐使用ThreadPoolExecutor来创建线程池,这样可以明确规定线程池的参数,避免资源的耗尽。

28.线程池使用场景CountDownLatch、Future(你们项目哪里用到了多线程)

CountDownLatch(闭锁/倒计时锁)用来进行线程同步协作,等待所有线程完成倒计时(一个或者多个线程,等待其他多个线程完成某件事情之后才能执行)

  • 其中构造参数用来初始化等待计数值
  • await() 用来等待计数归零
  • countDown() 用来让计数减一

img

案例代码:

public class CountDownLatchDemo {
    public static void main(String[] args) throws InterruptedException {
        //初始化了一个倒计时锁 参数为 3
        CountDownLatch latch = new CountDownLatch(3);
        new Thread(() -> {
            System.out.println(Thread.currentThread().getName()+"-begin...");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            //count--
            latch.countDown();
            System.out.println(Thread.currentThread().getName()+"-end..." +latch.getCount());
        }).start();
        new Thread(() -> {
            System.out.println(Thread.currentThread().getName()+"-begin...");
            try {
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            //count--
            latch.countDown();
            System.out.println(Thread.currentThread().getName()+"-end..." +latch.getCount());
        }).start();
        new Thread(() -> {
            System.out.println(Thread.currentThread().getName()+"-begin...");
            try {
                Thread.sleep(1500);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            //count--
            latch.countDown();
            System.out.println(Thread.currentThread().getName()+"-end..." +latch.getCount());
        }).start();
        String name = Thread.currentThread().getName();
        System.out.println(name + "-waiting...");
        //等待其他线程完成
        latch.await();
        System.out.println(name + "-wait end...");
    }
}

1.案例一(es数据批量导入)

在我们项目上线之前,我们需要把数据库中的数据一次性的同步到es索引库中,但是当时的数据好像是1000万左右,一次性读取数据肯定不行(oom异常),当时我就想到可以使用线程池的方式导入,利用CountDownLatch来控制,就能避免一次性加载过多,防止内存溢出

整体流程就是通过CountDownLatch+线程池配合去执行

image-20251105185544252详细实现流程:

img

image-20251105185908291

img

详细实现代码,请查看当天代码

  • 在实际开发的过程中,难免需要调用多个接口来汇总数据,如果所有接口(或部分接口)的没有依赖关系,就可以使用线程池+future来提升性能(关于这块我有点说明,尽量不要用executor去执行,因为ex只能执行runnable接口的方法,并且不能处理返回值,但是submit可以执行runnable和callable的方法,并且能使用future。可以说是ex的增强。推荐以后尽量使用submit提交任务)(execute方法定义在Executor接口中,是线程池的基础方法,用于执行Runnable任务。

    submit方法则定义在ExecutorService接口中,提供了更多的灵活性,可以提交实现了Callable或Runnable接口的任务。)

  • 报表汇总

img

  1. 案例二(异步调用)

img

在进行搜索的时候,需要保存用户的搜索记录,而搜索记录不能影响用户的正常搜索,我们通常会开启一个线程去执行历史记录的保存,在新开启的线程在执行的过程中,可以利用线程提交任务

参考回答

嗯~~,我想一下当时的场景[根据自己简历上的模块设计多线程场景]

参考场景一:

es数据批量导入

在我们项目上线之前,我们需要把数据量的数据一次性的同步到es索引库中,但是当时的数据好像是1000万左右,一次性读取数据肯定不行(oom异常),如果分批执行的话,耗时也太久了。所以,当时我就想到可以使用线程池的方式导入,利用CountDownLatch+Future来控制,就能大大提升导入的时间。

参考场景二:

在我做那个xx电商网站的时候,里面有一个数据汇总的功能,在用户下单之后需要查询订单信息,也需要获得订单中的商品详细信息(可能是多个),还需要查看物流发货信息。因为它们三个对应的分别三个微服务,如果一个一个的操作的话,互相等待的时间比较长。所以,我当时就想到可以使用线程池,让多个线程同时处理,最终再汇总结果就可以了,当然里面需要用到Future来获取每个线程执行之后的结果才行

参考场景三:

《黑马头条》项目中使用的

我当时做了一个文章搜索的功能,用户输入关键字要搜索文章,同时需要保存用户的搜索记录(搜索历史),这块我设计的时候,为了不影响用户的正常搜索,我们采用的异步的方式进行保存的,为了提升性能,我们加入了线程池,也就说在调用异步方法的时候,直接从线程池中获取线程使用

29.如何控制某个方法允许访问的并发线程数量

AQS这套框架底层用的CAS维护state来计数。AQS只是一个框架,在LOCK中利用该框架,1表上锁,0表无锁。不同的类去包装这个AQS,那么对于计数量可以有不同的用法。前面的countdownletch可以用来等待任务完成,这个semaphore用来使得线程获得许可。但终究离不开底层AQS的原理,但凡没有设置到state都会进去阻塞队列等待。

CAS利用乐观锁思想保证原子性,在cpu层面无论多高并发都是串行的,只是对于线程来说看起来是并行的。假设有个变量 count = 0,两个线程同时执行:

count++;

JVM 会将这转化为类似 CAS 的逻辑:

1️⃣ 线程 A 读到 count = 0,准备改为 1
2️⃣ 线程 B 也读到 count = 0,准备改为 1
3️⃣ CPU 层执行时:

  • A 先执行 CAS(0→1),成功;
  • B 的 CAS 比较时发现现在 count 已是 1,失败;
    4️⃣ B 会重新读取 count 再尝试。

整个过程:

  • 对线程来说,好像两个线程“同时”在加;
  • 对 CPU 来说,CAS 操作严格串行执行,保证数据安全。

image-20251105191104148

Semaphore [ˈsɛməˌfɔr] 信号量,是JUC包下的一个工具类,我们可以通过其限制执行的线程数量,达到限流的效果

当一个线程执行时先通过其方法进行获取许可操作,获取到许可的线程继续执行业务逻辑,当线程执行完成后进行释放许可操作,未获取达到许可的线程进行等待或者直接结束。

Semaphore两个重要的方法

lsemaphore.acquire(): 请求一个信号量,这时候的信号量个数-1(一旦没有可使用的信号量,也即信号量个数变为负数时,再次请求的时候就会阻塞,直到其他线程释放了信号量)

lsemaphore.release():释放一个信号量,此时信号量个数+1

线程任务类:

public class SemaphoreCase {
public static void main(String[] args) {
// 1. 创建 semaphore 对象
Semaphore semaphore = new Semaphore(3);
// 2. 10个线程同时运行
for (int i = 0; i < 10; i++) {
new Thread(() -> {
try {
// 3. 获取许可
semaphore.acquire();
} catch (InterruptedException e) {
e.printStackTrace();
}
try {
System.out.println("running...");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("end...");
} finally {
// 4. 释放许可
semaphore.release();
}
}).start();
}
}
}

参考回答

嗯~~,我想一下

在jdk中提供了一个Semaphore[seməfɔːr]类(信号量)

它提供了两个方法,semaphore.acquire() 请求信号量,可以限制线程的个数,是一个正数,如果信号量是-1,就代表已经用完了信号量,其他线程需要阻塞了

第二个方法是semaphore.release(),代表是释放一个信号量,此时信号量的个数+1

30.谈谈你对ThreadLocal的理解

ThreadLocal是多线程中对于解决线程安全的一个操作类,它会为每个线程都分配一个独立的线程副本从而解决了变量并发访问冲突的问题。ThreadLocal 同时实现了线程内的资源共享(这里的“资源共享”指的是 同一个线程内的多个方法或组件可以共享同一个变量副本)

案例:使用JDBC操作数据库时,会将每一个线程的Connection放入各自的ThreadLocal中,从而保证每个线程都在各自的 Connection 上进行数据库的操作,避免A线程关闭了B线程的连接。

img

  1. ThreadLocal基本使用

三个主要方法:

  • set(value) 设置值
  • get() 获取值
  • remove() 清除值
public class ThreadLocalTest {
    static ThreadLocal threadLocal = new ThreadLocal<>();
    public static void main(String[] args) {
        new Thread(() -> {
            String name = Thread.currentThread().getName();
            threadLocal.set("itcast");
            print(name);
            System.out.println(name + "-after remove : " + threadLocal.get());
        }, "t1").start();
        new Thread(() -> {
            String name = Thread.currentThread().getName();
            threadLocal.set("itheima");
            print(name);
            System.out.println(name + "-after remove : " + threadLocal.get());
        }, "t2").start();
    }
    static void print(String str) {
        //打印当前线程中本地内存中本地变量的值
        System.out.println(str + " :" + threadLocal.get());
        //清除本地内存中的本地变量
        threadLocal.remove();
    }
}
  1. ThreadLocal的实现原理&源码解析

ThreadLocal本质来说就是一个线程内部存储类,从而让多个线程只操作自己内部的值,从而实现线程数据隔离

img

在ThreadLocal中有一个内部类叫做ThreadLocalMap,类似于HashMap

ThreadLocalMap中有一个属性table数组,这个是真正存储数据的位置

set方法

img

get方法/remove方法

img

  1. ThreadLocal-内存泄露问题

Java对象中的四种引用类型:强引用、软引用、弱引用、虚引用

  • 强引用:最为普通的引用方式,表示一个对象处于有用且必须的状态,如果一个对象具有强引用,则GC并不会回收它。即便堆中内存不足了,宁可出现OOM,也不会对其进行回收

img

  • 弱引用:表示一个对象处于可能有用且非必须的状态。在GC线程扫描内存区域时,一旦发现弱引用,就会回收到弱引用相关联的对象。对于弱引用的回收,无关内存区域是否足够,一旦发现则会被回收

img

每一个Thread维护一个ThreadLocalMap,在ThreadLocalMap中的Entry对象继承了WeakReference。其中key为使用弱引用的ThreadLocal实例,value为线程变量的副本

img

在使用ThreadLocal的时候,强烈建议:务必手动remove

参考回答

面试官:谈谈你对ThreadLocal的理解

候选人

嗯,是这样的~~

ThreadLocal 主要功能有两个,第一个是可以实现资源对象的线程隔离,让每个线程各用各的资源对象,避免争用引发的线程安全问题,第二个是实现了线程内的资源共享

面试官:好的,那你知道ThreadLocal的底层原理实现吗?

候选人

嗯,知道一些~

在ThreadLocal内部维护了一个一个 ThreadLocalMap 类型的成员变量,用来存储资源对象

当我们调用 set 方法,就是以 ThreadLocal 自己作为 key,资源对象作为 value,放入当前线程的 ThreadLocalMap 集合中

当调用 get 方法,就是以 ThreadLocal 自己作为 key,到当前线程中查找关联的资源值

当调用 remove 方法,就是以 ThreadLocal 自己作为 key,移除当前线程关联的资源值

面试官:好的,那关于ThreadLocal会导致内存溢出这个事情,了解吗?

候选人

嗯,我之前看过源码,我想一下~~

是因为ThreadLocalMap 中的 key 被设计为弱引用,它是被动的被GC调用释放key,不过关键的是只有key可以得到内存释放,而value不会,因为value是一个强引用。

在使用ThreadLocal 时都把它作为静态变量(即强引用),因此无法被动依靠 GC 回收,建议主动的remove 释放 key,这样就能避免内存溢出。
etName();
threadLocal.set(“itheima”);
print(name);
System.out.println(name + "-after remove : " + threadLocal.get());
}, “t2”).start();
}

static void print(String str) {
    //打印当前线程中本地内存中本地变量的值
    System.out.println(str + " :" + threadLocal.get());
    //清除本地内存中的本地变量
    threadLocal.remove();
}

}

1.  ThreadLocal的实现原理&源码解析
ThreadLocal本质来说就是一个线程内部存储类,从而让多个线程只操作自己内部的值,从而实现线程数据隔离
[外链图片转存中...(img-nX7g7BIA-1762498699413)]
在ThreadLocal中有一个内部类叫做ThreadLocalMap,类似于HashMap
ThreadLocalMap中有一个属性table数组,这个是真正存储数据的位置
**set方法**
[外链图片转存中...(img-EjO7ysnN-1762498699413)]
**get方法/remove方法**
[外链图片转存中...(img-LYO72eos-1762498699413)]
1.  ThreadLocal-内存泄露问题
Java对象中的四种引用类型:强引用、软引用、弱引用、虚引用
- 强引用:最为普通的引用方式,表示一个对象处于有用且必须的状态,如果一个对象具有强引用,则GC并不会回收它。即便堆中内存不足了,宁可出现OOM,也不会对其进行回收
[外链图片转存中...(img-L2JWi6oB-1762498699413)]
- 弱引用:表示一个对象处于可能有用且非必须的状态。在GC线程扫描内存区域时,一旦发现弱引用,就会回收到弱引用相关联的对象。对于弱引用的回收,无关内存区域是否足够,一旦发现则会被回收
[外链图片转存中...(img-fYzMCbum-1762498699413)]
每一个Thread维护一个ThreadLocalMap,在ThreadLocalMap中的Entry对象继承了WeakReference。其中key为使用弱引用的ThreadLocal实例,value为线程变量的副本
[外链图片转存中...(img-eHAnqhpt-1762498699413)]
在使用ThreadLocal的时候,强烈建议:**务必手动remove**
**参考回答**
**面试官**:谈谈你对ThreadLocal的理解
**候选人**:
嗯,是这样的~~
ThreadLocal 主要功能有两个,第一个是可以实现资源对象的线程隔离,让每个线程各用各的资源对象,避免争用引发的线程安全问题,第二个是实现了线程内的资源共享
**面试官**:好的,那你知道ThreadLocal的底层原理实现吗?
**候选人**:
嗯,知道一些~
在ThreadLocal内部维护了一个一个 ThreadLocalMap 类型的成员变量,用来存储资源对象
当我们调用 set 方法,就是以 ThreadLocal 自己作为 key,资源对象作为 value,放入当前线程的 ThreadLocalMap 集合中
当调用 get 方法,就是以 ThreadLocal 自己作为 key,到当前线程中查找关联的资源值
当调用 remove 方法,就是以 ThreadLocal 自己作为 key,移除当前线程关联的资源值
**面试官**:好的,那关于ThreadLocal会导致内存溢出这个事情,了解吗?
**候选人**:
嗯,我之前看过源码,我想一下~~
是因为ThreadLocalMap 中的 key 被设计为弱引用,它是被动的被GC调用释放key,不过关键的是只有key可以得到内存释放,而value不会,因为value是一个强引用。
在使用ThreadLocal 时都把它作为静态变量(即强引用),因此无法被动依靠 GC 回收,建议主动的remove 释放 key,这样就能避免内存溢出。
posted @ 2025-12-12 14:02  clnchanpin  阅读(70)  评论(0)    收藏  举报