前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >笔记系列:Java内存模型JMM

笔记系列:Java内存模型JMM

作者头像
文彬
发布2022-06-06 09:27:56
3520
发布2022-06-06 09:27:56
举报
文章被收录于专栏:醒者呆醒者呆

Java的内存模型,即JMM。依托于操作系统的存储结构,JVM作为虚拟机相当于操作系统之上的一个软件。因此,本文先介绍了操作系统层面的存储结构、硬件级的数据一致性问题以及相对应的一些策略等的知识,这里面也涉及到指令乱序执行的问题,给出了验证的代码。然后结合JVM对于软件层面的一些内存屏障的实现,例如volatile、synchronized关键字。最后是比较重要的java的对象和内存的关系,包括了类的创建过程以及在内存中的排布状态细节,这里包括了通过java代理的使用来观察对象的大小等实际代码的操作。 说明:仍旧是笔记系列。 关键字:JMM,volatile,synchronized,agent,指令重排,乱序证明,对象大小,总线锁,缓存锁,缓存行,伪共享,内存屏障。

1、操作系统的存储结构

呈现一个金字塔型,整体呢又可以把金字塔一分为5层。从上至下依次为:

1、CPU

2、主板

3、内存

4、磁盘

5、网络

可以看到CPU是最快的,但是最窄,也就是说它虽然快,但存储量不大。CPU又可以分为三层,从上至下分别为:

1、寄存器register

2、L1高速缓存

3、L2高速缓存

从1到3,也是尊崇金字塔规律,越来越慢,但是存储量越来越大。这是一颗CPU内部的存储结构。继续看上面的5层结构,CPU下面就是主板。主板上面插着内存条、磁盘、网卡等。按道理来讲他们是平级的,但是由于内部原理不同,他们的存储速度和存储量有很大的差别。CPU下面是主板,多颗CPU共享一块L3高级缓存,注意这个L3高级缓存不是内存,而是主板自带的,它比内存要快,但是没有内存储量大。接下来继续往下,就是内存,我们比较熟悉,然后是磁盘,也叫硬盘,那么其实磁盘之上内存之下还应该有一个SSD固态硬盘的存在,这个我们也比较了解。最底部一层是网络,基于网络存储肯定是最慢的,然而它的储量上不封顶,远不是一台主机能比拟的。

2、硬件层数据一致性问题

2.1 CPU缓存不一致

前面提到了,从主板开始,多颗CPU共享存储空间,也就是说除了寄存器、L1、L2是单颗CPU的内部存储结构以外,金字塔再往下的存储工具,都是被多颗CPU所共享的。所以这就有一个问题,就是在多颗CPU在读取共享存储空间的时候,要保证数据的一致性。那么这个保证该具体如何实现呢?有总线锁和缓存锁。

2.2 总线锁

那么一种实现的情况,是相当于一个CPU在访问共享空间的时候,为数据上锁,其他CPU在访问时要等待。但这样的话,会造成一个CPU在读取共享空间会给整个总线上锁,其他CPU哪怕要访问其他的变量,也必须要等待。所以它很慢,这种实现情况已经过时。

2.3 缓存锁:MESI Cache一致性协议

硬件层的一致性协议很多,intel使用的是MESI。具体实现原理就是,给每一个缓存做了标记,相对于主存,它的四种标记状态有:Modified、Exclusive、Shared、Invalid,这四种状态的首字母合起来就是MESI。具体的协议:

1、Modified,修改过。

2、Exclusive,只有我在用,其他人不关心

3、Shared,大家都在用

4、Invalid,我用的时候,别人改过了。所以这时候,我要再主动去主存中读取一遍。

这四种状态以及特定处理,保证了各个CPU之间的缓存保持一致性。

但有些无法被缓存的数据,或者跨越多个缓存行的数据,依然必须使用总线锁。因此现代CPU的数据一致性实现是通过缓存锁(MESI ...)加上总线锁。

2.4 缓存行

CPU读取缓存以cache line为基本单位,目前多数实现是64字节。

2.5 伪共享

假如两个数据x和y位于同一个缓存行,被CPU所读取,会产生一个问题。当一颗CPU中的x被修改,会通知另一个CPU重新读取(相当于MESI的Invalid状态,那么就需要重新读取一遍)。然后另一个CPU其实没有用到x,它只是用了y,但是由于x和y在同一个缓存行,所以它被迫要重新读。相同的情况可能会蔓延到各个CPU,这会带来无效的开销,是由缓存行带来的。这种问题叫做伪共享

伪共享指位于同一缓存行的两个不同数据,被两个不同的CPU锁定,产生互相影响的伪共享问题。

2.6 缓存行对齐

就是针对伪共享的情况,还是以上面的x和y为例,就是强制把x和y放到两个缓存行中,这样一来,x和y就不再互相影响,会提高效率。这就是缓存行对齐。Disruptor高性能开源框架就采用了缓存行对齐的能力。那么如何把x和y放到两个缓存行中呢?我们知道一个缓存行是64字节,我们可以在对象申请内存空间的时候,先定义7个long类型变量先占据56个字节,然后只剩8个字节放x,那么这个缓存行肯定是满了,这时候y再申请的时候一定会放到另一个缓存行中。

缓存行对齐其实就是空间换时间,通过浪费掉56个字节的空间,完成对于伪共享问题的避免,实现缓存行对齐,提高性能。

2.7 乱序执行

CPU执行速度非常快,大约是主存IO速度的100倍。假设CPU从内存中读入5条指令,其中第一条指令是从内存中读取一个值。如果CPU在执行第一条指令的时候,什么也不做,它需要等待100倍的空闲时间,这无疑是浪费的。因此,现代CPU都会在执行内存读取时,不去等待而是去分析下一条指令内容,不让CPU空闲着,尽可能提高效率。如果下一条指令与第一条没有直接依赖关系,则可以在第一条指令还在内存取值的时候,CPU直接去执行下一条指令。这时候看上去,第一条指令和第二条指令是并行着的。所以这时候,CPU就是一个乱序执行。

高性能编程得合理利用操作系统底层硬件设计,所以看上去没什么道理。这里使用WCBuffer去合并写,如果你能用代码把这个特性利用上了,那么你的程序肯定比没利用上底层特性的要快!

上面是分析乱序执行读的操作,写操作也可以提高效率,就是通过CPU寄存器和L1缓存中间的WCBuffer来执行。WCBuffer是Write Combining Buffer,即写合并缓存。这个缓存只有4个字节。所以如果要利用WCBuffer,即写合并,就需要控制数据在4个字节的空间里进行操作,多条指令在该缓存中进行合并操作,然后一次性把结果写入低级存储。

2.8 乱序证明

代码语言:javascript
复制
public static void main(String[] args) throws InterruptedException {
    int i = 0;
    for(;;) {
        i++;
        x = 0; y = 0;
        a = 0; b = 0;
        Thread one = new Thread(new Runnable() {
            public void run() {
                //由于线程one先启动,下面这句话让它等一等线程two. 读着可根据自己电脑的实际性能适当调整等待时间.
                shortWait(100000);
                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();
        String result = "第" + i + "次 (" + x + "," + y + ")";
        if(x == 0 && y == 0) {
            System.err.println(result);
            break;
        } else {
            //System.out.println(result);
        }
    }
}

这段代码的一些情况:

1、x和y初始化都是0。

2、for循环是无限循环,只有x和y同时等于0的时候才会跳出。

3、判定x和y同时等于0的跳出条件是在两个线程执行以后执行的。

4、第一个线程首先会等待10_0000L毫秒,强制让第二个线程先执行。

5、第一个线程a=1和x=b。第二个线程b=1和y=a。注意,他们之间都不存在指令依赖。

如果线程内指令是顺序执行的,那么有几种结果:

1、第二个线程b=1肯定会先执行成功,而第一个线程a=1不一定在y=a之前执行完毕。若a=1未执行完毕,则(x,y)=(1,0)。

2、若a=1时,y=a还未执行,则(x,y)=(1,1)。

如果第一个线程不设等待时间,则第二个线程的b=1未必会先执行。则结果除了以上两种情况以外,分析的内容差不多就省略了,直接列出结果:

1、若x=b执行时,b=1还未执行完毕,则(x,y)=(0,1)。

2、若x=b执行时,b=1已执行完毕,则(x,y)=(1,1)。

所以,即使第一个线程不增加等待,(x,y)=[(1,0),(0,1),(1,1)],就是没有(0,0)的情况发生!

那么,当CPU指令乱序执行的时候,可能会出现(0,0)的情况,因为这两个线程内分别的2个指令均没有上下的依赖关系,所以乱序执行的前提有了。那么如果乱序执行,什么时候会出现(0,0)的情况呢?其实很简单,就是这两个线程内分别的2个指令的执行顺序全部颠倒了,即:

1、第一个线程,先执行x=b,再执行a=1。

2、第二个线程,先执行y=a,再执行b=1。

那么就可能出现:

1、当x=b执行的时候,b=1尚未执行,x=0。

2、当y=a执行的时候,a=1尚未执行,y=0。

所以,当程序能够识别到(0,0)的情况时,也就反证了CPU乱序执行指令了。下面看一下输出结果:

第461766次 (0,0)

一定会输出结果的,因为前提条件有了(指令间无依赖关系),则CPU乱序执行指令是确定的,只是执行时间的长短就不一定了,你不一定要等待多久,才能等到这个输出。

3、特定情况下保证有序

3.1 硬件级别保证有序

x86 CPU内存屏障:

  • sfence,save屏障。在sfence指令前的写操作必须在指令后的写操作前完成。
  • Ifence,load屏障。读操作不能重排。
  • mfence,读写混合。读写操作均不能重排。

原理就是通过硬件级CPU的设定,在两条有可能乱序执行的指令之间加一道栅栏(fence),强制保证不会乱序执行。

3.2 intel lock汇编指令

intel汇编指令集中也包括了lock命令,也可以实现屏障。

3.3 JVM的内存屏障

JVM的内存屏障依赖于硬件级别的内存屏障或者汇编的lock指令。JVM本质上是操作系统上面的软件,所以JVM来保证内存屏障是作为软件来依赖硬件的实现。JVM的内存屏障的规范包括:

  • LoadLoad屏障:两条指令都是读,中间加一层双读内存屏障,保证顺序。
  • StoreStore屏障:两条指令都是写,中间加一层双写内存屏障,保证顺序。
  • LoadStore屏障:先读后写两条指令,中间加一层读写内存屏障,保证顺序。
  • StoreLoad屏障:先写后读两条指令,中间加一层写读内存屏障,保证顺序。

不同的cpu的硬件层面的实现不同,例如龙芯、x86、arm等芯片设计框架,他们对于内存屏障的实现都不同,而JVM是跨平台的,它提出一种规范,基于不同底层向上提供统一的软件层级的内存屏障实现。

4、volatile

一段java代码要经过几个阶段:编码=》Class=》JVM=》操作系统。那我们研究volatile,也按照这个路线循迹追踪。

4.1 字节码

先编写一段代码:

image-20220603132950939
image-20220603132950939

我们看到变量a和b在字节码层面的唯一区别是访问标志,Access Flag不同,加了volatile修饰的变量b会变为0x0040。

4.2 JVM的处理

JVM对于volatile的实现,是基于3.3的处理。

1、针对写操作,StoreStore内存屏障 + volatile 写操作 + StoreLoad内存屏障。为什么后面是StoreLoad呢?第一个Store好理解,因为前面接的是写操作,第二个Load的含义就是你读的操作必须在我写完以后,保证了一致性。

2、针对读操作,LoadLoad内存屏障 + volatile 读操作 + LoadStore内存屏障。这里也一样,为什么后面是LoadStore呢?第一个Load是因为前面接的是读操作,第二个Store就是你再接写的指令一定是在我读完以后,保证了一致性。

4.3 操作系统层面

通过汇编代码去调用底层硬件层面的能力,保证对内存区域加锁,lock汇编指令。

工具:hsdis,HotSpot Dis Assembler。JVM反汇编。

5、synchronized

目前synchronized指令的优化速度已经可以替代volatile关键字来执行线程安全了。

我们仍旧按照字节码、JVM以及操作系统的方式来分析synchronized关键字的内部实现原理。

5.1 字节码

首先写一个代码,我们定义一个同步的空方法m,观察它的字节码。

image-20220603170224879
image-20220603170224879

与volatile关键字相同,我们增加的m方法只是增加了访问标志Access_Flag为synchronized。

下面再看一下synchronized关键字上锁的一种使用方法所对应的字节码的情况。

image-20220603170625976
image-20220603170625976

当我们使用synchronized关键字来上锁的时候,可以看到JVM的字节码有了变化。如上图所示,JVM的code,有monitorentermonitorexit的指令。

注意这个指令是JVM的指令集,不是操作系统的。

所以,这种方式的JVM级别的实现已经与volatile不同了,synchronized关键字明显能够支持的情况更加多元。

有两个exit的意思,是因为其中有一个是处理异常退出的情况。

5.2 JVM的处理

JVM这里的处理是通过C++实现的内容,它会拿到Class的字节码以后去执行,我们可以参照JVM规范查询一下monitorenter和monitorexit的内容。

1、monitorenter,进入一个针对某对象的监控中,它的字节码是0xc2。每个对象都会关联一个监控器,当这个监控器有owner的时候,会被上锁。它可以有1个或多个对应的monitorexit指令,为了实现synchronized状态。这里面包括对于操作数栈的访问计数的控制,当它为0的时候,可以给它指定owner。

2、monitorexit,执行monitorexit指令的当前线程一定是监控器的owner,它关联者一个对象实例。当操作数栈中的访问计数为0的时候,当前线程退出监控器,不再是当前对象的owner。其他线程可以尝试成为这个owner。

其实这个过程就是JVM在内存中对于操作权利的监控,用来调度不同线程的owner身份。

5.3 操作系统层面

仍旧是通过汇编语言的方式,x86的话还是lock comxchg XXXX 语句。

6、指令排序规范

6.1 java并发内存模型

java线程 <> 工作内存 <> |save| 主

java线程 <> 工作内存 <> |and | <==> 内

java线程 <> 工作内存 <> |load| 存

6.2 happens-before原则

JVM规范,规定有些指令不可以指令重排。java语言规范,Java Language Specification,有专门的说明,有哪些情况不能指令重排。

as if serial ,单线程执行结果与多线程指令重排的结果一致。

7、对象与内存

7.1 对象的创建过程

T t = new T();`

1、classloading

2、class linking(verfification, preparation, resolution)

3、class initializing

4、申请对象内存

5、成员变量赋默认值

6、调用构造方法<init>,①成员变量顺序赋初始值。②执行构造方法语句(先调用父类,再调用子类,若子类有实现)。

7.2 对象在内存中的存储布局

7.2.1 观察JVM虚拟机配置

通过java -version中间增加 -XX:+PrintCommandLineFlags,进行查看。

-XX:ConcGCThreads=3 -XX:G1ConcRefinementThreads=10 -XX:GCDrainStackTargetSize=64 -XX:InitialHeapSize=268435456 -XX:MarkStackSize=4194304 -XX:MaxHeapSize=4294967296 -XX:MinHeapSize=6815736 -XX:+PrintCommandLineFlags -XX:ReservedCodeCacheSize=251658240 -XX:+SegmentedCodeCache -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:+UseG1GC openjdk version "15.0.5" 2021-10-19 OpenJDK Runtime Environment Zulu15.36+13-CA (build 15.0.5+3-MTS) OpenJDK 64-Bit Server VM Zulu15.36+13-CA (build 15.0.5+3-MTS, mixed mode, sharing)

可以观察到虚拟机的初始堆大小、最大堆大小等信息。

7.2.2 普通对象

1、markword,对象头, 在HotSpot JVM中被称为markword,8个字节。

2、ClassPointer,类指针。一个对象有一个指针指向Class,例如对象t有一个指针,指向T.Class。

3、Instance,实例数据,成员变量,类指针下面有一个区域,记录成员变量的内容,例如int a =8。

4、Padding对齐,8字节的倍数。64位机器是按块读取,如果不足一个块,就是需要对齐。

7.2.3 数组对象

1、对象头

2、CLassPointer

3、数组长度,指定数组长度,4个字节。

4、数组数据

5、对齐

区块链的区块跟这个设计有点像,包括网络传输对象,也都是相似。

7.2.4 实例:查看对象大小

类加载器中很多具体的例如加载的操作都是native方法完成的,native方法是操作系统级别的本地方法,是C++实现的,这部分内容对于java来讲是黑盒的。但是在Class文件被加载到JVM之前,Java提供了一个代理,它在中间拦截,可获得Class的具体信息。本例就是通过Agent功能来获取对象的大小。

首先来编写Agent,最好新起一个单独的工程。创建一个AgentObjSize类。

代码语言:javascript
复制
package com.evsward.agent;

import java.lang.instrument.Instrumentation;

public class AgentObjSize {
    /**
     * 通过调试器,可以获得对象大小
     */
    private static Instrumentation inst;

    public static void premain(String agentArgs, Instrumentation _inst) {
        inst = _inst;
    }

    public static long sizeOf(Object o) {
        return inst.getObjectSize(o);
    }

}

其中要注意到的是Instrumentation类,这是一个仪表接口,可以理解为JVM虚拟机的仪表。它是在java.lang.instrument包下。

Instrumentation 提供了调试Java代码所需要的服务。调试器是将字节码添加到方法中,以便收集工具所使用的数据。

所以声明静态的Instrumentation对象以及声明静态premain方法是固定的写法。sizeOf方法是我们获取到inst实例以后自己编写使用的,用于对外提供服务。

如果要想该类AgentObjSize真正成为Agent,还需要指定MINIFEST.MF文件。在src下新建包META-INF,然后手动创建MF文件,或者也可以在Idea中配置Project Structure -> Artifacts -> 添加JAR -> copy to the output directory and link via manifest,确定以后,会在项目文件中自动帮你生成好META-INF包和MINIFEST.MF文件,然后再手动修改MINIFEST.MF文件即可。最后回到项目中Build -> Build Artifacts -> build,打包成jar。

代码语言:javascript
复制
Manifest-Version: 1.0
Main-Class: 
Premain-Class: com.evsward.agent.AgentObjSize

以上是MINIFEST.MF最终的内容情况。

然后我们开启一个新工程,把上面的jar引入到工程中(Project Structure -> Libraries -> 添加jar进来)。然后新建一个类ObjectUseAgent用来调用Agent方法。

代码语言:javascript
复制
package com.evswards.jvm;

import com.evsward.agent.AgentObjSize;

public class ObjectUseAgent {
    public static void main(String[] args) {
        System.out.println(AgentObjSize.sizeOf(new Object()));
        System.out.println(AgentObjSize.sizeOf(new int[]{}));
        System.out.println(AgentObjSize.sizeOf(new P()));
    }
    //一个Object占多少个字节
    // -XX:+UseCompressedClassPointers -XX:+UseCompressedOops
    // 开启UseCompressedOops,默认会开启UseCompressedClassPointers
    // Oops = ordinary object pointers
    private static class P {
        //8 _markword
        //4 _class pointer
        int id;         //4
        String name;    //4
        int age;        //4

        byte b1;        //1
        byte b2;        //1

        Object o;       //4
        byte b3;        //1

    }
}

然后去执行该main方法,执行前要Edit Configuration,在vm options命令中添加:

-javaagent:{指向你的jar包全路径}.jar

该参数-javaagent就是用来指定agent类的。然后再运行main函数,可以得到结果:

16 24 48

注意,这里默认设定了压缩参数,开启UseCompressedOops,默认会开启UseCompressedClassPointers。

所以,我们可以在上面vm options命令中继续添加 -XX:-UseCompressedClassPointers以及 -XX:-UseCompressedOops命令,然后去执行main方法,查看结果的不同。对象的大小,会受到压缩参数的影响,若加入压缩,ClassPointer指针会被从8个字节压缩成4个字节,另外的引用对象,例如String、Object的对象也会被从8个字节压缩成4个字节。这里可以分别尝试添加与删除压缩参数然后执行一下main方法去验证。

7.2.5 对象头的具体内容

对象头是markword,HotSpot JVM 64位机器,8个字节,定义在markOop.hpp文件中:

image-20220604142103415
image-20220604142103415

包括了:

1、锁定信息,代表了该对象是否被锁定。

2、GC的标记,用作垃圾回收

3、对象的hashcode,System.identityHashCode(...)

8个字节共64位,通过对这64位内存空间的使用,记录了对象的状态。

7.2.6 对象定位

当我们new出一个对象的时候,对象是如何找到类的,有两种方式:

1、句柄池,间接指针。

2、直接指针,HotSpot使用。

本文参与 腾讯云自媒体分享计划,分享自作者个人站点/博客。
原始发表:2022-06-04,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 1、操作系统的存储结构
  • 2、硬件层数据一致性问题
    • 2.1 CPU缓存不一致
      • 2.2 总线锁
        • 2.3 缓存锁:MESI Cache一致性协议
          • 2.4 缓存行
            • 2.5 伪共享
              • 2.6 缓存行对齐
                • 2.7 乱序执行
                  • 2.8 乱序证明
                  • 3、特定情况下保证有序
                    • 3.1 硬件级别保证有序
                      • 3.2 intel lock汇编指令
                        • 3.3 JVM的内存屏障
                        • 4、volatile
                          • 4.1 字节码
                            • 4.2 JVM的处理
                              • 4.3 操作系统层面
                              • 5、synchronized
                                • 5.1 字节码
                                  • 5.2 JVM的处理
                                    • 5.3 操作系统层面
                                    • 6、指令排序规范
                                      • 6.1 java并发内存模型
                                        • 6.2 happens-before原则
                                        • 7、对象与内存
                                          • 7.1 对象的创建过程
                                            • 7.2 对象在内存中的存储布局
                                              • 7.2.1 观察JVM虚拟机配置
                                              • 7.2.2 普通对象
                                              • 7.2.3 数组对象
                                              • 7.2.4 实例:查看对象大小
                                              • 7.2.5 对象头的具体内容
                                              • 7.2.6 对象定位
                                          相关产品与服务
                                          文件存储
                                          文件存储(Cloud File Storage,CFS)为您提供安全可靠、可扩展的共享文件存储服务。文件存储可与腾讯云服务器、容器服务、批量计算等服务搭配使用,为多个计算节点提供容量和性能可弹性扩展的高性能共享存储。腾讯云文件存储的管理界面简单、易使用,可实现对现有应用的无缝集成;按实际用量付费,为您节约成本,简化 IT 运维工作。
                                          领券
                                          问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档