[TOC]
0 前言
为什么需要学习并发编程?
- 大厂JD硬性要求,也是高级工程师必经之路,几乎所有的程序都需要并发和多线程
- 面试高频出现,书籍、网络博客内容水平参差不齐,知识点凌乱
- 众多框架的原理和基础,Spring 线程池、单例的应用;数据库的乐观锁思想;Log4J2 对阻塞队列的应用
本门课程的优点
- 系统:成体系不容易忘记,思维导图,为什么->演示代码->分析原理->得出结论
- 内容丰富:线程 8 大核心基础,java内存模型,死锁
- 分析面试题:答题思路,引申解答
- 分析本质:深入原理分析设计理念,interrupt与stop停止线程,wait必须在同步块中使用,JMM
- 学习方法:技术提高途径、技术前沿动态、业务中成长、自顶向下学习
- 通俗易懂:近朱者赤-happen-before,森然火灾-线上事故,夫妻迁让-死锁
- 逐步迭代:从0开始,逐渐优化,重视思路,分析错误代码到修复问题
- 案例演示丰富
- 习题检验:总结知识点,检验学习效果,知识卡防止走神
- 配套资料:思维导图,知识点文档,面试题总结
线程八大核心

1. 创建多线程(核心1)
查看Oracle Java官方文档 或 Thread 类注释,可知创建线程有两种方法,即实现Runable接口和继承Thread类。
/**
73 * There are two ways to create a new thread of execution. One is to declare a class to be a subclass of <code>Thread</code>
99 * The other way to create a thread is to declare a class that implements the <code>Runnable</code> interface.
**/
1. 实现Runable接口
public class RunnableStyle implements Runnable {
public static void main(String[] args) {
// 将将我们创建的 RunnableStyle 作为构造函数参数传入Thread
Thread t = new Thread(new RunnableStyle());
t.start();
}
@Override
public void run() {
System.out.println("实现Runnable接口创建线程");
}
}
2. 继承Thread类
public class ThreadStyle extends Thread {
public static void main(String[] args) {
ThreadStyle t = new ThreadStyle();
t.start();
}
@Override
public void run() {
System.out.println("继承Thread类创建线程");
}
}
两种创建方式的对比
方法1 实现 Runable 接口更好:
- 可扩展,java 只能单继承多实现,继承 Thread 类后就不能继承其他类,限制了可扩展性;而 Runnable 方式可以实现多个接口
- 节约资源,继承 Thread 类每次要新建一个任务,每次只能去新建一个线程,而新建一个线程开销是比较大的(具体在第8章),需要创建、执行和销毁;而 Runnable 方式可以利用线程池工具,可以避免创建线程、销毁线程带来的开销。线程创建需要开辟虚拟机栈、本地方法栈、程序计数器等线程私有的内存空间,线程销毁时需要回收这些资源,频繁创建销毁线程会浪费大量系统资源
- 解耦,实现 Runnable 接口解耦,一是具体的业务逻辑在
run()方法中,二是控制线程生命周期是 Thread 类,两个目的不一样,不建议写在一个类中,应该解耦。?
本质区别
方式1 实现 Runable 创建线程,查看下方 Thread.run()代码和注释可知,启动线程前,要将我们创建的 RunnableStyle 作为 Thread 构造函数的参数target,所以实际运行的是target.run(),即我们线程类 RunnableStyle 的run()方法
方式2 继承Thread类,会重写Thread.run()方法,启动线程后直接运行我们线程类 ThreadStyle 的run()方法
public class Thread implements Runnable {
private Runnable target;
// 构造函数,传入我们写的Runnable
public Thread(Runnable target) {...}
/**
* If this thread was constructed using a separate
* <code>Runnable</code> run object, then that
* <code>Runnable</code> object's <code>run</code> method is called;
* otherwise, this method does nothing and returns.
* <p>
* Subclasses of <code>Thread</code> should override this method.
*/
@Override
public void run() {
if (target != null) {
// 调用Runnable的run()方法
target.run();
}
}
}
思考题: 同时使用 Runnable 和 Thread 两种创建线程的方式会怎么样?
Runnable 方式中我们创建的 Runnable 实例 target 会作为构造函数参数传入到Thread类,然后被Thread.run()调用执行,而继承 Thread 方式会覆写Thread.run()方法,就使得 target 不会被调用执行,查看详细代码
面试题 1: 创建/实现线程有几种方式?
- 有两种方法,根据 Thread 类的注释(或 Java 官方文档),分别是实现 Runnable 接口和继承 Thread 类
- 准确的讲,本质都是一种方式,本质都是构造 Thread 类,调用
Thread.run()方法,只不过一种 Runnable 实现类作为 target 传入Thread,然后调用target.run()方法,另一种是直接重写 run() 方法(参考上面本质区别) - 分析优缺点,可扩展性、节约资源、解耦(参考上面两种创建方式的对比)
- 分析常见的 6 种典型错误观点,线程池、Callable等本质都是实现 Runnable 接口
6 种典型错误观点分析
- 线程池创建线程也算一种新建线程的方式 示例代码
ExecutorService 本质都是使用线程工厂创建线程,查看源码可知线程工厂都是 实现 Runnable 接口构造 Thread 类的方法创建线程
// @see java.util.concurrent.Executors.DefaultThreadFactory#newThread(java.lang.Runnable)
// 线程池的线程工厂,创建线程的方式如下,实现Runnable接口,构造Thread类
public Thread newThread(Runnable r) {
// 传入用户的Runnable实例,设置线程组,线程名称等
Thread t = new Thread(group, r,
namePrefix + threadNumber.getAndIncrement(),
0);
// ......
return t;
}
创建线程池也可以用户自定义线程工厂,从下方代码中也可以看出,用户自定义线程工厂线程工厂也是实现 Runnable 接口构造 Thread 类的方法创建线程
// 用户自定义线程工厂,见码出高效p239
public class UserThreadFactory implements ThreadFactory {
private final String namePrefix;
private final AtomicInteger nextId = new AtomicInteger(1);
UserThreadFactory(String whatFeatrueOfGroup) {
namePrefix = "UserThreadFactory's " + whatFeatrueOfGroup + "-Worker-";
}
@Override
public Thread newThread(Runnable task) {
String name = namePrefix + nextId.getAndIncrement();
// task是用户实现 Runnable 接口创建的,构造Thread类创建线程
Thread thread = new Thread(null, task, name, 0);
System.out.println(thread.getName());
return thread;
}
}
- 通过 Callable 和 FutureTask 创建线程,也算是一种新建线程的方式 示例代码
查看示例代码可知,Thread构造函数参数是 futureTask,Runnable 的实现类,查看下方FutureTask代码,可知FutureTask.run()调用了Callable.call()方法,并把返回值保存到FutureTask.outcome,本质还是实现 Runnable 接口。
public class FutureTask implements RunnableFuture {
private Object outcome; // 保存call()返回值
private Callable callable;
// 构造方法,出入用户创建的callable
public FutureTask(Callable<V> callable) {
if (callable == null)
throw new NullPointerException();
this.callable = callable;
this.state = NEW; // ensure visibility of callable
}
@Override
public void run() {
// .....
// callable是FutureTask构造函数传入的
Callable<V> c = callable;
if (c != null && state == NEW) {
V result;
boolean ran;
try {
// 在run()方法中调用用户定义的Callable.call()
result = c.call();
ran = true;
} catch (Throwable ex) {
result = null;
ran = false;
setException(ex);
}
if (ran)
set(result); // 将返回值保存到outcome
}
// ......
}
}

