【JavaEE初阶】多线程进阶(六)JUC 线程安全的集合类

在这里插入图片描述

JUC(java.util.concurrent)的常见类

Callable接口

Callable 是一个 interface . 相当于把线程封装了一个 “返回值”. 方便程序猿借助多线程的方式计算结果.

常见的创建线程的方式有两种方式, 第一种方法是直接继承Thread类, 重写run方法, 第二种方法是实现Runnable接口, 然后还是要靠Thread类的构造器, 把Runnable传进去, 最终调用的就是Runnablerun方法。; 和Runnable类似, 我们还可以通过Callable接口描述一个任务配合FutureTask类来创建线程, 和Runnable不同的是,Callable接口配合FutureTask类所创建的线程其中的任务是可以带有返回值的, 而一开始提到的那两种方式任务是不支持带返回值的.

理解Callable:
CallableRunnable 相对, 都是描述一个 “任务”. Callable 描述的是带有返回值的任务,
Runnable 描述的是不带返回值的任务.
Callable 通常需要搭配 FutureTask 来使用.FutureTask用来保存 Callable 的返回结果. 因为
Callable 往往是在另一个线程中执行的, 啥时候执行完并不确定.
FutureTask 就可以负责这个等待结果出来的工作.

理解FutureTask:
可以为想象去吃麻辣烫, 当餐点好后, 后厨就开始做了, 同时前台会给你一张 “小票”, 这个小票就是FutureTask, 后面我们可以随时凭这张小票去查看自己的这份麻辣烫做出来了没.

使用Thread类的构造器创建线程的时候, 传入的引用不能是Callable类型的, 而应该是FutrueTask类型, 因为构造器中传入的任务类型需要是一个Runnable类,CallableRunnable是没有直接关系的, 但FutrueTask类实现了Runnable类, 所以要想使用Callable创建线程, 我们就需要先把实现Callable接口的对象引用传给FutrueTask类的实例对象, 再将FutrueTask实例传入线程构造器中.

接下来,我们使用Callable实现 创建线程计算 1 + 2 + 3 + … + 1000

import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;

public class ThreadDemo29 {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        //使用Callable来计算1+2+3+4+...+1000
        Callable<Integer> callable = new Callable<Integer>() {
            @Override
            public Integer call() throws Exception {
                int sum = 0;
                for(int i = 0;i <= 10;i++){
                    sum += i;
                }
                return sum;
            }
        };
        FutureTask<Integer> futureTask = new FutureTask<>(callable);
        Thread t = new Thread(futureTask);
        t.start();
        //获取执行结果
        Integer sum = futureTask.get();
        System.out.println(sum);

    }
}
  • 创建一个匿名内部类,实现Callable接口.Callable带有泛型参数。泛型参数表示返回值的类型。
  • 重写Callablecall方法,完成累加的过程,直接通过返回值结算结果。
  • callable实例使用FutureTask包装一下。
  • 创建线程,线程的构造方法传入FutureTask.此时新线程就会执行FutureTask内部的Callablecall方法,完成计算,计算结果就放在了FutureTask对象中。
  • 在主线程中调用FutureTask.get();能够阻塞等待新线程计算完毕. 并获取到FutureTask中的结
    果.

相关面试题

介绍下 Callable 是什么

Callable 是一个 interface . 相当于把线程封装了一个 “返回值”. 方便程序猿借助多线程的方式计算
结果.
Callable Runnable 相对, 都是描述一个 “任务”. Callable 描述的是带有返回值的任务,
Runnable 描述的是不带返回值的任务.
Callable 通常需要搭配 FutureTask 来使用.FutureTask用来保存 Callable 的返回结果. 因为
Callable 往往是在另一个线程中执行的, 啥时候执行完并不确定.
FutureTask 就可以负责这个等待结果出来的工作.

ReentrantLock(可重入锁)

可重入互斥锁. 和 synchronized 定位类似, 都是用来实现互斥效果, 保证线程安全.

ReentrantLock 的用法:

  • lock(): 加锁, 如果获取不到锁就死等.
  • trylock(超时时间): 加锁, 如果获取不到锁, 等待一定的时间之后就放弃加锁
  • unlock(): 解锁

以上述trylock为例:

import java.util.concurrent.locks.ReentrantLock;

public class ThreadDemo30 {
    public static void main(String[] args) {
        ReentrantLock reentrantLock = new ReentrantLock();
        boolean result = reentrantLock.tryLock();
        try{
            if(result){

            }else{

            }
        }finally {
            if (result){
                reentrantLock.unlock();
            }
        }
    }
}

ReentrantLock synchronized 的区别:

  • synchronized 是一个关键字, 是 JVM 内部实现的(大概率是基于 C++ 实现). ReentrantLock 是标准
    库的一个类, 在 JVM 外实现的(基于 Java 实现).
  • synchronized 使用时不需要手动释放锁. ReentrantLock 使用时需要手动释放. 使用起来更灵活,
    但是也容易遗漏 unlock.
  • synchronized 在申请锁失败时, 会死等. ReentrantLock 可以通过 trylock 的方式等待一段时间就
    放弃.
  • synchronized 是非公平锁, ReentrantLock 默认是非公平锁. 可以通过构造方法传入一个true开启
    公平锁模式.
ReentrantLock reentrantLock = new ReentrantLock(true);
  • 更强大的唤醒机制. synchronized 是通过 Object 的 wait / notify 实现等待-唤醒. 每次唤醒的是一
    个随机等待的线程. ReentrantLock 搭配 Condition 类实现等待-唤醒, 可以更精确控制唤醒某个指
    定的线程.

结论:虽然ReentrantLock有一定的又是,但是在实际开发中,大部分情况下还是使用Synchronized

如何选择使用哪个锁?

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

原子类

原子类内部用的是CAS实现,所以性能要比加锁实现 i++ 高很多。原子类有以下几个

  • AtomicBoolean
  • AtomicInteger
  • AtomicIntegerArray
  • AtomicLong
  • AtomicReference
  • AtomicStampedReference
import java.util.concurrent.atomic.AtomicInteger;

