JVM入门-垃圾回收相关概念

Posted by 小拳头 on Monday, February 1, 2021

System.gc()

  • 在默认情况下, 通过System.gc()或者Runtime.getRuntime().gc()的调用, 会显式触发Full GC, 同时对老年代和新生代进行回收, 尝试释放被丢弃对象占用的内存.
  • System.gc()有免责声明, 只是提醒垃圾收集器运行, 不保证发生GC.
  • JVM实现者可以通过system.gc()调用来决定JVM的GC行为, 但一般垃圾回收应该是自动进行的.
public class SystemGCTest {
    public static void main(String[] args) {
        new SystemGCTest();
        System.gc(); //提醒jvm的垃圾回收器执行gc, 但是不确定是否马上执行GC. 多次运行才会调用重写的finalize()
        //System.gc()调用了Runtime.getRuntime().gc()
        System.runFinalization(); //强制调用引用的对象的finalize()方法, finalize()方法一定会被调用
    }

    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("SystemGCTest 重写了finalize()");
    }
}
/**
* -XX: +printGCDetails
*/
public class LocalVarGC {
    public void localvarGC1() {
        byte[] buffer = new byte[10 * 1024 * 1024]; //10MB
        System.gc(); //不会被回收, buffer在Young GC不变, FullGC时被放入老年代
    }

    public void localvarGC2() {
        byte[] buffer = new byte[10 * 1024 * 1024];
        buffer = null;
        System.gc(); //输出: 正常被回收
    }

    public void localvarGC3() {
        {
            byte[] buffer = new byte[10 * 1024 * 1024];
        }
        System.gc(); //不会被回收, buffer在Young GC不变, FullGC时被放入老年代; 因为buffer依然占用了, 查看字节码会发现最大局部变量表长度为2, 索引为0的位置是this, 为1的位置是buffer, 依然占用.
    }

    public void localvarGC4() {
        {
            byte[] buffer = new byte[10 * 1024 * 1024];
        }
        int value = 10;
        System.gc(); //正常被回收, 对比上面的方法, value占用之前的索引为1的位置, 替换了buffer, 所以堆空间的buffer就没有引用了
    }

    public void localvarGC5() {
        localvarGC1();
        System.gc();
        // 正常被回收, 对比localvarGC1, GC1已经跑完了, 所以buffer就回收了
    }

    public static void main(String[] args) {
        LocalVarGC local = new LocalVarGC();
        local.localvarGC5(); // 调用上面的方法, 会回收
    }
}

内存溢出与内存泄漏

内存溢出(OOM)

一般情况下, 除非应用程序占用的内存增长速度非常快, 造成垃圾回收已经跟不上内存消耗的速度, 否则不太容易出现OOM的情况. javadoc中对OutOfMemoryError的解释是: 没有空闲内存,并且垃圾收集器也无法提供更多内存. 对没有内存的情况(堆内存不够), 原因有两个:

  1. Java虚拟机的堆内存设置不够(可能存在内存泄漏问题或者堆的大小不合理. 比如我们要处理比较可观的数据量, 但是没有显式指定JVM堆大小或者指定数值偏小, 那么可以通过参数-Xms, -Xmx来调整).
  2. 代码中创建了大量大对象并且长时间不能被垃圾收集器收集(存在被引用). 对于老版本的Oracle JDK. 因为永久代的大小是有限的, 并且JVM对永久代垃圾回收非常不积极,所以当我们不断添加新类型的时候, 永久代OOM非常多见(java.lang.OutOfMemoryError: PermGen space), 尤其是在运行时存在大量动态类型生成的场合; intern字符串缓存占用太多空间, 也会导致OOM问题. 元数据区的引入使得方法区内存已经不过于窘迫, 有效改善了OOM, 如果出现会报java.lang. OutOfMemoryError: Metaspace, 也就是说直接内存不足了.

在OOM之前, 通常垃圾收集器会被触发. 除非分配一个超大对象, 其超过了堆空间的最大值, 垃圾回收器无法回收, 直接报OOM.

