前言
不知道你平时是否关注程序内存使用情况, 我是关注的比较少, 正好借着优化本地一个程序的空对比了一下. Net 平台垃圾回收和 jvm 垃圾回收, 顺便用 dotMemory 看了程序运行后的内存快照, 生成内存快照后, 妈妈再也不担心我优化程序找不到方向了.
.Net 平台垃圾回收
内存优化
凭空想象这些概念多少会索然无味, 下图是我我基于本地的一个程序生成的内存快照, 使用 jetbrains 推出的 dotMemory 工具生成.
生成内存快照
程序运行时可以通过右上角的 Get SnapShot 按钮生成内存快照, 内存快照里可以看到具体的对象, 消耗内存的情况, 比如说一些大的字符串对象, 重复的大量的字符串对象, 那么从上面这张图上都能看到哪些关键字呢?
什么是 Heap generation1 和 Heap greneration2 呢?
什么是 Allocated 呢?
什么是 GC
GC (Garbage Collection)如其名, 就是垃圾收集, 当然这里仅就内存而言. Garbage Collector(垃圾收集器, 在不至于混淆的情况下也成为 GC)以应用程序的 root 为基础, 遍历应用程序在托管堆 (Managed Heap) 上动态分配的所有对象, 通过识别它们是否被引用来确定哪些对象是已经死亡的, 哪些仍需要被使用. 已经不再被应用程序的 root 或者别的对象所引用的对象就是已经死亡对象, 即所谓的垃圾, 需要被回收. 这就是 GC 工作的原理. 为了实现这个原理, GC 有多种算法. 比较常见的算法有 Reference Counting,Mark Sweep,Copy Collection 等等. 目前主流的虚拟系统. NET CLR,JVM 都是采用的 Mark Sweep 算法.
Mark-Compact 标记压缩算法
简单地把. NET 的 GC 算法看作 Mark-Compact 算法. 阶段 1: Mark-Sweep 标记清除阶段, 先假设 heap 中所有对象都可以回收, 然后找出不能回收的对象, 给这些对象打上标记, 最后 heap 中没有打标记的对象都是可以被回收的; 阶段 2: Compact 压缩阶段, 对象回收之后 heap 内存空间变得不连续, 在 heap 中移动这些对象, 使他们重新从 heap 基地址开始连续排列, 类似于磁盘空间的碎片整理.
Heap 内存经过回收, 压缩之后, 可以继续采用前面的 heap 内存分配方法, 即仅用一个指针记录 heap 分配的起始地址就可以. 主要处理步骤: 将线程挂起→确定 roots→创建 reachable objects graph→对象回收→heap 压缩→指针修复. 可以这样理解 roots:heap 中对象的引用关系错综复杂(交叉引用, 循环引用), 形成复杂的 graph,roots 是 CLR 在 heap 之外可以找到的各种入口点.
GC 搜索 roots 的地方包括全局对象, 静态变量, 局部对象, 函数调用参数, 当前 CPU 寄存器中的对象指针 (还有 finalization queue) 等. 主要可以归为 2 种类型: 已经初始化了的静态变量, 线程仍在使用的对象(stack+CPU register) . Reachable objects: 指根据对象引用关系, 从 roots 出发可以到达的对象. 例如当前执行函数的局部变量对象 A 是一个 root object, 他的成员变量引用了对象 B, 则 B 是一个 reachable object. 从 roots 出发可以创建 reachable objects graph, 剩余对象即为 unreachable, 可以被回收.
指针修复是因为 compact 过程移动了 heap 对象, 对象地址发生变化, 需要修复所有引用指针, 包括 stack,CPU register 中的指针以及 heap 中其他对象的引用指针. Debug 和 release 执行模式之间稍有区别, release 模式下后续代码没有引用的对象是 unreachable 的, 而 debug 模式下需要等到当前函数执行完毕, 这些对象才会成为 unreachable, 目的是为了调试时跟踪局部对象的内容. 传给了 COM + 的托管对象也会成为 root, 并且具有一个引用计数器以兼容 COM + 的内存管理机制, 引用计数器为 0 时, 这些对象才可能成为被回收对象. Pinned objects 指分配之后不能移动位置的对象, 例如传递给非托管代码的对象(或者使用了 fixed 关键字),GC 在指针修复时无法修改非托管代码中的引用指针, 因此将这些对象移动将发生异常. pinned objects 会导致 heap 出现碎片, 但大部分情况来说传给非托管代码的对象应当在 GC 时能够被回收掉.
垃圾回收之三个阶段
Marking Phase: 在标记阶段会创建所有活动对象的列表. 这是通过遵循所有根对象的引用来完成的. 不在活动对象列表中的所有对象都可能从堆内存中删除.
Relocating Phase: 所有活动对象列表中所有对象的引用在重定位阶段进行更新, 以便它们指向在压缩阶段将对象重定位到的新位置.
Compacting Phase: 随着释放死亡对象占用的空间并移动剩余的活动对象, 堆会在压缩阶段被压缩. 垃圾回收后剩余的所有活动对象均按其原始顺序移至堆内存的较旧端.
垃圾回收之 Genearation - 分代
堆内存在回收过程中不是一次性回收所有, 而是分为 3 代, 目前也支持 3 代, 根据上面的截图可以看出来. 因此可以在垃圾回收期间适当地处理具有不同生存期的各种对象. 取决于项目的大小, 每一代的内存将由公共语言运行时 (CLR) 给出. 在内部, Optimization Engine 将调用 Collection Means 方法来选择哪些对象将进入第 1 代或第 2 代.
Generation 0: 所有短期对象 (例如临时变量) 都包含在堆内存的第 0 代中. 除非它们是大对象, 否则所有新分配的对象也是隐式的第 0 代对象. 通常, 垃圾回收的频率在第 0 代中最高.
Generation 1: 如果运行在垃圾回收中未释放的第 0 代对象占用的空间, 则这些对象将移至第 1 代. 这一代中的对象是第 0 代中的短期对象和第 2 代中的长期对象之间的一种缓冲区对象.
Generation 2: 如果某个第 1 代对象占用的空间未在下一次垃圾回收运行中释放, 则这些对象将移至第 2 代. 第 2 代对象的生存期很长, 例如静态对象, 因为它们整个都保留在堆内存中 处理持续时间.
GC 给我们带来的优势
垃圾回收使用 3 个代的概念成功的在托管堆上有效的分配对象内存.
不再需要手动释放内存, GC 会在不需要时自动释放内存空间.
垃圾回收可以安全地处理内存分配, 因此没有对象会错误地使用另一个对象的内容.
新创建的对象的构造函数不必初始化所有数据字段, 因为垃圾回收会清除以前释放的对象的内存.
非托管堆
说了半天都在说托管堆, 那么非托管堆呢? 垃圾回收是不知道什么时候去处理非托管堆资源, 比如文件句柄, 网络连接, 数据库连接. 以下两种方式用来处理非托管堆垃圾回收.
在定义类时声明析构函数.
在定义类时实现 IDisposable 接口并实现 Dispose 函数, 实现接口有在程序中有两种处理方法, 使用 using 关键字, 推荐使用, 再就是在 finally 中显式调用 Dispose 函数.
附录 GC 常用函数
返回指定对象的当前代数
public static int GetGeneration(Object);
检索当前认为要分配的字节数. 一个参数, 指示此方法是否可以等待较短间隔再返回, 以便系统回收垃圾和终结对象
public static long GetTotalMemory (bool forceFullCollection);
返回已经对对象的指定代进行的垃圾回收次数.
public static int CollectionCount (int generation);
获取垃圾回收的内存信息
public static GCMemoryInfo GetGCMemoryInfo ();
强制对所有代进行即时垃圾回收.
public static void Collect ();
jvm 垃圾回收
好吧, 说到这里还没提出来 jvm 垃圾回收, 如果你已经了解了 jvm 垃圾回收, 从上面的垃圾回收算法和分代回收来看,.Net 平台和 jvm 在垃圾回收这块设计思路是一致的, 两者的垃圾回收算法都包含: 标记清除算法, 复制算法, 标记整理算法, 分代收集算法.
** 当前商业虚拟机算法都使用分代收集算法, jvm 根据对象的存活周期把内存划分为: 年轻代, 老年代, 永久代.
新生代(Young generation)
绝大多数最新被创建的对象会被分配到这里, 由于大部分对象在创建后会很快变得不可达, 所以很多对象被创建在新生代, 然后消失. 对象从这个区域消失的过程我们称之为 minor GC.
新生代 中存在一个 Eden 区和两个 Survivor 区. 新对象会首先分配在 Eden 中(如果新对象过大, 会直接分配在老年代中). 在 GC 中, Eden 中的对象会被移动到 Survivor 中, 直至对象满足一定的年纪(定义为熬过 GC 的次数), 会被移动到老年代.
可以设置新生代和老年代的相对大小. 这种方式的优点是新生代大小会随着整个堆大小动态扩展. 参数 -XX:NewRatio 设置老年代与新生代的比例. 例如 -XX:NewRatio=8 指定 老年代 / 新生代 为 8/1. 老年代 占堆大小的 7/8 , 新生代 占堆大小的 1/8(默认即是 1/8).
例如:
-XX:NewSize=64m -XX:MaxNewSize=1024m -XX:NewRatio=8
老年代(Old generation)
对象没有变得不可达, 并且从新生代中存活下来, 会被拷贝到这里. 其所占用的空间要比新生代多. 也正由于其相对较大的空间, 发生在老年代上的 GC 要比新生代要少得多. 对象从老年代中消失的过程, 可以称之为 major GC(或者 full GC).
永久代(permanent generation)
像一些类的层级信息, 方法数据 和方法信息(如字节码, 栈 和 变量大小), 运行时常量池(JDK7 之后移出永久代), 已确定的符号引用和虚方法表等等. 它们几乎都是静态的并且很少被卸载和回收, 在 JDK8 之前的 HotSpot 虚拟机中, 类的这些 "永久的" 数据存放在一个叫做永久代的区域.
永久代一段连续的内存空间, 我们在 JVM 启动之前可以通过设置 - XX:MaxPermSize 的值来控制永久代的大小. 但是 JDK8 之后取消了永久代, 这些元数据被移到了一个与堆不相连的称为元空间 (Metaspace) 的本地内存区域.
小结
JDK8 堆内存一般是划分为年轻代和老年代, 不同年代 根据自身特性采用不同的垃圾收集算法.
对于新生代, 每次 GC 时都有大量的对象死亡, 只有少量对象存活. 考虑到复制成本低, 适合采用复制算法. 因此有了 From Survivor 和 To Survivor 区域.
对于老年代, 因为对象存活率 **** 高, 没有额外的内存空间对它进行担保. 因而适合采用标记 - 清理算法和标记 - 整理算法进行回收.
总结
目前对比了. Net 平台垃圾回收和 jvm 垃圾回收, 对于垃圾回收算法和分代的概念, 两者设计思路都相同, 唯一的区别我个人觉的 JDK8 以后 jvm 的垃圾回收效率更高, 根据不同的代使用不同的垃圾收集算法, 这一点似乎是. Net 平台垃圾回收没有实现的地方.
参考链接
- https://www.geeksforgeeks.org/garbage-collection-in-c-sharp-dot-net-framework/
- https://juejin.im/post/5b4dea755188251ac1098e98
- https://kb.cnblogs.com/page/106720/
- https://www.zhihu.com/question/31806845
来源: https://www.cnblogs.com/sword-successful/p/12808770.html