概述

本篇文章是根据学习《深入理解Java虚拟机》书籍及其聆听尚硅谷宋红康老师讲解 ,最终自己按照自己的理解总结而出

图片引用: https://imlql.cn/post/a7ad3cab.html && 尚硅谷教育

对于c++选手来说, 内存管理是一项基本功,因为c++没有自带的管理技术, 所以c++开发人员需要自己对实现的所有代码进行内存管理。 虽然说Java实现了一套自己的内存管理机制, 这让Java程序员可以全心投入到需求开发中去, 不需要对内存做太多了的了解。 但是问题也正是出现在这里, 因为不知道虚拟机是怎么使用内存的,所以出了问题也是无从下手,不知道具体哪里出了问题。 所以这些都是我们Java程序员需要了解和掌握的内存管理技术的原因。

之前我们学习了类加载器子系统的知识 ,知道了一个Class文件是如何一步步被加载到虚拟机内存中的开始 ,到卸载出内存位为止, 他的整个生命周期以及每个周期所做的事等等….

既然一个类已经被加载到内存了 ,那么下一步就是查看内存如何管理这些了

image-20230926181353338

运行时数据区(Runtime Data Area)

当我们通过前面的:类的加载 –> 验证 –> 准备 –> 解析 –> 初始化,这几个阶段完成后,就会用到执行引擎对我们的类进行使用,同时执行引擎将会使用到我们运行时数据区

虚拟机在执行Java文件的时候会把他所管理的内存划分为若干个不同的数据区域, 这些区域有各自的用途 , 会随着虚拟机进程的启动而创建 或者是 随着用户线程的启动和结束而建立和销毁。

我们通过磁盘或者网络IO得到的数据,都需要先加载到内存中,然后CPU从内存中获取数据进行读取,也就是说内存充当了CPU和磁盘之间的桥梁

最新的JDK 8中对之前的运行时数据区相关内容有着不同的划分, 具体参考阿里的JDK 8 运行时数据区图

image-20230926182609272

前面我们提到这些区域有各自的用途 , 会随着虚拟机进程的启动而创建 或者是 随着用户线程的启动和结束而建立和销毁。 随着虚拟机进程这点我可以理解,因为内存区域都是由Java虚拟机管理的 但是为什么会随着用户的线程呢?

线程的相关内容

线程是一个程序里的运行单元。JVM允许一个应用有多个线程并行的执行 。 在JVM内部, 每个线程都是与本地的线程直接映射

在我们Java的程序启动一个线程时, 操作系统同时也会创建一个本地线程,本地线程一旦初始化成功就会开始执行run()方法

一些JVM系统线程

  1. 虚拟机线程:这种线程的操作是需要JVM达到安全点才会出现。这些操作必须在不同的线程中发生的原因是他们都需要JVM达到安全点,这样堆才不会变化。这种线程的执行类型括”stop-the-world”的垃圾收集,线程栈收集,线程挂起以及偏向锁撤销
  2. 周期任务线程:这种线程是时间周期事件的体现(比如中断),他们一般用于周期性操作的调度执行
  3. GC线程:这种线程对在JVM里不同种类的垃圾收集行为提供了支持
  4. 编译线程:这种线程在运行时会将字节码编译成到本地代码
  5. 信号调度线程:这种线程接收信号并发送给JVM,在它内部通过调用适当的方法进行处理

以上的5个线程不包括main方法所在的线程以及通过main方法创建的线程, 这些都是Java程序启动之后 ,后台启动的一些线程。

程序计数器(Program Counter Register)

概述: 程序计数器占用的是一块很小的空间, 它相当于当前线程所执行的字节码的行号指示器。 可以理解为和计算机组成原理中的程序计数器一个概念(但是具体的执行功能还是不同的,知识有大致的相同), 都是通过这个计数器的值来选取下一条需要执行字节码指令。 同时程序计数器的生命周期正在执行的线程的生命周期相同

作用:

  1. JVM中 程序计数器用于追踪线程执行的位置。它保存了当前线程正在执行的字节码指令的地址或索引。当线程被调度执行时,程序计数器指示了下一条要执行的指令的位置。在线程切换时,程序计数器的值会被保存和恢复,以确保线程能够从正确的位置继续执行。

  2. 程序计数器也是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

  3. 任何时间一个线程都只有一个方法在执行,也就是所谓的当前方法。程序计数器会存储当前线程正在执行的Java方法的JVM指令地址;或者,如果是在执行native方法,则是未指定值(undefned)。

image-20230926185231721

PC寄存器用来存储指向下一条指令的地址,也即将要执行的指令代码。由执行引擎读取下一条指令,并执行该指令。

image-20230926185420246

程序计数器区域 是唯一一个在《Java虚拟机规范》中没有规定任何OneOfMemoryError情况的区域

Java虚拟机栈(Java Virtual Machine Stack)

与上面的程序计数器一样, 都是线程私有的 ,随着线程的创建和销毁 而 生 & 死 。

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

每个方法被执行的时候,Java虚拟机都 会同步创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态连接、方法出口等信 息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。

内存中的栈与堆区分

  1. 首先栈是运行时的单位,而堆是存储的单位。
  2. 栈解决程序的运行问题,即程序如何执行,或者说如何处理数据。堆解决的是数据存储的问题,即数据怎么放,放哪里

因为按照c++中的内存布局结构, 人们好像都会将其划分为栈内存 和 堆内存, 但是实际的内存布局结构却比这更加复杂。

虚拟机栈的作用:

  • 主管Java程序的运行,它保存方法的局部变量(8 种基本数据类型、对象的引用地址)、部分结果,并参与方法的调用和返回。
  • 局部变量,它是相比于成员变量来说的(或属性)
  • 基本数据类型变量 VS 引用类型变量(类、数组、接口)

image-20230926190940172

每个线程在创建时都会创建一个虚拟机栈,其内部保存一个个的栈帧(Stack Frame),对应着一次次的Java方法调用,栈是线程私有的

栈帧是一个内存区块,是一个数据集,维系着方法执行过程中的各种数据信息。

虚拟机栈的特点:

栈是一种快速有效的分配存储方式,访问速度仅次于程序计数器。

JVM直接对Java栈的操作只有两个:

  • 每个方法执行,伴随着进栈(入栈、压栈)
  • 执行结束后的出栈工作

对于栈来说不存在垃圾回收问题

  • 栈不需要GC,但是可能存在OOM

image-20230926191123060

栈运行原理

  1. JVM直接对Java栈的操作只有两个,就是对栈帧的压栈和出栈,遵循先进后出(后进先出)原则
  2. 在一条活动线程中,一个时间点上,只会有一个活动的栈帧。即只有当前正在执行的方法的栈帧(栈顶栈帧)是有效的。这个栈帧被称为当前栈帧(Current Frame),与当前栈帧相对应的方法就是当前方法(Current Method),定义这个方法的类就是当前类(Current Class)
  3. 执行引擎运行的所有字节码指令只针对当前栈帧进行操作。
  4. 如果在该方法中调用了其他方法,对应的新的栈帧会被创建出来,放在栈的顶端,成为新的当前帧。

注意:

不同线程中所包含的栈帧是不允许存在相互引用的,即不可能在一个栈帧之中引用另外一个线程的栈帧。

如果当前方法调用了其他方法,方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,接着,虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧。

虚拟机栈中的存储内容

  1. 每个线程都有自己的栈,栈中的数据都是以栈帧(Stack Frame)的格式存在
  2. 在这个线程上正在执行的每个方法都各自对应一个栈帧(Stack Frame)。
  3. 栈帧是一个内存区块,是一个数据集,维系着方法执行过程中的各种数据信息。

栈帧中的内容

  • 局部变量表(Local Variables)
  • 操作数栈(Operand Stack)(或表达式栈)
  • 动态链接(Dynamic Linking)(或指向运行时常量池的方法引用)
  • 方法返回地址(Return Address)(或方法正常退出或者异常退出的定义)
  • 一些附加信息( 有的被叫做帧数据区 )

image-20231004145513109

一、局部变量表中的内容简单区别

  1. 类变量和 局部变量的区别:
1
2
3
4
5
6
7
8
9
成员变量:
- 类变量: linking的prepare阶段:给类变量默认赋值
---> initial阶段:给类变量显式赋值即静态代码块赋值
- 实例变量:随着对象的创建,会在堆空间中分配实例变量空间,并进行默认赋值

局部变量:
- 在使用前,必须要进行显式赋值的!否则,编译不通过。


补充

  1. 在栈帧中,与性能调优关系最为密切的部分就是前面提到的局部变量表。在方法执行时,虚拟机使用局部变量表完成方法的传递。

  2. 局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或间接引用的对象都不会被回收。

  3. 局部变量表在栈帧中占据的内存是最大的,所以出现栈溢出也有可能是出现在这里

二、 操作数栈

操作数栈: 主要保存计算过程中的中间结果, 同时作为临时变量的存储空间。

  1. 操作数栈就是JVM执行引擎的一个工作区,当一个方法刚开始执行的时候,一个新的栈帧也会随之被创建出来,这时方法的操作数栈是空的。
  2. 每一个操作数栈都会拥有一个明确的栈深度用于存储数值,其所需的最大深度在编译期就定义好了,保存在方法的Code属性中,为maxstack的值。
  3. 栈中的任何一个元素都是可以任意的Java数据类型
    • 32bit的类型占用一个栈单位深度
    • 64bit的类型占用两个栈单位深度
  4. 操作数栈并非采用访问索引的方式来进行数据访问的,而是只能通过标准的入栈和出栈操作来完成一次数据访问。只不过操作数栈是用数组这个结构来实现的而已
  5. 如果被调用的方法带有返回值的话,其返回值将会被压入当前栈帧的操作数栈中,并更新PC寄存器中下一条需要执行的字节码指令。
  6. 操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,这由编译器在编译器期间进行验证,同时在类加载过程中的类检验阶段的数据流分析阶段要再次验证。
  7. 另外,我们说Java虚拟机的解释引擎是基于栈的执行引擎,其中的栈指的就是操作数栈

三、动态链接

动态链接(或指向运行时常量池的方法引用)

  1. 每一个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用。包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接(Dynamic Linking),比如:invokedynamic指令
  2. 在Java源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用(Symbolic Reference)保存在class文件的常量池里。比如:描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用来表示的,那么动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用

在字节码文件中, 专门有一个常量池(Constant pool)

四、 方法返回地址

在一些帖子里,方法返回地址、动态链接、一些附加信息 也叫做帧数据区

  1. 存放调用该方法的pc寄存器的值。一个方法的结束,有两种方式:
    • 正常执行完成
    • 出现未处理的异常,非正常退出
  2. 无论通过哪种方式退出,在方法退出后都返回到该方法被调用的位置。方法正常退出时,调用者的pc计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址。而通过异常退出的,返回地址是要通过异常表来确定,栈帧中一般不会保存这部分信息。
  3. 本质上,方法的退出就是当前栈帧出栈的过程。此时,需要恢复上层方法的局部变量表、操作数栈、将返回值压入调用者栈帧的操作数栈、设置PC寄存器值等,让调用者方法继续执行下去。
  4. 正常完成出口和异常完成出口的区别在于:通过异常完成出口退出的不会给他的上层调用者产生任何的返回值。

方法退出的两种方式

当一个方法开始执行后,只有两种方式可以退出这个方法,

正常退出:

  1. 执行引擎遇到任意一个方法返回的字节码指令(return),会有返回值传递给上层的方法调用者,简称正常完成出口
  2. 一个方法在正常调用完成之后,究竟需要使用哪一个返回指令,还需要根据方法返回值的实际数据类型而定。
  3. 在字节码指令中,返回指令包含:
    • ireturn:当返回值是boolean,byte,char,short和int类型时使用
    • lreturn:Long类型
    • freturn:Float类型
    • dreturn:Double类型
    • areturn:引用类型
    • return:返回值类型为void的方法、实例初始化方法、类和接口的初始化方法

异常退出:

  1. 在方法执行过程中遇到异常(Exception),并且这个异常没有在方法内进行处理,也就是只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,简称异常完成出口
  2. 方法执行过程中,抛出异常时的异常处理,存储在一个异常处理表,方便在发生异常的时候找到处理异常的代码

方法的调用

在JVM内部, 将符号引用转换为调用方法的直接引用与方法的绑定机制有关的 。

两种链接:

  1. 静态链接

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

  1. 动态链接

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

绑定机制中的两种绑定分别为

静态链接和动态链接对应的方法的绑定机制为:早期绑定(Early Binding)和晚期绑定(Late Binding)。绑定是一个字段、方法或者类在符号引用被替换为直接引用的过程,这仅仅发生一次。

  1. 早期绑定

早期绑定就是指被调用的目标方法如果在编译期可知,且运行期保持不变时,即可将这个方法与所属的类型进行绑定,这样一来,由于明确了被调用的目标方法究竟是哪一个,因此也就可以使用静态链接的方式将符号引用转换为直接引用

  1. 晚期绑定

如果被调用的方法在编译期无法被确定下来,只能够在程序运行期根据实际的类型绑定相关的方法,这种绑定方式也就被称之为晚期绑定。

异常情况(两类)

Java 虚拟机规范允许Java栈的大小是动态的或者是固定不变的。

  • 如果采用固定大小的Java虚拟机栈,那每一个线程的Java虚拟机栈容量可以在线程创建的时候独立选定。如果线程请求分配的栈容量超过Java虚拟机栈允许的最大容量,Java虚拟机将会抛出一个StackoverflowError 异常。
  • 如果Java虚拟机栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的虚拟机栈,那Java虚拟机将会抛出一个 OutofMemoryError 异常。

本地方法栈(Native Method Stack)

本地方法栈和虚拟机栈所发挥的作用是非常相似的,区别就是虚拟机栈为虚拟机执行Java方法服务, 而本地方法栈则是为虚拟机使用本地方法时提供服务。

《Java虚拟机规范》对本地方法栈中使用的任何内容都没有规范。 抛出的异常和Java虚拟机中的异常是一样的。

在栈深度溢出或者栈扩展失败时分别抛出StackoverflowError 异常 和 OutofMemoryError 异常。

Java堆(Java Heap)

**.Java堆是虚拟机所管理的内存中最大的一块, 被所有线程所共享, 生命周期是随着虚拟机的, 此内存的唯一目的就是存放对象实例的. **

上述就是堆区的重点。Java中, 几乎所有的对象示例都会在这里分配内存。 (但是在《Java虚拟机规范》中 它表明 所有的对象示例以及数组都应该在堆上分配), 具体听谁的咱也不知道…

相关细节:

  1. 一个JVM实例只存在一个堆内存,堆也是Java内存管理的核心区域。
  2. Java堆区在JVM启动的时候即被创建,其空间大小也就确定了,堆是JVM管理的最大一块内存空间,并且堆内存的大小是可以调节的。
  3. 《Java虚拟机规范》规定,堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的。
  4. 所有的线程共享Java堆,在这里还可以划分线程私有的缓冲区(Thread Local Allocation Buffer,TLAB)。
  5. 《Java虚拟机规范》中对Java堆的描述是:所有的对象实例以及数组都应当在运行时分配在堆上。(The heap is the run-time data area from which memory for all class instances and arrays is allocated)
    • 从实际使用角度看:“几乎”所有的对象实例都在堆分配内存,但并非全部。因为还有一些对象是在栈上分配的(逃逸分析,标量替换)
  6. 数组和对象可能永远不会存储在栈上,因为栈帧中保存引用,这个引用指向对象或者数组在堆中的位置。
  7. 在方法结束后,堆中的对象不会马上被移除,仅仅在垃圾收集的时候才会被移除。
    • 也就是触发了GC的时候,才会进行回收
    • 如果堆中对象马上被回收,那么用户线程就会收到影响,因为有stop the word
  8. 堆,是GC(Garbage Collection,垃圾收集器)执行垃圾回收的重点区域。

上述的内容 也有可能随着JVM的迭代 也会发生很多的变化, 这里是按照主流的Java 8

案例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class SimpleHeap {
private int id;//属性、成员变量

public SimpleHeap(int id) {
this.id = id;
}

public void show() {
System.out.println("My ID is " + id);
}
public static void main(String[] args) {
SimpleHeap sl = new SimpleHeap(1);
SimpleHeap s2 = new SimpleHeap(2);

int[] arr = new int[10];

Object[] arr1 = new Object[10];
}
}

image-20230926194836745

堆空间划分的细节

现代垃圾收集器大部分都基于分代收集理论设计,堆空间细分为:

  1. Java7 及之前堆内存逻辑上分为三部分:新生区+养老区+永久区

    • Young Generation Space 新生区 Young/New
      • 又被划分为Eden区和Survivor区
    • Old generation space 养老区 Old/Tenure
    • Permanent Space 永久区 Perm

    image-20230926195018113

  2. Java 8及之后堆内存逻辑上分为三部分:新生区+养老区+元空间

    • Young Generation Space 新生区,又被划分为Eden区和Survivor区
    • Old generation space 养老区
    • Meta Space 元空间 Meta

    image-20230926195033076

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

设置堆大小

Java堆区用于存储Java对象实例,那么堆的大小在JVM启动时就已经设定好了,可以通过选项”-Xms”和”-Xmx”来进行设置。

一旦堆区中的内存大小超过“-Xmx”所指定的最大内存时,将会抛出OutofMemoryError异常。

通常会将-Xms和-Xmx两个参数配置相同的值

  • -Xms用于表示堆区的起始内存,等价于**-XX:InitialHeapSize**
  • -Xmx则用于表示堆区的最大内存,等价于**-XX:MaxHeapSize**

原因:假设两个不一样,初始内存小,最大内存大。在运行期间如果堆内存不够用了,会一直扩容直到最大内存。如果内存够用且多了,也会不断的缩容释放。频繁的扩容和释放造成不必要的压力,避免在GC之后调整堆内存给服务器带来压力。

后续还需要对这方面内容进行扩展

方法区(Method Area)

这个又是和上面的Java堆区有着相似之处。

它是各个线程共享的内存区域,它用于存储已被虚拟机加载 的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。虽然《Java虚拟机规范》中把 方法区描述为堆的一个逻辑部分,但是它却有一个别名叫作“非堆”(Non-Heap),目的是与Java堆区 分开来。

栈、堆、方法区的交互关系

image-20230926195829771

  1. Person 类的 .class 信息存放在方法区中
  2. person 变量存放在 Java 栈的局部变量表中
  3. 真正的 person 对象存放在 Java 堆中
  4. 在 person 对象中,有个指针指向方法区中的 person 类型数据,表明这个 person 对象是用方法区中的 Person 类 new 出来的

image-20230926195849265

相关细节

方法区主要存放的是 Class,而堆中主要存放的是实例化的对象

  1. 方法区(Method Area)与Java堆一样,是各个线程共享的内存区域。多个线程同时加载统一个类时,只能有一个线程能加载该类,其他线程只能等等待该线程加载完毕,然后直接使用该类,即类只能加载一次。

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

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

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

    java.lang.OutofMemoryError:PermGen space或者java.lang.OutOfMemoryError:Metaspace

  5. 加载大量的第三方的jar包

    • Tomcat部署的工程过多(30~50个)
    • 大量动态的生成反射类
  6. 关闭JVM就会释放这个区域的内存。

方法区的演变

  1. 在 JDK7 及以前,习惯上把方法区,称为永久代。JDK8开始,使用元空间取代了永久代。我们可以将方法区类比为Java中的接口,将永久代或元空间类比为Java中具体的实现类
  2. 本质上,方法区和永久代并不等价。仅是对Hotspot而言的可以看作等价。《Java虚拟机规范》对如何实现方法区,不做统一要求。例如:BEAJRockit / IBM J9 中不存在永久代的概念。
    • 现在来看,当年使用永久代,不是好的idea。导致Java程序更容易OOm(超过-XX:MaxPermsize上限)
  3. 而到了JDK8,终于完全废弃了永久代的概念,改用与JRockit、J9一样在本地内存中实现的元空间(Metaspace)来代替
  4. 元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代最大的区别在于:元空间不在虚拟机设置的内存中,而是使用本地内存
  5. 永久代、元空间二者并不只是名字变了,内部结构也调整了
  6. 根据《Java虚拟机规范》的规定,如果方法区无法满足新的内存分配需求时,将抛出OOM异常

image-20230926200208528

后续还需要补充

垃圾回收问题

《Java虚拟机规范》对方法区的约束是非常宽松的,除了和Java堆一样不需要连续的内存和可以选 择固定大小或者可扩展外,甚至还可以选择不实现垃圾收集。

相对而言,垃圾收集行为在这个区域的 确是比较少出现的,但并非数据进入了方法区就如永久代的名字一样“永久”存在了。这区域的内存回 收目标主要是针对常量池的回收和对类型的卸载,一般来说这个区域的回收效果比较难令人满意,尤 其是类型的卸载,条件相当苛刻,但是这部分区域的回收有时又确实是必要的。

以前Sun公司的Bug列 表中,曾出现过的若干个严重的Bug就是由于低版本的HotSpot虚拟机对此区域未完全回收而导致内存 泄漏。

常见面试题

  1. 百度
    • 三面:说一下JVM内存模型吧,有哪些区?分别干什么的?
  2. 蚂蚁金服:
    • Java8的内存分代改进
    • JVM内存分哪几个区,每个区的作用是什么?
    • 一面:JVM内存分布/内存结构?栈和堆的区别?堆的结构?为什么两个survivor区?
    • 二面:Eden和survior的比例分配
  3. 小米:
    • jvm内存分区,为什么要有新生代和老年代
  4. 字节跳动:
    • 二面:Java的内存分区
    • 二面:讲讲vm运行时数据库区
    • 什么时候对象会进入老年代?
  5. 京东:
    • JVM的内存结构,Eden和Survivor比例。
    • JVM内存为什么要分成新生代,老年代,持久代。新生代中为什么要分为Eden和survivor。
  6. 天猫:
    • 一面:Jvm内存模型以及分区,需要详细到每个区放什么。
    • 一面:JVM的内存模型,Java8做了什么改
  7. 拼多多:
    • JVM内存分哪几个区,每个区的作用是什么?
  8. 美团:
    • java内存分配
    • jvm的永久代中会发生垃圾回收吗?
    • 一面:jvm内存分区,为什么要有新生代和老年代?

运行时常量池

运行时常量池(Runtime Constant Pool)是方法区的一部分。

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

运行时常量池相对于Class文件常量池的另外一个重要特征是具备动态性,Java语言并不要求常量 一定只有编译期才能产生,也就是说,并非预置入Class文件中常量池的内容才能进入方法区运行时常 量池

运行期间也可以将新的常量放入池中,这种特性被开发人员利用得比较多的便是String类的 intern()方法。(动态性的体现)

既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存 时会抛出OutOfMemoryError异常。

HotSpot 虚拟机对象探究

本章探讨的是对象创建的全部过程 ,以及对象的实例化的几种方式及其区别。

创建对象的5种方式 (类实例化的几种方式)

  1. 通过new 关键字的形式创建对象, 使用该方式会在堆内存中为对象分配空间
  2. 使用工厂模式创建对象静态工厂方法是一个类的静态方法,它返回类的实例。该方法可以根据自定义的逻辑来创建对象,并可以选择是否返回缓存的实例。MyClass obj = MyClass.create();
  3. 使用反射的方式创建对象。反射机制允许在运行时动态地创建对象。通过使用 Class.forName 获取类的引用,然后调用 newInstance 方法来创建对象。
1
MyClass obj = (MyClass) Class.forName("com.example.MyClass").newInstance();
  1. 使用对象克隆的方式创建对象。 对象克隆是通过实现 Cloneable 接口和重写 clone() 方法来实现的。clone() 方法可以创建一个对象的副本,其中的字段值与原始对象相同。(对象克隆是一种浅拷贝, 并没有实现对引用类型的创新)
  2. 使用序列化 和 反序列化的方式创建对象。 序列化和反序列化是通过将对象转换为字节流来创建和恢复对象。对象需要实现 Serializable 接口以支持序列化和反序列化操作。序列化将对象转换为字节流,可以保存到文件或通过网络传输。反序列化将字节流转换回对象。

一个对象创建的全过程(重点)

这里我们讨论的只是普通的Java类对象, 而不是数组 和 Class对象等

  1. 判断对象对应的类是否加载、链接、初始化
1
2
3
4
当Java虚拟机遇到一个创建对象的指令(字节码new)的时候 , 首先会检查这个指令的参数能否在Metaspace的常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载,解析和初始化。(即判断类元信息是否存在)。

- 如果该类没有加载,那么在双亲委派模式下,使用当前类加载器以ClassLoader + 包名 + 类名为key进行查找对应的.class文件,如果没有找到文件,则抛出ClassNotFoundException异常,如果找到,则进行类加载,并生成对应的Class对象。
- 如果类已经加载了 , 那么就会生成对应的Class对象
  1. 虚拟机为新创建的对象分配内存空间
