并发编程常见问题-可见性、原子性、有序性
在我们的开发过程中,对于一些比较复杂的场景很多时候都得靠多线程来处理。像电商搞大促,服务器要瞬间处理成千上万的订单,手机地图软件,得一边加载地图,一边定位你的位置,这些都离不开多线程。它确实能让程序执行更快,但如果这个过程中会去修改共享资源,就会带来一系列的并发问题。这里我们介绍下并发编程中会出现哪些问题,然后我们再根据这些问题去针对性解决,以及去了解JMM中提供了哪些方法去解决这些问题。
原子性问题
原子性是指一个操作是不可中断的,要么全部执行成功,要么全部不执行。在 Java 中,对于基本数据类型的变量的读取和赋值操作是原子性操作,例如int a = 5;
。但像a++
这种复合操作就不是原子性的,它实际上包含了读取、增加和赋值三个步骤。
以银行账户转账为例,假设有两个线程同时对同一个账户进行转账操作,一个线程增加账户余额,另一个线程减少账户余额。如果这两个操作不是原子性的,就可能会出现数据不一致的情况。例如,初始余额为 1000 元,线程 A 要增加 100 元,线程 B 要减少 200 元。如果线程 A 读取余额为 1000 元,还未进行增加操作时,线程 B 读取余额也是 1000 元,然后线程 B 进行减少操作,将余额变为 800 元并写回。接着线程 A 继续执行增加操作,将余额变为 1100 元并写回,最终余额就变成了 1100 元,而不是正确的 900 元。
从 CPU 指令层面来看,i++
操作通常会被分解为多条指令。在多线程环境下,当一个线程执行i++
操作时,在执行过程中可能会被其他线程打断。例如,线程 A 执行i++
操作,它先读取i
的值,然后进行加 1 操作,在将结果写回之前,线程 B 也读取了i
的值,由于线程 A 还未写回结果,线程 B 读取的值和线程 A 读取的值相同,最终就会导致结果错误。
可见性问题
可见性是指当一个线程修改了共享变量的值,其他线程能够立即看到修改后的值。在 Java 中,线程之间的通信是通过主内存来实现的,每个线程都有自己的工作内存,线程对变量的操作都是在自己的工作内存中进行,然后再将结果同步回主内存。如果一个线程修改了共享变量的值,但没有及时将结果同步回主内存,而其他线程又从自己的工作内存中读取该变量的值,就会出现可见性问题。
以volatile
关键字相关案例来说,当一个变量被volatile
修饰时,它会保证修改的值立即被同步回主内存,并且其他线程读取该变量时会从主内存中读取最新的值。例如:
public class VolatileTest {
private static volatile boolean flag = false;
public static void main(String[] args) {
new Thread(() -> {
while (!flag) {
// 线程会一直循环,直到flag为true
}
System.out.println("线程结束");
}).start();
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
flag = true;
System.out.println("主线程修改flag为true");
}
}
在这个例子中,如果flag
没有被volatile
修饰,子线程可能永远看不到主线程对flag
的修改,导致死循环。而加上volatile
修饰后,主线程修改flag
的值会立即被同步回主内存,子线程读取flag
时会从主内存中读取最新的值,从而能够结束循环。
有序性问题
有序性是指程序执行的顺序按照代码的先后顺序执行。在 Java 中,为了提高程序的执行效率,编译器和处理器会对指令进行重排序。在单线程环境下,重排序不会影响程序的正确性,但在多线程环境下,重排序可能会导致程序出现错误。
以单例模式的双重检查锁定(DCL)为例:
public class Singleton {
private static Singleton instance;
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
在这个代码中,instance = new Singleton();
这行代码实际上包含了三个步骤:1. 分配内存空间;2. 初始化对象;3. 将对象引用赋值给instance
。在多线程环境下,由于指令重排序,可能会先执行步骤 1 和 3,然后再执行步骤 2。如果此时另一个线程进入getInstance
方法,判断instance
不为null
,就会直接返回一个还未初始化完成的对象,从而导致程序出错。为了解决这个问题,可以使用volatile
关键字修饰instance
,禁止指令重排序,确保对象初始化的顺序正确。