C# ArrayList, Hashtable: Boxing Cost and Migration

ArrayList, Hashtable, Queue and Stack re-run on .NET 10: why Hashtable order changes per run, measured boxing cost vs List<int>, and a safe migration path.

Revised November 2026. This tutorial first appeared in Korean in 2021. We re-ran its four examples on .NET 10.0.14 (C# 14). All four compiled without warnings, but the Hashtable example printed a different order on different runs, while the 2021 text showed one fixed result. The original also never said that these four classes are legacy and should not be used in new code. Below are the real outputs, a boxing measurement, and a migration checklist.

This article is for developers who run into ArrayList, Hashtable, Queue or Stack in an older C# codebase, a Stack Overflow answer from 2008, or a textbook. The short answer: these four classes from System.Collections date from .NET 1.x, before generics existed. Today each has a direct replacement — List<T>, Dictionary<TKey, TValue>, Queue<T> and Stack<T> — and you should migrate. This post covers only the legacy four: how they behave, why they are slow, and how to move off them. Choosing between modern collections is covered in Choosing C# Collections, and how List<T> works inside is in C# List Internals.

What these collections are, and why they are legacy

A collection is a class that stores a group of values and lets you add, find and remove them. Unlike an array, whose length is fixed at creation (see C# arrays), a collection grows on demand. The four classes from the 2021 article all store their elements as object. That lets you put anything in, but it costs you twice:

  1. Every value type you add (int, double, a struct) is boxed: the runtime allocates a small heap object to hold it.
  2. Reading it back needs a cast, and a wrong cast fails at run time with InvalidCastException, not at compile time.

C# 2.0 (2005) added generics, and with them the System.Collections.Generic replacements that know their element type. How generics work is covered in C# generics. The legacy classes were never removed and are not marked [Obsolete], so the four examples below compiled with zero warnings on .NET 10. The compiler will not stop you; you have to know.

Four arrows from legacy classes to their generic replacements: ArrayList to List<T>, Hashtable to Dictionary<TKey, TValue>, Queue to Queue<T>, Stack to Stack<T>.
Each non-generic class from System.Collections has a generic replacement in System.Collections.Generic. The replacements keep the same method names, so most migrations are a type change plus fixes where code relied on object. One trap: a missing key returns null from Hashtable but throws from Dictionary, so use TryGetValue.

ArrayList: the 2021 example on .NET 10

The 2021 example filled an ArrayList with 0–9, then called Remove(5) and Insert(5, 20). It still works. The "Press any key to continue" line in the original output came from Visual Studio's console host and does not appear with dotnet run.

ArrayList alist = new ArrayList();
for (int i = 0; i < 10; i++) alist.Add(i);
alist.Remove(5);        // removes the first element equal to 5
alist.Insert(5, 20);    // inserts 20 at index 5
alist:
0 1 2 3 4 5 6 7 8 9 
0 1 2 3 4 20 6 7 8 9 

The 2021 text said Remove(5) "deletes the sixth element". That is not what it does: it deletes the first element whose value is 5. In this example value and index happen to coincide, which hid the difference. To remove by position, call RemoveAt(5). List<T> has the same Remove/RemoveAt split, so the distinction still matters after you migrate.

The real problem with ArrayList is the missing type check. Imagine an old API that returns scores as an ArrayList:

static ArrayList LegacyScores() => new ArrayList { 90, 75, "n/a", 88 };

int total = 0;
foreach (int s in LegacyScores()) total += s;   // compiles fine
foreach(int): InvalidCastException

The compiler says nothing; the loop throws at the third element. Make the same mistake with a List<int> and the build fails instead:

Program.cs(2,10): error CS1503: Argument 1: cannot convert from 'string' to 'int'

Hashtable: enumeration order is not guaranteed

The 2021 Hashtable example added three entries (the Korean words for apple, tomato and potato as keys) and printed them by iterating Keys. The article showed a single output order. We ran the same compiled executable five times on .NET 10. Here are the values in the order they came out:

potato  tomato  apple
apple   tomato  potato
potato  tomato  apple
potato  apple   tomato
potato  apple   tomato

Three different orders from one binary. Hashtable places each entry according to the key's hash code, and since .NET Core string.GetHashCode() uses a per-process random seed to resist hash-flooding attacks. The 2021 output was simply whichever order came up that day. This applies to every hash-based collection, including Dictionary and HashSet: do not depend on enumeration order.

The difference that bites hardest during migration is what happens on a missing key:

var ht = new Hashtable { ["Apple"] = "apple" };
Console.WriteLine(ht["Kiwi"] is null ? "null" : "value");   // null

var dict = new Dictionary<string, string> { ["Apple"] = "apple" };
_ = dict["Kiwi"];                                            // throws
Console.WriteLine(dict.TryGetValue("Kiwi", out var v));      // False
Hashtable missing -> null
Dictionary missing -> KeyNotFoundException
TryGetValue -> False / null
Hashtable null key -> ArgumentNullException

If you mechanically rewrite if (ht[key] == null) as a Dictionary lookup, that line now throws. And as the last line shows, Hashtable never accepted null as a key either.

Queue and Stack: FIFO and LIFO

A queue hands items back in the order they arrived (first in, first out); a stack hands back the most recent one first (last in, first out). Both 2021 examples ran unchanged. The queue output:

Dequeue: one, Count: 3
Dequeue: two, Count: 2
Dequeue: three, Count: 1
Dequeue: four, Count: 0

Count starts at 3 because arguments are evaluated left to right: queue.Dequeue() runs first, then queue.Count is read. The original stack example pushed four real book titles; we replaced them with neutral names, and the behavior is identical. The 2021 article illustrated these with a stock image of people in line and a photo of a bookshelf; neither had a clear source, so both are gone and the figure above is our own.

The generic versions keep the same method names and add TryDequeue, TryPop and TryPeek (since .NET Core 2.0), which return false on an empty collection instead of throwing:

Queue<string> q = new(["one", "two", "three"]);
Stack<string> st = new(["vol.1", "vol.2", "vol.3"]);
Console.WriteLine($"Dequeue={q.Dequeue()} Peek={q.Peek()} | Pop={st.Pop()} Peek={st.Peek()}");
Console.WriteLine($"TryPop on empty: {new Stack<int>().TryPop(out _)}");
Dequeue=one Peek=two | Pop=vol.3 Peek=vol.2
TryPop on empty: False

Measured: what boxing costs against List

"Boxing is slow" is easy to say, so we measured it. Legacy code rarely sets a capacity, so these runs start with no initial capacity, fill with Add, then sum with foreach. Allocation is the difference in GC.GetAllocatedBytesForCurrentThread(); gen0 is the number of generation-0 collections during the run.

var list = new ArrayList();
for (int i = 0; i < n; i++) list.Add(i);        // boxing
long sum = 0;
foreach (object o in list) sum += (int)o;       // unboxing
// The List<int>, Hashtable (t[i] = i) and Dictionary<int,int> versions have the same shape

.NET 10.0.14, Release build, Apple Silicon Mac, three runs after a warm-up:

Collection Run 1 Run 2 Run 3 Allocated gen0 GCs
ArrayList (10M ints) 603.5 ms 531.2 ms 568.0 ms 484.9 MB 42
List<int> (10M ints) 33.0 ms 26.9 ms 29.8 ms 128.0 MB 3
Hashtable (1M int keys) 118.4 ms 117.0 ms 114.3 ms 130.3 MB 15
Dictionary<int,int> (1M) 19.4 ms 17.4 ms 17.2 ms 51.4 MB 2
Bar chart: ArrayList with 10 million ints 568 ms, List<int> 30 ms; Hashtable with 1 million int keys 117 ms, Dictionary<int,int> 18 ms. The generic version is about 19 times faster for the list and about 6 times faster for the hash table.
Add then sum: 10 million ints for ArrayList and List<int>, 1 million int keys for Hashtable and Dictionary<int,int>. No initial capacity. Average of 3 runs, .NET 10.0.14 Release build, Apple Silicon Mac.

That is roughly 19× for the list and 6× for the hash table. The 484.9 MB for ArrayList is ten million boxed ints at 24 bytes each, plus the object[] (8 bytes per reference) that is reallocated every time the list grows. Most of the time goes into creating ten million tiny objects and having the GC trace them, which the gen0 counts (42 vs 3) make visible. The numbers with a preset capacity are in C# generics, and where List<int>'s 128.0 MB comes from is explained in C# List Internals.

Many diagrams explain boxing as "copying a value from the stack to the heap". In C#, "value types live on the stack" is not reliably true: an int inside an array or a List<int> is already on the heap, inside the array object. What matters about boxing is not location but that every value becomes its own object. The allocation and GC columns above are that cost.

Migrating legacy code, step by step

This is the order we use when removing ArrayList and Hashtable from a real codebase.

  1. Find out what the elements are. If they are all one type, switch to List<int> (or whatever the type is) directly. If types are mixed, do not reach for List<object>; define a record that describes the data.
  2. If an external API you cannot change returns ArrayList, convert once at the boundary. OfType<int>() skips elements of the wrong type; Cast<int>() throws on the first one.
List<int> scores = raw.OfType<int>().ToList();
Console.WriteLine($"OfType<int>: {string.Join(",", scores)} sum={scores.Sum()}");
var all = raw.Cast<int>().ToList();   // throws at "n/a"
OfType<int>: 90,75,88 sum=253
Cast<int>: InvalidCastException
  1. Replace null checks on Hashtable with TryGetValue. Miss one and it still compiles, then throws KeyNotFoundException in production.
  2. Look for code that relies on enumeration order. If a report or a saved file "always came out in the same order", that may have been luck. When order matters, say so with SortedDictionary or OrderBy.
  3. When you think you are done, delete using System.Collections;. Whatever fails to compile is still using the legacy types.

If you are unsure which modern collection to migrate to, the decision figure and lookup benchmarks in Choosing C# Collections are the next stop.

Test environment: .NET SDK 10.0.403 / runtime 10.0.14, C# 14 (net10.0 default), macOS on an Apple Silicon Mac.

FAQ

Is it still OK to use ArrayList and Hashtable?

They compile on .NET 10 without a single warning, but they do not belong in new code, and the Microsoft Learn page for ArrayList recommends List<T> for new development. Adding and summing 10 million ints took 568 ms on average and allocated 484.9 MB with ArrayList, against 30 ms and 128.0 MB with List<int> (.NET 10, Release, Apple Silicon Mac).

Why does a Hashtable print its keys in a different order each run?

Hashtable places entries by key hash, and since .NET Core the hash code of a string is randomized per process. Running the same executable five times produced three different orders for the same three keys. If you need an order, use SortedDictionary or sort the keys before you enumerate.

What breaks when I replace Hashtable with Dictionary?

Reading a missing key. Hashtable returns null, while Dictionary<TKey, TValue> throws KeyNotFoundException. Code that checks ht[key] == null to test for existence must switch to TryGetValue.

  • #C#
  • #collections
  • #ArrayList
  • #Hashtable
  • #boxing
  • #.NET

t.laboratory

A technical blog that records real problems from the apps, servers and services we build and run — and the code that fixed them.

About · Apps

↑