HashMap剖析

JAVA1.6版本

一、HashMap结构图

这里写图片描述
可以看出

  • 1.HashMap是一个数组+链表的结构,数组的下标在HashMap中称为Bucket值,每个数组项对应的是一个List
  • 2.每个List中存放的是一个Entry对象,这个Entry对象是包含键和值的

二、HashMap存放对象的流程

HashMap使用put(key,value)函数存放对象,当调用put(key,value)方法的时候会发生以下步骤

  • 1.通过key的hashCode()函数计算key的hash值,再通过hash值得出Bucket值(如何通过hash值的出Bucket值,继续玩下阅读)
  • 2.得出Bucket值后,如果该Bucket值对应的list是一个空列表,那么将生成entry对象,插入到list中,做为list的第一个元素
  • 3.如果该Bucket值对应的list中已经有其它对象了(如果两个key的hash值一样就会发生),这个时候就发生了碰撞。
  • 4.发生了碰撞后,新插入的key就会和链表中的其它entry的key进行比较,比较过程源码如下,需要同时用到equals()方法
p.hash == hash &&((k = p.key) == key || (key != null && key.equals(k)))
  • 从源码中可以看出如果两个key的hashcode一样,并且equals方法比较也相同,那么HashMap就判断这两个key完全一样
    如果想更多了解hashCode和equals方法参看《Java equals和HashCode方法总结》
  • 5.如果两个key比较结果一样,由于HashMap不允许同时存在两个相同的key,那么新插入的entry就会覆盖旧entry
  • 6.如果比较不一样,那么将新的entry插入到list的末尾 总体put源码如下:
public V put(K key, V value) {
    // 处理key为null,HashMap允许key和value为null
    if (key == null)
        return putForNullKey(value);
    // 得到key的哈希码
    int hash = hash(key);
    // 通过哈希码计算出bucketIndex
    int i = indexFor(hash, table.length);
    // 取出bucketIndex位置上的元素,并循环单链表,判断key是否已存在
    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;
        }
    }

    // key不存在时,加入新元素
    modCount++;
    addEntry(hash, key, value, i);
    return null;
}

三、rehashing

1.初始容量和加载因子。

默认初始容量是16,加载因子是0.75。容量是哈希表中数组的长度,如文章最开始给的图其容量就为16,初始容量只是哈希表在创建时的容量。加载因子是哈希表在其容量自动增加之前可以达到多满的一种尺度。

2.如果HashMap的大小超过了负载因子(load factor)定义的容量,怎么办?

默认的负载因子大小为0.75,也就是说,当一个map填满了75%的bucket时候,和其它集合类(如ArrayList等)一样,将会创建原来HashMap大小的两倍的bucket数组,来重新调整map的大小,并将原来的对象放入新的bucket数组中。这个过程叫作rehashing,因为它调用hash方法找到新的bucket位置。

3.重新调整HashMap大小存在什么问题

当重新调整HashMap大小的时候,确实存在条件竞争,因为如果两个线程都发现HashMap需要重新调整大小了,它们会同时试着调整大小。在调整大小的过程中,存储在LinkedList中的元素的次序会反过来,因为移动到新的bucket位置的时候,HashMap并不会将元素放在LinkedList的尾部,而是放在头部,这是为了避免尾部遍历(tail traversing)。如果条件竞争发生了,那么就死循环了。

所以不要在多线程的环境下使用HashMap

五、为什么容量一定为2的幂

1.HashMap的初始化函数
public HashMap(int initialCapacity, float loadFactor) {  
    ......
    // Find a power of 2 >= initialCapacity  
    // 设置最重的容量为小于initialCapacity的2的整数次幂的最小数
    int capacity = 1;  
    while (capacity < initialCapacity)  
        capacity <<= 1;  

    ......
}  

所以可以看出capacity被设计成了一个一定为2的整数幂的数
如果initialCapacity设置为11
那么最终的capacity就为8

2.为什么这么设计?

HashMap中的数据结构是数组+单链表的组合,我们希望的是元素存放的更均匀,最理想的效果是,Entry数组中每个位置都只有一个元素,这样,查询的时候效率最高,不需要遍历单链表,也不需要通过equals去比较K,而且空间利用率最大。那么怎么才能将分布最大的均匀化呢?那就是取余运算%,哈希值 % 容量 = bucketIndex
SUN的大师们是否也是如此做的呢?我们阅读一下这段源码:

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

上面这段代码就实现了取余操作
当容量一定是2^n时,h & (length - 1) == h % length。

举个直观的例子,看看这么设计的好处:
我要将hash值分别为2,4,6的key插入到HashMap中
如果容量为10那么 (length - 1)的二进制为:1001
那么h & (length-1)的结果分别为
2:10 & 1001 = 0
4:100 & 1001 = 0
6:110 & 1001 = 0
所以这3个数的hash值都为0,都发生碰撞了
如果容量为8那么 (length - 1)的二进制为:111
那么h & (length-1)的结果分别为
2:10 & 111 = 2
4:100 & 111 = 4
6:110 & 111 = 6
3个数都没有发生碰撞

