JVM&垃圾回收

1.JRE、JDK

  • JRE:Java的运行时环境,JVM的标准加上实现的一大堆基础类库。
  • JDK:包含JRE,还提供了一些小工具,如Javac、Java、Jar。

2.JVM

3.类的加载过程

  • 加载
    将外部的.class文件加载到方法区。

  • 验证
    不能将任何的.class文件都加载,不符合规范的将抛出java.lang.VerifyError错误。(如低版本的JVM无法加载一些高版本的类库)

  • 准备
    为一些类变量分配内存,并将其初始化为默认值。此时,实例对象还没有分配内存,所以这些动作是在方法区上进行的。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    code-snippet 1
         public class A {
    //类变量
             static int a ;
             public static void main(String[] args) {
                 System.out.println(a);
             }
         }
    a:0
    code-snippet 2
     public class A {
         public static void main(String[] args) {
    //局部变量
             int a ;
             System.out.println(a);
         }
     }
    编译错误

    类变量有两次赋初始值的过程:1.准备阶段,赋予初始值(也可以是指定值) 2.初始化阶段,赋予程序员指定的值

    局部变量不存在准备阶段,如果没有赋予初始值就不能使用

  • 解析
    将符号引用替换成直接引用
    符号引用:一种定义,可以是任何字面上的含义
    直接引用:直接指向目标的指针、相对变量,直接引用的对象都存在于内存中

    • 类或接口的解析
    • 类方法的解析
    • 接口方法的解析
    • 字段解析
  • 初始化
    初始化成员变量

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    public class A {
         static int a = 0 ;
         static {
             a = 1;
             b = 1;
         }
         static int b = 0;
         public static void main(String[] args) {
             System.out.println(a); //a:1
             System.out.println(b); //b:0
         }
     }
    1. static语句块只能访问到定义在static语句块之前的变量
    2. JVM会保证在子类的初始化方法执行之前,父类的初始化方法已经执行完毕

    类初始化顺序

    1. 父类静态变量、静态代码块(只有第一次加载类时执行)
    2. 子类静态变量、静态代码块(只有第一次加载类时执行)
    3. 父类非静态代码块
    4. 父类构造器
    5. 子类非静态代码块
    6. 子类构造器

4.clinit和init

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class A {
     static {
         System.out.println("1");
     }
     public A(){
         System.out.println("2");
         }
     }
     public class B extends A {
         static{
         System.out.println("a");
     }
     public B(){
         System.out.println("b");
     }
     public static void main(String[] args){
         A ab = new B();
         ab = new B();
     }
 }

// 1
// a
// 2
// b
// 2
// b

static字段和static代码块属于类,在类的初始化阶段就被执行,类的信息存放在方法区,同一个类加载器只有一份,所以上面的static只会执行一次,对应clinit方法。

对象初始化,在new一个新对象时,会调用构造方法来初始化对象的属性,对应init,每次新建对象都会执行

5.双亲委派机制


除了顶层的启动类加载器以外,其余的类加载器,在加载之前,都会委派给其父加载器进行加载。这样一层层向上传递,直到祖先们都无法胜任,它才会真正的加载。

6.引用级别

  • 强引用
    当内存空间不足时,JVM抛出OutOfMemoryError。即使程序异常终止,这种对象也不会被回收,是最普通最强硬的一种存在,只有在和GC Roots断绝关系时才会被消灭掉。

  • 软引用
    用于维护一些可有可无的对象。在内存足够时,软引用对象不会被回收,内存不足时,系统会回收软引用对象。如果回收了软引用对象仍然没有足够的内存,才会抛出内存溢出异常。软引用可以和引用队列联合使用,如果软引用的对象被垃圾回收,JVM就会把这个软引用加入到与之关联的引用队列中。

  • 弱引用
    垃圾回收时,无论内存是否充足,都会回收被弱引用关联的对象,它拥有更短的生命周期。

  • 虚引用
    如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收。虚引用主要用来跟踪对象被垃圾回收的活动。虚引用必须和引用队列联合使用,当垃圾回收准备回收一个对象时,如果发现它还有虚引用,就会在回收对象之前,把这个虚引用加入到与之关联的引用队列中。

7.典型OOM场景


除了程序计数器,其它区域都有可能会发生OOM,但最常见的还是发生在堆上。

  • 内存容量太小了,需要扩容,或者需要调整堆的空间
  • 错误的引用的方式,发生了内存泄漏。如线程池里的线程,在复用的情况下忘记清理ThreadLocal的内容。
  • 接口没有进行范围校验,外部传参超出范围。比如数据库查询时的每页条数等。
  • 对堆外内存无限制的使用。这种情况更加严重,会造成操作系统内存耗尽。

