HashMap的小认识

想充分了解HashMap很久了,这个我们时刻使用,但是对于我来说总感觉有着迷雾的数据结构。

##HashMap
下面是居于JDK7的,JDK8的红黑树结构暂时放弃。

数据结构

HashMap继承了AbstractMap、实现了Map

public class HashMap<K,V>
extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable
{
/**
 * The table, resized as necessary. Length MUST Always be a power of two.
 */
transient Entry<K,V>[] table;

重要的是数组table以及它的内部类Entry。

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

可以看到Entry里有一个next属性,所以它本身就是个单链表,以下就是我们常说的数组+链表的结构:

put方法

代码还是很简单的,稍微一看都能清楚:

public V put(K key, V value) {
       // 对key为null的处理
       if (key == null)
           return putForNullKey(value);
       // 根据key算出hash值
       int hash = hash(key);
       // 根据hash值和HashMap容量算出在table中应该存储的下标i
       int i = indexFor(hash, table.length);
       for (Entry<K,V> e = table[i]; e != null; e = e.next) {
           Object k;
           // 先判断hash值是否一样,如果一样,再判断key是否一样
           if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
               V oldValue = e.value;
               e.value = value;
               e.recordAccess(this);
               return oldValue;
           }
       }

       modCount++;
       addEntry(hash, key, value, i);
       return null;
   }

整理出来的流程图如下:

值得注意点的地方是:

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

可以看到是用&的位运算,&运算是CPU的运算方式,所以效率是杠杠的,至于为什么是跟length-1进行&的位运呢?
因为length为2的幂次方,即一定是偶数,偶数减1,即是奇数,这样保证了(length-1)在二进制中最低位是1,而&运算结果的最低位是1还是0完全取决于hash值二进制的最低位。如果length为奇数,则length-1则为偶数,则length-1二进制的最低位横为0,则&位运算的结果最低位横为0,即横为偶数。这样table数组就只可能在偶数下标的位置存储了数据,浪费了所有奇数下标的位置,这样也更容易产生hash冲突。这也是HashMap的容量为什么总是2的平方数的原因。

resize、get

这个的流程和put类似,都是先计算hash值以及对应的index下表,然后重新赋值或者获取元素,这里就不在啰嗦。

iterate

这里我了解下,为什么map的遍历不是有序的,以及它是如何做的。
核心代码如下:

final Entry<K,V> nextEntry() {
    // 保存下一个需要返回的Entry,作为返回结果
    Entry<K,V> e = next;
    // 如果遍历到table上单向链表的最后一个元素时
    if ((next = e.next) == null) {
        Entry[] t = table;
        // 继续往下寻找table上有元素的下标
        // 并且把下一个talbe上有单向链表的表头,作为下一个返回的Entry next
        while (index < t.length && (next = t[index++]) == null)
            ;
    }
    current = e;
    return e;
}

nextEntry的主要作用有两点

  1. 把当前遍历到的Entry返回
  2. 准备好下一个需要返回的Entry

如果当前返回的Entry不是单向链表的最后一个元素,那只要让下一个返回的Entrynext为当前Entry的next属性(下图红色过程);如果当前返回的Entry是单向链表的最后一个元素,那么它就没有next属性了,所以要寻找下一个table上有单向链表的表头(下图绿色过程)

##LinkedHashMap
LinkedHashMap是在HashMap的基础上又维护了Entry之间的一个双向链表,其结构如下图所示:

private static class Entry<K,V> extends HashMap.Entry<K,V> {
    // These fields comprise the doubly linked list used for iteration.
    Entry<K,V> before, after;

    Entry(int hash, K key, V value, HashMap.Entry<K,V> next) {
        super(hash, key, value, next);
    }

可以到相比HashMap.Entry,LinkedHashMap.Entry多了before和after,这就是用来维护双向链表的。

put方法

新加入的元素维护到双向链表的末尾,代码如下:

private void addBefore(Entry<K,V> existingEntry) {
    after  = existingEntry;
    before = existingEntry.before;
    before.after = this;
    after.before = this;
}

iterate

从双向链表表头的第一个元素开始,遍历效率是大于HashMap的。

重排序

LinkedHashMap有一个比较特殊的构造函数:

public LinkedHashMap(int initialCapacity,
                     float loadFactor,
                     boolean accessOrder) {
    super(initialCapacity, loadFactor);
    this.accessOrder = accessOrder;
}

accessOrder默认为false,标识元素顺序与插入顺序相关;如果为true,标识元素顺序与访问顺序相关。能做到这点,就是因为在accessOrder=true的情况下,每一次访问都会将元素重新排序:

void recordAccess(HashMap<K,V> m) {
    LinkedHashMap<K,V> lm = (LinkedHashMap<K,V>)m;
    // 如果LinkedHashMap的accessOrder为true,则进行重排序
    // 比如前面提到LruCache中使用到的LinkedHashMap的accessOrder属性就为true
    if (lm.accessOrder) {
        lm.modCount++;
        // 把更新的Entry从双向链表中移除
        remove();
        // 再把更新的Entry加入到双向链表的表尾
        addBefore(lm.header);
    }
}

其实到这里我就比较好奇了,根据插入顺序的HashMap还有一些场景,但是每次读顺序都会改变的HashMap有哪些使用场景呢?

LinkedHashMap与LRU

每次访问的元素都会被放置在队列末尾,这和LRU(Least Recently Used最近最少算法)算法的特性是一致的。所以我们利用这个特性,能很轻易实现LRU-1算法,事实上java本身也给我们留好了实现的接口:

protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
    return false;
}

我们只要覆盖这个方法就是一个线程不安全的LRU了。
LRU本身是内存淘汰算法,关于这个请听下回分解。

如果感到快乐,你就拍拍手。