Take a look at this piece of code from Understanding the Java Virtual Machine:
public class VolatileTest {
public static volatile int race = 0;
public static void increase() {
race++;
}
private static final int THREADS_COUNT = 20;
public static void main(String[] args) {
Thread[] threads = new Thread[THREADS_COUNT];
for (int i = 0; i < THREADS_COUNT; i++) {
new Thread(() -> {
for (int j = 0; j < 10000; j++) {
increase();
}
}).start();
}
while (Thread.activeCount() > 1) {
Thread.yield();
}
System.out.println(race);
}
}
Many developers would immediately focus on the volatile keyword and point out that volatile guarantees visibility but not atomicity. The race++ operation is a compound action, and thus the final result is unpredictable. That is the expected takeaway from the example.
However, something unusual happens when you run this code directly inside IntelliJ IDEA (using the Run button). The console shows nothing—no output line appears, no exception is thrown. The program seems to hang forever. When you switch to Debug mode and execute the same main method, everything finishes normally and prints a value.
Why does that happen?
Isolating the Problem
Strip the code down to its bare minimum. Even this version never reaches the print statement:
public class VolatileTest {
public static volatile int race = 0;
public static void main(String[] args) {
while (Thread.activeCount() > 1) {
Thread.yield();
}
System.out.println("race = " + race);
}
}
The program stays stuck in the while loop. Now log the active thread count:
System.out.println(Thread.activeCount());
When launched with Run, the output is 2. Under Debug, the output is 1. That difference is the key.
Looking at the Thread.activeCount() Javadoc, the return value is described as an estimate of the number of active threads in the current thread's thread group. Because threads are created and destroyed dynamically, the value you get is only a snapshot taken at call time. But here the number is stable: Run consistently reports 2, Debug reports 1.
Which Threads Are Runing?
Print every thread in the JVM:
import java.util.Arrays;
public static Thread[] findAllThreads() {
ThreadGroup group = Thread.currentThread().getThreadGroup();
while (group.getParent() != null) {
group = group.getParent();
}
int count = group.activeCount();
Thread[] threads = new Thread[count];
group.enumerate(threads);
Arrays.stream(threads).forEach(t ->
System.out.printf("Name: %s | Id: %d | State: %s%n",
t.getName(), t.getId(), t.getState())
);
return threads;
}
In Run mode, you’ll typically see six threads:
- Reference Handler – handles soft, weak, and phantom references during GC.
- Finalizer – calls
finalize()before garbage collection. - Attach Listener – waits for external commands (e.g., jmap, jstack) and activates on first use.
- Signal Dispatcher – distributes received external commands to internal modules.
- main – the primary application thread.
- Monitor Ctrl-Breeak – the extra thread that keeps
activeCount()at 2.
When you launch in Debug mode, the Moniter Ctrl-Break thread disappears. That is why activeCount() returns 1 and the loop exits.
Where Does Monitor Ctrl-Break Come From?
Take a thread dump using IntelliJ’s camera icon or with jstack. The Monitor Ctrl-Break trace leads to:
com.intellij.rt.execution.application.AppMainV2$1.run(AppMainV2.java:64)
That class belongs to IntelliJ IDEA’s runtime, not your project. Use jps to inspect the JVM process; you will spot a -javaagent argument passed to the Java command.
Example:
-javaagent:D:\Program Files\JetBrains\IntelliJ IDEA 2019.3.4\lib\idea_rt.jar=61960
idea_rt.jar is the agent JAR. Agents defined through the -javaagent flag leverage the java.lang.instrument package, allowing bytecode manipulation and runtime hooks without touching application source code—comparable to AOP at the JVM level. The JAR must contain a META-INF/MANIFEST.MF with a Premain-Class entry.
Inside idea_rt.jar, three classes reside. The one we care about is AppMainV2. Its startMonitor method creates a Socket connection:
Socket client = new Socket("127.0.0.1", portNumber);
// then loops while (true) reading commands
This socket connects to a port that IntelliJ assigns each time the application starts (you can see it in the Run console configuration as -javaagent:...=61960). The monitor thread listens for commands such as STOP to terminate the launched program gracefully.
Interacting with the Monitor Thread
Because the monitor communicates over a plain socket, you can attach to it manually.
Create a small server that listens on a fixed port:
import java.io.*;
import java.net.*;
import java.util.Scanner;
public class SocketTest {
public static void main(String[] args) throws IOException {
ServerSocket serverSocket = new ServerSocket(12345);
System.out.println("Waiting for client...");
Socket socket = serverSocket.accept();
System.out.println("Client connected: " +
socket.getInetAddress() + ":" + socket.getPort());
OutputStream out = socket.getOutputStream();
Scanner scanner = new Scanner(System.in);
while (true) {
System.out.print("Enter command: ");
String line = scanner.nextLine();
out.write((line + "\n").getBytes("US-ASCII"));
}
}
}
Adjust the launcher’s port number to 12345 inside the IntelliJ run configuration by editing the -javaagent argument, save it as a .bat script, then start the server. When the program runs, your server reports a connection. Sending the STOP command causes the target application to exit gracefully.
This experiment reveals that the Monitor Ctrl-Break thread is a harmless diagnostic helper added by IntelliJ’s runtime agent. It persists as long as the process runs, keeping the naive while (Thread.activeCount() > 1) loop from ever finishing in Run mode. Debug mode bypasses the agent, so the thread never starts.