一、HashMap
1、数据结构
-
java7
- 数组加链表
-
java8
-
数组+链表+红黑树
-
转化树的目的
- 链表越长查找效率越来越低,所以转成树,耗时log(n),短的时候链表快
-
何时转化
-
链表元素大于等于8并且数组长度超过64转为树
- 链表达到阈值但是数组长度没超过64,发生扩容,先利用扩容来缩小链表的长度
-
小于等于6还原链表
-
中间有个差值7可以有效防止链表和树频繁转换
-
链表转红黑树阈值是8,实际链表长度9
-
源码注释,泊松分布,8的时候hash冲突概率已经很小了
-
-
红黑树
-
-
2、初始容量
-
java7
-
16
- 考虑到了效率和内存使用的权衡。这个值不能太大,以避免浪费空间;也不能太小,以免频繁发生扩容影响效率
-
-
java8
- 0,put的时候才初始化16
3、扩容
-
阈值=容量*加载因子0.75。默认12,超过扩容
-
新容量=旧容量*2
-
扩容的过程就是对key重新计算hash,然后把数据拷贝到新的数组
-
java7使用头插法会导致环形链表
是因为并发扩容导致的,例如线程 T1 和T2进行扩容操作,此时 T1 和 T2 指向的是链表的头结点元素 A,而 T1 和 T2 的下一个节点,指向的是 B 节点
此时线程 T2正好挂起,线程 T1 扩容完成后,线程 T2 才被唤醒
在头插法中,新加入的节点总是被插入到链表的头部。所以,T1 执行完之后的顺序是 B 到 A,T2继续执行,将A插入完,循环至B, 此时B的next指向A(T1做了全局的修改), B执行完后。循环发现B.next!=null,把A又执行了一次,根据头插法,A的next又指向B.
当使用get获取元素时,发现A.next=B,B.next=A;形成环状,导致查询出现了死循环
- java8
尾插法,不会变化原Node的next关系,解决死循环的问题
4、put
-
通过key的hash值对数组长度取余
- 取余效率不如位移运算:hash&(length-1)
- 两者相等的前提是length是2的n次方,所以map容量都是向上取2的倍数
-
找到数组中的位置之后,如果数组中没有元素直接存入,反之则判断key是否相同,key相同就覆盖,否则就会插入到链表的尾部,如果链表的长度超过8,则会转换成红黑树,最后判断数组长度是否超过默认的长度*负载因子也就是12,超过则进行扩容。
5、get
计算key的hash值,定位数组位置,遍历链表或者红黑树找到相同的key返回value
6、线程不安全
-
java7
- 在多线程环境下,并发扩容时会造成环形链或数据丢失。
-
java8
- 在多线程环境下,put会发生数据覆盖的情况。
二、ConcurrentHashMap
1、数据结构
-
java7
- ReentrantLock+Segment+hashEntry,通过Segment分段锁保证线程安全,segment继承ReentrantLock,默认16个,并发16,每个Segment包含一个HashEntry数组,默认容量2,每个HashEntry又是一个链表
-
java8
- synchronized+CAS+Node(实现Map.Entry<K,V>)+红黑树,通过synchronized+CAS保证线程安全,锁链表的head节点,锁粒度更细,效率更高
2、put
-
java7
- 计算key的hash,定位到segment,segment如果是空就先初始化
- 使用ReentrantLock加锁,如果获取锁失败则尝试自旋,自旋超过次数就阻塞获取,保证一定获取锁成功
- 二次hash,定位HashEntry数组下标,遍历HashEntry,就是和HashMap一样,数组中key一样就直接替换,不存在就再插入链表,超过扩容,释放锁
-
java8
- 首先计算hash,遍历node数组,如果当前数组位置是空则直接通过CAS自旋写入数据
- 否则如果hash==MOVED,说明需要扩容,执行扩容
- 如果都不满足,就使用synchronized写入数据,key一样就覆盖,反之就尾插法,链表长度超过8就转换成红黑树
3、get
-
java7
- key通过hash定位到segment,再遍历链表定位到具体的元素上,需要注意的是value是volatile的,所以get是不需要加锁的
-
Java8
- 同hashmap
4、特性
java8,不允许nullkey和nullvalue,会跟get返回null产生歧义
5、线程安全
利用锁机制保证线程安全