Java Streams

4 minute read

What is a Stream?

  • A stream in Java is a sequence of data that takes input from Collections or IO Channels.
  • Streams don’t change the original data structure.
  • A Stream Pipeline is the operation (STREAM OPERATIONS) that run on a stream to produce a result.
  • Each intermediate operation is lazily executed and returns a stream as a result.
  • Terminal operations mark the end of the stream and return the result.
  • Finite Streams have a limit.
  • Infinite Streams are like sunrise/sunset cycles.

Important Notes

  • Re-stream a List each time: Don’t re-use a stream because “creating” a Stream just points at the existing data structure behind the scenes; it does not copy the data.
  • Do not support indexed access: findFirst() can give the top element, but not the second, third, or last element.
  • Simple syntax to build a List or array from a Stream.
  • In Streams, data flow downstream from the source to the terminal operation, where as control flows upstream from the terminal operation to the source.

Three Common Ways to Create a Stream

  1. From ArrayList
    List<Student> students = new ArrayList<>();
    Stream<Student> studentStream = students.stream();
    
  2. From Array of Objects (not array of primitives)
    Student[] students = {....};
    Stream<Student> studentStream = Stream.of(students).map().filter().other(); // No Terminal Operator
    
  3. From Individual Elements
    Student s1 = ;
    Student s2 = ;
       
    Stream<Student> studentStream = Stream.of(s1, s2, ...).map().filter().other(); // No Terminal Operator
    

Outputting Streams

Getting a List out of a Stream

List<SomeClass> list = someStream.map().collect(Collectors.toList());

Getting an Array out of a Stream

// Fill elements into an Array from a Stream
Student[] studentArray = someStream.map(someLambda).toArray(Student[]::new);
String[] strArray = stringStream.filter(...).map(...).toArray(String[]::new);

What Cannot be Done with Streams forEach

  1. Multiple Loops are not possible as forEach is a Terminal operation consuming a Stream.
  2. Local variable modification:
    list.stream().forEach(e -> total += e);
    
    • Do this with map and reduce.
    • Or use the built-in sum method of DoubleStream or IntStream.
  3. Cannot use break or return within forEach loop.

Stream Operations

  • SOURCE: Where the stream comes from.

  • INTERMEDIATE OPERATIONS: These transform a stream into another stream. Streams use lazy evaluation, meaning intermediate operations are not executed until a terminal operation is invoked.
  • Common Intermediate Operations:
    • filter(Predicate<T> predicate)
      Selects elements that match the given predicate.
      Example: stream.filter(x -> x > 10)

    • map(Function<T, R> mapper)
      Transforms each element using the provided function.
      Example: stream.map(String::toUpperCase)

    • flatMap(Function<T, Stream<R>> mapper)
      Flattens nested structures by mapping each element to a stream and then flattening the result.
      Example: stream.flatMap(list -> list.stream())

    • distinct()
      Removes duplicate elements from the stream (based on equals() method).
      Example: stream.distinct()

    • limit(long maxSize)
      Truncates the stream to contain no more than maxSize elements.
      Example: stream.limit(5)

    • peek(Consumer<T> action)
      Performs the given action on each element as they are consumed from the stream. Useful for debugging.
      Example: stream.peek(System.out::println)

    • sorted() / sorted(Comparator<T> comparator)
      Returns a stream with elements sorted in natural order or using a custom comparator.
      Example: stream.sorted() or stream.sorted(Comparator.reverseOrder())

  • Terminal Operations : These operations produce a result or a side-effect and consume the stream, making it unusable afterward. They trigger the processing of all intermediate operations.
  • Common Terminal Operations:
    • collect(Collector<T, A, R> collector)
      Performs a mutable reduction operation on the elements of the stream and returns a collection or another result container.
      Example: stream.collect(Collectors.toList())

    • forEach(Consumer<T> action)
      Performs the given action for each element of the stream.
      Example: stream.forEach(System.out::println)

    • reduce(BinaryOperator<T> accumulator)
      Reduces the stream to a single value using an associative accumulation function.
      Example: stream.reduce((a, b) -> a + b)

    • count()
      Returns the number of elements in the stream.
      Example: stream.count()

    • min(Comparator<T> comparator)
      Returns the minimum element of the stream according to the provided comparator, wrapped in an Optional.
      Example: stream.min(Comparator.naturalOrder())

    • max(Comparator<T> comparator)
      Returns the maximum element of the stream according to the provided comparator, wrapped in an Optional.
      Example: stream.max(Comparator.naturalOrder())

    • toArray()
      Returns an array containing the elements of the stream.
      Example: stream.toArray()

    • anyMatch(Predicate<T> predicate)
      Returns true if any elements of the stream match the provided predicate.
      Example: stream.anyMatch(x -> x > 10)

    • allMatch(Predicate<T> predicate)
      Returns true if all elements of the stream match the provided predicate.
      Example: stream.allMatch(x -> x > 0)

    • noneMatch(Predicate<T> predicate)
      Returns true if no elements of the stream match the provided predicate.
      Example: stream.noneMatch(x -> x < 0)

    • findFirst()
      Returns the first element of the stream, if present, wrapped in an Optional.
      Example: stream.findFirst()

    • findAny()
      Returns any element of the stream, useful in parallel streams, wrapped in an Optional.
      Example: stream.findAny()


Map vs FlatMap

Employee with Address Object

@Data
class Address {
    private String city;
}

Using map

Scenario: Transforming a list of Employee objects to a list of their names.

class Employee {
    private Address address;//One Address Object
}
public void testMain(List<Employee> employees) {

    //Extract the address of every employhee
    List<Address> addreses = employees.stream()
        .map(Employee::address)
        .collect(Collectors.toList());
}

Using flatMap

merge multiple streams into one

Stream<String> stream1 = Stream.of("a", "b");
Stream<String> stream2 = Stream.of("c", "d");

Stream<Stream<String>> streams = Stream.of(stream1, stream2);

List<String> combined = streams
    .flatMap(s -> s)
    .collect(Collectors.toList());

Scenario: Given a list of Employee objects, each with a list of Address objects within it, return a single list of all addresses.

class Employee {
    private List<Address> addresses;//Each employee has multiple addresses, including empty
}

public void testFlatMap(List<Employee> employees) {
    List<Address> allAddresses = employees.stream()
        .flatMap(employee -> employee.getAddresses().stream())//Flatmap needs a Stream that it can stitch together.
        .collect(Collectors.toList());
}

Streams with Map

The entry set returns a collection (Set) which can then be used to create the stream

Map<String, Integer> filteredMap = map.entrySet()
    .stream()
    .filter(entry -> entry.getValue() > 1)
    .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));