多线程编程常见面试题讲解(锁策略,CAS策略,synchronized原理,JUC组件,集合类)(上)
https://developer.aliyun.com/article/1480727?spm=a2c6h.13148508.setting.14.5f4e4f0eLGd5Sm
💕"跑起来就有意义"💕
作者:Mylvzi
文章主要内容:多线程编程常见面试题讲解
hello各位朋友们,最近笔者刚刚结束了学校的期末考试,现在回来继续更新啦!!!
今天要学习的是多线程常见面试题讲解,这些内容都是面试中常考的一些问题!
四.JUC部分组件讲解
JUC全程Java.util.concurrent,是Java标准库内部关于多线程常见的一个包,在多线程编程的过程中,会经常使用到包中的常用的组件,这里介绍几个常用的组件
1.Callable接口
Callable接口和Runnable接口类似,都是用于执行任务的类,区别在于Runnable执行的任务的返回值是void,也就是说不要求返回值,但是Callable接口执行的任务是有返回值的
考虑这么一个场景,我需要额外的一个线程去计算1+到100的结果(注意不是在主线程中执行,而是额外创建一个线程),并在主线程中打印结果,如果使用Runnable接口来完成这个任务,涉及到主线程去等待计算结果的线程执行完毕,才能去打印结果
// 使用Runnable解决 private static int sum; private static Object locker = new Object(); public static void main(String[] args) throws InterruptedException { Thread t = new Thread(() -> { synchronized (locker) { for (int i = 0; i <= 100; i++) { sum += i; } // 任务执行完毕 唤醒加锁的线程 locker.notify(); } }); t.start(); synchronized (locker) { while (sum == 0) { // 等待线程执行解锁 locker.wait(); } } System.out.println(sum); }
使用Runnable接口就涉及到线程的等待与唤醒,如果使用Callable接口可以不适用notify和wait方法
public static void main(String[] args) throws ExecutionException, InterruptedException { // 使用匿名内部类创建出Callable接口 // 类似于Runnable接口 都适用于存储要执行的任务 // 区别在于Callable接口里的任务有返回值 Callable<Integer> callable = new Callable<Integer>() { @Override public Integer call() throws Exception { int sum = 0; for (int i = 0; i <= 100; i++) { sum += i; } return sum; } }; // Thread类不能直接使用Callble接口创建 // 需要通过Runnable的子类FutureTask来实现 // 这个子类还是很常见的 只是叫的名字不同 如JS中被称作promise FutureTask<Integer> futureTask = new FutureTask<>(callable); // 创建一个线程 让线程执行上述的任务 Thread t1 = new Thread(futureTask); t1.start(); // get方法会接受任务的返回值 // 但是可能存在Callable接口内部的任务还没有执行完毕 // 此时get方法就会阻塞等待 System.out.println(futureTask.get()); }
说明:
- call方法是Callable接口的核心方法,用于存储有返回值的要执行的任务,类似于Runnable接口中的run方法
- Thread类的构造只能通过Runnable及其子类来实现,Callable接口无法直接参与到Thread类的构造方法,需要先将其转换为Runnable的子类FutureTask,再让FutureTask参与到Thread类的构造方法之中(FuturTask的参数要和Callable接口所执行任务的返回值相同)
- 要获取到任务的返回值,可以使用futuretask中的get方法,如果执行计算的线程并未执行完毕,get方法就会阻塞等待,一直等到计算完毕!
所以,对于要执行有返回值的任务来说,使用Callable接口会更加简便,实际上,Callable接口也是创建线程方式的一种,致此我们已经学过很多的线程创建的方式,这里总结一下:
- 继承Thread类,重写run(创建单独的类/匿名内部类)
- 实现RUnnable接口,重写run(创建单独的类/匿名内部类)
- 实现Callable接口,重写call
- 使用lambda表达式
- 使用ThreadFactory
- 通过线程池创建
2.ReentrantLock
ReentrantLock也是java中用于加锁的一种方式,英文名直译为"可重入的锁",证明ReentrantLock具有可重入的特性,相较于synchronized有以下几个优势
- ReentrantLock在加锁的时候有两种方式,lock和trylock,提供了更灵活的操作空间
- ReentrantLock可以搭配Condition类实现更加灵活的线程间通信
- ReentrantLock还提供了公平锁的实现机制(默认是非公平锁)
以下是ReentrantLock的一个简单的使用示例:
// 创建一个ReentrantLock对象 ReentrantLock lock = new ReentrantLock(); // 加锁 lock.lock(); try{ // 需要加锁执行的代码 }finally { // 注意ReentrantLock不会自动解锁 需要手动解锁!!! lock.unlock(); } }
使用ReentrantLock 实现公平锁,基于ReentrantLock 的构造方法实现,ReentrantLock 的构造方法中有一个布尔类型的参数,默认是false,所以默认情况下是非公平锁,如果改为true,那就是公平锁,公平锁就会按照"先来后到"的顺序让线程获取到锁
使用Reentrant实现公平锁的一个代码示例:
// 设置为true 表示是一个公平锁 public static ReentrantLock lock = new ReentrantLock(true); // 定义要执行的任务 static class Worker implements Runnable { // 用于标识线程id public int id; // 构造方法 public Worker(int id) { this.id = id; } // 规定要执行的任务 @Override public void run() { System.out.println("线程: " + id + "正在尝试获取锁"); lock.lock(); try { System.out.println("线程: " + id + "已经获取到锁"); }finally { // 解锁 lock.unlock(); System.out.println("线程: " + id + "释放锁"); } } } public static void main(String[] args) { for (int i = 0; i < 5; i++) { Thread t = new Thread(new Worker(i)); t.start(); } }
结果说明
虽然 ReentrantLock提供了更加灵活的操纵方式,但是特别容易忘记解锁,而且ReentrantLock在使用上也会更加的复杂,在需要加锁的时候,还是更推荐使用synchronized进行加锁(当然了,如果在一些需要使用公平锁的情境下,还是需要使用ReentrantLock)
3.Semaphore
Semaphore(信号量),是一个计数器,用于描述可用资源的个数
就像自动停车场一样,停车场中的停车位的个数就是可用资源的个数,每进去一个车,车位就减少1,每出来一个车,车位就加1.在Java中我们使用P操作代表使用一个可用资源(车进去),每P操作一次,可用资源个数就减1,使用V操作代表返还一个可用资源(车出来),每V操作一次,可用资源的个数就加1
P,V操作的最初命名是由荷兰的数学家迪杰斯特拉命名的,实际上,P操作就是acquire(获得),V操作就是release(释放)
假设我们将信号量设置为5,也就是可用资源的个数是5,我们连续P操作5次之后,可用资源的个数为0,如果想继续进行P操作,就要阻塞等待有其他线程进行V操作,讲到这里有没有一种锁
的感觉?实际上,锁是一种特殊的信号量,是可用资源为1的信号量,又被称为二元信号量
,每P操作一次,可用资源减1,变为0,其他线程想要获取(P操作),就要阻塞等待,只能等到持有锁的线程进行V操作,使可用资源个数再次变为1
Semaphore的简单使用(规定并发编程的线程数目)
public static void main(String[] args) { // 限制最多可以并发编程的线程数目为2 final int MAX_CONCURRENT_TASKS = 2; // 创建可用资源为2的信号量 Semaphore semaphore = new Semaphore(2); // 线程执行任务 for (int i = 1; i <= 5; i++) { final int taskId = i; // 启动线程 执行任务 Thread t = new Thread(() -> { try { // 先打印获取信号量的线程id System.out.println("线程 " + taskId + "正在获取信号量"); // 可用资源数目减1 如果为0 线程就阻塞等待 semaphore.acquire(); System.out.println("线程 " + taskId + "已经获取到信号量"); // 规定任务的执行时间 Thread.sleep(2000); } catch (InterruptedException e) { throw new RuntimeException(e); }finally { // 任务执行完毕 释放信号量 semaphore.release(); System.out.println("线程 " + taskId + "执行完毕,可用资源加1"); } }); t.start(); } }
执行结果:
4.CountDownLatch
CountDownLatch是java并发包中常用的一个类,用于多线程编程中需要等待多个线程完成某一操作之后再继续执行其它代码的场景
下面讲解CountDownLatch的基本用法
1.创建CountDownLatch对象
import java.util.concurrent.CountDownLatch; // 等待的线程数为3 CountDownLatch latch= new CountDownLatch(3);
2.等待线程调用await方法
// 阻塞等待直到所有线程都执行完毕 latch.await();
3.线程执行完毕 要等待的线程数目减1
latch.countDown();
以下是一个简单的使用案例
public static void main(String[] args) throws InterruptedException { // 等待的线程数为3 int numberOfTasks = 3; CountDownLatch latch = new CountDownLatch(numberOfTasks); for (int i = 0; i < numberOfTasks; i++) { Thread t = new Thread(() ->{ System.out.println(Thread.currentThread().getId() + "正在工作!"); // 每完成一个任务就减1 latch.countDown(); }); t.start(); } // 主线程等待所有的线程执行完毕 latch.await(); System.out.println("所有线程都执行完毕"); }
CountDownLatch 最常使用的场景是将大任务拆分为小任务一个一个执行,使用CountDownLatch 让一个线程去执行一个任务,让主线程等待所有的线程执行完毕,最后再合并结果,这样可以大大的提高效率
包括网络上常见的一些提高下载速度的软件,本质上也是使用了"先拆分为小任务,让一个线程去执行一个小任务,最后再合并"的思路,多条线路去进行下载,大大提高了效率
5.线程安全的集合类
在之前,我们所学习过得数据结构都是线程不安全的,如stackqueue等,在单个线程下使用时没有问题的,但是在多线程下机会发生问题,其实,java中也引入了一些线程安全的集合类,如:
Vector,Stack,HashTable
但是这些集合类都是比较古老的集合类,尤其是Vector和Stack未来是要被废弃的,不推荐继续使用了
为了解决这种数据结构的集合能够在多线程环境下安全的使用,java也提供了更多的安全使用方式,这些方式的核心还是通过加锁来实现线程安全
1.多线程环境使用 ArrayList
方法一:使用同步机制保证线程安全
常见的同步机制包括synchronized,Semaphore,ReentrantLock之前已经做过详细的介绍,这里不再解释
方法二:Collections.synchronizedList
Collections.synchronizedList()
这个方法会返回一个自带synchronized
的List集合,相当于给常规的ArrayList套了一个线程安全的盔甲
List<Integer> arrayList = Collections.synchronizedList(new ArrayList<Integer>());
为什么要通过给ArrayList套盔甲的方式来实现线程安全呢?主要是吸取了Vector集合将集合与加锁牢牢绑定在一起,导致处理较多数据时,性能会很低,而通过Collections.synchronizedList这种方式,实现了数据集合和加锁的分离,降低了耦合性**,既可以在单线程中保证效率,又可以在多线程中保证线程安全
方法三:使用 CopyOnWriteArrayList
CopyOnWriteArrayList 从组成的单词也可以大概看出这个集合的用法,即当需要写的时候进行复制,这种方法通过额外开辟空间的方式来保证线程安全,没有加锁,是通过空间换性能提升
当存在两个线程同时访问共享资源时,两个线程读的时候正常读,因为这不会引发线程安全问题,当一个线程尝试修改数据时,不是直接对原有的资源进行修改,而是先对要修改资源进行复制,创建出一个副本,对这个副本进行修改,另一个线程读取数据时还可以读取原来的数据,保证了线程安全
但实际上,这种方法也有一定的局限性,只适用于特定的场景,
- 当前的ArrayList不能太大,否则拷贝资源的成本会更大
- 更适用于一个线程修改,多个线程读取的情况,如果多个线程尝试修改,也会导致拷贝资源过大,发生混乱
CopyOnWriteArrayList经常在服务器配置更新中使用,服务器配置的更新是通过修改服务器文件的形式进行的,通常是使用一个线程去修改服务器配置文件,修改完毕之后,配置文件被存到内存之中,其他的服务器就可以进行读取
2.多线程环境使用队列
在Java标准库内部有一些专门用于线程安全的队列
- ArrayBlockingQueue 基于数组实现的阻塞队列
- LinkedBlockingQueue 基于链表实现的阻塞队列
- PriOritityBlockingQueue 基于优先级队列实现的阻塞队列
- TransferQueue 用于传输的阻塞队列
3.ConcurrentHashMap
hash表的一个优化的线程安全实现方式
最开始,java中有一个集合类HashTable就是哈希表的线程安全实现方式,他的安全是通过加锁实现的,但是它是对整个哈希表进行加锁,只要涉及到对hash表的操作,就会发生阻塞等待,但如果我们观察一下哈希表的结构,就会发现其实不需要对整个哈希表进行加锁,哈希表是通过哈希桶实现的,哈希桶就是一个特殊的数组,数组的每个元素是队列,下面是哈希表的结构
实际上,只有在多个线程同时访问同一个链表时才会发生线程安全问题,如果线程之间访问的不是同一个链表,就不会发生线程安全问题,所以没有必要对整个hash表进行加锁,所以ConcurrentHashMap通过对每个链表的头结点进行加锁的方式来降低了锁冲突发生的概率,提高了哈希表的使用性能.ConcurrentHashMap主要有以下四个方面的优化
- 最核心的优化就是降低了锁的粒度,通过对每个链表的头结点进行加锁的方式降低了锁冲突发生的概率,提高了性能
- ConcurrentHashMap还对哈希表中的一些操作进行了优化,比如通过CAS来统计哈希表中元素的数量,保证++的操作的原子性
- ConcurrentHashMap还有一些比较激进的做法,在HashTable中,多个线程无论是读还是写都会引发锁冲突,但是ConcurrentHashMap对于读,读和写这样的操作都不加锁,只会对写与写之间进行加锁
这样难道不会引发读到一个修改一般的值这样的问题么?实际上在ConcurrentHashMap内部尽量避免使用++/–这样的非原子的操作,而是直接使用"="这样的操作来进行修改的操作,保证了原子性 - ConcurrentHashMap还对扩容做出了一定的优化,对于在单线程中使用的HashMap来说,扩容时需要对整个哈希表的所有元素进行重新分配,对于数据量特别大的场景来说,这样的操作可能会导致系统短时间内的卡顿,为了避免这种情况发生,ConcurrentHashMap采用"分段扩容"的方式,即一个链表一个链表元素进行重新分配,保证了系统不会因大量数据的重新分配导致崩溃
补充:
在Java8之前,ConcurrentHashMap加锁的方式是"分段锁",即几个链表作为一个整体进行加锁,但是在Java8之后就采用了每个链表头结点加锁的方式
补充:
总结 HashTable, HashMap, ConcurrentHashMap 之间的区别
这三个集合类都是存储键值对的数据结构,但是在线程安全方面有所不同
- 线程安全
HashTable:线程安全,较早出现,锁的粒度较大,对整个哈希表进行加锁
HashMap:线程不安全,在多线程环境下需要额外的安全机制
ConcurrentHashMap:线程安全,锁的粒度更小,通过对锁头
加锁的方式带来更高的并发效率 - Null值的处理
HashTable:不允许键或值为null,否则会抛出NullPointerException。
HashMap:允许键和值都为null。
ConcurrentHashMap:允许键和值都为null。 - 性能
HashTable:所有的操作都是加锁的,在多线程环境下性能可能会比较低
HashMap:在单线程环境下性能较好,在多线程环境下需要额外的同步措施来保证线程安全。
ConcurrentHashMap:在多线程环境下的性能比HashTable更高,更细粒度的锁带来了更加高性能的并发环境
单线程环境下使用HashMap(性能最高,不用考虑线程安全),多线程环境下使用ConcurrentHashMap(性能较高,线程安全)