文章目录
- 垃圾回收算法
- 垃圾回收算法的历史和分类
- 垃圾回收算法的评价标准
- 标记清除算法
- 优缺点
- 复制算法
- 优缺点
- 标记整理算法(标记压缩算法)
- 优缺点
- 分代垃圾回收算法(常用)
- JVM参数设置
- 使用Arthas查看内存分区
- 垃圾回收执行流程
- 分代GC算法内存为什么分年轻代、老年代
- 文章说明
垃圾回收算法
Java是如何实现垃圾回收的呢?简单来说,垃圾回收算法要做的有两件事:
- 找到内存中存活的对象
- 释放不再存活对象的内存,使得程序能再次利用这部分空间
垃圾回收算法的历史和分类
- 1960年John McCarthy发布了第一个GC算法:标记-清除算法。
- 1963年Marvin L. Minsky 发布了复制算法。
本质上后续所有的垃圾回收算法,都是在上述两种算法的基础上优化而来。
垃圾回收算法的评价标准
Java垃圾回收过程会通过单独的GC线程来完成,但是不管使用哪一种GC算法,都会有部分阶段需要停止所有的用户线程。这个过程被称之为Stop The World简称STW,如果STW时间过长(系统假死)则会影响用户的使用。
如下图,用户代码执行和垃圾回收执行让用户线程停止执行(STW)是交替执行的。
交替执行过程可以通过如下代码验证:
package chapter04.gc;
import lombok.SneakyThrows;
import java.util.LinkedList;
import java.util.List;
/**
* STW测试
*/
public class StopWorldTest {
public static void main(String[] args) {
new PrintThread().start();
new ObjectThread().start();
}
}
/**
* 打印线程
*/
class PrintThread extends Thread{
@SneakyThrows
@Override
public void run() {
//记录开始时间
long last = System.currentTimeMillis();
while(true){
long now = System.currentTimeMillis();
// 如果不是垃圾回收影响,这里每次都应该是输出100
System.out.println(now - last);
last = now;
Thread.sleep(100);
}
}
}
/**
* 创建对象线程
*/
class ObjectThread extends Thread{
@SneakyThrows
@Override
public void run() {
List<byte[]> bytes = new LinkedList<>();
while(true){
// 最多存放8g,然后删除强引用,垃圾回收时释放8g
if(bytes.size() >= 80){
// 清空集合,强引用去除,垃圾回收器就会去回收对象
bytes.clear();
}
bytes.add(new byte[1024 * 1024 * 100]);
Thread.sleep(10);
}
}
}
代码运行之前,设置如下JVM参数
所以判断GC算法是否优秀,可以从三个方面来考虑:
- 吞吐量
吞吐量指的是 CPU 用于执行用户代码的时间与 CPU 总执行时间的比值,即吞吐量 = 执行用户代码时间 /(执行用户代码时间 + GC时间)。吞吐量数值越高,垃圾回收的效率就越高。
- 最大暂停时间
最大暂停时间指的是所有在垃圾回收过程中的STW时间最大值。比如如下的图中,黄色部分的STW就是最大暂停时间,显而易见上面的图比下面的图拥有更少的最大暂停时间。最大暂停时间越短,用户使用系统时受到的影响就越短。
- 堆使用效率
不同垃圾回收算法,对堆内存的使用方式是不同的。比如标记清除算法,可以使用完整的堆内存。而复制算法会将堆内存一分为二,每次只能使用一半内存。从堆使用效率上来说,标记清除算法要优于复制算法。
上述三种评价标准:堆使用效率、吞吐量,以及最大暂停时间不可兼得。
一般来说,堆内存越大,回收对象就越多,最大暂停时间就越长。想要减少最大暂停时间,就要减少堆内存,少量多次,因为每次清理有一些准备工作,因此垃圾回收总时间会上升,吞吐量会降低。
没有一个垃圾回收算法能兼顾上述三点评价标准,所以不同的垃圾回收算法它的侧重点是不同的,适用于不同的应用场景(即垃圾回收算法没有好与坏,只有是否适合)
- 秒杀场景,购买只有很少的时间,最大暂停时间越短越好
- 有的场景,程序就在后台处理数据,暂停时间长一点无所谓,目标是吞吐量高一点
标记清除算法
标记清除算法的核心思想分为两个阶段:
- 标记阶段,将所有存活的对象进行标记。Java中使用可达性分析算法,从GC Root开始通过引用链遍历出所有存活对象。
- 清除阶段,从内存中删除没有被标记也就是非存活对象。
第一个阶段,从GC Root对象开始扫描,将对象A、B、C在引用链上的对象标记出来:
第二个阶段,将没有标记的对象清理掉,所以对象D就被清理掉了。
优缺点
优点:实现简单,只需要在第一阶段给每个对象维护标志位(在引用链上,标记为1),第二阶段删除标记值为0的对象即可。
缺点:
- 碎片化问题:由于内存是连续的,所以在对象被删除之后,内存中会出现很多细小的可用内存单元。如果我们需要的是一个比较大的空间,很有可能这些内存单元的大小过小无法进行分配。如下图,红色部分已经被清理掉了,总共回收了9个字节,但是每个都是一个小碎片,无法为5个字节的对象分配空间。
- 分配速度慢。由于内存碎片的存在,需要维护一个空闲链表,极有可能发生每次需要遍历到链表的最后才能获得合适的内存空间。我们需要用一个链表来维护,哪些空间可以分配对象,很有可能需要遍历这个链表到最后,才能发现这块空间足够我们去创建一个对象。如下图,遍历到最后才发现有足够的空间分配3个字节的对象了。如果链表很长,遍历也会花费较长的时间。
复制算法
复制算法的核心思想是:
- 准备两块空间From空间和To空间,每次在对象分配阶段,只能使用其中一块空间(From空间)。
对象A首先分配在From空间:
- 在垃圾回收GC阶段,将From中的存活对象复制到To空间。
在垃圾回收阶段,如果对象A存活,就将其复制到To空间。然后将From空间直接清空。
- 将两块空间的From和To名字互换,下次依然在From空间上创建对象。
完整的复制算法的例子:
1、将堆内存分割成两块From空间 To空间,对象分配阶段,创建对象。
2、GC阶段开始,将GC Root搬运到To空间
3、将GC Root关联的对象,搬运到To空间
4、清理From空间,并把名称互换
优缺点
优点:
- 吞吐量高,复制算法只需要遍历一次存活对象复制到To空间即可,比
标记-整理算法
少了一次遍历的过程,因而性能较好;但是性能不如标记-清除算法
,因为标记清除算法不需要进行对象的移动 - 不会发生碎片化,复制算法在复制之后就会将对象按顺序放入To空间中,所以对象以外的区域都是可用空间,不存在碎片化内存空间。
缺点:
- 内存使用效率低,每次只能让一半的内存空间来给创建对象使用。
标记整理算法(标记压缩算法)
标记整理算法是对标记清理算法中容易产生内存碎片问题的一种解决方案。
核心思想分为两个阶段:
- 标记阶段,将所有存活的对象进行标记。Java中使用可达性分析算法,从GC Root开始通过引用链遍历出所有存活对象。
- 整理阶段,将存活对象移动到堆的一端。清理掉存活对象的内存空间。
优缺点
优点:
- 内存使用效率高,整个堆内存都可以使用,不像复制算法只能使用半个堆内存
- 不会发生碎片化,在整理阶段可以将对象往内存的一侧进行移动,剩下的空间都是可以分配对象的有效空间
缺点:
- 整理阶段的效率不高,需要遍历多次对象,还需要移动对象。整理算法有很多种,比如Lisp2整理算法需要对整个堆中的对象搜索3次,整体性能不佳。可以通过Two-Finger、表格算法、ImmixGC等高效的整理算法优化此阶段的性能。
分代垃圾回收算法(常用)
现代优秀的垃圾回收算法,会将上述描述的垃圾回收算法组合进行使用,其中应用最广的就是分代垃圾回收算法(Generational GC)。分代垃圾回收将整个内存区域划分为两块大区:年轻代、老年代:
- Eden区:对象刚被创建出来的时候放到的地方
- 幸存者区-S0、幸存者区-S1:用来实现复制算法
可以通过arthas来验证下内存划分的情况:
- 在JDK8中,添加
-XX:+UseSerialGC
参数使用分代回收的垃圾回收器,运行程序。 - 在arthas中使用memory命令查看内存,显示出三个区域的内存情况。
- Eden + survivor 这两块区域组成了年轻代。
- tenured_gen指的是晋升区域,其实就是老年代。
JVM参数设置
可以设置的虚拟机参数如下
参数名 | 参数含义 | 示例 |
---|---|---|
-Xms | 设置堆的最小和初始大小,必须是1024倍数且大于1MB | 比如初始大小6MB的写法: -Xms6291456 -Xms6144k -Xms6m |
-Xmx | 设置最大堆的大小,必须是1024倍数且大于2MB | 比如最大堆80 MB的写法: -Xmx83886080 -Xmx81920k -Xmx80m |
-Xmn | 新生代的大小 | 新生代256 MB的写法: -Xmn256m -Xmn262144k -Xmn268435456 |
-XX:SurvivorRatio | 伊甸园区和幸存区的比例,默认为8:如新生代有1g内存,则伊甸园区800MB,S0和S1各100MB | 比例调整为4的写法:-XX:SurvivorRatio=4 |
-XX:+PrintGCDetailsverbose:gc | 打印GC日志 | 无 |
老年代大小不需要设置,因为新生代设置完之后,老年代的大小就确定了(总的堆内存-新生代内存)
注
:如果使用其他版本的JDK,或者使用其他回收器,上面的部分参数可能就不会生效
使用Arthas查看内存分区
代码:
package chapter04.gc;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
/**
* 垃圾回收器案例1
*/
//-XX:+UseSerialGC -Xms60m -Xmn20m -Xmx60m -XX:SurvivorRatio=3 -XX:+PrintGCDetails
public class GcDemo0 {
public static void main(String[] args) throws IOException {
List<Object> list = new ArrayList<>();
int count = 0;
while (true){
System.in.read();
System.out.println(++count);
//每次添加1m的数据
list.add(new byte[1024 * 1024 * 1]);
}
}
}
使用arthas的memory展示出来的效果:
heap展示的是可用堆。
垃圾回收执行流程
1、分代回收时,创建出来的对象,首先会被放入Eden伊甸园区。
2、随着对象在Eden区越来越多,如果Eden区满,新创建的对象已经无法放入,就会触发年轻代的GC,称为Minor GC或者Young GC。Minor GC会把需要eden中和From需要回收的对象回收,把没有回收的对象放入To区(算法使用的是复制算法)。Minor GC结束之后**,**Eden区会被清空,后面创建的对象又可以放到Eden区。
3、接下来,S0会变成To区,S1变成From区。当eden区满时再往里放入对象,依然会发生Minor GC。
此时会回收eden区和S1(from)中的对象,并把eden和from区中存活的对象放入S0。
注意:每次Minor GC中都会为对象记录他的年龄,初始值为0,每次GC完加1。
4、如果Minor GC后对象的年龄达到阈值(最大15,默认值和垃圾回收器有关),对象就会被晋升至老年代。
5、当老年代中空间不足,无法放入新的对象时,先尝试minor gc(为啥?**因为young满了之后,部分对象年龄没有到15,也被放在了老年区,**minor gc可以清理young区来放新对象)。如果空间还是不足,就会触发Full GC(停顿时间较长),Full GC会对整个堆进行垃圾回收。如果Full GC依然无法回收掉老年代的对象,那么当对象继续放入老年代时,就会抛出Out Of Memory异常。
下图中的程序为什么会出现OutOfMemory?
从上图可以看到,Full GC无法回收掉老年代的对象,那么当对象继续放入老年代时,就会抛出Out Of Memory异常。
【测试代码】
//-XX:+UseSerialGC -Xms60m -Xmn20m -Xmx60m -XX:SurvivorRatio=3 -XX:+PrintGCDetails
public class GcDemo0 {
public static void main(String[] args) throws IOException {
List<Object> list = new ArrayList<>();
int count = 0;
while (true){
System.in.read();
System.out.println(++count);
//每次添加1m的数据
list.add(new byte[1024 * 1024 * 1]);
}
}
}
结果如下:
老年代已经满了,而且垃圾回收无法回收掉对象,如果还想往里面放就发生了OutOfMemoryError
。
分代GC算法内存为什么分年轻代、老年代
为什么分代GC算法要把堆分成年轻代和老年代?首先我们要知道堆内存中对象的特性:
- 系统中的大部分对象,都是创建出来之后很快就不再使用可以被回收,比如用户获取订单数据,订单数据返回给用户之后就可以释放了。
- 老年代中会存放长期存活的对象,比如Spring的大部分bean对象,在程序启动之后就不会被回收了。
- 在虚拟机的默认设置中,新生代大小要远小于老年代的大小。
分代GC算法将堆分成年轻代和老年代主要原因有:
- 可以通过调整年轻代和老年代的比例来适应不同类型的应用程序,提高内存的利用率和性能。
- 新生代和老年代使用不同的垃圾回收算法,新生代一般选择
复制
算法;老年代可以选择标记-清除
和标记-整理
算法,由程序员来选择灵活度较高。 - 分代的设计中允许只回收新生代(minor gc),如果能满足对象分配的要求就不需要对整个堆进行回收(full gc),STW时间就会减少。(尽可能做minor gc,少做full gc,尽量降低垃圾回收对程序运行的影响)
文章说明
该文章是本人学习 黑马程序员 的学习笔记,文章中大部分内容来源于 黑马程序员 的视频黑马程序员JVM虚拟机入门到实战全套视频教程,java大厂面试必会的jvm一套搞定(丰富的实战案例及最热面试题),也有部分内容来自于自己的思考,发布文章是想帮助其他学习的人更方便地整理自己的笔记或者直接通过文章学习相关知识,如有侵权请联系删除,最后对 黑马程序员 的优质课程表示感谢。