Java 8 Streams: The Gotchas Nobody Tells You About
We’ve been using Java 8 in production for about a year now, and while I love the Stream API, there are some gotchas that bit me hard. Here’s what I wish someone had told me when I started.
Gotcha #1: Streams Are Not Reusable
This one got me on day one:
Stream<String> stream = list.stream()
.filter(s -> s.startsWith("A"));
stream.forEach(System.out::println); // Works fine
stream.forEach(System.out::println); // IllegalStateException!
Streams can only be consumed once. If you need to iterate multiple times, collect to a List first or create a new stream. Seems obvious now, but it wasn’t when I was debugging a production issue at 2 AM.
Gotcha #2: Parallel Streams and Thread Safety
Parallel streams are awesome for performance, but they’ll bite you if you’re not careful:
List<String> results = new ArrayList<>(); // NOT thread-safe!
list.parallelStream()
.filter(s -> s.length() > 5)
.forEach(results::add); // Race condition!
Use collect() instead:
List<String> results = list.parallelStream()
.filter(s -> s.length() > 5)
.collect(Collectors.toList()); // Thread-safe
I spent two days tracking down a bug caused by this. The ArrayList was occasionally missing elements, and it only happened under load. Fun times.
Gotcha #3: Checked Exceptions
Streams and checked exceptions don’t play nice:
// This won't compile
list.stream()
.map(s -> new URL(s)) // MalformedURLException
.collect(Collectors.toList());
You have to wrap it:
list.stream()
.map(s -> {
try {
return new URL(s);
} catch (MalformedURLException e) {
throw new RuntimeException(e);
}
})
.collect(Collectors.toList());
Or create a helper method. Either way, it’s ugly. I’ve seen people suggest using sneaky throws, but that feels like a hack.
Gotcha #4: Performance Isn’t Always Better
Don’t blindly convert all your loops to streams thinking it’ll be faster:
// For small lists, this is actually SLOWER
int sum = smallList.stream()
.mapToInt(Integer::intValue)
.sum();
// Traditional loop is faster for < 100 elements
int sum = 0;
for (int i : smallList) {
sum += i;
}
Streams have overhead. For small collections or simple operations, a traditional loop is often faster. Profile before you optimize.
Gotcha #5: Debugging Is Harder
Try debugging this:
list.stream()
.filter(s -> s.length() > 5)
.map(String::toUpperCase)
.filter(s -> s.contains("ERROR"))
.collect(Collectors.toList());
Where do you put the breakpoint? How do you see intermediate values? I’ve started using peek() for debugging:
list.stream()
.filter(s -> s.length() > 5)
.peek(s -> System.out.println("After filter: " + s))
.map(String::toUpperCase)
.peek(s -> System.out.println("After map: " + s))
.collect(Collectors.toList());
Not elegant, but it works.
The Good Parts
Despite these gotchas, I still love streams. They make code more readable and expressive. Just be aware of the pitfalls:
- Streams are one-time use
- Be careful with parallel streams and shared state
- Checked exceptions are painful
- Profile before assuming streams are faster
- Use
peek()for debugging
Once you internalize these, streams become a powerful tool. Just don’t treat them as a silver bullet.
What I’m Doing Now
I’ve started creating utility methods for common patterns, especially around exception handling. Also being more selective about when to use parallel streams - they’re great for CPU-intensive operations on large datasets, but overkill for most cases.
Anyone else run into weird stream issues? I’d love to hear about them.