JVM 运行时内存空间详解——堆

Posted 格子衫111

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了JVM 运行时内存空间详解——堆相关的知识,希望对你有一定的参考价值。


通过上一篇文章,我们大体了解了JVM的整体架构,其分为:元数据(JDK7是方法区)、虚拟机栈、本地方法栈、程序计数器几个部分。

本篇文章,咱们对进行剖析,一探究竟。

1. 什么是Java 堆

对于Java应用程序来说, Java堆(Java Heap) 是虚拟机所管理的内存中最大的一块。 Java堆是被所有线程共享的一块内存区域, 在虚拟机启动时创建。
此内存区域的唯一目的就是存放对象实例, Java世界里“几乎”所有的对象实例都在这里分配内存。
几乎”是指从实现角度来看, 随着Java语言的发展,现在已经能看到些许迹象表明日后可能出现值类型的支持, 即使只考虑现在, 由于即时编译技术的进步, 尤其是逃逸分析技术的日渐强大, 栈上分配、标量替换优化手段已经导致一些微妙的变化悄然发生, 所以说Java对象实例都分配在堆上也渐渐变得不是那么绝对了。

2. 堆的特点

  1. 是Java虚拟机所管理的内存中最大的一块。
  2. 堆是jvm所有线程共享的。
    堆中也包含私有的线程缓冲区 Thread LocalAllocation Buffer (TLAB)。
  3. 虚拟机启动的时候创建
  4. 唯一目的就是存放对象实例,几乎所有的对象实例以及数组都要在这里分配内存。
  5. Java堆是垃圾收集器管理的主要区域
  6. 因此很多时候java堆也被称为“GC堆”(Garbage Collected Heap)。从内存回收的角度来看,由于现在收集器基本都采用分代收集算法,所以Java堆还可以细分为:新生代和老年代;新生代又可以分为:Eden 空间、From Survivor空间、To Survivor空间。
  7. Java堆是计算机物理存储上不连续的、逻辑上是连续的,也是大小可调节的(通过-Xms和-Xmx控制)。
  8. 方法结束后,堆中对象不会马上移出仅仅在垃圾回收的时候时候才移除。
  9. 如果在堆中没有内存完成实例的分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError异常

3. 如何设置堆空间的大小

内存大小 -Xmx(最大)/-Xms(最小)
使用示例: -Xmx20m -Xms5m
说明: 当下Java应用最大可用内存为20M, 最小内存为5M

测试代码:

public class TestVm {
    public static void main(String[] args) {
        // 堆最大空间(如有设置,则和设置的最大值相同)
        System.out.print("Xmx=");
        System.out.println(Runtime.getRuntime().maxMemory() / 1024.0 / 1024 + "M");

        // 空闲空间
        System.out.print("free mem=");
        System.out.println(Runtime.getRuntime().freeMemory() / 1024.0 / 1024 + "M");

        // 总共使用空间
        System.out.print("total mem=");
        System.out.println(Runtime.getRuntime().totalMemory() / 1024.0 / 1024 + "M");
    }
}

在IDEA中设置堆空间:

执行结果:

可以看到,最大20M, 空闲4M多,当前使用了 6M

现在再补充两行代码,给数组分配5M,再看一下结果如何:

public class TestVm {
    public static void main(String[] args) {
        // 补充, 分配5M给数组
        byte[] b=new byte[5*1024*1024];
        System.out.println("分配了5M空间给数组");

        // 堆最大空间(如有设置,则和设置的最大值相同)
        System.out.print("Xmx=");
        System.out.println(Runtime.getRuntime().maxMemory() / 1024.0 / 1024 + "M");

        // 空闲空间
        System.out.print("free mem=");
        System.out.println(Runtime.getRuntime().freeMemory() / 1024.0 / 1024 + "M");

        // 总共申请空间
        System.out.print("total mem=");
        System.out.println(Runtime.getRuntime().totalMemory() / 1024.0 / 1024 + "M");
    }
}

执行结果:

可以看到,最大还是20M, 空闲4M多,当前使用变成了12M

那么再将分配数组的5M改成10M呢?

        // 补充, 给数组分配10M
        byte[] b=new byte[10*1024*1024];
        System.out.println("分配了10M空间给数组");


可以看到,最大还是20M, 空闲4M多,当前使用变成了17M

那么,如果再给数组分配加大到20M呢?

  // 补充, 给数组分配20M
        byte[] b=new byte[20*1024*1024];
        System.out.println("分配了20M空间给数组");


可以看到,因为堆的总大小设置的是20M, 如果再给数组分配20M, 此处自然就会报内存溢出异常。

讲了那么多,有必要解释下代码中的三个核心方法,
1.maxMemory()

  • 这个方法返回的是java虚拟机(这个进程)能够从操作系统那里挖到的最大的内存,以字节为单位。
  • 如果添加了-Xmx参数,将以这个参数后面的值为准,这里设置了20M,所以挖出来是20M。如果不设置,则是默认值,我电脑是4050M。