内存泄漏(Memory Leak)

严格来说, 只有对象不会再被程序用到了, 但是GC又不能回收他们的情况, 才叫内存泄漏. 实际情况很多时候一些不太好的实践会导致对象的生命周期变得很长, 导致OOM, 也可以叫做广义的内存泄漏. 内存泄漏并不会立刻引起程序崩溃, 但是一旦发生内存泄漏, 内存就会被持续占用, 如果超出GC能力就会OOM. 这里的存储空间不是指物理内存, 而是指虚拟内存, 虚拟内存大小取决于磁盘交换区设定的大小.

下图右边的有没有断开的对象, GC就无法回收.

例子

回顾单例模式, 其生命周期和应用程序是一样长的, 如果持有对外部对象的引用的话, 那么这个外部对象是不能被回收的, 导致内存泄漏的产生. 一些提供close的资源未关闭导致内存泄漏数据库连接(dataSourse.getConnection())网络连接(socket)和io连接必须手动close, 否则不能被回收. 所以使用外部资源时要时刻记住不要因为疏忽导致内存泄漏.

Stop The World

简称STW, 指的是GC事件发生过程中, 会产生应用程序的停顿, 停顿产生时整个应用程序线程(用户线程)都会被暂停. 可达性分析算法中枚举根节点会导致所有Java执行线程停顿. 所以像之前提到的, 分析工作必须在一个能确保一致性的快照中进行, 一致性指整个分析期间整个执行系统看起来像被冻结在某个时间点上. 如果出现分析过程中对象引用关系还在不断变化, 则分析结果的准确性无法保.

  • STW事件和采用哪款GC无关, 所有的GC都有这个事件, G1也无法完全避免
  • STW是JVM在后台自动发起和自动完成的, 用户不可见
  • 开发中不要用System.gc(), 避免STW

并发与并行

操作系统并发(Concurrent)

  • 是指一个时间段中有几个程序都处于己启动运行到运行完毕之间, 且这几个程序都是在同一个处理器上运行
  • 并发不是真正意义上的同时进行,只是CPU快速切换任务

操作系统并行(Parallel)

  • 当系统有一个以上CPU或CPU有多核时, 当一个CPU执行一个进程时,另一个CPU可以执行另一个进程, 两个进程互不抢占CPU资源, 可以同时进行.
  • 还有一种情况就是线程在多核CPU并行, 这个时候线程的映射表都是相同的

垃圾回收并发与并行

  • 并行(parallel): 指多条垃圾收集线程并行工作, 但此时用户线程仍处于等待状态(ParNew, Parallel Scavenge, Parallel Old)

  • 串行(Serial): 相较于并行的概念, 单线程执行; 如果内存不够则程序暂停, 启动JVM垃圾回收器进行垃圾回收. 回收完再启动程序的线程.

  • 并发(Concurrent): 指用户线程与垃圾收集线程同时执行(同一个时间段), 垃圾回收线程在执行时不会停顿用户程序的运行, 不一定完全的真正并行(操作系统意义上的)

安全点与安全区域

安全点

程序执行时并非在所有地方都能停顿下来开始GC, 只有在特定的位置才能停顿下来开始GC, 这些位置称为安全点(Safepoint). 其选择很重要, 如果太少可能导致GC等待的时间太长, 如果太频繁可能导致运行时的性能问题. 大部分指令的执行时间都非常短暂, 通常会根据是否具有让程序长时间执行的特征为标准. 如选择些执行时间较长的指令作为安全点, 如方法调用, 循环跳转和异常跳转等.

如何在GC发生时, 检查所有线程都跑到最近的安全点停顿下来呢? 主要有两种中断.

  • 抢先式中断(目前没有虚拟机采用了): 首先中断所有线程, 如果还有线程不在安全点就恢复线程, 让线程跑到安全点
  • 主动式中断: 设置一个中断标志, 各个线程运行到安全点的时候主动轮询这个标志, 如果中断标志为真就将自己进行中断挂起

安全区域

安全点机制保证了程序执行时在不太长的时间内就会遇到可进入GC的Safepoint. 但是程序不执行的时候呢? 如线程处于Sleep状态或Blocked状态, 此时线程无法响应JVM的中断请求.

所以就需要安全区域(Safe Region)来解决. 安全区域是指在一段代码片段中, 对象的引用关系不会发生变化, 在这个区域中的任何位置开始GC都是安全的. 我们也可以把安全区域看做是被扩展了的安全点.

实际执行时:

  1. 当线程运行到Safe Region的代码时, 首先标识已经进入了Safe Region. 如果这段时间内GC, JVM会忽略标识为Safe Region的线程
  2. 当线程即将离开Safe Region时, 会检查JVM是否已经完成GC, 如果完成则继续运行, 否则线程必须等待直到收到可以安全离开Safe Region的信号为止

引用

我们希望能描述这样一类对象: 当内存空间还足够时则能保留在内存中, 如果内存空间在进行垃圾收集后还是不够则可以抛弃这些对象. 这意味着要缓存这些对象. 引用分为强引用(Strong Reference), 软引用(Soft Reference), 弱引用(Weak Reference)虚引用(Phantom Reference), 这4种引用强度依次逐渐减弱. 除强引用外其他3种引用均可以在java.lang.ref包中找到它们的身影.

最常见引用赋值, 即类似0bject obj=new object()这种引用关系. 任何情况只要强引用关系还存在, 垃圾收集器就永远不会回收掉被引用的对象, 即使OOM. 对于普通的对象如果没有其他的引用关系, 只要超过了引用的作用域或者显式地将相应强引用赋值为null, 就是可以当做垃圾被收集了. 软引用/弱引用/虚引用的对象是软可触及/弱可触及/虛可触及的, 都是可以被回收的. 而强引用是造成Java内存泄漏的主要原因之一.

在系统将要发生内存溢出之前, 将会把这些对象列入回收范围之中进行第二次回收(第一次回收不可触及的对象, 第二次表示可触及). 如果这次回收后还没有足够的内存, 才会抛出内存溢出异常. 也就是内存不足才回收.

软引用通常用来实现内存敏感的缓存, 高速缓存就有用到软引用. 如果还有空闲内存, 就可以暂时保留缓存 而当内存不足时就清理掉, 保证了使用缓存的同时不耗尽内存; 垃圾回收器在某个时刻决定回收软可达的对象的时候会清理软引用, 并可选地把引用存放到一个引用队列(Reference Queue); ava虚拟机会尽量让软引用的存活时间长一些.

/**
 * -Xms10m -Xmx10m -XX:+PrintGCDetails
 */
public class SoftReferenceTest {
    // 普通User类
    public static class User {
        public User(int id, String name) {
            this.id = id;
            this.name = name;
        }
        public int id;
        public String name;
        @Override
        public String toString() {
            return "[id=" + id + ", name=" + name + "] ";
        }
    }

    public static void main(String[] args) {
        //SoftReference<User> userSoftRef = new SoftReference<User>(new User(1, "songhk")); //创建对象,建立软引用
        //上面的一行代码, 等价于下面三行代码
        User u1 = new User(1,"songhk");
        SoftReference<User> userSoftRef = new SoftReference<User>(u1);
        u1 = null; //取消强引用(才能测试软引用)

        System.out.println(userSoftRef.get()); //从软引用中重新获得强引用对象, 还在
        System.gc(); //回收
        //垃圾回收之后获得软引用中的对象
        System.out.println(userSoftRef.get()); //还在(由于堆空间内存足够,所有不会回收软引用的可达对象)

        try {
            // 让系统认为内存资源紧张
            byte[] b = new byte[1024 * 7168 - 635 * 1024]; //能放下数组但放不下u1, 不会OOM(把老年代用了)
        } catch (Throwable e) {
            e.printStackTrace();
        } finally {
            System.out.println(userSoftRef.get()); //输出null, 在OOM之前, 软引用的可达对象被清除
        }
    }
}

