前言

​ 遇到个项目需要从外部加载很多图片,外部图片不是规整的2的幂次方图,加载占用内存额外大,刚好看到雨松在unity content 上面关于unity 内存的文章 ,顺便记一下 (其实就是自己复述一遍,偷笑.gif)

堆内存与垃圾回收

  • 托管内存

.NET 有自己的垃圾回收,会帮我们回收堆内存,回收时间是不确定的,当我们new一个对象的时候就会产生托管堆内存

1
2
3
4
5
6
7
class A : System.Object
{
}
private void Update()
{
var a = new A();
}

因为new了一个新的对象 所以产生了堆内存

  • 非托管内存

不是被.NET分配出来的内存,比如Unity里的贴图、网格、模型、材质Shader等等一些文件内存 ,如下代码所示,分别创建一个10X10和1024X1024的贴图资源。(Resources.Load或者AssetBundle.Load也一样)

1
2
3
4
5
6
7
8
9
private void Update() {
Profiler.BeginSample("10 x 10");
new Texture2D(10, 10);
Profiler.EndSample();

Profiler.BeginSample("1024 x 1024");
new Texture2D(1024, 1024);
Profiler.EndSample();
}

如下图所示,无论是10X10还是1024X1024的图所占用的堆内存都是一样的,换句话说堆内存中只是记录了一个文件内存的指针,实际文件则保存在非托管堆中了

如下图所示,用Profiler查看一下非托管堆内存,可以看到1024X1024和10X10所占的内存是完全不同的。

img

那么托管堆内存和非托管堆内存如何进行释放呢?很抱歉托管堆内存我们是无法释放的,必须等到垃圾回收的时候释放,但是非托管堆内存我们是可以释放的,Unity就提供了两个API来释放。

1
2
Resources.UnloadAsset(obj);
Resources.UnloadUnusedAssets();

Resources.UnloadAsset需要传入一个资源文件,也就是主动指定释放内存,比如texture、mesh、shader等,不能是GameObject、Component、AssetBundle文件。

Resources.UnloadUnusedAssets就是卸载无用资源,这个函数是比较慢。请大家想象一下在连续的内存中怎么才能确定那些是有引用的内存那些是无引用(垃圾)内存呢?得一个个的循环遍历才能找到垃圾内存吧,可想而知它会有多慢

除了Unity的资源,还有文件、数据库,这些都属于非托管堆内存,我们来做一个试验,如下代码所示。

1
2
3
4
5
6
7
8
9
10
FileStream fileStream;
private void Awake()
{
fileStream = new FileStream(Application.streamingAssetsPath + "/1.txt", FileMode.OpenOrCreate, FileAccess.ReadWrite);
}
void Start()
{
fileStream = null;
fileStream = new FileStream(Application.streamingAssetsPath + "/1.txt", FileMode.OpenOrCreate, FileAccess.ReadWrite);
}

就会收到下面这个IO错误,代码中虽然让fileStream的指针指向了null,但是由于fileStream所占的非托管内存并没有被delete掉,所以后面再去请求这个fileStream就报错了。

IOException: Sharing violation on path /Users/momo/New Unity Project (2019.1.4f1)/Assets/StreamingAssets/1.txt

如下代码所示,我们来改造一下,只需要进行一下GC,这样就不会再报错了。注意代码中的System.GC.WaitForPendingFinalizers();表示等析构函数执行完毕。

1
2
3
4
5
6
7
8
9
void Start()
{
fileStream = null;

System.GC.Collect();
System.GC.WaitForPendingFinalizers();

fileStream = new FileStream(Application.streamingAssetsPath + "/1.txt", FileMode.OpenOrCreate, FileAccess.ReadWrite);
}

fileStream = null;以后自身变成了垃圾,但是堆内存中还没有进行释放。System.GC.Collect();以后就会执行FileStream的析构函数,System.GC.WaitForPendingFinalizers();就是等析构函数执行完毕。FileStream最终在析构函数中将文件句柄Dispose掉,然后就不影响后面再去用它了。

.NET 中推内存 我们可以不管 但是非托管内存必须要管理,C#提供了一个主动释放托管堆内存的接口

System.IDisposable

它的目的就是让我们主动释放非托管堆内存的,如下代码所示,只要通过using()包起来就会自动调用类的Dispose方法,从而实现释放。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class A : System.IDisposable
{
FileStream fileStream;
public A()
{
fileStream = new FileStream(Application.streamingAssetsPath + "/1.txt", FileMode.OpenOrCreate, FileAccess.ReadWrite);

}
public void Dispose()
{
fileStream.Dispose();
}
}


private void Awake()
{
using (var a = new A())
{
}
using (var a = new A())
{
}
}

并不是所有人都会用using(),万一有个新手程序员不知道这么用,那System.IDisposable岂不是形同虚设了么?所以这时候就要引入一个新概念那就是析构函数。

析构函数的执行时机,并不是fileStream=null的时候执行。而是托管堆被真正GC垃圾回收后执行的,所以我们可以在析构函数中只进行非托管堆内存的释放工作。如下代码所示(简单来说 析构函数 就是用来让我们自己释放非托管堆内存)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class A : System.IDisposable
{
FileStream fileStream;
public A()
{
fileStream = new FileStream(Application.streamingAssetsPath + "/1.txt", FileMode.OpenOrCreate, FileAccess.ReadWrite);

}
public void Dispose()
{
fileStream.Dispose();
}
~A()
{
fileStream.Dispose();
}
}

这样析构函数也能正确的释放资源了,但是又出现一个新问题,如果是代码主动调用Dispose那岂不是还要多进行一次GC?所以我们在代码中要区分主动释放资源和析构函数自动释放资源的时机。如下代码所示,这样就完整了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class A : System.IDisposable
{
FileStream fileStream;
public A()
{
fileStream = new FileStream(Application.streamingAssetsPath + "/1.txt", FileMode.OpenOrCreate, FileAccess.ReadWrite);

}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);//告诉.NET我不需要GC了,别再遍历我了
}

~A()
{
Dispose(false);
}

void Dispose(bool disposing)
{
if (disposing)
{
//释放托管堆内存
}
//释放非托管堆内存
fileStream.Close();
}
}

强行解释一下这里为什么在方法一个传入true 一个传入false,在我们手动调用Dispose()方法的时候其实就是释放这个类的托管资源和非托管资源,在析构函数中 析构函数表示这个类就准备给GC了 他的托管资源.NET帮我们回收 我们只要负责回收非托管资源就可以了 这里有一篇分析很好的关于IDisposeable接口的文章

我们在回头说一下堆栈,.NET规定值类型数据保存在栈上,class类型数据保存在堆上。栈内存由系统自行管理,不需要垃圾回收。其实就是值类型数据系统方便复用,而class类型数据系统也没法搞,你们还是GC吧。成

员内存分配,由于A是一个class类对象,它本真就会占堆内存,那么A对象里即使是值类型数据,那么它也会跟A类一样占在堆内存中。所以请大家记住,值类型数据不一定不占堆内存,但是class数据一定会占堆内存

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class NewBehaviourScript : MonoBehaviour
{
struct B {}
class A
{
//堆
int i = 100;
//堆
B b = default(B);
//堆
//A a = new A();
}
//栈
int i = 100;
//栈
B b = default(B);
//堆
A a = new A();
private void Awake()
{
new A();//分配内存
}
}

可是为什么NewBehaviourScript类中的值类型成员对象并没有占堆内存呢?原因是NewBehaviourScript并不是new出来的,Unity内部用的是反射。

大家可以回想一下unity中常用的Vector3,这就是一个标准的struct对象。unity之所以这么用就是因为想减少堆内存。

再来说说值类型和class类型的区别,值类型可以说是个体,而 class类型可以说是引用。个体永远不会被别人改,而引用就很可能被别人改。如下代码所示,b虽然等于a,然是a在后面改了,b的值并没有跟着改。

1
2
3
4
5
int a = 100;
int b = a;
a = 200;
Debug.Log(a); //200
Debug.Log(b); //100

如下代码所示,如果是class那么一个改了就会影响到另一个,我想这些大家应该都很很好理解。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class  A
{
public int a;
}
private void Awake()
{
A a = new A();
A b = a;
a.a = 100;
b.a = 200;

Debug.Log(a.a); //200
Debug.Log(b.a); //200
}

通过上面代码我们还可以看出class的赋值是非常廉价的,代码中的 A b = a; 就是添加了一个新指针指向了一块相同的地址而已。

但是值类型就不一样了。如下代码所示,因为结构体是数值类型,所以A b = a;的时候需要将结构体里的每一个数据都进行一次完全拷贝,如果结构体比较大那么拷贝势必就会慢,但是优点就是没有GC。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct A
{
public int a;
public int b;
public int c;
}
private void Awake()
{
A a = new A();
A b = a;
a.a = 100;
b.a = 200;

Debug.Log(a.a); //100
Debug.Log(b.a); //200
}

我们在来看看参数传递,值类型参数传递其实就是在栈上拷贝了一份新的,此时栈上有两个int 值都是100。方法体内改了参数并不会影响外面的,结果肯定还是100。

