java线程模型与多线程基础

概述

在现代操作系统中,线程是处理器调度和分配的基本单位,进程则是作为资源(内存地址、文件 I/O 等)拥有的基本单位。线程是进程内部的一个执行单元,每一个进程至少有一个主执行线程,它无需由用户去主动创建,是由系统自动创建的。用户根据需要在应用程序中创建其它线程,多个线程并发地运行于同一个进程中。

在进行并发编程时,如果希望通过多线程执行任务让程序运行得更快,会面临诸多挑战:

  • 上下文切换问题;
  • 死锁问题;
  • 硬件和硬件资源限制。

上下文切换问题

CPU 通过给每个线程分配 CPU 时间片来实现事件推进,CPU分配给各个线程的时间非常短,所以 CPU 通过不停地切换线程执行,让我们感觉多个线程是同时执行的。在CPU切换前会保存上一个任务的状态,以便下次切换回这个任务时,可以再加载这个任务的状态,所以任务从保存到再加载的过程就是一次上下文切换

线程上下文切换是有成本的,主要体现在以下几个方面:

  • CPU 开销:保存和恢复线程状态需要 CPU 执行额外的指令
  • 缓存失效:上下文切换可能导致 CPU 缓存、TLB(Translation Lookaside Buffer)和分支预测器的失效,从而增加内存访问延迟。
  • 内核态开销:上下文切换通常涉及从用户态切换到内核态的操作,这进一步增加了开销。

减少上下文切换的方法:

  • 减少线程数量:使用合理数量的线程,避免线程过多导致频繁切换;
  • 无锁编程:减少线程之间的锁竞争,降低阻塞几率;
  • 使用适当的线程池:利用线程池复用线程,避免频繁的线程创建和销毁;
  • CAS 算法:使用 CAS 算法来更新数据,而不需要加锁;
  • 线程池复用:选择合适的调度策略,减少不必要的上下文切换。

Linux系统分层架构

进程作为资源拥有者,其调度及系统资源的分配离不开操作系统。这里以Linux系统为例,为了确保系统的安全性、稳定性和高效性,Linux 使用 用户空间 (User Space) 与内核空间 (Kernel Space) 的分离设计。

内核空间 (Kernel Space)
内核是 Linux 操作系统的核心,直接与硬件层交互,它负责管理系统的所有资源,并为上层软件提供接口。Linux 内核采用宏内核(monolithic kernel) 架构,这意味着所有的核心功能(如设备驱动、文件系统、内存管理、进程管理等)都包含在一个单一的、受保护的地址空间内。内核的主要功能包括:

  • 内存管理:管理系统的 RAM,确保进程高效、安全地使用内存。
  • 进程管理:负责创建、调度和终止进程,公平地分配 CPU 时间。
  • 设备驱动程序:控制特定的硬件设备,将硬件操作抽象为统一的接口。
  • 文件系统:管理数据的存储和检索,提供对不同类型文件系统的支持。
  • 网络管理:处理网络通信协议和接口。
  • 系统调用接口:提供应用程序访问内核功能的接口。

用户空间 (User Space)
用户空间是用户与计算机硬件之间的高级抽象层,它提供了一个安全、稳定且灵活的环境,允许各种应用程序运行,同时将复杂的硬件管理和系统控制任务留给特权的内核空间来处理。它是受限的、非特权的环境,与具有完全硬件访问权限的内核空间严格隔离。主要特点:

  • 隔离性:每个用户空间进程通常运行在自己独立的虚拟内存空间中,无法直接访问其他进程或内核的内存。
  • 非特权模式 (User Mode):用户空间的代码在 CPU 的非特权级别(如 x86 架构的 Ring 3)运行,权限受限。
  • 通过系统调用与内核通信:当用户程序需要执行特权操作(如文件 I/O、网络访问、内存分配等)时,必须发起系统调用 (System Call),请求内核代为执行。
  • 健壮性与安全性:用户空间的设计提供了一个沙盒环境。即使某个用户程序崩溃,也不会影响到操作系统内核或其他程序,从而保证了系统的稳定性。

总之,用户空间和内核空间的分离通过引入特权隔离层,使得 Linux 操作系统能够在一个多任务、多用户的环境中提供一个健壮、可靠且安全的运行平台。

线程模型

