剖析 HashMap

Map接口

基本概念

Map有键和值的概念,一个键映射到一个值,Map按照键存储和访问值,键不能重复,即一个键只会存储一份,给同一个键重复设值会覆盖原来的值。使用Map可以方便地处理需要根据键访问对象的场景,比如:

  • 一个词典应用,键可以为单词,值可以为单词信息类,包括含义、发音、例句等。
  • 统计和记录一本书中所有单词出现的次数,可以以单词为键,出现次数为值。
  • 管理配置文件中的配置项,配置项是典型的键值对。
  • 根据身份证号查询人员信息,身份证号为键,人员信息为值。

数组、ArrayList、LinkedList可以视为一种特殊的Map,键为索引,值为对象。

接口定义

Map接口的定义为:

public interface Map {
    V put(K key, V value);//保存键值对
    V get(Object key);
    V remove(Object key);
    int size();
    boolean isEmpty();
    boolean containsKey(Object key);
    boolean containsValue(Object value);
    void putAll(Map m);//批量保存,保存参数m中的所有键值对到当前Map
    void clear();
    Set keySet();//获取Map中键的集合,Set没有重复的元素集合
    Collection values();//获取Map中所有值的集合,可以重复
    Set> entrySet();//获取Map中的所有键值对
    interface Entry {
        K getKey();
        V getValue();
        V setValue(V value);
        boolean equals(Object o);
        int hashCode();
    }
    boolean equals(Object o);
    int hashCode();
}

HashMap

构造方法

除了默认构造方法,HashMap还有如下构造方法:

public HashMap(int initialCapacity)
public HashMap(int initialCapacity, float loadFactor)
public HashMap(Map m)

实现原理

内部组成

HashMap内部有如下几个主要的实例变量:

//被transient关键字修饰的变量不会被序列化
transient Entry[] table = (Entry[]) EMPTY_TABLE;//一个Entry类型的数组,其中的每个元素指向一个单向链表,链表中的每个节点表示一个键值对,Entry是一个内部类
transient int size;//实际键值对的个数
int threshold;//阈值,当键值对个数size大于等于threshold时考虑进行扩展
final float loadFactor;//负载因子,表示整体上table被占用的程度,是一个浮点数,默认为0.75,可以通过构造方法进行修改
static class Entry implements Map.Entry {
    final K key;
    V value;
    Entry next//指向下一个Entry节点
    int hash;//key的哈希值,直接存储hash值是为了在比较的时候加快计算

    Entry(int h, K k, V v, Entry n) {
        value = v;
        next = n;
        key = k;
        hash = h;
    }
} 

table的初始值为EMPTY_TABLE,是一个空表,具体定义为:

static final Entry[] EMPTY_TABLE = {};

当添加键值对后,table就不是空表了,它会随着键值对的添加进行扩展,扩展的策略类似于ArrayList,添加第一个元素时,默认分配的大小为16,不过,并不是size大于16时再进行扩展,下次什么时候扩展与threshold有关。一般而言,threshold等于table.length乘以loadFactor,比如,如果table.length为16,loadFactor为0.75,则threshold为12。

默认构造方法

public HashMap() {
    this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);//(16,0.75)
}

public HashMap(int initialCapacity, float loadFactor) {
    this.loadFactor = loadFactor;
    threshold = initialCapacity;
}

保存键值对

下面,我们来看HashMap是如何把一个键值对保存起来的,代码为:

public V put(K key, V value) {
    if (table == EMPTY_TABLE) {
        inflateTable(threshold);
    }
    if (key == null)
        return putForNullKey(value);
    int hash = hash(key);//计算key的哈希值
    int i = indexFor(hash, table.length);//计算应该将这个键值对放到table的哪个位置
    for (Entry e = table[i]; e != null; e = e.next) {//保存位置i,table[i]指向一个单向链表,在这个链表中逐个查找是否已经有这个键了
        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;
}

//如果是第一次保存,首先会调用inflateTable()方法给table分配实际的空间
private void inflateTable(int toSize) {
    // Find a power of 2 >= toSize
    int capacity = roundUpToPowerOf2(toSize);

    threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
    table = new Entry[capacity];
    //默认情况下,capacity的值为16,threshold会变为12,table会分配一个长度为16的Entry数组
}

static int indexFor(int h, int length) {
    return h & (length-1);
    //HashMap中,length为2的幂次方,h&(length-1)等同于求模运算:h%length
}

//在给定的位置添加一条
void addEntry(int hash, K key, V value, int bucketIndex) {
    if ((size >= threshold) && (null != table[bucketIndex])) {
        resize(2 * table.length);//空间不够,要扩展
        hash = (null != key) ? hash(key) : 0;
        bucketIndex = indexFor(hash, table.length);
    }

    createEntry(hash, key, value, bucketIndex);//如果空间是够的,不需要resize,调用createEntry添加一条数据
}

void createEntry(int hash, K key, V value, int bucketIndex) {
    Entry e = table[bucketIndex];
    table[bucketIndex] = new Entry<>(hash, key, value, e);//这里实际上是相当于添加到了链表头
    size++;
}

void resize(int newCapacity) {
    Entry[] oldTable = table;
    int oldCapacity = oldTable.length;
    Entry[] newTable = new Entry[newCapacity];
    transfer(newTable, initHashSeedAsNeeded(newCapacity));//将原来的键值对移植过来
    table = newTable;//更新内部的table变量
    threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}

//遍历原来的每个键值对,计算新位置,并保存到新位置
void transfer(Entry[] newTable, boolean rehash) {//rehash一般为false
    int newCapacity = newTable.length;
    for (Entry e : table) {
        while(null != e) {
            Entry next = e.next;
            if (rehash) {
                e.hash = null == e.key ? 0 : hash(e.key);
            }
            int i = indexFor(e.hash, newCapacity);
            e.next = newTable[i];
            newTable[i] = e;
            e = next;
        }
    }
}

以上,就是保存键值对的主要代码,简单总结一下,基本步骤为:

  1. 计算键的哈希值
  2. 根据哈希值得到保存位置(取模)
  3. 插到对应位置的链表头部或更新已有值
  4. 根据需要扩展table大小

图示说明

以上描述可能比较抽象,我们通过一个例子,用图示的方式说明,代码是:

Map countMap = new HashMap<>();
countMap.put("hello", 1);
countMap.put("world", 3);

countMap.put("position", 4);

在通过new HashMap()创建一个对象后,内存中的图示结构大概是:

image.png

接下来执行countMap.put("hello", 1);

"hello"的hash值为96207088,模16的结果为0,所以插入table[0]指向的链表头部,内存结构会变为:

image.png

"world"的hash值为111207038,模16结果为15,所以保存完"world"后,内存结构会变为:

image.png

"position"的hash值为771782464,模16结果也为0,table[0]已经有节点了,新节点会插到链表头部,内存结构会变为:

image.png

实现原理小结

以上就是HashMap的基本实现原理,内部有一个数组table,每个元素table[i]指向一个单向链表,根据键存取值,用键算出hash,取模得到数组中的索引位置buketIndex,然后操作table[buketIndex]指向的单向链表。

存取的时候依据键的hash值,只在对应的链表中操作,不会访问别的链表,在对应链表操作时也是先比较hash值,相同的话才用equals方法比较,这就要求,相同的对象其hashCode()返回值必须相同,如果键是自定义的类,就特别需要注意这一点。这也是hashCode和equals方法的一个关键约束

HashMap特点分析

HashMap实现了Map接口,内部使用数组链表和哈希的方式进行实现,这决定了它有如下特点:

  • 根据键保存和获取值的效率都很高,为O(1),每个单向链表往往只有一个或少数几个节点,根据hash值就可以直接快速定位。
  • HashMap中的键值对没有顺序,因为hash值是随机的。

如果经常需要根据键存取值,而且不要求顺序,那HashMap就是理想的选择。

不过HashMap没有顺序,如果要保持添加的顺序,可以使用HashMap的一个子类LinkedHashMap,Map还有一个重要的实现类TreeMap,它可以排序。

ref:Java编程的逻辑 (40) - 剖析HashMap

你可能感兴趣的:(剖析 HashMap)