在我们的开发过程中,对于一些比较复杂的场景很多时候都得靠多线程来处理。像电商搞大促,服务器要瞬间处理成千上万的订单,手机地图软件,得一边加载地图,一边定位你的位置,这些都离不开多线程。它确实能让程序执行更快,但如果这个过程中会去修改共享资源,就会带来一系列的并发问题。这里我们介绍下并发编程中会出现哪些问题,然后我们再根据这些问题去针对性解决,以及去了解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 ,禁止指令重排序,确保对象初始化的顺序正确。

文章作者: Z
本文链接:
版权声明: 本站所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 微博客
并发编程
喜欢就支持一下吧