03-JVM-垃圾回收算法

1、JVM内存分配与回收

1.1 对象优先在Eden区进行分配

  堆中存储的对象,大多数情况下优先存储在Eden区,当Eden区存满没有足够的空间的时候,虚拟机将进行一次minorGC。当满足一定条件以后,就会进行FullGC。垃圾回收分为minorGC和fullGC两种,下面来看一下这两者的差别:

  • Minor GC/Young GC:发生在年轻代的垃圾回收,频率比较高,回收速度也比较快
  • Full GC/Major GC:一般回收老年代,年轻代,方法区的垃圾,Full GC的速度一般比Young GC慢10倍以上。

下面我们来看一段代码:(添加JVM运行参数:-XX:+PrintGCDetails)

情况一:
public
class GCTest { public static void main(String[] args) { byte[] allocation1,allocation2,allocation3; // allocation1=new byte[1000*1024]; //allocation2=new byte[80000*1024]; // allocation3=new byte [1000*1024]; } 运行结果: Heap PSYoungGen total 35840K, used 4301K [0x00000000d8380000, 0x00000000dab80000, 0x0000000100000000) eden space 30720K, 14% used [0x00000000d8380000,0x00000000d87b3720,0x00000000da180000) from space 5120K, 0% used [0x00000000da680000,0x00000000da680000,0x00000000dab80000) to space 5120K, 0% used [0x00000000da180000,0x00000000da180000,0x00000000da680000) ParOldGen total 81920K, used 0K [0x0000000088a00000, 0x000000008da00000, 0x00000000d8380000) object space 81920K, 0% used [0x0000000088a00000,0x0000000088a00000,0x000000008da00000) Metaspace used 3510K, capacity 4498K, committed 4864K, reserved 1056768K class space used 387K, capacity 390K, committed 512K, reserved 1048576K
情况二:
public class GCTest { public static void main(String[] args) { byte[] allocation1,allocation2,allocation3;
    allocation1=new byte[9000*1024];
    allocation2=new byte[18000*1024];
  } 

运行结果:

Heap

PSYoungGen total 35840K, used 30720K [0x00000000d8380000, 0x00000000dab80000, 0x0000000100000000)

eden space 30720K, 100% used [0x00000000d8380000,0x00000000da180000,0x00000000da180000)
from space 5120K, 0% used [0x00000000da680000,0x00000000da680000,0x00000000dab80000)
to space 5120K, 0% used [0x00000000da180000,0x00000000da180000,0x00000000da680000)
ParOldGen total 81920K, used 0K [0x0000000088a00000, 0x000000008da00000, 0x00000000d8380000)
object space 81920K, 0% used [0x0000000088a00000,0x0000000088a00000,0x000000008da00000)
Metaspace used 3510K, capacity 4498K, committed 4864K, reserved 1056768K
class space used 387K, capacity 390K, committed 512K, reserved 1048576K


情况三:
    public static void main(String[] args) {
byte[] allocation1,allocation2,allocation3;
    allocation1=new byte[9000*1024];
    allocation2=new byte[18000*1024];
    allocation3=new byte [1000*1024];

}

运行结果:

[GC (Allocation Failure) [PSYoungGen: 30687K->936K(35840K)] 30687K->27944K(117760K), 0.0087232 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]
Heap
PSYoungGen total 35840K, used 2550K [0x00000000d8380000, 0x00000000dc980000, 0x0000000100000000)
eden space 30720K, 5% used [0x00000000d8380000,0x00000000d8513b40,0x00000000da180000)
from space 5120K, 18% used [0x00000000da180000,0x00000000da26a020,0x00000000da680000)
to space 5120K, 0% used [0x00000000dc480000,0x00000000dc480000,0x00000000dc980000)
ParOldGen total 81920K, used 27008K [0x0000000088a00000, 0x000000008da00000, 0x00000000d8380000)
object space 81920K, 32% used [0x0000000088a00000,0x000000008a460020,0x000000008da00000)
Metaspace used 3492K, capacity 4496K, committed 4864K, reserved 1056768K
class space used 385K, capacity 388K, committed 512K, reserved 1048576K

从以上代码可以看出,第一个代码什么都没有执行的时候eden区域已经使用了14%的内存。当我们执行第二段代码的时候,先给变量分配到eden区去了。当我们执行第三段代码的时候发现,老年代区域也已经使用了32%了。从情况2到情况3的原因是因为,在情况二的时候eden区域已经到了100%,当在情况三的时候放开allocation3放出,此时eden区域已经满了,会触发一次minorGC,将allocation3放入survior区域,但是发现survivor区域放不下,于是需要提前把年轻代的移入到老年代,老年代可以放下部分allocation1,所以不会触发fullGC.如果对象能够存在eden区的话,还是会在eden区域分配内存。

以下代码就可以验证:

public class GCTest {
    public static void main(String[] args) {
        byte[] allocation1,allocation2,allocation3;
       allocation1=new byte[9000*1024];
       allocation2=new byte[18000*1024];
       allocation3=new byte [1000*1024];
       byte[] allocation4=new byte[2000*1024];
    }

运行结果:
[GC (Allocation Failure) [PSYoungGen: 30687K->936K(35840K)] 30687K->27944K(117760K), 0.0091469 secs] [Times: user=0.05 sys=0.00, real=0.01 secs] 
Heap
 PSYoungGen      total 35840K, used 4839K [0x00000000d8380000, 0x00000000dc980000, 0x0000000100000000)
  eden space 30720K, 12% used [0x00000000d8380000,0x00000000d874fe20,0x00000000da180000)
  from space 5120K, 18% used [0x00000000da180000,0x00000000da26a020,0x00000000da680000)
  to   space 5120K, 0% used [0x00000000dc480000,0x00000000dc480000,0x00000000dc980000)
 ParOldGen       total 81920K, used 27008K [0x0000000088a00000, 0x000000008da00000, 0x00000000d8380000)
  object space 81920K, 32% used [0x0000000088a00000,0x000000008a460020,0x000000008da00000)
 Metaspace       used 3502K, capacity 4498K, committed 4864K, reserved 1056768K
  class space    used 387K, capacity 390K, committed 512K, reserved 1048576K

1.2大对象直接进入老年代

