深入理解Java并发编程:从办公室场景说起
引言
作为Java程序员,你一定听说过"可见性"、"原子性"、"有序性"这些概念。但这些抽象的概念往往让人难以理解。今天,让我们通过一个简单的办公室场景,来重新认识这些概念。在开始之前,我们先了解下为什么需要关注这些问题:
- 现代CPU架构中的多级缓存
- 编译器优化带来的指令重排
- 多线程并发访问共享资源的竞争
一、Java内存模型:一个现代化办公室
想象一下,我们的办公室就是一个小型的公司。在这个比喻中:
- 公告板(主内存):放在大厅中央,存储公司的所有重要信息
- 员工的记事本(工作内存):每个人随身携带,记录需要处理的信息
- 各位员工(线程):来来往往处理各种事务,需要相互协调
1.1 工作内存 vs 线程栈:不同的抽象层面
想象一下小李正在处理工资发放的场景。他的办公桌上(线程栈)有一个计算器和记事本(局部变量),同时他还随身携带着一个记录公司账户余额的便携本(工作内存)。他需要查看公告板(主内存)上的公司账户信息,记在自己的便携本上,然后结合桌上的计算器进行计算。
让我们通过这个工资发放的完整示例来理解这两个概念:
public class Employee {
// 公司账户余额-主内存
private static int companyMoney = 100000;
// 部门预算-主内存
private static int departmentBudget = 50000;
public void processPayment() {
// 局部变量-线程栈
int salary = 5000;
int bonus = 2000;
// 共享变量-涉及工作内存
companyMoney -= (salary + bonus);
departmentBudget -= salary;
// 记录日志-线程栈
String logMessage = "Processed payment: " + (salary + bonus);
}
}
线程栈的特点:
- 线程私有,不存在并发问题
- 生命周期跟随方法调用
- 存储基本数据类型和对象引用
- 自动管理内存,方法结束自动释放
工作内存的特点:
- 是主内存和线程的中间层
- 存储共享变量的副本
- 需要和主内存保持同步
- 通过内存屏障保证可见性
1.2 Java内存模型的交互规则
内存交互过程的完整示例:
class DocumentSystem {
// 主内存变量
private volatile int documentVersion = 1;
private String documentContent = "";
private boolean isModified = false;
public void updateDocument(String newContent) {
// 1. read & load:从主内存读取到工作内存
int currentVersion = documentVersion;
String currentContent = documentContent;
// 2. use & assign:在工作内存中操作
currentContent = newContent;
currentVersion++;
isModified = true;
// 3. store & write:写回主内存
documentContent = currentContent;
documentVersion = currentVersion;
}
}
八种原子操作的详细说明:
-
read(读取):
- 从主内存读取变量的值
- 类似查看公告板上的信息
-
load(载入):
- 将读取的值写入工作内存
- 类似将公告板内容抄写到记事本
-
use(使用):
- 从工作内存读取变量值
- 类似查看记事本中的信息
-
assign(赋值):
- 在工作内存中写入新值
- 类似在记事本上修改信息
-
store(存储):
- 将工作内存的值传输到主内存
- 类似准备更新公告板
-
write(写入):
- 将值写入主内存
- 类似实际更新公告板
-
lock(锁定):
- 将主内存变量加锁
- 类似预订会议室
-
unlock(解锁):
- 将主内存变量解锁
- 类似释放会议室
二、并发编程的三大困扰
2.1 可见性:错过的会议通知
生动的办公室场景:
想象这样一个场景:行政小张刚刚在公告板上贴了一个紧急会议通知,包括会议室信息和时间。但小李当时正在自己的工位上忙着处理文件,只是瞟了一眼自己随身携带的记事本(工作内存),发现没有会议通知就继续工作了。即使小张已经更新了公告板,但小李的记事本里还是旧的信息,导致他错过了这个重要会议。
这个场景完美地诠释了可见性问题:一个线程对共享变量的修改对其他线程不可见。让我们看看代码层面是如何体现的:
问题场景的深入分析:
public class MeetingNotice {
private boolean meetingScheduled = false;
private String meetingRoom;
private Date meetingTime;
// 行政小张:发布会议通知
public void scheduleMeeting() {
prepareMeeting();
meetingRoom = "Room 301"; // ①
meetingTime = new Date(); // ②
meetingScheduled = true; // ③
}
// 小李:等待并查看会议通知
public void waitForMeeting() {
while (!meetingScheduled) { // ④
// 不断查看记事本
}
// 可能看到不完整的会议信息
System.out.println("Meeting in " + meetingRoom + " at " + meetingTime);
}
}
问题分析:
- 指令重排可能导致③提前执行
- 工作内存的值没有及时刷新到主内存
- 其他线程看到了无效的缓存值
多种解决方案比较:
- volatile方案:
private volatile boolean meetingScheduled = false;
private volatile String meetingRoom;
private volatile Date meetingTime;
- synchronized方案:
public synchronized void scheduleMeeting() {
prepareMeeting();
meetingRoom = "Room 301";
meetingTime = new Date();
meetingScheduled = true;
}
- Lock方案:
private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
public void scheduleMeeting() {
lock.writeLock().lock();
try {
prepareMeeting();
meetingRoom = "Room 301";
meetingTime = new Date();
meetingScheduled = true;
} finally {
lock.writeLock().unlock();
}
}
2.2 原子性:混乱的会议室预订
生动的办公室场景:
想象一下这个尴尬的场景:小李和小王同时看到会议室是空的,都想预订下午3点的时段。小李正在登记预订信息时,被同事叫走处理了一个紧急问题。这时小王看到登记本还是空的,就直接把自己的名字写了上去。等小李回来继续登记时,发现会议室已经被小王预订了!
这就是典型的原子性问题:一个操作被打断,导致最终结果出现混乱。在会议室预订系统中,"查看状态-登记预订"这个完整操作必须是原子的,不能被打断。让我们看看代码中是如何体现的:
复杂场景示例:
public class MeetingRoom {
private static class RoomStatus {
boolean isBooked;
String bookedBy;
Date bookingTime;
}
private RoomStatus status = new RoomStatus();
public void book(String employee) {
// 以下操作需要保证原子性
if (!status.isBooked) { // 检查状态
validateEmployee(employee); // 验证身份
status.isBooked = true; // 更新预订状态
status.bookedBy = employee; // 记录预订人
status.bookingTime = new Date(); // 记录时间
}
}
}
各种解决方案的优缺点:
-
synchronized方案:
- 优点:实现简单,保证可见性
- 缺点:性能开销较大
CAS方案:
public class MeetingRoom {
private AtomicReference<RoomStatus> status =
new AtomicReference<>(new RoomStatus());
public void book(String employee) {
RoomStatus current, newStatus;
do {
current = status.get();
if (current.isBooked) {
return;
}
newStatus = new RoomStatus();
newStatus.isBooked = true;
newStatus.bookedBy = employee;
newStatus.bookingTime = new Date();
} while (!status.compareAndSet(current, newStatus));
}
}
- 显式锁方案:
private final Lock roomLock = new ReentrantLock();
public void book(String employee) {
roomLock.lock();
try {
if (!status.isBooked) {
// 预订逻辑
}
} finally {
roomLock.unlock();
}
}
2.3 有序性:混乱的入职流程
生动的办公室场景:
设想新员工小张入职的场景。人力资源部需要为他准备工位、配置电脑账号并安排入职培训。正常流程是:先准备好工位和电脑,然后创建账号,最后进行培训。但由于多个部门同时处理,可能出现这样的情况:IT部门先创建了账号,但工位还没准备好;或者培训已经开始了,但电脑账号还没建好。这种流程顺序的混乱就会导致新员工入职体验很差。
这个场景完美地映射了程序中的有序性问题:看似有严格顺序的操作,可能被重排导致出现意料之外的情况。让我们看看代码层面的表现:
问题场景的完整示例:
public class Onboarding {
private boolean workstationReady;
private boolean accountCreated;
private boolean trainingCompleted;
public void prepare() {
prepareWorkstation(); // ①
workstationReady = true; // ②
setupAccount(); // ③
accountCreated = true; // ④
conductTraining(); // ⑤
trainingCompleted = true; // ⑥
}
public void checkStatus() {
// 由于指令重排,可能出现意外的状态组合
if (accountCreated && !workstationReady) {
// 不合理的状态
}
}
}
重排序类型:
- 编译器重排序
- 处理器重排序
- 内存系统重排序
解决方案:
- volatile保证单个变量的读写顺序
- synchronized保证代码块的顺序性
- happens-before规则建立操作间的顺序关系
三、深入理解happens-before
让我们继续用办公室场景来理解happens-before:想象公司里有一个严格的工作流程规定,比如交接班时,上一班必须完成交接记录,下一班才能开始工作。这就像程序中的happens-before关系,它确保了操作的先后顺序。
happens-before关系的完整规则集:
- 程序顺序规则
void process() {
int a = 1; // ①
int b = 2; // ②
System.out.println(a); // ③
System.out.println(b); // ④
}
// ①happens-before②, ②happens-before③, ③happens-before④
- volatile变量规则
class SharedData {
private volatile boolean flag;
private int[] data = new int[10];
public void write() {
for (int i = 0; i < data.length; i++) {
data[i] = i; // ①
}
flag = true; // ②
}
public void read() {
if (flag) { // ③
int sum = 0;
for (int i = 0; i < data.length; i++) {
sum += data[i]; // ④
}
}
}
}
// ①happens-before②, ②happens-before③, 所以①happens-before④
- 传递性规则
class TransitiveExample {
private volatile int a = 0;
private volatile int b = 0;
void step1() { a = 1; } // ①
void step2() { b = a; } // ②
void step3() { print(b); } // ③
}
// ①happens-before②, ②happens-before③
// 所以①happens-before③
四、实践建议
1. 优先使用高层同步工具
想象一下,与其让每个员工自己制定规则来协调工作,不如使用公司已有的规章制度和流程。同样地,在并发编程中,我们应该优先使用JDK提供的成熟工具类,而不是自己实现底层的同步机制。
推荐使用的并发工具类:
- AtomicXXX类:
public class Counter {
private AtomicInteger count = new AtomicInteger(0);
private AtomicReference<State> state =
new AtomicReference<>(State.IDLE);
public void increment() {
count.incrementAndGet();
// 相比synchronized更高效
}
}
- 并发集合:
// 线程安全的集合类
private ConcurrentHashMap<String, User> userCache =
new ConcurrentHashMap<>();
private CopyOnWriteArrayList<Event> eventLog =
new CopyOnWriteArrayList<>();
2. 正确使用synchronized
就像在办公室中,我们不会为了一个简单的复印任务就把整个办公室锁起来,而是只锁复印室。同样,在使用synchronized时,我们也需要选择合适的同步范围,既要确保安全,又要避免过度同步影响性能。
最佳实践:
- 选择合适的同步范围:
public class BankAccount {
private double balance;
private final Lock balanceLock = new ReentrantLock();
public void transfer(BankAccount to, double amount) {
// 使用细粒度的锁
balanceLock.lock();
try {
if (balance >= amount) {
balance -= amount;
to.deposit(amount);
}
} finally {
balanceLock.unlock();
}
}
}
- 避免嵌套锁:
// 不推荐
synchronized void outer() {
synchronized void inner() {
// 可能导致死锁
}
}
3. volatile的适用场景
想象公司里有一个"紧急状态"的标志:当发生紧急情况时,只需要将标志设置为true,所有员工看到标志就立即停止当前工作开始处理紧急情况。这种简单的状态标志非常适合使用volatile来实现。
适用场景:
- 状态标志:
public class Service {
private volatile boolean running = true;
public void run() {
while (running) {
// 服务逻辑
}
}
public void stop() {
running = false;
}
}
- 双重检查锁定:
public class Singleton {
private volatile static Singleton instance;
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
总结
通过办公室的日常场景,我们深入理解了Java并发编程中的核心概念:
- 可见性:及时同步信息
- 原子性:操作的完整性
- 有序性:正确的执行顺序
实践建议:
- 理解内存模型的基本原理
- 合理使用synchronized和volatile
- 优先使用高层并发工具
- 保持代码的简单性和可维护性
参考资料
- 《Java并发编程实战》- Brian Goetz
- 《深入理解Java虚拟机》- 周志明
- JSR-133: