转载

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

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

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

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

对象已死吗?

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

引用计数算法

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

可达性分析算法

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

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

作为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)算法,算法分为“标记”和“清除”两个阶段:首先标记所有需要回收的对象,在标记完成后统一回收所有被标记的对象。标记清除算法有两点不足:第一就是效率问题,两个阶段效率都不高。第二个问题就是空间问题,标记清楚会产生大量碎片,让物理空间不连续,导致给较大对象分配空间的时候,很容易触发一次垃圾回收机制。

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

复制算法

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

现在的商业虚拟机基本都用这种算法回收 新生代 的数据。当一次GC,新生代分为两部分, Eden空间Survivor ,这两部分大小比一般是8:1, 老年代 空间是一个Survivor,所以新生代:老年代=9:1。当一次GC操作存活的对象超过新生代的Survivor时,就需要老年代 分配担保 ,来补充不足的空间。

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

标记-整理算法

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

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

分代收集算法

分代收集算法,是指不同的空间根据自己的实际情况选择不同的回收机制。一般来说新生代使用复制算法。老年代一般使用标记-整理算法。

HotSpot算法实现

枚举根节点

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

安全点

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

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

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

安全区域

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

垃圾收集器

【Java杂货铺】JVM#Java高墙之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。前面也说了除了Eden以外,还有两个Survivor区域,内存占比大概是8:1:1。新生代:老年代=9:1.另外老年的GC收集是新生代时间的10倍。

大对象直接进入老年代

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

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

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

动态对象年龄判定

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

空间担保机制

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

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

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

原文  https://juejin.im/post/5d738b84e51d4561c83e7cc2
正文到此结束
Loading...