HashMap

HashMap是Java Collection Framework重要成员,也是基于哈希表使用最多的Collection,以key-value形式存储数据,但由于其线程不安全性不用于多线程编程,但用途依然广泛,在JDK1.8上,HashMap的源码又加入了新的内容,可见其重要。这次就是基于JDK1.8的源码来理解HashMap的运行原理。

继承

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

这里可以看到HashMap是继承AbstractMap抽象类并且依赖Map接口的,而其中AbstractMap则是对Map接口进行了部分实现,主要基于迭代器。下面就是在AbstractMap中对get方法的实现。

public V get(Object key) {
    Iterator<Entry<K,V>> i = entrySet().iterator();
    if (key==null) {
        while (i.hasNext()) {
            Entry<K,V> e = i.next();
            if (e.getKey()==null)
                return e.getValue();
        }
    } else {
        while (i.hasNext()) {
            Entry<K,V> e = i.next();
            if (key.equals(e.getKey()))
                return e.getValue();
        }
    }
    return null;
}

构造和初始化

在了解HashMap的构造函数之前首先需要知道HashMap的数据结构,也就是如何去存储。HashMap是基于哈希表的,因此底层肯定有一个作为哈希表的数组。而了解数据结构哈希表的都知道,哈希表在存储过程中存在冲突的问题,即存在多个key的哈希值相同,HashMap的做法是链表或者红黑树的方法,将相同哈希值的元素以链的形式连接在哈希表对应的位置。
哈希表
这里需要注意的是关于红黑树的部分,在JDK1.7的部分是只有链表结构,但是由于链表过长的时候查找元素时间较长,在JDK1.8的时候加入了红黑树,当链表超过一定长度之后,链表就会转换成红黑树,便于查找和插入。
HashMap的构造函数有三种

  • HashMap() 默认初始容量(16)和默认加载因子(0.75)的空HashMap
  • HashMap(int initialCapacity) 指定初始容量和默认负载因子(0.75)的空HashMap
  • HashMap(int initialCapacity, float loadFactor) 指定初始容量和负载因子的空 HashMap

这里需要了解两个参数,initialCapacity(初始容量),指最开始创建哈希表数组的大小。loadFactor(负载因子),指哈希表扩容的阈值,一般是当前表中存有元素与哈希表容量相除的商。

put和get

从JDK1.8开始,HashMap的元素是以Node形式存在,主要结构如下

static class Node<K,V> implements Map.Entry<K,V> {
    final int hash;
    final K key;
    V value;
    Node<K,V> next;
    //类方法,这里省略
    .........
}

包括元素的hash值,key,value以及指向下一个元素的引用。

put

put方法是HashMap的重点,包含了阈值校验,链表到红黑树的调整以及初始开辟哈希表,都是在put方法中完成的。这里是我注释的源码

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;
//哈希表未创建或者长度为0时创建哈希表
//resize()是重建哈希表的方法
   if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
 //如果存入元素位置为空,创建一个node类,插入
   if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
//存入元素位置有元素
    else {
        Node<K,V> e; K k;
    //存在相同的key,替换原先的值
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
    //存储的链表已经变为红黑树,调用putTreeVal()方法去存入
        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) {
                //没有相同key元素,就最后加上该元素
                    p.next = newNode(hash, key, value, null);
		//链表过长的时候,转变成红黑树
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
                //找到相同key元素,替换value
                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) //true || --
                e.value = value;
	   //3.
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount;
//判断阈值,决定是否扩容
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

这里有简单的流程图表示这个过程
put
对于resize()的描述就不深入了,这里简单讲一下HashMap的扩容原则

else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // double threshold

这里可以看到,每次哈希表的扩容是在上一次的容量基础上乘以2。

get

get方法相对于put就简单很多,就是根据哈希值找到相应的元素链,然后遍历查找元素即可,源码如下

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))))
            return first;
         //非首元素,需要遍历元素链
        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);
        }
    }
    return null;
}

HashMap死循环

在JDK1.7多线程情况下HashMap存在死循环的情况,主要是由于扩容的过程是非线程安全的,并且在元素存入新表的过程中会出现元素倒置,由于插入过程,新插入元素总是插入到头部,源码如下

void transfer(Entry[] newTable)  {  
	Entry[] src = table;  
	int newCapacity = newTable.length;   
	for (int j = 0; j < src.length; j++) {  
    	Entry<K,V> e = src[j];  
    	if (e != null) {  
        	src[j] = null; 
        	//这里在做插入的过程中,总是将新插入元素放在链表头 
        	do {  
            	Entry<K,V> next = e.next;  
            	int i = indexFor(e.hash, newCapacity);  
            	e.next = newTable[i];  
            	newTable[i] = e;  
            	e = next;  
        	} while (e != null);  
    	}  
	}  
}

具体的过程用图来表示,假设有两个线程,同时到了扩容的阶段,线程一在while循环开始就挂起了,线程二执行结束
过程1
之后线程一开始执行,从e节点开始,但是注意由于线程二扩容的影响,此时next节点的下一个节点是e
过程2
之后再往下遍历的时候,发现又回到了key=3,出现了环
过程3
这个问题在JDK1.8中解决了,它用了新的插入方式

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;
	}
	//原索引+ oldCap位置
	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;
} 

由于扩容是在原容量基础上乘以2,那么hash码校验的时候会比原来的多一位,那么只需要比较这个位置是0还是1即可,是1那么就在原索引位置向后位移原容量大小即可,是0就不动。
扩容

原文地址:https://www.cnblogs.com/xudilei/p/6843338.html