Java并发手撕题详解:原理、实现与面试避坑指南

🔥你好我是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 优化与扩展

线程安全优化

  • 简单实现:在getput方法上加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. 先理思路再写代码:面试时不要急于动笔,先向面试官说明你的解题思路,确认理解正确后再开始写代码。
  2. 明确边界条件:提前考虑各种边界情况,如缓存容量为 1、线程数量为 1、打印到最后一个数等。
  3. 选择合适的并发机制 :短时间的锁竞争适合用 CAS,复杂的同步场景适合用synchronizedReentrantLock
  4. 代码规范:使用驼峰命名法,添加必要的注释,保持代码结构清晰。
  5. 边写边解释:写代码的同时向面试官解释每一步的作用,展示你的思考过程。

5.2 常见坑点总结

  1. 虚假唤醒 :永远使用while循环检查等待条件,不要用if语句。
  2. 可见性问题 :共享变量必须用volatile修饰,或者在锁内访问。
  3. 死锁问题 :避免多个线程互相等待对方释放锁,多线程协调时优先使用notifyAll
  4. 资源泄漏:删除元素时不要忘记从 HashMap 中移除对应的键值对,防止内存泄漏。
  5. 异常处理 :必须处理InterruptedException,并恢复线程的中断状态。

5.3 面试加分项

  • 指出基础实现的不足,并提出优化方案
  • 讲解技术背后的底层原理,如 CAS 的硬件实现、synchronized 的锁升级过程
  • 结合实际项目经验,说明这些技术在生产环境中的应用
  • 主动扩展问题,如如何实现公平的自旋锁、如何实现 LRU-K 淘汰策略

结语

本文详解了 Java 并发面试中 4 道最高频的手撕题,从底层原理到代码实现,覆盖了 CAS、线程通信、数据结构等核心知识点。 掌握这些题目不仅能帮助你顺利通过面试,更能加深对并发编程的理解,提升实际开发能力。 进阶学习可以深入研究 JUC 包的源码,了解 AQS、线程池、并发容器等高级特性,构建完整的并发编程知识体系。

相关推荐
IT利刃出鞘1 天前
Java多线程--三种写法(Thread、Runnable、Callable)
java·多线程
MC皮蛋侠客2 天前
C++17 多线程系列(十):多线程性能优化——从测量到调优
c++·多线程
MC皮蛋侠客4 天前
C++17 多线程系列(五):C++17 并行算法——从串行到并行的零成本迁移
c++·多线程
西凉的悲伤5 天前
Spring Boot 中 @Async(value = “alertThreadPool“) 是什么?为什么企业项目喜欢自定义线程池?
spring boot·多线程·async·异步
fengxin_rou6 天前
【从零开始的JUC并发第四章】:JUC常用工具类
并发·juc
better_liang7 天前
每日Java面试场景题知识点之-JUC并发编程核心原理与实战
java·线程池·并发编程·juc·aqs·reentrantlock·concurrenthashmap
fengxin_rou7 天前
【juc第三章】:AQS机制全解
juc
fengxin_rou8 天前
【JUC第二章下】:锁机制&关键字
架构·事务·cas·juc·volatile