JavaEE初阶——JUC的工具类和死锁

目录

[一、信号量 Semaphoe](#一、信号量 Semaphoe)

二、CountDownLatch

三、线程安全的集合类

[3.1 ArrayList](#3.1 ArrayList)

[3.1.1 Collections.synchronizedList(new ArrayList)](#3.1.1 Collections.synchronizedList(new ArrayList))

[3.1.2 CopyOnWriteArrayList](#3.1.2 CopyOnWriteArrayList)

3.2多线程使用队列

[3.3 多线程使用哈希表](#3.3 多线程使用哈希表)

[3.3.1 Hashtable](#3.3.1 Hashtable)

[3.3.2 ConcurrentHashMap](#3.3.2 ConcurrentHashMap)

四、死锁

[4.1 死锁的情景](#4.1 死锁的情景)

[4.1.1 一个线程获取一把锁](#4.1.1 一个线程获取一把锁)

[4.1.2 两个线程获取两把锁](#4.1.2 两个线程获取两把锁)

[4.1.3 多个线程获取多把锁](#4.1.3 多个线程获取多把锁)

[4.2 造成死锁的原因](#4.2 造成死锁的原因)

[4.3 解决死锁问题](#4.3 解决死锁问题)


一、信号量 Semaphoe

信号量用来表示"可用资源的个数",本质上就是一个计数器,控制对共享资源的并发访问数量,本质是 "资源访问许可证"------ 计数器大于 0 时允许访问,等于 0 时阻塞等待,释放资源时计数器递增,唤醒等待线程。

我们用停车场举例:

  • 停车场外面通常会有一个显示牌,牌子上会显示当前停车场中车位的可用个数
  • 一辆车进入停车场,显示牌显示的个数减1,表示停车位资源减少1
  • 一辆车从停车场出来,显示牌显示的个数加1,释放了一份停车场资源,外面等待的车就可以进入
  • 如果停车场的车位占满了,那么显示牌上就显示0,这是外面的车如果要进入停车场则需要阻塞等待

在Java中我们可以用Semaphore类来表示信号量,我们通过传给构造方法一个参数来设定信号量的可用资源有多少

java 复制代码
Semaphore semaphore = new Semaphore(5);

按照上述代码,semaphore信号量有5个可用资源

acquire()方法表示申请资源,也就是车辆进入停车场的过程

java 复制代码
semaphore.acquire();

release()方法表示释放资源,也就是车辆出停车场的过程

java 复制代码
semaphore.release();

我们用代码来测试一下信号量的工作流程

java 复制代码
public class Demo_1201 {
    public static void main(String[] args) {
        // 初始化一个信号量的对象, 指定系统可用资源的数量, 相当于一个停车场有5个车位
        Semaphore semaphore = new Semaphore(5);
        // 定义线程的任务
        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                System.out.println(Thread.currentThread().getName() + "开始申请资源...");
                try {
                    // 申请资源, 相当进入停车场,可用车位数减1
                    semaphore.acquire();
                    System.out.println(Thread.currentThread().getName() + "====== 已经申请到资源 ======");
                    // 处理业务逻辑, 用休眠来模拟, 相当于停车时间
                    TimeUnit.SECONDS.sleep(1);
                    // 释放资源, 相当于出停车场, 可用车位数加1
                    semaphore.release();
                    System.out.println(Thread.currentThread().getName() + "***** 释放资源");

                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        };

        // 创建线程执行任务, 10相当于有20辆车需要进停车场
        for (int i = 0; i < 10; i++) {
            // 创建线程并指定任务
            Thread thread = new Thread(runnable);
            // 启动线程
            thread.start();
        }

    }
}

我们观察代码

  • 前5个线程都是申请资源之后里面申请到了资源,这是因为信号量有5个可用资源,申请即可获得。
  • 再看信号量5个资源被申请完后的结果,剩下5个线程申请资源都没有获取到,这是因为之前5个线程还没有释放资源,信号量现在没有可用资源
  • 当前五个线程释放资源后,后来申请资源的5个线程陆续获得了资源

我们可以通过信号量限制系统中并发执行的线程个数

二、CountDownLatch

CountDownLatch 是 JUC包的线程同步工具 ,核心功能是:让一个或多个线程等待 "等待" ,直到其他指定数量的线程完成任务后,再继续执行。可以理解为 "倒计时门闩"------ 先设定一个倒计时数,线程完成任务后倒计时减 1,直到倒计时归 0,等待的线程才会被 "放行"。

我们通过传入参数count到构造方法创建一个CountDownLatch对象,下面代码的count就是10

java 复制代码
CountDownLatch countDownLatch = new CountDownLatch(10);

调用countDown()方法后count就减1

java 复制代码
countDownLatch.countDown();

调用await()方法就会让主线程阻塞等待,直到所有的线程都运行结束,也就是count归0

java 复制代码
countDownLatch.await();

我们用跑步比赛举例

  • 组委会说:"10 人参赛,全到齐才算结束"(初始化倒计时 10)。
  • 裁判喊预备,10 名选手同时开跑(线程启动)。
  • 选手们陆续冲线,每到 1 人,倒计时减 1(countDown())。
  • 裁判在终点等待,直到最后 1 人到齐(await() 等待倒计时 0)。
  • 所有人到齐后,裁判宣布结束并颁奖。
java 复制代码
public class Demo_1202 {
    public static void main(String[] args) throws InterruptedException {
        // 指定参赛选手的个数(线程数)
        CountDownLatch countDownLatch = new CountDownLatch(10);

        System.out.println("各就各位,预备...");
        for (int i = 0; i < 10; i++) {
            Thread player = new Thread(() -> {
                try {
                    System.out.println(Thread.currentThread().getName() + "开跑.");
                    // 模拟比赛过程, 休眠2秒
                    TimeUnit.SECONDS.sleep(2);
                    System.out.println(Thread.currentThread().getName() + "到达.");
                    // 标记选手已达到终点,让countDownLatch的计数减1, 当计数到0时,表示所有的选手都到达终点,比赛结束
                    countDownLatch.countDown();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }, "player" + i);
            // 启动线程
            player.start();
        }

        TimeUnit.MILLISECONDS.sleep(10);
        System.out.println("===== 比赛进行中 =====");
        // 等待比赛结束
        countDownLatch.await();
        // 颁奖
        System.out.println("比赛结束, 进行颁奖");
    }

}

三、线程安全的集合类

我们知道我们之前使用的很多集合类都是线程不安全的集合类,会产生线程安全问题。

3.1 ArrayList

创建10个线程向list里面写入数据

java 复制代码
public class Demo_1203 {
    public static void main(String[] args) {
        // 先定义一个集合对象(线程不安全)
        List<Integer> list = new ArrayList<>();

        // 多个线程同时对这个集合进行读写操作
        for (int i = 0; i < 10; i++) {
            int j = i + 1;
            Thread t = new Thread(() -> {
                // 写
                list.add(j);
                // 读
                System.out.println(list);
            });
            // 启动线程
            t.start();
        }
    }
}

我们可以看到执行结果报错,ConcurrentModificationException(并发修改异常)的核心原因是:多个线程同时操作了同一个 ArrayList,其中一个线程在遍历集合,另一个线程在修改集合(添加 / 删除元素),导致遍历过程中集合结构被意外改变,触发了 Java 的并发安全检查

那如果我们需要使用集合类ArrayList,除了可以用我们之前学的synchronized或ReentrantLock同步机制,还可以使用什么方法保证线程安全呢

3.1.1 Collections.synchronizedList(new ArrayList)

synchronizedList是标准库提供的⼀个基于synchronized进⾏线程的List

java 复制代码
public class Demo_1204 {
    public static void main(String[] args) {
        // 创建一个普通集合对象
        List<Integer> arrayList = new ArrayList<>();
        // 通过工具类把普通集合对象,转换线程安全的集合对象
        List<Integer> list = Collections.synchronizedList(arrayList);

        // 多个线程同时对这个集合进行读写操作
        for (int i = 0; i < 10; i++) {
            int j = i + 1;
            Thread t = new Thread(() -> {
                // 写
                list.add(j);
                // 读
                System.out.println(list);
            });
            // 启动线程
            t.start();
        }
    }
}

我们利用synchronizedList工具类把普通集合类转化成了线程安全的集合类,我们来通过源码来分析这是如何做到的

我们能看到调用synchronizedList方法之后返回了一个SynchronizedList实例对象,我们来看一下这个类的方法

观察SynchronizedList类的方法,发现每个方法都是被synchronized包裹的,这就是为什么synchronizedList可以把线程不安全的集合类转化成线程安全的类

3.1.2 CopyOnWriteArrayList

我们也可以直接使用CopyOnWriteArrayList类,这是一个线程安全的类,基于 "写时复制"(Copy-On-Write)的思想设计,适用于读多写少的场景。

核心原理

  • 当对 CopyOnWriteArrayList 进行修改操作(如添加、删除、修改元素)时
  • 它不会直接修改原数组,而是先复制一份原数组的副本,在副本上执行修改操作
  • 完成后再将原数组的引用指向新副本
  • 读操作则直接访问原数组,无需加锁,因此读操作效率很高,且不会阻塞其他线程的读或写。

我们来读源码,分析一下add方法

可以看到add方法全部上锁,方法中新创建了一个es集合类副本,在es副本中添加元素,随后调用setArray方法,这个方法里让array指向了es副本,最后释放锁。实现了线程安全的ArrayList集合类

3.2多线程使用队列

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

3.3 多线程使用哈希表

多线程环境下使用HashMap是不安全的

3.3.1 Hashtable

Hashtable实现线程安全是依靠把关键方法加上synchronized关键字来实现的

但是此时就会出现一个问题,当我们调用put方法时,其实我们只会操作一个hash桶,但是这样整体上锁会把整个哈希表锁住,大大降低了效率。而且⼀旦触发扩容,就由该线程完成整个扩容过程,这个过程会涉及到⼤量的元素拷⻉,效率会⾮常低。

3.3.2 ConcurrentHashMap

ConcurrentHashMap相比于Hashtable做出了一系列的改进和优化

  • 读操作没有加锁(但是使⽤了volatile保证从内存读取结果)
  • 只对写操作进⾏加锁.加锁的⽅式仍然是是⽤synchronized,但是不是锁整个对象,⼤⼤降低了锁冲突的概率

同时,ConcurrentHashMap在扩容上也做了优化

  • 扩容时把数组的容量增加到原来的两倍,但并不是一次性把Map中的数据全部复制到新Map中
  • 而只是复制当前访问的下标的元素,这样的操作会使两个Map同时存在一段时间
  • 当查询的时候同时在两个Map里面进行查询,删除也是在两个Map中删除
  • 写入操作时只往新的Map中写

四、死锁

死锁是这样⼀种情形:多个线程同时被阻塞,它们中的⼀个或者全部都在等待某个资源被释放。由于线程被⽆限期地阻塞,因此程序不可能正常终⽌

4.1 死锁的情景

4.1.1 一个线程获取一把锁

一个线程如果重复获取同一把锁两次以上,如果锁是可重入锁,那就不会出现死锁问题

如果是不可重入锁,就会发生死锁

4.1.2 两个线程获取两把锁

假设有两个线程,线程A和线程B,两把锁,锁A和锁B

此时线程A持有锁A,等待锁B;线程B持有锁B,等待锁A。这样循环等待也会造成死锁

java 复制代码
public class Demo_1302 {
    public static void main(String[] args) {
        // 定义两个锁对象
        Object locker1 = new Object();
        Object locker2 = new Object();

        // 创建线程1,先获取locker1 再获取locker2
        Thread t1 = new Thread(() -> {
            System.out.println("t1申请locker1....");
            synchronized (locker1) {
                System.out.println("t1获取到了locker1");
                // 模拟业务执行时间
                try {
                    TimeUnit.SECONDS.sleep(1);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

                // 在持有locker1的基础上获取locker2
                synchronized (locker2) {
                    System.out.println("t1获取了所有的锁资源。");
                }
            }
        });

        // 创建线程2,先获取locker2 再获取locker1
        Thread t2 = new Thread(() -> {
            System.out.println("t2申请locker2....");
            synchronized (locker2) {
                System.out.println("t2 获取到了locker2.");
                // 模拟业务执行时间
                try {
                    TimeUnit.SECONDS.sleep(1);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

                // 持有locker2 的基础上获取locker1
                synchronized (locker1) {
                    System.out.println("t2获取了所有的锁资源。");
                }
            }
        });

        // 启动两个线程
        t1.start();
        t2.start();
    }
}

观察结果显示,两个线程都获取到了一把锁,然后线程想获取另一把锁,代码发生死锁。

4.1.3 多个线程获取多把锁

我们以著名的哲学家就餐问题为例

可以看到有五个座位,每个座位上坐着一个哲学家,他们只有两个状态,一个是就餐状态一个是思考状态。每个哲学家左右都有一只筷子,规定只有获取到了两只筷子才可以用餐

我们可以让哲学家先拿左边的筷子,再拿右边的筷子,用完餐再放回原位,等待下一次用餐,这个模型大多数情况运行良好

  • 但是可能会出现极端情况,这种情况就容易出现死锁问题

当每个哲学家都同时拿起了左手筷子,此时他们都要获取右手筷子,都在等待旁边的哲学家放下筷子,从而发生了死锁问题

4.2 造成死锁的原因

  • 互斥使⽤,即当资源被⼀个线程使⽤(占有)时,别的线程不能使⽤
  • 不可抢占,资源请求者不能强制从资源占有者⼿中夺取资源,资源只能由资源占有者主动释放。
  • 请求和保持,即当资源请求者在请求其他的资源的同时保持对原有资源的占有。
  • 循环等待,即存在⼀个等待队列:P1占有P2的资源,P2占有P3的资源,P3占有P1的资源。这样就形成了⼀个等待环路。

当上述四个条件都成⽴的时候,便形成死锁。当然,死锁的情况下如果打破上述任何⼀个条件,便可让死锁消失。

4.3 解决死锁问题

我们根据造成死锁的原因来逐个分析

  • 互斥使用:这是锁自带的特性,我们无法破坏
  • 不可抢占:这也是锁自带的特性,我们无法破坏
  • 保持与请求:这和代码的设计相关,其实我们只需要设计合理的获取锁顺序就可以打破
  • 循环等待:同样与代码设计相关,我们合理设计就可以打破
java 复制代码
public class Demo_1303 {
    public static void main(String[] args) {
        // 定义两个锁对象
        Object locker1 = new Object();
        Object locker2 = new Object();

        // 所有的线程都是先拿locker1再拿locker2

        // 创建线程1
        Thread t1 = new Thread(() -> {
            System.out.println("t1申请locker1....");
            synchronized (locker1) {
                System.out.println("t1获取到了locker1");
                // 模拟业务执行时间
                try {
                    TimeUnit.SECONDS.sleep(1);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

                // 在持有locker1的基础上获取locker2
                synchronized (locker2) {
                    System.out.println("t1获取了所有的锁资源。");
                }
            }
        });

        // 创建线程2
        Thread t2 = new Thread(() -> {
            System.out.println("t2申请locker1....");
            synchronized (locker1) {
                System.out.println("t2获取到了locker1");
                // 模拟业务执行时间
                try {
                    TimeUnit.SECONDS.sleep(1);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

                // 在持有locker1的基础上获取locker2
                synchronized (locker2) {
                    System.out.println("t2获取了所有的锁资源。");
                }
            }
        });

        // 启动两个线程
        t1.start();
        t2.start();
    }
}

我们更换了一下获取锁的顺序,代码此时就执行正确了,两个线程都成功获取了锁资源

我们再回顾上文说的哲学家进餐问题,我们可以怎么设计获取锁策略来解决死锁问题呢?

我们可以给筷子编号,让每个哲学家都先拿编号小的筷子,再拿编号大的筷子

abcd哲学家都获取到了小编号的筷子之后,此时e想要获取编号1的筷子,但是此时筷子被哲学家a持有,所以e获取不到筷子1,也无法获取筷子5。

这个时候哲学家d就可以获取到筷子5,可以开始用餐,用完餐之后放下筷子,哲学家c也可以开始用餐,如此往复,不会出现死锁问题

相关推荐
chinesegf2 小时前
[特殊字符] 常用 Maven 命令
java·spring boot·maven
草莓熊Lotso2 小时前
《算法闯关指南:优选算法--位运算》--36.两个整数之和,37.只出现一次的数字 ||
开发语言·c++·算法
做运维的阿瑞2 小时前
Redis 高可用集群部署实战:单Docker实现1主2从3
java·redis·docker
小松の博客2 小时前
Mybatis 注解开发
java·tomcat·mybatis
爱吃烤鸡翅的酸菜鱼2 小时前
Java【缓存设计】定时任务+分布式锁实战:Redis vs Redisson实现状态自动扭转以及全量刷新预热机制
java·redis·分布式·缓存·rabbitmq
yugi9878382 小时前
MyBatis框架如何处理字符串相等的判断条件
java·开发语言·tomcat
彩旗工作室2 小时前
如何在自己的服务器上部署 n8n
开发语言·数据库·nodejs·n8n
liyi_hz20082 小时前
O2OA(翱途)开发平台 v9.5 前端框架设计|开放 · 安全 · 可控 · 信创优选
java·前端框架·开源软件
知兀2 小时前
IDEA的Code Style配置(使用google的Java Code Stytle)
java·ide·intellij-idea