当前位置 : 主页 > 编程语言 > java >

【多线程】JUC详解 (Callable接口、RenntrantLock、Semaphore、CountDownLatch) 、线程安全集

来源:互联网 收集:自由互联 发布时间:2022-07-20
@TOC 一、JUC (java.util.concurrent) 1. Callable 接口 Callable 是一个 interface . 也是一种创建线程的方式。 谈到创建多线程,就会想到Runnable 接口。 但是Runnable 有个问题:不适合于 让线程计算出一

@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 唤醒主线程,主线程再打印结果

image-20220703150015285

可以看到, 上述代码需要一个辅助类 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<Integer> callable = new Callable<Integer>() { @Override public Integer call() throws Exception { int sum = 6; for (int i = 1; i <= 1000; i++){ sum += i; } return sum; } }; //为了让线程执行 callable中的任务,光使用构造方法还不够,还需要一个辅助的类. FutureTask<Integer> task = new FutureTask<>(callable); //创建线程,来完成这里的大算工作 Thread t = new Thread(task); // 凭小票去获取自己地麻辣烫 // 如果线程的任务没有完成,get 就会阻塞,一直到任务完成了,结果算出来了 try { System.out.println(task.get()); } catch (InterruptedException e) { e.printStackTrace(); } catch (ExecutionException e) { e.printStackTrace(); } } }

理解 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 个任务执行结束

假设有一场跑步比赛,当所有的选手都冲过终点,此时认为是比赛结束

image-20220703153448767

这样的场景在开发中,也是存在的

例如:多线程下载

迅雷…下载一个比较大的资源 (电影),通过多线程下载就可以提高下载速度

把一个文件拆成多个部分,每个线程负责下载其中的一个部分,得是所有的线程都完成自己的下载,才算整

个下载完


  • 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 容器也是一种读写分离的思想,读和写不同的容器。

    优点:

    • 在读多写少的场景下, 性能很高, 不需要加锁竞争.

    缺点:

    • 占用内存较多.
    • 新写的数据不能被第一时间读取到

    image-20220703154752502

    这样做的好处,就是修改的同时对于读操作,是没有任何影响的,读的时候优先读旧的版本不会说出现读到一个 “修改了一半” 的中间状态

    也叫做 “双缓冲区" 策略操作系统,创建进程的时候,也是通过写时拷贝。显卡在渲染画面的时候,也是通过类似的机制。

    也是适合于读多写少的情况,也是适合于数据小的情况更新配置数据,经常会用到这种类似的操作


    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 的时候,无论是什么样的操作,什么样的数据,都会出现锁竞争。

    这样的设计就会导致锁竞争的概率非常大,效率就比较低!

    image-20220703155949846


    ConcurrentHashMap 是如何保证线程安全的 ?

    操作元素的时候,是针对这个 元素所在的链表的头结点 来加锁的

    如果你两个线程操作是针 对两个不同的链表上的元素, 没有线程安全问题,其实不必加锁

    由于 hash 表中,链表的数目非常多,每个链表的长度是相对短的,因此就可以保证锁冲突的概率就非常小了

    image-20220703160036068

    总结:

  • 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)、进程和线程的区别?

    • 进程是包含线程的,每个进程至少有一个线程存在,即主线程。
    • 进程和进程之间不共享内存空间. 同一个进程的线程之间共享同一个内存空间.
    • 进程是系统分配资源的最小单位,线程是系统调度的最小单位

    网友评论