A memory dump can preserve the state of a running .NET application at the moment a memory problem occurs.

Creating the dump is only the first step. The more important task is understanding what the dump tells you.

When a production application uses more memory than expected, developers often start by looking at the total process memory. That number can confirm that something is wrong, but it does not explain why the memory is being used.

A memory dump can help answer more useful questions:

  • Which object types are using the most memory?

  • Are large numbers of objects being retained?

  • Which objects are preventing garbage collection?

  • Is the managed heap responsible for the memory usage?

  • Could native memory be contributing to the problem?

  • Is the application holding data longer than expected?

This article walks through a practical approach to reading a C# memory dump and using it to investigate a production memory problem.

What a Memory Dump Can Tell You

A memory dump is a snapshot of a process.

For a .NET application, it can contain information about the managed runtime, threads, objects, stacks, loaded assemblies, and memory.

A simplified investigation looks like this:

Production Application
        |
        v
Abnormal Memory Usage
        |
        v
Memory Dump
        |
        v
Managed Heap Analysis
        |
        v
Object Retention Analysis
        |
        v
Root Cause

The important point is that a dump shows the state of the application at one moment.

It does not automatically tell you what happened before that moment.

That is why application metrics and multiple dumps can be valuable during a longer investigation.

Start With the Application Context

Before opening the dump, record the situation in which it was collected.

For example:

Application: OrderService
Environment: Production
Runtime: .NET
Process ID: 12345
Memory: 7.5 GB
Normal Memory: 2.5 GB
Dump Time: 14:30
Application Version: 5.4.2

Also record what users were experiencing.

For example:

- API response times increased
- Process memory kept growing
- Garbage collection became more frequent
- Container memory approached its limit

This context helps you avoid analyzing the dump without understanding why it was collected.

Open the Dump With dotnet-dump

The .NET diagnostics tools provide dotnet-dump for collecting and analyzing .NET process dumps.

A dump can be opened with:

dotnet-dump analyze /path/to/application.dmp

After opening the dump, you can use diagnostic commands to inspect the process.

For example:

> help

can be used to view available commands.

The exact commands available can depend on the runtime and diagnostic environment.

First Check the Managed Heap

One of the first useful commands is:

dumpheap -stat

This provides a summary of managed object types.

A simplified result might look like:

Count        Total Size       Type
------------------------------------------------
450000       180 MB           System.String
210000       165 MB           MyApp.Order
125000       140 MB           MyApp.Customer
50000        120 MB           System.Byte[]
18000         90 MB           MyApp.CacheEntry

This immediately gives you useful information.

You can ask:

Which types have the largest total size?

and:

Which types have unexpectedly high counts?

These questions are often more useful than simply looking at the total process memory.

Understand Count and Total Size

Consider these two examples.

Example 1 - Many Small Objects

Count: 2,000,000
Total Size: 100 MB

The object count is very high, but the total memory usage is relatively small.

Example 2 - Fewer Large Objects

Count: 10,000
Total Size: 2 GB

The count is much lower, but the memory impact is much larger.

This is why both values matter.

A good first pass should look at:

Object count
+
Total size
+
Object type

Find Specific Object Types

Suppose the heap statistics show:

MyApp.Session
Count: 450000
Total Size: 1.8 GB

That is worth investigating.

You can inspect instances of a specific type using:

dumpheap -type MyApp.Session

This can help identify the objects currently present in the managed heap.

The next question is not simply whether the objects exist.

The important question is:

Why are these objects still alive?

Understanding Object Retention

The garbage collector removes objects that are no longer reachable.

Consider:

public class CustomerService
{
    private readonly List<Customer> _customers = new();

    public void Add(Customer customer)
    {
        _customers.Add(customer);
    }
}

If CustomerService remains alive for the lifetime of the application and the list continues growing, the Customer objects remain reachable.

The garbage collector cannot remove them simply because the application is not actively using them.

Conceptually:

Long-lived service
       |
       v
List<Customer>
       |
       +-- Customer
       +-- Customer
       +-- Customer
       +-- Customer

If that list keeps growing, memory usage can also keep growing.

This is a common pattern to investigate when a dump shows a large number of long-lived objects.

Find What Is Keeping an Object Alive

A useful diagnostic command for examining an individual object is:

gcroot <object-address>

For example:

gcroot 000001F4A1234567

The result can show a reference path back to a garbage collection root.

Conceptually, the result might look like:

GC Root
   |
   v
Singleton Service
   |
   v
Cache
   |
   v
List<Customer>
   |
   v
Customer

This tells you why the object remains reachable.

The root path is often where the investigation becomes much more interesting.

What Is a GC Root?

A garbage collection root is a reference from a location that can keep an object alive.

Examples can include:

  • Static fields

  • Active thread references

  • Local variables on active stacks

  • Runtime handles

  • Long-lived objects

The garbage collector starts from these roots and determines which objects are still reachable.

A simplified model is:

GC Root
   |
   v
Object A
   |
   v
Object B
   |
   v
Object C

As long as the root can reach the object graph, those objects may remain alive.

Static Collections Can Cause Memory Growth

One common pattern is a static collection.

For example:

public static class RequestHistory
{
    private static readonly List<string> Requests = new();

    public static void Add(string request)
    {
        Requests.Add(request);
    }
}

If nothing removes old entries, the collection can grow for the lifetime of the process.

A dump may show:

RequestHistory
      |
      v
List<string>
      |
      +-- String
      +-- String
      +-- String
      +-- ...

The strings are not necessarily a problem individually.

The problem is the long-lived reference that prevents them from being collected.

Caches Need Special Attention

Caching is another area worth investigating.

A cache is intentionally designed to keep objects in memory.

For example:

private readonly MemoryCache _cache = new(new MemoryCacheOptions());

public void Add(string key, Customer customer)
{
    _cache.Set(key, customer);
}

Caching can improve performance, but an incorrectly configured cache can consume more memory than expected.

Check:

  • Cache size

  • Expiration policy

  • Entry lifetime

  • Number of entries

  • Object size

  • Eviction behavior

A large cache is not automatically a memory leak.

The important question is whether the memory usage matches the intended cache behavior.

Strings Can Consume Significant Memory

Strings frequently appear near the top of heap statistics.

For example:

System.String
Count: 1,200,000
Total Size: 650 MB

This can happen in applications that process:

  • Large JSON documents

  • Logs

  • HTML

  • XML

  • Database results

  • User-generated content

Do not immediately conclude that strings are the root cause.

Instead, investigate what is creating and retaining them.

For example:

var response = await httpClient.GetStringAsync(request);

If a service retrieves very large responses and holds them in memory, the resulting strings can contribute to memory pressure.

Byte Arrays Are Also Important

Another type worth checking is:

System.Byte[]

Large byte arrays can appear in:

  • File processing

  • Image processing

  • Network operations

  • Serialization

  • Compression

  • Encryption

  • Message processing

For example:

var file = await File.ReadAllBytesAsync(path);

This loads the complete file into memory.

If several large files are processed concurrently, memory usage can increase significantly.

A streaming approach may be more appropriate when the application does not need the entire file in memory.

The Large Object Heap

.NET has a Large Object Heap, commonly called the LOH.

Large allocations can be placed there instead of the normal small object heap.

Objects on the LOH deserve attention when an application processes large arrays, strings, buffers, or similar objects.

For example:

byte[] buffer = new byte[10_000_000];

Repeated large allocations can contribute to memory pressure.

When investigating large objects, determine:

What is being allocated?
Why is it large?
How frequently is it created?
How long does it remain reachable?

These questions are more useful than simply identifying that the LOH contains large objects.

Look at Threads When Necessary

Memory problems are not always caused by managed objects.

Threads can also help explain application behavior.

You can inspect thread information using:

threads

A dump may show multiple threads with different states.

For example:

ID     State
----------------
1      Running
2      Wait
3      Wait
4      Running
5      Wait

If the application is experiencing both memory and performance problems, thread information can provide additional context.

Managed Memory vs Process Memory

One of the most important concepts in memory troubleshooting is that:

Managed heap size is not always equal to process memory.

A .NET process can use memory outside the managed heap.

Conceptually:

Process Memory
|
+-- Managed Heap
|
+-- Native Memory
|
+-- Runtime Components
|
+-- Loaded Libraries
|
+-- Thread Stacks
|
+-- Other Allocations

Therefore, this situation is possible:

Process Memory: 8 GB
Managed Heap:   3 GB

The remaining memory needs further investigation.

Do not assume that the missing 5 GB represents a garbage collection problem.

Native Memory Can Change the Investigation

Applications can use native memory through:

  • Native libraries

  • Interoperability code

  • Operating system resources

  • Native buffers

  • Database drivers

  • Image libraries

  • Other unmanaged components

If the managed heap does not explain the process memory usage, investigate outside the managed heap.

This distinction prevents developers from spending hours looking for a managed memory leak that does not exist.

Compare the Dump With Application Metrics

A dump becomes more useful when you know what the application was doing when it was collected.

For example:

Time       Process Memory    Requests/sec
09:00      2.4 GB           120
10:00      3.1 GB           150
11:00      4.8 GB           175
12:00      7.2 GB           180

Now suppose a dump was collected at 12:00.

The investigation can connect:

Increasing traffic
        +
Increasing memory
        +
Heap statistics
        +
Object retention

This provides much stronger evidence than the dump alone.

Compare Multiple Dumps

If the application remains available, collecting multiple dumps can help identify growth.

For example:

Dump 1 - 3 GB process memory
        |
        v
Dump 2 - 5 GB process memory
        |
        v
Dump 3 - 7 GB process memory

Suppose the heap statistics show:

MyApp.Session

Dump 1: 100,000 objects
Dump 2: 220,000 objects
Dump 3: 480,000 objects

That pattern is more interesting than seeing 480,000 objects in a single snapshot.

It suggests that the population is increasing and should be investigated further.

A Practical Investigation Workflow

A production memory investigation can follow these steps.

Step 1 - Confirm the Problem

Check application and infrastructure metrics.

Process memory
GC heap
CPU
Request rate
Error rate

Step 2 - Collect the Dump

Capture the application state before restarting the process when possible.

Step 3 - Open the Dump

dotnet-dump analyze application.dmp

Step 4 - Inspect Heap Statistics

dumpheap -stat

Look for unusually large object types.

Step 5 - Investigate Suspicious Types

dumpheap -type MyApp.Session

Step 6 - Find Retention Paths

Select a relevant object and inspect its roots:

gcroot <object-address>

Step 7 - Check the Application Code

Search for:

  • Static collections

  • Unbounded caches

  • Long-lived services

  • Event subscriptions

  • Large buffers

  • Background queues

  • Large request or response objects

Step 8 - Compare With Metrics

Confirm whether the dump findings match the application's runtime behavior.

Step 9 - Collect Another Dump if Needed

A second snapshot can help determine whether an object population is growing.

Step 10 - Fix and Validate

After applying the fix, monitor memory behavior over time.

Example Investigation

Suppose the dump contains:

System.Byte[]       1.2 GB
System.String       800 MB
MyApp.RequestData   650 MB
MyApp.CacheEntry    500 MB

The initial conclusion should not be:

Byte arrays are the problem.

Instead, investigate the relationships.

You might discover:

CacheEntry
    |
    v
RequestData
    |
    v
Large byte[]

Now the investigation has a stronger direction.

The application may be caching large request payloads.

The fix could involve:

Smaller cached values
+
Expiration
+
Size limits
+
Streaming

The correct solution depends on the application's actual requirements.

Common Mistakes

Looking Only at Total Process Memory

Process memory tells you that memory is being used, not necessarily why.

Assuming the Largest Object Type Is the Root Cause

An object type may be large because another component is retaining it.

Ignoring GC Roots

Finding a large object is only the beginning.

The retention path often provides the more important information.

Assuming Every Cache Is a Leak

Caches intentionally retain objects.

Investigate whether their size and lifetime are expected.

Looking Only at Managed Memory

Native memory can account for a significant portion of process memory.

Collecting Only One Dump

A single snapshot cannot show how an object population changes over time.

Ignoring Application Metrics

A dump without runtime context can make diagnosis harder.

Best Practices

Collect Evidence Before Restarting

If the process is still available and the situation permits it, capture the diagnostic state before restarting.

Use Multiple Data Sources

Combine:

Memory dump
+
Application metrics
+
Logs
+
Runtime information

Focus on Retention

Ask why an object remains reachable instead of simply asking why it exists.

Investigate Unexpected Growth

Look for object populations that continue increasing over time.

Treat Dumps as Sensitive Data

Production dumps may contain data that should not be exposed outside approved diagnostic environments.

Validate the Fix

A memory fix is not complete when the code compiles.

Monitor the application after deployment and verify that the memory behavior has changed as expected.

Advantages

  1. Provides a snapshot of the running application's state.

  2. Helps identify object types consuming managed memory.

  3. Can reveal object retention paths.

  4. Allows investigation without relying only on logs.

  5. Multiple dumps can help identify memory growth.

  6. Can help distinguish managed heap issues from other process memory usage.

Disadvantages and Trade-Offs

  1. A dump can be very large.

  2. Analysis may require substantial system resources.

  3. Sensitive information can be present in the dump.

  4. One dump represents only a single point in time.

  5. Native memory problems may require additional diagnostic tools.

  6. Understanding object retention requires familiarity with the application's architecture.

Production Memory Dump Checklist

[ ] Confirm abnormal memory behavior
[ ] Record application and runtime information
[ ] Record current process memory
[ ] Collect the dump before restarting when possible
[ ] Open the dump in a controlled environment
[ ] Run dumpheap -stat
[ ] Identify suspicious object types
[ ] Inspect relevant instances
[ ] Check GC roots
[ ] Investigate caches and static collections
[ ] Check large arrays and strings
[ ] Consider native memory
[ ] Compare with application metrics
[ ] Compare multiple dumps when necessary
[ ] Apply the fix
[ ] Monitor memory after deployment

Conclusion

Reading a C# memory dump is not simply about finding the object that consumes the most memory.

The real investigation is about understanding how objects are created, how long they remain alive, and what is keeping them reachable.

Commands such as dumpheap -stat, dumpheap -type, and gcroot can provide useful information about the managed heap and object retention. When these findings are combined with application metrics and, when necessary, multiple dumps, developers can build a much clearer picture of a production memory problem.

The practical approach is:

Memory increase
      |
      v
Collect dump
      |
      v
Inspect heap
      |
      v
Find suspicious objects
      |
      v
Trace GC roots
      |
      v
Understand retention
      |
      v
Fix the application
      |
      v
Monitor memory again

A memory dump does not automatically identify the root cause. It provides evidence. The quality of the investigation depends on connecting that evidence with the application's code, architecture, runtime behavior, and production metrics.