💤一. 线程安全的集合类
原来的集合类, 大部分都不是线程安全的.
Vector, Stack, HashTable, 是线程安全的(不建议用), 其他的集合类不是线程安全的(ArrayList,LinkedList,HashMap,TreeMap,HashSet,TreeSet,StringBuilder)
此处可以具体参照我写的 synchronized 关键字文章里的原因👉点击我直接传送👈
❔1. 多线程环境使用 ArrayList
- 自己使用同步机制 (synchronized 或者 ReentrantLock)
看我上篇文章详解ReentrantLock 👉点我传送👈
- Collections.synchronizedList(new ArrayList);
- synchronizedList 是标准库提供的一个基于 synchronized 进行线程同步的 List.
- synchronizedList 的关键操作上都带有 synchronized
- 使用 CopyOnWriteArrayList
- CopyOnWrite容器即写时复制的容器。
- 如果出现修改操作,就把 ArrayList 进行复制
- 先拷贝一份数据,新线程修改副本,再用副本替换原有的数据(拷贝的成本可能会很高,当元素过多的时候!)
这样做的好处是我们可以对CopyOnWrite容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素。所以CopyOnWrite容器也是一种读写分离的思想,读和写不同的容器。
优点:
- 在读多写少的场景下, 性能很高, 不需要加锁竞争.
缺点:
- 占用内存较多.
- 新写的数据不能被第一时间读取到
❓2. 多线程环境使用哈希表
- HashMap(线程不安全的)
- Hashtable(线程安全的,不推荐使用)
- ConcurrentHashMap(推荐使用的)
- Hashtable
只是简单的把关键方法加上了 synchronized 关键字.
这相当于直接针对 Hashtable 对象本身加锁
- 如果多线程访问同一个 Hashtable 就会直接造成锁冲突.
- size 属性也是通过 synchronized 来控制同步, 也是比较慢的.
- 一旦触发扩容, 就由该线程完成整个扩容过程. 这个过程会涉及到大量的元素拷贝, 效率会非常低
一把锁锁住了很多个哈希桶,每个哈希桶上面又有一个链表,假设俩链表上俩元素,如果多个线程要操作这俩个元素,因为是被一把大锁锁住了,就会产生竞争。但是仔细分析之后发现,这俩操作在不同的哈希桶中,之间并不相关。对于两个不同的哈希桶上的元素,不牵扯修改同一个变量,因此就不会发生线程安全问题。如果两个修改落到同一个哈希桶上才会产生线程安全风险。因此 ConcurrentHashMap 相比之下做出了重大改进!
一个 Hashtable 只有一把锁,两个线程访问 Hashtable 中任意数据都会产生锁竞争
- ConcurrentHashMap
ConcurrentHashMap 把锁的粒度细化了,在每一个哈希桶上都加了锁。
ConcurrentHashMap 每个哈希桶都有一把锁,只有两个线程访问的恰好是同一个哈希桶上的数据才会产生锁冲突
优化特点:
- 把锁的粒度进行细分了,每个哈希桶,都有一把锁(每个链表的头结点),降低了锁冲突的概率。(最重要)
- 激进的操作,读没加锁,写才加锁。
- 更充分的使用了 CAS 特性,更高效的操作,比如 size 属性通过 CAS 来更新. 避免出现重量级锁的情况
- 针对扩容场景进行了优化(化整为零)
- 发现需要扩容的线程, 只需要创建一个新的数组, 同时只搬几个元素过去.
- 扩容期间, 新老数组同时存在.
- 后续每个来操作 ConcurrentHashMap 的线程, 都会参与搬家的过程. 每个操作负责搬运一小部分元素.
- 搬完最后一个元素再把老数组删掉.
- 这个期间, 插入只往新数组加.
- 这个期间, 查找需要同时查新数组和老数组
上述都是基于 Java 8 的,在 Java 1.7 里不是每个桶一个锁,而是 “分段锁”,一个锁管若干个桶。(在 Java 8 之前都是 Java 1.x ,到了 Java 8 及其以后就省去了 1.,如 Java 8,Java 9,…)
面试题:
Hashtable和HashMap、ConcurrentHashMap 之间的区别?
HashMap 的 key 允许为 null,其他的两个不能为 null (错误回答!!!是区别,但不是主要区别)
主要在线程安全上回答:
HashMap 线程不安全的,Hashtable 和 ConcurrentHashMap 是线程安全的!
Hashtable 是使用一把大锁,锁冲突的概率很高,ConcurrentHashMap 则是每个哈希桶一把锁,锁冲突概率大大降低了。
详细说 ConcurrentHashMap 其他的优化策略。
HashMap key 允许为 null,另外两个不允许。
💦二. 死锁
❗️1. 死锁是什么
尝试加锁的时候发现上次锁没有及时释放(因为一些原因,bug),导致加锁加不上。
死锁是多线程代码中常见的 bug
- 一个线程一把锁(可重入锁)
线程 1 针对锁 A 连续加锁两次,如果是不可重入锁,就死锁了
- 两个线程两把锁
线程 1 获取到锁 A
线程 2 获取到锁 B
线程 1 尝试获取锁 B,线程 2 尝试获取锁 A,就发生死锁了。
- N 个线程 M 把锁
“哲学家就餐问题”:
①:有个桌子, 围着一圈 哲学家, 桌子中间放着一盘意大利面. 每个哲学家两两之间, 放着一根筷子.
②:每个 哲学家 只做两件事: 思考人生 或者 吃面条. 思考人生的时候就会放下筷子. 吃面条就会拿起左右两边的筷子(先拿起左边, 再拿起右边).
③:如果 哲学家 发现筷子拿不起来了(被别人占用了), 就会阻塞等待.
④:[关键点在这] 假设同一时刻, 五个 哲学家 同时拿起左手边的筷子, 然后再尝试拿右手的筷子, 就会发现右手的筷子都被占用了. 由于 哲学家 们互不相让, 这个时候就形成了 死锁
在多个线程多把锁的情况下,死锁是一个概率性问题,绝对不能忽视
❕2. 如何避免死锁
由上总结死锁的四个条件:
- 互斥使用:线程 1 拿到锁 A ,其他线程无法获取到 A 。
- 不可抢占: 线程 1 拿到锁 A ,其他线程只能阻塞等待,等到线程 1 主动释放锁,而不是强行把锁抢走
- 请求和保持:当线程 1 拿到锁 A 之后,就会一直持有这个获取到锁的状态,直到主动释放
- 循环等待:线程 1 等待 线程 2,线程 2 又尝试等待线程 1。(和代码编写密切相关)
当上述四个条件都成立的时候,便形成死锁。当然,死锁的情况下如果打破上述任何一个条件,便可让死锁消失。其中最容易破坏的就是 “循环等待”.
我们该如何打破循环等待呢?
针对多把锁,进行编号 1 2 3 4 …,约定在获取多把锁的时候,要明确获取锁的顺序是从小到大的顺序。比如线程要拿到 1 2 两把锁,就先获取 1 再获取 2 ,如果要拿到 2 4 两把锁,就先获取 2 再获取 4 。只要所有的线程都遵守这个顺序,就不会死锁!!就不会出现循环等待!!!(简单并且靠谱)
当以后学习操作系统,也会涉及到哲学家就餐 / 死锁问题,教科书上也会有一个避免死锁的办法,“银行家算法” ,也能解决死锁问题,但是非常复杂,不建议使用。