深入理解JVM-读书笔记-垃圾回收&内存分配策略

1 垃圾回收

尽管java虚拟机有自动回收垃圾的能力,但这并不意味着java就不存在内存溢出和内存泄漏了。而当你碰到内存溢出或者泄露而不清楚虚拟机的GC和内存分配的话,是很难去排查出问题所在的。

GC要完成的三件事情是

  • 哪些内存需要回收?
  • 在什么时候回收?
  • 该如何回收?

1.1 哪些内存需要回收?

java虚拟机中需要进行垃圾回收的地点主要是堆和方法区

a.堆内存的回收

堆中的垃圾回收就是回收不可用的对象的内存,而java中对象是否可用取决于是否还存在某些对象还保持对它的引用,这样一个非常容易想到也非常容易实现的策略就是为每一个对象保持一个引用计数器,但引用计数器存在一个很大缺点就是它没法去回收两个相互引用的对象,即使没有其他对象保持对他们的引用,但它们的计数器值不会减为零。

另一个策略就是我们先找到一些所必需的对象,然后通过这些所必需的对象所保持的引用往下遍历,没有遍历到的就是要回收的垃圾了。这里还存在一个问题,如何确定遍历的根节点。我们可以总结GC Roots对象的生命周期应该具有的特点:当前方法的所用的(虚拟机栈中的本地变量表所引用的,本地方法栈帧的),当前方法没用到但后面可能用到的(静态变量,常量等)

java中可作为根节点(GC Roots)的对象包括以下几种:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈中JNI(一般所说的native方法)引用的对象

这里所说的引用都只是观念上的引用,也就是reference类型中存储的数据是所引用对象的内存块的起始地址,这样一个对象只有有引用和无引用两种状态,但实际上我们还有这样的需求——对于一些对象我们希望在内存充分的情况下保留它,而在内存紧张的情况下回收它,这样传统的引用就不合适了。

java从jdk1.2起就对引用的概念进行扩充,一共有四类:
强引用 : 程序中普遍存在的,类似“Object obj = new Object();”这样声明的引用,只要强引用在存在,垃圾回收器就不会回收这个对象。
软引用 : 用来描述一些有用但非必需的对象。虚拟机会在系统将要溢出之前对只被软引用关联的对象进行回收,这类对象回收完仍然不够才抛出内存溢出的异常,jdk1.2后提供了SoftReference类实现软引用。
弱引用 : 用来描述一些非必需对象,制备弱引用关联的对象只能生存到下一次垃圾回收之前,无论内存是否紧张,都将会被回收。dk1.2后提供了WeakReference类实现弱引用。
虚引用 : 也称为幽灵引用,它是最弱的一种引用关系。这种引用关系不会对生存时间造成影响,也无法通过虚引用来取得一个对象实例。让对象与幽灵引用关联的唯一目的是可以让我们在该对象被回收之时得到一个系统通知。dk1.2后提供了PhantomReference类实现虚引用。

还有要注意的是,不是在可达性分析中未被遍历到的对象就一定得“死”,未被遍历到的对象会接受检查是否需要执行finalize方法(通过条件是finalize方法未被执行过,以及对象覆盖了finalize方法),检查通过的对象然后会被放入一个叫F_Queue 队列之中,并在一个虚拟机自动建立,低优先级的Finalizer线程去执行其中对象的finalize ()方法,对象可以在finalize方法中自救——将自己的this变量赋给某个类变量或实例变量,重新建立一个引用关系。

b.方法区内存回收

方法区的内存回收包括废弃常量无用的类,其中废弃常量的回收同堆中对象的回收很像,如字符串常量”abc“进入了常量池后,在垃圾回收前系统中并没有任何String对象引用它,那么有必要的话就会被回收掉。常量池中的其他类(接口),方法,字段的符号引用也是如此。

而”无用的类“条件则苛刻多了,需要满足下面三个条件:

  • 该类的所有实例都已经被回收,即堆中再无该类的实例。
  • 加载该类的ClassLoader已经被回收
  • 该类对应的java.lang.Class对象没有在任何地方被引用,也就是说无法在任何地方通过反射访问该类的方法。

不过不是不使用的类就一定会被回收,是否回收”无用的类“,HotSpot虚拟机提供-Xnoclassgc参数进行控制,还可以通过-verbose:class以及 -XX:+TraceClassLoading,-XX:+TraceClassUnLoading来产看类加载卸载的信息。
在大量使用反射,动态代理,CGLib等ByteCode框架,动态生成JSP以及OSGi这类频繁自定义ClassLoader的场景都需要虚拟机具备类卸载的功能,以避免方法区溢出。

