七、CountDownLatch
CountDownLatch:count down — 倒计时,latch — ( n. 门闩 )
我们可以将 CountDownLatch 机制想象成一个倒计时的仪器,在运动会的跑步比赛中,如果选手跑完所有路程,最终一定会 " 撞终点线 ",每个选手撞一次线,就有人会记录,或许是红外装置能够检测到。不管怎样,只要一个选手撞线了,说明一个人就跑完了赛道,那么就减一。而 CountDownLatch 也是一样的,只要一个任务完成了,利用 countDown() 方法计数一次,底层就减一。
程序清单7:
import java.util.concurrent.CountDownLatch; public class Test { public static void main(String[] args) throws InterruptedException { //构造 CountDownLatch 实例, 括号中的 6 表示有 6 个任务需要完成 CountDownLatch latch = new CountDownLatch(6); Runnable runnable = new Runnable() { @Override public void run() { System.out.println("起跑!"); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } //相当于计数器自减 //每个任务完成之后,就减一次 latch.countDown(); System.out.println("撞线!"); } }; for (int i = 0; i < 6; i++) { Thread t = new Thread(runnable); t.start(); } //任务在执行中的时候,await 方法阻塞等待,直到所有任务都执行完成 latch.await(); System.out.println("比赛结束!"); } }
输出结果:
JUC 包中的常见类
JUC:java.util.concurrent ,concurrent ( adj. 并发的 )
当前介绍的 JUC 包中的这些东西,主要是在工作中用的比较多。但在面试中出现的不是特别多( 也是会出现,但相比于前面的内容,出现概率会少一些 )
- ReentrantLock 类:可重入互斥锁,通常使用 lock 和 unlock 加锁解锁
- 原子类:底层使用 CAS 机制实现,原子类具有原子性,可以避免加锁
- 线程池:ThreadPoolExecutor 和 Executors,前者复杂版本,后者简单版本
- 信号量:Semaphore
- CountDownLatch类:倒计时计数器
八、Java 标准库中的线程安全类
这里的集合类,大部分是线程不安全的。( 不能在多线程环境下去并发地修改同一个对象 )
例如:
ArrayList
LinkedList
HashMap
TreeMap
HashSet
TreeSet
StringBuilder
但还有些是线程安全的。
例如:
Vector (不推荐使用)
Hashtable (不推荐使用)
ConcurrentHashMap
StringBuffer
其中 Vector 和 Hashrable 为很多方法都加上了 synchronized,而大多数情况下,在我们使用单线程的时候,synchronized 就会带来负面影响。
还有的虽然没有加锁,但仍然是线程安全的,例如:String,因为 String 类描述的是一个不可变对象,所以不涉及 “修改”,那么加锁和无锁并没有区别。
1. 多线程环境使用哈希表
在上面的时候,我们提到 HashMap 是线程不安全的类,而 Hashtable 是线程安全的类,但不推荐使用,那我们怎么才能折中一下呢,使用既是线程安全,又是可以用的类呢?
我们先来看看 Hashtable 类:
这是我从源代码中,查看的 Hashtable 类,它在底层中,只是简单地把关键的方法加上了 synchronized 关键字,我只截取了部分加锁的方法,而还有的一些方法加了 synchronized ,有些方法没有加。
那么这就相当于直接针对 Hashtable 对象本身加锁,也就是说,整个哈希表只有一把锁,如果多线程访问同一个 Hashtable 就会直接造成锁冲突,size 属性也是通过 synchronized 来控制同步,所以也比较慢。一旦触发扩容, 就由该线程完成整个扩容过程,这个过程会涉及到大量的元素拷贝,效率会非常低。
那么问题就来了,一个 Hashtable 实际上就只有一把锁,多个线程访问 Hashtable 中的数据时,都会发生锁竞争,那么多个线程竞争同一把锁,一定会造成一个线程成功获取到锁,而其他线程阻塞等待,所以说,这个 Hashtable 带来的锁冲突还是比较激烈的,同时,发生锁冲突的概率也是比较大。
2. ConcurrentHashMap 类的优化
而 ConcurrentHashMap 实现的哈希桶是直接给每个哈希桶 ( 每个链表 ),分配了一个锁 ( 以每个链表的头结点对象作为锁对象 )
ConcurrentHashMap 相比于 Hashtable 做出了一系列的改进和优化,以 Java1.8 为例:
① 读操作没有加锁 ( 但使用了 volatile 保证从内存读取结果 ),只对写操作进行加锁,加锁的方式仍然是用 synchronized,但不是将整个对象加锁,而是 " 锁桶 " (用每个链表的头结点作为锁对象),大大降低了锁冲突的概率。
② 充分利用 CAS 机制,比如 size 属性通过 CAS 来更新,避免出现重量级锁的情况。
③ 优化了扩容方式,化整为零。发现需要扩容的线程,只需要创建一个新的数组,同时只搬几个元素过去,扩容期间,新老数组同时存在。后续每个来操作 ConcurrentHashMap 的线程,都会参与搬运的过程,每个操作负责搬运一小部分元素,搬完最后一个元素后,再把老数组删掉。这个期间,插入只往新数组加,同时,查找需要同时查新数组和老数组。
3. 相关面试题
(1) ConcurrentHashMap 的读是否要加锁,为什么?
读操作没有加锁,目的是为了进一步降低锁冲突的概率,为了保证读到刚修改的数据,搭配了volatile 关键字。
(2) 介绍下 ConcurrentHashMap的锁分段技术?
这个是 Java1.7 中采取的技术,Java1.8 中已经不再使用了,简单地说,就是把若干个哈希桶分成一个 " 段 " (Segment),再针对每个段分别加锁,目的也是为了降低锁竞争的概率,当两个线程访问的数据恰好在同一个段上的时候,才触发锁竞争。显然,这并没有 Java1.8 所使用的 " 锁桶 " 的并发程度高。
(3) ConcurrentHashMap 在 JDK1.8 中做了哪些优化?
取消了分段锁,直接给每个哈希桶 ( 每个链表 ) 分配了一把锁,( 以每个链表的头结点对象作为锁对象 )
(4) HashMap、Hashtable、ConcurrentHashMap 之间的区别和联系
HashMap:线程不安全,key 允许为 null
Hashtable:线程安全,使用 synchronized 锁 Hashtable 对象,效率较低,key 不允许为 null
ConcurrentHashMap:线程安全,使用 synchronized 来锁每个链表头结点,锁冲突概率低,充分利用 CAS 机制,优化了扩容方式,key 不允许为 null
九、死锁
说明
下面的一些代码只是为了演示死锁的情况,可能表达方式上会有些不恰当的地方,但我旨在说明逻辑和对应的原理。
1. 第一种情况
一个线程一把锁
synchronized 内部已经记录了当前的锁是由哪个线程持有的。因此当再次尝试加锁的时候,就会进行判定,看看当前尝试加锁的线程是否就是持有锁本身的线程。如果是,就不会阻塞,而是把引用计数给自增。所以说,由于 synchronized 内部的这个机制,就决定了它是一个可重入锁,因此,上面的代码实际上会被优化。
2. 第二种情况
两个线程两把锁
3. 第三种情况
多个线程多把锁
4. 是什么造成了死锁
我们必须明确,死锁产生的四个必要条件:
① 互斥使用:即当资源被一个线程使用( 占有 )时,别的线程不能使用。
② 不可抢占:资源请求者不能强制从资源占有者手中夺取资源,资源只能由资源占有者主动释放。
③ 请求和保持:即当资源请求者在请求其他资源的同时,也保持对原有资源的占有。
④ 循环等待:起初,线程1 占有锁1 的资源,线程2 占有锁2 的资源。而接着线程1 尝试获取锁2,线程2 尝试获取锁1,这就陷入了循环等待,或者说休眠状态。
当上述四个条件都成立的时候,便形成死锁。当然,死锁的情况下如果打破上述任何一个条件,便可让死锁消失。其中最容易破坏的就是第④个条件,( 循环等待 ),而前三个条件,在大部分情况下,不好干预。
5. 如何避免死锁
① 尽量避免复杂的设计,避免在某个锁的代码中,再尝试获取其他锁,即尽量不要嵌套锁。
② 如果有些应用场景下,必须使用锁的嵌套,那么,一方面我们要保证持有锁的时间足够短,代码足够简单;另一方面要保证按照统一的顺序来进行加锁。而这样的固定顺序,其实就是破坏了死锁的 " 循环等待 " 条件。
比方说,在刚刚的 " 哲学家就餐的问题上 ",我们为筷子编号,A 先使用 12 筷子,C 先使用 34 筷子,E 先使用 56 筷子,等他们吃好了,再让其他人用这些筷子…这就破坏了循环等待,最起码,这保持了一些线程在同步执行!