摘要

Java与C++之间有一堵由内存动态分配和垃圾收集技术所围成的高墙,墙外面的人想进去,墙里 面的人却想出来。
垃圾 收集需要完成的三件事情:
·哪些内存需要回收?
·什么时候回收?
·如何回收?

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

思维导图

image.png

为什么需要垃圾回收?

java语言中一个显著的特点就是引入了内存动态分配跟垃圾回收机制。是c/c++程序元最头痛的内存管理的问题迎刃而解;由于有个垃圾回收机制,Java中的对象不再有“作用域”概念,只有对象的引用才有“作用域”。垃圾回收可以有效的防止内存泄漏,有效的使用空闲的内存。

垃圾回收为什么只回收堆跟方法区

  • 程序计数器/栈确定性:线程私有的区域在编译期就知道内存分配的大小。
  • 程序计数器/栈线程关联性:程序计数器/栈线这块内存区域随线程而生,随线程而灭,栈 中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作;方法结束或者线程结束时,内存自然就跟随着 回收了。
  • 堆/方法区不确定性:一个接口的多个实现类需要的内存可能 会不一样,一个方法所执行的不同条件分支所需要的内存也可能不一样,只有处于运行期间,我们才 能知道程序究竟会创建哪些对象,创建多少个对象,这部分内存的分配和回收是动态的。

垃圾回收过程怎样的?

堆的垃圾回收肯定有一个触发机制:已使用空间达到80%或者90%;或者我这边分配一块大的内存空间:如上图红色数据区表示。我的堆已经不能支撑我内存空间的分配,那么就会触发垃圾回收(意思就是想要放一块内存放不进去,或者达到某个比例的时候,我们就触发垃圾回收)。

对象存活判断

1、引用计数法

原理:对象中绑定引用计数器,引用时,计数器值就加一;引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的。
image.png
图示分析:直接引用:就是使用方法栈里面局部变量表里面存放对象实例的reference指针去引用我们堆里面的实例数据。
对象实例数据引用的时候;比如我们方法执行的时候;方法栈里面的局部变量表里面存放对堆这边实例对象的指针。比如上面的reference实例对象指针。那么我们可以想到当我们的方法执行完之后,我这块局部变量表也就不存在了。对应的指针也是不存在了。局部变量表的生命周期是跟我们方法的生命周期一样的。所以我们以上的引用就断开了,引用断开了的话,那么我们堆里面的对象实例的内存是否需要释放呢?因为我们的堆。已经没有指针指向了,所以就需要释放了。那么我们如何计算栈的指针对堆的引用情况呢?那么有一种办法叫做引用计数法,其实这种方法。我们自己也能够想到。他的原理也是比较简单:只要有,栈里面局部变量表里面的指针引用我们的实例对象的时候;这个对象的引用计数加1.如果另外一个栈或者同一根栈的另外一个指针引用这块红色对象区域的时候,同样引用计数器加1,最后方法执行完之后,通知堆将其对应引用计数器减1.最后对象实例的引用计数器就会变成0,当我们内存达到一定比例之后,将会被回收。

引用计数优缺点:
优点:简单、快捷。维护起来方便。不用在触发垃圾回收的时候算哪些对象存活,哪些对象不存活。我只需要算一下其对应的引用计数器等于0就回收,不等于0就将它进行内存存放。
缺点:难解决对象之间相互循环引用的问题。

案例分析:

/**
 * 引用计数器GC
 */
public class ReferenceCounterGC {
    public Object instance = null;
    /**
     * 给对象分配内存
     * @param args
     */
    private static final int num = 1024*1024;
    private byte[] bigSize = new byte[2*num];//分配2M内存

