【线程锁事】篇二:并发容器为什么能实现高效并发?

1,045 阅读7分钟

系列文章索引

并发系列:线程锁事

  1. 篇一:为什么CountDownlatch能保证执行顺序?

  2. 篇二:并发容器为什么能实现高效并发?

  3. [篇三:从ReentrientLock看锁的正确使用姿势](创作中)

新系列:Android11系统源码解析

  1. Android11源码分析:Mac环境如何下载Android源码?

  2. Android11源码分析:应用是如何启动的?

  3. Android11源码分析:Activity是怎么启动的?

  4. Android11源码分析:Service启动流程分析

  5. Android11源码分析:静态广播是如何收到通知的?

  6. Android11源码分析:binder是如何实现跨进程的?(创作中)

  7. 番外篇 - 插件化探索:插件Activity是如何启动的?

  8. Android11源码分析: UI到底为什么会卡顿?

  9. Android11源码分析:SurfaceFlinger是如何对vsync信号进行分发的?(创作中)

经典系列:Android10系统启动流程

  1. 源码下载及编译

  2. Android系统启动流程纵览

  3. init进程源码解析

  4. zygote进程源码解析

  5. systemServer源码解析

前言

前篇文章介绍了CountdownLatch的使用及原理,而在并发中,除了多线程协作,我们还需要考虑如何实现高效并发

今天继续从源码的角度出发,聊一聊并发容器是如何实现高效并发的

下面,正文开始!

为什么Hashtable并发效率低?

在提高效并发前,我们先讲一讲HashMapHashtable

Hashmap是面试中的高频考点,也是实现hash表的重要的数据结构,可以用来存储键值对信息,可以根据hash快速的查找对一个的value值

其中最重要的两个函数是put()get(), Key和Value均可以通过范型指定

put()函数中会对key值进行hash,并调用putVal()将数据进行存储,内部使用数组 + 链表 + 红黑树的方式进行存储

其中,数组用来存储对应的Node<K,V>保存键值对信息,在发生hash冲突后,会使用开链法解决hash冲突问题,这里对应的就是Node就是链表的实现。在重复的hash值>8时,则会对其进行树化,使用红黑树进行处理,降低查询的时间复杂度(时间复杂度为O(nlog(n)),但代价是会占用额外的空间)

在平常的使用中,发生hash冲突发生8次以上的概率为千万分之一,因此一般情况下不会发生树化

在初始化时,会调用resize()对数组进行初始化大小的赋值,初始大小为1>>2,即16,最大大小为1>>30,约为10亿

当后续put数据大小不足时,会扩容为原来的2倍(oldVal>>1),并创建新的数组进行搬移操作

具体逻辑请查看以下代码中的注释

static final int MAXIMUM_CAPACITY = 1 << 30; //最大大小约为10亿

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 初始值为16

final Node<K,V>[] resize() { 
        Node<K,V>[] oldTab = table;
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        int oldThr = threshold;
        int newCap, newThr = 0;
        if (oldCap > 0) {
            if (oldCap >= MAXIMUM_CAPACITY) {
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && //最大大小为1>>30,约为10亿
                     oldCap >= DEFAULT_INITIAL_CAPACITY)  //初始大小为16
                newThr = oldThr << 1; // double threshold // 扩容大小为原来对2倍(<<1)
        }
        //...略
        threshold = newThr;
        @SuppressWarnings({"rawtypes","unchecked"})
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; //创建一个新的数组
        table = newTab;
        if (oldTab != null) {
            for (int j = 0; j < oldCap; ++j) { //将原数组搬移到新数组中
                Node<K,V> e;
                if ((e = oldTab[j]) != null) { //将原来的元素指向新的引用
                    oldTab[j] = null; //将原来的元素置为null(便于GC进行回收)
                    if (e.next == null) //判断是否有重复hash存储在当前链表中,
                        newTab[e.hash & (newCap - 1)] = e; 如果没有则直接放在新的数组的位置
                    else if (e instanceof TreeNode) //如果是使用TreeNode进行存储,则需要对TreeNode进行搬移处理
                        ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                    else { // preserve order //如果是链表结构,则需要循环遍历链表,完成链表的搬移操作
                        //...略
                    }
                }
            }
        }
        return newTab;
    }

要对Hahstable进行数据同步,需要保护的数据及为在put()get()中多个线程所操作的共享数据

HahshTable的实现同步直接在这两个函数上加上了synchornize,但弊端在于锁的粒度过大,且put执行是相当耗时的操作,所以该类早就不推荐使用了,因此才有了高效并发容器CurrentHashMap

CurrentHashMap做了哪些优化工作?

我们先从java7版本的CurrentHashMap说起

Java7版本中的并发优化

在Java7中,使用分段锁对数据进行并发操作,其中会使用Segment对数据进行分段加锁,Segment本身是ReentrantLock的子类对象,在对每个Segement进行数据put操作时,其中的逻辑与Hashmap基本一致,同样会使用数组存储键值对信息,在发生hash冲突时会使用拉链法,用链表对其进行维护

CurrentHashmap进行初始化时,可以指定Segment的数量,即指定并发度,如果未指定或超过了最大值(DEFAULT_CONCURRENCY_LEVEL)16,则会初始化为16个Segment,使用数组进行维护

初始化代码如下

public ConcurrentHashMap(int initialCapacity,
                             float loadFactor, int concurrencyLevel) { //可以通过concurrencyLevel设置它的并发度(支持多少个线程)
        //...略
        if (!(loadFactor > 0) || initialCapacity < 0 || concurrencyLevel <= 0)
            throw new IllegalArgumentException();
            //segment是ReentrantLock的是实现类,用来支持并发操作,即Currenthashmap最高支持16个线程同时进行并发操作(16个锁对象)
        Segment<K,V> s0 =
            new Segment<K,V>(loadFactor, (int)(cap * loadFactor),
                             (HashEntry<K,V>[])new HashEntry[cap]); //创建第一个Segment
        Segment<K,V>[] ss = (Segment<K,V>[])new Segment[ssize];//创建segment数组
        
		}
}                   

java8版本中的并发优化

针对我们现在使用的Java8版本的CurrentHashMap,又针对java7版本进行了进一步优化

具体优化在于舍弃了Segment分段锁的同步思想,改为使用CAS操作+ synchronize实现,并发度由原来支持16个线程同时执行,提高到每个数据节点都可以支持并发操作

另外,在此基础上,在发生hash冲突时,如果hash冲突次数小于8,继续使用链表解决冲突问题;如果hash冲突次数大于8,则会对其进行树化,时间复杂度由原来的0(n)降低为O(nlongn),但缺点是红黑树也会占用更多的内存空间

小结

在java7版本中,CurrentHashmap采用Segment对数据进行分段操作,最高支持16个线程同时并发执行

在java8版本中,CurrentHashmap采用CAS的方式对每个HashEntry数据进行并发操作,提高了并发度;并在hash冲突次数大于8时,会对链表进行树化,减少查找的时间复杂度

CopyOnWriteArrayList是如何实现高效并发的?

CopyOnWriteArrayList的主要思想是,写时复制,读写分离

在进行add()操作时,会将原数组拷贝一份,在拷贝的数据上进行修改,修改完成后再将其指向原数组,完成数据的更新

具体代码如下

public void add(int index, E element) {
        final ReentrantLock lock = this.lock;
        lock.lock(); //加锁,保证多线程操作的原子性
        try {
            Object[] elements = getArray(); //获取到原数组
            int len = elements.length;
            if (index > len || index < 0)
                throw new IndexOutOfBoundsException("Index: "+index+
                                                    ", Size: "+len);
            Object[] newElements; //创建新数组,并将原数组拷贝进去
            int numMoved = len - index;
            if (numMoved == 0)
                newElements = Arrays.copyOf(elements, len + 1); 
            else {
                newElements = new Object[len + 1];
                System.arraycopy(elements, 0, newElements, 0, index);
                System.arraycopy(elements, index, newElements, index + 1,
                                 numMoved);
            }
            newElements[index] = element; //在拷贝数组上进行数据添加操作
            setArray(newElements); //将拷贝数组指向原数组
        } finally {
            lock.unlock(); //解锁,保证完成后对锁进行释放
        }
    }

这样做的好处在于,当我们需要进行读操作时,由于每次的add()操作都不会对原数组直接修改,相当于原数组是不可变的,因此在get()方法进行取数据时,完全不需要加锁,直接取就可以了

代码如下

private E get(Object[] a, int index) {
        return (E) a[index];
    }

小结

CopyOnWriteArrayList的通过写时复制,读写分离实现了在无锁的get()操作,因此在读多写少的场景下,CopyOnWriteArrayList可以实现高效的并发操作

深入思考:高效并发的本质是什么?

在上文中,我们对HashTable,CurrentHashMapCopyOnWriteArrayList进行了源码分析;低效的并发往往将逻辑简单化,使用同一把锁对数据进行保护

高效的并发

要么将锁的粒度细化,比如使用分段锁将数据分段,或者使用CAS操作(乐观锁)来支持每个数据更新的原子性,或者将读写进行分离(读写锁也是读写分离的思想)

要么利用数据的不变性,只要数据不被改变,就没有并发问题

后记

针对并发系列已经完成了第二篇,对并发容器的相关进行了分析,在本文中提到了一些锁的类型,比如悲观锁/乐观锁读锁/写锁粗粒度锁/细粒度锁,等等

下篇文章中我们将对这些的类型,从源码的角度,结合相关理论进行分析

我是释然,我们下期文章再见!

如果本文对你有所启发,请多多点赞支持