【Java杂货铺】JVM#Java高墙之GC与内存分配策略

Java与C++之间有一堵由内存动态分配和垃圾回收技术所围成的“高墙”,墙外的人想进去,墙外的人想出来。——《深刻理解Java虚拟机》java

前言

上一章看了高墙的一半,接下来看另外一半——GC。算法

为何须要GC和内存分配策略?当须要排查各类内存溢出、内存泄漏问题时,当垃圾回收成为系统达到更高并发量的瓶颈时,咱们就须要对这些“自动化”的技术实施必要的控制和调节。数组

程序计数器、虚拟机栈、本地方法栈生命周期时伴随着线程的,因此更多的须要考虑Java堆和方法区的垃圾回收。咱们只有在程序处于运行期间时才能知道会建立哪些对象,这部份内存的分配和回收都是动态的。缓存

对象已死吗?

如何判断对象是没有用了,该块内存能够被GC回收掉了。主要有两个方法。安全

引用计数算法

就是每一个对象都有个计数器,若是有一个地方对该对象有引用,计数器就加1,不然就减1,知道计数器的值为0的时候就说明这个对象没有被使用了,能够回收之。可是,主流的Java虚拟机都没有使用这个方法,由于没法解决循环引用的问题。好比有个对象A,引用了对象B,同时对象B又引用了对象A,此时两个对象的计数器都是1,可是这两个对象在逻辑上已经没有用了,白白占用了内存空间。服务器

可达性分析算法

主流的虚拟机使用的都是这个算法来判断对象是否存活(或者被使用)。这个算法的基本思路就是经过一系列的被称为“GC Roots”的对象做为起始点,从这些节点开始向下搜索,搜索所通过的路径被称为引用链(搜索的是引用,不是对象自己)。当一个对象到GC Roots没有任何引用链相链接的时候,就被视为不可用了。例如大佬书中很是经典的图,Object五、Object六、Object7 都是能够被回收的对象。数据结构

做为GC Roots的对象包括一下几种:多线程

  1. 虚拟机栈(栈帧中的本地本地变量表)中引用的对象。
  2. 方法去中类静态属性引用的对象。
  3. 方法区中常量引用的对象。
  4. 本地方法栈中JNL(Native方法)引用的对象。

引用类型

Java引用的定义很传统:若是reference类型的数据中储存的数值表明的是另一块内存的起始地址,就称这块内存表明着一个引用。可是有些引用符合引用定义,可是此引用所指向的对象可能已经不可用了。因此对传统定义加强的解释就是:当内存空间还足够时,则能保存在内存之中;若是内存空间在进行垃圾收集后仍是很是紧张,则能够抛弃这些对象,不少系统的缓存功能都符合这个定义。并发

因此,引用就被分红了4种类型。高并发

  1. 强引用:最多见的引用,就是new个对象,该对象可达GC Roots。只要又强引用在,GC永远不会回收该空间。
  2. 软引用:软引用用来描述一些还有用但不是必须的对象。软引用在内存溢出异常以前,将会对这些对象列进回收范围之中进行第二次回收。若是此次回收尚未足够空间,才会抛出内存溢出异常。
  3. 弱引用:弱引用也是用来描述非必须的对象的,只是强度弱于软引用,弱引用所关联的对象只能生存到下一次垃圾回收发生以前,不管内存是否够用,都会回收之。
  4. 虚引用:形同虚设的引用。一个对象是否虚引用的存在,彻底不会对其生存时间构成影响,也没法经过虚引用来取得一个对象实例。为一个对象设置虚引用关联的惟一目的就是能在这个对象被回收器回收时收到一个系统通知。

finalize()的做用

被检测到可达性不可达的对象,并非当即就被收回内存,至少须要经历两次标记。第一次标记并进行一次筛选,筛选条件是是否重写了finalize()方法,如没有,或者此对象已经执行过finalize()方法(一个对象最多只能执行一次finalize()方法)了,虚拟机将它视为“没有必要执行”。

若是此对象重写了finalize()方法,而且没有执行,此对象就会被放到一个F-Queue队列中,而且根据低优先级的Finalizer线程去执行它。因为Finalizer线程优先级很低,因此须要在执行线程中sleep一下子等待它的执行。Finalizer线程的执行也不必定要等它执行完才进行垃圾回收,毕竟这里面执行的任务多是很是耗时的。

在重写的finalize()方法,此对象有一次(只有一次机会,毕竟finalize()方法只能执行一次)机会挽救本身,此时能够将本身(使用this关键字)从新与引用链上的对象创建关联,可达性可达就好。

