- 线程是比进程更轻量级的调度执行单位,CPU调度的基本单位就是线程。
- 线程的引入,将一个线程的资源分配和执行调度分开。
- 各个线程既可以共享进程资源(内存地址、文件I/O等),又可独立调度。
概念
线程的生命周期状态
通用的线程生命周期
首先,通用的线程生命周期模型将线程的状态分为了以下五种:
-
初始状态
- 线程仅仅在编程语言层面被创建,在操作系统中并没有被创建,因此还不能被分配CPU资源。
- 相当于现在只是在Java中new了个Thread对象,还没调用start()方法。
-
可运行状态
- 真正的操作系统线程此时已经成功被创建,线程已经可以被分配CPU资源了。
-
运行状态
- 当有空闲的CPU资源时,操作系统会将其分配给一个处于可运行状态的线程,可运行状态的线程一旦被分配的CPU,它的状态将变为运行状态。
-
休眠状态
- 运行状态的线程如果调用了某个阻塞式API(如以阻塞方式读文件),那么这个线程将变为休眠模式,并放弃自己的CPU使用权;
- 当它的阻塞状态结束了,它的状态会变为可运行状态,等待再次被分配CPU资源。
-
终止状态
- 当线程执行完成或出现异常,它就会进入终止状态,这是一个终态(只进不出的饕餮状态),就是挂了。
Java中线程的生命周期
主要有三条链路:
- RUNNABLE -> BLOCKED/WAITING/TIME_WAITING
- NEW -> RUNNABLE
- RUNNABLE -> TERMINATEd
线程的生命周期状态转换
可运行/运行的状态 -> 休眠状态
-
RUNNABLE -> BLOCKED
- 线程等待synchronized的隐式锁时,触发该状态转换。
-
RUNNABLE -> WAITING
-
已获取synchronized隐式锁的线程,调用无参数的Object.wait()方法。
-
调用无参数的Thread.join()方法。
- 因为Thread.join()其实就是通过调用线程对象本身的wait(0)方法实现的。
-
调用LockSupport.park()方法。
-
-
RUNNABLE -> TIME_WAITING
- 调用带超时参数的Thread.sleep(long millis)方法。
- 获得synchronized隐式锁的线程,调用带超时参数的Object.wait(long timeout)方法。
- 调用带超时参数的Thread.join(long millis)方法。
- 调用带超时参数的LockSupport.parkNanos(Object blocker, long deadline)方法。
- 调用带超时参数的LockSupport.parkUntil(long deadline)方法。
LockSupport对象说明:
Java并发包中的锁,都是基于该对象实现的,使用方法如下:
- 调用LockSupport.park()方法,当前线程会阻塞,线程的状态会从RUNNABLE转换到WAITING。
初始状态 -> 可运行/运行的状态
Java刚创建出来的Thread thread对象就是NEW状态,调用了thread.start()方法后,线程就进入了RUNNABLE状态。
可运行/运行的状态 -> 终止状态
- 线程执行完run()方法后,会自动切换到TERMINATED状态。
- 线程执行完run()方法后,有异常抛出,线程也会被终止。
如何强制中断run()方法的执行?
当run()方法中调用了一个耗时很长的方法时,我们等的不耐烦了,此时我们需要强制中断run()方法的执行。
在Java的Thread中,倒是给我们提供了一个stop()方法,不过该方法已经被标记为@Deprecated的了。不推荐它的原因是因为它太危险了,stop()方法不会给线程任何处理后事的机会,直接就杀掉线程,如果此时线程正好持有ReentrantLock锁,它被干掉后会导致这个锁永远不会被释放。
一种优雅的方式是中断,即调用interrupt()方法,这个方法并没有做什么实质上的事情,它相当于只是给线程打上了一个标记,而后我们通过一些手段(如调用Thread.interrupted方法)来检测当前线程是否被打上了中断标记,来决定如何终止线程。
scss
if (Thread.interrupted()) { // Clears interrupted status!
// do something like lock.unlock()
throw new InterruptedException();
}
使用
如何在Java中使用多线程
继承Thread类
scala
// 自定义线程对象
class MyThread extends Thread {
public void run() {
// 线程需要执行的代码
}
}
// 创建线程对象并启动线程
MyThread myThread = new MyThread();
myThread.start();
Java中的Thread类中,所有关键方法都是native的,说明这些方法无法使用平台无关的手段实现。
实现Runnable接口
java
// 实现 Runnable 接口
class Runner implements Runnable {
@Override
public void run() {
// 线程需要执行的代码
}
}
// 创建线程对象并启动线程
Thread thread = new Thread(new Runner());
thread.start();
实现Callable接口
Callable的使用需要搭配线程池,放在后续介绍线程池部分。
线程数配置原则
性能一般指:延迟
和吞吐量
,目标是降低延迟,提高吞吐量。
- 延迟:发出请求到收到响应这个过程的时间。
- 吞吐量:单位时间内能处理请求的数量。
一般常用的手段:
- 优化算法
- 将硬件的性能发挥到极致
在并发编程领域,提升性能本质上就是提升硬件的利用率。也就是说,我们的目标是让CPU时刻保持着100%的利用率,一刻也不停歇的工作着。
然而,线程也不是越多越好的,当一个CPU上同时有多个线程运行时,我们所看到的多个线程的并行运行其实是一种伪并行,在同一时刻,真正运行的线程其实只有一个,只不过CPU在多个线程的运行之间不停的切换,让我们看起来好像是这些个线程在同时运行罢了。然而,线程运行的切换不是没有代价的,每次切换时,我们首先需要保存当前线程的上下文,然后再将下一个线程的上下文设置好。这个过程也是要消耗CPU时间的,如果CPU将大量的时间都花在了切换线程上,而非执行线程的任务上,那就得不偿失了。
在线程切换中,上下文一般指CPU寄存器和程序计数器中的内容。
一般任务有以下两种类型:CPU密集型的任务
和I/O密集型的任务
。本质区别为:
-
CPU密集型的任务:
最佳线程数 = CPU核数 + 1
- 大多数时间里,只要在运行就有产出。
- 因此希望一个任务一直运行到底再运行下一个,而不是将时间耗费到线程的切换上(即上下文切换)。
- 后面的"+1"是为了一旦线程因为偶尔的内存页失效或其他原因导致阻塞时,这个额外的线程可以顶上,以保证CPU的利用率。
-
I/O密集型的任务:
最佳线程数 = CPU核数 * [ 1 + (I/O耗时 / CPU耗时)]
-
一个任务从开始到完成的时间可能很长,但其间真正在干活(使用CPU)的时间可能很短,大部分时间都在等待,如等待网络发来的数据包,或等待写入或读取磁盘上的数据等。
-
因此希望在没有产出的等待时间里,CPU不是闲呆着,而是去做其他事情。
-
示例:如CPU计算和I/O操作的耗时是1:2,那多少个线程合适呢?答:3个线程。
- 理想情况下,CPU在线程A、B、C之间按如下方式切换,理论上实现100%的CPU利用率。
-
线程间的通讯方式
选择通信
-
synchronized和volatile关键字
- 这两个关键字可以保障线程对变量访问的可见性。
-
等待/通知机制
-
Thread#join()
-
如果一个线程A执行里threadA.join(),那么只有当线程A执行完之后,threadA.join()之后的语句才会继续执行,类似于创建A的线程要等待A执行完后才继续执行。
-
使用join方法中线程被中断的效果 == 使用wait方法中线程被中断的效果,即会抛出 interruptedException。因为join方法内部就是用wait方法实现的。
-
join还有一个带参数的方法:join(long),这个方法就是等待传入的参数的毫秒数,如果计时过程中等待的方法执行完了,就接着往下执行,如果计时结束等待的方法还没有执行完,就不再继续等待,而是往下执行。
-
join(long)和sleep(long)的区别
- 如果等待的方法提前结束,join(long)不会再计时了,而sleep(long)一定要等待够足够的毫秒数。
- join(long)会释放锁,sleep(long)不会释放锁,原因是join(long)方法内部是用wait(long)方法实现的。
-
-
-
管道流:PipedInputStream & PipedOutputStream
csharp
public class PipedStreamDemo {
public static PipedInputStream in = new PipedInputStream();
public static PipedOutputStream out = new PipedOutputStream();
public static void send() {
new Thread() {
@Override
public void run() {
byte[] bytes = new byte[2000];
while (true) {
try {
out.write(bytes, 0, 2000);
System.out.println("Send Success");
} catch (IOException e) {
System.out.println("Send Failed");
e.printStackTrace();
}
}
}
}.start();
}
public static void receive() {
new Thread() {
@Override
public void run() {
byte[] bytes = new byte[100];
int len = 0;
while (true) {
try {
len = in.read(bytes, 0, 100);
System.out.println("len = " + len);
} catch (IOException e) {
System.out.println("Receive Failed");
e.printStackTrace();
}
}
}
}.start();
}
public static void main(String[] args) {
try {
in.connect(out);
} catch (IOException e) {
e.printStackTrace();
}
receive();
send();
}
}
选择不通信
也可以选择不通信,将变量封闭在线程内部,使用ThreadLocal可以实现这一效果。
原理
线程的调度
-
协同式线程调度:线程的执行时间由线程本身来实现控制,线程执行完自己的任务之后,主动通知系统切换到另一个线程。
- 优点:实现简单,没有线程同步的问题。
- 缺点:线程执行时间不可控,如果一个线程编写有问题一直无法结束,程序会一直阻塞在那里。
-
抢占式线程调度:每个线程由系统分配执行时间,系统决定切不切换线程。
- Java使用的线程调度方式就是这种。
线程的实现原理
三种线程的实现方式
使用内核线程实现
内核线程(KLT),就是直接由操作系统支持的线程,不过当然不是我们的程序可以直接去操作系统的进程,而是程序可以通过调用内核线程的一种高级接口------轻量级进程(LWP),来操作内核进程。也就是说,LWP和KLT之间是1:1的关系,因此我们也称这种模型为一对一的线程模型。
这类似于一种代理模式,LWP就是代理对象,而KLT则是被代理对象,我们把任务请求发送给代理人LWP,然后LWP会通过调用真实具备执行能力的被代理人KLT去执行任务。
-
优点:
- 每个LWP都是一个独立的调度单元,即使有一个LWP在调用过程中阻塞了,也不会影响到整个进程继续工作,系统的稳定性会比较好。
- 线程的调度和各种操作都委托给了操作系统,所以实现简单。
-
缺点:
- 各种线程操作(创建、析构、同步等)都需要进行系统调用,而系统调用的代价较高,需要在用户态和内核态中来回切换,这会消耗掉一些时间。
- 每个LWP都需要一个KLT支持,也就是说,每个LWP都会消耗掉一部分内部资源(内核线程和栈空间),因此系统可以支持的LWP数量是有限的。
使用用户线程实现
狭义上,用户线程(UT)指的是完全建立在用户空间的线程,即操作系统是感知不到线程的存在的,它只知道那个掌管这些UT的进程P。因此,进程和UT之间的比例为1:N。
-
优点:
- UT的创建、同步、销毁、调度都是在用户态完成的,完全不需要切换到内核态,因此各种线程操作可以是非常快速和低消耗的。
- 由于进程和UT之间的比例为1:N,所以可以支持更大规模的UT数量。
-
缺点:
-
由于没有系统内核的支持,所以所有的线程操作都需要自己实现,这就使得UT的实现程序一般都比较复杂,而且事实证明,我们很难实现的比操作系统好。
- 现在使用UT的程序越来越少,Java和Ruby等语言都曾使用过UT,最后都放弃了。
-
使用用户线程加轻量级进程
这种模式下,即存在用户线程,也存在轻量级进程。
- UT还是只存在于用户空间,因此线程的创建、同步、销毁的消耗依旧很小,同时也可以支持很多线程并发。
- 对应线程的调用,则通过LWP作为UT和KLT之间的桥梁,这样就可以使用操作系统提供的线程调度功能和处理器映射了。
- UT的系统调用要通过LWP完成,大大降低了整个进程被完全阻塞的风险。
- UT和LWP之间的比例是不确定的,即为N:M的关系。
Java线程的实现
JDK1.2之前,Java线程是基于名为Green Thread的用户线程实现的,JDK1.2之后,被替换为基于操作系统原生线程模型来实现。对于目前的JDK版本,这将取决于操作系统支持怎样的线程模型,虚拟机规范中并没有规定Java线程必须是要哪种线程模型来实现。