当前位置: 首页 > 后端技术 > Java

通过实例程序验证与优化谈谈网上很多对于Java DCL的一些误解以及为何要理解Java内存模型

时间:2023-04-02 00:01:34 Java

通过实例程序的验证和优化,说说网上对JavaDCL的一些误解,以及为什么需要理解Java内存模型标出,如有疏漏,欢迎大家批评指正。如果您在网上发现有人抄袭本文,请举报并积极向本github仓库提交issue。感谢大家的支持~本文基于OpenJDK11及以上版本。这个系列的文章最近火了。实验从硬件底层出发,全面分析了Java内存模型的设计,并为每一个结论提供了相关的参考论文和验证程序。我发现这些年对于Java内存模型存在很多误解,我发现很多人都有这样的误解,所以这次我们就通过不断优化一个经典的DCL(DoubleCheckLocking)程序来帮助大家消除这个误解实例。首先有这样一个程序,我们要实现一个单例值,只在第一次调用时初始化,多个线程都会访问这个单例值,那么我们就会有:getValue的实现是经典的DCL编写。在Java内存模型的限制下,这个ValueHolder有两个潜在的问题:如果按照Java内存模型的定义,不管实际的JVM实现如何,那么getValue可能返回null。可以读取到未初始化的Value字段值。接下来,我们将对这两个问题进行进一步的分析和优化。根据Java内存模型的定义,不管实际的JVM实现如何,getValue可能返回null的原因在7.1.全网最硬核的Java新内存模型分析和实验文章的Coherence(连贯性、连贯性)和Opaque。提到:假设一个对象字段intx初始为0,一个线程执行:另一个线程执行(r1,r2为局部变量):那么这实际上是对该字段的两次读取(对应字节码getfield),在Java内存下模型,可能的结果包括:r1=1,r2=1r1=0,r2=1r1=1,r2=0r1=0,r2=0第三个结果很有意思,从程序上理解是我们先看到x=1,然后看到x变成了0。其实这是因为编译器出了问题。如果我们不想看到这第三个结果,我们需要的特征就是连贯性。此处,由于私有Value值是普通字段,因此根据Java内存模型无法保证一致性。回到我们的程序,我们有3次读取该字段(对应字节码getfield),分别位于:由于1和2之间存在明显的分支关系(根据1的结果执行或不执行2),所以无论从任何编译器的角度来看,1都必须先执行,再执行2。但是对于1和3,它们之间没有这种依赖关系,在一些简单的编译器看来,可以执行出来命令。Java内存模型下,没有限制1和3之间的顺序是否不能乱。所以,可能你的程序是先读3,再读1等逻辑,最后方法返回读3的结果。但是在OpenJDKHotspot关联的编译器上下文中,就避免了这种情况。OpenJDKHotspot编译器是一个更严谨的编译器。它产生的1和3的两次读取(同一字段的两次读取)也是两次相互依赖的读取,在编译器维度中是不会存在的。乱序(注意这里是编译器维度,并不是说这里会有内存屏障来避免可能的CPU乱序,而是这里是针对同一个字段读取的,之前说过,只是乱序with编译器与CPU乱序无关)不过,这只是针对一般的程序编写。我们可以通过一些奇怪的写法来欺骗编译器,让他读两次任务也无所谓。内存模型分析与实验7.1.连贯性(coherence,coherence)和Opaque中的实验链接,OpenJDKHotspot对下面的程序没有编译紊乱:但是如果改成下面的写法,就会被骗过编译器:我们不用太去深入原理,只看其中一个结果:对于DCL的写法,我们也可以骗过编译器,但一般不会这样写,这里就不细说了。可以读取到未初始化的Value字段值。这不仅是编译器乱序,还涉及CPU指令乱序和CPU缓存乱序。需要内存屏障来解决可见性问题。我们从Value类的构造函数开始:forvalue=newValue(10);在这一步,将代码分解为更详细易懂的伪代码:中间没有内存屏障,根据语义分析,1和5之间存在依赖关系,因为5依赖在1的结果上,所以必须先执行1,再执行5。2和3之间也存在依赖关系,因为3依赖于2的结果。但是2和3、4和5之间没有依赖关系,它们可能会出现故障。我们用代码来测试这种乱码:虽然注释里写了这样写代码的原因,但是我还是想强调一下这样写的原因:jcstress的@Actor使用线程来执行代码在这种方法中。测试中,每次使用不同的JVM启动参数,使这段代码解释执行,C1编译执行,C2编译执行。同时修改了JIT编译的编译参数,使编译后的代码有不同的效果。这样我们就可以看到在不同的执行方式下是否会有不同的编译器重排序效果。jcstress的@Actor使用线程来执行该方法中的代码。每次使用不同的JVM测试启动时,这个@Actor都会绑定到一个CPU上执行,从而保证在测试过程中,这个方法只会在这个CPU上执行,CPU缓存被这个方法的代码,这样更容易测试出CPU缓存不一致导致的乱序。因此,我们@Actor注解方法的数量需要小于CPU的数量。我们的测试机只有两个CPU,所以只能有两个线程。如果两者都执行原来的代码,那么很可能一直执行到synchronized同步块等待。Synchronized本身有内存屏障的作用(后面会提到)。为了更容易测试不使用synchronized同步块的情况,我们的第二个@Actor注解方法直接去掉了同步块逻辑,如果值为null,我们将结果设置为-1,以区分x86而这个程序是在armCPU上测试的,结果是:x86-AMD64:arm-aarch64:我们可以看到在x86这样强一致性的CPU中,没有未初始化的字段值,但是在arm这个weakly一致的CPU,我们看到未初始化的值。在我的另一个系列——全网最硬核的Java新内存模型分析与实验中,也多次提到这个CPU乱序表:这里,我们需要的内存屏障是StoreStore(同时,我们也从上面的表中可以看出,x86天生就不需要StoreStore,只要没有编译器乱序,CPU级别就不会乱,而arm需要一块内存barrier保证Store和Store不会乱序),只要这个内存屏障保证我们在代码中,step2和step应该在step5之前,step4应该在step5之前。那么我们能做什么呢?参考我这篇全网最硬核的Java新内存模型的文章以及实验中各种内存屏障的对应关系,可以做如下操作,下面我们来对比一下各个方法中内存屏障的消耗:1.使用finalfinal是在赋值语句末尾添加StoreStore内存屏障,所以我们只需要在步骤2、3、4的末尾添加StoreStore内存屏障,即将a2和b设置为final,如如下图所示:对应的伪代码:我们来测试一下:这次在arm上的结果:如你所见,这次在armCPU上也没有看到未初始化的值。这里a1不需要设置为final,因为我们前面说过2和3之间存在依赖关系,它们可以看成是一个整体,只需要在整体后面加一个内存屏障即可。但这并不可靠!!!!因为在某些JDK中,这段代码可能会这样优化:让a1和a2之间没有依赖关系!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!所以最好让所有的变量都成为final,但是当我们不能让字段成为final时这就不起作用了。2.使用volatile,这是一种常用的,官方推荐的方法,将value设置为volatile。在我的另一篇全网最硬核的Java新内存模型分析实验系列文章中,我们知道对于volatile写,我们是通过在写入前添加LoadStore+StoreStore内存屏障来实现的,并添加写入后的StoreLoad内存屏障。如果将该值设置为volatile,那么之前的伪代码就变成了:我们通过下面的代码来测试一下:仍然在arm机上测试,结果是:没有看到未初始化的值。3.对于Java9+,可以使用Varhandle的acquire/releasebeforeanalysis。其实我们只需要保证在伪代码的第五步之前有一个StoreStore内存屏障即可。是的,这么volatile其实有点重,我们可以利用Varhandle的acquire/releaselevelofvisibilityapi,这样伪代码就变成了:我们的测试代码变成了:测试结果是:也没有看到值没有初始化。此方法使用最少的内存屏障,并且不限制目标类型中final字段的使用。4.一个有趣但无用的想法——如果是静态方法,可以通过类加载器机制轻松编写。如果我们,ValueHolder中的方法和字段可以是静态的,比如:将ValueHolder作为一个单独的类,或者内部类,这样也可以保证Value中字段的可见性,这是??通过类加载器机制实现的。加载同一个类时(在类加载过程代码中会初始化static字段并运行static块),受synchronized关键字同步块保护,参考类加载器源码(ClassLoader.java):ClassLoader.javasyncrhonized底层对应的monitorenter和monitorexit,monitorenter和volatileread有相同的内存屏障,即在AddLoadLoad和LoadStore操作后,monitorexit和volatile有相同的内存屏障,添加LoadStore+操作前的StoreStore内存屏障,操作后添加StoreLoad内存屏障。因此,能见度也能得到保证。不过这样写起来虽然好写,但是效率更低(低很多,类加载需要的东西更多),不够灵活,作为扩展知识就知道了。综上所述,DCL是一种通用的编程模式。受锁保护的字段值有两种字段可见性问题:如果根据Java内存模型的定义,不管实际的JVM实现如何,那么getValue可能返回null。但是目前的JVM设计一般都避免了这一点,我们在实际编程中可以忽略。可以读取到未初始化的Value字段值。这可以通过在构造函数的完成和对变量的赋值之间添加一个StoreStore内存屏障来解决。可以通过将Value字段设置为final来解决,但不够灵活。最简单的方式就是将value字段设置为volatile,这也是JDK中使用的方式,官方推荐。最有效的方法是使用VarHandle的释放模式。这种模式只引入了StoreStore和LoadStore内存屏障,比volatile内存屏障要小很多(没有StoreLoad,x86就没有内存屏障,因为x86天生就有LoadLoad,LoadStore,StoreStore,x86只是不能保证StoreLoad自然)微信搜索》我的编程喵》关注公众号,加作者微信,每天刷一刷,轻松提升技术,赢各种优惠:我会经常发一些好的各种框架官方社区的新闻视频资料,并添加个人翻译字幕到以下地址(含上公众号),欢迎关注:知乎:https://www.zhihu.com/people/..B站:https://space.bilibili.com/31。..