Jakob Jenkov 2020-08-12
Java 中的 synchronized 块用于将方法或代码块标记为同步。Java 的 synchronized 块在任意时刻只能被一个线程执行(具体取决于使用方式)。因此,Java synchronized 块可用于避免竞态条件(race conditions)。
本教程将更详细地解释 Java synchronized 关键字的工作原理。
Java 并发工具类
synchronized 机制是 Java 最早用于同步多线程共享对象访问的机制。然而,synchronized 机制功能并不强大。因此,从 Java 5 开始,引入了一整套 并发工具类(concurrency utility classes),帮助开发者实现比 synchronized 更细粒度的并发控制。
Java synchronized 关键字
Java 中的 synchronized 块通过 synchronized 关键字进行标记。Java 的 synchronized 块会基于某个对象进行同步。所有基于同一个对象进行同步的 synchronized 块,在同一时间只能有一个线程执行其中的代码。其他试图进入该 synchronized 块的线程会被阻塞,直到当前线程退出该块。
synchronized 关键字可以用于标记以下四种类型的代码块:
- 实例方法
- 静态方法
- 实例方法中的代码块
- 静态方法中的代码块
这些块基于不同的对象进行同步。你需要根据具体情况选择合适的同步方式。下面将逐一详细介绍。
同步实例方法(Synchronized Instance Methods)
以下是一个同步实例方法的示例:
public class MyCounter {
private int count = 0;
public synchronized void add(int value){
this.count += value;
}
}
注意 add() 方法声明中使用了 synchronized 关键字,这告诉 Java 该方法是同步的。
Java 中的同步实例方法是基于拥有该方法的对象实例进行同步的。因此,每个实例的同步方法都基于不同的对象(即各自的实例)进行同步。
对于同一个实例,同一时间只能有一个线程执行其同步实例方法。如果存在多个实例,则每个实例各自允许一个线程执行其同步方法(即“每个实例一个线程”)。
这一规则适用于同一个对象的所有同步实例方法。例如:
public class MyCounter {
private int count = 0;
public synchronized void add(int value){
this.count += value;
}
public synchronized void subtract(int value){
this.count -= value;
}
}
在这个例子中,对于同一个 MyCounter 实例,同一时间只能有一个线程执行 add() 或 subtract() 中的任意一个方法。
同步静态方法(Synchronized Static Methods)
静态方法也可以像实例方法一样使用 synchronized 关键字进行标记。例如:
public class MyStaticCounter {
private static int count = 0;
public static synchronized void add(int value){
count += value;
}
}
这里的 synchronized 关键字同样表示该静态方法是同步的。
同步静态方法是基于该类的 Class 对象进行同步的。由于在 Java 虚拟机中,每个类只有一个 Class 对象,因此同一时间只能有一个线程执行该类中的任意一个同步静态方法。
例如,如果一个类中有多个同步静态方法:
public class MyStaticCounter {
private static int count = 0;
public static synchronized void add(int value){
count += value;
}
public static synchronized void subtract(int value){
count -= value;
}
}
那么,同一时间只能有一个线程执行 add() 或 subtract() 中的任意一个。如果线程 A 正在执行 add(),那么线程 B 既不能执行 add(),也不能执行 subtract(),直到线程 A 退出 add()。
如果这些同步静态方法位于不同的类中,则每个类各自允许一个线程执行其同步静态方法(即“每个类一个线程”)。
实例方法中的同步块(Synchronized Blocks in Instance Methods)
你不需要对整个方法进行同步。有时只同步方法中的一部分代码更为合适。Java 允许在方法内部使用 synchronized 块来实现这一点。
以下是在非同步方法内部使用 synchronized 块的示例:
public void add(int value){
synchronized(this){
this.count += value;
}
}
这里使用了 Java 的 synchronized 块语法,并传入一个对象(这里是 this,即当前实例)作为监视器对象(monitor object)。这段代码的效果等同于将其整个方法声明为同步方法。
只有基于同一个监视器对象的 synchronized 块,在同一时间才能被一个线程执行。
以下两个方法都是基于 this 进行同步的,因此在同步行为上是等价的:
public class MyClass {
public synchronized void log1(String msg1, String msg2){
log.writeln(msg1);
log.writeln(msg2);
}
public void log2(String msg1, String msg2){
synchronized(this){
log.writeln(msg1);
log.writeln(msg2);
}
}
}
因此,这两个方法在同一时间只能被一个线程执行。
如果第二个 synchronized 块使用了不同于 this 的对象作为监视器,那么两个方法就可以被不同线程同时执行。
静态方法中的同步块(Synchronized Blocks in Static Methods)
synchronized 块也可以用在静态方法中。以下是上述示例的静态版本:
public class MyClass {
public static synchronized void log1(String msg1, String msg2){
log.writeln(msg1);
log.writeln(msg2);
}
public static void log2(String msg1, String msg2){
synchronized(MyClass.class){
log.writeln(msg1);
log.writeln(msg2);
}
}
}
这两个方法都是基于 MyClass.class(即该类的 Class 对象)进行同步的,因此同一时间只能有一个线程执行其中任意一个方法。
如果第二个 synchronized 块使用了不同的对象作为监视器,那么两个方法就可以被不同线程同时执行。
Lambda 表达式中的同步块(Synchronized Blocks in Lambda Expressions)
你甚至可以在 Java Lambda 表达式 或匿名类中使用 synchronized 块。
以下是一个在 Lambda 表达式中使用 synchronized 块的示例。注意,该块是基于包含该 Lambda 表达式的类的 Class 对象进行同步的(当然也可以基于其他对象,视具体场景而定):
import java.util.function.Consumer;
public class SynchronizedExample {
public static void main(String[] args) {
Consumer<String> func = (String param) -> {
synchronized(SynchronizedExample.class) {
System.out.println(
Thread.currentThread().getName() + " step 1: " + param);
try {
Thread.sleep((long) (Math.random() * 1000));
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(
Thread.currentThread().getName() + " step 2: " + param);
}
};
Thread thread1 = new Thread(() -> {
func.accept("Parameter");
}, "Thread 1");
Thread thread2 = new Thread(() -> {
func.accept("Parameter");
}, "Thread 2");
thread1.start();
thread2.start();
}
}
Java synchronized 示例
以下示例启动两个线程,并让它们调用同一个 Counter 实例的 add 方法。由于 add 方法是同步的(基于实例),因此同一时间只能有一个线程执行该方法。
public class Example {
public static void main(String[] args){
Counter counter = new Counter();
Thread threadA = new CounterThread(counter);
Thread threadB = new CounterThread(counter);
threadA.start();
threadB.start();
}
}
使用的两个类如下:
public class Counter{
long count = 0;
public synchronized void add(long value){
this.count += value;
}
}
public class CounterThread extends Thread{
protected Counter counter = null;
public CounterThread(Counter counter){
this.counter = counter;
}
public void run() {
for(int i=0; i<10; i++){
counter.add(i);
}
}
}
两个线程共享同一个 Counter 实例。由于 add() 方法是基于实例同步的,因此两个线程不能同时执行该方法。
如果两个线程分别使用不同的 Counter 实例,则它们可以同时调用各自的 add() 方法,因为它们是基于不同对象进行同步的:
public class Example {
public static void main(String[] args){
Counter counterA = new Counter();
Counter counterB = new Counter();
Thread threadA = new CounterThread(counterA);
Thread threadB = new CounterThread(counterB);
threadA.start();
threadB.start();
}
}
synchronized 与数据可见性(Synchronized and Data Visibility)
如果不使用 synchronized(或 volatile 关键字),当一个线程修改了与其他线程共享的变量(例如通过一个所有线程都能访问的对象)时,无法保证其他线程能看到这个修改后的值。这是因为 CPU 寄存器中的变量何时写回主内存、其他线程何时从主内存刷新寄存器中的变量值,都没有保证。
synchronized 关键字解决了这个问题:
- 当线程进入 synchronized 块时,它会刷新所有可见变量的值(从主内存加载最新值)。
- 当线程退出 synchronized 块时,它对所有可见变量的修改都会写回主内存。
这与 volatile 关键字的行为类似。
synchronized 与指令重排序(Synchronized and Instruction Reordering)
Java 编译器和 JVM 允许对代码中的指令进行重排序,以提高执行效率(例如让 CPU 并行执行某些指令)。
但在多线程环境下,指令重排序可能导致问题。例如,如果 synchronized 块内的写操作被重排到块外执行,就会破坏同步语义。
为了解决这个问题,Java 的 synchronized 关键字对 synchronized 块前后及内部的指令重排序施加了限制。这与 volatile 关键字施加的限制类似。
最终结果是:你可以确信你的代码行为符合预期——不会因指令重排序而导致逻辑错误。
应该基于什么对象进行同步?(What Objects to Synchronize On)
如前所述,synchronized 块必须基于某个对象进行同步。你可以选择任何对象,但不建议使用字符串字面量或基本类型包装类对象(如 Integer、Boolean 等)作为监视器对象,因为编译器或 JVM 可能会对它们进行优化(缓存/复用),导致你以为不同的 synchronized 块实际上同步在同一个对象上。
例如:
synchronized("Hey") {
// do something
}
多个地方使用 "Hey" 字符串字面量,JVM 可能会复用同一个 String 对象,导致这些 synchronized 块意外地同步在同一个对象上。
同样地:
synchronized(Integer.valueOf(1)) {
// do something
}
Integer.valueOf(1) 在一定范围内会返回缓存的相同对象,也会导致多个 synchronized 块同步在同一个对象上。
安全做法:使用 this 或 new Object() 作为监视器对象。这些对象不会被 JVM 或标准库缓存或复用。
synchronized 块的局限性与替代方案(Synchronized Block Limitations and Alternatives)
Java 的 synchronized 块存在一些局限性:
- 只允许一个线程进入:即使多个线程只是读取共享数据(而非修改),也无法并发执行。此时可考虑使用 读写锁(Read/Write Lock)。
- 无法控制并发数量:如果你希望允许多个(但有限数量)线程进入,可以使用 信号量(Semaphore)。Java 提供了
Semaphore类。 - 不保证公平性:等待的线程获得锁的顺序不确定。如果你需要 FIFO 公平调度,需自行实现 公平性机制。
- 仅适用于单 JVM:synchronized 块只在同一个 JVM 内有效。在集群环境中(多个 JVM 实例),需要使用分布式锁等其他机制。
- 简单读写场景:如果只有一个线程写、多个线程只读,可考虑使用 volatile 变量 而无需同步。
synchronized 块的性能开销(Synchronized Block Performance Overhead)
进入和退出 synchronized 块会带来轻微的性能开销。虽然随着 Java 版本演进,这一开销已大幅降低,但仍需注意:
- 如果在紧密循环中频繁进入/退出 synchronized 块,性能影响可能显著。
- 尽量缩小 synchronized 块的范围,只同步真正需要同步的代码,以提高程序的并行度。
synchronized 块的可重入性(Synchronized Block Reentrance)
一旦线程进入 synchronized 块,就认为它“持有”了该监视器对象的锁。如果该线程在持有锁的情况下再次尝试进入同一个监视器对象的 synchronized 块(例如递归调用),它是允许的(即可重入)。
例如:
public class MyClass {
List<String> elements = new ArrayList<String>();
public int count() {
if(elements.size() == 0) {
return 0;
}
synchronized(this) {
elements.remove(0);
return 1 + count(); // 递归调用,再次进入 synchronized 块
}
}
}
注:此例仅为演示可重入性,实际中不会这样计算列表长度。
但需注意:如果设计不当,多重 synchronized 块嵌套可能导致 嵌套监视器锁死(Nested Monitor Lockout)。
集群环境中的 synchronized 块(Synchronized Blocks in Cluster Setups)
synchronized 块仅在同一个 Java 虚拟机内有效。如果你的应用部署在多个 JVM(集群)中,那么每个 JVM 中的线程都可以同时进入各自的 synchronized 块。
如果需要跨 JVM 的同步,必须使用其他机制(如分布式锁、数据库锁、Redis 锁等)。