运行时数据区域
程序计数器
当前线程执行的字节码的行号指示器,通过改变计数器的值来选取下一条需要执行的字节码指令
每个线程都有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储
- 如果线程在执行一个java方法:计数器记录的是正在执行的虚拟机字节码指令的地址
- 如果线程执行的是本地(Native)方法:计数器值应为空
Java虚拟机栈
描述Java方法执行的线程内存模型:方法执行时,会创建一个栈帧,存储局部变量表、操作数栈、动态连接、方法出口等信息
- 如果线程请求的栈深度大于虚拟机所允许的深度,抛出StackOverFlowError异常
- 如果Java虚拟机栈容量可以动态扩展,当栈扩展时无法申请到足够的内存会抛出OutOfMemoryError异常
本地方法栈
与虚拟机栈发挥的作用相似,虚拟机栈为虚拟机执行java方法(字节码服务),本地方法栈为虚拟机使用到的本地(Native)方法服务。
本地方法栈也会在栈深度溢出或者栈扩展失败时分别抛出StackOverflowError和OutOfMemoryError异常
Java堆
虚拟机管理内存最大的一块,被所有线程共享,虚拟机启动时,java堆的唯一目的就是存放对象实例,几乎所有对象实例都在java堆中分配内存。
java堆是垃圾收集器管理的内存区域,也称为GC堆
Java堆可以处理物理上不连续的内存空间,逻辑上被视为连续的
Java堆可以被实现为固定大小的,也可以是扩展的,如果在Java堆中没有内存完成实例分配,并且堆也无法再扩展时,Java虚拟机将会抛出OutOfMemoryError异常
方法区
与java堆一样,是各个线程共享的内存区域,用于存储已被虚拟机加载的类型信息、常量、静态变量以及编译器编译后的代码缓存等数据
运行时常量池
方法区的一部分,用于存放编译期生成的各种字面量和符号引用,这些内容将在类加载后存放到方法区的运行时常量池中
运行时常量池具备动态性,不一定只有在编译期间才产生,运行期间也可以将新的常量放入池中
既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出OutOfMemoryError异常
直接内存
不是虚拟机运行时数据区的一部分,但是这部分频繁使用,可能也会导致OutOfMemoryError异常出现
HotSpot虚拟机对象
探讨HotSpot虚拟机在java堆中对象分配、布局和访问的全过程
对象的创建
当Java虚拟机遇到字节码new指令时:
- 检查指令的参数是否在常量池中定位到一个类的符号引用
- 检查符号引用代表的类是否已被加载、解析和初始化过
- 如果没有,则执行相应的类加载过程
- 类加载检查通过后,虚拟机为新生对象分配内存,对象所需的内存大小在类加载完成后便可以完全确定
分配内存方法取决于内存是否规整
- 当使用Serial\ParNew等带压缩整理过程的收集器时,内存是规整的,则使用'指针碰撞'的分配方式来分配内存,简单高效(指针碰撞就是将用来界定空闲和使用的内存的指针指示器向空闲方向挪动一段与对象大小相等的距离
- 当使用CMS这种基于清除(Sweep)算法的收集器时,只能采用复杂的'空闲列表'方式来分配内存
分配内存时要考虑冲突问题
- 对分配内存空间的动作进行同步处理-实际上虚拟机采用CAS配上失败重试的方法保证更新操作的原子性
- 将内存分配的动作按照线程划分在不同的空间中进行,每个线程在java堆中先预分配一小块内存,成为本地线程分配缓冲,只有本地缓冲区用完了,分配新的缓存区时才需要同步锁定。
- 内存分配完之后,虚拟机将分配到的内存空间初始化为零值,这步操作保证了对象的实例字段在java代码中可以不赋初始值就直接使用,使程序能访问到这些字段的数据类型对应的零值。
- 虚拟机对对象进行必要的设置:例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码(实际上对象的哈希码会延后到真正调用Object::hashCode()方法时才计算)、对象的GC分代年龄等信息。这些信息放在对象的对象头之中
- 从虚拟机的角度,对象已经产生了,但是只有执行构造函数init之后,一个真正可用的对象才算被完全构造出来。
对象的内存布局
在HotSpot虚拟机中,对象在堆内存中的存储布局可以划分为三个部分:
- 对象头(Header)
-
- 第一类信息:用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳
- 第二类信息:类型指针,即对象指向它的类型元数据的指针,Java虚拟机通过这个指针来确定该对象是哪个类的实例。
- 实例数据(Instance Data):对象真正存储的有效信息,即我们在程序代码里面所定义的各种类型的字段内容,无论是从父类继承下来的,还是在子类中定义的字段都必须记录起来。
- 对齐填充(Padding):仅仅是占位符作用,当对象实例数据部分没有对齐的时候,就通过对齐填充来补全。
对象的访问定位
创建对象之后要使用该对象,java程序通过栈上的reference数据来操作堆上的具体对象。reference类型只规定了它是一个指向对象的引用,并没有定义如何定位,如何访问对象的具体位置,所以对象访问方式也是由虚拟机实现定义的。
主流的访问方式有两种:使用句柄和直接指针
- 使用句柄访问:java堆中会划分一块内存来作为句柄池,reference中存储的就是对象的句柄地址,句柄包含了对象的实例数据与类型数据各自具体的地址信息
- 使用直接指针访问:java堆中对象的内存布局必须考虑如何放置访问类型数据的相关信息,reference中存储的直接就是对象地址,如果只是访问对象本身的话,就不需要多一次间接访问的开销。(速度快)
实战:OutOfMemoryError异常
目的:
- 通过代码验证各个运行时区域储存的内容
- 在遇到实际的内存溢出异常时,能迅速定位,迅速处理
Java堆溢出
不断创建对象,保证GC Roots到对象之间有可达路径来避免垃圾回收机制清除这些对象,当总容量触及最大堆的容量限制后就会产生内存溢出异常。
常规的方法是通过内存映像分析工具堆Dump的堆转储快照进行分析,弄清楚是出现了内存泄漏(Memory Leak)还是内存溢出(Memory OverFlow)
- 内存泄漏:通过工具查看泄漏对象到GC Roots的引用链,找到泄漏对象通过怎么样的引用路径,与哪些GC Roots相关联,才导致垃圾收集器无法回收它们,根据泄漏对象的类型信息以及它到GC Roots引用链的信息,从而准确找到对象创建的位置,进而找出产生内存泄漏的代码的具体位置。
- 不是内存泄漏:检查Java虚拟机的堆参数(-Xmx与-Xms)设置,与机器的内存对比,看是否能够向上调整,再从代码上检查是否存在某些对象生命周期过长,减少程序运行期的内存消耗。
虚拟机栈和本地方法栈溢出
关于虚拟机栈和本地方法栈,共有两种异常:
- 如果线程请求的栈深度大于虚拟机允许的深度,抛出StackOverFlowError异常。
- 如果虚拟机的栈内存允许动态扩展,当扩展容量无法申请到足够的内存时,将抛出OutOfMemoryError异常
HotSpot虚拟机不支持动态扩展,所以只有可能在创建线程申请内存时无法获得足够内存出现OutOfMemoryError异常,否则只会因为栈容量无法容纳新的栈帧而导致StackOverFlowError异常
如果是建立过多线程导致的内存溢出,在不能减少线程数量或者更换64位虚拟机的情况下,就只能通过减少最大堆和减少栈容量来换取更多的线程。
方法区和运行时常量池溢出
JDK7之后,放在永久代的字符串常量池被移至Java堆中
方法区溢出也是常见的内存溢出异常,一个类如果被垃圾收集器回收,是比较困难的,要关注动态类的回收状况。
- -XX:MaxMetaspaceSize:设置元空间最大值,默认是-1,即不限制,或者说只受限于本地内存大小
- -XX:MetaspaceSize:指定元空间的初始空间大小,以字节为单位,达到该值就会触发垃圾收集进行类型卸载,同时收集器会对该值进行调整
- -XX:MinMetaspaceFreeRatio:作用是在垃圾收集之后控制最小的元空间剩余容量的百分比,可减少因为元空间不足导致的垃圾收集的频率。
本机直接内存溢出
直接内存容量大小可通过-XX:MaxDirectMemorySize参数来指定,如果不去指定,则默认与Java堆最大值(由-Xmx指定)一致
由直接内存导致的内存溢出,明显特征是Heap Dump文件中不会看见有什么明显的异常 情况,如果读者发现内存溢出之后产生的Dump文件很小,而程序中又直接或间接使用了DirectMemory(典型的间接使用就是NIO),则考虑直接内存方面的问题。