一、HashMap结构图(数组+链表)
1. entry是一个对象,里面存放key、value、hash值、next(下一个entry对象)
2. HashMap中的数组叫做table。size是指整个上述结构中,entry对象的数量
二、HashMap的put方法
HashMap可以说是JDK中一个的经典的轮子,其中有许多非常实用的方法,但是逻辑最复杂的,还是put(K key, V value)方法。
- 其中包括了找出一个比当前数大的2的次方数、计算key的hash值、数组扩容以及旧数组转移到新数组的一系列操作及问题。
- 下图是put方法的执行流程:
三、put方法流程详解
3.1 初始化数组:
- 当第一次放入entry元素,即数组为空时,会初始化数组。
- 初始化时,将构造方法传入的initCapacity进行计算,算出比initCapacity大的2的次方整数capacity(如7->8、15->16),并创建长度为capacity的数组。
- 计算比initCapacity大的2的次方整数capacity时,是用的Integer.highestOneBit()方法,这里就不细聊了。
3.2 key == null
- 因为HashMap允许key为null的情况存在,这种情况,我们会默认它的hash值为0,即放在数组的第一个下标中,即table[0]。
3.3 key != null
- 计算key的hash值:
/**
* 根据key计算hashcode,计算完之后进行散列处理
* 避免与数组长度进行或运算时,总是得到相同的值
*/
final int hash(Object k) {
int h = hashSeed;
// if (0 != h && k instanceof String) {
// return sun.misc.Hashing.stringHash32((String) k);
// }
h ^= k.hashCode();
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
可以看到,求出key的hashcode之后,还对哈希值进行了很多次的右移运算,这样做的好处,我们之后再说。
- 计算key的hash值之后,会让该hash值与数组长度进行&运算,像这样: hash & table.length,得到的值为i,i就是这个key所在的数组下标,而hashcode的作用就是为了可以使数组的下标计算随机化。这样我们就能看出来上述的右移运算的作用了,可以使hash算法的散列化效果充分发挥,如果仅仅只是计算key的hashcode作为最终hash值,那么很有可能会发生数组占用效率低,而链表过长的情况。看到这里不得不感叹,JDK的工程师真的nb。
3.4 替换或创建entry
- (1)找到相应的数组下标之后,首先判断该元素是否为null,如果为null,那么说明该位置还没有entry元素,直接放入就好。
- (2) 如果不为null,说明有一个以上的entry元素组成的链表,则遍历该链表,对比key的值。
- 注意:这里要先对比key的hash值是否相等,如果不相等再用==和equals比较值,这样会提高比较的效率,因为hash值不同的两个对象,一定不相同。
- 如果key相同,则覆盖之前的value值。
- 如果整个链表没有相同的key,那么就新建一个entry元素,放入链表头,这就是大家一直说的头插法。
3.5 扩容
其实这一步,是创建新的entry数组之前的逻辑。但是因为比较复杂,所以放在这里说明:
- 每次创建新的entry之前,都会先判断一下:(entry的数量是否达到了阈值)&&(当前新建entry的数组元素是否 != 空),满足条件之后,才会进行扩容;
- 扩容的过程就是把创建一个比原本长度大两倍的数组,因为数组长度改变,所以要把现有的entry链表分别根据hash值和新长度再计算新数组的下标,这个过程需要遍历整个HashMap,效率很低,所以,当我们在使用HashMap时,要尽量预估好需要存放的entry元素个数,尽量避免扩容。
四、HashMap在多线程下,扩容时的弊端
- 先看扩容时的代码段:
/**
* 扩容函数
*
* @param newCapacity 扩容之后的数组长度
*/
private void resize(int newCapacity) {
Entry<K, V>[] oldTable = table;
int oldCapacity = oldTable.length;
// 当现在的数组长度已经到了Integer的最大容量
if (oldCapacity == Integer.MAX_VALUE) {
threshold = Integer.MAX_VALUE;
return;
}
Entry[] newTable = new Entry[newCapacity];
transfer(newTable);
table = newTable;
threshold = (int) Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
翻译一下就是如果当前数组长度已经达到Integer对象所能表示的最大值时,将阈值也设置成Integer最大值(一般不会触发);否则新建一个数组,长度为现任数组的2倍,并将旧数组的元素转移到新数组。
- 转移数组:transfer方法
- 代码段:
/**
* 将现有的表移动到新表中
*
* @param newTable 新表
*/
void transfer(Entry[] newTable) {
int newCapacity = newTable.length;
for (Entry<K, V> e : table) {
while (e != null) {
Entry<K, V> next = e.next;
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
总结一下就是循环整个数组和链表,先计算出key的对应新数组的下标,再将e的next元素指向新数组的下标,最后把e移动到数组下标的元素上。 如果这时候,是多线程环境下进行扩容,因为e.next = newTable[i],所以很容易发生循环链表,详情可以去bilibili搜一下hashMap的底层原理,很多视频讲的都很细。 为了解决这种并发执行的扩容问题,JDK提供了ConcurrentHashMap。
五、并发扩容的解决方案
5.1 从根本上解决:
使用HashMap时,估算好存放进Map的元素数量,这样就不会扩容,就不会引发扩容问题。
5.2 将整个HashMap加上锁:
即JDK的HashTable。HashTable就是将整个HashMap的put和get方法,加上synchronized关键字,然后直接锁住整个方法,这样虽然安全,但是效率极低,笔者并没有在实际的生产环境中见到有人用HashTable。
5.3 尽量不要使用ConcurrentHashMap
只有在不确定HashMap元素数量且还涉及到并发场景的情况下才推荐使用ConcurrentHashMap。
六、Fast-fail
在使用迭代器的过程中如果HashMap被修改,那么ConcurrentModificationException
将被抛出,也即Fast-fail策略。
当HashMap的iterator()方法被调用时,会构造并返回一个新的EntryIterator对象,并将EntryIterator的expectedModCount设置为HashMap的modCount(该变量记录了HashMap被修改的次数)。
HashIterator() {
expectedModCount = modCount;
if (size > 0) { // advance to first entry
Entry[] t = table;
while (index < t.length && (next = t[index++]) == null)
;
}
}
七、ConcurrentHashMap结构
7.1 结构图:
7.2 Segement
可以看到,所谓的并发安全的ConcurrentHashMap,可以看作是若干个小的HashMap组成一个ConcurrentHashMap,每一个小的HashMap都被称为Segement。这样扩容时就不用锁住整个表,只需要锁住当前操作的Segement即可,可以提高执行效率。
7.3 每个Segement里有几个Entry
而每个Segement里有几个Entry,其实取决于初始化ConcurrentHashMap时,传入的一个concurrencyLevel参数——并发级别,根据这个参数进行Segement数组的length计算。而根据整个ConcurrentHashMap的数组长度/Segement数组的length得到每个Segement中的entry数组长度。
7.4 查找元素的过程
Java 7中的ConcurrentHashMap的底层数据结构仍然是数组和链表。与HashMap不同的是,ConcurrentHashMap最外层不是一个大的数组,而是一个Segment的数组。每个Segment包含一个与HashMap数据结构差不多的链表数组。整体数据结构如下图所示。
在读写某个Key时,先取该Key的哈希值。并将哈希值的高N位对Segment个数取模从而得到该Key应该属于哪个Segment,接着如同操作HashMap一样操作这个Segment。
7.5 同步方式
Segment继承自ReentrantLock,所以我们可以很方便的对每一个Segment上锁。
对于读操作,获取Key所在的Segment时,需要保证可见性。具体实现上可以使用volatile关键字,也可使用锁。但使用锁开销太大,而使用volatile时每次写操作都会让所有CPU内缓存无效,也有一定开销。ConcurrentHashMap使用如下方法保证可见性,取得最新的Segment。
Segment<K,V> s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)
获取Segment中的HashEntry时也使用了类似方法
HashEntry<K,V> e = (HashEntry<K,V>) UNSAFE.getObjectVolatile
(tab, ((long)(((tab.length - 1) & h)) << TSHIFT) + TBASE)
对于写操作,并不要求同时获取所有Segment的锁,因为那样相当于锁住了整个Map。它会先获取该Key-Value对所在的Segment的锁,获取成功后就可以像操作一个普通的HashMap一样操作该Segment,并保证该Segment的安全性。 同时由于其它Segment的锁并未被获取,因此理论上可支持concurrencyLevel(等于Segment的个数)个线程安全的并发读写。
7.6 size操作
put、remove和get操作只需要关心一个Segment,而size操作需要遍历所有的Segment才能算出整个Map的大小。一个简单的方案是,先锁住所有Sgment,计算完后再解锁。但这样做,在做size操作时,不仅无法对Map进行写操作,同时也无法进行读操作,不利于对Map的并行操作。
为更好支持并发操作,ConcurrentHashMap会在不上锁的前提
逐个Segment计算3次
size,如果某相邻两次计算获取的所有Segment的更新次数(每个Segment都与HashMap一样通过modCount跟踪自己的修改次数,Segment每修改一次其modCount加一)相等,说明这两次计算过程中无更新操作,则这两次计算出的总size相等,可直接作为最终结果返回。如果这三次计算过程中Map有更新,则对所有Segment加锁重新计算Size。该计算方法代码如下
public int size() {
final Segment<K,V>[] segments = this.segments;
int size;
boolean overflow; // true if size overflows 32 bits
long sum; // sum of modCounts
long last = 0L; // previous sum
int retries = -1; // first iteration isn't retry
try {
for (;;) {
if (retries++ == RETRIES_BEFORE_LOCK) {
for (int j = 0; j < segments.length; ++j)
ensureSegment(j).lock(); // force creation
}
sum = 0L;
size = 0;
overflow = false;
for (int j = 0; j < segments.length; ++j) {
Segment<K,V> seg = segmentAt(segments, j);
if (seg != null) {
sum += seg.modCount;
int c = seg.count;
if (c < 0 || (size += c) < 0)
overflow = true;
}
}
if (sum == last)
break;
last = sum;
}
} finally {
if (retries > RETRIES_BEFORE_LOCK) {
for (int j = 0; j < segments.length; ++j)
segmentAt(segments, j).unlock();
}
}
return overflow ? Integer.MAX_VALUE : size;
}
八、对比
ConcurrentHashMap与HashMap相比,有以下不同点:
- ConcurrentHashMap线程安全,而HashMap非线程安全
- HashMap允许Key和Value为null,而ConcurrentHashMap不允许。
- 因为get()方法获取值为
null
会有多义性;可能是没有这个entry,也可能是entry的值为空 - 在并发的Hash表中,两次调用之间的map映射可能已更改。如果允许设置值为
null
,那么获取值为null
的情况,你并不知道映射是否已经更改。
- 因为get()方法获取值为
- HashMap不允许通过Iterator遍历的同时通过HashMap修改,而ConcurrentHashMap允许该行为,并且该更新对后续的遍历可见
ConcurrentHashMap参考:www.jasongj.com/java/concur…