关于垃圾回收机制中,引用计数法维护所有对象引用的方式Java 等语言的 GC 不实时释放内存的原因(转载)

原文链接: && ?group_id=85420565#comment-50027587

基于引用计数与基于trace这两大类别的自动内存管理方式最大的不同之处在于:前者只需要局部信息,而后者需要全局信息。引用计数方式最基本的形态就是让每个被管理的对象与一个引用计数器关联在一起,该计数器记录着该对象当前被引用的次数,每当创建一个新的引用指向该对象时其计数器就加1,每当指向该对象的引用失效时计数器就减1。当该计数器的值降到0就认为对象死亡。每个计数器只记录了其对应对象的局部信息——被引用的次数,而没有(也不需要)一份全局的对象图的生死信息。由于只维护局部信息,所以不需要扫描全局对象图就可以识别并释放死对象;但也因为缺乏全局对象图信息,所以无法处理循环引用的状况。更高级的引用计数实现会引入“弱引用”的概念来打破某些已知的循环引用,但那是另一个话题了。在实际实现中,引用计数存在什么地方是个有趣的话题。可以侵入式的存在对象内,例如CPython就把引用计数存在每个受自动内存管理的Python对象的对象头里(PyObject的ob_refcnt字段),或者COM的IUnknown::AddRef()/Release();也可以非侵入式的存在对象外面,例如C++11标准库里的std::shared_ptr。计数器的管理(自增/自减)可能由人工完成,例如老的Objective-C,或者是从C++里使用COM,等等;也可能是自动管理,例如CPython、使用“自动引用计数”(ARC)的Objective-C、C++/CX的“hat”、前面提到的C++11的std::shared_ptr等等。如果能自动管理,那么必然有一套明确的规则说明何种情况下一个引用会被认为失效;以std::shared_ptr为例的话,其析构函数被调用(例如离开作用域时)或者其指向别的对象时,原本指向的对象的引用计数就会减1。Tracing GC与引用计数正好相反,需要全局的对象图信息,从对象图的“根”(也就是必然活的引用)出发扫描出去,基于引用的可到达性来判断对象的生死。这使得对象只能批量的被识别出生死状态并释放死对象。Tracing GC不显式维护对象的引用计数,只在trace的时候才能回答“有”还是“没有”活引用指向某个对象。实际上,在内存充裕的前提下,tracing GC的整体开销比引用计数方式更低一些,所以吞吐量(throughput)高一些。因为引用计数方式通常需要统计冗余的局部信息,而tracing GC则可以通过全局信息一口气批量判断对象的生死;如果是带整理的tracing GC,则其内存分配通常也会更快。不过tracing GC通常会比引用计数方式的延迟(latency)大一些,而且内存越紧张的时候tracing GC的效率反而越低,所以在内存不太充裕的地方使用引用计数仍然是个合理的选择(例如iOS5上的ARC)。The Garbage Collection Handbook的第6章有对各种基本GC方式的详细对比,这边就不赘述了。GC相关的书我在豆瓣上整理了一份书单,,[Garbage Collection][垃圾回收][自动无用内存单元回收]相关读物其中ガベージコレクションのアルゴリズムと実装这本书有对几种语言实现里的GC做源码剖析,值得一读。书是日文的,不过我有在推动国内出版社引进和翻译它。请期待它的中文版的面世。

Java 等语言的 GC 不实时释放内存的原因,关键点在:

最基本的纯引用计数方式的自动内存管理可以做到实时释放死对象,但却无法处理存在循环引用的对象图的释放。这个问题一定程度上可以通过引入弱引用的概念来解决,但通用的能处理带循环引用对象图的引用计数都是有别的管理方式备份的(通常时某种tracing GC,例如mark-sweep),例如CPython使用以引用计数为主、mark-sweep为辅的方式,Adobe Flash的ActionScript VM 2(AVM2)也是以延迟引用计数(DRC)为主、增量/保守式mark-sweep为辅。反之,像C++的std::shared_ptr就是纯引用计数,无法靠自己处理带循环引用的对象图,而必须靠程序员自己小心使用,在必要的地方用std::weak_ptr来破除循环;CPython在2.0之前也使用纯引用计数,无法处理循环引用,只能等着泄漏内存。既然通用的引用计数还得用tracing GC来备份,实现这样的自动内存管理等于得实现两份,想偷懒的话还不如一开始就只实现某种tracing GC,例如mark-sweep。最基本的纯引用计数方式对引用计数器的操作非常频繁,这里有额外开销,至于是否严重到成问题就看具体应用的可忍受程度。在内存充裕的前提下,基本的tracing GC比基本的引用计数方式的性能更好(特别是从throughput角度看),不需要做冗余的计数器更新。同时,在多线程环境下引用计数器可能成为线程间共享的数据,需要做同步保护(这里把原子更新算同步保护的一种),这也是个额外开销的来源;因为tracing GC不需要维护引用计数器所以也就没有这种同步的开销。引用计数的这些性能缺点可以通过一些高级变种来缓解,例如前面提到AVM2的延迟引用计数,只记录堆上对象之间的引用计数而不记录栈上(主要是表达式临时值)对对象的引用计数,以此减少对计数器的更新次数来提高性能。详情可参考文档:。这些引用计数的高级变种通常意味着一定程度的延迟释放,跟楼主想实时释放的初衷就不符了。另一方面,虽然最基本的tracing GC会有较长的延迟,但它们也有高级变种,可以并行、并发、增量式执行,降低延迟;也有办法实现thread-local GC来应对像是“请求-响应”式的Web应用批量释放一个线程临时分配的对象的需求。如果选用tracing GC来实现自动内存管理,它是不显式维护对象的引用计数的,也就没有“引用计数到0”的概念。所以基于tracing GC的JVM或其它语言的运行时环境自然不会“引用计数到0就释放对象”。引用计数方式其实也有经典的卡顿情况。例子之一就是一个对象个数很多、引用链很长的对象图假如只是被一个引用而留活,那么那个引用一死就会引发大量对象扎堆释放(但却不是“批量释放”,开销不同),这一样会引起卡顿。单纯讨论最坏情况的话其实引用计数也有这样糟糕的一面。纯人工的malloc()/free()或new/delete可以让程序员人肉找出生命周期相同的对象,然后利用诸如arena之类的方式为它们分配内存,就可以它们死的时候真正批量释放掉它们,这样就很高效;但纯引用计数却不是这么回事。使用引用计数会否遇到这种卡顿全看你的程序里对象图的引用关系是怎样的。人生至少要有两次冲动,一为奋不顾身的爱情,一为说走就走的旅行。

关于垃圾回收机制中,引用计数法维护所有对象引用的方式Java 等语言的 GC 不实时释放内存的原因(转载)

相关文章:

你感兴趣的文章:

标签云: