Java-HashMap

Map的结构:

HashMap:它根据键的hashCode值存储数据,大多数情况下可以直接定位到它的值,因而具有很快的访问速度,但遍历顺序却是不确定的。 HashMap最多只允许一条记录的键为null,允许多条记录的值为null。HashMap非线程安全,即任一时刻可以有多个线程同时写HashMap,可能会导致数据的不一致。如果需要满足线程安全,可以用 Collections的synchronizedMap方法使HashMap具有线程安全的能力,或者使用ConcurrentHashMap。 

在HashMap中,哈希桶数组table的长度length大小必须为2的n次方(一定是合数),这是一种非常规的设计,常规的设计是把桶的大小设计为素数。相对来说素数导致冲突的概率要小于合数。Hashtable初始化桶大小为11,就是桶大小设计为素数的应用(Hashtable扩容后不能保证还是素数)。HashMap采用这种非常规设计,主要是为了在取模和扩容时做优化,同时为了减少冲突,HashMap定位哈希桶索引位置时,也加入了高位参与运算的过程。

但是即使是最好的hash函数和负载因子,还是会出现hash冲突导致链表过长,所以在1.8后引入了红黑树,长度默认超过8时链表转换为红黑树,利用红黑树快速增删改查的特点提高HashMap的性能,其中会用到红黑树的插入、删除、查找等算法,有兴趣可以自己去看一下这个数据结构。

存储结构:数组+链表+红黑树(jdk8)

 

HashMap的构造函数:

    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;
// tableSizeFor会得到离initialCapacity最近的2的整数次幂的值
this.threshold = tableSizeFor(initialCapacity); } public HashMap(int initialCapacity) { this(initialCapacity, DEFAULT_LOAD_FACTOR); } public HashMap() { this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted } public HashMap(Map<? extends K, ? extends V> m) { this.loadFactor = DEFAULT_LOAD_FACTOR; putMapEntries(m, false); }
    static final int tableSizeFor(int cap) {
// 返回里cap最近的2的整数次幂的数值
// 比如cap为8,二进制为1000,n-1后为0111,最后n为0111,返回n+1为1000
// 这些位或(|=)用来干什么的呢?比方你有一个数为10000000 00000000 00000000 00000000
// 那么执行完这些操作后的结果为 11111111 11111111 11111111 11111111
// 然后你想一想就明白了,32位的int执行完这几步后高位第一个1后面全变1了
// 那为什么cap要减一,加入还是上面的8,如果不减一那返回的结果就是16
// 减一的话就是8,如果是7,那返回的还是8,自己感受下为什么减一
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; }

Hash桶数组:

transient Node<K,V>[] table;

Node<K, V>:

static class Node<K,V> implements Map.Entry<K,V> {
        final int hash; //用来定位数组索引位置
        final K key;
        V value;
        Node<K,V> next; //下一个Node,所以是单向链表

        Node(int hash, K key, V value, Node<K,V> next) {
            this.hash = hash;
            this.key = key;
            this.value = value;
            this.next = next;
        }

        public final K getKey()        { return key; }
        public final V getValue()      { return value; }
        public final String toString() { return key + "=" + value; }

        public final int hashCode() {
            return Objects.hashCode(key) ^ Objects.hashCode(value);
        }

        public final V setValue(V newValue) {
            V oldValue = value;
            value = newValue;
            return oldValue;
        }

        public final boolean equals(Object o) {
            if (o == this)
                return true;
            if (o instanceof Map.Entry) {
                Map.Entry<?,?> e = (Map.Entry<?,?>)o;
                if (Objects.equals(key, e.getKey()) &&
                    Objects.equals(value, e.getValue()))
                    return true;
            }
            return false;
        }
    }

hash函数:(h = key.hashCode()) ^ (h >>> 16),hashCode方法返回int,这个式子就是让key的hashCode的高16位也参与hash的计算。

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

如果对象的hashcode值时一样的,那么hash的结果也是一样的,而且存在不同的对象拥有相同的hashcode值,所以会冲突,hashmap解决冲突的方法是链地址法。

哈希表: https://www.cnblogs.com/gavanwanggw/p/7307596.html

调用HashMap的put方法是会调用putVal:

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)
// hashmap的扩容,实例化HashMap后table还是null n
= (tab = resize()).length; if ((p = tab[i = (n - 1) & hash]) == null)
// (n - 1) & hash是取模运算
// 这个方法非常巧妙,它通过h & (table.length -1)来得到该对象的保存位,
// 而HashMap底层数组的长度总是2的n次方,这是HashMap在速度上的优化。
// 当length总是2的n次方时,h& (length-1)运算等价于对length取模,
// 也就是h%length,但是&比%具有更高的效率。可以看下下面的图
tab[i]
= newNode(hash, key, value, null); else {
// p那个位置有数据 Node
<K,V> e; K k; if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
// 如果他们hash是一样的并且key内存地址一样或者key逻辑相等,那么这两个Node当作是同一个,算是找到了节点e e
= p; else if (p instanceof TreeNode)
// 如果是树节点,那么按照红黑树插入 e
= ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); else {
// hash值一样但是是不同的节点,那么就是冲突了
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; }
// 一直找,直到找到一样的节点,链表长度也没到8
if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break; p = e; } } if (e != null) { // existing mapping for key
// 也就是映射到了key那个Node V oldValue = e.value; if (!onlyIfAbsent || oldValue == null) e.value = value;
// 这是一个回调函数,在HashMap里是空的,在LinkedHashMap有具体实现,
// 而LinkedHashMap是HashMap的子类,所以这里体现了模板方法这个设计模式
// 具体的实现延迟到了子类,LinkedHashMap里这个函数是将node放到最后 afterNodeAccess(e);
return oldValue; } }
// 这是一个记录HashMap结构改变的次数的变量
++modCount;
// 如果size大于了threshold那么又要resize了
// 一个Node[]长度为16,负载因子为0.75,那么threshold = cap(16) * loadFactory为12,
// size为HashMap中存在的键值对数量
if (++size > threshold) resize();
// 和上面的afterNodeAccess(e)是一个道理 afterNodeInsertion(evict);
return null; }

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) {
// MAXIMUM_CAPACITY = 1 << 30; Integer.MAX_VALUE = (1 << 31) - 1;
// 因为扩容变成两倍的话就是1 << 31,但是int是32为,还有一个符号位,所以最大是(1 << 31) -1 threshold
= Integer.MAX_VALUE; return oldTab; } else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY)
// 扩容后的HashMap容量是之前的两倍 newThr
= oldThr << 1; // double threshold } else if (oldThr > 0) // initial capacity was placed in threshold
// 在第一次带参数初始化时候会有这种情况, like this:new HashMap<String, String>(1) 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; @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) {
// 旧的table设为空 oldTab[j]
= null; if (e.next == null)
// 如果数组的某个位置只有这一个节点,那就直接重hash newTab[e.hash
& (newCap - 1)] = e; else if (e instanceof TreeNode)
// 如果节点是树 ((TreeNode
<K,V>)e).split(this, newTab, j, oldCap); else { // preserve order 保持顺序
// 1.7的时候链表会倒转,1.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) {
// 详细看下面
// 所以说如果e.hash & oldCap是0,则表示hash的高位不是1,那位置不变,比如再去看下面的例子
// oldCap = 0B10000 = 16; key1(hash1) = 00101;key2(hash2) = 10101
// 那是不是(e.hash & oldCap) == 0的时候高位为0,那么当cap翻倍后为32时
// (e.hash & (newCap - 1))是不是不变
// 下面就是求出位置不变的,组成新链表,下面直接把链表头节点给数组某个位置
// 所以这里的数组是不会倒置的,1.7会
// 我给出了一段代码,可以调试,加深理解,在这
if (loTail == null) loHead = e; else loTail.next = e; loTail = e; } else {
// 位置加上一个oldCap
// 组成新链表
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; }

 在这里

 这是JDK1.8的一个优化,可以不重新计算hash:经过观测可以发现,我们使用的是2次幂的扩展(指长度扩为原来2倍),所以,元素的位置要么是在原位置,要么是在原位置再移动2次幂的位置。比如你有一个容量为2,又两个Node的key为1,3,那扩容之后容量为4,那么1还在原来的位置,3的位置加了2。

