Files
rikako-note/jvm/深入理解java虚拟机.md
2025-11-27 15:09:51 +08:00

171 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

- [深入理解java虚拟机](#深入理解java虚拟机)
- [Java内存区域和内存溢出异常](#java内存区域和内存溢出异常)
- [运行时数据区](#运行时数据区)
- [程序计数器](#程序计数器)
- [Java虚拟机栈Java Virutal Machine Stack](#java虚拟机栈java-virutal-machine-stack)
- [局部变量](#局部变量)
- [本地方法栈](#本地方法栈)
- [Java堆](#java堆)
- [方法区](#方法区)
- [运行时常量池](#运行时常量池)
- [直接内存](#直接内存)
- [Hotspot虚拟机对象探秘](#hotspot虚拟机对象探秘)
- [对象创建](#对象创建)
- [类加载](#类加载)
- [内存分配方式](#内存分配方式)
- [TLAB](#tlab)
- [初始化](#初始化)
- [对象布局](#对象布局)
- [对象头](#对象头)
- [实例数据](#实例数据)
- [对齐填充](#对齐填充)
- [对象的访问定位](#对象的访问定位)
# 深入理解java虚拟机
## Java内存区域和内存溢出异常
### 运行时数据区
java程序在虚拟机运行时会将其所管理的内存区域划分为若干个不同的数据区。java运行时数据区的结构如下
<img src="https://i-blog.csdnimg.cn/blog_migrate/858b0aecc9d90cdb0dea25ea077c11c4.jpeg#pic_center" alt="在这里插入图片描述">
#### 程序计数器
程序计数器是一块较小的内存空间,其可以被看做是当前线程所执行字节码的行号指示器。`在虚拟机的概念模型中,字节码解释器在工作时会修改程序计数器的值,从而选取下一条需要执行的字节码指令`。分支、循环、跳转、异常处理、线程恢复等基础功能都依赖计数器来完成。
在java虚拟机中多线程是通过`线程轮流切换,并分配处理器的执行时间片`来实现的。故而,在任一给定时刻,一个处理器只会执行一个线程中的指令(即给定时刻一个处理器只和一个线程进行绑定)。为了线程切换后,仍然能恢复到线程上次执行的位置,`每个线程都会有一个独立的程序计数器`。各个线程之间的程序计数器互不干扰,独立存储,该类内存为`线程私有`的内存。
> 由于JVM支持通过`本地方法`来调用c++/c等其他语言的native方法故而
> - 如果线程正在执行的是java方法计数器记录的是正在执行的虚拟字节码指令地址
> - 如果线程正在执行的是native方法计数器的值为空
#### Java虚拟机栈Java Virutal Machine Stack
和程序计数器一样java虚拟机栈也是线程私有的其声明周期和线程相同。
虚拟机栈描述了java方法执行时的内存模型
- 每个方法在执行时都会创建一个栈帧,用于存储局部变量、操作数栈、动态链接、方法出口等信息
- 每个方法从调用直至执行完成的过程,对应一个栈帧在虚拟机栈中入栈、出栈的过程
##### 局部变量
局部变量中存放了编译器可知的各种数据类型,如下:
- 基本数据类型primitives
- 对象引用
- returnAddress类型指向一条字节码指令的地址
> 其中64位长度的long和double数据类型会占用2个局部变量空间其余数据类型只占用一个。
>
> 局部变量表所需的空间大小在编译时就已经确定,在运行时进入一个方法时,该方法需要的局部变量表大小就已经确定,并且`方法执行过程中不会改变局部变量表的大小`
在java虚拟机规范中针对该区域定义了两种异常状况
- `StackOverflowError`线程所请求的栈深度大于虚拟机所允许的栈深度将抛出StackOverflowError
- `OutOfMemoryError`:如果虚拟机栈可以动态拓展,且拓展时无法申请到足够的内存,将会抛出`OutOfMemoryError`
#### 本地方法栈
本地方法栈和虚拟机栈的作用类似,区别如下:
- 虚拟机栈为虚拟机执行Java方法服务
- 本地方法栈为虚拟机用到的Native方法服务
和虚拟机栈相同本地方法栈也会抛出OutOfMemoryError和StackOverflowError
#### Java堆
Java堆是java虚拟机所管理内存中最大的部分`java堆为所有线程所共享在虚拟机创建时启动`。java堆内存用于存放对象实例几乎所有的对象实例堆在该处分配内存。
Java堆是垃圾收集器管理的主要区域在很多时候也被称为`GC堆`。从垃圾收集的角度看现代收集器基本都采用分代收集的算法故而java堆内存还可以进一步细分为`新生代和老年代`
虽然Java堆内存为线程所共享的区域但从内存分配的角度看Java堆中可能划分出多个线程私有的分配缓冲区Thead Local Allocation Buffer,TLAB
但是不论Java堆内存如何划分其存放的内容都为对象实例。Java堆内存可以处于物理上不连续的内存空间中只需在逻辑上连续即可。通常java堆内存都是可拓展的通过-Xms和-Xmx控制如果在为实例分配内存时没有足够的内存空间进行分配并且堆也无法再进行拓展时将会抛出`OutOfMemoryError`
#### 方法区
方法区和java堆一样是各个线程共享的区域其用于存储已被虚拟机加载的类信息、常量、静态变量、JIT编译后的代码等数据。
jvm规范对方法区的限制比较宽松方法区的内存并不需要在物理上连续并可选择固定大小或可拓展。除此之外方法区还可以选择不实现垃圾回收。
通常,发生在方法区的垃圾回收比较罕见,对方法区的垃圾回收主要是针对常量池的回收和对类型的卸载。类型卸载触发的条件比较苛刻。
当方法区无法满足内存分配的需求时将抛出OutOfMemoryError。
##### 运行时常量池
运行时常量池是方法区的一部分。在class文件中除了有类的版本、字段、方法、接口等描述信息外还有一项信息是常量池用于存放在编译期生成的各种字面量和符号引用。
`该部分内存将会在类加载后,进入方法区的运行时常量池进行存放`
> 在java程序运行时也能将新的常量放入池中例如`String.intern()`方法。
#### 直接内存
直接内存不是虚拟机运行时数据区的一部分也不是java虚拟机规范中定义的内存区域但该部分内存也会被频繁使用并且可能导致OutOfMemoryError。
在JDK 1.4中引入了NIO其可以使用native函数库直接分配堆外内存然后通过一个存储在Java堆内的DirectByteBuffer对象操作堆外内存。这样能够在部分场景中显著提高性能避免在java堆和native堆中来回复制数据。
并且直接内存并不会受java堆大小的限制但仍然会受物理机物理内存大小的限制。
### Hotspot虚拟机对象探秘
#### 对象创建
##### 类加载
虚拟机在遇到new指令时首先会去方法区常量池中进行匹配检查是否有对应的符号引用并检查该符号引用对应的类是否已经被加载、解析、初始化。如果尚未被加载则先执行该类的加载过程。
在加载该类后,对象所需的内存大小便可完全确定,会为该对象的创建分配内存空间。
##### 内存分配方式
在java堆内存中的内存分配存在如下几种方式
- 指针碰撞: 如果Java堆内存中内存是绝对规整的所有用过的指针都放在一边空闲内存放在另一边那么可以通过一个中间的指针来划分使用的空间和空闲空间
- 当分配内存空间时,仅需将指针向空闲空间的那个方向移动即可
- FreeList如果Java中的堆内存并不规整已使用的内存空间和空闲内存空间相互交错此时并无法使用指针碰撞的方式来分配内存。虚拟机会维护一个列表列表中维护空闲的内存块。
- 在内存分配时,会从列表中找到一块足够大的空间划分给对象,并更新列表上的空闲内存记录
选择采用哪种内存分配方式取决于java堆内存是否规整而java堆是否规整则取决于采用的垃圾收集器是否带有`压缩整理`功能。
> 故而在使用Serial、ParNew等带Compact过程的收集器时系统采用的分配算法是指针碰撞而使用CMS和Mark-Sweep算法的收集器时通常采用FreeList
##### TLAB
对象创建是非常频繁的行为,多线程场景下容易出现并发安全的问题。即使使用指针碰撞的方式来分配内存空间,也会出现争用问题。解决内存分配下的并发问题存在两种方案:
- 对分配内存空间的操作进行同步可以采用CAS等方案保证分配内存动作的原子性
- 将内存分配的动作按照线程的不同划分在不同区域中即每个线程先在java堆内存中预先分配一小块内存称为TLAB每个线程都在自身的TLAB中分配内存空间
- 只有当线程的TLAB使用完并需要分配新的TLAB时才需要同步锁定操作这样能减少同步锁定这一耗时操作的次数
虚拟机是否采用TLAB可以通过`-XX:+/-UseTLAB`参数来设定。
内存分配完成后虚拟机需要将分配到的内存空间都初始化为0若使用TLAB则该操作可提前到分配TLAB时。该操作可以保证对象的实例字段在不赋值初始值时就能使用程序能够访问这些字段数据类型所对应的零值。
##### 初始化
在为对象分配完空间后,会执行`<init>`方法进行初始化。
#### 对象布局
在Hotspot虚拟机中对象在内存中的布局可以分为3块区域
- 对象头Header
- 实例数据Instance Data
- 对齐填充Padding
##### 对象头
对象头中包含两部分信息:
- 对象的运行时数据如hash code、gc分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等
- 该部分在32bit和64bit的虚拟机中长度分别为32bit和64bit被官方称为`MarkWord`
- 类型指针:该部分信息指向对象对应`类元数据`的指针,虚拟机会通过该指针来确定对象是哪个类的实例。
- 数组长度如果对象是一个java数组那么对象头中还需要记录数组的长度
##### 实例数据
实例数据为对象真正存储的有效信息,即程序代码中定义的各种类型字段内容,无论是从父类继承的还是子类中定义的。
实例数据中的存储顺序会收到虚拟机分配策略参数和字段在java源码中定义顺序的影响。Hotspot虚拟机默认的分配策略为longs/doubles, ints, shorts/chars, bytes/booleans、oopsOrdinary Object Pointers相同宽度的字段会被分配到一起。
##### 对齐填充
对齐填充并不是自然存在的仅起占位符的作用。hotsopt虚拟机自动内存管理系统要求对象起始地址必须是8字节的整数倍即对象大小必须为8字节的整数倍。
对象头刚好是8字节整数倍故而当对象的实例数据没有对对齐需要通过对象填充来对齐。
#### 对象的访问定位
java程序需要通过虚拟机栈上的reference来操作java堆中具体的对象。目前访问堆上对象有两种主流的方式
- 句柄访问java堆中会划分一块区域来作为句柄池references存储的地址即为对象句柄的地址句柄中则包含了对象实例数据地址和对象类型数据地址
- 使用句柄访问时reference中存储的是句柄地址即使对象被移动在垃圾回收时非常普遍也只需要修改句柄中的实例数据指针不需要修改虚拟机栈中的reference
<img width="825" height="477" src="https://img2024.cnblogs.com/blog/2123988/202406/2123988-20240609154659695-36774084.png" style="display: block; margin-left: auto; margin-right: auto">
- 直接指针访问采用直接指针访问时堆内存中无需维护句柄池而java的堆对象中则包含类型数据的地址。reference中直接会存储java堆对象的地址而可以通过堆对象中存储的类型数据地址再次访问类型数据
- 使用直接指针访问时,访问速度更改,在访问实例数据时能够节省一次内存查找的开销。
<a data-fancybox="gallery" href="https://img2024.cnblogs.com/blog/2123988/202406/2123988-20240609154731546-1183511045.png"><img width="808" height="500" src="https://img2024.cnblogs.com/blog/2123988/202406/2123988-20240609154731546-1183511045.png" style="display: block; margin-left: auto; margin-right: auto"></a>
在Hotspot实现中采用的是直接指针访问方案。