目录
前言
今天不学习,明天变垃圾!
本文主要内容:Callable接口、JUC的常见类、线程安全的集合类 和死锁。关键还是在【面试题】!
一、JUC的常见类
- 【JUC】在工作中用的还挺多的
- JUC其实是java.util.concurrent的缩写,concurrent是并发的意思,所以这个包里放的东西都是和多线程相关的。
Callable接口
1. Callable相关
-
类似于Runnable接口,都是用于描述任务的。但是Runnable描述的任务是不带返回值的,而Callable描述的任务是带返回值的。
-
Callable<返回类型>,重写call方法
-
线程中是无法直接传入Callable对象的,此时就需要中间类FutureTask来进行辅助;注意最后的结果的获取是使用中间类FutureTask的get方法,get方法会阻塞直到call方法计算完毕。
(中间类FutureTask存在的意义其实就是为了让我们能够获取到结果/也就是说中间类FutureTask是获取结果的凭证) -
参考代码:Callable接口
-
小结:
线程创建的方式:
1)继承Thread(可以使用匿名内部类,也可以不用)
2)实现Runnable(可以使用匿名内部类,也可以不用)
3)使用lambda
4)使用线程池
5)使用Callable(中间类FutureTask进行辅助,是获取结果的凭证)
2. 相关面试题
介绍下 Callable 是什么?
答: ① Callable 是一个 interface 。相当于把线程封装了一个 “返回值”, 方便程序员借助多线程的方式计算结果。
② Callable 和 Runnable 相对, 都是描述一个 “任务”。Callable 描述的是带有返回值的任务, Runnable 描述的是不带返回值的任务。
③ Callable 通常需要搭配 FutureTask 来使用, FutureTask 用来保存 Callable 的返回结果。 因为
Callable 往往是在另一个线程中执行的, 啥时候执行完并不确定。 FutureTask 就可以负责这个等待结果出来的工作。
ReentrantLock类
【ReentrantLock:一定要掌握发音以及拼写】
- ReentrantLock 是可重入锁。
- synchronized也是可重入锁,但是仍然有些操作是做不到的,此时的ReentrantLock是对synchronized的一个补充。
- ReentrantLock类的核心用法(三个方法):
①lock()加锁;
②unlock()解锁;
③tryLock()试试看能否加锁,成功即加锁,不成功就不加
- ReentrantLock的 缺点:如果在lock() 和 unlock() 之间有return或者是有异常,就可能执行不到unlock了;而synchronized没有该风险,只要代码出了代码块就一定执行解锁。
(即:synchronized 使用时不需要手动释放锁。ReentrantLock 使用时需要手动释放. 使用起来更灵活, 但是也容易遗漏 unlock)
一般会写为:
try {
locker.lock();
// ...
} finally {
locker.unlock();
}
//此时即使中间return或异常也可以执行unlock
-
有些特定的功能是synchronized做不到的。也就是ReentrantLock优势:
1)tryLock()试试看能否加锁,试成功即加锁,试失败就放弃不加;并且还可以设定加锁的等待超时时间。(实际开发中使用“死等”策略要慎重)
(即:synchronized 在申请锁失败时, 会死等。ReentrantLock 可以通过 trylock 的方式等待一段时间就放弃)
2)ReentrantLock 可以实现公平锁,本来默认是非公平锁。传入一个参数true就是公平锁(“先来后到”)
3)synchronized是搭配wait/notify实现等待通知机制的,唤醒操作是随机唤醒一个等待的线程。而 ReentrantLock搭配Condition类实现的,唤醒操作是可以指定哪个线程等待的线程被唤醒的。 -
【面试中常见问题:说说synchronized和ReentrantLock的区别】
答: ① 【区别=优势+缺点】
② 网上资料还有一个区别:synchronized是java关键字,底层是JVM实现的(也就是大概率通过C++实现的);而ReentrantLock是标准库中的一个类,底层是基于java实现的。 -
如何选择使用哪个锁?
答: ① 锁竞争不激烈的时候, 使用 synchronized, 效率更高, 自动释放更方便。
② 锁竞争激烈的时候, 使用 ReentrantLock, 搭配 trylock 更灵活控制加锁的行为, 而不是死等。
③ 如果需要使用公平锁, 使用 ReentrantLock。
原子类
-
原子类的底层是基于CAS实现的,java中已经封装好,可以直接使用。
-
Java帮助文档中的 java.util.concurrent.atomic就是原子类,其中最常用的类就是AtomicInteger和AtomicLong。
-
使用原子类最常见的场景就是多线程计数。如:写了个服务器,计算该服务器一共有多少并发量就可以通过这样的原子变量来进行累加。
-
参考代码:AtomicInteger原子类
-
CAS香归香,但是适用场景有限; synchronized虽然没有CAS香,但是适用场景更多,更通用。
线程池
- 重点理解ThreadPoolExecutor的构造方法的参数含义 以及 拒绝策略【重点!】!
- Executors (工厂类)创建线程池的几种方式:
① newFixedThreadPool: 创建固定线程数的线程池
② newCachedThreadPool: 创建线程数目动态增长的线程池.
③ newSingleThreadExecutor: 创建只包含单个线程的线程池.
④ newScheduledThreadPool: 设定延迟时间后执行命令,或者定期执行命令。 是进阶版的 Timer.
Executors 本质上是 ThreadPoolExecutor 类的封装。
- 详细参考:多线程
信号量Semaphore
【Semaphore单词要会拼写和读!】
- 信号量这个概念是数学家迪杰斯特拉提出的
- 信号量的基本操作有两个:
① P操作:申请一个资源;
② V操作:释放一个资源 - 信号量本身是一个计数器,表示可用资源的个数。
① P操作申请一个资源,可用资源数-1
② V操作释放一个资源,可用资源数+1.
(注意+1 / -1 不用弄混!) - 当计数为0的时候继续P操作就会产生阻塞,阻塞等待到其他线程V操作了为止(基于信号量也是可以实现“生产者消费者模型”)
- 信号量可以视为是一个更广义的锁,锁就是一个特殊的信号量(锁即可用资源只有1的信号量:一把锁只能被一个对象加锁)
- Semaphore 的 PV 操作中的加减计数器操作都是原子的, 可以在多线程环境下直接使用。
- 参考代码:标准库中Semaphore的使用
(注意:标准库中的acquire就是P操作申请资源, release就是V操作释放资源) - 当需求中就是有多个可用资源的时候,就要记得使用信号量Semaphore。
- 把互斥锁看做是计数器为1的信号量(取值只有0和1,也叫做二元信号量)
- Java标准库中提供了Semaphore这个类,其实就是把操作系统提供的信号量封装了一下。
【回顾】JUC:标准库提供的多线程安全相关的包
1.Callable声明一个带返回值的任务,需要搭配FutureTask
2.ReentrantLock容易遗漏解锁操作,可以实现公平锁,还可以实现tryLock,还可以搭配Condition来唤醒指定的等待线程
3.原子类:基于CAS实现的,能够比较高效的完成线程安全的自增自减
4.线程池
5.信号量Semaphore:这是广义的锁,相当于计数器,描述了可用资源的个数。
- P操作:申请资源,计数器-1; V操作:释放资源,计数器+1。
- 如果计数器的值被减成0时,继续P操作,则会产生阻塞。
CountDownLatch
- 使用CountDownLatch就是类似于跑步比赛,使用的时候先设定选手/线程的个数,每个选手撞线(完成工作)就调用以下countDown方法,当撞线次数达到选手的个数就结束比赛。(也就是说,要等到最后一个选手到达/最后一个线程也完成任务 才真正结束)
- 如使用多线程完成任务:要下载一个很大的文件,此时就切分成很多个部分,每个线程负责下载其中的一部分,当所有的线程都下载完毕,整个文件才算下载完成。
- 参考代码:标准库中CountDownLatch使用
(await操作是进行阻塞等待,它是可以指定超过时间/单位的)。 - 如果在未来使用的语言中有CountDownLatch就直接使用,没有的话就自己实现一个。
相关面试题
-
线程同步的方式有哪些?
答:synchronized, ReentrantLock, Semaphore 等都可以用于线程同步。 -
为什么有了 synchronized 还需要 juc 下的 lock?
答:以 juc 的 ReentrantLock 为例,
① synchronized 使用时不需要手动释放锁,ReentrantLock 使用时需要手动释放, 使用起来更
灵活。
② synchronized 在申请锁失败时, 会死等。 ReentrantLock 可以通过 trylock 的方式等待一段时间就放弃。
③ synchronized 是非公平锁, ReentrantLock 默认是非公平锁. 可以通过构造方法传入一个true 开启公平锁模式。
④ synchronized 是通过 Object 的 wait / notify 实现等待-唤醒, 每次唤醒的是一个随机等待的线程。 ReentrantLock 搭配 Condition 类实现等待-唤醒, 可以更精确控制唤醒某个指定的线程。 -
AtomicInteger 的实现原理是什么?
答: 参考:CAS的应用:原子类
(主要看伪代码那儿:比较相同并赋值) -
信号量听说过么?之前都用在过哪些场景下?
答:① 信号量, 用来表示 “可用资源的个数”, 本质上就是一个计数器。
② 使用信号量可以实现 “共享锁”, 比如某个资源允许 3 个线程同时使用, 那么就可以使用 P 操作作为加锁, V 操作作为解锁, 前三个线程的 P 操作都能顺利返回, 后续线程再进行 P 操作就会阻塞等待, 直到前面的线程执行了 V 操作。 -
解释一下 ThreadPoolExecutor 构造方法的参数的含义。
答: 参考:多线程
(构造方法+拒绝策略)
二、 线程安全的集合类
- 标准库里面大部分集合类都是线程不安全的,少数几个安全的如Vector、Stack、HashTable,但是不太推荐使用。
- 如果需要在多线程环境下保证集合类的线程安全,最简单的做法就是自己加锁。
ArrayList
- Collections.synchronizedList(new ArrayList); 其实就是套了一层壳,壳上加了个锁。但是这个做法有点简单粗暴。
- synchronizedList 是标准库提供的一个基于 synchronized 进行线程同步的 List.
- synchronizedList 的关键操作上都带有 synchronized
- 使用 CopyOnWriteArrayList写时(修改)拷贝(复制),也叫“双缓冲区”机制:不加锁保证线程安全,但是适用场景有限:一写多读,写的频率比较低。
- CopyOnWriteArrayList:先复制要修改的数据,然后修改操纵在复制好的新数据上进行,修改完成后修改原来的引用从旧数据到新数据。
- 但是这个操作适用范围非常有限,如果元素特别多/修改特别频繁,就不太适合使用这种方式)
- 这种写时拷贝的思想很多地方都会用到,一个典型的就是:显卡给显示器渲染画面也是类似的操作(在上一帧画面进行显示的时候同时渲染下一帧,下一帧渲染好了之后直接用新的帧代替旧的帧),还有服务器进行配置热加载也经常会用到类似操作)
- 我们可以对CopyOnWrite容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素。
所以:CopyOnWrite容器也是一种读写分离的思想,读和写不同的容器。 - CopyOnWrite的优缺点:
1)优点:
在读多写少的场景下, 性能很高, 不需要加锁竞争.
2)缺点:
① 占用内存较多.
② 新写的数据不能被第一时间读取到。
多线程环境使用队列
- ArrayBlockingQueue
基于数组实现的阻塞队列 - LinkedBlockingQueue
基于链表实现的阻塞队列 - PriorityBlockingQueue
基于堆实现的带优先级的阻塞队列 - TransferQueue
最多只包含一个元素的阻塞队列
多线程环境使用哈希表
- HashTable是线程安全的,但是不推荐使用,其是属于无脑给各种方法加synchronized
- ConcurrentHashMap线程安全的,推荐使用,背后做了很多优化。(多线程下直接无脑使用,单线程使用HashMap)
- HashTable只是简单的把关键方法加上了 synchronized 关键字。
ConcurrentHashMap的优化策略:
- 锁粒度的控制
1) HashTable直接在方法上加synchronized,相当于是对this加锁,即相当于是针对哈希表对象来加锁的,所以,一个哈希表只有一个锁。多个线程的时候,无论这些线程是如何操作这个哈希表的,都会产生锁冲突。
2) HashTable只有一把锁,而ConcurrentHashMap不是一把锁,而是多把锁,然后给每个哈希桶(哈希桶就是哈希表下的一个链表)都加一把锁。
3) 如果两个线程同时访问一个哈希桶的时候才会锁冲突,但如果不是同一个哈希桶就不会锁冲突了。
4)由于哈希桶个数很多,此时恰好两个线程操作同一个哈希桶的概率就大大降低了,因此锁冲突的概率就降低了,大大提升了性能。(锁冲突对性能影响是很大的。)
ConcurrentHashMap的每个哈希桶都有自己的锁,大大降低了锁冲突的概率,性能也就大大提升了。
- ConcurrentHashMap做了一个激进的操作:只给写操作加锁,读操作不加锁。
1)也就是说:两个线程同时修改才会有锁冲突。两个线程同时读是没有锁冲突的; 一个线程读、一个线程写也是没有锁冲突的。
2) 那么 “一个线程读、一个线程写没有锁冲突” 这个操作是否会有线程安全问题?
不一定,我们主要担心的是读的结果是一个修改了一半的数据。
但是ConcurrentHashMap在设计的时候慎重考虑了这一点,在读的时候能够保证读到的是囫囵个的数据,也就是说要么是旧版本、要么是新版本,不可能是写到一半的数据。
另外,读操作中也广泛使用了volatile关键字来保证读到的数据是及时的。
-
充分利用了CAS的特性
1)比如像维护元素个数,都是通过CAS来实现的,而不是加锁; 包括还有一些地方也是直接使用CAS实现的轻量级锁来实现。
2)虽然synchronized内部已经有很多的优化了,但是终究这里的优化是JVM内部的,程序员不可控; 而ConcurrentHashMap的思路就是能不加锁就不加锁)
3)则ConcurrentHashMap的核心优化思路:尽一切可能降低锁冲突的概率!
但凡涉及到很多加锁操作,代码就基本和“高性能”/运行效率/高并发 无缘了。
(性能固然重要,但是相比之下,代码的正确性、开发效率才是更重要的) -
ConcurrentHashMap对于扩容操作也进行了特殊的优化:化整为零(有点儿类似拷贝)
1)HashTable的扩容:当put元素的时候,如果发现当前的负载因子(元素个数/哈希桶的个数)已经超过阈值就需要进行扩容,即申请一个更大的数组,把之前旧的数据给搬运到新的数组上。有一个大问题:如果元素个数很多,则搬运操作就会开销很大。执行一个put操作,正常一个put瞬间完成(哈希表的O(1)特性),但是触发扩容的这一下put可能就会卡很久。
2)ConcurrentHashMap在扩容的时候就不是一次性搬运了,而是一次搬运一点儿。
在触发扩容的时候创建一个新的数组,在扩容期间内,旧的和新的会同时存在一段时间,每次进行哈希表操作的时候都会把旧的内存上的元素搬运一部分到新的空间上,直到最终搬运完成,然后再释放旧的空间。
在这个过程中如果要查询元素,则旧的和新的一起查;如果是插入元素,直接往新的上面插;如果是删除元素,直接删了就不用搬运了。
【经典面试题】
-
HashMap、HashTable、ConcurrentHashMap的区别:(顺序一定要搞对,①②最重要!!)
答:① HashMap线程不安全,HashTable、ConcurrentHashMap 线程安全。
②HashTable、ConcurrentHashMap虽然都是线程安全,但是有很多差别:锁粒度的控制(一把、很多锁),ConcurrentHashMap写操作加锁、读操作不加锁,ConcurrentHashMap利用了CAS特性,ConcurrentHashMap扩容优化:化整为零。
③ 旧版本(jdk1.8之前不包含1.8)的ConcurrentHashMap的实现是分段锁,而新版本(jdk1.8开始)的ConcurrentHashMap是每个链表分一个锁。 【分段锁:好几个链表共用同一把锁。 但是分段锁的锁冲突概率要比每个链表加一把锁更高,代码实现也更复杂】
④ HashMap的 key 允许为null(HashMap是无序的!!TreeMap是有序的),HashTable、ConcurrentHashMap的key不能为null。 -
ConcurrentHashMap的读是否要加锁,为什么?
答: 读操作没有加锁。目的是为了进一步降低锁冲突的概率, 为了保证读到刚修改的数据, 搭配了volatile 关键字。 -
介绍下 ConcurrentHashMap的锁分段技术?
答:① 这个是 Java1.7 中采取的技术, Java1.8 中已经不再使用了。
② 简单的说就是把若干个哈希桶分成一个"段" (Segment), 针对每个段分别加锁。
③ 目的也是为了降低锁竞争的概率。 当两个线程访问的数据恰好在同一个段上的时候, 才触发锁竞争。 -
ConcurrentHashMap在jdk1.8做了哪些优化?
答: ① 取消了分段锁, 直接给每个哈希桶(每个链表)分配了一个锁(就是以每个链表的头结点对象作为锁对象)。
② 将原来 数组 + 链表 的实现方式改进成 数组 + 链表 / 红黑树 的方式. 当链表较长的时候(大于等于8 个元素)就转换成红黑树。
三、【死锁】
-
死锁:一个线程加上锁之后解不开了,也就是程序僵住了。
-
死锁场景:
① 一个线程、一把锁,线程连续加锁两次,如果是不可重入锁就是死锁;而synchronized是可重入锁,就没有这个问题。
C++标准库中的锁/ 操作系统的原生API都是不可重入锁,都会死锁。
② 两个线程、两把锁,互相等待。这种死锁,你的锁即使是可重入的,也是没有办法的。
【延伸出一个问题:面试的时候,现场写一个死锁代码。参考:死锁代码】
③ 多个线程、多把锁,更容易死锁。 描述该死锁场景的一个典型问题:哲学家就餐。 -
【教科书给出死锁的四个必要条件:】
① 互斥使用:锁A被线程1占用,线程2就用不了 (打破不了,锁的基本特性)
② 不可抢占:锁A被线程1占用,线程2就不能把锁A给抢过来,除非线程1释放锁(打破不了,锁的基本特性)
③ 请求和保持:有多把锁,线程1拿到锁A之后,不想释放锁A,还想请求再拿到一个锁B(取决于代码:获取锁B的时候是否释放锁A,有可能打破,但是不普适。主要看需求场景是否允许这么写)
④ 循环等待:线程1等待线程2释放锁,线程2释放锁得等待线程3释放锁,线程3释放锁得等待线程1释放锁 (有把握打破:约定好加锁顺序就可以打破循环等待) -
只要打破其中一个必要条件,就可以解决死锁问题了。如给锁编号:约定在加多个锁的时候,先加编号小的锁,后加编号大的锁,有效避免循环等待-Demo4)
-
未来实际开发中,如果遇到了多个线程多把锁,一定要长个心眼,记得【给锁编号】的方式,否则可能真的会出现死锁。(其实就是“锁排序”方式。)
-
(学校操作系统针对死锁给出的方案是“银行家算法”(把所有资源统一进行统筹分配),也能避免死锁,是一个更普适的方案,但是比较复杂,不太适合实际开发)
-
另,如果有一个方案能够更简单且充分地利用多核资源,代码的坑还少,岂不美滋滋?
——这样的方案确实有:
①erlang actor模型
②Go csp模型 (go能火的很大一部分原因就是在于擅长处理并发编程的场景,即可以高效,代码又简单)
③Js/python async await -
死锁是一种严重的 BUG!! 导致一个程序的线程 “卡死”, 无法正常工作。
-
死锁是这样一种情形:多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止。
-
相关面试题:
谈谈死锁是什么,如何避免死锁,避免算法? 实际解决过没有?
【多线程完结!! 多线程非常重要!!】
(面试爱考,工作中常用)
四、 其他常见问题
-
谈谈 volatile关键字的用法?
答:volatile 能够保证内存可见性, 强制从主内存中读取数据。 此时如果有其他线程修改被 volatile 修饰的变量, 可以第一时间读取到最新的值。 -
Java多线程是如何实现数据共享的?
答:① JVM 把内存分成了这几个区域:方法区, 堆区, 栈区, 程序计数器。
② 其中堆区这个内存区域是多个线程之间共享的。 只要把某个数据放到堆内存中, 就可以让多个线程都能访问到。 -
Java创建线程池的接口是什么?参数LinkedBlockingQueue 的作用是什么?
答:1)创建线程池主要有两种方式:
① 通过 Executors 工厂类创建, 创建方式比较简单, 但是定制能力有限.
② 通过 ThreadPoolExecutor 创建, 创建方式比较复杂, 但是定制能力强.
2)LinkedBlockingQueue 表示线程池的任务队列, 用户通过 submit / execute 向这个任务队列中添加任务, 再由线程池中的工作线程来执行任务。 -
Java线程共有几种状态?状态之间怎么切换的?
答:① NEW: 安排了工作, 还未开始行动。 新创建的线程, 还没有调用 start 方法时处在这个状态.
② RUNNABLE: 可工作的。 又可以分成正在工作中和即将开始工作。 调用 start 方法之后, 并正在CPU 上运行/在即将准备运行 的状态。
③ BLOCKED: 使用 synchronized 的时候, 如果锁被其他线程占用, 就会阻塞等待, 从而进入该状态.
④ WAITING: 调用 wait 方法会进入该状态.
⑤ TIMED_WAITING: 调用 sleep 方法或者 wait(超时时间) 会进入该状态.
⑥ TERMINATED: 工作完成了。 当线程 run 方法执行完毕后, 会处于这个状态.
(可以参考:线程状态) -
在多线程下,如果对一个数进行叠加,该怎么做?
答:① 使用 synchronized / ReentrantLock 加锁
② 使用 AtomInteger 原子操作 -
Servlet是否是线程安全的?
答: Servlet 本身是工作在多线程环境下。
如果在 Servlet 中创建了某个成员变量, 此时如果有多个请求到达服务器, 服务器就会多线程进行操作, 是可能出现线程不安全的情况的。 -
Thread和Runnable的区别和联系?
答:① Thread 类描述了一个线程,Runnable 描述了一个任务。
② 在创建线程的时候需要指定线程完成的任务, 可以直接重写 Thread 的 run 方法, 也可以使用Runnable 来描述这个任务。 -
多次start一个线程会怎么样?
答: ① 第一次调用 start 可以成功调用.
② 后续再调用 start 会抛出java.lang.IllegalThreadStateException 异常。 -
有synchronized两个方法,两个线程分别同时用这个方法,请问会发生什么?
答:① synchronized 加在非静态方法上, 相当于针对当前对象加锁。
② 如果这两个方法属于同一个实例:
线程1 能够获取到锁, 并执行方法. 线程2 会阻塞等待, 直到线程1 执行完毕释放锁, 线程2 获取到锁之后才能执行方法内容。
③ 如果这两个方法属于不同实例:
两者能并发执行, 互不干扰。 -
进程和线程的区别?
答: ① 进程是包含线程的, 每个进程至少有一个线程存在,即主线程。
② 进程和进程之间不共享内存空间, 同一个进程的线程之间共享同一个内存空间。
③ 进程是系统分配资源的最小单位,线程是系统调度的最小单位。
THINK
- JUC常见类:
Callable接口、ReentrantLock类、原子类、线程池、信号量Semaphore、CountDownLatch - 线程安全:主要是HashTable、ConcurrentHashMap与HashMap
- 死锁
- 相关面试题【重要!!!】