Map随笔:最常用的Map——HashMap
前言:
HashMap作为我们工作中最常用的一个容器,去了解它的一些原理是非常有必要的,同时,HashMap也是面试中被问起的常客。所以接下来我就源码并穿插一些面试中最常被问起的面试题和大家分享一下HashMap相关的知识来避免以后在面试中再被HashMap相关知识所难住。
1,HashMap的一些属性(JDK8)
先看看源码中的几个常量属性
/** * 默认的数组容量 */ 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; /** * java8优化了HashMap的数据结构,在链上的结构数据超过固定数量便会从链表转换成红黑树存储 * 链表转树的默认值 8 */ static final int TREEIFY_THRESHOLD = 8; /** * 树转链表的默认值 6,当数据个数小于该值时,结构由红黑树转换成链表结构 */ static final int UNTREEIFY_THRESHOLD = 6;
再看看一些其他属性
/** * HashMap底层有一部分是hash表结构,用数组承载数据 */ transient Node<K,V>[] table; /** * HashMap的entrySet()方法的返回值 */ transient Set<Map.Entry<K,V>> entrySet; /** * 数据个数 */ transient int size; /** * 用来记录HashMap结构改变次数,因为HashMap是有Fast-Fail机制的 */ transient int modCount; /** * 阈值,如果数组中数据个数大于等于这个值,便会触发扩容 */ int threshold; /** * HashMap的加载因子,这个属性权衡哈希冲突和空间使用率的数值 */ final float loadFactor;
最后看一看HashMap中第一个内部类,也就是承载数据的载体Node(Java8)
static class Node<K,V> implements Map.Entry<K,V> { //key的hash值 final int hash; //key final K key; //value值 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在Java8以前呢是一个数组链表双结构数据类型,在Java8以后增加了红黑树结构。但是这个链表于LinkedList中的双向链表不同,HashMap中的链表是一个单向链表,只有指向下一个节点的指针(当年我被问过一道面试题便是HashMap中链表的“链”是什么?)。
值得说的是Java8之前,这个Node叫做Entry,这个也算是一个别人判断你是不是真的看过源码的一个依据,这个小细节要注意。
static class Entry<K,V> implements Map.Entry<K,V> { final K key; V value; Entry<K,V> next; final int hash; .................................. }
相关面试题:
1,HashMap的有没有默认容量,是多少?
源码中已经写的很清楚,HashMap是有默认容量的,值是16,这个容量指的是数组的容量
2,HashMap中的加载因子是什么?为什么默认值是0.75F?大于默认值或者小于默认值有什么影响吗?
熟悉HashMap的人都知道,HashMap在存储数据时,是通过key的哈希值进行计算后将数据放到hash桶中,但是如此会出现hash冲突(不同的key在计算后值相同),hash冲突后带来的问题便是查找数据时效率收到影响。而加载因子又和阈值threshold有直接关系(threshold = table.length * loadFactor),HashMap在hash表中数据超过阈值时会扩容,但是会造成空间浪费,比如,默认jvm给HashMap分了存放16个数据内存大小,但是 只能放14(16*0.75)个数据便会触发扩容,剩余2个存放数据的空间便浪费掉了,所以加载因子时平衡hash冲突和空间利用率的一个折中率。过大,空间利用率高了,但是hash冲突变大了,过小,hash冲突变低了,但是空间利用率也变低了,之所以默认是0.75f,这也也不是什么固定公式算出来的,这完全前辈们大数据测试下来的一个经验值(详细可参考源码注释)以及这里:HashMap为什么默认加载因子是0.75
3,HashMap什么时候就变成红黑树结构了?
key值经过计算后得值相同的key放在同一个hash桶中,并以单向链表的形式连接起来,如果链上的数据量大于8(默认),便转换成红黑树结构,当数据量小于6时,又变回链表。树结构的查找效率比链表快
2,HashMap的构造函数(JDK8)
/** * 自定义容量和加载因子,如果没有特殊要求,不建议自定义家在因子 */ 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); } /** * 无参构造函数,默认容量16,加载因子0.75f * java8 源码有部分改变,默认设置源码部分逻辑挪到了resize() */ public HashMap() { this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted } /** * 添加一个Map */ public HashMap(Map<? extends K, ? extends V> m) { this.loadFactor = DEFAULT_LOAD_FACTOR; putMapEntries(m, false); }
3,HashMap的一些方法(JDK8)
1,计算hash表容量
/** * 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要求hash表的容量必须是2的指数幂(原因后面解释),该方法返回的是大于指定容量cap的最小2的指数幂。
2,计算key的hash值
/** * jdk7 */ static int hash(int h) { h ^= (h >>> 20) ^ (h >>> 12); return h ^ (h >>> 7) ^ (h >>> 4); } /** * jdk8 */ static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
在获取key的hash值时,Java7和8都做了相关的位与运算目的都是为了近一步对key的hash值在插入HashMap时计算的混淆,增加它的随机度,降低hash冲突。不同的点是,7 进行了4次位与计算,而8只进行了一次,这种计算都叫做”扰动函数“,之所以8只进行一次,可能是因为多次的计算效果并不是很明显,反而影响效率,所以只做一次扰动就够了,而8 利用key 的hash值的高低位 异或运算 近一步 增加 key在HashMap中hash表的随机度,之所以右移16,是因为hash值是int数值,64位系统上是32位,所以右移16位,使得其高低位混淆,即混淆了低位的”随机度“又保留了高位的特征,这种设计就很巧妙。
3,面试被问最频繁的方法put()
put方法 Java8对put方法进行了新的逻辑和优化,所以一个一个分开来看。先看8中的put方法
public V put(K key, V value) { return putVal(hash(key), key, value, false, true); } // onlyIfAbsent:当key重复时,是否覆盖旧的数据 false:覆盖 true:不覆盖 // evict : 留给 LinkedHashMap扩展用的 final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { Node<K,V>[] tab; Node<K,V> p; int n, i; // 旧表数据为0,则新建一个表,初始容量和加载因子是默认值 // Java8之前,初始化表是在构造方法里,8以后优化成put操作时,原因个人觉得是为了节约内存空间(new了不一定用) if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; // 这个key在hash表中所对应下标上没有数据,直接插入 if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); // 这个key在hash表中所对应下标上有数据,数据添加到链上 else { //e用来判断是本次put操作时新增还是修改 Node<K,V> e; K k; //表中已经有该key, if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) e = p; //如果是LinkedHashMap的树节点,则以树结构去添加 else if (p instanceof TreeNode) 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); //链上节点超过8个,则把这个链表结构变成红黑树 if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st treeifyBin(tab, hash); break; } //链上有相同的key,停止遍历查找 if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break; p = e; } } //e != null,说明本次put操作时修改 if (e != null) { // existing mapping for key V oldValue = e.value; if (!onlyIfAbsent || oldValue == null) //用新的value替换旧的value值 e.value = value; //LinkedHashMap扩展用 afterNodeAccess(e); //返回旧值 (面试题) return oldValue; } } // -----------------如果进入以下代码,说明本次操作时新增操作 // 记录HashMap内部变化次数,触发Fast-Fail机制 ++modCount; // 判断,如果当前大小大于等于阈值,则触发扩容 if (++size > threshold) resize(); // LinkedHashMap扩展点 afterNodeInsertion(evict); // 如果是新增操作,则返回null(面试题) return null; }
再看看7中的源码
public V put(K key, V value) { // HashMap允许有且只有一个key为null的数据 if (key == null) return putForNullKey(value); int hash = hash(key.hashCode()); //计算下标 int i = indexFor(hash, table.length); //判断key是否已经存在 for (Entry<K,V> e = table[i]; e != null; e = e.next) { Object k; if (e.hash == hash && ((k = e.key) == key || key.equals(k))) { V oldValue = e.value; e.value = value; e.recordAccess(this); return oldValue; } } //同8 modCount++; //新增新节点 addEntry(hash, key, value, i); return null; } void addEntry(int hash, K key, V value, int bucketIndex) { //获取旧的节点 Entry<K,V> e = table[bucketIndex]; //生成新的节点,其中构造方法中的e对应的是Entry的next属性,所以8之前,HashMap采用的是头插法,新的节点插入到链的头部 table[bucketIndex] = new Entry<K,V>(hash, key, value, e); //判断是否需要扩容,扩容大小为原来的2倍 if (size++ >= threshold) resize(2 * table.length); }
Java7和8的put方法有较大的改变,首先,8添加了红黑树结构,在链上长度超过8(默认)时,会将两边转换成红黑树结构。其次,在链表上添加数据时,8采用的是尾插法,7采用的时头插法,所以在扩容时,7再扩容后,链上的数据顺序变成原来的倒序,8保持原来的数据,这样一来,在多线程环境下,7之前的HashMap
再扩容时发生的死锁问题的打了改善。最后,8添加了一些LinkedHashMap的扩展方法入口
值得说的是,为什么HashMap不直接用key的hash值作为下标呢,而是将key的hash值经过 “(table.length- 1) & hash”计算后的值作为下标呢?原因其实不难想到,首先hash值是一个int数值,int数值的范围时 -2^31~2^31-1,而HashMap要求的最大容量MAXIMUM_CAPACITY值时1<<30,显然直接用hash值是不合适的,另一方便也不可能new一个HashMap就给分配这么大的空间。因为HashMap要求容量必须时2的指数幂,所以 “(table.length- 1) & hash“ 就等价于 hash % table.length 的值,这样以来便能确保任何一个key都能分配在HashMap的容量内,这也是为什么要求HashMap的容量为什么时2的指数幂的原因。
未完待续...