一、概述

Java程序通过javac编译为字节码,所有的JVM虚拟机都遵循字节码的指令规则,所以Java语言具有一次开发多处部署的语言特性。而字节码在JVM中运行,运行时内存则由JVM进行管理。
Java虚拟机内存模型主要包括五个部分:堆、栈、本地方法栈、方法区(元空间)、程序计数器。
本文章以JDK1.8 Hotspot虚拟机为例。
image.png
image.png

1. 堆

《Java虚拟机规范》中的原文:The heap is the runtime data area from which memory for all class instances and arrays is allocated.
Java堆(Java Heap)是Java虚拟机所管理的内存中最大的一块。
Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。
“几乎”所有的对象实例都在这里分配内存。
Java堆内存分区
Java堆用于存放对象实例,而对象实例释放后需要进行内存回收(垃圾回收),因此根据不同的垃圾回收算法设计了多种垃圾回收器。
现代垃圾收集器大部分都是基于分代收集理论设计的,所以Java堆中经常会出现“新生代”“老年代”“永久代”“Eden空间”“From Survivor空间”“To Survivor空间”等名词。
技术博主(龚振杰)对于JMM的描述
Java堆分为两个区:“年轻代”、“老年代”,其内存空间默认按年轻代(1/3)老年代(2/3)分配。
image.png
在JDK1.8中,永久代被彻底移除,取而代之的是另一块与堆不相连的本地内存——元空间(Metaspace)。‑XX:MaxPermSize 参数失去了意义,取而代之的是-XX:MaxMetaspaceSize。
堆中的对象从创建开始即为0岁,每经历一次GC并存活下来则加1,对象的年龄保存在对象头中(只有4位用于保存对象年龄,默认是15(最大值),二进制表示为1111)。
image.png
可通过-XX:MaxTenuringThreshold设置对象晋升老年代的年龄阈值。
对象根据年龄大小,存在一个晋升过程Eden(0)->Survivor(1~15)->Old(15)。

1.1 年轻代

年轻代包括一个Eden区和两个Survivor区。
其内存空间大小按照8:1:1的比例分配,Eden(8)Survivor S0(1)Survivor S1(1)
也就是堆的总大小1/3再分为10份,8份给Eden区,剩余2份两个Survivor平分。
年轻代用于存放新创建的对象,当年轻代被填满时,将执行垃圾收集,这种垃圾收集称为MinorGC(YoungGC),主要收集三个部分Eden、Survivor S0和Survivor S1。

1.1.1 Eden

大多数情况下,对象在新生代Eden区中分配内存。当Eden区没有足够空间进行分配时,虚拟机将发起一次MinorGC。 幸存下来的对象年龄加1,同时晋升到Survivor区。Eden存放的对象年龄为0岁。
大对象直接进入老年代,大对象是指需要大量连续内存空间的Java对象,最典型的大对象就是超长的字符串、非常大的数组(写程序的时候应该避免)。Hotspot虚拟机提供了-XX:pertenureSizeThreshold参数,指定大于该值的对象直接在老年代分配内存,避免了在Eden和两个Survivor之间来回复制耗费系统资源。

1.1.2 Survivor

幸存者(Survivor)区虽然有两个,但同一时刻只有一个Survivor区被使用。原因是这里使用了一种垃圾回收算法对回收过程进行优化。处理逻辑是将当前使用的Survivor S0区内存活对象移动到Survivor S1区中,然后清空Survivor S0,如此往复。其优点是不会产生内存碎片,且时间高效。缺点是内存空间占两份。存放的对象年龄1~15。
Survivor目标存活率:-XX:TargetSurvivorRatio(默认为50)
Survivor区最大年龄:-XX:MaxTenuringThreshold(默认为15)

1.2 老年代

用于存储生命周期长的对象以及大对象。
空间分配担保,指在发生MinorGC之前,虚拟机必须检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那这一次MinorGC可以确保是安全的。如果不成立,则虚拟机会先查看-XX:HandlePromotionFailure参数的设置是否允许担保失败;如果允许,那会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将会尝试进行一次MinorGC,尽管这次MinorGC是有风险的;如果小于,或者-XX:HandlePromotionFailure设置为不允许冒险,那这时就要改为进行一次FullGC。
其中冒险指的是老年代的剩余空间是否能够容纳所有需要晋升的Survivor对象。通常情况下会将-XX:HandlePromotionFailure开关打开,避免FullGC过于频繁。

1.3 GC简介

MinorGC(YoungGC)指的是对年轻代(Eden、Survivor S0、Survivor S1)进行垃圾收集。
MajorGC(FullGC)指的是对年轻代和老年代进行垃圾收集,由于回收范围更大,会比MinorGC慢很多。
image.png

1.4 避免频繁的Full GC

  • 避免定义过大的对象(数组)
  • 避免将过大对象定义为静态变量
  • 避免朝生夕死的短命大对象
  • 释放对象时将对象引用置为null
  • 空间分配担保冒险设置-XX:HandlePromotionFailure=true
  • 晋升老年代年龄设置为15(默认15)-XX:MaxTenuringThreshold=15
  • 分配足够的内存空间,初始化内存大小-Xms、最大内存大小-Xmx、分配新生代空间-Xmn
  • 合理分配年轻代和老年代的比例-XX:Survivor-Ratio=8(Ende与一个Survivor的比例是8:1)

    1.5 堆JVM参数

    -Xms

    表示JVM初始化时分配给堆的内存大小

    -Xmx

    表示JVM运行过程中,堆可以申请的最大内存

    -Xmn

    表示分配给新生代(Eden、Survivor S1、Survivor S2)的总内存大小

    -XX:Survivor-Ratio

    年轻代中Eden与Survivor的内存占比,-XX:Survivor-Ratio=8表示8:1:1

    -XX:HandlePromotionFailure

    MinorGC时的老年代内存空间分配担保是否允许冒险

    -XX:MaxTenuringThreshold

    设置年轻代晋升老年代的年龄阈值,默认是15,最大值是15

    -XX:TargetSurvivorRatio

    设置Survivor区的目标存活率,默认是50%,超过这个阈值则会发生一次GC

    2. 方法区(元空间)

    方法区与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
    虽然《Java虚拟机规范》中把方法区描述为堆的一个逻辑部分,但它却有一个别名叫做“非堆”,目的是余Java堆区分开来。
    《Java虚拟机规范》中描述,如果方法区无法满足新的内存分配需求时,将抛出OutOfMemoryError异常。

    2.1 永久代到方法区(元空间)

    废弃永久代的原因:

    • 字符串存在永久代中,容易出现性能问题和内存溢出。
    • 类及方法的信息等比较难确定其大小,因此对于永久代的大小指定比较困难,太小容易出现永久代溢出,太大则容易导致老年代溢出。
    • 永久代会为 GC 带来不必要的复杂度,并且回收效率偏低。

发展过程:

  • 在JDK1.6版本Hotspot开发团队就有放弃永久代(堆中划分的一块区域),逐步改为采用本地内存(Native Memory)来实现方法区的计划了。
  • 在JDK1.7版本Hotspot已经将原本放在永久代的字符串常量池、静态变量等移至Java堆中。
  • 在JDK1.8版本Hotspot完全废弃了永久代的概念,改为与JRockit、J9一样的方式,在本地内存中实现元空间来代替,把JDK1.7中永久代剩余的内容(主要是类信息)全部移到元空间中。

    2.2 运行时常量池

    运行时常量池是方法区的一部分,Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表,用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量中。程序运行期间也可以将新的常量放入池中,例如String.intern()方法。(字符串常量也存放在这里面)
    遵循方法区的内存限制,当常量池无法再申请到内存时会抛出OutOfMemoryError异常 。

    2.3 方法区JVM参数

    -XX:MetaspaceSize

    初始空间大小,达到该值就会触发垃圾收集进行类型卸载,同时GC会对该值进行调整:如果释放了大量的空间,就适当降低该值;如果释放了很少的空间,那么在不超过MaxMetaspaceSize时,适当提高该值。

    -XX:MaxMetaspaceSize

    最大空间,默认是没有限制的。

    -XX:MinMetaspaceFreeRatio

    在GC之后,最小的Metaspace剩余空间容量的百分比,减少为分配空间所导致的垃圾收集

    -XX:MaxMetaspaceFreeRatio

    在GC之后,最大的Metaspace剩余空间容量的百分比,减少为释放空间所导致的垃圾收集

    3. 虚拟机栈

    Java虚拟机栈是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是Java方法执行的线程内存模型:每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态连接、方法出口等信息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
    局部变量表存放了编译器可知的各种Java虚拟机基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,它并不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或者其他与此对象相关的位置)和returnAddress类型(指向了一条字节码指令的地址)。
    这些数据类型在局部变量表中的存储空间以局部变量槽(Slot)来标识,其中64位长度的long和double类型的数据会占用两个变量槽,其余的数据类型只占用一个。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。(大小指的是变量槽的数量)
    《Java虚拟机规范》中对虚拟机栈内存区域规定了两类异常情况:如果线程请求的栈深度大于虚拟机所允许的深度将抛出StackOverflowError异常;如果Java虚拟机栈容量允许动态扩容(Hotspot虚拟机不允许扩容,Classic虚拟机允许),当栈扩展到无法申请足够的内存时抛出OutOfMemoryError异常。

    4. 本地方法栈

    本地方法栈与虚拟机栈所发挥的作用是非常相似的,其区别只是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地(Native)方法服务。
    《Java虚拟机规范》对本地方法栈中方法使用的语言、使用的方式与数据结构并没有任何强制规定,因此具体的虚拟机可以根据需要自由实现它,甚至有的Java虚拟机(例如Hotspot)直接就把本地方法栈和虚拟机栈合二为一。
    与虚拟机栈一样,本地方法栈也会在栈深度溢出或者扩展失败时分别抛出StackOverflowError和OutOfMemoryError异常。

    5. 程序计数器

    程序计数器是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。在Java虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
    每个线程都有一个独立的程序计数器,各线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。
    当线程正在执行的是一个Java方法,那么记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是本地(Native)方法,这个计数器值则为空(undefined)。
    此内存区域是唯一一个在《Java虚拟机规范》中没有规定任何OutOfMemoryError情况的区域。

    参考文献

    Java (JVM) Memory Model – Memory Management in Java
    什么是 Minor GC/Major GC
    Java(JVM) 内存模型与内存管理
    字符串常量池和运行时常量池是在堆还是在方法区?