为什么Delphi中的字符串需要额外的内存?

10

我正在读取一个1.4百万行、24 MB大小(每行平均17个字符)的大型文本文件。

我正在使用Delphi 2009,该文件是ANSI格式的,但在读取时会转换为Unicode格式,因此可以说一旦转换后的文本大小为48 MB。

(编辑:我找到了一个更简单的例子...)

我将这个文本加载到一个简单的StringList中:

  AllLines := TStringList.Create;
  AllLines.LoadFromFile(Filename);
我发现数据行占用的内存好像比它们的48 MB要多得多。
实际上,它们使用了155 MB的内存。
我不介意Delphi使用48 MB甚至60 MB来允许一些内存管理开销。但是155 MB似乎过多了。
这不是StringList的错误。我之前尝试将这些行加载到记录结构中,结果也是一样的(160 MB)。
我不明白或者说不理解是什么原因导致Delphi或FastMM内存管理器使用了3倍于存储字符串所需内存的量。堆分配不能如此低效,对吧?
我已经进行了调试并进行了尽可能多的研究。任何关于为什么会发生这种情况的想法或可以帮助我减少过度使用的想法都将不胜感激。
注意:我使用这个“较小”的文件作为示例。我真的正在尝试加载一个320 MB的文件,但由于这种过度的字符串需求,Delphi正在请求超过2 GB的RAM并且内存不足。
补充说明:Marco Cantu刚刚发布了关于Delphi和Unicode的白皮书。Delphi 2009将每个字符串的开销从8个字节增加到12个字节(加上实际指向字符串的指针可能还有4个字节)。每17x2 = 34个字节的额外16个字节几乎增加了50%。但我看到超过200%的开销。这多出来的150%是什么?

成功了!感谢大家的建议。你们让我思考了。但是我必须要给予Jan Goyvaerts答案的功劳,因为他问道:

...你为什么要使用TStringList?文件真的需要作为单独的行存储在内存中吗?

这引导我找到了解决方案,即将我的行分组成程序已知的自然组。因此,这导致127,000行加载到字符串列表中。

现在每行平均有190个字符而不是17个。每个StringList行的开销相同,但现在行数更少了。

当我将此应用于320MB文件时,它不再耗尽内存,现在只需要少于1GB的RAM即可加载。(而且只需要大约10秒钟才能加载,这非常好!)

解析分组行将需要一点额外的处理,但在每个组的实时处理中不应该会被注意到。

(如果你想知道,这是一个家谱程序,这可能是我需要的最后一步,允许它在不到30秒的32位地址空间中加载有关一百万人的所有数据。所以我还有20秒的缓冲时间来添加索引到数据中,这些索引将需要允许显示和编辑数据。)


你如何衡量所需的内存?我希望不是通过任务管理器中的“内存使用”列来测量。那并不是你想象中的衡量方式。 - Lars Truijens
对于内存测量,我使用GlobalMemoryStatusEx。请查看:http://msdn.microsoft.com/en-us/library/aa366589(VS.85).aspx - lkessler
你应该检查 Delphi 实际使用了多少内存。Delphi MM 会从操作系统中获取较大的块并进行子分配,在可能的情况下(如碎片等)才将它们释放给操作系统,因此 Windows 所看到的和 Delphi 所做的可能是不同的。如果你使用来自 Sourceforge 的完整 FastMM 库,它具有查询 MM 分配的功能,可以让你更深入地了解正在发生的情况。否则,你可以使用内存分析器(例如 AQTime)来检查它,并查看已分配的内存、何时以及为什么。 - Mad Hatter
8个回答

10

你个人要求我在这里回答你的问题。我不知道为什么会出现如此高的内存使用,但需要记住TStringList不仅仅是加载文件。每个步骤都需要内存,可能导致内存碎片化。TStringList需要将文件加载到内存中,从Ansi转换为Unicode,将其拆分为每行一个字符串,并将这些行插入将多次重新分配的数组中。

我的问题是为什么要使用TStringList?文件是否必须作为单独的行存储在内存中?您是否将修改文件内存中的内容,还是只显示其中的部分内容?将整个文件作为一大块保留在内存中,并使用正则表达式扫描匹配想要的部分,比存储单独的行更节省内存。

此外,整个文件是否必须转换为Unicode?虽然您的应用程序是Unicode,但您的文件是Ansi。我的一般建议是尽早将Ansi输入转换为Unicode,因为这样可以节省CPU周期。但是当您有320 MB的Ansi数据,它将保持为Ansi数据时,内存消耗将是瓶颈。尝试将文件保留为Ansi形式存储在内存中,并仅将要向用户显示的部分转换为Ansi。

如果320 MB文件不是您从中提取特定信息的数据文件,而是要修改的数据集,请考虑将其转换为关系数据库,并让数据库引擎担心如何管理具有有限内存的大型数据集。


谢谢Jan提供的想法,这让我有更多的思考。你提出的“块”建议让我想尝试加载一组字符串,每组平均约150个字符,而不是每行17个字符。家谱软件应该支持Unicode。 - lkessler
1
当然,你的软件应该是Unicode的。但这并不意味着当源不是Unicode时,你需要在内存中保存320MB的Unicode数据。 - Jan Goyvaerts

8
如果您的原始记录使用AnsiString,会怎样呢?这会立即将其减半吗?仅因为Delphi默认使用UnicodeString并不意味着您必须使用它。
此外,如果您确切地知道每个字符串的长度(在一个或两个字符内),那么使用短字符串可能更好,可以再节省几个字节。
我很好奇是否有更好的方法来完成您要尝试的操作。将320 MB的文本加载到内存中可能不是最佳解决方案,即使您可以将其缩减到只需要320 MB。

好的答案,我会考虑一下。我的程序是为Unicode设计的,所以如果要处理非常大的文件时不得不回到ANSI编码,那将是一种遗憾。也许我可以尝试文件内存映射。虽然我不指望这样做能够满足我的需求,但在尝试之前你永远不会知道。 - lkessler

6
我正在使用Delphi 2009,该文件是ANSI编码格式,但在读取时会被转换为Unicode编码格式,因此可以说这段文本一旦转换后的大小为48 MB。如果您需要程序支持Unicode编码,那么“ANSI”格式的文件(它必须有某个字符集,如WIN1252或ISO8859_1)不是正确的选择。我建议您首先将其转换为UTF8编码格式。如果该文件不包含任何大于等于128的字符,则它不会发生变化(甚至大小也将保持不变),但您已经做好了未来的准备。
现在您可以将其加载到UTF8字符串中,这样不会使内存消耗翻倍。对于屏幕上可见的少量字符串进行即时转换到Delphi Unicode字符串的效率可能会更慢,但由于更小的内存占用,您的程序在内存较少(空闲)的系统上的性能会更好。
如果您的程序仍然使用TStringList占用太多内存,您始终可以在程序中使用TStrings甚至IStrings,并编写一个实现IStrings或继承TStrings并不保留所有行的类。以下是一些想法:
1. 将文件读入TMemoryStream,并维护一个指向每行第一个字符的指针数组。然后只需返回正确的字符串,在行的起始和下一行的起始之间,CR和NL被剥离。
2. 如果这仍然占用太多内存,则将TMemoryStream替换为TFileStream,并不维护字符指针数组,而是维护行起始处的文件偏移量的数组。
3. 您还可以使用Windows API函数进行内存映射文件。这使您可以使用内存地址而不是文件偏移量工作,但不会像第一种方法那样消耗太多内存。

你的三个想法都很好。但在Delphi 2009中,将其转换为UTF8是低效且错误的。我要么必须将其保留为ANSI并在需要时转换为Unicode,要么吸收额外的24 MB(我愿意这样做)并将其转换为Unicode以供程序使用。 - lkessler
抱歉,我不同意。UTF8是数据存储和交换的正确格式,而且由于I/O比CPU处理要慢得多,因此它不仅可以给您更小的磁盘文件,还可以提供更好的性能。无论内部字符串格式如何,我都会始终使用UTF8作为数据文件的格式。 - mghie
1
数据文件的价值通常比程序代码高得多,因此针对特定编程环境进行优化是错误的。它们的格式必须具有表现力和效率,最好是标准化的。UTF8提供了所有这些,并且在Windows之外最常见。有什么不喜欢的呢? - mghie
UTF-8是正确的格式,仅当数据最好以UTF-8存储时。如果你要存储中文数据,使用UTF-16存储和传输它们比使用UTF-8会导致更差的存储和I/O。如果大部分文本是英文或拉丁语言,那么UTF-8就可以了。而Cyrillic、Greek、Hebrew和Arabic在UTF-8中需要两个字节,因此与UTF-16相比没有任何优势。这让我想起有人说ASCII7是适用于每种文本的正确存储方式,而事实并非如此...世界比西欧大得多。 - Mad Hatter

4

默认情况下,Delphi 2009的TStringList将以ANSI格式读取文件,除非有一个字节顺序标记来识别文件为其他格式,或者如果您向LoadFromFile提供可选的第二个参数作为编码方式。

因此,如果您发现TStringList占用了比您预期更多的内存,则可能是其他原因造成的。


谢谢,尼克。嗯...无法想象还会发生什么。我的例子非常简单。 - lkessler

3

你是否正在使用从SourceForge获取的FastMM源代码并定义了FullDebugMode来编译程序?如果是这种情况,FastMM实际上没有释放未使用的内存块,这可能解释了问题。


好想法,但不行。 我正在Delphi 2009中使用FastMM。 我改变的唯一选项是编译器选项,将String Format Checking关闭,这是几个博客推荐的。 - lkessler

1

你是否依赖Windows告诉你程序使用了多少内存?这在Delphi应用程序中被公认为会夸大内存使用量。

不过,我确实看到你的代码中有很多额外的内存使用。

你的记录结构是20字节——如果每行有一个这样的记录,那么你查看的数据比文本还要多。

此外,字符串具有固有的4字节开销——另外25%。

我相信Delphi的堆处理中存在一定数量的分配粒度,但我不记得目前是多少。即使是8字节(两个指向自由块链表的指针),你也要再增加25%。

请注意,我们已经增加了超过150%。


UnicodeString 的开销包括四个字节的长度,四个字节的引用计数和两个字节结尾处的空字符。 - Rob Kennedy
在我之前关于记录的例子中,我特别说明了我正在比较加载记录并将字符串分配给加载记录而不分配字符串的情况。因此,差异是由字符串本身而不是记录中的20个字节造成的。 - lkessler

1

其中一部分可能是块分配算法。随着列表的增长,它开始增加每个块分配的内存量。我很久没有看过它了,但我相信它大致上是在每次耗尽内存时将上次分配的数量翻倍。当你开始处理如此大的列表时,你的分配也比你最终需要的要大得多。

编辑- 正如lkessler指出的那样,这种增加实际上只有25%,但它仍然应该被视为问题的一部分。如果你刚好超过临界点,可能会有一个巨大的内存块分配给列表,而这个内存块并没有被使用。


那是一个好建议,但TStringList.Grow每次只增加25%的大小。因此,由此产生的开销最多为25%。 - lkessler

0
为什么要将那么多数据加载到TStringList中?列表本身会有一些开销。也许TTextReader可以帮助你。

TTextReader仅帮助解析输入。我已经非常高效地完成了这项工作。然后我必须把解析出来的行放在某个地方。我最初尝试使用记录,但发现了内存使用问题。然后我在TStringList中发现了同样的问题,并将其留在问题上作为一个更简单的例子。 - lkessler

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接