ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

读懂 JVM:内存模型、类加载机制、分代回收

读懂 JVM:内存模型、类加载机制、分代回收

JVM

  • 前言
  • 什么是JVM
  • JVM运行时数据区
    • 方法区(元数据区)
    • 堆Heap
    • 栈
    • 程序计数器
    • 本地方法栈
    • 例题
  • 类加载
    • 类加载的基本流程
    • 双亲委派模型
  • 垃圾回收GC
    • 垃圾回收步骤
      • 找到垃圾
      • 回收垃圾
  • 总结

前言

前面我们把计算机网络协议栈梳理完毕,了解数据如何跨主机通信。
而写好的 Java 代码,是怎么在机器上运行的?这就是 JVM 的工作。
JVM 是 Java 程序运行的虚拟机,实现了跨平台(一次编译,到处运行),也是后端核心模块。

什么是JVM

  1. JVM全称Java Virtual Machine,Java虚拟机。
  2. 一个运行起来的Java进程就是一个JVM虚拟机。
  3. 核心特性:跨平台。
    Java 源码编译成.class字节码文件,JVM 负责解析执行字节码。不同操作系统有对应版本 JVM,一份 class 到处跑。
  4. 区分一下JDK、JRE、JVM
    (1)JDK = JRE + 开发工具(javac、Java等)
    (2)JRE = JVM + Java基本类库

JVM运行时数据区

Java进程运行过程中就要从操作系统申请一大块内存,这块内存划分成不同的区域,每个区域都有不同的作用。
下面详细分析这几个区域。

方法区(元数据区)

该区存储的内容就是类对象。
.class文件加载到内存之后,就成了类对象。

堆Heap

该区存储的内容是代码中new的对象实例、数组。
堆区是占据空间最大的区域,也最容易发生OOM,即内存溢出。

栈

该区存储的是,代码执行过程中方法之间的调用关系。
方法调用就压栈,方法执行结束出栈。

程序计数器

该区是一个比较小的空间,存放一个“地址”,表示下一条要执行的指令在内存中的哪个地方。而要执行的指令存放在方法区,每个方法里面的指令都是以二进制的形式保存在对应的类对象中。

classTest{publicvoida(){...}publicvoidb(){...}/*这里的方法a和方法b都会被编译成二进制指令,放到.class文件中,执行类加载时,就能把.class文件中 的内容加载到类对象中,此时,方法里面的指令就以二进制的形式保存在对应的类对象中。*/}
  1. 刚开始调用方法时,程序计数器中存储的是方法的入口地址。
  2. 随着一条条指令的执行,程序计数器的值会自动更新去指向下一条指令。
  3. 若是顺序执行,则下一条指令就是把指令地址进行递增。
  4. 若是循环或条件代码,则下一条指令可能会跳转到一个较远的地址。

本地方法栈

该区存放的是使用native修饰的方法。
该区的方法不是由Java实现的,而是在JVM内部由C++实现。

注意:程序计数器、虚拟机栈、本地方法栈,是线程私有,每个线程都有独立一份;堆、元空间(方法区)是线程共享,整个 JVM 进程只有一份。

下图为JVM内存模型。

例题

classTest{publicintn=100;publicstaticinta=10;voidmain(){Testt=newTest();}}

问:n,a,t三个变量分别存在哪个区域?
分析:一个变量存在于哪个区域,与变量的形态密切相关,与类型无关。
(1)局部变量—栈
(2)成员变量—堆
(3)静态变量—方法区
答:
(1)n—成员变量—对象中包含n这个属性—对象存在于堆上—n在堆上;
(2)a—静态变量—类属性—在类对象中—a在方法区(元数据区);
(3)t—方法内部的局部变量—栈帧中的局部变量表—t在栈上。

类加载

类加载的基本流程

Java代码会被编译成.class文件,Java程序要想运行起来,就需要让JVM读取到这些.class文件,并且把里面的内容构造成类对象,保存到内存的方法区。

  1. 加载

找到.class文件,打开文件,读取文件内容。

往往代码中给定某个类的全限定类名,JVM就会根据这个类名,在一些指定的目录范围内寻找。

  1. 验证

.class文件是二进制格式,因此需要验证当前读到的这个格式是否符合要求。

Java官方文档中规定了语言规范、虚拟机规范,其中,虚拟机规范中写明了.class文件要遵守的格式:

ClassFile{//相当于结构体u4 magic;//用来表示文件的格式u2 minor_version;u2 major_version;//这两个共同构成.class的版本号u2 constant_pool_count;cp_info constant_pool[constant_pool_count-1];//当前.class文件中用到的常量池u2 access_flags;//表示类的权限修饰符u2 this_class;//当前类的相关信息u2 super_class;//该类的父类的相关信息u2 interfaces_count;u2 interfaces[interfaces_count];//该类实现了哪些接口u2 fields_count;field_info fields[fields_count];//类中有哪些属性u2 methods_count;method_info methods[methods_count];//类中有哪些方法u2 attributes_count;attribute_info attributes[attributes_count];//类中有哪些注解}
  1. 准备

给类对象分配内存空间。
仅分配空间,未进行初始化,并赋予默认零值。

  1. 解析

针对类对象中包含的字符串常量进行处理。

  1. 由于,引用本质上保存的是一个变量的地址,在.class文件中,不涉及到内存地址,因此,字符串常量的初始化语句会先被设置成“文件的偏移量”,通过偏移量可以找到字符串常量所在的位置。
  2. 当这个类真正被加载到内存时,再将偏移量替换回内存地址。
  1. 初始化

针对类对象进行初始化,还需要初始化static成员,执行静态代码块,加载父类。

双亲委派模型

双亲委派模型,是“加载”过程中的一个环节,负责的是根据全限定类名找到.class文件。

  1. 类加载器
    JVM中内置了三个类加载器:
    (1)BootStrap ClassLoader—相当于爷爷—负责的是JDK标准库目录。
    (2)Extension ClassLoader—相当于父亲—负责的是JDK中一些扩展的库对应的目录。
    (3)Application ClassLoader—相当于孩子—负责的是项目当前目录+第三方库对应目录。
    它们直接不是继承关系,而是ClassLoader中有一个parent属性,指向一个父“类加载器”。

  2. 加载的过程

加载,也就是找.class文件的过程,其实就是一个简单的查找优先级问题。

(1)给定一个类的全限定类名,比如java.lang.Long。
(2)从Application ClassLoader作为入口,开始执行查找的逻辑。
(3)Application ClassLoader不会立即扫描自己负责的目录,而是甩锅,把查找任务交给他的父“类加载器”Extension ClassLoader。
(4)同样,Extension ClassLoader也不会立即扫描自己负责的目录,而是甩锅,把查找任务交给他的父“类加载器”BootStrap ClassLoader。
(5)BootStrap ClassLoader也不想立即扫描自己负责的目录,但是他没有父“类加载器”,只能自己来做,扫描标准库目录,若扫描到目标文件,任务结束,执行后续的类加载操作。
(6)若没有扫描到,就会回到Extension ClassLoader,他来扫描负责的扩展库目录,若扫描到目标文件,任务结束,执行后续的类加载操作。
(7)若没有扫描到,就会回到Application ClassLoader,他来扫描负责的当前项目和第三方库的目录,若扫描到目标文件,任务结束,执行后续的类加载操作。若没有扫描到,就抛出ClassNotFoundException异常。

这套流程的目的,确保了标准库的类被加载的优先级最高,其次是扩展库,最后是自己写的类和第三方库。

垃圾回收GC

  1. 回收的垃圾是内存中的对象,也就是堆中的内存。
  2. 垃圾回收机制GC:让JVM自行判定,内存中的这个对象是否还会再次使用,若不再使用,则回收。

栈中的局部变量,跟随栈帧的生命周期。
静态变量,生命周期是整个程序,始终存在,因此静态变量无需释放。

垃圾回收机制缺点:

  1. 系统开销,需要额外消耗CPU、内存资源:需要有一些特定线程,不停扫描内存中的所有对象,看是否能回收。
  2. 效率问题:这样的扫描线程,不一定能及时释放内存,一旦同一时刻,出现大量的对象都要被回收,GC产生的负担很大,可能会引起整个程序卡顿(STW问题:可达性分析标记阶段,会触发 STW(Stop The World),暂停业务用户线程,防止对象引用关系不断变化,保证标记结果准确。)。

垃圾回收步骤

找到垃圾

  1. 引用计数

给new出来的对象,单独安排一块空间,来保存一个计数器,用来描述有几个引用指向这个对象。

但在Java中不使用引用计数:
(1)浪费内存。计数器最小也要占用两个字节,若对象本身很小,计数器占据的内存空间比例大。
(2)引用计数机制存在循环引用问题。
例如:

2.可达性分析

  1. 可达性分析,本质上是时间换空间。
  2. 一组线程周期性扫描代码中所有对象,从一些特定的对象出发,尽可能地进行访问遍历,把所有能够访问到的对象标记为“可达”,最终,经过扫描之后,未被标记为“可达”的对象就是垃圾。也就是N叉树。
  3. 可达性分析的出发点很多:所有局部变量、常量池中引用变量、方法区中静态变量引用的对象,统称为GCRoots。

回收垃圾

  1. 标记清除

(1)标记清除就是把对应的对象的内存空间直接释放掉。
(2)但这样做会产生很多内存碎片,随时间推移,内存碎片越来越严重,会导致后续内存申请举步维艰。

  1. 复制算法

复制算法就是,通过复制的方式,把有效的对象归类到一起,再统一释放剩下的空间。

  1. 标记整理

标记:从GCRoots出发,标记所有存活对象;
整理:把所有存活对象向内存一侧移动、紧凑排列,垃圾全部挤到另一边,一次性清理掉边界以外的垃圾。
既解决了内存碎片,又能处理复制算法的利用率,但是开销大。

实际上, JVM 采取的释放思路是三者结合体,分代回收。

总结

  1. JVM实现Java跨平台,Java源码编译为class字节码,交由JVM解释执行。
  2. JVM运行时数据区分为线程私有区域(PC寄存器、虚拟机栈、本地方法栈)和线程共享区域(堆、元空间)。
  3. 类加载五大阶段:加载、验证、准备、解析、初始化;双亲委派保证JDK核心类安全。
  4. GC以可达性分析判定对象生死,分代回收,新生代复制算法,老年代标记整理。

本篇是「Java后端编程系列」的连载内容,点击链接查看完整系列:

🔹 上一篇:网络(六)|应用层 HTTP&HTTPS:URL、请求响应、Cookie 与证书

👉 点击直达 Java后端基础专栏合集

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进