五、总结

  HashMap的工作原理
  HashMap基于hashing原理,我们通过put()和get()方法储存和获取对象。当我们将键值对传递给put()方法时,它调用键对象的hashCode()方法来计算hashcode,让后找到bucket位置来储存值对象。当获取对象时,通过键对象的equals()方法找到正确的键值对,然后返回值对象。
  当两个不同的键对象的hashcode相同时会发生什么?发生碰撞了,它们会储存在同一个bucket位置的LinkedList中。键对象的equals()方法用来找到键值对。
  一些优秀的开发者会指出使用不可变的、声明作final的对象,并且采用合适的equals()和hashCode()方法的话,将会减少碰撞的发生,提高效率。不可变性使得能够缓存不同键的hashcode,这将提高整个获取对象的速度,使用String,Interger这样的wrapper类作为键是非常好的选择。

参考文章:
HashMap深入解析
HashMap工作原理

JAVA1.8版本

一、前言

在JDK1.6中,HashMap采用数组+链表实现,即使用链表处理冲突,同一hash值的节点都存储在一个链表里。但是当位于一个链表中的元素较多时,通过key值依次查找的效率较低。而JDK1.8中,HashMap采用数组+链表+红黑树来实现,当链表长度超过阈值(8)时,将链表转换为红黑树,这样大大减少了查找时间。(关于红黑树

二、TreeNode

JDK1.8中引入了TreeNode类型的节点,当链表长度超过8时就将链表转换为红黑树

static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
    TreeNode<K,V> parent;
    TreeNode<K,V> left;
    TreeNode<K,V> right;
    TreeNode<K,V> prev;
    boolean red;
    TreeNode(int hash, K key, V val, Node<K,V> next) {
        super(hash, key, val, next);
    }
}
...

三、插入节点

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;
   //如果table为空或长度为0,则初始化
   if ((tab = table) == null || (n = tab.length) == 0)
       n = (tab = resize()).length;
   //(n - 1) & hash:相当于 hash值%数组长度,取余找到put位置,如果这个位置还为空,则直接创建Node作为该位置链表的第一位
   if ((p = tab[i = (n - 1) & hash]) == null)
       tab[i] = newNode(hash, key, value, null);
   else {
   //如果在这个位置已经有练表了
       Node<k,v> e; K k;
       //如果练表的第一个节点的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);
       else {
           //不是红黑树就是链表,binCount计数,如果大于8就要转换成红黑树
           for (int binCount = 0; ; ++binCount) {
               //p第一次指向表头,以后依次后移
               if ((e = p.next) == null) {
                   //e为空,表示已到表尾也没有找到key值相同节点,则新建节点
                   p.next = newNode(hash, key, value, null);
                   //新增节点后如果节点个数到达阈值,则将链表转换为红黑树
                   if (binCount >= TREEIFY_THRESHOLD - 1)
                       treeifyBin(tab, hash);
                   break;
               }
               //查找练表中key和插入key是否一致,一致的话位置找到
               if (e.hash == hash &&((k = e.key) == key || (key != null && key.equals(k))))
                   break;
               p = e;
           }
       }
       //更新hash值和key值均相同的节点的Value值
       if (e != null) { // existing mapping for key
           V oldValue = e.value;
           if (!onlyIfAbsent || oldValue == null)
               e.value = value;
           afterNodeAccess(e);
           return oldValue;
       }
   }
   ++modCount;
   if (++size > threshold)
       resize();
   afterNodeInsertion(evict);
   return null;
}

四、删除节点

final Node<K,V> removeNode(int hash, Object key, Object value,boolean matchValue, boolean movable) {
    Node<K,V>[] tab; Node<K,V> p; int n, index;
    if ((tab = table) != null && (n = tab.length) > 0 && (p = tab[index = (n - 1) & hash]) != null) {
        Node<K,V> node = null, e; K k; V v;
        //根据key的hash值找到对应位置的练表
        //如果练表的第一个元素的key和要删除元素的key不一致,说明没有找到,继续往后找
        if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
            node = p;
        //顺着练表往后找
        else if ((e = p.next) != null) {
            //如果该练表已经转换成了红黑树,那么用红黑树的方式查找
            if (p instanceof TreeNode)
                node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
            //如果还是练表,那么继续用遍历的方式往后找
            else {
                do {
                    if (e.hash == hash &&
                        ((k = e.key) == key ||
                         (key != null && key.equals(k)))) {
                        node = e;
                        break;
                    }
                    p = e;
                } while ((e = e.next) != null);
            }
        }
        //找到该节点位置后
        if (node != null && (!matchValue || (v = node.value) == value ||
                             (value != null && value.equals(v)))) {
            //找到该节点位置后,如果该节点已经是一个红黑树节点,那么通过红黑树的方式删除该节点
            if (node instanceof TreeNode)
                ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
            //如果找到的节点是练表的第一个节点,那么让这个节点的下一个节点作为这个链表的第一个节点
            else if (node == p)
                tab[index] = node.next;
            //如果找到的节点不是链表的第一个节点,那么此时p是node的前一个节点(观察前面在链表中遍历查找位置的代码就可以发现)
            //那么直接通过p.next = node.next跨过node节点,达到删除效果
            else
                p.next = node.next;
            ++modCount;
            --size;
            afterNodeRemoval(node);
            return node;
        }
    }
    return null;
}

可以看到jdk1.8中对Put的处理流程比1.6版本中多引入了红黑树的概念达到加快查询的效果

参考资料:
Java类集框架之HashMap(JDK1.8)源码剖析

评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值