深入理解Java并发编程:原理、实践与最佳实践
Java并发编程是构建高性能应用程序的关键技术,特别是在多核处理器时代。本文将全面而深入地探讨Java并发编程的核心概念、常见问题及解决方案。
本文适合人群:具有Java基础的开发者,希望深入了解并发编程的原理与实践
阅读时间:约25分钟
目录
并发编程基础
为什么需要并发?
- 充分利用多核CPU
- 提高响应性能
- 优化资源利用
- 简化建模与设计
并发的挑战
- 线程安全问题
- 死锁与活锁
- 性能开销
- 复杂度增加
线程与进程
进程是操作系统资源分配的基本单位,而线程是CPU调度的基本单位。一个进程可以包含多个线程,它们共享进程的内存空间和资源。
线程与进程的主要区别
特性 | 进程 | 线程 |
---|---|---|
定义 | 运行中的程序实例 | 进程中的执行单元 |
资源 | 拥有独立的内存空间 | 共享所属进程的内存 |
通信 | 通过IPC机制(复杂) | 直接访问共享变量(简单) |
开销 | 创建和切换开销大 | 创建和切换开销小 |
隔离性 | 高度隔离 | 共享资源,隔离度低 |
Java中创建线程的两种基本方式:
- 继承Thread类
- 实现Runnable接口
- Lambda表达式(Java 8+)
- Callable接口(带返回值)
class MyThread extends Thread {
@Override
public void run() {
System.out.println("Thread running: " + Thread.currentThread().getName());
}
}
// 使用方式
MyThread thread = new MyThread();
thread.start();
class MyRunnable implements Runnable {
@Override
public void run() {
System.out.println("Runnable running: " + Thread.currentThread().getName());
}
}
// 使用方式
Thread thread = new Thread(new MyRunnable());
thread.start();
// 使用Lambda表达式
Thread thread = new Thread(() -> {
System.out.println("Lambda running: " + Thread.currentThread().getName());
});
thread.start();
import java.util.concurrent.Callable;
import java.util.concurrent.FutureTask;
// 创建Callable任务
Callable<String> callable = () -> {
// 执行一些耗时操作
Thread.sleep(1000);
return "任务执行结果";
};
// 使用FutureTask包装
FutureTask<String> futureTask = new FutureTask<>(callable);
// 创建线程执行任务
Thread thread = new Thread(futureTask);
thread.start();
// 获取结果(会阻塞直到任务完成)
String result = futureTask.get();
线程状态
Java线程在其生命周期中可能处于以下状态:
NEW:新创建但尚未启动的线程
RUNNABLE:可运行状态,包括运行中和就绪
BLOCKED:被阻塞等待监视器锁
WAITING:无限期等待另一个线程执行特定操作
TIMED_WAITING:有限期等待另一个线程执行操作
TERMINATED:已完成执行的线程
线程状态转换图:
NEW → RUNNABLE → TERMINATED
↑ ↓
↑ ↓
BLOCKED
↑ ↓
↑ ↓
WAITING/TIMED_WAITING
Java内存模型
Java内存模型(JMM)规定了Java虚拟机如何与计算机内存协同工作。它定义了线程如何与主内存交互,以及多线程程序中变量的可见性、原子性和有序性问题。
主内存与工作内存
- 主内存:所有线程共享,存储所有变量的主副本
- 工作内存:每个线程独有,存储变量的副本
线程对变量的所有操作都必须在工作内存中进行,而不能直接操作主内存中的变量。
内存屏障
内存屏障是一种CPU指令,用于控制特定条件下的内存操作顺序,保证可见性。
Java中的内存屏障类型
- LoadLoad屏障:确保load1指令先于load2及之后的指令完成
- StoreStore屏障:确保store1指令的结果对其他处理器可见,先于store2及之后的指令
- LoadStore屏障:确保load1指令先于store2及之后的指令完成
- StoreLoad屏障:确保store1指令的结果对其他处理器可见,先于load2及之后的指令
volatile写操作会在写操作前插入StoreStore屏障,在写操作后插入StoreLoad屏障。 volatile读操作会在读操作后插入LoadLoad屏障和LoadStore屏障。
重排序
编译器和处理器可能会对指令进行重排序以提高性能,只要不改变单线程程序的执行结果。但在多线程环境下,重排序可能导致意想不到的问题。
重排序可能导致多线程程序出现非预期行为!Java内存模型通过happens-before关系保证了一定的有序性,确保重排序不会破坏程序的正确性。
线程安全性
线程安全性是并发编程中最重要的概念之一,它确保多个线程同时访问共享资源时不会导致程序出错。
竞态条件
当多个线程以不可预测的顺序访问共享资源并且至少有一个线程修改该资源时,可能出现竞态条件。
- 非线程安全示例
- 使用synchronized修复
public class Counter {
private int count = 0;
// 非线程安全的方法
public void increment() {
count++; // 这实际上是一个非原子操作
}
public int getCount() {
return count;
}
}
count++操作实际包含三个步骤:读取、递增、写回。多线程环境下可能导致计数错误!
public class SynchronizedCounter {
private int count = 0;
// 使用synchronized确保线程安全
public synchronized void increment() {
count++;
}
public synchronized int getCount() {
return count;
}
}
原子性
Java提供了java.util.concurrent.atomic
包,包含支持原子操作的类:
import java.util.concurrent.atomic.AtomicInteger;
public class AtomicCounter {
private AtomicInteger count = new AtomicInteger(0);
public void increment() {
count.incrementAndGet(); // 原子操作
}
public int getCount() {
return count.get();
}
}
原子类的优势
- 无锁实现,性能更好
- 基于CAS(Compare And Swap)操作
- 适用于简单的原子操作场景
可见性
volatile
关键字保证了变量的可见性,但不保证原子性:
public class VolatileExample {
private volatile boolean flag = false;
public void setFlag() {
flag = true; // 写操作会立即刷新到主内存
}
public boolean isFlag() {
return flag; // 读操作会直接从主内存获取最新值
}
}
- 适合使用volatile场景
- 不适合使用volatile场景
- 写入变量不依赖当前值
- 变量不会被包含在不变式中
- 访问变量不需要加锁
- 变量值依赖当前值(如count++)
- 变量需要与其他状态变量一起参与不变式
有序性
通过synchronized
和volatile
关键字以及显式锁可以保证有序性。
同步工具类
synchronized
synchronized
关键字可用于方法或代码块,确保同一时刻只有一个线程执行该代码:
- 同步方法
- 同步代码块
public class SynchronizedCounter {
private int count = 0;
// 同步实例方法
public synchronized void increment() {
count++;
}
// 同步静态方法
public static synchronized void staticMethod() {
// 共享资源访问
}
}
public class SynchronizedCounter {
private int count = 0;
private final Object lock = new Object(); // 专用锁对象
public void increment() {
// 同步代码块,更细粒度的控制
synchronized(this) { // 使用this作为锁
count++;
}
}
public void otherMethod() {
synchronized(lock) { // 使用专用对象作为锁
// 访问共享资源
}
}
}
最佳实践:优先使用同步代码块而非同步方法,以减小锁的范围,提高性能。
Lock接口
java.util.concurrent.locks
包提供了比synchronized
更灵活的锁机制:
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class LockCounter {
private final Lock lock = new ReentrantLock();
private int count = 0;
public void increment() {
lock.lock(); // 获取锁
try {
count++;
} finally {
lock.unlock(); // 释放锁
}
}
public int getCount() {
lock.lock();
try {
return count;
} finally {
lock.unlock();
}
}
}
Lock接口优势
- 非阻塞地获取锁(tryLock)
- 可中断的锁获取过程
- 可设置超时的锁获取
- 公平锁支持
Lock接口劣势
- 需要手动释放锁
- 必须在finally块中释放
- 代码更复杂
- 无法自动释放(出作用域)
ReadWriteLock
读写锁允许多个线程同时读取,但写入时需要独占:
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class ReadWriteMap<K, V> {
private final Map<K, V> map = new HashMap<>();
private final ReadWriteLock lock = new ReentrantReadWriteLock();
public V get(K key) {
lock.readLock().lock();
try {
return map.get(key);
} finally {
lock.readLock().unlock();
}
}
public V put(K key, V value) {
lock.writeLock().lock();
try {
return map.put(key, value);
} finally {
lock.writeLock().unlock();
}
}
}
读写锁非常适合于读多写少的场景,可以显著提高并发性能。
并发容器
Java提供了多种线程安全的容器类:
并发容器 | 对应的非并发容器 | 特点 | 适用场景 |
---|---|---|---|
ConcurrentHashMap | HashMap | 分段锁,高并发 | 高并发读写Map |
CopyOnWriteArrayList | ArrayList | 写时复制,读多写少 | 读多写少的List |
ConcurrentLinkedQueue | Queue | 无锁算法,高性能 | 高并发队列操作 |
BlockingQueue系列 | Queue | 阻塞操作,生产消费 | 生产者-消费者模式 |
ConcurrentHashMap
import java.util.concurrent.ConcurrentHashMap;
public class ConcurrentMapExample {
private final ConcurrentHashMap<String, String> map = new ConcurrentHashMap<>();
public void putIfAbsent(String key, String value) {
map.putIfAbsent(key, value);
}
}
CopyOnWriteArrayList
import java.util.concurrent.CopyOnWriteArrayList;
public class CopyOnWriteExample {
private final CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>();
public void add(String item) {
list.add(item); // 线程安全,但添加操作会复制整个底层数组
}
}
线程池
线程池管理一组工作线程,减少了线程创建和销毁的开销:
使用线程池可以有效控制线程数量,复用线程资源,提高响应速度,方便管理。
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class ThreadPoolExample {
public static void main(String[] args) {
// 创建固定大小的线程池
ExecutorService executor = Executors.newFixedThreadPool(5);
for (int i = 0; i < 10; i++) {
final int taskId = i;
executor.submit(() -> {
System.out.println("Task " + taskId + " executed by " +
Thread.currentThread().getName());
});
}
// 关闭线程池
executor.shutdown();
}
}
Java提供的四种常见线程池
- 固定大小线程池
- 缓存线程池
- 单线程池
- 调度线程池
// 创建包含5个线程的线程池
ExecutorService fixedPool = Executors.newFixedThreadPool(5);
适用于需要限制线程数量的场景,保持CPU不会过载。
// 创建按需创建线程的线程池
ExecutorService cachedPool = Executors.newCachedThreadPool();
适用于执行大量短期异步任务的场景,线程池会复用空闲线程。
// 创建只有一个工作线程的线程池
ExecutorService singlePool = Executors.newSingleThreadExecutor();
适用于需要保证顺序执行的场景。
// 创建可以执行定时任务的线程池
ScheduledExecutorService scheduledPool =
Executors.newScheduledThreadPool(3);
// 延迟2秒后执行
scheduledPool.schedule(task, 2, TimeUnit.SECONDS);
// 延迟1秒后,每3秒执行一次
scheduledPool.scheduleAtFixedRate(task, 1, 3, TimeUnit.SECONDS);
适用于需要执行定时任务的场景。
自定义线程池
import java.util.concurrent.*;
public class CustomThreadPoolExample {
public static void main(String[] args) {
ThreadPoolExecutor executor = new ThreadPoolExecutor(
2, // 核心线程数
5, // 最大线程数
60, TimeUnit.SECONDS, // 空闲线程存活时间
new ArrayBlockingQueue<>(100), // 工作队列
Executors.defaultThreadFactory(), // 线程工厂
new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略
);
// 提交任务...
// 关闭线程池
executor.shutdown();
}
}
线程池参数选择指南
- 核心线程数:通常设置为CPU核心数
- 最大线程数:CPU核心数 * (1 + 平均等待时间/平均计算时间)
- 队列大小:根据应用场景和内存情况设置
- 拒绝策略:根据业务需求选择合适的策略
并发编程最佳实践
最佳实践清单
- 尽量减少共享可变状态:使用不可变对象、ThreadLocal变量或方法局部变量
- 减小锁的粒度:锁定必要的代码段而非整个方法
- 避免锁嵌套:防止死锁
- 优先使用并发容器:而非同步包装器
- 优先使用原子变量:而非低级锁
- 使用线程池:而非直接创建线程
- 优先使用高级同步工具:如CountDownLatch、CyclicBarrier、Semaphore
死锁避免
- 可能导致死锁的代码
- 死锁避免方案
public class DeadlockRisk {
private final Object resource1 = new Object();
private final Object resource2 = new Object();
public void method1() {
synchronized(resource1) {
System.out.println("Using resource 1");
// 可能在这里发生死锁
synchronized(resource2) {
System.out.println("Using resource 1 and 2");
}
}
}
public void method2() {
synchronized(resource2) {
System.out.println("Using resource 2");
// 可能在这里发生死锁
synchronized(resource1) {
System.out.println("Using resource 1 and 2");
}
}
}
}
public class DeadlockAvoidance {
private final Object resource1 = new Object();
private final Object resource2 = new Object();
// 良好实践:总是按照相同的顺序获取锁
public void method1() {
synchronized(resource1) {
System.out.println("Using resource 1");
synchronized(resource2) {
System.out.println("Using resource 1 and 2");
}
}
}
public void method2() {
synchronized(resource1) { // 使用相同的获取顺序
System.out.println("Using resource 1");
synchronized(resource2) {
System.out.println("Using resource 1 and 2");
}
}
}
}
死锁是多线程编程中最危险的陷阱之一。检测和修复死锁通常很困难,预防是最佳策略!
性能优化与调优
并发程序的性能指标
吞吐量
单位时间内完成的工作量
影响因素:线程数量、锁竞争程度、任务处理速度
响应时间
从请求到响应的时间
影响因素:锁等待时间、线程调度、资源竞争
可扩展性
增加资源时性能的提升程度
影响因素:并行算法设计、同步开销、资源瓶颈
性能优化技巧
- 适当的并发度:线程数 = CPU核心数 * (1 + 等待时间/计算时间)
- 避免过度同步:确保同步范围尽可能小
- 批处理:合并多个小操作为一个大操作
- 减少上下文切换:避免频繁的线程切换
- 使用并行流:利用多核处理器
// 使用并行流加速操作
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
long sum = numbers.parallelStream()
.mapToLong(Integer::longValue)
.sum();
并不是所有操作都适合使用并行流。对于小规模数据或计算密集度低的操作,并行处理可能因线程开销而降低性能。
总结
Java并发编程是一个复杂而重要的领域,掌握它需要对底层原理有深入的理解。本文介绍了Java并发编程的基本概念、常用工具和最佳实践,希望能帮助读者构建高效、安全的并发应用。
未来趋势
随着Java语言的发展,并发API也在不断优化和增强:
- Java 8引入的CompletableFuture提供了强大的异步编程能力
- Java 9的Flow API带来了响应式编程的支持
- Project Loom将引入虚拟线程,彻底改变Java并发编程模型
持续学习和实践是掌握并发编程的关键。
参考资料
- 《Java并发编程实战》- Brian Goetz等
- 《Effective Java》- Joshua Bloch
- Oracle Java Documentation
- Java Concurrency in Practice (JCiP)