Java多线程同步:synchronized原理与最佳实践
1. 为什么我们需要synchronized?
当我在2013年第一次遇到多线程数据竞争问题时,一个简单的计数器程序给了我深刻教训。当时我创建了10个线程同时对同一个计数器进行++操作,理论上应该得到10000,但实际运行结果总是在8000-9000之间波动。这就是典型的线程安全问题,而synchronized正是Java为解决这类问题提供的内置解决方案。
synchronized关键字在Java中用于控制多线程对共享资源的访问,它能确保同一时刻只有一个线程可以执行特定代码段或访问特定对象。这个特性对于银行转账、库存扣减等需要原子性操作的场景至关重要。
注意:即使是最简单的i++操作,在底层也会被拆分为"读取-修改-写入"三个步骤,不加锁就会导致更新丢失。
2. synchronized的三种使用方式
2.1 实例方法同步
这是最常见的用法,直接在方法声明中添加synchronized:
public synchronized void transfer(Account target, int amount) { if (this.balance >= amount) { this.balance -= amount; target.balance += amount; } }这种写法等价于用synchronized(this)包裹方法体。锁对象是当前实例,不同实例间的操作不会互相阻塞。
2.2 静态方法同步
当需要同步静态方法时,锁对象变成类的Class对象:
public static synchronized void updateConfig() { // 更新配置的操作 }这相当于synchronized(MyClass.class)。因为Class对象在JVM中唯一,所以能保证全局同步。
2.3 同步代码块
最灵活的用法是指定任意对象作为锁:
private final Object lock = new Object(); public void doSomething() { // 非同步代码 synchronized(lock) { // 临界区代码 } // 非同步代码 }这种方式的优势是:
- 锁粒度更细,提升并发性能
- 可以使用专用锁对象,避免意外锁住this
- 可以实现更复杂的同步策略
3. 底层实现原理
3.1 对象头与Mark Word
每个Java对象在内存中都由三部分组成:
- 对象头
- 实例数据
- 对齐填充
其中对象头包含:
- Mark Word(存储哈希码、GC分代年龄、锁状态等)
- 类型指针(指向类元数据)
- 数组长度(如果是数组)
在32位JVM中,Mark Word结构如下:
| 锁状态 | 25bit | 4bit | 1bit(偏向锁) | 2bit(锁标志) |
|---|---|---|---|---|
| 无锁 | 哈希码 | 分代年龄 | 0 | 01 |
| 偏向锁 | 线程ID+时间戳 | 分代年龄 | 1 | 01 |
| 轻量级锁 | 指向栈中锁记录的指针 | 00 | ||
| 重量级锁 | 指向互斥量的指针 | 10 | ||
| GC标记 | 空 | 11 |
3.2 锁升级过程
现代JVM采用锁升级策略来优化同步性能:
- 无锁状态:初始状态
- 偏向锁:第一个线程访问时,在Mark Word中记录线程ID
- 轻量级锁:当有竞争时,升级为CAS自旋锁
- 重量级锁:自旋超过阈值(默认10次)后,升级为操作系统互斥锁
这个升级过程是不可逆的,目的是减少直接使用重量级锁带来的性能开销。
3.3 字节码层面
编译后的同步方法会多出两条指令:
- monitorenter:进入同步块
- monitorexit:退出同步块
JVM保证每个monitorenter必须有对应的monitorexit,即使在异常情况下也会执行。
4. 性能优化实践
4.1 减小锁粒度
错误的做法:
public synchronized void processOrder(Order order) { validate(order); calculate(order); save(order); notify(order); }优化方案:
public void processOrder(Order order) { validate(order); // 无需同步 synchronized(this) { calculate(order); save(order); } notify(order); // 无需同步 }4.2 分离读写锁
对于读多写少的场景,可以使用ReadWriteLock:
private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock(); public Data readData() { rwLock.readLock().lock(); try { return data; } finally { rwLock.readLock().unlock(); } } public void updateData(Data newData) { rwLock.writeLock().lock(); try { this.data = newData; } finally { rwLock.writeLock().unlock(); } }4.3 避免死锁
死锁的四个必要条件:
- 互斥条件
- 请求与保持
- 不剥夺条件
- 循环等待
预防措施:
- 按固定顺序获取锁
- 设置锁超时时间
- 使用tryLock()而非lock()
5. 常见问题排查
5.1 锁竞争问题诊断
使用jstack查看线程状态:
jstack <pid> | grep -A 10 "BLOCKED"典型输出:
"Thread-1" #12 prio=5 os_prio=0 tid=0x00007f48740f8000 nid=0x1e03 waiting for monitor entry [0x00007f486b7fe000] java.lang.Thread.State: BLOCKED (on object monitor) at com.example.Test.method(Test.java:15) - waiting to lock <0x000000076ab95c80> (a com.example.Test)5.2 锁膨胀问题
当发现大量线程处于BLOCKED状态时,可能是:
- 锁粒度过大
- 同步代码执行时间过长
- 锁分配不合理
解决方案:
- 使用JProfiler或VisualVM分析热点
- 考虑使用并发集合替代同步块
- 评估是否可以用原子变量(AtomicInteger等)
5.3 虚假唤醒问题
典型错误代码:
synchronized(lock) { while(!condition) { lock.wait(); } // 处理逻辑 }必须使用while循环而非if判断,因为:
- 操作系统可能产生虚假唤醒
- 其他线程可能意外调用了notifyAll()
6. 与volatile的比较
| 特性 | synchronized | volatile |
|---|---|---|
| 原子性 | 保证 | 不保证 |
| 可见性 | 保证 | 保证 |
| 有序性 | 保证 | 有限保证 |
| 适用范围 | 代码块/方法 | 变量 |
| 线程阻塞 | 会 | 不会 |
| 编译器优化 | 禁止 | 有限禁止 |
实际选择原则:
- 需要原子性操作 → synchronized
- 只需可见性保证 → volatile
- 既要可见性又要简单原子操作 → Atomic类
7. 现代Java中的替代方案
7.1 java.util.concurrent包
对于高并发场景,推荐使用:
- ReentrantLock:可中断、可定时、公平锁
- StampedLock:乐观读锁
- Semaphore:信号量控制
- CountDownLatch:线程协调
7.2 无锁编程
CAS(Compare-And-Swap)实现的无锁数据结构:
AtomicInteger counter = new AtomicInteger(0); // 线程安全的自增 counter.incrementAndGet();优点:
- 无阻塞
- 无死锁风险
- 高并发下性能更好
缺点:
- 实现复杂
- ABA问题
- 不适合复杂操作
8. 实际案例:线程安全的LRU缓存
public class SynchronizedLRUCache<K, V> { private final int capacity; private final LinkedHashMap<K, V> map; private final Object lock = new Object(); public SynchronizedLRUCache(int capacity) { this.capacity = capacity; this.map = new LinkedHashMap<K, V>(capacity, 0.75f, true) { @Override protected boolean removeEldestEntry(Map.Entry<K, V> eldest) { return size() > capacity; } }; } public V get(K key) { synchronized(lock) { return map.get(key); } } public void put(K key, V value) { synchronized(lock) { map.put(key, value); } } }优化点:
- 使用专用锁对象而非this
- 利用LinkedHashMap的访问顺序特性
- 重写removeEldestEntry实现自动淘汰
9. 面试常见问题
synchronized和ReentrantLock的区别?
- synchronized是JVM内置实现,ReentrantLock是JDK实现
- ReentrantLock提供更灵活的锁机制(公平锁、条件变量等)
- synchronized会自动释放锁,ReentrantLock需要手动unlock
什么是锁粗化?JVM会将相邻的同步块合并,减少锁获取/释放的开销
双重检查锁定问题?经典的错误单例模式实现:
// 错误示范 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; } }正确实现需要加volatile:
private static volatile Singleton instance;synchronized能否修饰构造方法?语法上可以,但没有实际意义,因为构造方法本身是线程安全的
如何选择锁对象?
- 对于实例同步,通常用this
- 对于静态同步,用Class对象
- 最佳实践是使用private final的专用锁对象
10. 最佳实践总结
锁对象选择:
- 优先使用private final对象
- 避免锁String常量等可能被共享的对象
同步范围:
- 只同步必要的代码块
- 尽量缩短同步块执行时间
异常处理:
synchronized(lock) { try { // 业务代码 } catch(Exception e) { // 处理异常 } }性能监控:
- 关注JVM的锁竞争统计
- 使用JMX或第三方工具监控锁等待时间
代码审查要点:
- 检查是否存在嵌套锁
- 验证锁释放路径是否完整
- 评估锁粒度是否合理
我在实际项目中最深刻的体会是:不要为了同步而同步。在最近的一个高并发项目中,我们通过分析发现80%的同步块其实是不必要的,移除后QPS提升了3倍。多线程编程的艺术在于找到安全与性能的平衡点。
