深入理解JVM:Java内存区域与内存溢出异常
概述
Java与C++之间有一堵由内存动态分配和垃圾收集技术所围成的高墙,墙外面的人想进去,墙里面的人却想出来。
运行时数据区域
Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而一直存在,有些区域则是依赖用户线程的启动和结束而建立和销毁。
根据《Java虚拟机规范》的规定,Java虚拟机所管理的内存将会包括以下几个运行时数据区域:
程序计数器(Program Counter Register)
程序计数器(Program Counter Register)是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。
- 字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
- 线程私有:每条线程都有一个独立的程序计数器,条线程间计数器互不影响;
- 此内存区域是“唯一一个在《Java虚拟机规范》中没有规定任何OutOfMemoryError情况的区域”。
- 执行本地方法时,程序计数器为空:
- 如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;
- 如果正在执行的是本地(Native)方法,这个计数器值则应为空(Undefined)。
Java虚拟机栈(Java Virtual Machine Stack)
用于描述Java方法执行的线程内存模型:
- 方法的执行,对应于栈中栈帧(Stack Frame)的创建;
- 方法的调用直至执行完毕的过程,对应栈帧在虚拟机栈中从入栈到出栈的过程。
- 栈帧,用于“存储局部变量表”、“操作数栈”、“动态连接”、“方法出口”等信息:【方法相关信息】
- “存储局部变量表”:包括方法参数、方法内变量
- “操作数栈”:(先入后出)元素时任意Java类型,随着方法的执行而进行入栈出栈操作
- “动态连接”:class文件中,方法调用其他方法时,将其他方法的符号引用(存在于运行时常量池对应方法信息中)转化为其内存的直接引用,即动态链接。
- 【每个栈帧都包含一个指向运行时常量池中该栈所属方法的符号引用】
- “方法出口”:包括正常出口、异常出口
- 线程私有:生命周期与线程相同
- 在《Java虚拟机规范》中,对这个内存区域规定了两类异常状况:
- 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;(请求大于栈深)
- 如果Java虚拟机栈容量可以动态扩展,当栈扩展时无法申请到足够的内存会抛出OutOfMemoryError异常。(扩展超出内存)
局部变量表
局部变量表存放了编译期可知的各种Java虚拟机:
- 基本数据类型(boolean、byte、char、short、int、float、long、double)、
- 对象引用(reference类型,它并不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或者其他与此对象相关的位置)
- 和returnAddress类型(指向了一条字节码指令的地址)。
- 局部变量表所需的内存空间在编译期间完成分配:
这些数据类型在局部变量表中的存储空间以局部变量槽(Slot)来表示,其中64位长度的long和double类型的数据会占用两个变量槽,其余的数据类型只占用一个。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。 (请读者注意,这里说的“大小”是指变量槽的数量,虚拟机真正使用多大的内存空间(譬如按照1个变量槽占用32个比特、64个比特,或者更多)来实现一个变量槽,这是完全由具体的虚拟机实现自行决定的事情。)
本地方法栈(Native Method Stacks)
与“虚拟机栈”作用相似的,区别虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地(Native)方法服务,即“Native方法执行的线程内存模型”。
- 线程私有;
- 与虚拟机栈一样,本地方法栈也会在栈深度溢出或者栈扩展失败时分别抛出“StackOverflowError”和“OutOfMemoryError”异常。
- HotSpot 的栈包括了虚拟机栈和本地方法栈:
- 《Java虚拟机规范》对本地方法栈中方法使用的语言、使用方式与数据结构并没有任何强制规定,因此具体的虚拟机可以根据需要自由实现它,甚至有的Java虚拟机(譬如Hot-Spot虚拟机)直接就把本地方法栈和虚拟机栈合二为一。
Java堆(Java Heap)
内存区域的唯一目的就是存放对象实例以及数组,Java世界里“几乎”所有的对象实例都在这里分配内存。
- 所有线程共享,在虚拟机启动时创建;
- 如果在Java堆中没有内存完成实例分配,并且堆也无法再扩展时,Java虚拟机将会抛出“OutOfMemoryError”异常。
- “GC堆”:Java堆是垃圾收集器管理的内存区域;
- Java堆是“物理上不连续,逻辑上连续”的内存空间;
- 通过“-Xmx”和“-Xms”设定堆大小:
- Java堆既可以被实现成固定大小的,也可以是可扩展的,不过当前主流的Java虚拟机都是按照可扩展来实现。
方法区(Method Area)
用于存储已被虚拟机加载的“类型信息”、“常量”、“静态变量”、“即时编译器编译后的代码”缓存等数据。【类的相关信息(对比“虚拟机栈”的“栈帧”:存放方法的相关信息)】
- 虽然《Java虚拟机规范》中把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫作“非堆”(Non-Heap),目的是与Java堆区分开来。
- 线程共享;
- 如果方法区无法满足新的内存分配需求时,将抛出“OutOfMemoryError”异常。
- JDK 8 之前,HotSpot用永久代来实现方法区;JDK 8 之后,废弃了永久代,改用“元空间”(Metaspace)来代替。
运行时常量池(Runtime Constant Pool)
运行时常量池(Runtime Constant Pool)用于保存静态常量引用和符号引用:
- 【class文件中的“常量池表”,就加载到“运行时常量池”中】
- Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表(Constant Pool Table),用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。
- 当常量池无法再申请到内存时会抛出“OutOfMemoryError”异常。
- 运行时常量长的动态性:
- 运行时常量池相对于Class文件常量池的另外一个重要特征是具备动态性,Java语言并不要求常量一定只有编译期才能产生,也就是说,并非预置入Class文件中常量池的内容才能进入方法区运行时常量池,运行期间也可以将新的常量放入池中,这种特性被开发人员利用得比较多的便是String类的“intern()”方法。
- 【intern:若常量池中不存在等值的字符串,才创建新的字符串对象保存在常量池,并返回其引用】
- JDK 7的HotSpot,字符串常量池、静态变量不再是永久代(方法区)的一部分;
直接内存
直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域。
- 不受Java堆大小的限制;
- 只受到本机总内存(包括物理内存、SWAP分区或者分页文件)大小以及处理器寻址空间的限制;
- 动态扩展时可能出现“OutOfMemoryError”异常。
关于 NIO:
在JDK 1.4中新加入了“NIO”(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆里面的“DirectByteBuffer”对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。
HotSpot虚拟机对象探秘
对象的创建
对象的创建分为以下几个步骤:(仅讨论使用“new”创建普通对象;数组、Class对象等不在讨论中)
- 类的检查和加载:
- 检查指令的参数是否能在常量池中定位到一个类的符号引用;
- 检查这个符号引用代表的类是否已被加载、解析和初始化过(如果没有,则执行相应的类加载过程);
- 内存分配:
- (对象所需内存的大小在类加载完成后便可完全确定)
- 分配方式:(分配方式的选择,与JVM使用的GC方式相关)
- “指针碰撞”:(内存规整时)移动空闲空间的指针即可;
- “空闲列表”:(内存不规整)在JVM维护的空闲空间列表中,查找可用的、足够大的空间进行分配;
- 分配安全:
- “CAS”:(“乐观锁”)虚拟机采用CAS配上失败重试的方式保证更新操作的原子性;
- “TLAB”:(“本地线程分配缓冲”)把内存分配的动作按照线程划分在不同的空间之中进行;
- (内存空间初始化)
- 即,将内存空间都初始化为零值,保证对象的域不赋初始值就直接使用。(如果使用了TLAB的话,这一项工作也可以提前至TLAB分配时顺便进行)
- 对象设置:
- 在对象的对象头(Object Header)中设置,诸如“这个对象是哪个类的实例”、“如何才能找到类的元数据信息”、对象的哈希码、对象的GC分代年龄等信息。
- 对象初始化:
- 即:执行“init”方法,按照程序员的意愿对对象进行初始化。
对象的内存布局
在HotSpot虚拟机里,对象在堆内存中的存储布局可以划分为三个部分:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
- 对象头(Header):分为两个部分
- “Mark Word”:用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等;(在32位和64位JVM中分别为32和64bit)
- “类型指针”:即对象指向它的类型元数据的指针(Java虚拟机通过这个指针来确定该对象是哪个类的实例);
- 实例数据(Instance Data):对象真正存储的有效信息,即代码所定义的各种类型的字段内容(包括从父类中定义的)。
- 对齐填充(Padding):不是必然存在的,仅起着占位作用。
HotSpot虚拟机对象头Mark Word:
对象的访问定位
Java程序会通过栈上的reference数据来操作堆上的具体对象。而对象访问方式也是由虚拟机实现而定的,主流的访问方式主要有两种:
- “使用句柄”:(Java堆中划分内存作为句柄池)reference中存储对象的句柄地址,而句柄中包含了对象“实例数据”与“类型数据”各自具体的地址信息;
- “直接指针”:reference中存储的直接就是对象地址(对象内部指针指向其类型数据);
- HotSpot 使用的是“直接指针”方式;
句柄:
指针:
比较:
- 句柄:在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要被修改。
- 指针:节了一次指针定位的时间开销,访问速度更快,适用于对象访问在Java中非常频繁;
实战:OutOfMemeoryError异常
Java堆溢出 虚拟机栈和本地方法栈溢出 方法区和运行时常量池溢出