从操作系统层面理解java

从操作系统层面理解java

从操作系统层面理解java

引言

背景知识点

CPU 的工作原理

陷入内核

虚拟存储

内存管理(MMU)

内存分段

内存分页

缺页中断

JVM 中对象的内存布局

对象头

MarkWord(标记字段)

实例数据

字段重排列

对齐填充(Padding)

内存对齐

附录

不同的进程可以有相同的虚拟内存地址

为什么需要压缩指针

从操作系统层面理解java

引言

【涉及JVM参数】:

-XX:+UseCompressedOops, -XX +CompactFields ,-XX:-RestrictContended ,-XX:ContendedPaddingWidth, -XX:ObjectAlignmentInBytes

背景知识点

操作系统的堆和栈

堆(操作系统):一般由程序员分配释放,若程序员不释放,程序结束时可能由OS回收,分配方式类似于链表。

栈(操作系统):由操作系统自动分配释放,存放函数的参数值,局部变量值等。操作方式与数据结构中的栈相类似。

时间局限性

某些指令被执行后,不久后可能会在此执行,某些数据被访问后,不久可能会再次访问

空间局限性

一旦程序访问了某个存储单元,不久后其相邻的存储单元也可能被访问

如下述例子:appender为时间局限性Temporal Locality,因为sum被引用了多次.数组元素为空间局限性Spatial Locality 如果case1[i]被应用了,后面的case1[i+1]也可能被应用

String case1[]={"123","456"}; 
String appender; 
for(int i=0;i<2;i++){ 
    appender=appender+case1[i]
}

CPU 的工作原理

现代 CPU 芯片中大都集成了,控制单元,运算单元,存储单元.控制单元是 CPU 的控制中心, CPU 需要通过它才知道下一步做什么,也就是执行什么指令,控制单元又包含:指令寄存器( IR ),指令译码器( ID )和操作控制器( OC )

CPU 内部集成的存储单元 SRAM ,正好和主存中的 DRAM 对应, RAM 是随机访问内存,就是给一个地址就能访问到数据,而磁盘这种存储媒介必须顺序访问,而 RAM 又分为动态和静态两种,静态 RAM 由于集成度较低,一般容量小,速度快,而动态 RAM 集成度较高,主要通过给电容充电和放电实现,速度没有静态 RAM 快,所以一般将动态 RAM 做为主存,而静态 RAM 作为 CPU 和主存之间的高速缓存(cache),用来屏蔽 CPU 和主存速度上的差异,也就是我们经常看到的 L1 、 L2 、L3缓存.每一级别缓存速度变低,容量变大。

cpu的多级架构的两个重要知识点:

    1. 多级缓存之间为保证数据的一致性协议
    2. cache 和主存的映射

cahce 缓存的单位是缓存行(默认64个字节),对应主存中的一个内存块,并不是一个变量,这个主要是因为 ** CPU 访问的时间局限性:被访问的某个存储单元,在一个较短时间内,很有可能再次被访问

以及空间局限性:被访问的某个存储单元,在较短时间内,他的相邻存储单元也会被访问到。

注意:L1,L2,为核心独享,L3为CPU共享

陷入内核

用户进程在执行时,为了访问系统资源时(包括硬件中断或内核数据结构),它需要进行系统调用。当前的进程被暂时终止执行,其进程上下文也会被内核中断程序保存起来。然后开始执行一段需要的内核代码。这样CPU便进入了内核态,这就是陷入内核。

中断

中断:CPU 只要一上电就像一个永动机, 不停的取指令,运算,周而复始,而中断便是操作系统的灵魂,故名思议,中断就是打断 CPU 的执行过程,转而去做点别的,例如系统执行期间发生了致命错误,需要结束执行,例如用户程序调用了一个系统调用的方法,例如mmp等,就会通过中断让 CPU 切换上下文,转到内核空

例如执行系统调用创建线程的指令,而 CPU 每执行完一个指令,就会检查中断寄存器中是否有中断,如果有就取出然后执行该中断对应的处理程序

Linux的设计者,为了保护操作系统,将进程的执行状态用内核态和用户态分开,同一个进程中,内核和用户共享同一个地址空间,一般 4G 的虚拟地址,其中 1G 给内核态, 3G 给用户态.在程序设计的时候我们要尽量减少用户态到内核态的切换,例如创建线程是一个系统调用,所以我们有了线程池的实现

虚拟存储

CPU采用 段基址 + 段内偏移地址 的方式访问内存,

