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

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

一. 引言

C、C++程序员在内存管理领域拥有最高权限的同时也担负着一切职责,而Java程序员在虚拟机内存管理机制的协助下不用为每一个对象的创建去写配对的删除和释放操作,减少了人为导致的内存泄漏和内存溢出问题。

思维导图:

2019050801_Java内存区域

2019050801_对象在虚拟机中的生命过程


二. 运行时数据区域

Java虚拟机在执行Java程序的过程中把其所管理的内存划分为若干个有不同用途和生命周期的数据区域。

2.1 程序计数器(Program Counter Register)

  • 程序计数器是一块比较小的内存空间,就是当前线程所执行字节码的行号指示器字节码解释器通过改变计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等功能都需要依赖程序计数器。
  • 线程私有:因为Java虚拟机的多线程其实同时只会有一个线程执行,为了支持多线程间上下文切换,在线程恢复时能恢复到执行位置,每个线程都会有独立的程序计数器,所以程序计数器是线程私有内存。
  • 线程执行Java方法时,计数器记录正在执行的虚拟机字节码指令的地址;线程执行Native方法时,则值为空。
  • 规范规定的异常:此内存区域是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError的区域。

2.2 虚拟机栈(Java Virtual Machine Stacks)

  • 虚拟机栈是线程私有内存。

  • 虚拟机栈是描述Java方法执行的内存模型:每个方法在执行时会创建一个栈帧,用来存储局部变量表、操作数栈、动态链接、方法出口等信息,每个方法从调用直至执行完成的过程,对应着一个栈帧在虚拟机栈中入栈到出栈的过程。

  • 把Java内存区域直接划分为堆内存和栈内存是不准确的,这两部分只是程序员最关注的两个区域,栈就是虚拟机栈,或是指虚拟机栈中的局部变量表。

  • 局部变量表:用来存放编译阶段可知的各种基本数据类型对象引用类型,和returnAdress类型

    1. 基本数据类型:boolean,byte,char,short,int,float,long,double。
    2. 对象引用类型:reference类型,不同于对象本身,可以是一个指向对象起始地址的引用指针,也可以是指向一个代表对象句柄或其他与此对象相关的位置。
    3. returnAdress类型:指向了一条字节码指令的地址。

    64位的long和double占两个局部变量空间,其他都占用一个。

    局部变量表所需的内存空间在编译阶段完成分配,所以当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在运行阶段局部变量表空间大小不会发生变化。

  • 规范规定的异常

    虚拟机规范规定此内存区域的两种异常:

    1. StackOverflowError:线程请求的栈深度超过虚拟机所允许的深度。
    2. OutOfMemoryError:虚拟机动态扩展时无法申请到足够的内存。

2.3 本地方法栈(Native Method Stack)

  • 本地方法栈类似于虚拟机栈,区别是虚拟机栈为虚拟机执行Java方法(字节码)服务,本地方法栈则为Native方法服务。
  • 虚拟机规范对本地方法栈的实现没有什么特殊的要求,所以二者甚至可以合并为一个方法栈。

2.4 Java堆(Java Heap)

  • Java堆是一块线程共享的内存区域,对于大部分应用来说它是最大的一块内存区域。
  • 堆就是用来存放所有的对象实例,所以所有的对象实例和数组都要在堆上分配(随着JIT编译器的发展和逃逸分析技术的成熟,栈上分配和标量替换技术可能就让这个说法不那么绝对了)。
  • 垃圾回收管理主要发生在Java堆,所以也叫GC堆。
  • 内存回收的收集器采用的是分代收集算法:可以把GC堆分为新生代老年代,更细分的话可以分为Eden空间From Survivor空间To Survivor空间(属于新生代)等。从内存分配的角度看,Java堆可以分出一些线程私有的分配缓冲区。无论怎样划分,堆存储的都是对象实例。所有划分的目的都是为了更好的回收内存和更快的分配内存。
  • 规范规定的异常:当堆中没有内存完成实例分配,堆也无法再扩展时,会抛出OutOfMemoryError。

2.5 方法区(Method Area)

  • 方法区是线程共享的内存区域,用来存储已被虚拟机加载的类信息、常量、静态常量、即时编译器编译后的代码等数据。
  • 方法区实际上可以看作堆的一个逻辑部分,但它的别名叫非堆(Non-Heap),应该是为了和堆区域区分一下。
  • 方法区有时被叫做永久代,原因就是HotSpot虚拟机的垃圾收集分代把方法区也纳入范围,GC收集器可以像管理Java堆一样管理方法区这块内存区域,而不用再为方法区再编写内存管理代码。但永久代的设计更容易遇到内存溢出的问题(永久代有 -XX:MaxPermSize 的上限,J9和JRockit只要未达到进程可用内存上限就不会有问题),并且有一些方法会在不同的虚拟机中得到不同的表现(如 String.intern() ),所以HotSpot也开始放弃永久代逐步改为Native Memory来实现方法区规划,在1.7之后的版本就把字符串常量池移到了堆中。
  • 虚拟机规范对方法区限制比较宽松,对了和堆一样不用连续的内存空间以外还可以不实现垃圾收集。所以垃圾收集很少出现在此区域,但并非其名字一样会永久存在。这个区域的内存回收目标是常量池的回收和对类型的卸载,但类型卸载比较苛刻,回收是很麻烦的。
  • 规范规定的异常:当方法区无法满足内存分配需求时,会抛出OutOfMemoryError。

