CrabNote螃蟹笔记

Java并发编程面试题

本文《Java并发编程面试题》由小宇宙从原站整理搬运,详细讲解相关技术要点。

大家好,我是小宇宙。 Java 并发编程是面试里的重头戏,尤其是中高级岗位,这块几乎是必考的。很多人平时工作里用的多线程场景不多,或者用了但没深究原理,到了面试就容易被问住。这块知识点比较分散,从底层的内存模型到上层的线程池配置,跨度很大,但只要理清楚主线,其实是很有逻辑的。 这篇文章整理了 Java 并发面试中最常被问到的知识点,涵盖多线程基础、并发安全机制、锁原理、线程池,以及一些常见的并发场景题。内容偏向原理理解,不是让你背 API,而是帮你搞清楚"这东西为什么这么设计"。 有几块是面试里的高频区域,建议重点投入: synchronized 和 ReentrantLock :两者的区别、锁升级的过程(偏向锁 → 轻量级锁 → 重量级锁)、以及 AQS 是什么、怎么实现的,这条线是并发面试的核心主线。 volatile 和 CAS :volatile 解决了什么问题、为什么不能保证原子性、CAS 的原理和 ABA 问题,这些问题入门好答,但深挖起来很多人答不全。 ThreadLocal :用途、底层 ThreadLocalMap 的实现、为什么会内存泄漏、怎么避免,这个在面试里出现频率越来越高。 线程池 :核心参数的含义、任务提交后的执行流程、几种拒绝策略的区别、以及常见的线程池类型,这块是工程实践和面试的双重重点。 死锁 :死锁的四个必要条件、怎么排查、怎么避免,这是个老题,但每次还是会考。 如果你是第一次系统准备这块,建议先搞清楚 JMM 和 synchronized 的原理,再去看 AQS 和线程池,这样整体脉络会更清晰,后面的内容理解起来也会快很多。 多线程 Java 的内存模型(JMM)介绍一下 JMM 是专门解决多线程并发问题的一套规则。简单说,就是规定了多线程环境下,线程怎么访问共享变量才能不出错,核心是处理可见性、原子性、有序性这三个问题。 先想个场景:两个线程同时操作一个变量,比如线程 A 改了变量的值,线程 B 能不能立刻看到? 如果线程 B 看到的还是旧值,这就是可见性问题。为啥会这样?因为现在 CPU 都有缓存,线程操作变量时,会先把主内存里的变量读到自己的工作内存(比如 CPU 缓存)里,改完可能没及时写回主内存,另一个线程读的还是主内存的旧值。JMM 里像 volatile 关键字就专门解决这个,用了 volatile 的变量,改完会立刻刷回主内存,同时让其他线程的缓存失效,必须重新从主内存读,这样就保证了可见性。 再说说原子性,比如 i++,看着简单,其实是 “读 i、加 1、写回 i” 三步。如果两个线程同时做,可能线程 A 刚读完 i=10,线程 B 就把 i 改成 11,线程 A 再加 1 写回去还是 11,结果就错了。JMM 里用 synchronized 或者 Lock 锁就能保证原子性,加了锁之后,同一时间只有一个线程能执行这三步,中间不会被打断。 还有有序性,就是代码执行顺序可能和你写的不一样。编译器或者 CPU 为了提速,会在不影响单线程结果的情况下调整指令顺序。比如你先初始化对象 A,再把 A 的引用给变量 a,可能被重排成先给引用再初始化。单线程没问题,但多线程下,另一个线程拿到 a 的引用时,A 可能还没初始化好,一调用就报错。这时候 volatile 或者 synchronized 就能通过内存屏障阻止这种重排序,保证顺序正确。 所以 JMM 的核心思路是:定义主内存(大家共享的内存)和工作内存(每个线程自己的缓存),规定变量必须从主内存加载到工作内存才能操作,改完再写回主内存。 然后通过 volatile、synchronized 这些关键字,控制加载、写回的时机,以及禁止不合理的指令重排,最终保证多线程操作共享变量时能正确交互。理解这些,就能明白为啥写并发代码时要用这些关键字,以及它们背后的原理了。 java多线程是什么?需要注意什么? Java 多线程是指在一个 Java 程序中同时运行多个线程,这些线程共享程序的内存空间(如全局变量、方法区等),但有各自的栈和程序计数器,能同时执行不同的任务,比如一个线程处理用户输入,另一个线程后台下载文件,提升程序效率。 使用 Java 多线程需要注意以下几点: 首先是线程安全问题。多个线程同时操作共享数据时,可能出现错误。比如两个线程同时给一个变量加 1,原本该加 2,结果可能只加了 1,这是因为线程切换时没做好数据保护。需要用 关键字、 锁等方式,保证同一时间只有一个线程操作共享数据。 其次是线程间通信。线程需要协作时,比如一个线程生产数据,另一个线程消费数据,要通过 、 等方法控制,避免出现一方没准备好,另一方就操作的情况,否则可能导致数据错误或线程无限等待。 然后是线程的创建和销毁成本。频繁创建和销毁线程会消耗系统资源,影响性能。可以用线程池管理线程,提前创建好一定数量的线程,重复使用,减少资源消耗。 java里面的线程和操作系统的线程一样吗? 要分两种情况回答: 平台线程(Platform Thread) 和 虚拟线程(Virtual Thread) 。 平台线程 :这是 Java 长期以来默认的线程实现。JVM 在 Linux 上通过 创建线程,一个 Java 平台线程对应一个操作系统线程,是严格的 1:1 线程模型 ,线程的调度、上下文切换都由操作系统内核负责,开销较大,一个线程默认栈空间约 1MB,所以不能无限创建。 虚拟线程 :Java 21 正式发布(JEP 444),是 JVM 在用户态自行调度的轻量级线程,采用 M:N 模型 ——大量虚拟线程会被映射到少量载体平台线程(Carrier Thread)上执行。虚拟线程由 JVM 的 调度,创建成本极低(几百字节起步,可动态扩缩),单 JVM 可以轻松跑上百万个虚拟线程,特别适合 IO 密集型的高并发场景。 所以"Java 线程等于操作系统线程"这种说法只适用于 JDK 21 之前或只使用平台线程的场景;用了虚拟线程后,Java 的并发模型已经很接近 Go 协程的 M:N 调度了。 使用多线程要注意哪些问题? 要保证多线程的程序是安全,不要出现数据竞争造成的数据混乱的问题。 Java的线程安全在三个方面体现: 原子性 :提供互斥访问,同一时刻只能有一个线程对数据进行操作,在Java中使用了atomic包(这个包提供了一些支持原子操作的类,这些类可以在多线程环境下保证操作的原子性)和synchronized关键字来确保原子性; 可见性 :一个线程对主内存的修改可以及时地被其他线程看到,在Java中使用了synchronized和volatile这两个关键字确保可见性; 有序性 :一个线程观察其他线程中的指令执行顺序,由于指令重排序,该观察结果一般杂乱无序,在Java中使用了happens before原则来确保有序性。 保证数据的一致性有哪些方案呢? 事务管理 :使用数据库事务来确保一组数据库操作要么全部成功提交,要么全部失败回滚。通过ACID(原子性、一致性、隔离性、持久性)属性,数据库事务可以保证数据的一致性。 锁机制 :使用锁来实现对共享资源的互斥访问。在 Java 中,可以使用 synchronized 关键字、ReentrantLock 或其他锁机制来控制并发访问,从而避免并发操作导致数据不一致。 版本控制 :通过乐观锁的方式,在更新数据时记录数据的版本信息,从而避免同时对同一数据进行修改,进而保证数据的一致性。 线程的创建方式有哪些? 1.继承Thread类 这是最直接的一种方式,用户自定义类继承java.lang.Thread类,重写其run()方法,run()方法中定义了线程执行的具体任务。创建该类的实例后,通过调用start()方法启动线程。 采用继承Thread类方式 优点: 编写简单,如果需要访问当前线程,无需使用Thread.currentThread ()方法,直接使用this,即可获得当前线程 缺点:因为线程类已经继承了Thread类,所以不能再继承其他的父类 2.实现Runnable接口 如果一个类已经继承了其他类,就不能再继承Thread类,此时可以实现java.lang.Runnable接口。实现Runnable接口需要重写run()方法,然后将此Runnable对象作为参数传递给Thread类的构造器,创建Thread对象后调用其start()方法启动线程。 采用实现Runnable接口方式: 优点:线程类只是实现了Runable接口,还可以继承其他的类。在这种方式下,可以多个线程共享同一个目标对象,所以非常适合多个相同线程来处理同一份资源的情况,从而可以将CPU代码和数据分开,形成清晰的模型,较好地体现了面向对象的思想。 缺点:编程稍微复杂,如果需要访问当前线程,必须使用Thread.currentThread()方法。 3. 实现Callable接口与FutureTask java.util.concurrent.Callable接口类似于Runnable,但Callable的call()方法可以有返回值并且可以抛出异常。要执行Callable任务,需将它包装进一个FutureTask,因为Thread类的构造器只接受Runnable参数,而FutureTask实现了Runnable接口。 采用实现Callable接口方式: 缺点:编程稍微复杂,如果需要访问当前线程,必须调用Thread.currentThread()方法。 优点:线程类只实现 Callable 接口,仍然可以继承其他类;且 方法可以有返回值,也可以抛出异常,配合 能方便地获取异步执行结果。 4. 使用线程池(Executor框架) 从Java 5开始引入的java.util.concurrent.ExecutorService和相关类提供了线程池的支持,这是一种更高效的线程管理方式,避免了频繁创建和销毁线程的开销。可以通过Executors类的静态方法创建不同类型的线程池。 采用线程池方式: 缺点:程池增加了程序的复杂度,特别是当涉及线程池参数调整和故障排查时。错误的配置可能导致死锁、资源耗尽等问题,这些问题的诊断和修复可能较为复杂。 优点:线程池可以重用预先创建的线程,避免了线程创建和销毁的开销,显著提高了程序的性能。对于需要快速响应的并发请求,线程池可以迅速提供线程来处理任务,减少等待时间。并且,线程池能够有效控制运行的线程数量,防止因创建过多线程导致的系统资源耗尽(如内存溢出)。通过合理配置线程池大小,可以最大化CPU利用率和系统吞吐量。 怎么启动线程 ? 启动线程的通过Thread类的 start() 。 如何停止一个线程的运行? 主要有这些方法: 异常法停止 :线程调用 方法后,在线程的 run 方法中通过 判断当前线程的中断状态(注意不要用静态方法 ,它会清除中断标志),如果是中断状态则抛出异常,达到中断线程的效果。 在沉睡中停止 :先将线程sleep,然后调用interrupt标记中断状态,interrupt会将阻塞状态的线程中断。会抛出中断异常,达到停止线程的效果 stop()暴力停止 :线程调用stop()方法会被暴力停止,方法已弃用,该方法会有不好的后果:强制让线程停止有可能使一些请理性的工作得不到完成。 使用return停止线程 :调用interrupt标记为中断状态后,在run方法中判断当前线程状态,如果为中断状态则return,能达到停止线程的效果。 调用 interrupt 是如何让线程抛出异常的? 每个线程都一个与之关联的布尔属性来表示其中断状态,中断状态的初始值为false,当一个线程被其它线程调用 方法中断时,会根据实际情况做出响应。 如果该线程正在执行低级别的可中断方法(如 、 或 ),则会解除阻塞并 抛出 异常 。 否则 仅设置线程的中断状态,在该被中断的线程中稍后可通过轮询中断状态来决定是否要停止当前正在执行的任务。 Java线程的状态有哪些? 源自《Java并发编程艺术》 java.lang.Thread.State枚举类中定义了六种线程的状态,可以调用线程Thread中的getState()方法 获取当前线程的状态 。 | 线程状态 | 解释 | | | | | NEW | 尚未启动的线程状态,即线程创建, 还未调用start方法 | | RUNNABLE | 就绪状态 (调用start,等待调度)+ 正在运行 | | BLOCKED | 等待监视器锁 时,陷入阻塞状态 | | WAITING | 等待状态的线程正在 等待 另一线程执行特定的操作(如notify) | | TIMED\ WAITING | 具有 指定等待时间 的等待状态 | | TERMINATED | 线程完成执行, 终止状态 | sleep 和 wait的区别是什么? 对比例表: | 特性 | | | | | | | | 所属类 | 类(静态方法) | 类(实例方法) | | 锁释放 | ❌ | ✅ | | 使用前提 | 任意位置调用 | 必须在同步块内(持有锁) | | 唤醒机制 | 超时自动恢复 | 需 / 或超时 | | 设计用途 | 暂停线程执行,不涉及锁协作 | 线程间协调,释放锁让其他线程工作 | 所属分类的不同 :sleep 是 类的静态方法,可以在任何地方直接通过 调用,无需依赖对象实例。wait 是 类的实例方法,这意味着必须通过对象实例来调用。 锁释放的情况 : 在调用时,线程会暂停执行指定的时间,但不会释放持有的对象锁。也就是说,在 期间,其他线程无法获得该线程持有的锁。 :调用该方法时,线程会释放持有的对象锁,进入等待状态,直到其他线程调用相同对象的 或 方法唤醒它 使用条件 :sleep 可在任意位置调用,无需事先获取锁。 wait 必须在同步块或同步方法内调用(即线程需持有该对象的锁),否则抛出 。 唤醒机制 :sleep 休眠时间结束后,线程 自动恢复 到就绪状态,等待CPU调度。wait 需要其他线程调用相同对象的 或 方法才能被唤醒。 会唤醒一个在该对象上等待的线程(具体策略由 JVM 实现决定,HotSpot 实际按 FIFO 顺序唤醒),而 会唤醒所有在该对象上等待的线程。 sleep会释放cpu吗? 是的,调用 时,线程会释放 CPU,但不会释放持有的锁。 当线程调用 后,会主动让出 CPU 时间片 ,进入 状态。此时操作系统会触发调度,将 CPU 分配给其他处于就绪状态的线程。这样其他线程(无论是需要同一锁的线程还是不相关线程)便有机会执行。 不会释放线程已持有的任何锁(如 同步代码块或方法中获取的锁)。因此,如果有其他线程试图获取同一把锁,它们仍会被阻塞,直到原线程退出同步代码块。 blocked和waiting有啥区别 区别如下: 触发条件 :线程进入BLOCKED状态通常是因为试图获取一个对象的锁(monitor lock),但该锁已经被另一个线程持有。这通常发生在尝试进入synchronized块或方法时,如果锁已被占用,则线程将被阻塞直到锁可用。线程进入WAITING状态是因为它正在等待另一个线程执行某些操作,例如调用Object.wait()方法、Thread.join()方法或LockSupport.park()方法。在这种状态下,线程将不会消耗CPU资源,并且不会参与锁的竞争。 唤醒机制 :当一个线程被阻塞等待锁时,一旦锁被释放,线程将有机会重新尝试获取锁。如果锁此时未被其他线程获取,那么线程可以从BLOCKED状态变为RUNNABLE状态。线程在WAITING状态中需要被显式唤醒。例如,如果线程调用了Object.wait(),那么它必须等待另一个线程调用同一对象上的Object.notify()或Object.notifyAll()方法才能被唤醒。 所以,BLOCKED和WAITING两个状态最大的区别有两个: BLOCKED 是锁竞争失败后被动触发的状态,WAITING 是人为主动触发的状态 BLOCKED 的唤醒是自动触发的,而 WAITING 状态必须要通过特定的方法来主动唤醒 wait 状态下的线程如何恢复到 RUNNABLE 状态? 线程从 状态恢复到 状态的核心机制是 通过外部事件触发或资源可用性变化 ,比如等待的线程 被其他线程对象唤醒 , 和 。(注意:Java 枚举中没有 RUNNING 状态,只有 RUNNABLE,包含 OS 层面的 ready 和 running 两个子状态。) notify 和 notifyAll 的区别? 同样是唤醒等待的线程,同样最多只有一个线程能获得锁,同样不能控制哪个线程获得锁。 区别在于: notify:唤醒一个线程,其他线程依然处于wait的等待唤醒状态,如果被唤醒的线程结束时没调用notify,其他线程就永远没人去唤醒,只能等待超时,或者被中断 notifyAll:所有线程退出wait的状态,开始竞争锁,但只有一个线程能抢到,这个线程执行完后,其他线程又会有一个幸运儿脱颖而出得到锁 notify 选择哪个线程? notify在源码的注释中说到notify选择唤醒的线程是任意的,但是依赖于具体实现的jvm。 JVM 有很多实现,比较流行的就是 HotSpot,HotSpot 对 的实现并不是我们以为的随机唤醒,而是"先进先出"的顺序唤醒。 不同的线程之间如何通信? 共享变量是最基本的线程间通信方式。多个线程可以访问和修改同一个共享变量,从而实现信息的传递。为了保证线程安全,通常需要使用 关键字或 关键字。 代码解释 关键字确保了 变量在多个线程之间的可见性,即一个线程修改了 的值,其他线程能立即看到。 生产者线程在睡眠 2 秒后将 设置为 ,消费者线程在 为 时一直等待,直到 变为 才继续执行。 类中的 、 和 方法可以用于线程间的协作。 方法使当前线程进入等待状态, 方法唤醒在此对象监视器上等待的单个线程, 方法唤醒在此对象监视器上等待的所有线程。 代码解释: 是一个用于同步的对象,生产者和消费者线程都需要获取该对象的锁才能执行相应的操作。 消费者线程调用 方法进入等待状态,释放锁;生产者线程执行完生产任务后调用 方法唤醒等待的消费者线程。 包中的 和 接口提供了比 更灵活的线程间通信方式。 接口的 方法类似于 方法, 方法类似于 方法, 方法类似于 方法。 代码解释: 是 接口的一个实现类, 是通过 方法创建的。 消费者线程调用 方法进入等待状态,生产者线程执行完生产任务后调用 方法唤醒等待的消费者线程。 包中的 接口提供了线程安全的队列操作,当队列满时,插入元素的线程会被阻塞;当队列为空时,获取元素的线程会被阻塞。 代码解释: 是 接口的一个实现类,容量为 1。 生产者线程调用 方法将元素插入队列,如果队列已满,线程会被阻塞;消费者线程调用 方法从队列中取出元素,如果队列为空,线程会被阻塞。 线程间通信方式有哪些? 1、Object 类的 wait()、notify() 和 notifyAll() 方法。这是 Java 中最基础的线程间通信方式,基于对象的监视器(锁)机制。 :使当前线程进入等待状态,直到其他线程调用该对象的 或 方法。 :唤醒在此对象监视器上等待的单个线程。 :唤醒在此对象监视器上等待的所有线程。 2、 和 接口。 接口提供了比 更灵活的锁机制, 接口则配合 实现线程间的等待 / 通知机制。 :使当前线程进入等待状态,直到被其他线程唤醒。 :唤醒一个等待在该 上的线程。 :唤醒所有等待在该 上的线程。 3、 关键字。 关键字用于保证变量的可见性,即当一个变量被声明为 时,它会保证对该变量的写操作会立即刷新到主内存中,而读操作会从主内存中读取最新的值。 4、CountDownLatch。 是一个同步辅助类,它允许一个或多个线程等待其他线程完成操作。 :构造函数,指定需要等待的线程数量。 :减少计数器的值。 :使当前线程等待,直到计数器的值为 0。 5、CyclicBarrier。 是一个同步辅助类,它允许一组线程相互等待,直到所有线程都到达某个公共屏障点。 :构造函数,指定参与的线程数量和所有线程到达屏障点后要执行的操作。 :使当前线程等待,直到所有线程都到达屏障点。 6、Semaphore。 是一个计数信号量,它可以控制同时访问特定资源的线程数量。 :构造函数,指定信号量的初始许可数量。 :获取一个许可,如果没有可用许可则阻塞。 :释放一个许可。 如何停止一个线程? 在 Java 中,停止线程的正确方式是 通过协作式的逻辑控制线程终止 ,而非强制暴力终止(如已废弃的 )。以下是实现安全停止线程的多种方法: 第一种方式:通过共享标志位主动终止 。定义一个 可见的 状态变量,由主线程控制其值,工作线程循环检测该变量以决定是否退出。 调用方式: 第二种方式使用线程中断机制 。通过 触发线程中断状态,结合中断检测逻辑实现安全停止。 调用方式: 不会立刻终止线程,只是设置中断标志位。 线程需手动检查中断状态( )或触发可中断操作(如 , , )响应中断。 阻塞操作中收到中断请求时,会抛出 并清除中断状态。 第三种方式通过 取消任务 。使用线程池提交任务,并通过 停止线程,依赖中断机制。 第四种方式处理不可中断的阻塞操作 。某些 I/O 或同步操作(如 、 )无法通过中断直接响应。此时需结合资源关闭操作。比如,关闭 Socket 释放阻塞。 调用方式:调用 方法关闭资源以解除阻塞。 线程停止的正确实践,如下表格: | 方法 | 适用场景 | 注意事项 | | | | | | 循环检测标志位 | 简单无阻塞的逻辑 | 确保标志位使用 或通过锁保证可见性 | | 中断机制 | 可中断的阻塞操作 | 正确处理 并恢复中断标志 | | Future.cancel() | 线程池管理任务 | 需要线程池任务支持中断处理机制 | | 资源关闭 | 不可中断的阻塞操作(如Sockets) | 显式关闭资源触发异常,结合中断状态判断回滚 | 避免使用以下已废弃方法: :暴力终止,可能导致状态不一致。 / :易导致死锁。 Go 的协程和 Java 的线程有啥区别? Go的协程和Java的线程确实有很大区别,我从几个方面来说一下。 首先最核心的区别是它们的调度模型不一样 。Java 的\ \ 平台线程(Platform Thread)\ \ 是操作系统级别的线程,一个 Java 平台线程对应一个操作系统线程,创建、销毁、调度都由操作系统内核管理。不过从 Java 21 开始,JEP 444 正式引入了虚拟线程(Virtual Thread) ,这是 JVM 层面的 M:N 轻量级线程,和 Go 协程的思路已经比较接近了。下面的对比主要针对传统平台线程,虚拟线程的部分会在最后补充说明。 而Go的协程,也就是goroutine,它是用户态的轻量级线程,是由Go运行时自己调度的,不需要经过操作系统内核。这就导致了很多后续的差异。 从资源消耗上来说,差别非常明显 。Java创建一个线程的开销是比较大的,默认情况下一个线程的栈空间大概是1MB左右,而且线程的创建和销毁都需要系统调用,开销不小。所以在Java里你不能无限制地创建线程,创建几千个线程系统可能就扛不住了,这也是为什么Java里要用线程池来复用线程。 但Go的协程就轻量多了,一个goroutine初始栈空间只有2KB,而且栈空间是可以动态伸缩的。Go里创建几万甚至几十万个协程都是很正常的事情,我之前做过一个项目,同时跑了十几万个goroutine,系统压力也不大。 调度方式上也不太一样 。Java线程的调度是抢占式的,由操作系统的调度器决定什么时候切换线程,线程切换需要保存和恢复上下文,涉及到用户态和内核态的切换,这个开销相对比较大。 Go的协程调度是在用户态完成的,Go运行时有个GMP模型,G就是goroutine,M是操作系统线程,P是逻辑处理器。Go会把多个协程映射到少量的操作系统线程上执行,协程的切换完全在用户态,不需要陷入内核,所以切换成本非常低。而且Go的调度器会在协程发生阻塞的时候,比如IO操作或者channel操作时,自动把这个协程挂起,让其他协程继续执行,这种协作式调度非常高效。 从使用方式上来讲,Go的协程用起来要简单太多了 。在Go里创建一个协程只需要在函数调用前加个go关键字就行了,比如go doSomething(),就这么简单,如下代码: 而在Java里创建线程,你要么new一个Thread对象,要么实现Runnable接口,或者用线程池,总之代码量要多一些。Go这种简洁的语法让并发编程的心智负担降低了很多。 通信机制也有很大差异 。Java线程之间通信主要靠共享内存,需要用synchronized、Lock这些同步机制来保证线程安全,还要小心处理死锁、竞态条件这些问题,写起来比较容易出错。Go虽然也支持共享内存和锁,但它更提倡用channel来通信,有句话叫"不要通过共享内存来通信,而要通过通信来共享内存"。用channel的话,代码会更清晰,也更不容易出现并发问题。 性能上来说,因为Go协程的轻量级特性和高效的调度器,在高并发场景下,Go的表现通常会更好 。比如说要处理几万个并发请求,Java可能需要用复杂的异步框架或者线程池来优化,而Go直接为每个请求开一个协程就行了,代码简单性能还好。当然这不是说Java不行,Java在企业级应用、生态完善性这些方面还是有很大优势的,只是在并发模型的设计上,Go确实更现代化一些。 从我实际使用的感受来说,如果是写IO密集型的高并发服务,比如API网关、聊天服务器这种,Go的协程模型开发效率高,性能也好。但如果是传统的企业级应用,需要用到大量的中间件和框架,Java的生态会更成熟一些。两者各有优势,选择哪个还是要看具体场景。 最后再补充一下 Java 21 的虚拟线程 。JEP 444 正式发布的虚拟线程,让 Java 终于也有了类似协程的 M:N 调度模型——虚拟线程由 JVM 的 在用户态调度,大量虚拟线程会被映射到少量载体平台线程上执行,创建成本极低(几百字节起步,栈可动态扩缩),单 JVM 可以轻松跑上百万个虚拟线程。创建方式也很简洁: 当虚拟线程遇到 IO 阻塞(如 Socket、文件、数据库调用)时,JVM 会自动把它从载体线程上"摘下",让载体线程去跑其他虚拟线程,完全不会阻塞操作系统线程——这正是 Go 协程高吞吐的核心原理。所以在 Java 21+ 下,Java 和 Go 在高并发 IO 场景下的差距已经大幅缩小。 并发安全 juc包下你常用的类? 线程池相关: :最核心的线程池类,用于创建和管理线程池。通过它可以灵活地配置线程池的参数,如核心线程数、最大线程数、任务队列等,以满足不同的并发处理需求。 :线程池工厂类,提供了一系列静态方法来创建不同类型的线程池,如 (创建固定线程数的线程池)、 (创建可缓存线程池)、 (创建单线程线程池)等,方便开发者快速创建线程池。 并发集合类: :线程安全的哈希映射表,用于在多线程环境下高效地存储和访问键值对。JDK 1.7 采用分段锁(Segment)实现; JDK 1.8 起已废弃分段锁 ,改为基于 锁桶头节点的方式,锁粒度更细,并发度更高,在高并发场景下比传统的 性能更好。 :线程安全的列表,在对列表进行修改操作时,会创建一个新的底层数组,将修改操作应用到新数组上,而读操作仍然可以在旧数组上进行,从而实现了读写分离,提高了并发读的性能,适用于读多写少的场景。 同步工具类: :允许一个或多个线程等待其他一组线程完成操作后再继续执行。它通过一个计数器来实现,计数器初始化为线程的数量,每个线程完成任务后调用 方法将计数器减一,当计数器为零时,等待的线程可以继续执行。常用于多个线程完成各自任务后,再进行汇总或下一步操作的场景。 :让一组线程互相等待,直到所有线程都到达某个屏障点后,再一起继续执行。与 不同的是, 可以重复使用,当所有线程都通过屏障后,计数器会重置,可以再次用于下一轮的等待。适用于多个线程需要协同工作,在某个阶段完成后再一起进入下一个阶段的场景。 :信号量,用于控制同时访问某个资源的线程数量。它维护了一个许可计数器,线程在访问资源前需要获取许可,如果有可用许可,则获取成功并将许可计数器减一,否则线程需要等待,直到有其他线程释放许可。常用于控制对有限资源的访问,如数据库连接池、线程池中的线程数量等。 原子类: :原子整数类,提供了对整数类型的原子操作,如自增、自减、比较并交换等。通过硬件级别的原子指令来保证操作的原子性和线程安全性,避免了使用锁带来的性能开销,在多线程环境下对整数进行计数、状态标记等操作非常方便。 :原子引用类,用于对对象引用进行原子操作。可以保证在多线程环境下,对对象的更新操作是原子性的,即要么全部成功,要么全部失败,不会出现数据不一致的情况。常用于实现无锁数据结构或需要对对象进行原子更新的场景。 怎么保证多线程安全? synchronized关键字 :可以使用 关键字来同步代码块或方法,确保同一时刻只有一个线程可以访问这些代码。对象锁是通过 关键字锁定对象的监视器(monitor)来实现的。 volatile关键字 : 关键字用于变量,确保所有线程看到的是该变量的最新值,而不是可能存储在本地寄存器中的副本。 Lock接口和ReentrantLock类 : 接口提供了比 更强大的锁定机制, 是一个实现该接口的例子,提供了更灵活的锁管理和更高的性能。 原子类 :Java并发库( )提供了原子类,如 、 等,这些类提供了原子操作,可以用于更新基本类型的变量而无需额外的同步。 示例: 线程局部变量 : 类可以为每个线程提供独立的变量副本,这样每个线程都拥有自己的变量,消除了竞争条件。 并发集合 :使用 包中的线程安全集合,如 、 等,这些集合内部已经实现了线程安全的逻辑。 JUC工具类 : 使用 包中的一些工具类可以用于控制线程间的同步和协作。例如: 和 等。 Java中有哪些常用的锁,在什么场景下使用? Java中的锁是用于管理多线程并发访问共享资源的关键机制。锁可以确保在任意给定时间内只有一个线程可以访问特定的资源,从而避免数据竞争和不一致性。Java提供了多种锁机制,可以分为以下几类: 内置锁(synchronized) :Java中的 关键字是内置锁机制的基础,可以用于方法或代码块。当一个线程进入 代码块或方法时,它会获取关联对象的锁;当线程离开该代码块或方法时,锁会被释放。如果其他线程尝试获取同一个对象的锁,它们将被阻塞,直到锁被释放。其中,syncronized加锁时有无锁、偏向锁、轻量级锁和重量级锁几个级别。偏向锁用于当一个线程进入同步块时,如果没有任何其他线程竞争,就会使用偏向锁,以减少锁的开销。轻量级锁使用线程栈上的数据结构,避免了操作系统级别的锁。重量级锁则涉及操作系统级的互斥锁。 ReentrantLock : 是一个显式的锁类,提供了比 更高级的功能,如可中断的锁等待、定时锁等待、公平锁选项等。 使用 和 方法来获取和释放锁。其中,公平锁按照线程请求锁的顺序来分配锁,保证了锁分配的公平性,但可能增加锁的等待时间。非公平锁不保证锁分配的顺序,可以减少锁的竞争,提高性能,但可能造成某些线程的饥饿。 读写锁(ReadWriteLock) : 接口定义了一种锁,允许多个读取者同时访问共享资源,但只允许一个写入者。读写锁通常用于读取远多于写入的情况,以提高并发性。 乐观锁和悲观锁 :悲观锁(Pessimistic Locking)通常指在访问数据前就锁定资源,假设最坏的情况,即数据很可能被其他线程修改。 和 都是悲观锁的例子。乐观锁(Optimistic Locking)通常不锁定资源,而是在更新数据时检查数据是否已被其他线程修改。乐观锁常使用版本号或时间戳来实现。 自旋锁 :自旋锁是一种锁机制,线程在等待锁时会持续循环检查锁是否可用,而不是放弃CPU并阻塞。通常可以使用CAS来实现。这在锁等待时间很短的情况下可以提高性能,但过度自旋会浪费CPU资源。 怎么在实践中用锁的? Java提供了多种锁的实现,包括 关键字、 包下的 接口及其具体实现如 、 等。下面我们来看看这些锁的使用方式。 1. 关键字可以用于方法或代码块,它是Java中最早的锁实现,使用起来非常简单。 示例:synchronized方法 示例:synchronized代码块 2. 使用 接口 接口提供了比 更灵活的锁操作,包括尝试锁、可中断锁、定时锁等。 是 接口的一个实现。 示例:使用ReentrantLock 3. 使用 接口提供了一种读写锁的实现,允许多个读操作同时进行,但写操作是独占的。 示例:使用ReadWriteLock Java 并发工具你知道哪些? Java 中一些常用的并发工具,它们位于 包中,常见的有: CountDownLatch :CountDownLatch 是一个同步辅助类,它允许一个或多个线程等待其他线程完成操作。它使用一个计数器进行初始化,调用 方法会使计数器减一,当计数器的值减为 0 时,等待的线程会被唤醒。可以把它想象成一个倒计时器,当倒计时结束(计数器为 0)时,等待的事件就会发生。示例代码: CyclicBarrier :CyclicBarrier 允许一组线程互相等待,直到到达一个公共的屏障点。当所有线程都到达这个屏障点后,它们可以继续执行后续操作,并且这个屏障可以被重置循环使用。与 不同, 侧重于线程间的相互等待,而不是等待某些操作完成。示例代码: Semaphore :Semaphore 是一个计数信号量,用于控制同时访问某个共享资源的线程数量。通过 方法获取许可,使用 方法释放许可。如果没有许可可用,线程将被阻塞,直到有许可被释放。可以用来限制对某些资源(如数据库连接池、文件操作等)的并发访问量。代码如下: Future 和 Callable :Callable 是一个类似于 的接口,但它可以返回结果,并且可以抛出异常。Future 用于表示一个异步计算的结果,可以通过它来获取 任务的执行结果或取消任务。代码如下: ConcurrentHashMap :ConcurrentHashMap 是一个线程安全的哈希表,它允许多个线程同时进行读操作,在一定程度上支持并发的修改操作,避免了 在多线程环境下需要使用 或 进行同步的性能问题。代码如下: CountDownLatch 是做什么的讲一讲? CountDownLatch 是 Java 并发包( )中的一个同步工具类, 用于让一个或多个线程等待其他线程完成操作后再继续执行 。 其核心是通过一个计数器(Counter)实现线程间的协调,常用于多线程任务的分阶段控制或主线程等待多个子线程就绪的场景,核心原理: 初始化计数器 :创建 时指定一个初始计数值(如 )。 等待线程阻塞 :调用 的线程会被阻塞,直到计数器变为 0。 任务完成通知 :其他线程完成任务后调用 ,使计数器减 1。 唤醒等待线程 :当计数器减到 0 时,所有等待的线程会被唤醒。 主线程等待所有子线程就绪后启动,代码例子如下: synchronized和reentrantlock及其应用场景? synchronized 工作原理 synchronized是Java提供的原子性内置锁,这种内置的并且使用者看不到的锁也被称为 监视器锁 , 使用synchronized之后,会在编译之后在同步的代码块前后加上monitorenter和monitorexit字节码指令,他依赖操作系统底层互斥锁实现。他的作用主要就是实现原子性操作和解决共享变量的内存可见性问题。 执行monitorenter指令时会尝试获取对象锁,如果对象没有被锁定或者已经获得了锁,锁的计数器+1。此时其他竞争锁的线程则会进入等待队列中。执行monitorexit指令时则会把计数器 1,当计数器值为0时,则锁释放,处于等待队列中的线程再继续竞争锁。 synchronized是排它锁,当一个线程获得锁之后,其他线程必须等待该线程释放锁后才能获得锁