HashMap浅析

  • 前言

HashMap是基于哈希表的Map接口的实现,它对数组以及链表做了综合考虑。在看Handler源码的时候看到需要了解这方面的知识,于是乎就了解下顺便写个博客加深理解。本文只对JDK7的HashMap源码进行分析,后续版本的红黑树先不考虑。

  • 相关知识

数组:采用一段连续的存储单元来存储数据。他的主要特点是:查找速度快,插入和删除效率低,内存空间要求高,必须有足够的连续内存空间。

链表:插入删除速度快,内存利用率高。

Hash:翻译成中文是“散列”的意思。把任意长度的输入通过散列算法变换成固定长度的输出,该输出就是散列值。

Hash冲突:Key键值经过行哈希运算得到一个存储地址,发现已经被其他的元素所占据。这就是所谓的Hash冲突。

  • HashMap源码解析

    1、介绍

    先看下HashMap的一张内部结构图:

img

纵向是一个数组,数组的每一项都是一个链表。数组相当于蓝牙电话列表的首字母,而链表相当于对应首字母的一组电话号码。当然这边的首字母是举得一个例子而已,HashMap中对应的是key键值经过一定运算得出来的结果。这个结果既要保证数组不能太长以免造成空间的浪费,又要保证链表不能太长造成时间的浪费。

OK!在介绍源码之前先看下几个重要的变量:

	/**
     * The number of key-value mappings contained in this map.
     */
    transient int size;

	/**
     * The next size value at which to resize (capacity * load factor).
     * @serial
     */
    // If table == EMPTY_TABLE then this is the initial capacity at which the
    // table will be created when inflated.
    int threshold;

    /**
     * The load factor for the hash table.
     *
     * @serial
     */
    // Android-Note: We always use a load factor of 0.75 and ignore any explicitly
    // selected values.
    final float loadFactor = DEFAULT_LOAD_FACTOR;

size代表的是HashMap中size。

threshold为当前的阈值,初始化的时候如果没人设置那么就是4。

loadFactor为负载因子,代表了table的填充度有多少,默认是0.75。

2、构造器

接下来就是分析源码了,对于HashMap可以从get、put以及构造这三方面入手。那么先看下构造的代码:

/**
 * Constructs an empty <tt>HashMap</tt> with the specified initial
 * capacity and load factor.
 *
 * @param  initialCapacity the initial capacity
 * @param  loadFactor      the load factor
 * @throws IllegalArgumentException if the initial capacity is negative
 *         or the load factor is nonpositive
 */
public HashMap(int initialCapacity, float loadFactor) {
    if (initialCapacity < 0)
        throw new IllegalArgumentException("Illegal initial capacity: " +
                                           initialCapacity);
    if (initialCapacity > MAXIMUM_CAPACITY) {
        initialCapacity = MAXIMUM_CAPACITY;
    } else if (initialCapacity < DEFAULT_INITIAL_CAPACITY) {
        initialCapacity = DEFAULT_INITIAL_CAPACITY;
    }

    if (loadFactor <= 0 || Float.isNaN(loadFactor))
        throw new IllegalArgumentException("Illegal load factor: " +
                                           loadFactor);
    // Android-Note: We always use the default load factor of 0.75f.

    // This might appear wrong but it's just awkward design. We always call
    // inflateTable() when table == EMPTY_TABLE. That method will take "threshold"
    // to mean "capacity" and then replace it with the real threshold (i.e, multiplied with
    // the load factor).
    threshold = initialCapacity;
    init();
}

/**
 * Constructs an empty <tt>HashMap</tt> with the specified initial
 * capacity and the default load factor (0.75).
 *
 * @param  initialCapacity the initial capacity.
 * @throws IllegalArgumentException if the initial capacity is negative.
 */
public HashMap(int initialCapacity) {
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}

/**
 * Constructs an empty <tt>HashMap</tt> with the default initial capacity
 * (16) and the default load factor (0.75).
 */
public HashMap() {
    this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
}

大概的意思是如果没有参数那么默认table的数组大小为4,加载因子为0.75。如果有参数,那么赋值为传进来的参数。

3、put方法介绍

下面介绍下put方法,贴上代码:

