一、常用Map结构图
1、HashTable:遗留类,继承自DIctionary,键值都不能为null,但是通过synchronized关键字保证线程安全,并发性不如concurrentHashMap
2、TreeMap:能够将保存的记录通过键值排序,当然也可以通过实现Comparator接口的compare方法或者Comparable的compareTo方法实现比较器。
3、ConcurrentHashMap:它首先肯定是一个HashMap,既然已经存在了HashMap之后又会设计出ConcurrentHashMap并发框架,原因在于HashMap线程不安全。大致思想就是:通过分块(segment)上锁的方式,每一个锁又是一个可重入锁,segment针对当前链表进行同步处理。
二、HashMap特性
1、首先HashMap允许null作为键值,但是在键中只能出现一个null值,值可以出现多个null
2、HashMap结构类型:
Java 7及以前:数组+链表
Java 8:数组+链表+红黑树(补充红黑树,待加)
3、线程不安全(第四部分会说明原因)
java 7 :多线程导致死锁
java 8:多线程导致键值对丢失
三、HashMap底层结构
1、数组结构Node[] table,数组大小为2的幂次个数(数组扩容会讲解为什么选择2的幂次)
2、Node类:
- 每一个节点是一个<k,v>结构,同时包含hash主要用来定位数组索引位置,在哪一个桶中(将每一个数组中的元素看作是一个桶),由于是链表结构,需要next指针,以上四种变量构成了Node核心属性。
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;//数组位置
final K key;
V value;
Node<K,V> next;//下一个节点
//构造函数
Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
}
- 重写hashCode方法:key值hashCode与valuehashCode异或运算
public final int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
-
重写equals:两个Node相等的条件:
(1)属于同一对象
(2)两对象key和value值相等
public final boolean equals(Object o) {
if (o == this)
return true;
if (o instanceof Map.Entry) {
Map.Entry<?,?> e = (Map.Entry<?,?>)o;
if (Objects.equals(key, e.getKey()) &&
Objects.equals(value, e.getValue()))
return true;
}
return false;
}
3、HashMap属性
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; //默认容量16
static final float DEFAULT_LOAD_FACTOR = 0.75f;//默认负载因子
static final int TREEIFY_THRESHOLD = 8;//超过该值,链表转化为红黑树
static final int UNTREEIFY_THRESHOLD = 6;//小于该值,红黑树转化为链表
final float loadFactor;
int threshold;
1、loadFactor负载因子的作用主要在于控制数据的疏密,越趋近于1则表明需要存储的数据越多,也就导致链表更加长,越趋近于0则表明存储的数据越少,链表更加短。
2、threshold = capacity * loadFactor,如果数组中容量大于Threshold,则需要扩容
4、HashMap构造方法
(1)HashMap(int initialCapacity, float loadFactor)
设置初始容量(默认是16)和负载因子
(2)HashMap(int initialCapacity)
,负载因子为默认值0.75
(3)HashMap()
(4)HashMap(Map<? extends K, ? extends V> m)
:添加一个Map集合,采取的方式是遍历该Map中的元素,之后添加到当前map中
5、Hashmap集合put(k,v),调用putVal方法,对用户只展示put方法
(1)i = (n - 1) & hash :与运算代替取模运算来查询所在数组的索引(该存放在哪一个桶),取模运算消耗比较大,更加高效
(2)流程图
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
//如果为空,则初始化该数组,resize()既可以用作初始化,也可以用作数组扩容
n = (tab = resize()).length;
//数组中添加第一个节点
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
//哈希碰撞
else {
Node<K,V> e; K k;
//节点已存在,直接覆盖
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//属于树节点,直接添加到红黑树中
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
//链表中添加节点
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
//java8 转化为直接插入到链表尾部,java7及之前都是通过头结点插入,倒置
p.next = newNode(hash, key, value, null);
//链表节点超过阈值,转化为红黑树
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
//如果在链表中添加的时候,节点已经存在,直接准跳出循环
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
//如果key已经存在,更新value值
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
6、HashMap: V get(Object key) 调用getNode函数
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
//确保当前数组存在&&当前数组长度 大于0 && 当前桶存在元素
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
//头结点满足条件直接返回头结点
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
if ((e = first.next) != null) {
//树节点获得
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
//遍历链表查找
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
7、resize() 数组初始化或者扩容
- 数组初始化,为什么需要将数组的大小设置为2的幂次?
(1)提高操作运算效率(位运算效率 > 取模运算效率),此时需要提到5中提到的i = (n - 1) & hash
用来计算桶的位置(数组索引),通过与运算代替取模运算能够更加高效;
(2)从HashMap查询的效率上来考虑,均匀分布(竟可能多的数据分布在数组上)导致查询的时间复杂度趋近于O(1),当数据分布在链表上的时候数据查询的时间复杂度变为O(n),能够减少碰撞,数据均匀分布,提HashMap查询效率
例如:数组容量=16 = 2^4,此时计算出的hash,值分别为8和9
8所在桶:(16 - 1) & 8 = 0000 1111 & 0000 1000 = 0000 1000
9所在桶:(16 - 1) & 9 = 0000 1111 & 0000 1001 = 0000 1001
此时会将8,9放置在两个不同的桶中,相反,如果此时桶的容量为15,不等于2的幂次
8所在桶:(15 - 1) & 8 = 0000 1110 & 0000 1000 = 0000 1000
9所在桶:(15 - 1) & 9 = 0000 1110 & 0000 1001 = 0000 1000
此时8,9会放置在同一个桶中,自然而然会降低查询的效率
(3)如果选择非2次幂,比如容量为15进行存储,由于(15-1) = 0000 1110 ,就会导致出现 0001,0011,0101,0111,1001,1101,1011,1111等位置的桶不能存放数据,造成浪费。 - 带参数(一般指包含数组容量)初始化HashMap
前边阐述了数组容量一定是2的幂次,但是在初始化的时候不是必须输入2的幂次数组未初始化容量,因为HashMap内部有一个tableSizeFor函数,会将获取正大于数字n的2的整数次幂
例如:
输入容量为7
首先n = n | n>>>1 = 0111
0011
-------
0111 =7
之后 n |= n >>> 2; -->7
之后n |= n >>> 4; -->7
依次计算。
最终得得到阈值= 7+1 = 8
static final int tableSizeFor(int cap) {
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;
}
-数组扩容
1、新旧数组高效复制
(1)秘密在于e.hash & oldCap
得到的是 元素的在数组中的位置是否需要移动,扩容后二进制位,0不移动,1需要移动,在新数组中的下标=元数组位置+元数组容量
旧数组容量:32
旧数组容量:16 = 0001 0000
e.hash : 10 = 0000 1010
&: 0000 0000 比较高位的第一位 0
表明新数组位置与原来数组位置不变=(原来数组位置)
旧数组容量:32
旧数组容量:16 = 0001 0000
e.hash : 17 = 0001 0001
&: 0001 0000 比较高位的第一位 1
表明新数组位置=(新的下标位置+原数组的容量)=(1+ 16)=17
(2)(e.hash & (oldCap-1))
得到的是原来数组下标位置
(3)根据(1)(2)理论来讲原来数组中的链表拆分为新旧链表
// loHead原来数组头loTail原来数组尾 //hiHead扩容后增长的数组头hiTai扩容后增长的数组尾
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
//旧链表位置不需要改变
next = e.next;
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
//就链表位置修改为新链表为止
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
2、数组扩容源码分析resize()
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;
}
//数组扩容为2倍,数组thresh也变为2倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0) // 第一次带参初始化,给出了初始容量,在第一次添加数据的时候需要初始化数组,数组大小为(2)求得的容量
newCap = oldThr;
else { // 无参初始化变为默认参数
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
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;//断开当前桶指向,(后边会说明,这就到治多线程put缺少键值对)
if (e.next == null)//只有一个元素,直接拷贝
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);//树节点复制
else { // preserve order
// loHead原来数组头loTail原来数组尾
//hiHead扩容后增长的数组头hiTai扩容后增长的数组尾
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
//...拆分为新旧链表
} while ((e = next) != null);
//位置未变的链表插入
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
//位置改变的链表插入
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
四、线性安全
- JAVA8并发** HashMap中的键值对丢失,(resize造成)**
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
...
}
由于在多个线程扩容的时候,线程一扩容,首先设置旧数组为null,之后就跑去开心的扩容了,此时线程二进入,线程 2 判定当前桶位置上没有键值对,如果线程 2 返回的哈希表数组覆盖了线程 1 的哈希表数组,就会丢失一部分设置为null的键值对
- Java7并发死锁成因(选择头插法,相互引用,resize扩容造成)
以下线程安全内容 转载自博客内容:https://coolshell.cn/articles/9606.html
do {
Entry<K,V> next = e.next; // <--假设线程一执行到这里就被调度挂起了
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
} while (e != null);
1、单线程正常插入到数组中
2、并发
(1)线程一在设置好e和next之后阻塞,之后线程二进行扩容完成
(2)线程一在唤醒之后
- newTalbe[i] = e;使得新数组索引3指向KEY:3的节点
- e = next 是得原来的next修改为e
- 循环条件next = e.next
(3)线程一由于e不为空,进入下一循环,正常运行
(4)e.next = newTable[i] 导致 key(3).next 指向了 key(7)
(5)导致环路死锁,无法继续添加元素
五、遍历HashMap键值对
- map.entrySet()效率高
相当于获取完整的节点,包含了key与value,通过Map.Entry.getKey()与Map.Entry.getValue()获得
// 假设map是HashMap对象
// map中的key是String类型,value是Integer类型
Integer integ = null;
Iterator iter = map.entrySet().iterator();
while(iter.hasNext()) {
Map.Entry entry = (Map.Entry)iter.next();
// 获取key
key = (String)entry.getKey();
// 获取value
integ = (Integer)entry.getValue();
}
- keySet()获取键所形成的集合,遍历获得key值,获取对应值的时候通过map.get(key)获得
- 效率低
// 假设map是HashMap对象
// map中的key是String类型,value是Integer类型
String key = null;
Integer integ = null;
Iterator iter = map.keySet().iterator();
while (iter.hasNext()) {
// 获取key
key = (String)iter.next();
// 根据key,获取value
integ = (Integer)map.get(key);
}
- values获取所有value值
// 假设map是HashMap对象
// map中的key是String类型,value是Integer类型
Integer value = null;
Collection c = map.values();
Iterator iter= c.iterator();
while (iter.hasNext()) {
value = (Integer)iter.next();
}
- for each语法糖遍历
Map<String, String> map = new HashMap<String, String>();
for (Entry<String, String> entry : map.entrySet()) {
entry.getKey();
entry.getValue();
}
六、引用博客
【1】PUT方法流程图:https://blog.csdn.net/login_sonata/article/details/76598675
【2】HashMap底层结构图与源码讲解:http://www.importnew.com/31096.html
【3】hashmap为什么容量是2的n次方:http://www.cnblogs.com/wengshuhang/articles/9867176.html
【4】数组扩容:https://blog.csdn.net/u013494765/article/details/77837338
【5】并发安全JAVA7死锁:https://coolshell.cn/articles/9606.html
【6】并发安全JAVA8丢失键值对: https://blog.csdn.net/codejas/article/details/85056606
【7】HashMap遍历:https://www.cnblogs.com/skywang12345/p/3310835.html
七、结语
花费了一天的时间,自己将一个概念从片面到了解HashMap内部结构,包括添加元素以及扩容,从原来只是一知半解的去使用函数,到现在起码能聊一聊HashMap,也见识到大神们机灵抖的我瑟瑟发抖,最近有点喜欢上阅读源码的感觉,希望自己越来越好!