JVM性能优化之JVM的内存结构

JVM性能优化之JVM的内存结构


前言

我们平常开发的时候肯定遇到过两个异常内存溢出(OutOfMemoryError)和栈溢出(StackOverflowError),这两个异常是怎么出现的?为什么会出现?怎么解决呢?这就需要我们对JVM的内存结构有个详细的认知了。

一、JVM内存结构

JVM的内存结构也就是我们常说的运行时数据区,它主要用来保存JAVA程序运行过程中需要用到的数据和相关信息,也就是经常说的把数据读到内存,包括类加载之后的信息,从磁盘读取文件信息等。
下图是JDK7的内存结构:
在这里插入图片描述
在JDK7的时候根据VM规范,VM应该被划分为五块区域:方法区、堆、虚拟机栈、本地方法栈、程序寄存器。而在JDK8的时候取消了方法区修改为了元空间,而且虚拟机的内存结构也分为了JVM内存和本地内存。

  • 虚拟机内存:由我们设置的参数控制虚拟机内存的大小,当大小超过参数设置的大小时就会报OOM内存溢出。
  • 本地内存:本地内存无法通过虚拟机参数来设置大小,是由物理内存来控制大小的,同样内存如果不够的话也会报OOM内存溢出。

为什么要这样修改呢?当时Oracle收购了JRockit VM,之后将JRockit VM与HotSport VM相结合的结果,JDK7扩之前的时候,字符串常量是放在方法区的,而虚拟机内存又受我们的配置限制了大小,这样很容易出现内存溢出,当时的解决方法就是继续加大配置参数,也没个具体的解决方案,这样做是不科学的。后来结合JRockit VM的优点之后,考虑将其放在了本地内存中,以后维护的时候不用考虑它的大小了,直接跟物理内存相关联,这样内存溢出的频率就少了,当然这样会稍微损失点性能的,毕竟直接读取虚拟机内存肯定要比读取本地内存快。

在这里插入图片描述

二、JVM的内存结构详细介绍

下面对JVM的内存结构每个部分进行一下详细的介绍。JDK7的时候是存在方法区的,而JDK8更改为元空间之后,方法区被放到了元空间中,所以方法区并不是去掉了,而是在元空间中实现了。

1.堆

堆的简介

堆是虚拟机中内存最大的部分,是线程共享的,在虚拟机启动的时候创建堆空间,主要用来存放我们new出来的对象实例,堆是GC中最重要的区域,所以堆有的时候也被成为GC堆,所以站在GC的角度上我们还可以把堆分为新生代和老年代(在JDK7的时候还有个永久代),新生代又可以分成Eden区、From Survivor区和To Survivor区,如下图所示:
在这里插入图片描述

  • 新生代: 新生代主要存放新创建的对象,内存大小相对会比较小,垃圾回收会比较频繁。年轻代分成Eden区、From Survivor区和To Survivor区。
  • 老年代: 老年代主要存放JVM认为生命周期比较长的对象(新生代的几次垃圾回收后仍然存在的),内存大小相对会比较大,垃圾回收也相对没有那么频繁。

堆的内存分配情况

默认情况下新生代老年代的比例是1:2,也就是说堆空间分为了三份大小,新生代占了1份,老年占了两份。我们可以通过修改参数 -XX:NewPatio=2 来改变,例如 -XX:NewPatio=4,代表着新生代老年代的比例是1:4。而新生代分中的Eden区、From Survivor区和To Survivor区默认比例分配是8:1:1,这个是可以通过修改参数 -XX:SurvivorRatio=8来修改的,From区和To区的大小是一样的,所以这个比例指的是From区为1,To区为1,Eden区为8,也就是2:8,其中的2分为From区和To区,由于From区和To区垃圾回收算法的关系,这两个区正常只使用了1块的大小,也就是说 当-XX:SurvivorRatio=8为时,实际新生代的使用空间为90%。堆的大小通过–Xms、-Xmx来指定。

下面按照默认比例来计算下当-Xmn设置为600M时的情况:
新生代:老年代=1:2,所以新生代的大小为200M,老年代的大小为400M;新生代中Eden:From:To=8:1:1,所以Eden区的大小为160M,From区的大小为20M,To区的大小为20M。

