引入
在多线程编程的世界里,共享资源的访问控制就像一场精心设计的交通管制,而Synchronized作为Java并发编程的基础同步机制,扮演着"交通警察"的关键角色。
并发编程的核心矛盾
当多个线程同时访问共享资源时,"线程安全"问题便应运而生。想象一个银行账户的场景:若两个线程同时执行扣款操作,可能导致账户余额出现负数或不一致的情况。这种情况下,我们需要一种机制来确保在同一时刻只有一个线程能操作共享资源,这就是同步锁的核心使命。
Synchronized的历史地位
作为Java语言内置的同步机制,Synchronized从JDK1.0时代便已存在。早期版本中,它因"重量级锁"的标签被认为性能不佳,但随着JDK6之后的一系列优化(如偏向锁、轻量级锁的引入),其性能表现已大幅提升,在很多场景下甚至优于ReentrantLock
。
同步锁的三大核心特性
-
互斥性:确保同一时刻只有一个线程获取锁并执行同步代码
-
可见性:保证释放锁时对共享变量的修改能立即被其他线程看见
-
有序性:禁止指令重排序,确保同步代码块内的操作按顺序执行
这些特性通过JVM底层的监视器锁(Monitor)机制实现,是理解Synchronized的关键切入点。
锁的状态与类型:从无锁到重量级锁的演进
JVM视角下的锁状态体系
从JVM实现层面看,锁的状态可分为4种,每种状态对应不同的竞争程度和性能特征:
状态值 | 状态名称 | 竞争程度 | 典型场景 | Mark Word结构(64位JVM) |
---|---|---|---|---|
0 | 无锁状态 | 无竞争 | 单线程访问 | 对象哈希码(25bit) + GC分代年龄(4bit) + 锁标志位(01) + 偏向锁标志(0) |
1 | 偏向锁状态 | 轻微竞争 | 单线程重复访问 | 线程ID(54bit) + GC分代年龄(4bit) + 锁标志位(01) + 偏向锁标志(1) |
2 | 轻量级锁状态 | 中度竞争 | 多线程自旋等待 | 指向栈中锁记录的指针(62bit) + 锁标志位(00) |
3 | 重量级锁状态 | 高度竞争 | 多线程阻塞等待 | 指向监视器对象的指针(62bit) + 锁标志位(10) |
锁状态转换图:
无锁状态 ↔ 偏向锁状态 ↔ 轻量级锁状态 ↔ 重量级锁状态
↑ ↓
└────────────── 锁膨胀 ────────────┘
这种状态转换是单向不可逆的,只能从低竞争状态向高竞争状态升级(锁膨胀),而不能降级,这是JVM为了优化性能做出的设计选择。
偏向锁:单线程优化的利器
偏向锁的核心思想
偏向锁是JVM对"同一线程多次获取同一锁"场景的优化,它通过在对象头中记录线程ID的方式,避免重复获取锁的开销。当一个线程首次获取对象锁时,JVM会将偏向锁标志位设为1,并将线程ID写入Mark Word,后续该线程再次访问时无需进行CAS操作,直接判断Mark Word中的线程ID是否与当前线程一致。
偏向锁的激活与撤销
-
激活条件 :JVM参数
-XX:+UseBiasedLocking
(JDK6后默认启用) -
撤销场景:
-
当其他线程尝试获取偏向锁时,会触发偏向锁撤销
-
调用
wait()
/notify()
等方法时,偏向锁会升级为轻量级锁 -
偏向锁可以通过
BiasedLockingStartupDelay
参数控制延迟激活时间
-
典型应用场景
偏向锁最适合"单线程反复访问同步资源"的场景,例如:
java
public class BiasedLockDemo {
private Object lock = new Object();
public void doWork() {
synchronized (lock) {
// 单线程频繁执行的业务逻辑
}
}
}
在这种场景下,偏向锁能消除几乎所有的锁获取开销。
轻量级锁:自旋等待的艺术
轻量级锁的实现原理
当偏向锁被撤销或遇到轻度竞争时,锁会升级为轻量级锁。其核心原理是通过CAS操作在栈帧中创建"锁记录"(Lock Record),并将对象头的Mark Word替换为指向锁记录的指针:
-
线程在栈中创建Lock Record,复制对象头Mark Word到Lock Record(Displaced Mark Word)
-
尝试用CAS将对象头Mark Word替换为指向Lock Record的指针
-
CAS成功则获取锁,失败则进入自旋等待
-
自旋一定次数后仍未获取锁,则升级为重量级锁
自旋优化的权衡
自旋等待(Spin Waiting)是指线程不放弃CPU,而是循环检查锁是否可用。这种方式避免了线程阻塞的开销,但会消耗CPU资源。JVM通过-XX:PreBlockSpin
参数控制自旋次数,默认值为10次。在多核CPU环境下,自旋优化能显著提升轻度竞争场景的性能。
轻量级锁与偏向锁的对比
特性 | 偏向锁 | 轻量级锁 |
---|---|---|
竞争程度 | 无竞争 | 轻度竞争 |
加锁方式 | CAS记录线程ID | CAS修改对象头指针 |
解锁开销 | 几乎无 | 需CAS还原Mark Word |
典型场景 | 单线程反复访问 | 多线程交替访问 |
重量级锁:操作系统级别的同步
重量级锁的底层实现
当轻量级锁自旋超过阈值或竞争更加激烈时,锁会膨胀为重量级锁。此时JVM会调用操作系统的互斥量(Mutex)来实现线程阻塞,具体过程包括:
-
创建与对象关联的监视器(Monitor)对象
-
线程进入监视器的等待队列,状态变为BLOCKED
-
释放CPU资源,等待操作系统调度唤醒
-
唤醒后重新尝试获取锁
重量级锁的性能开销
重量级锁的性能开销主要来自:
-
线程状态切换(用户态→内核态→用户态)
-
操作系统调度器的上下文切换
-
等待队列的管理开销
在JDK6之前,Synchronized默认使用重量级锁,这也是其"性能不佳"印象的来源。但经过锁升级优化后,重量级锁的使用场景已大幅减少。
锁膨胀的触发条件
锁状态从低到高升级的关键触发条件包括:
-
偏向锁遇到其他线程竞争
-
轻量级锁自旋次数超过阈值(默认10次)
-
调用
Object.wait()
等会导致线程阻塞的方法 -
锁竞争持续时间超过自旋优化的收益临界点
Synchronized与Java内存模型(JMM)的深层联系
JMM的核心架构
Java内存模型定义了线程和主内存之间的抽象关系:
-
主内存:所有线程共享的内存区域,存储共享变量
-
工作内存:每个线程私有的内存区域,存储共享变量的副本
这种架构导致了一个核心问题:线程间如何保证共享变量的可见性?Synchronized通过以下机制解决这一问题:
Synchronized的内存语义
当线程执行synchronized
同步块时,会遵循以下内存规则:
-
进入同步块:
-
从主内存读取共享变量的最新值到工作内存
-
清空工作内存中与同步块相关的变量副本
-
保证同步块内操作的有序性(禁止指令重排序)
-
-
退出同步块:
-
将工作内存中的变量修改刷新到主内存
-
确保所有对共享变量的修改对其他线程可见
-
建立happens-before关系,保证后续线程能看到最新数据
-
这种机制通过JVM在编译时生成的monitorenter
和monitorexit
指令实现,确保了同步操作的可见性和有序性。
happens-before原则与Synchronized
JMM中的happens-before原则定义了操作之间的偏序关系,其中与Synchronized相关的规则包括:
-
监视器锁规则:对一个锁的解锁操作happens-before于后续对该锁的加锁操作
-
程序顺序规则:同步块内的操作按程序顺序执行
-
传递性:若A happens-before B且B happens-before C,则A happens-before C
这些规则共同保证了Synchronized同步块内操作的正确性,例如:
java
private int x = 0;
private Object lock = new Object();
public void update() {
synchronized (lock) {
x = 1; // 操作1
x = 2; // 操作2
} // 解锁操作,happens-before后续加锁操作
}
public void read() {
synchronized (lock) { // 加锁操作,happens-after解锁操作
assert x == 2; // 一定成立
}
}
由于解锁操作happens-before加锁操作,读操作必然能看到写操作的最新结果。
其他同步解决方案:与Synchronized的对比与互补
ReentrantLock:灵活的显式锁
ReentrantLock的核心特性
ReentrantLock(可重入锁)是JUC包中提供的同步工具,与Synchronized相比具有以下优势:
-
显式锁控制 :通过
lock()
和unlock()
方法显式获取和释放锁 -
可中断获取锁 :支持
lockInterruptibly()
方法,可响应中断 -
公平锁机制:支持公平锁模式,保证线程获取锁的顺序
-
条件变量 :通过
newCondition()
方法创建条件变量,实现更灵活的等待/通知机制
公平锁与非公平锁的实现差异
ReentrantLock支持两种锁模式:
-
非公平锁(默认):新线程可能在等待队列头部线程之前获取锁,性能更高
-
公平锁:严格按照线程等待顺序获取锁,避免饥饿
java
// 创建公平锁
ReentrantLock fairLock = new ReentrantLock(true);
// 使用示例
fairLock.lock();
try {
// 同步代码块
} finally {
fairLock.unlock();
}
公平锁通过AQS
(AbstractQueuedSynchronizer)的等待队列实现,而非公平锁在获取锁时会先尝试直接获取,可能跳过等待队列中的线程。
ReentrantLock与Synchronized的对比
特性 | Synchronized | ReentrantLock |
---|---|---|
锁获取方式 | 隐式(自动加锁/解锁) | 显式(手动调用方法) |
可重入性 | 支持 | 支持 |
公平性 | 非公平 | 可选择公平/非公平 |
锁中断 | 不支持 | 支持 |
条件变量 | 不支持 | 支持 |
性能(无竞争) | 优(偏向锁优化) | 略逊 |
性能(高竞争) | 略逊 | 优(可中断、公平锁) |
ReadWriteLock:读写分离的同步策略
读写锁的核心思想
ReadWriteLock(读写锁)将锁分为读锁和写锁,允许多个线程同时获取读锁,但同一时刻只能有一个线程获取写锁。这种设计特别适合"读多写少"的场景,例如缓存系统:
java
public class Cache {
private final ReadWriteLock lock = new ReentrantReadWriteLock();
private Map<String, Object> data = new HashMap<>();
// 读操作获取读锁
public Object get(String key) {
lock.readLock().lock();
try {
return data.get(key);
} finally {
lock.readLock().unlock();
}
}
// 写操作获取写锁
public void put(String key, Object value) {
lock.writeLock().lock();
try {
data.put(key, value);
} finally {
lock.writeLock().unlock();
}
}
}
读写锁的状态管理
ReentrantReadWriteLock通过一个整数(32位)来管理两种锁状态:
-
高16位:记录读锁的获取次数(可被多个线程共享)
-
低16位:记录写锁的获取次数(仅能被一个线程持有)
这种设计使得读写锁能在一个变量中维护两种锁状态,提高了空间效率。
读写锁的适用场景
读写锁适合以下场景:
-
读取操作频率远高于写入操作
-
写入操作耗时较短
-
需要保证读取操作的一致性
例如:
-
配置文件读取(很少修改,频繁读取)
-
缓存系统(读多写少)
-
数据库查询缓存(查询频繁,更新较少)
但需注意,读写锁在写操作频繁的场景下性能可能不如普通互斥锁,因为读锁的释放可能导致写锁饥饿。
Synchronized在JDK源码中的典型应用
容器类中的同步实现
StringBuffer的同步实现
StringBuffer是JDK中典型的线程安全容器,其所有关键方法都使用Synchronized修饰:
java
public final class StringBuffer extends AbstractStringBuilder implements java.io.Serializable, CharSequence {
// 构造函数
public StringBuffer() {
super(16);
}
// 同步追加方法
public synchronized StringBuffer append(String str) {
toStringCache = null;
super.append(str);
return this;
}
// 同步插入方法
public synchronized StringBuffer insert(int offset, char str[]) {
toStringCache = null;
super.insert(offset, str);
return this;
}
// 其他同步方法...
}
这种实现方式保证了StringBuffer在多线程环境下的安全性,但也意味着所有操作都需要获取锁,在高并发场景下可能成为性能瓶颈。
Vector的同步机制
Vector与ArrayList功能相似,但所有操作都是同步的:
java
public class Vector<E> extends AbstractList<E> implements List<E>, RandomAccess, Cloneable, java.io.Serializable {
// 同步添加元素
public synchronized boolean add(E e) {
modCount++;
ensureCapacityHelper(elementCount + 1);
elementData[elementCount++] = e;
return true;
}
// 同步获取元素
public synchronized E get(int index) {
if (index >= elementCount)
throw new ArrayIndexOutOfBoundsException(index);
return elementData(index);
}
// 其他同步方法...
}
与StringBuffer类似,Vector的同步实现保证了线程安全,但在并发环境下性能不如非同步容器。JDK推荐在非必要时使用ArrayList,仅在需要线程安全时通过Collections.synchronizedList(new ArrayList<>())
包装。
基础类库中的同步应用
Hashtable的同步实现
Hashtable是Java早期的线程安全哈希表,其实现方式与Vector类似:
java
public class Hashtable<K,V> extends Dictionary<K,V> implements Map<K,V>, Cloneable, java.io.Serializable {
// 同步put方法
public synchronized V put(K key, V value) {
// 检查key是否为null
if (key == null) {
throw new NullPointerException();
}
V oldValue = get(key);
putVal(key, value, false);
return oldValue;
}
// 同步get方法
public synchronized V get(Object key) {
if (key == null) {
throw new NullPointerException();
}
Entry<?,?> e = getEntry(key);
return (e == null) ? null : (V)e.value;
}
// 其他同步方法...
}
由于Hashtable的同步粒度较大(整个哈希表),在高并发场景下性能较差,因此JDK后来提供了ConcurrentHashMap
作为替代方案,其采用分段锁机制大幅提升了并发性能。
自定义同步工具的基础
Synchronized也是JDK中许多自定义同步工具的实现基础,例如:
java
public class Semaphore {
// 内部通过AQS实现,但AQS的底层操作依赖于CAS和Monitor
public Semaphore(int permits) {
sync = new NonfairSync(permits);
}
// 其他方法...
}
虽然Semaphore的上层接口不直接使用Synchronized,但底层AQS的实现仍然依赖于JVM的监视器锁机制,体现了Synchronized在JDK中的基础地位。
Synchronized的最佳实践与性能优化
精细化同步范围
最小化同步代码块
java
// 反例:同步范围过大
public void badPractice() {
synchronized (this) {
// 非共享资源操作,无需同步
loadConfig();
// 共享资源操作,需要同步
updateSharedData();
// 非共享资源操作,无需同步
logOperation();
}
}
// 正例:缩小同步范围
public void goodPractice() {
// 非共享资源操作
loadConfig();
// 仅同步必要的代码块
synchronized (this) {
updateSharedData();
}
// 非共享资源操作
logOperation();
}
通过缩小同步范围,可以减少线程竞争,提高并发性。基本原则是:只对真正访问共享资源的代码加锁。
同步对象的选择
优先使用私有锁对象:
java
private final Object lock = new Object();
public void operation() {
synchronized (lock) {
// 同步代码
}
}
私有锁对象避免了外部代码直接访问锁,减少了锁竞争的意外风险。
避免使用this作为锁对象:
java
public void badLock() {
synchronized (this) { // 危险!外部可能获取this锁导致死锁
// 同步代码
}
}
除非类本身设计为线程安全的同步组件,否则应避免使用this作为锁对象。
利用锁的可重入性
可重入性的实际应用
java
public class ReentrantDemo {
public synchronized void method1() {
System.out.println("进入method1");
method2(); // 调用同步方法method2
System.out.println("退出method1");
}
public synchronized void method2() {
System.out.println("进入method2");
// 其他操作
System.out.println("退出method2");
}
}
在上述示例中,method1
调用method2
时,由于Synchronized锁的可重入性,线程无需再次获取锁,避免了死锁风险。可重入性是通过锁的计数器实现的,每次进入同步块时计数器加1,退出时减1,当计数器为0时才真正释放锁。
可重入性与继承场景
java
class Parent {
public synchronized void operation() {
System.out.println("Parent operation");
}
}
class Child extends Parent {
@Override
public synchronized void operation() {
System.out.println("Child before");
super.operation(); // 调用父类同步方法
System.out.println("Child after");
}
}
在继承场景下,子类重写同步方法并调用父类方法时,可重入性保证了锁的正确获取,避免了因多次加锁导致的死锁。
性能优化参数调整
偏向锁相关参数
启用/禁用偏向锁:
-XX:+UseBiasedLocking // 启用偏向锁(JDK6后默认启用)
-XX:-UseBiasedLocking // 禁用偏向锁
偏向锁延迟激活:
-XX:BiasedLockingStartupDelay=0 // 启动时立即激活偏向锁(默认延迟4秒)
轻量级锁自旋参数
自旋次数设置:
-XX:PreBlockSpin=20 // 设置自旋次数(默认10次)
自适应自旋:
-XX:+UseAdaptiveSpinning // 启用自适应自旋(JDK6后默认启用)
自适应自旋会根据前一次自旋的成功情况动态调整自旋次数,提高优化效果。
场景化选择策略
选择Synchronized的场景
-
简单同步需求:无需复杂锁控制的场景
-
单线程或低竞争环境:偏向锁和轻量级锁能发挥最佳性能
-
代码简洁性优先:隐式加锁/解锁减少代码量
-
与JMM结合的场景:需要利用Synchronized的内存语义保证可见性
选择ReentrantLock的场景
-
需要公平锁机制:避免线程饥饿
-
需要可中断锁:响应线程中断
-
需要条件变量:实现更灵活的等待/通知机制
-
高竞争环境:ReentrantLock的性能可能更优
-
需要手动控制锁释放 :如配合
try-finally
确保解锁
选择ReadWriteLock的场景
-
读多写少的场景:如缓存、配置文件等
-
需要读写分离:提高读操作的并发性
-
写入操作耗时较短:避免读锁饥饿
总结
从"重量级锁"到"智能锁"的进化
回顾Synchronized的发展历程,我们可以看到JVM团队在性能优化上的持续努力:
-
JDK1.0-1.5:仅支持重量级锁,性能较差
-
JDK6:引入偏向锁、轻量级锁,大幅提升性能
-
JDK7:优化锁膨胀路径,减少重量级锁的使用
-
JDK8+:进一步优化偏向锁的获取和撤销流程
这种进化使得Synchronized在无竞争和轻度竞争场景下的性能接近无锁操作,重新成为Java并发编程的首选同步工具之一。
同步机制的选择原则
在实际开发中,选择同步机制应遵循以下原则:
-
优先使用Synchronized:对于大多数场景,Synchronized已足够高效,且代码更简洁
-
ReentrantLock作为补充:当需要公平锁、可中断锁或条件变量时使用
-
ReadWriteLock谨慎使用:仅在读多写少场景下使用,避免写锁饥饿
-
性能测试验证:不同场景下锁的性能表现可能不同,需通过实测确定最优方案
核心知识回顾
-
锁状态体系:无锁→偏向锁→轻量级锁→重量级锁的状态转换
-
实现原理:基于JVM监视器锁,通过Mark Word记录锁状态
-
内存语义:保证共享变量的可见性和操作的有序性
-
性能优化:偏向锁、轻量级锁、自旋等待等优化手段
-
应用场景:结合具体业务需求选择合适的同步方案
掌握Synchronized的原理与应用,是成为Java并发编程高手的必经之路。通过深入理解其底层实现和优化机制,我们能够更精准地运用这一强大工具,构建高效、安全的多线程应用。