1
2
3
4
5
6
7
8
9
10
11
private void Awake()
{
int a = 100;
Test(a);
Debug.Log(a); //100
}

private void Test(int a)
{
a--;
}

再来看看类对象,这时候你可能会有疑问,传入的类对象是否也进行了拷贝,答案是肯定的。此时堆上只有1个A对象,但是有两个指针指向它。Test方法体内接收的就已经是拷贝出来的指针,但是由于两个指针指向了同一块内存,所以改了方法体里的外面的也就跟着变了,结果就是99了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class A
{
public int a;
}
private void Awake()
{
A a = new A() { a = 100 };
Test(a);
Debug.Log(a.a); //99
}
private void Test(A a)
{
a.a--;
}

总的来说数值类型传递慢,但是没GC ,class类型传递快,但有GC,还有一个ref out的用法,就是把真正的引用传进去,而不是拷贝,如下代码所示,因为是引用所以值类型数据传进去也会跟着改了。

1
2
3
4
5
6
7
8
9
10
private void Awake()
{
int a = 100;
Test(ref a);
Debug.Log(a); //99
}
private void Test(ref int a)
{
a--;
}

再来说一下装箱和拆箱,装箱就是将int值类型数据转成object类对象,拆箱就是将object类对象转回值类型数据,写法上是可以进行隐式转换的。如下代码所示,我们来看个经典的例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
private void Awake()
{
Test(A.TestA);
Test(B.TestB);
}
void Test(object o) //传进来就隐式被装箱了
{
if(o is A)
{
Debug.Log((A)o);//这里又显示拆箱了
}
if (o is B)
{
Debug.Log((B)o);//这里又显示拆箱了
}
}
enum A
{
TestA =0
}
enum B {
TestB =0
}

C#讲究个万物皆对象, 任何东西都可以通过参数传到object对象来接受,但是这一步就被隐式转换成堆内存了。。再来看个例子。

1
2
int i = 100;
var a = string.Format("{0}", i); //完了这里又被隐式装箱了

正确的写法应该是先转成字符串,这样就不需要装箱了。

1
2
int i = 100;
var a = string.Format("{0}", i.ToString());

凭啥int就是值类型?int就是System.int32对象,对应的还有float、double、等等,但是他们都继承了struct,所以统统都是值类型了。

1
2
3
public struct Int32 : IComparable, IFormattable, IConvertible
, IComparable<Int32>, IEquatable<Int32>
/// , IArithmetic<Int32>

装箱的效率是不如拆箱要高的,比如把一个 int装箱成object,那么此时需要在堆中开辟一块内存,并且赋值给它。而拆箱并不需要在栈上分开辟新的内存,只需要值重新赋到栈上就行了。赋值完后,堆上的这块内存就变成了垃圾等待下次垃圾回收掉。

注意一下,struct和enum对象,如果放在 Dictionary<T,T> List数据结构里,用法不当就会产生大量的堆内存,就是上面说的隐式装箱,我们来看看代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
struct S 
{
public int a;
}

private void Update()
{

//list
S s = new S() { a = 10 };
List<S> list = new List<S>() { s };
Dictionary<S, S> dict = new Dictionary<S, S>() { { s, s } };

Profiler.BeginSample("struct list contains");
list.Contains(s); //装箱40b
Profiler.EndSample();

Profiler.BeginSample("struct dictionary ContainsKey");
dict.ContainsKey(s);//装箱60b
Profiler.EndSample();

Profiler.BeginSample("struct dictionary ContainsValue");
dict.ContainsValue(s); //装箱40b
Profiler.EndSample();

Profiler.BeginSample("struct dictionary [a]");
var n = dict[s]; //装箱60b
Profiler.EndSample();

Profiler.BeginSample("struct dictionary out");
S o;
dict.TryGetValue(s, out o); //装箱60b
Profiler.EndSample();

}

List和Dictionary在调用以上API时C#都会转成object类型,然后就产生堆内存了。以上代码换成enum也有同样效果。

img

感谢下面留言的朋友,其实之前我这里是想写enum类型的。因为之前向项目里遇到了这个坑,之前想把枚举放入Dictionary中,结果在取值的时候就产生了大量的GC,后来就规定了enum不写在Dictionary和List中。如下代码所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
enum E 
{
Name=1
}

private void Update()
{

Dictionary<E,E> dict = new Dictionary<E, E>() { { E.Name , E.Name } };
Profiler.BeginSample("enum dict1 []");
var e = dict[E.Name]; //装箱60b
Profiler.EndSample();

Dictionary<int, int> dict1 = new Dictionary<int, int>() { { 1, 1 } };
Profiler.BeginSample("int dict2 []");
var e1 = dict1[1]; //无gc
Profiler.EndSample();
}

img

说了这么多最后我们在聊聊C#的垃圾回收,如果内存中有50M左右的堆内存,每个都遍历一遍判断是否垃圾其实是非常慢的,C#引入了一个代的概念,C#认为新分配的内存最容易是垃圾,比如方法体内的临时变量,每新的 一帧都会来回调用其实全都是需要垃圾回收的。

首次垃圾回收前所有的内存只是第0代,扫描垃圾后会将不是垃圾的内存标记到第1代。这样下次在进行垃圾回收就不再扫描第1代了,而只扫描第0代(除非内存实在不够了才会继续向上扫描)。这样将大大减少垃圾扫描的时间。整个流程依次类推C#会总共会保留 0 1 2 一共有3代垃圾标志,直到你的程序太猛把所有内存都吃满了,C#不得不来个内存溢出。

垃圾回收其实是不建议主动调用的,因为系统会自动调用。C#会在new 的时候进行检查是否需要触发垃圾回收。本身这套机制是没问题的,但是放到Unity里就有问题了,因为Unity的资源texture、mesh、shader等都被放到了托管堆中无法及时释放。为啥释放不了其实还是写法有问题,太依赖Resources.UnloadUnusedAssets。有些不常用的资源用过以后要及时主动Resources.UnloadAsset(obj); 从非托管堆中干掉。

时刻要注意的是非托管内存,.NET在厉害也只能回收自己的内存,非托管内存它是毫无办法的。前面我们已经举了一些实际例子,这里我在补充一下,非托管内存还有一些第三方SDK可能占的内存,或者用 lua开发可能很多人都见过

1
System.IntPtr   

它可以指向一个非托管的内存地址,比如文件句柄。一定要注意非托管内存的回收。

最后在说一下IL2CPP,可能会有人有疑问不是都转成c++代码?C++哪里有垃圾回收啊。C++确实没有垃圾回收,但是IL2CPP模拟了一套垃圾回收。在怎么IL2CPP它也无法知道何时准确的delete掉你的内存,所以IL2CPP和真正的C++效率还是不一样的。

Unity 中的非托管资源卸载

非托管资源 简单说就是自己从外部加载的资源,例如图片 音频 文本….

卸载图片

加载图片有Texture.load(bytes),直接从外部加载图片,卸载的时候只需要调用 Destoty(xxx)就可以卸载

卸载AssetBundle资源

ab中有一个卸载方法Unload(false/true)

Unload(false)的时候卸载的只是这个包的镜像文件,已经加载的资源不会被删除

Unload(true)的时候卸载的是整个ab包的资源,加入有一个图片是从ab中加载的,那么调用这个方法 这个图片直接会miss

使用Resources.UnloadUnusedAssets和Resources.UnloadAsset卸载

Resources.UnloadUnusedAssets:比较耗费,一是这个是全局的,二是这个会检查内存中没有引用的资源.卸载的东西必须是场景中没有关联的资源

Resources.UnloadAsset:可以从Ab包中卸载资源,不用管场景是否有关联音频和Texture可以卸载(已经确认),但是测试中发现Unity SpriteAtlas是没法卸载的


更新于2021年12月19日

Resources.UnloadAsset:This function can only be called on Assets that are stored on disk.(只能卸载磁盘中存在的文件,也就是只能卸载加载到Unity 内存中的文件,可以通过Profiler的Memory的Asset中查看.

切换场景卸载

切换场景的时候,会自动卸载没有用的资源和GC,也就是执行了

1
2
3
Resources.UnloadUnusedAssets();
System.GC.Collect();
System.GC.WaitForPendingFinalizers();

但是要注意的是,必须要清空场景中的引用,如下代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public RawImage rawImage;
private void Start()
{
byte[] bs = File.ReadAllBytes(@"M:\Image\壁纸\fuse.png");
Texture2D tex = new Texture2D(0,0);
tex.LoadImage(bs);
tex.Apply();
rawImage.texture = tex;
GetComponent<Button>().onClick.AddListener(()=>
{
//这里如果没有设置rawimg=null 那么就不会卸载这个图片资源
//必须设置=null 断开这个链接 才能保证资源卸载
// rawImage = null;
SceneManager.LoadScene("222");
});
}

如果是从Assetbundle中加载的资源,切换场景时候会自动卸载资源,AB包需要手动卸载