首先说说HashMap的设计思想,HashMap的思想主要是散列表的思想。利用散列函数将对表遍历转换成了对key的计算,通过key计算得到的值去表中寻找元素。在没有冲突的情况下,时间复杂度是O(1)的。
散列表一个明显的问题就是多个不同的key可能计算出来得到的哈希值相等,或者是计算出的哈希值和散列表的长度进行取模运算之后得到的索引值一样,这时候就产生了冲突。HashMap是使用的链接法解决冲突的。因此,HashMap的底层结构是数组+链表的结构,如下图:
图中的key,value的结构是HashMap的内部类Entry,也是HashMap的基础结构。
看一个类,先从构造方法开始看起:
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
threshold = initialCapacity;
init();
}
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
public HashMap() {
this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
}
public HashMap(Map<? extends K, ? extends V> m) {
this(Math.max((int) (m.size() / DEFAULT_LOAD_FACTOR) + 1,
DEFAULT_INITIAL_CAPACITY), DEFAULT_LOAD_FACTOR);
inflateTable(threshold);
putAllForCreate(m);
}
以上是HashMap的四个构造方法,我常用的是第三个,归根结底,都是调用的第一个构造方法。
这里面有两个参数:initialCapacity和loadFactor。
initialCapacity是散列表长度的参数,不过如果程序员传了这个参数,只能说散列表的长度会和这个参数相关,最终散列表的长度不一定会是这个参数的值。原因在下面会给出。
loadFactor是装填因子,当HashMap里面的元素个数超过散列表的长度*loadFactor时将会扩容,直接将容量扩大一倍。
HashMap的前三个构造函数基本是只初始化参数,除了最后一个构造函数,最后一个构造函数一开始就传入了一个Map的参数,第一行仍然是调用第一个构造方法进行初始化HashMap的一些参数,第二行出现了一个叫inflateTable的方法,此方法内容如下:
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];
initHashSeedAsNeeded(capacity);
}
这个方法则是真正的初始化散列表。
其中roundUpToPowerOf2这个方法是根据构造函数中初始化的initialCapacity这个参数,找到大于这个数,且离这个数最近的2乘幂。
方法内的逻辑如下:
如果initialCapacity>=默认最大散列表长度,则取默认最大散列表长度;
如果initialCapacity<默认最大散列表长度,则判断参数最高位是否为1
为1则判断1的个数是否是否大于1个
如果大于1个则取最高位左移一位为散列表的长度
不大于1个则取这个参数本身为散列表的长度(这个条件分支已经可以确定参数本身就是2的乘幂)
不为1则最高位取1
总结一下,也就是只有传入参数为Map的时候,才会在构造函数里面初始化散列表,其他的构造函数,只是初始化了一些必要的参数。那么,我们平常用的new HashMap()的构造方法,是什么时候初始化散列表的呢?
请看put方法:
public V put(K key, V value) {
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
if (key == null)
return putForNullKey(value);
int hash = hash(key);
int i = indexFor(hash, table.length);
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
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++;
addEntry(hash, key, value, i);
return null;
}
第一行,当table==EMPTY_TABLE的时候,调用inflateTable方法,即是初始化散列表。
这里面的indexFor方法即表明了为什么散列表的长度应该是2的乘幂,它是直接使用求得的hash值与散列表的长度-1相与,得到元素应该放置的位置。
那个循环即是去表对应位置的链表里面找是否存在相同key,如果存在,即替换并返回原来的值。不存在,则调用addEntry方法:
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);
}
这儿就包括了扩容,还有对key为null时候的处理,从这儿可以看出,当元素个数大于table.length*loadFactor(threshold)时,就进行扩容。
而且当key为null的时候,直接将hash值置0,也就是说,散列表的0号索引对应的是key=null的元素。
createEntry方法则是向散列表对应位置添加一个entry,具体实现大致如下:
拿到原有链表的头,然后把头的引用给新元素的next。
总结,HashMap是一个数组+链表的结构,每一个数组元素就是一个链表;每次放入元素都是放在链表的头部;对元素的重复判断主要是依靠==和equals方法。