面试题系列文章持续更新,源于个人搜寻和整理总结

线程基础知识

说说线程的生命周期和状态?

Java线程共有6种状态,定义在Thread.State枚举中。

Java 线程在运行的生命周期中的指定时刻只可能处于下面 6 种不同状态的其中一个状态:

  • NEW: 初始状态,线程被创建出来但没有被调用 start()
  • RUNNABLE: 运行状态,线程被调用了 start()等待运行的状态。
  • BLOCKED:阻塞状态,需要等待锁释放。
  • WAITING:等待状态,表示该线程需要等待其他线程做出一些特定动作(通知或中断)。
  • TIME_WAITING:超时等待状态,可以在指定的时间后自行返回而不是像 WAITING 那样一直等待。
  • TERMINATED:终止状态,表示该线程已经运行完毕。

线程在生命周期中并不是固定处于某一个状态而是随着代码的执行在不同状态之间切换。

Java 线程状态变迁图

Java 线程状态变迁图

线程创建后处于NEW状态,调用 start()方法进入RUNNABLE状态,此时线程可能正在执行或等待CPU调度。当线程调用Object.wait()Thread.join()LockSupport.park()时进入WAITING状态,需要其他线程显式唤醒。调用带超时参数的wait(timeout)sleep(timeout)join(timeout)会进入TIMED_WAITING状态,超时后自动返回RUNNABLE。线程尝试获取 synchronized 锁或显式锁失败时进入BLOCKED状态,获取到锁后回到RUNNABLE。线程执行完毕或抛出未捕获异常时进入TERMINATED状态

image-20260304194415384

乐观锁和悲观锁的概念?各自的实现方式和适用场景?

乐观锁认为数据冲突概率较低,不在读取时加锁,而是在更新时检查数据是否被其他线程修改过。常见实现方式包括版本号机制(每次更新递增版本号,更新时比较版本号是否一致)和CAS操作(Compare And Swap,原子性地比较内存值与期望值,相等则更新)。数据库中通过时间戳或版本字段实现,如UPDATE table SET value=? WHERE id=? AND version=?

悲观锁假设数据冲突频繁发生,在读取数据时就加锁,直到操作完成才释放锁。实现方式包括数据库行锁(SELECT FOR UPDATE)、互斥锁(Mutex)、读写锁等。Java中的synchronized关键字和ReentrantLock都属于悲观锁范畴。

适用场景方面乐观锁适合读多写少的场景,比如电商商品浏览、文章阅读等,因为冲突概率低,性能更好。悲观锁适合写操作频繁或数据一致性要求极高的场景,如银行转账、库存扣减等,虽然性能相对较低但能确保数据安全。选择时需要根据具体业务的并发特点和一致性要求来权衡,没有绝对的优劣之分。

Java里面的线程和操作系统的线程一样吗?

Java 底层会调用 Native 方法 pthread_create 来创建线程,所以本质上 Java 程序创建的线程,就是和操作系统线程是一样的,是 1 对 1 的线程模型。

什么情况会产生死锁问题?如何解决?

死锁只有同时满足以下四个条件才会发生:

  • 互斥条件:互斥条件是指多个线程不能同时使用同一个资源
  • 持有并等待条件:持有并等待条件是指,当线程 A 已经持有了资源 1,又想申请资源 2,而资源 2 已经被线程 C 持有了,所以线程 A 就会处于等待状态,但是线程 A 在等待资源 2 的同时并不会释放自己已经持有的资源 1
  • 不可剥夺条件:不可剥夺条件是指,当线程已经持有了资源 ,在自己使用完之前不能被其他线程获取,线程 B 如果也想使用此资源,则只能在线程 A 使用完并释放后才能获取。
  • 环路等待条件:环路等待条件指的是,在死锁发生的时候,两个线程获取资源的顺序构成了环形链

例如,线程 A 持有资源 R1 并试图获取资源 R2,而线程 B 持有资源 R2 并试图获取资源 R1,此时两个线程相互等待对方释放资源,从而导致死锁。

避免死锁问题就只需要破环其中一个条件就可以,最常见的并且可行的就是使用资源有序分配法,来破环环路等待条件

而且认为预测并且避免死锁也是很好的死锁解决方式,可以使用银行家算法等来避免死锁。

死锁的时候cpu利用率是高还是低?为什么?

死锁的本质是线程间互相持有对方需要的锁,并且都在无限期等待对方释放锁,进入了阻塞等待状态

死锁时 CPU 利用率通常很低,因为死锁线程都进入了阻塞等待状态,不再执行任何指令,不消耗 CPU 时间片,但是也不能一定说成这样,这个关键是看用了什么锁

  • 如果是用了自旋锁而非阻塞锁,线程在获取锁失败时会自旋循环重试,出现忙等待,这会持续消耗 CPU 来探测锁的状态,表现为 CPU 利用率高。
  • 如果是互斥锁,拿不到锁就让线程休眠的,这时候就相当于放弃了 cpu,从 RUNNING 状态变为 BLOCKED 或 WAITING 状态,它们不再占用 CPU 时间片去执行指令,CPU 利用率自然不是很高

如果不是死锁,而是大量线程争抢同一把锁,会导致频繁的上下文切换,这也会推高 CPU 使用率,但这属于锁竞争,不是死锁。

Thread

Java多线程是实现方式是什么?(线程的创建方式有哪些?)能不能简要介绍一下

