本文是读还没出版的伟民哥翻译的 .NET内存管理宝典 - 提高代码质量、性能和可扩展性 这本书的笔记
当前是 2020年9月 本文的知识最新就是当前的时间,因为 dotnet 的更新速度十分快,当前由 dotnet 基金会维护整套 dotnet 开源项目。从编译器到运行时全部都是开源的,采用最友好的 MIT 开源协议,每个项目都会附带完全的构建脚本
在阅读到了伟民哥翻译的 《.NET内存管理宝典 - 提高代码质量、性能和可扩展性》 这本书,我了解到了更多的关于 dotnet 内存的细节,下面请让我给大家分享一下
相信小伙伴都听说 dotnet 里面的内存其实逻辑上分为两块,一个是小对象堆,一个是大对象堆两个内存范围
在很多书籍里面都会将小对象堆的英文 Small Object Heap 简写为 SOH 而大对象堆 Large Object Heap 简写为 LOH 本文将会同时带上中文和英文缩写
从命名上可以看到 SOH 小对象堆和 LOH 大对象堆的不同就是存放的对象的大小
但上文的对象的大小指的是什么?其实这里说的对象的大小不是指在 VS 内存调试的时候看到的对象占用的内存,而是指的是对象的浅表大小。关于在 VisualStudio 调试的方法请看 dotnet 代码调试方法
对象的浅表大小指的是对象里面包含的字段数量,或者说是数组本身等。浅表大小就是对象本身的实际占用内存的大小,而不加上对象包含的引用的其他对象的大小,例如一个定义了 100 个字段的对象,这个对象定义的这 100 个字段都是指向其他的引用对象,此时这个对象的浅表大小仅仅是 100 个指针长度的大小加上一个空对象占用的大小
因此基本上能作为大对象的都是数组,而不是某个类对象,很少有小伙伴和我这样逗在代码里面定义一个对象包含了超级多的字段,让这个对象作为一个大对象
如果要定义一个类,让这个类有足够的字段作为大对象,那么这个类的浅表大小需要多大?需要85000字节,也就是 85kB 这么大
在 dotnet 里面,是根据对象的浅表大小,使用 85kB 作为界限,将小于这个大小的对象在 SOH 小对象堆里创建,而将大于等于这个大小的对象在 LOH 大对象堆创建
听说这个界限大小是古老的强大的开发者通过一系列测量决定的,而测量了什么现在还没有公开。那为什么用了这么久也没有去更改?因为没有任何理论或实践可以说明更改这个值可以拿到更好的性能
当然,也不是所有 dotnet 的发行版本都采用这个值,例如 Mono 的 5.4 版本,就采用了 8000 字节作为界限
敲黑板,在 dotnet 里面,是根据对象的浅表大小决定将对象放在 SOH 小对象堆还是在 LOH 大对象堆。一般的依据的界定值是 85000 字节。但是 Mono 的 5.4 版本采用 8000 字节作为界限
在 dotnet 里通过将对象放在两个不同的内存范围里面,可以做到更多的优化。从工程上可以了解,基本上的 SOH 小对象堆将会包含大量的对象,因为大部分对象的浅表大小都很小。而 LOH 大对象堆是存放很少的对象,如果一个应用里面包含了大量很大的对象,那么这个应用也很有趣
咱都知道,在 dotnet 里面一个优势就是对象可以做垃圾回收,在不使用对象的时候,这个对象会自动被回收。但是小伙伴是否想过,对象被回收了,那么原本对象占用的内存怎么办?应该如何回收
基本上的回收方法只有两个,一个就是标记这个范围已经没有使用了,另一个方法就是压缩空间
标记某个内存范围已经没有被使用,因此这个内存范围可以被重复分配给新的对象使用。这个方法的优势在于性能特别好,毕竟只需要做一个标记就完成了。但是缺点就是会让内存不连续,内存不连续的意思就是如我开始有三个对象,如 a 和 b 和 c 三个。此时我的 a 和 c 都可以释放了,如果我只是标记 a 和 c 两个内存释放,那么此时的内存相比一开始的连续长空间,当前就被对象 b 分割为两个短的空间了
内存如果不连续会存在的问题是可能占用的内存本来没那么多,但是新的对象却找不到空间可以用来分配的。原因是内存都是碎片,没有一段足够的连续空间
因此另一个回收内存的方法压缩空间的作用就是解决这样的问题,还是刚才的例子,假设有三个对象,如 a 和 b 和 c 三个,此时 a 和 c 都可以释放了。使用压缩空间的方式是将 b 对象移动到原本 a 对象的地方,这样内存里面就不会被对象分割为多段,依然空闲内存都在一段里面。此时如果有新的对象,这个对象的浅表大小大于 a 和 c 其中一个,但小于 a 和 c 之和,就可以依然在原本空间里面分配,因为释放的 a 和 c 占用的空间现在都在放在一起
回收内存采用压缩空间的方法可以在对象回收的时候,通过移动其他对象填补被回收对象释放的空间的方法,让空闲的内存都是连续的,这样可以解决应用程序运行一段时间之后,出现内存碎片的问题
但是小伙伴也可以发现,采用压缩空间的方法需要进行对象的空间移动,也就是需要使用更多的 CPU 资源,同时因为对象被移动了,原本指向对象的引用都需要更新。因此压缩空间的方法只适合对小的对象使用,因为小的对象需要移动的内容比较小。同时压缩空间时移动的对象尽可能都是那些引用数尽可能少的对象
在 dotnet 里面将内存分为两块,一个是小对象堆,一个是大对象堆就是为了对这两个内存分别做不同的内存回收方法
对于 SOH 小对象堆因为移动对象的成本很低,而且包含的对象很多,很多对象都会在 SOH 小对象堆创建,也就是说 SOH 小对象堆创建对象频率会很高。因此 SOH 小对象堆内存回收方法采用清除和就地压缩回收策略,这个方法因为压缩回收时虽然需要移动对象,但是移动成本低,而且压缩回收能减少内存碎片,解决因为对象快速创建的时候因为内存碎片而没有足够内存分配的坑,因此小对象堆采用压缩回收的方法更优。当然上文也提到了 SOH 小对象堆也会做清除内存而不是立即压缩,是因为有时候压缩不划算,因此仅仅只是做标记清除
在 LOH 大对象堆,因为对象都很大,如果想要进行压缩清理,那么每次需要移动的内存范围将会很多,移动的成本比较高。因此优先选择使用清除内存的方式标记内存空闲
在使用一段时候之后,也许在 LOH 大对象堆也会存在内存碎片,此时也会执行压缩内存。但是如果上文告诉大家,在 LOH 大对象堆执行压缩内存的成本比较高,因此执行压缩内存会尽可能少执行
敲黑板,在内存回收里面,一共有两个策略,一个是标记回收,另一个是压缩回收。在 SOH 小对象堆会更多进行压缩回收,而有时候压缩回收不够划算时也会执行标记回收。在 LOH 大对象堆基本上都是执行标记回收,只有很少的时候才执行压缩回收
为什么有时候在 SOH 小对象堆压缩回收不够划算?因为如上文告诉大家,在进行压缩回收的时候,需要移动对象,而如果对象的内存移动了,那么就需要更新对这个对象的引用。而如果应用程序还在运行,更新对某个对象的引用,是无法一次性完成的,这就会出现在某些代码访问的还是被移动对象的旧内存空间,而有些代码访问的是被移动对象的新的内存空间。如果此时都是只读,那么没有问题。如果有线程尝试写入就有趣了,如果写入到了对象的旧内存空间,那么相当于没有写入
为了解决这个问题,就需要在进行压缩回收的时候暂停所有的线程,在回收完成才能让线程继续执行。因为线程被暂停了,所以对线程来说好像回收是一瞬间完成的,所有的代码使用的对象的内存空间都被更新了
因为在回收的时候执行压缩回收需要暂停线程,将会降低应用的性能。这就是为什么很多 U3D 游戏在玩家玩的时候都不进行内存回收的原因,假定你在点击开枪的时候,应用进行回收,所有的线程都被暂停,那么你砸不砸桌子
因为在判断当前 SOH 小对象内存碎片化程度的时候,是不需要暂停线程的,即使此时有新的对象被多线程创建的时候,也只需要轻轻加一个锁更新一下占用内存的范围就可以解决,所以在判断当前碎片化程序不够压缩回收时,也就不会执行压缩回收了
在执行压缩回收的时候还有一个问题,假定 a 对象的内存空间可以被回收了,此时有 b 对象,刚好 b 对象的占用内存空间和 a 对象是一样大的,那么是否此时我将 b 对象移动到 a 对象的内存空间是划算的?其实不然,即使这个 b 对象的附近就是空闲内存空间。但是 b 对象也许在下一毫秒就可以被回收了,此时由需要再找另一个对象移动
在压缩回收的时候的一个问题是如何找到一个适合用来移动的对象,一个适合用来移动的对象需要让这个对象占用的空间不会很快被回收,不然又需要再次移动对象
还是上面的例子,假定现在有 b 对象和 c 对象两个都满足移动的条件,移动这两个对象都能减少相同的内存碎片。那么如何评估移动哪个对象更好?就是通过评估这个对象还会存在多久,存在越久就应该越优先移动。如果 b 对象在下一个毫秒就可以被回收了,而 c 对象也许可以用到应用程序关闭。那么如果此时移动了 b 对象,在下一个毫秒还是需要将 c 对象移动。如果此时移动了 c 对象,那么在下一毫秒依然不需要移动对象
但是 dotnet 是不会了解未来的,那么如何评估某个对象未来是否被回收?其实工程可以告诉大家,越先创建的对象将会被越先回收。越古老的对象将会被越慢回收
也就是评估 b 对象和 c 对象在未来哪个对象会被先回收,可以用一个不靠谱的方法,但是这个方法很有效,就是通过判断 b 对象和 c 对象哪个创建更久。如果 c 对象创建时间更长,那么 c 对象将会越慢被回收
虽然有时候上面这个判断也会不对,但是工程告诉大家,大部分时候这个不靠谱的方法是有效的。这个方法有各种计算机科学研究的广泛证实
根据上面这个理论,在 dotnet 将对象分为几代,第一代就是刚创建的对象,这些对象很快都会被回收,而第二代就是创建了一段时间的对象,当然还有第三代对象
这就是将对象分代的意义,基本上会进行大量回收的都是第一代,而如果第一代创建的对象在完成回收时,存在没有被回收的对象,就将这个对象放在第二代。所有第二代基本都会放在一个连续的内存里面,因为这部分对象将会很少被释放也就很少被移动
有一个方法可以大概说明上面的方法是有效的,在进入某个方法的时候,在这个方法内将会创建一些局部变量,这些变量如果都是引用对象,那么因为大部分局部变量都会在方法结束之后可以被回收,这些引用对象将会快就需要执行回收。如果此时创建的对象还没有被回收,要么这些对象被方法返回了,继续被上层方法使用,要么被加入到其他对象的对象或字段或集合里面,此时的对象基本上都会使用很久
在 SOH 小对象堆因为回收策略采用压缩内存的原因,需要在压缩内存时暂停所有线程,会降低应用的性能。因此为了减少压缩内存,就需要将对象按对象的生存期分成若干部分
当然,将 dotnet 里的对象分代将是另一个更复杂的话题,也不在本文范围内
这就是关于 dotnet 的 SOH 小对象堆和 LOH 大对象堆的笔记了,更详细还需要等伟民哥翻译的 《.NET内存管理宝典 - 提高代码质量、性能和可扩展性》 发布
另外推荐一下伟民哥的 《.NET并发编程实战 - 现代化的并发并行编程模式》(Concurrency in .NET - Modern patterns of concurrent and parallel programming) 这本书
本文会经常更新,请阅读原文: https://dotnet-campus.github.io//post/%E8%AF%BB%E4%B9%A6%E7%AC%94%E8%AE%B0-dotnet-%E5%A4%A7%E5%AF%B9%E8%B1%A1%E5%A0%86%E5%92%8C%E5%B0%8F%E5%AF%B9%E8%B1%A1%E5%A0%86.html ,以避免陈旧错误知识的误导,同时有更好的阅读体验。
本作品采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议 进行许可。欢迎转载、使用、重新发布,但务必保留文章署名 lindexi (包含链接: https://dotnet-campus.github.io/ ),不得用于商业目的,基于本文修改后的作品务必以相同的许可发布。如有任何疑问,请 与我联系 。