Java 中的同步关键字指南-Java快速入门教程
1. 概述
在本文中,我们将学习在 Java 中使用同步块。
简而言之,在多线程环境中,当两个或多个线程尝试同时更新可变共享数据时,就会发生争用条件。Java 提供了一种机制,通过同步对共享数据的线程访问来避免竞争条件。
标记为已同步的一段逻辑成为同步块,在任何给定时间只允许一个线程执行。
2. 为什么要同步?
让我们考虑一个典型的竞争条件,我们计算总和,多个线程执行calculate() 方法:
public class SynchronizedMethods {
private int sum = 0;
public void calculate() {
setSum(getSum() + 1);
}
// standard setters and getters
}
然后让我们写一个简单的测试:
@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)
...
当然,我们并不觉得这个结果出乎意料。
避免争用条件的一种简单方法是使用同步关键字使操作线程安全。
3.同步关键字
我们可以在不同级别使用同步关键字:
- 实例方法
- 静态方法
- 代码块
当我们使用同步块时,Java在内部使用监视器,也称为监视器锁或内部锁,以提供同步。这些监视器绑定到对象;因此,同一对象的所有同步块只能有一个线程同时执行它们。
3.1.同步实例方法
我们可以在方法声明中添加sync关键字,使方法同步:
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 仅存在一个类对象,因此每个类只有一个线程可以在静态同步方法中执行,而不管它有多少实例。
让我们测试一下:
@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.方法中的同步块
有时我们不想同步整个方法,只想同步其中的一些指令。我们可以通过将同步应用于块来实现这一点:
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());
}
请注意,我们将参数 this传递给了同步块。这是监视器对象。块中的代码在监视器对象上同步。简单地说,每个监视器对象只能在该代码块内执行一个线程。
如果方法是静态的,我们将传递类名来代替对象引用,并且该类将是块同步的监视器:
public static void performStaticSyncTask(){
synchronized (SynchronisedBlocks.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. 重入
同步方法和块后面的锁是可重入的。这意味着当前线程可以在持有它的同时一遍又一遍地获取相同的同步锁:
Object lock = new Object();
synchronized (lock) {
System.out.println("First time acquiring it");
synchronized (lock) {
System.out.println("Entering again");
synchronized (lock) {
System.out.println("And again");
}
}
}
如上所示,在同步块中,我们可以重复获取相同的监视器锁。
4. 结论
在这篇简短的文章中,我们探讨了使用sync关键字实现线程同步的不同方法。
我们还了解了争用条件如何影响我们的应用程序,以及同步如何帮助我们避免这种情况。