JVM内存模型概述

1.JVM简介

JVM(Java Virtual Machine)是Java虚拟机的缩写,它是Java程序运行的基础环境和平台JVM是一个软件程序,它可以将Java字节码解释或编译成特定计算机系统的机器码,使得Java程序可以在不同的计算机平台上运行。

JVM提供了Java程序运行所需的各种关键功能,包括内存管理垃圾回收即时编译安全性检查线程管理异常处理等。它在运行Java程序时,负责加载字节码文件、创建和管理线程、执行字节码指令以及处理异常等。

JVM的主要组成部分包括以下几个方面:

  1. 类加载器(ClassLoader):负责将Java类的字节码加载到JVM中,并创建对应的Class对象。
  2. 运行时数据区:包括堆、方法区、虚拟机栈、本地方法栈、程序计数器,用于存储程序运行时所需的数据。
  3. 执行引擎:负责执行字节码指令,并将其转换为机器码,可以采用解释执行或即时编译的方式。
  4. 垃圾回收器(Garbage Collector):负责自动回收不再使用的内存空间,确保内存的有效利用。
  5. 本地方法接口(Native Interface):允许Java代码与其他编程语言(如C、C++)进行交互。
  6. JIT编译器(Just-In-Time Compiler):将热点代码(经常执行的代码)编译成机器码,以提高执行效率。

通过JVM的存在,Java程序可以具有平台无关性,只需编写一次程序,然后可以在任何安装了JVM的操作系统上运行。这是因为JVM负责将Java代码转换成特定平台的机器码,屏蔽了底层硬件和操作系统的差异,使得Java程序具有跨平台的特性。

2.JVM内存模型

2.1JVM内存模型

2.1类加载子系统

2.1.1类加载子系统概述

类加载器子系统负责从文件系统或者网络中加载Class文件,并将其转换为 Java 虚拟机可以处理的二进制字节流。类加载过程一共分为3个步骤,分别为加载链接初始化

2.4.1加载阶段

  1. 通过一个类的全限定名获取此类的二进制字节流

  2. 将这个字节流所代表的静态存储结构转化为方法区的元数据

  3. 在堆内存当中生成一个该类的Class对象,作为方法区这个类的各种数据的访问入口。

2.4.2链接阶段

  • **验证(Verify)**:
    • 目的在子确保Class文件的字节流中包含信息符合当前虚拟机要求,保证被加载类的正确性,不会危害虚拟机自身安全。
    • 主要包括四种验证,文件格式验证,元数据验证,字节码验证,符号引用验证。
  • **准备(Prepare)**:
    • 类变量分配内存并且设置该类变量的默认初始值,int类型默认就是0。
    • 这里不包含用final修饰的static,因为final在编译的时候就会分配了,准备阶段会显式初始化;
    • 这里不会为实例变量分配初始化,因为未创建对象。类变量会分配在方法区中,而实例变量是会随着对象一起分配到Java堆中。
  • **解析(Resolve)**:
    • 将常量池内的符号引用转换为直接引用的过程
    • 事实上,解析操作往往会伴随着JVM在执行完初始化之后再执行。
    • 符号引用就是一组符号来描述所引用的目标。符号引用的字面量形式明确定义在《java虚拟机规范》的Class文件格式中。直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。

2.4.3初始化阶段

  • ==初始化阶段就是执行类构造器方法<clinit>()的过程,用于初始化静态变量和执行静态代码块。==

    • 此方法不需定义,是javac编译器自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并而来。如果一个类中不存在变量的赋值动作和静态代码块,执行类构造器方法<clinit>()就不会执行
  • ==构造器方法中指令按语句在源文件中出现的顺序执行。==

    • <clinit>()不同于类的构造器。(关联:构造器是虚拟机视角下的<init>())
  • **若该类具有父类,JVM会保证子类的<clinit>()执行前,父类的<clinit>()已经执行完毕**。

  • 虚拟机必须保证一个类的<clinit>()方法在多线程下被同步加锁。

2.2程序计数器

2.2.1程序计数器概述