1
2
3
4
5
6
7
8
9
10
11
1. 首先计算对象占用空间的大小,接着在堆中划分一块内存给新对象。如果实例成员变量是引用变量,仅分配引用变量空间即可,即4个字节大小
2. 如果内存规整:采用指针碰撞分配内存
- 如果内存是规整的,那么虚拟机将采用的是指针碰撞法(Bump The Point)来为对象分配内存。
- 意思是所有用过的内存在一边,空闲的内存放另外一边,中间放着一个指针作为分界点的指示器,分配内存就仅仅是把指针往空闲内存那边挪动一段与对象大小相等的距离罢了。
- 如果垃圾收集器选择的是Serial ,ParNew这种基于压缩算法的,虚拟机采用这种分配方式。一般使用带Compact(整理)过程的收集器时,使用指针碰撞。
- 标记压缩(整理)算法会整理内存碎片,堆内存一存对象,另一边为空闲区域
3. 如果内存不规整
- 如果内存不是规整的,已使用的内存和未使用的内存相互交错,那么虚拟机将采用的是空闲列表来为对象分配内存。
- 意思是虚拟机维护了一个列表,记录上哪些内存块是可用的,再分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的内容。这种分配方式成为了 “空闲列表(Free List)”
- 选择哪种分配方式由Java堆是否规整所决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定
- 标记清除算法清理过后的堆内存,就会存在很多内存碎片。
  1. 处理创建对象时的并发问题
1
2
3
4
5
 创建对象在虚拟机中是一件非常频繁的事情, 即使仅仅修改一个指针所在的位置, 在并发条件下那也是线程不安全的。 
如何解决这个并发问题呢?
1. 采用CAS+失败重试保证更新的原子性
2. 每个线程预先分配TLAB(本地对象分配缓存) - 通过设置 -XX:+UseTLAB参数来设置(区域加锁机制)
3. 在Eden区给每个线程分配一块区域

TLAB: 本地对象分配缓存。 每个线程在Java堆中预先分配的一小块内存。

  1. 初始化分配的空间
1
2
3
4
5
6
所有属性设置默认值,保证对象实例字段在不赋值可以直接使用
给对象属性赋值的顺序:
1. 属性的默认值初始化
2. 显示初始化/代码块初始化(并列关系,谁先谁后看代码编写的顺序)
3. 构造器初始化
如果在处理对象并发问题的时候使用了TLAB, 那么初始化就可以提前至TLAB分配缓存之后进行。
  1. 对对象进行必要的设计(对象头的设计)
1
2
3
4
例如这个对象是哪个类的实例、如何才能找到
类的元数据信息、对象的哈希码(实际上对象的哈希码会延后到真正调用Object::hashCode()方法时才
计算)、对象的GC分代年龄等信息。这些信息存放在对象的对象头(Object Header)之中。根据虚拟
机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。

通过上述的5步, 在虚拟机层面, 一个对象已经创建成功了。 但是从Java程序的角度来看, 对象的创建才刚刚开始, 所以还需要后续的步骤。

  1. 执行init()方法
1
2
初始化成员变量,执行实例化代码块,调用类的构造方法,并把堆内对象的首地址赋值给引用变量
因此一般来说(由字节码中跟随invokespecial指令所决定),new指令之后会接着就是执行init方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完成创建出来。

对象的内存布局

在HotSpot虚拟机里,对象在堆内存中的存储布局可以划分为三个部分:对象头(Header)、实例 数据(Instance Data)和对齐填充(Padding)

对象头中存储的两大类信息

  • 第一类是用于存储对象自身的运行时数据,如哈 希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部 分数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为32个比特和64个比特,官方称它 为“Mark Word”。(如下图)

image-20230927192533777

  • 的另外一部分是类型指针,即对象指向它的类型元数据的指针,Java虚拟机通过这个指针 来确定该对象是哪个类的实例。

内存布局简图

image-20230927192143636

内存布局图解:

image-20230927192211726

对象的访问定位

JVM是如何通过栈帧中的对象引用访问到其内部的对象实例呢?

image-20230927192729457

定位,通过栈上reference访问

对象的两种访问方式:句柄访问和直接指针

  • 句柄访问

缺点:在堆空间中开辟了一块空间作为句柄池,句柄池本身也会占用空间;通过两次指针访问才能访问到堆中的对象,效率低

优点:reference中存储稳定句柄地址,对象被移动(垃圾收集时移动对象很普遍)时只会改变句柄中实例数据指针即可,reference本身不需要被修改

image-20230927192832042

  • 直接指针访问

优点:直接指针是局部变量表中的引用,直接指向堆中的实例,在对象实例中有类型指针,指向的是方法区中的对象类型数据

缺点:对象被移动(垃圾收集时移动对象很普遍)时需要修改 reference 的值

image-20230927192953178