摘要:本文主要学习了虚拟机如何管理对象。
环境
Windows 10 企业版 LTSC 21H2
Java 1.8
1 内存区域模型
虚拟机定义的内存区域模型:
图中绿色部分就是所有线程之间共享的内存区域,包括方法区和堆。而绿色部分则是线程运行时独享的数据区域,包括程序计数器、本地方法栈、虚拟机栈。
之所以要划分这么多区域出来是因为这些区域都有自己的用途,以及创建和销毁的时间。
有些区域随着虚拟机进程的启动而存在,有的区域则依赖用户线程的启动和结束而销毁和建立。
2 运行时数据区
2.1 程序计数器
2.1.1 作用
存储指向下一条指令的地址,以便由执行引擎读取下一条指令。
如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址。如果正在执行的是Natvie方法,这个计数器值则为空(Undefined)。
2.1.2 独享
为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间的计数器互不影响,独立存储。
2.1.3 异常
此内存区域是唯一不会出现OutOfMemoryError情况的区域。
2.2 本地方法栈
2.2.1 作用
本地方法栈与虚拟机栈所发挥的作用是非常相似的,其区别不过是虚拟机栈为虚拟机执行Java方法服务,而本地方法栈则是为虚拟机使用到的Native方法服务。
2.2.2 独享
与程序计数器一样,本地方法栈也是线程私有的。
2.2.3 异常
在虚拟机规范中,对这个区域规定了两种异常状况:
- StackOverflowError
- 递归太深。如果线程请求的栈深度大于虚拟机所允许的深度,即递归调用次数太多,将抛出StackOverflowError异常。
- 数据过大。局部数组过大,当函数内部定义的数组过大时,有可能导致内存溢出,抛出StackOverflowError异常。
- OutOfMemoryError
- 内存不足。如果虚拟机栈无法申请到足够的内存去完成拓展,或者在建立新线程的时候没有足够的内存去创建对应的虚拟机栈,那虚拟机将会抛出OutOfMemoryError异常。
2.2.4 本地方法
Java诞生的时候是C/C++横行的时候,要想立足,必须有调用C/C++程序,于是使用native关键字标识调用了C/C++程序的方法,把这种方法称为Native方法。
为了和用于处理Java方法的虚拟机栈区分开来,又在内存中专门开辟了一块区域用于处理Native方法,这块区域就被称为本地方法栈。
目前该方法使用的越来越少了,除非是与硬件有关的应用,比如通过Java程序驱动打印机。
Object类中的hashCode()方法就是本地方法:
1 | public native int hashCode(); |
2.3 虚拟机栈
2.3.1 作用
虚拟机栈描述的是Java方法执行的内存模型,每个方法被执行的时候都会同时创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法返回地址等信息。
每一个方法被调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程,这个过程遵循先进后出的规则。
在活动线程中,只有位于栈顶的栈帧才是有效的,称为当前栈帧,与这个栈帧相关联的方法称为当前方法。
结构图:
2.3.2 独享
与本地方法栈一样,虚拟机栈也是线程私有的,它的生命周期与线程相同,每创建一个线程时就会对应创建一个虚拟机栈,所以虚拟机栈也是线程私有的内存区域。
2.3.3 异常
与本地方法栈一样,虚拟机栈也会抛出StackOverflowError异常和OutOfMemoryError异常。
2.3.4 栈帧
一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。
2.3.4.1 局部变量表
局部变量表(Local Variable Table)是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量,包括各种基本类型、引用类型和返回地址类型。
局部变量表所需的内存空间在编译期间完成分配,在方法运行期间不会改变局部变量表的大小,具体大小可在编译后的class文件中看到。
局部变量表的容量以变量槽(Variable Slot)为最小单位,每个变量槽都可以存储32位长度的内存空间。
64位长度的long类型和double类型的数据会占用2个变量槽,其余的数据类型只占用1个变量槽。
2.3.4.2 操作数栈
操作数栈(Operand Stack)同样也可以在编译期确定大小。
栈帧被创建时,操作栈是空的。方法执行的过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈和入栈操作。例如在做算术运算的时候是通过操作数栈来进行的,又或者在调用其它方法的时候是通过操作数栈来进行参数传递的。
2.3.4.3 动态链接
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态链接(Dynamic Linking)。
比较:
- 静态链接:当字节码文件被装载进JVM内部时,在类加载阶段中的解析阶段就将符号引用转为直接引用,这种过程被称为静态链接。
- 动态链接:如果在程序运行期才能将符号引用转为直接引用,这种过程被称为动态链接。
2.3.4.4 方法返回地址
方法返回地址(Return Address)用于存放调用该方法的程序计数器的值,用于在退出方法后执行下一条命令。
方法有两种方式可以退出:
- 正常执行退出。
- 执行异常退出。
2.4 堆
2.4.1 作用
此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。
2.4.2 共享
堆是被所有线程共享的一块内存区域,在虚拟机启动时创建,存储的数据不是线程安全的。
2.4.3 异常
在虚拟机规范中,堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可,就像我们的磁盘空间一样。在实现时,既可以实现成固定大小的,也可以是可扩展的,不过当前主流的虚拟机都是按照可扩展来实现的。
如果在养老区中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError异常。
一般出现异常的原因是:
- 堆内存设置不够。
- 代码中创建了大量对象,并且长时间不能被垃圾回收器收集。
2.4.4 分代
堆是垃圾回收器管理的主要区域,因此很多时候也被称做GC堆(Garbage Collected Heap)。
为了优化垃圾回收的性能,虚拟机会将堆空间进行分代。
在JDK1.8以后,将堆空间分为年轻代和老年代。
2.4.4.1 年轻代
年轻代(Young Generation Space)存放新生成的对象,年轻代对象朝生夕死,存活率很低。
在年轻代中进行垃圾回收一般可以回收70%到95%的空间,回收效率很高。
年轻代又可细分为Eden空间、From Survivor空间、To Survivor空间,默认比例为8:1:1。
2.4.4.2 老年代
老年代(Old Generation Space)存放在年轻代多次回收长期存活的对象,也会将大对象直接存放到老年代中,老年代中的对象生命周期较长,存活率比较高。
在老年代中进行垃圾回收的频率较低,而且回收的速度也比较慢。
当老年代内存满了之后会触发垃圾回收,如果垃圾回收后内存空间仍不满足,则会触发OOM异常。
2.5 方法区
2.5.1 作用
在JDK1.8之前,方法区存储类元数据、运行时常量池、静态变量等数据,并在逻辑上将方法区划分为堆中的永久代,作为堆的逻辑部分。
在JDK1.8之后,方法区中的数据被拆分到了堆和元空间:
- 静态变量:存储在堆中。
- 字符串常量池:将字符串常量池从运行时常量池拆分,存储在堆中。
- 运行时常量池:存储在元空间中,但运行时常量池的符号引用解析后可能指向堆中的对象。
- 类元数据:存储在元空间中。
2.5.2 共享
方法区与堆一样,是各个线程共享的内存区域。
2.5.3 异常
在虚拟机规范中,当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。
一般出现异常的原因是:
- 内存设置不够。
- 加载大量第三方Jar包。
- 存在大量调用反射的代码。
2.5.4 永久代
在JDK1.8之前,永久代参数设置不合理会产生问题,参数过小容易产生OOM,参数过大会导致空间浪费,因此在JDK1.8中使用元空间取代了永久代。
此外,移除永久代是为融合HotSpot与JRockit而做出的努力,JRockit没有永久代,不需要配置永久代。
2.5.5 垃圾回收
这个区域垃圾回收的任务:
- 对废弃常量的回收。
- 对类型的卸载。
3 对象内存结构
对象的内存结构:
对象内存结构分为对象头、实例数据、对齐填充。
3.1 对象头
对象头分为对象标记和类型指针,如果是数组对象还有数组长度。
3.1.1 对象标记
对象标记存储的数据会根据对象的锁状态进行复用,在运行期间,对象标记中的数据会随着锁标志位的变化而变化。
对象标记结构图:
在64位的HotSpot虚拟机下,对象标记占用8个字节。
3.1.2 类型指针
对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
在开启压缩的情况下占用4个字节,否则占用8个字节,JDK1.8默认开启压缩。
3.1.3 数组长度
如果对象是一个数组,那在对象头中还必须有一块用于记录数组长度的数据。
因为虚拟机可以通过普通对象的元数据信息确定对象的大小,但是从数组的元数据中却无法确定数组的大小。
数组长度占用4个字节。
3.2 实例数据
用于存储对象的有效信息,包括程序代码中定义的各种类型的字段(包括继承自父类的和自身声明的),规则如下:
- 相同宽度的字段总被分配在一起。
- 父类中定义的变量会出现在子类之前。
- 如果虚拟机CompactFields参数为true(默认为true),子类的窄变量可能插入到父类变量的空隙。
3.3 对齐填充
虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐。
4 对象生命周期
创建对象的几种方式:
- 使用new关键字,这是最常见的方式。
- 使用Class对象的
newInstance()
方法,通过反射的方式,只能调用空参的构造器,必须是public权限。 - 使用Constructor对象的
newInstance()
方法,通过反射的方式,可以调用任何定义的构造器,没有权限要求。 - 使用
clone()
方法,不调用任何构造器,当前类需要实现Cloneable接口,重写clone()
方法。 - 使用反序列化,从文件和网络中获取对象的二进制流,然后反序列化为对象。
- 使用第三方库,例如通过Objenesis创建。
4.1 创建
4.1.1 加载
检查能否在常量池中定位到类的符号引用,并且检查这个符号引用代表的类是否已经被加载、链接和初始化。如果没有,那么必须先执行类的初始化过程。
4.1.2 分配内存
类加载检查通过后,虚拟机为新生对象分配内存。对象所需内存大小在类加载完成后便可以完全确定,在堆中为对象分配内存。
分配方式:
- 指针碰撞法:内存是规整的,用过的内存在一边,空闲的内存在另外一边,中间放着一个指针作为分界点的指示器,在空闲的内存将指针挪动一段与对象大小相等的距离。
- 空闲列表法:内存不是规整的,已使用的内存和未使用的内存相互交错,从列表中找到一块足够大的空间划分给对象实例,并更新列表上的内容。
如果是在多线程环境下的为对象分配内存,需要使用TLAB(Thread Local Allocation Buffer,线程本地分配缓冲区)保证创建对象时的线程安全:
- 在堆中为每个线程创建创建一小块TLAB私有区域,线程分配对象时优先考虑分配在TLAB区域。
- 一旦对象在TLAB空间分配内存失败时,JVM就会尝试着通过使用加锁机制确保数据操作的原子性,直接在Eden空间中分配内存。
4.1.3 设置初始值
内存分配结束,虚拟机将分配到的内存空间都初始化为零值(不包括对象头)。
这一步保证了对象的实例字段在代码中可以不用赋初始值就可以直接使用。
4.1.4 设置对象头
设置对象的对象头信息。
这个过程的具体设置方式取决于JVM实现。
4.2 初始化
初始化成员变量,执行实例化代码块,调用类的构造方法,并把堆内对象的首地址赋值给引用变量。
4.3 使用
不同虚拟机实现的对象访问方式有所不同,主流的访问方式是句柄访问和直接指针。
4.3.1 句柄访问
在堆中划分出一块内存作为句柄池,栈帧中的reference存储的是对象的句柄地址,句柄中包含:
- 存储在堆中的对象实例数据的地址。
- 存储在方法区中的对象类型数据的地址。
句柄访问:
4.3.2 直接指针访问
栈帧中的reference存储的是对象实例数据的地址,对象实例数据中的类型指针存储指向方法区的对象类型数据的地址。
直接指针访问:
4.4 不可达
当对象经过存活判断为不可达后,进入不可达阶段。
4.5 回收
不可达对象会被垃圾回收器回收,释放其占用的堆内存。
4.6 销毁
当对象的内存被完全释放,且无法再被任何方式访问,则对象彻底销毁,生命周期结束。
5 逃逸分析
5.1 定义
逃逸分析(Escape Analysis)是虚拟机可以分析新创建对象的使用范围,并决定是否在堆上分配内存的一项技术。
一个在方法中创建的对象,可能在方法结束后返回被外部方法所引用,也可能在方法中调用其他方法时作为参数传入,以上两种情况都称之为对象逃逸。
根据作用域可分为下面三种情况:
- GlobalEscape(全局逃逸):对象的引用逃出了方法或者线程。例如:
- 将对象的引用赋值给类变量或者静态变量。
- 对象跟随方法返回。
- ArgEscape(参数级逃逸):在方法中调用其他方法时,对象的引用作为参数传递至其他方法。
- NoEscape(没有逃逸):对象的作用域范围就只在本方法中,随着方法栈帧的进栈而生、出栈而亡。这种情况下,对象可以分配在栈中,但不是一定分配在栈中。
5.2 使用
逃逸分析的JVM参数:
1 | # 开启逃逸分析 |
逃逸分析技术在JDK1.7以后开始支持,并默认设置为启用状态,可以不用额外加这个参数。
5.3 优化
如果使用逃逸分析判断一个对象没有逃逸,编译器可以对代码进行优化。
5.3.1 同步消除
如果一个对象只能被一个线程访问到,那么将此对象作为锁,或者对于此对象的同步操作,在虚拟机优化后都可以忽略,这也就是多线程中的锁消除技术。
同步消除的JVM参数:
1 | # 开启锁消除 |
同步消除在JDK1.8中是默认开启的,并且要建立在逃逸分析的基础上。
5.3.2 标量替换
标量(Scalar)是指一个无法再分解成更小的数据的数据。Java中的原始数据类型就是标量。
相对的,那些还可以分解的数据叫做聚合量(Aggregate)。Java中的对象就是聚合量,因为他可以分解成其他聚合量和标量。
在编译阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话,那么经过JIT编译器优化,就会把这个对象拆解成若干个其中包含的若干个成员变量来代替,这个过程就是标量替换。
标量替换的JVM参数:
1 | # 开启标量替换 |
标量替换在JDK1.8中是默认开启的,并且要建立在逃逸分析的基础上。
5.3.3 栈上分配
如果一个对象没有发生逃逸,那么这个对象可能会被优化存储在栈中,但也并不是绝对存储在栈中,也有可能还是存储在堆中。
需要说明的是,在现有的虚拟机中,并没有真正的实现栈上分配,其实是通过标量替换实现的。
当对象没有发生逃逸时,该对象就可以通过标量替换分解为成员标量分配在栈内存中,和方法的生命周期一致,随着栈帧出栈时销毁,减少了GC压力,提高了应用程序性能。
6 分析案例
6.1 导包
在pom.xml文件中引入依赖:
1 | <!-- JOL依赖 --> |
也可以直接导入jol-core-0.9.jar
Jar包。
在读取对象信息时,虚拟机是按照8位一组,从高位向低位读取的。
6.2 压缩
在64位的HotSpot虚拟机下,类型指针需要占8个字节。
从JDK1.6开始,64位的虚拟机可对OOP(Ordinary Object Pointer,普通对象指针)进行压缩,使其只占用4个字节,以达到节约内存的目的。
在JDK1.8下,该选项默认启用。
可以显式配置JVM参数:
1 | # 开启指针压缩 |
6.3 查看
6.3.1 查看Object对象
示例:
1 | public static void main(String[] args){ |
结果:
1 | java.lang.Object object internals: |
参数:
- OFFSET:偏移量,表示从第几个字节开始。
- SIZE:占用的字节大小。
- TYPE:Class中定义的类型。
- DESCRIPTION:对类型的描述。
- VALUE:在内存中的值。
一个空的Object对象占用16字节,对象标记占用8个字节,类型指针在关闭压缩后占用8个字节。
6.3.2 查看Integer对象
示例:
1 | public static void main(String[] args){ |
结果:
1 | java.lang.Integer object internals: |
对比空的Object对象,总大小占用24个字节。其中,对象标记仍为8个字节并且内容相同,指针类型仍为8个字节但是内容有变化,增加了占用4个字节的实例数据,增加了占用4个字节的对齐填充。
因为int类型长度为32位,也就是4个字节,所以实例数据的大小也就是4个字节,为了保证总大小为8的倍数,额外增加了4个字节的对齐填充。
6.3.3 查看Long对象
示例:
1 | public static void main(String[] args){ |
结果:
1 | java.lang.Long object internals: |
对比Integer对象,总大小仍为24个字节。其中,对象标记和指针类型变化不大,但是实例数据占用的大小变为8个字节,并且没有对齐填充。
因为long类型长度为64位,也就是8个字节,所以实例数据就占用8个字节,并且不需要对齐填充。
6.3.4 查看数组对象
示例:
1 | public static void main(String[] args) { |
结果:
1 | [Ljava.lang.Integer; object internals: |
数组对象在对象头中会增加数组长度,占用4个字节并且值为3表示长度为3,另外在对象标记中还需要4个字节的对齐填充。
实例数据占用了24个字节。
6.3.5 查看分代信息
示例:
1 | public static void main(String[] args){ |
结果:
1 | java.lang.Object object internals: |
因为虚拟机在读取对象头时,是将每8位作为一组,从高往低读取的,所以在代表对象标记的8个字节中,首先打印的8位数字实际上是最后的8位数字。
对照对象头存储的信息,当没有被垃圾回收时,高8位表示如下:第1位是无效位,后4位表示分代年龄,后1位表示偏向锁,最后2位表示锁标志。
垃圾回收前,分代年龄是0,执行垃圾回收后,分代年龄变为1,4位的分代年龄表示最高年龄为15。
条