Java - JVM内存模型及GC(垃圾回收)机制

JVM内存模型

JVM堆内存划分(JDK1.8以前)


JVM堆内存划分(JDK1.8之后)


主要变化在于:

  • java8没有了永久代(虚拟内存),替换为了元空间(本地内存)。
  • 常量池:1.7又把他放到了堆内存中;1.8之后出现了元空间,它又回到了方法区。
    年轻代:

新生成的对象都放在年轻代,主要存放一些生命周期比较短的对象

新生代一般分三个区:一个Eden区,两个 Survivor区:

大部分对象在Eden区中生成,当Eden区满时,还存活的对象将被复制到Survivor区,当这个 Survivor区满时,此区的存活对象将被复制到另外一个Survivor区,当这个Survivor去也满了的时候,从第一个Survivor区复制过来的并且此时还存活的对象,将被复制到老年代(Tenured)。

同时,根据程序需要,Survivor区是可以配置为多个的(多于两个),这样可以增加对象在年轻代中的存在时间,减少被放到年老代的可能。
老年代:

在年轻代中经历多次垃圾回收后仍存活的对象会被放入老年代中,一般存放生命周期较长的对象
java7永久代:

用于存放静态文件,如Java类、方法等。永久带对垃圾回收没有显著影响,一般不做垃圾回收,在JVM内存中划分空间。
java8元空间:

类似于永久带,不过它是直接使用物理内存而不占用JVM堆内存。


垃圾回收机制

Java不用像C++一样自己释放内存,通过垃圾回收器GC来进行内存的回收释放。

不需要进行垃圾回收:程序计数器、JVM栈、本地方法栈。

需要进行回收垃圾的区:堆和方法区

方法区,也不太需要被回收, 它存放的是类对象, 主要工作就是 "类加载", 但是很少会涉及到 "类卸载", 需要 GC 但不迫切。堆,才是 GC工作的主战场, 很多 new 出来的对象, 用完之后, 就需要被及时回收!!
什么时候进行垃圾回收?

该类的所有实例对象都已经被回收。

加载该类的ClassLoader已经被回收。

该类对应的反射类java.lang.Class对象没有被任何地方引用。
几种垃圾收集器:

Minor GC:新生代GC,即发生在新生代(包括Eden区和Survivor区)的垃圾回收操作,当新生代无法为新生对象分配内存空间的时候,会触发Minor GC。因为新生代中大多数对象的生命周期都很短,所以发生Minor GC的频率很高,虽然它会触发stop-the-world,但是它的回收速度很快。

Major GC:Tenured区GC,用于回收老年代,出现Major GC通常会出现至少一次Minor GC。

Full GC:是针对整个新生代、老生代、元空间(metaspace,java8以上版本取代perm gen)的全局范围的GC。Full GC不等于Major GC,也不等于Minor GC+Major GC,发生Full GC需要看使用了什么垃圾收集器组合,才能解释是什么样的垃圾回收。

垃圾回收机制流程

GC主要处理的是年轻代与老年代的内存清理操作,元空间(永久代)一般很少用GC。具体流程如下:

① 当一个新对象产生,需要内存空间,为该对象进行内存空间申请。

② 首先判断Eden区是否有有内存空间,有的话直接将新对象保存在Eden区。

③ 如果此时Eden区内存空间不足,会自动触发MinorGC,将Eden区不用的内存空间进行清理,清理之后判断Eden区内存空间是否充足,充足的话在Eden区分配内存空间。

④ 如果执行MinerGC发现Eden区不足,判断存活区,如果Survivor区有剩余空间,将Eden区部分活跃对象保存在Survivor区,随后继续判断Eden区是否充足,如果充足在Eden区进行分配。

⑤ 如果此时Survivor区也没空间了,继续判断老年区,如果老年区空间充足,则将Survivor区中活跃对象保存到老年代,而后存活区有空余空间,随后Eden区将活跃对象保存在Survivor区之中,在Eden区为新对象开辟空间。

⑥ 如果老年代满了,此时将产生MajorGC进行老年代内存清理,进行完全垃圾回收。

⑦ 如果老年代执行MajorGC发现依然无法进行对象保存,此时会进行OOM异常(OutOfMemoryError)。

上面流程就是整个垃圾回收机制流程,总的来说,新创建的对象一般都会在Eden区生成,除非这个创建对象太大,那有可能直接在老年区生成。


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

引用计数方案:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


几种垃圾回收算法

标记-清除算法(Mark-Sweep):最基础的GC算法,将需要进行回收的对象做标记,之后扫描,有标记的进行回收,这样就产生两个步骤:标记和清除。这个算法效率不高,而且在清理完成后会产生内存碎片,这样,如果有大对象需要连续的内存空间时,还需要进行碎片整理。
复制算法(Copying):新生代内存分为了三份,Eden区和2块Survivor区,保证有一块Survivor区是空闲的,这样,在垃圾回收的时候,将不需要进行回收的对象放在空闲的Survivor区,然后将Eden区和第一块Survivor区进行完全清理,这样有一个问题,就是如果第二块Survivor区的空间不够大怎么办?这个时候,就需要当Survivor区不够用的时候,暂时借持久代的内存用一下。此算法适用于新生代。
标记-整理(或叫压缩)算法(Mark-Compact):和标记-清楚算法前半段一样,只是在标记了不需要进行回收的对象后,将标记过的对象移动到一起,使得内存连续,这样,只要将标记边界以外的内存清理就行了。此算法适用于持久代。

分代回收算法

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

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

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

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

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

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

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

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

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

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

相关推荐
m0_5719575830 分钟前
Java | Leetcode Java题解之第543题二叉树的直径
java·leetcode·题解
一点媛艺2 小时前
Kotlin函数由易到难
开发语言·python·kotlin
姑苏风2 小时前
《Kotlin实战》-附录
android·开发语言·kotlin
奋斗的小花生3 小时前
c++ 多态性
开发语言·c++
魔道不误砍柴功3 小时前
Java 中如何巧妙应用 Function 让方法复用性更强
java·开发语言·python
NiNg_1_2343 小时前
SpringBoot整合SpringSecurity实现密码加密解密、登录认证退出功能
java·spring boot·后端
闲晨3 小时前
C++ 继承:代码传承的魔法棒,开启奇幻编程之旅
java·c语言·开发语言·c++·经验分享
老猿讲编程3 小时前
一个例子来说明Ada语言的实时性支持
开发语言·ada
Chrikk4 小时前
Go-性能调优实战案例
开发语言·后端·golang
幼儿园老大*4 小时前
Go的环境搭建以及GoLand安装教程
开发语言·经验分享·后端·golang·go