前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >C# 内存管理机制及 WP 内存泄漏定位方法

C# 内存管理机制及 WP 内存泄漏定位方法

作者头像
微信终端开发团队
发布2018-01-29 16:14:41
4K1
发布2018-01-29 16:14:41
举报

C#内存管理机制及WP内存泄漏定位方法

一、C#的内存管理机制

1. 托管资源与非托管资源

什么是托管资源?托管资源通俗的理解就是,把资源交给.net去管理,这些资源主要是数据,比如我们的各种对象,这些对象的回收都由.net来处理。非托管资源则是.net无法进行管理的的资源,必须在程序中显示的进行释放,比如文件、网络连接等。

2. C#的内存区域

在C#中,内存大致分成3个区,分别是堆、栈、静态/常量存储区。

a. 静态存储区,Static变量(值类型或者引用类型的指针)及常量存储的区域。

b. 栈。

c. 堆,堆又分为SOH堆(Small Object Heap,也叫GC堆)和LOH(Large Object Heap)堆,小于85KB的对象都在SOH堆中进行管理,否则放在LOH堆。LOH堆的内存分配和管理和C语言是很类似的,后面会讲到。

3. SOH堆的内存管理机制-标记和压缩算法。

SOH堆的管理方式可以说是C#语言最大的特征之一,它的职责为回收垃圾并保持堆的空闲空间和已用空间连续。

SOH堆采用标记压缩算法来管理内存,算法分为标记和压缩两个阶段:

a. 标记并清除:GC先假设heap中所有对象都可以回收,然后找出不能回收的对象,给这些对象打上标记,最后heap中没有打标记的对象都是可以被回收的。

b. 压缩阶段:对象回收之后heap内存空间变得不连续,在heap中移动这些对象,使他们重新从heap基地址开始连续排列,类似于磁盘空间的碎片整理。执行完后,由于对象被移动了,还要进行一个指针修复的操作,将所有被移动对象的指针修改定位到移动后的位置。

那么GC是怎么确定哪些对象是不可以被回收的?GC从所有的根对象出发开始搜索遍历,将所有能够访问到的对象标记为可到达。其他的对象则为不可到达。根对象包括全局对象、静态变量、局部对象、函数调用参数、当前CPU寄存器中的对象指针(还有finalization queue,后面会讲到)等。主要可以归为2种类型:已经初始化了的静态变量、线程仍在使用的对象。

这种清除不可到达对象的方式,相比引用计数法,可以彻底根除循环引用造成的内存泄漏

程序运行的时候对象这么多,对全部内存进行GC显然是不划算的。C#这里引入了分代算法,按代来回收,减少内存块移动的次数,依据主要是统计学基础。分代算法的假设前提条件:

a. 大量新创建的对象生命周期都比较短,而较老的对象生命周期会更长;

b. 对部分内存进行回收比基于全部内存的回收操作要快;

c. 新创建的对象之间关联程度通常较强。heap分配的对象是连续的,关联度较强有利于提高CPU cache的命中率,.NET将heap分成3个代龄区域: Gen 0、Gen 1、Gen 2;

当Gen0达到内存阈值,则触发0代GC,Gen0中幸存的对象进入Gen1,1代GC后,Gen1中幸存的对象进入Gen2。1代和2代GC被调的很少。这就意味着Gen2的对象会存在比较长的时间。C#提供了GC的接口,那我们是否应该代替平台主动调用GC呢?从这里可以看到,答案是:最好不要主动调用GC。因为主动调用GC会提前把Gen0中的对象送到Gen2,导致这些对象存在更长的时间。

可以看到SOH的已用空间和空闲空间都是连续的,这样有两个好处:一是在请求一块内存的时候效率很高,只要保留一个空闲内存起始位置,每次都从起始位置分配就可以了,这比C语言的链表管理空闲内存块要快很多。二是不存在内存碎片的问题。

4. LOH堆的内存管理。

由于大对象(>85000字节)一般来说都是会存在较长时间,且大块内存的移动非常耗时,所以对于大对象的管理,并没有采用标记-压缩算法,而是把标记为不可达的对象直接删除并清0内存,然后像操作系统一样使用一个链表链来管理空闲内存。当请求一块内存时,遍历空闲内存链表找到合适大小的内存块来满足请求。LOH的回收时机是在SOH中二代GC的时候。

所以大对象的分配会更慢,并且会产生内存碎片。

