文章目录
前言:在上一节中我们已经粗略的了解了
java
虚拟机中各个内存的作用。
【JVM-01】JVM内存管理及回收机制
本章带大家了解一下,内存中产生的对象如何回收、虚拟机有哪些收集器及各个部分的分配策略。
在正式进入介绍的内容之前可以思考几个问题:
- 哪些内存是需要回收的?
- 什么时候回收?
- 如何回收?
通常所说的垃圾回收一般都针对于Java堆及方法区,因为程序计数器、虚拟机栈、本地方法栈都是每个线程独有的,每个栈中的内存大小在类机构确定下来之后就已知;同时内存会随着线程的结束而回收。而Java堆及方法区都是多个线程共享的,真正的产生了多少内存只有运行时才知晓。因此通常意义的垃圾回收都是针对于Java堆及方法区而言。
1. 判断回收对象
问题1:如何判断哪些对象需要回收?
判断对象是否已死,通常有两种方法
1. 引用计数算法(jvm未采用该方法)
思想:给对象添加一个引用计数器,每引用一次,计数器加1;引用失效时,计数器减1。如果计数器为0,表示该对象不可能别引用。
实验表明:
JVM不是采用该方法判断对象是否存活。
2. 可达性分析算法
思想:选择一系列对象作为垃圾回收的起始点(GC Roots),当某个对象到起始点不可达时,则判断该节点已死。
那么问题来了,什么样的对象可以作为起始点呢?
一般包括以下四种
- 虚拟机栈(栈帧中的本地变量表)中引用的对象。
比如:Boy boy = new Boy()
new Boy()对象就可以当做Gc Root - 方法区中类静态属性引用的对象。
- 方法区中常量引用的对象。
- 本地方法栈中引用的对象
对象死亡的过程:
- 判刑
在可达性分析中判断为不可达的对象,也不是马上回收,一个对象的真正死亡,需要标记两次。第一次不可达时,虚拟机会标记一次并且进行一次筛选,筛选条件时此对象是否需要执行finalize()
方法。只有当对象没有覆盖finalize()
方法并且finalize()
已经被虚拟机调用过时,该对象不需要马上回收。 - 自我救赎
如果对象在第一次标记之后,与引用链上的任何一个对象建立关联,则可以在第二次标记的时候将它移除出"即将回收"的集合。 - 真正的死亡
如果对象在第一次与第二次标记之前没有得到引用链上任何对象的关联,则直接判断死亡。
3. 回收方法区
方法区,(又称为虚拟机的永久代)该地区主要回收两部分内容:
- 废弃常量 。比如说
String
对象的“helloWorld”
,没有任何的String对象引用常量池的中该常量,则该常量称为废弃常量。 - 无用的类。
无用类判断稍稍复杂,有很多个判别标准,一般而言需要满足一下三个条件才可称为不用类。- 该类的所有实例都已经被回收。也就是Java堆中不存在该类的任何实例。
- 加载该类的
classLoader
(后面的文章会介绍该部分) - 该类对应的java.lang.class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
2. 常见的垃圾回收算法
虚拟机是如何回收虚拟机堆中或者方法区中产生的垃圾的呢?
通常会采用如下三种回收算法:
1. 标记-清除算法
思想:该方法主要分为两个阶段
- 标记处所有需要回收的对象
- 统一回收所有的对象
缺点:1. 效率低。标记和清除过程效率都不高。2. 清除之后会产生大量的不连续的内存碎片。
2. 复制算法(用于新生代回收)
上面提到了标记-清除算法效率低下,为了解决这一问题,提出了复制算法。
思想:
- 将系统划分为容量等大的两块。
- 每次只是用其中一部分,等这一部分用尽;将该部分存活的对象复制到另一区域,然后一次性清除掉所有的垃圾。
缺点:内存浪费严重,毕竟有一半内存都用于备份。
3. 标记-整理算法(常用于老年代)
思想:
- 先标记需要回收的对象
- 所有存活的对象都往一端移动、然后直接清理掉另一端垃圾内存即可。
三种垃圾回收算法都有各自的利弊,虚拟机利用各自的优点综合回收垃圾。
比如新生代,每次在垃圾回收的时候都有大量的对象死去,只有少数对象可以存活,因此采用复制算法,只需要复制少量的对象就可以完成垃圾回收过程。
对于老年代,都是饱经沧桑,从新生代活过来的,一般存活机率极高,并且没有额外的空间作为担保,通常采用标记-清除,或者标记-整理算法。
3. 市面有哪些垃圾收集器
一张图带大家了解市面上常见的垃圾收集器
上半部分绿色的为主要作用于新生代的垃圾收集器,下半部分主要作用于老年代的垃圾收集器。(关于新生代和老年代的概念稍后会讲解)
为什么如此多不同种类的收集器?
虚拟机垃圾回收是一个消耗线程的过程。一个线程只能做一件事儿,比如出生较早的Serial
、ParNew
、Parallell Scavage
等等在垃圾回收的过程中,必须要暂停调所有的用户线程。就好比你在看电视的时候,突然有个人告诉你:你必须把电视机关掉,我需要清理电视桌,你估计会大怒。同样,在程序工作的时候,频繁的切换工作线程和垃圾回收线程会让系统消耗更多的资源。
因此,为了让垃圾回收时用户线程停顿时间缩短,或者说用户线程根本不需要停顿。一边回垃圾一边工作。即使无法真正的全程并发,也可以提前估算好需要停顿的时间。
重点介绍一下GMS收集器和G1垃圾收集器。
1. CMS
Concurrent Mark Swap:直观可以翻译成并发标记-清除。
主要的工作过程如下图所示:
主要分为四个阶段:
- 初始标记。标记一下GC Roots可以直接关联到的对象,速度快。但是用户线程必须停顿。
- 并发标记。和第一阶段工作一致。但是可以和用户线程并发进行。
- 重新标记。同样是标记垃圾,用于修正并行标记阶段程序运行期间产生的垃圾变动。用户线程停顿,回收线程并行。
- 并发清理。开始清理标记的垃圾,这阶段用户线程不用停顿。
思考:
上图中红色字体,SafePoint
,姑且翻译成安全点,这个点表示什么含义呢?
2. G1收集器
G1:Garbage First,当前最为先进的垃圾收集,可以建立可预测的停顿时间模型,有计划的避免对整个java堆进行全区的垃圾收集。
主要工作过程如下图所示:
同样分为四阶段:
- 初始标记。标记一下Gc Root可以关联的对象。用户线程停顿
- 并发标记。真正的标记垃圾。用户线程不用停顿。
- 最终标记。为了修正正在并发标记期间因用户程序继续运行而造成的原来标记发生变化的标记记录。用户线程停顿。
- 筛选回收。回收垃圾,这阶段可以与用户线程并发。
解释两个名词:并发、并行
并行:多条垃圾回收线程一起工作,但是用户线程仍然处于停顿状态
并发:用户线程和垃圾回收线程同时执行(可能是交替执行)。
回到上面遗留的思考问题?什么是safePoint?
这个问题其实关联到本文最初的问题:垃圾什么时候开始回收?
垃圾回收并不是旅行,说走就走,而是有自己的规律。只要达到安全点(SafePoint)的时候才开始执行Gc操作,安全点选定既不能太少,这样会让GC等待时间过长,也不能太多,否则Gc操作过于频繁,资源消耗过多。
根据《深入理解java虚拟机》该章节选定标准:是否具有让程序长时间执行的特征。
一般是方法的调用、循环的跳转、异常的跳转等节点作为优选点,这个个时候引用关系不会发生大的变化,在这个区域的任意地方开始Gc都是可以的。
3. 内存回收
回到文章的最初, 内存中的垃圾是如何回收的?
想要知道内存中的对象(或者说垃圾)是如何回收的,得首先知道对象在内存中如何分配。
1.分配及回收
-
大多数情况,对象会先在新生代Eden区(也就是之前说的新生代)分配,当该区域没有足够的内存空间时,虚拟机会先进性一次Minor Gc(新生代GC)。
-
如果大量占用连续内存空间的java对象(超级长的字符串、数组),则会分配到堆内存中的老年代。
什么叫大对象呢,有没有一个判断标准呢?肯定是有的,虚拟机提供一个参数-XX:PertennreSizeThreshold
,如果内存大于该值,则直接分配到老年代。 -
长期存活的对象直接进去老年代。
如果对象在Eden(新生代)出生,并且经过一次MinorGC
仍然存活,并且还可以被容纳,则将其一到Survivor
空间内(该区域也是见名知其意啊),并且对象年龄加1。也就是说对象每熬过一次MinorGC
,年龄增加1。
同样对于如何判断一个对象是否足够老,虚拟机也提供了一个参数:-XX:MaxTenuringThreshold
,只要年龄超过该值,则判断对象该进入老年代了。 -
动态年龄判断。当然虚拟机也是灵活的,并不是只有等对象年龄大于设定的
-XX:MaxTenuringThreshold
值时才需要回收。如果Survivor
区域内相同年龄所有对象大小的总和大于Survivor
区域的一半,大于该年龄的对象同样需要被回收。
下图展示堆内存中中的内存分配策略:
4. 总结
到目前为止,我们已经介绍了,哪些内存是需要回收的?什么时候回收?如何回收?等问题,并且介绍了相关的垃圾收集器,各个垃圾收集器的利弊分析,堆内存是如何分配的。本节在第一节宏观分析java内存的基础上,着重介绍了堆内存的分配策略及回收策略。
参考书籍:
周志明《深入理解java虚拟机》