为什么我们要学习并发编程?
面试必备;大厂技术标配;非大厂面试加分项;善用并发编程可以提升技术话语权
为什么开发中需要并发编程?
(1) 加快响应用户的时间
(2) 使你的代码模块化,异步化,简单化
(3) 充分利用CPU的资源,多核CPU,超线程技术
基础概念
进程和线程
进程
我们常听说的是应用程序,也就是app,由指令和数据组成。
不运行app时,就是保存在磁盘上的一些二进制代码
当运行app时,指令要运行,数据要读写,就必须将指令加载至 CPU,数据加载至内存。在指令运行过程中还需要用到磁盘、网络等设备,从这种角度来说,进程就是用来加载指令、管理内存、管理 IO的。
进程就可以视为程序的一个实例。大部分程序可以同时运行多个实例进程(例如记事本、画图、浏览器 等),也有的程序只能启动一个实例进程(例如网易云音乐、360 安全卫士等)。
进程分为系统进程和用户进程。凡是用于完成操作系统的各种功能的进程就是系统进程,它们就是处于运行状态下的操作系统本身,用户进程就是所有由你启动的进程。
站在操作系统的角度,进程是程序运行资源分配(以内存为主)的最小单位。
线程
一个机器中肯定会运行很多的程序,CPU又是有限的,怎么让有限的CPU运行这么多程序呢?就需要一种机制在程序之间进行协调,也就所谓CPU调度。线程则是CPU调度的最小单位。
线程必须依赖于进程而存在,线程是进程中的一个实体,是CPU调度和分派的基本单位,它是比进程更小的、能独立运行的基本单位。线程自己基本上不拥有系统资源,只拥有在运行中必不可少的资源(如程序计数器,一组寄存器和栈),但是它可与同属一个进程的其他的线程共享进程所拥有的全部资源。一个进程可以拥有多个线程,一个线程必须有一个父进程。线程,有时也被称为轻量级进程(Lightweight Process,LWP),早期Linux的线程实现几乎就是复用的进程,后来才独立出自己的API。
Java线程的无处不在
Java中不管任何程序都必须启动一个main函数的主线程; Java Web开发里面的定时任务、定时器、JSP和 Servlet、异步消息处理机制,远程访问接口RM等,任何一个监听事件,onclick的触发事件等都离不开线程和并发的知识。
大厂面试题
大厂常见的面试题就是,进程间通信有几种方式?
1、 管道,分为匿名管道(pipe)及命名管道(named pipe):匿名管道可用于具有亲缘关系的父子进程间的通信,命名管道除了具有管道所具有的功能外,它还允许无亲缘关系进程间的通信。
2、信号(signal):信号是在软件层次上对中断机制的一种模拟,它是比较复杂的通信方式,用于通知进程有某事件发生,一个进程收到一个信号与处理器收到一个中断请求效果上可以说是一致的。
3、消息队列(message queue):消息队列是消息的链接表,它克服了上两种通信方式中信号量有限的缺点,具有写权限得进程可以按照一定得规则向消息队列中添加新信息;对消息队列有读权限得进程则可以从消息队列中读取信息。
4、共享内存(shared memory):可以说这是最有用的进程间通信方式。它使得多个进程可以访问同一块内存空间,不同进程可以及时看到对方进程中对共享内存中数据得更新。这种方式需要依靠某种同步操作,如互斥锁和信号量等。
5、信号量(semaphore):主要作为进程之间及同一种进程的不同线程之间得同步和互斥手段。
6、套接字(socket):这是一种更为一般得进程间通信机制,它可用于网络中不同机器之间的进程间通信,应用非常广泛。同一机器中的进程还可以使用Unix domain socket(比如同一机器中MySQL中的控制台mysql shell和MySQL服务程序的连接),这种方式不需要经过网络协议栈,不需要打包拆包、计算校验和、维护序号和应答等,比纯粹基于网络的进程间通信肯定效率更高。
CPU核心数和线程数的关系
目前主流CPU都是多核的,线程是CPU调度的最小单位。同一时刻,一个CPU核心只能运行一个线程,也就是CPU内核和同时运行的线程数是1:1的关系,也就是说8核CPU同时可以执行8个线程的代码。但 Intel引入超线程技术后,产生了逻辑处理器的概念,使核心数与线程数形成1:2的关系。
在Java中提供了Runtime.getRuntime().availableProcessors()
,可以让我们获取当前的CPU核心数,注意这个核心数指的是逻辑处理器数。
获得当前的CPU核心数在并发编程中很重要,并发编程下的性能优化往往和CPU核心数密切相关。
上下文切换(Context switch)
每个线程在使用CPU时总是要使用CPU中的资源,比如CPU寄存器和程序计数器。这就意味着,操作系统要保证线程在调度前后的正常执行,所以,操作系统中就有上下文切换的概念,它是指CPU(中央处理单元)从一个进程或线程到另一个进程或线程的切换。
上下文是CPU寄存器和程序计数器在任何时间点的内容。
寄存器是CPU内部的一小部分非常快的内存(相对于CPU内部的缓存和CPU外部较慢的RAM主内存),它通过提供对常用值的快速访问来加快计算机程序的执行。
程序计数器是一种专门的寄存器,它指示CPU在其指令序列中的位置,并保存着正在执行的指令的地址或下一条要执行的指令的地址,这取决于具体的系统。
上下文切换可以更详细地描述为内核(即操作系统的核心)对CPU上的进程(包括线程)执行以下活动:
1、暂停一个进程的处理,并将该进程的CPU状态(即上下文)存储在内存中的某个地方
2、从内存中获取下一个进程的上下文,并在CPU的寄存器中恢复它
3、返回到程序计数器指示的位置(即返回到进程被中断的代码行)以恢复进程。
从数据来说,以程序员的角度来看, 是方法调用过程中的各种局部的变量与资源; 以线程的角度来看, 是方法的调用栈中存储的各类信息。
引发上下文切换的原因一般包括:线程、进程切换、系统调用等等。上下文切换通常是计算密集型的,因为涉及一系列数据在各种寄存器、 缓存中的来回拷贝。就CPU时间而言,一次上下文切换大概需要5000~20000个时钟周期,相对一个简单指令几个乃至十几个左右的执行时钟周期,可以看出这个成本的巨大。
并行和并发
并发Concurrent:指应用能够交替执行不同的任务,单核也是并发的,计算太快感知不到
并行Parallel:指应用能够同时执行不同的任务
新启线程有几种方式?
官方说法是在Java中有两种方式创建一个线程用以执行,一种是派生自Thread类,另一种是实现Runnable接口。
当然本质上Java中实现线程只有一种方式,都是通过new Thread()创建线程对象,调用Thread#start启动线程。
至于基于callable接口的方式,因为最终是要把实现了callable接口的对象通过FutureTask包装成Runnable,再交给Thread去执行,所以这个其实可以和实现Runnable接口看成同一类。
而线程池的方式,本质上是池化技术,是资源的复用,和新启线程没什么关系。
所以,比较赞同官方的说法,有两种方式创建一个线程用以执行。
认识Java里的线程
Java程序天生就是多线程的
一个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程序天生就是多线程的。
线程的启动与中止
启动
启动线程的方式有:
1、X extends Thread;,然后X.start
2、X implements Runnable;然后交给Thread运行
Thread和Runnable的区别
Thread才是Java里对线程的唯一抽象,Runnable只是对任务(业务逻辑)的抽象。Thread可以接受任意一个Runnable的实例并执行。
Callable、Future和FutureTask
Runnable是一个接口,在它里面只声明了一个run()方法,由于run()方法返回值为void类型,所以在执行完任务之后无法返回任何结果。
Callable位于java.util.concurrent包下,它也是一个接口,在它里面也只声明了一个方法,只不过这个方法叫做call(),这是一个泛型接口,call()函数返回的类型就是传递进来的V类型。
Future就是对于具体的Runnable或者Callable任务的执行结果进行取消、查询是否完成、获取结果。必要时可以通过get方法获取执行结果,该方法会阻塞直到任务返回结果。
因为Future只是一个接口,所以是无法直接用来创建对象使用的,因此就有了FutureTask。
RunnableFuture继承了Runnable接口和Future接口,而FutureTask实现了RunnableFuture接口。所以它既可以作为Runnable被线程执行,又可以作为Future得到Callable的返回值。
线程运行Callable时,Thread不支持构造方法中传递Callable的实例。所以需要通过FutureTask把一个Callable包装成Runnable,然后再通过这个FutureTask拿到Callable运行后的返回值。
中止
线程自然终止
run执行完成了;抛出了一个未处理的异常导致线程提前结束。
stop
暂停、恢复和停止操作对应在线程Thread的API就是suspend()、resume()和stop()。
但是这些API是过期的,也就是不建议使用的。原因主要有:以suspend()方法为例,在调用后,线程不会释放已经占有的资源(比如锁),而是占有着资源进入睡眠状态,这样容易引发死锁问题。同样,stop()方法在终结一个线程时不会保证线程的资源正常释放,通常是没有给予线程完成资源释放工作的机会,因此会导致程序可能工作在不确定状态下。正因为suspend()、resume()和stop()方法带来的副作用,这些方法才被标注为不建议使用的过期方法。
小结:延迟高响应速度慢,资源不释放可能造成死锁,强硬停止可能导致文件损坏
中断
安全的中止则是其他线程通过调用某个线程A的interrupt()方法对其进行中断操作。线程通过检查自身的中断标志位是否被置为true来进行响应。
线程通过方法isInterrupted()来进行判断是否被中断,也可以调用静态方法Thread.interrupted()来进行判断当前线程是否被中断,不过Thread.interrupted()会同时将中断标识位改写为false。
不建议自定义一个取消标志位来中止线程的运行。因为run方法里有阻塞调用时会无法很快检测到取消标志,线程必须从阻塞调用返回后,才会检查这个取消标志。使用中断会更好,因为:一般的阻塞方法,如sleep等本身就支持中断的检查;检查中断位的状态和检查取消标志位没什么区别,用中断位的状态还可以避免声明取消标志位,减少资源的消耗。
注意:处于死锁状态的线程无法被中断
小结:interrupt()
只是把中断标志位设为true,是否停止由线程本身决定;isInterrupted()
返回标志位是否为true,不修改;interrupted()
返回标志位是否为true,并且修改标志位为false