Java内存模型的顺序一致性问题

简介: Java内存模型的顺序一致性问题


一、数据竞争与顺序一致性的保证


当程序未正确同步时,就可能会存在数据竞争。


Java 内存模型规范对数据竞争的定义如下:


在一个线程中写一个变量

在另一个线程读同一个变量

而且写和读没有通过同步来排序。

当代码中包含数据竞争时,程序的执行往往产生违反直觉的结果(上篇的示例正是如此);


如果一个多线程程序能正确同步,这个程序将是一个没有数据竞争的程序,程序的执行将具有顺序一致性。


JMM 对正确同步的多线程程序的内存一致性做了如下保证:


如果程序是正确同步的,程序的执行将具有顺序一致性(sequentially consistent)–即程序的执行结果与该程序在顺序一致性内存模型中的执行结 果相同;


下面你将会看到,这对于程序员来说是一个极强的保证。这里的同步是指广义上的同步,包括对常用同步原语(synchronized,volatile 和 final) 的正确使用。


二、 顺序一致性内存模型


顺序一致性内存模型是一个被计算机科学家理想化了的理论参考模型,它为程序员提供了极强的内存可见性保证


顺序一致性内存模型有两大特性:


  1. 一个线程中的所有操作必须按照程序的顺序来执行。
  2. (不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序。在顺序 一致性内存模型中,每个操作都必须原子执行且立刻对所有线程可见。


顺序一致性内存模型为我们提供的视图如下:


image.png


在概念上,顺序一致性模型有一个单一的全局内存,这个内存通过一个左右摆动的开关可以连接到任意一个线程,同时每一个线程必须按照程序的顺序来执行内存读 /写操作;


从上面的示意图我们可以看出,在任意时间点最多只能有一个线程可以连接到内存。当多个线程并发执行时,图中的开关装置能把所有线程的所有内存读 /写操作串行化(即在顺序一致性模型中,所有操作之间具有全序关系);


为了更好的理解,下面我们通过两个示意图来对顺序一致性模型的特性做进一步的说明。


假设有两个线程 A 和 B 并发执行。其中 A 线程有三个操作,它们在程序中的顺序 是:A1->A2->A3。B 线程也有三个操作,它们在程序中的顺序是:B1->B2- >B3。


假设这两个线程使用监视器锁来正确同步:A 线程的三个操作执行后释放监视器 锁,随后 B 线程获取同一个监视器锁。


那么程序在顺序一致性模型中的执行效果将 如下图所示:


image.png


现在我们再假设这两个线程没有做同步,下面是这个未同步程序在顺序一致性模型 中的执行示意图:


image.png


未同步程序在顺序一致性模型中虽然整体执行顺序是无序的,但所有线程都只能看到一个一致的整体执行顺序;


以上图为例,线程 A 和 B 看到的执行顺序都是:B1- >A1->A2->B2->A3->B3。之所以能得到这个保证是因为顺序一致性内存模型中的每个操作必须立即对任意线程可见。 但是,在 JMM 中就没有这个保证。未同步程序在 JMM 中不但整体的执行顺序是无序的,而且所有线程看到的操作执行顺序也可能不一致;


比如,在当前线程把写过的数据缓存在本地内存中,在还没有刷新到主内存之前,这个写操作仅对当前线程可见;从其他线程的角度来观察,会认为这个写操作根本还没有被当前线程执行。只有当前线程把本地内存中写过的数据刷新到主内存之后,这个写操作才能对其他线程可见。在这种情况下,当前线程和其它线程看到的操作执行顺序将不一 致。


image.png


三、同步程序的顺序一致性效果


下面我们对前面的示例程序 ReorderExample 用锁来同步,看看正确同步的程序如何具有顺序一致性。


class SynchronizedExample { 
    int a = 0; 
    boolean flag = false; 
    public synchronized void writer() { //获取锁  
        a = 1;  
        flag = true; 
    } //释放锁 
    public synchronized void reader() { //获取锁  
        if (flag) { 
            int i = a;  
            ……  
        } //释放锁 
    } 
}


上面示例代码中,假设 A 线程执行 writer()方法后,B 线程执行 reader()方法。这是一个正确同步的多线程程序;


根据 JMM 规范,该程序的执行结果将与该程序在顺序一致性模型中的执行结果相同;


下面是该程序在两个内存模型中的执行时序对比图:


image.png


在顺序一致性模型中,所有操作完全按程序的顺序串行执行;


在 JMM 中,临界 区内的代码可以重排序(但 JMM 不允许临界区内的代码“逸出”到临界区之外, 那样会破坏监视器的语义)。JMM 会在退出临界区和进入临界区这两个关键时间点做一些特别处理,使得线程在这两个时间点具有与顺序一致性模型相同的内存视图(具体细节后文会说明);


虽然线程 A 在临界区内做了重排序,但由于监视器的互斥执行的特性,这里的线程 B 根本无法“观察”到线程 A 在临界区内的重排序。 这种重排序既提高了执行效率,又没有改变程序的执行结果;


从这里我们可以看到JMM 在具体实现上的基本方针:在不改变(正确同步的)程序执行结果的前提下,尽可能的为编译器和处理器的优化打开方便之门。


四、未同步程序的执行特性


对于未同步或未正确同步的多线程程序,JMM 只提供最小安全性:


线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值(0,null,false), JMM 保证线程读操作读取到的值不会无中生有(out of thin air)的冒出来;


image.png


为了实现最小安全性,JVM 在堆上分配对象时,首先会清零内存空间,然后才会在上面分配对象(JVM 内部会同步这两个操作);


因此,在已清零的内存空间(prezeroed memory)分配对象时,域的默认初始化已经完成了;


JMM 不保证未同步程序的执行结果与该程序在顺序一致性模型中的执行结果一 致。因为如果想要保证执行结果一致,JMM 需要禁止大量的处理器和编译器的优化,这对程序的执行性能会产生很大的影响。而且未同步程序在顺序一致性模型中执行时,整体是无序的,其执行结果往往无法预知。保证未同步程序在这两个模型中的执行结果一致没什么意义;


未同步程序在 JMM 中的执行时,整体上是无序的,其执行结果无法预知;


未同步程序在两个模型中的执行特性有下面几个差异:


顺序一致性模型保证单线程内的操作会按程序的顺序执行,而 JMM 不保证单线程内的操作会按程序的顺序执行(比如上面正确同步的多线程程序在临界区内的重排序)。这一点前面已经讲过了,这里就不再赘述。


顺序一致性模型保证所有线程只能看到一致的操作执行顺序,而 JMM 不保证所有线程能看到一致的操作执行顺序。这一点前面也已经讲过,这里就不再赘述。


JMM 不保证对 64 位的 long 型和 double 型变量的读/写操作具有原子性,而顺序一致性模型保证对所有的内存读/写操作都具有原子性。


第 3 个差异与处理器总线的工作机制密切相关;


在计算机中,数据通过总线在处理器和内存之间传递。每次处理器和内存之间的数据传递都是通过一系列步骤来完成的,这一系列步骤称之为总线事务(bus transaction);


总线事务包括:


  • 读事务 (read transaction)
  • 写事务(write transaction)


读事务从内存传送数据到处理器,写事务从处理器传送数据到内存,每个事务会读/写内存中一个或多个物 理上连续的字;


这里的关键是,总线会同步试图并发使用总线的事务。在一个处理器执行总线事务期间,总线会禁止其它所有的处理器和 I/O 设备执行内存的读/ 写;


下面让我们通过一个示意图来说明总线的工作机制:


image.png


如上图所示,假设处理器 A,B 和 C 同时向总线发起总线事务,这时总线仲裁(bus arbitration)会对竞争作出裁决,这里我们假设总线在仲裁后判定处理器 A 在竞争中获胜(总线仲裁会确保所有处理器都能公平的访问内存);


此时处理器 A 继续它的总线事务,而其它两个处理器则要等待处理器 A 的总线事务完成后才能开始再次执 行内存访问。假设在处理器 A 执行总线事务期间(不管这个总线事务是读事务还是写事务),处理器 D 向总线发起了总线事务,此时处理器 D 的这个请求会被总线禁止;


总线的这些工作机制可以把所有处理器对内存的访问以串行化的方式来执行;


在任意时间点,最多只能有一个处理器能访问内存。这个特性确保了单个总线事务之中的内存读/写操作具有原子性;


在一些 32 位的处理器上,如果要求对 64 位数据的写操作具有原子性,会有比较大的开销。为了照顾这种处理器,java 语言规范鼓励但不强求 JVM 对 64 位的 long 型变量和 double 型变量的写具有原子性;


当 JVM 在这种处理器上运行时,会把 一个 64 位 long/ double 型变量的写操作拆分为两个 32 位的写操作来执行。这两 个 32 位的写操作可能会被分配到不同的总线事务中执行,此时对这个 64 位变量的写将不具有原子性。 当单个内存操作不具有原子性,将可能会产生意想不到后果;


请看下面示意图:


image.png


如上图所示,假设处理器 A 写一个 long 型变量,同时处理器 B 要读这个 long 型 变量。处理器 A 中 64 位的写操作被拆分为两个 32 位的写操作,且这两个 32 位的写操作被分配到不同的写事务中执行。同时处理器 B 中 64 位的读操作被分配到单 个的读事务中执行。当处理器 A 和 B 按上图的时序来执行时,处理器 B 将看到仅仅被处理器 A写了一半的无效值。


注意,在 JSR -133 之前的旧内存模型中,一个 64 位 long/ double 型变量的读/ 写操作可以被拆分为两个 32 位的读/写操作来执行;


从 JSR -133 内存模型开始 (即从 JDK5 开始),仅仅只允许把一个 64 位 long/ double 型变量的写操作拆分为两个 32 位的写操作来执行,任意的读操作在 JSR -133 中都必须具有原子性(即任意读操作必须要在单个读事务中执行);


结束语


  • 由于博主才疏学浅,难免会有纰漏,假如你发现了错误或偏见的地方,还望留言给我指出来,我会对其加以修正。
  • 如果你觉得文章还不错,你的转发、分享、点赞、留言就是对我最大的鼓励。
  • 感谢您的阅读,十分欢迎并感谢您的关注。


目录
相关文章
|
14天前
|
存储 Java 编译器
Java内存模型(JMM)深度解析####
本文深入探讨了Java内存模型(JMM)的工作原理,旨在帮助开发者理解多线程环境下并发编程的挑战与解决方案。通过剖析JVM如何管理线程间的数据可见性、原子性和有序性问题,本文将揭示synchronized关键字背后的机制,并介绍volatile关键字和final关键字在保证变量同步与不可变性方面的作用。同时,文章还将讨论现代Java并发工具类如java.util.concurrent包中的核心组件,以及它们如何简化高效并发程序的设计。无论你是初学者还是有经验的开发者,本文都将为你提供宝贵的见解,助你在Java并发编程领域更进一步。 ####
|
24天前
|
缓存 easyexcel Java
Java EasyExcel 导出报内存溢出如何解决
大家好,我是V哥。使用EasyExcel进行大数据量导出时容易导致内存溢出,特别是在导出百万级别的数据时。以下是V哥整理的解决该问题的一些常见方法,包括分批写入、设置合适的JVM内存、减少数据对象的复杂性、关闭自动列宽设置、使用Stream导出以及选择合适的数据导出工具。此外,还介绍了使用Apache POI的SXSSFWorkbook实现百万级别数据量的导出案例,帮助大家更好地应对大数据导出的挑战。欢迎一起讨论!
140 1
|
8天前
|
缓存 算法 Java
本文聚焦于Java内存管理与调优,介绍Java内存模型、内存泄漏检测与预防、高效字符串拼接、数据结构优化及垃圾回收机制
在现代软件开发中,性能优化至关重要。本文聚焦于Java内存管理与调优,介绍Java内存模型、内存泄漏检测与预防、高效字符串拼接、数据结构优化及垃圾回收机制。通过调整垃圾回收器参数、优化堆大小与布局、使用对象池和缓存技术,开发者可显著提升应用性能和稳定性。
30 6
|
13天前
|
存储 缓存 安全
Java内存模型(JMM):深入理解并发编程的基石####
【10月更文挑战第29天】 本文作为一篇技术性文章,旨在深入探讨Java内存模型(JMM)的核心概念、工作原理及其在并发编程中的应用。我们将从JMM的基本定义出发,逐步剖析其如何通过happens-before原则、volatile关键字、synchronized关键字等机制,解决多线程环境下的数据可见性、原子性和有序性问题。不同于常规摘要的简述方式,本摘要将直接概述文章的核心内容,为读者提供一个清晰的学习路径。 ####
34 2
|
14天前
|
存储 安全 Java
什么是 Java 的内存模型?
Java内存模型(Java Memory Model, JMM)是Java虚拟机(JVM)规范的一部分,它定义了一套规则,用于指导Java程序中变量的访问和内存交互方式。
36 1
|
19天前
|
存储 运维 Java
💻Java零基础:深入了解Java内存机制
【10月更文挑战第18天】本文收录于「滚雪球学Java」专栏,专业攻坚指数级提升,希望能够助你一臂之力,帮你早日登顶实现财富自由🚀;同时,欢迎大家关注&&收藏&&订阅!持续更新中,up!up!up!!
27 1
|
23天前
|
存储 算法 Java
Java虚拟机(JVM)的内存管理与性能优化
本文深入探讨了Java虚拟机(JVM)的内存管理机制,包括堆、栈、方法区等关键区域的功能与作用。通过分析垃圾回收算法和调优策略,旨在帮助开发者理解如何有效提升Java应用的性能。文章采用通俗易懂的语言,结合具体实例,使读者能够轻松掌握复杂的内存管理概念,并应用于实际开发中。
|
23天前
|
监控 安全 Java
Java Z 垃圾收集器如何彻底改变内存管理
大家好,我是V哥。今天聊聊Java的ZGC(Z Garbage Collector)。ZGC是一个低延迟垃圾收集器,专为大内存应用场景设计。其核心优势包括:极低的暂停时间(通常低于10毫秒)、支持TB级内存、使用着色指针实现高效对象管理、并发压缩和去碎片化、不分代的内存管理。适用于实时数据分析、高性能服务器和在线交易系统等场景,能显著提升应用的性能和稳定性。如何启用?只需在JVM启动参数中加入`-XX:+UseZGC`即可。
144 0
|
8天前
|
安全 Java 测试技术
Java并行流陷阱:为什么指定线程池可能是个坏主意
本文探讨了Java并行流的使用陷阱,尤其是指定线程池的问题。文章分析了并行流的设计思想,指出了指定线程池的弊端,并提供了使用CompletableFuture等替代方案。同时,介绍了Parallel Collector库在处理阻塞任务时的优势和特点。
|
5天前
|
安全 Java 开发者
深入解读JAVA多线程:wait()、notify()、notifyAll()的奥秘
在Java多线程编程中,`wait()`、`notify()`和`notifyAll()`方法是实现线程间通信和同步的关键机制。这些方法定义在`java.lang.Object`类中,每个Java对象都可以作为线程间通信的媒介。本文将详细解析这三个方法的使用方法和最佳实践,帮助开发者更高效地进行多线程编程。 示例代码展示了如何在同步方法中使用这些方法,确保线程安全和高效的通信。
25 9