2.6 运行时常量池(Runtime Constant Pool)

  • 运行时常量池是方法区的一部分,Class文件除了包含类的版本、字段、方法、接口等描述信息外,还有一项就是常量池(Constant Pool Table),用来存放编译期生成的各种字面量符号引用,常量池表的数据在类加载后会进入运行时常量池存放。除了符号引用外,一般也会把翻译出来的直接引用存储在运行时常量池中。
  • 运行时常量池相比Class文件常量池的区别一个是没有细节上的过多要求,不同虚拟机可以根据各自需求实现。另一个区别是运行时常量池具有动态性,Java语言并不要求常量一定要在编译阶段才能产生,也就是并非预置入Class文件中常量池的内容才能进入方法区的运行时常量池,运行阶段也可以有新的常量被放入池中,用的最多的便是 String.intern() 方法。
  • 规范规定的异常:当常量池无法再申请到内存时,会抛出OutOfMemoryError。

2.7 直接内存(Direct Memory)

  • 直接内存并不是虚拟机规范中定义的内存区域,但这部分内存会被频繁使用,如NIO类引入了一种基于Channel通道于Buffer缓冲区的新I/O方式,其使用Native函数库直接分配堆外内存区域,然后再通过存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作,这样就避免了一直在Java堆和Native堆中来回复制数据,从而提高了性能。
  • 规范规定的异常:因为不属于虚拟机定义的内存区域,所以直接内存只受计算机本身内存大小和处理器寻址空间的限制,开发人员在配置虚拟机参数时,会根据实际内存设置-Xmx等参数信息,但经常忽略直接内存,使得各个内存区域的总和大于物理内存上限,从而导致动态扩展时抛出OutOfMemoryError。

更多可以参考:IO和NIO


三. 虚拟机中对象生命过程

3.1 对象的创建

在Java语言中,表面上看就是通过new关键字创建一个对象,在虚拟机中又是经历了一个怎样的过程?

  1. 虚拟机在执行到new指令时,首先要检查此指令的参数能否在常量池定位到一个类的符号引用,同时检查这个符号引用所代表的类是否已经被加载、解析和初始化过。如果没有,则执行类的加载过程,详细可参考类的加载机制

  2. 在类加载检查通过后,虚拟机会为新生的对象分配内存,在类加载后就可以确认所需内存大小。

    假设Java堆中的内存是规整的,分配内存就是将指针在空闲区域挪动对象大小的距离,这类分配方式叫指针碰撞

    如果Java堆中内存并不规整,虚拟机就需要维护一个列表来记录可用的内存块,在分配时查询此表,找到足够大的空闲空间放置对象,并更新表内数据,这类分配方式叫空闲列表

    选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整由垃圾收集器是否带有压缩整理功能决定,所以一般在使用Serial、ParNew等带Compact过程的收集器时使用指针碰撞,而使用CMS这类基于Mark-Sweep算法的收集器时,采用空闲列表

    在划分空间时需要考虑线程安全问题:

    比如给对象A分配内存,但指针还未来得及修改,此时对象B同时使用了指针来分配内存。

    解决方案一般如下:

    1. 对分配内存空间的动作进行同步处理,虚拟机采用CAS+失败重试的方式来保证操作的原子性。
    2. 把内存分配的动作按照线程划分在不同的空间中进行,即每个线程都预先在Java堆中预先分配一小块内存区域,叫做本地线程分配缓冲(TLAB),线程在自己的TLAB上面分配内存,只有当TLAB用完要分配新的TLAB时再同步锁定,虚拟机是否使用TLAB可以根据参数 -XX:+/-UserTLAB 来设定。

    内存分配完成后,虚拟机会将被分配的内存区域初始化为零值(不包括对象头),若采用TLAB则可以提前到分配TLAB时进行,这一操作使对象在未被赋值的情况下可以被访问其对应数据类型的0值。

  3. 虚拟机对对象进行必要的设置,如对象所属类、对象哈希码、类的元数据信息、对象的GC分代年龄信息等。然后把这些信息存放在对象头(Object Header)中。

此过程结束,对VM来说对象已经创建完成,对于Java程序而言才刚刚开始,然后就是初始化 <init> ,此时所有字段都还为

  1. 执行对象的 <init> 方法,把对象按开发设计进行初始化。

2.2 对象的内存布局

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

2.2.1 对象头

