深入理解JVM之内存管理
一、内存结构
深入理解JVM之内存管理1. 方法区:
存放类的信息(名称、修饰符等)、类中的静态变量、类中final型常量、类中的Field信息,类中的方法信息。方法区是全局共享的,在一定条件下也会被GC,当方法区要使用的内存超过其允许的大小时,会抛出OutOfMemory。
JVM中这块区域对应Permanet Generation,又称为持久代。默认最小为16MB,最大为64MB,可通过-XX:PermSize及-XX:MaxPermSize来指定最小值和最大值。
2. 堆:
存放对象实例及数组值,大部分new创建的对象的内存都在此分配,这里也是GC最频繁的地方。32位系统最大为2G,64位则没限制,大小可通过-Xmx和-Xms来指定,为了避免在运行时对heap的调整,通常将-Xmx和-Xms的值设成一样。
为了让内存回收更加高效,JDK1.2开始将堆区采用分代回收,不同的区域采用不同的回收算法。
-
新生代:大多数new对象都在此分配内存,新生代由Eden、两个存活区(survivor space)构成,可通过-Xmn来指定新生代的大小,还可以通过-XX:SurvivorRatio(在Parallel Scavenge中是通过-XX:InitialSurvivorRatio设置)来调整Eden、存活区的大小比例,默认是8:1。
-
老年代:用于存放新生代中经过多次垃圾回收任然存活的对象,如缓存的对象,新建的对象也有可能在老年代上直接分配,这种情况分两种,一种是大对象,直接在老年代分配,可以通过-XX:PretenureSizeThreshold指定大小阈值,但此参数在新生代采用Parallel Scavenge GC时无效,Parallel Scavenge GC会根据运行状况决定什么对象直接在老年代分配;另一种在老年代直接分配的是大数组,其数组中无引用外部对象。老年代的大小为-Xmx减去-Xmn
3. 本地方法栈:
用于支持native方法的执行,存储每个native方法调用的状态,在Hotspot中本地方法栈和JVM方法栈是同一个。
4. PC寄存器和JVM方法栈:
每个线程都会创建PC寄存器和JVM方法栈,PC寄存器占用的可能是CPU寄存器或操作系统内存,JVM方法栈占用操作系统内存,JVM方法栈为线程私有,其内存分配高效,当方法运行完毕时,其对应的栈帧所占用的内存也会被自动释放。当JVM方法栈空间不足时,会抛出StackOverflowError,可以通过-Xss来指定方法栈的大小,如果不出现无穷递归,栈的深度不会太大,一般配置1M够矣。
二、内存分配:
主要在堆上分配,堆为所有线程共享,因此在堆上分配内存时需要加锁,这导致创建对象开销比较大,当堆空间不足时,会触发GC,如果GC后任然不足,则抛OutOfMemory。
为了提升内存分配效率,JVM会为新创建的线程在新生代的Eden上分配一块独立的空间,称为TLAB(Thread Local Allocation Buffer),其大小由JVM根据运行情况计算而来,可通过-XX:TLABWasteTargetPrecent来设置TLAB可占用Eden的百分比,默认为1%。在TLAB上分配不需加锁,因此效率高,JVM在给线程中的对象分配内存时会尽量在TLAB上分配,如果对象过大或TLAB空间用完,则在堆上分配。
除了在堆上分配及在TLAB上分配,还有一种基于逃逸分析直接在栈上分配的情况(方法栈运行结束后自行销毁,不需要考虑GC)。
三、内存回收
1. 收集器
-
引用计数收集器:采用计数器来判断对象是否被引用,当计数器为0时,说明此对象已经不再被引用,可以被回收。引用计数对于循环引用的场景没办法回收,所以在Sun JDK实现GC时也未采用这种方式。
-
跟踪收集器:全局记录数据的引用状态,基于一定的条件触发(定时、空间不足)执行时需要从根集合来扫描对象的引用关系。
2. 收集算法
主要有复制、标记-清除、标记-压缩三种实现算法。
-
复制:从根集合扫描出存活的对象,并将它们复制到一块新的空间。当回收区域存活对象较少时,复制算法比较高效,其成本是开辟一块空内存以及对象的移动。
-
标记-清除:从根集合扫描出存活的对象,并对它们进行标记,之后清空未标记的对象。标记-清除动作不需要对象移动,仅对不存活的对象处理,在空间中存活对象较多的情况下较为高效,但会产生内存碎片。
-
标记-压缩:前面动作和标记-清除一样,但是清除不存活的对象后,会将存活对象都往空闲的空间移动,并更新引用指针。成本相对较高,但避免了内存碎片。
3. 收集依据(GC Roots)
判断对象是否需要被回收使用的是可达性分析法,Reachability Analysis,通过GC Roots开始所搜,可以被引用或者间接引用到的则是可达的。GC Roots有以下:
- 虚拟机栈中的本地变量中引用的对象;
- 方法区中类静态属性引用的对象;
- 方法区中常量引用的对象;
- native方法引用的对象。
方法区的类对象静态数据和常量数据作为GC Roots会长期存在,并且在类加载的时候就可以确定内存位置。
获取GC Roots最困难的部分在于如何快速找到JVM栈中局部变量所引用的对象。JVM栈里的引用类型数据是GC Roots的重要组成部分,找出栈上的引用是根枚举(root enumeration)中重要的一步。
3.1 保守式GC
最初的虚拟机实现不记录栈中的引用类型数据,无法区分内存里某个位置上的数据到底应该解读为引用类型还是整型还是别的什么。这种条件下,实现出来的GC就会是“保守式GC(conservative GC)”。在进行GC的时候,JVM开始从一些已知位置(例如说JVM栈)开始扫描内存,扫描的时候每看到一个数字就看看它“像不像是一个指向GC堆中的指针”。这里会涉及上下边界检查(GC堆的上下界是已知的)、对齐检查(通常分配空间的时候会有对齐要求,假如说是4字节对齐,那么不能被4整除的数字就肯定不是指针)之类的。
保守式GC的好处是实现简单,但是缺点很明显:
-
有些对象已经死了,但有疑似指针指向它们,使它们逃过GC的收集。这对程序语义来说是安全的,因为所有应该活着的对象都会是活的。但对内存占用量来说就不是件好事,总会有一些已经不需要的数据还占用着GC堆空间。
-
由于不知道疑似指针是否真的是指针,所以它们的值都不能改写。而移动对象就意味着要修正指针,换言之,对象就不可移动了。所以保守式GC通常使用不移动对象的算法,例如mark-sweep。
3.2 半保守式GC
和保守式GC一样,半保守式GC在栈上不记录引用类型信息,而在对象上记录类型信息。这样的话,扫描栈的时候仍然会跟保守式GC过程一样,但扫描到GC堆内的对象时因为对象带有足够类型信息了,JVM就能够判断出在该对象内什么位置的数据是引用类型了。这种是“半保守式GC”,也称为“根上保守(conservative with respect to the roots)”。
为了支持半保守式GC,运行时需要在对象上带有足够的元数据。如果是JVM的话,这些数据可能在类加载器或者对象模型的模块里计算得到,但不需要JIT编译器的特别支持。
由于半保守式GC在堆内部的数据是准确的,所以它可以在直接使用指针来实现引用的条件下支持部分对象的移动,方法是只将保守扫描能直接扫到的对象设置为不可移动(pinned),而从它们出发再扫描到的对象就可以移动了。
半保守方式的GC既可以使用mark-sweep,也可以使用移动部分对象的算法,例如Bartlett风格的mostly-copying GC。
3.3 准确式GC
现在主流的虚拟机基本都采用准确式的GC,“准确”是指什么?关键就是“类型”,也就是说给定某个位置上的某块数据,要能知道它的准确类型是什么,这样才可以合理地解读数据的含义;GC所关心的含义就是“这块数据是不是指针”。 要实现这样的GC,JVM就要能够判断出所有位置上的数据是不是指向GC堆里的引用,包括活动记录(栈+寄存器)里的数据。
有以下几种办法:
- 让数据自身带上标记(tag)。这种做法在JVM里不常见,但在别的一些语言实现里有体现,就不详细介绍了。
- 让编译器为每个方法生成特别的扫描代码。我还没见过JVM实现里这么做的,虽说在别的语言实现里有见过。
- 从外部记录下类型信息,存成映射表。现在三种主流的高性能JVM实现,HotSpot、JRockit和J9都是这样做的。其中,HotSpot把这样的数据结构叫做OopMap,JRockit里叫做livemap,J9里叫做GC map。Apache Harmony的DRLVM也把它叫GCMap。
3.4 OopMap(Ordinary Object Pointer Map)
要实现这种功能,需要虚拟机里的解释器和JIT编译器都有相应的支持,由它们来生成足够的元数据提供给GC,这个元数据就是OopMap,它是实现准确式GC的关键。
虚拟机有2个场景会生成OopMap:
- 类加载,在HotSpot中,对象的类型信息里有记录自己的OopMap,记录了在该类型的对象内什么偏移量上是什么类型的数据。所以从对象开始向外的扫描可以是准确的;
- JIT编译,在 HotSpot 的 JIT 编译过程中,同样会插入相关指令来标明哪些位置存放的是对象引用等,这样在 GC 发生时,HotSpot 就可以直接扫描 OopMap 来获取对象引用的存储位置,以保证从栈上扫描是准确的。
每个方法可能会有好几个OopMap。JIT编译时,根据SafePoint把一个方法的代码分成几段,每一段代码一个OopMap,作用域自然也仅限于这一段代码。循环中引用多个对象,肯定会有多个变量,编译后占据栈上的多个位置,那这段代码的OopMap就会包含多条记录。
对Java线程中的JNI方法,它们既不是由JVM里的解释器执行的,也不是由JVM的JIT编译器生成的,所以会缺少OopMap信息。那么GC碰到这样的栈帧该如何维持准确性呢?
HotSpot的解决方法是:所有经过JNI调用边界(调用JNI方法传入的参数、从JNI方法传回的返回值)的引用都必须用“句柄”(handle)包装起来。JNI需要调用Java API的时候也必须自己用句柄包装指针。在这种实现中,JNI方法里写的“jobject”实际上不是直接指向对象的指针,而是先指向一个句柄,通过句柄才能间接访问到对象。这样在扫描到JNI方法的时候就不需要扫描它的栈帧了——只要扫描句柄表就可以得到所有从JNI方法能访问到的GC堆里的对象。
但这也就意味着调用JNI方法会有句柄的包装/拆包装的开销,是导致JNI方法的调用比较慢的原因之一。
3.5 SafePoint(安全点)
程序并不能在任意地方都可以停下来进行GC,只有到达安全点时才能暂停。此外,OopMap信息的录入也发生在安全点。安全点的选择不能太少,否则GC等待时间太长;也不能太多,否则会增大运行负荷。其选择的原则为“是否具有让程序长时间执行的特征”,如方法调用,循环等等。具体安全点有下面几个:
- 循环的末尾 (防止大循环的时候一直不进入safepoint,而其他线程在等待它进入safepoint)
- 方法返回前
- 调用方法的call之后
- 抛出异常的位置
安全点暂停线程运行的手段有两种:抢先式中断和主动式中断。
3.6 抢先式中断
不需要线程的执行代码主动配合,在GC发生时,首先把所有线程全部中断,如果发现有线程中断的地方不在安全点上,就恢复线程,让它跑到安全点上再暂停。不过现在的虚拟机几乎没有采用此算法的。
3.7 主动式中断
GC需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志,各个线程执行时去主动轮询查询此标志,发现中断标志为真时就中断自己挂起。轮询标志的地方和安全点是重合的,另外再加上创建对象需要分配内存的地方。
4. JVM可用GC收集器
不同的区域采用不同的GC收集器4.1 新生代可用GC
新生代中的对象通常存活时间较短,因此采用复制算法。上面提到,复制时将存活对象移动到一块新的区域,这个区域就是新生代中的其中一个survior space。对新生代的回收又叫Minor GC。
当新生代不够空间来创建对象时,就会触发Minor GC。新生代 GC发生得非常频繁。一般来说, GC过程是这样的:首先枚举根节点。根节点有可能在新生代中,也有可能在老年代中。这里由于Minor GC只收集新生代,所以没有必要对位于老年代的 GC Roots 做全面的可达性分析。但问题是,确实可能存在位于老年代的某个 GC Root,它引用了新生代的某个对象,这个对象你是不能清除的。那怎么办呢?与OopMap一样,用空间换时间,用一个数据结构保存这种引用信息,这样在只需要在新生代上利用这两个东西就能完成可达性的分析。RememberedSet记录的是新生代的对象被老年代引用的关系。
所以“新生代的GC Roots” + “ RememberedSet 存储的内容”,才是新生代收集时真正的 GC Roots 。
关于OopMap 和 Remembered Set
总的来说:
- OopMap用于枚举根节点,Remembered Set 用来作可达性分析。
- OopMap避免了全栈扫描,Remembered Set避免了全堆扫描。
对象引用关系
除了默认的强引用,还有软引用、弱引用、虚引用
A a = new A(),这就是强引用,这种对象只有主动释放引用后才会被GC
软引用,采用SoftReference来实现,这种对象会在JVM内存不足时被回收,因此软引用很适合用于实现缓存,另外当GC认为扫描到的软引用对象不经常使用时,也会被回收。
弱引用:采用WeakReference来实现,这种对象在没有强引用后,会被回收。
串行GC(Serial GC):
在Minor GC后存活的对象并不是直接进入老年代,只有经历过几次Minor GC后任然存货的对象才会进入老年代。这个在Minor GC中存活的次数在串行、ParNew方式时可通过-XX:MaxTenuringThreshhold设置,在Parallel Scavenge则由hotspot根据运行状况来句定。当存活区已满,剩下的存活对象则直接进入老年代。
Serial GC在整个扫描、复制过程中均采用单线程,适用于单CPU、新生代空间较小、及对暂停时间要求
并行回收GC(Parallel Scavenge)
上面已经提过,在Parallel Scavenge中,Eden,survivor的比例通过-XX:InitialSurvivorRatio来配置,默认也为8,不过,在jdk6以后,也能通过-XX:SurvivorRatio来配置了。
在启动时Eden,survivor的比例按照配置划分,但是在运行一段时间以后,并行回收GC会根据Minor GC的频率,消耗的时间来动态调整比例,可以通过-XX:-UseAdaptiveSizePolicy来固定比例
在PS GC中不是通过-XX:PretenureSizeThreshold来决定对象是否在老年代直接分配的,而是当分配内存时,如果Eden空间不够,而且对象大小也大于等于Eden的一半,则直接在老年代分配。
PS GC也是采用复制算法回收垃圾,但区别于Serial GC的地方在于,其扫描和复制时均采用多线程方式来进行,在多CPU机器上效率更高,适合对暂停时间要求较短的应用上。PS GC也是C2级别上默认采用的GC方式。
并行GC(ParNew)
ParNew在SurvivorRatio的方式上和串行GC一样。ParNew与Ps GC的区别在于ParNew必须配合老年代使用CMS GC,因为CMS GC在对老年代回收时,有些过程是并发进行的,如此时发生Minor GC,需要进行相应的处理,而PS GC是没有做这些处理的,也正是这个原因,ParNew不可与并行的老年代GC同时使用。
在配置老年代使用CMS GC的情况下,新生代默认采用ParNew
同样,当Eden空间不足时,会触发Minor GC
综上:新生代各GC器的区别在于:
- -XX:PretenureSizeThreshold,关于大对象在老年代分配,Serial是根据此值判断,而Parallel Scavenge实在分配内存时判断,如果Eden不够,且对象大于Eden的一半,则直接在老年代分配
- 关于晋升老年代的条件,Serial、ParNew是设置-XX:MaxTenuringThreshhold,熬过了一定次数的GC则晋升老年代,如果survivor已满,则直接晋升老年代。而Parallel Scavenge则是根据运行状况来决定。
- ParNew的特点是必须搭配老年代的CMS GC使用
综上,主要区别还是与老年代有关
Minor GC的触发方式
- Eden上分配内存时空间不足,触发Minor GC
- System.gc显示调用也可以触发Minor GC
4.2 老年代、持久代可用GC
串行、并行、并发
主要讲讲并发CMS GC:其它几种回收都是stop the world,造成应用暂停,所以提供了CMS GC,它的大部分动作都能与应用并发执行。CMS GC采用Mark-Sweep
CMS分4个步骤:
- 初始标记:stop the world,也是从根集合出发,扫描
- 并发标记:并行运行,标记上一步存活对象的引用的对象
- 再次标记:stop the world,因为上一步中可能会有新对象创建,或者对象引用改变,所以要对这些对象进行扫描
- 并发收集
CMS GC触发方式有2种
- 如果老年代使用CMS,则可设置CMSInitiatingOccupancyFraction百分比,当老年代空间使用达到某个值时,触发
- 还一种是JVM自动触发,基于之前GC的频率以及老年代的增长趋势来决定
4.3 Full GC
除CMS GC之外,当老年代、持久代发生GC时,其实是对新生代、老年代、持久代都进行GC,因此又叫Full GC。
以下情况会触发Full GC:
- 老年代空间不足(新生代晋升、直接老年代创建大对象),会触发Full GC,若GC后空间还不够,则抛OutOfMemory:java heap space
- Permanent Generation空间满,该区域存放class信息,当空间满,则触发Full GC,如果GC后任然不够,则抛OutOfMemory:PermGen space
- CMS GC时出现promotion failed和concurrent mode failure。在执行Minor GC(对应ParNew)时,晋升老年代的对象过多;或者执行CMS GC时同时有对象要放入老年代,而老年代空间有不足,这两种CMS情况会触发Full GC
- 统计得到Minor GC晋升到老年代的平均大小大于老年代空间,在进行Minor GC时,如果之前统计得到Minor GC晋升到老年代的平均大小大于老年代剩余空间,则触发Full GC。