一、什么是 synchronized?
synchronized 是 Java 提供的内置同步机制,主要用于解决多线程环境下的线程安全问题。
当多个线程同时访问共享数据时,如果没有适当的同步控制,就可能出现数据竞争、更新丢失等问题。
例如,多个线程同时对一个共享变量执行 count++,由于该操作并不是原子的,最终结果可能小于预期值。
java
public class Counter {
private int count = 0;
public void increment() {
count++;
}
public int getCount() {
return count;
}
}
假设两个线程各自执行 10000 次 increment(),理论上最终结果应该是 20000,但实际上可能小于 20000。
原因是 count++ 并不是一个原子操作,它大致包含读取、计算和写入三个步骤。多个线程交错执行这些步骤,就可能覆盖彼此的更新。
这时,可以使用 synchronized 保护共享数据。
java
public class Counter {
private int count = 0;
public synchronized void increment() {
count++;
}
public synchronized int getCount() {
return count;
}
}
当多个线程调用同一个 Counter 实例的 increment() 方法时,它们需要竞争同一把锁,从而避免多个线程同时执行受保护的自增操作。
二、synchronized 的四种常见写法
严格来说,synchronized 主要有两种语法形式:修饰方法和修饰代码块。根据锁对象的不同,实际使用中通常可以分为以下四种常见形式。
1. 修饰实例方法
java
public synchronized void method() {
// 临界区
}
实例同步方法锁住的是当前对象,也就是 this。
它等价于:
java
public void method() {
synchronized (this) {
// 临界区
}
}
需要注意,这里的等价是指同步范围和锁对象等价,而不是说所有字节码实现细节都完全相同。
例如:
java
class Demo {
public synchronized void methodA() {
System.out.println("A");
}
public synchronized void methodB() {
System.out.println("B");
}
}
当线程 A 执行 d.methodA() 时,线程 B 如果同时调用同一个对象 d 的 methodB(),就需要等待线程 A 释放锁。
但是,如果两个线程操作的是不同实例:
java
Demo d1 = new Demo();
Demo d2 = new Demo();
那么 d1 和 d2 对应不同的对象锁,两个实例的同步方法可以并发执行。
结论:实例同步方法锁的是当前对象,而不是整个类。
2. 修饰静态方法
java
public static synchronized void method() {
// 临界区
}
静态同步方法锁住的是当前运行时类对应的 Class 对象。
例如:
java
class Demo {
public static synchronized void methodA() {
System.out.println("A");
}
public static synchronized void methodB() {
System.out.println("B");
}
}
两个静态同步方法使用同一把类锁,因此它们之间会互斥。
即使线程通过不同的 Demo 实例来调用静态方法,最终竞争的仍然是 Demo.class 对应的锁。
需要注意,如果同名类由不同的类加载器加载,它们可能对应不同的 Class 对象,也就不一定共享同一把类锁。
结论:静态同步方法锁的是类对象,而不是某个实例。
3. 同步代码块锁 this
java
public void method() {
System.out.println("开始");
synchronized (this) {
// 需要同步的代码
System.out.println("执行临界区");
}
System.out.println("结束");
}
synchronized (this) 表示获取当前实例对象的锁。
与同步实例方法相比,同步代码块可以缩小锁的范围,只对真正需要保护的代码加锁。
例如:
java
public void method() {
doSomething();
synchronized (this) {
updateSharedData();
}
doSomethingElse();
}
如果 doSomething() 和 doSomethingElse() 不需要在锁内执行,就可以将它们放在同步代码块外面,从而减少持锁时间。
不过,缩小锁的范围并不意味着一定能提高性能,还需要结合实际竞争情况和共享数据的访问方式判断。
4. 同步代码块锁指定对象
java
private final Object lock = new Object();
public void method() {
synchronized (lock) {
// 临界区
}
}
这种写法允许开发者自行指定锁对象。
例如:
java
class Demo {
private final Object lockA = new Object();
private final Object lockB = new Object();
public void updateA() {
synchronized (lockA) {
// 修改共享数据 A
}
}
public void updateB() {
synchronized (lockB) {
// 修改共享数据 B
}
}
}
如果两个操作访问的是彼此独立的数据,并且不需要共同维护某种不变量,那么使用不同的锁,可以让它们有机会并发执行。
这里推荐使用 private final Object 作为专用锁对象:
private:避免外部代码直接获取这把锁。final:避免锁引用被重新赋值,导致不同代码段意外使用不同的锁对象。
需要注意,使用不同的锁并不能自动保证线程安全。如果两个操作实际访问同一份共享数据,仍然需要正确设计同步策略。
三、synchronized (Xxx.class) 有什么作用?
除了锁 this 和成员锁之外,还可以直接锁住类对象。
java
class Demo {
public void method() {
synchronized (Demo.class) {
System.out.println("执行临界区");
}
}
}
这里锁住的是 Demo.class,因此同一个运行时类的不同实例,也会竞争同一把锁。
例如:
java
Demo d1 = new Demo();
Demo d2 = new Demo();
当两个线程分别调用 d1.method() 和 d2.method() 时,由于两个方法都锁住 Demo.class,所以它们的同步代码块会互斥执行。
这与 synchronized (this) 有明显区别:
synchronized (this):每个实例有自己的对象锁。synchronized (Demo.class):同一个运行时类的实例共享同一把类锁。
此外,下面两种写法使用的是同一把锁:
java
public static synchronized void methodA() {
// ...
}
java
public void methodB() {
synchronized (Demo.class) {
// ...
}
}
因为静态同步方法锁住的也是 Demo.class。
因此,一个线程执行 methodA() 时,另一个线程不能同时进入 methodB() 中受该类锁保护的代码块。
四、synchronized 的核心:锁对象相同才会互斥
理解 synchronized 最重要的一点,是判断两个代码段最终锁住的对象是否相同。
例如:
java
Object a = new Object();
Object b = a;
此时:
java
System.out.println(a == b); // true
虽然引用变量名称不同,但它们指向同一个对象。
因此:
java
synchronized (a) {
// ...
}
和:
java
synchronized (b) {
// ...
}
竞争的是同一把监视器锁。
反过来,即使两个变量名称相同,如果它们在不同对象中分别指向不同的锁实例,也不一定会互斥。
可以记住下面这张表:
| 同步方式 | 锁对象 | 不同实例之间是否会互斥 |
|---|---|---|
| 实例同步方法 | this |
通常不会 |
synchronized (this) |
当前实例 | 通常不会 |
synchronized (lock) |
lock 指向的对象 |
取决于是否共享同一个锁对象 |
synchronized (Demo.class) |
Demo.class |
同一个运行时类的实例会竞争同一把锁 |
| 静态同步方法 | 当前类的 Class 对象 |
同一个运行时类的静态同步方法共享类锁 |
这里的"不会互斥"是指不会因为这些锁而相互阻塞,并不代表线程之间不存在其他形式的依赖或竞争。
五、synchronized 的三个重要特性
1. 原子性:保护临界区内的复合操作
synchronized 可以让受同一把锁保护的代码段互斥执行。
例如:
java
class Counter {
private int count = 0;
public synchronized void increment() {
count++;
}
}
多个线程对同一个 Counter 实例调用 increment() 时,不能同时执行这个受保护的自增操作,因此不会因为该操作的交错执行而丢失更新。
需要注意,原子性是针对受保护的操作及其同步约定而言的。如果其他代码不遵守同一把锁的规则,仍然可能破坏数据一致性。
2. 可见性:保证正确同步下的内存可见性
Java 内存模型规定:
对某个监视器锁的解锁操作,happens-before 后续对同一把锁的加锁操作。
例如:
java
class Demo {
private boolean flag = false;
public synchronized void setFlag() {
flag = true;
}
public synchronized boolean getFlag() {
return flag;
}
}
当一个线程执行 setFlag() 并释放锁后,另一个线程随后获取同一把锁执行 getFlag(),就能够观察到前一个线程在相应同步关系之前完成的写入。
这也是为什么 synchronized 不只是互斥工具,还具有内存可见性方面的作用。
3. 可重入性:同一个线程可以重复获取同一把锁
Java 的 synchronized 是可重入的。
例如:
java
class Demo {
public synchronized void methodA() {
methodB();
}
public synchronized void methodB() {
System.out.println("执行 B");
}
}
当线程进入 methodA() 时,已经获取了当前对象的锁。
随后调用 methodB() 时,因为 methodB() 也锁住同一个对象,而当前线程已经持有该锁,所以可以再次获取锁并继续执行。
不会因为重复获取同一把锁而把自己阻塞。
可重入性在继承调用、方法之间相互调用以及嵌套同步代码块中都非常重要。
六、为什么 Hashtable 的 get 方法也需要加锁?
这是理解 Java 并发集合时一个很典型的问题。
Hashtable 的许多主要操作方法,包括 get() 和 put(),都使用同步机制。
java
public synchronized V get(Object key) {
// 查找元素
}
public synchronized V put(K key, V value) {
// 插入元素
}
由于它们使用同一个 Hashtable 实例的对象锁,因此不同线程的读写操作也可能互相阻塞。
这样做的优点是实现相对简单,容易建立基本的同步关系;缺点是锁的粒度较大,读操作也可能受到写操作的阻塞。
在 Java 8 的 ConcurrentHashMap 中,普通 get() 通常不需要获取互斥锁,而是依靠安全的并发数据结构设计、volatile 相关的内存可见性保证及其他机制完成读取。
因此,在需要高并发访问的场景中,通常优先考虑 ConcurrentHashMap,而不是 Hashtable。
七、synchronized 的常见误区
误区一:一个类中的所有 synchronized 方法都会互斥
不正确。
只有竞争同一把锁的代码段,才会因为该锁而互斥。
实例同步方法锁 this,静态同步方法锁 Class 对象,两者并不是同一把锁。
误区二:锁住一个对象,就能保护这个对象的所有数据
不完全正确。
锁只有在相关代码都遵循相同的同步约定时,才能保护对应的共享状态。
如果一个线程在同步代码块内修改共享变量,另一个线程却不加锁直接访问该变量,就不能依赖前者的 synchronized 自动保证所有访问的线程安全。
误区三:synchronized 一定能解决所有线程安全问题
不正确。
synchronized 可以提供互斥、可见性以及相应的原子性保障,但它不能自动解决所有并发问题。
例如:
- 多把锁的获取顺序不一致,可能导致死锁。
- 锁粒度过大,可能降低并发性能。
- 不同代码段采用不一致的同步策略,仍然可能出现数据竞争。
- 对于需要超时获取锁、可中断获取锁等场景,可能需要使用
ReentrantLock等工具。
八、什么时候应该使用 synchronized?
在日常开发中,可以参考以下原则:
- 保护当前对象的共享状态 :可以考虑实例同步方法或
synchronized (this)。 - 保护类级别的共享状态 :可以考虑静态同步方法或
synchronized (Demo.class)。 - 保护一小段临界区:可以使用同步代码块,避免不必要地扩大持锁范围。
- 多个独立数据区域需要并发访问:可以考虑使用不同的专用锁,但要先确认数据之间不存在需要共同维护的不变量。
- 需要高并发集合 :优先考虑
ConcurrentHashMap、并发队列等专用并发工具。 - 需要可中断、定时获取锁等高级能力 :可以进一步学习
ReentrantLock。
九、总结
synchronized 是 Java 并发编程中最基础、最重要的同步工具之一。
理解它,不应该只停留在"给方法加一个关键字",而应该掌握以下几个核心概念:
- 锁对象决定互斥范围 :锁的是
this、成员变量还是Class对象,决定了哪些代码会竞争同一把锁。 - 锁的粒度影响并发性能:合理控制临界区范围,可以减少不必要的竞争。
- 同步不仅提供互斥,还涉及内存可见性:正确的同步关系是保障共享数据一致性的基础。
- 可重入性允许同一线程重复获取同一把锁:这使得嵌套调用同步方法成为可能。
最后记住一句话:
理解 synchronized 的关键,不是记住多少种写法,而是弄清楚谁在竞争哪一把锁,以及共享数据是否始终遵守同一套同步规则。