Java类加载,垃圾收集

写在前面:

Java 的类加载过程可以分为 7 个阶段:载入、验证、准备、解析、初始化、使用和卸载。这 7 个阶段一般是顺序发生的,但在动态绑定的情况下,解析阶段发生在初始化阶段之后。

一,类加载过程

1,载入(Loading)

JVM 在该阶段的主要目的是将字节码从不同的数据源(可能是 class 文件、也可能是 jar 包,甚至网络)转化为二进制字节流加载到内存中,并生成一个代表该类的 java.lang.Class 对象。主要分成三个步骤:

  • 通过一个类的全限定名获取该类的二进制流
  • 将该二进制流种的静态存储结构转化为方法区运行时数据结构
  • 在内存中生成该类的Class对象,作为该类的数据访问入口

2,校验(Verification)

JVM 会在该阶段对二进制字节流进行校验,只有符合 JVM 字节码规范的才能被 JVM 正确执行。该阶段是保证 JVM 安全的重要屏障。在该阶段主要完成以下四种验证:

  • 文件格式验证:验证字节码流是否负荷Class文件的规范,如主次版本号是否在当前虚拟机范围内,常量池中的常量是否有不被支持的类型
  • 元数据验证: 对字节码描述的信息进行语义分析,如这个类是否有父类,是否继承了不被继承的类等
  • 字节码验证: 是整个验证过程中最复杂的一个阶段,通过验证数据流和控制流的分析,确定程序语义是否正确,主要针对方法体的验证。如:方法中的类型转换是否正确,跳转指令是否正确等。
  • 符号引用验证:这个动作在后面的解析过程中发生,主要是为了确保解析动作被正确执行

下面是一些实际中主要的检查:

1.确保二进制字节流格式符合预期(比如说是否以 cafe bene 开头)。
2.是否所有方法都遵守访问控制关键字的限定。
3.方法调用的参数个数和类型是否正确。
4.确保变量在使用之前被正确初始化了。
5.检查变量是否被赋予恰当类型的值。

 

3,准备(Preparation)

JVM 会在该阶段对类变量(也称为静态变量,static 关键字修饰的)分配内存并初始化(对应数据类型的默认初始值,如 0、0L、null、false 等)。

也就是说,假如有这样一段代码:

public String user1 = "E1";
public static String user2 = "E2";
public static final String user3 = "E3";
public static int value=123; //在准备阶段value初始值为0。在初始桦阶段才会变为123.

user1 不会被分配内存,而 user2 会;但 user2的初始值不是“E2”而是 null。

需要注意的是,static final 修饰的变量被称作为常量,和类变量不同。常量一旦赋值就不会改变了,所以 user3 在准备阶段的值为“E3”而不是 null。

注意:准备阶段是为类的静态变量分配内存并将其初始化为默认值,这些内存都将在方法区中进行分配。准备阶段不分配类中的实例变量的内存,实例变量将会在对象实例化时随着对象一起分配在java堆中。

 

4,解析(Resolution)

一句话:该阶段将常量池中的符号引用转化为直接引用。

what?符号引用,直接引用?

符号引用以一组符号(任何形式的字面量,只要在使用时能够无歧义的定位到目标即可)来描述所引用的目标。

在编译时,Java 类并不知道所引用的类的实际地址,因此只能使用符号引用来代替。比如 com.Wanger 类引用了 com.Chenmo 类,编译时 Wanger 类并不知道 Chenmo 类的实际内存地址,因此只能使用符号 com.Chenmo。

直接引用通过对符号引用进行解析,找到引用的实际内存地址。

注意:解析动作并不一定在初始化动作完成之前,也有可能在初始化之后。

 

5,初始化(Initialization)

该阶段是类加载过程的最后一步。在准备阶段,类变量已经被赋过默认初始值,而在初始化阶段,类变量将被赋值为代码期望赋的值。换句话说,初始化阶段是执行类构造器方法的过程。

oh,no,上面这段话说得很抽象,不好理解,对不对,我来举个例子。

String user = new String("@EQuaker");

上面这段代码使用了 new 关键字来实例化一个字符串对象,那么这时候,就会调用 String 类的构造方法对 user 进行实例化。

 

初始化是类加载的最后一步,前面的类加载过程,除了在加载阶段用户应用程序可以通过自定义类加载器的方式参与外,其余动作全由虚拟机主导和控制。到了初始化阶段,才真正开始执行类中定义的java程序代码。比如,String user = new String("@EQuaker");

 

6,使用(Using)

7,卸载(Unloading)

不像类加载,Java中没有提供显式进行类卸载的API,但是如果加载类的ClassLoader对象被垃圾回收器回收的话,这个类就会被卸载。所以我们可以自己实现ClassLoader,自己加载类,然后对ClassLoader对象的引用赋值为null,等ClassLoader对象剩下的引用数量为0时会被回收,这样就达到卸载类的目的了。当然,除非特定情况,一般我们是不参与这个工作的。

那么多的类,什么时候卸载呢,满足如下条件:

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

 

二,类加载器

在上面讲解类加载过程的时候,提到过用户自定义加载器。一般来说,Java 程序员并不需要直接同类加载器进行交互。JVM 默认的行为就已经足够满足大多数情况的需求了。不过,如果遇到了需要和类加载器进行交互的情况,而对类加载器的机制又不是很了解的话,就不得不花大量的时间去调试
ClassNotFoundException 和 NoClassDefFoundError 等异常。

对于任意一个类,都需要由它的类加载器和这个类本身一同确定其在 JVM 中的唯一性。也就是说,如果两个类的加载器不同,即使两个类来源于同一个字节码文件,那这两个类就必定不相等(比如两个类的 Class 对象不 equals)。

