Java ArrayList Explained — How It Works, When to Use It, and What to Watch Out For
Every non-trivial Java application manages collections of data — a shopping cart full of products, a feed full of posts, a queue full of tasks. The humble array can do this, but only if you know exactly how many items you'll have before you write a single line of code. In the real world, you almost never do. ArrayList exists precisely because the world is unpredictable, and your code needs to keep up.
ArrayList solves the fixed-size problem of plain arrays by wrapping one internally and automatically resizing it whenever capacity is exhausted. It gives you the fast random access of an array (jump straight to index 7 without touching 0–6) while also letting you add and remove items freely at runtime. That combination — speed plus flexibility — is why ArrayList is the most-used collection class in Java by a significant margin.
By the end of this article you'll understand not just how to declare and populate an ArrayList, but why it resizes the way it does, how that affects performance, when you should reach for something else (like LinkedList or a plain array), and the specific gotchas that cause subtle bugs in production code. You'll also have a handful of interview-ready answers ready to go.
Creating and Populating an ArrayList — The Right Way from Day One
Declaring an ArrayList takes one line, but that one line contains decisions that matter more than they look.
First, always use the generic type parameter — ArrayList instead of the raw ArrayList. Without it, Java lets you shove any object in, and the compiler can't warn you when you mix types. You'll get a ClassCastException at runtime instead of a compile-time error, and runtime surprises are the worst kind.
Second, favour the List interface type on the left side of the declaration: List. This is called programming to the interface. It means if you later decide to swap ArrayList for a LinkedList, you change exactly one word. Every method call you wrote still compiles. This is not just an academic best practice — it's the pattern you'll see in every professional codebase.
Third, if you have a rough idea of your list's final size, pass it as the initial capacity: new ArrayList<>(50). This pre-allocates space and avoids the internal resizing penalty we'll dig into shortly. You're not locking in a maximum — you're just giving ArrayList a head start.
import java.util.ArrayList; import java.util.List; public class GuestListDemo { public static void main(String[] args) { // Program to the List interface — not ArrayList directly. // This keeps your code flexible if you need to swap implementations later. List<String> guestList = new ArrayList<>(); // add() appends to the end of the list — O(1) amortised time guestList.add("Alice"); guestList.add("Bob"); guestList.add("Charlie"); // add(index, element) inserts at a specific position — O(n) because // everything after that index has to shift one spot to the right guestList.add(1, "Zara"); // Zara goes between Alice and Bob System.out.println("Guest list: " + guestList); System.out.println("Number of guests: " + guestList.size()); // get(index) retrieves by position — O(1), same speed as a plain array String firstGuest = guestList.get(0); System.out.println("First guest: " + firstGuest); // contains() checks if a value exists — O(n) linear scan under the hood boolean isInvited = guestList.contains("Bob"); System.out.println("Is Bob invited? " + isInvited); } }
Number of guests: 4
First guest: Alice
Is Bob invited? true
How ArrayList Actually Resizes — Why This Changes How You Write Code
This is the section most tutorials skip, and it's exactly where performance bugs hide.
An ArrayList is backed by a plain Object[] array internally. When you create new ArrayList<>() without specifying a capacity, Java allocates an internal array of size 10. The moment you add an 11th element, ArrayList creates a new array that is 50% larger (so size 15), copies all 10 existing elements into it, and then adds your new element. This copy operation is O(n). It happens again at 16, then 24, then 36, and so on.
Most of the time this is invisible because modern hardware is fast. But if you're building a list of 10,000 items inside a tight loop — say, parsing a large CSV file — those repeated copy operations add up. The fix is simple: if you know the approximate final size, pass it to the constructor upfront. You don't need to be exact. Even a rough estimate dramatically reduces the number of resize events.
The key mental model: add() at the end is O(1) amortised (fast on average), but occasionally triggers an O(n) resize. add(index, element) in the middle is always O(n) because elements must shift. get(index) is always O(1). Understanding this lets you write code that stays fast even at scale.
import java.util.ArrayList; import java.util.List; public class ResizingDemo { public static void main(String[] args) { // --- Scenario 1: No initial capacity hint --- // ArrayList starts with internal array of size 10. // Every time we exceed capacity, it resizes (copies everything). long startTime = System.nanoTime(); List<Integer> productIds = new ArrayList<>(); for (int i = 0; i < 100_000; i++) { productIds.add(i); // triggers multiple internal resizes } long durationWithoutHint = System.nanoTime() - startTime; // --- Scenario 2: With initial capacity hint --- // We tell ArrayList we expect ~100,000 items. // It allocates space upfront — zero resize operations. startTime = System.nanoTime(); List<Integer> productIdsOptimised = new ArrayList<>(100_000); for (int i = 0; i < 100_000; i++) { productIdsOptimised.add(i); // no resizing, just writing into pre-allocated space } long durationWithHint = System.nanoTime() - startTime; System.out.println("Without capacity hint: " + durationWithoutHint / 1_000_000 + " ms"); System.out.println("With capacity hint: " + durationWithHint / 1_000_000 + " ms"); // trimToSize() releases any unused allocated slots — useful after // you've finished building a list that will now be read-only ((ArrayList<Integer>) productIdsOptimised).trimToSize(); System.out.println("Memory trimmed. List size: " + productIdsOptimised.size()); } }
With capacity hint: 4 ms
Memory trimmed. List size: 100000
Iterating, Removing and Sorting — Real-World Patterns That Don't Break
Iterating an ArrayList seems trivial until you try to remove items while iterating — then it breaks in ways that are genuinely confusing the first time you hit them.
The safest way to iterate is the enhanced for-loop when you're just reading. When you need the index, use a classic for-loop with get(i). When you need to remove items during iteration, you have two good options: use an Iterator explicitly and call iterator.remove(), or use removeIf() with a lambda (available since Java 8). Never call list.remove() inside an enhanced for-loop — that causes a ConcurrentModificationException.
Sorting an ArrayList is one line: Collections.sort(list) for natural ordering, or list.sort(Comparator.comparing(...)) when you need custom logic. The sort is backed by TimSort, an adaptive algorithm that's particularly efficient on partially-sorted data — which is exactly what real-world data tends to be.
For filtering and transformation, streams give you a clean declarative pipeline. They don't modify the original list — they produce a new one. This immutability makes your code much easier to reason about, especially in concurrent contexts.
import java.util.ArrayList; import java.util.Comparator; import java.util.Iterator; import java.util.List; import java.util.stream.Collectors; public class OrderProcessing { record Order(String id, String status, double amount) {} public static void main(String[] args) { List<Order> orders = new ArrayList<>(); orders.add(new Order("ORD-001", "PENDING", 149.99)); orders.add(new Order("ORD-002", "SHIPPED", 59.00)); orders.add(new Order("ORD-003", "PENDING", 299.50)); orders.add(new Order("ORD-004", "CANCELLED", 25.00)); orders.add(new Order("ORD-005", "SHIPPED", 179.99)); // --- Pattern 1: Enhanced for-loop (read-only) --- System.out.println("All orders:"); for (Order order : orders) { System.out.println(" " + order.id() + " — " + order.status()); } // --- Pattern 2: removeIf — safe removal during iteration --- // DO NOT call orders.remove() inside a for-each loop. // removeIf handles the iterator internally and avoids ConcurrentModificationException. orders.removeIf(order -> order.status().equals("CANCELLED")); System.out.println("\nAfter removing cancelled orders: " + orders.size() + " remaining"); // --- Pattern 3: Sort by amount descending --- orders.sort(Comparator.comparingDouble(Order::amount).reversed()); System.out.println("\nOrders by value (highest first):"); for (Order order : orders) { System.out.printf(" %s $%.2f%n", order.id(), order.amount()); } // --- Pattern 4: Stream to filter into a new list --- // The original list is untouched — stream produces a fresh collection List<Order> pendingOrders = orders.stream() .filter(order -> order.status().equals("PENDING")) .collect(Collectors.toList()); System.out.println("\nPending orders only:"); pendingOrders.forEach(o -> System.out.println(" " + o.id() + " $" + o.amount())); } }
ORD-001 — PENDING
ORD-002 — SHIPPED
ORD-003 — PENDING
ORD-004 — CANCELLED
ORD-005 — SHIPPED
After removing cancelled orders: 4 remaining
Orders by value (highest first):
ORD-003 $299.50
ORD-005 $179.99
ORD-001 $149.99
ORD-002 $59.00
Pending orders only:
ORD-003 $299.50
ORD-001 $149.99
ArrayList vs Array vs LinkedList — Choosing the Right Tool Without Guessing
Knowing how to use ArrayList is only half the skill. The other half is knowing when not to use it.
A plain array (String[]) beats ArrayList when your size is fixed and known at compile time, and you care about memory or raw speed. Arrays avoid the object overhead of ArrayList and let you use primitive types directly (int[] instead of List). If you're writing a fixed lookup table or working in a performance-critical path, arrays are your friend.
LinkedList is the other common alternative. It's a doubly-linked list, meaning each element holds a reference to the next and previous elements. This makes insertions and deletions at both ends O(1) — perfect for a queue or stack. But get(index) on a LinkedList is O(n) because it has to walk the chain from the beginning. In practice, LinkedList also has higher memory usage per element due to those extra references. Most developers reach for it too quickly — unless you're doing heavy insertion/deletion at both ends, ArrayList is usually faster in practice due to better cache locality.
Vector is the old thread-safe ArrayList from Java 1.0. Don't use it in new code. If you need thread safety, use Collections.synchronizedList() or CopyOnWriteArrayList from java.util.concurrent depending on your read/write ratio.
import java.util.ArrayList; import java.util.LinkedList; import java.util.List; public class CollectionComparison { public static void main(String[] args) { int iterations = 50_000; // --- ArrayList: fast random access, slower mid-list insert --- List<String> arrayList = new ArrayList<>(); long start = System.nanoTime(); for (int i = 0; i < iterations; i++) { // Inserting at index 0 forces everything else to shift right — O(n) each time arrayList.add(0, "Task-" + i); } long arrayListInsertTime = System.nanoTime() - start; // --- LinkedList: fast front/back insert, slow random access --- List<String> linkedList = new LinkedList<>(); start = System.nanoTime(); for (int i = 0; i < iterations; i++) { // addFirst() is O(1) — just rewire the head pointer, no shifting ((LinkedList<String>) linkedList).addFirst("Task-" + i); } long linkedListInsertTime = System.nanoTime() - start; System.out.println("Inserting " + iterations + " items at the front:"); System.out.println(" ArrayList: " + arrayListInsertTime / 1_000_000 + " ms ← lots of shifting"); System.out.println(" LinkedList: " + linkedListInsertTime / 1_000_000 + " ms ← just pointer rewiring"); // Now show the flip side: random access start = System.nanoTime(); String ignored1 = arrayList.get(iterations / 2); // O(1) — direct index jump long arrayListGetTime = System.nanoTime() - start; start = System.nanoTime(); String ignored2 = linkedList.get(iterations / 2); // O(n) — walks the chain long linkedListGetTime = System.nanoTime() - start; System.out.println("\nRandom access (middle element):"); System.out.println(" ArrayList: " + arrayListGetTime + " ns ← direct jump"); System.out.println(" LinkedList: " + linkedListGetTime + " ns ← walked the chain"); } }
ArrayList: 312 ms ← lots of shifting
LinkedList: 3 ms ← just pointer rewiring
Random access (middle element):
ArrayList: 180 ns ← direct jump
LinkedList: 892340 ns ← walked the chain
| Feature / Aspect | ArrayList | LinkedList | Plain Array |
|---|---|---|---|
| Backed by | Object[] internally | Doubly-linked nodes | Native memory block |
| get(index) speed | O(1) — direct access | O(n) — walks the chain | O(1) — direct access |
| add() at end | O(1) amortised | O(1) always | N/A — fixed size |
| add() in middle | O(n) — elements shift | O(1) if you have the node | N/A — fixed size |
| remove() in middle | O(n) — elements shift | O(1) if you have the node | N/A — fixed size |
| Memory overhead | Low — just the array + metadata | High — two pointers per node | Lowest — no wrapper |
| Supports primitives | No — boxes int to Integer | No — boxes int to Integer | Yes — int[], double[], etc. |
| Thread safe? | No — use synchronizedList() | No — use synchronizedList() | No |
| Best for | General purpose, read-heavy | Queue/deque, frequent front inserts | Fixed size, primitives, performance |
🎯 Key Takeaways
- Always declare your variable as
List, notArrayList— programming to the interface keeps your code swappable and is the mark of a professional. - ArrayList resizes by 50% when full, copying all elements into a new array — pre-size with
new ArrayList<>(estimatedCapacity)in any loop that builds large lists to avoid repeated O(n) copy operations. - Never call
list.remove()inside a for-each loop — uselist.removeIf()or an explicit Iterator to avoidConcurrentModificationException. - ArrayList beats LinkedList in most real-world scenarios because contiguous memory gives better CPU cache performance — only reach for LinkedList when you genuinely need O(1) insertion at both ends with no random access.
⚠ Common Mistakes to Avoid
- ✕Mistake 1: Removing elements inside a for-each loop — This throws
java.util.ConcurrentModificationExceptionat runtime because the ArrayList's internal modCount changes while the iterator is active, which the iterator detects and treats as a contract violation. Fix it by usinglist.removeIf(predicate)for simple filter-and-remove scenarios, or use an explicitIteratorwithiterator.remove()when you need conditional logic during the loop. - ✕Mistake 2: Using
==to compare ArrayList contents instead of.equals()— Writingif (listA == listB)checks whether both variables point to the exact same object in memory, not whether they contain the same elements. Two separate ArrayLists with identical contents will always return false with==. UselistA.equals(listB)instead, which compares element-by-element using each element's own.equals()method. - ✕Mistake 3: Storing Integer indexes from
indexOf()without checking for -1 —list.indexOf(element)returns -1 when the element isn't found. If you pass that -1 directly intolist.get(-1)or use it in an off-by-one calculation, you'll get anIndexOutOfBoundsExceptionor silently wrong results. Always check:int idx = list.indexOf(target); if (idx != -1) { ... }before using the result.
Interview Questions on This Topic
- QWhat is the difference between ArrayList and LinkedList, and when would you actually choose LinkedList over ArrayList in a production application?
- QWhat does 'amortised O(1)' mean in the context of ArrayList.add(), and what triggers the worst-case O(n) behaviour?
- QIf ArrayList is not thread-safe, what are your options for using a list in a multi-threaded environment, and what are the trade-offs between Collections.synchronizedList() and CopyOnWriteArrayList?
Frequently Asked Questions
What is the difference between ArrayList and a regular array in Java?
A regular array has a fixed size you must declare upfront and cannot change. ArrayList is a resizable wrapper around an array that grows automatically when you exceed its capacity. You also get built-in methods like add(), remove(), contains(), and sort() with ArrayList, none of which exist on a plain array. The trade-off is that ArrayList can't hold primitives directly — it boxes them into wrapper types like Integer, which adds a small memory and performance cost.
Is ArrayList thread-safe in Java?
No, ArrayList is not thread-safe. If multiple threads read and write to the same ArrayList concurrently without synchronisation, you'll get unpredictable results and ConcurrentModificationException errors. Use Collections.synchronizedList(new ArrayList<>()) for a simple synchronised wrapper, or CopyOnWriteArrayList from java.util.concurrent if reads vastly outnumber writes — it creates a fresh copy of the backing array on every write, making reads completely lock-free.
Why does ArrayList use Object[] internally instead of a typed array like String[]?
Java generics are erased at compile time through a mechanism called type erasure — the JVM doesn't actually know about the generic type parameter at runtime. So ArrayList and ArrayList both use the same Object[] under the hood, with the generic type only enforced at the compiler level. This is why you'll occasionally see unchecked cast warnings when working with reflection or raw types around ArrayList.
Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.