美文网首页java学习之路
java大厂面试题整理(二)集合类不安全与java中的锁

java大厂面试题整理(二)集合类不安全与java中的锁

作者: 唯有努力不欺人丶 | 来源:发表于2021-03-16 20:44 被阅读0次

    因为这块资料比较多,所以随缘分篇。这篇主要讲集合类不安全。
    其实这个是常识,可能刚刚入门就背下来了:HashMap不安全,HashTable安全,ArrayList不安全,Vector安全。这些东西很容易背,但是为什么安全?为什么不安全?从哪里体现的?什么情况下不安全?
    这些问题当我们知道了集合类的底层原理,就很容易理解了,下面让我们走进具体问题。

    ArrayList不安全

    ps:这种不安全是指在多线程的情况下,单线程情况下不存在不安全。
    首先ArrayList不安全我们众所周知,但是具体怎么不安全了?
    其实这个就涉及到ArrayList的源码了。对于集合类,其实操作也就那么几个:增删改查。

    ArrayList增加源码
    这个源码写的。。简单易懂,就是内部维护的数组当前长度的下一个赋值为给定值。然后整个代码上没有任何防护措施。在单线程的环境下没问题,但是如果多线程同时往里添加呢?让我们用代码测试一下:
    ArrayList多线程下的添加

    如图,很简单的代码,用30个线程添加,然后就出问题了。报的错其实名字也很明显:并发-修改-异常。
    会不会觉得有点懵?为什么啊?这个代码里也没修改啊怎么能报这个错呢!
    其实很简单啊,我们知道是多线程。而且没有任何锁。可能走进这个方法是时候多个线程同时想往list里添加第i个元素。于是就出现了这个并发修改异常的错误。其实往下看这个异常的原因:checkForComodification。
    也就是添加是抢了,也和预计的情况不符合了,但是真正报错的是我们添加完还读了。顺着报错找代码:


    next方法中调用了这个方法
    checkForComodification方法代码

    其实挺好理解的,这里直接输出是个遍历的过程,我们在遍历的时候发现实际的数组长度和预期的数组长度不一样(因为可能在获取预期之后又增加元素了)。所以抛出这个错误,意思是我遍历到一半发现集合改变了,我没法往下走了!所以抛了个错。
    当然了,其实除了这个显而易见报出来的错误,这里还有一个结果和期望不统一的错,因为没明显的报异常但是也不要忽视:


    代码截图
    如上代码,我明明是添加了300次,结果只有298个元素,就说明发生了争抢的结果(睡了3s,都添加完了的)
    当然了,解决这个问题的办法也很多,比如:
    1. 用Vector替代ArrayList(但是Vector比ArrayList出的早,之所以现在不用就是因为用了Synchronized。所以性能很差,一般不用)
    2. 用Collections工具类包装一层,使之成为线程安全的(如下代码)
    List<Integer> list = Collections.synchronizedList(new ArrayList<Integer>());
    
    Collections的包装类
    1. 使用CopyOnWriteArrayList类
    List<Integer> list = new CopyOnWriteArrayList<>();
    

    这个类我们点进去看看源码:

    add方法
    如图,这个方法的add是安全的,加锁的,就不会出现之前ArrayList的实际上是添加,结果是多个抢一个位置,添加个数和预期不符合的问题了。而解决遍历到一半发现集合修改的报错的代码:就是这个类名字了:copy on write。这个数据结构有意思的点可以理解为:读写分离!

    HashSet线程不安全

    其实这个也是我们比较常用的set实现类,至于代码演示其不安全可以和ArrayList差不多。


    set不安全

    解决办法也简单说下:

    1. 用工具类包装:
    Set<Integer> set = Collections.synchronizedSet(new HashSet<Integer>());
    
    工具类包装成线程安全的
    1. 用JUC中的CopyOnWriteArraySet。原理和上个CopyOnWriteArrayList类似。

    HashMap线程不安全

    HashSet的底层是HashMap,之所有map是kv对,set只有一个元素的原因是set.add中,添加的元素是key,而value是一个Object的命名为PRESENT的常量。
    所以HashSet的不安全,和HashMap的不安全其实是可以归类到一起的。展示不安全的代码如下:

    HashMap线程不安全
    而其解决办法依旧如上:
    1. 用工具类包装
    2. 用JUC中的类(注意这里名字变了,不是CopyOnWriteXXX了): ConcurrentHashMap

    Java中的锁

    公平锁:这个其实就是排队获取锁。
    非公平锁:这个是每次锁一释放所有等待的线程都参与抢锁。
    自旋锁:尝试获取锁的相乘不会立即阻塞,而是采用循环的方式去尝试获取锁,这样的好处是减少线程上下文切换的消耗,缺点是循环会消耗cpu。(CAS就是标准的自旋)
    独占锁:指该锁一次只能被一个线程所持有。ReentrantLock和Synchronized都是独占锁。
    共享锁:指该锁可以被多个线程所持有,比如读写锁ReentrantReadWriteLock该锁读锁是共享的,写锁的独占的。(因为读本身在自身不修改的情况下不会出问题,所以可以共享,保证高效。而写在多线程的情况下会出问题,所以写是互斥的,也就是独占的。)这个其实可以看成是synchronized->lock->读写锁。现在这个读写锁是一刀切的进一步进化。

    其实这里的公平锁和非公平锁就没啥好说的了,一半默认都是非公平。而这么设置的原因是为了公平(这句话要细品)。当然了,至于实现的机制就不说了,无非就是一个有序一个无序。
    然后说自旋锁:这个比较经典,昨天学CAS的时候就看到过demo:其中CAS中就是在do-while中实现的。上面也说了,其实就是用循环取尝试获取锁,减少上下文切换的消耗,但是循环本身也消耗cpu。(个人感觉什么时候用自旋还是比较清晰的,获取锁后处理快的话,比较适合自旋。这样哪怕同时大量线程进入循环等待也没多久就都处理完了。但是如果获取锁一个线程要很久,那么大量线程一直循环等待。比上下文切换的消耗还大,就不应该用自旋。这个是个人看法)
    下面是一个自旋锁的demo:

    /**
     * 
     * @author lisijia
     *
     */
    public class Test6 {
        AtomicReference<Thread> atomicReference = new AtomicReference<Thread>();
        public void lock() {
            Thread thread = Thread.currentThread();
            System.out.println(thread.getName()+"进入!");
            while(!atomicReference.compareAndSet(null, thread)) {
                
            }
            
        }
        public void unLock() {
            Thread thread = Thread.currentThread();
            System.out.println(thread.getName()+"离开!");
            atomicReference.compareAndSet(thread, null);
        }
        public static void main(String[] args) {
            Test6 test6 = new Test6();
            for(int i = 0;i<100;i++) {
                new Thread(()-> {
                    test6.lock();
                    try {
                        TimeUnit.SECONDS.sleep(2);
                    } catch (Exception e) {
                        // TODO: handle exception
                    }
                    test6.unLock();
                    }).start();
            }
            
        }
    }
    

    这个代码运行起来的效果就是大家几乎同时一起进入到lock方法中。然后两秒钟出去一个。由此说明代码是严格按照预计走的(第一个线程进入并锁上了,然后过了两秒出去解锁。这个时候第二个线程早就进去了等一号线程解锁后拿到锁了,过了两秒解锁。这个两秒我们可以理解为while中执行的消耗时间。)
    整个代码中没有用到lock也没有用到synchronized,但是确实是实现了加锁的功能。这就是自旋锁。
    接着说下读写锁,众所周知的读占了百分之八十的请求,但是读本身是无害的,所以读的时候没必要加锁。但是写的时候如果不加锁就会发生很诡异的现象。最上面的demo中就有同时add结果抢一个位置的现象。所以读写锁可以实现的功能是读锁可以共享,写锁互斥。下面是一个demo:

    class MyCache {
        private volatile Map<String, Object> map = new HashMap<String, Object>();
        private ReentrantReadWriteLock rwlock = new ReentrantReadWriteLock();
        public void put(String key,Object value) {
            rwlock.writeLock().lock();
            try {
                System.out.println(Thread.currentThread().getName()+"正在写入:"+key);
                map.put(key, value);
                System.out.println(Thread.currentThread().getName()+"写完了!");
            } catch (Exception e) {
                e.printStackTrace();
            }finally {
                rwlock.writeLock().unlock();
            }
        }
        public Object get(String key) {
            rwlock.readLock().lock();
            try {
                System.out.println(Thread.currentThread().getName()+"正在读值!");
                Object value = map.get(key);
                System.out.println(Thread.currentThread().getName()+"读到的内容:"+value);
                return value;
            } catch (Exception e) {
                return null;
            }finally {
                rwlock.readLock().unlock();
            }
        }
    }
    

    这个是加锁的代码,而且是用内部类的形式(注意这个类不是public),下面是测试代码:

        public static void main(String[] args) {
             MyCache myCache = new MyCache();
            for(int i = 0;i<10;i++) {
                final int j = i;
                new Thread(()-> {
                        myCache.put(j+"",j);
                    }).start();
            }
            for(int i = 0;i<10;i++) {
                final int j = i;
                new Thread(()-> {
                        myCache.get(j+"");
                    }).start();
            }
            
        }
    

    因为每次进入和出去都有打印,我们运行一下就能发现这段代码跑起来如下:

    运行结果
    读的时候顺序是打乱的,同时多个线程读。而写的时候都是一个写完了才有下一个的。所以说写是独占,读是共享。
    本篇笔记就记到这里,如果稍微帮到你了记得点个喜欢点个关注,也祝大家工作顺顺利利!念念不忘,必有回响,愿所有的付出都有回报!

    相关文章

      网友评论

        本文标题:java大厂面试题整理(二)集合类不安全与java中的锁

        本文链接:https://www.haomeiwen.com/subject/zvxpfltx.html