Compares the non-generic ArrayList with the generic `List<T>`: type safety, boxing, performance and when you still meet ArrayList.
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>.
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.
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 |
ArrayList is still in .NETIList/ICollection (WinForms data binding, System.Data, older serialisation code, COM interop).System.Collections.NonGeneric package in .NET Core but is still part of the shared framework so ported code compiles.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"
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.(long)list[0] throws if an int was stored.CollectionsMarshal, Span).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.List<T> already implements IList, so you can usually pass it directly.arrayList.Cast<Order>().ToList().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.
Checks basic understanding of generics, boxing and type safety.
System.Collections versus System.Collections.Generic, and that ArrayList is legacy.ArrayList is a valid modern choice, cannot explain boxing, or believes the two have the same memory layout.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>.
List<T> relies on .NET's reified generics:
List<int>, List<decimal>), the JIT generates specialised native code with T known — no boxing, direct comparisons, inlining.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.
Adding an int to ArrayList:
box Int32 allocates a 24-byte object on the heap (header + MethodTable + 4-byte value + padding).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.
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.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.
| 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.
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.
ArrayList vs List<int> using [MemoryDiagnoser].dotnet-gcdump and count System.Int32 objects in each case.string to an ArrayList of numbers and sum it; then try the same with List<decimal>.List<int> to a method expecting IList and call Add("x") through it.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
ArrayList in new codeWhat 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.
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.
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.
ArrayList with List<object> and stopping thereWhat 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.
ArrayList.Synchronized for thread safetyWhat 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.
Cast<T>() repeatedly in hot codeWhat 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>.
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.
ArrayList or IList from public APIsWhat 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.
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.
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.
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.
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).
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>.
Last updated 30 Oct 2026.