8.垃圾回收算法

  1. 标记清除

    • 标记:从根集合扫描,对存活的对象进行标记
    • 清除:从堆内粗从头到尾进行线性遍历,回收不可达对象内存
  2. 标记整理

    • 标记:从根集合扫描,对存活的对象进行标记
    • 整理:移动所有存活的对象,按照内存地址排序,内存地址以后的内存全部回收。
  3. 复制算法
    分对象面和空闲面,对象在对象面创建,回收时,存活的对象被从对象面复制到空闲面,后将对象面所有对象清除。

  4. 分代收集
    把死的快的对象所占区域,叫作年轻代。其他活的长的对象所占的区域,叫作老年代。

    • 年轻代:使用复制算法

      年轻代分为:一个伊甸园空间(Eden),两个幸存者空间(Survivor)
      当年轻代中的Eden区分配满的时候,就会触发年轻代的GC(Minoe GC)。

      1. 在Eden区执行了第一次GC之后,存活的对象会移动到其中一个Survivor区(from)

      2. Eden再次GC,这时会采用复制算法,将Eden和from区一起清理。存活的对象会被复制到to区,接下来清空from区就可以了。

        Eden:from:to = 8:1:1 -XX:SurvivorRatio 默认为8

        TLAB:JVM默认给每个线程开辟一个buffer区域来加速对象分配,这个buffer就放在Eden区。对象的分配优先在TLAB上分配,但通常TLAB很小,所以对象比较大时,会在Eden的共享区域进行分配。

    • 老年代:使用标记清除、标记整理算法

9.对象如何进入老年代

  1. 提升
    每当发生一次Minor GC,存活下的对象年龄会加1,达到阈值(-XX:+MaxTenuringThreshold,最大值为15),就会提升到老年代。

  2. 分配担保
    每次存活的对象,都会放入其中一个幸存区,默认比例为10%。但无法保证每次存活的对象小于10%,当Survivor空间不够,就需要依赖其他内存(老年代)进行分配担保。

  3. 大对象直接在老年代分配
    超过某个大小的对象将直接在老年代分配,-XX:PretenureSizeThreshold进行配置,默认为0。

  4. 动态对象年龄判定
    有的垃圾回收算法,并不需要年龄达到15,会使用一些动态的计算方法,如幸存区中相同年龄对象大小的和大于幸存区的一半,大于或等于age的对象将直接进入老年代

10.垃圾回收器

  • 年轻代垃圾收集器

    1. Serial垃圾收集器
      处理GC的只有一条线程,并且在垃圾回收过程中暂停一切用户线程。

    2. ParNew垃圾收集器
      Serial的多线程版本,多条GC线程并行的进行垃圾清理,清理过程中依然要停止用户线程。

    3. Parallel Scavenge垃圾收集器
      另一个多线程版本的垃圾回收器。与ParNew的主要区别:

      • Parallel Scacenge:追求CPU吞吐量,适合没有交互的后台计算。弱交互强计算。
      • ParNew:追求降低用户停顿时间,适合交互式应用。强交互弱计算。
  • 老年代垃圾收集器

    1. Serial Old垃圾收集器
      与年轻代的Serial垃圾收集器对应,都是单线程版本,同样适用客户端使用。
      年轻代的Serial,使用复制算法,老年代的Serial Old,使用标记-整理算法。

    2. Parallel Old
      Parallel Scavenge的老年代版本,追求CPU吞吐量。

    3. CMS垃圾收集器
      垃圾收集时用户线程和GC线程可以并发执行。

11.CMS回收过程

Minor GC:发生在年轻代的GC

Major GC:发生在老年代的GC

Full GC:全堆垃圾回收,如Metaspace区引起年轻代和老年代的回收

CMS(主要并发标记清除收集器),年轻代使用复制算法,老年代使用标记-清除算法

  1. 初始标记
    初始标记阶段,只标记直接关联GC root的对象,不用向下追溯。最耗时的就是tracing阶段,极大地缩短了初始标记阶段,所以该过程时间较短,这个过程是STW的。

  2. 并发标记
    在初始标记的基础上,进行并发标记。tracing,标记所有可达的对象。这个阶段会比较久,但却可以和用户线程并行。

    • 有些对象,从新生代晋升到了老年代
    • 有些对象,直接分配到了老年代
    • 老年代或者新生代的对象引用发生了变化
  3. 并发预清理
    不需要STW,目的是缩短重新标记的时间。这个时候,老年代中被标记为dirty的卡页中的对象,就会被重新标记,然后清除掉dirty的状态。由于这个阶段也是可以并发的,有可能还会有处于dirty状态的卡页。

  4. 并发可取消的预清理
    重新标记阶段是STW的,所以会有很多次预清理动作。可以在满足某些条件时,可以终止,如迭代次数、有用工作量、消耗的系统时间等。

    意图:避免回扫年轻代的大量对象;当满足重新标记时,自动退出。

  5. 重新标记
    CMS尝试在年轻代尽可能空的情况下运行重新标记,以免接连多次发生STW。这是CMS垃圾回收阶段的第二次STW阶段,目标是完成老年代中所有存活对象的标记。

  6. 并发清理
    用户线程被重新激活,目标时删掉不可达对象。

    由于CMS并发清理阶段用户线程还在运行中,CMS无法在当次GC中处理它们,只好留在下一处GC时再清理掉,这一部分垃圾称为”浮动垃圾”。

  7. 并发重置
    与用户线程并发执行,重置CMS算法相关的内部数据,为下一次GC循环做准备。

12.内存碎片

CMS执行过程中,用户线程还在运行,如果老年代空间快满了,才开始回收,用户线程可能会产生”Concurrent Mode Failure”的错误,这时会临时启用Serial Old收集器来重新进行老年代垃圾收集,这样STW会很久。

CMS对老年代回收的时候,并没有内存的整理阶段。程序长时间运行后,碎片太多,如果你申请一个稍大的对象,就会引起分配失败。(1) UseCMSCompactAtFullCollection(默认开启),在进行Full GC时,进行碎片整理。内存碎片整理是无法并发的,STW时间较长。(2)CMSFullGCsBeforeCompation(默认为0),每隔多少次不压缩的Full GC后,执行一次带压缩的Full GC。

13.CMS

优势:

  1. 低延迟,尤其对大堆来说。大部分垃圾回收过程并发执行。

劣势:

  1. 内存碎片问题。Full GC的整理阶段,会造成较长时间的停顿。
  2. 需要预留空间,用来收集”浮动垃圾”。
  3. 使用更多的CPU资源。

14.G1

G1也是有Eden区和Survivor区的概念的,但内存上不是连续的。小区域(Region)的大小是固定的,名字叫做小队区,小队区可以是Eden,也可以是Survivor,还可以是Old。Region大小(-XX:G1HeapRegionSize=M)一致,为1M-32M字节间的一个2的幂指数。如果对象太大,大小超过Region 50%的对象,将会分配在Humongous Region。垃圾最多的小堆区,会被优先收集。
-XX:MaxGCPauseMills=10

15.卡表与RSet

  • 卡表
    老年代被分成众多的卡页(一般是2的次幂),卡表就是用于标记卡页状态的一个集合,每个卡表对应一个卡页。如果年轻代有对象分配,而且老年代有对象指向这个新对象,那么这个老年代对象所对应内存的卡页,就会标识为dirty,卡表只需要很小的存储空间就可以保留这些状态。垃圾回收时,就可以先读卡表,进行快速判断。

  • RSet
    RSet是一个空间换时间的数据结构。卡表是一种points-out(我引用了谁对象)的结构,而RSet是一种points-into(谁引用了我的对象)的结构。RSet类似一个Hash,key是引用的Region地址,value是引用它的对象的卡页集合

16.G1回收过程

  1. 年轻代回收
    JVM启动时,G1会先准备好Eden区,程序在运行时不断创建到Eden区,当所有的Eden区满了,启动一次年轻代垃圾回收过程。年轻代是一个STW过程,它的跨代引用使用RSet来追溯,会一次回收掉年轻代的所有Region。

  2. 并发标记
    当整个堆内存使用达到一定比例(-XX:InitatingHeapOccupancyPercent 默认45%),启动并发标记阶段。为混合回收提供标记服务,类似CMS的垃圾回收过程。

  3. 混合回收
    通过并发标记阶段,已经统计了老年代的垃圾占比,在Minor GC之后,如果占比达到阈值(-XX:G1HeapWastePercent 默认是堆大小的5%,该参数可以调整Mixed GC的频率),下次就会触发混合回收。参数G1MixedGCCountTarget:一次并发标记之后,最多执行Mixed GC的次数。

请作者喝瓶肥宅快乐水