【JVM】内存结构

408 阅读31分钟

JDK、JRE、JVM

JavaSE:Java 平台标准版,为 Java EE 和 Java ME 提供了基础。

JDK:Java 开发工具包,JDK 是 JRE 的超集,包含 JRE 中的所有内容,以及开发程序所需的编译器和调试程序等工具。

JRE:Java SE 运行时环境 ,提供库、Java 虚拟机和其他组件来运行用 Java 编程语言编写的程序。主要类库,包括:程序部署发布、用户界面工具类、继承库、其他基础库,语言和工具基础库。

JVM:java 虚拟机,负责JavaSE平台的硬件和操作系统无关性、编译执行代码(字节码)和平台安全性。

image-20210816153922528

JVM 全称 Java Virtual Machine,也就是我们耳熟能详的 Java 虚拟机。它能识别 .class 后缀的文件,并且能够解析它的指令,最终调用操作系统上的函数,完成我们想要的操作。

一个 Java 程序,首先需要经过 javac 编译成 .class 文件,然后 JVM 将其加载到方法区,执行引擎将会执行这些字节码。执行时,会翻译成操作系统相关的函数。JVM 作为 .class 文件的翻译存在,输入字节码,调用操作系统函数。

过程如下:Java 文件--->编译器--->字节码--->JVM--->机器码。

注:JVM是一个虚拟化的操作系统,所以除了要虚拟指令之外,最重要的一个事情就是需要虚拟化内存,这个虚拟化内存就是JVM的内存区域。

Java程序运行过程

image-20210816225907214

cpu + 缓存 + 内存
​
执行引擎 + 操作数栈 + 堆

JVM的内存区域

Java 虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域 ,分析JVM内存结构,主要就是分析 JVM 运行时数据存储区域。

JVM 的运行时数据区主要 包括:堆、栈、方法区、程序计数器等。

按照线程的关系可分为:线程私有和线程共享。

同时,这里还有一个直接内存,这个虽然不是运行时数据区的一部分,但是会被频繁使用。你可以理解成没有被虚拟机化的操作系统上的其他内存(比如操作系统上有 8G 内存,被 JVM 虚拟化了 3G,那么还剩余 5G,JVM 是借助一些工具使用这 5G 内存的,这个内存部分称之为直接内存)。

image-20210816161959892

其他常见的结构图:

image-20210816171403685

线程私有

程序计数器

程序计数器(Program Counter Register)可以看作是当前线程所执行字节码的行号指示器,指向下一个将要执行的指令代码,由执行引擎来读取下一条指令。更确切的说,一个线程的执行,是通过字节码解释器改变当前线程的计数器的值,来获取下一条需要执行的字节码指令,从而确保线程的正确执行。

程序计数器是一块很小的内存空间,主要用来记录各个线程执行的字节码的地址,例如,分支、循环、跳转、异常、线程恢复等都依赖于计数器。

由于 Java 是多线程语言,当执行的线程数量超过 CPU 核数时,线程之间会根据时间片轮询争夺 CPU 资源。如果一个线程的时间片用完了,或者是其它原因导致这个线程的 CPU 资源被提前抢夺,那么这个退出的线程就需要单独的一个程序计数器,来记录下一条运行的指令。

因为 JVM 是虚拟机,内部有完整的指令与执行的一套流程,所以在运行 Java 方法的时候需要使用程序计数器(记录字节码执行的地址或行号),如果是遇到本地方法(native 方法),这个方法不是 JVM 来具体执行,所以程序计数器不需要记录了,这个是因为在操作系统层面也有一个程序计数器,这个会记录本地代码的执行的地址,所以在执行 native 方法时,JVM 中程序计数器的值为空(Undefined)。另外程序计数器也是 JVM 中唯一不会 OOM(OutOfMemory)的内存区域

