更新记录
| 日期 | 内容 |
|---|---|
| 2023-04-14 | 新增直接内存,对象创建 |
1. JVM 内存区域
Java内存区域分为几个重要部分,一般包含:方法区(Method Area),虚拟机栈(VM Stack),本地方法栈(Native Method Stack),堆(Heap),程序计数器(Program Counter Register)。
图片来自-8张图 让你明白 Java内存区域 - 知乎 (zhihu.com)
1.1. 程序计数器
程序计数器可以看作记录当前线程所执行的字节码行号的指示器。字节码解释器就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
每个线程都有独立的一个程序计数器,是属于线程私有的内存。此内存区域是唯一一个在 JVM 规范中没有规定任何OutOfMemeoryError情况的区域。
1.2. Java 虚拟机栈
Java 虚拟机栈(小节内简称栈)是线程私有的,生命周期与线程是相同的。方法在执行时都会创建一个栈帧,存储了局部变量表、操作数栈、动态链接、方法出口等信息。每个方法调用到执行完成就是对应的一个栈帧在虚拟机栈中入栈和出栈的过程。
局部变量表所需的内存空间在编译期间会完成分配,方法运行时不会改变其大小。如果线程请求的栈深度大于虚拟机所允许的深度,则抛出StackOverflowError(如果是内存不足则是OOM,但是实际上在栈很多时候都只会固定抛出一种,大部分时候都是栈溢出)。
1.3. 本地方法栈
本地方法栈与虚拟机栈的作用是非常相似的,只是虚拟机栈执行 Java 方法(字节码)服务,而本地方法栈为虚拟机使用到的 Native 方法服务。
1.4. Java 堆
对大多数应用,堆是虚拟机所管理的内存中最大的一块,也是被所有线程共享的区域,几乎所有对象实例都是在这里分配内存。
从内存分配角度看,堆可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB)。
如果在堆中没有内存完成实例的分配,并且堆也无法再扩展时,将会抛出 OOM。
堆是垃圾收集器管理的主要区域,很多时候被称为”GC堆“,使用了分代收集算法,进一步可以细分为新生代和老年代。
1.5. 方法区
方法区与堆一样是各个线程共享的内在区域,用于来存储被虚拟机加载的类信息、常量、表态变量、即时编译器编译后的代码等数据。
方法区可以不实现垃圾收集,因为回收条件比较苛刻(如类的卸载)。
1.5.1. 运行时常量池
运行时常量池(Runtime Constant Pool)是方法区的一部分。类文件中除了一些类的描述信息外,还有类的常量池,用于存储编译期生成的各种字面量和符号引用,这些都是会入到方法区的运行时常量池。
注意常量不一定是编译期才会产生,运行期间也可以将新的常量放入池中,如String.intern()方法。
1.6. 直接内存
直接内存并不是虚拟机的运行时数据区的一部分,也不是 Java 虚拟机规范中定义的内在区域。本地直接内存的分配不会受到 Java 堆大小的限制,但是会受到本地总内存的大小及处理器寻址空间的限制。
在 JDK 中新添加的 NIO(new input/output)类引入了基于通道和缓冲区的 IO 方式,它就可以使用 Native 函数库直接分配堆外内存,然后在通过 Java 堆中DirectByteBuffer对象作为这块内存的引用进行操作,避免在虚拟机堆和本地堆中来回复制数据。
2. 虚拟对象
2.1. 对象创建
对象通过new实现对创建,创建过程如下:
1.虚拟机遇到 new 指令时,先检查这个指令的参数是否能在常量池中定位到类的符号引用,并且检查这个符号引用代表的类是否已经被加载、解析和初始化过。如果成功则执行下一小操作;如果没有,则先执行相应类的加载过程。
2.类检查通过后,为虚拟机分配内存,并在分配完成后初始化零值。对象所需的内存大小在类加载完成后便可完全确定,这个过程涉及到堆内存的划分和线程安全处理。
3.对象分配完内存后,需要对对象进行必须的设置,例如对象属于哪个类的实例,如何能找到类的元数据信息,对象的哈希码,对象的 GC 分代年龄等信息。这些信息存储在对象头中。
4.通过上述流程,在虚位机的角度其实对象已经产生创建完成了。但是在 Java 程度中,对象还需要初始化执行<init>实例构造方法,为字段初始化值或其它操作。
2.1.1. 内存分配方式
内存分配方式有两种:指针碰撞和空闲列表。选择哪种分配方式由 Java 堆是否规整决定,而 Java 堆是否规整由所采用的垃圾收集器是否带有压缩整理功能决定。
Java 规整指所有使用过的内在都放在一边,空闲的放在另一边,中间放着一个指针分界点的指示器。如果不规整的话,则虚拟机就必须维护一个列表,记录哪块内存块是可用的。
- 指针碰撞:分配内存时将指针向空间空间挪动一段与对象大小相等的距离
- 空闲列表:分配内存时从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录
2.1.2. 线程安全
除了划分空间外,对象在虚拟中创建是非常频繁的行为,在并发情况下即使是修改一个指针所指向的位置,也不是线程安全的。
可能出现正在给对象 A 分配内存,指针还没有来得及修改,对象 B 又使用了原来的指针进行分配内存,导致数据错乱的情况。解决这个问题就是确保对象分配时稳定安全的就行了。
- 使用同步处理:如采用 CAS(自旋锁)配上失败重试的方式保证操作的原子性
- 使用独立空间分配:为每个线程在堆中预分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer, TLAB),哪个线程需要分配内存,则在该线程的 TLAB 上分配。只有 TLAB 用完并分配新的 TLAB 时,则需要同步锁定。
如果是使用 TLAB,则初始化零值的操作也可以在 TLAB 分配时进行。
2.2. 对象的内存布局
在 HotSpot 虚拟机中对象在内存中存储的布局有3个部分:对象头、实例数据、对齐填充。
1.对象头包含两部分信息:
-
用于存储自身运行时的数据:如哈希码、分代年龄、锁状态标志等
-
类型指针:对象指向他的类元数据的指针,虚拟机通过这个指针来确定对象是哪个类的实例。但是并不是所有的虚拟机都必须有类型指针,查找对象元数据信息并不一定要经过对象本身。
对象头中还可能包含特殊信息:如果对象是一个数组,则对象头中必须有一块用于记录数组长度的数据,因为虚拟机无法通过元数据直接确定数组的大小。
2.实例数据:对象真正存储的有效信息,在代码中定义的各种类型字段内容等(包含从父类继承的字段)。
- 数据在存储顺序可能会受虚拟机的分配策略和源码中定义的顺序影响。默认相同宽度的字段会被分配到一起,基于此条件,父类中定义的变量会在子类之前。
- 对齐填充:不是必须存在的,仅仅作为占位符作用。在 HotSpot 中用于填充对象大小不是8字节整数倍的空间。
2.3. 对象访问定位
对象通常通过 reference 数据来操作堆上某个具体的对象。ref 只是指向对象的引用,对象的访问一般有两种方式:使用句柄和直接指针。
2.3.1. 使用句柄
如果使用句柄的话,那么堆中会划出一块内存用来作句柄池,ref 中存储的就是对象的句柄地址。句柄中包含了对象实例数据和类型数据各自的具体地址信息。
使用句柄优势是 ref 中存储的是稳定的句柄地址,对象被移动时只会改变句柄中的实例数据指针,而 ref 对象不需要修改。
2.3.2. 直接指针
如果使用直接指针访问,堆对象的布局必须存在对象类型数据的具体地址,ref 中存储的直接就是对象地址。
使用直接指针访问优势是速度快,节省了一次指针定位的时间开销。但是对象进行移动修改时,ref 中的数据就需要修改了。