zoukankan      html  css  js  c++  java
  • JVM-标记清除算法原理

    参考:

    https://www.jianshu.com/p/74727c856da4

    https://www.cnblogs.com/Leo_wl/p/3269590.html

    https://segmentfault.com/a/1190000015724577

    https://www.cnblogs.com/leisurelylicht/p/GC-biao-jiqing-chu-suan-fa-Mark-Sweep-GC.html

    标记-清除算法原理及优缺点

    当成功区分出内存中存活对象和死亡对象后,GC接下来的任务就是执行垃圾回收,释放掉吴用对象所占用的内存空间,以便有足够的可用内存空间为新对象分配内存。
    目前在JVM中比较常见的三种垃圾收集算法是标记-清除算法(Mark-Sweep)、复制算法(Copying)、标记-压缩算法(Mark-Compact)。

    背景:

    标记---清除算法(Mark-Sweep)是一种非常基础和常见的垃圾收集算法,该算法被J.McCarthy等人在1960年提出并并应用于Lisp语言。

    执行过程:

    当堆中的有效内存空间(available memory)被耗尽的时候,就会停止整个程序(也被称为stop the world),然后进行两项工作,第一项则是标记,第二项则是清除。

    • 标记: Collector从引用根结点开始遍历,标记所有被引用的对象。一般是在对象的Header中记录为可达对象
    • 清除: Collector对堆内存从头到尾进行线性的遍历,如果发现某个对象在其Header中没有标记为可达对象,则将其回收


       
       

    缺点

    • 效率不算高
    • 在进行GC的时候,需要停止整个应用程序,导致用户体验差
    • 这种方式清理出来的空闲内存是不连续的,产生内存碎片。需要维护一个空闲列表

    注意:何为清除?

    这里所谓的清除并不是真的置空,而是把需要清除的对象地址保存在空闲的地址列表里。下次有新对象需要加载时,判断垃圾的位置空间是否够,如果够,就存放。

    作者:Shaw_Young
    链接:https://www.jianshu.com/p/74727c856da4
    来源:简书
    著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
     
     
     
     
     
     
     

    相信不少猿友看到标题就认为LZ是标题党了,不过既然您已经被LZ忽悠进来了,那就好好的享受一顿算法大餐吧。不过LZ丑话说前面哦,这篇文章应该能让各位彻底理解标记/清除算法,不过倘若各位猿友不能在五分钟内看完,那就不是LZ的错啦。

           好了,前面只是小小开个玩笑,让各位猿友放松下心情。下面即将与各位分享的,是GC算法中最基础的算法------标记/清除算法。如果搞清楚这个算法,那么后面两个就完全是小菜一碟了。

           首先,我们回想一下上一章提到的根搜索算法,它可以解决我们应该回收哪些对象的问题,但是它显然还不能承担垃圾搜集的重任,因为我们在程序(程序也就是指我们运行在JVM上的JAVA程序)运行期间如果想进行垃圾回收,就必须让GC线程与程序当中的线程互相配合,才能在不影响程序运行的前提下,顺利的将垃圾进行回收

           为了达到这个目的,标记/清除算法就应运而生了。它的做法是当堆中的有效内存空间(available memory)被耗尽的时候,就会停止整个程序(也被成为stop the world),然后进行两项工作,第一项则是标记,第二项则是清除

           下面LZ具体解释一下标记和清除分别都会做些什么。

           标记:标记的过程其实就是,遍历所有的GC Roots,然后将所有GC Roots可达的对象标记为存活的对象。

           清除:清除的过程将遍历堆中所有的对象,将没有标记的对象全部清除掉。

           其实这两个步骤并不是特别复杂,也很容易理解。LZ用通俗的话解释一下标记/清除算法,就是当程序运行期间,若可以使用的内存被耗尽的时候,GC线程就会被触发并将程序暂停,随后将依旧存活的对象标记一遍,最终再将堆中所有没被标记的对象全部清除掉,接下来便让程序恢复运行

           下面LZ给各位制作了一组描述上面过程的图片,结合着图片,我们来直观的看下这一过程,首先是第一张图。

              这张图代表的是程序运行期间所有对象的状态,它们的标志位全部是0(也就是未标记,以下默认0就是未标记,1为已标记),假设这会儿有效内存空间耗尽了,JVM将会停止应用程序的运行并开启GC线程,然后开始进行标记工作,按照根搜索算法,标记完以后,对象的状态如下图。

             可以看到,按照根搜索算法,所有从root对象可达的对象就被标记为了存活的对象,此时已经完成了第一阶段标记。接下来,就要执行第二阶段清除了,那么清除完以后,剩下的对象以及对象的状态如下图所示。

             可以看到,没有被标记的对象将会回收清除掉,而被标记的对象将会留下,并且会将标记位重新归0。接下来就不用说了,唤醒停止的程序线程,让程序继续运行即可。

      

             其实这一过程并不复杂,甚至可以说非常简单,各位说对吗。不过其中有一点值得LZ一提,就是为什么非要停止程序的运行呢?

             这个其实也不难理解,LZ举个最简单的例子,假设我们的程序与GC线程是一起运行的,各位试想这样一种场景。

             假设我们刚标记完图中最右边的那个对象,暂且记为A,结果此时在程序当中又new了一个新对象B,且A对象可以到达B对象。但是由于此时A对象已经标记结束,B对象此时的标记位依然是0,因为它错过了标记阶段。因此当接下来轮到清除阶段的时候,新对象B将会被苦逼的清除掉。如此一来,不难想象结果,GC线程将会导致程序无法正常工作。

             上面的结果当然令人无法接受,我们刚new了一个对象,结果经过一次GC,忽然变成null了,这还怎么玩?

             

    用户空间与内核空间,进程上下文与中断上下文[总结]

             到此为止,标记/清除算法LZ已经介绍完了,下面我们来看下它的缺点,其实了解完它的算法原理,它的缺点就很好理解了。

             1、首先,它的缺点就是效率比较低(递归与全堆对象遍历),而且在进行GC的时候,需要停止应用程序,这会导致用户体验非常差劲,尤其对于交互式的应用程序来说简直是无法接受。试想一下,如果你玩一个网站,这个网站一个小时就挂五分钟,你还玩吗?

             2、第二点主要的缺点,则是这种方式清理出来的空闲内存是不连续的,这点不难理解,我们的死亡对象都是随即的出现在内存的各个角落的,现在把它们清除之后,内存的布局自然会乱七八糟。而为了应付这一点,JVM就不得不维持一个内存的空闲列表,这又是一种开销。而且在分配数组对象的时候,寻找连续的内存空间会不太好找。

             看完它的缺点估计有的猿友要忍不住吐糟了,“这么说这个算法根本没法用嘛,那LZ还介绍这么个玩意干什么。”

             猿友们莫要着急,一个算法有缺点,高人们自然会想尽办法去完善它的。而接下来我们要介绍的两种算法,皆是在标记/清除算法的基础上优化而产生的。具体的内容,下一次LZ再和各位分享。

             本次的分享就到此结束了,希望各位看完都能有所收获,0.0。

    1、前言

      最近在学习linux内核方面的知识,经常会看到用户空间与内核空间及进程上下文与中断上下文。看着很熟悉,半天又说不出到底是怎么回事,有什么区别。看书过程经常被感觉欺骗,似懂非懂的感觉,很是不爽,今天好好结合书和网上的资料总结一下,加深理解。

    2、用户空间与内核空间  

      我们知道现在操作系统都是采用虚拟存储器,那么对32位操作系统而言,它的寻址空间(虚拟存储空间)为4G(2的32次方)。操心系统的核心是内核,独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的所有权限。为了保证用户进程不能直接操作内核,保证内核的安全,操心系统将虚拟空间划分为两部分,一部分为内核空间,一部分为用户空间。针对linux操作系统而言,将最高的1G字节(从虚拟地址0xC0000000到0xFFFFFFFF),供内核使用,称为内核空间,而将较低的3G字节(从虚拟地址0x00000000到0xBFFFFFFF),供各个进程使用,称为用户空间。每个进程可以通过系统调用进入内核,因此,Linux内核由系统内的所有进程共享。于是,从具体进程的角度来看,每个进程可以拥有4G字节的虚拟空间。空间分配如下图所示:

      有了用户空间和内核空间,整个linux内部结构可以分为三部分,从最底层到最上层依次是:硬件-->内核空间-->用户空间。如下图所示:

      需要注意的细节问题:

    (1) 内核空间中存放的是内核代码和数据,而进程的用户空间中存放的是用户程序的代码和数据。不管是内核空间还是用户空间,它们都处于虚拟空间中。 

    (2) Linux使用两级保护机制:0级供内核使用,3级供用户程序使用。

      内核态与用户态:

    (1)当一个任务(进程)执行系统调用而陷入内核代码中执行时,称进程处于内核运行态(内核态)。此时处理器处于特权级最高的(0级)内核代码中执行。当进程处于内核态时,执行的内核代码会使用当前进程的内核栈。每个进程都有自己的内核栈。

    (2)当进程在执行用户自己的代码时,则称其处于用户运行态(用户态)。此时处理器在特权级最低的(3级)用户代码中运行。当正在执行用户程序而突然被中断程序中断时,此时用户程序也可以象征性地称为处于进程的内核态。因为中断处理程序将使用当前进程的内核栈。

    参考资料:

    http://blog.csdn.net/f22jay/article/details/7925531

    http://blog.csdn.net/zhangskd/article/details/6956638

    http://blog.chinaunix.net/uid-26838492-id-3162146.html

    3、进程上下文与中断上下文

      我在看《linux内核设计与实现》这本书的第三章进程管理时候,看到进程上下文。书中说当一个程序执行了系统调用或者触发某个异常(软中断),此时就会陷入内核空间,内核此时代表进程执行,并处于进程上下文中。看后还是没有弄清楚,什么是进程上下文,如何上google上面狂搜一把,总结如下:

      程序在执行过程中通常有用户态和内核态两种状态,CPU对处于内核态根据上下文环境进一步细分,因此有了下面三种状态:

    (1)内核态,运行于进程上下文,内核代表进程运行于内核空间。
    (2)内核态,运行于中断上下文,内核代表硬件运行于内核空间。
    (3)用户态,运行于用户空间。

      上下文context: 上下文简单说来就是一个环境。

      用户空间的应用程序,通过系统调用,进入内核空间。这个时候用户空间的进程要传递 很多变量、参数的值给内核,内核态运行的时候也要保存用户进程的一些寄存 器值、变量等。所谓的“进程上下文”,可以看作是用户进程传递给内核的这些参数以及内核要保存的那一整套的变量和寄存器值和当时的环境等。

      相对于进程而言,就是进程执行时的环境。具体来说就是各个变量和数据,包括所有的寄存器变量、进程打开的文件、内存信息等。一个进程的上下文可以分为三个部分:用户级上下文、寄存器上下文以及系统级上下文。

    (1)用户级上下文: 正文、数据、用户堆栈以及共享存储区;
    (2)寄存器上下文: 通用寄存器、程序寄存器(IP)、处理器状态寄存器(EFLAGS)、栈指针(ESP);
    (3)系统级上下文: 进程控制块task_struct、内存管理信息(mm_struct、vm_area_struct、pgd、pte)、内核栈。

        当发生进程调度时,进行进程切换就是上下文切换(context switch).操作系统必须对上面提到的全部信息进行切换,新调度的进程才能运行。而系统调用进行的模式切换(mode switch)。模式切换与进程切换比较起来,容易很多,而且节省时间,因为模式切换最主要的任务只是切换进程寄存器上下文的切换。

      硬件通过触发信号,导致内核调用中断处理程序,进入内核空间。这个过程中,硬件的 一些变量和参数也要传递给内核,内核通过这些参数进行中断处理。所谓的“ 中断上下文”,其实也可以看作就是硬件传递过来的这些参数和内核需要保存的一些其他环境(主要是当前被打断执行的进程环境)。中断时,内核不代表任何进程运行,它一般只访问系统空间,而不会访问进程空间,内核在中断上下文中执行时一般不会阻塞。

     
     
     
     
     
     
     

    垃圾回收算法|GC标记-清除算法

    什么是GC标记-清除算法(Mark Sweep GC)

    GC 标记-清除算法由标记阶段清除阶段构成。在标记阶段会把所有的活动对象都做上标记,然后在清除阶段会把没有标记的对象,也就是非活动对象回收。

    名词解释:

    在 GC 的世界里对象指的是通过应用程序利用的数据的集合。是 GC 的基本单位。一般由头(header)和域(field)构成。

    活动对象:能通过引用程序引用的对象就被称为活动对象。(可以直接或间接从全局变量空间中引出的对象)

    非活动对象:不能通过程序引用的对象呗称为非活动对象。(这就是被清除的目标)

    标记-清除算法的伪代码如下所示:

    func mark_sweep(){
        mark_phase()   // 标记阶段
        sweep_phase()  // 清除阶段
    } 

    标记阶段

    标记阶段就是遍历对象并标记的处理过程。

    标记阶段伪代码如下:

    func mark_phase(){
        for (r : $roots)  // 在标记阶段,会给所有的活动对象打上标记
            mark(*r)
    }
    
    func mark(){
        if (obj.mark == False)
            obj.mark = True            // 先标记找出的活动对象
            for (child: children(obj)) // 然后递归的标记通过指针数组能访问到的对象
                mark(*child)
    }
    这里 $root 是指针对象的起点,通过$root 可以遍历全部活动对象。

    下图是标记前和标记后内存中堆的状态

    执行 GC 前堆的状态

    执行 GC 后堆的状态

    清除阶段

    在清除阶段,collector 会遍历整个堆,回收没有打上标记的对象(垃圾),使其能再次利用。

    sweep_phase() 函数伪代码实现如下:

    func sweep_phase(){
        sweeping = $heap_start            // 首先将堆的首地址赋值给 sweeping
        while(sweeping < $head_end){
            if(sweeping.mark == TRUE)
                // 如果是标记状态就设为 FALSE,如果是活动对象,还会在标记阶段被标记为 TRUE
                sweeping.mark == FALSE    
            else:
                sweeping.next = $free_list   // 将非活动对象 拼接到 $free_list 头部位置
                $free_list = sweeping
            sweeping += sweeping.size
        }     
    }
    size 域指的是存储对象大小的域,在对象头中事先定义。

    next 域只在生成空闲链表以及从空闲链表中取出分块时才会用到。

    分块(chunk) 这里是指为利用对象而事先准备出来的空间。

    内存中区块的块生路线为 分块-->活动对象-->垃圾—>分块-->...

    在清除阶段我们会把非活动回收再利用。回收对象就是把对象作为分块,连接到被称为空闲链表的单向链表。之后再分配空间时只需遍历这个空闲链表就可以了找到分块了。

    下图是清除阶段结束后堆的状态:

    清除阶段结束后堆的状态

    分配

    回收垃圾的目的是为了能再次分配

    当程序申请分块时,怎样才能把大小合适的分块分配给程序呢?

    分配伪代码如下:

    func new_obj(size){  // size 是需要的分块大小
        chunk = pickup_chunk(size, $free_list)  // 遍历 $free_list 寻找大于等于 size 的分块
        if(chunk != NULL)  
            return chunk
        else
            allocation_fail()   // 如果没找到大小合适的分块 提示分配失败
    }

    pickup_chunk()函数不止返回和 size 大小相同的分块,也会返回大于 size 大小的分块(这时会将其分割成 size 大小的分块和去掉 size 后剩余大小的分块,并把剩余部分还给空闲链表)。

    分配策略有三种 First-fit,Best-fit,Worst-fit

    First-fit:发现大于等于 size的分块立刻返回

    Best-fit:找到大小和 size 相等的分块再返回

    `Worst-fit:找到最大的分块,然后分割成 size 大小和剩余大小(这种方法容易产生大量小的分块

    合并

    根据分配策略的不同,分配过程中会出现大量小的分块,如果分块是连续的,我们就可以把小分块合并成一个大的分块,合并是在清除阶段完成的,包含了合并策略的清除代码如下:

    func sweep_phase(){
        sweeping = $heap_start            // 首先将堆的首地址赋值给 sweeping
        while(sweeping < $head_end){
            if(sweeping.mark == TRUE)
                // 如果是标记状态就设为 FALSE,如果是活动对象,还会在标记阶段被标记为 TRUE
                sweeping.mark == FALSE    
            else:
                if(sweeping == $free_list + $free_list.size)  // 堆的地址正好和空闲链表大小相同
                    $free_list.size += sweeping.size
                else
                    sweeping.next = $free_list   // 将非活动对象 拼接到 $free_list 头部位置
                    $free_list = sweeping
            sweeping += sweeping.size
        }     
    }
    $heap_end = $heap_start + HEAP_SIZE

    所以这里sweeping == $free_list + $free_list.size可以理解为需要清除的堆的地址正好和空闲链接相邻

    优/缺 点

    优点

    • 实现简单
    • 保守式 GC 算法兼容

    缺点

    • 碎片化严重(由上面描述的分配算法可知,容易产生大量小的分块
    • 分配速度慢(由于空闲区块是用链表实现,分块可能都不连续,每次分配都需要遍历空闲链表,极端情况是需要遍历整个链表的。
    • 写时复制技术不兼容
    写时复制(copy-on-write)是众多 UNIX 操作系统用到的内存优化的方法。比如在 Linux 系统中使用 fork() 函数复制进程时,大部分内存空间都不会被复制,只是复制进程,只有在内存中内容被改变时才会复制内存数据。

    但是如果使用标记清除算法,这时内存会被设置标志位,就会频繁发生不应该发生的复制。

    多个空闲链表

    上面所说的标记清除算法只用到了一个空闲链表对大小不一的分块统一处理。但这样做每次都需要遍历一遍来寻找大小合适的分块,非常浪费时间。

    这里我们使用多个空闲链表的方法来存储非活动对象。比如:将两个字的分块组成一个空闲链表,三个字的分块组成另一个空闲链表,等等。。

    这时,如果需要分配三个字的分块,那我们只需要查询对应的三个字的空闲链表就可以了。

    到底需要制造多少个空闲链表呢?

    因为通常程序不会 申请特别大的分块,所以我们通常给分块大小设置一个上限,比如100,大于这个上限的组成一个特殊的空闲链表。这样101 个空闲链表就够了。

    位图标记

    在单纯的 GC 标记-清除算法中,用于标记的位是被分配到对象头中的。算法是把对象和头一并处理,但这和写时复制不兼容。

    位图标记法是只收集各个对象的标志位并表格化,不喝对象一起管理。在标记的时候不在对象的头里设置位置,而是在特定的表格中置位。

    位图标记

    在位图标记中重要的是,位图表格中位的位置要和堆里的各个对象切实对应。一般来说堆中的一个字会分配到一个位。

    位图标记中 mark() 函数的伪代码实现如下:

    func mark(obj){
        obj_num = (obj - $heap_start) / WORD_LENGTH  // WORD_LENGTH 是一个常量,表示机器中一个字的位宽
        index = obj_num / WORD_LENGTH
        offset = obj_num % WORD_LENGTH
        
        if ($bitmap_tbl[index] & (1 << offset)) == 0
            $bitmap_tbl[index] |= (1 << offset)
            for (child: children(obj)) // 然后递归的标记通过指针数组能访问到的对象
                mark(*child)
    }

    这里 obj_num 指的是从位图表格前面数,obj 的标志位在第几个。例如 E 的 obj_num 是8。

    obj_num 除以 WORD_LENGTH 得到的商 index 以及余数 offset 来分别表示位图表格的行编号和列编号。

    优点

    • 和写时复制技术兼容
    • 清除更高效(只需要遍历位图表格就可以,清除的时候也只需要清除表格中的标志位)。

    延迟清除

    清除操作所花费的时间和堆的大小成正比,堆越大,标记-清除 动作花费的时间越长,也就越影响程序的运行。

    延迟清除(lazy sweep)是缩短清除操作花费导致程序最大暂停时间的方法。

    最大暂停时间,因执行 GC 而暂停执行程序的最长时间。

    延迟清除中 new_obj() 函数会在分配的时候调用 lazy_sweep()函数,进行清除操作。如果它能用清除操作来分配分块,就会返回分块,如果不能分配分块,就会执行标记操作。然后重复这个步骤,直到找到分块或者allocation_fail

    通过延迟清除法可以缩减程序的暂停时间,不过延迟效果并不是均衡的。比如下图这种刚标记完堆的情况:

    堆里垃圾分布不均的情况

    这时,活动对象和非活动对象都是相邻分布,如果程序在活动对象周围开始清除,那它找到的对象都是活动对象不可清除,只能不停遍历,暂停时间就会变长。

    参考链接

     
     
  • 相关阅读:
    [考试反思]1108csp-s模拟测试105: 傀儡
    [考试反思]1107csp-s模拟测试104: 速度
    联赛前的咕咕咕(小计划)
    [考试反思]1106csp-s模拟测试103: 渺茫
    csp-s模拟测试101的T3代码+注释
    [考试反思]1105csp-s模拟测试102: 贪婪
    [考试反思]1105csp-s模拟测试101: 临别
    [考试反思]1104csp-s模拟测试100: 终结
    [考试反思]1103csp-s模拟测试99: 美梦
    [考试反思]1102csp-s模拟测试98:苟活
  • 原文地址:https://www.cnblogs.com/xuwc/p/14054104.html
Copyright © 2011-2022 走看看