因此,在JVM中为什么需要程序计数器,主要有以下两点:

  • Java是多线程的,意味着线程切换

    为了确保线程切换后(上下文切换)能恢复到正确的执行位置,每个线程都有一个独立的程序计数器,各个线程的计数器互不影响,独立存储。也就是说程序计数器是线程私有的内存。

  • 确保多线程情况下的程序正常执行

举个栗子,假如你是面试官正在面试,面试过程中突然老板给你打个电话说面试完一起吃饭,那此时面试这一行为是暂停的,当电话结束之后你还要面试,那么此时你不可能把之前的问题在问一遍,而是会接着接电话之前的问题接着问。

Java虚拟机栈

实际上就是满足先进后出(FILO)的性质,是一种数据项按序排列的数据结构,只能在一端(称为栈顶(top))对数据项进行插入和删除。JVM中的栈包括 Java 虚拟机栈本地方法栈,两者的区别就是,Java 虚拟机栈为 JVM 执行 Java 方法服务,本地方法栈则为 JVM 使用到的 Native 方法服务。

虚拟机栈的作用: 在 JVM 运行过程中存储当前线程运行方法所需的数据,指令、返回地址

虚拟机栈是基于线程的:哪怕你只有一个 main() 方法,也是以线程的方式运行的。在线程的生命周期中,参与计算的数据会频繁地入栈和出栈,栈的生命周期是和线程一样的。

虚拟机栈的大小缺省为 1M,可用参数 –Xss 调整大小,例如-Xss256k。在官方文档中可以看到一些常见平台的默认值。如下:

image-20210816165517849

当前线程运行方法所需的数据,指令、返回地址是怎么存储的?

每个程序在执行的过程中都是一个私有的线程,线程在运行时,每个被执行的方法都会被打包成一个栈帧,并入栈。一旦方法完成相应的调用,则出栈。

栈帧大体都包含四个区域:(局部变量表、操作数栈、动态连接、返回地址)

image-20210816170617761

局部变量表:顾名思义就是局部变量的表,用于存放我们的局部变量的。首先它是一个 32 位的长度,主要存放我们的 Java 的八大基础数据类型,一般 32 位就可以存放下,如果是 64 位的就使用高低位占用两个也可以存放下,如果是局部的一些对象,比如我们的 Object 对象,我们只需要存放它的一个引用地址即可。(基本数据类型、对象引用、returnAddress 类型)

操作数据栈:存放我们方法执行的操作数的,它就是一个栈,先进后出的栈结构,操作数栈,就是用来操作的,操作的的元素可以是任意的 java 数据类型,所以我们知道一个方法刚刚开始的时候,这个方法的操作数栈就是空的,操作数栈运行方法是会一直运行入栈/出栈的操作。

动态连接:每个栈帧都包含一个指向运行时常量池中该栈帧所属性方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。在Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用为参数。这些符号引用一部分会在类加载阶段或第一次使用的时候转化为直接引用,这种转化称为静态解析。另外一部分将在每一次的运行期期间转化为直接引用,这部分称为动态连接。

返回地址:当一个方法被执行后,有两种方式退出这个方法。第一种方式是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者(调用当前方法的的方法称为调用者),是否有返回值和返回值的类型将根据遇到何种方法返回指令来决定,这种退出方法方式称为正常完成出口(Normal Method Invocation Completion)。另外一种退出方式是,在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,无论是Java虚拟机内部产生的异常,还是代码中使用athrow字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方式称为异常完成出口(Abrupt Method Invocation Completion)。一个方法使用异常完成出口的方式退出,是不会给它的调用都产生任何返回值的。 无论采用何种方式退出,在方法退出之前,都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层方法的执行状态。一般来说,方法正常退出时,调用者PC计数器的值就可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器来确定的,栈帧中一般不会保存这部分信息。 方法退出的过程实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用都栈帧的操作数栈中,调用PC计数器的值以指向方法调用指令后面的一条指令等。

