Java中的HashMap源码解析
作者:会飞的猪zhu
HashMap
静态常量
//序列化版本号,在序列化和反序列化的时候使用 private static final long serialVersionUID = 362498820763181265L; //集合初始容量为16, static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16 //最大容量 table数组存放元素的最大数量 static final int MAXIMUM_CAPACITY = 1 << 30; //负载因子默认为0.75 负载因子= 表中的元素个数/散列表的长度,达到0.75扩容 static final float DEFAULT_LOAD_FACTOR = 0.75f; //链表转成树的阈值,链表中的元素个数大于8,变为红黑树和MIN_TREEIFY_CAPACITY一起决定的 static final int TREEIFY_THRESHOLD = 8; //树转换成链表的阈值 树中的元素小于6,转换为链表 static final int UNTREEIFY_THRESHOLD = 6; //最小转成树的容量,当数组长度达到64转换为红黑树----和TREEIFY_THRESHOLD一起决定的 static final int MIN_TREEIFY_CAPACITY = 64;
成员变量
//存放元素的数组 transient Node<K,V>[] table; //存放Entry类型的元素集合 transient Set<Map.Entry<K,V>> entrySet; //哈希表中元素的个数 transient int size; //每次扩容和更改map结构的计数器 transient int modCount; //扩容的阈值,当实际大小为(16 * 0.75 = 12) 阈值时开始扩容 int threshold; //哈希表自己的负载因子 final float loadFactor;
构造函数
//指定容量大小的构造函数 public HashMap(int initialCapacity) { this(initialCapacity, DEFAULT_LOAD_FACTOR); } /* 指定“容量大小”和“加载因子”的构造函数 initialCapacity: 指定的容量 loadFactor:指定的加载因子 */ public HashMap(int initialCapacity, float loadFactor) { //判断初始化容量initialCapacity是否小于0 if (initialCapacity < 0) //如果小于0,则抛出非法的参数异常IllegalArgumentException throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity); //判断初始化容量initialCapacity是否大于集合的最大容量MAXIMUM_CAPACITY-》2的30次幂 if (initialCapacity > MAXIMUM_CAPACITY) //如果超过MAXIMUM_CAPACITY,会将MAXIMUM_CAPACITY赋值给initialCapacity initialCapacity = MAXIMUM_CAPACITY; //判断负载因子loadFactor是否小于等于0或者是否是一个非数值 if (loadFactor <= 0 || Float.isNaN(loadFactor)) //如果满足上述其中之一,则抛出非法的参数异常IllegalArgumentException throw new IllegalArgumentException("Illegal load factor: " + loadFactor); //将指定的加载因子赋值给HashMap成员变量的负载因子loadFactor this.loadFactor = loadFactor; /* tableSizeFor(initialCapacity) 判断指定的初始化容量是否是2的n次幂,如果不是那么会变为比指定初始化容量大的最小的2的n次幂。 但是注意,在tableSizeFor方法体内部将计算后的数据返回给调用这里了,并且直接赋值给threshold边界值了。有些人会觉得这里是一个bug,应该这样书写: this.threshold = tableSizeFor(initialCapacity) *this.loadFactor; 这样才符合threshold的意思(当HashMap的size到达threshold这个阈值时会扩容)。 但是,请注意,在jdk8以后的构造方法中,并没有对table这个成员变量进行初始化,table的初始化被推迟到了put方法中,在put方法中会对threshold重新计算 */ this.threshold = tableSizeFor(initialCapacity); } //无参构造 public HashMap() { this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted }
//返回比指定初始容量大的最小的2的n次方 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; }
put()方法流程
public V put(K key, V value) { return putVal(hash(key), key, value, false, true); } final V putVal(int hash, K key, V value, boolean onlyIfAbsent,boolean evict) { //transient Node<K,V>[] table; 表示存储Map集合中元素的数组。 Node<K,V>[] tab; Node<K,V> p; //n代表数组的长度 int n, i; // ① 将table赋值给tab,并判断是否为null,第一次赋值时肯定为null // ② 将tab.length赋值给n,并判断是否为0 if ((tab = table) == null || (n = tab.length) == 0) //③ 由于数组为tab==null,对数组进行初始化,并将初始化后的数组长度赋值给n //④ 执行n = (tab = resize()).length,数组tab每个空间都是null n = (tab = resize()).length; // ① i = (n - 1) & hash 表示计算数组的索引赋值给i,即确定元素存放在哪个桶中 // ② p = tab[i = (n - 1) & hash] 将计算出的数组索引对应的数据赋值给节点p // ③ 判断p是否为null,即当前桶没有哈希冲突,则直接把键值对插入空间位置 if ((p = tab[i = (n - 1) & hash]) == null) // ④ 根据键值对创建新的节点放入该位置的桶中 // p表示tab[i],即 newNode(hash, key, value, null)方法返回的Node对象。 tab[i] = newNode(hash, key, value, null); else { // ① 执行else说明p=tab[i]不等于null,表示这个位置已经有值了, Node<K,V> e; K k; // ② p.hash == hash:比较桶中元素的hash值和新添加元素key的hash值是否相等 // ③ (k = p.key) == key:比较两个key的地址值是否相等 // ④ (key != null && key.equals(k)):能够执行到这里说明两个key的地址值不相等, //那么先判断后添加的key是否等于null,如果不等于null再调用equals方法判断两个key的内容是否相等 if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) // 两个元素哈希值相等,并且key的值也相等,将旧的元素整体对象赋值给e,用e来记录 e = p; // hash值不相等或者key不相等;判断p是否为红黑树结点 else if (p instanceof TreeNode) // 放入树中 e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); // hash值不相等或者key不相等,说明是链表节点 else { // 如果hash(key)不相同,说明是计算的桶下标相同,key也不相同,说明直接插在链表最后 for (int binCount = 0; ; ++binCount) { // ① e = p.next 获取p的下一个元素赋值给e // ② 判断p.next是否等于null,等于null,说明p没有下一个元素 // ③ 到达了链表的尾部,还没有找到重复的key,说明HashMap没有包含该键,将该键值对插入链表中 if ((e = p.next) == null) { //④ 创建一个新的节点插入到尾部 p.next = newNode(hash, key, value, null); //① 节点添加完成之后判断此时节点个数是否大于临界值8,如果大于则将链表转换为红黑树 //② binCount从0开始计数,TREEIFY_THRESHOLD - 1=7 if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st //转换为红黑树 treeifyBin(tab, hash); break; } // ① e = p.next 不是null,不是最后一个元素。 // ②继续判断链表中结点的key值与插入的元素的key值是否相等 if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) // 相等,跳出循环 //要添加的元素和链表中的存在的元素的key相等了,则跳出for循环。 //不用再继续比较了,直接执行下面的if语句去替换去 if (e != null) break; //说明新添加的元素和当前节点不相等,继续查找下一个节点。 // 用于遍历桶中的链表,与前面的e = p.next组合,可以遍历链表 p = e; } } /* 表示在桶中找到key值、hash值与插入元素相等的结点 也就是说通过上面的操作找到了重复的键,所以这里就是把该键的值变为新的值,并返回旧值 这里完成了put方法的修改功能 */ if (e != null) { // 记录e的value V oldValue = e.value; // onlyIfAbsent为false或者旧值为null if (!onlyIfAbsent || oldValue == null) //用新值替换旧值:e.value 表示旧值 value表示新值 e.value = value; // 访问后回调 afterNodeAccess(e); // 返回旧值 return oldValue; } } //修改记录次数 ++modCount; // 判断实际大小是否大于threshold阈值,如果超过则扩容 if (++size > threshold) resize(); // 插入后回调 afterNodeInsertion(evict); return null; }
hash()方法实现
① 计算hash(key)
对于key的hashCode做hash操作,无符号右移16位后做异或运算。 还有伪随机数法和取余数法。这2种效率都比较低。而无符号右移16位和异或运算效率是最高的。
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
② 计算数组桶下标: hash(key) & (table.length-1)
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,boolean evict) { // n表示数组初始化的长度是16 // 数组的长度必须为2的n次方,这样能够让hash(key)更加均匀的分布在桶下标中,减少hash冲突 // 使用 & 运算是为了提高效率 if ((p = tab[i = (n - 1) & hash]) == null) }
具体流程:
resize方法的实现
当HashMap中的元素个数超过数组大小 *loadFactor时,就会进行数组扩容,loadFactor的默认值是0.75,这是一个折中的取值。也就是说,默认情况下,数组大小为16,那么当HashMap中的元素个数超过16×0.75=12(这个值就是阈值或者边界值threshold值)的时候,就把数组的大小扩展为2×16=32,即扩大一倍,然后重新计算每个元素在数组中的位置,而这是一个非常耗性能的操作,所以如果我们已经预知HashMap中元素的个数,那么预知元素的个数能够有效的提高HashMap的性能。
当HashMap中的其中一个链表的对象个数如果达到了8个,此时如果数组长度没有达到64,那么HashMap会先扩容解决,如果已经达到了64,那么这个链表会变成红黑树,节点类型由Node变成TreeNode类型。当然,如果映射关系被移除后,下次执行resize方法时判断树的节点个数低于6,也会再把树转换为链表。
进行扩容,会伴随着一次重新hash分配,并且会遍历hash表中所有的元素,是非常耗时的。在编写程序中,要尽量避免resize。
HashMap在进行扩容时,使用的rehash方式非常巧妙,因为每次扩容都是翻倍,与原来计算的 (n-1)&hash的结果相比,只是多了一个bit位,所以节点要么就在原来的位置,要么就被分配到"原位置+旧容量"这个位置。
怎么理解呢?例如我们从16扩展为32时,具体的变化如下所示:
元素在重新计算hash之后,因为n变为2倍,那么n-1的标记范围在高位多1bit(红色),因此新的index就会发生这样的变化:
说明:5是假设计算出来的原来的索引。这样就验证了上述所描述的:扩容之后所以节点要么就在原来的位置,要么就被分配到"原位置+旧容量"这个位置。如果新的索引高位为0那么存储在原来索引位置,如果高位是1那么存在原来索引+旧的数组长度位置。
因此,我们在扩充HashMap的时候,不需要重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就可以了,是0的话索引没变,是1的话索引变成“原索引+oldCap(原位置+旧容量)”。可以看看下图为16扩充为32的resize示意图:
正是因为这样巧妙的rehash方式,既省去了重新计算hash值的时间,而且同时,由于新增的1bit是0还是1可以认为是随机的,在resize的过程中保证了rehash之后每个桶上的节点数一定小于等于原来桶上的节点数,保证了rehash之后不会出现更严重的hash冲突,均匀的把之前的冲突的节点分散到新的桶中了。
final Node<K,V>[] resize() { //得到当前数组 Node<K,V>[] oldTab = table; //如果当前数组等于null长度返回0,否则返回当前数组的长度 int oldCap = (oldTab == null) ? 0 : oldTab.length; //旧阈值点 默认是12(16*0.75) int oldThr = threshold; int newCap, newThr = 0; //第一次的数组容量都是0肯定 // ① 数组容量>0 if (oldCap > 0) { // ① 超过最大值就不再扩充了,就只好随你碰撞去吧 if (oldCap >= MAXIMUM_CAPACITY) { threshold = Integer.MAX_VALUE; return oldTab; } // ② 没超过最大值,数组容量就扩充为原来的2倍,newCap = 16*2=32 else if((newCap = oldCap << 1)< MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) //阈值扩大一倍, 默认原来是12 乘以2之后变为24 newThr = oldThr << 1; } //② 旧阈值点大于0 直接赋值 else if (oldThr > 0) newCap = oldThr; //③ 直接使用默认值 else { newCap = DEFAULT_INITIAL_CAPACITY;//16 newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); } // ① 如果新的容量为0 if (newThr == 0) { float ft = (float)newCap * loadFactor; newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ? (int)ft : Integer.MAX_VALUE); } // ② 新的阈值threshold = 24 threshold = newThr; // ③ 创建新的哈希表,数组大小为newCap=32 Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; table = newTab; // ④ 判断旧数组不等于空,遍历旧的哈希表 ,重新计算桶里元素的新位置,把每个bucket都移动到新的buckets中 if (oldTab != null) { // 把每个bucket都移动到新的buckets中 for (int j = 0; j < oldCap; ++j) { Node<K,V> e; //取出桶中的元素赋值给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; //① e这个节点在resize之后不需要移动位置 if ((e.hash & oldCap) == 0) { if (loTail == null) loHead = e; else loTail.next = e; loTail = e; } //② 否则e这个节点要插入的位置为:原索引+oldCap else { if (hiTail == null) hiHead = e; else hiTail.next = e; hiTail = e; } } while ((e = next) != null); // 原索引放到bucket里 if (loTail != null) { loTail.next = null; newTab[j] = loHead; } // 原索引+oldCap放到bucket里 if (hiTail != null) { hiTail.next = null; newTab[j + oldCap] = hiHead; } } } } } return newTab; }
总结:
1.如果当前数组的容量大于0并且小于数组容量的最大值,那么就会将数组容量扩容为原来的2倍
2. 如果数组容量为0,但是阈值大于0,那么table会初始化为阈值大小
3. 如果数组容量为0,且阈值为0,那么table会初始化为默认值16
4. 创建一个新的数组,数组的容量为扩容后的数组大小,然后遍历原来数组中的元素:
5.如果数组桶下标处只有一个键值对,将当前位置的元素直接插入到新数组对应桶下标处
6.如果红黑树节点,拆分红黑树解决哈希冲突
7.如果是链表节点,遍历链表,并计算链表中每个节点应该插入到新数组中的位置
8.如果当前节点的hash值和原来数组的容量进行与运算等于0,那么当前元素插入新数组的该索引处
9.如果当前节点的hash值和原来数组的容量进行与运算等于1,那么当前元素插入到新数组的位置为:当前元素在旧数组中的索引+旧数组的长度
treeifyBin()方法实现
final void treeifyBin(Node<K,V>[] tab, int hash) { int n, index; Node<K,V> e; //① 如果数组为空,或者数组的长度小于64,就先扩容,而不是将节点转为红黑树 if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY) resize(); //② 执行到这里说明哈希表中的数组长度大于阈值64,开始进行树形化 // 将数组中的元素取出赋值给e,e是哈希表中指定位置桶里的链表节点,从第一个开始 else if ((e = tab[index = (n - 1) & hash]) != null) { ///hd:红黑树的头结点 tl :红黑树的尾结点 TreeNode<K,V> hd = null, tl = null; do { //新创建一个树的节点,内容和当前链表节点e一致 TreeNode<K,V> p = replacementTreeNode(e, null); //将新创键的p节点赋值给红黑树的头结点 if (tl == null) hd = p; else { p.prev = tl; tl.next = p; } tl = p; } while ((e = e.next) != null); //让桶中的第一个元素即数组中的元素指向新建的红黑树的节点,以后这个桶里的元素就是红黑树,而不是链表数据结构了 if ((tab[index] = hd) != null) hd.treeify(tab); } }
get()方法流程
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) { //判断数组中的第一个节点的hash、key的地址、key的内容是否和要查找的key相同,如果相同返回即可 if (first.hash == hash && ((k = first.key) == key || (key != null && key.equals(k)))) return first; //如果数组中节点还指向了下一个节点 if ((e = first.next) != null) { //如果是红黑树,调用红黑树的getTreeNode()方法 if (first instanceof TreeNode) return ((TreeNode<K,V>)first).getTreeNode(hash, 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; }
到此这篇关于Java中的HashMap源码解析的文章就介绍到这了,更多相关HashMap源码内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!