Java 中实现多线程是通过创建线程分配任务来实现,创建线程的方式有如下四种

  1. 继承 Thread 类:

    Thread 本身是 Java 多线程的核心类,它实现了 Runnable 接口。如果继承 Thread 类,需要重写run()方法,调用时通过start()方法通知 JVM 创建新线程并回调 run()

    直接调用run()只是普通方法执行,不会新建线程,这也是 run()start()方法的区别

    但项目中几乎不用这种方式,因为 Java 是单继承机制,继承 Thread 后就无法继承其他业务类

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    // 基础示例(项目中几乎不用,因为Java单继承限制)
    class MyThread extends Thread {
    @Override
    public void run() {
    // 线程执行的业务逻辑
    System.out.println("线程执行:" + Thread.currentThread().getName());
    }
    }
    // 调用
    new MyThread().start();
  2. 实现 Runnable 接口

    这是更推荐的方式,因为 Runnable 是接口,不影响类继承其他类、实现其他接口,符合 面向接口编程 的设计原则。实现 Runnable 后,同样重写run()方法,启动时需要把 Runnable 实例传入 Thread 类。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    // 项目中适配场景:比如异步处理用户操作日志
    class LogRunnable implements Runnable {
    private String logContent;
    public LogRunnable(String logContent) {
    this.logContent = logContent;
    }
    @Override
    public void run() {
    // 异步写入操作日志到数据库(项目中实际场景)
    logService.saveOperateLog(logContent);
    }
    }
    // 调用(项目中会配合线程池,而非直接new Thread)
    ExecutorService executor = Executors.newFixedThreadPool(5);
    executor.submit(new LogRunnable("用户提交AI分析请求"));
  3. 实现 Callable 接口

    实现 Callable 接口来实现带返回值的多线程,是 JDK 1.5 后新增的方式,解决了 Runnable 无返回值、无法抛出检查异常的问题。Callable 的call()方法可以返回结果,配合 Future/FutureTask 能获取异步执行结果,项目中我在 AI 报告生成的异步结果获取 场景

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    // 项目场景:异步生成分析报告,需要返回报告内容
    class ReportCallable implements Callable<String> {
    private Long userId;
    public ReportCallable(Long userId) {
    this.userId = userId;
    }
    @Override
    public String call() throws Exception {
    // 调用AI接口生成报告(耗时操作)
    return aiService.generateDreamReport(userId);
    }
    }
    // 调用:提交到线程池并获取结果
    ExecutorService executor = Executors.newSingleThreadExecutor();
    Future<String> future = executor.submit(new ReportCallable(1001L));
    // 主线程可做其他操作,后续获取结果(非阻塞/阻塞均可)
    String report = future.get(30, TimeUnit.SECONDS); // 超时时间30秒
  4. 使用线程池创建

    这是一种更高效的线程管理方式,避免了频繁创建和销毁线程的开销。

如何启动一个线程?可以直接调用 Thread 类的 run 方法吗?

可以是可以,但是 run 不会创建一个新的线程,只是一个普通的方法去运行方法中的内容

new 一个 Thread,线程进入了新建状态。调用 start()方法,会启动一个线程并使线程进入了就绪状态,当分配到时间片后就可以开始运行了。 start() 会执行线程的相应准备工作,然后自动执行 run() 方法的内容,这是真正的多线程工作。 但是,直接执行 run() 方法,会把 run() 方法当成一个普通方法在调用该方法的线程去执行,所以这并不是在多线程工作。

调用 start() 方法方可启动线程并使线程进入就绪状态,直接执行 run() 方法的话不会以多线程的方式执行

而且由于线程状态是单向流转的,一旦离开 NEW 状态就回不去了。因为一个线程执行完毕后,它的栈空间、寄存器上下文都被回收了,没法复活。

所以一个线程在 Java 中被两次调用 start() 方法,JVM 会拒绝启动并抛异常

如何停止一个线程的运行?Thread 线程使用完了的关闭方法是什么?如果我希望他能够立刻被GC应该怎么办

Java 从设计上就不允许开发者主动销毁线程,Thread.stop()Thread.destroy()都是废弃方法,因为它们会导致线程资源泄露、数据不一致,线程的结束只能靠让线程的 run () 方法执行完毕

  1. 自然执行完 run () 方法,线程自动进入 TERMINATED 终止状态

  2. 通过中断优雅停止线程

    一个线程不能直接杀死另一个线程,只能请求它停止,由被请求的线程自己决定何时、如何退出。这就是 线程中断,中断一个线程非常简单,只需要在其他线程中对目标线程调用interrupt()方法,目标线程需要反复检测自身状态是否是interrupted状态,如果是,就立刻结束运行。

    调用 t.interrupt()实际上是给线程 t 打上一个 中断标记,设置其内部的 interrupted 标志为 true,不会立刻终止线程。然后线程 t 自己在代码中定期检查这个标记,如果发现被中断,就主动退出 run() 方法。

    但是请注意,interrupt()方法仅仅向t线程发出了中断请求,至于t线程是否能立刻响应,是怎么样响应,响应后多久退出run()方法,要看具体代码。

    阻塞操作中收到中断请求时,会抛出 InterruptedException 并清除中断状态。

    那么,利用中断停止线程也很简单,就是如果线程为中断状态则 return,能达到停止线程的效果。

    或者先将线程 sleep()让其阻塞,然后调用 interrupt 标记中断状态, 会抛出中断异常来退出阻塞状态,这样也能停止线程,但是不好

  3. 使用停止标记

    定义一个 可见的 状态变量,由主线程控制其值,工作线程循环检测该变量以决定是否退出。

    img
  4. 通过 Future 取消任务

    这是针对线程池的取消办法,但是请注意需要使用submit()方法,他能支持提交 Callable 接口的对象,然后返回Future作为异步结果,才能使用它进行取消

  5. 资源关闭

    对于不可中断的阻塞操作,显式关闭资源触发异常然后让线程结束。

让线程进入 终止状态TERMINATED,线程对象才会失去活跃引用,这是 GC 的基础,但是线程中如果持有静态引用、ThreadLocal 未清理、连接或流未关闭等等情况干扰了线程的显示引用,即使线程终止,也会导致线程对象无法被 GC,这是项目中最常见的问题,比如 ThreadLocal 使用后必须调用remove(),否则线程池中的线程会一直持有 ThreadLocal 的引用,导致内存泄漏

当然你也可以试着调用System.gc()Runtime.getRuntime().gc()提示 JVM 进行 GC))))

调用 interrupt 是如何让线程抛出异常的?

每个线程都一个与之关联的布尔属性来表示其中断状态,中断状态的初始值为false,当一个线程被其它线程调用Thread.interrupt()方法中断时,会根据实际情况做出响应。

  • 如果该线程正在执行低级别的可中断方法(如Thread.sleep()Thread.join()Object.wait()),则会解除阻塞并抛出InterruptedException异常
  • 否则Thread.interrupt()仅设置线程的中断状态,在该被中断的线程中稍后可通过轮询中断状态来决定是否要停止当前正在执行的任务。

Java 中 wait() 和 sleep() 的区别?

最关键的区别就一个:wait() 会释放锁,sleep() 不会。详细一些就是这样

  • wait():是 Object 类的实例方法,必须在 synchronized 块里调用,即线程需持有该对象的锁,调用后当前线程释放对象锁,进入等待队列,等着别人调 notify()notifyAll() 把它唤醒。
  • sleep() 是 Thread 类的静态方法,随便在哪都能调,而且无需事先获取锁,线程睡指定时间后自己恢复。如果当前线程持有锁,睡觉期间锁还持有,别的线程进不来。
image-20260308174303218

这里引申一个问题?

sleep会释放cpu吗?

  • 是的,调用 Thread.sleep() 时,线程会释放 CPU,但不会释放持有的锁。当线程调用sleep() 后,会主动让出 CPU 时间片,进入 TIMED_WAITING 状态。操作系统会把 CPU 分配给其他处于就绪状态的线程

wait 状态下的线程如何进行恢复到 running 状态?那么 notify 和 notifyAll 的区别?notify 选择哪个线程?

线程从 等待(WAIT) 状态恢复到 运行(RUNNING) 状态,就是让正在等待的线程被其他线程对象唤醒,使用notify()notifyAll()

当然,如果是使用的是condition.await() 方法进入等待状态,需要使用对应的condition.signal() 方法唤醒

notify()notifyAll()同样是唤醒等待的线程,同样最多只有一个线程能获得锁,同样不能控制哪个线程获得锁。

区别在于:

  • notify():唤醒一个线程,其他线程依然处于 wait 的等待唤醒状态,如果被唤醒的线程结束时没调用notify,其他线程就永远没人去唤醒,只能等待超时,或者被中断
  • notifyAll():所有线程退出 wait 的状态,开始竞争锁,但只有一个线程能抢到,这个线程执行完后,其他线程又会有一个幸运儿脱颖而出得到锁

notify()在源码的注释中说到notify() 选择唤醒的线程是任意的,但是依赖于具体实现的JVM。

但是注意,HotSpot 对 notify() 的实现并不是我们以为的随机唤醒,,而是 先进先出 的顺序唤醒

synchronized 和 volatile

synchronized 和 volatile 有什么区别?

synchronized 关键字和 voliatle 关键字是两个互补的存在

  • 是否保证线程安全volatile 关键字能保证数据的可见性,但不能保证数据的原子性,因此不能完全保证线程安全。synchronized 关键字保证了多个线程访问共享资源时的互斥性,原子,有序,可见都能保证,能完全保证线程安全
  • 性能和用途volatile 关键字是线程同步的轻量级实现,所以 volatile性能肯定比synchronized关键字要好 。但是 volatile 关键字只能用于变量而 synchronized 关键字可以修饰方法以及代码块 。
  • 使用场景volatile关键字主要用于解决变量在多个线程之间的可见性,而 synchronized 关键字解决的是多个线程之间访问资源的同步性。

voliatle关键字有什么作用?

volatile作用有 2 个:

  • 保证变量对所有线程的可见性。当一个变量被声明为volatile时,它会保证对这个变量的写操作会立即刷新到主存中,而对这个变量的读操作会直接从主存中读取,从而确保了多线程环境下对该变量访问的可见性。这意味着一个线程修改了volatile变量的值,其他线程能够立刻看到这个修改,不会受到各自线程工作内存的影响。
  • 禁止指令重排序优化volatile关键字在 Java 中主要通过内存屏障来禁止特定类型的指令重排序。
    • 写-写(Write-Write)屏障:在对volatile变量执行写操作之前,会插入一个写屏障。这确保了在该变量写操作之前的所有普通写操作都已完成,防止了这些写操作被移到volatile写操作之后。
    • 读-写(Read-Write)屏障:在对volatile变量执行读操作之后,会插入一个读屏障。它确保了对volatile变量的读操作之后的所有普通读操作都不会被提前到volatile读volatile执行,保证了读取到的数据是最新的。
    • 写-读(Write-Read)屏障:这是最重要的一个屏障,它发生在volatile写之后和volatile读之前。这个屏障确保了volatile写操作之前的所有内存操作(包括写操作)都不会被重排序到volatile读之后,同时也确保了volatile读操作之后的所有内存操作(包括读操作)都不会被重排序到volatile写之前。

简单说下synchronized锁升级过程

具体的锁升级的过程是:无锁->偏向锁->轻量级锁->重量级锁。、

  • 无锁:这是没有开启偏向锁的时候的状态,在 JDK1.6 之后偏向锁的默认开启的,但是有一个偏向延迟,需要在JVM 启动之后的多少秒之后才能开启,这个可以通过 JVM 参数进行设置,同时是否开启偏向锁也可以通过 JVM 参数设置。
  • 偏向锁:这个是在偏向锁开启之后的锁的状态,如果还没有一个线程拿到这个锁的话,这个状态叫做匿名偏向,当一个线程拿到偏向锁的时候,下次想要竞争锁只需要拿线程ID跟MarkWord当中存储的线程ID进行比较,如果线程ID相同则直接获取锁(相当于锁偏向于这个线程),不需要进行CAS操作和将线程挂起的操作。
  • 轻量级锁:在这个状态下线程主要是通过 CAS 操作实现的。将对象的 MarkWord 存储到线程的虚拟机栈上,然后通过 CAS 将对象的 MarkWord 的内容设置为指向 Displaced Mark Word 的指针,如果设置成功则获取锁。在线程出临界区的时候,也需要使用 CAS,如果使用 CAS 替换成功则同步成功,如果失败表示有其他线程在获取锁,那么就需要在释放锁之后将被挂起的线程唤醒。
  • 重量级锁:当有两个以上的线程获取锁的时候轻量级锁就会升级为重量级锁,因为 CAS 如果没有成功的话始终都在自旋,进行 while 循环操作,这是非常消耗 CPU 的,但是在升级为重量级锁之后,线程会被操作系统调度然后挂起,这可以节约 CPU 资源。

