Skip to main content

Sequenced Collections in Java 21+: getFirst, getLast and reversed()

JEP 431’s SequencedCollection, SequencedSet and SequencedMap give List, Deque, LinkedHashSet, LinkedHashMap and TreeMap a shared getFirst/getLast/reversed() contract. This replaces the stream.reduce((a,b)->b) and skip(size-1) tricks people use to find the last element of a Set, with real javap-verified interface output and a working demo of why reversed() is a live view, not a copy.

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.

Finding “checkout”, the last of 5 elements reduce((a,b)->b) / skip(size-1) / copy&index home search product/42 cart checkout ← visited or copied every element to reach this one (O(n)) set.getLast() home search product/42 cart checkout ← LinkedHashMap already has a pointer straight to the last node (O(1)) Same answer either way. The old three ways pay for an O(n) walk every time; getLast() does not.
On a List, this was never the hard case. list.getLast() and list.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 is Set and Map, 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:

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.

SequencedCollection<E> getFirst/getLast/addFirst/addLast/reversed() List Deque ArrayList ArrayDeque SequencedSet<E> extends SequencedCollection + Set LinkedHashSet TreeSet SequencedMap<K,V> extends Map; own interface, not a SequencedCollection LinkedHashMap TreeMap firstEntry/lastEntry/putFirst/putLast/sequencedKeySet() are SequencedMap’s names for the same idea on a Map. TreeMap and TreeSet order by comparator, not insertion — “sequenced” means “has an encounter order,” any order.
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:

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).

yourCollection.getLast() default: reversed() .getFirst() Only reversed() is abstract (must be implemented by each class). getFirst/getLast/addFirst/addLast/removeFirst/removeLast are all default methods built on top of it, which is why five unrelated classes picked up the full contract by implementing just one new method each.

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:

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.

one backing array [10, 20, 30, 40] original reversedView add(40) here… …shows up here, same storage
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.copyOf too — 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 the reduce/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 that reversed() 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

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.