Search “java stream find last” and you get the same three answers: stream().reduce((a,b) -> b), stream().skip(size() - 1).findFirst(), or just copying the whole thing into an ArrayList so you can call .get(size()-1). All three work. All three also walk or copy the entire collection just to read one element off the end — and the honest reason people reach for a stream trick at all is that Set, as an interface, never had a getLast(). Not because a LinkedHashSet is unordered — it very much has a defined iteration order — but because Set never gave you a name for “first” or “last” to ask for.
JEP 431, which shipped as part of Java 21 in September 2023, fixes exactly this gap. It adds three small interfaces — SequencedCollection, SequencedSet, SequencedMap — and retrofits them onto List, Deque, LinkedHashSet, LinkedHashMap and the sorted collections, so all of them answer getFirst(), getLast() and reversed() the same way. This article is that gap, the fix, the one assumption bug it introduces, and what the defaults still don’t promise you — in that order, and everything in it was run. The companion module is asmhatre/java-core-examples/sequenced, where a 6-test suite backs every demo below and output/ holds the captured transcripts every quoted line is taken from verbatim.
Versions this was tested against. JDK 25.0.4.1+1 (Temurin, LTS), where this code runs identically to JDK 21 — JEP 431 shipped in Java 21 (GA September 2023) and nothing about these three interfaces has changed since. JUnit Jupiter 5.11.0, Maven 3.9.11. Interface method lists below are quoted directly from javap against the compiled JDK, not from documentation prose.
Why a LinkedHashSet couldn’t tell you its own last element
Here’s the concrete version of the problem. You’re tracking a user’s page-visit history in a LinkedHashSet<String> — a set because you want to de-duplicate repeat visits to the same page, LinkedHashSet specifically because you want to preserve the order pages were first visited in. Now you want “the page they’re on right now,” which is the last element. On a List this was never hard — list.get(list.size() - 1) always worked. On this set, before Java 21, there was no method to call at all.
LinkedHashSet<String> visited = new LinkedHashSet<>();
visited.add("home");
visited.add("search");
visited.add("product/42");
visited.add("cart");
visited.add("checkout");
// Old #1 - stream().reduce((a,b) -> b)
Optional<String> viaReduce = visited.stream().reduce((first, second) -> second);
// Old #2 - stream().skip(size - 1).findFirst()
Optional<String> viaSkip = visited.stream().skip(visited.size() - 1L).findFirst();
// Old #3 - copy into something indexable just to index it
String viaCopy = new ArrayList<>(visited).get(visited.size() - 1);
// New, Java 21+
String viaGetLast = visited.getLast();
Full source, including the agreement check that all four return the same answer: FindLastDemo.java.
A LinkedHashSet has a real iteration order, but pre-Java-21 Set has no getLast():
[home, search, product/42, cart, checkout]
Old #1 - stream().reduce((a,b) -> b): checkout
Old #2 - stream().skip(size - 1).findFirst(): checkout
Old #3 - new ArrayList<>(set).get(size-1): checkout
New - set.getLast(): checkout
All four agree: true
Captured run: output/02-find-last-demo.txt.
They agree on the answer, but not on the cost, and this is the part the stream tricks hide. reduce((a,b) -> b) walks the entire set every single time, because reduce has no way to know you only care about the final element — it’s O(n). skip(size-1).findFirst() still has to drive the whole stream pipeline up to that point — also O(n). Copying into an ArrayList just to throw the copy away is O(n) in both time and memory. getLast(), by contrast, resolves through SequencedCollection’s default method path to reversed().iterator().next(); for LinkedHashSet specifically, that iterator is backed by the doubly-linked insertion-order list LinkedHashMap already maintains internally, so it returns in O(1) in the JDK’s actual implementation.
On a List, this was never the hard case.list.getLast()andlist.get(list.size()-1)return the same answer with the same cost — the win there is readability, not Big-O. The real gap this JEP closes isSetandMap, which had an iteration order but no vocabulary to ask for either end of it.
Reference depth: why that O(1) is an implementation detail, not an interface guarantee
SequencedCollection’s own default implementation of getLast() is specified in terms of reversed().getFirst(), with no complexity guarantee attached — the interface promises a correct answer, not a fast one. LinkedHashSet happens to resolve that in O(1) because the class backing it, LinkedHashMap, has carried a doubly-linked list of its entries since long before JEP 431 existed; reversed() and getLast() just gave that existing structure a name on the public API. A hypothetical SequencedCollection implementor that doesn’t keep end-pointers could legally satisfy the interface with an O(n) getLast(), and the interface’s own contract wouldn’t be violated. Don’t assume O(1) on a type you haven’t checked — TreeMap, further down this page, is the case where it genuinely isn’t free.
Going deeper on this section:
- Full demo and the Big-O commentary it prints: FindLastDemo.java.
- JEP 431 itself names this exact
Set/Dequeasymmetry as its primary motivation: openjdk.org/jeps/431.
One contract, five collection types that had nothing in common before
The interesting part of JEP 431 isn’t any single method — it’s that List, Deque, LinkedHashSet, LinkedHashMap and the sorted collections now share one. Before Java 21, ArrayList and LinkedHashSet had no common ancestor that knew about “first” or “last” — they only happened to each have their own, unrelated way of expressing it (indexing for one, nothing at all for the other). SequencedCollection, SequencedSet and SequencedMap are the shared vocabulary.
System.out.println("=== LinkedHashMap implements SequencedMap (insertion order) ===");
LinkedHashMap<String, Integer> lhm = new LinkedHashMap<>();
lhm.put("jan", 1);
lhm.put("feb", 2);
lhm.put("mar", 3);
SequencedMap<String, Integer> lhmSeq = lhm;
System.out.println("firstEntry() = " + lhmSeq.firstEntry());
lhmSeq.putFirst("dec", 12); // moves/inserts "dec" at the front
System.out.println("after putFirst(\"dec\", 12): " + lhm);
System.out.println("=== TreeMap implements SequencedMap too (order = comparator order) ===");
TreeMap<Integer, String> tree = new TreeMap<>(Map.of(3, "three", 1, "one", 2, "two"));
SequencedMap<Integer, String> treeSeq = tree;
System.out.println("firstEntry() = " + treeSeq.firstEntry());
System.out.println("reversed() = " + treeSeq.reversed());
Full source, also covering List, Deque and LinkedHashSet: AcrossCollectionsDemo.java.
=== LinkedHashMap implements SequencedMap (insertion order) ===
firstEntry() = jan=1
lastEntry() = mar=3
after putFirst("dec", 12): {dec=12, jan=1, feb=2, mar=3}
sequencedKeySet() = [dec, jan, feb, mar]
reversed() = {mar=3, feb=2, jan=1, dec=12}
=== TreeMap implements SequencedMap too (order = comparator order, not insertion) ===
tree contents (natural order) = {1=one, 2=two, 3=three}
firstEntry() = 1=one
lastEntry() = 3=three
reversed() = {3=three, 2=two, 1=one}
Captured run: output/01-across-collections-demo.txt.
putFirst("dec", 12) is worth a second look — it doesn’t just insert at the front, it moves an existing key there if it’s already present, which is a reordering operation LinkedHashMap had no public method for before this. That one behaviour change — a map operation that reorders rather than only inserting or replacing — is the detail worth remembering, because reversed() later in this page relies on the same “this mutates order, not just contents” idea.
Intermediate depth: what TreeMap/TreeSet actually gained, versus what they already had
NavigableMap and NavigableSet have had firstEntry()/lastEntry()/pollFirstEntry()/pollLastEntry() (and the NavigableSet equivalents) since Java 6 — nearly two decades before JEP 431. What the JEP actually adds for TreeMap/TreeSet is mostly reversed() as a named counterpart to the existing descendingMap()/descendingSet(), plus the shared SequencedMap/SequencedSet interface itself, so code that used to need a type check (“is this a NavigableMap?”) to get first/last behaviour can now depend on the narrower, shared interface instead. The genuinely new capability — a type that never had first/last vocabulary before — lands on LinkedHashSet and LinkedHashMap, which is why this page spends most of its time there.
Going deeper on this section:
javapoutput for all three interfaces, the primary source the next section is built on: output/00-javap-sequenced-interfaces.txt.- Oracle’s SequencedCollection Javadoc and SequencedMap Javadoc.
How it resolves underneath: default methods, not five reimplementations
None of ArrayList, ArrayDeque, LinkedHashSet, LinkedHashMap or TreeMap had to write new getFirst()/getLast() logic from scratch. SequencedCollection and SequencedMap ship getFirst(), getLast(), addFirst(), addLast(), removeFirst(), removeLast() (and the map equivalents) as default methods — the interface itself supplies a correct implementation in terms of the one method every implementor truly has to write: reversed(). Everything else is free once reversed() exists.
public interface java.util.SequencedCollection<E> extends java.util.Collection<E> {
public abstract java.util.SequencedCollection<E> reversed();
public default void addFirst(E);
public default void addLast(E);
public default E getFirst();
public default E getLast();
public default E removeFirst();
public default E removeLast();
}
Straight from the compiled JDK, no documentation involved: output/00-javap-sequenced-interfaces.txt (run with javap java.util.SequencedCollection).
The one wrinkle worth quoting directly: SequencedSet re-declares reversed() with a narrower return type than its parent.
public interface java.util.SequencedSet<E> extends java.util.SequencedCollection<E>, java.util.Set<E> {
public abstract java.util.SequencedSet<E> reversed();
public default java.util.SequencedCollection reversed();
}
Same source as above: output/00-javap-sequenced-interfaces.txt.
That second line isn’t a typo in the JDK and it isn’t two real methods — it’s javap showing the compiler-generated bridge method that erasure requires. SequencedSet.reversed() covariantly narrows SequencedCollection.reversed()‘s return type from SequencedCollection<E> to SequencedSet<E> — a reversed set is still a set — and the JVM needs a raw-erased bridge with the parent’s exact signature so existing bytecode calling through the SequencedCollection reference still resolves. It’s invisible from Java source; it only shows up because this page insisted on reading the actual class file instead of trusting a description of it.
Going deeper on this section:
- Correctness tests pinning every default-method path demoed on this page, 6/6 passing: SequencedCollectionsTest.java, captured run output/04-correctness-test.txt.
- Covariant return types and bridge methods are a general JVM mechanism, not specific to this JEP — the JLS section on inheriting methods with override-equivalent signatures is the primary source.
What breaks: reversed() is a view, not a copy
This is the one assumption bug this feature actually produces, and it’s easy to write without noticing: code that does var snapshot = list.reversed(); and expects snapshot to stop changing once it’s been taken. It doesn’t. reversed() returns a live view backed by the same underlying storage — mutating either side is visible through the other, immediately, with no code touching the view directly.
List<Integer> original = new ArrayList<>(List.of(10, 20, 30));
SequencedCollection<Integer> reversedView = original.reversed();
System.out.println("Mutating the ORIGINAL list (original.add(40)):");
original.add(40);
System.out.println("reversedView = " + reversedView); // updated with no code touching it
System.out.println("Mutating the REVERSED VIEW (reversedView.addFirst(0)):");
reversedView.addFirst(0); // "first" of the reversed view = "last" of the original
System.out.println("original = " + original); // the 0 landed at the END
Full source: ReversedViewIsLiveDemo.java.
original = [10, 20, 30]
reversedView = [30, 20, 10]
Mutating the ORIGINAL list (original.add(40)):
original = [10, 20, 30, 40]
reversedView = [40, 30, 20, 10] <- updated with no code touching reversedView directly
Mutating the REVERSED VIEW (reversedView.addFirst(0)):
reversedView = [0, 40, 30, 20, 10]
original = [10, 20, 30, 40, 0] <- the 0 landed at the END of the original list
Captured run: output/03-reversed-view-is-live-demo.txt.
The fingerprint of this bug. Code that calls.reversed()once, early, stores the result in a field or passes it on, and later notices that collection “changed on its own.” It didn’t — the original list changed, and the reversed view was never a snapshot to begin with. If you need an actual snapshot, copy it explicitly:new ArrayList<>(list.reversed()).
Reference depth: reversed().reversed() proves it’s structural, not computed
If reversed() is really a view and not a fresh reversed copy, reversing twice should land you back at an object still wired to the same backing list — and it does. letters.reversed().reversed() returns a collection equal in content and order to letters itself, and a mutation through either end is still visible through the other; the demo above’s final block checks exactly this. This also means reversed() is cheap — O(1) to obtain, since no copying happens — which matters if your code is tempted to call it defensively “just in case” inside a hot loop. It’s fine to; it allocates a thin view object, not a second collection.
Going deeper on this section:
- Full demo, including the double-reversal structural check: ReversedViewIsLiveDemo.java.
- The same view-vs-copy question matters for
Collections.unmodifiableList/List.copyOftoo — that comparison is covered in a companion post on this site (linked in Further reading below).
What the defaults don’t promise you
Two things worth knowing before you lean on this feature everywhere, both because people assume them and both are wrong.
Immutable collections implement the interface, but reject the mutating half of it. List.of("a", "b", "c") genuinely is a SequencedCollection — getFirst()/getLast()/reversed() all work on it — but addFirst(...) throws UnsupportedOperationException immediately, exactly as the read-only half of any other mutator does on an immutable list. Checked directly against the JDK: List.of("a","b","c").addFirst("z") throws, and List.of(...).reversed() returns another immutable view, not a mutable copy you could use to work around the restriction.
A reversed view of a sorted collection is still mutable, independent of whether the original is. new TreeSet<>(Set.of(1,2,3)).reversed() is not read-only — adding to the reversed view inserts into the same underlying tree, in sorted position, same as adding to the TreeSet directly would. “Reversed” changes which end you’re looking from; it says nothing about mutability either way, and each collection’s own mutability rules (not something special about reversed()) decide whether you can write through it.
Going deeper on this section:
- Oracle’s List.of() Javadoc documents the unsupported-operations list for the immutable collections this applies to.
- If you’re choosing between an immutable collection, an unmodifiable view, and a defensive copy for your own APIs — a related decision, not the same one — see the companion post linked below.
Should you reach for this?
Yes, freely — this is a readability fix, not a performance decision you need to weigh. Replace thereduce/skip/copy tricks wherever you see them; they were never faster, only necessary, and they aren’t necessary anymore. The one thing to actually remember is thatreversed()is a view: never store it somewhere expecting it to freeze, and if you need a real snapshot, copy it explicitly. Everything else on this page you can use exactly as it reads.
Further reading
- Companion repository,
sequencedmodule: full source, demos and captured output. - ArrayList vs LinkedList in 2026: JMH Benchmarks and Why LinkedList Rarely Wins — the other half of this site’s collections coverage, on performance rather than API shape.
- Java HashMap vs ConcurrentHashMap: Complete Interview Guide — for the map side of the collections framework in more depth.
- JEP 431: openjdk.org/jeps/431 — the proposal itself, including the motivation section this page’s opening is drawn from.
- Oracle Javadoc: SequencedCollection, SequencedSet, SequencedMap.
No Comments yet!