太复杂了上述的,简单说就是如下

  • 无锁:锁还没被任何线程争抢,对象的 MarkWord 里没有锁信息
  • 偏向锁:只有 1 个线程抢锁,锁偏心于这个线程,第一次抢锁记录线程 ID,后续该线程再抢锁,直接对比 ID 就能拿锁,不用耗性能的 CAS 或 挂起。
  • 轻量级锁:有 2 个线程抢锁,但竞争不激烈,线程通过 CAS 操作自旋抢锁,不用挂起,省 CPU。
  • 重量级锁:多个线程激烈抢锁,自旋耗 CPU 太狠,锁升级成重量级,没抢到锁的线程直接被操作系统挂起,等锁释放再唤醒,虽然慢但不耗 CPU。

Java 锁升级是自适应过程:无锁状态下,单线程抢锁会变成偏向锁(直接对比线程 ID);有少量线程竞争就升级轻量级锁(CAS 自旋);多线程激烈竞争时,升级重量级锁(线程挂起),核心是为了兼顾性能和资源消耗。

synchronized 锁静态方法和普通方法区别?

锁的对象不同:

  • 普通方法:锁的是当前对象的实例,同一对象实例的 synchronized 普通方法,同一时间只能被一个线程访问。不同实例之间就不互相影响了,可被不同线程同时访问各自的同步普通方法。
  • 静态方法:锁的是当前类的 Class 对象。由于类的 Class 对象全局唯一,无论多少个对象实例,该静态同步方法同一时间只能被一个线程访问。

作用范围不同

  • 普通方法:仅对同一对象实例的同步方法调用互斥,不同对象实例的同步普通方法可并行执行。
  • 静态方法:对整个类的所有实例的该静态方法调用都互斥,一个线程进入静态同步方法,其他线程无法进入同一类任何实例的该方法。

synchronized 支持重入吗?如何实现的?

synchronized是基于原子性的内部锁机制,是可重入的

因此在一个线程调用 synchronized 方法的同时,可以在其锁的方法体内部调用该对象的另一个 synchronized方法

这样,一个线程得到一个对象锁后再次请求该对象锁,是允许的,这就是 synchronized 的可重入性。

synchronized 底层是利用计算机系统mutex Lock实现的。每一个可重入锁都会关联一个线程ID和一个锁状态status

当一个线程请求方法时,会去检查锁状态。

  1. 如果锁状态是0,代表该锁没有被占用,使用CAS操作获取锁,将线程ID替换成自己的线程ID。
  2. 如果锁状态不是0,代表有线程在访问该方法。此时,如果线程ID是自己的线程ID,且是可重入锁,会将 status 自增1,然后获取到该锁,进而执行相应的方法;如果是非重入锁,就会进入阻塞队列等待。

在释放锁时,

  1. 如果是可重入锁的,每一次退出方法,就会将 status 减1,直至 status 的值为0,最后释放该锁。
  2. 如果非可重入锁的,线程退出方法,直接就会释放该锁

JUC锁机制

不同的线程之间如何通信?线程间通信方式有哪些?

线程间通信的核心目的是就是让多个线程协同工作**,解决线程间「数据共享」和「执行顺序」的问题。

不同的线程之间如何通信呢?

  • 共享变量是最基本的线程间通信方式。

    多个线程可以访问和修改同一个共享变量,从而实现信息的传递。

    为了保证线程安全,通常需要使用 synchronized 关键字或 volatile 关键字。

  • Object 类中的 wait()notify()notifyAll() 方法可以用于线程间的协作。

    wait() 方法使当前线程进入等待状态,notify() 方法唤醒在此对象监视器上等待的单个线程,notifyAll() 方法唤醒在此对象监视器上等待的所有线程,他们依赖对象锁,一般是生产和消费的协同问题使用这个

  • JUC 封装的通信方式

    • Lock + Condition替代Object 类中的 wait()notify()notifyAll() 方法

      Condition可以创建多个等待队列,能够精准唤醒一个线程,对应关系是await()替代 wait ()signal()替代 notify ()signalAll()替代 notifyAll ()

    • 阻塞队列

      BlockingQueue是 JUC 提供的阻塞队列,提供了线程安全的队列操作,内置了线程通信逻辑,无需手动写等待和唤醒的逻辑,是生产消费模型的首选。put()(队列满时阻塞)、take()(队列空时阻塞)。一般异步任务也很经常使用这个。

    • CountDownLatch倒计时门闩,Semaphore信号量,CyclicBarrier循环栅栏,等机制也可以做线程通信

Java中有哪些常用的锁,在什么场景下使用?

