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

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

本文将简要介绍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并发环境中的烦心事

相关文章
|
1月前
|
安全 Java 程序员
深入理解Java内存模型与并发编程####
本文旨在探讨Java内存模型(JMM)的复杂性及其对并发编程的影响,不同于传统的摘要形式,本文将以一个实际案例为引子,逐步揭示JMM的核心概念,包括原子性、可见性、有序性,以及这些特性在多线程环境下的具体表现。通过对比分析不同并发工具类的应用,如synchronized、volatile关键字、Lock接口及其实现等,本文将展示如何在实践中有效利用JMM来设计高效且安全的并发程序。最后,还将简要介绍Java 8及更高版本中引入的新特性,如StampedLock,以及它们如何进一步优化多线程编程模型。 ####
34 0
|
1月前
|
存储 监控 算法
Java内存管理深度剖析:从垃圾收集到内存泄漏的全面指南####
本文深入探讨了Java虚拟机(JVM)中的内存管理机制,特别是垃圾收集(GC)的工作原理及其调优策略。不同于传统的摘要概述,本文将通过实际案例分析,揭示内存泄漏的根源与预防措施,为开发者提供实战中的优化建议,旨在帮助读者构建高效、稳定的Java应用。 ####
42 8
|
1月前
|
存储 监控 算法
深入探索Java虚拟机(JVM)的内存管理机制
本文旨在为读者提供对Java虚拟机(JVM)内存管理机制的深入理解。通过详细解析JVM的内存结构、垃圾回收算法以及性能优化策略,本文不仅揭示了Java程序高效运行背后的原理,还为开发者提供了优化应用程序性能的实用技巧。不同于常规摘要仅概述文章大意,本文摘要将简要介绍JVM内存管理的关键点,为读者提供一个清晰的学习路线图。
|
1月前
|
存储 算法 Java
Java 内存管理与优化:掌控堆与栈,雕琢高效代码
Java内存管理与优化是提升程序性能的关键。掌握堆与栈的运作机制,学习如何有效管理内存资源,雕琢出更加高效的代码,是每个Java开发者必备的技能。
62 5
|
1月前
|
存储 算法 Java
Java内存管理深度解析####
本文深入探讨了Java虚拟机(JVM)中的内存分配与垃圾回收机制,揭示了其高效管理内存的奥秘。文章首先概述了JVM内存模型,随后详细阐述了堆、栈、方法区等关键区域的作用及管理策略。在垃圾回收部分,重点介绍了标记-清除、复制算法、标记-整理等多种回收算法的工作原理及其适用场景,并通过实际案例分析了不同GC策略对应用性能的影响。对于开发者而言,理解这些原理有助于编写出更加高效、稳定的Java应用程序。 ####
|
1月前
|
安全 Java 程序员
Java内存模型的深入理解与实践
本文旨在深入探讨Java内存模型(JMM)的核心概念,包括原子性、可见性和有序性,并通过实例代码分析这些特性在实际编程中的应用。我们将从理论到实践,逐步揭示JMM在多线程编程中的重要性和复杂性,帮助读者构建更加健壮的并发程序。
|
2月前
|
SQL 安全 Java
Java 异常处理:筑牢程序稳定性的 “安全网”
本文深入探讨Java异常处理,涵盖异常的基础分类、处理机制及最佳实践。从`Error`与`Exception`的区分,到`try-catch-finally`和`throws`的运用,再到自定义异常的设计,全面解析如何有效管理程序中的异常情况,提升代码的健壮性和可维护性。通过实例代码,帮助开发者掌握异常处理技巧,确保程序稳定运行。
52 1
|
1月前
|
存储 监控 算法
Java内存管理的艺术:深入理解垃圾回收机制####
本文将引领读者探索Java虚拟机(JVM)中垃圾回收的奥秘,解析其背后的算法原理,通过实例揭示调优策略,旨在提升Java开发者对内存管理能力的认知,优化应用程序性能。 ####
47 0
|
安全 Java
Java安全管理器
总的来说,Java安全应该包括两方面的内容,一是Java平台(即是Java运行环境)的安全性;二是Java语言开发的应用程序的安全性。
1363 0