JVM 总结

本文围绕JVM展开,介绍了JVM内存模型,包括堆、方法区、栈等的功能及可能出现的内存错误与解决办法。阐述了GC判断对象回收的方法,还介绍了多种垃圾收集器。此外,讲解了class文件结构、类加载过程、双亲委派模型及其破坏案例,以及JVM的整体结构。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

JVM 是用来解析和运行 Java 程序的,在 JDK 的 bin 目录

JRE - Java Runtime Environment
java 运行时环境,由 JVM(bin 目录) 和 java 类库(lib 目录)组成

JDK - Java Development Kit
java 最小开发环境,由 JRE 和 开发者工具 组成

OpenJDK 免费开源,Oracle JDK 生产环境商用要收费
Oracle JDK 比 OpenJDK 更稳定,性能更好

HotSpot VM 从 JDK 1.3 到 JDK 8 是 Sun/Oracle JDK 和 OpenJDK 默认的 Java 虚拟机

java 跨平台
java 编译器把 java 编译成一种跨平台的中间代码叫字节码, 存在 class 文件里, 各平台的 java 虚拟机都可以解释执行字节码,一次编译,处处运行

热点代码探测技术
通过执行计数器找出最具有编译价值的代码,通知即时编译器以方法为单位编译成物理硬件可直接执行的机器码
某个方法被频繁调用,触发标准即时编译
某个方法中有效循环次数很多,触发栈上替换编译 (OSR - On-Stack Replacement)
HotSpot VM 执行子系统 由编译器与解释器构成
通过编译器和解释器协同工作,在响应时间和执行性能中取得平衡

对象的创建过程
1.类加载
先检查指令的参数是否能在常量池中定位到一个类的符号引用,
并检查这个符号引用所代表的类是否已被加载,如果没有,
就先执行相应类的加载过程

2.为新对象分配内存
指针碰撞 bump the pointer
如果 堆 中的内存是绝对规整的,已使用内存和空闲内存各在一边,通过一个指针,把已使用内存和空闲内存分隔开,分配内存时,只需将指针向空闲内存移动一段与对象大小相等的距离

空闲列表 free list
如果 堆 中的内存是不规整的,已使用内存和空闲内存相互交错,虚拟机需维护一个列表,记录可用的内存块,分配的时候在列表里,找到一块足够大的内存,划给对象实例,并更新列表上的记录

分配方式由堆是否规整决定
带整理功能的收集器,分配算法采用指针碰撞,如 Serial, ParNew
基于标记清除算法的收集器,分配算法采用空闲列表,如 CMS

分配内存的线程安全性问题
同步处理
通过 CAS 加 失败重试 来保证更新操作的原子性

本地线程分配缓冲 TLAB - Thread Local Allocation Buffer
每个线程在 JAVA堆 中分配一小块内存作为分配缓冲区,某个线程要分配内存的时候,就从对应的 本地线程分配缓冲区 中分配,当本地缓存区用完之后,分配新的缓冲区时,才需要同步锁定

虚拟机是否使用 TLAB , 可以配置 -XX: +/-UseTLAB

3.把对象的实例字段初始化为零值

4.设置对象头信息
对象的类型信息,GC分代年龄等

5.调用构造函数

对象的内存布局

HotSpot 虚拟机,对象在 堆内存 中的存储布局分为
对象头 header, 实例数据 Instance Data, 对齐填充 Padding

对象头 header
Mark Word - 对象运行时数据
哈希码 HashCode, GC分代年龄, 锁状态标志, 线程持有的锁,偏向线程ID,偏向时间戳等

类型指针 - 对象所属的类型信息

数组长度 - 如果对象是数组的话

实例数据 Instance Data
class 里的字段,父类定义的字段在子类定义的字段前面
HotSpot虚拟机可配置压缩字段空间,子类较窄的字段可以插入父类变量的空隙之中
+XX: CompactFields = true (默认 true)

对齐填充 Padding
HotSpot 虚拟机内存管理系统 对象起始地址是8字节的整数倍,对象头已经设计成8字节的整数倍,实例数据没有对齐的部分通过对齐填充来补全

对象的访问
java 程序通过栈上的 引用(reference) 来访问堆上的具体对象, 一般是通过 句柄 或 直接指针 来访问

句柄
java 堆中划分一块内存做 句柄池,栈中的 reference 存储的是对象的句柄地址,
句柄中包含了对象 实例数据 与 类型数据 的地址信息

直接指针
栈中的 reference 存储的是对象地址,对象里放置了类型数据的地址信息


JVM 内存模型
内存分为 堆,方法区,栈,本地方法栈,程序计数器


线程共享,用来存放对象实例及数组
OutOfMemoryError - 堆不够内存完成实例分配,并且堆无法再扩展时
1.通过参数让虚拟机在堆内存溢出的时候 Dump 出堆转储快照
-XX: +HeapDumpOnOutOf-MemeryError
2.通过内存映像分析工具(如 Eclipse Memory Analyzer),对 Dump
出来的堆转储快照进行分析,确认导致 OOM 的对象是否有必要,
如果是内存泄露(memory leak), 修改代码,及时释放
如果是内存溢出(memory overflow), 一方面检查堆大小配置(-Xmx -Xms),
看是否有调大的空间,另一方面检查代码,是否有某些对象没有及时释放,存储结构不合理的情况

方法区
线程共享,类型信息(类名,访问修饰符,字段描述,方法描述,常量池),常量,静态变量,即时编译器编译后的代码缓存等数据
jdk 1.7之前 Perm Space 永久代, JVM 自己的内存
jdk 1.8之后 元空间, 直接内存
OutOfMemoryError - 运行时产生大量的类填满方法区

运行时常量池
class 文件里有一项信息是 常量池表,用于存放编译期生成的各种字面量与符号引用
在类加载的时候,存放到方法区的运行时常量池
运行时也可以把新的常量放入池中,如 String.intern()

hotSpot 虚拟机 jdk6 以前 是方法区的一部分,和方法区一样放在永久代
-XX:PermSize
-XX:MaxPermSize
OutOfMemoryError - 当 运行时常量池 无法满足新的内存分配需求时

hotSpot 虚拟机 jdk7 把 运行时常量池 移到了 java 堆中
-Xmx
OutOfMemoryError - 当 运行时常量池 无法满足新的内存分配需求时


Java 虚拟机堆栈,跟线程对应,用来存储栈帧
栈帧在方法被调用的时候创建,用来存储局部变量表,操作数栈,动态连接和方法出口等信息,
每个方法从被调用,到执行完毕,都对应着 虚拟机栈中的 一个栈帧 从入栈到出栈的过程

本地方法栈 - 与栈类似
HotSpot 虚拟机并不区分栈与本地方法栈
栈为虚拟机执行 java 方法服务
本地方法栈为虚拟机执行本地方法服务

-Xss 栈内存容量
局部变量表 存着 基本数据类型 和 对象引用

StackOverflowError
当线程请求的栈深度大于虚拟机所允许的栈深度时

OutOfMemoryError
当栈无法申请到足够的内存时

程序计数器
线程私有,当前线程所执行的字节码的行号指示器

GC 如何判断对象可以被回收
JVM 通过可达性分析算法来判断对象是否存活,从 GC Roots 开始,根据引用关系向下搜索,所经过的路径形成
引用链 Reference Chain, 不在引用链上的对象,到 GC Roots 不可达,视为可回收对象

什么是 GC Roots?
GC Roots 是一组活跃的引用
主要包括

  1. 静态变量
    方法区中类静态属性引用的对象
  2. 常量
    方法区中常量引用的对象
  3. 局部变量
    虚拟机栈(栈帧中的本地变量表)中引用的对象
  4. 本地方法栈中 JNI (native 方法) 中引用的对象
  5. java 虚拟机内部的引用,如基本数据类型对应的 class 对象,系统类加载器,常驻异常对象
  6. 同步锁 synchronized 持有的对象

强引用 strongly reference
代码里常见的引用赋值,如 Object obj = new Object();
只要引用关系还在,垃圾收集器无法回收

软引用 soft reference
系统抛出 OOM 之前,会将软引用对象列入回收范围,进行二次回收
通过 SoftReference 类实现

弱引用 Weak reference
下一次 GC 回收
通过 WeakReference 类实现, 可用于缓存中的对象

虚引用 phantom reference
又称 幽灵引用 或 幻影引用
虚引用对 GC 完全没影响,也无法通过虚引用拿到引用的对象
虚引用被回收时,会收到一个系统通知
通过 PhantomReference 类实现

方法区类对象的回收规则

  1. 该类的所有实例都已经被回收
  2. 加载该类的 ClassLoader 已经被回收了
  3. 该类的 class 对象没有在代码中被引用

你知道哪些 GC 类型?
新生代收集 Minor GC / Young GC
针对新生代的垃圾收集

老年代收集 Old GC
针对老年代的垃圾收集

整堆收集 Full GC
对整个 java 堆和方法区的垃圾收集

运行时常量池 垃圾回收
假如 在常量池中存在字符串 “abc”,如果当前没有任何 String 对象引用该字符串常量,常量 “abc” 就是废弃常量,如果这时发生垃圾回收且有必要的话,“abc” 就会被系统清理出常量池

常量池中的类,接口,方法,字段的符号引用与此类似

标记 - 清除算法
Mark - Sweep
首先标记所有存活的对象,然后统一回收所有未标记的对象, 如果需要回收的对象很多,效率就会降低, 会产生大量不连续的内存碎片,以后需要分配大对象时, 没有足够大的连续内存, 可能会提前触发一次 GC

标记 - 复制算法
Mark - Copy
为了解决内存碎片问题, 将可用内存分为大小相等的两块, 每次只使用其中一块, 当使用的这块空间不够用了, 就将存活对象复制到另一块, 再把已使用过的内存空间一次清理掉, 主要用于新生代
实现简单, 运行高效, 解决了内存碎片问题, 代价是可用内存会变小, 要留一部分空间来倒腾存活对象

HotSpot 虚拟机把新生代划分为一块较大的 Eden 和两块较小的 Survivor, 每次分配内存只使用 Eden 和其中一块 Survivor, 垃圾收集时将 Eden 和 Survivor 中仍然存活的对象一次性复制到另一块 Survivor 上, 然后直接清理掉 Eden 和已用过的那块 Survivor

HotSpot 虚拟机默认 Eden 和 Survivor 的大小比例是 8:1, 即新生代可用内存空间为整个新生代容量的 90%

如果另一块 Survivor 不够空间存放上一次新生代收集下来的存活对象, 这些对象就通过分配担保机制直接进入老年代

标记 - 整理算法
Mark - Compact
一般用于老年代,首先标记所有存活的对象,然后把所有存活对象都移向内存空间一端, 最后清理掉边界以外的内存

OopMap - ordinary object pointer Map
安全点 - safe point
HotSpot 虚拟机在指令流的特定位置生成 OopMap, 记录栈中引用类型数据的位置,
垃圾收集之前, 必须让用户线程在指令流里 OopMap 的位置停下来, 这些位置就叫 安全点

虚拟机需要暂停用户线程, 就设置一个标志位, 用户线程会轮询这个标志位, 看是否需要中断挂起

安全区域 Safe Region
某一段代码片段中,引用关系不会发生变化,用户线程在这段代码片段里时, 并不妨碍虚拟机垃圾回收,所以叫安全区域

用户线程执行到安全区域里的代码时, 会标识自己进入安全区域,
当用户线程要离开安全区域时, 会检查虚拟机是否还处于需要暂停用户线程的阶段, 看情况决定是否等待

记忆集
是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构

卡表
卡表是一个字节数组, 每一个元素都对应着一个特定大小的内存块, 叫卡页, 卡表用来记录哪些卡页里的对象含有跨代引用, 垃圾收集时, 就把它们加入 GC Roots 中一并扫描

写屏障
hotSpot 虚拟机通过写屏障来维护卡表状态, 写屏障可以看作是在虚拟机层面对 引用类型字段赋值 的 AOP 切面, 虚拟机会为所有赋值操作生成相应的指令, 更新卡表

三色标记算法
白色 未扫描,或已扫描不可达
黑色 已扫描,且安全存活
灰色 正在扫描

三色标记算法缺陷
并发标记阶段,有可能会产生多标或者漏标

多标
用户线程去掉了黑色到灰色的引用

漏标 - 需同时满足2个条件
1.用户线程去掉了灰色到白色的引用
原始快照
可以通过写屏障记录下来要删除的引用,等并发扫描结束之后,再将这些记录过的引用关系扫描一次,就好像按照删除前的对象图快照扫描

2.用户线程增加了黑色到白色的引用
增量更新
可以通过写屏障记录下来黑到白的引用,等并发扫描结束之后,再将这些记录过的引用关系扫描一次

hotSpot 虚拟机中, CMS 基于增量更新做并发标记, G1, Shenandoah 则是用原始快照来实现

并行 parallel
在谈垃圾收集器的时候,并行就是 有多条垃圾收集器线程协同工作,而用户线程处于等待状态

