HashMap是一个用来保存<Key,Value>数据的类,凭借其极其优秀的效率,一直是编程中最常用的基础类。一直以来就是面试的热点,感觉不问问HashMap的原理, 都不好意思是Java面试了。不好好看下HashMap,怎么能说准备好了面试呢。
我们都知道HashMap是基于数组+链表的数据结构,可以高效的插入和查找数据,克服数组插入数据需要移位,而链表查找数据需要遍历的缺点,结构如下图。
通过存储的Key的哈希值来计算<Key,Value >存储的槽位(数组上的位置),槽位上已经有其他<Key,Value >,则称为是 哈希冲突 。通过链表连接起来。那么问题来了,极端情况下,所有的key计算结果全都在一个槽位上,那会是什么情况?实际上就是弱化成一个链表,这时候查询的时间复杂度就为O(n)
为避免出现这种情况,链表长度超过一定长度(默认为8),就直接生成红黑树(一种近似平衡二叉树的结构)这样的话最差的情况下,查找的时间复杂度是O(logn)
通过哈希值运算存储的槽位时取余可以通过位运算来实现,效率高。
关于这个问题,源码里有段注释“As a general rule, the default load factor (.75) offers a good * tradeoff between time and space costs.”意思是说负载因子0.75能在时间和空间上去得很好平衡。负载因子太高了,容易造成冲突。太小了,空间利用率又不高,还得频繁扩容。那么为什么不是0.6,0.8呢?我在网上看到一个解释觉得有一定道理,因为容量是2幂次方,容量*负载因子(扩容的临界值)刚好会是整数。
还是从源码中的注释找答案,“Because TreeNodes are about twice the size of regular nodes”,树节点占用的空间是常规节点的两倍,“Ideally, under random hashCodes, the frequency of nodes in bins follows a Poisson distribution with a parameter of about 0.5 on average for the default resizing threshold of 0.75。在随机hashCode下,节点在槽中的分布符合泊松分布,在负载因子为0.75下,泊松分布参数为0.5。槽中节点的个数及对应的概率如下
0: 0.60653066
1: 0.30326533
2: 0.07581633
3: 0.01263606
4: 0.00157952
5: 0.00015795
6: 0.00001316
7: 0.00000094
8: 0.00000006
可以看到,但链表长度为8个的时候概率是非常非常低的,所以取树化为8是为了降低树化的概率,同时当链表太长时有可以通过转换为树,提高查找效率(空间换时间)。
4、HashMap的Key或Value是否可以为null
可以,HashMap的Key或Value都可以为null,key为null的<key,value>键值对存储在槽位为0的位置。
直接打开代码,来看看hashmap是怎么实现的。 :warning:注意:本文的分析基于JDK1.8
//默认初始化的容量16 二进制"10000",即数组的默认长度,或者叫做“槽”的长度 static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; /** "泊松分布",负载因子,存储元素超过该比例即扩容,默认0.75 即存储的元素个数大于槽容量的0.75倍就扩容槽位 */ static final float DEFAULT_LOAD_FACTOR = 0.75f; //最大容量 2^30 static final int MAXIMUM_CAPACITY = 1 << 30; //链表长度大于该值时转成黑树 static final int TREEIFY_THRESHOLD = 8; //红黑树原生个数小于该值时转成链表 static final int UNTREEIFY_THRESHOLD = 6; //存储元素的数组,或者叫槽 transient Node<K,V>[] table; //目前存放元素的个数 transient int size; //修改的次数,可认为当前的版本 transient int modCount; /* 扩容的阀值=容量*因子,当存储的<key,value>数超过该变量,则扩容. 未初始化前,这个值会存储首次初始化时的容量,构造函数中赋值*/ int threshold; //因子 final float loadFactor;复制代码
以上各变量暂时不知道,可以先不理,先混个眼熟,一会代码里用到会做说明。
/** 构造函数,传入初始容量,及因子 */ public HashMap(int initialCapacity, float loadFactor) { //这一段很简单,验证传进来的参数 if (initialCapacity < 0) throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity); if (initialCapacity > MAXIMUM_CAPACITY) initialCapacity = MAXIMUM_CAPACITY; if (loadFactor <= 0 || Float.isNaN(loadFactor)) throw new IllegalArgumentException("Illegal load factor: " + loadFactor); //因子赋值 this.loadFactor = loadFactor; //这个有点意思了,初始化的容量并不一定是我们传进来的大小。 this.threshold = tableSizeFor(initialCapacity); } /******************************************************************* 这个方法的作用就是计算容量,这个方法返回 “大于传进来参数的最小‘2的幂次方’”, 为什么是2的幂次方的,你先不要管,只要知道hashmap的容量是2的幂次方即可... 是不是有点绕? 例子 参数14,返回:2^4=16 参数 16<cap<=32,则返回2^5=32 ********************************************************************/ static final int tableSizeFor(int cap) { /** 这么一大串位移看起来很头疼?作用就是让参数二进制最高为1的位后面的所有位数变为1 举个例子cap为 14则二进制为 cap为14 0000 0000 0000 0000 0000 0000 0000 1110 n为cap-1 0000 0000 0000 0000 0000 0000 0000 1101 */ int n = cap - 1; n |= n >>> 1; n |= n >>> 2; n |= n >>> 4; n |= n >>> 8; n |= n >>> 16; //上面位移结果 0000 0000 0000 0000 0000 0000 0000 1111 //这个结果+1是 0000 0000 0000 0000 0000 0000 0001 0000 就是2^4=16 return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1; } /******************************************************************************* 位移解析 为什么这里要-1?这里-1其实是作用在传进来的数已经是2^n 如16 0001 0000 减1 会是0000 1111,可以与传16以下统一,位移最后的结果都是 0000 1111 *********************************************************************************/ int n = cap - 1; n |= n >>> 1; n |= n >>> 2; n |= n >>> 4; n |= n >>> 8; n |= n >>> 16; 还有点懵?再给个例子,自己去意会吧 0010 0000 0000 0000 0000 0000 0000 0001 参数2^29+1 0010 0000 0000 0000 0000 0000 0000 0000 -1后结果 0011 0000 0000 0000 0000 0000 0000 0000 移1位再|原来数据 0011 1100 0000 0000 0000 0000 0000 0000 移2位再|原来数据 0011 1111 1100 0000 0000 0000 0000 0000 移4位再|原来数据 0011 1111 1111 1111 1100 0000 0000 0000 移8位再|原来数据 0011 1111 1111 1111 1111 1111 1111 1111 移16位后再|原来数据 0100 0000 0000 0000 0000 0000 0000 0000 +1后得到的结果2^30 最后的结果是否就是大于参数的最小幂次方?2^30 就是大于2^29+1的最小幂次方 复制代码
传初始容量构造函数:调用了上面的构造函数,因子为默认的0.75
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}复制代码
无参数构造函数,所有参数使用默认值
public HashMap() { this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted }复制代码
通过传进来一个Map来初始化HashMap
public HashMap(Map<? extends K, ? extends V> m) { //因子使用默认值 this.loadFactor = DEFAULT_LOAD_FACTOR; //将map复制到当前map中 putMapEntries(m, false); } final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) { int s = m.size(); if (s > 0) { //1、槽还未初始化,计算要初始化的槽大小 //2、槽已经初始化且容量不够,则通过resize()扩容 if (table == null) { float ft = ((float)s / loadFactor) + 1.0F; int t = ((ft < (float)MAXIMUM_CAPACITY) ? (int)ft : MAXIMUM_CAPACITY); if (t > threshold) threshold = tableSizeFor(t); } else if (s > threshold) resize(); //将源Map中的key、value存储到当前Map中 for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) { K key = e.getKey(); V value = e.getValue(); putVal(hash(key), key, value, false, evict); } } } /********************************扩容函数********************************************/ 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) { /* 当前容量已经是最大容量,直接返回 否则直接容量扩展为原来的2倍 */ if (oldCap >= MAXIMUM_CAPACITY) { threshold = Integer.MAX_VALUE; return oldTab; } else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) newThr = oldThr << 1; // double threshold } //未初始化过,且初始化容量已经计算过,则容量设为threshold else if (oldThr > 0) // initial capacity was placed in threshold newCap = oldThr; //其他情况,直接用默认容量 else { // zero initial threshold signifies using defaults 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; //定义新容量的槽,重新计算<key,value>的位置 @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; /** 如果槽位没有链表(红黑树),则直接计算槽位存储的<key,value>新位置, 否则是链表则遍历链表,重新计算链表上所有<key,value>的位置, 红黑树同样做遍历及计算。 这里可以看到计算位置只需要(key的哈希值)按位与(容量-1), 其实就是hash值对容量取余,这就是容量是2的幂次方的好处,计算取余非常方便。 */ 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 Node<K,V> loHead = null, loTail = null; Node<K,V> hiHead = null, hiTail = null; Node<K,V> next; do { next = e.next; /** 这里的位置计算有一个小技巧,只“按位与”容量, 为0则槽位不变,为1则新槽位=(旧槽位位置+旧容量)*/ 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; } 复制代码
阶段总结
HashMap总共有4个构造函数,分别可以传入容量、因子、Map。 1、默认情况下,初始化容量会是16,因子会是0.75 。 2、如果传入容量,则初始化的容量会是大于传入的容量的 最小“2的幂次方”。
添加一个<key,value> public V put(K key, V value) { return putVal(hash(key), key, value, false, true); } /** 这里有个点需要说明以下, 1、hashmap不是直接使用对象的哈希值, 而是会让高16为按位异或低16位,这是为了尽量让32位的hash值都能在计算槽位时起作用,减少冲突。 2、如果key是null,则hash值会取0,也就是key位null,则会存储在槽位0的位置。 */ static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); } 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; //通过hash计算存储位置,如果位置上没有节点,则直接将节点放入该位置,槽位个数n为2的幂次方 //的好处就在这里,这里哈希值对槽大小计算余数,即要存储的槽位置,通过按为与即可实现。 if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); else { Node<K,V> e; K k; //如果计算出的位置有节点,且节点的key等于要添加节点的key,先不做什么操作,只把已有节点保存到e 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); /** 链表结构,则遍历链表,直到链表尾部,或者链表中已经有相同的key。 如果遍历到链表尾部,则新增新节点,如果key已经在链表中,则停止遍历*/ else { for (int binCount = 0; ; ++binCount) { if ((e = p.next) == null) { 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已经存在于Map中,直接替换其value值 if (e != null) { // existing mapping for key V oldValue = e.value; if (!onlyIfAbsent || oldValue == null) e.value = value; //移动节点到链表尾部 afterNodeAccess(e); return oldValue; } } //Map版本号+1 ++modCount; //超过阀值,直接扩容 if (++size > threshold) resize(); //这个方法,HashMap没做操作,主要是给扩展子类来使用 afterNodeInsertion(evict); return null; }复制代码
/**通过key,获取value*/ public V get(Object key) { Node<K,V> e; return (e = getNode(hash(key), key)) == null ? null : e.value; } final Node<K,V> getNode(int hash, Object key) { Node<K,V>[] tab; Node<K,V> first, e; int n; K k; if ((tab = table) != null && (n = tab.length) > 0 && (first = tab[(n - 1) & hash]) != null) { //槽位上节点first,如果key等于first的key,直接返回first 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); //链表结构,链表结构,直接往后遍历链表,直到找到节点key等于传进来的key或者链表结束 do { if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) return e; } while ((e = e.next) != null); } } return null; }复制代码
final Node<K,V> removeNode(int hash, Object key, Object value, boolean matchValue, boolean movable) { Node<K,V>[] tab; Node<K,V> p; int n, index; //通过hashcode找到存储的槽位 if ((tab = table) != null && (n = tab.length) > 0 && (p = tab[index = (n - 1) & hash]) != null) { Node<K,V> node = null, e; K k; V v; //如果槽位上的节点key等于要删除的key,则将节点记录到node if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) node = p; //否则,槽上面的节点还有下一个节点 else if ((e = p.next) != null) { //如果是树结构 if (p instanceof TreeNode) node = ((TreeNode<K,V>)p).getTreeNode(hash, key); //如果是链表、遍历链表,直到找到跌点或者找到链表尾部 else { do { if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) { node = e; break; } p = e; } while ((e = e.next) != null); } } //判断是否找到节点 if (node != null && (!matchValue || (v = node.value) == value || (value != null && value.equals(v)))) { //如果是树,删除数中的节点 if (node instanceof TreeNode) ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable); /** node为要删除的节点,p为要删除节点的删一个节点, 如果要删的是槽位置,则p跟node都指向槽位上的节点*/ else if (node == p) tab[index] = node.next; else p.next = node.next; //版本更改 ++modCount; --size; afterNodeRemoval(node); return node; } } return null; }复制代码
阶段总结
至此,增删查都已经解析完毕,代码相对也比较简单,就是通过hash计算出槽位,再顺槽位上的链表或者树查找元素。注意点: 1、HashMap中key跟vlue是可以位null的,key为null会存储在槽位为0的位置。2、可以看到,增删都没有做任何数据同步或者锁,所以HashMap是非线程安全的。
通过HashMap对象,可以获取到 key的集合 还有 Value的集合 ,或者 节点的集合 ,通过集合Iterator迭代器可以遍历HashMap中所有的key、value或者节点。
//获取key的集合、value的集合,还有节点的集合方法如下 hashMap.keySet(); hashMap.values(); hashMap.entrySet(); //再来看看这些集合共用的迭代器 abstract class HashIterator { Node<K,V> next; // next entry to return Node<K,V> current; // current entry int expectedModCount; // for fast-fail int index; // current slot HashIterator() { expectedModCount = modCount; //.....省略不重要代码 } public final boolean hasNext() { return next != null; } final Node<K,V> nextNode() { Node<K,V>[] t; Node<K,V> e = next; /*************关键代码***************** 迭代器创建的时候会保存HashMap的版本,一旦发现当前的HashMap版本 与创建迭代器时候的HashMap版本不一致,则说明遍历期间, HashMap被其他线程修改了,直接抛出一个ConcurrentModificationException异常 **/ if (modCount != expectedModCount) throw new ConcurrentModificationException(); if (e == null) throw new NoSuchElementException(); if ((next = (current = e).next) == null && (t = table) != null) { do {} while (index < t.length && (next = t[index++]) == null); } return e; } //......省略不重要代码 }复制代码
阶段总结:HashMap中有个变量,用来记录HashMap的版本号。HashMap迭代器对于多线程修改的反应是“快速失败”的方法。迭代过程,一旦发现HashMap被修改了,迭代元素就直接抛出异常。