被弱引用关联的对象只能生存到下一次垃圾收集之前, 无论内存空间是否足够, 都会回收掉被弱引用关联的对象. 发现即回收. 弱引用和软引用一样, 在构造弱引用时可以指定一个引用队列, 当弱引用对象被回收时就加入指定的引用队列, 通过这个队列可以跟踪对象的回收情况.

Object obj = new Object();
WeakReferencewr<Object> wr = new WeakReference<Object>(obj);
obj = null;

也叫幽灵引用/幻影引用, 一个对象是否有虛引用的存在, 完全不会对其生存时间构成影响. 虚引用不能单独使用, 也无法通过虚引用来获得一个对象的实例. 当试图通过虚引用的get方法取得对象时, 拿不到对象, 总是返回null. 为一个对象设置虛引用关联的唯一目的是: 在这个对象被回收时收到一个系统通知(回收跟踪). 虚引用在创建时必须提供一个引用队列作为参数, 当垃圾回收器准备回收一个对象时发现它还有虛引用, 就会在回收对象后将这个虚引用加入引用队列, 来通知应用程序对象的回收情况.

由于虚引用可以跟踪对象的回收时间, 所以可以将一些资源释放操作放置在虛引用中执行和记录.

public class PhantomReferenceTest {
    public static PhantomReferenceTest obj; //当前类对象的声明
    static ReferenceQueue<PhantomReferenceTest> phantomQueue = null; //引用队列

    public static class CheckRefQueue extends Thread {
        @Override
        public void run() {
            while (true) {
                if (phantomQueue != null) { // obj回收时, 虚引用才会放到这个引用队列, 就进入if
                    PhantomReference<PhantomReferenceTest> objt = null;
                    try {
                        objt = (PhantomReference<PhantomReferenceTest>) phantomQueue.remove();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    if (objt != null) {
                        System.out.println("PhantomReferenceTest实例被GC了");
                    }
                }
            }
        }
    }

    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        obj = this; //复活对象
    }

    public static void main(String[] args) {
        Thread t = new CheckRefQueue();
        t.setDaemon(true); //设置为守护线程: 如果这又守护线程, 他就结束
        t.start();

        phantomQueue = new ReferenceQueue<PhantomReferenceTest>();
        obj = new PhantomReferenceTest();
        PhantomReference<PhantomReferenceTest> phantomRef = PhantomReference<PhantomReferenceTest>(obj, phantomQueue); //构造PhantomReferenceTest对象的虚引用, 指定引用队列

        try {
            System.out.println(phantomRef.get()); //null, 虚引用无法获取对象

            obj = null; //去除强引用
            System.gc(); //回收obj, 但是finalize中对象复活, GC无法回收该对象
            Thread.sleep(1000); //保证finalize运行

            if (obj == null) {
                System.out.println("obj是null");
            } else {
                System.out.println("obj可用"); //跑这个
            }

            System.out.println("第 2 次 gc");
            obj = null;
            System.gc(); //一旦将obj对象回收, 就会将此虚引用存放到引用队列中
            Thread.sleep(1000);
            if (obj == null) {
                System.out.println("obj是null"); //跑这个, 因为finalize方法调用过一次了, 无法复活
            } else {
                System.out.println("obj 可用");
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

终结器引用

  • 它用以实现对象的finalize方法,也可以称为终结器引用
  • 无需手动编码, 其内部配合引用队列使用
  • 在GC时, 终结器引用入队. 由Finalizer线程通过终结器引用找到被引用对象并调用它的finalize()方法, 第二次GC时才能回收被引用对象

参考

  1. 尚硅谷最新版宋红康JVM教程
  2. The Java® Virtual Machine Specification
  3. JVM垃圾回收

comments powered by Disqus