常见 JVM垃圾回收器、内存分配策略、JVM调优
一、哪些内存需要回收?
回答这个问题前需要先了解JVM到底有哪些内存区域,可参考:https://blog.csdn.net/weixin_41231928/article/details/107094638
程序计数器、虚拟机栈、本地方法栈3个区域随线程而生,随线程而灭,栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作。每一个栈帧中分配多少内存基本上是在类结构确定下来时就已知的(尽管在运行期会由即时编译器进行一些优化,但在基于概念模型的讨论里,大体上可以认为是编译期可知的),因此这几个区域的内存分配和回收都具备确定性,在这几个区域内就不需要过多考虑如何回收的问题,当方法结束或者线程结束时,内存自然就跟随着回收了。
那还剩下什么区域?还有方法区和堆!
对于方法区,上面链接对应的文章中也说过:方法区的内存回收目标主要是针对常量池的回收和对类型的卸载,即:废弃的常量和不再使用的类型,但总的来说方法区的回收触发条件比较苛刻,发生垃圾回收的频率比较低。举个常量池中字面量回收的例子,假如一个字符串“java”曾经进入常量池中,但是当前系统又没有任何一个字符串对象的值是“java”,换句话说,已经没有任何字符串对象引用常量池中的“java”常量,且虚拟机中也没有其他地方引用这个字面量。如果在这时发生内存回收,而且垃圾收集器判断确有必要的话,这个“java”常量就将会被系统清理出常量池。常量池中其他类(接口)、方法、字段的符号引用也与此类似。
- 该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例。
- 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如OSGi、JSP的重加载等,否则通常是很难达成的。
- 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法
垃圾回收的主要阵地是堆,为什么这么说?一个接口的多个实现类需要的内存可能会不一样,一个方法所执行的不同条件分支所需要的内存也可能不一样,只有处于运行期间,我们才能知道程序究竟会创建哪些对象,创建多少个对象,这部分内存的分配和回收是动态的。垃圾收集器所关注的正是这部分内存该如何管理。
二、垃圾收集第一步 —— 判断对象是死是活
2.1 引用计数法
public static void testGC(){
GC objA = new GC();
GC objB = new GC();
objA.instance = objB;
objB.instance = objA;
objA = null;
objB = null;
System.gc();
}
2.2 可达分析法
-
在虚拟机栈(栈帧中的本地变量表)中引用的对象,如各线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
-
在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。
-
在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用
-
在本地方法栈中JNI(即通常所说的Native方法)引用的对象
-
Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。
-
所有被同步锁(synchronized关键字)持有的对象。
-
反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。
2.3 引用分类
- 强引用是最传统的“引用”的定义,即类似“Object obj=new Object()”这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。哪怕内存不足时,JVM也会直接抛出OutOfMemoryError。
- 软引用是用来描述一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。
- 弱引用也是用来描述那些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。
-
虚引用也称为 “ 幽灵引用 ” 或者 “ 幻影引用 ”,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,它随时可能会被回收,在 JDK1.2 之后,用 PhantomReference 类来表示,通过查看这个类的源码,发现它只有一个构造函数和一个 get() 方法,而且它的 get() 方法仅仅是返回一个null,也就是说将永远无法通过虚引用来获取对象,虚引用必须要和 ReferenceQueue 引用队列一起使用。
public class Main {
public static void main(String[] args) {
new Main().fun1();
}
public void fun1() {
Object object = new Object();
Object[] objArr = new Object[1000];
}
}
当运行至Object[] objArr = new Object[1000];这句时,如果内存不足,JVM会抛出OOM错误也不会回收object指向的对象。不过要注意的是,当fun1运行完之后,object和objArr都已经不存在了,所以它们指向的对象都会被JVM回收。
如果想中断强引用和某个对象之间的关联,可以显示地将引用赋值为null,这样一来的话,JVM在合适的时间就会回收该对象。
public class Main {
public static void main(String[] args) {
SoftReference<String> sr = new SoftReference<String>(new String("hello"));
System.out.println(sr.get());
}
}
3.弱引用(WeakReference)
弱引用也是用来描述非必需对象的,当JVM进行垃圾回收时,无论内存是否充足,都会回收被弱引用关联的对象。在java中,用java.lang.ref.WeakReference类来表示。下面是使用示例:
public class Main {
public static void main(String[] args) {
WeakReference<String> sr = new WeakReference<String>(new String("hello"));
System.out.println(sr.get());
System.gc(); //通知JVM的gc进行垃圾回收
System.out.println(sr.get());
}
}
hello
null
第二个输出结果是null,这说明只要JVM进行垃圾回收,被弱引用关联的对象必定会被回收掉。不过要注意的是,这里说的被弱引用关联的对象是指只有弱引用与之关联,如果存在强引用同时与之关联,则垃圾回收时也不会回收该对象(软引用也是如此)。
public class Main {
public static void main(String[] args) {
ReferenceQueue<String> queue = new ReferenceQueue<String>();
PhantomReference<String> pr = new PhantomReference<String>(new String("hello"), queue);
System.out.println(pr.get());
}
}
null
要注意的是,虚引用必须和引用队列关联使用,当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会把这个虚引用加入到与之 关联的引用队列中。程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。如果程序发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动。
如何利用软引用和弱引用解决OOM问题?
2.4 其他说明
假如一个对象被可达分析法法判定为不可达对象,该对象是否就一定会被回收呢?答案是否定的。
三、垃圾收集算法
前面说了虚拟机怎么判定一个对象是死是活的基本理论,即引用计数法和可达性分析法。
垃圾收集算法基于这两种理论可以分为:引用计数式垃圾收集 和 追踪式垃圾收集 两种。但是主流的虚拟机一般都属于追踪式垃圾收集,因此此类型的垃圾收集器是重点。
3.1 分代收集理论
3.1.1 什么是分代?
- 新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集。
- 老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集。目前只有CMS收集器会有单独收集老年代的行为。另外请注意“Major GC”这个说法现在有点混淆,在不同资料上常有不同所指,需按上下文区分到底是指老年代的收集还是整堆收集。
- 混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集。目前只有G1收集器会有这种行为。
- 整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集。
(新生代(Young)、老年代(Old)是HotSpot虚拟机,也是现在业界主流的命名方式)
3.1.2 跨代引用
3.1.3 分代的意义
3.2 标记--清除 算法
- 第一个是执行效率不稳定,如果Java堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低;
- 内存空间的碎片化问题,标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
3.3 标记--复制 算法
也叫复制算法,主要针对标记--清除算法面对大量可回收对象时执行效率的不足进行了改进。
标记--复制 算法将内存按容量划分为大小相等的两块,每次只使用其中的一块。当其中一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。
如果Java堆中包含大量对象,而且其中大部分是需要被回收的,标记--复制 算法只需要将少数的存活对象复制到另一块内存即可,内存复制开销很小,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可。
所以 标记--复制 算法的优点是:1)面对大量要回收的对象时执行效率高;2)没有内存碎片产生。那它有没有缺陷呢?可定有啊,这种复制回收算法的代价是将可用内存缩小为了原来的一半,太浪费空间。其次,在面对堆中有大量的对象,且多数为不可回收的对象时,那复制的开销就会变大。所以 标记--复制 比较适用于年轻代的垃圾收集器采用,不适合老年代的垃圾收集器采用。
此外,还存在”内存担保“问题:这里是按1:1的比例划分内存的,但实际上,比如新生代中的对象有98%熬不过第一轮收集,那就没必要再按这个比例来划分内存,可以适当缩小保留区域的内存,因为毕竟存活下来的对象不多,不需要那么多内存来保存极少活下来的对象。
3.4 标记--整理 算法
四、经典垃圾回收器
4.1 CMS收集器
- 1)初始标记
- 2)并发标记
- 3)重新标记
- 4)并发清除
可以看出CMS中重点是标记,其中初始标记、重新标记这两个步骤仍然需要“Stop The World”,也就是需要暂停所有线程,那这样怎么还说CMS是响应快速的呢?那是因为初始标记仅仅只是标记一下GC Root能直接关联到的对象,速度很快;并发标记阶段就是从GC Root的直接关联对象开始遍历整个对象图的过程,这个过程虽然很耗时但却不需要停顿所有用户线程,用户线程可以和垃圾收集线程一起并发运行;重新标记阶段则是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间通常会比初始标记阶段稍长一些,但也远比并发标记阶段的时间短;最后是并发清除阶段,清理删除掉标记阶段判断的已经死亡的 对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的。
优缺点:
优点:并发收集、低停顿。
缺点:(1)CMS收集器对处理器资源非常敏感。如果应用本来的处理器负载就很高,还要分出一半的运算能力去执行收集器线程,就可能导致用户程序的执行速度忽然大幅降低。(2)CMS是一款基于“标记-清除”算法实现的收集器,收集结束时会有大量空间碎片产生。
4.2 Garbage First收集器 -- G1
- 1)初始标记:仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。这个阶段需要停顿线程,但耗时很短,而且是借用进行Minor GC的时候同步完成的,所以G1收集器在这个阶段实际并没有额外的停顿。
- 2)并发标记:从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。对象图扫描完成以后,还要重新处理SATB记录下的在并发时有引用变动的对象。
- 3)最终标记:主要修正在并发标记阶段因为用户线程继续运行而导致标记记录产生变动的那一部分对象的标记记录。对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的SATB记录。
- 4)筛选回收:更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行完成的。
补充一、如何打印gc细节?打印的gc日志怎么看?
如何打印:
public class ReferenceCountingGC {
public Object instance = null;
private static final int _1MB = 1024*1024;
private byte[] bigSize = new byte[2 * _1MB];
public static void testGC(){
ReferenceCountingGC objA = new ReferenceCountingGC();
ReferenceCountingGC objB = new ReferenceCountingGC();
objA.instance = objB;
objB.instance = objA;
objA = null;
objB = null;
System.gc();
}
public static void main(String[] args) {
testGC();
}
}
-XX:+PrintGCDetails (打印GC前后的详细信息)-XX:+PrintHeapAtGC(打印GC前后的详细堆栈信息)-Xloggc:filename (输出垃圾收集器的信息到一个指定的文件,例如:-Xloggc:F://log.txt )
串行收集器:
DefNew:是使用-XX:+UseSerialGC(新生代,老年代都使用串行回收收集器)。
并行收集器:
ParNew:是使用-XX:+UseParNewGC(新生代使用并行收集器,老年代使用串行回收收集器)或者-XX:+UseConcMarkSweepGC(新生代使用并行收集器,老年代使用CMS)。
PSYoungGen:是使用-XX:+UseParallelOldGC(新生代,老年代都使用并行回收收集器)或者-XX:+UseParallelGC(新生代使用并行回收收集器,老年代使用串行收集器)
garbage-first heap:是使用-XX:+UseG1GC(G1收集器)
补充二、如何使用JDK自带工具JConsole?
概述 :记录了“堆内存使用情况”、“线程”、“类”、“CPU使用情况”共四个资源的实时情况;
内存 :可以选择查看“堆内存使用情况”、“非堆内存使用情况”、“内存池"PS Eden Space"”等内存占用的实时情况;界面右下角还有图形化的堆一级、二级、三级缓存(从左到右)占用情况,当然,如果三级缓存被全部占用也就是很可能内存溢出啦!这时可以去查看服务器的tomcat日志,应该会有“outofmemory"的异常日志信息。界面右上角处还提供了一个“执行GC”的手动垃圾收集功能,这个也很实用~而且界面下方还有详细的GC信息记录。
线程 :界面上部显示实时线程数目。下部还能查看到详细的每个进程及相应状态、等待、堆栈追踪等信息;
类 :显示“已装入类的数目”、“已卸载类的数目”信息;
VM概要 :显示服务器详细资源信息,包括:线程、类、OS、内存等;
MBean : 可在此页进行参数的配置。
补充三、JVM调优经验
1、首先考虑GC策略,不合适的GC策略会影响效率。可以在jvm执行参数中选择合适的垃圾收集器;
2、其次考虑内存设置,虽然现在线上业务系统基本物理内存都是够用的,不过物尽其用,调优就是争取让每M空间都发挥出最大的作用。内存的设置还是最直观见效的。
-Xmx500m ,-Xms500m
最大堆内存和最小堆内存,这两个值要设的一致,避免虚拟机还要动态的计算分配内存空间。
PS:堆也不是越大越好,堆太大带来的后果就是单次GC会较长。
-Xmn250m
新生代大小,非G1收集器可以设置这个值,G1的官方建议是不要显示分配新生代和老年代空间大小,因为G1会通过网格化内存来动态分配new/old区,官方认为不设置new size是最佳实践。
-Xss2m
每个线程的栈空间大小,默认值是1m,一般不需要设置,除非有递归方法存在可能会爆栈。
-XX:PermSize=128m,-XX:MaxPermSize=256m
JDK8之前永久代的空间设置,Spring框架了大量依赖AOP的实现都用的动态代理生成字节码,所以设个最大值求保险。
不过JDK8之后取消了永久代,改为元空间(MetaSpace),这块属于本地内存,理论上可以利用系统剩余的所有内存,不过跑了多个实例的话还是要设置一下为妙:-XX:MetaspaceSize=128m,-XX:MaxMetaspaceSize=256m
-XX:MaxDirectMemorySize=128m
这个属于对外内存,可以合理控制大小。Heap区总内存减去一个Survivor区的大小,不宜过大,否则可能
heap size + Direct Memory Size
把物理内存耗光。
-XX:SurvivorRatio=7
默认是8,新生代中Eden与Survivor的比值,过大的话可能Survivor存不下临时对象而频繁触发分配担保。可以根据GC日志看实际情况。
3、监控输出
监控参数还是需要的,线上偶尔OOM了时快速定位解决。
-XX:+HeapDumpOnOutOfMemoryError,-XX:HeapDumpPath={path}
OOM的时候会输出dump快照到{path}目录,只需要指向目录,文件名JVM会保持唯一性。
-XX:+PrintGCDetails,-Xloggc:logs/gc.log,-XX:+PrintGCTimeStamps,-XX:+PrintGCDateStamps
打印GC详细记录。