线程状态与操作系统的用户态、内核态
# 线程状态与操作系统的用户态、内核态
# 你需要知道的
# JMM
Java中的JMM(Java Memory Model)是为了解决多线程环境下并发编程所面临的数据不一致性和同步性问题而诞生的。具体而言,JMM主要致力于解决以下几个关键问题:
- 数据可见性(Visibility): 当多个线程同时访问和修改共享变量时,如果没有适当的同步控制,一个线程对共享变量的修改可能不会立即对其他线程可见。即,线程A修改了某个变量的值,线程B可能无法立即感知到这个变化,还在使用旧的值进行计算,导致数据不一致。JMM通过定义规则确保一个线程对共享变量的修改对于其他线程来说是可见的,通常通过内存屏障、缓存刷新等机制来实现。
- 原子性(Atomicity): 在没有同步的情况下,多线程环境下的某些操作并非原子性的,即这些操作可能会被中断,导致中间状态被其他线程观察到。例如,一个非原子的64位long型变量的写入操作,在某些硬件平台上可能被拆分为两个32位的操作,期间如果线程切换,其他线程可能看到只更新了一半的值。JMM通过提供原子性操作(如
volatile
关键字)、同步块(如synchronized
)和锁(如java.util.concurrent.locks.Lock
接口的实现)来确保特定操作的原子性。 - 有序性(Ordering): 在单线程环境中,程序的执行顺序通常是按照代码的书写顺序进行的。但在多线程环境下,由于编译器优化、处理器乱序执行等因素,线程内部的操作顺序可能与代码逻辑不符,线程间的操作顺序也可能不符合程序员的预期。这种乱序执行可能导致难以预料的后果。JMM定义了 happens-before 关系,为程序中操作的执行顺序提供了明确的规则,保证了在特定条件下的程序执行顺序的可预测性,即使实际执行时可能被重排序,但对程序员来说观察到的效果如同按照指定顺序执行一样。
综上所述,JMM作为Java语言的一部分,为并发编程提供了一套清晰的内存访问规则和同步约定,旨在确保在多线程环境下共享数据的正确访问和更新,避免数据竞争、竞态条件等并发问题,保证程序的正确性和一致性。通过JMM,开发者可以更有效地编写出高效且线程安全的代码,而无需过分关注底层硬件细节和复杂的内存交互行为。
# 线程与进程
在学习线程
前,我们还需要先了解一下线程的“母亲”——进程
。
进程是计算机系统中一个正在进行中的程序关于其所处理数据集合的执行过程。它是由操作系统进行管理和调度的基本单位,代表了系统中运行的程序的一个动态实例。它有如下特性:
- 动态性:进程是一个动态的概念,反映了程序从加载到内存开始执行直到终止的整个生命周期。它经历了创建、执行、暂停(阻塞)、继续(就绪)、结束等一系列状态变迁。
- 独立性:进程作为资源分配的最小单位,具有独立的功能和逻辑上的独立性。每个进程都有自己的虚拟地址空间(包括代码、数据、堆、栈等),并拥有独立的程序计数器、寄存器状态以及系统资源(如文件描述符、信号处理函数等)。不同的进程之间彼此隔离,互不影响,除非通过操作系统提供的通信机制进行交互。
- 并发性:在多任务或多处理器系统中,多个进程可以同时处于运行状态(并行)或看似同时运行(并发),表现为系统中的多个活动任务同时进行。操作系统通过时间片轮转、优先级调度等方式,使得多个进程在处理器上交替执行,给用户带来并发执行的体验。
- 结构组成:进程通常由三部分构成:程序段(包含被执行的机器指令)、数据段(包含全局变量、静态变量、常量等)以及进程控制块(Process Control Block, PCB)。PCB是操作系统用于管理和控制进程运行的重要数据结构,存储了进程的状态信息、优先级、资源分配情况、调度参数、上下文信息等。
- 系统资源分配:进程是操作系统进行资源分配(如CPU时间、内存、I/O设备等)的基本单位。操作系统根据进程的需求和调度策略为其分配必要的资源,确保进程能够正常执行。
- 可包含线程:现代操作系统中,一个进程可以进一步包含一个或多个线程。这些线程共享进程的地址空间和其他资源,但各自拥有独立的执行上下文(如程序计数器、栈等),从而实现进程内部的并发执行。
还是感觉很模糊对吧,举一个具体的例子就是:在windows系统中,你双击某个后缀为.exe程序执行的时候,该.exe文件中的指令就会被加载,那么你就能得到一个有关这个.exe程序的一个进程
或者说进程就是完成一件事或执行一个任务——包含开始到结束的所有步骤集合。而线程就是其中的步骤。
所以它们的关系就是:一个进程可以包含一个或多个线程。或者说:线程是进程的组成部分。
# 线程的状态
在进行并发程序设计时,为什么使用多线程而不是多进程呢?仅仅因为它是程序执行的最小单位吗?并非如此,其实是因为线程间的切换和调度的成本远远小于进程。
线程的所有状态都在Thread中的State枚举中定义,共6种
public enum State {
NEW,
RUNNABLE,
BLOCKED,
WAITING,
TIMED_WAITING,
TERMINATED;
}
2
3
4
5
6
7
8
NEW状态表示刚刚创建的线程,这种线程还没开始执行。等到线程的start()方法调用时,才表示线程开始执行。
当线程执行(或者运行)时,处于RUNNABLE状态,表示线程所需的一切资源都已经准备好了。
# 运行为何是Runnable,而非running
- 操作系统层面的区分:
- 在操作系统层面,线程有两种基本状态:“可运行”(Runnable)和“运行中”(Running)。“可运行”状态指的是线程已经准备好运行,但是还没有实际分配到 CPU 资源;“运行中”状态指的是线程已经获得了 CPU 资源,并正在执行。
- Java 的线程模型与操作系统层面的线程模型保持一致,因此使用“RUNNABLE”来表示线程已经准备好运行,但还没有实际开始运行。
- 线程调度的不确定性:
- 在 Java 中,线程的实际执行是由操作系统调度器决定的。换句话说,Java并不能,线程处于“RUNNABLE”状态时,意味着它可以随时被调度器选中执行,但它不一定立即获得 CPU 时间片。
- 因此,使用“RUNNABLE”来描述这个状态,更准确地反映了线程当前的准备就绪状态,而不是实际的执行状态。
如果线程在执行过程中遇到了synchronized同步块,就会进入BLOCKED
阻塞状态,这时线程就会暂停执行,直到获得请求的锁。
WAITING和TIMED_WAITING都表示等待状态,它们的区别是WAITING会进入一个无时间限制的等待,TIMED_WAITING会进行一个有时间限制的等待。
那等待的线程究竟在等什么呢?一般来说,WAITING的线程正是在等待一些特殊的事件。比如,通过wait()方法等待的线程在等待notify()方法,而通过join()方法等待的线程则会等待目标线程的终止。一旦等到了期望的事件,线程就会再次执行,进入RUNNABLE状态。
而当指定的时间过去后,线程会从 TIMED_WAITING
状态恢复到 RUNNABLE
状态,并重新尝试获取锁。如果锁可用,线程将继续执行;如果锁不可用,线程可能会再次进入 BLOCKED
状态。
当线程执行完毕后,则进入TERMINATED状态,表示结束。
# 线程的创建
# 继承Thread类
public class Demo1 {
public static void main(String[] args) {
System.out.println("hello world");
ATest aTest = new ATest();
aTest.start();
}
}
class ATest extends Thread{
@Override
public void run() {
System.out.println("this is a new Thread");
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
继承Thread类,并重写run()
方法(run方法就是该线程要做的事情)。
最后使用该类调用start()
即可开启一个线程。
Java中的类是单继承,但是类可以实现多个接口。
该种方法的弊端很明显,因为类是单继承,如果要想继承一些其他类就无法实现了。因此该种开启线程的方法很少用。
# 实现Runnable接口
定义一个类,继承Runnable
接口,并重写run()
方法。
然后创建一个Thread对象(A),并使用实现Runnable接口
的类创建对象,并将其作为参数传给Thread的构造方法。
最后 A 调用start()
方法即可开启一个新的线程。
public class Demo2 implements Runnable{
@Override
public void run() {
System.out.println(1024);
}
public static void main(String[] args) {
Thread thread = new Thread(new Demo2());
thread.start();
}
}
2
3
4
5
6
7
8
9
10
11
12
Thread类有一个非常重要的构造方法:
public Thread(Runnable target)
它传入一个Runnable接口的实例,在start()方法调用时,新的线程就会执行Runnable.run()方法。实际上,默认的Thread.run()就是这么做的:
public void run() {
if (target != null) {
target.run();
}
}
2
3
4
5
# 终止线程
现在线程已经会创建了,那么如何停止呢?似乎API中有一个stop()方法,但再仔细些你就会发现,它是一个被废弃的方法、
为什么stop()被废弃而不推荐使用呢?原因是stop()方法太过于暴力,强行把执行到一半的线程终止,可能会引起一些数据不一致的问题。
那么如何终止线程呢?其实最好的方法就在重写run()方法时,通过一些条件判断语句,让这个线程自行结束。比如以下这种:
private boolean flag = false;
private int num = 1;
@Override
public void run() {
while (!flag){
if(num>=10){
this.flag = true;
break;
}else {
System.out.println("num = "+num);
num++;
}
}
}
public static void main(String[] args) {
Thread t1 = new Thread(new Demo2());
t1.start();
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
当然你也可以把“结束线程的条件”,自定义成线程中的方法,这样一来容易我们调用:
private boolean flag = false;
private int num = 1;
public void stopThread(){
this.flag = true;
}
@Override
public void run() {
while (true){
System.out.println("num = "+num);
num++;
if(flag){
System.out.println("Runnable Thread is stop");
break;
}
}
}
// test
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
上述例子中,线程的停止方法就是stopThread()
# 线程中断
那JDK中是否有提供比stop()更强大的支持呢?有的,那就是线程中断。
线程中断并不会使线程立即退出,而是给线程发送一个通知,告知目标线程,有人希望你退出啦!至于目标线程接到通知后如何处理,则完全由目标线程自行决定。
跟线程中断的方法有三个,但它们很相似:
public void Thread.interrupt() // 中断线程
public boolean Thread.isInterrupted() // 判断是否被中断
public static boolean Thread.interrupted() // 判断是否被中断,并清除当前中断状态
2
3
来看个例子吧:
public class Demo3 extends Thread{
@Override
public void run() {
while (true) {
System.out.println("this is new Thread");
}
}
public static void main(String[] args) throws InterruptedException {
Demo3 demo3 = new Demo3();
demo3.start();
demo3.interrupt();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
上述代码中尽管使用了中断方法,demo3线程被置上了中断状态,但是这个中断不会发生任何作用。它仍会不停打印“this is new Thread”
只有我们对其进行相应处理,它才会停下:
@Override
public void run() {
while (true) {
System.out.println("this is new Thread");
if(Thread.currentThread().isInterrupted()){
System.out.println("game is over");
break;
}
}
}
2
3
4
5
6
7
8
9
10
它似乎跟我们自定义的方法很像,但实际它强大得多,因为如果在循环体中,出现了类似于wait()
或者sleep()
这样的操作,则只能通过中断来识别了。
# 扩展
Thread.sleep()方法会让当前线程休眠若干时间,它会抛出一个InterruptedException中断异常。InterruptedException不是运行时异常,也就是说程序必须捕获并且处理它,当线程在sleep()休眠时,如果被中断,这个异常就会产生。
但当Thread.sleep()
方法由于线程接收到中断请求而抛出InterruptedException
时,它确实会清除中断标记。这意味着,在抛出异常之前,线程的中断状态会被设为 true,但在抛出异常之后,中断状态会被复位为 false。
因此,如果你希望在捕获InterruptedException
后保留中断状态,以便其它代码也能感知到线程已被中断,可以在捕获异常后手动再次调用Thread.currentThread().interrupt()
来重新设置中断状态。例如:
try {
Thread.sleep(10000);
} catch (InterruptedException e) {
// 处理中断
Thread.currentThread().interrupt(); // 重新设置中断状态
}
2
3
4
5
6
# 相关问题
- Java默认有几个线程
Java默认有2个线程:main,GC
- Java真的可以开启线程吗
不可以
//java源码:调用native方法(本地方法栈的C++方法),java运行在虚拟机之上,无法直接操作硬件,由C++开启多线程。
private native void start0();
2
- 并行和并发
并发是一个人,交替着做多件事,宏观来看好像一个人同时做多件事
并行是多个人,同时做不同的事
- 并发编程的本质
充分利用cpu资源
# 等待wait()和通知notify()
为了支持多线程之间的协作,JDK提供了两个非常重要的接口线程等待wait()方法
和通知notify()
方法。这两个方法并不是在Thread类中的,而是输出Object
类。这也意味着任何对象都可以调用这两个方法。
当在一个对象实例上调用wait()
方法后,当前线程就会在这个对象上等待。这是什么意思呢?
比如,线程A中,调用了obj.wait()方法,那么线程A就会停止继续执行,而转为等待状态。等待到何时结束呢?线程A会一直等到其他线程调用了obj.notify()方法把它唤醒为止。这时,obj对象就俨然成为多个线程之间的有效通信手段。
如果一个线程调用了object.wait()
,那么它就会进入object对象的等待队列。这个等待队列中,可能会有多个线程,因为系统运行多个线程同时等待某一个对象。当object.notify()
被调用时,它就会从这个等待队列中,随机选择一个线程,并将其唤醒
这里希望大家注意的是,这个选择是不公平的,并不是先等待的线程会优先被选择,这个选择完全是随机的
。
如果某个线程进入了wait(),而没被唤醒,那它将会一直等待。
在进行程序编写的时候,一定注意最后一个线程是否能从wait中醒来,否则你的程序将一直处于运行中,无法停止。
除了notify()
方法外,Object对象还有一个类似的notifyAll()
方法,它和notify()的功能基本一致,但不同的是,它会唤醒在这个等待队列中所有等待的线程,而不是随机选择一个。”
if(number>0){
this.wait();
}
System.out.println("this number");
this.notify();
2
3
4
5
一旦执行了 wait()
方法,当前线程会释放对象的锁并进入等待状态。在等待状态下,线程会暂停执行,不会继续执行 wait()
方法后面的代码,直到被其他线程唤醒。
以上是一个简单的例子。此时不会打印this number。
# 注意
Object.wait()方法并不是可以随便调用的。它必须包含在对应的synchronzied语句中,无论是wait()或者notify()都需要首先获得目标对象的一个监视器。而在他们执行后,也会释放这个监视器。
而Thread类的sleep方法可以在任何地方(sleep可以在任何地方睡觉)
wait不需要捕获异常,sleep需要捕获异常(可能发生超时等待)
Object.wait()和Thread.sleep()方法都可以让线程等待若干时间。除了wait()可以被唤醒外,另外一个主要区别就是
wait()
方法会释放目标对象的锁,而Thread.sleep()
方法不会释放任何资源。怎么记忆呢?可以类比现实生活中,我们睡觉是不会让出床位的,只是无法做事而已
而wait对应——让出,排队的时候,你一旦让了,别人就可以先你一步去缴费了,因此它会让出锁
# 等待线程结束join()和谦让yield()
等待线程结束join()
和 谦让yield()
是两个用于线程交互和调度的方法,分别服务于不同的目的。
很多时候,一个线程的输入可能非常依赖于另外一个或者多个线程的输出,此时,这个线程就需要等待依赖线程执行完毕,才能继续执行。JDK提供了join()
操作来实现这个功能,如下所示,显示了2个join()方法:
public final void join() throws InterruptedException
public final synchronized void join(long millis) throws InterruptedException
2
第一个join()方法表示无限等待,它会一直阻塞当前线程,直到目标线程执行完毕。第二个方法给出了一个最大等待时间,如果超过给定时间目标线程还在执行,当前线程也会因为“等不及了”,而继续往下执行。
比如:当在某个线程 A 上调用线程 B 的 join()
方法时,线程 A 将会阻塞(暂停执行),直到线程 B 完成其任务并终止。
Thread threadB = new Thread(() -> {
// 执行线程B的任务
});
threadB.start();
try {
threadB.join(); // 线程A在此阻塞,直到线程B执行完毕
} catch (InterruptedException e) {
// 处理中断
}
// 这里只有在 threadB 完成后才会执行
System.out.println("Thread B has finished.");
2
3
4
5
6
7
8
9
10
11
12
13
14
yield()
方法是一种线程礼让操作,它提示当前运行线程主动放弃 CPU 时间片,使调度器有机会将 CPU 执行权分配给其他同优先级的就绪线程。调用 yield()
不会阻止线程的执行,只是将其状态由运行态(Running)切换回可运行态(Runnable)。
但要注意,让出CPU并不表示当前线程不执行了。当前线程在让出CPU后,还会进行CPU资源的争夺,当前线程还可能会再次分配到然后继续执行,当然也可能不会,从而让其他线程先执行。
# Java线程跟操作系统
Java线程实际上是依赖于操作系统的原生线程实现的。然后,需要解释操作系统本身确实支持线程和并发,比如通过时间片轮转
或者多核处理器
的并行执行。这里可能需要区分用户级线程
和内核级线程
,但现代Java通常使用内核级线程,也就是1:1模型
Java线程实际上是对操作系统线程的映射
现代JVM实现通常使用内核线程,所以Java线程的创建和销毁成本较高,这也是为什么有线程池的原因。
这也是Redis为什么采用单线程的原因之一,线程切换的开销很大,有时甚至超过了线程本身执行的时间。
用户可能对“映射”这个词有疑问,所以需要详细解释Java线程如何被JVM映射到操作系统的线程上,比如通过POSIX线程(pthread)在Linux上的实现。同时,要提到不同的操作系统可能有不同的线程实现,但JVM会处理这些差异,使得Java线程的行为在不同平台上保持一致。
操作系统的并发,需要说明操作系统通过调度算法管理多个线程或进程,实现并发执行。这里可以提到多核CPU的并行处理,以及单核CPU通过时间分片实现的并发。此外,操作系统提供的同步机制,如互斥锁、信号量等,也是并发操作的重要组成部分。
# 用户级线程与内核级线程
上面的2种不同级别的线程是对于操作系统来说。换句话说,就是电脑-操作系统中有两种线程——用户级线程
和内核级线程
。
值得一提的是,计算机操作系统中的也有两种运行模式,分别是——用户态(User Mode)
和内核态(Kernel Mode)
,用于实现权限隔离和资源保护。
那么用户态线程和内核态线程,是否就是用户态和内核态的直接对应呢?可能不完全一致,但确实有关联。用户态线程是指在用户空间实现的线程,由用户级的线程库(如POSIX线程库pthread)来管理线程的创建、调度和销毁,不需要内核的介入。而内核态线程则是由操作系统内核直接支持的线程,线程的调度和管理由内核负责,每个内核线程对应一个内核可调度的实体。
这里的关键区别在于线程的管理者是谁:用户态线程由用户空间的库管理,而内核态线程由操作系统内核管理。这种管理方式的不同会带来不同的优缺点。例如,用户态线程的切换不需要陷入内核态,因此开销较小,但无法利用多核处理器的并行能力,因为内核并不知道这些线程的存在;而内核态线程虽然切换开销较大,但可以更好地利用多核处理器,并且能够更精细地进行调度。
用户级线程:
定义:用户级线程完全由用户空间的线程库(如Java早期使用的Green Threads、C的Pthreads库等)管理,操作系统内核不感知这些线程的存在。线程的创建、调度、同步等操作均在用户态完成,无需内核介入。
- 轻量级: 线程的创建、切换和销毁完全在用户空间完成,无需陷入内核态,开销极低。
- 无多核并行: 内核仅将整个进程视为一个调度单位,所有用户级线程共享一个内核线程(即进程)。即使系统有多核CPU,用户级线程也无法被分配到不同核心并行执行。
内核级线程:
定义:内核级线程由操作系统内核直接管理,每个线程在内核中对应一个可调度的实体(如Linux的task_struct
)。线程的创建、调度、同步需通过系统调用请求内核完成。
- 内核感知: 每个线程独立作为调度单位,内核可将不同线程分配到不同CPU核心实现并行。
- 阻塞不影响其他线程: 若某线程因I/O或锁阻塞,内核可调度同一进程内的其他线程继续执行。
- 资源开销大: 线程创建、切换需陷入内核态,涉及上下文切换(保存/恢复寄存器、更新页表等),开销显著高于用户级线程。
- 独立性高: 内核可为线程分配独立资源(如信号处理、优先级),并提供隔离保护。
Java线程为什么使用内核态?
用户级线程如果其中一个线程阻塞,比如进行I/O操作,那么整个进程都会被阻塞,因为内核不知道用户级线程的存在,只能看到进程级别的阻塞。而内核级线程的话,一个线程阻塞,其他线程还可以继续运行,因为内核可以调度其他线程。
所以Java使用内核级线程的话,就能避免这个问题,每个线程的阻塞不会影响其他线程。但创建大量线程的话,可能会消耗更多资源,因为每个线程都需要内核资源,比如栈空间、寄存器状态等。而用户级线程可能更节省资源,适合高并发场景,但需要自己处理阻塞问题,或者使用非阻塞I/O。
不过现代服务器通常有足够资源,而且多核普遍,所以Java选择1:1模型更合理。
使用1:1还有一个优势就是,它简化了线程管理,开发者不需要处理复杂的用户态调度,避免阻塞问题。
# 线程的映射再探究
# 为何选择1:1模型?
Java线程与操作系统内核线程的1:1映射(即每个Java线程对应一个内核线程)是主流JVM(如HotSpot)的实现选择:
主要原因包括:
- 充分利用操作系统调度能力:内核线程由操作系统直接调度,可自动适配多核CPU的并行执行,实现真正的并发。例如,在多核环境下,不同线程可以分配到不同核心上运行,无需开发者手动优化。
- 简化开发复杂性:开发者无需自行实现线程调度、同步等底层逻辑,由操作系统统一管理,降低编程难度
- 避免阻塞问题:若某个线程因I/O或系统调用阻塞,其他线程仍能继续执行,避免整个进程被挂起(用户态线程的N:1模型则会面临此问题)
# 1:1模型的劣势
- 资源开销大:每个线程需分配独立的内核资源(如栈空间、线程控制块),导致内存占用较高
- 上下文切换成本高:线程切换需从用户态切换到内核态,涉及寄存器保存、缓存失效等操作,频繁切换会降低性能
# 为何不采用N:1或M:N模型?
N:1模型(用户线程):多个用户线程映射到一个内核线程,虽轻量但存在致命缺陷:
- 无法利用多核CPU:所有线程运行在单个内核线程上,无法并行
- 单点阻塞风险:若某一线程因系统调用阻塞,整个进程的线程均被阻塞,Java早期曾尝试此模型(绿色线程),后因性能问题放弃
M:N模型(混合线程):用户线程与内核线程多对多映射,理论上兼具轻量和并行优势,但实现复杂:
- 开发难度高:需在用户态实现线程调度和同步机制,易引入竞态条件和死锁
- 调试困难:线程状态对操作系统不可见,难以借助系统工具分析问题
混合模型(N:M)如虚拟线程在Java 19引入,处于预览版
# 一个问题
假如cpu只有4核,但Java线程我去启动了6个,此时不就无法实现一对一的映射了吗
1:1模型下,线程的调度由操作系统负责,可以并发执行多个线程,即使线程数超过核心数。操作系统通过时间片轮转等方式进行调度,每个线程轮流使用CPU资源,因此即使有6个线程,它们依然可以在一对一的模型下运行,只是并发执行时会频繁切换上下文。