透析HashMap-源码分析

闲谈       

        HashMap是一个用来保存<Key,Value>数据的类,凭借其极其优秀的效率,一直是编程中最常用的基础类。一直以来就是面试的热点,感觉不问问HashMap的原理, 都不好意思是Java面试了。不好好看下HashMap,怎么能说准备好了面试呢。

概述

       我们都知道HashMap是基于数组+链表的数据结构,可以高效的插入和查找数据,克服数组插入数据需要移位,而链表查找数据需要遍历的缺点,结构如下图。


通过存储的Key的哈希值来计算<Key,Value>存储的槽位(数组上的位置),槽位上已经有其他<Key,Value>,则称为是哈希冲突。通过链表连接起来。那么问题来了,极端情况下,所有的key计算结果全都在一个槽位上,那会是什么情况?实际上就是弱化成一个链表,这时候查询的时间复杂度就为O(n)


为避免出现这种情况,链表长度超过一定长度(默认为8),就直接生成红黑树(一种近似平衡二叉树的结构)这样的话最差的情况下,查找的时间复杂度是O(logn)


常见面试题

1、HashMap的容量为什么会是2的幂次方?

通过哈希值运算存储的槽位时取余可以通过位运算来实现,效率高。

2、HashMap中负载因子为什么是0.75?

关于这个问题,源码里有段注释“As a general rule, the default load factor (.75) offers a good * tradeoff between time and space costs.”意思是说负载因子0.75能在时间和空间上去得很好平衡。负载因子太高了,容易造成冲突。太小了,空间利用率又不高,还得频繁扩容。那么为什么不是0.6,0.8呢?我在网上看到一个解释觉得有一定道理,因为容量是2幂次方,容量*负载因子(扩容的临界值)刚好会是整数。

3、HashMap链表转化为数为什么是8?

还是从源码中的注释找答案,“Because TreeNodes are about twice the size of regular nodes”,树节点占用的空间是常规节点的两倍,“Ideally, under random hashCodes, the frequency of  nodes in bins follows a Poisson distribution with a parameter of about 0.5 on average for the default resizing  threshold of 0.75。在随机hashCode下,节点在槽中的分布符合泊松分布,在负载因子为0.75下,泊松分布参数为0.5。槽中节点的个数及对应的概率如下

0: 0.60653066 

1: 0.30326533 

2: 0.07581633 

3: 0.01263606 

4: 0.00157952 

5: 0.00015795 

6: 0.00001316 

7: 0.00000094 

8: 0.00000006

可以看到,但链表长度为8个的时候概率是非常非常低的,所以取树化为8是为了降低树化的概率,同时当链表太长时有可以通过转换为树,提高查找效率(空间换时间)。

4、HashMap的Key或Value是否可以为null

可以,HashMap的Key或Value都可以为null,key为null的<key,value>键值对存储在槽位为0的位置。

源码解析

直接打开代码,来看看hashmap是怎么实现的。⚠️注意:本文的分析基于JDK1.8

一、常量及成员变量

//默认初始化的容量16 二进制"10000",即数组的默认长度,或者叫做“槽”的长度
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;

/**
"泊松分布",负载因子,存储元素超过该比例即扩容,默认0.75
    即存储的元素个数大于槽容量的0.75倍就扩容槽位
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;

//最大容量 2^30
static final int MAXIMUM_CAPACITY = 1 << 30;

//链表长度大于该值时转成黑树
static final int TREEIFY_THRESHOLD = 8;

//红黑树原生个数小于该值时转成链表
static final int UNTREEIFY_THRESHOLD = 6;

//存储元素的数组,或者叫槽
transient Node<K,V>[] table;

//目前存放元素的个数
transient int size;

//修改的次数,可认为当前的版本
transient int modCount;
/*
扩容的阀值=容量*因子,当存储的<key,value>数超过该变量,则扩容.
未初始化前,这个值会存储首次初始化时的容量,构造函数中赋值*/
int threshold;
//因子
final float loadFactor;复制代码

以上各变量暂时不知道,可以先不理,先混个眼熟,一会代码里用到会做说明。

二、构造函数

/**
	构造函数,传入初始容量,及因子
*/
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);
}

/*******************************************************************
    这个方法的作用就是计算容量,这个方法返回 “大于传进来参数的最小‘2的幂次方’”,
    为什么是2的幂次方的,你先不要管,只要知道hashmap的容量是2的幂次方即可...
    是不是有点绕?
    例子 参数14,返回:2^4=16   
        参数 16<cap<=32,则返回2^5=32
********************************************************************/
static final int tableSizeFor(int cap) {
    /**
    这么一大串位移看起来很头疼?作用就是让参数二进制最高为1的位后面的所有位数变为1
    举个例子cap为 14则二进制为
    cap为14	0000 0000 0000 0000 0000 0000 0000 1110   
    n为cap-1	0000 0000 0000 0000 0000 0000 0000 1101
    */
    int n = cap - 1;
    n |= n >>> 1;
    n |= n >>> 2;
    n |= n >>> 4;
    n |= n >>> 8;
    n |= n >>> 16;
    //上面位移结果  0000 0000 0000 0000 0000 0000 0000 1111 
    //这个结果+1是  0000 0000 0000 0000 0000 0000 0001 0000 就是2^4=16
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

/*******************************************************************************
位移解析    
       为什么这里要-1?这里-1其实是作用在传进来的数已经是2^n
       如16  0001 0000 减1 会是0000 1111,可以与传16以下统一,位移最后的结果都是 0000 1111
*********************************************************************************/
    int n = cap - 1;
    n |= n >>> 1;
    n |= n >>> 2;
    n |= n >>> 4;
    n |= n >>> 8;
    n |= n >>> 16;
还有点懵?再给个例子,自己去意会吧
0010 0000 0000 0000 0000 0000 0000 0001   参数2^29+1
0010 0000 0000 0000 0000 0000 0000 0000   -1后结果
0011 0000 0000 0000 0000 0000 0000 0000   移1位再|原来数据
0011 1100 0000 0000 0000 0000 0000 0000   移2位再|原来数据
0011 1111 1100 0000 0000 0000 0000 0000   移4位再|原来数据
0011 1111 1111 1111 1100 0000 0000 0000   移8位再|原来数据
0011 1111 1111 1111 1111 1111 1111 1111   移16位后再|原来数据
0100 0000 0000 0000 0000 0000 0000 0000   +1后得到的结果2^30
最后的结果是否就是大于参数的最小幂次方?2^30 就是大于2^29+1的最小幂次方
复制代码

传初始容量构造函数:调用了上面的构造函数,因子为默认的0.75

public HashMap(int initialCapacity) {
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}复制代码

无参数构造函数,所有参数使用默认值

public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}复制代码

通过传进来一个Map来初始化HashMap

public HashMap(Map<? extends K, ? extends V> m) {
    //因子使用默认值
    this.loadFactor = DEFAULT_LOAD_FACTOR;
    //将map复制到当前map中
    putMapEntries(m, false);
}

final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
    int s = m.size();
    if (s > 0) {
        //1、槽还未初始化,计算要初始化的槽大小
        //2、槽已经初始化且容量不够,则通过resize()扩容
        if (table == null) {
            float ft = ((float)s / loadFactor) + 1.0F;
            int t = ((ft < (float)MAXIMUM_CAPACITY) ?
                     (int)ft : MAXIMUM_CAPACITY);
            if (t > threshold)
                threshold = tableSizeFor(t);
        }
        else if (s > threshold)
            resize();
        //将源Map中的key、value存储到当前Map中
        for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) {
            K key = e.getKey();
            V value = e.getValue();
            putVal(hash(key), key, value, false, evict);
        }
    }
}
/********************************扩容函数********************************************/
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) {
        /*    
              当前容量已经是最大容量,直接返回
              否则直接容量扩展为原来的2倍
        */
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        } else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // double threshold
    }
    //未初始化过,且初始化容量已经计算过,则容量设为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;
    //定义新容量的槽,重新计算<key,value>的位置
    @SuppressWarnings({"rawtypes","unchecked"})
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;
    if (oldTab != null) {
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            if ((e = oldTab[j]) != null) {
                oldTab[j] = null;
                /**
                如果槽位没有链表(红黑树),则直接计算槽位存储的<key,value>新位置,
                否则是链表则遍历链表,重新计算链表上所有<key,value>的位置,
                红黑树同样做遍历及计算。
                这里可以看到计算位置只需要(key的哈希值)按位与(容量-1),
                其实就是hash值对容量取余,这就是容量是2的幂次方的好处,计算取余非常方便。
                */
                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;
                        /**
                        这里的位置计算有一个小技巧,只“按位与”容量,
                        为0则槽位不变,为1则新槽位=(旧槽位位置+旧容量)*/
                        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;
}

复制代码

阶段总结

HashMap总共有4个构造函数,分别可以传入容量、因子、Map。1、默认情况下,初始化容量会是16,因子会是0.752、如果传入容量,则初始化的容量会是大于传入的容量的 最小“2的幂次方”。

往HashMap中增删查数据

一)、添加

添加一个<key,value>
public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}
/**
    这里有个点需要说明以下,
    1、hashmap不是直接使用对象的哈希值,
    而是会让高16为按位异或低16位,这是为了尽量让32位的hash值都能在计算槽位时起作用,减少冲突。
    2、如果key是null,则hash值会取0,也就是key位null,则会存储在槽位0的位置。
*/
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

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;
    //通过hash计算存储位置,如果位置上没有节点,则直接将节点放入该位置,槽位个数n为2的幂次方
    //的好处就在这里,这里哈希值对槽大小计算余数,即要存储的槽位置,通过按为与即可实现。
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {
        Node<K,V> e; K k;
      //如果计算出的位置有节点,且节点的key等于要添加节点的key,先不做什么操作,只把已有节点保存到e
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        //如果是树结构,往红黑树中增加节点。同样,不做解析
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        /**
        链表结构,则遍历链表,直到链表尾部,或者链表中已经有相同的key。
        如果遍历到链表尾部,则新增新节点,如果key已经在链表中,则停止遍历*/
        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;
            }
        }
        //key已经存在于Map中,直接替换其value值
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            //移动节点到链表尾部
            afterNodeAccess(e);
            return oldValue;
        }
    }
    //Map版本号+1
    ++modCount;
    //超过阀值,直接扩容
    if (++size > threshold)
        resize();
    //这个方法,HashMap没做操作,主要是给扩展子类来使用
    afterNodeInsertion(evict);
    return null;
}复制代码

二)、HashMap中通过key获取value

/**通过key,获取value*/
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) {
        //槽位上节点first,如果key等于first的key,直接返回first
        if (first.hash == hash && // always check first node
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        //不等于槽位上节点,且槽位还有链表结构或红黑树结构,则在红黑树或链表中查找
        if ((e = first.next) != null) {
            //红黑树结构
            if (first instanceof TreeNode)
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            //链表结构,链表结构,直接往后遍历链表,直到找到节点key等于传进来的key或者链表结束
            do {
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;
}复制代码

三、删除HashMap中的节点

final Node<K,V> removeNode(int hash, Object key, Object value,
                               boolean matchValue, boolean movable) {
    Node<K,V>[] tab; Node<K,V> p; int n, index;
    //通过hashcode找到存储的槽位
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (p = tab[index = (n - 1) & hash]) != null) {
        Node<K,V> node = null, e; K k; V v;
        //如果槽位上的节点key等于要删除的key,则将节点记录到node
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            node = p;
        //否则,槽上面的节点还有下一个节点
        else if ((e = p.next) != null) {
            //如果是树结构
            if (p instanceof TreeNode)
                node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
            //如果是链表、遍历链表,直到找到跌点或者找到链表尾部
            else {
                do {
                    if (e.hash == hash &&
                        ((k = e.key) == key ||
                         (key != null && key.equals(k)))) {
                        node = e;
                        break;
                    }
                    p = e;
                } while ((e = e.next) != null);
            }
        }
        //判断是否找到节点
        if (node != null && (!matchValue || (v = node.value) == value ||
                             (value != null && value.equals(v)))) {
            //如果是树,删除数中的节点
            if (node instanceof TreeNode)
                ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
            /**
            node为要删除的节点,p为要删除节点的删一个节点,
            如果要删的是槽位置,则p跟node都指向槽位上的节点*/
            else if (node == p)
                tab[index] = node.next;
            else
                p.next = node.next;
            //版本更改
            ++modCount;
            --size;
            afterNodeRemoval(node);
            return node;
        }
    }
    return null;
}复制代码

阶段总结

至此,增删查都已经解析完毕,代码相对也比较简单,就是通过hash计算出槽位,再顺槽位上的链表或者树查找元素。注意点:1、HashMap中key跟vlue是可以位null的,key为null会存储在槽位为0的位置。2、可以看到,增删都没有做任何数据同步或者锁,所以HashMap是非线程安全的。

版本号用武之地

通过HashMap对象,可以获取到key的集合还有Value的集合,或者节点的集合,通过集合Iterator迭代器可以遍历HashMap中所有的key、value或者节点。

//获取key的集合、value的集合,还有节点的集合方法如下
hashMap.keySet();
hashMap.values();
hashMap.entrySet();
//再来看看这些集合共用的迭代器
abstract class HashIterator {
    Node<K,V> next;        // next entry to return
    Node<K,V> current;     // current entry
    int expectedModCount;  // for fast-fail
    int index;             // current slot

    HashIterator() {
        expectedModCount = modCount;
        //.....省略不重要代码
    }

    public final boolean hasNext() {
        return next != null;
    }

    final Node<K,V> nextNode() {
        Node<K,V>[] t;
        Node<K,V> e = next;
        /*************关键代码*****************
        迭代器创建的时候会保存HashMap的版本,一旦发现当前的HashMap版本
        与创建迭代器时候的HashMap版本不一致,则说明遍历期间,
        HashMap被其他线程修改了,直接抛出一个ConcurrentModificationException异常
        **/
        if (modCount != expectedModCount)
            throw new ConcurrentModificationException();
        if (e == null)
            throw new NoSuchElementException();
        if ((next = (current = e).next) == null && (t = table) != null) {
            do {} while (index < t.length && (next = t[index++]) == null);
        }
        return e;
    }
    //......省略不重要代码
}复制代码

阶段总结:HashMap中有个变量,用来记录HashMap的版本号。HashMap迭代器对于多线程修改的反应是“快速失败”的方法。迭代过程,一旦发现HashMap被修改了,迭代元素就直接抛出异常。


猜你喜欢

转载自juejin.im/post/5d412a5be51d45620e0b99af
今日推荐