图(a)表示扩容前的key1和key2两种key确定索引位置的示例,图(b)表示扩容后key1和key2两种key确定索引位置的示例,其中hash1是key1对应的哈希与高位运算结果。

还是来图吧。

元素在重新计算hash之后,因为n变为2倍,那么n-1的mask范围在高位多1bit(红色),因此新的index就会发生这样的变化:

 

回到代码

调试代码:断点搭载第四行,capacity为2,threshold为2,扩容后1,5不变,3的位置加2,试试看

        HashMap<String, String> map2 = new HashMap<String, String>(2, 1f);
        map2.put("1", "1");
        map2.put("3", "3");
        map2.put("5", "5");

cap从16变为32的示例图:

 

小结

(1) 扩容是一个特别耗性能的操作,所以当程序员在使用HashMap的时候,估算map的大小,初始化的时候给一个大致的数值,避免map进行频繁的扩容。

(2) 负载因子是可以修改的,也可以大于1,但是建议不要轻易修改(负载因子越小,能放的键值对也少,冲突的几率和链表的长度也小,性能比较好,空间换时间吧),除非情况非常特殊。

(3) HashMap是线程不安全的,不要在并发的环境中同时操作HashMap,建议使用ConcurrentHashMap。

(4) JDK1.8引入红黑树大程度优化了HashMap的性能。

(5) 还没升级JDK1.8的,现在开始升级吧。HashMap的性能提升仅仅是JDK1.8的冰山一角。

 

最后试着回答一下这几个问题:

1. HashMap的实现原理,jdk8做了哪些优化?

1)数组 + 链表 + 红黑树
2)相较1.7:加入红黑树,对于链表过长时的性能优化;链表可以不rehash,因为位置要么在原地或者加oldCap,而且元素额顺序不变
参考

 

2. HashMap怎么扩容的,为什么容量是2的整数次幂?

1)
2)在HashMap中,哈希桶数组table的长度length大小必须为2的n次方(一定是合数),这是一种非常规的设计,常规的设计是把桶的大小设计为素数。HashMap采用这种非常规设计,主要是为了在取模和扩容时做优化(源代码中有很多地方都是因为table长度是合数才可以进行优化,比如取模时用&而不是%),同时为了减少冲突,HashMap定位哈希桶索引位置时,也加入了高位参与运算的过程。
参考

 

3.为什么HashMap是线程不安全的?

public static void main(String[] args) throws InterruptedException {
        final HashMap<String, String> map = new HashMap<String, String>();
        int threads = 1000;
// 这是闭锁,这个代码里就是让多个线程一起开始(并行的话还是看cpu的)
final CountDownLatch startGate = new CountDownLatch(1); final CountDownLatch endGate = new CountDownLatch(threads); for (int i = 0; i < threads; i++) { Thread thread = new Thread() { @Override public void run() { try {
// 都等着startGate变0 startGate.await();
try { map.put(Thread.currentThread().getName(), " "); } finally { endGate.countDown(); } } catch (InterruptedException e) { e.printStackTrace(); } } }; thread.start(); } startGate.countDown(); // -1
// 等所有线程的任务都结束 endGate.await();
// 找理由应该输出1000,但是可能输出999,998之类的 System.out.println(map.size()); }

就这段代码来说,可能多个线程发现同一个位置是null,都直接赋值上去,那么后面的线程可能会覆盖前面的。

甚至resize可能造成节点之间的环路,然后你get(key)的时候就GG了。

 

参考:

https://zhuanlan.zhihu.com/p/21673805

posted @ 2019-07-30 19:23  KuroNJQ  阅读(281)  评论(0编辑  收藏  举报