  大对象就是说需要大量连续内存空间的对象。设置大对象大小的JVM参数如下:-XX:PretenureSizeThreshold,对象超过设置的该值就会直接进入老年代。这样做的原因是为了避免大对象分配内存的时候因为复制操作而降低效率。

package com.jvm.jvmCourse3;

public class GCTest {
    public static void main(String[] args) {
        byte[] allocation1,allocation2,allocation3;
       allocation1=new byte[9000*1024];
       //allocation2=new byte[180000*1024];
       //allocation3=new byte [1000*1024];
      // byte[] allocation4=new byte[2000*1024];
    }
}
//运行结果:
Heap
 PSYoungGen      total 35840K, used 13301K [0x00000000d8380000, 0x00000000dab80000, 0x0000000100000000)
  eden space 30720K, 43% used [0x00000000d8380000,0x00000000d907d730,0x00000000da180000)
  from space 5120K, 0% used [0x00000000da680000,0x00000000da680000,0x00000000dab80000)
  to   space 5120K, 0% used [0x00000000da180000,0x00000000da180000,0x00000000da680000)
 ParOldGen       total 81920K, used 0K [0x0000000088a00000, 0x000000008da00000, 0x00000000d8380000)
  object space 81920K, 0% used [0x0000000088a00000,0x0000000088a00000,0x000000008da00000)
 Metaspace       used 3504K, capacity 4498K, committed 4864K, reserved 1056768K
  class space    used 387K, capacity 390K, committed 512K, reserved 1048576K

//放开allocation2,可以看到直接放到了老年代
package com.jvm.jvmCourse3;

public class GCTest {
    public static void main(String[] args) {
        byte[] allocation1,allocation2,allocation3;
       allocation1=new byte[9000*1024];
       allocation2=new byte[180000*1024];
       //allocation3=new byte [1000*1024];
      // byte[] allocation4=new byte[2000*1024];
    }
}

//运行结果如下:
Heap
 PSYoungGen      total 35840K, used 13301K [0x00000000d8380000, 0x00000000dab80000, 0x0000000100000000)
  eden space 30720K, 43% used [0x00000000d8380000,0x00000000d907d730,0x00000000da180000)
  from space 5120K, 0% used [0x00000000da680000,0x00000000da680000,0x00000000dab80000)
  to   space 5120K, 0% used [0x00000000da180000,0x00000000da180000,0x00000000da680000)
 ParOldGen       total 262144K, used 180000K [0x0000000088a00000, 0x0000000098a00000, 0x00000000d8380000)
  object space 262144K, 68% used [0x0000000088a00000,0x00000000939c8010,0x0000000098a00000)
 Metaspace       used 3510K, capacity 4498K, committed 4864K, reserved 1056768K
  class space    used 387K, capacity 390K, committed 512K, reserved 1048576K

1.3长期存活的对象进入老年代

