sequenced: JEP 431 Sequenced Collections across List/Deque/LinkedHashSet/LinkedHashMap/TreeMap, and the getLast() replacement for the Set find-last stream tricks

This commit is contained in:
Claude
2026-10-01 04:39:39 +00:00
parent 9059df4ad2
commit 64de44fa65
15 changed files with 477 additions and 0 deletions
+1
View File
@@ -21,6 +21,7 @@ article; each module's own README has that article's version table, quickstart,
| [`collections`](collections/) | How to Sort a HashMap by Value (and Key) in Java |
| [`nio2`](nio2/) | Java NIO.2 File API: Files, Path, WatchService, and Streaming Large Files Without OOM |
| [`list-benchmarks`](list-benchmarks/) | ArrayList vs LinkedList in 2026: JMH Benchmarks and Why LinkedList Rarely Wins |
| [`sequenced`](sequenced/) | Sequenced Collections in Java 21+: getFirst, getLast and reversed() |
## License
+1
View File
@@ -29,6 +29,7 @@
<module>collections</module>
<module>nio2</module>
<module>list-benchmarks</module>
<module>sequenced</module>
</modules>
<properties>
+46
View File
@@ -0,0 +1,46 @@
# sequenced
Companion code for the ankurm.com post *"Sequenced Collections in Java 21+: getFirst, getLast and
reversed()."* Module in `java-core-examples`, the Java-core series.
## Versions this was built and tested against
| Component | Version | Notes |
|---|---|---|
| JDK | 25.0.4.1+1 (Temurin, LTS) | JEP 431 shipped in JDK 21 (GA September 2023) and is unchanged since. |
| JUnit Jupiter | 5.11.0 | |
| Maven | 3.9.11 | |
## Quickstart
```bash
export JAVA_HOME=/path/to/jdk-21-or-newer
mvn compile
java -cp target/classes com.ankurm.sequenced.AcrossCollectionsDemo
java -cp target/classes com.ankurm.sequenced.FindLastDemo
java -cp target/classes com.ankurm.sequenced.ReversedViewIsLiveDemo
```
`scripts/run-all.sh` regenerates every file in `output/`. `scripts/run.sh <ClassName>` runs one
demo ad hoc.
## What's in here
| File | What it shows |
|---|---|
| `src/main/java/.../AcrossCollectionsDemo.java` | `getFirst`/`getLast`/`addFirst`/`addLast`/`reversed()` run against `ArrayList`, `ArrayDeque`, `LinkedHashSet`, `LinkedHashMap` and `TreeMap` - the one shared contract across five previously-unrelated types. |
| `src/main/java/.../FindLastDemo.java` | The three pre-Java-21 ways people found "the last element" of a `Set` (two stream tricks and a throwaway copy) next to the one-line `getLast()` replacement, with a note on why the old ways cost more than they looked like they did. |
| `src/main/java/.../ReversedViewIsLiveDemo.java` | `reversed()` is a live view, not a copy - mutating either side is visible through the other, immediately. |
| `src/test/java/.../SequencedCollectionsTest.java` | Pins all of the above as assertions, 6/6 passing. |
| `output/00-javap-sequenced-interfaces.txt` | `javap` output for `SequencedCollection`, `SequencedMap`, `SequencedSet` straight from the compiled JDK - the primary-source check this repo's discipline requires before describing any interface's method list. |
| `output/01-04` | Captured runs of the three demos and the test suite. |
## Notes worth knowing before reading the post
- **`TreeMap`/`TreeSet` already had most of this.** `firstEntry()`/`lastEntry()`/`pollFirstEntry()`/`pollLastEntry()` existed on `NavigableMap` since Java 6; what JEP 431 adds for sorted collections is mostly `reversed()` as a named counterpart to `descendingMap()`/`descendingSet()`, plus the shared interface. The real new capability lands on `LinkedHashSet` and `LinkedHashMap`, which had an iteration order but no vocabulary for "first" or "last" before this.
- **`reversed()` is a view.** See `output/03` - mutating the original after taking `reversed()` is visible through the view and vice versa. This is the most common assumption bug the feature produces.
- **`LinkedHashSet.getLast()` is O(1) in practice**, not by interface contract - it resolves through `SequencedCollection`'s default method path to the reversed view's first element, and `LinkedHashSet`/`LinkedHashMap` already maintain a doubly-linked insertion-order list internally, so there's no O(n) walk. That is an implementation detail of the JDK's `LinkedHashMap`, not a guarantee `SequencedCollection` itself makes for arbitrary implementors.
## License
MIT - see the [repo-wide LICENSE](../LICENSE).
@@ -0,0 +1,28 @@
Compiled from "SequencedCollection.java"
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();
}
Compiled from "SequencedMap.java"
public interface java.util.SequencedMap<K, V> extends java.util.Map<K, V> {
public abstract java.util.SequencedMap<K, V> reversed();
public default java.util.Map$Entry<K, V> firstEntry();
public default java.util.Map$Entry<K, V> lastEntry();
public default java.util.Map$Entry<K, V> pollFirstEntry();
public default java.util.Map$Entry<K, V> pollLastEntry();
public default V putFirst(K, V);
public default V putLast(K, V);
public default java.util.SequencedSet<K> sequencedKeySet();
public default java.util.SequencedCollection<V> sequencedValues();
public default java.util.SequencedSet<java.util.Map$Entry<K, V>> sequencedEntrySet();
}
Compiled from "SequencedSet.java"
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();
}
@@ -0,0 +1,28 @@
=== List (ArrayList implements SequencedCollection) ===
getFirst() = alpha
getLast() = gamma
after addLast("delta"): [alpha, beta, gamma, delta]
reversed() = [delta, gamma, beta, alpha]
=== Deque (ArrayDeque implements SequencedCollection) ===
getFirst() = 1
getLast() = 3
reversed() = [3, 2, 1]
=== LinkedHashSet implements SequencedSet (insertion order preserved) ===
getFirst() = red
getLast() = blue
reversed() = [blue, green, red]
=== 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}
+22
View File
@@ -0,0 +1,22 @@
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
Why the old ways were worse than they looked:
- reduce((a,b)->b) walks the ENTIRE set, every time: O(n).
- skip(size-1).findFirst() also walks the entire stream pipeline up to size-1: O(n).
- new ArrayList<>(set) copies every element just to throw the copy away: O(n) time AND space.
- set.getLast() walks the SequencedCollection's default path to reversed().iterator().next();
for LinkedHashSet specifically, that's backed by LinkedHashMap's existing doubly-linked
insertion-order list, so it resolves in O(1) in the JDK's actual implementation - though
that O(1) is an implementation detail of LinkedHashMap, not a guarantee the interface makes.
On a List this was never actually hard - list.get(list.size()-1) already worked.
list.getLast() = c (same answer, no size() arithmetic, and it now
reads identically whether the underlying type is a List, a Deque, or a LinkedHashSet).
@@ -0,0 +1,15 @@
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
This is a VIEW, confirmed structurally: reversed().reversed() is back to the
same logical order as the original, still backed by the same list:
letters = [x, y, zed]
letters.reversed().reversed() = [x, y, zed] (back to original order, same backing list)
+4
View File
@@ -0,0 +1,4 @@
-------------------------------------------------------------------------------
Test set: com.ankurm.sequenced.SequencedCollectionsTest
-------------------------------------------------------------------------------
Tests run: 6, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.158 s -- in com.ankurm.sequenced.SequencedCollectionsTest
+43
View File
@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.ankurm</groupId>
<artifactId>java-core-examples</artifactId>
<version>1.0</version>
</parent>
<artifactId>sequenced</artifactId>
<name>sequenced</name>
<description>JEP 431 Sequenced Collections: getFirst/getLast/reversed() across List, Deque, LinkedHashSet, LinkedHashMap and TreeMap, and why they replace the "find the last element" stream tricks.</description>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.11.0</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
<configuration>
<release>25</release>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
</plugin>
</plugins>
</build>
</project>
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
javap java.util.SequencedCollection > /tmp/jp1.txt 2>&1
javap java.util.SequencedMap >> /tmp/jp1.txt 2>&1
javap java.util.SequencedSet >> /tmp/jp1.txt 2>&1
grep -vE 'JAVA_TOOL_OPTIONS|Djavax\.net\.ssl|^WARNING' /tmp/jp1.txt > output/00-javap-sequenced-interfaces.txt
mvn -q -B compile
java -cp target/classes com.ankurm.sequenced.AcrossCollectionsDemo 2>&1 \
| grep -vE 'JAVA_TOOL_OPTIONS|Djavax\.net\.ssl|^WARNING' > output/01-across-collections-demo.txt
java -cp target/classes com.ankurm.sequenced.FindLastDemo 2>&1 \
| grep -vE 'JAVA_TOOL_OPTIONS|Djavax\.net\.ssl|^WARNING' > output/02-find-last-demo.txt
java -cp target/classes com.ankurm.sequenced.ReversedViewIsLiveDemo 2>&1 \
| grep -vE 'JAVA_TOOL_OPTIONS|Djavax\.net\.ssl|^WARNING' > output/03-reversed-view-is-live-demo.txt
mvn -q -B test
cp target/surefire-reports/com.ankurm.sequenced.SequencedCollectionsTest.txt output/04-correctness-test.txt
echo "Regenerated output/00-04."
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
# Run one of the demo classes ad hoc, e.g.: ./scripts/run.sh FindLastDemo
set -euo pipefail
cd "$(dirname "$0")/.."
mvn -q -B compile
java -cp target/classes "com.ankurm.sequenced.$1"
@@ -0,0 +1,76 @@
package com.ankurm.sequenced;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.SequencedCollection;
import java.util.SequencedMap;
import java.util.SequencedSet;
import java.util.TreeMap;
/**
* JEP 431 (Java 21) added three interfaces to {@code java.util}: {@link SequencedCollection},
* {@link SequencedSet} and {@link SequencedMap}. This class runs the same five operations -
* {@code getFirst}, {@code getLast}, {@code addFirst}, {@code addLast}, {@code reversed()} -
* against every collection family that picked them up, so the one shared contract is visible
* across types that previously had no common "has an encounter order" interface at all.
*
* <p>Confirmed against the compiled JDK with {@code javap java.util.SequencedCollection} /
* {@code SequencedMap} / {@code SequencedSet} - see {@code output/00-javap-sequenced-interfaces.txt}.
*/
public final class AcrossCollectionsDemo {
private AcrossCollectionsDemo() {}
public static void main(String[] args) {
System.out.println("=== List (ArrayList implements SequencedCollection) ===");
List<String> list = new ArrayList<>(List.of("alpha", "beta", "gamma"));
SequencedCollection<String> listSeq = list; // List<E> extends SequencedCollection<E>
System.out.println("getFirst() = " + listSeq.getFirst());
System.out.println("getLast() = " + listSeq.getLast());
listSeq.addLast("delta");
System.out.println("after addLast(\"delta\"): " + list);
System.out.println("reversed() = " + listSeq.reversed());
System.out.println();
System.out.println("=== Deque (ArrayDeque implements SequencedCollection) ===");
ArrayDeque<Integer> deque = new ArrayDeque<>(List.of(1, 2, 3));
System.out.println("getFirst() = " + deque.getFirst());
System.out.println("getLast() = " + deque.getLast());
System.out.println("reversed() = " + deque.reversed());
System.out.println();
System.out.println("=== LinkedHashSet implements SequencedSet (insertion order preserved) ===");
LinkedHashSet<String> set = new LinkedHashSet<>(List.of("red", "green", "blue"));
SequencedSet<String> setSeq = set;
System.out.println("getFirst() = " + setSeq.getFirst());
System.out.println("getLast() = " + setSeq.getLast());
System.out.println("reversed() = " + setSeq.reversed());
System.out.println();
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());
System.out.println("lastEntry() = " + lhmSeq.lastEntry());
lhmSeq.putFirst("dec", 12); // moves/inserts "dec" at the front
System.out.println("after putFirst(\"dec\", 12): " + lhm);
System.out.println("sequencedKeySet() = " + lhmSeq.sequencedKeySet());
System.out.println("reversed() = " + lhmSeq.reversed());
System.out.println();
System.out.println("=== TreeMap implements SequencedMap too (order = comparator order, not insertion) ===");
TreeMap<Integer, String> tree = new TreeMap<>(Map.of(3, "three", 1, "one", 2, "two"));
SequencedMap<Integer, String> treeSeq = tree;
System.out.println("tree contents (natural order) = " + tree);
System.out.println("firstEntry() = " + treeSeq.firstEntry());
System.out.println("lastEntry() = " + treeSeq.lastEntry());
System.out.println("reversed() = " + treeSeq.reversed());
}
}
@@ -0,0 +1,70 @@
package com.ankurm.sequenced;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
/**
* The specific pain point JEP 431 fixes: before Java 21, {@link Set} had no concept of "first" or
* "last" at all - not because sets are unordered (a {@link LinkedHashSet} very much has a defined
* iteration order) but because {@code Set} the interface never exposed one. The workarounds people
* reached for were stream tricks, and this class runs both the old workaround and the new one-liner
* side by side on the same {@code LinkedHashSet}.
*/
public final class FindLastDemo {
private FindLastDemo() {}
public static void main(String[] args) {
LinkedHashSet<String> visited = new LinkedHashSet<>();
visited.add("home");
visited.add("search");
visited.add("product/42");
visited.add("cart");
visited.add("checkout");
System.out.println("A LinkedHashSet has a real iteration order, but pre-Java-21 Set has no getLast():");
System.out.println(visited);
System.out.println();
// --- Old workaround 1: reduce to the last stream element ---
Optional<String> viaReduce = visited.stream().reduce((first, second) -> second);
System.out.println("Old #1 - stream().reduce((a,b) -> b): " + viaReduce.orElseThrow());
// --- Old workaround 2: skip to the end ---
Optional<String> viaSkip = visited.stream().skip(visited.size() - 1L).findFirst();
System.out.println("Old #2 - stream().skip(size - 1).findFirst(): " + viaSkip.orElseThrow());
// --- Old workaround 3: copy into an indexable list just to index it ---
String viaCopy = new ArrayList<>(visited).get(visited.size() - 1);
System.out.println("Old #3 - new ArrayList<>(set).get(size-1): " + viaCopy);
// --- New: Java 21+, Set extends SequencedCollection via SequencedSet ---
String viaGetLast = visited.getLast();
System.out.println("New - set.getLast(): " + viaGetLast);
System.out.println();
boolean allFourAgree = viaReduce.get().equals(viaSkip.get())
&& viaSkip.get().equals(viaCopy)
&& viaCopy.equals(viaGetLast);
System.out.println("All four agree: " + allFourAgree);
System.out.println();
System.out.println("Why the old ways were worse than they looked:");
System.out.println(" - reduce((a,b)->b) walks the ENTIRE set, every time: O(n).");
System.out.println(" - skip(size-1).findFirst() also walks the entire stream pipeline up to size-1: O(n).");
System.out.println(" - new ArrayList<>(set) copies every element just to throw the copy away: O(n) time AND space.");
System.out.println(" - set.getLast() walks the SequencedCollection's default path to reversed().iterator().next();");
System.out.println(" for LinkedHashSet specifically, that's backed by LinkedHashMap's existing doubly-linked");
System.out.println(" insertion-order list, so it resolves in O(1) in the JDK's actual implementation - though");
System.out.println(" that O(1) is an implementation detail of LinkedHashMap, not a guarantee the interface makes.");
List<String> asList = List.of("a", "b", "c");
System.out.println();
System.out.println("On a List this was never actually hard - list.get(list.size()-1) already worked.");
System.out.println("list.getLast() = " + asList.getLast() + " (same answer, no size() arithmetic, and it now");
System.out.println("reads identically whether the underlying type is a List, a Deque, or a LinkedHashSet).");
}
}
@@ -0,0 +1,46 @@
package com.ankurm.sequenced;
import java.util.ArrayList;
import java.util.List;
import java.util.SequencedCollection;
/**
* {@code reversed()} returns a <strong>view</strong>, not a copy - mutations through either the
* original collection or the reversed view are visible through the other, immediately. This is
* the single most common assumption bug this topic produces: code that does
* {@code var snapshot = list.reversed();} and expects {@code snapshot} to be independent of later
* changes to {@code list}.
*/
public final class ReversedViewIsLiveDemo {
private ReversedViewIsLiveDemo() {}
public static void main(String[] args) {
List<Integer> original = new ArrayList<>(List.of(10, 20, 30));
SequencedCollection<Integer> reversedView = original.reversed();
System.out.println("original = " + original);
System.out.println("reversedView = " + reversedView);
System.out.println();
System.out.println("Mutating the ORIGINAL list (original.add(40)):");
original.add(40);
System.out.println("original = " + original);
System.out.println("reversedView = " + reversedView + " <- updated with no code touching reversedView directly");
System.out.println();
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("reversedView = " + reversedView);
System.out.println("original = " + original + " <- the 0 landed at the END of the original list");
System.out.println();
System.out.println("This is a VIEW, confirmed structurally: reversed().reversed() is back to the");
System.out.println("same logical order as the original, still backed by the same list:");
List<String> letters = new ArrayList<>(List.of("x", "y", "zed"));
var once = letters.reversed();
var twice = once.reversed();
System.out.println("letters = " + letters);
System.out.println("letters.reversed().reversed() = " + twice + " (back to original order, same backing list)");
}
}
@@ -0,0 +1,70 @@
package com.ankurm.sequenced;
import org.junit.jupiter.api.Test;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.SequencedCollection;
import java.util.SequencedMap;
import java.util.TreeMap;
import static org.junit.jupiter.api.Assertions.assertEquals;
class SequencedCollectionsTest {
@Test
void listIsASequencedCollection() {
List<String> list = new ArrayList<>(List.of("a", "b", "c"));
assertEquals("a", list.getFirst());
assertEquals("c", list.getLast());
assertEquals(List.of("c", "b", "a"), list.reversed());
}
@Test
void linkedHashSetGetsFirstAndLastThatPlainSetNeverHad() {
LinkedHashSet<String> set = new LinkedHashSet<>(List.of("one", "two", "three"));
assertEquals("one", set.getFirst());
assertEquals("three", set.getLast());
}
@Test
void linkedHashMapPutFirstReordersInsteadOfJustInserting() {
LinkedHashMap<String, Integer> map = new LinkedHashMap<>();
map.put("b", 2);
map.put("c", 3);
((SequencedMap<String, Integer>) map).putFirst("a", 1);
// putFirst moves "a" to the front of the iteration order, not just adds it
assertEquals(List.of("a", "b", "c"), new ArrayList<>(map.keySet()));
}
@Test
void treeMapOrderIsComparatorOrderNotInsertionOrder() {
TreeMap<Integer, String> tree = new TreeMap<>(Map.of(3, "three", 1, "one", 2, "two"));
SequencedMap<Integer, String> seq = tree;
assertEquals(1, seq.firstEntry().getKey());
assertEquals(3, seq.lastEntry().getKey());
}
@Test
void reversedIsALiveViewNotACopy() {
List<Integer> list = new ArrayList<>(List.of(1, 2, 3));
SequencedCollection<Integer> view = list.reversed();
list.add(4);
assertEquals(List.of(4, 3, 2, 1), view, "the view must see the mutation on the original");
view.addFirst(0); // "first of reversed" = "last of original"
assertEquals(List.of(1, 2, 3, 4, 0), list, "the original must see the mutation through the view");
}
@Test
void arrayDequeImplementsSequencedCollectionToo() {
ArrayDeque<String> deque = new ArrayDeque<>(List.of("x", "y", "z"));
assertEquals("x", deque.getFirst());
assertEquals("z", deque.getLast());
// reversed() flips which end is "first" - the original's last is the reversed view's first.
assertEquals(deque.getLast(), deque.reversed().getFirst());
}
}