站在程序员的角度来看,Java 类加载器可以分为三种。

  • 启动类加载器(Bootstrap Class-Loader),加载 jre/lib 包下面的 jar 文件,比如说常见的 rt.jar。
  • 扩展类加载器(Extension or Ext Class-Loader),加载 jre/lib/ext 包下面的 jar 文件。
  • 应用类加载器(Application or App Clas-Loader),根据程序的类路径(classpath)来加载 Java 类。

现在,通过简单的代码了解一下类加载器。

public class Sync {


    public static void main(String[] args) {

        ClassLoader classLoader = Sync.class.getClassLoader();
        System.out.println(classLoader);
        ClassLoader parent = classLoader.getParent();
        System.out.println(parent);

        ClassLoader grand = parent.getParent();
        System.out.println(grand);

    }

}

每个 Java 类都维护着一个指向定义它的类加载器的引用,通过 类名.class.getClassLoader() 可以获取到此引用;然后通过 loader.getParent() 可以获取类加载器的上层类加载器。

这段代码的输出结果如下:

 

第一行输出为 Sync 的类加载器,即应用类加载器,它是 sun.misc.Launcher$AppClassLoader 类的实例;第二行输出为扩展类加载器,是 sun.misc.Launcher$ExtClassLoader 类的实例。那启动类加载器呢?

按理说,扩展类加载器的上层类加载器是启动类加载器,但一些实现可能用null来代表引导类加载器。如果此类加载器的父类(这里指的是类加载器中的父子关系,不是继承的父子关系)是引导类加载器,这个方法将在这些实现中将返回null。

 

三,双亲委派模型

如果以上三种类加载器不能满足要求的话,程序员还可以自定义类加载器(继承 java.lang.ClassLoader 类),它们之间的层级关系如下图所示。

 

 

这种层次关系被称作为双亲委派模型:如果一个类加载器收到了加载类的请求,它会先把请求委托给上层加载器去完成,上层加载器又会委托上上层加载器,一直到最顶层的类加载器;如果上层加载器无法完成类的加载工作时,当前类加载器才会尝试自己去加载这个类。

PS:双亲委派模型突然让我联想到朱元璋同志,这个同志当上了皇帝之后连宰相都不要了,所有的事情都亲力亲为,只有自己没精力没时间做的事才交给大臣们去干。

使用双亲委派模型有一个很明显的好处,那就是 Java 类随着它的类加载器一起具备了一种带有优先级的层次关系,这对于保证 Java 程序的稳定运作很重要。

上文中曾提到,如果两个类的加载器不同,即使两个类来源于同一个字节码文件,那这两个类就必定不相等——双亲委派模型能够保证同一个类最终会被特定的类加载器加载。

 

四,垃圾回收(GC)

 

在Java中,程序媛不需要显示的去释放一个对象的内存,而是由虚拟机自行执行的。在JVM中,有一个垃圾回收线程,它是低优先级的,在正常情况下是不会执行的,只有虚拟机空闲或者当前堆内存不足时,才会触发执行,扫面那些没有被任何引用的对象,并将他们添加到要回收的集合中,进行回收。那么问题来了,如何判断一个对象是否存活呢?

4.1,判断对象存活的两种方法

①,引用计数法

所谓引用计数法就是给每个对象设置一个引用计数器,每当有一个地方引用这个对象时,就将计数器加1,引用失效时,计数器就减1.当一个对象的引用计数器为0时,说明此对象没有被引用,也就是“死对象”,将会被垃圾回收。

引用计数法实现简单,判定高效,但不能解决对象之间循环引用的问题。也就是当对象A引用对象B时,对象B又引用对象A,那么此时A,B对象的引用计数器都不为0,也就无法完成垃圾回收,所以主流的虚拟机都没有采用这种话算法。下面看看演示。

public class eq {

    private Object instance = null;

    private static final int _10M = 10 * 1 << 20;

    // 一个对象占10M,方便在GC日志中看出是否被回收

    private byte[] bigSize = new byte[_10M];


    public static void main(String[] args) {

        eq objA = new eq();

        eq objB = new eq();

        objA.instance = objB;
        objB.instance = objA;

        objA = null;

        objB = null;

        System.gc();

    }

}

通过添加-XX:+PrintGC参数,运行结果:

[GC (System.gc())  30115K->1970K(251392K), 0.0030767 secs]
[Full GC (System.gc())  1970K->1847K(251392K), 0.0119800 secs]

从GC日志中可以看出objA和objB相互引用,但是它们所占的内存没有被垃圾收集器回收了。

 

②,可达性分析(引用链法)

该算法的思想是: 从一个被成为GC Roots的对象向下搜索,如果一个对象到GC Roots没有任何引用链相连时,则说明此对象不可用。在java中可以作为GC Roots的对象有以下几种:

1,虚拟机栈中引用的对象(栈帧中的本地变量表)

2,方法区类静态属性引用对象

3,方法区常量池引用的对象

4,本地方法栈JNI引用的对象

有没有发现什么特点,为什么这些可以其他的不可以做为GC Roots呢?

作为 GC Roots 的节点主要在全局性的引用与执行上下文中。要明确的是,tracing gc必须以当前存活的对象集为 Roots,因此必须选取确定存活的引用类型对象(栈,方法区这两个部分的对象肯定是存活的,慢慢品)。

GC 管理的区域是 Java 堆。虚拟机栈、方法区和本地方法栈不被 GC 所管理,因此选用这些区域内引用的对象作为 GC Roots,是不会被 GC 所回收的。(回收都是根据这几个地方确定的,肯定不会清楚自己啊)

其中虚拟机栈和本地方法栈都是线程私有的内存区域,只要线程没有终止,就能确保它们中引用的对象的存活。而方法区中类静态属性引用的对象是显然存活的。常量引用的对象在当前可能存活,因此,也可能是 GC roots 的一部分。

 

说到这里,顺便介绍一下java的四种引用类型。

  • 强引用:之前我们使用的大部分引用实际上都是强引用,这是使用最普遍的引用
Object object = new Object();
String str = "StrongReference";

如果一个对象具有强引用,那就类似于必不可少的物品,不会被垃圾回收器回收。当内存空间不足,Java虚拟机宁愿抛出OutOfMemoryError错误,使程序异常终止,也不回收这种对象。

  • 软引用:有用但并不是必需
public static void main(String[] args){  
        System.out.println("start");            
        Obj obj = new Obj();            
        SoftReference<Obj> sr = new SoftReference<Obj>(obj);
        obj = null;  
        System.out.println(sr.get());  
        System.out.println("end");     
} 

 只有在内存不足的时候JVM才会回收该对象

  • 弱引用:非必需对象

    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());
    }

软引用关联的对象只有在内存不足时才会被回收,而被弱引用关联的对象在JVM进行垃圾回收时总会被回收。

  • 虚引用:不影响对象的生命周期
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());
    }

总结引用类型: 

引用类型被回收时间用途生存时间
强引用从来不会对象的一般状态JVM停止运行时
软引用内存不足时对象缓存内存不足时
弱引用jvm垃圾回收时对象缓存gc运行后
虚引用未知未知未知

 

 

 

虽然可达性分析算法判定一个对象是否能被回收,但是当满足上述条件时,一个对象并不一定会被回收。当一个对象不可达GC Roots时,这个对象并不会立马被回收,二十处于一个死缓的阶段,若是被真正的回收需要经历两次标记。

  1. 如果对象objA到 GC Roots没有引用链,则进行第一次标记。
  2. 如果对象objA重写了finalize()方法,且还未执行过,那么objA会被插入到F-Queue队列中,由一个虚拟机自动创建的、低优先级的Finalizer线程触发其finalize()方法。finalize()方法是对象逃脱死亡的最后机会,GC会对队列中的对象进行第二次标记,如果objA在finalize()方法中与引用链上的任何一个对象建立联系,那么在第二次标记时,objA会被移出“即将回收”集合。

看看代码演示:

public class FinalizerTest {

    public static FinalizerTest object;

    public void isAlive() {
        System.out.println("I'm alive");
    }

    @Override

    protected void finalize() throws Throwable {

        super.finalize();

        System.out.println("method finalize is running");

        object = this;

    }

    public static void main(String[] args) throws Exception {

        object = new FinalizerTest();

        // 第一次执行,finalize方法会自救

        object = null;

        System.gc();

        Thread.sleep(500);

        if (object != null) {

            object.isAlive();

        } else {

            System.out.println("I'm dead");

        }


        // 第二次执行,finalize方法已经执行过

        object = null;

        System.gc();


        Thread.sleep(500);

        if (object != null) {

            object.isAlive();

        } else {

            System.out.println("I'm dead");

        }

    }

}

通过添加-XX:+PrintGC参数,运行结果:

[GC (System.gc())  5243K->951K(251392K), 0.0009774 secs]
[Full GC (System.gc())  951K->812K(251392K), 0.0055079 secs]
method finalize is running
I'm alive
[GC (System.gc())  3434K->908K(251392K), 0.0004220 secs]
[Full GC (System.gc())  908K->810K(251392K), 0.0071639 secs]
I'm dead

从执行结果可以看出:

第一次发生GC时,finalize方法的确执行了,并且在被回收之前成功逃脱,也就是object没有被回收;

第二次发生GC时,由于finalize方法只会被JVM调用一次,而第一次已经调用了,所以object直接被回收。

上面介绍的是两种判断对象存活的方法,只是做了垃圾回收的准备工作,真要到垃圾回收的话,还要做哪些操作呢?也就是垃圾如何回收,有纳西人方法。

 

4.2,垃圾回收算法

        java有一个显著的特点就是引入了垃圾回收机制,使c++程序员最头疼的内存管理问题迎刃而解,它使得java程序员在编写程序的时候不再需要考虑内存管理。由于有个垃圾回收机制,java的对象不在有“作用域”的概念,只有对象的引用才有“作用域”。结合JVm讲解的话就是,对象的创建初始化都是放在 堆里面的,没有作用域,只有堆,但是你如果想使用我的话,就需要声明引用这个对象。至于你在哪里引用(类变量还是局部变量等)这些是有作用域概念的。假如是在方法内部的局部变量,当方法结束时,这个引用肯定要销毁,但这个在堆里面的对象不会销毁,除非后面的垃圾回收。

       垃圾回收可以有效的防止内存泄漏(内存泄漏就是值一个不再被程序使用的对象或变量一直被占据在内存中),有效的使用可以使用的内存。垃圾回收器通常作为一个单独的低级别的线程运行,不可预知的情况下对内存堆中已经死亡的或者长时间没有使用的对象进行清除和回收,程序媛不能实时的调用垃圾回收器对某个对象或者所有对象进行垃圾回收。

垃圾收集算法主要有:标记-清除、复制和标记-整理。

①,标记-清除算法(Mark-Sweep)

对待回收的对象进行标记,然后统一回收被标记对象。需要两个循环

