For a long time, my Java code for "take this list, keep the long names, make them uppercase, collect the result" was six lines of loop, an if and a temporary list. Then I saw this:
names.stream().filter(n -> n.length() > 2).map(String::toUpperCase).toList();
One line. It read almost like English, but it also raised questions. What is a stream? What does String::toUpperCase mean? And why do some methods in this style return something called Optional instead of just the value?
In this post, we'll answer those questions in order: method references and constructor references (the :: syntax), the Stream API, and the Optional class. They fit together, so by the end you'll be able to read and write the one-liner above with confidence. If you haven't read about lambdas, skim that first, because everything here builds on them.
All the snippets assume these imports and go inside a main method unless I show a full class:
import java.util.*;
import java.util.function.*;
import java.util.stream.*;
Part 1: Method references and constructor references
A method reference is a shorter way to write a lambda that does nothing except call one existing method. You write the class (or object), then ::, then the method name, with no brackets.
s -> s.length() // lambda
String::length // method reference, same meaning
There are four kinds. Here they are with their lambda twins:
KIND REFERENCE SAME AS THIS LAMBDA
1. static method Integer::parseInt s -> Integer.parseInt(s)
2. method of a given object prefix::concat s -> prefix.concat(s)
3. method of "any" object String::length s -> s.length()
of a type
4. constructor Person::new n -> new Person(n)
Let's see them one by one:
// 1. static method
Function<String, Integer> parse = Integer::parseInt;
System.out.println(parse.apply("42") + 1);
// 2. method of a specific object we already have
String prefix = "Hello, ";
Function<String, String> greet = prefix::concat;
System.out.println(greet.apply("Asha"));
// 3. method of an object that will be given to us later
Function<String, Integer> len = String::length;
System.out.println(len.apply("lambda"));
BiFunction<String, String, Boolean> same = String::equalsIgnoreCase;
System.out.println(same.apply("Java", "JAVA"));
Output:
43
Hello, Asha
6
true
Kind 3 is the one that confuses people. In String::length, no object is named, so where does the string come from? From the first parameter of the functional interface. Function<String, Integer> gives us one String, and Java calls .length() on it. In String::equalsIgnoreCase, the first parameter is the object, and the second is the argument: (a, b) -> a.equalsIgnoreCase(b).
Constructor references
A constructor reference uses ::new. It's a lambda that creates an object:
class Person {
private final String name;
Person(String name) {
this.name = name;
}
@Override
public String toString() {
return "Person(" + name + ")";
}
}
Function<String, Person> maker = Person::new;
Supplier<List<String>> listMaker = ArrayList::new;
IntFunction<int[]> arrayMaker = int[]::new;
System.out.println(maker.apply("Asha"));
List<String> list = listMaker.get();
list.add("x");
System.out.println(list);
System.out.println(arrayMaker.apply(3).length);
Output:
Person(Asha)
[x]
3
How does Java know which constructor Person::new means? It looks at the functional interface. Function<String, Person> takes a String, so Java picks Person(String). Supplier<List<String>> takes nothing, so ArrayList::new means the no-argument constructor. The same words, ArrayList::new, can mean different constructors depending on the target type.
Behind the scenes
Method references work like lambdas. The compiler doesn't create a separate class for them. It writes an invokedynamic instruction, and the JVM builds the function object at run time. The compiler picks the method by matching the parameter types of the functional interface. If two methods could fit, you get an "ambiguous method reference" error, and the fix is to write a normal lambda.
Use a method reference when it makes the code shorter and clearer. If the lambda does more than one call, or the reference looks cryptic, keep the arrow form.
Part 2: The Stream API
What is a stream?
A stream is a pipeline for processing a sequence of data, usually from a collection. It's not a data structure. It doesn't store anything, and it never changes the original collection. Think of a factory conveyor belt: items come in, pass through stations and come out the other end.
Every stream pipeline has three parts:
SOURCE INTERMEDIATE operations TERMINAL operation
(where data --> (each one returns a stream) --> (produces a result
comes from) and starts the work)
list.stream() --> filter(...) --> map(...) --> sorted() --> collect(...) / count() / forEach(...)
-
Source:
list.stream(),Arrays.stream(array),Stream.of(...),IntStream.rangeClosed(1, 5)and others. -
Intermediate operations:
filter,map,sorted,distinct,limit,skip,flatMap. Each one returns a new stream, so you can chain them. -
Terminal operation:
collect,toList,forEach,count,sum,reduce,min,max,findFirst,anyMatchand others. It ends the pipeline.
From a loop to a stream
Here's the task from my intro, done the old way and the stream way:
List<String> names = List.of("Meera", "Jo", "Ravi", "Asha", "Jo");
// the old way
List<String> result = new ArrayList<>();
for (String n : names) {
if (n.length() > 2) {
result.add(n.toUpperCase());
}
}
// the stream way
List<String> result2 = names.stream()
.filter(n -> n.length() > 2)
.map(String::toUpperCase)
.toList();
System.out.println(result2);
Output:
[MEERA, RAVI, ASHA]
-
filterkeeps the items where the condition is true (aPredicate). -
maptransforms each item into something else (aFunction). -
toList()collects the result into a list. (It needs Java 16 or newer. In older versions, write.collect(Collectors.toList()).)
The stream version says what you want. The loop says how to do it step by step.
The operations you'll use most
List<Integer> nums = List.of(5, 3, 8, 3, 1, 9, 8);
System.out.println(nums.stream().distinct().sorted().limit(4).toList());
System.out.println(nums.stream().filter(n -> n > 4).count());
System.out.println(nums.stream().mapToInt(Integer::intValue).sum());
System.out.println(nums.stream().reduce(0, Integer::sum));
System.out.println(nums.stream().max(Integer::compare));
System.out.println(nums.stream().anyMatch(n -> n > 8));
Output:
[1, 3, 5, 8]
4
37
37
Optional[9]
true
Let's read them:
-
distinct().sorted().limit(4)removed duplicates, sorted, then kept the first four. -
count()counted the numbers bigger than 4 (5, 8, 9, 8). -
mapToInt(...).sum()andreduce(0, Integer::sum)both add everything up.reducerepeatedly combines two values into one, starting from0. -
max(...)returns anOptional, which we'll meet in the next part. Here it printed asOptional[9]. -
anyMatchchecks whether at least one item passes. (allMatchandnoneMatchwork the same way.)
A tip: for numbers, prefer mapToInt(...) and the primitive streams (IntStream, LongStream, DoubleStream). They avoid the boxing cost we discussed in the wrapper classes topic, and they give you sum(), average() and similar methods for free.
Collecting results: Collectors
collect takes a collector, a recipe for building the final result:
List<String> names = List.of("Meera", "Jo", "Ravi", "Asha", "Jo");
System.out.println(names.stream().distinct()
.collect(Collectors.joining(", ", "[", "]")));
Map<Integer, List<String>> byLength = names.stream().distinct()
.collect(Collectors.groupingBy(String::length, TreeMap::new, Collectors.toList()));
System.out.println(byLength);
Map<String, Long> frequency = names.stream()
.collect(Collectors.groupingBy(n -> n, TreeMap::new, Collectors.counting()));
System.out.println(frequency);
Output:
[Meera, Jo, Ravi, Asha]
{2=[Jo], 4=[Ravi, Asha], 5=[Meera]}
{Asha=1, Jo=2, Meera=1, Ravi=1}
-
joiningglued the names into one string. -
groupingBymade a map from each key (the length) to the list of items that share it. -
countingcounted the items in each group.
Look closely at TreeMap::new. That's a constructor reference from earlier, used to say "put the result in a TreeMap," so the keys come out sorted.
flatMap: flattening nested lists
When each item turns into several items, or you have a list of lists, use flatMap:
List<List<Integer>> nested = List.of(List.of(1, 2), List.of(3), List.of(4, 5));
List<Integer> flat = nested.stream()
.flatMap(List::stream)
.toList();
System.out.println(flat);
Output:
[1, 2, 3, 4, 5]
map would give you a stream of streams. flatMap merges them into one.
Streams are lazy
This is the most surprising thing about streams. Intermediate operations do nothing until a terminal operation starts the pipeline:
Stream<String> s = Stream.of("a", "bb", "ccc")
.filter(x -> {
System.out.println("filter " + x);
return x.length() > 1;
});
System.out.println("Nothing printed yet");
s.forEach(x -> System.out.println("got " + x));
Output:
Nothing printed yet
filter a
filter bb
got bb
filter ccc
got ccc
Two lessons are hidden here:
-
Nothing ran when we built the pipeline. The
filtercode only ran afterforEach, the terminal operation, started the work. - Each item travels through the whole pipeline before the next one starts. It's not "filter everything, then print everything." It's item by item:
"a" -> filter (fails) -> stops here
"bb" -> filter (passes) -> forEach prints "got bb"
"ccc" -> filter (passes) -> forEach prints "got ccc"
Laziness also allows short-circuiting. Operations like findFirst and limit can stop early:
Stream.of(1, 2, 3, 4, 5)
.filter(n -> {
System.out.println("check " + n);
return n % 2 == 0;
})
.findFirst()
.ifPresent(n -> System.out.println("first even: " + n));
Output:
check 1
check 2
first even: 2
It never even looked at 3, 4 or 5.
Streams can be used only once
After a terminal operation, the stream is finished:
Stream<Integer> s = Stream.of(1, 2, 3);
s.forEach(System.out::println);
s.forEach(System.out::println);
1
2
3
Exception in thread "main" java.lang.IllegalStateException: stream has already been operated upon or closed
If you need it again, create a new stream from the source.
Behind the scenes
When you chain filter(...) and map(...), Java doesn't process any data. It just builds a chain of stages, like a recipe. Only when the terminal operation runs does it pull items from the source through all the stages. Because of this, the stages can be fused into one pass over the data, and short-circuit operations can stop early. A few operations, like sorted(), need to see all items before they can pass any on, so they act as a wall in the pipeline.
Also, the lambdas you write become the standard functional interfaces we learned about: Predicate for filter, Function for map, Consumer for forEach.
Two rules keep streams safe: don't change the source while a pipeline runs, and avoid side effects inside the lambdas (like adding to an outside list). Use collect to build results instead.
You may also see .parallelStream(), which splits the work across several threads. It can speed up big, CPU-heavy jobs, but for small data it's usually slower, and shared mutable state can cause bugs. Don't reach for it by default.
Part 3: The Optional class
The problem: null
You've probably met this error many times:
Exception in thread "main" java.lang.NullPointerException
It happens when a method returns null ("no result") and the caller forgets to check. The method signature String findEmail(String user) doesn't tell you that null is possible. You only find out when the program crashes.
Optional<T> fixes this by making "maybe no value" visible in the type. It's a small container that either holds one value or is empty. When you see Optional<String> as a return type, you know right away: "there might be nothing here, and I must deal with that."
Creating an Optional
Optional<String> a = Optional.of("Asha"); // value must NOT be null
Optional<String> b = Optional.ofNullable(null); // null becomes "empty"
Optional<String> c = Optional.empty(); // explicitly empty
System.out.println(a);
System.out.println(b);
Output:
Optional[Asha]
Optional.empty
Use ofNullable when the value might be null. Optional.of(null) throws a NullPointerException immediately.
Using an Optional
Don't pull the value out and test it by hand. Use the methods that handle both cases for you:
Optional<String> name = Optional.of("Asha");
Optional<String> none = Optional.empty();
System.out.println(name.map(String::toUpperCase).orElse("unknown"));
System.out.println(none.map(String::toUpperCase).orElse("unknown"));
name.ifPresent(n -> System.out.println("Hello " + n));
none.ifPresentOrElse(
n -> System.out.println("Hello " + n),
() -> System.out.println("No name"));
Output:
ASHA
unknown
Hello Asha
No name
The main tools:
-
map(...)transforms the value if there is one, and does nothing if empty. -
filter(...)keeps the value only if it passes a test. -
orElse(default)gives the value, or a default. -
orElseGet(supplier)gives the value, or calls a supplier to make a default. -
orElseThrow(...)gives the value, or throws an exception you choose. -
ifPresent(...)andifPresentOrElse(...)run code depending on the case. -
isPresent()andisEmpty()check without reading. -
get()returns the value, but throwsNoSuchElementExceptionif empty. Avoid it.
A real example: a method that returns Optional
static Optional<String> findEmail(String user) {
Map<String, String> emails = Map.of("asha", "asha@example.com");
return Optional.ofNullable(emails.get(user));
}
System.out.println(findEmail("asha").orElse("no email"));
System.out.println(findEmail("ravi").orElse("no email"));
findEmail("ravi").ifPresentOrElse(
e -> System.out.println("Email: " + e),
() -> System.out.println("Missing"));
Output:
asha@example.com
no email
Missing
Notice that the caller can't forget the empty case. The type forces the question.
Chaining instead of nested null checks
Optional shines when you chain steps that each might fail:
static String clean(String input) {
return Optional.ofNullable(input)
.map(String::trim)
.filter(s -> !s.isEmpty())
.map(String::toUpperCase)
.orElse("EMPTY");
}
System.out.println(clean(" hi "));
System.out.println(clean(" "));
System.out.println(clean(null));
Output:
HI
EMPTY
EMPTY
The same logic with if (input != null) { ... } would take several nested checks. Here, if any step fails (null, or blank after trimming), the whole chain turns into "empty" and falls to orElse.
orElse vs orElseGet: a hidden trap
static String expensive() {
System.out.println("computing default");
return "default";
}
Optional<String> present = Optional.of("value");
System.out.println(present.orElse(expensive()));
System.out.println(present.orElseGet(() -> expensive()));
Output:
computing default
value
value
Even though the Optional had a value, the first line still ran expensive(). With orElse, Java computes the argument before the call, whether it's needed or not. With orElseGet, you pass a Supplier, and it runs only if the Optional is empty. For cheap constants, orElse is fine. For anything that does real work, use orElseGet.
Optional and streams
You already met Optional in the stream section: findFirst, max, min and reduce (the version without a starting value) all return one, because a stream might be empty:
List<Integer> nums = List.of(5, 3, 8, 3, 1, 9, 8);
System.out.println(nums.stream().filter(n -> n > 100).findFirst());
System.out.println(nums.stream().mapToInt(Integer::intValue).average());
System.out.println(nums.stream().mapToInt(Integer::intValue).average().orElse(0));
Output:
Optional.empty
OptionalDouble[5.285714285714286]
5.285714285714286
Primitive streams have their own versions: OptionalInt, OptionalLong and OptionalDouble.
Behind the scenes
Optional isn't magic. It's a small final class with one field holding the value, which is null when empty. The empty case is one shared object, so Optional.empty() doesn't create a new one each time.
Optional.of("Asha") -> [ Optional | value --> "Asha" ]
Optional.empty() -> the shared EMPTY object [ Optional | value = null ]
Because it's an extra object, it has a small cost. And it's meant for one job only: return types that mean "might have no result." That leads to a few guidelines:
- Don't use
Optionalfor fields, method parameters or items inside collections. Just use normal types, or overloads. - Don't return an
Optionalof a collection. Return an empty list instead. - Never return
nullfrom a method whose return type isOptional. - Don't write
if (opt.isPresent()) { opt.get() }. That's just a null check in disguise. Usemap,orElseorifPresent.
Which one should I use?
-
Lambda or method reference? Use a method reference when the lambda only calls one method and the result is clearer (
String::toUpperCase). Use a lambda when there's more logic. -
Loop or stream? Use a stream for transform-filter-collect jobs, since it reads clearly. Keep a loop when you need
break, an index, several changing variables or the simplest possible code. Don't force a stream into a job where it makes the code harder to read. -
toList()orcollect(Collectors.toList())? On Java 16 or newer,toList()is shorter, but the result can't be modified. If you need a changeable list, usecollect(Collectors.toCollection(ArrayList::new)). -
mapormapToInt? For numeric work (sums, averages),mapToIntavoids boxing and gives yousum()andaverage(). -
orElse,orElseGetororElseThrow?orElsefor cheap defaults,orElseGetfor computed ones andorElseThrowwhen "empty" is truly an error. -
Optional or null? Use
Optionalas a return type when "no result" is a normal answer. Don't use it everywhere.
Common mistakes
-
Forgetting the terminal operation.
list.stream().filter(...).map(...)alone does nothing, because the pipeline never starts. Always end withtoList(),collect,forEach,countor similar. -
Reusing a stream. After a terminal operation, it's closed. You'll get
IllegalStateException. Make a new stream from the source. -
Changing things from inside a stream. Adding to an outside list in
forEach, or modifying the source collection, causes confusing bugs. Letcollectbuild your result. -
Calling
get()on an Optional without checking, or usingOptional.of(null). Both throw exceptions. PreferorElse...andofNullable. -
Using
orElsewith an expensive call. It always runs, even when the value is present. UseorElseGet. -
Writing huge, unreadable chains, or using
parallelStream()by default. If a pipeline needs a comment to explain it, split it into named steps. Parallel streams help only for large, CPU-heavy work.
Quick recap
- A method reference (
Class::method) is a short lambda. There are four kinds: static, specific object, "any object of a type" and constructor (::new). - A constructor reference picks the constructor that matches the functional interface's parameters.
- A stream is a lazy pipeline: source → intermediate operations → terminal operation. It doesn't store data or change the source, and it can be used only once.
- Nothing runs until the terminal operation starts, and each item flows through the whole pipeline in turn.
- Common tools:
filter,map,flatMap,sorted,distinct,limit,reduce,collectwithCollectorslikegroupingBy,countingandjoining. -
Optional<T>is a container that holds one value or nothing. It makes "maybe no result" visible in a method's return type. - Use
map,filter,orElse,orElseGet,orElseThrowandifPresentinstead ofget(). UseorElseGetwhen the default is expensive. - Use
Optionalfor return types, not for fields, parameters or collection items.
Try it yourself
Here's a small practice idea: take a list of words, such as the lines of a short paragraph split by spaces. Use a stream to remove blanks, make everything lowercase, count how often each word appears with groupingBy and counting into a TreeMap, and print them sorted. Then write a method Optional<String> longestWord(List<String> words) that returns the longest word, and print it with orElse("none").
For a bonus experiment, add a System.out.println inside a filter and a map, and watch how the items flow one by one. Then add .limit(2) and see how many items get processed.
If this helped, or if you spotted something I should fix, tell me in the comments. I'm still learning too.
Next in this series: var, records and sealed classes.
Top comments (0)