Java 中的内存区域与内存溢出异常

本文深入探讨了Java虚拟机中对象的创建过程,包括类加载检查、内存分配、对象头的设置以及初始化。同时,阐述了对象在内存中的布局,分为对象头、实例数据和对齐填充,并讨论了对象访问定位的两种方式:句柄和直接指针。文章还介绍了HotSpot虚拟机的对象头结构及其在不同锁状态下的变化。
摘要由CSDN通过智能技术生成
1.1 运行时数据区域

运行时数据区

  • 程序计数器

    每个线程都拥有一个程序计数器(各线程之间相互独立,线程私有),用于记录当前正在执行的虚拟机字节码内存地址,目的是为了在线程切换之后能够恢复到原来执行的地址。

    程序计数器是唯一一个在 Java 虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域。

    Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的

  • Java 虚拟机栈

    Java 虚拟机栈也是线程私有的,与线程的声明周期相同,用于存储 Java 方法执行时的内存模型,即栈帧。每个方法在执行时都会创建一个栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每个方法从开始调用到执行完成的过程,对应着一个栈帧从虚拟机栈中入栈到出栈的过程。

    当线程请求的栈深度大于虚拟机锁允许的深度,会抛出 StackOverflowError,如果虚拟机栈可动态扩展且扩展时申请不到足够的内存,就会抛出 OutOfMemoryError 异常。

  • 本地方法栈

    本地方法栈与 Java 虚拟机栈几乎相同,有的虚拟机甚至将本地方法栈与 Java 虚拟机栈合二为一,两者的唯一区别是虚拟机栈为虚拟机执行 Java 方法服务,而本地方法栈为虚拟机执行 Native 方法服务。

  • Java 堆

    Java 堆是被所有线程共享的区域,在虚拟机启动时创建,用于存储对象实例,几乎所有的对象都在堆上分配内存。如果堆没有剩余的内存分配给实例且堆无法继续扩展时,会抛出 OutOfMemoryError 异常。

  • 方法区

    方法区也是被所有线程共享的一个区域,用于存储被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。当方法区无法满足内存分配需求时,将抛出 OutOfMemoryError 异常。

1.2 虚拟机中的对象(以 HotSpot 虚拟机为例)

HotSpot 虚拟机是 Sun JDK 和 Open JDK 中所带的虚拟机

  • 对象的创建过程

    在 Java 中,我们通过 new 关键字便可创建出一个对象,但在虚拟机中,创建一个对象需要经过下面的一系列过程。

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

    类加载检查通过后,虚拟机会为新建的对象进行内存的分配。对象所需的内存大小在类加载完成后即可确定,分配空间就是在 Java 堆中把一块确定大小的内存划分出来。如果 Java 堆中的内存是绝对规整的,即所有被使用的内存在一边,没有使用过的内存在另一边,中间有个指针作为分界点,那只需将指针向没有被使用的那边移动与对象相等大小的距离即可,这种分配方式称为指针碰撞。如果堆中的内存并不规整,使用过的内存和没有使用过的内存相互交错,虚拟机就只能维护一个列表,用于记录内存块的使用情况,分配时只需找出一块足够大且没有被使用过的内存即可,这种分配方式称为空闲列表

    在为对象分配内存时,我们还需要考虑并发问题,可能会出现正在给对象 A 分配内存,指针还没有来得及修改,对象 B 又同时使用了原来的指针来分配内存的情况。一种解决该问题的方案是对分配内存空间的动作进行同步处理,实际上虚拟机采用 CAS(CAS: Compare and Swap,即比较再交换,是一种无锁算法,CAS 有三个操作数,内存值 V,旧的预期值 A,要修改的新值 B,当且仅当预期值 A 和内存值 V 相同时,将内存值 V 修改为 B,否则什么都不做) 配上失败重试的方式保证更新操作的原子性。另一种方案是把内存分配的动作按照线程划分在不同的空间中进行,即每个线程在 Java 堆中预先分配一小块内存,称为本地线程分配缓冲 (Thread Local Allocation Buffer, TLAB) ,哪个线程要分配内存就在哪个线程的 TLAB 上分配,只有 TLAB 用完并分配新的 TLAB 时才需要同步锁定。

    内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),如果使用 TLAB,这一工作过程也可以提前至 TLAB 分配时进行。这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。

    接下来,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。这些信息存放在对象的对象头(Object Header)之中。根据虚拟机当前的运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。

    在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚刚开始——<init>方法还没有执行,所有的字段都还为零。所以,一般来说(由字节码中是否跟随 invokespecial 指令所决定),执行 new 指令之后会接着执行 <init> 方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。

  • 对象的内存布局

    在 HotSpot 虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。

    HotSpot 虚拟机的对象头包括两部分信息,第一部分用于存储对象自身的运行时数据,如哈希码(HashCode)、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等,这部分数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为 32bit 和 64bit,官方称它为“Mark Word”。对象需要存储的运行时数据很多,其实已经超出了32位、64位Bitmap结构所能记录的限度,但是对象头信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效率,Mark Word被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息,它会根据对象的状态复用自己的存储空间。例如,在32位的HotSpot虚拟机中,如果对象处于未被锁定的状态下,那么Mark Word的 32bit 空间中的 25bit 用于存储对象哈希码,4bit 用于存储对象分代年龄,2bit 用于存储锁标志位,1bit 固定为0,而在其他状态(轻量级锁定、重量级锁定、GC标记、可偏向)下对象的存储内容见下表。

    存储内容标志位状态
    对象哈希码、对象分代年龄01未锁定
    指向锁记录的指针00轻量级锁定
    指向重量级锁的指针10膨胀(重量级锁定)
    空,不需要记录信息11GC 标记
    偏向线程 ID、偏向时间戳、对象分代年龄01可偏向

    对象头的另外一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。并不是所有的虚拟机实现都必须在对象数据上保留类型指针,换句话说,查找对象的元数据信息并不一定要经过对象本身。另外,如果对象是一个 Java 数组,那在对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通Java对象的元数据信息确定Java对象的大小,但是从数组的元数据中却无法确定数组的大小。

    接下来的实例数据部分是对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容。无论是从父类继承下来的,还是在子类中定义的,都需要记录起来。这部分的存储顺序会受到虚拟机分配策略参数(FieldsAllocationStyle)和字段在 Java 源码中定义顺序的影响。HotSpot 虚拟机默认的分配策略为 longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers),从分配策略中可以看出,相同宽度的字段总是被分配到一起。在满足这个前提条件的情况下,在父类中定义的变量会出现在子类之前。如果 CompactFields 参数值为true(默认为true),那么子类之中较窄的变量也可能会插入到父类变量的空隙之中。

    第三部分对齐填充并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。由于 HotSpot VM 的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说,就是对象的大小必须是8字节的整数倍。而对象头部分正好是8字节的倍数(1倍或者2倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。

  • 对象的访问定位

    我们创建对象的最终目的是使用对象,Java 程序需要通过栈上的 reference 数据来操作堆上的具体对象。由于 reference 类型在 Java 虚拟机规范中只规定了一个指向对象的引用,并没有定义这个引用应该通过何种方式去定位、访问堆中的对象的具体位置,所以对象访问方式也是取决于虚拟机实现而定的。目前主流的对象访问方式有使用句柄直接指针两种。

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

    image-20220805152904466

    如果使用直接指针访问,那么Java堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而reference中存储的直接就是对象地址,如图所示。

    image-20220805153017304

    这两种对象访问方式各有优势,使用句柄来访问的最大好处就是 reference 中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为) 时只会改变句柄中的实例数据指针,而 reference 本身不需要修改。使用直接指针访问方式的最大好处就是速度更快,它节省了一次指针定位的时间开销,由于对象的访问在 Java 中非常频繁,因此这类开销积少成多后也是一项非常可观的执行成本。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

陈宇一

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值