今日八股——JUC篇
线程基础
线程和进程的区别?
- 进程是正在运行程序的实例,进程中包含了线程,每个线程执行不同的任务
- 不同的进程使用不同的内存空间,在当前进程下的所有线程可以共享内存空间
- 线程更轻量,线程上下文切换成本一般上要比进程上下文切换低(上下文切换指的是从一个线程切换到另一个线程)
并行和并发有什么区别?
并发(concurrent)是同一时刻应对(dealing with)多件事情的能力。
在同一时刻,只有一个线程执行,但一个时间段内两个线程都执行了。
并行(parallel)是同一时刻动手做(doing)多件事情的能力。
在同一时刻,两个线程同时执行。需要有两个 CPU 分别执行两个线程。
单核 CPU
- 单核CPU下线程实际还是串行执行的
- 操作系统中有一个组件叫做任务调度器, 将cpu的时间片(windows下时间片最小约为 15 毫秒) 分给不同的程序使用, 只是由于 cpu在线程间(时间片很短)的切换非常快, 人类感觉是同时运行的。
- 总结为一句话就是: 微观串行, 宏观并行
- 一般会将这种线程轮流使用CPU的做法称为并发(concurrent)
多核 CPU
创建线程的方式有哪些?(高频)
创建线程的方式有哪些?
- 继承Thread类
- 实现Runnable接口
- 实现Callable接口
- 线程池创建线程(项目中使用方式)
runnable 和 callable 区别?
- Runnable 接口run方法没有返回值
- Callable接口call方法有返回值,是个泛型,和Future、FutureTask配合可以用来获取异步执行的结果
- Callable接口的call()方法允许抛出异常;而Runnable接口的run()方法的异常只能在内部消化(使用 try catch),不能继续上抛
启动线程的时候可以使用 run()方法吗,run()和 start()有什么区别?
start(): 用来启动线程,通过该线程调用run方法执行run方法中所定义的逻辑代码。start方法只能被调用一次。
run(): 封装了要被线程执行的代码,可以被调用多次。
扩展
为什么 Runnable.run()
不能抛异常,而 Callable.call()
可以抛异常?抛异常在创建线程时有什么意义?
Runnable
诞生于 Java 1.0,是为Thread
设计的。
Thread
在底层直接调用run()
方法;- 没有任何机制可以把异常传回主线程;
- 因此
run()
不能抛出受检异常,只能在内部try-catch
。
Callable
出现于 Java 5,是为ExecutorService
+Future
设计的。
Callable.call()
允许返回值和抛出异常;- 线程池会捕获异常并封装到
Future
中; - 主线程可以通过
Future.get()
得到结果或异常。
是因为 Callable
能抛异常所以要有返回值,还是因为要有返回值所以得能抛异常?
Callable
的设计初衷是:要让主线程拿到子线程的执行结果。- 有了返回值,就要同时解决“执行失败”时怎么通知主线程 → 所以允许抛异常。
- 因此,有返回值是主因,允许抛异常是配套。
- 异常传播只是为了让返回值机制完整(成功给结果,失败给异常)
“抛异常”到底抛到哪去了?谁接得住?如果没人接,会发生什么?”
Runnable
的异常只存在于子线程内部;默认无人接就打印堆栈信息;主线程收不到。- 可以用
Thread.setUncaughtExceptionHandler()
捕获子线程未处理的异常。 Callable
的异常被线程池捕获并封装到Future
;主线程在Future.get()
时得到ExecutionException
,里面包含原始异常。- 在单线程中,异常沿调用栈向上传递。
public void a() {b();
}public void b() {c();
}public void c() throws IOException {throw new IOException("boom");
}
异常沿当前线程的调用栈一层层往上传递;经过b()
、a()
,都没 catch,就传到线程的最外层,没人接就终止线程并打印堆栈。
- 多线程中:异常不会跨线程传播!
在子线程抛出的主线程是接受不到的
场景 | 异常去向 | 说明 |
---|---|---|
① 普通 Runnable + Thread | 打印到控制台 | Thread 默认用 UncaughtExceptionHandler 打印异常信息 |
② 自定义 UncaughtExceptionHandler | 被你自定义捕获 | 你可以注册一个全局处理器 |
③ Callable + Future | 被线程池捕获并封装进 Future | 主线程通过 future.get() 拿到 ExecutionException |
线程之间包括哪些状态,状态之间是如何切换的?
- 新建(NEW)
- 可运行(RUNNABLE)
- 阻塞(BLOCKED)
- 等待(WAITING)
- 时间等待(TIMED_WALTING)
- 终止(TERMINATED)
public enum State{
// 尚未启动的线程的线程状态
NEW,// 可运行线程的线程状态。
RUNNABLE,// 线程阻塞等待监视器锁的线程状态。
BLOCKED,//等待线程的线程状态
WAITING,// 具有指定等待时间的等待线程的线程状态
TIMED_WAITING,// 已终止线程的线程状态。线程已完成执行
TERMINATED
}
- 创建线程对象是新建状态
- 调用了start()方法转变为可执行状态
- 线程获取到了CPU的执行权, 执行结束是终止状态
- 在可执行状态的过程中, 如果没有获取CPU的执行权, 可能会切换其他状态
- 如果没有获取锁(synchronized或lock)进入阻塞状态, 获得锁再切换为可执行状态
- 如果线程调用了wait()方法进入等待状态, 其他线程调用notify()唤醒后可切换为可执行状态
- 如果线程调用了sleep(50)方法, 进入计时等待状态, 到时间后可切换为可执行状态
新建 T1、T2、T3 三个线程,如何保证他们按顺序执行?
可以用线程的 join()方法解决。
比如:t.join()
会阻塞调用当前方法的线程,置为 timed_waiting 状态,直到 t 线程执行完,此线程继续执行。
notify()
和notifyAll()
的区别?
notify()
只随机唤醒一个 wait 线程notifyAll()
是唤醒全部 wait 线程
Java 中wait()
和sleep()
方法的不同?
共同点
wait(), wait(long) 和 sleep(long) 的效果都是让当前线程暂时放弃 CPU 的使用权, 进入阻塞状态
不同点
- 方法归属不同
- sleep(long) 是 Thread 的静态方法
- 而 wait(), wait(long) 都是 Object 的成员方法, 每个对象都有
- 醒来时机不同
- 执行 sleep(long)和 wait(long) 的线程都会在等待相应毫秒后醒来
- wait(long) 和 wait() 还可以被 notify 唤醒, wait() 如果不唤醒就一直等下去
- 它们都可以被打断唤醒
- 锁特性不同 (重点)
- wait 方法的调用必须先获取 wait 对象的锁, 而 sleep 则无此限制
- wait 方法执行后会释放对象锁, 允许其它线程获得该对象锁(我放弃 cpu, 但你们还可以用)
- 而 sleep 如果在 synchronized 代码块中执行, 并不会释放对象锁(我放弃 cpu, 你们也用不了)
使用sleep()
public class SleepExample {public static void main(String[] args) throws InterruptedException {Thread t = new Thread(() -> {synchronized (SleepExample.class) {System.out.println("Thread start...");try {Thread.sleep(2000); // 不释放锁} catch (InterruptedException e) {e.printStackTrace();}System.out.println("Thread end...");}});t.start();Thread.sleep(500); // 主线程等待子线程拿到锁synchronized (SleepExample.class) {System.out.println("Main thread got lock!");}}
}
输出结果
Thread start...
(2 秒后)
Thread end...
Main thread got lock!
按理说子线程sleep
了 2000ms,而主线程sleep
了 500ms,那么按理说主线程先输出内容,但是由于sleep
时并不会释放锁,所以即使子线程释放了 CPU,也不会释放锁,那么主线程获取不到锁,主线程就会等待子线程直到子线程释放锁。
使用wait()
public class WaitExample {public static void main(String[] args) throws InterruptedException {Object lock = new Object();Thread t = new Thread(() -> {synchronized (lock) {try {System.out.println("Thread waiting...");lock.wait(); // 释放锁System.out.println("Thread resumed!");} catch (InterruptedException e) {e.printStackTrace();}}});t.start();Thread.sleep(1000); // 确保子线程进入wait状态synchronized (lock) {System.out.println("Main thread notify...");lock.notify(); // 唤醒子线程}}
}
输出结果:
Thread waiting...
Main thread notify...
Thread resumed!
如何停止一个正在执行的线程?
有三种方式可以停止线程
- 使用退出标志, 使线程正常退出, 也就是当run方法完成后线程终止
(volatile)
- 使用
stop
方法强行终止(不推荐, 方法已作废) - 使用
interrupt
方法中断线程
打断阻塞的线程 (sleep, wait, join)
的线程, 线程会抛出InterruptedException
异常。
打断正常的线程, 可以根据打断状态来标记是否退出线程。
线程安全
synchronized 关键字的底层原理-基础回答
- Synchronized【对象锁】采用互斥的方式让同一时刻至多只有一个线程能持有【对象锁】
- 它的底层由monitor实现的, monitor是jvm级别的对象 (C++实现), 线程获得锁需要使用对象(锁)关联monitor
- 在monitor内部有三个属性, 分别是
owner
、entrylist
、waitset
- 其中owner是关联的获得锁的线程,并且只能关联一个线程;entrylist 关联的是处于阻塞状态的线程; waitset关联的是处于Waiting状态的线程
synchronized
实现原理依赖于 JVM 的 Monitor(监视器锁)
- Owner: 存储当前获取锁的线程的, 只能有一个线程可以获取
- EntryList: 关联没有抢到锁的线程, 处于Blocked状态的线程。线程并非排队而是随机抢夺 owner。
- WaitSet: 关联调用了wait方法的线程, 处于Waiting状态的线程
synchronized 关键字的底层原理-进阶回答
Monitor 实现的锁是重量级锁,你了解过锁升级吗?
- Monitor实现的锁属于重量级锁, 里面涉及到了用户态和内核态的切换、进程的上下文切换, 成本较高, 性能比较低。
- 在JDK 1.6引入了两种新型锁机制: 偏向锁和轻量级锁, 它们的引入是为了解决在没有多线程竞争或基本没有竞争的场景下因使用传统锁机制带来的性能开销问题。
对象的内存结构
在HotSpot虚拟机中, 对象在内存中存储的布局可分为3块区域:对象头(Header)、实例数据(Instance Data)和对齐填充
MarkWord
轻量级锁
在很多的情况下,在Java程序运行时,同步块中的代码都是不存在竞争的,不同的线程交替的执行同步块中的代码。这种情况下,用重量级锁是没必要的。因此JVM引入了轻量级锁的概念。
加锁流程
- 在线程栈中创建一个Lock Record, 将其obj字段指向锁对象。
- 通过CAS指令将Lock Record的地址存储在对象头的mark word中, 如果对象处于无锁状态则修改成功, 代表该线程获得了轻量级锁。
- 如果是当前线程已经持有该锁了, 代表这是一次锁重入。设置Lock Record第一部分为null,起到了一个重入计数器的作用。
- 如果CAS修改失败, 说明发生了竞争, 需要膨胀为重量级锁。
解锁过程
- 遍历线程栈, 找到所有obj字段等于当前锁对象的Lock Record。
- 如果Lock Record的Mark Word为null, 代表这是一次重入, 将obj设置为null后continue。
- 如果Lock Record的 MarkWord不为null, 则利用CAS指令将对象头的mark word恢复成为无锁状态。如果失败则膨胀为重量级锁。
偏向锁
轻量级锁在没有竞争时(就自己这个线程),每次重入仍然需要执行 CAS 操作。
Java 6 中引入了偏向锁来做进一步优化:只有第一次使用 CAS 将线程 ID 设置到对象的 Mark Word 头,之后发现这个线程 ID 是自己的就表示没有竞争,不用重新 CAS。以后只要不发生竞争,这个对象就归该线程所有。
偏向锁只会在第一次添加锁记录的时候进行 CAS,轻量级锁每一次都会进行 CAS。
一旦锁发生了竞争都会升级为重量级锁。
谈谈 JMM(Java 内存模型)
- JMM(Java Memory Model) Java内存模型, 定义了共享内存中多线程程序读写操作的行为规范, 通过这些规则来规范对内存的读写操作从而保证指令的正确性。
- JMM 把内存分为两块,一块是私有线程的工作区域(工作内存),一块是是线程之间的共享区域(主内存)
- 线程跟线程之间是隔离的,线程之间的数据同步需要借助主内存。
谈谈对 CAS 的理解
CAS的全称是: Compare And Swap(比较再交换),它体现的一种乐观锁的思想,在无锁情况下保证线程操作共享数据的原子性。
在JUC(java.util.concurrent)包下实现的很多类都用到了CAS操作
- AbstractQueuedSynchronizer (AQS框架)
- AtomicXXX类
过程:
一个当前内存值V、旧的预期值A、即将更新的值B,当且仅当旧的预期值A和内存值V相同时,将内存值修改为B并返回true,否则什么都不做,并返回false。如果CAS操作失败,通过自旋的方式等待并再次尝试,直到成功。
这个过程就是先比较再交换。
乐观锁和悲观锁
谈谈对 volatile 的理解-可见性
- 保证线程间的可见性
- 禁止进行指令重排序
保证线程间的可见性
用 volatile 修饰共享变量,能够防止编译器等优化发生,让一个线程对共享变量的修改对另一个线程可见。
解决方案一:在程序运行的时候加入vm参数-Xint表示禁用即时编译器,不推荐,得不偿失(其他程序还要使用)
解决方案二:在修饰stop变量的时候加上volatile,当前告诉 jit,不要对 volatile 修饰的变量做优化