    public static void main(String[] args) {
        ReferenceCounterGC objA = new ReferenceCounterGC();
        ReferenceCounterGC objB = new ReferenceCounterGC();
        objA.instance = objB;
        objB.instance = objA;
        objA = null;
        objB = null;
        /**
         * 上面只是把引用局部变量表里面的指针指向了null;但是其对应的引用计数器却不为0
         * 使用引用计数法是没法回收的;
         * 那java虚拟机是如何做的呢?java虚拟机到底是如何实现这种垃圾回收的呢?
         */
        /**
         * 我们可以通过设置vm options再通过:System.gc打印出回收情况。
         * -verbose:gc -XX:+PrintGCDetails
         */
        System.gc();
    }
}

vm options参数配置:

-verbose:gc -XX:+PrintGCDetails 

打印出日志:

Connected to the target VM, address: '127.0.0.1:55314', transport: 'socket'
[GC (System.gc()) [PSYoungGen: 8051K->727K(76288K)] 8051K->735K(251392K), 0.0006944 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (System.gc()) [PSYoungGen: 727K->0K(76288K)] [ParOldGen: 8K->450K(175104K)] 735K->450K(251392K), [Metaspace: 2936K->2936K(1056768K)], 0.0045384 secs] [Times: user=0.04 sys=0.00, real=0.00 secs] 
Heap
 PSYoungGen      total 76288K, used 1966K [0x000000076ab00000, 0x0000000770000000, 0x00000007c0000000)
  eden space 65536K, 3% used [0x000000076ab00000,0x000000076aceb9e0,0x000000076eb00000)
  from space 10752K, 0% used [0x000000076eb00000,0x000000076eb00000,0x000000076f580000)
  to   space 10752K, 0% used [0x000000076f580000,0x000000076f580000,0x0000000770000000)
 ParOldGen       total 175104K, used 450K [0x00000006c0000000, 0x00000006cab00000, 0x000000076ab00000)
  object space 175104K, 0% used [0x00000006c0000000,0x00000006c0070bd8,0x00000006cab00000)
 Metaspace       used 2942K, capacity 4556K, committed 4864K, reserved 1056768K
  class space    used 312K, capacity 392K, committed 512K, reserved 1048576K
Disconnected from the target VM, address: '127.0.0.1:55314', transport: 'socket'

从运行结果中可以清楚看到内存回收日志中包含“8051K->727K”,意味着虚拟机并没有因为这两 个对象互相引用就放弃回收它们,这也从侧面说明了Java虚拟机并不是通过引用计数算法来判断对象 是否存活的。

日志信息解释:
YoungGC日志解释如下:
image.png
FullGC:
image.png
规律:
image.png

2、引用计数法

回顾:上面我们讲解了判断一个对象是否需要回收的算法,我们称之为:引用计数法。
1、循环引用问题引用计数法没法解决。
2、但是我们跑了循环引用代码后,使用HotSpot虚拟机,发现我们的循环引用的堆内存空间也被垃圾回收了。那他使用的是哪种算法呢?这就是本节我们需要学习的方法。

含义:我们从可达性分析的名字入手;什么是可达性:我们从一个点或者一个面出发到达另外一个点或者另外一个面。我们就称其为可达性。可达性分析就是我们从某一个点或者某一个面出发然后到另外一个点或者另外一个面。这就是我们从可达性分析字面得到的信息。

原理:通过“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到GC Roots间没有任何引用链相连, 或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的。

image.png
比如上图我们方法栈里面的局部变量表里面有一个reference;我们从reference出发,通过每一个蓝色的引用链。引用链如果能够指向堆里面的内存的话。我们就说这种就是可达性。
上面连接的四个对象是通过GC Root可达的,所以我们可以认为他是可达的,是不能被垃圾回收的。然而上面孤立的对象,我们通过局部变量表里面的reference的GC Root引用链是不能达到的,所以我们把这个对象标记为需要待回收对象。他不是马上回收,而是被标记为:待回收。经过指定次数的标记,我们最终才会将其对象回收。其他对象是有引用链的,所以不能回收,引用链的出发点,我们将其叫做GC Root;上面的线条就是引用链。

