关注「Java视界」公众号,获取更多技术干货

常见 JVM垃圾回收器、内存分配策略、JVM调优

垃圾收集( Garbage Collection ,下文简称 GC),垃圾收集的历史远远比 Java久远。经过半个世纪的发展,今天的内存动态分配与内存回收技术已经相当成熟,一切看起来都进入了“自动化”时代,那为什么我们还要去了解垃圾收集和内存分配?答案很简单:当需要排查各种内存溢出、内存泄漏问题时,当垃圾收集成为系统达到更高并发量的瓶颈时,我们就必须对这些“自动化”的技术实施必要的监控和调节。
 

一、哪些内存需要回收?

回答这个问题前需要先了解JVM到底有哪些内存区域,可参考:https://blog.csdn.net/weixin_41231928/article/details/107094638

 程序计数器、虚拟机栈、本地方法栈3个区域随线程而生,随线程而灭,栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作。每一个栈帧中分配多少内存基本上是在类结构确定下来时就已知的(尽管在运行期会由即时编译器进行一些优化,但在基于概念模型的讨论里,大体上可以认为是编译期可知的),因此这几个区域的内存分配和回收都具备确定性,在这几个区域内就不需要过多考虑如何回收的问题,当方法结束或者线程结束时,内存自然就跟随着回收了。

那还剩下什么区域?还有方法区和堆!

对于方法区,上面链接对应的文章中也说过:方法区的内存回收目标主要是针对常量池的回收和对类型的卸载,即:废弃的常量和不再使用的类型,但总的来说方法区的回收触发条件比较苛刻,发生垃圾回收的频率比较低。举个常量池中字面量回收的例子,假如一个字符串“java”曾经进入常量池中,但是当前系统又没有任何一个字符串对象的值是“java”,换句话说,已经没有任何字符串对象引用常量池中的“java”常量,且虚拟机中也没有其他地方引用这个字面量。如果在这时发生内存回收,而且垃圾收集器判断确有必要的话,这个“java”常量就将会被系统清理出常量池。常量池中其他类(接口)、方法、字段的符号引用也与此类似。

在大量使用反射、动态代理、 CGLib 等字节码框架,动态生成 JSP 以及 OSGi 这类频繁自定义类加载器的场景中,通常都需要Java 虚拟机具备类型卸载的能力,以保证不会对方法区造成过大的内存压力。判定一个类型是否属于“不再被使用的类” 的条件就比较苛刻了。需要同时满足下面三个条件:
  1. 该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例。
  2. 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如OSGiJSP的重加载等,否则通常是很难达成的。
  3. 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方

垃圾回收的主要阵地是,为什么这么说?一个接口的多个实现类需要的内存可能会不一样,一个方法所执行的不同条件分支所需要的内存也可能不一样,只有处于运行期间,我们才能知道程序究竟会创建哪些对象,创建多少个对象,这部分内存的分配和回收是动态的。垃圾收集器所关注的正是这部分内存该如何管理。

二、垃圾收集第一步 —— 判断对象是死是活

垃圾收集器在对堆进行回收前,第一件事情就是要确定这些对象之中哪些还“ 存活 着,哪些已经 死去 (不会再被使用的对象)。

 

2.1 引用计数法

在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就认为是不可能再被使用的。
 
原理很简单,但 引用计数算法( Reference Counting)虽然占用了一些额外的内存空间来进行计数,最主要的它会产生决对象之间相互循环引用的问题 ,如下代码。这也是好多主流JVM不使用引用计数法管理内存的原因。
 
    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 可达分析法

这个算法的基本思路就是通过一系列称为“GC Roots” 的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“ 引用链 Reference Chain ),如果某个对象到 GC Roots 间没有任何引用链相连,或者用图论的话来说就是从GC Roots 到这个对象不可达时,则证明此对象是不可能再被使用的。
 

对象 object 5 object 6 object 7 虽然互有关联,但是它们到 GC Roots 是不可达的,因此它们将会被判定为可回收的对象。
 
其实这种方法的关键是先确定GC Root Set,即哪些对象是可以作为根对象。可以作为GC Root的对象包含:
  1. 在虚拟机栈(栈帧中的本地变量表)中引用的对象,如各线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
  2. 在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。
  3. 在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用
  4. 在本地方法栈中JNI(即通常所说的Native方法)引用的对象
  5. Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如NullPointExcepitonOutOfMemoryError)等,还有系统类加载器。
  6. 所有被同步锁(synchronized关键字)持有的对象。
  7. 反映Java虚拟机内部情况的JMXBeanJVMTI中注册的回调、本地代码缓存等。
除了这些固定的 GC Roots 集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象“ 临时性 地加入,共同构成完整 GC Roots 集合。譬如分代收集和局部回收(Partial GC ),如果只针对 Java 堆中某一块区域发起垃圾收集时必须考虑到内存区域是虚拟机自己的实现细节,某个区域里的对象完全有可能被位于堆中其他区域的对象所引用,这时候就需要将这些关联区域的对象也一并加入GC Roots 集合中去,才能保证可达性分析的正确性。

2.3 引用分类

无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象是否引用链可达,判定对象是否存活都和“ 引用 离不开关系。在JDK 1.2版之后,Java对引用的概念进行了扩充,将引用分为强引用(Strongly Re-ference)、软 引用(Soft Reference )、弱引用( Weak Reference )和虚引用( Phantom Reference 4 种,这 4 种引用强度依次逐渐减弱。
  • 强引用是最传统的“引用的定义,即类似“Object obj=new Object()”这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。哪怕内存不足时,JVM也会直接抛出OutOfMemoryError。
  • 软引用是用来描述一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。
  • 弱引用也是用来描述那些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。
  • 虚引用也称为 幽灵引用 或者 幻影引用 ”,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,它随时可能会被回收,在 JDK1.2 之后,用 PhantomReference 类来表示,通过查看这个类的源码,发现它只有一个构造函数和一个 get() 方法,而且它的 get() 方法仅仅是返回一个null,也就是说将永远无法通过虚引用来获取对象,虚引用必须要和 ReferenceQueue 引用队列一起使用。
1.强引用(StrongReference)
 
只要某个对象有强引用与之关联,JVM必定不会回收这个对象,即使在内存不足的情况下,JVM宁愿抛出OutOfMemory错误也不会回收这种对象。比如下面这段代码:
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在合适的时间就会回收该对象。

2.软引用(SoftReference)
 
在Java中用java.lang.ref.SoftReference类来表示。对于软引用关联着的对象,只有在内存不足的时候JVM才会回收该对象。因此,这一点可以很好地用来解决OOM的问题,并且这个特性很适合用来实现缓存:比如网页缓存、图片缓存等。下面是一个使用示例:
 
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进行垃圾回收,被弱引用关联的对象必定会被回收掉。不过要注意的是,这里说的被弱引用关联的对象是指只有弱引用与之关联,如果存在强引用同时与之关联,则垃圾回收时也不会回收该对象(软引用也是如此)。

4.虚引用(PhantomReference)
 
虚引用和前面的软引用、弱引用不同,它并不影响对象的生命周期。在java中用java.lang.ref.PhantomReference类表示。如果一个对象与虚引用关联,则跟没有引用与之关联一样,在任何时候都可能被垃圾回收器回收。
 
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问题?

假如有一个应用需要读取大量的本地图片,如果每次读取图片都从硬盘读取,则会严重影响性能,但是如果全部加载到内存当中,又有可能造成内存溢出,此时使用软引用可以解决这个问题。
设计思路是:用一个HashMap来保存图片的路径 和 相应图片对象关联的软引用之间的映射关系,在内存不足时,JVM会自动回收这些缓存图片对象所占用的空间,从而有效地避免了OOM的问题。在Android开发中对于大量图片下载会经常用到。
 

2.4 其他说明

假如一个对象被可达分析法法判定为不可达对象,该对象是否就一定会被回收呢?答案是否定的。

即使在可达性分析算法中判定为不可达的对象,也不是 非死不可 的,这时候它们暂时还处于 缓刑” 阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行可达性分析后发现没有与GC Roots 相连接的引用链,那它将会被第一次标记,随后进行一次筛选,筛选的条件是此对象是否有必要执行finalize() 方法。假如对象没有覆盖 finalize() 方法,或者 finalize() 方法已经被虚拟机调用过,就不会再回收该对象
 

三、垃圾收集算法

前面说了虚拟机怎么判定一个对象是死是活的基本理论,即引用计数法和可达性分析法。

垃圾收集算法基于这两种理论可以分为:引用计数式垃圾收集 和 追踪式垃圾收集 两种。但是主流的虚拟机一般都属于追踪式垃圾收集,因此此类型的垃圾收集器是重点。

3.1 分代收集理论

3.1.1 什么是分代?

常用的垃圾收集器的一致的设计原则:收集器应该将 Java 堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。如果一个区域中大多数对象都是朝生夕灭,难以熬过垃圾收集过程的话,那么把它们集中放在一起,每次回收时只关注如何保留少量存活而不是去标记那些大量将要被回收的对象,就能以较低代价回收到大量的空间;如果剩下的都是难以消亡的对象,那把它们集中放在一块,虚拟机便可以使用较低的频率来回收这个区域,这就同时兼顾了垃圾收集的时间开销和内存的空间有效利用。
具体的可以分为:
  • 新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集。
  • 老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集。目前只有CMS收集器会有单独收集老年代的行为。另外请注意“Major GC”这个说法现在有点混淆,在不同资料上常有不同所指,需按上下文区分到底是指老年代的收集还是整堆收集。
  • 混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集。目前只有G1集器会有这种行为。
  • 整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集。

(新生代(Young)、老年代(Old)是HotSpot虚拟机,也是现在业界主流的命名方式)

3.1.2 跨代引用

在新生代中,每次垃圾收集时都发现有大批对象死去,而每次回收后存活的少量对象,将会逐步晋升到老年代中存放。但是实际中分代时会有跨代问题:因为对象不是孤立的,所以对象间会存在跨代引用。比如新生代中的对象是完全有可能被老年代所引用,一旦新生代中出现了这种情况,虚拟机在回收时就必须在固定的GC Root之外再额外遍历整个老年代才能最终判定某个对象的“死活”,这样很显然有何分代理论相悖了,说好的分代之后每个分代单独垃圾收集呢?
为了解决这个矛盾,出现了一个重要的法则: 跨代引用相对于同代引用来说仅占极少数。存在互相引用关系的两个对象,是应该倾向于同时生存或者同时消亡的。举个例子,如果某个新生代对象存在跨代引用,由于老年代对象难以消亡,该引用会使得新生代对象在收集时同样得以存活,进而在年龄增长之后晋升到老年代中,这时跨代引用也随即被消除了。按照这个法则,就不应再为了少量的跨代引用去扫描整个老年代,也不必浪费空间专门记录每一个对象是否存在及存在哪些跨代引用,只需在新生代上建立一个全局的数据结构(该结构被称为“记忆集”,Remembered Set),这个结构把老年代划分成若干小块,标识出老年代的哪一块内存会存在跨代引用。此后当发生Minor GC时,只有包含了跨代引用的小块内存里的对象才会被加入到GCRoots进行扫描。虽然这种方法需要在对象改变引用关系(如将自己或者某个属性赋值)时维护记录数据的正确性,会增加一些运行时的开销,但比起收集时扫描整个老年代来说仍然是划算的。
 

3.1.3 分代的意义

这个分代收集理论是垃圾回收的第二个基础(第一个是判断对象死活的两个方法:引用计数和可达性分析),它使得垃圾收集器不需要每次都针对所有的堆内存区域进行收集,而只需要每次只回收其中某一个或者某些部分的区域即可,提升效率和准确性。同时,因为将堆内存分成了不同的年龄区域,每块区域是单独收集的,比如“Minor/Young GC”“Major/Old GC”“Full GC”就是分别针对年轻代、老年代及整个堆的垃圾回收,那就可以对不同的代采取不同的垃圾回收算法,主要有 “标记-复制算法” “标记-清除算法”“标记-整理算法”三种。
 

3.2  标记--清除 算法

算法分为 标记 清除 ”两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。标记过程就是对象“生死”的判定过程。
 
标记 -清除算法是最早且最基础的垃圾收集算法,其他的垃圾回收算法大多以此算法为基础进行改进。说到改进那就说明这个算法是有缺陷的,不然还改进干啥?标记 - 清除算法主要有两个缺点:
  1. 第一个是执行效率不稳定,如果Java堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低;
  2. 内存空间的碎片化问题,标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

 

3.3  标记--复制 算法

也叫复制算法,主要针对标记--清除算法面对大量可回收对象时执行效率的不足进行了改进。

标记--复制 算法将内存按容量划分为大小相等的两块,每次只使用其中的一块。当其中一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。

如果Java堆中包含大量对象,而且其中大部分是需要被回收的,标记--复制 算法只需要将少数的存活对象复制到另一块内存即可,内存复制开销很小,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可。

所以 标记--复制 算法的优点是:1)面对大量要回收的对象时执行效率高;2)没有内存碎片产生。那它有没有缺陷呢?可定有啊,这种复制回收算法的代价是将可用内存缩小为了原来的一半,太浪费空间。其次,在面对堆中有大量的对象,且多数为不可回收的对象时,那复制的开销就会变大。所以 标记--复制 比较适用于年轻代的垃圾收集器采用,不适合老年代的垃圾收集器采用。

此外,还存在”内存担保“问题:这里是按1:1的比例划分内存的,但实际上,比如新生代中的对象有98%熬不过第一轮收集,那就没必要再按这个比例来划分内存,可以适当缩小保留区域的内存,因为毕竟存活下来的对象不多,不需要那么多内存来保存极少活下来的对象。

 

3.4  标记--整理 算法

标记 - 复制算法在对象存活率较高时就要进行较多的复制操作,效率将会降低。更关键的是,如果不想浪费50% 的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都 100% 存活的极端情况,所以在老年代一般不能直接选用这种算法。
 
针对老年代对象的存亡特征,诞生了“标记-整理”(Mark-Compact)算法,其的标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。标记-清除算法与标记-整理算法的本质差异在于前者是一种非移动式的回收算法,而后者是移动式的。
 

是否移动回收后的存活对象是一项优缺点并存的风险决策:如果移动存活对象,尤其是在老年代这种每次回收都有大量对象存活区域,移动存活对象并更新 所有引用这些对象的地方将会是一种极为负重的操作,而且这种对象移动操作必须全程暂停用户应用
程序才能进行。如果跟标记-清除算法那样完全不考虑移动和整理存活对象的话,弥散于堆中的存活对象导致的
空间碎片化问题就只能依赖更为复杂的内存分配器和内存访问器来解决。
 
基于以上两点,是否移动对象都存在弊端,移动则内存回收时会更复杂,不移动则内存分配时会更复杂。从垃圾收集的停顿时间来看,不移动对象停顿时间会更短,甚至可以不需要停顿,但是从整个程序的吞吐量(使用垃圾收集的用户程序或 用户线程)来看,移动对象会更划算。
 
另外,还有一种 解决方案可以不在内存分配和访问上增加太大额外负担,做法是让虚拟机平时多数时间都采用标记- 清除算法,暂时容忍内存碎片的存在,直到内存空间的碎片化程度已经大到影响对象分配时,再采用标记- 整理算法收集一次,以获得规整的内存空间。
 
 

四、经典垃圾回收器

垃圾收集算法是内存回收的方法论,垃圾收集器就是内存回收的实践者。垃圾收集器的历史比java还要悠久,因此有很多的经典的垃圾回收器,但好多已经年代久远,这里主要介绍两种:CMS(Concurrent Mark Sweep) 和 G1(Garbage First)。
 

4.1 CMS收集器

Concurrent Mark Sweep翻译过来就是:并发-标记-清除。这几个关键字一出,想必已经知道CMS背后大致的“故事”。
 
CMS Concurrent Mark Sweep )收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java 应用集中在互联网网站或者基于浏览器的 B/S 系统的服务端上,这类应用通常都会较为关注服务的响应速度,希望系统停顿时间尽可能短,以给用户带来良好的交互体验。CMS收集器就非常符合这类应用的需求。CMS收集器是老年代的垃圾收集器,一般情况下会有ParNew来配合执行(默认情况下也是ParNew),ParNew也是使用并行的算法来执行年轻代的回收。
 
CMS基于 标记--清除 算法实现,整个过程分为四步:
  • 1)初始标记
  • 2)并发标记
  • 3)重新标记
  • 4)并发清除

可以看出CMS中重点是标记,其中初始标记、重新标记这两个步骤仍然需要“Stop The World”,也就是需要暂停所有线程,那这样怎么还说CMS是响应快速的呢?那是因为初始标记仅仅只是标记一下GC Root能直接关联到的对象,速度很快;并发标记阶段就是从GC Root的直接关联对象开始遍历整个对象图的过程,这个过程虽然很耗时但却不需要停顿所有用户线程,用户线程可以和垃圾收集线程一起并发运行;重新标记阶段则是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间通常会比初始标记阶段稍长一些,但也远比并发标记阶段的时间短;最后是并发清除阶段,清理删除掉标记阶段判断的已经死亡的 对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的。

在整个过程中耗时最长的并发标记和并发清除阶段中,垃圾收集器线程都可以与用户线程一起工作,所以从总体上来说,CMS 收集器的内存回收过程是与用户线程一起并发执行的,所以有灵敏的响应。

 

优缺点:

优点:并发收集、低停顿。

缺点:(1)CMS收集器对处理器资源非常敏感。如果应用本来的处理器负载就很高,还要分出一半的运算能力去执行收集器线程,就可能导致用户程序的执行速度忽然大幅降低。(2)CMS是一款基于“标记-清除”算法实现的收集器,收集结束时会有大量空间碎片产生。

4.2 Garbage First收集器 -- G1

G1 收集器才被 Oracle 官方称为 全功能的垃圾收集器” Fully-Featured Garbage Collector )。
 
G1 从整体来看是基于 标记 - 整理 算法实现的收集器,但从局部(两个 Region之间)上看又是基于“ 标记 - 复制 算法实现。
 
G1 收集器出现之前的所有其他收集器,包括CMS 在内,垃圾收集的目标范围要么是整个新生代( Minor GC ),要么就是整个老年代(Major GC ),再要么就是整个 Java 堆( Full GC )。而 G1 跳出了这个樊笼,它可以面向堆内存任何部分来组成回收集(Collection Set ,一般简称 CSet )进行回收, 衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大 ,这就是G1 收集器的 Mixed GC 模式。
 
G1 开创的基于 Region 的堆内存布局是它能够实现这个目标的关键。虽然 G1 也仍是遵循分代收集理论设计的,但其堆内存的布局与其他收集器有非常明显的差异:G1 不再坚持固定大小以及固定数量的分代区域划分,而是把连续的Java 堆划分为多个大小相等的独立区域( Region ),每一个 Region 都可以 根据需要,扮演新生代的 Eden 空间、 Survivor 空间,或者老年代空间。收集器能够对扮演不同角色的Region采用不同的策略去处理,这样无论是新创建的对象还是已经存活了一段时间、熬过多次收集的旧对象都能获取很好的收集效果。
 
Region 中还有一类特殊的 Humongous 区域,专门用来存储大对象。 G1认为只要大小超过了一个Region容量一半的对象即可判定为大对象。每个Region的大小可以通过参数-XX:G1HeapRegionSize设定,取值范围为1MB~32MB,且应为2的N次幂。而对于那些超过了整个Region容量的超级大对象,将会被存放在N个连续的Humongous Region之中,G1的大多数都把Humongous作为老年代的一部分来进行看待。
 
