The JVM doesn’t really have threads in the way you’re thinking; it has lightweight objects that represent threads, and those representations are multiplexed onto a smaller number of actual operating system threads.
Let’s see this in action. Imagine we have a simple Java program with a few threads:
public class ThreadDemo {
public static void main(String[] args) throws InterruptedException {
Runnable task = () -> {
String threadName = Thread.currentThread().getName();
System.out.println("Hello from " + threadName);
try {
Thread.sleep(1000); // Simulate work
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
System.out.println("Exiting " + threadName);
};
Thread t1 = new Thread(task, "Worker-1");
Thread t2 = new Thread(task, "Worker-2");
Thread t3 = new Thread(task, "Worker-3");
t1.start();
t2.start();
t3.start();
// Keep the main thread alive to see worker threads finish
Thread.sleep(3000);
}
}
When you run this, you’ll see output like this (the exact order might vary):
Hello from Worker-1
Hello from Worker-2
Hello from Worker-3
Exiting Worker-1
Exiting Worker-2
Exiting Worker-3
Now, how does the JVM manage these three Thread objects (t1, t2, t3) and map them to OS threads? The key is the thread model the JVM uses. Historically, there were two main models:
-
Many-to-One (Green Threads): In this model, multiple JVM threads (green threads) are mapped to a single OS thread. The JVM itself is responsible for scheduling and switching between these green threads. This was an early approach, but it had significant drawbacks, most notably that if one green thread blocked on an I/O operation, the entire OS thread would block, preventing any other green thread from running. This model is largely obsolete in modern JVMs.
-
One-to-One (Native Threads): This is the model used by virtually all modern JVMs (since JDK 1.3). Each JVM
Threadobject is directly mapped to a dedicated OS thread. When you callthread.start(), the JVM asks the operating system to create a new native thread. The OS then handles all scheduling and management of these threads. This is much more efficient and allows threads to run truly in parallel on multi-core processors, and crucially, a blocked OS thread only affects itself, not other JVM threads mapped to different OS threads.
So, when you create new Thread(...) and call start(), the JVM is essentially telling the OS, "Hey, make me a new thread for this job." This is a relatively expensive operation, involving system calls and context switching by the OS.
The JVM’s Thread object is a handle or a representation of this underlying OS thread. It contains information like the thread’s name, priority, state (Runnable, Blocked, Waiting, Timed_Waiting, Terminated), and most importantly, a reference to the actual OS thread it’s running on.
You can observe this mapping using JVM and OS tools. For example, on Linux, you can use jstack <pid> to see the state of JVM threads and ps -T -p <pid> or top -H -p <pid> to see the actual OS threads associated with the JVM process. You’ll notice that the number of OS threads listed by ps -T or top -H is often very close to, or the same as, the number of JVM threads you’ve created (excluding the main thread, GC threads, etc.).
For instance, running jstack <pid> on our ThreadDemo might show:
...
"Worker-1" #12 prio=5 tid=0x00007f8b14120800 nid=0x1234 runnable [0x00007f8b1025a000]
java.lang.Thread.State: TIMED_WAITING (sleeping)
at java.lang.Thread.sleep(Native Method)
at ThreadDemo.lambda$main$0(ThreadDemo.java:8)
at java.lang.Thread.run(Thread.java:748)
...
"Worker-2" #13 prio=5 tid=0x00007f8b14121000 nid=0x1235 runnable [0x00007f8b1035c000]
java.lang.Thread.State: TIMED_WAITING (sleeping)
at java.lang.Thread.sleep(Native Method)
at ThreadDemo.lambda$main$0(ThreadDemo.java:8)
at java.lang.Thread.run(Thread.java:748)
...
"Worker-3" #14 prio=5 tid=0x00007f8b14121800 nid=0x1236 runnable [0x00007f8b1045e000]
java.lang.Thread.State: TIMED_WAITING (sleeping)
at java.lang.Thread.sleep(Native Method)
at ThreadDemo.lambda$main$0(ThreadDemo.java:8)
at java.lang.Thread.run(Thread.java:748)
...
Notice the nid (native thread ID). If you then run ps -T -p <pid> and look for those nid values, you’ll see them as distinct OS threads.
The implication of this one-to-one mapping is that creating too many threads can be detrimental. Each OS thread consumes resources: memory for its stack, and CPU time for the OS scheduler to manage it. While Java threads are "lightweight" compared to OS threads of older operating systems, they are still backed by native OS threads. This is why thread pools are crucial in high-concurrency applications: they reuse a fixed number of threads, avoiding the overhead of constant thread creation and destruction.
The JVM doesn’t use a fixed number of OS threads for all its work. It has dedicated threads for garbage collection (e.g., G1 Evacuation Pause), JIT compilation (CompilerThread0), and other internal operations, in addition to the OS threads mapped from your Java Thread objects. The total number of OS threads for a Java process can therefore be significantly larger than just the threads you explicitly create in your application code.
When a Java thread calls a blocking I/O operation (like FileInputStream.read()), it’s the underlying OS thread that blocks. If this OS thread is the only OS thread running your application’s Java threads, then all your Java threads would appear to hang. However, with the one-to-one mapping, other Java threads running on different OS threads continue to execute unimpeded.
The most surprising thing is that the Thread.sleep(long millis) method, while appearing to pause only the current Java thread, actually causes the underlying OS thread to yield its execution to the OS scheduler for at least the specified duration. The OS thread is not truly "sleeping" in a deep power-saving sense, but rather it tells the kernel, "I don’t have anything to do right now for at least X milliseconds, wake me up then." This is a crucial distinction from how older, non-preemptive threading models worked.
The next concept you’ll want to explore is how the JVM utilizes thread pools to manage the creation and lifecycle of these OS-backed threads efficiently for concurrent operations.