并发编程八股
并发编程八股
多线程
什么是 Java 内存模型 JMM,多线程要注意哪些问题
JMM 是 Java 定义的一套 多线程 访问 共享变量 的规则
OS 层(CPU)有如下问题:
CPU 每个核心都有自己的缓存 (L1/L2)。线程改一个变量时,往往只改了缓存里的值,没立刻写回主存。于是线程 A 改了值,值还留在 A 的缓存里,线程 B 去读,读到的还是主存里的旧值。
编译器和 CPU 为了提速,会把没有依赖关系的指令重新排序。单线程下没事,多线程一混就出乱子。比喻:想象公司里多个员工(线程)协作同一个项目。办公室有一块共享白板(主存),每个员工桌上还贴着一张自己的便签(CPU 缓存)。
JMM 主要解决三大问题:
- 可见性:一个线程改了变量,别的线程能看到吗?
- JMM 用主内存和工作内存来描述这种交互
- 主要工具:
volatile,保证可见性 + 有序性。改 volatile 变量就像拿大喇叭喊"我改了,都看白板!",所有人丢掉便签去读主存最新值。但 不保证原子性 (i++仍不安全)。
- 原子性:操作能不能被打断? (
i++其实是读、加、写三步,可能被插队)- 主要工具:synchronized 悲观锁、ReentrantLock 悲观锁、CAS、AtomicInteger 原子类,使用锁把整个操作保护起来
- 有序性:指令能不能被重排?
- 主要工具:happens-before 规则来规定哪些操作之间具有可见性和顺序保证
Java 多线程和 OS 线程区别,Java 多线程要注意什么
Java 线程有 平台线程 和 虚拟线程
- 平台线程:与 OS 线程严格 1:1
- 虚拟线程:Java 21 正式发布,JVM 在用户态自行调度的轻量级线程,采用 M:N 模型
Java 多线程是指在 一个 Java 程序 中同时 运行多个线程,这些线程共享程序的内存空间(如全局变量、方法区等),但有各自的栈和程序计数器,需要注意:
- 线程安全问题
- 数据一致性
- 死锁
- 线程间通信
- 阻塞队列
- 中断机制
- 线程的创建和销毁成本
保证数据一致性的方案有哪些
- 事务管理
- 锁机制
- CAS 乐观锁版本控制
Java 线程状态有哪些
| 线程状态 | 解释 |
|---|---|
| NEW | 尚未启动的线程状态,即线程创建,还未调用 start 方法 |
| RUNNABLE | 就绪状态(调用 start,等待调度)+ 正在运行 |
| BLOCKED | 等待监视器锁时,陷入阻塞状态 |
| WAITING | 等待状态的线程正在 等待 另一线程执行特定的操作(如 notify) |
| TIMED_WAITING | 具有 指定等待时间 的等待状态 |
| TERMINATED | 线程完成执行,终止状态 |
sleep() 和 wait() 区别
| 对比项 | Thread.sleep() | Object.wait() |
|---|---|---|
| 所属类 | Thread 的静态方法 | Object 的实例方法 |
| 调用前提 | 无需持有锁 | 必须 持有对象锁(在 synchronized 内) |
| (有锁)是否释放锁 | 不释放 | 释放 |
| 唤醒方式 | 时间到自动醒 | 需 notify/notifyAll,或 wait(timeout) 超时 |
| 异常 | 抛 InterruptedException | 抛 InterruptedException |
| 用途 | 让线程"歇一会儿" | 线程间通信,等某个条件成立 |
一句话记忆:sleep 睡的是时间,不放手(锁);wait 等的是信号,先放手。
blocked 和 waiting 区别
- BLOCKED:等的是锁。线程想进入 synchronized 方法/块,但监视器锁被别的线程占着,只能在同步队列里排队。锁释放后它还要竞争才能拿到。
- WAITING:等的是另一个线程的动作。调用 wait()、join()、park() 后进入,此时不持有锁(wait() 会先释放)。需要 notify/notifyAll、unpark 或 join 的目标线程结束才能唤醒。
notify 和 notifyAll 区别
notify():随机唤醒一个在等待队列里的线程,唤醒哪个由调度器决定,不可控。notifyAll():唤醒所有等待该监视器的线程,各自醒来后 重新竞争锁、检查条件。
实践建议:默认用 notifyAll() 保证正确性(notify() 会唤醒条件不满足的线程,导致程序卡死);只有在确定等待队列里只有一个线程、或条件简单时,才用 notify() 省开销。另外两者都必须在 synchronized 内调用,且调用后不释放锁(锁释放时等待线程才真正醒来)。
Java 线程怎么停止
停止线程要靠 协作式取消,外部发出停止请求,线程在合适的位置检查请求、清理资源,然后自己退出。(interrupt() 中断机制)
线程之间如何通信?有哪些方式
- wait / notify / notifyAll
线程 A 调用wait()释放锁并挂起,线程 B 修改共享状态后调用notify()唤醒 A。通常配合synchronized和标志变量使用,是生产者-消费者模型的最原始实现。 - 阻塞队列(BlockingQueue)
put()在队列满时阻塞,take()在队列空时阻塞,天然解决同步问题,代码比 wait/notify 简洁得多。 - 共享变量 + volatile
- 同步工具类(JUC)
- Future / CompletableFuture
- 管道流(PipedStream)
并发安全
乐观锁与悲观锁区别
- 乐观锁:乐观锁假设冲突很少,不加锁,靠版本号/CAS 在提交时检查,冲突就重试
- 悲观锁:悲观锁假设冲突频繁,操作前先加锁,别人必须等锁释放才能操作。
如何实现乐观锁
CAS(Compare and Swap)是乐观锁的基础
- 代码实现包
- 版本号控制
- 时间戳
CAS 的缺点
- ABA 问题:线程读到值 A,准备 CAS 时,值被别的线程改成 B 又改回了 A,因此要带上版本号比较
- 竞争激烈时重试成本高:大量线程同时更新一个变量时,可能反复失败,消耗 CPU
- 不能直接保护多个独立变量的整体操作(不适合复杂业务逻辑)
- 不会自动提供阻塞等待、超时、中断响应
什么是公平锁和非公平锁,Synchronized 是公平锁吗
- 公平锁:线程按请求锁的顺序(FIFO)依次获取,排队等待,先来后到。
- 非公平锁:新来的线程可以直接尝试抢锁,抢到了就用,不用排队,等待中的线程可能被"插队"。
synchronized 是非公平锁
线程池
什么是 ThreadLocal,原理是什么,有什么问题
ThreadLocal 是 Java 提供的线程局部变量工具。
同一个 ThreadLocal,在 不同线程里可以存不同的值,各个线程访问的是自己的那一份
比如同一个 ThreadLocal,在不同线程里可以存不同的值,各个线程访问的是自己的那一份
ThreadLocal 原理:
ThreadLocal底层依赖ThreadLocalMap- 每个 Thread 对象里有 自己的
ThreadLocalMap - Map 内部维护了 k-v 数组:
- key:对 ThreadLocal 对象的弱引用(GC 会回收)
- value:当前线程存进去的值,是强引用(GC 不会回收)
ThreadLocal 的问题:
- 内存泄露
如果外部不再持有这个ThreadLocal,的强引用,它就可能被 GC 回收,Entry的key变成null,key 被回收,vlaude 不一定会被回收,导致 value 还可以被访问 - 线程池数据串用
上一个任务往ThreadLocal存了值,没清理,下一个任务拿到同一个线程,直接读到上一个任务的脏数据
线程池的好处,工作原理?
以 ThreadPoolExecutor 为例
好处
线程池 的核心作用是 复用线程、控制并发数,避免频繁创建销毁线程带来的性能开销
线程池主要看三大核心参数:
- 核心线程数
- 最大线程数
- 任务队列
提交一个任务时,处理流程如下:
- 当前线程数小于核心线程数,创建一个工作线程来执行新任务
- 当前线程数达到核心线程数,尝试把新任务放进任务队列,等待工作线程领取
- 任务队列满了,任务无法入队,尝试继续创建工作线程,但线程总数不能超过最大线程数
- 任务队列满了,任务无法入队,也无法继续创建线程,执行拒绝策略(默认报错)
注意:达到核心线程数之后,会先尝试入队,而不是直接把线程数增加到最大线程数。
举例:
核心线程数 = 2,最大线程数 = 4,队列容量 = 3,连续提交 8 个任务,前面的都没执行完:
- 第 1、2 个任务:线程数没到核心数 2,直接创建线程执行。
- 第 3、4、5 个任务:核心线程满了,队列还没满(容量 3),进队列排队。
- 第 6、7 个任务:队列满了,但线程数 2 还没到最大 4,创建新工作线程执行,线程总数到 4。
- 第 8 个任务:队列满了,线程数也到上限 4 了,执行拒绝策略。
线程池不一定按照提交顺序执行任务
到最大线程数后的拒绝策略有哪些
- AbortPolicy:默认策略,直接抛出
RejectedExecutionException。适合需要让调用方明确知道提交失败的场景,调用方可以返回错误、降级或者安排后续重试。 - CallerRunsPolicy:线程池没有关闭时,由 提交任务的线程自己执行任务,让提交方慢下来。注意是“提交任务的线程”,不一定是主线程;线程池已经关闭时,则丢弃任务。
- DiscardPolicy:直接 丢弃新任务,不抛异常。只适合业务明确允许丢失的任务,否则容易造成结果缺失又不好排查。
- DiscardOldestPolicy:线程池没有关闭时,丢弃队列头部的任务,再尝试提交新任务。它丢掉的是队列头,不一定是提交时间最早的任务,具体取决于队列的排序规则。
工作线程执行完成后,做什么
工作线程执行完任务后,会回到队列里取下一个任务。
如果队列空了,线程就闲着等。超过核心数的空闲线程,等过 keepAliveTime 后被回收;核心线程默认一直留着。如果调了 allowCoreThreadTimeOut(true),核心线程闲太久也会被回收。
另外,核心线程是按需创建的,不是建池子时就全部启动。想提前拉起来,调 prestartAllCoreThreads()
线程池参数怎么设置
先看任务类型、处理时间、流量,以及下游能承受多少并发,再通过 压测 调整
- 估算线程数
- CPU 密集型(计算):从可用 CPU 核数附近开始
- IO 密集型(调用数据库,远程接口,线程要等待,可适当增加):粗略计算方式
线程数 ≈ CPU 核数 × 目标 CPU 利用率 × (1 + 等待时间 / 计算时间)
- 设置有界队列和拒绝策略
- 看压测与监控结果
《阿里巴巴 Java 开发手册》中提到,禁止使用
newFixedThreadPool和newCachedThreadPool创建线程池,应该手动new ThreadPoolExecutor