深入理解java虚拟机(一)
一、Java 内存区域与内存溢出异常
1.1 运行时数据区域
java虚拟机在执行java程序的过程中会把它管理的内存划分为若干个不同的数据区域。这些区域各有各自的用途,以及创建时间和销毁时间,有的区域随着虚拟机的进程的启动而一直存在,有些区域则是依赖用户线程的启动和结束而建立和销毁。根据《Java虚拟机规范》的规定,java虚拟机的内存将会包括几个运行时数据区域:
1.1.1 程序计数器
程序计数器(Program Counter Register)是一块较小的内存空间,可以当作程序执行的行号指示器。它用来控制程序的分支、循环、跳转、异常处理、线程恢复等基础功能。
因为每个线程都是独立执行的,为了能够在切换线程后能恢复到正确的执行位置,每个线程都需要有独立的程序计数器,独立存储互不影响,这类内存区域称为“线程私有”内存。
如果正在执行Java方法,则记录正在执行的虚拟机字节码指令的地址,如果正在执行本地方法,这个计数器则应为空(Underfined)。此区域是《Java虚拟机规范》中没有规定任何OutOfMemoryError情况的区域。
1.1.2 Java虚拟机栈
与程序计数器一样,虚拟机栈(Java Virtual Machine Stack)也是线程私有的,他的生命周期与线程相同。虚拟机栈描述的是Java方法执行的线程内存模型:方法执行时,Java虚拟机都会同步创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态连接、方法出口等信息。每个方法的调用到结束,就对应栈帧在虚拟机栈中入栈和出栈的过程。
我们常关注的内存区域的“堆”和“栈”中“栈”,通常就是虚拟机栈,实际上应该指的是虚拟机栈中的局部变量表部分。
局部变量表存放了Java虚拟机基本的数据类型(原始数据类型)、对象引用(类引用、数组引用、接口引用)和returnAddress类型(指向一条字节码指令的地址,returnAddress类型与Java编程语言类型没有直接关联)。
这些数据类型在局部变量表中都以局部变量槽(Solt)来表示,其中long、double都会占用两个槽,其余的数据类型只有一个。具体的大小还要和虚拟机的具体实现相关,如果一个槽可能占32个比特、64个比特。
《Java虚拟机规范》中,虚拟机栈中规定了两种异常状况:如果线程请求的栈深度大于虚拟机允许的深度,将抛出StackOverflowError(最常见的就是循环调用);如果虚拟机栈容量可以动态扩展(和虚拟机的实现有关,Classic虚拟机允许扩展,HotSpot是不允许的,所以只要申请栈空间成功了就不会出现OOM,如果申请失败会出现OOM),当栈扩展时无法申请到足够的内存则会跑出OutOfMemoryError。
1.1.3 本地方法栈
本地方法栈(Native Method Stacks)与虚拟机栈的作用非常相似,一个是为执行Java方法服务,一个是为执行本地方法服务。《Java虚拟机规范》中对其没有做强制规定(Hotspot甚至把它合二为一),两者都会抛出StackOverflowError和OOM。
1.1.4 Java堆
Java堆是垃圾收集器管理的内存区域,因此也被称为“GC堆”。此内存区域的唯一目的就是存放对象实例,Java世界“几乎”所有的对象实例都在这里分配内存。垃圾收集器大部分都是基于分代收集设计的,所以Java堆中经常会出现“新生代”、“老年代”、“永久代”、“Eden空间”、“From Survivor空间”、“To Survivor空间”名词。
从分配内存的角度看,所有线程共享的Java堆中可以划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB),以提升对象分配时的效率。
Java堆既可以被实现成固定大小,也可以是可扩展的,不过当前主流的Java虚拟机都是按照可扩展来实现的(通过参数-Xmx和-Xms设定)。如果Java堆中没有内存完成实例分配,并且堆也无法扩展时,就会抛出OOM异常。
1.1.5 方法区
方法区(Method Area)与Java堆一样,是各个线程共享的内存区域,它用于存储被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等区域。虽然《Java虚拟机规范》中把方法区描述为堆的一个逻辑部分,但它却有一个别名叫做“非堆”(Non-Heap),目的是为了和Java堆区分开来。
方法区和永生代不是等价的,具体的虚拟机的实现是不同的。《Java虚拟机规范》对方法区的约束是非常宽松的,甚至可以不实现垃圾收集。并不是进入方法区就如同永生代的名字一样“永久”存在,这区域的回收目标主要是对常量池的回收和对类型的卸载。如果方法区无法满足新的内存分配需求时,将抛出OOM异常。
1.1.6 运行时常量池
运行时常量池(Runtime Constant Pool)是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表(Constant Pool Table),用来存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。
运行时常量池对于Class文件常量池的另外一个重要的特征就是具有动态性,有些特性可以在运行时将常量放入池中,比如String#intern()方法。
运行时常量池在无法申请到内存的时候也会抛出OOM异常。
1.1.7 直接内存(堆外内存)
Java应用程序通过直接方法(Unsafe类、本地方法等)申请的内存,这部分内存不会受到Java堆大小的限制,当内存超过物理内存限制的时候也会导致出现OOM异常。
1.2 HotSpot虚拟机对象探秘
1.2.1 对象的创建
语言层面上可以简单通过new、clone、反射、反序列化等方式创建一个对象。
HotSpot虚拟机对象创建过程如下图所示:
- 指针碰撞(Bump The Pointer):假设内存规整,使用的内存和空闲的内存各放一边,中间放一个指针来作为分界,分配内存就是将指针向空闲空间移动一个和对象大小相等的距离;
- 空闲列表(Free List):使用的内存和空闲内存交错,虚拟机维护一个列表,记录可用的内存,然后从列表中寻找一块足够大的内存;
一般内存是否规整又由所采用的垃圾收集器是否带有空间压缩功能(Compact)能力决定。因此当使用Serial、ParNew等压缩整理过程的收集器时,系统采用的分配算法是指针碰撞,简单而高效;当使用CMS这种基于清除(Sweep)算法的收集器时,理论上只能采用复杂的空间列表来分配。
另一个要考虑的问题就是并发,怎么保证多个线程同时分配内存的情况。解决这个问题有两个可选方案:
- 对分配内存进行同步——实际上采用CAS配上失败重试的方法保证更新操作的原子性;
- 每个线程在Java堆中预先分配一小块内存:本地线程分配缓冲(Thread Local Allocation Buffer,TLAB),只有本地缓冲区用完了才会需要同步锁定。虚拟机是否使用TLAB,可以通过
-XX:+/-UseTLAB参数来决定。
初始化零值,如果使用TLAB的话,这项工作可以提现到TLAB分配时顺便进行。
设置对象头信息,例如这是哪个类的实例、如何找到类的元数据信息、对象的哈希码(实际是调用Object#hashCode()时才会计算)、对象的GC分代年龄、是否启用偏向锁等信息。
到这时,从虚拟机视角一个对象已经产生了,从Java程序视角来看,对象创建才刚刚开始——构造函数(
1.2.2 对象内存布局
HotSpot虚拟机中,对象在内存中的存储布局分为三个部分:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
对象头一般包含两类信息:
- Mark Word:存储对象自身运行时数据,如哈希码、GC分代年龄、所状态标志、线程持有的锁、偏向线程ID、偏向时间戳等(大小和虚拟机位数相关);
- Klass Pointer:对象指向它的类的元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例;如果对象是Java数组,还有一块记录数组长度的数据,虚拟机可以确定普通对象的大小,但是数组长度不确定,就无法算出数组的实际大小;为了节约内存可以使用选项 -XX:+UseCompressedOops 开启指针压缩,其中 oop即ordinary object pointer 普通对象指针;
实例数据部分才是真正存储的有效信息,即我们定义的各种类型的字段内容,无论是父类继承还是子类的字段。字段顺序会受HotSpot虚拟机分配策略参数-XX:FieldsAllocationStyle和字段在Java源码中的定义顺序影响。如果HotSpot虚拟机参数+XX:CompactFields为true(默认为true),子类的字段可以插在父类的空隙中,以节省空间。
对象填充就是占位符的作用,HotSpot虚拟机要求对象起始地址都是8字节的整倍数。所以就需要填充来对齐。
1.2.3 对象的访问定位
对象的访问方式由虚拟机实现而定,主流的访问方式有使用句柄和直接访问指针两种:
- 句柄访问:Java堆中分出一块内存作为句柄池,reference中存储对象句柄地址,句柄包含了对象实例数据和类型数据各自的地址;
- 直接指针访问:Java堆中对象的内存中就放入对象类型数据,reference就是对象地址;
直接使用指针最大的好处就是速度快,HotSpot主要使用直接指针方式进行访问(如果使用了Shenandoah收集器,会有一次额外转发)。
1.3 OOM异常
1.3.1 Java堆溢出
堆最小值参数-Xms和最大值-Xmx设置成一样可以避免自动扩展,通过参数-XX:+HeapDumpOnOutOfMemoryError可以让虚拟机在出现OOM的时候自动Dump内存堆转储快照。
1 | |
1 | |
使用工具(如Eclipse Memory Analyzer,Idea也可以直接打开.hprof文件)分析,先确认是内存泄漏(Memory Leak)还是内存溢出(Memory Overflow)。
内存泄漏:查看内存泄漏对象的GC Roots引用链,找到内存泄漏对象的引用路径,从而找出为什么无法回收它们。
不是内存泄漏:看那些对象是必须存活的,调整参数或者检查让生命周期过长的对象设计是否合理。
1.3.2 虚拟机栈和本地方法栈溢出
HotSpot虚拟机并不区分虚拟机栈和本地方法栈,对HotSpot来说-Xoss参数(设置本地方法栈大小)虽然存在但没有任何效果,栈容量只能由-Xss参数来设定。
《Java虚拟机规范》在此处定义了两种异常:
- 如果线程请求的栈深度大雨虚拟机所允许的最大深度,将会抛出StackOverflowError;
- 如果虚拟机的栈内存允许动态扩展,当扩展栈容量无法申请到足够的内存时,将抛出OOM;
HotSpot虚拟机是不支持栈的动态扩展的,所以只会因为栈容量无法容纳新的栈帧而抛出StackOverflowError。
1 | |
异常信息:
1 | |
缩减栈内存容量(注意⚠️:虚拟机会有一个最小栈内存限制,小于该限制值虚拟机将无法启动):
1 | |
异常信息:
1 | |
添加大量本地变量:
1 | |
异常信息:
1 | |
- 使用
-Xss参数减少栈内存容量,抛出StackOverflowError,异常出现时输出的堆栈深度也会相应地减小; - 定义大量本地变量,增大此方法的本地变量表的长度,抛出异常时堆栈的深度也会相应的减少;
1.3.3 方法区和运行时常量池溢出
JDK8以后,运行时常量池是在堆内的,通过使用-Xmx参数限制对内存,产生运行时常量池的溢出。
1 | |
异常信息:
1 | |
方法区溢出不是特别好操作,在JDK8之前可以通过CGLib生成大量的动态类,JDK8之后“永生代”被“元空间”代替后方法区就更难迫使虚拟机产生方法区的溢出异常了。
HotSpot提供了一些“元空间”的防御措施:
-XX:MaxMetaspaceSize:设置元空间最大值,默认是-1,即不限制,或者说受限于本地内存大小;-XX:MetaspaceSize:元空间的初始空间大小,以字节为单位,达到该值就会触发垃圾收集器进行类型卸载,同时收集器会对该值进行调整:如果释放了大量空间,就是适当降低该值;如果释放了很少的空间,那么在不超过-XX:MaxMetaspaceSize适当提高该值;-XX:MinMetaspaceFreeRatio:在垃圾收集之后控制最小的元空间剩余容量的百分比,可减少元空间不足导致的垃圾收集的频率。-XX:MaxMetaSpaceFreeRatio相反的功能。
1.3.4 本地内存溢出
直接内存导致的溢出,一般Heap Dump文件中不会看到有什么明显的异常信息,如果溢出后Dump文件很小,就有可能是直接内存溢出了。