Concurrency vs Parallelism

  • Core = physical hardware that actually executes instructions
  • Thread = a unit of work managed by the OS

A single core processor executes one instruction at a time – Only one process can run at any one time – Context-switching allows multi-tasking on a single processor

Concurrent programs run concurrently via threads – OS switches between threads – Separate unrelated tasks into separate threads – Improves processor utilization

Essentially, Parallel programs use threads (concurrent) and multi-core processors (for multi-subtasks). Concurrent programs use threads, and hence may not be parallel unless they use multi-core too. Parallel programs are concurrent, but not all concurrent programs are parallel.

How do parallel streams use threads?

They use a common ForkJoinPool. (this runs underneath parallelStream() and be configured) ForkJoinPool is a pool of threads that handles this pattern — split big task → many threads work in parallel → merge results. getCommonPoolParallelism() returning 8 just means there are 8 threads available in the pool — likely because that machine has 8 cores.

Configuration

jshell> ForkJoinPool.getCommonPoolParallelism()
$.. ==> 8

ForkJoinPool.commonPool().setParallelism(3)
// Caps the pool at 3 threads instead of the default 8

Parallel Stream

A stream pipeline should be inherently parallelizable. parallel() switches the stream pipeline to parallel / sequential() disables any parallel operation. Reduce can use divide-and-conquer (e.g. 1,2,3 becomes 0 + 1 and 2 + 3).

Things to note: Choice of source/terminal affects how well parallelism work

Source: of/iterate are sequential (ordered), generate is unordered — affects how easily Java can split the work

Terminal: forEachOrdered forces encounter order (slower in parallel), forEach doesn't (faster but unpredictable order)

Watching / Debugging Parallel Streams

Thread.currentThread().getName() to identify the thread To simulate computation load, Thread.sleep(long millis) causes the currently executing thread to sleep (i.e. temporarily cease execution) for the specified number of milliseconds.

<T> T timed(T t) {
    try {
        System.out.println(t + " : " + Thread.currentThread().getName());
        Thread.sleep(1000);
    } catch (InterruptedException ex) {}
    return t;
}
jshell> ForkJoinPool.getCommonPoolParallelism()
$.. ==> 3

Sequential Ordered Streams produce identical. The output is the encounter order.

Stream.of(1,4,3,6,2,5).map(x -> timed(x)).forEach(x -> System.out.println(x))

Parallel outputs whatever order threads finish first. forEachOrdered(x System.out.println(x))


Stateless vs Stateful Operations

Stateless operations does not depend on other stream elements. Example: filter, map

Stateful operations depend on the current state, and impedes parallelism. Example: sorted, limit, distinct.

They just need to check more. Each stateful operation is a synchronization point where threads have to wait. Stateless operations like map and filter are where the work gets split across threads (forked) — each thread can independently process its chunk without caring about what other threads are doing. Then stateful operations like sorted are where they join back — all threads have to merge their results before proceeding.


Correctness of Streams

To ensure correct execution, stream operations must not interfere with stream data.

// Rule 1: Don't modify the source while streaming
List<String> list = new ArrayList<>(List.of("abc", "def", "xyz"));

list.stream().peek(str -> {
    if (str.equals("xyz")) { list.add("pqr"); } 
	// ConcurrentModificationException
	}).forEach(x -> {});

stream operations preferably stateless with no side effects.

// Rule 2: Don't use shared mutable state with parallel streams
List<Integer> list = List.of(1, 3, 5, 7, 9, 11, 13, 15, 17, 19);
List<Integer> result = new ArrayList<>();

list.stream().parallel()
    .filter(x -> isPrime(x))
    .forEach(x -> result.add(x)); // race condition

// Fix: use toList() instead
List<Integer> result = list.stream().parallel()
    .filter(x -> isPrime(x))
    .toList();
    
result.add(x) could be called at the same instant and that's chaotic.

Associative Accumulation Function

reduce method uses an associative accumulation function. Addition and function composition is associative, subtraction is not. Essentially, to use reduce, your accumulator must be associative.

Normally, reduction uses the 2-arg reduce method

IntStream.of(1, 2, 3, 4).parallel().reduce(0, (x, y) -> x + y)

Accumulator vs Combiner

parallel() divides a stream into sub-streams.

reduction uses the 3-arg reduce method.

Stream.of(1,2,3).parallel().reduce(0, (x,y) -> x + y, (x,y) -> x + y)
1. identity
2. accumulator (sub-stream goes through left2right associative accum)
3. combiner (partial results are aggregated with associative combiner)

Semi-Associative Accumulator

Left substream evaluates 0 - t1 - t2 - t3 Right substream evaluates 0 - t4 - t5 Together, (0 - t1 - t2 - t3) + (0 - t4 - t5)

We say that the operator - is semi-associative over the associative operator +. That means that the substreams can be merged in different ways. (0 - t1 - t2 - t3) + (0 - t4 - t5) gives the same result as (0 - t4 - t5) + (0 - t1 - t2 - t3).

Rules

Combiners must be associative: cmb(cmb(u_1, u_2), u_3) = cmb(u_1, cmb(u_2, u_3)) cmb(I,u) = u and cmb(u,I) = u

Accumulator acc is associative: acc(acc(u, t_1), t_2) Accumulator acc is semi-associative over an associative chamber: cmb(u, acc(acc(I, t_1), t_2))

Both combiner and accumulator must be compatible acc(u, t) = cmb(u, acc(I, t))


Don’t parallel if not needed

Parallelizing a trivial task actually creates more work in terms of parallelizing overhead.

boolean isPrime(int n) {
	return IntStream.rangeClosed(2, (int) Math.sqrt(n))
		.parallel()
		.noneMatch(x -> n % x == 0);
}

In primality testing, checking (n % x == 0) is trivial;

Accumulator and Combiner Example

Parallelism in Streams

javaRuntime.getRuntime().availableProcessors(); // 8
ForkJoinPool.commonPool().getParallelism(); // 7

import java.time.*;

long numOfPrimes(int from, int to) {
    Instant start = Instant.now();
    long howMany = IntStream.rangeClosed(from, to)
        .parallel()
        .filter(x -> isPrime(x))
        .count();
    Instant stop = Instant.now();
    System.out.println("Duration: " +
        Duration.between(start, stop).toMillis() + "ms");
    return howMany;
}

numOfPrimes(2_000_000, 3_000_000); 
// Duration: 239ms
result: 67883

Accumulator vs Combiner

String name() {
    return Thread.currentThread().getName();
}

Stream.of(1, 2, 3, 4, 5).
    parallel().
    filter(x -> {
        System.out.println("filter: " + x + " " + name());
        return x % 2 == 1;
    }).
    reduce(0,
        (x, y) -> {
            System.out.println("accumulate: " + x + " + " + y + " " + name());
            return x + y;
        },
        (x, y) -> {
            System.out.println("combine: " + x + " + " + y + " " + name());
            return x + y;
        })
        
// filter:     5 ForkJoinPool.commonPool-worker-1
// filter:     4 ForkJoinPool.commonPool-worker-3
// filter:     1 ForkJoinPool.commonPool-worker-3
// filter:     3 main
// filter:     2 ForkJoinPool.commonPool-worker-2
// accumulate: 0 + 5 ForkJoinPool.commonPool-worker-1
// accumulate: 0 + 1 ForkJoinPool.commonPool-worker-3
// combine:    1 + 0 ForkJoinPool.commonPool-worker-3
// accumulate: 0 + 3 main
// combine:    0 + 5 ForkJoinPool.commonPool-worker-1
// combine:    3 + 5 ForkJoinPool.commonPool-worker-1
// combine:    1 + 8 ForkJoinPool.commonPool-worker-1
// ==> 9

The threads are non-deterministic. If you ran it again, they might change the order again. So you just need to read in phases.