算法缺点:效率问题,标记和清除过程效率都很低;空间问题,收集之后会产生大量的内存碎片,不利于大对象的分配。

 

②,复制算法

复制算法将可用内存划分成大小相等的两块A和B,每次只使用其中一块,当A的内存用完了,就把存活的对象复制到B,并清空A的内存,不仅提高了标记的效率,因为只需要标记存活的对象,同时也避免了内存碎片的问题,代价是可用内存缩小为原来的一半。

现在的商业虚拟机都采用这种算法来回收新生代,IBM 研究指出新生代中的对象 98% 是“朝生夕死”的,所以并不需要按照 1:1 的比例来划分内存空间,而是将内存分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 和其中一块 Survivor 。

当回收时,将 Eden 和 Survivor 中还存活着的对象一次性地复制到另外一块 Survivor 空间上,最后清理掉 Eden 和刚才用过的 Survivor 空间。HotSpot 虚拟机默认 Eden:Survivor = 8:1,也就是每次新生代中可用内存空间为整个新生代容量的 90%(其中一块Survivor不可用),只有 10% 的内存会被“浪费”。

当然,98%的对象可回收只是一般场景下的数据,我们没有办法保证每次回收都只有不多于 10% 的对象存活,当 Survivor 空间不够用时,需要依赖其他内存(这里指老年代)进行分配担保(Handle Promotion)。

内存的分配担保就好比我们去银行借款,如果我们信誉很好,在 98% 的情况下都能按时偿还,于是银行可能会默认我们下一次也能按时按量地偿还贷款,只需要有一个担保人能保证如果我不能还款时,可以从他的账户扣钱,那银行就认为没有风险了。

内存的分配担保也一样,如果另外一块 Survivor 空间没有足够空间存放上一次新生代收集下来的存活对象时,这些对象将直接通过分配担保机制进入老年代。

 

③,标记-整理算法(Mark-Compact)

复制算法在对象存活率较高时就要进行较多的复制操作,效率将会变低。更关键的是,如果不想浪费 50% 的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都 100% 存活的极端情况,所以在老年代一般不能直接选用这种算法。

根据老年代的特点,有人提出了另外一种“标记-整理”(Mark-Compact)算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存,“标记-整理”算法的示意图如下:

 

④,分代收集算法

当前商业虚拟机的垃圾收集都采用“分代收集”(Generational Collection)算法,根据对象存活周期的不同将内存划分为几块并采用不用的垃圾收集算法。

一般是把 Java 堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。

  • 新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。
  • 老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记—清理”或者“标记—整理”算法来进行回收。
  • 永久代中,来装载Class,方法等信息,默认为64M,不会被回收。一般存放像Hibernate,Spring这类喜欢AOP动态生成类的框架,往往会生成大量的动态代理类

 

4.3,垃圾收集器

如果说垃圾回收算法是内存回收的方法论,那么垃圾收集器就是内存回收算法的具体实现。不同商家、不同版本的JVM所提供的垃圾收集器可能会有很在差别。这里讨论的收集器基于JDK 1.7 Update 14之后的 HotSpot 虚拟机,这个虚拟机包含的所有收集器如下图所示

      新生代收集器:Serial、ParNew、Parallel Scavenge;

      老年代收集器:Serial Old、Parallel Old、CMS;

      整堆收集器:G1;

上图展示了 7 种作用于不同分代的收集器,如果两个收集器之间存在连线,就说明它们可以搭配使用。虚拟机所处的区域,则表示它是属于新生代收集器还是老年代收集器。接下来将逐一介绍这些收集器的特性、基本原理和使用场景,并重点分析 CMS 和 G1 这两款相对复杂的收集器,了解它们的部分运作细节。

①,年轻代-串行收集器(Serial)

Serial 收集器是最基本、发展历史最悠久的收集器,曾经是虚拟机新生代收集的唯一选择。这是一个单线程的收集器,但它的“单线程”的意义并不仅仅说明它只会使用一个 CPU 或一条收集线程去完成垃圾收集工作,更重要的是在它进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束。

"Stop The World"这个名字也许听起来很酷,但这项工作实际上是由虚拟机在后台自动发起和自动完成的,在用户不可见的情况下把用户正常工作的线程全部停掉,这对很多应用来说都是难以接受的。下图示意了 Serial/Serial Old 收集器的运行过程。

实际上到现在为止,它依然是虚拟机运行在 Client 模式下的默认新生代收集器。它也有着优于其他收集器的地方:简单而高效(与其他收集器的单线程比),对于限定单个 CPU 的环境来说,Serial 收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。

在用户的桌面应用场景中,分配给虚拟机管理的内存一般来说不会很大,收集几十兆甚至一两百兆的新生代(仅仅是新生代使用的内存,桌面应用基本上不会再大了),停顿时间完全可以控制在几十毫秒最多一百多毫秒以内,只要不是频繁发生,这点停顿是可以接受的。所以,Serial 收集器对于运行在 Client 模式下的虚拟机来说是一个很好的选择。

②,新生代-ParNew收集器

 ParNew 收集器其实就是 Serial 收集器的多线程版本,除了使用多条线程进行垃圾收集之外,其余行为包括 Serial 收集器可用的所有控制参数(例如:-XX:SurvivorRatio-XX:PretenureSizeThreshold-XX:HandlePromotionFailure等)、收集算法、Stop The World、对象分配规则、回收策略等都与 Serial 收集器完全一样,在实现上,这两种收集器也共用了相当多的代码。ParNew 收集器的工作过程如下图所示。

 

