【JAVAEE】线程安全的集合类及死锁

目录

1.多线程环境使用集合类

2.多线程环境使用队列

3.多线程环境使用哈希表

3.1HashTable

3.2ConcurrentHashMap

4.死锁

4.1死锁是什么

4.2死锁的代码示例

4.3产生死锁的原因

4.4如何避免死锁


这里有一个代码示例:

定义一个普通的集合类,通过多线程同时对这个集合类进行add操作,并打印集合。

    public static void Demo01() throws InterruptedException {
        List<Integer> list=new ArrayList<>();

        for (int i = 0; i < 10; i++) {
            int finalI=i;
            Thread thread=new Thread(()->{
                list.add(finalI);
                System.out.println(list);
            });
            thread.start();
        }
        TimeUnit.SECONDS.sleep(1);
        System.out.println("=================");
        System.out.println(list);
    }

却抛出了异常,这是一个并发修改异常,也就是说在多线程环境下使用了线程不安全的集合类。

那么在多线程环境下如何使用线程安全的集合类?

1.多线程环境使用集合类

在多线程环境下如何使用线程安全的集合类?

1.使用Vector,HashTable等JDK提供的线程安全的类(不建议用)

2.自己使用同步机制(synchronized或者ReentrantLock)(同上,不建议用)

3.使用工具类转换Collections.synchronizedList(new ArrayList)

        //通过工具类来创建一个线程安全的集合
        List<Object>list= Collections.synchronizedList(new ArrayList<>());

实现方式是在普通集合对象外层又包裹了一层synchronized完成的线程安全。(不建议用)

4.CopyOnWriteArrayList

他时JUC包下的一个类,使用的是一种叫写时复制技术来实现的。

        //使用CopyOnWriteArrayList
        CopyOnWriteArrayList<Integer>list=new CopyOnWriteArrayList<>();

写时复制技术

1.当要修改一个集合时,先复制这个集合的复本

2.修改复本的数据,修改完成后,用复本覆盖原始集合

优点:

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

缺点:

1.占用内存较多,因为复制了一份新的数据需要修改

2.新写的数据不能被第一时间读取到

在多线程环境中如果需要使用集合类那么优先考虑CopyOnWriteArrayList

2.多线程环境使用队列

多线程环境下使用队列都是基于底层的数据结果,并具备其特性。

1.ArrayBlockingQueue

基于数组实现的阻塞队列

2.LinkedBlockingQueue

基于链表实现的阻塞队列

3.PriorityBlockingQueue

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

4.TransferQueue

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

3.多线程环境使用哈希表

HashMap本身不是线程安全的。在多线程环境下使用哈希表可以使用:

  • HashTable
  • ConcurrentHashMap

3.1HashTable

只是简单的把关键方法加上了synchronized关键字。

 这相当于直接针对HashTable对象本身加锁。读写的时候都加锁这样效率比较低,不推荐使用。

  • 如果多线程访问同一个HashTable就会直接造成锁冲突
  • size属性也是通过synchronized来控制同步,也是比较慢的
  • 一旦触发扩容,就由该线程完成整个扩容过程,这个过程会涉及到大量的元素拷贝,效率会降低

一个HashTable只有一把锁,两个线程访问HashTable中的任意数据都会出现锁竞争。

3.2ConcurrentHashMap

相比于HashTable做出了一系列的改进和优化。

多线程环境下强烈推荐使用这种方式保证线程安全,它与HashTable,Collections不同,并不是使用synchronized关键字实现加锁的,而是通过JUC包下的ReentrantLock实现加锁。(ReentrantLock使用的是CAS,用户态来实现加锁)

优化:

1.更小的锁粒度

HashTable加锁的方式,对所有的操作全部加锁,必然对性能有影响

 ConcurrentHashMap对每个Hash桶进行加锁,提高并发能力

2.只给写加锁,不给读加锁

加锁的方式是ReentrantLock,大量运用CAS操作,而且共享变量使用volatile修饰

3. 充分利用CAS特性。比如size属性通过CAS来更新,避免出现重量级锁的情况