当然, 虚拟机栈这个内存也不是无限大,它有大小限制,默认情况下是 1M。如果我们不断的往虚拟机栈中入栈帧,但是就是不出栈的话,那么这个虚拟机栈就会爆掉,也就是StackOverflowError异常。

image-20210816171745711

Java中方法在虚拟机栈的执行过程 给定下面一个程序

/**
 * 虚拟机栈
 */
public class JavaStack {
​
    public void getMoney(int money) {
        money = money + 100;
    }
​
    public static void main(String[] args) {
        JavaStack javaStack = new JavaStack();
        javaStack.getMoney(10000);
    }
}

getMoney方法的字节码信息如下(查看字节码的方法:javap -c Xxxx.class):

    descriptor: (I)V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=2, args_size=2
         0: iload_1
         1: bipush        100
         3: iadd
         4: istore_1
         5: return
      LineNumberTable:
        start Length Slot Name Signature
         0      6     0   this    ...
         0      6     1   money   ...

对于getMoney方法来讲,它就是一个栈帧

image-20210816173523240

  1. 0: iload_1 :表示下标位1的int的局部变量表入栈

    image-20210816173827448

  2. 1: bipush 100:将一个byte类型常量入栈

    image-20210816173927685

  3. 3: iadd:将栈顶两个int的数据出栈,相加,结果入栈

    image-20210816174011456

  4. 4: istore_1:将栈顶的int类型, "_1" 存入局部变量 "1" 的位置

    image-20210816174112340

附:Java字节码指令大全

image-20210816174728633

本地方法栈

本地方法栈跟 Java 虚拟机栈的功能类似,Java 虚拟机栈用于管理 Java 函数的调用,而本地方法栈则用于管理本地方法的调用。但本地方法并不是用 Java 实现的,而是由 C 语言实现的(比如 Object.hashcode 方法)。

本地方法栈是和虚拟机栈非常相似的一个区域,它服务的对象是 native 方法。你甚至可以认为虚拟机栈和本地方法栈是同一个区域。

虚拟机规范无强制规定,各版本虚拟机自由实现 ,HotSpot 直接把本地方法栈和虚拟机栈合二为一

线程共享

方法区

方法区(Method Area)是可供各条线程共享的运行时内存区域。它存储了每一个类的结构信息,例如运行时常量池(Runtime Constant Pool),字段和方法数据、构造函数和普通方法的字节码内容、还包括一些在类、实例、接口初始化时用到的特殊方法。

方法区是 JVM 对内存的“逻辑划分”,在 JDK1.7 及之前很多开发者都习惯将方法区称为“永久代”,是因为在 HotSpot 虚拟机中,设计人员使用了永久代来实现了 JVM 规范的方法区。在 JDK1.8 及以后使用了元空间来实现方法区。

JVM 在执行某个类的时候,必须先加载。在加载类(加载、验证、准备、解析、初始化)的时候,JVM 会先加载 class 文件,而在 class 文件中除了有类的版本、字段、方法和接口等描述信息外,还有一项信息是常量池 (Constant Pool Table),用于存放编译期间生成的各种字面量符号引用

常量池与运行时常量池

而当类加载到内存中后,JVM 就会将 class 文件常量池中的内容存放到运行时的常量池中;在解析阶段,JVM 会把符号引用替换为直接引用(对象的索引值)。例如,类中的一个字符串常量在 class 文件中时,存放在 class 文件常量池中的;在 JVM 加载完类之后,JVM 会将这个字符串常量放到运行时常量池中,并在解析阶段,指定该字符串对象的索引值。

运行时常量池是全局共享的,多个类共用一个运行时常量池,class 文件中常量池多个相同的字符串 在运行时常量池只会存在一份。

常量池有很多概念,包括运行时常量池、class 常量池、字符串常量池(后续会仔细讲到,也可直接跳到最后) 。虚拟机规范只规定以上区域属于方法区,并没有规定虚拟机厂商的实现。严格来说是静态常量池和运行时常量池,静态常量池是存放字符串字面量、符号引用以及类和方法的信息,而运行时常量池存放的是运行时一些直接引用。

