JVM - 内存区域划分 & 类加载机制 & 垃圾回收机制

目录

1. 内存区域划分

2. 类加载

2.1 双亲委派模型

3. 垃圾回收机制 (GC)

3.1 如何判断一个对象是否为 "垃圾"

3.1 可达性分析

3.2 垃圾回收算法


1. 内存区域划分

JVM 作本质上是一个 Java 进程, 它启动的时候, 就会从操作系统申请一大块内存, 并且把这一大块内存划分成多个区域, 每个区域有不同的功能:   栈, 堆, 程序计数器, 方法区.

1. 栈:  用来存放局部变量以及方法之间的调用关系. 还有当代码抛出异常, 它打印的那个信息,  就是来自于栈. (以前分为 Java 虚拟机栈和本地方法栈,  Java 虚拟机栈是给 Java 代码使用的栈, 本地方法栈是给 JVM 内部 C++ 代码使用的栈. 后来合并了.)

2. 堆:  用于存放 new 出来的对象.(最重要的区域, 也是占地面积最大的区域)

3. 程序计数器: 保存当前执行到哪一条指令, 或者下一条指令的内存地址. 相当于一个书签, 而且也是和线程调度有关.

4. 方法区: 存放的是类对象以及静态成员. 类对象里就是自己的写的 Java 代码 + 静态变量. (我们写的程序是 .java 文件, 编译成 .class 文件, JVM 启动会把 .class 文件从硬盘读到内存中, 然后构造出类对象, 进而把 .java 文件中的信息都反馈到类对象中了.)

【注意事项】

  • 其中 "栈" 和 "程序计数器" 是线程之间私有的, 而 "堆" 和 "方法区" 是一个进程里面所有线程共享的.
  • 局部变量在栈里, 成员变量在堆里, 静态变量在方法区.
  • 开发中遇见 StackOverflowException, 说明栈溢出了. 则需要检查是否方法调用层数太多了, 如果遇见 OutOfMemoryException, 说明堆溢出了. 则需要检查是否 new 的对象太多了.

2. 类加载

Java 程序启动的时候, 就需要让JVM 把 .class 文件给读进内存并进行一系列后续工作. 这个过程就叫做类加载.

类加载又大致可以分为三个步骤:

🍔1. 加载:  找到 .class 文件, 打开文件, 读文件, 创建空的类对象.并把类的元信息填到类对象里边.

🍔2. 连接

  • 验证:   检查 .class 文件格式是否符合规范要求.
  • 准备:   给静态变量分配内存空间. 将空间里填充为 0 值;  (例如 static int a = 100,  在这个阶段,             也只是给 a 申请了空间, 并初始化为 0)
  • 解析:   把字符串常量进行初始化, 把 "符号引用" 替换成 "直接引用".

符号引用替换成直接引用的意思就是指在编译过程中, 编译器能够发现当前代码里有哪些字符串常量, 编译过程中就会使用一些特殊的符号来分别表示这些字符串常量. 当真正进行类加载的时候, 就可以把字符串常量真正的放到内存中, 把对应的内存地址替换前面特殊的占位符号.

🍔3.  初始化:  针对类的静态成员进行初始化, 同时执行静态代码块. 如果这个类的父类还没加载, 也要去加载父类. 

2.1 双亲委派模型

双亲委派模型, 描述的是类加载中的 "加载阶段", 去哪些目录里找 .class 文件.

类加载器:  JVM 中一个特殊的模块就是类加载器, 它需要负责把类给加载起来.

JVM 中自带的三个类加载器, 各有分工, 各自负责去扫描对应的目录.

  • BootStrapClassLoader : 负责加载标准库中的类.
  • ExtensionClassLoader : 负责加载一些扩展的类.
  • ApplicationClassLoader : 负责加载应用程序里自己写的类.

在 JVM 中, 这三个类加载器约定了父子关系: 

BootStrapClassLoader  是 ExtensionClassLoader 的爸爸;

