Java内存模型小析之重排序(三)

简介: 我们在上一篇文章中说了JAVA内存模型中原子性可见性的相关概念(点这里查看),我们在这一篇文章里说一下java内存模型中的重排序的内容。重排序重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段。

我们在上一篇文章中说了JAVA内存模型中原子性可见性的相关概念(点这里查看),我们在这一篇文章里说一下java内存模型中的重排序的内容。

重排序

重排序是指编译器处理器为了优化程序性能而对指令序列进行重新排序的一种手段。也就是说重排序的目的是 提高程序的执行性能

重排序的分类

编译器优化的重排序

编译器在不改变单线程程序执行结果的前提下,可以重新安排语句的执行顺序。这里需要注意的是: 不改变单线程程序的语义(as-if-serial)。  

指令级并行的重排序

现代处理器采用了指令级并行技术(ILP)来将多条指令重叠执行。在单线程和单处理器中,如果两个操作之间不存在数据依赖,处理器可以改变语句对应机器指令的执行顺序。

内存系统的重排序

由于处理器使用缓存和读/写缓冲区,处理器会重排对内存的读/写操作的执行顺序。
所以从java源代码到最终实际执行的指令序列,会分别经历下面三种重排序 :
    源代码---->编译器优化重排序---->指令级并行重排序---->内存系统重排序---->最终执行的指令序列

数据依赖

我们在指令级并行的重排序中说如果两个操作之间没有数据依赖,处理器会进行指令的重排序。那么什么是数据依赖呢? 如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。这里的数据依赖仅针对单个处理器中执行的指令序列和单个线程中执行的操作。数据依赖分为下列三种类型:
类型 代码示例 说明
写后读 a=1;
b=a;
写一个变量之后,再读这个变量
写后写 a=1; 
a=2;
写一个变量之后,再写这个变量
写后读 a=b;
b=1;
度一个变量之后,再写这个变量

指令依赖

在按序执行中,一旦遇到指令依赖的情况,流水线就会停滞(因为CPU从主存加载读取数据是一个很慢(相对于CPU的处理速度来说)很复杂的IO操作,但是CPU层面实现了异步IO,通过异步IO的方式读取内存数据。),为了让CPU一直处于工作状态,把时间浪费减到最小,CPU就会进行重排序,跳到下一个非依赖指令。如a=b;c=1;d=1; 如果b不在缓存行里,需要从主存加载,这就是一个指令依赖。CPU可以对此进行重排序,先读c=1;或者d=1;。

重排序导致的问题

重排序会引起多线程的可见性问题。

例子及详细说明

下面我们详细的说明一下重排序会引起的多线程的可见性问题。
大家先看这样的一段代码:
    class Visibility extends Thread {
        private boolean flag = false;

        @Override
        public void run() {
            int i = 0;
            while (!flag) {
                i++;
            }
            System.out.println("finish loop i:" + i);
        }

        public void stopFlag() {
            flag = true;
        }

        public boolean getFlag() {
            return flag;
        }
    }
    @Test
    public void testSame() {
        Visibility visibility = new Visibility();
        visibility.start();
        try {
            //让线程执行一段时间
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        //停止线程
        visibility.stopFlag();
        System.out.println(visibility.getFlag());
        try {
            //陷入死循环
            visibility.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
如果以server模式运行上面的代码的话,程序的执行结果可能和大家以为的不太一样。有人可能会认为程序在调用stopFlag()方法之后,就会停止循环,然后输出finish loop i:。但是程序的执行结果确实可能会陷入到死循环中。这里会出现死循环的 原因是:程序在进入Visibility的run方法之前,先读取到了flag的值,然后在while循环中不再读取flag的值了,即使后续对flag的值进行了修改,run方法中也不会再读取flag的值。相当于   run{ int i ; while(!false){  循环 } }。编译器层面进行了重排序。这里想让程序停下来也很简单,只需要用volatile修改变量即可。
这里需要说明一下的是:我们在安装64位JDK时,一般都是server模式运行程序的(默认)。32位不支持server模式。server模式与client模式的区别是:server模式启动时,速度较慢,但是一旦运行起来后,性能将会有很大的提升.原因是。当虚拟机运行在-client模式的时候,使用的是一个代号为C1的轻量级编译器, 而-server模式启动的虚拟机采用相对重量级,代号为C2的编译器,这个编译器对代码做了很多的优化。可以通过java -version这个命令来查看对应的模式。上面是client模式,下面是server模式。


我们再看一个CPU重排序的例子
public class CPUDisordeTest {

    int a = 0, b = 0, x = 0, y = 0;

    public static void test(int i){

        CPUDisordeTest test = new CPUDisordeTest();
        Thread threadA = new Thread(()->{
            test.a = 1;
            test.x = test.b;
        });
        Thread threadB = new Thread(()->{
            test.b = 1;
            test.y = test.a;
        });
        threadA.start();
        threadB.start();
        //确保线程执行完
        try {
            threadA.join();
            threadB.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(String.format("case%s x:%s y:%s",i,test.x,test.y));
    }

    public static void main(String[] args) {
        try {
            PrintStream pw = new PrintStream(new FileOutputStream("G:\\LearnVideo\\testout.txt"));
            System.setOut(pw);
        } catch (FileNotFoundException e) {
            e.printStackTrace();
        }
        //循环
        IntStream.range(0,1000000).forEach(CPUDisordeTest::test);
    }
}
上面的程序可能会出现如下所示的结果(说明:x:1 y:1这个结果我没有跑出来,但是有可能会存在这一的结果。以下程序的结果,都是本人亲自执行程序测试出来的结果):
对于x:0 y:0这个结果大家可能会非常奇怪。程序执行时CPU和内存的交互如下图所示:


当处理器A和处理器B把各自的结果写入缓冲区(A1 B1),然后从主存中读取另外的共享变量的值,注意这时处理器A和处理器B写入的值还在写入缓冲区,还没刷新到主存中去,所以这时从主存中读取到的共享变量a和b的值还是0,即text.x和test.y的值是0。最后处理器A和处理器B把自己写入缓冲区中的数据刷新到主存中去。注意这里发生了内存系统的重排序。按照程序发生的顺序应该是A3把A1写缓存区中的值刷新到主存中a的变量写入才算数成功了。即应该是A1-A3-A2。但是实际发生的顺序可能是A1-A2-A3或者是A2-A1-A3(因为这里没有数据依赖关系,可能会发生重排序)。
下面我们再看最后一个例子关于指令级重排序的:
一条指令的执行是可以分为很多步骤的,下面列了一下主要的步骤:
  1. 取值 IF
  2. 译码和取寄存器操作数 ID
  3. 执行或者有效地址计算 EX
  4. 存储器访问 MEM
  5. 写回 WB  
即,指令的执行顺序为:IF ID EX MEM WB。下面我们看一下 A=B+C的操作的指令序列:

上面我们说过一条指令的顺序为 IF ID EX MEM WB那当我们有两条指令的时候是不是先等第一条指令顺序执行完的时候,才开始执行第二条?很明显不是的。指令的执行就如同流水线,当我们第一个指令执行完IF的时候,我们就可以跟着执行第二条指令的IF了如上图所示。但是当执行到ADD这一行的时候我们发现ID和EX中间多了一个X,这个X代表的时候一个停顿。为什么这里会有一个停顿呢?因为这时要进行一个计算的操作,而第二条指令还没有从内存中读出来值。为什么第二条指令还没有写回到寄存器中,就可以进行EX呢?在硬件电路中处理数据冲突的时候会使用一种旁路的技术,直接把数据从硬件中读取出来,所以不用等第二条指令完全执行完,就可以进行计算了。我们再看一个复杂的例子:


在上图中我们看到很多X存在,那么有没有办法尽可能的减少这些X呢?答案是肯定的,指令重排。我们看一下重排之后的结果:
从上图中我们发现没有了X的存在,程序的性能得到了一定的提升,并且在单线程和单处理器中程序的执行结果不会发生变化。
既然重排序会导致的不可见的问题,那么能不能禁止特定类型的重排序呢?答案是:能。通过什么方式呢?就是我们下面要说的栅栏或者内存屏障。

内存屏障

简单理解:内存屏障(Memory Barrier / Memory Fence)是一种CPU指令,用于控制特定条件下的重排序和内存可见性问题
java编译器在生成指令序列时插入特定类型的内存屏障(Memory Barriers/Memory Fence),可以禁止特定类型的重排序。
内存屏障可以分为下面四类:

StoreLoad Barriers是一个“全能型”的屏障,它同时具有其他3个屏障的效果,现代的多处理器大多支持该屏障 执行该屏障开销会很昂贵。因为当前处理器通常要把写缓冲区中的数据全部刷新到内存中。在volatile的时候我们在详细说这个。

as-if-serial语义

as-if-serial语义的意思是:不管怎么重排序,在单线程中程序的执行结果不能被改变。  
   编译器、runtime和处理器都必须遵守as-if-serial语义。

控制依赖性  

什么是控制依赖性? 像:if(条件){ 执行代码。。。 }这样的代码之间存在控制依赖性。  

控制依赖性会导致什么问题?  

    当代码中存在控制依赖性时,会影响指令序列执行的并行度。为此,编译器和处理器会采用猜测(Speculation)执行来克服 控制依赖对并行度的影响。   
    在单线程程序中,对存在控制依赖的操作重排序不会改变执行结果;但在多线程程序中,对存在控制依赖的操作重排序,可能会改变程序的执行结果。  

参考:
java并发编程的艺术。
葛一鸣的相关资料。

相关文章
|
17天前
|
安全 Java 应用服务中间件
Spring Boot + Java 21:内存减少 60%,启动速度提高 30% — 零代码
通过调整三个JVM和Spring Boot配置开关,无需重写代码即可显著优化Java应用性能:内存减少60%,启动速度提升30%。适用于所有在JVM上运行API的生产团队,低成本实现高效能。
114 3
|
1月前
|
缓存 监控 Kubernetes
Java虚拟机内存溢出(Java Heap Space)问题处理方案
综上所述, 解决Java Heap Space溢出需从多角度综合施策; 包括但不限于配置调整、代码审查与优化以及系统设计层面改进; 同样也不能忽视运行期监控与预警设置之重要性; 及早发现潜在风险点并采取相应补救手段至关重要.
241 17
|
2月前
|
存储 监控 算法
Java垃圾回收机制(GC)与内存模型
本文主要讲述JVM的内存模型和基本调优机制。
|
2月前
|
存储 缓存 Java
Java数组全解析:一维、多维与内存模型
本文深入解析Java数组的内存布局与操作技巧,涵盖一维及多维数组的声明、初始化、内存模型,以及数组常见陷阱和性能优化。通过图文结合的方式帮助开发者彻底理解数组本质,并提供Arrays工具类的实用方法与面试高频问题解析,助你掌握数组核心知识,避免常见错误。
|
2月前
|
边缘计算 算法 Java
Java 绿色计算与性能优化:从内存管理到能耗降低的全方位优化策略与实践技巧
本文探讨了Java绿色计算与性能优化的技术方案和应用实例。文章从JVM调优(包括垃圾回收器选择、内存管理和并发优化)、代码优化(数据结构选择、对象创建和I/O操作优化)等方面提出优化策略,并结合电商平台、社交平台和智能工厂的实际案例,展示了通过Java新特性提升性能、降低能耗的显著效果。最终指出,综合运用这些优化方法不仅能提高系统性能,还能实现绿色计算目标,为企业节省成本并符合环保要求。
94 0
|
2月前
|
监控 Kubernetes Java
最新技术栈驱动的 Java 绿色计算与性能优化实操指南涵盖内存优化与能效提升实战技巧
本文介绍了基于Java 24+技术栈的绿色计算与性能优化实操指南。主要内容包括:1)JVM调优,如分代ZGC配置和结构化并发优化;2)代码级优化,包括向量API加速数据处理和零拷贝I/O;3)容器化环境优化,如K8s资源匹配和节能模式配置;4)监控分析工具使用。通过实践表明,这些优化能显著提升性能(响应时间降低40-60%)同时降低资源消耗(内存减少30-50%,CPU降低20-40%)和能耗(服务器功耗减少15-35%)。建议采用渐进式优化策略。
139 2
|
3月前
|
SQL 缓存 安全
深度理解 Java 内存模型:从并发基石到实践应用
本文深入解析 Java 内存模型(JMM),涵盖其在并发编程中的核心作用与实践应用。内容包括 JMM 解决的可见性、原子性和有序性问题,线程与内存的交互机制,volatile、synchronized 和 happens-before 等关键机制的使用,以及在单例模式、线程通信等场景中的实战案例。同时,还介绍了常见并发 Bug 的排查与解决方案,帮助开发者写出高效、线程安全的 Java 程序。
177 0
|
3月前
|
存储 Java
Java对象的内存布局
在HotSpot虚拟机中,Java对象的内存布局分为三部分:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。对象头包含Mark Word、Class对象指针及数组长度;实例数据存储对象的实际字段内容;对齐填充用于确保对象大小为8字节的整数倍。
|
4月前
|
存储 Java
说一说 JAVA 内存模型与线程
我是小假 期待与你的下一次相遇 ~
112 5
|
4天前
|
JSON 网络协议 安全
【Java】(10)进程与线程的关系、Tread类;讲解基本线程安全、网络编程内容;JSON序列化与反序列化
几乎所有的操作系统都支持进程的概念,进程是处于运行过程中的程序,并且具有一定的独立功能,进程是系统进行资源分配和调度的一个独立单位一般而言,进程包含如下三个特征。独立性动态性并发性。
37 1

热门文章

最新文章