前言 作为java开发人员,HashMap可谓是业务中的一把利器,9龙再次捡起这老生常谈的知识点,深入源码,细细品味。 首先,我们抛出几个关于HashMap的问题,带着问题去学习,就像捉迷藏一样有意思。 1、为什么要使用HashMap?HashMap有什么特性? 2、HashMap的主要参数有哪些?都有什么作用? 3、HashMap是基于什么数据结构实现的? 4、构造HashMap时传入的初始容量是如何处理的?为什么要这样做? 5、HashMap在什么时候扩容?扩容的时候都做了什么事?hash碰撞8次一定会转换为红黑树吗? 6、在foreach时对hashMap进行增删操作会发生什么? 1、为什么要使用HashMap?我们在使用一种工具的时候,肯定是因为其的某种特性很符合我们的需求,能够快速准确的解决我们的问题。那我们为什么要使用HashMap呢? This implementation provides constant-time performance for the basic operations (get and put), assuming the hash function disperses the elements properly among the buckets. 源码注释里有这样一句话,这就是我们使用HashMap的原因。 意为:HashMap为基本操作(get和put)提供了常数时间性能(即O(1)),假设散列函数将元素适当地分散到各个bucket中。 我们可以这样理解,如果当你需要快速存储并查询值,可以使用HashMap,它可以保证在O(1)的时间复杂度完成。前提是你键的hashCode要足够不同。 Map还有一个特性就是key不允许重复。下面我们就来看看HashMap如何保证O(1)进行get和put。 2、细嚼HashMap主要参数2.1、静态常量 //默认的初始化桶容量,必须是2的幂次方(后面会说为什么) 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; //判断是否可以执行将链表转化为树,如果当前桶的容量小于此值,则进行resize()。避免表容量过小,较容易产生hash碰撞。 static final int MIN_TREEIFY_CAPACITY = 64;2.2、字段 //hash表 transient Node<K,V>[] table; //缓存的EntrySet,便与迭代使用 transient Set<Map.Entry<K,V>> entrySet; //记录HashMap中键值对的数量 transient int size; //当对hashMap进行一次结构上的变更,会进行加1。结构变更指的是对Hash表的增删操作。 transient int modCount; //判断是否扩容的阈值。threshold = capacity * load factor int threshold; //负载因子,用于计算threshold,可以在构造函数时指定。 final float loadFactor;3、嗅探HashMap数据结构上面我们看到一个Node<K,V>[] table的Node数组。 为什么要使用数组呢? 答:为了能快速访问元素。哦,说的什么鬼,那我得追问,为什么数组能快速访问元素了? - 数组只需对 [首地址+元素大小*k] 就能找到第k个元素的地址,对其取地址就能获得该元素。
- CPU缓存会把一片连续的内存空间读入,因为数组结构是连续的内存地址,所以数组全部或者部分元素被连续存在CPU缓存里面。
让我们看看Node的结构。 static class Node<K,V> implements Map.Entry<K,V> { final int hash; //key 的hash final K key; //key对象 V value; //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; } }我们看到,Node节点内部保留了一个next节点的引用,太熟悉了,这不就是链表嘛。 到这,我们知道了HashMap的底层数据结构是基于数组+链表。但是,这就完了吗?在jdk1.7确实只是这样,jdk1.8为了提高hash碰撞时链表查询效率低的问题,在hash碰撞达到8次之后会将链表转化为红黑树,以至于将链表查询的时间复杂度从O(N)提高到O(logN)。 到这我们就可以明白,HashMap如果能够均匀的将Node节点放置到table数组中,我们只要能够通过某种方式知道指定key的Node所在数组中的索引,基于数组,我们就可以很快查找到所需的值。 接着我们就要看看如何定位到table数组中。 4、走进HashMap构造函数有了上面的基础知识,知道字段含义及数据结构,我们就有一点信心可以正式进入源码阅读。我觉得了解一个类,得从构造函数入手,知道构造对象的时候做了哪些初始化工作,其次再深入常用的方法,抽丝剥茧。 public HashMap(int initialCapacity) { //如果只传入初始值,则负载因子使用默认的0.75 this(initialCapacity, DEFAULT_LOAD_FACTOR); } public HashMap(int initialCapacity, float loadFactor) { if (initialCapacity < 0) throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity); //保证初始容量最大为2^30 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); }我们可以看到,构造函数主要是为了初始化负载因子及hash表的容量。可能大家会疑问,这不是初始化的是threshold吗?不要被表面所欺骗,这只是临时将hash表的容量存储在threshold上,我想是因为HashMap不想增加多余的字段来保存hash表的容量,因为数组的length就可以表示,只是暂时数组还未初始化,所以容量暂先保存在threshold。 我们看到将用户指定的initialCapacity传入tableSizeFor方法返回了一个值,返回的值才是真正初始化的容量。???搞毛子这是?然我们揭开它神秘的面纱。 /** * Returns a power of two size for the given target capacity. */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; }好吧, 我们还是把它盖上吧,9龙也没去推算过。我们从jdk给的方法注释看出,该方法返回一个目标值的2的幂次方,进一步9龙翻译为:返回大于或等于目标值的第一个数,该数必须是2的幂次方。 举例说一下: 如果输入10,大于等于10的第一个数,又是2的幂次方的数是16; 如果输入7,大于等于7的第一个数,又是2的幂次方的数是8; 如果输入20;大于等于20的第一个数,又是2的幂次方的是32; 到这我们又得问自己,为什么hash表的容量必须是2的幂次方呢? 5、解剖HashMap主要方法5.1、put当我们new出HashMa的对象,都会调用put方法进行添加键值对。我跟那些直接贴代码的能一样吗?有啥不一样,哈哈哈。9龙会先读源码,再贴流程图,这样大家会更理解一点。 public V put(K key, V value) { return putVal(hash(key), key, value, false, true);}static final int hash(Object key) { int h; //将key的高16位与低16位异或,减小hash碰撞的机率 return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }让我们看看putVal干了什么。 /** * 此方法用于将(k,v)键值对存储到HashMap中 * * @param hash key的hash * @param key key对象 * @param value key对应的value对象 * @param onlyIfAbsent 如果是true,则不覆盖原值。 * @param evict if false, the table is in creation mode. * @return 返回旧值,如果没有,则返回null。 */ final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { Node<K,V>[] tab; Node<K,V> p; int n, i; //在第一次put的时候,此时Node表还未初始化,上面我们已经知道,构造HashMap对象时只是初始化了负载因子及初始容量,但并没有初始化hash表。在这里会进行第一次的初始化操作。 if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; //如果得到了一个hash值,并且hash值在很少相同的情况下,如何均匀的分布到table数组里呢?最容易想到的就是用hash%n,n为table数组的长度。但是%运算是很慢的,我们知道位运算才是最快的,计算机识别的都是二进制。所以如果保证n为2的幂次方,hash%n 与 hash&(n-1)的结果就是相同的。这就是为什么初始容量要是2的幂次方的原因。 //当找到的hash桶位没有值时,直接构建一个Node进行插入 if ((p = tab[i = (n - 1) & hash]) == null) tab = newNode(hash, key, value, null); else { //否则,表明hash碰撞产生。 Node<K,V> e; K k; //判断hash是否与桶槽的节点hash是否相同并且key的equals方法也为true,表明是重复的key,则记录下当前节点 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) { //如果节点的next索引是null,表明后面没有节点,则使用尾插法进行插入 if ((e = p.next) == null) { p.next = newNode(hash, key, value, null); //此时链表长度为9,即hash碰撞8次,会将链表转化为红黑树 if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st treeifyBin(tab, hash); break; } //如果key是同一个key,则跳出循环链表 if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break; p = e; } } //判断是否是重复的key if (e != null) { // existing mapping for key //拿到旧值 V oldValue = e.value; //因为put操作默认的onlyIfAbsent为false,所以,默认都是使用新值覆盖旧值 if (!onlyIfAbsent || oldValue == null) e.value = value; afterNodeAccess(e); //返回旧值 return oldValue; } } //到这里,表明有新数据插入到Hash表中,则将modCount进行自增 ++modCount; //判断当前键值对容量是否满足扩容条件,满足则进行扩容 if (++size > threshold) resize(); afterNodeInsertion(evict); return null; }总结一下: - put方法先通过计算key的hash值;
- 如果hash表没有初始化,则进行初始化;
- 然后计算该hash应该处于hash桶的哪个位置;
- 如果该位置没有值,则直接插入;
- 如果有值,判断是否为树节点,是的话插入到红黑树中;
- 否则则是链表,使用尾插法进行插入,插入后判断hash碰撞是否满足8次,如果满足,则将链表转化为红黑树;
- 插入后判断key是否相同,相同则使用新值覆盖旧值;
- 进行++modCount,表明插入了新键值对;再判断是否进行扩容。、
以上内容转载于网络
|