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

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

Scroll Down

前言

本篇内容是《深入理解 Java 虚拟机-第三版》第 2 章的读书笔记,重新温故 Java 内存区域相关的知识。

正文

运行时数据区域

程序计数器

当前线程所执行的字节码的行号指示器。各线程之间计数器互不影响、独立存储,线程私有

如果执行的是 Java 方法,计数器记录的是正在执行的虚拟机字节码指令的地址;如果执行的是本地(Native)方法,计数器值为空(Undefined)。

此内存区域是唯一一个在《Java虚拟机归还》中没有规定任何 OutOfMemoryError情况的区域。

Java 虚拟机栈

每个方法被执行的时候,Java 虚拟机都会同步创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态连接、方法出口等信息。线程私有,生命周期与线程相同。

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

本地方法栈

与虚拟机栈的作用非常相似,区别是本地方法栈为虚拟机使用到的本地(Native)方法服务。也会在栈深度溢出或者栈扩展失败时分别抛出StackOverflowErrorOutOfMemoryError异常。

Java 堆

存放对象实例,是虚拟机所管理的内存中最大的一块,线程共享

方法区

存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器后的代码缓存等数据。是各个线程共享的内存区域。

JDK 7 与 JDK 8 的区别是,JDK 8 使用本地内存中的元空间来代替 JDK 7中的永久代

这一块区域的内存回收目标主要是针对常量池的回收和对类型的卸载,一般来说这个区域的回收效果不是十分明显。

运行时常量池

运行时常量池是方法区的一部分。常量池用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。

运行时常量池还有一个重要特征是动态性,运行期间也可以将新的常量放入池中,用得比较多的是 String 类的 intern() 方法。

直接内存

直接内存并不是虚拟机运行时数据区的一部分,也不是《Java 虚拟机规范》中定义的内存区域,但是这部分内存也被频繁地使用,而且也可能会导致 OutofMemoryError异常出现。

NIO(New Input / Output)类,使用 Native 函数库直接分配堆外内存,然后通过 DirectByteBuffer 对象作为内存的引用进行操作,避免在 Java 堆和 Native 堆中的来回复制数据。

直接内存不会受到 Java 堆大小的限制,但是会受到本机总内存大小、处理器寻址空间的限制。

HotSpot 虚拟机对象探秘

对象的创建

  1. 类加载检查通过后,为对象分配空间,需要把一块确定大小的内存块从 Java 堆中划分出来。

    分配方式:

    • 指针碰撞

    ​ 被使用过的内存和空闲的内存使用一个指针作为分界点的指示器(规整)

    • 空闲列表

      维护一个列表,记录可用的内存块(不规整)

    Java 堆是否规整由采用的垃圾收集器是否带有空间压缩整理的能力决定。Serial、ParNew 等带压缩整理过程的收集器,采用的是指针碰撞,简单高效;而 CMS 这种基于清除算法的收集器,使用空闲列表。

  2. 对象创建是非常频繁的操作,在多线程分配内存的时候可能会存在并发线程安全的问题。

    解决这个问题有两种方案:

    • 采用 CAS 配上失败重试的方式保证更新操作的原子性
    • 每个线程在 Java 堆中预先分配一小块内存,本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)。可以通过 -XX:+/-UseTLAB参数设置是否使用 TLAB。
  3. 分配完内存后,虚拟机需要将分配到的内存空间都初始化为零值,如果是 TLAB,这项工作可以提前到 TLAB 分配时一起进行。初始化保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,能访问到这些字段的数据类型所对应的零值。

  4. 对对象进行必要的设置,例如对象是哪个类的实例、如何找到类的元数据信息、对象的哈希码、GC分代年龄等信息。这些信息都存放在对象的对象头之中。

  5. 调用构造函数,即 Class 文件中 <init>() 方法。Java 编译器会在遇到 new 关键字的地方同事生成字节码指令,new 指令之后会接着执行 <init>()方法,这样子一个真正可用的对象才算完整构造出来。