ParNew 收集器除了多线程收集之外,其他与 Serial 收集器相比并没有太多创新之处,但它却是许多运行在 Server 模式下的虚拟机中首选的新生代收集器,其中有一个与性能无关但很重要的原因是,除了 Serial 收集器外,目前只有它能与 CMS 收集器(并发收集器,后面有介绍)配合工作。

ParNew 收集器在单 CPU 的环境中不会有比 Serial 收集器更好的效果,甚至由于存在线程交互的开销,该收集器在通过超线程技术实现的两个 CPU 的环境中都不能百分之百地保证可以超越 Serial 收集器。

当然,随着可以使用的 CPU 的数量的增加,它对于 GC 时系统资源的有效利用还是很有好处的。它默认开启的收集线程数与 CPU 的数量相同,在 CPU 非常多(如 32 个)的环境下,可以使用-XX:ParallelGCThreads参数来限制垃圾收集的线程数。

注意,从 ParNew 收集器开始,后面还会接触到几款并发和并行的收集器。这里有必要先解释两个名词:并发和并行。这两个名词都是并发编程中的概念,在谈论垃圾收集器的上下文语境中,它们可以解释如下。

  • 并行(Parallel):指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态。
  • 并发(Concurrent):指用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),用户程序在继续运行,而垃圾收集程序运行于另一个 CPU 上。

 ③,新生代-Parllenel Scavenge收集器

Parallel Scavenge 收集器是一个新生代收集器,它也是使用复制算法的收集器,又是并行的多线程收集器……看上去和 ParNew 都一样,那它有什么特别之处呢?

Parallel Scavenge 收集器的特点是它的关注点与其他收集器不同,CMS 等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而 Parallel Scavenge 收集器的目标则是达到一个可控制的吞吐量(Throughput)。

所谓吞吐量就是 CPU 用于运行用户代码的时间与 CPU 总消耗时间的比值,即吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间),虚拟机总共运行了 100 分钟,其中垃圾收集花掉1分钟,那吞吐量就是99% 。

停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验,而高吞吐量则可以高效率地利用 CPU 时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。

Parallel Scavenge收集器提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间的-XX:MaxGCPauseMillis参数以及直接设置吞吐量大小的-XX:GCTimeRatio参数。

MaxGCPauseMillis参数允许的值是一个大于 0 的毫秒数,收集器将尽可能地保证内存回收花费的时间不超过设定值。

不过大家不要认为如果把这个参数的值设置得稍小一点就能使得系统的垃圾收集速度变得更快,GC停顿时间缩短是以牺牲吞吐量和新生代空间来换取的:系统把新生代调小一些,收集 300MB 新生代肯定比收集 500MB 快吧,这也直接导致垃圾收集发生得更频繁一些,原来10秒收集一次、每次停顿100毫秒,现在变成5秒收集一次、每次停顿70毫秒。停顿时间的确在下降,但吞吐量也降下来了。

GCTimeRatio 参数的值应当是一个 0 到 100 的整数,也就是垃圾收集时间占总时间的比率,相当于是吞吐量的倒数。如果把此参数设置为 19,那允许的最大 GC 时间就占总时间的 5%(即 1/(1+19)),默认值为 99 ,就是允许最大 1%(即 1/(1+99))的垃圾收集时间。

由于与吞吐量关系密切,Parallel Scavenge 收集器也经常称为“吞吐量优先”收集器。除上述两个参数之外,Parallel Scavenge 收集器还有一个参数-XX:+UseAdaptiveSizePolicy值得关注。这是一个开关参数,当这个参数打开之后,就不需要手工指定新生代的大小(-Xmn)、Eden 与 Survivor 区的比例(-XX:SurvivorRatio)、晋升老年代对象年龄(-XX:PretenureSizeThreshold)等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量,这种调节方式称为 GC 自适应的调节策略(GC Ergonomics)。

④,老年代-Serial Old 收集器

Serial Old 是 Serial 收集器的老年代版本,它同样是一个单线程收集器,使用“标记-整理”算法。这个收集器的主要意义也是在于给 Client 模式下的虚拟机使用。如果在 Server 模式下,那么它主要还有两大用途:一种用途是在 JDK 1.5 以及之前的版本中与 Parallel Scavenge 收集器搭配使用,另一种用途就是作为 CMS 收集器的后备预案,在并发收集发生 Concurrent Mode Failure 时使用。这两点都将在后面的内容中详细讲解。Serial Old 收集器的工作过程如下图所示。

⑤,老年代-Parallel Old收集器

Parallel Old 是 Parallel Scavenge 收集器的老年代版本,使用多线程和“标记-整理”算法。这个收集器是在 JDK 1.6 中才开始提供的,在此之前,新生代的 Parallel Scavenge 收集器一直处于比较尴尬的状态。

原因是,如果新生代选择了 Parallel Scavenge 收集器,老年代除了 Serial Old(PS MarkSweep)收集器外别无选择(Parallel Scavenge 收集器无法与 CMS 收集器配合工作)。

由于老年代 Serial Old 收集器在服务端应用性能上的“拖累”,使用了 Parallel Scavenge 收集器也未必能在整体应用上获得吞吐量最大化的效果,由于单线程的老年代收集中无法充分利用服务器多 CPU 的处理能力,在老年代很大而且硬件比较高级的环境中,这种组合的吞吐量甚至还不一定有 ParNew 加 CMS 的组合“给力”。

直到 Parallel Old 收集器出现后,“吞吐量优先”收集器终于有了比较名副其实的应用组合,在注重吞吐量以及 CPU 资源敏感的场合,都可以优先考虑 Parallel Scavenge 加 Parallel Old 收集器。Parallel Old 收集器的工作过程如下图所示。

⑥,老年代-CMS收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器

目前很大一部分的 Java 应用集中在互联网站或者 B/S 系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。CMS 收集器就非常符合这类应用的需求。

从名字(包含"Mark Sweep")上就可以看出,CMS 收集器是基于“标记—清除”算法实现的,它的运作过程相对于前面几种收集器来说更复杂一些,整个过程分为4个步骤,包括:

  1. 初始标记(CMS initial mark)
  2. 并发标记(CMS concurrent mark)
  3. 重新标记(CMS remark)
  4. 并发清除(CMS concurrent sweep)

其中,初始标记、重新标记这两个步骤仍然需要"Stop The World"。初始标记仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快,并发标记阶段就是进行 GC RootsTracing 的过程,而重新标记阶段则是为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。

由于整个过程中耗时最长的并发标记和并发清除过程收集器线程都可以与用户线程一起工作,所以,从总体上来说,CMS 收集器的内存回收过程是与用户线程一起并发执行的。

CMS 是一款优秀的收集器,它的主要优点在名字上已经体现出来了:并发收集、低停顿,但是 CMS 还远达不到完美的程度,它有以下 3 个明显的缺点:

第一、导致吞吐量降低。CMS 收集器对 CPU 资源非常敏感。其实,面向并发设计的程序都对 CPU 资源比较敏感。在并发阶段,它虽然不会导致用户线程停顿,但是会因为占用了一部分线程(或者说CPU资源)而导致应用程序变慢,总吞吐量会降低。

CMS 默认启动的回收线程数是(CPU数量+3)/4,也就是当 CPU 在4个以上时,并发回收时垃圾收集线程不少于 25% 的 CPU 资源,并且随着 CPU 数量的增加而下降。但是当 CPU 不足 4 个(譬如2个)时,CMS 对用户程序的影响就可能变得很大,如果本来 CPU 负载就比较大,还分出一半的运算能力去执行收集器线程,就可能导致用户程序的执行速度忽然降低了 50%,其实也让人无法接受。

第二、CMS 收集器无法处理浮动垃圾(Floating Garbage),可能出现"Concurrent Mode Failure"失败而导致另一次 Full GC(新生代和老年代同时回收) 的产生。由于 CMS 并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS 无法在当次收集中处理掉它们,只好留待下一次 GC 时再清理掉。这一部分垃圾就称为“浮动垃圾”。

也是由于在垃圾收集阶段用户线程还需要运行,那也就还需要预留有足够的内存空间给用户线程使用,因此 CMS 收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,需要预留一部分空间提供并发收集时的程序运作使用。

在 JDK 1.5 的默认设置下,CMS 收集器当老年代使用了 68% 的空间后就会被激活,这是一个偏保守的设置,如果在应用中老年代增长不是太快,可以适当调高参数-XX:CMSInitiatingOccupancyFraction的值来提高触发百分比,以便降低内存回收次数从而获取更好的性能,在 JDK 1.6 中,CMS 收集器的启动阈值已经提升至 92% 。

要是 CMS 运行期间预留的内存无法满足程序需要,就会出现一次"Concurrent Mode Failure"失败,这时虚拟机将启动后备预案:临时启用 Serial Old 收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了。所以说参数-XX:CM SInitiatingOccupancyFraction设置得太高很容易导致大量"Concurrent Mode Failure"失败,性能反而降低。

第三、产生空间碎片。 CMS 是一款基于“标记—清除”算法实现的收集器,这意味着收集结束时会有大量空间碎片产生。空间碎片过多时,将会给大对象分配带来很大麻烦,往往会出现老年代还有很大空间剩余,但是无法找到足够大的连续空间来分配当前对象,不得不提前触发一次 Full GC 。

为了解决这个问题,CMS 收集器提供了一个-XX:+UseCMSCompactAtFullCollection开关参数(默认就是开启的),用于在CMS收集器顶不住要进行 FullGC 时开启内存碎片的合并整理过程,内存整理的过程是无法并发的,空间碎片问题没有了,但停顿时间不得不变长。虚拟机设计者还提供了另外一个参数-XX:CMSFullGCsBeforeCompaction,这个参数是用于设置执行多少次不压缩的 Full GC 后,跟着来一次带压缩的(默认值为0,表示每次进入Full GC时都进行碎片整理)。

 

⑦,G1收集器

G1(Garbage-First)收集器是当今收集器技术发展的最前沿成果之一,G1 是一款面向服务端应用的垃圾收集器。HotSpot 开发团队赋予它的使命是(在比较长期的)未来可以替换掉 JDK 1.5 中发布的 CMS 收集器。与其他 GC 收集器相比,G1 具备如下特点。

并行与并发: G1 能充分利用多 CPU、多核环境下的硬件优势,使用多个CPU(CPU或者CPU核心)来缩短 Stop-The-World 停顿的时间,部分其他收集器原本需要停顿 Java 线程执行的 GC 动作,G1 收集器仍然可以通过并发的方式让 Java 程序继续执行。

分代收集: 与其他收集器一样,分代概念在 G1 中依然得以保留。虽然 G1 可以不需要其他收集器配合就能独立管理整个 GC 堆,但它能够采用不同的方式去处理新创建的对象和已经存活了一段时间、熬过多次 GC 的旧对象以获取更好的收集效果。

空间整合: 与 CMS 的“标记—清理”算法不同,G1 从整体来看是基于“标记—整理”算法实现的收集器,从局部(两个 Region 之间)上来看是基于“复制”算法实现的,但无论如何,这两种算法都意味着 G1 运作期间不会产生内存空间碎片,收集后能提供规整的可用内存。这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次 GC 。

可预测的停顿: 这是 G1 相对于 CMS 的另一大优势,降低停顿时间是 G1 和 CMS 共同的关注点,但 G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒,这几乎已经是实时 Java(RTSJ)的垃圾收集器的特征了。

在 G1 之前的其他收集器进行收集的范围都是整个新生代或者老年代,而 G1 不再是这样。使用 G1 收集器时,Java 堆的内存布局就与其他收集器有很大差别,它将整个 Java 堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分 Region (不需要连续)的集合。

G1 收集器之所以能建立可预测的停顿时间模型,是因为它可以有计划地避免在整个Java堆中进行全区域的垃圾收集。G1 在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的 Region(这也就是Garbage-First名称的来由),保证了 G1 收集器在有限的时间内可以获取尽可能高的收集效率。

在 G1 收集器中,Region 之间的对象引用以及其他收集器中的新生代与老年代之间的对象引用,虚拟机都是使用 Remembered Set 来避免全堆扫描的。

G1 中每个Region 都有一个与之对应的 Remembered Set,虚拟机发现程序在对 Reference 类型的数据进行写操作时,会产生一个 Write Barrier 暂时中断写操作,检查 Reference 引用的对象是否处于不同的 Region 之中(在分代的例子中就是检查是否老年代中的对象引用了新生代中的对象),如果是,便通过 CardTable 把相关引用信息记录到被引用对象所属的 Region 的 Remembered Set 之中。当进行内存回收时,在 GC 根节点的枚举范围中加入 Remembered Set 即可保证不对全堆扫描也不会有遗漏。

如果不计算维护 Remembered Set 的操作,G1 收集器的运作大致可划分为以下几个步骤:

  1. 初始标记(Initial Marking)
  2. 并发标记(Concurrent Marking)
  3. 最终标记(Final Marking)
  4. 筛选回收(Live Data Counting and Evacuation)

G1 的前几个步骤的运作过程和 CMS 有很多相似之处。

初始标记阶段仅仅只是标记一下 GC Roots 能直接关联到的对象,并且修改 TAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可用的 Region 中创建新对象,这阶段需要停顿线程,但耗时很短。

并发标记阶段是从 GC Root 开始对堆中对象进行可达性分析,找出存活的对象,这阶段耗时较长,但可与用户程序并发执行。

而最终标记阶段则是为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程 Remembered Set Logs 里面,最终标记阶段需要把 Remembered Set Logs 的数据合并到 Remembered Set 中,这阶段需要停顿线程,但是可并行执行。

最后在筛选回收阶段首先对各个 Region 的回收价值和成本进行排序,根据用户所期望的 GC 停顿时间来制定回收计划,从Sun公司透露出来的信息来看,这个阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分 Region,时间是用户可控制的,而且停顿用户线程将大幅提高收集效率。通过下图可以比较清楚地看到G1收集器的运作步骤中并发和需要停顿的阶段。

垃圾收集器参数总结:

五,内存分配与回收策略

对象的内存分配,往大方向讲,就是在堆上分配,对象主要分配在新生代的Eden区上。少数情况下也可能会直接分配在老年代中,分配的规则并不是百分之百固定的,其细节取决于当前使用的是哪一种垃圾收集器组合,还有虚拟机中与内存相关的参数的设置。

5.1,对象优先在Eden分配

大多数情况下,对象在新生代 Eden 区中分配。当 Eden 区没有足够空间进行分配时,虚拟机将发起一次 Minor GC。

虚拟机提供了-XX:+PrintGCDetails这个收集器日志参数,告诉虚拟机在发生垃圾收集行为时打印内存回收日志,并且在进程退出的时候输出当前的内存各区域分配情况。

public class EdenTest {

    private static final int _1MB = 1024 * 1024;
    /**
     *VM参数:-verbose:gc-Xms20M-Xmx20M-Xmn10M-XX:+PrintGCDetails
     -XX:SurvivorRatio=8
     */
    public static void testAllocation () {
        byte[] allocation1,allocation2,allocation3,allocation4;
        allocation1 = new byte[2 * _1MB];
        allocation2 = new byte[2 * _1MB];
        allocation3 = new byte[2 * _1MB];
        allocation4 = new byte[4 * _1MB];//出现一次Minor GC
    }

    public static void main(String[] args) {
        testAllocation();
    }

}

运行结果:

[GC (Allocation Failure) [PSYoungGen: 6599K->1023K(9216K)] 6599K->1878K(19456K), 0.0020210 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
Heap
 PSYoungGen      total 9216K, used 7489K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
  eden space 8192K, 78% used [0x00000000ff600000,0x00000000ffc50800,0x00000000ffe00000)
  from space 1024K, 99% used [0x00000000ffe00000,0x00000000ffeffe08,0x00000000fff00000)
  to   space 1024K, 0% used [0x00000000fff00000,0x00000000fff00000,0x0000000100000000)
 ParOldGen       total 10240K, used 4951K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
  object space 10240K, 48% used [0x00000000fec00000,0x00000000ff0d5d48,0x00000000ff600000)
 Metaspace       used 3217K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 339K, capacity 388K, committed 512K, reserved 1048576K
 

上方代码的 testAllocation() 方法中,尝试分配 3 个 2MB 大小和 1 个 4MB 大小的对象,在运行时通过-Xms20M-Xmx20M-Xmn10M这 3 个参数限制了 Java 堆大小为 20MB ,不可扩展,其中 10MB 分配给新生代,剩下的 10MB 分配给老年代。-XX:SurvivorRatio=8决定了新生代中 Eden 区与一个 Survivor 区的空间比例是 8:1,从输出的结果也可以清晰地看到 eden space 8192K、from space 1024K、to space 1024K 的信息,新生代总可用空间为 9216KB(Eden区+1个Survivor区的总容量)。

执行 testAllocation() 中分配 allocation4 对象的语句时会发生一次 Minor GC,这次 GC 的结果是新生代 6599KB 变为 1023KB ,而总内存占用量则几乎没有减少(因为 allocation1、allocation2、allocation3 三个对象都是存活的,虚拟机几乎没有找到可回收的对象)。

这次 GC 发生的原因是给 allocation4 分配内存的时候,发现 Eden 已经被占用了 6MB,剩余空间已不足以分配 allocation4 所需的 4MB 内存,因此发生 Minor GC。GC 期间虚拟机又发现已有的 3 个 2MB 大小的对象全部无法放入 Survivor 空间(Survivor 空间只有 1MB 大小),所以只好通过分配担保机制提前转移到老年代去。

这次 GC 结束后,4MB 的 allocation4 对象顺利分配在 Eden 中,因此程序执行完的结果是 Eden 占用 4MB(被allocation4占用),Survivor 空闲,老年代被占用 6MB(被allocation1、allocation2、allocation3占用)。通过 GC 日志可以证实这一点。

5.2,Minor GC 和 Full GC 有什么不一样吗?

  • 新生代 GC(Minor GC):指发生在新生代的垃圾收集动作,因为 Java 对象大多都具备朝生夕灭的特性,所以 Minor GC 非常频繁,一般回收速度也比较快。

  • 老年代 GC(Major GC/Full GC):指发生在老年代的 GC,出现了 Major GC,经常会伴随至少一次的 Minor GC(但非绝对的,在 Parallel Scavenge 收集器的收集策略里就有直接进行 Major GC 的策略选择过程)。Major GC 的速度一般会比 Minor GC 慢 10 倍以上。

5.3,大对象直接进入老年代

所谓的大对象是指,需要大量连续内存空间的 Java 对象,最典型的大对象就是那种很长的字符串以及数组( byte[] 数组就是典型的大对象)。大对象对虚拟机的内存分配来说就是一个坏消息(特别是短命大对象,写程序的时候应当避免),经常出现大对象容易导致内存还有不少空间时就提前触发垃圾收集以获取足够的连续空间来“安置”它们。

虚拟机提供了一个 -XX:PretenureSizeThreshold 参数,令大于这个设置值的对象直接在老年代分配。这样做的目的是避免在 Eden 区及两个 Survivor 区之间发生大量的内存复制。


public class EdenTest {

    private static final int _1MB = 1024 * 1024;

    /**
     *VM参数:-verbose:gc-Xms20M-Xmx20M-Xmn10M-XX:+PrintGCDetails-XX:SurvivorRatio=8
     *-XX:PretenureSizeThreshold=3145728
     */
    public static void testPretenureSizeThreshold () {
        byte[] allocation;
        allocation = new byte[4 * _1MB]; //直接分配在老年代中
    }

    public static void main(String[] args) {
        testPretenureSizeThreshold();
    }

}

运行结果:

Heap
 PSYoungGen      total 9216K, used 6763K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
  eden space 8192K, 82% used [0x00000000ff600000,0x00000000ffc9ae38,0x00000000ffe00000)
  from space 1024K, 0% used [0x00000000fff00000,0x00000000fff00000,0x0000000100000000)
  to   space 1024K, 0% used [0x00000000ffe00000,0x00000000ffe00000,0x00000000fff00000)
 ParOldGen       total 10240K, used 4096K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
  object space 10240K, 40% used [0x00000000fec00000,0x00000000ff000010,0x00000000ff600000)
 Metaspace       used 3182K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 337K, capacity 388K, committed 512K, reserved 1048576K

执行以上代码中的 testPretenureSizeThreshold() 方法后,我们看到 Eden 空间几乎没有被使用,而老年代的 10MB 空间被使用了 40%,也就是 4MB 的 allocation 对象直接就分配在老年代中,这是因为 PretenureSizeThreshold 参数被设置为 3MB(就是 3145728,这个参数不能像 -Xmx 之类的参数一样直接写 3MB),因此超过 3MB 的对象都会直接在老年代进行分配。

注意 PretenureSizeThreshold 参数只对 Serial 和 ParNew 两款收集器有效,Parallel Scavenge 收集器不认识这个参数,Parallel Scavenge 收集器一般并不需要设置。如果遇到必须使用此参数的场合,可以考虑 ParNew 加 CMS 的收集器组合。

5.4,长期存活的对象将进入老年代

虚拟机给每个对象定义了一个对象年龄(Age)计数器。

如果对象在 Eden 出生并经过第一次 Minor GC 后仍然存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间中,并且对象年龄设为 1 。对象在 Survivor 区中每“熬过”一次 Minor GC,年龄就增加 1 岁,当它的年龄增加到一定程度(默认为 15 岁),就将会被晋升到老年代中。

对象晋升老年代的年龄阈值,可以通过参数-XX:MaxTenuringThreshold设置。

 

5.5,动态对象年龄判定

为了能更好地适应不同程序的内存状况,无须等到 MaxTenuringThreshold 中要求的年龄,同年对象达到 Survivor 空间的一半后,他们以及年龄大于他们的对象都将直接进入老年代。

5.6,空间分配担保

在发生 Minor GC 之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代
所有对象总空间,如果这个条件成立,那么 Minor GC 可以确保是安全的。

只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行 Minor GC ,否则将进行 Full GC 。

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值