并发 concurrent
在谈垃圾收集器的时候,并发就是 垃圾收集器线程与用户线程同时工作

serial 收集器
新生代,标记复制算法,单线程收集器
客户端模式下默认的新生代收集器,适合小内存环境

serial old 收集器
老年代,标记整理算法,单线程收集器
可用于客户端模式下,也可用于服务器模式下 CMS 收集器收集垃圾报错时候的后备方案

parnew 收集器
新生代,标记复制算法,多线程并行收集,多 CPU 环境 Server 模式与 CMS 配合使用
jdk 7 之前服务端模式下的首选新生代收集器

parallel scavenge 收集器
标记复制算法,并行收集器
适合注重吞吐量,如没有太多交互的后台运算

parallel old 收集器
老年代,标记整理算法,并行收集器
适合注重吞吐量,如没有太多交互的后台运算

CMS 问题很多,jdk 1.8 和后面的版本都没有把它设置成默认的垃圾回收器
jdk 9 不推荐使用, jdk 14 remove 掉

jdk 1.8 里 CMS 有 83 个参数
java -XX:+PrintFlagsFinal|grep CMS

jdk 1.8 里 G1 有 24 个参数
java -XX:+PrintFlagsFinal|grep G1

CMS 收集器 - Concurrent mark sweep
老年代,标记清除算法,并发收集器
目标是获取最短垃圾回收停顿时间,适合关注服务响应时间的互联网应用

整个过程分为以下4个步骤
1.初始标记
只是标记一下 GC Roots 能直接关联的对象, 速度很快, 需要暂停用户线程

2.并发标记
从 GC Roots 直接关联的对象开始遍历整个对象图的过程, 和用户线程一起工作

3.重新标记
通过 增量更新 来处理并发标记阶段, 因用户线程继续运行而产生的漏标, 需要暂停用户线程

4.并发清除
清除 GC Roots 不可达对象, 和用户线程一起工作

由于耗时最长的 并发标记 和 并发清除 阶段, 垃圾收集线程和用户线程并发工作, 所以总体上来看 CMS 收集器的内存回收 和 用户线程 是一起并发执行

分代设计
90% 的对象朝生夕灭

内存分配回收策略
对象优先在 Eden 区分配
对象在新生代 Eden 区分配, 当 Eden 区不够空间分配时, 触发 Minor GC, Eden 区里的存活对象, 会进入 Survior 区, 如果 Survior 区放不下, 通过分配担保机制提前转移到老年代

大对象直接进入老年代
为了避免大对象在 Eden 区和两个 Survior 区之间来回复制, 产生大量的内存复制动作,
Serial 和 ParNew 两款新生代收集器提供了一个参数配置, 大于该参数的对象直接在老年代分配
-XX: PretenureSizeThreshold

长期存活的对象将进入老年代
对象在 Eden 区诞生, 熬过一次 Minor GC 后仍然存活, 且能被 Survior 容纳的话, 该对象被转移至 Survior 区, 对象头里的 Age 变成 1, 以后在 Survior 区每熬过一次 Minor GC, 年龄就增加一岁, 当年龄达到阈值(默认15), 就晋升到老年代
-XX:MaxTenuringThreshold

动态对象年龄判定
当同年对象大小总和大于 survivor 空间一半,可以直接进入老年代

Minor GC 还是 Full GC
在 Minor GC 之前, 虚拟机会判断, 老年代的连续可用空间是否大于 新生代对象的总大小,
或者历次晋升的平均大小, 如果大于就 Minor GC, 否则就 Full GC

class 文件
一个类或接口的定义信息, 字节码格式, 类似 C 语言的结构体, 主要记录了类, 父类, 接口, 字段, 方法等信息

class 文件的结构

魔数

版本号

常量池
主要存放 字面量 符号引用

字面量
文本字符串,被申明为 final 的常量值

符号引用
编译原理方面的概念

类 / 接口 的全限定名

字段的名称和描述符

方法的名称和描述符

方法句柄

方法类型

访问标志
类或接口,是否 public, 是否 abstract, 是否 final

类索引,父类索引,接口索引集合

字段表集合
用于描述接口或类中声明的变量,包括类级变量和实例级变量

方法表集合
用于描述接口或类中声明的方法

属性表集合
class文件, 字段表, 方法表都用到了属性表集合来描述某些场景专有的信息

类加载的时机
预加载
JVM 启动时会加载一些核心类,比如 java.lang.String

懒加载

  1. 构造该类的实例 new
  2. 首次访问类的静态属性或静态方法
  3. 使用子类时会触发父类的加载
  4. 反射访问,Class.forName

类加载的过程
加载 -> 验证 -> 准备 -> 解析 -> 初始化

类的生命周期
加载 -> 验证 -> 准备 -> 解析 -> 初始化 -> 使用 -> 卸载
连接 =(验证 -> 准备 -> 解析)

加载
1.根据类名获取定义此类的二进制字节流
2.在方法区建立此类对应的运行时数据结构
3.在 java 堆中实例化一个该类的 class 对象

验证
1.文件格式验证
保证字节流能被正确的解析,并存储于方法区
2.元数据验证
确保数据类型符合 java 语言规范
3.字节码验证
确保被验证类的方法运行时安全
4.符号引用验证
确保依赖的类,方法,字段都能被正常访问

准备
为静态变量分配内存并设置初始值,0 或 常量值
没有 final 修饰的 static 变量赋零值
final 修饰的 static 变量赋常量值

解析
虚拟机将常量池内的符号引用替换为直接引用的过程

初始化
执行类构造器 ()
类构造器 由编译器收集类中 静态变量赋值,和静态语句块合并产生

JVM 启动 - main 方法的调用
1.加载启动类 (Main-Class)
通过 java 命令启动, 其后的参数会有 Main-Class,
启动 jar 包, jar 包的 manifest 配置文件中有配 Main-Class
2.JVM 对这个启动类 执行 链接 和 初始化
3.调用初始类中的 main() 方法
4.执行 main() 方法
链接(预加载) 或 加载 使用其他的 类/接口

双亲委派模型 - 加载类的规则
一个类加载器收到加载请求, 先查找缓存,有则返回,没有则委派给父加载器, 一层层向上委派, 直到最顶层的启动类加载器,
启动类加载器缓存中没有就到加载路径中查找,有则加载返回,没有则由子加载器加载,一层层向下查找
1.避免重复加载
比如, java.lang.Object, rt.jar 里的类, 无论哪一个类加载器要加载这个类, 最终都是委派给模型最顶端的启动类加载器加载, 确保 Object 类在内存中只有一个, 如果不使用双亲委派模型, 各个类加载器自行加载, 同一个类在内存中就有多个
2.避免核心类被篡改或替换
通过双亲委派模型, 核心类只能由启动类加载器加载, 加载的时机, 类的全限定名, 路径都定死了, 你想加载一个同名的类, 加载不了

jdk 自带了三个类加载器

启动类加载器 bootstrap class loader
负责加载 <JAVA_HOME>\jre\lib 目录下的 jdk 核心类库, rt.jar, tools.jar

扩展类加载器 extension class loader
负责加载 <JAVA_HOME>\jre\lib\ext 目录下的扩展类库

应用程序类加载器 application class loader / 系统类加载器
负责加载用户类路径 ClassPath 下所有的类

SPI - service provider interface
虚拟机层面的服务注册与发现的机制, 通过服务消费方和服务提供方解耦, 动态扩展应用
约定如下

  1. 在 jar 包的 meta-inf/services 包下, 以接口全限定名为文件名, 文件内容是实现类
  2. 接口和实现类都放在 classpath 路径下
  3. 服务消费方通过 java.util.ServiceLoader 动态装载实现模块

案例
jdbc
servlet 初始化器
spring 容器
Dubbo

破坏双亲委派模型
jdbc
DriverManager 是启动类加载器加载的, 它会通过 ServiceLoader 加载 mysql 的驱动, 而 ServiceLoader 会去拿线程上下文类加载器, 拿到的是系统类加载器, 最终 mysql 的驱动是通过系统类加载器加载的, 这种行为实际上是父加载器请求子加载器完成类加载,违背了双亲委派模型的规则

JVM 的整体结构
JVM 存在三个系统,类加载子系统,运行时数据区,执行引擎
类加载子系统把字节码加载到运行时数据区的方法区中
运行时数据区分为方法区 堆 虚拟机栈 本地方法栈 程序计数器
执行引擎包括解释器 即时编译器 垃圾回收器
解释器负责解释执行字节码
即时编译器会把热点代码编译成机器码提高执行性能
垃圾回收器负责垃圾回收

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

叫我三师弟

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值