对象头,包括两部分信息,第一部分存储对象自身的运行时数据,如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分被叫做Mark Word

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

第二部分是类型指针,即对象指向它的类元数据的指针,VM通过这个指针确定此对象是哪个类的实例。此外若对象是Java数组时,对象头需要记录数组长度,VM可以根据对象的元数据来确定Java对象大小,对于数组则无法确定。

2.2.2 实例数据

实例数据部分就是对象的具体信息,即类中定义的各类型信息,包含父类继承的,此部分的存储顺序会受虚拟机分配策略参数和字段在Java源码中定义顺序的影响。HotSpot的默认顺序:longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers)。

相同宽度的字段总是分配到一起,父类定义的变量会先于子类,若CompactFileds为true则子类可能出现在父类变量的空隙。

2.2.3 对齐填充

没有其它含义,只是起占位符的作用,因为HotSpot VM要求对象起始地址必须是8字节的整数倍,也就是对象大小必须是8字节的整数倍,对象头刚好是8或16,当实例数据部分不对齐时就需要此部分来填充。

2.3 对象的访问定位

如何访问对象?Java定义了reference类型只规定了一个指向对象的引用,如何定位和访问堆中对象的物理位置需要VM的实现。主流的访问方式是句柄直接指针

2.3.1 句柄**

Java堆会划分一块内存作为句柄池,reference类型存储对象的句柄地址,句柄则包含了对象的实例数据和类型数据的具体地址信息。

通过句柄访问对象

2.3.2 直接指针**

当使用直接指针访问,那么Java堆布局时就必须考虑到访问类型数据的信息,而reference类型存储的是对象的堆地址

通过直接指针访问对象

2.3.3 优势对比**

句柄的优势是reference存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而reference类型则无需修改。

直接指针的优势是速度更快,节省了一次指针定位的时间开销,因为对象的访问是很频繁的,所以此类开销是很可观的,HotSpot采用直接指针访问。


第三节 内存溢出**

Java的内存区域除了程序计数器外都有发生OutOfMemoryError异常的可能:

3.1 Java堆溢出**

当对象数量超过堆的容量限制产生OOM异常,java.lang.OutOfMemoryError: Java heap space

解决方案是通过内存分析工具分析此时内存存储快照中,对象是否是必要的,确认是内存泄漏还是内存溢出。

如果是内存泄漏,可以通过工具进一步查看泄漏对象到GC Roots的引用链,所以可以找到泄漏对象通过怎样的路径与GC Roots关联导致垃圾收集器无法回收此对象。

如果不存在内存泄漏,也就是对象都是必要的,就需要检查虚拟机的堆参数(-Xmx与-Xms),看一下是否可以调整一下,检查是否有些可以优化的部分,来减少一些对象的生命周期。降低运行时的内存消耗。

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

HotSpot不区分虚拟机栈和本地方法栈,栈容量只由-Xss参数设定。

Java虚拟机规范规定了两种异常:1.如果线程请求的栈深度大于虚拟机所允许的最大深度,抛出StackOverflowError异常。2.如果虚拟机在扩展栈时无法申请到足够的内存空间,抛出OutOfMemoryError异常。

若栈空间无法继续分配时,如何确定是内存太小还是栈空间太大?

通过一些实验证明,在单线程时,无论是栈帧太大还是虚拟机栈容量太小,当内存无法分配时VM都会抛出StackOverflowError异常。多线程时可能会因为创建线程分配资源的问题产生内存溢出异常,此时内存溢出异常和栈空间大小没有太大关系,而且若不能减少线程数时只能通过减少最大堆和栈容量来换取更多的线程。

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

运行时常量池是方法区的一部分,通过String.intern()方法来测试。

String.intern()方法作用:当字符串常量池中已包含某个String对象的字符串,就返回池中此字符串的String对象;否则要将此String对象包含的字符串添加到常量池中,并返回其引用。在1.6版本前常量池在永久代中,可以通过-XX:PermSize和-XX:MaxPermSize限制方法区大小。

更多可以参考:Java字符串内存分配-字符串常量池String的intern方法详解

循环调用String.intern()创建字符串对象,会抛出java.lang.OutOfMemoryError: PermGen space异常提示运行时常量池溢出,PermGen space表示了运行时常量池属于方法区/永久代的一部分。

通过CGLib字节码技术在运行时动态生成类,模拟方法区溢出场景,因为类要被垃圾回收是比较苛刻的,所以如果需要大量生成类时一定要注意类的回收情况。

3.4 本机内存溢出**

Direct Memory容量可以根据-XX: MaxDirectMemorySize指定,默认等于-Xmx数值。

通过反射获取Unsafe实例进行内存分配,模拟内存溢出场景,会抛出java.lang.OutOfMemoryError,如果内存溢出时堆转储文件(Heap dump)很小没有异常,且有用到NIO时可以考虑此情况。

更多可以参考:Java反射Unsafe类详解


参考:

🔗 《深入理解Java虚拟机》