由一个单例模式引发的对指令重排的思考

先说下单例模式,由于频繁创建对象比较浪费资源,就考虑将所有用到某个类的实例时,公用同一个实例,于是就有了单例模式。

单例模式写法有很多,于是我看到了这么一种写法:

public class SingletonTest {

    private SingletonTest() {
    }

    private static SingletonTest singletonTest = null;

    public static SingletonTest getSingletonTest() {
        if (singletonTest == null) {
            // 若singletonTest为空,则加锁,再进一步判空
            synchronized (SingletonTest.class) {
                // 再判断一次是否为null
                if (singletonTest == null) {
                    //若为空,则创建一个新的实例
                    singletonTest = new SingletonTest();
                }
            }
        }
        return SingletonTest;
    }
}

这种写法算是一个考虑比较得当的设计了 为了防止多线程调用产生多个实例,采用了同步锁 加锁位置得当,尽可能降低了加锁对性能的影响
但是在这个示例下方,有指出可能会由于指令重排的影响,导致代码执行错误,只是概率很低。

我不由得重新审视着这段代码,难道看似稳的一逼的代码如此不堪一击?
于是,我大致了解了下指令重排
指令重排序是JVM为了优化指令,提高程序运行效率,在不影响单线程程序执行结果的前提下,尽可能地提高并行度。
也就是说,JVM为了执行效率会将指令进行重新排序,但是这种重新排序不会对单线程程序产生影响。

首先,JVM是如何保证单线程下的指令在重新排序后执行结果不受影响的呢?

happens-before

《JSR-133:Java Memory Model and Thread Specification》对happens-before关系的定义如下:

1.如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。

2.两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么这种重排序并不非法。

  上面1是JMM对程序员的承诺。从程序员的角度来说,可以这样理解happens-before关系:如果A happens-before B,那么Java内存模型将向程序员保证——A操作的结果将对B可见,且A的执行顺序排在B之前。注意,这只是Java内存模型向程序员做出的保证!

  上面2是JMM对编译器和处理器冲排序的约束。MM其实是在遵循一个基本原则:只要不改变程序的执行结果,编译器和处理器怎么优化都行。happens-before这么做的目的,都是为了在不改变程序执行结果的前提下,尽可能地提高程序执行的并行度。

happens-before规则

  1. 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作;
  2. 锁定规则:一个unLock操作先行发生于后面对同一个锁额lock操作;
  3. volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作;
  4. 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C;
  5. 线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作;
  6. 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生;
  7. 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行;
  8. 对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始;

《深入理解Java虚拟机第12章》中对以上规则解读

程序次序规则:一段代码在单线程中执行的结果是有序的。注意是执行结果,因为虚拟机、处理器会对指令进行重排序(重排序后面会详细介绍)。虽然重排序了,但是并不会影响程序的执行结果,所以程序最终执行的结果与顺序执行的结果是一致的。故而这个规则只对单线程有效,在多线程环境下无法保证正确性。
锁定规则:这个规则比较好理解,无论是在单线程环境还是多线程环境,一个锁处于被锁定状态,那么必须先执行unlock操作后面才能进行lock操作。
volatile变量规则:这是一条比较重要的规则,它标志着volatile保证了线程可见性。通俗点讲就是如果一个线程先去写一个volatile变量,然后一个线程去读这个变量,那么这个写操作一定是happens-before读操作的。
传递规则:提现了happens-before原则具有传递性,即A happens-before B , B happens-before C,那么A happens-before C
线程启动规则:假定线程A在执行过程中,通过执行ThreadB.start()来启动线程B,那么线程A对共享变量的修改在接下来线程B开始执行后确保对线程B可见。
线程终结规则:假定线程A在执行的过程中,通过制定ThreadB.join()等待线程B终止,那么线程B在终止之前对共享变量的修改在线程A等待返回后可见。

如下代码,可能的输出结果:(1,1)、(1,0)、(0,1)、(0,0)

public class Test {
    private static int x = 0, y = 0;
    private static int a = 0, b =0;

    public static void main(String[] args) throws InterruptedException {
        int i = 0;
        for(;;) {
            i++;
            x = 0; y = 0;
            a = 0; b = 0;
            Thread one = new Thread(new Runnable() {
                public void run() {
                    a = 1;
                    x = b;
                }
            });

            Thread other = new Thread(new Runnable() {
                public void run() {
                    b = 1;
                    y = a;
                }
            });
            one.start();other.start();
            one.join();other.join();
            String result = "第" + i + "次 (" + x + "," + y + ")";
            if(x == 0 && y == 0) {
                System.err.println(result);
                break;
            } else {
                System.out.println(result);
            }
        }
    }
}

以上输出结果中,其中(0,0)这种输出结果是在两个线程均被指令重排后发生的。
其实一开始,我是蒙蔽的,规则第一条是这么描述的程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作。既然run方法中的代码都遵循了happens-before了,为啥还被重排序了?
原因是happens-before关系的定义的第二点如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么这种重排序并不非法,如下图所示:
这里写图片描述

由于run方法中的代码被指令重排后,执行的结果并不会对当前线程产生不同的结果,所以即使它符合了happens-before规则,还是被重排了。这点问题纠结了我好一会儿,看图后豁然开朗…

最后,再分析下文章开头的单例代码

public static SingletonTest getSingletonTest() {
     if (singletonTest == null) {
         // 若singletonTest为空,则加锁,再进一步判空
         synchronized (SingletonTest.class) {
             // 再判断一次是否为null
             if (singletonTest == null) {
                 //若为空,则创建一个新的实例
                 singletonTest = new SingletonTest();
             }
         }
     }
     return SingletonTest;
 }

由于singletonTest = new SingletonTest()操作并不是一个原子性指令,会被分为多个指令:

memory = allocate(); //1:分配对象的内存空间
ctorInstance(memory); //2:初始化对象
instance = memory; //3:设置instance指向刚分配的内存地址

但是经过重排序后如下:

memory = allocate(); //1:分配对象的内存空间
instance = memory; //3:设置instance指向刚分配的内存地址,此时对象还没被初始化
ctorInstance(memory); //2:初始化对象

若有A线程进行完重排后的第二步,且未执行初始化对象。此时B线程来取singletonTest时,发现singletonTest不为空,于是便返回该值,但由于没有初始化完该对象,此时返回的对象是有问题的。这也就是为什么说看似稳的一逼的代码,实则不堪一击。
另外,在《java并发编程实战》16.2.4中对该种双重检查加锁(DCL)提出了批评。批评的主要点在于,该方式会导致上述指出的取到一个无效或错误状态的对象。
上述代码的改进方法:将singletonTest声明为volatile类型即可(volatile有内存屏障的功能)。

参考博客:
https://www.cnblogs.com/senlinyang/p/7875458.html

  • 7
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值