目录
2.1.1 一种有返回结果的线程实现方式-->Callable
2.3.1.5 wait()、notify()、notifyAll()
1. 基础概念
1.1 进程
我们常听说的是应用程序,也就是app,由指令和数据组成。但是当我们不运行一个具体的app时,这些应用程序就是放在磁盘(也包括U盘、远程网络存储等等)上的一些二进制的代码。一旦我们运行这些应用程序,指令要运行,数据要读写,就必须将指令加载至 CPU,数据加载至内存。在指令运行过程中还需要用到磁盘、网络等设备,从这种角度来说,进程就是用来加载指令、管理内存、管理 IO的。
进程可以分为系统进程和用户进程。凡是用于完成操作系统的各种功能的进程就是系统进程,它们就是处于运行状态下的操作系统本身,用户进程就是所有由你启动的进程。下图为我们任务管理中的一些进程,可以看出,我们的一个进程就是一个应用程序的实例。
站在操作系统的角度,进程是程序运行资源分配(以内存为主)的最小单位。
1.2 线程
- 线程是CPU调度的最小单位。
- 线程必须依赖于进程而存在,线程是进程中的一个实体,是CPU调度和分派的基本单位,它是比进程更小的、能独立运行的基本单位。
- 线程自己基本上不拥有系统资源,只拥有在运行中必不可少的资源(如程序计数器,一组寄存器和栈),但是它可与同属一个进程的其他的线程共享进程所拥有的全部资源。
- 一个进程可以拥有多个线程,一个线程必须有一个父进程。
- 线程,有时也被称为轻量级进程(Lightweight Process,LWP),早期Linux的线程实现几乎就是复用的进程,后来才独立出自己的API。
1.3 进程间的通信
同一台计算机的进程通信称为 IPC(Inter-process communication),不同计算机之间的进程通信被称为R(mote)PC,需要通过网络,并遵守共同的协议,比如大家熟悉的Dubbo就是一个RPC框架,而Http协议也经常用在RPC上,比如SpringCloud微服务。
1.3.1 进程间的通信方式
1.3.1.1 管道
分为匿名管道(pipe)及命名管道(named pipe):匿名管道可用于具有亲缘关系的父子进程间的通信,命名管道除了具有管道所具有的功能外,它还允许无亲缘关系进程间的通信。
1.3.1.2 信号(signal)
信号是在软件层次上对中断机制的一种模拟,它是比较复杂的通信方式,用于通知进程有某事件发生,一个进程收到一个信号与处理器收到一个中断请求效果上可以说是一致的。
1.3.1.3 消息队列(message queue)
消息队列是消息的链接表,它克服了上两种通信方式中信号量有限的缺点,具有写权限得进程可以按照一定得规则向消息队列中添加新信息;对消息队列有读权限得进程则可以从消息队列中读取信息。
1.3.1.4 共享内存(shared memory)
可以说这是最有用的进程间通信方式。它使得多个进程可以访问同一块内存空间,不同进程可以及时看到对方进程中对共享内存中数据得更新。这种方式需要依靠某种同步操作,如互斥锁和信号量等。
1.3.1.5. 信号量(semaphore)
主要作为进程之间及同一种进程的不同线程之间得同步和互斥手段。
1.3.1.6 套接字(socket)
这是一种更为一般得进程间通信机制,它可用于网络中不同机器之间的进程间通信,应用非常广泛。同一机器中的进程还可以使用Unix domain socket(比如同一机器中MySQL中的控制台mysql shell和MySQL服务程序的连接),这种方式不需要经过网络协议栈,不需要打包拆包、计算校验和、维护序号和应答等,比纯粹基于网络的进程间通信肯定效率更高。
1.4 CPU核心数和线程的关系
目前主流CPU都是多核的,线程是CPU调度的最小单位。同一时刻,一个CPU核心只能运行一个线程,也就是CPU内核和同时运行的线程数是1:1的关系,也就是说8核CPU同时可以执行8个线程的代码。但 Intel引入超线程技术后,产生了逻辑处理器的概念,使核心数与线程数形成1:2的关系。
如下图:在我们的Windows任务管理器中就能看出来,内核是4,逻辑处理器就8,1:2的关系。
1.5 上下文切换
操作系统要在多个进程(线程)之间进行调度,而每个线程在使用CPU时总是要使用CPU中的资源,比如CPU寄存器和程序计数器。这就意味着,操作系统要保证线程在调度前后的正常执行,所以,操作系统中就有上下文切换的概念,它是指CPU(中央处理单元)从一个进程或线程到另一个进程或线程的切换。
上下文是CPU寄存器和程序计数器在任何时间点的内容。
寄存器是CPU内部的一小部分非常快的内存(相对于CPU内部的缓存和CPU外部较慢的RAM主内存),它通过提供对常用值的快速访问来加快计算机程序的执行。
程序计数器是一种专门的寄存器,它指示CPU在其指令序列中的位置,并保存着正在执行的指令的地址或下一条要执行的指令的地址,这取决于具体的系统。
上下文切换可以更详细地描述为内核(即操作系统的核心)对CPU上的进程(包括线程)执行以下活动:
- 1. 暂停一个进程的处理,并将该进程的CPU状态(即上下文)存储在内存中的某个地方
- 2. 从内存中获取下一个进程的上下文,并在CPU的寄存器中恢复它
- 3. 返回到程序计数器指示的位置(即返回到进程被中断的代码行)以恢复进程。
从数据来说,以程序员的角度来看, 是方法调用过程中的各种局部的变量与资源; 以线程的角度来看, 是方法的调用栈中存储的各类信息。
引发上下文切换的原因一般包括:线程、进程切换、系统调用等等。上下文切换通常是计算密集型的,因为涉及一系列数据在各种寄存器、 缓存中的来回拷贝。就CPU时间而言,一次上下文切换大概需要5000~20000个时钟周期,相对一个简单指令几个乃至十几个左右的执行时钟周期,可以看出这个成本的巨大。
1.6 并行和并发
并发Concurrent:指应用能够交替执行不同的任务,比如单CPU核心下执行多线程并非是同时执行多个任务,如果你开两个线程执行,就是在你几乎不可能察觉到的速度不断去切换这两个任务,已达到"同时执行效果",其实并不是的,只是计算机的速度太快,我们无法察觉到而已.
当谈论并发的时候一定要加个单位时间,也就是说单位时间内并发量是多少?离开了单位时间其实是没有意义的。
并行Parallel:指应用能够同时执行不同的任务,例:吃饭的时候可以边吃饭边打电话,这两件事情可以同时执行。
2. JAVA里的线程
java程序里面的线程无处不在,一个Java程序从main()方法开始执行,然后按照既定的代码逻辑执行,看似没有其他线程参与,但实际上Java程序天生就是多线程程序,因为执行main()方法的是一个名称为main的线程。
而一个Java程序的运行就算是没有用户自己开启的线程,实际也有有很多JVM自行启动的线程,一般来说有:
- [6] Monitor Ctrl-Break //监控Ctrl-Break中断信号的
- [5] Attach Listener //内存dump,线程dump,类信息统计,获取系统属性等
- [4] Signal Dispatcher // 分发处理发送给JVM信号的线程
- [3] Finalizer // 调用对象finalize方法的线程
- [2] Reference Handler//清除Reference的线程
- [1] main //main线程,用户程序入口
尽管这些线程根据不同的JDK版本会有差异,但是依然证明了Java程序天生就是多线程的。
下图为jdk1.8环境下,JVM自行启动的线程的测试。
2.1 JAVA线程的创建与启动
如下图所示,根据Thread的源码文档中,可知,java线程创建的方式有两种,一种是 XXX extends Thread,然后用XXX.strat()启动;另一种是实现Runnable接口,然后交给Thread执行;当然本质上Java中实现线程只有一种方式,都是通过new Thread()创建线程对象,调用Thread#start启动线程。
Thread是Java里对线程的唯一抽象,Runnable只是对任务(业务逻辑)的抽象。Thread可以接受任意一个Runnable的实例并执行。
以下是两种创建及启动线程的代码示例:
执行结果package cn.my.test.base; import cn.my.test.tools.SleepTools; import java.util.concurrent.ExecutionException; /** *类说明:新启线程的方式 */ public class NewThread { /*扩展Thread类*/ private static class UseThread extends Thread{ @Override public void run() { super.run(); SleepTools.second(1);//睡眠 // do my work; System.out.println("I am extendec Thread"); } } /*实现Runnable接口*/ private static class UseRunnable implements Runnable{ @Override public void run() { // do my work; System.out.println("I am implements Runnable"); } } public static void main(String[] args) throws InterruptedException, ExecutionException { /*第一种方式*/ UseThread useThread = new UseThread(); useThread.start(); /*第二种方式*/ UseRunnable useRunnable = new UseRunnable(); new Thread(useRunnable).start(); System.out.println("main end"); } }
![]()
2.1.1 一种有返回结果的线程实现方式-->Callable
以下是callable的源码截图,源码注释中说,callable的call方法可以返回一个值,并且callable和runnable很相似,都可能被一个线程执行。这么说来,如果我们要得到一个现在中的返回值,那么我们就可以实现callable这个接口,交与一个线程执行;
上面提到,我们可以实现callable接口交给一个线程来执行,那我们来看一下线程Thread的源码,如下图所示,我们得知,Thread的构造方法没有一个参数是Callable的,但是有构造方法参数为Runnable的,既然上述中提到,callable和Runnable很类似,可以交给一个线程Thread执行,但是线程Thread又没有参数为callable实例的构造方法,那么我就得想办法把callable转化为Runnable,这就出现了FutureTask。
接下来我们看一下FutureTask,从图中我们可以看出,FutureTask及有Runnable功能也有Futhere的功能,(Future就是对于具体的Runnable或者Callable任务的执行结果进行取消、查询是否完成、获取结果。必要时可以通过get方法获取执行结果,该方法会阻塞直到任务返回结果。)正好满足们将callable包装为Runnable,也满足了能取到callable的call()的返回值。
实现案例:
public class UseFuture { /*实现Callable接口,允许有返回值*/ private static class UseCallable implements Callable<Integer>{ private int sum; @Override public Integer call() throws Exception { System.out.println("Callable子线程开始计算!"); // Thread.sleep(1000); for(int i=0 ;i<5000;i++){ if(Thread.currentThread().isInterrupted()) { System.out.println("Callable子线程计算任务中断!"); return null; } sum=sum+i; System.out.println("sum="+sum); } System.out.println("Callable子线程计算结束!结果为: "+sum); return sum; } } public static void main(String[] args) throws InterruptedException, ExecutionException { UseCallable useCallable = new UseCallable(); //包装 FutureTask<Integer> futureTask = new FutureTask<>(useCallable); Random r = new Random(); new Thread(futureTask).start(); Thread.sleep(1); if(r.nextInt(100)>50){ System.out.println("Get UseCallable result = "+futureTask.get()); }else{ System.out.println("Cancel................. "); futureTask.cancel(true); }
小结
- Runnable是一个接口,在它里面只声明了一个run()方法,由于run()方法返回值为void类型,所以在执行完任务之后无法返回任何结果。
- Callable位于java.util.concurrent包下,它也是一个接口,在它里面也只声明了一个方法,只不过这个方法叫做call(),这是一个泛型接口,call()函数返回的类型就是传递进来的V类型。
- Future就是对于具体的Runnable或者Callable任务的执行结果进行取消、查询是否完成、获取结果。必要时可以通过get方法获取执行结果,该方法会阻塞直到任务返回结果。Future只是一个接口,所以是无法直接用来创建对象使用的。
- FutureTask类实现了RunnableFuture接口,RunnableFuture继承了Runnable接口和Future接口,而FutureTask实现了RunnableFuture接口。所以它既可以作为Runnable被线程执行,又可以作为Future得到Callable的返回值。
- Callable启动一个线程步骤
- 第一步: 实现Callable接口XXX;
- 第二步: 用FutureTask包装XXX;
- 第三步: 交给Thread; T=new Thread(FutureTask);
- 第四步:启动线程--》 T.start();
2.2 JAVA线程的中止
2.2.1 自然终止
线程要么是run执行完成了,要么是抛出了一个未处理的异常导致线程提前结束。
2.2.2 手动中止
由Thread源码图可知, 线程暂停、恢复和停止操作对应在线程Thread的API就是suspend()、resume()和stop()。但是这些API是过期的,也就是不建议使用的。
不建议使用的原因主要有:以suspend()方法为例,在调用后,线程不会释放已经占有的资源(比如锁),而是占有着资源进入睡眠状态,这样容易引发死锁问题。同样,stop()方法在终结一个线程时不会保证线程的资源正常释放,通常是没有给予线程完成资源释放工作的机会,因此会导致程序可能工作在不确定状态下。正因为suspend()、resume()和stop()方法带来的副作用,这些方法才被标注为不建议使用的过期方法。
2.2.3 安全终止的方式--》中断
安全的中止则是其他线程通过调用某个线程A的interrupt()方法对其进行中断操作, 中断好比其他线程对该线程打了个招呼,“A,你要中断了”,不代表线程A会立即停止自己的工作,同样的A线程完全可以不理会这种中断请求。线程通过检查自身的中断标志位是否被置为true来进行响应。
线程通过方法isInterrupted()来进行判断是否被中断,也可以调用静态方法Thread.interrupted()来进行判断当前线程是否被中断,不过Thread.interrupted()会同时将中断标识位改写为false。
/** *类说明:如何安全中断线程 */ public class EndThread { private static class UseThread extends Thread{ private boolean cancel; public UseThread(String name) { super(name); } public void setCancel(boolean cancel) { this.cancel = cancel; } @Override public void run() { String threadName = Thread.currentThread().getName(); System.out.println(threadName+" interrrupt flag ="+isInterrupted()); //while(!isInterrupted()){ //Thread.sleep(); while(!Thread.interrupted()){ } System.out.println(threadName+" interrrupt flag ="+isInterrupted()); } } public static void main(String[] args) throws InterruptedException { UseThread endThread = new UseThread("endThread"); endThread.start(); Thread.sleep(20); endThread.interrupt();//中断线程,其实设置线程的中断标识位=true } }
如果一个线程处于了阻塞状态(如线程调用了thread.sleep、thread.join、thread.wait等),则在线程在检查中断标示时如果发现中断标示为true,则会在这些阻塞方法调用处抛出InterruptedException异常,并且在抛出异常后会立即将线程的中断标示位清除,即重新设置为false。
/** *类说明:如何安全中断线程 */ public class EndThread { private static class UseThread extends Thread{ private boolean cancel; public UseThread(String name) { super(name); } public void setCancel(boolean cancel) { this.cancel = cancel; } @Override public void run() { String threadName = Thread.currentThread().getName(); System.out.println(threadName+" interrrupt flag ="+isInterrupted()); while(!isInterrupted()){ try { Thread.sleep(20); } catch (InterruptedException e) { e.printStackTrace(); } } System.out.println(threadName+" interrrupt flag ="+isInterrupted()); } } public static void main(String[] args) throws InterruptedException { UseThread endThread = new UseThread("endThread"); endThread.start(); Thread.sleep(20); endThread.interrupt();//中断线程,其实设置线程的中断标识位=true } }
不建议自定义一个取消标志位来中止线程的运行。因为run方法里有阻塞调用时会无法很快检测到取消标志,线程必须从阻塞调用返回后,才会检查这个取消标志。这种情况下,使用中断会更好,因为:
- 一般的阻塞方法,如sleep等本身就支持中断的检查,
- 检查中断位的状态和检查取消标志位没什么区别,用中断位的状态还可以避免声明取消标志位,减少资源的消耗。
注意:处于死锁状态的线程无法被中断
2.3 JAVA线程的状态与生命周期
Java中线程的状态分为6种:
1. 初始(NEW):新创建了一个线程对象,但还没有调用start()方法。
2. 运行(RUNNABLE):Java线程中将就绪(ready)和运行中(running)两种状态笼统的称为“运行”。
线程对象创建后,其他线程(比如main线程)调用了该对象的start()方法。该状态的线程位于可运行线程池中,等待被线程调度选中,获取CPU的使用权,此时处于就绪状态(ready)。就绪状态的线程在获得CPU时间片后变为运行中状态(running)。
3. 阻塞(BLOCKED):表示线程阻塞于锁,java中,只有用到synchronized关键字的才会导致阻塞。
4. 等待(WAITING):进入该状态的线程需要等待其他线程做出一些特定动作(通知或中断)。
5. 超时等待(TIMED_WAITING):该状态不同于WAITING,它可以在指定的时间后自行返回。
6. 终止(TERMINATED):表示该线程已经执行完毕。
如下图所示:
2.3.1 相关方法与关键字详解
2.3.1.1 start() 方法
Thread类是Java里对线程概念的抽象,可以这样理解:我们通过new Thread()其实只是new出一个Thread的实例,还没有操作系统中真正的线程挂起钩来。只有执行了start()方法后,才实现了真正意义上的启动线程。
从Thread的源码可以看到,Thread的start方法中调用了start0()方法,而start0()是个native方法,这就说明Thread#start一定和操作系统是密切相关的。
start()方法让一个线程进入就绪队列等待分配cpu,分到cpu后才调用实现的run()方法,start()方法不能重复调用,如果重复调用会抛出异常。
run方法是业务逻辑实现的地方,本质上和任意一个类的任意一个成员方法并没有任何区别,可以重复执行,也可以被单独调用。
2.3.1.2 yield()方法
yield()方法使当前线程让出CPU占有权,但让出的时间是不可设定的。也不会释放锁资源。同时执行yield()的线程有可能在进入到就绪状态后会被操作系统再次选中马上又被执行。
比如,ConcurrentHashMap#initTable 方法中就使用了这个方法,
这是因为ConcurrentHashMap中可能被多个线程同时初始化table,但是其实这个时候只允许一个线程进行初始化操作,其他的线程就需要被阻塞或等待,但是初始化操作其实很快,这里Doug Lea大师为了避免阻塞或者等待这些操作引发的上下文切换等等开销,就让其他不执行初始化操作的线程干脆执行yield()方法,以让出CPU执行权,让执行初始化操作的线程可以更快的执行完成。
2.3.1.3 join方法
把指定的线程加入到当前线程,可以将两个交替执行的线程合并为顺序执行。比如在线程B中调用了线程A的Join()方法,直到线程A执行完毕后,才会继续执行线程B剩下的代码。
/** *类说明:演示Join()方法的使用 */ public class UseJoin { static class Goddess implements Runnable { private Thread thread; public Goddess(Thread thread) { this.thread = thread; } public Goddess() { } public void run() { System.out.println("Goddess开始排队打饭....."); try { if(thread!=null) thread.join(); } catch (InterruptedException e) { } SleepTools.second(2);//休眠2秒 System.out.println(Thread.currentThread().getName() + " Goddess打饭完成."); } } static class GoddessBoyfriend implements Runnable { public void run() { SleepTools.second(2);//休眠2秒 System.out.println("GoddessBoyfriend开始排队打饭....."); System.out.println(Thread.currentThread().getName() + " GoddessBoyfriend打饭完成."); } } public static void main(String[] args) throws Exception { Thread zhu = Thread.currentThread(); GoddessBoyfriend goddessBoyfriend = new GoddessBoyfriend(); Thread gbf = new Thread(goddessBoyfriend); Goddess goddess = new Goddess(gbf); Thread g = new Thread(goddess); g.start(); gbf.start(); System.out.println("zhu开始排队打饭....."); g.join(); SleepTools.second(2);//让主线程休眠2秒 System.out.println(zhu.getName() + " zhu打饭完成."); } }
2.3.1.4 synchronized
Java支持多个线程同时访问一个对象或者对象的成员变量,关键字synchronized可以修饰方法或者以同步块的形式来进行使用,它主要确保多个线程在同一个时刻,只能有一个线程处于方法或者同步块中,它保证了线程对变量访问的可见性和排他性,又称为内置锁机制。
synchronized内置锁会导致线程处于阻塞状态。
对象锁和类锁:
- 对象锁是用于对象实例方法,或者一个对象实例上的。
- 类锁是用于类的静态方法或者一个类的class对象上的。
- 类锁只是一个概念上的东西,并不是真实存在的,类锁其实锁的是每个类的对应的class对象。类锁和对象锁之间也是互不干扰的。
2.3.1.5 wait()、notify()、notifyAll()
wait()、notify()、notifyAll()是等待/通知机制的实现,首先,我们了解一下,什么是等待/通知机制。
等待/通知机制出现的背景:
线程之间相互配合,完成某项工作,比如:一个线程修改了一个对象的值,而另一个线程感知到了变化,然后进行相应的操作,整个过程开始于一个线程,而最终执行又是另一个线程。前者是生产者,后者就是消费者,这种模式隔离了“做什么”(what)和“怎么做”(How),简单的办法是让消费者线程不断地循环检查变量是否符合预期在while循环中设置不满足的条件,如果条件满足则退出while循环,从而完成消费者的工作。却存在如下问题:
1)难以确保及时性。
2)难以降低开销。如果降低睡眠的时间,比如休眠1毫秒,这样消费者能更加迅速地发现条件变化,但是却可能消耗更多的处理器资源,造成了无端的浪费。
等待和通知的标准范式
等待方遵循如下原则。
1)获取对象的锁。
2)如果条件不满足,那么调用对象的wait()方法,被通知后仍要检查条件。
3)条件满足则执行对应的逻辑。
通知方遵循如下原则。
1)获得对象的锁。
2)改变条件。
3)通知所有等待在对象上的线程。
java等待/通知机制:
这种机制是指一个线程A调用了对象O的wait()方法进入等待状态,而另一个线程B调用了对象O的notify()或者notifyAll()方法,线程A收到通知后从对象O的wait()方法返回,进而执行后续操作。上述两个线程通过对象O来完成交互,而对象上的wait()和notify/notifyAll()的关系就如同开关信号一样,用来完成等待方和通知方之间的交互工作。
- notify():通知一个在对象上等待的线程,使其从wait方法返回,而返回的前提是该线程获取到了对象的锁,没有获得锁的线程重新进入WAITING状态。
- notifyAll():通知所有等待在该对象上的线程
- wait(): 调用该方法的线程进入 WAITING状态,只有等待另外线程的通知或被中断才会返回.需要注意,调用wait()方法后,会释放对象的锁
- wait(long): 超时等待一段时间,这里的参数时间是毫秒,也就是等待长达n毫秒,如果没有通知就超时返回
- wait (long,int): 对于超时时间更细粒度的控制,可以达到纳秒
在调用wait()、notify()系列方法之前,线程必须要获得该对象的对象级别锁,即只能在同步方法或同步块中调用wait()方法、notify()系列方法,进入wait()方法后,当前线程释放锁,在从wait()返回前,线程与其他线程竞争重新获得锁, 执行notify()系列方法的线程退出调用了notifyAll的synchronized代码块的时候后,他们就会去竞争。如果其中一个线程获得了该对象锁,它就会继续往下执行,在它退出synchronized代码块,释放锁后,其他的已经被唤醒的线程将会继续竞争获取该锁,一直进行下去,直到所有被唤醒的线程都执行完毕。
notifyall(),谨慎使用notify(),因为notify()只会唤醒一个线程,我们无法确保被唤醒的这个线程一定就是我们需要唤醒的线程
2.4 线程的优先级
在Java线程中,通过一个整型成员变量priority来控制优先级,优先级的范围从1~10,在线程构建的时候可以通过setPriority(int)方法来修改优先级,默认优先级是5,优先级高的线程分配时间片的数量要多于优先级低的线程。
设置线程优先级时,针对频繁阻塞(休眠或者I/O操作)的线程需要设置较高优先级,而偏重计算(需要较多CPU时间或者偏运算)的线程则设置较低的优先级,确保处理器不会被独占。在不同的JVM以及操作系统上,线程规划会存在差异,有些操作系统甚至会忽略对线程优先级的设定。
2.5 线程的调度
- 线程调度是指系统为线程分配CPU使用权的过程,主要调度方式有两种:协同式线程调度(Cooperative Threads-Scheduling)和抢占式线程调度(Preemptive Threads-Scheduling)
- 使用协同式线程调度的多线程系统,线程执行的时间由线程本身来控制,线程把自己的工作执行完之后,要主动通知系统切换到另外一个线程上。使用协同式线程调度的最大好处是实现简单,由于线程要把自己的事情做完后才会通知系统进行线程切换,所以没有线程同步的问题,但是坏处也很明显,如果一个线程出了问题,则程序就会一直阻塞。
- 使用抢占式线程调度的多线程系统,每个线程执行的时间以及是否切换都由系统决定。在这种情况下,线程的执行时间不可控,所以不会有「一个线程导致整个进程阻塞」的问题出现。
- Java线程调度就是抢占式调度。
- 在Java中,线程唯一可以使用的手段是设置线程优先级,Java设置了10个级别的程序优先级,当两个线程同时处于Ready状态时,优先级越高的线程越容易被系统选择执行。
2.6 线程的实现
任何语言实现线程主要有三种方式:使用内核线程实现(1:1实现),使用用户线程实现(1:N实现),使用用户线程加轻量级进程混合实现(N:M实现)。
2.6.1 内核线程实现
- 使用内核线程实现的方式也被称为1: 1实现。 内核线程(Kernel-Level Thread, KLT) 就是直接由操作系统内核(Kernel, 下称内核) 支持的线程, 这种线程由内核来完成线程切换, 内核通过操纵调度器(Scheduler) 对线程进行调度, 并负责将线程的任务映射到各个处理器上。
- 由于内核线程的支持,每个线程都成为一个独立的调度单元,即使其中某一个在系统调用中被阻塞了,也不会影响整个进程继续工作,相关的调度工作也不需要额外考虑,已经由操作系统处理了。
- 局限性:首先,由于是基于内核线程实现的,所以各种线程操作,如创建、 析构及同步,都需要进行系统调用。而系统调用的代价相对较高, 需要在用户态(User Mode)和内核态(Kernel Mode)中来回切换。其次,每个语言层面的线程都需要有一个内核线程的支持,因此要消耗一定的内核资源(如内核线程的栈空间),因此一个系统支持的线程数量是有限的。
2.6.2 用户线程实现
用户线程指的是完全建立在用户空间的线程库上,系统内核不能感知到用户线程的存在及如何实现的。用户线程的建立、同步、销毁和调度完全在用户态中完成,不需要内核的帮助。 如果程序实现得当, 这种线程不需要切换到内核态, 因此操作可以是非常快速且低消耗的, 也能够支持规模更大的线程数量, 部分高性能数据库中的多线程就是由用户线程实现的。
用户线程的优势在于不需要系统内核支援,劣势也在于没有系统内核的支援, 所有的线程操作都需要由用户程序自己去处理。线程的创建、销毁、切换和调度都是用户必须考虑的问题,而且由于操作系统只把处理器资源分配到进程,那诸如“阻塞如何处理”“多处理器系统中如何将线程映射到其他处理器上”这类问题解决起来将会异常困难, 甚至有些是不可能实现的。 因为使用用户线程实现的程序通常都比较复杂,所以一般的应用程序都不倾向使用用户线程。Java语言曾经使用过用户线程,最终又放弃了。 但是近年来许多新的、以高并发为卖点的编程语言又普遍支持了用户线程,譬如Golang。
2.6.3 混合实现
线程除了依赖内核线程实现和完全由用户程序自己实现之外, 还有一种将内核线程与用户线程一起使用的实现方式, 被称为N:M实现。 在这种混合实现下, 既存在用户线程, 也存在内核线程。
用户线程还是完全建立在用户空间中, 因此用户线程的创建、 切换、 析构等操作依然廉价, 并且可以支持大规模的用户线程并发。
同样又可以使用内核提供的线程调度功能及处理器映射,并且用户线程的系统调用要通过内核线程来完成。在这种混合模式中, 用户线程与轻量级进程的数量比是不定的,是N:M的关系。
2.6.4 Java线程的实现
Java线程在早期的Classic虚拟机上(JDK 1.2以前),是用户线程实现的, 但从JDK 1.3起, 主流商用Java虚拟机的线程模型普遍都被替换为基于操作系统原生线程模型来实现,即采用1: 1的线程模型。
以HotSpot为例,它的每一个Java线程都是直接映射到一个操作系统原生线程来实现的,而且中间 没有额外的间接结构, 所以HotSpot自己是不会去干涉线程调度的,全权交给底下的操作系统去处理。
所以,这就是我们说Java线程调度是抢占式调度的原因。而且Java中的线程优先级是通过映射到操作系统的原生线程上实现的,所以线程的调度最终取决于操作系统,操作系统中线程的优先级有时并不能和Java中的一一对应,所以Java优先级并不是特别靠谱。
2.7 协程
2.7.1 协程出现的原因
随着互联网行业的发展,目前内核线程实现在很多场景已经有点不适宜了。比如,互联网服务架构在处理一次对外部业务请求的响应, 往往需要分布在不同机器上的大量服务共同协作来实现,,也就是我们常说的微服务,这种服务细分的架构在减少单个服务复杂度、 增加复用性的同时, 也不可避免地增加了服务的数量, 缩短了留给每个服务的响应时间。这要求每一个服务都必须在极短的时间内完成计算, 这样组合多个服务的总耗时才不会太长;也要求每一个服务提供者都要能同时处理数量更庞大的请求, 这样才不会出现请求由于某个服务被阻塞而出现等待。
Java目前的并发编程机制就与上述架构趋势产生了一些矛盾,1:1的内核线程模型是如今Java虚拟机线程实现的主流选择, 但是这种映射到操作系统上的线程天然的缺陷是切换、调度成本高昂,系统能容纳的线程数量也很有限。 以前处理一个请求可以允许花费很长时间在单体应用中,具有这种线程切换的成本也是无伤大雅的, 但现在在每个请求本身的执行时间变得很短、 数量变得很多的前提下,用户本身的业务线程切换的开销甚至可能会接近用于计算本身的开销,这就会造成严重的浪费。
另外我们常见的Java Web服务器,比如Tomcat的线程池的容量通常在几十个到两百之间, 当把数以百万计的请求往线程池里面灌时, 系统即使能处理得过来,但其中的切换损耗也是相当可观的。
这样的话,对Java语言来说,用户线程的重新引入成为了解决上述问题一个非常可行的方案。
其次,Go语言等支持用户线程等新型语言给Java带来了巨大的压力,也使得Java引入用户线程成为了一个绕不开的话题。
2.7.2 协程介绍
为什么用户线程又被称为协程呢?我们知道,内核线程的切换开销是来自于保护和恢复现场的成本, 那如果改为采用用户线程, 这部分开销就能够省略掉吗? 答案还是“不能”。 但是,一旦把保护、恢复现场及调度的工作从操作系统交到程序员手上,则可以通过很多手段来缩减这些开销。
由于最初多数的用户线程是被设计成协同式调度(Cooperative Scheduling) 的,所以它有了一个别名——“协程”(Coroutine) 完整地做调用栈的保护、恢复工作,所以今天也被称为“有栈协程”(Stackfull Coroutine)。
协程的主要优势是轻量, 无论是有栈协程还是无栈协程, 都要比传统内核线程要轻量得多。如果进行量化的话, 那么如果不显式设置,则在64位Linux上HotSpot的线程栈容量默认是1MB, 此外内核数据结构(Kernel Data Structures) 还会额外消耗16KB内存。与之相对的, 一个协程的栈通常在几百个字节到几KB之间, 所以Java虚拟机里线程池容量达到两百就已经不算小了, 而很多支持协程的应用中, 同时并存的协程数量可数以十万计。
协程当然也有它的局限, 需要在应用层面实现的内容(调用栈、 调度器这些)特别多,同时因为协程基本上是协同式调度,则协同式调度的缺点自然在协程上也存在。
总的来说,协程机制适用于被阻塞的,且需要大量并发的场景(网络io),不适合大量计算的场景,因为协程提供规模(更高的吞吐量),而不是速度(更低的延迟)
2.7.3 java中的协程--纤程
在JVM的实现上,以HotSpot为例,协程的实现会有些额外的限制,Java调用栈跟本地调用栈是做在一起的。 如果在协程中调用了本地方法, 还能否正常切换协程而不影响整个线程? 另外, 如果协程中遇传统的线程同步措施会怎样? 譬如Kotlin提供的协程实现, 一旦遭遇synchronize关键字, 那挂起来的仍将是整个线程。
所以Java开发组就Java中协程的实现也做了很多努力,OpenJDK在2018年创建了Loom项目,这是Java的官方解决方案, 并用了“纤程(Fiber)”这个名字。
Loom项目背后的意图是重新提供对用户线程的支持, 但这些新功能不是为了取代当前基于操作系统的线程实现, 而是会有两个并发编程模型在Java虚拟机中并存, 可以在程序中同时使用。 新模型有意地保持了与目前线程模型相似的API设计, 它们甚至可以拥有一个共同的基类, 这样现有的代码就不需要为了使用纤程而进行过多改动, 甚至不需要知道背后采用了哪个并发编程模型。
根据Loom团队在2018年公布的他们对Jetty基于纤程改造后的测试结果, 同样在5000QPS的压力下, 以容量为400的线程池的传统模式和每个请求配以一个纤程的新并发处理模式进行对比, 前者的请求响应延迟在10000至20000毫秒之间, 而后者的延迟普遍在200毫秒以下,
目前Java中比较出名的协程库是Quasar[ˈkweɪzɑː(r)](Loom项目的Leader就是Quasar的作者Ron Pressler), Quasar的实现原理是字节码注入,在字节码层面对当前被调用函数中的所有局部变量进行保存和恢复。这种不依赖Java虚拟机的现场保护虽然能够工作,但影响性能。
2.8 守护线程
Daemon(守护)线程是一种支持型线程,因为它主要被用作程序中后台调度以及支持性工作。这意味着,当一个Java虚拟机中不存在非Daemon线程的时候,Java虚拟机将会退出。可以通过调用Thread.setDaemon(true)将线程设置为Daemon线程。我们一般用不上,比如垃圾回收线程就是Daemon线程。
Daemon线程被用作完成支持性工作,但是在Java虚拟机退出时Daemon线程中的finally块并不一定会执行。在构建Daemon线程时,不能依靠finally块中的内容来确保执行关闭或清理资源的逻辑。
2.9 线程间的通信和协调、协作
2.9.1 管道输入输出流
我们已经知道,进程间有好几种通信机制,其中包括了管道,其实Java的线程里也有类似的管道机制,用于线程之间的数据传输,而传输的媒介为内存。
设想这么一个应用场景:通过 Java 应用生成文件,然后需要将文件上传到云端,比如:
1、页面点击导出后,后台触发导出任务,然后将mysql 中的数据根据导出条件查询出来,生成 Excel文件,然后将文件上传到 oss,最后发步一个下载文件的链接。
2、和银行以及金融机构对接时,从本地某个数据源查询数据后,上报 xml 格式的数据,给到指定的 ftp、或是 oss 的某个目录下也是类似的。
我们一般的做法是,先将文件写入到本地磁盘,然后从文件磁盘读出来上传到云盘,但是通过Java中的管道输入输出流一步到位,则可以避免写入磁盘这一步。
Java中的管道输入/输出流主要包括了如下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) {
}
}
}
}
2.9.2 volatile,最轻量的通信/同步机制
volatile保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。
不加volatile时,子线程无法感知主线程修改了ready的值,从而不会退出循环,而加了volatile后,子线程可以感知主线程修改了ready的值,迅速退出循环。
但是volatile不能保证数据在多个线程下同时写时的线程安全,volatile最适用的场景:一个线程写,多个线程读。
/**
* 类说明:演示Volatile的提供的可见性
*/
public class VolatileCase {
private static boolean ready;
private static int number;
private static class PrintThread extends Thread{
@Override
public void run() {
System.out.println("PrintThread is running.......");
while(!ready){
//System.out.println("lll");
};//无限循环
System.out.println("number = "+number);
}
}
public static void main(String[] args) throws InterruptedException {
new PrintThread().start();
SleepTools.second(1);
number = 51;
ready = true;
SleepTools.second(5);
System.out.println("main is ended!");
}
}