Java集合类的源码是深刻学习Java很是好的素材,源码里不少优雅的写法和思路,会让人叹为观止。HashMap的源码尤其经典,是很是值得去深刻研究的,jdk1.8中HashMap发生了比较大的变化,这方面的东西也是各个公司高频的考点。网上也有不少应对面试的标准答案,我以前也写过相似的面试技巧(面试必备:Hashtable、HashMap、ConcurrentHashMap的原理与区别),应付通常的面试应该是够了,但我的以为这仍是远远不够,毕竟咱们不能只苟且于获得offer,更应去勇敢的追求诗和远方(源码)。面试
jdk版本目前更新的相对频繁,好多小伙伴说jdk1.7才刚真正弄明白,1.8就出现了,1.8还用都没开始用,更高的jdk版本就又发布了。不少小伙伴大声疾呼:臣妾真的学不动啦!这也许就是技术的最大魅力吧,活到老学到老,没有人能说精通全部技术。无论jdk版本如何更新,目前jdk1.7和1.8仍是各个公司的主力版本。无论是否学得动,难道各位小伙伴忘记了《倚天屠龙记》里九阳真经里的口诀:他强由他强,清风拂山岗;他横由他横,明月照大江。他自狠来他自恶,我自一口真气足。(原谅我插入广告缅怀金庸大师,年少时期读的最多的书就是金庸大师的,遍及侠骨柔情大义啊)。这里的“真气”就是先掌握好jdk1.7和1.8,其它学不动的版本之后再说。sql
1、初窥HashMap HashMap是应用更普遍的哈希表实现,并且大部分状况下,都能在常数时间性能的状况下进行put和get操做。要掌握HashMap,主要从以下几点来把握:数组
jdk1.7中底层是由数组(也有叫作“位桶”的)+链表实现;jdk1.8中底层是由数组+链表/红黑树实现 能够存储null键和null值,线程不安全 初始size为16,扩容:newsize = oldsize*2,size必定为2的n次幂 扩容针对整个Map,每次扩容时,原来数组中的元素依次从新计算存放位置,并从新插入 插入元素后才判断该不应扩容,有可能无效扩容(插入后若是扩容,若是没有再次插入,就会产生无效扩容) 当Map中元素总数超过Entry数组的75%,触发扩容操做,为了减小链表长度,元素分配更均匀 为何说HashMap是线程不安全的?在接近临界点时,若此时两个或者多个线程进行put操做,都会进行resize(扩容)和reHash(为key从新计算所在位置),而reHash在并发的状况下可能会造成链表环。安全
2、jdk1.7中HashMap的实现 HashMap底层维护的是数组+链表,咱们能够经过一小段源码来看看:服务器
/**架构
/**并发
/**app
/**分布式
/**高并发
static class Entry<K,V> implements Map.Entry<K,V> { final K key; V value; Entry<K,V> next; int hash;
Entry(int h, K k, V v, Entry<K,V> n) { value = v; next = n; key = k; hash = h; }
public final K getKey() { return key; }
public final V getValue() { return value; }
public final V setValue(V newValue) { V oldValue = value; value = newValue; return oldValue; }
public final boolean equals(Object o) { if (!(o instanceof Map.Entry)) return false; Map.Entry e = (Map.Entry)o; Object k1 = getKey(); Object k2 = e.getKey(); if (k1 == k2 || (k1 != null && k1.equals(k2))) { Object v1 = getValue(); Object v2 = e.getValue(); if (v1 == v2 || (v1 != null && v1.equals(v2))) return true; } return false; }
public final int hashCode() { return Objects.hashCode(getKey()) ^ Objects.hashCode(getValue()); }
public final String toString() { return getKey() + "=" + getValue(); }
/**
/**
transient int hashSeed = 0; final int hash(Object k) { int h = hashSeed; if (0 != h && k instanceof String) { return sun.misc.Hashing.stringHash32((String) k); }
h ^= k.hashCode();
// This function ensures that hashCodes that differ only by // constant multiples at each bit position have a bounded // number of collisions (approximately 8 at default load factor). h ^= (h >>> 20) ^ (h >>> 12); return h ^ (h >>> 7) ^ (h >>> 4); }
/**
若是该位置没有对象存在,就将此对象直接放进数组当中;若是该位置已经有对象存在了,则顺着此存在的对象的链开始寻找(为了判断是不是否值相同,map不容许<key,value>键值对重复), 若是此链上有对象的话,再去使用 equals方法进行比较,若是对此链上的每一个对象的 equals 方法比较都为 false,则将该对象放到数组当中,而后将数组中该位置之前存在的那个对象连接到此对象的后面。
图中,左边部分即表明哈希表,也称为哈希数组(默认数组大小是16,每对key-value键值对实际上是存在map的内部类entry里的),数组的每一个元素都是一个单链表的头节点,跟着的蓝色链表是用来解决冲突的,若是不一样的key映射到了数组的同一位置处,就将其放入单链表中。
前面说过HashMap的key是容许为null的,当出现这种状况时,会放到table[0]中。
private V putForNullKey(V value) { for (Entry<K,V> e = table[0]; e != null; e = e.next) { if (e.key == null) { V oldValue = e.value; e.value = value; e.recordAccess(this); return oldValue; } } modCount++; addEntry(0, null, value, 0); return null; } 当size>=threshold( threshold等于“容量*负载因子”)时,会发生扩容。
id addEntry(int hash, K key, V value, int bucketIndex) { if ((size >= threshold) && (null != table[bucketIndex])) { resize(2 * table.length); hash = (null != key) ? hash(key) : 0; bucketIndex = indexFor(hash, table.length); }
createEntry(hash, key, value, bucketIndex); } jdk1.7中resize,只有当 size>=threshold而且 table中的那个槽中已经有Entry时,才会发生resize。即有可能虽然size>=threshold,可是必须等到每一个槽都至少有一个Entry时,才会扩容,能够经过上面的代码看到每次resize都会扩大一倍容量(2 * table.length)。
3、jdk1.8中HashMap的实现 在jdk1.8中HashMap的内部结构能够看做是数组(Node<K,V>[] table)和链表的复合结构,数组被分为一个个桶(bucket),经过哈希值决定了键值对在这个数组中的寻址(哈希值相同的键值对,则以链表形式存储。有一点须要注意,若是链表大小超过阈值(TREEIFY_THRESHOLD,8),图中的链表就会被改造为树形(红黑树)结构。
transient Node<K,V>[] table; Entry的名字变成了Node,缘由是和红黑树的实现TreeNode相关联。
在分析jdk1.7中HashMap的hash冲突时,不知你们是否有个疑问就是万一发生碰撞的节点很是多怎么版?若是说成百上千个节点在hash时发生碰撞,存储一个链表中,那么若是要查找其中一个节点,那就不可避免的花费O(N)的查找时间,这将是多么大的性能损失。这个问题终于在JDK1.8中获得了解决,在最坏的状况下,链表查找的时间复杂度为O(n),而红黑树一直是O(logn),这样会提升HashMap的效率。
jdk1.7中HashMap采用的是位桶+链表的方式,即咱们常说的散列链表的方式,而jdk1.8中采用的是位桶+链表/红黑树的方式,也是非线程安全的。当某个位桶的链表的长度达到某个阀值的时候,这个链表就将转换成红黑树。
jdk1.8中,当同一个hash值的节点数不小于8时,将再也不以单链表的形式存储了,会被调整成一颗红黑树(上图中null节点没画)。这就是jdk1.7与jdk1.8中HashMap实现的最大区别。
经过分析put方法的源码,可让这种区别更直观:
static final int TREEIFY_THRESHOLD = 8;
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) { Node<K,V>[] tab; Node<K,V> p; int n, i; //若是当前map中无数据,执行resize方法。而且返回n if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; //若是要插入的键值对要存放的这个位置恰好没有元素,那么把他封装成Node对象,放在这个位置上便可 if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); //不然的话,说明这上面有元素 else { Node<K,V> e; K k; //若是这个元素的key与要插入的同样,那么就替换一下。 if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) e = p; //1.若是当前节点是TreeNode类型的数据,执行putTreeVal方法 else if (p instanceof TreeNode) e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); else { //仍是遍历这条链子上的数据,跟jdk7没什么区别 for (int binCount = 0; ; ++binCount) { if ((e = p.next) == null) { p.next = newNode(hash, key, value, null); //2.完成了操做后多作了一件事情,判断,而且可能执行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) //true || -- e.value = value; //3. afterNodeAccess(e); return oldValue; } } ++modCount; //判断阈值,决定是否扩容 if (++size > threshold) resize(); //4. afterNodeInsertion(evict); return null; } 以上代码中的特别之处以下: 1 2 if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st treeifyBin(tab, hash); treeifyBin()就是将链表转换成红黑树。
putVal方法处理的逻辑比较多,包括初始化、扩容、树化,近乎在这个方法中都能体现,针对源码简单讲解下几个关键点:
若是Node<K,V>[] table是null,resize方法会负责初始化,即以下代码: if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; resize方法兼顾两个职责,建立初始存储表格,或者在容量不知足需求的时候,进行扩容(resize)。 在放置新的键值对的过程当中,若是发生下面条件,就会发生扩容。 if (++size > threshold) resize(); 具体键值对在哈希表中的位置(数组index)取决于下面的位运算: i = (n - 1) & hash 仔细观察哈希值的源头,会发现它并非key自己的hashCode,而是来自于HashMap内部的另外一个hash方法。为何这里须要将高位数据移位到低位进行异或运算呢?这是由于有些数据计算出的哈希值差别主要在高位,而HashMap里的哈希寻址是忽略容量以上的高位的,那么这种处理就能够有效避免相似状况下的哈希碰撞。
在jdk1.8中取消了indefFor()方法,直接用(tab.length-1)&hash,因此看到这个,表明的就是数组的下角标。
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); } 为何HashMap为何要树化?
以前在极客时间的专栏里看到过一个解释。本质上这是个安全问题。由于在元素放置过程当中,若是一个对象哈希冲突,都被放置到同一个桶里,则会造成一个链表,咱们知道链表查询是线性的,会严重影响存取的性能。而在现实世界,构造哈希冲突的数据并非很是复杂的事情,恶意代码就能够利用这些数据大量与服务器端交互,致使服务器端CPU大量占用,这就构成了哈希碰撞拒绝服务攻击,国内一线互联网公司就发生过相似攻击事件。
4、分析Hashtable、HashMap、TreeMap的区别
HashMap是继承自AbstractMap类,而HashTable是继承自Dictionary类。不过它们都实现了同时实现了map、Cloneable(可复制)、Serializable(可序列化)这三个接口。存储的内容是基于key-value的键值对映射,不能由重复的key,并且一个key只能映射一个value。 Hashtable的key、value都不能为null;HashMap的key、value能够为null,不过只能有一个key为null,但能够有多个null的value;TreeMap键、值都不能为null。 Hashtable、HashMap具备无序特性。TreeMap是利用红黑树实现的(树中的每一个节点的值都会大于或等于它的左子树中的全部节点的值,而且小于或等于它的右子树中的全部节点的值),实现了SortMap接口,可以对保存的记录根据键进行排序。因此通常需求排序的状况下首选TreeMap,默认按键的升序排序(深度优先搜索),也能够自定义实现Comparator接口实现排序方式。 通常状况下咱们选用HashMap,由于HashMap的键值对在取出时是随机的,其依据键的hashCode和键的equals方法存取数据,具备很快的访问速度,因此在Map中插入、删除及索引元素时其是效率最高的实现。而TreeMap的键值对在取出时是排过序的,因此效率会低点。
TreeMap是基于红黑树的一种提供顺序访问的Map,与HashMap不一样的是它的get、put、remove之类操做都是o(log(n))的时间复杂度,具体顺序能够由指定的Comparator来决定,或者根据键的天然顺序来判断。
对HashMap作下总结:
HashMap基于哈希散列表实现 ,能够实现对数据的读写。将键值对传递给put方法时,它调用键对象的hashCode()方法来计算hashCode,而后找到相应的bucket位置(即数组)来储存值对象。当获取对象时,经过键对象的equals()方法找到正确的键值对,而后返回值对象。HashMap使用链表来解决hash冲突问题,当发生冲突了,对象将会储存在链表的头节点中。HashMap在每一个链表节点中储存键值对对象,当两个不一样的键对象的hashCode相同时,它们会储存在同一个bucket位置的链表中,若是链表大小超过阈值(TREEIFY_THRESHOLD,8),链表就会被改造为树形结构。 欢迎工做一到五年的Java工程师朋友们加入Java架构开发: 854393687 群内提供免费的Java架构学习资料(里面有高可用、高并发、高性能及分布式、Jvm性能调优、Spring源码,MyBatis,Netty,Redis,Kafka,Mysql,Zookeeper,Tomcat,Docker,Dubbo,Nginx等多个知识点的架构资料)合理利用本身每一分每一秒的时间来学习提高本身,不要再用"没有时间“来掩饰本身思想上的懒惰!趁年轻,使劲拼,给将来的本身一个交代!