2.totalMemory()

  • 这个方法返回的是java虚拟机现在已经从操作系统那里挖过来的内存大小,也就是java虚拟机这个进程当时所占用的所有内存。
  • 如果在运行java的时候没有添加-Xms参数,那么,在java程序运行的过程的,内存总是慢慢的从操作系统那里挖的,基本上是用多少挖多少,直到挖到maxMemory()为止,所以totalMemory()是慢慢增大的。
  • 如果用了-Xms参数,程序在启动的时候就会无条件的从操作系统中挖-Xms后面定义的内存数,然后在这些内存用的差不多的时候,再去挖。
    3.freeMemory()
    刚才讲到如果在运行java的时候没有添加-Xms参数,那么,在java程序运行的过程的,内存总是慢慢的从操作系统那里挖的,基本上是用多少挖多少,但是java虚拟机100%的情况下是会稍微多挖一点的,这些挖过来而又没有用上的内存,实际上就是 freeMemory(),所以freeMemory()的值一般情况下都是很小的,但是如果你在运行java程序的时候使用了-Xms,这个时候因为程序在启动的时候就会无条件的从操作系统中挖-Xms后面定义的内存数,这个时候,挖过来的内存可能大部分没用上,所以这个时候freeMemory()可能会有些大 。

默认值:

含义:相当于未手动设置-Xmx和-Xms的情况下,操作系统共有4050M可以给java虚拟机申请。虚拟机挖了254M内存出来,挖出来的内存中,真正使用了4M左右,还剩余250M未使用。

以上,可以发现,其实JVM在分配内存过程中是动态的, 按需来分配的。

4. 堆的分类

现在垃圾回收器都使用分代理论,堆空间也分类如下:
在Java7 Hotspot虚拟机中将Java堆内存分为3个部分:
青年代Young Generation:Eden(伊甸元区)、S0(幸存者0区,存放伊甸元区无法销毁的实例)、S1(幸存者1区,存放伊甸元区无法销毁的实例)
老年代Old GenerationS1区经过很多轮GC后,超过一定阈值,实例就会进入老年代,说明这个对象的生命周期比较长。
永久代Permanent Generation: JDK7才有,JDK8换成了元空间。

在Java8以后,由于方法区的内存不再分配在Java堆上,而是存储于本地内存元空间Metaspace中,所以永久代就不存在了,在2018年9月25日正式发布Java11以后,在关于Java11中垃圾收集器的官方文档中没有提到“永久代”,而只有青年代和老年代。

通过日志查看堆空间:

public class TestVm {
    public static void main(String[] args) {
        // 堆最大空间(如有设置,则和设置的最大值相同)
        System.out.print("Xmx=");
        System.out.println(Runtime.getRuntime().maxMemory() / 1024.0 / 1024 + "M");

        // 空闲空间
        System.out.print("free mem=");
        System.out.println(Runtime.getRuntime().freeMemory() / 1024.0 / 1024 + "M");

        // 总共使用空间
        System.out.print("total mem=");
        System.out.println(Runtime.getRuntime().totalMemory() / 1024.0 / 1024 + "M");
    }
}

在如上代码中,设置GC日志打印:-XX:+PrintGCDetails

选择JRE11运行

可以看到,有元空间大小的显示,每个JDK版本打印可能不相同,下面是JDK8的控制台显示

同理,将JRE换成1.7,就没有元空间了

5. 年轻代和老年代

1.JVM中存储java对象可以被分为两类
1)年轻代(Young Gen):年轻代主要存放新创建的对象,内存大小相对会比较小,垃圾回收会比较频繁。年轻代分成1个Eden Space和2个Suvivor Space(from 和to)。
2)年老代(Tenured Gen):年老代主要存放JVM认为生命周期比较长的对象(经过几次的Young Gen的垃圾回收后仍然存在),内存大小相对会比较大,垃圾回收也相对没有那么频繁。

2.配置新生代和老年代堆结构占比

  • 默认 -XX:NewRatio=2 , 标识新生代占1 , 老年代占2 ,新生代占整个堆的1/3
  • 修改占比-XX:NewPatio=4, 标识新生代占1 , 老年代占4 , 新生代占整个堆的1/5
  • Eden空间和另外两个Survivor空间占比分别为8:1:1
  • 可以通过操作选项 -XX:SurvivorRatio 调整这个空间比例。 比如 -XX:SurvivorRatio=8 代表Eden区占比80%
  • 几乎所有的java对象都在Eden区创建, 但80%的对象生命周期都很短,创建出来就会被销毁.

    从图中可以看出: 堆大小 = 新生代 + 老年代。其中,堆的大小可以通过参数 –Xms、-Xmx 来指定。

默认的,新生代 ( Young ) 与老年代 ( Old ) 的比例的值为 1:2 ( 该值可以通过参数 –XX:NewRatio 来指定 ),即:新生代 ( Young ) = 1/3 的堆空间大小。老年代 ( Old ) = 2/3 的堆空间大小。
其中,新生代 ( Young ) 被细分为 Eden 和 两个Survivor 区域,这两个 Survivor 区域分别被命名为 from 和 to,以示区分。
默认的,Edem : from : to = 8 : 1 : 1 ( 可以通过参数 –XX:SurvivorRatio 来设定 ),即: Eden = 8/10 的新生代空间大小,from = to= 1/10 的新生代空间大小。
JVM 每次只会使用 Eden 和其中的一块 Survivor 区域来为对象服务,所以无论什么时候,总是有一块 Survivor 区域 是空闲着的。因此,新生代实际可用的内存空间为 9/10 ( 即90% )的新生代空间

