1. 前置知识——进程
在学习多线程前需要了解操作系统中的基本知识,这里简单回顾下。
1.1 进程控制块
一个进程对应着一个进程控制块PCB,PCB是一个用于管理和维护进程信息的数据结构,这个数据结构中大致包含下面内容(并不完整):
- 进程标识符PID:唯一标识进程的数值
- 进程分配的资源:如分配的内存(指向内存的指针)以及文件描述符表(该进程打开了什么文件)等
- 程序计数器:指向进程当前执行的指令的地址(由于进程需要经常被调度,因此需要寄存器记录该进程执行到哪了)
1.2 进程的五种状态
进程共有五种状态,它们分别为:
- 运行态:指进程上CPU运行的状态;
- 就绪态:指进程在就绪队列中等待CPU调度的状态;
- 阻塞态:指进程上CPU运行过程中,(由于某些原因)发现需要阻塞,知道满足条件后才能回到等待调度的状态;
- 创建态:操作系统为进程分配资源,创建PCB;
- 终止态:操作系统回收进程资源,撤销PCB;
状态间的关系如下:
1.3 进程的调度
进程的调度是指一个进程由就绪态到运行态的过程,在引入多线程之前,调度的基本单元是进程,这里我们先了解一下进程的调度,以便后续了解多线程的调度。
我们可以把就绪队列简单的看作一个链式队列,就绪队列会根据PCB的优先级组织PCB,当CPU处于空闲状态的时候,调度器就会从就绪队列中取出PCB,此时进程就由就绪态转为运行态了。
在程序猿的视角中,调度器将进程由就绪态调度上CPU进行运行的过程是透明的,我们可以把这么一个过程看作调度器对就绪队列的进程的随机调度。
2. 线程的引入
引入线程后,调度的基本单位不再是进程,而是线程。也就是说前面我们讲到的进程的调度,此时基本单位是线程,也就是在
2.1 进程与线程的区别
- 一个进程可能包含一个或多个线程,而一个线程只能属于一个进程
- 每个进程都有独立的虚拟地址空间,也有自己独立的文件描述符,同一个进程的多个线程之间,则共用这一份虚拟地址空间和文件描述符表
- 进程是资源分配的基本单位,线程是调度的基本单位
- 一个进程挂了一般不会影响到其他进程,一个线程挂了很可能把整个进程带走,其他线程也就没了
2.2 为什么要使用多线程
为了提高CPU的资源利用率,可能会选择通过多进程以及多线程的方式来处理一段程序,然而在编程中为什么更加倾向于使用多线程呢,原因如下:
-
首先,由于进程的独立性,每个进程都有自己独立的虚拟地址空间,因此进程间进行通信的步骤较为麻烦;
-
更为重要的一点是创建进程需要涉及资源分配的工作,如分配内存空间以及创建文件描述符表,而同一个进程的多个线程共享资源,则省去了分配资源的步骤。
3. 第一个自定义线程
其实我们在程序开发的过程中早就涉及到多线程了:
public class Demo1 {
public static void main(String[] args) {
System.out.println("hello world");
}
}
即使是一个最简单的hello world程序,其实在运行的时候也设计到“线程”了,虽然我们没有手动在上面的代码中创建其他线程,JVM内部也会创建出多个线程,如:主线程,垃圾扫描线程等。
3.1 定义线程
通过继承Thread的方式自定义一个线程,run方法中描述的是线程执行的具体任务:
class MyThread extends Thread {
@Override
public void run() {
System.out.println("hello thread!");
}
}
run方法描述的是线程的工作
3.2 创建线程
前面只是定义了一个线程需要完成的工作,我们需要在程序中实例化线程,并且调用它的start()
方法才算是创建了一个线程:创建出线程的TCB,并加入到就绪队列中,参与调度。
public class Demo1 {
public static void main(String[] args) {
Thread t = new MyThread();
t.start();
System.out.println("hello world!");
}
}
运行结果:
hello world!
hello thread!
3.3 线程的随机调度
这里如果对调度器的随机调度理解不是很深,可能会提出一个疑问:明明我们是先调用了t.start()
方法,为什么运行结果中先打印了hello world!
呢?
线程中没有父线程和子线程的概念,多个线程涉及并发与并行,后续我们统称为并发:
- 并发指的是当cpu忙碌的时候轮流调用线程,一般是一个时间片结束的时候,将cpu中运行的线程放入就绪队列,然后再随机调度就绪队列中的线程
- 并行指的是多核cpu能同时进行运行多个线程
原因:前面我们讲到,创建线程后会将TCB
添加到就绪队列中等待调度器调度,然而调度的过程是随机的,不可预知的。
我们在启动程序的时候就会有一个main
线程,而当main进程在cpu中执行到t.start()
语句后,会创建一个MyThread
线程(在就绪态等待调度器调度),由于两个线程是并发的,因此打印的结果是随机的。
听了上面的解释,大家此时可能又有一个疑问:既然调度是随机的,为什么我执行了这么多次都是先打印的Hello world!
由于线程的创建是需要开销的,因此可能大家尝试了许多次都是先执行main
线程中的语句,但谁也不能保证第n次运行程序的时候,顺序是否发生变化。
3.4 进程退出码
在console中不只打印了hello world!
和hello thread!
,还打印了一句:
Process finished with exit code 0
操作系统中用进程的退出码来表示“进程的运行状态”,而上面的code 0
就是进程的退出码,在C语言阶段的main函数有一个return值,都是写作了return 0
:
- 使用0表示进程执行完毕,结果正确
- 使用非0标识进程执行完毕,结果不正确
- 如果还没有返回,表示进程此时正在运行
- 进程崩溃,此时返回的值很可能是一个随机值
4. jconsole的使用
在jdk中,有一个叫jconsole
的运行程序,通过该程序可以观察线程的基本信息以及调用方法栈,在多线程的开发中经常需要使用该工具来定位问题。
这里我们加上一个死循环观察线程的调度。
class MyThread extends Thread {
@Override
public void run() {
while (true) {
System.out.println("hello thread!");
}
}
}
public class Demo1 {
public static void main(String[] args) {
Thread t = new MyThread();
t.start();
while (true) {
System.out.println("hello world!");
}
}
}
找到自己jdk的位置,并打开jconsole
(如果是windows,需以管理员身份打开),我的系统是macos,位于jdk目录下的:jdk-1.8.jdk/Contents/Home/bin/jconsole:
在这里可以看到java的所有进程,由于我的启动类名为Demo1
,因此直接连接thread.Demo1
直接点击不安全的连接:
重点关注这两个线程,分别为main线程,和我们刚才自定义的线程。
在列表的右边显示的就是当前线程的信息,上半部分为线程信息,下半部分为线程的函数调用栈,线程信息的内容如下:
- 线程名称Name(程序员可在创建时自定义)
- 状态State:此时状态为阻塞blocked,原因是main此时在使用打印机,因此被main线程阻塞
- 总阻塞次数
5. 创建线程的常见方式
-
创建一个类继承Thread,重写run,前面已经用过,不多赘述
-
创建一个类,实现runnable接口,重写run
此时Runnable相当于定义了一个任务,还是需要实例化Thread实例,把任务交给Thread,这个写法,线程和任务是分开的,可以更好地解耦合。
//实现runnable接口
class MyRunnable implements Runnable {
@Override
public void run() {
while (true) {
System.out.println("hello thread!");
}
}
}
public class Demo2 {
public static void main(String[] args) {
MyRunnable runnable = new MyRunnable();
Thread thread = new Thread(runnable);
//创建线程
thread.start();
while (true) {
System.out.println("hello world!");
}
}
}
- 匿名内部类(Thread)
public class Demo3 {
public static void main(String[] args) {
Thread thread = new Thread() {
@Override
public void run() {
while (true) {
System.out.println("hello thread!");
}
}
};
thread.start();
while (true) {
System.out.println("hello world!");
}
}
}
- 匿名内部类(Runnable)
public class Demo4 {
public static void main(String[] args) {
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
while (true) {
System.out.println("hello thread");
}
}
});
thread.start();
while (true) {
System.out.println("hello world");
}
}
}
- [推荐]
lambda
表达式为方式4的简化版
public class Demo5 {
public static void main(String[] args) {
Thread thread = new Thread(() -> {
while (true) {
System.out.println("hello thread!");
}
});
thread.start();
while (true) {
System.out.println("hello world");
}
}
}
6. 性能对比
对比单线程和双线程的情况下,变量自增20亿次所耗费时间
单线程:
public class Demo6 {
private static final long COUNT = 10_0000_0000;
private static void serial() {
long begin = System.currentTimeMillis();
int a = 0;
for (int i = 0; i < COUNT; i++) {
a++;
}
a = 0;
for (int i = 0; i < COUNT; i++) {
a++;
}
long end = System.currentTimeMillis();
System.out.println("共花费了" + (end - begin) + "ms");
}
public static void main(String[] args) {
serial();
}
}
多线程:这里需要用到join()
方法来保证两个线程执行完毕才计时,并且该方法可能抛中断异常InterruptedException
(当线程运行中断时会触发的异常)
private static void concurrency() {
long begin = System.currentTimeMillis();
Thread t1 = new Thread(() -> {
for (int i = 0; i < COUNT; i++) {
int a = 0;
a++;
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < COUNT; i++) {
int a = 0;
a++;
}
});
t1.start();
t2.start();
try {
//使用join方法,保证等待t1,t2两个线程执行完毕
t1.join();
t2.join();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
long end = System.currentTimeMillis();
System.out.println("共花费了" + (end - begin) + "ms");
}
public static void main(String[] args) {
concurrency();
}
在我的机器上,单线程的耗费时间大概在1500~1600ms区间,双线程的耗费时间大概在900~1000ms区间。
由此可见线程虽说可以提高效率,但并不是预想中的双线程就将性能提高两倍左右,因为多线程的场景涉及创建线程以及频繁的调度线程的开销。
7. 多线程的使用场景
- CPU密集型场景:代码中大部分工作都是在使用CPU进行运算(比如反复++的操作),使用多线程可以更好的利用CPU多个核心并行计算资源,从而提高效率。
- I/O密集型场景:读写磁盘,读写网卡这些操作都属于I/O,当线程在运行时遇到I/O操作就会由运行态转为阻塞态,串性执行程序的话,此时CPU就会处于空闲的状态,引入多线程可以避免CPU过于闲置。