**PC寄存器用来存储当前线程的字节码指令地址。由执行引擎读取下一条指令。**在JVM规范中,每个线程都有它自己的程序计数器,是线程私有的,生命周期与线程的生命周期保持一致。

2.2.2程序计数器的具体操作

对于以下字节码信息,程序计数器会存储指令地址,再由执行引擎来读取程序计数器当中的指令地址,根据指令地址取出操作指令

image-20230527142132337

3.2.5程序计数器常见的问题

  1. 为什么使用PC寄存器记录当前线程的指令地址呢?使用PC寄存器存储字节码指令地址有什么用呢?

因为CPU可以在多个线程之间切换执行,在切换回之前执行的线程时,就需要知道线程执行的指令地址,以用来继续执行。

  1. PC寄存器为什么被设定为私有的?

我们都知道所谓的多线程在一个特定的时间段内只会执行其中某一个线程的方法,CPU会不停地做任务切换,这样必然导致经常中断或恢复,如何保证分毫无差呢?**为了能够准确地记录各个线程正在执行的当前字节码指令地址,最好的办法自然是为每一个线程都分配一个PC寄存器,这样一来各个线程之间便可以进行独立计算,从而不会出现相互干扰的情况。**

由于CPU时间片轮转限制,众多线程在并发执行过程中,任何一个确定的时刻,一个处理器或者多核处理器中的一个内核,只会执行某个线程中的一条指令。

这样必然导致经常中断或恢复,如何保证分毫无差呢?每个线程在创建后,都会产生自己的程序计数器和栈帧,程序计数器在各个线程之间互不影响。

2.3虚拟机栈

2.3.1虚拟机栈概述

Java虚拟机栈(Java Virtual Machine Stack)。**每个线程在创建时都会创建一个虚拟机栈,其内部保存一个个的栈帧(Stack Frame),对应着一次次的方法调用。其中栈帧是一个内存区块,是一个数据集,维系着方法执行过程中的各种数据信息。**栈中的结构可以分为局部变量表操作数栈动态链接方法返回地址以及附加信息

2.3.2虚拟机栈的作用

==虚拟机栈用于存储 Java 方法调用过程中的临时数据和函数调用者的地址等信息。==

2.3.3局部变量表

2.3.3.1局部变量表概述

局部变量表也被称之为局部变量数组或本地变量表

  • ==定义为一个数字数组,主要用于存储方法形参和定义在方法体内的局部变量,这些数据类型包括各类基本数据类型、对象引用(reference),以及returnAddress类型。==

  • 由于局部变量表是建立在线程的栈上,是线程的私有数据,因此不存在数据安全问题

  • 局部变量表所需的容量大小是 在编译期确定下来的,并保存在方法的Code属性的maximum local variables数据项中。在方法运行期间是不会改变局部变量表的大小的。

  • 方法嵌套调用的次数由栈的大小决定。一般来说,栈越大,方法嵌套调用次数越多。对一个函数而言,它的参数和局部变量越多,使得局部变量表膨胀,它的栈帧就越大,以满足方法调用所需传递的信息增大的需求。进而函数调用就会占用更多的栈空间,导致其嵌套调用次数就会减少。

  • 局部变量表中的变量只在当前方法调用中有效。在方法执行时,虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程。当方法调用结束后,随着方法栈帧的销毁,局部变量表也会随之销毁。

如下代码:

public class Practise07 {
    public void add(int a, int b) {
        int c = a + b;
    }
}

查看局部变量表:

image-20230527210617041

2.3.3.2局部变量表中的Slot

  • 局部变量表,最基本的存储单元是Slot(变量槽)

  • 参数值的存放总是在局部变量数组的index0开始,到数组长度-1的索引结束。

  • 局部变量表中存放编译期可知的各种基本数据类型(8种),引用类型(reference),returnAddress类型的变量。

  • 在局部变量表里,32位以内的类型只占用一个slot(包括returnAddress类型),64位的类型(long和double)占用两个slot。

  • byte、short、char 在存储前被转换为int,boolean也被转换为int,0表示false,非0表示true。

  • JVM会为局部变量表中的每一个Slot都分配一个访问索引,通过这个索引即可成功访问到局部变量表中指定的局部变量值

  • 当一个实例方法被调用的时候,它的方法参数和方法体内部定义的局部变量将会按照顺序被复制到局部变量表中的每一个slot上

  • 如果需要访问局部变量表中一个64bit的局部变量值时,只需要使用起始索引即可。(比如:访问long或doub1e类型变量)

  • 如果当前帧是由构造方法或者实例方法创建的,那么该对象引用this将会存放在index为0的slot处。

    这就解释了为什么在静态方法中不能使用this来调用类的实例变量,因为在静态方法中的局部变量表中不存在this变量

2.3.4操作数栈

每一个独立的栈帧除了包含局部变量表以外,还包含一个后进先出(Last-In-First-Out)的 操作数栈,也可以称之为表达式栈(Expression Stack) 。**操作数栈,用来存储计算过程中的操作数的临时存储区域。**

操作数栈,在方法执行过程中,根据字节码指令,往栈中写入数据或提取数据,即入栈(push)和 出栈(pop)

  • 某些字节码指令将值压入操作数栈,其余的字节码指令将操作数取出栈。使用它们后再把结果压入栈

  • 比如:执行复制、交换、求和等操作

代码举例

public void testAddOperation(){
    byte i = 15; 
    int j = 8; 
    int k = i + j;
}

字节码指令信息

public void testAddOperation(); 
    Code:
    0: bipush 15 //将int类型的常量15压入操作数栈中
    2: istore_1  //常量15出栈,存储到局部变量表中
    3: bipush 8  //将int类型的常量8压入操作数栈中
    5: istore_2  //常量8出栈,存储到局部变量表中
    6:iload_1    //将局部变量表中索引为1的数据压入到操作数栈中
    7:iload_2 	 //将局部变量表中索引为2的数据压入到操作数栈中
    8:iadd       //相加操作
    9:istore_3   //相加结果出栈,将相加结果存储到局部变量表中索引为3的位置
    10:return
  1. ==PC寄存器(程序计数器)记录指令地址,执行引擎根据PC寄存器当中的指令地址执行指令操作,将15压入操作数栈当中。==

  1. ==指令地址向下移动,变量15出栈,放入到局部变量表中索引为1的位置(索引的位置为this变量,因为该方法为实例方法)。==

  1. ==指令地址向下移动,变量8入栈。==

image-20230527224525575

  1. ==指令地址向下移动,变量8出栈,放入到局部变量表中索引为2的位置。==

image-20230527224634525

  1. ==指令地址向下移动,分别取出局部变量表中索引为1和2位置的数据,也就是15和8,并进行入栈==

image-20230527225017624

image-20230527224942393

  1. ==指令地址向下移动,执行add操作,15和8出栈,并将相加结果23入栈==

image-20230527225434150

  1. ==操作数栈中的23被放到局部变量表中==

image-20230527225959993

  1. 对于以上操作,15和8分别进行入栈、出栈,23进栈,所以操作数栈的最大深度为2。局部变量表的最大长度为4。

image-20230527230110215

2.3.5动态链接

动态链接、方法返回地址、附加信息 : 有些地方被称为帧数据区

每一个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用。包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接(Dynamic Linking)。比如:invokedynamic指令

在Java源文件被编译到字节码文件中时,所有的变量方法引用都作为符号引用(Symbolic Reference)保存在class文件的常量池里。比如:描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用来表示的,那么==动态链接的作用就是将运行时常量池的符号引用转换为调用方法的直接引用==。

img

常量池的作用:**就是为了提供一些符号和常量,便于指令的识别**

2.3.6方法返回地址

**存放调用该方法的pc寄存器的值**。一个方法的结束,有两种方式:

  • 正常执行完成

  • 出现未处理的异常,非正常退出

无论通过哪种方式退出,在方法退出后都返回到该方法被调用的位置。方法正常退出时,调用者的pc计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址。而通过异常退出的,返回地址是要通过异常表来确定,栈帧中一般不会保存这部分信息。

2.3.7一些附加信息

