垃圾回收机制的实现(php,python,golang,java)

总结一下各个语言的垃圾回收机制。

1、 Python垃圾回收机制 引用: Python垃圾回收机制--完美讲解!

2、Java回收机制

3、Redis垃圾回收

4、Php垃圾回收;

5、Golang的垃圾回收

.......................................................................................................................................................................

1、Python垃圾回收机制

Python主要用三种GC算法,引用计数算法、标记-清除算法、分代回收。其中,Python主要采用的还是引用计数算法,标记清除算法是辅助算法,主要是用于容器对象,比如列表,元祖,字典等等,分代回收是只是建立在标记清除算法上的对不同代的对象进行回收。为此,对于每一个Python对象,其结构体内部都维护了一个计数器。

 typedef struct_object {
 int ob_refcnt;
 struct_typeobject *ob_type;
} PyObject;

引用计数应该算是发展最早,被利用也最广泛的一种算法,因为其比较简单。但其也有自己本身的一些缺陷,比较在出现循环引用的情况下,可能会导致有一些对象无法始终无法被回收,尽管它们已经不再使用。

例子:

x = [1,2]
y=[2,3]
z=[4]
m=[5]
x.append(y)
y.append(x)
z.append(z)
y.append(m)

a = x

为了解决容器对象的循环引用问题,Python引入了辅助算法:标记-清除算法。简单来说就是两个大阶段,一是对于所有可达的对象进行标记,二是对于所有不可大的对象进行回收。当然,事实不像说的这么简单。

为了实现该算法,Python维护了两个链表,一个用来存放着需要被扫描的对象(Object to Scan),一个是存放暂时不可达的对象(Unreachble),在进行垃圾回收的过程中,两个表存放的对象是不断变化的。

就拿上面的例子中的循环引用来说。此时 x的计数是2,y是1,z也是1,m也是1

具体流程:

  1. gc会首先扫描Obejct to Scan中的对象,  会将所有对象所引用的对象都减1.此时上面的x的计数变成1,y变成0,z也变成0,也变成0.
  2. gc会再次扫描该链表中的对象,它会把所有计数为0的标记为GC_TENTATIVELY_UNREACHABLE,并放到Unreachable的链表中,不为0的就放标记为GC_REACHABLE。此时就解决了循环引用的问题。但此时还存在问题,就是有误伤的情况,y,m被标记为不可达了。别着急,接下来还有。
  3. 对于可达的点,gc会从该节点点触发,顺藤摸瓜,递归地将其所有可达的结点都标记为GC_REACHABLE,那么此时,y,m的引用计数虽然都为0,但还是会标记可达。并再重新将其放到Object to Scan链表中。
  4. 此时gc会将所有Unreachable链表中的对象进行垃圾回收。就是上述例子中的z.

比较好的文章: Python内存管理

标记清除解决了循环引用的问题,但其每次都要扫描所有对象,效率一般,且进行垃圾回收时,会产生阻塞,应用程序都要先等待垃圾回收结束之后再进行。因此,针对该问题,Python提出了分代收集。

Python中, 引入了分代收集, 一共有三个代,幼年,青年,老年,根据存活时间划分。

Python根据其生存时间来决定放在哪个代,其生存时间越长就意味着越不可能回收,因此应该减少垃圾回收的频率。生存时间越短,就提高回收频率。但话说回来,这种方法只是在一定程度上提高了垃圾回收的效率,其实也并没有完全解决上述提到的问题。

Python 中, 一个代就是一个链表, 所有属于同一代的内存块都在同一个链表。

代码来源于网络! 

struct gc_generation {
      PyGC_Head head;
      int threshold; /* collection threshold */  // 阈值
      int count; /* count of allocations or collections of younger
                    generations */    // 实时个数
  };

#define NUM_GENERATIONS 3
  #define GEN_HEAD(n) (&generations[n].head)

  //  三代都放到这个数组中
  /* linked lists of container objects */
  static struct gc_generation generations[NUM_GENERATIONS] = {
      /* PyGC_Head,                               threshold,      count */
      {{{GEN_HEAD(0), GEN_HEAD(0), 0}},           700,            0},    //700个container, 超过立即触发垃圾回收机制
      {{{GEN_HEAD(1), GEN_HEAD(1), 0}},           10,             0},    // 10个
      {{{GEN_HEAD(2), GEN_HEAD(2), 0}},           10,             0},    // 10个
  };

  PyGC_Head *_PyGC_generation0 = GEN_HEAD(0);

Python触发垃圾回收机制的情况包括:

  1. gc.collect()手动触发;
  2. 程序退出触发;
  3. 达到阈值触发。

2、Redis的垃圾回收机制(强行乱入)

Redis的垃圾回收机制比较简单,就是使用了引用计数算法。

Redis的每个对象都使用一个结构体来表示,即RedisObject。

#define LRU_BITS 24
typedef struct redisObject {    // redis对象
    unsigned type:4;    // 类型,4bit
    unsigned encoding:4;    // 编码,4bit
    unsigned lru:LRU_BITS; /* lru time (relative to server.lruclock) */ // 24bit
    int refcount;   // 引用计数
    void *ptr;  // 指向各种基础类型的指针
} robj;

refCount存储了对象引用的次数。那么可能这时候要问了,Redis如何解决循环引用的问题。哈哈,那是因为Redis中根本不会出现循环引用的场景,所有对象的创建和维护都是Redis自己做的,这种情况不会存在。

那么Redis的垃圾回收的触发主要有定时删除和惰性删除,使用的策略就是主要的那几种,比如LRU,TTL等等。具体如下:

# volatile-lru -> remove the key with an expire set using an LRU algorithm
# allkeys-lru -> remove any key according to the LRU algorithm
# volatile-random -> remove a random key with an expire set
# allkeys-random -> remove a random key, any key
# volatile-ttl -> remove the key with the nearest expire time (minor TTL)
# noeviction -> don't expire at all, just return an error on write operations

Redis默认使用的是noeviction.

说一下上面的LRU,Redis中的LRU是采用惰性删除的执行方式,当进行一个操作时,如果超出了最大内存限制,就随机选择5个key(选择什么样的key取决于配置的策略时volatile还是allkeys;选择几个还要根据配置来server.maxmemory_samples),然后淘汰最旧的key。如果内存还超,就继续执行上面的操作,直到低于最大限制。Redis之所以选择这种方式是为了减少额外的消耗。

摘抄:Redis3.0对近似LRU算法进行了一些优化。新算法会维护一个候选池(大小为16),池中的数据根据访问时间进行排序,第一次随机选取的key都会放入池中,随后每次随机选取的key只有在访问时间小于池中最小的时间才会放入池中,直到候选池被放满。当放满后,如果有新的key需要放入,则将池中最后访问时间最大(最近被访问)的移除。
当需要淘汰的时候,则直接从池中选取最近访问时间最小(最久没被访问)的key淘汰掉就行。

一篇比较好的文章: redis

用Python实现的一个简单的LRU:



"""

Python实现简单的LRU算法


"""
class HbnnLRU:

    def __init__(self, size):
        #最大容量
        self.max_size = size
        #LRU 时钟,每个存储的对象都有一个自己的时钟,这个就是和Redis中 redisObject的lru一样
        self.clock = 1
        #lru字典以及实际数据字典 lru存储lru每个key的时钟
        self.lru = self.data = {}

    def get(self, key):
        if key in self.data:
            self.lru[key] += 1
            return self.data[key]
        return None

    def set(self, key, value):
        if self.should_del_oldest_key():
            del_key = min(self.lru, key=lambda item_key: self.lru[item_key])
            self.data.pop(del_key)
            self.lru.pop(del_key)

        self.data[key] = value
        self.clock += 1
        self.lru[key] = self.clock

    def should_del_oldest_key(self):
        return len(self.data) >= self.max_size

上述的也是一种惰性删除的思想,在进行写操作的时侯,如果容量已经超了,那么就开始进行就数据回收。其实Python还有一个更好的实现,是OrderedDict,可以记录插入顺序的字典,可参考: LRU Cache in Python . 看下其内置插入运算符,它也是专门维护了一个双向链表。

class OrderedDict(dict):

  # An inherited dict maps keys to values.
    # The inherited dict provides __getitem__, __len__, __contains__, and get.
    # The remaining methods are order-aware.
    # Big-O running times for all methods are the same as regular dictionaries.

    # The internal self.__map dict maps keys to links in a doubly linked list.
    # The circular doubly linked list starts and ends with a sentinel element.
    # The sentinel element never gets deleted (this simplifies the algorithm).
    # The sentinel is in self.__hardroot with a weakref proxy in self.__root.
    # The prev links are weakref proxies (to prevent circular references).
    # Individual links are kept alive by the hard reference in self.__map.
    # Those hard references disappear when a key is deleted from an OrderedDict.
    def __setitem__(self, key, value,
                    dict_setitem=dict.__setitem__, proxy=_proxy, Link=_Link):
        'od.__setitem__(i, y) <==> od[i]=y'
        # Setting a new item creates a new link at the end of the linked list,
        # and the inherited dictionary is updated with the new key/value pair.
        if key not in self:
            self.__map[key] = link = Link()
            root = self.__root
            last = root.prev
            link.prev, link.next, link.key = last, root, key
            last.next = link
            root.prev = proxy(link)
        dict_setitem(self, key, value)

类似地,在Java中其实也有,LinkedHashMap中也是可以记录插入顺序的Hash Map,它专门维护了一个双向链表来记录插入顺序。在每次插入操作之后都要执行afterNodeInsertion方法,源码:

 void afterNodeInsertion(boolean evict) { // possibly remove eldest
        LinkedHashMap.Entry<K,V> first;
        if (evict && (first = head) != null && removeEldestEntry(first)) {
            K key = first.key;
            removeNode(hash(key), key, null, false, true);
        }
    }

其中removeEldestEntry就是判断是否应该移除老元素的方法。这个默认是false。可以自己Override。

3、PHP的垃圾回收机制

php是C语言开发的,我草,C语言真强!其变量的存储结构是个结构体,如下:

struct _zval_struct {
	union {
		long lval;
		double dval;
		struct {
			char *val;
			int len;
		} str;
		HashTable *ht;
		zend_object_value obj;
	} value;					
	zend_uint refcount__gc;   //引用计数
	zend_uchar type;		   //变量类型
	zend_uchar is_ref__gc;    //是否引用变量
};

从上面的数据结构可以看出来,PHP采用的也是循环引用的计数算法。具体的不用介绍了,在上面也已经说过了。但这势必还会出现循环引用的问题,会又内存泄漏的问题。

php5.3为了解决上述的问题,以及不断扫描所有对象带来的性能问题,引入了新的算法:同步回收算法。该作者的文章: Concurrent Cycle Collection in Reference Counted Systems

具体的算法,我感觉我不用写出来了,直接看官网介绍:php官方介绍: php Manual

但说实话,其思想我觉得和Python的标记清除差不多把。只不过是Python使用了两个链表;而PHP使用的是颜色标记的思想。首先将可能的垃圾放到根缓冲区(缓冲区是一个双向链表),并把其标记为紫色(在Python中是放到Objecy to scan链表),然后如果满了的话,默认是10000(Python中是分代设置了不同的阈值),开始进行垃圾回收过程。注意,放到根缓冲区的变量只包括:其refCount的值在减少,但不是0的这些可能垃圾放到根缓冲区中。这个和Python是不同的,Python是把所有可达的对象都放到Object to Scan中。这也就说明PHP更有效率。,它更有针对性,就是解决可能的循环引用的问题。

具体流程:

  1. 首先进行深度优先遍历,对所有root可以遍历的对象进行计数减去1,并标记为灰色,主要防止被多次减去1,又时一个对象可能被多个root对象引用。还要说一下,它减1不是所有的都减1,假如当前节点中的某个元素又有指向其节点本身的才会减1,这也是和Python不同的,Python是一把梭哈,然后再二次遍历复原。
  2. gc再次遍历,算法仍然还是深度优先,然后对计数是0的标记为蓝色,计数不是0的计数值再加1,就是恢复原有值,并标记为黑色。
  3. 回收所有蓝色标记的节点。

触发机制:

根缓冲区满时自动触发。

4、Golang垃圾回收机制

最初golang的垃圾回收(1.5之前)就是简单的标记清除,性能比较low,俗称STW,在标记清除进行的过程中啥都不能干,任何应用程序都必须停止,只能等着。这对服务端开发简直是不能忍受的。后来又把标记和清除分离,标记的时侯需要STW,然后清除的过程可以单开协程执行,效率得到了提升。

后来又采用了三色标记法(V1.5),且不必扫描所有对象,其具体流程:

1、首先将所有对象都设置为白色;

2、从root对象出发,将所有引用的对象都标记为灰色; 此时时stop the world

3、对于灰色对象再将其引用的对象变为灰色,其本身变为黑色。  此时 start the world

4、如此反复,直到没有灰色对象为止;

5、将所有白色的对象回收。

关键之处:root对象指的是哪些?golang中的root对象包括全局变量和当前时刻运行的栈都是root对象。

但上述还是存在一个问题的,就是在标记的时侯有可能没有被引用的对象,同时被重新被新对象引用了,那么该对象可能就会误被标记为白色,最后被清除。

后来,golang引入了混合屏障。其思想就是在开始标记前开启写屏障,保证有内存操作的时侯可以直接将对象入队,从而进行上述的标记操作。标记之后,回收之前会关闭写屏障。

写屏障采用的算法:

1、Steele算法 当发现黑色对象指向白色对象,那么就把黑色对象变成白色,然后重新扫描;

2、汤浅算法  对于新生成的对象就直接标记为黑色;对于引用的变更,该算法的写入屏障是记录指针删除的操作,当有指针删除的操作的时候,并且被删除的指引指向的是白色的对象,那么我们就把这个白色的对象给标记成灰色。

其实,golang的垃圾回收机制虽然没有Java那么成熟,但是不断优化的,未来可期。

触发机制:

1 、达到阈值。但阈值会不断变大。

2、定时触发。

go的垃圾回收不是立即回收,而是待内存紧张的时侯才回收,这么做主要还是考虑性能吧,和Java类似。因此,综上几点,go占用的内存是不断增大的。

好的文章: GO GC 垃圾回收机制 golang垃圾回收 Golang内存管理

5、Java垃圾回收

JVM的虚拟模型主要包括程序计数器、方法区、JVM栈、本地方法栈、堆等等,其中只有程序计数器是没有内存泄漏的问题,其他的都会涉及到这个问题。具体的关于上述几大部分的介绍可以看这篇文章:

JVM内存模型

我之前在网上看到一个图,关于JVM垃圾回收的,感觉挺牛逼的,把涉及到的知识点都囊括了。可以点进去看一下: 深入理解JVM垃圾收集机制(JDK1.8)

本文主要介绍JAVA的垃圾回收机制。

目前存在的GC算法:

  • 引用计数法;
  • 标记-整理算法;
  • 标记-清除算法;
  • 分代收集算法;

其中引用计数法是一种比较传统粗糙的算法,Java并没采用。剩下的三种在Java中都有涉及,但不是单纯采用某种算法,因为这三种算法都存在一定程度的缺点。JVM将内存划分成代,分情况进行垃圾回收,也就是著名的分代收集算法。

划分情况:

新生代(Young Genaration)  MinorGC  采用复制算法  ,其又还划分出Edge suvivor1 suvivor2。

老年代(Old Genaration)

在MinorGC中采用了标记和复制算法。当有可回收对象时,会被标记,并将可用对象移到S1或者S2,然后将标记对象进行清除,此时edge以及(S1或者S2)就空闲出来了。那么问题来了,一个对象如果生命周期较长,要是一直让他在新生代来回复制,必然降低效率。因为针对这个问题,JVM规定一个对象每次移动一个区,年龄+1,当达到一定阈值后,就会被移动到老年代。一般新生代年龄达到15(记录的空间只有4位),就可以移到老年代。Edgen和SUivor的比例是8:1:1,Eden设置大主要是因为大部分对像都会被回收。

对于老年代的GC一般称为Major GC,或者Full GC.网上很多人说两者有区别,也有说没区别的。其实我是觉得怎么说都不为过。Full GC可以包括Minor GC,所以说Full GC范围更广,但Minor GC在Full GC中不是必需的,一般情况下Full GC就是对老年代/永久代的回收。

下面对象说明了触发Full GC的其中一种情况,即要晋升老年代的对象大于老年代的内存空间,会触发Full GC。

下面的图说明了GC的主要过程:

除了上述所说的老年代空间不足时会触发FullGC之外,还有就是当PermanetGeneration空间满时也会触发。

上面说了很多的GC算法,有个问题是,哪些是可回收对象?其实在其他语言种已经都说了,从GC ROOT对象开始,如果没有任何引用链的都是不可达,可回收。那么在Java中哪些是GC ROOT对象呢?

  • 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等;
  • 在方法区中类静态属性引用的对象,譬如 Java 类中引用类型的静态变量;
  • 在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用;
  • 在本地方法栈中的 JNI(即 Native 方法)引用的对象;
  • Java 虚拟机内部的引用,如基本数据类型对应的 Class 对象,一些常驻的异常对象(如 NullPointException,OutOfMemoryError 等)及系统类加载器;
  • 所有被同步锁(synchronized 关键字)持有的对象;
  • 反应 Java 虚拟机内部情况的 JMXBean,JVMTI 中注册的回调,本地代码缓存等。

上面说的都是一些GC算法,而实现这些算法的叫做垃圾收集器,JVM的收集器有很多种。

1) Serial 收集器

这种是最古老的一种收集器了,它是单线程版本,在最早的新生代收集中都采用这种算法。这种单线程收集意味着当要进行GC时,就会发生STW。但是,也正因为是单线程,不会存在线程切换的开销,效率很高,且STW的时间一般不长,尤其是对于单CPU的情况。如果GC频率不高,还是很优秀的,这也是为什么现在很多的虚拟机依然把它当作新生代默认的收集器。

该收集器在新生代采用复制算法,老年代采用标记-整理算法。

2) ParNew 收集器

它是Serial收集器的多线程版本,其余完全没区别。因此在多CPU的情况下,它比Serial收集器更高效。

3)Parallel Scavenge 收集器

这是一个新生代收集器,它追求的目标是达到更高的吞吐量。它可以自适应调整参数。

其余几个不说了,主要说下CMS和G1.

CMS收集器

它的目标就是尽可能的所端STW的时间,从而提高GC效率。是老年代的一种收集器。是、也为后续的G1和ZGC奠定了基础。

CMS全称Cocurrent Mark Sweep,它的意思已经很清晰明了了,并发标记清除,其主要经历以下几个过程:

  • 标记;
  • 并发标记;
  • 重新标记;
  • 并发清除。

1、初始标记

初始标记会标记所有与GC Root直接关联的对象,这个过程会STW,但耗时较短;

2、并发标记

并发标记所有与GC ROOT有关联的对象,不一定是直接关联的。耗时长,但因为是并发,所以不影响用户进程。

3、重新标记;

重新标记是因为在上述过程中,可能会有新产生的对象,这个过程会STW,但因为新产生的也不会很多,因此耗时也不会很长。

4、并发清除

标记后,开始并发清除,这个过程耗时也相对长一些,但同样是因为并发,并不会出现STW。

上述就是CMS的整个过程,优点很明显,提高了效率,但缺点也很明显,一是它采用标记-清除算法,会产生很多的内存碎片;其次就是在并发清除时,用户进程仍然会产生很多需要清除的垃圾,这部分CMS是无法处理的,这部分被称作是浮动垃圾。

G1收集器

G1收集器是从JDK1.7开始使用的,它的产生很好解决了CMS所产生的碎片化的问题,它是基于标记-整理算法进行垃圾回收。

G1不再像传统的收集器那样,把整个内存区域分成固定的新生代,老年代和永久代。而是把整个区域分成固定大小的区,叫做region,这个region彼此之间是逻辑连续的。且也都较做Eden、Survivor 、Old、Humongous .

新生代GC:

简单描述其思想就是当Edgen空间耗尽时,会触发 Young GC,Edgen区域可用对象移动到Survivor空间,如果Survivor空间不足,就会移动Old空间。这样Eden空间就空了。

那么对于G1,它是否需要扫描整个老年代对象来标记的。答案是否定的,G1通过记录堆和堆的引用关系来避免扫描整个老年代。每一个region都会记录old->yound的引用,称为RSet。因此扫描时,只需要扫描新生代的区域即可,这样就大大提高了效率。

老年代GC:

也叫MixGC,其实它不止时清理老年代,Young也会清理.官网描述的过程:Initial Mark(STW) -> Root Region Scan -> Cocurrent Marking -> Remark(STW) -> Copying/Cleanup(STW && Concurrent)

简单说下,哪些对象会进入老年代:

1、大对象,会直接进入老年代;

2、拿到阈值年龄的对象,会进入老年代,一般默认是15岁。每经历一次MinorGC且没被回收的,都会增长一岁。

3、在Survivor空间中相同对象年龄的所有对象的大小之和超过了整个Survivor空间的一半,那么所有大哥这个年两的对象都会放到老年代中。

具体可见下面参考资料。

ZGC收集器

是G1的变种,在G1的基础上做了演进。

ZGC把整个堆分成了三种不同粒度的页面,小,中,大。不同页面存储不同大小的页面,从而可以更加细粒度控制内存的分配。

其主要特点是:

  • 停顿时间不超过10ms(JDK16 已经达到不超过 1ms);

  • 停顿时间不会随着堆的大小,或者活跃对象的大小而增加;

  • 支持8MB~4TB级别的堆,JDK15 后已经可以支持 16TB。

其得益于以下几种技术:

1、着色技术。不再像之前三色标记一样,要在对象头MarkWord中做标记,而是使用独立的内存保存指针。

2、内存管理。

参考资料:

一文看懂 JVM 内存布局及 GC 原理

《深入理解 JAVA 虚拟机》第三版(2019.12)读书笔记

G1 收集器原理理解与分析

--------EOF---------
微信分享/微信扫码阅读