浅谈HashMap

版本

jdk1.7.0_06

引入

​ HashMap是java中常用的集合类之一,几乎每个程序员都知道它,也会在不经意间使用到它。HashMap常会被用来与Hashtable和ConcurrentHashMap作比较。在弄清它们的区别前,首先应该了解下各个类底层究竟是怎么实现的。本文就来谈谈HashMap的实现原理。

​ HashMap在jdk1.7和jdk1.8的实现上有较大的不同,本文仅针对jdk1.7(jdk7不同的小版本细节上也有些不同)来谈论,关于HashMap在jdk1.8下的实现将在之后有空补充。

关系

HashMap的继承关系如下:

public class HashMap<K,V>
    extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable

HashMap继承自AbstractMap,同时实现了Map、Cloneable和Serializable接口。因此,HashMap支持序列化(Serializable只是个标注性接口,并没有任何方法。实际上jdk提供的大部分类都实现了这个接口),HashMap可以被克隆(Cloneable也是一个标注性的接口,具体clone()是Object类的方法,需要自行重写以实现对应类的clone)

属性

先来简单了解下HashMap里的几个变量:

static final int DEFAULT_INITIAL_CAPACITY = 16;
static final int MAXIMUM_CAPACITY = 1 << 30;
static final float DEFAULT_LOAD_FACTOR = 0.75f;
transient Entry[] table;
transient int size;
int threshold;
final float loadFactor;
transient int modCount;

DEFAULT_INITIAL_CAPACITY:默认初始化大小为16,这里主要是用在初始化Entry数组的大小;

MAXIMUM_CAPACITY:hashmap最大限制容量

DEFAULT_LOAD_FACTOR:默认负载因子,默认0.75,主要是用于扩容的判断的,后面扩容时会详细说明;

table:是存储主要数据的数组,每一个又称为槽(bucket),初始默认数组大小为16(DEFAULT_INITIAL_CAPACITY 决定),每一个槽又是一个链表的头结点。

size:HashMap实际存储键值对的数量;

threshold:threshold = capacity * loadFactor,是判断是否需要扩容的阈值。

loadFactor:实际负载因子,若没有自己赋值,则默认loadFactor=DEFAULT_LOAD_FACTOR;

modCount:主要是用在fast-fail机制的判断,这里可暂时不理会。在进行迭代时该参数有重要作用。可能有些参数还不是很理解,可暂时放下,后面在解释HashMap具体方法的时候可能会好理解些。

存储结构

在继续往下理解之前,先来了解下HashMap底层是怎么存储数据的。

在上面的属性中可以看到hashmap有一个Entry类数组,那么Entry具体是个怎么样的结构呢?

transient Entry[] table;

通过源码看到,Entry是HashMap的一个静态内部类

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

        /**
         * Creates new entry.
         */
        Entry(int h, K k, V v, Entry<K,V> n) {
            value = v;
            next = n;
            key = k;
            hash = h;
        }

        public final K getKey() {
            return key;
        }

        public final V getValue() {
            return value;
        }

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

        public final boolean equals(Object o) {
            if (!(o instanceof Map.Entry))
                return false;
            Map.Entry e = (Map.Entry)o;
            Object k1 = getKey();
            Object k2 = e.getKey();
            if (k1 == k2 || (k1 != null && k1.equals(k2))) {
                Object v1 = getValue();
                Object v2 = e.getValue();
                if (v1 == v2 || (v1 != null && v1.equals(v2)))
                    return true;
            }
            return false;
        }

        public final int hashCode() {
            return (key==null   ? 0 : key.hashCode()) ^
                   (value==null ? 0 : value.hashCode());
        }

        public final String toString() {
            return getKey() + "=" + getValue();
        }

        /**
         * This method is invoked whenever the value in an entry is
         * overwritten by an invocation of put(k,v) for a key k that's already
         * in the HashMap.
         */
        void recordAccess(HashMap<K,V> m) {
        }

        /**
         * This method is invoked whenever the entry is
         * removed from the table.
         */
        void recordRemoval(HashMap<K,V> m) {
        }
    }