Java中的锁是用于管理多线程并发访问共享资源的关键机制。锁可以确保在任意给定时间内只有一个线程可以访问特定的资源,从而避免数据竞争和不一致性。Java提供了多种锁机制,可以分为以下几类:

  • 内置锁(synchronized):Java中的synchronized关键字是内置锁机制的基础,可以用于方法或代码块。当一个线程进入synchronized代码块或方法时,它会获取关联对象的锁;当线程离开该代码块或方法时,锁会被释放。如果其他线程尝试获取同一个对象的锁,它们将被阻塞,直到锁被释放。其中,syncronized加锁时有无锁、偏向锁、轻量级锁和重量级锁几个级别。偏向锁用于当一个线程进入同步块时,如果没有任何其他线程竞争,就会使用偏向锁,以减少锁的开销。轻量级锁使用线程栈上的数据结构,避免了操作系统级别的锁。重量级锁则涉及操作系统级的互斥锁。
  • 可重入锁 ReentrantLockjava.util.concurrent.locks.ReentrantLock是一个显式的锁类,提供了比synchronized更高级的功能,如可中断的锁等待、定时锁等待、公平锁选项等。ReentrantLock使用lock()unlock()方法来获取和释放锁。其中,公平锁按照线程请求锁的顺序来分配锁,保证了锁分配的公平性,但可能增加锁的等待时间。非公平锁不保证锁分配的顺序,可以减少锁的竞争,提高性能,但可能造成某些线程的饥饿。
  • 读写锁 ReadWriteLockjava.util.concurrent.locks.ReadWriteLock接口定义了一种锁,允许多个读取者同时访问共享资源,但只允许一个写入者。读写锁通常用于读取远多于写入的情况,以提高并发性。
  • 乐观锁和悲观锁:悲观锁(Pessimistic Locking)通常指在访问数据前就锁定资源,假设最坏的情况,即数据很可能被其他线程修改。synchronizedReentrantLock都是悲观锁的例子。乐观锁(Optimistic Locking)通常不锁定资源,而是在更新数据时检查数据是否已被其他线程修改。乐观锁常使用版本号或时间戳来实现。
  • 自旋锁:自旋锁是一种锁机制,线程在等待锁时会持续循环检查锁是否可用,而不是放弃CPU并阻塞。通常可以使用 CAS 来实现。这在锁等待时间很短的情况下可以提高性能,但过度自旋会浪费CPU资源。

synchronized 和 ReentrantLock 有什么相同点和区别?

对于相同的地方来说

  1. 两者都是可重入锁

    两者都是 Java 中解决多线程并发安全的核心锁机制,用于保证临界区代码的原子性、可见性、有序性,且均为可重入锁

    允许同一个线程多次获取同一把锁,不会因自己持有锁而造成死锁。比如一个线程获得了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的,进入方法A获取锁后还可以进入方法B继续获取锁,如果是不可重入锁的话,就会造成死锁。

    JDK 提供的所有现成的 Lock 实现类,包括 synchronized 关键字锁都是可重入的。

  2. 都能保证原子性,内存可见性,有序性,解决的核心问题一致

不同点也很明显

  1. synchronized是 Java 提供的关键字,依赖于 JVM, ReentrantLock 依赖于 API

    synchronized 是依赖于 JVM 实现的关键字,是 Java 虚拟机为 Java 语言在多线程环境上提供的一个机制

    ReentrantLock 是 JDK 层面实现的,也就是 API 层面,需要 lock()unlock() 方法配合 try/finally 语句块来完成

  2. synchronized会自动加锁和释放锁,ReentrantLock加锁释放锁分别需要显式使用 lock()unlock() 方法

  3. synchronized 可用来修饰普通方法、静态方法和代码块,而 ReentrantLock 只能用在代码块上。

  4. 两者底层实现几乎完全不一致

    JDK1.6 前,synchronized 是重量级锁,基于操作系统的互斥量实现,线程阻塞和唤醒需要切换内核态,性能损耗大;JDK1.6 后做了大量优化,底层通过对象头的 Mark Word 存储锁状态,结合 Monitor 监视器 实现

    对于,ReentrantLock,它基于AQS实现,AQS 是 JUC 包下所有锁和同步工具的基础,核心是同步状态 state 和 CLH 实现,状态 state 是用 volatile 修饰的 int 变量,记录锁的重入计数,CLH 队列是管理线程等待锁的,未获取到锁的线程会被封装为 Node 加入 CLH 尾部,通过 CAS 操作实现队列的入队和出队进一步实现对线程的阻塞和唤醒

  5. ReentrantLock 支持公平锁和非公平锁

    ReentrantLock可以指定是公平锁还是非公平锁。而synchronized只能是非公平锁。所谓的公平锁就是先等待的线程先获得锁。ReentrantLock默认情况是非公平的,可以通过 ReentrantLock类的ReentrantLock(boolean fair)构造方法来指定是否是公平的。

  6. ReentrantLock 支持超时,并且等待过程中可中断

    ReentrantLock 提供了 tryLock(timeout)这种带超时机制的方法,可以指定等待获取锁的最长等待时间,如果超过了等待时间,就会获取锁失败,不会一直等待。synchronized做不到,synchronized一旦开始等待就除非中断了

    而且ReentrantLock提供了一种能够中断等待锁的线程的机制,通过 lock.lockInterruptibly() 来实现这个机制。也就是说当前线程在等待获取锁的过程中,如果其他线程中断当前线程interrupt() ,当前线程就会抛出 InterruptedException 异常,可以捕捉该异常进行相应处理。

  7. ReentrantLock 通知机制更强大

    ReentrantLock 通过绑定多个 Condition 对象,可以实现分组唤醒和选择性通知。

    这解决了 synchronized只能使用Object.wait/notify/notifyAll来进行线程协作的弊端,包括wait()方法只能释放当前线程的锁并等待,和要么只能使用notify随机唤醒要么就notifyAll全部唤醒的问题,为复杂的线程协作场景提供了强大的支持。

线程持有读锁还能获取写锁吗?为什么不支持锁升级?

JDK 原生的 ReentrantReadWriteLock 明确不支持锁升级,这是为了避免死锁和保证并发安全性。

Java 多线程中,认为写锁是重量级锁,读锁是轻量级锁

当线程A持有读锁时,如果允许它获取写锁,就会出现问题:假设此时线程B也持有读锁,如果A等待B释放读锁来获取写锁,而B也想升级为写锁,就形成了典型的死锁场景。更重要的是,读锁的语义是允许多个线程并发读取,如果某个读线程突然升级为写锁,会破坏其他正在读取的线程的并发安全性。

ReentrantReadWriteLock的设计理念就遵循写锁具有排他性,必须在没有任何读写操作时才能获取写锁,如果支持锁升级,就需要复杂的协调机制来处理多个读线程同时升级的竞争问题,而这在开发阶段完全可以被避免。

不过,写锁可以降级为读锁,写锁本来就不支持共享,同一时间只能被一个线程持有,读锁允许多个线程同时持有,降级是不会引发问题的。

如果需要锁升级这种调整,考虑使用StampedLock,它提供了更灵活的乐观读锁和一些转换机制。

