🔥你好我是fengxin_rou这是我的个人主页 fengxin_rou的主页
❄️欢迎查看我的专栏我的专栏
《Java后端学习》、《JAVASE基础》、《JUC并发》、《redis》、《JVM虚拟机》、《MYSQL》、《黑马点评》、《rabbitmq》、《JavaWeb+AI的talis学习系统》、《苍穹外卖》

目录
[一、Java 并发手撕题的考察核心与价值](#一、Java 并发手撕题的考察核心与价值)
[二、CAS 自旋锁:无锁并发的底层实现](#二、CAS 自旋锁:无锁并发的底层实现)
[2.1 CAS 原理与核心组件](#2.1 CAS 原理与核心组件)
[2.2 CAS 自旋锁的手写实现](#2.2 CAS 自旋锁的手写实现)
[2.3 优缺点与面试扩展](#2.3 优缺点与面试扩展)
[3.1 线程通信基础:synchronized+wait/notify](#3.1 线程通信基础:synchronized+wait/notify)
[3.2 两线程交替打印 0-100(奇偶分离)](#3.2 两线程交替打印 0-100(奇偶分离))
[3.3 三线程顺序打印 ABC](#3.3 三线程顺序打印 ABC)
[四、LRU 缓存:数据结构与并发设计](#四、LRU 缓存:数据结构与并发设计)
[4.1 LRU 原理与数据结构选择](#4.1 LRU 原理与数据结构选择)
[4.2 LRU 缓存的手写实现](#4.2 LRU 缓存的手写实现)
[4.3 优化与扩展](#4.3 优化与扩展)
[5.1 通用解题技巧](#5.1 通用解题技巧)
[5.2 常见坑点总结](#5.2 常见坑点总结)
[5.3 面试加分项](#5.3 面试加分项)
前言
Java 并发编程是后端开发的核心能力,也是大厂面试的必考点。 手写实现类题目能直接考察开发者对底层原理的理解和代码功底。 本文详解 4 道最高频并发手撕题,从原理到实现,全面覆盖面试考点。
一、Java 并发手撕题的考察核心与价值
在多核 CPU 时代,并发编程是充分利用硬件资源、提升系统吞吐量的关键技术。 面试中的并发手撕题并非考察代码背诵能力,而是验证开发者对原子性、可见性、有序性三大并发特性的理解程度。
本文覆盖的 4 道题目是近三年 Java 后端面试中出现频率最高的手撕题:
- CAS 自旋锁:考察无锁编程思想和 Unsafe 类的底层应用
- 两线程交替打印奇偶:考察 synchronized 锁与 wait/notify 线程通信机制
- 三线程顺序打印 ABC:考察多线程协调与执行顺序控制
- 手写 LRU 缓存:考察数据结构设计与并发安全基础
这些题目不仅是面试的敲门砖,其背后的技术原理更是 JUC 并发包、缓存系统、分布式协调等核心技术的基础。掌握这些题目,能帮助开发者建立扎实的并发编程思维,提升实际开发中的问题解决能力。
二、CAS 自旋锁:无锁并发的底层实现
2.1 CAS 原理与核心组件
CAS(Compare And Swap,比较并交换)是一种无锁原子操作,通过硬件指令保证操作的原子性。 其核心逻辑是:先比较内存中的值与期望值是否相等,若相等则更新为新值,否则不做任何操作。
Java 中 CAS 操作依赖Unsafe类实现,该类提供了一系列 native 方法直接操作内存。 同时,共享变量必须用volatile修饰,保证其内存可见性,防止指令重排序导致的线程安全问题。
2.2 CAS 自旋锁的手写实现
以下是基于 CAS 实现的基础自旋锁代码,修正了原笔记中的命名规范问题:
java
import sun.misc.Unsafe;
import java.lang.reflect.Field;
/**
* CAS自旋锁基础实现
* 原理:通过无限循环尝试CAS修改状态变量,成功则获取锁
*/
public class CasSpinLock {
// 锁状态:0表示未锁定,1表示已锁定
private volatile int state;
// Unsafe实例,用于执行CAS操作
private static final Unsafe unsafe;
// state变量在对象中的偏移量
private static final long stateOffset;
static {
try {
// 通过反射获取Unsafe类的单例实例
Field field = Unsafe.class.getDeclaredField("theUnsafe");
field.setAccessible(true);
unsafe = (Unsafe) field.get(null);
// 获取state变量的内存偏移量
stateOffset = unsafe.objectFieldOffset(
CasSpinLock.class.getDeclaredField("state")
);
} catch (NoSuchFieldException | IllegalAccessException e) {
throw new RuntimeException("Unsafe初始化失败", e);
}
}
/**
* 获取锁:自旋直到CAS成功
*/
public void lock() {
// 无限循环(自旋)尝试获取锁
for (;;) {
// CAS操作:比较当前state是否为0,若是则改为1
if (unsafe.compareAndSwapInt(this, stateOffset, 0, 1)) {
break;
}
}
}
/**
* 释放锁:直接将state置为0
* 由于state是volatile变量,写操作会立即刷新到主内存
*/
public void unlock() {
state = 0;
}
}
2.3 优缺点与面试扩展
优点 :无阻塞,避免了线程上下文切换的开销,在锁竞争不激烈的场景下性能优异。 缺点:不可重入(同一线程多次获取锁会导致死锁)、非公平(等待时间长的线程可能无法优先获取锁)、存在 ABA 问题、长时间自旋会消耗大量 CPU 资源。
面试常见扩展:
- 可重入 CAS 自旋锁:添加线程标识和重入计数器
- ABA 问题解决:使用带版本号的 CAS 操作(
AtomicStampedReference) - 自适应自旋:根据历史自旋次数动态调整自旋时间
三、线程通信经典题:交替打印与顺序执行
3.1 线程通信基础:synchronized+wait/notify
Java 中线程间通信主要通过wait()、notify()和notifyAll()方法实现,这些方法必须在synchronized同步块中调用。
wait():释放当前持有的锁,使线程进入等待状态notify():随机唤醒一个等待该锁的线程notifyAll():唤醒所有等待该锁的线程
重要注意事项 :必须使用while循环检查等待条件,而不是if语句。 这是为了防止虚假唤醒 ------ 线程可能在没有被notify()的情况下被唤醒,此时条件可能仍不满足,需要重新检查。
3.2 两线程交替打印 0-100(奇偶分离)
需求:两个线程交替打印 0 到 100 的整数,一个线程只打印奇数,另一个线程只打印偶数。
java
/**
* 两线程交替打印奇偶数字
* 核心:通过共享计数器和对象锁实现线程同步
*/
public class PrintOddEven {
// 共享计数器
private int count = 0;
// 锁对象:必须是引用类型,才能调用wait/notify方法
private final Object lock = new Object();
/**
* 打印奇数的方法
*/
public void printOdd() {
while (count <= 100) {
synchronized (lock) {
// 等待count变为奇数
while (count % 2 == 0) {
try {
lock.wait();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("线程被中断", e);
}
}
// 二次检查边界条件,防止count超过100
if (count > 100) {
lock.notify();
return;
}
System.out.println(Thread.currentThread().getName() + ": " + count);
count++;
// 唤醒等待的偶数线程
lock.notify();
}
}
}
/**
* 打印偶数的方法
*/
public void printEven() {
while (count <= 100) {
synchronized (lock) {
// 等待count变为偶数
while (count % 2 != 0) {
try {
lock.wait();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("线程被中断", e);
}
}
// 二次检查边界条件
if (count > 100) {
lock.notify();
return;
}
System.out.println(Thread.currentThread().getName() + ": " + count);
count++;
// 唤醒等待的奇数线程
lock.notify();
}
}
}
public static void main(String[] args) {
PrintOddEven printer = new PrintOddEven();
new Thread(printer::printOdd, "奇数线程").start();
new Thread(printer::printEven, "偶数线程").start();
}
}
关键坑点说明:
- 边界条件检查必须放在
wait()之后,因为当 count=100 时,线程可能从wait()位置被唤醒,不会执行循环开头的判断 - 必须处理
InterruptedException,并恢复线程的中断状态 - 每次打印完成后必须调用
notify()唤醒对方线程
3.3 三线程顺序打印 ABC
需求:三个线程按顺序打印 A、B、C,循环执行指定次数。
java
运行
java
/**
* 三线程顺序打印ABC
* 核心:通过共享标志位控制执行顺序
*/
public class PrintABC {
// 锁对象
private final Object lock = new Object();
// 执行标志:1表示打印A,2表示打印B,3表示打印C
private int flag = 1;
// 循环打印次数
private static final int PRINT_TIMES = 10;
/**
* 打印A的方法
*/
public void printA() {
for (int i = 0; i < PRINT_TIMES; i++) {
synchronized (lock) {
// 等待flag变为1
while (flag != 1) {
try {
lock.wait();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("线程被中断", e);
}
}
System.out.print("A");
// 修改标志位,允许B线程执行
flag = 2;
// 唤醒所有等待线程
lock.notifyAll();
}
}
}
/**
* 打印B的方法
*/
public void printB() {
for (int i = 0; i < PRINT_TIMES; i++) {
synchronized (lock) {
// 等待flag变为2
while (flag != 2) {
try {
lock.wait();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("线程被中断", e);
}
}
System.out.print("B");
// 修改标志位,允许C线程执行
flag = 3;
lock.notifyAll();
}
}
}
/**
* 打印C的方法
*/
public void printC() {
for (int i = 0; i < PRINT_TIMES; i++) {
synchronized (lock) {
// 等待flag变为3
while (flag != 3) {
try {
lock.wait();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("线程被中断", e);
}
}
System.out.println("C");
// 修改标志位,允许A线程执行
flag = 1;
lock.notifyAll();
}
}
}
public static void main(String[] args) {
PrintABC printer = new PrintABC();
new Thread(printer::printA, "A线程").start();
new Thread(printer::printB, "B线程").start();
new Thread(printer::printC, "C线程").start();
}
}
关键设计说明:
- 使用
notifyAll()而不是notify(),因为notify()只能随机唤醒一个线程,可能唤醒的不是下一个需要执行的线程,导致死锁 - 通过循环控制打印次数,避免无限循环
- 标志位的修改必须在同步块内完成,保证可见性和原子性
四、LRU 缓存:数据结构与并发设计
4.1 LRU 原理与数据结构选择
LRU(Least Recently Used,最近最少使用)是一种经典的缓存淘汰策略。 当缓存容量达到上限时,会淘汰最久未被访问的元素。
为了实现高效的 LRU 缓存,需要同时满足以下操作的 O (1) 时间复杂度:
- 查找元素:使用 HashMap 实现
- 插入元素:使用双向链表实现头部插入
- 删除元素:使用双向链表实现 O (1) 删除
数据结构设计:
- HashMap:存储键到节点的映射,实现快速查找
- 双向链表:维护元素的访问顺序,最近访问的元素放在头部,最久未访问的放在尾部
4.2 LRU 缓存的手写实现
以下是基础 LRU 缓存的实现,修正了原笔记中的构造方法名错误:
java
import java.util.HashMap;
/**
* LRU缓存实现
* 核心:HashMap + 双向链表
*/
public class LRUCache {
/**
* 双向链表节点
*/
static class Node {
int key;
int value;
Node prev;
Node next;
public Node(int key, int value) {
this.key = key;
this.value = value;
}
}
// 头节点和尾节点(哨兵节点,简化边界处理)
private final Node head;
private final Node tail;
// 键到节点的映射
private final HashMap<Integer, Node> map;
// 缓存容量
private final int capacity;
// 当前缓存大小
private int size;
/**
* 构造方法:初始化缓存
* @param capacity 缓存容量
*/
public LRUCache(int capacity) {
this.capacity = capacity;
this.map = new HashMap<>(capacity);
this.size = 0;
// 初始化哨兵节点
head = new Node(0, 0);
tail = new Node(0, 0);
head.next = tail;
tail.prev = head;
}
/**
* 获取缓存值
* @param key 键
* @return 值,不存在返回-1
*/
public int get(int key) {
Node node = map.get(key);
if (node == null) {
return -1;
}
// 将访问的节点移动到头部,表示最近使用
moveToHead(node);
return node.value;
}
/**
* 存入缓存值
* @param key 键
* @param value 值
*/
public void put(int key, int value) {
Node node = map.get(key);
if (node != null) {
// 键已存在,更新值并移动到头部
node.value = value;
moveToHead(node);
return;
}
// 键不存在,创建新节点
Node newNode = new Node(key, value);
map.put(key, newNode);
addToHead(newNode);
size++;
// 超过容量,删除最久未使用的节点
if (size > capacity) {
Node removeNode = removeTail();
map.remove(removeNode.key);
size--;
}
}
/**
* 将节点添加到链表头部
*/
private void addToHead(Node node) {
node.next = head.next;
head.next.prev = node;
head.next = node;
node.prev = head;
}
/**
* 将节点移动到链表头部
*/
private void moveToHead(Node node) {
removeNode(node);
addToHead(node);
}
/**
* 删除指定节点
*/
private void removeNode(Node node) {
node.prev.next = node.next;
node.next.prev = node.prev;
}
/**
* 删除链表尾部节点(最久未使用)
* @return 被删除的节点
*/
private Node removeTail() {
Node res = tail.prev;
removeNode(res);
return res;
}
}
4.3 优化与扩展
线程安全优化:
- 简单实现:在
get和put方法上加synchronized锁 - 高性能实现:使用
ConcurrentHashMap替代HashMap,并对链表操作加细粒度锁
JDK 内置实现 : JDK 提供了LinkedHashMap类,其本身就实现了 LRU 顺序。 通过重写removeEldestEntry方法,可以轻松实现 LRU 缓存:
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 基于LinkedHashMap的LRU缓存实现
*/
public class LinkedHashMapLRUCache<K, V> extends LinkedHashMap<K, V> {
private final int capacity;
public LinkedHashMapLRUCache(int capacity) {
// accessOrder设为true,表示按访问顺序排序
super(capacity, 0.75f, true);
this.capacity = capacity;
}
/**
* 当缓存大小超过容量时,返回true,删除最久未使用的元素
*/
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
return size() > capacity;
}
}
应用场景:
- Redis 的 LRU 淘汰策略
- MySQL 的 InnoDB 缓冲池
- 本地缓存框架(如 Caffeine)
五、面试手撕题通用技巧与避坑指南
5.1 通用解题技巧
- 先理思路再写代码:面试时不要急于动笔,先向面试官说明你的解题思路,确认理解正确后再开始写代码。
- 明确边界条件:提前考虑各种边界情况,如缓存容量为 1、线程数量为 1、打印到最后一个数等。
- 选择合适的并发机制 :短时间的锁竞争适合用 CAS,复杂的同步场景适合用
synchronized或ReentrantLock。 - 代码规范:使用驼峰命名法,添加必要的注释,保持代码结构清晰。
- 边写边解释:写代码的同时向面试官解释每一步的作用,展示你的思考过程。
5.2 常见坑点总结
- 虚假唤醒 :永远使用
while循环检查等待条件,不要用if语句。 - 可见性问题 :共享变量必须用
volatile修饰,或者在锁内访问。 - 死锁问题 :避免多个线程互相等待对方释放锁,多线程协调时优先使用
notifyAll。 - 资源泄漏:删除元素时不要忘记从 HashMap 中移除对应的键值对,防止内存泄漏。
- 异常处理 :必须处理
InterruptedException,并恢复线程的中断状态。
5.3 面试加分项
- 指出基础实现的不足,并提出优化方案
- 讲解技术背后的底层原理,如 CAS 的硬件实现、synchronized 的锁升级过程
- 结合实际项目经验,说明这些技术在生产环境中的应用
- 主动扩展问题,如如何实现公平的自旋锁、如何实现 LRU-K 淘汰策略
结语
本文详解了 Java 并发面试中 4 道最高频的手撕题,从底层原理到代码实现,覆盖了 CAS、线程通信、数据结构等核心知识点。 掌握这些题目不仅能帮助你顺利通过面试,更能加深对并发编程的理解,提升实际开发能力。 进阶学习可以深入研究 JUC 包的源码,了解 AQS、线程池、并发容器等高级特性,构建完整的并发编程知识体系。
