Java 并发底层原理详解

从"并发在哪发生" → 到"锁到底锁了什么" → 到"CAS 为什么不用锁也能控制并发"

JVM 内存模型 synchronized 底层 CAS 硬件级 Redis 分布式锁
📚 想要深入理解原理?推荐搭配专题图解学习

本页侧重面试考点速答。若需理解底层原理和运行机制,请看以下专题图解:

📘 Java并发编程图解 📘 Java线程间通信图解

1. 最根本的问题:并发到底发生在哪一层?

你理解数据库层面的并发(大家同时操作同一行),但对 Java 代码层面的并发不清楚。这一章把"并发发生在哪"讲透。

核心结论:数据库并发争夺的是"磁盘上的某一行数据",Java 代码并发争夺的是"JVM 堆内存中的某一个对象"。

同一个商品库存扣减,两个层面的并发

假设 100 个用户同时下单扣库存,看看两个层面分别发生了什么:

🌐 数据库层面
100 个请求 → 100 个数据库连接 → 同时执行 UPDATE inventory SET stock = stock - 1 WHERE id = 1
⬆️ ⬇️
💾 MySQL InnoDB 行锁 / MVCC
所有连接操作的是同一个磁盘上的数据行 → 数据库用行锁 + 乐观锁保证一致性
类比:数据库 = 公共仓库

所有门店(JVM 实例)都往同一个仓库(MySQL)取货。仓库管理员(InnoDB 行锁)保证每次只能一个人取货。

你理解这个层面,因为"一个仓库,大家共享"这个概念很直观。

那 Java 代码层面呢?

在请求到达数据库之前,你的 Java 代码已经在 JVM 里运行了。如果你在代码里做了缓存、计数、判断逻辑,这些操作在 JVM 内存中就可能产生并发问题。

🖥️ Java 代码层面(JVM 内)
100 个请求 → Tomcat 线程池 100 个线程 → 同时调用同一个 Spring Bean 实例
⬆️ ⬇️
📦 JVM 堆内存中的同一个对象
Spring 单例 → 整个 JVM 只有一个 InventoryService 实例 → 100 个线程同时操作这个实例的字段/方法
⬆️ ⬇️
⚠️ 并发争夺的是对象内部的变量
比如 InventoryService 里的 int stock = 100,或者 ConcurrentHashMap 里的某个 key
类比:JVM = 店面内的白板

每个门店(JVM 实例)内部有一块白板(堆内存),上面写着"当前库存 100"。店员们(线程)都看这块白板、改这块白板。

如果 3 个店员同时看到白板上写"100",各自在脑子里减 1,然后各自把白板改成 99 → 最终白板上是 99,但实际卖了 3 件 → 超卖!

⚠️ 集群部署的致命问题

如果你部署了 2 个 JVM 实例(负载均衡),那就有两块白板!实例 A 的白板和白板 B 互不可见。synchronized 只能锁当前 JVM 内的对象,跨 JVM 无效。

这就是为什么单机用 synchronized,集群就必须用 Redis 分布式锁。

面试金句 Java 并发的本质是多个线程同时访问 JVM 堆内存中的同一个共享对象(字段、集合、单例 Bean)。Spring 单例模式下,100 个 Tomcat 线程操作同一个 Service 实例的实例变量,就会产生并发问题。这与数据库层面的行锁并发(大家操作同一行磁盘数据)是完全不同的两个层面

2. synchronized 到底锁了什么?为什么能控制并发?

这一章讲透 synchronized 的底层原理:锁的不是"代码",锁的是"对象"。

先看一段最常见的 synchronized 代码

📁 inventory-service
📂 src/main/java/com/example
📂 service
📄 InventoryService.java
📂 controller
📄 OrderController.java
// Spring 默认单例!整个 JVM 只有一个 InventoryService 实例 @Service public class InventoryService { private int stock = 100; // 实例变量,存在堆内存中 // 100 个线程同时调用这个方法 public synchronized void deductStock() { if (stock > 0) { stock--; // 这一步不是原子操作!读 stock → 减 1 → 写 stock System.out.println("扣减成功,剩余: " + stock); } } } // ❓ synchronized 到底锁了什么? // 答:锁的是 this —— 也就是 InventoryService 这个对象实例 // ❓ 为什么锁住 this 就能保证 stock-- 的原子性? // 答:因为 stock 是 this 的实例字段,而 synchronized(this) 保证同一时间只有一个线程进入这个对象的方法

