垃圾回收收集器与内存分配策略

垃圾回收(GC),主要关注三个点:如何进行回收,哪些内存需要回收,什么时候回收。
在上一个文章中介绍到了程序计数器,Java虚拟机栈,本地方法栈会随着线程生而生灭而灭,同时Java虚拟机栈中的栈帧会随着方法的进入和退出执行者入栈和出栈的操作。每一个栈帧分配多少内存在类结构确定的时候就已经确认(JIT优化不算,换一句话说在编译的时候就已经确定)。因此这三个地方的垃圾回收很好做,线程结束进行操作就行(回收或者放置在别的地方)
而Java堆和方法区则比较发杂,因为一个接口的实现类可能类的所需要的内存不一样,一个方法的多个分支也可能不一样,只有在程序运行的时候才会创建这些对象,这两个部分的内存分配是动态的。
引用计数算法
这个算法是一个很简单而且高效的算法,如何一个地方被引用加+1,如果这个地方引用失效就-1。很多公司包括python其实也都是使用这个算法来对内存进行管理,但是在Java中可能并不是特别适用(例如A和B,互相引用对方的地址)
可达性分析算法
在主流的商用程序语言(Java、C#,甚至包括前面提到的在老的Lisp)的主流实现中,
都是称通过可达性分析(Reac/bility Analysis〉来判定魔象是否存活的。这个算法的基本思
路就是通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,捜索
.所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连
,(用图论的话来说,就是庆GC Roots到这个对象不可达)时,则证明此对篆是不可用的,如OBJ5,6,7虽然依然相连但是已经是可以回收的对象了。
在Java中GC Roots的对象:虚拟机栈中引用的对象,方法区中类静态属性引用的对象,方法区中常量引用的对象,本地方法栈中JNI引用的对象。
无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象的引
用链是否可达,判定对象是否存活都与“引用”有关。在jDK 1.2 以前,Java 中的引用的定
义很传统:如果reference类型的数据中存储的数值代表的是另外块内存的起始地址,就称
这块内存代表着一个引用。这种定义很纯粹,但是太过狭隘,一个对象在这种定义下只有被
引用或者没有被引用两种状态,对于如何描述一些“食之无味,弃之可惜”的对象就显得无
能为力。我们希望能描述这样类对象: :当内存空间还足够时,则能保留在内存之中:如果
内存空间在进行垃圾收集后还是非常紧张,则可以抛弃这些对象。很多系统的缓存功能都符
合这样的应用场景。
但是在JDK1.2之后,Java对引用的概念进行了深化,引用可以分成4大类
强引用:在代码中普遍存在,例如OBJ obj = new OBJ(),这种引用不会被垃圾收集器回收
软引用:软引用是用来描述一些还有用但并非必须的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围进行第二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。软引用关联的对象不会被GC回收。JVM在分配空间时,若果Heap空间不足,就会进行相应的GC,但是这次GC并不会收集软引用关联的对象,但是在JVM发现就算进行了一次回收后还是不足(Allocation Failure),JVM会尝试第二次GC,回收软引用关联的对象。
/**
 * description: test
 * date: 2020/6/26 11:06
 *
 * @author: 张哲珲
 * version: 1.0.0
 */
public class WeakReferenceDemo {
    public static void main(String[] args) throws InterruptedException {
        //100M的缓存数据
        byte[] cacheData = new byte[100 * 1024 * 1024];
        //将缓存数据用软引用持有
        SoftReference<byte[]> cacheRef = new SoftReference<>(cacheData);
        System.out.println("第一次GC前" + cacheData);
        System.out.println("第一次GC前" + cacheRef.get());
        //进行一次GC后查看对象的回收情况
        System.gc();
        //等待GC
        Thread.sleep(500);
        System.out.println("第一次GC后" + cacheData);
        System.out.println("第一次GC后" + cacheRef.get());
        //将缓存数据的强引用去除
        cacheData = null;
        System.gc();
        //等待GC
        Thread.sleep(500);
        System.out.println("第二次GC后" + cacheData);
        System.out.println("第二次GC后" + cacheRef.get());
    }
}
//结果
第一次GC前[B@1b6d3586
第一次GC前[B@1b6d3586
第一次GC后[B@1b6d3586
第一次GC后[B@1b6d3586
第二次GC后null
第二次GC后[B@1b6d3586

弱引用:弱引用也是用来描述非必须对象的,他的强度比软引用更弱一些,被弱引用关联的对象,在垃圾回收时,如果这个对象只被弱引用关联(没有任何强引用关联他),那么这个对象就会被回收。

static Map<Object,Object> container = new HashMap<>();
public static void putToContainer(Object key,Object value){
    container.put(key,value);
}

public static void main(String[] args) {
    //某个类中有这样一段代码
    Object key = new Object();
    Object value = new Object();
    putToContainer(key,value);

    //..........
    /**
     * 若干调用层次后程序员发现这个key指向的对象没有用了,
     * 为了节省内存打算把这个对象抛弃,然而下面这个方式真的能把对象回收掉吗?
     * 由于container对象中包含了这个对象的引用,所以这个对象不能按照程序员的意向进行回收.
     * 并且由于在程序中的任何部分没有再出现这个键,所以,这个键 / 值 对无法从映射中删除。
     * 很可能会造成内存泄漏。
     */
    key = null;
}
虚引用:
一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来获取一个对象的实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。虚引用和弱引用对关联对象的回收都不会产生影响,如果只有虚引用活着弱引用关联着对象,那么这个对象就会被回收。它们的不同之处在于弱引用的get方法,虚引用的get方法始终返回null,弱引用可以使用ReferenceQueue,虚引用必须配合ReferenceQueue使用。
即使在可达性分析算法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处
于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行可
达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记并且进行一次筛
选,筛选的条件是此对象是否有必要执行finalize()方法。当对象没有覆盖finalize()方法,或
者finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”。
如果这个对象被判定为有必要执行finalize()方法,那么这个对象将会放置在一个叫做
F-Queue的队列之中,并在稍后由一个由虚拟机自动建立的、低优先级的Finalizer线程去执
行它。这里所谓的“执行”是指虚拟机会触发这个方法,但并不承诺会等待它运行结束,这
样做的原因是,如果一个对象在finalize()方法中执行缓慢,或者发生了死循环(更极端的情
况),将很可能会导致F-Queue队列中其他对象永久处于等待,甚至导致整个内存回收系统
崩溃。finalize()方法是对象逃脱死亡命运的最后一次机会,稍后GC将对F-Queuc中的对象
进行第二次小规模的标记,如果对象要在finalize()中成功拯救自己一只要重新与引用链上
的任何一个对象建立关联即可,譬如把自己(this关键字)赋值给某个类变量或者对象的成
员变量,那在第二次标记时它将被移除出“即将回收”的集合;如果对象这时候还没有逃脱,
那基本上它就真的被回收了。从代码清单3-2中我们可以看到一个对象的hnalizeO被执行,
但是它仍然可以存活。

垃圾收集算法
这边只介绍计算算法的思想和发展过程
标记—清除算法
最基础的收集算法是“标记-清除”(Mark-Sweep)算法,如同它的名字一样,算法分
为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收
所有被标记的对象,它的标记过程其实在前一节讲述对象标记判定时已经介绍过了。之所以
说它是最基础的收集算法,是因为后续的收集算法都是基于这种思路并对其不足进行改进而
得到的。它的主要不足有两个:一个是效率问题,标记和清除两个过程的效率都不高:另一
个是空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后
在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次
垃圾收集动作。标紀一清除算法的执行过程如图3-2所示。
复制算法
我们首先一起来看一下复制算法的做法,复制算法将内存划分为两个区间,在任意时间点,所有动态分配的对象都只能分配在其中一个区间(称为活动区间),而另外一个区间(称为空闲区间)则是空闲的。当有效内存空间耗尽时,JVM将暂停程序运行,开启复制算法GC线程。接下来GC线程会将活动区间内的存活对象,全部复制到空闲区间,且严格按照内存地址依次排列,与此同时,GC线程将更新存活对象的内存引用地址指向新的内存地址。此时,空闲区间已经与活动区间交换,而垃圾对象现在已经全部留在了原来的活动区间,也就是现在的空闲区间。事实上,在活动区间转换为空间区间的同时,垃圾对象已经被一次性全部回收。
标记—整理算法
复制收集算法在对象存活率较高时就要进行较多的复制操作,效率将会变低.更关健的
是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中
所有对象都100%存活的极端悄况,所以在老年代一般不能直接选用这种算法。
根据老年代的特点,有人提出了另外一种“标记-整理”(MarkCompact)算法,标记
过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让
所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存,“标记-整理”算法的
示意图如图3.4所示.
分代收集算法
当前商业虚拟机的垃圾收集都采用“分代收集” (Generational Collection)算法,这种
算法并没有什么新的思想,只是根据对象存活周期的不同将内存划分为几块。一般是把Java
堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。在新生代
中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付
岀少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间
对它进行分配担保,就必须使用“标记一清理”或者“标记一整理”算法来进行回收。

枚举根节点
从上面的GC Roots节点中有一个找引用链的操作,这个会从根节点(虚拟机栈中引用的对象,方法区中类静态属性引用的对象,方法区中常量引用的对象,本地方法栈中JNI引用的对象)开始找上下文引用(例如Java虚拟栈中的栈帧中的本地变量表)。但是现在很多大的工程,光方法区里面就几百兆了,如果要一个一个很花时间和资源。而且在资源中,是动态的,可能上一秒是可以GC的,后一秒就又要变了。所以虚拟机应当是有办法直接得知哪些地方存放着对象引用。在HotSpot中有一个OopMap的数据结构来完成这个操作。
OopMap 记录了栈上本地变量到堆上对象的引用关系。其作用是:垃圾收集时,收集线程会对栈上的内存进行扫描,看看哪些位置存储了 Reference 类型。如果发现某个位置确实存的是 Reference 类型,就意味着它所引用的对象这一次不能被回收。但问题是,栈上的本地变量表里面只有一部分数据是 Reference 类型的(它们是我们所需要的),那些非 Reference 类型的数据对我们而言毫无用处(例如0~100这种已经缓冲的数据),但我们还是不得不对整个栈全部扫描一遍,这是对时间和资源的一种浪费。
一个很自然的想法是,能不能用空间换时间,在某个时候把栈上代表引用的位置全部记录下来,这样到真正 gc 的时候就可以直接读取,而不用再一点一点的扫描了。事实上,大部分主流的虚拟机也正是这么做的,比如 HotSpot ,它使用一种叫做 OopMap 的数据结构来记录这类信息。
我们知道,一个线程意味着一个栈,一个栈由多个栈帧组成,一个栈帧对应着一个方法,一个方法里面可能有多个安全点。 gc 发生时,程序首先运行到最近的一个安全点停下来,然后更新自己的 OopMap ,记下栈上哪些位置代表着引用。枚举根节点时,递归遍历每个栈帧的 OopMap ,通过栈中记录的被引用对象的内存地址,即可找到这些对象( GC Roots )
通过上面的解释,我们可以很清楚的看到使用 OopMap 可以避免全栈扫描,加快枚举根节点的速度。但这并不是它的全部用意。它的另外一个更根本的作用是,可以帮助 HotSpot 实现准确式 GC (个人感觉这才是 OopMap 被设计出来的根本原因,提高 GC Roots Enumeration 速度更像是一个“意外的惊喜”)。
说白了就是如果我每一个都要去扫描就会很浪费资源。在Java堆里面存了很多对象,这些对象有很多很多的引用组成(从Java虚拟机栈),有些引用其实是已经被缓冲的,例如数字里面的 0~100这种。所以我只需要记录要一些真正被引用的东西就可以,一些没有必要的完全可以不记录,因此OomMap就完成了这个。
安全点
因为OopMap会记录每一个对象的引用,假设没有OomMap我们虽然会经常全局扫描但是不会有很多额外的空间,但有了OomMap就会有很多的额外空间(也就是时间换空间),但是如果OopMap也经常变化其实又会增加很多的时间,这样的话反正可能在某一些情况下不如全局扫描。
所以出现了一个制约,安全点,安全点的出现使得OomMap并不是长时间处于一个记录的状态,只有到达安全点的时候才会进行生成OomMap的操作,安全点的依据就是:是否让程序处于一个长时间执行的状态。一般在:方法跳转,循环跳转,异常跳转的时候产生安全点,然后进行OopMap,然后进行枚举根节点然后进行GC。
对于安全点还有一个要求就是发生GC的时候,如何让所有的线程都跑到最近的安全点上去。
这里有两种方案可供选择:抢
先式中断(Preemptive Suspension)和主动式中断(Voluntary Suspension)其中抢先式中断
不需要线程的执行代码主动去配合,在GC发生时,首先把所有线程全部中断,如果发现有
线程中断的地方不在安全点上,就恢复线程,让它“跑”到安全点上。现在几乎没有虚拟机
实现采用抢先式中断来暂停线程从而响应GC事件。
而主动式中断的思想是当GC需要中断线程的时候,不直接对线程操作,仅仅简单地设
置一个标志,各个线程执行时主动去轮询这个标志,发现中断标志为真时就白己中断挂起。
轮询标志的地方和安全点是重合的,另外再加上创建对象需要分配内存的地方。下面代码清
单3Y中的test指令是HotSpot生成的轮询指令,当需要暂停线程时,虚拟机把0x160100的
内存页设置为不可读,线程执行到test指令时就会产生一个自陷异常信号,在预先注册的异
常处理器中暂停线程实现等待,这样一条汇编指令便完成安全点轮询和触发线程中断。
安全区域
如果线程处于一个休息的状态,就会很尴尬,无法进行SafePoint。所以引入了安全区域,安全区域是指在一段代码片段之中,引用关系不会发生变化。在这个区域中的任意地方
开始GC都是安全的.我们也可以把Safe Region看做是被扩展了的Safepoint。在线程执行到Safb Region中的代码时,首先标识自己已经进入了 Safb Region,那样,当在
这段时间里JVM要发起GC时,就不用管标识自己为Safi: Regi加状态的线程了。在线程要离
开Safe Region Ht,它要检査系统是否已经完成了根节点枚举(或者是整个GC过程),如果完
成了,那线程就继续执行,否则它就必须等待直到收到可以安全离开Sa隹Region的信号为止。

垃圾回收器
  • serial收集器
这个收集器是一个单线程的收集器,但它的
“单线程”的意义并不仅仅说明它只会使用一个CPU或一条收集线程去完成垃圾收集工作,
更重要的是在它进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束。“Stop
The World"这个名字也许听起来很酷,但这项工作实际上是由虚拟机在后台自动发起和自动
完成的,在用户不可见的情况下把用户正常工作的线程全部停掉,这对很多应用来说都是难
以接受的。读者不妨试想一下,要是你的计算机每运行一个小时就会暂停响应5分钟,你会
有什么样的心情?图3-6示意了 Serial / Serial Old收集器的运行过程。
 
  • parnew收集器

ParNew收集器其实就是Serial收集器的多线程版本,除了使用多条线程进行垃圾收集
之外,其余行为包括Serial收集器可用的所有控制参数(例如:-XX:SurvivorRatio.
-XX:PrctenureSizeThreshold^ -XX:HandlePromotionFailure 等)、收集算法、Stop The Wbrld.
对象分配规则、回收策略等都与Serial收集器完全一样,在实现上,这两种收集器也共用了
相当多的代码。ParNew收集器的工作过程如图3-7所示.

  • parallel scavenge收集器

Parallel Scavenge收集器的特点是它的关注点与其他收集器不同,CMS等收集器的
关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目
标则是达到一个可控制的吞吐量(Throughput)。所谓吞吐量就是CPU用于运行用户代码
的时间与CPU总消耗时间的比值,即吞吐量=运行用户代码时间/ (运行用户代码时间+
垃圾收集时间),虚拟机总共运行了 100分钟,其中垃圾收集花掉I分钟,那吞吐量就是
99%.

  • serial old收集器

Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器,使用“标记一
整理”算法。这个收集器的主要意义也是在于给Client模式下的虚拟机使用。如果在Server
模式下,那么它主要还有两大用途:一种用途是在JDK 1.5以及之前的版本中与Parallel
Scavenge收集器搭配使用令,另一种用途就是作为CMS收集器的后备预案,在并发收集发生
Concurrent Mode Failure时使用。这两点都将在后面的内容中详细讲解。Serial Old收集器的
工作过程如图3-8所示。

  • parallel old收集器
  • cms收集器
CMS (Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。
目前很大一部分的Java应用集中在互联网站或者B/S系统的服务端上,这类应用尤其重视服
务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。CMS收集器就非常符合
这类应用的需求。从名字(包含“Mark Sweep")上就可以看出,CMS收集器是基于“标记-清除”算
法实现的,它的运作过程相对于前面几种收集器来说更复杂-些, 整个过程分为4个步骤,
包括:初始标记,并发标记,重新标记,并发清除。
  • g1收集器

 


 

内存分配与回收策略
大多数情况会在新生代区进行内存分配,不足的时候会想办法发起一次GC。
像过大的对象,例如数组这种会直接进入老年代,经常岀现大对象容易
导致内存还有不少空间时就提前触发垃圾收集以获取足够的连续空间来“安置”它们。
长期存活的对象会进入老年代,那么什么样的对象是长期存活的对象呢?虚拟机会给对象定义一个Age,每一次Gc就是一年,如果活下来的就是大一岁~一般默认是15岁。
此外一般的JVM都有一个动态年龄的判断,并不是只有达到15岁才会进入到老年代,如果两个对象的空间大小,而且年龄一样满足同年对象达到进入老年代一般的规则就会直接进入老年代
最后有一个空间分配担保的概念这个就很好理解,我新生进老年之前肯定要看空间足不足,不足的话先老年代GC然后新生代GC

 

 

 

posted @ 2020-06-26 15:33  smartcat994  阅读(85)  评论(0编辑  收藏  举报