由于Linux系统的分层架构,线程的实现可以分为两类:用户级线程(User-LevelThread, ULT)和内核级线程(Kemel-LevelThread, KLT)。在用户级线程中,线程的创建和销毁都是由用户程序来完成,用户程序需要调用系统提供的接口来创建和销毁线程,而线程的调度则交由操作系统来完成。多线程模型即用户级线程和内核级线程的不同连接方式,线程模型影响着并发规模及操作成本(开销)。通常多线程有如下几种实现:

  • 多对一模式 - 使用用户线程实现(m:1):多个用户线程映射到一个内核线程,用户线程建立在用户空间的线程库上,用户线程的建立、同步、销毁和调度完全在用户态中完成,对内核透明。

    • 优点:

      • 线程的上下文切换都发生在用户空间,避免了模态切换(mode switch),减少了性能的开销。
      • 用户线程的创建不受内核资源的限制,可以支持更大规模的线程数量。
    • 缺点:

      • 所有的线程基于一个内核调度实体即内核线程,这意味着只有一个处理器可以被利用,浪费了其它处理器资源,不支持并行,在多处理器环境下这是不能够被接受的,如果线程因为 I/O 操作陷入了内核态,内核态线程阻塞等待 I/O 数据,则所有的线程都将会被阻塞。
      • 增加了复杂度,所有的线程操作都需要用户程序自己处理,而且在用户空间要想自己实现 “阻塞的时候把线程映射到其他处理器上” 异常困难。
  • 一对一模式 - 使用内核线程实现(1:1):程序使用的是轻量级进程(Light Weight Process, LWP),轻量级进程就是我们通常意义上所讲的线程, 也是属于用户线程。在实现上每个用户线程都映射到一个内核线程,每个线程都成为一个独立的调度单元,由内核调度器独立调度,一个线程的阻塞不会影响到其他线程,从而保障整个进程继续工作。

    • 优点:
      • 每个线程都成为一个独立的调度单元,使用内核提供的线程调度功能及处理器映射,可以完成线程的切换,并将线程的任务映射到其他处理器上,充分利用多核处理器的优势,实现真正的并行。
    • 缺点:
      • 每创建一个用户级线程都需要创建一个内核级线程与其对应,因此需要消耗一定的内核资源, 而内核资源是有限的,所以能创建的线程数量也是有限的。
      • 模态切换频繁,各种线程操作,如创建、析构及同步,都需要进行系统调用,需要频繁的在用户态和内核态之间切换,开销大。
  • 多对多模式 - 混合模型(m:n):内核线程和用户线程的数量比为 M : N,这种模型需要内核线程调度器和用户空间线程调度器相互操作,本质上是多个线程被映射到了多个内核线程。其综合了前面两种模型的优点:

    • 用户线程的创建、切换、析构及同步依然发生在用户空间,能创建数量更多的线程,支持更大规模的并发。
    • 大部分的线程上下文切换都发生在用户空间,减少了模态切换带来的开销。
    • 可以使用内核提供的线程调度功能及处理器映射,充分利用多核处理器的优势,实现真正的并行,并降低了整个进程被完全阻塞的风险。

Java线程模型

Java 的线程是映射到操作系统的原生线程之上。JVM 只封装了底层操作系统的差异,不同的操作系统可能使用不同的线程模型,例如 Linux 和 windows 可能使用了一对一模型,solaris 和 unix 某些版本可能使用多对多模型,所以谈到 Java 语言的多线程模型,需要针对具体 JVM 实现。

在Java中Thread类实现了对操作系统线程的抽象。具体来说,在Java中一个操作系统线程与一个Thread对象关联,通过调用Thread对象的start()方法来启动一个操作系统线程执行。其运行逻辑:在调用Thread.start()方法之后,会调用到JVM本地方法,随后申请创建一个新的操作系统线程环境执行Thread.run(),在Thread.run()中最终调用的的Runnable.run(),也就是通过Thread.start()启动的线程最终执行的是Runnable.run()。

Thread

在 Java 中,线程可以使用 java.lang.Thread 类来创建和管理线程,最常见的写法例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class ThreadTest {

public static void main(String[] args) {
Thread thread = new Thread(new Task());
thread.setName("test-thread");
thread.start();
}

static class Task implements Runnable {
@Override
public void run() {
System.out.println("线程运行,线程名称为:" + Thread.currentThread().getName());
}
}
}

线程生命周期与状态

在 Java 中线程的生命周期中一共有 6 种状态:

  • New(新创建)
  • Runnable(可运行)
  • Blocked(被阻塞)
  • Waiting(等待)
  • Timed Waiting(计时等待)
  • Terminated(被终止)

  • New
    表示线程被创建但尚未启动的状态:当我们用 new Thread() 新建一个线程时,如果线程没有开始运行 start() 方法,所以也没有开始执行 run() 方法里面的代码,那么此时它的状态就是 New。而一旦线程调用了 start(),它的状态就会从 New 变成 Runnable。

  • Runable
    对应操作系统线程状态中的两种状态,分别是 Running 和 Ready。Java 中处于 Runnable 状态的线程有可能正在执行,也有可能没有正在执行,正在等待被分配 CPU 资源。所以,如果一个正在运行的线程是 Runnable 状态,当它运行到任务的一半时,执行该线程的 CPU 被调度去做其他事情,导致该线程暂时不运行,它的状态依然不变,还是 Runnable,因为它有可能随时被调度回来继续执行任务。

  • Blocked 从箭头的流转方向可以看出,从 Runnable 状态进入 Blocked 状态只有一种可能,就是进入 synchronized 保护的代码时没有抢到 monitor 锁,无论是进入 synchronized 代码块,还是 synchronized 方法,都是一样。

  • Waiting 状态有三种可能性:

    • 没有设置 Timeout 参数的 Object.wait() 方法。
    • 没有设置 Timeout 参数的 Thread.join() 方法。
    • LockSupport.park() 方法。
  • Timed Waiting 状态:

    • 设置了时间参数的 Thread.sleep(long millis) 方法;
    • 设置了时间参数的 Object.wait(long timeout) 方法;
    • 设置了时间参数的 Thread.join(long millis) 方法;
    • 设置了时间参数的 LockSupport.parkNanos(long nanos) 方法和 LockSupport.parkUntil(long deadline) 方法。

