如何判断Java对象是否存活?
1. 引用计数法
给每个对象设置一个计数器,有地方引用这个对象计数器就+1,引用失效计数器-1。当对象的引用计数器为0的时候就认为该对象不被使用了。
该算法简单,但是有缺陷,不能解决循环依赖问题(a依赖b,b依赖a,但是a和b不被其他对象依赖了,a和b的引用计数器都不是0,不能被回收)
2. 根搜索法
把一些对象设置为根节点,当任何一个根节点都不可达某个对象的时候,认为这个对象就可以回收了。这个思想是垃圾回收算法的思想基础。
可以作为根对象的对象
- Java虚拟机栈(栈帧中的本地变量表)中引用的对象
- 方法区中的类静态成员引用的对象
- 方法区中的常量引用的对象
- 本地方法栈中JNI(Native方法)引用的对象
作为GC Roots的节点主要在全局性的引用(常量或者类静态属性)与执行上下文(栈帧中的本地变量)中。虚拟机栈、本地方法栈都是局部变量,某个方法执行完,这些局部使用的对象可以被回收。
垃圾收集算法
标记-清除算法
适用于老年代
该算法分为两步
- Marking(标记):遍历所有可达的对象,并在本地内存(native)中分门别类几下。
- Sweeping(清除):遍历堆中的所有对象,对那些没有被标记的对象进行清除。
JVM中包含了多种GC算法,如Parallel Scavenge(并行清除),Parallel Mark+Copy(并行标记+复制)以及CMS,他们在实现上略有不同,但理论上都采用了以上两个步骤。
标记清除算法最重要的优势,就是不再因为循环引用而导致内存泄漏。
为了清楚的标记和清除上百万对象,就需要暂停应用程序的所有线程,这样对象间的引用关系就不会发生变化。这种情况叫做STW停顿(Stop The World)。很多原因会触发STW,垃圾收集是最主要的原因。
除了GC,其他触发STW的VM Operation:
- JIT相关,比如Code deoptimization, Flushing code cache
- Class redefinition (e.g. javaagent,AOP代码植入的产生的instrumentation);
- Biased lock revocation 取消偏向锁
- Various debug operation (e.g. thread dump or deadlock check);
在启动参数上加上-XX:+PrintGCApplicationStoppedTime 就会把JVM的停顿时间打印出来,
停顿原因:-XX:+PrintSafepointStatistics -XX:PrintSafepointStatisticsCount=1
缺点:
- 执行效率不稳定,如果Java堆中包含大量对象,其中大部分需要时回收的,这时必须进行大量标记和清除动作,导致标记和清除这两个过程随着对象数量增长时间边长。
- 内存空间碎片化问题,标记、清除之后会产生大量的不连续的内存碎片,可能会导致需要分配较大对象时无法找到足够大的连续的内存而不得不提前触发一个GC动作。
标记-复制算法-适用于新生代
简称为复制算法。
将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块用完了就将还存活的对象复制到另一块上,然后把已使用过的内存空间一次清除掉。如果时内存中多数对象是存活的,这种算法将会产生大量内存间复制的开销,但对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也不用考虑空间碎片问题,只要移动堆顶指针按顺序分配就行。简单高效,但是可用内存会缩小到原来的一半。
标记-整理算法-适用于老年代
该算法也分为两个阶段:标记阶段和压缩阶段。标记阶段和标记-清楚算法一样,压缩阶段:将所有的未标记的对象移动到空间的一端,然后清除边界之外的所有空间,解决了内存不连续,碎片过多的问题。
JVM中的引用是一个抽象的概念,如果GC移动某个对象,就会修改(堆和栈中)所有指向该对象的引用。
是否移动回收后的对象是一项优缺点并存的风向决策:
移动存活对象,尤其是老年代这种每次回收都有大量对象存活区域,移动存活对象并更新所有引用这些对象的地方会是一种极为负重的操作。而且这种对象移动操作需要STW才能进行。
不移动对象内存分配时会更复杂,垃圾收集的停顿时间会更短。
从整个程序的吞吐量来看,移动对象会更划算。
分代收集算法
分代假设:
- 大部分新生对象很快无用
- 存活较长时间的对象,可能存活更长时间
根据对象存活周期的不同将内存划分为几同区域,不同策略处理。一般将堆分为新生代和老年代。新生代中每次垃圾收集都有大批的对象死去,只有少量存活,就用复制算法。老年代对象存活率高,没有额外的空间对他进行担保,所有用标记清除或者标记压缩算法进行回收。
新生代(Eden Space)
新生代用来分配新创建的对象,通常会有多个线程同时创建多个对象,所以Eden区被划分为多个线程本地分配缓冲区(Thread Local Alloction Buffer,简称TLAB)。通过这中缓冲区划分,大部分对象直接由JVM在对应线程的TLAB中分配,避免与其他线程的同步操作。
如果TLAB中没有足够的内存空间,会在Eden区中分配,Eden区也没有足够的空间,就会触发一个Yong GC。
存活区(Survivor Spaces)
Eden旁边是两个存活区(Survivor Spaces),有from和to两个空间。在任意时刻总有一个存活区是空的。
空的那个存活区用于在下一次Yong GC的时候存放对象。年轻代中所有的存活对象(包括Eden区和另一个非空的那个”from“存活区区)都会被复制到”to“存活区。GC 完成后,”to“区有对象,”from“区没有对象,角色互换。对象存活到一定周期阈值就会提升到老年代。
具体的提升阈值由 JVM 动态调整,但也可以用参数 -XX:+MaxTenuringThreshold 来指定上限。如果设置 -XX:+MaxTenuringThreshold=0 ,则 GC 时存活对象不在存活区之间复制,直接提升到老年代。现代 JVM 中这个阈值默认设置为 15 个 GC 周期。这也是 HotSpot JVM 中允许的最大值。
如果存活区空间不够存放年轻代中的存活对象,提升(Promotion)也可能更早地进行。
老年代
老年代内存空间通常会更大,里面的对象是垃圾的概率也更小。
老年代 GC 发生的频率比年轻代小很多。同时,因为预期老年代中的对象大部分是存活的,所以不再使用标记和复制(Mark and Copy)算法。而是采用移动对象的方式来实现最小化内存碎片。老年代空间的清理算法通常是建立在不同的基础上的。原则上,会执行以下这些步骤:
- 标记所有通过 GC roots 可达的对象;
- 删除所有不可达对象;
- 整理老年代空间中的内容,方法是将所有的存活对象复制,从老年代空间开始的地方依次存放。
通过上面的描述可知,老年代 GC 必须明确地进行整理,以避免内存碎片过多。
持久代/元数据区
持久代
在 Java 8 之前有一个特殊的空间,称为“永久代”(Permanent Generation)。这是存储元数据(metadata)的地方,比如 class 信息等。此外,这个区域中也保存有其他的数据和信息,包括内部化的字符串(internalized strings)等等。
实际上这给 Java 开发者造成了很多麻烦,因为很难去计算这块区域到底需要占用多少内存空间。预测失败导致的结果就是产生 java.lang.OutOfMemoryError: Permgen space 这种形式的错误。除非 OutOfMemoryError 确实是内存泄漏导致的,否则就只能增加 permgen 的大小,例如下面的示例,就是设置 permgen 最大空间为 256 MB:
-XX:MaxPermSize=256m
元数据区
既然估算元数据所需空间那么复杂,Java 8 直接删除了永久代(Permanent Generation),改用 Metaspace。从此以后,Java 中很多杂七杂八的东西都放置到普通的堆内存里。
当然,像类定义(class definitions)之类的信息会被加载到 Metaspace 中。元数据区位于本地内存(native memory),不再影响到普通的 Java 对象。默认情况下,Metaspace 的大小只受限于 Java 进程可用的本地内存。这样程序就不再因为多加载了几个类/JAR 包就导致 java.lang.OutOfMemoryError: Permgen space. 。注意,这种不受限制的空间也不是没有代价的 —— 如果 Metaspace 失控,则可能会导致严重影响程序性能的内存交换(swapping),或者导致本地内存分配失败。
如果需要避免这种最坏情况,那么可以通过下面这样的方式来限制 Metaspace 的大小,如 256 MB:
-XX:MaxMetaspaceSize=256m
|