📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

What is the difference between ArrayList and List<T> in C#? Why is ArrayList still in .NET, and what goes wrong when you use it in new code?

Compares the non-generic ArrayList with the generic `List<T>`: type safety, boxing, performance and when you still meet ArrayList.

Junior Theory Beginner arraylist boxing collections generics list

🎯 Key points

  • `ArrayList` stores `object`; `List<T>` stores a typed `T[]`
  • Value types in `ArrayList` are boxed on add and unboxed on read
  • `ArrayList` errors appear at run time as `InvalidCastException`; `List<T>` errors are compile-time
  • Boxing creates one heap object per element, increasing GC pressure and hurting cache locality
  • `ArrayList` exists only for backward compatibility with pre-generics .NET 1.x code
  • `List<T>` has a struct enumerator and works directly with LINQ
  • Migrate legacy data with `Cast<T>()` (fails loudly) rather than `OfType<T>()` (drops silently)
  • CLR generics are reified, so `List<int>` gets specialised code with no boxing

⏱ 30-second answer

ArrayList is the .NET 1.x non-generic resizable list. It stores every element as object, so value types are boxed, every read needs a cast, and type errors show up only at run time as InvalidCastException. List<T> (.NET 2.0) does the same job with a typed T[] inside: no boxing, no casts, compile-time type checks and better performance. ArrayList stays in System.Collections only for backward compatibility; new code should always use List<T>.

🗣 Full answer (senior)

How I would answer this in an interview

ArrayList is the non-generic resizable list from .NET 1.0: it stores everything as object. List<T> (.NET 2.0) is the generic equivalent: it stores T directly. Both are a growable array internally, but List<T> gives compile-time type safety, no boxing for value types, no casts, and better performance. ArrayList survives only for backward compatibility.

1. The differences

ArrayList List<T>
Namespace System.Collections System.Collections.Generic
Element type object T
Type safety Run time (casts can throw InvalidCastException) Compile time
Value types Boxed on add, unboxed on read Stored inline, no boxing
Reading (int)list[0] cast required list[0]
Mixed types Allowed (often accidentally) Not allowed (unless List<object>)
foreach enumerator Class (allocates), returns object Struct (no allocation), returns T
LINQ Needs Cast<T>() / OfType<T>() first Works directly
Interfaces IList, ICollection, IEnumerable Plus IList<T>, IReadOnlyList<T>, etc.
Span access No CollectionsMarshal.AsSpan(list)
Nullable annotations / analysers None Full

2. Why ArrayList is still in .NET

  • Backward compatibility: millions of lines of .NET Framework 1.x-era code, and APIs that return or accept non-generic IList/ICollection (WinForms data binding, System.Data, older serialisation code, COM interop).
  • It was moved to the System.Collections.NonGeneric package in .NET Core but is still part of the shared framework so ported code compiles.
  • Microsoft's guidance is explicit: don't use non-generic collections in new code (analysers and documentation say so).

3. What goes wrong in new code

var totals = new ArrayList();
totals.Add(10.5m);
totals.Add("20");            // compiles - a bug waiting to happen
decimal sum = 0;
foreach (object o in totals)
    sum += (decimal)o;       // InvalidCastException at run time on "20"
  • Run-time type errors instead of compile errors.
  • Boxing: each int/decimal/DateTime added allocates a ~24–32-byte object; reading unboxes. A list of 1 million ints is ~4 MB as List<int> but ~32 MB as ArrayList (8 MB of references plus 24 MB of boxes), and the GC has to track a million extra objects.
  • Casts everywhere reduce readability and hide intent.
  • Unboxing must match the exact type: (long)list[0] throws if an int was stored.
  • No nullable reference type information, poor tooling, no modern APIs (CollectionsMarshal, Span).

4. Migrating legacy code

  • Replace ArrayList with List<T> where the element type is known; use List<object> temporarily if the list really is heterogeneous, then refactor toward a common type or a discriminated record hierarchy.
  • At boundaries that require non-generic interfaces, List<T> already implements IList, so you can usually pass it directly.
  • For data read from legacy APIs, convert once: arrayList.Cast<Order>().ToList().
  • Add the analyser/banned API rule to prevent new usages.

5. A production example

