来,进来的小伙伴们,咱们认识一下。html
我是俗世游子,在外流浪多年的Java程序猿java
前面两篇咱们聊了聊关于集合容器中的List集合,其中包含两个子类:node
若是尚未看过的小伙伴,能够先返回去看看面试
我在LinkedList中留下了几个思考问题,不知道有没有想到的,咱们在评论区里探讨探讨啊api
这篇咱们聊一聊Map,咱们再回顾一下,前面介绍的时候,咱们说过,Map和List很大的一个区别在于:数组
在传统的系统中,咱们的数据大概10W,百W的存储就足够了,可是在一些特殊的应用或者大数据平台中,涉及到千万甚至更多的数据,安全
本人在上家公司开发的广告投放系统中,天天至少会产生2000W的数据数据结构
这时若是咱们想从其中查找到某一条数据就很是麻烦,涉及到性能等的问题,而经过K,V
形式存储,咱们就至关于对某一个值添加了索引,经过这个索引咱们就能很快定位到数据,提升系统的性能。oracle
关于K,V
形式的存储,咱们在工做中还会用到如:less
好,了解到这一点以后,咱们继续日后看。
前面咱们讲到,集合中全部的父类是Collection,可是Map是单独的一套接口,这里不能混在一块儿,下面咱们来看看Map的实现子类:
了解ArrayList中我也给出了一张思惟导图
咱们就一个一个来看
最重要的一点:面试出场率贼高了【9月份面试一个月,80%的公司都有问到(当时不懂啊-_-||)】
这里,在聊今天的主角:HashMap以前,咱们先来简单的认识一下什么是哈希表
HashMap很长,文采略烂,你们要有耐心哦
也叫散列表,是根据关键码值(Key value)而直接进行访问的一种数据结构。也就是说,它经过把关键码值映射到表中一个位置来访问记录,以加快查找的速度
来源:百度百科
PS:概念都不是人话,不用记他,直接看结构
哈希表分为多种类型,下面咱们来看到的是在HashMap底层实现的结构:
上面是一个数组,在内存中是一块连续的内存空间,Key值取到hashCode码
而后再对数组长度作取模操做,获得对应的下标位置,而后将Value值放到对应下标位置的地方;若是在对应下标位置的地方存在元素,那么就已链表的形式追加
这种方式在散列函数中称为:除法散列法
取值也是同样:经过关键key的哈希取到对应下标以后,若是对应位置只有一个数据,那么就直接取出,不然就在链表中进行比对而后再取出对应的数据
简单对哈希表认识一下,咱们继续聊HashMap
背景说明:JDK1.8
老规矩,咱们来先来看一看HashMap的一个类分布图
咱们都是这样来构造HashMap的:
Map<String, String> hashMap = new HashMap<>(); Map<String, String> hashMap = new HashMap<>(20);
这样一个无参的构造方式,咱们来看看具体作了什么?
/**MUST be a power of two.*/ // 默认初始化长度 static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 负载因子,负责断定数据存储到什么地步的时候进行扩容 static final float DEFAULT_LOAD_FACTOR = 0.75f; public HashMap() { this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted }
按照咱们以前的经验,存储数据须要在内存中开辟空间,可是在HashMap的构造方法中并无这么作,包括其余有参的构造方法:
public HashMap(int initialCapacity) { this(initialCapacity, DEFAULT_LOAD_FACTOR); } 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(Map<? extends K, ? extends V> m) { this.loadFactor = DEFAULT_LOAD_FACTOR; putMapEntries(m, false); }
这里值得咱们借鉴:若是直接在定义构造方法的时候就在内存中开辟空间的话,若是不存储数据的话,那么这块内存不就被浪费了么
前面咱们都知道,在定义数组的时候须要指定数组的长度,而哈希表中有采用数组的结构,那么若是不定义指定长度的话,默认的一个长度就是属性值:DEFAULT_INITIAL_CAPACITY
,等于 16。
咱们重点还要关注它的注释:MUST be a power of two WHY?
在Hashtable中,是按照除法散列法中的规范来作的:也就是上面说的不太接近2的整数幂的素数,可是为何在HashMap中就没有采用这种规范,而是要采用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; }
经过计算,获得离传递参数最近的2的N次幂的数,因此说,哪怕传递的参数不知足规定,在代码中也会帮咱们进行调整
一样在HashMap中还存在一个属性loadFactor
:表示负载因子,简单来讲就是该属性决定了HashMap容器空间何时该扩容,默认是0.75
好比:初始长度为16,负载因子是0.75,那么当容器中存储了> (16 * 0.75 = 12)的时候,就会进行扩容操做
下面咱们再来看两个属性
static final int TREEIFY_THRESHOLD = 8; static final int UNTREEIFY_THRESHOLD = 6;
这两个属性值是在JDK1.8以后加进来的,简单来讲就是:当链表长度>8的时候,链表会转成红黑树的结构存储数据,当红黑树的节点<6个的时候,会转成链表的形式
也就是下面的结构:
关于为何是8转红黑树?
在属性值
DEFAULT_INITIAL_CAPACITY
上面有一段注释,给出了分析:/* * the frequency of * nodes in bins follows a Poisson distribution * (http://en.wikipedia.org/wiki/Poisson_distribution) with a * parameter of about 0.5 on average for the default resizing * threshold of 0.75, although with a large variance because of * resizing granularity. Ignoring variance, the expected * occurrences of list size k are (exp(-0.5) * pow(0.5, k) / * factorial(k)). The first values are: * * 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 * more: less than 1 in ten million * */也就是说,经过计算,在k=8的时候,接近于0,因此定义为8,提高检索的效率,这里涉及到一个叫作《泊松分布》:这是一种统计与几率学里常见到的离散几率分布
介绍的话推荐你们看这一篇:如何通俗理解泊松分布
了解完基本的属性值以后,咱们来看具体的操做方法,仍是同样的,在此以前咱们来看两个类:
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; } // ... }
这个类你们确定不陌生,前面在聊LinkedList的时候就已经见过了,不过这个是单向链表的方式
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); } //... }
这是关于红黑树的具体类
下面咱们继续,
咱们是这样调用的:
hashMap.put("key1", "value1"); public V put(K key, V value) { return putVal(hash(key), key, value, false, true); }
前面也说到,肯定关键值Key在数组中的位置,那么咱们先来看看是如何进行hash运算的:
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
为了可以让计算出来索引位置更分散,因此先(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; // 注释1 if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); // 注释2 else { Node<K,V> e; K k; if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) // 注释4 e = p; else if (p instanceof TreeNode) e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);// 注释5.2 else { for (int binCount = 0; ; ++binCount) { // 链表的插入过程 if ((e = p.next) == null) { p.next = newNode(hash, key, value, null); if (binCount >= TREEIFY_THRESHOLD - 1) // 注释5 treeifyBin(tab, hash); break; } if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break; p = e; } } if (e != null) { // 注释4.2 V oldValue = e.value; if (!onlyIfAbsent || oldValue == null) e.value = value; afterNodeAccess(e); return oldValue; } } ++modCount; if (++size > threshold) // 注释3 resize(); afterNodeInsertion(evict); return null; }
前面说到,在构造方法中什么都没作,只有在实际添加元素的时候才会开辟空间。在上面注释1的地方就是开辟空间的过程,同时在注释3的地方,该方法也是咱们扩容的过程,调用的都是同一个方法resize()
:
一步步来,先剖析
putVal()
方法,而后咱们在来看resize()
在第一次put()
,只是建立了一个Node数组
,没有其余操做, 也就是在内存中开辟空间,而后咱们继续往下看
第一次put()
元素,那么确定会进入到注释2的位置,经过 &
来计算当前元素所处的下标位置并赋值
同时,若是以前有看过JDK1.7
的源码的话,会发如今1.7中有这样一个方式:
static int indexFor(int h, int length) { return h & (length - 1); }
在JDK1.8
中:
i = (n - 1) & hash
举个例子:
(n-1)以后的二进制: 01111
hash = 18,转成二进制: 10010
&运算以后: 00010
经过计算,i=2
在设置成2的N次幂以后,在计算下标位置的时候能够保证(n-1)的后几位必定是1,方便进行 &
运算,而&
的效率要高于%
运算。
多用用位运算符,那不是摆设。O(∩_∩)O
继续调用put()
添加元素,注释4和注释4.2是相辅相成的,走到这里会判断key是否存在,若是存在key,那么e仍是会获得从HashMap中数组索引位置上获得的key,在注释4.2的地方将value进行赋值操做,也就是覆盖原先的值并返回旧值。
也就是说:在HashMap,不存在重复元素,若是在同一个key上存储了多个元素,那么只会存储最新的元素
hashMap.put("key1", "value1"); hashMap.put("key1", "value2"); System.out.println(hashMap); // {key1=value2}
前面咱们也说过,HashMap在JDK1.8的版本中:当链表长度>8的时候会将存储结构转成红黑树来存储,那么在注释5的地方咱们就获得了验证,一样,咱们来看一下转换过程:
final void treeifyBin(Node<K,V>[] tab, int hash) { int n, index; Node<K,V> e; if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY) resize(); else if ((e = tab[index = (n - 1) & hash]) != null) { TreeNode<K,V> hd = null, tl = null; do { TreeNode<K,V> p = replacementTreeNode(e, null); 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); } }
前面咱们已经看过TreeNode
这个类,这样也就对应到了咱们上面的底层结构图
同时咱们能够看到注释5.2的地方,若是存储已经转换成红黑树的形式,那么就对红黑树进行插入操做
红黑树后面聊
接下来咱们看resize()
,上面知道初始化的时候是建立数组的过程,那么咱们看有值的时候作了什么事情
final Node<K,V>[] resize() { Node<K,V>[] oldTab = table; int oldCap = (oldTab == null) ? 0 : oldTab.length; // oldCap = 16 int oldThr = threshold; // oldThr = 12 int newCap, newThr = 0; if (oldCap > 0) { if (oldCap >= MAXIMUM_CAPACITY) { threshold = Integer.MAX_VALUE; return oldTab; } else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && // newCap = 32 oldCap >= DEFAULT_INITIAL_CAPACITY) newThr = oldThr << 1; // double threshold // newThr = 24 } 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; @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; 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; 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; }
后面注释举了个小栗子。咱们也能够看到
在JDK1.8
中数据迁移是判断:当前hash & 旧容器长度的结果:
具体逻辑在这里:
// e.hash = 65 经过计算为0, if ((e.hash & oldCap) == 0) { if (loTail == null) loHead = e; else loTail.next = e; loTail = e; } // e.hash = 6366 经过计算不为0,走else else { if (hiTail == null) hiHead = e; else hiTail.next = e; hiTail = e; } // 原数组中的位置和新数组中位置相同 if (loTail != null) { loTail.next = null; newTab[j] = loHead; } // 新数组中的位置改变 if (hiTail != null) { hiTail.next = null; newTab[j + oldCap] = hiHead; }
HashMap关键点也就聊完了,下面咱们来总结一下
&
计算,性能对比%
更高更多关于HashMap使用方法推荐查看其文档: