多线程常见面试题
文章目录
- 多线程常见面试题
- 1. 常见的锁策略
- 1.1乐观锁&悲观锁
- 1.2 轻量级锁&重量级锁
- 1.3 自旋锁&挂起等待锁
- 1.4 读写锁&普通互斥锁
- 1.5 公平锁&非公平锁
- 1.6可重入锁&不可重入锁
- 2. CAS
- 3. Sychronized原理
- 3.1 锁升级
- 3.2 锁消除
- 3.3 锁粗化
- 4. JUC
- 4.1 Callable接口(创建线程的方式)
- 4.2 创建线程的几种方式
- 4.3 ReentrantLock
- 5. JUC工具类
- 5.1 Semaphore-信号量
- 5.2 CountDownLatch
- 5.3 CyclicBarrier - 循环栅栏
- 6.线程安全的集合类
- 7.多线程使用队列
- 8.多线程环境使用哈希表
- 9.死锁
1. 常见的锁策略
1.1乐观锁&悲观锁
在加锁的态度的角度去执行加锁逻辑
乐观锁:在获取锁的时候预期这个锁竞争不太激烈,那么就可以先不加锁,或者少加锁(有真实的竞争再来加锁)
悲观锁:在获取锁的时候预期这个锁竞争非常激烈,那么就必须先加锁再执行任务
1.2 轻量级锁&重量级锁
站在加锁过程角度的描述
轻量级锁:加锁的过程比较简单,用到的资源比较少,典型就是用户态的一些加锁操作(在Java层面就可以完成加锁)
重量级锁:加锁的过程比较复杂,用到的资源比较多,典型就是内核态的一些操作
乐观锁是能不加就不加,从而导致他干的活就比较少了,那么他消耗的资源就比较少了, 从而可以说乐观锁是一个轻量级锁
悲观锁是不管怎么样都先把锁加上,从而导致他干的活多了,那么消耗的资源就比较多了,可以说被悲观锁也是一种重量级锁
1.3 自旋锁&挂起等待锁
自旋锁:不停的检查锁是否被释放,如果一旦锁被释放掉那么就直接获取锁资源
自旋锁的优点:
- 他是一个纯用户态的操作,比较轻量
- 锁一旦被释放就可以马上知道
还有一些缺点:
不停的循环比较浪费系统的资源
可以通过控制自旋的次数来提高效率,并且避免系统资源的过度浪费
挂起等待锁:不主动询问锁资源,而是让系统调度去竞争锁资源
- 通过阻塞与就绪状态的切换来获取锁资源
- 如果锁一旦释放,没有办法立马知道
- 是通过系统内核来处理的
自旋锁是一种典型的轻量级锁的具体实现
挂起等待锁是一种典型的重量级锁的具体实现
1.4 读写锁&普通互斥锁
读写锁:在锁中标识读或写,在竞争时根据这个标识来判断是否参与竞争
读的时候加读锁(共享锁),多个锁可以共存,同时多个读锁是互不影响的
写的时候加写锁(排他锁),只能有一个写锁在执行任务,和别的锁是冲突的
写锁和写锁不能共存
写锁和读锁不能共存
读锁和读锁可以共存
为什么要用读写锁
在我们的程序中可能会出现激烈的锁竞争,但是获取锁之后的操作,有可能是大量的读操作,读操作又不涉及修改
所以所有的读都可以并发执行,只有读的时候不让别的线程来修改就可以了
如果一个操作是读操作,当前也是读锁,那么就直接读了,不产生锁竞争,从而节省了资源
如果一个操作是写操作,这个时候就让他等待
针对高并发读的业务场景中,锁竞争就会大大降低,从而提交程序的运行效率
互斥锁
有竞争关系,只能一个线程释放锁之后,别的线程再来抢
1.5 公平锁&非公平锁
公平锁:先来后到,先排队的线程先获取到锁,后排队的后获取到锁
非公平锁:谁先抢到就是谁的
在Java JUC中有一个类实现了公平锁
synchronized是一个非公平锁
1.6可重入锁&不可重入锁
可重入锁:对一把锁可以连续加锁多次,而不造成死锁
不可重入锁:对一把锁连续加锁多次,造成死锁
对比sychronized
乐观锁&悲观锁 | 即是乐观锁也是悲观锁 |
---|---|
轻量级锁&重量级锁 | 即是轻量级锁也是重量级锁 |
自旋锁&挂起等待锁 | 即是自旋锁也是挂起等待锁 |
读写锁&普通互斥锁 | 是互斥锁 |
公平锁&非公平锁 | 是非公平锁 |
可重入锁&不可重入锁 | 是可重入锁 |
当锁竞争不激烈时,是一个乐观锁,轻量级锁,自旋锁
如果竞争不激烈这个状态就一直保证
如果锁竞争激烈的时候,就会升级为悲观锁,重量级锁,挂起等待锁
2. CAS
compare and swap 比较并交换
首先比较当前变量值与某个值期望的值是否相同,如果相同则用一个新的值变量内存中的值
boolean CAS(address,exceptValue,swapValue){if(&address==exceptValue){&address=swapValue;return true;}return false;
}//核心逻辑
if(value==exceptValue){value=oldValue;
}
- 用一个预期值去和内存中的值做比较
- 如果预期值与内存中的值相等,那么就用新值更新内存中的值
- 如果预期值与内存中的值不相等,那么就不做任何操作
画图演示
1.两个线程读取value的值到oldvalue
-
线程1执行CAS操作,由于oldvale与value值相等。直接对value赋值
-
线程2再执行CAS操作,第一次CAS时发现oldCValue和value不相等,不能进行赋值。因此需要进入循环(自旋),在循环里重新读取value的值赋给oldValue
-
线程2接下来第二次执行CAS,此时oldValue与value相同,于是直接执行赋值操作
CAS操作直接修改的是内存中的值,每次都会去读,去比较去修改指定内存地址的值,从而保证了原子性的操作
CAS中ABA的问题
3. Sychronized原理
Sychronize本身会对锁做一些智能的优化,在程序不同的运行时期,适应不同的锁策略
3.1 锁升级
无锁——>偏向锁(JVM启动4秒后,创建的锁对象才会偏向状态)——>轻量级锁——>重量级锁
无锁:没有锁竞争时
偏向锁:只是给锁对象中加入了一个标签,并没有真正的去加锁
轻量级锁:通过自旋锁实现用户态的操作
重量级锁:内核态的加锁操作,调用的是CPU加锁指令
3.2 锁消除
sychronized的一种优化策略
sychronized是程序员自己手动加的获取锁的逻辑,什么时候加,加在哪个代码块,JVM管不了,但是在编译和运行的时候,JVM可以知道程序是读变量,还是写变量
如果程序员对所有的读操作都加了sychronized关键字,但是又没有写操作,那么这是JVM就认为这个锁是多余的,那么sychronized就不会真正的去加锁了,这个现象称为锁消除
在多线程状态下,多个线程对同一变量进行修改才会又线程安全问题,多个线程读一个变量没有线程安全问题
3.3 锁粗化
锁的粒度,也就是锁的范围,也就是sychronized包裹代码的多少,包的越多粒度就越粗,包的越少粒度就越细,对于多个连续的任务,如果每个任务都加一把锁,这个过程就会产生频繁的锁竞争,JVM就会把锁的范围增大到整个任务的开始与结束,减少锁竞争的次数来提高效率。
4. JUC
4.1 Callable接口(创建线程的方式)
描述线程要执行的任务
Callable和Runnable有什么区别
- Callable要实现call()且有返回值,Runnable方法要实现run(),没有返回值
- Callable的call()方法可以抛出异常,Runnable的Run()方法不能抛出异常(业务异常)
- Callable要搭配FutureTask一起使用,通过futureTask.get()来获取call()的返回值
- 两者都是描述线程任务的接口
4.2 创建线程的几种方式
创建线程的几种方式
- 继承Tread类,实现run()方法
- 实现Runnable接口,实现run()方法
- 实现Callable接口,实现call()方法
- 通过线程池,提交任务
由于Runnable和Callable都是函数式接口,我们可以通过Lambda表达式的方式简化写法,也可以通过匿名内部类的方式来简化写法
4.3 ReentrantLock
JUC中重要的一个技术点
lock()
加锁
unLock()
解锁
tryLock()
尝试解锁
sychrinized与ReentrantLock区别
- sychronized退出代码块就自动释放锁,ReentrantLock必须手动释放锁,注意要是有try/finallu处理加锁释放锁的代码
- sychrinized是非公平锁,ReentrantLock即是非公平锁也是公平锁
- ReentrantLock可以根据不同的条件去进行休眠和唤醒
- sychronized在申请锁失败时,会一直等待锁资源,而ReetrantLock可以通过tryLock的方式等待一段时间就放弃
- sychronized是JVM的一个对锁的实现,最终调用CPU加锁指令,而ReetrantLock是Java层面的JUC包中的一组实现类
5. JUC工具类
5.1 Semaphore-信号量
申请资源的操作称为P操作,资源数量减一,当减到0的时候其他的线程就要等待
释放资源的操作称为V操作,资源数量就要加一
信号量本质上就是要维护资源的数量
5.2 CountDownLatch
等待所有线程全都完成任务后才执行后面的操作
5.3 CyclicBarrier - 循环栅栏
CountDownLatch的进级版,可以实现线程间的相互等待,计数重置
6.线程安全的集合类
Vector,Stack,HashTable
如何在多线程环境下保证集合类的线程安全
- 手动加锁,sychronized包裹代码块,ReentrantLock 加锁解锁
- 使用工具类
Collections.synchronizedList(array)
(不常用) - JUC 提供了集合类
CopyOnWriteArraylist
7.多线程使用队列
ArrayBlockingQueue
基于数组实现的队列LinkedBlockingQueue
基于链表实现的队列PriorityBlockingQueue
基于堆实现的优先级队列TransferQueue
最多只包含一个元素的阻塞队列
8.多线程环境使用哈希表
HashTable
JDK1.0提供 线程安全不推荐使用
HashMap
线程不安全
面试题
-
多线程环境下,用哪个类来保证Map的线程安全
JUC包下的ConcurrentHashMap
-
ConcurrentHashMap与HashMap/HashTable 的区别
HashMap
是线程不安全的HashTable
是线程安全的,但是不推荐使用,因为所有的操作都加锁了ConcurrentHashMap
的锁粒度比较小,不是对整个Hash表加锁,而是对每一个数据的下标进行加锁ConsurrentHashMap
对写操作加锁,对读操作不加锁- 对于共享变量大量运行了volatile关键字去修饰
ConcurrentHashMap
对扩容进行了优化
扩容
HashMap和HashTable在扩容时,重新创建一个容量是当前容量2倍的新数组,把所有的元素全部重新hash到新数组里去,是一个完整的操作,效率并不是很高
ConcurrentHashMap,创建一个新数组容量是原来的2倍,原数组与新数组同时存在一段时间,每次调用ConcurrentHashMap方法的时候,都去搬运一部分元素到新数组中,当原数组中的数据搬运完后,原数组就可以删除了,只使用新数组就可以了
当两个数组同时存在的时候,查询数据就需要在两个Map中同时查询
删除时在两个Map中查找,找到后删除
写入时只往新的Map中去写
典型的以空间换时间的做法,浪费了一些存储空间,但提高了运行效率
9.死锁
在多线程环境中遇到的最严重的问题之一,线程在获取锁资源的时候,由于获取不到导致线程卡死(阻塞),不执行了
造成死锁的原因
- 资源互斥:线程1拿到了锁A,线程2不能同时得到锁
- 不可抢占:获取到锁的线程,除非自己主动释放锁,别的线程不能从他的手里抢过来
- 保持与请求:线程1已经获得了锁A,还要在这个基础再去获取锁B
- 循环等待:线程1等待线程2释放锁,线程2等待线程3释放锁,线程3等待线程1释放锁……
以上四条是造成死锁的必要条件,必须同时满足
如何解决死锁
- 互斥访问:锁的基本特性,不能打破
- 不可抢占:锁的基本特性,不能打破
- 保持与请求:和代码实现或是设计的角度来说是可以改变保持与请求的顺序,也就是获取锁的顺序
- 循环等待:最有可能也是最常见的解决死锁的策略就是打破循环等待