作为Java并发编程中最基础的同步机制,**synchronized看似简单直接,只需在方法或代码块上加上关键字,就能确保线程安全。**然而,这种表面的简单背后,却隐藏着诸多陷阱。
在我十年的Java开发生涯中,亲眼目睹过无数由synchronized使用不当导致的系统灾难:从莫名其妙的数据错乱,到令人头痛的性能瓶颈,再到那些让整个团队通宵排查的神秘死锁。这些问题往往在开发环境中难以复现,却在生产环境中肆意妄为,给业务带来严重影响。
今天,我将揭开synchronized使用中最常见的三个问题,通过真实案例帮助你彻底理解这些陷阱,并掌握对应的解决方案。
一、锁对象与被保护资源不匹配
准备在开发一个多线程计数器服务,需要让多个线程安全地递增同一个计数器,使用synchronized关键字来保护共享资源。
java
public class CounterService {
private int counter = 0;
public void incrementCounter() {
// 每次调用都创建新的锁对象
Object lock = new Object();
synchronized(lock) {
counter++; // 看似加锁,实际无保护效果
}
}
public int getCounter() {
return counter;
}
}
在修改counter时使用了synchronized块。然而,实际运行时发现计数器的值非常混乱,与预期完全不符。
问题就在于锁对象的选择上。
在这段代码中,每次调用incrementCounter()方法都会创建一个全新的lock对象。这就好比每个人进入同一个房间时都带着自己的钥匙,而不是使用同一把钥匙来控制入口 - 自然无法起到互斥的作用!
要解决这个问题,我们需要确保所有线程使用相同的锁对象:
java
public class CounterService {
private int counter = 0;
private final Object lock = new Object(); // 使用一个一致的锁对象
public void incrementCounter() {
synchronized(lock) {
counter++;
}
}
public int getCounter() {
synchronized(lock) { // 读取操作也需要加锁
return counter;
}
}
}
所有线程现在都是使用同一个"lock对象"来控制对计数器的访问。对读取操作加了锁,这确保了读取操作能够看到其他线程的最新修改,解决了可见性问题。
在实际项目中,这种改进可能看起来微小,但却能彻底解决由于锁选择不当导致的竞态条件,在并发环境下避免出错。
二、锁粒度选择不当
随着用户量增长,需要开发了一个缓存系统来提高性能,这个系统需要允许多个线程同时读写不同的缓存项。
java
public class SimpleCache<K, V> {
private Map<K, V> cache = new HashMap<>();
public V get(K key) {
synchronized(this) { // 对整个对象加锁
return cache.get(key);
}
}
public void put(K key, V value) {
synchronized(this) { // 对整个对象加锁
cache.put(key, value);
}
}
}
这段代码确实是线程安全的,但随着系统负载增加,你会发现性能开始急剧下降。用户开始抱怨系统响应缓慢,监控显示CPU利用率不高,但吞吐量却很低。
在代码中,我们对整个缓存对象加锁,导致即使线程操作的是不同的键值对,也必须排队等待,这就是所谓的"粗粒度锁"问题。
解决方案是使用更细粒度的锁设计,或者直接使用Java并发包中专门设计的并发集合:
java
public class ImprovedCache<K, V> {
// 使用支持并发的集合
private final ConcurrentHashMap<K, V> cache = new ConcurrentHashMap<>();
public V get(K key) {
return cache.get(key);
}
public void put(K key, V value) {
cache.put(key, value);
}
// 对于需要原子性的复合操作
public V putIfAbsent(K key, V value) {
return cache.putIfAbsent(key, value);
}
}
这样的改进就像是将超市改造成多个入口,顾客可以直接进入自己想去的区域。ConcurrentHashMap内部实现了分段锁机制,不同的键可能映射到不同的锁,大大提高了并发处理能力。
在实际项目中,这种优化可能会将系统的并发吞吐量提升数倍甚至数十倍。特别是在微服务架构中,一个看似小的锁优化可能会对整个系统的响应时间产生显著影响。在高并发环境中,细粒度锁往往是性能提升的关键。
三、多锁导致的死锁问题
在银行系统中,用户可以在不同账户之间转移资金。一个直观的实现可能是这样的:
java
public class BankAccount {
private double balance;
private final String accountId;
public BankAccount(String accountId, double initialBalance) {
this.accountId = accountId;
this.balance = initialBalance;
}
public void transfer(BankAccount to, double amount) {
synchronized(this) { // 先锁住当前账户
if (balance >= amount) {
synchronized(to) { // 再锁住目标账户
this.balance -= amount;
to.balance += amount;
}
}
}
}
}
这段代码看起来合理,先锁住源账户确认余额充足,再锁住目标账户完成转账。然而,在测试过程中,你可能会偶然发现系统有时会完全卡住,没有任何错误日志,应用程序也没有崩溃,但就是停止响应了。
这正是臭名昭著的死锁问题。
想象两个顾客A和B各自持有一把钥匙,且都需要两把钥匙才能完成操作。如果A拿着钥匙1等钥匙2,而B拿着钥匙2等钥匙1,他们将永远等待下去。在我们的代码中,当两个线程同时尝试在两个账户之间相互转账时:
- 线程1执行:accountA.transfer(accountB, 100),锁住了accountA,等待accountB的锁
- 同时,线程2执行:accountB.transfer(accountA, 50),锁住了accountB,等待accountA的锁
两个线程都无法继续执行,形成了死锁,系统陷入了永久等待状态。
解决这个问题需要一个巧妙的策略,让所有线程以相同的顺序获取锁:
java
public class SafeBankAccount {
private double balance;
private final String accountId;
public SafeBankAccount(String accountId, double initialBalance) {
this.accountId = accountId;
this.balance = initialBalance;
}
public void transfer(SafeBankAccount to, double amount) {
SafeBankAccount first = this;
SafeBankAccount second = to;
// 根据账户ID确定锁的获取顺序,防止死锁
if (this.accountId.compareTo(to.accountId) > 0) {
first = to;
second = this;
}
synchronized(first) {
synchronized(second) {
if (this.balance >= amount) {
this.balance -= amount;
to.balance += amount;
}
}
}
}
}
这种解决方案就像是制定了一个规则:不管谁先到,都必须按照账户ID的字典顺序获取锁。这样,所有线程都遵循相同的获取锁顺序,从根本上避免了死锁的可能性。
**在实际项目中,这种锁顺序策略可以防止系统出现难以排查的死锁问题,大大提高了系统的稳定性和可靠性。**特别是在金融系统中,这种稳定性至关重要。值得注意的是,这种解决方案不仅保持了转账操作的原子性,还巧妙地避免了死锁风险。
四、正确使用synchronized
1、锁对象与资源的匹配问题
确保锁对象与被保护资源有明确的对应关系,并且在所有线程间共享同一把锁。就像一个房间只能用一把钥匙控制进入一样。
2、锁粒度的选择问题
根据性能需求选择合适的锁粒度。粗粒度锁实现简单但并发度低,细粒度锁并发度高但复杂度增加。在高并发系统中,恰当的锁粒度设计常常是性能优化的关键。
3、建立一致的锁获取顺序
当系统中需要多个锁时,制定明确的锁获取规则,所有线程按照相同的顺序获取锁,从根本上避免死锁风险。
4、根据系统需求选择合适的锁粒度
在保证线程安全的前提下,尽可能使用细粒度锁来提高系统并发性能。在高并发场景下,考虑使用ConcurrentHashMap等并发集合类来替代简单的synchronized块。
五、总结
本文深入探讨了Java开发中使用synchronized可能遇到的三个典型问题及其解决方案。
在Java并发编程中,正确使用synchronized可以保证线程安全,但使用不当则会引发各种问题。开发者需要确保锁对象与资源的正确对应、选择合适的锁粒度、制定一致的锁获取顺序,才能构建既安全又高效的多线程应用。这些看似简单的原则,却是解决多线程编程中大多数复杂问题的基础。