Technology 10/13/2021
14
0
0

JVM - 垃圾回收器

jvm , ParNew , Serial , 垃圾回收器 , Parallel , GC
JVM - 垃圾回收器

Java 内存运行时区域的各个部分,其中程序计数器、虚拟机栈、本地方法栈3个区域随线程而生,随线程而灭;栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作,每个栈帧中分配多少内存基本上是类结构确定下来时就已知的。

但是 Java 堆和方法区这两个区域有着很显著的不确定性,::一个接口的多个实现类需要的内存可能会不一样,一个方法所执行的不同条件分支所需的内存也可能不一样,只有处于运行期间,才能知道程序究竟会创建那些对象,创建多少个对象,这部分内存的分配和回收是动态的::,也正是垃圾回收器关注的部分。

垃圾回收器:

分代模型——GC的宏观愿景;
垃圾回收器——GC的具体实现;

  • 并行(Parallel):
    多个收集器的线程同时工作,但是用户线程处于等待状态;
  • 并发(Concurrent):
    收集器在工作的同时,用户线程同时工作;并发不代表解决了GC停顿的问题,在关键的步骤还是要停顿,比如在收集器标记垃圾的时候。但是在清除垃圾的时候,收集器线程和用户线程可以同时工作。

Serial收集器:

最早的收集器,单线程收集器,没有额外开销,简单实用。
收集时会暂停所有的用户线程(STW),使用复制收集算法,虚拟机运行在Client模式时默认的新生代收集器;
在新生代(此时使用复制算法)和老年代(此时使用标记-整理算法)都可以使用;
HotSpot Client模式缺省的垃圾收集器;

ParNew收集器:

Serial收集器的多线程版本,除了使用多个收集线程外,其余的行为包括算法、STW、对象分配规则、回收策略等都与Serial收集器一样;
运行在Server模式的默认的新生代垃圾收集器,在单CPU环境下,并不会比Serial收集器效率高,只有在多CPU环境下才会比Serial收集器效率高;
使用复制算法,结合具体CPU的个数,可以通过-XX:ParallelGCThreads参数来控制GC的线程数;

Parallel Scavenge收集器:

多线程收集器,也是使用的复制算法,但是对象分配规则,回收策略都与ParNew不同,它是以吞吐量最大化(GC时间占总运行时间最小)为目标的收集器,它允许较长时间的STW以换取最大的吞吐量;

Serial Old收集器:

单线程收集器,使用的算法时标记-整理(Mark-Compact)算法,是老年代使用的收集器;

Parallel Old收集器:

老年代吞吐量优先的收集器,多线程,使用的算法是标记-整理算法(Mark-Compact),jdk1.6提供,在此之前,新生代使用PS收集器,老年代使用Serial Old收集器(除此之外,别无选择,因为新生代使用PS收集器的话,CMS收集器无法与之协同工作);

CMS(Concurrent Mark Sweep)收集器:

以最短停顿时间为目标的收集器,使用的算法时标记-清除算法(Mark-Sweep)因此会有碎片问题,容易导致频繁的Full GC;
使用CMS并不能达到GC效率最高,但是它能尽可能降低服务停顿时间;
只针对老年代,一般配合ParNew收集器使用;
并发运行,可以使用-XX:+UseConcMarkSweepGC命令打开;
以牺牲CPU资源的代价来减少用户线程的停顿,当CPU个数少于4的时候,有可能对吞吐量的影响非常大;
CMS在并发清理过程中,用户线程还在执行,这时候需要预留一部分空间给用户线程;

整个过程分为以下几个步骤:

  1. 初始标记(Inital Mark):只是标记一下GC Roots 能直接关联到的对象或者被年轻代存活的对象引用的对象,速度非常快,会发生STW;

  2. 并发标记(Concurrent Mark):就是进行GC Roots Tracing的过程,这个阶段会遍历老年代,然后标记(根据上一阶段找到的GC Roots进行遍历)所有存活对象,但并不是老年代所有存活的对象都会被标记,因为在标记期间用户线程可能会发生一些引用变化,本阶段会与用户线程并发执行;

  3. 并发预清除(Concurrent PreClean):本阶段会与用户线程并发执行,在并发运行过程中,一些对象的引用可能会发生变化,此时JVM会将该对象所在的区域(Card)标记为Dirty,也就是Card Marking。在本阶段哪些被标记为Dirty的对象引用的对象也会被标记,当这个标记完成之后,Dirty Card标记就会被清除;

  4. 并发的可能失败的预清除(Concurrent Abortable PreClean):为了尽量承担STW中最终标记阶段的工作。由于本阶段在重复做很多的工作,例如:重复迭代的次数、完成的工作量或时钟时间等;

  5. 最终重新标记(Final Remark):为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那部分的对象的标记记录;由于之前的阶段是并发执行的,GC线程又可能跟不上用户程序的变化,为了完成标记老年代所有存活对象,发生STW就非常必要了,而且比初始标记阶段发生的STW时间要长,但远比并发标记的时间短;本阶段会在年轻代尽可能干净的时候进行,目的是为了减少连续STW发生的可能(年轻代存活对象越多,会导致老年代存活对象可能很多);

  6. 并发清除(Concurrent Sweep):清除不再使用的对象,回收占用的内存空间;

  7. 并发重置(Concurrent Reset):重置CMS内部的数据结构,为下次GC做准备;

