【Java虚拟机系列(二)】自动内存管理
看山是山、看山不是山、看山还是山,说的是不同的境界。
初学Java,只需要API堆叠,加上自带的各种数据结构一顿操作就可以让系统跑得好好的,不需要关心应用的内存问题。
慢慢的知道了编码过程需要考虑各种简单的优化技巧,如大字符串拼接适用StringBuffer/StringBuilder代替、List/Map等数据结构在使用时设置合适的初始容量,避免使用过程频繁扩容等,开始了解到其实是需要关注应用内存使用的。
等工作一定年份,遇到不少问题,需要更深入底层去分析。开始了解虚拟机到底为我们做了什么、我们还需要做些什么才能让系统更高效、更健康的运行。
今天就来了解JAVA虚拟机最神秘、最核心部分:自动内存管理,自动内存管理可以分为两大部分:自动内存分配、自动内存回收,后面会分别详细介绍,首先了解一下虚拟机运行时数据区域。
运行时数据区域
许多开发者都习惯将虚拟机数据区域分为堆和栈:栈存储基本数据类型和对象的引用、堆存储真实对象,这种说法是有失偏颇的,真实的运行时数据区域如下图所示:
方法区:在虚拟机规范中时堆的一个逻辑部分,用于存储加载的类信息、常量、静态变量、即时编译器编译后的代码等数据,在JAVA8之前也被称为永久代,JAVA8开始使用直接内存实现,其中还包括运行时常量池。
堆:由虚拟机的内存分代收集方案将堆区分为新生代与老年代,新生代又分为:Eden区、From Survivor以及To Survivor。用于存放对象实例,是垃圾收集器管理的主要区域。
虚拟机栈:前文所说的栈便是指虚拟机栈,虚拟机栈与线程生命周期相同。描述JAVA方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出入口等信息。每个方法从调用到执行完成的过程,就对应着一个栈帧从虚拟机栈中入栈到出栈的过程。
本地方法栈:用于虚拟机执行native方法服务,HotSpot虚拟机将其与虚拟机栈合为一体实现。
程序计数器:当前线程所执行的字节码的行号指示器,用于记录分支、循环、跳转、异常处理、线程恢复等功能
以上各个区域,除程序计数器空间不会出现OOM错误之外,其它区域都有可能在运行过程申请内存时因为内存不够用而出现OOM错误,其中栈空间更多的是出现StackOverFlowError。
自动内存分配
对象的生命周期从加载、验证、准备、解析、初始化、使用、卸载,在虚拟机对象内存管理层面则是为对象分配内存空间再到内存回收的过程,虚拟机对象的创建过程
- 虚拟机遇到一条new指令,检查所在类是否经过了加载过程,没有则顺便执行类加载过程
- 为对象分配内存,根据堆内存是否规整,有”指针碰撞”和”空闲列表”两种方式;为对象分配内存是很频繁的事,需要保证整个过程的线程安全,有CAS配上失败重试以及TLAB两种方式,后者可以通过虚拟机参数:
-XX:+UseTLAB
指定。 - 为对象初始化零值,有了这一步才保证对象中的变量可以不赋初始值便可以使用。
- 对象头设置
- 执行
init
方法将对象按代码指定进行初始化。
HotSpot虚拟机中对象内存布局分为3个区域:对象头、实例数据与对齐填充。
对象头包含两部分信息,一部分用于存储对象自身的运行时数据,称为Mark Word
,另一部分是类型指针(HotSpot使用直接指针的方式定位对象在堆中内存地址,其它虚拟机实现也有句柄池的);如果对象是数组,则对象头还必须包含数组长度信息。
实例数据保存本对象本身所有有效信息,从分配策略看,相同宽度的字段总被分配到一起。
对齐填充并非必须,因为虚拟机要求对象大小必须是8的整数倍,如果对象实例数据不满足条件,则需要对齐填充部分补上。
优先在Eden分配
大多数情况下,对象在新生代Eden区分配,当Eden区没有足够空间分配时,虚拟机会发起一次Minor GC
大对象直接进入老年代
需要大量连续空间的对象,如大数组、长字符串等。因为虚拟机的内存布局与使用的垃圾收集器强相关,在Serial和ParNew(对Paralle Scavenge收集器无效)两款收集器中可以指定-XX:PretenureSizeThreshold
参数用于限制超过多大内存占用的对象直接进入老年代。
长期存活的对象进入老年代
新生代对象有一个 年龄 的概念,在每一次Minor GC后还存活的对象,会被复制到Survivor区,对象年龄+1;
规则1:年龄增加到一定阙值(默认15岁,可以通过-XX:MaxTenuringThreshold
指定),就会晋升到老年代。
规则2:如果Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,大于等于该年龄的所有对象将直接进入老年代,无需年龄值满足阙值。
Minor GC过程对象通过分配担保直接进入老年代
Minor GC某些情况下如果效率不尽如人意,如极端情况下Eden区经过GC后对象全部存活,根据新生代采用复制算法以及现有新生代区域分布情况(默认情况下Eden:S0:S1=8:1:1),单个Survivor根本不可能容纳Eden所有剩余对象,此时如果没有任何措施,理应进行一次Full GC,对全堆进行一次垃圾收集操作,会导致效率低下。
虚拟机针对这种情况引入空间分配担保机制:如果老年代可用连续空间大小大于历次新生代晋升的对象的平均大小,那么会将Eden中存活的对象直接进入老年代(1.6 Update 24后的策略,总会通过比对平均值大小来判断是否允许分配担保)。
而在内存回收层面,允许分配担保,则尝试Minor GC,如果检测到老年代连续可用空间小于历次剩余平均大小或者出现分配担保失败,那么前者直接进行Full GC,后者绕一圈还是要进行Full GC。
自动内存回收
上面说到虚拟机的对象内存分配,也提到了GC相关的不少东西。实际上因为虚拟机自动内存管理是一个整体,运行时内存区域与所需用的垃圾收集器有比较大的相关性,在内存分配过程中就已经一并考虑了怎么方便、高效的进行自动回收,所以这两个模块逻辑上本来就是耦合的。
JAVA虚拟机的GC使用分代收集的策略,将堆空间划分为新生代与老年代,这在前面已经多次描述,这里不再展开。
既然是自动内存回收,就需要判断哪些对象是可以被回收的,HotSpot虚拟机使用可达性分析判断对象是否还存活:如果对象还保持着到GC Roots的引用,则认为此对象尚且存活,否则代表已死亡。
可作为GC Roots的对象有:虚拟机栈中的局部变量、方法区常量、方法区静态变量以及本地方法栈中本地方法变量。
JDK中引用分为:强引用、软引用、弱引用以及虚引用,引用强度一次减弱。强引用表示直接关联对象,如果强引用还存在,永远不会GC;软引用使用SoftReference
实现,描述有用但非必须的对象,在系统将要发生内存溢出异常之前,会把这些对象列进回收范围进行二次回收;弱引用使用WeakReference
实现,被弱引用关联的对象只能生存到下一次垃圾收集发生之前;虚引用通过PhantomReference
实现,不会对生存时间构成影响,也无法通过虚引用来取得一个对象实例,设置虚引用唯一的目的就是能在被回收时收到一个系统通知。
虚拟机判断对象真正死亡,至少要经历两次标记的过程。如果在可达性分析后对象没有雨GC Roots相连,则第一次标记并且进行一次筛选,筛选的条件就是虚拟机是否有必要执行finalize()
方法,如果未覆盖或者已调用过(finalize放只会调用一次),则认为没有必要执行;如果有必要执行,则将对象加入F-Queue
队列,稍后GC会对队列中对象进行二次标记,此时对象可以通过在finalize方法中将对象绑定到GC Roots的方式拯救自己。
对象最开始在新生代进行内存分配,由于大部分JAVA对象都是”朝生夕死”,所以理论上来说一次新生代的垃圾收集活动可以带来比较大的内存收益,于是在新生代使用了高效的复制算法:每次将剩余存活对象复制到另外一块内存区域,然后将原区域直接清空。这样带来的好处和坏处都显而易见:只需要将剩余对象复制一次,原空间(一块连续可用的大空间)便可以使用,但是需要腾出一部分空间用于备用,这一部分空间再也不能用于对象分配,也就是说会有一部分空间的浪费。HotSpot将新生代划分为Eden、From Survivor和To Survivor三部分,只有To Survivor用作备用角色。
前面提到新生代中经过多次Minor GC的对象会进入老年代,具体的过程为:Eden区对象在一次Minor GC后如果还存活,则年龄增加一岁,复制到Survivor区。From Survivor和To Survivor是两个不断交替替换的概念,从开始执行Minor GC,新生代可用内存为Eden+From Survivor,其中From Survivor中存放上次新生代回收后剩余的对象总和,且里面所有对象都一个年龄标志,本次Minor GC过程会将Eden区与From Survivor中不满足需要晋升到老年代的的对象都复制到To Survivor,然后一次性将Eden与From Survivor都清空;Minor GC完成后,From与To两个区域会互换角色,新生代保证To Survivor一直为空,作为下一次Minor GC的剩余对象的复制目标区域;直到To Survivor满后,会将其中对象晋升到老年代。
老年代的垃圾收集算法有标记-清除与标记-整理,不同的垃圾收集器存在不同,Serial Old、Parallel Old以及G1使用标记-整理算法,在垃圾收集完成后不会产生内存碎片;而CMS使用标记清除算法,可能会造成在老年代还有大的内存情况下无法完成大对象的内存分配或对象晋升而提前进行Old GC/Full GC。