Ruikuan/blog

.net 中的大对象

Closed this issue · 0 comments

在 .net 的内存分配中,有四种逻辑区域,Gen 0/1/2 三代是针对小对象的,还专门有个 LOH 大对象堆,里面放的是“大”对象。因为大对象在内存中复制来复制去成本很高,因此特别给它安排了 LOH,LOH 回收的时候只标记和清除废弃的对象,不会进行压缩,也就是说,不会在 LOH 中挪动大对象。因此如果中间部分大对象被回收了,这个段中间就有孔洞,这跟小对象堆是不同的,小对象堆每次回收都会压缩,全都缩在一起(pin住了的除外)。

.net 团队经过大量测试,找到了大小对象的分界点,为 85000 字节,>= 85000 bytes 的就是大对象。大对象一般是各种数组,除了机器生成的代码,正常的对象也到不了那么大。但 double 数组是个特例,如果 double 数组超过 1000 个元素,就认为它是大对象了,据说是因为 double 是 8 bytes 的,要 8 字节对齐,所以特殊对待。但没找到资料说 long 是怎么处理的。现在 clr 开源了,有空可以去看看具体是怎么处理。我在知乎看到个帖子说 primitive type 的数组,无论多大都不会进 LOH,但我没查到对应的资料,而且对这个说法我是很怀疑的,毕竟大对象提出的目的就是解决复制成本问题。还是得等看了 clr 代码才知道是不是真如所说,在此之前,先保留怀疑态度。

大对象 gc 有两个特点:

  • 回收时不压缩,因此可能会留洞。留洞多了,内存利用率就不高,很可能总体内存满足需求的,但因为不连续的洞比较小,就塞不进去,然后 oom。而且留洞也会导致查找空闲空间塞对象的过程比较复杂耗时,降低性能。
  • LOH 的回收是跟 Gen 2 回收一起的。因此每次对 LOH 回收都会触发 full gc,成本很高,造成吞吐量急剧下降。

如何避免频繁 gc LOH 呢?我想到的有下面几个办法:

  1. 将一块大对象 pin 起来,尽可能复用,而不是生成新的大对象。这种模式有对象池、memoryPool 等。分配越少,就越不会触发 gc。
  2. 可以将一个逻辑大对象实现成多个小对象,这样它就不会跑进 LOH 了,就可以早期在 Gen 0 中被回收,成本很低。做法就是假如需要一个 > 85000 bytes 的 List,可以实现一个 IList 对象,内部是将 List 切分为多个小 List 的链表,每个小 List 不会超过 85000 bytes。这样就可以避免对象过大了。需要注意的是,在 x64 中,每个引用需要占用 8 bytes 而不是 x86 中的 4 bytes。应该现在已经有这样的技术了。
  3. 在栈上分配数组,完全避免回收。可以用 unsafe 的 stackalloc 将数组分配在栈上,如下面代码。但需要注意不要同时分配过多过大的数组,造成 stackoverflow,而且由于对象太大,要注意隐式的赋值复制。尽量用 ref 操作。
//在栈上直接分配数组
unsafe
{
    Char* pc = stackalloc Char[20000];
}
//或构造一个 struct,将数组嵌入到 struct,由于 struct 是分配在栈上的,也实现了栈上分配数组
unsafe struct CharArray
{
    public fixed Char Characters[20];
}
  1. 使用非托管的内存,使用 Marshal.AllocHGlobal 或 Marshal.AllocCoTaskMem 分配内存,然后对应地手动使用 Marshal.FreeHGlobal 和 Marshal.FreeCoTaskMem 来释放内存。这样不会给 GC 增加压力。现实的使用方式可以见 Kestrel 的一些使用

在实践中,大对象经常是比较长的字符串,例如 xml、生成的 html、以及传来传去的 json 等,这都是需要注意的地方。譬如小心选择靠谱的 json 序列化方案,限制用户上传的内容长度或将长度分块,流式处理数据以避免分配过多内存等。