Search code examples
c#.netmemory-management

Understanding heap memory allocated by string empty checks


While profiling a project I noticed we have an extension method for checking empty IEnumerable<T> and it was accidentally being used on a string, rather than a string-specific extension method. I wrote a small program to determine if the IEnumerable<T> version might be allocating and it seems to be, however I ran into unexpected results when I found that basically any method I wrote to check if a string was null or empty was allocating. Only using string.IsNullOrEmpty(str) could prevent allocations. Does anyone know why all of my methods are allocating memory to the heap? Is it simply because of the use of a function? If I avoid calling a function it doesn't allocate memory.

Here's the program demonstrating all of this:

using System.Collections.Generic;
using System.Linq;
    
public class Program
{
    public static void Main()
    {
        string myStr = "Blah";
        
        long startMemory = GC.GetTotalMemory(true);
            bool empty = IEnumerableExtensions.IsNullOrEmpty(myStr);
        long memoryUsed = GC.GetTotalMemory(false) - startMemory;   
        
        // memory used: 8192
        Console.WriteLine($"IEnumerableExtensions.IsNullOrEmpty, memory used: {memoryUsed}");
            
        startMemory = GC.GetTotalMemory(true);
            empty = StringExtensions.IsNullOrEmpty(myStr);
        memoryUsed = GC.GetTotalMemory(false) - startMemory;
        
        // memory used: 4040
        Console.WriteLine($"StringExtensions.IsNullOrEmpty, memory used: {memoryUsed}");
        
        startMemory = GC.GetTotalMemory(true);
            empty = string.IsNullOrEmpty(myStr);
        memoryUsed = GC.GetTotalMemory(false) - startMemory;
        
        // memory used: 0
        Console.WriteLine($"string.IsNullOrEmpty, memory used: {memoryUsed}");
        
        startMemory = GC.GetTotalMemory(true);
            empty = IsNullOrEmpty(myStr);
        memoryUsed = GC.GetTotalMemory(false) - startMemory;
        
        // memory used: 4040
        Console.WriteLine($"IsNullOrEmpty, memory used: {memoryUsed}");

        startMemory = GC.GetTotalMemory(true);
            empty = (myStr == null || myStr.Length == 0);
        memoryUsed = GC.GetTotalMemory(false) - startMemory;
        
        // memory used: 0
        Console.WriteLine($"Direct (no function call), memory used: {memoryUsed}");
    }
    
    // Non-extension method
    private static bool IsNullOrEmpty(string str)
    {
        return str == null || str.Length == 0;
    }
}

public static class IEnumerableExtensions
{
    public static bool IsNullOrEmpty<T>(this IEnumerable<T> enumerable)
    {
        if (enumerable != null)
        {
            return !enumerable.Any();
        }

        return true;
    }
}
    
public static class StringExtensions
{
    public static bool IsNullOrEmpty(this string str)
    {
        return str == null || str.Length == 0;
    }
}

If you want to see the program yourself, here's a DotNetFiddle version of it you can run: https://dotnetfiddle.net/2Zf0co


Solution

  • GC.GetTotalMemory is not a very good method to use here because it counts native allocation too, for example when some function is called first time JIT will compile it into native code so app memory usage will increase (via native allocations). Move your code into some method and call it several times and you will see the difference.

    Alternatively switch to GC.GetTotalAllocatedBytes(true) instead of GetTotalMemory.

    For example for me second invocation results in:

    IEnumerableExtensions.IsNullOrEmpty, memory used: 16384
    string.IsNullOrEmpty, memory used: 0
    StringExtensions.IsNullOrEmpty, memory used: 0
    IsNullOrEmpty, memory used: 0
    

    Demo

    As for allocations for the allocations for the IEnumerableExtensions - check out the IEnumerable<char> implementation which is done via CharEnumerator which is a class so it at least partially explains the behaviour.

    P.S.

    In general (as noted in comments) it is not a fully reliable way to assess the allocated memory. Also you can look into using BenchmarkDotNet with MemoryDiagnoser.