面试:精通Java;面试官:来讲一下JVM虚拟机内存模型的最底层原理,必须说详细说清楚,知其所以然。

举报
YuShiwen 发表于 2022/03/31 09:53:06 2022/03/31
【摘要】 通Java?来看看下面这些底层中的底层原理你是否知道吧。 提到JVM必不可少的就得谈到它的内存模型,根据 JVM 规范,JVM 内存共分为虚拟机栈VM stack、堆heap、方法区Method Area、程序计数器Program Counter Register、本地方法栈Native Method Stack五个部分。如下图,咋们分别对这五个区域进行详细的原理讲解。(为节省读者的时间,方便大家

精通Java?来看看下面这些底层中的底层原理你是否知道吧。
提到JVM必不可少的就得谈到它的内存模型,根据 JVM 规范,JVM 内存共分为虚拟机栈VM stack堆heap方法区Method Area程序计数器Program Counter Register本地方法栈Native Method Stack五个部分。如下图,咋们分别对这五个区域进行详细的原理讲解。(为节省读者的时间,方便大家理解记忆,笔者把全部知识点分层分段,用较短的语言去描述,言简意赅,句句都是重点。)

在这里插入图片描述

1.虚拟机栈(VM stack)

  • 每个线程有一个私有的栈,随着线程的创建而创建。

  • 能抛出StackOverflowError和OutOfMemoryError异常。

    • 如果线程请求分配的栈容量超过虚拟机栈允许的最大容量,java虚拟机将会抛出一个Stack Overflow Error异常。
    • 如果虚拟机栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时,没有足够的内存去创建对应的虚拟机栈,那么java虚拟机将会抛出一个OutOfMemoryError异常。
  • 方法调用相关知识:

    • 方法调用时,创建栈帧,并压入虚拟机栈;方法执行完毕,栈帧出栈并被销毁,
    • 栈里面存着的是一种叫“栈帧”的东西,每个方法会创建一个栈帧,栈帧结构分为:局部变量表(基本数据类型和对象引用)、操作数栈、方法出口等信息。
    • 我们debug的时候可以很明确的看到Frames,如下图:
      在这里插入图片描述
      对应的原理图如下:
      在这里插入图片描述
  • 栈内存指的便是虚拟机栈的栈帧中的局部变量表,因为这里存放了一个方法的所有局部变量。

  • 栈的大小可以固定也可以动态扩展。当栈调用深度大于JVM所允许的范围,会抛出StackOverflowError的错误,不过这个深度范围不是一个固定的值,(我们也可以更改vmoptions文件中的参数来调整其大小,具体参见本文最后的附录,这里先不展开),大家可以通过下面的代码进行测试:

public class HeapDeepDemo {

    private static int index = 0;

    public void addIndex() {
        index++;
        addIndex();
    }


    public static void main(String[] args) {

        HeapDeepDemo heapDeepDemo = new HeapDeepDemo();

        try {
            heapDeepDemo.addIndex();
        } catch (Error e) {
            System.out.println("Stack deep : " + index);
            e.printStackTrace();
        }
    }
}

四次执行结果都不同,如下:
在这里插入图片描述在这里插入图片描述
在这里插入图片描述

在这里插入图片描述


2.本地方法栈(Native Method Stack)

Java方法调用本地方法栈的过程如下图:
在这里插入图片描述
什么是本地方法栈(Native Method Stack)?

  • 一个Native Method就是一个Java调用非Java代码的接口。方法的实现由非Java语言实现,比如C或C++。他的具体做法是Native Method Stack中登记native方法,在Execution Engine执行时加载本地方法库。

为什么要用到本地方法栈(Native Method Stack)?

  • 有些层次的任务用java实现起来不容易,或者对程序的效率有要求时,还有时java应用需要与java外部的环境交互,这就是本地方法存在的主要原因。

本地方法栈的知识点:

  • 上面我们提到了VM虚拟机栈,虚拟机栈用于管理java方法的调用,而本地方法栈用于管理本地方法的调用,各司其职。
  • 和虚拟机栈VM stack一样,本地方法栈Native Method Stack同样它也是线程私有的。
  • 和虚拟机栈VM stack一样,允许被实现成固定或者是可动态扩展的大小
  • 和虚拟机栈VM stack一样,本地方法栈Native Method Stack也能抛出StackOverflowError和OutOfMemoryError异常。
    • 如果线程请求分配的栈容量超过本地方法栈允许的最大容量,java虚拟机将会抛出一个Stack Overflow Error异常。
    • 如果本地方法栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时,没有足够的内存去创建对应的本地方法栈,那么java虚拟机将会抛出一个OutOfMemoryError异常。

3.程序计数器(Program Counter Register)

3.1类比X86架构中的IP指令指针寄存器

程序计数器又可翻译为PC寄存器,可类比汇编与微机原理中X86架构的CPU中的IP指令指针寄存器,详情可参考本人另外一篇博客:https://blog.csdn.net/MrYushiwen/article/details/122627634
其中的大致内容截取如下:
在这里插入图片描述

x86架构的寄存器图如下:(记得我们大学的课本上的图也是这么画的,图片恒永久,一张永流传!哈哈哈)
在这里插入图片描述

  • 为什么要类比我们x86架构中的ip指令指针寄存器呢,因为在 Java1.2 之后. Linux中的JVM是基于pthread实现的, 可以直接说 Java 线程就是依赖操作系统实现的,Java线程总是需要以某种形式映射到OS线程上;映射模型可以是1:1(原生线程模型)、n:1(绿色线程 / 用户态线程模型)、m:n(混合模型)。
  • 它目前在大多数平台上都使用1:1模型。也就是每个Java线程都直接映射到一个OS线程上执行。此时,native方法就由原生平台直接执行,并不需要理会抽象的JVM层面上的“pc寄存器”概念——原生的CPU上真正的PC寄存器是怎样就是怎样。现在的Java中线程的本质,其实就是操作系统中的线程。

3.2JVM中的程序计数器

  • 它在JVM中是一块较小的内存空间,JVM支持多个线程同时运行,每个线程都有自己的程序计数器。
  • 它的作用可以看作是当前线程所执行的字节码的行号指示器。在虚拟机的概念模型里字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
  • 运行时的特点:
    • 随着线程的创建而启动;
    • 如果线程正在执行的是Java 方法,则这个计数器记录的是正在执行的虚拟机字节码指令地址;
    • 如果正在执行的是Native 方法,则这个技术器值为空(Undefined);
    • 此内存区域是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域。

4.方法区(Method Area)

  • 方法区是所有线程共享。
  • 它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
    在这里插入图片描述
  • 方法区逻辑上属于堆的一部分,但是为了与堆进行区分,通常又叫“非堆”。
  • 永久代(PermGen)、方法区(Method Area)、元空间(Metaspace)之间的关系:
    • 方法区(Method Area)是规范层面的东西,规定了这一个区域要存放哪些东西,永久代(PermGen)和元空间(Metaspace)是对方法区(Method Area)的不同实现。
    • 永久代(PermGen)是Java7以及之前JVM对于方法区(Method Area)的实现。
    • 元空间(Metaspace)是Java8以及之后JVM对于方法区(Method Area)的实现。
    • 举个例子:方法区比作手机,那么永生带可以比作诺基亚手机,元空间可以比作华为手机。
  • Java8的时候为什么要用元空间(Metaspace)替换掉永久代(PermGen):
    • 永久代大小有限制,如果加载的类太多,很可能导致永久代内存溢出,即java.lang.OutOfMemoryError: PermGen,因此 JVM 的开发者希望这一块内存可以更灵活地被管理,不要再经常出现这样的 OOM。
    • 移除 永久代 可以促进 HotSpot JVM 与 JRockit VM 的融合,因为 JRockit 没有永久代。
    • 在Java7的时候,对于interned strings,不再分配在堆的永久代中了,而是分配在了堆中的主要部分:新生代和老年代中。在Java8的时候官方文档讲到了移除了永久代,但没有说其它关于interned strings相关的变化信息,因此,可以确定在Java8中字符串常量池存放在堆中。
    • 也就是说在Java8的时候方法区由原来的永久代变成了元空间(类信息)和堆实现(常量池、静态变量)两个部分。堆中包含正常对象和常量池,new String()放入堆中,String::intern方法会首先从堆中的常量池中取,如果常量池中没有,就在常量池中保存String的值,然后返回其引用,下次在调用String::intern方法时,会直接返回常量池中的该值。
    • 我们在Java8中也可以说常量池在方法区,因为永久代(PermGen)和元空间(Metaspace)是对方法区(Method Area)的不同实现,在上面我们刚刚也提到过。
    • 元空间是使用本地内存(Native Memory)实现的,也就是说它的内存是不在虚拟机内的,所以可以理论上物理机器还有多个内存就可以分配,而不用再受限于JVM本身分配的内存
    • 如果Metaspace的空间占用达到了设定的最大值,那么就会触发GC来收集死亡对象和类的加载器。

5.堆(heap)

Java7以及之前的结构图:
在这里插入图片描述
在4章节方法区(Method Area)中以及提到过Java8的时候用元空间(Metaspace)替换掉了永久代(PermGen),所以Java8以及之后的图如下:
在这里插入图片描述

  • 堆为什么为什么分代:

    • 分代的唯一理由就是优化GC性能。
    • 如果没有分代,所有的对象都在一块,GC的时要找到哪些对象是没用的,这样就会对堆的所有区域进行扫描。而我们的很多对象都是朝生夕死的,比如年轻代中的对象基本都是朝生夕死的(80%以上),所以在年轻代的垃圾回收算法使用的是复制算法(后续会写一遍博文详细介绍)。
    • 如果分代的话,把新创建的对象放到某一地方,当GC的时先把这块存“朝生夕死”对象的区域进行回收,这样就会腾出很大的空间出来。
  • Minor GC、Major GC和Full GC之间的区别:

    • Minor GC或Young GC,用来回收年轻代(包括 Eden 和 Survivor 区域)内存空间的 。
    • Old GC ,是清理老年代内存空间的。
    • Full GC ,是回收整个堆空间,包括年轻代和老年代。在Java7以及之前还包括永久代;Java8及以后由于改成了元空间,它的垃圾回收就不是由java来控制了,元空间的默认情况下内存空间是使用的操作系统的内存空间,所以空间的容量是比较充裕的,不会发生元空间的空间不足问题,如果Metaspace的空间占用达到了设定的最大值,那么也会触发GC来收集死亡对象和类的加载器。
    • Major GC ,有的人会把它和 Old GC等价,有的人会把它和Full GC等价,我们尽量不提这个Major GC,如果提到了,要问清楚对方指的是Old GC还是Full GC。
    • 关于GC以及GC回收算法,笔者会在后续写一遍博文详细介绍。
  • HotSpot JVM把年轻代分为了三部分:

    • 三个部分分别是1个Eden区和2个Survivor区(分别叫from和to)。默认比例为8:1:1
    • 如果没有Survivor,Eden区每进行一次Minor GC,存活的对象就会被送到老年代。老年代很快被填满,老年代的内存空间超过某个阈值或者远大于新生代时,会进行一次Full GC,而Full GC消耗的时间比Minor GC长得多。
    • 设置两个Survivor区最大的好处就是解决了碎片化。
  • 年轻代如何变成老年代

    • 一般情况下,新创建的对象都会被分配到Eden区(一些大对象特殊处理),这些对象经过第一次Minor GC后,
      如果仍然存活,将会被移到Survivor区。对象在Survivor区中每熬过一次Minor GC,年龄就会增加1岁,
      当它的年龄增加到一定程度时(一般16次),就会被移动到年老代中。

6.附录(VM options参数)

在这里插入图片描述
打开后的内容如下:
在这里插入图片描述

文件路径如下:
在这里插入图片描述
文件内容如下:
在这里插入图片描述
参数值如下:

  • -Xms1024m,设置JVM初始堆内存为1024m。此值可以设置与-Xmx相同,以避免每次垃圾回收完成后JVM重新分配内存。
  • -Xmx2048m,设置JVM最大堆内存为2048m。
  • -Xss512k,设置每个线程的栈大小。JDK5.0以后每个线程栈大小为1M,之前每个线程栈大小为256K。在相同物理内存下,减小这个值能生成更多的线程,当然操作系统对一个进程内的线程数还是有限制的,不能无限生成。线程栈的大小是个双刃剑,如果设置过小,可能会出现栈溢出,特别是在该线程内有递归、大的循环时出现溢出的可能性更大,如果该值设置过大,就有影响到创建栈的数量,如果是多线程的应用,就会出现内存溢出的错误。
  • -Xmn341m,设置年轻代大小为341m。在整个堆内存大小确定的情况下,增大年轻代将会减小年老代,反之亦然。此值关系到JVM垃圾回收,对系统性能影响较大,官方推荐配置为整个堆大小的3/8。
  • -XX:NewSize=341m,设置年轻代初始值为341M。
  • -XX:MaxNewSize=341m,设置年轻代最大值为341M。
  • -XX:PermSize=512m,设置持久代初始值为512M,但在java8及之后就不支持了,改用-XX:MetaspaceSize=512m。
  • -XX:MaxPermSize=512m,设置持久代最大值为512M,同样在java8及之后就不支持了,改用-XX:MaxMetaspaceSize=512m。
  • -XX:NewRatio=2,设置年轻代(包括1个Eden和2个Survivor区)与年老代的比值。表示年轻代比年老代为1:2。
  • -XX:SurvivorRatio=8,设置年轻代中Eden区与Survivor区的比值。表示2个Survivor区(JVM堆内存年轻代中默认有2个大小相等的Survivor区)与1个Eden区的比值为1:1:8,即1个Survivor区占整个年轻代大小的1/10。
  • -XX:MaxTenuringThreshold=15,具体参看JVM系列之内存分配和回收策略中对象的衰老过程。
  • -XX:ReservedCodeCacheSize=240m,设置代码缓存的大小,用来存储已编译方法生成的本地代码。
  • 更多参数配置说明请参考官方文档:https://www.oracle.com/java/technologies/javase/vmoptions-jsp.html

该篇已完结
YuShiwen
【版权声明】本文为华为云社区用户原创内容,转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息, 否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。