内存分段:每个进程都会分配一个内存段,而且这个内存段需要是一块连续的空间(但是内存短之间会有空隙,一个100M 的内存,无法支持 10 个需要 10M 内存才能运行的程序),主存里维护着多个内存段,当某个进程需要更多内存,并且超出物理内存的时候,就需要将某个不常用的内存段换到硬盘上,等有充足内存的时候在从硬盘加载进来。那么如何让段内地址不连续呢(即如何能有效利用这些不连续的内存段?),答案:内存分页

内存分页:在保护模式下,每一个进程都有自己独立的地址空间,所以段基地址是固定的,只需要给出段内偏移地址就可以了,而这个偏移地址称为线性地址,线性地址是连续的,而内存分页将连续的线性地址和和分页后的物理地址相关联,这样逻辑上的连续线性地址可以对应不连续的物理地址.物理地址空间可以被多个进程共享.而这个映射关系将通过页表( page table)进行维护. 标准页的尺寸一般为 4KB ,分页后,物理内存被分成若干个 4KB 的数据页,进程申请内存的时候,可以映射为多个 4KB 大小的物理内存,而应用程序读取数据的时候会以页为最小单位,当需要和硬盘发生交换的时候也是以页为单位.

后面会提到内存分段和内存分页

内存管理(MMU)

CPU是通过寻址来访问内存的。32位CPU的寻址宽度是 0~0xFFFFFFFF ,计算后得到的大小是4G,也就是说可支持的物理内存最大是4G,但在实践过程中,碰到了这样的问题,程序需要使用4G内存,而可用物理内存小于4G,导致程序不得不降低内存占用。而且运行我们的程序如果直接操作物理内存,将会引发很多的问题(比如不同进程之间访问/修改的隔离、权限等等).

为了解决此类问题,现代CPU引入了 MMU(Memory Management Unit 内存管理单元),MMU 的核心思想是利用虚拟地址替代物理地址,给每个进程都分配一个虚拟的内存空间,这样程序所使用的地址就是虚拟内存地址,即CPU寻址时使用虚址,进程持有的虚拟地址会通过 CPU 芯片中的内存管理单元(MMU)的映射关系,来转换变成物理地址。MMU的引入,解决了对物理内存的限制,对程序来说,就像自己在使用4G内存一样。然后实际运行时,再把虚拟地址映射到物理地址,从而给程序分配物理内存地址使用。

操作系统的内存管理需要实现以下几个功能:

    • 地址转换:将程序中的逻辑地址转换成内存中的物理地址(抽象)
    • 存储保护:保证个个作业在自己的内存空间内运行,互不干扰(保护)
    • 内存的分配与回收:当作业或进程创建后系统会为他们分配内存空间,当结束后内存空间也会被回收。
    • 内存空间的扩充:利用虚拟存储技术或自动覆盖技术,从逻辑上扩充内存(虚拟化)
    • 进程间通信(共享)

内存分段

在内存分段管理中,程序是由若干个逻辑分段组成的,如可由代码分段、数据分段、栈段、堆段组成。不同的段是有不同的属性的,所以就用分段(Segmentation)的形式把这些段分离出来。

为什么要分段?

用户程序里面每个区域都有其自己的特点,比如主程序这部分应该是只读的,变量所在的区域是可写的,函数库应该是可以链接也可以不链接的,栈应该只能单向增加。如果是将整个程序都放在一块的话这些要求肯定不能保证。因此程序应该是要分段保存的,并且这些段都有自己的特点。在程序员眼中的程序是分为很多段的,每一段都有不同的特点。适用于不同的领域。每一段都是从该段的地址0开始的。就是说主程序存放的地方地址应该是从零开始的,变量存放的地方地址也应该是从零开始的,其他区域也是如此。

分段机制下的虚拟地址由两部分组成,段选择符和段内偏移量。

  • 段选择符就保存在段寄存器里面。段表里面保存的是段描述符,包括这个段基地址、段界限和特权等级等。
  • 虚拟地址中的段内偏移量应该位于 0 和段界限之间,如果段内偏移量是合法的,就将段基地址加上段内偏移量得到物理内存地址。

如何在内存里面找到空闲分区。【固定分区、可变分区等--省略,自己百度操作系统】

内存分页

内存分页(Paging)是在使用MMU的基础上,提出的一种内存管理机制。它将虚拟地址和物理地址按固定大小(4K)分割成页(page)和页帧(page frame),并保证页与页帧的大小相同。

这种机制,从数据结构上,保证了访问内存的高效,并使OS能支持非连续性的内存分配。在程序内存不够用时,还可以将不常用的物理内存页转移到其他存储设备上,比如磁盘,这就是大家耳熟能详的虚拟内存。

在上文中提到,虚拟地址与物理地址需要通过映射,才能使CPU正常工作。而映射就需要存储映射表。在现代CPU架构中,映射关系通常被存储在物理内存上一个被称之为页表(page table)

现代计算机多采用虚拟存储技术,虚拟存储让每个进程以为自己独占整个内存空间,其实这个虚拟空间是主存和磁盘的抽象,这样的好处是,每个进程拥有一致的虚拟地址空间,简化了内存管理,进程不需要和其他进程竞争内存空间,因为他是独占的,也保护了各自进程不被其他进程破坏.

另外,他把主存看成磁盘的一个缓存,主存中仅保存活动的程序段和数据段,当主存中不存在数据的时候发生缺页中断,然后从磁盘加载进来,当物理内存不足的时候会发生 swap 到磁盘.页表保存了虚拟地址和物理地址的映射,页表是一个数组,每个元素为一个页的映射关系,这个映射关系可能是和主存地址,也可能和磁盘,页表存储在主存,我们将存储在高速缓冲区 cache  中的页表称为快表 TLAB

缺页中断

进程线性地址空间里的页面不必常驻内存,在执行一条指令时,如果发现他要访问的页没有在内存中(即存在位为0),那么停止该指令的执行,并产生一个页不存在的异常,对应的故障处理程序可通过从外存加载该页的方法来排除故障,之后,原先引起的异常的指令就可以继续执行,而不再产生异常。

内存映射mmp机制

在这里主要是硬盘上文件的位置与进程 逻辑地址空间 中一块大小相同的区域之间的一一对应.,通过页表维护虚拟地址到磁盘的映射.减少了从内核缓冲区到用户空

间的拷贝,直接从磁盘读取数据到内存,减少了系统调用的开销,对用户而言,仿佛直接操作的磁盘上的文件,如图:mmap()会返回一个指针ptr,它指向进程逻辑地址空间中的一个地址.进程无需再调用read或write对文件进行读写,而只需要通过ptr就能够操作文件。

但是ptr所指向的是一个逻辑地址,要操作其中的数据,必须通过MMU将逻辑地址转换成物理地址. 而这时MMU在地址映射表中是无法找到与ptr相对应的物理地址的,也就是MMU失败,将产生一个缺页中断,缺页中断的中断响应函数会在swap中寻找相对应的页面

如果找不到(也就是该文件从来没有被读入内存的情况),则会通过mmap()建立的映射关系,从硬盘上将文件读取到物理内存中。如果在拷贝数据时,发现物理内存不够用,则会通过虚拟内存机制(swap)将暂时不用的物理页面交换到硬盘上

在 Java 中,我们使用 MappedByteBuffer 来实现内存映射,这是一个堆外内存,在映射完之后,并没有立即占有物理内存,而是访问数据页的时候,先查页表,发现还没加载,发起缺页异常,然后在从磁盘将数据加载进内存,

所以一些对实时性要求很高的中间件,例如rocketmq,消息存储在一个大小为1G的文件中,为了加快读写速度,会将这个文件映射到内存后,在每个页写一比特数据,这样就可以把整个1G文件都加载进内存,在实际读写的时候就不会发生缺页了,这个在rocketmq内部叫做文件预热.

 private void init(final String fileName, final int fileSize) throws IOException {        this.fileName = fileName;        this.fileSize = fileSize;        this.file = new File(fileName);        this.fileFromOffset = Long.parseLong(this.file.getName());        boolean ok = false;        ensureDirOK(this.file.getParent());        try {            this.fileChannel = new RandomAccessFile(this.file, "rw").getChannel();            this.mappedByteBuffer = this.fileChannel.map(MapMode.READ_WRITE, 0, fileSize);            TOTAL_MAPPED_VIRTUAL_MEMORY.addAndGet(fileSize);            TOTAL_MAPPED_FILES.incrementAndGet();            ok = true;        } catch (FileNotFoundException e) {            log.error("create file channel " + this.fileName + " Failed. ", e);            throw e;        } catch (IOException e) {            log.error("map file " + this.fileName + " Failed. ", e);            throw e;        } finally {            if (!ok && this.fileChannel != null) {                this.fileChannel.close();            }        }    }//文件预热,OS_PAGE_SIZE = 4kb 相当于每 4kb 就写一个 byte 0 ,将所有的页都加载到内存,真正使用的时候就不会发生缺页异常了 public void warmMappedFile(FlushDiskType type, int pages) {        long beginTime = System.currentTimeMillis();        ByteBuffer byteBuffer = this.mappedByteBuffer.slice();        int flush = 0;        long time = System.currentTimeMillis();        for (int i = 0, j = 0; i < this.fileSize; i += MappedFile.OS_PAGE_SIZE, j++) {            byteBuffer.put(i, (byte) 0);            // force flush when flush disk type is sync            if (type == FlushDiskType.SYNC_FLUSH) {                if ((i / OS_PAGE_SIZE) - (flush / OS_PAGE_SIZE) >= pages) {                    flush = i;                    mappedByteBuffer.force();                }            }            // prevent gc            if (j % 1000 == 0) {                log.info("j={}, costTime={}", j, System.currentTimeMillis() - time);                time = System.currentTimeMillis();                try {                // 这里sleep(0),让线程让出 CPU 权限,供其他更高优先级的线程执行,此线程从运行中转换为就绪                    Thread.sleep(0);                } catch (InterruptedException e) {                    log.error("Interrupted", e);                }            }        }        // force flush when prepare load finished        if (type == FlushDiskType.SYNC_FLUSH) {            log.info("mapped file warm-up done, force to disk, mappedFile={}, costTime={}",                this.getFileName(), System.currentTimeMillis() - beginTime);            mappedByteBuffer.force();        }        log.info("mapped file warm-up done. mappedFile={}, costTime={}", this.getFileName(),            System.currentTimeMillis() - beginTime);        this.mlock();    }final String fileName, final int fileSize) throws IOException {
        this.fileName = fileName;
        this.fileSize = fileSize;
        this.file = new File(fileName);
        this.fileFromOffset = Long.parseLong(this.file.getName());
        boolean ok = false;
 
        ensureDirOK(this.file.getParent());
 
        try {
            this.fileChannel = new RandomAccessFile(this.file, "rw").getChannel();
            this.mappedByteBuffer = this.fileChannel.map(MapMode.READ_WRITE, 0, fileSize);
            TOTAL_MAPPED_VIRTUAL_MEMORY.addAndGet(fileSize);
            TOTAL_MAPPED_FILES.incrementAndGet();
            ok = true;
        } catch (FileNotFoundException e) {
            log.error("create file channel " + this.fileName + " Failed. ", e);
            throw e;
        } catch (IOException e) {
            log.error("map file " + this.fileName + " Failed. ", e);
            throw e;
        } finally {
            if (!ok && this.fileChannel != null) {
                this.fileChannel.close();
            }
        }
    }
 
//文件预热,OS_PAGE_SIZE = 4kb 相当于每 4kb 就写一个 byte 0 ,将所有的页都加载到内存,真正使用的时候就不会发生缺页异常了
 public void warmMappedFile(FlushDiskType type, int pages) {
        long beginTime = System.currentTimeMillis();
        ByteBuffer byteBuffer = this.mappedByteBuffer.slice();
        int flush = 0;
        long time = System.currentTimeMillis();
        for (int i = 0, j = 0; i < this.fileSize; i += MappedFile.OS_PAGE_SIZE, j++) {
            byteBuffer.put(i, (byte) 0);
            // force flush when flush disk type is sync
            if (type == FlushDiskType.SYNC_FLUSH) {
                if ((i / OS_PAGE_SIZE) - (flush / OS_PAGE_SIZE) >= pages) {
                    flush = i;
                    mappedByteBuffer.force();
                }
            }
 
            // prevent gc
            if (j % 1000 == 0) {
                log.info("j={}, costTime={}", j, System.currentTimeMillis() - time);
                time = System.currentTimeMillis();
                try {
                // 这里sleep(0),让线程让出 CPU 权限,供其他更高优先级的线程执行,此线程从运行中转换为就绪
                    Thread.sleep(0);
                } catch (InterruptedException e) {
                    log.error("Interrupted", e);
                }
            }
        } 
        // force flush when prepare load finished
        if (type == FlushDiskType.SYNC_FLUSH) {
            log.info("mapped file warm-up done, force to disk, mappedFile={}, costTime={}",
                this.getFileName(), System.currentTimeMillis() - beginTime);
            mappedByteBuffer.force();
        }
        log.info("mapped file warm-up done. mappedFile={}, costTime={}", this.getFileName(),
            System.currentTimeMillis() - beginTime);
 
        this.mlock();
    }

