Java中的Hashmap

JDK7与JDK8中HashMap的实现

JDK7中的HashMap

HashMap底层维护一个数组,数组中的每一项都是一个Entryhtml

transient Entry<K,V>[] table;

咱们向 HashMap 中所放置的对象其实是存储在该数组当中; java

而Map中的key,value则以Entry的形式存放在数组中算法

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

而这个Entry应该放在数组的哪个位置上(这个位置一般称为位桶或者hash桶,即hash值相同的Entry会放在同一位置,用链表相连),是经过key的hashCode来计算的。shell

final int hash(Object k) {
        int h = 0;
        h ^= k.hashCode();

        h ^= (h >>> 20) ^ (h >>> 12);
        return h ^ (h >>> 7) ^ (h >>> 4);
    }

经过hash计算出来的值将会使用indexFor方法找到它应该所在的table下标:数组

static int indexFor(int h, int length) {
        return h & (length-1);
    }

这个方法其实至关于对table.length取模。安全

当两个key经过hashCode计算相同时,则发生了hash冲突(碰撞),HashMap解决hash冲突的方式是用链表。数据结构

当发生hash冲突时,则将存放在数组中的Entry设置为新值的next(这里要注意的是,好比A和B都hash后都映射到下标i中,以前已经有A了,当map.put(B)时,将B放到下标i中,A则为B的next,因此新值存放在数组中,旧值在新值的链表上)多线程

示意图:并发

因此当hash冲突不少时,HashMap退化成链表。app

总结一下map.put后的过程:

当向 HashMap 中 put 一对键值时,它会根据 key的 hashCode 值计算出一个位置, 该位置就是此对象准备往数组中存放的位置。 

若是该位置没有对象存在,就将此对象直接放进数组当中;若是该位置已经有对象存在了,则顺着此存在的对象的链开始寻找(为了判断是不是否值相同,map不容许<key,value>键值对重复), 若是此链上有对象的话,再去使用 equals方法进行比较,若是对此链上的每一个对象的 equals 方法比较都为 false,则将该对象放到数组当中,而后将数组中该位置之前存在的那个对象连接到此对象的后面。 

值得注意的是,当key为null时,都放到table[0]中

private V putForNullKey(V value) {
        for (Entry<K,V> e = table[0]; e != null; e = e.next) {
            if (e.key == null) {
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                return oldValue;
            }
        }
        modCount++;
        addEntry(0, null, value, 0);
        return null;
    }

当size大于threshold时,会发生扩容。 threshold等于capacity*load factor

 

void addEntry(int hash, K key, V value, int bucketIndex) {
        if ((size >= threshold) && (null != table[bucketIndex])) {
            resize(2 * table.length);
            hash = (null != key) ? hash(key) : 0;
            bucketIndex = indexFor(hash, table.length);
        }

        createEntry(hash, key, value, bucketIndex);
    }

jdk7中resize,只有当 size>=threshold而且 table中的那个槽中已经有Entry时,才会发生resize。即有可能虽然size>=threshold,可是必须等到每一个槽都至少有一个Entry时,才会扩容。还有注意每次resize都会扩大一倍容量

JDK8中的HashMap

一直到JDK7为止,HashMap的结构都是这么简单,基于一个数组以及多个链表的实现,hash值冲突的时候,就将对应节点以链表的形式存储。

这样子的HashMap性能上就抱有必定疑问,若是说成百上千个节点在hash时发生碰撞,存储一个链表中,那么若是要查找其中一个节点,那就不可避免的花费O(N)的查找时间,这将是多么大的性能损失。这个问题终于在JDK8中获得了解决。再最坏的状况下,链表查找的时间复杂度为O(n),而红黑树一直是O(logn),这样会提升HashMap的效率。

JDK7中HashMap采用的是位桶+链表的方式,即咱们常说的散列链表的方式,而JDK8中采用的是位桶+链表/红黑树(有关红黑树请查看红黑树)的方式,也是非线程安全的。当某个位桶的链表的长度达到某个阀值的时候,这个链表就将转换成红黑树。

JDK8中,当同一个hash值的节点数不小于8时,将再也不以单链表的形式存储了,会被调整成一颗红黑树(上图中null节点没画)。这就是JDK7与JDK8中HashMap实现的最大区别。

接下来,咱们来看下JDK8中HashMap的源码实现。

JDK中Entry的名字变成了Node,缘由是和红黑树的实现TreeNode相关联。

transient Node<K,V>[] table;

当冲突节点数不小于8-1时,转换成红黑树。

static final int TREEIFY_THRESHOLD = 8;

以put方法在JDK8中有了很大的改变

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;
	//若是当前map中无数据,执行resize方法。而且返回n
        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;
	    //1.若是当前节点是TreeNode类型的数据,执行putTreeVal方法
            else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else {
		//仍是遍历这条链子上的数据,跟jdk7没什么区别
                for (int binCount = 0; ; ++binCount) {
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
			//2.完成了操做后多作了一件事情,判断,而且可能执行treeifyBin方法
                        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;
                }
            }
            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();
	    //4.
        afterNodeInsertion(evict);
        return null;
    }

treeifyBin()就是将链表转换成红黑树。

以前的indefFor()方法消失 了,直接用(tab.length-1)&hash,因此看到这个,表明的就是数组的下角标。

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

 

HashMap线程不安全的体现

为何说HashMap是线程不安全的呢?它在多线程环境下,会发生什么状况呢?

1. resize死循环

咱们都知道HashMap初始容量大小为16,通常来讲,当有数据要插入时,都会检查容量有没有超过设定的thredhold,若是超过,须要增大Hash表的尺寸,可是这样一来,整个Hash表里的元素都须要被重算一遍。这叫rehash,这个成本至关的大。

void resize(int newCapacity) {
        Entry[] oldTable = table;
        int oldCapacity = oldTable.length;
        if (oldCapacity == MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return;
        }

        Entry[] newTable = new Entry[newCapacity];
        transfer(newTable, initHashSeedAsNeeded(newCapacity));
        table = newTable;
        threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
void transfer(Entry[] newTable, boolean rehash) {
        int newCapacity = newTable.length;
        for (Entry<K,V> e : table) {
            while(null != e) {
                Entry<K,V> next = e.next;
                if (rehash) {
                    e.hash = null == e.key ? 0 : hash(e.key);
                }
                int i = indexFor(e.hash, newCapacity);
                e.next = newTable[i];
                newTable[i] = e;
                e = next;
            }
        }
}

大概看下transfer:

  1. 对索引数组中的元素遍历
  2. 对链表上的每个节点遍历:用 next 取得要转移那个元素的下一个,将 e 转移到新 Hash 表的头部,使用头插法插入节点。
  3. 循环2,直到链表节点所有转移
  4. 循环1,直到全部索引数组所有转移

通过这几步,咱们会发现转移的时候是逆序的。假如转移前链表顺序是1->2->3,那么转移后就会变成3->2->1。这时候就有点头绪了,死锁问题不就是由于1->2的同时2->1形成的吗?因此,HashMap 的死锁问题就出在这个transfer()函数上。

1.1 单线程 rehash 详细演示

单线程状况下,rehash 不会出现任何问题:

  • 假设hash算法就是最简单的 key mod table.length(也就是数组的长度)。
  • 最上面的是old hash 表,其中的Hash表的 size = 2, 因此 key = 3, 7, 5,在 mod 2之后碰撞发生在 table[1]
  • 接下来的三个步骤是 Hash表 resize 到4,并将全部的 <key,value> 从新rehash到新 Hash 表的过程

如图所示:

1.2 多线程 rehash 详细演示

为了思路更清晰,咱们只将关键代码展现出来

while(null != e) {
    Entry<K,V> next = e.next;
    e.next = newTable[i];
    newTable[i] = e;
    e = next;
}
  1. Entry<K,V> next = e.next;——由于是单链表,若是要转移头指针,必定要保存下一个结点,否则转移后链表就丢了
  2. e.next = newTable[i];——e 要插入到链表的头部,因此要先用 e.next 指向新的 Hash 表第一个元素(为何不加到新链表最后?由于复杂度是 O(N))
  3. newTable[i] = e;——如今新 Hash 表的头指针仍然指向 e 没转移前的第一个元素,因此须要将新 Hash 表的头指针指向 e
  4. e = next——转移 e 的下一个结点

假设这里有两个线程同时执行了put()操做,并进入了transfer()环节

while(null != e) {
    Entry<K,V> next = e.next; //线程1执行到这里被调度挂起了
    e.next = newTable[i];
    newTable[i] = e;
    e = next;
}

那么如今的状态为:

从上面的图咱们能够看到,由于线程1的 e 指向了 key(3),而 next 指向了 key(7),在线程2 rehash 后,就指向了线程2 rehash 后的链表。

而后线程1被唤醒了:

  1. 执行e.next = newTable[i],因而 key(3)的 next 指向了线程1的新 Hash 表,由于新 Hash 表为空,因此e.next = null
  2. 执行newTable[i] = e,因此线程1的新 Hash 表第一个元素指向了线程2新 Hash 表的 key(3)。好了,e 处理完毕。
  3. 执行e = next,将 e 指向 next,因此新的 e 是 key(7)

而后该执行 key(3)的 next 节点 key(7)了:

  1. 如今的 e 节点是 key(7),首先执行Entry<K,V> next = e.next,那么 next 就是 key(3)了
  2. 执行e.next = newTable[i],因而key(7) 的 next 就成了 key(3)
  3. 执行newTable[i] = e,那么线程1的新 Hash 表第一个元素变成了 key(7)
  4. 执行e = next,将 e 指向 next,因此新的 e 是 key(3)

这时候的状态图为:

而后又该执行 key(7)的 next 节点 key(3)了:

  1. 如今的 e 节点是 key(3),首先执行Entry<K,V> next = e.next,那么 next 就是 null
  2. 执行e.next = newTable[i],因而key(3) 的 next 就成了 key(7)
  3. 执行newTable[i] = e,那么线程1的新 Hash 表第一个元素变成了 key(3)
  4. 执行e = next,将 e 指向 next,因此新的 e 是 key(7)

这时候的状态如图所示:

很明显,环形链表出现了!!固然,如今尚未事情,由于下一个节点是 null,因此transfer()就完成了,等put()的其他过程搞定后,HashMap 的底层实现就是线程1的新 Hash 表了。

2. fail-fast

若是在使用迭代器的过程当中有其余线程修改了map,那么将抛出ConcurrentModificationException,这就是所谓fail-fast策略。

这个异常意在提醒开发者及早意识到线程安全问题,具体缘由请查看ConcurrentModificationException的缘由以及解决措施

 

顺便再记录一个HashMap的问题:

为何String, Interger这样的wrapper类适合做为键? String, Interger这样的wrapper类做为HashMap的键是再适合不过了,并且String最为经常使用。由于String是不可变的,也是final的,并且已经重写了equals()和hashCode()方法了。其余的wrapper类也有这个特色。不可变性是必要的,由于为了要计算hashCode(),就要防止键值改变,若是键值在放入时和获取时返回不一样的hashcode的话,那么就不能从HashMap中找到你想要的对象。不可变性还有其余的优势如线程安全。若是你能够仅仅经过将某个field声明成final就能保证hashCode是不变的,那么请这么作吧。由于获取对象的时候要用到equals()和hashCode()方法,那么键对象正确的重写这两个方法是很是重要的。若是两个不相等的对象返回不一样的hashcode的话,那么碰撞的概率就会小些,这样就能提升HashMap的性能。

 

Java HashMap的死循环

在淘宝内网里看到同事发了贴说了一个 CPU 被 100% 的线上故障,而且这个事发生了不少次,缘由是在 Java 语言在并发状况下使用 HashMap 形成 Race Condition,从而致使死循环。这个事情我四、5 年前也经历过,原本以为没什么好写的,由于 Java 的 HashMap 是非线程安全的,因此在并发下必然出现问题。可是,我发现近几年,不少人都经历过这个事(在网上查“HashMap Infinite Loop”能够看到不少人都在说这个事)因此,以为这个是个广泛问题,须要写篇疫苗文章说一下这个事,而且给你们看看一个完美的“Race Condition”是怎么造成的。

问题的症状

从前咱们的 Java 代码由于一些缘由使用了 HashMap 这个东西,可是当时的程序是单线程的,一切都没有问题。后来,咱们的程序性能有问题,因此须要变成多线程的,因而,变成多线程后到了线上,发现程序常常占 了 100% 的 CPU,查看堆栈,你会发现程序都 Hang 在了 HashMap.get ()这个方法上了,重启程序后问题消失。可是过段时间又会来。并且,这个问题在测试环境里可能很难重现。

咱们简单的看一下咱们本身的代码,咱们就知道 HashMap 被多个线程操做。而 Java 的文档说 HashMap 是非线程安全的,应该用 ConcurrentHashMap。

可是在这里咱们能够来研究一下缘由。

Hash 表数据结构

我须要简单地说一下 HashMap 这个经典的数据结构。

HashMap 一般会用一个指针数组(假设为 table[])来作分散全部的 key,当一个 key 被加入时,会经过 Hash 算法经过 key 算出这个数组的下标i,而后就把这个插到 table[i]中,若是有两个不一样的 key 被算在了同一个i,那么就叫冲突,又叫碰撞,这样会在 table[i]上造成一个链表。

咱们知道,若是 table[]的尺寸很小,好比只有 2 个,若是要放进 10 个 keys 的话,那么碰撞很是频繁,因而一个O(1) 的查找算法,就变成了链表遍历,性能变成了O(n),这是 Hash 表的缺陷(可参看《Hash Collision DoS 问题》)。

因此,Hash 表的尺寸和容量很是的重要。通常来讲,Hash 表这个容器当有数据要插入时,都会检查容量有没有超过设定的 thredhold,若是超过,须要增大 Hash 表的尺寸,可是这样一来,整个 Hash 表里的无素都须要被重算一遍。这叫 rehash,这个成本至关的大。

相信你们对这个基础知识已经很熟悉了。

HashMap 的 rehash 源代码

下面,咱们来看一下 Java 的 HashMap 的源代码。

Put 一个 Key,Value 对到 Hash 表中:

public V put (K key, V value)
{
    ......
    //算 Hash 值 int hash = hash (key.hashCode ());
    int i = indexFor (hash, table.length);
    //若是该 key 已被插入,则替换掉旧的 value (连接操做) for (Entry<K,V> e = table[i]; e != null; e = e.next) {
        Object k;
        if (e.hash == hash && ((k = e.key) == key || key.equals (k))) {
            V oldValue = e.value;
            e.value = value;
            e.recordAccess (this);
            return oldValue;
        }
    }
    modCount++;
    //该 key 不存在,须要增长一个结点     addEntry (hash, key, value, i);
    return null;
}

