1. JVM内存模型
以下为HotSpot JDK1.7 及以前JVM 内存结构:
1.1. 程序计数器 (线程私有)
线程计数器是一块很小的内存空间, 用来记录正在执行的虚拟机字节码指令的地址. 在多线程环境中, 一条线程从阻塞状态回到运行状态必须依赖程序计数器来恢复到正确的执行位置, 该内存区域也是Java 虚拟机规范中没有规定任何 OOM 情况的区域.
执行 native 本地方法时,程序计数器的值为空。原因是 native 方法是java 通过 jni 调用本地C/C++库来实现,非java字节码实现,所以无法统计
1.2. Java 虚拟机栈 (线程私有)
生命周期与线程相同, 主要存放基本数据类型数据和对象的引用.
可以通过 -Xss
这个虚拟机参数来指定每个线程的 Java 虚拟机栈内存大小,在 JDK 1.4 中默认为 256K,而在 JDK 1.5+ 默认为 1M
这个区域可能抛出以下异常:
- 当线程请求的栈深度超过最大值,会抛出 StackOverflowError 异常.
- 栈进行动态扩展时如果无法申请到足够内存,会抛出 OutOfMemoryError 异常.
1.3. 本地方法栈 (线程私有)
本地方法栈与 Java 虚拟机栈类似,它们之间的区别只不过是本地方法栈为本地方法服务, 该区域也会抛出 StackOverflowError 异常和 OutOfMemoryError 异常.
1.4. Java 堆 (线程共享)
所有对象实例都在这里分配内存,是垃圾回收的主要区域.
垃圾收集器基本都是采用分代收集算法,其主要的思想是针对不同类型的对象采取不同的垃圾回收算法.可以将堆分成两块:
- 新生代(Young Generation), 进一步可细分为 Eden: From Survivor:To Survivor = 8:1:1
- 老年代(Old Generation)
堆不需要物理上的连续内存,只要逻辑上是连续的即可, 并且可以动态增加其内存,增加失败会抛出 OutOfMemoryError 异常. 另外在堆中可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer, TLAB).
可以通过 -Xms 和 -Xmx 这两个虚拟机参数来指定一个程序的堆内存大小,第一个参数设置初始值,第二个参数设置最大值.
1.5. 方法区 (线程共享)
- 用于存放已被加载的类信息、常量、静态变量、即时编译器编译后的方法代码等数据.
- 和堆一样不需要物理上连续的内存,并且可以动态扩展,动态扩展失败一样会抛出 OutOfMemoryError 异常.
- 对这块区域进行垃圾回收的主要目标是对常量池的回收和对类的卸载,但是一般比较难实现. HotSpot 虚拟机把它当成永久代来进行垃圾回收, 但很难确定永久代的大小,因为它受到很多因素影响,并且每次 Full GC 之后永久代的大小都会改变,所以经常会抛出 OutOfMemoryError 异常.
- 方法区是一个 JVM 规范,永久代与元空间都是其一种实现方式.在 JDK 1.8 之后,为了更容易管理方法区,HotSpot虚拟机移除永久代,并把方法区移至元空间,它位于本地内存(Native Memory)中,而不是虚拟机内存中. 原来永久代的数据被分到了堆和元空间中.元空间存储类的元信息,字符串常量池放入堆中.
以下为HotSpot JDK1.8 后 JVM 内存结构:
1.6. 运行时常量池 (线程共享)
运行时常量池是方法区的一部分.Class 文件中的常量(编译期生成的字面量和符号引用)会在类加载后被放入这个区域. 除了在编译期生成的常量,还允许动态生成,例如 String 类的 intern().
1.7. 直接内存 (堆外内存)
在 JDK 1.4 中新引入了 NIO 类,它可以使用 Native 函数库直接分配堆外内存,然后通过 Java 堆里的 DirectByteBuffer 对象作为这块内存的引用进行操作.这样避免了在Java堆内存 和 Native堆内存来回拷贝数据,可以在一些场景中显著提高性能.
2. 垃圾查找算法
2.1. 新建对象内存分配方式
对象所需的内存大小在类加载完成后便可完全确定,为对象分配空间的任务等同于把一块确定大小的内存块从Java 堆中划分出来, 通常有两种分配方式:
- 指针碰撞: 适用于规整内存,用指针作为分界点指示器,一边为用过的内存,另一边为空闲内存
- 空闲列表: 内存不规整适用, 使用列表记录可用内存块, 在分配时从列表中找到一块足够大的空间划分给对象示例.
对象创建是非常频繁的,即使仅仅是修改一个指针指向的位置在线程并发环境下也不一定安全,故需要线程安全方案:
- CAS 和失败重试, 保证更新操作原子性.
- 把分配动作按照线程划分在不同空间中进行,使用线程本地分配缓冲(TLAB)作为分配空间,用完TLAB并分配新的TLAB时才需要同步锁定.(-XX:+UseTLAB 开启)
2.2. 对象是否存活
要进行垃圾回收,首先要做的就是确定哪些对象还活着,哪些已经不再被使用.对依然在被使用的对象进行回收是很严重的错误,会导致不可预知的问题.
2.2.1 引用计数法
使用计数器来记录引用,当对象增加一个引用时计数器加 1,引用失效时计数器减 1, 计数器值为0则表示对象不再被引用,可被回收.
- 优点:实现简单,判断效率高.
- 缺点:无法解决循环引用的问题.
2.2.2 可达性分析算法
以GC Roots 对象作为起始点, 从这些节点向下搜索,路径为引用链.当一个对象到GC Roots没有任何引用链相连的话,则证明此对象不可用,可以回收.
GC Roots 对象可包括以下几类:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象
- 本地方法栈中JNI 引用的对象
- 方法区中静态属性引用的对象
- 方法区中常量引用的对象
2.3 引用分类
2.3.1 强引用
强引用存在,不可回收被引用的对象。当 JVM 内存空间不足,JVM 宁愿抛出OutOfMemoryError运行时错误使程序异常终止,也不会靠回收具有强引用的“存活”对象来解决内存不足的问题
使用方式:
Object a = new Object();
- 1
2.3.2 软引用
被软引用关联的对象只有在内存不够的情况下才会被回收,即 JVM 会确保在抛出 OutOfMemoryError 之前,清理软引用指向的对象
使用方式:
Object a = new Object();
SoftReference<Object> sf = new SoftReference<Object>(a);
a = null; // 使对象只被软引用关联
- 1
- 2
- 3
2.3.3 弱引用
一旦发生垃圾回收,被弱引用关联的对象会立即被回收,也就是说它只能存活到下一次垃圾回收发生之前.
使用方式:
Object a = new Object();
WeakReference<Object> wf = new WeakReference<Object>(a);
a = null;
- 1
- 2
- 3
2.3.4 虚引用
不会对对象生命周期造成影响,也无法通过虚引用得到一个对象.对象设置虚引用的唯一目的是能在这个对象被回收时收到一个系统通知.
使用方式:
Object a = new Object();
PhantomReference<Object> pf = new PhantomReference<Object>(a, null);
a = null;
- 1
- 2
- 3
2.4 垃圾对象回收过程
一旦对象不可达, 则处于缓刑阶段,还需进行两次标记过程才能判断对象是否存活.
- 是否有必要执行finalize()方法. 当对象没有覆盖该方法或者该方法已经被虚拟机调用过,则没有必要执行.
- 有必要执行finalize()方法的对象会被置于F-Queue队列中,如果对象在自身finalize()方法中与引用链上任何一个对象建立关联,则会被移出即将回收的集合,如没有则被回收.
NOTE
:
自救只能成功一次,因为系统只会自动调用finalize()方法一次,对象下一次面临回收它的finalize()方法不会再执行.另外不建议手动调用该方法.
2.5 方法区的回收
因为方法区主要存放永久代对象,而永久代对象的回收率比新生代低很多,所以在方法区上进行回收性价比不高.这一块内存区域主要是对常量池的回收和对废弃无用的类的卸载.
类的卸载需要满足以下三个条件,并且满足了条件也不一定会被卸载:
- 该类所有的实例都已经被回收,此时堆中不存在该类的任何实例.
- 加载该类的 ClassLoader 已经被回收.
- 该类对应的 Class 对象没有在任何地方被引用,也就无法在任何地方通过反射访问该类方法.
3. 垃圾回收算法
3.1 标记-清除算法
标记存活的对象,完成后统一回收未被标记的对象.
- 缺点:
1.效率不高
2.会产生大量内存碎片,可能导致无法给大对象分配足够的连续内存从而提前触发垃圾收集动作.
3.2 标记-整理算法
标记存活对象,移动存活对象按照内存地址依次排列,然后将末端地址后的内存全部回收.
- 优点:
不会产生内存碎片- 缺点:
需要移动大量对象,处理效率比较低.
3.3 复制算法
将内存分为大小相等的两块,每次使用一块,当一块用完就将存活对象复制到另一块上面,然后将上次使用的那块内存一次清理掉.
一般采用这种收集算法回收新生代,但并不是划分为大小相等的两块,而是一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 和其中一块 Survivor.在回收时,将 Eden 和 Survivor 中还存活着的对象全部复制到另一块 Survivor 上,最后清理 Eden 和使用过的那一块 Survivor.
- 优点:
内存分配时不用考虑内存碎片等复杂情况,实现简单,运行高效.- 缺点:
内存缺失较大,每次只使用一半,代价太高.
3.4 分代收集算法
以上为HotSpot Java 虚拟机(JDK 1.8 以前)分代结构, 新生代与老年代属于Java 堆内存区域, 永久代属于方法区内存区域.
商业虚拟机一般采用分代收集算法,它根据对象存活周期将内存划分为几块,不同块采用适当的收集算法.通常将堆分为新生代和老年代:
- 新生代使用:复制算法
在Minor GC之前, to survivor区域保持清空, 对象保存在Eden和from survivor区. Minor GC运行时,Eden中的幸存对象会被复制到to Survivor(同时对象年龄会增加1),而from survivor区中的幸存对象会考虑对象年龄, 如果年龄没达到阈值,对象依然复制到to survivor中, 如果对象达到阈值那么将被移到老年代. 复制阶段完成后,Eden和From幸存区中只剩下死对象,执行垃圾回收即可.如果在复制过程中to幸存区被填满了,剩余的对象将被放到老年代.最后From survivor和to survivor会调换一下名字,下次Minor GC时,To survivor变为From Survivor.- 老年代使用:标记 - 清除 或者 标记 - 整理 算法
4. JVM内存分配策略
4.1 对象优先在Eden分配
大多数情况下,对象在新生代 Eden 上分配,如果启用了本地线程分配缓冲,将按线程优先在TLAB上分配.当 Eden 空间不足时,虚拟机将发起 Minor GC.
4.2 大对象直接进入老年代
大对象是指需要连续内存空间的对象,最典型的大对象是那种很长的字符串以及数组.
经常出现大对象容易导致内存还有不少空间的时候, 就提前触发垃圾收集以获取足够的连续空间分配给大对象.
- -XX:PretenureSizeThreshold, 大于此值的对象直接在老年代分配,避免在 Eden 和 Survivor 之间的大量内存复制.
4.3 长期存活的对象将进入老年代
虚拟机为对象定义年龄(Age)计数器,对象在 Eden 出生并经过 Minor GC 依然存活,将移动到 Survivor 中,年龄就增加 1 岁,增加到一定年龄(默认配置为15,在对象头中用4 bit存储,故对象最大年龄就是15)则晋升到老年代中.
- -XX:MaxTenuringThreshold 用来定义年龄的阈值.
4.4 动态对象年龄判定
虚拟机并不绝对要求对象的年龄达到 MaxTenuringThreshold
才能晋升老年代,如果在 Survivor 中相同年龄所有对象大小的总和大于 Survivor 空间的一半, 则年龄大于或等于该年龄的对象可以直接进入老年代,无需满足 MaxTenuringThreshold的年龄要求.
- 需注意另一个关键参数
-XX:TargetSurvivorRatio
:目标存活率,默认为50%。如果年龄1的占用了33%,年龄2的占用了33%,累加和超过默认的TargetSurvivorRatio(50%),年龄2和年龄3的对象都要晋升到老年代
4.5 空间分配担保
- Eden 空间不足发生 Minor GC 之前,虚拟机先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果条件成立的话,那么 Minor GC 可以确认是安全的.
- 如果不成立的话虚拟机查看 HandlePromotionFailure的值是否允许担保失败(
JDK 1.6之后默认 -XX:HandlePromotionFailure = true
) ,如果 HandlePromotionFailure 的值不允许冒险,那么就要进行一次 Full GC.如果允许则检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于将尝试进行Minor GC,否则进行 Full GC
Full GC 触发条件
- 调用 System.gc()
只是建议虚拟机执行 Full GC,但是虚拟机不一定真正去执行.- 老年代空间不足
老年代空间不足的常见场景为前文所讲的大对象直接进入老年代、长期存活的对象进入老年代等.为了避免以上原因引起的 Full GC,应当尽量不要创建过大的对象以及数组.除此之外,可以通过-Xmn
虚拟机参数调大新生代的大小,让对象尽量在新生代被回收掉,不进入老年代.另外也可以通过 -XX:MaxTenuringThreshold 调整对象进入老年代的年龄,让对象在新生代多存活一段时间.- 空间分配担保失败
4.6 新生代为什么需要两个Survivor (From, To)区域
- 如果没有Survivor区,Eden区每进行一次Minor GC,存活的对象就会被送到老年代,老年代很快被填满,触发Major GC. Survivor区作为缓冲,可以减少被送到老年代的对象,进而减少Full GC的发生.因为Survivor的预筛选保证只有经历15次 Minor GC 还能在新生代中存活的对象,才会被送到老年代.
- 新建的对象在Eden中分配,经历一次Minor GC 后 Eden中的存活对象就被移动到第一块
survivor space From
,Eden清空.等Eden区再次填满, 就再触发一次Minor GC,Eden和 From 中的存活对象又被复制送入第二块survivor space To
. 复制算法保证了 To 中来自 From 和Eden两部分的存活对象占用连续的内存空间,避免了碎片化的发生.
5. CMS与G1垃圾回收器
虚拟机为了分析对象引用关系的准确性,会停顿所有Java执行线程(Stop The Word),从而造成GC停顿.
5.1 CMS 垃圾收集器
CMS(Concurrent Mark Sweep,-XX:+UseConcMarkSweepGC
)基于标记-清除算法,以获取最短回收停顿时间为目标,具有并发收集,低停顿的特点,通常用于老年代垃圾回收.
5.1.1 CMS 垃圾收集流程
- 初始标记:仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快,需要停顿(STW).
- 并发标记:进行 GC Roots Tracing 的过程, 它在整个回收过程中耗时最长,不需要停顿.
- 重新标记:为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,需要停顿(STW).
- 并发清除:不需要停顿.
整个过程中耗时最长的 并发标记 和 并发清除 收集器线程可以和用户线程一起工作,所以从总体上来说CMS的内存回收过程是与用户线程一起并发执行的.
5.1.2 CMS 的缺点
- 对CPU 资源影响显著.会占用部分CPU资源(启用线程数为 (CPU+3)/4)导致程序运行变慢,其低停顿时间是以牺牲吞吐量(吞吐量=运行用户代码时间/(运行用户代码时间+垃圾回收时间))为代价的.
- 无法处理浮动垃圾.浮动垃圾是指并发清除阶段由于用户线程继续运行而产生的垃圾,这部分垃圾只能到下一次 GC 时才能进行回收,因为标记已经结束了. 由于浮动垃圾的存在,因此需要预留出一部分内存,意味着 CMS 收集不能像其它收集器那样等待老年代快满的时候再回收.如果预留的内存不够存放浮动垃圾, 就会出现 Concurrent Mode Failure(老年代空间不足便会报 Concurrent Mode Failure 错误,并触发 Full GC) ,这时虚拟机将临时启用 Serial Old 来替代 CMS.
- 标记 - 清除算法导致大量空间碎片.会出现老年代空间剩余,但无法找到足够大连续空间来分配当前对象,不得不提前触发一次 Full GC.
5.1.3 CMS 的优化
- -XX:+UseCMSCompactAtFullCollection,在CMS收集器顶不住要进行FullGC时候开启内存碎片的合并整理过程,但是会加长停顿时间.
- -XX:CMSFullGCsBeforeCompaction,表示用于设置执行多少次不压缩的Full GC后,跟着来一次带压缩的.
5.2 G1 垃圾收集器
G1(Garbage-First,-XX:+UseG1GC
) 基于标记-整理算法, G1可以不需要其他收集器配合就能独立管理整个GC堆,但是还是保留了分代的概念,只不过G1把新生代,老年代的物理空间划分取消了. 另外相比CMS, G1有以下优点:
- G1是一个有整理内存过程的垃圾收集器,不会产生很多内存碎片.
- G1的Stop The World(STW)更可控,G1在停顿时间上添加了预测机制,用户可以指定期望停顿时间.
5.2.1 G1 的内存空间结构
HotSpot虚拟机中传统的GC收集器(serial,parallel,CMS)都把heap分成固定大小物理上连续的三个空间:新生代, 老年代和永久代. G1 却采用了一种全新的内存布局,在G1中Java 堆被分成一块块大小相等的heap region(参数-XX:G1HeapRegionSize 可设定Region大小,取值范围从1M到32M,且是2的指数),这些region在逻辑上是连续的.每块region都会被打上唯一的分代标志(eden,survivor,old).在逻辑上,eden regions构成Eden空间,survivor regions构成Survivor空间,old regions构成了old 空间.
G1中有一种特殊的区域,叫Humongous区域. 如果一个对象占用的空间超过了分区容量50%以上,G1收集器就认为这是一个巨型对象.这些巨型对象默认直接会被分配在年老代,但是如果它是一个短期存在的巨型对象,就会对垃圾收集器造成负面影响.为了解决这个问题,G1划分了一个Humongous区,它用来专门存放巨型对象.如果一个H区装不下一个巨型对象,那么G1会寻找连续的H分区来存储.为了能找到连续的H区, 有时候不得不启动Full GC.
5.2.2 G1中的GC收集
G1保留了Young GC并加上了一种全新的Mixed GC用于收集老年代. G1中没有Full GC,G1中的Full GC是采用serial old Full GC.
5.2.2.1 G1 Young GC
5.2.2.1.1 G1 Young GC 过程
Young GC主要是对Eden区进行GC,它在Eden空间耗尽时会被触发.
- 在这种情况下,Eden空间的存活对象移动到Survivor空间中.
- 如果Survivor空间不够,Eden空间的部分对象会直接晋升到年老代空间.
- Survivor区的对象移动到新的Survivor区中,也有部分对象晋升到老年代空间中.
- 最终Eden空间的内存为空, 此时会有一次 stop the world暂停,用于计算出 Eden大小和 survivor 大小.用于下次young GC.这种做法使得调整各代区域的大小变得很容易,根据需求可以动态调整年轻代.
5.2.2.1.2 G1 Young GC 不需要扫描整个老年代
- CMS中使用了Card Table的结构,里面记录了老年代对象到新生代引用. Card Table的结构是一个连续的byte[]数组,扫描Card Table的时间比扫描整个老年代的代价要小很多.
- G1也参照了这个思路,不过采用了一种新的数据结构 Remembered Set 简称Rset. RSet记录了其他Region中的对象引用本Region中对象的关系,属于points-into结构(谁引用了我的对象).而Card Table则是一种points-out(我引用了谁的对象)的结构,每个Card 覆盖一定范围的Heap(一般为512Bytes).
- G1的RSet是在Card Table的基础上实现的:每个Region会记录下别的Region有指向自己的指针,并标记这些指针分别在哪些Card的范围内.这个RSet其实是一个Hash Table,Key是别的Region的起始地址,Value是一个集合,里面的元素是Card Table的Index. 每个Region都有一个对应的Rset.
在做YGC的时候,只需要选定新生代 region的RSet作为根集,这些RSet记录了old->young的跨代引用,避免了扫描整个老年代. 而Mixed gc的时候,老年代 region中记录了old->old的RSet,young->old的引用由扫描全部新生代region得到,这样也不用扫描全部old generation region.所以RSet的引入大大减少了GC的工作量.
5.2.2.2 G1 Mix GC
G1中的MIXGC选定所有新生代里的Region, 外加根据全局并发标记统计得出收集收益高的若干老年代Region,在用户指定的开销目标范围内尽可能选择收益高的老年代Region进行回收.所以MIXGC回收的内存区域是新生代+老年代. 根据参数 -XX:InitiatingHeapOccupancyPercent,当老年代大小占整个堆大小百分比达到该阈值时(默认 45%),会触发一次mixed gc.
5.2.2.2.1 全局并发标记(global concurrent marking)
-
Initial Mark 初始标记
Initial Mark初始标记是一个STW事件,其完成工作是标记GC ROOTS 直接可达的对象,并将它们的字段压入扫描栈(marking stack)中等到后续扫描.因为 STW,所以通常YGC的时候借用YGC的STW顺便启动Initial Mark,也就是启动全局并发标记,全局并发标记与YGC在逻辑上独立. -
Root Region Scanning 根区域扫描
根区域扫描是从Survior区的对象出发,标记被引用到老年代中的对象,并把它们的字段在压入扫描栈(marking stack)中等到后续扫描.与Initial Mark不一样的是,Root Region Scanning不需要STW与应用程序是并发运行.Root Region Scanning必须在YGC开始前完成. -
Concurrent Marking 并发标记
不需要STW.不断从扫描栈取出引用递归扫描整个堆里的对象.每扫描到一个对象就会对其标记,并将其字段压入扫描栈.重复扫描过程直到扫描栈清空.Concurrent Marking 可以被YGC中断. -
Remark 最终标记
STW操作.在完成并发标记后,每个Java线程还会有一些剩下的SATB write barrier记录的引用尚未处理.这个阶段就负责把剩下的引用处理完,同时这个阶段也进行弱引用处理(reference processing). -
Cleanup 清除
STW操作,清点出有存活对象的Region和没有存活对象的Region(Empty Region).
STW操作,更新Rset.
Concurrent操作,把Empty Region收集起来到可分配Region队列.
5.2.2.2.2 MIXGC 回收
经过全局并发标记, G1 就知道哪些Region有存活的对象, 并将那些完全可回收的Region(没有存活对象)收集起来加入到可分配Region队列,实现对该部分内存的回收. 对于有存活对象的Region,G1会根据统计模型找处收益最高、开销不超过用户指定的上限的若干Region进行对象回收.这些选中被回收的Region组成的集合就叫做collection set 简称Cset.
- MIXGC中的Cset是选定所有young gen里的region,外加根据全局并发标记统计得出收集收益高的若干old gen region.
- 在YGC中的Cset是选定所有young gen里的region,通过控制young gen的region个数来控制young GC的开销.
YGC与MIXGC都是采用多线程复制清除,整个过程会STW.G1的低延迟原理在于其回收的区域变得精确并且范围变小了.
6. JVM 常见参数设置
- -XX:+< option > '+'表示启用该选项
- -XX:-< option > '-'表示关闭该选项
- -Xmx4g:堆内存最大值为4GB.
- -Xms4g:初始化堆内存大小为4GB .
- -Xmn1200m:设置年轻代大小为1200MB.增大年轻代后,将会减小年老代大小,此值对系统性能影响较大,Sun官方推荐配置为整个堆的3/8.
- -Xss512k:设置每个线程的堆栈大小。JDK5.0以后每个线程堆栈大小为1MB,以前每个线程堆栈大小为256K.在相同物理内存下,减小这个值能生成更多的线程,但是操作系统对一个进程内的线程数还是有限制的,不能无限生成,经验值在3000~5000左右.
- -XX:NewRatio=4:设置年轻代(包括Eden和两个Survivor区)与年老代的比值(除去持久代).设置为4,则年轻代与年老代所占比值为1:4,年轻代占整个堆栈的1/5
- -XX:SurvivorRatio=8:设置年轻代中Eden区与Survivor区的大小比值.设置为8,则两个Survivor区与一个Eden区的比值为2:8,一个Survivor区占整个年轻代的1/10
- -XX:PermSize=100m:初始化永久代大小为100MB.
- -XX:MaxPermSize=256m:设置持久代大小为256MB.
- -XX:MaxTenuringThreshold=15:设置对象晋升至老年代年龄阈值.