多线程(一) --- 线程的基础知识
目录
一.线程是如何做到减少额外开销的
二.进程和线程的区别
三.如何创建线程
3.1 继承Thread类,重写run方法
3.2 实现Runnable接口,重写run方法
3.3 继承Thread类,重写run方法,但是使用匿名内部类
3.4 实现Runnable接口,重写run方法,但是使用匿名内部类
3.5 [常用/推荐] 使用lambda表达式
四.小结
上次写的博客中提到了,在有些场景下,需要频繁的创建和销毁进程的时候使用多进程编程,系统开销会很大。
就拿早期编写一个服务器程序来说:
互联网发展的早期,“网站//web开发”这件事就开始出现了。最早的web开发是使用C语言来编写服务器程序(基于一种CGI(基于多进程的编程模式)这样的技术)。
这样一来,服务器同一时刻会收到很多请求。针对每个请求,都会创建出一个进程给这个请求提供一定的服务,并返回对应的响应。一旦这个请求处理完了,这个进程就要销毁了。
如果请求很多,那么就意味着你的服务器需要不停的创建新的进程,也不停的销毁旧的进程。这样的操作,开销是比较大的。
开销比较大,其中最关键的原因是资源的申请和释放。我们都知道,进程是资源(CPU,硬盘,内存,网络带宽...)分配的基本单位。所以一个进程刚刚启动的时候,首当其冲,就是需要把依赖的代码和数据,从磁盘加载到内存中。而从系统分配一个内存,并非是一件容易事。
因此,线程就是解决上述问题的方案。线程也可以称为是“轻量级进程”,它是在进程的基础上做出了改进。它的好处在于:保持了独立调度执行,同时省去“分配资源”“释放资源”带来的额外开销。接下来,我会从“线程是如何减少额外开销的”、“进程和线程的区别”,“怎么创建线程”等方面来详细讲解多线程。
一.线程是如何做到减少额外开销的
前面提到了,会用PCB来描述一个进程。现在,也使用PCB来描述一个线程。
PCB中有个属性,是内存指针。多个线程PCB的内存指针,指向的是同一个内存空间。这就意味着,只创建第一个线程的时候需要从系统分配资源,后续的线程就不必分配,直接共用前面的那份资源就可以了。
除了内存之外,文件描述符表(操作硬盘)这个东西也是多个线程共用一份的,相当于是“共享经济”。
然而,也不是随便搞两个线程,就能资源共享。我们把能够进行资源共享的这些线程,分成组,称为“线程组”。换句话讲,线程组也是进程的一部分。
由图可见,一个进程有一个PCB。但是实际上,一个进程可以有多个PCB,意味着这个进程包含了一个线程组,也就是包含了多个线程。
总之,每个进程,都可以包含一个线程/多个线程。
如图所示,多个线程指向了同一块内存空间。这一块内存空间里存放的是一些代码(指令)数据,它包含了所有线程依赖的所有数据和代码。这些线程可能是各取所需,也可能是有一定公共的。
就算代码数据量很大,也是共享关系。也就意味着,创建第一个线程申请的这些资源,后续的第二个第三个...线程,就不必重新申请了。这样一来,就能够降低频繁申请释放带来的额外开销。
二.进程和线程的区别
在有线程之前,进程需要扮演两个角色(资源分配的基本单位,也是调度执行的基本单位)。有了线程之后,就把这两个角色给分开了。此时,进程专注于资源分配,而线程负责调度执行了。
为了让大家能更清楚地理解进程与线程的关系/区别,我在这里举个栗子🌰
假设一个房间里,有一个滑稽老铁吃100 只鸡。这个事情消耗的时间,是比较多的。
那么如何加快效率呢?有这样一种方案:搞两个房间,再搞两个滑稽老铁。每个滑稽老铁吃50只鸡,这样一来,速度一定会大幅增加。
这其实就相当于是多进程方案,但是创建新的进程就需要申请更多的资源(房间和桌子)。所以,另一个方案就是使用多线程。
也就是说,房间和桌子没有增加,但是吃鸡的滑稽老铁多了一个。此时,有两个滑稽老铁,一人吃50只鸡,仍然能够提高效率。并且,这种方案资源开销更小。
那么让我们思考一下,有两个滑稽能够提高效率,如果引入更多的滑稽,吃鸡的速度还会提升吗?换句话说,引入的滑稽是越多越好吗?
答案是“当然不是”!当引入的线程达到一定数量之后,再尝试引入新的线程,效率就没有办法提升了。从图中直观看出,就是桌子坐不下了。
而且,当线程数量太多的时候,线程之间就会相互竞争CPU的资源了(CPU核心数是有限的)。这样非但不会提高效率,反而还会增加调度的开销。
多线程还有一个重要问题,就是线程之间可能会“打架”。
如图所示,这里的1号滑稽和2号滑稽,假如看上了同一个鸡大腿,同时伸手去拿,谁能拿到?这里存在诸多变数。
一旦线程之间起了冲突,就可能会导致代码中出现一些逻辑上的错误(线程安全问题)。
多线程还有一个问题,就是共享资源时,也会有副作用。
一个线程如果抛出异常,并且没有处理好,就可能会导致整个进程被终止。
还是拿1号和2号抢鸡大腿来说。假如1号抢到了,2号没抢到,那么这时2号就很生气。2号说:不让我吃,大家都别吃了!(乌鸦附体)
我在这里来简单小结一下进程与线程之间的关系/区别。第一,进程是包含线程的。第二,每个线程也是一个独立的执行流,可以执行一些代码,并且单独参与到CPU的调度之中。第三,每个进程有自己的资源,进程中的线程可以共享这一份资源(内存空间和文件描述符表等)。进程是资源分配的基本单位,线程是执行调度的基本单位。第四,进程和进程之间不会相互影响,但如果同一个进程中的某个线程抛出异常,可能会影响到其他线程,从而会把整个进程中的所有线程都异常终止(掀桌)。第五,同一个进程中的线程之间可能会相互干扰,引起线程安全问题。第六,线程也不是越多越好,最好要适当。如果线程太多了,调度开销可能会非常明显。
三.如何创建线程
线程的基础知识了解的差不多之后,我们再来谈谈如何创建线程。Java中创建线程的方法有很多种,但其实本质上是一样的。
3.1 继承Thread类,重写run方法
我们知道,写代码的时候,可以使用多进程并发编程,也可以使用多线程并发编程。在Java中不太推荐多进程并发编程,因为很多和多进程编程相关的API,在Java标准库中都没有提供。但是系统提供了多线程编程的API,Java标准库把这些API封装了,在代码中就可以使用了。
Java提供的API,就有Thread这样的类。
我们先创建一个类,然后让它继承Thread类。
接着,我们需要在刚刚创建好的类中重写run方法。所谓run方法,就类似于main方法,是一个Java进程的入口方法。一个进程中至少会有一个线程,而这个进程中的第一个线程,也就称之为主线程。main方法,也就是主线程的入口方法。
此处的run方法,不需要我们程序员手动调用,会在合适的时机(也就是线程创建好了之后),被JVM自动调用执行。
接着在main方法中创建线程的实例,线程的实例才是真正的线程。想要让线程执行起来,还需要调用start方法。
我们需要注意的是,在调用Thread的start方法时,才会真正调用系统API,在系统内核中创建出线程。虽然没有手动调用run方法,但是run方法还是执行了。
3.2 实现Runnable接口,重写run方法
Runnable可以理解成“可执行的”,通过这个接口,就可以抽象地表示出一段可以被其他实体来执行的代码。
其中重写的run方法就是Runnable要表示的这一段代码。
如图所示,第一行代码只是一段可以执行的代码,还需要搭配Thread类,才能真正在系统中创建出线程。这种写法,其实就是把线程和要执行的任务进行“解耦合”了。
3.3 继承Thread类,重写run方法,但是使用匿名内部类
所谓的匿名内部类,其实就是一个在一个类里面定义的类,没有名字。这就意味着这个类不能重复使用,用一次就扔了。
写{}的意思是要定义一个类。与此同时,新的这个类继承自Thread。此处{}中可以定义子类的属性和方法,最主要的目的就是重写run方法。
而且,这里的t指向的实例,并非是单纯的Thread,而是Thread的子类(但是我们不知道这个子类叫什么,因为是匿名的)。
3.4 实现Runnable接口,重写run方法,但是使用匿名内部类
该写法的样式如下图所示:
其中,在Thread构造方法的参数中,填写了Runnable匿名内部类的实例
3.5 [常用/推荐] 使用lambda表达式
由于方法不能脱离类而单独存在,这就导致为了设置回调函数(run),不得不套上一层类了。因此,上述几种方法都不常用,最常用的还是lambda表达式。
其实,lambda在主流语言中都有,只不过不一定都叫lambda。在C++,Python中是叫做lambda,但在JS,GO中,直接叫做匿名函数了。
lambda表达式创建线程的方式如下图所示:
()中是形参列表,这里能带参数,但线程的入口不需要参数。比如使用lambda代替Comparator,可以带上两个参数,这个是最简洁的代码了。此外,()前面应该还有一个函数名,此处作为匿名函数,就没有名字了。
上述这些写法都是等价的,都是可以相互转换的。
四.小结
以上,就是我对Java中多线程基础知识的介绍。在下期博客中,我会接着讲解一些关于Thread类的相关属性和用法。觉得博主写的不错的,记得一键三连哦!