并发编程
基础概念
进程和线程
进程
进程是程序运行资源分配的最小单位。
线程
一个机器中会运行很多程序,程序之间的切换依赖CPU调度,线程就是CPU调度的最小单位。线程依赖进程存在,是进程中的一个实体,是CPU调度分派的基本单位。
进程间的通信方式
-
管道(channel)
-
匿名管道(pipe)
父子进程间的通信,即父进程可以fork出一个子进程
-
命名管道(named pipe)
除了具备管道所有功能外,还允许无亲缘关系间进程的通信
-
-
信号(single)
信号是软件层次上对中断机制的一种模拟,是比较复杂的一种通信方式,用于通知进程有某事件发,生一个进程收到一个信号与处理器 收到一个中断请求效果上可以说是一致的。
-
消息队列(message queue)
消息队列是消息的链接表,它克服了上两 种通信方式中信号量有限的缺点,具有写权限得进程可以按照一定得规则向消息队列中添加新信息;对消息队列有读权限得进程则可以从消息队列中读取信息。
- 共享内存(shared memory)
可以说这是最有用的进程间通信方式。它使得多个进程可以访问同一块内存空间,不同进程可以及时看到对方进程中对共享内存中数据得更新。这种方式需要依靠某种同步操作,如互斥锁和信号量等。
-
信号量(semaphore)
主要作为进程之间及同一种进程的不同线程之间的同步和互斥手段。
-
套接字(socket)
这是一种更为一般得进程间通信机制,它可用于网络 中不同机器之间的进程间通信,应用非常广泛。同一机器中的进程还可以使用 Unix domain socket(比如同一机器中 MySQL 中的控制台 mysql shell 和 MySQL 服务程序的连接),这种方式不需要经过网络协议栈,不需要打包拆包、计算校验、维护序号和应答等,比纯粹基于网络的进程间通信肯定效率更高。
上下文切换
既然操作系统要在多个进程(线程)之间进行调度,而每个线程在使用 CPU时总是要使用 CPU中的资源,比如 CPU寄存器和程序计数器。这就意味着,操作系统要保证线程在调度前后的正常执行,所以,操作系统中就有上下文切换的概念,它是指 CPU(中央处理单元)从一个进程或线程到另一个进程或线程的切换。
上下文是 CPU 寄存器和程序计数器在任何时间点的内容。
寄存器是 CPU 内部的一小部分非常快的内存(相对于 CPU 内部的缓存和 CPU 外部较慢的 RAM 主内存),它通过提供对常用值的快速访问来加快计算机程序的执行。
程序计数器是一种专门的寄存器,它指示 CPU 在其指令序列中的位置,并保存着正在执行的指令的地址或下一条要执行的指令的地址,这取决于具体的系统。
上下文切换可以更详细地描述为内核(即操作系统的核心)对 CPU 上的进程 (包括线程)执行以下活动:
- 暂停一个进程的处理,并将该进程的 CPU 状态(即上下文)存储在内存中的 某个地方
- 从内存中获取下一个进程的上下文,并在 CPU 的寄存器中恢复它
- 返回到程序计数器指示的位置(即返回到进程被中断的代码行)以恢复进程。
从数据来说,以程序员的角度来看,是方法调用过程中的各种局部的变量与资源; 以线程的角度来看,是方法的调用栈中存储的各类信息。
引发上下文切换的原因一般包括:线程、进程切换、系统调用等等。上下文切换通常是计算密集型的,因为涉及一系列数据在各种寄存器、缓存中的来回拷贝。
就 CPU 时间而言,一次上下文切换大概需要 5000~20000 个时钟周期,相 对一个简单指令几个乃至十几个左右的执行时钟周期,可以看出这个成本的巨大。
并发和并行
并发
指应用能够交替执行不同的任务,比如单 CPU 核心下执行多线程并非是同时执行多个任务,如果你开两个线程执行,就是在你几乎不可能察觉到的速度不断去切换这两任务,已达到"同时执行效果",其实并不是的,只是计算机的速度太快,我们无法察觉到而已。
并行
指应用能够同时执行不同的任务,例:吃饭的时候可以边吃饭边 打电话,这两件事情可以同时执行。
两者区别
一个是交替执行,一个是同时执行。
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 线程,用户程序入口
即Java天生就是多线程的。
线程的启动
启动线程的方式
继承Thread类
X.extends Thread -----> x.start();
public class UseThread extends Thread{
@Override
public void run() {
super.run();
// 业务逻辑;
}
}
//启动
UseThread useThread = new UseThread();
useThread.start();
实现Runnable接口
+…X implements Runnable;然后交给 Thread 运行
public class UseRunnable implements Runnable{
@Override
public void run() {
// 业务逻辑;
}
}
//启动
UseRunnable useRunnable = new UseRunnable();
new Thread(useRunnable).start();
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。
FutureTask 类实现了 RunnableFuture 接口,RunnableFuture 继承了 Runnable 接口和 Future 接口,而 FutureTask 实现了 RunnableFuture 接口。所以它既可以作为 Runnable 被线程执行,又可以作为 Future 得到 Callable 的返回值。
因此我们通过一个线程运行 Callable,但是 Thread 不支持构造方法中传递 Callable 的实例,所以我们需要通过FutureTask 把一个Callable 包装成 Runnable,
然后再通过这个 FutureTask 拿到 Callable 运行后的返回值。
public class UseCallable implements Callable {
@Override
public Object call() throws Exception {
// 业务逻辑
return null;
}
}
//启动
UseCallable useCallable = new UseCallable();
FutureTask<UseCallable> futureTask = new FutureTask<UseCallable>(useCallable);
new Thread(futureTask).start();
run和start的区别
start()方法让一个线程进行就绪队列等待分配CPU资源,分到CPU之后才会调用实现的run()方法,start()方法不可以重复调用,会抛出异常。
run()方法是实现业务逻辑的方法,可以重复执行。
线程的终止
自然终止
即run方法执行完成或抛出一个未处理的异常导致线程提前结束。
stop
线程的暂停、恢复、停止之类的操作对应的Thread的API就是 suspend()、resume() 和 stop()。但是这些API都是过期的,不推荐使用。
以 suspend()方法为例,在调用后,线程不会释放已经占有的资源(比如 锁),而是占有着资源进入睡眠状态,这样容易引发死锁问题。同样,stop()方法在终结一个线程时不会保证线程的资源正常释放,通常是没有给予线程完成资 源释放工作的机会,因此会导致程序可能工作在不确定状态下。正因为 suspend()、esume()和 stop()方法带来的副作用,这些方法才被标注为不建议使用的过期方法。
中断
既然上述的方法都不推荐使用,那么必然有推荐使用的中断方法,即 interrupt()方法。
其他线程通过调用某个线程的 interrupt()方法对其进行中断操作,但是该线程并不会立即停止工作,线程会通过检查自身的中断标志位是否被置为true来响应。
线程通过isInterrupted()来进行判断是否被中断,也可以调用静态方法 Thread.interrupted()来进行判断当前线程是否被中断,不过 Thread.interrupted()会同时将中断标识位改写为 false。
如果一个线程处于了阻塞状态(如线程调用了 thread.sleep、thread.join、 thread.wait 等),则在线程在检查中断标示时如果发现中断标示为 true,则会在这些阻塞方法调用处抛出 InterruptedException 异常,并且在抛出异常后会立即将线程的中断标示位清除,即重新设置为 false。
不建议自定义一个取消标志位来中止线程的运行。
因为 run 方法里有阻塞调用时会无法很快检测到取消标志,线程必须从阻塞调用返回后,才会检查这个取消标志。
这种情况下,使用中断会更好,因为,
一、一般的阻塞方法,如 sleep 等本身就支持中断的检查,
二、检查中断位的状态和检查取消标志位没什么区别,用中断位的状态还可 以避免声明取消标志位,减少资源的消耗。
注意:处于死锁状态的线程无法被中断
线程的生命周期
Java中的线程状态分为6种:
-
初始(NEW):新创建了一个线程对象,但还没有调用 start()方法。
-
运行(RUNNABLE):Java 线程中将就绪(ready)和运行中(running)两种 状态笼统的称为“运行”。 线程对象创建后,其他线程(比如 main 线程)调用了该对象的 start()方法。 该状态的线程位于可运行线程池中,等待被线程调度选中,获取 CPU 的使用权, 此时处于就绪状态(ready)。就绪状态的线程在获得 CPU 时间片后变为运行中 状态(running)。
-
阻塞(BLOCKED):表示线程阻塞于锁。
-
等待(WAITING):进入该状态的线程需要等待其他线程做出一些特定动作 (通知或中断)。
-
超时等待(TIMED_WAITING):该状态不同于 WAITING,它可以在指定的时 间后自行返回。
-
终止(TERMINATED):表示该线程已经执行完毕。
相关方法
yield()方法:使当前线程让出 CPU 占有权,但让出的时间是不可设定的。也不会释放锁资源。同时执行 yield()的线程有可能在进入到就绪状态后会被操作系
统再次选中马上又被执行。
join():把指定的线程加入到当前线程,可以将两个交替执行的线程合并为顺序执行。比如在线程 B 中调用了线程 A 的 Join()方法,直到线程 A 执行完毕后,才会继续执行线程 B 剩下的代码。
notify(): 通知一个在对象上等待的线程,使其从wait方法返回,而返回的前提是该线程 获取到了对象的锁,没有获得锁的线程重新进入 WAITING 状态。
notifyAll():通知所有等待在该对象上的线程。
wait():调用该方法的线程进入 WAITING 状态,只有等待另外线程的通知或被中断才会返回.需要注意,调用wait()方法后,会释放对象的锁。
wait(long):超时等待一段时间,这里的参数时间是毫秒,也就是等待长达n毫秒,如果没有 通知就超时返回 wait (long,int)对于超时时间更细粒度的控制,可以达到纳秒。
线程的优先级
在 Java 线程中,通过一个整型成员变量 priority 来控制优先级,优先级的范 围从 1~10,在线程构建的时候可以通过 setPriority(int)方法来修改优先级,默认 优先级是 5,优先级高的线程分配时间片的数量要多于优先级低的线程。
设置线程优先级时,针对频繁阻塞(休眠或者 I/O 操作)的线程需要设置较高优先级,而偏重计算(需要较多 CPU 时间或者偏运算)的线程则设置较低的优先级,确保处理器不会被独占。在不同的 JVM以及操作系统上,线程规划会存在差异,有些操作系统甚至会忽略对线程优先级的设定。
线程的调度
线程调度是指系统为线程分配 CPU 使用权的过程,主要调度方式有两种:
协同式线程调度(Cooperative Threads-Scheduling)
线程的执行时间由线程本身控制,执行完成会通知操作系统切换到另一线程,没有同步问题,但是当一个线程除了问题,程序也会因此一直阻塞。
抢占式线程调度(Preemptive Threads-Scheduling)
线程的执行时间和切换都由系统控制。
线程和协程
线程的实现方式主要有以下三种:
-
内核线程实现(1:1)
直接由操作系统内核支持的线程,由内核完成线程切换,每个线程都是一个独立的调度单元,即使某一个线程阻塞了,不会影响整个进程的工作。但是由于使用内核线程调度,各种线程操作,如创建、 析构及同步,都需要进行系统调用。而系统调用的代价相对较高, 需要在用户态(User Mode)和内核态(Kernel Mode)中来回切换。其次,每个语言层面的 线程都需要有一个内核线程的支持,因此要消耗一定的内核资源(如内核线程的栈空间),因此一个系统支持的线程数量是有限的。
-
用户线程实现(1:N)
完全建立在用户空间的线程库上,系统内核感知不到线程的存在和实现,所有线程操作都在用户态完成,操作快速且消耗低,能支持规模更大的线程数目。但由于没有内核的帮助,大量的操作都需要用户程序自行处理,很大问题解决起来也比较困难甚至无法实现,导致程序非常复杂,一般不倾向于使用。
-
用户线程加轻量级进程混合实现(N:M)
即存在用户线程也存在内核线程,用户线程的创建、切换等操作仍在用户线程,操作快速消耗低,又可以使用内核线程的调度功能。
Java线程实现
目前来说Java采用的是基于操作系统原生线程模式,即1:1,因此Java线程的优先级都由操作系统控制,显得不是特别靠谱。
守护线程
Daemon(守护)线程是一种支持型线程,因为它主要被用作程序中后台调 度以及支持性工作。这意味着,当一个 Java 虚拟机中不存在非 Daemon 线程的 时候,Java 虚拟机将会退出。可以通过调用 Thread.setDaemon(true)将线程设置 为 Daemon 线程。我们一般用不上,比如垃圾回收线程就是 Daemon 线程。 +Daemon 线程被用作完成支持性工作,但是在 Java 虚拟机退出时 Daemon 线程中的 finally 块并不一定会执行。在构建 Daemon 线程时,不能依靠 finally 块中的内容来确保执行关闭或清理资源的逻辑。
synchronized 内置锁
多个线程同时访问一块共享资源,会导致不可预料的结果。关键字 synchronized 可以修饰方法或者以同步块的形式来进行使用,它主要确保多个线程在同一个时刻,只能有一个线程处于方法或者同步块中,它保证了线程对变量访问的可见性和排他性,使多个线程访问同一个变量的结果正确,它又称为内置锁机制。
对象锁和类锁
对象锁是用于对象实例方法,或者一个对象实例上的,类锁是用于类的静态 方法或者一个类的 class 对象上的。
/*用在同步块上*/
public void incCountBlock(){
synchronized (this){
count++;
}
}
/*用在同步块上,但是锁的是单独的对象实例*/
public void incCountObj(){
synchronized (obj){
count++;
}
}
volatile关键字
volatile 保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。但是 volatile 不能保证数据在多个线程下同时写时的线程安全,volatile 最适用的 场景:一个线程写,多个线程读。
等待和通知
等待方遵循如下原则。
1)获取对象的锁。
2)如果条件不满足,那么调用对象的wait()方法,被通知后仍要检查条件。
3)条件满足则执行对应的逻辑。
sychroizied(obj) {
while(!条件){
obj.wait();
}
//业务逻辑
}
通知方遵循如下原则。
1)获得对象的锁。
2)改变条件。
3)通知所有等待在对象上的线程。
sychroizied(obj) {
//改变条件
obj.notifyAll();
}
notify和notifyAll
尽可能用 notifyall(),谨慎使用 notify(),因为 notify()只会唤醒一个线程,我 们无法确保被唤醒的这个线程一定就是我们需要唤醒的线程。
方法和锁
调用 yield() 、sleep()、wait()、notify()等方法对锁有何影响?
yield() 、sleep()被调用后,都不会释放当前线程所持有的锁。 调用wait()方法后,会释放当前线程持有的锁,而且当前被唤醒后,会重新 去竞争锁,锁竞争到后才会执行wait 方法后面的代码。 调用 notify()系列方法后,对锁无影响,线程只有在 syn 同步代码执行完后才会自然而然的释放锁,所以 notify()系列方法一般都是 sync 同步代码的最后一行。
wait和notify
为什么wait 和 notify 方法要在同步块中调用?
原因
主要是因为 Java API 强制要求这样做,如果你不这么做,代码会抛出IllegalMonitorStateException 异常。其实真实原因是: 这个问题并不是说只在 Java 语言中会出现,而是会在所有的多线程环境下出现。