4.对扩容进行了特殊优化

  • 发现需要扩容的线程, 只需要创建一个新的数组, 同时只搬几个元素过去.
  • 扩容期间, 新老数组同时存在.
  • 后续每个来操作 ConcurrentHashMap 的线程, 都会参与搬家的过程. 每个操作负责搬运一小 部分元素.
  • 搬完最后一个元素再把老数组删掉.
  • 这个期间, 插入只往新数组加.
  • 这个期间, 查找需要同时查新数组和老数组

是一个典型的以空间换时间的用例。

4.死锁

4.1死锁是什么

死锁就是一个线程加上锁之后不运行也不释放僵持住了。死锁会导致程序无法运行,是一个最严重的bug之一。

举个栗子理解死锁
滑稽老哥和女神一起去饺子馆吃饺子 . 吃饺子需要酱油和醋 .
滑稽老哥抄起了酱油瓶 , 女神抄起了醋瓶 .
滑稽 : 你先把醋瓶给我 , 我用完了就把酱油瓶给你 .
女神 : 你先把酱油瓶给我 , 我用完了就把醋瓶给你 .
如果这俩人彼此之间互不相让 , 就构成了死锁 .
酱油和醋相当于是两把锁 , 这两个人就是两个线程

4.2死锁的代码示例

定义两个锁对象

        //定义两个锁对象
        Object locker1=new Object();
        Object locker2=new Object();

线程1,先获取locker1,在获取locker2

        //线程1,先获取locker1,再获取locker2
        Thread t1=new Thread(()->{
            System.out.println(Thread.currentThread().getName()+"t1申请locker1");
            synchronized (locker1){
                System.out.println(Thread.currentThread().getName()+"t1申请到了locker1");
                //模拟业务处理过程
                try {
                    TimeUnit.SECONDS.sleep(1);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                //获取locker2
                System.out.println(Thread.currentThread().getName()+"t1申请locker2");
                synchronized (locker2) {
                    System.out.println(Thread.currentThread().getName() + "t1申请到了两把锁");
                }
            }
        });
        //启动t1
        t1.start();

线程2,先获取locker2,在获取locker1

        //线程2,先获取locker2,再获取locker1
        Thread t2=new Thread(()->{
            System.out.println(Thread.currentThread().getName()+"t2申请locker2");
            synchronized (locker2){
                System.out.println(Thread.currentThread().getName()+"t2申请到了locker2");
                //模拟业务处理过程
                try {
                    TimeUnit.SECONDS.sleep(1);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                //获取locker2
                System.out.println(Thread.currentThread().getName()+"t2申请locker2");
                synchronized (locker1) {
                    System.out.println(Thread.currentThread().getName() + "t2申请到了两把锁");
                }
            }
        });
        //启动t2
        t2.start();

运行结果

 这样就造成了死锁,程序无法退出。两个线程对于加锁的顺序没有约定,就容易产生环路等待。

4.3产生死锁的原因

1.互斥使用:A被线程1占用了,线程2就不能用了

2.不可抢占:A被线程1占用了,线程2不能主动把锁A抢过来,除非线程1主动释放

3.请求保持:有多把锁,线程1拿到了锁A之后,不释放还要继续再拿锁B

4.循环等待:线程1等待线程2释放锁,线程2要释放锁得等待线程3先释放锁...形成了循环关系

4.4如何避免死锁

以上四条是形成死锁的必要条件,只要打破其中任何一条就可以避免死锁。

1.互斥使用不可抢占是锁的基本特性,无法打破。

2.请求保持是有可能打破的,取决于代码怎么写

3.循环等待,约定好加锁顺序就可以打破循环等待。在4.2的代码示例中t1.locker1->locker2,t2.locker2->locker1这个顺序造成了循环等待,如果调整加锁顺序,就可以避免循环等待。

4.2示例代码改正:

Object lock1 = new Object();
Object lock2 = new Object();
Thread t1 = new Thread() {
    @Override
    public void run() {
        synchronized (lock1) {
            synchronized (lock2) {
                // do something...
           }
       }
   }
};
t1.start();
Thread t2 = new Thread() {
    @Override
    public void run() {
        synchronized (lock1) {
            synchronized (lock2) {
                // do something...
           }
       }
   }
};
t2.start();

  • 3
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值