这篇文章阐述了当使用MemoryStream处理大型数据集时经常触发的模棱两可的OutofMemoryException异常,并且介绍了一个类——MemoryTributary,他可以用来替代.NET内置的MemoryStream,并且能够支持大型数据的处理。
这篇文章阐述了当使用MemoryStream处理大型数据集时经常触发的模棱两可的OutofMemoryException异常,并且介绍了一个类——MemoryTributary,他可以用来替代.NET内置的MemoryStream,并且能够支持大型数据的处理。
背景
当试图使用MemoryStream处理较大数据(in the order of tens of MB)时,它通常会引发OutofMemoryException异常。这是不是因为,正如其命名的那样,超出了系统内存的限制了呢?但实际上那都是进程的虚拟地址空间。
当进程从Windows申请内存的时候,内存管理器并没有从RAM中分配地址空间,但是“页面”——存储块(通常是4KB)可以存在于RAM、磁盘或者任何内存管理器能够决定存储它们的地方。页面映射到进程的地址空间,因此,假如当进程试图从[0xAF758000]开始访问内存时,它实际上是在访问[第496页]开始的地方,无论是否恰好是[第496页]。可以为进程分配任何大小的足够的内存,只要磁盘空间充足,并可以将其中的一大部分作为虚拟地址空间,这适用于任何时候。在这个分配过程中会产生大量的小碎片(这里是指存储块)。
这是因为进程地址空间是支离破碎的:大部分都被操作系统占用,诸如应用程序映像、库以及其他预分配的空间等。一旦内存管理器分配了所请求的相当于页面大小的内存,就必须在进程内映射到(虚拟)地址空间。而当剩余的地址空间中没有足够的连续空间时,页面就无法被映射,从而导致内存分配失败,也就引发了OutofMemoryException异常。
这个过程并没有耗尽空间和地址,它运行在连续的地址上。要看到这个(如果你在用64位系统),在x86目标平台下编译如下程序并运行它,然后再编译为x64目标平台,看看两者的区别。
1 static void Main(string[] args)
2 {
3 List<byte[]> allocations = new List<byte[]>();
4
5 for (int i = 0; true; i++)
6 {
7 try
8 {
9 allocations.Add(new byte[i * i * 10]);
10 }
11 catch (OutOfMemoryException e)
12 {
13 Console.Write(string.Format("Performed {0} allocations",i));
14 }
15 }
16 }
.NET当前实现的MemoryStream使用了一个字节数组作为后备存储。当要写入的内容超出数组长度的时候,容量就会自动扩展一倍(老陈注:这是.NET数组自身的内存分配机制)。根据程序这种行为,基于这种后备存储机制的MemoryStream会很快就需要比可用虚拟地址空间更多的内存。
代码用例
这个解决方案并不是申请更多更大的连续内存去存储流中包含的数据。MemoryTributary内部使用了4KB的动态集合来作为后备存储,实现按需分配。
MemoryTributary从Stream派生,因此可以像使用其他Stream一样的使用它,类似于MemoryStream。
MemoryTributary不过是想作为MemoryStream的一个替代方案,但不是可以在任何情况下都可以替代MemoryStream的,以下是一些注意事项:
- MemoryTributary没有完全实现MemoryStream所有的构造函数(因为MemoryTributary的容量是没有人为限制的)。MemoryTributary的初始容量从一个byte[]开始。
- MemoryTributary是Stream的子类,而不是MemoryStream,所以不能用在仅接受MemoryStream成员的地方。
-
MemoryTriburary
没有后备存储实现GetBuffer(),类似功能的方法是ToArray(),但是要慎用!
使用MemoryTributary要注意以下几点:
- 块是在访问时按需分配的(例如在读取或写入时)。在读取之前,重新检查Position,以确保读取操作是在流范围内进行的。
1 // 创建 MemoryTributary 的新实例:此时Length为0,Position为0,没有内存被分配
2 MemoryTributary d = new MemoryTributary();
3
4 // 因为Length为0所以返回-1,没有内存被分配
5 int a = d.ReadByte();
6
7 // Length现在设定为10000字节,但是没有内存被分配!
8 d.SetLength(10000);
9
10 // 现在有3个内存块被分配了,
11 // 但是b是未定义的,因为它们还没有完成初始化
12 int b = d.ReadByte();
- 内存是分配在连续的块上的,也就是说,如果第一个块是块3的话,那么块1和块2也会被自动分配掉;
- MemoryTributary包含了一个ToArray()方法,但这是不安全的;
- 作为替代,MemoryTributary使用ReadFrom()和WriteTo()这两个方法对大数据进行操作。
性能指标
在容量和速度上,MemoryStream和MemoryTributary都是很难预料的,因为这依赖于很多因素。一个很重要的因素是当前进程的内存碎片——一个进程分配了大量内存将会导致大量内存碎片的发生。
容量
速度(访问时间)
流测试执行时间比对 (ms) | ||||
读写容量 (MB) | MemoryStream | MemoryTributary | MemoryTributary | MemoryTributary |
10 | 10 | 13 | 11 | 7 |
| 3 | 5 | 3 | 3 |
| 3 | 6 | 3 | 3 |
| 3 | 5 | 3 | 3 |
| 4 | 5 | 3 | 3 |
| 3 | 6 | 3 | 3 |
100 | 100 | 148 | 123 | 52 |
| 34 | 54 | 42 | 35 |
| 34 | 48 | 35 | 34 |
| 35 | 47 | 36 | 35 |
| 34 | 48 | 36 | 35 |
| 35 | 51 | 35 | 35 |
500 | 516 | 390 | 290 | 237 |
| 167 | 222 | 184 | 170 |
| 168 | 186 | 154 | 167 |
| 167 | 187 | 151 | 168 |
| 167 | 186 | 151 | 168 |
| 167 | 185 | 153 | 168 |
1,000 | 1185 | 1585 | 1299 | 485 |
| 347 | 547 | 431 | 344 |
| 343 | 463 | 350 | 345 |
| 338 | 462 | 350 | 345 |
| 3377 | 461 | 349 | 345 |
| 339 | 465 | 351 | 343 |