1.hashMap底层实现原理
可以访问这篇文档 --->传送门
2.hashMap是怎样取值和设置
HashMap基于hashing原理,我们通过put()和get()方法储存和获取对象。当我们将键值对传递给put()方法时,它调用键对象的hashCode()方法来计算hashcode,让后找到bucket位置来储存值对象。当获取对象时,通过键对象的equals()方法找到正确的键值对,然后返回值对象。HashMap使用链表来解决碰撞问题,当发生碰撞了,对象将会储存在链表的下一个节点中。 HashMap在每个链表节点中储存键值对对象
当两个不同的键对象的hashcode相同时会发生什么? 它们会储存在同一个bucket位置的链表中。键对象的equals()方法用来找到键值对。
3.HashMap和Hashtable的区别
HashMap和Hashtable都实现了Map接口,但决定用哪一个之前先要弄清楚它们之间的分别。
主要的区别有:线程安全性,同步(synchronization),以及速度。
HashMap是非synchronized的,并可以接受null(HashMap可以接受为null的键值(key)和值(value),而Hashtable则不行)。
- HashMap是非synchronized,而Hashtable是synchronized,这意味着Hashtable是线程安全的,多个线程可以共享一个Hashtable;而如果没有正确的同步的话,多个线程是不能共享HashMap的。Java 5提供了ConcurrentHashMap,它是HashTable的替代,比HashTable的扩展性更好。
- 由于Hashtable是线程安全的也是synchronized,所以在单线程环境下它比HashMap要慢。如果你不需要同步,只需要单一线程,那么使用HashMap性能要好过Hashtable。
- HashMap不能保证随着时间的推移Map中的元素次序是不变的。
4.HashMap和HashSet的区别
1.什么是HashSet
HashSet实现了Set接口,它不允许集合中有重复的值,当我们提到HashSet时,第一件事情就是在将对象存储在HashSet之前,要先确保对象重写equals()和hashCode()方法,这样才能比较对象的值是否相等,以确保set中没有储存相等的对象。如果我们没有重写这两个方法,将会使用这个方法的默认实现。
public boolean add(Object o)方法用来在Set中添加元素,当元素值重复时则会立即返回false,如果成功添加的话会返回true。
2.什么是HashMap
HashMap实现了Map接口,Map接口对键值对进行映射。Map中不允许重复的键。Map接口有两个基本的实现,HashMap和TreeMap。TreeMap保存了对象的排列次序,而HashMap则不能。HashMap允许键和值为null。HashMap是非synchronized的,但collection框架提供方法能保证HashMap synchronized,这样多个线程同时访问HashMap时,能保证只有一个线程更改Map。
public Object put(Object Key,Object value)方法用来将元素添加到map中
5.HashMap基础
HashMap继承了AbstractMap类,实现了Map,Cloneable,Serializable接口
HashMap的容量,默认是16
/** * The default initial capacity - MUST be a power of two. */ static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16 HashMap的加载因子,默认是0.75 /** * The load factor used when none specified in constructor. */ static final float DEFAULT_LOAD_FACTOR = 0.75f;
当HashMap中元素数超过容量*加载因子时,HashMap会进行扩容。
5.1HashMap实现原理
Node和Node链
首先来了解一下HashMap中的元素类型
HashMap类中的元素是Node类,翻译过来就是节点,是定义在HashMap中的一个内部类,实现了Map.Entry接口。
Node类的定义如下:
/** * Basic hash bin node, used for most entries. (See below for * TreeNode subclass, and in LinkedHashMap for its Entry subclass.) */ 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; } 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; } }
可以看到,Node类的基本属性有:
hash:key的哈希值
key:节点的key,类型和定义HashMap时的key相同
value:节点的value,类型和定义HashMap时的value相同
next:该节点的下一节点
值得注意的是其中的next属性,记录的是下一个节点本身,也是一个Node节点,这个Node节点也有next属性,记录了下一个节点,于是,只要不断的调用Node.next.next.next……,就可以得到:
Node-->下个Node-->下下个Node……-->null
这样的一个链表结构,而对于一个HashMap来说,只要明确记录每个链表的第一个节点,就能顺序遍历链表上的所有节点。
拉链法
HashMap使用拉链法管理其中的每个节点。
由Node节点组成链表之后,HashMap定义了一个Node数组:
transient Node<K,V>[] table;
这个数组记录了每个链表的第一个节点,于是最终形成了HashMap下面这样的数据结构:
这种数组+链表的数据结构,使得HashMap可以较为高效的管理每一个节点。
关于Node数组 table
对于table的理解,对后面关于扩容的理解很有帮助。
table在第一次往HashMap中put元素的时候初始化。
如果HashMap初始化的时候没有指定容量,那么初始化table的时候会使用默认的DEFAULT_INITIAL_CAPACITY参数,也就是16,作为table初始化时的长度。
如果HashMap初始化的时候指定了容量,HashMap会把这个容量修改为2的倍数,然后创建对应长度的table。
table在HashMap扩容的时候,长度会翻倍。
所以table的长度肯定是2的倍数。
修改容量的方法是这样的:
/** * 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; }
所以要注意,如果要往HashMap中放1000个元素,又不想让HashMap不停的扩容,最好一开始就把容量设为2048,设为1024不行,因为元素添加到七百多的时候还是会扩容。
散列算法
当调用HashMap.put()方法时,经历了以下步骤:
1,对key进行hash值计算
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
2,hash值和table.length取模
取模的方法是(table.length - 1) & hash,算法直接舍弃了二进制hash值在table.length以上的位,因为那些位都代表table.length的2的n次方倍数。
取模的结果就是Node将要放入table的下标。
比如,一个Node的hash值是5,table长度是4,那么取余的结果是1,也就是说,这个Node将被放入table[1]所代表的链表(table[1]本身指向的是链表的第一个节点)。
3,添加元素
如果此时table的对应位置没有任何元素,也就是table[i]=null,那么就直接把Node放入table[i]的位置,并且这个Node的next==null。
如果此时table对应位置是一个Node,说明对应的位置已经保存了一个Node链表,则需要遍历链表,如果发现相同hash值则替换Node节点,如果没有相同hash值,则把新的Node插入链表的末端,作为之前末端Node的next,同时新Node的next==null。
如果此时table对应位置是一个TreeNode,说明链表被转换成了红黑树,则根据hash值向红黑树中添加或替换TreeNode。(JDK1.8)
4,如果添加元素之后,Node链表的节点数超过了8个,则该链表会考虑转为红黑树。(JDK1.8)
5,如果添加元素之后,HashMap总节点数超过了阈值,则HashMap会进行扩容。
相关代码是这样的:
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; if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; if ((p = tab[i = (n - 1) & hash]) == null) //注释1 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)))) //注释2 e = p; else if (p instanceof TreeNode) //注释3 e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); else { for (int binCount = 0; ; ++binCount) { if ((e = p.next) == null) { p.next = newNode(hash, key, value, null); if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st treeifyBin(tab, hash); //注释4 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; if (++size > threshold) //注释5 resize(); afterNodeInsertion(evict); return null; }
代码解析:
1,注释1,table对应位置无节点,则创建新的Node节点放入对应位置。
2,注释2,table对应位置有节点,如果hash值匹配,则替换。
3,注释3,table对应位置有节点,如果table对应位置已经是一个TreeNode,不再是Node,也就说,table对应位置是TreeNode,表示已经从链表转换成了红黑树,则执行插入红黑树节点的逻辑。
4,注释4,table对应位置有节点,且节点是Node(链表状态,不是红黑树),链表中节点数量大于TREEIFY_THRESHOLD,则考虑变为红黑树。实际上不一定真的立刻就变,table短的时候扩容一下也能解决问题,后面的代码会提到。
5,注释5,HashMap中节点个数大于threshold,会进行扩容。
HashMap扩容机制 -----resize()
什么时候扩容:当向容器添加元素的时候,会判断当前容器的元素个数,如果大于等于阈值(念yu值四声)---即当前数组的长度乘以加载因子的值的时候,就要自动扩容啦。
扩容(resize)就是重新计算容量,向HashMap对象里不停的添加元素,而HashMap对象内部的数组无法装载更多的元素时,对象就需要扩大数组的长度,以便能装入更多的元素。当然Java里的数组是无法自动扩容的,方法是使用一个新的数组代替已有的容量小的数组,就像我们用一个小桶装水,如果想装更多的水,就得换大水桶。
先看一下什么时候,resize();
/** * HashMap 添加节点 * * @param hash 当前key生成的hashcode * @param key 要添加到 HashMap 的key * @param value 要添加到 HashMap 的value * @param bucketIndex 桶,也就是这个要添加 HashMap 里的这个数据对应到数组的位置下标 */ void addEntry(int hash, K key, V value, int bucketIndex) { //size:The number of key-value mappings contained in this map. //threshold:The next size value at which to resize (capacity * load factor) //数组扩容条件:1.已经存在的key-value mappings的个数大于等于阈值 // 2.底层数组的bucketIndex坐标处不等于null if ((size >= threshold) && (null != table[bucketIndex])) { resize(2 * table.length);//扩容之后,数组长度变了 hash = (null != key) ? hash(key) : 0;//为什么要再次计算一下hash值呢? bucketIndex = indexFor(hash, table.length);//扩容之后,数组长度变了,在数组的下标跟数组长度有关,得重算。 } createEntry(hash, key, value, bucketIndex); } /** * 这地方就是链表出现的地方,有2种情况 * 1,原来的桶bucketIndex处是没值的,那么就不会有链表出来啦 * 2,原来这地方有值,那么根据Entry的构造函数,把新传进来的key-value mapping放在数组上,原来的就挂在这个新来的next属性上了 */ void createEntry(int hash, K key, V value, int bucketIndex) { HashMap.Entry<K, V> e = table[bucketIndex]; table[bucketIndex] = new HashMap.Entry<>(hash, key, value, e); size++; }
我们分析下resize的源码,鉴于JDK1.8融入了红黑树,较复杂,为了便于理解我们仍然使用JDK1.7的代码,好理解一些,本质上区别不大,具体区别后文再说
void resize(int newCapacity) { //传入新的容量 Entry[] oldTable = table; //引用扩容前的Entry数组 int oldCapacity = oldTable.length; if (oldCapacity == MAXIMUM_CAPACITY) { //扩容前的数组大小如果已经达到最大(2^30)了 threshold = Integer.MAX_VALUE; //修改阈值为int的最大值(2^31-1),这样以后就不会扩容了 return; } Entry[] newTable = new Entry[newCapacity]; //初始化一个新的Entry数组 transfer(newTable); //!!将数据转移到新的Entry数组里 table = newTable; //HashMap的table属性引用新的Entry数组 threshold = (int) (newCapacity * loadFactor);//修改阈值 }
代码中可以看到,如果原有table长度已经达到了上限,就不再扩容了。
如果还未达到上限,则创建一个新的table,并调用transfer方法:
这里就是使用一个容量更大的数组来代替已有的容量小的数组,transfer()方法将原有Entry数组的元素拷贝到新的Entry数组里。
void transfer(Entry[] newTable) { Entry[] src = table; //src引用了旧的Entry数组 int newCapacity = newTable.length; for (int j = 0; j < src.length; j++) { //遍历旧的Entry数组 Entry<K, V> e = src[j]; //取得旧Entry数组的每个元素 if (e != null) { src[j] = null;//释放旧Entry数组的对象引用(for循环后,旧的Entry数组不再引用任何对象) do { Entry<K, V> next = e.next; //注释1 int i = indexFor(e.hash, newCapacity); //!!重新计算每个元素在数组中的位置 //注释2 e.next = newTable[i]; //标记[1] //注释3 newTable[i] = e; //将元素放在数组上 //注释4 e = next; //访问下一个Entry链上的元素 //注释5 } while (e != null); } } }
static int indexFor(int h, int length) { return h & (length - 1); }
transfer方法的作用是把原table的Node放到新的table中,使用的是头插法,也就是说,新table中链表的顺序和旧列表中是相反的,在HashMap线程不安全的情况下,这种头插法可能会导致环状节点。
其中的while循环描述了头插法的过程,这个逻辑有点绕,下面举个例子来解析一下这段代码。
假设原有table记录的某个链表,比如table[1]=3,链表为3-->5-->7,那么处理流程为:
1,注释1:记录e.next的值。开始时e是table[1],所以e==3,e.next==5,那么此时next==5。
2,注释2,计算e在newTable中的节点。为了展示头插法的倒序结果,这里假设e再次散列到了newTable[1]的链表中。
3,注释3,把newTable [1]赋值给e.next。因为newTable是新建的,所以newTable[1]==null,所以此时3.next==null。
4,注释4,e赋值给newTable[1]。此时newTable[1]=3。
5,注释5,next赋值给e。此时e==5。
此时newTable[1]中添加了第一个Node节点3,下面进入第二次循环,第二次循环开始时e==5。