[JVM]JVM内存划分, 类加载过程, 双亲委派模型,垃圾回收机制
文章目录
- 一. JVM内存划分
- 1. 堆
- 2. 栈
- 3. 元数据区
- 4. 程序计数器
- 二. 类加载过程
- 1. 加载
- 2. 验证
- 3. 准备
- 4. 解析
- 5. 初始化
- 三. 双亲委派模型
- 四. JVM的垃圾回收机制GC
- 1. 找到需要回收的对象
- 2. 释放垃圾的策略
一. JVM内存划分
JVM就是java进程
这个进程一旦跑起来, 就会从操作系统这里, 申请一大块内存空间
JVM接下来就要进一步的对这个大的空间, 进行划分, 划分成不同区域, 从而每个与都有不同的功能作用
1. 堆
整个内存区域中, 最大的区域
放的是代码中new出来的对象和成员变量
2. 栈
分为JVM虚拟机栈和本地方法栈
栈, 保存了方法中的调用关系 和 局部变量
JVM虚拟机栈, 是java使用的
本地方法栈, 是给本地方法使用的
3. 元数据区
放的是类对象 Test.class 和 类中的方法 和 常量池
代码中的每个类, 在jvm上运行的时候, 都会有对应的类对象
类有一些方法, 每个方法, 都代表了一系列的"指令集合"(JVM字节码指令)
4. 程序计数器
是内存区域中最小的区域, 只需要保存当前要执行的下一条指令的地址(这个地址就是元数据区里面的一个地址)
上述代码:
Test类的类对象, 在元数据区
a, 是成员变量, 在堆上
t2, 是成员变量, 在堆上, 存放的是Test2实例的地址
new Test2(), 在堆上
s, 是成员变量, 在堆上, 存放指向"hello"的地址
“hello”, 是常量, 存储在常量池中, 在元数据区
b, 是静态成员变量, 成为了类属性, 在元数据区
t, 是局部变量, 在栈上, 存放的是Test实例的地址
new Test(), 在堆上
上四个区域, 堆 和 元数据区, 是整个进程只有一份, 每个线程共享
栈 和 程序计数器, 是每个线程都有一份, 各个线程不可共享的
二. 类加载过程
我们写的代码, 是.java文件, 存储在硬盘上
javac编译器将.java文件, 编译成.class文件
类加载的过程, 就是将.class文件加载到JVM中, 得到类对象
1. 加载
在硬盘上, 找到对应的.class文件, 读取文件内容
2. 验证
检查.class里面的内容, 是否符合要求
这是java虚拟机规范中的.class文件的格式
把读取的内容, 往这个格式里套, 如果能套用, 就没有问题
3. 准备
给类对象, 分配内存空间(元数据区)
把这个空间中的数据都填充成0
4. 解析
针对字符串常量初始化
把刚才的.class文件中的常量内容, 取出来, 放到"元数据区"
5. 初始化
针对类对象 静态成员进行初始化, 执行静态代码块
(不是针对对象初始化, 和构造方法无关)
此时, 类对象就搞定了
三. 双亲委派模型
双亲委派模型, 是类加载中的第一步, 找.class文件的过程, 根据"全限定类名"找到对应的.class文件
“全限定类名”, 就是报名 + 类名 :String => java.lang.String
“类加载器”, 可以当做JVM中包含的一个特定的模块, 这个模块就是负责类加载的工作
JVM内置了三个类加载器:
- BootstrapClassLoader
负责加载标准库中的类(爷爷) - ExtentionClassLoader
负责加载JVM扩展库的类(父亲) - ApplicationClassLoader
负责加载第三方库的类, 和你自己写的代码的类(儿子)
这个的父子关系,是通过类加载器中存在一个parent这样的字段, 指向自己父亲
工作过程:
例如找我自己写的类: java.Test
简单一句话概括, 就是拿到任务, 先交给父亲处理, 父亲处理不了, 再自己处理
上述过程, 主要为了应对这个场景:
比如自己代码里写了一个类, 类的名字和标准库/扩展库冲突了, JVM会确保加载的类是标准库的类, 就不加载你自己写的类了
四. JVM的垃圾回收机制GC
1)程序计数器, 不需要额外回收, 线程销毁, 自然回收
2)栈, 不需要额外回收, 线程销毁, 自然回收
3)元数据区, 存的是类对象, 一般不需要回收
所以, GC回收的是内存, 更准确说, 是对象, 回收的是"堆"上的内存
GC的流程:
- 找到谁是垃圾
- 释放对应的内存
1. 找到需要回收的对象
一个对象, 什么时候创建, 时机往往是明确的, 但是什么时候不再使用, 实际往往是模糊的
在编程中, 要确保, 代码中使用的对象, 都是有效的, 千万不要出现"提前释放"的情况
所以, 我们判断是否回收的保守办法, 就是判定某个对象, 是否存在引用指向他
具体怎么判定某个对象, 是否有引用指向?
下面介绍两种方式
** 1. 引用计数**
注意: 这个方法不是JVM采用的方案, 而是Python/PHP的方案
这种方法存在两个缺陷:
1.消耗额外的存储空间
如果对象比较大, 浪费的空间还好, 如果对象比较小, 空间占用就多了
2.存在**“循环引用”**问题
在内存中是这样的:
现在有下面代码:
此时, 俩对象相互指对方, 导致两个对象的引用计数, 都不为1, (不为0, 就不是垃圾), 但是外部的代码, 也无法访问到这俩对象, 出现"循环引用"
2. 可达性分析
注意: 可达性分析是JVM采取的方案
JVM把对象之间的引用关系, 理解成了一个**“树形结构”**
JVM就会不停地周期性遍历这样的结构, 把所有能够遍历访问到的对象标记成"可达", 剩下的就是"不可达"
假设对象之间存在这样的树形引用关系:
此时, 从a出发, 任何一个结点都是可达的, 没有垃圾
如果此时c中的引用f = null, 相当于c.right = null, 那么f 就不可达了, 此时后续进行遍历时, 就会把f标记成垃圾了
如果此时a中c的引用c = null, 相当于a.right = null, 那么c和f都不可达了, 此时后续遍历, 就会把c和f标记成垃圾
由于可达性分析, 需要消耗一定的时间, 因此java垃圾回收, 没法做到"实时性", JVM提供了一组专门负责GC的线程, 不停地进行扫描工作
2. 释放垃圾的策略
1. 标记清除
直接把标记为垃圾的对象对应的内存, 释放掉(简单粗暴)
将灰色的回收掉
这样的做法, 就会存在"内存碎片", 空闲内存被分成一个个小的碎片了, 后续很难申请到大的内存
申请内存, 都是要申请"连续"的内存空间的
2. 复制算法
比如, 要释放246, 保留135, 不会直接释放246, 而是先把135拷贝到另一块连续的内存上去
虽然内存碎片问题解决了, 但是空间浪费太多了
3. 标记整理
采用类似"顺序表删除中间元素"的方法, 向前搬运
先回收2, 把3向前搬运, 再释放4, 5向前搬运…
但是, 这样的搬运, 时间开销很大
其实, JVM实际的方案, 是综合上述的方案, 分代回收
根据对象的’‘年龄’'选择不同的方案
某个对象, 经历了一轮GC之后, 还是存在, 那么GC + 1
实际上堆区是分成Young区和Old区
Eden: 伊甸区
s0,s1: 生存区/幸存区
把创建的对象, 放在伊甸区中, 伊甸区大部分的对象, 生命周期都是比较短的, 第一轮GC到达的时候, 就会成为垃圾
把第一轮剩下的对象, 通过复制算法, 复制到生存区
每经过一轮GC, 生存区中都会淘汰掉一批对象, 剩下的通过复制算法, 进入到另一个生存区, 同时, 伊甸区中生存下来的也复制到这个生存区中
某些对象, 经历了很多轮GC, 都没有称为垃圾, 就会复制到Old区
Old区的对象, 也是需要GC的, 但是老年代的对象生命周期比较长, 就可以降低GC的扫描频率
对象在Old区, 就通过标记整理方法来进行回收