1. 为什么需要synchronized锁机制
在Java多线程编程中,线程安全问题是每个开发者都必须面对的挑战。想象一下,当多个线程同时访问和修改同一个共享资源时,如果没有适当的同步机制,就会导致数据不一致、脏读等问题。这就是synchronized锁机制存在的根本原因。
我曾在实际项目中遇到过这样一个案例:一个电商平台的库存管理系统,在高并发场景下出现了超卖问题。经过排查发现,正是因为多个线程同时执行库存扣减操作,而缺乏有效的同步控制,导致库存数量出现负数。引入synchronized后,这个问题得到了完美解决。
synchronized是Java内置的锁机制,它提供了三种基本用法:
- 实例方法同步
- 静态方法同步
- 代码块同步
每种用法都有其特定的应用场景和实现原理,我们将在后续章节详细解析。
2. synchronized的基本用法
2.1 实例方法同步
在实例方法上使用synchronized是最简单的同步方式。当一个线程访问某个对象的synchronized方法时,其他线程将无法同时访问该对象的任何synchronized方法。
java复制public class Counter {
private int count = 0;
public synchronized void increment() {
count++;
}
public synchronized int getCount() {
return count;
}
}
注意:实例方法同步锁住的是当前对象实例(this),不同实例之间不会相互影响。
2.2 静态方法同步
静态方法同步锁住的是类的Class对象,这意味着它会阻止多个线程同时访问该类的任何同步静态方法。
java复制public class StaticCounter {
private static int count = 0;
public static synchronized void increment() {
count++;
}
public static synchronized int getCount() {
return count;
}
}
2.3 代码块同步
代码块同步提供了更细粒度的控制,可以指定要锁定的对象。
java复制public class FineGrainedLock {
private final Object lock1 = new Object();
private final Object lock2 = new Object();
private int value1 = 0;
private int value2 = 0;
public void increment1() {
synchronized(lock1) {
value1++;
}
}
public void increment2() {
synchronized(lock2) {
value2++;
}
}
}
这种细粒度锁可以显著提高并发性能,因为不同的操作可以并行执行,只要它们不竞争同一个锁。
3. synchronized的底层实现原理
3.1 Java对象头与Monitor
每个Java对象在内存中都有对象头,其中包含了与锁相关的信息。synchronized的实现正是基于这个对象头中的Mark Word。
对象头主要包含:
- Mark Word:存储对象的hashCode、GC分代年龄、锁状态等信息
- 类型指针:指向类的元数据
- 数组长度(如果是数组)
在32位JVM中,Mark Word的结构如下:
| 锁状态 | 25bit | 4bit | 1bit(偏向锁) | 2bit(锁标志) |
|---|---|---|---|---|
| 无锁 | hashCode | 分代年龄 | 0 | 01 |
| 偏向锁 | 线程ID+epoch | 分代年龄 | 1 | 01 |
| 轻量级锁 | 指向栈中锁记录的指针 | 00 | ||
| 重量级锁 | 指向Monitor的指针 | 10 | ||
| GC标记 | 11 |
3.2 Monitor机制
Monitor是synchronized实现的核心,每个Java对象都关联一个Monitor对象。Monitor主要由以下几部分组成:
- Owner:持有锁的线程
- EntryList:等待获取锁的线程队列
- WaitSet:调用wait()方法后进入等待状态的线程队列
当线程尝试获取锁时,JVM会根据当前锁状态采取不同的策略:
- 无锁状态:通过CAS操作尝试获取锁
- 偏向锁:检查是否偏向当前线程
- 轻量级锁:通过自旋尝试获取锁
- 重量级锁:进入阻塞状态,等待唤醒
3.3 锁升级过程
为了提高性能,JVM实现了锁升级机制:
- 初始状态:无锁
- 第一个线程访问:升级为偏向锁
- 出现竞争:升级为轻量级锁(自旋锁)
- 自旋超过阈值:升级为重量级锁
这个升级过程是不可逆的,称为锁膨胀。
4. synchronized的性能优化
4.1 锁消除
JVM在JIT编译时,会对不可能存在共享数据竞争的锁进行消除。例如:
java复制public String concatString(String s1, String s2, String s3) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
sb.append(s3);
return sb.toString();
}
在这个例子中,StringBuffer是方法内部的局部变量,不会被其他线程访问,因此JVM会消除其内部的同步操作。
4.2 锁粗化
当JVM检测到一连串的操作都对同一个对象反复加锁和解锁时,会将锁的范围扩展到整个操作序列的外部。例如:
java复制public void method() {
synchronized(lock) {
// 操作1
}
synchronized(lock) {
// 操作2
}
synchronized(lock) {
// 操作3
}
}
会被优化为:
java复制public void method() {
synchronized(lock) {
// 操作1
// 操作2
// 操作3
}
}
4.3 偏向锁与轻量级锁
偏向锁和轻量级锁都是为了减少同步性能开销而设计的优化手段:
- 偏向锁:假设只有一个线程会访问同步代码,通过记录线程ID来避免同步操作
- 轻量级锁:当有少量线程竞争时,通过CAS和自旋来避免线程阻塞
在实际应用中,可以通过JVM参数来控制这些优化:
code复制-XX:+UseBiasedLocking // 启用偏向锁(JDK15后已废弃)
-XX:BiasedLockingStartupDelay=0 // 偏向锁启动延迟
5. 常见问题与解决方案
5.1 死锁问题
死锁是指两个或多个线程互相持有对方需要的资源,导致所有线程都无法继续执行。例如:
java复制// 线程1
synchronized(lockA) {
synchronized(lockB) {
// 操作
}
}
// 线程2
synchronized(lockB) {
synchronized(lockA) {
// 操作
}
}
解决方案:
- 按固定顺序获取锁
- 使用tryLock()设置超时时间
- 使用jstack等工具检测死锁
5.2 锁粒度过大
过度使用synchronized可能导致性能问题。例如:
java复制public synchronized void process() {
// 耗时操作1
// 耗时操作2
// 耗时操作3
}
优化方案:
- 缩小同步范围
- 使用读写锁
- 使用并发容器
5.3 虚假唤醒问题
当使用wait()/notify()时,可能会出现虚假唤醒。正确的做法是在条件判断时使用while循环:
java复制synchronized(lock) {
while(!condition) {
lock.wait();
}
// 处理逻辑
}
6. synchronized与其他锁的比较
6.1 synchronized vs ReentrantLock
| 特性 | synchronized | ReentrantLock |
|---|---|---|
| 实现方式 | JVM内置 | JDK实现 |
| 锁获取方式 | 自动获取和释放 | 需要手动lock()和unlock() |
| 可中断 | 不支持 | 支持 |
| 公平锁 | 非公平 | 可配置公平/非公平 |
| 条件变量 | 一个 | 多个 |
| 性能 | JDK6后优化,性能相当 | 高竞争时性能略好 |
6.2 synchronized vs volatile
synchronized提供原子性和可见性保证,而volatile只提供可见性保证。volatile适用于:
- 单一变量的原子操作
- 变量的写操作不依赖于当前值
- 变量不需要与其他变量共同参与不变性条件
7. 实战经验分享
7.1 选择合适的锁粒度
在实际项目中,我遇到过这样一个场景:一个高频调用的统计服务,最初使用了类级别的锁:
java复制public class StatService {
public static synchronized void addStat(String type) {
// 更新统计
}
}
这导致了严重的性能瓶颈。优化后改为细粒度锁:
java复制public class StatService {
private static final Map<String, Object> locks = new ConcurrentHashMap<>();
public static void addStat(String type) {
Object lock = locks.computeIfAbsent(type, k -> new Object());
synchronized(lock) {
// 更新统计
}
}
}
性能提升了近10倍。
7.2 避免锁的嵌套
锁嵌套是死锁的常见原因。在代码审查时,我通常会特别注意以下几种情况:
- 方法A调用方法B,两者都使用synchronized
- 同步代码块中调用外部服务
- 持有锁时执行耗时操作
7.3 监控锁竞争
使用JVisualVM或Arthas等工具可以监控锁竞争情况。重点关注:
- 锁等待时间
- 持有锁的线程
- 锁的持有时间
通过这些数据可以识别性能瓶颈并进行针对性优化。
8. 面试常见问题解析
8.1 synchronized的实现原理
这个问题考察对Java内存模型和锁机制的理解。回答要点:
- Java对象头中的Mark Word
- Monitor机制(Owner、EntryList、WaitSet)
- 锁升级过程(无锁→偏向锁→轻量级锁→重量级锁)
8.2 synchronized和ReentrantLock的区别
回答框架:
- 实现层面:JVM内置 vs JDK实现
- 功能层面:可中断、公平锁、条件变量等
- 性能层面:不同场景下的表现
- 使用层面:自动释放 vs 手动释放
8.3 什么是锁膨胀
锁膨胀是指锁状态从偏向锁升级到轻量级锁,再到重量级锁的过程。需要说明:
- 触发条件(竞争激烈程度)
- 各状态的特点
- 为什么需要锁膨胀(性能权衡)
8.4 wait()和sleep()的区别
关键区别点:
- 所属类:Object vs Thread
- 锁的释放:wait()会释放锁,sleep()不会
- 唤醒方式:notify()/notifyAll() vs 超时或interrupt()
- 使用条件:wait()必须在同步块中调用
9. 最佳实践建议
-
尽量使用同步代码块:相比同步方法,同步代码块可以更精确地控制锁的范围。
-
避免在构造方法中同步:这可能导致其他线程看到未完全初始化的对象。
-
考虑使用并发容器:对于集合类操作,优先考虑使用ConcurrentHashMap等并发容器。
-
注意锁的可见性:确保锁对象本身对所有线程可见,通常使用final字段。
-
文档化锁策略:在团队协作中,明确记录每个锁的保护范围和获取顺序。
-
性能测试:任何锁优化都应该基于实际性能测试数据,而不是主观猜测。
-
考虑替代方案:在某些场景下,原子变量或不可变对象可能是更好的选择。
10. 未来发展趋势
随着Java版本的演进,synchronized的实现也在不断优化:
-
偏向锁的废弃:从JDK15开始,偏向锁被标记为废弃,因为现代应用往往有更多线程竞争。
-
自适应自旋:JVM会根据历史数据动态调整自旋次数,提高性能。
-
锁消除优化:JVM会进行更激进的锁消除优化,减少不必要的同步开销。
-
与虚拟线程的配合:随着Project Loom的推进,synchronized与虚拟线程的交互将更加高效。
在实际开发中,我们应该持续关注这些变化,但不必过早优化。基本原则仍然是:先确保正确性,再考虑性能优化。
