JUC 并发编程
Java 并发:JMM、volatile、线程池、锁、AQS、CAS、Synchronized。
JUC 并发编程
JMM Java内存模型
- 缓存不一致性:在一个多核 CPU 系统中,每个核心都有自己独立的高速缓存(L1, L2 Cache)。 于是便有了下面两个概念。
主内存 (Main Memory) : 所有线程共享的内存区域
工作内存 (Working Memory) : 每个线程私有的内存区域
JMM 的解决的三大核心问题:原子性、可见性和有序性
原子性:一个操作或多个操作,要么全部执行并且执行过程不被任何因素打断,要么就都不执行。类似于数据库的事务。
可见性: 当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。因为工作内存的存在,普通变量无法保证立即可见。
有序性:程序的执行顺序按照代码的先后顺序执行。为了提升性能,编译器和处理器通常会对指令进行重排序 (Instruction Reordering),在多线程下这会导致执行结果错误。
线程安全 - 问题核心:原子性,可见性,有序性
- 原子性
- Lock锁 Synchronized
- Atomic原子包
- 可见性
- volatile 读写屏障
- Lock锁 Synchronized
- 有序性
- volatile 内存屏障 禁止指令重排序
- Happens-Before原则
- 如果操作 A happens-before 操作 B,那么 A 操作的执行结果将对 B 操作可见,并且 A 的执行顺序排在 B 之前。
Volatile
- 保证内存的可见性 当一个变量被一个线程高频调用 会加入该线程的缓存 Volatile可强制从主内存加载
- 禁止指令重排序 保证内存的有序性 cpu 是和缓存做交互的,但是由于 cpu 运行效率太高,所以会不等待当前命令返回结果从而继续执行下一个命令,就会有乱序执行的情况发生
进程和线程的区别
关系:一个进程可以包含一个或多个线程,但至少有一个主线程。线程是“轻量级的进程”。
目的:引入线程是为了在进程内部实现并发,减少程序在并发执行时所付出的时空开销,提高执行效率。而多进程则是为了让操作系统能够同时运行多个独立的程序,实现隔离和并行。
资源上: 进程是操作系统分配资源的基本单位,而线程是 CPU 调度和执行的基本单位。
创建线程的方式
- 继承Thread 重写 run方法
- 实现Runnable接口
- 实现Callable接口
- 使用线程池
线程状态 - Java中的六种状态
- 新建 (NEW)使用
new Thread()但是还没有调用start()方法 - 可运行 (RUNNABLE)调用了线程的
start()方法后,线程就进入了 RUNNABLE 状态 。- 在 Java 的虚拟机层面,RUNNABLE 实际上包含了操作系统层面的两种状态:就绪(Ready)和运行中(Running)
- 阻塞 (BLOCKED)当线程试图进入一个被 synchronized 关键字保护的代码块或方法,但该区域的“锁”已经被其他线程拿走时,它就会进入 BLOCKED 状态。
- 等待 (WAITING) 线程主动放弃 CPU,并且无限期地等待另一个线程执行特定的操作来唤醒它 。
Object.wait() - 计时等待 (TIMED_WAITING)
Thread.sleep(long millis) - 终止 (TERMINATED)执行完毕或意外中断
线程状态 - OS中的五种状态
-
新建状态 (New / Created) —— 正在办理入职
-
就绪状态 (Ready) —— 坐在工位上,等老板分配活儿
-
运行状态 (Running) —— 正在疯狂敲代码
-
阻塞/等待状态 (Blocked / Waiting) —— 电脑死机了,等 IT 来修
含义: 线程在运行过程中,遇到了一些需要等待的事情,比如:等待读取磁盘文件(I/O 操作)、等待网络数据、等待获取锁、或者主动要求睡眠(Sleep)。
状态: 操作系统非常聪明,它发现这个线程现在干不了活,就会立刻剥夺它的 CPU,把它扔进“阻塞队列”。直到它等待的事情完成了(比如网速恢复了),它才会被唤醒,但注意:唤醒后不是立刻执行,而是回到 就绪状态 (Ready) 重新排队。
-
终止状态 (Terminated / Exit) —— 彻底离职
核心对比:为什么 Java 是 6 种,而 OS 是 5 种?
Java 虚拟机(JVM)是跑在操作系统之上的程序。Java 的线程状态是对 OS 状态的一种重新封装和映射。它们的核心差异在于:
| 操作系统状态 | Java 线程状态 | 为什么会有差异? |
|---|---|---|
| Ready (就绪) + Running (运行) | 👉 RUNNABLE (可运行) | 合并了! 对 Java 程序员来说,区分线程是在排队还是在执行意义不大,因为这是操作系统 CPU 调度器管的事,JVM 管不了。所以 Java 把它们统称为 RUNNABLE(只要没被阻塞,都在这个状态)。 |
| Blocked (阻塞) | 👉 BLOCKED (阻塞) 👉 WAITING (无限期等待) 👉 TIMED_WAITING (限期等待) | 细分了! Java 作为一门高级语言,为了更精细地控制多线程并发(比如锁机制、线程通信),把操作系统底层粗暴的“阻塞”状态,根据导致阻塞的具体原因拆分成了三种不同的状态。 |
Sleep与Wait的区别
| 对比维度 | Thread.sleep(long millis) | Object.wait() |
|---|---|---|
| 是否释放锁 | ❌ 不释放 | ✅ 释放 |
| 所属类 | java.lang.Thread (静态方法) | java.lang.Object (实例方法) |
| 主要用途 | 线程暂停,时间控制 | 线程协作,等待通知 |
| 唤醒条件 | 时间到 或 被中断 | 被notify/notifyAll唤醒 或 被中断 |
| 使用前提 | 任何地方都可以调用 | 必须在 synchronized 块或方法中调用 |
| 生活比喻 | 抱着枕头(锁)睡觉,别人只能干等 | 让出座位(锁)去等候区,等别人叫号 |
线程池
线程池的核心目的是减少开销:
- 线程创建和销毁的开销
- 上下文切换的开销
- 当线程数量远远超过 CPU 核心数时,CPU 就必须在大量线程之间来回切换,让人人都有“上机”的机会。
- 内存耗尽的风险
public ThreadPollExecutor(int corePoolSize,
int maximuPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Ruanable> workQueue,
ThreadFactor threadFactory,
RejectedExcutionHandler handler)- CPU密集型
- corePoolSize = CPU核数+1
- IO密集型
- corePoolSize = CPU核数*2
- 混合型
但是实际问题是复杂的,这并不能很好的适配实际的情况。
核心思想是要尽可能发挥CPU的性能。
测试参数,压测,动态调整参数,得到最好的结果。
- 拒绝策略
AbortPolicy(中止策略) - 默认策略 直接抛出一个RejectedExecutionException异常CallerRunsPolicy(调用者运行策略) 将该任务回退给提交它的那个线程(调用线程)来亲自执行。DiscardPolicy(丢弃策略)DiscardOldestPolicy(丢弃最旧策略)
线程池的执行流程
提交任务 → 核心线程是否已满?
├─ 未满 → 创建核心线程执行
└─ 已满 → 任务入队
├─ 队列未满 → 等待执行
└─ 队列已满 → 创建非核心线程
├─ 未达最大线程数 → 执行任务
└─ 已达最大线程数 → 执行拒绝策略线程池是怎么保证核心线程不会被回收的
用一句话总结就是:死循环(获取任务) + 阻塞队列 (BlockingQueue) 的条件阻塞
- 对于核心线程
- 调用的是阻塞队列的
take()方法。这是一个无限期阻塞的方法。如果队列为空,该方法会将当前线程挂起(底层通过LockSupport.park()实现),线程进入WAITING状态,不消耗 CPU 资源。它会一直停在这个位置死等,直到有新任务被offer进队列并唤醒它。
- 调用的是阻塞队列的
什么是协程
- 在传统的 Java 中: Java 原生的
Thread模型通常是1:1模型,即一个 Java 线程底层映射到一个操作系统的内核线程。并且这样一个线程会占到内容1MB多,这导致在面对高并发的 I/O 密集型场景(比如高并发 API 接口、大规模网页爬虫)时,系统容易遇到瓶颈。(注:Java 21 引入了虚拟线程 Virtual Threads,这其实就是 Java 官方实现的协程,用来解决这个问题)。 - 在 Go 语言中: Go 语言在并发编程上极其出色,正是因为它原生内置了协程,即 Goroutine。Go 在运行时(Runtime)层面实现了一套非常高效的
M:N调度模型(也就是著名的 GMP 模型),将 M 个 Goroutine 动态映射到 N 个操作系统线程上执行,协程的创建、销毁和上下文切换完全在用户态完成,开销极小,占的内存在几KB。 完美屏蔽了底层的复杂性,开发者只需要一个go关键字就能轻松实现高并发。
锁
- 悲观锁 (排他锁、互斥锁) 并发高 读写竞争高的情况
- Synchronized 无锁 - 偏向锁 - 自旋锁 - 重量级锁
- ReentryLock AQS
- 乐观锁
- 自旋锁
- CAS机制 Compare And Set
- 公平锁
- new ReentryLock(true)
- 等待的线程先来后到 效率较低 避免线程饥饿
- 非公平锁
- 多数情况非公平锁更好
- 等待的线程不保证先到先得 效率更高 可能出现线程饥饿
- 排他锁 - 写锁 ReentryReadWriteLock.ReadLock
- 共享锁 - 读锁 ReentryReadWriteLock.WriteLock
- 可重入锁 同一个线程可以重复持有一个锁
- 锁升级 无锁 - 偏向锁(仅第一次获取锁有开销) - 轻量级锁(自旋锁) - 重量级锁(Monitor实现,排他锁) Synchronized的锁升级机制
悲观锁与乐观锁
- 悲观锁
- 始终假设并发操作会发生冲突(“悲观” 地认为冲突是常态),因此在操作共享资源前,会先获取锁,确保自己独占资源,直到操作完成后才释放锁。在整个过程中,其他线程会被阻塞,无法操作该资源。
- synchronized、ReentrantLock
- MySQL中的行锁,表锁
- 乐观锁
- 假设并发操作很少发生冲突(“乐观” 地认为冲突是少数情况),因此不会提前获取锁,而是在操作资源时通过某种机制检测是否发生冲突,如果没有冲突则成功操作,否则放弃操作或重试。
- 版本号机制
- CAS Compare And Set
ReentrantLock机制 - AQS
AQS
ReentrLock的底层是AQS
AbstractQueuedSynchronizer 抽象队列同步器
抽象框架 封装了
- 同步状态管理 state 同步状态 (volatile修饰)
- 线程的阻塞和唤醒(动作)
- 等待队列的管理 CLH 队列 (FIFO等待队列) 存线程
Synchronized
无论是加在方法上,还是加在代码块(对象/类)上,synchronized 的本质都是对象锁。
Synchronized的锁升级机制
无锁 - 偏向锁 - 轻量级锁(自旋锁) - 重量级锁
Cas是什么
CompareAndSet (自旋锁就有用到)
- 实现原子类
- CPU开销过大 线程反复尝试更新一个变量(自旋锁)
- 实现乐观锁
- ABA问题?
- 版本号机制 Version
- 只能保证一个共享变量的原子性
- 数据库的实现 - 版本号机制 (Version Number)
- Automic包是使用CAS保证的并发安全
交替打印
方法一:基础派 synchronized + wait/notify
这是最基础、最必须掌握的解法。它考察你对对象监视器(Monitor)底层机制的理解。
核心思想: 两个线程共用一把锁。拿到锁的线程打印数字,然后唤醒另一个线程,接着自己进入等待状态并释放锁。
public class AlternatePrintWaitNotify {
private static int count = 1; // 共享的计数器
private static final Object lock = new Object(); // 共享的锁对象
private static final int MAX = 100; // 打印上限
public static void main(String[] args) {
Runnable printTask = () -> {
while (true) {
synchronized (lock) {
// 1. 检查是否结束
if (count > MAX) {
lock.notifyAll(); // 结束前唤醒可能还在等待的线程,防止死锁
break;
}
// 2. 打印并累加
System.out.println(Thread.currentThread().getName() + " 打印: " + count++);
// 3. 叫醒另外一个线程
lock.notify();
// 4. 自己进入等待状态,并释放锁 (如果还没打印完的话)
if (count <= MAX) {
try {
lock.wait();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
}
};
new Thread(printTask, "线程A (奇数)").start();
new Thread(printTask, "线程B (偶数)").start();
}
}面试官可能会追问的细节:
- 为什么
**wait()**必须在**synchronized**里面? 答:因为wait()的语义是“释放当前持有的锁并挂起”,如果你都没拿到锁,怎么释放?所以必须在同步块内。 **notify()**和**wait()**的顺序能反吗? 答:绝对不能。如果先wait(),线程就直接挂起并释放锁了,后面的notify()根本执行不到,两个线程会互相等待,造成死锁。
方法二:进阶派 ReentrantLock + Condition (JUC 推荐)
在实际开发中,我们更推荐使用 JUC 包下的高级锁。它的优势在于可以创建多个 Condition(条件变量),实现精准唤醒。
虽然对于两个线程交替打印,它和 wait/notify 区别不大,但在“三个线程交替打印 ABC”的场景下,它的威力就显现出来了(A 明确唤醒 B,B 明确唤醒 C)。
这里演示使用单个 Condition 实现 1-100 交替打印:
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
public class AlternatePrintLock {
private static int count = 1;
private static final int MAX = 100;
// JUC 的可重入锁
private static final ReentrantLock lock = new ReentrantLock();
// 类似于 wait/notify 的条件变量
private static final Condition condition = lock.newCondition();
public static void main(String[] args) {
Runnable printTask = () -> {
while (true) {
lock.lock(); // 加锁
try {
if (count > MAX) {
condition.signalAll(); // 结束前唤醒其他线程
break;
}
System.out.println(Thread.currentThread().getName() + " 打印: " + count++);
condition.signal(); // 相当于 notify()
if (count <= MAX) {
condition.await(); // 相当于 wait()
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
lock.unlock(); // 关键点:必须在 finally 中释放锁!
}
}
};
new Thread(printTask, "线程A").start();
new Thread(printTask, "线程B").start();
}
}面试官可能会追问的细节:
**ReentrantLock**和**synchronized**有什么区别?
答:synchronized 是 JVM 层面的关键字,自动释放锁;ReentrantLock 是 API 层面的类,提供了更丰富的功能(如公平锁、响应中断、超时获取锁等),但必须手动在**finally**块中调用**unlock()**释放锁,否则如果发生异常会导致死锁。
💡 附加题:如果是“三个线程交替打印 ABC”怎么做?
如果面试官把题目升级为 3 个或更多线程的按顺序交替执行,最优雅的解法是引入一个“状态变量 (State)”来控制到底该谁执行。
int state = 0; // 0代表该A执行,1代表该B执行,2代表该C执行
// 线程 A 的代码
while(true) {
lock.lock();
try {
while (state % 3 != 0) { // 如果没轮到我,我就睡
conditionA.await();
}
print("A");
state++; // 状态变更,轮到下一个
conditionB.signal(); // 精准唤醒 B
} finally {
lock.unlock();
}
}注意:这里判断状态必须用 _while_ 循环,防止虚假唤醒。
掌握了这两种写法和其背后的“锁+等待/唤醒”机制,应对所有形式的交替打印问题都不在话下了。
幂等性如何保证
一个操作如果多次执行所产生的影响,与一次执行的影响相同,我们就称这个操作是幂等的。
- 天然幂等:
SELECT(查询)、DELETE(删除特定ID)、UPDATE table SET status = 1 WHERE id = 1(绝对值更新)。 - 非幂等:
INSERT(多次执行会产生多条数据)、UPDATE table SET amount = amount - 10 WHERE id = 1(相对值更新)。
- 前端防重 - 防止正常用户 但不防止恶意用户
- Token 机制(防重 Token)—— 适用于前后端交互的提交场景
- 提交前先获取一个全局唯一的 Token,服务端验证后销毁。
- 数据库唯一索引(Unique Key)—— 适用于 Insert
- 乐观锁(Optimistic Locking)—— 适用于 Update
- 在数据库表中增加一个
version(版本号)字段。
- 在数据库表中增加一个
i++是原子操作吗
读取 - 修改 - 写入”(Read-Modify-Write)的复合操作