并发问题
多线程是一个非常强大的工具,它使我们能够更好地利用系统资源,但是在读写由多个线程共享的数据时,我们需要特别小心。
当多个线程尝试同时读写共享数据时,会出现两种类型的问题:
- 线程干扰错误
- 内存一致性错误
让我们一一理解这些问题。
线程干扰错误(竞态条件)
考虑下面的Counter
类,该类包含一个increment()
在每次调用时将计数加一的方法-
class Counter {
int count = 0;
public void increment() {
count = count + 1;
}
public int getCount() {
return count;
}
}
现在,假设几个线程试图通过increment()
同时调用方法来增加计数-
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
public class RaceConditionExample {
public static void main(String[] args) throws InterruptedException {
ExecutorService executorService = Executors.newFixedThreadPool(10);
Counter counter = new Counter();
for(int i = 0; i < 1000; i++) {
executorService.submit(() -> counter.increment());
}
executorService.shutdown();
executorService.awaitTermination(60, TimeUnit.SECONDS);
System.out.println("Final count is : " + counter.getCount());
}
}
您认为上述程序的结果是什么?因为我们要调用增量1000次,最终计数将为1000吗?
好吧,答案是否定的!只需运行上面的程序,然后自己查看输出即可。而不是产生最终计数1000,而是每次运行都给出不一致的结果。我在计算机上运行了上述程序3次,输出为992、996和993。
让我们更深入地研究该程序,并了解为什么该程序的输出不一致-
当线程执行crement()方法时,将执行以下三个步骤:1.检索count的当前值2.将检索到的值递增1 3.将递增的值存储回count
现在,假设两个线程(ThreadA和ThreadB)按以下顺序执行这些操作-
- ThreadA:检索计数,初始值= 0
- ThreadB:检索计数,初始值= 0
- ThreadA:递增的检索值,结果= 1
- ThreadB:递增的检索值,结果= 1
- ThreadA:存储增量值,现在计数为1
- ThreadB:存储增量值,现在计数为1
两个线程都尝试将计数加1,但是最终结果是1而不是2,因为线程执行的操作相互交错。在上述情况下,由ThreadA完成的更新将丢失。
上面的执行顺序只是一种可能。这些操作可以执行很多这样的顺序,从而使程序的输出不一致。
当多个线程尝试同时读取和写入共享变量,并且这些读取和写入操作在执行中重叠时,最终结果取决于读取和写入发生的顺序,这是无法预测的。这种现象称为竞态条件。
可以通过同步访问共享变量来避免线程干扰错误。我们将在下一节中学习同步。
首先让我们看一下多线程程序中发生的第二种错误-内存一致性错误。
内存一致性错误
当不同的线程对同一数据的视图不一致时,将发生内存不一致错误。当一个线程更新某些共享数据,但此更新不会传播到其他线程,并且最终使用旧数据时,会发生这种情况。
为什么会这样?好吧,可能有很多原因。编译器对程序进行了一些优化,以提高性能。它还可能会对指令进行重新排序以优化性能。处理器也会尝试优化事物,例如,处理器可能会从临时寄存器(包含变量的最后读取值)而不是主存储器(具有变量的最新值)读取变量的当前值。 。
考虑以下示例,该示例演示了操作中的内存一致性错误-
public class MemoryConsistencyErrorExample {
private static boolean sayHello = false;
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(() -> {
while (!sayHello) {
}
System.out.println("Hello World!");
while (sayHello) {
}
System.out.println("Good Bye!");
});
thread.start();
Thread.sleep(1000);
System.out.println("Say Hello..");
sayHello = true;
Thread.sleep(1000);
System.out.println("Say Bye..");
sayHello = false;
}
}
在理想情况下,上述程序应-
- 等待一秒钟,然后
Hello World!
在显示为sayHello
真后进行打印。 - 再等待一秒钟,然后
Good Bye!
在sayHello
出现错误之后进行打印。
# Ideal Output
Say Hello..
Hello World!
Say Bye..
Good Bye!
但是,运行上述程序后,我们是否获得了所需的输出?好吧,如果您运行该程序,您将看到以下输出-
# Actual Output
Say Hello..
Say Bye..
此外,该程序甚至不会终止。
等待。什么?那怎么可能?
是! 那就是内存一致性错误。第一个线程不知道主线程对sayHello
变量所做的更改。
您可以使用volatile
关键字来避免内存一致性错误。我们将在短期内详细了解volatile关键字。
同步化
通过确保以下两项,可以避免线程干扰和内存一致性错误:
-
一次只能有一个线程读写共享变量。当一个线程正在访问共享变量时,其他线程应等待第一个线程完成。这样可以确保对共享变量的访问是Atomic,并且多个线程不会干扰。
-
每当任何线程修改共享变量时,它都会自动与其他线程随后对共享变量的后续读写建立先发生关系。这样可以保证一个线程所做的更改对其他线程可见。
幸运的是,Java有一个synchronized
关键字,您可以使用该关键字同步对任何共享资源的访问,从而避免两种错误。
同步方法
以下是Counter类的同步版本。我们使用Java的synchronized
关键字onincrement()
方法来防止多个线程同时访问它-
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
class SynchronizedCounter {
private int count = 0;
// Synchronized Method
public synchronized void increment() {
count = count + 1;
}
public int getCount() {
return count;
}
}
public class SynchronizedMethodExample {
public static void main(String[] args) throws InterruptedException {
ExecutorService executorService = Executors.newFixedThreadPool(10);
SynchronizedCounter synchronizedCounter = new SynchronizedCounter();
for(int i = 0; i < 1000; i++) {
executorService.submit(() -> synchronizedCounter.increment());
}
executorService.shutdown();
executorService.awaitTermination(60, TimeUnit.SECONDS);
System.out.println("Final count is : " + synchronizedCounter.getCount());
}
}
如果运行上面的程序,它将产生期望的1000输出。不会发生争用情况,并且最终输出始终是一致的。该synchronized
关键字可确保只有一个线程可以进入increment()
一次的方法。
请注意,同步的概念始终绑定到对象。在上述情况下,对Leadincrement()
的相同实例多次调用methodSynchonizedCounter
会导致竞争条件。而且我们使用synchronized
关键字来防止这种情况。但是线程可以在同一时间increment()
在不同实例上安全地调用方法SynchronizedCounter
,而这不会导致争用条件。
对于静态方法,同步与Class对象关联。
同步块
Java在内部使用所谓的固有锁定或监视器锁定来管理线程同步。每个对象都有一个与之关联的固有锁。
当线程在对象上调用同步方法时,它将自动获取该对象的内在锁,并在方法退出时释放它。即使该方法引发异常,也会发生锁定释放。
在使用静态方法的情况下,线程获取Class
与该类关联的对象的固有锁,这与该类的任何实例的固有锁都不相同。
synchronized
关键字也可以用作阻塞语句,但是与synchronized
方法不同,synchronized
语句必须指定提供内部锁的对象-
public void increment() {
// Synchronized Block -
// Acquire Lock
synchronized (this) {
count = count + 1;
}
// Release Lock
}
当线程获取对象的固有锁时,其他线程必须等待,直到释放该锁为止。但是,当前拥有该锁的线程可以多次获取它,而不会出现任何问题。
允许线程多次获取同一锁的想法称为可重入同步。
Volatile关键字
Volatile关键字用于避免多线程程序中的内存一致性错误。它告诉编译器避免对变量进行任何优化。如果将变量标记为volatile
,则编译器将不会对该变量进行优化或对指令进行重新排序。
同样,变量的值将始终从主存储器而不是临时寄存器中读取。
以下是我们在上一节中看到的相同的MemoryConsistencyError示例,不同的是,这次,我们sayHello
使用volatile
关键字标记了变量。
public class VolatileKeywordExample {
private static volatile boolean sayHello = false;
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(() -> {
while (!sayHello) {
}
System.out.println("Hello World!");
while (sayHello) {
}
System.out.println("Good Bye!");
});
thread.start();
Thread.sleep(1000);
System.out.println("Say Hello..");
sayHello = true;
Thread.sleep(1000);
System.out.println("Say Bye..");
sayHello = false;
}
}
运行上面的程序会产生所需的输出-
# Output
Say Hello..
Hello World!
Say Bye..
Good Bye!
结论
在本教程中,我们了解了多线程程序中可能出现的不同并发问题,以及如何使用synchronized
方法和块避免它们。同步是一个功能强大的工具,但请注意,不必要的同步会导致其他问题,例如死锁和饥饿。