Featured image of post JVM_01_Java内存区域详解

JVM_01_Java内存区域详解

🌏Java工程师 JVM 🎯 这系列文章用于记录 JVM_01_Java内存区域详解 相关的学习和总结

🎄 前言

对于 Java 程序员来说,在虚拟机自动内存管理机制下,不再需要像 C/C++程序开发程序员这样为每一个 new 操作去写对应的 delete/free 操作,不容易出现内存泄漏和内存溢出问题。

正是因为 Java 程序员把内存控制权利交给 Java 虚拟机,所以有必要了解虚拟机是怎样使用内存的,这样一旦出现内存泄漏和溢出方面的问题,我们就有能力去排查问题并解决问题。

🎄 运行时数据区域

Java 虚拟机在执行 Java 程序的过程中会把它管理的内存划分成若干个不同的数据区域 这里只考虑JDK1.8的版本:

🍭线程私有的

🌴程序计数器

字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。

在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。

🌴Java虚拟机栈(JVM栈)-> 为字节码的执行服务

与程序计数器一样,Java 虚拟机栈(后文简称栈)也是线程私有的,它的生命周期和线程相同,随着线程的创建而创建,随着线程的死亡而死亡。

栈绝对算的上是 JVM 运行时数据区域的一个核心,除了一些 Native 方法调用是通过本地方法栈实现的(后面会提到),其他所有的 Java 方法调用都是通过栈来实现的(也需要和其他运行时数据区域比如程序计数器配合)。

栈由一个个栈帧组成,而每个栈帧中都拥有:局部变量表、操作数栈、动态链接、方法返回地址。和数据结构上的栈类似,两者都是先进后出的数据结构,只支持出栈和入栈两种操作。

(1)局部变量表

(2)操作数栈 :用于存放方法执行过程中产生的中间计算结果和临时变量

(3)动态链接

(4)方法返回地址:栈帧中的方法返回地址是指方法执行完毕后将要返回的下一条指令的地址。

它记录了方法调用的返回位置,用于在方法执行完毕后恢复到调用该方法的位置继续执行。方法返回地址通常保存在栈帧的特定位置,当方法执行完毕后,根据该地址可以准确地返回到调用该方法的地方。

⚡StackOverFlowError && OutOfMemoryError

如果函数调用陷入无限循环的话,就会导致栈中被压入太多栈帧而占用太多空间,导致栈空间过深。当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候,就抛出 StackOverFlowError 错误。

Java 方法有两种返回方式,一种是 return 语句正常返回,一种是抛出异常。不管哪种返回方式,都会导致栈帧被弹出。也就是说, 栈帧随着方法调用而创建,随着方法结束而销毁。无论方法正常完成还是异常完成都算作方法结束。

除了 StackOverFlowError 错误之外,栈还可能会出现OutOfMemoryError错误,这是因为当栈的内存大小可以动态扩展, 而虚拟机在动态扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError异常。但是需要注意:

🌴本地方法栈 -> 为Native方法服务

和虚拟机栈所发挥的作用非常相似,区别是:虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。

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

方法执行完毕后相应的栈帧也会出栈并释放内存空间,也会出现 StackOverFlowErrorOutOfMemoryError 两种错误。

⚡Native方法和本地方法

Native方法和Java方法的区别在于它们的实现方式和执行环境。

Java方法是用Java语言编写的方法,由Java虚拟机解释执行或编译执行。Java方法运行在Java虚拟机上,可以跨平台执行。

而Native方法是用其他语言(如C、C++)编写的方法,它的实现由底层的操作系统或硬件提供。Native方法通过Java本地接口(JNI)与Java代码进行交互。Native方法通常用于与底层系统进行交互,获取更高的性能或使用特定的系统功能。

总结,Java方法是纯Java语言编写的,运行在Java虚拟机上,而Native方法是用其他语言编写的,通过Java本地接口与Java代码进行交互,可以调用底层系统的功能。

🍭线程共享的

🌴堆(heap)

Java 虚拟机所管理的内存中最大的一块,Java 堆是所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。(注意这里的用词是:”几乎所有“)

Java 堆是垃圾收集器管理的主要区域,因此也被称作 GC 堆(Garbage Collected Heap)

在 JDK 7 版本及 JDK 7 版本之前,堆内存被通常分为下面三部分:

(1)新生代内存(Young Generation)

(2)老生代(Old Generation)

(3)永久代(Permanent Generation)

下图所示的 Eden 区、两个 Survivor 区 S0 和 S1 都属于新生代,中间一层属于老年代,最下面一层属于永久代。JDK 8 版本之后 PermGen(永久代) 已被 Metaspace(元空间) 取代,元空间使用的是本地内存。

看到这里我的脑子里就产生了一系列疑问了 接下来进行探索~

❓ Eden是什么含义?

在Java的堆内存结构中,Eden是新生代中的一个区域,用于存放新创建的对象。当我们使用new关键字创建一个对象时,该对象会被分配到Eden空间。

Eden空间是新生代中最大的区域,通常占据整个新生代的一部分空间。 在垃圾回收的过程中,当Eden空间满了时,会触发一次新生代的垃圾回收(Minor GC)。在这个过程中,垃圾回收器会标记并清除不再使用的对象,并将存活的对象复制到Survivor空间中的一个空间。

因为大部分新创建的对象往往很快就会变成垃圾,所以Eden空间通常会频繁地进行垃圾回收。只有经过多次垃圾回收后仍然存活的对象,才会被移动到Survivor空间或老年代。

所以,Eden空间可以看作是临时存放新创建对象的区域,在垃圾回收中起到了非常重要的作用。

❓ 为什么Survivor有两个?

Java内存区域的新生代(Young Generation)中设置两个Survivor区(通常称为S0和S1,或者From和To)的主要原因是为了更有效地实现垃圾收集(Garbage Collection,GC)过程中的复制算法。这种设计有助于减少内存碎片提高垃圾收集的效率

以下是这种设计的一些关键优点:

  1. 复制算法:在新生代中,对象通常很快死亡或很快变得持久。复制算法是一种高效的垃圾收集策略,它涉及将活动对象从一个内存区域复制到另一个内存区域,然后立即回收原始区域中的所有内存。通过两个Survivor区,JVM可以在两个区域之间交替复制活动对象,而不需要每次都回收到老年代。
  2. 内存碎片整理:使用两个Survivor区可以减少内存碎片。每次GC发生时,活动对象被紧密地打包到一个Survivor区,释放了另一个Survivor区中的连续内存空间。这有助于保持内存空间的连续性,从而提高内存分配的效率。
  3. 对象年龄管理:在新生代中,对象的“年龄”是通过GC周期来计算的。每次GC后,如果一个对象仍然存活,它的年龄就会增加。通过两个Survivor区,JVM可以更容易地跟踪和管理对象的年龄。当对象的年龄达到某个阈值时,它们会被移动到老年代。⚡对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置
  4. 效率:通过使用两个Survivor区,JVM可以更有效地管理新生代中的内存。这种设计允许JVM在较短的GC暂停时间内回收大量内存,从而提高了应用程序的总体性能。
❓ Why 老年代?老年代和新生代的区别?

Tenured /ˈtenjəd/ 翻译过来是:(美)享有终身职位的 -> 在Java虚拟机管理的内存中称为老年代

新生代和老年代是Java内存区域中的两个重要部分,它们之间存在一些明显的区别:

  1. 存储对象:新生代主要用于存储新创建的对象,这些对象通常生命周期较短,很快就会被回收。而老年代则用于存放经过多次垃圾回收仍然存活的对象,这些对象通常生命周期较长,比较稳定。
  2. 内存分配:新生代通常被划分为Eden区、Survivor From区和Survivor To区。新创建的对象首先会被分配到Eden区,当Eden区空间不足时,会触发Minor GC,将存活的对象移动到Survivor区。而老年代则用于存放经过多次Minor GC后仍然存活的对象,当老年代满了时,会触发Major GC(或称为Full GC)。
  3. 垃圾回收频率:由于新生代中存储的对象生命周期较短,因此Minor GC会频繁发生。而老年代中的对象较为稳定,生命周期较长,因此Major GC的发生频率相对较低。
  4. 垃圾回收算法:新生代和老年代采用不同的垃圾回收算法。新生代通常采用复制算法或标记清除算法,而老年代则主要采用标记清除算法或标记整理算法。这些算法的选择主要是基于对象的生命周期和内存分配的特点。
❓ Full GC是什么含义?当老年代满了,触发Major GC时,会发生什么?

Full GC(也称为Major GC)是Java垃圾收集(Garbage Collection,GC)中的一种,它涉及到整个Java堆的清理。与Minor GC不同,Minor GC主要发生在新生代,只清理新生代中的对象,而Full GC会同时清理新生代和老年代中的对象。

当老年代满了,触发Major GC时,以下是可能发生的事情:

  1. 停止所有应用线程:为了进行垃圾收集,JVM通常会暂停所有正在运行的应用线程,这个暂停称为“Stop-The-World”事件。在这个暂停期间,应用程序不会响应任何用户请求或执行任何操作。
  2. 清理老年代:垃圾收集器会开始标记和清理老年代中的不再被引用的对象。这涉及到扫描对象的引用关系,找出那些无法从根集合(Roots)可达的对象,并将它们标记为垃圾。
  3. 清理新生代:虽然Major GC主要关注老年代,但在某些情况下,它也可能同时清理新生代。这取决于所使用的垃圾收集器的类型和配置。
  4. 内存碎片整理:在清理完不再被引用的对象后,垃圾收集器可能会进行内存碎片整理,将存活的对象移动到内存中的连续区域,以便更有效地利用内存空间。
  5. 升级老年代对象的年龄:对于在新生代中经过多次垃圾收集仍然存活的对象,它们的年龄会被增加。当对象的年龄达到某个阈值时,它们会被移动到老年代。然而,在Major GC期间,这一步通常不是必需的,因为触发Major GC的原因通常是因为老年代已满。
  6. 恢复应用线程:完成垃圾收集后,JVM会恢复所有被暂停的应用线程,应用程序继续执行。

需要注意的是,Full GC通常比Minor GC更加耗时,因为它涉及到清理整个堆内存,包括老年代和可能的新生代。频繁的Full GC会对应用程序的性能产生负面影响,因此应该尽量避免。通过优化代码、合理配置JVM参数和选择合适的垃圾收集器,可以减少Full GC的频率,提高应用程序的性能。

❓ Why 元空间(永久代)?

永久代(在Java 8及以后的版本被元空间取代)是Java内存区域的一部分,永久代的功能:

  1. 存储类的元数据:永久代用于存放JDK自身所携带的Class和Interface的元数据,包括类的结构信息、方法、字段等。这些信息是JVM在执行过程中需要的,因此被加载到永久代中。
  2. 常量池:Java中的常量池也存储在永久代中。常量池用于存储编译期间生成的常量,如字符串常量、基本类型常量等。这些常量在程序运行期间被共享和使用。

在Java 8及以后的版本中,永久代被元空间所取代。元空间与永久代在功能上相似,但实现方式和内存管理有所不同。元空间的功能:

  1. 类的元数据存储:与永久代相似,元空间也用于存储类的元数据。这些元数据包括类的结构信息、方法、字段等,是JVM在执行过程中必需的。
  2. 使用本机内存:与永久代不同的是,元空间不再使用JVM的堆内存,而是直接使用本机物理内存。这意味着元空间的大小不再受JVM堆大小的限制,而是受本机物理内存的限制。
  3. 动态调整大小:元空间可以根据需要动态调整大小。如果没有指定元空间的大小上限,它会根据需要自动增长。这种灵活性使得元空间能够更好地适应不同的应用程序和负载。
❓ JDK8以后,常量池到哪去了?

回顾文章开头的那张图,我们可以得知元空间中是包括运行时常量池的,而字符串常量池放在堆中

运行时常量池字符串常量池的区别?

运行时常量池(Runtime Constant Pool)和字符串常量池(String Constant Pool)在Java虚拟机(JVM)中扮演着不同的角色,它们之间存在以下区别:

  1. 存储位置运行时常量池是每个类或接口的Class文件的一部分,在类加载后存在于JVM的方法区(元空间)中。而字符串常量池是全局唯一的,存在于Java堆内存中,用于存储所有字符串字面量的引用。
  2. 存储内容:运行时常量池主要存储编译期间生成的字面量和符号引用。字面量包括数值字面量和字符串字面量,符号引用则包括类符号引用、字段符号引用和方法符号引用。当类加载到内存中后,JVM会将Class文件中的常量池内容加载到运行时常量池中,并经过解析阶段将符号引用替换为直接引用。而字符串常量池仅存储字符串字面量的引用,而不是字符串对象本身。
  3. 动态性:运行时常量池在Java运行时具有动态性,可以将新的常量放入池中(例如通过String类的intern()方法)。这意味着运行时常量池的内容可以在程序执行期间发生变化。而字符串常量池虽然也是动态的,但它的主要目的是共享字符串字面量,以减少内存消耗。
  4. 作用范围:运行时常量池是每个类或接口独有的,每个类都有一个运行时常量池,用于存储该类相关的常量信息。而字符串常量池是全局唯一的,供所有类共享使用。

🌴方法区

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

《Java 虚拟机规范》只是规定了有方法区这么个概念和它的作用,方法区到底要如何实现那就是虚拟机自己要考虑的事情了。也就是说,在不同的虚拟机实现上,方法区的实现是不同的。

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

❓方法区和永久代以及元空间是什么关系呢?

方法区和永久代以及元空间的关系很像 Java 中接口和类的关系,类实现了接口,这里的类就可以看作是永久代和元空间,接口可以看作是方法区。

也就是说永久代以及元空间是 HotSpot 虚拟机对虚拟机规范中方法区的两种实现方式。并且,永久代是 JDK 1.8 之前的方法区实现,JDK 1.8 及以后方法区的实现变成了元空间。

🌴直接内存 (非运行时数据区的一部分)

直接内存(Direct Memory)在Java内存模型中并不是Java虚拟机(JVM)运行时数据区的一部分,但它会被频繁地使用,尤其是在进行大量的I/O操作或与本地代码(Native Code)交互时。

直接内存不受JVM内存回收和管理机制的控制,它是由操作系统进行管理的。

直接内存的主要用途和优势包括:

  1. 减少内存拷贝:在进行文件读写或网络通信时,如果使用传统的在Java堆内存中的方式,数据可能需要在Java堆内存和操作系统内存之间来回拷贝。而使用直接内存,可以将数据直接写入或读取到操作系统的内存中,从而避免不必要的内存拷贝,提高I/O操作的效率。
  2. 与本地代码交互:当Java程序需要与本地代码(如C或C++编写的库)进行交互时,直接内存可以作为一种共享内存的机制。本地代码可以直接访问直接内存中的数据,而不需要将数据从Java堆内存拷贝到本地内存中。
  3. 支持大内存分配:由于直接内存不受JVM堆内存大小的限制,它可以支持分配比堆内存更大的连续内存空间。这对于需要处理大量数据的应用程序来说是非常有用的。
  4. 避免垃圾回收的影响:由于直接内存不受JVM垃圾回收的影响,所以在某些对实时性要求较高的场景中,使用直接内存可以避免垃圾回收带来的停顿和延迟。

在Java中,可以通过ByteBuffer类的allocateDirect方法来分配直接内存。分配的直接内存大小受操作系统和JVM参数的限制。在使用完直接内存后,需要显式地调用相应的释放方法来释放内存,否则可能会导致内存泄漏。不过,实际上ByteBuffer实例被垃圾回收时,它所关联的直接内存通常也会被自动释放。

🎄HotSpot 虚拟机对象探秘

通过上面的介绍我们大概知道了虚拟机的内存情况,下面我们来详细的了解一下 HotSpot 虚拟机在 Java 堆中对象分配、布局和访问的全过程。

🍭对象的创建

1️⃣ Step1: 类加载检查

虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程。

2️⃣ Step2: 分配内存

类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。

分配方式“指针碰撞”“空闲列表” 两种,选择哪种分配方式由 Java 堆是否规整决定,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定

⚡内存分配的两种方式

(1)指针碰撞 -> 堆内存规整(即没有内存碎片)的情况下使用

原理:用过的内存全部整合到一边,没有用过的内存放在另一边,中间有一个分界指针,只需要向着没用过的内存方向将该指针移动对象内存大小位置即可。

使用该分配方式的 GC 收集器:Serial, ParNew

(2)空闲列表 -> 堆内存不规整的情况下使用

原理:虚拟机会维护一个列表,该列表中会记录哪些内存块是可用的,在分配的时候,找一块儿足够大的内存块儿来划分给对象实例,最后更新列表记录。

使用该分配方式的 GC 收集器:CMS

选择以上两种方式中的哪一种,取决于 Java 堆内存是否规整。而 Java 堆内存是否规整,取决于 GC 收集器的算法是"标记-清除",还是"标记-整理"(也称作"标记-压缩"),值得注意的是,复制算法内存也是规整的。

⚡内存分配并发问题

在创建对象的时候有一个很重要的问题,就是线程安全,因为在实际开发过程中,创建对象是很频繁的事情,作为虚拟机来说,必须要保证线程是安全的,通常来讲,虚拟机采用两种方式来保证线程安全:

(1)CAS+失败重试: CAS 是乐观锁的一种实现方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性。

(2)TLAB: 为每一个线程预先在 Eden 区分配一块儿内存,JVM 在给线程中的对象分配内存时,首先在 TLAB 分配,当对象大于 TLAB 中的剩余内存或 TLAB 的内存已用尽时,再采用上述的 CAS 进行内存分配

3️⃣ Step3: 初始化零值

内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。

// 测试验证代码
public class Test {
    public int num1;
    public Integer num2;
    public static void main(String[] args) {
        System.out.println(new Test().num1); // num1的值是0
        System.out.println(new Test().num2); // num2的值是null
    }
}

4️⃣ Step4: 设置对象头

初始化零值完成之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。

另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。

5️⃣ Step5: 执行 init 方法

在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始,<init> 方法还没有执行,所有的字段都还为零。所以一般来说,执行 new 指令之后会接着执行 <init> 方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。

🍭对象内存布局

在 Hotspot 虚拟机中,对象在内存中的布局可以分为 3 块区域:对象头实例数据对齐填充

Hotspot 虚拟机的对象头包括两部分信息第一部分用于存储对象自身的运行时数据(哈希码、GC 分代年龄、锁状态标志等等),另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

实例数据部分是对象真正存储的有效信息,也是在程序中所定义的各种类型的字段内容。

对齐填充部分不是必然存在的,也没有什么特别的含义,仅仅起占位作用。 Hotspot 虚拟机的自动内存管理系统要求对象起始地址必须是 8 字节的整数倍,换句话说就是对象的大小必须是 8 字节的整数倍。而对象头部分正好是 8 字节的倍数(1 倍或 2 倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。

🍭对象的访问定位

建立对象就是为了使用对象,我们的 Java 程序通过栈上的 reference 数据来操作堆上的具体对象。对象的访问方式由虚拟机实现而定,目前主流的访问方式有:使用句柄直接指针

🌴句柄

如果使用句柄的话,那么 Java 堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与对象类型数据各自的具体地址信息。

对象的访问定位-使用句柄对象

🌴直接指针

如果使用直接指针访问,reference 中存储的直接就是对象的地址。

对象的访问定位-直接指针对象

这两种对象访问方式各有优势。使用句柄来访问的最大好处是 reference 中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而 reference 本身不需要修改。使用直接指针访问方式最大的好处就是速度快,它节省了一次指针定位的时间开销。

HotSpot 虚拟机主要使用的就是直接指针来进行对象访问。

🎄原文链接

https://javaguide.cn/java/jvm/memory-area.html

Licensed under CC BY-NC-SA 4.0
最后更新于 2024年1月16日