并发编程三:深入理解并发List、Set、ConcurrentHashMap底层原理

深入理解并发List、Set、ConcurrentHashMap底层原理

之前两篇分析了并发的三大特性和JMM模型,从硬件、jvm、java层面分别进行分析。JMM模型是并发当中最难理解的部分,涉及到的概念很多。
本次分析经常使用到的集合的底层原理和数据结构。在这里插入图片描述

深入ArrayList分析

ArrayList使我们经常使用到的List的实现类。先来看先它有什么特点。
首先List的特点:元素有放入顺序,元素可重复 。ArrayList自然也有相同的特点。
其次ArrayList的存储结构底层采用数组来实现的。什么是数组:数组是采用一段连续的存储单元来存储数据。什么是连续的存储单元,在jvm中对象会在堆中分配空间,那么数组呢同样在堆中分配空间并且是一段连续的空间。
数组解析
数组是一个数据结构,它的特点是查询的时间复杂度O(1),插入的时间复杂度O(N)。就是说查询速度快,插入速度慢。
在这里插入图片描述
上图定义了一个数组。这个数组每一个元素都要以一个下标,下标从0开始,0指向了数组起始位置在内存中的地址,也就是数组的起始内存地址。从上图来说0指向了起始地址为100,这个100是内存中的地址。
如果要查找数组的元素怎么查找呢,比如要查找a[2]的元素。这里有一个数组的查找公式:a[n]=起始地址+(n*字节数)。int类型的字节数是4,那么a[2]的内存地址=100+(2*4)=108。那么找到内存地址是108的位置获取到对应的值就是34。这就是查询的时间复杂度O(1)的概念。这里的1不是操作一次的意思,而是一个常量的意思。数组是本身就是一个连续的空间,所以知道起始位置去找其他位置上的元素是非常快的。这就是数组查询速度快的原因。
在来看下数组插入的过程
在这里插入图片描述
如上图,向数组插入数据d,比如插入到2和3之间,首先3会移动一定空间让d插入,由于下标是连续的,当d插入以后,d后面数据的下标就要往前挪一个位置,本来下标3指向e,现在下标3指向d,一次类推。所以数组插入的时间复杂度是O(N)。当然了如果向数组的最后一个下标插入元素那么时间复杂度就是O(1),但是从数组的角度来说这样的概率1/n,概率较小,从常规来说数组插入的时间复杂度是O(N)。在理解了数组后来看下ArrayList底层数组是如何实现的。
ArrayList源码分析
怎么证实ArrayList底层是数组实现的呢?先来看add方法
add方法
在这里插入图片描述
在这里插入图片描述
通过上面这两张图就能很好看出,ArrayList底层是通过Object数组来实现。
那么再来看看这个add方法的时间复杂度是多少?这个add的方法时间复杂度为O(1)。这里是默认从数组的最后一位插入。虽然说数组的插入比较慢,但是在ArrayList的这个add方法中插入效率是非常高的。

在执行elementData[size++] = e赋值之前呢先执行了ensureCapacityInternal(size + 1); 看下这部分源码
在这里插入图片描述
这部分代码主要是用来扩容的。具体在 grow(minCapacity);
在这里插入图片描述
扩容机制的底层实现通过数组拷贝(浅拷贝)的方式。这个数组拷贝是说原来数组长度为6,扩容的时候重新开辟一个新的数组长度为12,然后把原来那个长度为6的数组拷贝到新的数组当中去。这样做的好处是什么呢,利用空间获取时间。如果说直接在原数组上扩容六个位置,那么可能导致下标要向前或者向后挪动,就像数组插入流程那样,插入六个位置。这样做的缺点就是占用了两份数组的空间,所以说是利用空间换取时间
像使用redis做缓存,减少对数据库的压力也是一种空间换时间的做法。
再来看下另一个add方法
在这里插入图片描述
那么这个add方法的时间复杂度是O(N),它是指定下标插入。
ArrayList还有常用的get()方法,这个方法实际上就是用了数组查询的公式,a[n]=起始地址+(n*字节数)。比较简单,上面分析过了就不在分析了。
再来看下ArrayList类的关系
在这里插入图片描述
RandomAccess是一个随机访问的接口。
Cloneable是一个拷贝的接口,解释一下:实现Cloneable接口,重写clone方法、方法内容默认调用父类的clone方法。像浅拷贝、深拷贝要实现这个接口。
Serializable是一个序列化接口
继承了AbstractList ,说明它是一个列表,拥有相应的增,删,查,改等功能。
为什么继承了 AbstractList 还需要实现List 接口?据说是作者写错了感兴趣可以去了解下

ArrayList中定义两个空数组有什么作用
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
调用有参构造和无参构造分别使用到了这两个空数组。有什么用呢,就是说如果在new ArrayList的时候调用有参构造指定了大小,那么add方法中就不会去判断扩容,而是直接使用指定的大小,相当于提高性能。
总结一下:ArrayList底层使用数组来实现的,满足数组的特性查询快插入慢不指定下标的add方法默认是从最后一个下标开始插入数据,它的插入效率也挺高。
ArrayList实现了List满足List的特点:有序的可重复的

深入LinkedList分析

LinkedList的继承关系和ArrayList一样。但是底层是通过链表实现的。
LinkedList的特点:有序的可重复的,由于底层使用链表实现,查询效率低,插入效率高
链表分析
链表的定义:链表是一种在物理单元存储上非连续、非顺序的数据结构。
链表特点:插入删除的时间复杂度O(1),查询的时间复杂度O(N),就是插入块查询慢
看下链表在java当中的代码实现

public class NodeDemo {
	//存储的数据
	private Object data;
	//指向下一个节点
	public NodeDemo next;
	public NodeDemo(Object data){
		this.data = data;
	}
	public static void main(String[] args) {
		NodeDemo head = new NodeDemo("刘备");
		//相当于链表增加操作
		head.next = new NodeDemo("关羽");
		//head.next = null //相当于链表删除操作
		head.next.next = new NodeDemo("张飞");
		System.out.println(head.data);
		System.out.println(head.next.data);
		System.out.println(head.next.next.data);
	 }
}

在这里插入图片描述
对于链表查询需要从头节点遍历整个链表,所以比较慢,链表插入删除对next指针的修改所以比较快。
LinkedList源码分析
LinkedList的类继承关系和ArrayList一样。
add方法:
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
通过上面的源码,可以看到LinkedList的层一个双向链表。链表分为单向链表、双向链表、循环链表。LinkedList插入删除方法和上图链表的插入删除过程相同。

插入相同数据量的话LinkedList和ArrayList那个会更快。如果说ArrayList没有指定容量的话,因为涉及到扩容问题LinkedList的插入会更快。如果ArrayList指定了容量的话,不存在扩容问题,那么会ArrayList的会更快,因为ArrayList的add方法是默认往数组最后一个元素插入。

HashSet分析

特点: 元素无放入顺序,元素不可重复(注意:元素虽然无放入顺序,但是元素在set中的位置是有该元素的HashCode决定的,其位置其实是固定的)
底层采用HashMap来实现
add方法
在这里插入图片描述
底层通过map进行数据添加,所以元素不可重复。对于HashSet分析重点在于对HashMap的分析

深入HashMap分析

HashMap的底层结构采用数组、链表、红黑树来实现。
程序=数据结构+算法。现在知道HashMap的数据结构,那么来看看HashMap的算法。
哈希算法(也叫散列),就是把任意长度值(Key)通过散列算法变换成固定长度的key(地址) 通过这个地址进行访问的数据结构它通过把关键码值映射到表中一个。位置来访问记录,以加快查找的速度。
这是哈希算法的原理,有很多种实现方式,例如MD5。那我们怎么实现一个HashMap的功能。

在这里插入图片描述

在这里插入图片描述
上图就是把一个名字转成对应的ascll,进行取模,这样就能放到对应下标,比如对429取模10得到9,那么就放入到9的下标当中。那么问题来了,为什么要取模?为了节省空间,数组的定义是一个连续的存储单元,如果ascll成千上万,那要开辟成千上万的空间只为存储一个名字,明显不合理,所以要进行取模处理。
那么还要一个问题,看下图
在这里插入图片描述
得到相同ascll通过散列算法之后会放入数组对应下标的当中,导致lies放入到9的位置,foes也放入到9的位置,那么就会覆盖了之前值。为了避免这种情况出现,引入了链表结构。这就是为什么HashMap既有数组又有链表的原因。

那么为什么HashMap还要用到红黑树呢?主要是解决链表查询慢的问题,在上面说过链表查询时找到头节点,根据next节点找到下一个元素,说白了就是要遍历整个链表。如果链表太长那么查询效率就会非常低下。

刚才说了HashMap的计算出hashCode相同的时候,会把相同数据插入到链表中,那么具体怎么插入。在JDK1.7的时候,采用的是头插法。就像上图,如果先插入foes,那么HashMap会把foes放入到数据中,因为此时只有一个429的hashCode。如果又来一个lies,它的hashCode也是429,那么foes会让出位置,让lies插入到数组中,然后lies的指针指向foes。这就是头插法。
在JDK1.8的时候采用的是尾插法。插入的方向相反。foes的指针指向lies。
在JDK1.7的时候这种头插法在并发的情况下,如果使用不当会出现cpu100%的问题。这里就不扩展了。在JDK1.8采用了尾插法,解决了这个问题。
HashMap源码分析
先来看put方法
在这里插入图片描述
重点来看putVal方法
在这里插入图片描述
第一个if判断map是否为空,然后初始化的操作。第二个if就是判断tab存储hashcode有没有重复,如果没有采用数组方式存储。如果有采用链表方式存储。再来看看else的部分代码 。
在这里插入图片描述
else的部分就是采用链表和红黑树的存储方式,红框表示的是采用红黑树的条件,调用treeifyBin方法就是将链表转成红黑树。treeifyBin的源码就不分析了。
在这里插入图片描述
HashMap定义两个常量,上面一个是常量链表转成红黑树的条件,下面一个常量等于6是红黑树转成链表的条件。就是说如果对红黑树的数据删除,长度小于6那么就会转成链表。
HashMap定义很多常量例如扩容的常量和加载因子的常量,就不在一一解释了。说一下加载因子HashMap默认数组长度是16,加载因子定义为0.75,就是说当插入的数据达到12个是开始扩容。
下图是HashMap的put方法的流程图
在这里插入图片描述