ExtensionClassLoader 是 ApplicationClassLoader 的爸爸.

 双亲委派模型就是在上述体系下, 进行展开的, 假设此时我们要加载一个类, 它的整个过程如下: 

 1. 假如我们要加载标准库中的类, 那么会经历 1, 2 步骤, 然后到了 BootStrapClassLoader 加载器, 它也想去找他的父亲, 但是它没有父亲, 于是就只能扫描自己的目录, 此时找到了, 然后继续负责后续的加载.

2. 假如我们加载自己写的类, 此时要经历 1, 2 步骤, 然后 3, 4 返回步骤, 一直返回到 ApplicationClassLoader 加载器, 此时扫描目录才找到.

每个类加载器在工作的时候, 要先问问父亲, 然后才会自己动手. 相当于把任务委派给父亲先看一下, 这就叫做双亲委派模型.

【问题一】类加载器为啥要按照这个双亲委派模型的规则来进行工作呢?

为了防止程序猿自己写一个特殊的类, 导致把标准库中的类给覆盖了. 例如: java.lang.String

当程序猿真的写了这一个这样的类, 在双亲委派模型下, 它会先走到 BootStrapClassLoader 这个类加载器, 在此处就已经扫描到了 java.lang.String, 在这一层已经加载过了, 所以不会再去加载你写的 java.lang.String 了.

>>> 双亲委派模型中的三个类加载器其实就好比公司中的基层员工,中层领导, 高管三者, 当基层员工遇到一个很大的问题不能自己做主时, 他需要向他的领导汇报, 他的领导又需要向高管汇报一下, 此时高管有两种选择: 1. 问题很棘手, 他得亲自解决; 2. 问题很简单, 交给中层领导来处理; 到了中层领导这一块, 做法又和高管一样.

【问题二】如果自己写一个类加载器, 是否需要遵守双亲委派模型规则 ?

想遵守也行, 不遵守也可以. 例如 Tomcat 里面, 加载一些 webapps 中的类的时候, 就有自己的类加载器, 也并没有遵守双亲委派模型.

3. 垃圾回收机制 (GC)

我们在 C 语言阶段, 学过的一个东西 - malloc(动态申请内存空间), 在 C 中, malloc 申请到的内存, 除非我们手动通过 free 来进行释放, 否则就需要等到程序结束时才会释放.

所以使用 malloc 申请了空间, 就要记得释放, 否则就会造成内存泄漏!!, 内存泄漏还是相当严重的. 但是话又说回来, 需要靠程序猿手动来保证的事情, 一定是不靠谱的, 所以就让机器来帮我们做这件事情了, 由机器自动负责回收不再使用的内存, 这就是 "垃圾回收机制", 也叫作 GC

>>> 哪些内存需要被回收呢

1. 程序计数器, 是不需要被回收的, 因为这个空间是固定的, 每个线程只有一个, 它会跟随线程一起销毁.

2. 栈, 也不太需要被回收, 主要就是局部变量需要约定变量出了作用域就可以被回收了.

3. 方法区, 也不太需要被回收, 它存放的是类对象, 主要工作就是 "类加载", 但是很少会涉及到 "类卸载", 需要 GC 但不迫切.

4. 堆, 这才是 GC工作的主战场!!, 很多 new 出来的对象, 用完之后, 就需要被及时回收!!

>>> 垃圾回收, 是以对象为单位进行释放!!

1.  上图 1 号对象, 所有的内存都在使用, 不能被释放

2.  2 号对象, 一半正在使用, 一半不使用了, 此时也不能回收, 需要等到整个对象都不使用了, 才能回收. (假设一个对象有 a, b 两个成员, 你的代码很多地方都在使用 a 属性, 但是后续的代码不再使用 b 属性, 此时 b 需要等 a 属性不再使用才能一起被回收)

3.  3 号对象, 整个对象都不使用了,  就需要被 GC 回收了.

3.1 如何判断一个对象是否为 "垃圾"

引用计数