GCRoots的对象:
·在虚拟机栈(栈帧中的本地变量表)中引用的对象:譬如各个线程被调用的方法堆栈中使用到的 参数、局部变量、临时变量等。
·在方法区中类静态属性引用的对象:譬如Java类的引用类型静态变量。
·在方法区中常量引用的对象:譬如字符串常量池(String Table)里的引用。

3、生存还是死亡?

即使在可达性分析算法中判定为不可达的对象,也不是“非死不可”的,这时候它们暂时还处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记,随后进行一次筛选,筛选的条件是此对象是 否有必要执行finalize()方法。假如对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,那么虚拟机将这两种情况都视为“没有必要执行”。

垃圾回收算法

1、分代收集理论

分代收集名为理论,实质是一套符合大多数程序运行实际情况的经验法则,它建立在两个分代假说之上:
1)弱分代假说(Weak Generational Hypothesis):绝大多数对象(98%)都是朝生夕灭的。
2)强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡。

这两个分代假说共同奠定了多款常用的垃圾收集器的一致的设计原则:收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。显而易见,如果一个区域中大多数对象都是朝生夕灭,难以熬过垃圾收集过程的话,那 么把它们集中放在一起,每次回收时只关注如何保留少量存活而不是去标记那些大量将要被回收的对 象,就能以较低代价回收到大量的空间;如果剩下的都是难以消亡的对象,那把它们集中放在一块, 虚拟机便可以使用较低的频率来回收这个区域,这就同时兼顾了垃圾收集的时间开销和内存的空间有 效利用。

把分代收集理论具体放到现在的商用Java虚拟机里,设计者一般至少会把Java堆划分为新生代 (Young Generation)和老年代(Old Generation)两个区域.

2、标记-清除法

最早出现也是最基础的垃圾收集算法是“标记-清除”(Mark-Sweep)算法,在1960年由Lisp之父 John McCarthy所提出。如它的名字一样,算法分为“标记”和“清除”两个阶段:首先标记出所有需要回 收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回 收所有未被标记的对象。标记过程就是对象是否属于垃圾的判定过程,

原理:顾名思义:分为标记、清除两步骤:先通过可达性分析法将对象标记;然后将垃圾对象清除。

image.png

图例我们通过上图知道:
1、黑色表示的是需要垃圾回收的标记对象。浅蓝色是具有GC引用链的需要保存的对象。白色区域表示的是还没有被使用的对象。所以上面这块大的内存就分为3种:垃圾、不需要被回收的对象、还没有被使用内存。上面是垃圾回收前的情况。
2、回收后:标记为垃圾的内存对象被垃圾回收,这部分内存变成未被使用的内存-变成白色。
我们发现一个问题:就是垃圾回收之后的内存快(浅蓝色部分)变的不连续了。就是已使用内存跟未使用内存之间变得断断续续。这个就是标记清除算法的一个缺点,标记清除之后内存快不连续,内存快不连续会造成:比如我们现在需要分配内存为5个格子空间的对象。那么我们将不能分配对象。

缺点:

效率/时间:如果Java堆中包含大量对 象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过 程的执行效率都随对象数量增长而降低。

空间:内存空间的碎片化问题,标记、清除之后会产生大 量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

3、标记-复制法

原理:将可用内存分成2块;将标记为存活对象出发垃圾回收时候移动到另一块,然后垃圾对象一次性清除。
优点:1、对于多数对象都是可回收的情况:只需要将少量存活对象移动到另一块区域 2、通过要移动堆顶指针,解决了空间碎片的复杂情况。
缺点:1、对于大多数是存活对象的情况,效率低,2、这种复制回收算法的代价是将可用内存缩小为了原来的一半。

image.png
为了解决标记-清除算法面对大量可回收对象时执行效率低的问题。将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着 的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。如果内存中多数对象都是存 活的,这种算法将会产生大量的内存间复制的开销,但对于多数对象都是可回收的情况,算法需要复 制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有 空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可。这样实现简单,运行高效,不过其缺陷 也显而易见,这种复制回收算法的代价是将可用内存缩小为了原来的一半,空间浪费未免太多了一 点。

现在虚拟机采用方式:现在的商业虚拟机都采⽤这种收集算法来回收新⽣代,我们将java堆还可以分为新生代跟老年代;复制算法主要是回收新生代的对象;研究表明,新⽣代中的对象 98%是“朝⽣夕死”的,所以我们有很多对象都需要回收的;只有少部分的数据才会移动,更多的对象是会回收的。新生代又可以进行划分:Eden 区跟Survivor 区;对象分配的时候是先分配到Eden 区;我们将对象回收时候所保留的对象存放到Survivor 区;Survivor 区主要分为两块:一块是 Survivor from一块是Survivor to。
所以并不需要按照 1:1 的⽐例来划分内存空间,⽽是将内存分为⼀块较⼤的 Eden 空间和两块较⼩的 Survivor 空间,每次使⽤ Eden 和其中⼀块 Survivor。 Survivor from 和Survivor to,内存⽐例 8:1:1

当回收时,将Eden 和 Survivor 中还存活着的对象⼀次性地复制到另外⼀块 Survivor(Survivor to区) 空间上,当垃圾回收完毕之后;我们又将Survivor to区存活的对象拷贝到Survivor from区。以此循环。最后清理掉 Eden 和刚才⽤过的 Survivor 空间。HotSpot 虚拟机默认 Eden 和 Survivor 的⼤⼩⽐例是 8:1, 也就是每次新⽣代中可⽤内存空间为整个新⽣代容量的 90% (80%+10%),只有 10% 的内存会被“浪费”。当然,98%的对象可回收只是⼀般场景下的数 据,我们没有办法保证每次回收都只有不多于 10%的对象存活,当 Survivor 空间不够⽤时,需要依赖其他内存(这⾥指⽼年代)进⾏分配担保(Handle Promotion)。

4、标记-整理算法

回顾:上一节我们讲解了java垃圾回收的一个算法:复制算法。复制算法主要用在新生代里面的对象回收。新生代里面的98%对象是“朝存夕亡”的,所以也就是说我们对这些朝存夕亡的对象可以采用复制算法。主要原因就是复制算法将我们存活的对象复制到另一块内存里面。但是除了我们的新生代之外,java对象里面还有老年代。所以针对于这些老年代的话,对象存活的周期就比较长。对存活时间长的对象进行垃圾回收的话,是怎样的呢?我们用复制算法来回收这些存活时间比较长的对象的话,效率就比较低。(因为我们存活的对象都要复制到另外一块对象上,就会存在很多对象的复制,所以牵涉到大量的对象迁移,效率比较低下),所以复制算法在存活率比价高的区域就不受用。而且还会浪费50%的空间,因为我们会开辟两块内存。所以针对于这种存活时间比较长的对象我们需要使用标记整理算法。

标记-复制算法在对象存活率较高时就要进行较多的复制操作,效率将会降低。更关键的是,如果 不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存 活的极端情况,所以在老年代一般不能直接选用这种算法。

image.png
标记-清除算法与标记-整理算法的本质差异在于前者是一种非移动式的回收算法,而后者是移动 式的。是否移动回收后的存活对象是一项优缺点并存的风险决策:如果移动存活对象,尤其是在老年代这种每次回收都有大量对象存活区域,移动存活对象并更新 所有引用这些对象的地方将会是一种极为负重的操作,而且这种对象移动操作必须全程暂停用户应用 程序才能进行[1],这就更加让使用者不得不小心翼翼地权衡其弊端了,像这样的停顿被最初的虚拟机 设计者形象地描述为“Stop The World”[2]。

优点:
1、解决了复制算法中在老年代频繁复制效率低下问题。
2、通过移动指针内存 消除了空间碎片化。


startshineye
91 声望26 粉丝

我在规定的时间内,做到了我计划的事情;我自己也变得自信了,对于外界的人跟困难也更加从容了,我已经很强大了。可是如果我在规定时间内,我只有3分钟热度,哎,我不行,我就放弃了,那么这个就是我自己的问题,因为你自己...