栈帧中还允许携带与Java虚拟机实现相关的一些附加信息。例如:对程序调试提供支持的信息。

2.4本地方法栈

本地方法栈用于管理本地方法(Native Method)的调用

本地方法栈,也是线程私有的。

允许被实现成固定或者是可动态扩展的内存大小。(在内存溢出方面是相同的)

  • 如果线程请求分配的栈容量超过本地方法栈允许的最大容量,Java虚拟机将会抛出一个StackOverflowError 异常。

  • 如果本地方法栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的本地方法栈,那么Java虚拟机将会抛出一个OutOfMemoryError异常。

本地方法是使用C语言实现的。

它的具体做法是Native Method Stack中登记native方法,在Execution Engine 执行时加载本地方法库。

当某个线程调用一个本地方法时,它就进入了一个全新的并且不再受虚拟机限制的世界。它和虚拟机拥有同样的权限。

  • 本地方法可以通过本地方法接口来访问虚拟机内部的运行时数据区。

  • 它甚至可以直接使用本地处理器中的寄存器

  • 直接从本地内存的堆中分配任意数量的内存。

并不是所有的JVM都支持本地方法。因为Java虚拟机规范并没有明确要求本地方法栈的使用语言、具体实现方式、数据结构等。如果JVM产品不打算支持native方法,也可以无需实现本地方法栈。

==在Hotspot JVM中,直接将本地方法栈和虚拟机栈合二为一。==

2.5堆(Heap)

2.5.1堆概述

在 JVM 中,堆是用于存储对象实例和数组的区域。

  • 堆针对一个JVM进程来说是唯一的,也就是一个进程只有一个JVM,进程中的多个线程共享同一堆空间的。
  • ==一个JVM实例只存在一个堆内存,堆也是Java内存管理的核心区域。==
  • ==Java堆区在JVM启动的时候即被创建,其空间大小也就确定了。是JVM管理的最大一块内存空间。==
  • 堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的。
  • 所有的线程共享Java堆。在这里还可以划分线程私有的缓冲区(Thread Local Allocation Buffer,TLAB)。
  • 《Java虚拟机规范》中对Java堆的描述是:所有的对象实例以及数组都应当在运行时分配在堆上。
  • 数组和对象可能永远不会存储在栈上,因为栈帧中保存引用,这个引用指向对象或者数组在堆中的位置。
  • 在方法结束后,堆中的对象不会马上被移除,仅仅在垃圾收集的时候才会被移除。
  • ==堆,是GC(Garbage Collection,垃圾收集器)执行垃圾回收的重点区域。==

2.5.2堆内存细分

Java 7及之前堆内存逻辑上分为三部分:新生代+老年代+永久代

Java 8及之后堆内存逻辑上分为三部分:新生代+老年代+元空间

约定:==新生区(代)<=>年轻代 、 养老区<=>老年区(代)、 永久区<=>永久代==

2.5.3年轻代与老年代

2.5.3.1年轻代和老年代结构划分

存储在JVM中的Java对象可以被划分为两类:

  • ==一类是生命周期较短的瞬时对象,这类对象的创建和消亡都非常迅速==

  • ==另外一类对象的生命周期却非常长,在某些极端的情况下还能够与JVM的生命周期保持一致==

Java堆区进一步细分的话,可以划分为年轻代(YoungGen)和老年代(oldGen)

其中年轻代又可以划分为Eden区、Survivor0区和Survivor1区(有时也叫做from区、to区)

2.5.3.2年轻代和老年代的结构占比

配置新生代与老年代在堆结构的占比。

  • 默认-XX:NewRatio=2,表示新生代占1,老年代占2,新生代占整个堆的1/3

  • 可以修改-XX:NewRatio=4,表示新生代占1,老年代占4,新生代占整个堆的1/5

2.5.3.3年轻代中edeu区和survivor0、survivor1区结构占比

在HotSpot中,Eden空间和另外两个survivor空间缺省所占的比例是8:1:1,但是事实上却不是该比例,是由于自适应策略

当然开发人员可以通过选项“-xx:SurvivorRatio”调整这个空间比例。比如-xx:SurvivorRatio=8

