什么是垃圾(Garbage)呢?
垃圾是指在 运行程序中没有任何指针指向的对象, 这个对象就是需要被回收的垃圾。
如果不及时对内存中的垃圾进行清理,那么这些垃圾对象所占的内存空间会一直保留到应用程序结束,被保留的空间无法被其他对象使用。甚至可能导致内存溢出。
想要学习GC,首先需要理解为什么需要GC?
在堆里存放着几乎所有的Java对象实例,在GC(垃圾回收器)执行垃圾回收之前,首先需要区分出内存中哪些是存活对象(有用对象),哪些是死亡对象(垃圾对象)。只有被标记为已经死亡的对象,GC才会在执行垃圾回收时,释放掉其所占用的内存空间,因此这个过程我们可以称为垃圾标记阶段
那么在JVM中究竟是如何标记一个死亡对象呢?简单来说,当一个对象已经不再被任何的存活对象继续引用时,就可以宣告为已经死亡
判断对象存活一般有两种方式:引用计数算法 和 可达性分析算法
引用计数算法(Reference Counting)比较简单,对每个对象保存一个整形的引用计数器属性,用于记录对象被引用的情况
优点:实现简单,垃圾对象便于辨识;判定效率高,回收没有延迟性。
缺点:
循环引用现象
可达性分析特点:
基本思路:
问题来了,哪些对象可被称为GC Roots对象呢?或者说Java中,GC Roots包含哪几类对象呢?
注意:
当成功区分出内存中存活对象和死亡对象后,GC接下来的任务就是执行垃圾回收、释放掉垃圾对象所占用的内存,以便有足够的可用空间为新对象分配内存。
目前在JVM中比较常见的三种垃圾回收算法是:
标记-清除(Mark - Sweep)算法是最基础的收集算法,它分为“标记”和“清除”两个阶段:
优点:
缺点:
标记-整理分为“标记”和“整理”两个阶段:
标记-整理算法的最终效果等同于标记-清除算法执行后,再进行一次内存碎片整理,因此也可以把它称为标记-清除-压缩算法
可以看到,标记的存活对象将会被整理,按照内存地址依次排序。如此一来,当我们需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可,这比维护一个空闲列表显然少了许多开销
优点:
缺点:
核心思想:
将内存空间分为两块,每次只使用其中一块,在垃圾回收时将正在使用的内存中的存活对象复制到未被使用的内存块中,之后清除正在使用的内存块中的对象,交换两个内存的角色,最后完成垃圾回收
对于这种算法来说,如果存活的对象过多的话则要执行较多的复制操作,效率会变低,因此它适合存活率较低的情况。事实上在年轻代中就是使用的复制算法。
优点:
缺点:
Mark-Sweep | Nark-Compact | Copying | |
---|---|---|---|
速度 | 中等 | 最慢 | 最快 |
空间开销 | 少(但有内存碎片) | 少(没有内存碎片) | 需要额外的一半内存开销 |
移动对象 | 否 | 是 | 是 |
从效率来说,复制算法是当之无愧的老大,但是却浪费了太多内存
而为了尽量兼顾上面提到的三个指标,标记-整理算法相对来说更平滑一些,但是效率上不尽如人意,它比复制算法多了一个标记的阶段,比标记-清除算法多了一个整理的阶段
通俗的理解java对象的这一辈子
我是一个普通的java对象,我出生在Eden区,在Eden区我还看到和我长的很像的小兄弟,我们在Eden区中玩了挺长时间。有一天Eden区中的人实在是太多了,我就被迫去了Survivor区的“From”区,自从去了Survivor区,我就开始漂了,有时候在Survivor的“From”区,有时候在Survivor的“To”区,居无定所。直到我18岁的时候,爸爸说我成人了,该去社会上闯闯了。于是我就去了年老代那边,年老代里,人很多,并且年龄都挺大的,我在这里也认识了很多人。在年老代里,我生活了20年(每次GC加一岁),然后被回收。
分代收集过程
8:1:1
的比例分为一个 Eden
(伊甸园区) 和两个 Survivor
(幸存区),幸存区一个称为“From”区、一个称为“To”区,名字是动态的(谁空谁是“to”)。当新对象生成,Eden存满了空间不足,则会发起一次 Minor GC。回收时先将 Eden 区存活对象复制到一个 From区,然后清空 Eden 区。再次触发Minor GC时(Eden园满了才会触发),则将 Eden 区和 From区的存活对象复制到另一个 To区,然后清空 Eden 和这个 From区,此时 From区是空的,然后将 From区和 To区交换(谁空谁是To区), 如此往复。当 To区不足以存放 Eden 和 From的存活对象时,就将存活对象直接存放到老年代。当对象在 Survivor 区躲过一次 GC 的话,其对象年龄便会加 1,默认情况下,如果对象年龄达到 15 岁,就会移动到老年代中。若是老年代也满了就会触发一次 Full GC,也就是新生代、老年代都进行回收。新生代大小可以由-Xmn来控制,也可以用-XX:SurvivorRatio来控制 Eden 和 Survivor 的比例。(垃圾清除算法用的是复制算法)因此我提问个问题,调用System.gc()方法后就一定会触发Full GC吗?答案是不一定,接下来我用代码来演示:
注意:Java提供了一个名为finalize()的方法,它的工作原理应该是这样的:一旦垃圾收集器准备好释放对象占用的存储空间,它首先调用finalize()。
因此我们利用finalize()这个方法来判断是否触发了垃圾回收
public class GCTest {
public static void main(String[] args) {
new SystemGCTest();
System.gc();//提醒jvm的垃圾回收器执行gc,但是不确定是否马上执行gc
//与Runtime.getRuntime().gc();的作用一样。
// System.runFinalization();//强制调用 失去引用的对象的finalize()方法
}
@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("SystemGCTest 重写了finalize()");
}
}
分析上述代码,new SystemGCTest()这个代码并没有任何引用指向它,说明它是一个垃圾对象,那么如果发生Full GC的话就肯定会回收这个垃圾对象,紧接着肯定会调用finalize方法,并打印"SystemGCTest 重写了finalize()"信息
测试结果:
其实我测试了好多次,大多数情况下还是打印"SystemGCTest 重写了finalize()"这个信息的,表示Full GC的几率还是挺大的,但是在我不屈服的、不认输的努力下,这次测试是没有打印信息的,也即是说,这个垃圾对象没有被回收,并没有进行Full GC。得出结论,System.gc()无法保证Full GC一定执行
Stop The World,简称STW,指的是垃圾回收事件发生过程中,会产生应用程序的停顿。停顿产生时整个应用程序线程都会被暂停,没有任何响应,有点像卡死的感觉,这个停顿称为“STW”。可达性分析算法中枚举GC Roots时会导致Java执行线程停顿。
GC时为什么会有全局停顿?
类比在聚会时打扫房间,聚会时很乱,又有新的垃圾产生,房间永远打扫不干净,只有让大家停止活动了,才能将房间打扫干净。当gc线程在处理垃圾的时候,其它java线程要停止才能彻底清除干净,否则会影响gc线程的处理效率增加gc线程负担,特别是在垃圾标记的时候。
Javadoc中对OutOfMemoryError的解释是:没有空闲内存,并且垃圾收集器也无法提供更多内存
Java虚拟机的堆内存不够,原因有二:
-Xms
、-Xmx
来调整java.lang.OutOfMemoryError: PermGen space
”;随着元空间的引用,方法区内存就不再那么窘迫了,所以相应的OOM有所改观,出现OOM异常的信息则变成:“java.lang.OutOfMemoryError: Metaspace
”; 直接内存不足也会导致OOM。严格来说,只有对象不会再被程序用到了,但是GC有不能回收他们的情况,才叫内存泄漏
内存泄漏举例:
在java中,除了基本数据类型的变量外,其他所有的变量都是引用类型,指向堆上各种不同的对象。
在jvm中,除了我们常用的强引用
(我们平时无意之间用的大都是强引用)外,还有软引用
、弱引用
、虚引用
,这四种引用类型的生命周期与jvm的垃圾回收过程息息相关。
所有引用类型,都是抽象类java.lang.ref.Reference的子类,这个类的主要方法为get()方法:
public T get() {
return this.referent;
}
除了虚引用(因为get永远返回null),如果对象还没有被销毁,都可以通过get方法获取原有对象。
最传统的引用的定义,是指在程序代码中最普遍存在的引用赋值,即类似“Object object = new Object()”
这种引用关系。强引用(Strong references)就是直接new一个普通对象,表示一种比较强的引用关系,只要还有强引用对象指向一个对象,那么表示这个对象还活着(GC Roots可达),垃圾收集器宁可抛出OOM异常,也不会回收这个对象。
软引用用于关联一些可有可无的对象,例如缓存。当系统内存充足时,这些对象不会被回收;当系统内存不足,将要发生内存溢出之前,就会回收这些对象(即使这些对象GC Roots可达),如果回收完这些对象后内存还是不足,就会抛出OOM异常。
// vm args: -Xmx36m -XX:+PrintGCDetails
public class SoftReferenceDemo {
public static void main(String[] args) throws InterruptedException {
SoftReference<User> softReference = new SoftReference<>(new User()); // 软引用
System.out.println(softReference.get());
System.gc();
TimeUnit.SECONDS.sleep(3); // wait gc thread run
System.out.println(softReference.get()); // User对象不会被回收
byte[] bytes = new byte[1024 * 1024 * 10]; // 分配一个大对象使得堆空间不足,软引用对象会在OOM之前先被回收
System.out.println(softReference.get());
}
}
在上面的例子中,第一次发生gc时,User对象不会被回收,第二次发生gc时由于堆空间不足,会先回收软引用的对象,回收完了还是空间不足,最后抛出OOM异常。
被弱引用关联的对象只能生存到一下次垃圾回收之前。当垃圾收集器工作时,无论内存空间是否充足,都会回收掉被弱引用关联的对象。ThreadLocal中就使用了WeakReference来避免内存泄漏。
public class WeakReferenceDemo {
public static void main(String[] args) throws InterruptedException {
WeakReference<User> weakReference = new WeakReference<>(new User());
System.out.println(weakReference.get());
System.gc();
TimeUnit.SECONDS.sleep(3); // wait gc thread run
System.out.println(weakReference.get()); // null
}
}
上面的例子只要发生gc,User对象就会被垃圾收集器回收。
接下来我们重点研究Jvm的垃圾收集器(serial收集器、parnew收集器、parallel scavenge收集器、serial old 收集器、parallel old收集器、cms收集器、g1收集器)。上面讲了那么多就是为它做铺垫的
正式进入前先看下图解HotSpot虚拟机所包含的收集器:
图中展示了7种作用于不同分代的收集器,如果两个收集器之间存在连线,则说明它们可以搭配使用。虚拟机所处的区域则表示它是属于新生代还是老年代收集器。
几个相关概念:
特点:
Serial 即串行的意思,也就是说它以串行的方式执行,单线程、简单高效(限定单个CPU的环境来说),Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程手机效率。收集器进行垃圾回收时,必须暂停其他所有的工作线程,直到它结束(Stop The World)。
ParNew收集器其实就是Serial收集器的多线程版本。除了使用多线程外其余行为均和Serial收集器一模一样(参数控制、收集算法、Stop The World、对象分配规则、回收策略等)。
特点:
多线程、ParNew收集器默认开启的收集线程数与CPU的数量相同,在CPU非常多的环境中,可以使用-XX:ParallelGCThreads
参数来限制垃圾收集的线程数。和Serial收集器一样存在Stop The World问题
应用场景:
ParNew收集器是许多运行在Server模式下的虚拟机中首选的新生代收集器,因为它是除了Serial收集器外,唯一一个能与CMS收集器配合工作的。
ParNew/Serial Old组合收集器运行示意图如下:
与吞吐量关系密切,故也称为吞吐量优先收集器。
特点:
属于新生代收集器也是采用复制算法的收集器,又是并行的多线程收集器(与ParNew收集器类似)。该收集器的目标是达到一个可控制的吞吐量。还有一个值得关注的点是:GC自适应调节策略(与ParNew收集器最重要的一个区别)
GC自适应调节策略:Parallel Scavenge收集器可设置-XX:+UseAdptiveSizePolicy
参数。当开关打开时不需要手动指定新生代的大小(-Xmn
)、Eden与Survivor区的比例(-XX:SurvivorRation
)、晋升老年代的对象年龄(-XX:PretenureSizeThreshold
)等,虚拟机会根据系统的运行状况收集性能监控信息,动态设置这些参数以提供最优的停顿时间和最高的吞吐量,这种调节方式称为GC的自适应调节策略。
Parallel Scavenge收集器使用两个参数控制吞吐量:
XX:MaxGCPauseMillis
控制最大的垃圾收集停顿时间XX:GCRatio
直接设置吞吐量的大小Serial Old是Serial收集器的老年代版本。
特点:同样是单线程收集器,采用标记-整理算法
Serial / Serial Old收集器工作过程图(Serial收集器图示相同):
Parallel Old是Parallel Scavenge收集器的老年代版本。
特点:多线程,采用标记-整理算法。
应用场景:注重高吞吐量以及CPU资源敏感的场合,都可以优先考虑Parallel Scavenge+Parallel Old 收集器。
Parallel Scavenge/Parallel Old收集器工作过程图:
一种以获取最短回收停顿时间为目标的收集器。
特点:基于标记-清除算法实现。并发收集、低停顿。
应用场景:适用于注重服务的响应速度,希望系统停顿时间最短,给用户带来更好的体验等场景下。如web程序、b/s服务。
CMS收集器的GC周期主要由7个阶段组成,其中有两个阶段会发生stop-the-world,其他阶段都是并发执行的。(亦有4个阶段、6个阶段等说法)
初始化标记阶段,是CMS GC的第一个阶段,也是标记阶段的开始。主要工作是标记GC Roota可直达的存活对象。
主要标记过程:
程序执行情况:
在该阶段,GC线程和应用线程将并发执行。也就是说,在第一个阶段(Initial Mark)被暂停的应用线程将恢复运行。
并发标记阶段的主要工作是,通过遍历第一个阶段(Initial Mark)标记出来的存活对象,继续递归遍历老年代,并标记可直接或间接到达的所有老年代存活对象。
由于在并发标记阶段,应用线程和GC线程是并发执行的,因此可能产生新的对象或对象关系发生变化,例如:
对于这些对象,后面需要重新标记以防止被遗漏(漏标)。为了提高重新标记的效率,本阶段会把这些发生变化的对象所在的Card标识为Dirty,这样后续就只需要扫描这些Dirty Card的对象,从而避免扫描整个老年代。
该阶段将会重新扫描前一个阶段标记的Dirty对象,并标记被Dirty对象直接或间接引用的对象,然后清除Card标识。
本阶段尽可能承担更多的并发预处理工作,从而减轻在Final Remark阶段的stop-the-world。
在该阶段,主要循环的做两件事:
具体执行多久,取决于许多因素,满足其中一个条件将会中止运行:
预清理阶段也是并发执行的,并不一定是所有存活对象都会被标记,因为在并发标记的过程中对象及其引用关系还在不断变化中。
因此,需要有一个stop-the-world的阶段来完成最后的标记工作,这就是重新标记阶段(CMS标记阶段的最后一个阶段)。主要目的是重新扫描之前并发处理阶段的所有残留更新对象。
主要工作:
并发清理阶段,主要工作是清理所有的死亡对象,回收被占用的空间。
并发重置阶段,将清理并恢复在CMS GC过程中的各种状态,重新初始化CMS相关数据结构,为下一个垃圾收集周期做好准备。
问:CMS中的浮动垃圾理解?
书上说:并发清理阶段用户线程还在运行,这段时间就可能产生新的垃圾,新的垃圾在此次GC无法清除,只能等到下次清理。这些垃圾有个专业名词:浮动垃圾。
这个浮动垃圾如何理解?难道不是在本次GC重新标记remark的过程中被发现然后清理吗?为何还要等下次GC才能清理?
答:
重新标记(Remark) 的作用在于:之前在并发标记时,因为是 GC 和用户程序是并发执行的,可能导致一部分漏标的对象,因为用户程序的(并发)运行。Remark 的作用就是将这部分对象又标记为 可达对象。 至于 “浮动垃圾”,因为 CMS 在 并发标记 时是并发的,GC 线程和用户线程并发执行,这个过程当然可能会因为线程的交替执行而导致新产生的垃圾(即浮动垃圾)没有被标记到;而 重新标记 (Remark)主要关注的 前面并发标记 漏标的情况,所以是没有办法处理 “浮动垃圾” 的。
G1从jdk7开始,jdk9被设为默认垃圾收集器;目标就是彻底替换掉CMS
将内存分成一个个的Region,且不要求各部分是连续的。G1在逻辑上还是划分Eden、Survivor、OLd,但是物理上他们不是连续的。
每个Region的大小在JVM启动时就确定,JVM通常生成2048个左右的heap区, 根据堆内存的总大小,区的size范围为1-32Mb,一般4M.
region类型:
特别说明:某个region的类型不是固定的,比如一次ygc过后,原来的Eden的分区就会变成空闲的可用分区,随后也可能被用作分配巨型对象
G1的运行过程与CMS大体相似,分为以下四个步骤:
TAMS指针:在并发标记过程中,如何进行新对象的内存分配呢?
G1有两个TAMS指针,把Region中的一部分空间划分出来用于并发回收过程中的新对象的分配。并发回收时新分配的对象地址都必须在这两个指针之上,G1收集器默认在这个地址上的对象是存活的,不纳入回收范围。
收集集合(CSet)代表每次GC暂停时回收的一系列目标分区。在任意一次收集暂停中,CSet所有分区都会被释放,内部存活的对象都会被转移到分配的空闲分区中。因此无论是年轻代收集,还是混合收集,工作的机制都是一致的。年轻代收集CSet只容纳年轻代分区,而混合收集会通过启发式算法,在老年代候选回收分区中,筛选出回收收益最高的分区添加到CSet中。
CSet根据两种不同的回收类型分为两种不同CSet。
CSet of Young Collection 只专注回收 Young Region 跟 Survivor Region ,而CSet of Mix Collection 模式下的CSet 则会通过RSet计算Region中对象的活跃度,活跃度阈值-XX:G1MixedGCLiveThresholdPercent(默认85%),只有活跃度高于这个阈值的才会准入CSet,混合模式下CSet还可以通过XX:G1OldCSetRegionThresholdPercent(默认10%)设置,CSet跟整个堆的比例的数量上限。
Java堆划分为相等大小的一个个区域,这个小的区域(一般size在128-512字节)被当做Card,而Card Table维护着所有的Card。Card Table的结构是一个字节数组,Card Table用单字节的信息映射着一个Card。当Card中存储了对象时,称为这个Card被脏化了(dirty card)。 对于一些热点Card会存放到Hot card cache。同Card Table一样,Hot card cache也是全局的结构。
RememberedSets,存储着其他分区中的对象对本分区对象的引用,每个分区有且只有一个RSet。用于提高GC效率。
YGC时,GC root主要是两类:栈空间和老年代分区到新生代分区的引用关系。所以记录老年代分区对新生代分区的引用
Mixed GC时,由于仅回收部分老年代分区,老年代分区之间的引用关系也将被使用。所以记录老年代分区之间的引用
因此,我们仅需要记录两种引用关系:老年代分区引用新生代分区,老年代分区之间的引用。
CSet是怎么维护的?怎么知道哪些是要回收的?
由G1MixedGCLiveThresholdPercent参数控制的,old代分区中的存活对象比,达到阀值时,说明该region可以被回收的对象比较多,这个old分区会被放入CSet,等待被GC。
cardTable和region是什么关系?对region的细分吗?有什么用?
可以认为region切分为一个一个固定大小card。而CardTable是一个全局的存储结构,其通过一个byte数组结构存储了对于每个Card的Entry,其不需要太大的存储空间。而RSet中的HashTable也就是一些其他Region(引用了RSet所在的Region)的card集合。
cardTable和RSets
RSets:哈希表来存储,key是region index,value是card数组
cardTable:记录多个cardPage信息,单个CardPage大小为512字节,卡表(Card Table)被实现为一个简单的字节数组,即卡表的每个标记项为1个字节。当对一个对象引用进行写操作时(对象引用改变),写屏障逻辑将会标记对象所在的卡页为dirty。如:
CARD_TABLE [this address >> 9] = 0;
GC后会将存活对象复制到可用分区(未分配的分区),所以不会产生空间碎片。
触发:分配一般对象(非巨型对象)时,当所有eden的region使用达到最大阀值并且无法申请足够内存时。
younggc会回收所有Eden以及Survivor区,并且将存活对象复制到Old区以及另一部分的Survivor区。因为YoungGC会进行根扫描,所以会stop the world。
YoungGC的回收过程如下:
触发:一次YoungGc之后,老年代占据堆内存的百占比超过InitiatingHeapOccupancyPercent(默认45%)时,超过这个值就会触发mixedGC。
混合回收都是基于复制算法进行的,把要回收的Region区存活的对象放入其他Region,然后这个Region全部清理掉,这样就会不断空出来新的Region;
有一个参数-XX:G1HeapWastePercent,默认值5%,即空出来的区域大于整个堆的5%,就会立即停止混合回收了。如正常默认回收次数是8次,但是可能到了4次,空闲Region大于整个堆的5%,就不会再进行后续回收了。
MixGc过程:
G1在对象复制/转移失败或者没法分配足够内存(比如巨型对象没有足够的连续分区分配)时,会触发FullGC。
开始版本FullGC使用的是stop the world的单线程的Serial Old模式。
JDK10以后,Full GC已经是并行运行,在很多场景下,其表现还略优于 Parallel GC 的并行 Full GC 实现。
该篇文章,是我看了多个垃圾回收相关视频以及多篇优秀文章总结出来。(我不生产水,只是大自然的搬运工)
如果这篇文章对你有帮助的话,请点个赞吧!
如果文章有叙述不正确的地方,欢迎各位在评论区提出!!
谢谢,希望能够帮到你!!!