检查容量是否超标

void addEntry (int hash, K key, V value, int bucketIndex)
{
    Entry<K,V> e = table[bucketIndex];
    table[bucketIndex] = new Entry<K,V>(hash, key, value, e);
    //查看当前的 size 是否超过了咱们设定的阈值 threshold,若是超过,须要 resize if (size++ >= threshold)
        resize (2 * table.length);
}

新建一个更大尺寸的 hash 表,而后把数据从老的 Hash 表中迁移到新的 Hash 表中。

void resize (int newCapacity)
{
    Entry[] oldTable = table;
    int oldCapacity = oldTable.length;
    ......
    //建立一个新的 Hash Table Entry[] newTable = new Entry[newCapacity];
    //将 Old Hash Table 上的数据迁移到 New Hash Table 上     transfer (newTable);
    table = newTable;
    threshold = (int)(newCapacity * loadFactor);
}

迁移的源代码,注意高亮处:

void transfer (Entry[] newTable)
{
    Entry[] src = table;
    int newCapacity = newTable.length;
    //下面这段代码的意思是:
    //  从 OldTable 里摘一个元素出来,而后放到 NewTable 中 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);
        }
    }
}

 好了,这个代码算是比较正常的。并且没有什么问题。

正常的 ReHash 的过程

画了个图作了个演示。

  • 我假设了咱们的 hash 算法就是简单的用 key mod 一下表的大小(也就是数组的长度)。
  • 最上面的是 old hash 表,其中的 Hash 表的 size=2, 因此 key = 3, 7, 5,在 mod 2 之后都冲突在 table[1]这里了。
  • 接下来的三个步骤是 Hash 表 resize 成4,而后全部的 从新 rehash 的过程

并发下的 Rehash

1)假设咱们有两个线程。我用红色和浅蓝色标注了一下。

咱们再回头看一下咱们的 transfer 代码中的这个细节:

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);

而咱们的线程二执行完成了。因而咱们有下面的这个样子。

注意,由于 Thread1 的 e 指向了 key (3),而 next 指向了 key (7),其在线程二 rehash 后,指向了线程二重组后的链表。咱们能够看到链表的顺序被反转后。

 2)线程一被调度回来执行。

  • 先是执行 newTalbe[i] = e;
  • 而后是 e = next,致使了e指向了 key (7),
  • 而下一次循环的 next = e.next 致使了 next 指向了 key (3)

3)一切安好。

线程一接着工做。把 key (7) 摘下来,放到 newTable[i]的第一个,而后把e和 next 往下移

4)环形连接出现。

e.next = newTable[i] 致使  key (3) .next 指向了 key (7)

注意:此时的 key (7) .next 已经指向了 key (3), 环形链表就这样出现了。

 因而,当咱们的线程一调用到,HashTable.get (11) 时,悲剧就出现了——Infinite Loop。

 

其它

有人把这个问题报给了 Sun,不过 Sun 不认为这个是一个问题。由于 HashMap 原本就不支持并发。要并发就用 ConcurrentHashmap

 http://bugs.sun.com/bugdatabase/view_bug.do?bug_id=6423457

我在这里把这个事情记录下来,只是为了让你们了解并体会一下并发环境下的危险。

 

原文:

JDK7与JDK8中HashMap的实现

谈谈HashMap线程不安全的体现

Java HashMap的死循环

相关文章
相关标签/搜索