本章内容

JVM组成

如图所示:

JVM主要组成部分:运行时数据区、执行引擎、本地接口、本地方法库等。

本文主要分享类JVM的内存结构。

JVM内存结构

JVM内存结构指的是JVM运行时数据区结构,它主要包含以下几个部分:

  • 堆(Heap):线程共享。

  • 方法区(Method Area):线程共享。

  • 虚拟机栈(VM Stack):线程私有。

  • 程序计数器(Program Counter Register):线程私有。

  • 本地方法栈(Native Method Stack):线程私有。

如图所示:

堆(Heap)

JVM堆(Heap)是Java虚拟机中的一块内存区域(所有线程共享),主要用于存储对象实例和数组。堆被划分为三个部分:年轻代、老年代和永久代(在JDK8中取消了永久代),其中,年轻代又被划分为Eden区、Survivor区(含:S0和S1)。

如图所示:

年轻代与老年代

在年轻代中,大部分对象朝生夕死,因此,年轻代的设计目标是尽可能地减少对象的存活时间,以便更快地回收内存。

默认情况下,年轻代和老年代的比例为1:2,即:年轻代占整个堆空间的1/3,老年代占整个堆空间的2/3。

  • 可以通过参数-XX:NewRatio=<n>来调整年轻代和老年代的比例。其中<n>表示老年代和年轻代的比例,默认值为2。如:设置-XX:NewRatio=2,表示老年代占整个堆空间的2/3,年轻代占整个堆空间的1/3。

  • 可以通过参数-XX:SurvivorRatio=<n>来调整Eden区和Survivor区的比例大小。其中<n>表示Eden区和一个Survivor区的比例,默认值为8。如:设置-XX:SurvivorRatio=8,表示Eden区与Survivor区的比例为8:1:1。

对象分配过程

在分析对象分配过程之前,先简单说明一下GC类型:

  • Minor GC(又称Young GC):主要用于收集年轻代中的非存活对象。Minor GC在Eden区空间不足时触发。注意:Minor GC可能会引发STW(Stop The World,即暂停用户线程)。

  • Major GC:主要用于收集老年代中的非存活对象。Major GC在老年代空间不足时触发。注意:Major GC一般都会伴随一次Minor GC,Major GC的速度一般会比Minor GC慢10倍,因此,STW的时间会更长(应尽量避免Minor GC)。目前只有CMS会有单独收集老年代的行为。

  • Mixed GC(混合收集):主要用于收集年轻代以及部分老年代中的非存活对象。目前只有G1 GC会有这种行为。

  • Full GC:主要用于收集整个堆(含:年轻代和老年代)中的非存活对象,即:Major GC+Minor GC组合。Full GC触发条件:

    • 调用System.gc()方法时,可通过参数-XX:+ DisableExplicitGC来禁止调用System.gc()。

    • 方法区空间不足时。

    • Minor GC后,存活对象的大小超过了老年代的剩余空间。

    • Minor GC时,年轻代Survivor区空间不足,判断是否允许担保失败,不允许则触发Full GC。允许并且每次晋升到老年代的对象平均大小>老年代最大可用连续内存空间,也会触发Full GC。

    • CMS GC异常且CMS运行期间预留的内存无法满足程序需要时,抛出Concurrent Mode Failure异常,将CMS退化成Serial Old并触发Full GC。

对象进入老年代的触发条件:

  • 1)对象的年龄达到15岁时。默认的情况下,对象经过15次Minor GC后会被转移到老年代中。对象进入老年代的Minor GC次数可以通过JVM参数:-XX:MaxTenuringThreshold进行设置,默认为15次。

  • 2)动态年龄判断。当一批存活对象的总大小超过Survivor区内存大小的50%时,按照年龄的大小(年龄大的存活对象优先转移)将部分存活对象转移到老年代中。

  • 3)大对象直接进入老年代 。当需要创建一个大于年轻代剩余空间的对象(如:一个超大数组)时,该对象会被直接存放到老年代中,可以通过参数-XX:PretenureSizeThreshold(默认值是0,即:任何对象都会先在年轻代分配内存)进行设置。

  • 4)Minor GC后的存活对象太多无法放入Survivor区时, 会将这些对象直接转移到老年代中。

