洞察纵观鸿蒙next版本,如何凭借FinClip加强小程序的跨平台管理,确保企业在数字化转型中的高效运营和数据安全?
681
2022-10-30
【多线程】JUC详解 (Callable接口、RenntrantLock、Semaphore、CountDownLatch) 、线程安全集合类面试题
@TOC
一、JUC (java.util.concurrent)
1. Callable 接口
Callable 是一个 interface . 也是一种创建线程的方式。
谈到创建多线程,就会想到Runnable 接口。 但是Runnable 有个问题:不适合于 让线程计算出一个结果,这样的代码。
例如:像创建一个线程,让这个线程计算 1+2+3+…+1000
要基于 Runnable 来实现,就很麻烦。
创建一个类 Result , 包含一个 sum 表示最终结果,lock 表示线程同步使用的锁对象. main 方法中先创建 Result 实例,然后创建一个线程 t. 在线程内部计算 1 + 2 + 3 + … + 1000. 主线程同时使用 wait 等待线程 t 计算结束. (注意, 如果执行到 wait 之前, 线程 t 已经计算完了, 就不必等待了). 当线程 t 计算完毕后,通过 notify 唤醒主线程,主线程再打印结果
可以看到, 上述代码需要一个辅助类 Result, 还需要使用一系列的加锁和 wait notify 操作, 代码复 杂, 容易出错.
代码示例: 创建线程计算 1 + 2 + 3 + ... + 1000, 使用 Callable 版本
创建一个匿名内部类, 实现 Callable 接口. Callable 带有泛型参数. 泛型参数表示返回值的类型. 重写 Callable 的 call 方法, 完成累加的过程. 直接通过返回值返回计算结果. 把 callable 实例使用 FutureTask 包装一下. 创建线程, 线程的构造方法传入 FutureTask . 此时新线程就会执行 FutureTask 内部的 Callable 的 call 方法, 完成计算. 计算结果就放到了 FutureTask 对象中. 在主线程中调用 futureTask.get( ) 能够阻塞等待新线程计算完毕. 并获取到 FutureTask 中的结 果.
import java.util.concurrent.Callable;
import java.util.concurrent.FutureTask;
public class Test {
public static void main(String[] args) {
//通过callable来描述一个这样的任务~~
Callable
理解 Callable
Callable 和 Runnable 相对, 都是描述一个 “任务”. Callable 描述的是带有返回值的任务,Runnable 描述的是不带返回值的任务. Callable 通常需要搭配 FutureTask 来使用. FutureTask 用来保存 Callable 的返回结果. 因为Callable 往往是在另一个线程中执行的, 啥时候执行完并不确定. FutureTask 就可以负责这个等待结果出来的工作
理解 FutureTask
想象去吃麻辣烫. 当餐点好后, 后厨就开始做了. 同时前台会给你一张 “小票” . 这个小票就是FutureTask. 后面我们可以随时凭这张小票去查看自己的这份麻辣烫做出来了没
2. ReentrantLock (可重入锁)
ReentrantLock其实就是可重入锁
我们都知道 synchronized 也是一个可重入锁。现在又蹦出一个 ReentrantLock。 为什么有了 synchronized,还需要 ReentrantLock呢? 这是因为 ReentrantLock 可以做到一些 synchronized 实现不了的功能。 也就是说 ReentrantLock 提供了 一些 synchronized 没有的功能。
基础用法
ReentrantLock 主要提供了3个方法:
1、lock:加锁 2、unlock:解锁 3、trylock(超时时间):加锁,如果获取不到锁,等待一定的时间之后就放弃加锁
把加锁和解锁两个操作分开了
ReentrantLock lock = new ReentrantLock(); // ----------------------------------------- lock.lock(); try { // working } finally { lock.unlock() // 保证不管是否异常都能执行到 unlock, 这么写比较麻烦 }
当多个线程竞争同一把锁的时候,就会阻塞。【这一点和 synchronized一样】
ReentrantLock 和 synchronized 区别
1、
synchronized 是一个关键字 ReentrantLock 是一个标准库的类。
关键字就意味着:其背后的逻辑是 JVM 内部实现的。(C++代码实现的)类:背后的逻辑是 Java代码实现的
2、
synchronized 不需要手动释放锁,出了代码块,锁就自然释放了。 ReentrantLock 必须要手动释放锁,要谨防忘记释放。
3、(重点)
synchronized 如果竞争锁失败,就会阻塞等待 ReentrantLock 除了会阻塞等待,还有一手:trylock【失败了,就直接返回】
trylock 给我们加锁操作增添了一份灵活性,并不需要完全去进行阻塞死等,可以根据我们的需要,来选择等还是不等,还是说等,以及等多久、 所以 trylock 给了我们更加灵活的回旋余地。 这是synchronized 所不具备的!
4、(重点)
synchronized 是一个非公平锁。 ReentrantLock 提供了 非公平锁 和 公平锁 两个版本!!!
在构造方法中,通过参数来指定 当前是公平锁,还是非公平锁。
5、
基于 synchronized 衍生出来的 等待机制,是 wait 和notify。功能是有限的 基于 ReentrantLock 衍生出来的 等待机制,是 Condition 类(又可称为条件变量)功能要更丰富一些。
如何选择使用哪个锁?
锁竞争不激烈的时候,使用 synchronized,效率更高,自动释放更方便 锁竞争激烈的时候,使用 ReentrantLock,搭配 trylock 更灵活控制加锁的行为,而不是死等 如果需要使用公平锁,使用 ReentrantLock 日常开发中,绝大部分情况下,synchronized就够用了!
3. Semaphore 信号量
是一个更广义的锁
锁是信号量里第一种特殊情况,叫做 “二元信号量"
理解信号量
开车经常会遇到一个情况,停车,停车场入口一般会有个牌子,上面写着 “当前空闲xx个车位”,每次有个车开出来,车位数+1这个牌子就是信号量,描述了可用资源 (车位)的个数,每次申请一个可用资源,计数器就 -1 (称为Р操作) 当有车开进去的时候,就相当于申请一个可用资源,可用车位就 -1 (这个称为信号量的 P 操作) 当有车开出来的时候,就相当于释放一个可用资源,可用车位就 +1 (这个称为信号量的 V 操作) 如果计数器的值已经为 0 了,还尝试申请资源,就会阻塞等待,直到有其他线程释放资源 锁就可以视为 “二元信号量”,可用资源就一个,计数器的取值非 0 即 1 信号量就把锁推广到了一般情况,可用资源更多的时候,如何处理的 实际开发中,并不会经常用到信号量
Semaphore 的 PV 操作中的加减计数器操作都是原子的, 可以在多线程环境下直接使用
代码示例:
创建 Semaphore 示例, 初始化为 4, 表示有 4 个可用资源. acquire 方法表示申请资源(P操作), release 方法表示释放资源(V操作) 创建 20 个线程, 每个线程都尝试申请资源, sleep 1秒之后, 释放资源. 观察程序的执行效果
Semaphore semaphore = new Semaphore(4); Runnable runnable = new Runnable() { @Override public void run() { try { System.out.println("申请资源"); semaphore.acquire(); System.out.println("我获取到资源了"); Thread.sleep(1000); System.out.println("我释放资源了"); semaphore.release(); } catch (InterruptedException e) { e.printStackTrace(); } } }; for (int i = 0; i < 20; i++) { Thread t = new Thread(runnable); t.start(); }
4. CountDownLatch
我们可以把它理解为 “终点线”
同时等待 N 个任务执行结束
这样的场景在开发中,也是存在的
例如:多线程- 迅雷…-一个比较大的资源 (电影),通过多线程-就可以提高-速度 把一个文件拆成多个部分,每个线程负责-其中的一个部分,得是所有的线程都完成自己的-,才算整 个-完
countDown 给每个线程里面去调用,就表示到达终点了 await 是给等待线程去调用,当所有的任务都到达终点了,await 就从阻塞中返回,就表示任务完成
好像跑步比赛,10个选手依次就位,哨声响才同时出发;所有选手都通过终点,才能公布成绩
代码示例:
构造 CountDownLatch 实例,初始化 10 表示有 10 个任务需要完成 每个任务执行完毕,都调用 latch.countDown() ,在 CountDownLatch 内部的计数器同时自减. 主线程中使用 latch.await(); 阻塞等待所有任务执行完毕,相当于计数器为 0 了
import java.util.concurrent.CountDownLatch; public class Test2 { public static void main(String[] args) throws InterruptedException { // 构造方法的参数表示有几个选手 CountDownLatch latch = new CountDownLatch(10); for (int i = 0; i < 10; i++) { Thread t = new Thread(() -> { try { Thread.sleep(3000); System.out.println(Thread.currentThread().getName() + " 到达终点"); latch.countDown(); // 调用 countDown 的次数和个数一致,此时就会await返回的情况 } catch (InterruptedException e) { e.printStackTrace(); } }); t.start(); } // 裁判要等所有线程到达 // 当这些线程没有执行完的时候,wait 就会阻塞,所有线程执行完了,await 才返回 latch.await(); System.out.println("比赛结束"); } }
运行结果:
Thread-7 到达终点 Thread-8 到达终点 Thread-6 到达终点 Thread-9 到达终点 Thread-1 到达终点 Thread-0 到达终点 Thread-3 到达终点 Thread-2 到达终点 Thread-4 到达终点 Thread-5 到达终点 比赛结束
二、线程安全的集合类
这里面其实也算是 JUC 的一部分。
Vector、Stack、HashTable 是线程安全的,但是不推荐使用,其他剩余类都是线程不安全的。
1. 多线程环境使用 ArrayList
1、 自己使用同步机制 (synchronized 或者 ReentrantLock)
2、使用标准库里面的操作:Collections.synchronizedList(new ArrayList);
Collections.synchronizedList(new ArrayList);
synchronizedList 是标准库提供的一个基于 synchronized 进行线程同步的 List. synchronizedList 的关键操作上都带有 synchronized 第二种方法没有第一种方法灵活,因为并不是所有的方法都涉及到加锁。 第二种方法,就属于无脑加锁的
3、使用 CopyOnWriteArrayLis
写时拷贝,在修改的时候,会创建一份副本出来
CopyOnWrite容器即写时复制的容器。 当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy,复制出一个新的容器,然后新的容器里添加元素, 添加完元素之后,再将原容器的引用指向新的容器。这样做的好处是我们可以对 CopyOnWrite 容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素。所以 CopyOnWrite 容器也是一种读写分离的思想,读和写不同的容器。 优点: 在读多写少的场景下, 性能很高, 不需要加锁竞争. 缺点: 占用内存较多. 新写的数据不能被第一时间读取到
这样做的好处,就是修改的同时对于读操作,是没有任何影响的,读的时候优先读旧的版本不会说出现读到一个 “修改了一半” 的中间状态 也叫做 “双缓冲区" 策略操作系统,创建进程的时候,也是通过写时拷贝。显卡在渲染画面的时候,也是通过类似的机制。 也是适合于读多写少的情况,也是适合于数据小的情况更新配置数据,经常会用到这种类似的操作
2、多线程环境使用队列
1). ArrayBlockingQueue //基于数组实现的阻塞队列 2). LinkedBlockingQueue //基于链表实现的阻塞队列 3). PriorityBlockingQueue //基于堆实现的带优先级的阻塞队列 4). TransferQueue //最多只包含一个元素的阻塞队列
这个在之前写过了就不写了
3、多线程下使用哈希表 【高频】
首先我们要明白 HashMap 这个类 本身 线程并不安全。不能直接在多线程中使用
解决方法:
1、使用 HashTable 类 【不推荐使用】
2、使用 ConcurrentHashMap 类 【推荐使用】
至于为什么不推荐使用 HashTable ,而是推荐 ConcurrentHashMap 类。 这就需要了解 HashTable 内部的构造, HashTable 与 HashTableConcurrentHashMap 的区别。
HashTable 是如何保证线程安全的 ?
就是给关键方法加锁 public synchronized V put(K key, V value) { public synchronized V get(Object key) { 上述这种直接对 put/get 方法进行加锁的操作。 其实就是在针对 this 来进行加锁 当有多个线程 来访问这个 HashTable 的时候,无论是什么样的操作,什么样的数据,都会出现锁竞争。 这样的设计就会导致锁竞争的概率非常大,效率就比较低!
ConcurrentHashMap 是如何保证线程安全的 ?
操作元素的时候,是针对这个 元素所在的链表的头结点 来加锁的 如果你两个线程操作是针 对两个不同的链表上的元素, 没有线程安全问题,其实不必加锁 由于 hash 表中,链表的数目非常多,每个链表的长度是相对短的,因此就可以保证锁冲突的概率就非常小了
总结:
ConcurrentHashMap 减少了锁冲突,就让锁加到每个链表的头结点上 (锁桶) ConcurrentHashMap 只是针对写操作加锁了,读操作没加锁,而只是使用 ConcurrentHashMap 中更广泛的使用 CAS,进一步提高效率 (比如维护 size 操作) ConcurrentHashMap 针对扩容,进行了巧妙的化整为零 对于 HashTable 来说,只要你这次 put 触发了扩容,就一口气搬运完,会导致这次 put 非常卡顿 对于 ConcurrentHashMap,每次操作只搬运一点点,通过多次操作完成整个搬运的过程 同时维护一个新的 HashMap 和一个旧的,查找的时候,既需要查旧的也要查新的,插入的时候**只插入新的,直到搬运完毕再销毁旧的
三、多线程哈希表面试题
1. ConcurrentHashMap的读是否要加锁,为什么?
读操作没有加锁. 目的是为了进一步降低锁冲突的概率。 为了保证读到刚修改的数据, 搭配了volatile 关键字
2. 介绍下 ConcurrentHashMap的锁分段技术?
这个是 Java1.7 中采取的技术. Java1.8 中已经不再使用了。 简单的说就是把若干个哈希桶分成一个"段" (Segment), 针对每个段分别加锁. 目的也是为了降低锁竞争的概率. 当两个线程访问的数据恰好在同一个段上的时候, 才触发锁竞争.
3. ConcurrentHashMap在jdk1.8做了哪些优化?
取消了分段锁, 直接给每个哈希桶(每个链表)分配了一个锁(就是以每个链表的头结点对象作为锁对象)。 将原来 数组 + 链表 的实现方式改进成 数组 + 链表 / 红黑树 的方式. 当链表较长的时候(大于等于 8 个元素)就转换成红黑树
4. Hashtable和HashMap、ConcurrentHashMap 之间的区别?
HashMap: 线程不安全. key 允许为 null Hashtable: 线程安全. 使用 synchronized 锁 Hashtable 对象, 效率低. key 不允许为 null. ConcurrentHashMap: 线程安全. 使用 synchronized 锁每个链表头结点, 锁冲突概率低, 充分利用CAS 机制. 优化了扩容方式. key 不允许为 null
四、其他面试题
1)、谈谈 volatile关键字的用法?
1)、谈谈 volatile关键字的用法?
volatile 能够保证内存可见性. 强制从主内存中读取数据. 此时如果有其他线程修改被 volatile 修饰 的变量, 可以第一时间读取到最新的值
2)、Java多线程是如何实现数据共享的?
2)、Java多线程是如何实现数据共享的?
JVM 把内存分成了这几个区域: 方法区, 堆区, 栈区, 程序计数器. 其中堆区这个内存区域是多个线程之间共享的. 只要把某个数据放到堆内存中, 就可以让多个线程都能访问到
3)、Java创建线程池的接口是什么?参数 LinkedBlockingQueue 的作用是什么?
3)、Java创建线程池的接口是什么?参数 LinkedBlockingQueue 的作用是什么?
创建线程池主要有两种方式: 通过 Executors 工厂类创建. 创建方式比较简单, 但是定制能力有限. 通过 ThreadPoolExecutor 创建. 创建方式比较复杂, 但是定制能力强. LinkedBlockingQueue 表示线程池的任务队列. 用户通过 submit / execute 向这个任务队列中添加任务, 再由线程池中的工作线程来执行任务.
4)、Java线程共有几种状态?状态之间怎么切换的?
4)、Java线程共有几种状态?状态之间怎么切换的?
NEW: 安排了工作, 还未开始行动. 新创建的线程, 还没有调用 start 方法时处在这个状态. RUNNABLE: 可工作的. 又可以分成正在工作中和即将开始工作. 调用 start 方法之后, 并正在CPU 上运行/在即将准备运行 的状态. BLOCKED: 使用 synchronized 的时候, 如果锁被其他线程占用, 就会阻塞等待, 从而进入该状态. WAITING: 调用 wait 方法会进入该状态. TIMED_WAITING: 调用 sleep 方法或者 wait(超时时间) 会进入该状态. TERMINATED: 工作完成了. 当线程 run 方法执行完毕后, 会处于这个状态.
5)、在多线程下,如果对一个数进行叠加,该怎么做?
5)、在多线程下,如果对一个数进行叠加,该怎么做?
使用 synchronized / ReentrantLock 加锁 使用 AtomInteger 原子操作
6)、Servlet是否是线程安全的?
6)、Servlet是否是线程安全的?
Servlet 本身是工作在多线程环境下. 如果在 Servlet 中创建了某个成员变量, 此时如果有多个请求到达服务器, 服务器就会多线程进行操作, 是可能出现线程不安全的情况的
7)、Thread和Runnable的区别和联系?
7)、Thread和Runnable的区别和联系?
Thread 类描述了一个线程. Runnable 描述了一个任务. 在创建线程的时候需要指定线程完成的任务, 可以直接重写 Thread 的 run 方法, 也可以使用 Runnable 来描述这个任务
8)、多次start一个线程会怎么样
8)、多次start一个线程会怎么样
第一次调用 start 可以成功调用. 后续再调用 start 会抛出 java.lang.IllegalThreadStateException 异常
9)、有synchronized两个方法,两个线程分别同时用这个方法,请问会发生什么?
9)、有synchronized两个方法,两个线程分别同时用这个方法,请问会发生什么?
synchronized 加在非静态方法上, 相当于针对当前对象加锁.如果这两个方法属于同一个实例: 线程1 能够获取到锁, 并执行方法. 线程2 会阻塞等待, 直到线程1 执行完毕, 释放锁, 线程2 获取到锁之后才能执行方法内容. 如果这两个方法属于不同实例: 两者能并发执行, 互不干扰
10)、进程和线程的区别?
10)、进程和线程的区别?
进程是包含线程的,每个进程至少有一个线程存在,即主线程。 进程和进程之间不共享内存空间. 同一个进程的线程之间共享同一个内存空间. 进程是系统分配资源的最小单位,线程是系统调度的最小单位
版权声明:本文内容由网络用户投稿,版权归原作者所有,本站不拥有其著作权,亦不承担相应法律责任。如果您发现本站中有涉嫌抄袭或描述失实的内容,请联系我们jiasou666@gmail.com 处理,核实后本网站将在24小时内删除侵权内容。
发表评论
暂时没有评论,来抢沙发吧~