本文是对并发编程基础知识总结,不包含高级用法,旨在巩固基础,增强对知识点的记忆。所以可以看做是一个手稿,适合熟悉基本概念的同学阅读。
基础概念
1)CPU 核心数和线程数的关系
核心数:线程数=1:1 ;使用了超线程技术后=1:2
2)CPU 时间片轮转机制
又称 RR 调度,会导致上下文切换
3)什么是进程和线程?
进程:程序运行资源分配的最小单位,进程内部有多个线程,会共享这个进程的资源
线程:CPU 调度的最小单位,必须依赖进程而存在。
4)为什么使用并发编程?
好处:充分利用 cpu 的资源、加快用户响应的时间,程序模块化,异步化
问题:线程共享资源,存在冲突;容易导致死锁;启用太多的线程,有搞垮机器的可能
你必须知道的几个概念
1)同步和异步?
同步方法一旦调用开始,调用者必须等到方法返回后才可以继续后续的行为。
异步方法调用更像一个消息传递,一旦开始,方法调用就会立即返回,调用者可以立刻继续后续的操作。而异步方法通常会在另外一个线程中执行。
2)并发和并行?
严格来说:并行的多个任务是真的在同时执行,真正的并行只可能出现在多个 CPU 的系统中(如多核 CPU)。
对于并发而言,多个任务是在交替执行,系统不断的在任务之间切换,但对外部观察者而言看起来是在并行。另外,并发更关注在单位时间内可以处理事情的能力 。
3)临界区?
临界区用来表示一种公共资源或者共享数据,可以被多个线程使用,但是每一次都只能有一个线程使用它。一旦临界区资源被占用,其他线程只能等待。在并行程序中,临界区资源是保护的对象,如果保护不得当,出现的结果可能都不是线程需要的。
4)阻塞和非阻塞?
阻塞和非阻塞通常用来形容多线程的相互影响,比如一个线程占用了临界区资源,那么其他需要这个资源的线程都必须在临界区等待,即出现线程挂起现象,这种情况就是阻塞。如果占用资源的线程一直不释放资源,其他阻塞在临界区的线程会一直等待。
非阻塞意思与之相反。
5)死锁、活锁、饥饿?
死锁恐怕是并发编程中最糟糕的情况了,通常是因为两个线程互相占用对方需要的资源,而都不进行释放,导致彼此之间相互等待对方释放资源,产生了无限期等待的情况,如果没有外力介入,这种等待将一直存在。
Tips:如果想避免死锁,可以使用无锁函数,还可使用可重入锁,通过重入锁的中断或者限时等待都可以有效规避死锁的问题。
活锁:线程之间互相谦让,为对方让出资源(两个人一起用电梯,一个人想进来,一个人想出去,不停的为对方让位置)。出现这种情况,资源不停的在线程间跳动,导致没有一个线程可以同时拿到所有资源正常执行,这种情况就是活锁。
饥饿:可能是因为线程优先级低或者关键的资源一直被占用,导致自己一直得不到执行。饥饿现象没有死锁那么严重,可能在未来一段时间内解决。
6)并发级别
阻塞
(synchronized)
无饥饿
(对非公平锁允许高优先级线程插队,低优先会饥饿)
无障碍
(多个线程同时进入临界区,进行修改,如果出现问题则回滚重试;一种可行的实现是依赖一致性标记,操作之前读取保存这个标记,操作完成后检查标记是否被更改过。如果不一致则重试)
无锁
(要求:一个线程可以在有限步内完成操作。无限循环 CAS,由于重试次数过多某些运气差的线程会出现饥饿现象)
无等待
(要求:所有线程可以在有限步内完成操作。一种典型的实现就是 RCU[Read Copy Update],基本思想是对数据的读不加控制。但是在写数据时,先取得原始数据的副本,接着修改副本数据,这也是为什么读可以不加控制的原因,修改完成在合适的时机写回数据)
认识 Java 里的线程
1)线程的状态?
有五种:新建、就绪、阻塞、运行、死亡
当调用wait()、sleep()时进入阻塞状态,在 wait 状态的线程被notify()或notifyAll()时重新进入就绪态。当阻塞状态的线程被中断时会抛出InterruptedException异常。
2)启动线程的三种方式?
- 实现 runnable 接口
- 继承 Thread 类
- 实现 Callable 接口(有返回值)
3)如何让线程安全停止?
任务执行完成会自然终止或者出现未知异常会停止。
- 强制
JDK 提供了一些类强制停止的方法,可以近似理解为结束任务管理器中的进程。但由于这些设计并非良好,所以已不再建议使用:
stop(),resume(),suspend()已不建议使用,stop()会导致线程不会正确释放资源,suspend()容易导致死锁。
- 协作式停止
使用线程的interrupt()方法。调用一个线程的 interrupt()方法中断一个线程,并不是强行关闭这个线程,只是跟这个线程打个招呼,将线程的中断标志位置为 true,线程是否中断,由线程本身决定。
isInterrupted()判定当前线程是否处于中断状态。
方法里如果抛出 InterruptedException,线程的中断标志位会被复位成 false,如果确实是需要中断线程,要求我们自己在 catch 语句块里再次调用 interrupt()。
static 方法Thread.interrupted()判定当前线程是否处于中断状态,同时中断标志位改为 false,使用时需要特别注意。
一个设计良好的响应中断范式:
public class RunnableTask implements Runnable {
@Override
public void run() {
// 如果线程本身就是需要轮询执行的可以使用这种方式
while (!Thread.currentThread().isInterrupted()) {
System.out.println("RunnableTask working...");
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
Thread.currentThread().interrupt();
// 静态方法,是否中断状态,注意:此方法会重新将标记为 置为 false
// System.out.println(Thread.interrupted());
// Thread.currentThread().interrupt();
}
}
// 如果本身只执行一次,那么在需要终中断的时候判定标志位
// if (!Thread.currentThread().isInterrupted()) {
// System.out.println("RunnableTask working...");
// try {
// TimeUnit.SECONDS.sleep(1);
// } catch (InterruptedException e) {
// e.printStackTrace();
// Thread.currentThread().interrupt();
// }
// }
}
}
4)其他概念
run()和start():run 方法就是普通对象的普通方法,只有调用了start()后,Java 才会将线程对象和操作系统中实际的线程进行映射,再来执行 run 方法。
yield():高风亮节让出 cpu 的执行权,将线程从运行转到可运行状态,但是下个时间片,该线程依然有可能被再次选中运行。
线程的优先级
取值为 1~10,缺省为 5,但线程的优先级不可靠,不建议作为线程开发时候的手段;因为有可能在 Windows 下操作系统支持,但切换为 Linux 后则无法正常工作了。
守护线程
和主线程共死,finally 不能保证一定执行。
join():一般用于控制线程执行的顺序,可以理解为插队。thread.join();即代表thread线程将在当前线程任务之前执行。实现原理为不停检查 join 线程是否存活,如果存活则一直等待wait(0),如果结束则调用notifyAll()。一般用法是将某个线程传递进具体的任务run()中调用join()。需要注意的是:由于join()机制本身是依靠wait()、notify()实现的,所以使用时应避免直接使用等待通知,避免异常。
5)线程间的共享
synchronized 内置锁,可以锁对象也可以锁类
对象锁,锁的是类的对象实例。
类锁,锁的是每个类的的 Class 对象,每个类的的 Class 对象在一个虚拟机中只有一个,所以类锁也只有一个。
synchronized 使用时如果锁定的方法是 static 的,也是类锁。需要注意的是:在发生异常时会释放当前持有的锁。
- volatile
适合于只有一个线程写,多个线程读的场景,因为它只能确保可见性。
- ThreadLocal
线程变量。可以理解为是个 map,类型 Map<Thread, Object>
6)线程间的协作
使用等待通知机制,这里有一套标准范式:
等待方:
1.获取对象的锁; 2.循环里判断条件是否满足,不满足调用 wait 方法 3.条件满足执行业务逻辑
通知方:
1.获取对象的锁 2.改变条件 3.通知所有等待在对象的线程(注意:应当在临界区结束时进行通知,一般是代码块的最后)
notify 和 notifyAll 应该用谁?应该尽量使用 notifyAll,使用 notify 因为有可能发生信号丢失的的情况,因为它是随机选择一个持有相同锁的线程。
7)调用yield()、sleep()、wait()、notify()等方法对锁有何影响?
线程在执行yield()以后,持有的锁是不释放的。
sleep()方法被调用以后,持有的锁是不释放的。
调动方法之前,必须要持有锁。调用了wait()方法以后,锁就会被释放(由虚拟机自动执行),当wait()方法返回的时候(即收到通知继续执行),线程会重新持有锁。
调动方法之前,必须要持有锁,调用notify()、notifyAll()方法本身不会释放锁,一般会在退出synchronized区时自动释放。一般会写在退出区大括号的上一行,保证逻辑上的连续性。