Investigating Thread Persistence in Java: When ParallelStream Prevents ThreadGroup Completion

Investigating Thread Persistence in Java: When ParallelStream Prevents ThreadGroup Completion

Disclaimer: This is not a tutorial - it's a question to the Java community. I'm sharing my findings about what seems like unexpected behavior with parallel streams and ThreadGroup monitoring, and I'm genuinely seeking answers. If you've encountered this issue or have insights into whether it's intentional design, I'd appreciate your input.

Context: A Bug (Or Not) That Followed Me Through Time

Back in 2017-2018, I was working on migrating legacy batch processing services from Java 1.4 to Java 1.8. These services relied heavily on ThreadGroup management for coordinating parallel tasks, using activeCount() to wait for completion before proceeding to the next batch phase. The migration seemed straightforward until I decided to modernize the code by replacing traditional loops with the new parallel streams feature.

That's when I first encountered this puzzling behavior: ThreadGroup.activeCount() would never reach zero when parallel streams were involved, causing our batch services to hang indefinitely. At the time, I suspected it was a bug in the relatively new Java 8 streams implementation, but I wasn't entirely sure - it could have been intentional design that I simply didn't understand.

Fast forward to 2025, while dusting off my Java skills and exploring current threading patterns, I decided to revisit this issue. Has this behavior been fixed in newer Java versions? Was it actually a bug, or was it always intended design that I simply misunderstood? Here's what I found when I recreated the scenario.

My Initial Expectation

While working on a multi-threaded application, I needed to execute four methods in parallel and wait for all of them to complete before continuing. The old code was using ThreadGroup with activeCount() monitoring:

while ((activeThreads = group.activeCount()) > 0) {
    System.out.println("Waiting for " + activeThreads + " threads to complete...");
    Thread.sleep(500);
}        

This seemed logical - once all task threads finished their work, the active count should drop to zero, and execution would continue.

The Unexpected Behavior

However, when I added parallel stream processing to each of my methods, something strange happened. My monitoring output looked like this:

Progress: 4/4 completed, 3 still running
Active Threads Details:
   PARALLEL STREAM [ForkJoinPool.commonPool-worker-3] - WAITING (P5) | Processing parallel stream operations
   PARALLEL STREAM [ForkJoinPool.commonPool-worker-2] - WAITING (P5) | Processing parallel stream operations
   PARALLEL STREAM [ForkJoinPool.commonPool-worker-1] - WAITING (P5) | Processing parallel stream operations
   PARALLEL STREAM [ForkJoinPool.commonPool-worker-4] - WAITING (P5) | Processing parallel stream operations
   PARALLEL STREAM [ForkJoinPool.commonPool-worker-6] - TIMED_WAITING (P5) | Processing parallel stream operations
   PARALLEL STREAM [ForkJoinPool.commonPool-worker-5] - WAITING (P5) | Processing parallel stream operations
   PARALLEL STREAM [ForkJoinPool.commonPool-worker-7] - WAITING (P5) | Processing parallel stream operations        

All four of my task threads (Task-1 through Task-4) had completed successfully, yet the ThreadGroup was reporting active threads that never terminated. The application would hang indefinitely in the monitoring loop.

Here is code I used to reproduce

package com.marionzr.playground;

import java.util.List;
import java.util.Random;
import java.util.stream.Collectors;
import java.util.stream.IntStream;

public class ParallelTaskExecutor {

    private static final Random random = new Random();

    private List<Integer> generateRandomNumbers(int count) {
        return IntStream.range(0, count)
                .map(i -> random.nextInt(1000))
                .boxed()
                .collect(Collectors.toList());
    }

    public void method1() {
        System.out.println("Method 1 started - Thread: " + 
            Thread.currentThread().getName());
        
        var numbers = generateRandomNumbers(10000);
        
        try {
            var result = numbers.parallelStream()
                    .mapToLong(n -> n * n)
                    .sum();
            
            System.out.println("Method 1: Parallel stream finished. Result: " + result);
            Thread.sleep(2000);
            System.out.println("Method 1 completed");
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            System.err.println("Method 1 interrupted");
        }
    }

    public void method2() {
        System.out.println("Method 2 started - Thread: " + 
            Thread.currentThread().getName());
        
        var numbers = generateRandomNumbers(8000);
        
        try {
            var result = numbers.parallelStream()
                    .mapToLong(n -> n * n)
                    .average();
            
            System.out.println("Method 2: Parallel stream finished. Result: " + result);
            Thread.sleep(1500);
            System.out.println("Method 2 completed");
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            System.err.println("Method 2 interrupted");
        }
    }

    public void method3() {
        System.out.println("Method 3 started - Thread: " + Thread.currentThread().getName());
        
        var numbers = generateRandomNumbers(12000);
        
        try {
            var result = numbers.parallelStream()
                    .mapToLong(n -> n * n)
                    .max();
            
            System.out.println("Method 3: Parallel stream finished. Result: " + result);
            Thread.sleep(3000);
            System.out.println("Method 3 completed");
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            System.err.println("Method 3 interrupted");
        }
    }