JVM 中对象的内存布局

Java 对象在 JVM 中是用 instanceOopDesc 结构表示,每个 Java 对象都有一个对象头 (object header) ,由标记字段和类型指针构成:(Object Oriented Programming,oops:Ordianry Object Point 引用类型指针)。

对象头

MarkWord(标记字段)

MarkWord(标记字段)用来存储对象的哈希码, GC 信息, 持有的锁信息,而类型指针指向该对象的类 Class ,在 64 位操作系统中,标记字段占有 64 位,在 32 位操作系统占4个字节也就是32位.

类型指针

类型指针:JVM 中的类型指针封装在 klassOopDesc 结构中,类型指针指向了 InstanceKclass 对象,Java 类在 JVM 中是用 InstanceKclass 对象封装的,里边包含了 Java 类的元信息,比如继承结构、方法、静态变量、构造函数等。

  • 在不开启指针压缩的情况下(-XX:-UseCompressedOops)。在 32 位操作系统和 64 位操作系统中类型指针分别占用 4B 和 8B 大小的内存。
  • 在开启指针压缩的情况下(-XX:+UseCompressedOops)。在 32 位操作系统和 64 位操作系统中类型指针分别占用 4B 和 4B 大小的内存。

数组长度:如果 Java 对象是一个数组类型的话,那么在数组对象的对象头中还会包含一个 4Byte(32位) 大小的用于记录数组长度的属性

知识点:由于在对象头中用于记录数组长度大小的属性只占4B的内存,所以Java数组可以申请的最大长度为:2^32

也就是说一个  Java  对象在什么属性都没有的情况下要占有 16 字节的空间当前 JVM 中默认开启了压缩指针,这样类型指针可以只占 32 位,所以对象头占 12 字节, 压缩指针可以作用于对象头,以及引用类型的字段

实例数据

Java 对象在内存中的实例数据区用来存储 Java 类中定义的实例字段,包括所有父类中的实例字段。也就是说,虽然子类无法访问父类的私有实例字段,或者子类的实例字段隐藏了父类的同名实例字段,但是子类的实例还是会为这些父类实例字段分配内存。

  • 基础类型:Java 类中实例字段定义的基础类型在实例数据区的内存占用如下:
    • long、double 占用 8 个字节
    • int、float 占用 4 个字节。
    • short、char 占用 2 个字节。
    • byte、boolean 占用 1 个字节。
  • 引用类型:Java 类中实例字段的引用类型在实例数据区内存占用分为两种情况:
    • 不开启指针压缩(-XX:-UseCompressedOops):在 32 位操作系统中引用类型的内存占用为 4 个字节。在 64 位操作系统中引用类型的内存占用为 8 个字节。
    • 开启指针压缩(-XX:+UseCompressedOops):在 64 为操作系统下,引用类型内存占用则变为为 4 个字节,32 位操作系统中引用类型的内存占用继续为 4 个字节。

为什么 32 位操作系统的引用类型占4个字节,而 64 位操作系统引用类型占 8 字节

在 Java 中,引用类型所保存的是被引用对象的内存地址。

在 32 位操作系统中内存地址是由 32 个 bit 表示,因此是 4 个字节。能够记录的虚拟地址空间是 2^32 大小,也就是只能够表示 4G 大小的内存。

64 位操作系统中内存地址是由 64 个 bit 表示,因是8 个字节,但在 64 位系统里只使用了低 48 位,所以它的虚拟地址空间是 2^48 大小,能够表示 256T 大小的内存,其中低 128T 的空间划分为用户空间,高 128T 划分为内核空间,可以说是非常大了

字段重排列

其实我们在编写 Java 源代码文件的时候,定义的那些实例字段的顺序会被 JVM 重新分配排列,JVM 重新分配字段的排列顺序受 -XX:FieldsAllocationStyle 参数的影响,默认值为 1。

如果 JVM 开启了 -XX +CompactFields 时,int 型字段是可以插入对象中的第一个 long 型字段(也就是 Parent.l 字段)之前的空隙中的。如果 JVM 设置了 -XX -CompactFields 则  int 型字段的这种插入行为是不被允许的。具体可以参考:一文聊透对象在JVM中的内存布局,以及内存对齐和压缩指针的原理及应用 | HeapDump性能社区

默认情况下指针压缩 -XX:+UseCompressedOops 以及字段压缩 -XX +CompactFields 都是开启的。

对齐填充(Padding)

Java 虚拟机堆中对象之间的内存地址需要对齐至 8N(8 的倍数),虚拟机中内存对齐的选项为 -XX:ObjectAlignmentInBytes,默认为 8。也就是说对象与对象之间的内存地址需要对齐至多少倍,是由这个 JVM 参数控制的。

为了内存对齐的需要,对象头与字段之间,以及字段与字段之间需要填充一些不必要的字节。字段与字段之间,对象与对象之间填充的不必要字节,我们就称之为对齐填充。

@Contended 注解

Java8 中引入了一个新注解 @Contended,用于解决 False Sharing 的问题,同时这个注解也会影响到 Java 对象中的字段排列.因为虽然现在所有主流的处理器缓存行大小均为 64 字节,但是也还是有处理器的缓存行大小为 32 字节,有的甚至是 128 字节.引入 @Contended 注解可以使我们忽略底层硬件设备的差异性,做到 Java 语言的初衷:平台无关性。

@Contended 注解默认只是在 JDK 内部起作用,如果我们的程序代码中需要使用到@Contended 注解,那么需要开启 JVM 参数 -XX:-RestrictContended 才会生效。@Contended 注解可以标注在类上也可以标注在类中的字段上,被 @Contended 标注的对象会独占缓存行,不会和任何变量或者对象共享缓存行。

内存对齐

内存结构和存储控制器

内存(主存)由一个一个的存储器模块(memory module)组成,它们插在主板的扩展槽上,每个存储器模块中包含 8个 DRAM 芯片。常见的存储器模块通常以 64 位单位(8 个字节)传输数据到存储控制器上或者从存储控制器传出数据。 DRAM 芯片就包装在存储器模块中,每个存储器模块中包含 8 个 DRAM 芯片,依次编号为 0 - 7。

如图示模块上的八个黑色矩形包含DRAM芯片:

而每一个 DRAM 芯片的存储结构是一个二维矩阵,二维矩阵中存储的元素我们称为超单元(supercell),每个 supercell 大小为一个字节(8 bit)。每个 supercell 都由一个坐标地址(i,j)。

i 表示二维矩阵中的行地址,在计算机中行地址称为 RAS(row access strobe,行访问选通脉冲)。j 表示二维矩阵中的列地址,在计算机中列地址称为 CAS(column access strobe,列访问选通脉冲)

DRAM 芯片中的信息通过引脚流入流出 DRAM 芯片。每个引脚携带 1 bit 的信号。

图中 DRAM 芯片包含了两个地址引脚(addr),因为我们要通过 RAS、CAS 来定位要获取的 supercell。还有 8 个数据引脚(data),因为 DRAM 芯片的 IO 单位为一个字节(8 bit),所以需要 8 个 data 引脚从 DRAM 芯片传入传出数据。正是由于存储器模块中这种由 8 个 DRAM 芯片组成的物理存储结构的限制,内存读取数据只能是按照地址顺序 8 个字节的依次读取,,所以内存 IO 单位为 8 个字节

CPU 读写主存

CPU 与内存之间的数据交互是通过总线(bus)完成的,而数据在总线上的传送是通过一系列的步骤完成的,这些步骤称为总线事务(bus transaction)。 

其中数据从内存传送到CPU称之为读事务(read transaction),数据从CPU传送到内存称之为写事务(write transaction)。 

总线上传输的信号包括:地址信号,数据信号,控制信号。其中控制总线上传输的控制信号可以同步事务

CPU读取步骤如下

  • CPU 将内存地址A放到系统总线上。随后 IO bridge 将信号传递到存储总线上
  • 当主存中的存储控制器感受到了存储总线上的地址信号时,会将内存地址从存储总线上读取出来
  • 存储控制器会将内存地址转换为 DRAM 芯片中 supercell 在二维矩阵中的坐标地址(RAS,CAS)。并将这个坐标地址发送给对应的存储器模块。随后存储器模块会将 RAS 和 CAS 广播到存储器模块中的所有 DRAM 芯片。依次通过 (RAS,CAS) 从 DRAM0 到 DRAM7 读取到相应的 supercell
  • 一个 supercell 存储了 8 bit 数据,这里我们从 DRAM0 到 DRAM7 依次读取到了8 个 supercell 也就是 8 个字节,然后将这 8 个字节返回给存储控制器,由存储控制器将数据放到存储总线上。
  • O bridge 将存储总线上的数据信号转换为系统总线上的数据信号,然后继续沿着系统总线传递。CPU 芯片感受到系统总线上的数据信号,将数据从系统总线上读取出来并拷贝到寄存器中

