在Java多线程编程中,synchronized是最基础也是最常用的线程同步机制。它就像是一个交通信号灯,控制着多个线程对共享资源的访问顺序,确保在任何时刻只有一个线程能够进入临界区。这个关键字背后蕴含着JVM精心设计的锁机制,理解它的工作原理对于编写高性能、线程安全的Java程序至关重要。
synchronized之所以能成为Java并发编程的基石,是因为它完美解决了多线程环境下的三个核心问题:
原子性问题:想象你在银行柜台办理业务,柜员一次只能处理一个客户的请求。synchronized保证了代码块内的操作就像这个柜员一样,不会被其他线程打断,要么全部执行完成,要么完全不执行。
可见性问题:当线程A修改了共享变量后,线程B可能看不到这个变化。synchronized通过在释放锁时将本地内存的修改刷新到主内存,获取锁时清空本地内存来保证可见性,就像在办公室公告板上更新通知,确保所有人都能看到最新信息。
有序性问题:编译器和处理器可能会对指令进行重排序优化。synchronized通过"监视器锁"机制保证了同步块内的代码执行顺序与程序顺序一致,就像严格按照菜谱步骤做菜,不会随意调整操作顺序。
根据不同的应用场景,synchronized有三种灵活的使用方式:
java复制public class BankAccount {
private double balance;
public synchronized void deposit(double amount) {
balance += amount;
}
}
这种方式锁定的是当前对象实例(this),适用于多个线程操作同一个对象实例的场景。比如多个ATM机同时向同一个银行账户存款。
注意:不同实例间的同步方法不会互相阻塞。如果需要对所有实例进行同步控制,需要使用类锁。
java复制public class IdGenerator {
private static int counter = 0;
public static synchronized int getNextId() {
return ++counter;
}
}
静态同步方法锁定的是类的Class对象,相当于IdGenerator.class。这种锁的范围更大,会影响该类的所有实例。常用于生成全局唯一的序列号等场景。
java复制public class FineGrainedLock {
private final Object readLock = new Object();
private final Object writeLock = new Object();
public void read() {
synchronized(readLock) {
// 读操作
}
}
public void write() {
synchronized(writeLock) {
// 写操作
}
}
}
同步代码块是最灵活的方式,可以指定任意对象作为锁,实现更细粒度的锁控制。上面的示例展示了读写分离锁的实现,允许多个读操作并行执行,而写操作则需要独占锁。
synchronized的底层实现是一套精妙的锁升级机制,它根据线程竞争情况动态调整锁策略,在保证线程安全的同时尽可能提高性能。
每个Java对象在内存中都包含对象头,其中Mark Word存储了对象的哈希码、GC分代年龄和锁状态信息。在32位JVM中,Mark Word的结构如下:
| 锁状态 | 25bit | 4bit | 1bit(偏向锁) | 2bit(锁标志) |
|---|---|---|---|---|
| 无锁 | 对象的hashCode | 分代年龄 | 0 | 01 |
| 偏向锁 | 线程ID + Epoch | 分代年龄 | 1 | 01 |
| 轻量级锁 | 指向栈中锁记录的指针 | 00 | ||
| 重量级锁 | 指向Monitor的指针 | 10 | ||
| GC标记 | 11 |
JDK1.6之后,synchronized的锁状态会随着竞争情况逐步升级:
偏向锁:适用于单线程重复访问的场景。JVM通过CAS操作将线程ID记录到Mark Word中,后续该线程进入同步块时只需检查线程ID是否匹配,无需同步操作。
轻量级锁:当有第二个线程尝试获取锁时,偏向锁升级为轻量级锁。线程通过CAS操作将Mark Word替换为指向线程栈中锁记录的指针。如果失败,线程会通过自旋(循环尝试)等待锁释放。
重量级锁:当自旋超过一定次数(默认10次)仍未获取锁,轻量级锁升级为重量级锁。此时线程会被挂起,进入操作系统内核的等待队列,等待被唤醒。
重量级锁的实现依赖于Monitor对象(ObjectMonitor),其核心结构包括:
_owner:指向持有锁的线程_EntryList:等待获取锁的线程队列_WaitSet:调用wait()方法等待的线程队列_recursions:锁的重入次数当线程尝试获取锁时,如果Monitor的_owner为空,则获取成功;否则进入_EntryList等待。释放锁时,会从_EntryList或_WaitSet中唤醒等待线程。
减少锁的持有时间:只在必要的地方加锁,同步块中不要包含耗时操作(如IO操作)。
降低锁的粒度:使用多个细粒度锁代替一个大锁,如ConcurrentHashMap的分段锁设计。
避免锁的嵌套:容易导致死锁,如果必须嵌套,要确保所有线程以相同的顺序获取锁。
考虑使用读写锁:对于读多写少的场景,ReentrantReadWriteLock比synchronized性能更好。
注意锁的对象选择:避免使用可能被重用的对象(如字符串常量)作为锁,容易导致意外冲突。
如果说synchronized是Java并发编程中的重型武器,那么volatile就是一把精巧的手术刀。它虽然不能保证原子性,但在特定场景下能提供更轻量级的线程安全解决方案。
volatile解决了多线程环境下的两个关键问题:
可见性问题:确保一个线程对变量的修改能立即被其他线程看到。这通过强制读写直接操作主内存来实现,绕过了CPU缓存。
有序性问题:禁止指令重排序优化,保证代码执行顺序与程序顺序一致。这对于双重检查锁定等模式至关重要。
重要限制:
volatile不保证复合操作的原子性。例如count++这样的操作,虽然count是volatile的,但仍然需要额外的同步措施。
java复制public class Server {
private volatile boolean running = true;
public void start() {
new Thread(() -> {
while(running) {
// 处理请求
}
}).start();
}
public void stop() {
running = false;
}
}
在这个经典的开关模式中,volatile确保了主线程调用stop()方法后,工作线程能立即看到running标志的变化,及时终止循环。
java复制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的魔法来自于JVM插入的内存屏障(Memory Barrier)指令和CPU的缓存一致性协议。
JVM会在volatile变量的读写操作前后插入特定的内存屏障:
这些屏障就像交通警察,严格控制指令的执行顺序和内存的可见性。
现代CPU通常使用MESI协议来维护缓存一致性。当一个CPU核心修改了volatile变量时:
这个过程确保了所有线程看到的volatile变量值都是一致的。
| 特性 | synchronized | volatile |
|---|---|---|
| 原子性 | 保证 | 不保证 |
| 可见性 | 保证 | 保证 |
| 有序性 | 保证 | 保证 |
| 阻塞 | 是 | 否 |
| 性能 | 较高开销 | 较低开销 |
| 适用场景 | 复杂同步逻辑 | 简单状态标志 |
| 锁升级 | 支持 | 不支持 |
| 重入 | 支持 | 不适用 |
不适用于复合操作:像i++这样的操作需要额外的同步措施,可以考虑使用AtomicInteger等原子类。
不要过度使用:volatile不能替代synchronized,只有在真正需要解决可见性问题时才使用。
注意64位变量的特殊处理:在32位JVM上,long和double的非volatile变量可能会被分成两个32位操作,导致问题。
与final的配合使用:final字段本身具有很好的可见性保证,通常不需要再加volatile。
Java内存模型(JMM)通过happen-before关系来定义操作之间的内存可见性保证。理解这些规则对于编写正确的并发程序至关重要。
happen-before关系就像时间线上的先后顺序标记,它确保:
重要提示:happen-before不等于物理时间上的先后顺序。编译器在不改变程序语义的前提下可以重排序指令。
在一个线程内,代码的书写顺序决定了操作之间的happen-before关系。这就像阅读一本书,正常情况下你会从前到后按顺序阅读。
java复制int x = 1; // 操作A
int y = 2; // 操作B
在这个例子中,操作A happen-before 操作B。
一个锁的解锁操作 happen-before 后续对这个锁的加锁操作。这就像会议室的使用规则:前一个团队离开(解锁)后,下一个团队才能进入(加锁)。
java复制// 线程1
synchronized(lock) {
x = 42; // 操作A
} // 解锁操作
// 线程2
synchronized(lock) {
System.out.println(x); // 保证看到42
}
对一个volatile变量的写操作 happen-before 后续对这个变量的读操作。这类似于新闻发布:记者发布新闻(写)后,读者才能看到(读)。
java复制// 线程1
volatile boolean flag = false;
x = 42; // 操作A
flag = true; // 操作B
// 线程2
if(flag) {
System.out.println(x); // 保证看到42
}
线程的start()方法调用 happen-before 该线程的任何操作。就像比赛开始前,裁判必须先吹哨。
java复制int x = 42;
Thread t = new Thread(() -> {
System.out.println(x); // 保证看到42
});
x = 100;
t.start();
线程中的所有操作 happen-before 其他线程检测到该线程已经终止(通过Thread.join()或Thread.isAlive())。这类似于等待同事完成工作:只有他真正完成后,你才能确认结果。
java复制int x = 0;
Thread t = new Thread(() -> {
x = 42;
});
t.start();
t.join();
System.out.println(x); // 保证看到42
如果A happen-before B,且B happen-before C,那么A happen-before C。这种传递性就像多米诺骨牌效应。
理解happen-before关系可以帮助我们:
例如,下面的代码虽然看起来没有同步,但由于happen-before规则,实际上是线程安全的:
java复制class SafePublication {
static volatile Resource resource;
public static Resource getInstance() {
if(resource == null) {
synchronized(SafePublication.class) {
if(resource == null) {
resource = new Resource();
}
}
}
return resource;
}
}
这里volatile保证了Resource的初始化完成 happen-before 其他线程读取resource变量。
掌握了并发关键字的理论知识后,我们需要关注实际开发中的最佳实践和常见陷阱。
java复制// 不推荐 - 锁范围过大
public synchronized void process() {
readFile(); // 耗时IO操作
compute(); // 计算密集型操作
updateDB(); // 网络IO操作
}
// 推荐 - 只锁必要的部分
public void process() {
readFile();
synchronized(this) {
compute();
}
updateDB();
}
java复制// 危险 - 可能导致死锁
public void transfer(Account from, Account to, int amount) {
synchronized(from) {
synchronized(to) {
from.withdraw(amount);
to.deposit(amount);
}
}
}
// 安全方案1 - 按固定顺序获取锁
public void transfer(Account a1, Account a2, int amount) {
Account first = a1.id < a2.id ? a1 : a2;
Account second = first == a1 ? a2 : a1;
synchronized(first) {
synchronized(second) {
first.withdraw(amount);
second.deposit(amount);
}
}
}
// 安全方案2 - 使用tryLock
public void transfer(Account from, Account to, int amount) {
while(true) {
if(from.lock.tryLock()) {
try {
if(to.lock.tryLock()) {
try {
from.withdraw(amount);
to.deposit(amount);
return;
} finally {
to.lock.unlock();
}
}
} finally {
from.lock.unlock();
}
}
Thread.sleep(randomDelay);
}
}
java复制class DateParser {
private final SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
public Date parse(String dateStr) throws ParseException {
return sdf.parse(dateStr); // SimpleDateFormat不是线程安全的
}
}
解决方案:
java复制class FalseSharing {
volatile long value1; // 可能和value2在同一个缓存行
volatile long value2;
}
解决方案:
java复制class PaddedAtomicLong {
volatile long value;
long p1, p2, p3, p4, p5, p6; // 填充
}
java复制class OverSynchronized {
private final Map<String, String> map = new HashMap<>();
private final Object lock = new Object();
public void put(String key, String value) {
synchronized(lock) { // 锁粒度太粗
map.put(key, value);
}
}
}
改进方案:
减少锁的争用:
减小临界区范围:
考虑无锁算法:
合理使用volatile:
并发问题往往难以复现和调试,以下是一些有用的技巧:
使用线程转储(Thread Dump):
jstack <pid>使用JConsole或VisualVM:
编写确定性测试:
记录日志:
java复制class ConcurrentLogger {
private static final Logger logger = LoggerFactory.getLogger(ConcurrentLogger.class);
public void doWork() {
logger.debug("Thread {} entering sync block", Thread.currentThread().getId());
synchronized(this) {
logger.debug("Thread {} acquired lock", Thread.currentThread().getId());
// 工作代码
}
logger.debug("Thread {} released lock", Thread.currentThread().getId());
}
}
在实际项目中,我发现大多数并发问题都源于对共享状态的不当管理。遵循"要么共享不可变,要么可变不共享"的原则,可以避免很多麻烦。对于必须共享的可变状态,要清楚地定义访问协议,并使用适当的同步机制。