深入理解Java虚拟机学习之内存区域与内存溢出异常

1.概述

Java中内存管理由虚拟机自动管理,虽然不需要手动去清理回收垃圾,出现内存泄漏和溢出时,了解虚拟机是如何使用内存的,对于Java程序员排查错误和修正问题来说是有很大帮助的

2.运行时数据区域

Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域,如下图所示:
深入理解Java虚拟机学习之内存区域与内存溢出异常_第1张图片

  • 类加载器子系统用于将编译好的.Class文件加载到JVM中
  • 运行时数据区用于存储在JVM运行过程中产生的数据,包括程序计数器、方法区、本地方法栈、虚拟机栈和堆
  • 执行引擎包括即时编译器和垃圾回收器,即时编译器用于将Java字节码编译成具体的机器执行码,垃圾回收器用于回收在运行过程中不再使用的对象
  • 本地库接口用于调用操作系统的本地方法库完成具体的指令操作

2.1.程序计数器

程序计数器是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖其来完成。
为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,因此程序计数器是线程私有的。
程序计数器是唯一没有内存溢出(OutOfMemoryError)的区域

2.2.Java虚拟机栈

Java虚拟机栈为虚拟机执行Java方法服务,生命周期与线程相同,与程序计数器一样也是线程私有的。每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧(Stack Frame)用于存储局部变量表操作数栈动态链接方法出口等。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机中从入栈到出栈的过程。
如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常
如果Java虚拟机栈容量可以动态扩展,当栈扩展时无法申请到足够的内存会抛出OutOfMemoryError异常

2.3.本地方法栈

本地方法栈是为虚拟机栈使用到的本地方法服务,与虚拟机栈一样也会在栈深度溢出或者扩展失败时分别抛出StackOverflowErrorOutOfMemoryError异常

2.4.Java堆

Java堆是虚拟机所管理的内存中最大的一块区域,是所有线程共享的一块内存区域,在虚拟机启动时创建,目的就是存放对象实例
Java堆是垃圾收集器管理的内存区域,因此也叫GC堆。
Java堆可以处于物理上不连续的内存空间中,但在逻辑上应该被视为连续的。Java堆可以被实现成固定大小的,也可以是扩展的,如果在Java堆中没有内存完成实例分配,并且堆也无法再扩展时,Java虚拟机将会抛出OutOfMemoryError异常。

2.5.方法区

方法区与Java堆一样,是各个线程共享的内存区域,用于存储已被虚拟机加载的类型信息常量静态变量、即时编译器编译后的代码缓存等数据。
方法区不需要连续的内存和可以选择固定大小或者可扩展外,还可以选择不实现垃圾收集,垃圾收集行为在这个区比较少出现,主要是针对常量池的回收和对类型的卸载。
如果方法区无法满足新的内存分配需求时,将抛出OutOfMemoryError异常

2.6.运行时常量池

运行时常量池(Runtime Constant Pool)是方法区的一部分,Class文件中有类的版本、字段、方法、接口和常量池表,常量池表用于存放编译期生成的各种字面量与符合引用,这部分内容将在类加载后存放到方法区的运行时常量池中。
运行时常量池还有一个重要特征是具备动态性,Java语言并不要求常量一定只有编译期才能产生,即并非预置入Class文件中常量池的内容才能进入方法区运行时常量池,运行期间也可以将新的常量放入池中,如String类的intern()方法。
如果常量池无法再申请到内存时会抛出OutOfMemoryError异常。

2.7.直接内存

直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,这部分内存也被频繁的使用,也可能导致OutOfMemoryError异常出现

3.HotSpot虚拟机对象探秘

HotSpot是最常用的虚拟机

3.1.对象的创建

类加载检查------>为新生对象分配内存------>初始化------>设置元数据、哈希码、GC分代年龄等信息------>执行()方法

类加载检查。当Java虚拟机遇到一条字节码new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符合引用,并且检查这个符合引用代表的类是否已被加载、解析和初始化过,如果没有,那必须先执行相应的类加载过程。

为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定,为对象分配空间的任务实际上便等同于把一块确定大小的内存块从Java堆中划分出来。
指针碰撞(Bump The Pointer):假设Java堆内存是绝对规整的,所有被使用过的内存都被放在一边,空闲的内存被放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间方向挪动一段与对象大小相等的距离。
空闲列表(Free List):如果Java堆中的内存并不是规整的,已被使用的内存和空间的内存相互交错在一起,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录
选择哪种分配方式由Java堆是否规整决定,是否规整又由所采用的垃圾收集器是否带有空间压缩整理(Compact)的能力决定。
使用Serial和ParNew带压缩整理过程的收集器时,系统采用的分配算法是指针碰撞,既简单又高效。
使用CMS这种基于清除(Sweep)算法的收集器时,理论上就只能采用较为复杂的空闲列表分配内存。
内存创建在虚拟机中是非常频繁的行为,即使仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况。
解决方案一:对分配内存空间的动作进行同步处理,实际上虚拟机是采用CAS配上失败重试的方式保证更新操作的原子性。
解决方案二:把内存分配的动作按照线程划分在不同的空间之中进行。即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB),哪个线程要分配内存,就在哪个线程的本地缓冲区中分配,只有本地缓冲区用完了,分配新的缓存区时才需要同步锁定。
虚拟机是否使用TLAB,可以通过-XX:+/-UseTLAB参数来设定。

将分配到的内存空间(不包括对象头)都初始化为零值,如果使用了TLAB的话,这一项工作也可以提前至TLAB分配时顺便进行。保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,使程序能访问到这些字段的数据类型所对应的零值。

对对象进行必要的设置,如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。

至此,对虚拟机来讲,一个新的对象已经产生了,从Java程序来看,对象创建才开始------构造函数,new指令之后会执行()方法

3.2.对象的内存布局

对象在堆内存中的存储布局划分为:对象头(Header)实例数据(Instance Data)对齐填充(Padding)

对象头包括两类信息:
第一类是用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,在32位虚拟机中为32个比特,64位虚拟机中位64个比特。
对象未被同步锁锁定时,32个比特存储空间中25个用于存储对象哈希码,4个用于存储对象分代年龄,2个用于存储锁标志位,1个固定为0。
轻量级锁定、重量级锁定、GC标记和可偏向时如下:

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

第二类是类型指针,即对象指向它的类型元数据的指针,Java虚拟机通过这个指针来确定该对象是哪个类的实例

实例数据部分是对象真正存储的有效信息,即在程序代码里面所定义的各种类型的字段内容,存储顺序会受到虚拟机分配策略参数(-XX:FieldsAllocationStyle参数)和字段在Java源码中定义顺序的影响。
默认分配顺序为longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers,OOPs),相同宽度的字段总是被分配到一起存放,在此之上,父类中定义的变量会出现在子类之前,若+XX:CompactFields参数值为true,子类中较窄的变量允许插入父类变量的空隙之中。

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

3.3.对象的访问定位

主流的访问方式有使用句柄直接指针
使用句柄------Java堆中将可能会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自具体的地址信息
使用直接指针------Java堆中对象的内存布局必须考虑如何放置访问类型数据的相关信息,reference中存储的直接就是对象地址,如果只是访问对象本身的话,就不需要对一次间接访问的开销。

两种方式比较:

访问方式 优势
使用句柄 reference中存储的是稳定句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而reference本身不需要被修改
使用直接指针 速度更快,节省了一次指针定位的时间开销

注意:HotSpot虚拟机主要使用直接指针方式进行对象访问

3.4.实战:OutOfMemoryError异常

其一通过代码验证各个运行时区域储存的内容,其二根据异常提示信息迅速得知时哪个区域的内存溢出,怎样的代码可能会导致这些区域内存溢出,如何处理异常

3.4.1.Java堆溢出

不断创建对象,并且保证GC Roots到对象之间有可达路径来避免垃圾回收机制清除这些对象,那么随着对象数量的增加,总容量触及最大堆的容量限制后就会产生内存溢出异常
处理办法
首先通过内存映像分析工具对Dump出来的堆转储快照进行分析。第一步先确认内存中导致OOM的对象是否时必需的,分清是出现了内存泄漏(Memory Leak)还是内存溢出(Memory Overflow)。

内存泄漏,进一步通过工具查看泄漏对象到GC Roots的引用链,找到泄漏对象是通过怎样的引用路径、与哪些GC Roots相关联,才导致垃圾收集器无法回收它们,根据泄漏对象的类型信息以及到GC Roots引用链的信息就可以定位对象创建的位置,进而找出产生内存泄漏的代码位置。

内存溢出,检查Java虚拟机的堆参数(-Xmx和-Xms)设置,与机器的内存相比,是否还有向上调整的空间,再检查代码是否存在某些对象生命周期过长、持有状态时间过长、存储结构设计不合理等。

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

虚拟机栈和本地方法栈存在两种异常:

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

不同版本的Java虚拟机和不同操作系统,栈容量最小值会有所限制,主要取决于操作系统的内存分页大小。
建立过多线程导致的内存溢出,可以通过减少线程数量或更换64位虚拟机或减少最大堆和减少栈容量来换取更多线程,从JDK7开始会提示possibly out of memory or process/resource limits reached

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

JDK6运行时常量池溢出时,在OutOfMemoryError异常后面跟随的提示信息时“PermGen space”,运行时常量池属于方法区
JDK8使用元空间代替了永久代,在默认情况下不会出现此异常,提供一些参数作为元空间防御措施
-XX:MaxMetaspaceSize:设置元空间最大值,默认时-1,即不限制,或者说只受限于本地内存大小
-XX:MetaspaceSize:指定元空间的初始空间大小,以字节为单位,达到该值就会触发垃圾收集进行类型卸载,同时收集器会对该值进行调整:如释放了大量的空间,就适当降低该值;如果释放了很少的空间,在不超过-XX:MaxMetaspaceSize的情况下,适当提高该值
-XX:MinMetaspaceFreeRatio:在垃圾收集之后控制最小的元空间剩余容量的百分比,可减少因为元空间不足导致的垃圾收集的频率
-XX:Max-MetaspaceFreeRatio:用于控制最大的元空间剩余容量的百分比

3.4.4.本机直接内存溢出

直接内存容量大小可通过-XX:MaxDirectMemorySize参数来指定,默认与Java堆最大值(-Xmx指定)一致
内存溢出时,在Heap Dump文件中不会看见有什么明显的异常情况,发现内存溢出后产生的Dump文件很小,而程序中又直接或间接使用了DirectMemory(如间接使用NIO)

你可能感兴趣的:(Java虚拟机,java虚拟机,内存泄漏)