- 无返回值是实现 Runnable 接口,有返回值是实现 Callable 接口,所以 Callable 是新的创建线程的方式 示例代码
与上一个问题类似,本质还是要借助 FutureTask(FutureTask 实现了 Runnable 接口),构造 Thread 类创建线程,启动线程后会调用target.run(),即FutureTask.run(),其中会调用Callable.call()方法,所以不算是一种新的创建线程方式,本质还是实现 Runable 接口,不过是创建 FutureTask 实现 Runnable 接口的工作JDK帮我们做了。
- 定时器
- 匿名内部类 示例代码
运行示例代码,会生成AnonymousInnerClassStyle2.class反编译发现该类继承了 Thread 类
- lambda 表达式 示例代码
查看示例代码 ,代码中打印了 lambda 表达式实现的接口,创建线程本质还是实现 Runnable 接口,但并不完全等价于匿名内部类的方式,逻辑与下方代码类似。
虽然也是匿名内部类,不会生成内部类的 .class 文件,而会动态生成内部类LambdaStyle$$Lambda$1,lambda 表达式中的内容会被编译成静态方法LambdaStyle.lambda$main$0(),动态生成的内部类 Runnable 实例LambdaStyle$$Lambda$1直接调用静态方法LambdaStyle.lambda$main$0(),详细参考《Java8 实战》附录D和掘金小册《JVM 字节码从入门到精通》第9节
public class LambdaStyle {
private static void lambda$main$0() {
System.out.println("hello, lambda");
}
}
final class LambdaStyle$$Lambda$1 implements Runnable {
@Override
public void run() {
LambdaStyle.lambda$main$0();
}
}
面试题 2:实现 Runnable 接口和继承 Thread 类的哪种方式更好?
- 可扩展性,Java 不支持多继承,继承 Thread 类后就不能继承其他类,限制了可扩展性,而实现 Runnable 方式可以实现多个接口
- 代码架构角度,实现 Runnable 接口解耦,一是具体的业务逻辑在
run()方法中,二是控制线程生命周期是 Thread 类,两个目的不一样,不建议写在一个类中,应该解耦。 - 节约资源,继承 Thread 类,新建任务只能去 new 一个对象,但是资源损耗比较大,继承 Thread 类每次要新建一个任务,每次只能去新建一个线程,而新建一个线程开销是比较大的(具体在第8章),需要创建、执行和销毁;而 Runnable 方式可以利用线程池工具传入Runnable 实例 target,可以避免创建线程、销毁线程带来的开销。线程创建需要开辟虚拟机栈、本地方法栈、程序计数器等线程私有的内存空间,线程销毁时需要回收这些资源,频繁创建销毁线程会浪费大量系统资源
彩蛋:学习编程知识的优质路径
-
宏观
- 责任心,不要放过任何 Bug,找到原因并去解决,因为很多 Bug 都需要非常深入的知识才能解决,解决问题的能力比学很多的知识更重要
- 主动,永远不要觉得自己的时间多余,不断重构、优化、学习、总结
- 敢于承担,对于没碰过的技术难题,在一定调研后,敢于承担,让工作充满挑战,攻克难关的过程进步飞速
- 关心产品和业务,不仅要写好代码,更要在业务层面多思考
-
微观
- 系统学习,碎片化知识公众号文章最容易忘记和一叶障目,要看经典书籍的译本
- 官方文档,专家撰写不断迭代,最权威的,像线程实现方式百度结果有非常多的错误
- 分析源码,随着看源码和官方文档的次数增多,就会更熟悉更快,而baidu并不能达到这个效果
- 英文搜索,前面几个不能解决问题,再搜索 Google 和 StackOverflow,用英文更容易找到正确答案如Annoymouses Class
- 多实践,遇到新知识,多动手写Demo,并尝试用到项目里,三个阶段,看、写、生产环境
彩蛋:如何了解技术领域的最新动态
- 高质量固定途径,掘金、阮一峰博客
- 订阅技术论坛,InfoQ
- 公众号
彩蛋:如何在业务开发中成长
- 偏业务方向开发,了解业务核心模型架构,如电商交易、订单、结算等核心系统的设计
- 偏技术方向开发,通用性非常强,就业方向广,如中间件、RPC,APM
- 两个 25% 理论,在一个领域达到前 25% 比较容易,但前 5% 很难,如果能在两个领域做到前 25%,一旦把两个领域能结合起来,就能做到非常优秀的 5%,如小灰是编程+写作领域,liuyubo是编程+授课领域,雷军是编程+管理,两个领域 25% 非常不错的职业规划
2. 启动多线程(核心2)
查看示例代码,启动线程调用start()方法,然后会调用本地方法start0(),开辟新线程,而调用run()方法相当于Main线程调用方法,无法启动新线程
Thread 类的源代码分析
/* Java thread status for tools, initialized to indicate thread 'not yet started'
* 线程的状态标志,初始化为0来标志线程尚未start()
*/
private volatile int threadStatus = 0;
// synchronized保证线程安全,即同一个线程对象不能同时调用start()方法
public synchronized void start() {
/**
* 0 状态值对应线程的 NEW 状态,如果两次调用start()方法会抛出线程状态异常
* A zero status value corresponds to state "NEW".
*
*/
if (threadStatus != 0)
throw new IllegalThreadStateException();
// 添加到线程组
group.add(this);
boolean started = false;
try {
// 调用native方法start0
start0();
started = true;
} finally {
// ......
}
}
// native方法,开辟新线程,更改线程状态threadStatus,C++代码
private native void start0();
start0()方法在 Thread.c 中,具体逻辑在jvm.cpp,详细见 Java 线程源码解析之 start
面试题 3:一个线程能调用两次
start()方法吗?会发生什么情况?
- 一个线程只能调用一次
start()方法,否则会抛出IllegalThreadStateException,因为start()方法的卫语句会检查线程状态,线程状态不为 NEW 则抛出异常 -
start()是synchronized修饰的线程安全方法,不存在线程已启动但线程状态 threadStatus 还未修改的情况,所以也不用担心被调用两次 - 即使线程执行结束(TERMINATED)也不能再调用
start方法,只有线程状态为 NEW 时才可以调用 start。线程池复用的线程是不退出的,复用的是Runnable实例,而不是对同一个线程调用了多次start()方法,见第4章 - 为什么这么设计?
问题? 线程结束后能不能再调用start,那什么时候结束?
面试题 4:既然
start()还是会调用run()方法,为什么我们不直接调用run()方法呢?
- 因为调用
start()方法才会真的启动一个新线程,而调用run()只是简单的调用方法 -
start()方法会调用本地方法start0(),然后开辟新线程,代码可以在 OpenJDK 中查看(详细参考 第2章 启动多线程)
3. 停止线程(核心3)
一般情况下都是线程执行完毕之后停止,如果用户想要主动停止线程,可以使用Thread.interrupt来通知线程停止,Thread.interrupt并不能真正的中断线程,而是「通知线程应该停止了」,具体到底停止还是继续运行,应该由被通知的线程自己写代码处理。
具体来说,当对一个线程,调用 interrupt() 时:
- 如果线程处于正常活动状态,那么会将该线程的中断标志位设置为 true,仅此而已
- 如果线程处于被阻塞状态(例如处于sleep, wait, join 等状态),那么线程将立即退出被阻塞状态,并抛出一个InterruptedException异常。仅此而已。
- 被设置中断标志的线程将继续正常运行,不受影响,具体停止线程的逻辑需要自己写代码处理
停止正常活动状态线程
停止正常活动线程的代码逻辑是Main线程使用interrupt()方法发送中断请求(相当于修改了标志位),当任务线程接收到中断请求,然后使用Thread.interrupted()检测标志位,退出while循环,结束线程,代码范式如下:
Thread thread = new Thread(() -> {
// 检测中断标志位,如果收到中断请求,退出while循环。所有的业务逻辑应该都写在while循环中
while (!Thread.interrupted()) {
// do more work.
}
});
thread.start();
// 一段时间以后
thread.interrupt();
public class RightWayStopThreadWithoutSleep {
public static void main(String[] args) throws InterruptedException {
Runnable r = () -> {
// 当接收到中断信号,退出循环,任务结束
while (!Thread.interrupted()) {
System.out.println("线程正在运行..");
}
};
Thread t = new Thread(r);
t.start();
Thread.sleep(100L); // 等待线程启动完成
System.out.println("是否收到中断信号:" + t.isInterrupted());
/*
* 发送中断信号,改变中断标志位, 仅此而已
* 如果线程循环条件是while (true), t线程会继续执行下去
* 如果线程循环条件是while (!Thread.interrupted()), t线程会退出
*/
t.interrupt();
System.out.println("是否收到中断信号:" + t.isInterrupted());
}
}
停止被阻塞状态线程
代码逻辑任务线程处于被阻塞状态(例如处于·sleep, wait, join等状态),Main线程使用interrupt()方法发送中断通知改变中断标志位,当任务线程调用sleep等方法时,发现中断标志位被修改,Java虚拟机会先将该线程的中断标志位复位,然后立即退出被阻塞状态,并抛出一个InterruptedException异常
public class RightWayStopThreadWithSleepEveryLoop {
public static void main(String[] args) throws InterruptedException {
Runnable r = () -> {
int num = 0;
try {
// 每次循环都会sleep的,不需要!Thread.currentThread().isInterrupted()判断条件,
// 因为在抛出InterruptedException之前Java虚拟机会先将该线程的中断标志位复位,
// 即使调用!Thread.currentThread().isInterrupted()返回也是true
while (num <= 10000) {
if (num % 100 == 0) {
// 验证JVM是否将中断标志位复位,返回true,说明复位了,故加在while循环条件中无用
System.out.println(!Thread.currentThread().isInterrupted()); //
System.out.println(num + "是100的倍数");
}
num++;
// 这里会检测中断标志位,如果被修改则抛出异常退出while循环
Thread.sleep(10);
}
} catch (InterruptedException e) {
System.out.println("收到中断信号Interrupt, 抛出异常, 结束线程");
e.printStackTrace();
}
};
Thread thread = new Thread(r);
thread.start();
// 等待线程完全启动
Thread.sleep(5000);
// 发送中断通知,修改中断标志位
thread.interrupt();
}
}
通过上面的代码我们也可以清楚的知道,这也是在调用Thread.sleep()方法时需要处理InterruptedException异常的原因
不能停止的线程
运行下面的示例代码,可以发现与上一小节的运行结果不同,会一直进行循环,原因是try-catch没有包住while循环,线程在sleep阻塞状态时,收到interrupt信号,抛出异常,然后被catch住,无法退出while循环结束线程,所以代码会一直运行直到num <= 10000
public class CantInterrupt {
public static void main(String[] args) throws InterruptedException {
Runnable runnable = () -> {
int num = 0;
while (num <= 10000 && !Thread.currentThread().isInterrupted()) {
if (num % 100 == 0) {
System.out.println(num + "是100的倍数");
}
num++;
// 收到interrupt`信号,复原中断标志位,抛出异常,但是无法退出while循环,所以会继续运行
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
};
Thread thread = new Thread(runnable);
thread.start();
// 等待线程完全启动
Thread.sleep(5000);
// 发送中断通知
thread.interrupt();
}
}
Thread.interrupted()与Thread.currentThread().isInterrupted()的区别
查看源码易知,都是返回当前线程的中断标志位,Thread.interrupted()会复原标志位,Thread.currentThread().isInterrupted()不会
/**
* Tests whether the current thread has been interrupted. The
* <i>interrupted status</i> of the thread is cleared by this method.
* Thread.interrupted()方法检测当前线程是否已经中断,这个方法会将中断标志位interrupted status清除复位
*
* In other words, if this method were to be called twice in succession, the second call would return false
* 换句话说,如果该方法被调用两次,第二次会返回false
*/
public static boolean interrupted() {
return currentThread().isInterrupted(true);
}
/**
* Thread.currentThread().isInterrupted()返回线程的中断标志位,
* 与上面代码的区别是参数ClearInterrupted为false,即不清除标志位
*/
public boolean isInterrupted() {
return isInterrupted(false);
}
停止线程的最佳实践
由于Runnable.run()方法签名不允许抛出异常,所以只能catch住,所以需要传递中断。总之,无论如何,都不应屏蔽中断
为什么不扩大try-catch范围 ?
public class RightWayStopThreadInProd implements Runnable {
@Override
public void run() {
while (true && !Thread.currentThread().isInterrupted()) {
System.out.println("...");
try {
throwInMethod();
} catch (InterruptedException e) {
// 阻塞状态受到中断信号,jvm会复位中断标志位,
// 这里设置中断标志位为false,用于传递中断,使得while条件可以结束线程
Thread.currentThread().interrupt();
//保存日志、停止程序
System.out.println("保存日志");
e.printStackTrace();
}
}
}
// 业务方法
private void throwInMethod() throws InterruptedException {
Thread.sleep(2000);
}
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(new RightWayStopThreadInProd());
thread.start();
Thread.sleep(1000);
thread.interrupt();
}
}
上面代码依赖sleep检查中断标志位,如果没有调用sleep,应该怎么写?没有sleep更简单,直接while条件判断中断标志位即可
响应中断的方法列表
响应中断的意思是这这些方法的执行中,如果中断信号过来了,是可以感知到的。我们可以使用下面的方法让线程进入阻塞状态,为了使线程从阻塞状态恢复,就可以使用interrupt()方法中断线程
Object.wait()/wait(long)/wait(long, int)
Thread.sleep()/sleep(long)/sleep(long, int)
Thread.join()/join(long)/join(long, int)
java.util.concurrent.BlockingQueue.take()/put(E)
java.util.concurrent.locks.Lock.lockInterruptibly()
java.util.concurrent.CountDownLatch.await()
java.util.concurrent.CyclicBarrier.await()
java.util.concurrent.Exchanger.exchange(V)
java.nio.channels.InterruptibleChannel
java.nio.channels.Selector
为什么要使用
interrupt来停止线程,有什么好处?
被中断的线程有如何响应中断的权利,因为线程的某些代码可能是非常重要的,我们必须要等待线程处理完后,再由线程自己主动去中止,或者线程不想中止也是可以的,不应该鲁莽的使用stop,而应该使用interrupt方法发送中断信号,这样使得线程代码更加安全,数据的完整性也得到了保障。
错误的线程停止方法
:过期方法,悟空说会导致数据不完整,但是加synchronized可以解决此问题,具体原因不知stop()
:suspend()
:resume()
用volatile设置标记位:
彩蛋:如何分析 native 方法
- 查看
Thread.interrupt()源码,发现底层是调用native方法private native void interrupt0();
public void interrupt() {
// ...
synchronized (blockerLock) {
Interruptible b = blocker;
if (b != null) {
interrupt0(); // Just to set the interrupt flag
b.interrupt(this);
return;
}
}
interrupt0(); // 调用native方法interrupt0
}
- 进入Github查看OpenJDK代码库,点击<kbd>FindFile</kbd>搜索
Thread.c文件,JDK native 方法的源码都在同类名的 .c 文件中
20200107061222.png
- 找到native方法对应的方法名
JVM_IsInterrupted,在本仓库中搜索方法名JVM_IsInterrupted,发现在jvm.cpp中定义了该方法
// Thread.c文件中可以知道Native方法interrupt0对应的本地方法是JVM_Interrupt
static JNINativeMethod methods[] = {
{"interrupt0", "()V", (void *)&JVM_Interrupt},
};
{"isInterrupted", "(Z)Z", (void *)&JVM_IsInterrupted},
JVM_ENTRY(void, JVM_Interrupt(JNIEnv* env, jobject jthread))
JVMWrapper("JVM_Interrupt"); // 绑定对应方法
// Ensure that the C++ Thread and OSThread structures aren't freed before we operate
oop java_thread = JNIHandles::resolve_non_null(jthread);
MutexLockerEx ml(thread->threadObj() == java_thread ? NULL : Threads_lock);
// We need to re-resolve the java_thread, since a GC might have happened during the
// acquire of the lock
JavaThread* thr = java_lang_Thread::thread(JNIHandles::resolve_non_null(jthread));
if (thr != NULL) {
Thread::interrupt(thr);
}
- 上面代码调用了
Thread::interrupt(thr),我们查看thread.cpp源码,找到该方法
void Thread::interrupt(Thread* thread) {
trace("interrupt", thread);
debug_only(check_for_dangling_thread_pointer(thread);)
os::interrupt(thread);
}
- 找到os::interrupt(thread)源码在os_windows.cpp中,然后分析源码
void os::interrupt(Thread* thread) {
assert(!thread->is_Java_thread() || Thread::current() == thread || Threads_lock->owned_by_self(),
"possibility of dangling Thread pointer");
OSThread* osthread = thread->osthread();
osthread->set_interrupted(true);
// More than one thread can get here with the same value of osthread,
// resulting in multiple notifications. We do, however, want the store
// to interrupted() to be visible to other threads before we post
// the interrupt event.
OrderAccess::release();
SetEvent(osthread->interrupt_event());
// For JSR166: unpark after setting status
if (thread->is_Java_thread())
((JavaThread*)thread)->parker()->unpark();
ParkEvent * ev = thread->_ParkEvent ;
if (ev != NULL) ev->unpark() ;
}
面试题 5:如何停止一个线程?
使用interrupt发送中断通知,
面试题 6:如何处理不可中断的阻塞?
4. 线程状态(核心4)
Thread的内部类State源码如下
public enum State {
// 更多线程状态的描述信息见源码注释
NEW,
RUNNABLE,
BLOCKED,
WAITING,
TIMED_WAITING,
TERMINATED;
}
线程一共有 6 种状态,Java线程在运行的生命周期中会处于下表所示的6种不同的状态,同一时刻,线程只能处于其中的一个状态
| 状态名称 | 说明 |
|---|---|
| NEW | 新建状态,线程被创建,但还没有调用start()方法 |
| RUNNABLE | (可)运行状态,Java线程将操作系统中的就绪Ready和运行Running两种状态合称为“可运行Runnable状态”,此状态的线程可能正在执行,也有可能正在等待CPU为它分配执行时间 |
| BLOCKED | 阻塞状态,表示线程在等待着一个排他锁, |
| WAITING | 等待状态,表示当前线程需要等待其他线程显式唤醒或中断,这种状态的线程不会被CPU分配执行时间。wait(),join()等方法会让线程进入无限期的等待状态 |
| TIME_WAITING | 计时等待状态,表示当前线程需要等待其他线程唤醒或中断,但是需要设置最长等待时间,等待超时会进入RUNNABLE状态,这种状态的线程也不会被CPU分配执行时间 |
| TERMINATED | 终止状态,表示当前线程已经执行完毕 |
线程 6 种状态的转换图如下所示,可以知道,
-
start0()会将线程状态从 NEW 修改到 RUNNABLE,Debug 观察this.getState()可知 - NEW、RUNNABLE、TERMINATED三种状态只能从前往后,不可逆,
- BLOCKED、WAITING、TIME_WAITING三种状态都可以与RUNNABLE相互转换。
当线程状态到达TERMINATED,如果还想执行任务,需要重新创建线程,复用Runnable实现类即可