5. 析构函数(在C#中叫做Finalizer)

在GC过程中,遇到有析构函数的对象,会怎么处理?因为析构函数的复杂度是未知的,有可能非常耗时,所以在GC的过程中调用析构函数是不明智的。于是遇到有析构函数的对象,把这些对象放到一个待析构队列。会有一个低优先级的线程去执行这些对象的析构函数。为了兼容程序员在析构函数里激活对象,比如在析构函数里把this赋值给一个静态变量导致对象又变成可到达了,GC在执行完析构函数之后再决定是否要从内存里删除这个对象。可见,除非是需要在析构函数中释放非托管资源,其他任何情况下都不应该使用析构函数,因为析构函数会导致对象的内存被延后释放并带来额外开销。

6. 非托管资源的处理

非托管资源,诸如文件、网络Socket、摄像头等资源GC是没有办法释放的。我们可以用一个代理对象来封装一个非托管资源,并在析构函数里进行释放非托管资源,这样可以确保非托管资源不泄漏。

一旦要使用析构函数,就会加大GC的负担。那么如何能保障非托管资源不泄露,又有不错的性能呢?C#提供了IDisposable接口和GC.SuppressFinalize(功能是让GC忽略对象的析构函数),所以处理非托管资源的正确方式应该是这样:

a. 继承IDisposable接口;

b. 实现Dispose()方法,在其中释放托管资源和非托管资源,并调用GC.SuppressFinalize将对象本身从垃圾回收器中移除(垃圾回收器不在回收此资源);

c. 实现类析构函数,在其中释放非托管资源。

到目前看起来,好像IDisposable没有什么特殊,似乎随便自己写一个函数也能满足相同的功能。但其实C#对IDisposable的子类是有相应的语言支持的。比如使用using块的时候,编译器会自动增加调用对象的Dispose方法,并且确保异常发生的情况下,Dispose接口也会被调用到。比如下面这个代码:

会被编译器翻译成:

7. 值类型和引用类型

C#几乎所有的类型都继承自Object,当你用class声明一个没有基类的类的时候,是隐式继承自Object的,而Object还有一个特殊的子类ValueType,所有用Struct关键字声明的类型都隐式继承自ValueType,ValueType的子类就是值类型。所以区分值类型和引用类型的方式就是,看它是用Struct声明还是用Class声明。可以看到int、long这些基础类型都是用struct声明的。

引用类型通过new关键字创建,对象都是存储在堆里的,值类型则不一样,值类型的对象在函数中声明时,即使是通过new关键字创建,也是在栈中分配。

引用类型的特征就是永远是指针,永远按指针传递,而值类型则永远按值传递,区别可以看下面的代码:

那么问题来了,引用类型值类型到底哪家强?我认为大部分情况下都应该使用引用类型,因为共享同一个copy可以减少内存的占用,在参数传递时只传递指针也要更高效,但下面几种情况我认为应该考虑使用值类型:

a. 如果有大量生命周期短的小对象,比如在一些循环中需要反复创建和销毁的小型数据结构,那么应该使用值类型,因为值类型在栈上创建非常快,并且不会给GC带来负担。

b. 如果需要对数据进行”拍照”来快速获取并保留数据的状态,也可以用值类型。比如Datetime,每次获取都是获得一个Copy,可以及时的保存当前的时间。

c. 数据实在太小,又不需要共享一个copy的情况,比如Point,Size这种结构。

如果既需要像引用类型一样减少重复内容,又需要像值类型一样确保copy不会被其他地方修改。那么C#的string类就是最好的例子。个人感觉C#string的好用程度秒杀std::string。原因如下:

a. C#string是一个引用类型,所以你在传值时不必担心会重复创建内存。这点std::string就经常被迫需要复制一份新的std::string出来从而造成重复的内存分配和复制,且C语言的内存分配还很低效。

b. C#string不提供任何对已存在string修改的接口,所有的接口都是返回一个新的C#string,比如C#string.replace(),其实是新创建了一个string返回。这样保证了共享一个对象的时候不用担心这个对象从其他地方被修改,这又是值类型的优点。

c. 提供StringBuilder类来处理构建C#string的过程,不会引起C#String构建过程中+=这种操作造成大量小对象。

8. 小结

a. 在堆中分配内存(<85KB),C#是非常高效的,比C要快的多。

b. 相比IOS平台使用的引用计数的方式来管理内存,效率要高一些,但是有循环引用的陷阱。

c. 最好不要主动调用GC.Collect(),因为这会提前把一些对象移到第二代堆里。导致这些对象的回收变慢。

d. 尽量避免使用超大对象(>85KB),因为这类对象回收频率很低,分配很慢,还会造成内存碎片。

e. 没有非托管资源的时候不要使用析构函数。

f. 处理非托管资源,要遵循规范使用IDisposable接口、GC.SuppressFinalize、以及析构函数。

g. 使用非托管资源,最好使用using块。

h. 必要的情况下,可以考虑使用值类型。

二、发现内存泄漏

微软提供了工具可以查看程序运行过程中各种对象的数量,但是这个工具非高内存电脑跑不起来,跑一次需要的时间也很久。这套工具royle比较熟悉,我研究的较少,就不在这里讨论了。

WP中占内存最大的还是UI,所以这里主要讨论的也是UI内存泄漏的定位。

1. 通过对构造函数和析构函数的调用次数来统计存活对象的个数。

用一个静态变量来记录这个类当前存活的数量,在需要监控的类的基类的构造函数里计数+1,在析构函数里计数-1。代码如下:

同理,也可以用一个静态的map<TypeName, InstanceCount>来记录每一个类的对象数量。只要在关键类的基类的构造函数和析构函数里加代码就可以了。

2. 使用Weakrefrence来监控对象的存活。

如果想看某一个对象什么时候释放,C#提供了一个弱引用Weakrefrence,GC搜索可到达对象的时候会忽略Weakrefrence指向的对象,使用方法如下:

3. 在WP微信中是如何发现内存泄漏的。

WP微信使用对象计数的方式来初步发现内存泄漏,如果已经离开一个页面,但这个页面仍然有存活的实例,那么就说明这个页面发生泄漏了。可见要发现UI的内存泄漏,还是很容易的。

三、如何定位泄漏的原因。

1. WP UI树的结构

发现UI有内存泄漏后,往往还是很难得知具体的泄漏点,这和WP UI结构有关系。众所周知,WP的UI结构是一颗树,但从内存引用关系的角度来看,在UI树上,任意相连的两个节点之间的连接并不是单向的,而是双向的,举个例子:一个Panel通过Children容器引用了所有的子元素,而每一个子元素又通过Parent属性引用其父控件。这样导致的结果就是从任一个节点出发去遍历内存,都能遍历完整个UI树,这意味着WP的UI结构在内存的视角上其实是一个强连通图,任何一个元素的泄漏都会引起整个Page所有元素的泄漏。这样一来,我们就很难知道具体是哪个控件引起的泄漏,因为真凶隐藏在人民群众的汪洋大海中了。

2. 拆散UI树

前面提到UI树中元素的引用关系是一个强连通图,所以只要找到办法将这个图破坏掉,让真凶失去群众基础,就可以逼出真凶了。这里直接上代码:

遍历整个UI树,将所有的UI元素的子元素清空。

3. 将UI内部的代码引用置为NULL

完成上一步后,其实还没有完全拆散UI元素之间的引用关系。原因在于我们在写xaml时会用x:Name为很多元素取名字。

xaml会被IDE处理成这样的代码:

可以看到这里Page里面会有很多指针引用了子元素。但经过观察,发现_contentLoaded这个变量永远都会在所有的x:Name自动生成的变量后面。于是利用反射,可以有一个猥琐的方法来实现将这些指针置为NULL,详见下面代码:

至此,UI树中元素大部分的引用关系已经被解除了,剩下的引用关系主要是UI元素之间事件的监听以及业务本身逻辑所导致的引用。

4. 使用WeakRefrence来最终定位泄漏点

如果已经确定一个页面存在泄漏,那么可以在这个页面退出的时候,将页面所有的元素通过上面说的方式拆散并放入一个WeakRefrence数组中,过10秒左右再查看这个WeakRfrence数组中哪些对象是存活的,存活的对象就是泄漏点了。这10秒内可以适当做几次GC.Collect()

5. 查找泄漏原因

a. 泄漏的原因主要还是监听了事件中心的事件。所以看看该类代码中注册事件监听和反注册监听是否配对,在代码中搜索+=。

b. 其他被引用导致的泄漏,一般可以在泄漏的类中搜索this指针,看this指针是否有被添加到一些静态变量中。

6. 小结

查找内存泄漏的步骤分为三部:

a. 发现泄漏(存活对象计数)

b. 缩小观察范围(尽量解除元素之间的引用关系)

c. 对可疑泄漏类查找泄漏原因(在代码中搜索this指针及+=回调)

可以把a和b中的逻辑分别封装成单独的工具类。

四、一次实际的寻找内存泄漏的例子

WP微信中已经将发现泄漏和定位泄漏的逻辑封装成了工具类,并有相应的UI展示,下面是一次实际的使用案例。

1. 发现泄漏

装上WP微信Debug版本,使用一段时间后,查看计数的UI个数:

可以看到OfficialAccountSessionList(公众号会话列表)这个页面存在3个实例没有释放,于是发现一个内存泄漏的页面。

2. 定位泄漏点

打开提示泄漏定位功能,再次进入公众账号会话列表,然后退出,静等10秒左右。当前泄漏的控件为:

可以看到:有三个泄漏类型:页面,MMListBox,和SessionListItem。

这个三个类型通过回调以及数据互相有引用关系,所以同时泄漏了。

3. 分析泄漏原因

其中MMListBox是一个公用控件,不会是泄漏的源头,排除在外。

SessionListItem是列表项,没有数据的时候就不会有列表项,所以排除法试一下没有数据的情况,进入公众号会话列表看看还会不会泄漏。结果是,没有数据,这个页面就不会泄漏了。所以可以认定SessionListItem是泄漏点。

查看SessionListItem的代码,搜索this指针的传递,发现this指针被多处静态集合引用,挨个排除找到最后引起泄漏的原因为this指针被传入到一个静态集合里,却没有在合适的时机被解除引用。

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2015-09-28,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 WeMobileDev 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • C#内存管理机制及WP内存泄漏定位方法
相关产品与服务
容器服务
腾讯云容器服务(Tencent Kubernetes Engine, TKE)基于原生 kubernetes 提供以容器为核心的、高度可扩展的高性能容器管理服务,覆盖 Serverless、边缘计算、分布式云等多种业务部署场景,业内首创单个集群兼容多种计算节点的容器资源管理模式。同时产品作为云原生 Finops 领先布道者,主导开源项目Crane,全面助力客户实现资源优化、成本控制。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档