A legacy .NET Framework reporting module ported to .NET kept ArrayList for line items. A new feature added string amounts from an import alongside decimal amounts from the database; it compiled, passed a happy-path test, and failed in production with InvalidCastException when summing. Memory profiling of the same module also showed hundreds of thousands of boxed Decimal objects. Converting to List<LineItem> with a typed decimal Amount made the bad import a compile error at the mapping layer and cut the module's heap usage noticeably.

🔍 What the interviewer is checking

Checks basic understanding of generics, boxing and type safety.

  • Green flags: mentions boxing, compile-time type checking, System.Collections versus System.Collections.Generic, and that ArrayList is legacy.
  • Red flags: thinks ArrayList is a valid modern choice, cannot explain boxing, or believes the two have the same memory layout.

📚 Learn in depth

1. Same internal design, different element storage

Both types hold a backing array plus a count:

// ArrayList (simplified)
private object?[] _items;
private int _size;
private int _version;

// List<T>
private T[] _items;
private int _size;
private int _version;

Growth is the same (doubling, starting at 4), Add is amortised O(1), Insert/RemoveAt are O(n) shifts. The difference is entirely what lives in the array: references to boxes for value types in ArrayList, versus inline values in List<int>.

2. Generics and code generation

List<T> relies on .NET's reified generics:

  • For each value type argument (List<int>, List<decimal>), the JIT generates specialised native code with T known — no boxing, direct comparisons, inlining.
  • For reference type arguments, one shared implementation (List<__Canon>) is used, with type information passed via a hidden dictionary for things like casts.

This is unlike Java's type-erased generics, where List<Integer> still boxes. It is why List<T> of value types is dramatically more efficient than ArrayList.

3. Boxing costs in detail

Adding an int to ArrayList:

  1. box Int32 allocates a 24-byte object on the heap (header + MethodTable + 4-byte value + padding).
  2. The reference is stored in the object[] (with a GC write barrier and a covariance check).

Reading: unbox.any Int32 checks the type and copies the value. For a million elements: a million allocations, a million objects for the GC to mark, and poor cache locality because boxes are scattered across the heap.

4. Enumerators

  • ArrayList.GetEnumerator() returns an IEnumerator implemented by a class — one allocation per foreach, plus interface calls for MoveNext and Current, and Current returns object (requiring an unbox).
  • List<T>.GetEnumerator() returns List<T>.Enumerator, a struct, so foreach allocates nothing and the JIT can inline the calls.

5. Thread-safety wrappers

ArrayList.Synchronized(list) returns a wrapper that locks on every call — but compound operations (check-then-add, enumerate) still aren't atomic. Generic collections dropped these wrappers; the recommended approach is explicit locking or concurrent collections. Seeing ArrayList.Synchronized in legacy code is a sign of possible race conditions.

6. Other non-generic collections to avoid

Non-generic Generic replacement
ArrayList List<T>
Hashtable Dictionary<TKey,TValue> (or ConcurrentDictionary)
Queue / Stack Queue<T> / Stack<T>
SortedList SortedList<TKey,TValue> / SortedDictionary
CollectionBase, DictionaryBase Collection<T>, KeyedCollection<TKey,TItem>
BitArray Still fine (it's specialised, not object-based)

Roslyn analysers and code-quality tools (and the Microsoft.CodeAnalysis.BannedApiAnalyzers with a BannedSymbols.txt) can block them in new code.

7. Interop with non-generic interfaces

List<T> implements IList explicitly, so legacy APIs expecting IList accept it. Writes through IList.Add(object) are type-checked at run time and throw ArgumentException for wrong types — safer than ArrayList, which accepts anything.

Try it yourself

  1. Benchmark adding and summing 1 million ints with ArrayList vs List<int> using [MemoryDiagnoser].
  2. Take a dotnet-gcdump and count System.Int32 objects in each case.
  3. Add a string to an ArrayList of numbers and sum it; then try the same with List<decimal>.
  4. Pass a List<int> to a method expecting IList and call Add("x") through it.

💻 Code

using System.Collections;

// Legacy: compiles, fails at run time
var legacy = new ArrayList { 10, 20, "thirty" };   // 10 and 20 are boxed
int total = 0;
foreach (object o in legacy)
    total += (int)o;                                  // InvalidCastException on "thirty"

// Modern: the compiler stops the bug
List<int> amounts = [10, 20];
// amounts.Add("thirty");                           // CS1503: cannot convert string to int
int sum = amounts.Sum();                              // no casts, no boxing

// Migrating legacy data
List<int> migrated = legacy.OfType<int>().ToList();   // skips "thirty" silently
List<int> strict   = legacy.Cast<int>().ToList();     // throws, which is usually what you want

⚠ Common mistakes

1. Using ArrayList in new code

What happens: developers familiar with old tutorials use ArrayList for convenience. Why it is wrong: no type safety, boxing, casts, and worse performance. Do instead: use List<T>; ban non-generic collections with analysers.

2. Mixing types in one list

What happens: an ArrayList holds strings, numbers and objects together. Why it is wrong: consumers must guess types; casts fail at run time. Do instead: use a common base type, interface or a record hierarchy with pattern matching.

3. Unboxing to the wrong numeric type

What happens: (long)arrayList[0] when an int was added. Why it is wrong: unboxing requires the exact stored type — InvalidCastException. Do instead: use generic collections; if stuck with object, use Convert.ToInt64.

4. Replacing ArrayList with List<object> and stopping there

What happens: a mechanical migration produces List<object> everywhere. Why it is wrong: still boxes and still needs casts — most benefits are lost. Do instead: identify the real element type and use it.

5. Trusting ArrayList.Synchronized for thread safety

What happens: synchronized wrappers used for shared lists. Why it is wrong: individual calls are locked, but check-then-act and enumeration are not atomic. Do instead: use explicit locks, immutable snapshots or concurrent collections.

6. Using Cast<T>() repeatedly in hot code

What happens: legacy ArrayList data is cast with LINQ in every request. Why it is wrong: repeated enumeration, unboxing and allocation. Do instead: convert once at the boundary to List<T>.

7. Saying generics are "just syntax"

What happens: candidates claim List<int> and ArrayList perform the same. Why it is wrong: .NET generics are reified; value-type specialisation avoids boxing entirely. Do instead: explain JIT specialisation and memory layout.

8. Exposing ArrayList or IList from public APIs

What happens: new APIs return non-generic interfaces to stay "flexible". Why it is wrong: consumers lose type information and must cast. Do instead: return IReadOnlyList<T> or another generic abstraction.

9. Ignoring the enumerator allocation

What happens: foreach over ArrayList in hot paths. Why it is wrong: each loop allocates a class enumerator and unboxes each element. Do instead: use List<T> with its struct enumerator.

10. Assuming migration is risk-free

What happens: swapping ArrayList for List<T> without checking code that relied on heterogeneous contents or null elements. Why it is wrong: hidden assumptions surface as compile errors or behaviour changes. Do instead: migrate with tests around the module, and let the compiler errors guide the redesign.

🏛 Senior insight: trade-offs

A senior answer treats ArrayList as a migration task, not a design option, and knows why the generic version wins at the runtime level.

Concern ArrayList List<T> Senior view
Correctness Run-time casts Compile-time types Shift errors left
Memory One box per value Inline values Fewer GC objects
API surface Non-generic IList Generic and non-generic interfaces List<T> still works with old APIs
Migration n/a Cast<T>() plus tests Fail loudly, never silently drop data

The deeper point is that the CLR's reified generics give List<int> its own specialised code with int in it, unlike Java's type erasure, so there is no boxing at all for value types.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate says List<T> is a dynamic array Why: Checks they know how List<T> grows and its operation costs
    How to answer

    List<T> wraps a T[] plus a _size count and a _version counter. When the array is full, Add allocates a new array twice as large (starting at 4) and copies, so Add is amortised O(1). Insert and Remove shift elements, so they are O(n). Every change increments _version; the enumerator checks it on each MoveNext and throws InvalidOperationException if the list changed. In hot paths, pre-size with the constructor or EnsureCapacity, and use CollectionsMarshal.AsSpan to work on the backing array without copying (but never add or remove while holding that span).

  2. When: Candidate mentions boxing of value types Why: Probes the cost and detection of boxing
    How to answer

    Boxing wraps a value type in a new heap object so it can be used as object or an interface; unboxing checks the object's type and copies the value back, and it must be the exact type or you get InvalidCastException. Every box is a heap allocation, so in hot paths it creates GC pressure. Hidden boxing comes from non-generic APIs like ArrayList and params object[], passing structs as interfaces, calling GetType() or non-overridden Equals/GetHashCode on structs, and enumerating a List<T> through IEnumerable<T>. I find it with BenchmarkDotNet's MemoryDiagnoser or an allocation profiler and fix it with generics, constraints and IEquatable<T>.

More in Collections

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me