阻塞状态
一般习惯而言,把BLOCKED(被阻塞),WAITING(等待),TIME_WAITING(计时等待)都称为阻塞状态
面试题 7:线程的生命周期是什么,线程有哪几种状态?
根据上面的线程生命周期图进行描述,线程有 6 种状态,转换关系和转换条件。
5. 线程的方法(核心5)
Object.wait() 释放调用对象的锁,进入WAITING状态
Object.notify() 唤醒同一对象一个WAITING/TIMED_WAITING状态的线程,用户无法指定具体唤醒的线程
Object.notifyAll() 唤醒同一对象所有WAITING/TIMED_WAITING状态的线程,
Thread.sleep() 进入WAITING状态,不释放锁,因为不释放锁,所以sleep都是有参方法,需要设置时间,否则会持有锁永久等待
5.1 wait/notify 实现线程通信
线程 t1 调用了object.wait()进入等待状态,线程 t2 调用object.notify()/notifyAll()唤醒 t1。
需要注意的是object.notify()/notifyAll()唤醒的是调用了object.wait()的线程,需要保证是同一个对象,并且先wait后notify。
前提: 由同一个lock对象调用wait、notify方法。
- 当线程A执行wait方法时,该线程会被挂起
- 当线程B执行notify方法时,会唤醒一个被挂起的线程A
面试题:lock对象、线程A和线程B三者是一种什么关系?
根据上面的结论,可以想象一个场景:
- lock对象维护了一个等待队列list
- 线程A中执行lock的wait方法,把线程A保存到list中
- 线程B中执行lock的notify方法,从等待队列中取出线程A继续执行
public class Wait {
public static Object object = new Object();
public static void main(String[] args) throws InterruptedException {
Runnable r = () -> {
// 获取object的monitor锁
synchronized (object) {
System.out.println("线程" + Thread.currentThread().getName() +"开始执行了");
try {
// 调用wait,释放object的monitor锁
// wait方法必须在synchronized中调用
object.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("线程" + Thread.currentThread().getName() + "获取到了锁");
}
};
Runnable r2 = () -> {
// 线程1释放锁后,进入同步块
synchronized (object) {
// 唤醒线程1,执行完毕后,线程1开始执行
object.notify();
System.out.println("线程" + Thread.currentThread().getName() + "调用了notify");
}
};
Thread t1 = new Thread(r, "t1");
Thread t2 = new Thread(r2, "t2");
t1.start();
// 等待线程1启动, 这样才能保证先wait后notify
Thread.sleep(200);
t2.start();
}
}
wait()、notify()、notifyAll()方法需要在synchronized块中调用,即必须获取对象Monitor锁,否则会抛出IllegalMonitorStateException异常
// wait方法必须在synchronized块中调用,否则会报异常IllegalMonitorStateException
public class WaitException {
public static Object object = new Object();
public static void main(String[] args) {
Thread t = new Thread(() -> {
try {
object.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("...");
});
// 启动线程,会抛出IllegalMonitorStateException
t.start();
}
}
面试题 6:如何处理不可中断的阻塞?
5.2 生产者消费者模型
查看实例代码可知:
生产者消费者模型有三个组成部分:仓库、生产者、消费者
仓库有一个属性容量
maxsize,两个功能生产put和消费takeput和take必须是线程安全的,防止多个生产者生产产品数量超出仓库maxsize生产者
put时当仓库满了进入等待状态(调用wait),不再生产,等待消费者消费并唤醒自己;消费者take时仓库空了进入等待状态,不再消费,等待生产者生产后并唤醒自己
面试题 7:两个线程交替打印 0-100 的奇偶数,即 A 线程只打印奇数,B 线程只打印偶数
思路1:synchronized关键字,缺点是奇数线程释放锁后并不一定是偶数线程拿到锁,会多次进入无用循环,性能较差
public class PrintOddEvenSync {
private static Object lock = new Object();
private static int count = 0;
public static void main(String[] args) {
new Thread(() -> {
while (count < 100) {
synchronized (lock) {
if((count & 1) == 0) {
System.out.println(Thread.currentThread().getName() + ": " + count);
count++;
}
}
}
}, "偶数Even").start();
// 奇数线程
new Thread(() -> {
while (count < 100) {
synchronized (lock) {
if((count & 1) == 1) {
System.out.println(Thread.currentThread().getName() + ": " + count);
count++;
}
}
}
}, "奇数Odd").start();
}
}
思路2:wait/notify,线程A打印偶数数字之后,唤醒另一个线程,自己进入等待状态;线程B打印奇数数字之后,唤醒另一个线程,自己进入等待状态。
A线程打印完后唤醒了其他线程,还未进入状态,此时CPU切换到了线程B,打印数字,唤醒其他线程,但是此时A还没有进入WAITING状态,就会导致永久等待。所以需要synchronized保证同一时刻只有一个线程在打印,当然,wait/notify也只能在同步方法中调用
public class PrintOddEvenWait {
private static Object lock = new Object();
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
Runnable r = () -> {
while (count < 100) {
synchronized (lock) {
System.out.println(Thread.currentThread().getName() + ": " + count);
count++;
// 打印之后,唤醒其他线程
lock.notify();
if(count < 100) {
try {
// 进入等待状态
lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
};
new Thread(r, "偶数").start();
// 使用sleep保证偶数线程先启动,或者使用CountDownLatch
Thread.sleep(100);
new Thread(r, "奇数").start();
}
}
面试题 8:手写生产者消费者设计模式?
见上面 生产者消费者模型,查看示例代码
面试题 9:wait和sleep有什么区别,为什么 wait 需要在同步代码块中使用,而 sleep 不需要?
- 区别见 wait 和 sleep 的区别
- wait释放锁的前提是获取了对象的独占锁Monitor,调用wait()之后,当前线程又立即释放掉锁,线程随后进入WAIT_SET(等待池)中。正如wait方法的注释所说:This method should only be called by a thread that is the owner of this object's monitor
- wait 在执行之后需要其他线程去 notify 唤醒,但是 wait 不一定能保证在 notify 之前执行(线程切换执行),如果 notify 先执行,wait 后执行就不能释放锁,可能会导致永久等待或死锁,所以在 synchronized 同步代码块中使用是为了保证 wait/notify的先后顺序
面试题 10:为什么线程通信方法 wait/notify/notifyAll 被定义在Object中,而sleep定义在Thread类中?
wait/notify/notifyAll 属于锁操作,而锁状态标志保存在对象头中Mark Word,所以应该定义在Object中。
sleep 是线程操作,所以定义在 Thread 类中。
面试题 11:wait 方法是属于 Object 的,那调用 Thread.wait 会怎么样?
面试题 12:如何选择 notify 和 notifyAll?
唤醒一个线程还是唤醒全部线程,notify 无法指定唤醒的线程
面试题 13:notifyAll 会唤醒所有线程,但是只有一个线程能获取到锁,那其他线程怎么办?
其他线程会进入 BLOCKED 阻塞状态,这类似于起初多个线程获取锁,获取不到的线程会进入 BLOCKED 状态等待锁释放
面试题 13:可以用suspend和resume来阻塞线程吗?
这两个方法已经过时了,推荐使用wait/和notify来阻塞唤醒线程
sleep 方法
作用: 我只想让线程在预期时间执行,其他时候不要占用 CPU 资源;例如定时检查等
下面演示了sleep不释放锁
public class SleepDontReleaseLock implements Runnable {
private static final Lock lock = new ReentrantLock();
public static void main(String[] args) {
SleepDontReleaseLock sleepDontReleaseLock = new SleepDontReleaseLock();
new Thread(sleepDontReleaseLock).start();
new Thread(sleepDontReleaseLock).start();
}
@Override
public void run() {
lock.lock();
System.out.println("线程" + Thread.currentThread().getName() + "获取到了锁");
try {
Thread.sleep(5000);
System.out.println("线程" + Thread.currentThread().getName() + "已经苏醒");
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
// 一定要解锁
lock.unlock();
}
}
}
sleep 的优雅写法
// 休眠3小时25分1秒,休眠时间小于0直接忽略,而sleep会抛出异常
TimeUnit.HOURS.sleep(3);
TimeUnit.MINUTES.sleep(25);
TimeUnit.SECONDS.sleep(1);
wait 和 sleep 的区别
- 相同
- wait 和 sleep 都可以使线程进入(广义)阻塞状态
- wait 和 sleep 都是可中断方法,被中断后会抛出InterruptException
- 不同
- wait 是 Object 的方法,而 sleep 是 Thread 特有的方法
- wait 方法的调用必须在同步方法中进行,而 sleep 不需要
- 线程在同步方法中执行 wait 时会释放 monitor 锁,而 sleep 并不会释放 monitor 锁
- sleep 方法短暂休眠后会主动退出阻塞,而 wait(没有指定时间)则需要等待其他线程中断或唤醒
join 方法
作用: 因为新的线程加入了,我们需要等待他执行完成,如果线程 M 中执行了t1.join()方法,表示当前线程 M 等待线程 t1 执行完毕后才开始执行线程 M,查看示例代码
注意: Main 线程中调用子线程的join方法,表示 Main 线程需要等待子线程执行完毕,Main 线程会进入WAITING状态,而非子线程,这点与sleep,wait不同,验证方法见示例代码。有些资料说join会进入BLOCKED状态是错误的
因为进入WAITING状态的是Main线程,所以中断WAITING状态需要在子线程中调用main.interrupt(),具体见示例代码
原理: 查看下面Thread类的源码,可知join()方法底层调用的是wait(),由于每个线程执行完后都会唤醒等待在该线程对象上的其他线程(源码在 Thread.cpp),所以子线程执行完后会唤醒 Main 线程。
// Thread类join()方法的源码
public final void join() throws InterruptedException {
join(0);
}
// 同步方法,t1.join()表示获得了t1对象的锁,
public final synchronized void join(long millis) throws InterruptedException {
long base = System.currentTimeMillis();
long now = 0;
if (millis < 0) {
throw new IllegalArgumentException("timeout value is negative");
}
if (millis == 0) {
while (isAlive()) {
// 参数为0表示进入WAITING状态,直到其他线程唤醒
// 调用者为线程对象a,a线程执行完后会唤醒等待在a上的其他线程
wait(0);
}
} else {
while (isAlive()) {
long delay = millis - now;
if (delay <= 0) {
break;
}
// join参数不为0,进入TIMED-WAITING状态表示等待一段时间或其他线程唤醒
wait(delay);
now = System.currentTimeMillis() - base;
}
}
}
// Thread.cpp源码,可知线程执行完毕会唤醒其他线程
static void ensure_join(JavaThread* thread) {
java_lang_Thread::set_thread_status(threadObj(), java_lang_Thread::TERMINATED);
// to complete once we've done the notify_all below
java_lang_Thread::set_thread(threadObj(), NULL);
lock.notify_all(thread); // 线程执行完毕,唤醒等待在thread对象上的其他线程
thread->clear_pending_exception();
}
通过上面的源码,我们可以知道,join 底层原理就是 wait(),所以我们可以自己实现以下join方法
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(new Runnable() {
@Override
public void run() {
// 子线程执行完毕,会自动调用notifyAll唤醒其他线程,源码Thread.cpp中
System.out.println("子线程执行完毕,唤醒主线程");
}
});
t.start();
System.out.println("等待子线程运行完毕");
// 下面三行代码与 t.join() 等价,获取线程t的Monitor锁,调用wait方法
synchronized (t) {
t.wait();
}
System.out.println("所有子线程执行完毕,开始执行Main线程");
}
CountDownLatch 与 CylicBarrier
作用于join 类似且更加强大,参考《java并发编程的艺术》
面试题 14:在 join 期间,线程会处于那种状态?(wait/notify,CountDownLatch都可以引到这个问题上来)
-
join期间,主线程处于WAITING状态,可以通过 debug 或mainThread.getState验证 - 因为
join底层是调用wait()方法,所以会处于WAITING状态 -
wait()方法获取的Monitor锁是线程对象的锁,子线程执行完会唤醒主线程
yield 方法
作用: 释放我的 CPU 时间片,线程状态依然是RUNNABLE。
一般开发中不会使用yield,但是JUC中AQS,ConcurrentHashMap,FutuerTask等都会使用到yield方法
sleep 会让出调度权,而yield虽然让出了调度权,但也随时可能被调度
其他Thread 方法
| Thread.currentThread() | 获取正在执行的线程对象 |
|---|---|
| getState() | 获取线程状态 |
| getName() | 获取线程名称 |
| interrupt() | 发送中断通知 |
| isInterrupted() | 获取中断标志位 |
| public Thread(Runnable target, String threadName) | 构造方法,可以设置线程名称 |
6. 线程的属性
| 线程属性 | 说明 |
|---|---|
| ID | 每个线程都有自己的ID,用于标识不同的线程,不允许被修改 |
| 名称 Name | 在开发过程中更容易区分不同线程,方便调试、定位问题 |
| 守护线程 isDaemon | true表示是守护线程,false表示是用户线程 |
| 优先级 Priority | 告诉线程调度器,用户哪个线程多运行,哪个少运行 |
线程ID
查看 Thread 源码可知,线程ID从1开始,不允许被修改。
第一个是Main线程,因为 Main 是入口方法,同时还要启动若干个线程,如Finalizer线程用来执行对象的finalize()方法,Reference Handler线程用于处理GC相关
// 线程ID
private static long threadSeqNumber;
// 生成线程ID
private static synchronized long nextThreadID() {
// 先++后return,所以第一个线程Main线程ID为1
return ++threadSeqNumber;
}
线程名称
查看 Thread 源码可知,若未指定线程名称,则默认使用 Thread+数字 作为线程名称
// 线程的构造方法,若未指定线程名称,则默认使用 Thread+X 作为线程名称
public Thread(Runnable target) {
init(null, target, "Thread-" + nextThreadNum(), 0);
}
守护线程
作用: 为用户线程提供服务的线程称为守护线程,JVM 中没有了非Daemon线程,JVM需要退出,JVM中的所有Deamon线程都需要立即终止
特性:
- 线程类型默认继承自父线程
- 除了Main线程,被JVM启动都是守护线程,用户启动的当然都是用户线程
- 守护线程不影响JVM的退出,用户线程执行完后,JVM就会退出。
线程优先级
线程优先级共有 10 个级别,默认为 5。但是程序设计不应该依赖于优先级,因为不同的操作系统对优先级的处理不一样,比如 Windows 中线程只有7个优先级,Linux 中会忽略线程优先级;
面试题 15:如何利用线程优先级帮助程序运行,有哪些禁忌?
因为不同操作系统对优先级的处理不同,不一定能生效,比如Win有7个级别,而Linux会忽略线程优先级
7 线程的异常
主线程可以轻松发现异常,而子线程发生异常却很难发现
子线程异常无法用传统方法捕获
如何全局处理异常,为什么要全局处理,不处理可以吗?
run 方法是否可以抛出异常?如果抛出异常,线程状态会怎么样?
线程中如何处理某个未处理异常?
8 线程安全
什么是线程安全?
当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象就是线程安全的 ---- Brin Goetz
9 Java 内存模型JMM
彩蛋:自顶向下的好处
先讲适用场景,再讲怎么用,最后讲原理。问题兴趣驱动,与传统的教育方式相反
直观的理解,感性的认识,有助于加深理解,最后带着好奇心去分析源码
《计算机网络 自顶向下方法》
为什么需要JMM(Java Memory Model)?
因为不同的 CPU 平台的机器指令千差万别,无法保证Java 代码到 CPU 指令翻译的准确无误,所以需要JMM来统一规范,让多线程运行的结果可预期
并发编程有三个问题:
- 缓存导致的可见性问题 - happen-before规则,volatile也能禁用CPU本地缓存
- 编译优化带来的重排序问题 - volatile语义增强禁止重排序
- 线程切换带来的原子性问题 - 互斥锁来禁止线程切换
所以为了解决上述问题,JMM 分为三个部分:重排序、可见性、原子性
本章节详细内容参考《Java并发编程的艺术》第3章
9.1 重排序
重排序带来的问题
先来看一段代码,由于 CPU 执行多线程时不断切换,有可能得到 4 中结果
- x=1,y=0 t2先执行,t1再执行
- x=0, y=1 t1先执行,t2再执行
- x=1, y=1 t1给a赋值完后 CPU 切换到 t2执行
- x=0, y=0 由于内存重排序,t1对a进行了赋值
a=1,但没有将该数据刷新到主存,导致t2执行y=a时从主存拿到的a==0,所以最终y=0;同理,t2 执行的b=1也没有刷新到主存,这就是重排序带来的问题。
public class OutOfOrderDemo {
private static int a = 0, b = 0;
private static int x = 0, y = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
a = 1;
x = b;
});
Thread t2 = new Thread(() -> {
b = 1;
y = a;
});
// 启动线程,交换线程的启动顺序,会得到不同的x,y
t1.start();
t2.start();
// 等待两个线程执行完毕
t1.join();
t2.join();
System.out.println("x = " + x + ", y = " + y);
}
}

[图片上传失败...(image-8d82da-1583382844738)]
上图处理器A和处理器B可以同时把共享变量a=1,b=1写入自己的缓冲区(A1,B1),然后从主存中读取另一个共享变量b=0,a=0(A2,B2),最后才把自己写缓冲区保存的脏数据x=0,y=0刷新到了主存中(A3,B3),虽然处理器A的执行顺序是A1->A2,但内存操作实际发生的顺序是A2->A1,此时,处理器A的内存操作就被重排序了。(详见Java并发编程的艺术P25)
为了演示x=0, y=0的情况,可以查看示例代码,需要多次运行,运行结果如下图所示

重排序分为三种情况:
- 编译器优化:包括JVM,JIT编译器
- CPU 指令重排:
- 内存重排序

重排序的好处

如上图所示,CPU 对代码进行了重排序,使得原先 9 条指令优化为了 7 条指令,提高了 CPU 的处理速度。其中 Load 表示从内存读取到CPU,Set 表示赋值,Store 表示存储到内存
9.2 可见性
因为CPU 有多级缓存,导致读的数据会过期。
高速缓存的容量比主内存小,但是速度仅次于寄存器,所以CPU和主内存之间就多了Cache层
线程间对于共享变量的可见性问题不是直接由多核引起的,而是多级缓存引起的

由上图可知,CPU 有多级缓存,JMM将共享的内存L3 Cache、RAM抽象为主存,将核心独有的内存registers(寄存器)、L1 chache、L2 cache抽象为本地内存(工作内存)
为什么需要多级缓存?
为了提高CPU的执行速度,因为CPU的速度远远高于内存,所以需要将内存中的数据提前读取到缓存中。
比如我们找一本书的过程,书桌上有常用的书,数量少,找书速度快,如果书桌上找不到,那么我们就去校图书馆找,数量较大速度一般,如果还找不到,就去市图书馆去找,数量极大速度最慢。书桌就是一级缓存,校图书馆就是二级缓存,市图书馆是内存,这三者容量依次升高,查找速度依次降低。虽然一级缓存速度最高,但也不能指望提高一级缓存的大小来提高缓存读取速度,因为缓存越大,查找速度越慢。
另外将内存中的数据读到内存中,但是CPU需要的数据不一定在缓存中,这里涉及缓存命中(此处添加缓存命中文章的链接)的知识
主存与本地内存的关系
JMM 有以下规定:
- 所有变量都存储在主存中,同时每个线程也有自己的本地内存,工作内存中的变量内容是主存中的拷贝
- 线程不能直接读写主存中的变量,只能操作本地内存中的变量,然后再同步到主存中
- 主存是多个线程共享的,但线程间不共享本地内存,如果线程间需要通信,必须借助主存中专来完成
Happen-Before (先行发生)原则
如果说操作A Happen-Before 于操作B,其实就是说操作B之前,操作A的影响对于操作B可见
Happen-before 的概念来阐述操作之间的内存可见性,happen-before仅要求第一个操作执行结果对第二个操作可见,且前一个操作实际执行时间排在第二个操作之前(the first is visible to and ordered before the second)
在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happen-before关系,详细见《并发编程艺术》p26,《深入浅出JVM》p376,一共有 9 个规则,其中最重要的是前 4 条规则
- 单线程原则:一个线程中的每个操作,对于该线程中的任意后续动作可见,也就是说都在本地内存运行,不存在可见性问题,又叫程序顺序原则
-
Monitor锁原则(synchronized和Lock):对一个锁的解锁,对于后续其他线程同一个锁的加锁可见,这里的“后续”指的是时间上的先后顺序,又叫管程锁定原则, -
volatile变量原则:对于一个volatile变量的写,对于后续其他线程对volatile变量的读可见,也就是说volatile变量的写会直接刷新到主存,这里的“后续”指的是时间上的先后顺序 - 传递性原则:如果A happen-before B,B happen-before C,那么A happen-before C,最常用到的一个原则
-
start()原则:如果线程A执行ThreadB.start()启动线程B,那么A线程ThreadB.start()及之前的操作对于线程B的所有操作可见 -
线程终止原则:线程中的所有操作,对于此线程的终止检测可见,我们可以通过
Thread.join()方法结束,Thread.isAlive()的返回值等手段,检测到线程终止运行 -
join()原则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的所有操作,对于线程A可见,实际开发中,我们也是用join()方法来获取线程B中的执行结果,这一条规则其实是线程终止原则的细化部分 -
线程中断规则:对线程A 调用
ThreadB.interrupt()方法,对于线程B检测中断isInterrupted可见 -
对象终结原则:一个对象的初始化完成,对于该对象的
finalize()方法可见

上图中由程序顺序原则可知,1 happen-before 2,3 happen-before 4,由volatile 变量原则 可知,2 happen-before 3,再结合传递性原则,可知 1 happen-before 4
另外还有一个重要的并发工具类原则:
- 线程安全的容器,如
CurrenthashMap的put操作,对于后续get操作可见 -
CountDownLatch的countDown()操作,对于后续await()可见 -
Semaphore的release()释放许可证操作,对于后续acquire()获取许可证操作可见 -
CyclicBarrier的最后一个线程到达屏障时,对于所有被拦截的线程await()可见 -
Future的call()操作执行结果,对于后续get()操作可见,详细见示例 - 线程池
面试题: 对于一个锁的unlock操作,对于后续的lock操作可见。因为如果不可见,其他线程就没法获取锁了。那对于一个锁的lock操作,对于后续的unlock肯定也是可见的,那么这个由什么保证呢?
对于一个锁的unlock操作,对于后续的lock操作可见,隐含条件是对其他线程后续的lock操作可见。而对于一个锁的lock操作和unlock操作,肯定是在同一个线程里,可以用单线程原则解释
面试题:volatile 变量的写 happen-before volatile 变量的读是怎么保证的?
插入内存屏障,每个volatile写操作的前面都会插入StoreStore屏障,后面都会插入一个StoreLoad屏障,如下图所示
[图片上传失败...(image-381312-1583382844738)]
StoreStore屏障保证在volatile写之前的普通写操作,已经对任意处理器可见了。因为StoreStore屏障把上面的所有普通写刷新到了内存
详细见(详细见《Java并发编程的艺术》3.4.4)
volatile 关键字
volatile是一种同步机制,比synchronized或者Lock相关类更轻量,因为使用volatile不会发生上下文切换等开销很大的行为。
需要注意volatile做不到synchronized那样的原子保护
不适应场景:i++
不是一个原子操作,volatile无法保证原子性,导致出错
使用场景1:boolean flag
如果一个共享变量自始至终只被各个线程赋值,没有其他操作,则可以使用volatile来替代synchronized和原子变量,因为赋值本身就是原子操作,而volatile又保证了可见性,所以线程安全
使用场景2:作为刷新之前变量的触发器
volatile能保证之前的操作全部刷新到主存
下面的代码,变量 v 的作用就是触发器,当v=true时,前面的代码(x=42)的执行结果已经刷新到了主存
面试题:线程A执行完
writer()后,线程B执行reader(),下面代码注释部分x会是多少呢?为什么
class VolatileExample {
int x = 0;
volatile boolean v = false;
public void writer() {
x = 42; // 1
v = true; // 2
}
public void reader() {
if (v == true) { // 3
// 这里x会是多少呢?
int i = x; // 4
}
}
}
根据happen-before单线程原则,1 happen-before 2,3 happen-before 4;volatile变量原则,2 happen-before 3;再结合传递性原则,1 happen-before 4,所以得到i=42

在旧的内存模型中,当1和2之间没有数据依赖关系时,1和2之间就可能被重排序。其结果就是线程B执行到4时,不一定能看到线程A对共享变量x的修改,x可能为0。
JSR133之前对下面代码重排序不进行限制,JSR133增强了volatile的内存语义,严格限制编译器和处理器对volatile变量与普通变量的重排序,所以x=42。(详细见《Java并发编程的艺术》3.4.5)
x=45; // 1
v=true; // 2
volatile的两点作用
可见性:读一个volatile变量之前,需要先使相应的本地缓存失效,这样就必须到主内存读取最新值,写一个volatile属性会立即刷入到主内存
禁止指令重排序:解决单例双重锁乱序问题
volatile小结
volatile属性的读写操作都是无锁的,它不能替代synchronized,因为它没有提供原子性和互斥性。因为无锁,不需要花费时间在获取锁和释放锁,所以说volatile是低成本的
volatile只能作用于属性,我们用volatile修饰属性,这样能禁止重排序
volatile提供了可见性,任何一个线程对其的修改对其他线程立即可见。volatile属性不会使用本地缓存,始终从主存中读取和写入
volatile 提供了Happen-before保证,对volatile变量v的写入happen-before所有其他线程后续对v的读取
volatile 可以使得 long 和double 变量的赋值是原子操作
保证可见性的几种方法
volatile、synchronized、Lock、并发集合、Thread.join()、Thread.start()
Happen-before
面试题:有一个共享变量 abc,在一个线程里设置了 abc 的值 abc=3,你思考一下,有哪些办法可以让其他线程能够看到abc==3?
见极客时间
synchronized
synchronized不仅保证了原子性,还保证了可见性
9.3 原子性
一系列的操作,要么全部执行成功,要么全部不执行,不会出现执行一般的情况
Java中的原子操作
基本类型(int,byte,boolean,short,char,float)的赋值操作,除了long和double,i++不是原子操作
所有引用的赋值操作,不管是32位还是64位机器
java.concurrent.Atomic.*包中所有类的原子操作
long和double的原子性
有可能出现线程安全问题,32位机器上对64位的long/double类型变量进行读写操作,可能出现线程安全问题。Java语言规范鼓励但不强求JVM对64位的long和double类型变量的写操作具有原子性,所以存在不是原子操作的可能
不需要加volatile,JSR对于商用的JVM,强烈建议将load, store, read, write四个操作实现为原子操作,而且目前各平台下的商用JVM都将其实现为了原子操作。因此实际编程中不需要把long,double类型修饰为volatile变量。
详细见《深入JVM》
全同步的HashMap的线程安全问题,待补充
原子操作的实现原理
见《Java并发编程艺术》2.3原子操作的实现原理。
处理器使用一下两种方法实现原子操作
1.使用总线LOCK#信号保证原子性
2.通过缓存锁定保证原子性
Java 使用CAS实现原子操作
Atomic的原理就是CAS,CAS操作会带来三个问题:1. ABA 2. 循环时间长开销大 3. 只能保证一个共享变量的原子操作
面试题:volatile修饰的变量 i,能保证
i++操作线程安全吗?
经过示例代码github.jmm.NOVolaitile验证,不能保证线程安全,因为i++不是原子操作,需要4步才能完成,而volatile仅能解决重排序和可见性问题,原子性问题需要锁或CAS(原子类)来解决,详细见《码书》p230
9.4 单例模式
为什么需要单例模式?
- 节省内存CPU资源,如果创建一个需要耗费大量内存、大量计算(耗费CPU资源),大量耗时(从DB读取数据)的对象,我们使用单例模式,可以节省内存CPU资源
- 保证结果正确,如多线程统计访问人数,需要一个全局的计数器实例,如果创建了多个计数器,就会统计错误,需要代码层面限制创建多个计数器对象实例
- 方便管理,如日期工具类、字符串工具类,我们不需要创建多个工具类对象实例,只会耗费内存,一般工具类都是
类.静态方法调用,也需要代码层面限制创建工具类实例,如java.lang.Math类的构造方法都是私有的,java.lang.Runtime也是单例模式
单例模式适用场景
- 无状态的工具类:如日志工具类,不管在哪里适用,只需要它记录日志信息,并不需要它的实例对象上存储任何状态,故我们只需要一个实例对象即可。spring无状态bean,
- 全局信息类:比如一个类用来统计网站的访问次数,我们不希望有的访问记录在对象A上,有的记录在对象B上,我们就让这个类成为单例
spirng框架使用单例模式
对于最常用的spring框架来说,我们经常用spring来帮我们管理一些无状态的bean,其默认设置为单例,这样在整个spring框架的运行过程中,即使被多个线程访问和调用,这些“无状态”的bean就只会存在一个,为他们服务。那么“无状态”bean指的是什么呢?
无状态:当前我们托管给spring框架管理的javabean主要有service、mybatis的mapper、一些utils,这些bean中一般都是与当前线程会话状态无关的,没有自己的属性,只是在方法中会处理相应的逻辑,每个线程调用的都是自己的方法,在自己的方法栈中。
有状态:指的是每个用户有自己特有的一个实例,在用户的生存期内,bean保持了用户的信息,即“有状态”;一旦用户灭亡(调用结束或实例结束),bean的生命期也告结束。即每个用户最初都会得到一个初始的bean,因此在将一些bean如User这些托管给spring管理时,需要设置为prototype多例,因为比如user,每个线程会话进来时操作的user对象都不同,因此需要设置为多例。
优势:
- 减少了新生成实例的消耗,spring会通过反射或者cglib来生成bean实例这都是耗性能的操作,其次给对象分配内存也会涉及复杂算法;
- 减少jvm垃圾回收;
- 可以快速获取到bean;
劣势:
单例的bean一个最大的劣势就是要时刻注意线程安全的问题,因为一旦有线程间共享数据变很可能引发问题。
log4j中的单例模式
在使用log4j框架时也注意到了其使用的是单例,当然也为了保证单个线程对日志文件的读写时不出问题。如果是多例,那么后面的实例日志操作会覆盖之前的日志文件
参考慕课网《Java设计模式》单例模式的实践完善本小节
单例模式的8种实现
- 饿汉式
- 懒汉式
- 双重检查
- 静态内部类
- 枚举
双重检查单例模式
双重检查单例模式的实现如下所示,INSTANCE = new DoubleCheckSingleton();不是原子操作,分为三个步骤:
- 分配堆内存
- 对象初始化,调用构造方法创建对象
- 将对象赋值给INSTANCE变量
其中步骤2和步骤三可能出现重排序,如果执行顺序为132,当执行到步骤2时,另一个线程进入,发现INSTANCE不为NULL,则会返回一个未初始化完毕的实例对象
[图片上传失败...(image-35cd16-1583382844738)]
/**
* 双重检查单例模式, 推荐使用
* 线程安全, 延迟加载, 效率高
*/
public class DoubleCheckSingleton {
private volatile static DoubleCheckSingleton INSTANCE;
/**
* 构造函数私有, 避免破坏单例
*/
private DoubleCheckSingleton(){};
/**
* 获取单例对象, 需要两次if检查, 故称为双重检查
* 解决了LazyUnSyncSingleton线程不安全的问题, 解决了LazySyncSingleton后续获取对象的效率低的问题
*
* 但是 new DoubleCheckSingleton() 不是一个原子操作, 当另一个线程进入第一次检查if(null == INSTANCE), 会返回一个未初始化完成的实例对象
* 所以需要volatile 来禁止重排序
*/
public static DoubleCheckSingleton getInstance() {
if(null == INSTANCE) {
synchronized (DoubleCheckSingleton.class) {
if(null == INSTANCE) {
// 不是原子操作,需要volatile禁止重排序
INSTANCE = new DoubleCheckSingleton();
}
}
}
return INSTANCE;
}
}
静态内部类单例模式
实现原理见JVM书,懒加载,用JVM类加载特性保证线程安全
https://blog.csdn.net/mnb65482/article/details/80458571
单例模式静态内部类原理
枚举单例模式
《Effective Java》说使用单元素的枚举是实现单例模式的最佳方法
- 写法简单
- 线程安全
- 避免反序列化破坏单例
- 避免反射攻击
验证反射是否能够破坏枚举模式,示例代码如下,会报NoSuchMethodException异常,详细原理见参考文档12
EnumSingleton singleton1=EnumSingleton.INSTANCE;
EnumSingleton singleton2=EnumSingleton.INSTANCE;
System.out.println("正常情况下,实例化两个实例是否相同:"+(singleton1==singleton2));
Constructor<EnumSingleton> constructor= null;
constructor = EnumSingleton.class.getDeclaredConstructor();
constructor.setAccessible(true);
EnumSingleton singleton3= null;
singleton3 = constructor.newInstance();
System.out.println(singleton1+"\n"+singleton2+"\n"+singleton3);
System.out.println("通过反射攻击单例模式情况下,实例化两个实例是否相同:"+(singleton1==singleton3));
面试题:双重检查单例模式的特点
优点:线程安全,延迟加载,获取对象效率高
- 为什么要double-check?
- synchronized修饰方法线程安全但后续获取实例效率低
- synchronized缩小范围,单check线程不安全
为了兼顾线程安全和后续获取实例的效率,衍生出来双重检测单例模式
-
为什么要用volatile?
新建对象不是原子操作,需要分类内存,调用构造方法,赋值操作三部分
重排序可能会使得赋值操作早于调用构造方法,出现NPE,所以需要volatile禁止重排序
实践
tsp中使用单例模式,没有使用volatile,且创建对象后还要调用set方法,不是原子操作,会出现线程安全问题,解决办法见印象笔记
面试题:单例模式的最佳实现是什么?
- Effective Java中说枚举是单例的最佳实现
- 写法简单
- 线程安全
- 避免反序列化破坏单例
面试题: 单例模式实现有几种,各有哪些优缺点?
静态内部类的实现方式可以引申到JVM类加载
双重检查的实现方式可以引申到并发、锁、volatile、重排序、原子操作等知识
枚举类的实现方式可以引申到反编译,枚举的原理,反序列化
面试题:什么是JMM,JMM为了解决什么问题?
面试题:Java内存模型
Happen-before volatile 主存和本地缓存
面试题:volatile和synchronized的异同
面试题: 什么是原子操作,i++、创建对象、赋值、long类型的写是不是原子操作,怎么解决?
面试题:什么是内存可见性?
[图片上传失败...(image-f637ef-1583382844739)]
面试题:64位的double和long写入操作是原子操作吗?
- 有可能,Java语言规范鼓励但不强求JVM对64位的long和double类型变量的写操作具有原子性,所以存在不是原子操作的可能
- 不需要加volatile,JSR对于商用的JVM,强烈建议将load, store, read, write四个操作实现为原子操作,而且目前各平台下的商用JVM都将其实现为了原子操作。因此不需要把long,double类型修饰为volatile变量。
10 死锁
考考你
- 写一个必然死锁的例子(百度面试题)
- 发生死锁必须满足哪些条件?
- 如何定位死锁?
- 有哪些解决死锁问题的策略
- 讲讲经典的哲学家就餐问题
- 实际工程中如何避免死锁?
- 什么是活跃性问题? 活锁、饥饿和死锁有什么区别?
死锁是什么?
当两个(或更多)线程(或进程)相互持有对方所需要的资源,又不主动释放,导致所有线程都无法继续运行,陷入无尽的阻塞,这就是死锁
10.1 死锁的影响
数据库中:两个事务互相持有对方需要的资源,检测到死锁后会放弃事务,然后指派一个事务先放弃,释放资源,另一个事务执行后再执行该事务
JVM中:无法自动处理,因为不确定线程的重要性,所以JVM无法指派一个线程先放弃。但是JVM提供检测死锁的功能,将处理权利交给程序员
死锁代码
上述代码运行会进入死锁,一直等待无法结束,手动停止线程后会打印如下信息
Process finished with exit code 130(interrupted by signal 2:SIGINT)
正常退出exit code为0,但是不确定130是否是死锁退出的标志
死锁产生的四大条件
- 互斥条件:共享资源X和Y只能被一个线程占有
- 占有且等待条件:线程T1已经获得共享资源X,在等待共享资源Y时,不释放X
- 不剥夺条件:不能剥夺线程T1已经获得的共享资源
- 循环等待条件:线程T1等待T2占有的资源;线程T2等待T1占有的资源
10.2 定位死锁
jstack [pid]:通过jps获得进程pid,然后使用jstack命令,获取死锁信息如下
[图片上传失败...(image-e408d-1583382844739)]
ThreadMXBean:通过代码检测死锁,只能检测当前进程中的死锁
ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean();
long[] deadlockedThreads = threadMXBean.findDeadlockedThreads();
if (deadlockedThreads != null && deadlockedThreads.length > 0) {
for (int i = 0; i < deadlockedThreads.length; i++) {
ThreadInfo threadInfo = threadMXBean.getThreadInfo(deadlockedThreads[i]);
System.out.println("发现死锁" + threadInfo.getThreadName());
}
}
10.3 修复死锁
修复死锁的思路就是破坏死锁产生的四大条件,常用的解决方案有3种:
- 避免策略:哲学家就餐的换手方案,转账换序方案
- 检测与恢复策略:定时检测是否存在死锁,如果有就剥夺某一个资源,来打开死锁
- 鸵鸟策略:如果死锁发生概率极低,可以直接忽略它,直到死锁发生的时候再人工修复
避免策略
思路:避免相反的获取锁的顺序。如转账时需要获取转出账户、转入账户两把锁,但是实际上不在乎获取锁的顺序,当两把锁都获取到了才能进行转账操作。所以可以避免两个线程产生死锁。
通过hashcode来决定获取锁的顺序,hashcode相同时需要“加时赛”,如果对象锁有主键,则利用主键替代hashcode更方便
下面转账代码中,两个线程互相转账,操作前要获取两个Account对象锁,分别是from和to,哪个锁的hash值小,哪个锁先被获取,这样两个线程都先请求获取hash值小的锁,获取到了才能获取另一把锁,这样就不会产生死锁了。破坏了死锁产生条件中循环等待条件。即使是更加复杂的多人随机转账产生,因为无法形成闭环了,也不会产生死锁。
哲学家就餐问题
哲学家就餐问题本质是一个死锁问题,解决哲学家就餐问题,就是解决死锁问题。
问题描述:五个哲学家在一张桌子上吃饭,两人之间有一只筷子,共5只筷子,哲学家就餐流程:
先拿起左手边的一只筷子
然后拿起右手边的一只筷子
如果筷子正在被别人使用,那就等待别人用完
拿到两只筷子后开始吃饭,吃完后将筷子放回
死锁:每个哲学家都拿着左手的筷子,永远都在等待右边的筷子,就会陷入一直等待的状态
解决办法:
- 服务员检查(避免策略):每个哲学家拿左手边筷子前先询问服务员,当服务员发现其他4个哲学家都有且仅有左手边筷子时,即这个哲学家拿起左手筷子就会死锁,为了防止死锁,服务员不允许这个哲学家拿左手边筷子。
- 改变一个哲学家拿筷子的顺序(避免策略):因为都拿到了左手边筷子,都在请求右手边筷子,形成了一个闭环,只需要改变其中一个哲学家拿筷子的顺序,即可打破这个闭环
- 餐票(避免策略):因为5个人同时拿到左手筷子会发生死锁,所以只提供4张餐票,即最多有4个人拿筷子就餐,即可避免死锁问题
- 领导调节(检测与恢复策略):与避免策略不同,该策略不避免你发生死锁,当发生死锁后,检测出死锁,领导命令其中一个人放下筷子,破坏了死锁形成四个条件中的不剥夺条件,解决了死锁问题
死锁检测算法:每次获取锁都有记录,检查锁的调用链路图,如果存在环路,则说形成了死锁
/**
* 描述: 演示哲学家就餐问题导致的死锁
* 解决办法: 改变一个哲学家拿筷子的顺序
*/
public class DiningPhilosophers {
// 哲学家类
public static class Philosopher implements Runnable {
private Object leftChopstick;
public Philosopher(Object leftChopstick, Object rightChopstick) {
this.leftChopstick = leftChopstick;
this.rightChopstick = rightChopstick;
}
private Object rightChopstick;
@Override
public void run() {
try {
// 先拿左边筷子,再拿右边筷子,吃完后放下筷子
while (true) {
doAction("Thinking");
synchronized (leftChopstick) {
doAction("Picked up left chopstick");
synchronized (rightChopstick) {
doAction("Picked up right chopstick - eating");
doAction("Put down right chopstick");
}
doAction("Put down left chopstick");
}
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
private void doAction(String action) throws InterruptedException {
System.out.println(Thread.currentThread().getName() + " " + action);
Thread.sleep((long) (Math.random() * 10));
}
}
public static void main(String[] args) {
// 总共5个哲学家,5只筷子
Philosopher[] philosophers = new Philosopher[5];
Object[] chopsticks = new Object[philosophers.length];
for (int i = 0; i < chopsticks.length; i++) {
chopsticks[i] = new Object();
}
for (int i = 0; i < philosophers.length; i++) {
Object leftChopstick = chopsticks[i];
Object rightChopstick = chopsticks[(i + 1) % chopsticks.length];
// 如果是最后一位哲学家, 则先拿右手边筷子, 避免了环路的形成
if (i == philosophers.length - 1) {
philosophers[i] = new Philosopher(rightChopstick, leftChopstick);
} else {
philosophers[i] = new Philosopher(leftChopstick, rightChopstick);
}
new Thread(philosophers[i], "哲学家" + (i + 1) + "号").start();
}
}
}
检测与恢复策略
检测:每次获取锁都有记录,定期检查锁的调用链路图,如果存在环路,则说形成了死锁,一旦出现死锁,就用死锁恢复机制进行恢复。
恢复方法有两种:
线程终止,逐个终止线程,直至死锁消除
资源抢占,把已经分发的锁给收回来,让线程回退几步,这样就不用结束整个线程
??? 两种恢复方法实际如何操作并不清楚
10.4 如何避免死锁
- 设置超时时间
Lock.tryLock(long timeout, TimeUnit unit) 尝试获取锁,获取成功返回true,超时后放弃返回false
示例代码如下:
synchronized不具备尝试锁的能力
获取锁失败:打印日志,发送报警信息、重启等
- 多使用并发类而不是自己设计锁
- 尽量降低锁的粒度
- 同步代码块优于同步方法,自己制定锁对象更好
- 线程设置一个有意义名称,debug和排查问题事半功倍,框架和JDK都遵守这个规则
- 尽量避免锁的嵌套
- 分配资源前先看能不能收回来:银行家算法
- 尽量不要多个功能用同一把锁:专锁专用
10.5 活锁
会导致程序无法顺利进行,统称为活跃性问题。死锁是最常见的活跃性问题,活锁(LiveLock)和饥饿都是活跃性问题。
什么是活锁?
虽然线程并没有阻塞,也始终在运行,但是程序却得不到进展,因为线程始终在做同样的事。
活锁对应到哲学家就餐问题:
五个哲学家都拿到了左边的筷子,都在等待右边的的筷子,最多等待5分钟,如果拿不到右边的筷子,就放下手中的筷子,再等五分钟,又同时拿起左手边的筷子
工程中的活锁实例
消息队列中的消息如果处理失败,不能放在队列开头重试,应该放到队列尾部,设置重试次数,如果还是失败,可以考虑保存到数据库或写到文件中
10.6 饥饿
当线程需要某些资源(例如CPU),但是始终得不到,称为饥饿。
面试题:写一个必然死锁的例子,生产中什么场景会产生死锁?
线程a获得锁1,请求锁2,;线程b获得锁2,请求锁1
面试题:发生死锁必须满足哪些条件?
四大条件:互斥条件、占有且等待条件、不剥夺条件、循环等待条件
面试题:如何定位死锁?发现死锁的原理是什么?
jstack命令、jconsole、ThreadMXBean都可以定位死锁
发现死锁的原理是根据锁的调用链图,形成闭环则说明形成了死锁
面试题:有哪些解决死锁的策略?
避免策略:哲学家就餐的换手方案,转账换序方案,根据账户ID确定获取锁的顺序
检测与恢复策略:一段时间检测是否有死锁,如果有就剥夺某一个资源,来打开死锁
面试题:哲学家就餐问题
哲学家就餐死锁问题有四种解决办法:服务员检查(避免策略)、改变一个哲学家拿筷子的顺序(避免策略)、餐票(避免策略)、领导调节(检测与恢复策略)
面试题:实际工程中如何避免死锁?
设置等待锁的超市时间 Lock.tryLock()
-
多使用并发类而不是自己设计锁
...共8点,见上方详解
面试题:什么是活跃性问题? 活锁、饥饿和死锁有什么区别?
11 总结
使用锚点整体面试题
<a name="q1">面试题1</a>
<a href="#q1">跳转到面试题1</a>
12 面试题归纳与套路
单例,分析双重锁,引出线程安全和和volatile禁止重排序,结合TSP实践说明单例模式。再引出Spring单例,再引出cglib,再引出ThreadLocal,结合注解记录日志说明ThreadLocal
分析静态内部类,引出类加载方式
分析原子操作,引出i++不是线程安全,引出Atomic,引出CAS
分析原子操作,引出锁,解释sync'原理,引出死锁,结合WG死锁检测实践,再引出死锁调用链形成闭环检测死锁,再引出哲学家就餐问题
都有哪些方法会抛出InterruptedException?
参考文档
- Java并发核心知识体系精讲 - 视频教程
- 线程8大核心基础 - 思维导图
- 配套高频并发面试题汇总 - 持续更新
- OpenJDK 在线代码:class 目录是 Java 代码,native 目录是 C++ 代码
- OpenJDK 源码 - Github
- Java线程源码解析之 start
- Java线程面试题
- JVM源码分析之Object.wait/notify实现 - 占小狼
- 123个Java并发面试题
- Java 单例模式详解
- Java单例模式的5种写法
- 枚举实现单例模式的原理
结合极客时间、汪文君并发、Java并发编程的艺术,微信收藏、印象笔记、简书笔记等完善,不完善总结相当于白学了
学完后过一下参考文档9面试题,检验一下学习成果。
局部性原理笔记
