目录
一、线程的产生
当前我们已经了解了关于进程的一些相关情况,引入进程最主要的目的是为了解决“并发编程”这样的问题(能够同时去执行很多个任务)。那为什么要搞并发编程呢?是因为当前的CPU进入了多核心的时代(CPU再往小了做,困难程度呈跨量级上升),要想进一步提高程序的执行速度,就需要充分的利用CPU的多核资源。但是也不是说CPU核心多了,程序就能跑得快了,还需要我们的程序代码能够把这些CPU核心给利用上。
其实,多进程编程已经可以解决并发编程问题了,也就是已经可以利用起CPU多核资源了。但是我们发现这引入了一个新的问题:进程太重了!(重说的是:创建一个进程、销毁一个进程、调度一个进程开销都非常大;也就是:消耗资源多 & 速度慢)虽然说能解决问题,但是并不是一个最优的解法,因此我们期望有一个开销更小,重量更轻的概念,也能解决我们的并发编程问题,因此我们的线程应运而生。
线程也叫做“轻量级进程”。线程能让我们在解决并发编程的前提下,让创建,销毁,调度的速度,更快一些。换句话说,线程就是也要解决并发编程这样的问题的,只不过它比进程更加轻量,使用线程可以更快速的进行创建、销毁以及调度。
接下来问题来了,为什么线程就更加轻量,那是因为进程“重”就重在“资源分配/回收”上,线程“轻”就轻在把这部分开销给省掉了,把申请资源/释放资源的操作给省下了。
二、线程与进程的区别联系
举例说明线程“省资源”:
有一个加工厂,里面有一些机器设备生产线,能对运进来的原材料进行加工,加工好的运走。
现由于需要,打算扩大生产力,有如下两种方案:
第一种是再租个厂房,搞一套机器设备,既要搭建生产线,也要搭建物流体系(这也就是相当于是 多进程 的方案)
第二种方案,在原来的 厂房上新增生产线,也就是说整个场地和物流体系,生产线都可以复用之前的生产线,这俩生产线就共用同一套资源。(相当于 多线程 的方案)
很明显第二种方案比第一种成本要小很多。此时,只是搞第一套生产线的时候,需要把资源申请到位,后续再加新的生产线,此时就复用之前的资源即可。
我们认为:进程和线程的关系,是 进程 包含 线程。一个进程可以包含一个线程,也可以包含多个线程(不能没有)。
至少得有一个线程,所以只有第一个线程启动的时候,开销是比较大的,后续线程就省事了。
同一个进程里的多个线程之间,共用了进程的同一份资源(主要指的是 内存 和 文件描述符表(注:内存:比如线程1 new的对象,在线程2,3,4里都可以使用;文件描述符表:线程1 打开的文件,在线程2,3,4里都可以直接使用))
CPU:操作系统实际调度的时候,是以线程为单位进行调度的(上篇文章讲的进程调度,相当于每个进程里面只有一个线程这样的情况)如果每个进程有多个线程了,每个线程是独立在CPU上调度的。也就是说:线程是操作系统调度执行的基本单位。 每个线程也都有自己的执行逻辑(执行流)。操作系统调度的时候,其实不关心进程,而只关心线程。
之前有讲到:进程的PCB当中有一些属性,要去维护一些调度关系,像上下文、优先级,那么在线程中怎么办?
其实,一个线程也是通过一个PCB来描述的。一个进程里面可能是对应一个PCB,也可能是对应多个。之前介绍的,PCB里的状态,上下文,优先级,记账信息,都是每个线程有自己的,各自记录各自的。但是同一个进程里的PCB之间,pid是一样的(通过同一个pid去标识出它们是同一个进程的线程),内存指针和文件描述符表也是一样的。
以上就是 进程 和 线程 之间的一个包含关系,一个进程可以包含多个线程,这一个进程的多个线程都共用了同样的资源,也就是共用一样的pid,内存指针,文件描述符表。每个线程被独立调度执行。每个线程都有自己的状态/优先级/上下文/记账信息。
因此但凡我们谈到调度相关的话题,其实已经和进程没啥关系了,进程是操作系统调度资源的基本单位,线程是操作系统调度执行的基本单位。线程来接管和调度相关的一切内容。之前聊到的进程调度相关的过程,和当前线程调度完全一致。
再举个例子说明多线程的一些情况:
有一个人,需要吃100只鸡:
虽然一个人也能吃,但是需要提高效率,让他吃得快一点,那么就有了“多进程”和“多线程”两种方式:
如果是“多进程”,就需要找两个房间,两套桌子,两个人,这样就每个人吃50只,这样我们的效率就提高了:
但是很明显这个版本带来的问题就是成本比较高,要搞很多额外的东西,那么多线程就可以解决这个问题:
接下来我们需要考虑:让人更多,速度能否更快呢?
很明显此时外面的人根本够不着,桌子已经被围得水泄不通了。
所以可以看出,当我们增加线程数量的时候,也不是可以一直提高速度。桌子的空间是有限的(和CPU核心数量有限是一样的)。人太多,大家相互推攘,会导致正在吃的人没法专心吃。
线程太多,核心数目有限,不少的开销反而浪费在线程调度上了。
多线程的情况下,多个人,共享同一份鸡肉,此时就可能会打架。就比如说有两个人同时看上同一只鸡,那么这个时候两个人就可能会打起来。所以在多进程中,则不会出现这种情况(多线程里已经把鸡分好了,自己吃自己的)。因此,当前场景下,多个人访问同一个鸡肉,这种情况就把它称作线程安全问题(线程不安全)。
多线程还有一种情况:
一个人先把这个鸡大腿抢走了,导致另一个人吃不着,另一个人就生气了,就掀桌了,这样谁也吃不了。这种情况下就属于:如果一个线程抛异常,如果处理不好,很可能把整个进程都给带走了,其他线程也就挂了。
以上例子为后续文章的介绍做了铺垫,这里先行理解场景。
三、创建线程
线程是操作系统中的概念。操作系统内核实现了线程这样的机制,并且对用户提供了一些API供用户使用。(本博客主打Java编程,后续文章无具体说明均以Java为载体进行代码方面叙述。)
Java是一个跨平台的语言。很多操作系统提供的功能,都被JVM给封装好了,所以我们不需要学习系统原生API(C语言),只需要学习Java提供的API就行了。
Java操作多线程,最核心的类是Thread,注意使用Thread类,不需要import别的包,因为Thread类在java.lang包中,像String、StringBuilder、StringBuffer也是类似的不需要导包。于是我们在main方法中创建Thread对象:
Thread t = new MyThread();
创建线程,是希望线程称为一个独立的执行流,也就是能够执行一段代码。但是我们当前这样一句代码并没有把要执行的代码给写进去,因此我们就需要把要执行的代码指定进去,如何指定?有跟多办法。
第一个多线程程序
我们创建一个特殊的类MyThread,这个类继承我们标准类Thread,在MyThread中重写run()方法,然后使用线程中的特殊方法start()方法启动一个线程,这样我们就完成了一个最基础的多线程的代码:
package thread;
class MyThread extends Thread {
@Override
public void run() {
System.out.println("hello world");
}
}
public class ThreadDemo1 {
public static void main(String[] args) {
Thread t = new MyThread();
t.start();
}
}
运行结果:
注意:
(1)此处结果的hello world和我们刚学Java时打印的hello world是本质上是不一样的。
(2)start这里的工作,就是创建了一个新的线程,新的线程负责执行t.run()。其中新的线程说的就是调用操作系统的API,通过操作系统内核创建新线程的PCB,并且把要执行的指令交给这个PCB,当PCB被调度到CPU上执行的时候,也就执行到了线程run方法中的代码了。也就是由PCB来执行run中的代码。
(3)如果只是打印hello world,java进程中主要就只有一个线程(调用main方法的线程),是主线程。通过t.start();,主线程调用t.start(),创建出一个新的线程,新的线程调用t.run。如果run方法执行完毕,新的这个线程自然销毁。
怎么体现并发?我们修改一下上述代码,把打印放进while循环,这时就可以看得到并发效果了:
class MyThread extends Thread {
@Override
public void run() {
while (true) {
System.out.println("hello thread");
//由于打印很快我们额外加个休眠并放入异常中
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public class ThreadDemo1 {
public static void main(String[] args) {
Thread t = new MyThread();
t.start();
while (true) {
System.out.println("hello main");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
运行结果:有两个线程,两个线程在运行,就会出现两个打印结果交替出现的情况:
注:main和thread先后打印次序是不确定的。操作系统调度线程的时候,是“抢占式执行”的策略,具体哪个线程先上,哪个线程后上,不确定,这取决于操作系统调度器的具体实现策略。
虽然有优先级,但是在应用程序层面上无法修改。从应用程序(代码)的角度,看到的效果,就好像是 线程之间的调度顺序 是“随机”的一样。(这里的“随机”并不是真正的随机,因为操作系统内核里本身并非是随机,但是干预因素太多,并且应用程序这一层也无法感知到细节,就只能认为是随机的了)
解释一下start和run的区别:
start是真正创建了一个线程(从系统这里创建的),线程是独立的执行流。run只是描述了线程要做的工作。如果直接在main中调用run,此时没有创建新新线程,全是main线程一个人在干活。
总结一下:写了start就等于创建了一个另外的“生产线”在干活,但是原来的主“生产线”也在同时往下执行。
使用jconsole命令观察线程
我们可以使用jdk自带的工具jconsole查看当前的java进程汇总的所有线程:
双击运行我们就打开了一个窗口,这个窗口就把当前我们正在运行的java进程给列了出来 :
如图所示,第一个进程是我们刚刚运行的程序,第二个是jconsole自己,也是java写的,第三个就是IDEA编译器 。
双击thread进程之后就可以看到窗口左下角是我们当前ThreadDemo1里的线程:
其中点中某个线程之后,右侧的堆栈跟踪(也叫调用栈)就会显示出当前的方法之间的调用关系。
创建线程的方法
1. 继承Thread,重写run
class MyThread extends Thread {
@Override
public void run() {
while (true) {
System.out.println("hello thread");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
2. 实现Runnable接口
//Runnable 作用,是描述一个“要执行的任务”,run方法就是任务的执行细节
class MyRunnable implements Runnable{
@Override
public void run(){
System.out.println("hello thread");
}
}
public class ThreadDemo2 {
public static void main(String[] args) {
//这只是描述了个任务
Runnable runnable = new MyRunnable();
//把任务交给线程来执行
Thread t = new Thread(runnable);
t.start();
}
}
我们把线程本身和要完成的任务给分离开(解耦合)了,未来 如果要改代码,不用多线程,使用多进程,或者线程池,或者协程……此时代码改动比较小。
3. 使用匿名内部类,继承Thread
//使用匿名内部类来创建线程
public class ThreadDemo3 {
public static void main(String[] args) {
Thread t = new Thread(){
@Override
public void run(){
System.out.println("hello");
}
};
t.start();
}
}
在new Thread() 时,做了两件事:(1)创建了一个Thread的子类(子类没有名字),所以才叫做“匿名”;(2)创建了子类的实例,并且让t引用指向该实例
4. 使用匿名内部类,实现Runnable
public class ThreadDemo4 {
public static void main(String[] args) {
Thread t = new Thread(new Runnable() {
@Override
public void run() {
System.out.println("hello");
}
});
t.start();
}
}
这个写法和2本质相同。只不过是把实现Runnable任务交给匿名内部类的语法。此处是创建了一个类,实现Runnable,同时创建了类的实例,并且传给Thread的构造方法。
5. 使用Lambda表达式
public class ThreadDemo5 {
public static void main(String[] args) {
Thread t = new Thread(()->{
System.out.println("hello");
});
t.start();
}
}
把任务用lambda表达式来描述,直接把lambda传给Thread构造方法。
上述办法,只是语法规则不同,本质上都是一样的方式,这些方式创建出来的线程都是一样的。