案例:

/**
 * 1.设置年轻代和老年代的比例 1 : 4
 * 2.设置 伊甸园  from  to  8: 1: 1
 */
public class YoungOldTest {
    public static void main(String[] args) throws InterruptedException {
        System.out.println("hello");
        Thread.sleep(3000000);
    }
}

设置以上程序的堆空间比例分配:年轻代和老年代分配比例为1:4,年轻代Eden区和survivor区占比8:1:1

采用JDK1.8的工具 jvisualvm.exe查看JVM堆的大小(直接点开即可)

安装插件Visual GC
运行程序后,点开这里的对应程序,查看Visual GC窗口中堆的占用情况:

6. 对象分配过程

分配过程
1.new的对象先放在伊甸园区,该区域有大小限制 ;
2.当伊甸园区域填满时,程序又需要创建对象,JVM的垃圾回收器将对伊甸园预期进行垃圾回收(Minor GC),将伊甸园区域中不再被其他对象引用的额对象进行销毁,再加载新的对象放到伊甸园区;
3.然后将伊甸园区中的剩余对象移动到幸存者0区;
tips:
Minor GC:YGC(Young GC),即年轻代的垃圾回收。

4.如果再次触发垃圾回收(Minor GC),Eden区的对象直接放到空的幸存者1区。然后上次幸存下来放在幸存者0区的对象,如果还没有回收,也会放到幸存者1区,并且计数加1 。此时幸存者0区就空着了。
tips:

  • 始终保持一个幸存者区是空着的,空着的幸存者区叫To Survivor空间,未空的幸存者区叫From Survivor空间。
  • 每次GC后幸存的对象会在幸存者0区和1区中来回存放,每存放一次计算加1,直到达到阈值15,才会进入养老区(即老年代)。

5.如果再次触发垃圾回收(Minor GC),Eden区的对象直接放到空的幸存者0区。然后上次幸存下来放在幸存者1区的对象,如果还没有回收,也会放到幸存者0区,并且计数加1 。此时幸存者1区就空着了。
6.如果累计次数到达默认的15次,这会进入养老区。
可以通过设置参数,调整阈值 -XX:MaxTenuringThreshold=N
7.养老区内存不足时,会再次出发GC:Major GC 进行养老区的内存清理
8.如果养老区执行了Major GC后仍然没有办法进行对象的保存,就会报OOM异常。
tips:
Major GC:即老年代的垃圾回收。

图解分配对象的流程:

描述:
申请对象时,先判断Eden区域是否装得下,装不下则进行YGC(YGC后保留的对象会在S0和S1区切换存放,直至达到阈值放入老年代),进行YGC后,还装不下,则判断老年代是否装的下,老年代装不下,再进行FGC(FUll GC),进行FGC后,还装不下,则报OOM异常。

7.堆GC

Java 中的堆也是 GC 收集垃圾的主要区域。GC 分为两种:一种是部分收集器(Partial GC)另一类是整堆收集器(Full GC)
部分收集器: 不是完整收集java堆的的收集器,它又分为:

  • 新生代收集(Minor GC / Young GC): 只是新生代的垃圾收集
  • 老年代收集 (Major GC / Old GC): 只是老年代的垃圾收集 (CMS GC 单独回收老年代)
  • 混合收集(Mixed GC): 收集整个新生代及老年代的垃圾收集 (G1 GC会混合回收, region区域回收)
  • 整堆收集(Full GC):收集整个java堆和方法区的垃圾收集器

年轻代GC触发条件:

  • 年轻代空间不足,就会触发Minor GC, 这里年轻代指的是Eden代满,Survivor满不会引发GC
  • Minor GC会引发STW(stop the world) ,暂停其他用户的线程,等垃圾回收接收,用户的线程才恢复.

老年代GC (Major GC)触发机制

  • 老年代空间不足时,会尝试触发MinorGC(至少触发一次),如果空间还是不足,则触发Major GC;
  • 如果Major GC , 内存仍然不足,则报错OOM ;
  • Major GC的速度比Minor GC慢10倍以上.

FullGC 触发机制:

  • 调用System.gc() , 系统会执行Full GC ,不是立即执行.
  • 老年代空间不足
  • 方法区空间不足
  • 通过Minor GC进入老年代平均大小大于老年代可用内存

注意:Full GC也会引发stop the world,时间非常长。调优过程中,尽量避免FullGC的执行

以上是关于JVM 运行时内存空间详解——堆的主要内容,如果未能解决你的问题,请参考以下文章

JVM 运行时内存空间详解——方法区

JVM 运行时内存空间详解——元空间

JVM 运行时内存空间详解——本地方法栈

JVM 运行时内存空间详解——程序计数器

JVM 运行时内存空间详解——虚拟机栈

Java——JVM内存详解