public V put(K key, V value) {
    if (table == EMPTY_TABLE) {
        inflateTable(threshold);//标注1
    }
    if (key == null)
        return putForNullKey(value);//标注2
    int hash = sun.misc.Hashing.singleWordWangJenkinsHash(key);//标注3
    int i = indexFor(hash, table.length);//标注4
    for (HashMapEntry<K,V> e = table[i]; e != null; e = e.next) {//标注5
        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++;//标注8
    addEntry(hash, key, value, i);//标注7
    return null;
}

先看下标注1。当table为空的时候会调用inflateTable方法:

private void inflateTable(int toSize) {
    // Find a power of 2 >= toSize
    int capacity = roundUpToPowerOf2(toSize);

    // Android-changed: Replace usage of Math.min() here because this method is
    // called from the <clinit> of runtime, at which point the native libraries
    // needed by Float.* might not be loaded.
    float thresholdFloat = capacity * loadFactor;
    if (thresholdFloat > MAXIMUM_CAPACITY + 1) {
        thresholdFloat = MAXIMUM_CAPACITY + 1;
    }

    threshold = (int) thresholdFloat;
    table = new HashMapEntry[capacity];
}

首先他是计算获取容量,容量的大小必须是2的n次方且大于toSize的数值,然后根据capacity * loadFactor得出阈值,也就是说当元素个数超过容量loadFactor倍的时候才进行扩容。最后是分配容量大小的内存给table。OK,那么table的初始化完成了。

此处有两个要重点理解的:1、为什么容量一定要是2的n次方。2、HashMapEntry结构包含了那些元素以及作用。

第一个问题接下来分析到indexFor的时候会解答。那么看下HashMapEntry的变量:

final K key;
V value;
HashMapEntry<K,V> next;
int hash;

key和value就不用多说了,这个是键值对的基本参数。next用于建立链表,而hash用于存储获取到的hash值。

OK,看下put方法的标注2,如果键值为空的情况下会调用putForNullKey方法。贴上putForNullKey方法代码:

private V putForNullKey(V value) {
    for (HashMapEntry<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;
}

逻辑是:遍历table[0]的链表,如果存在key为null的Entry那么替换成新值。如果没找到,那么在table[0]位置添加该值(该操作由addEntry方法完成,后续介绍)。

回头再看下put方法的标注3,他的功能是对hashcode进行二次哈希计算,目前只知道他的目的是为了使哈希值分布的更加均匀,具体怎么计算的Mark一下有时间看看。

put方法的标注4是获取hash值低位的索引号,先看看代码。

/**
 * Returns index for hash code h.
 */
static int indexFor(int h, int length) {
    // assert Integer.bitCount(length) == 1 : "length must be a non-zero power of 2";
    return h & (length-1);
}

我的理解是:1、比如说table有length的长度,比如说是16也就是0~15。OK,那么有12个元素的hash值,那么如何均匀地将它分布在这些数组呢。HashMap应该就是先通过二次哈希计算使得这12个hash值从低位开始尽量地均匀分布,也就是通过与运算能够让这12个值尽量的分散在table上。2、该方法也正回答了前面的疑问,长度如果是2的n次方,那么对于indexFor的与运算更加的友好。

put方法的标注5,他是循环遍历table数组中获取到的索引处的链表,如果找出key相等的键值对那么替换成新的值返回旧的值。

下面看下如何判断key相等首先是e.hash == hash,因为hash值不相等的话key一定不相等所以首先判断下这个必要不充分条件,第二步才是判断((k = e.key) == key || key.equals(k))。由于equals判断比hash更耗时间,所以这样子更能提高效率。

put方法的标注6,modCount是记录修改次数,他与线程安全有关。在后续的fail-fast策略会提到这个。

put方法的标注7,addEntry(hash, key, value, i)。也就是当在对应的链表中找不到的相同key的时候用来增加一个新的Entry。先看看源码:

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

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

首先是判断如果元素的数目大于阈值的时候,扩容成原来长度的两倍并重新计算哈希值以及Index。这边有个疑问:为什么要重新计算哈希值,哈希值难道和低位位数有关?Mark一下。接下来就是重新创建Entry了。总的来说就是如果满足条件就先扩容,然后再创建键值对。

这里有两个地方需要理解:第一是resize方法,第二个是createEntry方法。

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

    HashMapEntry[] newTable = new HashMapEntry[newCapacity];
    transfer(newTable);
    table = newTable;
    threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}

void transfer(HashMapEntry[] newTable) {
        int newCapacity = newTable.length;
        for (HashMapEntry<K,V> e : table) {
            while(null != e) {
                HashMapEntry<K,V> next = e.next;
                int i = indexFor(e.hash, newCapacity);
                e.next = newTable[i];
                newTable[i] = e;
                e = next;
            }
        }
    }

void createEntry(int hash, K key, V value, int bucketIndex) {
        HashMapEntry<K,V> e = table[bucketIndex];
        table[bucketIndex] = new HashMapEntry<>(hash, key, value, e);
        size++;
    }

resize也就是根据新的长度创建newTable。具体方法在transfer中实现,遍历所有的元素重新计算index放入对应新table的桶中。createEntry即增加一个新的Entry。这些操作都是从桶的头部开始插入!put方法介绍完毕。

4、get方法介绍

下面看下get方法:

public V get(Object key) {
    if (key == null)
        return getForNullKey();
    Entry<K,V> entry = getEntry(key);

    return null == entry ? null : entry.getValue();
}

关键是getEntry方法,跟进去看看:

final Entry<K,V> getEntry(Object key) {
    if (size == 0) {
        return null;
    }

    int hash = (key == null) ? 0 : sun.misc.Hashing.singleWordWangJenkinsHash(key);
    for (HashMapEntry<K,V> e = table[indexFor(hash, table.length)];
         e != null;
         e = e.next) {
        Object k;
        if (e.hash == hash &&
            ((k = e.key) == key || (key != null && key.equals(k))))//标注1 
            return e;
    }
    return null;
}

这边先通过key的哈希值获取index,然后只需在对应的桶中遍历寻找相同的值即可。标注1可以看到必须同时满足hash值相同以及key相同才能返回e。第一个hash的条件即可屏蔽很多键值对,而相对于只用equal来判断这样子高效了很多。同时有个疑问:tabel数组的大小永远会小于元素大小,那么链表不是很难产生,那和链表的特性不是体现不出来?从get这个方法看出,或许HashMap可能重点是在判断hash上面这样子比直接equal效率高多了。

5、fail-fast(快速失败)机制

fail-fast 机制,即快速失败机制,是java集合(Collection)中的一种错误检测机制。当在迭代的过程中该就有可能会发生fail-fast抛出ConcurrentModificationException异常。前面提到modCount变量,注释可知该变量用于迭代时触发fail-fast机制。

/**
 * The number of times this HashMap has been structurally modified
 * Structural modifications are those that change the number of mappings in
 * the HashMap or otherwise modify its internal structure (e.g.,
 * rehash).  This field is used to make iterators on Collection-views of
 * the HashMap fail-fast.  (See ConcurrentModificationException).
 */
transient int modCount;

现在看下HashMap抛出异常的代码:

private abstract class HashIterator<E> implements Iterator<E> {
	...

    HashIterator() {
        expectedModCount = modCount;
        ...
    }

    final Entry<K,V> nextEntry() {
        if (modCount != expectedModCount)
            throw new ConcurrentModificationException();
        ...
    }

    ...
}

在HashIterator方法在构造的时候赋值一下modCount,然后调用nextEntry的时候判断是否在迭代的过程中被修改了。如果被修改了,那么就报异常。分为两种情况:

1、单线程环境下

while(iterator.hasNext()) {
      if (i == 3) list.remove(3);
      System.out.println(iterator.next());
      i ++;
}

在遍历的过程中删除一个元素,那么就会报出这个异常。

2、多线程条件下,由于HashMap不是线程安全的。所以比如A线程正在迭代的过程中,B线程修改了modCount值。那么就会报异常。至于modCount修饰符从volatile 变为transient不是很清楚。Mark一下!

  • 结语

1、与其他集合的差异性可以总结一下

2、Hash表的底层算法原理可以去了解

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值