1. 为什么需要独占锁?
在Java多线程编程中,synchronized关键字是最基础的线程同步机制。当多个线程同时访问共享资源时,如果没有适当的同步控制,就会导致数据不一致的问题。我曾在实际项目中遇到过这样一个案例:一个简单的计数器在多线程环境下运行,由于没有同步控制,最终结果总是小于预期值。
synchronized方法实现的独占锁特性,本质上是为了解决多线程环境下的三大核心问题:
- 原子性问题(一个操作要么全部执行,要么都不执行)
- 可见性问题(一个线程对共享变量的修改对其他线程立即可见)
- 有序性问题(程序执行的顺序按照代码的先后顺序执行)
注意:虽然volatile关键字可以解决可见性和有序性问题,但它无法保证原子性,这就是为什么在需要原子性操作时我们必须使用synchronized。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. synchronized方法的底层实现
2.1 对象监视器机制
每个Java对象都有一个内置的监视器锁(monitor),synchronized方法正是通过这个机制实现的。当线程进入synchronized方法时,它会自动获取该方法所属对象的监视器锁;当方法执行完毕退出时,无论是正常返回还是抛出异常,锁都会被自动释放。
在JVM层面,synchronized方法的实现依赖于两个重要的字节码指令:
- monitorenter:获取对象的监视器锁
- monitorexit:释放对象的监视器锁
2.2 锁的存储位置
对象头中的Mark Word是存储锁状态的关键区域。在32位JVM中,Mark Word的结构如下:
| 锁状态 | 25bit | 4bit | 1bit(偏向锁) | 2bit(锁标志位) |
|---|---|---|---|---|
| 无锁 | 对象的hashCode | 对象分代年龄 | 0 | 01 |
| 偏向锁 | 线程ID | Epoch | 1 | 01 |
| 轻量级锁 | 指向栈中锁记录的指针 | 00 | ||
| 重量级锁 | 指向互斥量的指针 | 10 | ||
| GC标记 | 空 | 11 |
2.3 锁升级过程
现代JVM为了优化synchronized性能,实现了锁升级机制:
- 无锁状态:初始状态
- 偏向锁:当第一个线程访问时,会将对象头中的线程ID设置为当前线程ID
- 轻量级锁:当有第二个线程尝试获取锁时,升级为轻量级锁
- 重量级锁:当竞争激烈时,最终会升级为重量级锁
提示:了解这个升级过程对于性能调优非常重要。在低竞争环境下,偏向锁和轻量级锁可以显著减少同步开销。
3. synchronized方法的使用细节
3.1 方法级别的同步
在方法声明中使用synchronized关键字,会使整个方法成为同步方法:
java复制public synchronized void increment() {
count++;
}
这种写法等价于:
java复制public void increment() {
synchronized(this) {
count++;
}
}
3.2 静态方法的同步
对于静态方法,锁的是类的Class对象:
java复制public static synchronized void staticMethod() {
// 方法体
}
这等价于:
java复制public static void staticMethod() {
synchronized(MyClass.class) {
// 方法体
}
}
3.3 可重入性
synchronized锁是可重入的,这意味着同一个线程可以多次获取同一个锁:
java复制public synchronized void methodA() {
methodB(); // 可以调用另一个synchronized方法
}
public synchronized void methodB() {
// 方法体
}
这种特性避免了线程自己把自己阻塞的情况,是设计上的一个重要考虑。
4. 性能考量与最佳实践
4.1 锁粒度控制
过粗的锁粒度会导致性能问题。我曾经优化过一个系统,将一个大synchronized方法拆分为多个小范围的同步块,性能提升了3倍:
java复制// 不推荐 - 锁粒度太粗
public synchronized void processData() {
// 大量不相关的操作
}
// 推荐 - 细粒度锁
public void processData() {
// 非同步操作
synchronized(this) {
// 只有这部分需要同步
}
// 其他非同步操作
}
4.2 锁对象选择
选择合适的锁对象可以避免不必要的竞争:
java复制// 不推荐 - 使用可变对象作为锁
private final List<String> lock = new ArrayList<>();
// 推荐 - 使用专门的对象作为锁
private final Object lock = new Object();
4.3 避免死锁
死锁是多线程编程中的常见问题。我总结了几条避免死锁的实践经验:
- 按固定顺序获取多个锁
- 设置锁获取的超时时间(可以使用tryLock)
- 避免在持有锁时调用外部方法
- 尽量减少同步代码块的大小
5. 常见误区与问题排查
5.1 锁不住的问题
新手常犯的一个错误是锁错了对象:
java复制public void addItem(String item) {
synchronized(new Object()) { // 每次都是新对象,根本锁不住!
list.add(item);
}
}
正确的做法是使用一个final的成员变量作为锁对象。
5.2 性能瓶颈定位
当系统出现性能问题时,如何判断是否是synchronized导致的?
- 使用JVisualVM或JProfiler等工具查看线程状态
- 关注BLOCKED状态的线程
- 检查锁竞争热点
5.3 与Lock接口的对比
虽然Java 5引入了更灵活的Lock接口,但synchronized仍有其优势:
| 特性 | synchronized | Lock |
|---|---|---|
| 自动释放锁 | 是 | 需要手动释放 |
| 可中断 | 否 | 是 |
| 公平锁 | 否 | 可配置 |
| 尝试获取锁 | 否 | 是 |
| 条件变量 | 有限支持 | 灵活支持 |
在实际项目中,我通常会遵循这样的原则:简单场景用synchronized,复杂场景用Lock。
6. 高级话题:JVM对synchronized的优化
6.1 锁消除
JVM会通过逃逸分析判断某些锁是否可以被消除。例如:
java复制public String concat(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb.toString();
}
在这个例子中,StringBuffer是方法局部变量,不会逃逸出方法,因此JVM会消除其内部的同步操作。
6.2 锁粗化
当JVM检测到一连串的对同一个对象的加锁解锁操作时,会把它们合并为一个更大的同步块:
java复制for(int i=0; i<100; i++) {
synchronized(obj) {
// 小操作
}
}
可能会被优化为:
java复制synchronized(obj) {
for(int i=0; i<100; i++) {
// 小操作
}
}
6.3 偏向锁的撤销
当有其他线程尝试获取偏向锁时,持有偏向锁的线程需要撤销偏向锁。这个过程需要等待全局安全点(SafePoint),会导致Stop The World。因此,在高并发环境下,有时关闭偏向锁反而能提升性能:
code复制-XX:-UseBiasedLocking
7. 实际案例:线程安全的计数器实现
让我们通过一个完整的例子来看看如何正确使用synchronized:
java复制public class SafeCounter {
private int count;
private final Object lock = new Object();
// 方法1:同步方法
public synchronized void increment() {
count++;
}
// 方法2:同步块
public void decrement() {
synchronized(lock) {
count--;
}
}
// 方法3:细粒度同步
public int getCount() {
synchronized(lock) {
return count;
}
}
// 方法4:静态同步方法
public static synchronized void log(String message) {
System.out.println(message);
}
}
在这个实现中,我们展示了四种不同的同步方式。根据我的经验,方法2和方法3是更优的选择,因为它们:
- 使用了专门的锁对象,避免了意外锁定this
- 可以更精确地控制同步范围
- 更易于维护和扩展
8. 测试与验证
为了验证我们的理解,我设计了一个简单的测试用例:
java复制public class SynchronizedTest {
private static int sharedCount = 0;
public static void main(String[] args) throws InterruptedException {
final int THREADS = 100;
final int ITERATIONS = 1000;
Thread[] threads = new Thread[THREADS];
for (int i = 0; i < THREADS; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < ITERATIONS; j++) {
increment();
}
});
threads[i].start();
}
for (Thread t : threads) {
t.join();
}
System.out.println("Expected: " + (THREADS * ITERATIONS));
System.out.println("Actual: " + sharedCount);
}
private static synchronized void increment() {
sharedCount++;
}
}
这个测试创建了100个线程,每个线程对共享计数器进行1000次递增操作。如果没有同步,结果会远小于预期的100,000。但有了synchronized关键字,我们总能得到正确的结果。
9. 与其他同步机制的对比
9.1 synchronized vs volatile
| 特性 | synchronized | volatile |
|---|---|---|
| 原子性 | 保证 | 不保证复合操作 |
| 可见性 | 保证 | 保证 |
| 有序性 | 保证 | 有限保证 |
| 互斥访问 | 是 | 否 |
| 性能开销 | 较高 | 较低 |
9.2 synchronized vs Atomic类
Java的java.util.concurrent.atomic包提供了一系列原子类,如AtomicInteger。它们的优势在于:
- 使用CAS(Compare-And-Swap)实现,无锁算法
- 适用于简单的原子操作
- 性能通常优于synchronized
但在复杂操作或需要多个变量保持一致性时,synchronized仍然是更好的选择。
10. 常见面试问题解析
根据我参与面试的经验,以下是与synchronized相关的常见问题:
-
synchronized方法的锁是什么?
- 实例方法锁的是当前实例对象
- 静态方法锁的是类的Class对象
-
synchronized和ReentrantLock有什么区别?
- synchronized是JVM层面的实现,ReentrantLock是API层面的实现
- ReentrantLock提供更多功能:可中断、公平锁、多个条件变量等
- synchronized会自动释放锁,ReentrantLock需要手动释放
-
什么是锁升级?
- 从偏向锁到轻量级锁再到重量级锁的过程
- 目的是在无竞争或低竞争时减少同步开销
-
synchronized能保证可见性吗?
- 能,因为解锁前会把变量写回主内存,加锁时会从主内存读取
-
为什么synchronized是可重入的?
- 为了避免线程自己阻塞自己
- 通过计数器实现,每次重入计数器加1,退出时减1,为0时释放锁
11. 性能优化实战技巧
基于多年的性能调优经验,我总结了几条synchronized的性能优化建议:
-
减小同步范围:只同步必要的代码块,而不是整个方法
-
降低锁粒度:使用多个锁保护不同的资源,而不是一个锁保护所有
-
避免在同步块中执行耗时操作:如IO操作、网络请求等
-
考虑使用读写锁:当读多写少时,ReentrantReadWriteLock可能更合适
-
监控锁竞争:使用JConsole或VisualVM监控BLOCKED线程数量
-
考虑无锁数据结构:如ConcurrentHashMap、AtomicLong等
-
避免锁嵌套:容易导致死锁和性能问题
12. 疑难问题排查案例
曾经遇到过一个生产环境的问题:系统在高并发时性能急剧下降。通过分析发现:
- 线程dump显示大量线程BLOCKED在同一个锁上
- 该锁保护的是一个频繁访问的缓存
- 缓存更新操作较慢,导致锁持有时间过长
解决方案:
- 将一个大锁拆分为多个小锁(锁分段)
- 使用读写锁分离读和写操作
- 引入二级缓存减少对主缓存的访问
优化后,系统吞吐量提升了5倍,平均响应时间降低了80%。这个案例让我深刻理解了合理使用synchronized的重要性。