虽然 G1 仍然保留新生代和老年代的概念,但新生代和老年代不再是固定的了。 它将Region作为单次回收的最小单元,即每次收集到的内存空间都是Region大小的整数倍,这样可以避免在整个Java 堆中进行全区域的垃圾收集。更具体的处理思路是让 G1 收集器去跟踪各个 Region 里面的垃圾堆积的“ 价值 大小,然后在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间(使用参数-XX MaxGCPauseMillis 指定,默认值是200 毫秒),优先处理回收价值收益最大的那些 Region ,这也就是 “Garbage First” 名字的由来。 这种使用Region 划分内存空间,以及具有优先级的区域回收方式,保证了 G1 收集器在有限的时间内获取尽可能高的收集效率。
 
G1 收集器的运作过程大致可划分为以下四个步骤:
  • 1)初始标记:仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。这个阶段需要停顿线程,但耗时很短,而且是借用进行Minor GC的时候同步完成的,所以G1收集器在这个阶段实际并没有额外的停顿。
  • 2)并发标记:从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。对象图扫描完成以后,还要重新处理SATB记录下的在并发时有引用变动的对象。
  • 3)最终标记:主要修正在并发标记阶段因为用户线程继续运行而导致标记记录产生变动的那一部分对象的标记记录。对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的SATB记录。
  • 4)筛选回收:更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行完成的。
G1 收集器除了并发标记外,其余阶段也是要完全暂停用户线程的,它并非纯粹地追求低延迟。上面的步骤还有需要解释的地方:
TAMS和SATB。
 
用户线程改变对象引用关系时,必须保证其不能打破原本的对象图结构,导致标记结果出现错误 CMS 收集器采用增量更新算法实现,而 G1收集器则是通过原始快照(SATB )算法来实现的。此外,垃圾收集对用户线程的影响还体现在回收过程中新创建对象的内存分配上,程序要继续运行就肯定会持续有新对象被创建,G1 为每一个 Region 设计了两个名为TAMS Top at Mark Start )的指针,把 Region 中的一部分空间划分出来用于并发回收过程中的新对象分配,并发回收时新分配的对象地址都必须要在这两个指针位置以上。G1 收集器默认在这个地址以上的对象是被隐式标记过的,即默认它们是存活的,不纳入回收范围。与CMS 中的“Concurrent Mode Failure” 失败会导致 Full GC 类似,如果内存回收的速度赶不上内存分配的速度,G1收集器也要被迫冻结用户线程执行,导致 Full GC 而产生长时间 “Stop The World”
 

补充一、如何打印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 )
 
 
 
怎么看:
 
 
可以在启动时,jvm 执行参数里面制定垃圾回收器的类型,主要的垃圾回收器如下:
串行收集器:
DefNew:是使用-XX:+UseSerialGC(新生代,老年代都使用串行回收收集器)。
并行收集器:
ParNew:是使用-XX:+UseParNewGC(新生代使用并行收集器,老年代使用串行回收收集器)或者-XX:+UseConcMarkSweepGC(新生代使用并行收集器,老年代使用CMS)。
PSYoungGen:是使用-XX:+UseParallelOldGC(新生代,老年代都使用并行回收收集器)或者-XX:+UseParallelGC(新生代使用并行回收收集器,老年代使用串行收集器)
garbage-first heap:是使用-XX:+UseG1GC(G1收集器)
 
例如我们指定垃圾回收器是G1:
 
 
 
 
 

补充二、如何使用JDK自带工具JConsole

jconsole.exe为jdk自带的监控工具,在JDK安装目录的bin文件夹下。
 
可以本地运行一个java程序,这时上面的本地进程中会检测到你运行的程序,连接后:
 

  概述 :记录了“堆内存使用情况”、“线程”、“类”、“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详细记录。

 

posted @ 2022-06-25 14:02  沙滩de流沙  阅读(99)  评论(0编辑  收藏  举报

关注「Java视界」公众号,获取更多技术干货