public class ThreadDemo31 {
    public static void main(String[] args) throws InterruptedException {
        AtomicInteger count = new AtomicInteger(0);

        //使用原子类来解决线程安全问题
        Thread t1 = new Thread(()->{
            for (int i = 0; i < 5000; i++) {
                count.getAndIncrement();// count++
                // count.incrementAndGet(); // ++count
                // count.getAndDecrement(); // count--
                // count.decrementAndGet(); // --count
            }
        });
        Thread t2 = new Thread(()->{
            for (int i = 0; i < 5000; i++) {
                count.getAndIncrement();
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();

        System.out.println(count.get());
    }
}

在这里插入图片描述

信号量Semaphore

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

举个🌰: 停车场的车位,是有固定上限的。
很多停车场会在入口显示一个牌子,牌子上面写:当前空闲车位有xx个(这个牌子,就相当于Semaphore)。
每次有车,从入口进去,计数器就-1
每次有车,从出口出来,计数器就+1
如果当前停车场里面的车满了,计数器就是0.
此时,如果还有车想停,有两种方式:
(1)等待其他车出去
(2)放弃这里,去别的停车场

P操作:申请一个可用资源,计数器就-1
V操作:释放一个可用资源,计数器就+1
P操作要是计数器为0了,继续P操作,就会出现阻塞等待。

考虑一个计数初始值为1的信号量
针对这个信号量,就只有1和0两种取值。(信号量不能是负的)
执行一次P(acquire)操作,1->0
执行一次V(release)操作,0->1
如果已经进行一次P操作了,继续进行P操作,就会阻塞等待。

锁是信号量的一种特殊情况,信号量是锁的一般表达。锁可以看为计数器是1的信号量(二元信号量)

CountDownLatch

假设有一场跑步比赛:
在这里插入图片描述
这场比赛,开始时间使明确的(裁判的发令枪)
结束时间,则是不确定的、(所有选手都冲过终点比赛才算结束)
为了等待这个跑步比赛结束,就引入了这个CountDownLatch

主要是两个方法:

  1. await(wait->等待 ,a->all)主线程来调用这个方法
  2. countDown表示选手冲过了重点线
    countDown在构造的时候,指定一个计数(选手的个数)

CountDownLatch类常用方法:

  • 构造方法
public CountDownLatch(int count)构造实例对象, count表示CountDownLatch对象中计数器的值
  • 普通方法
public void await() throws InterruptedException使所处的线程进入阻塞等待, 直到计数器的值清零
public void countDown()将计数器的值减1
public long getCount()获取计数器最初的值

上述例子中,有五个选手进行比赛,初始情况下每个选手都会冲过终点,都会调用countDown方法。
前四次调用countDownawait没有任何影响
第五次调用countDownawait被唤醒。(解除阻塞),此时就可以认为是整个比赛都结束了。

import java.util.concurrent.CountDownLatch;

public class ThreadDemo32 {
    public static void main(String[] args) throws InterruptedException {
        CountDownLatch latch = new CountDownLatch(5);
        for (int i = 0; i < 5; i++) {
            Thread t = new Thread(()->{
                try {
                    Thread.sleep(100);
                    System.out.println(Thread.currentThread().getName() +"跑到了终点");
                    latch.countDown();//调用countDown的次数和个数一致
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                System.out.println();
            });
            t.start();
        }

        latch.await();
        System.out.println("比赛结束!");
    }
}

在这里插入图片描述

线程安全的集合类

原来的集合类, 大部分都不是线程安全的.
Vector, Stack, HashTable, 是线程安全的(不建议用), 其他的集合类不是线程安全的.

多线程环境使用 ArrayList

  1. 自己加锁,自己使用synchronized或者ReentrantLock
  2. Collections.synchronized 这里会提供一些ArrayList相关的方法,同时是带锁的
  3. CopyOnWriteArrayList:简称COW,也叫做“写时拷贝”
    如果针对这个ArrayList进行读操作,不做任何额外的工作。
    如果进行写操作,则拷贝一份新的ArrayList,针对新的进行修改,修改过程中如果有读操作,就继续读旧的这份数据,当修改完毕了,使用新的替换旧的(本质上是一个引用之间的赋值,原子的)

很明显,这种方案,有点是不需要加锁,缺点则是要求这个ArrayList不能太大。适用于数组比较小的情况下。

比如:
服务器程序的配置维护:
一个程序可能包含很多的子功能,有的功能想要使用,有的不想要使用,有的希望功能应用不同的形态,就可以使用一系列的“开关选型”来控制当前这个程序的工作状态。
服务器程序的配置文件,可能会需要进行修改。修改配置可能就需要重启服务器才能生效。但是重启的操作可能成本比较高。

假设一个服务器重启需要耗时5min,如果有20台服务器,就需要100min。

因此,很多服务器,都提供了“热加载”(reload)这样的功能,通过这样的功能就可以不重启服务器,实现配置的更新。热加载的实现,就可以使用刚才所说的 写时拷贝 思路。

新的配置放到新的对象中,加载过程中,请求任然基于旧配置进行工作。当新的对象加载完毕,使用新对象替代旧对象。(替换完成之后,旧的对象就可以释放了)

多线程使用队列

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

多线程使用哈希表(重点)

HashMap是线程不安全的。
HashTable是线程安全的。(给关键方法加了Synchronized)
更推荐使用的是ConcurrentHashMap:更优化的线程安全哈希表。
考点:ConcurrentHashMap进行了哪些优化?比HashTable好在哪里?和HashTable之间的区别是什么?

  1. 最大的优化之处:ConcurrentHashMap相比于HashTable大大缩小了锁冲突的概率,把一把大锁,转化成多把小锁了。
    HashTable做法是直接在方法上加synchronized,等于是给this加锁,只要操作哈希表上的任何元素,都会产生加锁,也就有可能发生锁冲突。
    但是实际上,仔细思考不难发现,其实基于哈希表的结构特点,有些元素在进行并发操作的时候,是不会产生线程安全问题的,也就不需要使用锁控制。

在这里插入图片描述

此时,元素1,2在同一个链表上。如果线程A修改元素1,线程B修改元素2,就可能会有线程安全问题。(比如这两个元素相邻,此时并发删除/插入,就需要修改这两个节点相邻的节点的next的指向)
如果线程A修改元素3,线程B修改元素4不会有线程安全问题。这个情况是不需要加锁的。
HashTable,锁冲突概率就大大增加了,任何两个元素的操作都会有锁冲突,即使是在不同链表上。

ConcurrentHashMap做法是:每个链表有各自的锁。(而不是大家公用一把锁)
具体来说,就是使用每个链表的头结点作为锁对象。(两个线程针对同一个锁对象加锁,才有竞争,才有阻塞等待,针对不同对象,没有锁竞争)
在这里插入图片描述
此时,锁的粒度变小了。针对1,2。是针对同一把锁进行加锁,会有锁竞争,会保证线程安全。
针对3,4.是针对不同的锁进行加锁,不会有锁竞争了,没有阻塞等待,程序就会更快。(快是相对的)

上图中的情况, 是针对JDK1.8及其以后的情况, 而JDK1.8之前, ConcurrentHashMap使用的是 “分段锁”, 分段锁本质上也是缩小锁的范围从而降低锁冲突的概率, 但是这种做法不够彻底, 一方面锁的粒度切分的还不够细, 另一方面代码实现也更繁琐.

在这里插入图片描述
2. ConcurrentHashMap做了一个激进的操作:针对读操作,不加锁,只针对写操作加锁。
读和读之间没有冲突
写和写之间有冲突
读和写之间没有冲突(很多场景下,读写之间不加锁控制,可能就读到了一个写了一半的操作,如果写操作不是院子的,此时读就可能会读到写了一般的数据,相当于脏读)针对此情况可以使用volatile+原子的写操作。

3. ConcurrentHashMap内部充分地使用了CAS,通过这个也来进一步的削减加锁操作的数目。比如维护元素个数
4. 针对扩容,采取了“化整为零”的方式。
HashMap/HashTable扩容:
创建一个更大的数据空间,把旧的数组上的链表上的每个元素搬运到新的数组上。(删除+插入)
这个扩容操作会在某次put的时候进行触发
如果元素个数特别多,就会导致这样的搬运操作比较耗时。
就会出现:某次put比平时的put卡很多倍。

ConcurrentHashMap中,扩容采取的是每次搬运一小部分元素的方式。
创建新的数组,旧的数组也保留。
每次put操作,都会往新数组上添加,同时进行一部分搬运(把一小部分旧的元素搬到新数组上)
每次get的时候,则旧数组和新数组都查询
每次remove的时候,只是把元素删了就行。

经过一段时间后,所有的元素都搬运好了,最终再释放旧数组。

相关面试题

  1. ConcurrentHashMap的读是否要加锁,为什么?

读操作没有加锁. 目的是为了进一步降低锁冲突的概率. 为了保证读到刚修改的数据, 搭配了
volatile 关键字.

  1. 介绍下 ConcurrentHashMap的锁分段技术?

这个是 Java1.7 中采取的技术. Java1.8 中已经不再使用了. 简单的说就是把若干个哈希桶分成一个"段" (Segment), 针对每个段分别加锁.
目的也是为了降低锁竞争的概率. 当两个线程访问的数据恰好在同一个段上的时候, 才触发锁竞争.

  1. ConcurrentHashMap在jdk1.8做了哪些优化?

取消了分段锁, 直接给每个哈希桶(每个链表)分配了一个锁(就是以每个链表的头结点对象作为锁对 象). 将原来 数组 + 链表的实现方式改进成 数组 + 链表 / 红黑树 的方式. 当链表较长的时候(大于等于 8 个元素)就转换成红黑树.

4) HashtableHashMap、ConcurrentHashMap 之间的区别?

HashMap: 线程不安全. key 允许为 null
Hashtable: 线程安全. 使用 synchronized 锁 Hashtable 对象, 效率较低. key 不允许为 null.
ConcurrentHashMap: 线程安全. 使用 synchronized 锁每个链表头结点, 锁冲突概率低, 充分利用
CAS 机制. 优化了扩容方式. key 不允许为 null

  • 10
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 12
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 12
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

xxxflower.

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值