简单说一下可中断锁和不可中断锁有什么区别?

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

  • 不可中断锁

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

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

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

    • ReentrantLock.lockInterruptibly() 实现了可中断锁。
    • ReentrantLock.tryLock(long time, TimeUnit unit) (带超时的尝试获取锁)也是可中断的

ReentrantReadWriteLock 的锁是可重入吗?它的锁的实现情况如何?

ReentrantReadWriteLock 的写锁(WriteLock)是可重入的互斥锁

  • 同一线程获取写锁后,可再次获取写锁(重入),无需释放;
  • 写锁是互斥的:其他线程在当前线程释放写锁前,无法获取写锁 / 读锁。

ReentrantReadWriteLock 的读锁是悲观读锁,而且是可重入的共享锁

  • 共享锁:无写锁占用时,多个线程可同时获取读锁
  • 可重入:同一线程可多次获取读锁;
  • 悲观锁:悲观读写互斥:写锁占用时候,读锁阻塞,读锁占用时,写锁阻塞

虽然ReentrantReadWriteLock 本身是悲观读写锁,读读共享、写写 / 读写互斥,,但它提供了一个 tryLock() 方法,是非阻塞获取读锁,基于这个方法可以实现读锁的 “乐观读” 逻辑,写锁没有乐观锁啊

  • 先假设没有写操作干扰,尝试非阻塞获取读锁,直接读取数据,读完后再检查是否有写锁被占用,如果没有则读取有效,如果有则降级为 悲观读

线程池

当核心线程数已满时提交任务会发生什么?讲一下这时候线程池的执行策略?

核心逻辑在ThreadPoolExecutor.execute(Runnable command)方法中,可以这样描述

Java 线程池的任务执行遵循固定优先级流程,核心线程数corePoolSize未满,是触发后续流程的第一节点,会按以下步骤执行

  1. 如果任务队列未存满 → 任务入队等待,不创建临时线程

    这是最常见的场景,核心线程满后,线程池会优先将任务放入workQueue,等待核心线程执行完当前任务后,从队列中取任务继续执行。

  2. 如果任务队列已存满 → 创建临时线程执行任务

    若任务队列也满了,且当前线程总数未达maximumPoolSize,线程池会创建临时线程来执行新提交的任务

    临时线程执行完任务后,若空闲时间超过keepAliveTime,会被销毁,释放资源;

  3. 任务队列已满 + 线程总数达最大数 → 触发拒绝策略

    JDK 默认提供 4 种拒绝策略:

    • AbortPolicy:默认,直接抛出RejectedExecutionException,终止任务提交
    • CallerRunsPolicy:让提交任务的线程直接执行任务,例如主线程
    • DiscardPolicy:静默丢弃任务,无异常,无返回
    • DiscardOldestPolicy:丢弃队列中最旧的任务,将新任务入队

别忘了,若使用LinkedBlockingQueue这种无界队列,任务队列永远不会满,因此核心线程满后,所有任务都会入队,永远不会创建临时线程,也不会触发拒绝策略,很容易就OOM了

什么是线程池?使用线程池的优势?ThreadPoolExecutor核心参数?

线程池是 JUC 提供的一种线程管理机制,利用了池化思想,通过预创建并复用固定数量的线程来执行提交的任务,避免因为频繁创建,销毁线程带来的系统开销,所以说使用线程池能够降低系统资源消耗

更重要的是线程池本质上是一个线程资源管理器,它解决的核心问题是线程的生命周期管理问题,线程池让线程变成了一种可复用的计算资源

同时能够提高响应速度,任务到达时无需等待线程创建即可执行;还能有效控制并发数量,防止系统因线程过多而崩溃。

ThreadPoolExecutor的核心参数包括

  • corePoolSize:定义核心线程数量,这些线程会一直存活;

  • maximumPoolSize:设置最大线程数上限;

  • keepAliveTime:控制非核心线程的空闲存活时间;

  • workQueue:是任务等待队列,当核心线程忙碌时新任务会进入队列等待;

  • threadFactory是线程工厂,负责创建新线程;

  • rejectedExecutionHandler处理队列满且达到最大线程数时的拒绝策略。

    拒绝策略是线程池的安全保护机制

    四种策略分别对应不同的业务场景:AbortPolicy 直接抛异常适合关键业务;CallerRunsPolicy 让调用线程执行,实现了一种背压机制;DiscardPolicy 静默丢弃适合可容忍丢失的场景;DiscardOldestPolicy 丢弃最老任务适合时效性要求高的场景。

线程池的执行流程是:任务提交时优先使用核心线程,核心线程满了就放入队列自选等待,队列满了再创建非核心线程直到最大线程数,最后触发拒绝策略。在Web服务器处理HTTP请求、批量数据处理等场景中,线程池能显著提升系统性能和稳定性。

要注意不同业务场景需要差异化的线程池配置策略,特别是CPU密集型任务和IO密集型任务分别需要不同的线程池配置

image-20260304201105184

为什么不直接用Executors工具类创建线程池

Executors提供的线程池虽然使用方便,但参数都是预设的,无法根据业务特点调优,而且及其容易导致各种问题,主要问题就是如下四种

image-20260304201205592

submit()execute()方法的区别?异常处理的差异?

submit()和execute()是JUC线程池中两个核心方法,主要区别体现在返回值和异常处理机制上。

execute()方法来自Executor接口,只能提交Runnable任务,没有返回值。当任务执行过程中抛出异常时,异常会直接打印到控制台,无法在提交任务的线程中捕获这些异常。这种方式适合”发射后不管”的场景,比如日志异步写入、消息推送等不需要获取执行结果的操作。

submit()方法来自 ExecutorService 接口,既可以提交 Runnable 也可以提交 Callable 任务,返回 Future 对象,实现了在需要的时候通过 Future 对象再取结果。通过 Future 你可以获取任务执行结果、检查任务状态,更重要的是可以捕获任务执行过程中的异常。当任务抛出异常时,异常会被包装在 Future 中,只有当你调用 Future.get() 方法时才会抛出异常

