HashMap 1.8版本的原理介绍以及源码分析 1.7与1.8版本的区别及改进
HashMap1.8版本的原理介绍以及源码分析 1.7与1.8版本的区别及改进
- HashMap文字原理串讲简介
- 代码部分----成员
- 代码部分----静态内部类Node K,V
- 代码部分----构造函数
- 代码部分----tableSizeFor方法
- 代码部分----hash扰动函数
- Hash简介
- hash的特点
如果你希望弄清楚HashMap的底层代码原理,恭喜你找对了,请你耐心看下去哦!这里有最详细的备注解释!也会介绍1.7与1.8版本的区别及改进。
HashMap 的结构 : 数组 + 链表 + 红黑树;
ps:这个图仅作参考 (红黑树条件不满足,下面会讲解条件)
以下为本人自己按照顺序串讲简介,这里不涉及细节,细节会对照代码去讲。如果想直接看源码分析,以及细节部分请直接点击目录相应位置~
HashMap文字原理串讲简介
1、首先HashMap在初始化时,有四个构造方法可选。常用的构造方法:用户可以传入数组容量大小、数组容量大小和负载因子(后面讲解)。但大多数情况我们都会直接调用无参构造,使用默认数组长度为16,默认负载因子为0.75 ,所以初始数组长度就是16。若用户自己传入数组容量大小,则会经过一个检测函数使得传入容量变成一个大于等于传入容量的2的次方数!(后面具体讲解)
注意:HashMap在初始化时并不会申请连续的存储空间,而是在存第一个数据(put)时申请,这样做是为了避免内存空间的浪费。
2、当我们存入键值对时,key(键)值会经过一个扰动函数(哈希函数),目的是为了让key值更加散列化,在极大程度上避免重复;key值经过扰动后将得到32位固定长度的二进制值;
此时才申请数组空间,数组长度为16,下标范围0~15 。(默认无参构造情况下)
我们需要将得到的哈希值后四位与1111做“与”运算。因为数组长度为16 ,16转换为二进制是10000,(16-1)转换为二进制为1111。 我们可以发现无论哈希值的后四位是什么,它和1111做“与”运算后得到的值转换为十进制范围都在0~15中,这样我们就可以把结果作为下标,把键值对存到对应的下标处。
但是随着我们存入的键值对越来越多,会出现一种我们不想看到的情况!哈希碰撞!我们想想如果有两个不同的键(key),他们得到的哈希值不同,但哈希值的后四位却是相同的,那么我们做完上述操作后得到的数组下标就是相同的了!这种情况就称为哈希碰撞!
本来我们采用数组存储键值对就是因为它能进行下标定位,查找的时间复杂度是O(1)。但无奈,由于哈希碰撞,我们只能将下标相同的键值对用链表链起来。
但是,如果存储的数据量庞大,而数组只有16个空间以供存储;随着键值对的存入,哈希碰撞的次数会越来越多,链表会越来越长,此时查找的时间复杂度退化成了O(N),这不是我们想要看到的!
知识点:
节点(Node):是一个类,定义的成员有 hash(哈希值) 、key(键) 、value(值) 、next指针 (Node<K,V> next :就是定义了一个指向下一个节点的指针,这样链表才能链起来) 。
所以,HashMap的编写者规定如果数组中的总节点数超过64个,且一条链表中的节点数超过8个,这条链表就会升级为红黑树!因为红黑树的查找时间复杂度为O(logN),也是很快的,相当于二分查找法的速度!(这里的8个节点是经过严密计算的,因为符合泊松分布,每个链表上节点数超过8个的概率小于十万分之一)
知识点:
阈值( int threshold;):就是数组容量大小*负载因子。
3、当数组元素个数超过阈值时,数组就要扩容-----resize()方法。划重点!后面讲!
当数组元素个数超过阈值时,数组扩容成16的2倍 。为什么?因为2的倍数都满足减一再转换为二进制数,其值全为1!可以利用这点将数组下标平均分配,一个不落。
假设数组下标为2的节点有两个,它们的哈希值后五位分别是00010和10010,在第一次数组容量为16时,它们只有后四位参与“与”运算,得到的下标均为2 ,存在一个桶中(相同下标处)形成链表;若数组容量扩容为16*2 = 32时,00010和10010与11111做“与”运算后,转换成的十进制数为 2和18 ,我们发现,他们现在在不同的桶里了(不同下标处)!这就是扩容的价值所在,能将链表缩短,以空间换时间,提高了查找效率!
叙述结束!下面进入手撕代码环节!
首先本人理解,哈希桶就是以数组下标为基础的一个桶,用来存放节点数据。如上图,该数组中有16个哈希桶。
哈希值就是我们要存储的键值对,其中对键进行散列化处理使其变成一个二进制数,这个二进制数就是该键的哈希值。我们将得到的哈希值通过一个算法将它转化为数组下标,再将键值对对应的节点存入该下标处。如图,红色圈出的都是节点。(这个图仅仅作为参考,真正升级为红黑树,必须满足两个条件,下面讲)
而哈希表就是这个数组(散列表),是基于哈希函数建立的一种查找表。
代码部分----成员
//以下是HashMap类的静态不可改成员 // 默认数组容量大小16(1左移四位,即2的4次方 = 16),如果用户不传数组容量就使用此默认值。 static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; //数组最大容量 2的30次方 static final int MAXIMUM_CAPACITY = 1 << 30; //默认负载因子:其值为0.75,负载因子相当于一个阈值。打个比方,水桶的水超过75%后,就要换一个大桶装水。 static final float DEFAULT_LOAD_FACTOR = 0.75f; //1、树化阈值:8 当一个桶中储存大于等于8个节点后,(若同时满足1、2条件桶中链表会升级为红黑树,以此提高查找效率) static final int TREEIFY_THRESHOLD = 8; //非数化阈值:6 当红黑树中的节点个数小于等于6个,则红黑树退化为链表 static final int UNTREEIFY_THRESHOLD = 6; //2、最小数化容量:64 当数组中节点数大于等于64并且一个桶中的节点数大于等于8个,该桶中的链表升级为红黑树 static final int MIN_TREEIFY_CAPACITY = 64; /* ---------------- Fields -------------- */ //用transient关键字标记的成员变量不参与序列化过程 //哈希表,即一个数组,用于存放表示键值对数据的Node元素。 transient HashMap.Node<K,V>[] table; //HashMap将数据转换成set的另一种存储形式,这个变量主要用于迭代功能。 transient Set<Map.Entry<K,V>> entrySet; //当前哈希表中的元素个数 transient int size; //当前哈希表结构修改次数 增删+1,修改value值不+1 transient int modCount; //扩容阈值 其值 = 数组容量大小 * 负载因子 (第一次被赋值为数组容量大小!) int threshold; //负载因子 final float loadFactor;
代码部分----静态内部类Node K,V
Node<K,V>标题打不出来<>见谅~
Node <K,V>内部类共有四个成员,我们把他们四个构造成一个节点用来存储键值对,即一个节点是一个最小单位。
这个静态内部类比较简单,大家一看就懂了。
static class Node<K,V> implements Map.Entry<K,V> { final int hash;//哈希值 final K key;//键 V value;//值 HashMap.Node<K,V> next;//指向下一个节点的指针,因为下一个节点的类型也是Node<K,V>,所以这样定义 //Node函数:用来给成员赋值 Node(int hash, K key, V value, HashMap.Node<K,V> next) { this.hash = hash; this.key = key; this.value = value; this.next = next; } public final K getKey() { return key; } public final V getValue() { return value; } public final String toString() { return key + "=" + value; } public final int hashCode() { return Objects.hashCode(key) ^ Objects.hashCode(value); } public final V setValue(V newValue) { V oldValue = value; value = newValue; return oldValue; } 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; } }
代码部分----构造函数
/* ---------------- Public operations -------------- */ //其实单参构造是套娃的双参构造,所以我们重点讲解双参构造 //双参构造传递需要传入 数组初始容量大小 和 负载因子值 public HashMap(int initialCapacity, float loadFactor) { //首先,做一些基础判断,若数组初始容量大小 小于0,抛异常! if (initialCapacity < 0) throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity); //若数组初始容量大小 大于最大数组容量,就将数组初始容量设置为最大数组容量 if (initialCapacity > MAXIMUM_CAPACITY) initialCapacity = MAXIMUM_CAPACITY; //同样负载因子数也不能小于0,而且负载因子也不能是一个非数!其中任一条件满足则抛异常 //在float类中有一个方法isNaN,该方法用于判断该数是否为非法的float。比如:当将一个非数字转换为数字以及有非数字参与数值计算的时候就会产生NaN;还有,当除数为0或0.0,而被除数为0.0的时候,也会产生NaN。 if (loadFactor <= 0 || Float.isNaN(loadFactor)) throw new IllegalArgumentException("Illegal load factor: " + loadFactor); //然后就是赋值操作 this.loadFactor = loadFactor; //tableSizeFor方法的目的是为了的得到一个大于或等于initialCapacity的2的次方数!用这个数作为数组初始容量大小! //注意!此时,扩容阈值被赋值为散列表的大小,也就是数组容量大小! //我们会觉得奇怪,之前提到 扩容阈值 = 数组容量大小 * 负载因子,这个代码没错,这样写是为了简化代码,第二次就恢复正常赋值了!在put方法中调用resize()方法时,重新被赋值成容量*负载因子。 this.threshold = tableSizeFor(initialCapacity); //tableSizeFor方法在下面哦 } //单参构造就是一个套娃代码,调用了双参构造 public HashMap(int initialCapacity) { this(initialCapacity, DEFAULT_LOAD_FACTOR); } //无参构造,比较常用,只用给负载因子赋默认值 public HashMap() { this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted } //构造一个和指定Map有相同mappings的HashMap,负载因子为0.75 public HashMap(Map<? extends K, ? extends V> m) { this.loadFactor = DEFAULT_LOAD_FACTOR; putMapEntries(m, false); }
代码部分----tableSizeFor方法
这个算法目的就是为了得到一个比较靠近传入的cap 且大于等于cap 的2的次方数!
/** * Returns a power of two size for the given target capacity. * 这个算法目的就是为了得到一个比较靠近传入的cap 且大于等于cap 的2的次方数! * * 我们变量跟踪一下这个方法 ">>>" 表示无符号右移 * 假设传入 cap = 10 * n = 10 - 1 = 9; * 即 ob1001 | ob0100 => ob1101 * ob1101 | ob0011 => ob1111 * ob1111 | ob0000 => ob1111 * 下面右移8,16位再进行或运算 结果仍然是 ob1111 转化为十进制为 15 * return 15 + 1 = 16 (ob10000) 即2的4次方! * 最后return时巧用三目运算符 * 意思是若计算出的 n 值小于0,则返回值为 1,否则继续判断 :若 n 值大于最大数组容量,则返回最大数组容量,否则返回 n+1 * * 我们发现中间的这些操作都是为了将这个二进制值中为一的最高位右边全部置一,最后再加一,就会变成2的次方! * 位运算速度快!很高效! * * 这个算法之所以要做 int n = cap - 1;这步操作,原因是 如果不减一,得到的返回值可能会比传入的cap大一倍! * * 若不减一举例: * cap = 16 n = 16; * 0b10000 | 0b01000 =>0b11000 * 0b11000 | 0b00110 =>0b11110 * 0b11110 | 0b00001 =>0b11111 => 31 * return 31 + 1; 所以需要减一! * */ 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; }
代码部分----hash扰动函数
Hash简介
Hash,又叫“散列”或者直译为“哈希”,Hash函数就是把任意长度的输入,通过散列算法,变换成固定长度的输出,该输出就是散列值(hash值)。这种转换是一种压缩映射,也就是,散列值的空间通常远小于输入的空间,不同的输入可能会散列成相同的输出,所以不可能从散列值来唯一的确定输入值。
hash的特点
1、根据同一hash函数计算出的hash值如果不同,那么输入值肯定也不同。
2、根据同一hash函数计算出的hash值如果相同,输入值不一定相同。所以hash值不能够反推出原始数据。
3、输入数据微小的变化都会导致得到hash值的完全不相同,但是相同的输入数据得到的hash值一定相同。
4、hash算法的冲突概率一定要小,就是不同数据得到相同hash值的概率一定要小。
5、hash算法执行的效率要高效,因为它面对的输入数据可能是长文本等等,但也要快速计算出hash值。
//hash函数目的:让key的hash值高16位也参与运算,可以抵御一些设计不良的hashCode函数。 异或概念:相同为0,不同为1。 //以key(键)作为输入数据,目的是得到一个更加散列化的32位二进制串。 //之所以这样异或是为了让key的高16位的特征也参与进来。 //因为做indexFor的时候实际上只是利用了低16位,高16位是用不到的,所以集合仅在当前掩码位上变化的散列将会总是碰撞。 //因此我们异或高位,使得得到的hash值更加散列化,减少了哈希冲突的概率。 static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
代码部分----putVal方法
put方法过程叙述
在第一次往表里put数据时,散列表才初始化。如果我们调用无参构造,那么就会使用默认的初始容量DEFAULT_INITIAL_CAPACITY = 16 和负载因子DEFAULT_LOAD_FACTOR = 0.75f 。所以申请的数组下标是从0~15 。
如下图,假设我们要put的键值对分别是键:“迪士尼”、值:“在逃公主”。
1、首先要做的就是获取 键:“迪士尼” 的哈希值(hash)。hash函数上面讲过了。
2、然后将next指针赋值为null,因为第一次put键值对,没有形成链表。
3、将hash值,key,value,next封装成一个Node <K,V> 节点。
4、通过路由算法获得Node节点所要存储的下标位置。
路由算法详解 以及与取余运算优劣比较
我们都知道hash值的取值范围是(负2的31次方 ~ 2的31次方 - 1) 。
那么如何发、把hash值均匀的放到16个桶中去呢? 相信很多人都会想到取余吧,但是%的代价实在不小,有缺点的。
缺点一:负数取余还是负数,我们还需要把负数变成正数,麻烦。(也有可能取余后为0)
缺点二:速度较慢。
于是一种奇特的算法诞生了,很其奥妙!看下图:
我们发现数组长度减一后得到的二进制值后面几位全部为1 。
16的二进制为 10000 , (16 - 1)二进制值为 1111 ,前面的高位全部为0。那么它“与”上任何二进制值 所做的操作就是把前面的高位全部置0,最后低四位的数值不变。我们可以发现低四位的取值范围是从0000 ~ 1111 ,而这些值转换为十进制恰好就是我们申请数组的下标范围 0 ~ 15。
那有人要问了,如果我们传入的散列表初始容量是32呢?
如果传32 ,数组则会申请32个连续内存空间,下标为0 ~ 31 。
32的二进制为 100000 , (32 - 1)二进制值为 11111 ,B0000 0001 1111 & B 0011 1111 1010 => B11010 => 26
那么这个节点就会放到下标为26的桶里。
路由算法优势:
1、位运算速度极快;
2、得到的数值是正数且与数组下标完全匹配,保证每个桶都能被利用到,分布均匀。
/** * put方法相信大家都再熟悉不过了吧,它套娃了putval方法,所以咱们就直奔主题讲解putval方法吧,代码在下面。 */ public V put(K key, V value) { // 取关键字key的哈希值,上面讲到的hash函数 return putVal(hash(key), key, value, false, true); } /** * Implements Map.put and related methods. * * @param hash hash for key * @param key the key * @param value the value to put * @param onlyIfAbsent if true, don't change existing value * onlyIfAbsent这个参数的作用在于,如果我们传入的key已存在我们是否去替换,true:不替换,false:替换。一般我们都传false. * @param evict if false, the table is in creation mode. * @return previous value, or null if none */ final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { //tab:引用当前hashMap的散列表 //p:表示当前散列表的元素 //n: 表示散列表数组的长度 //i: 表示路由寻址结果 HashMap.Node<K,V>[] tab; HashMap.Node<K,V> p; int n, i; //这里将成员--散列表table赋值给tab,并且判断其值是否为null;将数组长度赋值给n并判断其值是否为0,其中任一项成立,则执行扩容方法! //扩容方法在后面单独列出来讲,这里就是做了申请数组空间,然后讲申请数组的长度赋值给n。 //注意,之前提到过HashMap是在这里初始化的,即第一次做put操作时才申请空间的!延迟初始化时为了减少内存空间的浪费!因为有人定义hashMap后不put! if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; //我们用路由算法得到数组下标并赋值给 i ,并将表中下标为 i 的节点首地址赋值给 p ; //若 p值为null,说明该下标处还没有存放过节点,所以直接new一个节点放进去就完事! if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); //else就说明p不等于null,可能是红黑树,可能是链表(一个节点或多个节点链在一起) else { //e:临时节点 k:临时变量 HashMap.Node<K,V> e; K k; //此时p一定有值,这步是判断传入的key是否与当前节点的key相同,相同p赋值给e //后半句是为了防止编程者重写方法设计的!意思与前半句相同 if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) e = p; //若不相同,则判断是不是树,是就执行putTreeVal方法,并将结果赋值给e else if (p instanceof HashMap.TreeNode) e = ((HashMap.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); //若节点数大于等于8个,升级为红黑树 if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st treeifyBin(tab, hash); break; } //寻找key值与传入key相同的节点,找到了直接break if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break; //保证查找一直相前进行 p = e; } } //e等于null只有一种情况,就是没找到相同key,将传入数据构造成节点插在链尾或者树中了 //若条件成立,此时的e恰好指向key值与传入key值相同的节点 //onlyIfAbsent一般传false,当key相同时,用新value将老value替换掉 if (e != null) { // existing mapping for key V oldValue = e.value; if (!onlyIfAbsent || oldValue == null) e.value = value; afterNodeAccess(e); return oldValue; } } //散列表结构修改次数,替换Node元素的value不计数 ++modCount; //插入新元素, size自增,如果自增后的值大于扩容阈值,则触发扩容 if (++size > threshold) resize(); afterNodeInsertion(evict); return null; }
代码部分----resize方法
扩容原理
如图所示,若散列表中的节点总数size大于扩容阈值时,我们就要进行扩容了。若原来的数组长度为16,那么我们就要扩容为16 * 2 = 32 。我们假设数组下标为15的空间存放了4个节点,每个节点的hash值低四位都为1111;当散列表扩容成32个空间时,由路由算法得:
32的二进制为 100000 , (32 - 1)二进制值为 11111 ,这四个节点的hash值低 5 位有可能是 01111 或者 11111 。各个节点的hash值 与 11111 做完&运算后,会得到两种结果 01111 和 11111 。结果为 01111 的桶位不变,还是在下标为15得桶里;结果为 11111 的节点就要改变桶位了 ,它会被放到下标为31的同里。
此时若数据分配均匀,每个桶中差不多可以减少一半节点。
有人要问为什么这样做呢,这么麻烦?
扩容方法其实就是一个以空间换时间的典型例子!
之所以采用数组存储节点,是因为它支持下标定位,时间复杂度为O(1),但是若每个桶里链化严重,这意味着,时间复杂度将由O(1) 退化成O(n)。所以一旦整个数组的总节点个数超过计算好的扩容阈值时,就要扩容,以提高查找效率。
/** * 为什么需要扩容 ? * 为了解决哈希冲突导致的链化影响查询效率的问题,扩容会缓解该问题。 */ final HashMap.Node<K,V>[] resize() { // oldTab: 引用扩容前的哈希表 HashMap.Node<K,V>[] oldTab = table; //oldCap: 表示扩容之前table数组的长度 int oldCap = (oldTab == null) ? 0 : oldTab.length; // oldThr:表示扩容之前的扩容阈值,触发本次扩容的阈值 int oldThr = threshold; // newCap :扩容后的新哈希表容量 // newThr :扩容后的新扩容阈值 int newCap, newThr = 0; //条件成立,说明是一次正常的扩容,不是散列表第一次初始化 if (oldCap > 0) { //若旧数组容量大于等于最大容量,就把扩容阈值设为int能表示的最大值 0x7fffffff(2^31 - 1),将旧数组返回 if (oldCap >= MAXIMUM_CAPACITY) { threshold = Integer.MAX_VALUE; return oldTab; } //旧容量左移一位,就是乘以2,赋值给新容量,并且判断新容量值是否小于最大容量,且旧容量 >= 16 //就让新阈值也增加一倍 else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) newThr = oldThr << 1; // double threshold } //oldCap == 0, 说明hashMap中的散列表是null,将要执行散列表初始化操作 //以下三种情况可能出现oldThr > 0 //1.new HashMap(initCap, loadFactor ) ; //2. new HashMap(initCap); //3. new HashMap(map); 并且这个map有数据 //注意!有些小伙伴就会觉得这里写错了,并没有!双参构造方法中有一条语句: this.threshold = tableSizeFor(initialCapacity); //这句话意思是检测用户传入的初始化数组容量,并返回一个且大于等于传入容量的2的次方数!返回的是正确的数组容量! 并赋值给了threshold,所以这里会执行newCap = oldThr;因为oldThr就是数组初始容量啊! else if (oldThr > 0) // initial capacity was placed in threshold newCap = oldThr; //若用户没传参数,即调用无参构造 new HashMap(); //oldCap == 0 oldThr == 0 else { // zero initial threshold signifies using defaults newCap = DEFAULT_INITIAL_CAPACITY; newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); } //这里重新计算并赋值了Threshold if (newThr == 0) { float ft = (float)newCap * loadFactor; //计算公式扩容阈值 = 数组容量 * 负载因子 //若newCap 小于 最大容量 且 临时阈值变量ft 小于 强转成float的最大容量,就把ft赋值给newThr,否则把(2 ^ 31 - 1)赋值给newThr newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ? (int)ft : Integer.MAX_VALUE); } //赋值给threshold!!! threshold = newThr; //上面代码干了两件事,计算出了 newCap 和 newThr 。 //创建一个更长更大的数组 @SuppressWarnings({"rawtypes","unchecked"}) //用新容量new了一个newTab HashMap.Node<K,V>[] newTab = (HashMap.Node<K,V>[])new HashMap.Node[newCap]; table = newTab; if (oldTab != null) { //注意条件,一个桶一个桶遍历! for (int j = 0; j < oldCap; ++j) { //e:当前节点 HashMap.Node<K,V> e; //将原数组下标为j的空间首地址赋值给e,如果下标j的桶非空,将原数组下标为j的空间赋值为null if ((e = oldTab[j]) != null) { oldTab[j] = null; //如果e后面没有链节点,执行路由寻址算法(前面讲过)得到新数组下标处,并将e插入新数组的对应下标处。 if (e.next == null) newTab[e.hash & (newCap - 1)] = e; //e.next != null 说明 要么是链表,要么是红黑树 //是红黑树,就执行split方法 else if (e instanceof HashMap.TreeNode) ((HashMap.TreeNode<K,V>)e).split(this, newTab, j, oldCap); //是链表,执行以下操作,别怕,我们一起走一遍! else { // preserve order //首先定义了低位的头尾指针和高位的头尾指针 //以原数组容量为16为例 //上面讲过了一个桶里的元素一定是低四位相同, //那如果扩容一倍后,路由寻址算法就要再多取一位,此时的第5位二进制值有两种情况0和1, //若为0则下标位置不变,就是低位;为1的话就相当于下标值加了16,会到高位的桶里去,所以是高位。 HashMap.Node<K,V> loHead = null, loTail = null; HashMap.Node<K,V> hiHead = null, hiTail = null; //临时指针变量 HashMap.Node<K,V> next; //do{} while() 就是先做do{}里面的操作,再判断while()里面的条件,满足就继续做do{},不满足就不做了 do { next = e.next; //00111 & 10000 => 00000 说明e在低位桶里 //操作比较简单,我就不一一解释了 if ((e.hash & oldCap) == 0) { if (loTail == null) loHead = e; else loTail.next = e; loTail = e; } //10111 & 10000 => 10000 说明e在高位桶里 else { if (hiTail == null) hiHead = e; else hiTail.next = e; hiTail = e; } } while ((e = next) != null); if (loTail != null) { loTail.next = null; //把链表头节点首地址存入下标为j的空间,就是把链表放进对应桶里 newTab[j] = loHead; } if (hiTail != null) { hiTail.next = null; //同上 newTab[j + oldCap] = hiHead; } } } } } //返回新散列表 return newTab; }
代码部分----get方法
//这个没啥说的,一个三目运算判断e值是否为null public V get(Object key) { HashMap.Node<K,V> e; return (e = getNode(hash(key), key)) == null ? null : e.value; } final HashMap.Node<K,V> getNode(int hash, Object key) { //tab: 引用当前hashMap的散列表 //first: 桶位中的头元素 //e: 临时node元素 //n: table数组长度 HashMap.Node<K,V>[] tab; HashMap.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值与传入的都相同,返回first节点 if (first.hash == hash && // always check first node ((k = first.key) == key || (key != null && key.equals(k)))) return first; //头节点hash值和key值与传入的不相同,且头节点后有节点 if ((e = first.next) != null) { //判断是不是红黑树 if (first instanceof HashMap.TreeNode) return ((HashMap.TreeNode<K,V>)first).getTreeNode(hash, key); //否则就只剩下链表一种情况了。一个一个遍历,找到了就返回e节点,可以变量跟踪,会发现e刚好就是要找的节点 do { if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) return e; } while ((e = e.next) != null); } } //若前面都没return ,说明没找到,return null return null; }
代码部分----remove方法
public V remove(Object key) { HashMap.Node<K,V> e; return (e = removeNode(hash(key), key, null, false, true)) == null ? null : e.value; } /** * Implements Map.remove and related methods. * * @param hash hash for key * @param key the key * @param value the value to match if matchValue, else ignored * @param matchValue if true only remove if value is equal * @param movable if false do not move other nodes while removing * @return the node, or null if none */ final HashMap.Node<K,V> removeNode(int hash, Object key, Object value, boolean matchValue, boolean movable) { //tab:引用当前hashMap中的散列表 //p: 当前node元素 //n: 表示散列表数组长度 // index: 表示寻址结果 HashMap.Node<K,V>[] tab; HashMap.Node<K,V> p; int n, index; if ((tab = table) != null && (n = tab.length) > 0 && (p = tab[index = (n - 1) & hash]) != null) { //说明路由的桶位是有数据的,需要进行查找操作,并且删除 //node: 查找到的结果 //e:当前Node的下一个元素 HashMap.Node<K,V> node = null, e; K k; V v; //第一种情况:当前桶位中的元素即为你要删除的元素 if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) node = p; //红黑树或者链表 套路都相同 else if ((e = p.next) != null) { //红黑树 if (p instanceof HashMap.TreeNode) node = ((HashMap.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); } } //最后半句条件:equals方法是为了防止编程者重写方法而设计的! //matchValue为false,即,只要key相同就删;为true,即,key和value都相同才删! //当matchValue为true时,才需要看后面的条件是否成立。短路运算效率高! if (node != null && (!matchValue || (v = node.value) == value || (value != null && value.equals(v)))) { //红黑树 if (node instanceof HashMap.TreeNode) ((HashMap.TreeNode<K,V>)node).removeTreeNode(this, tab, movable); else if (node == p) tab[index] = node.next; else p.next = node.next; //当前哈希表结构修改次数 增删+1,修改value值不+1 ++modCount; //当前哈希表中的元素个数-- --size; afterNodeRemoval(node); return node; } } return null; } @Override public boolean remove(Object key, Object value) { return removeNode(hash(key), key, value, true, true) != null; }
- 点赞
- 收藏
- 分享
- 文章举报
- 面试必备1:HashMap(JDK1.8)原理以及源码分析
- HashMap的实现原理,以及在JDK1.7和1.8的区别
- 【Java并发编程】23、ConcurrentHashMap原理分析(1.7和1.8版本对比)
- jdk源码剖析四:JDK1.7升级1.8 HashMap原理的变化
- jdk 1.7 1.8 HashMap 源码分析
- Java中HashMap底层实现原理(JDK1.8)源码分析
- Java中HashMap底层实现原理(JDK1.8)源码分析
- JDK1.8版本HashMap的源码分析
- Java面试绕不开的问题: Java中HashMap底层实现原理(JDK1.8)源码分析
- 【转】ConcurrentHashMap原理分析(1.7与1.8)
- ConcurrentHashMap原理分析(1.7与1.8)
- JDK1.7版本HashMap的源码分析
- ConcurrentHashMap原理分析(1.7与1.8)put 和 get
- 【图解JDK源码】HashMap的容量大小增长原理(JDK1.6/1.7/1.8)
- (转载)Java中HashMap底层实现原理(JDK1.8)源码分析
- SharePreferences源码分析(commit与apply的区别以及原理)
- HashMap在JDK1.7和1.8版本的区别
- Java中HashMap底层实现原理(JDK1.8)源码分析
- 面试中被问到HashMap的结构,1.7和1.8有哪些区别?这篇做深入分析!
- 牛客网Java刷题知识点之HashMap的实现原理、HashMap的存储结构、HashMap在JDK1.6、JDK1.7、JDK1.8之间的差异以及带来的性能影响