对象的内存布局

对象再堆内存中的存储布局可以分为三个部分:对象头(Header)、实例数据(Instance Data)、对齐填充(Padding)

对象头

第一类:用于存储对象自身的运行时数据,如哈希码、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,称为 Mark Word。考虑到虚拟机的空间效率, Mark Word被设计成一个有着动态定义的数据结构,以便在极小的空间内存存储尽量多的数据。

Mark Word 的结构如图所示:

第二类:类型指针,即对象指向它的类型原数据的指针,Java 虚拟机通过这个指针来确定该对象是哪个类的实例。但是并不是所有的虚拟机实现都必须在对象数据上保留类型指针。如果对象是一个 Java 数组,对象头必须有一块用于记录数组长度的数据。

实例数据

对象真正存储的有效信息,即程序代码里面所定义的各种类型的字段内容。

对齐填充

仅仅起占位符的作用,不是必然存在的。任何对象的大小都必须是 8 字节的整数倍,如果实例数据部分没有对齐的话,就需要通过对齐填充来补全。

对象的访问定位

Java 程序会通过栈上的 reference 数据来操作堆上的具体对象,对象的访问方式有两种:句柄、直接访问

  • 如果使用句柄访问,Java 堆中需要划分出一块内存作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含对象实例数据类型数据各自具体的地址信息。优点是 reference 中存储的是稳定句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而 reference 本身不需要修改。
  • 如果使用直接指针访问的话, reference 中存储的直接就是对象地址,如果只是访问对象本身的话,就不需要再多一次间接访问的开销。优点是访问速度更快,节省了一次指针定位的时间开销。HotSpot 虚拟机主要也是使用第种方式进行对象访问

实战:OutOfMemoryError 异常

除了程序计数器外,其他几个运行时区域都可能会发生 OutOfMemoryError(OOM)异常。

Java 堆溢出

  • 内存泄露(Memory Leak)
  • 内存溢出(Memory Overflow)

内存泄露需要通过工具查看对象的 GC Roots 的引用链信息,定位具体的代码位置。

内存溢出需要检查 -Xmx-Xms 的设置是否需要调整优化

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

HotSpot 虚拟机中不区分虚拟机栈和本地方法栈,所以栈的容量只能通过 -Xss 参数来设定。

《Java 虚拟机规范》描述了两种异常

  1. 如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出 StackOverflowError 异常
  2. 如果虚拟机的栈内存允许动态扩展,当扩展容量无法申请到足够的内存是,将抛出 OutOfMemoryError 异常

但是 HotSpot 虚拟机不支持扩展,所以一般都是抛出 StackOverflowError 异常。

方法区和运行时常量池溢出

运行时常量池是方法区的一部分。JDK 7 起,原本存放在永久代的字符串常量池被移至 Java 堆中。

HotSpot 提供了一些参数作为元空间的防御措施:

  • -XX:MaxMetaspaceSize : 设置元空间最大值,默认是 -1, 即不限制,或者说只受限于本地内存大小。
  • -XX:MetaspaceSize : 指定元空间的初始空间大小,以字节为单位,达到该值就会触发垃圾收集进行类型卸载,同时收集器会对该值进行调整:如果释放了大量的空间,就适当降低该值;如果释放了很少的空间,那么在不超过 -XX:MaxMetaspaceSize 的情况下,适当提高该值。
  • -XX:MinMetaspaceFreeRatio : 作用是在垃圾收集之后控制最小的元空间剩余容量的百分比,可减少因为元空间不足导致的垃圾收集的频率。

本机直接内存溢出

可以通过 -XX:MaxDirectMemorySize 参数指定直接内存的容量大小,默认值为 Java 堆最大值(-Xmx)一致。

普通的改变,将改变普通

我是肥壕,一个在互联网低调前行的小青年

欢迎关注我的博客📖 edisonz.cn,查看更多分享文章