HashMap基本用法
通过HashMap与Hashtable比较:
- HashMap能接受为null的键和值,Hashtable键和值都不能为null(通过put方法跟踪源码就一目了然);
- HashMap是非synchronized的,所以快,Hashtable是synchronized,相对慢(源码);
- HashMap 数组+链表 的存储结构,存储键值对;而一般的集合List、Set则是存储单个对象。
HashMap的工作原理
HashMap是基于hashing的原理,我们在使用put(key,value)存储对象到HashMap中,使用get(key)从HashMap中获取对象;当我们给put()方法传递键和值时,我们先对键调用hashCode()方法,返回的hashCode用于找到bucket的位置来存储Entry对象。我们来看看这句话涉及的源码,首先从put(key, value)方法开始。
put()方法源码实现
HashMap存储结构在外层是数组,在源码中有:
static final Entry<?,?>[] EMPTY_TABLE = {};
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
- 1
- 2
- 3
在下面的put方法中,第2~4行,若是第一次操作HashMap,这里table必然是空的,需要去初始化。
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;
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
在第5行,key为null时,并没有抛出异常,说明HashMap中允许键为null值的。
在第7行,调用了hash方法,键作为参数传入。
这里看看hash()方法具体实现:
final int hash(Object k) {
int h = hashSeed;
if (0 != h && k instanceof String) {
return sun.misc.Hashing.stringHash32((String) k);
}
h ^= k.hashCode();
// This function ensures that hashCodes that differ only by
// constant multiples at each bit position have a bounded
// number of collisions (approximately 8 at default load factor).
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
对于hashCode(),它是一个本地方法,实质就是地址取样运算
public native int hashCode();
- 1
该方法第7行,调用了key的hashCode()方法,并通过一系列位运算,获取最后的hash值。
反观HashTable中put()方法中调用的hash()方法实现:
private int hash(Object k) {
// hashSeed will be zero if alternative hashing is disabled.
return hashSeed ^ k.hashCode();
}
- 1
- 2
- 3
- 4
HashMap在HashTable的基础上做了优化,我们继续HashMap的put()方法的源码研究。
在第8行:
int i = indexFor(hash, table.length);
- 1
通过返回的hash值找到(table中)bucket的位置来存储Entry对象。
/** * Returns index for hash code h. * 返回hashCode在table中的下标,以便存储Entry对象 */
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
- 2
- 3
- 4
- 5
- 6
- 7
- 8
在第9~17行,在判断value值是不是在HashMap中已经存在,存在的话就返回旧值。
在第20行,
addEntry(hash, key, value, i);
- 1
看方法名就知道,这里就是真正将键值对添加到HashMap中的方法。传入四个参数:hash值,key-value,以及该Entry对象存储的位置。看看具体实现:
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);
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
如果HashMap的大小(size)超过了阀值(threshold)并且该Entry对象存储的位置被占用了,这时候就需要“扩容”了。也就是所谓的rehash。
resize(2 * table.length)
- 1
将HashMap的大小扩充为原来大小的两倍,并且重新计算该Entry对象存储的位置。
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
Entry[] newTable = new Entry[newCapacity];
transfer(newTable, initHashSeedAsNeeded(newCapacity));
table = newTable;
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
通过resize()方法,我们可以看到在第9,10行,重新创建了容量是原来两倍大小的新Entry数组,并且在方法transfer()中会把原来Entry对象中的数据迁移到新的Entry中。
/** * Transfers all entries from current table to newTable. * 把所有的Entry对象从当前table(旧)转义到刚创建的table(新)中 */
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> 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
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
这一过程还是相当耗时的。
在addEntry方法中的第8行:
createEntry(hash, key, value, bucketIndex);
- 1
创建具体的Entry对象:
void createEntry(int hash, K key, V value, int bucketIndex) {
Entry<K,V> e = table[bucketIndex];
table[bucketIndex] = new Entry<>(hash, key, value, e);
size++;
}
/** * Creates new entry. */
Entry(int h, K k, V v, Entry<K,V> n) {
value = v;
next = n;
key = k;
hash = h;
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
这里很清晰了,根据之前计算的Entry对象的位置和键值对,创建了Entry对象。这里需要注意这行代码:
Entry<K,V> e = table[bucketIndex];
- 1
计算出来的该key在bucket中的下标bucketIndex,返回该下标在数组中存储的对象,然后通过Entry构造器,在新Entry对象中,最为next存储。这里就利用到了链表结构。后面会详细讲到,这就是整个put()方法的调用过程。
get()方法源码实现
关于通过键获取值的get(key)方法,我们需要了解其中的碰撞探测以及碰撞的解决办法。首先看看get()方法的实现:
public V get(Object key) {
if (key == null)
return getForNullKey();
Entry<K,V> entry = getEntry(key);
return null == entry ? null : entry.getValue();
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
key为null值这里就不看了,逻辑很简单。
在第4行,这里通过key获取到了Entry对象。我们看看getEntry()的具体实现:
final Entry<K,V> getEntry(Object key) {
if (size == 0) {
return null;
}
int hash = (key == null) ? 0 : hash(key);
for (Entry<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))))
return e;
}
return null;
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
在第6行,这里调用的key的hash方法,计算key的hashCode值。
在第7行,通过返回的hashCode值获取该key在bucket中的index(下标,索引),并返回该key对应的Entry对象在数组中的存在。
在9行if
语句中,首先判断计算的hash值和该Entry对象创建时存储的key的hash值是否相等,一般人会认为两个key比较时,只要hash值相等,这个key就相等,其实这是不对的,这里就涉及到了碰撞探测。换句话说,这里用到了HashMap的存储结构–数组+链表。
首先是数组,我们上面的代码中计算hashCode值在bucket中的位置,这个bucket就是数组(table);
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
- 1
若有两个key对象的hash值相同的话,也就是说两个值对象存储在同一个bucket中,这时候怎么获取值对象(value)?继续看第9行后面的部分:
&& ((k = e.key) == key || (key != null && key.equals(k))
- 1
首先用的运算符&&,也就是说光传入key的hash和存储在Entry中的hash值相等还不行,后面部分运算结果也得是true。后面“||”左面部分为true的条件是,该传入的key和获取的Entry对象中的key是同一个,这样肯定能精确获得想要的值对象(hash值相同,key也相同);“||”右面部分调用的key的equals()方法,返回结果为true,当然是同一个key,也就是说左右部分的运算都是为了找到同一个key。那么具体在同一个bucket中怎么同时存两个或多个hash值相同的不同key对象的呢?明白了怎么存的也就清楚了怎么取了。我们先回到:
// put() ==> addEntry() ==> createEntry()
void createEntry(int hash, K key, V value, int bucketIndex) {
Entry<K,V> e = table[bucketIndex];
table[bucketIndex] = new Entry<>(hash, key, value, e);
size++;
}
- 1
- 2
- 3
- 4
- 5
- 6
若前面已经通过put(key, value)形式存储的一个Entry对象,这里又来了一个key1,通过计算key和key1拥有相同的hashCode值,也即在bucket中的位置是一致的,即bucketIndex相同。
在第3行中,首先就是通过下标取出数组中的Entry对象;
在第4行中,新建了一个Entry对象,将新的key,value,计算的hash值以及上一个相同hash值的Entry对象一起保存在该新Entry对象中,然后在相同的bucket位置返回新的Entry对象,旧的Entry对象被挂起了;后来再来一个不同的key2,若计算得出的hash值也相同,刚新建的Entry对象也将变成后来这个新Entry对象的next被挂起。这样就是链表的形式存储了具有相同hash值的不同的key对象。
所以说,HashMap就是通过数组+链表的形式实现的。这里说到了怎么存具有相同hash值的不同key对象,取呢?
// get() ==> getEntry()
for (Entry<K,V> e = table[indexFor(hash, table.length)]; e != null; e = e.next) {
....
}
- 1
- 2
- 3
- 4
首先这里用到了for循环遍历,其实也说明了“数不止一个” 。在循环时用到了 e = e.next ,这里正是遍历挂起的Entry对象。
static class Entry<K,V> implements Map.Entry<K,V> {
final K key;
V value;
Entry<K,V> next;
int hash;
/** * Creates new entry. */
Entry(int h, K k, V v, Entry<K,V> n) {
value = v;
next = n;
key = k;
hash = h;
}
....
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
以上就是HashMap中最重要的存取方法:put(key,value),get(key)源码解析过程。了解了这些,下面常见的问题也很容易回答。
常见问题
当两个不同的键对象的hashCode相同时会发生什么?
它们会存储在同一个bucket位置的HashMap.Entry组成的链表中。若两个键的hashCode值相同,你如何正确取出值对象的?
当我们调用get(key)方法时,会先计算key的hashCode值,通过该值找到key在bucket(数组)中的位置,找到bucket位置后,循环遍历(next),并调用keys.equals()方法找到链表中正确的节点。什么是hash,什么是碰撞?
hash:是一种信息摘要算法,它还叫做哈希,或者散列。我们平时使用的MD5就属于Hash算法,通过输入key进行Hash计算,就可以获取key的HashCode(),比如我们通过校验MD5来验证文件的完整性。
碰撞:好的Hash算法可以出计算几乎出独一无二的HashCode,如果出现了重复的hashCode,就称作碰撞;
就算是MD5这样优秀的算法也会发生碰撞,即两个不同的key也有可能生成相同的MD5。如何减少碰撞?
使用不可变的,声明做final的对象,并且采用合适的equals()和hashCode()方法的话,将会减少碰撞的发生(若has不同对象的hashCode值都不相同,自然就不需要链表来存储了),提高效率。不可变性使得能够缓存不同键的hashCode,这将提高整个获取对象的速度(不需要遍历,速度当然就快了),使用String,Integer这样的wrapper类作为键是非常好的选择。为什么String, Interger这样的wrapper类适合作为键?
因为String是不可变的,是final的,已经重写了hashCode()和equals()方法,其他的Wrapper类也有类似的特点。不可变性是必要的,因为为了要计算hashCode值,就要防止键值改变,如果键值在put和get时,返回了不同的hash值,也就不能正确的从HashMap中获取想要的对象了;如果可以仅仅通过将某个对象声明成final就能保证hashCode是不变的,就可以这么处理。因为获取对象时,需要调用hashCode()和equals()方法,对键值对象正确重写这两个方法时非常重要的。如果两个不相等的键值对象返回不同的hash值,那么碰撞的几率会小很多,这样较少了不必要的对链表的操作,就能提高HashMap的性能。可以使用自定义的对象作为键吗?
当然可以,只要其遵守equals()方法和hashCode()方法规则,当键值对象插入HashMap中不会再改变就可以。如果HashMap的大小超过了负载因子(load factor)定义的容量,怎么办?
默认的负载因子是0.75,也就是说,当一个HashMap中填满了75%的bucket时,将会创建原来两倍大小的新bucket数组,并将原来的对象迁移到新创建的bucket中。static final float DEFAULT_LOAD_FACTOR = 0.75f;
- 1
重新调整HashMap大小存在什么问题吗?
由于HashMap是非线程安全的,在多线程环境下,会产生条件竞争。因为若两个线程都发现需要调整HashMap时,都会尝试去调整。我们看下扩容的源码:// put() ==> addEntry() ==> resize(2 * table.length) ==> transfer void transfer(Entry[] newTable, boolean rehash) { int newCapacity = newTable.length; for (Entry<K,V> e : table) { while(null != e) { Entry<K,V> 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
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
假设有两个不同的key:A,B对应了同一个hash值,假设当前bucket中存储最上面的是A,A.next = B。那么从上面的源码可以看到,当前是 A = e,e.next就是B,
第11行,先将e.next(B)处“清空”,因为newTable[i]目前只是空位置;
第12行,将A放入新bucket位置;
第13行,将当前对象e对象设置成B,继续while循环。
在B的循环中,B.next 为null,
第11行,此处newTable[i]是前面的A,这里赋值给了当前对象e(B)的next对象;
第12行,将B对象存储在该bucket位置;
第13行,next为null赋值给当前e,while循环为false,结束循环。
也就是说新的bucket中存储的最上面的是B,B.next = A,整个链表反过来了。这是为了避免尾部遍历(tail traversing)。如果条件竞争发生了,那么就死循环了。所以在多线程环境下,不能使用HashMap。能否让HashMap同步?
HashMap可以通过下面的手段实现同步:Collections.synchronizedMap(hashMap);
- 1
如何提升HashMap的性能?
解决扩容损失:如果知道大致需要的容量,把初始容量设置好以解决扩容损失;
比如我现在有1000个数据,需要 1000/0.75 = 1333,又 1024 < 1333 < 2048,所以最好使用2048作为初始容量。
2048 = roundUpToPowerOf2(1333)public V put(K key, V value) { if (table == EMPTY_TABLE) { inflateTable(threshold); } ... } private void inflateTable(int toSize) { // Find a power of 2 >= toSize int capacity = roundUpToPowerOf2(toSize); ... } private static int roundUpToPowerOf2(int number) { // assert number >= 0 : "number must be non-negative"; return number >= MAXIMUM_CAPACITY ? MAXIMUM_CAPACITY : (number > 1) ? Integer.highestOneBit((number - 1) << 1) : 1; }