可是finalize()方法机会不多有业务上的需求,毕竟它的功能try-finally也能够完成,毕竟这对于你的某个方法来讲更具备实时性,而且更好控制。

回收方法区

这部分不是重点,毕竟如今流行的JDK1.8已经没有了方法区,而且这块空间的垃圾回收效率极低。只须要知道这块空间只要被回收的是两部分,废弃常量和无用的类就好。

废弃常量好理解,就比方说一个字符串"abc",没有再被引用,根据可达性算法这个很好判断。对于无用的类判断条件须要符合如下三条:

  1. 该类全部的实例都已经被回收,也就是Java堆中不存在该类的任何实例。
  2. 加载该类的类加载器ClassLoader已经被回收。
  3. 该对象的java.lang.Class对象没有在任何一个地方被引用,没法在任何地方经过反射访问该类的方法。

垃圾收集算法

标记-清除算法

最基础的算法就是“标记-清除”(Mark-Sweep)算法,算法分为“标记”和“清除”两个阶段:首先标记全部须要回收的对象,在标记完成后统一回收全部被标记的对象。标记清除算法有两点不足:第一就是效率问题,两个阶段效率都不高。第二个问题就是空间问题,标记清楚会产生大量碎片,让物理空间不连续,致使给较大对象分配空间的时候,很容易触发一次垃圾回收机制。

复制算法

复制算法将空间分红两个部分,一次只是用一个部分,当这一部分的空间用完了,直接将还存活的对象复制到另一部分上去,而后将这一部分使用过的空间一次性清理掉。这样就是每次只对空间的通常及逆行GC操做。这样就不须要考虑碎片整理的问题了,只要移动堆顶指针,按顺序分配内存就好了。

如今的商业虚拟机基本都用这种算法回收新生代的数据。当一次GC,新生代分为两部分,一个Eden空间和两个Survivor,这两部分大小比通常是8:1:1。当一次GC操做存活的对象超过新生代的Survivor时,就须要老年代分配担保,来补充不足的空间。

标记-整理算法

“标记-整理”算法首先将不可达的对象进行标记,而后将存活的对象向一端移动,而后直接清除掉端边界之外的内存。这样空间物理上就是连续的了。

分代收集算法

分代收集算法,是指不一样的空间根据本身的实际状况选择不一样的回收机制。通常来讲新生代使用复制算法。老年代通常使用标记-整理算法。

HotSpot算法实现

枚举根节点

因为JVM管理的内存十分的大,对象引用所占的空间可能很小而且十分零散,避免在一次GC消耗过长的时间,因此须要有种方式快速获取到对象引用。在HotSpot的虚拟机实现里面,有一个叫作OopMap的数据结构来储存这些对象引用,用于快速定位。在执行一个方法的时候,字节码层面会遇到一个OopMap记录,它经过偏移量记录着本次方法操做的字节码什么位置有引用,这样就能够找到引用了。

安全点

虽然说OopMap能够快速找到全部的引用,可是不可能为每一条指令都添加OopMap记录,毕竟这样的内存消耗是十分大的。只有在一些特定的地方才会添加OopMap记录,这些地点被称为安全点。安全点的选取须要符合“是否让程序长时间执行”的特征。“长时间执行”的最明显的特征就是指令序列的复用。比方说,方法调用、循环跳转、异常跳转等功能上。这里还须要注意一个问题,某一个线程就是当达到安全点了,要开始启动GC了,须要让整个程序都停下来,防止在GC的过程当中产生新的垃圾,让本次垃圾回收不完全。因此须要让全部的线程都到安全点,而后进行统一的垃圾回收。这里又两种机制,抢先式中断主动式中断

抢先式中断:在GC发生时,先把全部线程中断,若是发现有些线程没有在安全点,让它们恢复活跃,从新跑到安全点再中断,而后进行垃圾回收。

主动式中断:不直接对线程操做,仅仅简单设置一个标志,各个线程去轮询访问这个标志,当某个线程执行到安全点就去轮询一下,发现标志是中断状态,就将本身挂起,当全部线程都挂起的时候,就进行一次GC操做。

安全区域

进行一次GC,都须要在安全点完成,可是有些线程是没有办法等它到达安全点的,好比说sleep(),不可能全部线程都等它睡完了再继续执行。因此除了安全点,还要引入安全区域的概念。安全区域是指在一段代码片断之中,引用关系不会再发生变化,因此GC是安全的。在某个线程执行在安全区域的时候,能够随意GC,当这个线程要离开安全区域的时候,须要查看此时是否又GC操做,没有的话就能够离开,若是有GC操做,就须要等待GC完成后再离开安全区域。

垃圾收集器

几个简单的垃圾回收器

Serial收集器:这个垃圾回收器是线程工做的,当它开始回收的时候,全部线程都须要中断,用于新生代。

ParNew收集器:ParNew就是Serial的多线程版本。除了Serial之外,ParNew是惟一能够与CMS收集器配合工做的。ParNew在单线程或者数量较少的多线程下(CPU数量少)性能并不比Serial优秀,毕竟切换线程也很须要成本。此收集器也是用在新生代。

Parallel Scavenge收集器:也是用在新生代,这个收集器更在意吞吐量,即用户代码运行的时间占用用户代码和垃圾回收总时间的比重。此收集器能够动态调整参数来保证适当的停顿时间和最大的吞吐量。

Serial Old收集器:单线程的用于老年代的收集器。

Parallel Old收集器:多线程的老年代收集器。

CMS收集器

CMS是一种获取最短回收时间为目标的收集器,目前很大一部分的Java应用集中在互联网站或者B/S系统的服务器上,这类应用尤为重视服务的响应,但愿更短的停顿时间。

CMS须要四个步骤:初始标记、并发标记、从新标记、并发清除。其中初始标记和从新标记都须要让全部线程都终止。并发标记可让用户的工做线程同时运行,因此可能出现新的垃圾,从新标记就是为了解决这个问题的。

CMS有三个明显的缺点:

  1. CMS收集器对CPU资源很是敏感,当CPU数量少的时候性能极差。
  2. CMS阈值低,因为须要一部分空间留给并发,因此不能达到100%就须要开启GC。如今最高占用空间达到92%。
  3. 因为使用的“标记-清除”功能,因此会产生大量的碎片。

G1收集器

G1收集器是一款面向服务端应用的垃圾收集器。G1收集器能够做用于新生代和老年代。而且有很是好的并发并行机制,能够进行空间整理,还有个很是优秀的特色是能够预测停顿时间,可让使用者指定在固定的时间M毫秒内,垃圾回收所占用的时间不能超过N毫秒。

G1 收集器可让Java堆划分红多个Region空间(其中仍然有新生代和老年代)独自管理,这样就能够根据某个区域内进行垃圾回收。而且后台维护者一个优先列表,指定哪些Region空间先被手机。

同时为了解决不一样的Region通信问题,好比ARegion中的对象引用了BRegion内的对象,每一个Region维护着一个Remembered Set记录着这些信息。

内存分配与收回策略

对象主要分配在新生代的Eden区上,或者分配在TLAB(线程独享)上,少数状况也能够直接分配在老年代上。这取决于你使用的垃圾收集器和参数设定。下面有几条广泛的内存分配规则。

对象有限在Eden上分配

若是发现Eden上的空间不够了,会进行一次新生代GC。2个Survivor一个叫FROM,一个叫TO。当进行新生代GC的时候,Eden中的数据会复制到TO中,FROM内的数据根据年龄看是去往TO仍是进入老年代。接着TO和FROM互换姓名,而后清空Eden和TO的数据。另外老年的GC收集是新生代时间的10倍。

大对象直接进入老年代

通常来讲新生的对象会在新生代,过了一段时间,必定数量的新生代GC(默认15次)以后,存活下来的对象再被放进老年代中。可是有些比较大型的对象,好比字符串或者很是大的数组就直接放到老年代了,这样就避免了屡次新生代GC,来回复制这种超长的空间了。

长期存活的对象进入老年代

必定数量的新生代GC(MaxTenuringThreshold默认15次)以后,存活下来的对象再被放进老年代中。

动态对象年龄断定

若是再Servivor空间中相同年龄(经历GC次数)全部对象大小的总数大于Servivor空间的一半的时候,年龄大于或等于这一数值的对象直接进入老年代,无需等待MaxTenuringThreshold要求的年龄。

空间担保机制

空间担保机制就是在新生代GC的时候,若是Servivor空间不够放来自Eden的对象,能够由担保人老年代来放些数据。

在新生代GC以前,虚拟机会区检查老年代最大可用的连续空间是否大于新生代全部对象的总空间,若是大于,此次GC是安全的。若是不大于,就回去看是否开启了空间担保机制,若是开启了就会继续检查老年代最大可用的连续空间是否大于历次晋升老年代对象的平均大小,若是大于就能够冒险试一下GC,若是不大于,就会触发全局的GC(Full GC)。

为何会有这样的冒险?由于新生代多出来的数据老年代不必定放的下,毕竟没人为老年代作担保了。究竟多出来的数据能不能放下呢,这就须要经验来判断,算下历次重新生代过来的数据平均值,假定频率等于几率,来和老年代剩余的空间做比较。