HashMap是如何工作的

大多数Java程序员都会用到Map类特别是Hash Map,HashMap虽然实现很简单,但是在存取数据上确有很强的优势。但是有多少开发人员知道HashMap是如何工作的呢?几天前,我阅读了大量篇幅的java.util.HashMap的源码(Java 7 和 Java 8)就是为了对他的数据结构基础有一定的了解。在这篇文章中,我将解释java.util.HashMap(包括Java 8)在内的HashMap的性能,内存,已经在使用中遇到的问题。

  1、内存模型
Java中HashMap类,实现了Map

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

一个HashMap把数据存储在多个entry对象组成的链表中(也称为桶或者箱)。所有的这些链表都被注册在一个Entery对象组成的数组链表中,默认的容量为16。

从图片中我们可以看出,HashMap内存中是一串可以为空的entry组成的链表。这些Entry中的每一个都可以指向另外一个Entry组成的linkedList。
 所有拥有相同哈希值的key被放到相同的桶中。不同哈希值的Key最终可能被放到相同的桶中。
 当我们调用put(K key,V value) 或者是 get(Object key)时,函数首先根据Key计算出哈希值,找到索引链表中这个Key对应的位置,然后,通过迭代的方式遍历桶中的元素,找到Key值相等的对象(使用equals() 方法比较Key)。
 当我们调用get()方法时,方法会返回一个entry的集合(如果entry存在)。
 当我们调用put(Key key,Value value)方法时,如果Entry已经存在,则使用新的值替换它,如果不存在,则在Linked list的头部创建一个新的Entry(依据key,value的值)。
 在map中一个桶(linked list)的形成主要分为三个步骤:
  (1) 通过key获取一个hashCode值
  (2) 重新计算key的hashCode值防止所有的数据都放在同一个桶内。
  (3) 新的hashCode值会与数组的长度length - 1做与运算(hashCode & (length - 1)),相当于对数组长度进行取模运算。使产生的hashCode值不会超出数组的长度。
 下面是Java8,Java7中有关hashCode的源码:

// the "rehash" function in JAVA 7 that takes the hashcode of the key
static int hash(int h) {
    h ^= (h >>> 20) ^ (h >>> 12);
    return h ^ (h >>> 7) ^ (h >>> 4);
}
// the "rehash" function in JAVA 8 that directly takes the key
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }
// the function that returns the index from the rehashed hash
static int indexFor(int h, int length) {
    return h & (length-1);
}

 为了更高的工作效率,内部数组应该是2的次幂。让我们看一下这是为什么:
 想像一下,一个长度为17的数组,实际上要做运算的数字是16(length - 1),它的二进制表示为 00……00010000,所以任何hashCode与16做“&运算“后的结果只有0或16。也就是说长度是17的数组中只用到了两个,碰撞发生的几率很大,效率也会很差的。
 但是如果你选择了2的次幂作为数组的长度,例如16,按位与元算(length - 1)之后。可以输出0-15中的任意一个数。显然这样的利用率,是非常高的,碰撞的几率相对来说减小了不少。
 这就是为什么map中数组的长度都是2的次幂,对于开发者来说这种机制是透明的。如果他定义了一个HashMap的长度为37,Map将自动计算出下次下次要扩展的数组长度为64。
 2、自动扩展
 获取索引之后,函数(get,put,remove)会逐个查看链表中是否已经存在了相同Key的Entry。如果没有扩展这种机制将会带来性能上的问题,因为函数需要遍历整个链表才能确定是否存在要找的Entry.即使在最好的方案中,每个LinkedList中都将有125000个entry,所以每个get(),remove(),put()方法,都会带来125000个entry的操作。为了避免这种情况,HashMap具有自动扩大内部数组的能力来使LinkedList保持尽量的短。
 当你创建一个HashMap时,你可以通过构造函数来初始化容量和负载因子。
 public HashMap(int initialCapacity,float loadFactor)
 如果你没有声明,那么初始化的容量为16,负载因子为0.75,初始化容量代表了内部数组的长度。
 每次当你使用put()方法在Map中新增Key/value组合时,函数需要坚持是否需要扩展内部数组的长度。为了做到这个,Map中存了两个数据。
  map的总容量:它代表了HashMap中entry的个数,这个数量在每次新增或删除entry时会发生改变。
  一个上限门槛(threshold):它等于内部数组的个数乘以负载因子,它在每次自动扩展内存之后会更新。
 在执行put()函数之前,也就是新增Entry之前,首先比较size与threshold的大小,如果size大于threshold那么数组要扩展为原来的一倍。当新的数组已经生成完之后,返回索引的函数发生了改变,新生成的数组要创建是原来两倍的entry,并且重新分配原来的entry到新的HashMap中去。
 扩展HashMap中数组的目的是,减少每个桶(linkedList)的长度,从而减少put(),remove()和get()方法的花费的时间。所有拥有相同key的Entry在新的HashMap中还会在同一个桶中,但是之前在同一个桶中拥有不同key的Entry,可能会被分配到不同的桶中。
这里写图片描述
 上面的图片很好的展示了数组扩展前后HashMap的变化,从图中可以看出每个桶的长度都有所减小,所以整体的put(),get(),remove()效率都有所提升。
注:HashMap只有增大数组的方法,但是没有缩小数组的方法
  线程安全
 如果你已经知道了HashMap,并且你知道了它是线程不安全的,但是为什么呢?举个栗子,想象一下一个写的线程往Map中写数据,一个读的线程从数据库中读数据,为什么它不能工作呢?
 因为map在自动扩展期间,如果一个线程想get或put一个对象,Map可能使用老的索引值,所以不能在新的桶中找到对应的entey。
 最坏的情况是两个线程同时调用put方法,让map同时自动扩展,当两个线程同时修改链表时,map最终可能生成一个内部循环的列表。如果尝试在这样的链表中获取一个数据,get()方法将永远不会结束。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值