本文从以下五大部分解读JVM的垃圾回收:
- 如何判断对象可以回收
- 垃圾回收算法
- 分代垃圾回收
- 垃圾回收器
- 垃圾回收调优
1、如何判断对象可以回收
引用计数法
弊端:循环引用时,两个对象的计数都为1,导致两个对象都无法被释放。导致内存泄露。Java不采用这个方法。
可达性分析算法
基本概念:GC Root对象是肯定不会被当做垃圾回收的对象。 JVM中的垃圾回收器通过可达性分析来探索所有存活的对象。Java采用这个方法。
算法具体步骤:扫描一遍堆中的对象,看能否沿着GC Root对象为起点的引用链找到该对象,如果找不到,则表示可以回收,找到了,就不能被回收。
可以作为GC Root的对象:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象。
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈中JNI(即一般说的Native方法)引用的对象
引用存在于活动栈帧中,而引用对象是在堆里的。
五种引用
强引用
只有所有 GC Roots 对象都不通过【强引用】引用该对象,该对象才能被垃圾回收。如果GC Roots一直通过强引用 引用该对象,那么垃圾回收器绝不会回收它,当内存空间不足,Java虚拟机宁愿抛出OOM异常,使程序异常终止。
- 如上图B、C对象都不引用A1对象时,A1对象才会被回收
场景
例如网络读取图片资源时,如果是用强引用 引用这些存放图片资源的bytes数组,在内存不足时会OOM。这些不太重要的资源,在内存紧张时,应该要释放掉,之后再重新读取即可。所以不要使用强引用引用这些bytes数组了,而应该采取其它的引用。
所以,需要用软引用/弱引用。
最后一个byte数组保留下来。
关联引用队列,去掉无用的软引用:
具体讲解软引用、弱引用、虚引用如下:
软引用
当GC Root指向软引用对象时,在内存不足时,会回收软引用所引用的对象
- 如上图如果B对象不再引用A2对象且内存不足时,软引用所引用的A2对象就会被回收
设置一下VM参数:
-Xmx20M //设置堆内存空间大小20MB
-verbose:gc //在控制台输出GC情况
-XX:+PrintGCDetails //在控制台输出详细的GC情况
软引用的使用
public class Demo1 {
public static void main(String[] args) {
final int _4M = 4*1024*1024;
//使用软引用对象 list和SoftReference是强引用,而SoftReference和byte数组则是软引用
List<SoftReference<byte[]>> list = new ArrayList<>();
SoftReference<byte[]> ref= new SoftReference<>(new byte[_4M]);
}
}
如果在垃圾回收时发现内存不足,在回收软引用所指向的对象时,软引用本身不会被清理
如果想要清理软引用,需要使用引用队列
public class HelloWorld {
public static final int _4MB = 4*1024*1024;
public static void main(String[] args) {
List<SoftReference<byte[]>> list = new ArrayList<>();
ReferenceQueue<byte[]> queue = new ReferenceQueue<>();
for(int i = 0;i < 5;i ++){
//这里将软引用对象和引用队列进行关联。注:现在并没有直接放到引用队列中。
//当内存不足时,软引用指向的对象会被GC,软引用对象本身不会被清理,而是放到引用队列中。
SoftReference<byte[]> softReference = new SoftReference<>(new byte[_4MB],queue);
list.add(softReference);
}
//再查看引用队列中有无软引用
Reference<? extends byte[]> poll = queue.poll();
while(poll!=null){
//从存放软引用的集合中移除。
list.remove(poll);
poll = queue.poll();
}
for(SoftReference<byte[]> softReference: list){
System.out.println(softReference.get());
}
}
}
大概思路为 :查看引用队列中有无软引用,如果有,则将该软引用从存放它的集合中移除(这里为一个list集合)如下图:
弱引用
只有弱引用引用该对象时,在垃圾回收时,无论内存是否充足,都会回收弱引用所引用的对象
- 如上图如果B对象不再引用A3对象,则A3对象会被回收
弱引用的使用和软引用类似,只是将 SoftReference 换为了 WeakReference
public class HelloWorld {
public static final int _4MB = 4*1024*1024;
public static void main(String[] args) {
List<WeakReference<byte[]>> list = new ArrayList<>();
ReferenceQueue<byte[]> queue = new ReferenceQueue<>();
for(int i = 0;i < 5;i ++) {
//这里将软引用对象和引用队列进行关联。注:现在并没有直接放到引用队列中。
//当内存不足时,软引用指向的对象会被GC,软引用对象本身不会被清理,而是放到引用队列中。
WeakReference<byte[]> WeakReference = new WeakReference<>(new byte[_4MB], queue);
list.add(WeakReference);
for (WeakReference<byte[]> weakReference : list) {
System.out.print(weakReference.get() +" ");
}
System.out.println();
}
}
}
输出:
[B@1b6d3586
[B@1b6d3586 [B@4554617c
[B@1b6d3586 [B@4554617c [B@74a14482
[GC (Allocation Failure) [PSYoungGen: 1784K->488K(6144K)] 14073K->13000K(19968K), 0.0013709 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[B@1b6d3586 [B@4554617c [B@74a14482 [B@1540e19d
[GC (Allocation Failure) [PSYoungGen: 4696K->496K(6144K)] 17208K->13104K(19968K), 0.0011455 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[B@1b6d3586 [B@4554617c [B@74a14482 null [B@677327b6
Heap
PSYoungGen total 6144K, used 4761K [0x00000000ff980000, 0x0000000100000000, 0x0000000100000000)
eden space 5632K, 75% used [0x00000000ff980000,0x00000000ffdaa540,0x00000000fff00000)
from space 512K, 96% used [0x00000000fff80000,0x00000000ffffc020,0x0000000100000000)
to space 512K, 0% used [0x00000000fff00000,0x00000000fff00000,0x00000000fff80000)
ParOldGen total 13824K, used 12608K [0x00000000fec00000, 0x00000000ff980000, 0x00000000ff980000)
object space 13824K, 91% used [0x00000000fec00000,0x00000000ff850030,0x00000000ff980000)
Metaspace used 3303K, capacity 4500K, committed 4864K, reserved 1056768K
class space used 359K, capacity 388K, committed 512K, reserved 1048576K
Process finished with exit code 0
特点:只要发生垃圾回收,就会被回收,但不是把堆中所有弱引用全部回收,回收到足够放下接下来的数据即可。
fullGC才会将所有的弱引用进行垃圾回收
虚引用
虚引用和终结器引用必须配合引用队列。如下:
当虚引用对象所引用的对象被回收以后,虚引用对象就会被放入引用队列中,调用虚引用的方法。
- 虚引用的一个体现是释放直接内存所分配的内存,当引用的对象ByteBuffer被垃圾回收以后,虚引用对象Cleaner就会被放入引用队列中,然后调用Cleaner的clean方法来释放直接内存
如下图,B对象不再引用ByteBuffer对象,ByteBuffer就会被回收。但是直接内存中的内存还未被回收。
由ReferenceHandler后台线程检查引用队列,是否有虚引用Cleaner,然后来调用Cleaner的clean方法调用freeMemory来释放内存。
终结器引用
所有的类都继承自Object类,Object类有一个finalize方法。当某个对象不再被其他的对象所引用时,会先将终结器引用对象放入引用队列中,然后根据终结器引用对象找到它所引用的对象,然后调用该对象的finalize方法。调用以后,该对象就可以被垃圾回收了
- 如图,B对象不再引用A4对象。这是终结器对象就会被放入引用队列中,引用队列会根据它,找到它所引用的对象。然后调用被引用对象的finalize方法。调用以后,该对象就可以被垃圾回收了
这里重写了finallize方法,A4对象执行垃圾回收时并不是立马被回收的,因为重写了finallize方法,所以虚拟机会自动创建一个终结器引用,执行A4对象的回收时,会将终结器引用放入引用队列中,然后由一个较低优先级的线程finalizeHandler,去查看引用队列,根据终结器引用调用重写了的finallize方法,这个时候A4对象才真正被回收。
不是直接回收的,需要先放到引用队列,并且执行回收的线程优先级很低,所以fianlize方法的对象迟迟得不到回收,造成内存泄露。
不推荐finalize方法释放资源。
引用队列
- 软引用和弱引用可以配合引用队列
- 在弱引用和虚引用所引用的对象被回收以后,会将这些引用放入引用队列中,方便一起回收这些软/弱引用对象
- 虚引用和终结器引用必须配合引用队列
- 虚引用和终结器引用在使用时会关联一个引用队列
2、垃圾回收算法
标记-清除
定义:标记清除算法顾名思义,是指在虚拟机执行垃圾回收的过程中,先采用标记算法确定可回收对象,然后垃圾收集器根据标识清除相应的内容,给堆内存腾出相应的空间
- 这里的腾出内存空间并不是将内存空间的字节清0,而是记录下这段内存的起始结束地址,下次分配内存的时候,会直接覆盖这段内存
缺点:容易产生大量的内存碎片,可能无法满足大对象的内存分配,一旦导致无法分配对象,那就会导致jvm启动gc,一旦启动gc,我们的应用程序就会暂停,这就导致应用的响应速度变慢
标记-整理
标记-整理 会将不被GC Root引用的对象回收,清楚其占用的内存空间。然后整理剩余的对象,可以有效避免因内存碎片而导致的问题,但是因为整体需要消耗一定的时间,所以效率较低
复制
将内存分为等大小的两个区域,FROM和TO(TO中为空)。先将被GC Root引用的对象从FROM放入TO中,再回收不被GC Root引用的对象。然后交换FROM和TO。这样也可以避免内存碎片的问题,但是会占用双倍的内存空间。
GC整个过程
分为新生代和老年代,新生代默认占总空间的 1/3,老年代默认占 2/3。 新生代使用复制算法,有 3 个分区:Eden、To Survivor、From Survivor,它们的默认占比是 8:1:1。当新生代中的 Eden 区内存不足时,就会触发 Minor GC(YoungGC)。
- 新生代 GC(Minor GC):指发生新生代的的垃圾收集动作,Minor GC 非常频繁,回收速度一般也比较快。
- 老年代 GC(Major GC/Full GC):指发生在老年代的 GC,出现了 Major GC 经常会伴随至少一次的 Minor GC(并非绝对),Major GC 的速度一般会比 Minor GC 的慢 10 倍以上。
新生代回收过程如下:
- 在 Eden 区执行了第一次 GC 之后,存活的对象会被移动到其中一个 Survivor 分区;
- Eden 区再次 GC,这时会采用复制算法,将 Eden 和 from 区一起清理,存活的对象会被复制到 to 区;
- 移动一次,对象年龄加 1,对象年龄大于一定阀值会直接移动到老年代。GC年龄的阀值可以通过参数 -XX:MaxTenuringThreshold 设置,默认为 15。
- Survivor 区相同年龄所有对象大小的总和 > (Survivor 区内存大小 * 这个目标使用率)时,大于或等于该年龄的对象直接进入老年代。其中这个使用率通过 -XX:TargetSurvivorRatio 指定,默认为 50%。
- Survivor 区内存不足会发生担保分配,超过指定大小的对象可以直接进入老年代。
- 大对象将直接进入老年代。为了避免为大对象分配内存时由于分配担保机制(就是当在新生代无法分配内存的时候,把新生代的对象转移到老生代,然后把新对象放入腾空的新生代。)带来的复制而降低效率。
老年代回收(FullGC)触发条件:
- 无法放入Survivor区直接进入老年代。YoungGC时,如果eden区+ from survivor 区存活的对象无法放到 to survivor 区了,这个时候会直接将部分对象放入到老年代。
- 每次晋升到老年代的对象平均大小>老年代剩余空间。
- 调用System.gc时,系统建议执行Full GC,但是不必然执行。
- 老年代空间不足。
空间分配担保原则
3、分代回收
回收流程
新创建的对象都被放在了新生代的伊甸园中
当伊甸园中的内存不足时,就会进行一次垃圾回收,这时的回收叫做 Minor GC
Minor GC 会将伊甸园和幸存区FROM存活的对象先复制到 幸存区 TO中, 并让其寿命加1,再交换两个幸存区
再次创建对象,若新生代的伊甸园又满了,则会再次触发 Minor GC(会触发 stop the world, 暂停其他用户线程,只让垃圾回收线程工作),这时不仅会回收伊甸园中的垃圾,还会回收幸存区中的垃圾,再将活跃对象复制到幸存区TO中。回收以后会交换两个幸存区,并让幸存区中的对象寿命加1
如果幸存区中的对象的寿命超过某个阈值(最大为15,4bit),就会被放入老年代中
如果新生代老年代中的内存都满了,就会先触发Minor GC,再触发Full GC,扫描新生代和老年代中所有不再使用的对象并回收
GC 分析
大对象处理策略
当遇到一个较大的对象时,就算新生代的伊甸园为空,也无法容纳该对象时,会将该对象直接晋升为老年代
线程内存溢出
某个线程的内存溢出了而抛异常(out of memory),不会让其他的线程结束运行
这是因为当一个线程抛出OOM异常后,它所占据的内存资源会全部被释放掉,从而不会影响其他线程的运行,进程依然正常
4、垃圾回收器
相关概念
并行收集:指多条垃圾收集线程并行工作,但此时用户线程仍处于等待状态。
并发收集:指用户线程与垃圾收集线程同时工作(不一定是并行的可能会交替执行)。用户程序在继续运行,而垃圾收集程序运行在另一个CPU上
吞吐量:即CPU用于运行用户代码的时间与CPU总消耗时间的比值(吞吐量 = 运行用户代码时间 / ( 运行用户代码时间 + 垃圾收集时间 )),也就是。例如:虚拟机共运行100分钟,垃圾收集器花掉1分钟,那么吞吐量就是99%
串行
- 单线程
- 内存较小,个人电脑(CPU核数较少)
安全点:让其他线程都在这个点停下来,以免垃圾回收时移动对象地址,使得其他线程找不到被移动的对象
因为是串行的,所以只有一个垃圾回收线程。且在该线程执行回收工作时,其他线程进入阻塞状态
Serial 收集器
Serial收集器是最基本的、发展历史最悠久的收集器
特点:单线程、简单高效(与其他收集器的单线程相比),采用复制算法。对于限定单个CPU的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程手机效率。收集器进行垃圾回收时,必须暂停其他所有的工作线程,直到它结束(Stop The World)
ParNew 收集器
ParNew收集器其实就是Serial收集器的多线程版本
特点:多线程、ParNew收集器默认开启的收集线程数与CPU的数量相同,在CPU非常多的环境中,可以使用-XX:ParallelGCThreads参数来限制垃圾收集的线程数。和Serial收集器一样存在Stop The World问题
Serial Old 收集器
Serial Old是Serial收集器的老年代版本
特点:同样是单线程收集器,采用标记-整理算法
吞吐量优先
- 多线程
- 堆内存较大,多核CPU
- 单位时间内,STW(stop the world,停掉其他所有工作线程)时间最短
- JDK1.8默认使用的垃圾回收器
Parallel Scavenge 收集器
与吞吐量关系密切,故也称为吞吐量优先收集器
特点:属于新生代收集器也是采用复制算法的收集器(用到了新生代的幸存区),又是并行的多线程收集器(与ParNew收集器类似)
该收集器的目标是达到一个可控制的吞吐量。还有一个值得关注的点是:GC自适应调节策略(与ParNew收集器最重要的一个区别)
GC自适应调节策略:Parallel Scavenge收集器可设置-XX:+UseAdptiveSizePolicy参数。当开关打开时不需要手动指定新生代的大小(-Xmn)、Eden与Survivor区的比例(-XX:SurvivorRation)、晋升老年代的对象年龄(-XX:PretenureSizeThreshold)等,虚拟机会根据系统的运行状况收集性能监控信息,动态设置这些参数以提供最优的停顿时间和最高的吞吐量,这种调节方式称为GC的自适应调节策略。
Parallel Scavenge收集器使用两个参数控制吞吐量:
- XX:MaxGCPauseMillis 控制最大的垃圾收集停顿时间
- XX:GCRatio 直接设置吞吐量的大小
Parallel Old 收集器
是Parallel Scavenge收集器的老年代版本
特点:多线程,采用标记-整理算法(老年代没有幸存区)
响应时间优先
- 多线程
- 堆内存较大,多核CPU
- 尽可能让单次STW时间变短(尽量不影响其他线程运行)
CMS 收集器
Concurrent Mark Sweep,一种以获取最短回收停顿时间为目标的老年代收集器
特点:基于标记-清除算法实现。并发收集、低停顿,但是会产生内存碎片
应用场景:适用于注重服务的响应速度,希望系统停顿时间最短,给用户带来更好的体验等场景下。如web程序、b/s服务
CMS收集器的运行过程分为下列4步:
初始标记:标记GC Roots能直接到的对象。速度很快但是仍存在Stop The World问题
并发标记:进行GC Roots Tracing 的过程,找出存活对象且用户线程可并发执行
重新标记:为了修正并发标记期间因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录。仍然存在Stop The World问题
并发清除:对标记的对象进行清除回收
CMS收集器的内存回收过程是与用户线程一起并发执行的
初始标记、重新标记这两个步骤仍然需要“stop the word”。初始标记仅仅只是标记一下GC roots能直接关联到的对象,速度很快,并发标记阶段就是进行GC Roots Tracing的过程,而重新标记阶段是为了修正并发标记期间因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长,但远比并发标记的时间短。由于整个过程耗时最长的并发标记和并发清除过程收集器线程都可以与用户线程一起工作,所以,从整体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。
G1
定义:
Garbage First
JDK 9以后默认使用,而且替代了CMS 收集器
适用场景
- 同时注重吞吐量和低延迟(响应时间)
- 超大堆内存(内存大的),会将堆内存划分为多个大小相等的区域
- 整体上是标记-整理算法,两个区域之间是复制算法
相关参数:JDK8 并不是默认开启的,所需要参数开启
G1垃圾回收阶段
新生代伊甸园垃圾回收—–>内存不足,新生代回收+并发标记—–>回收新生代伊甸园、幸存区、老年代内存——>新生代伊甸园垃圾回收(重新开始)
Young Collection
分区算法region
分代是按对象的生命周期划分,分区则是将堆空间划分连续几个不同小区间,每一个小区间独立回收,可以控制一次回收多少个小区间,方便控制 GC 产生的停顿时间
E:伊甸园 S:幸存区 O:老年代
- 会STW
Young Collection + CM
CM:并发标记
- 在 Young GC 时会对 GC Root 进行初始标记
- 在老年代占用堆内存的比例达到阈值时,对进行并发标记(不会STW),阈值可以根据用户来进行设定
Mixed Collection
会对E S O 进行全面的回收
- 最终标记
- 拷贝存活
-XX:MaxGCPauseMills:xxx 用于指定最长的停顿时间
问:为什么有的老年代被拷贝了,有的没拷贝?
因为指定了最大停顿时间,如果对所有老年代都进行回收,耗时可能过高。为了保证时间不超过设定的停顿时间,会回收最有价值的老年代(回收后,能够得到更多内存)
Full GC
G1在老年代内存不足时(老年代所占内存超过阈值)
- 如果垃圾产生速度慢于垃圾回收速度,不会触发Full GC,还是并发地进行清理
- 如果垃圾产生速度快于垃圾回收速度,便会触发Full GC
Young Collection 跨代引用
- 新生代回收的跨代引用(老年代引用新生代)问题
- 卡表与Remembered Set
- Remembered Set 存在于E中,用于保存新生代对象对应的脏卡
- 脏卡:O被划分为多个区域(一个区域512K),如果该区域引用了新生代对象,则该区域被称为脏卡
- Remembered Set 存在于E中,用于保存新生代对象对应的脏卡
- 在引用变更时通过post-write barried + dirty card queue
- concurrent refinement threads 更新 Remembered Set
Remark
重新标记阶段
在垃圾回收时,收集器处理对象的过程中
黑色:已被处理,需要保留的 灰色:正在处理中的 白色:还未处理的
但是在并发标记过程中,有可能A被处理了以后未引用C,但该处理过程还未结束,在处理过程结束之前A引用了C,这时就会用到remark
过程如下
- 之前C未被引用,这时A引用了C,就会给C加一个写屏障,写屏障的指令会被执行,将C放入一个队列当中,并将C变为 处理中 状态
- 在并发标记阶段结束以后,重新标记阶段会STW,然后将放在该队列中的对象重新处理,发现有强引用引用它,就会处理它
JDK 8u20 字符串去重
过程
- 将所有新分配的字符串(底层是char[])放入一个队列
- 当新生代回收时,G1并发检查是否有重复的字符串
- 如果字符串的值一样,就让他们引用同一个字符串对象
- 注意,其与String.intern的区别
- intern关注的是字符串对象
- 字符串去重关注的是char[]
- 在JVM内部,使用了不同的字符串标
优点与缺点
- 节省了大量内存
- 新生代回收时间略微增加,导致略微多占用CPU
JDK 8u40 并发标记类卸载
在并发标记阶段结束以后,就能知道哪些类不再被使用。如果一个类加载器的所有类都不在使用,则卸载它所加载的所有类
JDK 8u60 回收巨型对象
- 一个对象大于region的一半时,就称为巨型对象
- G1不会对巨型对象进行拷贝
- 回收时被优先考虑
- G1会跟踪老年代所有incoming引用,如果老年代incoming引用为0的巨型对象就可以在新生代垃圾回收时处理掉
5、GC 调优
查看虚拟机参数命令
"F:\JAVA\JDK8.0\bin\java" -XX:+PrintFlagsFinal -version | findstr "GC"
可以根据参数去查询具体的信息
调优领域
- 内存
- 锁竞争
- CPU占用
- IO
- GC
确定目标
低延迟/高吞吐量? 选择合适的GC
- CMS G1 ZGC
- ParallelGC
- Zing
最快的GC是不发生GC
首先排除减少因为自身编写的代码而引发的内存问题
- 查看Full GC前后的内存占用,考虑以下几个问题
- 数据是不是太多?
- 数据表示是否太臃肿
- 对象图
- 对象大小
- 是否存在内存泄漏
新生代调优
- 新生代的特点
- 所有的new操作分配内存都是非常廉价的
- TLAB
- 死亡对象回收零代价
- 大部分对象用过即死(朝生夕死)
- MInor GC 所用时间远小于Full GC
- 所有的new操作分配内存都是非常廉价的
- 新生代内存越大越好么?
- 不是
- 新生代内存太小:频繁触发Minor GC,会STW,会使得吞吐量下降
- 新生代内存太大:老年代内存占比有所降低,会更频繁地触发Full GC。而且触发Minor GC时,清理新生代所花费的时间会更长
- 新生代内存设置为内容纳[并发量*(请求-响应)]的数据为宜
- 不是
幸存区调优
- 幸存区需要能够保存 当前活跃对象+需要晋升的对象
- 晋升阈值配置得当,让长时间存活的对象尽快晋升
老年代调优
案例
- 增加新生代空间,垃圾回收不频繁了。晋升阈值降低了,使得年龄较短的留在幸存区,而不是转到老年区。
- 要查看gc日志,发现remark阶段耗时1s,2s。扫描整个堆内存,不光是扫描老年代对象,也要同时扫描新生代对象,如果业务高峰,新生代对象较多。那么remark扫描标记时间就非常多。根据对象找它的引用,这是一种遍历算法,耗时太多。能不能在remark之前先对新生代对象做一次垃圾回收,减少新生代对象数量,从而减少remark的时间。这个参数:打开开关 -XX:+CMSScavangeBeforeRemark,先对新生代对象做一次垃圾清理。
- jdk1.8元空间是使用操作系统的内存空间(空间足),JDK1.7是永久代使用JVM内存,所以增加永久代内存空间。