开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第12天,点击查看活动详情
1.概述
Java与C++之间有一堵由内存动态分配和垃圾回收机制所围成的高墙,墙外面的人想进去,墙里面的人出不来。
必要性:虽然JVM有自动内存管理机制,不需要人为地给每一个new操作写配对的delete/free代码,不容易出现内存泄漏和内存溢出问题。然而一旦出现内存泄漏和溢出方面的问题,如果不清楚JVM内存的内存管理机制,那么将很难定位与解决问题。
2.JVM内存区域划分
JVM执行Java程序的过程:Java源代码文件(.java)会被Java编译器编译为字节码文件(.class),然后由JVM中的类加载器加载各个类的字节码文件,加载完毕之后,交由JVM执行引擎执行。
在上述过程中,JVM会用一段空间来存储执行程序期间需要用到的数据和相关信息,这段空间就是运行时数据区(Runtime Data Area) ,也就是常说的JVM内存。JVM会将它所管理的内存划分为若干个不同的数据区域,划分结果如图:
可见,运行时数据区被分为线程私有数据区和线程共享数据区两大类:
- 线程私有数据区包含:程序计数器、虚拟机栈、本地方法栈
- 线程共享数据区包含:Java堆、方法区(内部包含常量池)
接下来分别介绍:
程序计数器(Program Counter Register)
-
是当前线程所执行的字节码的行号指示器。
- 如果线程正在执行的是一个Java方法,那么计数器记录的是正在执行的虚拟机字节码指令的地址;
- 如果线程正在执行的是一个Native方法,那么计数器的值则为空。
字节码解释器工作时,就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
- 为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,因此它是线程私有的内存。
- 在Java虚拟机规范中,是唯一一个没有规定任何OutOfMemoryError情况的区域。
Java虚拟机栈(Java Virtual Machine Stacks)
-
是Java方法执行的内存模型。
- 每个方法在执行的同时都会创建一个栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口等信息。
- 每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。
局部变量表存放了编译期可知的各种基本数据类型、对象引用类型和returnAddress类型,它所需的内存空间在编译期间完成分配。
-
是线程私有的内存,与线程生命周期相同。
-
一般把Java内存区分为堆内存(Heap)和栈内存(Stack),其中『栈』指的是虚拟机栈,『堆』指的是Java堆。
-
在Java虚拟机规范中,对这个区域规定了两种异常状况:
- 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;
- 如果虚拟机栈可动态扩展且扩展时无法申请到足够的内存,将抛出OutOfMemoryError异常。
本地方法栈(Native Method Stack)
- 是虚拟机使用到的Native方法服务。
- 在虚拟机规范中,对这个区域无强制规定,由具体的虚拟机自由实现。与虚拟机栈一样,本地方法栈区域也会抛出StackOverflowError和OutOfMemoryError异常。
Java堆(Java Heap)
- 用于存放几乎所有的对象实例和数组。
- 被所有线程共享的一块内存区域,在虚拟机启动时创建。
在Java堆中,可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB),但无论哪个区域,存储的都仍然是对象实例,进一步划分的目的是为了更好地回收内存,或者更快地分配内存。
- 是垃圾收集器管理的主要区域,也被称做“GC堆”。
- 是Java虚拟机所管理的内存中最大的一块。
- 可处于物理上不连续的内存空间中,只要逻辑上是连续的即可。
- 在Java虚拟机规范中,如果在堆中没有内存完成实例分配,且堆也无法再扩展时,将会抛出OutOfMemoryError异常。
方法区(Method Area)
- 用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
- 与Java堆一样,是各个线程共享的内存区域。
- 人们更愿意把这个区域称为 “永久代”(Permanent Generation) ,在发布的JDK1.7的HotSpot中,已经把原本放在永久代的字符串常量池移出。它还有个别名叫做Non-Heap(非堆) 。
- 和Java堆一样不需要连续的内存和可以选择固定大小或可扩展外,还可选择不实现GC。
- 在Java虚拟机规范中,当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。
运行时常量池(Runtime Constant Pool)
Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池(Constant Pool Table) ,用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。
- 相对于Class文件常量池的一个重要特征是具备动态性,体现在并非只有预置入Class文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中。
- 是方法区的一部分,会受到方法区内存的限制。
- 在Java虚拟机规范中,当常量池无法再申请到内存时会抛出OutOfMemoryError异常。
推荐阅读:JVM内存溢出详解(栈溢出,堆溢出,持久代溢出、无法创建本地线程)
3.操作系统内存与JVM内存
从上图可见操作系统内存和JVM内存的联系:
操作系统分为栈和堆:
栈由操作系统管理,并由操作系统自动回收。
- JVM本地方法栈使用的是操作系统的栈。
堆由用户分配使用。
- 除JVM本地方法栈以外的JVM内存使用的操作系统的堆,以防JVM分配的内存被操作系统回收。
图片来源:JVM内存管理—运行时内存区域
4.HotSpot虚拟机内存对象探秘
在熟悉虚拟机内存划分及其具体内容之后,为详细了解虚拟机内存中数据的其他细节,以常用的虚拟机HotSpot和常用的内存区域Java堆为例,探讨HotSpot虚拟机在Java堆中对象分配、布局和访问的全过程。
对象的创建
遇到一个new指令后创建过程分三步
-
类加载检查:检查new指令的参数是否能在常量池中定位到一个类的符号引用且该符号引用代表的类是否已被加载、解析和初始化,若没有则需先执行相应的类加载,反之下一步。
-
分配内存:由Java堆中的内存是否规整决定如何给新生对象分配可用空间。
-
若规整,采用“指针碰撞”分配方式:
- 过程:将用过和空闲的内存放在两边,中间以一个指针作为分界指示器。当分配内存时,就把指针向空闲一边挪动与对象大小相等的距离即可。
- 应用:Serial、ParNew等带Compact过程的收集器。
-
若非规整,采用“空闲列表”分配方式:
- 过程:维护一个记录可用内存块的列表。当分配内存时,就从列表中找到一块足够大的空间划分给对象实例并更新记录。
- 应用:基于Mark-Sweep算法的CMS收集器。
-
保证内存分配是线程安全的解决方案:
- 对内存分配的动作进行同步处理;
- 每个线程在Java堆中预先分配一块内存(本地线程分配缓冲TLAB),在本线程的TLAB上进行分配,当TLAB用完需要分配新的TLAB时再同步锁定。
- 设置对象头:将对象的所属类、找到类的元数据信息的方式、对象的哈希码、对象的GC分代年龄等信息存放在对象的对象头中。
经过上述步骤,一个对象就产生了,但此时所有的字段都还为零,还需要执行
<init>方法进行初始化,才能成为真正可用的对象。
对象的内存布局
分为三块区域
-
对象头(Header):包括两部分信息
- Mark Word:用于存储对象自身的运行时数据,如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。
- 类型指针:用于确定这个对象的所属类。
-
实例数据(Instance Data):存储真正的有效信息,是程序代码中定义的各种类型的字段内容。存储顺序会受虚拟机分配策略参数和字段在Java源码中定义顺序这两个因素影响。
-
对齐填充(Padding):占位符,帮助补全未对齐的对象实例数据部分(保证是8字节的倍数),非必需。
对象的访问定位
主流的两种访问方式
- 通过句柄访问对象:在Java堆中划分出一块内存来作为句柄池,reference存储的是对象的句柄地址,在句柄中包含了对象实例数据与类型数据各自的具体地址信息。好处:reference中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而reference本身不需要修改。
- 通过直接指针访问对象:在Java堆对象的布局中考虑如何放置访问类型数据的相关信息,reference存储的直接就是对象地址。好处:速度更快,节省了一次指针定位的时间开销。
5. 对象存活判定算法
概念:引用的四种类型
强引用(StrongReference)
- 具有强引用的对象不会被GC;
- 即便内存空间不足,JVM宁愿抛出
OutOfMemoryError使程序异常终止,也不会随意回收具有强引用的对象。软引用(SoftReference)
- 只具有软引用的对象,会在内存空间不足的时候被GC,如果回收之后内存仍不足,才会抛出OOM异常;
- 软引用常用于描述有用但并非必需的对象,比如实现内存敏感的高速缓存。
弱引用(WeakReference)
- 只被弱引用关联的对象,无论当前内存是否足够都会被GC;
- 强度比软引用更弱,常用于描述非必需对象。
虚引用(PhantomReference)
- 仅持有虚引用的对象,在任何时候都可能被GC;
- 常用于跟踪对象被GC回收的活动;
- 必须和引用队列 (ReferenceQueue)联合使用,当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。
引用计数算法
给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器为0的对象就是不可能再被使用的。
然而在主流的Java虚拟机里未选用引用计数算法来管理内存,主要原因是它难以解决对象之间相互循环引用的问题,所以出现了另一种对象存活判定算法。
可达性分析法(目前主流)
通过一系列被称为『GC Roots』的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的。
可作为GC Roots的对象:
- 虚拟机栈中引用的对象,主要是指栈帧中的本地变量
- 本地方法栈中Native方法引用的对象
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
需要注意的是,在可达性分析算法中被判定不可达的对象还未真的判『死刑』,至少要经历两次标记过程:判断对象是否有必要执行finalize()方法;若被判定为有必要执行finalize()方法,之后还会对对象再进行一次筛选,如果对象能在finalize()中重新与引用链上的任何一个对象建立关联,将被移除出“即将回收”的集合。
引伸:有关方法区的GC,可分成两部分
废弃常量与回收Java堆中的对象的GC很类似,即在任何地方都未被引用的常量会被GC。
无用的类需满足以下三个条件才会被GC:
- 该类所有的实例都已被回收,即Java堆中不存在该类的任何实例;
- 加载该类的
ClassLoader已经被回收;- 该类对应的java.lang.Class对象没在任何地方被引用,即无法在任何地方通过反射访问该类的方法。
6. 垃圾回收策略
分代收集
-
根据对象存活周期的不同,将Java堆划分为新生代和老年代,并根据各个年代的特点采用最适当的收集算法。
- 新生代:大批对象死去,只有少量存活。使用『复制算法』,只需复制少量存活对象即可。
- 老年代:对象存活率高。使用『标记—清理算法』或者『标记—整理算法』,只需标记较少的回收对象即可。
-
是当前商业虚拟机都采用的一种算法。
接下来依次介绍以上提及的另外三种算法。
复制算法
- 把可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用尽后,把还存活着的对象『复制』到另外一块上面,再将这一块内存空间一次清理掉。
- 优点:每次都是对整个半区进行内存回收,无需考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。
- 缺点:每次可使用的内存缩小为原来的一半,内存使用率低。
有研究表明新生代中的对象98%是朝生夕死的,因此没必要按照1:1来划分内存空间,而是分为一块较大的Eden空间和两块较小的Survivor空间,在HotSpot虚拟机中默认比例为8:1:1。每次使用Eden和一块Survivor,回收时将这两块中存活着的对象一次性地复制到另外一块Survivor上,再做清理。可见只有10%的内存会被“浪费”,倘若Survivor空间不足还需要依赖其他内存(老年代)进行分配担保。
标记-清除算法
- 首先『标记』出所有需要回收的对象,然后统一『清除』所有被标记的对象。
- 是最基础的收集算法。
- 缺点:『标记』和『清除』过程的效率不高;空间碎片太多,『标记』『清除』之后会产生大量不连续的内存碎片,可能会导致后续需要分配较大对象时,因无法找到足够的连续内存而提前触发另一次GC,影响系统性能。
标记-整理算法
- 首先『标记』出所有需要回收的对象,然后进行『整理』,使得存活的对象都向一端移动,最后直接清理掉端边界以外的内存。
- 优点:即没有浪费50%的空间,又不存在空间碎片问题,性价比较高。
- 一般情况下,老年代会选择标记-整理算法。
7.HotSpot算法实现&垃圾回收器
接下来介绍如何在HotSpot虚拟机上实现对象存活判定算法和垃圾收集算法,并保证虚拟机高效运行。
枚举根节点
主流Java虚拟机使用的都是准确式GC,在执行系统停顿之后无需检查所有执行上下文和全局的引用位置,而是通过一些办法直接获取到存放对象引用的地方,在HotSpot中是通过一组称为OopMap的数据结构来实现的,完成类加载后会计算出对象某偏移量上某类型数据、JIT编译时会在特定的位置记录栈和寄存器中是引用的位置。这样GC在扫描时就可直接得知这些信息,并快速准确地完成GC Roots的枚举。
安全点(Sefepoint)
上述“特定的位置”被称为安全点,即程序执行时并非在所有地方都停顿执行GC,只在到达安全点时才暂停,降低GC的空间成本。
-
安全点的选定标准:可让程序长时间执行的地方,如方法调用、循环跳转、异常跳转等具有指令序列复用的特征。
-
使所有线程在最近的安全点上再停顿的方案:
- 抢先式中断(Preemptive Suspension):无需代码主动配合,在GC发生时把所有线程全部中断,若线程中断处不在安全点上就恢复线程,让它“跑”到安全点上。现在几乎没有虚拟机实现采用抢先式中断来暂停线程从而响应GC事件。
- 主动式中断(Voluntary Suspension):在GC要中断线程时不直接对线程操作,而是设置一个中断标志,让各个线程在执行时主动轮询它,当中断标志为真时就自己中断挂起。
安全区域(Safe Region)
安全点机制只能保证程序执行时,在不太长的时间内遇到可进入GC的安全点,但在程序不执行时(如线程处于Sleep或Blocked状态)线程无法响应JVM的中断请求,此时就需要安全区域来解决。
- 安全区域:引用关系不会发生变化的一段代码片段,在安全区域中的任意地方开始GC都是安全的,可看做是扩展的安全点。
- 执行过程:当线程执行到安全区域中的代码时就标识一下,如果这时JVM要发起GC就不用管被标识的线程;在线程要离开安全区域时检查系统是否已经完成了根节点枚举,若完成则线程可以继续执行,否则等待直到收到可以安全离开安全区域的信号为止。
到此只是简单介绍了HotSpot如何发起内存回收,而具体的回收动作是由虚拟机所采用的GC收集器决定的,通常虚拟机中往往不止有一种GC收集器,下图展示的是HotSpot虚拟机中存在的七种作用于不同分代(新生代、老年代)的收集器,其中被连线的两个收集器表示可以搭配使用。
以下是对比图,来源于文章JVM(HotSpot) 垃圾收集器
并行(Parallel) :多条垃圾收集线程并行工作,而用户线程仍处于等待状态。
并发(Concurrent) :垃圾收集线程与用户线程一段时间内同时工作,用户程序在继续运行,而垃圾收集程序运行于另一个CPU上。
内存分配与回收策略
对象的内存分配广义上是指在堆上分配,主要是在新生代的Eden区上,如果启动了TLAB,将按线程优先在TLAB上分配,少数情况下也可能会分配在老年代中。分配细节还是取决于所使用的GC收集器组合以及虚拟机中与内存相关的参数的设置。以下介绍几条普遍的内存分配规则。
- 对象优先在Eden分配:大多数情况下对象在新生代Eden区中分配,当Eden区没有足够空间进行分配时虚拟机将发起一次Minor GC。
新生代GC(Minor GC):发生在新生代的垃圾收集动作。较频繁、回收速度也较快。
老年代GC(Major GC/Full GC):发生在老年代的垃圾收集动作。出现Major GC经常会伴随至少一次的Minor GC。速度一般比Minor GC慢10倍以上。
- 大对象直接进入老年代:对于需要大量连续内存空间的Java对象(如很长的字符串以及数组),如果大于虚拟机设定的
-XX:PretenureSizeThreshold参数值将直接在老年代分配。这样做的目的是避免在Eden区及两个Survivor区之间发生大量的内存复制。 - 长期存活的对象将进入老年代:虚拟机会给每个对象定义一个年龄计数器,当对象在Eden出生并经过第一次Minor GC后仍存活且能被Survivor容纳的话,将被移动到Survivor空间中并将对象年龄设为1;当对象在Survivor区中每“熬过”一次Minor GC年龄就+1,直至增加到一定程度(默认为15岁,可通过
-XX: MaxTenuringThreshold设置)就会被晋升到老年代中。 - 动态对象年龄判定:为了能更好地适应不同程序的内存状况,虚拟机并不要求一定要达到
-XX: MaxTenuringThreshold设置值才能晋升到老年代,当Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,那么年龄大于或等于该年龄的对象可以直接进入老年代。 - 空间分配担保:在发生Minor GC之前虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,若是,说明可确保Minor GC是安全的,反之虚拟机会查看
-XX:HandlePromotionFailure设置值是否允许担保失败;若允许,会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小;若大于,将尝试进行一次Minor GC,若小于或者不允许担保失败,将改为进行一次Full GC。
解释:当大量对象在MinorGC后仍然存活的情况时,需要借助老年代进行分配担保,把Survivor无法容纳的对象直接进入老年代,但前提是老年代本身还有容纳这些对象的剩余空间,由于在完成内存回收之前无法预知实际存活对象,只好取之前每次回收晋升到老年代对象容量的平均大小值作为经验值,与老年代的剩余空间进行比较,从而决定是否进行Full GC来让老年代腾出更多空间。