文章目录
- 一、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执行分为四个模块协同工作:
- 类加载子系统 ClassLoader:读取class字节码文件,加载进内存;
- 运行时数据区 Runtime Data Area:存放程序运行期间所有内存数据;
- 执行引擎 Execution Engine:解析/编译字节码,转为操作系统指令;
- 本地接口 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阶段合称类加载过程。
- 加载 Loading
- 找到.class文件,根据类的全限定名(包名+类名,例如
java.lang.String) - 打开文件,读取文件内容到内存中
- 找到.class文件,根据类的全限定名(包名+类名,例如
- 验证 Verification:解析,校验.class文件合规性,防止恶意字节码破坏虚拟机,并把.class里的内容转换为结构化的数据(也就是符合虚拟机规范的格式);
- 准备 Preparation:给类对象申请内存空间,此时的空间相当于”全0“的状态;
- 解析 Resolution:初始化字符串常量。字符串常量本身就包含在.class中,解析阶段就要把解析出的字符串常量放到内存中(元数据区的常量池);
- 初始化 Initialization:针对类对象进行最后的初始化。为类对象各种属性进行填充,包括静态成员变量。如果这个类有父类,也会触发父类的加载。
一个进程中一个类的加载只会触发一次,并不是Java程序一启动就会加载所有的类,类加载也是懒汉模式(懒加载)的一种体现。
触发类加载的时机:
- 构造这个类的实例
- 调用类的静态属性或静态方法
- 使用某个类时,如果父类还没加载,也会触发父类的加载
2.2 双亲委派模型
类加载器
JVM中有专门的模块负责类加载,默认提供了三种类加载器:
BootstrapClassLoaderExtensionClassLoaderAplicationClassLoader
AplicationClassLoader中的parent引用指向ExtensionClassLoader,ExtensionClassLoader中的parent引用指向BootstrapClassLoader,BootstrapClassLoader中的parent引用指向null,构成了类似链表关系。
这三个类加载器主要作用都是找.class文件,各自负责找的目录不同:
BootstrapClassLoader负责找Java标准库中的类ExtensionClassLoader负责找Java扩展库中的类。扩展可以认为JVM厂商对于Java的库做的扩充AplicationClassLoader负责找第三方库/当前项目。(当前学习过程中,通过Maven下载的类都属于第三方库)
双亲委派模型过程
- 进行类加载时,通过全限定名找.class文件时,就会从
AplicationClassLoader作为入口开始,把“加载类”这样的任务委托给它的父亲ExtensionClassLoader来执行 ExtensionClassLoader也不会立即干活,先把任务委托给它的父亲AplicationClassLoader来执行AplicationClassLoader也想踢足球,但是它的parent为空,只能自己进行类加载,从标准库中找是否匹配的.class文件。如果没找到就把任务还给孩子ExtensionClassLoaderExtensionClassLoader在扩展库中继续找.class文件,找到就加载,没找到就继续还给孩子AplicationClassLoaderAplicationClassLoader继续负责找.class文件,找到就加载;没找到就抛出ClassNotFount异常
也可以自定义类加载器,参与到双亲委派模型中
- 双亲委派模型两大优势
- 避免类重复加载;
- 安全防护:防止开发者自定义
java.lang.Object篡改核心API。
三、垃圾回收GC原理
GC仅关注堆、方法区,虚拟机栈、本地方法栈、程序计数器生命周期随线程自动释放,无需回收。
3.1 GC工作流程
GC主要做两个工作:
- 找到垃圾(不再使用的对象)
- 释放垃圾(释放对应的内存)
1. 找到垃圾
(1)引用计数算法
每个对象在创建的的时候,就会分配一小块内存空间,保存一个整数,表示有多少个引用指向它,每次进行引用赋值的时候,都会触发引用计数的修改。
在Java中,使用对象一定要通过引用实现,当引用计数为0时,说明没有引用指向这个对象了,这样就可以判断这个对象就是垃圾
缺陷:
- 内存消耗更多,尤其对象本身较小时,引用计数消耗的占比就更大
- 可能出现循环引用
举个例子:
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的对象:
- 栈上的局部变量(引用类型)
- 常量池中引用指向的对象
- 静态成员(引用类型)
2. 释放垃圾
垃圾清除有四种算法:
标记-清除:把垃圾对象的内存直接进行释放
缺陷:会产生“内存碎片问题”,很难找到大块的连续的内存空间复制算法:把内存分为两部分,起始都保存在A区域;当垃圾回收时,把不释放的数据复制到B区域,再把A区域整体释放。这样就确保空闲内存都是连续的
缺陷:内存空间利用率低,直接砍半;如果不需要释放的数据很多,复制开销很大标记-整理:类似于顺序表删除,把不需要释放的数据重新搬运整理
缺陷:内存搬运数据的操作开销也很大,只解决了空间成本问题。分代收集:综合前三种方案。“代”指的是对象的年龄,单位是GC的轮次。
- 每个对象初始年龄是0,每经过一轮GC可达性分析,不是垃圾的对象年龄+1
- 根据年龄,把对象分为新生代和老年代。经过统计发现,如果一个对象是新生儿,大概率死的很快;如果一个对象是老油条,大概率能活很长时间。
- 对象在新生代反复被GC筛选,年龄增长,进入老年代。
堆区域划分:
- 新生代 Young:新建对象存放,98%对象朝生夕死。新生代进一步划分:
- Eden 伊甸园:80%新生代,是所有新对象诞生地,由伊甸园到幸存区采用复制算法,因为绝大部分对象都活不过一轮GC,复制对象少,开销可控;
- S0/S1 Survivor幸存者:各10%。幸存区的对象也要经历GC扫描,每一轮GC消灭一大批对象,然后通过复制算法把存活的对象复制到另一个幸存区;如果一个对象经过多次复制,都存活下来,就可以晋升到老年代了;
- 老年代 Old:长期存活对象、大对象。GC频次比较低,采用标记-整理算法;
一个对象经历的区域:伊甸园 -> 幸存区->幸存区->...->幸存区->老年代新生代统一采用复制算法;老年代采用标记整理
特殊情况:如果某个对象很大,复制成本很高,可以跳过新生代,进入老年代
3.2 垃圾收集器
在JVM中,由垃圾回收器来实现上述分代回收的策略。
分代回收只是最基本的策略,落实到垃圾收集器上,还有更特殊和进阶的策略。
| 收集器 | 分代 | 特点 | 使用场景 |
|---|---|---|---|
| Serial | 新生代 | 单线程串行,STW | Client客户端、小内存程序 |
| ParNew | 新生代 | Serial多线程版,唯一可配合CMS | 老年代CMS搭配使用 |
| Parallel Scavenge | 新生代 | 吞吐量优先,自适应调参 | 后台计算、无交互服务 |
| Serial Old | 老年代 | 单线程标记整理 | Client、CMS失败后备方案 |
| Parallel Old | 老年代 | 多线程吞吐量优先 | 高吞吐离线任务 |
| CMS | 老年代 | 并发低停顿,标记清除,碎片多 | JDK8前互联网低延迟服务 |
| G1 | 全堆分区Region | 可预测停顿,自动压缩,替代CMS | 大内存服务端,JDK9默认 |