Ch 14.1 Introduction to Streams
Introduced in Java 8, the Stream API allows developers to process sequences of elements (arrays, Lists, Sets, Maps, etc.) declaratively by applying functional interfaces (lambda expressions) in an iterative manner.
Streams dramatically reduce the complexity and boilerplate code of traditional for loops and Iterator usage, enabling data manipulation in a declarative style similar to database queries.
1. Collections vs Streams
import java.util.*;
import java.util.stream.*;
public class CollectionVsStreamExample {
public static void main(String[] args) {
List<Integer> numbers = Arrays.asList(5, 3, 8, 1, 9, 2, 7, 4, 6);
// Traditional approach: imperative (focus on "How")
List<Integer> result1 = new ArrayList<>();
for (int n : numbers) {
if (n > 5) {
result1.add(n * 2);
}
}
Collections.sort(result1);
System.out.println("Traditional: " + result1);
// Stream approach: declarative (focus on "What")
List<Integer> result2 = numbers.stream()
.filter(n -> n > 5)
.map(n -> n * 2)
.sorted()
.collect(Collectors.toList());
System.out.println("Stream: " + result2);
}
}
| Aspect | Collection | Stream |
|---|---|---|
| Purpose | Store and manage data | Data processing pipeline |
| Iteration | External (for, iterator) | Internal (automatic) |
| Data modification | Possible (add, remove, etc.) | Not possible (original unchanged) |
| Reusability | Reusable | Single-use (one-shot) |
| Lazy evaluation | None | Yes (Lazy) |
2. The Three-Stage Stream Pipeline
Stream operations flow through multiple stages, like water through a pipe.
Source → Intermediate Operations → Terminal Operation
List<String> names = Arrays.asList("Alice", "Bob", "Charlie", "David", "Eve");
long count = names.stream() // 1. Source: create stream
.filter(n -> n.length() > 3) // 2. Intermediate: filter
.map(String::toUpperCase) // 2. Intermediate: transform
.count(); // 3. Terminal: count elements
System.out.println("Names with length > 3: " + count); // 3
3. Lazy Evaluation
This is one of the most important characteristics of streams. No intermediate operation is actually executed until a terminal operation is invoked.
import java.util.stream.Stream;
public class LazyEvaluationExample {
public static void main(String[] args) {
System.out.println("Building stream pipeline...");
Stream<Integer> stream = Stream.of(1, 2, 3, 4, 5)
.filter(n -> {
System.out.println(" filter executing: " + n);
return n > 2;
})
.map(n -> {
System.out.println(" map executing: " + n);
return n * 10;
});
System.out.println("Nothing has executed yet before the terminal operation!");
System.out.println("\n--- Terminal operation called ---");
stream.forEach(n -> System.out.println("Result: " + n));
}
}
Output:
Building stream pipeline...
Nothing has executed yet before the terminal operation!
--- Terminal operation called ---
filter executing: 1
filter executing: 2
filter executing: 3
map executing: 3
Result: 30
filter executing: 4
map executing: 4
Result: 40
filter executing: 5
map executing: 5
Result: 50
Thanks to lazy evaluation, when filter returns false for an element, map is never called for that element. Each element passes through the pipeline individually, avoiding unnecessary computation.
4. Creating Stream Sources
From Collections and Arrays
import java.util.*;
import java.util.stream.*;
public class StreamSourceExample {
public static void main(String[] args) {
// 1. Stream from a List
List<String> list = Arrays.asList("Java", "Python", "Go");
Stream<String> listStream = list.stream();
listStream.forEach(System.out::println);
// 2. Stream from an array
String[] arr = {"A", "B", "C"};
Stream<String> arrayStream = Arrays.stream(arr);
// Stream from a portion of an array
Stream<String> partialStream = Arrays.stream(arr, 1, 3); // index 1~2
// 3. Stream.of() direct creation
Stream<Integer> directStream = Stream.of(1, 2, 3, 4, 5);
directStream.forEach(n -> System.out.print(n + " "));
System.out.println();
// 4. Empty stream
Stream<String> emptyStream = Stream.empty();
System.out.println("Empty stream size: " + emptyStream.count()); // 0
}
}
Creating Infinite Streams
import java.util.stream.*;
public class InfiniteStreamExample {
public static void main(String[] args) {
// Stream.iterate(): generates next value from previous value
System.out.println("Fibonacci sequence (10 elements):");
Stream.iterate(new long[]{0, 1}, f -> new long[]{f[1], f[0] + f[1]})
.limit(10)
.map(f -> f[0])
.forEach(n -> System.out.print(n + " "));
System.out.println();
// Stream.generate(): generates a new value each time (supplier)
System.out.println("5 random numbers:");
Stream.generate(Math::random)
.limit(5)
.map(n -> String.format("%.3f", n))
.forEach(n -> System.out.print(n + " "));
System.out.println();
// Java 9+ Stream.iterate with predicate (termination condition)
System.out.println("Powers of 2 (under 1000):");
Stream.iterate(1, n -> n < 1000, n -> n * 2)
.forEach(n -> System.out.print(n + " "));
System.out.println();
}
}
5. Primitive Streams
Using primitive streams instead of generic Stream<Integer> eliminates the cost of auto-boxing/unboxing.
import java.util.stream.*;
public class PrimitiveStreamExample {
public static void main(String[] args) {
// IntStream: dedicated stream for int
IntStream intStream = IntStream.range(1, 6); // 1, 2, 3, 4, 5
IntStream closedStream = IntStream.rangeClosed(1, 5); // 1, 2, 3, 4, 5
// Sum, average, max, min
IntStream.rangeClosed(1, 10).sum(); // 55
IntStream.rangeClosed(1, 10).average(); // OptionalDouble[5.5]
IntStream.rangeClosed(1, 10).max(); // OptionalInt[10]
IntStream.rangeClosed(1, 10).min(); // OptionalInt[1]
// LongStream and DoubleStream work the same way
LongStream longStream = LongStream.rangeClosed(1L, 1_000_000L);
System.out.println("Sum of 1 to 1 million: " + longStream.sum()); // 500000500000
// Converting between primitive and object streams
IntStream intS = IntStream.range(1, 4);
Stream<Integer> boxed = intS.boxed(); // int -> Integer
Stream<String> stringS = IntStream.range(1, 4).mapToObj(i -> "Item-" + i);
// From Stream<Integer> to IntStream
Stream<Integer> objStream = Stream.of(1, 2, 3, 4, 5);
int sum = objStream.mapToInt(Integer::intValue).sum();
System.out.println("Sum: " + sum);
}
}
6. Key Stream Characteristics
Single-Use (One-shot)
Stream<String> stream = Stream.of("A", "B", "C");
stream.forEach(System.out::println); // works fine
// stream.forEach(System.out::println); // IllegalStateException! Stream cannot be reused
Non-modifying (Immutable Source)
List<String> original = new ArrayList<>(Arrays.asList("a", "b", "c"));
List<String> upperCase = original.stream()
.map(String::toUpperCase)
.collect(Collectors.toList());
System.out.println("Original: " + original); // [a, b, c] (unchanged)
System.out.println("Transformed: " + upperCase); // [A, B, C]
7. Practical Example: Average Score of Students
import java.util.*;
import java.util.stream.*;
class Student {
private String name;
private String major;
private int score;
private int grade;
public Student(String name, String major, int score, int grade) {
this.name = name;
this.major = major;
this.score = score;
this.grade = grade;
}
public String getName() { return name; }
public String getMajor() { return major; }
public int getScore() { return score; }
public int getGrade() { return grade; }
@Override
public String toString() {
return String.format("Student{%s, %s, %d pts, grade %d}",
name, major, score, grade);
}
}
public class StudentStreamExample {
public static void main(String[] args) {
List<Student> students = Arrays.asList(
new Student("Alice", "Computer Science", 95, 3),
new Student("Bob", "Mathematics", 78, 2),
new Student("Charlie", "Computer Science", 88, 1),
new Student("David", "Physics", 65, 4),
new Student("Eve", "Computer Science", 92, 2),
new Student("Frank", "Mathematics", 83, 3),
new Student("Grace", "Physics", 71, 1)
);
// Overall average score
OptionalDouble avgScore = students.stream()
.mapToInt(Student::getScore)
.average();
System.out.printf("Overall average: %.1f pts%n", avgScore.getAsDouble());
// Average score for Computer Science students
double csAvg = students.stream()
.filter(s -> s.getMajor().equals("Computer Science"))
.mapToInt(Student::getScore)
.average()
.orElse(0.0);
System.out.printf("CS average: %.1f pts%n", csAvg);
// Names of students scoring 80+ (sorted descending)
List<String> highScorers = students.stream()
.filter(s -> s.getScore() >= 80)
.sorted(Comparator.comparingInt(Student::getScore).reversed())
.map(Student::getName)
.collect(Collectors.toList());
System.out.println("80+ scores (descending): " + highScorers);
// Top scorer by grade
Map<Integer, Optional<Student>> topByGrade = students.stream()
.collect(Collectors.groupingBy(
Student::getGrade,
Collectors.maxBy(Comparator.comparingInt(Student::getScore))
));
topByGrade.forEach((grade, top) ->
System.out.printf("Grade %d top: %s%n", grade, top.map(Student::getName).orElse("none"))
);
}
}
Pro tip: Streams internally use Spliterator. Using parallelStream() enables parallel processing across multiple CPU cores, but performance gains only occur when there is no shared state and the data set is large enough. parallelStream() is not always faster.