HashMap 的实现原理

2017/04/16 Java

在JDK1.8中,HashMap的改进是一个很大的亮点,其源码也多次被提及,今天就让我们来聊一下它!

HashMap内部的数据结构是什么

HashMap的数据结构是哈希表,哈希表采用开放地址法和链地址法等来解决冲突问题,HashMap用的是链地址法,即数组加链表。每个数组元素上有一个链表结构,数据被Hash后,得到数组下标,把数据放在对应下标元素的链表上。另外,JDK1.8新增了红黑树,当链表过长时会将链表转成红黑树以实现O(logn)时间复杂度内查找。

HashMap的put方法执行过程是怎样的

在JDK1.8中,put方法的性能得到了大幅提升,我们这里以JDK1.8为例进行讲解:

  1. 判断键值对数组table是否为null或长度为0,如果是,执行resize()进行扩容

    Node是HashMap的内部类,实现了Map.Entry接口,本质就是一个键值对,table是一个元素为键值对的数组,如果执行put操作时判断table为空或长度为0,则调用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;
    		}
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) // 没超过最大值,则扩充为原来的两倍
    			newThr = oldThr << 1;
    	} else if (oldThr > 0) // 用可容纳键值对极限threshold表示初始容量,可通过构造函数设置threshold
    			newCap = oldThr;
    	else { // threshold的初始值为0时,采用默认初始容量,即16
    		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;
    	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 { // 保留顺序
    					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;
    }
    

    HashMap的table长度要求为2的n次方,这是非常规设计,常规是把桶大小设计为素数,素数导致冲突的概率小于合数,证明可参考这里。HashMap采用这种非常规设计,主要是为了取模和扩容时做优化。

  2. 根据key计算hash值得到插入索引i,如果table[i]还没有节点,新建节点添加并跳到第6步,否则执行下一步

    在JDK1.8中,HashMap的hash函数如下:

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

    HashMap只允许一条记录的键为null,允许多条记录的值为null。对于key为null的情况,hash的结果为0,如果key不为null,hash函数则是先取key的hashCode值,再通过移位异或运算得到hash值。

  3. 判断table[i]的首个元素是否和key相同,如果相同直接覆盖,否则执行下一步

    这里的相同指的是hashCode以及equals两个方法同时满足相同条件,也就是说,即使两个hashCode值相同的key,也可以被同时存储到HashMap中。

  4. 判断table[i]是否为treeNode,即table[i]是否为红黑树,如果是则在树中插入键值对,否则执行下一步

  5. 遍历table[i],判断链表长度是否大于8,大于8则把链表转换为红黑树,在红黑树中执行插入操作,否则进行链表的插入操作。遍历过程中若发现key已存在直接覆盖value即可

    HashMap将键值对划分到不同桶,桶数量通常比记录数稍大,这样每个桶的值会较少(最好是一个)。当通过key查找时,可以在O(1)内定位到某桶以及要找的对象。最坏情况下,所有key都映射到同个桶中,这样HashMap退化成了链表,查找时间从O(1)变成了O(n)。如果某个桶中的记录过大的话(我们设置阀值TREEIFY_THRESHOLD为8),HashMap会动态地将它替换成一个红黑树。这样会将时间复杂度从O(n)降为O(logn)。

  6. 插入成功后,判断实际存在的键值对数量size是否超过最大容量threshold,如果超过,进行扩容

HashMap和Hashtable有什么区别

  1. HashMap可以接受为null的key和value,而Hashtable不行
  2. HashMap是非线程安全的,而Hashtable是是线程安全的
  3. HashMap的迭代器Iterator是fail-fast迭代器,而Hashtable的enumerator迭代器不是fail-fast的。所谓fail-fast,就是其他线程不能修改正被Iterator遍历的集合里的对象
  4. Hashtable是线程安全的,所以单线程下比HashMap要慢。如果只需单一线程,那么使用HashMap好过Hashtable
  5. HashMap不能保证随着时间的推移Map中的元素次序是不变的

事实上,Hashtable是JDK1.0的产物,HashMap就是用来替代Hashtable的,所以在这两者之间优先选择HashMap。

HashMap和ConcurrentHashMap有什么区别

  1. ConcurrentHashMap是线程安全的,并发环境下不要额外同步,而HashMap是非线程安全的
  2. 可以用Collections.synchronizedMap(HashMap)包装HashMap作为同步容器,这时它的作用几乎与Hashtable一样,每次对Map做修改时都会锁住Map对象,而ConcurrentHashMap会基于并发等级划分Map达到线程安全,它只会锁操作的那段数据而不是整个Map
  3. ConcurrentHashMap在多线程下性能比做了同步的HashMap要好,但在单线程下,HashMap更好一点

Search

    Table of Contents