文章目录
如何判断对象可以回收
引用计数法
当一个对象被引用时,就当引用对象的值加一,当值为 0 时,就表示该对象不被引用,可以被垃圾收集器回收。
弊端:循环引用时,两个对象的计数都为1,导致两个对象都无法被释放
可达性分析算法
-
JVM中的垃圾回收器通过可达性分析来探索所有存活的对象
-
扫描堆中的对象,看能否沿着GC Root对象为起点的引用链找到该对象,如果找不到,则表示可以回收
-
可以作为GC Root的对象:
-
虚拟机栈(栈帧中的本地变量表)中引用的对象。
-
方法区中类静态属性引用的对象
-
方法区中常量引用的对象
-
本地方法栈中JNI(即一般说的Native方法)引用的对象
-
可以作为GC Root的另一种分类方向:
- 一些系统对象,由启动类加载器加载,非常核心的一些对象:
- 锁在引用的对象
- 活动线程中所使用的一些对象
- 操作系统的方法引用的一些java对象
从程序运行的角度来说,一些在程序运行过程中始终保持存活,不死亡的对象可以作为GC Root,例如静态变量和常量所引用的对象等。
五种引用
图中实线表示强引用,虚线表示另外四种引用。
强引用
- 只有GC Root都不引用该对象时,才会回收强引用对象
- 我们平时用new出来的对象,用一个引用来指向这个对象,这个引用是强引用
软引用
- 通过
GC Root
对象强引用了我们的软引用对象,然后用软引用对象指向一个对象,这个对象就是被软引用指向的对象 - 软引用自身也是会占内存的
- 仅有软引用引用该对象时,在垃圾回收后,内存仍不足时会再次触发垃圾回收,回收软引用对象,可以配合引用队列来释放软引用自身
软引用的应用
我们先看看下面这个例子:
我们将堆内存分配为20mb,然后我们在一个List中循环添加大小为4mb的字节数组,毫无疑问当循环五次之后就会发生堆内存空间不足。在实际生产中,有时候的业务和这类似,例如把一个个的图片放入到List中去。由于我们使用的都是强引用,当图片的数量过多的时候,很容易就会出现内存空间不足异常。
我们的解决思路就是在内存资源紧张的时候,先把这些不重要的图片占用的内存给释放掉。以后再用到这张图片的时候再去读取,这个时候我们就可以使用软引用:
// 演示 软引用
//list --> SoftReference --> byte[]
public static void method2() throws IOException {
ArrayList<SoftReference<byte[]>> list = new ArrayList<>();
for(int i = 0; i < 5; i++) {
SoftReference<byte[]> ref = new SoftReference<>(new byte[_4MB]);
System.out.println(ref.get());
list.add(ref);
System.out.println(list.size());
}
System.out.println("循环结束:" + list.size());
for(SoftReference<byte[]> ref : list) {
System.out.println(ref.get());
}
}
这里在list
集合中存放了软引用对象SoftReference
,当内存不足时,会触发 full gc
,将软引用的对象回收:
上面的代码中,当软引用引用的对象被回收了,但是软引用还存在,我们将这些软引用留下来也没有什么意思,所以,一般软引用需要搭配一个引用队列一起使用,方便后期对软引用自身的清理:
// 演示 软引用 搭配引用队列
public static void method3() throws IOException {
ArrayList<SoftReference<byte[]>> list = new ArrayList<>();
// 引用队列
ReferenceQueue<byte[]> queue = new ReferenceQueue<>();
for(int i = 0; i < 5; i++) {
// 关联了引用队列,当软引用所关联的 byte[] 被回收时,软引用自己会加入到 queue 中去
SoftReference<byte[]> ref = new SoftReference<>(new byte[_4MB], queue);
System.out.println(ref.get());
list.add(ref);
System.out.println(list.size());
}
// 从队列中获取无用的 软引用对象,并移除
Reference<? extends byte[]> poll = queue.poll();
while(poll != null) {
list.remove(poll);
poll = queue.poll();
}
System.out.println("=====================");
for(SoftReference<byte[]> ref : list) {
System.out.println(ref.get());
}
}
大概思路为:查看引用队列中有无软引用,如果有,则将该软引用从存放它的集合中移除
弱引用
- 仅有弱引用引用该对象时,在垃圾回收时,无论内存是否充足,都会回收弱引用对象,可以配合引用队列来释放弱引用自身
弱引用的应用
弱引用的使用和软引用类似,只是将 SoftReference 换为了 WeakReference
public class Code_09_WeakReferenceTest {
public static void main(String[] args) {
// method1();
method2();
}
public static int _4MB = 4 * 1024 *1024;
// 演示 弱引用
public static void method1() {
List<WeakReference<byte[]>> list = new ArrayList<>();
for(int i = 0; i < 10; i++) {
WeakReference<byte[]> weakReference = new WeakReference<>(new byte[_4MB]);
list.add(weakReference);
for(WeakReference<byte[]> wake : list) {
System.out.print(wake.get() + ",");
}
System.out.println();
}
}
// 演示 弱引用搭配 引用队列
public static void method2() {
List<WeakReference<byte[]>> list = new ArrayList<>();
ReferenceQueue<byte[]> queue = new ReferenceQueue<>();
for(int i = 0; i < 9; i++) {
WeakReference<byte[]> weakReference = new WeakReference<>(new byte[_4MB], queue);
list.add(weakReference);
for(WeakReference<byte[]> wake : list) {
System.out.print(wake.get() + ",");
}
System.out.println();
}
System.out.println("===========================================");
Reference<? extends byte[]> poll = queue.poll();
while (poll != null) {
list.remove(poll);
poll = queue.poll();
}
for(WeakReference<byte[]> wake : list) {
System.out.print(wake.get() + ",");
}
}
}
虚引用
虚引用有个最重要的应用就是我们的直接内存分配的ByteBuffer
-
必须配合引用队列使用,在上的
ByteBuffer
对象中的cleaner
对象就是我们的虚引用,存储的是我们的直接内存的地址,然后我们的ReferenceHandler线程调用cleaner
对象的clean方法,然后调用了unsafe.freeMemory
来释放我们的直接内存 -
因为直接内存不受JVM的内存自动管理的控制,所以通过这些方法来间接的实现对直接内存的回收
终结器引用
finallize()
方法是我们的Object中的方法,所以所有对象都有这个方法- 重写了了finalize方法,A4对象第一次垃圾回收的时候并不是立马被回收,因为重写了finallize方法,所以虚拟机会自动创建一个终结器引用,执行A4对象的第一次回收的时候,会将终结器引用放入引用队列(不会回收A4对象)
- 由我们的
Finalizer
线程通过终结器引用找到被引用的对象,并调用他的finallize方法,第二次GC时才会回收这个A4对象 - 不推荐使用
- 因为处理引用队列的线程优先级很低,被处理的机会很少,可能造成这个对象的
finallize()
迟迟不被调用,导致这块内存迟迟不被回收
- 因为处理引用队列的线程优先级很低,被处理的机会很少,可能造成这个对象的
垃圾回收算法
标记-清除算法
定义
:标记清除算法顾名思义,是指在虚拟机执行垃圾回收的过程中,先采用标记算法确定可回收对象,然后垃圾收集器根据标识清除相应的内容,给堆内存腾出相应的空间
这里的腾出内存空间并不是将内存空间的字节清0,而是记录下这段内存的起始结束地址,下次分配内存的时候,会直接覆盖这段内存
优点
:速度快
缺点
:容易产生大量的内存碎片,可能无法满足大对象的内存分配,一旦导致无法分配对象,那就会导致jvm启动gc,一旦启动gc,我们的应用程序就会暂停,这就导致应用的响应速度变慢
内存碎片表示一些不连续的内存,而这些地方有时会满足不了一些
顺序存储结构
的存储大小要求
标记-整理算法
标记-整理 会将不被GC Root引用的对象回收,清楚其占用的内存空间。然后整理剩余的对象。
优点
:可以有效避免因内存碎片而导致的问题
缺点
:整体需要消耗一定的时间,所以效率较低
复制算法
-
将内存分为等大小的两个区域,
FROM
和TO
(TO中为空)。
-
先将被
GC Root
引用的对象从FROM
放入TO
中
-
再回收不被
GC Root
引用的对象。
-
最后交换
FROM
和TO
优点
:可以有效避免因内存碎片而导致的问题
缺点
:会产生双倍的内存空间
分代垃圾回收
如上的三种垃圾回收算法,JVM并不会单独采用其中一种算法,而是结合三种算法协同工作。具体的实现就是这种被称为分代
的垃圾回收机制。
它将我们的堆内存划分成了两块:
- 新生代
- 伊甸园
- 幸存区From
- 幸存区To
- 老年代
我们也可以把伊甸园区称为
eden区
这种区域划分的目的:Java中有些对象可能需要长时间使用,那些长时间使用的对象我们就将他们放在老年代中,而那些用完了就可以丢弃的对象我们把它放在新生代中。这样我们就可以根据不同对象的生命周期特点,采取不同的垃圾回收策略。老年代的垃圾回收发生的较少,而新生代的垃圾回收就会发生的比较频繁。
回收流程
新创建的对象都被放在了新生代的伊甸园
中:
当伊甸园中的内存不足时,就会进行一次垃圾回收,这时的回收叫做 Minor GC
Minor GC
是针对新生代的回收
Minor GC 会将伊甸园和幸存区FROM存活的对象先复制(也就是我们前面提到的复制算法)到 幸存区 TO
中, 并让其寿命加1,再交换两个幸存区
再次创建对象,若新生代的伊甸园又满了,则会再次触发 Minor GC
,这时不仅会回收伊甸园中的垃圾,还会回收幸存区中的垃圾,再将活跃对象复制到幸存区TO中。回收以后会交换两个幸存区,并让幸存区中的对象寿命加1
Minor GC
会触发 stop the world, 暂停其他用户线程,只让垃圾回收线程工作.如果不暂停其他用户线程的话,因为垃圾清理会涉及到对象的复制,而这样会导致对象的位置发生变化,其他用户此时使用对象的话,有可能会找不到从而引发混乱。
如果幸存区中的对象的寿命超过某个阈值(最大为15,4bit),就会被放入老年代中
当老年代空间不足时,会先触发 Minor GC
,如果空间仍然不足,那么就触发 Full GC
,停止的时间更长!
Full GC
通过老年代内存空间不足来触发,从而对整个堆进行垃圾收集
Full GC
的实现可能是标记-清除也有可能是标记-处理,这个不能确定
相关VM参数
GC分析
public class Code_10_GCTest {
// -Xms20m -Xmx20m -Xmn10m -XX:+UseSerialGC -XX:+PrintGCDetails -verbose:gc
public static void main(String[] args) {
}
}
-Xms20m -Xmx20m
:堆的初始大小和最大大小都设为20m-Xmn10m
:新生代的大小设为10m-XX:+PrintGCDetails -verbose:gc
:呈现GC详情-XX:+UseSerialGC
:指定垃圾回收器的类型,这种垃圾回收器不会动态改变幸存区的比例,方便后续的实验
运行结果如下:
我们可以发现默认eden区、from区、to区的大小比例是8:1:1
我们从运行结果可以看到我们给新生代分配的空间是10m,但是新生代总的可用空间只有9216k,也就是说没有算to区那一部分大小,这是因为我们从分代垃圾回收过程中可以发现to区基本上只有在中转的时候里面会有东西,其他的时候里面都是空的,那部分空间不可用,所以新生代的可以提供的最大可用空间只有eden区和from区,也就是9216k。
public class Code_10_GCTest {
private static final int _512KB = 512 * 1024;
private static final int _1MB = 1024 * 1024;
private static final int _6MB = 6 * 1024 * 1024;
private static final int _7MB = 7 * 1024 * 1024;
private static final int _8MB = 8 * 1024 * 1024;
// -Xms20m -Xmx20m -Xmn10m -XX:+UseSerialGC -XX:+PrintGCDetails -verbose:gc
public static void main(String[] args) {
List<byte[]> list = new ArrayList<>();
list.add(new byte[_7MB]);
}
}
我们前面eden区已经占用28%,这个时候往列表里面添加7m的字节数组肯定会触发一次垃圾回收,运行结果如下:
第一行显示的就是垃圾回收相关信息,其中GC代表的就是Minor GC,而Full GC则会显示原式。
接下来我们再往列表里面添加一些东西触发第二次垃圾回收:
运行结果如下:
我们可以注意到因为新生代的内存实在紧迫,一些没有突破阈值的对象提前晋升到了老年代。这是为什么?我们这里就要提一下 —- 大对象处理策略
。
大对象处理策略
当遇到一个较大的对象时(就算新生代的伊甸园为空,也无法容纳该对象),会将该对象直接晋升为老年代
加下来我们把老年代也给占满:
结果如下:
先进行一次Minor GC再进行一次Full GC发现还是不行,于是报错堆内存溢出。
线程内存溢出
某个线程的内存溢出了而抛异常(out of memory),不会让其他的线程结束运行
这是因为当一个线程抛出OOM异常后,它所占据的内存资源会全部被释放掉,从而不会影响其他线程的运行,进程依然正常。
例如下面这段代码:
结果如下:
垃圾回收器
相关概念
-
并行收集
:指多条垃圾收集线程并行工作,但此时用户线程仍处于等待状态。 -
并发收集
:指用户线程与垃圾收集线程同时工作(不一定是并行的可能会交替执行)。用户程序在继续运行,而垃圾收集程序运行在另一个CPU上 -
吞吐量
:即CPU用于运行用户代码的时间与CPU总消耗时间的比值(吞吐量 = 运行用户代码时间 / ( 运行用户代码时间 + 垃圾收集时间 )),也就是。例如:虚拟机共运行100分钟,垃圾收集器花掉1分钟,那么吞吐量就是99%
串行
特点:
- 单线程完成垃圾回收
- 适用于内存较小的情况,适合个人电脑(CPU核数较少)
开启串行垃圾回收器的JVM参数:
-XX:+UseSerialGC=serial + serialOld
我们可以发现串行的垃圾处理器分为两个部分:
- serial:工作在新生代,采用的是复制算法
- serialOld:工作在老年代,采用的是标记-整理算法
安全点
:让其他线程都在这个点停下来,以免垃圾回收时移动对象地址,使得其他线程找不到被移动的对象
因为是串行的,所以只有一个垃圾回收线程。且在该线程执行回收工作时,其他线程进入阻塞状态
实现:
-
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默认使用的垃圾回收器
开启吞吐量优先垃圾回收器的JVM参数:
-XX:+UseParallelGC ~ -XX:+UsePrallerOldGC
-XX:+UseAdaptiveSizePolicy //开启动态指定堆内存大小,堆占比、阈值等的模式
-XX:GCTimeRatio=ratio // 1/(1+radio)
-XX:MaxGCPauseMillis=ms // 200ms
-XX:ParallelGCThreads=n //指定垃圾回收的线程数
-XX:+UseParallelGC ~ -XX:+UsePrallerOldGC
在JDK8中默认开启,值得注意的是开启一个另外一个会自动开启
ParallelGC:工作在新生代,采用的是复制算法
PrallerOldGC:工作在老年代,采用的是标记-整理算法
-XX:GCTimeRatio=ratio
:设定吞吐量,默认值99,1/(1+radio)
-XX:MaxGCPauseMillis=ms
:设定最大暂停毫秒数,默认值200ms
我们通过这两个选项指定垃圾收集目标,-XX:+UseAdaptiveSizePolicy
的动态模式就是根据目标来调整的
这两个目标其实是冲突的,因为我们调整了GCTimeRatio
,一般情况下会把堆调大从而提高吞吐量(因为减少了垃圾回收的次数),但是堆变大了,那么我们每一次垃圾回收的时间又会变长。所以说他们两个是一种相斥的关系,我们需要有一个折中的设定
实现:
-
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时间变短(尽量不影响其他线程运行)
注意:
吞吐量优先的垃圾处理器是让单位时间内STW时间变短
响应时间优先的垃圾处理器是让单次STW时间变短
我们举个例子:
在一段时间内,吞吐量优先的垃圾处理器处理垃圾时间为0.2 + 0.2 = 0.4
响应时间优先的垃圾处理器处理垃圾时间为0.1 + 0.1 + 0.1+ 0.1+ 0.1 = 0.5
开启响应时间优先的垃圾回收器的JVM参数:
-XX:+UseConcMarkSweepGC ~ -XX:+UseParNewGC ~ SerialOld
-XX:ParallelGCThreads=n ~ -XX:ConcGCThreads=threads
-XX:CMSInitiatingOccupancyFraction=percent
-XX:+CMSScavengeBeforeRemark
-XX:+UseConcMarkSweepGC ~ -XX:+UseParNewGC ~ SerialOld
:
CMS是一个老年代的垃圾回收器,与之配合的ParNew垃圾回收器是基于复制算法的新生代垃圾回收器。有时候CMS会发生并发失败的问题,这时候就会采取一种补救措施:让老年代的多线程垃圾回收器从CMS退化到SerialOld(基于标记-整理算法的老年代的单线程垃圾回收器)
-XX:ParallelGCThreads=n ~ -XX:ConcGCThreads=threads
ParallelGCThreads指定并行的垃圾回收线程数,一般跟我们的CPU核数一样
ConcGCThreads设置并发标记的线程数。一般设为并行垃圾回收线程数(ParallelGCThreads)的1/4左右。
-XX:CMSInitiatingOccupancyFraction=percent
用来控制CMS垃圾回收的时机。其含义为执行CMS垃圾回收的内存占比
比如说percent为80,则代表老年代的内存占用达到百分之八十的时候就执行一次垃圾回收,这样的话是为了预留一些空间给浮动垃圾。
也就是说设置的越小,触发垃圾回收的时机就越早
-XX:+CMSScavengeBeforeRemark
(+表示启用、-表示禁用)
在重新标记的阶段有一个特殊的场景:新生代的对象可能会引用老年代的对象,这个时候重新标记要扫描整个堆,通过新生代的引用扫描老年代里面的对象做可达性的分析,但这样的话对性能的影响有一些大,因为新生代创建的对象比较多,其中很多是要作为垃圾的,我们从新生代找到老年代,就算是找到了以后新生代的垃圾也要被回收掉,相当于我们在回收之前多做了一些无谓的查找工作。 为了避免这种现象我们就可以使用这个参数。
它会在我们做重新标记之前做一次新生代的垃圾回收,这样的话新生代的存活对象少了,将来扫描的对象也就少了,从而减轻重新标记时的压力。
实现:
CMS 收集器
-
Concurrent Mark Sweep,一种以获取最短回收停顿时间为目标的老年代收集器
-
特点:基于标记-清除算法实现。并发收集、低停顿,但是会产生内存碎片
-
应用场景:适用于注重服务的响应速度,希望系统停顿时间最短,给用户带来更好的体验等场景下。如web程序、b/s服务
-
CMS收集器的运行过程分为下列4步:
-
初始标记
:标记GC Roots
能直接连到的对象。速度很快但是仍存在Stop The World
问题 -
并发标记
:进行GC Roots Tracing
(也就是深入的递归寻找) 的过程,找出存活对象且用户线程可并发执行 -
重新标记
:为了修正并发标记期间因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录。仍然存在Stop The World
问题 -
并发清除
:对标记的对象进行清除回收,清除的过程中,可能又会有新的垃圾产生(因为其他用户线程此时正在运行),这些垃圾就叫浮动垃圾,如果当用户需要存入一个很大的对象时,新生代放不下去,老年代由于浮动垃圾过多,就会退化为 serial Old 收集器,将老年代垃圾进行标记-整理,当然这也是很耗费时间的!
CMS 收集器的内存回收过程是与用户线程一起并发执行的,可以搭配 ParNew 收集器(多线程,新生代,复制算法)与 Serial Old 收集器(单线程,老年代,标记-整理算法)使用。
G1
定义:
Garbage First
JDK 9以后默认使用,而且替代了CMS 收集器
适用场景:
- 同时注重吞吐量和低延迟(响应时间)
- 超大堆内存(内存大的),会将堆内存划分为多个大小相等的区域
- 整体上(从整个堆的角度去看)是标记-整理算法,两个区域之间是复制算法
相关参数:JDK8 并不是默认开启的,所需要参数开启
-XX:+UseG1GC //G1使用开关
-XX:G1HeapRegionSize=size //指定划分的区域大小
-XX:MaxGCPauseMillis=time //设置最大暂停毫秒数
G1垃圾回收过程
Young Collection:对新生代垃圾收集
Young Collection + Concurrent Mark:如果老年代内存到达一定的阈值了,新生代垃圾收集同时会执行一些并发的标记。
Mixed Collection:会对新生代 + 老年代 + 幸存区等进行混合收集,然后收集结束,会重新进入新生代收集。
(如果需要,单线程、独占式、高强度的Full GC还是会存在的。他针对GC的评估失败提供了一种失败保护机制,即强力回收)
接下来我们看看每个阶段的具体过程:
Young Collection
分代是按对象的生命周期划分,分区则是将堆空间划分连续几个不同小区间,每一个小区间独立回收,可以控制一次回收多少个小区间,方便控制 GC 产生的停顿时间
E:伊甸园 S:幸存区 O:老年代
当年轻代的Eden区用尽时开始年轻代回收过程;G1的年轻代收集阶段是一个并行的独占式收集器。在年轻代回收期,G1GC暂停所有应用程序线程,启动多线程执行年轻代回收。然后从年轻代区间移动存活对象到Survivor区间或者老年区间,也有可能是两个区间都会涉及。
详细过程:
当Eden区已满,JVM分配对象到Eden区失败时,便会触发一次STW式的年轻代收集young GC,将 Eden 区存活的对象将被拷贝到 to survivor 区;from survivor 区存活的对象则根据存活次数阈值分别晋升到 PLAB、to survivor 区和老年代中;如果 survivor 空间不够,Eden区的部分数据会直接晋升到年老代空间。最终Eden空间的数据为空,GC停止工作,应用线程继续执行。
拓展:
young GC 还负责维护对象的年龄(存活次数),辅助判断老化(tenuring)对象晋升时的去向。young GC 首先将晋升对象尺寸总和、年龄信息维护到年龄表中,再根据年龄表、Survivor尺寸、Survivor填充容量 -XX:TargetSurvivorRatio(默认50%)、最大任期阈值 -XX:MaxTenuringThreshold(默认15),计算出一个恰当的任期阈值,凡是超过任期阈值的对象都会被晋升到老年代。
这时,我们需要考虑一个问题,如果仅仅 GC 新生代对象,我们如何找到所有的根对象呢? 老年代的所有对象都是根么?那这样扫描下来会耗费大量的时间。于是就需要使用到我们后文介绍到的 RSet 了,RSet 中记录了其他 region 对当前 region 的引用,因此,在进行Young GC 时,扫描根时,仅仅需要扫描这一块区域,而不需要扫描整个老年代。
第一阶段,根扫描
:根是指static变量指向的对象、正在执行的方法调用链上的局部变量等。根引用连同 RSet 记录的外部引用作为扫描存活对象的入口。第二阶段,更新RSet
:处理 dirty card 队列中的 card,更新 RSet,此阶段完成后,RSet 可以准确的反映老年代对所在的region 分区中对象的引用第三阶段:处理RSet
:识别被老年代对象指向的 Eden 中的对象,这些被指向的Eden中的对象被认为是存活的对象第四阶段:对象拷贝
:将 Eden 区存活的对象将被拷贝到 to survivor 区;from survivor 区存活的对象则根据存活次数阈值分别晋升到 PLAB、to survivor 区和老年代中;如果 survivor 空间不够,Eden区的部分数据会直接晋升到年老代空间。第五阶段:处理引用
:处理软引用、弱引用、虚引用,最终Eden空间的数据为空,GC停止工作,而目标内存中的对象都是连续存储的、没有碎片,所以复制过程可以达到内存整理的效果,减少碎片。
Young Collection + Concurrent Mark
- 在 Young GC 时会对 GC Root 进行初始标记
- 在老年代占用堆内存的比例达到阈值时,会进行并发标记(不会STW),阈值可以根据用户来进行设定
XX:InitiatingHeapOccupancyPercent=percent
(默认45%)
详细过程:
初始标记阶段
:标记从根节点直接可达的对象。这个阶段是STW的,并且会触发一次年轻代GC。根区域扫描(Root Region Scanning)
:G1 GC扫描Survivor区直接可达的老年代区域对象,并标记被引用的对象。这一过程必须在YoungGC之前完成。并发标记(Concurrent Marking)
:在整个堆中进行并发标记(和应用程序并发执行),此过程可能被YoungGC中断。在并发标记阶段,若发现区域对象中的所有对象都是垃圾,那这个区域会被立即回收。同时,并发标记过程中,会计算每个区域的对象活性(区域中存活对象的比例)。重新标记(Remark)
:由于应用程序持续进行,需要修正上一次的标记结果。是STW的。G1中采用了比CMS更快的初始快照算法:snapshot-at-the-beginning(SATB)。独占清理(cleanup,STW)
:计算各个区域的存活对象和GC回收比例,并进行排序,识别可以混合回收的区域。为下阶段做铺垫。是STW的。这个阶段并不会实际上去做垃圾的收集并发清理阶段
:识别并清理完全空闲的区域。
Mixed Collection
详细过程
当越来越多的对象晋升到老年代old region时,为了避免堆内存被耗尽,虚拟机会触发一个混合的垃圾收集器,即Mixed GC,该算法并不是一个Old GC,除了回收整个Young Region,还会回收一部分的Old Region。这里需要注意:是一部分老年代,而不是全部老年代。可以选择哪些Old Region进行收集,从而可以对垃圾回收的耗时时间进行控制。也要注意的是Mixed GC并不是Full GC。
并发标记结束以后,老年代中百分百为垃圾的内存分段被回收了,部分为垃圾的内存分段被计算了出来。默认情况下,这些老年代的内存分段会分8次被回收(可以通过-XX:G1MixedGCCountTarget
设置)
混合回收的回收集(Collection Set)
包括:八分之一的老年代内存分段、Eden区内存分段、Survivor区内存分段。混合回收的算法和年轻代回收的算法完全一样,只是回收集多了老年代的内存分段。具体过程请参考上面的年轻代回收过程。
由于老年代中的内存分段默认分8次回收,G1会优先回收垃圾多的内存分段。垃圾占内存分段比例越高的,越会被先回收。并且有一个阈值会决定内存分段是否被回收,-XX:G1MixedGCLiveThresholdPercent
,默认为65%,意思是垃圾占内存分段比例要达到65%才会被回收。如果垃圾占比太低,意味着存活的对象占比高,在复制的时候会花费更多的时间。
混合回收并不一定要进行8次。有一个阈值-XX:G1HeapWastePercent
,默认值为10%,意思是允许整个堆内存中有10%的空间被浪费,意味着如果发现可以回收的垃圾占堆内存的比例低于10%,则不再进行混合回收。因为GC会花费很多的时间但是回收到的内存却很少。
问:为什么有的老年代被拷贝了,有的没拷贝?
因为指定了最大停顿时间,如果对所有老年代都进行回收,耗时可能过高。为了保证时间不超过设定的停顿时间,会回收最有价值的老年代(回收后,能够得到更多内存)
-XX:MaxGCPauseMills:xxx
用于指定最长的停顿时间(JVM会尽力实现,但不保证达到,所以我们称之为目标配置)
此处的G1垃圾回收过程并不是非常的深入,如果想了解更多可以参考如下文章:
JVM学习—-七种垃圾收集器(GC)
G1 垃圾收集器原理详解
Full GC辨析
G1在老年代内存不足时(老年代所占内存超过阈值)
- 如果垃圾产生速度慢于垃圾回收速度,不会触发Full GC,还是并发地进行清理
- 如果垃圾产生速度快于垃圾回收速度,便会触发Full GC
- SerialGC
- 新生代内存不足发生的垃圾收集 - minor gc
- 老年代内存不足发生的垃圾收集 - full gc
- ParallelGC
- 新生代内存不足发生的垃圾收集 - minor gc
- 老年代内存不足发生的垃圾收集 - full gc
- CMS
- 新生代内存不足发生的垃圾收集 - minor gc
- 老年代内存不足
- G1
- 新生代内存不足发生的垃圾收集 - minor gc
- 老年代内存不足
对象分配策略
在语言层面上,创建对象通常(例外:复制、反序列化)仅仅是一个new关键字而已。虚拟机中,对象的创建过程(仅限于普通Java对象,不包括数组和Class对象等)分为以下5步:
- 类加载检查
- 为新生对象分配内存
- 初始化零值
- 设置对象头信息
- 构造方法
这里我们只说明为新生对象分配内存这一部分:在类加载检查通过后,虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定,为对象分配空间的任务实际上便等同于把一块确定大小的内存块从Java堆中划分出来。分配方式有两种:指针碰撞
和空闲列表
指针碰撞
: 若Java堆中内存是绝对规整的,所有被使用过的内存都被放在一边,空闲的内存被放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间方向挪动一段与对象大小相等的距离,这种分配方式称为“指针碰撞”(Bump The Pointer)。
空闲列表
:若Java堆中的内存并不是规整的,已被使用的内存和空闲的内存相互交错在一起,那就没有办法简单地进行指针碰撞了,JVM就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为“空闲列表”(Free List)。
选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有空间压缩整理(Compact)的能力决定。因此:
- 当使用Serial、ParNew等带压缩整理过程的收集器时,系统采用的分配算法是指针碰撞,既简单又高效;
- 而当使用CMS这种基于清除(Sweep)算法的收集器时,理论上就只能采用较为复杂的空闲列表来分配内存。
- 强调“理论上”是因为在CMS的实现里面,为了能在多数情况下分配得更快,设计了一个叫做Linear Allocation Buffer的分配缓冲区,通过空闲列表拿到一大块分配缓冲区之后,在它里面仍然可以使用指针碰撞方式类分配。
为新生对象分配内存时除要考虑如何划分可用空间之外,还要考虑线程安全性。场景:对象创建在JVM中是非常频繁的行为,即使仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况。
解决方案有两种:
- 对分配内存空间的动作进行同步处理——实际上JVM是采用CAS配上失败重试的方式保证更新操作的原子性。
- 把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存,称为
本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)
,哪个线程要分配内存,就在哪个线程的本地缓冲区中分配,只有本地缓冲区用完了,分配新的缓存区时才需要同步锁定。虚拟机是否使用TLAB,可以通过-XX:+/-UseTLAB
参数来设定。
- TLAB很小,缺省情况下是Eden区的1%,所以放不下大对象
Remember Set
其存在意义就是为了解决一个对象被不同区域引用的问题,也就是我们后面会提到的跨代引用。目的就是为了提高垃圾回收效率,在判断对象存活的时候不需要扫描整个堆。
在串行和并行收集器中,GC时是通过整堆扫描来确定对象是否处于可达路径中。然而G1为了避免STW式的整堆扫描,为每个分区各自分配了一个 RSet(Remembered Set)
,它内部类似于一个反向指针,记录了其它 Region 对当前 Region 的引用情况,这样就带来一个极大的好处:回收某个Region时,不需要执行全堆扫描,只需扫描它的 RSet 就可以找到外部引用,来确定引用本分区内的对象是否存活,进而确定本分区内的对象存活情况,而这些引用就是 initial mark 的根之一。
事实上,并非所有的引用都需要记录在RSet中,如果引用源是本分区的对象,那么就不需要记录在 RSet 中;同时 G1 每次 GC 时,所有的新生代都会被扫描,因此引用源是年轻代的对象,也不需要在RSet中记录;所以最终只需要记录老年代到新生代之间的引用即可。
注意:
- 无论是G1还是其他分代收集器,JVM都是使用Remember Set来避免全局的扫描
Collect Set
Collect Set(CSet)
是指,在 Evaluation 阶段,由G1垃圾回收器选择的待回收的Region集合,在任意一次收集器中,CSet 所有分区都会被释放,内部存活的对象都会被转移到分配的空闲分区中。G1 的软实时性就是通过CSet的选择来实现的,对应于算法的两种模式 fully-young generational mode
和 partially-young mode
,CSet的选择可以分成两种:
fully-young generational mode
:也称young GC,该模式下CSet将只包含 young region,G1通过调整新生代的 region 的数量来匹配软实时的目标partially-young mode
:也称 Mixed GC,该模式会选择所有的 young region,并且选择一部分的 old region,old region 的选择将依据在Marking cycle phase中对存活对象的计数,筛选出回收收益最高的分区添加到CSet中(存活对象最少的Region进行回收)
候选老年代分区的CSet准入条件,可以通过活跃度阈值 -XX:G1MixedGCLiveThresholdPercent
(默认85%) 进行设置,从而拦截那些回收开销巨大的对象;同时,每次混合收集可以包含候选老年代分区,可根据CSet对堆的总大小占比 -XX:G1OldCSetRegionThresholdPercent
(默认10%) 设置数量上限。
由上述可知,G1的收集都是根据CSet进行操作的,年轻代收集与混合收集没有明显的不同,最大的区别在于两种收集的触发条件。
Card Table
Card Table我们也叫做卡表,然后这里我们还要引入一个概念叫card
:
- RSet:全称Remembered Sets, 用来记录外部指向本Region的所有引用,每个Region维护一个RSet。
- Card: 将一个 Region 在逻辑上划分为若干个固定大小(介于128到512字节之间)的连续区域,每个区域称之为卡片 Card
- G1对内存的使用以分区(Region)为单位,而对对象的分配则以卡片(Card)为单位。
- Card 是堆内存中的最小可用粒度,分配的对象会占用物理上连续的若干个卡片,当查找堆分区内对象的引用时便可通过卡片 Card 来查找
- 每次对内存的回收,也都是对指定分区的卡片进行处理。每个 Card 都用一个 Byte 来记录是否修改过,Card Table 就是这些 Byte 的集合,是一个字节数组。默认情况下,每个 Card 都未被引用,当一个地址空间被引用时,这个地址空间对应的数组索引的值被标记为”0″,即标记为脏被引用,此外 RSet 也将这个数组下标记录下来。
下图就展示了RSet与Card的关系。每个Region被分成了多个Card,其中绿色部分的Card表示该Card中有对象引用了其他Card中的对象,这种引用关系用蓝色实线表示。RSet其实是一个HashTable,Key是Region的起始地址,Value是Card Table (字节数组),字节数组下标表示Card的空间地址,当该地址空间被引用的时候会被标记为dirty_card。
Region、Card Table、Remember Set的关系;
图中RS的虚线表明的是,RSet 并不是一个和 Card Table独立的、不同的数据结构,而是指RS是一个概念模型。实际上,Card Table 是 RS 的一种实现方式。
一个Region可能有多个线程在并发修改,因此也可能会并发修改 RSet。为避免冲突,G1垃圾回收器进一步把 RSet 划分成了多个 HashTable,每个线程都在各自的 HashTable 里修改。最终,从逻辑上来说,RSet 就是这些 HashTable 的集合。哈希表是实现 RSet 的一种常见方式,它的好处就是能够去除重复,这意味着,RS的大小将和修改的指针数量相当,而在不去重的情况下,RS的数量和写操作的数量相当。
Young Collection 跨代引用
- 新生代回收的跨代引用(老年代引用新生代)问题
- 卡表与Remembered Set
- Remembered Set 存在于E中,用于保存新生代对象对应的脏卡
- 脏卡:O被划分为多个区域(一个区域512K),如果该区域引用了新生代对象,则该区域被称为脏卡
- Remembered Set 存在于E中,用于保存新生代对象对应的脏卡
- 在引用变更时通过post-write barried + dirty card queue
- concurrent refinement threads 更新 Remembered Set
解决办法:
-
无论G1还是其他分代收集器,JVM都是使用Remembered Set来避免全局扫描
-
每个Region都有一个对应的Remembered Set;
-
每次Reference类型数据写操作时,都会产生一个Write Barrier暂时中断操作;
-
然后检查将要写入的引用指向的对象是否和该Reference类型数据在不同的Region(其他收集器:检查老年代对象是否引用了新生代对象);
-
如果不同,通过CardTable把相关引用信息记录到引用指向对象的所在Region对应的Remembered Set中;
-
当进行垃圾收集时,在GC根节点的枚举范围加入Remembered Set;就可以保证不进行全局扫描,也不会有遗漏
Write Barrier
是指写屏障
写屏障
:每次 Reference 引用类型在执行写操作时,都会产生 Write Barrier 写屏障暂时中断操作并额外执行一些动作。
三色标记算法
三色标记算法是并发收集阶段的重要算法,它是描述追踪式回收器的一种有用的方法,利用它可以推演回收器的正确性。 首先,我们将对象分成三种类型的。
- 黑色:根对象,或者该对象与它的子对象都被扫描了
- 灰色:对象本身被扫描,但还没扫描完该对象中的子对象
- 白色:未被扫描对象,扫描完成所有对象之后,最终为白色的为不可达对象,即垃圾对象
下面我们就以一组演变图,加深下对三色标记算法的理解,当GC开始扫描对象时,按照如下图步骤进行对象的扫描:
- 根对象被置为黑色,子对象被置为灰色
- 继续由灰色遍历,将已扫描了子对象的对象置为黑色。
- 遍历了所有可达的对象后,所有可达的对象都变成了黑色。不可达的对象即为白色,需要被清理
如果在标记过程中,应用程序也在运行,那么对象的指针就有可能改变(这也就对应着Young Collection + Concurrent Mark中的重新标记)。这样的话,我们就会遇到一个问题:对象丢失问题。我们看下面一种情况,当垃圾收集器扫描到下面情况时:
这个时候应用程序执行了以下操作:
A.c=C
B.c=null
这样,对象的状态图变成如下情形:
这时候垃圾收集器再标记扫描的时候就会下图成这样:
很显然,此时C是白色,被认为是垃圾需要清理掉,显然这是不合理的。那么我们如何保证应用程序在运行的时候,GC标记的对象不丢失呢?有如下两种可行的方式:
- 在插入的时候记录对象
- 在删除的时候记录对象
刚好这对应 CMS 和 G1 的两种不同实现方式:
-
CMS采用的是
增量更新(Incremental update)
:只要在写屏障里发现一个白对象的引用被赋值到一个黑对象的字段里,那就把这个白对象变成灰色的,即插入的时候记录下来。 -
G1 使用的是
STAB(snapshot-at-the-beginning)
的方式:删除的时候记录所有的对象,它有三个步骤:- ① 在开始标记的时候生成一个存活对象的快照图
- ② 在并发标记时,所有被改变的对象入队(在write barrier里把所有旧的引用所指向的对象都变成非白的)
- ③ 可能存在游离的垃圾,将在下次被收集
我们按照G1的步骤解决上面的问题:
之前C被B引用,这时A引用了C,就会给C加一个写屏障,写屏障的指令会被执行,将C放入一个队列当中,并将C变为处理中状态(也就是灰色)。在并发标记阶段结束以后,重新标记阶段会STW,然后将放在该队列中的对象重新处理,这时发现有强引用引用它,就会处理它。
JDK 8u20 字符串去重
过程
- 将所有新分配的字符串(底层是char[])放入一个队列
- 当新生代回收时,G1并发检查是否有重复的字符串
- 如果字符串的值一样,就让他们引用同一个字符串对象
- 注意,其与String.intern的区别
- intern关注的是字符串对象
- 字符串去重关注的是char[]
- 在JVM内部,使用了不同的字符串标
优点与缺点
- 节省了大量内存
- 新生代回收时间略微增加,导致略微多占用CPU
-XX:+UseStringDeduplication
JDK 8u40 并发标记类卸载
在并发标记阶段结束以后,就能知道哪些类不再被使用。如果一个类加载器的所有类都不在使用,则卸载它所加载的所有类
JDK 8u60 回收巨型对象
其实分区Region还有一个特殊的区域
H区(Humongous)
:
H区专门用于存放巨型对象,如果一个对象的大小超过Region容量的50%以上,G1 就认为这是个巨型对象。在其他垃圾收集器中,这些巨型对象默认会被分配在老年代,但如果它是一个短期存活的巨型对象,放入老年代就会对垃圾收集器造成负面影响,触发老年代频繁GC。为了解决这个问题,G1划分了一个H区专门存放巨型对象,如果一个H区装不下巨型对象,那么G1会寻找连续的H分区来存储,如果寻找不到连续的H区的话,就不得不启动 Full GC 了。
- 一个对象大于region的一半时,就称为巨型对象
- G1不会对巨型对象进行拷贝
- 回收时被优先考虑
- G1会跟踪老年代所有incoming引用,如果老年代incoming引用为0的巨型对象就可以在新生代垃圾回收时处理掉
JDK 9 并发标记起始时间的调整
- 并发标记必须在堆空间占满前完成,否则退化为 FulGC
- JDK 9 之前需要使用 -XX:InitiatingHeapOccupancyPercent
- JDK 9 可以动态调整
- -XX:InitiatingHeapOccupancyPercent 用来设置初始值
- 进行数据采样并动态调整
- 总会添加一个安全的空挡空间
垃圾回收调优
查看虚拟机参数命令
"F:\JAVA\JDK8.0\bin\java" -XX:+PrintFlagsFinal -version | findstr "GC"
可以根据参数去查询具体的信息
调优领域
- 内存
- 锁竞争
- cpu 占用
- io
- gc
确定目标
低延迟/高吞吐量? 选择合适的GC
追求低延迟一般是在互联网项目,响应时间非常重要
追求高吞吐量一般是在科学计算领域,响应时间并不是非常重要
-
追求低延迟选择:CMS G1 ZGC
-
追求高吞吐量:ParallelGC
如果找不到满意的可以尝试换一种虚拟机,例如Zing虚拟机。
其宣传具有无间歇垃圾回收技术:
最快的 GC
首先排除减少因为自身编写的代码而引发的内存问题
- 查看 Full GC 前后的内存占用,考虑以下几个问题
- 数据是不是太多?
- resultSet = statement.executeQuery(“select * from 大表 limit n”)
- 直接
select * from 大表
会导致非常庞大的内存占用
- 数据表示是否太臃肿
- 对象图:用到对象的哪个数据就查哪个数据,不要先全查出来
- 对象大小 16 Integer 24 int 4
- 是否存在内存泄漏
- 在一些缓存场景中会出现static Map map,这种对象不容易被回收,随着缓存元素的逐渐增多,会有堆内存泄露的风险
- 我们可以考虑使用软、弱引用
- 或者使用专门的第三方缓存实现,例如:OSCache、EhCache、JbossCache、OSCache
- 数据是不是太多?
新生代调优
-
新生代的特点
- 所有的 new 操作分配内存都是非常廉价的
- TLAB (thread-lcoal allocation buffer)
- 我们在前面的对象分配策略中提到过,TLAB 是为了解决对象分配时的线程安全问题,我们分配对象内存的时候会先在TLAB中进行,所以分配速度非常的快。
- 死亡对象回收零代价
- 大部分对象用过即死(朝生夕死)
- Minor GC 所用时间远小于 Full GC
- 所有的 new 操作分配内存都是非常廉价的
-
新生代调优我们很容易就想到增大新生代的内存,但是新生代内存越大越好么?
- 不是
- 新生代内存太小:频繁触发 Minor GC ,会 STW ,会使得吞吐量下降
- 新生代内存太大:老年代内存占比有所降低,会更频繁地触发 Full GC。而且触发 Minor GC 时,清理新生代所花费的时间会更长
- Oracle的建议是新生代的内存大小在整个堆的25%~50%之间
- 新生代内存设置为能容纳[并发量*(请求-响应过程中涉及的对象大小)]的数据为宜,这样的话能不触发或者少触发新生代的垃圾回收
- 不是
幸存区调优
- 幸存区需要能够保存 【当前活跃对象+需要晋升的对象】
- 如果太小的话,jvm会动态的调整晋升阈值,这样会使一些当前活跃对象提前晋升到老年代,而如果他们中有的存活时间较短,要等到Full GC的时候才能清除,这会浪费内存影响效率。
- 所以我们幸存区的调优就是要把晋升阈值配置得当,让长时间存活的对象尽快晋升
-XX:MaxTenuringThreshold=threshold //设置最大晋升阈值
-XX:+PrintTenuringDistrubution //打印有关晋升的信息
第一列显示的是对象的年龄,第二列是此年龄对象所占的大小。我们可以通过这些数据来帮助我们决定晋升的阈值。
老年代调优
以 CMS 为例:
- CMS 的老年代内存越大越好
- 先尝试不做调优,如果没有 Full GC 那么已经可以了,否则先尝试调优新生代。
- 在进行老年代调优的时候,观察 Full GC 时老年代内存占用,将老年代内存预设调大 1/4 ~ 1/3
-XX:CMSInitiatingOccupancyFraction=percent //设置老年代垃圾占据多少时开始垃圾回收
调优案例
案例1:Full GC 和 Minor GC 频繁
解决思路:提升新生代的内存空间,然后提升幸存空间的大小和阈值
案例2:请求高峰期发生 Full GC,单次暂停时间特别长(CMS)
解决思路:很明显这里追求的是低延时。首先我们查看GC日志,看看CMS的哪个阶段耗费时间最长。在CMS中,初始标记和并发标记的时间一般较短,而重新标记阶段可能会占用更多的时间。在重新标记阶段CMS既会扫描新生代也会扫描老年代,高峰期的时候新生代里面的对象会非常的多,这样的话标记时间会非常的长,那么我们就希望在重新标记之前在新生代做一次垃圾回收,这样减少新生代的数量,使重新标记阶段的耗时变少。想要达到这种效果我们前面也说过,可以开启一个选项:
-XX:+CMSScavengeBeforeRemark
案例3:老年代充裕情况下,发生 Full GC(jdk1.7)
在jdk1.7的时候方法区的实现是使用的永久代,而这个永久代是存在于堆中的。而如果永久代的空间不足是会触发Full GC的,所以这里我们只需要扩张永久代的空间即可。
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/121963.html