异常处理是两者最关键的差异execute()的异常会”丢失”在线程池中,而submit()的异常可以通过Future.get()在主线程中捕获和处理。在实际开发中,如果你需要知道任务是否执行成功或需要处理任务异常,应该使用submit();如果只是简单的异步执行且不关心结果,execute()更直接高效。

Runnable和Callable接口的区别?Future和FutureTask的作用?

Runnable和Callable是任务接口,Future是结果容器,FutureTask是连接两者的桥梁

Runnable和Callable的核心区别在于返回值和异常处理能力。Runnable 的run()方法返回 void,无法获取执行结果,也不能声明抛出受检异常;而 Callable 的 call() 方法可以返回泛型结果,并且允许抛出异常。当你需要从线程执行中获取计算结果时,比如批量数据处理后返回统计信息,就必须使用Callable

Future是异步计算结果的容器接口,提供了 get() 方法阻塞获取结果、cancel() 取消任务、isDone()检查完成状态等功能。它解决了主线程如何获取其他线程执行结果的问题。当你submit一个Callable到线程池时,会立即返回一个Future对象,你可以继续执行其他逻辑,稍后通过Future.get()获取结果。

FutureTask 是 Future 接口的具体实现类,同时实现了 Runnable 接口,这种双重身份让它既可以直接传给 Thread 构造器执行,或者丢到线程池里作为任务的包装和结果的容器二合一,也可以作为 Future 使用。FutureTask 内部维护了任务状态(NEW、RUNNING、COMPLETED等),并通过 CAS 操作保证线程安全。在实际开发中,当你需要手动管理异步任务而不使用线程池时,FutureTask 就很有用,比如在某些框架的异步回调场景中包装 Callable 任务。

ThreadLocal

对 ThreadLocal 熟悉吗?Threadlocal作用,原理,具体里面存的 key value 是啥,会有什么问题,如何解决?

ThreadLocal是Java中用于解决线程安全问题的一种机制,它为每个线程创建自己独立的变量副本,让线程只操作自己的副本,从而避免了线程间的资源共享和同步问题。

ThreadLocal 是空间换时间,给每个线程配副本,无锁;

synchronized 是时间换空间,加锁互斥;前者适合变量独享,后者适合变量共享。

ThreadLocal的整体结构是这样的:

Thread 线程对象 → 持有 ThreadLocalMap 对象 → ThreadLocalMapThreadLocal 的静态内部类,存储线程本地变量 → ThreadLocalMapKey 是 ThreadLocal 实例(弱引用),Value 是变量副本(强引用)

1
2
3
4
5
6
7
8
9
10
11
12
13
// 1. 创建ThreadLocal实例
private static ThreadLocal<String> userToken = new ThreadLocal<>();

public void doRequest() {
// 2. 当前线程存入变量副本(只属于当前线程)
userToken.set("user-123-token");

// 3. 当前线程获取自己的副本(其他线程拿不到)
String token = userToken.get();

// 4. 用完清理(避免内存泄漏)
userToken.remove();
}
  • set():当前线程拿到自己的 ThreadLocalMap以 ThreadLocal 实例为 Key,变量为 Value 存入
  • get():当前线程从自己的 ThreadLocalMap 中,根据 ThreadLocal 实例取 Value;
  • remove():当前线程从 ThreadLocalMap 中删除该 Key-Value。

为什么用 Key 是弱引用?

  • 若 Key 是强引用,即使 ThreadLocal 实例被置 null,ThreadLocalMap 仍持有强引用,ThreadLocal 无法被 GC,导致内存泄漏;弱引用让每次 GC 时都会自动回收 Key

  • 而 Value 是强引用,这是 ThreadLocal 内存泄漏的核心风险点。

    上面提到了 ThreadLocal 实例被置 null,那么作为 Key 的 ThreadLocal 实例是弱引用,会被 GC 回收。而因为 Value 是强引用,永不GC,ThreadLocalMap 中出现了 Key 为 null,Value ≠ null 的脏 Key,大量堆积就容易出现 OOM

而且线程池的线程是复用的,若前一个线程用完 ThreadLocalremove(),后一个线程复用该线程时,会读到前一个线程的变量副本。这就是ThreadLocal线程池场景下的数据污染

这也就是为什么,使用完 ThreadLocal 后,必须调用 remove() 清理,而且如果可以,让 ThreadLocal 声明为 static,减少实例创建,降低泄漏概率

ThreadLocal 典型场景:

  1. 存储用户上下文(登录 token、用户 ID):比如你项目中的用户登录态,每个请求线程独立存储,不用层层传递参数;
  2. 存储数据库连接 / 事务:避免多线程共享 Connection 导致的事务混乱;

而且,主线程设置的 ThreadLocal 变量,子线程默认拿不到,所以有时候用 InheritableThreadLocal 替代 ThreadLocal,它会让子线程继承父线程的 ThreadLocal 变量副本。(原理是子线程创建时,复制父线程的 ThreadLocalMap 到子线程。)

讲讲ThreadLocal的使用。你在项目开发中什么地方用到了 ThreadLocal?对ThreadLocal的实践你有什么理解

ThreadLocal 是 Java 提供的线程本地变量,作用是让每个线程拥有自己独立的一份变量副本,线程之间互不干扰、不共享,从而避免线程安全问题,也能在同一个线程内安全传递数据,不用层层传参。

这是一种无锁的线程安全策略,ThreadLocal 并不是解决多线程对一个共享资源的安全访问,而是解决 线程内传参、线程隔离 的,而且,用完一定要 remove(),因为 Tomcat 线程和线程池都是复用的

我在 xx平台 中,多处使用 ThreadLocal

  1. 项目使用 Spring Security + JWT 做登录认证,每次请求进来,我在拦截器 / 过滤器里解析 Token,拿到 当前登录用户 ID、用户信息、角色权限,然后重写 SecurityContextHolder,存入对应的信息到 ThreadLocal

    这样整个请求链路在任何地方都能直接获取当前用户,代码更干净,而且因为一次请求就是一个独立线程,绝对安全,不会串用户

  2. 存储请求上下文

    比如请求ID,客户端的IP,接口执行时间,入参,日志追踪ID等内容,存入 ThreadLocal 后,就方便做全链路监控,而且 AOP 记录操作日志时,直接从 ThreadLocal 拿当前用户,不用修改方法参数,不会串