运行时常量池是在类加载完成之后,将静态常量池中的符号引用值转存到运行时常量池中,类在解析之后,将符号引用替换成直接引用。

运行时常量池在 JDK1.7 版本之后,就移到堆内存中了,这里指的是物理空间,而逻辑上还是属于方法区(方法区是逻辑分区)。

元空间

方法区与堆空间类似,也是一个共享内存区,所以方法区是线程共享的。假如两个线程都试图访问方法区中的同一个类信息,而这个类还没有装入 JVM,那么此时就只允许一个线程去加载它,另一个线程必须等待。

在 HotSpot 虚拟机、Java7 版本中已经将永久代的静态变量和运行时常量池转移到了堆中,其余部分则存储在 JVM 的非堆内存中,而 Java8 版本已经将方法区中实现的永久代去掉了,并用元空间(class metadata)代替了之前的永久代,并且元空间的存储位置是本地内存。

Java8 为什么使用元空间替代永久代,这样做有什么好处呢?

官方给出的解释是:

移除永久代是为了融合 HotSpot JVM 与 JRockit VM 而做出的努力,因为 JRockit 没有永久代,所以不需要配置永久代。

永久代内存经常不够用或发生内存溢出,抛出异常 java.lang.OutOfMemoryError: PermGen。这是因为在 JDK1.7 版本中,指定的 PermGen 区大小为8M,由于 PermGen 中类的元数据信息在每次 FullGC 的时候都可能被收集,回收率都偏低,成绩很难令人满意。

还有为 PermGen 分配多大的空间很难确定,PermSize 的大小依赖于很多因素,比如,JVM 加载的 class 总数、常量池的大小和方法的大小等。

元空间大小参数:

jdk1.7 及以前(初始和最大值):-XX:PermSize;-XX:MaxPermSize; jdk1.8 以后(初始和最大值):-XX:MetaspaceSize; -XX:MaxMetaspaceSize jdk1.8 以后大小就只受本机总内存的限制(如果不设置参数的话)

堆是 JVM 上最大的内存区域,我们申请的几乎所有的对象,都是在这里存储的。我们常说的垃圾回收,操作的对象就是堆。

堆空间一般是程序启动时,就申请了,但是并不一定会全部使用。堆一般设置成可伸缩的。随着对象的频繁创建,堆空间占用的越来越多,就需要不定期的对不再使用的对象进行回收。这个在 Java 中,就叫作 GC(Garbage Collection)。

那一个对象创建的时候,到底是在堆上分配,还是在栈上分配呢?这和两个方面有关:对象的类型和在 Java 类中存在的位置。

Java 的对象可以分为基本数据类型和普通对象。

  • 对于普通对象来说,JVM 会首先在堆上创建对象,然后在其他地方使用的其实是它的引用。比如,把这个引用保存在虚拟机栈的局部变量表中。

  • 对于基本数据类型来说(byte、short、int、long、float、double、char),有两种情况。

    • 当你在方法体内声明了基本数据类型的对象,它就会在栈上直接分配。
    • 其他情况,都是在堆上分配。

堆大小参数:

-Xms:堆的最小值; -Xmx:堆的最大值; -Xmn:新生代的大小; -XX:NewSize;新生代最小值; -XX:MaxNewSize:新生代最大值; 例如- Xmx256m

直接内存(堆外内存)

直接内存有一种更加科学的叫法,堆外内存。

JVM 在运行时,会从操作系统申请大块的堆内存,进行数据的存储;同时还有虚拟机栈、本地方法栈和程序计数器,这块称之为栈区。操作系统剩余的内存也就是堆外内存。

它不是虚拟机运行时数据区的一部分,也不是 java 虚拟机规范中定义的内存区域;如果使用了 NIO,这块区域会被频繁使用,在 java 堆内可以用directByteBuffer 对象直接引用并操作。

这块内存不受 java 堆大小限制,但受本机总内存的限制,可以通过-XX:MaxDirectMemorySize 来设置(默认与堆内存最大值一样),所以也会出现 OOM 异常。

深入理解运行时数据区

上面讲了一堆概念性的东西,有的比较难理解,继续往下看。

JVM内存处理流程

分析一个实例代码运行在JVM内存处理全流程,如下:

image-20210818000908882

  1. JVM 向操作系统申请内存

    JVM 第一步就是通过配置参数或者默认配置参数向操作系统申请内存空间,根据内存大小找到具体的内存分配表,然后把内存段的起始地址和终止地址分配给 JVM,接下来 JVM 就进行内部分配。

  2. JVM 获得内存空间后,会根据配置参数分配堆、栈以及方法区的内存大小,我们这里把他设置为-Xms30m -Xmx30m -Xss1m -XX:MaxMetaspaceSize=30m

    image-20210817164947948

  3. 类加载

    这里主要是把 class 放入方法区、还有 class 中的静态变量和常量也要放入方法区。

  4. 执行方法及创建对象

    启动 main 线程,执行 main 方法,开始执行第一行代码。此时堆内存中会创建一个 Teacher对象,对象引用 T1就存放在栈中。后续代码中遇到 new 关键字,会再创建一个 Teacher对象,对象引用就存放在栈中。

    image-20210817171916387

JVM 在操作系统上启动,申请内存,先进行运行时数据区的初始化,然后把类加载到方法区,最后执行方法。 方法的执行和退出过程在内存上的体现上就是虚拟机栈中栈帧的入栈和出栈。同时在方法的执行过程中创建的对象(new出来的)一般情况下都是放在堆中,最后堆中的对象也是需要进行垃圾回收清理的。

堆空间分代划分

堆被划分为新生代和老年代(Tenured),新生代又被进一步划分为 EdenSurvivor 区,最后 SurvivorFrom SurvivorTo Survivor 组成。常说的GC就发生在堆中。

image-20210817173644553

什么是GC

GC(Garbage Collection)垃圾回收,在 JVM 中是自动化的垃圾回收机制,我们一般不用去关注,在 JVM 中 GC 的重要区域是堆空间。我们也可以通过一些额外方式主动发起它,比如 System.gc(),主动发起。

JHSDB可视化工具

JHSDB 是一款基于服务性代理实现的进程外调试工具。服务性代理是 HotSpot 虚拟机中一组用于映射 Java 虚拟机运行信息的,主要基于 Java 语言实现的API 集合。

启动

JDK8

启动 JHSDB 的时候必须将 sawindbg.dll(一般会在 JDK 的目录下)复制到对应目录的 jre 下(注意在 win 上安装了 JDK8 后往往同级目录下有一个jre 的目录)。然后到F:\softwares\jdk8\lib目录下执行java -cp .\sa-jdi.jar sun.jvm.hotspot.HSDB。如下:

image-20210817175714166

JDK9及后续版本

进入 JDK 的 bin 目录下,我们可以在命令行中使用 jhsdb hsdb 来启动它。

在之前的代码的VM参数加入XX:+UseConcMarkSweepGC-XX:-UseCompressedOops指定垃圾回收器。

image-20210817190643291

在之前的main方法中手动调用GC

image-20210818000946135

JHSDB 操作

因为 JVM 启动有一个进程,需要借助命令 jps 查找到对应程序的进程,如下

image-20210817192820894

然后通过进程id在JHSDB中查看

image-20210817193513282

查看堆参数

image-20210817193734883

image-20210817231013058

上图中可以看到实际 JVM 启动过程中堆中参数的对照,可以看到,在不启动内存压缩的情况下,堆空间里面的分代划分都是连续的

查看对象

这里可以看到 JVM 中所有的对象,都是基于 class 的对象

image-20210818093428616

我们找到JVMObject类中的对象,如下:

image-20210818095242312

image-20210818095708162

