《深入理解Java虚拟机》第二章 —— Java内存区域与内存溢出异常
文章目录
一、运行时数据区域
Java虚拟机在执行Java程序时会把它所管理的内存划分为若干个不同的数据区域,这些区域各有各的用途、以及创建和销毁的时间
接下来介绍Java虚拟机所管理的内存将会包括一下几个运行时数据内存
1.程序计数器
Program Counter Register:
- 一块较小的内存空间,可以看作时当前线程所执行的字节码的行号指示器
- 它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成
在任意一个确定的时间,一个处理器都会只执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程要有一个独立的程序计数器,各条线程之前计数器互不影响、独立存储,我们称这类内存区域为“线程私有”的内存
2.Java虚拟机栈
Java Virtual Machine Stack:
- 虚拟机栈描述的是Java方法执行的线程内存模型:
- 每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧用于存储局部变量表、操作数栈、动态连接、方法出口等信息
- 每一个方法被调用直至执行完毕的过程,就对应一个战阵在虚拟机栈中从入栈到出栈的过程
- 也是线程私有的,生命周期与线程相同
局部变量表:存放编译期可知的各种基本数据类型、对象引用。局部变量表的大小(变量槽的数量)在编译期已经确定,不会改变。
3.本地方法栈
Native Method Stacks 与虚拟机栈发挥的作用很类似,区别:
- 虚拟机栈为虚拟机执行Java方法(字节码)服务
- 本地方法栈为虚拟机使用到的本地(native)方法服务
4.Java堆
Java Heap:
- Java堆是虚拟机所管理的内存最大的一块,几乎所有的对象实例都在这里分配内存
- Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建,唯一目的是存放对象实例
- 也是垃圾收集器管理的主要区域
5.方法区
Method Area(别名:非堆 Non-Heap):
- 同样是各个线程共享的区域
- 用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
- 永久代的概念再jdk8被完全废弃,改用了与JRockit、J9一样的再本地内存中实现的元数据(Meta-space)来代替
6.运行时常量池
Runtime Constant Pool:
- 方法区的一部分
- class文件除了类的版本、字段、方法等信息外,还有一项信息是常量池表(Constant Pool Table)用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池
- 运行期间也可以将新的变量放入池中,如:String类的intern()方法
7.直接内存
Direct Memory:并不是虚拟机运行时数据区的一部分,由于这部分内存被频繁调用且可能导致Out Of Memory Error,放到这里一起讲解
- jdk1.4中增加了NIO,可以分配堆外内存(系统内存替代用户内存),提高了性能。
二、HotSpot虚拟机对象探秘
深入探讨一下HotSpot虚拟机在Java堆中对象分配、布局和访问的全过程
1.对象的创建
对象所需的内存大小在类加载之后即可以完全确定。
分配内存方法:
- 指针碰撞(Bump The Pointer):如果java堆带有空间压缩整理(Compact)能力,那么java堆就是规整的,即被使用过的内存放在一边,未使用的内存放在另一边,中间放着一个指针作为分界点的指示器,内存分配就是向空闲空间的方向移动一段与对象相等的距离。简单、高效。
- 空闲列表(Free List):如果java堆不带有空间压缩整理(Compact)能力,Java堆中的空闲内存和已使用的内存交织在一起。虚拟机就必须维护一个列表,记录哪些内存块是可用的,在分配时找到一块可用的足够大的内存分配,并更新列表上的记录。
线程安全问题:
对象在虚拟机中创建时很频繁的行为,仅仅修改一个指针指向的位置,在并发情况下也不是线程安全的。
可能在给A分配内存时,指针还没来得及修改,对象B就使用了原来的指针来分配内存。
两种解决方法:
- 对分配内存空间的动作进行同步处理,采用CAS+失败重试的方法保证更新操作的原子性
- 每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer ,TLAB)。只有当本地缓冲区用完了,分配新的缓冲区才需要同步锁定。
虚拟机是否使用TLAB,可以通过 -XX:+/-UseTLAB 参数来设定
2.对象的内存布局
在HotSpot虚拟机中,对象在堆内存中的存储布局可以划分为三个部分:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
对象头:
- 自身运行时数据:哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁等。这部分数据被官方称为“Mark Word”。Mark Word 被设计成一个有着动态定义的数据结构,以便在极小的空间内存存储尽量多的数据,根据对象的状态复用自己的存储空间。
- 类型指针:对象指向它的类型元数据的指针,Java虚拟机通过这个指针来确定该对象是哪个实例。
**实例数据:**相同宽度的字段会分配到一起存放,父类定义的变量会出现在子类之前
**对齐填充:**不是必然存在的,仅仅起占位符的作用:对于对象实例数据部分没有对齐的部分,用对齐填充来补齐
3.对象的访问定位
Java程序会通过栈上的reference数据来操作堆上的具体对象。reference类型被定义是一个指向对象的引用,但这个引用时通过什么方式、访问到堆中对象的具体位置呢?
对象访问方式也是由虚拟机实现而定的,主流的访问方式有 使用句柄 和 直接指针:
- 使用句柄访问:Java堆可能划分一块内存作为句柄池,reference中存储的就是对象的句柄地址,而句柄包含了对象实例数据与类型数据各自具体的地址信息。
- 直接指针访问:reference中存储的直接就是对象地址。
各有优势。使用句柄最大好处就是在对象被移动的时候,改变的句柄中的示例数据指针,而reference不需要改变。直接指针的最大好处就是速度更快,节省了一次指针定位的时间开销,由于对象访问极为频繁,这类开销积少成多也是一项极为可观的执行成本。
HotSpot 主要使用的是直接指针访问方式。
三、实战:Out Of Memory异常
以下称作OOM。除了程序计数器外,其他的几个运行时区域包括直接内存都有OOM的可能。
1.Java堆溢出
Java堆用于存储对象实例,随着对象数量的增加,总容量触及最大堆的容量限制后就会产生内存溢出异常。
虚拟机启动参数配置:
2.虚拟机栈和本地方法栈溢出
由于HotSpot并不区分虚拟机栈和本地方法栈,因此-Xoss(设置本地方法栈大小)虽然存在但不起作用,只能由-Xss来设定栈容量。
关于虚拟机栈和本地方法栈有两种异常:
- 如果线程请求的栈的深度大于虚拟机所允许的最大深度,将抛出StackOverflowError异常
- 如果虚拟机的栈内存允许动态扩展,当扩展栈容量无法申请到足够的内存时,将抛出OutOfMemoryError异常
HotSpot不支持栈的动态扩展,所以除非在创建线程申请内存时就因为无法获得足够的内存而出现OutOfMemoryError异常,否则在线程运行时是不会因为扩展而导致内存溢出的,只会因为栈容量无法容纳新的栈帧而导致StackOverflowError。
限制栈内存容量:
还有一种测试方法是多占局部变量表空间,由于太过麻烦在这里不再示范。
当新的栈帧内存无法分配的时候,HotSpot虚拟机抛出的都是StackOverflowError异常。但如果在允许动态扩展栈容量大小的虚拟机上,抛出的异常就会是OutOfMemoryError异常了。
在不断建立线程的情况下,HotSpot也是可以产生OutOfMemoryError异常的,,在这种情况下,给每个线程分配的内存越大,越容易产生内存溢出异常。
3.方法区和运行时常量池异常
运行时常量池实时方法区的一部分,一起测试。
永久代在JDK8中已经完全被元空间代替,这里在JDK8的环境下通过-XX:PermSize和-XX:MaxPermSize限制永久代的大小,可以间接限制其中常量池的容量。
提示已经被移除了。
如果在JDK6中:
4.本机直接内存溢出
直接内存可以由-XX:MaxDirectMemorySize来设定,如果不指定,默认和Java堆最大值一致。
由直接内存导致的内存溢出,明显特征是Heap Dump文件不会看见有什么明显的异常情况,如果Dump文件很小,而程序中又间接使用了DirectMemory(典型的间接使用就是NIO),那就可以考虑重点检查一下直接内存方法的原因了。