目录

  • Java 内存区域
  • JVM 垃圾回收
  • 类加载器
  • HotSpot 虚拟机对象实现

一、Java 内存区域

Java 运行时内存区域

程序计数器

当前线程所执行的字节码的行号指示器。字节码解释器工作时通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等功能都需要依赖这个计数器来完成。程序计数器是唯一一个不会出现 OutOfMemoryError 的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡。


虚拟机栈

除Native方法外,所有的方法都是通过栈来实现的。方法调用的数据需要通过栈进行传递,每次方法调用,都会有一个对应的栈帧被压入栈中,每次方法调用结束,都会有一个栈帧被弹出。栈的生命周期也和线程相同。
虚拟机栈

  • 局部变量表:存放了编译期可知的各种数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型)。
  • 操作数栈:作为方法调用的中转站,用于存放方法执行过程中的临时操作数和中间计算结果。由于JVM的无寄存器设计,操作数栈成为核心的计算区域。例如:

    int a = 2;
    int b = 3;
    int c = a + b;
    
    // 对应的JVM字节码
    iconst_2    // [2]    将整数 2 压入操作数栈
    istore_1    // []     将栈顶值弹出,存入局部变量表 slot1 (a)
    iconst_3    // [3]    将整数 3 压入操作数栈
    istore_2    // []     将栈顶值弹出,存入局部变量表 slot2 (b)
    iload_1     // [2]    将局部变量表 slot1 的值压入操作数栈
    iload_2     // [2,3]  将局部变量表 slot2 的值压入操作数栈
    iadd        // [5]    从操作数栈弹出两个值,执行加法,并将结果压入操作数栈
    istore_3    // []     将栈顶值弹出,存入局部变量表 slot3 (c)
  • 动态链接:指向运行时常量池中的方法引用(Method References)。用于在方法中调用其他方法时,将Class文件常量池中指向方法的符号引用(因为编译期无法知道实际内存地址)转化为其在内存地址中的直接引用

 
程序运行中栈可能会出现两种错误:

  1. StackOverFlowError:栈的内存大小不允许动态扩展时,线程请求栈的深度超过当前虚拟机栈最大深度(如函数调用陷入无限循环)。
  2. OutOfMemoryError:栈的内存大小允许动态扩展时,虚拟机在动态扩展栈时无法申请到足够的内存空间。

本地方法栈

与虚拟机栈类似,区别在于其为Native方法服务,HotSpot中与虚拟机栈合二为一。


在JVM启动时创建,是由所有线程共享的一块最大的内存区域。堆的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。

JDK1.7后默认开启逃逸分析,如果某些方法中的对象引用没有被返回或未被外面使用(即未逃逸),那么对象可以直接在栈上分配内存。

堆
堆也称为GC堆,是垃圾收集器管理的主要区域。收集器基本都采用分代垃圾收集算法,因此堆可以细分为:新生代、老年代、永久代,JDK8后永久代被元空间取代。新生代还能划分为Eden、Survivor。详见内存分配与回收原则

程序运行中堆最容易出现 OutOfMemoryError 错误,例如:

  1. java.lang.OutOfMemoryError: GC Overhead Limit Exceeded:当JVM花费过多的时间进行GC,却只能回收很少的堆空间时。
  2. java.lang.OutOfMemoryError: Java heap space:堆内存不足以存放新创建的对象。(受制于配置的最大堆内存 -Xmx 和物理内存大小。)

方法区

方法区是JVM运行时线程共享的一块逻辑区域(抽象概念),在不同的虚拟机上,方法区的实现(如永久代、元空间)是不同的。当JVM要使用一个类时,它会读取并解析Class文件获取相关信息(类信息、字段信息、方法信息、常量、静态变量、即时编译器编译后的代码缓存等),再将信息存入到方法区。

为什么要将永久代 (PermGen) 替换为元空间 (MetaSpace) ?

  1. 永久代受制于JVM设置的固定大小 -XX:MaxPermSize ,启动后无法调整,加载大量类时容易出现 java.lang.OutOfMemoryError: PermGen space 错误;而元空间使用本地内存,加载多少个类的元数据只受制于本机实际可用内存,溢出的概率更小。(元空间溢出时会出现 java.lang.OutOfMemoryError: MetaSpace 错误)

    -XX:MaxMetaspaceSize:设置最大元空间大小,默认值为unlimited。
    -XX:MetaspaceSize:设置元空间的初始大小,如果未指定,则根据运行时的应用程序需求动态调整。
  2. 永久代与堆共享垃圾回收器,增加了GC的复杂度,且回收效率低。
  3. JDK8以前,HotSpot通过永久代存储类的元数据,而JRockit中没有类似的概念,因此在两者合并后,就没有必要额外设置一个永久代了。

运行时常量池

Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有用于存放编译期生成的各种字面量符号引用常量池表(Constant Pool Table)。常量池表会在类加载后存放到方法区或元空间的运行时常量池中,内存受制于方法区或元空间。

  • 字面量:程序中显式写出的值,如数字、字符、字符串等。
  • 符号引用:对类、字段、方法、接口方法等的引用。解析阶段就是JVM把常量池中的符号引用替换成直接引用的过程。

字符串常量池

字符串常量池是JVM为了提升性能和减少内存而开辟的一块区域,用于避免字符串的重复创建。在HotSpot的实现中,通过哈希表保存字符串(key)和堆中字符串对象的引用(value)的映射关系。

JDK1.7前,字符串常量池和静态变量在永久代(即方法区)中;JDK1.7后移到了堆中。这是因为永久代的GC回收效率太低,只有在整堆收集(Full GC)的时候才会执行;将字符串常量池放到堆中,能够更高效及时地回收字符串内存。


直接内存 / 堆外内存

直接内存并不是JVM运行时数据区域的一部分,但是这部分内存也被频繁地使用,也会导致 OutOfMemoryError 错误。这些内存直接受操作系统管理(而不是JVM),能够在一定程度上减少垃圾回收对应用程序造成的影响。

JDK1.4 中新加入的 NIO(Non-Blocking I/O),引入了一种基于通道(Channel)与缓冲区(Buffer)的 I/O 方式,它可以直接使用Native函数库分配堆外内存,然后通过一个存储在堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作,避免了在Java堆和Native堆(本地内存)之间来回复制数据。Native堆不会受到JVM的垃圾回收机制影响,必须手动管理内存的分配和释放。

二、JVM 垃圾回收

内存分配与回收原则

1. 对象优先在 Eden 区分配
大多数情况下,对象会先在Eden区分配。若Eden区空间不够,则发起一次Minor GC。

2. 大对象直接进入老年代
需要大量连续内存空间的对象(如字符串、数组)直接进入老年代,从而减少新生代的垃圾回收频率和成本。该行为由具体使用的垃圾回收器的相关参数动态决定。

3. 长期存活的对象进入老年代
如果在Eden区出生的对象经过第一次Minor GC后仍能存活,并且能被Survivor区容纳,则进入Survivor区的S0或S1,将年龄设为1。Survivor中的对象每熬过一次MinorGC,年龄就增加1岁;当年龄超过阈值时,就会晋升到老年代中。

Hotspot对象晋升到老年代的年龄阈值:遍历所有对象,按年龄从小到大对其占用大小进行累加,若累加到某个年龄时,占用大小超过了Survivor区的一半,则取这个年龄和 MaxTenuringThreshold(4位,0-15)中更小的值作为新阈值。

4. 空间分配担保机制
确保在Minor GC之前老年代还有能容纳新生代所有对象的剩余空间。
空间分配担保机制


死亡对象判断方法

垃圾回收前的第一步就是要判断哪些对象已经死亡(即不能再被任何途径使用)。

  1. 引用计数法:每被一个地方引用计数器就加1,引用失效则减1,计数器为0的对象就是不可能再被使用的。这个方法实现简单且效率高,但没有被主流的虚拟机所采纳,因为它很难解决对象之间循环引用的问题(A引用B,B引用A)。
  2. 可达性分析算法:通过一系列的称为GC Roots的对象作为起点,从这些节点开始向下搜索,节点所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连的话,则证明此对象需要被回收。这样,即使一些对象间互相引用,但它们到GC Roots不可达,因此会被回收。能够作为GC Roots的节点有:

    • 虚拟机栈(栈帧中的局部变量表)中引用的对象
    • 本地方法栈(Native方法)中引用的对象
    • 方法区中类静态属性引用的对象
    • 方法区中常量引用的对象
    • 所有被同步锁持有的对象
    • JNI(Java Native Interface)引用的对象

     
    但要真正宣告一个对象死亡,至少要经历两次标记过程:可达性分析法中不可达的对象被第一次标记,之后会根据此对象是否有必要执行 finalize 方法进行筛选。如果对象没有覆盖 finalize 方法,或 finalize 方法已经被虚拟机调用过,则认为没有必要执行。而被判定为需要执行的对象会被放在一个队列中进行第二次标记,除非这个对象与引用链上的任何一个对象建立关联,否则就会被回收。

finalize 在JDK9后被弃用,其初衷是让开发者能够清理对象的资源,但是:

  • finalize 是垃圾回收过程的一部分,其额外处理增加了内存管理的复杂度,会影响垃圾回收的效率。
  • 调用时机由垃圾回收器决定,而GC的时间和频率是不确定的。finalize 方法可能在对象被回收很久后才执行,或在程序结束后都没有执行。
  • finalize 内部抛出的异常会被JVM捕获并忽略,可能导致资源无法被释放。
  • finalize 方法中引用 this 会导致对象无法被回收,产生内存泄漏。
  • 替代方案:try-with-resources 语句或实现 AutoCloseable 接口。

 
如何判断一个常量是废弃常量?
如果字符串常量池中的某个字符串常量没有被任何 String 对象引用,则该字符串为废弃常量;如果运行时常量池中类、方法等的符号引用不再被任何地方使用(如类加载器卸载、类被回收等),这些常量也会成为废弃常量。

如何判断一个类是无用的类?
满足以下条件的类可以被回收(不是必然回收):

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

垃圾收集算法

1. 标记-清除算法:先标记出所有存活对象,标记完后统一回收掉没有被标记的对象。
标记-清除算法
特点:标记和清除的效率都不高;会产生大量不连续的内存碎片。

2. 复制算法:将内存分为大小相同的两块,每次使用其中的一块;当一块内存使用完后,就将存活对象复制到另一块,再将原来的一半内存全部回收。
复制算法
特点:可用内存缩小为原来的一半;如果存活对象数量多,复制性能会很差。

3. 标记-整理算法:标记后将所有存活对象向一端移动,然后清理掉端边界外的内存。
标记-整理算法
特点:整理的效率不高,但适合老年代这种GC频率不是很高的场景。

4. 分代收集算法(为什么要分代?):比如在新生代中,每次收集都会有大量对象死去,所以可以选择“复制”算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集;而老年代的对象存活几率更高,且没有额外的空间对它进行分配担保,所以必须选择“标记-清除”或“标记-整理”算法。


垃圾收集器

JDK 默认垃圾收集器

  • JDK8:Parallel Scavenge(新生代)+ Parallel Old(老年代)
  • JDK9~JDK22:G1

三、类加载器


四、HotSpot 虚拟机对象实现


参考资料:JavaGuide

2024-12-12 技术学习·none