CodeGym /Courses /JAVA 25 SELF /Custom Collector and Spliterator

Custom Collector and Spliterator

JAVA 25 SELF
Level 33, Lesson 4
Available

1. Custom collectors: when and how to write your own

In the Java Stream API, the Collector interface is used to transform a stream into a collection or aggregate. Usually, you use the built-in collectors from the Collectors class (toList(), toMap(), groupingBy(), etc.), but sometimes you need something special — and then you can write your own collector.

A Collector is an object that describes how to accumulate stream elements into a final result. It defines four (actually five) key components:

  • supplier — creates a new container for collecting elements (for example, a new list or map).
  • accumulator — adds the next element to the container.
  • combiner — merges two containers (important for parallel streams!).
  • finisher — turns the container into the final result (for example, makes it immutable or converts it to another type).
  • characteristics — a set of flags describing the collector’s properties (for example, whether it supports parallelism, whether it changes the result type, etc.).

Signature:

Collector<T, A, R>
  • T — the type of stream elements,
  • A — the type of the intermediate accumulator,
  • R — the result type.

2. Example: a Collector for a MultiMap (Map<K, List<V>>)

Suppose you want to collect a stream of pairs Pair<K, V> into a Map<K, List<V>> (a multi-map), where each key corresponds to a list of values.

Sample implementation:

public static <K, V> Collector<Pair<K, V>, ?, Map<K, List<V>>> toMultiMap() {
    return Collector.of(
        HashMap::new, // supplier
        (map, pair) -> map.computeIfAbsent(pair.key(), k -> new ArrayList<>()).add(pair.value()), // accumulator
        (map1, map2) -> { // combiner
            map2.forEach((k, vList) -> map1.merge(k, vList, (l1, l2) -> { l1.addAll(l2); return l1; }));
            return map1;
        },
        Function.identity(), // finisher
        Collector.Characteristics.UNORDERED
    );
}

Usage:

List<Pair<String, Integer>> pairs = List.of(
    new Pair<>("a", 1), new Pair<>("b", 2), new Pair<>("a", 3)
);

Map<String, List<Integer>> multiMap = pairs.stream().collect(toMultiMap());
// multiMap: {a=[1, 3], b=[2]}

3. Example: a Collector for the top-N elements

Suppose you want to collect a stream into a list of the N largest elements (for example, top 5 in descending order).

Implementation:

public static <T> Collector<T, ?, List<T>> topN(int n, Comparator<? super T> comparator) {
    return Collector.of(
        () -> new PriorityQueue<>(n, comparator), // supplier
        (pq, t) -> {
            pq.offer(t);
            if (pq.size() > n) pq.poll(); // remove the smallest
        },
        (pq1, pq2) -> {
            pq2.forEach(t -> {
                pq1.offer(t);
                if (pq1.size() > n) pq1.poll();
            });
            return pq1;
        },
        pq -> {
            List<T> result = new ArrayList<>(pq);
            result.sort(comparator.reversed()); // descending
            return result;
        },
        Collector.Characteristics.UNORDERED
    );
}

Usage:

List<Integer> top3 = Stream.of(5, 1, 9, 3, 7, 2).collect(topN(3, Comparator.naturalOrder()));
// top3: [9, 7, 5]

4. When you should NOT write your own Collector

  • If the task can be expressed via a combination of standard collectors and downstream operations (groupingBy, mapping, flatMapping, collectingAndThen, etc.), prefer using them.
  • Write a custom Collector only for truly non-standard scenarios (a special data structure, complex aggregation, top-N, multi-maps, etc.).
  • Do not write a Collector just for the sake of it — it complicates maintenance and testing.

Example:

// Instead of a custom Collector for Map<K, Set<V>>:
.collect(Collectors.groupingBy(
    Pair::key,
    Collectors.mapping(Pair::value, Collectors.toSet())
))

5. Custom Spliterator: why and how

A Spliterator is a special interface for efficiently iterating and splitting collections (or other data sources) into parts, especially for parallel processing. Unlike a regular iterator, a Spliterator can “split” a collection into independent chunks for parallel processing.

Key methods:

  • tryAdvance(Consumer<? super T> action) — process the next element.
  • trySplit() — attempt to split the collection into two parts (returns a new Spliterator for one of the parts).
  • estimateSize() — an estimate of the remaining number of elements.
  • characteristics() — a bitmask of characteristics (ORDERED, SIZED, SUBSIZED, etc.).

trySplit: splitting strategies

Balanced splitting is important for parallel streams: trySplit should return approximately equal-sized parts so that threads are evenly loaded.

If there is nothing meaningful to split (for example, too few elements), return null.

Example: a Spliterator for reading a file in chunks
Suppose you have a large file and want to process it in 1000-line chunks so you don’t keep everything in memory.

