目录
1.如何判断对象可以回收
1.1引用计数法
1.2可达性分析算法
1.3五种引用
1.4拓展:直接内存
2.垃圾回收算法
2.1标记清除算法
2.2标记整理算法
2.3复制
3.分代垃圾回收
3.垃圾回收器
3.1串行垃圾回收器
3.2吞吐量优先垃圾回收器
3.3响应时间优先垃圾回收器 (CMS)
3.4 G1垃圾回收器
3.4.1 相关介绍
3.4.2 内部示意图(方便理解的)
3.4.3 工作机制
3.4.5字符串去重
3.4.6类卸载
3.4.7回收巨型对象
3.4.8动态调整堆内存占用阈值
4.MinorGC和FullGC
5.垃圾回收调优
5.1垃圾回收器选择
5.2代码角度
5.3内存调优
5.3.1新生代调优
5.3.1.1调节新生代内存大小
5.3.1.2调节幸存区的大小
5.3.2老年代调优
5.4案例调优分析
5.4.1GC发生频繁
5.3.2请求高峰期发生FullGC且时间较长(CMS等并发回收器)
5.3.3当使用JDK1.8之前的版本时出现老年代内存充足但有FullGC发生的情况
1.如何判断对象可以回收
1.1引用计数法
当一个对象被另一个对象引用时引用计数加一,当不被引用时计数减一,当计数为零也就是没有对象引用它时就将其当做垃圾回收;但这个方法有弊端,当两个对象相互引用时,即使这两个对象不再被使用,但这两个对象的计数都是一,还是无法被回收,造成内存泄漏。
1.2可达性分析算法
在进行垃圾回收时先对堆内存进行扫描,查看是否每个对象都被根对象直接或间接地引用,如果被引用则不能作为垃圾回收,如果没有则会被当做垃圾回收。根对象包括:系统类(运行的核心类)、正在加锁的对象、活动线程使用的对象 (线程在运行时会进行方法调用,每次方法调用都是一个栈帧,也就是栈帧中那些局部变量所引用的对象都是根对象,比如String s=new String(),前半段是局部变量,存在于栈帧中,后半段才是引用的对象,放在堆内存中,后半段才是根对象;但当s=null后,String不再被引用,所以它就不再是根对象,再执行垃圾回收就会被回收掉),还有就是操作系统方法在执行时引用的java对象(JVM在进行一些方法调用时必须要用到一些操作系统的方法,所以这些方法引用的java对象也是根对象不能被回收)。
1.3五种引用
强引用:只有当该对象没有被强引用时才能被回收(new的对象都是强引用)。
软引用:在只有软引用引用该对象时,在进行一次垃圾回收后若内存仍不足则会再次触发垃圾回收,此时会回收该对象;当该对象回收后,可以利用引用队列将软引用本身也回收掉,软引用自身也是一个对象,也会占用内存。
弱引用:在只有弱引用引用该对象时,只要进行垃圾回收就会回收该对象;当该对象回收后,可以利用引用队列将弱引用回收掉,弱引用自身也是一个对象,也会占用内存。
虚引用:必须配合引用队列使用,主要用于释放直接内存。当被引用对象被回收时(即被引用对象使用完毕),就会把虚引用放入引用队列,然后ReferenceHandler线程会定时在队列中查找有无虚引用,如果有就会调用虚引用的相关方法释放直接内存。
终结器引用:须配合引用队列使用,在垃圾回收时会将终结器引用入队,此时被引用对象还没有被回收,再由Finalizer线程通过终结器引用找到被引用对象并调用它的finalize方法,第二次垃圾回收时才会回收被引用对象;由于Finalizer线程的优先级很低,所以finalize方法被执行的机会很少,并且需要两次垃圾回收才能将被引用对象回收掉,所以终结器引用用的次数很少。
在实际开发中,比如要存一些图片,这些图片并不是核心业务资源,如果都采用强引用很可能导致内存不足,因为当垃圾回收时并不会回收强引用的对象,这时就可以使用软引用或弱引用,当内存不足时可以先回收一部分图片,待下次用时再加载即可,解决了内存占用过大的问题。
1.4拓展:直接内存
直接内存是操作系统的一块内存,用于供Java读写磁盘文件时使用。
如果不使用直接内存执行IO操作,读写性能较低:
如果使用直接内存,那么Java程序就能直接从直接内存读写数据,不用复制两次了:
直接内存的分配:
//申请一块1Mb的直接内存
static int _1Mb=1024*1024;
ByteBuffer bb=ByteBuffer.allocateDirect(_1Mb);
//allocateDirect()底层是通过new了一个DirectBuffer对象实现的
public static ByteBuffer allocateDirect(int capacity) {
return new DirectByteBuffer(capacity);
}
//DirectByteBuffer构造器中是使用了Unsafe对象的setMemory方法
DirectByteBuffer(int cap) { // package-private
super(-1, 0, cap, cap);
boolean pa = VM.isDirectMemoryPageAligned();
int ps = Bits.pageSize();
long size = Math.max(1L, (long)cap + (pa ? ps : 0));
Bits.reserveMemory(size, cap);
long base = 0;
//调用了Unsafe对象的allocateMemory和setMemory方法实现分配直接内存
try {
base = unsafe.allocateMemory(size);
} catch (OutOfMemoryError x) {
Bits.unreserveMemory(size, cap);
throw x;
}
unsafe.setMemory(base, size, (byte) 0);
if (pa && (base % ps != 0)) {
// Round up to page boundary
address = base + ps - (base & (ps - 1));
} else {
address = base;
}
//使用虚引用对象进行监听
cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
att = null;
}
由于直接内存不受JVM垃圾回收的管理,所以当直接内存占用过大时也会出现内存溢出的问题(比如一直分配多块直接内存且不被释放),需要对直接内存进行回收。而Unsafe对象可以完成对直接内存的回收,这需要调用Unsafe对象的freeMemory方法。
一种回收方法是通过显式的垃圾回收(即输入代码System.gc()手动调用垃圾回收),将ByteBuffer对象回收掉,由于ByteBuffer的实现类内部使用了Cleaner虚引用来监测ByteBuffer对象,一旦被回收就由ReferenceHandler线程通过Cleaner的clean方法调用run(),而run方法中调用了freeMemory方法释放直接内存;另一种方法是直接用Usafe对象调用freeMemory方法释放直接内存。
在进行JVM调优时一般会禁用显式的垃圾回收,因为显式gc不止回收新生代也会回收老年代,造成程序暂停时间较长,这时由于禁用了显式调用垃圾回收就无法通过释放ByteBuffer对象触发对直接内存的释放,所以就主动调用freeMemory方法释放直接内存。
2.垃圾回收算法
2.1标记清除算法
首先标记那些在堆内存中没有被引用的对象,然后再将这些对象占用的空间释放掉;释放并不是说把这部分内存中的数据清零,而是将这些内存的首尾地址放入一个空闲链表中表示这一区间的内存是空闲的,再次用到时直接将原来的数据覆盖掉。这种算法的优点就是速度快,只需要将首尾地址放入空闲链表中,但缺点就是会产生内存碎片,当需要分配一块较大的连续空间时,这些内存总和虽然满足,但每一块都是一小块空间,并没有将这些空间进行整理,会导致内存溢出。
2.2标记整理算法
首先标记那些在堆内存中没有被引用的对象,然后在清除的过程中会将其他对象占用的内存前移,使得这些对象更紧凑,且能产生较大的连续空间。优点是没有内存碎片,缺点就是速度较慢,需要对剩下的对象进行整理。
2.3复制
首先标记那些在堆内存中没有被引用的对象,然后将存活的对象先复制到另一片空闲的区域,复制完成后原来的整个区域便可以当做一个空闲区域,然后交换这两个区域,将原来的区域作为下一次复制的空闲区域,解决了内存碎片的问题同时也提高了速度,缺点就是需要双倍的内存空间。
实际情况中这三种算法混合使用。
3.分代垃圾回收
下面是和垃圾回收相关的参数,可以在编写项目时使用 :
新生代回收时的跨代引用:
在垃圾回收时会先标记根对象,这些根对象要么被堆外部所引用,要么被老年代的对象所引用,这就需要遍历老年代来查找哪些新生代中的对象被老年代所引用,但遍历老年代所花费的时间较长,并且这种跨代引用的情况很少,所以使用记忆集来记录老年代中哪些引用了新生代中的对象,这样在标记根对象时就可以只看那些被堆外部引用的对象以及记忆集中的信息。记忆集存放在新生代中。
但是这种方法也有漏洞,当老年代中的一个对象不可达(不被堆外引用)但还是引用了一个新生代对象时(比如上图的U和V),这个引用就会记录在记忆集中,但实际上这个新生代的对象也不可达了,需要被回收,但在标记根对象时由于记忆集中还有这条引用信息所以这个对象不会被回收,所以记忆集在提高时间效率的同时也降低了空间利用率。
不过无论如何,它依然确保了垃圾回收所遵循的原则:垃圾回收确保回收的对象必然是不可达对象,但是不确保所有的不可达对象都会被回收。
分代垃圾回收的案例:
初始状态下各部分空间充足:
然后添加一个7Mb大小的对象,并存到list中防止被垃圾回收器回收掉(存到list中就是一直被使用了,除非list置空) :
3.垃圾回收器
3.1串行垃圾回收器
3.2吞吐量优先垃圾回收器
吞吐量优先的意思是追求总的垃圾回收时间在总的工作时间中的占比尽可能低。
3.3响应时间优先垃圾回收器 (CMS)
响应时间优先追求的是在每次gc时的暂停时间(STW,stop the world)尽可能少,也就是追求低延迟。
补充,为什么后面还要进行一次重新标记?
在并发标记时,由于标记垃圾的同时其他线程也在工作,所以会出现这样一种情况,某个对象在进行标记时是垃圾,但后面有一个线程的对象又引用了这个对象且并发标记还没有进行完,这时就又不能被清除;所以当一个对象在初始标记后,在并发标记完成前如果这个对象的引用关系发生了变化,那么就会先执行写屏障,也就是将这个对象放入到一个待处理队列,等到并发标记结束后会对这个待处理队列中所有对象再重新标记一次。
3.4 G1垃圾回收器
3.4.1 相关介绍
3.4.2 内部示意图(方便理解的)
3.4.3 工作机制
三个阶段循环进行:
- 当伊甸园被占满时会触发垃圾回收,同时也会触发STW;幸存的对象会以复制的算法放到幸存区(对应示意图中E->S);当一个幸存区内存不足时则会再次触发垃圾回收,寿命达到阈值的会将其放入老年代中(对应示意图中S->O),其他寿命不足的则会复制到其他幸存区中(对应示意图中S->S)。
- 新生代在垃圾回收时会进行初始标记,标记那些根对象,当老年代的内存占用达到阈值时,则会进行并发标记。
- 并发标记完成后会先进行最终标记,类似于CMS的重新标记,也会触发STW;然后对伊甸园、幸存区和老年代的幸存对象进行复制,在此期间也会触发STW;其中在对老年代进行垃圾回收时,由于回收时间较长,且设置了最大暂停时间,所以G1只会选择最有价值的几个老年区进行垃圾回收,所谓最有价值也就是对这些老年区回收后能释放的空间最多;当然如果对所有老年区进行回收的时间在最大暂停时间内,G1也是会对所有的老年区进行回收。
3.4.5字符串去重
G1会将所有新分配的字符串放入一个队列,然后在进行新生代垃圾回收时会并发检查是否有字符串重复,由于字符串底层是一个char数组,所以如果他们的值一样则让他们引用同一个char数组,但他们还是两个不同的对象。
优点是可以节省内存,缺点就是略微占用CPU的时间,垃圾回收的时间略微增加(因为要检查是否有重复的)。
注意区分intern的去重方法,intern方法的结果是两个对象变成了同一个串池中的对象,而G1的结果是他们还是两个不同的对象,但底层只存储一个char数组。
3.4.6类卸载
在并发标记结束后就能知道哪些类不被使用了,如果不进行类卸载就会一直占用内存,所以G1就会在一个类加载器中所有类都不被使用时卸载这个类加载器中的所有类。由于JDK中都是启动类加载器、扩展类加载器以及应用程序类加载器,会始终存在,所以类卸载主要用于自定义类加载器。
3.4.7回收巨型对象
当一个对象所占空间的大小占一个region(区域)的一半以上时就被称为巨型对象,G1会将这种对象单独放在一个或多个连续的region中;在进行垃圾回收时这些巨型对象会被优先考虑回收,并且在进行复制时不会复制巨型对象;当这些巨型对象没有被堆外部或老年代的对象所引用时就会在新生代垃圾回收中被回收掉。
3.4.8动态调整堆内存占用阈值
JDK9之前可以通过设置堆内存占用阈值来触发垃圾回收,但这个值是固定的,设置的较大就容易引发FullGC,退化为串行或并行回收器,设置的较小就容易产生频繁的标记清理,所以JDK9之后就可以只设置阈值的初始值,G1会在垃圾回收时对数据进行采样来动态调整阈值的大小,避免出现FullGC的情况,并且也能留出一个安全的空闲空间来容纳那些浮动垃圾。
4.MinorGC和FullGC
所有的垃圾回收器触发的新生代垃圾回收都叫做MinorGC;而串行、并行(响应比优先)的老年代垃圾回收器触发的垃圾回收是FullGC,CMS和G1的老年代回收不是FullGC,只有退化为串行或并行的老年代回收器在进行垃圾回收时才叫做FullGC。当回收垃圾的速度赶不上产生垃圾的速度时,此时的老年代回收才是FullGC。
5.垃圾回收调优
5.1垃圾回收器选择
需要高吞吐量的选择ParallelGC,需要低延迟的选择CMS、G1或者ZGC。
5.2代码角度
尽量不触发垃圾回收,检查数据加载的是否过多、数据类型是否太臃肿、是否存在内存泄漏,需要缓存数据尽量使用第三方缓存数据实现,减少内存的占用来降低垃圾回收的频率。正所谓最好的垃圾回收调优就是不触发垃圾回收。
5.3内存调优
5.3.1新生代调优
5.3.1.1调节新生代内存大小
新生代内存过小会导致垃圾回收频繁发生,会提升STW的时间,当新生代内存过大时,就会导致老年代内存过小,容易出现新生代内存充足但老年代内存不足的情况,这时触发垃圾回收就是FullGC了,花费的时间比MinorGC多很多。
但是应该尽量扩大新生代的内存,因为新生代的垃圾回收使用的是复制算法,当内存太小时垃圾回收频繁,使得有些对象被来回复制,花费大量的时间,而新生代中的绝大部分对象都是朝生夕死的,所以可以通过减少垃圾回收的频次,等这些对象不被使用后再进行复制。新生代内存理想情况下是并发数 * 一次请求响应创建的对象数。
5.3.1.2调节幸存区的大小
幸存区的大小首先应当能够承受那些虽然使用时间不长但是进行回收时仍在使用的对象,还有等待晋升到老年代的对象,如果内存过小会导致使用时间不长的对象因为内存不足提前晋升至老年代,而对老年代的回收很长时间才进行一次,造成这些对象不被使用后仍然长时间占据内存的问题;其次就是晋升阈值的设定,尽量让长时间存活的对象尽快晋升,这样可以减少垃圾回收时的复制时间。
5.3.2老年代调优
先观察程序运行是否会导致FullGC,如果不会就没有必要进行老年代调优,如果出现了FullGC优先对新生代进行调优。对老年代调优就是增大老年代的内存,减少FullGC的发生。
5.4案例调优分析
5.4.1GC发生频繁
这是由于新生代内存不足,创建的对象过多引发频繁的垃圾回收,可以适当增大新生代内存来减少GC发生的频率。
5.3.2请求高峰期发生FullGC且时间较长(CMS等并发回收器)
CMS等并发回收器在并发回收器中重新标记所花费的时间最长,重新标记需要扫描整个堆内存,当请求高峰时创建的对象很多,在扫描新生代时会扫描大量的对象,所以应当在重新标记之前先进行一次垃圾回收来减少要扫描的对象,这就需要打开CMSScavengeBeforeRemark开关(也是垃圾回收的一个相关参数),在重新标记前先进行一次新生代垃圾回收。
5.3.3当使用JDK1.8之前的版本时出现老年代内存充足但有FullGC发生的情况
在JDK1.8之前方法区位于永久代中,而永久代位于堆内存中,所以当方法区内存不足时会引发FullGC来清理整个堆内存,而JDK1.8之后方法区位于元空间中,元空间又放在操作系统的内存中,不在JVM的垃圾回收管理内,不会出现这种情况;需要增大永久代的内存空间来避免FullGC的发生。