1. 理解synchronized的本质
在Java并发编程中,synchronized是最基础也是最常用的同步机制。很多人虽然会用,但对它到底锁住了什么对象却一知半解。这个问题在面试中经常被问到,在实际开发中也容易踩坑。
synchronized关键字可以修饰方法,也可以修饰代码块,但两种形式锁定的对象是不同的。理解这个区别对于编写正确的并发程序至关重要。我们先来看一个典型的错误案例:
java复制public class Counter {
private int count = 0;
public synchronized void increment() {
count++;
}
public void decrement() {
synchronized (this) {
count--;
}
}
}
这个例子中,increment()是同步方法,decrement()是同步代码块。看起来它们都在修改count变量,似乎达到了同步效果。但实际上,如果不知道它们各自锁定了什么对象,就可能写出有问题的代码。
2. synchronized方法的锁定机制
2.1 实例方法的锁定
当synchronized修饰实例方法时,它锁定的是当前实例对象,也就是this。换句话说:
java复制public synchronized void method() {
// 方法体
}
等价于:
java复制public void method() {
synchronized(this) {
// 方法体
}
}
这意味着,同一个实例的多个线程调用这个方法时,会互斥执行。但不同实例的线程可以并发执行这个方法。
2.2 静态方法的锁定
当synchronized修饰静态方法时,它锁定的是当前类的Class对象。例如:
java复制public static synchronized void staticMethod() {
// 方法体
}
等价于:
java复制public static void staticMethod() {
synchronized(MyClass.class) {
// 方法体
}
}
这种情况下,无论有多少个实例,只要调用这个静态方法,都会竞争同一把锁。
提示:静态同步方法和实例同步方法使用的是不同的锁,因此它们之间不会互相阻塞。
3. synchronized代码块的锁定机制
synchronized代码块比同步方法更灵活,因为它允许你显式指定要锁定的对象。语法是:
java复制synchronized(lockObject) {
// 代码块
}
3.1 锁定当前对象
最常见的用法是锁定当前实例:
java复制public void method() {
synchronized(this) {
// 代码块
}
}
这与同步实例方法效果相同,但可以只同步方法中的部分代码,而不是整个方法。
3.2 锁定其他对象
你也可以锁定任意对象:
java复制private final Object lock = new Object();
public void method() {
synchronized(lock) {
// 代码块
}
}
这种做法通常用于细粒度锁控制,可以减少锁竞争。
3.3 锁定类对象
与静态同步方法类似,可以锁定Class对象:
java复制public void method() {
synchronized(MyClass.class) {
// 代码块
}
}
4. 锁的范围与性能考量
4.1 锁的范围
同步方法会锁定整个方法体,而同步代码块可以只锁定必要的部分。例如:
java复制public synchronized void process() {
// 不需要同步的代码
// ...
// 需要同步的代码
// ...
// 不需要同步的代码
// ...
}
更好的做法是:
java复制public void process() {
// 不需要同步的代码
synchronized(this) {
// 需要同步的代码
}
// 不需要同步的代码
}
这样可以减少锁的持有时间,提高并发性能。
4.2 锁对象的选择
选择锁对象时需要注意:
- 不要使用可能被重用的对象,比如字符串字面量:
java复制// 错误做法
synchronized("lock") { ... }
因为字符串字面量会被JVM缓存,可能导致意外的锁竞争。
- 最好使用final修饰的专用锁对象:
java复制private final Object lock = new Object();
public void method() {
synchronized(lock) {
// 代码块
}
}
这样可以确保锁对象不会被意外修改。
5. 常见误区与最佳实践
5.1 误区:认为synchronized锁定的是代码
很多初学者误以为synchronized锁定的是代码块或方法本身。实际上,它锁定的是对象。同一个对象的同步方法/代码块才会互斥,不同对象的同步方法/代码块可以并发执行。
5.2 误区:过度使用同步
过度使用synchronized会导致性能问题。应该:
- 只在必要时使用同步
- 尽量减小同步范围
- 考虑使用更高级的并发工具,如ReentrantLock
5.3 最佳实践
- 文档化锁策略:在类文档中说明使用了哪些锁
- 保持锁的简单性:避免嵌套锁和复杂的锁交互
- 考虑使用并发集合:如ConcurrentHashMap代替同步的HashMap
- 避免在同步块中调用外部方法:这可能导致死锁
6. 实际案例分析
6.1 双重检查锁定(DCL)问题
单例模式中常见的双重检查锁定:
java复制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;
}
}
这个实现在Java 5之前是有问题的,因为指令重排序可能导致其他线程看到未完全初始化的实例。Java 5之后可以通过volatile修复:
java复制private static volatile Singleton instance;
6.2 账户转账问题
考虑一个银行账户转账的场景:
java复制public void transfer(Account from, Account to, int amount) {
synchronized(from) {
synchronized(to) {
from.withdraw(amount);
to.deposit(amount);
}
}
}
这种实现可能导致死锁。更好的做法是定义全局锁顺序:
java复制public void transfer(Account from, Account to, int amount) {
Account first = from.getId() < to.getId() ? from : to;
Account second = from.getId() < to.getId() ? to : from;
synchronized(first) {
synchronized(second) {
from.withdraw(amount);
to.deposit(amount);
}
}
}
7. 性能优化技巧
7.1 减小锁粒度
将一个大锁拆分为多个小锁:
java复制// 之前
private final Object lock = new Object();
// 之后
private final Object[] locks = new Object[16];
{
for (int i = 0; i < locks.length; i++) {
locks[i] = new Object();
}
}
public void method(int key) {
synchronized(locks[key % locks.length]) {
// 代码块
}
}
7.2 读写分离
对于读多写少的场景,考虑使用读写锁:
java复制private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
private final Lock readLock = rwLock.readLock();
private final Lock writeLock = rwLock.writeLock();
public void read() {
readLock.lock();
try {
// 读操作
} finally {
readLock.unlock();
}
}
public void write() {
writeLock.lock();
try {
// 写操作
} finally {
writeLock.unlock();
}
}
7.3 锁粗化
在连续获取同一个锁的情况下,JVM可能会自动将多个锁操作合并为一个:
java复制// 可能会被优化为:
synchronized(lock) {
for (int i = 0; i < n; i++) {
// 操作
}
}
8. 常见问题排查
8.1 死锁检测
当程序挂起时,可以使用jstack工具检测死锁:
bash复制jstack <pid>
输出中会显示"Found one Java-level deadlock"和相关的线程堆栈。
8.2 锁竞争分析
使用JVisualVM或Java Mission Control可以分析锁竞争情况,找出热点锁。
8.3 性能监控
关注以下指标:
- 锁等待时间
- 持有锁的时间
- 锁获取频率
9. 替代方案
虽然synchronized简单易用,但在某些场景下可能需要考虑其他方案:
- ReentrantLock:提供更灵活的锁操作,如尝试获取锁、公平锁等
- StampedLock:乐观读锁,在读多写少场景性能更好
- 原子变量:如AtomicInteger,适用于简单的原子操作
- 并发集合:如ConcurrentHashMap,内部已经实现了高效的并发控制
10. 实际项目中的经验
在我参与的一个高并发交易系统中,我们最初大量使用synchronized方法,导致性能瓶颈。通过以下优化显著提升了性能:
- 将粗粒度锁拆分为细粒度锁
- 用并发集合替代同步的集合
- 对读多写少的数据使用读写锁
- 对热点路径进行无锁化改造
一个具体的例子是订单状态更新:原本使用synchronized方法同步整个订单对象,后来改为只同步状态变更相关的部分,吞吐量提升了3倍。
另一个教训是关于锁对象的选择:曾经因为使用字符串作为锁对象导致意外的锁竞争,后来改为使用专用的Object实例作为锁。
在Java并发编程中,理解synchronized到底锁定了什么是基本功。正确的锁策略可以避免很多并发问题,而错误的锁使用则可能导致性能问题甚至死锁。根据我的经验,在设计和实现同步策略时,一定要明确每个锁保护的是什么数据,并确保所有访问这些数据的路径都使用了正确的锁。
