大家好,我是半夏之沫 😁😁 一名金融科技领域的JAVA系统研发😊😊
我希望将自己工作和学习中的经验以最朴实,最严谨的方式分享给大家,共同进步👉💓👈
👉👉👉👉👉👉👉👉💓写作不易,期待大家的关注和点赞💓👈👈👈👈👈👈👈👈
👉👉👉👉👉👉👉👉💓关注微信公众号【技术探界】 💓👈👈👈👈👈👈👈👈
前言
说到并发
,离不开线程
这个概念,在操作系统层面有线程,在JAVA
进程层面也有线程。同时还知道线程既可以共享进程资源
,又可以被独立调度
,那么JAVA
进程的线程,也肯定存在一种调度机制
。本篇文章将对操作系统的线程的三种实现方式进行说明,然后再对JAVA
线程的实现方式进行阐述,最后针对JAVA
线程的调度机制进行解释并结合例子说明JAVA
线程调度机制的应用。
本篇文章参考资料为《深入理解JAVA虚拟机》
第3
版,第12
章。
正文
一. 操作系统的线程实现
1. 内核线程实现
内核线程
的实现又叫做1:1实现
。内核线程(Kernel-Level Thread,KLT
)就是由操作系统内核(Kernel
)支持的线程,内核线程的调度由调度器(Scheduler
)负责,调度器同时也负责将内核线程的任务映射到CPU
核心上。
通常应用程序不会直接使用内核线程,而是使用内核线程提供的轻量级进程(Light Weight Process,LWP
),每个轻量级进程都由一个内核线程支持。
内核线程的实现模型如下图所示(LWP
与KLT
是1:1
关系)。
内核线程的实现方式的优点
如下:
简单
。无需考虑线程的创建,销毁,切换和调度等;安全
。一个轻量级进程在系统调用时被阻塞,不会阻塞整个进程。
内核线程的实现方式的缺点
如下。
- 对线程的操作涉及
用户态
与内核态
的切换,对效率有影响; - 操作系统支持的
轻量级进程数
是有限
的。
2. 用户线程实现
用户线程
的实现又叫做1:N实现
。用户线程(User Thread,UT
)是建立在用户空间的线程库
上,系统内核无法感知其实现与存在。
用户线程的实现模型如下图所示(进程
与UT
是1:N
关系)。
用户线程的实现方式由于不需要操作系统内核的支持,所以具有以下优点。
快
。线程的创建,销毁,切换和调度完全在用户态
中完成,不需要切换到内核态
,因此线程相关的操作可以非常快
;多
。用户线程相较于内核线程,消耗更小,能够支持更大规模的线程数量。
也正是由于用户线程不需要操作系统内核的支持,所以用户线程的实现有如下缺点
。
程序复杂
。因为用户程序需要考虑线程的创建,销毁,切换和调度等,而在内核线程的实现中,这些问题是由操作系统内核来完成;容易阻塞进程
。由于操作系统只把CPU
资源分配到进程,那么如果用户线程实现不当,容易出现一个用户线程的阻塞导致整个用户进程的阻塞;难以利用多核CPU
。用户程序中需要考虑如何将用户线程任务映射到其它CPU
核心上,这往往是很困难
的。
3. 混合实现
混合实现
又叫做N:M实现
。在混合实现中,内核线程
与用户线程
并存,用户线程的创建,销毁和切换在用户态
中完成,同时轻量级进程LWP
作为用户线程UT
与内核线程KLT
的桥梁,使得用户线程的调度和CPU映射可以由操作系统内核完成。
混合实现的模型如下图所示(UT
和LWP
是N:M
关系)。
混合模式的优点
就是:能够支持大规模
的线程数量,同时还能将线程的调度和CPU映射交由操作系统内核完成,并且也不容易出现一个用户线程阻塞导致整个用户进程阻塞的情况。
二. JAVA
线程的实现
通常(JDK
1.3以后的HotSpot
虚拟机),JAVA
线程的实现采用的是1:1模型
,也就是我们通过new Thread()
创建出来的JAVA
线程会直接映射到一个内核线程,从而JAVA
线程的调度
由操作系统内核来完成。
三. JAVA
线程的调度
已知JAVA
线程的调度完全由操作系统
来完成,而操作系统调度主要有两种方式。
第一种是协同式线程调度
:线程的执行时间由线程本身来控制,线程执行完任务后,需要主动通知操作系统切换到另外一个线程。这种方式的优点
是实现简单
,缺点
是可能会因为代码自身问题导致线程一直不通知操作系统进行切换,最终导致其它线程一直得不到CPU
资源而成为饥饿线程
。
第二种是抢占式线程调度
:由操作系统来为线程分配时间片
(执行时间),将时间片分配给哪个线程也完全由操作系统决定。这种方式由于CPU
资源的分配是操作系统来决定,所以不会有饥饿线程
的出现。
JAVA
线程的调度采用的就是抢占式线程调度
。一个JAVA
线程何时得到时间片,完全由操作系统决定。
四. Thread#yield
方法详解
通过前面的讨论已知,JAVA
线程的实现模型是1:1实现
,即一个JAVA
线程直接映射到操作系统的一个内核线程,所以JAVA
线程的调度由操作系统来实现(内核操作调度器来调度线程),并且调度方式是抢占式线程调度
。
所以一个JAVA
线程何时得到时间片是JAVA
线程无法控制的,但是JAVA
线程可以通过Thread#yield
方法来决定放弃分配给自己的时间片。Thread#yield
方法的官方注释如下。
向调度器示意当前线程愿意放弃当前使用的处理器。
Thread#yield
的使用,可以先看一下如下的一个场景。
线程1需要计算1-100万的累加和,线程2需要在线程1计算完毕后,打印这个累加和。
通常上述场景可以使用下面的程序实现
public class YieldTest {
volatile long finalSum = -1;
@Test
public void Yield的占用CPU资源的例子() throws Exception {
Thread thread1 = new Thread(() -> {
long tempSum = 0;
for (long num = 1; num < 100000000; num++) {
tempSum += num;
}
System.out.println(Thread.currentThread().getName() + " 执行完毕");
finalSum = tempSum;
}, "线程1");
thread1.start();
Thread thread2 = new Thread(() -> {
while (finalSum == -1) {
// empty run
}
System.out.println("累加结果是:" + finalSum);
});
thread2.start();
// 主线程等待线程2执行完毕
thread2.join();
}
}
线程2分配到时间片后,会循环的判断finalSum的值,但finalSum只有在线程1完成累加计算后才会被赋值,而同一时刻时间片又只能给线程1或者线程2,则会出现线程2无效占用时间片的情况(浪费CPU
资源),拖慢线程1累加计算的速度。
为了避免上述问题的出现,可以进行如下改进。
public class YieldTest {
volatile long finalSum = -1;
@Test
public void Yield的放弃CPU资源的例子() throws Exception {
Thread thread1 = new Thread(() -> {
long tempSum = 0;
for (long num = 1; num < 100000000; num++) {
tempSum += num;
}
System.out.println(Thread.currentThread().getName() + " 执行完毕");
finalSum = tempSum;
}, "线程1");
thread1.start();
Thread thread2 = new Thread(() -> {
while (finalSum == -1) {
Thread.yield();
}
System.out.println("累加结果是:" + finalSum);
});
thread2.start();
// 主线程等待线程2执行完毕
thread2.join();
}
}
也就是每次线程2分配到时间片后,如果判断finalSum还没有完成赋值,则通过Thread.yield
方法及时放弃时间片,好让线程1尽可能的拿到时间片并完成累加计算。
特别注意:Thread.yield
方法是会导致线程上下文切换的(线程上下文切换会有资源和性能的开销,且线程上下文切换会导致用户态与内核态的切换),所以不宜频繁的调用Thread.yield
方法,除非可以肯定另外一个线程很快就能执行结束并输出当前线程期望的结果,那么可以使用Thread.yield
方法来帮助另外一个线程更快的完成任务执行。
五. 设置线程优先级
前面提到,JAVA
线程是由操作系统基于抢占式调度
的方式来分配时间片,实际上操作系统会让优先级更高的线程有更大概率获得时间片。在Thread
类中,提供了Thread#setPriority
方法来设置JAVA
线程的优先级。
JAVA
中为线程优先级提供了十个级别(1
到10
),在两个线程状态都是Ready(等待分配时间片)时,优先级更高的线程有更大概率分配到时间片。
如下是一个示例。
public class ThreadPriorityTest {
@Test
public void 线程优先级简单示例() throws Exception {
// 计数任务
Runnable runnable = new Runnable() {
@Override
public void run() {
long count = 0;
while (count != 2000000000) {
count++;
}
System.out.println(Thread.currentThread().getName() + " 计数完成");
}
};
// 线程1,优先级1
Thread thread1 = new Thread(runnable, "线程1");
thread1.setPriority(1);
// 线程2,优先级5
Thread thread2 = new Thread(runnable, "线程2");
thread2.setPriority(5);
// 线程3,优先级10
Thread thread3 = new Thread(runnable, "线程3");
thread3.setPriority(10);
// 同时运行三个线程
thread1.start();
thread2.start();
thread3.start();
// 主线程等待5秒
Thread.sleep(5000);
}
}
运行测试程序,结果如下。
总结
操作系统的线程实现,有如下三种方式。
- 内核线程实现(
轻量级进程
与内核线程
是1:1
关系)。优点
是无需考虑线程的创建,销毁,切换和调度,且一个轻量级进程的阻塞不会导致整个进程的阻塞。缺点
是线程操作的效率不高,且无法支持大规模的线程数量; - 用户线程实现(
进程
与UT
是1:N
关系)。优点
是线程操作全部在用户态
中完成,线程操作可以很快,且能够支持大规模的线程数量。缺点
是用户程序实现复杂,难以利用多核CPU
的优势,且容易出现一个线程的阻塞导致整个进程的阻塞; - 混合实现(
用户线程
与轻量级进程
是N:M
关系)。优点
是能够支持大规模
的线程数量,同时还能将线程的调度和CPU映射交由操作系统内核完成,并且也不容易出现一个用户线程阻塞导致整个用户进程阻塞的情况。
在JDK
1.3以后的HotSpot
虚拟机中,JAVA
线程的实现采用的是1:1
模型,即一个JAVA
线程直接映射到一个内核线程。
JAVA
线程的调度是由内核调度器进行调度,调度方式是抢占式线程调度
,一个JAVA
线程何时被分配时间片,完全由操作系统决定。
在JAVA
线程中可以通过Thread#yield
方法来放弃时间片,但是放弃时间片(或时间片耗尽)会导致线程上下文切换,所以不宜频繁调用Thread#yield
方法。
可以通过Thread#setPriority
方法来为JAVA
线程设置优先级,JAVA
提供了十个级别(1
到10
),优先级更高的线程有更大概率获得时间片,但并不是绝对的。
大家好,我是半夏之沫 😁😁 一名金融科技领域的JAVA系统研发😊😊
我希望将自己工作和学习中的经验以最朴实,最严谨的方式分享给大家,共同进步👉💓👈
👉👉👉👉👉👉👉👉💓写作不易,期待大家的关注和点赞💓👈👈👈👈👈👈👈👈
👉👉👉👉👉👉👉👉💓关注微信公众号【技术探界】 💓👈👈👈👈👈👈👈👈