一、为什么我们必须搞懂并发编程?
很多人会问:"我就是个写业务 CRUD 的,平时很少写多线程代码,学并发有什么用?" 我给你三个无法拒绝的理由:
1. 解决线上核心故障
Java 服务线上 80% 的诡异问题,都和并发相关。超卖、重复数据、脏读、服务卡死、CPU 飙升,这些问题的根因几乎都在并发层面。不懂并发,你连问题出在哪都找不到,更别说解决了。
2. 充分利用服务器性能
现在的服务器都是多核 CPU,单线程程序只能利用一个核心,多核的性能完全被浪费了。而合理的并发编程,能让你的服务吞吐量提升数倍。
3. 主流框架的底层基础
你每天用的 Spring、MyBatis、Tomcat、Redis 客户端、消息队列,底层全是并发编程。不懂并发,你永远只能停留在 "用框架" 的层面,出了问题根本不知道怎么排查。
4. 面试晋升的硬门槛
不管是初级升中级,还是中级升高级,并发编程都是 Java 面试必问的核心内容。不懂并发,你永远只能停留在 "业务开发" 的舒适区,很难突破职业瓶颈。
二、线程安全的本质:三大特性与 JMM 内存模型
所有的并发问题,根源都来自于 Java 内存模型(JMM)的三大特性:原子性、可见性、有序性。线程安全的代码,必须同时保证这三个特性。
2.1 什么是 JMM 内存模型?
Java 内存模型(Java Memory Model)是 Java 虚拟机规范中定义的,用来解决多线程环境下,CPU 缓存、寄存器和主内存之间的数据同步问题。
简单来说,每个线程都有自己的工作内存(对应 CPU 的缓存和寄存器),所有的变量都存储在主内存中。线程对变量的所有操作,都必须在工作内存中进行,不能直接读写主内存。不同线程之间也无法直接访问对方的工作内存,线程间的变量传递必须通过主内存来完成。
plaintext
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ 线程A工作内存 │ │ 线程B工作内存 │ │ 线程C工作内存 │ └───────┬─────┘ └───────┬─────┘ └───────┬─────┘ │ │ │ └────────────────────┼────────────────────┘ ▼ ┌─────────────────┐ │ 主内存 │ └─────────────────┘这个模型带来了三个核心问题,也就是并发编程的三大特性。
2.2 原子性
原子性:一个操作是不可分割的,要么全部执行成功,要么全部不执行,执行过程中不会被其他线程打断。
比如经典的count++操作,看起来是一行代码,实际上分为 3 步:
- 从主内存读取 count 的值到工作内存
- 在工作内存中对 count 进行 + 1 操作
- 将计算后的结果写回主内存
在多线程环境下,这三个步骤随时可能被其他线程打断,导致最终的结果不符合预期。这就是原子性问题,也是最常见的并发问题。
2.3 可见性
可见性:一个线程修改了共享变量的值,其他线程能够立即看到这个修改。
在 JMM 模型中,线程修改的是自己工作内存中的变量副本,什么时候把这个修改同步到主内存,是不确定的。这就会导致一个线程修改了变量,另一个线程看不到,最终引发线程安全问题。
举个最简单的例子:
java
运行
public class VisibilityDemo { private static boolean flag = true; public static void main(String[] args) throws InterruptedException { new Thread(() -> { while (flag) { // 空循环 } System.out.println("线程感知到flag变化,退出循环"); }).start(); Thread.sleep(1000); flag = false; System.out.println("主线程已经将flag设置为false"); } }这段代码在大多数情况下,子线程永远不会退出循环。因为主线程修改的flag值,没有被及时同步到子线程的工作内存中,子线程看不到这个变化。这就是典型的可见性问题。
2.4 有序性
有序性:程序执行的顺序,按照代码的先后顺序执行。
为了提升性能,编译器和 CPU 会对指令进行重排序。重排序不会影响单线程的执行结果,但在多线程环境下,会导致意想不到的问题。
最经典的例子就是单例模式的双重检查锁(DCL)问题:
java
运行
public class Singleton { private static Singleton instance; private Singleton() {} public static Singleton getInstance() { if (instance == null) { // 第一次检查 synchronized (Singleton.class) { if (instance == null) { // 第二次检查 instance = new Singleton(); // 问题出在这里 } } } return instance; } }instance = new Singleton()这行代码,实际上分为 3 步:
- 为 Singleton 对象分配内存空间
- 初始化 Singleton 对象
- 将 instance 引用指向分配的内存地址
编译器和 CPU 可能会对这 3 步进行重排序,变成 1→3→2。这就会导致一个线程执行了 1 和 3,还没执行 2 的时候,另一个线程进来,发现 instance 已经不为 null 了,直接返回一个未初始化完成的对象,最终引发空指针异常。这就是有序性问题。
三、Java 并发编程的核心解决方案
Java 提供了一套完整的工具,来解决三大特性带来的线程安全问题。下面我从基础到进阶,逐一讲解最常用的解决方案,以及它们的适用场景和优缺点。
3.1 基础关键字:volatile 与 synchronized
1. volatile 关键字
volatile是 Java 提供的最轻量级的同步机制,它能解决可见性和有序性问题,但不能解决原子性问题。
volatile的核心作用:
- 保证可见性:对 volatile 变量的修改,会立即同步到主内存;每次读取 volatile 变量,都会从主内存重新加载
- 禁止指令重排序:通过内存屏障,禁止编译器和 CPU 对指令进行重排序
适用场景:
- 状态标记变量:比如上面例子中的
flag,用 volatile 修饰就能解决可见性问题 - 双重检查锁单例模式:给 instance 变量加上 volatile,就能禁止指令重排序,解决 DCL 问题
正确示例:
java
运行
// 状态标记 private volatile boolean flag = true; // 安全的DCL单例 public class Singleton { private static volatile Singleton instance; private Singleton() {} public static Singleton getInstance() { if (instance == null) { synchronized (Singleton.class) { if (instance == null) { instance = new Singleton(); } } } return instance; } }注意:volatile 不能解决原子性问题,比如volatile int count; count++依然是线程不安全的。
2. synchronized 关键字
synchronized是 Java 最基础的锁机制,它能同时保证原子性、可见性、有序性,是解决线程安全问题的 "万能钥匙"。
synchronized的使用方式:
java
运行
// 1. 修饰实例方法:锁的是当前对象this public synchronized void add() { count++; } // 2. 修饰静态方法:锁的是当前类的Class对象 public static synchronized void staticAdd() { staticCount++; } // 3. 修饰代码块:锁的是括号里的对象 public void add() { synchronized (this) { count++; } }锁的升级过程:很多人觉得 synchronized 性能差,其实从 JDK1.6 开始,JVM 对 synchronized 做了大量优化,引入了锁升级机制,性能已经和 ReentrantLock 相差无几。
锁的升级流程:无锁 → 偏向锁 → 轻量级锁 → 重量级锁
- 偏向锁:只有一个线程访问同步块时,锁会偏向这个线程,避免多次加锁解锁的开销
- 轻量级锁:多个线程交替访问同步块时,使用 CAS 操作加锁,不会阻塞线程
- 重量级锁:多个线程同时竞争锁时,锁升级为重量级锁,未抢到锁的线程会被阻塞
优点:
- 使用简单,不需要手动释放锁,JVM 会自动释放
- 不会出现死锁问题(发生异常时 JVM 会自动释放锁)
- JVM 内置优化,性能优秀
缺点:
- 不支持手动中断锁等待
- 不支持尝试非阻塞获取锁
- 不支持公平锁
- 是独占锁,读多写少的场景下性能不佳
3.2 JUC 显式锁:Lock 体系
JDK1.5 引入了 java.util.concurrent(简称 JUC)包,提供了更灵活的显式锁体系,核心接口是Lock,最常用的实现类是ReentrantLock和ReentrantReadWriteLock。
1. ReentrantLock 可重入锁
ReentrantLock和synchronized一样,是可重入的独占锁,但它提供了更灵活的功能。
核心用法:
java
运行
private final Lock lock = new ReentrantLock(); private int count; public void add() { lock.lock(); // 加锁 try { count++; } finally { lock.unlock(); // 必须在finally中释放锁,否则会发生死锁 } }ReentrantLock 的高级功能:
- 支持公平锁:
new ReentrantLock(true)可以创建公平锁,先等待的线程先获取锁 - 支持非阻塞尝试获取锁:
tryLock()方法,获取不到锁立即返回,不会阻塞 - 支持可中断的锁等待:
lockInterruptibly()方法,等待过程中可以被中断 - 支持多个等待条件:
newCondition()方法,可以创建多个条件队列,实现更精细的线程协作
和 synchronized 的对比:
表格
| 特性 | synchronized | ReentrantLock |
|---|---|---|
| 可重入 | 支持 | 支持 |
| 公平锁 | 不支持 | 支持 |
| 非阻塞获取锁 | 不支持 | 支持 |
| 可中断 | 不支持 | 支持 |
| 多个条件队列 | 不支持 | 支持 |
| 自动释放锁 | 支持 | 不支持,必须手动释放 |
| 异常处理 | 自动释放锁 | 必须在 finally 中释放 |
适用场景:需要更灵活的锁控制,比如尝试获取锁、超时获取锁、公平锁、多条件队列的场景。
2. ReentrantReadWriteLock 读写锁
读写锁是为了解决读多写少的场景而设计的。它维护了两个锁:读锁(共享锁)和写锁(独占锁)。
- 读锁:多个线程可以同时获取读锁,读读不互斥
- 写锁:只有一个线程能获取写锁,读写、写写都互斥
核心用法:
java
运行
private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock(); private final Lock readLock = rwLock.readLock(); private final Lock writeLock = rwLock.writeLock(); private Map<String, Object> data = new HashMap<>(); // 读操作,加读锁 public Object get(String key) { readLock.lock(); try { return data.get(key); } finally { readLock.unlock(); } } // 写操作,加写锁 public void put(String key, Object value) { writeLock.lock(); try { data.put(key, value); } finally { writeLock.unlock(); } }适用场景:读多写少的场景,比如缓存、配置管理、元数据存储等,能大幅提升并发性能。
3.3 原子类:无锁编程解决原子性问题
对于简单的数值递增、递减操作,用锁会带来额外的开销。JUC 包提供了一系列原子类,基于 CAS(Compare And Swap)操作实现无锁的原子性保证,性能比锁高很多。
常用的原子类:
- 基本类型原子类:
AtomicInteger、AtomicLong、AtomicBoolean - 数组原子类:
AtomicIntegerArray、AtomicLongArray - 引用原子类:
AtomicReference、AtomicStampedReference - 高并发累加器:
LongAdder、DoubleAdder
核心用法:
java
运行
// AtomicInteger示例 private AtomicInteger count = new AtomicInteger(0); public void add() { count.incrementAndGet(); // 原子性+1,相当于count++ } // LongAdder示例,高并发下性能比AtomicLong更好 private LongAdder sum = new LongAdder(); public void add(long x) { sum.add(x); }CAS 的原理:CAS 操作包含三个参数:内存地址 V、旧的预期值 A、新值 B。只有当 V 的值等于 A 时,才会将 V 的值更新为 B,否则什么都不做。整个操作是原子性的,由 CPU 指令保证。
CAS 是无锁编程的核心,它不会阻塞线程,在并发量不高的场景下,性能远高于锁。
CAS 的缺点:
- ABA 问题:一个值从 A 变成 B,又变回 A,CAS 会认为它没有变化。可以用
AtomicStampedReference加版本号解决 - 循环时间长开销大:高并发下,大量线程同时 CAS,会导致多次重试,CPU 开销大。可以用
LongAdder解决 - 只能保证一个变量的原子性:多个变量的原子操作,还是需要用锁
3.4 线程池:解决多线程的资源管理问题
很多人写多线程代码,喜欢直接new Thread(),这是非常错误的做法。线程的创建和销毁都有很大的开销,无限制的创建线程会导致内存溢出、CPU 占用过高。
线程池的核心作用就是统一管理线程,实现线程的复用,控制最大并发数,避免资源耗尽。
线程池的核心参数
java
运行
public ThreadPoolExecutor( int corePoolSize, // 核心线程数 int maximumPoolSize, // 最大线程数 long keepAliveTime, // 非核心线程的空闲存活时间 TimeUnit unit, // 时间单位 BlockingQueue<Runnable> workQueue, // 任务等待队列 ThreadFactory threadFactory, // 线程创建工厂 RejectedExecutionHandler handler // 拒绝策略 )核心参数说明:
- 核心线程数:线程池中一直保持存活的线程数,即使空闲也不会被销毁
- 最大线程数:线程池允许的最大线程数量
- 等待队列:当核心线程都在忙碌时,新任务会进入等待队列排队
- 拒绝策略:当等待队列满了,且达到最大线程数时,新任务会被拒绝,有 4 种内置策略:
- AbortPolicy:直接抛出异常,默认策略
- CallerRunsPolicy:用调用者所在的线程来执行任务
- DiscardOldestPolicy:丢弃队列中最老的任务,执行当前任务
- DiscardPolicy:直接丢弃当前任务,不抛出异常
线程池的正确创建方式
绝对不要用 Executors 创建线程池,阿里开发规范明确禁止这种做法。因为 Executors 创建的线程池,要么无限制的创建线程,要么无限制的添加任务,最终都会导致 OOM。
正确的创建方式:手动创建 ThreadPoolExecutor,根据业务场景设置合理的参数。
java
运行
@Configuration public class ThreadPoolConfig { @Bean public ExecutorService businessExecutor() { // 核心线程数:CPU密集型任务设置为CPU核心数,IO密集型设置为CPU核心数*2 int corePoolSize = Runtime.getRuntime().availableProcessors() * 2; return new ThreadPoolExecutor( corePoolSize, corePoolSize * 2, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue<>(200), // 有界队列,避免OOM new ThreadFactory() { // 自定义线程工厂,给线程起名字,方便排查问题 private final AtomicInteger threadNumber = new AtomicInteger(1); @Override public Thread newThread(Runnable r) { Thread thread = new Thread(r, "business-pool-" + threadNumber.getAndIncrement()); thread.setDaemon(false); return thread; } }, new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略,避免任务丢失 ); } }线程池的常见坑
- 使用无界队列:比如
new LinkedBlockingQueue(),会导致任务无限堆积,最终 OOM - 核心线程数设置不合理:设置太大导致 CPU 切换频繁,设置太小导致并发量上不去
- 不自定义线程工厂:线程没有名字,出了问题根本不知道是哪个线程池的线程出了问题
- 所有任务共用一个线程池:核心任务和非核心任务共用一个线程池,非核心任务阻塞会影响核心任务
- 在任务中捕获了所有异常:导致任务执行失败了也看不到异常信息,排查问题困难
3.5 并发容器:替代同步容器,提升并发性能
很多人会用Collections.synchronizedList(new ArrayList())这种同步容器,它的实现是给所有方法都加上 synchronized 锁,并发性能极差。
JUC 包提供了一系列高性能的并发容器,专门为多线程场景设计,性能远高于同步容器。
常用的并发容器:
表格
| 普通容器 | 同步容器 | 并发容器 | 适用场景 |
|---|---|---|---|
| ArrayList | Vector/SynchronizedList | CopyOnWriteArrayList | 读多写少的列表场景 |
| HashMap | Hashtable/SynchronizedMap | ConcurrentHashMap | 高并发的键值对存储 |
| TreeMap | SynchronizedSortedMap | ConcurrentSkipListMap | 高并发的有序键值对存储 |
| LinkedList | SynchronizedList | LinkedBlockingQueue | 高并发的生产者消费者队列 |
核心用法示例:
java
运行
// ConcurrentHashMap,高并发场景下替代HashMap ConcurrentHashMap<String, Object> map = new ConcurrentHashMap<>(); map.put("key", "value"); map.get("key"); // CopyOnWriteArrayList,读多写少场景下替代ArrayList CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>(); list.add("a"); list.get(0); // LinkedBlockingQueue,生产者消费者模型 BlockingQueue<String> queue = new LinkedBlockingQueue<>(100); // 生产者 queue.put("task"); // 消费者 String task = queue.take();四、生产环境高频并发问题与解决方案
4.1 秒杀超卖问题
问题描述:秒杀活动中,库存扣减出现负数,商品超卖。这是最经典的并发问题。
错误代码:
java
运行
// 错误示例:并发下会出现超卖 public void seckill(Long productId, Integer quantity) { // 查询库存 Integer stock = stockMapper.selectStock(productId); // 判断库存是否充足 if (stock < quantity) { throw new BusinessException("库存不足"); } // 扣减库存 stockMapper.deductStock(productId, quantity); // 生成订单 orderMapper.insert(order); }解决方案:
- 数据库乐观锁:给库存表加 version 字段,更新时判断 version 是否一致
sql
UPDATE product_stock SET stock = stock - #{quantity}, version = version + 1 WHERE product_id = #{productId} AND version = #{version} AND stock >= #{quantity}- 分布式锁:用 Redis 或 Zookeeper 分布式锁,保证同一时间只有一个线程能扣减库存
java
运行
public void seckill(Long productId, Integer quantity) { String lockKey = "stock:lock:" + productId; try { // 获取分布式锁 if (!redisLock.tryLock(lockKey, 3, TimeUnit.SECONDS)) { throw new BusinessException("系统繁忙,请稍后再试"); } // 扣减库存逻辑 Integer stock = stockMapper.selectStock(productId); if (stock < quantity) { throw new BusinessException("库存不足"); } stockMapper.deductStock(productId, quantity); orderMapper.insert(order); } finally { // 释放锁 redisLock.unlock(lockKey); } }- 数据库行锁:用 SQL 自带的行锁,先锁行再扣减
sql
SELECT stock FROM product_stock WHERE product_id = #{productId} FOR UPDATE; UPDATE product_stock SET stock = stock - #{quantity} WHERE product_id = #{productId} AND stock >= #{quantity};4.2 死锁问题
问题描述:两个线程互相等待对方持有的锁,导致线程永远阻塞,服务卡死。
死锁产生的四个必要条件:
- 互斥条件:一个资源只能被一个线程持有
- 持有并等待:线程持有一个资源,同时等待另一个资源
- 不可剥夺:线程持有的资源,只能自己释放,不能被其他线程剥夺
- 循环等待:多个线程之间形成循环等待资源的关系
死锁示例:
java
运行
public class DeadLockDemo { private static final Object lockA = new Object(); private static final Object lockB = new Object(); public static void main(String[] args) { new Thread(() -> { synchronized (lockA) { System.out.println("线程1持有lockA,等待lockB"); try { Thread.sleep(1000); } catch (InterruptedException e) {} synchronized (lockB) { System.out.println("线程1持有lockA和lockB"); } } }).start(); new Thread(() -> { synchronized (lockB) { System.out.println("线程2持有lockB,等待lockA"); try { Thread.sleep(1000); } catch (InterruptedException e) {} synchronized (lockA) { System.out.println("线程2持有lockB和lockA"); } } }).start(); } }解决方案:
- 预防死锁:破坏四个必要条件中的一个,最常用的是破坏循环等待条件,让所有线程按照相同的顺序获取锁
- 排查死锁:用
jstack命令查看线程堆栈,找到死锁的线程和锁 - 使用 tryLock:用 ReentrantLock 的 tryLock 方法设置超时时间,避免无限等待
- 减少锁的嵌套:尽量避免在一个锁里面获取另一个锁
4.3 线程池导致的 OOM 问题
问题描述:服务运行一段时间后,出现OutOfMemoryError: unable to create new native thread或者OutOfMemoryError: Java heap space。
常见原因:
- 使用无界队列,任务无限堆积,占用大量内存
- 最大线程数设置过大,创建了上千个线程,导致内存耗尽
- 任务执行时间过长,队列不断堆积新任务
- 线程池没有关闭,比如每次请求都创建一个新的线程池
解决方案:
- 必须使用有界队列,设置合理的队列长度
- 根据业务场景设置合理的最大线程数,一般不超过 CPU 核心数 * 10
- 监控线程池的状态:队列长度、活跃线程数、任务执行时间
- 任务设置超时时间,避免长时间阻塞
- 核心业务和非核心业务使用不同的线程池,互相隔离
五、生产环境最佳实践
- 无锁优先:能用原子类解决的,就不用锁;能用读写锁解决的,就不用独占锁。锁的粒度越小越好,持有锁的时间越短越好。
- 优先使用 JDK 内置工具:不要自己手写锁和线程同步逻辑,JDK 内置的并发工具已经经过了严格的测试,比你自己写的更可靠。
- 合理设置线程池参数:核心线程数根据任务类型设置,CPU 密集型任务设置为 CPU 核心数,IO 密集型设置为 CPU 核心数 * 2;必须使用有界队列;自定义线程工厂给线程命名。
- 避免共享可变状态:这是解决并发问题的根本方法。尽量减少共享变量,能用局部变量就不用成员变量;能使用不可变对象就不用可变对象。
- 并发代码必须做压测:并发问题在低并发下很难复现,必须在测试环境做高并发压测,验证代码的线程安全性。
- 监控线程池和锁的状态:生产环境必须监控线程池的队列长度、活跃线程数、任务拒绝数;监控锁的竞争情况,及时发现性能瓶颈。
- 避免在锁中执行耗时操作:持有锁的时间越长,锁的竞争越激烈,性能越差。不要在锁中执行数据库查询、RPC 调用等耗时操作。
- 正确处理线程中的异常:线程池中的任务如果抛出异常,不会打印到日志中,必须在任务中捕获异常并记录日志,否则任务失败了都不知道。
- 优先使用并发容器,不要自己实现同步:ConcurrentHashMap、CopyOnWriteArrayList 这些并发容器,比你自己用 synchronized 包装的容器性能好得多。
- 不要滥用 volatile:volatile 只能解决可见性和有序性问题,不能解决原子性问题。不要指望用 volatile 来解决计数类的原子性问题。
总结
并发编程不是玄学,也不是只有架构师才需要懂的底层知识。它是每个 Java 全栈工程师必须掌握的核心技能,是解决线上核心故障、突破职业瓶颈的关键。