本章用来处理一下之前遗漏的很多问题,在多线程那一章,很多常见面试题都没有讲,这里再来补充一下。
HashTable, HashMap, ConcurrentHashMap 之间的区别
HashTable, HashMap, ConcurrentHashMap 都带有Map,它们其实都是 Map 的接口,都是以键值对的 形式来存储数据。
HashMap
HashMap
是在JDK1.2
中引入的Map
的实现类。
HashMap是基于哈希表实现的,其主要的特点有:
- HashMap 的键值对均可以为 null(当key 为null 时,哈希会被赋值为0)
- 初始的size 默认为 16,每次以二倍的形式扩容,最大值为 2^30
- 底层使用的数据结构为:数组 + 链表 + 红黑树
- 当Map中元素总数超过Entry数组的75%,触发扩容操作,为了减少链表长度,元素分配更均匀;计算index方法:index = hash & (tab.length – 1)
- HashMap 效率非常高,但线程不安全
HashTable
Hash table,叫做散列表(也叫哈希表),其主要特点有:
- 底层是由 数组 + 链表 实现的
- 无论是 key 还是 value 都是 不允许为 null 的
- 虽然线程是安全的,但是只是简单得用 synchronized 给所有方法加锁,相当于是对this加锁,也就是对整个HashTable对象进行加锁(非常无脑) 因为是 无脑加锁,所以Java官方并不推荐使用,而建议不涉及到线程安全问题时使用:HashMap,遇到线程安全问题时 使用:ConcurrentHashMap
- 实现线程安全的方式是在修改数据时锁住整个HashTable,所以效率非常低
- 初始size为11,扩容:newsize = olesize*2+1
计算index的方法:index = (hash & 0x7FFFFFFF) % tab.length【我查的】
ConcurrentHashMap
- 底层数据结构:数组 + 链表 + 红黑树
- ConcurrentHashMap 的键值不可以为null
ConcurrentHashMap 最重要的点要说 线程安全
ConcurrentHashMap 相比比较于HashTable 有很多的优化
核心思想就是降低 锁冲突的概率:
具体的优化手段有:
(1)锁粒度的控制
ConcurrentHashMap 不是锁整个对象,而是使用多把锁,对每个哈希桶(链表)都进行加锁,只有当两个线程同时访问同一个哈希桶时,才会产生锁冲突,这样也就降低了锁冲突的概率,性能也就提高了
(2) 只给读加锁,不给写加锁
我们知道 写会造成冲突,而只读不会有影响。
(3)充分利用到了CAS的特性
比如更新元素个数,都是通过CAS来实现的,而不是加锁
(4)ConcurrentHashMap 对于扩容操作,进行了特殊优化
HashTable的扩容是这样:当put元素的时候,发现当前的负载因子已经超过阀值了,就触发扩容。
扩容操作时这样:申请一个更大的数组,然后把这之前旧的数据给搬运到新的数组上
但这样的操作会存在这样的问题:如果元素个数特别多,那么搬运的操作就会开销很大
执行一个put操作,正常一个put会瞬间完成O(1)
但是触发扩容的这一下put,可能就会卡很久(正常情况下服务器都没问题,但也有极小概率会发生请求超时(put卡了,导致请求超时),虽然是极小概率,但是在大量数据下,就不是小问题了)
ConcurrentHashMap 在扩容时,就不再是直接一次性完成搬运了
而是搬运一点,具体是这样的
扩容过程中,旧的和新的会同时存在一段时间,每次进行哈希表的操作,都会把旧的内存上的元素搬运一部分到新的空间上,直到最终搬运完成,就释放旧的空间
在这个过程中如果要查询元素,旧的和新的一起查询;如果要插入元素,直接在新的上插入
;如果是要删除元素,那就直接删就可以了
具体的可以参考下面两篇博客:
ConcurrentHashMap_亦安✘的博客-CSDN博客
死锁的成因, 和解决方案
什么是死锁
所谓死锁,是指多个进程在运行过程中因争夺资源而造成的一种僵局,当进程处于这种僵持状态时,若无外力作用,它们都将无法再向前推进。
产生死锁的三个经典案例
案例一:一个线程 一把锁
一个线程正常操作,不会发生线程安全问题,如果是说,针对一个线程,多次加锁,那么就会产生问题。
// 第一次加锁成功
lock();
// 再次尝试对其加锁,原来的锁还未被释放
lock();
// 加锁失败,造成阻塞等待
像这样,第二次加锁,再等待第一次加锁的资源释放,第一次加锁释放又在等待第二次加锁的完成,于是只能造成死锁。
对可重入锁和不可重入锁的补充
如果同一个线程在重复获取同一把锁的过程中,形成了死锁。这把锁又被称为不可重入锁。而可重入锁的字面意思是“可以重新进入的锁”,即允许同一个线程多次获取同一把锁,不会出现死锁的情况。synchronized 是可重入锁
案例二:(两个线程,两把锁)
简单理解就是 车钥匙 在 家里 , 家钥匙 在 车里。
简单用伪代码来距离:
Object locker1 = new Object();
Object locker2 = new Object();
// 线程 T1
synchronized (locker1) {
synchronized (locker2) {
}
}
// 线程 T2
synchronized (locker2) {
synchronized (locker1) {
}
}
线程抢占式执行,假设 T1 和 T2 同时执行,T1拿到了 locker1 ,T2 拿到了locker2 ,都卡在了第一步,要想拿到 另一把锁,必须得让对方先释放,双方都无法释放,那么就造成了死锁。
案例三:(N个线程,M把锁)
哲学家吃面条问题
5位哲学家围着一张桌子,桌子上有几碗面条。这5位哲学家的左右手两边各有一根筷子(注意是一根,不是一双,两根筷子才是一双,才能拿来吃面,一根筷子无法吃面)
5位哲学家相当于是5个线程,这些线程只有分别拿到左右手旁的两根筷子(各自要求的两把锁),才能完成进程,并释放自己所占用的锁。
然后呢,在某一时刻,哲学家都想吃面条:他们同时拿起了自己右手边的那根筷子。5位哲学家、5根筷子,他们每个人都只拿了一根筷子(获取到了一个锁) 。于是他们每个人都完成不了各自的进程,也无法释放他们所占用的锁(筷子),都吃不到面条。
这又是一个死锁问题。
解决办法
那么怎么解决呢?和上面死锁的解决方案相同——我们要分析为什么会出现死锁,就是因为线程对锁的互相等待,线程一要获取的锁被线程二占用着,但同时线程二要获取的锁又被线程一占用着,于是他们两个都无法获取到完整的锁,无法完成各自的进程,并释放锁。都处于一个循环等待的过程。
要解决死锁问题,重点就是解决循环等待问题。如果每个线程都按一定的顺序来获取对应的锁,比如在上面的栗子中,我们给5根筷子(5把锁)按从1到5的顺序进行编号,哲学家只能拿到到左右两边锁编号最小的那把锁。(已经拿到的锁不用进行编号的比较)
形成死锁的四个条件
- 互斥性:当多个线程对同一把锁,有竞争。在某一时刻,最终只有一个线程可以拥有这把锁
- 不可抢夺性:当一个线程已经获取到了锁A,其他线程要想获取锁A,这个时候只能等该线程把A释放了之后再获取,不能中途抢夺别的线程的锁。
- 请求和保持性:当一个线程获取到了锁A,除非该线程自己释放锁A,否则该线程就一直保持占有锁A
- 循环等待性:在死锁中往往会出现,线程A等着线程B释放锁,同时线程B又在等着线程A来释放他所占有的锁,结果A、B的锁都无法正常释放,也都无法完成各自的进程,陷入了一个循环等待的状态
当上述四个条件某一条被破坏之后,死锁就解决了。
synchronized
synchronized的特点:
- 既是乐观锁,又是悲观锁
- 既是轻量级锁,又是重量级锁
- 轻量级锁基于自旋锁,重量级锁基于挂起等待锁
- 不是读写锁
- 是可重入锁
- 是非公平锁
synchronized关键字通常使用在下面四个地方:
synchronized修饰实例方法。
synchronized修饰静态方法。
synchronized修饰实例方法的代码块。
synchronized修饰静态方法的代码块。
锁升级
Java 1.6为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”,在Java SE 1.6中,锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,这几个状态会随着竞争情况逐渐升级。锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率。
如下图:
锁策略, cas 和 synchronized 优化过程
可以参考之前写过的文章:
多线程(八):常见锁策略_我可是ikun啊的博客-CSDN博客
synchronized 和 ReentrantLock 之间的区别
相同点:
- synchronized 和 ReentrantLock 都是 Java 中提供的可重入锁
不同点:
- 用法不同:synchronized 可以用来修饰普通方法、静态方法和代码块;ReentrantLock 只能用于代码块;
- 获取和释放锁的机制不同:进入synchronized 块自动加锁和执行完后自动释放锁; ReentrantLock 需要显示的手动加锁和释放锁;
- 锁类型不同:synchronized 是非公平锁; ReentrantLock 默认为非公平锁,也可以手动指定为公平锁;
- 响应中断不同:synchronized 不能响应中断;ReentrantLock 可以响应中断,可用于解决死锁的问题;
- 底层实现不同:synchronized 是 JVM 层面通过监视器实现的;ReentrantLock 是基于 AQS 实现的。
线程池的执行流程和拒绝策略
线程池的执行流程:
- 当新加入一个任务时,先判断当前线程数是否大于核心线程数,如果结果为 false,则新建线程并执行任务;
- 如果结果为 true,则判断任务队列是否已满,如果结果为 false,则把任务添加到任务队列中等待线程执行
- 如果结果为 true,则判断当前线程数量是否超过最大线程数?如果结果为 false,则新建线程执行此任务
- 如果结果为 true,执行拒绝策略。
拒绝策略:
- AbortPolicy:中止策略,线程池会抛出异常并中止执行此任务;
- CallerRunsPolicy:把任务交给添加此任务的线程来执行;
- DiscardPolicy:忽略此任务(最新加入的任务);
- DiscardOldestPolicy:忽略最先加入队列的任务(最老的任务)。
线程池的执行流程图: