你真的懂JVM内存结构吗?—深入理解JVM之内存结构

 2022-09-05

在上一篇章中,我们学习了JVM的类加载机制,类加载之后会进入到Linking和Initializing阶段;在这一阶段会进行代码的执行和变量的赋值等等操作,而这一过程中产生的数据都会存储在JVM的内存中,那么JVM是怎么分配内存空间的呢?它的内存结构又是怎么划分的呢?这就是我们这一篇章所研究的内容引言部分,总领全篇文章的中心内容。

Linking和Initializing过程简介

202209052247495341.png

上图是我们上一篇章中画过的一张图,我们在上一个篇章中重点讲解了Loading阶段,从图中我们可以看出Linking阶段包含三步,分别是: verification、preparation、resolution,而Initializing阶段就是给静态变量赋初始值,接下来我们就简单介绍一下Linking阶段的三步,当然这不是我们这一篇章的重点。

1. verification阶段

在verification阶段,主要是由JVM校验字节码文件是否符合JVM能够执行的字节码文件的规范,在这里给大家推荐一款可以采用2进制、8进制、10进制、16进制阅读字节码文件的idea插件:Bin-Ed Binary。

我们使用Bin-Ed Binary插件采用16进制的方式查看多个class文件,发现所有class文件的开头部分是一样的,如下图所示:

202209052247508612.png最开始的四个字节表示Magic Number,其实就是字节码文件的标示,JVM在Verification阶段会校验前四个字节是否是CA FE BA BE,如果是则校验通过,如果不是则不能被JVM所识别。

接下来的俩字节表示Minor Version,其实就是JVM的次版本号,后面俩字节表示Major Version,其实就是JVM的主版本号,只要使用的是相同的JVM,那么Minor Version和Major Version必定是相同的。

2. preparation阶段

在这个阶段JVM会对类成员变量进行分配空间,然后给类成员变量赋默认值,注意 这里是赋默认值不是赋初始值 ,比如如果一个类成员变量是int类型则会赋默认值为0,如果是引用类型则会赋默认值为null。

3. resolution阶段

在这个阶段JVM会将类、方法、属性等符号引用解析为直接引用,也就是将常量池中的各种符号引用解析为指针、偏移量等内存地址的直接引用。

那么在这里有两个概念需要特别强调:

符号引用: 符号引用以一组符号来描述所引用的目标。符号引用可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。个人理解为:在编译的时候一个每个java类都会被编译成一个class文件,但在编译的时候虚拟机并不知道所引用类的地址,所以就用符号引用来代替,而在resolution阶段就是为了把这个符号引用转化成为真正的地址的阶段。

直接引用: 直接引用就是真正的内存地址值,即 直接指向目标的指针、偏移量等内存地址形式。

JVM内存分区

1. 方法区(MetaSpace)

首先我们的字节码文件加载进内存之后,那么JVM内存中必须有一块区域来存储类的相关信息,这块区域在JDK1.8之前叫做方法区(永久代); 在JDK1.8之后,方法区被废弃,改成了基于本地内存 (Native Memory)存储的 Metaspace ,我们可以理解成元数据区,它是一块线程共享的内存区域。主要用来保存被JVM加载的类的信息、常量、静态变量以及即时编译器(JIT)编译后的代码等数据。

那么为什么官方会在JDK8之后,去除永久代,而改用元数据区呢?官方给出的解释是:移除永久代是为融合HotSpot JVM与 JRockit VM而做出的努力,因为JRockit没有永久代,不需要配置永久代。

202209052247518363.png

注意:严格意义来讲元数据区并不在虚拟机中,而是使用本地物理内存

2. 程序计数器(Program Counter Register)

假设我们执行的是下面一块代码

    public class User {    
    public static void main(String[] args) {       
    	Card card = new Card();        
      card.setNumber(1);    
      }
  	}

这段代码是面向程序员的,JVM虚拟机是看不懂你的这段代码的。所以我们得通过编译器,将这段代码所在的.java文件编译成.class文件。而这个.class文件存放的就是你写出来的代码对应的字节码,字节码才是JVM虚拟机能够看懂并且执行的一种语言。那么字节码到底长成什么样呢?我们之前使用过idea的Bin-Ed Binary插件采用16进制的方式查看过,大概长成下面这样子。

202209052247531384.png

