垃圾收集器与内存分配策略
哪些内存需要垃圾回收
在上一节中有提到在运行时数据区域包括:堆、虚拟机栈、本地方法栈、程序计数器、方法区(JDK1.7及之前)、元空间(JDK1.8及之后)。在这些区域中,程序计数器占用内存极小,可以忽略;栈区域在编译期就可以确定下来,并且其声明周期随线程保持一致,也不用管;而Java堆和方法区、元空间中接口的不同实现类需要的内存不同,方法的不同实现需要的内存也不同,而且这些所需的内存需要在运行时才能确定,所以垃圾回收关注的主要内容就是这些区域。
对象是否不再使用
引用计数法
给对象添加一个引用计数器,每当有一个地方引用它时,计数器加一;引用失效的时候,计数器就减一;在任何时候只要计数器为0就代表该对象就是不会再被使用的。
该方法的优点:
- 实现较为简单
- 判定效率很高,基本没有其他额外的操作
缺点:
很难解决对象之间相互循环引用的问题。即两个对象相互持有对方的引用,除此之外再没有别的地方使用这两个对象,但是因为相互引用导致计数器不可能为0,所以无法被回收
可达性分析算法
算法描述
通过选择一些满足一定条件的对象作为节点,从这些节点开始往下搜索,搜索经过的路径被称为引用链(有直接或间接引用关系的对象都在引用链上),这些对象被成为"GC Roots",当一个对象达到GC Roots没有任何引用链时则判定该对象不可用,即使该对象仍旧被其他对象引用,只要其与GC Roots没有关系既是不可用的。
可作为GC Roots的对象
- 虚拟机栈中引用的对象。
- 方法区中常量引用的对象。
- 方法区中类静态属性引用的对象。
- 本地方法区中JNI引用的对象。
简单来说包括以下几种类型:
- Class - 由系统类加载器(system class loader)加载的对象,这些类是不能够被回收的
- Thread - 活着的线程
- Stack Local - Java方法的local变量或参数
- JNI Local - JNI方法的local变量或参数
- JNI Global - 全局JNI引用
- Monitor Used - 用于同步的监控对象
Java中的引用
在最初的Java中,引用仅仅是指一个对象的数据中存储的值是另外一块内存的起始地址。在JDK1.2之后将引用分为多种:
- 强引用:强引用是类似于
User user = new User()
,是在代码中最常用的一种方式。只要强引用存在,那么垃圾回收器就永远不会回收掉被引用的对象。 - 软引用:软引用用于描述一些有用但是并不是一定需要的对象,对于软引用的对象,当内存将要发生溢出时,会将这些对象列入回收范围中进行一次回收,如果将软引用的对象回收后内存还是不足才会抛出内存溢出异常。在JDK中使用
SoftReference
类实现软引用。SoftReference
softReference = new SoftReference(new Object()); - 弱引用:弱引用用于描述非必须的对象,弱引用对象在下一次垃圾回收时一定会被回收,无论当前内存是否足够。在JDK中使用WeakReference定义弱引用。
- 虚引用:一个对象是否存在虚引用对其生存时间不会有任何关系,只是在这个对象呗收集器回收时收到一个系统通知。在JDK中使用PhantomReference来实现虚引用。
- 如果对象在可达性分析中被判定没有与GC Roots相连接的引用链那么改对象将会被标记,然后进行一次筛选。
- 筛选的条件是判断该对象是否有必要执行finalize()方法。是否有必要执行finalize()方法的条件是当对象没有覆盖finalize()方法或者该对象的finalize()方法已经被虚拟机调用过,这两种情况都会被判定为没有必要执行。
- 如果被判定为有必要执行finalize方法,则会将其放在一个队列中,稍后执行。在finalize()方法中是对象逃脱被回收的最后机会,只要重新与引用链中的任何一个对象建立关系即可。
- 废弃常量
- 无用的类
- 该类所有的实例都已经被回收
- 加载该类的ClassLoader已经被回收
- 该类对应的字节码对象Class没有在任何地方被引用,无法再任何地方通过反射访问到该类的方法
- -XX:MetaspaceSize :是分配给类元数据空间(字节)的初始大小。该值设置的过大会延长垃圾回收时间。垃圾回收过后,引起下一次垃圾回收的类元数据空间的大小可能会变大。
- -XX:MaxMetaspaceSize :是分配给类元数据空间的最大值,超过此值就会触发Full GC,此值默认没有限制,但应取决于系统内存的大小。JVM会动态地改变此值。
- -XX:MinMetaspaceFreeRatio/-XX:MaxMetaspaceFreeRatio :表示一次GC以后,为了避免增加元数据空间的大小,空闲的类元数据的容量的最小/最大比例,不够就会触发垃圾回收。
- 并发:简单来讲是指一个处理器在同一时间间隔处理多个任务。放到垃圾回收这里指垃圾回收线程和用户工作线程同时进行工作。
- 并行:并行是指多个处理器在同一时刻处理多个任务。放到垃圾回收这里指多个垃圾回收线程同时工作,但是用户工作线程处于等待状态。
- -XX:MaxGCPauseMillis:控制最大垃圾收集停顿时间,该参数的值运行为一个大于0的毫秒数,虚拟机会尽量将垃圾回收花费的时间控制在设定的值之下。但是并不代表将每次垃圾回收的时间减小就一定是有利的,因为回收时间的降低是以降低吞吐量和新生代空间为代价的。为了保证每次垃圾回收的时间减小,就需要降低每一次垃圾回收的区域,所以需要减小新生代的大小。但在降低新生代大小的同时也增加了垃圾回收的次数。所以在设置该值是不能盲目的追求小的回收时间,而应该根据项目实际情况进行设置。
- -XX:GCTimeRatio:直接设置吞吐量大小。该值代表吞吐量比率,所以其应该是一个大于0并且小于100的数,这里还要求其为整数。
- -XX:UseAdaptiveSizePolicy:用于确定是否开启GC自适应的调整策略,如果打开该策略,就不需要手工指定新生代的大小(-Xmn)、Eden区和Survivor区的比例(-XX:SurvivorRatio)、晋升老年代年龄(-XX:PretentureSizeThreshold)等参数。虚拟机会监控当前系统运行状态收集性能监控信息,自动的调整系统中垃圾回收停顿时间或者最大的吞吐量。该方式适合不太清楚如何设置上面那些参数的同学。
对象的自救
实际上,在可达性算法中即使是不可达的对象也并非一定会被回收的,判断其是否会被回收还需要走
以下流程:
public class FinalizeEscape { private static FinalizeEscape escape = null; public static void main(String[] args) throws InterruptedException { escape = new FinalizeEscape(); //模拟对象使用后断开引用链 escape = null; //对象自救 System.gc(); Thread.sleep(500); if(escape != null){ System.out.println("对象没有被清除!"); }else { System.out.println("对象已经被清除!"); } //模拟第二次逃脱gc escape = null; System.gc(); Thread.sleep(500); if(escape != null){ System.out.println("对象没有被清除!"); }else { System.out.println("对象已经被清除!"); } } @Override protected void finalize() throws Throwable { super.finalize(); System.out.println("finalize execute!"); escape = this; } } 运行结果: finalize execute! 对象没有被清除! 对象已经被清除! 复制代码
在对同一对象进行两次模拟逃脱gc,第一次成功第二次失败,是因为一个对象的finalize()方法只会被调用一次。
方法区回收
在方法区的回收主要包括两个方面:
废弃常量
废弃常量是指在常量池中存在一个值,假设为一个字面量,但是在当前系统中没有任何的一个对象引用了该字面量。那么久认为该字面量是废弃的,在下一次垃圾回收的时候将其进行回收。同理常量池中的其他类、接口、方法、字段等的符号引用的回收也是类似。
无用的类
要判定一个类是否可以被回收需要满足以下几个条件:
当一个类满足以上条件后就允许被回收,但不是一定会被回收。是否对类进行回收再HotSpot虚拟机中提供了-Xnoclassgc参数进行控制。也可以使用-verbose:class,-XX:+TraceClassLoading,-XX:+TraceClassUnloading参数查看类加载和卸载信息。
在使用反射、动态代理、动态生成jsp和OSGI等频繁自定义ClassLoader的场景都需要虚拟机具备卸载类的功能,保证永久代不会溢出。
需要注意的是在JDK1.7时HotSpot就已经将运行时常量池迁移到堆中,在JDK1.8中更是直接移除了方法区,所以上面的介绍需要对应到具体的版本,并不是指着一定是在方法区完成。虽然区域发生变化但是回收的原则基本还是这样。
元空间回收
JDK1.8开始把类的元数据放到本地堆内存(native heap)中,如果Metaspace的空间占用达到了设定的最大值,那么就会触发GC来收集死亡对象和进行类卸载,这一块的回收要求较高,上文中有简单说过。
有关元空间的JVM参数:
垃圾收集算法
标记-清除算法
标记-清除算法的基本内容就同其名字一样,存在着标记和清除两个阶段:首先查找与GC Roots无任何关联的对象,标记处所需回收的对象(如何标记在内存回收中已经介绍了,通过判断是否有必要或已经执行了finalize()方法),在标记完成之后再统一清除。
标记过程:虚拟机从作为GC Roots的根节点出发进行搜索,对可被访问到的对象做一个标记,其他未被标记的对象就是需要被回收的。效率低是因为目前来说项目中的对象极多,单单是进行遍历就需要耗费较长的时间。
好处:实现简单,标记-清除算法流程十分简单,实现也没有很复杂的地方。
缺点: 1.效率较低:因为标记和清除的过程效率都不高 2.浪费内存空间:在清除标记的对象后造成了内存中大量不连续的空间,一旦有大的对象进入可能会因为没有合适的存放的地方而造成再一次的GC。
复制算法(多用于新生代)
复制算法的基本内容是要求虚拟机并不将所有的内存空间用来存放对象。复制算法将内存分为两块,每一次都只是使用其中的一块,当触发GC时,将存放对象的那一块内存上还存活的对象复制到另一块上去,然后将之前的内存块全部清除。
优点:实现简单,而且因为在将存活对象转移时顺序内存存放不用考虑内存碎片的问题,效率较高。
缺点: 1.始终有一部分内存没有得到使用,造成空间浪费。要保证存活的对象能够完全复制,那么就要求两块内存大小一致(50%),因为可能存在没有任何对象死亡的极端情况,但是这样将会极其浪费,而如果不这样分配,就必须引入其他机制保证对象能够被完整的复制。
标记-整理算法
标记整理算法的标记阶段同标记-清除算法一致,不过标记后并不立即清除,而是将存活(不会被GC)的对象移向内存的一端,将存活的对象全部移动后将边界外的清除掉。
优点:解决了内存碎片的问题
缺点:标记阶段效率本身较低,还多加了一个整理阶段,还是在于总体效率较低
分代收集算法
分代收集算法实际上并不是一个新的实现方式,只是将虚拟机分成几块,每一块根据它的实际作用来选择适合的算法,这些算法可以是标记-清除,复制算法等等。
基于分代的收集思想,将堆内存分为以下几个部分:
将堆内存分为新生代(Young)和老年代(Old),新生代又分为Eden区、from区和to区,默认Eden:from:to=8:1:1。一般情况下,新创建的对象都会被分配到Eden区(一些大对象可能会直接放到老年代),具体的内存分配在后面记录。
HotSpot中的算法实现
枚举根节点
在可达性分析中,可作为GC Roots的节点主要是全局性的引用与执行上下文,如果要逐个检查引用,必然消耗时间。 另外可达性分析对执行时间的敏感还体现在GC停顿上,因为这项分析工作必须在一个能确保一致性的时间间隔中进行,这里的“一致性”的意思是指整个分析期间整个系统执行系统看起来就像被暂停在某个时间点,不可以出现分析过程中对象引用关系还在不断变化的情况,也就是在分析过程中用户线程还在工作。这点是导致GC进行时必须暂停所有Java执行线程的其中一个重要原因。
但是目前主流的Java虚拟机都是准确式GC(准确式GC是指就是让JVM知道内存中某个位置数据的类型是什么),所以在执行系统停顿下来之后,并不需要一个不漏的检查执行上下文和全局的引用位置,虚拟机是有办法得知哪些地方存放的是对象的引用。在HotSpot的实现中,是使用一组OopMap的数据结构来达到这个目的的。
安全点
在OopMap的协助下,HotSpot可以快速且准确的完成GC Roots的枚举,但可能导致引用关系变化的指令非常多,如果为每一条指令都生成OopMap,那将会需要大量的额外空间,这样GC的空间成本会变的很高。
实际上,HotSpot也的确没有为每条指令生成OopMap,只是在特定的位置记录了这些信息,这些位置被称为安全点(SafePoint)。SafePoint的选定既不能太少,以致让GC等待时间太久,也不能设置的太频繁以至于增大运行时负荷。所以安全点的设置是以让程序“是否具有让程序长时间执行的特征”为标准选定的。“长时间执行”最明显的特征就是指令序列的复用,例如方法调用、循环跳转、异常跳转等,所以具有这些功能的指令才会产生SafePoint。
对于SafePoint,另一个问题是如何在GC发生时让所有线程都跑到安全点在停顿下来。这里有两种方案:抢先式中断和主动式中断。抢先式中断不需要线程代码主动配合,当GC发生时,首先把所有线程中断,如果发现线程中断的地方不在安全点上,就恢复线程,让他跑到安全点上。现在几乎没有虚拟机实现采用抢先式中断来暂停线程来响应GC。 而主动式中断的思想是当GC需要中断线程的时候,不直接对线程操作,仅仅简单的设置一个标志,各个线程执行时主动去轮询这个标志,发现中断标志为真时就自己中断挂起,轮询标志的地方和安全点是重合的另外再加上创建对象需要分配的内存的地方。
安全区域
使用安全点似乎已经完美解决了如何进入GC的问题,但实际情况却并不一定,安全点机制保证了程序执行时,在不太长的时间内就会进入到可进入的GC的安全点。但是程序如果不执行呢?所谓的程序不执行就是没有分配cpu时间,典型的例子就是线程处于sleep状态或者blocked状态,这时候线程无法响应jvm中断请求,走到安全的地方中断挂起,jvm显然不太可能等待线程重新分配cpu时间,对于这种情况,我们使用安全区域来解决。
安全区域是指在一段代码片段之中,你用关系不会发生变化。在这个区域的任何地方开始GC都是安全的,我们可以把安全区域看做是扩展了的安全点。
当线程执行到安全区域中的代码时,首先标识自己已经进入了安全区,那样当在这段时间里,JVM要发起GC时,就不用管标识自己为安全区域状态的线程了。当线程要离开安全区域时,他要检查系统是否完成了根节点枚举,如果完成了,那线程就继续执行,否则他就必须等待,直到收到可以安全离开安全区域的信号为止。
垃圾收集器
Serial收集器
Serial是一个单线程的收集器,这表示其Serial只会使用一个CPU或者是一条收集线程进行垃圾回收的工作,同时需要注意的是它在进行回收工作是会停掉所有的其他工作线程(Stop the World),知道它的回收工作结束。
Serial虽然存在上面的问题,但是这并不表示它是一个无用的收集器,反而到目前为止Serial收集器在Client模式下被用在新生代的收集(64位虚拟机默认支持Server模式,并且无法切换;32位虚拟机可在Client和Server之间切换。正常情况下,Server模式启动较慢,但启动后性能远高于Client模式)。但是实际上使用也不多了。。。
Serial收集器的优点在于:在单CPU环境中,由于Serial由于没有线程的开销,专心做垃圾回收自然能获得极高的回收效率。
ParNew收集器
ParNew实际上就是一个多线程版的Serial收集器,除了多线程进行垃圾回收外其他都和Serial基本一致。
ParNew在很多运行于Server模式下的虚拟机中被用于新生代的首选。最大的原因在于目前为止只有其能CMS(Concurrent Mark Sweep)收集器配合使用。
并发与并行
Parallel Scavenge收集器
Parallel Scavenge是一个新生代的收集器,同时它是一个并行的多线程的收集器,其使用复制算法。Parallel Scavenge的目标是达到一个可控制的吞吐量(throughput)。
吞吐量:CPU用于运行用户代码的时间和CPU总共运行时间的比值
吞吐量越高表示停顿时间短,程序响应速度快,CPU利用率越高。
Parallel Scavenge提供了几个用于精确控制吞吐量的参数:
Serial Old
Serial Old是Serial的老年代版本,它也是一个单线程收集器,使用“标记-整理”算法。它的作用主要是两个:一个是搭配Parallel Scavenge收集器使用;另外就是当CMS收集器发生Concurrency Mode Failure时作为备用收集器。
JVM系列4-垃圾收集器与内存分配策略(二):https://developer.aliyun.com/article/1535507