深入ConcurrentHashMap分析

存储结构: 底层采用数组、链表、红黑树 内部大量采用CAS操作。并发控制使⽤synchronized 和 CAS 来操作来实现的。
并发安全的HashMap ,比Hashtable效率更高。
Hashtable同样也是线程安全的,看下它的put方法
在这里插入图片描述
Hashtable的put方法是方法级别的synchronized,相对于ConcurrentHashMap来说,锁的范围更大,细粒度更大。不过synchronized一直在优化,性能并没有想象中那么差,用还是能够使用的。

下面来分析ConcurrentHashMap的源码。ConcurrentHashMap在jdk1.7和1.8差别比较大,在1.8做了很多优化,JDK1.8ConcurrentHashMap的源码流程和HashMap的源码流程差不多。
ConcurrentHashMap的源码分析
还是以put方法为例子
在这里插入图片描述
和HashMap一样调用了putVal方法。

final V putVal(K key, V value, boolean onlyIfAbsent) {
		//空置判断
        if (key == null || value == null) throw new NullPointerException();
        //两次hash
        int hash = spread(key.hashCode());
        int binCount = 0;
        //采用了自旋方式对table进行遍历
        for (Node<K,V>[] tab = table;;) {
        //定义一些变量
            Node<K,V> f; int n, i, fh;
            if (tab == null || (n = tab.length) == 0)
            	//初始化
                tab = initTable();
            else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
            //用数组方式存储,这里用到了CAS的方式保证线程安全性
                if (casTabAt(tab, i, null,
                             new Node<K,V>(hash, key, value, null)))
                    break;                   // no lock when adding to empty bin
            }
            //判断状态如果是MOVED的状态进行扩容
            else if ((fh = f.hash) == MOVED)
            //扩容的方法, 底层也是采用了CAS方式进行扩容
                tab = helpTransfer(tab, f);
            else {
            //采用链表或者红黑树存储
                V oldVal = null;
                //对链表或者红黑树上锁,这里锁的粒度很小
                //后面代码判断转成链表还是转成红黑树,逻辑和HashMap差不多
                synchronized (f) {
                    if (tabAt(tab, i) == f) {
                        if (fh >= 0) {
                            binCount = 1;
                            for (Node<K,V> e = f;; ++binCount) {
                                K ek;
                                if (e.hash == hash &&
                                    ((ek = e.key) == key ||
                                     (ek != null && key.equals(ek)))) {
                                    oldVal = e.val;
                                    if (!onlyIfAbsent)
                                        e.val = value;
                                    break;
                                }
                                Node<K,V> pred = e;
                                if ((e = e.next) == null) {
                                    pred.next = new Node<K,V>(hash, key,
                                                              value, null);
                                    break;
                                }
                            }
                        }
                        else if (f instanceof TreeBin) {
                            Node<K,V> p;
                            binCount = 2;
                            if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
                                                           value)) != null) {
                                oldVal = p.val;
                                if (!onlyIfAbsent)
                                    p.val = value;
                            }
                        }
                    }
                }
                if (binCount != 0) {
                    if (binCount >= TREEIFY_THRESHOLD)
                        treeifyBin(tab, i);
                    if (oldVal != null)
                        return oldVal;
                    break;
                }
            }
        }
        addCount(1L, binCount);
        return null;
    }

上面代码的流程及时先做一些判断,然后遍历table。遍历的这个table是定义的一个volatile类型的
看下遍历table的时候,这个table是一个
在这里插入图片描述
遍历的过程中定义一些变量,然后判断要不要初始化(执行 tab = initTable();)。为什么要在put方法里面进行初始化,首先ConcurrentHashMap的构造方法中并没有做任何初始化处理。除非你指定了容量。
在这里插入图片描述
再来看initTable()方法
在这里插入图片描述
这段代码意思是判断有没有初始化,如果初始化了那个当前线程就要让出时间片,就是不用执行初始化了。如果没有初始化,则采用CAS方式,然后进行扩容。
后续代码执行流程,在上面源码上注释有说明了。
ConcurrentHashMap和HashMap的处理逻辑差不多。差别在于,ConcurrentHashMap在put方法中判断需不需要执行初始化(采用CAS方式保证安全性),然后通过自旋的方式对整个table进行操作。判断是采用数组方式存储(数组方式存储时使用的是CAS方式保证安全性),还是链表或者红黑树(使用这两种方式存储时采用的是synchronized 上锁,保证安全性,这样锁的粒度很小)。

那么采用自旋的方式和采用synchronized 的 方式有什么差别?首先这是阻塞和不阻塞的区别,还有一个是自旋和CAS是不需要改变线程的状态,synchronized 就会改变线程的状态。线程状态的改变会影响性能。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值