[解密JVM-07] 运行时数据区域:堆

1、前言

  这一章继续讲述运行时数据区的一个组成部分:堆。
在这里插入图片描述
  本章重点讲解的堆的内容会涉及到:GC、对象创建、内存策略等一系列知识。

2、堆的核心概述

  一个 JVM 实例就对应一个运行时数据区,即对应一个堆内存,堆是 Java 内存管理的核心区域。Java 堆在 JVM 启动的时候就被创建,空间大小也就确定了,是 JVM 管理的最大一块空间。可以通过参数来调节堆内存的大小,下面会讲解到。

  我们前面章节已经学过,虚拟机栈、本地方法栈和程序计数器是线程私有的,而方法区和本章的堆是线程共享的。即便如此,JVM 也可以在堆中创建线程私有的缓冲区:线程本地分配缓冲区(Thread Local Allocation Buffer,TLAB),这个区域也是线程私有的,但是空间很小。

  JVM 虚拟机规范上描述说:所有的对象实例以及数组都应当在运行时分配在堆上。但是随着 JVM 的发展,很多工程师不断挑战这个规定,把一些对象实例不分配在堆上,这一点也会在后面详细说明。

  按照 JVM 规范说的,数组和对象创建出来后是在堆上面的,我们前面学虚拟机栈的时候也知道,数组和对象可能永远不会存储在栈上,因为虚拟机栈的栈帧中有一个局部变量表,里面除了基本数据类型,还保存了对象引用,这个引用指向的就是对象或者数组在堆中的实体。
在这里插入图片描述

  和栈不同,在方法结束后,堆中的对象不会马上被移除,也就是说,一个方法执行结束,它的栈帧被弹出,但是方法产生的对象实体还在堆中,仅仅在垃圾收集的时候才会被移除(没有引用的对象实体都是垃圾)。

  堆也是GC(Garbage Collection,垃圾收集器)执行垃圾回收的重点区域。

  现代垃圾收集器大部分都是基于分代收集理论设计,即把堆分成若干区域:
  在 Java7 及以前的堆内存,在逻辑上分为三个区域:新生区、养老区、永久区。
  在 Java8 及以后的堆内存,在逻辑上分为三个区域:新生区、养老区、元空间。
  对于同一段代码:

public class SimpleHeap {
    private int id;//属性、成员变量

    public SimpleHeap(int id) {
        this.id = id;
    }

    public void show() {
        System.out.println("My ID is " + id);
    }
    public static void main(String[] args) {
        SimpleHeap sl = new SimpleHeap(1);
        SimpleHeap s2 = new SimpleHeap(2);

        int[] arr = new int[10];

        Object[] arr1 = new Object[10];
    }
}

  加上参数-XX:+PrintGCDetails使用 Java7 以前和 Java8 以后的 JDK 编译和执行会有以下区别:
在这里插入图片描述
在这里插入图片描述
  很多书本说的新生区、新生代、年轻代,指的是一个东西。养老区、老年区、老年代也是一个东西。永久区和永久代也是一个东西。只是翻译不同而已。
在这里插入图片描述

3、设置堆内存大小与OOM

  堆内存大小有初始值和最大值,默认情况下,初始值为物理电脑内存的 64 分之一,最大值为物理电脑内存的 4 分之一。一旦堆区的内存超过指定的最大内存,会抛出 OOM 异常。

  我们可以通过两个参数来修改这个默认设置:-Xms-Xmx
  -Xms 用来设置堆空间(年轻代+老年代)的初始内存大小。
  -Xmx 用来设置堆空间(年轻代+老年代)的最大内存大小。
  -X 是jvm的运行参数,ms 是 memory start 的意思。
  开发中建议将初始堆内存和最大的堆内存设置成相同的值。

  下面看一个源码为:

