金三银四又来啦!八股文还是得复习起来,最近准备把一些常见的八股文知识点聊聊。
本文详解了JVM内存结构和各个部分详细内容,应付面试绰绰有余!
点击上方“后端开发技术”,选择“设为星标” ,优质资源及时送达
JVM体系基本结构
学习JVM内存结构之前,首先要明白它在整个JVM体系中所处的位置。
JVM 体系主要是两个部分,JVM的内部体系结构分为三个子系统和两大组件,分别是:类装载器(ClassLoader)子系统、执行引擎子系统和GC子系统,组件是内存运行数据区域和本地接口。
类加载器子系统,GC子系统以及内存运行数据区域是我们研究的重点。
JVM 内存空间结构
JVM内存结构主要有五部分组成:程序计数器、方法区、虚拟机栈、本地方法栈、堆。
程序计数器(PC寄存器)
JVM中的程序计数器(Program Counter Register)又名 PC 寄存器,是线程私有的,Java虚拟机会为每个线程创建PC寄存器,与线程共存亡。PC寄存器是一块很小的内存空间(只存下一条指令的地址),几乎可以忽略不计。也是运行速度最快的存储区域。
作用:PC寄存器用来存储指向下一条指令的地址(即将要执行的指令代码),由执行引擎读取下一条指令。每执行一条指令 PC 都会自增,因此 PC 存储了指向下一条要被执行的指令地址。
注意,程序计数器并不是一个指针,它只是一个计数器,用于记录下一条要执行的字节码指令的位置,字节码指令通常是存储在方法区或者堆中的永久代(Permanent Generation)中。在方法区中,字节码指令存储在类的字节码文件中,当类被加载到内存中时,字节码文件就会被读取并存储到方法区中。在执行 Java 程序时,JVM 会从方法区中读取字节码指令,并将其解释执行或者编译执行。
可以看作是当前线程所执行的字节码的行号指示器,但是并不直接指向方法区的代码,他保存的地址才指向方法区字节码信息。
在任意时刻,一个 Java 线程总是在执行一个方法,这个方法称为当前方法。如果当前方法不是本地方法,PC寄存器总会执行当前正在被执行的指令,如果是本地方法,则PC寄存器值为Underfined。
Register的命名源于CPU的寄存器,寄存器存储指令相关的现场信息。CPU只有把数据装载到寄存器才能够运行。JVM中的PC寄存器是对物理PC寄存器的一种抽象模拟(即软件方面的)。
程序计数器是JVM中唯一一个没有 OOM异常的区域。
方法区(永久代)
方法区存储了每个类的信息(版本、字段、方法、接口等)、常量、静态变量、即时编译后的代码等数据。所有线程共享同一个方法区,因此访问方法区数据的和动态链接的进程必须线程安全。如果两个线程试图访问一个还未加载的类的字段或方法,必须只加载一次,而且两个线程必须等它加载完毕才能继续执行。
永久代(Permanent Generation)是 HotSpot 的概念,方法区是 Java 虚拟机实现规范,JDK 1.7及之前,Hotspot 使用永久代实现方法区。JDK 1.8 开始 HotSpots 取消了永久代,引入元空间。元空间是直接存在内存中,不在 Java虚拟机中的,因此元空间依赖于内存大小,当然你也可以自定义元空间大小。
你可能对此有疑问。那么是不是就没有方法区了呢?
当然不是,方法区是一个规范,规范没变,它就一直在。那么取代永久代的就是元空间。原先永生代中类的元信息会被放入本地内存(元数据区,metaspace),将类的静态变量和内部字符串放入到java堆中。
在JDK1.7中存储在永久代的部分数据就已经转移到Java Heap或者Native memory。但永久代仍存在于JDK 1.7中,并没有完全移除,譬如符号引用(Symbols)转移到了native memory;字符串常量池(interned strings)转移到了堆中;JDK1.8开始,类的静态变量(class statics variables )转移到了Java heap;
方法区不需要连续的内存,可以选择固定大小或者可扩展。并且还可以选择不实现垃圾收集。相对而言,垃圾收集行为在这个区域是比较少出现的,但并非数据进入了方法区就如永久代的名字一样“永久”存在了。
这个区域的内存回收目标主要是针对常量池的回收和对类型的卸载,一般来说这个区域的回收“成绩”比较难以令人满意,尤其是类型的卸载,条件相当苛刻,但是这部分区域的回收确实是有必要的。当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。
为什么引入元空间
元空间(Metaspace)是在JDK 1.8中引入的。在JDK 1.8之前的版本中,JVM使用永久代(Permanent Generation)来存储类的元数据信息,如类的字节码、方法、字段等信息。但是永久代的大小是有限制的,当加载的类或者应用程序使用的字符串常量等数据量较大时,可能会导致永久代的内存不足,从而引发OutOfMemoryError等问题。为了解决这个问题,JDK 1.8引入了元空间,它将类的元数据信息存储在堆外内存中,不再依赖于永久代,从而避免了永久代的内存限制问题。同时,元空间还支持动态调整大小,可以根据需要动态增加或缩减空间大小,提高了应用程序的灵活性。但是如果超过机器内存,也会出现OOM。
可以使用如下参数来调节方法区的大小:
JDK1.8之前调节方法区大小:
-XX:PermSize=N //方法区(永久代)初始大小
-XX:MaxPermSize=N //方法区(永久代)最大大小,超出这个值将会抛出OutOfMemoryError
JDK1.8开始方法区(HotSpot的永久代)被彻底删除了,取而代之的是元空间,元空间直接使用的是本机内存。参数设置:
-XX:MetaspaceSize=N //设置Metaspace的初始(和最小大小)
-XX:MaxMetaspaceSize=N //设置Metaspace的最大大小
虚拟机栈
Java虚拟机栈(Java Virtual Machine Stack),早期也叫Java栈。每个线程在创建时都会创建一个虚拟机栈,其内部存储一个个用于线程执行方法的栈帧(Stack Frame),每个方法在执行时都会创建一个栈帧并推入虚拟机栈顶。栈是线程私有的,生命周期和线程一致,也就是线程结束了。
虚拟机栈中主要存放以下内容:
局部变量表:用于存储方法执行过程中的局部变量,包括基本数据类型、对象引用(类、数组、接口)以及returnAddress类型(指向方法返回地址的指针)。
操作数栈:用于存储方法执行过程中的操作数,包括方法参数、局部变量以及运算时的临时变量等。
方法返回地址:用于存储方法调用后的返回地址,以便方法执行完成后能够返回到原来的调用位置。
动态链接:用于存储方法调用的动态链接信息,包括指向该方法所在类的常量池中的方法符号引用和该方法在运行时常量池中的直接引用等。
栈上分配是什么?
栈上分配是指在方法调用时,如果一个对象满足一些特定条件(如对象的生命周期非常短,不逃逸出当前方法等),那么可以将这个对象分配在虚拟机栈中,而不是在堆中进行分配。这种方式称为栈上分配。
相比在堆中分配,栈上分配的优势在于可以避免对堆内存的频繁申请和释放,减少了垃圾回收器的负担,从而提高了程序的执行效率。
需要注意的是,虚拟机并不是所有的对象都可以进行栈上分配,只有在编译器能够确定对象的生命周期和作用域不会逃逸出当前方法时,才能进行栈上分配。否则,对象必须在堆中进行分配,并由垃圾回收器负责管理和回收。
栈上分配是一项高级优化技术,在某些情况下可以显著提高程序的性能。但需要注意的是,过度依赖栈上分配可能会导致栈溢出等问题,因此应该谨慎使用。
栈中可能出现的异常
Java 虚拟机规范允许Java栈的大小是动态的或者是固定不变的。
如果采用固定大小的Java虚拟机栈,那每一个线程的Java虚拟机栈容量可以在线程创建的时候独立选定。如果线程请求分配的栈容量超过Java虚拟机栈允许的最大容量,Java虚拟机将会抛出一个StackOverflowError(给的固定内存不够) 异常。
如果Java虚拟机栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的虚拟机栈,那Java虚拟机将会抛出一个 OutOfMemoryError (无法申请足够的内存)异常。
本地方法栈
Java虚拟机栈于管理Java方法的调用,而本地方法栈用于管理本地方法(Native Method)的调用。本地方法栈,也是线程私有的。
允许被实现成固定或者是可动态扩展的内存大小。在内存溢出方面与Java栈是相同的,可能出现 StackOverflowError 和 OutOfMemoryError。
如果线程请求分配的栈容量超过本地方法栈允许的最大容量,Java虚拟机将会抛出一个StackOverflowError 异常。如果本地方法栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的本地方法栈,那么Java虚拟机将会抛出一个OutOfMemoryError异常。(上面两点跟虚拟机栈一样)
本地方法是使用C语言实现的。
它的具体做法是Native Method Stack中登记native方法,在Execution Engine 执行时加载本地方法库(本地方法压入本地方法栈,然后给执行引擎获取(动态链接的方式),然后调用)。
堆(Heap)
应用系统对象都保存在Java堆中,堆被用来在运行时分配类实例、数组。堆上存储的对象在方法结束时不会被移除,只能由垃圾回收器移除。堆也是Java垃圾收集器管理的主要区域(所以很多时候会称它为GC堆),所有线程共享。
从GC回收的角度看,由于现在GC基本都是采用的分代收集算法,所以堆内存结构还可以分块成:新生代和老年代;再细一点的有Eden空间、From Survivor空间、To Survivor空间
为了给对象在内存中分配一块确定大小的空间,有两种堆内分配内存的方法,选择哪种分配方式由Java堆是否规整决定。
指针碰撞(Bump the Pointer):在堆内存中,用一个指针作为分界点,一侧为已经分配的内存,另一侧则是未分配的内存,当要分配内存时,只需要将指针向未分配的内存移动一段与对象大小相等的距离即可,分配出一块连续的内存空间来存储对象。(Serial、ParNew等带压缩过程的收集器采用)
空闲列表(Free List):在堆内存中,已经分配的内存和未分配的内存是交织在一起的,没有分界点,为了分配内存,需要维护一个空闲列表(Free List),记录哪些内存块是可用的,当需要分配内存时,从空闲列表中找到一块足够大小的空闲内存,然后将其分配给对象。分配内存后,需要从空闲列表中删除已分配的内存块,如果内存块被释放,那么需要将其加入到空闲列表中,方便下一次分配使用。(CMS这种基于标记清除Mark-Sweep算法的收集器采用)
总结
最后,我用一张图来总结今天的知识,大家收藏即可。
最后,欢迎大家提问和交流。
如果对你有帮助,欢迎点赞、评论或分享,感谢阅读!
无需注册直接体验ChatGPT!附注册ChatGPT详细教程
2023-02-09
一文掌握,单机Redis、哨兵和Redis Cluster的搭建,建议收藏
2023-02-11
MySQL事务ACID都知道,原理是什么?附面试题
2023-02-07