几乎所有的Java对象都是在Eden区被new出来的。绝大部分的Java对象的销毁都在新生代进行了。

  • IBM公司的专门研究表明,新生代中80%的对象都是“朝生夕死”的。

可以使用选项”-Xmn“设置新生代最大内存大小,这个参数一般使用默认值就可以了。

2.5.4堆中对象的分配过程

2.5.4.1堆中对象的分配的步骤

为新对象分配内存是一件非常严谨和复杂的任务,JVM的设计者们不仅需要考虑内存如何分配、在哪里分配等问题,并且由于内存分配算法与内存回收算法密切相关,所以还需要考虑GC执行完内存回收后是否会在内存空间中产生内存碎片。

  1. new的对象先放入Eden区。此区有大小限制。

  2. Edne的空间填满时,程序又需要创建对象,Young GC将对Eden区进行垃圾回收(MinorGC),将Edne区中的不再被其他对象所引用的对象进行销毁。再加载新的对象放到Eden

  3. 然后将Eden中的剩余对象移动到survivor0区,放入survivor区后对象的年龄计数器就会加1。

  4. 如果再次触发垃圾回收,将Eden区中继续使用的对象和上次幸存下来的放到survivor0区的,如果没有回收,就会都放到survivor1区。

  5. 如果再次经历垃圾回收,此时会重新放回survivor0区,接着再去survivor1区。

  6. 啥时候能去老年代呢?可以设置年龄计数器。默认是15次。

    • 可以设置参数:进行设置-Xx:MaxTenuringThreshold= N
  7. 当老年代内存不足时,再次触发GC:Major GC,进行老年代的内存清理

  8. 若老年代执行了Major GC之后,发现依然无法进行对象的保存,就会产生OOM异常。

流程图如下:

总结

  • 对于survivor当中的from区to区是不固定的,当谁为空时,谁就是to区Eden区中继续使用的对象就会放到to区。

  • survivor区满的时候,不会触发垃圾回收机制。而survivor区的垃圾回收时跟随Eden区的垃圾回收进行的。

  • 关于垃圾回收:频繁在新生区收集,很少在老年代收集,几乎不再永久代和元空间进行收集

2.5.4.2堆中对象分配的演示

  1. 使用如下代码进行演示:
import java.util.ArrayList;
import java.util.Random;

public class Practise08 {
    public static void main(String[] args) {
        ArrayList<Picture> list = new ArrayList<>();
        while (true) {
            try {
                Thread.sleep(20);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            list.add(new Picture(new Random().nextInt(1024 * 1024)));
        }
    }
}

class Picture {
    private byte[] pixels;

    public Picture(int length) {
        this.pixels = new byte[length];
    }
}

可以发现下图5:

  1. Eden区内存满后,Yong GC就会对其进行垃圾回收,将继续使用的对象存放在survivor区的to区。
  2. Eden区进行垃圾回收的同时,对survivor区也进行垃圾回收,将超过阈值(15次)的对象放入老年代。
  3. 当老年代内存满的时候就会出现OutOfMemoryError异常。

根据图表,对堆中对象的分配过程进行分析:

image-20230529163609819

2.5.5Minor GC,MajorGC、Full GC

JVM在进行GC时,并非每次都对上面三个内存区域一起回收的,大部分时候回收的都是指新生代

针对Hotspot VM的实现,它里面的GC按照回收区域又分为两大种类型:一种是部分收集(Partial GC)一种是整堆收集(FullGC)

  • 部分收集:不是完整收集整个Java堆的垃圾收集。其中又分为:

    • 新生代收集(Minor GC / Young GC):只是新生代的垃圾收集

    • 老年代收集(Major GC / Old GC):只是老年代的垃圾收集。

      • 目前,只有CMSGC会有单独收集老年代的行为。

      • 注意,很多时候Major GC会和Full GC混淆使用,需要具体分辨是老年代回收还是整堆回收。

    • 混合收集(MixedGC):收集整个新生代以及部分老年代的垃圾收集。

    • 目前,只有G1 GC会有这种行为

  • 整堆收集(Full GC):收集整个java堆和方法区的垃圾收集。

2.5.6GC策略的触发条件

2.5.6.1年轻代GC(Minor GC)触发机制

  • 当年轻代空间不足时,就会触发MinorGC,这里的年轻代满指的是Eden代满,Survivor满不会引发GC。(每次Minor GC会清理年轻代的内存。)

  • 因为Java对象大多都具备朝生夕灭的特性.,所以Minor GC非常频繁,一般回收速度也比较快。这一定义既清晰又易于理解。

  • Minor GC会引发STW,暂停其它用户的线程,等垃圾回收结束,用户线程才恢复运行

2.5.6.2老年代GC(Major GC)触发机制

  • 指发生在老年代的GC,对象从老年代消失时,我们说 “Major GC” 或 “Full GC” 发生了
  • 出现了Major Gc,经常会伴随至少一次的Minor GC(但非绝对的,在Paralle1 Scavenge收集器的收集策略里就有直接进行MajorGC的策略选择过程)
    • 也就是在老年代空间不足时,会先尝试触发Minor Gc。如果之后空间还不足,则触发Major GC
  • Major GC的速度一般会比Minor GC慢10倍以上,STW的时间更长
  • 如果Major GC后,内存还不足,就报OOM了

2.5.6.3Full GC触发机制:

触发Full GC执行的情况有如下五种:

  1. 调用System.gc()时,系统建议执行Full GC,但是不必然执行

  2. 老年代空间不足

  3. 方法区空间不足(JDK1.7及之前的永久代内存不足)

  4. 通过Minor GC后进入老年代的平均大小大于老年代的可用内存

  5. 由Eden区、survivor space0(From Space)区向survivor space1(To Space)区复制时,对象大小大于To Space可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小

说明:Full GC 是开发或调优中尽量要避免的。这样用户线程暂时时间会短一些

2.6方法区(Method Area)

2.6.1方法区概述

方法区是Java虚拟机(JVM)中的一个重要内存区域,用于存储已被虚拟机加载的类型信息运行时常量池静态变量即时编译器编译后的代码缓存等。它是所有线程共享的内存区域,与堆、栈不同。方法区在JVM启动时被创建,它存在于物理内存中,并且具有固定大小。

2.6.2虚拟机栈、堆和方法区的交互关系

在Java虚拟机栈的局部变量表中,存储的对象引用指向堆空间中的对象实例。而堆空间中的对象实例包含一个称为”对象类型指针“的字段,它指向方法区中对应的对象类型。

2.6.3HotSpot中方法区的演进

在jdk7及以前,习惯上把方法区,称为永久代。jdk8开始,使用元空间取代了永久代。

本质上,方法区和永久代并不等价元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代最大的区别在于:元空间不在虚拟机设置的内存中,而是使用本地内存。

2.6.4方法区和元空间or永久代之间的关系

方法区和永久代以及元空间的关系很像 Java 中接口和类的关系,类实现了接口,这里的类就可以看作是永久代和元空间,接口可以看作是方法区,也就是说永久代以及元空间是 HotSpot 虚拟机对虚拟机规范中方法区的两种实现方式。

并且,永久代是 JDK 1.8 之前的方法区实现,JDK 1.8 及以后方法区的实现便成为元空间。

image-20230604112050519

2.6.5方法区的内部结构

2.6.5.1方法区存储什么?

它用于存储已被虚拟机加载的类型信息运行时常量池静态变量即时编译器编译后的代码缓存等。

image-20230603233937392

2.6.5.2 方法区的内部结构

  1. 类型信息

对每个加载的类型(类class、接口interface、枚举enum、注解annotation),JVM必须在方法区中存储以下类型信息:

  1. 这个类型的完整有效名称(全名=包名.类名)

  2. 这个类型直接父类的完整有效名(对于interface或是java.lang.object,都没有父类)

  3. 这个类型的修饰符(public,abstract,final的某个子集)

  4. 这个类型直接接口的一个有序列表

  1. 域信息

域(Field)也就是属性。

JVM必须在方法区中保存类型的所有域的相关信息以及域的声明顺序。

域的相关信息包括:域名称、域类型、域修饰符(public,private,protected,static,final,volatile,transient的某个子集)

  1. 方法信息

JVM必须保存所有方法的以下信息,同域信息一样包括声明顺序:

  1. 方法名称
  2. 方法的返回类型(或void)
  3. 方法参数的数量和类型(按顺序)
  4. 方法的修饰符(public,private,protected,static,final,synchronized,native,abstract的一个子集)
  5. 方法的字节码(bytecodes)、操作数栈、局部变量表及大小(abstract和native方法除外)
  6. 异常表(abstract和native方法除外)
    • 每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移地址、被捕获的异常类的常量池索引

2.6.6Class文件常量池、运行时常量池、字符串常量池

2.6.6.1Class文件常量池和运行时常量池的关系

Class文件常量池是指编译生成的字节码文件结构中的一个常量池,用于存放编译期间生成的字面量和符号引用。而在类加载时(链接阶段的解析步骤),会将Class文件常量池当中的字面量和符号引用以及将符号引用转换为对应后的直接引用,存储在运行时常量池当中。

  • 字节码文件,内部包含了常量池
  • 方法区,内部包含了运行时常量池
image-20230604090918352

2.6.6.2Class文件常量池

Class 文件常量池是 Java 类文件结构中的一部分,用于存放编译器生成的各种字面量和符号引用。它不是 JVM 结构的一部分,而是字节码文件中的内容。

一个有效的字节码文件中除了包含类的版本信息、字段、方法以及接口等描述符信息外,还包含一项信息就是常量池表(Constant Pool Table),包括各种字面量和对类型、域和方法的符号引用

常量池、可以看做是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等类型。即根据符号引用找到直接引用。

常量池中有什么?

常量池内存储的数据类型包括字面量和符号引用

  • CONSTANT_Utf8_info:表示字符串类型的常量。
  • CONSTANT_Integer_info:表示整型字面量。
  • CONSTANT_Float_info:表示浮点型字面量。
  • CONSTANT_Long_info:表示长整型字面量。
  • CONSTANT_Double_info:表示双精度浮点型字面量。
  • CONSTANT_Class_info:表示类或接口的符号引用。
  • CONSTANT_String_info:表示字符串类型字面量的引用。
  • CONSTANT_Fieldref_info:表示字段的符号引用。
  • CONSTANT_Methodref_info:表示类中方法的符号引用。
  • CONSTANT_InterfaceMethodref_info:表示接口中方法的符号引用。
  • CONSTANT_NameAndType_info:表示字段或方法的部分符号引用。
  1. 根据符号引用指向类引用,创建一个StringBuilder
image-20230604094651212
  1. 符号引用指向字符串值,指向字符串常量
image-20230604094958453

2.6.6.3运行时常量池

运行时常量池是方法区的一部分。而在类加载时(链接阶段的解析步骤),会将Class文件常量池当中的字面量和符号引用以及将符号引用转换为对应后的直接引用,存储在运行时常量池当中。

运行时常量池相对于Class文件常量池的另一个重要特征是具备动态性,运行期间也可以将新的常量池放入运行时常量池中。它的字面量是可以动态添加的(String类的intern()方法),符号引用可以被解析为直接引用。

