从Java内存模型角度理解安全初始化

本文将简要介绍java内存模型(JMM)的底层细节以及所提供的保障,并从JMM的角度再谈如何在并发环境下正确初始化对象,这将有助于理解更高层面的并发同步机制背后的原理。

相关阅读
1.多线程安全性:每个人都在谈,但是不是每个人都谈地清
2.对象共享:Java并发环境中的烦心事

1. 何为内存模型

如大家所知,Java代码在编译和运行的过程中会对代码有很多意想不到且不受开发人员控制的操作:

  • 在生成指令顺序可能和源代码中顺序不相同;
  • 编译器可能会把变量保存到寄存器中而非内存中;
  • 处理器可以采用乱序或者并行的方式执行指令;
  • 缓存可能会改变将写入变量提交到主内存的次序;
  • 保存在处理器本地缓存中的值,对于其他处理器是不可见的;
  • .....

以上所有的这些情况都可能会导致多线程同步的问题。

其实,在单线程的环境下,这些底层的技术都是为了提高执行效率而存在,不会影响运行结果:JVM只会在运行结果和严格串行执行结果相同的情况下进行如上的优化操作。我们需要知道近些年以来计算性能的提高很大程度上要感谢这些重新排序的操作。

为了进一步提高效率,多核处理器已经广泛被使用,程序在多数时间内都是并发执行,只有在需要的时候才回去协调各个线程之间的操作。那什么是需要的时候呢,JVM将这个问题抛给了程序,要求在代码中使用同步机制来保证多线程安全。

1.1 多处理器架构中的内存模型

在多核理器架构中,每个处理器都拥有自己的缓存,并且会定期地与主内存进行协调。这样的架构就需要解决缓存一致性(Cache Coherence)的问题。很可惜,一些框架中只提供了最小保证,即允许不同处理器在任意时刻从同一存储位置上看到不同的值。

正因此存在上面所述的硬件能力和线程安全需求的差异,才导致需要在代码中使用同步机制来保证多线程安全。

这样“不靠谱”的设计还是为了追求性能,因为要保证每个处理器都能在任意时刻知道其他处理器在做什么需要很大的开销,而且大部分情况下处理器也没有这样的需求,放宽对于存储一致性的保障,以换取性能的提升。

架构中定义了一些特殊的指令,也就是内存栅栏,当需要多线程间数据共享的时,这些指令将会提供额外的存储协调。

值得庆幸的是JMM为我们屏蔽了各个框架在内存模型上的差异,让开发人员不用再去关系这些底层问题。

1.2 重排序

JVM不光会改变命令执行的顺序,甚至还会让不同线程看到的代码执行的顺序也是不同的,这就会让在没有同步操作的情况下预测代码执行结果边变的困难。

下面的代码是《Java Concurrency in Practice》给出的一个例子

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

    public static void main(String[] args) throws InterruptedException {
        //对于每个线程内部而言,语句的执行顺序和结果无关
        //但是对于线程之间,语句的执行顺序却和结果密切相关
        //而不同线程之间的见到的代码执行顺序可能都是不同的
        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();
        System.out.println("( " + x + "," + y + ")");
    }
}

以上代码的输出结果可能是(1,0)、(0,1)、(1,1)甚至是(0,0),这是由于两个线程的执行先后顺序可能不同,线程内部的赋值操作的顺序也有可能相互颠倒。

上面这样简单的代码,如果缺少合理的同步机制都很难预测其结果,复杂的程序将更为困难,这正是通过同步机制限制编译器和运行时对于内存操作重排序限制的意义所在。

1.3 Java内存模型与Happens-Before规则

Java内存模型是通过各种操作来定义的,包括对于变量的对写操作,监视器的加锁和释放锁操作,以及线程的启动和合并,而这些操作都要满足一种偏序关系——Happen-Before规则:想要保证执行操作B的线程看到执行操作A的结果,而无论两个操作是否在同一线程,则操作A和操作B之间必须满足Happens-Before关系,否者JVM将可以对他们的执行顺序任意安排。

Happens-Before规则:

  • 程序顺序规则:一个线程中的每个操作,先于随后该线程中的任意后续操作执行(针对可见性而言);
  • 监视器锁规则:对一个锁的解锁操作,先于随后对这个锁的获取操作执行;
  • volatile变量规则:对一个volatile变量的写操作,先于对这个变量的读操作执行;
  • 传递性:如果A happens-before B,B happens-before C,那么A happens-before C;
  • start规则:如果线程A执行线程B的start方法,那么线程A的ThreadB.start()先于线程B的任意操作执行;
  • join规则:如果线程A执行线程B的join方法,那么线程B的任意操作先于线程A从TreadB.join()方法成功返回之前执行;
  • 中断规则:当线程A调用另一个线程B的interrupt方法时,必须在线程A检测到线程B被中断(抛出InterruptException,或者调用ThreadB.isInterrupted())之前执行。
  • 终结器规则:一个对象的构造函数先于该对象的finalizer方法执行前完成;

