在阅读这篇文章:Announcing Net Core 3 Preview3的时候,我看到了这样一个特性:git
Docker and cgroup memory Limitsgithub
We concluded that the primary fix is to set a GC heap maximum significantly lower than the overall memory limit as a default behavior. In retrospect, this choice seems like an obvious requirement of our implementation. We also found that Java has taken a similar approach, introduced in Java 9 and updated in Java 10.docker
大概的意思呢就是在 .NET Core 3.0 版本中,咱们已经经过修改 GC 堆内存的最大值,来避免这样一个状况:在 docker 容器中运行的 .NET Core 程序,由于 docker 容器内存限制而被 docker 杀死。编程
刚好,我在 docker swarm 集群中跑的一个程序,老是被 docker 杀死,大都是由于内存超出了限制。那么升级到 .NET Core 3.0 是否是会起做用呢?这篇文章将浅显的了解 .NET Core 3.0 的 Garbage Collection
机制,以及 Linux 的 Cgroups
内核功能。最后再写一组 实验程序 去真实的了解 .NET Core 3.0 带来的 GC 变化。c#
.NET 程序是运行在 CLR : Common Language Runtime 之上。CLR 就像 JAVA 中的 JVM 虚拟机。CLR 包括了 JIT 编译器,GC 垃圾回收器,CIL CLI 语言标准。安全
那么 .NET Core 呢?它运行在 CoreCLR 上
,是属于 .NET Core 的 Runtime。两者大致我以为应该差很少吧。因此我介绍 CLR 中的一些概念,这样才能够更好的理解 GC服务器
咱们的程序都是在操做虚拟内存地址,历来不直接操做内存地址,即便是 Native Code。并发
一个进程会被分配一个独立的虚拟内存空间,咱们定义的和管理的对象都在这些空间之中。
虚拟内存空间中的内存 有三种状态:空闲 (能够随时分配对象),预约 (被某个进程预约,尚且不能分配对象),提交(从物理内存中分配了地址到该虚拟内存,这个时候才能够分配对象)app
CLR 初始化GC 后,GC 就在上面说的虚拟内存空间中分配内存,用来让它管理和分配对象,被分配的内存叫作 Managed Heap
管理堆,每一个进程都有一个管理堆内存,进程中的线程共享一个管理堆内存编程语言
CLR 中还有一块堆内存叫作LOH
Large Object Heap 。它也是隶属于 GC 管理,可是它很特别,只分配大于 85000byte 的对象,因此叫作大对象,为何要这么作呢?很显然大对象太难管理了,GC 回收大对象将很耗时,因此没办法,只有给这些 “大象” 另选一出房子,GC 这个“管理员” 不多管 “大象”。
那么何时对象会被分配到堆内存中呢?
全部引用类型的对象,以及做为类属性的值类型对象,都会分配在堆中。大于 85000byte 的对象扔到 “大象房” 里。
堆内存中的对象越少,GC 干的事情越少,你的程序就越快,由于 GC 在干事的时候,程序中的其余线程都必须毕恭毕敬的站着不动(挂起),等 GC 说:我已经清理好了。而后你们才开始继续忙碌。因此 GC 一直都是在干帮线程擦屁股的事情。
因此没有 GC 的编程语言更快,可是也更容易产生废物。
那么 GC 在收拾垃圾的过程当中到底作了什么呢?首先要了解 CLR 的 GC 有一个Generation
代 的概念 GC 经过将对象分为三代,优化对象管理。GC 中的代分为三代:
Generation 0
零代或者叫作初代,初代中都是一些短命的对象,shorter object,它们一般会被很快清除。当 new 一个新对象的时候,该对象都会分配在 Generation 0 中。只有一段连续的内存
Generation 1
一代,一代中的对象也是短命对象,它至关于 shorter object 和 longer object 之间的缓冲区。只有一段连续的内存
Generation 2
二代,二代中的对象都是长寿对象,他们都是从零代和一代中选拔而来,一旦进入二代,那就意味着你很安全。以前说的 LOH 就属于二代,static 定义的对象也是直接分配在二代中。包含多段连续的内存。
零代和一代 占用的内存由于他们都是短暂对象,因此叫作短暂内存块。 那么他们占用的内存大小是多大?32位和63位的系统是不同的,不一样的GC类型也是不同的。
WorkStation GC:
32 位操做系统 16MB ,64位 操做系统 256M
Server GC:
32 w位操做系统 65MB,64 位操做系统 4GB!
当 管理堆内存中使用到达必定的阈值的时候,这个阈值是GC 决定的,或者系统内存不够用的时候,或者调用 GC.Collect()
的时候,GC 都会马上能够开始回收,没有商量的余地。因而全部线程都会被挂起(也并不都是这样)
GC 会在 Generation 0 中开始巡查,若是是 死对象,就把他们的内存释放,若是是 活的对象,那么就标记这些对象。接着把这些活的对象升级到下一代:移动到下一代 Generation 1 中。
同理 在 Generation 1 中也是如此,释放死对象,升级活对象。
三个 Generation 中,Generation 0 被 GC 清理的最频繁,Generation 1 其次,Generation 2 被 GC 访问的最少。由于要清理 Generation 2 的消耗太大了。
GC 在每个 Generation 进行清理都要进行三个步骤:
标记: GC 循环遍历每个对象,给它们标记是 死对象 仍是 活对象
从新分配:从新分配活对象的引用
清理:将死对象释放,将活对象移动到下一代中
GC 有两种形式:WorkStation GC
和 Server GC
默认的.NET 程序都是 WorkStation GC ,那么 WorkStation GC 和 Server GC 有什么区别呢。
上面已经提到一个区别,那就是 Server GC 的 Generation 内存更大,64位操做系统 Generation 0 的大小竟然有4G ,这意味着啥?在不调用GC.Collect
的状况下,4G 塞满GC 才会去回收。那样性能但是有很大的提高。可是一旦回收了,4GB 的“垃圾” 也够GC 喝一壶的了。
还有一个很大的区别就是,Server GC 拥有专门用来处理 GC的线程,而WorkStation GC 的处理线程就是你的应用程序线程。WorkStation 形式下,GC 开始,全部应用程序线程挂起,GC选择最后一个应用程序线程用来跑GC,直到GC 完成。全部线程恢复。
而ServerGC 形式下: 有几核 CPU ,那么就有几个专有的线程来处理 GC。每一个线程都一个堆进行GC ,不一样的堆的对象能够相互引用。
因此在GC 的过程当中,Server GC 比 WorkStation GC 更快。可是有专有线程,并不表明能够并行GC 哦。
上面两个区别,决定了 Server GC 用于对付高吞吐量的程序,而WorkStation GC 用于通常的客户端程序足以。
若是你的.NET 程序正在疲于应付 高并发,不妨开启 Server GC : https://docs.microsoft.com/en-us/dotnet/framework/configure-apps/file-schema/runtime/gcserver-element
GC 有两种模式:Concurrent
和 Non-Concurrent
,也就是并行 GC 和 不并行 GC 。不管是 Server GC 仍是 Concurrent GC 均可以开启 Concurrent GC 模式或者关闭 Concurrent GC 模式。
Concurrent GC 固然是为了解决上述 GC 过程当中全部线程挂起等待 GC 完成的问题。由于工做线程挂起将会影响 用户交互的流畅性和响应速度。
Concurrent 并行实际上 只发生在Generation 2 中,由于 Generation 0 和 Generation1 的处理是在太快了,至关于工做线程没有阻塞。
在 GC 处理 Generation 2 中的第一步,也就是标记过程当中,工做线程是能够同步进行的,工做线程仍然能够在 Generation 0 和 Generation 1 中分配对象。
因此并行 GC 能够减小工做进程由于GC 须要挂起的时间。可是与此同时,在标记的过程当中工做进程也能够继续分配对象,因此GC占用的内存可能更多。
而Non-Concurrent GC 就更好理解了。
.NET 默认开启了 Concurrent 模式,能够在 https://docs.microsoft.com/en-us/dotnet/framework/configure-apps/file-schema/runtime/gcconcurrent-element 进行配置
又来了一种新的 GC 模式: Background GC
。那么 Background GC 和 Concurrent GC 的区别是什么呢?在阅读不少资料后,终于搞清楚了,由于英语水平很差。如下内容比较重要。
首先:Background GC 和 Concurrent GC 都是为了减小 由于 GC 而挂起工做线程的时间,从而提高用户交互体验,程序响应速度。
其次:Background GC 和 Concurrent GC 同样,都是使用一个专有的GC 线程,而且都是在 Generation 2 中起做用。
最后:Background GC 是 Concurrent GC 的加强版,在.NET 4.0 以前都是默认使用 Concurrent GC 而 .NET 4.0+ 以后使用Background GC 代替了 Concurrent GC。
那么 Background GC 比 Concurrent GC 多了什么呢:
以前说到 Concurrent GC 在 Generation 2 中进行清理时,工做线程仍然能够在 Generation 0/1 中进行分配对象,可是这是有限制的,当 Generation 0/1 中的内存片断 Segment 用完的时候,就不能再分配了,知道 Concurrent GC 完成。而 Background GC 没有这个限制,为啥呢?由于 Background GC 在 Generation 2 中进行清理时,容许了 Generation 0/1 进行清理,也就说是当 Generation 0/1 的 Segment 用完的时候, GC 能够去清理它们,这个GC 称做 Foreground GC
( 前台GC ) ,Foreground GC 清理完以后,工做线程就能够继续分配对象了。
因此 Background GC 比 Concurrent GC 减小了更多 工做线程暂停的时间。
GC 的简单概念就到这里了以上是阅读大量英文资料的精短总结,若是有写错的地方还请斧正。
做为最后一句总结GC的话:并非使用了 Background GC 和 Concurrent GC 的程序运行速度就快,它们只是提高了用户交互的速度。由于 专有的GC 线程会对CPU 形成拖累,此外GC 的同时,工做线程分配对象 和正常的时候分配对象 是不同的,它会对性能形成拖累。
堆内存的大小进行了限制:max (20mb , 75% of memory limit on the container)
ServerGC 模式下 默认的Segment 最小是16mb, 一个堆 就是 一个segment。这样的好处能够举例来讲明,好比32核服务器,运行一个内存限制32 mb的程序,那么在Server GC 模式下,会分配32个Heap,每一个Heap 大小是1mb。可是如今,只须要分配2个Heap,每一个Heap 大小16mb。
其余的就不太了解了。
从开头的 介绍 ASP.NET Core 3.0 文章中了解到 ,在 Docker 中,对容器的资源限制是经过 cgroup 实现的。cgroup 是 Linux 内核特性,它能够限制 进程组的 资源占用。当容器使用的内存超出docker的限制,docker 就会将改容器杀死。在以前 .NET Core 版本中,常常出现 .NET Core 应用程序消耗内存超过了docker 的 内存限制,从而致使被杀死。而在.NET Core 3.0 中这个问题被解决了。
为此我作了一个实验。
这是一段代码:
using System; using System.Collections.Generic; using System.Threading; namespace ConsoleApp1 { class Program { static void Main(string[] args) { if (GCSettings.IsServerGC == true) Console.WriteLine("Server GC"); else Console.WriteLine("GC WorkStationGC"); byte[] buffer; for (int i = 0; i <= 100; i++) { buffer = new byte[ 1024 * 1024]; Console.WriteLine($"allocate number {i+1} objet "); var num = GC.CollectionCount(0); var usedMemory = GC.GetTotalMemory(false) /1024 /1024; Console.WriteLine($"heap use {usedMemory} mb"); Console.WriteLine($"GC occurs {num} times"); Thread.Sleep(TimeSpan.FromSeconds(5)); } } } }
这段代码是在 for 循环 分配对象。buffer = new byte[1024 * 1024]
占用了 1M 的内存
这段代码分别在 .NET Core 2.2 和 .NET Core 3.0 运行,彻底相同的代码。运行的内存限制是 9mb
.NET Core 2.2 运行的结果是:
GC WorkStationGC allocate number 1 objet heap use 1 mb GC occurs 0 times allocate number 2 objet heap use 2 mb GC occurs 0 times allocate number 3 objet heap use 3 mb GC occurs 0 times allocate number 4 objet heap use 1 mb GC occurs 1 times allocate number 5 objet heap use 2 mb GC occurs 1 times allocate number 6 objet heap use 3 mb GC occurs 1 times allocate number 7 objet heap use 4 mb GC occurs 2 times allocate number 8 objet heap use 5 mb GC occurs 3 times allocate number 9 objet heap use 6 mb GC occurs 4 times allocate number 10 objet heap use 7 mb GC occurs 5 times allocate number 11 objet heap use 8 mb GC occurs 6 times allocate number 12 objet heap use 9 mb Exit
首先.NET Core 2.2默认使用 WorkStation GC ,当heap使用内存到达9mb时,程序就被docker 杀死了。
在.NET Core 3.0 中
GC WorkStationGC allocate number 1 objet heap use 1 mb GC occurs 0 times allocate number 2 objet heap use 2 mb GC occurs 0 times allocate number 3 objet heap use 3 mb GC occurs 0 times allocate number 4 objet heap use 1 mb GC occurs 1 times allocate number 5 objet heap use 2 mb GC occurs 1 times allocate number 6 objet heap use 3 mb GC occurs 1 times allocate number 7 objet heap use 1 mb GC occurs 2 times allocate number 8 objet heap use 2 mb GC occurs 2 times allocate number 9 objet heap use 3 mb GC occurs 2 times ....
运行一直正常没问题。
两者的区别就是 .NET Core 2.2 GC 以后,堆内存没有减小。为何会发生这样的现象呢?
一下是个人推测,没有具体跟踪GC的运行状况
首先定义的占用 1Mb 的对象,因为大于 85kb 都存放在LOH 中,Large Object Heap,前面提到过。 GC 是不多会处理LOH 的对象的, 除非是 GC heap真的不够用了(一个GC heap包括 Large Object Heap 和 Small Object Heap)因为.NET Core 3.0 对GC heap大小作了限制,因此当heap不够用的时候,它会清理LOH,可是.NET Core 2.2 下认为heap还有不少,因此它不清理LOH ,致使程序被docker杀死。
我也试过将分配的对象大小设置小于 85kb, .NET Core 3.0 和.NET Core2.2 在内存限制小于10mb均可以正常运行,这应该是和 GC 在 Generation 0 中的频繁清理的机制有关,由于清理几乎不消耗时间,不像 Generation 2, 因此在没有限制GC heap的状况也能够运行。
我将上述代码 发布到了 StackOverFlow 和Github 进行提问,
https://stackoverflow.com/questions/56578084/why-doesnt-heap-memory-used-go-down-after-a-gc-in-clr
https://github.com/dotnet/coreclr/issues/25148
有兴趣能够探讨一下。
.NET Core 3.0 的改动仍是很大滴,以及应该根据本身具体的应用场景去配置GC ,让GC 发挥最好的做用,充分利用Microsoft 给咱们的权限。好比启用Server GC 对于高吞吐量的程序有帮助,好比禁用 Concurrent GC 实际上对一个高密度计算的程序是有性能提高的。
参考文章
========================更新=========================
对于.NET Core 3.0 GC的变化,我有针对Github 上做者的Merge Request 作出了如下总结:
.NET Core3.0 对GC 改动的 Merge Request
代码就不看了,一是看不懂,二是根本没发现对内存的限制,只是添加了获取容器是否设置内存限制的代码,和HeapHardLimit的宏定义,那就意味着,GCHeadHardLimit
只是一个阈值而已。由次可见,GCHeapHardLimit
属于GC的一个小部件。
其中有一段很重要的总结,是.NET Core 3.0 GC的主要变化
// + we never need to acquire new segments. This simplies the perf // calculations by a lot. // // + we now need a different definition of "end of seg" because we // need to make sure the total does not exceed the limit. // // + if we detect that we exceed the commit limit in the allocator we // wouldn't want to treat that as a normal commit failure because that // would mean we always do full compacting GCs.
首先就是,在有内存限制的 Docker 容器中,GC不须要去问虚拟内存要新的Segments
,由于初始化CLR的时候,把heap
和Segment
都分配好了。在Server GC
模式下,一个核心 CPU 对应一个进程,对应一个heap
, 而一个segment
大小 就是 limit / number of heaps
。
因此程序启动时,若是分配CPU 是一核,那么就会分配一个heap
,一个heap
中只有一个segment
,大小就是 limit
,GC 也不会再去问CLR要内存了。请注意这里的 limit
和 GCHeapHardLimit
不是同一个,这里的limit
应该就是容器内存限制。因此GC 堆大小是多少?就是容器的内存限制limit
特殊的判断segment结束标志,以判断是否超过GCHeapHardLimit
若是发现,在 segment
中分配内存的时候超出了GCHeadHardLimit
,那么不会把此次分配看作失败的,因此就不会发生GC。结合上面两点的铺垫咱们能够发现:
首先从上述代码咱们能够发现GCHeapHardLimit
只是一个数字而已。它就是一个阈值。
其次 GC堆的大小: 请注意,GC堆大小不是 HeapHardLimit 而是 容器内存限制 limit。GC 分配对象的时候,若是溢出了这个GCHeapHardLimit
数字,GC 也会睁一只眼闭一只眼,不然只要溢出,它就要去整个heap
中 GC 一遍。因此 GCHeadHardLimit
不是 GC堆申请的segment
的大小,而是 GC 会管住本身的手脚,不能碰的东西咱尽可能不要去碰,要是真碰了,也只有那么一次。
若是你的程序使用内存超出了GCHeapHardLimit
阈值,segment 中仍是有空余的,可是 GC 就是不用,它就是等着报OutOfMemoryException
错误,并且docker根本杀不死你。
可是这并不表明GCHeapHardLimit
的设置是不合理的,若是你的程序本身不能合理管理对象,或者你太抠门了,那么神仙也乏术。
可是人家说了!GCHeapHardLimit
是能够修改的!
// Users can specify a hard limit for the GC heap via GCHeapHardLimit or // a percentage of the physical memory this process is allowed to use via // GCHeapHardLimitPercent. This is the maximum commit size the GC heap // can consume. // // The way the hard limit is decided is: // // If the GCHeapHardLimit config is specified that's the value we use; // else if the GCHeapHardLimitPercent config is specified we use that // value; // else if the process is running inside a container with a memory limit, // the hard limit is // max (20mb, 75% of the memory limit on the container).
若是你以为GCHeapHardLimit
太气人了,那么就手动修改它的数值吧。