这是《现代编译原理》中第二部分(高级主题)中的内容。个人感觉此章在副标题的结构上略有问题,应将本章主要内容分为两大部分:
- 判断对象是否存活
- 垃圾回收机制
概念
垃圾:堆中分配且通过任何程序变量形成的指针都无法到达的记录称为垃圾
垃圾收集由运行时系统完成
判断对象是否存活
标记-清扫式
在标记清扫式中,程序变量和指针构成一个有向图,用DFS对图进行遍历,标记可以到达的点,于是未被标记的点就是垃圾,应当被回收。从第一个地址到最后一个地址对整个堆进行清扫。清扫出来的垃圾用一个链表(空闲表)链接在一起。
代价
令堆的大小为H,可到达数据为R,则未到达数据为R-H.(小写字母代表常数)。
清扫所需时间与堆的大小成正比,一次垃圾收集的代价可用aR+bH表示,回收得到的H-R是回收得到的好处,于是,分摊代价就是:
书中还提到优化,对于DFS的优化就是将递归算法(递归深度最坏为H)改为非递归算法,并用指针逆转(暂时用不到的、其内容已被存储过的指针指向某个有用的数据,在适当时候返还其原来的值给它)来进行优化。
- 空闲表的优化:思想是用不同大小的内存块群来存储相应大小的内容
- 碎片:外部碎片和内部碎片,外部碎片就是有很多没用到的很小的不能利用的记录(windows中的磁盘整理就是整理这些碎片),内部碎片就是说分配的内存大于实际用到的,造成内部碎片的产生。
引用计数
思想
每次将p存储到一个指针时便增加p的引用计数(每次类似的赋值操作需要多出好多条指令来完成这样的计数工作)。
问题
- 无法回收环形垃圾
- 增加指令的开销很大
垃圾回收的具体机制
复制式收集
堆中有两块区域from-space和to-space,将不是随便的对象从from-space复制到to-space。于是不会产生碎片问题。
- 引用局部性:如果一个位于地址a的记录指向另一个位于地址b的记录,则a和b可能相距很远。
再有告诉缓存的计算机中,具有良好的局部引用性非常重要,当程序读取了地址a的数据后,会预期不久将读取a附近的数据。此时深度优先遍历和广度优先遍历(书中叫“宽度优先遍历”)的性能就差别很大了,在深度优先遍历中父亲节点往往与其孩子节点相邻较近,故其局部引用性比较好。
分代收集
思想
根据对象的活跃度将对象氛围几个“代(G)”,年轻的会慢慢变老哦。
收集器会更多得关注年轻的代(其成为垃圾的可能性更高)。
问题
年老的指向年轻的,这样光对年轻代扫描得到的数据不准确。
解决方案:
- 记忆表:用向量记录更新过的对象
- 记忆集合:用对象内的一位来记录是否更新过
- 卡片标记:划分存储区
- 页标记:利用操作系统中的脏位
增量式收集
提供了良好的交互性。不多展开了。
其中提到两个概念,在并发编程中很常见(类似于《java并发编程实战》中提到的先验和后验):
- 栅栏写:每一条存数指令进行检查以确保其遵守相关不变式。
- 栅栏读:每一条度数指令进行检查以确保其遵守相关不变式。
在JAVA中的应用
主流java虚拟机使用可达性分析(标记-清扫)来判断内存是否是垃圾。
java中的对象被宣告”正式死亡“需要经历两次标记,第一次标记的时候会进行筛选,筛选的条件是是否有必要执行finalize方法。如果是,则将这个对象放入F-Queue中。第二次是对F-Queue中的对象进行标记,此时调用对象的finalize方法(如果此时对象将自己和任何一个对象建立关联即可逃脱被“杀死”的命运),然后对象就一命呜呼了。
建议不要使用finalize方法而改用try-finally之类的方法。
java中使用分代收集的方法来对垃圾进行回收。新生代使用复制式收集,老年代使用标记-清理或标记-清扫。其中新生代的内存区域分为两个Survivor区和一个Eden区,对象存储在一个Surivor区和Eden区,垃圾回收之后放入另一个Surivor区。