可以看到Entry主要有四个属性,其中一个属性仍然为Entry(Entry<K,V> next),这其实可以说是链表的一个实现方式。

HashMap是通过数组+链表的方式存储键值对的,通过计算key的哈希值得到键值对在Entry数组(bucket)的位置,如果这个位置上已经存在其他键值对了,即产生哈希冲突,则会采用拉链法进行存储。所以每个Entry数组元素实际上是一个单链表头。

存储结构

构造方法

大概知道了HashMap的存储结构后,来看看HashMap的构造方法。

HashMap提供了4个构造方法

public HashMap()
public HashMap(int initialCapacity)
public HashMap(Map<? extends K, ? extends V> m)
public HashMap(int initialCapacity, float loadFactor)

逐一来看:

public HashMap()

源码如上,当调用hashmap的无参构造方法时,其会调用public HashMap(int initialCapacity, float loadFactor),自动为HashMap初始化一些默认值,包括当前负载因子默认为0.75,Entry数组的大小为16,即默认16个槽(bucket),(这里16是2的4次方,实际上HashMap在扩容时也会使大小为2的n次方,关于原因会在后面解释),threshold仍然按公式计算=0.75*16=12;即当size>12时就会进行扩容操作。为什么是大于12而不是16呢?主要是因为HashMap底层的存储方式影响的。因为HashMap底层是采用hash函数来决定每一个键值对存放在哪个槽的,而我们知道,当键值对的数量越接近与槽的数量,则出现冲突的可能性就会越大,这样效率反而低下,所以这里设置了负载因子和threshold用于判断扩容的时机。

public HashMap(int initialCapacity)

从源码知,该方法调用了另一个构造方法。传入的参数中,第一个为我们指定的容量大小,第二个为负载因子,使用默认的0.75.

public HashMap(int initialCapacity, float loadFactor)

这是该构造方法的源码,可以看到,前三个if都是对传入的参数大小类型等进行判断。如果指定的大小超出限制的最大容量,则直接让初始化大小等于限制的最大容量。

后面的这段代码:

      int capacity = 1;
        while (capacity < initialCapacity)
            capacity <<= 1;

则是通过循环找到第一个大于指定的initialCapacity的且是2的n次方的数。capacity <<= 1指相左移一位,相当于乘以2,以此方法得到的数,都可以通过2的n次方得到。为什么一定要使用2的n次方而不直接使用我们构造时传入的容量参数呢?后面会解释。得到具体的容量capacity后,即可通过负载因子和capacity计算出threshold,而Entry数组的大小也就可以初始化了。这里init()方法是个空方法。

在这里init方法并没有什么用,但是其它继承HashMap的集合类可重写该方法。在linkedHashMap可看到重写了这个方法。

public HashMap(Map<? extends K, ? extends V> m)

很明显,这个构造方法是打算使用另一个map的数据来构造一个新的HashMap,其源码如下:

很明显,调用的仍然是上面的构造方法,但是传入的槽的大小这个参数却是由构造这个HashMap时传入的m的大小.

(m.size() / DEFAULT_LOAD_FACTOR) + 1

指的是刚好达到需要扩容的这个原数组的大小,如果这个数比16小,则仍然使用默认的16。

在初始化完成后,在调用putAllForCreate方法将m的各个数据存放进去。通过putForCreat方法将e散列到对应的bucket中。putForCreate方法调用indexFor来确定键值对散列的bucket的位置。indexFor通过h & (length-1)返回bucket的位置,接着遍历对应的单链表来决定是更新操作还是插入操作。(与put方法部分类似,可参考后面).

put方法

下面来分析put(K key, V value)方法。该方法的源码如下:

首先判断key值是否为null,如果是,则调用putForNullKey方法。putForNullKey方法的实现如下:

可以清楚的看到,这for中指定了使用table[0],即null key总是存放在Entry[]数组的第一个元素。再接下来的if语句中,则是判断是否已经存放了key为null的键值对,如果存在,则替换其value,并放回旧值,所以HashMap中只可能存在一个key为null键值对。如果不存在,则调用addEntry方法来设置Entry数组的第一个元素的内容:一个key为null,值为value的值。(这里modCount++主要是在迭代过程中fast-fail机制中使用,这里暂时都忽略它),继续跟进去addEntry方法:

可以看出在这个方法主要是初始化了Entry数组的一个槽,并将传入的参数作为数据设置进去。其中,

new Entry<>(hash, key, value, e)

调用的是内部类Entry的构造方法。由于table[0]可能本身就已经有其他非null数据连接成的链表,那么新增一个节点是怎么处理的呢?看看下面这个构造方法的具体实现:

从源码看到,当前table[0]指向新的Entry,而原来的则成为其next(next=n),举个例子,原先table[0]上是A->B->C,这时候有个D刚好hash后也在table[0]这个槽,所以加入后变成D->A->B->C,即当前数组元素中链表头节点所在的数据是最后插入的元素

同时,在完成后插入新值后还要将size+1(size用来记录当前HashMap有多少个键值对),并判断其大小是否超过了threshold(if(size++>=threshold)),如果超过,则要调用方法进行扩容,扩容时传进的参数为当前数组的大小*2。关于resize扩容的具体内容,留待后面再说。暂且回到pull方法。在判断了key是否null之后,假设不为null,则计算使用key的hashcode来计算其hash值,通过这个hash方法,计算出其hash结果,然后调用indexFor方法来计算得出应该将这个key-value放在Entry数组的哪个槽上:

这个方法的解释也是在后面说明。

接着利用算出的i即在Entry数组的位置,通过for循环遍历该槽上的链表,查找是否有相同的key值,若有则覆盖旧值。

如果尚未存在这个key,则仍然通过addEntry方法插入新值。这个方法在前面说过,就不再赘述。

get方法

先看看源码:

首先当然是先判断key是否为null,如果是,则调用getForNullKey方法,由上面put方法的分析,我们知道key为null的键值对都是存放在table[0]里的,所以这里不难猜测,getForNullKey方法就是从table[0]里取出数据:

如果key不为null,则先计算Key的hash值,定位到数组中对应的bucket,然后开始遍历Entry单链表, 通过对比哈希码相同并且对象(key)相同,直到找到则返回其value,否则返回null。

HashMap的扩容

从上面的分析我们知道,在addEntry(int hash, K key, V value, int bucketIndex)方法中,没增加一个数据,则会对size进行判断,如果超过threshold,则会调用resize进行2倍扩容,很明显,这里扩容为原来的2倍也是满足了2的n次方。这里主要分析resize方法:

从源码看出,首先对原数组的容量oldCapacity进行判断,如果已经达到限制的最大容量MAXIMUM_CAPACITY,则不再进行扩容,令threshold = Integer.MAX_VALUE,然后返回。如果oldCapacity未超过MAXIMUM_CAPACITY,则new出一个新的Entry数组newTable,其大小为newCapacity,即为原来的2倍,然后调用transfer方法将原来的数据重新hash到新数据中去,因为数组的大小变了,则原来的数据经过hash计算后就不一定会在原来的那个位置了,所以这里需要对原来的全部数据进行重新hash计算并重新存储,当数据比较多时,这个开销还是比较大的。(这个过程一般也称rehash)

完成新数组的复制操作后,将HashMap的属性table指向新的数组newTable,然后重新计算threshold的值。至此,完成了HashMap的扩容操作。

jdk1.7和jdk1.8下HashMap比较明显的变化

jdk1.7下,但链表过长时,可能就会有效率问题,如果不幸大部分数据都落在同一个bucket下,就会形成很长的链,算法就退化为O(n)的时间复杂度了,jdk1.8下hashMap增加了红黑树,即结构是数组+链表+红黑树,默认是链表长度大于8时,链表会转化为红黑树,从而提高查找效率。由于增加了红黑树,jdk1.8的hashmap源码看起来就比1.7的复杂了不少,有兴趣的可以看看哈。

为什么HashMap容量一定要为2的n次方

下面就来说说前面留下的这个问题。我们知道,hashmap默认初始化时table数组大小为16,即2的4次幂,如果发生扩容,每次也是扩容2倍。并且在构造函数时,如果你指定了大小,比如指定了6,那么实际会变成8,即满足>6的且是2的幂的数。那么,为什么一定要严格遵守这个呢?

	/**
     * Returns index for hash code h.
     */
    static int indexFor(int h, int length) {
        return h & (length-1);
    }

我们知道,HashMap的存储结构是数组+单链表,当然,我们希望数据能存放的越均匀越好,链表长度越短越好,即减少冲突,可以hash找到数组就直接找到需要的数,这除了控制扩容的时机(即负载因子的选择)外,还要选择正确的hash函数,比较好的一种就是:哈希值%容量,我们看到HashMap的indexFor方法是怎么实现的,可以看到,其使用了位运算h & (length-1) ,很明显,使用位运算是为了提高效率,因为机器在计算位运算时远远比计算乘除运算快得多。而HashMap在扩容时进行rehash会重新计算其位置,所以这一步的效率还是比较重要的。那么,这段h & (length-1)究竟代表了什么样的运算呢?其实容量设计为2的幂主要就是与这里有关,因为2的n次方换算为二进制肯定就是1后面n个0,比如16的二进制为10000,即length-1=15即01111,而&运算就是只有两个都为1,结果才为1,则此时当任意大于0小于16的数与01111进行与操作后,结果都是其本身,而当任何大于15的数与01111进行与操作后,结果相当于进行了%运算,这正是达到了前面说的比较好的hash函数。

比如:14,二进制01110,与15进行与操作:01110&01111=01110,还是14;

又比如:17,二进制10001,与15进行与操作:10001&01111=00001,结果为1,相当于(17-1)%(16-1)=1; (数组是从0开始的)

从某方面看,将容量设置为2的幂可能浪费了空间,比如你构造函数时只需要容量5,但是却变成了8,浪费了空间,但是从另一个角度看,却也节省了空间,比如:假设HashMap的容量不遵循2的n次幂,比如为15时,此时不为2的n次幂,其二进制01111,length-1后等于01110,则此时不论h为什么,h&(length-1)得到的二进制结果末尾是不可能为1的,因为&操作要求两个都为1,结果才为1,而这里length-1结果为01110,很明显已经不满足这个条件了,所以此时Entry数组的某些槽如:0001,0011,0101,1001,1011,0111,1101却永远无法存放数据了,这在一定程度上浪费了空间。这样导致的结果是,会增加发生冲突的可能性,进而减慢了查询。

关于Hashtable和HashMap的区别

区别1:HashMap允许key和value为null,但仅允许一个key为的null,而Hashtable不允许key或value为null;

HashMap的这一点,在上面我们已经看出了,但有key为null多个时,会替换旧的key为null的值,所以仅可能存在一个,并且HashMap对value没有null的限制;关于Hashtable的这一点,我们可以通过源码说明:

而在HashMap中,在求key.hashCode前会对key进行null的判断,若为null,则调用putForNullKey方法,此时将hash值设置为0,具体通过源码即可知;

区别2:HashMap是非线程安全的,而Hashtable是线程安全的;

Hashtable的线程安全主要体现在它的大部分方法都是加了 sychronized的:

区别3:由于HashMap是没有加锁的,自然HashMap在效率上会比Hashtable好一些;

**区别4:**Hashtable使用 enumerator迭代器,HashMap使用 Iterator迭代器, Iterator是 fail-fast迭代器,即在迭代过程中,如果有其他线程增加或者移除了集合中的元素,则会抛出 ConcurrentModificationException异常;

**区别5:**继承的父类不同,Hashtable继承了Dictionary,而HashMap 继承了AbstractMap;

但二者都实现了Map接口。

区别6:HashMap把Hashtable的contains方法去掉了,改成containsValue和containsKey,因为contains方法容易让人引起误解。 Hashtable则保留了contains,containsValue和containsKey三个方法,其中contains和containsValue功能相同。containsValue和containsKey的源码很简单,有兴趣可以自己查看。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值