"锁住一个对象"是什么意思?—— 对象头 Mark Word

每个 Java 对象在堆内存中都有两部分:对象头(Mark Word)+ 实例数据(你写的字段)。

synchronized 做的事就是修改对象头 Mark Word里的一个标记位。具体过程:

线程1 进入 调用 deductStock()
JVM 执行 monitorenter 检查 this 对象的 Mark Word
Mark Word = 0? 空闲 → 改成线程1的ID
线程1 拿到锁 执行 stock--
线程2 进入 也要调用 deductStock()
JVM 执行 monitorenter 检查 this 对象的 Mark Word
Mark Word ≠ 0! 已被线程1占用
线程2 被挂起 进入等待队列

用一张图看清 synchronized 锁的位置

📁 JVM 堆内存
📂 Thread-1 (线程栈)
📄 栈帧: deductStock()
📂 Thread-2 (线程栈)
📄 栈帧: deductStock() [阻塞中]
📂 堆内存 (共享)
📄 InventoryService@0x7F3A
├ Mark Word = Thread-1
├ stock = 100 → 99
// JVM 堆内存中,InventoryService 只有一个实例(Spring 单例) // 地址: 0x7F3A // ┌──────────────────────────────────────────────┐ // │ InventoryService @0x7F3A │ // │ ┌──────────────────────────────────────┐ │ // │ │ 对象头 (Mark Word) │ │ // │ │ lock_flag = Thread-1 的 ID ← 锁标记│ │ // │ └──────────────────────────────────────┘ │ // │ ┌──────────────────────────────────────┐ │ // │ │ 实例数据 │ │ // │ │ stock = 99 ← 被保护 │ │ // │ └──────────────────────────────────────┘ │ // └──────────────────────────────────────────────┘ // Thread-1 进入 synchronized(this) 时: // → CPU 原子操作:CAS(Mark Word, 0, Thread-1-ID) // → 成功!Mark Word 从 0 变成 Thread-1-ID // → Thread-1 独占这个对象 // Thread-2 也要进入 synchronized(this) 时: // → 检查 Mark Word,发现不是 0 // → 进入等待队列(操作系统级别的线程阻塞)
专业定义 synchronized 锁的是对象,不是代码。JVM 在字节码层通过 monitorenter / monitorexit 指令实现:进入时 CAS 修改对象头的 Mark Word(锁标记),退出时恢复 Mark Word。JDK 6 后引入锁升级:无锁 → 偏向锁 → 轻量级锁(CAS 自旋)→ 重量级锁(OS 互斥量)。
⚠️ 集群部署下 synchronized 失效

2 个 JVM 实例 = 2 个独立的堆内存 = 2 个不同的 InventoryService 对象(地址不同,Mark Word 各自独立)。实例 A 的锁跟实例 B 的锁毫无关系。

这就是集群必须用 Redis 分布式锁的根本原因——Redis 是 JVM 外部的共享存储,所有实例都能看到同一把锁。

3. CAS 无锁并发:为什么 AtomicInteger 不用锁也能控制并发?

你说"如果并发类能满足需求,为什么不用锁?"——因为 CAS 比 synchronized 轻量得多。这一章讲透 CAS 的原理。

先看 AtomicInteger 怎么用的

