学了 JMM 指令重排序,让我明白该如何写单例模式了

1,019 阅读7分钟

JMM 是什么?

JMM 是 Java Memory Model 的缩写, Java 内存模型来屏蔽各种硬件和操作系统的内存访问差异,以实现让 Java 程序在各种平台下能够达到一致的内存访问效果。 image.png

重排序

在许多情况下,访问程序变量(对象实例字段、类静态字段和数组元素)的执行顺序可能与程序指定的顺序不同。编译器可以以优化的名义随意调整指令的顺序。在某些情况下,处理器可能会乱序执行指令。数据可以按照不同于程序指定的顺序在寄存器、处理器缓存和主存储器之间移动。 image.png

产生重排序?

下面是一个简单的例子,我运行的多次次之后出现了指令重排序的问题。代码如下:

/**
 * 当m1 和 m2 发生重排序后,先执行x=b,y=a; 在执行a=1,b=1,最后x=0,y=0
 *
 * @author xx
 * @date 2021-08-31
 */
public class JmmTest {

    private int a = 0;
    private int b = 0;
    private int x = 1;
    private int y = 1;

    public void m1() {
        a = 1;
        x = b;
    }

    public void m2() {
        b = 1;
        y = a;
    }

    public void test() throws Exception {
        CyclicBarrier cyc = new CyclicBarrier(2);
        Thread thread1 = new Thread(() -> {
            try {
                cyc.await();
            } catch (Throwable e) {
                e.printStackTrace();
            }
            m1();
        }, "A");

        Thread thread2 = new Thread(() -> {
            try {
                cyc.await();
            } catch (Throwable e) {
                e.printStackTrace();
            }
            m2();
        }, "B");


        thread1.start();
        thread2.start();
        thread1.join();
        thread1.join();

        if (x == 0 && y == 0) {
            System.out.println("重排序了。。。。");
        }

    }


    public static void main(String[] args) throws Exception {
        for (int i = 0; i < 100000; i++) {
            JmmTest jmmTest = new JmmTest();
            jmmTest.test();
        }
    }
}


// 输出结果
重排序了。。。。
重排序了。。。。
重排序了。。。。
重排序了。。。。

final 关键字

对于基本类型前加以 final 修饰,表示被修饰的变量为常数,不可以修改。一个既是static又是final的字段表示只占据一段不能改变的存储空间。

final 用于对象应用时,final 使应用恒定不变。一旦引用被初始化指向一个对象,就无法再把它改为指向另一个对象。

final 关键字如何工作?

对象的最终字段的值在其构造函数中设置。假设对象被“正确”构造,一旦构造了对象,分配给构造函数中的最终字段的值将对所有其他线程可见,无需同步。此外,这些最终字段引用的任何其他对象或数组的可见值将至少与最终字段一样最新。 ​

正确构造对象意味着什么?它只是意味着在构造过程中不允许对正在构造的对象的引用“逃逸”。换句话说,不要将正在构造的对象的引用放在另一个线程可能能够看到它的任何地方;不要将其分配给静态字段,不要将其注册为任何其他对象的侦听器,等等。这些任务应该在构造函数完成后完成,而不是在构造函数中完成。 ​

举个例子:

class FinalFieldExample {
    final int x;
    int y;
    static FinalFieldExample f;

    public FinalFieldExample() {
        x = 3;
        y = 4;
    }

    static void writer() {
        f = new FinalFieldExample();
    }

    static void reader() {
        if (f != null) {
            int i = f.x;
            int j = f.y;
        }
    }
}

上面的类是如何使用 final 字段的示例。执行reader的线程一定会看到fx的值 3 ,因为它是最终的。不能保证看到y的值 4 ,因为它不是最终的。如果FinalFieldExample的构造函数如下所示:

public FinalFieldExample() { // bad!
  x = 3;
  y = 4;
  // bad construction - allowing this to escape
  global.obj = this;
}

然后该读出的参照线程 此从 global.obj 是保证看到 x =3。

查看正确构造的字段值的能力很好,但是如果该字段本身是一个引用,那么您还希望您的代码看到它指向的对象(或数组)的最新值。如果您的字段是最终字段,这也是有保证的。因此,您可以拥有指向数组的最终指针,而不必担心其他线程会看到数组引用的正确值,但会看到数组内容的错误值。同样,这里的“正确”是指“截至对象构造函数结束时的最新值”,而不是“可用的最新值”。 ​

现在,说了这么多,如果在一个线程构造了一个不可变对象(即一个只包含 final 字段的对象)之后,你想确保它被所有其他线程正确地看到,你通常仍然需要使用同步。例如,没有其他方法可以确保第二个线程可以看到对不可变对象的引用。程序从 final 字段获得的保证应该通过对如何在代码中管理并发性的深入和仔细的理解来仔细调整。

volatile 关键字

volatile 字段是用于在线程之间通信状态的特殊字段。对 volatile 的每次读取都将看到任何线程对该 volatile 的最后一次写入;实际上,程序员将它们指定为字段,因为缓存或重新排序的结果永远不会允许看到“过时”值。禁止编译器和运行时在寄存器中分配它们。它们还必须确保在写入之后,它们会从缓存中刷新到主内存,以便其他线程可以立即看到它们。类似地,在读取易失性字段之前,必须使缓存失效,以便看到的是主存中的值,而不是本地处理器缓存中的值。对易失性变量访问的重新排序也有额外的限制。 ​

在旧的内存模型下,对易失性变量的访问不能相互重新排序,但可以使用非易失性变量访问重新排序。这破坏了易失性字段作为从一个线程向另一个线程发送条件信号的手段的实用性。 ​

在新的内存模型下,易变变量不能相互重新排序仍然是事实。不同之处在于,现在对它们周围的正常字段访问重新排序不再那么容易了。写入易失性字段具有与监视器释放相同的记忆效果,从易失性字段读取具有与监视器获取相同的记忆效果。实际上,由于新的内存模型对易失性字段访问与其他字段访问(无论是否易失性)的重新排序施加了更严格的限制,因此线程A 在写入易失性字段f时可见的任何内容在线程 B 读取f时都变为可见。

class VolatileExample {
  int x = 0;
  volatile boolean v = false;
  public void writer() {
    x = 42;
    v = true;
  }

  public void reader() {
    if (v == true) {
      //uses x - guaranteed to see 42.
    }
  }
}

DCL 单例模式

通过 DCL(double-checked-locking) ,通过延迟初始化单例对象来减少初始化过程中内存的开销,下面是一个双重锁实现单例模式的例子:

// double-checked-locking - don't do this!

private static Something instance = null;

public Something getInstance() {
  if (instance == null) {
    synchronized (this) {
      if (instance == null)
        instance = new Something();
    }
  }
  return instance;
}

**上面做看上去没有,但是这样写是错误的。 **最明显的原因是初始化 instance 的写入和对 instance 字段的写入可以由编译器或缓存重新排序,这将具有返回看起来是部分构造的东西的效果。结果是我们读取了一个未初始化的对象。还有很多其他原因导致这是错误的,以及为什么对它的算法更正是错误的。 ​

许多人认为使用 volatile关键字会消除尝试使用双重检查锁定模式时出现的问题。在 1.5 之前的 JVM 中,volatile 不能确保它工作。在新的内存模型下,使实例 字段 volatile 将“修复”双重检查锁定的问题,因为这样在构造线程初始化Something和返回其值之间会有一个 happens-before 关系读取它的线程。 ​

推荐使用使用 Initialization On Demand Holder 用法,它是线程安全的并且更容易理解:

private static class LazySomethingHolder {
  public static Something something = new Something();
}

public static Something getInstance() {
  return LazySomethingHolder.something;
}

由于静态字段的初始化保证,此代码保证是正确的;如果在静态初始值设定项中设置了一个字段,则保证它对访问该类的任何线程都正确可见。

参考资料