引用计数非 Java, 别的编程语言中使用的方法, 此处也简单介绍一下, 因为在 <<深入理解 Java 虚拟机>> 这本书中, 这两种方法都提到了.

"引用计数": 是使用额外的计数器, 记录某个对象, 被多少个引用指向. 如果某一时刻, 计数器为 0 了, 就说明此时没有引用指向它了, 此时这个对象就可以视为 "垃圾" 了.

引用计数就类似上图, 每新产生一个引用, 引用计数就 + 1, 每销毁一个引用, 引用计数就-1,

销毁可以理解为如果引用是局部变量, 出了作用域就是销毁了. 如果引用是成员变量, 则该引用对应的对象被销毁时, 引用本身才被销毁.

>>> "引用计数" 的缺陷

1. 在多线程中, 需要修改同一个引用计数, 需要考虑到线程安全问题.

2. 有可能会造成不必要的开销!!  如果本身是大的对象, 多引入一个引用计数器, 负担不大; 如果本身是小的对象, 并且数量还多, 引入引用计数器就会造成不小的空间开销.

1. 假设一个对象大小是 1KB, 引用计数器大小是 4 个字节, 那么负担就从 1024 -> 1028, 相对来说负担不大.

2. 假设一个对象大小是 2个字节, 引用计数器大小是 4 个字节, 那么负担就从 2 -> 6, 相对来说负担翻倍了.

3. 可能会带来循环引用的问题 (最致命的)

上图中, 有一个 Test 类, 类中有一个 Test 类型的成员变量,  操作1 是将这个类实例化 2 个对象, 操作 2 是分别将两个对象中的引用类型的成员变量互相指向对方.

此时计数器为 2, 当 a, b 对象某一时刻被销毁时: 

此时, 当前这俩对象引用计数都不为 0, 因此就都不会被当成垃圾, 但是这俩引用的地址都在对方手里, 就导致无法使用这俩对象, 最终就成了既不能被回收, 又不能被使用, 非常类似于 "死锁". 正因为引用计数有上述三个缺陷, 所以在 Java 中就不太合适, 就没有采取这个方案.

3.1 可达性分析

Java 中真正采用判断对象是否为垃圾的方案就是 "可达性分析".

可达性分析: 以代码中一些特殊的变量作为起点(GCRoot), 然后以起点出发, 判断哪些对象能够被访问到. 如果对象能被访问到, 就标记为 "可达", 当所有能被访问到的对象都被标记成 "可达" 时, 剩下的对象就是 "不可达" 的了, 就需要被当做垃圾回收了.

>>> 什么样的变量可以被称作 "起点" - GCRoot

1. 局部变量表中的引用. (栈里面的局部变量)

栈有多个, 每个线程一个栈, 每个栈里面有很多栈帧, 每个栈帧里面有一个自己的局部变量表. 所有线程的所有栈的所有栈帧的所有局部变量表中的所有变量, 都可以视为起点 - GCRoot.

2. 常量池中对应的对象.

3. 方法区中, 静态引用类型的成员.

>>> 什么叫做能够被访问到

 对于二叉树而言, 只要记住根节点, 就能判断其他结点能否被访问到了.

1. 如果在代码中写了 root.right.right = null , 那么 F 结点就不可达了, 就可以被回收了.

2. 如果在代码中写了 root.right = null, 那么 C 结点就不可达了, 同时  F 结点也被一起带走了(回收).

>>> 可达性分析相较于引用计数解决了两个缺陷: 

1. 可达性分析不需要引用计数器, 没有占用额外的空间;

2. 可达性分析不会涉及到循环引用的问题.

3.2 垃圾回收算法

>>> 标记-清除算法 

  • 第一步: 首先标记出所有需要回收的对象;
  • 第二步: 在标记完成后统一回收所有被标记的对象.

 这种方案虽然把内存释放了, 但是又引入了内存碎片!!

>>> 释放之后得到的内存, 并非非连续的.  而 new 对象时, 需要 new 出连续的空间, 这就导致我有 4 kb 的空间 , 却 无法 new 出 2kb 大小的空间.

>>> 复制算法

为了解决内存碎片, 引入了复制算法方案.

  • 第一步: 将内存一分为二, 一半使用, 一半留着;
  • 第二步: 将要保留的对象拷贝到另一半未被使用的空间, 然后将左半部分全部释放.

虽然解决了内存碎片问题, 但是这种算法最大的缺陷就是可用空间少了一半,

原来可以 new 10 kb 大小的空间, 现在最大只能 new 5kb 大小的空间. 空间利用率大大降低了

>>> 标记-整理算法

第一步: 首先标记出所有需要回收的对象;

第二步: 将需要保留的空间往一端搬运, 剩下的空间全部释放.

这种算法非常类似于顺序表删除元素, 它虽然解决了前两种算法的缺陷, 但是它本身的缺陷也非常明显, 搬运操作比较耗时!!!

>>> 分代回收算法

分代回收算法是通过区域划分, 实现不同区域和不同的垃圾回收策略, 从而实现更好的垃圾回收。

分带算法给对象引入了 "年龄" 的概念, 此处的年龄单位不是年, 而是对象活过 GC 的轮次. 对象刚创建出来, 没经历过 GC 的洗礼, 就认为年龄是 0, 没经过一轮 GC, 如果没被回收, 年龄就 + 1.

>>> 它是将 "复制算法" 和 "标记-整理算法" 的方案综合起来了, 只是根据对象存活周期的不同将内存划分为 "新生代" 和 "老年代" 两块大的区域. (具体要经过多少轮 GC 才会变成老年代, 我们不关心, 我们只关注策略本身.)

1. 新创建的对象都放在伊甸区中. 伊甸区中的对象有个特点: 绝大部分对象都活不过一轮 GC ,

基本上就是 "朝生夕死" .

2. 经过一轮 GC 的考验, 还存活下来的对象就通过 "复制算法" 放到了 "幸存区".

3. 幸存区中的对象又会经过下一轮 GC 的考验, 且每经过一轮 GC 都会淘汰一部分对象, 没被淘汰的对象, 就继续通过 "复制算法" 拷贝到下一个幸存区中. (在 2 号幸存区考验时, 存活的又复制到 1 号幸存区) 这个过程, 既保留了复制算法的高效, 无内存碎片的优势, 又避免了过多的浪费空间, 因为此处浪费的幸存区的空间, 它相对整体空间来说, 微乎其微.

4. 当对象在幸存区中经历了多轮 GC , 仍然没有被销毁, 就认为该对象一时半会不会被销毁, 于是就把这个对象拷贝到 "老年代" 了.

5. 老年代的对象, 也要经历 GC 的考验, 只是考验的频率大大降低了, 如果老年代的对象即将要被回收, 就是用 "标记-整理" 算法, 因为老年代对象被回收的频率不高, 就可以接受 "标记-整理" 算法带来的时间开销.

6. 还要注意的是: 有一个小的例外, 如果当前有一个特别大的对象, 就不经历上述分代回收过程, 直接就进入老年代, 因为大的对象在复制算法中, 是不太友好的.

上述分代回收算法就好比大学生找工作一样, 新创建的对象就是一批又一批的大学生简历, 第一轮 GC 就是 HR 针对简历的筛选, 存活下来的那部分就继续进行不断的笔试面试, 仍然存活下来的就可以进到公司入职了. 进入到公司之后就相当于被拷贝到了老年代, 此时仍然会有被淘汰的风险, 需要进行一系列的绩效考核, 考核如果不合格, 还是会被淘汰, 只是考验的频率大大降低了. 最后一个小特例就相当于那些有背景的, 例如公司是他爸开的, 不需要经过笔试面试, 直接入职.


本篇文章就到这里了, 谢谢观看!!

  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

Master_hl

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

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

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

打赏作者

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

抵扣说明:

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

余额充值