📁 inventory-service
📂 src/main/java/com/example
📂 service
📄 StockCounter.java
📂 util
📄 ConcurrentUtils.java
import java.util.concurrent.atomic.AtomicInteger; @Service public class StockCounter { // 用 AtomicInteger 替代 int,不用 synchronized 也能安全递增! private AtomicInteger stock = new AtomicInteger(100); // 100 个线程同时调用,不会出错 public boolean deduct() { return stock.decrementAndGet() >= 0; } } // ❓ 为什么不用 synchronized 也能保证原子性? // 答:decrementAndGet() 内部用的是 CAS(Compare And Swap) // AtomicInteger 的源码(JDK): // private volatile int value; ← volatile 保证所有线程看到最新值 // // public final int decrementAndGet() { // return U.getAndAddInt(this, VALUE, -1) + ...; // } // // 底层调用了 Unsafe.getAndAddInt: // public final int getAndAddInt(Object o, long offset, int delta) { // int v; // do { // v = getIntVolatile(o, offset); // 1. 读当前值 // } while (!compareAndSwapInt(o, offset, v, v + delta)); // 2. CAS 自旋 // return v; // }

CAS 的完整过程(Compare And Swap)

CAS 就是"比较并交换"。三个关键参数:内存地址期望值(旧值)新值

第1步:读取 int v = value; → v=5
第2步:计算 新值 = v + 1 = 6
第3步:CAS 比较内存值是否还是 5
第4步:交换 是→改成 6;否→回到第1步

两个线程同时 CAS 会怎样?

⏱ T1: 线程A 读到 value=5

线程A 在自己的栈帧中保存 oldValue=5,计算 newValue=6

⏱ T2: 线程B 也读到 value=5

线程B 在自己的栈帧中保存 oldValue=5,计算 newValue=6

⏱ T3: 线程A 先执行 CAS(5, 6)

CPU 指令 cmpxchg:比较内存值 == 5?是 → 写入 6 → 返回成功

线程A 成功,value 现在是 6

⏱ T4: 线程B 执行 CAS(5, 6)

CPU 指令 cmpxchg:比较内存值 == 5?不是(已经是 6 了)→ 返回失败

线程B 的 CAS 失败了!

⏱ T5: 线程B 进入 do-while 自旋

回到循环开头,重新读取:v = 6(最新值)

计算新值 = 7

再次 CAS(6, 7) → 成功!

✅ 核心答案:CAS 为什么不需要锁?

因为 CPU 的 cmpxchg 指令本身就是硬件级原子操作。"比较内存值 + 写入新值"这两步在 CPU 层面是一条指令完成的,中间不可能被打断。

所以不需要像 synchronized 那样挂起线程、排队等待。线程失败了就重试(自旋),成功就直接写入。

类比:synchronized = 大家排队取号,一个人办的时候其他人只能等。CAS = 大家同时冲到柜台,只有第一个办成的成功,其他人被拒绝后重新冲,直到成功。

跟数据库乐观锁的对比(你已经理解的那个)

对比维度 数据库乐观锁 Java CAS(AtomicInteger)
共享的是什么 MySQL 里一行数据(带 version 字段) JVM 内存里的一个 volatile int value
怎么发现冲突 UPDATE ... WHERE version=5
影响行数=0 就知道被改了
cmpxchg 指令:比较内存值 ≠ 期望值
→ 返回 false
怎么处理冲突 重新查 → 重新计算 → 再次 UPDATE do-while 循环 → 重新读 → 再次 CAS
靠什么保证原子性 数据库的行锁(InnoDB) CPU 硬件指令 cmpxchg
适用场景 冲突少的场景(写少读多) 冲突少的场景(线程少、竞争不激烈)
专业定义 CAS(Compare And Swap)是一种无锁并发技术,依赖 CPU 的 cmpxchg 硬件指令保证"比较+交换"的原子性。线程失败后自旋重试而非阻塞挂起。缺点:高竞争下自旋浪费 CPU(ABA 问题可用 AtomicStampedReference 解决)。

4. Redis 分布式锁:为什么需要看门狗(Watchdog)自动续期?

你问的两个问题——"为什么要自动续期"和"代码里为什么没有体现"——这一章一起回答。

核心结论:锁设了过期时间,但业务执行可能超时。锁提前过期 → 别人也能加锁 → 并发问题。

看门狗(Watchdog)的作用:后台定时任务,每隔一段时间自动把锁的过期时间续上,直到你业务执行完毕手动 unlock

如果没有看门狗,会发生什么?

⏱ 0s — 线程A 加锁成功

SET lock:inventory:1 threadA-id NX EX 10

锁过期时间 = 10 秒,线程A 开始执行扣库存业务

⏱ 0~8s — 业务执行中(但很慢)

突然发生 Full GC 停顿 3 秒,或者调用的下游服务超时

⏱ 10s — 锁到期自动删除!

Redis 发现 key 过期了,自动删除

但线程A 的业务还没执行完!

⏱ 10.1s — 线程B 来了

线程B 执行 SET lock:inventory:1 threadB-id NX EX 10 → 成功!

线程B 也开始执行扣库存 → 两个线程同时执行了扣库存!

⏱ 11s — 线程A 终于执行完了

线程A 执行 finally { unlock() }

但此时锁是线程B 的!线程A 把线程B 的锁给删了!

(如果不加 Lua 脚本验证的话)

⏱ 12s — 线程C 来了

锁又被删了,线程C 加锁成功 → 超卖!

方案对比:手动 SET NX EX vs Redisson(看门狗)

你的 backend-java.html 资料里用的是方案A:手动 SET NX EX,所以没有体现看门狗代码。面试时要知道两种方案的区别:

📁 inventory-service
📂 src/main/java/com/example
📂 service
📄 InventoryService.java
📄 DistributedLockService.java
📂 config
📄 RedissonConfig.java
// ═══════════════════════════════════════════════════════ // 方案A:手动 SET NX EX(你的资料里用的就是这个) // 特点:没有看门狗,过期时间写死,业务超时就会出问题 // ═══════════════════════════════════════════════════════ public class DistributedLockService { @Autowired private StringRedisTemplate redis; public boolean tryLock(String key, long expireSeconds) { String value = Thread.currentThread().getName() + "-" + UUID.randomUUID(); Boolean ok = redis.opsForValue() .setIfAbsent(key, value, expireSeconds, TimeUnit.SECONDS); return ok != null && ok; } // ↑ 过期时间写死为 10 秒,如果业务执行超过 10 秒 → 锁提前释放! // 这就是"没有看门狗"的问题 } // ═══════════════════════════════════════════════════════ // 方案B:Redisson(面试重点) // 特点:自动看门狗续期,不需要你手动设过期时间 // ═══════════════════════════════════════════════════════ @Autowired private RedissonClient redisson; // Redisson 客户端 public void deductStockWithRedisson(Long goodsId) { RLock lock = redisson.getLock("lock:inventory:" + goodsId); try { // tryLock() 不传过期时间 → 启动看门狗! // 看门狗默认 30 秒过期,每 10 秒自动续期一次 if (lock.tryLock(0, TimeUnit.SECONDS)) { // 业务逻辑(不管执行多久,锁都不会提前过期) deductFromDB(goodsId); } } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); // unlock 后看门狗自动停止 } } } // ═══════════════════════════════════════════════════════ // Redisson 看门狗内部源码(简化版,你不需要写这个) // ═══════════════════════════════════════════════════════ // 1. tryLock 成功后,Redisson 内部启动一个 Netty 定时任务 // 2. 每 10 秒(= 过期时间 30s / 3)执行一次: // IF Redis 中这个 key 还属于当前线程 THEN // EXPIRE key 30 // 重新续期到 30 秒 // ELSE // 停止看门狗 // 锁已经不属于我了,停止续期 // END IF // 3. 业务执行完 lock.unlock() → 看门狗自动取消
面试金句 Redisson 看门狗(Watchdog)是 Redisson 对分布式锁的核心增强。当你调用 tryLock() 不指定 leaseTime 时,Redisson 自动启动后台定时任务,每隔 10 秒(= 默认 30s 过期时间 / 3)检查当前线程是否仍持有锁,是则 EXPIRE 续期到 30 秒。业务执行多久,锁就续多久,直到 unlock 后自动停止。解决了手动 SET NX EX 因业务超时导致锁提前释放的问题。
⚠️ 看门狗的两个坑

坑1:如果你调用 tryLock(waitTime, leaseTime, unit) 指定了 leaseTime,看门狗不会启动!必须不传 leaseTime 或传 -1 才启动。

坑2:unlock 前必须检查 isHeldByCurrentThread(),否则可能误删别人的锁(业务超时锁被续期失败后自动释放了)。

5. 三种并发控制方式的横向对比

synchronized / CAS / Redis 分布式锁,一张表看懂什么时候用什么。

维度 synchronized CAS(AtomicInteger / 并发集合) Redis 分布式锁
锁的范围 单个 JVM 内的对象 JVM 内的单个变量 跨所有 JVM 实例(全局)
靠什么保证原子性 JVM monitorenter/exit + 对象头 Mark Word CPU 硬件指令 cmpxchg Redis 单线程 SET NX EX
线程如何等待 阻塞挂起(OS 级别,重量级) 不阻塞,自旋重试(CPU 空转) 自己写重试逻辑或 sleep
并发争夺的是什么 JVM 堆中同一个对象 JVM 堆中同一个 volatile 变量 Redis 中同一个 key
集群是否有效 ❌ 无效(各 JVM 锁独立) ❌ 无效(各 JVM 变量独立) ✅ 有效(Redis 是共享的外部存储)
性能开销 中(线程阻塞/唤醒有成本) 低(无阻塞,但高竞争下自旋浪费 CPU) 高(网络 IO + Redis 操作)
适用场景 单 JVM 复杂逻辑互斥 简单计数/状态标志/并发集合 多 JVM 集群部署
类比 公共厕所(一人用,其他人排队等) 抢答器(大家同时按,只有一个有效) 大堂公告栏(所有人都能看到同一份信息)
💡 决策建议

能用 CAS 解决 → 不要用 synchronized(比如 AtomicInteger 替代 synchronized int++)

能用 synchronized 解决 → 不要用分布式锁(比如单 JVM 内的缓存操作)

集群部署 + 共享资源 → 必须用分布式锁(synchronized 在集群下失效)

6. 面试速答:30 秒回答版

面试官问这三个问题时,你怎么在 30 秒内回答清楚。

Q1: "synchronized 锁的是什么?"

30秒回答:synchronized 锁的是对象,不是代码块。底层通过 JVM 的 monitorenter/monitorexit 指令,修改对象头的 Mark Word来实现互斥。Spring 单例模式下,100 个线程同时调用同一个 Bean 的 synchronized 方法,就是竞争这一个对象实例的 Mark Word。集群部署下每个 JVM 是独立的堆内存,对象地址不同,所以 synchronized 在集群下无效。

Q2: "CAS 为什么不需要加锁?"

30秒回答:CAS(Compare And Swap)依赖 CPU 的 cmpxchg 硬件指令,"比较内存值 + 写入新值"在 CPU 层面是一条原子指令,中间不可能被打断。线程先读旧值,计算新值,然后用 CAS 尝试写入。如果内存值已被其他线程改了,CAS 返回失败,线程自旋重试(回到循环开头重新读值),不需要像 synchronized 那样挂起阻塞。本质和数据库乐观锁一样,只是数据库靠 WHERE version=x,Java CAS 靠 CPU 指令。

Q3: "Redis 分布式锁为什么要看门狗续期?"

30秒回答:锁设了过期时间(比如 10 秒),但业务执行可能超过 10 秒(GC 停顿、网络超时等)。锁提前过期后,其他线程就能加锁成功,导致并发问题。Redisson 的看门狗在后台每 10 秒(= 默认 30s 过期时间的 1/3)自动把锁续期到 30 秒,直到业务执行完毕手动 unlock 后停止。注意:只有调用 tryLock() 不传 leaseTime 时才会启动看门狗;如果手动指定了过期时间,看门狗不会启动。

终极总结 数据库并发争夺的是磁盘上的行数据(用行锁/乐观锁保证)。Java 代码并发争夺的是JVM 堆内存中的对象(用 synchronized 修改对象头 Mark Word、用 CAS 操作 volatile 变量)。集群部署下各 JVM 内存独立,本地锁失效,必须用 Redis 分布式锁——因为 Redis 是所有实例共享的外部存储,等于在 JVM 外面放了一把所有人都能看到的锁。
↑ 回到顶部

Java 并发底层原理详解 — 配套 backend-java.html s2 章节深度理解