对象分配的过程

下面模拟讲解一下创建一个对象后的存放位置:

  • 首先Object obj = new Object()创建了一个对象,刚创建好的对象是存放在Eden区的。
  • 当Eden区放满了,这时候还想放对象就触发了垃圾回收机制,将其中没有被引用的对象进行回收,而还在引用的对象放到了From区。
  • 这个时候如果From区没满正常操作,如果From区满了则From区也触发了垃圾回收机制,没有回收的放到了To区(这里需要注意的是From区和To区不是固定的,来回颠倒,From放到了To之后,To变成了From,From变成了To)。
  • 然后继续执行,如果下次再触发回收则又从From放到了To区,来回如果执行了15次还没有被回收就被放到了老年代。当然这个次数是可以通过调整参数 -XX:MaxTenuringThreshold=N 进行设置的。
  • 如果老年代放不下了,则老年代进行垃圾回收,老年代垃圾满了,而且都是无法回收的,这时候就出现了内存溢出OOM,因为堆就这么大了,老年代已经最外层了,外面没地继续扔了。
    在这里插入图片描述

堆的垃圾回收

堆又叫GC堆,是GC回收的主要区域。GC回收分为两种:部分收集器(Partial GC)和整堆收集器(Full GC),这里需要注意的是Full GC会触发STW(Stop The World),暂停所有线程,只回收垃圾,非常影响性能的,所以优化性能的时候保证尽量少Full GC
部分收集器又分为新生代收集(Minor GC / Young GC)、老年代收集 (Major GC / Old GC)、和混合收集(Mixed GC)。当新生代不足的时候会触发Minor GC ,同样会触发STW机制,当然新生代不足指的是Eden区、From区和To区都满了。老年代空间不足时,会先触发MinorGC,如果空间还是不足,则触发Major GC,空间还不足就OOM了,Major GC的执行速度相当于Minor GC的10倍。
Full GC触发的条件:

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

2.元空间

元空间介绍

从JDK8开始,HotSpot 虚拟机取消了永久代,并把方法区移到了元空间,以前的永久代与新生代和老年代一样为堆中的一部分,现在的元空间直接分配到了本地内存;JDK7的时候永久代用来存放类的元数据信息、静态变量以及常量池等。现在类的元信息存储在元空间中,静态变量和常量池等并入堆中,相当于原来的永久代中的数据,被元空间和堆内存给瓜分了。

方法区介绍

和堆空间一样,方法区也是线程共享的,在JVM启动的时候创建方法区,主要用于存储已被虚拟机加载的类型信息、常量、 静态变量、 即时编译器编译后的代码缓存等数据,如果我们创建的类太多同样会触发OOM异常。方法区同样可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可。元空间是方法区的具体实现,JDK8之后方法区移动到了元空间。
在这里插入图片描述
其中类的信息分为类型信息、域信息、方法信息。

  • 类型信息:指的是类的信息,类的完整有效名、父类的完整有效名、类的修饰符、直接接口的有序列表等,其中完整有效名只包名+类名。
  • 域信息:指的是类中属性的相关信息,包括属性的名称、属性的类型以及属性的修饰符等。
  • 方法信息:指的是类中方法的相关信息,包括方法的返回类型、参数、修饰符、异常表、字节码、操作数栈、局部变量表等信息。

常量池存放编译期间生成的各种字面量与符号引用,可以看做是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等类型。而运行时常量池指的是常量池表在运行时的表现形式,执行引擎取数据的时候取运行时常量池去取,被加载的字节码常量池就放到了运行时常量池中。我们随便写个类,然后反编译为汇编就可以看到其中的运行时常量池
在这里插入图片描述