对象分配过程:

  • 新生成的对象在年轻代Eden区中分配内存,当Eden空间已满时,触发Minor GC,将不再被其他对象所引用的对象进行回收,存活下来的对象被转移到Survivor0区。

  • Survivor0区满后触发Minor GC,将Survivor0区存活下来的对象转移到Survivor1区,同时,清空Survivor0区,保证总有一个Survivor区为空。

  • 经过多次Minor GC后,仍然存活的对象被转移到老年代,进入老年代的Minor GC次数可以通过参数-XX:MaxTenuringThreshold=<N>进行设置,默认为15次。

  • 当老年代已满时会触发Major GC(即:Full GC,因此执行Major GC时会先执行Minor GC)。

分代收集的原因:将对象按照存活概率进行分类,主要是为了减少扫描范围和执行GC的频率,同时,对不同区域采用不同的回收算法,提高回收效率。

年轻代中存在两块相同大小的Survivor区的原因:解决内存碎片化,即:保证分配对象(如:大对象)时有足够的连续内存空间。

字符串常量池

字符串常量池是Java中的一个特殊的存储区域,用于存储字符串常量。在Java中,字符串常量是不可变的,因此可以被共享。这样可以减少内存的使用,提高程序的性能。在JDK8中,字符串常量池存储在堆中。

静态变量

静态变量是指在类中定义的变量,它们的值在整个程序运行期间都不会改变。在JDK8中取消了永久代,方法区变成了一个逻辑上的区域,因此,静态变量的内存在堆中进行分配(JDK7及以前,静态变量的内存在永久代中进行分配)。它们的生命周期与类的生命周期相同。

线程本地分配缓冲区(TLAB)

TLAB(Thread Local Allocation Buffer)是Java虚拟机中的一个优化技术,主要用于提高对象的分配效率。每个线程都有自己的TLAB,用于分配对象。当一个线程需要分配对象时,它会先在自己的TLAB中分配,如果TLAB中的空间不足,则会向堆中申请空间。

TLAB相关参数:

  • 参数-XX:UseTLAB:设置TLAB启动或关闭,默认开启。

  • 参数-XX:TLABWasteTargetPercent:设置TLAB所占用Eden区空间的百分比。

使用TLAB的原因:

  • 1)保证创建对象时线程安全。堆(Heap)是线程共享区域,在并发环境下,对象在堆中分配内存时存在线程安全问题。通过使用TLAB(无锁方式)解决多个线程同时操作同一地址带来的线程安全问题。

  • 2)提高对象的内存分配效率。在堆(Heap)中创建对象非常频繁,在并发环境下,通过加载机制为对象在堆中分配内存时会影响内存分配速度。通过使用TLAB(无锁方式)提高对象的内存分配效率。

堆内存常用参数

方法区(Method Area)

在JDK8及之前,方法区属于永久代,而在JDK8之后,永久代被移除,方法区被移到了本地内存中,即:元空间(Meta Space)。

元空间逻辑上属于堆的一部分,但是为了与堆进行区分,通常又叫非堆。元空间是各个线程共享的内存区域,它主要存储两部分内容:

  • 类信息。

  • 运行时常量池。

方法区与堆,如图所示:

元空间相关参数:

  • MetaspaceSize :初始化元空间大小,控制发生GC阈值,默认值为20MB。

  • MaxMetaspaceSize : 限制元空间大小上限,防止异常占用过多物理内存,默认值为-1(表示无限制)。

类信息

类信息指的是加载到方法区中的Class文件信息,Class文件信息中除了有类的版本、字段、方法、接口等描述信息外, 还包含一项常量池表(又称静态常量池)信息,常量池表主要用于存放编译器生成的各种静态常量(又称字面常量或字面量)和符号引用。其中:

  • 静态常量:指由字母、数字等构成的字符串或者数字常量。静态常量只可以右值出现,右值指的是赋值时等号右边的值,如:int a=1,a为左值,1为右值。

  • 符号引用:符号引用主要包括以下三类常量:

    • 类和接口的全限定名。

    • 字段的名称和描述符。

    • 方法的名称和描述符。

运行时常量池