image-20210818095755362

在之前的代码中手动调用了15次(后面在gc部分再讲)GC,所以对象T1进入老年代,而T2则再新生代的Eden区。

image-20210818102006794

查看栈信息

查看main方法的栈信息

image-20210818102613547

image-20210818102941807

从上图中可以验证栈内存,同时也可以验证到虚拟机栈和本地方法栈在 Hotspot 中是合二为一的实现了。

了解虚拟机内存优化技术---栈帧之间数据的共享

在一般的模型中,两个不同的栈帧的内存区域是独立的,但是大部分的 JVM 在实现中会进行一些优化,使得两个栈帧出现一部分重叠(主要体现在方法中有参数传递的情况)。让下面栈帧的操作数栈和上面栈帧的部分局部变量重叠在一起,这样做不但节约了一部分空间,更加重要的是在进行方法调用时就可以直接公用一部分数据,无需进行额外的参数复制传递了。

如下面的代码执行后的栈信息

image-20210818113440494

image-20210818110831292

了解内存溢出

内存溢出(Out Of Memory,简称OOM)是指应用系统中存在无法回收的内存或使用的内存过多,最终使得程序运行要用到的内存大于能提供的最大内存。此时程序就运行不了,系统会提示内存溢出,有时候会自动关闭软件,重启电脑或者软件后释放掉一部分内存又可以正常运行该软件,而由系统配置数据流、用户代码等原因而导致的内存溢出错误,即使用户重新执行任务依然无法避免。---百度百科

顺便再说说内存溢出和内存泄漏的区别,个人理解:

  • 内存溢出:程序运行所需要的内存大于所提供的内存。
  • 内存泄漏:程序运行时划分了内存,但是程序执行完成后对象没有被回收,处于一直存活的状态,比如使用ThreadLocal之后没有remove
  • 两者关系:内存泄漏过多之后就会造成内存溢出。怎么理解?多线程执行同一个内存泄漏的程序,也就是占用过多的内存之后,超出了规定的内存大小,自然就溢出了。

栈溢出

我们把栈的内存大小默认为1m:-Xss1m,下面的代码可演示栈溢出

image-20210818135656259

HotSpot 版本中栈的大小是固定的,是不支持拓展的。

java.lang.StackOverflowError 一般的方法调用是很难出现的,如果出现了可能会是无限递归。

虚拟机栈带给我们的启示:方法的执行因为要打包成栈桢,所以天生要比实现同样功能的循环慢,所以树的遍历算法中:递归和非递归(循环来实现)都有存在的意义。递归代码简洁,非递归代码复杂但是速度较快。

OutOfMemoryError:不断建立线程,JVM 申请栈内存,机器没有足够的内存。(一般演示不出,演示出来机器也死了)

同时要注意,栈区的空间 JVM 没有办法去限制的,因为 JVM 在运行过程中会有线程不断的运行,没办法限制,所以只限制单个虚拟机栈的大小。

堆溢出

内存直接溢出:申请内存空间,超出最大堆内存空间。如果是内存溢出,则通过 调大 -Xms,-Xmx 参数。

设置VM Args:-Xms30m -Xmx30m -XX:+PrintGCDetails

image-20210818135500849

在工作中还可能会遇到这样的一个异常:GC overhead limit exceeded,如下面的代码

image-20210818143022006

这种情况不是内存直接溢出,就是说内存中的对象却是都是必须存活的,也就是达到一定的量才会溢出,就好比水杯装水,刚开始是空的,接水的时候不满就会一直接,但是如果你没注意,当水满了,这个时候就溢出了,这个过程就类似于内存溢出。但是如果在要满的时候你喝几口再去接,那杯子又可以重新接水,这个过程可以在逻辑上理解为GC调优。

但是既然有GC调优为什么还会溢出呢?官方给出的原因是:

超过98%的时间用来做GC并且回收了不到2%的堆内存时会抛出此异常。