元空间的配置参数

  • -XX:MaxMetaspaceSize: 用来指定元空间的最大内存大小,默认是我们系统的内存大小,当然如果我们的代码有异常导致系统内存不断的被使用,最后肯定会被系统杀死进程;当我们设置了这个参数后空间不够使用同样会抛出OOM异常,
  • -XX:MetaspaceSize: 用来指定元空间的初始大小,当存储的信息到达该值则会触发垃圾收集,同时GC也会堆该值进行调整,如果回收释放的内存比较大,则适当的降低该值;如果回收释放的内存较小,则会适当的提高该值,当然不会超过我们设置的最大空间大小。
  • -XX:MinMetaspaceFreeRatio: 用来指定在垃圾回收之后最小的元空间剩余空间容量的百分比,当垃圾回收后的剩余空间小于该值,则虚拟机会增加元空间的大小。
  • -XX:MaxMetaspaceFreeRatio: 用来指定在垃圾回收之后最大的元空间剩余空间容量的百分比,当垃圾回收后的剩余空间大于该值,则虚拟机会减少元空间的大小。
  • -XX:MaxMetaspaceExpansion: 用来指定元空间增长时的最大幅度,最大步进。
  • -XX:MinMetaspaceExpansion: 用来指定元空间增长时的最小幅度,最小步进。

在这里插入图片描述
简单的用个图来说明下这几个配置,虚拟机的想法是把元区间的空闲内存的范围控制在绿色区域,假设红色的为我们设置的初始空间限制(当然这个数值会根据后面的运行情况自动调整的),当使用的空间触及到初始空间则进行垃圾回收,回收后的空闲空间控制在绿色区域,如果超出了则通过调整元区间的大小来保证其一直在绿色区域,每次调整的大小为步进大小。

3.直接内存

NIO是JDK1.4中新加入的类,引入了一种基于通道(channel)和缓冲区(buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过堆上的DirectByteBuffer对象对这块内存进行引用和操作,这就是直接内存。

直接内存(Direct Memory) 不是虚拟机运行时数据区的一部分,所以它不受虚拟机内存的限制。

4.虚拟机栈

虚拟机栈是线程私有的,Java虚拟机栈和线程同时创建,用于存储栈帧。每个方法在执行时都会创建一个栈帧(Stack Frame),用于存储局部变量表、操作数栈、动态链接、方法出口等信息。

栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构。栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。每一个方法从调用至执行完成的过程,都对应着一个栈帧在虚拟机栈里从入栈到出栈的过程。

在这里插入图片描述

  • 局部变量表:一组变量值存储空间,用于存放方法参数和方法内定义的局部变量。包括8种基本数据类型、对象引用(reference类型)和returnAddress类型(指向一条字节码指令的地址)。
  • 操组数栈:一个后入先出栈(LIFO)。随着方法执行和字节码指令的执行,会从局部变量表或对象实例的字段中复制常量或变量写入到操作数栈,再随着计算的进行将栈中元素出栈到局部变量表或者返回给方法调用者,也就是出栈/入栈操作。
  • 动态链接:Java虚拟机栈中,每个栈帧都包含一个指向运行时常量池中该栈所属方法的符号引用,持有这个引用的目的是为了支持方法调用过程中的动态链接。
  • 方法返回地址:方法返回地址存放调用该方法的PC寄存器的值。一个方法的结束,有两种方式:正常地执行完成和抛出异常后退出。方法正常退出时,调用者的PC计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址;异常退出时,返回地址是要通过异常表来确定,栈帧中一般不会保存这部分信息。

虚拟机栈的配置

我们可以通过-Xss参数来设置虚拟机为每个线程分配的内存大小。

5.本地方法栈

本地方法栈与虚拟机栈的作用相似,当然本地方法栈也是线程私有的,看名字我们就知道,它区别于虚拟机栈是本地方法栈调用的是本地方法,也就是我们常见的Native方法。
本地方法栈与虚拟机栈一样存在:栈溢出和内存溢出两种异常。

6.程序计数器

程序计数器也叫PC寄存器,是一块较小的内存空间,它可以看做是当前线程所执行的字节码的行号指示器。在虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令、分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。当虚拟机正在执行的方法是一个本地(native)方法的时候,jvm的pc寄存器存储的值是undefined;此内存区域是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域。

三、总结

在这里插入图片描述

本文简单的对JVM的内存结构进行了分析,其中涉及到一点配置参数,后面讲完整个JVM后会对所有的配置参数进行一下总结后单独发一个文章。如果有写的不对的地方请大家留言指正。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值