运行时常量池主要用于程序运行时为常量池表中的静态变量、符号引用等静态信息分配内存地址。

虚拟机栈(VM Stack)

虚拟机栈为线程私有,它描述的是Java方法执行的内存模型。每个栈由多个栈帧(Stack Frame)组成,每个方法被执行时,Java虚拟机都会同步创建一个栈帧用于存储该方法的局部变量表、操作数栈、动态链接、方法返回地址等信息。

栈与栈帧

每一个方法从被调用到执行完毕的过程,对应着一个栈帧在虚拟机栈中从入栈到出栈的过程(注:对栈不了解的同学请移步个人主页查阅->「一文讲清楚」数据结构与算法之栈)。栈顶的栈帧是当前执行方法,当这个方法调用其他方法时会创建一个新的栈帧,这个新的栈帧会被放到虚拟机栈的栈顶,变为当前活动栈帧,该栈帧所有指令都完成后,会将其从栈中移除,之前的栈帧变为当前活动栈帧,前面移除栈帧的返回值变为当前活动栈帧的一个操作数。

如图所示:

栈帧包含局部变量表、操作数栈、动态连接、方法返回地址等。

局部变量表

局部变量表是一组变量值的存储空间,用于存放方法参数和方法内部定义的局部变量。在将Java文件编译成Class文件时,会在方法的Code属性的max_locals数据项中确定局部变量表所需要分配的最大容量。

  • 局部变量表的容量以变量槽(Slot)为最小单位,32位虚拟机中一个Slot可以存放32位(4字节)以内的数据类型(boolean、byte、char、short、int、float、reference以及returnAddress)。

  • 对于64位长度的数据类型(long,double),虚拟机会以高位对齐方式为其分配两个连续的Slot空间(即:将long和double数据类型读写分割成为两次32位读写)。

  • 对于reference类型,虚拟机规范没有明确说明它的长度,但虚拟机一般可以从此引用中直接或者间接地查找到对象在Java堆中的起始地址索引和方法区中的对象类型数据。

  • 为了尽可能节省栈帧空间,Slot被设计成可以重用,即:当程序计数器的指令已经超出了某个变量的作用域(执行完毕),这个变量对应的Slot就可以交给其他变量使用。但是这个机制会影响到GC,如:存在一个方法占用较多的Slot,执行完该方法的作用域后没有对Slot赋值或者清空,垃圾回收器便不能及时回收该Slot的内存。

  • 系统不会为局部变量赋予初始值(实例变量和类变量都会被赋予初始值)。

操作数栈

每一个独立的栈帧中除了包含局部变量表以外,还包含一个后进先出(Last-In-First-Out)的操作数栈,操作数栈主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间。当一个方法开始执行时,该方法中的操作数栈为空,在方法执行过程中,会有各种字节码指令向操作数栈中写入和提取内容(即:入栈和出栈)。如:通过操作数栈来进行算术运算、通过操作数栈来进行调用方法时的参数传递等。

  • 操作数栈和局部变量表一样,在将Java文件编译成Class文件时,在方法的Code属性的max_stack数据项中确定操作数栈所需要分配的最大容量。

  • 每一个操作数栈都会拥有一个明确的栈深度用于存储数值,32位数据类型占用一个栈单位深度,64位数据类型占用两个栈单位深度。

  • 操作数栈和局部变量表在访问方式上存在着较大差异,操作数栈并不是采用访问索引的方式进行数据访问,而是通过标准的入栈和出栈操作来完成一次数据访问。

  • 如果被调用的方法存在返回值,其返回值将会被压入当前栈帧的操作数栈中,并更新PC寄存器中下一条需要执行的字节码指令。

  • 在概念模型中,栈帧之间相互独立,大多数虚拟机都会做一些优化处理,使两个栈帧的局部变量表和操作数栈之间有部分重叠,这样在进行方法调用时可以直接共用参数,而不需要进行额外的参数复制。

动态链接

每一个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用,包含这个引用的目的是为了支持当前方法的代码能够实现动态链接(Dynamic Linking)。

