JVM学习——Java内存区域与内存溢出异常

1. 简介

在C/C++当中,内存的管理需要由程序员手动操作,使用mallocnew创建对象,然后再相对应地调用freedelete对先前分配出去的空间予以释放

尽管最终C/C++的执行效率比较高,但是开发起来会很头疼,因为内存的问题是绕不开的,正所谓权力越大,责任越大,内存的操作可得谨慎地多想想

而在Java当中,内存的管理交由虚拟机,也就是JVM负责,而程序员可以好好关注业务逻辑,GC会对空间进行释放,虽然会牺牲一部分性能,但是开发效率提升了很多,而性能方面也可以借助硬件往上怼

2. 运行时数据区域

img

2.1. 程序计数器

程序计数器简称PC(Program Counter),是一块较小的内存空间,将其看做是当前程序所执行的字节码的行号指示器

2.1.1. 作用

Java源文件在编译成字节码文件后,丢给JVM执行,字节码解释器通过改变PC的值,往下读指令,将指令一条条地取出来执行

2.1.2. 多线程场景

Java在多线程的情况下,线程会轮流进行切换,根据各自被分配到的CPU时间而定

image-20230311131144297

在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令

线程切换执行后还需要继续从上次的位置继续执行,那么为了能够使其恢复到正确的位置,每条线程需要独立的PC(就好像看书的时候放个书签)

image-20230311130752009

因而各个线程间的PC是互不影响的,独立地进行存储,这类内存区域也被称为**“线程私有”内存**,就比方说你在你家里呆着,我在我家里呆着,互不影响

2.1.3. 特点

如果当前线程正在执行的是一个Java方法,那么PC记录的便是当前JVM字节码指令的地址;如果是Native方法,那么PC记录的则是空(Undefined)

PC是唯一一个在Java虚拟机规范当中没有规定任何OutOfMemoryError情况的内存区域

2.2. Java虚拟机栈

Java虚拟机栈(Java Virtual Machine Stacks)是线程私有的,其生命周期与线程相同

虚拟机栈描述的是Java方法执行的内存模型 :每个方法执行的同时会创建一个栈帧(平时使用IDE的断点调试应该都能够看到)

2.2.1. 栈帧

栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息

方法开始调用时,会对其入栈,也就是把对应的栈帧往栈中存放,等到方法调用完成,再将方法对应的栈帧从栈中弹出(对应于栈顶指针的下移);因而方法的调用过程便对应一个栈帧在虚拟机栈的入栈与出栈操作

image-20230311131901681

2.2.2. 内存划分

Java的内存远不仅仅是栈空间和堆空间,这只是由于这两块区域与编程人员关系最为密切

通常意义上所谈到的栈便是虚拟机栈,或者说是其中局部变量表的部分(开发者Debug项目代码时主要关注的部分)

2.2.3. 局部变量表

局部变量表存放了编译期可知的各种基本数据类型、对象引用、returnAddress类型

64位长度的为longdouble类型,需要占用两个局部变量空间(Slot),其余的数据类型只占用一个

局部变量表所需的内存空间在编译期已完成分配,因而在进入一个方法内部的时候,该方法对应的帧需要分配给局部变量表的空间是已经确定的,并且在方法的运行期间不会发生改变

2.2.4. 规定异常

栈溢出(StackOverflowError)

线程请求的栈的深度超过了虚拟机所能允许的深度,便会抛出StackOverflowError异常

OOM(OutOfMemoryError)

涉及到虚拟机动态扩展(大部分虚拟机都可以支持),如果在扩展的过程中无法申请到足够的内存,将会抛出OutOfMemoryError异常

2.3. 本地方法栈

本地方法栈(Native Method Stack)与Java虚拟机栈有很多相似之处,二者主要的区别在于Java虚拟机栈的服务对象是Java方法(也就是字节码),而本地方法栈的服务对象则是Native方法

虚拟机的规范中对于本地方法栈的语言、使用方式、数据结构并没有强制规定,可以由虚拟机自由实现

并且,本地方法栈和Java虚拟机栈一样,会抛出StackOverflowErrorOutOfMemoryError异常

2.4. Java堆

Java堆(Java Heap)是Java虚拟机所管理的内存中最大的一块被所有线程共享,并且在虚拟机启动的时候进行创建

image-20230306233516594

该内存区域唯一的目的就是用来存放对象实例,几乎所有的对象实例都在这里分配内存(后期由于栈上分配、标量替换优化技术导致了一些微妙的变化)

image-20230306234005845

Java堆是垃圾收集器管理的主要区域,因此有时也被称为GC堆

由于现在的GC基本上采用的都是分代收集算法,因此在Java堆中可以进一步细分为新生代老年代,也可以继续向下细分为Eden空间From Survivor空间To Survivor空间等

image-20230306235101585

从内存分配的角度,线程共享的堆当中可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB),但是存放的东西还是实例,这一点倒是没有变的

Java堆可以是物理上不连续的空间,只要逻辑上连续即可既可以是固定大小的,也可以是可扩展的(当前主流的虚拟机都是按照可扩展来实现的)

如果当前的堆中没有足够的空间完成实例分配,且无法扩展时,将会抛出OutOfMemoryError异常

2.5. 方法区

方法区(Method Area)与Java堆一样,为各个线程共享的内存区域

image-20230307230422675

方法区用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据

image-20230307231331414

在Java虚拟机的规范中把方法区描述为堆的一个逻辑部分,但是其有一个别名,称为 Non-Heap (非堆)

永久代是HotSpot虚拟机团队把GC的分代收集扩展至方法区而形成方法区的内存管理实现,对其他虚拟机而言并不存在该概念

Java虚拟机规范对方法区的限制非常宽松,不需要物理连续内存(可以固定大小或者可扩展,与堆一样),还可以选择不实现垃圾收集;内存区的垃圾收集频率是比较低的,其回收的主要目标是对常量池的回收和对类型的卸载,但是很必要

当方法区无法满足内存分配需求时,将同样抛出OutOfMemoryError异常

2.6. 运行时常量池

运行时常量池(Runtime Constant Pool)是方法区的一部分

Class文件中除了有类的版本、字段、方法、接口等描述信息,还包括了常量池(Constant Pool Table)信息,用于存放编译期生成的各种字面量和符号引用,这部分内容在类加载后会进入方法区的运行时常量池存放

image-20230310000116337

Java虚拟机对Class文件每一部分(包括常量池)的格式都有严格规定:每一个字节用于存储什么数据都需要符合规范上的要求才能够得到虚拟机的认可、装载以及执行

至于运行时常量池,Java虚拟机并没有做细节层面上的要求,各个虚拟机提供商可以根据具体的需求进行不同的实现

除了Class文件中的符号引用,还会将翻译出来的直接引用存储到运行时常量池

相对于Class文件常量池,运行时常量池还具有动态性常量并非仅在编译期才产生运行期也可以将新的常量放入运行时常量池,如String类的intern()

image-20230311000548539

由于运行时常量池也还是内存的一部分,因此内存的大小依然会对其形成制约,当无法申请到内存时会抛出OutOfMemoryError异常

2.7. 直接内存

直接内存(Direct Memory) 本身并不是虚拟机运行时数据区的一部分,也非Java虚拟机规范中定义的内存区域

但是在实际情况中,这块区域被频繁使用,也会导致OutOfMemoryError异常的出现