Back to Blog
Java

Java Stream Filter: Usage, Predicates, and Performance

java stream filter: Learn how to use Java Stream filter() with predicates, combine conditions, avoid common mistakes, and understand performance tradeoffs.

Java StreamsStream APIPredicateFunctional ProgrammingJava 8
A Java stream pipeline with a filter operation selecting elements that match a predicate.

java stream filter requires a clear understanding of the core syntax, runtime behavior, and practical implementation patterns demonstrated in the examples below.

The filter() method is one of the most used intermediate operations in the Java Stream API. It takes a Predicate and returns a stream containing only elements that match the predicate. This lets you express conditional logic declaratively, without explicit loops or temporary collections.

Basic Usage of filter()

The simplest form of filter() applies a lambda expression to each element. The predicate returns true for elements that should be kept, and false for those that should be dropped. Here is a minimal example:

List<String> names = List.of("Alice", "Bob", "Charlie", "David"); List<String> longNames = names.stream() .filter(name -> name.length() > 4) .collect(Collectors.toList());

The resulting list contains "Alice" and "Charlie" because those names have more than four characters. The original list is unchanged; streams do not modify their source.

The filter() operation is lazy. It does not evaluate the predicate until a terminal operation, such as collect() or forEach(), is invoked. This laziness is important for performance and for building pipelines that can short-circuit.

Combining Multiple Conditions with Predicate

A single filter() call accepts one Predicate, but you can combine conditions using the default methods on Predicate: and(), or(), and negate(). This keeps the pipeline readable and avoids nested filter() calls.

List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10); Predicate<Integer> isEven = n -> n % 2 == 0; Predicate<Integer> isGreaterThanFour = n -> n > 4; List<Integer> result = numbers.stream() .filter(isEven.and(isGreaterThanFour)) .collect(Collectors.toList());

This returns [6, 8, 10]. You can also use or() to include elements that satisfy any of the conditions, and negate() to invert a predicate. For more complex logic, consider extracting predicates into named variables or methods to improve readability.

Filtering Null Values and Using Optional

Null values are a common source of NullPointerException when you access fields or call methods inside a predicate. You can filter them out explicitly:

List<String> listWithNulls = Arrays.asList("one", null, "two", null); List<String> nonNull = listWithNulls.stream() .filter(Objects::nonNull) .collect(Collectors.toList());

If you need to transform the result of a method that returns Optional, you can combine filter() with flatMap() or use Optional.filter(). For example, when you have a stream of Optional<String> and want to keep only values that are present and match a condition:

Stream<Optional<String>> streamOfOptionals = ...; List<String> filtered = streamOfOptionals .filter(Optional::isPresent) .map(Optional::get) .filter(s -> s.startsWith("A")) .collect(Collectors.toList());

This pattern is common when dealing with lookup operations that may not find a value.

Performance Considerations

The filter() operation is not a performance magic bullet. It adds a small overhead compared to a traditional for loop because of the stream infrastructure and lambda invocation. However, for most collections, this overhead is negligible. The real performance benefit comes from lazy evaluation and short-circuiting.

Consider a pipeline that filters a large collection and then takes only the first few matches:

List<Integer> largeList = ...; List<Integer> firstFive = largeList.stream() .filter(n -> n % 3 == 0) .limit(5) .collect(Collectors.toList());

Because streams are lazy, filter() does not process the entire list. It evaluates elements one by one, and limit(5) stops after finding five matches. This can be far more efficient than a loop that scans the entire list.

On the other hand, if you apply filter() and then collect all results, the stream will process every element. The overhead is similar to a loop with a conditional check. Avoid using parallel streams with filter() unless your dataset is large and the predicate is independent of element order, because parallel processing can introduce ordering costs.

Common Mistakes and Pitfalls

One frequent mistake is trying to reuse a stream after a terminal operation has been called. A stream is a one-use object; calling collect() or forEach() closes it. If you attempt to call filter() again, you'll get an IllegalStateException. Always create a new stream from the source.

Another pitfall is modifying the source collection while streaming. If you remove elements from the underlying list inside a predicate, you may get a ConcurrentModificationException or unpredictable behavior. Streams are designed to work with non-concurrent modifications. If you need to remove elements conditionally, consider using removeIf() on the collection directly.

A third issue is using stateful predicates. A predicate that depends on mutable state or external variables can produce inconsistent results when the stream is processed in parallel. For example, a predicate that counts how many elements it has seen is not safe for parallel execution.

When to Use filter() vs Alternatives

filter() is not always the best choice. For simple conditional removal from a Collection, Collection.removeIf() is more direct and often more readable:

names.removeIf(name -> name.length() <= 4);

This modifies the list in place, whereas filter() creates a new stream and typically a new collection. Use filter() when you want to keep the original collection unchanged, or when you need to chain multiple operations like map(), sorted(), or collect().

For loops are still appropriate when you need to break early based on a complex condition that cannot be expressed as a predicate, or when you need to access the index. Streams do not provide indexed access.

Advanced Filtering with Custom Predicates

You can reuse predicates by defining them as static methods or using method references. This improves maintainability, especially when the same condition appears in multiple places.

public class NameFilters { public static boolean isLongName(String name) { return name.length() > 4; } } List<String> longNames = names.stream() .filter(NameFilters::isLongName) .collect(Collectors.toList());

You can also compose predicates dynamically. For example, building a filter based on user input:

Predicate<String> predicate = name -> name.startsWith("A"); if (includeLongNames) { predicate = predicate.or(NameFilters::isLongName); }

This flexibility makes filter() a powerful tool for building configurable data-processing pipelines.

Edge Cases and Parallel Streams

When you use filter() on a parallel stream, the predicate must be stateless and non-interfering. If the predicate throws an exception, the behavior depends on the terminal operation and the runtime. For example, findFirst() may return any element if the predicate fails, while forEach() will propagate the exception.

Ordering is another concern. In a sequential stream, filter() preserves the encounter order of the source. In a parallel stream, the order is not guaranteed unless you use forEachOrdered() or collect with a collector that preserves order. If order matters, avoid parallel streams or use sequential().

Finally, remember that filter() does not change the size of the stream in a way that is visible until a terminal operation. If you need to know how many elements passed the filter, you can use count() as the terminal operation.

The filter() method is a fundamental tool in the Java Stream API. Understanding its behavior, combining predicates correctly, and being aware of its performance characteristics will help you write cleaner and more efficient data-processing code.

java stream filter: Practical Usage and Code Examples | RYUSLOG DEV