1. 概述
在本文中,我们将学习如何在 Java 中使用 synchronized 块。
简单来说,在多线程环境中,当两个或多个线程同时尝试修改共享的可变数据时,就会发生竞态条件(race condition)。Java 提供了一种机制,通过同步线程对共享数据的访问来避免竞态条件。
使用 synchronized 标记的一段逻辑会成为一个同步块(synchronized block),确保在任意时刻只有一个线程可以执行该代码。
2. 为什么要使用同步?
让我们考虑一个典型的竞态条件场景:我们计算一个总和,多个线程同时执行 calculate() 方法:
public class SynchronizedMethods {
private int sum = 0;
public void calculate() {
setSum(getSum() + 1);
}
// 标准的 setter 和 getter 方法
}
然后编写一个简单的测试用例:
@Test
public void givenMultiThread_whenNonSyncMethod() {
ExecutorService service = Executors.newFixedThreadPool(3);
SynchronizedMethods summation = new SynchronizedMethods();
IntStream.range(0, 1000)
.forEach(count -> service.submit(summation::calculate));
service.awaitTermination(1000, TimeUnit.MILLISECONDS);
assertEquals(1000, summation.getSum());
}
这里我们使用了一个包含 3 个线程的 ExecutorService,执行 calculate() 方法 1000 次。
如果串行执行,预期结果应为 1000,但在多线程环境下,几乎每次都会失败,并返回不一致的实际结果:
java.lang.AssertionError: expected:<1000> but was:<965>
at org.junit.Assert.fail(Assert.java:88)
at org.junit.Assert.failNotEquals(Assert.java:834)
...
当然,这个结果并不意外。
避免竞态条件的一个简单方法就是使用 synchronized 关键字,使操作变成线程安全的。
3. synchronized 关键字
我们可以在不同层级使用 synchronized 关键字:
- 实例方法(Instance methods)
- 静态方法(Static methods)
- 代码块(Code blocks)
当我们使用 synchronized 块时,Java 内部会使用一种称为监视器(monitor)(也叫监视器锁或内置锁)的机制来实现同步。这些监视器与对象绑定,因此同一个对象的所有 synchronized 块在同一时间只能被一个线程执行。
3.1 同步实例方法
我们可以在方法声明中添加 synchronized 关键字,使其成为同步方法:
public synchronized void synchronisedCalculate() {
setSum(getSum() + 1);
}
注意,一旦我们将方法同步化,测试用例就能通过,实际输出为 1000:
@Test
public void givenMultiThread_whenMethodSync() {
ExecutorService service = Executors.newFixedThreadPool(3);
SynchronizedMethods method = new SynchronizedMethods();
IntStream.range(0, 1000)
.forEach(count -> service.submit(method::synchronisedCalculate));
service.awaitTermination(1000, TimeUnit.MILLISECONDS);
assertEquals(1000, method.getSum());
}
实例方法的同步是基于拥有该方法的类的实例对象。这意味着每个类的实例在同一时间只能有一个线程执行该同步方法。
3.2 同步静态方法
静态方法也可以像实例方法一样进行同步:
public static synchronized void syncStaticCalculate() {
staticSum = staticSum + 1;
}
这类方法是基于与该类关联的 Class 对象进行同步的。由于每个 JVM 中每个类只有一个 Class 对象,因此无论该类有多少个实例,同一时间只能有一个线程执行该类的静态同步方法。
测试如下:
@Test
public void givenMultiThread_whenStaticSyncMethod() {
ExecutorService service = Executors.newCachedThreadPool();
IntStream.range(0, 1000)
.forEach(count ->
service.submit(SynchronizedMethods::syncStaticCalculate));
service.awaitTermination(100, TimeUnit.MILLISECONDS);
assertEquals(1000, SynchronizedMethods.staticSum);
}
3.3 方法内的同步代码块
有时我们并不希望同步整个方法,而只是同步其中的一部分代码。这时可以将 synchronized 应用于代码块:
public void performSynchronisedTask() {
synchronized (this) {
setCount(getCount() + 1);
}
}
然后我们可以测试这一改动:
@Test
public void givenMultiThread_whenBlockSync() {
ExecutorService service = Executors.newFixedThreadPool(3);
SynchronizedBlocks synchronizedBlocks = new SynchronizedBlocks();
IntStream.range(0, 1000)
.forEach(count ->
service.submit(synchronizedBlocks::performSynchronisedTask));
service.awaitTermination(100, TimeUnit.MILLISECONDS);
assertEquals(1000, synchronizedBlocks.getCount());
}
注意,我们在 synchronized 块中传入了 this 作为参数。这就是监视器对象(monitor object)。块内的代码将基于该监视器对象进行同步。简而言之,每个监视器对象在同一时间只允许一个线程执行该代码块。
如果方法是静态的,则应传入类名作为监视器对象,此时该类的 Class 对象将作为同步监视器:
public static void performStaticSyncTask(){
synchronized (SynchronizedBlocks.class) {
setStaticCount(getStaticCount() + 1);
}
}
测试静态方法中的同步块:
@Test
public void givenMultiThread_whenStaticSyncBlock() {
ExecutorService service = Executors.newCachedThreadPool();
IntStream.range(0, 1000)
.forEach(count ->
service.submit(SynchronizedBlocks::performStaticSyncTask));
service.awaitTermination(100, TimeUnit.MILLISECONDS);
assertEquals(1000, SynchronizedBlocks.getStaticCount());
}
3.4 可重入性(Reentrancy)
synchronized 方法和代码块背后的锁是可重入的(reentrant)。这意味着当前线程在已经持有某个同步锁的情况下,可以多次再次获取同一个锁:
Object lock = new Object();
synchronized (lock) {
System.out.println("第一次获取锁");
synchronized (lock) {
System.out.println("再次进入");
synchronized (lock) {
System.out.println("又一次进入");
}
}
}
如上所示,在一个 synchronized 块内部,我们可以反复获取同一个监视器锁。
4. 结论
在本文中,我们探讨了使用 synchronized 关键字实现线程同步的多种方式。
我们还了解了竞态条件可能对应用程序造成的影响,以及同步机制如何帮助我们避免此类问题。