
不会说话的刘同学
2022/09/14阅读:40主题:自定义主题1
JVM内存模型
大家好,我是哪吒,JVM 内存模型是一个非常重要的东西,不管是在面试中,还是在实际的运用中,比如调优等,都需要对这个东西有一定的了解才能搞懂背后的逻辑,本期文章就给大家讲解一下 JVM 的内存模型,在讲解的过程中我会通过一个例子来做进一步分析
JDK 体系结构
作为一个 java 开发人员来说,我们有必要了解一下这个体系结构是什么,这样可以让我们对 JDK 有一个整体的认识
JDK包括了 JRE ,还有一些 java 运行的一些指令,而 JRE 包含了 JVM虚拟机、运行程序的一些核心类库,大致的整体结构如下:

我们弄懂了这个体系结构对于我们后面了解 JVM 来说是非常有帮助的
JDK 跨平台性
我们把某个项目开发完之后,是可以到处去运行的,比如除了在 windows 上运行,我们还可以放到 linux 上去运行,为什么会这样呢,因为 JVM 会根据不同的操作系统编译出不同的机器编码,比如在 windows 上可以编译出 windows 识别的机器码,在 linux 上编译出 linux 识别的机器码,这样就更好的规避了由于不同的系统而要去更改代码的问题

当我们需要为代码运行搭建环境的时候,需要根据不同的操作系统来选择不同的JDK版本,那不同的JDK版本也就会对代码编译成当前系统所能识别的机器编码,因此我们的程序是可以随意的运行在不同的操作系统上的
JVM 内存模型
JVM 就像是一个操作系统,在使用的时候也需要对内存进行管理,为此 JVM 建立了一套内存管理模型
JVM 虚拟机分为类装载子系统、字节码执行引擎、内存模型

类装载自子系统负责加载相关的类库,自己码执行引擎负责执行字节编码,内存模型负责数据的存放
内存模型是JVM中最重要的一块东西,在面试中回经常被问题,下面就来着重分析以下JVM的内存模型
内存模型重要包括了五大区域,堆、方法区、栈、本地方法栈、程序计数器,我想这最基本的东西应该都知道吧
其中堆和方法区是线程共享的,也就是说所有的线程都可以访问到,栈、本地方法栈和程序计数器是线程私有的,也就说这些私有的区域会为某个线程开辟一个单独的空间使用

官方对于 JVM 虚拟机各个区域的解释大概是这样子的:
堆:被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,Java世界里“几乎”所有的对象实例都在这里分配内存
方法区:是各个线程共享的内存区域,它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据
栈:是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是Java方法执行的线程内存模型:每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧用于存储局部变量表、操作数栈、动态连接、方法出口等信息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程
本地方法栈:与虚拟机栈所发挥的作用是非常相似的,其区别只是虚拟机栈为虚拟机执行 Java 方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地方法服务
程序计数器:是一块较小的内存空间,它可以看作是当前线程所执行的 字节码的行号指示器
JVM对堆也分了两大块区域---新生代、老年代
新生代主要包括了 Eden 、Sourvivor ,注意这里 JVM 又把 Sourvivor 分成了两块区域S0、S1
至于为什么要把Sourvivor分成两块,这与其中的分代垃圾收集算法有关,我这里不做过多的阐述,后面我会单独写一篇文章来讲解

说到这里,我还需要再提一下,在 JDK1.8 之前方法区中还有一个永久代用来存放静态变量、常量等信息,也就是说起初方法区的内存释放也是也是交由垃圾收集器来处理的,而这种这几很容易造成内存溢出,所以再 JDK1.8 就摒弃了它
堆栈内存分配流程
当我们 new 出一个新的对象的时候,首先这个对象会存放到 Eden 区,当 Edn 区存放满了,就会触发GC,此时会把 Eden 区还存活的对象放到S0

首先A、B、C、D、E、F、G、H、I这几个对象占满了 Eden 区,此时 Eden 区会直接触发 GC,此时你会发现对象A、B、C还存活着,其他的成为了垃圾被垃圾收集器回收掉,而这三个存活的对象会直接移到 S0 区
移动完之后,Eden 区又被J、K、L、M、O、P、Q、R、S这几个对象堆满了,此时又会触发 GC ,而这次 GC 会回收掉 Eden 和 S0 这两个区域的垃圾,回收完之后,会把存活下来的 C、B、J、K、L 放到 S1 这个区域

移动完之后,Eden 区又被堆满了,此时会回收 Eden 和 S1 这两个区域的垃圾对象,并把存活的对象移到 S1 区

就这样新生代每触发一次GC就会移动存活的对象,但是存活的对象也不是无限制的在新生代区移动下去,而是当某存活的对象移动的次数到达一定程度之后会直接把这个对象移到老年代区,上图中B对象因为被移动的次数比较多,所以就直接移到了老年代
以上就是堆内存中整个GC的过程
那了解完堆,我们再来看看栈
栈是线程不共享的,也就是说栈内存会为每个线程开辟一个单独的内存空间,供其使用,那什么时候采用得到这个栈空间呢,那就是在我们每次调用方法的时候
我们每调用一次方法,就会在栈内存里面创建一个栈帧,这个帧栈就相当于是给调用方法的那个线程使用的,帧栈主要包括了局部变量表、操作数栈、动态链接、方法出口等

