聊聊Happens-Before和单例模式

654 阅读6分钟

总的来说:前面的操作的结果对后面的操作是可见的

一段经典的程序

class VolatileTest {
    int x = 0;
    volatile boolean flag = false;
    public void writer() {
        x = 42;						// 1
        flag = true;				// 2
    }
    public void reader() {
        if (flag) {					// 3
            System.out.print(x);	// 4
        }
    }
}

1. 程序的顺序性规则

  操作1 Happens-Before 操作2;操作3 Happens-Before 操作 4。

当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值立即刷新到主内存中。
当读一个volatile变量时,JMM会把该线程对应的本地内存设置为无效,直接从主内存中读取共享变量

翻译如下

  • 第一个操作为volatile读,则不管第二个操作是啥,都不能重排序。这个操作确保volatile读之后的操作不会被编译器重排序到volatile读之前;
  • 第二个操作为volatile写,则不管第一个操作是啥,都不能重排序。这个操作确保volatile写之前的操作不会被编译器重排序到volatile写之后;
  • 第一个操作volatile写,第二操作为volatile读时,不能重排序。

2. volatile 变量规则

  指对一个 volatile 变量的写操作, Happens-Before 于后续对这个 volatile 变量的读操作。

3. 传递性

  A 操作 Happens-Before B 操作,且 B 操作 Happens-Before C 操作,那么 A 操作 Happens-Before C 操作。

在代码中,我们可以看到:

  • 操作1 Happens-Before 操作2,操作3 Happens-Before 操作4,这是规则1的内容
  • volatile 变量写操作 flag = true Happens-Before 读变量 flag,这是规则2的内容

根据传递性规则,操作1 Happens-Before 操作4,这意味着输出结果肯定是 42。

4. 管程中锁的规则

  对一个锁的解锁 Happens-Before 于后续对这个锁的加锁。

  在Java中,管程是操作系统的同步原语,Java的实现为 synchronized 。管程中的锁在Java中是隐式实现的,在进入同步块之前自动加锁,出同步块自动解锁。如下面代码:

class Test {
    private static volatile int x = 0;
    public static void main(String[] args) throws InterruptedException {
        synchronized (Test.class) { // 此处自动加锁
            // x 是共享变量, 初始值 =10
            if (x < 666) {
                x = 666;
            }  
        } // 此处自动解锁,x=666
    }
}

注意x为 volatile 变量

5. 线程 start() 规则

  在线程上对 Thread.start() 的调用,必须在该线程中执行任何操作前执行。

  如主线程A启动子线程B后,子线程B能够看到主线程A在启动子线程B之前的操作。

class Test {
    private static volatile int var = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread B = new Thread(()->{
            // 主线程调用 B.start() 之前
            // 所有对共享变量的修改,此处皆可见    
        });
        // 此处对共享变量 var 修改
        var = 666;
        // 主线程启动子线程
        B.start();
    }
}

6. 线程 join() 规则

  线程中的任何操作都必须在其他线程检测到该线程已经结束之前执行,或者从 Thread.join() 中成功返回,或者在调用 Thread.isAlive() 时返回 false。

  如主线程A等待子线程B执行完成(通过调用子线程B的join()方法实现),当B线程执行完毕后,主线程A能够看到子线程的关于对 volatile 变量操作。

class Test {
    private static volatile int var = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread B = new Thread(() -> {
            // 此处对共享变量 var 修改
            var = 666;
        });
        // 例如此处对共享变量修改,
        // 则这个修改结果对线程 B 可见
        // 主线程启动子线程
        B.start();
        B.join();
        // 子线程所有对共享变量的修改
        // 在主线程调用 B.join() 之后皆可见
        // 此例中,var==666
    }
}

7. 中断规则

  当一个线程在另一个线程上调用 interrupt() 时,必须在被中断线程检测到 interrupt() 之前执行(通过抛出 InterruptedException ,或者调用 isInterrupted()interrupted)。

class Test {
    private static volatile int var = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(() -> {
            var = 666;
        });
        // main线程启动子线程
        thread.start();
        // main线程通知子线程停止
        thread.interrupt();
        // 等待线程结束
        thread.join();
        // 此处var = 666
    }
}

8. 终结器规则

  对象的构造函数必须在启动该对象的终结器(finalize()方法)之前执行。finalize() 本就是在对象被回收之前调用,这个不难理解。

单例模式中的双检锁与静态内部类

懒汉式中最丑陋的单例实现
public class Singleton {
    private static Singleton singleton;

    private Singleton() {}

    public static synchronized Singleton getInstance() {
        if (singleton == null) {
            singleton = new Singleton();
        }
        return singleton;
    }
}

  因为是类锁,所以会导致这个方法会很低效,导致程序性能下降。

  在初始化器中采用了特殊的方式来处理静态域(或者在静态初始化代码块中初始化的值),并提供了额外的线程安全性保证。

  静态初始化器是由JVM在类的初始化阶段执行,即在类被加载后并且被线程使用之前。JVM将在初始化期间获得一个锁,并且每个线程都至少获取一次这个锁以保证这个类已经加载。因此在类初始化期间,内存的写入操作将自动对所有线程可见。

  这个规则仅仅适用于在构造时候的状态,如果这个对象是可变的,后续在对这个对象进行读写操作时仍需要同步操作。

双检锁
public class Singleton {
    private static Singleton singleton;

    private Singleton() {}

    public static Singleton getInstance() {
        if (singleton == null) {                             // 1
            synchronized (Singleton.class) {                 // 2
                if (singleton == null) {                     // 3
                    singleton = new Singleton();             // 4
                }
            }
        }
        return singleton;
    }
}

上述代码”看起来很完美”

  • 第一个检查null操作,优化创建对象后的速度
  • 操作 2 由于 synchronized 存在,只会有一个线程创建对象
  • 第二个检查为:在第一个获取锁的线程创建对象之后,避免再创建对象,此时再判断为空,对象一定不为空,直接返回

上述代码是错误的,实际上就是错误的理解了对象初始化过程,误以为是原子操作,实例化对象一般为3个步骤:

  1. 分配内存空间
  2. 初始化对象
  3. 将内存空间的地址赋值给对应的引用

但是由于重排序的缘故,步骤2、3可能会发生重排序,其过程如下:

  1. 分配内存空间
  2. 将内存空间的地址赋值给对应的引用
  3. 初始化对象

如果2、3发生了重排序就会导致第二个判断会出错,singleton != null,但是它其实仅仅只是一个地址而已,此时对象还没有被初始化,所以return的singleton对象是一个没有被初始化的对象。

  知道问题根源所在,就好解决了:不允许初始化阶段步骤2,3发生重排序,将变量 singleton 声明为 volatile 即可。

静态内部类

  这种解决方案的实质是:运行步骤2和步骤3重排序,但是不允许其他线程看见。解释同 懒汉式中最丑陋的单例实现

public class Singleton {
    private static class SingletonHolder {
        public static Singleton singleton = new Singleton();
    }
    public static Singleton getInstance() {
        return SingletonHolder.singleton;
    }
}

参考资料

  1. 极客时间
  2. chenssy的博客
  3. chenssy的博客
  4. 《Java并发编程实战》

本文由 发给官兵 创作,采用 CC BY 3.0 CN协议 进行许可。 可自由转载、引用,但需署名作者且注明文章出 处。如转载至微信公众号,请在文末添加作者公众号二维码。