从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内存模型(JMM):深入理解并发编程的基石####
【10月更文挑战第29天】 本文作为一篇技术性文章,旨在深入探讨Java内存模型(JMM)的核心概念、工作原理及其在并发编程中的应用。我们将从JMM的基本定义出发,逐步剖析其如何通过happens-before原则、volatile关键字、synchronized关键字等机制,解决多线程环境下的数据可见性、原子性和有序性问题。不同于常规摘要的简述方式,本摘要将直接概述文章的核心内容,为读者提供一个清晰的学习路径。 ####
11 2
|
2天前
|
存储 Java 编译器
Java内存模型(JMM)深度解析####
本文深入探讨了Java内存模型(JMM)的工作原理,旨在帮助开发者理解多线程环境下并发编程的挑战与解决方案。通过剖析JVM如何管理线程间的数据可见性、原子性和有序性问题,本文将揭示synchronized关键字背后的机制,并介绍volatile关键字和final关键字在保证变量同步与不可变性方面的作用。同时,文章还将讨论现代Java并发工具类如java.util.concurrent包中的核心组件,以及它们如何简化高效并发程序的设计。无论你是初学者还是有经验的开发者,本文都将为你提供宝贵的见解,助你在Java并发编程领域更进一步。 ####
|
2天前
|
存储 安全 Java
什么是 Java 的内存模型?
Java内存模型(Java Memory Model, JMM)是Java虚拟机(JVM)规范的一部分,它定义了一套规则,用于指导Java程序中变量的访问和内存交互方式。
8 1
|
7天前
|
Java
Java 静态变量的初始化顺序
【10月更文挑战第15天】了解 Java 静态变量的初始化顺序对于正确编写和维护代码至关重要。通过深入理解初始化顺序的原理和细节,我们可以更好地避免潜在的问题,并提高代码的质量和可靠性。
|
8天前
|
存储 运维 Java
💻Java零基础:深入了解Java内存机制
【10月更文挑战第18天】本文收录于「滚雪球学Java」专栏,专业攻坚指数级提升,希望能够助你一臂之力,帮你早日登顶实现财富自由🚀;同时,欢迎大家关注&&收藏&&订阅!持续更新中,up!up!up!!
21 1
|
11天前
|
监控 安全 Java
Java Z 垃圾收集器如何彻底改变内存管理
大家好,我是V哥。今天聊聊Java的ZGC(Z Garbage Collector)。ZGC是一个低延迟垃圾收集器,专为大内存应用场景设计。其核心优势包括:极低的暂停时间(通常低于10毫秒)、支持TB级内存、使用着色指针实现高效对象管理、并发压缩和去碎片化、不分代的内存管理。适用于实时数据分析、高性能服务器和在线交易系统等场景,能显著提升应用的性能和稳定性。如何启用?只需在JVM启动参数中加入`-XX:+UseZGC`即可。
134 0
|
3月前
|
存储 编译器 C语言
【C语言篇】数据在内存中的存储(超详细)
浮点数就采⽤下⾯的规则表⽰,即指数E的真实值加上127(或1023),再将有效数字M去掉整数部分的1。
325 0
|
10天前
|
存储 C语言
数据在内存中的存储方式
本文介绍了计算机中整数和浮点数的存储方式,包括整数的原码、反码、补码,以及浮点数的IEEE754标准存储格式。同时,探讨了大小端字节序的概念及其判断方法,通过实例代码展示了这些概念的实际应用。
19 1
|
14天前
|
存储
共用体在内存中如何存储数据
共用体(Union)在内存中为所有成员分配同一段内存空间,大小等于最大成员所需的空间。这意味着所有成员共享同一块内存,但同一时间只能存储其中一个成员的数据,无法同时保存多个成员的值。
|
19天前
|
存储 弹性计算 算法
前端大模型应用笔记(四):如何在资源受限例如1核和1G内存的端侧或ECS上运行一个合适的向量存储库及如何优化
本文探讨了在资源受限的嵌入式设备(如1核处理器和1GB内存)上实现高效向量存储和检索的方法,旨在支持端侧大模型应用。文章分析了Annoy、HNSWLib、NMSLib、FLANN、VP-Trees和Lshbox等向量存储库的特点与适用场景,推荐Annoy作为多数情况下的首选方案,并提出了数据预处理、索引优化、查询优化等策略以提升性能。通过这些方法,即使在资源受限的环境中也能实现高效的向量检索。