HashMap源码分析与面试题,隔壁家小孩“**,我都看懂了”

HashMap源码分析

在这里插入图片描述

继承关系

java.lang.Object
java.util.AbstractMap<K,V>
java.util.HashMap<K,V>

public class HashMap<K,V>
        extends AbstractMap<K,V>
        implements Map<K,V>, Cloneable, Serializable

关键参数

//默认数组长度为16
        static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;

// 数组的最大长度为2^30
        static final int MAXIMUM_CAPACITY = 1 << 30;

// 默认负载因子,当元素个数超过0.75*数组长度时resize()
        static final float DEFAULT_LOAD_FACTOR = 0.75f;

// 树形化阈值,当链表节点个大于等于TREEIFY_THRESHOLD - 1时,会将该链表换成红黑树。

        static final int TREEIFY_THRESHOLD = 8;

// 解除树形化阈值,当链表节点小于等于这个值时,会将红黑树转换成普通的链表。
        static final int UNTREEIFY_THRESHOLD = 6;

// 最小树形化的容量,即:当内部数组长度小于64时,不会将链表转化成红黑树,而是优先扩充数组。
        static final int MIN_TREEIFY_CAPACITY = 64;

// 这个就是hashMap的内部数组了,而Node则是链表节点对象。
        transient Node<K,V>[] table;

//容器结构的修改次数,fail-fast机制。
        transient int modCount;

// 阈值,超过这个值时扩充数组。 threshold = capacity * loadfactor,具体看上面的静态常量。
        int threshold;

// 装载因子,具体看上面的静态常量。如果未赋值,则等于0.75f
        final float loadFactor;

构造函数

无参构造

// 默认数组初始容量为16,负载因子为0.75f
public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}

有参构造

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);
}

tableSizeFor()方法

//将给定容量转换成2的次方。例如给定10,返回16
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;
}

put()方法

由此可见,put()的真正实现方法为putVal()

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

hash()方法

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

哈希表索引下标计算:

i = (n - 1) & hash

hash方法的作用是将hashCode方法进一步打散,增加其 “随机度” ,减少HashMap中的hash冲突,提高"离散性能".
将hashCode的gao16位与低16位进行异或运算,提高离散性能.
问:
1. 为何不采用Object类提供的hashCode方法计算出来的key值作为桶下标:
基本不会发生碰撞,哈希表就和普通数组基本没有区别
2. 为何h >>> 16?为何取出key值得高16位右移参与hash运算?
因为hash基本上是在高16位进行hash运算
3. 为何HashMap中容量均为2^n ?
(n - 1) & hash : 当n为2^n,此时的位运算就相当于 hash % (n-1)

