51工具盒子

依楼听风雨
笑看云卷云舒,淡观潮起潮落

JVM之内存布局详解,图文并茂

作者:追风少年潇歌
原文链接: https://www.cnblogs.com/hyxiao97/p/15395886.html

今天给大家分享一下面试官上来就喜欢问的 JVM 干货。

1、内存布局 {#toc_h2_0}

JVM内存布局规定了Java在运行过程中内存申请、分配、管理的策略,保证了JVM的稳定高效运行。不同的JVM对于内存的划分方式和管理机制存在部分差异。结合JVM虚拟机规范,一起来探讨jVM的内存布局。如下图所示:

2、Heap 堆区 {#toc_h2_1}

Heap 堆区是Java发生 OOM (Out Of Memory)故障的地方,堆中存储着我们平时创建的 实例对象 ,最终这些不再使用的对象会被垃圾收集器回收掉,而且 堆是线程共享的 。一般情况下,堆所占用的内存空间是JVM内存区域中最大的,我们在平时编码中,创建对象如果不加以克制,内存空间也会被耗尽。堆的内存空间是可以 自定义大小 的,同时也支持在 运行时动态修改 ,通过 -Xms-Xmx 这两参数去改变堆的 初始值最大值-X 指的是JVM运行参数, ms 是memory start的简称,代表的是 最小堆容量mx 是memory max的简称,代表的是 最大堆容量 ;如 -Xms256M代表堆的初始值是256M,-Xmx1024M代表堆的最大值是1024M。由于堆的内存空间是可以动态调整的,所以在服务器运行的时候,请求流量的不确定性可能会导致我们堆的内存空间不断调整,会增加服务器的压力,所以我们一般都会将JVM的 XmsXmx 的值设置成一样,同样也为了避免在 GC (垃圾回收)之后调整堆大小时带来的额外压力。

堆区分为两大区: Young 区和 Old 区,又称 新生代老年代 。对象刚创建的时候,会被创建在 新生代到一定阶段之后会移送至老年代 ,如果创建了一个新生代无法容纳的新对象,那么这个新对象也可以创建到老年代。如上图所示。 新生代 分为1个 Eden 区和2个 S区 ,S代表 Survivor 。大部分的对象会在 Eden区 中生成,当Eden区没有足够的空间容纳新对象时,会触发Young Garbage Collection,即 YGC 。在Eden区进行垃圾清除时,它的策略是会把 没有引用的对象直接给回收掉,还有引用的对象会被移送到Survivor区 。Survivor区有 S0S1 两个内存空间, 每次进行YGC的时候,会将存活的对象复制到未使用的那块内存空间,然后将当前正在使用的空间完全清除掉,再交换两个空间的使用状况 。如果YGC要移送的对象Survivor区无法容纳,那么就会将该对象直接移交给老年代。上面说了,到 一定阶段 的对象会移送到老年区,这是什么意思呢?每一个对象都有一个计数器,当每次进行YGC的时候,都会 +1 。通过 -XX:MAXTenuringThrehold 参数可以配置当计数器的值到达某个阈值时,对象就会从新生代移送至老年代。该参数的默认值为15,也就是说 对象在Survivor区中的S0和S1内存空间交换的次数累加到15次之后,就会移送至老年代 。如果参数配置为1,那么创建的对象就会直接移送至老年代。具体的对象分配即回收流程可观看下图所示。

如果 Survivor 区无法放下,或者创建了一个超大新对象, EdenOld 区都无法存放,就会触发Full Garbage Collection,即 FGG ,便再尝试放在 Old 区,如果还是容纳不了,就会抛出 OOM 异常。在不同的JVM实现及不同的回收机制中,堆内存的划分方式是不一样的。

3、Metaspace 元空间 {#toc_h2_2}

在JDK8版本中,元空间的前身 Pern 区已经被淘汰。在JDK7及之前的版本中, Hotspot 还有 Pern 区,翻译为永久代,在启动时就已经确定了大小,难以进行调优,并且只有 FGC 时会移动类元信息。不同于之前版本的 Pern (永久代),JDK8的 元空间 已经在 本地内存 中进行分配,并且, Pern 区中的所有内容中 字符串常量 移至 堆内存 ,其他内容也包括了 类元信息字段静态属性方法常量 等等都移至 元空间 内。

4、JVM Stacks 虚拟机栈 {#toc_h2_3}

栈(Stack)是一个 先进后出 的数据结构,先进后出怎么理解?类似于我们平时打羽毛球时,装羽毛球的球筒,第一个先放进去的往往最后一个才能拿出来,最后放进去的一个最先拿出来。

相对于基于寄存器的运行环境来说,JVM是基于 栈结构 的运行环境。因为栈结构移植性更好,可控性更强。JVM的虚拟机栈是描述Java方法执行的内存区域,并且是 线程私有 的。 栈中的元素用于支持虚拟机进行方法调用,每个方法从开始调用到执行完成的过程,就是栈帧从入帧到出帧的过程。 在活动线程中,只有位于栈顶的帧才是有效的,称为 当前栈帧 。正在执行的方法称为 当前方法 ,栈帧是方法运行的基本结构。在执行引擎运行时,所有指令都只能针对当前栈帧进行操作。而StackOverflowError表示请求的栈溢出,导致内存耗尽,通常出现在递归方法中。如果把 JVM 当做一个棋盘, 虚拟机栈 就是棋盘上的将/帅, 当前方法的栈帧 就是棋子能走的区域,而 操作栈 就是每一个棋子。操作栈的压栈和出栈如下图所示:

虚拟机栈通过 压栈出栈 的方式,对每个方法对应的活动栈帧进行运算处理,方法正常执行结束,肯定会跳转到另外一个栈帧上。在执行的过程中,如果出现异常,会进行异常回溯,返回地址通过异常处理表确定。栈帧在整个JVM体系中的地位颇高,包括 局部变量表操作栈动态连接方法返回地址 等。

下面对栈帧的各个活动栈帧进行简要的分析

(1) 局部变量表 {#toc_h3_4}

局部变量表是存放 方法参数局部变量 的区域。我们都知道,类属性变量一共要经历两个阶段,分为 准备阶段初始化阶段 ,而局部变量是没有准备阶段,只有 初始化阶段 ,而且必须是 显式 的。如果是非静态方法,则在index[0]位置上存储的是 方法所属对象的实例引用 ,随后存储的是 参数局部变量 。字节码指令中的STORE指令就是 将操作栈中计算完成的局部变量写回局部变量表的存储空间内

(2) 操作栈 {#toc_h3_5}

操作栈是一个 初始状态为空的桶式结构栈 。在方法执行过程中,会有各种指令往栈中写入和提取信息。JVM的执行引擎是基于栈的执行引擎,其中的栈指的就是 操作栈 。字节码指令集的定义都是 基于栈类型 的,栈的深度在方法元信息的stack属性中,下面就通过一个例子来说明下操作栈与局部变量表的交互:

  
  
                  public int add() { int x = 10; int y = 20; int z = x + y; return z; }                  

    <svg>
                      <use xlink:href="#vditor-icon-copy" />
                    

    </svg>
                  </span>



  
  
                
                  public int add() {
    int x = 10;
    int y = 20;
    int z = x + y;

    return z;




}


                </code>
              </pre>



 
字节码操作顺序如下:

 
```java
  
    
                  public int add(); Code: 0: bipush 10 // 常量 10 压入操作栈 2: istore_1 // 并保存到局部变量表的 slot_1 中 (第 1 处) 3: bipush 20 // 常量 20 压入操作栈 5: istore_2 // 并保存到局部变量表的 slot_2 中 6: iload_1 // 把局部变量表的 slot_1 元素(int x)压入操作栈 7: iload_2 // 把局部变量表的 slot_2 元素(int y)压入操作栈 8: iadd // 把上方的两个数都取出来,在 CPU 里加一下,并压回操作栈的栈顶 9: istore_3 // 把栈顶的结果存储到局部变量表的 slot_3 中 10: iload_3 11: ireturn // 返回栈顶元素值                  

    <svg>
                      <use xlink:href="#vditor-icon-copy" />
                    

    </svg>
                  </span>



  
    
                
                  public int add();
  Code:
     0: bipush        10	//	常量 10 压入操作栈
     2: istore_1		   //	并保存到局部变量表的 slot_1 中  (第 1 处)
     3: bipush        20	//	常量 20 压入操作栈
     5: istore_2		   //	并保存到局部变量表的 slot_2 中 
     6: iload_1			   //	把局部变量表的 slot_1 元素(int x)压入操作栈
     7: iload_2			   //	把局部变量表的 slot_2 元素(int y)压入操作栈
     8: iadd			   //	把上方的两个数都取出来,在 CPU 里加一下,并压回操作栈的栈顶
     9: istore_3		   //	把栈顶的结果存储到局部变量表的 slot_3 中
    10: iload_3
    11: ireturn			   //	返回栈顶元素值

                </code>
              </pre>



 
第 1 处说明:局部变量表就像一个快递柜,有着很多的柜子,依次编号为1,2,3,...,n,字节码指令 ` istore_1 ` 就代表打开了 1 号柜子,再把栈顶中的值 10 存进去。栈就好如一个桶,任何时候只能对桶口的元素进行操作,所以数据只能在栈顶进行存取。部分指令可以直接在柜子里面直接进行,比如 ` iinc ` 指令,直接对抽屉里的数值进行 ` +1 ` 操作。我们经常遇到的 i++ 和 ++i,通过字节码对比起来,答案一下子就一目了然了。如下表格所示:

 
![](http://static.51tbox.com/static/2025-01-06/col/058a36766bc602cd99da1f2f622a3ace/e09c41599dee4cd58c9cae792042f98a.jpg)

 
左列中, ` iload_1 ` 从局部变量表的第1号柜子取出一个数,压入栈顶,下一步直接在柜子(局部变量表)里实现 + 1的操作,而这个操作时对栈顶元素的值没有任何影响,所以 ` istore_2 ` 只是把栈顶元素赋值给 a,而右列,它是先在柜子(局部变量表)里面进行 +1的操作,然后再通过 ` iload_1 ` 把第1号柜子里的数压入栈顶,所以 ` istore_2 ` 赋给a的值是 +1 之后的值。扩展下,i++ 并非是原子操作。即使通过 ` volatile ` 关键字来修饰,多线程情况下,还是会出现数据互相覆盖的情况。

 
### (3) 动态连接 {#toc_h3_6}


 
每个栈帧中包含一个 ` 在常量池中对当前方法的引用 ` ,目的是 ` 支持方法调用过程的动态连接 ` 。

 
### (4) 方法返回地址 {#toc_h3_7}


 
方法执行时有两种退出情况:第一,正常退出,即 ` 正常执行到任何方法的返回字节码指令 ` ,如 ` RETURN ` 、 ` IRETURN ` 、 ` ARETURN ` 等;第二,异常退出。 ` 无论何种退出情况,都将返回方法当前被调用的位置 ` 。方法退出的过程相当于弹出当前栈帧,而退出可能有三种方式:

 
     

  
      
* 返回值压入上层调用栈帧。

  
      
* 异常信息抛给能够处理的栈帧。

  
      
* PC 计数器指向方法调用后的下一条指令。

 
     

 
5、Native Method Stacks(本地方法栈) {#toc_h2_8}
-----------------------------------------


 
本地方法栈(Native Method Stack)在JVM内存布局中,也是 ` 线程对象私有 ` 的,但是虚拟机栈"主内",而本地方法栈"主外"。这个"内外"是针对JVM来说的,本地方法栈为 ` Native方法 ` 服务。线程开始调用本地方法时,会进入一个不再受JVM约束的世界。本地方法可以通过 ` JVNI ` (Java Native Interface)来访问虚拟机运行时的数据区,甚至可以调用寄存器,具有和JVM相同的能力和权限。当大量本地方法出现时,势必会削弱JVM对系统的控制力,因为它的出错信息都比较黑盒,难以捉摸。对于内存不足的情况,本地方法栈还是会抛出 ` native heap OutOfMemory ` 。

 
重点说下JNI类本地方法,最常用的本地方法应该是 ` System.currentTimeMills() ` , ` JNI ` 使Java深度使用操作系统的特性功能,复用非Java代码。但是在项目过程中,如果大量使用其他语言来实现 ` JNI ` ,就会丧失跨平台特性,威胁到程序运行的稳定性。假如需要与本地代码交互,就可以用中间标准框架来进行解耦,这样即使本地方法崩溃也不至于影响到 ` JVM ` 的稳定。

 
6、Program Counter Register (程序计数寄存器) {#toc_h2_9}
------------------------------------------------


 
在程序计数寄存器(Program Counter Register,PC)中,Register的命名源于CPU的寄存器, ` CPU只有把数据装载到寄存器才能够运行 ` 。寄存器存储指令相关的现场信息,由于CPU时间片轮限制,众多线程在并发执行过程中,任何一个确定的时刻,一个处理器或者多核处理器中的一个内核,只会执行某个线程中的一个指令。这样必然会导致经常中断或恢复,如何才能保证分毫无差呢?每个线程在创建之后,都会产生自己的 ` 程序计数器 ` 和 ` 栈帧 ` , ` 程序计数器 ` 用来存放 ` 执行指令的偏移量和行号指示器等 ` ,线程执行或恢复都要依赖 ` 程序计数器 ` 。 ` 程序计数器在各个线程之间互不影响,此区域也不会发生内存溢出异常 ` 。

 
7、小结 {#toc_h2_10}
-----------------


 
最后,从线程的角度来看,堆和元空间是所有线程共享的,而虚拟机栈、本地方法栈、程序计数器是线程内部私有的,我们以线程的角度再来看看Java的内存结构图:

 
![](http://static.51tbox.com/static/2025-01-06/col/058a36766bc602cd99da1f2f622a3ace/9f72051ba78941bda60c9091a1df136f.jpg)

 
参考自《码出高效》

 
     

  
      
*** ** * ** ***

 标题:JVM之内存布局详解,图文并茂 
 作者: https://51tbox.com/ 
 地址: / 

  
      
如未加特殊说明,文章均为原创,转载必须注明出处。均采用 https://creativecommons.org/licenses/by-sa/4.0/deed.zh-Hans !
  本网站发布的内容(图片、视频和文字)以原创、转载和分享网络内容为主,如果涉及侵权请尽快告知,我们将会在第一时间删除。若本站转载文章遗漏了原文链接,请及时告知,我们将做删除处理!文章观点不代表本网站立场,如需处理请联系首页客服。  
  • 网站转载须在文章起始位置标注作者及原文连接,否则保留追究法律责任的权利。  
  • 公众号转载请联系网站首页的微信号申请白名单!  

  
      
*** ** * ** ***

  
 ![]() 

 
     

```

赞(1)
未经允许不得转载:工具盒子 » JVM之内存布局详解,图文并茂