2. 安全发布与内存模型

对象共享:Java并发环境中的烦心事中曾介绍过安全发布和数据共享的问题,而造成不正确的发布的根源就在于发布对象的操作和访问对象的操作之间缺少Happens-Before关系。

请看下面这个例子,这是一个不安全的懒加载,只有在第一次用到Resource对象时才会去初始化该对象。

public class UnsafeLazyInitialization {
    private static Resource resource;

    public static Resource getInstance() {
        if (resource == null)
            resource = new Resource(); // unsafe publication
        return resource;
    }

    static class Resource {
    }
}

getInstance() 方法是一个静态方法,可以被多个线程同时调用,就有可能出现数据竞争的问题,在Java内存模型的角度来说就是读取resource对象判断是都为空和对resource赋值的写操作并不存在Happens-Before关系,彼此在多线程环境中不一定是可见的。此外,new Resource()来创建一个类对象,要先分配内存空间,对象各个域都是被赋予默认值,然后再调用构造函数对写入各个域。由于这个过程和读取Resource对象的操作并不满足Happens-Before关系,所以可能一个线程中正在创建对象但是没有执行完毕,而这时另一个线程看到的Resource对象的确不是为空,但却是个失效的状态。

真正线程安全的懒加载应该是这样的,通过同步机制上锁,让读操作和写操作满足Happens-Before规则。

public class SafeLazyInitialization {
    private static Resource resource;

    //一线程获得内置锁之后,在释放锁之前的操作都会先于另外一个线程得到锁的操作执行
    public synchronized static Resource getInstance() {
        if (resource == null)
            resource = new Resource();
        return resource;
    }

    static class Resource {
    }
}

2.1 正确的延迟初始化

为了避免懒加载每次调用getInstance方法的同步开销,可以使用提前初始化的方法,如下:

public class EagerInitialization {
    private static Resource resource = new Resource();

    public static Resource getResource() {
        return resource;
    }

    static class Resource {
    }
}

提前初始化方法利用静态初始化提前加载并有同步机制保护的特性实现了安全发布。更进一步,该方法和JVM的延迟加载机制结合,形成了一种完备的延迟初始化技术-延迟初始化占位类模式,实例如下:

public class ResourceFactory {
    //静态初始化不需要额外的同步机制
    private static class ResourceHolder {
        public static Resource resource = new Resource();
    }

    //延迟加载
    public static Resource getResource() {
        return ResourceHolder.resource;
    }

    static class Resource {
    }
}

上述代码中专门使用了一个类ResourceHolder来初始化Resource对象,ResourceHolder会被JVM推迟初始化直到被真正的调用,并且因为利用了静态初始化而不需要额外的同步机制。

静态初始化或静态代码块因为由JVM的机制保护,不需要额外的同步机制;

2.2 双重检查加锁

下面让我们从Java内存模型的角度谈谈臭名昭著的双重检查加锁(DCL),示例代码如下:

public class DoubleCheckedLocking {
    private static Resource resource;

    public static Resource getInstance() {
        //没有在同步的情况下读取共享变量,破坏了Happens_Before规则
        if (resource == null) {
            synchronized (DoubleCheckedLocking.class) {
                if (resource == null)
                    resource = new Resource();
            }
        }
        return resource;
    }

    static class Resource {

    }
}

由于在早期的JVM中,同步操作很是效率低,所以延迟初始化常被用来避免不必要的同步开销,但是对于DCL,其虽然很好的解决了“独占性”,但是没有正确理解"可见性"。

对象共享:Java并发环境中的烦心事中曾经介绍过:对于共享变量,读写操作都需要在同一个锁的保护之下,从而使得读/写操作都满足Happens-Before规则,在多线程环境中可见。但是在DCL中,第一次对于resource的空判断没有在同步机制下进行,和写操作之间没有Happens-Before关系,即使写操作是同步的,也不能保证写操作的结果是多线程可见的,此时读出的resource的值就可能是初始化到一半的失效状态。

其实只要把resource设置为Volatile就能保证DCL的正常工作,而且性能的影响很小,但是现在JVM已经不断成熟和完善, 没有必要再使用DCL技术,延迟初始化占位模式更为简单和易于理解。

相关阅读
1.多线程安全性:每个人都在谈,但是不是每个人都谈地清
2.对象共享:Java并发环境中的烦心事

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 157,012评论 4 359
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,589评论 1 290
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 106,819评论 0 237
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,652评论 0 202
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 51,954评论 3 285
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,381评论 1 210
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,687评论 2 310
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,404评论 0 194
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,082评论 1 238
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,355评论 2 241
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,880评论 1 255
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,249评论 2 250
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,864评论 3 232
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,007评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,760评论 0 192
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,394评论 2 269
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,281评论 2 259

推荐阅读更多精彩内容