多线程是程序编程里非常重要的一个领域,这是一个大数据和高并发的时代,海量的数据和持续增长的用户量使得多线程技术显得尤为重要,甚至可以说是代码的灵魂,所以多线程技术是一个开发人员的一个必备的技能。
1. 多线程技术的发展
多线程技术早在20世纪60年代就被提出来了,然而操作系统中,真正使用多线程技术的是在20世纪80年代,目前主流的操作系统都支持多线程技术,包括Windows、Linux、Unix、Mac等。
上世纪80年代,因特尔公司推出了第一个通用型微处理器,该处理器总共由两千多个晶体管组成,我们知道cpu的运算速度(即:主频)是和芯片上集成的晶体管的数量成正比的,也就是说集成的集体管的数量越多,cpu的主频也就越快,cpu的运算速度也就越快,然而cpu的运算速度再快,它也有运算速度的上限,即使目前的cpu运算速度最高可以达到每秒几十亿甚至上百亿次以上,仍然满足不了当今世界对大数据量和高吞吐量的要求,所以多核cpu技术就应运而生了,但是并不是说有了多核cpu技术,我们的计算机运算速度就一定很快,这还要取决于怎样高效的使用cpu资源,其实多线程技术的根本目的就是为了提高cpu的使用率,提高程序的执行效率。下面会对进程和线程做一个基本的讲解。
2. 什么是进程
在Windows系统中,我们打开任务管理器,在Process栏下可以看到很多正在运行的软件,这里的每一行其实就是一个进程,每个软件在启动的时候,系统就会为这个软件开启一个进程,如下图里正在运行的软件,就是进程的一个直观的展示,而且还有进程正在使用的系统资源情况,比如cpu、内存和磁盘使用率,以及网络带宽情况。
在Linux或者Unix系统中,可以通过命令:
ps-ef 来查看正在运行的进程,如果要显示进程下面的线程信息,可以使用ps-elf 命令,那到底什么是进程呢,下面是对进程的官方的解释:
进程:一个具有一定独立功能的程序在一个数据集合上一次动态执行过程,在传统的操作系统中,进程是操作系统动态执行的基本单元,
也是基本的资源分配单元
这样说是不是感觉有点抽象,我们来点实际的,先来看看进程都有哪些特征:
- 结构特征: 进程由程序、数据和进程控制块三部分组成
- 动态性: 进程的本质是程序在多道程序系统中的一次执行过程,起动态性主要体现在:它由创建而产生,由调度而执行,由撤消而消亡
- 并发性: 任何进程都可以同其他进程一起并发执行
- 独立性: 进程是系统分配资源和调度的独立单位,也是一个能独立运行的基本单位
- 异步性: 由于进程间的相互制约,使进程具有执行的间断性,即进程按各自独立的、不可预知的速度向前推进
进程的三种状态:
- 就绪状态: 操作系统已经为进程分配了其运行所需的资源,但是此时还没有分配cpu资源,一旦分配了cpu资源,进程就可以进入运行状态,此时的进程可以按优先级来划分,比如,当一个进程由于cpu时间片用完而进入就绪状态时,就会排入低优先级队列,但是当进程由I/O操作结束而进入就绪状态时,就会排入高优先级队列。
- 运行状态: 此时的进程正在占用cpu资源,处于此状态的进程的数目小于等于cpu的数目,在没有其他进程可以执行时(如所有进程都在阻塞状态),通常会自动执行系统的空闲进程。
- 阻塞状态: 由于某种条件(如进程同步或I/O操作)而使得在条件满足之前进程无法继续执行,那么在该事件发生前即使把cpu资源分配给该进程,仍然无法运行。
三种状态之间可以互相转换,如下是三种状态的转换示意图:
3. 什么是线程
线程对于有过一点工作经验的朋友都应该很熟悉了,在我们的工作和学习中,或多或少的都会接触到线程,并且还会有意无意的使用多线程来提高程序的执行效率,增加程序的灵活性,从而最大限度的利用计算机资源,提高计算机资源的使用率,这也是引入线程的根本原因,要不为什么不直接在进程里执行各种任务呢。
线程是由进程创建的,可以说线程是轻量级的进程,并且线程不可能单独存在于进程之外,一个进程可以创建多个线程,并且这些线程共享进程的资源。线程是cpu调度的基本单位,它是由程序计数器、堆栈和一组寄存器组成,当然每个线程也有一个线程id,那说了这么多,好像对线程具体的“形象”还是有点盲人摸象的感觉吧,好,那我们说的再本质一点:
线程的本质就是一组指令序列,是进程中的一组控制流,其由开始点、一个执行序列以及结束点组成,
在线程运行期间的任意给定的时刻,都有一个执行点,即程序里的某一行代码,因为线程不能脱离程序而单独运行。
再举一个形象一点的例子:
小A是一个公司的员工,首先他是一个人,有人的所有的属性,比如运动能力,思考能力,执行任务的能力,小A要收到部门领导的支配,执行具体的任务,当有任务的时候,领导就分配给小A,小A立马执行,没有任务的时候,小A就在那里摸鱼,刷手机。
在上面的例子里面,线程就好比小A,它是一个形象具体的事物,小A身上的属性就好比操作系统分配给线程的资源,领导就好比CPU,公司就好比进程。
我们来比较一下线程和进程的区别和关系:
| 线程 | 进程 |
|---|---|
| 线程是轻量级的,比进程占用更少的资源 | 进程是重量级的或资源密集型的 |
| 线程切换不需要与操作系统交互,同一个进程里的线程切换不会引起进程切换,不同进程的线程切换会引起进程切换 | 进程切换需要与操作系统交互 |
| 所有线程都可以共享同一组代码,打开的文件、以及进程里的资源 | 进程只能拥有自己的内存和文件资源 |
| 同一个任务或者同一个进程里的线程可以是多种状态,比如一个在waiting,另一个在running | 如果一个进程被阻塞,那么在第一个进程被解除阻塞之前,其他进程必须要等待 |
| 线程间可以进行相互通信,可以修改共享变量 | 多个进程在运行期间是相互隔离的 |
3.1 线程的创建
网络上有很多博客和帖子讲创建线程的方式有以下几种(具体的代码实现笔者在此就略过了):
- 继承Thread类
- 实现Runnable接口
- 实现Callable接口
但是准确的讲,在java中,创建线程只有Thread类这一种方式,如果我们想创建一个线程,只能通过new Thread()来实现,像实现Runnable和Callable接口这两种只能说是线程执行的具体任务罢了,实现了这两个接口并不是说就创建了线程,因为线程的创建本质上是要调用操作系统来实现的。
有些人经常会问:既然可以通过继承Thread类实现多线程,那为什么还要Runnable和Callable接口,或者说这两种方式有什么区别?
根本区别就在于使用实现Runnable和Callable接口的方式要比直接继承Thread类的方式使用起来更灵活,而且在实际的工作中,更推荐使用实现接口的方式,我会从以下几个方面对这个问题做一个阐述,或者说如果不用实现接口的方式,它有什么缺点和不足:
-
继承Thread只能由一个线程执行任务,因为Thread类的start()方法只能被调用一次,所以不可能通过多new几个Thread对象来执行这个任务(
注意这里所说的任务就是run()方法里要执行的代码),而实现接口的方式可以被放在线程池里由多个线程同时去执行。 -
继承Thread类的方式其实是把任务和具体的线程绑定在一起了,换句话说就是把某个具体的任务交给某个具体的线程去执行,但是实现接口的方式只是创建了一个任务,并没有把这个任务交给某个具体的线程,实现了任务与线程的解耦,所以其执行起来就非常灵活,可以交给一个线程处理,也可以交给一批线程去处理,因为在我们平时的开发过程中,很多情况下,需要根据实际的业务情况判断具体怎么执行一个任务。
-
由于java是单继承的,所以一个类继承了Thread,就无法再继承其他的类,如果是实现接口的方式,该类还可以继承其他的类。
既然实现接口的方式这么多好处,那为什么还要继承Thread呢?
要知道,我们用线程的根本目的是为了执行任务,在代码层面也就是实现Runnable接口的run()方法,或者Callable的call()方法,继承Thread的优点主要是为了方便,就是当业务场景不是很复杂或者数据量不是很大的情况下,并且可以明确的知道一个任务的使用场景,那么可以直接继承Thread类,这样就会简便很多。我们也可以从jdk的设计者们的角度出发来思考这个问题,毕竟作为一个框架的开发者,要考虑到方方面面,程序界是复杂的,各种场景都会遇到,为了方便开发人员,设计者们也是煞费苦心。
其实在实际的工作中,更多的是通过实现Runnable和Callable接口这两种方式来实现多线程的方式处理任务,但是这两者又有什么区别呢?我们从其源码中就很容易的看出来:
Runnable接口:
/
* @author Arthur van Hoff
* @see java.lang.Thread
* @see java.util.concurrent.Callable
* @since JDK1.0
*/
@FunctionalInterface
public interface Runnable {
public abstract void run();
}
Callable接口:
/
* @see Executor
* @since 1.5
* @author Doug Lea
* @param <V> the result type of method {@code call}
*/
@FunctionalInterface
public interface Callable<V> {
V call() throws Exception;
}
可以看出这两个接口都是函数式接口,Runnable和Callable接口不同的是:
1. run()方法没有返回值,call()方法有返回值
2. Runnable无法抛异常信息,而Callable却可以抛异常信息
特别是第二点,有时候在开发的过程中难免要抛出任务执行过程中的异常信息,所以基于这两点区别,就可以在实际的应用过程中做灵活的选择了,工作中,这两种方式的使用都是很常见的。
现在又引出一个问题:既然有了Callable为什么还要Runnable?
其实这个问题和上面那个问题有相似的地方,就是“为了方便”,那除了方便之外还有其他原因吗,当然是有的,Runnable接口是从jdk“出世”就有,而Callable接口是从jdk1.5才有,并且它的作者是赫赫有名的Doug Lea,而且是在JUC(java.util.concurrent)包下的,juc包下的并发项目本身是对老版本jdk在并发问题上的优化和补充,所以这也算是历史遗留问题吧,而且又不能直接把Runnable去掉,毕竟还是有很多早起的老项目仍然在使用Runnable。
3.2 线程的类型
平时在开发的过程中,一般都会接触过一个场景,就是需要通过自己开启一个线程来执行某个具体的任务,但是或许你不曾想过这个自己创建的线程是什么类型的,简单来说,操作系统在创建线程的时候会有两种类型:
1. 用户级线程
2. 内核级线程
其实不管是用户级线程还是内核级线程,其本质都是线程,只是一个在用户空间创建,一个在内核空间创建。
3.2.1 用户级线程(User-Level Thread, ULT)
顾名思义,用户级线程是由用户管理的线程,这种情况下,内核并不知道用户线程的存在。用户线程只在用户空间中创建,线程的创建、切换、同步以及通信等都不需要系统调度,只需通过用户空间里的线程库调度器来实现线程调度。下图是用户空间和内核空间的线程关系模型图:
用户级线程的优缺点
在进程的章节里我们知道,操作系统内部是以进程单位分配资源的,包括cpu资源,当系统中的进程数小于或者等于cpu核心数的时候,一个cpu执行一个进程,但是当进程数超过cpu核心数量的时候,具体要执行那个进程就需要系统的调度了,所以不管在用户空间的某个进程开启了多少线程,在操作系统内核是不知道的,系统还是以这个进程为单位分配cpu资源,一个进程一次只能在一个cpu核心上运行,换句话说,在用户空间内不管创建多少个线程,其一次只能有一个线程运行在一个cpu核上。所以多核cpu在系统级别,实际上是在进程上的切换,但是在用户空间内,线程间的切换不需要通过内核,用户空间可以通过调度器自主实现线程切换,只有当需要系统调用的时候,才从用户态切换到内核态,下面对用户级线程的优缺点做个简单总结:
优点:
- 线程切换不需要内核模式权限,不需要系统调用
- 用户级线程可以在任何操作系统上运行
- 调度可以是用户级线程中的应用程序特定的
- 用户级线程可以快速创建、管理和切换
缺点:
- 不能使用系统的多重处理,仅有一个用户级线程可以被执行,会浪费多核的价值
- 当一个进程内的某个线程阻塞的时候,整个进程将被阻塞
3.2.2 内核级线程 (Kernel-Level Thread, KLT)
内核级线程是在内核空间创建,其线程的切换由系统线程调度器来调度,内核线程是真正意义上的线程,因为系统可以为其分配资源,内存资源,cpu资源等。
优点:
- 内核拥有较高权限,因此可以在多个CPU核心上执行内核线程。
- 内核中的线程操作I/O不需要进行系统调用;一个内核线程阻塞了,可以立即让另一个执行。
缺点:
- 创建的时候需要系统调用,即需要切换到内核态。
- 由一个内核程序管理,内核线程不可能创建太多,扩展性较差。
由于用户级线程不可以利用多核,不能充分利用cpu的资源,所以系统一般会在内核中预先创建一些线程,并重复利用这些线程,这样,用户态线程和内核态线程之间就有如下四种关系:
多对一(Many-to-One)
用户态某个进程中的多个线程会重复利用一个内核态线程,这样就可以提高内核态线程的利用率,极大的减小了内核态线程的创建成本,极大地减少了创建内核态线程的成本,但是这样就导致用户态的线程无法达到并发的效果,所以这种模型基本上不怎么使用,下面是多对一关系图:
一对一(One-to-One)
每个用户态线程会分配一个内核态线程,所以在这种模型下,每个用户态线程都要通过系统调用创建一个内核线程,来和这个用户态线程进行绑定,并把程序切到内核天执行,这种情况的好处是所有的用户态线程都可以并发执行,能够充分利用多核cpu优势,充分利用系统资源,但是缺点也很明显,因为要创建很多内核线程,对操作系统和内核调度的压力会明显增大。
多对多(Many-To-Many)
多对多是为n个用户态的线程分配m个内核态的线程,这之间的对应关系一般是m的个数要小于n的个数,通常也把m的个数设置为cpu核心数,所以这种模式下既可以达到用户态线程并发执行的效果,同时也可以减少内核线程的数量,目前Linux系统就使用这种策略:
两层设计(Two Level)
很明显这种设计结合了多对多和一对一的情况,这样可以很好的利用这两种策略的优势,在大多数情况下还是多对多模式,偶尔有少量的用户态线程使用一对一的关系。
总结
进程和线程是我们在开发过程中经常碰到的两个名词,也经常使用,所以有必要很好的了解和掌握这两者的含义以及区别,从而能在平时的开发过程中更好的使用,提高程序的执行效率,同时还能避免一些不必要的错误。