CPU 每次会向内存读写一个 cache line 大小的数据(64 个字节),但是内存一次只能吞吐 8 个字节。

这连续的 8 个字节其实是存储于不同的 DRAM 芯片上的。每个 DRAM 芯片存储一个字节(supercell),DRAM0 芯片存储第一个低位字节(supercell),DRAM1 芯片存储第二个字节……依次类推 DRAM7 芯片存储最后一个高位字节,连续的内存地址实际上在物理上是不连续的.

在 64 位内存中,内存 IO 单位为 8 个字节,我们前边也提到内存结构中的存储器模块通常以 64 位为单位(8 个字节)传输数据到存储控制器上或者从存储控制器传出数据。因为每次内存 IO 读取数据都是从数据所在具体的存储器模块中包含的这 8 个 DRAM 芯片中以相同的(RAM、CAS)依次读取一个字节,然后在存储控制器中聚合成 8 个字节返回给 CPU

  • 假设我们现在读取 0x0000 - 0x0007 这段连续内存地址上的 8 个字节。由于内存读取是按照 8 个字节为单位依次顺序读取的,而我们要读取的这段内存地址的起始地址是 0(8 的倍数),所以 0x0000 - 0x0007 中每个地址的坐标都是相同的(因为其实内存地址都是0,RAS、CAS)。所以他可以在 8 个 DRAM 芯片中通过相同的(RAS、CAS)一次性读取出来。
  • 如果我们现在读取 0x0008 - 0x0015 这段连续内存上的8个字节也是一样的,因为内存段起始地址为 8(8 的倍数),所以这段内存上的每个内存地址在 DREAM 芯片中的坐标地址(RAS、CAS)也是相同的,我们也可以一次性读取出来。 

注意:0x0000 - 0x0007 内存段中的坐标地址(RAS、CAS)与 0x0008 - 0x0015 内存段中的坐标地址(RAS、CAS)

是不相同的。 

  • 但如果我们现在读取 0x0007 - 0x0014 这段连续内存上的8个字节情况就不一样了,由于起始地址 0x0007 在 DRAM 芯片中的(RAS、CAS)与后边地址 0x0008 - 0x0014 的(RAS、CAS)不相同,所以 CPU 只能先从 0x0000 - 0x0007 读取 8 个字节出来先放入结果寄存器中并左移 7 个字节(目的是只获取 0x0007),然后 CPU 在从 0x0008 - 0x0015 读取 8 个字节出来放入临时寄存器中并右移 1 个字节

从以上分析过程来看,当 CPU 访问内存对齐的地址时,比如 0x0000 和 0x0008 这两个起始地址都是对齐至8的倍数。CPU 可以通过一次 read transaction 读取出来。 

但是当 CPU 访问内存没有对齐的地址时,比如 0x0007 这个起始地址就没有对齐至8的倍数。CPU 就需要两次 read transaction 才能将数据读取出来

所以进行内存对齐目的是:

1、堆中对象的起始地址通过内存对齐至8的倍数,可以让对象尽可能的分配到一个缓存行中。一个内存起始地址未对齐的对象可能会跨缓存行存储,这样会导致 CPU 的执行效率慢 2 倍

2、如果对象过大,则对象中字段内存对齐,避免跨行存储字段

3、让不同的变量存在不通缓存行,则是防止一个变量的修改影响另一个线程对该对象相邻变量的修改

4、可以通过压缩指针可在在开启压缩指针情况下通过 32 位的对象引用将寻址空间提升至 32G,而不是4G(后面会提到)

5、原子性:CPU 可以原子地操作一个对齐的 word size memory(64 位处理器中 word size = 8 字节)

字段重排列的目的则是保证在字段内存对齐的基础上使得实例数据区占用内存尽可能的小

JVM 中基本类型数组的内存布局

上图表示的是基本类型数组在内存中的布局,基本类型数组在 JVM 中用 typeArrayOop 结构体表示,基本类型数组类型元信息用 TypeArrayKlass 结构体表示。数组的内存布局大体上和普通对象的内存布局差不多,唯一不同的是在数组类型对象头中多出了 4 个字节用来表示数组长度的部分。

JVM 中引用类型数组的内存布局