public class ChunkedLineSpliterator implements Spliterator<List<String>> {
    private final BufferedReader reader;
    private final int chunkSize;

    public ChunkedLineSpliterator(BufferedReader reader, int chunkSize) {
        this.reader = reader;
        this.chunkSize = chunkSize;
    }

    @Override
    public boolean tryAdvance(Consumer<? super List<String>> action) {
        List<String> chunk = new ArrayList<>(chunkSize);
        try {
            String line;
            for (int i = 0; i < chunkSize && (line = reader.readLine()) != null; i++) {
                chunk.add(line);
            }
            if (chunk.isEmpty()) return false;
            action.accept(chunk);
            return true;
        } catch (IOException e) {
            throw new UncheckedIOException(e);
        }
    }

    @Override
    public Spliterator<List<String>> trySplit() {
        // Splitting does not make sense for streaming file reads — return null
        return null;
    }

    @Override
    public long estimateSize() {
        return Long.MAX_VALUE; // unknown in advance
    }

    @Override
    public int characteristics() {
        return ORDERED | NONNULL;
    }
}

Usage:

try (BufferedReader reader = Files.newBufferedReader(Path.of("big.txt"))) {
    StreamSupport.stream(new ChunkedLineSpliterator(reader, 1000), false)
        .forEach(chunk -> processChunk(chunk));
}

Spliterator characteristics

  • ORDERED — elements have a defined order (for example, a list).
  • SIZED — the exact number of elements is known.
  • SUBSIZED — all Spliterators obtained via trySplit are also SIZED.
  • IMMUTABLE — the source does not change during traversal.
  • CONCURRENT — the source supports safe concurrent modification.
  • DISTINCT, SORTED, NONNULL — additional properties.

Important: specify characteristics correctly — this affects stream optimizations.

6. Examples

  • Reading a file in chunks — allows processing large files piecewise without loading everything into memory.
  • Parsing with minimal allocations — if you parse a byte/char stream and want to minimize temporary object creation, you can implement a Spliterator that yields “windows” or “slices” of the original array.

Example: a Spliterator for parsing CSV line by line

public class CsvLineSpliterator implements Spliterator<String[]> {
    private final BufferedReader reader;

    public CsvLineSpliterator(BufferedReader reader) {
        this.reader = reader;
    }

    @Override
    public boolean tryAdvance(Consumer<? super String[]> action) {
        try {
            String line = reader.readLine();
            if (line == null) return false;
            action.accept(line.split(","));
            return true;
        } catch (IOException e) {
            throw new UncheckedIOException(e);
        }
    }

    @Override
    public Spliterator<String[]> trySplit() {
        return null; // sequential parsing
    }

    @Override
    public long estimateSize() {
        return Long.MAX_VALUE;
    }

    @Override
    public int characteristics() {
        return ORDERED | NONNULL;
    }
}

7. Integration with parallel() — how to do it safely

  • If your Spliterator supports parallel splitting (trySplit does not return null) and its characteristics include SIZED/SUBSIZED, the Stream API can parallelize processing efficiently.
  • For streaming sources (files, sockets), splitting is usually not supported — use sequential streams.
  • For collections and arrays, implement balanced splitting (for example, split an array in half).

Example: a Spliterator for an array

public class ArraySpliterator<T> implements Spliterator<T> {
    private final T[] array;
    private int start, end;

    public ArraySpliterator(T[] array, int start, int end) {
        this.array = array;
        this.start = start;
        this.end = end;
    }

    @Override
    public boolean tryAdvance(Consumer<? super T> action) {
        if (start < end) {
            action.accept(array[start++]);
            return true;
        }
        return false;
    }

    @Override
    public Spliterator<T> trySplit() {
        int mid = (start + end) >>> 1;
        if (mid == start) return null;
        ArraySpliterator<T> split = new ArraySpliterator<>(array, start, mid);
        start = mid;
        return split;
    }

    @Override
    public long estimateSize() {
        return end - start;
    }

    @Override
    public int characteristics() {
        return ORDERED | SIZED | SUBSIZED | IMMUTABLE;
    }
}

Usage:

String[] arr = {"a", "b", "c", "d"};
StreamSupport.stream(new ArraySpliterator<>(arr, 0, arr.length), true)
    .forEach(System.out::println);
1
Task
JAVA 25 SELF, level 33, lesson 4
Locked
Organizing the Library of Ancient Programming Languages 📜
Organizing the Library of Ancient Programming Languages 📜
1
Task
JAVA 25 SELF, level 33, lesson 4
Locked
Zoological Quest: Grouping Animals by Name Length 🦁🐱
Zoological Quest: Grouping Animals by Name Length 🦁🐱
1
Survey/quiz
Optimizing Work with Collections, level 33, lesson 4
Unavailable
Optimizing Work with Collections
Optimizing Work with Collections
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION