源码分析常见问题

一、ArrayList

1、用数组存储数据
2、默认容量是10
3、容量不够时,创建新的一个数组,并且容量为原来的1.5倍(* old+(old<<1)* ,并将原来的data复制到新的数组里,原来的那个被垃圾回收。
4、非线程安全
5、clone()方法是浅克隆,只是创建一个新的数组,但是数据=引用


二、LinkedList

1、用链表存储数据
2、是List和Deque接口的双向链表的实现,它可以被当作堆栈、队列或双端队列进行操作
3、不是线程安全的
4、clone()方法——浅复制


三、HashMap

1、传统HashMap的缺点:

在JDK1.6中,HashMap采用数组+链表实现,即使用链表处理冲突,同一hash值的链表都存储在一个链表里。但是当位于一个数组中的元素较多,即hash值相等的元素较多时,通过key值依次查找的效率较低。而JDK1.8中,HashMap采用数组+链表+红黑树实现,当链表长度超过阈值8时,将链表转换为红黑树,这样大大减少了查找时间。

2、基本元素默认值

(1)默认初始容量 = 16(容量为HashMap中槽的数目),且实际容量必须是2的整数次幂。
(2)最大容量 = 1 << 30(必须是2的幂且小于2的30次方,传入容量过大将被这个值替换)
(3)装填因子 = 0.75,如果当前键值对个数 >= HashMap最大容量*装填因子,进行rehash操作
(4)Entry链表最大长度 = 8,当桶中节点数目大于该长度时,将链表转成红黑树存储(JDK1.8 新加)
(5)红黑树最小节点数 = 6,当桶中节点数小于该长度,将红黑树转为链表存储;
(6)桶可能被转化为树形结构的最小容量 = 64,当哈希表的大小超过这个阈值,才会把链式结构转化成树型结构,否则仅采取扩容来尝试减少冲突。应该至少 4*Entry链表最大长度(8) 来避免扩容和树形结构化之间的冲突。
(7)HashMap的扩容阈值 ,默认=16,在HashMap中存储的Node键值对超过这个数量时,自动扩容容量为原来的二倍
(8)JDK1.6用Entry描述键值对,JDK1.8中用Node代替Entry

// hash存储key的hashCode
final int hash;
// final:一个键值对的key不可改变
final K key;
V value;
//指向下个节点的引用
Node<K, V> next;

3、将链表转化为红黑树

HashMap中键值对的存储形式为链表节点,hashCode相同的节点(位于同一个桶)用链表组织,链表长度超过8(默认)则转换成红黑树。

4、hash()方法:

static final int hash(Object key) {
    int h;
    //计算key的hashCode, h = Objects.hashCode(key)
    //h >>> 16表示对h无符号右移16位,高位补0,然后h与h >>> 16按位异或
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
//相当于key的hash值高16位不变,低16位与高16位异或作为key的最终hash值。

为什么不能直接用key的哈希值?
这个与HashMap中table下标的计算有关。

n = table.length;
index = (n-1) & hash;
//(n - 1) & hash 本质上是hash % n,位运算更快

因为,table的长度都是2的幂,因此index仅与hash值的低n位有关,hash值的高位都被与操作置为0了。 直接用key的哈希值,很容易产生碰撞。将高16位与低16位异或来减少这种碰撞概率。

5、构造函数初始化容量不一定是传入的参数,初始化容量是大于等于该给定整数的最小2^次幂值。拓容的时候也会调用下面的函数。

//结果为>=cap的最小2的自然数幂
static final int tableSizeFor(int cap) {
         //先移位再或运算,最终保证返回值是2的整数幂
         int n = cap - 1;
         n |= n >>> 1;
         n |= n >>> 2;
         n |= n >>> 4;
         n |= n >>> 8;
         n |= n >>> 16;
         return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
 178     }

6、根据key的哈希值和key获取对应的节点

getNode可分为以下几个步骤:
1.如果哈希表为空,或key对应的桶为空,返回null
2.如果桶中的第一个节点就和指定参数hash和key匹配上了,返回这个节点。
3.如果桶中的第一个节点没有匹配上,而且有后续节点
3.1如果当前的桶采用红黑树,则调用红黑树的get方法去获取节点:查找时基本就是折半查找,效率很高。
3.2如果当前的桶不采用红黑树,即桶中节点结构为链式结构,遍历链表,直到key匹配
4.找到节点返回null,否则返回null。

注:匹配hash值用==,匹配key用equals方法。

7、将指定参数key和指定参数value插入map中,如果key已经存在,那就替换key对应的value

put(K key, V value)可以分为三个步骤:
1.通过hash(Object key)方法计算key的哈希值。
2.通过putVal(hash(key), key, value, false, true)方法实现功能。
3.返回putVal方法返回的结果。

putVal方法可以分为下面的几个步骤:
1.如果哈希表为空,调用resize()创建一个哈希表。
2.如果指定参数hash在表中没有对应的桶,即为没有碰撞,直接将键值对插入到哈希表中即可。
3.如果有碰撞,遍历桶,找到key映射的节点
3.1桶中的第一个节点就匹配了,将桶中的第一个节点记录起来。
3.2如果桶中的第一个节点没有匹配,且桶中结构为红黑树,则调用红黑树对应的方法插入键值对。
3.3如果不是红黑树,那么就肯定是链表。遍历链表,如果找到了key映射的节点,就记录这个节点,退出循环。如果没有找到,在链表尾部插入节点。插入后,如果链的长度大于TREEIFY_THRESHOLD这个临界值,则使用treeifyBin方法把链表转为红黑树。
4.如果找到了key映射的节点,且节点不为null
4.1记录节点的vlaue。
4.2如果参数onlyIfAbsent为false,或者oldValue为null,替换value,否则不替换。
4.3返回记录下来的节点的value。
5.如果没有找到key映射的节点(2、3步中讲了,这种情况会插入到hashMap中),插入节点后size会加1,这时要检查size是否大于阈值threshold,如果大于会使用resize方法进行扩容。
这里写图片描述

8、resize()方法

对table进行初始化或者扩容。
如果对table扩容,因为每次扩容都是翻倍,与原来计算(n-1)&hash的结果相比,节点要么就在原来的位置,要么就被分配到“原位置+旧容量”这个位置

resize的步骤总结为:
1.计算扩容后的容量,临界值threshold。

  • 如果原来的数组长度大于最大值2^30:oldCap >= MAXIMUM_CAPACITY,则threshold = Integer.MAX_VALUE;并且无法拓容,返回原来的数组
  • 如果现在数组长度的两倍小于MAXIMUM_CAPACITY且现在的容量大于DEFAULT_INITIAL_CAPACITY(16), 临界值变为原来的2倍:lnewThr = oldThr << 1;
  • 如果旧数组长度<= 0,而且旧临界值threshold > 0,数组的新容量设置为老数组扩容的临界值
  • 如果旧数组长度 <= 0,且旧临界值 <= 0,新容量扩充为默认初始化容量,新临界值为DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY

2.将hashMap的临界值修改为扩容后的临界值 threshold = newThr;
3.根据扩容后的容量新建数组,然后将hashMap的table的引用指向新数组。
4.如果旧table不为空,将旧table中的元素复制到新的table中。

  • oldTab[j]放入newTab中e.hash & (newCap - 1)的位置
  • 如果旧桶中的结构为链表,resize时,对原有的元素根据hash值重新就算索引位置,重新安放所有对象(1.6)而且,先放在一个索引上的元素终会被放到Entry链的尾部
  • 在1.8中,对链表重拍做了一系列优化,链表元素相对位置没有变化。它并没有重新计算元素在数组中的位置,而是采用了 原始位置加原数组长度的方法计算得到位置
e = oldTab[j];
if(e.hash & oldCap)==0 //原位置
    newTab[j] = e;
else{ 
    newTab[j + oldCap] = e;//原始位置加原数组长度
}
  • 如果旧桶中的结构为红黑树,将树中的node分离:循环遍历整棵树,对于每个节点判断是在index还是index+bit; 指定位置index或者 index + bit 之后的元素,指向 hXXX 还原成链表或者修剪过的树;如果 lXXX 树的数量小于 6,则还原成链表

9、将链表转化为红黑树

  • 根据哈希表中元素个数确定是扩容还是树形化
  • 如果是树形化
    遍历桶中的元素,创建相同个数的树形节点,复制内容,建立起联系
    然后让桶第一个元素指向新建的树头结点,替换桶的链表内容为树形内容 JDK 1.8 以后哈希表的 添加、删除、查找、扩容方法都增加了一种 节点为 TreeNode 的情况:

10、clone()

浅拷贝。
clone方法虽然生成了新的HashMap对象,新的HashMap中的table数组虽然也是新生成的,但是数组中的元素还是引用以前的HashMap中的元素。
这就导致在对HashMap中的元素进行修改的时候,即对数组中元素进行修改,会导致原对象和clone对象都发生改变,但进行新增或删除就不会影响对方,因为这相当于是对数组做出的改变,clone对象新生成了一个数组。

11、序列化

writeObject(java.io.ObjectOutputStream s)
序列化hashMap到ObjectOutputStream中
将hashMap的总容量capacity、实际容量size、键值对映射写入到ObjectOutputStream中。键值对映射序列化时是无序的。

readObject(java.io.ObjectInputStream s)
将hashMap的总容量capacity、实际容量size、键值对映射读取出来

11、总结

添加时,当桶中链表个数超过 8 时会转换成红黑树;
删除、扩容时,如果桶中结构为红黑树,并且树中元素个数太少的话,会进行修剪或者直接还原成链表结构;
查找时即使哈希函数不优,大量元素集中在一个桶中,由于有红黑树结构,性能也不会差。

四、HashTable

1、Hashtable存储的内容是键值对(key-value)映射,其底层实现是一个Entry数组+链表;
2、Hashtable和HashMap一样也是散列表,存储元素也是键值对;
3、HashMap允许key和value都为null,而Hashtable都不能为null,Hashtable中的映射不是有序的;
4、Hashtable和HashMap扩容的方法不一样,Hashtable中数组默认大小11,扩容方式是 old*2+1。
5、Hashtable继承于Dictionary类(Dictionary类声明了操作键值对的接口方法),实现Map接口(定义键值对接口);
6、Hashtable大部分类用synchronized修饰,证明Hashtable是线程安全的。


五、ConcurrentHashMap

JDK.6与1.7

1、采用了分段锁的设计,由于不是对整个Map加锁。分段锁称为Segment。2、ConcurrentHashMap中的HashEntry的value以及next都被volatile修饰,保持它们的可见性。
3、默认的并发度为16,即同时可以加16个segment。当用户设置并发度时,ConcurrentHashMap会使用大于等于该值的最小2幂指数作为实际并发度。
其他默认参数同hashmap,初始容量=16,拓展因子=0.75,树转链表的阀值=8,链表转树的阀值=6
4、如果并发度设置的过小,会带来严重的锁竞争问题;如果并发度设置的过大,原本位于同一个Segment内的访问会扩散到不同的Segment中,CPU cache命中率会下降,从而引起程序性能下降。(文档的说法是根据你并发的线程数量决定,太多会导性能降低)

5、put()
(1)在真正申请锁之前,put方法会通过tryLock()方法尝试获得锁,在尝试获得锁的过程中会对对应hashcode的链表进行遍历,如果遍历完毕仍然找不到与key相同的HashEntry节点,则为后续的put操作提前创建一个HashEntry。当tryLock一定次数后仍无法获得锁,则通过lock申请锁。
之所以在获取锁的过程中对整个链表进行遍历,主要目的是希望遍历的链表被CPU cache所缓存,为后续实际put过程中的链表遍历操作提升性能。
(2)在获得锁之后,Segment对链表进行遍历,如果某个HashEntry节点具有相同的key,则更新该HashEntry的value值,否则新建一个HashEntry节点,将它设置为链表的新head节点并将原头节点设为新head的下一个节点。新建过程中如果节点总数(含新建的HashEntry)超过threshold,则调用rehash()方法对Segment进行扩容,最后将新建HashEntry写入到数组中。

6、remove()
和put类似,remove在真正获得锁之前,也会对链表进行遍历以提高缓存命中率。

7、get()、containsKey()
两个方法几乎完全一致:他们都没有使用锁,而是通过Unsafe对象的getObjectVolatile()方法提供的原子读语义,来获得Segment以及对应的链表,然后对链表遍历判断是否存在key相同的节点以及获得该节点的value。但由于遍历过程中其他线程可能对链表结构做了调整,因此get和containsKey返回的可能是过时的数据,这一点是ConcurrentHashMap在弱一致性上的体现。如果要求强一致性,那么必须使用Collections.synchronizedMap()方法。

8、size()、containsValue()
首先不加锁循环执行以下操作:循环所有的Segment(通过Unsafe的getObjectVolatile()以保证原子读语义),获得对应的值以及所有Segment的modcount之和。如果连续两次所有Segment的modcount和相等,则过程中没有发生其他线程修改ConcurrentHashMap的情况,返回获得的值。

当循环次数超过预定义的值时,这时需要对所有的Segment依次进行加锁,获取返回值后再依次解锁。值得注意的是,加锁过程中要强制创建所有的Segment,否则容易出现其他线程创建Segment并进行put,remove等操作。

对于containsValue方法来说,如果在循环过程中发现匹配value的HashEntry,则直接返回true。

ConcurrentHashMap并不允许key或者value为null

JDK1.8

1、1.8中摒弃了Segment(锁段)的概念,而采用CAS和synchronized来保证并发安全。底层依然由“数组”+链表+红黑树的方式思想(JDK7与JDK8中HashMap的实现),但是为了做到并发,又增加了很多辅助的类,例如TreeBin,Traverser等对象内部类。

2、sizeCtl属性

负数代表正在进行初始化或扩容操作
-1代表正在初始化
-N 表示有N-1个线程正在进行扩容操作
正数或0代表hash表还没有被初始化,这个数值表示初始化或下一次进行扩容的大小,这一点类似于扩容阈值的概念。还后面可以看到,它的值始终是当前ConcurrentHashMap容量的0.75倍,这与loadfactor是对应的。

3、重要的类
(1)Node。最核心的内部类,它对value和next属性设置了volatile同步锁
(2)TreeNode。与HashMap不相同的是,它并不是直接转换为红黑树,而是把这些结点包装成TreeNode放在TreeBin对象中,由TreeBin完成对红黑树的包装。
(3)TreeBin。包装的很多TreeNode节点。它代替了TreeNode的根节点,也就是说在实际的ConcurrentHashMap“数组”中,存放的是TreeBin对象,而不是TreeNode对象,这是与HashMap的区别。另外这个类还带有了读写锁。

4、更新元素putVal()
更新元素是使用CAS机制更新,需要不断的失败重试,直到成功为止。
1 判断Node[]数组是否初始化,没有则进行初始化操作
2 通过hash定位Node[]数组的索引坐标,是否有Node节点,如果没有则使用CAS进行添加(链表的头结点),添加失败则进入下次循环。
3 检查到内部正在扩容,如果正在扩容,就帮助它一块扩容。
4 如果f!=null,则使用synchronized锁住f元素(链表/红黑二叉树的头元素)
4.1 如果是Node(链表结构)则执行链表的添加操作。
4.2 如果是TreeNode(树型结果)则执行树添加操作。
5 判断链表长度已经达到临界值8 就需要把链表转换为树结构。

5、size()

final long sumCount() { 
    CounterCell[] as = counterCells; 
    CounterCell a; 
    long sum = baseCount; 
    if (as != null) { 
        for (int i = 0; i < as.length; ++i) { 
        if ((a = as[i]) != null) 
            sum += a.value; 
        } 
    } 
return sum; 
}

JDK1.8中使用一个volatile类型的变量baseCount记录元素的个数,当插入新数据put()或则删除数据remove()时,会通过addCount()方法更新baseCount。counterCells存储的都是value为1的CounterCell对象,而这些对象是因为在CAS更新baseCounter值时,由于高并发而导致失败,最终将值保存到CounterCell中,放到counterCells里。这也就是为什么sumCount()中需要遍历counterCells数组,sum累加CounterCell.value值了。

猜你喜欢

转载自blog.csdn.net/github_38687585/article/details/80989597