1.2 在什么时候回收?

垃圾回收主要是主要是发生在堆内存中,而要判断堆内存中的对象是否应该被回收需要依赖GC Roots来判断是否可达,而GC Roots的集合是会随时间发生变化的,所以当我们需要进行GC的时候,就需要对GCRoots进行枚举,所以怎么找的当前所有的GCRoots是一个问题,还有就是枚举是需要一定时间的,而在这段时间里堆里对象的引用关系肯定不能发生变化,否则就丢失了一致性了,所以还有一个问题就是枚举需要确保前后的一致性。要保持前后的一致性,一般的做法就是常说的”stop the world“,但是”stop the world“就意味着系统会发生停顿,对于大部分用户来说是绝对无法忍受所使用的系统频繁发生停顿的,因此GC肯定得在尽量短的时间内完成

当前大部分主流Java虚拟机使用的都是准确性GC,所以当执行系统停顿下来后,并不需要一个不漏的检查所有执行上下文和全局的引用位置,虚拟机应当是有办法直接得知哪些地方存放着对象引用,在HotSpot的实现中,是使用一组称为OopMap的数据结构来达到这个目的。

而具体是在程序执行的什么时候执行GC这个问题,HotSpot虚拟机是这样实现的,在如方法调用,循环跳转,异常跳转等指令序列复用的代码处,设置能使程序长时间执行的“安全点”,GC只在安全点处执行,当然没法保证多线程里所有线程能在同一时间到达安全点。

对于这个问题有两种解决方式,一种是抢先式中断,抢先式中断就是在要执行GC的时候把所有线程都中断,然后在检查所有线程,把未在安全点的线程恢复,执行到安全点。

现在几乎所有虚拟机都采用的是主动式中断,也就是设置一个标志,轮询标志的地方和安全点是重合的,然后各个线程执行时轮询这个标志,当发现标志为真的时候就自己中断挂起。

这个我不是很明白,标志不应该是一个bool值吗?咋还跟安全点重合了?那这样安全点又是个啥?还有一个标志位咋能给所有线程安全点的位置??标志位只有一个而线程当前的安全点应该不止一个啊!反倒是我觉得,设一个标志位,标志位的意义是系统是否需要GC,然后所有线程轮询这个标志位,发现系统需要执行GC的时候,就主动执行到下一个安全点,然后中断挂起就OK了。

上面是在线程具有执行能力的情况下的操作,但被挂起或睡眠等等没有执行能力但又没结束的线程也是需要进行GC的啊!这里的解决方式就是在这种线程可能挂起的地方设置一个安全区,在安全区域里线程中的引用关系不会发生变化,也就是只要该线程还在安全区域,则可以随时执行GC,当线程获得cpu的时候,需要查看GC是否完成,未完成的话则等待完成,GC完成后才离开安全区。

1.3 如何回收?

如何回收牵扯到的是回收算法。不同的收集器的回收算法都不一样,也各有优点。

a.回收算法

标记-清除算法(Mark-Sweep)

标记清除的思想挺简单的,就是先标记要回收的对象,然后直接回收这些被标记的对象就行了。不过这个算法有两个缺点:一个是效率问题,对所有要被回收的对象先逐个标记,然后再逐个清除,这样效率不高。另一个是空间问题,这样直接清除要回收的对象,很容易就产生大量的内存碎片,导致如果碰到大对象而无法顺利的找到一块足够的内存来分配。

复制算法(Copy)

复制算法是为了解决标记清除算法的效率问题提出的,最简单的复制算法就是把内存分为两块,每次只用其中一块,需要执行GC的时候就把当前用的这一块中不需要回收的对象复制到另一块,这在有大量对象“朝生夕死”的内存区中非常有效,现在主流的虚拟机也都是采用这种算法来进行新生代的回收的。

不过复制算法有点太浪费内存了,因为大部分时候需要复制的对象都很少。这是一个很自然的想法就是把内存区进行不对称分配,实现方法就是把内存分为一块Eden区和两块Survivor区,每次只使用一块Eden区和一块Survivor区,执行GC的时候就把当前使用的Eden区和Survivor区中存活的对象给复制到另一块Survivor区,这样就只浪费10%的内存空间了。当然还是有可能GC时存活的对象大于Survivor区的内存,所以为了应对这种情况,就需要采用内存担保的机制了,就是把多出来的对象复制到其他内存区域,如老年区。

现在大部分虚拟机的默认Eden:Survivor为8:1。至于为什么是8:1,是因为经验表明新生代的对象90%以上是朝生夕死的= =。

标记-整理算法(Mark-Compact)

