多线程之五(JUC+线程安全的集合类+死锁)

导读:本篇文章讲解 多线程之五(JUC+线程安全的集合类+死锁),希望对大家有帮助,欢迎收藏,转发!站点地址:www.bmabk.com

目录

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

1.1 callable 接口

1.2 Reentrantlock

1.3 Reentrantlock 和 synchronized 的区别

1.4 原子类(atomic)

1.5 线程池(ExecutorService)

1.6 信号量(semaphore)

1.7 同时等待N个任务执行结束(CountDownLatch)

2. 线程安全的集合类

2.1 多线程环境使用ArrayList

2.2 多线程环境使用队列

2.3 多线程环境使用哈希表(ConcurrentHashMap)

2.4 面试题谈谈HashMap、HashTable、ConcurrentHashMap之间的区别

3. 死锁(*)


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

JUC是一个缩写,JUC是一个包 java.util.concurrent

并发,这个包中放的都是和多线程是相关的

1.1 callable 接口

Callable接口类似于Runnable

Runnable 描述的任务,不带返回值

Callable 描述的任务是带返回值的

如果当前多线程完成的任务,需要带上结果,使用Callable就比较方便

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

public class demo01 {
    //创建线程,通过线程来计算 1 + 2 + 3 + ... + 1000

    public static void main(String[] args) throws ExecutionException, InterruptedException {
        //使用Callable 定义一个任务
        Callable<Integer> callable = new Callable<Integer>() {
            @Override
            public Integer call() throws Exception {
                int sum = 0;
                for (int i = 0; i <= 1000; i++) {
                    sum += i;
                }
                return sum;
            }
        };

        FutureTask<Integer> futureTask = new FutureTask<>(callable);
        //创建线程来执行上述任务
        //Thread 的构造方法,不能直接传callable,还需要一个中间的类
        Thread t = new Thread(futureTask);
        t.start();

        //获取线程的计算结果
        //get方法产生阻塞,直到call方法计算完成,get方法才会返回
        System.out.println(futureTask.get());
    }
}

多线程之五(JUC+线程安全的集合类+死锁)

1.2 Reentrantlock

Reentrantlock 可重入锁

Reentrantlock 和 synchronized都是可重入锁

虽然synchronized已经非常强了,但还是有些操作是做不到的

Reentrantlock是对synchronized的一个补充

Reentrantlock核心用法,三个方法

(1)lock() 加锁

(2)unlock()  解锁

(3)tryLock(超时时间)加锁, 如果获取不到锁, 等待一定的时间之后就放弃加锁

public class demo02 {
    public static void main(String[] args) {
        ReentrantLock locker = new ReentrantLock(true);
        try{
            //加锁
            locker.lock();
        } finally {
            //解锁
            locker.unlock();
        }
    }
}

1.3 Reentrantlock 和 synchronized 的区别

ReentrantLock的缺点:synchronized是只要代码出来代码块,就一定执行结束,而ReentrantLock相比于synchronized就没有这个优势了

但总体来看,reentrantLock的有些特定功能,synchronized做不到的

优点:

(1)tryLock,能够先试试加锁,试成功了,就加锁成功

试失败了,就等待一定时间后就放弃加锁

这种优点,对于“死等的策略”提供了更多的可能
 

(2)ReentrantLock可以实现公平锁(默认是非公平的,构造时传入一个参数,就成了公平锁)

        ReentrantLock locker = new ReentrantLock(true);

 (3)synchronized是搭配wait/notify 实现等待通知机制,唤醒操作时随机唤醒一个等待的进程

ReentrantLock搭配 Condition 类实现的,唤醒操作是可以指定唤醒哪个等待的线程的

还有一个区别是:

synchronized 是java关键字,底层是JVM实现的(通过C++实现的)

ReentrantLock 标准库中的一个类,底层是基于java实现的

总结就是:

(1)用法不同:synchronized 可以用来修饰普通方法、静态方法和代码块,而ReentrantLock只能用于代码块

(2)锁类型不同:synchronized 是非公平锁,而 ReentrantLock 默认为非公平锁,也可以手动指定为公平锁(构造时传入一个参数,就成了公平锁(true))

(3)获取锁和释放锁的机制不同:synchronized是自动加锁和释放锁的(搭配 wait/ notify 实现等待通知机制,唤醒操作时随机唤醒一个等待进程),而 ReentrantLock 需要手动加锁和释放锁(tryLock试试加锁能不能成功,如果失败了,就等待一定时间后就放弃加锁。使用Condition可以将唤醒操作指定唤醒哪个等待的线程的)

(4)响应时间不同:ReentrantLock 可以响应中断,解决死锁问题,而 synchronized 不能响应中断

(5)底层实现不同:synchroized 是JVM 层面通过监视器实现的,而 ReentrantLock 是基于AQS 实现的

1.4 原子类(atomic)

 原子类的底层,是基于CAS实现的

java已经封装好了,可以直接来使用

多线程之五(JUC+线程安全的集合类+死锁)

                //相当于 count++
                count.getAndIncrement();
                //相当于 ++count
                count.incrementAndGet();
                //相当于 count--
                count.getAndDecrement();
                //相当于 --count
                count.decrementAndGet();
import java.util.concurrent.atomic.AtomicInteger;

/**
 * Created with IntelliJ IDEA.
 * Description:
 * User: 28463
 * Date: 2022—09—26
 * Time: 17:00
 */
public class demo03 {
    public static void main(String[] args) throws InterruptedException {
        AtomicInteger count = new AtomicInteger(0);

        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                //相当于 count++
                count.getAndIncrement();
            }
        });

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                //相当于 count++
                count.getAndIncrement();
            }
        });

        t1.start();
        t2.start();
        t1.join();
        t2.join();
        //get() 获取到内部的值
        System.out.println(count.get());
    }
}

多线程之五(JUC+线程安全的集合类+死锁)

1.5 线程池(ExecutorService

这块知识点可以看我前一篇博客,里面有很好的讲解

多线程之三(【多线程案例】单例模式+阻塞式队列+定时器+线程池)

1.6 信号量(semaphore)

信号量的基本操作时两个:

a)P 操作,申请一个资源

c)V 操作,释放一个资源

信号量可用视为是一个更广义的锁

锁就是一个特殊的信号量(可用资源只有1的信号量)

信号量本身是一个计数器,表示可用资源的个数

P 操作申请一个资源,可用资源数就-1

V 操作释放一个资源,可用资源数就+1

当计数为0的时候,继续P操作,就会产生阻塞等待,阻塞等待到其他线程V操作了为止

比如,去饭店吃饭,正常可以同时坐100人

每次有人进来,就是P操作,剩余位置-1

每次有人出去,就是V操作,剩余位置+1

如果当时空余位置是0,你还想进去吃饭,进不去的,只能等待排队/放弃

当需求中,就是有多个可用资源的时候,就是记得使用信号量

Java标准库提供了Semaphore这个类,也就是把 操作系统 提供的信号量封装了一下

        //这是 P 操作,申请资源,计数器 -1
        semaphore.acquire();
        //这是 V 操作,释放资源,计数器 +1
        semaphore.release();
import java.util.concurrent.Semaphore;

public class demo04 {
    public static void main(String[] args) throws InterruptedException {
        //构造时需要指定初始值,计数器的初始值,表示有几个可用资源
        Semaphore semaphore = new Semaphore(4);
        //这是 P 操作,申请资源,计数器 -1
        semaphore.acquire();
        System.out.println("p 操作");
        semaphore.acquire();
        System.out.println("p 操作");
        semaphore.acquire();
        System.out.println("p 操作");
        semaphore.acquire();
        System.out.println("p 操作");
        semaphore.acquire();
        System.out.println("p 操作");
        //这是 V 操作,释放资源,计数器 +1
        semaphore.release();
    }
}

多线程之五(JUC+线程安全的集合类+死锁)​可以看到,输入了五次p操作,结果就执行了4次,第5次阻塞了

1.7 同时等待N个任务执行结束(CountDownLatch)

同时等待 N 个任务执行结束

比如,赛车比赛中

当比赛开始后,只有当最后一个赛车选手,抵达终点后,才结束

而对比CountDownLatch使用效果就是

使用CountDownLatch时,先设置一下有几个选手

每个选手抵达终点了,就调用一下countDown方法

当抵达终点的次数达到了选手的个数,就可以认为比赛结束了

多线程之五(JUC+线程安全的集合类+死锁)

import java.util.concurrent.CountDownLatch;

/**
 * Created with IntelliJ IDEA.
 * Description:
 * User: 28463
 * Date: 2022—09—26
 * Time: 18:08
 */
public class CountDownLatch1 {
    public static void main(String[] args) throws InterruptedException {
        // 有 20 个选手参加了比赛
        CountDownLatch countDownLatch = new CountDownLatch(20);
        for (int i = 0; i < 20; i++) {
            //创建 20个线程来执行一批任务
            Thread t = new Thread(() -> {
                System.out.println("选手出发" + Thread.currentThread().getName());
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("选手到达" + Thread.currentThread().getName());
                // 相当于 ”抵达终点“
                countDownLatch.countDown();
            });
            t.start();
        }
        // await 是进行阻塞等待,会等到所有的选手都撞线之后,才解除阻塞
        countDownLatch.await();
        System.out.println("比赛结束");
    }
}

 多线程之五(JUC+线程安全的集合类+死锁)​等到所有,选手到达后,比赛结束

多线程之五(JUC+线程安全的集合类+死锁)

2. 线程安全的集合类

标准库中大部分的集合类,都是线程不安全的

少数几个安全的

vector、Stack、HashTable,不太推荐用

最好的还是自己来加锁

2.1 多线程环境使用ArrayList

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

         synchronizedList 的关键操作上都带有 synchronized

Collections.synchronizedList(new ArrayList);

 (2)使用CopyOnWriteArrayList ,这个是不加锁就可以保证线程安全的

使用的情况有限,一写多读,写的频率比较低

CopyOnWrite  ,写(修改)时拷贝(复制)

修改时,不直接修改,而是修改这个新拷贝到的数据

这个CopyOnWriteArrayList,操作范围非常有限

如果元素特别多,或者修改特别频繁,就不太适合这种方式了

多线程之五(JUC+线程安全的集合类+死锁)

2.2 多线程环境使用队列

(1)ArrayBlockingQueue

基于数组实现的阻塞队列

(2)LinkedBlockingQueue

基于链表实现的阻塞队列

(3)PriorityBlockingQueue

基于堆实现的带优先级的阻塞队列

(4)TransferQueue

最多只包含一个元素的阻塞队列

2.3 多线程环境使用哈希表(ConcurrentHashMap)

HashMap 本身不是线程安全的.

在多线程环境下使用哈希表可以使用:

(1)Hashtable

(2)ConcurrentHashMap

(1)Hashtable(不推荐,因为这个给各种方法都加synchronized)

(2)ConcurrentHashMap (推荐,因为内部有很多的优化策略)

优化策略:

a)锁粒度的控制

HashTable 直接在方法上加 synchronized ,相当于是对 this 加锁

也就是针对哈希表对象来加锁,一个哈希表只有一个锁

多个线程,无论这些线程,是如何来操作这个哈希表,都会产生锁冲突

ConcurrentHashMap就不是加一把锁了,而是加多把锁,给每个哈希桶都分配一把锁

也就是只有当两个线程访问同一个哈希桶的时候,才有锁冲突

这样就降低了锁冲突的概率,性能也就提高了

多线程之五(JUC+线程安全的集合类+死锁)

 b)ConcurrentHashMap 只给写操作加锁,读操作没加锁

如果两个线程同时修改,才会有锁冲突

如果两个线程同时读,就不会有锁冲突

如果一个线程读,一个线程写,也是不会有锁冲突的

(这个操作也是可能会锁冲突的,因为有可能,读的结果是一个修改了一半的数据

不过ConcurrentHashMap在设计时,就考虑到这一点,就能够保证读出来的一定时一个“完整的数据”,要么是旧版本数据,要么是新版本数据,不会是读到改了一半的数据;而且读操作中也使用到了volatile保证读到的数据是最新的)

c)充分利用到了CAS的特性

 比如更新元素个数,都是通过CAS来实现的,而不是加锁

d)ConcurrentHashMap 对于扩容操作,进行了特殊优化

HashTable的扩容是这样:当put元素的时候,发现当前的负载因子已经超过阀值了,就触发扩容。

扩容操作时这样:申请一个更大的数组,然后把这之前旧的数据给搬运到新的数组上

但这样的操作会存在这样的问题:如果元素个数特别多,那么搬运的操作就会开销很大

执行一个put操作,正常一个put会瞬间完成O(1)

但是触发扩容的这一下put,可能就会卡很久(正常情况下服务器都没问题,但也有极小概率会发生请求超时(put卡了,导致请求超时),虽然是极小概率,但是在大量数据下,就不是小问题了)

ConcurrentHashMap 在扩容时,就不再是直接一次性完成搬运了

而是搬运一点具体是这样的

扩容过程中,旧的和新的会同时存在一段时间,每次进行哈希表的操作,都会把旧的内存上的元素搬运一部分到新的空间上,直到最终搬运完成,就释放旧的空间

在这个过程中如果要查询元素,旧的和新的一起查询;如果要插入元素,直接在新的上插入

;如果是要删除元素,那就直接删就可以了

ConcurrentHashMap 就是能不加锁就不加锁

核心优化思路:尽一切方法,降低锁冲突的概率

2.4 面试题谈谈HashMap、HashTable、ConcurrentHashMap之间的区别

回答的思路就是   线程安全 =》锁粒度等多线程下的优化

HashMap key 允许为null

HashTable 和 ConcurrentHashMap key 不能为null

3. 死锁(*)

死锁就是,一个线程加上锁之后,解不开了,一直在等着

多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线

程被无限期地阻塞,因此程序不可能正常终止。

a)一个线程,一把锁,线程连续加锁两次

如果这个锁时不可重入锁,那肯定会死锁了

synchronized 是可重入锁,这个不影响

b)两个线程,两把锁,都获取到第一把锁后,在释放之前获取另一把锁

比如,家门钥匙锁车里了,而车钥匙锁家里了

public class demo03 {
    //这两个线程都是获取到一把锁后,释放之前获取到的另一把锁
    //不是拿到锁释放了,再拿第二把锁(这种情况不会死锁)
    public static void main(String[] args) {
        Object locker1 = new Object();
        Object locker2 = new Object();

        Thread t1 = new Thread(() -> {
            System.out.println("t1 尝试获取 locker1");
           synchronized (locker1) {
               try {
                   Thread.sleep(500);
               } catch (InterruptedException e) {
                   e.printStackTrace();
               }

               System.out.println("t1 尝试获取 locker2");
               synchronized (locker2) {
                   System.out.println("t1 获取两把锁成功!");
               }
           }
        });
        Thread t2 = new Thread(() -> {
            System.out.println("t2 尝试获取 locker2");
            synchronized (locker2) {
                try {
                    Thread.sleep(500);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("t2 尝试获取 locker1");
                synchronized (locker1) {
                    System.out.println("t2 获取两把锁成功!");
                }
            }
        });
        t1.start();
        t2.start();
    }
}

多线程之五(JUC+线程安全的集合类+死锁)​可以看到死锁了

c)多个线程多把锁,更容易死锁

这个死锁常见,就有一个经典的模型,哲学家就餐问题

该问题描述的是五个哲学家共用一张圆桌,分别坐在周围的五张椅子上,在圆桌上有五个碗和五只筷子,他们的生活方式是交替的进行思考和进餐。平时,一个哲学家进行思考,饥饿时便试图取用其左右最靠近他的筷子,只有在他拿到两只筷子(叉子)时才能进餐。进餐完毕,放下筷子继续思考。

多线程之五(JUC+线程安全的集合类+死锁)

 死锁的四个必要条件:

(1)互斥使用,锁A被线程1占用,线程2就用不了

(2)不可抢占,锁A别线程1占用,线程2不能把锁A抢过来,除非线程1主动释放

(3)请求和保持,有多把锁,线程1拿到锁A之后,不想释放锁A,还想拿到锁B

(4)循环等待,线程1等待线程2释放锁,线程2要想释放锁得等待线程3释放锁,线程3释放锁得等待线程1释放锁

所以死锁问题只要解决上面,四个条件中一个就可以了

(1)和(2)都是锁的基本特性,我们解决不了

(3)是取决于代码的写法,获取锁B的时候是不是先释放锁A了,这个(3)是有可能打破的,

主要是看需求场景是否允许这样写

而(4)是有把握解决的,只要约定好加锁的顺序,就可以打破循环等待

多线程之五(JUC+线程安全的集合类+死锁)

比如,给锁编号,约定加多个锁时,必须先加编号小的锁,后加编号大的锁

现在修改一下前面的死锁代码,可以看到解决死锁问题了

多线程之五(JUC+线程安全的集合类+死锁)

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/91222.html

(0)
小半的头像小半

相关推荐

极客之音——专业性很强的中文编程技术网站,欢迎收藏到浏览器,订阅我们!