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 | |
线程生命周期与状态
在 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 | |
从上面的代码可以看到,在执行 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 | |
由上面代码可看到,与平台线程类似,虚拟线程也是平台线程的一个实例java.lang.Thread。但虚拟线程的实际执行依赖于由平台线程组成的、小的、共享的线程池,这些平台线程称为载体线程。JVM 在实现上依赖于线程池,默认使用 ForkJoinPool 实例作为调度器,管理这些载体线程,通常其并行度与可用 CPU 核心数相当。
虚拟线程的核心思想是解耦:将业务逻辑(虚拟线程)与底层执行资源(载体线程/OS线程)分离。通过在 I/O 阻塞时动态切换任务,JVM 可以在少量 OS 线程上高效地运行海量的并发任务,同时保留了简单易懂的一个请求一个线程的同步编程模型。
virtual thread 使用
使用 Thread 类和 Thread.Builder 接口创建虚拟线程
1 | |
使用 Executors.newVirtualThreadPerTaskExecutor() 方法创建和运行虚拟线程
1 | |