剑指Java面试-Java 多线程与并发整理(不定期更新!)
文章目录
- 剑指Java面试-Java 多线程与并发整理(不定期更新!)
- 一、进程和线程的区别
-
- 1. 进程和线程的由来:
- 2. 进程和线程的区别
- 3. Java线程的进程的区别
- 二、Java多线程常见面试题
-
- 1. start()与run()的区别
- 2. Thread和Runnable的关系
- 3. 如何给run()方法传参
- 4. 如何处理线程的返回值
- 5. 线程的状态
- 6. sleep和wait的区别
- 7. notify()与notifyAll()的区别
-
- 锁池
- 等待池
- notify()与notifyAll()的区别
- 8. yield
- 9. interrupt 中断线程
- 10. 线程状态以及状态之间的转换
- 三、synchronized
-
- 1. 互斥锁的特性
- 2. 对象锁与类锁
-
- 3. synchronized底层实现原理
-
- 对象在内存中的布局
- 重入
- Monitor
- 早期的synchronized
- Java6之后的synchronized
- 4. 自旋锁与自适应自旋锁
-
- 5. 锁消除
- 6. 锁粗化
- 7. 锁的内存语义
- 8. synchronized的四种状态
-
- 偏向锁
- 轻量级锁
- 偏向锁、轻量级锁、重量级锁的汇总
- 四、synchronized和ReentrantLock的区别
-
- 1. ReentrantLock公平性的设置
- 2. ReentrantLock将锁对象化
- 3. synchronized和ReentrantLock的区别
- 五、Java内存模型JMM
-
- 1. JMM中的主内存
- 2. JMM中的工作内存
- 3.JMM与Java内存区域划分
- 4. 主内存与工作内存的数据存储类型以及操作方式归纳
- 5. JMM如何解决可见性问题
-
- 指令重排序
- happens-before原则
- volatile
-
- volatile变量为何立即可间?
- volatile如何禁止重排序优化?
- volatile和synchronized的区别
- 6. CAS(Compare and Swap)
- 六、Java线程池
-
- 1. Executors创建线程池
- 2. Fork/Join框架
- 3. 为什么要使用线程池
- 4. Executor框架
- 5. ThreadPoolExecutor
- 6. 线程池的状态
- 7. 线程池的大小如何选定
一、进程和线程的区别
1. 进程和线程的由来:
2. 进程和线程的区别
进程是资源分配的最小单位,线程是CPU调度的最小单位
- 所有与进程相关的资源,都被记录在PCB中
- 进程是抢占处理机的调度单位,线程属于某个进程,共享其资源
- 线程只由堆栈寄存器、程序计数器和TCB组成
总结:
- 线程不能看作独立应用,而进程可看作独立应用
- 进程有独立的地址空间,相互不影响,线程只是进程的不同执行路径
- 线程没有独立的地址空间,多进程的程序比多线程程序健壮
- 进程的切换比线程的切换开销大
3. Java线程的进程的区别
- Java对操作系统提供的功能进行封装,包括进程和线程
- 运行一个程序会产生一个进程,进程包含至少一个线程
- 每个进程对应一个JVM实例,多个线程共享JVM里的堆
- Java采用单线程编程模型,程序会自动创建主线程
- 主线程可以创建子线程,原则上要后于子线程完成执行
二、Java多线程常见面试题
1. start()与run()的区别
- 调用start()方法会创建一个新的子线程并启动
- run()方法只是Thread的一个普通方法的调用
2. Thread和Runnable的关系
- Runable接口并没有start方法,需要依赖Thread来启动
- Thread是实现了Runnable接口的类,使得run支持多线程
- 因类的单一继承原则,推荐多使用Runnable
3. 如何给run()方法传参
实现的方式主要有三种:
- 构造函数传参
- 成员变量传参
- 回调函数传参
4. 如何处理线程的返回值
实现的方式有三种:
- 主线程等待法
- 使用Thread类的join()阻塞当前线程以等待子线程处理完毕
- 通过Callable接口实现:通过FutureTask 或者线程池获取
5. 线程的状态
从源码和官方的说明里面,我们了解到Java中的线程主要有六个状态。
public enum State {
NEW,
RUNNABLE,
BLOCKED,
WAITING,
TIMED_WAITING,
TERMINATED;
}
- 新建(New):创建后尚未启动的线程的状态
- 运行(Runnable):包含Running和Ready
- 无限期等待(Waiting):不会被分配CPU执行事件,需要显示被唤醒
-
- 没有设置Timeout参数的Object.wait()方法
-
- 没有设置Timeout参数的Thread.join()方法
-
- 限期等待(Timed Waiting):在一定时间后会由系统自动唤醒
-
-
- 设置了Timeout参数的Object.wait()方法
-
- 设置了Timeout参数的Thread.join()方法
-
- LockSupport.parkNanos()方法
-
- LockSupport.parkUntil()方法
- 阻塞(Blocked):等待获取排他锁
- 结束(Terminated):已终止线程的状态,线程已经结束执行
6. sleep和wait的区别
基本的差别:
- sleep是Thread类的方法,wait是Object类中定义的方法
- sleep()方法可以在任何地方使用
- wait()方法只能在synchronized方法或synchronized块中使用
最主要的本质区别
- Thread.sleep只会让出CPU,不会导致锁行为的改变
- Object.wait不仅让出CPU,还会释放已经占有的同步资源锁
下面我们用代码测试一下:
public class WaitSleepDemo {
public static void main(String[] args) {
final Object lock = new Object();
new Thread(new Runnable() {
@Override
public void run() {
System.out.println("thread A is waiting to get lock");
synchronized (lock){
try {
System.out.println("thread A get lock");
Thread.sleep(20);
System.out.println("thread A do wait method");
lock.wait(1000);
System.out.println("thread A is done");
} catch (InterruptedException e){
e.printStackTrace();
}
}
}
}).start();
try{
Thread.sleep(5);
} catch (InterruptedException e){
e.printStackTrace();
}
new Thread(new Runnable() {
@Override
public void run() {
System.out.println("thread B is waiting to get lock");
synchronized (lock){
try {
System.out.println("thread B get lock");
System.out.println("thread B is sleeping 10 ms");
Thread.sleep(10);
System.out.println("thread B is done");
} catch (InterruptedException e){
e.printStackTrace();
}
}
}
}).start();
}
}
运行结果如下:
thread A is waiting to get lock
thread A get lock
thread B is waiting to get lock
thread A do wait method//Thread A执行wait方法释放锁
thread B get lock//然后Thread B获得到锁然后才可执行任务
thread B is sleeping 10 ms
thread B is done
thread A is done
7. notify()与notifyAll()的区别
先介绍俩个概念:
锁池
假设线程A已经拥有的某个对象(不是类)的锁,而其它线程B、C想要调用这个对象的某个synchronized方法(或者块),由于B、C线程在进入对象的synchronized方法(或者块)之前必须先获得该对象锁的拥有权,而恰巧该对象的锁目前正在被线程A所占用,此时B、C线程就会被阻塞,进入一个地方去等待锁的释放,这个地方便是该对象的锁池。
等待池
假设线程A调用了某个对象的wait()方法,线程A就会释放该对象的锁,同时线程A就进入到了该对象的等待池中,进入到等待池中的线程不会去竞争该对象的锁。
notify()与notifyAll()的区别
- notifyAll会让所有处于等待池中的线程全部进入锁池去竞争获取锁的机会
- notify只会随机选取一个处于等待池中的线程进入锁池去竞争获取锁的机会
8. yield
概念:当调用Thread.yield()函数时,会给线程调度器一个当前线程愿意让出CPU使用的暗示,但是线程调度器可能会忽略这个暗示
9. interrupt 中断线程
下面几个是已经被抛弃的方法,不建议使用:
- 通过调用stop()方法停止线程,太过暴力,且是线程不安全的,如果突然调用stop方法,线程内的清理工作可能还没做完,又或者突然调用了stop方法,迫使所持有的锁被释放,造成数据不同步的情况。
- 通过调用suspend()和resume()方法,同样也被废弃了
目前使用的方法:
调用interrupt(),通知线程应该中断了
- 如果线程处于被阻塞状态,那么线程将立即退出被阻塞状态,并抛出一个InterruptedException异常
- 如果线程处于正常活动状态,那么会将该线程的中断标志设置为true。被设置中断标志的线程将继续正常运行,不受影响。
需要被调用的线程配合中断
- 在正常运行任务时,经常检查本线程的中断标志位,如果被设置了中断标志就自行停止线程
- 如果线程处于正常活动状态,那么会将该线程的中断标志设置为true。被设置中断标志的线程将继续正常运行,不受影响。
10. 线程状态以及状态之间的转换
三、synchronized
synchronized锁的不是代码,锁的都是对象
线程安全问题的主要诱因
- 存在共享数据(也称临界资源)
- 存在多条线程共同操作这些共享数据
解决问题的根本方法:
同一时刻有且只有一个线程在操作共享数据,其他线程必须等到该线程处理完数据后再对共享数据进行操作
1. 互斥锁的特性
互斥性:即在同一时间只允许一个线程持有某个对象锁,通过这种特性来实现多线程的协调机制,这样在同一时间只有一个线程对需要同步的代码块进行访问。互斥性也称为操作的原子性。
可见性:必须确保在锁被释放之前,对共享变量所做的修改,对于随后获得该锁的另一个线程是可见的(即在获得锁时应获得最新共享变量的值),否则另一个线程可能是本地缓存的某个副本上继续操作,从而引起不一致
2. 对象锁与类锁
获取对象锁的俩种方法:
- 同步代码块(synchronized(this),synchronized(类实例对象)),锁是小括号()中的实例对象
- 同步非静态方法(synchronized method),锁是当前对象的实例对象
获取类锁的俩种用法:
- 同步代码块(synchronized(类.class)),锁是小括号()中的类对象(Class对象)
- 同步静态方法(synchronized static method),锁是当前对象的类对象(Class对象)
对象锁和类锁的总结
- 有线程访问对象的同步代码块时,另外的线程可以访问该对象的非同步代码块。
- 若锁住的是同一个对象,一个线程在访问对象的同步代码块时,另一个访问对象的同步代码块的线程会被阻塞;
- 若锁住的是同一个对象,一个线程在访问对象的同步方法时,另一个访问对象同步方法的线程会被阻塞;
- 若锁住的是同一个对象,一个线程在访问对象的同步代码块时,另一个访问对象同步方法的线程会被阻塞,反之亦然。
- 同一个类的不同对象的对象锁互不干扰。
- 类锁由于也是一种特殊的对象锁,因此表现和上述1,2,3,4一致,而由于一个类只有一把对象锁,所以同一个类的不同对象使用类锁将会是同步的;
- 类锁和对象锁互不干扰。
3. synchronized底层实现原理
实现synchronized的基础
对象在内存中的布局
对象头的结构
Mark Word
重入
什么是重入:
从互斥锁的设计上来说,当一个线程试图操作一个由其他线程持有的对象锁的临界资源时,将会处于阻塞状态,但是当一个线程再次请求自己持有锁的临界资源时,这种情况属于重入。
synchronized具有可重入性
Monitor
Monitor:每个Java对象天生自带了一把看不见的锁
Monitor锁的竞争、获取与释放:
由此看来Monitor对象存在于每个Java对象的对象头中,synchronized锁便是通过这种方式去获取锁的,这也是为什么Java种任意对象可以作为锁的原因。
早期的synchronized
- 早期版本种,synchronized属于重量级锁,依赖于Mutex Lock实现
- 线程之间的切换需要从用户态转换到核心态,开销较大
Java6之后的synchronized
Java6以后,synchronized性能得到了很大的提升
- Adaptive Spinning(自适应自旋锁)
- Lock Eliminate(锁消除)
- Lock Coarsening(锁粗化)
- Lightweight Locking(轻量级锁)
- Biased Locking(偏向锁)
- …
4. 自旋锁与自适应自旋锁
自旋锁
- 在许多情况下,共享数据的锁定状态持续时间较短,切换线程不值得
- 通过让线程执行忙于循环等待锁的释放,不让出CPU
缺点:
若锁被其他线程长时间占用,会带来许多性能上的开销
自适应自旋锁
- 自旋的次数不再固定
- 由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定
5. 锁消除
更彻底的优化
- JIT编译时,对运行上下文进行扫描,去除不可能存在竞争的锁
public class StringBufferWithoutSync {
public void add(String str1, String str2) {
//StringBuffer是线程安全,由于stringBuffer只会在append方法中使用,不可能被其他线程引用
//因此stringBuffer属于不可能共享的资源,JVM会自动消除内部的锁
StringBuffer stringBuffer = new StringBuffer();
stringBuffer.append(str1).append(str2);
}
public static void main(String[] args) {
StringBufferWithoutSync withoutSync = new StringBufferWithoutSync();
for (int i = 0; i < 1000; i++) {
withoutSync.add("aaa", "bbb");
}
}
}
6. 锁粗化
另一种极端
public static String copyString100Times(String target){
int i = 0;
StringBuffer stringBuffer = new StringBuffer();
while (i<100){
//像这种连续的append操作,JVM会检测到这样一连串同步锁的情况
//JVM就会将锁粗化到整个加锁操作的外部,使整个append操作只需要加一次锁
stringBuffer.append(target);
i++;
}
return stringBuffer.toString();
}
7. 锁的内存语义
当线程释放锁时,Java内存模型会把该线程对应的本地内存种的共享变量刷新到主内存中;
而当线程获取锁时,Java内存模型会把该线程对应的本地内存置为无效,从而使得被监视器保护的临界区代码必须从主内存中读取共享变量
8. synchronized的四种状态
锁膨胀方向(锁升级):无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁
偏向锁
**偏向锁:**减少同一线程获取锁的代价
- 大多数情况下,锁不存在多线程竞争,总是由同一线程多次获得
核心思想:
如果一个线程获得了锁,那么锁就进入了偏向模式,此时Mark Word的结构也变为了偏向锁结构,当线程再次请求锁时,无需在做任何同步操作,即获取锁的过程只需要检查Mark Word的锁标记位为偏向锁以及当前线程ID等于 Mark Word 的ThreadID即可,这样就省去了大量有关锁申请的操作。
不适合于锁竞争比较激烈的多线程场合
轻量级锁
轻量级锁是由偏向锁升级来的,偏向锁运行在一个线程进入同步块的情况下,当第二个线程加入锁争用的时候,偏向锁就会升级为轻量级锁。
使用的场景:线程交替执行同步块
若存在同一时间访问同一锁的情况,就会导致轻量级锁膨胀为重量级锁
这里实在没听明白,,,,,待以后回头再研究研究:
可以参考一下:https://blog.csdn.net/tongdanping/article/details/79647337
偏向锁、轻量级锁、重量级锁的汇总
四、synchronized和ReentrantLock的区别
ReentrantLock(再入锁,重入锁)
- 位于java.util.concurrent.locks包
- 和CountDownLatch、FutureTask、Semaphore一样基于AQS实现
- 能够实现比synchronized更细粒度的控制,如控制fairness
- 调用lock()之后,必须调用unlock()释放锁
- 性能未必比synchronized高,并且也是可重入的
1. ReentrantLock公平性的设置
- ReentrantLock fairLock = new ReentrantLock(true);
- 参数为true时,倾向于将锁赋予等待时间最久的线程
公平锁与非公平锁:
- 公平锁: 获取锁的顺序按先后调用lock方法的顺序(慎用)
- 非公平锁:抢占的顺序不一定,看运气
- synchronized 是非公平锁
2. ReentrantLock将锁对象化
- 判断是否有线程,或者某个特定线程,在排队等待获取锁
- 带超时的获取锁的尝试
- 感知有没有成功获取锁
3. synchronized和ReentrantLock的区别
总结:
- synchronized是关键字,ReentrantLock是类
- ReentrantLock可以对获取锁的等待时间进行设置,避免死锁
- ReentrantLock可以获取各种锁的信息
- ReentrantLock可以灵活地实现多路通知
- 机制:sync操作Mark Word,lock调用Unsafe类地park()方法
五、Java内存模型JMM
Java内存模型(即Java Memory Model,简称JMM本身是一种抽象的概念),并不真实存在,它描述的是一组规则或者规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式。
1. JMM中的主内存
- 存储Java实例对象
- 包括成员变量、类信息、常量、静态变量等
- 属于数据共享的区域,多线程并发操作时会引发线程安全问题
2. JMM中的工作内存
- 存储当前方法的所有本地变量信息,本地变量对其他线程不可见
- 字节码行号指示器、Native方法信息
- 属于线程私有数据区域,不存在线程安全问题
3.JMM与Java内存区域划分
JMM与Java内存区域划分是不同的概念层次
- JMM描述的是一组规则,围绕原子性、有序性、可见性展开
- 相似点:存在共享区域和私有区域
JMM与Java内存区域唯一相似点即都存在共享数据区域和私有数据区域,在JMM中主内存属于共享数据区域,从某个层次上讲应该包括了堆和方法区,而工作内存即线程私有数据区域从某个层次上讲则应该包括程序计数器、虚拟机栈以及本地方法栈。
4. 主内存与工作内存的数据存储类型以及操作方式归纳
- 方法里的基本数据类型本地变量将直接存储在工作内存的栈帧结构中
- 引用类型的本地变量:引用存储在工作内存中,实例存储在主内存中
- 成员变量、static变量、类信息均会被存储在主内存中
- 主内存共享的方式是线程各自拷贝一份数据到工作内存,操作完成后刷新回主内存
5. JMM如何解决可见性问题
指令重排序
指令重排序需要满足的条件
- 在单线程环境下不能改变程序运行的结果
- 存在数据依赖关系的不允许重排序
happens-before原则
无法通过happends-before原则推导出来的,才能进行指令的重排序。
A操作的结果需要对B操作可见,则A与B存在happens-before关系。
happens-before的八大原则:
- 程序次序原则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作
- 锁定规则:一个unLock操作先行发生于后面对同一个锁的lock操作
- volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作
- 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C
- 线程启动规则:Thread对象的start()方法先行发生于此线程的每一个动作
- 线程中断原则:对线程interrupt()方法的调用先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行
- 对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始
happens-before的概念
如果俩个操作不满足上述任意一个happens-before规则,那么这俩个操作就没有顺序的保障,JVM可以对这俩个操作进行重排序;
如果操作A happens-before 操作B,那么操作A在内存上所做的操作对操作B都是可见的。
volatile
volatile:JVM提供的轻量级同步机制
- 保证被volatile修饰的共享变量对所有线程总是可见的
- 禁止指令的重排序优化
volatile变量为何立即可间?
当写一个volatile变量时,JMM会把该线程对应的工作内存中的共享变量值刷新到主内存中;
当读取一个volatile变量时,JMM会把该线程对应的工作内存置为无效
volatile如何禁止重排序优化?
内存屏障(Memory Barrier):
- 保证特定操作的执行顺序
- 保证某些变量的内存可见性
volatile通过插入内存屏障指令禁止在内存屏障前后的指令执行重排序优化。
强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本
volatile和synchronized的区别
- volatile本质是在告诉JVM当前变量在寄存器(工作内存)中的值是不确定的,需要从主内存中读取;synchronized则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住直到该线程完成变量操作为止。
- volatile仅能使用在变量级别;synchronized则可以使用在变量、方法和类级别
- volatile仅能实现变量的修改可见性,不能保证原子性;而synchronized则可以保证变量修改的可见性和原子性
- volatile不会造成线程的阻塞;synchronized可能会造成线程的阻塞
- volatile标记的变量不会被编译器优化;synchronized标记的变量可以被编译器优化
6. CAS(Compare and Swap)
一种高效实现线程安全性的方法
- 支持原子更新操作,适用于计数器,序列发生器等场景
- 属于乐观锁机制,号称lock-free
- CAS操作失败时由开发者决定是继续尝试,还是执行别的操作
CAS思想:
包含三个操作数 ---- 内存位置(V)预期原指(A)和新值(B)
CAS多数情况下对开发者来说是透明的
- JUC的atomic包提供了常用的原子性数据类型以及引用、数组等相关原子类型和更新操作工具,是很多线程安全程序的首选
- Unsafe类虽然提供CAS服务,但因能够操纵任意内存地址读写而有隐患
- Java9之后,可以适用Variable Handle API来替代Unsafe
缺点:
- 若循环时间长,则开销很大
- 只能保证一个共享变量的原子操作
- ABA问题 解决:AtomicStampedReference
六、Java线程池
1. Executors创建线程池
利用Executors创建不同的线程池满足不同场景的需求
- newFixedTreadPool(int nThreads) 指定工作线程数量的线程池
- newCachedTreadPool()处理大量短时间工作任务的线程池
- 试图缓存线程并重用,当无缓存线程可用时,就会创建新的工作线程
- 如果线程闲置的时间超过阈值,则会被终止并移除缓存
- 系统长时间闲置的时候,不会消耗什么资源
- newSingleThreadExecutor() 创建唯一的工作者线程来执行任务,如果线程异常结束,会有另一个线程取代它
- newSingleThreadScheduleExecutor()与newScheduledThreadPool(int corePoolSize)定时或者周期性的工作调度,俩者的区别在于单一工作线程还是多个线程
- newWorkStealingPool()内部会构建ForkJoinPool,利用working-stealing算法,并行的处理任务,不保证处理顺序
2. Fork/Join框架
- 把大任务分割成若干个小任务并行执行,最终汇总每个小人物结果后得到大任务结果的框架
Work-Stealing算法:某个线程从其他队列里窃取任务来执行
3. 为什么要使用线程池
4. Executor框架
JUC中的三个Executor接口
- Executor:运行新任务的简单接口,将任务提交和任务执行细节解耦
- ExecutorService:具备管理执行器和任务生命周期的方法,提交任务机制更完善
- ScheduledExecutorService:支持Future和定期执行任务
5. ThreadPoolExecutor
ThreadPoolExecutor的构造函数:
- corePoolSize :核心线程数量
- maximumPoolSize:线程不够用时能够创建的最大线程数
- workQueue:任务等待队列
- keepAliveTime:抢占的顺序不一定,看运气
- threadFactory:创建新线程,Executors.defaultThreadFactory()
- handler :线程池的饱和策略
-
-
- CallerRunsPolicy:用调用者所在的线程来执行任务
-
- DiscardOldestPolicy:丢弃阻塞队列里最老的任务,队列里最靠前的任务
-
-
- 可根据实际需求,来实现自己的饱和策略,实现RejectedExecutionHandler接口即可
新任务提交execute执行后的判断:
- 如果运行的线程少于corePoolSize,则创建新线程来处理任务,即使线程池中的其他线程是空闲的
- 如果线程池中的线程数量大于等于corePoolSize且小于maximumPoolSize,则只有当workQueue满时才创建新的线程去处理任务
- 如果设置的corePoolSize和maximumPoolSize相同,则创建的线程池的大小是固定的,这时如果有新任务提交,若workQueue未满,则将请求放入workQueue中,等待有空闲的线程去从workQueue中取任务并处理
- 如果运行的线程数量大于等于maximumPoolSize,这时如果workQueue已经满了,则通过handler所指定的策略来处理任务
6. 线程池的状态
- RUNNING:能接受新提交的任务,并且也能处理阻塞队列中的任务
- SHUTDOWN:不再接受新提交的任务,但可以处理存量任务
- STOP:不再接受新提交的任务,也不处理存量任务
- TIDYING:所有的任务都已终止
- TERMINATED:terminated()方法执行完后进入该状态
7. 线程池的大小如何选定
- CPU密集型:线程数 = 按照核数或者核数 + 1设定
- IO密集型:线程数 = CPU核数 * (1 + 平均等待时间/平均工作时间)