程序由指令和数据组成,但指令运行和数据读写需要将指令加载到CPU,数据加载到内存,指令运行过程中还会用到磁盘、网络等设备。
进程就是用来加载指令,管理内存,管理IO的。
进程可以看作是一个程序的实例,大部分程序可以同时运行多个实例,例如记事本,浏览器,有的只能是一个实例,例如360安全卫士等。
因此,当一个程序被运行,从磁盘加载这个程序的代码到内存,实际上就是开启了一个进程。
操作系统以进程为单位来分配系统资源(CPU时间片,内存等资源),也就是说进程是资源分配的最小操作单元。
线程是进程中的实体,一个线程必须有一个父进程,一个进程可以有多个线程。
一个线程其实就是一个指令流,将指令流中的指令一条条的按照一定顺序交给CPU去执行。
线程,也被成为“轻量级进程”,它是操作系统调度=的最小单元。
管道(pipe)及有名管道(named pipe)
信号(signal)
消息队列(message queue)
共享内存(shared memory)
信号量(semaphore)
套接字(socket)
线程同步指的是线程之间存在依赖关系,一个线程的执行依赖于另一个线程的消息,当没有收到消息时会处于等待状态,直到消息到达时才会被唤醒
线程互斥是指多个线程同时访问其共享的进程系统资源时的排他性。
任何时刻都只允许一个线程去访问共享资源,其他线程必须等待直至资源占用者释放资源。
因此,线程互斥亦可以理解为是特殊的线程同步。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-0s6ACGDJ-1648567490219)(E:\file\学习\课后总结\并发编程\assert\进程上下文)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Kwtx85af-1648567490221)(E:\file\学习\课后总结\并发编程\assert\线程上下文)]
进程是程序的一个执行实例。在Linux中,线程是轻量级进程,可以并行运行,并与父进程共享一个地址空间和其他资源。
上下文是CPU寄存器和程序计数器在任何时间点的内容。
寄存器是CPU内部的一小部分非常快的内存(相对于CPU外部较慢的RAM主内存),它通过提供对常用值的快速访问来加快计算机程序的执行。
程序计数器是一种专门的寄存器,它指示CPU在其指令序列中的位置,并保存着正在执行的指令的地址或下一条要执行的指令的地址,这取决于具体的系统。
上下文切换指的是CPU从一个进程(或线程)到另一个进程(或线程)的切换。
深入到内核层面就是 暂停一个进程,并将该进程的上下文存储在内存的某个地方;再从内存中获取下一个进程的上下文,并在CPU的寄存器中恢复它,并返回到程序计数器所指示的位置,也就是上一次进程被中断的指令行,从而恢复进程;
上下文切换只能发生在内核模式下,内核模式是CPU的特权模式,并提供对所有内存位置以及其他资源的访问。应用程序最初都在用户模式下运行,它们也可以通过系统调用来运行部分内核代码。
上下文切换是多任务操作系统的一个基本特性。在多任务操作系统中,多个进程似乎同时在一个CPU上执行,彼此之间互不干扰。这种并发的错觉是通过快速连续发生的上下文切换(每秒数十次或数百次)来实现的。这些上下文切换发生的原因是进程自愿放弃它们在CPU中的时间,或者是调度器在进程耗尽其CPU时间片时进行切换的结果。
上下文切换通常是计算密集型的。就CPU时间而言,上下文切换对系统来说是一个巨大的成本,实际上,它可能是操作系统上成本最高的操作。因此,操作系统设计中的一个主要焦点是尽可能地避免不必要的上下文切换。与其他操作系统(包括一些其他类unix系统)相比,Linux的众多优势之一是它的上下文切换和模式切换成本极低。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-fEGfwUQ5-1648567490222)(E:\file\学习\课后总结\并发编程\assert\vmstat)]
vmstat l
其中cs列就是CPU上下文切换的统计。当然,CPU上下文切换不等价于线程切换,很多操作会造成CPU上下文切换:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-rL8qDcUT-1648567490223)(E:\file\学习\课后总结\并发编程\assert\pidstat)]
常用的参数:
-u 默认参数,显示各个进程的 CPU 统计信息
-r 显示各个进程的内存使用情况
-d 显示各个进程的 IO 使用
-w 显示各个进程的上下文切换
-p PID 指定 PID
其中cswch表示主动切换,nvcswch表示被动切换。从统计数据中看到,该进程每秒主动切换次数达到将近500次,因此代码中存在大量的 睡眠\唤醒 操作。
通过命令 cat /proc/5598/status 查看进程的状态信息
这2项就是该进程从启动到当前总的上下文切换情况。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-G2VEIJPd-1648567490224)(E:\file\学习\课后总结\并发编程\assert\OS-Thread-lifeCycle)]
操作系统的线程生命周期主要有:
这五种状态在不同编程语言里会有简化合并。
可以通过以下命令查看系统是使用哪种线程实现
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-C0k1MLAQ-1648567490226)(E:\file\学习\课后总结\并发编程\assert\linux-thread-impl)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Ug0GyM8L-1648567490227)(E:\file\学习\课后总结\并发编程\assert\CPU保护环.png)]
在内核模式下,执行代码时可以完全不受限制的访问底层硬件,执行任意CPU指令和引用任何内存地址。内核模式通常是操作系统最低级别、最受信任的功能保留,内核模式下的崩溃对于计算机而言是灾难性的。
在用户模式下,执行代码不能直接访问硬件或引用内存,必须委托系统API来访问硬件或内存。正是由于这种隔离提供的保护,才能使用户模式下的崩溃变得可恢复。
CPU保护模式
x86 CPU提供了四个保护环:0,1,2,3。通常情况下只使用0(内核)和3(用户)。
应用程序一般会在以下几种情况切换到内核模式
Microsoft解释:https://docs.microsoft.com/en-us/windows-hardware/drivers/gettingstarted/user-mode-and-kernel-mode
// 创建线程对象
Thread t = new Thread() {
public void run() {
// 要执行的任务
}
};
// 启动线程
Runnable runnable = new Runnable() {
public void run(){
// 要执行的任务
}
};
// 创建线程对象
Thread t = new Thread( runnable );
// 启动线程
class CallableTask implements Callable {
@Override
public Integer call() throws Exception {
return new Random().nextInt();
}
}
//创建线程池
ExecutorService service = Executors.newFixedThreadPool(10);
//提交任务,并用 Future提交返回结果
//线程池源码
static class DefaultThreadFactory implements ThreadFactory {
private static final AtomicInteger poolNumber = new AtomicInteger(1);
private final ThreadGroup group;
private final AtomicInteger threadNumber = new AtomicInteger(1);
private final String namePrefix;
DefaultThreadFactory() {
SecurityManager s = System.getSecurityManager();
group = (s != null) ? s.getThreadGroup() :
Thread.currentThread().getThreadGroup();
namePrefix = "pool-" +
poolNumber.getAndIncrement() +
"-thread-";
}
public Thread newThread(Runnable r) {
Thread t = new Thread(group, r,
namePrefix + threadNumber.getAndIncrement(),
0);
if (t.isDaemon())
t.setDaemon(false);
if (t.getPriority() != Thread.NORM_PRIORITY)
t.setPriority(Thread.NORM_PRIORITY);
return t;
}
}
本质上Java中实现线程只有一种方式,都是通过new Thread()创建线程,调用==Thread#start()==启动线程最终都会调用Thread#run方法。
首先我们来思考,为什么Thread执行start方法之后要调用run方法,而不是直接调用run方法?
首先在Thread初始化之前会调用Thread#registerNatives()这个注册方法,用于完成native和真正方法的绑定;
/* Make sure registerNatives is the first thing does. */
private static native void registerNatives();
static {
registerNatives();
}
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-zrxLlOFJ-1648567490228)(E:\file\学习\课后总结\并发编程\assert\JNINativeMethod)]
Thread#start()是Java线程的启动入口,Thread#start()会调用Thread#start0(),这个方法是个native方法;JNINativeMethod中建立了JNI的映射关系,因此调用Thread#start0()实际上调用了JVM中的Thread.c#JVM_StartThread来进行线程的创建;
public synchronized void start() {
/**
* This method is not invoked for the main method thread or "system"
* group threads created/set up by the VM. Any new functionality added
* to this method in the future may have to also be added to the VM.
*
* A zero status value corresponds to state "NEW".
*/
if (threadStatus != 0)
throw new IllegalThreadStateException();
/* Notify the group that this thread is about to be started
* so that it can be added to the group's list of threads
* and the group's unstarted count can be decremented. */
group.add(this);
boolean started = false;
try {
start0();
started = true;
} finally {
try {
if (!started) {
group.threadStartFailed(this);
}
} catch (Throwable ignore) {
/* do nothing. If start0 threw a Throwable then
it will be passed up the call stack */
}
}
}
private native void start0();
利用new JavaThread(&thread_entry,sz)进行线程的创建,并根据不同的操作系统平台调用对应的os::create_Thread方法进行线程的创建,create_Thread方法最终调用的是pthread_create,相当于是操作系统进行线程的创建,属于系统调用,相当于是从用户态到内核态的切换;
创建完之后,线程会有一个初始化状态initialized,并调用sync->wait()方法进行阻塞;
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-WO2LDhX5-1648567490233)(E:\file\学习\课后总结\并发编程\assert\thread#init)]
通过native_thread->prepare(jthread)来进行Java的Thread和JVM的Thread之间一对一的绑定;
通过调用Thread::start(native_thread)来启动线程,此时设置Java线程状态为runnable,接着调用os::start_thread(thread)来根据不同操作系统选择不同的线程启动方式;
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-UmXkBMh8-1648567490237)(E:\file\学习\课后总结\并发编程\assert\thread#run)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-PTXvOZgF-1648567490238)(E:\file\学习\课后总结\并发编程\assert\setRun)]
此时线程状态为runnable,唤醒线程,并执行thread.cpp#JavaThread::run()方法;
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-OKSe01Qp-1648567490242)(E:\file\学习\课后总结\并发编程\assert\wakeup)]
然后通过JavaCall::call的方式去回调new Thread()时重写的run方法;
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-62OgHH5N-1648567490243)(E:\file\学习\课后总结\并发编程\assert\innner)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Bm8l2ZVj-1648567490244)(E:\file\学习\课后总结\并发编程\assert\javaCall)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-f6mCJ1RG-1648567490250)(E:\file\学习\课后总结\并发编程\assert\内核级线程)]
内核级线程:它们都是依赖于内核的,也就是说无论是系统进程还是用户进程,其创建、切换、撤销都需要由内核来完成
用户级线程:操作系统内核不知道应用进程的存在
JDK1.2基于操作系统原生线程模型实现Java线程。Sun JDK,其Windows版本和Linux版本都是用一对一的线程模型来实现的,一条Java线程映射到一条轻量级进程。
协程(Coroutines),是一种基于线程之上,但又比线程更加轻量级的存在,协程不是被操作系统内核所管理,而完全是由程序所控制(也就是在用户态执行),具有对内核来说不可见的特性。这样带来的好处就是性能得到了很大的提升,不会像线程切换那样消耗资源。
协程的特点在于是一个线程执行,那和多线程比,协程有何优势?
线程的切换由操作系统调度,协程由用户自己进行调度,因此减少了上下文切换,提高了效率。
线程的默认stack大小是1M,而协程更轻量,接近1k。因此可以在相同的内存中开启更多的协程。
不需要多线程的锁机制:因为只有一个线程,也不存在同时写变量冲突,在协程中控制共享资源不加锁,只需要判断状态就好了,所以执行效率比多线程高很多。
注意: 协程适用于被阻塞的,且需要大量并发的场景(网络io)。不适合大量计算的场景。
java中是否存在协程?
java中协程框架: kilim quasar
线程调度是指系统为线程分配处理器使用权的过程,主要分为协同式调度和抢占式调度。
线程执行时间由线程本身来控制,线程把自己的工作执行完之后,要主动通知系统切换到另外一个线程上。最大好处是实现简单,且切换操作对线程自己是可知的,没啥线程同步问题。坏处是线程执行时间不可控制,如果一个线程有问题,可能一直阻塞在那里。
每个线程将由系统来分配执行时间,线程的切换不由线程本身来决定(Java中,Thread.yield()可以让出执行时间,但无法获取执行时间)。线程执行时间系统可控,也不会由一个线程导致整个进程阻塞。
Java线程调度就是抢占式调度
希望系统能给某些线程多分配一些时间,给一些线程少分配一些时间,可以通过设置线程优先级来完成。Java语言一共10个级别的线程优先级(Thread.MIN_PRIORITY至Thread.MAX_PRIORITY),在两线程同时处于ready状态时,优先级越高的线程越容易被系统选择执行。但优先级并不是很靠谱,因为Java线程是通过映射到系统的原生线程上来实现的,所以线程调度最终还是取决于操作系统。
public class SellTicketDemo implements Runnable {
private int ticket;
public SellTicketDemo() {
this.ticket = 100;
}
@Override
public void run() {
while (ticket > 0) {
synchronized (this) {
if (ticket > 0) {
try {
Thread.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + ":正在执行操作,余票:" + ticket--);
}
}
Thread.yield();
}
}
public static void main(String[] args) {
SellTicketDemo demo = new SellTicketDemo();
Thread thread1 = new Thread(demo,"thread1");
Thread thread2 = new Thread(demo,"thread2");
Thread thread3 = new Thread(demo,"thread3");
Thread thread4 = new Thread(demo,"thread4");
thread1.setPriority(Thread.MAX_PRIORITY);
thread2.setPriority(Thread.MAX_PRIORITY);
thread3.setPriority(Thread.MIN_PRIORITY);
thread4.setPriority(Thread.MIN_PRIORITY);
thread1.start();
thread2.start();
thread3.start();
thread4.start();
}
Java 语言中线程共有六种状态,分别是:
在操作系统层面,Java 线程中的 BLOCKED、WAITING、TIMED_WAITING 是一种状态,即前面我们提到的休眠状态。也就是说只要 Java 线程处于这三种状态之一,那么这个线程就永远没有 CPU 的使用权。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-eqbTog9v-1648567490253)(\assert\Java-Thread-lifeCycle)]
public enum State {
/**
* Thread state for a thread which has not yet started.
*/
NEW,
/**
* Thread state for a runnable thread. A thread in the runnable
* state is executing in the Java virtual machine but it may
* be waiting for other resources from the operating system
* such as processor.
*/
RUNNABLE,
/**
* Thread state for a thread blocked waiting for a monitor lock.
* A thread in the blocked state is waiting for a monitor lock
* to enter a synchronized block/method or
* reenter a synchronized block/method after calling
* {@link Object#wait() Object.wait}.
*/
BLOCKED,
/**
* Thread state for a waiting thread.
* A thread is in the waiting state due to calling one of the
* following methods:
*
* - {@link Object#wait() Object.wait} with no timeout
* - {@link #join() Thread.join} with no timeout
* - {@link LockSupport#park() LockSupport.park}
*
*
* A thread in the waiting state is waiting for another thread to
* perform a particular action.
*
* For example, a thread that has called Object.wait()
* on an object is waiting for another thread to call
* Object.notify() or Object.notifyAll() on
* that object. A thread that has called Thread.join()
* is waiting for a specified thread to terminate.
*/
WAITING,
/**
* Thread state for a waiting thread with a specified waiting time.
* A thread is in the timed waiting state due to calling one of
* the following methods with a specified positive waiting time:
*
* - {@link #sleep Thread.sleep}
* - {@link Object#wait(long) Object.wait} with timeout
* - {@link #join(long) Thread.join} with timeout
* - {@link LockSupport#parkNanos LockSupport.parkNanos}
* - {@link LockSupport#parkUntil LockSupport.parkUntil}
*
*/
TIMED_WAITING,
/**
* Thread state for a terminated thread.
* The thread has completed execution.
*/
TERMINATED;
}
从JavaThread的角度,JVM定义了一些针对Java Thread对象的状态(jvm.h)
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-8NEIPKTK-1648567490254)(\assert\JVM-Thread)]
从OSThread的角度,JVM还定义了一些线程状态给外部使用,比如用jstack输出的线程堆栈信息中线程的状态(osThread.hpp)
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-bb89kSrR-1648567490255)(\assert\OS-Thread)]
sleep()
yield()
join()
stop()
stop()方法已经被jdk废弃,原因就是stop()方法太过于暴力,强行把执行到一半的线程终止。
stop会释放对象锁,可能会造成数据不一致。
Java没有提供一种安全、直接的方法来停止某个线程,而是提供了中断机制。中断机制是一种协作机制,也就是说通过中断并不能直接终止另一个线程,而需要被中断的线程自己处理。被中断的线程拥有完全的自主权,它既可以选择立即停止,也可以选择一段时间后停止,也可以选择压根不停止。
所谓协作式,是通过一个中断标识位来实现的。其它线程如果想要中断线程A,就对线程A的中断标识位做一个标记,线程A自己通过**「轮询」**去检查标识位,然后自己做处理。
API的使用
public class ThreadInterruptTest {
static int i = 0;
public static void main(String[] args) {
System.out.println("begin");
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
while (true) {
i++;
System.out.println(i);
//Thread.interrupted() 清除中断标志位
//Thread.currentThread().isInterrupted() 不会清除中断标志位
if (Thread.currentThread().isInterrupted() ) {
System.out.println("=========");
}
if(i==10){
break;
}
}
}
});
t1.start();
//不会停止线程t1,只会设置一个中断标志位 flag=true
t1.interrupt();
}
利用中断机制优雅的停止线程
while (!Thread.currentThread().isInterrupted() && more work to do) {
do more work
public class StopThread implements Runnable {
public class StopThread implements Runnable {
@Override
public void run() {
int count = 0;
while (!Thread.currentThread().isInterrupted() && count < 1000) {
System.out.println("count = " + count++);
}
System.out.println("线程停止: stop thread");
}
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(new StopThread());
thread.start();
Thread.sleep(5);
thread.interrupt();
}
注意:使用中断机制时一定要注意是否存在中断标志位被清除的情况
sleep 期间能否感受到中断
修改上面的代码,线程执行任务期间有休眠需求
@Override
public void run() {
int count = 0;
while (!Thread.currentThread().isInterrupted() && count < 1000) {
System.out.println("count = " + count++);
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("线程停止: stop thread");
处于休眠中的线程被中断,线程是可以感受到中断信号的,并且会抛出一个 InterruptedException 异常,同时清除中断信号,将中断标记位设置成 false。这样就会导致while条件Thread.currentThread().isInterrupted()为false,程序会在不满足count < 1000这个条件时退出。如果不在catch中重新手动添加中断信号,不做任何处理,就会屏蔽中断请求,有可能导致线程无法正确停止。
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
//重新设置线程中断状态为true
Thread.currentThread().interrupt()
sleep可以被中断 抛出中断异常:sleep interrupted, 清除中断标志位
wait可以被中断 抛出中断异常:InterruptedException, 清除中断标志位
volatile有两大特性,一是可见性,二是有序性,禁止指令重排序,其中可见性就是可以让线程之间进行通信。
public class VolatileDemo {
private static volatile boolean flag = true;
public static void main(String[] args) {
new Thread(new Runnable() {
@Override
public void run() {
while (true){
if (flag){
System.out.println("trun on");
flag = false;
}
}
}
}).start();
new Thread(new Runnable() {
@Override
public void run() {
while (true){
if (!flag){
System.out.println("trun off");
flag = true;
}
}
}
}).start();
}
等待唤醒机制可以基于wait和notify方法来实现,在一个线程内调用该线程锁对象的wait方法,线程将进入等待队列进行等待直到被唤醒。
public class WaitDemo {
private static Object lock = new Object();
private static boolean flag = true;
public static void main(String[] args) {
new Thread(new Runnable() {
@Override
public void run() {
synchronized (lock){
while (flag){
try {
System.out.println("wait start .......");
lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("wait end ....... ");
}
}
}).start();
new Thread(new Runnable() {
@Override
public void run() {
if (flag){
synchronized (lock){
if (flag){
lock.notify();
System.out.println("notify .......");
flag = false;
}
}
}
}
}).start();
}
LockSupport是JDK中用来实现线程阻塞和唤醒的工具,线程调用park则等待“许可”,调用unpark则为指定线程提供“许可”。使用它可以在任何场合使线程阻塞,可以指定任何线程进行唤醒,并且不用担心阻塞和唤醒操作的顺序,但要注意连续多次唤醒的效果和一次唤醒是一样的。
public class LockSupportTest {
public static void main(String[] args) {
Thread parkThread = new Thread(new ParkThread());
parkThread.start();
System.out.println("唤醒parkThread");
LockSupport.unpark(parkThread);
}
static class ParkThread implements Runnable{
@Override
public void run() {
System.out.println("ParkThread开始执行");
LockSupport.park();
System.out.println("ParkThread执行完成");
}
}
管道输入/输出流和普通的文件输入/输出流或者网络输入/输出流不同之处在于,它主要用于线程之间的数据传输,而传输的媒介为内存。管道输入/输出流主要包括了如下4种具体实现:PipedOutputStream、PipedInputStream、PipedReader和PipedWriter,前两种面向字节,而后两种面向字符。
public class Piped {
public static void main(String[] args) throws Exception {
PipedWriter out = new PipedWriter();
PipedReader in = new PipedReader();
// 将输出流和输入流进行连接,否则在使用时会抛出IOException
out.connect(in);
Thread printThread = new Thread(new Print(in), "PrintThread");
printThread.start();
int receive = 0;
try {
while ((receive = System.in.read()) != -1) {
out.write(receive);
}
} finally {
out.close();
}
}
static class Print implements Runnable {
private PipedReader in;
public Print(PipedReader in) {
this.in = in;
}
@Override
public void run() {
int receive = 0;
try {
while ((receive = in.read()) != -1) {
System.out.print((char) receive);
}
} catch (IOException ex) {
}
}
}
join可以理解成是线程合并,当在一个线程调用另一个线程的join方法时,当前线程阻塞等待被调用join方法的线程执行完毕才能继续执行,所以join的好处能够保证线程的执行顺序,但是如果调用线程的join方法其实已经失去了并行的意义,虽然存在多个线程,但是本质上还是串行的,最后join的实现其实是基于等待通知机制的。
e = 0;
try {
while ((receive = System.in.read()) != -1) {
out.write(receive);
}
} finally {
out.close();
}
}
static class Print implements Runnable {
private PipedReader in;
public Print(PipedReader in) {
this.in = in;
}
@Override
public void run() {
int receive = 0;
try {
while ((receive = in.read()) != -1) {
System.out.print((char) receive);
}
} catch (IOException ex) {
}
}
}
### 2.7.4**Thread.join**
join可以理解成是线程合并,当在一个线程调用另一个线程的join方法时,当前线程阻塞等待被调用join方法的线程执行完毕才能继续执行,所以join的好处能够保证线程的执行顺序,但是如果调用线程的join方法其实已经失去了并行的意义,虽然存在多个线程,但是本质上还是串行的,最后join的实现其实是基于等待通知机制的。