这样的内容你能看懂吗?能看懂就见鬼了!!!如果接触过汇编的同学应该知道有汇编指令这么一个东西,汇编指令就是一些操作符和助记符,帮助程序员能够更容易看懂汇编;同样的道理,我们JVM也有相应的字节码指令,我们想要看懂字节码文件,不需要去阅读这些生涩的二进制、十六进制,只需要找到这些字节码对应的字节码指令就行了。而官方的JVM规范文档的第七章中,就给大家列出了,具体的每一个字节码和字节码指令的对应关系,如下图所示:

202209052247542745.png

那么我们就可以将一个个字节对应上字节码指令,然后到JVM规范的第六章去查询每一个字节码指令的含义和作用,这样我们就能够愉快地阅读字节码文件了。Are You Crazy???你要我一个个字节去找对应的字节码指令?你还是sha了我算了,难道就没有工具能够帮助我们将字节码自动翻译成字节码指令吗?

当然有,我们可以使用jdk自带的javap,当然我这边更推荐大家使用idea的jclasslib Bytecode Viewer插件,可以轻松地将字节码文件中的字节码翻译成字节码指令,这样的话,我们可以轻松地阅读字节码指令了,如下图所示:

202209052247555286.png

上图就是main方法中执行的代码对应的字节码指令,我不是要讲程序计数器吗?我讲这些干嘛?别慌,正因为我们的代码会翻译成一行行的字节码指令,所以当类加载进内存之后,JVM的执行引擎会去执行那一行行的字节码指令。所以此时,JVM中就必须有一块内存区域去存储用来记录当前执行的字节码指令的位置,也就是记录当前执行到了哪条字节码指令,这块内存区域就叫做 程序计数器 。我们使用一张图来结合说明:

202209052247565007.png

大家都知道JVM是支持多个线程的,所以其实你写好的代码可能会开启多个线程并发执行不同的代码,所以就会有多个线程来执行不同的代码指令,因此每个线程都会有自己的一个程序计数器,专门记录当前这个线程目前执行到了哪一条字节码指令了,所以对上图进行优化,方便展示不同线程各自的内存区域:

202209052247582378.png

3. Java虚拟机栈(Stack)

Java虚拟机栈是用于存储每个方法中的局部变量等数据,由于Java虚拟机栈是线程独享的内存空间,所以每一个线程都有自己的栈内存空间。

每一个方法在栈内存中执行的时候,都会在栈内存中创建一个自己的 栈帧 ,每一个栈帧里会存储该方法执行时候的数据,每一个栈帧都包含局部变量表、操作数栈、动态链接、方法出口等。每一个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机里面从入栈到出栈的过程。在活动线程中,只有位于栈顶的栈帧才是有效的,称为当前栈帧,与这个栈帧相关联的方法称为当前方法。

202209052247593489.png

那么接下来,我们就介绍每一个栈帧中的局部变量表、操作数栈、动态链接、方法出口分别用于存放什么数据。

3.1 局部变量表

栈内存中我们主要关注的是局部变量表,所以局部变量表在栈内存中的地位是举足轻重的。局部变量表是一组变量存储空间,用于存放方法参数和方法内部定义的局部变量。并且在Java编译为Class文件时,就已经确定了该方法所需要分配的局部变量表的最大容量。

局部变量表的容量是以变量槽作为最小单位的,每个变量槽可存储32位长度的数据。在局部变量表中,会存储基本数据类型和引用( reference)数据类型的变量,对于 reference 虚拟机会从直接或者间接引用中找到对象的以下两点:

  1. 堆内存中对象存储的地址索引
  2. 所属数据类型在MetaSpace中存储的数据类型

而对于基本数据类型的变量,它的值会直接存储在局部变量表中,而我们前面说了局部变量表中的最小单位变量槽最大可存储长度为32位,那么我们先回顾一下各个基本数据类型的长度:

2022090522480039210.png

我们发现,对于长度小于等于32位的数据,一个变量槽完全可以存储,但是对于长度为64位的long、double类型,一个变量槽完全无能为力,那么此时怎么办呢?此时虚拟机会以高位对齐方式为其分配两个连续的变量槽空间,也就是相当于把一次long和double数据类型读写分割成为两次32位读写。

为了尽可能节省栈帧空间,局部变量表中的变量槽是可以复用的,但这样也会有缺点: 影响到系统的垃圾收集行为,比如某个大方法占用较多的Slot,执行完该方法的作用域后没有对Slot赋值或者清空设置null值,垃圾回收器便不能及时的回收该内存。

3.2 操作数栈

