大家好,这里是后端基础拾光集。
前面我们把 Java 基础语法、内部类、Lambda 全部梳理完了,现在进入 Java 并发核心板块:多线程基础 + 锁机制。并发是 Java 面试的必考重难点,也是我们平时业务开发中常用到的技术,很多小伙伴分不清进程与线程、synchronized 与 volatile、各种锁的种类和适用场景。今天这篇我们一起从基础来学习一下,先举个例子:
把操作系统想象成一家餐厅:
- 进程 = 餐厅里的一个个独立厨房。每个厨房有自己独立的灶台、食材柜、冰箱(独立内存空间)。厨房之间互相隔离,A 厨房的食材 B 厨房拿不到,要传递只能通过"传菜口"(进程间通信 IPC)。
- 线程 = 同一个厨房里的多个厨师。他们共享同一个厨房的灶台、食材柜、冰箱(共享进程内存),但每个人有自己的围裙和手边的小工具(线程私有栈、程序计数器)。
同一个厨房多个厨师(多线程)同时操作同一口锅(共享变量),就会抢资源,出现并发安全问题。锁,就是用来规定:同一时间,锅只能交给一个厨师操作。
对应到技术
| 类比 | 技术概念 | 关键点 |
|---|---|---|
| 独立厨房 | 进程 | 独立内存空间,隔离性好,创建/切换开销大 |
| 同一厨房的厨师 | 线程 | 共享进程内存,切换开销小,需同步 |
| 传菜口 | IPC | 管道、消息队列、共享内存、Socket |
| 厨师各自的小工具 | 线程私有栈、PC | 栈、程序计数器、本地方法栈私有 |
面试要点:线程是 CPU 调度的最小单位,进程是资源分配的最小单位。线程共享堆和方法区,私有栈、PC、本地方法栈。共享带来效率,也带来竞态条件——这正是所有并发问题的根源。
重点:进程是资源分配最小单位;线程是 CPU 调度最小单位。
一、进程 vs 线程
- 进程:独立资源空间,进程之间互相隔离,通信成本高。
- 线程:隶属于进程,同一进程下所有线程共享堆、方法区;每个线程独有自己的虚拟机栈、程序计数器。
一句话:进程是独立包厢,线程是包厢里面干活的服务员。
代码:创建线程的 4 种方式
- 继承 Thread 类
public class MyThread extends Thread{
@Override
public void run() {
System.out.println("线程执行");
}
public static void main(String[] args) {
new MyThread().start(); // start()开启新线程,不是直接调用run()
}
}
注意:调用
run()只是普通方法执行;start()才会向操作系统申请创建新线程。
- 实现 Runnable 接口(推荐,避免单继承限制)
class Task implements Runnable{
@Override
public void run() {
System.out.println("runnable任务");
}
}
public class Demo {
public static void main(String[] args) {
new Thread(new Task()).start();
}
}
- Callable + FutureTask:可以获取返回值、抛异常
- 线程池创建(生产环境首选!)
开发规范:业务代码不要手动 new Thread,优先线程池。
二、线程核心状态
线程一共 6 种状态:NEW → RUNNABLE → BLOCKED / WAITING / TIMED_WAITING → TERMINATED
继续餐厅类比:
- NEW:服务员招聘好了,还没上岗。
- RUNNABLE:待命,可以干活(不一定正在被 CPU 执行,等待调度)
- BLOCKED:抢锁失败,排队等待拿锁(synchronized 锁)
- WAITING:主动休息,无限等待,需要别人唤醒(wait ())
- TIMED_WAITING:限时休息,睡一段时间自动醒来(sleep)
- TERMINATED:任务完成,下班。
高频面试区分:sleep () 和 wait () 区别
- sleep 是 Thread 方法;wait 是 Object 方法
- sleep 不会释放锁;wait 会释放持有的锁
- sleep 是限时;wait 不指定时间会无限等待,需要 notify 唤醒
三、并发问题根源(三大要素)
两个厨师同时伸手去拿同一口锅(共享变量),一个在炒菜一个在盛汤,结果菜糊了——这就是竞态条件(Race Condition) 。
三大并发特性,缺一不可:
- 原子性:一个操作不可分割。
i++看似一步,实际是"读-改-写"三步,中间可能被打断。 - 可见性:共享资源,一个线程改了共享变量,另一个线程要能立刻看到。CPU 缓存会导致看不到。
- 有序性:编译器和 CPU 会指令重排优化,单线程没问题,多线程可能乱套。
三个条件同时满足,就会出现线程安全问题。 锁,就是用来解决这三个问题。
四、锁分类与原理
1. synchronized 同步锁(内置锁、悲观锁)
类比:厨房只有一口锅,给这口锅上一把锁,规定谁拿到锅钥匙谁才能用,用完归还。其他人排队等——这就是
synchronized的互斥。 可以修饰方法 / 代码块。
- 修饰实例方法:锁对象是当前 this 实例
- 修饰静态方法:锁对象是类的 Class 对象
- 修饰代码块:锁是我们手动指定的对象
三种用法
public class Kitchen {
private int food = 0;
// 1. 修饰实例方法:锁的是当前对象 this(这个厨房)
public synchronized void cook() {
food++;
}
// 2. 修饰静态方法:锁的是 Class 对象(整个厨房类型)
public static synchronized void clean() { }
// 3. 修饰代码块:锁指定对象(最灵活,推荐)
public void serve() {
synchronized (this) {
food--;
}
}
}
底层原理:每个 Java 对象头里有个 Mark Word,记录锁状态。synchronized 的锁升级过程:
无锁 → 偏向锁 → 轻量级锁 → 重量级锁
- 偏向锁:只有一个厨师用这口锅,记下他的名字,下次直接进,无需加锁。
- 轻量级锁:有第二个厨师来了,升级,用 CAS 自旋抢锁(不阻塞,转圈等)。
- 重量级锁:抢的人多、自旋太久,升级为操作系统层面的互斥量,抢不到就阻塞挂起(不占 CPU,但唤醒开销大)。
✅优点:JDK 自带,简单,自动释放锁,不会死锁(异常自动释放)
❌缺点:锁竞争激烈的时候,重量级锁会阻塞,性能下降
2. volatile 关键字(不是锁!)
很多面试踩坑:volatile不是锁,只能保证可见性、禁止指令重排,不能保证原子性。 类比:厨师 A 改了菜谱,但只写在自己脑子里(CPU 缓存),厨师 B 看不到。volatile 相当于规定:菜谱一改,必须立刻写到中央公告板上,所有人每次都去公告板读。
它保证什么
public class Flag {
private volatile boolean running = true; // 不加 volatile,可能死循环
public void stop() { running = false; }
public void work() {
while (running) { // 每次从主内存读,能及时看到修改
// do something
}
}
}
- ✅ 保证可见性:写立即刷主内存,读强制从主内存读。
- ✅ 保证有序性:通过内存屏障禁止指令重排。
- ❌ 不保证原子性!
volatile int i; i++依然线程不安全。
适用场景:状态标记位(开关),双重检查锁 DCL 单例。
经典应用:双重检查锁单例
public class Singleton {
private static volatile Singleton instance; // volatile 不能少!
public static Singleton getInstance() {
if (instance == null) { // 第一次检查,避免每次都加锁
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查,防止重复创建
instance = new Singleton();
}
}
}
return instance;
}
}
为什么必须 volatile? new Singleton() 分三步:分配内存 → 初始化对象 → 引用指向内存。指令重排可能变成"分配内存 → 引用指向内存 → 初始化",此时另一个线程第一次检查发现 instance != null,拿到一个半成品对象。volatile 禁止这个重排。
3. Lock 显式锁(ReentrantLock)
JDK 层面实现的锁,手动加锁、手动解锁。
private final ReentrantLock lock = new ReentrantLock();
public void cook() {
lock.lock(); // 可中断、可超时、可公平
try {
// 临界区
} finally {
lock.unlock(); // 必须 finally 释放!
}
}
特点:
- ReentrantLock 必须手动
unlock(放 finally),忘记 unlock 会永久死锁。 - ReentrantLock 支持公平锁、可中断、超时获取、多条件变量(Condition)。
- 复杂场景用 ReentrantLock。
4. CAS 与乐观锁:不锁也能安全
不锁锅,而是规定:盛汤前先看一眼锅是不是你刚才那口、汤量没变,变了就重来——这就是 CAS(Compare And Swap)。
AtomicInteger count = new AtomicInteger(0);
count.incrementAndGet(); // 底层 CAS 循环,无锁线程安全
CAS 三大问题:
- ABA 问题:值从 A→B→A,CAS 以为没变。解决:加版本号
AtomicStampedReference。 - 自旋开销:竞争激烈时空转耗 CPU。
- 只能保证一个变量原子性。
5. 其他锁概念(面试常问)
- 可重入锁:同一个线程拿到锁之后,可以再次获取同一把锁。synchronized、ReentrantLock 都是可重入锁。类比:同一个服务员拿到盘子锁之后,可以再次拿这把锁,不会自己卡死自己。
- 公平锁 vs 非公平锁:公平锁,排队先来先得;非公平锁,来了可以直接抢,吞吐量更高(ReentrantLock 默认非公平)
- 悲观锁:默认一定会有人抢资源,访问直接上锁。synchronized、ReentrantLock 属于悲观锁。
- 乐观锁:默认没人抢,不加锁,更新的时候校验版本号,冲突重试。典型:CAS、数据库版本号。
6. 一张表理清
| 分类维度 | 锁类型 | 说明 |
|---|---|---|
| 是否阻塞 | 悲观锁 / 乐观锁 | 悲观:先加锁再操作(synchronized);乐观:先操作后校验(CAS) |
| 竞争策略 | 公平锁 / 非公平锁 | 公平按排队顺序,非公平可插队(默认非公平,吞吐高) |
| 重入性 | 可重入锁 | 同一线程可重复获取(synchronized、ReentrantLock) |
| 读写分离 | 共享锁 / 排他锁 | 读锁共享,写锁排他(ReadWriteLock) |
| 锁状态 | 偏向/轻量/重量 | synchronized 的升级路径 |
| 实现 | 隐式锁 / 显式锁 | synchronized(隐式)vs Lock 接口(显式,需手动 unlock) |
⚠️开发高频踩坑点
坑 1:count++ 不是原子操作! count++ 分为 3 步:读取 → +1 → 写回。多线程不加锁,一定会丢数据。volatile 解决不了。多线程必须用 AtomicInteger 或加锁。
坑 2:synchronized 锁错对象 静态方法锁 Class;实例方法锁 this。如果锁的对象变了,锁直接失效。
坑 3:忘记在 finally 释放 Lock 锁,发生异常直接死锁。
坑 4:不要在锁内调用外部方法或做 IO,可能长时间持锁或引发死锁。
坑 5:锁粒度过大性能差,过小可能失效,临界区尽量只包必要代码。
坑 6:双重检查锁单例必须加 volatile。
坑 7:死锁四大条件(面试必考)
- 互斥:资源同一时间只能一个线程持有
- 持有并等待:线程拿到 A 锁,等待 B 锁,不释放 A
- 不可剥夺:别人不能强行抢走线程手里的锁
- 循环等待:线程 1 等线程 2 资源,线程 2 等线程 1 资源
解决思路:破坏任意一条。最常用:统一锁的获取顺序。
🧪面试高频考点
Q1:synchronized 和 Lock 的区别?
A:
- synchronized JVM 内置,自动释放;Lock 是 API,手动 lock/unlock
- Lock 支持公平锁、可中断、超时 tryLock;synchronized 不支持
- 锁升级后 synchronized 性能已经很好,简单场景优先 synchronized
Q2:volatile 能保证原子性吗?
A:不能。仅可见性 + 禁止重排,count++ 这种复合操作无法保证原子性。
Q3:什么是锁升级?
A:synchronized 在 JDK1.6 引入锁优化。无锁→偏向锁→轻量级锁(CAS 自旋)→重量级锁(操作系统互斥),只能升级,不能降级。
Q4:sleep () 和 wait () 区别?
A:前面讲过,sleep 不释放锁,wait 释放锁;sleep 属于 Thread,wait 属于 Object,必须在同步代码块调用。
✅总结避坑清单
- 生产环境不要手动 new Thread,优先线程池。
- 简单同步优先
synchronized;需要公平锁、超时、中断,选 ReentrantLock。 - volatile 保可见和有序、不保原子,只适合状态标记,不能用于计数、自增。
- 使用 Lock,unlock 必须写在 finally。
- 死锁:保证多个锁获取顺序一致,避免循环等待。
- 锁分类记牢:悲观/乐观、公平/非公平、可重入、读写、隐式/显式。
- synchronized 三者全保,靠对象头 Mark Word 锁升级(偏向→轻量→重量)。
- CAS 是无锁乐观实现,注意 ABA 和自旋开销。
📝本文属于专栏【Java 后端基础拾光集】,生活化讲解 Java 基础,持续更新面试知识点。 公众号【Java后端程序媛手记】同步更新 Java 底层原理、后端线上踩坑实录,系列文章整理成合集,方便系统学习。