写代码就像是在繁忙的十字路口指挥交通。如果每个司机(线程)都随心所欲,那后果不堪设想。在Java的世界里,synchronized就像是红绿灯和交警,负责强制秩序;而volatile则像是一个透明的公告板,确保每个人看到的最新路况是一致的。很多初学者觉得这两者很抽象,但只要你把它们想象成日常生活中的场景,理解起来就简单多了。今天我们就抛开那些枯燥的定义,直接聊聊怎么在实际开发中用好这两个神器,特别是如何避免那些让人头疼的死锁和竞态条件。
为什么我们需要“同步”?
想象一下,你和朋友共同维护一个共享的Excel表格。你正在修改A1单元格的数据,此时朋友也正好想修改A1。如果你们同时操作,最后保存的结果可能是混乱的——也许你的修改被覆盖了,或者数据变得不完整。这就是典型的竞态条件(Race Condition):程序的正确性依赖于事件发生的时序,而这种时序往往是不确定的。
在多线程环境下,多个线程同时访问共享资源(比如全局变量、文件、数据库连接等),如果没有适当的同步机制,数据一致性就无法保证。Java提供了多种工具来解决这个问题,其中最基础也是最常用的就是synchronized关键字和volatile关键字。虽然它们名字里都有“同步”,但作用范围和原理截然不同。
synchronized:重量级的守门员
synchronized是Java内置的互斥锁机制。你可以把它理解为一个房间的门,门上只有一把钥匙。谁拿到了钥匙,谁就能进入房间处理事情;其他人必须在门外排队等待。
1. 基本用法与场景
synchronized可以修饰方法,也可以修饰代码块。
修饰实例方法:
public class Counter {
private int count = 0;
// 每次调用这个方法时,线程必须先获取当前Counter对象的锁
public synchronized void increment() {
count++; // 这是一个非原子操作,包含读取、修改、写入三个步骤
}
}
在这个例子中,increment()方法被标记为synchronized。当线程A进入该方法时,它获得了this对象的锁。此时,如果线程B也想执行同一个对象的increment()方法,它会被阻塞,直到线程A执行完毕并释放锁。
修饰静态方法:
public static synchronized void staticMethod() {
// ...
}
静态方法属于类本身,所以它锁住的是Class对象(即Counter.class)。这意味着,无论创建多少个Counter实例,同一时刻只有一个线程能执行这个静态方法。
修饰代码块(推荐):
public void safeIncrement() {
synchronized (this) {
count++;
}
}
使用代码块可以更精细地控制锁的范围。只保护需要保护的那部分代码,而不是整个方法,这样可以提高并发性能。
2. 锁的粒度与性能权衡
虽然synchronized保证了安全性,但它也有代价。每次加锁和解锁都需要操作系统参与,这涉及到用户态到内核态的切换,开销较大。因此,在高并发场景下,过度使用粗粒度的synchronized可能会导致性能瓶颈。
实际案例:银行转账 假设我们要实现一个银行转账功能,从账户A转到账户B。如果不加锁,可能会出现以下情况:
- 线程1读取账户A余额为100元。
- 线程2同时读取账户A余额为100元。
- 线程1扣除50元,写入50元。
- 线程2扣除30元,写入70元。 最终账户A余额变成了70元,而不是预期的20元。这就是数据不一致导致的错误。
使用synchronized解决:
public class BankAccount {
private double balance;
public synchronized void withdraw(double amount) {
if (balance >= amount) {
System.out.println(Thread.currentThread().getName() + " 取款: " + amount);
try {
Thread.sleep(100); // 模拟耗时操作
} catch (InterruptedException e) {
e.printStackTrace();
}
balance -= amount;
}
}
public synchronized double getBalance() {
return balance;
}
}
这里我们将withdraw和getBalance都设为synchronized,确保对余额的操作是原子的。
3. 死锁:锁的陷阱
使用synchronized最大的风险之一就是死锁(Deadlock)。死锁发生在两个或多个线程互相持有对方需要的锁,并且都在等待对方释放锁,导致所有线程都无法继续执行。
死锁示例:
Object lock1 = new Object();
Object lock2 = new Object();
// 线程1
new Thread(() -> {
synchronized (lock1) {
System.out.println("Thread 1: Holding lock 1...");
try { Thread.sleep(10); } catch (Exception e) {}
System.out.println("Thread 1: Waiting for lock 2...");
synchronized (lock2) { // 尝试获取lock2
System.out.println("Thread 1: Holding lock 1 & 2...");
}
}
}).start();
// 线程2
new Thread(() -> {
synchronized (lock2) {
System.out.println("Thread 2: Holding lock 2...");
try { Thread.sleep(10); } catch (Exception e) {}
System.out.println("Thread 2: Waiting for lock 1...");
synchronized (lock1) { // 尝试获取lock1
System.out.println("Thread 2: Holding lock 1 & 2...");
}
}
}).start();
在这个例子中,线程1先持有lock1再请求lock2,而线程2先持有lock2再请求lock1。如果它们的执行顺序恰好交叉,就会形成死锁。
如何避免死锁?
- 固定锁的顺序:始终按照相同的顺序获取锁。例如,总是先获取
lock1再获取lock2。 - 使用超时机制:在JDK 5之后,可以使用
ReentrantLock的tryLock(timeout, TimeUnit)方法,如果在指定时间内无法获取锁,就放弃并回滚已持有的锁。 - 减少锁的范围:尽量缩小
synchronized代码块的粒度,减少持有锁的时间。
volatile:轻量级的可见性保障
如果说synchronized是重拳出击,那么volatile就是轻功水上漂。它不能保证原子性,但能保证可见性和有序性。
1. 什么是可见性?
在多核CPU中,每个核心都有自己的缓存。当一个线程修改了一个共享变量时,它可能会将新值写入自己的缓存,而不是立即写回主内存。其他线程可能仍然从自己的缓存或旧的主内存副本中读取该变量,从而看到过期的值。
volatile关键字告诉JVM:“这个变量是易变的,每次读写都要直接从主内存中进行。”这样,当一个线程修改了volatile变量后,其他线程能立即看到最新的值。
volatile示例:
public class VolatileExample {
private volatile boolean running = true;
public void start() {
new Thread(() -> {
while (running) {
// 做一些工作
}
System.out.println("线程停止");
}).start();
// 模拟一段时间后停止
try { Thread.sleep(1000); } catch (Exception e) {}
running = false; // 其他线程能立即看到这个变化
}
}
如果没有volatile,running变量可能被缓存在工作线程的寄存器或缓存中,即使主线程将其设置为false,工作线程可能永远看不到这个变化,导致死循环。
2. volatile不能做什么?
很多人误以为volatile可以替代synchronized,这是错误的。volatile只保证可见性和有序性,不保证原子性。
错误用法:
private volatile int count = 0;
public void increment() {
count++; // 这不是原子操作!
}
count++实际上分为三步:读取count、加1、写回count。即使count是volatile的,两个线程可能同时读取到相同的值,然后各自加1并写回,导致其中一个线程的修改丢失。这就是竞态条件。
正确做法:
如果需要原子性操作,应该使用AtomicInteger或synchronized。
import java.util.concurrent.atomic.AtomicInteger;
private AtomicInteger count = new AtomicInteger(0);
public void increment() {
count.incrementAndGet(); // 原子操作
}
3. 有序性:防止指令重排
编译器或处理器为了优化性能,可能会对指令进行重排序。例如,在单例模式的懒加载实现中,如果不使用volatile,可能会出现以下问题:
public class Singleton {
private static Singleton instance;
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton(); // 这里可能发生指令重排
}
}
}
return instance;
}
}
在instance = new Singleton()这一步,JVM可能会先分配内存空间,然后返回引用,最后才调用构造函数。如果发生重排,另一个线程可能在构造函数尚未完成时就看到了非空的instance引用,从而使用了一个未初始化的对象,导致程序崩溃。
将instance声明为volatile可以禁止这种重排序,确保instance只有在完全初始化后才对其他线程可见。
实战对比:何时用synchronized,何时用volatile?
| 特性 | synchronized | volatile |
|---|---|---|
| 原子性 | 保证 | 不保证 |
| 可见性 | 保证 | 保证 |
| 有序性 | 保证 | 保证 |
| 锁机制 | 悲观锁,阻塞式 | 无锁,基于内存屏障 |
| 性能 | 较低(尤其是竞争激烈时) | 较高 |
| 适用场景 | 复杂逻辑、复合操作、需要互斥访问 | 状态标志、简单的读写操作、发布-订阅模式 |
建议:
- 如果你只需要保证一个变量的可见性(如开关标志),使用
volatile。 - 如果你需要保证一组操作的原子性(如计数、累加),使用
synchronized或Atomic类。 - 在高并发场景下,优先考虑
java.util.concurrent包中的工具,如ConcurrentHashMap、AtomicInteger等,它们通常比synchronized更高效。
给初学者的贴心提示
学习多线程编程就像学骑自行车,刚开始总会摔倒。以下是一些实用的建议:
- 从小处着手:先写一个简单的计数器,分别用
synchronized和volatile实现,观察结果差异。 - 善用调试工具:使用IDE的调试功能,设置断点,观察线程的执行顺序和变量值的变化。
- 阅读源码:看看JDK内部是如何实现
ConcurrentHashMap或ThreadPoolExecutor的,这些是最好的教科书。 - 保持冷静:遇到死锁时,不要慌张。使用
jstack命令生成线程dump,分析线程状态,找出锁的顺序问题。
记住,多线程编程的核心不是炫技,而是正确地管理共享状态。当你能够清晰地理解每个线程在做什么,以及它们如何交互时,你就已经迈出了成为专家的第一步。希望这篇文章能帮你理清思路,在未来的项目中游刃有余地处理并发问题。