Thread 运行逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class Thread implements Runnable {
/* What will be run. */
private Runnable target;

public synchronized void start() {
//省略
boolean started = false;
try {
start0();
started = true;
} finally {
//省略
}
}

private native void start0();

@Override
public void run() {
if (target != null) {
target.run();
}
}
}

从上面的代码可以看到,在执行 thread.start() 时会调用 JNI 方法 start0(),JVM 执行 start0() 会创建并启动一个真正的操作系统级线程,并将该线程的执行入口与 Java 线程的 run() 方法关联起来。 具体过程如下:

  • 调用JNI接口:JVM 通过 Java Native Interface (JNI) 调用预先加载好的本地库(例如, 在 Linux 中是 libpthread,在 Windows 中是使用 Win32 API 的 kernel32.lib)中对应的 C/C++ 函数。
  • 创建操作系统线程:在本地代码中,JVM 调用底层的操作系统API来创建一个新的原生线程。例如,在 POSIX 系统(如 Linux、macOS)上,会调用 pthread_create() 函数;在 Windows 上,会调用 CreateThread() 函数。这个新的操作系统线程会拥有自己独立的堆栈空间 (stack)。
  • 指定执行入口函数: 在调用操作系统 API 创建线程时,JVM 会传递一个特定的 C/C++ 函数地址作为新线程的执行入口点。在 OpenJDK 源码中,这个入口函数通常是 thread_entry() 或类似的函数。
  • 回调Java:当新的操作系统线程开始执行时,它首先运行上述指定的 C/C++ 入口函数 (thread_entry)。这个入口函数负责进行必要的设置(如与 JVM 环境的关联),然后 回调(thunk back) 到 Java 层,最终执行对应 Java Thread 对象的 run() 方法。
  • 并发执行: 此时,原来的主线程从 start() 方法返回,继续执行其后续代码;新创建的线程则开始并发执行其 run() 方法中的业务逻辑。

总之:start0() 是连接 Java 世界和底层操作系统线程机制的桥梁,它利用 JNI 请求OS创建新线程,并确保新线程的起点是用户定义的 run() 方法

virtual thread

虚拟线程(Virtual Threads)是JDK 21引入的,其实现原理是一种用户态线程(User-mode threads)模型,由 JVM 而非操作系统(OS)管理和调度,使用载体线程(Carrier Threads) 作为底层执行资源的抽象层。传统的平台线程(Platform Threads)采用 1:1 模型,即一个 Java 线程永久绑定一个 OS 线程。虚拟线程则采用 M:N 模型,M 个虚拟线程复用 N 个(数量少得多的)平台线程。

1
2
3
4
5
6
7
8
9
10
11
12
sealed abstract class BaseVirtualThread extends Thread
permits VirtualThread, ThreadBuilders.BoundVirtualThread {
// 省略
}

final class VirtualThread extends BaseVirtualThread {
private static final ForkJoinPool DEFAULT_SCHEDULER = createDefaultScheduler();
// scheduler and continuation
private final Executor scheduler;
private final Continuation cont;
private final Runnable runContinuation;
}

由上面代码可看到,与平台线程类似,虚拟线程也是平台线程的一个实例java.lang.Thread。但虚拟线程的实际执行依赖于由平台线程组成的、小的、共享的线程池,这些平台线程称为载体线程。JVM 在实现上依赖于线程池,默认使用 ForkJoinPool 实例作为调度器,管理这些载体线程,通常其并行度与可用 CPU 核心数相当。

虚拟线程的核心思想是解耦:将业务逻辑(虚拟线程)与底层执行资源(载体线程/OS线程)分离。通过在 I/O 阻塞时动态切换任务,JVM 可以在少量 OS 线程上高效地运行海量的并发任务,同时保留了简单易懂的一个请求一个线程的同步编程模型。

virtual thread 使用

使用 Thread 类和 Thread.Builder 接口创建虚拟线程

1
2
3
4
5
6
7
Thread thread = Thread.ofVirtual().start(() -> System.out.println("Hello"));

Thread.Builder builder = Thread.ofVirtual().name("MyThread");
Runnable task = () -> {
System.out.println("Running thread");
};
Thread t = builder.start(task);

使用 Executors.newVirtualThreadPerTaskExecutor() 方法创建和运行虚拟线程

1
2
3
4
5
ExecutorService myExecutor = Executors.newVirtualThreadPerTaskExecutor()) {
Future<?> future = myExecutor.submit(() -> System.out.println("Running thread"));
future.get();
System.out.println("Task completed");
}

java线程模型与多线程基础
http://example.com/2025/07/10/java-juc-java线程模型与线程/
作者
ares
发布于
2025年7月10日
许可协议