u06-垃圾回收

330 阅读12分钟

1. 垃圾回收机制

概念: java语言中一个显著的特点就是引入了垃圾回收机制,使c++程序员最头疼的内存管理的问题迎刃而解,由于有个GC,java中的对象不再有作用域的概念,只有对象的引用有作用域,垃圾回收可以有效的防止内存泄漏,有效的使用空闲的内存。

  • 所谓程序的运行,其实就是变量不断地在内存中的申请领地,进行活动,最后交还领地给JVM内存的一个过程,假设程序最后不交还领地,则会导致内存垃圾越来越多。
  • Java对内存垃圾的处理方式是全自动的,JDK提供了一个垃圾回收员(Garbage Collection),它会不定时跑到你的程序中去回收内存垃圾,消除了程序员手动清理垃圾的烦恼。

1.1 JVM栈的GC

概念: 内存的划分中,程序计数器、JVM栈和本地方法栈都是随线程生和死的,栈中的栈帧随着方法的调用有序的进栈和出栈,每个栈帧上分配的内存大小在类结构确定时就已知了,所以这些区域内存的分配和回收都是具有确定性的,很容易回收,当方法调用结束或者线程结束,占用的内存就可以被回收。

1.2 方法区的GC

概念:

  • 虽然JVM规范中没有要求对方法区进行垃圾回收,但是一些虚拟机,如HotSpot虚拟机仍然实现了方法区的垃圾回收,方法区的垃圾主要是废弃的常量和无用的类。我们知道方法区中有一些常量池,如字符串常量池,如果系统中不存在引用常量池中常量的引用,那么在内存紧张的时候,这些常量就应该被废弃回收,常量池中的其他类(接口)、方法、字段、符号引用也是如此。
  • 判断常量是否应该被废弃的方法比较简单,而判断一个类是无用的类,则需要满足下面三个条件:
    • 该类的所有实例都已经被回收了,即Java堆内存中没有该类的对象实例。
    • 加载该类的类加载器ClassLoader已经被回收了。
    • 该类对应的java.lang.Class对象在任何地方都没有被引用,也即无法通过反射访问该类。
    • 但满足了上述这些条件,也不是说这个类就要被非回收不可,我们是可以通过设置虚拟机参数进行控制的。

1.3 堆的GC

概念: 在Java堆中,每个类需要的内存都可能不一样,一个方法中多个分支需要的内存也可能不一样,这些都只有在运行期才能知道创建哪些对象,所以这部分内存的分配和回收都是动态的,垃圾回收也主要是对这部分的内存进行回收。

我们可以使用代码来建议GC回收System.gc();

2. 垃圾判定算法

概念: 什么样的实例算是垃圾?判断一个实例对象的生死很难,就像有些人虽然活着,但其实早就死了,一些人虽然死了,但永远活在我们心中,文艺可以扯淡,但是编程必须严谨,所以我们必须要有判断实例对象生或者死的方法。

2.1 引用计数算法

概念:

  • 当一个对象被创建时,为这个对象实例分配一个变量,该变量计数设置为1:
    • 每当有一个地方引用它时,计数器加1。
    • 每当一个对它的引用失效时,计数器减1。
    • 当计数器的值为0时,就说明不存在对它的引用了,它就可以去死了(不是立刻死亡,而是等待GC啥时候心情好,就过来干死它)。
  • 一个对象被回收时,这个对象所引用的其他任何对象的引用计数器也会减一。
  • 优点:简单高效直接。
  • 缺点:无法检测和解决实例和实例之间的循环引用的问题,如实例A和实例B都是同一个类的实例,A中引用B,B中引用A,则A和B都不能被回收。

源码: /javase-oop/

  • src: c.y.gc.ReferenceCountDemo
/**
 * 建立循环引用,测试当前JDK版本是否使用了引用计数回收策略。
 *
 * 使用 `-verbose:gc -XX:+PrintGCDetails` 运行参数可以看到详细的GC情况,
 * 可以发现PSYoungGen空间变小,说明程序依然执行了GC,也说明jdk1.8使用的不是引用计数回收策略。
 *
 * @author yap
 */
public class ReferenceCountDemo {
    private ReferenceCountDemo field;

    public static void main(String[] args) {
        ReferenceCountDemo instanceA = new ReferenceCountDemo();
        ReferenceCountDemo instanceB = new ReferenceCountDemo();
        // 让两个实例循环引用,此时两个实例的引用计数均为1
        instanceA.field = instanceB;
        instanceB.field = instanceA;
        // 断开两个实例的堆栈联系,此时堆中的两个对象应为垃圾,但因为互相引用,永远无法被GC回收
        instanceA = null;
        instanceB = null;
        // 事实上内存在GC之后变小了,说明jdk8使用的不是引用计数的回收方式
        System.gc();
    }
}

2.2 可达性分析算法

概念: jdk1.8使用的是可达性分析算法,可达性分析算法也叫根搜索算法,是从离散数学中的图论引进而来的,算法把所有的引用关系看成是一张图,从根节点(GC-Root)开始向下搜索实例对象,搜索所走的路径称为是引用链,当一个对象从根节点开始找不到任何一条引用链时,就说明这个对象可被回收,可达性分析算法的本质就是判断某个实例是否有从GC-Root出发的,可达的引用链。

  • GC-Root是一个特殊的对象,且绝对不能被其他对象引用(所以不会出现循环引用的问题)。
    • 虚拟机栈(栈帧本地变量表)中引用的对象。
    • 方法区中静态属性引用的对象。
    • 方法区常量引用的对象。
    • 本地方法栈中(Native 方法)引用的对象。
  • finalize():在可达性分析算法中,即使是不可达的对象,也并非是要立即执行死刑,它们暂时处于死缓状态,然后被判断是否要执行这个实例的 finalize()
    • 不执行:如果实例没有重写Object类中的 finalize(),或者已经执行过一次 finalize() 且复活了一次,那么均被判定不执行 finalize(),直接被回收。
    • 执行:判定需要执行 finalize() 的实例会被加入到一个队列中,并且由JVM分配一个单独的线程来执行队列中这些实例中的 finalize(),如果某个实例的 finalize() 方法中,该实例突然被引用链上其他的可达实例关联了,那么这个实例就可以被移出这个即将回收的队列,从而死里逃生。

可达性分析算法图

image.png

2.3 引用强软弱虚

概念: 引用也是一个很模糊的概念,为了更加清晰的描述Java中的对象引用,在JDK1.2后,Java将引用分为4种,并且除了强引用外都有与之对应的Java类,都继承自Reference类,引用强度自上而下:

  • 强引用:new的时候就是强引用:
    • Object obj = new Object()
  • 软引用:指向一些有用,但非必须的实例引用,当内存特别紧张的时候才会回收这些对象,若回收后还是没有足够的内存,则OOM。
    • 对应 java.lang.ref.SoftReference,一般用于缓存设计。
  • 弱引用:指向一些有用,但非必须的实例引用,GC看到它,就会立刻将它干掉。
    • 对应 java.lang.ref.WeakReference,一般用于容器设计,如ThreadLocal。
  • 虚引用:又称幽灵引用或幻影引用,GC看到它,就会立刻将它干掉。
    • 一个实例是否被虚引用,完全影响它的生死,也无法用虚引用来获取这个实例,即无法使用get()。
    • 为一个实例关联虚引用之后,在这个实例被GC回收的时候,虚引用会被存储到一个队列中,然后返回给用户一个死亡通知。
    • 对应 java.lang.ref.PhantomReference,一般用于管理堆外内存,比如NIO的直接内存,你可以设计一个变量并为其添加虚引用,当虚引被回收的时候,你会收到一个通知,当收到这个通知的时候,你去利用别的手段回收对应的堆外内存。

