Java Concurrency & Streams: Modern Parallel Java
Master the Streams API and Optional for expressive data processing, then tackle threads, ExecutorService, synchronization, and a practical overview of JVM memory for concurrent code.
4 sections · ~35 min · 5-question quiz (pass ≥ 70%)
1The Streams API: Declarative Data Pipelines
The Streams API (Java 8+) lets you express bulk operations on collections as pipelines of intermediate and terminal operations. Streams are lazy — nothing runs until a terminal operation triggers evaluation.
List<String> activeEmails = users.stream()
.filter(User::isActive)
.map(User::getEmail)
.sorted()
.toList(); // terminal — triggers the pipeline
long count = orders.stream()
.filter(o -> o.getTotal() > 100)
.count();
Common operations:
- Intermediate (return a Stream):
filter,map,flatMap,sorted,distinct,limit - Terminal (produce a result):
collect,reduce,forEach,count,findFirst
Rules of thumb:
- Streams do not modify the source collection — they produce new results.
- Avoid
parallelStream()unless you have measured a CPU-bound bottleneck on large data — synchronization overhead often wins. - Prefer method references (
User::getEmail) when they read cleanly.
2Optional: Modeling Absence Without null
Optional<T> is a container that may or may not hold a value. It forces callers to explicitly handle absence instead of silently getting null.
public Optional<User> findByEmail(String email) {
return repo.findAll().stream()
.filter(u -> u.getEmail().equals(email))
.findFirst();
}
String displayName = findByEmail("ada@example.com")
.map(User::getName)
.orElse("Guest");
User user = findByEmail("missing@example.com")
.orElseThrow(() -> new UserNotFoundException("missing@example.com"));
Do:
- Return
Optionalfrom methods where absence is a normal outcome. - Chain with
map,flatMap,filter.
Don't:
- Use
Optionalas a field type or method parameter — it adds ceremony without benefit. - Call
.get()without checking — useorElse,orElseThrow, orifPresentinstead.
3Concurrency: Threads, ExecutorService, and Synchronization
Creating a raw Thread per task does not scale — thread creation is expensive. ExecutorService manages a pool of reusable worker threads:
ExecutorService pool = Executors.newFixedThreadPool(4);
try {
Future<Integer> result = pool.submit(() -> heavyComputation());
int value = result.get(); // blocks until done
} finally {
pool.shutdown();
}
When multiple threads share mutable state, you need synchronization:
private final Object lock = new Object();
private int counter = 0;
public void increment() {
synchronized (lock) {
counter++;
}
}
// Or use java.util.concurrent atomic types:
private final AtomicInteger safeCounter = new AtomicInteger(0);
public void safeIncrement() { safeCounter.incrementAndGet(); }
ReentrantLock offers more control (try-lock, fairness) than synchronized, but synchronized is simpler and sufficient for most cases.
Rules of thumb:
- Prefer higher-level APIs (
ConcurrentHashMap,CompletableFuture) over hand-rolled locking. - Hold locks for the shortest time possible — never call external I/O while holding a lock.
- Always shut down executor pools — leaked threads keep the JVM alive.
4JVM Memory Overview for Concurrent Developers
Understanding where data lives helps you reason about visibility and bugs.
Stack (per thread): Each thread has its own stack storing method frames, local primitives, and references to heap objects. Locals are thread-safe by definition — no other thread can see them.
Heap (shared): All objects live here. Multiple threads reading and writing the same object without coordination causes data races.
// BUG: visibility — another thread may never see stop = true
private boolean stop = false;
// FIX: volatile guarantees visibility across threads
private volatile boolean stop = false;
The Java Memory Model (JMM) defines when writes by one thread become visible to another. Without synchronized, volatile, or atomic classes, the JVM may reorder or cache reads in ways that break your assumptions.
Garbage collection reclaims unreachable heap objects. Short-lived objects in young generation collections are cheap; long-lived objects promoted to old generation are costlier — another reason to avoid unnecessary object churn in hot loops.
For production concurrent code: use java.util.concurrent, minimize shared mutable state, and test under load — race conditions hide until they do not.