Java虚拟机的解释执行引擎被称为"基于栈的执行引擎",其中所指的栈就是指-操作数栈。操作数栈是通过标准的 栈操作 —压栈和出栈来访问的,比如某个指令将一个数据压栈到操作数栈中,那么下一个指令就可以通过出栈操作从操作数栈中获取数据并且使用虚拟机把操作数栈作为它的工作区——大多数指令都要从这里弹出数据,执行运算,然后把结果压回操作数栈。我们通过一个简单的加法例子来说明操作数栈的使用。

    public class User {
        public int sum(){
            int a = 1;
            int b = 2;
            int c = a + b;
            return c;
        }
        public static void main(String[] args) {
            User user = new User();
            user.sum();
        }
    }

我们重点分析sum方法的字节码指令的执行,先使用jclasslib插件查看sum方法的字节码指令,下图就是sum方法要执行的字节码指令:

2022090522480144811.png

我们通过查询JVM的规范文档来解释每一句字节码指令的含义,有需要的同学可以到官网下载JVM规范文档https://docs.oracle.com/javase/specs/index.html

2022090522480231212.png

在这个过程中第一步,会将int类型的1压入操作数栈中临时存储,第二步会将栈顶的1弹出栈然后将1放到局部变量表中(局部变量表中的索引为0的位置默认存储的是this(本类对象),在这里我就不画出来了)。

2022090522480349113.png

第三步、第四步操作的流程和前两步是一样的,先将int类型的2压入操作数栈中,然后让int类型的2出栈然后存入局部变量表中,最终前四步完成之后的结果如下图所示:

2022090522480452914.png

接下来继续分析字节码指令:

2022090522480536415.png

第五步和第六步分别是获取局部变量表中的索引为1和索引为2的数据进行压栈,那么经过第五步和第六步之后的图应该是这样的:

2022090522480663316.png

第七步是弹出操作数栈中的数据(2和1),然后进行加法运算,并且将运算结果(3)进行压栈进入操作数栈,所以运行完之后的结果是:

2022090522480748217.png

第八步是将操作数栈顶的数据弹出并且存储到局部变量表的下标为3的位置,所以运行之后的结果是:

2022090522480841018.png

相信通过上面的分析,大家应该都知道了操作数栈的作用,其实就是用于存储需要操作的临时数据的。

3.3 动态链接(Dynamic Linking)

我们之前再将Linking阶段的时候提到过,在Linking阶段JVM会将类、方法、属性等符号引用解析为直接引用,也就是将常量池中的各种符号引用解析为指针、偏移量等内存地址的直接引用。在这个阶段所做的链接我们称之为静态链接。

每一个栈帧内部都包含有一个指向运行时常量池中该栈帧方法的引用。包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接(Dynamic Linking)。在Java源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用(Symbolic Reference)保存在class文件的常量池。

我们依旧以这段代码为例:

    public class User {   
    	public static void main(String[] args) {        
      	Card card = new Card();        
        card.setNumber(1);    
      }
    }

我们采用jclasslib插件查看其字节码指令:

2022090522480926019.png

我们所看到的上图#开头的就是符号引用,而我们在描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用来表示的,那么动态链接的作用就是为了将这些符号引用转换为调用方法的直接调用(地址引用)。不仅是引用方法的时候会采用动态链接将符合引用转换为直接引用,在引用变量的时候也是如此。

3.4 方法出口

方法出口就是记录方法返回的地址,也就是说一个方法 开始执行后,有2种方式可以退出这个方法 :

  1. 方法返回指令 : 执行引擎遇到一个方法返回的字节码指令(类似上面的ireturn),这时候有可能会有返回值传递给上层的方法调用者,这种退出方式称为正常完成出口。
  2. 异常退出 :在方法执行过程中遇到了异常,并且没有处理这个异常,就会导致方法退出。

但是无论是哪种情况退出,只要是退出方法执行了,都必须得返回到方法被调用的位置,那么JVM怎么才能知道该方法到底是在什么位置被调用的呢?这就是方法出口的作用,方法出口就是记录方法调用的位置的。

4. Java堆(Heap)

Java堆内存是用于存放由new创建的对象和数组,是java虚拟机所管理的内存中最大的一块,它里面的数据是所有线程共享的所以堆中的对象需要考虑线程安全性问题,它也是是垃圾收集器管理的主要区域。

下图是Java堆的结构:

2022090522481047920.png

从上图我们可以看出Java堆包含Young Generation(新生代)和Old Generation(老年代),而且在没有经过人为配置的情况下,新生代会占据对空间的1/3,而老年代会占据对空间的2/3。那么到底什么是新生代,什么是老年代呢?他们是怎么划分的,他们里面又会存储什么数据呢?

4.1 新生代

新生代主要是用来存放新创建的对象。一般占据堆的1/3空间。由于频繁创建对象,所以新生代会频繁触发MinorGC进行垃圾回收。至于什么是MinorGC,我们先暂时将其理解成新生代中的GC,后续会专门有GC的篇章

新生代又分为Eden区和Survivor区,而Survivor区又分为Survivor From区和Survivor To区,它们之间所占的空间的比例为(8:1:1)

  1. Eden区: Java新对象的出生地(如果新创建的对象占用内存很大,则直接分配到老年代)。当Eden区内存不够的时候就会触发MinorGC,对新生代区进行一次垃圾回收。
  2. Survivor From:上一次GC的幸存者,作为这一次GC的被扫描者。
  3. Survivor To:保留这一次MinorGC过程中的幸存者。

总而言之: Eden区就是存放新创建出来的对象,Survivor From区就是存放要被GC扫描的对象,而Survivor To区就是存放经过MinorGC扫描之后还幸存的对象。按照对象的年龄排序的话,应该是 Survivor To区 > Survivor From区 > Eden区

但是每次MinorGC完之后,Survivor To区的内容又会变成Survivor From区的内容,成为下一次MinorGC被扫描者。在发生MinorGC时,Eden区和Survival From区会把一些仍然存活的对象复制进Survival To区,并清除自身内存。Survival To区会把一些存活得足够旧的对象移至年老代。

4.2 老年代

新生代中经过多次MinorGC之后依旧存活的对象会被移动到老年代,所以老年代中的对象很稳定,所以老年代中不会频繁进行MajorGC。至于什么是MajorGC,我们先将其理解为老年代中的GC。在执行MajorGC之前会先进行,会先进行MinorGC,目的是为了让新生代中的对象有机会晋级到老年代。当老年代空间不足的时候,会进行MajorGC,进行老年代空间清理,如果经过MajorGC之后,依然发现没有足够的空间对对象进行保存,那么就会抛出OOM(Out of Memory)异常

小结:对象创建出来的时候先被存放在新生代,当然如果一个对象太大的时候会直接创建出来存储到老年代。新生代中会频繁进行MinorGC,所以新生代中的对象也会经常被销毁。如果一个对象经过多次GC之后依旧存活,就会被移至老年代

4.3 永久代(方法区)

首先声明: 永久代不是堆内存中的。永久代是指内存的永久保存区域 ,主要存放类、常量、静态成员等相关信息。Class在被加载的时候被放入永久区域。它和和存放实例的区域不同,GC不会在主程序运行期对永久区域进行清理。所以这也导致了永久代的区域会随着加载的Class的增多而胀满,最终抛出OOM异常。

在jdk1.8中,永久代已经被移除了,取而代之的是MetaSpace(元数据区),本质和永久代类似,都是对JVM规范中方法区的实现,区别在于元数据区并不在虚拟机中,而是使用本地物理内存,永久代在虚拟机中。

另外关于常量池,jdk1.6及之前常量池是在永久代中,jdk1.7虽然还有永久代,但是常量池已经被放到了堆内存中,jdk1.8已经去除了永久代,常量池放在MetaSpace中。

5. 本地方法栈

对于一个运行中的Java程序而言,它还可能会用到一些跟本地方法相关的数据区。当某一个线程调用本地方法时,便进入了一个全新的不受虚拟机控制的全新的世界,它和虚拟机拥有同样权限。因为他进入了C的运行。本地方法甚至可以访问运行时数据区,直接使用CPU的寄存器。

本地方法栈服务的对象是JVM执行的native方法,而虚拟机栈服务的是JVM执行的java方法。如何去服务native方法?native方法使用什么语言实现?怎么组织像栈帧这种为了服务方法的数据结构?虚拟机规范并未给出强制规定,因此不同的虚拟机可以进行自由实现,我们常用的HotSpot虚拟机选择合并了虚拟机栈和本地方法栈。

综合上述讲解,我们可以得出JVM的内存结构的图示:

2022090522481209021.png

结语

通过今天的学习,我们彻底弄清楚了JVM内存结构,最后留给大家一个思考题:java中String字符串、new String创建的字符串、还有对象中的String字符串,这三者在内存中的存储有何不同;大家先思考思考,下个篇章开头会为大家先进行讲解。