上图表示的是引用类型数组在内存中的布局,引用类型数组在 JVM 中用 objArrayOop 结构体表示,基本类型数组类型元信息用 ObjArrayKlass 结构体表示。同样在引用类型数组的对象头中也会有一个 4 字节大小用来表示数组长度的部分

引用类型和基本类型的数组在指针压缩和未压缩的情况下有不同

开启了压缩指针 -XX:+UseCompressedOops,所以对象引用占用内存大小为 4 个字节,但是基本类型比如long还是8个字节

关闭压缩指针,对象引用占用内存大小为 8个字节

也因此会产生的字段重排的情况不同

附录

不同的进程可以有相同的虚拟内存地址

这是因为每个进程都有自己的页表.每个进程都认为它是 32 位机器上的 4Gb 内存.所以 P1 和 P2 都可以访问地址 0xabcdef - 但物理内存位置可能不同.。

操作系统管理虚拟地址与物理地址之间的关系:分别是内存分段和内存分页,分段是比较早提出的,我们先来看看内存分段

为什么要进行内存对齐?为什么需要对齐至 8 的倍数?请参照内存对齐环节.

为什么要压缩指针

我们来介绍下前边经常提到的压缩指针。可以通过 JVM 参数 XX:+UseCompressedOops 开启,当然默认是开启的

假设我们现在正在准备将 32 位系统切换到 64 位系统,起初我们可能会期望系统性能会立马得到提升,但现实情况可能并不是这样的。

在 JVM 中导致性能下降的最主要原因就是 64 位系统中的对象引用。在前边我们也提到过,64 位系统中对象的引用以及类型指针占用 64 bit 也就是 8 个字节

1、对象的引用变大了,那么 CPU 可缓存的对象相对就少了,增加了对内存的访问频率

2、在 64 位系统中的对象引用占用的内存空间是 32 位系统中的两倍大小,因此间接的导致了在 64 位系统中更多的内存消耗以及更频繁的 GC 发生,GC 占用的 CPU 时间越多,那么我们的应用程序占用 CPU 的时间就越少。

从另一方面来说,在 64 位系统中内存的寻址空间为 2^48(前面已经提过为什么是48而不是64) = 256T,在现实情况中我们真的需要这么大的寻址空间吗?好像也没必要吧~~

压缩指针如何在 64 位系统中利用 32 位的对象引用获得超过 4G 的内存寻址空间?

由于堆中对象的起始地址均是对齐至8的倍数,既然 JVM 已经知道了这些对象的内存地址后三位始终是 0,那么这些无意义的 0 就没必要在堆中继续存储。相反,我们可以利用存储 0 的这 3 位 bit 存储一些有意义的信息,这样我们就多出 3 位 bit 的寻址空间。这样在存储的时候,JVM 还是按照 32 位来存储,只不过后三位原本用来存储0的bit现在被我们用来存放有意义的地址空间信息。而在寻址的时候,JVM 将这 32 位的对象引用左移 3 位(后三位补 0),这就导致了在开启压缩指针的情况下,我们原本32位的内存寻址空间一下变成了 35 位。可寻址的内存空间变为 2^32 * 2^3 = 32G。这样一来,JVM虽然额外的执行了一些位运算但是极大的提高了寻址空间,并且将对象引用占用内存大小降低了一半,节省了大量空间。况且这些位运算对于 CPU 来说是非常容易且轻量的操作

从 Java7 开始,当 maximum heap size 小于 32G 的时候,压缩指针是默认开启的。但是当 maximum heap size 大于 32G 的时候,压缩指针就会关闭。

如何进一步扩大寻址空间?寻求和GC的平衡?

前边提到我们在 Java 虚拟机堆中对象起始地址均需要对其至 8 的倍数,不过这个数值我们可以通过 JVM 参数 -XX:ObjectAlignmentInBytes 来改变(默认值为 8)。当然这个数值的必须是 2 的次幂,数值范围需要在 8 - 256 之间。

如果我们将 ObjectAlignmentInBytes 的数值设置为 16 呢?那么就会多出 4 位 bit 让我们存储额外的地址信息。寻址空间变为 2^32 * 2^4 = 64G。但是虽然能扩大寻址范围,但是这同时也可能增加了对象之间的字节填充,导致压缩指针没有达到原本节省空间的效果。同时现在通用PC操作系统地址总线总共也就扩了3根,寻址能力也就是32G而已。扩大到超过2^35后得不偿失

参见我的另一篇文章计算辑组成原理中的现在的64位PC最大也就是支持32GB的内存章节

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值