1、线程基础知识
1.1、线程和进程
线程和进程的区别?
程序由指令
和数据
组成,但这些指令要运行,数据要读写,就必须将指令加载到CPU,数据加载到内存。
在指令运行过程中还需要用到磁盘,网络等设备。
进程就是用来加载指令、管理内存、管理IO的。
简单来说,当一个程序被运行,从磁盘加载这个程序的代码到内存
,这时就开启了一个进程。
一个线程就是一个指令流
,将指令流中的一条条指令以一定的顺序交给CPU执行,一个进程之内可以分为一到多
个线程。
区别:
- 进程是正在运行程序的实例,进程中包含了线程,每个线程执行不同的任务
- 不同的进程使用不同的内存空间,在当前进程下的所有线程可以
共享
内存空间 - 线程更轻量,线程上下文切换成本一般比进程上下文切换低
1.2、并行与并发
并行与并发有什么区别?
单核CPU下:
- 单核CPU下线程实际是
串行
执行的 - 操作系统有个组件叫
任务调度器
,将CPU的时间片分给不同的程序使用,只是由于CPU在线程间的切换非常快,我们感觉是同时运行的 - 简单来说:微观串行、宏观并行
- 一般将这种
线程轮流使用CPU
的做法称为并发
多核CPU下:
每个核(core)都可以调度运行线程,这时候线程可以是并行的。
现在都是多核CPU,在多核CPU下
-
并发是同一时间
应对
多件事情的能力,多个线程轮流使用一个或多个CPU -
并行是同一时间
动手做
多件事情的能力,4核CPU同时执行4个线程
1.3、线程的创建方式
创建线程的方式有哪些?
共有四种方式可以创建线程,分别是:
- 继承Thread类
- 实现runnable接口
- 实现Callable接口
- 线程池创建线程(项目中使用方式)
① 继承Thread类
public class MyThread extends Thread {
@Override
public void run() {
System.out.println("MyThread...run...");
}
public static void main(String[] args) {
// 创建MyThread对象
MyThread t1 = new MyThread() ;
MyThread t2 = new MyThread() ;
// 调用start方法启动线程
t1.start();
t2.start();
}
}
② 实现runnable接口
public class MyRunnable implements Runnable{
@Override
public void run() {
System.out.println("MyRunnable...run...");
}
public static void main(String[] args) {
// 创建MyRunnable对象
MyRunnable mr = new MyRunnable() ;
// 创建Thread对象
Thread t1 = new Thread(mr) ;
Thread t2 = new Thread(mr) ;
// 调用start方法启动线程
t1.start();
t2.start();
}
}
③ 实现Callable接口
public class MyCallable implements Callable<String> {
@Override
public String call() throws Exception {
System.out.println("MyCallable...call...");
return "OK";
}
public static void main(String[] args) throws ExecutionException, InterruptedException {
// 创建MyCallable对象
MyCallable mc = new MyCallable() ;
// 创建F
FutureTask<String> ft = new FutureTask<String>(mc) ;
// 创建Thread对象
Thread t1 = new Thread(ft) ;
Thread t2 = new Thread(ft) ;
// 调用start方法启动线程
t1.start();
// 调用ft的get方法获取执行结果
String result = ft.get();
// 输出
System.out.println(result);
}
}
④ 线程池创建线程
public class MyExecutors implements Runnable{
@Override
public void run() {
System.out.println("MyRunnable...run...");
}
public static void main(String[] args) {
// 创建线程池对象
ExecutorService threadPool = Executors.newFixedThreadPool(3);
threadPool.submit(new MyExecutors()) ;
// 关闭线程池
threadPool.shutdown();
}
}
刚才你说过,使用runnable和callable都可以创建线程,它们有什么区别呢?
- Runnable 接口run方法没有返回值
- Callable接口call方法有返回值,是个泛型,和Future、
FutureTask
配合可以用来获取异步
执行的结果 - Callalbe接口支持返回执行结果,需要调用FutureTask.get()得到,此方法会
阻塞
主进程的继续往下执行,如果不调用不会阻塞。 - Callable接口的
call()方法允许抛出异常
;而Runnable接口的run()方法的异常只能在内部消化,不能继续上抛
在启动线程的时候,可以使用run方法吗?run()和 start()有什么区别?
-
start(): 用来启动线程,通过该线程调用run方法执行run方法中所定义的逻辑代码。start方法只能被调用一次。
-
run(): 封装了要被线程执行的代码,可以被调用多次。
1.4、线程之间的状态
线程包括哪些状态,状态之间是如何变化的?
线程的状态可以参考JDK中的Thread类中的枚举State
看源码:
public enum State {
/**
* 尚未启动的线程的线程状态
*/
NEW,
/**
* 可运行线程的线程状态。处于可运行状态的线程正在 Java 虚拟机中执行,但它可能正在等待来自操作系统的其他资源,例如处理器。
*/
RUNNABLE,
/**
* 线程阻塞等待监视器锁的线程状态。处于阻塞状态的线程正在等待监视器锁进入同步块/方法或在调用Object.wait后重新进入同步块/方法。
*/
BLOCKED,
/**
* 等待线程的线程状态。由于调用以下方法之一,线程处于等待状态:
* Object.wait没有超时
* 没有超时的Thread.join
* LockSupport.park
* 处于等待状态的线程正在等待另一个线程执行特定操作。
* 例如,一个对对象调用Object.wait()的线程正在等待另一个线程对该对象调用Object.notify()或Object.notifyAll() 。已调用Thread.join()的线程正在等待指定线程终止。
*/
WAITING,
/**
* 具有指定等待时间的等待线程的线程状态。由于以指定的正等待时间调用以下方法之一,线程处于定时等待状态:
* Thread.sleep
* Object.wait超时
* Thread.join超时
* LockSupport.parkNanos
* LockSupport.parkUntil
* </ul>
*/
TIMED_WAITING,
/**
* 已终止线程的线程状态。线程已完成执行
*/
TERMINATED;
}
看流程图:状态之间是如何变化的
分别是
- 新建
- 当一个线程对象被创建,但还
未调用 start
方法时处于新建
状态 - 此时未与操作系统底层线程关联
- 当一个线程对象被创建,但还
- 可运行
- 调用了 start 方法,就会由
新建
进入可运行
- 此时与底层线程关联,由操作系统调度执行
- 调用了 start 方法,就会由
- 终结
- 线程内代码已经执行完毕,由
可运行
进入终结
- 此时会取消与底层线程关联
- 线程内代码已经执行完毕,由
- 阻塞
- 当获取锁失败后,由
可运行
进入 Monitor 的阻塞队列阻塞
,此时不占用 cpu 时间 - 当持锁线程释放锁时,会按照一定规则唤醒阻塞队列中的
阻塞
线程,唤醒后的线程进入可运行
状态
- 当获取锁失败后,由
- 等待
- 当获取锁成功后,但由于条件不满足,调用了 wait() 方法,此时从
可运行
状态释放锁进入 Monitor 等待集合等待
,同样不占用 cpu 时间 - 当其它持锁线程调用 notify() 或 notifyAll() 方法,会按照一定规则唤醒等待集合中的
等待
线程,恢复为可运行
状态
- 当获取锁成功后,但由于条件不满足,调用了 wait() 方法,此时从
- 有时限等待
- 当获取锁成功后,但由于条件不满足,调用了 wait(long) 方法,此时从
可运行
状态释放锁进入 Monitor 等待集合进行有时限等待
,同样不占用 cpu 时间 - 当其它持锁线程调用 notify() 或 notifyAll() 方法,会按照一定规则唤醒等待集合中的
有时限等待
线程,恢复为可运行
状态,并重新去竞争锁 - 如果等待超时,也会从
有时限等待
状态恢复为可运行
状态,并重新去竞争锁 - 还有一种情况是调用 sleep(long) 方法也会从
可运行
状态进入有时限等待
状态,但与 Monitor 无关,不需要主动唤醒,超时时间到自然恢复为可运行
状态
- 当获取锁成功后,但由于条件不满足,调用了 wait(long) 方法,此时从
总结
-
线程包括哪些状态?
新建(NEW)、可运行(RUNNABLE)、阻塞(BLOCKED)、等待( WAITING )
时间等待(TIMED_WALTING)、终止(TERMINATED)
-
线程状态之间是如何变化的?
- 创建线程对象是
新建状态
- 调用了start()方法转变为
可执行状态
- 线程获取到了CPU的执行权,执行结束是
终止状态
- 在可执行状态的过程中,如果没有获取CPU的执行权,可能会切换其他状态
- 如果没有获取锁进入
阻塞状态
,获得锁再次转换为可执行状态 - 如果线程调用了wait()方法进入
等待状态
,其他线程调用notify()方法唤醒后可切换为可执行状态 - 如果线程调用了sleep(50)方法,进入
计时等待状态
,到时间后切换为可执行状态
- 如果没有获取锁进入
- 创建线程对象是
新建 T1、T2、T3 三个线程,如何保证它们按顺序执行?
可以使用线程中的join
方法解决
举个栗子:
t.join(),阻塞调用此方法的线程进入timed_waiting,直到线程t执行完成后,此线程再继续执行
为了确保三个线程的顺序应该先启动最后一个(T3调用T2,T2调用T1),这样T1就会先完成而T3最后完成
public class JoinTest {
public static void main(String[] args) {
// 创建线程对象
Thread t1 = new Thread(() -> {
System.out.println("t1");
}) ;
Thread t2 = new Thread(() -> {
try {
t1.join(); // 加入线程t1,只有t1线程执行完毕以后,再次执行该线程
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("t2");
}) ;
Thread t3 = new Thread(() -> {
try {
t2.join(); // 加入线程t2,只有t2线程执行完毕以后,再次执行该线程
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("t3");
}) ;
// 启动线程
t1.start();
t2.start();
t3.start();
}
}
notify()和 notifyAll()有什么区别?
-
notifyAll:唤醒所有wait的线程
-
notify:只随机唤醒一个 wait 线程
1.5、wait和sleep方法的区别
在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,你们也用不了)
- wait 方法的调用必须先获取 wait 对象的
如何停止一个正在运行的线程?
有三种方式可以停止线程
-
使用
退出标志
,使线程正常退出,也就是当run方法完成后线程终止 -
使用
stop方法
强行终止(不推荐,方法已作废) -
使用
interrupt
方法中断线程- 打断阻塞的线程( sleep,wait,join )的线程,线程会抛出InterruptedException异常
- 打断正常的线程,可以根据打断状态来标记是否退出线程
① 使用退出标志,使线程正常退出
public class MyInterrupt1 extends Thread {
volatile boolean flag = false ; // 线程执行的退出标记
@Override
public void run() {
while(!flag) {
System.out.println("MyThread...run...");
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
public static void main(String[] args) throws InterruptedException {
// 创建MyThread对象
MyInterrupt1 t1 = new MyInterrupt1() ;
t1.start();
// 主线程休眠6秒
Thread.sleep(6000);
// 更改标记为true
t1.flag = true ;
}
}
② 使用stop方法强行终止
public class MyInterrupt2 extends Thread {
volatile boolean flag = false ; // 线程执行的退出标记
@Override
public void run() {
while(!flag) {
System.out.println("MyThread...run...");
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
public static void main(String[] args) throws InterruptedException {
// 创建MyThread对象
MyInterrupt2 t1 = new MyInterrupt2() ;
t1.start();
// 主线程休眠2秒
Thread.sleep(6000);
// 调用stop方法
t1.stop();
}
}
③ 使用interrupt方法中断线程
public class MyInterrupt3 {
public static void main(String[] args) throws InterruptedException {
//1.打断阻塞的线程
/*Thread t1 = new Thread(()->{
System.out.println("t1 正在运行...");
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}, "t1");
t1.start();
Thread.sleep(500);
t1.interrupt();
System.out.println(t1.isInterrupted());*/
//2.打断正常的线程
Thread t2 = new Thread(()->{
while(true) {
Thread current = Thread.currentThread();
boolean interrupted = current.isInterrupted();
if(interrupted) {
System.out.println("打断状态:"+interrupted);
break;
}
}
}, "t2");
t2.start();
Thread.sleep(500);
//打断
t2.interrupt();
}
}
2、线程中并发安全
2.1、Synchronized关键字
先来看一段抢票的逻辑
如果不加锁,就会出现超卖或者一张票卖给多个人
public class TicketDemo {
static Object lock = new Object();
int ticketNum = 10;
public synchronized void getTicket() {
//加锁
synchronized (this) {
if (ticketNum <= 0) {
return;
}
System.out.println(Thread.currentThread().getName() + "抢到一张票,剩余:" + ticketNum);
// 非原子性操作
ticketNum--;
}
}
public static void main(String[] args) {
TicketDemo ticketDemo = new TicketDemo();
for (int i = 0; i < 20; i++) {
new Thread(() -> {
ticketDemo.getTicket();
}).start();
}
}
}
Synchronized(对象锁)采用互斥
的方式让同一时刻至多只有一个线程能持有对象锁
,其他线程再想获取这个对象锁时就会阻塞住
Synchronized底层是Monitor,Monitor 被翻译为监视器,是由jvm提供,c++语言实现
那这个Monitor具体是什么呢?我们来写一段逻辑
public class SyncTest {
static final Object lock = new Object();
static int counter = 0;
public static void main(String[] args) {
synchronized (lock) {
counter++;
}
}
}
在target文件夹中找到SyncTest类,右键在终端中打开,使用 javap -v SyncTest.class
查看class字节码的信息
解释一下:
- monitorenter 上锁开始的地方
- monitorexit 解锁的地方
- 其中被monitorenter和monitorexit包围住的指令就是上锁的代码
可以看到解锁了两次,为什么呢?因为有可能锁有可能是在try catch代码块中执行,代码要是抛出异常就不能及时释放锁
在使用了synchornized代码块时需要指定一个对象,所以synchornized也被称为对象锁
monitor主要就是跟这个对象产生关联
,如下图
在Monitor内部有三个属性:
- Owner:存储
当前获取锁
的线程的,只能有一个
线程可以获取 - EntryList:关联没有抢到锁的线程,处于
Blocked状态
的线程 - WaitSet:关联调用了wait方法的线程,处于
Waiting状态
的线程
具体的流程:
- 代码进入synchorized代码块,先让
lock(对象锁)关联到monitor
,然后判断Owner是否有线程持有 - 如果没有线程持有,则让当前线程持有,表示该线程获取锁成功
- 如果有线程持有,则让当前线程进入entryList进行
阻塞
,如果Owner持有的线程已经释放了锁,在EntryList中的线程去竞争锁的持有权(非公平) - 如果代码块中调用了wait()方法,则会进去WaitSet中进行等待
Synchronized关键字的底层原理
- Synchronized(对象锁)采用
互斥
的方式让同一个时刻只能有一个线程能持有对象锁 - 它的底层是由monitor实现,monitor是jvm级别的对象(C++实现),
线程获得锁需要使用对象(锁)关联monitor
- 在monitor内部有三个属性,分别是owner、entrylist、waitset
- 其中owner是关联的获得锁的线程,并且只能关联一个线程;entrylist关联的是处于阻塞状态的线程;waitset关联的是处于waiting状态的线程
2.2、锁升级
Monitor实现的锁属于重量级锁,涉及到用户态和内核态的切换,进程的上下文切换,成本较高,性能较低
Jdk1.6引入了两种新型的锁机制:偏向锁、轻量级锁
它们的引入是为了解决在没有多线程竞争的场景下因使用传统锁机制带来的性能开销问题
要想聊明白这个问题,我们首先需要该清楚对象的内存结构
对象的内存结构:
我们需要重点分析MarkWord对象头
解释一下属性:
- hashcode:25位的对象标识Hash码
- age:对象分代年龄占4位
- biased_lock:偏向锁标识,占1位 ,0表示没有开始偏向锁,1表示开启了偏向锁
- thread:持有偏向锁的线程ID,占23位
- epoch:偏向时间戳,占2位
- ptr_to_lock_record:轻量级锁状态下,
指向栈中锁记录的指针
,占30位 - ptr_to_heavyweight_monitor:重量级锁状态下,
指向对象监视器Monitor的指针
,占30位
Monitor重量级锁
每个 Java 对象都可以关联一个 Monitor 对象,如果使用 synchronized 给对象上锁(重量级)之后,该对象头的Mark Word 中就被设置指向 Monitor 对象的指针
简单说就是:每个对象的对象头都可以设置monoitor的指针,让对象与monitor产生关联
轻量级锁:
在很多情况下,在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的Mark Word不为null,则利用CAS指令将对象头的mark word恢复为无锁状态,如果失败就膨胀为重量级锁
偏向锁:
- 轻量级锁在没有竞争时(就自己这个线程),
每次重入仍然需要执行CAS操作
- Java6中引入了偏向锁来做进一步优化,只有第一次使用CAS将线程ID设置到对象的Mark Word头中,之后发现这个线程ID是自己的就表示没有锁竞争,不用重新CAS,以后只要不发生竞争,这个对象就归该线程所有
比如说下面这个栗子
static final Object obj = new Object();
public static void m1() {
synchronized (obj) {
// 同步块 A
m2();
}
}
public static void m2() {
synchronized (obj) {
// 同步块 B
m3();
}
}
public static void m3() {
synchronized (obj) {
}
}
Monitor实现的锁属于重量级锁,你了解过锁升级吗?
Java中的Synchronized有偏向锁、轻量级锁、重量级锁三种形式,分别对应了锁只被一个线程持有
、不同线程交替持有锁
和多线程竞争锁
三种情况
描述 | |
---|---|
重量级锁 | 底层使用的Monitor实现,里面涉及到了用户态和内核态的切换、进程的上下文切换,成本较高,性能比较低。 |
轻量级锁 | 线程加锁的时间是错开的(也就是线程之间没有竞争),可以使用轻量级锁来优化。轻量级修改了对象头的锁标志,相对重量级锁性能提升很多。每次修改都是CAS操作 ,保证原子性 |
偏向锁 | 一段很长的时间内都只被一个线程使用锁,可以使用了偏向锁,在第一次 获得锁时,会有一个CAS操作 ,之后该线程再获取锁,只需要判断mark word中是否是自己的线程id 即可,而不是开销相对较大的CAS命令 |
一旦发生锁竞争,都会升级为重量级锁
2.3、Java内存模型
JMM(Java Memory Model)Java内存模型,定义了共享内存
中多线程读写操作
的行为规范,通过这些规则来规范对内存的读写操作从而保证指令的正确性
JMM 定义了一套在多线程读写共享数据
时(成员变量、数组)时,对数据的可见性、有序性、和原子性的规则和保障
你谈谈 JMM(Java内存模型)
Java内存模型,定义了共享内存
中多线程程序读写操作数据
的规范,通过这些规范保证指令的正确性
JMM将内存分为两块,一块是私有线程的工作区域(工作内存),一块是所有线程共享区域(主内存)
线程与线程之间相互隔离,线程跟线程交互需要通过主内存
2.4、CAS
CAS的全称是Compare And Swap (比较并交换),它体现的是一种乐观锁
的思想,在无锁情况下保证线程操作共享数据的原子性
在JUC( java.util.concurrent )包下实现的很多类都用到了CAS操作
-
AbstractQueuedSynchronizer(AQS框架)
-
AtomicXXX类
CAS交换数据流程:
一个当前内存值V、旧的预期值A、即将更新的值B,当且仅当旧的预期值A和内存值V相同
时,将内存值修改为B并返回true,否则什么都不做,并返回false。如果CAS操作失败,通过自旋的方式等待并再次尝试,直到成功
-
因为没有加锁,所以线程不会陷入阻塞,效率较高
-
如果竞争激烈,重试频繁发生,效率会受影响
CAS 底层实现
CAS 底层依赖于一个 Unsafe 类
来直接调用操作系统底层的 CAS 指令
都是native修饰的方法,由系统提供的接口执行,并非java代码实现,一般的思路也都是自旋锁实现
在java中比较常见使用有很多,比如ReentrantLock
和Atomic开头的线程安全类
,都调用了Unsafe中的方法
我们看一段ReentrantLock中的CAS代码
CAS 你知道吗?
-
CAS的全称是: Compare And Swap(比较再交换);它体现的一种乐观锁的思想,在无锁状态下保证线程操作数据的原子性。
-
CAS使用到的地方很多:AQS框架、AtomicXXX类
-
在操作共享变量的时候使用的
自旋锁
,效率上更高一些 -
CAS的底层是调用的Unsafe类中的方法,都是操作系统提供的,其他语言实现
2.5、乐观锁和悲观锁
CAS 是基于乐观锁的思想:最乐观的估计,不怕别的线程来修改共享变量,就算改了也没关系,我吃亏点再重试呗。
synchronized 是基于悲观锁的思想:最悲观的估计,得防着其它线程来修改共享变量,我上了锁你们都别想改,我改完了解开锁,你们才有机会。
2.6、volatile关键字
请谈谈你对 volatile 的理解?
一旦一个共享变量(类的成员变量、类的静态成员变量)被volatile修饰之后,那么就具备了两层语义:
① 保证线程间的可见性
用 volatile 修饰共享变量,能够防止编译器等优化发生,让一个线程对共享变量的修改对另一个线程可见
在JVM虚拟机中有一个JIT(即时编译器)给代码做了优化
JIT编译器认为,这个循环的条件多次满足,那我就将条件改为true,让它一直执行下去
解决方案:
- 在程序运行的时候加入vm参数
-Xint
表示禁用即时编译器,不推荐 - 在修饰stop变量的时候加上volatile,当前告诉 JIT,不要对 volatile 修饰的变量做优化
② 禁止进行指令重排序
用 volatile 修饰共享变量会在读、写共享变量时加入不同的屏障,阻止其他读写操作越过屏障,从而达到阻止重排序的效果
我们看下面这个栗子:有哪几种执行情况呢?
在变量上添加volatile,禁止指令重排序,则可以解决问题
volatile使用技巧:
-
写变量让volatile修饰的变量在代码最后位置
-
读变量让volatile修饰的变量的在代码最开始位置
请谈谈你对 volatile 的理解
①保证线程间的可见性
用 volatile 修饰共享变量,能够防止编译器等优化发生,让一个线程对共享变量的修改对另一个线程可见
② 禁止进行指令重排序
指令重排:用 volatile 修饰共享变量会在读、写共享变量时加入不同的屏障,阻止其他读写操作越过屏障,从而达到阻止重排序的效果
2.7、AQS
什么是AQS?
全称是 AbstractQueuedSynchronizer,即抽象队列同步器。它是构建锁或者其他同步组件的基础框架
AQS与Synchronized的区别
synchronized | AQS |
---|---|
关键字,c++ 语言实现 | java 语言实现 |
悲观锁,自动释放锁 | 悲观锁,手动开启和关闭 |
锁竞争激烈都是重量级锁,性能差 | 锁竞争激烈的情况下,提供了多种解决方案 |
AQS常见的实现类
-
ReentrantLock 阻塞式锁
-
Semaphore 信号量
-
CountDownLatch 倒计时锁
AQS-基本工作机制
- 在AQS中维护了一个使用了volatile修饰的
state属性
来表示资源的状态,0表示无锁,1表示有锁 - 提供了基于
FIFO 的等待队列
,类似于 Monitor 的 EntryList -
条件变量来实现等待、唤醒机制,支持多个条件变量
,类似于 Monitor 的 WaitSet
流程是什么呢?
- 线程0来了以后,去尝试修改state属性,如果发现state属性是0,就修改state状态为1,表示线程0抢锁成功
- 线程1和线程2也会先尝试修改state属性,发现state的值已经是1了,有其他线程持有锁,它们都会到FIFO队列中进行等待,
- FIFO是一个双向队列,head属性表示头结点,tail表示尾结点
多个线程共同去抢这个资源是如何保证原子性的呢?
在去修改state状态的时候,使用的cas自旋锁来保证原子性,确保只能有一个线程修改成功,修改失败的线程将会进入FIFO队列中等待
AQS是公平锁,还是非公平锁?
- 新的线程与队列中的线程共同来抢资源,是非公平锁
- 新的线程到队列中等待,只让队列中的
head线程获取锁
,是公平锁 - 比较典型的AQS实现类ReentrantLock,它默认就是非公平锁,新的线程与队列中的线程共同来抢资源
什么是AQS?
- 是多线程中的队列同步器,是一种锁机制,它是作为一个基础框架使用的,像ReentrantLock,Semaphore都是基于AQS实现的
- AQS内部维护了一个先进先出的双向队列,队列中存储排队的线程
- 再AQS内部还有一个属性state,这个state就相当于是一个资源,默认是0(无锁状态),如果队列中有一个线程修改成功了state为1,则当前线程就相当于取得了资源
- 在对state修改的时候使用的CAS操作,保证多个线程修改的情况下原子性
2.8、ReentrantLock的实现原理
ReentrantLock翻译过来是可重入锁,相对于synchronized它具备以下特点:
- 可中断
- 可以设置超时时间
- 可以设置公平锁
- 支持多个条件变量
- 与synchronized一样,都支持重入
ReentrantLock主要利用CAS+AQS队列
来实现。
它支持公平锁和非公平锁,两者的实现类似,构造方法接受一个可选的公平参数(默认非公平锁
),当设置为true时,表示公平锁,否则为非公平锁。
公平锁的效率往往没有非公平锁的效率高,在多线程访问的情况下,公平锁表现出较低的吞吐量。
查看ReentrantLock源码中的构造方法:
提供了两个构造方法,不带参数的默认为非公平
如果使用带参数的构造函数,并且传的值为true,则是公平锁
其中NonfairSync和FairSync这两个类父类都是Sync
而Sync的父类是AQS
,所以可以得出ReentrantLock底层主要实现就是基于AQS来实现的
ReentrantLock的实现原理
- 线程来抢锁后使用cas的方式修改state状态,修改状态成功为1,则让
exclusiveOwnerThread
属性指向当前线程,获取锁成功 - 假如修改状态失败,则会进入双向队列中等待,head指向双向队列头部,tail指向双向队列尾部
- 当exclusiveOwnerThread为null的时候,则会唤醒在双向队列中等待的线程
- 公平锁则体现在按照先后顺序获取锁,非公平体现在不在排队的线程也可以抢锁
ReentrantLock的实现原理
- ReentrantLock表示支持重新进入的锁,调用 lock 方法获取了锁之后,再次调用 lock,是不会再阻塞
- ReentrantLock主要利用
CAS+AQS队列
来实现 - 支持公平锁和非公平锁,在提供的构造器的中无参默认是非公平锁,也可以传参设置为公平锁
synchronized和Lock有什么区别 ?
语法层面:
- synchronized是关键字,由 Jvm提供,C++语言实现
- Lock是接口,由 Jdk提供,Java语言实现
- 使用synchronized时,退出同步代码块会自动释放锁
- 而使用Lock时,需要手动调用unlock方法释放锁
功能层面:
- 都属于悲观锁、支持
互斥、同步、锁重入
- Lock提供了许多synchronized不具备的功能,例如
公平锁、可打断、可超时、多条件变量
- Lock有适合不同场景的实现,如ReentrantLock、ReentrantReadWriteLock
性能层面:
- 在没有竞争时,对synchronized做了很多优化,如偏向锁、轻量级锁,性能很好
- 在竞争激烈时,Lock的实现通常会提供更好的性能
2.9、死锁
死锁产生的条件是什么?
死锁:一个线程需要同时获取多把锁,这时就容易发生死锁
看下面的栗子:
public class Deadlock {
public static void main(String[] args) {
Object A = new Object();
Object B = new Object();
Thread t1 = new Thread(() -> {
synchronized (A) {
System.out.println(Thread.currentThread().getName()+"-lock A");
try {
sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (B) {
System.out.println(Thread.currentThread().getName()+"-lock B");
System.out.println(Thread.currentThread().getName()+"-操作...");
}
}
}, "t1");
Thread t2 = new Thread(() -> {
synchronized (B) {
System.out.println(Thread.currentThread().getName()+"-lock B");
try {
sleep(500);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (A) {
System.out.println(Thread.currentThread().getName()+"-lock A");
System.out.println(Thread.currentThread().getName()+"-操作...");
}
}
}, "t2");
t1.start();
t2.start();
}
/**
* t1-lock A
* t2-lock B
*/
}
有两个线程,线程1拥有A锁,想去获取B锁;线程2拥有B锁,想获取A锁;两个线程互相持有对方想要的资源,这就造成了死锁。
如何进行死锁诊断?
当程序出现了死锁现象,我们可以使用jdk自带的工具:jps和 jstack
-
jps:输出JVM中运行的
进程状态信息
-
jstack:查看java进程内
线程的堆栈信息
第一步:查看运行的线程
第二步:使用jstack查看线程运行的情况
其他解决工具,可视化工具
jconsole
-
用于对jvm的内存,线程,类 的监控,是一个基于 jmx 的 GUI 性能监控工具
-
打开方式:java 安装目录 bin目录下 直接启动 jconsole.exe 就行
VisualVM
:故障处理工具
-
能够监控线程,内存情况,查看方法的CPU时间和内存中的对 象,已被GC的对象,反向查看分配的堆栈
-
打开方式:java 安装目录 bin目录下 直接启动 jvisualvm.exe就行
死锁产生的条件是什么?
一个线程需要同时获取多把锁,这时就容易发生死锁
如何进行死锁诊断?
-
当程序出现了死锁现象,我们可以使用jdk自带的工具:jps和 jstack
-
jps:输出JVM中运行的进程状态信息
-
jstack:查看java进程内线程的堆栈信息,查看日志,检查是否有死锁
如果有死锁现象,需要查看具体代码分析后,可修复
-
-
可视化工具jconsole、VisualVM也可以检查死锁问题
2.10、ConcurrentHashMap
ConcurrentHashMap 是一种线程安全的高效Map集合
底层数据结构:
-
JDK1.7底层采用分段的数组+链表实现
-
JDK1.8 采用的数据结构跟HashMap1.8的结构一样,数组+链表+红黑树。
数据结构:
- 提供了一个
segment数组
,在初始化ConcurrentHashMap 的时候可以指定数组的长度,默认是16,一旦初始化之后中间不可扩容
- 在每个segment中都可以挂一个
HashEntry
数组,数组里面可以存储具体的元素,HashEntry数组是可以扩容
的 - 在HashEntry存储的数组中存储的元素,如果发生冲突,则可以
挂单向链表
存储流程:
- 先去计算key的hash值,然后确定segment数组下标
- 再通过hash值确定hashEntry数组中的下标存储数据
- 在进行操作数据的之前,会先判断当前segment对应下标位置是否有线程进行
操作,为了线程安全使用的是ReentrantLock进行加锁,如果获取锁会使
用cas自旋锁进行尝试
聊一下ConcurrentHashMap
-
底层数据结构:
- JDK1.7底层采用分段的数组+链表实现
- JDK1.8 采用的数据结构跟HashMap1.8的结构一样,数组+链表+红黑二叉树
-
加锁的方式
- JDK1.7采用
Segment分段锁
,底层使用的是ReentrantLock - JDK1.8采用CAS添加新节点,采用synchronized锁定链表或红黑二叉树的首节点,相对Segment分段锁粒度更细,性能更好
- JDK1.7采用
2.11、导致并发程序出现问题的根本原因
Java并发编程三大特性
- 原子性
- 可见性
- 有序性
原子性:一个线程在CPU中操作不可暂停,也不可中断,不能被其他线程干扰
比如,如下代码能保证原子性吗?
以上代码会出现超卖或者是一张票卖给同一个人,执行并不是原子性的
不是原子操作,怎么保证原子操作呢?
-
synchronized:同步加锁
-
JUC里面的lock:加锁
可见性:让一个线程对共享变量的修改对另一个线程可见
解决方案:volatile修饰共享变量
有序性:
指令重排,处理器为了提高程序运行效率,可能会对代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的一致,但它保证执行结果一致
禁止指令重排:用volatile修饰变量
Java程序中怎么保证多线程的执行安全?
-
原子性 synchronized、lock
-
内存可见性 volatile、synchronized、lock
-
有序性 volatile
3、线程池
3.1、线程池的核心参数
说一下线程池的核心参数
看一下源码:
-
corePoolSize
核心线程数目 -
maximumPoolSize
最大线程数目 = (核心线程+救急线程的最大数目) -
keepAliveTime
生存时间 - 救急线程的生存时间,生存时间内没有新任务,此线程资源会释放 -
unit
时间单位 - 救急线程的生存时间单位,如秒、毫秒等 -
workQueue
当没有空闲核心线程时,新来任务会加入到此队列排队,队列满会创建救急线程执行任务 -
threadFactory
线程工厂 - 可以定制线程对象的创建,例如设置线程名字、是否是守护线程等 -
handler
拒绝策略 - 当所有线程都在繁忙,workQueue 也放满时,会触发拒绝策略
3.2、线程池的执行原理
工作流程:
-
任务在提交的时候,首先判断
核心线程数
是否已满,如果没有满则直接添加到工作线程执行 -
如果核心线程数满了,则判断
阻塞队列
是否已满,如果没有满,当前任务存入阻塞队列 -
如果阻塞队列也满了,则判断
线程数是否小于最大线程数
,如果满足条件,则使用临时线程执行任务如果核心或临时线程执行完成任务后会检查阻塞队列中是否有需要执行的线程,如果有,则使用非核心线程执行任务
-
如果所有线程都在忙着(核心线程+临时线程),则走拒绝策略
拒绝策略:
-
AbortPolicy:直接抛出异常,默认策略;
-
CallerRunsPolicy:用调用者所在的线程来执行任务;
-
DiscardOldestPolicy:丢弃阻塞队列中靠最前的任务,并执行当前任务;
-
DiscardPolicy:直接丢弃任务;
3.3、常见的阻塞队列
线程池中有哪些常见的阻塞队列?
workQueue - 当没有空闲核心线程时,新来任务会加入到此队列排队,队列满会创建救急线程
执行任务
-
ArrayBlockingQueue
:基于数组结构的有界阻塞队列,FIFO。 -
LinkedBlockingQueue
:基于链表结构的有界阻塞队列,FIFO。 -
DelayedWorkQueue
:是一个优先级队列,它可以保证每次出队的任务都是当前队列中执行时间最靠前的 -
SynchronousQueue
:不存储元素的阻塞队列,每个插入操作都必须等待一个移出操作。
ArrayBlockingQueue的LinkedBlockingQueue区别
LinkedBlockingQueue | ArrayBlockingQueue |
---|---|
默认无界,支持有界 | 强制有界 |
底层是链表 | 底层是数组 |
是懒惰的,创建节点的时候添加数据 | 提前初始化 Node 数组 |
入队会生成新 Node | Node需要是提前创建好的 |
两把锁(头尾) | 一把锁 |
3.4、核心线程数
-
IO密集型任务.
一般来说:文件读写、DB读写、网络请求等 核心线程数大小设置为
2n+1
-
CPU密集型.
一般来说:计算型代码、Bitmap转换、Gson转换等 核心线程数
n+1
查看CPU核数:
public static void main(String[] args) {
//查看机器的CPU核数
System.out.println(Runtime.getRuntime().availableProcessors());
}
如何确定核心线程数?
① 高并发、任务执行时间短 ( CPU核数+1 ),减少线程上下文的切换
② 并发不高、任务执行时间长
-
IO密集型的任务 (CPU核数 * 2 + 1)
-
计算密集型任务 ( CPU核数+1 )
③ 并发高、业务执行时间长,解决这种类型任务的关键不在于线程池而在于整体架构的设计,看看这些业务里面某些数据是否能做缓存是第一步,增加服务器是第二步,至于线程池的设置,设置参考②
3.5、线程池的种类
线程池的种类有哪些?
在java.util.concurrent.Executors类中提供了大量创建连接池的静态方法,常见就有四种
-
创建使用固定线程数的线程池
-
核心线程数与最大线程数一样,没有救急线程
-
阻塞队列是
LinkedBlockingQueue
,最大容量为Integer.MAX_VALUE
-
适用场景:适用于任务量已知,相对耗时的任务
-
案例:
public class FixedThreadPoolCase { static class FixedThreadDemo implements Runnable{ @Override public void run() { String name = Thread.currentThread().getName(); for (int i = 0; i < 2; i++) { System.out.println(name + ":" + i); } } } public static void main(String[] args) throws InterruptedException { //创建一个固定大小的线程池,核心线程数和最大线程数都是3 ExecutorService executorService = Executors.newFixedThreadPool(3); for (int i = 0; i < 5; i++) { executorService.submit(new FixedThreadDemo()); Thread.sleep(10); } executorService.shutdown(); } }
-
-
单线程化的线程池,它只会用唯一的工作线程来执行任 务,保证所有任务按照指定顺序(FIFO)执行
-
核心线程数和最大线程数都是1
-
阻塞队列是
LinkedBlockingQueue
,最大容量为Integer.MAX_VALUE -
适用场景:适用于按照顺序执行的任务
-
案例:
public class NewSingleThreadCase { static int count = 0; static class Demo implements Runnable { @Override public void run() { count++; System.out.println(Thread.currentThread().getName() + ":" + count); } } public static void main(String[] args) throws InterruptedException { //单个线程池,核心线程数和最大线程数都是1 ExecutorService exec = Executors.newSingleThreadExecutor(); for (int i = 0; i < 10; i++) { exec.execute(new Demo()); Thread.sleep(5); } exec.shutdown(); } }
-
-
可缓存线程池
-
核心线程数为0
-
最大线程数是Integer.MAX_VALUE
-
阻塞队列为
SynchronousQueue
:不存储元素的阻塞队列,每个插入操作都必须等待一个移出操作。 -
使用场景:适合任务数比较密集,但每个任务执行时间较短的情况
-
案例:
public class CachedThreadPoolCase { static class Demo implements Runnable { @Override public void run() { String name = Thread.currentThread().getName(); try { //修改睡眠时间,模拟线程执行需要花费的时间 Thread.sleep(100); System.out.println(name + "执行完了"); } catch (InterruptedException e) { e.printStackTrace(); } } } public static void main(String[] args) throws InterruptedException { //创建一个缓存的线程,没有核心线程数,最大线程数为Integer.MAX_VALUE ExecutorService exec = Executors.newCachedThreadPool(); for (int i = 0; i < 10; i++) { exec.execute(new Demo()); Thread.sleep(1); } exec.shutdown(); } }
-
-
提供了“延迟”和“周期执行”功能的ThreadPoolExecutor。
public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) { return new ScheduledThreadPoolExecutor(corePoolSize); } public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize, ThreadFactory threadFactory) { return new ScheduledThreadPoolExecutor(corePoolSize, threadFactory); }
-
适用场景:有
定时和延迟执行
的任务 -
案例:
public class ScheduledThreadPoolCase { static class Task implements Runnable { @Override public void run() { try { String name = Thread.currentThread().getName(); System.out.println(name + ", 开始:" + new Date()); Thread.sleep(1000); System.out.println(name + ", 结束:" + new Date()); } catch (InterruptedException e) { e.printStackTrace(); } } } public static void main(String[] args) throws InterruptedException { //按照周期执行的线程池,核心线程数为2,最大线程数为Integer.MAX_VALUE ScheduledExecutorService scheduledThreadPool = Executors.newScheduledThreadPool(2); System.out.println("程序开始:" + new Date()); /** * schedule 提交任务到线程池中 * 第一个参数:提交的任务 * 第二个参数:任务执行的延迟时间 * 第三个参数:时间单位 */ scheduledThreadPool.schedule(new Task(), 0, TimeUnit.SECONDS); scheduledThreadPool.schedule(new Task(), 1, TimeUnit.SECONDS); scheduledThreadPool.schedule(new Task(), 5, TimeUnit.SECONDS); Thread.sleep(5000); // 关闭线程池 scheduledThreadPool.shutdown(); } }
-
线程池的种类有哪些?
①newFixedThreadPool:创建一个固定大小的线程池,可控制线程最大并发数,超出的线程会在队列中等待
②newSingleThreadExecutor:创建一个单线程化的线程池,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序(FIFO)执行
③newCachedThreadPool:创建一个可缓存线程池,如果线程池长度超过处理需要,可灵活回收空闲线程,若无可回收,则新建线程
④newScheduledThreadPool:可以执行延迟任务的线程池,支持定时及周期性任务执行
为什么不建议用Executors创建线程池
参考阿里开发手册《Java开发手册-嵩山版》
4、线程池使用场景
4.1、CountDownLatch
CountDownLatch(闭锁/倒计时锁)用来进行线程同步协作,等待所有线程完成倒计时
(一个或者多个线程,等待其他多个线程完成某件事情之后才能执行)
-
其中构造参数用来
初始化
等待计数值 -
await() 用来等待
计数归零
-
countDown() 用来让
计数减一
4.2、多线程使用场景
多线程使用场景一( es数据批量导入)
在我们项目上线之前,我们需要把数据库中的数据一次性的同步到es索引库中
但是当时的数据好像是1000万左右,一次性读取数据肯定不行(oom异常)
当时我就想到可以使用线程池的方式导入,利用CountDownLatch来控制,就能避免一次性加载过多,防止内存溢出
我们看一下整个流程:
多线程使用场景二(数据汇总)
在实际开发的过程中,难免需要调用多个接口来汇总数据
如果所有接口(或部分接口)的没有依赖关系,就可以使用线程池+future
来提升性能
多线程使用场景三(异步调用)
在进行搜索的时候,需要保存用户的搜索记录,而搜索记录不能影响用户的正常搜索,我们通常会在线程池中获取一个新线程去执行历史记录的保存
你们项目哪里用到了多线程
-
批量导入:使用了线程池+CountDownLatch批量把数据库中的数据导入到了ES中,避免OOM
-
数据汇总:调用多个接口来汇总数据,如果所有接口(或部分接口)都没有依赖关系,就可以使用线程池+future来提升性能
-
异步线程(线程池):为了避免下一级方法影响上一级方法(性能考虑),可使用异步线程调用下一个方法(不需要下一级方法返回值),可以提升方法响应时间
4.3、Semaphore
Semaphore 信号量,是JUC包下的一个工具类,底层是AQS,我们可以通过其限制执行的线程数量
使用场景:
通常用于那些资源有明确访问数量限制的场景,常用于限流
。
Semaphore使用步骤:
-
创建Semaphore对象,可以给一个容量
-
semaphore.acquire(): 请求一个信号量,这时候的信号量个数-1(一旦没有可使用的信号量,也即信号量个数变为负数时,再次请求的时候就会阻塞,直到其他线程释放了信号量)
-
semaphore.release():释放一个信号量,此时信号量个数+1
public class SemaphoreCase {
public static void main(String[] args) {
// 1. 创建 semaphore 对象
Semaphore semaphore = new Semaphore(3);
// 2. 10个线程同时运行
for (int i = 0; i < 10; i++) {
new Thread(() -> {
try {
// 3. 获取许可,计数-1
semaphore.acquire();
} catch (InterruptedException e) {
e.printStackTrace();
}
try {
System.out.println("running...");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("end...");
} finally {
// 4. 释放许可 计数+1
semaphore.release();
}
}).start();
}
}
}
如何控制某个方法允许并发访问线程的数量
在多线程中提供了一个工具类Semaphore,信号量。在并发的情况下,可以控制方法的访问量
1.创建Semaphore对象,可以给一个容量
2.acquire()可以请求一个信号量,这时候的信号量个数-1
3.release()释放一个信号量,此时信号量个数+1
4.4、ThreadLocal
ThreadLocal是多线程中对于解决线程安全
的一个操作类,它会为每个线程都分配一个独立的线程副本
从而解决了变量并发访问冲突的问题。ThreadLocal 同时实现了线程内的资源共享
案例:使用JDBC操作数据库时,会将每一个线程的Connection放入各自的ThreadLocal中,从而保证每个线程都在各自的 Connection 上进行数据库的操作,避免A线程关闭了B线程的连接。
ThreadLocal基本使用
-
set(value) 设置值
-
get() 获取值
-
remove() 清除值
public class ThreadLocalTest {
static ThreadLocal<String> threadLocal = new ThreadLocal<>();
public static void main(String[] args) {
new Thread(() -> {
String name = Thread.currentThread().getName();
threadLocal.set("itcast");
print(name);
System.out.println(name + "-after remove : " + threadLocal.get());
}, "t1").start();
new Thread(() -> {
String name = Thread.currentThread().getName();
threadLocal.set("itheima");
print(name);
System.out.println(name + "-after remove : " + threadLocal.get());
}, "t2").start();
}
static void print(String str) {
//打印当前线程中本地内存中本地变量的值
System.out.println(str + " :" + threadLocal.get());
//清除本地内存中的本地变量
threadLocal.remove();
}
/**
* t1 :itcast
* t2 :itheima
* t1-after remove : null
* t2-after remove : null
*
* 进程已结束,退出代码0
*/
}
ThreadLocal的实现原理&源码解析
ThreadLocal本质来说就是一个线程内部存储类,从而让多个线程只操作自己内部的值,从而实现线程数据隔离
你知道ThreadLocal的内存泄露问题吗?
Java对象中的四种引用类型:强引用、软引用、弱引用、虚引用
每一个Thread维护一个ThreadLocalMap,在ThreadLocalMap中的Entry对象继承了WeakReference。其中key为使用弱引用的ThreadLocal实例,value为线程变量的副本
在使用ThreadLocal的时候,强烈建议:务必手动remove
谈谈你对ThreadLocal的理解
-
ThreadLocal 可以实现【资源对象】的线程隔离,让每个线程各用各的【资源对象】,避免争用引发的线程安全问题
-
ThreadLocal 同时实现了线程内的资源共享
-
每个线程内有一个 ThreadLocalMap 类型的成员变量,用来存储资源对象
- 调用 set 方法,就是以 ThreadLocal 自己作为 key,资源对象作为 value,放入当前线程的 ThreadLocalMap 集合中
- 调用 get 方法,就是以 ThreadLocal 自己作为 key,到当前线程中查找关联的资源值
- 调用 remove 方法,就是以 ThreadLocal 自己作为 key,移除当前线程关联的资源值
-
ThreadLocal内存泄漏问题
ThreadLocalMap 中的 key 是弱引用,值为强引用; key 会被GC 释放内存,关联 value 的内存并不会释放。建议主动 remove 释放 key,value
5、面试快速答法
5.1 线程的基础知识
面试官:聊一下并行和并发有什么区别?
候选人:
是这样的~~
现在都是多核CPU,在多核CPU下
并发是同一时间应对多件事情的能力,多个线程轮流使用一个或多个CPU
并行是同一时间动手做多件事情的能力,4核CPU同时执行4个线程
面试官:说一下线程和进程的区别?
候选人:
嗯,好~
- 进程是正在运行程序的实例,进程中包含了线程,每个线程执行不同的任务
- 不同的进程使用不同的内存空间,在当前进程下的所有线程可以共享内存空间
- 线程更轻量,线程上下文切换成本一般上要比进程上下文切换低(上下文切换指的是从一个线程切换到另一个线程)
面试官:如果在java中创建线程有哪些方式?
候选人:
在java中一共有四种常见的创建方式,分别是:继承Thread类、实现runnable接口、实现Callable接口、线程池创建线程。通常情况下,我们项目中都会采用线程池的方式创建线程。
面试官:好的,刚才你说的runnable 和 callable 两个接口创建线程有什么不同呢?
候选人:
是这样的~
最主要的两个线程一个是有返回值,一个是没有返回值的。
Runnable 接口run方法无返回值;Callable接口call方法有返回值,是个泛型,和Future、FutureTask配合可以用来获取异步执行的结果
还有一个就是,他们异常处理也不一样。Runnable接口run方法只能抛出运行时异常,也无法捕获处理;Callable接口call方法允许抛出异常,可以获取异常信息
在实际开发中,如果需要拿到执行的结果,需要使用Callalbe接口创建线程,调用FutureTask.get()得到可以得到返回值,此方法会阻塞主进程的继续往下执行,如果不调用不会阻塞。
面试官:线程包括哪些状态,状态之间是如何变化的?
候选人:
在JDK中的Thread类中的枚举State里面定义了6中线程的状态分别是:新建、可运行、终结、阻塞、等待和有时限等待六种。
关于线程的状态切换情况比较多。我分别介绍一下
当一个线程对象被创建,但还未调用 start 方法时处于新建状态,调用了 start 方法,就会由新建进入可运行状态。如果线程内代码已经执行完毕,由可运行进入终结状态。当然这些是一个线程正常执行情况。
如果线程获取锁失败后,由可运行进入 Monitor 的阻塞队列阻塞,只有当持锁线程释放锁时,会按照一定规则唤醒阻塞队列中的阻塞线程,唤醒后的线程进入可运行状态
如果线程获取锁成功后,但由于条件不满足,调用了 wait() 方法,此时从可运行状态释放锁等待状态,当其它持锁线程调用 notify() 或 notifyAll() 方法,会恢复为可运行状态
还有一种情况是调用 sleep(long) 方法也会从可运行状态进入有时限等待状态,不需要主动唤醒,超时时间到自然恢复为可运行状态
面试官:嗯,好的,刚才你说的线程中的 wait 和 sleep方法有什么不同呢?
候选人:
它们两个的相同点是都可以让当前线程暂时放弃 CPU 的使用权,进入阻塞状态。
不同点主要有三个方面:
第一:方法归属不同
sleep(long) 是 Thread 的静态方法。而 wait(),是 Object 的成员方法,每个对象都有
第二:线程醒来时机不同
线程执行 sleep(long) 会在等待相应毫秒后醒来,而 wait() 需要被 notify 唤醒,wait() 如果不唤醒就一直等下去
第三:锁特性不同
wait 方法的调用必须先获取 wait 对象的锁,而 sleep 则无此限制
wait 方法执行后会释放对象锁,允许其它线程获得该对象锁(相当于我放弃 cpu,但你们还可以用)
而 sleep 如果在 synchronized 代码块中执行,并不会释放对象锁(相当于我放弃 cpu,你们也用不了)
面试官:好的,我现在举一个场景,你来分析一下怎么做,新建 T1、T2、T3 三个线程,如何保证它们按顺序执行?
候选人:
嗯~~,我思考一下 (适当的思考或想一下属于正常情况,脱口而出反而太假[背诵痕迹])
可以这么做,在多线程中有多种方法让线程按特定顺序执行,可以用线程类的join()方法在一个线程中启动另一个线程,另外一个线程完成该线程继续执行。
比如说:
使用join方法,T3调用T2,T2调用T1,这样就能确保T1就会先完成而T3最后完成
面试官:在我们使用线程的过程中,有两个方法。线程的 run()和 start()有什么区别?
候选人:
start方法用来启动线程,通过该线程调用run方法执行run方法中所定义的逻辑代码。start方法只能被调用一次。run方法封装了要被线程执行的代码,可以被调用多次。
面试官:那如何停止一个正在运行的线程呢?
候选人:
有三种方式可以停止线程
第一:可以使用退出标志,使线程正常退出,也就是当run方法完成后线程终止,一般我们加一个标记
第二:可以使用线程的stop方法强行终止,不过一般不推荐,这个方法已作废
第三:可以使用线程的interrupt方法中断线程,内部其实也是使用中断标志来中断线程
我们项目中使用的话,建议使用第一种或第三种方式中断线程
5.2 线程中并发锁
面试官:讲一下synchronized关键字的底层原理?
候选人:
嗯~~好的,
synchronized 底层使用的JVM级别中的Monitor 来决定当前线程是否获得了锁,如果某一个线程获得了锁,在没有释放锁之前,其他线程是不能或得到锁的。synchronized 属于悲观锁。
synchronized 因为需要依赖于JVM级别的Monitor ,相对性能也比较低。
面试官:好的,你能具体说下Monitor 吗?
候选人:
monitor对象存在于每个Java对象的对象头中,synchronized 锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因
monitor内部维护了三个变量
WaitSet:保存处于Waiting状态的线程
EntryList:保存处于Blocked状态的线程
Owner:持有锁的线程
只有一个线程获取到的标志就是在monitor中设置成功了Owner,一个monitor中只能有一个Owner
在上锁的过程中,如果有其他线程也来抢锁,则进入EntryList 进行阻塞,当获得锁的线程执行完了,释放了锁,就会唤醒EntryList 中等待的线程竞争锁,竞争的时候是非公平的。
面试官:好的,那关于synchronized 的锁升级的情况了解吗?
候选人:
嗯,知道一些(要谦虚)
Java中的synchronized有偏向锁、轻量级锁、重量级锁三种形式,分别对应了锁只被一个线程持有、不同线程交替持有锁、多线程竞争锁三种情况。
重量级锁:底层使用的Monitor实现,里面涉及到了用户态和内核态的切换、进程的上下文切换,成本较高,性能比较低。
轻量级锁:线程加锁的时间是错开的(也就是没有竞争),可以使用轻量级锁来优化。轻量级修改了对象头的锁标志,相对重量级锁性能提升很多。每次修改都是CAS操作,保证原子性
偏向锁:一段很长的时间内都只被一个线程使用锁,可以使用了偏向锁,在第一次获得锁时,会有一个CAS操作,之后该线程再获取锁,只需要判断mark word中是否是自己的线程id即可,而不是开销相对较大的CAS命令
一旦锁发生了竞争,都会升级为重量级锁
面试官:好的,刚才你说了synchronized它在高并发量的情况下,性能不高,在项目该如何控制使用锁呢?
候选人:
嗯,其实,在高并发下,我们可以采用ReentrantLock来加锁。
面试官:嗯,那你说下ReentrantLock的使用方式和底层原理?
候选人:
好的,
ReentrantLock是一个可重入锁:,调用 lock 方 法获取了锁之后,再次调用 lock,是不会再阻塞,内部直接增加重入次数 就行了,标识这个线程已经重复获取一把锁而不需要等待锁的释放。
ReentrantLock是属于juc报下的类,属于api层面的锁,跟synchronized一样,都是悲观锁。通过lock()用来获取锁,unlock()释放锁。
它的底层实现原理主要利用CAS+AQS队列来实现。它支持公平锁和非公平锁,两者的实现类似
构造方法接受一个可选的公平参数(默认非公平锁),当设置为true时,表示公平锁,否则为非公平锁。公平锁的效率往往没有非公平锁的效率高。
面试官:好的,刚才你说了CAS和AQS,你能介绍一下吗?
候选人:
好的。
CAS的全称是: Compare And Swap(比较再交换);它体现的一种乐观锁的思想,在无锁状态下保证线程操作数据的原子性。
CAS使用到的地方很多:AQS框架、AtomicXXX类
在操作共享变量的时候使用的自旋锁,效率上更高一些
CAS的底层是调用的Unsafe类中的方法,都是操作系统提供的,其他语言实现
AQS的话,其实就一个jdk提供的类AbstractQueuedSynchronizer,是阻塞式锁和相关的同步器工具的框架。
内部有一个属性 state 属性来表示资源的状态,默认state等于0,表示没有获取锁,state等于1的时候才标明获取到了锁。通过cas 机制设置 state 状态
在它的内部还提供了基于 FIFO 的等待队列,是一个双向列表,其中
tail 指向队列最后一个元素
head 指向队列中最久的一个元素
其中我们刚刚聊的ReentrantLock底层的实现就是一个AQS。
面试官:synchronized和Lock有什么区别 ?
候选人:
嗯~~,好的,主要有三个方面不太一样
第一,语法层面
- synchronized 是关键字,源码在 jvm 中,用 c++ 语言实现,退出同步代码块锁会自动释放
- Lock 是接口,源码由 jdk 提供,用 java 语言实现,需要手动调用 unlock 方法释放锁
第二,功能层面
- 二者均属于悲观锁、都具备基本的互斥、同步、锁重入功能
- Lock 提供了许多 synchronized 不具备的功能,例如获取等待状态、公平锁、可打断、可超时、多条件变量,同时Lock 可以实现不同的场景,如 ReentrantLock, ReentrantReadWriteLock
第三,性能层面
- 在没有竞争时,synchronized 做了很多优化,如偏向锁、轻量级锁,性能不赖
- 在竞争激烈时,Lock 的实现通常会提供更好的性能
统合来看,需要根据不同的场景来选择不同的锁的使用。
面试官:死锁产生的条件是什么?
候选人:
嗯,是这样的,一个线程需要同时获取多把锁,这时就容易发生死锁,举个例子来说:
t1 线程获得A对象锁,接下来想获取B对象的锁
t2 线程获得B对象锁,接下来想获取A对象的锁
这个时候t1线程和t2线程都在互相等待对方的锁,就产生了死锁
面试官:那如果产出了这样的,如何进行死锁诊断?
候选人:
这个也很容易,我们只需要通过jdk自动的工具就能搞定
我们可以先通过jps来查看当前java程序运行的进程id
然后通过jstack来查看这个进程id,就能展示出来死锁的问题,并且,可以定位代码的具体行号范围,我们再去找到对应的代码进行排查就行了。
面试官:请谈谈你对 volatile 的理解
候选人:
嗯~~
volatile 是一个关键字,可以修饰类的成员变量、类的静态成员变量,主要有两个功能
第一:保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的,volatile关键字会强制将修改的值立即写入主存。
第二: 禁止进行指令重排序,可以保证代码执行有序性。底层实现原理是,添加了一个内存屏障,通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化
本文作者:接《集合相关面试题》
面试官:那你能聊一下ConcurrentHashMap的原理吗?
候选人:
嗯好的,
ConcurrentHashMap 是一种线程安全的高效Map集合,jdk1.7和1.8也做了很多调整。
- JDK1.7的底层采用是分段的数组+链表 实现
- JDK1.8 采用的数据结构跟HashMap1.8的结构一样,数组+链表/红黑二叉树。
在jdk1.7中 ConcurrentHashMap 里包含一个 Segment 数组。Segment 的结构和HashMap类似,是一 种数组和链表结构,一个 Segment 包含一个 HashEntry 数组,每个 HashEntry 是一个链表结构 的元素,每个 Segment 守护着一个HashEntry数组里的元素,当对 HashEntry 数组的数据进行修 改时,必须首先获得对应的 Segment的锁。
Segment 是一种可重入的锁 ReentrantLock,每个 Segment 守护一个HashEntry 数组里得元 素,当对 HashEntry 数组的数据进行修改时,必须首先获得对应的 Segment 锁
在jdk1.8中的ConcurrentHashMap 做了较大的优化,性能提升了不少。首先是它的数据结构与jdk1.8的hashMap数据结构完全一致。其次是放弃了Segment臃肿的设计,取而代之的是采用Node + CAS + Synchronized来保 证并发安全进行实现,synchronized只锁定当前链表或红黑二叉树的首节点,这样只要hash不冲 突,就不会产生并发 , 效率得到提升
5.3 线程池
面试官:线程池的种类有哪些?
候选人:
嗯!是这样
在jdk中默认提供了4中方式创建线程池
第一个是:newCachedThreadPool创建一个可缓存线程池,如果线程池长度超过处理需要,可灵活回 收空闲线程,若无可回收,则新建线程。
第二个是:newFixedThreadPool 创建一个定长线程池,可控制线程最大并发数,超出的线程会在队列 中等待。
第三个是:newScheduledThreadPool 创建一个定长线程池,支持定时及周期性任务执行。
第四个是:newSingleThreadExecutor 创建一个单线程化的线程池,它只会用唯一的工作线程来执行任 务,保证所有任务按照指定顺序(FIFO, LIFO, 优先级)执行。
面试官:线程池的核心参数有哪些?
候选人:
在线程池中一共有7个核心参数:
corePoolSize 核心线程数目 - 池中会保留的最多线程数
maximumPoolSize 最大线程数目 - 核心线程+救急线程的最大数目
keepAliveTime 生存时间 - 救急线程的生存时间,生存时间内没有新任务,此线程资源会释放
unit 时间单位 - 救急线程的生存时间单位,如秒、毫秒等
workQueue - 当没有空闲核心线程时,新来任务会加入到此队列排队,队列满会创建救急线程执行任务
threadFactory 线程工厂 - 可以定制线程对象的创建,例如设置线程名字、是否是守护线程等
handler 拒绝策略 - 当所有线程都在繁忙,workQueue 也放满时,会触发拒绝策略
在拒绝策略中又有4中拒绝策略
当线程数过多以后,第一种是抛异常、第二种是由调用者执行任务、第三是丢弃当前的任务,第四是丢弃最早排队任务。默认是直接抛异常。
面试官:如何确定核心线程池呢?
候选人:
是这样的,我们公司当时有一些规范,为了减少线程上下文的切换,要根据当时部署的服务器的CPU核数来决定,我们规则是:CPU核数+1就是最终的核心线程数。
面试官:线程池的执行原理知道吗?
候选人:
嗯~,它是这样的
首先判断线程池里的核心线程是否都在执行任务,如果不是则创建一个新的工作线程来执行任务。如果核心线程都在执行任务,则线程池判断工作队列是否已满,如果工作队列没有满,则将新提交的任务存储在这个工作队 列里。如果工作队列满了,则判断线程池里的线程是否都处于工作状态,如果没有,则创建一个新的工作线程来执行任 务。如果已经满了,则交给拒绝策略来处理这个任务。
面试官:为什么不建议使用Executors创建线程池呢?
候选人:
好的,其实这个事情在阿里提供的最新开发手册《Java开发手册-嵩山版》中也提到了
主要原因是如果使用Executors创建线程池的话,它允许的请求队列默认长度是Integer.MAX_VALUE,这样的话,有可能导致堆积大量的请求,从而导致OOM(内存溢出)。
所以,我们一般推荐使用ThreadPoolExecutor来创建线程池,这样可以明确规定线程池的参数,避免资源的耗尽。
5.4 线程使用场景问题
面试官:如果控制某一个方法允许并发访问线程的数量?
候选人:
嗯~~,我想一下
在jdk中提供了一个Semaphore[seməfɔːr]类(信号量)
它提供了两个方法,semaphore.acquire() 请求信号量,可以限制线程的个数,是一个正数,如果信号量是-1,就代表已经用完了信号量,其他线程需要阻塞了
第二个方法是semaphore.release(),代表是释放一个信号量,此时信号量的个数+1
面试官:好的,那该如何保证Java程序在多线程的情况下执行安全呢?
候选人:
嗯,刚才讲过了导致线程安全的原因,如果解决的话,jdk中也提供了很多的类帮助我们解决多线程安全的问题,比如:
- JDK Atomic开头的原子类、synchronized、LOCK,可以解决原子性问题
- synchronized、volatile、LOCK,可以解决可见性问题
- Happens-Before 规则可以解决有序性问题
面试官:你在项目中哪里用了多线程?
候选人:
嗯~~,我想一下当时的场景[根据自己简历上的模块设计多线程场景]
参考场景一:
es数据批量导入
在我们项目上线之前,我们需要把数据量的数据一次性的同步到es索引库中,但是当时的数据好像是1000万左右,一次性读取数据肯定不行(oom异常),如果分批执行的话,耗时也太久了。所以,当时我就想到可以使用线程池的方式导入,利用CountDownLatch+Future来控制,就能大大提升导入的时间。
参考场景二:
在我做那个xx电商网站的时候,里面有一个数据汇总的功能,在用户下单之后需要查询订单信息,也需要获得订单中的商品详细信息(可能是多个),还需要查看物流发货信息。因为它们三个对应的分别三个微服务,如果一个一个的操作的话,互相等待的时间比较长。所以,我当时就想到可以使用线程池,让多个线程同时处理,最终再汇总结果就可以了,当然里面需要用到Future来获取每个线程执行之后的结果才行
参考场景三:
《黑马头条》项目中使用的
我当时做了一个文章搜索的功能,用户输入关键字要搜索文章,同时需要保存用户的搜索记录(搜索历史),这块我设计的时候,为了不影响用户的正常搜索,我们采用的异步的方式进行保存的,为了提升性能,我们加入了线程池,也就说在调用异步方法的时候,直接从线程池中获取线程使用
5.5 其他
面试官:谈谈你对ThreadLocal的理解
候选人:
嗯,是这样的~~
ThreadLocal 主要功能有两个,第一个是可以实现资源对象的线程隔离,让每个线程各用各的资源对象,避免争用引发的线程安全问题,第二个是实现了线程内的资源共享
面试官:好的,那你知道ThreadLocal的底层原理实现吗?
候选人:
嗯,知道一些~
在ThreadLocal内部维护了一个一个 ThreadLocalMap 类型的成员变量,用来存储资源对象
当我们调用 set 方法,就是以 ThreadLocal 自己作为 key,资源对象作为 value,放入当前线程的 ThreadLocalMap 集合中
当调用 get 方法,就是以 ThreadLocal 自己作为 key,到当前线程中查找关联的资源值
当调用 remove 方法,就是以 ThreadLocal 自己作为 key,移除当前线程关联的资源值
面试官:好的,那关于ThreadLocal会导致内存溢出这个事情,了解吗?
候选人:
嗯,我之前看过源码,我想一下~~
是应为ThreadLocalMap 中的 key 被设计为弱引用,它是被动的被GC调用释放key,不过关键的是只有key可以得到内存释放,而value不会,因为value是一个强引用。文章来源:https://www.toymoban.com/news/detail-526153.html
在使用ThreadLocal 时都把它作为静态变量(即强引用),因此无法被动依靠 GC 回收,建议主动的remove 释放 key,这样就能避免内存溢出。文章来源地址https://www.toymoban.com/news/detail-526153.html
到了这里,关于多线程进阶学习(高并发、线程池、使用场景)的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!