【问题标题】:Why does C# memory stream reserve so much memory?为什么C#内存流要预留这么多内存?
【发布时间】:2014-08-29 10:48:49
【问题描述】:

我们的软件正在通过GZipStream 解压缩某些字节数据,它从MemoryStream 读取数据。这些数据以 4KB 的块解压并写入另一个MemoryStream

我们已经意识到进程分配的内存远高于实际解压后的数据。

示例: 具有 2,425,536 字节的压缩字节数组被解压缩为 23,050,718 字节。我们使用的内存分析器显示方法 MemoryStream.set_Capacity(Int32 value) 分配了 67,104,936 字节。这是保留内存和实际写入内存之间的 2.9 倍。

注意:MemoryStream.set_Capacity 是从 MemoryStream.EnsureCapacity 调用的,而 MemoryStream.EnsureCapacity 在我们的函数中是从 MemoryStream.Write 调用的。

为什么MemoryStream 保留这么多容量,即使它只附加 4KB 的块?

这里是解压数据的代码sn-p:

private byte[] Decompress(byte[] data)
{
    using (MemoryStream compressedStream = new MemoryStream(data))
    using (GZipStream zipStream = new GZipStream(compressedStream, CompressionMode.Decompress))
    using (MemoryStream resultStream = new MemoryStream())
    {
        byte[] buffer = new byte[4096];
        int iCount = 0;

        while ((iCount = zipStream.Read(buffer, 0, buffer.Length)) > 0)
        {
            resultStream.Write(buffer, 0, iCount);
        }
        return resultStream.ToArray();
    }
}

注意:如果相关,这是系统配置:

  • Windows XP 32 位,
  • .NET 3.5
  • 使用 Visual Studio 2008 编译

【问题讨论】:

  • 如果您可以将压缩率与压缩流一起存储,您可以对最终大小进行最佳猜测+误差余量并将其分配一次为byte[],并完全避免使用MemoryStream,然后修剪阵列或减少最后浪费的空间。
  • 我会提醒使用MemoryStream 进行任何解压。 .net 中的 GC 将大对象堆用于任何大于 85kB 的对象,例如 byte[]。这会迅速分散你的记忆,实际上会导致你目前面临的更大问题。
  • @AdamHouldsworth @Aron 感谢您的提示,数据是使用 gzip 压缩的,因此在最后四个字节中包含原始数据的大小。我提取了这个大小,并简单地分配了一个 byte[] 与所需的确切大小。我不再孤独地写信给MemoryStream

标签: c# memory memory-management memorystream gzipstream


【解决方案1】:

因为this is the algorithm 是如何扩展其容量的。

public override void Write(byte[] buffer, int offset, int count) {

    //... Removed Error checking for example

    int i = _position + count;
    // Check for overflow
    if (i < 0)
        throw new IOException(Environment.GetResourceString("IO.IO_StreamTooLong"));

    if (i > _length) {
        bool mustZero = _position > _length;
        if (i > _capacity) {
            bool allocatedNewArray = EnsureCapacity(i);
            if (allocatedNewArray)
                mustZero = false;
        }
        if (mustZero)
            Array.Clear(_buffer, _length, i - _length);
        _length = i;
    }

    //... 
}

private bool EnsureCapacity(int value) {
    // Check for overflow
    if (value < 0)
        throw new IOException(Environment.GetResourceString("IO.IO_StreamTooLong"));
    if (value > _capacity) {
        int newCapacity = value;
        if (newCapacity < 256)
            newCapacity = 256;
        if (newCapacity < _capacity * 2)
            newCapacity = _capacity * 2;
        Capacity = newCapacity;
        return true;
    }
    return false;
}

public virtual int Capacity 
{
    //...

    set {
         //...

        // MemoryStream has this invariant: _origin > 0 => !expandable (see ctors)
        if (_expandable && value != _capacity) {
            if (value > 0) {
                byte[] newBuffer = new byte[value];
                if (_length > 0) Buffer.InternalBlockCopy(_buffer, 0, newBuffer, 0, _length);
                _buffer = newBuffer;
            }
            else {
                _buffer = null;
            }
            _capacity = value;
        }
    }
}

因此,每次您达到容量限制时,容量都会增加一倍。这样做的原因是 Buffer.InternalBlockCopy 操作对于大型数组来说很慢,所以如果它必须频繁调整每个 Write 调用的大小,性能会显着下降。

您可以做几件事来提高性能您正在使用的内存。

const double ResizeFactor = 1.25;

private byte[] Decompress(byte[] data)
{
    using (MemoryStream compressedStream = new MemoryStream(data))
    using (GZipStream zipStream = new GZipStream(compressedStream, CompressionMode.Decompress))
    using (MemoryStream resultStream = new MemoryStream(data.Length * ResizeFactor)) //Set the initial size to be the same as the compressed size + 25%.
    {
        byte[] buffer = new byte[4096];
        int iCount = 0;

        while ((iCount = zipStream.Read(buffer, 0, buffer.Length)) > 0)
        {
            if(resultStream.Capacity < resultStream.Length + iCount)
               resultStream.Capacity = resultStream.Capacity * ResizeFactor; //Resize to 125% instead of 200%

            resultStream.Write(buffer, 0, iCount);
        }
        return resultStream.ToArray();
    }
}

如果你愿意,你可以做更多花哨的算法,比如根据当前压缩比调整大小

const double MinResizeFactor = 1.05;

private byte[] Decompress(byte[] data)
{
    using (MemoryStream compressedStream = new MemoryStream(data))
    using (GZipStream zipStream = new GZipStream(compressedStream, CompressionMode.Decompress))
    using (MemoryStream resultStream = new MemoryStream(data.Length * MinResizeFactor)) //Set the initial size to be the same as the compressed size + the minimum resize factor.
    {
        byte[] buffer = new byte[4096];
        int iCount = 0;

        while ((iCount = zipStream.Read(buffer, 0, buffer.Length)) > 0)
        {
            if(resultStream.Capacity < resultStream.Length + iCount)
            {
               double sizeRatio = ((double)resultStream.Position + iCount) / (compressedStream.Position + 1); //The +1 is to prevent divide by 0 errors, it may not be necessary in practice.

               //Resize to minimum resize factor of the current capacity or the 
               // compressed stream length times the compression ratio + min resize 
               // factor, whichever is larger.
               resultStream.Capacity =  Math.Max(resultStream.Capacity * MinResizeFactor, 
                                                 (sizeRatio + (MinResizeFactor - 1)) * compressedStream.Length);
             }

            resultStream.Write(buffer, 0, iCount);
        }
        return resultStream.ToArray();
    }
}

【讨论】:

  • 接受了这个答案,因为它既提供了对我的问题的详细答案,也提供了有关如何节省内存的其他建议。我最终完全删除了MemoryStream resultStream,方法是从 GZip 数据的最后四个字节(源自 GZip 文件)中提取未压缩的数据大小并创建一个具有该精确大小的字节数组作为目标,但我会这样做如果另一种方法不起作用,则另一种方法。
  • 值得注意的是,任何小于黄金比例的调整因子都会导致系统最终有足够的剩余内存来分配先前块使用的内存中的新块(假设旧块在连续内存),而任何大于黄金比例的调整大小因子将确保调整大小操作永远能够重用之前为缓冲区分配的内存。 (假设系统的其他部分没有使用内存)。见this blog post
【解决方案2】:

看起来您正在查看分配的内存总量,而不是最后一次调用。由于内存流在重新分配时会增加一倍的大小,它每次会增长大约两倍 - 所以总分配的内存大约是 2 的幂的总和,例如:

总和 i=1 k (2i) = 2k+1 -1。

(其中k 是重新分配的数量,例如 k = 1 + log2 StreamSize

这与你所看到的有关。

【讨论】:

  • 感谢数学。请添加k 的确切定义;-)
  • @TimMeyer - 添加(和固定总和...)
【解决方案3】:

嗯,增加流的容量意味着用新容量创建一个全新的数组,然后复制旧数组。这是非常昂贵的,如果你对每个Write 都这样做,你的表现会受到很大影响。因此,MemoryStream 的扩展超出了必要的范围。如果您想改善这种行为并且您知道所需的总容量,只需使用带有capacity 参数的MemoryStream 构造函数:) 然后您也可以使用MemoryStream.GetBuffer 而不是ToArray

您还会在内存分析器中看到丢弃的旧缓冲区(例如,从 8 MiB 到 16 MiB 等)。

当然,您并不关心拥有一个连续的数组,因此您最好拥有一个自己的内存流,该内存流使用根据需要创建的多个数组,并根据需要分成大块,然后一次将其全部复制到输出byte[](如果您甚至需要byte[] - 很可能,这是一个设计问题)。

【讨论】:

    【解决方案4】:

    MemoryStream 在空间不足时将其内部缓冲区翻倍。这会导致 2 倍的浪费。我不知道为什么你看到的不止这些。但这种基本行为是意料之中的。

    如果您不喜欢这种行为,请编写自己的流,将其数据存储在更小的块中(例如 List&lt;byte[1024 * 64]&gt;)。这样的算法会将其浪费量限制在 64KB。

    【讨论】:

    • +1:使用自定义分块流非常好。请注意,对于足够大的操作,即使使用临时文件流也可能比使用 MemoryStream 更快。
    • 它加倍分配的原因是分配通常很慢,因为您需要分配内存,然后将旧内存复制到新内存。实现它的开发人员一定认为速度比有效使用内存更重要,我同意这一点,因为通常MemoryStream 的寿命很短,所以内存在分配后不久就会被回收。
    • @Matthew 使用一系列缓冲区 bean,根本不需要复制。可能更快。
    • @usr 查看 List 的实现,它具有相同的 capacity problem 但是您现在将复制 byte[1024 * 64] 的数组而不是字节本身,因此复制操作将更加罕见,并且更小。您可以通过使用LinkedList&lt;byte[1024 * 64]&gt; 来权衡较慢的搜索时间来消除复制成本。
    猜你喜欢
    • 2017-09-14
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2016-04-16
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2012-11-08
    相关资源
    最近更新 更多