目录
2.4 使用匿名内部类,实现 Runnable 接口,重写run
3.6 获取当前线程引用 - currentThread()
前言
引入进程这个概念,最主要的目的是为了解决“并发编程"这样的问题。多进程编程已经可以解决并发编程的问题了,能很好的利用cpu多核资源了,但是进程太重了。
- 进程太重了(消耗资源多&速度慢)
- 创建、销毁、调度一个进程,开销比较大。说进程重,主要就是重在“资源分配/回收”上。为了解决上述问题,线程应运而生,线程也叫做"轻量级进程"。
- 解决并发编程问题的前提下,让创建,销毁,调度的速度更快一些;线程为啥更"轻",把申请资源/释放资源的操作给省下了,创建效率就更高了。
1. 认识线程(Thread)
1.1 线程是什么
一个线程就是一个单独的 "执行流",每个线程之间都可以按照顺序执行自己的代码,多个线程之间 "同时" 执行着多份代码。
有如下场景:
一家公司要去银行办理业务,既要进行财务转账,又要进行福利发放,还得进行缴社保。
如果只有张三一个会计就会忙不过来,耗费的时间特别长。为了让业务更快的办理好,张三又找来两位同事李四、王五一起来帮助他,三个人分别负责一个事情,分别申请一个号码进行排自此就有了三个执行流共同完成任务,但本质上他们都是为了办理一家公司的业务。
此时,我们就把这种情况称为多线程,将一个大任务分解成不同小任务,交给不同执行流就分别排队执行。其中李四、王五都是张三叫来的,所以张三一般被称为主线程(Main Thread)
1.2 为啥要有线程
首先,"并发编程" 成为 "刚需".
- 单核 CPU 的发展遇到了瓶颈(cpu不能再小了),要想提高算力就需要多核 CPU,而并发编程能更充分利用多核 CPU 资源。
- 有些任务场景需要 "等待 IO",为了让等待 IO 的时间能够去做一些其他的工作,也需要用到并发编程。
其次,虽然多进程也能实现并发编程,但是线程比进程更轻量
- 创建线程比创建进程更快
- 销毁线程比销毁进程更快
- 调度线程比调度进程更快
最后,线程虽然比进程轻量,但是人们还不满足,于是又有了 "线程池"(ThreadPool) 和 "协程"(Coroutine),后面会讲解。
1.3 进程和线程的区别(经典面试题)
- 进程是包含线程的,每个进程至少有一个线程存在,即主线程。只有第一个线程启动的时候,开销是比较大的,后续线程就省事了。
- 进程和进程之间不共享内存空间,同一个进程的多个线程之间共享了进程同一个内存空间.(主要指的是内存指针和文件描述符表)
内存具体到代码上:线程1 new的对象在线程2,3,4里都可以直接使用。
文件描述符表:线程1打开的文件在线程2,3,4里都可以直接使用。
- 进程是系统分配资源的最小单位,线程是系统调度的最小单位。
(上篇博客中进程调度,相当于每个进程里面只有一个线程这样的情况)
一个核心上执行的是一个线程,如果一个进程有两个线程,线程1可能在核心A上执行,线程2可能在核心B上执行;操作系统调度的时候,其实不关心进程,而是只关心线程。且cpu不知道哪个线程跟哪个线程是一伙的。
- 实际上,一个进程中是可以有多个线程的。每个线程,都是可以独立的进行调度。每一个线程也有状态,优先级,上下文,记账信息.....
- 进程是使用 PCB 表示描述的。一个进程可能使用一个PCB表示,也可能使用多个PCB表示。每个PCB 对应到一个线程上。
- 每个线程也都包含:pid,内存,文件描述表符,调度属性:PCB里的状态,上下文,优先级,记账信息等。
- 同一个进程中多个线程之间PCB中的pid是相同的,内存指针和文件描述符表是共用一份的。(创建线程的时候,不需要重新申请资源了,直接复用之前已经分配给进程的资源,省去了资源分配的开销,于是创建效率就更高了)
例如:一个工厂,想要扩大生产力,两种方案。
第一种方案:再租个院子,搞一套机器设备 ,重新搞一条生产流程。场地和物流体系,不能复用之前的。(相当于多进程的方案)
第二种方案:在原来的基础上扩建一下,搞一套机器设备,与原来共用一条生产流程。场地和物流体系,都是可以复用之前的。(相当于多线程的方案)。
很明显,第二种方案,成本要比第一种小很多,场地和物流体系,都是可以复用之前的。
多线程版本的方案,只是创建第一套生产线的时候,需要把资源申请到位后续再加新的生产线,此时就复用之前的资源即可。
增加线程的数量能提高效率,但并不是线程越多效率越高。CPU核心数量是有限,线程太多,不少的开销反而会浪费在线程调度上,从而导致效率降低。
如果线程数目多了,可能就会产生一定的冲突,称为"线程不安全问题"。
如果一个线程抛出异常,没有进行妥善处理,很容易把整个进程都带走(崩溃),此时其他线程自然也就随之消亡。
面试题,进程和线程的区别,就是"操作系统"这个模块最高频的问题,没有之一
1.4 Java 的线程 和 操作系统线程 的关系
- 线程是操作系统中的概念,操作系统内核实现了线程这样的机制,并且对用户层提供了一些API供用户使用来操控线程。例如 Linux 的 pthread 库。
- Java 标准库中Thread 类可以视为是对操作系统提供的 API 进行了进一步的抽象和封装。体现了跨平台使用的特性。
- (API 是应用程序编程接口,即给你个软件,你能对他干啥(代码层次上的),基于它提供的这些功能,就可以写一些其他代码;对程序来说,API往往就是以"函数""类"的形式来提供的。)
- Java操作多线程,最核心的类Thread。Thread类相当于操作系统给我们提供的实现线程的类。
- 学习Java提供操作线程的API,通过用Java语言编写关于Thread类代码实例化进程,然后代码通过操作系统的API再操作系统内创建真正的线程(PCB),操作系统根据pcb中的属性以及自己的调度方式,来进行资源的调度和分配。
2. 创建线程
- 每个线程都是一个独立的执行流,各执行各的。每个线程都可以执行一系列的逻辑(代码)。
- 多个线程之间是 "并发" 执行的,快速切换执行或者在不同的多个cup核心上执行。
- 通过Thread类,创建Thread对象(创建出一个线程出来),进一步的就可以操作系统内部的线程了。
创建线程后,是希望线程成为一个独立的执行流(即执行一段代码),如何指定,有下面几种方式指定。
2.1 继承 Thread 类,重写run
使用Thread类(Java 标准库内置的类),不需要 import 包名。thread类是在java.lang包里包含的,java.lang包默认导入,还有String、StringBuilder、StringBuffer类。
一个线程跑起来,是从它的入口方法开始执行的。run方法就是线程的入口。
我们之前说一个Java程序的入口是 main方法,这里从进程和线程角度这样说不严谨。应该是:运行Java程序,就是跑起来一个java进程。这个进程里面至少会有一个线程(主线程),主线程的入口方法就是main方法。main这个线程是jvm自动创建的,和其他线程相比没啥特殊的。
在运行的java程序中,只有主线程的入口是main方法,其他线程的入口是run方法。
上述代码存在两个线程,main方法对应主线程,run方法对应 t 线程。(此时两个线程写的跟一个线程很像,后续会在进行区分)
- start 和 run 都是 Thread 的成员。run()方法是Thread中特殊的方法,只是描述了线程的入口(线程要做什么任务),要重写这个方法(方法重写Override,重载Overload)
- Thread t = new MyThread(); 可以认为是实例化了一个线程,是在代码层次上创建了一个线程。
- t.start(); 是在操作系统中创建一个真正的 t 线程且启动 t 线程,调用start是在操作系统层次上创建了一个线程(PCB)。start 则是真正调用了系统 API,在系统中创建出线程,让线程再调用run。
- 新线程负责执行run方法(即新线程成为了一个独立的执行流(执行一段代码)),并非是start底层调用了run方法。
- 新线程的创建就是调用操作系统的API,通过操作系统内核创建新线程的PCB,并且把要执行的指令交给这个PCB,当PCB调度到CPU上执行的时候,也就执行到了线程run方法中的代码了。
直接打印与创建线程打印本质上是不一样的
- 如果只是直接打印 hello world,java进程只有一个线程(调用main方法的线程),即主线程。
- 如果通过t.start()打印,主线程(有主线程接下来创建新线程开支很小)执行t.start,在操作系统内创建出一个新的线程(PCB),新的线程执行run方法。如果run方法执行完毕,新的这个线程自然销毁。
- new Thread 对象操作不创建线程(这里说的线程指的是系统内核里的PCB),是创建出来的Thread对象调用start方法,在操作系统中才是创建pcb ,才是有货真价实的线程的。即主线程相当于管理者,调用start是找人干活;如果想再加一个线程是再new一个对象然后调用start。
那有主线程了为什么还要创建新的线程?
- 解决并发编程,cpu多核时代,尽可能利用起来cpu资源,进程过重线程相对来说比较轻,一个进程包含多个线程,都放在主要线程上就做不到并发了。
- 如果是之前普通写代码,通过类名调用run()方法,就先执行run方法里面的while循环,直到执行完,然后才会执行另一个循环语句。以往的程序都是单线程的。
- 而现在是创建了一个新线程,共有两个线程,每个线程都是一个独立的执行流(各自执行各自的一段代码),多个线程之间是 "并发" 执行的。
- 现在说的并发 是指 并发和并行的统称。
- C中使用的Sleep方法是Windows系统自带的
- 在Java中,sleep方法是Thread类的静态方法。
在main方法中可以直接使用 throws,因为这是自定义的方法。
- 这里main每次都是比thread先打印吗?不一定谁先谁后。
- 操作系统调度线程的时候,线程抢占式执行,具体哪个线程先上,哪个线程后上不确定,取决于操作系统内核调度器具体实现策略。
- 虽然有优先级,但是在应用程序层面上无法修改。从应用程序(代码)的角度,看到的效果,就好像是线程之间的调度顺序是"随机"的一样。从内核里看本身并非是随机,但是干预因素太多,并且应用程序这一层也无法感知到细节,就只能认为是随机调度。
- 此处的随机,不是数学上"概率均等"这种随机。取决于操作系统对于线程调度的模块(调度器)具体实现。
这个执行顺序能解决吗?
- 内核实现的无解,但是可以通过一些api进行有限度的干预。
Thread类有实现具体的线程的调度算法嘛?
- Thread类本质上还是系统里线程的封装,每个Thread 的对象就对应到系统中的一个线程(也就对应一个PCB),调度是操作系统内核决定的。
start 和 run之间的区别:
- start是真正创建了一个线程(从系统这里创建的),线程是独立的执行流.
- run只是描述了线程要干的活是啥。如果直接在 main 中调用run,此时没有创建新线程,全是main线程(主线程)一个人干活。
怎么查看线程:
多线程程序运行的时候,可以使用IDEA或者jconsole来观察到该进程里的多线程情况。
- 使用IDEA观察:以调试模式启动程序,会有一个专门的窗口,查看方法的调用栈在这里可以看到所有线程的信息。对新手不太友好,我们使用jconsole工具
- 使用jconsole观察:JDK中带有的工具,bin目录下的jconsole工具。
在IDEA中这里可以查看JDK的位置
先让编写的java程序运行起来,然后可以使用jdk自带的工具jconsole查看当前的java进程中的所有线程,JDK => Java开发工具包,这里带的工具很多的,不只是javac和java。
运行jconsole,此处可能什么都不显示,此时就要通过"管理员方式"运行。
2.2 实现 Runnable 接口,重写run
Runnable作用,是描述一个“要执行的任务”,run方法是任务的执行细节
- 使用Runnable的写法和直接继承Thread之间的区别,更加的解耦合了,目的就是为了让 线程 和 线程要干的活之间分离开。
- 未来如果要改代码不用多线程了,使用多进程或者线程池,或者协程等方式,此时代码改动比较小。
创建一个线程,需要进行两个关键操作:
- 明确线程要执行的任务。
- 调用系统 api 创建出线程。
2.3 使用匿名内部类,继承 Thread 类,重写run
2.4 使用匿名内部类,实现 Runnable 接口,重写run
这个写法和2本质相同,只不过是把实现Runnable 任务交给匿名内部类的语法。
此处是创建了一个类,实现 Runnable,同时创建了类的实例,并且传给Thread的构造方法。
2.5 使用 Lambda 表达式(最简单,推荐写法)
lambda表达式,更简化的语法表示方式,"语法糖"。相当于是"匿名内部类"替换写法。
lambda表达式,本质上是一个匿名函数。主要用来实现"回调函数"的效果。
- lambda表达式:Java中不允许函数独立存在的。(其它语言叫函数function,Java这里叫方法method)lambda本质函数式接口(本质上还是没有脱离类)
- 匿名函数:没有名字的函数,用一次就完了。
- 回调函数:回调函数不是你主动调用的,也不是现在就立即调用。而是把调用的机会交给别人(通常是操作系统,库,框架,别人写的代码)来进行使用。别人会在合适的时机来调用这个函数。
面试题: Java 中创建线程都有哪些方式?
除了上面的方式之外,还有一些其他方式(后面还会介绍到)
【小结】
- Thread类相当于java线程 和 操作系统线程的媒介,
- 操作系统是工厂,Thread是机器人产地,能创建机器人去工厂, start是送到工厂的机器人,run是机器人要干的活。
3. Thread 类及常见方法
Thread 类是 JVM 用来管理线程的一个类,换句话说,每个线程都有一个唯一的 Thread 对象与之关联。
用我们上面的例子来看,每个执行流,也需要有一个对象来描述,类似下图所示,而 Thread 类的对象就是用来描述一个线程执行流的,JVM 会将这些 Thread 对象组织起来,用于线程调度,线程管理。
3.1 Thread 的常见构造方法
方法 | 说明 |
Thread() | 创建线程对象 |
Thread(Runnable target) | 使用 Runnable 对象创建线程对象 |
Thread(String name) | 创建线程对象,并命名 |
Thread(Runnable target, String name) | 使用 Runnable 对象创建线程对象,并命名 |
【了解】Thread(ThreadGroup group, Runnable target) | 线程可以被用来分组管理,分好的组即为线程组,这 个目前我们了解即可 |
线程默认的名字,叫做thread-0之类的,thread-1,2,3 (可读性较差),起名字的目的是为了方便调试与区分。
- 这里为什么不见主线程。因为主线程执行到调用start之后,结束了main方法,对于主线程来说, main方法执行完就销毁了。线程入口方法执行结束,则这个线程自然销毁了。
- 同样对于 t 线程也一样,run方法执行完了,即通过t.start创建的新线程 t 线程也销毁了。
- 这里的 t 是代码里的变量名,mythread是操作系统里的线程名,两个表示的都是新的线程。
3.2 Thread 的几个常见属性
属性 | 获取方法 |
ID | getId() |
名称 | getName() |
状态 | getState() |
优先级 | getPriority() |
是否后台线程 | isDaemon() |
是否存活 | isAlive() |
是否被中断 | isInterrupted() |
- getId(); ID 是线程的身份标识,唯一标识,不同线程不会重复。(这个id是Java 给这个线程分配的,不是系统 api提供的线程 id,更不是pcb 中的 id)
- getName(); 名称是各种调试工具用到,构造方法里面起的名字。
- getPriority(); 优先级高的线程理论上来说更容易被调度到(虽然提供了api可以设置/获取优先级,但是设置了也没用,应用程序的角度很难察觉出,优先级带来的差异;优先级影响到的是系统在微观上进行的调度。所以几乎不用)
- isDaemon() 关于后台线程,需要记住一点:JVM会在一个进程的所有非后台线程(前台进程)结束后,才会结束运行。
前台线程会阻止进程结束:前台线程没有结束,整个进程是一定不会结束的。 代码里手动创建的线程和main方法,默认都是前台线程。
后台线程不会阻止进程结束:后台线程没有结束,不影响整个进程的结束。其他的JVM自带的线程都是后台的。后台线程(守护线程)。
手动使用setDaemon()方法可以将前台线程设置为后台线程。
- 时刻记住线程是抢占式执行,随机调用的。这里运行代码只是主线程抢先了新线程的执行以及先调度,才没打印出hello,如果下次运行代码可能是新线程抢先主线程执行以及先调度,打印出hello,或者下次运行代码还是主线程抢先了新线程的执行以及先调度,还不会打印出hello,这里完全取决于操作系统内核的调用器是怎么决策实现的。且即使先执行了新线程,也不会打印出很多个hello,因为在很短的时间内就会再次执行到主线程,然后就结束了。
- 上图代码,把 t 设置成守护线程/后台线程,此时进程的结束与否就和t无关了,只剩下main 这一个前台线程,main方法执行完就结束了。
- isAlive(); 内核线程是否存活,即简单的理解,为 run 方法是否运行结束了。
如果光是创建一个 t 变量,不调用start是不会在系统内核里有线程。这只是把任务梳理好了。变量 t 调用start才是真正开始做任务。
内核线程中回调方法执行完毕,线程就没了。
Thread对象的生命周期要比(系统内核创建的线程)PCB生命周期更久一些。
t 这个对象不一定释放的原因:
t 啥时候会不在,等引用不指向这个对象了 t 就被GC回收了。
pcb都释放了,线程对象存在的意义是啥?
- 没有意义,只是内核的PCD销毁时,java里做不到同步释放对象。
- 针对线程来说的,这个对象还是能调用属性方法的。不能重新 start,一个线程只能start一次。
如果t的run还没跑,isAlive就是false
如果t的 run正在跑,isAlive 就是true
如果t的run跑完了,isAlive就是false
这两轮操作hello在前,还是true在前不确定,完全看调度器的调度结果,不可预期的,抢占式执行
时刻牢记,线程之间是并发执行的,并且是抢占式调度的。
这俩打印的执行顺序,是完全不确定的,完全就是随机的
两个线程在微观上可能是并行(两个核心)也可能是并发的(一个核心)
宏观上感知不到(应用程序这里),咱们看到的始终是随机执行,抢占式调度。
- islnterrupted(); 线程的中断问题,下面中断一个线程有讲。
- getState(); 状态表示线程当前所处的一个情况(Java里面线程状态要比操作系统原生的状态更丰富一些),可以查看下面线程的状态。
【小结】
3.3 启动一个线程 - start()
- 覆写 run 方法是提供给线程要做的事情的指令清单
- 线程对象可以认为是把 李四、王五叫过来了
- 而调用 start() 方法,就是喊一声:”行动起来!“,线程才真正独立去执行了。
start方法内部,是会调用到系统api,在系统内核中创建出线程。
run方法,只是单纯的描述了该线程要执行啥内容。(会在start创建好线程之后自动被调用的)
3.4 中断一个线程 - interrupt()
中断(这里是动词)的意思是:不是让线程立即停止而是通知线程,应该要停止了,是否真的停止,取决于线程这里具体的代码写法。
应该叫终止/打断,因为操作系统中也有一个概念,叫做"中断"。interrupted() 让一个线程停止运行(销毁)。
在Java中,要销毁/终止线程,做法是比较唯一的,就是想办法让run方法尽快执行结束。而在C++中能直接强行终止一个正在运行的线程的。过于粗暴,可能会导致线程只执行一半,环境中出现残留数据的情况。
目前常见的有以下两种方式:
1、使用自定义的变量来作为标志位。在代码中手动创建出标志位,来作为run的执行结束的条件。
其中flag 就是自定义的变量标志位。
- 这个代码之所以能够起到修改flag, t 线程就结束,完全取决于 t 线程内部的代码写法,代码里通过flag 控制循环。如果这里循环代码里面的条件是true或者其他变量控制的,这个线程就不会结束会继续执行。
- 因此这里只是告诉让这个线程该结束了,这个线程是否要结束,什么时候结束都是线程内部自己代码来决定的。
- 自定义变量这种方式,不能及时响应,尤其是在sleep 休眠的时间比较久的时候,例如上述代码如果新的线程刚进入休眠1s,主线程的flag就修改,需要等1s才能结束运行,对于计算机来说1s就是很长时间了。
2、使用Thread类中自带的标志位,来进行判断
这个就是自带的标志位,可以唤醒上面的sleep方法的休眠
- currentThread() 这是Thread类的静态方法,通过这个方法可以获取到当前线程。即在哪个线程里调用的这个方法,就得到哪个线程的对象引用,很类似于this。这里相当于是在t.run中被调用的,此处获取的线程就是 t 线程。
- isInterrupted() 是Thread类的内部标志位,这个标志位可以用来判定线程是否结束。方法的背后,相当于是判定一个boolean变量。这里逻辑取反,为true 表示while循环被终止,为false表示未被终止。
t.interrupt(); 这里会做两件事:
- 把线程内部的标志位给设置成true
- 如果线程在进行sleep(阻塞),就会触发异常把sleep唤醒。
但是sleep在通过异常唤醒的同时,会自动清除刚才设置的标志位,即再设置回false。
这就导致当sleep的异常被catch完了之后,相当于线程t忽略了你的中断请求,循环继续执行。所以我们要在定义异常的时候加上break,线程t就能立即响应中断请求。或者我们还可以让 t 稍后中断。
结合代码注意理解下面使用Thread 自带的标志位,判断进行中断的操作情况:
线程 t 忽略了你的终止请求
线程 t 立即响应你的中断请求
线程 t 稍后响应你的中断请求,这里可以写任意代码
为啥sleep异常被唤醒,要清除标志位?
唤醒之后,线程到底要终止,还是不要,到底是立即终止还是稍后,就把选择权交给程序猿自己了。
isInterrupted() 是判断标志位;interrupt() 是设置标志位,sleep() 在被唤醒后触发异常有可能清除标志位。
3.5 等待一个线程 - join()
因为线程是一个随机调度的过程,等待线程是控制两个线程的结束顺序。
主线程等待 t 线程彻底执行完毕之后,才继续往下执行了
时刻记住,这里打印的 “join 之前” 和 “hello thread” 谁先打印是我们认为随机的。随机调度,抢占式执行。
如果是执行join的时候,t线程已经结束了,join不会阻塞等待线程,会立即返回。
方法 | 说明 |
public void join() | 等待线程结束 |
public void join(long millis) | 等待线程结束,最多等 millis 毫秒 |
public void join(long millis, int nanos) | 同理,但可以更高精度 |
无参数版本,"死等"很容易有问题。指定一个超时时间(最大等待时间),一般比较常用
【小结】
3.6 获取当前线程引用 - currentThread()
方法 | 说明 |
public static Thread currentThread(); | 返回当前线程对象的引用 |
currentThread(); 在哪个线程中调用,就能获取到哪个线程的实例。
3.7 休眠当前线程 - sleep()
让线程休眠,本质上是让这个线程不参与调度(不去CPU上执行)
有一点要记得,因为线程的调度是不可控的,所以这个方法只能保证参数设置的休眠时间的小于于等于实际休眠时间。
方法 | 说明 |
public static void sleep(long millis) throws InterruptedException | 休眠当前线程 millis 毫秒 |
public static void sleep(long millis, int nanos) throws InterruptedException | 可以更高精度的休眠 |
System.currentTimeMillis() 获取当前时间戳 打印前后时间
4. 线程的状态
4.1 线程的所有状态
状态是针对当前的线程调度的情况来描述的。
咱们现在认为线程是调度的基本单位,状态更应该是线程的属性(后面再谈到状态,都是考虑线程的状态了)
在Java对于线程的状态进行了细化:
new terminated runnable waiting timed_waiting blocked
线程的状态是枚举类型 Thread.State,下面代码是通过枚举的方式打印出了java线程的所有状态。
4.2 线程的状态转换
值得一提的是,Scanner 等待IO时(读写文件,读写控制台,读写网络)也是阻塞状态,等待IO也会进行一些线程操作,内部可能会涉及到锁操作或者是wait之类的操作,所以可能属于WAITING状态,也可能属于BLOCKED状态。
此处之所以看到RUNNABLE状态,主要就是因为当前线程run里面,没写任何sleep之类的方法。
此处在后续的打印状态中,具体看到的是RUNNABLE还是TIMED_WAITING就不一定了。
(取决于获取 t 线程状态时,当前 t 线程是运行到哪个环节了)
通过这里的循环获取,就能够看到这里的交替状态了。当前获取到的状态到底是啥,完全取决于系统里的调度操作,获取状态这一瞬间,到底 t 线程是处在啥样的状态(正在执行,还是正在sleep)。
5. 案例:多线程的意义
写个代码来感受下,单个线程和多个线程之间,执行速度的差别。
程序分成:
- CPU密集,包含了大量的加减乘除等算术运算。
- IO密集,涉及到读写文件,读写控制台,读写网络。
假设当前有两个变量,需要把两个变量各自自增100亿次(典型的CPU密集型的场景),
可以一个线程,先针对a自增,然后再针对b 自增;还可以两个线程,分别对a和b 自增。
这里的join只是限制了main线程最后结束,并不知道t1线程和t2线程谁先结束,不过不影响最后的结果。
- main线程中调用t1.join含义是让main线程等待t1线程的结束。
- main线程中调用t2.join含义是让main线程等待t2线程的结束。
- 多个线程同时执行,最终的时间,是最慢的线程的时间。
此处使用两个线程并发执行,时间确实缩短的很明显,但是并不是正好缩短一半。
- 为啥使用多线程能快,因为多线程可以更充分的利用到多核心cpu的资源。
- 为什么缩短的不是一半,因为不能保证 t1和t2一定是分布在两个CPU核心上执行,也不能保证 t1和t2一定全部都是并行执行的,而不是并发执行的。到底多少次是并发,多少次是并行不好预估,取决于你的系统的配置,也取决于当前程序的运行环境(系统同一时刻跑了很多程序,并行的概率就更小,有更多的线程来抢CPU核心)
- 实际上,t1和t2在执行过程中会经历很多次的调度,这些次调度,有些是并发执行的(在一个核心上),有些是并行执行的(正好在两个核心上)
- 另一方面,线程调度自身也是有时间消耗的,虽然缩短的不是50%但是仍然很明显,仍然很有意义。
- 像这样的代码示例,相当于一个最简单的跑分程序,执行时间越短认为你的电脑硬件配置越好,但是存在运行环境的误差。
好啦Y(^o^)Y,本节内容到此就结束了。下一篇内容一定会火速更新!!!
后续还会持续更新多线程方面的内容,还请大家多多关注本博主,第一时间获取新鲜的知识。
如果觉得文章不错,别忘了一键三连哟!