1. 为什么需要synchronized?
我第一次接触synchronized关键字是在处理一个电商平台的库存扣减问题时。当时系统在促销活动期间出现了严重的超卖现象,明明库存只剩100件商品,却卖出了150单。这个惨痛教训让我深刻理解了多线程环境下数据竞争的危险性。
synchronized是Java中最基础的线程同步机制,它的核心作用是解决多线程环境下的三大问题:
- 原子性:确保操作不可分割
- 可见性:保证线程间修改的及时可见
- 有序性:防止指令重排序
注意:很多人误以为synchronized只解决原子性问题,实际上它同时保证了可见性和有序性,这是通过Java内存模型(JMM)的happens-before规则实现的。
1.1 多线程环境下的数据竞争
让我们通过一个简单的计数器例子来说明问题:
java复制public class UnsafeCounter {
private int count = 0;
public void increment() {
count++; // 这不是原子操作!
}
public int getCount() {
return count;
}
}
在单线程环境下,这段代码完美运行。但在多线程环境下,count++操作实际上包含三个步骤:
- 读取count值
- 将值加1
- 写回新值
当多个线程同时执行这些步骤时,就可能出现更新丢失。比如两个线程同时读取到count=5,各自加1后都写回6,而实际上应该变成7。
1.2 synchronized的基本用法
解决这个问题最简单的方式就是使用synchronized:
java复制public class SafeCounter {
private int count = 0;
public synchronized void increment() {
count++;
}
public synchronized int getCount() {
return count;
}
}
现在,increment()和getCount()方法都变成了原子操作。synchronized关键字在这里实现了:
- 互斥性:同一时间只有一个线程能执行这些方法
- 可见性:一个线程的修改对其他线程立即可见
- 有序性:方法内的指令不会被重排序到锁区域之外
2. synchronized的三种使用方式
在实际开发中,synchronized有三种主要的使用形式,每种都有其适用场景和性能特点。
2.1 实例方法同步
java复制public synchronized void method() {
// 方法体
}
这种形式锁住的是当前实例对象(this)。当多个线程访问同一个对象的同步方法时,它们会相互阻塞。但如果访问的是不同实例的方法,则不会互相影响。
适用场景:
- 需要保护实例级别的共享数据
- 方法逻辑只涉及当前实例的状态
性能考虑:
- 锁粒度较粗,可能影响并发性能
- 对于高频访问的方法,建议减小同步块范围
2.2 静态方法同步
java复制public static synchronized void staticMethod() {
// 方法体
}
静态同步方法锁住的是类的Class对象(如MyClass.class)。这意味着即使有多个实例,所有线程访问该静态方法时都会竞争同一把锁。
适用场景:
- 需要保护类级别的静态共享数据
- 工具类中的全局状态维护
典型例子:
java复制public class IdGenerator {
private static long id = 0;
public static synchronized long nextId() {
return id++;
}
}
2.3 同步代码块
java复制public void method() {
// 非同步代码
synchronized(lockObject) {
// 需要同步的代码块
}
// 非同步代码
}
同步代码块是最灵活的形式,可以指定任意对象作为锁(lockObject)。这允许我们实现更细粒度的锁控制。
锁对象选择原则:
- 对于实例数据,通常使用private final的锁对象:
java复制private final Object lock = new Object(); - 避免使用可能被外部访问的对象作为锁(如public对象或this)
- 静态数据应使用Class对象或专门的静态锁对象
优势:
- 减小锁粒度,提高并发性
- 可以灵活控制同步范围
- 避免不必要的方法级同步开销
3. synchronized的底层原理
理解synchronized的底层实现,有助于我们更好地使用和优化同步代码。Java中的synchronized是基于Monitor机制实现的。
3.1 Java对象头与Monitor
每个Java对象在内存中都由三部分组成:
- 对象头(Header)
- 实例数据(Instance Data)
- 对齐填充(Padding)
其中对象头包含了两部分重要信息:
- Mark Word:存储对象的hashCode、GC分代年龄、锁状态等
- Klass Pointer:指向类元数据的指针
在32位JVM中,Mark Word的结构如下:
| 锁状态 | 25bit | 4bit | 1bit(偏向锁) | 2bit(锁标志) |
|---|---|---|---|---|
| 无锁 | hashCode | 分代年龄 | 0 | 01 |
| 偏向锁 | 线程ID+Epoch | 分代年龄 | 1 | 01 |
| 轻量级锁 | 指向栈中锁记录的指针 | 00 | ||
| 重量级锁 | 指向Monitor的指针 | 10 | ||
| GC标记 | 空 | 11 |
3.2 锁升级过程
现代JVM中,synchronized的锁状态会随着竞争情况发生变化,这个过程称为锁升级或锁膨胀:
- 无锁状态:初始状态,没有线程访问同步块
- 偏向锁:第一个线程访问时,JVM会将对象头中的线程ID设置为当前线程ID
- 适用于只有一个线程访问同步块的场景
- 避免了CAS操作的开销
- 轻量级锁:当有第二个线程尝试获取锁时,偏向锁升级为轻量级锁
- 使用CAS操作来获取锁
- 适用于线程交替执行同步块的场景
- 重量级锁:当多个线程竞争同一把锁时,轻量级锁升级为重量级锁
- 线程会进入阻塞状态,由操作系统进行调度
- 涉及用户态到内核态的切换,开销较大
提示:在Java 6之后,JVM对synchronized做了大量优化,包括锁消除、锁粗化、适应性自旋等,使得在大多数场景下性能已经足够好。
3.3 Monitor的工作机制
重量级锁对应的Monitor由ObjectMonitor实现(在HotSpot源码中),主要包含以下字段:
- _owner:指向持有锁的线程
- _EntryList:存放等待锁的线程
- _WaitSet:存放调用了wait()的线程
获取锁的流程:
- 线程通过CAS操作尝试将_owner设置为自身
- 成功则获取锁
- 失败则进入_EntryList等待
- 当锁释放时,会从_EntryList中唤醒线程竞争锁
4. synchronized的进阶使用技巧
掌握了基本原理后,我们来看一些实际开发中的高级用法和注意事项。
4.1 双重检查锁定(DCL)模式
单例模式的一种线程安全实现:
java复制public class Singleton {
private static volatile Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) { // 第一次检查
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查
instance = new Singleton();
}
}
}
return instance;
}
}
关键点:
- 必须使用volatile修饰instance
- 防止指令重排序导致其他线程看到未初始化的对象
- 两次null检查缺一不可
- 第一次检查避免不必要的同步
- 第二次检查确保只有一个实例被创建
4.2 等待-通知机制
synchronized与wait()/notify()配合使用可以实现线程间协作:
java复制public class TaskQueue {
private final Object lock = new Object();
private boolean condition = false;
public void waitForCondition() throws InterruptedException {
synchronized (lock) {
while (!condition) {
lock.wait(); // 释放锁并等待
}
// 条件满足后继续执行
}
}
public void signalCondition() {
synchronized (lock) {
condition = true;
lock.notifyAll(); // 唤醒所有等待线程
}
}
}
使用规范:
- 必须在同步块内调用wait()/notify()
- 使用while循环检查条件,避免虚假唤醒
- 优先使用notifyAll()而不是notify(),除非明确知道只需要唤醒一个特定线程
4.3 死锁预防与诊断
synchronized使用不当可能导致死锁。下面是一个典型的死锁例子:
java复制// 线程1
synchronized (lockA) {
synchronized (lockB) {
// 操作资源A和B
}
}
// 线程2
synchronized (lockB) {
synchronized (lockA) {
// 操作资源B和A
}
}
预防措施:
- 固定锁的获取顺序(如总是先获取lockA再获取lockB)
- 使用tryLock()等带超时的锁获取方式
- 通过工具检测死锁:
bash复制jstack <pid> # 查看线程堆栈
5. synchronized的性能优化
虽然现代JVM已经对synchronized做了很多优化,但在高并发场景下,我们仍需注意性能问题。
5.1 减小锁粒度
不好的实践:
java复制public synchronized void processBigData() {
// 处理大量数据
// 只有一小部分代码真正需要同步
}
优化方案:
java复制public void processBigData() {
// 非同步处理
synchronized (this) {
// 只有这部分需要同步
}
// 非同步处理
}
5.2 锁分离技术
将读写操作分离,提高并发性:
java复制public class ReadWriteContainer {
private final Object readLock = new Object();
private final Object writeLock = new Object();
private int value;
public int getValue() {
synchronized (readLock) {
return value;
}
}
public void setValue(int newValue) {
synchronized (writeLock) {
value = newValue;
}
}
}
对于更复杂的场景,可以直接使用Java提供的ReentrantReadWriteLock。
5.3 避免锁竞争热点
当多个线程频繁竞争同一把锁时,会导致严重的性能下降。解决方法包括:
- 使用并发容器代替同步容器
- ConcurrentHashMap vs Collections.synchronizedMap
- 采用无锁算法
- AtomicInteger等原子类
- 使用线程本地存储
- ThreadLocal
6. synchronized与其它同步机制对比
在实际开发中,我们还需要了解synchronized与其他同步工具的区别和适用场景。
6.1 synchronized vs ReentrantLock
| 特性 | synchronized | ReentrantLock |
|---|---|---|
| 实现方式 | JVM内置 | JDK实现 |
| 锁获取 | 自动获取释放 | 需要显式lock()/unlock() |
| 可中断 | 不支持 | 支持lockInterruptibly() |
| 公平锁 | 非公平 | 可配置公平/非公平 |
| 条件变量 | 一个等待队列 | 支持多个Condition |
| 性能 | Java 6后优化良好 | 高竞争下表现更好 |
选择建议:
- 简单场景优先使用synchronized
- 需要高级功能(如可中断、公平锁等)时使用ReentrantLock
6.2 synchronized vs volatile
volatile只保证可见性和有序性,不保证原子性。它适用于:
- 单一变量的读写操作
- 不依赖于当前值的操作(如标志位)
- 不需要与其他变量共同参与不变式的情况
而synchronized适用于:
- 复合操作(如check-then-act)
- 需要保护多个变量的情况
- 需要实现等待-通知机制的情况
6.3 synchronized vs 原子类
Java的java.util.concurrent.atomic包提供了一系列原子类(如AtomicInteger),它们:
- 使用CAS操作实现无锁线程安全
- 适用于计数器等简单场景
- 在高竞争下性能可能优于synchronized
但对于复杂操作,原子类可能不够用,仍需使用synchronized。
7. 常见问题与解决方案
在实际项目中使用synchronized时,经常会遇到一些典型问题。
7.1 锁对象选择不当
错误示例:
java复制public class Logger {
private static final String LOCK = "LOCK";
public static void log(String message) {
synchronized (LOCK) {
// 记录日志
}
}
}
问题在于字符串字面量会被JVM内部化,可能导致不同类库意外共享同一把锁。
解决方案:
java复制private static final Object LOCK = new Object();
7.2 同步方法继承问题
子类覆盖父类的同步方法时,synchronized修饰符不会被自动继承:
java复制class Parent {
public synchronized void method() {}
}
class Child extends Parent {
@Override
public void method() {} // 不是同步的!
}
解决方法:
- 显式添加synchronized
- 使用私有锁对象并通过非同步方法委托:
java复制class Parent {
private final Object lock = new Object();
public void method() {
synchronized (lock) {
doMethod();
}
}
protected void doMethod() {}
}
7.3 锁与异常处理
在同步块中抛出异常时,锁会自动释放:
java复制public void transfer(Account from, Account to, int amount) {
synchronized (from) {
synchronized (to) {
from.withdraw(amount); // 可能抛出异常
to.deposit(amount);
}
}
}
如果withdraw()抛出异常,to的锁会先释放,然后from的锁也会释放,不会导致死锁。
8. 实战案例分析
让我们通过一个完整的案例来综合运用synchronized的各种知识。
8.1 线程安全的LRU缓存实现
java复制public class LRUCache<K, V> {
private final int capacity;
private final Map<K, Node<K, V>> map;
private final Node<K, V> head, tail;
private final Object lock = new Object();
public LRUCache(int capacity) {
this.capacity = capacity;
this.map = new HashMap<>();
this.head = new Node<>(null, null);
this.tail = new Node<>(null, null);
head.next = tail;
tail.prev = head;
}
public V get(K key) {
synchronized (lock) {
Node<K, V> node = map.get(key);
if (node == null) return null;
// 移动到链表头部
moveToHead(node);
return node.value;
}
}
public void put(K key, V value) {
synchronized (lock) {
Node<K, V> node = map.get(key);
if (node != null) {
node.value = value;
moveToHead(node);
} else {
if (map.size() >= capacity) {
// 移除最久未使用的
Node<K, V> last = removeTail();
map.remove(last.key);
}
Node<K, V> newNode = new Node<>(key, value);
map.put(key, newNode);
addToHead(newNode);
}
}
}
private void moveToHead(Node<K, V> node) {
removeNode(node);
addToHead(node);
}
private void addToHead(Node<K, V> node) {
node.prev = head;
node.next = head.next;
head.next.prev = node;
head.next = node;
}
private Node<K, V> removeTail() {
Node<K, V> last = tail.prev;
removeNode(last);
return last;
}
private void removeNode(Node<K, V> node) {
node.prev.next = node.next;
node.next.prev = node.prev;
}
private static class Node<K, V> {
K key;
V value;
Node<K, V> prev, next;
Node(K key, V value) {
this.key = key;
this.value = value;
}
}
}
设计要点:
- 使用私有final对象作为锁
- 同步块保护所有状态变更操作
- 内部使用双向链表维护访问顺序
- 达到容量时自动移除最久未使用的项
8.2 性能测试与优化
对于高并发场景,我们可以进一步优化:
- 分段锁:将缓存分成多个段,每个段有自己的锁
- 读写锁:读操作使用读锁,写操作使用写锁
- 无锁算法:考虑使用ConcurrentHashMap等并发容器
9. JVM对synchronized的优化
现代JVM对synchronized做了大量优化,了解这些有助于我们写出更高效的代码。
9.1 锁消除(Lock Elimination)
JVM通过逃逸分析判断同步块是否只被单个线程访问,如果是则会消除锁。例如:
java复制public String concat(String s1, String s2, String s3) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
sb.append(s3);
return sb.toString();
}
在这个例子中,StringBuffer是局部变量,不会逃逸出方法,因此JVM会消除其内部同步。
9.2 锁粗化(Lock Coarsening)
当JVM检测到一连串连续的对同一个对象的加锁解锁操作时,会将多个锁合并为一个更大的锁:
java复制public void method() {
synchronized (obj) {
// 操作1
}
synchronized (obj) {
// 操作2
}
// ...
}
可能被优化为:
java复制public void method() {
synchronized (obj) {
// 操作1
// 操作2
// ...
}
}
9.3 偏向锁与轻量级锁
Java 6引入了偏向锁和轻量级锁,大幅提升了无竞争或低竞争情况下的性能:
- 偏向锁:第一个获取锁的线程会"偏向"这个线程,后续获取锁不需要同步操作
- 轻量级锁:当有轻微竞争时,使用CAS操作而不是操作系统互斥量
可以通过JVM参数控制这些优化:
bash复制-XX:+UseBiasedLocking # 启用偏向锁(默认开启)
-XX:BiasedLockingStartupDelay=0 # 虚拟机启动后立即启用偏向锁
10. 最佳实践总结
根据多年使用synchronized的经验,我总结了以下最佳实践:
-
锁对象选择:
- 使用private final对象作为锁
- 避免使用String字面量或可能被重用的对象
- 静态同步使用Class对象或专门的静态锁对象
-
同步范围:
- 尽量减小同步块的范围
- 只在必要时同步,避免过度同步
- 将不需要同步的代码移出同步块
-
性能考虑:
- 低竞争场景下,synchronized性能已经足够好
- 高竞争场景考虑其他并发工具
- 避免在同步块中执行耗时操作
-
避免常见陷阱:
- 注意锁的获取顺序,预防死锁
- 确保wait()在循环中调用
- 子类覆盖同步方法时要特别小心
-
监控与调优:
- 使用JVisualVM等工具监控锁竞争情况
- 关注线程转储中的锁信息
- 根据实际情况调整锁策略
在实际项目中,我发现很多同步问题都源于对synchronized理解的不足。曾经有一个性能问题困扰了我们团队两周,最后发现是因为开发人员在同步块中调用了外部服务。将外部调用移到同步块外后,系统吞吐量提升了10倍。这个教训让我深刻认识到:理解原理只是第一步,在实际编码时保持警惕和良好习惯同样重要。
