程序员社区

(Java集合面试题)HashMap的实现原理

HashMap是基于哈希表实现的map,哈希表(也叫关联数组)一种通用的数据结构,是Java开发者常用的类,常用来存储和获取数据,功能强大使用起来也很方便,是居家旅行...不对,是Java开发需要掌握的基本技能,也是面试必考的知识点,所以,了解HashMap是很有必要的。

原理

简单讲解下HashMap的原理:HashMap基于Hash算法,我们通过put(key,value)存储,get(key)来获取。当传入key时,HashMap会根据key.hashCode()计算出hash值,根据hash值将value保存在bucket里。当计算出的hash值相同时怎么办呢,我们称之为Hash冲突,HashMap的做法是用链表和红黑树存储相同hash值的value。当Hash冲突的个数比较少时,使用链表,否则使用红黑树。

内部存储结构

HashMap类实现了Map< K, V>接口,主要包含以下几个方法:

  • V put(K key, V value)
  • V get(Object key)
  • V remove(Object key)
  • Boolean containsKey(Object key)

HashMap使用了一个内部类Node< K, V>来存储数据

我阅读的是Java 8的源码,在Java 8之前存储数据的内部类是Entry< K, V>,代码大体都是一样的

Node代码:

static class Node<K,V> implements Map.Entry<K,V> {
    final int hash;
    final K key;
    V value;
    Node<K,V> next;
    ...
}

可以看见Node类中除了键值对(key-value)以外,还有额外的两个数据:

  • hash : 这个是通过计算得到的散列值
  • next:指向另一个Node,这样HashMap可以像链表一样存储数据

因此可以知道,HashMap的结构大致如下:

我们可以将每个横向看成一个个的桶,每个桶中存放着具有相同Hash值的Node,通过一个list来存放每个桶。

内部变量

// 默认容量大小
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;
// 转换为二叉树的阀值
static final int TREEIFY_THRESHOLD = 8;
// 转换为二叉树的最低阀值
static final int UNTREEIFY_THRESHOLD = 6;
// 二叉树最小容量
static final int MIN_TREEIFY_CAPACITY = 64;
// 哈希表
transient Node<K,V>[] table;
// 键值对的数量
transient int size;
// 记录HashMap结构改变次数,与HashMap的快速失败相关
transient int modCount;
// 扩容的阈值
int threshold;
// 装载因子
final float loadFactor;

常用方法

put操作

put函数大致的思路为:

  1. 对key的hashCode()做hash,然后再计算index;
  2. 如果没碰撞直接放到bucket里;
  3. 如果碰撞了,以链表的形式存在buckets后;
  4. 如果碰撞导致链表过长(大于等于TREEIFY_THRESHOLD),就把链表转换成红黑树;
  5. 如果节点已经存在就替换old value(保证key的唯一性)
  6. 如果bucket满了(超过load factor*current capacity),就要resize。
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; // resize()是调整table数组大小的,如果table数组为空或长度为0,重新调整大小
    if ((p = tab[i = (n - 1) & hash]) == null) // i = (n - 1) & hash | 这里计算出来的i值就是存放数组的位置,如果当前位置为空,则直接放入其中
        tab[i] = newNode(hash, key, value, null);
    else { // hash冲突
        Node<K,V> e; K k;
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k)))) // 如果hash相同,并且key值也相同,则找到存放位置
            e = p;
        else if (p instanceof TreeNode) // 如果当前p是二叉树,则放入二叉树中
            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); // 如果链表中的值大于TREEIFY_THRESHOLD - 1,则将链表转换成二叉树
                    break;
                }
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        if (e != null) { // 表示对于当前key早已经存在
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null) // 如果onlyIfAbsent为false或则oldValue为空,替换原来的值
                e.value = value;
            afterNodeAccess(e);
            return oldValue; // 返回原来的值
        }
    }
    ++modCount; // HashMap结构修改次数,主要用于判断迭代器中fail-fast
    if (++size > threshold) // 如果++size后的值比阀值大,则重新调整大小
        resize();
    afterNodeInsertion(evict);
    return null;
}

代码也比较容易看懂,值得注意的就是

else if (p instanceof TreeNode) // 如果当前p是二叉树,则放入二叉树中
     e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);

if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
    treeifyBin(tab, hash); // 如果链表中的值大于TREEIFY_THRESHOLD - 1,则将链表转换成二叉树

这是Java 8相对于以前版本一个比较大的改变。

在Java 8以前,每次产生hash冲突,就将记录追加到链表后面,然后通过遍历链表来查找。如果某个链表中记录过大,每次遍历的数据就越多,效率也就很低,复杂度为O(n);

在Java 8中,加入了一个常量TREEIFY_THRESHOLD=8,如果某个链表中的记录大于这个常量的话,HashMap会动态的使用一个专门的treemap实现来替换掉它。这样复杂度是O(logn),比链表的O(n)会好很多。

对于前面产生冲突的那些KEY对应的记录只是简单的追加到一个链表后面,这些记录只能通过遍历来进行查找。但是超过这个阈值后HashMap开始将列表升级成一个二叉树,使用哈希值作为树的分支变量,如果两个哈希值不等,但指向同一个桶的话,较大的那个会插入到右子树里。如果哈希值相等,HashMap希望key值最好是实现了Comparable接口的,这样它可以按照顺序来进行插入。

get操作

在理解了put之后,get就很简单了。大致思路如下:

  1. bucket里的第一个节点,直接命中;
  2. 如果有冲突,则通过key.equals(k)去查找对应的entry
  3. 若为树,则在树中通过key.equals(k)查找,O(logn);
  4. 若为链表,则在链表中通过key.equals(k)查找,O(n)。
public V get(Object key) {
    Node<K,V> e;
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}

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)))) // 如果hash相同并且key值一样则返回当前node
            return first;
        if ((e = first.next) != null) { 
            if (first instanceof TreeNode) // 如果当前node为二叉树,则在二叉树中查找
                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);
        }
    }
    return null;
}

Java面试题

赞(0) 打赏
未经允许不得转载:IDEA激活码 » (Java集合面试题)HashMap的实现原理

一个分享Java & Python知识的社区