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的版本:

栈是运行时的单位,堆是存储的单位。

(1)堆空间垃圾回收一般情况概述:

  1. 新对象放在在Eden区;
  2. Eden区首次满则进行YGC(MinorGC)Eden区幸存对象晋升到S0或者S1区,且到达S0或者S1区被计数生存次数为1;
  3. 下一次Eden区满了后,再次触发YGC,这个时候Eden和from区同时进行GC,幸存的对象被放入to区且from区被清空,幸存对象计数加1;
  4. 依次类推,直到幸存对象的计数达到16,则会被放入老年代。

注意:一般来说,垃圾回收会频繁在新生区进行,很少在老年代进行,几乎不在元空间进行。

(2)特殊情况概述:

  1. 对象currObj非常大,以至于Eden区的全部空间都放心下;
  2. 进转而将currObj放在老年代,如果此时老年代的空间足够则放下,如果老年代的空间仍然不够则报错OOM;

🍭线程私有的

🌴程序计数器

字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。程序计数器的作用就是存储下一条指令的地址。由执行引擎读取下一条指令。

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

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

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

栈帧何时会被弹出?(1)方法执行结束;(2)方法抛出异常。

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

(1)局部变量表

  • 局部变量表定义为一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量;

  • 线程私有,不存在数据安全问题,随着栈帧的销毁随之销毁;

  • 局部变量表所需的容量大小在编译期间确定下来,运行期间不会改变;

  • 最基本的存储单元是slot(槽),32位以内的变量(char、float、short、int、boolean)占用一个槽,64位的变量(long、double)占用两个槽;

  • 注意局部变量必须显示赋值,否则无法使用。

(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)

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

🌴线程本地分配缓冲区(TLAB)

【1】什么是 TLAB?

TLAB 的全称是 Thread-Local Allocation Buffer,即线程本地分配缓冲区

它是一个线程专用的内存分配区域,属于 Java 堆内存的一部分。JVM 为每个线程在 Eden 区(新生代)分配一小块私有内存,这就是 TLAB。当线程需要创建新对象时,会优先尝试在自己的 TLAB 中进行分配,从而避免与其他线程竞争共享的堆内存,提高了内存分配的效率。

【2】为什么需要 TLAB?(解决的问题)

在多线程环境下,对象分配是一个非常频繁的操作。如果所有线程都直接在共享的堆内存(尤其是 Eden 区)上分配对象,就需要通过加锁(锁机制) 来保证线程安全,避免多个线程使用同一块内存地址。

频繁的加锁和解锁操作会带来严重的性能开销。TLAB 的引入正是为了消除这种锁竞争,其核心思想是: “以空间换时间”:通过给每个线程预先分配一块专属的“自留地”,让大部分的内存分配操作变得线程私有,无需加锁,极大地提升了分配速度。

【3】TLAB 的工作流程

  1. 初始化:当线程启动时,JVM 会为其在 Eden 区分配一个 TLAB。每个 TLAB 的大小不是固定的,JVM 会根据运行时的分配情况动态调整。
  2. 分配对象
    • 当线程需要分配一个新对象时,它首先会从自己的 TLAB 中分配内存。
    • 分配过程非常简单,只是一个简单的指针加法(bump-the-pointer):检查 TLAB 中剩余的空间是否大于对象所需的大小。如果足够,就将 TLAB 当前的顶部指针(top)移动对象大小的距离,然后将这块内存区域返回给线程使用。这个过程是线程私有的,无需任何同步。
  3. TLAB 耗尽
    • 如果当前 TLAB 的剩余空间不足以分配新对象,这个 TLAB 就被“耗尽”了。
    • 线程会向 JVM 申请一个新的 TLAB。
    • 申请新 TLAB 的过程是需要同步的,但因为这个操作发生的频率远低于单个对象分配的频率,所以性能开销很小。
  4. 废弃 TLAB 的处理:被线程丢弃的旧 TLAB 就变成了 Eden 区中的普通内存块,仍然可以被其他线程在分配大对象时使用(因为大对象无法在 TLAB 中分配),或者在下一次垃圾回收(Minor GC)时被回收。

【4】TLAB 的关键特性

  • 小对象分配:TLAB 通常只用于分配小型对象。因为 TLAB 本身尺寸不大,如果对象过大,直接无法在 TLAB 中分配。
  • 大对象分配:如果一个对象的大小超过 TLAB 的剩余空间,甚至超过整个 TLAB 的大小,它会被直接分配到 Eden 区的共享区域(这一步可能需要锁),或者如果它特别大,可能会直接晋升到老年代。
  • 指针碰撞:TLAB 内的分配使用“指针碰撞”方式,效率极高。
  • 动态调整:JVM 会监控线程的分配行为,并动态调整每个线程的 TLAB 大小,以在提高分配效率和减少内存浪费之间找到平衡。

【5】线程数急剧增长的情况

JVM会收缩 TLAB -> TLAB 分配失败 -> 触发 GC -> 直接分配在 Eden(加锁) -> 必要时分配在老年代 -> 频繁GC或OOM

实际启示

  1. 不要过度创建线程:线程本身是有开销的(内存:栈内存、TLAB;调度:CPU 上下文切换)。应根据实际业务需求和机器资源,使用合理大小的线程池。
  2. 监控是关键:使用 jstat、GC 日志(-Xlog:gc*)等工具监控 GC 频率、Eden 区使用情况、TLAB 分配情况。如果看到 TLAB 尺寸变得很小且 GC 频繁,可能就是线程数过多或 Eden 区过小的信号。
  3. 合理配置堆大小:如果应用确实需要大量线程(例如高并发的网络服务),在调整线程池大小的同时,也需要相应地增加堆内存(特别是新生代的大小,-Xmn),为 TLAB 提供足够的“弹药”。
  4. 了解对象大小:关注大对象的创建,因为它们会绕过 TLAB 优化并可能直接晋升老年代,对性能影响更大。
❓ 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. 动态调整大小:元空间可以根据需要动态调整大小。如果没有指定元空间的大小上限,它会根据需要自动增长。这种灵活性使得元空间能够更好地适应不同的应用程序和负载。
运行时常量池字符串常量池的区别?

运行时常量池(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 及以后方法区的实现变成了元空间。

  • 永久代默认大小20.75M 默认最大值82M
  • 元空间默认大小21M 默认最大值无限制(使用本地内存 受限于本地内存)

.class 文件可以解析出来的所有元数据

1. 类型信息 (Type Information)

这是类的基本身份信息,相当于一个人的身份证。

  • 类的全限定名 (Fully Qualified Name):如 java.lang.String
  • 类的直接父类的全限定名:对于所有非Object的类,都有父类信息。
  • 类的修饰符 (Access Flags):public, abstract, final 等关键字的信息。
  • 类实现的接口列表:一个有序的、包含所有直接实现接口名称的列表。
  • 类的类型:是普通类、接口、枚举还是注解。

2. 运行时常量池 (Runtime Constant Pool)

这是**.class文件中“常量池”的内存映射**,是方法区中非常核心的一部分。每个被加载的类都有自己的运行时常量池。它包含的内容远比其名字所暗示的“常量”要丰富,主要包括:

  • 字面量 (Literals):文本字符串、final常量、以及基本数据类型(如int, long)的值。
  • 符号引用 (Symbolic References)
    • 类和接口的全限定名
    • 字段的名称和描述符
    • 方法的名称和描述符
    • 方法句柄和方法类型
    • 动态调用点和动态常量

重要提示:符号引用在类加载的解析(Resolution)阶段,会被转变为直接引用(指向方法区的内存地址、指向方法字节码的指针等)。

3. 字段信息 (Field Information)

对于类中声明的每一个字段,都会存储以下信息:

  • 字段名称
  • 字段类型(基本类型或对象类型)
  • 字段的修饰符public, private, protected, static, final, volatile, transient 等。

4. 方法信息 (Method Information)

与字段信息类似,对于每个方法,都会存储其完整的描述:

  • 方法名称
  • 方法的返回类型(或void
  • 方法参数的数量、类型(按顺序)
  • 方法的修饰符public, private, protected, static, final, synchronized, native, abstract 等。
  • 方法的字节码 (Bytecodes)可执行代码部分(nativeabstract方法除外)。
  • 操作数栈和局部变量表的大小:这些信息在方法被调用时,用于在栈帧中分配内存。
  • 异常表:规定了try-catch语句捕获的异常类型和对应的处理代码位置。

5. 类变量(静态变量)的引用

  • 方法区并不直接存储静态变量的值。
  • 它存储的是对这些静态变量的引用静态变量本身在逻辑上是方法区的一部分,但其实际值(即static变量对应的实例)是存储在堆(Heap)中的
  • 对于基本类型(如 static int),其值就存储在堆中对应的Class对象之后。
  • 对于引用类型(如 static Object),在堆中存储的是一个指向实际对象的地址。

6. 指向类加载器的引用 (Reference to ClassLoader)

  • 存储了加载该类的 ClassLoader 实例的引用。
  • 这个引用对于确定类的唯一性(命名空间)、实现垃圾回收(判断类是否“无用”)以及打破双亲委派模型(如Tomcat)至关重要。

7. 指向Class类的引用 (Reference to java.lang.Class)

  • JVM在堆(Heap) 中为每个加载的类创建一个 java.lang.Class 对象的实例。
  • 方法区中存储着指向这个Class对象的引用。
  • 这个 Class 对象是Java程序访问方法区中所有这些元数据的对外接口和入口。通过反射(如 MyClass.classobj.getClass())获取到的就是这个对象。

8. 方法表 (Method Table)

  • 这是一个为了提高方法调用效率(特别是虚方法调用)而存在的数据结构,本质上是一个指向方法字节码的指针数组。
  • 对于非接口、非抽象类,如果它定义了方法,JVM就会为其生成一个方法表。
  • 在执行诸如 invokevirtual(调用实例方法)这样的指令时,JVM通过方法表来执行动态分派,找到实际应该执行的方法版本。这是实现多态性的关键机制。

代码示例:

/**
 * 这个类用于演示JVM方法区中存储的各类Class信息。
 * 对应【1. 类型信息 (Type Information)】
 * - 全限定名: com.example.advanced.jvm.MethodAreaDemo
 * - 直接父类: java.lang.Object (隐式继承)
 * - 实现的接口: Runnable, Serializable
 * - 修饰符: public, final
 * - 类型: 普通类 (非接口、枚举、注解)
 */
public final class MethodAreaDemo extends Object implements Runnable, java.io.Serializable {

    /**
     * 对应【2. 运行时常量池 (Runtime Constant Pool)】中的字面量
     * - 数字字面量: 10, 3.14, 123456789L
     * - 字符串字面量: "CONSTANT_STRING", "Hello"
     * 同时也对应【5. 类变量】的引用 (因为被static修饰)
     */
    public static final int CONSTANT_INT = 10;
    public static final double CONSTANT_DOUBLE = 3.14;
    public static final long CONSTANT_LONG = 123456789L;
    public static final String CONSTANT_STRING = "CONSTANT_STRING";

    /**
     * 对应【3. 字段信息 (Field Information)】
     * - 字段名: instanceField
     * - 类型: String (引用类型)
     * - 修饰符: private (无static, 故为实例字段)
     */
    private String instanceField;

    /**
     * 对应【3. 字段信息】和【5. 类变量】
     * - 字段名: staticField
     * - 类型: int (基本类型)
     * - 修饰符: private, static
     * 注意:staticField变量的"值"本身存储在堆中,但字段的"描述信息"存储于方法区。
     */
    private static int staticField;

    /**
     * 对应【4. 方法信息 (Method Information)】 - 构造器
     * - 方法名: <init> (编译器生成的构造器方法名)
     * - 返回类型: void (构造器无返回类型)
     * - 参数: String (一个String类型的参数)
     * - 修饰符: public
     * - 字节码: 方法体内的代码编译后的字节码指令
     * - 操作数栈/局部变量表: JVM根据字节码计算出的栈帧大小信息
     */
    public MethodAreaDemo(String initialValue) {
        this.instanceField = initialValue; // 字节码指令: aload_0, aload_1, putfield
    }

    /**
     * 对应【4. 方法信息】 - 实例方法
     * - 方法名: getInstanceField
     * - 返回类型: String
     * - 参数: 无
     * - 修饰符: public
     * - 字节码: 方法体内的代码 (return指令等)
     */
    public String getInstanceField() {
        return instanceField; // 字节码指令: aload_0, getfield, areturn
    }

    /**
     * 对应【4. 方法信息】 - 静态方法
     * - 方法名: staticMethod
     * - 返回类型: void
     * - 参数: int
     * - 修饰符: public, static
     * - 字节码: 方法体内的代码 (getstatic, iinc, putstatic等指令)
     */
    public static void staticMethod(int increment) {
        staticField += increment; // 字节码指令: getstatic, iload_0, iadd, putstatic
        System.out.println("Static field is now: " + staticField); // 涉及【2. 运行时常量池】中的字符串字面量 "Static field is now: "
    }

    /**
     * 对应【4. 方法信息】 - 重写的接口方法 (来自Runnable)
     * - 方法名: run
     * - 返回类型: void
     * - 参数: 无
     * - 修饰符: public
     * - 异常表: 由于有try-catch,字节码中包含异常表信息,指定捕获Exception及其处理代码位置
     */
    @Override
    public void run() {
        try {
            System.out.println("Thread is running with instance field: " + this.instanceField); // 使用【2. 运行时常量池】中的字符串字面量
        } catch (Exception e) { // 异常处理信息存储在方法信息的异常表中
            e.printStackTrace();
        }
    }

    /**
     * 对应【4. 方法信息】 - 重载方法
     * - 方法名: overloadedMethod (与下面的方法同名,构成重载)
     * - 返回类型: void
     * - 参数: String
     * - 修饰符: private
     */
    private void overloadedMethod(String input) {
        System.out.println(input.toLowerCase());
    }

    /**
     * 对应【4. 方法信息】 - 重载方法
     * - 方法名: overloadedMethod
     * - 返回类型: int
     * - 参数: int
     * - 修饰符: private
     * 方法表 (Method Table) 中会包含这两个overloadedMethod的条目,JVM根据参数类型和数量来决定调用哪一个。
     */
    private int overloadedMethod(int input) {
        return input * 2;
    }


    // ========== 以下信息由JVM运行时添加,并非直接由源代码声明产生 ========== //

    /**
     * 【6. 指向类加载器的引用 (Reference to ClassLoader)】
     * 此信息由JVM在加载此类时自动关联并存储。
     * 例如,是由AppClassLoader、ExtClassLoader还是自定义ClassLoader加载的。
     */

    /**
     * 【7. 指向Class类的引用 (Reference to java.lang.Class)】
     * JVM会在堆中创建一个java.lang.Class对象来表示MethodAreaDemo类。
     * 方法区中存储着指向这个Class对象的引用。
     * 我们通过 MethodAreaDemo.class 或 obj.getClass() 获取的就是它。
     */

    /**
     * 【8. 方法表 (Method Table)】
     * JVM为此类(非接口)生成的一个方法指针数组,用于高效实现虚方法分派(多态)。
     * 表中包含了getInstanceField(), run(), overloadedMethod(String), overloadedMethod(int)等
     * 虚方法的实际入口地址。staticMethod不在其内,因为它是静态方法。
     */
}

.class对象中可以提取到哪些信息?

(1)类型信息;

Class<MethodAreaDemo> clazz = MethodAreaDemo.class;

// 1. 获取全限定名
String className = clazz.getName(); // "com.example.advanced.jvm.MethodAreaDemo"
String simpleName = clazz.getSimpleName(); // "MethodAreaDemo"

// 2. 获取直接父类的Class对象
Class<?> superclass = clazz.getSuperclass(); // java.lang.Object.class

// 3. 获取实现的接口列表
Class<?>[] interfaces = clazz.getInterfaces(); 
// [java.lang.Runnable.class, java.io.Serializable.class]

// 4. 获取类的修饰符
int modifiers = clazz.getModifiers(); // 一个整数位掩码
String modifierStr = Modifier.toString(modifiers); // "public final"
boolean isFinal = Modifier.isFinal(modifiers); // true
boolean isInterface = clazz.isInterface(); // false

// 5. 获取类的类型信息
boolean isEnum = clazz.isEnum(); // false
boolean isAnnotation = clazz.isAnnotation(); // false
boolean isArray = clazz.isArray(); // false

(2)字段信息;

// 获取所有声明的字段(包括private的,但不包括继承的)
Field[] declaredFields = clazz.getDeclaredFields();
for (Field field : declaredFields) {
    // 字段名: "instanceField", "staticField", "CONSTANT_INT", etc.
    String fieldName = field.getName();
    // 字段类型: String.class, int.class, double.class, etc.
    Class<?> fieldType = field.getType();
    // 字段修饰符: "private", "public static final"
    String fieldModifiers = Modifier.toString(field.getModifiers());
}
// 获取所有公共字段(包括继承的)
Field[] publicFields = clazz.getFields(); 
// 只会找到 public 的字段: CONSTANT_INT, CONSTANT_DOUBLE, etc.

(3)方法信息

// 获取所有声明的方法(包括private的,但不包括继承的)
Method[] declaredMethods = clazz.getDeclaredMethods();
for (Method method : declaredMethods) {
    // 方法名: "getInstanceField", "run", "staticMethod", "overloadedMethod"
    String methodName = method.getName();
    
    // 方法返回类型: String.class, void.class, int.class
    Class<?> returnType = method.getReturnType();
    
    // 方法参数类型列表: [String.class], [int.class], []
    Class<?>[] parameterTypes = method.getParameterTypes();
    
    // 方法异常列表: [Exception.class] (对于run方法)
    Class<?>[] exceptionTypes = method.getExceptionTypes();
    
    // 方法修饰符: "public", "private static", etc.
    String methodModifiers = Modifier.toString(method.getModifiers());
}

// 获取所有公共方法(包括继承自Object的方法)
Method[] publicMethods = clazz.getMethods(); 
// 会找到: getInstanceField, run, staticMethod, 以及 wait, notify, toString 等来自Object的方法

(4)构造器信息

// 获取所有声明的构造器
Constructor<?>[] declaredConstructors = clazz.getDeclaredConstructors();
for (Constructor<?> constructor : declaredConstructors) {
    // 参数类型列表: [String.class]
    Class<?>[] parameterTypes = constructor.getParameterTypes();
}

// 获取特定参数类型的公共构造器
try {
    Constructor<MethodAreaDemo> constructor = clazz.getConstructor(String.class);
} catch (NoSuchMethodException e) {
    // ...
}

(5)注解信息

// 获取类上的所有注解
Annotation[] annotations = clazz.getAnnotations();

// 获取指定类型的注解
SomeAnnotation annotation = clazz.getAnnotation(SomeAnnotation.class);

(6)操作类变量和实例变量

MethodAreaDemo instance = new MethodAreaDemo("Test");

// 获取并操作实例字段
Field instanceField = clazz.getDeclaredField("instanceField");
instanceField.setAccessible(true); // 突破private限制
String value = (String) instanceField.get(instance); // 读取值: "Test"
instanceField.set(instance, "New Value"); // 写入新值

// 获取并操作静态字段
Field staticField = clazz.getDeclaredField("staticField");
staticField.setAccessible(true);
int staticValue = (int) staticField.get(null); // 读取静态值,传入null
staticField.set(null, 100); // 设置静态值,传入null

(7)执行方法

// 调用实例方法
Method getInstanceFieldMethod = clazz.getMethod("getInstanceField");
String result = (String) getInstanceFieldMethod.invoke(instance); // 调用方法

// 调用静态方法
Method staticMethod = clazz.getMethod("staticMethod", int.class); // 获取方法,指定参数类型
staticMethod.invoke(null, 5); // 调用静态方法,传入null作为实例参数

// 调用重载方法 (需要精确匹配参数类型)
Method overloadedMethodStr = clazz.getDeclaredMethod("overloadedMethod", String.class);
Method overloadedMethodInt = clazz.getDeclaredMethod("overloadedMethod", int.class);
overloadedMethodInt.invoke(instance, 10); // 调用 int 版本的重载方法

(8)其他重要信息

// 获取类加载器 (【指向类加载器的引用】)
ClassLoader classLoader = clazz.getClassLoader();
// 可能是 AppClassLoader, ExtClassLoader, 或者自定义加载器

// 创建新实例 (本质上调用了构造器)
MethodAreaDemo newInstance = clazz.newInstance(); // 已废弃,推荐用构造器
Constructor<MethodAreaDemo> cons = clazz.getConstructor(String.class);
MethodAreaDemo newInstance = cons.newInstance("Constructor Arg");

// 判断对象类型
boolean isInstance = clazz.isInstance(someObject); // someObject 是否是此类的实例

直接内存

直接内存(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日