常用API
put过程:它会hash传入的key值,将hash的值&上map长度减一(这里用的是&而不是取模运算,应该是考虑到性能问题,这里是length-1是应为这样可以取到0到map.length-1的值)插入到对应的数组下标上面去。这时产生了一个问题就是2个key值hash到一个位置怎么办?这就是Hash碰撞,产生了Hash碰撞在当前数组位置产生一个链表,如果链表过长查询效率可能会减低,在jdk1.8上面达到了一定的程度会链表转红黑树增强查询效率。
get过程:hash传入的key值,将hash的值&上map长度减一在数组里面去找找到对应的数组位置如果不是传入的key值就顺着链表或红黑树继续往里面找,所以他的时间复杂度是O(1)。
扩容:当数据达到map总长度的加载因子倍数就会开始扩容
基本参数介绍
// 数组默认大小,默认为16,必须设值为2的n次幂,如果设值不是2的n次幂它会往大于你的值的最近的2次幂
// 比如你设map长度为9它会修改map长度为2的4次,也就是16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
static final int MAXIMUM_CAPACITY = 1 << 30;
// 加载因子,数组长度大于容量的0.75就会扩容,为什么是9.75?基于空间与时间的考虑,如果太满了可能会有很多的hash碰撞,hash碰撞越多,链表就会越长,查询效率就会太低,加载因子如果太小了就会导致扩容频繁,通过牛顿二项式推导得出,0.693是最为合适的值,java选择了0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;
jdk1.7和jdk1.8对比
jdk1.7
数据结构:数组+链表
扩容:1.7的扩容会先把当前数组长度乘2生成一个新数组,然后对原来的Map里面的值进行reHash.重新落入到新数组中。
链表插入方式:尾插法
jdk1.8
数据结构:数组+链表+红黑树
扩容:1.8的扩容有个高低位的概念,它将原来的key Hash过后&map长度,这时候会生成2种值要么是0,要么是数组长度,这时候是0的我们算作低位,数组长度的算作高位,这时候我们有2个链表一个低位链表一个高位链表,低位链表还是在原来的位置,高位链表移到原来位置加上数组长度的位置。
链表插入方式:尾插法
链表转红黑树判定:链表长度大于8,也就是第9个元素,还有一个条件是容量大于64
为什么要把阈值设为8呢?
下面是HashMap源码里面的注释。大致意思就是数组中链表长度达到相应的数字概率是多少(这里是通过泊松分布计算得出),前提是随机hash码和加载因子是0.75.可以看出来到8的时候概率是很低了。从这一方面可以看出HashMap正常情况下转红黑树的几率是极其小的。也可以侧面的反映出HashMap在1.7到1.8性能上的提升其实并不大,数据量极大的情况下是有提升的。
* Because TreeNodes are about twice the size of regular nodes, we
* use them only when bins contain enough nodes to warrant use
* (see TREEIFY_THRESHOLD). And when they become too small (due to
* removal or resizing) they are converted back to plain bins. In
* usages with well-distributed user hashCodes, tree bins are
* rarely used. Ideally, under random hashCodes, the frequency of
* nodes in bins follows a Poisson distribution
* (http://en.wikipedia.org/wiki/Poisson_distribution) with a
* parameter of about 0.5 on average for the default resizing
* threshold of 0.75, although with a large variance because of
* resizing granularity. Ignoring variance, the expected
* occurrences of list size k are (exp(-0.5) * pow(0.5, k) /
* factorial(k)). The first values are:
*
* 0: 0.60653066
* 1: 0.30326533
* 2: 0.07581633
* 3: 0.01263606
* 4: 0.00157952
* 5: 0.00015795
* 6: 0.00001316
* 7: 0.00000094
* 8: 0.00000006
// 链表转红黑树阈值
static final int TREEIFY_THRESHOLD = 8;
// 红黑树转链表阈值
static final int UNTREEIFY_THRESHOLD = 6;
// 链表转红黑树,map最小容量
static final int MIN_TREEIFY_CAPACITY = 64;
HashMap jdk1.7源码解析
public V put(K key, V value) {
// 第一次开始put值的时候,hash表是空的
// threshold是传进来的长度
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
// 这里证明了HashMap的key可以为空
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;
// 这里判断传进来的key是不是已经写过了,写过了就把value修改掉
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
// 下文有介绍modeCount存在的意义。这里不影响put主体流程,所以暂时可以不关心
modCount++;
// put进去
addEntry(hash, key, value, i);
return null;
}
private void inflateTable(int toSize) {
// Find a power of 2 >= toSize
// 这里就是空间修正代码,找到当前传的值,大于值且是2的指数次幂
int capacity = roundUpToPowerOf2(toSize);
// 这里是扩容指标
threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
table = new Entry[capacity];
initHashSeedAsNeeded(capacity);
}
void addEntry(int hash, K key, V value, int bucketIndex) {
// 这里判断是判断是否需要扩容
// 判断条件是map长度大于扩容阈值,而且当前数组下标必须放了值
if ((size >= threshold) && (null != table[bucketIndex])) {
// 扩容大小 -> map长度的2倍
resize(2 * table.length);
// 这里就是keu为null的时候会往数组第一个位置放
hash = (null != key) ? hash(key) : 0;
// indexFor就是&数组长度-1
bucketIndex = indexFor(hash, table.length);
}
// 元素放进去了
createEntry(hash, key, value, bucketIndex);
}
// ========================================扩容方法===============================
void resize(int newCapacity) {
// 容错不管它
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
// new 一个新的hash表
Entry[] newTable = new Entry[newCapacity];
// 核心方法
transfer(newTable, initHashSeedAsNeeded(newCapacity));
// 把新的table,扩容阈值重新写入
table = newTable;
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
void transfer(Entry[] newTable, boolean rehash) {
// 新的数组容量
int newCapacity = newTable.length;
// 遍历原来的table表
// 这里使用双重循环是因为我们的hashmap数据结构是数组加链表,第一重遍历数组第二重遍历链表找到元素开始reHash
for (Entry<K,V> e : table) {
while(null != e) {
// next为当前链表的下一个元素,为了后面的循环
// 1
Entry<K,V> next = e.next;
if (rehash) {
// rehash当前节点
e.hash = null == e.key ? 0 : hash(e.key);
}
// 用扩容后的hashmap长度计算出当前节点应该落到哪个节点上面去。
int i = indexFor(e.hash, newCapacity);
// 当前节点与别的节点指针断开指向新的节点位置
// 2
e.next = newTable[i];
// 把值赋给新的节点位置,从这可以看出它是使用的是头插法
// 3
newTable[i] = e;
// 为了新一轮的遍历
e = next;
}
}
}
HashMap并发场景下的死锁问题
(吐槽:在并发场景使用HashMap,想什么呢,HashMap本身是线程不安全的还敢在并发下用?)。
我们想一下现在有2个线程thread1,thread2,这2个线程都在进行扩容,都走到了transfer方法的2重循环内,这完全是可能的吧,因为它全程没有锁,因为它是线程不安全的。现在thread1走到了transfer代码段1(上面源码解析标注)的位置,已经把next节点定下来了时间片轮转到thread2了。thread2执行到代码段3,这时候thread1在继续往下走,最后会发现2个节点会出现循环链表。说不清,画个图来说吧。


HashMap多线程丢数据情况分析
代码定位到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++;
}
如果有多个线程同时拿的e,e应该是当前table[i]链表的第一个元素,然后你这时候拿到多个元素就会出现元素覆盖的请况,thread1头插法排到了第一个元素,而thread2拿到的e是第二个元素,用头插法,一插好家伙,吧我thread1插入的数据给顶掉了,最后thread1写的数据就丢失了,在jdk1.8这种情况也在发生,因为他是线程不安全的。注意,多线程不要用HashMap,请用ConcurrentHashMap
HashMap jdk1.8源码解析
hashMap jdk1.8跟1.7大体逻辑是差不多的,我们就看下改动比较大的几个点
树化
上面说了jdk1.8的HashMap有了红黑树的数据结构,那么他怎么才会变为红黑树呢?TREEIFY_THRESHOLD = 8
下面这段是put中的关键代码从这段代码可以看出,binCount>=7,所以循环肯定是8次也就是这个时候链表长度是8了。但在这个时候它先执行的是p.next = newNode(hash, key, value, null);然后在判断链表长度,所以链表长度是9的时候它才会满足树化的第一个条件走treeifyBin;不信可以测试一下
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
树化还有一个条件就是hashmap容量大于64,这个条件就在上面的treeifyBin方法内,第一个判断就是如果容量没有达到MIN_TREEIFY_CAPACITY=64就先走扩容策略,不走树化,反之直接树化(在else逻辑内)
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
else if ((e = tab[index = (n - 1) & hash]) != null) {
TreeNode<K,V> hd = null, tl = null;
do {
TreeNode<K,V> p = replacementTreeNode(e, null);
if (tl == null)
hd = p;
else {
p.prev = tl;
tl.next = p;
}
tl = p;
} while ((e = e.next) != null);
if ((tab[index] = hd) != null)
hd.treeify(tab);
}
}
扩容策略
上面说到了它的扩容策略和jdk1.7是有了极大的区别,现在我们来看一下源码是怎么实现的。源码太长就不细看了,主要逻辑就是它里面分了2组链表
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
这2组就是高位链表和低位链表,怎么往这2个链表里面放呢?if ((e.hash & oldCap) == 0) 满足这个条件就是低位链表,简单来说就是&上老的容量算出来只有可能为0或hashmap的长度,为0算低位,不为0算高位。newTab[j] = loHead; newTab[j + oldCap] = hiHead;这2段代码就表明了它们应该落得位置。
(1.8的HashMap源码太复杂了,全是位运算和判断逻辑看的心累,对于1.8的源码就不详细写了。)
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
modCount字段是干嘛的
在很多集合类都有modCount的身影,那么它到底是干嘛的呢?我们发现它存在的地方都是对集合进行了修改操作比如增加元素,删除元素。而且看过ConcurrentHashMap源码发现没有这个参数了,那么很明显它可能跟线程安全有关。还记得我们在hashMap遍历的时候如果对其进行元素的操作会发生ConcurrentModificationException异常。其实他就跟modCount有关,在迭代过程中,它会判断 modCount 跟 expectedModCount 是否相等,不等说明有其他线程在修改元素,就抛出了异常。这是Fail-Fast 机制