源码: /javase-oop/

  • src: c.y.gc.StrongReferenceTest
/**
 * @author yap
 */
public class StrongReferenceTest {

    public static void main(String[] args) throws IOException {
        Demo demo = new Demo();
        demo = null;
        System.gc();
        // block the main thread...
        System.out.println(System.in.read());
    }

    static class Demo {
        @Override
        protected void finalize() {
            System.out.println("GC会调用finalize()...");
        }
    }
}
  • src: c.y.gc.SoftReferenceTest
/**
 * -Xms20M -Xmx20M
 *
 * @author yap
 */
public class SoftReferenceTest {
    public static void main(String[] args) throws InterruptedException {
        System.gc();
        TimeUnit.SECONDS.sleep(1L);
        SoftReference<byte[]> softReference = new SoftReference<>(new byte[1024 * 1024 * 10]);
        System.out.println(softReference.get() == null ? "be recycled" : "not recycled");
        System.gc();
        TimeUnit.SECONDS.sleep(1L);
        System.out.println(softReference.get() == null ? "be recycled" : "not recycled");
        byte[] bsB = new byte[1024 * 1024 * 11];
        System.out.println(softReference.get() == null ? "be recycled" : "not recycled");
    }
}
  • src: c.y.gc.WeekReferenceTest
/**
 * @author yap
 */
public class WeekReferenceTest {
    public static void main(String[] args) throws InterruptedException {
        WeakReference<Object> weakReference = new WeakReference<>(new Object());
        System.out.println(weakReference.get() == null ? "be recycled" : "not recycled");
        System.gc();
        TimeUnit.SECONDS.sleep(1L);
        System.out.println(weakReference.get() == null ? "be recycled" : "not recycled");
    }
}

3. 垃圾回收算法

3.1 Mark-Sweep

标记清除算法图

image.png 概念: Mark-Sweep算法,标记-清除算法,它是最基础的收集算法,后续的收集算法都是对它的改进。

  • 描述:从头到尾遍历内存区域,绿色标记0,黄色标记1,再次遍历,将所有1清除。
  • 优点:直接在原内存上进行操作,不需要占用额外的内存空间
  • 缺点:
    • 整理后内存不连续,产生内存碎片,可能会导致以后再需要分配较大对象的时候,无法找到足够的连续内存而不得不提前触发另一次GC动作。
    • 效率慢,标记和清除这两个过程的效率都不高。

3.2 Copy

复制算法图

image.png 概念: copy算法,复制算法

  • 描述:复制算法需要在内存中,再创建一块相同大小的区域,从头到尾遍历之前的区域,将所有已使用的内存移动到新区域中,然后删除之前的内存区域。
  • 优点:整理后没有碎片,复制代价小,速度快。
  • 缺点:
    • 需要额外开辟相同大小的内存空间。
    • 在对象存活率较高的时候,进行大量的复制操作的效率也是很低的,所以对于存活时间长的对象一般不使用这种收集算法。

3.3 Mark-Move

标记移动算法图

image.png 概念: Mark-Move算法,标记-移动算法。

  • 描述:从头到尾遍历区域,绿色标记0,黄色标记1,再次遍历,遇到0向前移动,遇到1直接清除。
  • 优点:整理后没有碎片,不需要双倍内存。
  • 缺点:但因在标记-清除算法的基础上增加了"移动",所以效率比标记-清除要低一些。

3.4 分代收集算法

image.png

分代收集算法图