    public void method4() {
        System.out.println("Method 4 started - Thread: " + Thread.currentThread().getName());
        
        var numbers = generateRandomNumbers(6000);
        
        try {
            var result = numbers.parallelStream()
                    .mapToLong(n -> n * n)
                    .min();
            
            System.out.println("Method 3: Parallel stream finished. Result: " + result);
            Thread.sleep(1000);
            System.out.println("Method 4 completed");
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            System.err.println("Method 4 interrupted");
        }
    }

    public void executeParallelTasks() {
        System.out.println("Starting parallel execution...");
        
        // Create a ThreadGroup to manage all threads
        ThreadGroup taskGroup = new ThreadGroup("ParallelTasks");
        
        // Create threads for each method
        Thread t1 = new Thread(taskGroup, this::method1, "Task-1");
        Thread t2 = new Thread(taskGroup, this::method2, "Task-2");
        Thread t3 = new Thread(taskGroup, this::method3, "Task-3");
        Thread t4 = new Thread(taskGroup, this::method4, "Task-4");
        
        // Start all threads
        t1.start();
        t2.start();
        t3.start();
        t4.start();
        
        // Wait for all threads in the group to complete using detailed monitoring
        waitForThreadGroupCompletionDetailed(taskGroup, 4);
        
        System.out.println("All tasks completed. Continuing with main execution...");
    }

    private void waitForThreadGroupCompletionDetailed(ThreadGroup group, int expectedThreadCount) {
        try {
            int activeThreads;
            int completedThreads;
            
            System.out.println("\n" + "=".repeat(60));
            System.out.println("ThreadGroup Monitor: " + group.getName());
            System.out.println("Expected task threads: " + expectedThreadCount);
            System.out.println("=".repeat(60));
            
            while ((activeThreads = group.activeCount()) > 0) {
                completedThreads = expectedThreadCount - activeThreads;
                
                System.out.println("\nProgress Summary:");
                System.out.printf("   Completed: %d/%d tasks%n", completedThreads, expectedThreadCount);
                System.out.printf("   Running: %d threads%n", activeThreads);
                                
                Thread[] activeThreadList = new Thread[activeThreads];
                int actualCount = group.enumerate(activeThreadList, false);
                
                System.out.println("\nActive Threads Details:");
                for (int i = 0; i < actualCount; i++) {
                    if (activeThreadList[i] != null) {
                        Thread t = activeThreadList[i];
                        String threadInfo = analyzeThread(t);
                        System.out.println("   " + threadInfo);
                    }
                }
                
                // Show parallel stream thread pool status
                showParallelStreamStatus();
                
                System.out.println("-".repeat(60));
                Thread.sleep(1000); // Check every second for better monitoring
            }
            
            System.out.println("\nAll Tasks Completed!");
            System.out.println("✓ All " + expectedThreadCount + " threads finished successfully");
            showFinalParallelStreamStatus();
            System.out.println("=".repeat(60) + "\n");
            
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            System.err.println("Main thread interrupted while waiting");
        }
    }

    private String analyzeThread(Thread thread) {
        String name = thread.getName();
        String state = thread.getState().toString();
        String priority = "P" + thread.getPriority();
        
        String type;
        String description;
        
        if (name.startsWith("Task-")) {
            type = "MAIN TASK";
            description = getTaskDescription(name);
        } else if (name.contains("ForkJoinPool")) {
            type = "PARALLEL STREAM";
            description = "Processing parallel stream operations";
        } else if (name.contains("pool")) {
            type = "WORKER";
            description = "Thread pool worker";
        } else {
            type = "OTHER";
            description = "System or utility thread";
        }
        
        return String.format("%s [%s] - %s (%s) | %s", 
                type, name, state, priority, description);
    }

    private String getTaskDescription(String threadName) {
        switch (threadName) {
            case "Task-1":
            case "Task-1-Join":
                return "Task-1 Parallel Stream";
            case "Task-2":
            case "Task-2-Join":
                return "Task-2 Parallel Stream";
            case "Task-3":
            case "Task-3-Join":
                return "Task-3 Parallel Stream";
            case "Task-4":
            case "Task-4-Join":
                return "Task-4 Parallel Stream";
            default:
                return "Custom parallel processing task";
        }
    }

    private void showParallelStreamStatus() {
        try {
            var commonPool = java.util.concurrent.ForkJoinPool.commonPool();
            System.out.println("\nParallel Streams Status:");
            System.out.printf("   Pool Size: %d threads%n", commonPool.getPoolSize());
            System.out.printf("   Active Threads: %d%n", commonPool.getActiveThreadCount());
            System.out.printf("   Running Tasks: %d%n", commonPool.getRunningThreadCount());
            System.out.printf("   Queued Tasks: %d%n", commonPool.getQueuedTaskCount());
            
            if (commonPool.getActiveThreadCount() > 0) {
                System.out.println("   Parallel streams are actively processing data!");
            }
        } catch (Exception e) {
            System.out.println("   Parallel stream details unavailable");
        }
    }

    private void showFinalParallelStreamStatus() {
        try {
            var commonPool =  java.util.concurrent.ForkJoinPool.commonPool();
            System.out.println("\nFinal Parallel Streams Summary:");
            System.out.printf("   Pool Parallelism: %d%n", commonPool.getParallelism());
            System.out.println("   All parallel stream operations completed");
        } catch (Exception e) {
            System.out.println("   Final parallel stream summary unavailable");
        }
    }

    public static void main(String[] args) {
        var executor = new ParallelTaskExecutor();
        
        System.out.println("=== ThreadGroup Active Count Approach ===");
        var startTime = System.currentTimeMillis();
        executor.executeParallelTasks();
        var endTime = System.currentTimeMillis();
        System.out.println("Total execution time: " + (endTime - startTime) + "ms\n");
    }
}        

What I Discovered

Through investigation, I found that parallel streams introduce persistent background threads that don't follow the same lifecycle as regular threads:

The ForkJoinPool.commonPool() Reality

When I examined the thread details more closely, I noticed:

  1. My task threads completed normally - Task-1, Task-2, Task-3, and Task-4 all finished and were no longer in the active thread list
  2. New threads appeared and persisted - Threads named ForkJoinPool.commonPool-worker-X remained active
  3. These threads showed WAITING state - They weren't actively processing, just... existing
  4. The count stabilized but never reached zero - Typically 2-4 ForkJoinPool threads remained

Thread Pool Information Revealed More

When I added monitoring of the ForkJoinPool status, I saw:

Parallel Streams Status:
   Pool Size: 7 threads
   Active Threads: 0
   Running Tasks: 0
   Queued Tasks: 0        

This was puzzling - the ForkJoinPool reported no active work, yet the threads remained alive and counted by my ThreadGroup.

My Findings and Questions

What Appears to be Happening

Based on my observations, it seems that:

  1. Parallel streams use a shared, global thread pool (ForkJoinPool.commonPool())
  2. This pool creates worker threads that persist beyond task completion
  3. These threads become part of whatever ThreadGroup was active when the parallel stream executed
  4. The threads remain in WAITING state, ready for future parallel operations
  5. ThreadGroup.activeCount() includes these persistent threads in its count

Is This Intentional Design or a Bug?

I'm genuinely uncertain whether this behavior represents:

Intentional Design:

  • Performance optimization to avoid thread creation overhead
  • Global resource sharing for parallel operations
  • Daemon thread pattern for background processing

Potential Bug or Oversight:

  • ThreadGroup monitoring becomes unreliable
  • Unexpected thread lifecycle management
  • Breaking the expected contract of thread completion

Real-World Impact

This behavior has practical consequences:

  • Monitoring applications that rely on ThreadGroup.activeCount() may hang indefinitely
  • Resource management becomes more complex when threads don't terminate as expected
  • Testing and debugging scenarios where clean thread termination is expected fail
  • Container environments might see unexpected resource utilization patterns

Questions for the Java Community

This experience raises several questions:

  1. Is this documented behavior? Should developers expect ForkJoinPool threads to persist in ThreadGroups?
  2. Is ThreadGroup.activeCount() the wrong tool for monitoring parallel stream applications?
  3. Should there be a way to cleanly shutdown the common ForkJoinPool when needed?
  4. Is this a design flaw in how parallel streams interact with ThreadGroup management?

Workarounds that I considered

1. Explicit Thread Tracking

Instead of relying on ThreadGroup counts, I now track my specific threads:

List<Thread> myTasks = Arrays.asList(t1, t2, t3, t4);
for (Thread t : myTasks) {
    t.join(); // Wait for my specific threads only
}        

2. Custom ForkJoinPool Management

For better control, I create dedicated pools:

ForkJoinPool customPool = new ForkJoinPool();
try {
    // Submit parallel stream work to custom pool
} finally {
    customPool.shutdown();
    customPool.awaitTermination(30, TimeUnit.SECONDS);
}        

3. Modified Monitoring Logic

I filter out ForkJoinPool threads from my monitoring:

private boolean onlyForkJoinThreadsRemain(ThreadGroup group) {
    Thread[] threads = new Thread[group.activeCount()];
    group.enumerate(threads);
    
    for (Thread t : threads) {
        if (t != null && !t.getName().startsWith("ForkJoinPool")) {
            return false; // Found a non-ForkJoinPool thread
        }
    }
    return true; // Only ForkJoinPool threads remain
}        

Conclusion

This experience raises some questions about the evolution of Java's threading model. What I encountered as a potential bug during the Java 1.4 to 1.8 migration in 2017-2018 appears to persist in current Java versions, suggesting it was indeed intentional design rather than an oversight.

However, this behavior changes how ThreadGroup monitoring works when parallel streams are involved. For those migrating from older Java versions where ThreadGroups provided predictable lifecycle management, this represents a significant shift in threading patterns that may not be immediately obvious.

After revisiting this issue years later, I'm now more inclined to believe this was intentional design from the beginning, though I still question whether the interaction with ThreadGroup was fully considered. The fact that it remains unchanged suggests the Java team views this as correct behavior, even if it breaks some traditional threading patterns that developers relied on in earlier versions.

To view or add a comment, sign in

Others also viewed

Explore topics