标记清除算法在对象存活率较高的的时候需要进行较多的复制操作效率会变低。还有就是如果不想浪费50%的空间的话就需要进行分配担保,以应对对象可能100%存活的极端情况。而老年代中对象的存活率普遍较高,因此并不适合标记清除算法。

老年代中常采用的是标记整理算法,该算法思想是先仍然像标记清除算法那样标记,不过后续步骤是把存活的对象都向一端移动,完成后直接清理端边界外的内存。

分代算法(Generational Collection)

现在主流的虚拟机整体上都是采用的这个算法,因为对应堆里面对象的存活周期不一样的情况,采用分代算法将其“分而治之”,这是非常自然的想法。分代算法把堆内存分为新生代和老年代两块,并根据不同的特点采用不同的垃圾收集算法。

b. 垃圾收集器

深入理解JVM-读书笔记-垃圾回收&内存分配策略
这是jdk1.7之后的SpotHot虚拟机提供的收集器,新生代有Serial,ParNew,Parallel Scavenge,老年代有CMS,Serial Old(MSC),Parallel Old,以及都能用的G1。有连线的意味着能配合使用。

Serial/Serial Old收集器

Serial收集器就是最普通的单线程收集器,它在进行垃圾收集的时候会暂停所有工作线程,然后以一条单独的线程进行GC。
深入理解JVM-读书笔记-垃圾回收&内存分配策略
Serial(新生代)采用复制算法,Serial Old(老年代)采用标记-整理算法。
Serial收集器最大的优势就是简单高效。它是JVM在client模式下默认的收集器。

ParNew收集器

ParNew收集器是Serial收集器的多线程版本,其他都跟Serial一样。多线程意味着它在垃圾收集的时候能创建多条线程同时收集,因此在多个可用的处理器的情况下效率会比Serial高一些。
深入理解JVM-读书笔记-垃圾回收&内存分配策略

Parallel Scavenge/Parallel Old收集器

深入理解JVM-读书笔记-垃圾回收&内存分配策略
Parallel Scavenge收集器是新生代中使用复制算法的并行多线程收集器,与ParNew很像。Parallel Scavenge收集器的特点是其他收集器关注的是如何尽量减少Stop the world的时间,但Parallel Scavenge收集器主要关注系统的吞吐量(吞吐量就是CPU运行用户代码的时间比上CPU消耗的总时间)。
它有两个参数用于精准控制吞吐量,分别是控制最大垃圾收集时间的-XX:MaxGCPause参数和直接设置吞吐量大小的-XX:GCTimeRatio参数。还有一个自适应调节参数-XX:+UseAdaptiveSizePolicy,设置了这个参数的话,就不用在自己手动去设置Eden Survivor之比,新生代的大小,晋升年龄之类的了。
若不知道需要用哪个的时候,可以试一下Parallel Scavenge收集器,打开自适应调节参数,然后设置GCTimeRatio参数和基本的内存参数就可以了。
Parallel Old收集器是Parallel Scavenge收集器的老年代版本,jdk1.6后出现,1.6之前只能使用Parallel Scavenge加Serial Old来使用,但Serial Old 在服务器端表现并不好。

CMS收集器(Concurrent Mark Sweep)

深入理解JVM-读书笔记-垃圾回收&内存分配策略
CMS收集器收集有四个阶段,分别为初始标记,并发标记,重新标记,并发清除四个步骤,CMS收集器只有初始标记和重新标记需要系统停顿,并发标记和并发清除都是同工作线程并发进行的,因此停顿时间很短。

CMS存在的几个缺点:

  • 对CPU资源很敏感,可能因为并发而是用户程序变慢
  • 无法处理浮动垃圾,并发清除阶段由于工作线程还在运行,所以很可能产生新的本次GC无法回收的浮动垃圾
  • 基于标记-清除算法而导致空间碎片的产生,虚拟机提供了一些参数来解决这个问题

G1收集器(Garbage First)

G1收集器是SpotHot团队最新的一款面向服务器端程序的收集器,它的目的是替换掉CMS。同其他收集器比较,G1有以下特点:并行与并发,分代收集,空间整合,可预测的停顿。

2. 内存分配策略

  • 对象优先在Eden分配
  • 大对象直接进入老年代
  • 长期存活的对象将进入老年代
  • 动态对象年龄判定, 这个是说如果Survivor中某一年龄的对象的数量大于Survivor的一半则大于等于该年龄的对象可直接进入老年代,可忽略晋升年龄MaxTenuringThreshold参数的限制
  • 空间分配担保,这个解决的是minor GC后存活的对象空间可能大于Survivor区,所以需要老年区的担保,如果老年区无法担保或不允许担保,则进行full GC。