HashMap
最常用的Map结构,使用的是拉链法
数据结构 HashMap采用Entry数组来存储key-value,Entry有四个属性,Key,value,哈希值和下一个的指针
这里是1.8的版本,Node是Entry的一种实现
1 2 3 4 5 6 7 8 9 10 11 12 13 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; } }
表的数据结构就是一个个的Entry
1 transient Node<K,V>[] table;
属性 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 static final int DEFAULT_INITIAL_CAPACITY = 1 << 4 ; static final int MAXIMUM_CAPACITY = 1 << 30 ;static final float DEFAULT_LOAD_FACTOR = 0.75f ;static final int TREEIFY_THRESHOLD = 8 ;static final int UNTREEIFY_THRESHOLD = 6 ;static final int MIN_TREEIFY_CAPACITY = 64 ;
hashCode()和equals()方法 首先看Object的hashCode方法:
作用:返回对象的哈希码值,用于在哈希表等数据结构中快速定位对象,提高哈希表的性能。
默认实现:Object类中的hashCode()方法默认返回对象的内存地址的哈希码值。
这个默认的方法是根据地址计算出来的, 如果两个对象的hashCode()返回值相同,不一定表示这两个对象相等(一般情况下是可以说明两个对象相等),因为可能存在哈希冲突。
哈希值不能等价于地址,因为Java是在JVM中执行的,并不是真正的地址。
1 2 3 4 5 6 7 8 9 10 11 12 13 public class Hashcode { public static void main (String[] args) { A a = new A (); A a1 = new A (); A a2 = a1; System.out.println(a.hashCode()); System.out.println(a1.hashCode()); System.out.println(a2.hashCode()); } } class A {}
我们再看HashMap重写的hashCode
这里选择高十六位和第十六位进行异或运算,是为了尽量提取全部位的特征参与哈希计算,使得其更加分散
1 2 3 4 static final int hash (Object key) { int h; return (key == null ) ? 0 : (h = key.hashCode()) ^ (h >>> 16 ); }
int h;:定义一个整型变量 h,用于存储计算出的哈希值。
(key == null) ? 0 : (h = key.hashCode()):这是一个三元运算符,用于判断给定的键是否为 null。如果键为 null,则将哈希值 h 设为 0;否则,调用键的 hashCode() 方法获取其哈希码,并将结果赋给变量 h。
(h >>> 16):这是一个无符号右移运算,将变量 h 的二进制表示向右移动 16 位。这么做的目的是为了增加哈希值的随机性,使得哈希值的高位和低位都参与了哈希码的计算。
(h = key.hashCode()) ^ (h >>> 16):这是一个按位异或运算,将哈希值 h 和右移后的哈希值进行异或运算。按位异或运算是一种常用的混合哈希函数,用于将高位的信息与低位的信息混合在一起,增加哈希值的随机性。
return:将计算出的哈希值返回。
在put操作里面是这样判断hashcode和equals的
1 p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))
首先判断两个的哈希是否相等,如果哈希不相等那么肯定不存在冲突
如果哈希相等,可能是哈希碰撞,也可能是真的就是一样的元素,需要对具体的k进行判断
p.key == key判断的是引用是否相等 ,也就是指针,有的时候两个类的指针指向的是同一块空间,那么这个时候就肯定是相等的
key.equals(k)判断的是内容是否相等 ,有的时候两个指针指得确实是不一样的,但是他们包含的内容是一样的,那么肯定也不能放进哈希表,new Student(“yyf”)和new Student(“xxy”)指针肯定是不同的,因为指向不同的内存空间,但是内容不一样。这个检测就需要equal来检测了
看源码这一段判断,书里的长篇大论都可以不看了:
hashcode一致,内容不一样的两个肯定是能进哈希表的,这就是哈希碰撞
hashcode一致,equals返回true那肯定是不能进的,这里逻辑是或,就是内容或者引用满足一个相等就不能放了
重写了hashcode,但是equals返回true是可以放进去的,因为逻辑是与
put操作 1 2 3 public V put (K key, V value) { return putVal(hash(key), key, value, false , true ); }
1 2 3 4 static final int hash (Object key) { int h; return (key == null ) ? 0 : (h = key.hashCode()) ^ (h >>> 16 ); }
这里主要有几点注意:
(n - 1) & hash是怎么来的,其实这就是一个取余的操作,因为n为2的整数倍,那么n-1肯定是末尾全为1前面全为0的,用位运算会比%快
p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))在前面已经讲过了
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 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 ) 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 ) { p.next = newNode(hash, key, value, null ); if (binCount >= TREEIFY_THRESHOLD - 1 ) treeifyBin(tab, hash); break ; } if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break ; p = e; } } if (e != null ) { V oldValue = e.value; if (!onlyIfAbsent || oldValue == null ) e.value = value; afterNodeAccess(e); return oldValue; } } ++modCount; if (++size > threshold) resize(); afterNodeInsertion(evict); return null ; }
put的逻辑是:
首先判断是不是没有进行初始化,如果是第一次加入,那么resize,这里的默认长度是16,阈值为16*0.75
如果初始化了,看tab[(n - 1) & hash]是否有数据,如果没有,就直接放这里;如果有那么就进行后面的逻辑
判断p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))看跟当前的这个是不是一样的,如果是一样的那就进行修改,如果不是,那就要进行链表或者红黑树的添加了
如果是红黑树,那么就走红黑树的添加逻辑
如果是链表,就遍历链表,每一个都进行判断p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))),如果是那就修改,如果遍历到最后了,那就添加到末尾
边走边判断是不是要超过8个了,如果遍历到的节点到第七个了,那就要树化了
最后遍历完了看是否超过扩容阈值了,如果要扩容还要扩
关于这里链表的尾插法,后续还有相关知识点
HashMap的扩容 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 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 && oldCap >= DEFAULT_INITIAL_CAPACITY) newThr = oldThr << 1 ; } else if (oldThr > 0 ) 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 ; 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 { 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 ); if (loTail != null ) { loTail.next = null ; newTab[j] = loHead; } if (hiTail != null ) { hiTail.next = null ; newTab[j + oldCap] = hiHead; } } } } } return newTab; }
resize()的逻辑是:
首先判断oldCap是否大于0,如果是大于0那么说明已经初始化了,如果是等于0但是有阈值,说明可能是通过remove光了,也可能是初始化的时候使用有参构造方法,最后是等于0但是也没有阈值,对应的是无参构造方法,这个时候还没有真正给hashmap空间
大于0,那么就是扩展hashmap长度为原来两倍(这里是左移一位,也就是2),那么阈值也跟着扩展,如果等于0但是有阈值,那么新的阈值也是原来的,如果是无参的初始化,那么就是默认的16,阈值16 0,75
完成了新数组长度和新阈值的计算,接着就是移动数组了,新建一个新长度的数组
在旧的数组里面遍历,如果不为空,说明要移动了,如果后续没有,那么直接进新数组,如果还有后续,先判断是否是红黑树的TreeNode节点,是的话就走红黑树的逻辑,如果不是那就说明是链表了
链表的添加有两种,首先新建两个链表,一个是需要原封不动放在新链表对应节点的,另一组是放在$newTab[j + oldCap] = hiHead$中
如何判断移动呢?进行$e.hash & oldCap$的判断,这个计算的结果是oldcap最高位的值,我们放在table数组里面的哈希碰撞都是通过取余操作,这个计算的是oldcap的最高位后面的位数,最高位被忽视了,比如101010 & 001111和111010 & 001111结果都是1010,放在10的位置,对于这个链表来说最高位10和11的效果是一样的,因此为了缩短链表,把这一部分进行分开是很有必要的,这样就可以有效缩短链表。
然后通过判断遍历这个链表,按照上面的条件分别进入两个待移动链表。
最后移动链表,完成扩容
引申:为什么HashMap的数组是2的次幂 回答这个问题可以看两个判断条件
(n - 1) & hash,n为数组长度
e.hash & oldCap,oldCap为数组长度 参考回答:
对于取余操作:计算索引的效率更高,如果是2的n次幂可以用位运算代替取模运算
对于计算新索引,e.hash & oldCap可以计算高位,判断是否转移到新表下。
Java1.7的HashMap死循环问题 jdk7的的数据结构是:数组+链表
在数组进行扩容的时候,因为链表是头插法,在进行数据迁移的过程中,有可能导致死循环
变量e指向的是需要迁移的对象
变量next指向的是下一个需要迁移的对象
Jdk1.7中的链表采用的头插法
在数据迁移的过程中并没有新的对象产生,只是改变了对象的引用
产生死循环的过程:
线程1和线程2的变量e和next都引用了这个两个节点
线程2扩容后,由于头插法,链表顺序颠倒,但是线程1的临时变量e和next还引用了这两个节点
第一次循环
由于线程2迁移的时候,已经把B的next执行了A
第二次循环
第三次循环
参考回答:
在jdk1.7的hashmap中在数组进行扩容的时候,因为链表是头插法,在进行数据迁移的过程中,有可能导致死循环
比如说,现在有两个线程
线程一:读取到当前的hashmap数据,数据中一个链表,在准备扩容时,线程二介入
线程二:也读取hashmap,直接进行扩容。因为是头插法,链表的顺序会进行颠倒过来。比如原来的顺序是AB,扩容后的顺序是BA,线程二执行结束。
线程一:继续执行的时候就会出现死循环的问题。
线程一先将A移入新的链表,再将B插入到链头,由于另外一个线程的原因,B的next指向了A,
所以B->A->B,形成循环。
当然,JDK 8 将扩容算法做了调整,不再将元素加入链表头(而是保持与扩容前一样的顺序),尾插法 ,就避免了jdk7中死循环的问题。
LinkedHashMap LinkedHashMap继承自HashMap,实现了Map接口,HashMap是不记录顺序的,有的时候我们需要记住插入的顺序,LinkedHashMap通过维护一个链表来记录顺序,可以想象成每插入一个到HashMap,链表也要插入一个保留顺序。
1 2 3 public class LinkedHashMap <K,V> extends HashMap <K,V> implements Map <K,V>
数据结构 在1.8维护了一个双向链表,定义头指针和尾指针,每一个Entry都会有前驱和后继
这是新的 Entry的数据结构,继承自HashMap的Node,多了前驱和后继
1 2 3 4 5 6 7 8 9 static class Entry <K,V> extends HashMap .Node<K,V> { Entry<K,V> before, after; Entry(int hash, K key, V value, Node<K,V> next) { super (hash, key, value, next); } }
LinkedHashMap定义了头尾指针,accessOrder是一个重要变量,设置为真的话,get也会使元素移动到链表末尾,可以用这个变量实现LRU
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 transient LinkedHashMap.Entry<K,V> head; transient LinkedHashMap.Entry<K,V> tail; final boolean accessOrder;
构造函数 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 public LinkedHashMap (int initialCapacity, float loadFactor) { super (initialCapacity, loadFactor); accessOrder = false ; } public LinkedHashMap (int initialCapacity) { super (initialCapacity); accessOrder = false ; } public LinkedHashMap () { super (); accessOrder = false ; } public LinkedHashMap (Map<? extends K, ? extends V> m) { super (); accessOrder = false ; putMapEntries(m, false ); } public LinkedHashMap (int initialCapacity, float loadFactor, boolean accessOrder) { super (initialCapacity, loadFactor); this .accessOrder = accessOrder; }
Java1.7和1.8LinkedHashMap的区别
1.7版本的是用的双向循环链表,相比于HashMap多了两个变量,一个是头节点,因为是双向循环链表,head既可以是头也可以是尾。还有个顺序的变量,true是顺序,false是逆序,对于链表来说就是头插法和尾插法的区别,头插法就是逆序,尾插法就是顺序
1.8用的是双向链表,接下来重点介绍
1.8维护链表的操作
对于数组的插入和扩容继承自HashMap,不在赘述。主要研究双向链表如何维护。
afterNodeRemoval 顾名思义,在节点移除之后要做的事情
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 void afterNodeRemoval (Node<K,V> e) { LinkedHashMap.Entry<K,V> p = (LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after; p.before = p.after = null ; if (b == null ) head = a; else b.after = a; if (a == null ) tail = b; else a.before = b; }
afterNodeAccess 根据accessOrder判断是否要在get之后进行调整
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 void afterNodeAccess (Node<K,V> e) { LinkedHashMap.Entry<K,V> last; if (accessOrder && (last = tail) != e) { LinkedHashMap.Entry<K,V> p = (LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after; p.after = null ; if (b == null ) head = a; else b.after = a; if (a != null ) a.before = b; else last = b; if (last == null ) head = p; else { p.before = last; last.after = p; } tail = p; ++modCount; } }
afterNodeInsertion 这个方法是在添加之后的操作,可以想象成LRU,当容量超过阈值的时候可以重写removeEldestEntry方法返回true,就会删除最老的一个元素
1 2 3 4 5 6 7 8 9 10 11 12 13 void afterNodeInsertion (boolean evict) { LinkedHashMap.Entry<K,V> first; if (evict && (first = head) != null && removeEldestEntry(first)) { K key = first.key; removeNode(hash(key), key, null , false , true ); } } protected boolean removeEldestEntry (Map.Entry<K,V> eldest) { return false ; }
get操作 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 public V get (Object key) { Node<K,V> e; if ((e = getNode(hash(key), key)) == null ) return null ; if (accessOrder) afterNodeAccess(e); return e.value; }
put操作