public class HeapDemo {
    public static void main(String[] args) {
        System.out.println("start...");
        try {
            Thread.sleep(1000000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("end...");
    }
}

  设置运行参数为:-Xms600m -Xmx600m

  我们可以通过以下方式来查看正在运行的 Java 进程的堆设置情况:
  1、通过命令 jps 查看正在运行的所有 Java 进程;
在这里插入图片描述

  2、使用命令 jstat -gc 进程id 查看堆内存的使用情况:
在这里插入图片描述
  里面的如 S0C 和 S1C 都表示什么意思得掌握分代理论才知道。下一节讲解。

4、年轻代与老年代

  存储在 JVM 中的 Java 对象可以划分为两类:
  1、生命周期较短的对象,这类对象的创建和消亡都非常迅速;
  2、生命周期非常长的对象,在某些极端的情况下还能够和 JVM 的生命周期保持一致。

  无论是 Java7 以前还是 Java8 以后,堆中都有年轻代(YoungGen)和老年代(OldGen)两个分区。其中年轻代还可以细分为:Eden 空间、Survivor0 空间和 Survivor1 空间。
在这里插入图片描述
  年轻代和老年代的大小一般来说是按比例划分,我们可以通过参数-XX:NewRatio=x来调整,默认情况下是-XX:NewRatio=2,表示年轻代占1,老年代占2,即年轻代占整个堆的三分之一。这个数字指的是老年代占的比例,默认年轻代占 1 成的情况下。

  在年轻代内部又划分三个区域:Eden、Survivor0 和 Survivor1。这三者的比例默认是:8:1:1。
  可以通过参数-XX:SurviviorRatio=x来调整比例,这个 x 就是 Eden 的份数,其他两个默认为 1 的情况下。除了调整比例,还可以用-Xmn直接设置新生代内存的大小(不是比例)。

  一般情况下,几乎所有的 Java 对象都是在 Eden 区域里被 new 出来的。绝大部分的 Java 对象的出生和销毁都是在年轻代。有理论统计,80% 的对象都是 new 出来没多久就死了。

5、对象分配过程

  既然大部分的 Java 对象都是在堆中生成的,而堆又分为两个代,年轻代还划分三个区间,那对象到底是 new 在那个区间里的呢?
  要知道,JVM 为新对象分配内存是一件非常严谨和复杂的任务,JVM 的设计者们不仅要考虑内存如何分配、在哪里分配等问题,还需要考虑 GC 执行完内存回收后是否会在内存空间中产生内存随便。

  大部分情况下,对象分配过程如下:

  1、new 的对象先放在 Eden 区域,此区域有大小限制。

  2、当 Eden 区域空间填满了,程序又要创建对象,那么 JVM 的垃圾回收器将会对 Eden 区域进行垃圾回收(Minor GC),把 Eden 区域里的垃圾(已经没有引用的对象实体)销毁,再把要创建的新对象放到 Eden 区域。然后把不是垃圾的剩余对象移动到幸存者区(Survivor)。

  3、如果又一次触发 Eden 区域的 GC(Minor GC) ,执行之后,会连同 Eden 剩余的对象和 Survivor 里的对象一起放到另一个 Survivor 区域(上面介绍的幸存者区域有 0 和 1 两个)。

  4、一个对象从 Eden 到 Survivor 后,会给这个对象设置一个年龄计数 1,每次对象从一个 Survivor 复制到另一个 Sruvivor,这个计数都会加 1,当一个对象的年龄计数达到 15 (默认值),JVM 会把该对象放到老年代。我们可以通过参数-XX:MaxTenuringThreshold=<N>来设置阈值。

  5、前面说年轻代和老年代的比例可以知道,老年代的区域相对大一点,但也有填满的一天,当老年代内存不足,会触发老年代的 GC (Major GC),对老年代区域里的对象中的垃圾进行销毁。如果老年代的 GC 执行过后还是放不下对象,会产生 OOM 异常。
在这里插入图片描述
  很多书上会把幸存者区(Survivor0、Survivor1)称为 from 区和 to 区,但是都是相对的,复制之后有交换,谁空谁是 to。
  垃圾回收频繁发生在新生区,很少发生在养老区,几乎不发生在永久区或元空间。
在这里插入图片描述

6、三种 GC

  JVM 在进行 GC 时,并非每次都对上面三个区域(年轻代、老年代、方法区)一起回收,大部分的 GC 指的都是年轻代的 GC。

  针对 HotSpot VM 的实现,它里面的 GC 按照回收区域分为两种类型:部分收集(Oartial GC)和整堆收集(Full GC)。
  部分收集指的是不完整的垃圾收集,其中又分为:
  年轻代收集(Minor GC):Eden 区域、S0、S1;
  老年代收集(Major GC)。但是,很多时候 Major GC 会和 Full GC 混淆使用。
  混淆收集(Mixed GC):收集整个年轻代和部分老年代。

  整堆收集指的是整个 Java 对和方法区的垃圾收集。

  下面是最简单的分代式 GC 策略的触发条件:
在这里插入图片描述
  年轻代 Minor GC 触发:Eden 区满了就触发。
  老年代 Major GC 触发:老年代区域空间不足时,会先尝试触发 Minor GC,Eden 还是不足就触发 Major GC。

  触发老年代 Major GC 经常会伴随至少一次的 Minor GC,年轻代的 Minor GC 比老年代的 Major GC 快 10 倍以上。如果老年代 Major GC 之后,内存还不足,就报 OOM 了。

  Full GC 触发:
  1、调用 System.gc() 时,系统减一执行 Full GC,但是不是必然这样;
  2、老年代空间不足;
  3、方法区空间不足;
  4、通过年轻代 Minor GC 后进入老年代的对象的平均大小大于老年代的可用内存。
  5、在 Eden 区、Survivor( from 区)向 Survivor( To 区)复制对象时,复制的对象大于 To 区的可用内存,且老年代的可用内存小于复制对象的大小,那就直接把复制的对象转存到老年代。

  Full GC 时间开销大,在开发或者调优中要避免。

  在 Minor GC 之前, JVM 会检查老年代最大可用的连续空间是否大于年轻代所有对象的总空间。如果大于,说明此次 Minor GC 是安全的,因为有可能年轻代的对象全部放到老年代。如果小于,则查看是否允许担保失败,即参数-XX:HandlePromotionFailure的设置值。

  如果-XX:HandlePromotionFailure=true,JVM 会继续检查老年代最大可用空间是否大于历次晋升到老年代的对象的平均大小。如果大于,则尝试进行一次 Minor GC,但此次仍然是有风险的。如果小于,则改为进行一次 Full GC。
  如果-XX:HandlePromotionFailure=false,JVM 直接进行 Full GC。

  上面两段话描述很拗口,大白话来讲就是:现在 Eden 区域满了,准备进行 Minor GC 了,但是我先检查老年代是否做好“年轻代对象全部拷贝过去”的准备,如果准备好了,那么 Minor GC 就是安全的了,因为当真的出现 Eden 中一个垃圾都没有的情况,幸存者区是放不下的了,只能和幸存者区加起来一起放到老年代。
  换句话说,如果老年代做不到“年轻代对象全部拷贝过去”的准备,即空间不够让你 Eden 的对象全部复制过来,那就先看担保参数是否担保了,如果担保了,继续Minor GC,出了问题有处理方案;如果没有担保,那就为了安全起见,直接 Full GC。

7、堆空间分代思想

  学习了堆空间的分代思想,思考一个问题:为什么要把 Java 堆进行分代呢?

  其实分代的目的就是划分不同的区域,让 GC 对不同的区域有不同的频率。对于年轻代,GC 频率高,然后把那些 GC 好几次都不是垃圾的对象转移到老年代,既然好几次 GC 都发现它不是垃圾,那么它很有可能就是长期存在的对象,移动到老年代,减少对该对象的判断,提高 GC 的效率。

  那不分代可以吗?当然也可以的,因为分代的唯一理由就是优化 GC 性能。如果不分代,就牺牲 GC 的效率了,因为把所有对象都放在一个区域,每次 GC 都有遍历每一个对象,对于某些长期存在的对象,每次都对它进行一次垃圾判断,很浪费性能。

8、内存分配策略

  通过前面的学习,我们知道了,如果对象在年轻代的 Eden 区出生,并且经历一次 Minor GC 后还存活,就会复制到 Survivor 区,并且年龄设置为 1。每熬过一次 Minor GC ,年龄就加一,直到达到阈值,就会被移动到老年代。

  上面只是针对一般情况,但是还有特殊情况:
  1、new 一个 Java 对象,会优先考虑分配到 Eden 区域,如果该对象过大,超出了 Minor GC 之后的 Eden 区域,那只能直接放到老年代了;
  2、JVM 并不一定等到对象达到 15 岁再移到老年代,如果幸存区中相同年龄的对象的大小总和大于幸存者区空间的一半,那么,年龄大于等于这个对象的直接进入老年代,这是一种优化策略;

9、TLAB

  TLAB:Thread Local Allocation Buffer,线程本地分配缓冲区。

  我们知道,堆区是线程共享的,由于对象实例的创建在 JVM 中非常频繁,因此在并发环境下,从堆区中划分内存是线程不安全的。有人考虑,为了避免多个线程操作同一个地址,使用加锁机制,但这样又影响分配速度。

  从内存模型的角度来看,对 Eden 区域继续进行划分,为每一个线程都划分一个私有的缓存区域,它包含在 Eden 空间内。

  多线程同时分配内存时,使用 TLAB 可以避免一系列的非线程安全问题。同时还可以提升分寸分配的吞吐量,因此可以把这种内存分配方式称为快速分配策略。
在这里插入图片描述
  尽管不是所有的对象实例都能够在 TLAB 中成功分配内存,但 JVM 确实是将 TLAB 作为内存分配的首选。我们可以使用参数-XX:UseTLAB来设置是否开启 TLAB 空间。

  既然是空间,我们就可以设置它的大小,默认情况下, TLAB 占用整个 Eden 空间的百分之一,我们可以通过参数-XX:TLABWasteTargetPercent来设置空间百分比大小。

  如果一个对象在 TLAB 空间分配内存失败,那么 JVM 就尝试使用加锁的机制来确保数据操作的原子性,从而直接在 Eden 空间中分配内存。
在这里插入图片描述

10、堆空间的参数设置

  上面介绍当中使用了比较多的各种参数设置,下面来整理一下。

  1、查看该所有参数的默认初始值:-XX:+PrintFlagsInitial
  2、查看所有参数的最终值:-XX:+PrintFlagsFinal
  3、初始堆空间内存:-Xms,默认为物理内存的 64 分之一;
  4、最大对空间内存:-Xmx,默认为物理内存的 4 分之一;
  5、年轻代的内存大小:-Xmn
  6、年轻代和老年代的占比:-XX:NewRatio
  7、Eden和幸存者区的比例:-XX:SurvivorRatio
  8、进入老年代的年龄阈值:-XX:MaxTenuringThreshold
  9、详细的 GC 输出日志:-XX:+PrintGCDetils
  10、简要的 GC 信息:-XX:+PrintGC-verbose:gc
  11、设置空间分配担保:-XX:HandlePromotionFailure

11、逃逸分析

  逃逸分析的基本行为就是在编译期分析对象实例的动态作用域(注意不是引用)。
  比如下面一段代码:

public void useEscapeAnalysis(){
    EscapeAnalysis e = new EscapeAnalysis();
}

  这个类方法里面 new 了一个 EscapeAnalysis 对象,但这个对象实例只能在这个方法内部使用,出了这个方法,这个实例就失去作用了,也就是所谓的逃不出这个作用域,逃逸分析的结果就是这个对象实例没有发生逃逸。
  再看一段代码:

public EscapeAnalysis getInstance(){
    return obj == null? new EscapeAnalysis() : obj;
}

  这个方法返回一个 obj 实例对象,这个实例对象可以被其他的方法调用,逃逸分析的结果就是这个对象发生了逃逸。

  总结来说就是:
  1、当一个对象在方法中被定义后,对象只在方法内部使用,则认为没有发生逃逸;
  2、当一个对象在方法中被被定义后,它被外部方法所引用,则认为发生了逃逸。

  经过编译器的逃逸分析后,JVM 对方法内 new 的对象就有区别对待,对于发生逃逸的对象,就分配在堆内存中;对于没有发生逃逸的对象,则分配到栈内存中(操作数栈)。

  在 JCK 6u23 版本之后,HotSpot 中就默认开启了逃逸分析。对于较早的版本,可以设置参数 -XX:+DoEscapeAnaalysis显式开启逃逸分析,通过参数-XX:+PrintEscapeAnalysis来查看逃逸分析的筛选结果。

  通过上面的介绍,我们知道,如果一个对象只是方法内部使用,为了节省堆空间和减少 GC 的压力,能使用局部变量的,就不要在方法外定义。(虚拟机栈表示:不把我当人看咯?)

  那么,逃逸分析可以做哪些事情呢?
  1、栈上分配:将原本分配到堆上的对象转为分配到栈上,分配完成后,当线程执行完毕,栈空间被回收,局部变量对象也被回收,这样就无须 GC 了;
  2、同步省略:如果一个对象被发现只能从一个线程被访问,那么对于这个对象的操作可以不考虑同步,比如说下面这段代码:

public void f() {
    Object hollis = new Object();
    synchronized(hollis) {
        System.out.println(hollis);
    }
}

  很明显,对象 hollis 是没有发生逃逸的,所以编译器编译后,会自动把这个锁给去掉,因为没啥用,达到同步省略的目的。

public void f(){
	Object hollis = new Object();
	System.out.println(hollis);
}

  3、标量替换:把对象拆解成若干个其中包含的若干成员变量来代替,比如一个代码如下:

class Point{
	private int x;
	private int y;
}

Class Test{
	public static void main(String[] args){
		Point point = new Point(1,2);
		System.out.println("point.x = " + point.x);
		System.out.println("point.y = " + point.y);
	}
}

  经过标量替换后变为:

Class Test{
	public static void main(String[] args){
		int x = 1;
		int y = 1;
		System.out.println("point.x = " + x);
		System.out.println("point.y = " + y);
	}
}

  可以看到,Point 这个聚合量经过逃逸分析后,发现它并没有逃逸,于是就被替换成两个标量了。可以大大减少堆内存的占用和 GC 的判断压力。
  标量替换为栈上分配提供了很好的基础。

  我们使用的 JDK8 是默认开启了标量替换,运行将对象打散分配在栈上。

  关于逃逸分析,其实当前还不太成熟,用的最多的就是利用它实现标量替换。

12、小结

  年轻代是对象诞生、成长、消亡的区域,一个对象在这里产生、应用,最后被垃圾回收器收集、结束生命。

  老年代放置生命周期长的对象,通常是从幸存者区中筛选拷贝过来的。当然,也有特殊情况,普通的 Java 对象会被分配到 TLAB 上,如果对象较大,则 JVM 会师徒直接分配在 Eden 的其他位置上,如果还是太大,则直接扔到老年代。

  年轻代的 GC 称为 Minor GC,老年代的 GC 称为 Major GC ,很多时候老年代 GC 和 Full GC 是混着来的,通过第 6 节的介绍就可以知道为什么。

  年轻代的 GC 速度比 老年代的 GC 快 10 倍以上。老年代的 GC 发生的频率远小于年轻代。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值