在将Java文件编译成Class文件时,所有的变量和方法引用都作为符号引用(Symbolic Reference)保存在Class文件的常量池中。比如:描述一个方法调用其他方法就是通过常量池中指向该方法的符号引用来表示,动态链接的作用就是将这些符号引用转换为调用方法的直接引用。

如图所示:

方法引用:

  • 静态链接:当一个字节码文件被装载进JVM内部时,如果被调用的目标方法在编译期确定,且运行期保持不变时,这种情况下将调用方法的符号引用转换为直接引用的过程称之为静态链接。

  • 动态链接:如果被调用的方法在编译期无法确定,只能在程序运行期将调用方法的符号引用转换为直接引用,由于这种引用转换过程具备动态性,因此也被称之为动态链接。

绑定机制:绑定指的是一个字段、方法或者类在符号引用被替换为直接引用的过程,这仅仅发生一次。静态链接和动态链接对应的方法的绑定机制为:早期绑定(Early Binding)和晚期绑定(Late Binding)。

  • 早期绑定:早期绑定就是指被调用的目标方法如果在编译期可知,且运行期保持不变时,即可将这个方法与所属的类型进行绑定。由于明确了被调用的目标方法,也就可以使用静态链接的方式将符号引用转换为直接引用。

  • 晚期绑定:如果被调用的方法在编译期无法确定,只能在程序运行期根据实际的类型绑定相关的方法,这种绑定方式也就被称之为晚期绑定。

栈顶缓存:由于操作数存储在内存中,频繁的执行内存读/写操作必然会影响执行速度。为了解决这个问题,HotSpot JVM设计者们提出了栈顶缓存技术,将栈顶元素全部缓存在物理 CPU的寄存器中,以此降低对内存的读/写次数,提升执行引擎的执行效率。

方法返回地址

存放调用该方法的PC寄存器的值。一个方法的结束,有两种方式:

  • 正常执行完成。

  • 出现未处理的异常,非正常退出。

无论通过哪种方式退出,在方法退出后都返回到该方法被调用的位置。方法正常退出时,调用者的PC计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址。而方法异常退出时,返回地址需要通过异常表来确定,栈帧中一般不会保存这部分信息。

正常完成出口和异常完成出口的区别在于:通过异常完成出口退出的方法不会给上层调用者产生任何的返回值

程序计数器(Program Counter Register)

程序计数器是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。在虚拟机的概念模型中,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖程序计数器来完成

程序计数器特点:

  • 每个线程都有一个独立的程序计数器,各线程之间计数器互不影响,独立存储。

  • 执行Java方法时,程序计数器的值不为空;执行Native本地方法时,程序计数器的值为空(Undefined)。

  • 程序计数器是唯一一个在Java虚拟机规范中没有规定任何内存溢出情况的区域。

本地方法栈(Native Method Stack)

本地方法栈与虚拟机栈类似,不同的是虚拟机栈为虚拟机执行Java方法服务,而本地方法栈则为虚拟机使用到的Native方法服务。在Java程序调用Native方法时,Native方法所需要的内存空间在本地方法栈中开辟。

本地方法栈特点:

  • 本地方法栈为线程私有。

  • 本地方法栈允许被实现成固定或者是可动态扩展的内存大小:

    • 固定内存大小,当线程请求分配的栈容量超过本地方法栈允许的最大内存大小时,Java虚拟机会抛出StackOverflowError异常。

    • 可动态扩展内存大小,当本地方法栈尝试扩展时无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的本地方法栈,Java虚拟机会抛出OutOfMemoryError异常。

  • Native方法一般由C/C++实现,它的具体做法是先在本地方法栈中登记Native方法,然后在执行引擎中加载本地方法库并执行。

【阅读推荐】

更多精彩内容,如:

  • Redis系列

  • 数据结构与算法系列

  • Nacos系列

  • MySQL系列

  • JVM系列

  • Kafka系列

  • 并发编程系列

请移步【南秋同学】个人主页进行查阅。内容持续更新中......

【作者简介】

一枚热爱技术和生活的老贝比,专注于Java领域,关注【南秋同学】带你一起学习成长~

举报/反馈

南秋同学

197获赞 97粉丝
一枚热爱技术和生活的老贝比,专注于Java领域,关注【南秋同学】带你一起学习成长~
关注
0
0
收藏
分享