从零开始的JVM学习--Java运行时数据区域

482 阅读32分钟

简单介绍

什么是Java内存区域?

「Java内存区域」也可以叫做「Java运行时数据区域」是指 JVM 运行时会把它管理的内存划分成若干个不同的数据区域 ,简单的说就是不同的数据放在不同的地方。

所以本篇文章的关键就在正确的描述Java内存区域的划分,以及各个区域的存储内容和解释。

很重要的一点,「Java内存区域」和「Java内存模型」并不是一个东西。

这一点在我的博客:从零开始的JVM学习--Java内存模型(JMM)中有详细的说明。

Java内存区域

「Java内存区域」是对内存的区域划分,并且JDK 1.8 的 「Java内存区域」和 JDK 1.8 以前的有所不同。下面我用几张图来描述一下不同版本的「Java内存区域」

JDK 1.8 之前的 Java内存区域

image.png

JDK 1.8 及之后的 Java内存区域

image-20221004204448375

JDK1.8 之前的 和 JDK1.8 的有什么不同?

JDK 1.8 中「元空间」替代了「方法区」。

JDK 1.7 其实是并没完全移除「方法区」,但是不会像JDK 1.6 以前报 “java.lang.OutOfMemoryError: PermGen space”,而是报 java.lang.OutOfMemoryError: Java heap space

其中在 JDK 1.7 ,「常量池」、「静态变量」,从「方法区」转移到了「堆」。

image.png

为什么JDK 1.8中 PermGen(永久代) 被移出 HotSpot JVM了?

  • 由于 PermGen 内存经常会溢出,引发恼人的 java.lang.OutOfMemoryError: PermGen,因此 JVM的开发者希望这一块内存可以更灵活地被管理,不要再经常出现这样的 OOM
  • 移除 PermGen 可以促进 HotSpot JVMJRockit VM 的融合,因为 JRockit 没有永久代。

线程共享,线程私有?

  • JDK 1.8 之前

    image-20221004204717948

    线程私有

    • 本地方法栈
    • 程序计数器
    • 虚拟机栈

    线程共享

    • 方法区
  • JDK 1.8 之后

    image.png

    线程私有

    • 本地方法栈
    • 程序计数器
    • 虚拟机栈

    线程共享

    • 元空间

程序计数器

本章我们将从CPU的程序计数器开始学习,接着将计算机上的进程运行和进程切换恢复场景代入到Java程序中来深入理解程序计数器的作用。

CPU中的程序计数器

什么是CPU的PC?

CPU中的PC是一个大小为一个字的存储设备(寄存器)

PC中存储的都是内存地址,而CPU就根据PC中的内存地址,到相应的内存取出指令然后执行并且更新PC的值。

操作系统如何管理多进程?

每创建一个进程,操作系统都会为这个进程创建一个PCB(进程控制块)来记录这个进程的信息(比如:PID...)。

当发生进程切换时,操作系统会在PCB保存进程执行的当前信息(比如:内存,寄存器使用的情况...),在恢复进程的时候,再根据PCB恢复状态。

所以可以发现在计算机上,进程的运行一个是依赖一个「取值」功能,还有就是进程切换恢复的处理。这个场景映射到Java程序的运行和多线程的切换恢复是一样的!

讲完CPU中的程序计数器后,那么Java运行时数据区域的「程序计数器」呢?

Java内存区域的程序计数器

CPU中PC是一个物理设备,而在Java中PC是一块比较小的内存空间。具体内容将在本章介绍。

什么是「程序计数器」?

「程序计数器」是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。

「字节码解释器」工作时通过改变「程序计数器」的值来选取下一条需要执行的字节码指令

分支、循环、跳转、异常处理、线程恢复等功能都需要依赖这个计数器来完成

为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的「程序计数器」,各线程之间计数器互不影响,独立存储,这类内存区域就是所谓“线程私有”的内存。

字节码的执行原理

编译后的字节码在没有经过JIT(实时编译器)编译前,是通过字节码解释器进行解释执行。

其执行原理为:字节码解释器读取内存中的字节码,按照顺序读取字节码指令,读取一个指令就将其翻译成固定的操作,根据这些操作进行分支,循环,跳转等动作。

程序计数器的作用

  • 「字节码解释器」通过「改变程序计数器」来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
  • 在多线程的情况下,「程序计数器」用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。

程序计数器的特点

  • 「程序计数器」具有线程隔离性(线程私有)

  • 「程序计数器」的生命周期随着线程的创建而创建,随着线程的结束而死亡。

  • 「程序计数器」占用的内存空间非常小,可以忽略不计

  • 「程序计数器」是JVM规范中唯一一个没有规定任何OutofMemeryError的区域

  • 程序执行的时候,「程序计数器」是有值的,其记录的是程序正在执行的字节码的地址

  • 执行native本地方法时,「程序计数器」的值为空。

    原因是native方法是Java通过 JNI(Java Native Interface) 调用本地C/C++库来实现,非Java字节码实现,不受JVM控制。

Java 虚拟机栈

什么是Java虚拟机栈?

除了一些 native本地方法调用是通过本地方法栈实现的,其他所有的 Java 方法调用都是通过「Java虚拟机栈」来实现的(也需要和其他运行时数据区域比如程序计数器配合)

「Java虚拟机栈 」描述的是 Java 方法执行的内存模型

image-20221003202541321

每个方法在执行的同时都会创建一个「栈帧」(Stack Frame)

什么是栈帧?

「栈帧」的结构图:

image.png

「栈帧」存储了方法运行的基础数据结构,包括:

  • 局部变量表

    什么是局部变量表?

    「局部变量表」 定义为一个数字数组,主要存放了编译期可知的各种数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference 类型,它不同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)。

    「局部变量表」容量大小是在编译期确定下来的。最基本的存储单元是 slot,32 位占用一个 slot,64 位类型(long 和 double)占用两个 slot。

    局部变量表有什么用?

    当方法运行过程中需要创建局部变量时,就将局部变量的值存入「栈帧」中的「局部变量表」中。

    在「栈帧」中,与性能调优关系最密切的部分,就是「局部变量表」,方法执行时,虚拟机使用「局部变量表」完成方法的传递「局部变量表」中的变量也是重要的垃圾回收根节点,只要被「局部变量表」中直接或间接引用的对象都不会被回收。

  • 操作数栈

    操作数栈有什么用?

    「操作数栈」 主要作为方法调用的中转站使用,用于存放方法执行过程中产生的中间计算结果。另外,计算过程中产生的临时变量也会放在操作数栈中。

    当一个方法刚刚开始执行的时候,这个方法的「操作数栈」是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈/入栈操作。

  • 动态链接

    动态链接有什么用?

    「动态链接」主要服务一个方法需要调用其他方法的场景:

    在 Java 源文件被编译成字节码文件时,所有的变量和方法引用都作为「符号引用」保存在 「Class 文件常量池」里。(ps:在类加载的解析阶段,会将其中的一部分「符号引用」转化为「直接引用」。)

    当一个方法要调用其他方法,需要确定被调用方法的版本(即调用哪一个方法),将「常量池」中指向方法的「符号引用」转化为其在内存地址中的「直接引用」

    「动态链接」的作用就是为了将「符号引用」转换为调用方法的「直接引用」。

    image.png

    这里关于动态链接的内容在「运行时常量池」章节也有提及。

  • 方法出口

    什么是方法出口?

    「方法出口」也就是所谓的「方法返回地址」,返回分为 正常返回 和 异常退出。

    无论何种退出情况,都将返回至方法当前被调用的位置,这也程序才能继续执行。方法返回时可能需要在当前「栈帧」中保存一些信息,用来帮他恢复它的上层方法执行状态。

    方法退出过程实际上就等同于把当前栈帧出栈。 因此退出可以执行的操作有:

    • 恢复上层方法的「局部变量表」和「操作数栈」
    • 把返回值(如果有的话) 压入调用者的「操作数栈」中
    • 调整「PC计数器」的值以指向方法调用指令后的下一条指令

    一般来说,方法正常退出时,调用者的「PC计数器」的值可以作为返回地址,「栈帧」中会保存这个计数器值。

    方法退出的过程相当于弹出当前「栈帧」。

压栈出栈过程

「虚拟机栈」可以有很多「栈帧」每一个方法从调用至执行完成的过程,就对应着一个「栈帧」在「Java虚拟机栈」中入栈到出栈的过程。

在活动线程中,只有位于栈顶的「栈帧」才是有效的,被称为当前栈帧,也就是对应的当前执行的方法。

方法结束后,当前「栈帧」被移出,「栈帧」的返回值变成新的活动栈帧中操作数栈的一个操作数。如果没有返回值,那么新的活动栈帧中「操作数栈」的操作数没有变化。

方法调用

关于Java方法调用部分还有一些细节:

  • 静态链接

    当一个字节码文件被装载进 JVM 内部时,如果被调用的目标方法在编译期可知,且运行时期间保持不变,这种情况下降调用方的符号引用转为直接引用的过程称为「静态链接」。

  • 动态链接

    如果被调用的方法无法在编译期被确定下来,只能在运行期将调用的方法的符号引用转为直接引用,这种引用转换过程具备动态性,因此被称为「动态链接」。

  • 方法绑定

    • 早期绑定:

      被调用的目标方法如果在编译期可知,且运行期保持不变。

    • 晚期绑定:

      被调用的方法在编译期无法被确定,只能够在程序运行期根据实际的类型绑定相关的方法。

  • 非虚方法

    如果方法在编译期就确定了具体的调用版本,则这个版本在运行时是不可变的。这样的方法称为「非虚方法」静态方法,私有方法,final 方法,实例构造器,父类方法都是非虚方法,除了这些以外都是虚方法。

  • 虚方法表

    面向对象的编程中,会很频繁的使用动态分配,如果每次动态分配的过程都要重新在类的方法元数据中搜索合适的目标的话,就可能影响到执行效率,因此为了提高性能,JVM 采用在类的方法区建立一个「虚方法表」,使用索引表来代替查找。

    • 每个类都有一个「虚方法表」,表中存放着各个方法的实际入口。
    • 「虚方法表」会在类加载的链接阶段被创建,并开始初始化,类的变量初始值准备完成之后,JVM 会把该类的方法也初始化完毕。
  • 方法重写的本质

    • 找到操作数栈顶的第一个元素所执行的对象的实际类型,记做 C。如果在类型 C 中找到与常量池中描述符和简单名称都相符的方法,则进行访问权限校验。
    • 如果通过则返回这个方法的直接引用,查找过程结束;如果不通过,则返回 java.lang.IllegalAccessError 异常。
    • 否则,按照继承关系从下往上依次对 C 的各个父类进行上一步的搜索和验证过程。
    • 如果始终没有找到合适的方法,则抛出 java.lang.AbstractMethodError 异常。

Java 中任何一个普通方法都具备虚函数的特征(运行期确认,具备晚期绑定的特点) ,C++ 中则使用关键字 virtual 来显式定义。如果在 Java 程序中,不希望某个方法拥有虚函数的特征,则可以使用关键字 final 来标记这个方法。

Java虚拟机栈的特点

  • 「Java虚拟机栈」具有线程隔离性(线程私有)

  • 「Java虚拟机栈」的生命周期随着线程的创建而创建,随着线程的结束而死亡。

  • 「Java虚拟机栈」运行速度特别快,仅仅次于「程序计数器」。

  • 「Java虚拟机栈」会出现两种异常:StackOverFlowErrorOutOfMemoryError

    • StackOverFlowError: 若 「Java虚拟机栈」的大小不允许动态扩展,那么当线程请求栈的深度超过当前 「Java虚拟机栈」的最大深度时,抛出 StackOverFlowError 异常。
    • OutOfMemoryError :若允许动态扩展,那么当线程请求栈时内存用完了,无法再动态扩展时,抛出 OutOfMemoryError异常。
  • 出现 StackOverFlowError 异常时,内存空间可能还有很多。

常见的运行时异常

  • NullPointerException - 空指针引用异常
  • ClassCastException - 类型强制转换异
  • IllegalArgumentException - 传递非法参数异常
  • ArithmeticException - 算术运算异常
  • ArrayStoreException - 向数组中存放与声明类型不兼容对象异常
  • IndexOutOfBoundsException - 下标越界异常
  • NegativeArraySizeException - 创建一个大小为负数的数组错误异常
  • NumberFormatException - 数字格式异常
  • SecurityException - 安全异常
  • UnsupportedOperationException - 不支持的操作异常

本地方法栈

什么是本地方法栈?

「本地方法栈」是为 JVM 运行 native 方法准备的空间,由于很多 native 方法都是用 C 语言实现的,所以它通常又叫 C 栈。

「本地方法栈」与 「Java虚拟机栈」实现的功能类似,只不过**「本地方法栈」是描述本地方法运行过程的内存模型**。

栈帧变化过程

本地方法被执行时,在「本地方法栈」也会创建一块「栈帧」,用于存放该方法的局部变量表、操作数栈、动态链接、方法出口信息等。

方法执行结束后,相应的「栈帧」也会出栈,并释放内存空间。也会抛出 StackOverFlowErrorOutOfMemoryError 异常。

image-20221004102204635

如果 JVM 本身不支持 native 方法,或是本身不依赖于传统栈,那么可以不提供「本地方法栈」。如果支持「本地方法栈」,那么这个栈一般会在线程创建的时候按线程分配。

Java 堆

本章将从简单的介绍堆的基本概念和特点,并简要介绍堆中的内存划分。具体的内容以后在分析垃圾回收的时候会详细说明。

什么是堆?

「堆」是用来存放对象的内存空间,此内存区域的唯一目的就是存放对象实例,「几乎」所有的对象实例以及数组都在这里分配内存。

「堆」是垃圾收集器管理的主要区域,又被称为“GC堆”。

为什么是「几乎」?

随着 JIT 编译器的发展与逃逸分析技术逐渐成熟,「栈」上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到「堆」上也渐渐变得不那么“绝对”了。从 JDK 1.7 开始已经默认开启逃逸分析,如果某些方法中的对象引用没有被返回或者未被外面使用(也就是未逃逸出去),那么对象可以直接在「栈」上分配内存。

堆的特点

  • 线程共享,整个 「Java虚拟机」只有一个「堆」,所有的线程都访问同一个「堆」。
  • 「堆」是「Java虚拟机」管理的内存中最大的一块
  • 「堆」的大小是可扩展的, 通过-Xmx-Xms控制。
  • 「堆」在虚拟机启动时创建。
  • 「堆」是垃圾回收的主要场所。
  • 「堆」可分为新生代(Eden 区:From SurviorTo Survivor)、老年代。
  • 「Java虚拟机」规范规定,「堆」可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的。
  • 关于 Survivor 区:From SurvivorTo Survivor,复制之后有交换,谁空谁是 To。
  • 如果「堆」内存不够分配实例对象, 并且对也无法在扩展时, 将会抛出OutOfMemoryError异常。

堆内存的划分

在 JDK 1.7 及 JDK 1.7 版本之前,堆内存通常被分成下面的三个部分:

  • 新生代(Young Generation)

    什么是新生代?

    这块内存我们也叫做Young

    Young区 被划分为三部分,Eden区 和两个大小严格相同的 Survivor区(S0,S1)

    Survivor区间中,某一时刻只有其中一个是被使用的,另外一个留做垃圾收集时复制对象用,在Eden区间变满的时候, GC就会将存活的对象移到空闲的Survivor区间中,根据JVM的策略,在经过几次垃圾收集后,任然存活于Survivor的对象将被移动到 Tenured 区间。

  • 老生代(Old Generation)

    什么是老年代?

    这块内存我们也叫做Tenured

    Tenured区 主要保存生命周期长的对象,一般是一些老的对象,当一些对象在Young复制转移一定的次数以后,对象就会被转移到Tenured区,一般如果系统中用了application级别的缓存,缓存中的对象往往会被转移到这一区间。

  • 永久代(Permanent Generation)

    什么是永久代?

    这块内存也是「方法区」在JDK1.7及以前版本的具体实现。我们把它叫做Perm区。

    Perm区主要保存class,method,filed对象,这部分的空间一般不会溢出,除非一次性加载了很多的类。

    不过在涉及到热部署的应用服务器的时候,有时候会遇到 java.lang.OutOfMemoryError : PermGen space 的错误,造成这个错误的很大原因就有可能是每次都重新部署,但是重新部署后,类的class没有被卸载掉,这样就造成了大量的class对象保存在了Perm中,这种情况下,一般重新启动应用服务器可以解决问题。

image.png

JDK1.7:

堆大小 = 新生代 + 老年代 + 永久代。(ps:堆的大小可通过参数–Xms(堆的初始容量)、-Xmx(堆的最大容量) 来指定)

在 JDK1.8 中堆内存是由新生代+老年代组成的,Perm区被Metaspace(元空间)代替。Metaspace所占用的内存空间并不是在JVM内部,而是在本地内存空间中,这也是和1.7版本的永久代最大的区别: image.png

在 JDK 1.8 中:

堆大小 = 新生代 + 老年代。

Virtual 区

最大内存和初始内存的差值,就是Virtual区。

上面所说的Young区,Tenured区,Perm区都有可能存在Virtual区(即还未被分配的内存)

新生代和老年代

  • 「老年代」比「新生代」生命周期长。

  • 「新生代」与「老年代」空间默认比例 1:2

    JVM 调参数,XX:NewRatio=2,表示新生代占 1,老年代占 2,「新生代」占整个堆的 1/3,「老年代」占整个堆的 2/3。

  • HotSpot 中,Eden 空间和另外两个 Survivor 空间缺省所占的比例是:8:1:1

  • 几乎所有的 Java 对象都是在 Eden 区被 new 出来的,Eden 放不了的大对象,就直接进入「老年代」了。

image-20221004113411856

方法区

什么是方法区?

「方法区」属于是 JVM 运行时数据区域的一块逻辑区域,是各个线程共享的内存区域。

《Java 虚拟机规范》规定了有「方法区」这么个概念和它的作用,「方法区」如何实现是虚拟机要考虑的事情。在不同的虚拟机实现上,「方法区」的实现是不同的。所以说**「方法区」是「Java虚拟机」规范中的定义,是一种规范**。

当虚拟机要使用一个类时,它需要读取并解析 Class 文件获取相关信息,再将信息存入到「方法区」。「方法区」会存储已被虚拟机加载的 类信息、域信息、方法信息、字段信息、常量、静态变量、即时编译器编译后的代码缓存等数据。(方法区中实际存储的信息根据实现的不同也是不同的)

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

image-20221005151912150

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

方法区存放以下信息

image.png

  • 类信息(类型信息)

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

    • 这个类型的完整有效名称(全名-包名.类名)
    • 这个类型直接父类的完整有效名(对于interface或是java. lang .object,都没有父类)
    • 这个类型的修饰符(public, abstract, final的某个子集)
    • 这个类型直接接口的一个有序列表
  • 域信息

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

    域的相关信息包括:

    • 域名称
    • 域类型
    • 域修饰符(public, private,protected, static, final, volatile, transient的某个子集)

    域(Field)= 字段 = 属性 = 成员变量 (一个意思)

  • 方法信息

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

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

    为了提高访问效率,必须仔细的设计存储在方法区中的数据信息结构。除了以上讨论的结构,JVM的实现者还可以添加一些其他的数据结构,如「方法表」。

    JVM对每个加载的非虚拟类的类型信息中都添加了一个「方法表」,「方法表」是一组对类实例方法的直接引用(包括从父类继承的方法。JVM可以通过方法表快速激活实例方法

  • 类加载器的引用

    JVM必须知道一个类型是由「启动加载器加载」的还是由「用户类加载器」加载的。如果一个类型是由「用户类加载器」加载的,那么JVM会将这个「类加载器」的一个引用作为类型信息的一部分保存在「方法区」中。JVM在「动态链接」的时候需要这个信息。当解析一个类型到另一个类型的引用的时候,JVM需要保证这两个类型的「类加载器」是相同的。这对JVM区分名字空间的方式是至关重要的。

  • Class类实例的引用

    JVM为每个加载的类都创建一个java.lang.Class的实例(存储在「堆」上)。而JVM必须以某种方式把Class的这个实例和存储在方法区中的类型数据(类的元数据)联系起来, 因此,类的元数据里面保存了一个Class对象的引用

    需要注意的是类元信息和Class对象的不同之处,Class对象是存在

  • JIT编译产物

    「JIT编译产物」也可以叫做「JIT代码缓存」,从字面意思理解就是代码缓存区(codeCache),它缓存的是JIT(Just in Time)即时编译器编译的代码,简言之codeCache是存放JIT生成的机器码(native code)。当然JNI(Java本地接口)的机器码也放在codeCache里,不过JIT编译生成的native code占主要部分。

    JVM会对频繁使用的代码,即热点代码(Hot Spot Code),达到一定的阈值后会编译成本地平台相关的机器码,并进行各层次的优化,提升执行效率。

    热点代码也分两种:

    • 被多次调用的方法
    • 被多次执行的循环体
  • 字段信息

  • 静态变量

  • 运行时常量池

    运行时常量池比较重要,后面将在「运行时常量池」章节详细解释。

  • ...

各个版本的方法区区别

image.png

版本变化
JDK1.6及之前有永久代(permanent generation),静态变量存放在永久代上
JDK1.7有永久代,但已经逐步“去永久代”,字符串常量池、静态变量移除,保存在堆中
JDK1.8及之后无永久代,类型信息、字段、方法、常量保存在本地内存的元空间,但字符串常量池、静态变量仍在堆

方法区的特点

  • 「方法区」与「堆」一样,是各个线程共享的内存区域。

  • 「方法区」在JVM启动的时候被创建,并且它的实际的物理内存空间中和「堆」一样都可以是不连续的。

  • 「方法区」的大小,跟堆空间一样,可以选择固定大小或者可扩展。

  • 「方法区」的大小决定了系统可以保存多少个类,如果系统定义了太多的类,导致「方法区」溢出,虚拟机同样会抛出内存溢出错误:

    • java.lang.OutOfMemoryError:PermGen space (jdk7前)
    • java.lang.OutOfMemoryError:Metaspace(jdk8后)

    溢出场景:

    • 加载大量的第三方的jar包;
    • Tomcat部署的工程过多(30-50个)
    • 大量动态的生成反射类
  • 关闭JVM就会释放这个区域的内存。

运行时常量池

在学习运行时常量池之前我们先理解常量的概念

常量

Java中是指以final关键字修饰的变量。

由于final的不可改变性,因此final类变量的值在编译期间就被确定了。因此被保存在类的「常量池」里面,然后在加载类的时候,复制进方法区的「运行时常量池」里面 ;final类变量存储在「运行时常量池」里面,每一个使用它的类保存着一个对其的引用

什么是运行时常量池?

image-20221005154527451

「运行时常量池」(Runtime Constant Pool) 是「方法区」的一部分。在加载类和接口到虚拟机后,就会创建对应的「运行时常量池」。

为什么叫「运行时常量池」呢?

是因为运行期间可能会把新的常量放入池中,比如说常见的Stringintern()方法。也因此「运行时常量池」相对于「Class 文件常量池」的一个重要特征就是”动态性“。

「运行时常量池」中包含多种不同的常量,包括编译期就已经明确的数值「字面量」,也包括到运行期解析后才能够获得的方法或者字段引用。此时不再是常量池中的符号地址了,这里换为真实地址。

当创建类或接口的「运行时常量池」时,如果构造「运行时常量池」所需的内存空间超过了方法区所能提供的最大值,则JVM 会抛 OutofMemoryError异常。

字面量

「字面量」一般可以分成以下几种:

  • 文本字符串
  • 声明为final的常量值
  • 基本数据类型的值等
 final int a = 10; //a为常量,10为字面量
 String b = “hello world!”; // b 为变量,hello world!为字面量

符号引用

「符号引用」包括:

  • 类和接口的全限定名
  • 字段的名称和描述符
  • 方法的名称和描述符

「符号引用」以一组符号来描述所引用的目标,符号可以是任何形式的「字面量」,只要使用时能够无歧义的定位到目标即可。

「符号引用」与虚拟机的内存布局无关,引用的目标并不一定加载到内存中。

在Java中,一个Java类将会编译成一个class文件。在编译时,Java类并不知道所引用的类的实际地址,因此只能使用「符号引用」来代替。

  • 例子

    org.example.People类引用了org.example.Language类,在编译时People类并不知道Language类的实际内存地址,因此只能使用符号org.example.Language来表示Language类的地址。

各种虚拟机实现的内存布局可能有所不同,但是它们能接受的「符号引用」都是一致的,因为「符号引用」的「字面量」形式明确定义在JVM规范的Class文件格式中。

  • 直接引用

    和「符号引用」,相对的就是「直接引用」了。

    「直接引用」是和虚拟机的布局相关的,同一个「符号引用」在不同的虚拟机实例上翻译(「动态链接」)出来的「直接引用」一般不会相同。

    如果有了「直接引用」,那引用的目标必定已经被加载入内存中了。

    直接引用的实现方式:

    • 直接指向目标的指针(比如,指向“类型”【Class对象】、类变量、类方法的直接引用可能是指向方法区的指针)
    • 相对偏移量(比如,指向实例变量、实例方法的直接引用都是偏移量)
    • 一个能间接定位到目标的句柄。

常量池

要弄清楚「方法区」,需要理解清楚Class文件,因为加载类的信息都在「方法区」。

要弄清楚「方法区」的「运行时常量池」,需要理解清楚Class文件中的「常量池」。

(ps: Class文件就是所谓的字节码文件)

  • 什么是常量池?

    image.png

    「运行时常量池」和这里的「常量池」不是一个东西!

    我们这里讲的「常量池」(Class 常量池), 是Class文件的一部分。

    Java文件被编译成 Class文件,Class文件中除了包含类的版本、字段、方法、接口等描述信息外,还有一项就是「常量池」(Constant pool),用于存放编译期生成的各种「字面量」与「符号引用」,这部分内容将在类加载后存放到方法区的「运行时常量池」中。

    「常量池」可以看做是一张表,虚拟机指令会根据这张常量表找到要执行的类名、方法名、参数类型、字面量等类型。

  • 为什么需要常量池?

    一个Java源文件中的类、接口,编译后产生一个字节码文件。而Java中的字节码需要数据支持,通常这种数据会很大以至于不能直接存到字节码里,换另一种方式,可以存到「常量池」,这个字节码包含了指向「常量池」的引用。在「动态链接」的时候会用到「运行时常量池」

    这个在「Java 虚拟机栈」章节也有提及。

字符串常量池

什么是字符串常量池?

「字符串常量池」也可以叫做全局字符串池,英文叫做 String Pool 或 String Table。

String类是我们使用频率非常高的一种对象类型。JVM为了提升性能和减少内存开销,避免字符串的重复创建,其维护了一块特殊的内存空间。

「字符串常量池」由String类私有的维护。「字符串常量池」存放的是字符串的引用或者字符串。

JVM在执行某个类的时候,必须经过加载、连接、初始化,而连接又包括验证、准备、解析三个阶段。

「常量池」用于存放编译期生成的各种「字面量」和「符号引用」,而当类加载到内存中后,JVM就会将「常量池」中的内容存放到「运行时常量池」中,而字符串常量存在「堆」中「字符串常量池」中(JDK1.7及以后)。

String.intern()方法

直接使用双引号声明出来的String对象会直接存储在「常量池」中。

如果不是用双引号声明的String对象,可以使用String提供的intern方法。intern 方法会从「字符串常量池」中查询当前字符串是否存在,若不存在就会将当前字符串放入「常量池」中

字符串常量池为什么要移动到堆?

JDK1.7 中将 String Table 放到了「堆」空间中。因为永久代的回收效率很低,在 full gc 的时候才会触发。

full gc 是老年代的空间不足、永久代不足时才会触发。这就导致 StringTable 回收效率不高。

我们开发中会有大量的字符串被创建,回收效率低,导致永久代内存不足。放到「堆」里,能及时回收内存。

关于字符串常量池的详细内容以后我会再写博客解释。

元空间

元空间和永久代的区别

  • 存储位置不同

    永久代在物理上是堆的一部分,和新生代、老年代的地址是连续的,而「元空间」属于本地内存

  • 存储内容不同

    在原来的永久代划分中,永久代用来存放类的元数据信息、静态变量以及常量池等。现在类的元信息存储在「元空间」中,静态变量和常量池等并入堆中,相当于原来的永久代中的数据,被「元空间」和堆内存给瓜分了。

为什么要废弃永久代,引入元空间?JDK版本升级对方法区实现的改进?

相比于之前的永久代划分,Oracle为什么要做这样的改进呢?

  • 在原来的永久代划分中,永久代需要存放类的元数据、静态变量和常量等。它的大小不容易确定,因为这其中有很多影响因素,比如类的总数,常量池的大小和方法数量等,-XX:MaxPermSize 指定太小很容易造成永久代内存溢出。

    废除永久代后,类的元数据分配在本地内存中,元空间的最大可分配空间就是系统可用内存空间。不会遇到永久代存在时的内存溢出错误。

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

  • 永久代会为GC带来不必要的复杂度,并且回收效率偏低。

    类的元数据从PermGen剥离出来到Metaspace,可以提升对元数据的管理同时提升GC效率。

  • 废除永久代后,将运行时常量池从PermGen分离出来,与类的元数据分开,提升类元数据的独立性。

元空间相关参数

  • -XX:MetaspaceSize

    初始空间大小,达到该值就会触发垃圾收集进行类型卸载,同时GC会对该值进行调整:如果释放了大量的空间,就适当降低该值;如果释放了很少的空间,那么在不超过MaxMetaspaceSize时,适当提高该值。

  • -XX:MaxMetaspaceSize

    最大空间,默认是没有限制的。如果没有使用该参数来设置类的元数据的大小,其最大可利用空间是整个系统内存的可用空间。 JVM也可以增加本地内存空间来满足类元数据信息的存储。但是如果没有设置最大值,则可能存在bug导致Metaspace的空间在不停的扩展,会导致机器的内存不足;进而可能出现swap内存被耗尽;最终导致进程直接被系统直接kill掉。如果设置了该参数,当Metaspace剩余空间不足,会抛出java.lang.OutOfMemoryError: Metaspace space

  • -XX:MinMetaspaceFreeRatio

    GC之后,最小的Metaspace剩余空间容量的百分比,减少为分配空间所导致的垃圾收集。

  • -XX:MaxMetaspaceFreeRatio

    GC之后,最大的Metaspace剩余空间容量的百分比,减少为释放空间所导致的垃圾收集。

小结

Java内存区域,一般也叫做Java运行时数据区域。是理解JVM各种机制的基础。毕竟所有的操作最终都是在和内存进行交互,因此理解内存很重要。

本篇原来打算2天写完但没想到内容意外的多,花了不少时间,才终于发出来。希望能对这个知识点的总结有帮助。

本文参考: