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

🎷本文章是个人对《深入理解Java虚拟机》这本书第二章内容的知识的整理和总结,因为只是个人对该书内容的总结,部分地方不会特别详细,想详细了解本文章所讲述的知识可以移步该书P42-66。

1. 概述

对于C/C++而言,它们拥有每一个对象的“所有权”,也有维护每一个对象生命从开始到终结的责任。而对于Java,在虚拟机自动内存管理机制下,不在需要为每一个new操作去写配对的delete/free代码,不容易出现内存泄漏和溢出方面的问题,但是如果不了解虚拟机是怎么使用内存的,那么排查错误、修正问题将会成为一项异常艰难的问题。

2. 运行时数据区域

Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。本小节主要内容就是介绍这几个区域。

2.1 程序计数器

  1. 程序计数器(Program Counter Register)是一块较小的内存空间,它可以看做是当前线程所执行的字节码的行号指示器
  2. 字节码解释器通过改变程序计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要该计数器来完成。
  3. 每条线程都拥有一个独立的程序计数器,各条线程之间的计数器互不影响,独立存储。

2.2 Java虚拟机栈

  1. Java 虚拟机栈(Java Virtual Machine Stack)也为线程私有。
  2. Java 虚拟机栈描述的是 Java 方法执行的线程内存模型:每个方法被执行的时候,Java 虚拟机都会同步创建一个栈帧,用于存储局部变量表、操作数栈、动态连接、方法出口等信息。方法从调用到结束就对应着一个栈帧从入栈到出栈的过程。
  3. 局部变量表存放了编译期可知的各种Java虚拟机基本数据类型、对象引用(reference类型——用于对象的定位//todo 文章后面会提到)和returnAddress类型(指向一条字节码指令的地址)

在《Java 虚拟机规范》中,对该内存区域规定了两类异常:

  • 如果线程请求的栈深度大于虚拟机所允许的栈深度,将抛出 StackOverflowError 异常;
  • 如果 Java 虚拟机栈的容量允许动态扩展,当栈扩展时如果无法申请到足够的内存会抛出 OutOfMemoryError 异常。

2.3 本地方法栈

本地方法栈(Native Method Stacks)与虚拟机栈类似,其区别在于:Java 虚拟机栈是为虚拟机执行 Java 方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地(Native)方法服务。

Native:Java调用非Java(通常是C/C++)代码的接口,操作系统的底层可能需要Native去调用

2.4 Java堆

Java 堆(Java Heap)是虚拟机所管理的最大一块的内存空间,它被所有线程所共享,在虚拟机启动时创建,唯一目的是用于存放对象实例。

Java 堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为是连续的。Java 堆可以被实现成固定大小的,也可以是可扩展的,当前大多数主流的虚拟机都是按照可扩展来实现的,即可以通过最大值参数 -Xmx 和最小值参数 -Xms 进行设定。如果 Java 堆中没有足够的内存来完成实例分配,并且堆也无法再扩展时,Java 虚拟机将会抛出 OutOfMemoryError 异常。

2.5 方法区

方法区(Method Area)也是各个线程共享的内存区域,它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。方法区也被称为 “非堆”,目的是与 Java 堆进行区分。

《Java 虚拟机规范》规定,如果方法区无法满足新的内存分配需求时,将会抛出 OutOfMemoryError 异常。

2.6 运行时常量池

运行时常量池(Runtime Constant Pool)是方法区的一部分,用于存放常量池表(Constant Pool Table),常量池表中存放了编译期生成的各种符号字面量和符号引用。

2.7 直接内存

  1. 直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域。这部分区域也会导致OutOfMemory异常出现。
  2. 在JDK1.4中新加入的NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆里面的DirectByteBuffer对象作为这块内存的引用进行操作。

3. HotSpot虚拟机对象探秘

3.1 对象的创建

3.1. 前言

在语言层面上,创建对象通常(例外:复制、反序列化)仅仅是一个new关键字,而在虚拟机中对象(普通Java对象)的创建会经历一个复杂的过程。

3.2. 前提

当Java虚拟机遇到一个字节码new时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程(本书第7章将探讨这部分内容)

3.3. 分配内存

在类加载检查通过后,虚拟机需要新生对象分配内存空间(对象所需要的内存大小在类加载完成后便可以完全确定)。 根据 Java 堆是否规整,可以有以下两种分配方案:

  • 指针碰撞(Bump The Pointer) :假设 Java 堆中内存是绝对规整的,所有使用的内存放在一边,所有未被使用的内存放在另外一边,中间以指针作为分界点指示器。此时内存分配只是将指针向空闲方向偏移出对象大小的空间即可,这种方式被称为指针碰撞。

  • 空闲列表(Free List) :如果 Java 堆不是规整的,此时虚拟机需要维护一个列表,记录哪些内存块是可用的,哪些是不可用的。在进行内存分配时,只需要从该列表中选取出一块足够的内存空间划分给对象实例即可。

Java堆是否规整由所采用的垃圾收集器是否带有空间压缩整理(Compact)的能力决定。因此当使用Serial、ParNew等带压缩整理过程的收集器,系统采用指针碰撞;当使用CMS这种基于清除(Sweep)算法的收集器时,理论上只能使用比较复杂的空闲列表来分配内存。

3.4. 针对分配内存时的线程安全问题及解决方案

对象创建在虚拟机中是非常频繁的行为,即使仅仅修改一个指针指向的位置,在并发情况下也并不是线程安全的,可能出现正在给A分配内存,指针还没有来得及修改,对象B又同时使用了原来的指针来分配内存的情况。解决这个问题有两种可选方案:

  • 方式一:采用同步锁定,或采用 CAS 配上失败重试的方式来保证更新操作的原子性。
  • 方式二:为每个线程在 Java 堆中预先分配一块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)。线程在进行内存分配时优先使用本地缓冲,当本地缓冲使用完成后,再向 Java 堆申请分配,此时 Java 堆采用同步锁定的方式来保证分配行为的线程安全。

3.5. 内存空间初始化

内存分配完成后,虚拟机必须将分配到的内存空间(不包括对象头)都初始化为零值,如果使用了TLAB的话,这一项工作也可以提前至TLAB分配时顺便进行。

这一步的操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,使程序能访问到这些字段的数据类型所对应的零值。

3.6. 处理对象头

将对象有关的元数据信息、对象的哈希码、分代年龄等信息存储到对象头中。

具体信息后续会重点介绍

3.7. 构造函数

上面步骤完成后。从虚拟机的角度看一个新的对象已经产生,但是从Java程序的视角看,对象创建刚刚开始——构造函数,即 Class 文件中的 () 方法还没执行,所有字段都是默认的零值,对象需要的其他资源和状态信息也还没有按照预定的意图构造好。

new指令之后会接着执行() 方法,按照程序员的意愿对对象进行初始化。

3.2 对象的内存布局

在HotSpot虚拟机中,对象在堆内存中的存储布局可以分为三个部分:对象头(Header)、实例数据(Instence Data)和 对齐填充(Padding)。

3.1. 对象头 (Header)

对象头包括两部分信息:

  • Mark Word:对象自身的运行时数据,如哈希码、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等,官方统称为 Mark Word 。
存储内容 标志位 状态
对象哈希码、对象分代年龄 01 未锁定
指向锁记录的指针 00 轻量级锁定
指向重量级锁的指针 10 膨胀(重量级锁定)
空,不需要记录信息 11 GC标志
偏向线程ID、偏向时间戳、对象分代年龄 01 可偏向
  • 类型指针:对象指向它类型元数据的指针,Java 虚拟机通过这个指针来确定该对象是哪个类的示例。需要说明的是并非所有的虚拟机都必须要在对象数据上保留类型指针,这取决于对象的访问定位方式(详见下文)。

3.2. 实例数据 (Instance Data)

即我们在程序代码中定义的各种类型的字段的内容,无论是从父类继承而来,还是子类中定义的都需要记录。

3.3. 对其填充 (Padding)

主要起占位符的作用。HotSpot 虚拟机要求对象起始地址必须是 8 字节的整倍数,即间接要求了任何对象的大小都必须是 8 字节的整倍数。对象头部分在设计上就是 8 字节的整倍数,如果对象的实例数据不是 8 字节的整倍数,则由对齐填充进行补全。

对象=对象头(Header)+示例数据(Instance Data)+填充(Padding)

3.3 对象的访问定位

Java程序会通过栈上的reference数据来操作堆上的具体对象。

由于reference类型在《Java虚拟机规范》中只规定了它是一个指向对象的引用,并没有定义这个引用应该用什么方式去定位、访问到堆中对象的具体位置,所以对象访问方式也是由虚拟机实现而定。主流的方式方式有以下两种:

  • 句柄访问:Java 堆将划分出一块内存来作为句柄池, reference 中存储的是对象的句柄地址,而句柄则包含了对象实例数据和类型数据的地址信息。
  • 指针访问:reference 中存储的直接就是对象地址,而对象的类型数据则由上文介绍的对象头中的类型指针来指定。

通过句柄访问对象:

通过直接指针访问对象:

句柄访问的优点在于对象移动时(垃圾收集时移动对象是非常普遍的行为)只需要改变句柄中实例数据的指针,而 reference 本身并不需要修改;

指针访问则反之,由于其 reference 中存储的直接就是对象地址,所以当对象移动时, reference 需要被修改。但针对只需要访问对象本身的场景,指针访问则可以减少一次定位开销。

由于对象访问是一项非常频繁的操作,所以这类减少的效果会非常显著,基于这个原因,HotSpot 主要使用的是指针访问的方式。

4. 关于OutOfMemoryError异常

4.1 Java堆溢出

Java堆用于存储对象实例,只要不断地创建对象,并保证GC Roots 到对象之间有可达路径来避免垃圾回收机制清除这些对象,那么随着对象数量的增加,总容量触及最大堆的容量限制后就会产生内存溢出异常。

Java堆内存的OutOfMemoryError异常是实际应用中最常见的内存溢出异常情况。出现Java堆内存溢出时,异常堆栈信息“java.lang.OutOfMemoryError”会跟随进一步提示“java heap space”。

解决内存区域的异常,常规的处理方法是首先通过内存映像分析工具对Dump出来的堆转储快照进行分析。第一步先确认内存中导致OOM的对象是否是必须的,也就是要先分清除到底是出现了内存泄漏(Memory Leak)还是内存溢出(Memory Overflow)

如果是内存泄漏,可进一步通过工具查看泄漏对象到GC Roots的引用链,找到泄漏对象是通过怎样的引用路径、与哪些GCRoots相关联才导致垃圾收集器无法回收它们,根据泄漏对象的类型信息以及它到GC Roots引用链的信息,一般可以比较准确地定位到这些对象创建的位置,进而找到产生内存泄漏的代码的具体位置。

如果不是内存泄漏,也就是内存中的对象确实都是必须存活的,那就应当检查Java虚拟机的堆参数(-Xmx与-Xms)设置,与机器的内存对比,看看是否还有可以调整的空间。再从代码代码上检查是否存在某些对象的生命周期过长、持有状态时间过长、存储结构设计不合理等情况,尽量减少程序运行期的内存消耗。

4.2 虚拟机栈和本地方法栈溢出

由于HotSpot虚拟机中并不区分虚拟机栈和本地方法栈,因此对于HotSpot来说,-Xoss参数(设置本地方法栈大小)虽然存在,但实际上是没有任何效果的,栈的容量只能由-Xss参数来设定。关于虚拟机栈和本地方法栈,再《Java虚拟机规范》中描述了两种异常:

1)如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverflowError异常。

2)如果虚拟机的栈内存允许动态扩展,当扩展栈容量无法申请到足够的内存时,将抛出OurOfMemoryError异常。

《Java虚拟机规范》明确允许Java虚拟机实现自行选择是否支持栈的动态扩展,而HotSpot虚拟机的选择是不支持扩展,所以除非在创建线程申请内存的时候就因无法获得足够内存而出现OutOfMemoryError异常,否则在线程运行时是不会因为扩展而导致内存溢出的。只会因为栈容量无法容纳新的栈帧而导致OutOfMemoryError异常。

出现StackOverflowError异常时,会有明确错误堆栈可供分析,相对而言比较容易定位问题。

4.3 方法区和运行时常量池

在JDK 8 以后,永久代完全退出历史舞台,元空间作为其替代者登场。在默认设置下,正常的动态创建新类型已经很难使虚拟机产生方法区的溢出异常。

4.4 本机直接内存溢出

直接内存(Direct Memory)的容量大小可以通过-XX:MaxDirectMemorySize参数来指定,如果不去指定,则默认与Java堆最大值(由-Xmx指定)一致。

由直接内存导致的内存溢出,一个明显的的特征是在Heap Dump文件中不会看见有什么明显的异常情况,如果发现内存溢出后产生的Dump文件很小,而程序中又直接或间接使用了DirectMemory(典型的间接使用就是NIO),那就可以考虑重点检查一下直接内存方面的原因。


这是一个从 https://juejin.cn/post/7368669650576982025 下的原始话题分离的讨论话题