怎么解决?

  1. 那么就应该检查 JVM 的堆参数设置,与机器的内存对比,看是否还有可以调整的空间。
  2. 查看项目中是否有大量的死循环或有使用大内存的代码,优化代码。
  3. 增大堆内存。

方法区溢出

方法区溢出有以下两种情况:

  1. 运行时常量池溢出

  2. 方法区中保存的 Class 对象没有被及时回收掉或者 Class 信息占用的内存超过了我们配置。而 Class 要被回收,条件比较苛刻(仅仅是可以,不代表必然,因为还有一些参数可以进行控制)

    • 该类所有的实例都已经被回收,也就是堆中不存在该类的任何实例。
    • 加载该类的 ClassLoader 已经被回收。
    • 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

CGLIB 是一个强大的,高性能,高质量的 Code 生成类库,它可以在运行期扩展 Java 类与实现 Java 接口。CGLIB 包的底层是通过使用一个小而快的字节码处理框架 ASM,来转换字节码并生成新的类。除了 CGLIB 包,脚本语言例如 Groovy 和 BeanShell,也是使用 ASM 来生成 java 的字节码。当然不鼓励直接使用 ASM,因为它要求你必须对 JVM 内部结构包括 class 文件的格式和指令集都很熟悉。

本机直接内存溢出

直接内存的容量可以通过 MaxDirectMemorySize 来设置(默认与堆内存最大值一样),所以也会出现 OOM 异常;由直接内存导致的内存溢出,一个比较明显的特征是在 HeapDump 文件中不会看见有什么明显的异常情况,如果发生了 OOM,同时 Dump 文件很小,可以考虑重点排查下直接内存方面的原因。

image-20210818152812290

深入理解常量池

Class常量池

在 class 文件中除了有类的版本、字段、方法和接口等描述信息外,还有一项信息是常量池 (Constant Pool Table),用于存放编译期间生成的各种字面量和符号引用

符号引用

一个 java 类(假设为 People 类)被编译成一个 class 文件时,如果 People 类引用了 Tool 类,但是在编译时 People 类并不知道引用类的实际内存地址,因此只能使用符号引用来代替。

而在类装载器装载 People 类时,此时可以通过虚拟机获取 Tool 类的实际内存地址,因此便可以既将符号 org.simple.Tool 替换为 Tool 类的实际内存地址,及直接引用地址。即在编译时用符号引用来代替引用类,在加载时再通过虚拟机获取该引用类的实际地址。以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。

符号引用与虚拟机实现的内存布局是无关的,引用的目标不一定已经加载到内存中。

字面量

  • 文本字符串 String a = "abc",这个 abc 就是字面量
  • 八种基本类型 int a = 1,这个 1 就是字面量
  • 声明为 final 的常量

运行时常量池

运行时常量池(Runtime Constant Pool)是每一个类或接口的常量池(Constant_Pool)的运行时表示形式,它包括了若干种不同的常量:

从编译期可知的数值字面量到必须运行期解析后才能获得的方法或字段引用。(这个是虚拟机规范中的描述,很生涩)

运行时常量池是在类加载完成之后,将 Class 常量池中的符号引用值转存到运行时常量池中,类在解析之后,将符号引用替换成直接引用。

运行时常量池在 JDK1.7 版本之后,就移到堆内存中了,这里指的是物理空间,而逻辑上还是属于方法区(方法区是逻辑分区)。

在 JDK1.8 中,使用元空间代替永久代来实现方法区,但是方法区并没有改变,所谓"Your father will always be your father"。变动的只是方法区中内容的物理存放位置,但是运行时常量池和字符串常量池被移动到了堆中。但是不论它们物理上如何存放,逻辑上还是属于方法区的。

字符串常量池

字符串常量池这个概念是最有争议的,对于虚拟机规范等很多正式文档,发现没有这个概念的官方定义,所以与运行时常量池的关系不去抬杠,我们从它的作用和 JVM 设计它用于解决什么问题的点来分析它,暂且把它理解为在方法区中的一块区域。

以 JDK1.8 为例,字符串常量池是存放在堆中,并且与 java.lang.String 类有很大关系。设计这块内存区域的原因在于:String 对象作为 Java 语言中重要的数据类型,是内存中占据空间最大的一个对象。高效地使用字符串,可以提升系统的整体性能。所以要彻底弄懂,我们的重心其实在于深入理解 String。

Java中的String

String 对象是对 char 数组进行了封装实现的对象,主要有 2 个成员变量:char 数组,hash 值。

String 对象的不可变性

String 类被 final 关键字修饰了,而且变量 char 数组也被 final 修饰了。我们知道类被 final 修饰代表该类不可继承,而 char[]被 final+private 修饰,代表了 String 对象不可被更改。Java 实现的这个特性叫作 String 对象的不可变性,即 String 对象一旦创建成功,就不能再对它进行改变。

Java 这样做的好处在哪里呢?

  • 保证 String 对象的安全性。假设 String 对象是可变的,那么 String 对象将可能被恶意修改。
  • 保证 hash 属性值不会频繁变更,确保了唯一性,使得类似 HashMap 容器才能实现相应的 key-value 缓存功能。
  • 可以实现字符串常量池。在 Java 中,通常有两种创建字符串对象的方式,一种是通过字符串常量的方式创建,如 String str="ayue";另一种是字符串变量通过 new 形式的创建,如 String str = new String("ayue")
String 的创建方式及内存分配的方式
  1. String str = "ayue"

    当代码中使用这种方式创建字符串对象时,JVM 首先会检查该对象是否在字符串常量池中,如果在,就返回该对象引用,否则新的字符串将在常量池中被创建。这种方式可以减少同一个值的字符串对象的重复创建,节约内存。(str 只是一个引用)

    image-20210818161741267

  2. String str = new String("ayue")

    首先在编译类文件时,"ayue"常量字符串将会放入到常量结构中,在类加载时,"ayue"将会在常量池中创建;其次,在调用 new 时,JVM 命令将会调用 String 的构造函数,同时引用常量池中的"ayue"字符串,在堆内存中创建一个 String 对象;最后,str 将引用 String 对象。

    image-20210818162637097

  3. 实体对象赋值

    使用 new,对象会创建在堆中,同时赋值的话,会在常量池中创建一个字符串对象,复制到堆中。具体的复制过程是先将常量池中的字符串压入栈中,在使用 String 的构造方法,拿到栈中的字符串作为构造方法的参数。

    这个构造函数是一个 char 数组的赋值过程,而不是 new 出来的,所以是引用了常量池中的字符串对象,存在引用关系。

    image-20210818164905370

  4. String str = "a" + "yu" + "e"

    编程过程中,字符串的拼接很常见。前面讲过 String 对象是不可变的,如果我们使用 String 对象相加,拼接我们想要的字符串,是不是就会产生多个对象呢?对于"a" + "yu" + "e"

    首先会生成 a 对象,再生成 ayu 对象,最后生成 ayue对象,也就是3个对象。从理论上来说,这段代码是低效的。 但是编译器自动优化了这行代码,编译后的代码,你会发现编译器自动优化了这行代码,即String str= "ayue"

  5. String.intern()

    String 的 intern() 方法,如果常量池中有相同值,就会重复使用该对象,返回对象引用。

    image-20210818170522523

    所以,对于下面这段代码,你觉得应该输出什么呢?

    image-20210818170740465

    image-20210818172524967

个人觉得JVM的内存结构是比较偏概念的东西,也就是所谓的八股文,但是到后面涉及到GC调优等等又都是建立在基础概念之上的,所以在学习JVM的时候是比较枯燥的,而且可能今天看明天就忘记了,这都是很正常的,我也一样,所以如果你想学,那就多花精力吧。

参考

  • 《深入理解Java虚拟机》_周志明