Java LinkedList Explained — How It Works, When to Use It, and What to Avoid
Every Java developer reaches a point where ArrayList stops feeling like the obvious answer. Maybe you're building a task queue that grows and shrinks thousands of times per second, or implementing an undo/redo stack in a text editor. Reach for the wrong list type and you'll pay for it in performance — microseconds that compound into real latency. LinkedList exists precisely because not every problem is best solved by a resizable array.
ArrayList stores elements in a contiguous block of memory, which makes reading by index lightning-fast but makes inserting or removing from the middle painfully slow — the whole array has to shuffle. LinkedList solves this by abandoning the contiguous block entirely. Each element lives in its own little node object that knows its neighbours. Inserting between two nodes is just rewiring two pointers — no shuffling, no copying.
By the end of this article you'll understand the internal node structure that makes LinkedList tick, know exactly which operations are O(1) versus O(n) and why, be able to use LinkedList as a Queue and Deque (two roles it plays brilliantly), and spot the common mistakes that make developers swear off LinkedList forever — when the real culprit was misuse, not the data structure itself.
The Node Chain: What's Actually Happening Under the Hood
Java's LinkedList is a doubly-linked list. That word 'doubly' is the key. Each element is wrapped in a private inner class called Node that holds three things: the data itself, a reference to the NEXT node, and a reference to the PREVIOUS node. The LinkedList object itself only stores two references — head (the first node) and tail (the last node) — plus a size counter.
This architecture has a beautiful consequence: to add an element at the front, Java just creates a new node, points its 'next' at the old head, points the old head's 'prev' back at it, and updates the head reference. Done. No array resizing, no copying. It's O(1) — constant time regardless of how large the list is.
The flip side is equally important to understand. If you ask for element at index 50, Java has no choice but to start at the head and hop through nodes one by one until it reaches 50. That's O(n). There's no mathematical shortcut because nodes aren't stored in contiguous memory — there are no addresses to calculate. This single trade-off explains every performance decision you'll make with LinkedList.
import java.util.LinkedList; public class LinkedListInternals { public static void main(String[] args) { // Creating an empty LinkedList — only head and tail references exist at this point LinkedList<String> browserHistory = new LinkedList<>(); // addFirst() is O(1) — just rewires the head pointer browserHistory.addFirst("google.com"); browserHistory.addFirst("github.com"); // github is now the new head browserHistory.addFirst("stackoverflow.com"); // stackoverflow is now head // addLast() is also O(1) — just rewires the tail pointer browserHistory.addLast("codeforge.io"); System.out.println("Browser history (front to back):"); System.out.println(browserHistory); // Prints in insertion order: [stackoverflow.com, github.com, google.com, codeforge.io] System.out.println("\nFirst page visited: " + browserHistory.getFirst()); // O(1) System.out.println("Last page visited: " + browserHistory.getLast()); // O(1) // get(index) must walk the chain — notice Java is smart enough to start // from the TAIL if the index is in the second half of the list System.out.println("Page at index 2: " + browserHistory.get(2)); // O(n) System.out.println("\nTotal pages in history: " + browserHistory.size()); // removeFirst() is O(1) — back button pops from the front String closedPage = browserHistory.removeFirst(); System.out.println("\nClosed tab: " + closedPage); System.out.println("History after closing: " + browserHistory); } }
[stackoverflow.com, github.com, google.com, codeforge.io]
First page visited: stackoverflow.com
Last page visited: codeforge.io
Page at index 2: google.com
Total pages in history: 4
Closed tab: stackoverflow.com
History after closing: [github.com, google.com, codeforge.io]
LinkedList as a Queue and Deque — Its Real Superpower
Most tutorials show LinkedList as just another list. That undersells it badly. LinkedList implements both the Queue and Deque interfaces, making it the go-to concrete class whenever you need a double-ended queue in Java — and that unlocks a whole category of real-world problems.
A Queue is a first-in, first-out (FIFO) structure. Think of a print job queue or an HTTP request pipeline: the first request in is the first one served. LinkedList nails this because both enqueue (addLast) and dequeue (removeFirst) are O(1) — no performance cost no matter how long the queue grows.
A Deque (pronounced 'deck', short for Double-Ended Queue) lets you add and remove from BOTH ends in O(1). This is perfect for implementing an undo stack in a text editor — you push changes onto one end, pop them off the same end to undo, but you also need to limit history size by evicting old entries from the other end.
The naming convention in Deque can trip people up. Prefer the semantically clear method names: addFirst/addLast, removeFirst/removeLast, peekFirst/peekLast. Avoid the older push/pop aliases unless you're consciously modelling a stack — they add confusion.
import java.util.Deque; import java.util.LinkedList; public class TaskQueueDemo { // Simulates a customer support ticket system // Regular tickets go to the back; VIP tickets jump to the front public static void main(String[] args) { // Declare against the Deque interface — not LinkedList directly. // This keeps your code flexible (easy to swap implementation later) Deque<String> supportQueue = new LinkedList<>(); // Standard customers — addLast() puts them at the back of the queue (O(1)) supportQueue.addLast("Ticket #101 — Password reset"); supportQueue.addLast("Ticket #102 — Billing question"); supportQueue.addLast("Ticket #103 — Feature request"); System.out.println("Initial queue: " + supportQueue); // VIP customer — addFirst() jumps them to the front without reshuffling anything (O(1)) supportQueue.addFirst("Ticket #104 — VIP account locked [URGENT]"); System.out.println("After VIP ticket: " + supportQueue); System.out.println("\n--- Processing tickets ---"); // peekFirst() lets us SEE the next ticket without removing it (O(1)) System.out.println("Up next: " + supportQueue.peekFirst()); // Process all tickets in FIFO order using removeFirst() (O(1) each time) while (!supportQueue.isEmpty()) { String currentTicket = supportQueue.removeFirst(); System.out.println("Resolving: " + currentTicket); } // peekFirst() returns null on an empty deque — no exception System.out.println("\nQueue empty. Next ticket: " + supportQueue.peekFirst()); // Compare: removeFirst() on empty queue WOULD throw NoSuchElementException // Use pollFirst() instead if empty queue is a normal condition — it returns null safely System.out.println("Safe poll on empty queue: " + supportQueue.pollFirst()); } }
After VIP ticket: [Ticket #104 — VIP account locked [URGENT], Ticket #101 — Password reset, Ticket #102 — Billing question, Ticket #103 — Feature request]
--- Processing tickets ---
Up next: Ticket #104 — VIP account locked [URGENT]
Resolving: Ticket #104 — VIP account locked [URGENT]
Resolving: Ticket #101 — Password reset
Resolving: Ticket #102 — Billing question
Resolving: Ticket #103 — Feature request
Queue empty. Next ticket: null
Safe poll on empty queue: null
LinkedList vs ArrayList — Choosing the Right Tool for the Job
The ArrayList vs LinkedList decision is one of the most misunderstood in Java. The naive rule of thumb — 'lots of inserts? Use LinkedList' — is dangerously incomplete. Modern hardware has made this choice more nuanced than it looks on paper.
ArrayList stores elements in a contiguous array. CPUs love this because of cache locality — when you access element 5, the CPU prefetches elements 6, 7, 8 into its cache automatically. Traversing an ArrayList is blazing fast for this reason. LinkedList's nodes are scattered all over the heap. Every hop to the next node is potentially a cache miss — the CPU has to pause and fetch memory from a completely different address. In practice, this means that even for operations where LinkedList is theoretically O(1) vs ArrayList's O(n), LinkedList can lose on real hardware if your list is large.
So when DOES LinkedList win in practice? When you're doing many insertions and deletions at the ENDS of a large list, when you're using it as a Queue or Deque, or when you hold an Iterator at a specific position and repeatedly insert there. The middle-insertion advantage of LinkedList also only materialises once you already have an iterator positioned there — if you're calling list.add(index, element) with a fresh index each time, you're paying for the O(n) traversal anyway.
The comparison table below gives you a decision framework you can actually use.
import java.util.ArrayList; import java.util.LinkedList; import java.util.List; public class PerformanceComparison { private static final int OPERATION_COUNT = 100_000; public static void main(String[] args) { // --- TEST 1: Repeated insertion at the FRONT --- // ArrayList must shift every existing element right — O(n) per insert // LinkedList just rewires the head pointer — O(1) per insert List<Integer> arrayList = new ArrayList<>(); long startTime = System.nanoTime(); for (int i = 0; i < OPERATION_COUNT; i++) { arrayList.add(0, i); // always insert at index 0 (the front) } long arrayListFrontInsertTime = System.nanoTime() - startTime; List<Integer> linkedList = new LinkedList<>(); startTime = System.nanoTime(); for (int i = 0; i < OPERATION_COUNT; i++) { ((LinkedList<Integer>) linkedList).addFirst(i); // O(1) head rewire } long linkedListFrontInsertTime = System.nanoTime() - startTime; System.out.println("=== Front Insertion " + OPERATION_COUNT + " times ==="); System.out.printf("ArrayList: %,d ms%n", arrayListFrontInsertTime / 1_000_000); System.out.printf("LinkedList: %,d ms%n", linkedListFrontInsertTime / 1_000_000); // --- TEST 2: Sequential READ (iteration) --- // ArrayList wins here due to CPU cache locality — elements are contiguous in memory // LinkedList nodes are scattered on the heap — expect cache misses startTime = System.nanoTime(); long arraySum = 0; for (int value : arrayList) { // enhanced for-loop uses Iterator internally arraySum += value; } long arrayListReadTime = System.nanoTime() - startTime; startTime = System.nanoTime(); long linkedSum = 0; for (int value : linkedList) { linkedSum += value; } long linkedListReadTime = System.nanoTime() - startTime; System.out.println("\n=== Sequential Iteration ==="); System.out.printf("ArrayList: %,d ms%n", arrayListReadTime / 1_000_000); System.out.printf("LinkedList: %,d ms%n", linkedListReadTime / 1_000_000); // Confirm both computed the same sum (sanity check) System.out.println("Sums match: " + (arraySum == linkedSum)); } }
ArrayList: 1,823 ms
LinkedList: 4 ms
=== Sequential Iteration ===
ArrayList: 2 ms
LinkedList: 11 ms
Sums match: true
Iterator-Based Middle Insertion — Where LinkedList Actually Earns Its Keep
There's one pattern where LinkedList genuinely shines over ArrayList in a way that cache effects can't overcome: using a ListIterator to insert or delete at a position you're already standing at.
Here's the scenario: you need to scan through a list and, based on some condition, insert new elements or remove existing ones as you go. With ArrayList, every insert in the middle triggers a System.arraycopy — Java shifts all subsequent elements one slot to the right. Do this repeatedly inside a loop and you've turned an O(n) scan into an O(n²) nightmare. With LinkedList and a ListIterator, the iterator holds a direct reference to the current node. Inserting next to it is just pointer rewiring — O(1) — no matter where you are in the list.
This pattern appears in real code more than you'd think: log processors that enrich records mid-stream, rule engines that inject computed entries into ordered pipelines, and text diff algorithms that splice change markers into document lines. The key discipline is: never call list.add(index, element) inside a loop. Get a ListIterator first, position it, then let the iterator do the inserting.
import java.util.LinkedList; import java.util.ListIterator; public class MidStreamEnrichment { // Real-world scenario: we have a log pipeline. // After every ERROR entry, we want to inject a DIAGNOSTIC_CHECK entry. // We're scanning the list once and enriching it in-place. public static void main(String[] args) { LinkedList<String> logPipeline = new LinkedList<>(); logPipeline.add("INFO — Server started"); logPipeline.add("INFO — Request received"); logPipeline.add("ERROR — Database timeout"); // inject after this logPipeline.add("INFO — Retry attempted"); logPipeline.add("ERROR — Auth token expired"); // and after this logPipeline.add("INFO — Session closed"); System.out.println("Before enrichment:"); logPipeline.forEach(System.out::println); // ListIterator lets us traverse AND modify the list safely at the same time. // It holds a direct node reference — no index lookup needed for add/remove. ListIterator<String> iterator = logPipeline.listIterator(); while (iterator.hasNext()) { String logEntry = iterator.next(); if (logEntry.startsWith("ERROR")) { // iterator.add() inserts AFTER the current element in O(1) // This is the key win over ArrayList: no array shifting happens iterator.add("DIAG — Running diagnostic snapshot..."); } } System.out.println("\nAfter enrichment:"); logPipeline.forEach(System.out::println); System.out.println("\nTotal entries after enrichment: " + logPipeline.size()); } }
INFO — Server started
INFO — Request received
ERROR — Database timeout
INFO — Retry attempted
ERROR — Auth token expired
INFO — Session closed
After enrichment:
INFO — Server started
INFO — Request received
ERROR — Database timeout
DIAG — Running diagnostic snapshot...
INFO — Retry attempted
ERROR — Auth token expired
DIAG — Running diagnostic snapshot...
INFO — Session closed
Total entries after enrichment: 8
| Feature / Aspect | LinkedList | ArrayList |
|---|---|---|
| Internal structure | Doubly-linked nodes on the heap | Resizable contiguous array |
| addFirst() / addLast() | O(1) — pointer rewire only | O(1) amortised for addLast; O(n) for addFirst |
| get(index) | O(n) — must hop through nodes | O(1) — direct array index calculation |
| add(index, element) mid-list | O(n) to find + O(1) to insert | O(n) to find + O(n) to shift elements |
| remove(index) mid-list | O(n) to find + O(1) to unlink | O(n) to find + O(n) to shift elements |
| Iterator-positioned insert | O(1) — iterator holds node ref directly | O(n) — must still shift array elements |
| Memory per element | ~24 bytes overhead per node (prev, next, header) | ~4-8 bytes overhead (array slot reference) |
| CPU cache behaviour | Poor — nodes scattered across heap | Excellent — sequential memory access |
| Implements Queue/Deque? | Yes — natively, all ops O(1) | No — use ArrayDeque instead |
| Null elements allowed? | Yes | Yes |
| Thread-safe? | No — use Collections.synchronizedList() | No — use Collections.synchronizedList() |
| Best use case | Queue, Deque, iterator-driven mid-list edits | Random access, iteration, most general use |
🎯 Key Takeaways
- LinkedList's O(1) add/remove at both ends comes from pointer rewiring, not array copying — this is its structural advantage and the reason it shines as a Queue and Deque.
- get(index) on a LinkedList is O(n) because nodes live scattered on the heap — never use index-based loops to iterate; always use for-each or an Iterator.
- The ListIterator pattern is where LinkedList's mid-list O(1) insert actually delivers in practice — without a positioned iterator, you're paying O(n) to find the spot anyway.
- LinkedList uses ~3x more memory per element than ArrayList due to Node object overhead — factor this into your decision for large collections or memory-sensitive environments.
⚠ Common Mistakes to Avoid
- ✕Mistake 1: Using get(index) inside a loop — If you write
for (int i = 0; i < linkedList.size(); i++) { linkedList.get(i); }, you've created O(n²) complexity. Each get(i) walks from the head (or tail) all the way to index i. On a list of 10,000 elements, that's potentially 50 million node hops. Fix: always use a for-each loop or an explicit Iterator for sequential traversal of a LinkedList — both are O(n) total. - ✕Mistake 2: Reaching for LinkedList when you actually need ArrayDeque for a Queue — LinkedList works as a Queue, but ArrayDeque is faster for that use case because its circular array avoids object allocation for each element and benefits from cache locality. LinkedList allocates a new Node object on every add(). Fix: use ArrayDeque
when you need a Queue or Stack and don't need null elements or List-style access. Only use LinkedList when you specifically need the Deque-plus-List combination. - ✕Mistake 3: Calling removeFirst() or getFirst() on an empty LinkedList — These methods throw NoSuchElementException when the list is empty, which is a runtime crash. This catches developers who assume the list has elements without checking. Fix: use the safer 'poll' family — pollFirst() returns null instead of throwing. Similarly, peekFirst() safely returns null on an empty list. Use isEmpty() or null-checks depending on which method family you choose.
Interview Questions on This Topic
- QWhat's the time complexity of inserting an element at the beginning of a LinkedList versus an ArrayList, and why is one faster than the other at a memory level?
- QLinkedList implements both List and Deque. Can you explain a scenario where using it as a Deque gives you an architectural advantage over using it as a plain List?
- QIf LinkedList's middle insertion is theoretically O(1) once you have a node reference, why might an ArrayList still outperform a LinkedList for repeated middle insertions in a real benchmark — and what does that tell you about how you'd make the decision in production code?
Frequently Asked Questions
Is Java LinkedList thread-safe?
No. LinkedList is not synchronized and is not safe for concurrent access by multiple threads without external synchronization. You can wrap it with Collections.synchronizedList(new LinkedList<>()) for basic thread safety, but for high-concurrency queue scenarios, consider ConcurrentLinkedDeque or LinkedBlockingDeque from java.util.concurrent instead.
When should I use LinkedList instead of ArrayList in Java?
Use LinkedList when you need frequent O(1) insertions or removals at both ends (Queue/Deque patterns), or when you're using a ListIterator to insert at a specific position mid-traversal. Use ArrayList for almost everything else — random access, general storage, and sequential iteration are all faster due to array locality and lower memory overhead.
Does LinkedList allow null values?
Yes, Java's LinkedList allows multiple null elements. However, this can cause issues if you're using it as a Queue and relying on poll() returning null to detect an empty queue — you won't be able to distinguish between 'queue is empty' and 'the next element is actually null'. If you need null-free queue semantics, use ArrayDeque, which does not permit null elements.
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.