缺点:

  1. CMS收集器对CPU资源非常敏感;

  2. CMS收集器无法处理浮动垃圾(Floating Garbage),可能出现“Concurrent Mode Failure”失败而导致另一次的Full GC,如果应用中,老年代增长不是太快,可以适当调高参数-XX:CMSInitiatingOccupancyFraction的值来提高触发的百分比,以便降低内存回收次数来获取更好的性能;钥匙CMS执行期间预留的内存无法满足程序要求时,虚拟机将启动后备预案:临时启用Serial Old收集器重新进行老年代的垃圾回收,这样就导致停顿时间很长了,所以-XX:CMSInitiatingOccupancyFraction的参数值设置太高会很容易导致大量的“Concurrent Mode Failure”失败,性能反而降低;

  3. CMS收集结束时,会有大量的空间产生碎片,碎片太多时,将会给打对象分配内存带来很大麻烦,往往会出现老年代还有很大空间剩余,但是找不到足够大的连续空间来分配给大对象,因此不得不提前进行Full GC;CMS收集器提供了一个-XX:+UseCMSCompactAtFullCollection开关参数(默认开启)用于在CMS收集器顶不住要进行Full GC时开启内存碎片的合并和整理过程,内存碎片的整理时无法并发的,这样虽然碎片问题解决了,但是停顿时间变长了。

方法区:

  • Java虚拟机规范表示不要求虚拟机在方法区实现GC,方法区的GC效果不是很明显,性价比比较低;
  • 在堆中,尤其是在新生代,常规应用进行一次GC一般可以回收70%~95%的空间,而方法区的GC远低于此;
  • 当前的商业JVM都实现方法区的GC,主要回收两部分内容:废弃的常量和无用的类。
  • 类回收需要满足三个条件:
    1. 该类的所有实例都已经被GC,也就是JVM中不存在任何该类的实例对象;
    2. 加载该类的ClassLoader已经被GC;
    3. 该类对应的java.lang.Class对象没有在任何地方被引用(例如:在任何地方都不能通过反射的方式访问该类的方法或属性);
  • 在大量使用反射、动态代理、CGLib等字节码框架、动态生成JSP以及OSGI这类频繁自定义ClassLoader的场景都需要JVM具备类卸载的支持,以保证方法区不会溢出。

内存分配:

  1. 堆上分配:大多数情况下在Eden上分配,偶尔会直接在old上分配,细节取决于GC实现;
  2. 栈上分配:原子类型的局部变量;

内存回收:

GC是要将哪些Dead的对象占用的内存进行回收,HotSpot认为没有引用的对象就是Dead的;

HotSpot将引用分为4种:强引用(Strong),软引用(Soft),弱引用(Week),虚引用(Phantom);

  • 强引用(Strong):通过Object obj = new Object()方式创建的引用;
  • 软引用(Soft),弱引用(Week),虚引用(Phantom)则是通过继承Reference,在Full GC时会对Reference类型的引用进行特殊处理;
    1. 软引用(Soft):内存不够时会被GC,长时间不使用时也会被GC;
    2. 弱引用(Week):一定会被GC,当被标记(Mark)为Dead时,会在ReferenceQueue中通知;
    3. 虚引用(Phantom):本来就没有引用,当从JVM Head中释放时,会在ReferenceQueue中通知;

GC的时机:

在分代模型的基础上,GC从实际上分为两种:Scavenge GC 和 Full GC;

  1. Scavenge GC(Minor GC):
    出发时机:新对象生成时,Eden空间满了;
    理论上Eden区大多数对象会在Scavenge GC回收,复制算法的执行效率很高,Scavenge GC时间较短;

  2. Full GC:
    对整个JVM进行整理,包括新生代,老年代和永久代(jdk1.7之前);
    主要的触发时机:

    • old区(老年代)满了;
    • perm区(永久代)满了;
    • 执行System.gc();

Full GC 的效率较低,Full GC 存在Stop The World问题,简称STW(在执行Full GC时,整个业务的线程会暂停一段时间,等待所有工作等待执行完成),应尽量减少Full GC;

java内存泄露的经典原因:

  1. 对象定义在错误的范围;
  2. 异常处理不当;
  3. 集合数据管理不当:
    • 当使用基于数组的集合(ArrayList,HasnMap等)时,要尽量减少resize,即扩容;比如使用new ArrayList()时,尽可能的估算其大小,在创建时把size确定;这样可以避免没有必要的数组拷贝(array copying),GC碎片的问题;
    • 如果一个List只需要顺序访问,不会随机访问(Random Access),用LinkedList代替ArrayList,因为LinkedList本质时链表,不会进行resize,适合顺序访问;

示例1

class Foo {
		private String[] names;

		public void excuted(int length) {
			if(names == null || names.length < length){
				names = new String[length];
			}
			populate(names);
		}
}

示例中的names仅作为临时变量使用,当Foo对象声明周期较长时,会导致临时性内存溢出;

优化方法:将names变量放到方法中声明,优化后如下代码:
示例2

class Foo {
		public void excuted(int length) {
			private String[] names = new String[length];
			populate(names);
		}
}

GC演示示例:

示例3

class Foo {
		public void excuted(int length) {
			int size = 1024 * 1024;
			byte[] a = new byte[2 * size];
			byte[] b = new byte[2 * size];
			byte[] c = new byte[2 * size];
			byte[] d = new byte[2 * size];
		}
}

示例4

class Foo {
		public void excuted(int length) {
			int size = 1024 * 1024;
			byte[] a = new byte[2 * size];
			byte[] b = new byte[2 * size];
			byte[] c = new byte[3 * size];
			byte[] d = new byte[3 * size];
		}
}

当新生代的内存不足够创建新的对象时,会直接在老年代创建这个对象;

jvm参数:
MaxTenuringThreshold:其作用是在可以自动调节对象晋升(Promote)到老年代阈值的GC中,设置改阈值的大小;默认为15,CMS中默认16,G1中默认15(在jvm中,该值占位为4bit,其最大值为1111,即十进制15);

经历了多次的GC之后,存活的对象会在From Survivor和To Survivor之间来回存储,而这个的前提是这两块空间有足够的大小。在GC算法中,会计算每个对象年龄的大小,如果发现达到某个年龄的对象的总大小已经超过了Survivor空间的50%,那么这时候就需要调整阈值,不能在继续等到默认的15次GC后才完成晋升,因为这样会导致Survivor空间不足,新创建的对象可能直接进入老年代,所以需要调整阈值,让这些存活的对象尽快晋升。

枚举根节点:

当执行系统停下来后,并不需要一个不漏的检查完所有执行上下文和全局的引用位置,在HotSpot虚拟机中,通过使用一组OopMap数据结构来实现记录对象引用的存放位置。

安全点:

在OopMap的协助下,HotSpot可以快速准确的完成GC Roots 的枚举,但是一个很现实的问题随之而来:可能导致引用关系变化,或者说OopMap内容的变化指令非常多,如果为每一条指令都生成OopMap,那么将需要大量的额外空间进,这样GC的成本空间会变的很高。
实际上,HotSpot没有为每条指令都生成OopMap,而只是在“特定的位置”记录了这些信息,这些特定的位置成为安全点(SafePoint),即程序在执行时并非在所有地方都能停顿下来执行GC,只有在达到安全点时才能暂停。
安全点(SafePoint)的选定既不能太少以至于GC执行时间太长,也不能太少以至于GC太频繁过分增大运行时的负载,所以安全点的选定基本上是以“是否具有让程序长时间运行的特征”为标准进行的,原因是:每条指令的执行时间非常短暂,程序不太可能因为指令流太长这个原因而长时间运行,“长时间运行”的最明显的特征就是指令序列复用,例如:方法调用、循环跳转、异常跳转等,所以具有这些功能的指令才会产生安全点(SafePoint)。
对于安全点,另一个需要考虑的问题是如何在GC发生时,让所有的线程(这里不包括执行JNI调用的线程)都“跑”到最近的安全点上在停下来;

停顿分为两种:抢占式终端和主动式中断;

  • 抢占式中断:不需要线程的执行代码主动去配合,在GC执行时,中断所有线程,然后检查是否“跑”到安全点附近,如果没有,就恢复线程让其继续执行,“跑”到安全点附近;
  • 主动式中断:当GC需要中断线程的时候,不直接操作线程,仅仅简单的设置一个标记,各个线程执行时主动轮询这个标记,发现标记为真时就主动挂起;轮询标记的地方和安全点是重合的,另外再加上创建对象需要分配内存的地方。

安全区域:

在使用安全点(SafePoint)似乎已经完美的解决了如何进入GC的问题,但是,::安全点只保证程序执行时在不太长的时间内能够进入GC::,而当程序没有执行时(没有分配CPU时间,典型的例子就是线程处于Sleep状态或者Blocked状态),这时候线程就无法响应JVM的中断请求,JVM也不可能等待线程重新飞配CPU时间,这时候就需要安全区域(Safe Region)来解决。

在线程执行到安全区域中代码时,首先标记自己已经进入安全区域,那样,当在这段里JVM要发器GC时,就不用管标记Safe Region的线程了,在线程要离开安全区时,要检查系统是否已经完成了根节点枚举或整个GC过程,如果完成了,线程就继续执行,否则,就必须等待直到收到可以离开安全区的信号为止。

空间分配担保:

在发生Minor GC之前,虚拟机会先检查老年代最大的可使用的连续空间,是否大于新生代所有对象的总空间,如果条件成立,那么Minor GC可以确保是安全的;当大量的对象在Minor GC后任然存活,就需要老年代进行内存空间分配担保,把Survivor无法容纳的对象直接分配到老年代。如果老年代判断到生育内存不足(依据是:以往每一次回收到老年代的对象的容量的平均值作为经验值)则进行一次Full GC。

Comments