• 【JavaEE初阶--多线程进阶】JUC里的一些组件和多线程中的一些集合类


    1.Java中的JUC

    1.1 Callable

    Callable 是一个接口(interface),相当于把线程封装成了一个“返回值”,方便程序员借助多线程的计算结果。并且是一种创建线程的方式。

    在Callable这个接口中,还是重写了一个call()方法,用来描述线程执行的任务,在完成结果之后,可以返回一个计算结果。但是我们的Runnbale 就不太适合 让线程计算出一个结果。

    例如:分别使用两种方法创建一个线程,让这个线程计算1 + 2 + 3 + …+ 1000

    Runnable 来实现就会比较麻烦

    • 创建一个类 Result ,包含一个sum,表示计算之后的结果,locker表示的是一个锁对象
    • main方法中向创建Result实例,然后创建一个线程,在线程内部计算 1 + 2 + 3 + …1000
    • 主线程同时使用wait等待线程 t 计算结束(注意,如果执行到wait之前,线程 t 已经计算完了,就不必等待了)
    • 当线程t计算完毕之后,通过notify唤醒主线程,主线程在打印结果。
    public class TestDemo2 {
        static class Result{
            public int sum = 0;
            public Object locker = new Object(); //创建锁对象
        }
        public static void main(String[] args) throws InterruptedException {
            Result result = new Result();
            Thread t = new Thread(){
                @Override
                public void run() {
                    int sum = 0;
                     for(int i = 1;i<=1000;i++){
                         sum += i;
                     }
                     synchronized (result.locker){
                         result.sum = sum;
                         result.locker.notify();
                     }
                }
            };
            t.start();
            synchronized (result.locker){
                while(result.sum == 0){
                    result.locker.wait();
                }
            }
            System.out.println(result.sum);
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29

    可以看出,上述代码需要一个辅助类Result ,还需要使用一系列的加锁wait notify 操作,代码复杂,容易出错。

    使用Callable接口,重写Callbale接口中的call()方法

    • 创建一个匿名内部类,实现Callbale 接口,Callable带有的泛型参数,泛型参数表示返回值的类型
    • 重写Callanle的call()方法,完成累加的过程,直接通过返回值返回计算结果。
    • 把callable 实例使用的FutureTask包装一下
    • 创建线程,线程的构造方法传入 FutureTask,此时新线程就会执行FutureTask内部的Callable的call()方法,完成计算,计算的结果就放到FutureTask对象中
    • 在主线程中调用funtureTask.get()能够阻塞等待新线程计算完毕,并获取带FutureTask中的结果。
    import java.util.concurrent.Callable;
    import java.util.concurrent.ExecutionException;
    import java.util.concurrent.FutureTask;
    
    public class TestDemo3 {
        public static void main(String[] args) throws ExecutionException, InterruptedException {
            Callable<Integer> callable = new Callable<Integer>() {
                @Override
                public Integer call() throws Exception {
                    int sum = 0;
                     for(int i = 1;i<=1000;i++){
                         sum += i;
                     }
                     return sum;
                }
            };
            FutureTask<Integer> task = new FutureTask<>(callable);
            Thread t = new Thread(task);
            t.start();
            System.out.println(task.get());
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22

    在这里插入图片描述

    理解Callable

    Callable 和 Runnable 相对,都是描述一个任务,Callable 描述的是带有返回的任务,Runnable 描述的是不带有返回值的任务。

    Callable 通常需要配合FutrureTask 来使用。FutureTask 用来保存 Callable 的返回结果。因为Callable往往是在另一个线程中执行的,啥时候执行完并不确定。

    FutureTask 就可以负责这个等待结果出来的工作。

    1.2 ReentrantLock

    • ReentrantLock(可重入锁):可重入互斥锁,和synchronized的定位类似,都是用来实现互斥的效果,保证线程安全。

      ReentranLock 的用法:

      1. lock() 加锁,如果获得不到锁就死等

      2. unlock() 解锁

    把加锁和解锁两个操作分开,那么ReentrantLock是把锁的两个操作分开好呢和时synchronized把所得两个操作和到一起好呢?

    这种分开的做法不太好,很容易遗漏unlock(),容易出现死锁,当多个线程竞争同一把锁的时候,会发生严重的阻塞。因为忘记了解锁。

    所以就要了一种方式,保证不管是否异常都能执行到unlock()

    public class TestDemo4 {
        public static void main(String[] args) {
            ReentrantLock locker = new ReentrantLock();
            try{
                locker.lock(); //加锁
            }finally {
                locker.unlock(); //解锁
            }
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    1.3 ReentrantLock和synchronized的区别

    • synchronized 是一个关键字(背后的逻辑是JVM内部实现的,使用C++代码实的),ReentrantLock是一个标准库中的类(其背后的逻辑是由Java实现的)
    • synchronized 不需要手动的释放锁,出了代码块,就会自动解锁。ReentrantLock 必须手动释放锁,要谨慎防止忘记解锁
    • synchronized 如果锁竞争加锁失败,那么这个加锁失败的线程,就会进入到阻塞等待状态,但是ReentrantLock 除了阻塞这一手之外,还提供了一个trylock(),失败后直接返回,给了我们更多的回旋余地
    • synchronized 是一个非公平锁,ReentrantLock 提供了非公平锁 和 公平锁两个版本,在构造方法中,通过参数来指定当前是公平锁还是非公平锁。
    • 在这里插入图片描述
    • 基于 synchronized 衍生出来的等待机制,是wait,notify,功能时相对有限的。
    • 基于ReentrantLock 衍生出的等待机制,是Condition类,功能要更丰富一定
    • 在日常开发中,绝大部分情况下,使用synchronized 就够了

    如何选择使用那个锁:

    • 锁竞争不激烈的时候,使用synchronized ,效率更高,自动脂肪更方便
    • 锁竞争激烈的时候,使用ReentrantLock,搭配trylock 更灵活控制锁的行为,而不是死等
    • 如果需要使用公平锁,使用ReentrantLock

    1.4 Semaphore

    Semaphore(信号量):是一个广义的锁,锁是信号量里的一种特殊情况,叫做“二元信号量”,信号量,用来表示“可用资源个数”,本质上就是一个计数器。

    正确理解信号量:

    可以把信号量想象成为停车场的展示牌,当前停车场的车位有100个,表示有100个可用资源。当有车开进去的时候,就相当于申请了一个可用资源。这时候100个可用资源的1个已经被占用了,可用车位数 - 1(这个称为信号量的P操作)当有车开出去,就相当于释放了一个可用资源,那么么此时的可用车位 + 1(这里称为信号量的V操作),如果计数器的值已经为0了,当时此时还尝试申请资源,那么就会发生阻塞等待,直到有其他线程资源的释放。

    但是有些老铁就会问上述所说的 P 操作 和 V操作,里面的 P 和 V 分别是那个因为单词的缩写呢?

    其实提出信号量的老哥是一个 芬兰人 叫 地杰斯特拉(数学家) 如果老铁们熟悉数据结构中的 图 (这里面有一著名的算法—地杰斯特拉算法—>能够计算两点之间的最短路径),这里的P 和 V 是芬兰语的单词缩写 英文 :P —> acquire(申请) V —> relese(释放)

    我们熟知的 “锁” 就可以视为一个“二原信号量”,可用资源就一个,计数器的取值,非0 即 1

    例如以下代码:

    在这里插入图片描述

    1.5 CountDownLatch

    CountDownLatch(闭锁)

    主要功能:同时等待N个任务执行结束

    举一个例子: 就好比跑步比赛,10个选手依次就位。哨声向才能出发,所有的选手都通过终点,才能公布成绩。

    在这里插入图片描述

    在CountDownLatch类中提供了这两种方法:countDown() 和 await()方法

    countDown() 给每个线程里面器调用,就表示这个线程中的任务执行结束了

    await() 是等待线程去调用,当所有线程中的任务都执行完时,await() 就从阻塞中返回,就表示所有线程中的任务都执行完成了。==

    当调用 countDown()方法 的次数和线程的总个数一直的时候,那么此时就会有await()返回的情况

    在这里插入图片描述

    相关面试题

    线程同步的方式有哪些?

    synchronized,ReetranLock,Semaphore 等都可以用于线程同步

    为什么有了 synchronized 还需要 JUC下的lock?

    以 JUC 的 ReentrantLock为例:

    • synchronized 使用是不需要手动的释放锁, ReentrantLock 使用是需要手动释放,使用起来更灵活。
    • synchronized 在申请锁失败时,会死等,ReetrantLock 可以通过trylock的凡是等待一段时间就放弃。
    • synchronized 是非公平锁,ReetrantLock 默认是非公平锁,可以通过构造方法一如一个true开启 公平锁模式
    • synchronized 是通过Object 的wait/notify 实现等待 唤醒,每次唤醒的是一个随机等待的线程。ReetrantLock 搭配Condition 类实现等待 唤醒 可以更精确控制唤醒每个指定的线程。

    信号量听说过吗?之前都用在那些场景下?

    信号量,用于表示“可用的资源的个数”,本质上是一个计数器

    使用信号量可以实现 “共享锁”,比如某个资源允许3个线程同时使用,那么就可以使用 P 操作作为加锁,V 操作做为解锁,前三个线程的 P 操作,都能顺利返回,后续线程在进行 P 操作就会阻塞等待,直到前面的线程执行了V 操作。

    2.线程安全的集合类

    原来的集合类,大部分都不是线程安全的

    Vector,Stack,HashTable 是线程安全的(但是不建议使用)其他的集合类不是线程安全的

    2.1 多线程环境下使用 ArrayList

    1.使用同步机制(synchronized 或者 ReetrantLock) 用 关键字或者类修饰ArrayList

    2.Collections.synchronizedList(new ArrayList)

    synchronizedList 是一个标准库中提供的一个基于 synchronized 进行线程同步的List

    synchronizedList 的关键操作上都带有 synchronized

    这种就是直接使用synchronized 修饰ArrayList中的类或者方法属于 “无脑加锁”

    3.使用 CopyOnWirteArrayList

    CopyOnWrite 容器即写时复制的容器。

    • 当我们往一个容器中添加元素的时候,不直接往当前的容器添加元素,而是现将当前容器进行Copy,复制出一个新的容器,然后新的容器里添加元素
    • 复制完元素之后,再将原容器里增添元素。

    例如:如果咱们是多线程去读这个ArrayList,此时没有线程安全问题,完全不需要加锁,也不需要其他方面的控制,但是如果有多线程去写这个ArrayList,就是把这个ArrayList给复制一份,先去修改副本。

    在这里插入图片描述

    优点: 这样做的好处就是,修改的同时对于读操作,是没有任何影响的,读的时候就会读取原来的旧版本,不会出现,读带一个"修改了一半"的中间版本。也就是说适合于 读多写少的情况,也适合数据小的情况,在我们日常配置数据的时候,经常就会用到这种类似的操作。这种策略也叫做“双缓冲区策略”。操作系统,创建进程的时候,也会通过写时拷贝。显卡在渲染的时候,也是通过类似的机制。

    缺点: 占用内存较多,新写的数据不能第一时间读取到。

    2.2 多线程环境下使用队列

    • ArrayBlockingQueue 基于数组实现的阻塞队列
    • LinkedBlickingQueue 基于链表实现的阻塞队列
    • PriorityBlockingQueue 基于对实现的带优先级的阻塞对列
    • TransferQueue 最多只包含一个元素的阻塞队列

    2.3 多线程环境下使用哈希表

    HashMap 本身是线程不安全的

    2.3.1 Hashtable(不推荐)

    HashTable 如何保证线程安全的?

    就是给关键字加锁
    在这里插入图片描述

    针对 this 加锁,当有多个线程来访问这个HashTable的时候,无论是怎么样的操作,无论是怎么样的数据,都会产生锁竞争。

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

    2.3.2 ConcurrentHashMap

    在这里插入图片描述

    concurrentHashMap的优点:

    • ConcurrentHahsMap 减少了锁冲突,就让锁加到每个链表的头节点上(锁桶)
    • ConcurrentHashMap 只是针对写操作加锁了,读操作没有加锁
    • ConcurrentHashMap 中更广泛的使用CAS,进一步提高了效率(比如size操作)
    • ConcurrenHashMap 针对扩容,进行了巧妙的化整为零,对于HashTable 来说只要你这次put触发了就一口气把数据搬运完,那么就会导致这次put非常的卡顿。对于ConcurrentHashMap,每次操作只会搬运一点点,通过多次操作完成整个搬运的过程,同时维护一个新的HashMap和一个旧的。查找的时候急需要查旧的,也需要查新的。插入的时候直插入新的,直到搬运完成在销毁旧的。
      • 发现需要扩容的线程, 只需要创建一个新的数组, 同时只搬几个元素过去.
      • 扩容期间, 新老数组同时存在.
      • 后续每个来操作 ConcurrentHashMap 的线程, 都会参与搬家的过程. 每个操作负责搬运一小部分元素.
      • 搬完最后一个元素再把老数组删掉.
      • 这个期间, 插入只往新数组加.
      • 这个期间, 查找需要同时查新数组和老数组

    2.3.3.3 Hashtable 和 HashMap,ConcurrentHashMap之间的区别

    HashMap: 线程不安全. key 允许为 null

    Hashtable: 线程安全. 使用 synchronized 锁 Hashtable 对象, 效率较低. key 不允许为 null.

    ConcurrentHashMap: 线程安全. 使用 synchronized 锁每个链表头结点, 锁冲突概率低, 充分利用

    CAS 机制. 优化了扩容方式. key 不允许为 null

  • 相关阅读:
    Go分布式缓存 单机并发缓存(day2)
    MindSponge分子动力学模拟——增强采样(2024.11)
    Opencv(C++)笔记--打开摄像头、保存摄像头视频
    web服务之https超文本传输安全协议
    BIM如何算量?以及工作流程是怎么样?
    接口的安全设计要素有哪些?
    算法进阶-2sat-cf-1400C
    各种排序算法
    Jenkins自动化部署 中小型企业
    CentOS时代即将结束 国产系统能否避免“受限”覆辙?
  • 原文地址:https://blog.csdn.net/qq_54883034/article/details/126237877