Flyweight Pattern: Saving Memory with Shared State — string.Intern and ArrayPool
Flyweight stores the repeating, immutable part of many objects (intrinsic state) once through a factory and shares it, keeping the line-specific part (extrinsic state) outside. A BookInfoFactory that cuts a million-line report from 377 MB to 30 MB, the side effects of string.Intern, the Task and array instances .NET already shares, why ArrayPool is an object pool rather than a flyweight, and BenchmarkDotNet measurements.
Version note: The code in this post was built and run with C# 14 and .NET 10.
Guess First
Bookstore's accounting team wants a sales report at year end: every order line of the past year, together with the book information, grouped and summed in memory. The exported file has a million lines; each line has an ISBN (International Standard Book Number), title, author, publisher, category, quantity and unit price. There are only 500 different books in the catalog, so the same book information repeats about two thousand times on average. The code reading the file is entirely ordinary:
using System.Globalization;
namespace Bookstore.Reports;
// Naive row: every row carries its own copy of all the book's information
public sealed record OrderLineRow(string Isbn, string Title, string Author, string Publisher, string Category,
int Quantity, decimal UnitPrice)
{
public static OrderLineRow Parse(string line)
{
string[] parts = line.Split(';');
return new(parts[0], parts[1], parts[2], parts[3], parts[4],
int.Parse(parts[5]), decimal.Parse(parts[6], CultureInfo.InvariantCulture));
}
}
namespace Bookstore.Reports;
public static class ExportFile
{
// Simulates a year's order export made up of 500 different books
public static IEnumerable<string> Lines(int count)
{
string[] publishers = ["Ace Books", "Penguin", "Vintage", "Tor Books", "Orbit"];
string[] categories = ["Science Fiction", "Fantasy", "Classics", "History", "Programming"];
for (int i = 0; i < count; i++)
{
int book = i % 500;
yield return $"978-{book:D10};Title {book};Author {book % 120};{publishers[book % 5]};{categories[book % 5]};{1 + i % 3};{10 + book % 30}.99";
}
}
}
When a million OrderLineRows sit in a List, how much extra memory do you think the process holds: 30 MB, 100 MB, 400 MB? And how much of that memory is actually distinct information?
Two Thousand Copies of the Same Book
We'll see the full measurement shortly; here is the naive row's result:
naive : retained 377 MB, allocated 579 MB
Even after the work finishes and the garbage collector does a full collection, roughly 370 MB stays alive (it varied between 363 and 377 MB across a few runs); about 380 bytes per line. Only a small part of that is line-specific information: the quantity is an int, the price a decimal. The rest is each row's own object header and five separate strings: Split produces "Science Fiction", "Ace Books" and the book's title again on every line. Memory holds two hundred thousand copies of the same "Science Fiction" text, and each of them is a separate burden on the garbage collector.
This isn't an algorithm problem; it's a sharing problem. The information already repeats; storing the repeating part once and pointing to it from every line is enough. GoF's name for this idea is Flyweight.
The GoF Definition: Intrinsic and Extrinsic State
Flyweight is one of the structural patterns in the GoF (Gang of Four: Erich Gamma, Richard Helm, Ralph Johnson and John Vlissides' 1994 book Design Patterns: Elements of Reusable Object-Oriented Software) catalog. In my own words, its intent is to support large numbers of small objects efficiently by using sharing. The name comes from one of boxing's lightest weight divisions: individually very light objects.
The book's example is a text editor. Modeling every character in a document as its own object is very clean from a design point of view (each character can be drawn and can be part of a row or a column), but in a document with hundreds of thousands of characters it is impossible memory-wise. The solution is to split the state in two. Intrinsic state is the part stored in the object itself and independent of context: the character code. This part can be shared; the editor has a single object for the letter "a". Extrinsic state is the part that depends on context and can't be shared: the character's position in the document, its current font. The flyweight doesn't store it; the client computes or holds it and passes it to the flyweight as a parameter when needed.
The roles from the GoF "Participants" section: Flyweight (the interface of the shared object), ConcreteFlyweight (the shared class carrying the intrinsic state), FlyweightFactory (the place that creates flyweights and always hands out the same instance for the same key, guaranteeing the sharing) and Client (the side holding the extrinsic state). The critical rule GoF states: the client doesn't create a flyweight directly with new; it asks the factory. Otherwise sharing is left to chance.
Translated to Bookstore, the mapping is natural: the book's ISBN, title, author, publisher and category are intrinsic; there are 500 different values in a million lines, and none of them varies per line. The quantity and the unit price in that order are extrinsic; each line owns them.
BookInfoFactory: Shared Book Information
using System.Globalization;
namespace Bookstore.Reports;
// Intrinsic state: the same on every line, shared, immutable
public sealed record BookInfo(string Isbn, string Title, string Author, string Publisher, string Category);
// Extrinsic state: specific to the line; the book information is just a reference
public readonly record struct OrderLine(BookInfo Book, int Quantity, decimal UnitPrice);
// Flyweight factory: always hands out the same BookInfo instance for the same ISBN
public sealed class BookInfoFactory
{
private readonly Dictionary<string, BookInfo> _books = [];
private readonly Dictionary<string, BookInfo>.AlternateLookup<ReadOnlySpan<char>> _bySpan;
public BookInfoFactory() => _bySpan = _books.GetAlternateLookup<ReadOnlySpan<char>>();
public int Count => _books.Count;
public OrderLine Parse(string line)
{
ReadOnlySpan<char> span = line;
Span<Range> parts = stackalloc Range[8];
span.Split(parts, ';');
// Look up an existing book by span without creating any string
if (!_bySpan.TryGetValue(span[parts[0]], out BookInfo? book))
{
book = new BookInfo(span[parts[0]].ToString(), span[parts[1]].ToString(), span[parts[2]].ToString(),
span[parts[3]].ToString(), span[parts[4]].ToString());
_books.Add(book.Isbn, book);
}
return new OrderLine(book, int.Parse(span[parts[5]]), decimal.Parse(span[parts[6]], CultureInfo.InvariantCulture));
}
}
There are three decisions. BookInfo is a sealed record: shared state must be immutable, otherwise a "correction" made for one line changes two thousand lines at once. OrderLine is a readonly record struct: the line itself isn't a separate heap object but a 32-byte value sitting side by side in the List's internal array. And the factory uses Dictionary.GetAlternateLookup<ReadOnlySpan<char>>, introduced in .NET 9: we slice the ISBN out of the line as a span and look it up in the dictionary; if the book already exists, not a single string is created. Strings are created only the first time we see a book, 500 times. For parsing with spans, see the "Span<T> and Memory<T>" post in the Advanced C# series.
Let's measure both versions with the same data:
namespace Bookstore.Reports;
public static class MemoryDemo
{
private const int Count = 1_000_000;
public static void Run()
{
Measure("naive ", () =>
{
List<OrderLineRow> rows = new(Count);
foreach (string line in ExportFile.Lines(Count))
rows.Add(OrderLineRow.Parse(line));
return rows;
});
BookInfoFactory factory = new();
Measure("flyweight", () =>
{
List<OrderLine> lines = new(Count);
foreach (string line in ExportFile.Lines(Count))
lines.Add(factory.Parse(line));
return lines;
});
Console.WriteLine($"distinct BookInfo instances: {factory.Count}");
}
// retained: memory still alive when the work ends; allocated: total memory allocated during the work
private static void Measure<T>(string name, Func<T> build)
{
long before = GC.GetTotalMemory(forceFullCollection: true);
long allocatedBefore = GC.GetTotalAllocatedBytes(precise: true);
T result = build();
long allocated = GC.GetTotalAllocatedBytes(precise: true) - allocatedBefore;
long retained = GC.GetTotalMemory(forceFullCollection: true) - before;
GC.KeepAlive(result);
Console.WriteLine($"{name}: retained {retained / 1024 / 1024,4} MB, allocated {allocated / 1024 / 1024,4} MB");
}
}
naive : retained 377 MB, allocated 579 MB
flyweight: retained 30 MB, allocated 170 MB
distinct BookInfo instances: 500
Retained memory dropped from 377 MB to 30 MB; nearly all of those 30 MB are a million 32-byte OrderLines. There are 500 BookInfo instances for a million lines. Most of the 170 MB of total allocation is the source itself: ExportFile.Lines produces every line as a string, and both versions pay that cost. In a real file reader that can also be reduced with PipeReader or line-by-line span reading, but that's no longer Flyweight; it's an I/O (input/output) optimization.
string.Intern: The Runtime's Own Flyweight Factory
The .NET runtime ships with a ready-made flyweight factory for strings: the intern pool. string.Intern returns the single reference in the pool for every string with the same value; if there is none in the pool, it adds the string you passed.
using System.Text;
namespace Bookstore.Reports;
public static class InternDemo
{
public static void Run()
{
string a = OrderLineRow.Parse("978-0441013593;Dune;Frank Herbert;Ace Books;Science Fiction;1;12.99").Category;
string b = OrderLineRow.Parse("978-0765326355;The Way of Kings;Brandon Sanderson;Tor Books;Science Fiction;1;15.00").Category;
string built = new StringBuilder("History").Append(" of Rome").ToString();
Console.WriteLine($"a == b: {a == b}, same object: {ReferenceEquals(a, b)}");
Console.WriteLine($"after Intern, same object: {ReferenceEquals(string.Intern(a), string.Intern(b))}");
Console.WriteLine($"IsInterned(\"{built}\"): {string.IsInterned(built) ?? "null"}");
}
}
a == b: True, same object: False
after Intern, same object: True
IsInterned("History of Rome"): null
The "Science Fiction" parsed from two lines is equal in value but two separate objects; after interning it's the same object. For a string created at runtime that nobody interned, IsInterned returns null.
So why didn't we pass every field through string.Intern instead of writing BookInfoFactory? The String.Intern page on Microsoft Learn spells out two side effects. First, the memory of interned strings is unlikely to be released until the CLR (Common Language Runtime) terminates; the pool is global state that lives as long as the process. Even after the report finishes and its results are discarded, those 500 titles stay in memory; interning unbounded values coming from users is a slow memory leak. Second, to intern a string you first have to create it; the allocation has already happened and just becomes garbage afterwards. Your own factory, on the other hand, is tied to the lifetime of the work: when the report is done, all the shared objects can be collected together with the factory. One more note: the same page says the C# compiler marks assemblies with CompilationRelaxations.NoStringInterning by default and that literals aren't guaranteed to enter the pool; don't build your code on the assumption that "literals are interned anyway".
Instances .NET Already Shares
Flyweight in its simplest form is creating an immutable, frequently requested object once and giving everyone the same one. The BCL (Base Class Library) does this in many places you use every day:
namespace Bookstore.Reports;
public static class CachedDemo
{
public static async Task RunAsync()
{
int[] empty = [];
Console.WriteLine($"Task.CompletedTask: {ReferenceEquals(Task.CompletedTask, Task.CompletedTask)}");
Console.WriteLine($"Array.Empty<int>(): {ReferenceEquals(Array.Empty<int>(), Array.Empty<int>())}");
Console.WriteLine($"int[] empty = []: {ReferenceEquals(empty, Array.Empty<int>())}");
Console.WriteLine($"Task.FromResult(true): {ReferenceEquals(Task.FromResult(true), Task.FromResult(true))}");
Console.WriteLine($"Task.FromResult(1000): {ReferenceEquals(Task.FromResult(1000), Task.FromResult(1000))}");
Console.WriteLine($"async Task<bool>: {ReferenceEquals(IsInStockAsync(), IsInStockAsync())}");
await Task.CompletedTask;
}
// An async method that completes synchronously: for true it returns a Task cached by the runtime
private static async Task<bool> IsInStockAsync()
{
await Task.CompletedTask;
return true;
}
}
Task.CompletedTask: True
Array.Empty<int>(): True
int[] empty = []: True
Task.FromResult(true): True
Task.FromResult(1000): False
async Task<bool>: True
Task.CompletedTask and Array.Empty<T>() are the same instance every time. C#'s collection expression doesn't create a new array for an empty array either; it uses Array.Empty<int>(). Task.FromResult(true) returns the same Task on two calls, while Task.FromResult(1000) returns a new one each time; the runtime keeps ready-made tasks only for a few common values (such as bool). An async Task<bool> method that completes synchronously benefits from the same cache. The only reason these objects can be shared is that they are immutable: the result of a completed task or the contents of an empty array can't change. Stateless or read-only singletons such as StringComparer.Ordinal, CultureInfo.InvariantCulture and EqualityComparer<T>.Default are created once and shared by the same logic.
ArrayPool<T>: Flyweight's Close Relative
In the Facade post we saw that File.ReadAllTextAsync rents its buffer from ArrayPool<char>.Shared. ArrayPool<T> is often cited as a Flyweight example, but it's important to know the difference: a flyweight is shared by many clients at the same time and is therefore immutable; an array from the pool belongs to a single client for as long as it's rented, and that client changes it as it likes. What they have in common is that both reduce the number of allocations through reuse. The correct name is object pool; you could call it Flyweight's "share" idea spread over time.
using System.Buffers;
namespace Bookstore.Reports;
public static class PoolDemo
{
public static void Run()
{
byte[] first = ArrayPool<byte>.Shared.Rent(1000);
Console.WriteLine($"asked 1000, got {first.Length}");
first[0] = 42;
ArrayPool<byte>.Shared.Return(first);
// The pool hands the array back without clearing it: the previous user's data is still inside
byte[] second = ArrayPool<byte>.Shared.Rent(1000);
Console.WriteLine($"same array again: {ReferenceEquals(first, second)}, second[0] = {second[0]}");
ArrayPool<byte>.Shared.Return(second, clearArray: true);
byte[] third = ArrayPool<byte>.Shared.Rent(1000);
Console.WriteLine($"after clearArray: third[0] = {third[0]}");
ArrayPool<byte>.Shared.Return(third);
}
}
asked 1000, got 1024
same array again: True, second[0] = 42
after clearArray: third[0] = 0
Each of the three output lines is a rule. You asked for 1000 bytes and got 1024: Rent returns an array at least as long as requested and may return a longer one; don't trust buffer.Length and process the whole array, carry your own length separately. On the second rent the same array came back with the previous user's 42 still in it: as the documentation says, a rented array may not be zero-initialized. When returning a buffer that held a password, a token or personal data, use clearArray: true. And don't touch the array after Return; it's someone else's buffer now.
Measuring with BenchmarkDotNet
GC.GetTotalMemory is a rough measure: a single run, no warm-up, no statistics. Before making the call, let's measure with BenchmarkDotNet: parsing ten thousand lines both ways, and allocating a 64 KB buffer every time versus renting it from the pool.
using System.Buffers;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using Bookstore.Reports;
BenchmarkRunner.Run([typeof(ReportBenchmarks), typeof(BufferBenchmarks)]);
[MemoryDiagnoser]
public class ReportBenchmarks
{
private readonly string[] _lines = ExportFile.Lines(10_000).ToArray();
[Benchmark(Baseline = true)]
public int NaiveRows()
{
List<OrderLineRow> rows = new(_lines.Length);
foreach (string line in _lines)
rows.Add(OrderLineRow.Parse(line));
return rows.Count;
}
[Benchmark]
public int FlyweightRows()
{
BookInfoFactory factory = new();
List<OrderLine> lines = new(_lines.Length);
foreach (string line in _lines)
lines.Add(factory.Parse(line));
return lines.Count;
}
}
[MemoryDiagnoser]
public class BufferBenchmarks
{
private const int Size = 64 * 1024;
// A new 64 KB buffer on every call
[Benchmark(Baseline = true)]
public byte NewBuffer()
{
byte[] buffer = new byte[Size];
buffer.AsSpan(0, Size).Fill(7);
return buffer[Size - 1];
}
// The same buffer is rented from the pool and returned
[Benchmark]
public byte PooledBuffer()
{
byte[] buffer = ArrayPool<byte>.Shared.Rent(Size);
try
{
buffer.AsSpan(0, Size).Fill(7);
return buffer[Size - 1];
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}
}
BenchmarkDotNet v0.15.8, macOS 27.0.1 (26A434) [Darwin 27.0.0]
Apple M5, 1 CPU, 10 logical and 10 physical cores
.NET SDK 10.0.402
[Host] : .NET 10.0.13 (10.0.13, 10.0.1326.50504), Arm64 RyuJIT armv8.0-a
| Method | Mean | Error | StdDev | Ratio | RatioSD | Gen0 | Gen1 | Gen2 | Allocated | Alloc Ratio |
|-------------- |-----------:|---------:|---------:|------:|--------:|---------:|---------:|--------:|-----------:|------------:|
| NaiveRows | 1,274.2 us | 24.61 us | 32.86 us | 1.00 | 0.04 | 531.2500 | 250.0000 | - | 4340.68 KB | 1.00 |
| FlyweightRows | 661.0 us | 7.89 us | 6.99 us | 0.52 | 0.01 | 99.6094 | 99.6094 | 99.6094 | 494.65 KB | 0.11 |
| Method | Mean | Error | StdDev | Median | Ratio | RatioSD | Gen0 | Allocated | Alloc Ratio |
|------------- |-----------:|---------:|---------:|-----------:|------:|--------:|-------:|----------:|------------:|
| NewBuffer | 1,533.5 ns | 10.49 ns | 8.76 ns | 1,530.4 ns | 1.00 | 0.01 | 7.8106 | 65560 B | 1.00 |
| PooledBuffer | 664.0 ns | 21.40 ns | 62.77 ns | 638.4 ns | 0.43 | 0.04 | - | - | 0.00 |
On ten thousand lines the flyweight version cut allocation from about 4.2 MB to 495 KB (89 percent less) and ran about twice as fast; the drop in the Gen0 and Gen1 columns shows the gain comes from less work for the garbage collector. One detail stands out: the Gen2 column is filled for the flyweight row. The reason is that the internal array holding 10,000 32-byte OrderLines crosses the 85,000-byte threshold and lands on the LOH (Large Object Heap); the naive version's array of references stays below the threshold at 80 KB. Benchmarks make surprises like this visible. On the buffer side, renting from the pool eliminated the 64 KB allocation on every call entirely and more than halved the time. new byte[] both allocates memory and zeroes the array every time; the pool, as we just saw, hands the array back without zeroing it. These numbers were taken on a single Apple M5 machine; measure on your own hardware with your own data distribution.
For the habit of measuring and BenchmarkDotNet itself, see the "Performance: Allocations, Structs and Measuring with BenchmarkDotNet" post in the Advanced C# series.
Common Pitfalls
A mutable flyweight. If BookInfo were a class and Title had a setter, code that "corrects" the title of one line in the report would change all two thousand lines of that book at once. Immutable shared state is a precondition of Flyweight; if you need a change, create a new instance with with and ask the factory with a new key.
A factory that isn't thread-safe. The Dictionary in the example is for single-threaded parsing. The moment you process the lines with Parallel.ForEach, two threads may call Add at the same time. Use ConcurrentDictionary; it also supports GetAlternateLookup since .NET 9.
Bypassing the factory. If someone writes new BookInfo(...) somewhere, that line falls outside the sharing and nobody notices; memory slowly creeps back. Restricting the shared type's constructor to the factory's assembly (internal) lets the compiler enforce it.
A global, unbounded pool. Accumulating user input in string.Intern or in a static dictionary turns a flyweight into a memory leak. Tie the pool's lifetime to the work's lifetime or bound its size.
Optimizing without measuring. Flyweight makes the code more complex: a line no longer carries its own data; it depends on a factory and a shared object. In a list of a hundred lines there is no payoff at all.
When Not to Use It
Flyweight only makes sense when three conditions hold together: there are many objects (hundreds of thousands, millions), a large part of their state repeats, and that repeating part is immutable. If any of these is missing, don't use it. If the data is already normalized in the database, grouping in SQL (Structured Query Language) is often a better answer than building the report in memory; Flyweight is for large data you have to load into memory. If every line comes from a different book, there's nothing to share. And if the profiler or a benchmark doesn't show memory pressure, stay with the readable naive code.
A Small Experiment
★☆☆ Make BookInfo a class with setters instead of a sealed record and change the title of one of the flyweight lines. How many lines of the same book were affected? What would the same change have done in the naive version?
★★☆ Add a small separate flyweight factory each for category and publisher (Category, Publisher) and have BookInfo hold references to these shared objects. Does this change make a measurable difference with 500 books? What happens with 500,000 different books?
★★★ Do the parsing with Parallel.ForEach and make the factory thread-safe with ConcurrentDictionary. Compare it with the single-threaded version using BenchmarkDotNet; if two threads arrive at the same time for the same ISBN, how many BookInfos can be created?
In the next post we move to the last of the structural patterns: Proxy. We'll look at stepping in front of the real object with the same interface to control access; lazy loading, remote and protection proxies, generating proxies at runtime with DispatchProxy, and the N+1 cost hidden by the lazy-loading proxies of Entity Framework Core (EF Core).