本文已参与掘金创作者训练营第三期「话题写作」赛道,详情查看:掘力计划|创作者训练营第三期正在进行,「写」出个人影响力
hashMap
HashMap底层基于数组+链表。
- 扩容
初始化大小16,负载因子0.75 ,当存放数据的数量大于16*0.75,就会进行扩容。扩容的数量为2下一次幂(16后面为32......)。
有一个很重要的方法,包含了几乎属于的扩容过程,这就是resize()
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;
}
}
}
旧桶数组中的某个桶的外挂单链表是通过头插法插入新桶数组中的,并且原链表中的Entry结点并不一定仍然在新桶数组的同一链表。
为什么是扩容2倍呢?
答案当然是为了性能。在HashMap通过键的哈希值进行定位桶位置的时候,调用了一个indexFor(hash, table.length);方法。
/** * Returns index for hash code h. */
static int indexFor(int h, int length) { return h & (length-1); }
将哈希值h与桶数组的length-1(实际上也是map的容量-1)进行了一个与操作得出了对应的桶的位置,h & (length-1)。
Java的%、/操作比&慢10倍左右,因此采用&运算会提高性能
通过限制length是一个2的幂数,h & (length-1)和h % length结果是一致的。 这就是为什么要限制容量必须是一个2的幂的原因。
- 实现原理
当put时,会调用HashCode获得哈希地址。然后查看数组中这个哈希地址上有没有元素。
1.如果有,那肯定存放在链表中,遍历链表查看是否有相同的值,如果相等则进行覆盖,并返回原来的值。如果链表中没有相同的值,就插入链表中。
2.如果当前地址没有元素,就会新增1个Entry对象写入。当新增时会判断是否需要扩容。如果需要就两杯扩容, 将当前的key重新hash并定位。
- 1.7 的缺陷
1.链表插入使用头插法,并发的情况下可能会导致死锁。
2.Hash 冲突严重时,如果一个哈希地址中的链表长度多了,就会影响效率O(n) 。
- 1.8的优化
1.当链表长度大于8时,将链表转换为红黑树,提高查询效率O(logn) 。
2.将链表的插入改成尾插法。
- 遍历
1.使用EntrySet 进行便利,能够同时获取到key和value
2.使用Iterator获得key,但是还要通过get才能获得value,效率低。
HashMap中实际中存放数据是在Entry中,这个是HashMap的内部类
put
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
concurrentHashMap
1.7
ConcurrentHashMap是由Segment数组结构和HashEntry数组结构组成。
一个ConcurrentHashMap被多个Segment分成多段,而一个Segment又有多个HashEntry。
- segment
static class Segment<K,V> extends ReentrantLock implements Serializable {
private static final long serialVersionUID = 2249069246763182397L;
final float loadFactor;
Segment(float lf) { this.loadFactor = lf; }
}
segment 时一个内部类, 继承了ReentrantLock锁。当新增修改时,会通过hash(key)定位到哪一个segment,并给这个segment加锁(分段锁),不会影响到其他的segment的操作。锁的粒度变小,性能更高。
- put
1.通过hash(key)找到Segment,先去获取Segment的锁,如果获取失败,利用 scanAndLockForPut() 自旋获取锁。如果超过最大次数,就改为阻塞锁获取,保证能获取成功。
2.拿到锁后,在Segment中通过hash(key)找到HashEntry,遍历链表是否有相等,没有就插入,有就覆盖。新建时需要扩容判断,put成功,解除锁。
- get
hash(Key)找到Segment,通过hash(key)找到hashEntry。从链表中找到对应元素。
HashEntry中的value时volatile的,保证内存可见性。 get方法不需要加锁。
1.8
1.8中也将链表改成了红黑树,提高查询效率。
抛弃Segment锁,改成Sync+CAS,将HashEntry改成Node,但作用相同。使用了经过优化的sync
1.7中使用Segment和Entry,需要二次哈希,并且锁住的是一段HashnEntry。
1.8中put和get不用二次哈希,一把锁只锁住一个链表或者一棵树,并发效率更加提升