这篇文章是在网上看了别人的博客再结合自己的理解写的,仅供自己复习用
hashmap的构成
hashmap由一个数组(桶bucket)和桶上所挂的链表组成
put()
当一个键值对放入hashmap时,首先会将key拿去得到一个hashcode从去寻找bucket
static final int hash(Object key) {
int h;
return key == null ? 0 : (h = key.hashCode()) ^ h >>> 16;
}
如果不存在bucket就将该hash值赋给一个bucket,如果存在就在,首先要与这个bucket上的entry进行==或.equals()。如果找到了相等的则覆盖,否则就在链表尾部插入一个新的entry节点
if (((HashMap.Node)p).hash == hash && ((k = ((HashMap.Node)p).key) == key || key != null && key.equals(k))) {
e = p;
} else if (p instanceof HashMap.TreeNode) {
e = ((HashMap.TreeNode)p).putTreeVal(this, tab, hash, key, value);
}
//put操作
但是如果链表后一直新插入一个节点会很容易导致hash冲突以及搜索时间过长,所以引入了转红黑树。当一个bucket上的entry数量大于8了的话就会转为红黑树。当数量为6就会转换回去
while(true) {
if ((e = ((HashMap.Node)p).next) == null) {
((HashMap.Node)p).next = this.newNode(hash, key, value, (HashMap.Node)null);
if (binCount >= 7) {
this.treeifyBin(tab, hash);
}
break;
}
if (((HashMap.Node)e).hash == hash && ((k = ((HashMap.Node)e).key) == key || key != null && key.equals(k))) {
break;
}
p = e;
++binCount;
}
}
//至于为什么是8我也没搞明白
get()
get()类似于put()。都是先把key转为hashcode然后去找对应的bucket,然后再去判断key是否和bucket上的entry相等,相等则拿到
hashmap扩展
引入两个重要概念,第一个是初始容量,第二个是装载因子
public HashMap(int initialCapacity, float loadFactor) ;
装载因子是一个当前bucket与最大值的比值,默认为0.75.
那么是在什么时候进行hashmap 的扩展呢?
public V put(K key, V value) {
if (key == null)
return putForNullKey(value);
int hash = hash(key);//计算键的hash值
int i = indexFor(hash, table.length);//通过hash值对应到桶位置
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))) {//注意这里的键的比较方式== 或者 equals()
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
addEntry(hash, key, value, i);//遍历单链表完毕,没有找到与键相对的Entry,需要新建一个Entry换句话说就是桶i是一个空桶;
return null;
}
还是这段代码,在put时如果没有对应的entry就会新建一个换句话说这个bucket是个空桶。既然是个空桶,新建这个entry必然是该bucket第一个节点,这就找到了扩容的时机
void addEntry(int hash, K key, V value, int bucketIndex) {
if ((size >= threshold) && (null != table[bucketIndex])) {//当size大于等于某一个阈值thresholdde时候且该桶并不是一个空桶;
/*这个这样说明比较好理解:因为size 已经大于等于阈值了,说明Entry数量较多,哈希冲突严重,那么若该Entry对应的桶不是一个空桶,这个Entry的加入必然会把原来的链表拉得更长,因此需要扩容;若对应的桶是一个空桶,那么此时没有必要扩容。*/
resize(2 * table.length);//将容量扩容为原来的2倍
hash = (null != key) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length);//扩容后的,该hash值对应的新的桶位置
}
createEntry(hash, key, value, bucketIndex);//在指定的桶位置上,创建一个新的Entry
}
/**
* Like addEntry except that this version is used when creating entries
* as part of Map construction or "pseudo-construction" (cloning,
* deserialization). This version needn't worry about resizing the table.
*
* Subclass overrides this to alter the behavior of HashMap(Map),
* clone, and readObject.
*/
void createEntry(int hash, K key, V value, int bucketIndex) {
Entry<K,V> e = table[bucketIndex];
table[bucketIndex] = new Entry<>(hash, key, value, e);//链表的头插法插入新建的Entry
size++;//更新size
}
上面两个重要的成员变量size和threshold
size记录的是map中包含的Entry的数量,而threshold记录的是需要resize的阈值 且 threshold = loadFactor * capacity ,capacity 其实就是桶的长度
上面的代码其实就对应一点,什么时候该扩容。当entry的数量大于阙值时就应该扩容。扩容的操作就是将数组的容量扩大两倍,为什么扩大两倍,后面会说。在扩大两倍只会重新把hash值放在新的桶的位置,并在桶后面跟上entry
扩容的过程
上面扩容的时候有个重要的函数
resize(2 * table.length);//将容量扩容为原来的2倍
现在来看下这个函数
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {//最大容量为 1 << 30
threshold = Integer.MAX_VALUE;
return;
}
Entry[] newTable = new Entry[newCapacity];//新建一个新表
boolean oldAltHashing = useAltHashing;
useAltHashing |= sun.misc.VM.isBooted() &&
(newCapacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
boolean rehash = oldAltHashing ^ useAltHashing;//是否再hash
transfer(newTable, rehash);//完成旧表到新表的转移
table = newTable;
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
/**
* Transfers all entries from current table to newTable.
*/
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;//引用next
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);//找到新表的桶位置;原桶数组中的某个桶上的同一链表中的Entry此刻可能被分散到不同的桶中去了,有效的缓解了哈希冲突。
e.next = newTable[i];//头插法插入新表中
newTable[i] = e;
e = next;
}
}
}
capacity就是数组的长度,在扩展了数组的长度后,会判断是否要重新计算hashcode,同时要完成旧链表到新链表的转移,并且将容量阙值threshold更新。
更新链表在tansfer方法里,遍历数组中所有的桶,再将entry重新挂上,采用头插法
hashmap线程不安全的原因
从上面也可以看到,如果采用多线程编程,next可能会改变,就会产生本应接在后面的entry跑到其他地方去了,更严重的产生循环死链表,所以hashmap是线程不安全的,如果要在多线程中运用要用concurrenthashmap
为什么扩容要扩2的倍数
在resize的时候,为什么我们要扩2倍呢?
resize(2 * table.length);//将容量扩容为原来的2倍
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 找到一个大于等于初始容量的且是2的幂的数作为实际容量
int capacity = 1;
while (capacity < initialCapacity)
capacity <<= 1;
this.loadFactor = loadFactor;
threshold = (int)Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
table = new Entry[capacity];
useAltHashing = sun.misc.VM.isBooted() &&
(capacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
init();
}
通过以上我们知道容量必须是2的幂,但为什么必须是2呢?我们在改变桶的定位时调用了一个indexfor()方法,我们去看一下
static int indexFor(int h, int length) {
return h & (length-1);
}
我们可以看到这里用的&,举例
System.out.println(5&3);
System.out.println(5%2);
这两个结果是一样的,因为如果除数为2的幂数和&2的幂数减1值是一样的,但&的运算速度比%快10倍,所以我们必须要扩容为2的倍数
rehash()
因为hash算法是将二进制低位拿来&操作,有时候会出现低八位&操作相等但值不相等的时候,这个时候就叫hash冲突。极端情况下,所有不同的key的hashcode都相等,为了防止这种情况,这种时候就要调用rehash