前言
在高并发后端系统中,并发编程是提升系统吞吐量、充分利用多核 CPU 资源的核心技术。然而,并发编程也是后端开发中最容易出错的领域之一,线程安全、死锁、上下文切换开销、内存可见性等问题,稍有不慎就会导致系统出现数据错乱、性能下降甚至崩溃。
很多开发者对并发编程的理解停留在 "会用线程池" 的层面,缺乏对底层原理的深入认知,导致在生产环境中频繁踩坑。本文从线程与线程池、锁机制、并发容器、原子类与 CAS、并发编程常见陷阱五个维度,结合生产环境实战经验,拆解 Java 并发编程的核心原理与最佳实践,适合 Java 后端开发、架构师参考复用。
一、并发编程基础与核心问题
1.1 并发与并行的区别
- 并发:多个任务在同一时间段内交替执行,宏观上看起来是同时进行的,本质是 CPU 在多个任务之间快速切换
- 并行:多个任务在同一时刻同时执行,需要多核 CPU 的支持
在实际系统中,并发和并行通常是同时存在的,我们通过并发编程来提高系统的资源利用率和响应速度。
1.2 并发编程的三大核心问题
- 原子性:一个操作要么全部执行成功,要么全部执行失败,中间不能被中断
- 可见性:一个线程对共享变量的修改,能够立即被其他线程看到
- 有序性:程序执行的顺序按照代码的先后顺序执行,不会因为编译器和 CPU 的指令重排而改变
这三个问题是导致线程安全问题的根本原因,Java 提供了一系列机制来解决这些问题,如 synchronized、volatile、原子类等。
二、线程与线程池的正确使用
2.1 线程的创建与生命周期
Java 中线程有五种状态:新建、就绪、运行、阻塞、终止。创建线程有三种方式:继承 Thread 类、实现 Runnable 接口、实现 Callable 接口(带返回值)。
不推荐直接创建线程,因为频繁创建和销毁线程会带来很大的开销,而且无法控制线程的数量,容易导致系统资源耗尽。
2.2 线程池的核心原理
线程池是管理线程的容器,通过复用线程来减少创建和销毁线程的开销,同时可以控制线程的最大数量,避免系统资源耗尽。Java 中的线程池核心是ThreadPoolExecutor类,其核心参数如下:
- corePoolSize:核心线程数,线程池中长期保留的线程数量
- maximumPoolSize:最大线程数,线程池允许创建的最大线程数量
- keepAliveTime:非核心线程的空闲时间,超过这个时间会被回收
- workQueue:任务队列,用于存放等待执行的任务
- threadFactory:线程工厂,用于创建线程
- handler:拒绝策略,当任务队列和线程池都满了时,处理新任务的策略
2.3 线程池的最佳实践
- 禁止使用 Executors 创建线程池:Executors 提供的默认线程池存在很多问题,如 FixedThreadPool 和 SingleThreadPool 的任务队列无界,CachedThreadPool 的最大线程数无界,都可能导致 OOM
- 手动创建 ThreadPoolExecutor:根据业务场景合理设置核心参数,如 CPU 密集型任务设置核心线程数为 CPU 核心数 + 1,IO 密集型任务设置核心线程数为 2*CPU 核心数
- 使用自定义线程工厂:给线程设置有意义的名称,便于问题排查
- 合理设置拒绝策略:根据业务需求选择合适的拒绝策略,如 AbortPolicy(抛出异常)、CallerRunsPolicy(由调用线程执行)、DiscardPolicy(直接丢弃)等
正确的线程池创建示例:
java
运行
scss
// CPU密集型任务线程池
ExecutorService cpuIntensivePool = new ThreadPoolExecutor(
Runtime.getRuntime().availableProcessors() + 1,
Runtime.getRuntime().availableProcessors() + 1,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<>(1000),
new ThreadFactoryBuilder().setNameFormat("cpu-pool-%d").build(),
new ThreadPoolExecutor.CallerRunsPolicy()
);
// IO密集型任务线程池
ExecutorService ioIntensivePool = new ThreadPoolExecutor(
2 * Runtime.getRuntime().availableProcessors(),
2 * Runtime.getRuntime().availableProcessors(),
60L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(10000),
new ThreadFactoryBuilder().setNameFormat("io-pool-%d").build(),
new ThreadPoolExecutor.CallerRunsPolicy()
);
三、锁机制的原理与使用
3.1 synchronized 关键字
synchronized 是 Java 中最常用的锁机制,用于保证原子性、可见性和有序性。synchronized 可以修饰方法和代码块,其底层是通过对象头中的监视器锁(Monitor)来实现的。
synchronized 的锁升级过程:
- 无锁:对象刚创建时,没有任何线程竞争锁
- 偏向锁:当只有一个线程访问锁时,锁会偏向这个线程,避免每次加锁和解锁的开销
- 轻量级锁:当有多个线程竞争锁时,偏向锁升级为轻量级锁,通过 CAS 操作来获取锁
- 重量级锁:当轻量级锁竞争失败时,升级为重量级锁,线程会被阻塞,等待锁释放
3.2 ReentrantLock 可重入锁
ReentrantLock 是 JDK 提供的显式锁,相比 synchronized 更加灵活,支持公平锁和非公平锁、可中断锁、超时锁等特性。
ReentrantLock 的使用示例:
java
运行
csharp
ReentrantLock lock = new ReentrantLock();
try {
lock.lock();
// 业务逻辑
} finally {
lock.unlock(); // 必须在finally中释放锁,防止死锁
}
3.3 锁的最佳实践
- 缩小锁的范围:尽量只在需要同步的代码块上加锁,避免锁的范围过大
- 避免锁嵌套:不要在持有一个锁的同时去获取另一个锁,容易导致死锁
- 优先使用 synchronized:在大多数场景下,synchronized 的性能已经足够好,而且使用简单,不容易出错
- 使用 tryLock 避免死锁 :使用
tryLock(long timeout, TimeUnit unit)方法尝试获取锁,如果超时则放弃,避免无限等待
四、并发容器的原理与使用
Java 提供了一系列线程安全的并发容器,用于替代传统的非线程安全容器,如 ArrayList、HashMap 等。
4.1 ConcurrentHashMap
ConcurrentHashMap 是线程安全的 HashMap,相比 Hashtable 和 Collections.synchronizedMap,性能更高。其底层采用分段锁(JDK1.7)和 CAS+synchronized(JDK1.8+)的实现方式,大大提高了并发度。
4.2 CopyOnWriteArrayList
CopyOnWriteArrayList 是线程安全的 ArrayList,其实现原理是在写操作时,复制一份新的数组,修改完成后再将原数组的引用指向新数组。读操作不需要加锁,性能很高,适合读多写少的场景。
4.3 其他并发容器
- ConcurrentLinkedQueue:线程安全的无界队列,采用 CAS 操作实现,性能很高
- BlockingQueue:阻塞队列,支持阻塞的入队和出队操作,常用于生产者消费者模型
- ConcurrentSkipListMap:线程安全的有序 Map,基于跳表实现,支持高并发
五、原子类与 CAS 原理
5.1 CAS 原理
CAS(Compare And Swap)是一种无锁算法,通过比较内存中的值和预期值,如果相等则更新为新值,否则不更新。CAS 操作是原子性的,由 CPU 硬件指令保证。
CAS 的三个操作数:内存地址 V、预期值 A、新值 B。当且仅当 V 的值等于 A 时,将 V 的值更新为 B,否则什么都不做。
5.2 原子类
Java.util.concurrent.atomic 包下提供了一系列原子类,如 AtomicInteger、AtomicLong、AtomicReference 等,它们都是基于 CAS 实现的,用于保证单个变量的原子性操作。
AtomicInteger 使用示例:
java
运行
scss
AtomicInteger count = new AtomicInteger(0);
count.incrementAndGet(); // 原子性加1
count.addAndGet(5); // 原子性加5
5.3 ABA 问题与解决方案
CAS 存在一个经典的 ABA 问题:如果一个变量的值从 A 变成了 B,又变成了 A,那么 CAS 操作会认为它没有发生变化,从而导致错误。
解决方案是使用版本号,每次修改变量时版本号加 1,CAS 操作时不仅比较变量的值,还要比较版本号。Java 中提供了 AtomicStampedReference 类来解决 ABA 问题。
六、并发编程常见坑点与避坑指南
表格
| 常见问题 | 根本原因 | 解决方案 |
|---|---|---|
| 线程安全问题 | 多个线程同时修改共享变量,没有正确同步 | 使用 synchronized、ReentrantLock 或原子类保证原子性 |
| 死锁 | 多个线程互相等待对方释放锁 | 避免锁嵌套、统一锁的获取顺序、使用 tryLock |
| 上下文切换开销大 | 线程数量过多,导致 CPU 频繁切换线程 | 合理设置线程池大小,避免创建过多线程 |
| 内存可见性问题 | 一个线程修改的变量,其他线程看不到 | 使用 volatile 关键字或锁机制保证可见性 |
| ThreadLocal 内存泄漏 | ThreadLocal 的 key 是弱引用,value 是强引用,没有及时清理 | 使用完 ThreadLocal 后,手动调用 remove () 方法 |
| 指令重排导致的问题 | 编译器和 CPU 对指令进行重排,导致程序执行顺序不符合预期 | 使用 volatile 关键字禁止指令重排 |
七、总结
Java 并发编程是后端开发必须掌握的核心技能,其核心是解决线程安全问题和提高系统性能。在实际开发中,我们应该遵循以下原则:
- 优先使用线程池管理线程,避免手动创建线程
- 尽量缩小锁的范围,减少锁竞争
- 优先使用并发容器和原子类,避免手动同步
- 注意并发编程中的常见坑点,如死锁、内存泄漏、可见性问题等
并发编程没有银弹,只有深入理解底层原理,结合业务场景合理使用各种并发工具,才能写出高效、安全、稳定的并发代码。本文介绍的技术方案和最佳实践,已在多个生产环境中得到验证,可直接复用在各类高并发 Java 系统中。