注意,ThreadLocal 不支持父子线程共享,如果用 @Async 异步、线程池,父线程的 ThreadLocal 传不过去。

JMM

什么是内存屏障(Memory Barrier)?在JMM中的作用?

综合场景题

能说一下你在做项目的时候用到的框架或者中间件中涉及到多线程的相关内容吗

例如,在我负责的核心项目中,结合 Spring 生态的多线程能力做了实际落地,围绕异步处理、定时任务两大场景使用,既解决了业务的性能问题,也提升了用户体验,同时也用到了 Java 多线程的基础特性做上下文保障

  1. 首先是使用了 Spring 框架中的 @Async 注解实现业务解耦与接口性能优化

    项目里有很多非核心、耗时高的同步操作,例如操作日志导出,AI报告的生成和分析,AI RAG回复的一些内容,如果这些业务和主业务同步执行,比如用户提交 AI 分析请求、操作日志记录,会导致接口响应时间变长,用户等待感强。

    我基于 Spring 的@Async注解做了异步化改造,先在启动类加@EnableAsync开启多线程能力,再自定义线程池 ThreadPoolTaskExecutor,避免默认线程池的 OOM 风险,然后给报表生成、报告导出的业务方法加@Async,让这些耗时操作提交到独立线程池执行,主业务接口直接返回 “处理中” 的状态,后续通过 SSE / 站内信推送处理结果。

    这样做的核心价值是把主业务和耗时异步业务解耦,核心接口的响应时间降低了很多,也避免了单线程执行导致的资源阻塞。

  2. Spring Task 基于多线程实现定时任务调度

    我的两个项目都有批量的定时业务需求,比如项目管理平台需要定时清理过期的验证码、临时缓存数据,小程序需要定时对用户历史输入的情绪标签做加权聚合、生成情绪趋势报告,我用了 Spri。ng Task 的@Scheduled注解(配合@EnableScheduling)实现,底层其实是 Spring 的定时任务线程池做多线程调度。

    针对不同定时任务的执行频率、耗时差异,我做了任务隔离:把高频轻量任务和低频重量级人物分到两个不同的线程池在不同的时间执行,避免一个耗时任务阻塞其他所有定时任务,保证了定时业务的执行稳定性。

  3. ThreadLocal 实现用户上下文的多线程传递

    这个是配合多线程场景做的基础保障,在项目平台中,用户认证后会生成包含用户 ID、角色、租户信息的上下文,而异步任务(比如报表生成)、拦截器中的权限校验,都需要获取当前用户的上下文信息。

    因为多线程中 ThreadLocal 的线程隔离特性,在 Spring Security 的实践中一般可以重写一下 SecurityContextHolder 来定制安全上下文,用 ThreadLocal 存储用户上下文,在登录拦截器中初始化上下文,在异步任务、业务方法中直接获取,既保证了多线程环境下用户信息的安全传递,又避免了通过方法参数层层传递上下文的冗余代码,同时在任务执行完成后手动移除 ThreadLocal 中的数据,避免了内存泄漏。

介绍一下为什么 Spring 中使用 ThreadPoolTaskExecutor 来替代 ThreadPoolExecutor

Spring 框架中的ThreadPoolTaskExecutor 的目的不是为了替代 ThreadPoolExecutor,而是 Spring 对 JDK 原生ThreadPoolExecutor封装和适配,Spring 中底层依然是用ThreadPoolExecutor实现线程池的核心逻辑,这类似适配器的设计模式

ThreadPoolTaskExecutor内部持有一个ThreadPoolExecutor实例,所有线程池的核心执行逻辑最终还是交给ThreadPoolExecutor完成;而ThreadPoolTaskExecutor做了两件关键的 Spring 化改造,实现了 Spring 的TaskExecutor接口,可以和 Spring 的@Async@Scheduled等注解无缝集成,而且支持 Spring 的Bean生命周期管理,可以通过 Spring 配置文件或者注解轻松配置,无需手动编码创建ThreadPoolExecutor

只要你在 Spring 中使用线程池,比如@Async、定时任务、手动创建线程池,无论是否显式声明,底层最终都会创建ThreadPoolExecutor实例来执行任务

怎么在实践中用锁的?讲一下你在项目中涉及到多线程操作的时候对锁的实践。

在项目中主要用锁解决三类问题

  1. 共享资源的并发安全
  2. 接口幂等性,防止重复提交
  3. 异步任务、定时任务、线程池的并发控制

例如,我在用户认证模块,对登录请求、验证码发送做了并发控制。本地用 synchronized 控制单机重复请求,避免重复缓存写入。既保证安全,又避免缓存击穿、重复插入。

当多人同时编辑科研项目、文档、任务的时候,就可以使用 ReentrantReadWriteLock,因为文档可以认为是读多写少的资源,用 读写锁 分离读和写,读锁共享,多人同时查看不影响,写锁互斥,修改时阻塞所有读,保证数据一致性

然后使用@Async实现了异步任务,当项目有用户行为日志导出,wiki文档导出等报表导出行为的时候,用@Async异步化改造化导出方法,在启动类加@EnableAsync开启多线程能力,再自定义线程池 ThreadPoolTaskExecutor,让这些耗时操作提交到独立线程池执行,主业务接口直接返回 “处理中” 的状态,后续通知处理结果。

而且类似用户点赞、取消点赞这种极易高并发且重复的场景,可以使用 Redis 分布式锁 控制同一用户同一内容只能一次操作,保证点赞、取消点赞的幂等性,避免计数错乱。

简单业务用 synchronized

读多写少用 读写锁 ReentrantReadWriteLock

高并发、分布式用 Redis 分布式锁

异步任务、定时任务用 ReentrantLock 保证不重复执行。