  • 动态链接是在程序运行期间完成的,将符号引用替换成直接引用。在类加载阶段,虚拟机会将Class文件中的符号引用保存到运行时常量池中,并将其中一些符号引用转换为直接引用。但是,并不是所有的符号引用都会在类加载阶段被转换为直接引用。有些符号引用需要在每次运行期间才能转化为直接引用,这种转换就叫做动态链接。

2.6.6.4字符串常量池

字符串常量池在JDK1.7的时候从方法区当中的运行时常量池中分离出来并放到堆当中。在JDK1.8的时候使用元空间替换掉了永久代,字符串常量池依旧存在于堆当中。

字符串常量池是用于存储字符串常量。它的目的是为了避免字符串的重复创建。当我们在程序中创建一个字符串常量时,JVM会首先检查字符串常量池中是否已经存在该字符串。如果存在,JVM会直接返回该字符串的引用;如果不存在,JVM会在字符串常量池中创建一个新的字符串,并返回其引用。

下面是一些关于字符串常量池的例子:

String s1 = "hello";
String s2 = "hello";
System.out.println(s1 == s2); // 输出true

上面的代码中,我们创建了两个字符串常量"hello"。由于它们具有相同的值,所以JVM只会在字符串常量池中创建一个"hello"字符串,并返回其引用。因此,s1s2指向的是同一个字符串对象,所以它们相等。

String s1 = new String("hello");
String s2 = "hello";
System.out.println(s1 == s2); // 输出false

使用new String("hello")创建字符串对象时,JVM会在堆内存中创建一个新的字符串对象,并将其引用赋值给s1。同时,JVM也会检查字符串常量池中是否已经存在"hello"这个字符串。如果不存在,JVM会在字符串常量池中创建一个新的"hello"字符串。因此,总共创建了两个对象。

2.6.7方法区的演进细节

  1. 首先明确:只有Hotspot才有永久代。BEA JRockit、IBMJ9等来说,是不存在永久代的概念的。原则上如何实现方法区属于虚拟机实现细节,不受《Java虚拟机规范》管束,并不要求统一

  2. Hotspot中方法区的变化:

版本 说明
JDK1.6及之前 有永久代(permanet),静态变量存储在永久代上
JDK1.7 有永久代,但已经逐步 “去永久代”,字符串常量池,静态变量移除,保存在堆中
JDK1.8 永久代,取而代之的是元空间类型信息,字段,方法,常量保存在本地内存的元空间(方法区),但字符串常量池、静态变量仍然在堆中。
  1. 所以说JDK1.6及之前字符串常量池是在方法区当中的运行时常量池当中,而静态变量就是存储在方法区当中


2. ​ JDK1.7的时候分别将字符串常量池静态变量方法区常量池方法区中移到堆空间中。


3. JDK1.8及之后使用元空间代替了永久代,并使用本地内存。字符串常量池和静态变量依旧存在于堆空间中。

2.6.8为什么要使用元空间替换永久代

  1. 降低OOM

当使用永久代实现方法区时,永久代的最大容量受制于 PermSize 和 MaxPermSize 参数设置的大小,而这两个参数的大小又很难确定,因为在程序运行时需要加载多少类是很难估算的,如果这两个参数设置的过小就会频繁的触发 FullGC 和导致 OOM(Out of Memory,内存溢出)。

但是,当使用元空间替代了永久代之后,出现 OOM 的几率就被大大降低了,因为元空间使用的是本地内存,从而大大降低了 OOM 的问题。

  1. 降低维护成本

因为元空间使用的是本地内存,这样就无需专门设置和调整元空间的大小了。

2.6.9为什么要将字符串常量池调整到堆

因为永久代(方法区实现)的 GC 回收效率太低,只有在整堆收集 (Full GC)的时候才会被执行 GC。Java 程序在运行过程中通常会有大量的被创建的字符串等待回收,最终可能会导致永久代空间不足。

==将字符串常量池放到堆中,能够更高效及时地回收内存。==

3.JVM的执行流程

对于下图的执行信息如下:

  • java源代码通过编译后得到字节码文件,由类加载子系统中的类加载器将字节码转换为二进制字节流,并放入运行时数据区。
  • 类信息放入到方法区当中。
  • 堆内存当中存储对象实例。
  • 虚拟机栈当中存储方法调用过程当中的临时数据和调用者的地址信息。
    • 局部变量表中存储方法的形参和局部变量
    • 操作数栈作为在计算过程中操作数的临时存储空间
    • 动态链接将运行时常量池当中的符号引用转换为方法调用时的直接引用
  • 程序计数器记录指令地址,并由执行引擎读取指令地址,执行相应的操作指令。
  • 执行引擎将解析后的字节码逐条解释或者即时编译执行。

image-20230603232428082


JVM内存模型概述
https://xhablog.online/2023/06/24/JVM内存模型概述/
作者
Xu huaiang
发布于
2023年6月24日
许可协议