hash表是应用最普遍的数据结构,是对键值对数据结构的一种重要实现。
它可以将关键字key映射到内存中的某一位置,查询和插入都能达到平均时间复杂度为O(1)的性能。
HashMap是java对hash表的实现,它是非线程安全的,也即不会考虑并发的场景。java
<!-- more -->node
hash表是常见的数据结构,大学都学过,之前也曾用C语言实现过一个:
https://github.com/frapples/c...git
偷点懒,这里就大概总结一下了,毕竟这篇博文jdk代码才是重点。github
在使用者的角度来看,HashMap可以存储给定的键值对,而且对于给定key的查询和插入都达到平均时间复杂度为O(1)。算法
实现hash表的关键在于:数组
hash算法存在hash冲突,也即多个不一样的K被映射到数组的同一个位置上。如何解决hash冲突?有三种方法。缓存
先来看Node节点。这代表HashMap采用的是分离链表的方法实现。
Node为链表节点,其中存储了键值对,key和value。安全
不过实际上,HashMap
的真正思路更复杂,会用到平衡树,这个后面再说。数据结构
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; } /* ... */ }
还能发现,这是一个单链表。对于HashMap来讲,单链表就已经足够了,双向链表反而多一个浪费内存的字段。并发
除此以外,还可以注意到节点额外保存了hash字段,为key的hash值。
仔细一想不难明白,HashMap可以存储任意对象,对象的hash值是由hashCode
方法获得,这个方法由所属对象本身定义,里面可能有费时的操做。
而hash值在Hash表内部实现会屡次用到,所以这里将它保存起来,是一种优化的手段。
这个TreeNode节点,其实是平衡树的节点。
看属性有一个red
,因此是红黑树的节点。
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> { TreeNode<K,V> parent; // red-black tree links TreeNode<K,V> left; TreeNode<K,V> right; TreeNode<K,V> prev; // needed to unlink next upon deletion boolean red; TreeNode(int hash, K key, V val, Node<K,V> next) { super(hash, key, val, next); } /* ... */ }
除此以外,还能发现这个节点有prev
属性,此外,它还在父类那里继承了一个next
属性。
这两个属性是干吗的?经过后面代码能够发现,这个TreeNode不只用来组织红黑树,还用来组织双向链表。。。
HashMap会在链表过长的时候,将其重构成红黑树,这个看后面的代码。
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16 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; transient Node<K,V>[] table; transient Set<Map.Entry<K,V>> entrySet; transient int size; transient int modCount; int threshold; final float loadFactor;
最重要的是table
、size
、loadFactor
这三个字段:
table
能够看出是个节点数组,也即hash表中用于映射key的数组。因为链表是递归数据结构,这里数组保存的是链表的头节点。size
,hash表中元素个数。loadFactor
,装填因子,控制HashMap
扩容的时机。至于entrySet
字段,其实是个缓存,给entrySet
方法用的。
而modCount
字段的意义和LinkedList
同样,前面已经分析过了。
最后,threshold
这个字段,含义是不肯定的,像女孩子的脸同样多变。。。
坦诚的说这样作很很差,可能java为了优化时省点内存吧,看后面的代码就知道了,这里总结下:
table
尚未被分配,threshold
为初始的空间大小。若是是0,则是默认大小,DEFAULT_INITIAL_CAPACITY
。table
已经分配了,这个值为扩容阈值,也就是table.length * loadFactor
。/** * Constructs an empty <tt>HashMap</tt> with the specified initial * capacity and load factor. * * @param initialCapacity the initial capacity * @param loadFactor the load factor * @throws IllegalArgumentException if the initial capacity is negative * or the load factor is nonpositive */ 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); } public HashMap(int initialCapacity) { this(initialCapacity, DEFAULT_LOAD_FACTOR); } public HashMap() { this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted } 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; }
第一个构造函数是重点,它接收两个参数initialCapacity
表明初始的table也即hash桶数组的大小,loadFactor
能够自定义扩容阈值。
this.threshold = tableSizeFor(initialCapacity);
这里也用到了相似前面ArrayList
的“延迟分配”的思路,一开始table是null,只有在第一次插入数据时才会真正分配空间。
这样,因为实际场景中会出现大量空表,并且极可能一直都不添加元素,这样“延迟分配”的优化技巧可以节约内存空间。
这里就体现出threshold
的含义了,hash桶数组的空间未分配时它保存的是table初始的大小。
tableSizeFor
函数是将给定的数对齐到2的幂。这个函数用位运算优化过,我没怎么研究具体的思路。。。
可是由此能够知道,hash桶数组的初始大小必定是2的幂,实际上,hash桶数组大小老是为2的幂。
先从get
函数看起。
public V get(Object key) { Node<K,V> e; return (e = getNode(hash(key), key)) == null ? null : e.value; }
咱们发现,调用getNode
时:
return (e = getNode(hash(key), key)) == null ? null : e.value;
其中调用了hash
这个静态函数:
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
也就是说,用于HashMap的hash值,还须要通过这个函数的二次计算。那这个二次计算的目的是什么呢?
经过阅读注释:
- Computes key.hashCode() and spreads (XORs) higher bits of hash
- to lower. Because the table uses power-of-two masking, sets of
- hashes that vary only in bits above the current mask will
- always collide. (Among known examples are sets of Float keys
- holding consecutive whole numbers in small tables.) So we
- apply a transform that spreads the impact of higher bits
- downward. There is a tradeoff between speed, utility, and
- quality of bit-spreading. Because many common sets of hashes
- are already reasonably distributed (so don't benefit from
- spreading), and because we use trees to handle large sets of
- collisions in bins, we just XOR some shifted bits in the
- cheapest possible way to reduce systematic lossage, as well as
- to incorporate impact of the highest bits that would otherwise
- never be used in index calculations because of table bounds.
嗯。。。大概意思是说,因为hash桶数组的大小是2的幂次方,对其取余只有低位会被使用。这个特色用二进制写法研究一下就发现了:如1110 1100 % 0010 0000 为 0000 1100,高位直接被忽略掉了。
也即高位的信息没有被利用上,会加大hash冲突的几率。因而,一种思路是把高位的信息混合到低位上去,提升区分度。就是上面这个hash
函数了。
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) { 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; }
get
函数调用了getNode
,它接受给定的key,定位出对应的节点。这里检查了table为null的状况。此外first = tab[(n - 1) & hash]
实际上就是first = tab[hash % n]
的优化,这个细节太多,等会再分析。
代码虽然有点多,可是大部分都是一些特别状况的检查。首先是根据key的hash值来计算这个key放在了hash桶数组的哪一个位置上。找到后,分三种状况处理:
三种状况三种不一样的处理方案。比较奇怪的是为何1不和2合并。。。
若是是红黑树的话,调用红黑树的查找函数来最终找到这个节点。
若是是链表的话,则遍历链表找到这个节点。值得关注的是对key的比较:
if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
相似于hashCode
方法,equals
方法也是所属对象自定义的,比较可能比较耗时。
因此这里先比较Node节点保存的hash值和引用,这样尽可能减小调用equals
比较的时机。
回到刚才的位运算:
first = tab[(n - 1) & hash]
这个位运算,其实是对取余运算的优化。因为hash桶数组的大小必定是2的幂次方,所以可以这样优化。
思路是这样的,bi是b二进制第i位的值:
b % 2i = (2NbN + 2N-1 bN-1+ ... + 2ibi + ... 20b0) % 2i
设x >= i,则必定有2xbx % 2i = 0
因此,上面的式子展开后就是:
b % 2i = 2i-1bi-1 + 2i-2bi-2 + ... 20b0
反映到二进制上来讲,以8位二进制举个例子:
这样,就不难理解上面的(n - 1) & hash
了。以上面那个例子,
00001000 - 1 = 00000111,这样减一以后,须要保留的对应位为全是1,须要置0的对应位全都是0。把它与B做与运算,就能获得结果。
没想到写这个比想象中的费时间。。。还有不少其余事情要作呢
这个put函数太长了,容我偷个懒直接贴代码和我本身的注释吧
public V put(K key, V value) { return putVal(hash(key), key, value, false, true); } // onlyIfAbsent含义是若是那个位置已经有值了,是否替换 // evict什么鬼?table处于创造模式?先无论 final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { Node<K,V>[] tab; Node<K,V> p; int n, i; // table为null或者没有值的时候reisze(),所以这个函数还负责初始分配 if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; // 定位hash桶。若是是空链表的话(即null),直接新节点插入: 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) // 若是hash桶挂的是二叉树,调用TreeNode的putTreeVal方法完成插入 e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); else { // 若是挂的是链表,插入实现 // 遍历链表,顺便binCount变量统计长度 for (int binCount = 0; ; ++binCount) { // 状况一:到尾巴了,就插入一条 if ((e = p.next) == null) { p.next = newNode(hash, key, value, null); // 插入会致使链表变长 // 能够发现,TREEIFY_THRESHOLD是个阈值,超过了就调用treeifyBin把链表换成二叉树 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; } } // 状况二的处理 if (e != null) { // existing mapping for key V oldValue = e.value; if (!onlyIfAbsent || oldValue == null) e.value = value; afterNodeAccess(e); return oldValue; } } ++modCount; // 若是hash桶数组的大小超过了阈值threshold,就resize(),可见resize负责扩容 if (++size > threshold) resize(); // evice的含义得看afterNodeInsertion函数才能知道 afterNodeInsertion(evict); return null; }
思路大概是这样的逻辑:
一样,根据hash值定位hash桶数组的位置。而后:
该位置为链表。遍历链表,进行插入。会出现两种状况:
在这里我有几点疑惑:
在遍历链表时会同时统计链表长度,而后链表若是被插入,会触发树化逻辑:
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st treeifyBin(tab, hash);
TREEIFY_THRESHOLD
的值是8,也就是说,插入后的链表长度若是超过了8,则会将这条链表重构为红黑树,以提升定位性能。
在插入后,若是hash表中元素个数超过阈值,则触发扩容逻辑:
if (++size > threshold) resize();
记得前面说过,threshold
在table已经分配的时候,表明是扩容阈值,即table.length * loadFactor
。
考虑到篇幅够长了,仍是拆分红两篇比较好,剩下的留到下一篇博文再写吧。