目录
线程基础
线程和进程
进程
线程
进程和线程的区别
进程间通信方式
线程的同步互斥
上下文切换
内核模式和用户模式
CPU保护环
操作系统层面线程生命周期
Java线程详解
Java线程的实现方式
Thread
Runnable
Callable
lambda
线程创建和启动的流程
Java线程的实现原理
thread.start()源码分析
Java线程 → 内核线程
协程
Java线程调度机制
协同式调度
抢占式调度
Java线程调度
Java线程生命周期
thread常用方法
sleep
yield
join
stop
Java线程中断机制
api
sleep感受中断
Java线程间通信
volatile
等待唤醒机制
管道输入输出流
在开始研究java线程之前,我们先回想一下线程相关的知识
再分清楚进程和线程
程序由指令+数据组成,但是这些指令要运行,数据要进行读写,必须将指令加载到CPU,数据加载到内存,指令的运行过程中,还需要用到磁盘、网络等等的设备,进程,即使用来加载指令,管理内存、管理IO的
当一个程序被运行,从磁盘加载这个程序的代码到内存,这个时候,就开启了一个进程,比如,你电脑上打开运行了一个杀毒软件,这就开启了一个进程
看到没,这些玩意,就是一个个应用进程
进程,可以当做是程序的一个实例,大部分程序可以同时运行多个实例进程的,比如,浏览器,记事本,but,有的就只能启动一个,比如,音乐APP
操作系统会以进程为单位,分配系统的资源,所谓资源,就是CPU时间片啊,内存啊这些的,进程是资源分配的最小单位
别迷糊啊,线程和进程不是一个东西,线程,是进程中的实体,一个进程可以有多个线程,但,一个线程必须有一个父进程,也就是说,一个线程一定有它归属的进程
一个线程,其实就是一个指令流,将指令流中的一条条指令,以一定的顺序来交给CPU执行
线程,也会被成为轻量级进程,是操作系统调度的最小单位,也就是说,是CPU调度执行的最小单位
别混,别混,你看,就像这玩意,就是一个个线程
ok,单独解释了进程和线程,我们来直截了当的对比区别吧
进程间有一些通信方式
补充一下信号量和PV操作
在操作系统中,进程间经常会存在互斥(都需要共享独占性资源的时候)和同步(完成异步的两个进程的写作)两种关系,为了有效的处理这两种情况,就有了信号量和PV操作
信号量:是一种特殊的变量,表现形式是一个整型S和一个队列
P操作:S = S - 1,若 S < 0,表示当前没有资源分配给该进程,进程暂停执行,进入等待队列
V操作:S = S + 1,若 S ≤ 0,表示阻塞队列中有等待该资源的进程,唤醒等待队列中的第一个进程
信号量与PV操作是用来解决并发问题的,信号量的初值就是表示资源的可用数,而且通常对于初始为0的信号量,会先做V操作
在资源使用之前,会先进行P操作,资源使用完成后,进行V操作
在互斥关系中,PV操作是在一个进程中成对出现的,而在同步关系中,PV操作一定是在两个进程甚至多个进程中成对出现的
互斥控制,就是为了保护共享资源,不让多个进程同时访问这个共享资源
线程同步,指的是线程间具有的一种制约关系,一个线程的执行依赖于另一个线程的消息,当另一个线程的消息没到达时,它应该等待,直到另一个线程的消息到达 比如,一个http请求到Tomcat,再到Java程序
线程互斥,是指对于个共享的进程系统资源,在各单个线程访问时的排他性,当有多个线程需要访问同一个共享资源时,任意时刻只能有一个线程去使用,其他线程必须等待,直到占用资源的线程释放资源,线程的互斥,可以看成一种比较特殊的线程同步关系 比如,synchronized以及同步锁
有一些线程同步互斥的控制方法
-- 临界区:通过对多线程串行化来访问公共资源或者某一段代码,速度快,适合用来做控制数据访问
(在一段时间内只允许一个线程访问的资源就被称为临界资源)
-- 互斥量:为了协调对一个共享资源的单独访问所设计
-- 信号量:为了控制一个具有有限数量用户资源而设计
-- 事件:用来通知线程有一些事情已经发生,从而可以启动后续任务
上下文切换,就是指CPU从一个进程或者线程,到另一个进程或者线程的切换
上下文切换可以更详细一点的描述为内核对CPU上的进程/线程执行以下活动:
所谓内核,其实就是指操作系统的核心
这种分时操作,可以提高CPU的利用率
补充:上下文是CPU寄存器和程序计数器在任何时间点的内容;寄存器是CPU内部的一小部分非常快的内存,当然,这是相对于CPU外部的RAM主内存来说,它通过提供对常用值的快速访问来加快计算机程序的执行;程序计数器是一种专门的寄存器,它指示CPU在其指令序列中的位置,并保存着正在执行的指令的地址或者下一条要执行的指令的地址,这取决于具体的操作系统
我们既然提到了上下文切换,那就得注意这么几个地方
我们可以通过命令 vmstat 1 来查看CPU每秒的上下文切换统计,其中,cs 列就是CPU的上下文切换统计, 注意! 注意!上下文切换不等价于线程切换,很多操作都会造成CPU上下文切换,比如线程/进程切换、系统调用、中断
(不清楚其他参数什么意思的话,直接搜一下这个命令吧~ 我不是运维,这些我也接触不到,都是用什么找什么)
我们可以用命令查看某个线程/进程的切换情况
比如,我要查看 PID 59657 的这个进程,每秒的切换情况
pidstat -w -p 59657 1
cswch表示主动切换,nvcswch表示被动切换
如果没有这个命令,也可以用cat命令去查看,以刚才这个为例子,我就是
cat /proc/5598/status
看我选中的这两项,就是进程从启动到现在的总的上下文切换情况
上面的,代表主动切换,下面的,代表被动切换,仔细看,下面的前面多了个non
其实查看上下文还是有用的,比如一个Java程序,进行了大量的主动切换,那可能,这个程序存在大量的休眠或者释放操作
在现代操作系统中,CPU实际上都在两种截然不同的模式中花费时间,分别是内核模式和用户模式
在内核模式下,执行代码可以完全不受限制地访问底层硬件,它可以执行任何CPU指令和引用任何内存地址,内核模式通常为操作系统的最低级别,最受信任的功能保留,内核模式下的崩溃是灾难性的,它们会让整个电脑瘫痪
在用户模式下,执行代码不能直接访问硬件或者引用内存,在用户模式下运行的代码,必须委托给系统API来访问硬件或内存,由于这种隔离提供的保护,用户模式下的崩溃总是可以恢复才,在计算机上运行的大多数代码都将在用户模式下执行
应用程序一般会在以下几种情况从用户态切换到内核态:
这里补充一个,CAS,compare and swap ,它涉及的是原子指令,CPU可以直接执行,不涉及内核态和用户态的切换
提到了内核模式和用户模式,还有一个东西可以了解接触一下,分级保护域,可以叫做保护环,也叫作环形保护,也叫作CPU环,简称Rings,这是一种用来在发生故障时保护数据和功能,提升容错度,避免恶意操作,提升计算机安全的设计方式
工作在不同Ring中的对象对资源有不同的访问级别,Rings是从最高特权级到最低特权级排列,通常是数字越小级别越高
在大多数操作系统中,Ring0 具有最高特权,并且可以和最多的硬件直接交互,比如CPU,比如内存,同时内层Ring可以随便使用外层Ring的资源
Ring0-2 为管理员层级,可以做大部分事情,但是Ring1-2 不能使用特权指令
Ring3 代表用户模式
在 X86架构下,CPU提供了四个保护环,通常,只是用0环-内核,以及3环-用户
事实上,Rings的概念最早出现于x86保护模式的设计中
Ring 的设计将用户程序和服务程序对资源的利用进行隔离,正确使用 Ring 可以提升资源使用的安全性,比如,某个病毒程序作为一个 Ring3 运行的用户程序,它尝试在不通知用户的情况下打开硬件摄像头,就应该被阻止掉,因为访问硬件,需要 Ring1 甚至 Ring 0
操作系统层面的线程生命周期为五种:初始状态、可运行状态、运行状态、休眠状态、终止状态
初始状态:指的是线程已经被创建,但是还不允许分配CPU执行,这个状态属于编程语言特有的,注意,这个所谓的被创建,指的是在编程语言层面被创建,并非在操作系统中已经创建,在操作系统层面,真正的线程还没有创建
可运行状态:指的是线程可以分配CPU执行,在这种状态下,真正的操作系统线程已经被成功撞见了,所以可以分配CPU执行
运行状态:当有空闲的CPU时,操作系统会将其分配给一个处于可运行状态的线程,被分配到CPU的线程的状态就会变为运行状态
休眠状态:运行状态的线程如果调用一个阻塞 API 或者等待某个事件,那么线程的状态就会变为休眠状态,同时释放 CPU 使用权,休眠状态的线程永远没机会获得CPU使用权,当等待的事件出现了,线程就会从休眠状态转换到可运行状态
终止状态:线程执行完或者出现异常就会进入到终止状态,终止状态的线程不会切换到其他任何状态,进入终止状态,也就意味着线程对生命周期结束了
这五种状态并非在编程语言里都有,在某些编程语言里,可能会进行状态的简化合并,比如在C语言,就把初始状态和可运行状态合并了;在Java里,把可运行状态和运行状态合并了,这两个状态在操作系统调度层面有用,但jvm层面并不关心这两个状态,因为jvm把线程调度交给操作系统处理了
ok,现在我们来看Java线程相关的知识
首先,就是Java中线程的实现方式,这里不过多介绍,简单说一下就行
直接使用 Thread 类即可,或者继承 Thread 类
实现 Runnable 接口,实现后,再配合 Thread 即可使用
实现 Callable 接口,与 Runnable 不同,Callable 接口有返回值
直接new Thread 去运行
其实本质上只有一种,那就是通过 new Thread() 来创建线程
1、使用 new Thread() 创建一个线程,然后调用 .start() 方法进行 Java 层面的线程启动
2、调用本地方法 start0() ,去调用JVM中的JVM_StartThread方法进行线程的创建和启动
3、调用 new JavaThread(&thread_entry, sz) 进行线程的创建,并根据不同的操作系统平台,调用对应的OS::create_thread方法进行线程创建
4、新创建的线程状态为 initialized,调用了sync → wait() 的方法进行等待,等到被唤醒才继续执行 thread → run()
5、调用 Thread::start(native_thread)方法进行线程启动,此时将线程状态设置为 RUNNABLE,接着调用OS::start_thread(thread),根据不同的操作系统选择不同的线程启动方式
6、线程启动之后,状态设置为RUNNABLE,并唤醒第四步中等待的线程,接着执行thread → run()的方法
7、JavaThread::run()方法会回调第一步new Thread()中复写的run()方法
Java thread → JVM thread → OS thread
会发生用户态和内核态切换,系统调用,所以说在Java中,创建一个线程是重量级操作
谈到实现原理,我们不得不对一个问题的答案有认知,那就是为什么调用.start()方法,而不是.run()方法
public synchronized void start() {
if (threadStatus != 0)
throw new IllegalThreadStateException();
group.add(this);
boolean started = false;
try {
start0();
started = true;
} finally {
try {
if (!started) {
group.threadStartFailed(this);
}
} catch (Throwable ignore) {
/* do nothing. If start0 threw a Throwable then
it will be passed up the call stack */
}
}
}
可以看到,上来先判断了线程状态,初始的时候为0,如果不是0,那就直接抛异常
后面最重要的,其实就是调用了 start0()这个方法
哦吼,本地方法,没招了,撸源码吧,看JVM吧
OK,可以看到,调用了 JVM_StartThread,这个东西在哪儿呢?就在这儿
src/java.base/share/native/libjava/Thread.c
可能你会好奇,为什么是这儿,其实是因为 Thread 类你一创建,就会执行一个地方
看,static修饰,这也是个本地方法 registerNatives,这个方法会调用注册方法,完成相关方法和JVM方法的映射绑定,也就是上面那一大堆玩意
在 JVM_StartThread 中,有一个地方, 在创建 Thread ,位置在这儿
src/hotspot/share/prims/jvm.cpp
而这个 JavaThread,也有对应的实现
src/hotspot/share/runtime/thread.cpp
可以看到,这块开始调用操作系统去创建Java线程对应的内核线程,真正去创建一个线程
既然调用了os的,那我们试试看,有没有对应的实现,搜索一下,发现有一些
我们就看看Linux的吧
可以看到,这里面就是Linux的实现(恕我菜看不懂,而且主要整理Java,C和C++就不搞太多了)
在这儿,线程创建完了,但是,别忽略一个问题,我们从Java线程到JVM线程到OS线程,最后落在了OS线程,但是OS线程不是我们的Java线程,所以需要进行绑定,并进行相应的状态变更(这儿我真看不懂除了Java以外的语言,就不乱分析了,有兴趣有能力的可以看看JDK源码)
从上面我们就可以看到,Java线程属于内核级别的线程,基于操作系统的原生线程模型来实现,一个Java线程就映射到一个轻量级进程之中
补充一下内核级线程和用户级线程
内核级线程:依赖于内核,即无论是用户进程中的线程,还是系统进程中的线程,它们的创建、撤销、切换,都由内核实现
用户级线程:操作系统内核不知道应用线程的存在
我们提到了用户级线程和内核级线程,就会涉及到一个玩意,协程
协程是一种基于县城至上,但比线程更加轻量级的存在,协程不是被操作系统内核所管理的,而是完全由程序控制,也就是用户态执行,具有对内核来说不可见的特性,这样的好处就是性能得到了很大的提升,不会像线程切换那样耗费资源
给大家画一幅图
协程的特点在于是一个线程执行,和多线程相比,协程有一些优势
但是,协程适用于被阻塞的,且需要大量并发的场景,比如网络IO,不适合大量计算的场景
线程调度是指系统为线程分配处理器使用权的过程,主要调度方式分两种,分别是协同式线程调度和抢占式线程调度
线程执行时间由线程本身来控制,线程把自己的工作执行完之后,主动通知系统切换到另外一个线程上,好处是实现简单,且切换操作对线程自己是可知的,没有线程同步问题,坏处是线程执行时间不可控制,如果一个线程有问题,可能一直阻塞在那里
每个线程将由系统来分配执行时间,线程的切换不由线程本身来决定,线程执行时间系统可控,不会有一个线程导致整个进程阻塞
Java的线程调度,属于抢占式调度,Thread.yield()可以让出执行时间,但无法获取执行时间
如果希望系统能给某些线程多分配一些时间,给一些线程少分配一些时间,可以通过设置线程优先级来完成,Java一共有10个线程优先级,Thread.MIN_PRIORITY至Thread.MAX_PRIORITY,在两线程同时处于ready状态时,优先级越高的线程越容易被系统选择执行
但是,优先级不是一定靠谱,因为Java线程是通过映射到系统的原生线程上来实现的,所以线程的调度最终还是得取决于操作系统
OK,上面说了Java线程的东西,我们来说Java线程的最后一点,生命周期
Java线程有六种状态
我们也可以从 Thread 类看到
在操作系统层面,Java 线程中的 BLOCKED、WAITING、TIMED_WAITING 是一种状态,即休眠状态,也就是说只要 Java 线程处于这三种状态之一,那么这个线程就永远没有 CPU 的使用权
上图
Thread类的常用方法,大家一定都用过,我们不讲怎么用,只补充一些其他相关的信息
调用sleep会让当前线程从Running进入到TIMED_WAITING状态,但,不会释放锁对象
其他线程可以使用 interrupt 方法打断正在休眠的线程,这时 sleep 方法会抛出中断异常 InterruptedException,并会清除中断标志
睡眠结束后,线程未必会立刻得到执行
sleep当传入的参数为0时,效果与yield相同
释放CPU资源,让当前线程从Running进入Runnable状态,让优先级等于或高于自己的线程获得执行机会,但,不会释放锁对象
假设当前进程只有main线程,当调用yield之后,main线程会继续运行,因为,没有比它优先级更高的线程了
具体的实现依赖于操作系统的任务调度器
这个没什么补充的,其实就是等待调用join的线程结束之后,程序再继续执行,适合需要等待异步执行结果的场景
可以理解成线程合并,当在一个线程调用另一个线程的join方法时,当前线程阻塞等待被调用join方法的线程执行完毕才能继续执行,所以join的好处能够保证线程的执行顺序,不过,如果调用线程的join方法其实已经失去了并行的意义,虽然存在多个线程,但是本质上还是串行的
呐,还有一件事,join的实现是基于等待通知机制的
已经被JDK废弃,太过暴力,强行把执行到一半的线程终止,会释放锁对象
Java中,没有提供一种安全的,可以直接停止某个线程的方法,而是提供了中断机制,它是一种协作机制,也就是说,不能直接中断某个线程,而是由被中断的线程自己处理,被中断的线程有完全的自主权,可以选择任何时候停止,也可以选择,不停止
注意这三种的区别
注意!!!使用中断机制的时候,一定一定要注意是否存在中断标志位被清除的情况
我们已经说过好几次,sleep中断会清除标志
我们来做个测试,加深一下印象
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(() -> {
int count = 0;
while (!Thread.currentThread().isInterrupted() && count <= 10000) {
System.out.println("count = " + count++);
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("thread is end......");
});
thread.start();
Thread.sleep(500);
thread.interrupt();
System.out.println("main is running........");
}
可以看到,它在sleep的时候,的确感受到了,但是,它把标志位又清除了,又继续了
我们在catch中给它再改回去试试
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(() -> {
int count = 0;
while (!Thread.currentThread().isInterrupted() && count <= 10000) {
System.out.println("count = " + count++);
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
Thread.currentThread().interrupt();
}
}
System.out.println("thread is end......");
});
thread.start();
Thread.sleep(500);
thread.interrupt();
System.out.println("main is running........");
}
可以看到,结束了,所以说,如果用了中断机制,一定要记得改掉
另外,不止sleep,wait方法也是一样的,也会清除中断标志位
我们采用多线程是为了提高效率,不可避免的,可能会需要进行线程间通信
我们之前看过volatile一点内容,其中,可见性,其实就是让线程间进行通信
等待唤醒机制,或者说,等待通知机制,也是线程间通信的方式
等待唤醒机制可以基于wait和notify方法来实现,在一个线程内调用该线程锁对象的wait方法,线程将进入等待队列进行等待直到被唤醒
在JDK中,我们可以使用LockSupport来实现阻塞和唤醒,线程调用park,等待‘许可’的发放,调用unpark,给指定线程发放‘许可’,它可以在任何场合使线程阻塞,可以指定任何线程进行唤醒,并且不用担心阻塞和唤醒操作的顺序,但,连续多次唤醒和一次唤醒效果是一样的
我们先来看阻塞一次唤醒一次的效果
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(() -> {
System.out.println("thread is park...... " + System.currentTimeMillis());
LockSupport.park();
System.out.println("thread is unpark......" + System.currentTimeMillis());
});
thread.start();
Thread.sleep(5000);
LockSupport.unpark(thread);
}
我们再来看阻塞多次,唤醒多次的效果
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(() -> {
System.out.println("thread is park...... " + System.currentTimeMillis());
LockSupport.park();
System.out.println("thread is unpark1......" + System.currentTimeMillis());
LockSupport.park();
System.out.println("thread is unpark2......" + System.currentTimeMillis());
});
thread.start();
LockSupport.unpark(thread);
Thread.sleep(1000);
LockSupport.unpark(thread);
}
注意,我们提到了,park和unpark没有顺序问题,本质就是发放许可,我们可以先发放许可
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(() -> {
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("thread is park...... " + System.currentTimeMillis());
LockSupport.park();
System.out.println("thread is unpark......" + System.currentTimeMillis());
});
thread.start();
LockSupport.unpark(thread);
}
wait\notify机制,是moniter提供的,依赖于synchronized,也就是说,wait方法必须在synchronized加锁内部使用,notify没有绑定参数,也就是说,不一定唤醒的是哪个线程,所以一般用notifyAll,再加判断,防止虚假唤醒
注意哦,wait方法会释放锁,它要不释放锁,别的线程怎么获取锁,别的线程不获取锁,这个线程永远没机会被唤醒了
别混,千万别混,sleep不会释放锁,它只是让出了CPU而已
提到输入输出流,大部分人第一印象应该都是文件输入输出流或者网络的输入输出流,当然我自己也是这大部分人中的一个
管道输入输出流,和文件、网络的输入输出流不一样的地方在于,它主要用于线程间的数据传输,传输的媒介,就是内存
管道的输入输出流,包含四种实现:PipedOutputStream、PipedInputStream、PipedReader和PipedWriter,前两种字节,后两种字符
public static void main(String[] args) throws Exception {
PipedReader in = new PipedReader();
PipedWriter out = new PipedWriter();
out.connect(in);
Thread thread = new Thread(new RunnableTask(in));
thread.start();
try {
int tag = 0;
while ((tag = System.in.read()) != -1) {
out.write(tag);
}
} finally {
out.close();
in.close();
}
}
static class RunnableTask implements Runnable {
private PipedReader in;
public RunnableTask(PipedReader in) {
this.in = in;
}
@SneakyThrows
@Override
public void run() {
int tag = 0;
while ((tag = in.read()) != -1) {
System.out.println((char) tag);
}
}
}
好了,本次的分享总结到此为止,嘛,祝各位开心~