概念: 分代收集指的是根据实例年龄的不同而进行不同方式的回收,堆内存分为新生代和老年代,默认内存大小比例是1:2,这个比例值是可以通过 -XX:NewRatio 运行参数来动态设置的。

  • 新生代 Young Generation:动态存储新new出来的对象,新生代又被划分为3个区域:伊甸园 Eden 和两个 Survivor 幸存区:Survivor fromSurvivor to
    • Eden:实例一开始都在Eden区产生,当Eden满了,JVM执行一次 minorGC,在整个新生代里面利用复制算法执行GC动作,80%-90%的实例会在这里直接死去,存活下来的幸存实例会被拷贝到某个Survivor区(假设为S1),且将幸存对象身上的计数器加1,Eden被清空的同时S2中存活下来的幸存实例也会被拷贝到S1,因为JVM规定必须保证至少有一个Survivor区是空的,另外,GC过程中年龄足够老的对象(计数器值过大)会直接进入老年代。
    • Survivor:达到某个条件时,会将大于Survivor区的一半相对年龄大的实例都移入老年代。
  • 老年代 Old Generation:存储年龄稍大的实例,主要由 MajorGC 负责GC工作。
    • MajorGC 采用标记清除或者标记移动的算法,回收速度比 minorGC 低10倍左右。
    • 达到某个条件时,JVM会执行一次 FullGC,整体大回收。

4. 垃圾收集实现

概念: 垃圾收集器是垃圾回收机制的具体实现,在JVM中,垃圾收集器可能存在一个或多个(单线程或多线程),它们在工作的时候,会短暂地暂停其他线程,这种情况被称为STW(Stop The Word),减少STW时间是优化垃圾回收机制的重要指标。

  • 垃圾收集器常见组合:
    • SerialGC = Serial + Serial Old,JVM客户端默认使用它。
    • ParNewGC = ParNew + Serial Old
    • ConcMarkSweepGC = ParNew + CMS + Serial Old
    • ParallelGC = PS Scavenge + Serial Old(PS MarkSweep),JVM服务端默认使用它。

4.1 垃圾收集器分类

概念: 垃圾收集器的种类很多:

  • Serial
    • 一个单线程的新生代收集器,采取复制算法实现。
    • 在单CPU环境下,因为没有线程切换的开销,效率最高,STW控制在在几十到几百毫秒内。
  • Serial Old
    • Serial收集器的老年代版本,也是一个单线程收集器,采取标记-整理算法。
    • 它有个别名叫 PS-MarkSweep
  • ParNew
    • Serial收集器的多线程版本,收集算法、STW、对象分配的规则、回收策略等都与Serial收集器完全一样。
    • ParNew收集器的优势是更充分的利用CPU资源(多线程),缺点是是在单CPU下,效果不一定会比Serial好。
  • PS Scavenge 收集器:
    • 一个多线程的新生代收集器,采取复制算法实现。
    • 对比ParNew收集器,它可以更加精准的控制CPU的吞吐量和STW的时间。
  • PS Old
    • PS-Scavenge收集器的老年代版本,采取标记-清除算法。
    • PS-Scavenge + PS-Old的组合,对于多CPU环境,吞吐量要求高的环境是很适合的。
  • CMS
    • CMS(Concurrent Mark Sweep),采取标记-清除算法,设计理念是尽可能地缩短STW时间。
    • CMD适用于一些特别重视响应速度的项目,但缺点是会产生大量的空间碎片,(用时间换空间)。
  • G1
    • 目前最高端的收集器,采取标记-整理算法,不会产生内存碎片,并且也可以精准地控制STW的时间。
    • 对于新生代和老年代都是适用的,优先回收垃圾最多的区域。

4.2 垃圾收集器查看

概念: 不同版本的JDK选择的垃圾收集器也可能不同,JDK8使用的是 ParallelGC 包括新生代收集器 PS Scavenge 和老年代收集器 PS MarkSweep

源码: /javase-oop/

  • src: c.y.gc.GarbageCollectorTest
/**
 * @author yap
 */
public class GarbageCollectorTest {
    @Test
    public void myGarbageCollector() {
        for (GarbageCollectorMXBean e : ManagementFactory.getGarbageCollectorMXBeans()) {
            System.out.println(e.getName());
        }
    }
}

也可以直接使用java -XX:+PrintCommandLineFlags -version命令查看垃圾回收器。