  JVM对参数进行分代管理,就需要区分哪些对象需要放在年轻代,哪些对象需要放在老年代,为了区分,JVM给对象设置了一个参数age。当对象在eden区域出生,经过一次minorGC后,能够在survivor区域存活,将年龄设置为1,age=1。对象在Survivor区域中每经历一次minorGC年龄就会增长1(+1),当达到一定年龄的时候(默认是15)就会将对象移动到老年代中去。法定年龄的设置参数如下:

-XX:MaxTenuringThreshold

1.4对象动态年龄判断

当前对象的Survivor区域里,有一批对象的总大小大于Survivor区域的50%,那么大于该批对象中年龄最大的的对象就可以直接进入老年代了。比如在Survivor区域中,age8+age9+age10+agen的一批对象大小的和大于Survivor区域的50%,那么age>n的对象就可以进入老年代了。这样做是为了让可能长期存活的对象尽早的进入老年代。对象动态年龄判断主要是发生minorGC之后。

1.5minorGC后存活的对象在Survivor区域存放不下

在本文刚开始的情况三种已经有这样的案例。在这种情况下可能会把存活对象的部分移动到老年代,部分留在Survivor区域。

1.6老年代空间分配担保机制

在进行minor gc之前,JVM都会计算一下老年代的剩余空间是否小于年轻代里所有对象的大小(包括垃圾对象)的和,如果小于,就会看参数“-XX:-HandlePromotionFailure”是否进行了设置,如果设置了,就会查看老年代的可用内存是否小于每一次minor gc后进入老年代的对象的平均大小,如果参数没有设置或者老年代可用空间小于每次minor gc后进入老年代对象的平均大小,那么就会触发 full gc,对老年代年轻代一起进行回收,如果回收完还是没有足够的空间存放新对象就会产生OOM.具体的流程如下图

1.7 Eden与Survivor区默认8:1:1

  默认情况下Eden区域与Survivor区域的默认比例是8:1:1。新增对象存储到堆的时候先分配到Eden区域,当Eden区域满的时候就会触发MinorGC,MinorGC在进行回收的时候会将还存活的对象放到Survivor区域去,基本上MinorGC能清除99%的垃圾,因此Survivor区域够用即可,Eden区域尽量的大。

  JVM默认有这个参数-XX:+UseAdaptiveSizePolicy,会导致这个比例自动变化,如果不想这个比例有变化可以设置参数-XX:-UseAdaptiveSizePolicy。

以上了解到了JVM内存区域的分配机制,下面我们来看一下如何判断对象可以被回收了。

2、怎么判断对象可以被回收

2.1引用计数法

  顾名思义就是给对象添加一个引用计数器,当有地方引用的时候就+1,不引用了就-1,当引用计数器为0的时候就可以回收该对象了。该方法简单高效,但是主流的虚拟机并没有选择这样的方法来管理内存,最主要的原因就是它很难解决对象之间相互循环引用的问题。如下代码所示

public class ReferenctToMe {
    Object tome=null;

    public static void main(String[] args) {
        ReferenctToMe a=new ReferenctToMe();
        ReferenctToMe b=new ReferenctToMe();
        a.tome=b;
        b.tome=a;
    }
}

对象啊a和b相互引用,除此之外再无关联,都为彼此引用,所以引用计数器都不为0,因此垃圾回收器无法对其进行回收。

 2.2可达性分析算法

通过一系列的"GC ROOT"的对象作为起点,从这个节点开始向下搜索,能找到的对象就是非垃圾对象,其余的未标记的对象就是垃圾对象可以进行回收。

GC Root 根节点:线程栈的局部变量,静态变量,本地方法栈的变量等等。

 2.3常见引用类型

java的引用一般分为强引用、软引用,弱引用,虚引用。

强引用:一般普通变量的引用 如:private static User user=new User();

软引用:用SoftReference软引用类型包裹的对象,正常情况下不会被回收,但是在GC完成以后发现没有新的内存空间存放对象的时候,就会把软引用回收掉,如:

SoftReference<User> user=new SoftReference<>(new User());软引用通常用来实现内存敏感的高速缓存。
软引用在实际中有重要的应用,例如浏览器的后退按钮。按后退时,这个后退时显示的网页内容是重新进行请求还是从缓存中取出呢?这就要看具体的实现策略了。
(1)如果一个网页在浏览结束时就进行内容的回收,则按后退查看前面浏览过的页面时,需要重新构建
(2)如果将浏览过的网页存储到内存中会造成内存的大量浪费,甚至会造成内存溢出
弱引用:将对象用WeakReference软引用类型的对象包裹,弱引用跟没引用差不多,GC会直接回收掉,很少用 ,如:
public static WeakReference<User> user = new WeakReference<User>(new User());

2.4 finalize()方法最终判定对象是否存活

  在可达性分析算法中,没有找到GC Root根的不可达对象,也不一定是非死不可,此时它们处于死缓的状态,要判定对象死刑的话,至少要经历再次标记。标记的前提是对象在进行可达性分析后发现没有与GC Root相连接的引用链。

1、第一次标记并进行一次筛选

筛选的时候是看该对象是否有必要执行finalize()方法,当对象没有覆盖finalize方法,对象将被直接回收。

2、第二次标记

如果这个对象覆盖了finalize方法,该方法就是这个对象死里逃生的唯一机会。要成功的利用finalize()方法完成死里逃生的话,只要重新与引用链上的任何一个对象建立关联就可以了,比如把自己赋值给某个类变量或者对象的成员变量,那么在第二次标记的时候将它移出“垃圾回收”集合。如果这个对象没有利用finalize()成功的话就只能被回收了。

package com.jvm;

import com.jvm.jvmCourse3.OOMTest;

public class User {
    
    private int id;
    private String name;

    byte[] a = new byte[1024*100];

    public User(){}

    public User(int id, String name) {
        super();
        this.id = id;
        this.name = name;
    }
    protected  void finalize(){
        //OOMTest.list.add(this);
        System.out.println("资源关闭,user "+id+"即将被回收");
    }
    public int getId() {
        return id;
    }
    public void setId(int id) {
        this.id = id;
    }
    public String getName() {
        return name;
    }
    public void setName(String name) {
        this.name = name;
    }

}

package com.jvm.jvmCourse3;

import com.jvm.User;

import java.util.ArrayList;
import java.util.List;
import java.util.UUID;

public class OOMTest {
   //public static List<Object> list=new ArrayList<>();
    public static void main(String[] args) {
        List<Object> list=new ArrayList<>();
        int i=0;
        int j=0;
        while(true){
            list.add(new User(i++, UUID.randomUUID().toString()));
            new User(j--,UUID.randomUUID().toString());
        }
    }
}

运行结果:
资源关闭,user -15169即将被回收
资源关闭,user -15171即将被回收
资源关闭,user -15170即将被回收
资源关闭,user -15172即将被回收
资源关闭,user -15173即将被回收
资源关闭,user -15174即将被回收
资源关闭,user -15175即将被回收
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
    at com.jvm.User.<init>(User.java:8)
    at com.jvm.jvmCourse3.OOMTest.main(OOMTest.java:15)

根据上图的运行结果可以看到user id 都是负数,正数的都是被引用了,放在list里面,但是凭空new出来的游离的对象没有GC Root根,并且没有覆盖finalize()方法因此第一次标记的时候就会被回收。要让j--的对象如何实现自我拯救呢?将上述代码中的红色部分解除注释即可。

2.5如何判断一个类是无用的类

在方法区主要回收的就是无用的类,那么如何判断一个类是无用的类呢?判断一个类是无用的类需要同时满足一下3个条件才能算无用的类:

  • 该类所有的实例都已经被回收,也就是java堆中不存在任何该类的实例。
  • 加载该类的ClassLoader已经被回收
  • 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

3、垃圾收集算法

 3.1标记清除算法

顾名思义就是分为两个阶段“标记”和“清除”两个阶段,先标记需要回收的对象,标记完成后,统一对标记的对象进行清除。是最基础的收集算法,但是存在两个明显的问题。

  • 效率问题(效率不是很高,跟复制算法比较)
  • 效率问题(会产生大量的空间碎片)

 

 3.2复制算法

为了提高效率,出现了复制算法。它是将内存分为大小相同的两块,每次使用其中一块,当使用的那一块内存满了时候,就将还存活的对象移动到未使用的那一块内存中去,然后对满了的这块内存整体清除,这样就是每次都对内存的一半进行回收。具体如下图:

 3.3 标记整理算法

标记整理算法的标记过程与“标记-清除”算法一致,只是后续清除的时候不是像“标记-清除”算法一样直接清除,而是将存活对象向一端移动,然后清除端边界以外的内存,如下图所示:

 3.4分代收集算法

分代收集算法就是根据对象存活周期不同将内存分为几块。一般把java堆内存分为新生代和老年代,这样就可以根据每个代的特点选择不同的垃圾收集算法。年轻代中每次收集都有大量的对象(99%)死亡,可以选择复制收集算法,付出少量的对象复制成本就可以完成每次垃圾回收,但是在老年代的对象存活得比较久,而且没有额外的空间对其进行分配担保,所以必须选择“标记-清除” 或者“标记-整理”算法进行垃圾回收。“标记-清除” 或者“标记-整理”算法 比“复制算法”慢10倍以上。

对JVM优化就是尽可能让对象都在新生代里分配和回收,别让太多对象频繁进入老年代,避免频繁对老年代进行垃圾回收,同时给系统充足的内存大小,避免新生代频繁的进行垃圾回收。
原文地址:https://www.cnblogs.com/yatou-blog/p/11972504.html