java中HashMap是如何实现的?
HashMap底层使用的是数组加链表的数据结构实现。
java中HashMap是如何使用的?
首先,得new一个HashMap对象不是?很多人都是用默认的无参构造器来创建HashMap对象的。看一下jdk1.6中的源码
public HashMap() {
//加载因子,默认0.75
this.loadFactor = DEFAULT_LOAD_FACTOR;
//当HashMap中存储的键值对数超过threshold,会引起扩容
//默认threshold为 默认数组长度*默认加载因子 即16*0.75
threshold = (int)(DEFAULT_INITIAL_CAPACITY * DEFAULT_LOAD_FACTOR);
//创建entry数组,默认值16
table = new Entry[DEFAULT_INITIAL_CAPACITY];
init();
}
注释写的很清楚,默认创建一个大小为16的entry数组,当HashMap里存的entry个数达到12,就会发生扩容,这当然会影响HashMap的性能不是?所以,推荐咱们使用HashMap提供的另外一个构造器。
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
调用的是HashMap提供的另一个构造器,传入的加载因子为默认的0.75,看源码
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);
// Find a power of 2 >= initialCapacity
int capacity = 1;
while (capacity < initialCapacity)
capacity <<= 1;
this.loadFactor = loadFactor;
threshold = (int)(capacity * loadFactor);
table = new Entry[capacity];
init();
}
假如现在我要往HashMap里存12个键值对,那么经过代码段1:
while (capacity < initialCapacity)
capacity <<= 1;
计算得出的capacity为16,然后再经过代码段2:
threshold = (int)(capacity * loadFactor);
table = new Entry[capacity];
创建了一个threshold为12,数组大小为16的entry数组。刚好达到我们的要求。
但是,如果我想要往HashMap里存16个键值对,那么经过代码段1,计算得出的capacity依然为16,然后经过代码段2创建了一个threshold为12,数组大小为16的entry数组。ok那当我往这个HashMap里存入第13个键值对的时候,就会引起扩容(因为threshold =12),扩容操作如果说基数小的话,对性能的影响可能不大,但随着基数的变大,对性能的影响也会随之变大。
所以,推荐在使用这个构造器的时候,我们先计算一下,设你想存x个键值对,那么你应该传入的参数为y。默认加载因子为0.75,那么先用x除以0.75,然后再计算出比x/0.75大的最小的2的幂。
ok,带入计算公式,我要存12个键值对,x=12,则x/0.75=16,因为2^4=16,所以y=16
我要存15个键值对,x=15,x/0.75=20,那么大于20的最小的2的幂为32,y=32,我们传入参数32。这样就不会发生扩容。
当然,理想很丰满,现实很骨感,实际开发中,我们基本上是无法确定我们要存入多少个键值对的,所以,管它呢,咱大概估一个值,传入就行了,但是最好不要使用默认的无参的构造器,原因很简单,假设我们现在要大概要存100个建值对,使用无参构造器,在存入16 * 0.75=12个建值对 的时候会发生第一次扩容,扩容为16 * 2=32,然后在插入32 * 0.75=28个建值对的时候发生第二次扩容,扩容为32 * 2=64,依次类推。但是我们调用new HashMap(100),那么,只会在我们插入第128 * 0.75=96个建值对的时候发生一次扩容。对性能的提升由此可见一斑。
然后,当我们创建好HashMap的实例后,我们会调用它的put方法插入键值对,看源码:
public V put(K key, V value) {
//step 1
if (key == null)
return putForNullKey(value);
//step 2
int hash = hash(key.hashCode());
//step 3
int i = indexFor(hash, table.length);
//step 4
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++;
//step 5
addEntry(hash, key, value, i);
return null;
}
step1:如果传入的key为空,那么返回空键对应的值(HashMap允许一个null键)。
step2:把key的hashcode经过一个hash算法,得到一个hash值。(这个方法咱不讨论,那是数学家研究的事)
step3:根据上一步得到的hash值定位数组的下标。
step4:上一步定位到数组的下标的位置如果有元素,取第一个元素,判断他们的hash值是否相同,如果相同,再判断两个对象是否相等。如果hash值不同,或hash值相同,却不相等,判断第二个元素。直到判断完所有的元素。
step5:把当前元素作为数组下标位置的第一个元素,把之前的元素设为新插入元素的next节点,判断是否超过threshold,超过则扩容(addEntry逻辑说明)。
这里需要注意的是step3中,根据hash值定位数组下标后,这个下标处的元素的hash值是不一定相等的。为什么?看step3处的方法的源码:
static int indexFor(int h, int length) {
return h & (length-1);
}
ok,假如传进来的hash值为1,那么返回的下标是1 & 15
java中int类型是32位那么 1 & 15 如下:
0000 0000 0000 0000 0000 0000 0000 0001 -1的二进制表示
0000 0000 0000 0000 0000 0000 0000 1111 -15的二进制表示
按位与操作只有两个数都是1才会返回1,否则返回0。
那么显然,1 & 15 得1,即返回的数组下标为1。
咱再来看看如果传进来的hash值是17,会怎样。
ok,若传进来的hash值为17,则返回 17 & 15
0000 0000 0000 0000 0000 0000 0001 0001 -17的二进制表示
0000 0000 0000 0000 0000 0000 0000 1111 -15的二进制表示
对的整整齐齐,明明白白,一眼就能看出17 & 15 = 1
综上,不同的hash值通过下标是可以定位到相同的数组位桶的。
这里的这个定位算法和数组的长度也是有关系的,为什么HashMap的数组长度是2的幂?
咱假设这里不是二的幂,看看会出现啥情况。
若数组的长度是10,indexFor方法返回的是 h & 9 则
0000 0000 0000 0000 0000 0000 0000 1001 -9的二进制表示
看到最后4位,中间的两个0了么?无论你传进来的hash值是多少,中间那两个0相与,永远返回0。这样一来,就大大的增加了hash碰撞的概率。
所以这时我们再来看看构造器中这一段代码:
while (capacity < initialCapacity)
capacity <<= 1;
它能保证初始化的数组的大小一定是2的幂。以便在定位数组位桶的下标时使用。
最后,我们使用get方法,从HashMap中找到我们想要的键值对。来看看get方法的源码:
public V get(Object key) {
if (key == null)
return getForNullKey();
int hash = hash(key.hashCode());
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.equals(k)))
return e.value;
}
return null;
}
这个就不用多说了吧。先hash,再定位,再比较hash值,再equals。。。
HashMap使用的一些建议
一般而言,HashMap的key是要用类似String、Integer这样的不可变类的。夺西碟?
咱们看看String类的hashCode方法:
//成员变量hash,初始值为0.
private int hash;
public int hashCode() {
int h = hash;
int len = count;
//如果h==0代表第一次调用此方法,调用完之后,把计算出来的值付给成员变量hash
//第二次调用此方法时,h == 0 返回 false,直接return h。
if (h == 0 && len > 0) {
int off = offset;
char val[] = value;
for (int i = 0; i < len; i++) {
h = 31*h + val[off++];
}
hash = h;
}
return h;
}
使用String当做key,只会在第一次调用hashCode方法时,计算出hash值,以后的调用直接使用已缓存的hash值即可。这样设计,又能大大的提升HashMap的性能。
所以,推荐使用这种不可变类。
当然,如果你想用自定义的类当做key,那么,请参考String类的设计,而且,这个你自定义的类必须是不可变得,至少参与hashCode计算的字段是不可变的,不然,会出现存进去的键值对和取出来的键值对不一致。