HashMap源码详解上篇

154 阅读6分钟

作为一个合格的java开发工程师,源码是必不可少的,而hashmap更是重中之重,相比很多在秋招的同学面试经常会被问道hashmap吧!今天就来看一下hashmap的魅力!

数据结构

首先我们需要知道hashmap的数据结构:在jdk1.7之前是数组+链表,之所以会有链表的存在,则是因为hashmap中采用的是一种hash算法来进行定位,而hash算法会造成不同数据的hash值是一样的,则会进行链化,然后通过next进行遍历寻找指定的值。

Base1.8:之后推出了红黑树,目的就是解决链表过长导致的效率问题,如果链表的长度过长则会导致遍历的时间从O(1)变成了O(n),而推出红黑树则解决了这个问题。

从上图中可以看出链表的长度是达到一定长度后会进行树化变成红黑树的,其长度为8,至于为什么是8?这是因为经过研究发现一个hash槽的链表长度达到8的概率是百万分之6,也就是说发生hash碰撞8次的概率是很低的。补充一点,红黑树转链表的值是6,为了防止hash碰撞在8附近徘徊,而一直导致树化/链化。

接下来来看一下hashmap源码中的一些基本属性

	
   //初始化容量大小,也就是数组的默认大小
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

   // 容量的最大值
    static final int MAXIMUM_CAPACITY = 1 << 30;

     //负载因子,用于计算扩容阈值,默认为0.75
    static final float DEFAULT_LOAD_FACTOR = 0.75f;

    //树化条件,链表长度达到8
    static final int TREEIFY_THRESHOLD = 8;

    //取消树化,也就是红黑树转链表的值为6
    static final int UNTREEIFY_THRESHOLD = 6;

    //最小树形化容量阈值,当哈希表中的容量达到64才允许树化。
    //如果没有达到是不允许链表转红黑树的,而是进行扩容。
    static final int MIN_TREEIFY_CAPACITY = 64;
    
    //真正存放数据的数组,用transient防止序列化,存放在内存
    transient Node<K,V>[] table;

    //K-V键值对
    transient Set<Map.Entry<K,V>> entrySet;

    //map中的存放的数量大小包括链表
    transient int size;

    //修改的次数
    transient int modCount;

    //数组大小,loadFactor*数组容量
    int threshold;

    //加载因子
    final float loadFactor;

了解了基本属性以及解释之后来开始看每个方法,首先来看看构造方法

//传入初始容量大小以及加载因子大小
public HashMap(int initialCapacity, float loadFactor) {
		//如果传入的值小于0则会抛出异常
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: " +
                                               initialCapacity);
        //如果传入的容量大小大于了数组的最大容量则将容量变为最大容量
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
        //如果加载因子<=0或者为null的情况会抛出异常
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: " +
                                               loadFactor);
        //初始化
        this.loadFactor = loadFactor;
        //调用tableSizeFor方法会将扩容阈值变为2的次幂
        this.threshold = tableSizeFor(initialCapacity);
    }
    //保证了传入的容量大小都是2的次幂,作用是减少hash冲突
    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;
    }
    //传入一个map集合,将传入的Map的键值对加入
    public HashMap(Map<? extends K, ? extends V> m) {
    	//初始化加载因子为0.75
        this.loadFactor = DEFAULT_LOAD_FACTOR;
        putMapEntries(m, false);
    }
    //不带参数赋予默认值
    public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR; 
    }
    //调用第一个构造方法,传入设定的容量大小,和默认的加载因子0.75
    public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }

可以看到,无论我们传入的容量大小是多少都会变成大于这个数的最近的一个2的次幂数,为什么要进行这要的操作?

  • 为了替换掉模运算的效率低问题,因为一开始是采用hash值%数组大小进行定位的,2的次幂数的二进制数是只有一位为1其余为0,-1之后则第一位变成了0,后面的变成了1,这样进行(length-1)&hash的结果和模运算是一样的,并且与运算在操作系统中是最高效的。
  • 另外一个情况是为了减少hash冲突,可以对比一下容量大小为16/10时的情况,当容量大小为16时,lenth-1的二进制为01111,01001/01101/01100/01111四种不同hash值时进行&运算之后结果是不同的。而如果容量长度为10的话,-1之后的二进制位01001,相同的01001/01101/01111情况下,这三个结果是一致的,这样就增加了hash冲突。
  • 说到减少hash冲突,还有一个扰动算法是必须要知道的,其核心就是将hash值的高16位与低16位进行异或运算。为什么要这样进行运算,因为我们在进行定位时(length-1)&hash这个过程是只对hash的后16位进行了运算,将高16位也参与运算则是进一步降低了hash冲突。

接下来看一下hashmap的几大核心方法

首先是添加数据的方法


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

    /**
     * @param hash key的hash值
     * @param key ,K
     * @param value,V
     * @param onlyIfAbsent 如果是true,不改变已经存在的key的value值
     * @param evict
     * @return 如果结点已经存在,返回之前的value,否则返回null
     */
    final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        //tab:引用当前的hash散列表
        //p:散列表的元素
        //n:数组的长度
        //i:路由寻址结果
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        //如果数组为空或者数组长度=0,则进行扩容
        if ((tab = table) == null || (n = tab.length) == 0)
        	//resize()方法中有进行判断进行初始化的方法
            n = (tab = resize()).length;
        //i的路由寻址算法(数组长度-1)&hash,如果数组的这个位置没有数据的情况下创建一个头节点
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        //进入到这里则是发生了hash碰撞的情况了
        else {
        	//e用来临时存储一个node元素,k表示一个临时key
            Node<K,V> e; K k;
            //表示当前桶位的hash值和插入的key的hash值相等并且key值相等或者key不为空并且key等于当前的key,会发生替换
            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);
            //也不是红黑树,则是链表的情况下遍历链表尾插法/jdk1.7是头插法
            else {
                for (int binCount = 0; ; ++binCount) {
                    if ((e = p.next) == null) {
                    	//插入链表尾部
                        p.next = newNode(hash, key, value, null);
                        //判断是否要进行树化
                        if (binCount >= TREEIFY_THRESHOLD - 1) 
                            treeifyBin(tab, hash);
                        break;
                    }
                    //如果遍历过程中发现了hash相同并且key相等的情况或者key不为空并且与传进的key相等的情况则直接跳出遍历
                    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;
                //如果onlyIfAbsent为false会进行value覆盖,并且返回旧值
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        //维护一个记录修改的次数
        ++modCount;
        //判断是否需要进行扩容
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }

get方法,获取数据的方法

//根据key来寻址
public V get(Object key) {
        Node<K,V> e;
        return (e = getNode(hash(key), key)) == null ? null : e.value;
    }
	//传入key的hash值和key
    final Node<K,V> getNode(int hash, Object key) {
    	//tab:当前的散列表
        //first:代表当前桶位的头节点
        //e:代表临时node元素
        //n:数组的长度
        Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
  //如果当前哈希表不为空并且表的长度大于0并且key对应的桶位有头节点,则继续执行,否则直接返回null
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (first = tab[(n - 1) & hash]) != null) {
            //如果头节的hash等于key的hash并且头节点的key等于传入key等情况又或者key等于头节点可以直接返回头节点
            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);
                //遍历链表
                do {
                	//判断节点是否和key相等
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        //返回匹配的节点
                        return e;
                } while ((e = e.next) != null);
            }
        }
        //没找到返回null
        return null;
    }

先暂时分析hashmap的插入和获取数据的方法,下一篇会进一步分析hashmap的扩容方法。敬请期待,有所差异请指正!