【Java SE】认识JVM:内存区域划分、类加载机制与GC
2026/7/12 23:48:19 网站建设 项目流程

文章目录

  • 一、JVM基础与内存划分
    • 1.1 什么是JVM
    • 1.2 JVM完整执行流程
    • 1.3 内存区域划分
  • 二、类加载机制
    • 2.1 类完整生命周期
    • 2.2 双亲委派模型
      • 类加载器
      • 双亲委派模型过程
  • 三、垃圾回收GC原理
    • 3.1 GC工作流程
      • 1. 找到垃圾
        • (1)引用计数算法
        • (2)可达性分析
      • 2. 释放垃圾
    • 3.2 垃圾收集器

一、JVM基础与内存划分

1.1 什么是JVM

JVM全称Java Virtual Machine,Java虚拟机,是一套软件模拟的虚拟计算机,隔离底层操作系统,实现Java一次编译,到处运行

1.2 JVM完整执行流程

Java程序从.java到CPU执行分为四个模块协同工作:

  1. 类加载子系统 ClassLoader:读取class字节码文件,加载进内存;
  2. 运行时数据区 Runtime Data Area:存放程序运行期间所有内存数据;
  3. 执行引擎 Execution Engine:解析/编译字节码,转为操作系统指令;
  4. 本地接口 Native Interface:调用C/C++本地库,完成底层操作。

完整流程:
.java编译→.class字节码文件 → 类加载器加载至运行时数据区 → 执行引擎解析字节码 → 调用本地方法接口操作系统资源,CPU执行。

1.3 内存区域划分

在真实的操作系统中,对于进程的地址空间进行了功能区域划分,JVM仿照真实的机器和真实的操作系统进行设计。

JVM从操作系统中申请到了一些内存空间,供JVM进程的代码使用。

JVM内存空间的划分就是把JVM自身申请到的内存空间,再把内存按不同功能进行划分。类比把一栋大楼分成若干层,每一层细致划分。

核心区域

1. 程序计数器:记录当前线程执行字节码行号,执行Java方法存指令地址

这里的程序寄存器指内存上的空间,与计组中的程序寄存器不同,计组中的程序寄存器是在CPU上的

  • 唯一特性:JVM规范中唯一不会发生OOM的内存区域;
  • 线程私有:多线程切换时依靠PC寄存器恢复执行位置。

2. 元数据区:保存当前类被加载好的数据
.java文件经过javac编译变成.class文件,想要运行这个文件,就需要把字节码文件加载到内存中,也就是元数据区。

元信息就是指属性,例如类名称,访问权限,继承自哪些类或实现了哪些接口;方法名称,参数,返回值等。

Java8之前元数据区也称为”方法区“

3. 栈:保存方法调用关系
每次调用方法时都会进入方法内部,当方法调用完毕,返回调用位置,程序继续向后执行。这里的栈与数据结构中的栈逻辑类似,都具有后进先出的特性。

  • 与操作系统中的栈联系:
    JVM进程是C++代码编写的程序,本身就是存在一系列C++中的方法调用。维护这些C++方法的栈就是操作系统原生的栈。这些方法调用构成了JVM虚拟机程序。通过这些C++代码,解释执行.class文件中的字节码,这个过程又涉及到了Java方法调用,就构造出了Java的栈

栈区的两种异常:

  • StackOverflow:栈所占用的空间只有几十MB,如果递归函数出口逻辑错误,就有可能导致大量方法调用但是不返回,栈深度超出范围,造成栈溢出。
  • OOM:多线程大量创建线程,虚拟机无法分配栈内存。

4. 堆:保存对象实例,是JVM中最大的空间区域

假设创建一个对象实例:Test t = new Test()

  • 如果t是局部变量,t保存在栈中,函数调用完毕就会销毁
  • 如果t是成员变量,t保存在堆中
  • 如果t是静态成员变量,t保存在元数据区,随类加载创建
  • new Test()一定保存在堆上

如果堆上的对象不再使用就会被释放,也就是垃圾回收的工作。

元数据区和堆,整个Java进程共用一份,程序计数器和栈一个进程中可能有多份,每个线程各一份。

二、类加载机制

2.1 类完整生命周期

加载 → 连接(验证、准备、解析) → 初始化 → 使用 → 卸载

前3阶段合称类加载过程

  1. 加载 Loading
    1. 找到.class文件,根据类的全限定名(包名+类名,例如java.lang.String
    2. 打开文件,读取文件内容到内存中
  2. 验证 Verification:解析,校验.class文件合规性,防止恶意字节码破坏虚拟机,并把.class里的内容转换为结构化的数据(也就是符合虚拟机规范的格式);
  3. 准备 Preparation:给类对象申请内存空间,此时的空间相当于”全0“的状态;
  4. 解析 Resolution:初始化字符串常量。字符串常量本身就包含在.class中,解析阶段就要把解析出的字符串常量放到内存中(元数据区的常量池);
  5. 初始化 Initialization:针对类对象进行最后的初始化。为类对象各种属性进行填充,包括静态成员变量。如果这个类有父类,也会触发父类的加载。

一个进程中一个类的加载只会触发一次,并不是Java程序一启动就会加载所有的类,类加载也是懒汉模式(懒加载)的一种体现。

触发类加载的时机:

  1. 构造这个类的实例
  2. 调用类的静态属性或静态方法
  3. 使用某个类时,如果父类还没加载,也会触发父类的加载

2.2 双亲委派模型

类加载器

JVM中有专门的模块负责类加载,默认提供了三种类加载器:

  1. BootstrapClassLoader
  2. ExtensionClassLoader
  3. AplicationClassLoader

AplicationClassLoader中的parent引用指向ExtensionClassLoaderExtensionClassLoader中的parent引用指向BootstrapClassLoaderBootstrapClassLoader中的parent引用指向null,构成了类似链表关系。

这三个类加载器主要作用都是找.class文件,各自负责找的目录不同:

  • BootstrapClassLoader负责找Java标准库中的类
  • ExtensionClassLoader负责找Java扩展库中的类。扩展可以认为JVM厂商对于Java的库做的扩充
  • AplicationClassLoader负责找第三方库/当前项目。(当前学习过程中,通过Maven下载的类都属于第三方库)

双亲委派模型过程

  1. 进行类加载时,通过全限定名找.class文件时,就会从AplicationClassLoader作为入口开始,把“加载类”这样的任务委托给它的父亲ExtensionClassLoader来执行
  2. ExtensionClassLoader也不会立即干活,先把任务委托给它的父亲AplicationClassLoader来执行
  3. AplicationClassLoader也想踢足球,但是它的parent为空,只能自己进行类加载,从标准库中找是否匹配的.class文件。如果没找到就把任务还给孩子ExtensionClassLoader
  4. ExtensionClassLoader在扩展库中继续找.class文件,找到就加载,没找到就继续还给孩子AplicationClassLoader
  5. AplicationClassLoader继续负责找.class文件,找到就加载;没找到就抛出ClassNotFount异常

也可以自定义类加载器,参与到双亲委派模型中

  • 双亲委派模型两大优势
    • 避免类重复加载;
    • 安全防护:防止开发者自定义java.lang.Object篡改核心API。

三、垃圾回收GC原理

GC仅关注堆、方法区,虚拟机栈、本地方法栈、程序计数器生命周期随线程自动释放,无需回收。

3.1 GC工作流程

GC主要做两个工作:

  1. 找到垃圾(不再使用的对象)
  2. 释放垃圾(释放对应的内存)

1. 找到垃圾

(1)引用计数算法

每个对象在创建的的时候,就会分配一小块内存空间,保存一个整数,表示有多少个引用指向它,每次进行引用赋值的时候,都会触发引用计数的修改。

在Java中,使用对象一定要通过引用实现,当引用计数为0时,说明没有引用指向这个对象了,这样就可以判断这个对象就是垃圾

缺陷:

  1. 内存消耗更多,尤其对象本身较小时,引用计数消耗的占比就更大
  2. 可能出现循环引用

举个例子:

classTest{Testt=null;}classMain{publicstaticvoidmain(String[]args){Testa=newTest();//计数器+1Testb=newTest();//计数器+1a.t=b;//计数器+1b.t=a;//计数器+1}}

此时两个对象实例各有两个引用指向

如果把a和b都置为null

程序计数器各-1,不为0,但是都不能被调用。

PHP,Python都采用计数算法,搭配其他方案解决循环引用问题

(2)可达性分析

以代码中特定的对象作为遍历的起点,也称为GCRoots,进行遍历,来判定某个对象是否能被访问到到。每遍历一个对象,就标记为“可达”,剩余没有被标记的就认为是不可达,就是接下来要回收的垃圾了。

可作为GC Roots的对象:

  1. 栈上的局部变量(引用类型)
  2. 常量池中引用指向的对象
  3. 静态成员(引用类型)

2. 释放垃圾

垃圾清除有四种算法:

  1. 标记-清除:把垃圾对象的内存直接进行释放
    缺陷:会产生“内存碎片问题”,很难找到大块的连续的内存空间

  2. 复制算法:把内存分为两部分,起始都保存在A区域;当垃圾回收时,把不释放的数据复制到B区域,再把A区域整体释放。这样就确保空闲内存都是连续的
    缺陷:内存空间利用率低,直接砍半;如果不需要释放的数据很多,复制开销很大

  3. 标记-整理:类似于顺序表删除,把不需要释放的数据重新搬运整理
    缺陷:内存搬运数据的操作开销也很大,只解决了空间成本问题。

  4. 分代收集:综合前三种方案。“代”指的是对象的年龄,单位是GC的轮次。

    1. 每个对象初始年龄是0,每经过一轮GC可达性分析,不是垃圾的对象年龄+1
    2. 根据年龄,把对象分为新生代和老年代。经过统计发现,如果一个对象是新生儿,大概率死的很快;如果一个对象是老油条,大概率能活很长时间。
    3. 对象在新生代反复被GC筛选,年龄增长,进入老年代。

堆区域划分

  • 新生代 Young:新建对象存放,98%对象朝生夕死。新生代进一步划分:
    • Eden 伊甸园:80%新生代,是所有新对象诞生地,由伊甸园到幸存区采用复制算法,因为绝大部分对象都活不过一轮GC,复制对象少,开销可控;
    • S0/S1 Survivor幸存者:各10%。幸存区的对象也要经历GC扫描,每一轮GC消灭一大批对象,然后通过复制算法把存活的对象复制到另一个幸存区;如果一个对象经过多次复制,都存活下来,就可以晋升到老年代了;
  • 老年代 Old:长期存活对象、大对象。GC频次比较低,采用标记-整理算法;

一个对象经历的区域:伊甸园 -> 幸存区->幸存区->...->幸存区->老年代新生代统一采用复制算法;老年代采用标记整理

特殊情况:如果某个对象很大,复制成本很高,可以跳过新生代,进入老年代

3.2 垃圾收集器

在JVM中,由垃圾回收器来实现上述分代回收的策略。

分代回收只是最基本的策略,落实到垃圾收集器上,还有更特殊和进阶的策略。

收集器分代特点使用场景
Serial新生代单线程串行,STWClient客户端、小内存程序
ParNew新生代Serial多线程版,唯一可配合CMS老年代CMS搭配使用
Parallel Scavenge新生代吞吐量优先,自适应调参后台计算、无交互服务
Serial Old老年代单线程标记整理Client、CMS失败后备方案
Parallel Old老年代多线程吞吐量优先高吞吐离线任务
CMS老年代并发低停顿,标记清除,碎片多JDK8前互联网低延迟服务
G1全堆分区Region可预测停顿,自动压缩,替代CMS大内存服务端,JDK9默认

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询