putVal()方法

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                       boolean evict) {
	// tab:内部数组
	// p:hash对应的索引位中的首节点
	// n:内部数组的长度
	// i:hash对应的索引位
	 Node[] tab; Node p; int n, i;
        
        // 第一次put值时,将哈希表初始化
        // resize():1.完成哈希表的初始化 2.完成哈希表的扩容
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        // 当目标索引未存储元素时,将当前元素存储到目标索引位置
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        // 哈希表已经初始化并且算出的数组下标已经有元素了
        else {
            Node e; K k;
            // 若索引下标对应的元素key恰好与当前元素key值相等且不为null
            // 将value替换为当前元素的value
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            // 此时索引对应的链表已经树化了,采用红黑树方式将当前节点添加到树中
            else if (p instanceof TreeNode)
                e = ((TreeNode)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);
                        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;//fail-fast机制
        // 如果元素个数大于阈值,扩容
        if (++size > threshold)
        	// 扩容
            resize();
        afterNodeInsertion(evict);
        return null;
    }

putVal()步骤:

  1. 检查数组是否为空,如果为空,执行resize()扩充(默认初始化为16个桶);
  2. 通过hash值计算数组索引,获取该索引位的首节点。
  3. 如果首节点为null,直接添加节点到该索引位。
  4. 如果首节点不为null,那么有3种情况
    ① key和首节点的key相同,覆盖value ;
    ② 如果首节点是红黑树节点(TreeNode),将键值对添加到红黑树。
    ③ 如果首节点是链表,将键值对添加到链表。添加之后会判断链表长度是否到
    TREEIFY_THRESHOLD - 1这个阈值,将链表转换成红黑树。
  5. 若桶满了(rize++ 是否大于threshold),调用resize()扩容哈希表。
    thresholed = 容量(默认16) * 负载因子 (默认0.75)

为何JDK1.8要引入红黑树?

  1. 当链表长度过长时,会将哈希表查找的时间复杂度退化为O(n)
  2. 树化保证即便在哈希冲突严重时,查找时间复杂度也为O(logn)
  3. 当红黑树节点个数在扩容或删除元素时减少为6以下,在下次resize过程中会将红黑树退化为链表,节省空间。

resize()方法

final Node<K,V>[] resize() {
        Node<K,V>[] oldTab = table;
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        int oldThr = threshold;
        int newCap, newThr = 0;
        if (oldCap > 0) {
            // 当前哈希表已经达到最大值
            if (oldCap >= MAXIMUM_CAPACITY) {
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            // 扩容为原hash表二倍
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                newThr = oldThr << 1; // double threshold
        }
        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;
        // 下面代码是将原来数组的元素转移到新数组中。问题在于,数组长度发生变化。 
	// 那么通过hash%数组长度计算的索引也将和原来的不同。
	// jdk 1.7中是通过重新计算每个元素的索引,重新存入新的数组,称为rehash操作。
	// 这也是hashMap无序性的原因之一。而现在jdk 1.8对此做了优化,非常的巧妙。
        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;
    }

扩容resize()步骤

  1. 负责哈希表的初始化操作
  2. 当表中元素个数达到阈值: 容量 * 负载因子后进行扩容为原哈希表的二倍(扩容的是桶的个数)
  3. 扩容后,原来元素进行rehash:
    要么元素还待在原桶中,要么待在二倍桶中

那么扩容后的元素究竟呆在原桶中还是二倍桶中?

  • i = (n - 1) & hash此处的n为扩容后的数组长度
  • 看 i 的最高位是0还是1
  • 如果是0,则呆在原桶中
  • 如果是1,则呆在二倍桶中,移至"原索引 + oldCap"

getNode()方法

final Node<K,V> getNode(int hash, Object key) {
        Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (first = tab[(n - 1) & hash]) != null) {
            if (first.hash == hash && // always check first node
                ((k = first.key) == key || (key != null && key.equals(k))))
                // 当前节点key刚好是桶的第一个节点
                return first;
            // 遍历桶的其他节点找到指定key返回其value    
            if ((e = first.next) != null) {
                // 树的遍历
                if (first instanceof TreeNode)
                    return ((TreeNode<K,V>)first).getTreeNode(hash, key);
                // 链表的遍历
                do {
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        return e;
                } while ((e = e.next) != null);
            }
        }
        // 当表还未初始化或者key值是null
        return null;
    }
  1. 当表还未初始化或者key值为null,return nullII
  2. 表已经初始化并且key不为null
    a. key值刚好是桶的头结点,直接返回
    b.若已经树化,调用树的遍历方式找到指定key对应的Node返回
    调用链表的遍历方式找到指定key对应的Node返回

hashCode 与 equals关系
hashCode:
取得任意一个对象的哈希码
equals :
比较两个对象是否相等
关系:
hashCode返回值相等的两个对象,equals不一定相等(x与f(x)x为equals,f (x) 为hashCode)
关系equals返回值相等的的两个对象,hashCode一定相等

HashMap是如何判断key是否相同呢?

if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
  • e.hash == hash,hash变量的值取决于key的hashCode的值,因此需要key的hashCode相同,也就是hashCode方法返回值要相同;
  • (k = e.key) == key,判断两个对象是否相同,则判定key相同;
  • key != null && key.equals(k) ,调用equals方法返回true,则判定key相同;

想正确的获取HashMap中集合的元素,判定key是否相同,要同时重写的hashCode方法和equals方法。

ConcurrentHashMap

JDK7的ConcurrentHashMap:
哈希表将原先的16个桶设计改为16个Segment,每个Segment都有独立的一把锁。拆分后的每个Segment都相当于原先的一个HashMap(double-hash设计).并且Segment在初始化后无法扩容,每个Segment对应的哈希表可以扩容,扩容只扩容相应Segment下面的哈希表。
线程安全:使用ReentrantLock保证相应Segment下的线程安全
在这里插入图片描述
JDK8下的ConcurrentHashMap:
整体结构与HashMap别无二致,都是使用哈希表+红黑树结构
线程安全:使用内建锁Sychronized+CAS锁每个桶的头结点,使得锁进一步细粒度化
在这里插入图片描述

ConcurrentHashMap不允许键值对为空

JDK7与JDK8 ConcurrentHashMap的变化:

  1. 结构上的变化:
    取消原先的Segment设计,取而代之的是使用与HashMap同样的数据结构,即哈希表+红黑树,并且引入了懒加载机制。
  2. 线程安全:
    2.1 锁粒度更细:由原来的锁Segment一片区域到锁桶的头结点
    2.2 由原先的ReentrantLock替换为Sychronized+CAS:现版本的sychronized已经经过不断优化,性能上与ReentrantLock基本没有差异,并且相对于ReentrantLock,使用Sychronized可以节省大量内存空间,这是非常大的优势所在。

面试题

1. HashMap的实现原理,内部数据结构?
底层使用哈希表,也就是数组+链表,当链表长度超过8个时会转化为红黑树,以实现查找的时间复杂度为log n。

2. HashMap中put方法的过程?
调用哈希函数获取Key对应的hash值,再计算其数组下标;
如果没有出现哈希冲突,则直接放入数组;
如果出现哈希冲突,则以链表的方式放在链表后面;
如果链表的长度超过8,则会转化为红黑树;
如果结点的key已经存在,则替换其value即可;
如果集合中的键值对大于12,调用resize方法进行数组扩容;

3. 哈希函数怎么实现的?
调用Key的hashCode方法获取hashCode值,并将该值的高16位和低16位进行异或运算。

4. 哈希冲突怎么解决?
将新结点添加在链表后面

5. 组扩容的过程?
创建一个新的数组,其容量为旧数组的两倍,并重新计算旧数组中结点的存储位置。结点在新数组中的位置只有两种,原下标位置或原下标+旧数组的大小。

6. 除了链地址法,哈希冲突的其他解决方案?
开放定址法:发生哈希冲突,寻找另一个未被占用的数组地址
再哈希法:提供多个哈希函数,直到不再产生冲突;
建立公共溢出区:将哈希表分为基本表和溢出表,产生哈希冲突的结点放入溢出表

7. 如何规避HashMap的线程不安全?

  • 将Map转换成包装类,使用Collections.SynchronizedMap()方法
  • 使用HashTable,但是性能较低
  • 使用ConcurrentHashMap保证线程安全

8. 多线程情况下使用HashMap会引起什么情况?

  • 死循环
  • 数据重复
  • 数据覆盖(丢失)
发布了42 篇原创文章 · 获赞 13 · 访问量 6514

猜你喜欢

转载自blog.csdn.net/weixin_43508555/article/details/104186966