【后端开发笔记】JVM底层原理-内存结构篇

内存结构

运行时数据区域

|-------|----------------------------------------|-------|----------------------------------------------------------------|
| 内存区域 | 作用 | 线程隔离性 | 溢出错误 |
| 程序计数器 | 记录当前线程执行的字节码位置 | 私有 | 无溢出 |
| 虚拟机栈 | 存储方法执行时的栈帧(包括局部变量表、操作数栈、动态链接、方法出口等信息) | 私有 | StackOverflowError(线程请求栈深度超过允许值)、OutOfMemoryError(扩展时无法申请足够内存) |
| 本地方法栈 | 支持Native 方法 | 私有 | 同虚拟机栈,StackOverflowError 和 OutOfMemoryError |
| 堆 | 存放对象实例和数组,是垃圾回收的主要区域 | 共享 | OutOfMemoryError(堆中没有足够内存完成实例分配且无法扩展时) |
| 方法区 | 存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等 | 共享 | OutOfMemoryError(方法区无法满足内存分配需求时) |

程序计数器

**作用:**内部保存字节码的行号,用于记录正在执行的字节码地址。字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制;多线程中,用于记录当前线程执行的位置,从而确保在多线程切换后,能正确恢复到原来的执行位置。

特点:

  • 线程私有。保证多线程轮流执行时,每个线程能够正确高效地切换和恢复执行,确保各线程之间的程序计数器互不干扰
  • 不存在内存溢出(OOM),因此这个空间不会进行 GC,线程周期随线程的创建而创建,随线程的结束而死亡。因为它只存储一个固定大小的指针
虚拟机栈

线程私有, 是每个 JVM 为所有 正在执行的方法 创建的一块私有的内存区域。随线程的创建而创建,随线程的结束而死亡

  • 每个方法执行时,都会在虚拟机栈中创建一个栈帧(一个方法一个栈帧
  • 栈的大小可以是动态的也可以是固定的
  • 每个栈由多个栈帧组成,对应每次方法调用时占用的内存,每个栈帧存储着:
    • 局部变量表:存储方法中的基本数据类型和对象的引用
    • 动态连接:即指向运行时常量池的方法引用
    • 方法返回地址:定义方法执行完毕后,程序应该回到调用者方法的哪条指令继续执行
  • 栈内存不需要进行GC,因为方法开始执行时进栈,执行完后会自动弹出栈,相当于清空内存
  • 栈内存分配越大,可用线程数越少
  • 方法内的局部变量是否线程安全:
    • 如果方法内局部变量没有逃离方法的作用访问,它是线程安全的(逃逸分析)
    • 如果是局部变量引用了对象,并逃离方法的作用范围,需要考虑线程安全
  • 异常:
    • 当虚拟机在动态扩展栈时无法申请到足够的内存空间,抛出 OutOfMemoryError 异常
    • 当线程请求的栈深度超过最大值,会抛出 StackOverflowError 异常。例如:函数调用陷入无限循环时,就会导致栈中被压入太多的栈帧,导致栈空间过深,进而抛出异常。
局部变量表

本质上定义为一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量

  • 表是建立在线程的栈上,是线程私有的数据,因此不存在数据安全问题
  • 表的容量大小是在编译期确定的,保存在方法的 Code 属性的 maximum local variables 数据项中
  • 表中的变量只在当前方法调用中有效,方法结束栈帧销毁,局部变量表也会随之销毁
  • 表中的变量也是重要的垃圾回收根节点,只要被表中数据直接或间接引用的对象都不会被回收

局部变量表最基本的存储单元是 slot(变量槽)

  • 参数值的存放总是在局部变量数组的 index0 开始,到数组长度 -1 的索引结束,JVM 为每一个 slot 都分配一个访问索引,通过索引即可访问到槽中的数据
  • 存放编译期可知的各种基本数据类型(8种),引用类型(reference),returnAddress 类型的变量
  • 32 位以内的类型只占一个 slot(包括 returnAddress 类型),64 位的类型(long 和 double)占两个 slot
  • 局部变量表中的槽位是可以重复利用的,如果一个局部变量过了其作用域,那么之后申明的新的局部变量就可能会复用过期局部变量的槽位,从而达到节省资源的目的
操作数栈

主要作为方法调用的中转站使用,用于存放方法执行过程中产生的中间计算结果临时变量

栈顶缓存技术 ToS:将栈顶元素全部缓存在 CPU 的寄存器中,以此降低对内存的读/写次数,提升执行的效率

动态连接

主要服务一个方法需要调用其他方法的场景。当一个方法要调用其他方法,需要将常量池中指向方法的符号引用转化为其在内存地址中的直接引用。动态链接的作用就是为了将符号引用转换为调用方法的直接引用,这个过程也被称为 动态连接

  • 为了支持当前方法的代码能够实现动态链接,每一个栈帧内部都包含一个指向运行时常量池或该栈帧所属方法的引用
  • 在 Java 源文件被编译成的字节码文件中,所有的变量和方法引用都作为符号引用保存在 class 的常量池中
  • 常量池的作用:提供一些符号和常量,便于指令的识别
本地方法栈

本地方法栈是为虚拟机执行本地方法时提供服务的

  • 不需要进行 GC,与虚拟机栈类似,也是线程私有的,有 StackOverFlowError 和 OutOfMemoryError 异常
  • 虚拟机栈执行的是 Java 方法,在 HotSpot JVM 中,直接将本地方法栈和虚拟机栈合二为一
  • 本地方法一般是由其他语言编写,并且被编译为基于本机硬件和操作系统的程序
  • 当某个线程调用一个本地方法时,就进入了不再受虚拟机限制的世界,和虚拟机拥有同样的权限
    • 本地方法可以通过本地方法接口来访问虚拟机内部的运行时数据区
    • 直接从本地内存的堆中分配任意数量的内存
    • 可以直接使用本地处理器中的寄存器
程序计数器

作用:内部保存字节码的行号,用于记录正在执行的字节码指令地址(如果正在执行的是本地方法则为空)

原理:

  • JVM 对于多线程是通过线程轮流切换并且分配线程执行时间,一个处理器只会处理执行一个线程
  • 切换线程需要从程序计数器中来回去到当前的线程上一次执行的行号

特点:

  • 是线程私有的
  • 不会存在内存溢出,是 JVM 规范中唯一一个不出现 OOM 的区域,所以这个空间不会进行 GC

JVM 内存中最大的一块,由所有线程共享,在虚拟机启动时创建,用于存放对象实例,几乎所有的对象实例以及数组都在这里分配内存,由垃圾回收器管理的主要区域,堆中对象大部分都需要考虑线程安全的问题

存放哪些资源:

  • 对象实例:类初始化生成的对象,基本数据类型的数组也是对象实例,new 创建对象都使用堆内存
  • 字符串常量池:
    • 字符串常量池原本存放于方法区,JDK7 开始放置于堆中
    • 字符串常量池存储的是 String 对象的直接引用或者对象,是一张 string table
  • 静态变量:静态变量是有 static 修饰的变量,JDK8 时从方法区迁移至堆中
  • 线程分配缓冲区 Thread Local Allocation Buffer:线程私有但不影响堆的共性,可以提升对象分配的效率
方法区

各个线程共享的内存区域,用于存储已被虚拟机加载的类信息、常量、即时编译器编译后的代码等数据

方法区是一个 JVM 规范,永久代与元空间都是其一种实现方式

方法区的大小不必是固定的,可以动态扩展,加载的类太多,可能导致永久代内存溢出 (OutOfMemoryError)

方法区的 GC:针对常量池的回收及对类型的卸载,比较难实现

避免方法区出现 OOM ,在 JDK8 中将堆内的方法区(永久代)移动到了本地内存上,重新开辟了一块空间,叫做元空间,元空间存储类的元信息,静态变量和字符串常量池等放入堆中

类元信息:在类编译期间放入方法区,存放了类的基本信息,包括类的方法、参数、接口以及常量池表

常量池表是 Class 文件的一部分,存储了类在编译期间生成的字面量、符号引用,JVM 为每个已加载的类维护一个常量池

  • 字面量:基本数据类型、字符串类型常量、声明为 final 的常量值等
  • 符号引用:类、字段、方法、接口等的符号引用

运行时常量池是方法区的一部分

  • 常量池(编译器生成的字面量和符号引用)中的数据会在类加载的加载阶段放入运行时常量池
  • 类在解析阶段将这些符号引用替换成直接引用
  • 除了在编译期生成的常量,还允许动态生成,例如 String 类的 intern()
字符串常量池

为了提升性能和减少内存消耗针对字符串(String 类)专门开辟的一块区域,主要目的是为了避免字符串的重复创建。

JDK1.7 之前,字符串常量池存放在永久代。JDK1.7 字符串常量池和静态变量从永久代移动到了 Java 堆中。

JDK 1.7 为什么要将字符串常量池移动到堆中?

主要是因为永久代(方法区实现)的 GC 回收效率太低,只有在整堆收集 (Full GC)的时候才会被执行 GC。Java 程序中通常会有大量的被创建的字符串等待回收,将字符串常量池放到堆中,能够更高效及时地回收字符串内存

JVM内存结构和Java内存模型的区别

  • JVM内存结构:指JVM运行时,其内存空间的划分,分为:堆、方法区、虚拟机栈、本地方法栈、程序计数器。其中,堆和方法区是线程共享的,其他则是线程私有的。
  • Java内存模型 (JMM):一套规范,定义了 线程主内存之间的抽象关系。
JVM内存结构

下面解释JVM内存结构中各部分的作用。

虚拟机启动 时创建,用于存放对象实例,几乎所有的对象实例以及数组都在这里分配内存,由垃圾回收器管理的主要区域,堆中对象大部分都需要考虑线程安全的问题。

本地内存

虚拟机内存:Java 虚拟机在执行的时候会把管理的内存分配成不同的区域,受虚拟机内存大小的参数控制,当大小超过参数设置的大小时就会报 OOM

本地内存:又叫做堆外内存,线程共享的区域,本地内存这块区域是不会受到 JVM 的控制的,不会发生 GC;因此对于整个 Java 的执行效率是提升非常大,但是如果内存的占用超出物理内存的大小,同样也会报 OOM

本地内存概述图:

元空间

PermGen 被元空间代替,永久代的类信息、方法、常量池等都移动到元空间区

元空间与永久代区别:元空间不在虚拟机中,使用的本地内存,默认情况下,元空间的大小仅受本地内存限制

直接内存

直接内存是一种特殊的内存缓冲区,并不在 Java 堆或方法区中分配的,而是通过 JNI 的方式在本地内存上分配的。

直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用。而且也可能导致 OutOfMemoryError 错误出现。

变量位置

变量的位置不取决于它是基本数据类型还是引用数据类型,取决于它的声明位置

静态内部类和其他内部类:

  • 一个 class 文件只能对应一个 public 类型的类,这个类可以有内部类,但不会生成新的 class 文件
  • 静态内部类属于类本身,加载到方法区,其他内部类属于内部类的属性,加载到堆(待考证)

类变量:

  • 类变量是用 static 修饰符修饰,定义在方法外的变量,随着 Java 进程产生和销毁
  • 在 Java8 之前把静态变量存放于方法区,在 Java8 时存放在堆中的静态变量区

实例变量:

  • 实例(成员)变量是定义在类中,没有 static 修饰的变量,随着类的实例产生和销毁,是类实例的一部分
  • 在类初始化的时候,从运行时常量池取出直接引用或者值,与初始化的对象一起放入堆中

局部变量:

  • 局部变量是定义在类的方法中的变量
  • 在所在方法被调用时放入虚拟机栈的栈帧中,方法执行结束后从虚拟机栈中弹出,

类常量池、运行时常量池、字符串常量池有什么关系?有什么区别?

  • 类常量池与运行时常量池都存储在方法区,而字符串常量池在 Jdk7 时就已经从方法区迁移到了 Java 堆中
  • 在类编译过程中,会把类元信息放到方法区,类元信息的其中一部分便是类常量池,主要存放字面量和符号引用,而字面量的一部分便是文本字符
  • 在类加载时将字面量和符号引用解析为直接引用存储在运行时常量池
  • 对于文本字符,会在解析时查找字符串常量池,查出这个文本字符对应的字符串对象的直接引用,将直接引用存储在运行时常量池

什么是字面量?什么是符号引用?

  • 字面量:java 代码在编译过程中是无法构建引用的,字面量就是在编译时对于数据的一种表示

    int a = 1; //这个1便是字面量
    String b = "iloveu"; //iloveu便是字面量

  • 符号引用:在编译过程中并不知道每个类的地址,因为可能这个类还没有加载,如果在一个类中引用了另一个类,无法知道它的内存地址,只能用它的类名作为符号引用,在类加载完后用这个符号引用去获取内存地址

对象的创建

1.类加载检查

虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程

2.分配内存

类加载检查 通过后,接下来虚拟机将为新生对象分配内存 。对象所需的内存大小在类加载完成后 便可确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 中划分出来。

分配方式"指针碰撞""空闲列表" 两种,选择哪种分配方式由 Java 堆是否规整决定,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定

两种分配方式

指针碰撞:

  • 适用场合:堆内存规整(即没有内存碎片)的情况下。
  • 原理:用过的内存全部整合到一边,没有用过的内存放在另一边,中间有一个分界指针,只需要向着没用过的内存方向将该指针移动对象内存大小位置即可。
  • 使用该分配方式的 GC 收集器:Serial, ParNew

空闲列表:

  • 适用场合:堆内存不规整的情况下。
  • 原理:虚拟机会维护一个列表,该列表中会记录哪些内存块是可用的,在分配的时候,找一块儿足够大的内存块儿来划分给对象实例,最后更新列表记录。
  • 使用该分配方式的 GC 收集器:CMS
内存分配并发问题

在创建对象的时候有一个很重要的问题,就是线程安全,因为在实际开发过程中,创建对象是很频繁的事情,作为虚拟机来说,必须要保证线程是安全的,通常来讲,虚拟机采用两种方式来保证线程安全:

  • CAS+失败重试: CAS 是乐观锁的一种实现方式。虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性。
  • TLAB: 为每一个线程预先在 Eden 区分配一块内存,JVM 在给线程中的对象分配内存时,首先在 TLAB 分配,当对象大于 TLAB 中的剩余内存或 TLAB 的内存已用尽时,再采用上述的 CAS 进行内存分配
3.初始化零值

内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值

4.设置对象头

初始化零值完成之后,虚拟机要对对象进行必要的设置 ,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。

5.执行 init 方法

把对象按照程序员的意愿进行初始化

对象的内存布局

对象在内存中的布局可以分为 3 块区域:对象头、实例数据和对齐填充

对象头包括两部分信息:

  1. 标记字段 :用于存储对象自身的运行时数据, 如哈希码实例数据部分是对象真正存储的有效信息,也是在程序中所定义的各种类型的字段内容。、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等等。
  2. 类型指针:对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

实例数据部分是对象真正存储的有效信息,也是在程序中所定义的各种类型的字段内容。

对齐填充部分不是必然存在的,也没有什么特别的含义,仅仅起占位作用。

对象的访问定位

Java 程序通过栈上的 reference 数据来操作堆上的具体对象。对象的访问方式由虚拟机实现而定,目前主流的访问方式有:使用句柄直接指针

句柄

使用句柄的话,那么 Java 堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与对象类型数据各自的具体地址信息。

直接指针

如果使用直接指针访问,reference 中存储的直接就是对象的地址。

对比
  • 使用句柄来访问的最大好处是 reference 中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而 reference 本身不需要修改。
  • 使用直接指针访问方式最大的好处就是速度快,它节省了一次指针定位的时间开销。
相关推荐
苦瓜小生21 小时前
【黑马点评学习笔记 | 实战篇 】| 6-Redis消息队列
redis·笔记·后端
Aawy12021 小时前
Python生成器(Generator)与Yield关键字:惰性求值之美
jvm·数据库·python
大傻^21 小时前
LangChain4j Spring Boot Starter:自动配置与声明式 Bean 管理
java·人工智能·spring boot·spring·langchain4j
沐硕21 小时前
《基于改进协同过滤与多目标优化的健康饮食推荐系统设计与实现》
java·python·算法·fastapi·多目标优化·饮食推荐·改进协同过滤
yhole21 小时前
springboot 修复 Spring Framework 特定条件下目录遍历漏洞(CVE-2024-38819)
spring boot·后端·spring
BingoGo1 天前
Laravel 13 正式发布 使用 Laravel AI 无缝平滑升级
后端·php
愣头不青1 天前
560.和为k的子数组
java·数据结构
共享家95271 天前
Java入门(String类)
java·开发语言
l软件定制开发工作室1 天前
Spring开发系列教程(34)——打包Spring Boot应用
java·spring boot·后端·spring·springboot
0xDevNull1 天前
Spring Boot 循环依赖解决方案完全指南
java·开发语言·spring