局部变量表:存放编译期可知的各种基本数据类型、对象引用类型和returnAddress类型(指向一条字节码指令的地址:函数返回地址)。long、double占用两个局部变量控件Slot。局部变量表所需的内存空间在编译期确定,当进入一个方法时,方法在栈帧中所需要分配的局部变量控件是完全确定的,不可动态改变大小
操作数栈:后进先出LIFO,最大深度由编译期确定。栈帧刚建立使,操作数栈为空,执行方法操作时,操作数栈用于存放JVM从局部变量表复制的常量或者变量,提供提取,及结果入栈,也用于存放调用方法需要的参数及接受方法返回的结果。 操作数栈可以存放一个jvm中定义的任意数据类型的值。 在任意时刻,操作数栈都一个固定的栈深度,基本类型除了long、double占用两个深度,其它占用一个深度
动态连接:每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。Class文件的常量池中存在有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用为参数。这些符号引用,一部分会在类加载阶段或第一次使用的时候转化为直接引用(如final、static域等),称为静态解析,另一部分将在每一次的运行期间转化为直接引用,这部分称为动态连接。
方法出口:当一个方法被执行后,有两种方式退出该方法:执行引擎遇到了任意一个方法返回的字节码指令或遇到了异常,并且该异常没有在方法体内得到处理。无论采用何种退出方式,在方法退出之后,都需要返回到方法被调用的位置,程序才能继续执行。方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层方法的执行状态。一般来说,方法正常退出时,调用者的PC计数器的值就可以作为返回地址,栈帧中很可能保存了这个计数器值,而方法异常退出时,返回地址是要通过异常处理器来确定的,栈帧中一般不会保存这部分信息。 方法退出的过程实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,如果有返回值,则把它压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令。
假如我们有个方法
public int sum(){
int a = 1;
int b = 2;
int c = a + b;
return c ;
}
这是一个很简单的方法,那我们来看看当程序调用了这个方法的时候,栈内存都在干些什么
首先程序调用这个方法,那就会在我们的栈里面创建一个这个方法的栈帧,创建完之后,会把局部变量a、b、c、d放到局部变量表中,把赋予这些变量的数字放到操作数栈里面去

当给变量进行了赋值的时候,会把值从操作数栈中把弹出赋值给局部变量,当两个变量进行计算了时候,会把计算的两个值压入操作数栈中,然后再弹栈交给cpu去执行相关加减法运算,算出的值再次压入到操作数栈中,压入之后再次弹栈,把计算出的值放到局部变量表中,当整个方法运行完了的时候,这个栈帧也就随之销毁,整个的空间也就会得到释放
所以为什么有时候我们的栈内存会溢出,原因就在这里,当线程创建了一个栈帧而没有去得到释放,那整个栈就会放满,没有地方放了,那就会抛出这个异常了
JVM参数设置
我们程序在运行的过程中,如果没有对JVM进行参数设置的话,JVM就会只用默认的参数,比如我们的堆空间大小应该设置成多少,栈内存空间大小应该设置成多少,这些都是JVM根据系统的具体配置来的,当然我们也可以自己设置
每个参数都有其特定的含义,我这边就列举几个比较重要的参数
「-Xss」:每个线程的栈大小
「-Xms」:初始堆大小,默认物理内存的1/64
「-Xmx」:最大堆大小,默认物理内存的1/4
「-Xmn」:新生代大小
「-XX:NewSize」:设置新生代初始大小
「-XX:NewRatio」:默认2表示新生代占年老代的1/2,占整个堆内存的1/3。
「-XX:SurvivorRatio」:默认8表示一个survivor区占用1/8的Eden内存,即1/10的新生代内存。
关于元空间的JVM参数有两个:-XX:MetaspaceSize=N和 -XX:MaxMetaspaceSize=N
「-XX:MaxMetaspaceSize」: 设置元空间最大值, 默认是-1, 即不限制, 或者说只受限于本地内存大小。
「-XX:MetaspaceSize」: 指定元空间触发Fullgc的初始阈值(元空间无固定初始大小), 以字节为单位,默认是21M左右,达到该值就会触发full gc进行类型卸载, 同时收集器会对该值进行调整: 如果释放了大量的空间, 就适当降低该值; 如果释放了很少的空间, 那么在不超过 -XX:MaxMetaspaceSize(如果设置了的话) 的情况下, 适当提高该值。这个跟早期jdk版本的 -XX:PermSize 参数意思不一样,-XX:PermSize 代表永久代的初始容量。
由于调整元空间的大小需要Full GC,这是非常昂贵的操作,如果应用在启动的时候发生大量Full GC,通常都是由于永久代或元空间发生了大小调整,基于这种情况,一般建议在JVM参数中将MetaspaceSize和MaxMetaspaceSize设置成一样的值,并设置得比初始值要大,对于8G物理内存的机器来说,一般我会将这两个值都设置为256M。
只有了解了JVM的每个参数具体的作用,我们才能更好的给JVM进行调优操作
我们在给JVM进行调优的时候,要根据程序具体的情况来给JVM设置一个符合情理的参数,这样才能让JVM达到一个整体的最优的效果
作者介绍
