HashMap与
ConcurrentHashMap`源码解析
JDK
版本:1.7 & 1.8
开发中常见的数据结构有三种:
1、数组结构:存储区间连续、内存占用严重、空间复杂度大
- 优点:因为数组是连续的,所以随机读取和修改效率高,随机访问性强,查找速度快。
- 缺点:因为数组是连续的,当需要往数组中某一个位置插入一个元素时,这个位置后的所有元素都需要往后移动,并且数组大小固定,不容易进行动态扩展。
2、链表接口:存储区间离散、内存占用宽松、空间复杂度小
- 优点:插入、删除速度快,只需要修改指向下一个对象的指针即可。同时链表结构内存利用率高,没有固定大小,动态扩展灵活。
- 缺点:查找效率低,每次查询元素都需要从第一个元素开始遍历查找。
3、哈希表结构:结合了数组结构与链表结构的优点,从而实现查找、修改、插入、删除效率都高的一种数据结构
常见的HashMap
的底层实现在JDK 8
版本前后都有不同,在JDK 8
之前HashMap
底层采用数组+链表的数据结构实现,称之为Entry
。在JDK 8
之后HashMap
底层采用数组+链表+红黑树实现称之为Node
。
1.JDK 7 HashMap
JDK 7
中HashMap
的底层数据结构是数组和链表。其具体结构如下图所示:
其中Entry
的定义如下:
static class Entry<K, V> implements Map.Entry<K, V> {
final K key;
V value;
Entry<K, V> next;
int hash;
}
1.1put()
过程
相较于从HashMap
中获取元素,可以先看HashMap
是如何存储元素的。JDK 7
中HashMap
的put()
方法,还是比较简单的:
public V put(K key, V value) {
// 判断table是否为空, 如果为空则初始化数组大小
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
// 如果 key 为 null, 最终会将这个entry放到table[0]中, 所以这也是为什么HashMap支持key为null
if (key == null)
return putForNullKey(value);
// 1. 计算key的hash值, 返回一个32位的int整形
int hash = hash(key);
// 2. 根据key的hash值, 找到对应的数组下标。也就是找到当前key对应的value位于哪个桶中
int i = indexFor(hash, table.length);
// 3. 根据计算出的数组下标, 使用数组的随机访问获取对应数组桶上的链表, 遍历获取到的单向链表
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
// 链表元素的hash值与当前key计算出的hash值相等 && (链表元素中的key与当前key相等 || 链表元素中的key.equals(当前key))
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
// 进入该方法证明有此链表上存在重复的key
// 获取当前链表中元素的value
V oldValue = e.value;
// 使用新value直接进行覆盖
e.value = value;
e.recordAccess(this);
// 返回oldValue, put方法结束
return oldValue;
}
}
modCount++;
// 4. 不存在重复的 key, 将此entry添加到链表中,
addEntry(hash, key, value, i);
// 返回null
return null;
}
数组初始化inflateTable(threshold)
方法,当往HashMap
中put()
元素时,首先会检查此时数组是否为空,如果为空则会触发数组的初始化,在第一次往HashMap
中put()
元素时会执行这个方法:
private void inflateTable(int toSize) {
// 保证数组大小一定是2的n次方。它会计算出比threshold大的最小的2的次方数
// 比如这样初始化:new HashMap(20),那么处理成初始数组大小是32
int capacity = roundUpToPowerOf2(toSize);
// 计算扩容阈值:capacity * loadFactor, 如果capacity * loadFactor大于HashMap的最大容量则默认使用MAXIMUM_CAPACITY + 1
threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
// 初始化数组
table = new Entry[capacity];
initHashSeedAsNeeded(capacity); //ignore
}
在执行数组初始化的时候,将数组大小保持为2
的n
次方,这一点很重要,不论是JDK 7
还是JDK 8
中对HashMap
的实现都是有相应的要求,因为后续会使用这个值做按位与操作。
然后就是根据key
的hash
值与数组的长度计算出key
的数组下标,indexFor(hash, table.length)
方法:
static int indexFor(int hash, int length) {
// assert Integer.bitCount(length) == 1 : "length must be a non-zero power of 2";
return hash & (length-1);
}
在初始化数组的方法中可以看到,数组的长度始终是2
的次方。而使用key
计算出的hash
值是一个32
位的整形。所以这里hash & (length-1)
就是做按位与,比如此时数组长度为2
的n
次方,那么此时计算出来的值就是hash
值的低n
位。
举个实际的例子来说:
比如现在往
new HashMap<String, String>(1<<4)
中put()
一个值,比如这个值为kapcb
。 根据上面分析的代码执行流程,首先会对这个
HashMap
的数组进行初始化。初始化完成之后由于这里创建HashMap
的时候指定了初始容量的大小,所以数组的长度为16
。然后计算出key
的hash
值,也就是kapcb
的hashCode
,假设计算后得到的值为2759
。那么hash & (length-1)
这行代码做的事情就是这样的:
key
的hash
为2759
,对应二进制为:0000 0000 0000 0000 0000 1010 1100 0111
数组长度为
16
,(length-1)
后得出的结果是15
,对应二进制为:0000 0000 0000 0000 0000 0000 0000 1111
hash & (length-1)
其实就是取低4
位:0000 0000 0000 0000 0000 1010 1100 0111 & 0000 0000 0000 0000 0000 0000 0000 1111 ------------------------------------------- 0000 0000 0000 0000 0000 0000 0000 0111
此时
hash & (length-1)
计算出的结果就是0000 0000 0000 0000 0000 0000 0000 0111
,也就是7
。所以此时key
对应的数组下标就是7
。这也是为什么HashMap
会要求数组初始容量必须是2
的n
次方,因为当执行length - 1
这步操作之后,对应二进制上的低位就全部是1
,此时在做按位与的话,就能够保证取到的一定是key
的hash
值的低n
位。 所以,根据上面的分析也会得出,如果在创建
HashMap
实例的时候使用的是HashMap
内部默认的capacity
值。那么其实也就是取key
的hash
值的低5
位。
计算出key
对应的数组下标之后,首先会获取数组对应下标上的单向链表,并对链表做遍历操作。如果在遍历链表的过程中,存在某一个节点的hash
值和key
与此时put()
的元素的hash
值与key
相等,则直接对其进行覆盖并返回,此时put()
函数就执行完成了。
反之,就会将此时put()
的元素添加到链表中:
void addEntry(int hash, K key, V value, int bucketIndex) {
// 如果当前HashMap大小已经达到了阈值 && 新值要插入的数组位置已经有元素了, 则需要进行扩容
// 这里的size记录的是HashMap中元素的总数, 也就是说当HashMap中元素的总数大于threshold时会触发扩容机制
if ((size >= threshold) && (null != table[bucketIndex])) {
// 扩容
resize(2 * table.length);
// 扩容以后, 重新计算hash值, 这里可以看到, HashMap针对key为null的情况是将其放在table[0]上的
hash = (null != key) ? hash(key) : 0;
// 重新计算扩容后的新的下标
bucketIndex = indexFor(hash, table.length);
}
// 创建新节点并将使用头插法将元素添加到链表中
createEntry(hash, key, value, bucketIndex);
}
// 这个很简单, 其实就是将新值放到链表的表头, 然后size++
void createEntry(int hash, K key, V value, int bucketIndex) {
// 将数组中该位置上的元素赋值给e
Entry<K,V> e = table[bucketIndex];
// 将新元素放入数组中对应下标的位置, 并将Entry的next设置为e
table[bucketIndex] = new Entry<>(hash, key, value, e);
// 添加完成之后size++
size++;
}
在将新元素插入到链表中时,首先会判断此时HashMap
的size
是否超过了阈值,如果需要扩容则需要重新计算该数据在扩容后的table
中的下标值,然后使用头插法将新元素添加到链表的头部。
1.2resize()
数组扩容
根据前面的源码分析,可以看到当往HashMap
中添加新元素时,如果此时HashMap
的size >= threshold
或者数组对应下标上的值为null
时,会触发HashMap
的扩容机制,也就是resize(2 * table.length)
这行代码:
void resize(int newCapacity) {
// 获取当前的数组
Entry[] oldTable = table;
// 获取当前数组的容量
int oldCapacity = oldTable.length;
// 如果当前数组的容量 == HashMap默认的最大容量
if (oldCapacity == MAXIMUM_CAPACITY) {
// 那么将Integer.MAX_VALUE设置为HashMap的扩容阈值
threshold = Integer.MAX_VALUE;
// 直接返回
return;
}
// 根据新的大小创建一个新的数组
Entry[] newTable = new Entry[newCapacity];
// 将原来数组中的值迁移到新的更大的数组中
transfer(newTable, initHashSeedAsNeeded(newCapacity));
// 将新的table赋值给table进行替换
table = newTable;
// 重新设置HashMap的扩容阈值
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
扩容就是将创建一个容量翻倍的数组,将老数组上的数据迁移到新的数组上的过程。
由于是双倍扩容,在迁移的过程中,会将原来table[i]
中的链表的所有节点,分拆到新的数组的newTable[i]
和newTable[i+oldLength]
位置上。
比如在扩容之前,
HashMap
的数组长度是16
。那么在扩容之后,HashMap
的数组长度会变为32
。原来处于table[1]
链表上的所有元素会被分配到新数组的table[1]
和table[17]
这两个位置上。
1.3get()
过程
了解了HashMap
的put()
过程,get()
自然就容易理解得多。get()
的过程大致分为以下三步:
- 根据
key
计算出hash
值。 - 根据
hash
值计算出key
对应的数组下标,hash & (length - 1)
。 - 根据数组的随机访问机制,从数组中获取上一步计算出的数组下标上的链表。遍历链表,当存在
== / equals
的key
时,返回结果。否则返回null
。
public V get(Object key) {
// 首先判断key为null的话, 直接从table[0]获取链表进行遍历返回结果
if (key == null)
return getForNullKey();
// 根据key获取数组中指定下标上的链表, 然后遍历找到符合条件的entry返回
Entry<K,V> entry = getEntry(key);
// 如果entry为null, 则代表HashMap中不存在该key返回null, 否则返回对应的value值
return null == entry ? null : entry.getValue();
}
getEntry(key)
方法源码:
final Entry<K,V> getEntry(Object key) {
// 首先判断HashMap中是否有元素, 如果没有元素则直接返回null即可
if (size == 0) {
return null;
}
// 获取key的hash值
int hash = (key == null) ? 0 : hash(key);
// 确定数组下标, 然后从头开始遍历链表, 直到找到为止
for (Entry<K,V> e = table[indexFor(hash, table.length)];
e != null;
e = e.next) {
Object k;
// hash值相等 && (== || equals key) 判断链表中的元素节点的key是否与目标key相等
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
// 找到Entry直接返回
return e;
}
// 没找到直接返回null
return null;
}
可以看到JDK 7
中HashMap
的存取还是非常简单的,代码的思路也比较清晰。
2.JDK 7 ConcurrentHashMap
JDK 7
中的ConcurrentHashMap
其底层数据结果可以理解为数组和HashMap
。其具体结构如下图所示:
整个ConcurrentHashMap
是由一个个的Segment
组成的,Segment
代表部分或一段的意思,所以很多地方都会将其描述为分段锁。
简单理解就是,ConcurrentHashMap
是一个Segment
类型的数组,Segment
通过继承ReentrantLock
来进行加锁,所以每次需要加锁的操作锁对象就是一个Segment
,这样只要保证每个Segment
是线程安全的,也就实现了容器的线程安全。
/**
* Stripped-down version of helper class used in previous version,
* declared for the sake of serialization compatibility
*/
static class Segment<K,V> extends ReentrantLock implements Serializable {
private static final long serialVersionUID = 2249069246763182397L;
final float loadFactor;
Segment(float lf) { this.loadFactor = lf; }
}
concurrencyLevel
:称为并行级别、并发数、Segment
数。它的默认值是16
,也就是说ConcurrentHashMap
拥有16
个Segment
。多以理论上,只要线程的操作分别在不同的Segment
上,那么最多可同时支持16
个线程并发写。这个值在ConcurrentHashMap
初始化时可以进行设置,ConcurrentHashMap
有提供此参数的构造器,但是一旦初始化之后,它的值是不可改变的,也就是说Segment
是不可扩容的。
/**
* The default concurrency level for this table. Unused but
* defined for compatibility with previous versions of this class.
*/
private static final int DEFAULT_CONCURRENCY_LEVEL = 16;
每个Segment
内部,其实与HashMap
一样,不过它会保证线程安全。
与HashMap
一样,在ConcurrentHashMap
内部同样有一些关键的变量:
initialCapacity
:ConcurrentHashMap
的初始容量,这个值是整个ConcurrentHashMap
的初始容量,实际操作的时候需要平分给每个Segment
。loadFactor
:负载因子。因为当ConcurrentHashMap
在初始化完成之后,Segment
是不可进行扩容的,所以这个loadFactor
是给每个Segment
内部进行扩容使用的。
ConcurrentHashMap
提供的配置concurrencyLevel
的构造器:
public ConcurrentHashMap(int initialCapacity,
float loadFactor, int concurrencyLevel) {
if (!(loadFactor > 0) || initialCapacity < 0 || concurrencyLevel <= 0)
throw new IllegalArgumentException();
if (concurrencyLevel > MAX_SEGMENTS)
concurrencyLevel = MAX_SEGMENTS;
int sshift = 0;
int ssize = 1;
// 计算并行级别ssize,因为要保持并行级别是2的n次方
while (ssize < concurrencyLevel) {
++sshift;
ssize <<= 1;
}
// 默认值concurrencyLevel为16, sshift为4
// 那么计算出segmentShift为28, segmentMask为15, 后面会用到这两个值
this.segmentShift = 32 - sshift;
this.segmentMask = ssize - 1;
// 为initialCapacity设置最大默认值
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
// initialCapacity是设置整个map初始的大小,
// 这里根据initialCapacity计算每个Segment数组中每个位置可以分到的大小
// 如initialCapacity为64, 那么每个Segment或称之为"槽"可以分到4个
int c = initialCapacity / ssize;
if (c * ssize < initialCapacity)
++c;
// 默认MIN_SEGMENT_TABLE_CAPACITY是2,这个值也是有讲究的,因为这样的话,对于具体的槽上,
// 插入一个元素不至于扩容,插入第二个的时候才会扩容
int cap = MIN_SEGMENT_TABLE_CAPACITY;
while (cap < c)
cap <<= 1;
// 创建Segment数组,
// 并创建数组的第一个元素segment[0]
Segment<K,V> s0 =
new Segment<K,V>(loadFactor, (int)(cap * loadFactor),
(HashEntry<K,V>[])new HashEntry[cap]);
Segment<K,V>[] ss = (Segment<K,V>[])new Segment[ssize];
// 往数组写入 segment[0]
UNSAFE.putOrderedObject(ss, SBASE, s0); // ordered write of segments[0]
this.segments = ss;
}
初始化完成之后,就会得到一个Segment
数组。当使用new ConcurrentHashMap<String, Object>()
无参构造器进行初始化时,那么初始化完成之后,ConcurrentHashMap
内部的参数分为如下:
Segment
数组长度为16
,不可以扩容。Sgement[i]
的默认大小为2
,负载因子是0.75
,计算可以得出扩容阈值为2 * 0.75 = 1.5
,也就是当往Segment
中插入第二个元素时,Segment
内部会进行一次扩容。- 这里已经初始化了
segment[0]
,其它位置的值还是null
。 - 当前的
segmentShift
的值为32 -4 = 28
,segmentMask
的值为16 - 1 = 15
。
2.1put()
过程
public V put(K key, V value) {
Segment<K,V> s;
// value为空, 直接抛出异常
if (value == null)
throw new NullPointerException();
// 1. 计算key的hash值
int hash = hash(key);
// 2. 根据hash值找到Segment数组中的位置j
// hash是32位,无符号右移segmentShift(28)位,剩下高4位,
// 然后和segmentMask(15)做一次按位与操作,也就是说j是hash值的高4位,也就是槽的数组下标
int j = (hash >>> segmentShift) & segmentMask;
// 刚刚说了,初始化的时候初始化了segment[0],但是其他位置还是null,
// ensureSegment(j)对segment[j]进行初始化
if ((s = (Segment<K,V>)UNSAFE.getObject // nonvolatile; recheck
(segments, (j << SSHIFT) + SBASE)) == null) // in ensureSegment
s = ensureSegment(j);
// 3. 插入新值到槽s中
return s.put(key, hash, value, false);
}
Segment
内部是由数组+链表组成的。
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
// 在往该 segment 写入前,需要先获取该 segment 的独占锁
HashEntry<K,V> node = tryLock() ? null :
scanAndLockForPut(key, hash, value);
V oldValue;
try {
// 这个是 segment 内部的数组
HashEntry<K,V>[] tab = table;
// 再利用 hash 值,求应该放置的数组下标
int index = (tab.length - 1) & hash;
// first 是数组该位置处的链表的表头
HashEntry<K,V> first = entryAt(tab, index);
// 下面这串 for 循环虽然很长,不过也很好理解,想想该位置没有任何元素和已经存在一个链表这两种情况
for (HashEntry<K,V> e = first;;) {
if (e != null) {
K k;
// 如果链表上已经存在当前key
if ((k = e.key) == key ||
(e.hash == hash && key.equals(k))) {
oldValue = e.value;
if (!onlyIfAbsent) {
// 覆盖旧值
e.value = value;
++modCount;
}
break;
}
// 继续顺着链表走
e = e.next;
}
else {
// node 到底是不是 null,这个要看获取锁的过程,不过和这里都没有关系。
// 如果不为 null,那就直接将它设置为链表表头;如果是null,初始化并设置为链表表头。
if (node != null)
node.setNext(first);
else
node = new HashEntry<K,V>(hash, key, value, first);
int c = count + 1;
// 如果超过了该 segment 的阈值,这个 segment 需要扩容
if (c > threshold && tab.length < MAXIMUM_CAPACITY)
// 扩容
rehash(node);
else
// 没有达到阈值,将 node 放到数组 tab 的 index 位置,
// 其实就是将新的节点设置成原链表的表头
setEntryAt(tab, index, node);
++modCount;
count = c;
oldValue = null;
break;
}
}
} finally {
// 解锁
unlock();
}
return oldValue;
}
ConcurrentHashMap
整体的put()
流程和思路还是非常清晰的,由于有独占锁保护,所以Segment
内部的操作并没有过于复杂。
至此put()
操作就结束了。来看看关键的步骤。
在ConcurrnetHashMap
初始化的时候默认会初始化第一个槽segement[0]
,对于其它槽来说,在插入第一个值的时候进行segment
的初始化。因为考虑到并发访问的问题,可能会存在多个线程同时进来初始化同一个槽segment[u]
,但是只需要一个成功即可:
private Segment<K,V> ensureSegment(int k) {
final Segment<K,V>[] ss = this.segments;
// 计算需要初始化的segement数组下标
long u = (k << SSHIFT) + SBASE; // raw offset
Segment<K,V> seg;
// 如果此时segement数组的u下标为空
if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u)) == null) {
// 这里看到为什么之前要初始化 segment[0] 了,
// 使用当前segment[0]处的数组长度和负载因子来初始化segment[k]
// 为什么要用“当前”,因为segment[0]可能早就扩容过了
Segment<K,V> proto = ss[0];
int cap = proto.table.length;
float lf = proto.loadFactor;
int threshold = (int)(cap * lf);
// 初始化 segment[k] 内部的数组
HashEntry<K,V>[] tab = (HashEntry<K,V>[])new HashEntry[cap];
// 再次检查一遍该槽是否被其他线程初始化了。
if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
== null) {
Segment<K,V> s = new Segment<K,V>(lf, threshold, tab);
// 使用 while 循环,内部用 CAS,当前线程成功设值或其他线程成功设值后,退出
// 如果有一个线程成功初始化之后, 另外其它的线程就会CAS失败, 此时while循环的作用
// 就体现出来了, 它会将seg赋值返回, 所以当一个线程成功初始化之后, 其它线程CAS失败同时
// 会获取到seg
while ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
== null) {
if (UNSAFE.compareAndSwapObject(ss, u, null, seg = s))
break;
}
}
}
return seg;
}
ensureSegment(int k)
的流程与逻辑还是比较清晰的,使用自旋+CAS
对segment[u]
执行初始化操作,保证只会有一个线程去初始化segment[u]
。
在往segment
中put()
元素的时候,首先会调用HashEntry<K,V> node = tryLock() ? null : scanAndLockForPut(key, hash, value);
。也就是先使用tryLock()
快速获取该segment
的独占锁,如果失败,那么进入到scanAndLockForPut(key, hash, value)
这个分支来获取锁。
private HashEntry<K,V> scanAndLockForPut(K key, int hash, V value) {
HashEntry<K,V> first = entryForHash(this, hash);
HashEntry<K,V> e = first;
HashEntry<K,V> node = null;
int retries = -1; // negative while locating node
// 循环获取锁
while (!tryLock()) {
HashEntry<K,V> f; // to recheck first below
if (retries < 0) {
if (e == null) {
if (node == null) // speculatively create node
// 进到这里说明数组该位置的链表是空的,没有任何元素
// 当然,进到这里的另一个原因是 tryLock() 失败,所以该槽存在并发,不一定是该位置
node = new HashEntry<K,V>(hash, key, value, null);
retries = 0;
}
else if (key.equals(e.key))
retries = 0;
else
// 顺着链表往下走
e = e.next;
}
// 重试次数如果超过 MAX_SCAN_RETRIES(单核1多核64),那么不抢了,进入到阻塞队列等待锁
// lock() 是阻塞方法,直到获取锁后返回
else if (++retries > MAX_SCAN_RETRIES) {
lock();
break;
}
else if ((retries & 1) == 0 &&
// 这个时候是有大问题了,那就是有新的元素进到了链表,成为了新的表头
// 所以这边的策略是,相当于重新走一遍这个 scanAndLockForPut 方法
(f = entryForHash(this, hash)) != first) {
e = first = f; // re-traverse if entry changed
retries = -1;
}
}
return node;
}
scanAndLockForPut(K key, int hash, V value)
方法有两个出口,一个是tryLock()
成功,循环终止。另一个就是重试次数超过了MAX_SCAN_RETRIES
,那么就会调用lock()
方法阻塞等待,直至成功拿到独占锁。
这个方法看似复杂,其实只做了一件事,就是**获取该segment
**的独占锁,如果需要的话顺便实例化了一下node
。
在put()
元素到COncurrentHashMap
中时,首先会将当前ConcurrentHashMap
中的数据量累加1
,然后判断是否大于扩容阈值,并且此时的阈值是小于ConcurrentHashMap
的最大容量的,也就是判断该值的插入是否会导致该segment
的元素个数超过阈值。如果大于则会触发当前segment
槽的扩容机制。
put()
方法中涉及扩容的代码片段:
if (c > threshold && tab.length < MAXIMUM_CAPACITY)
// 扩容
rehash(node);
首先是c
值得累加,以及该方法的判断都不会有并发安全问题,因为此时以及获取到了该Segment
的独占锁。只会有当前线程才能进入互斥区。
rehash(node)
:
// 方法参数上的 node 是这次扩容后,需要添加到新的数组中的数据。
private void rehash(HashEntry<K,V> node) {
HashEntry<K,V>[] oldTable = table;
int oldCapacity = oldTable.length;
// 左移一位相当于扩容为之前容量的2倍
int newCapacity = oldCapacity << 1;
// 计算新的扩容阈值
threshold = (int)(newCapacity * loadFactor);
// 创建新数组
HashEntry<K,V>[] newTable =
(HashEntry<K,V>[]) new HashEntry[newCapacity];
// 新的掩码,如从 16 扩容到 32,那么 sizeMask 为 31,对应二进制 ‘000...00011111’
int sizeMask = newCapacity - 1;
// 遍历原数组,老套路,将原数组位置 i 处的链表拆分到 新数组位置 i 和 i+oldCap 两个位置
for (int i = 0; i < oldCapacity ; i++) {
// e 是链表的第一个元素
HashEntry<K,V> e = oldTable[i];
if (e != null) {
HashEntry<K,V> next = e.next;
// 计算应该放置在新数组中的位置,
// 假设原数组长度为 16,e 在 oldTable[3] 处,那么 idx 只可能是 3 或者是 3 + 16 = 19
int idx = e.hash & sizeMask;
if (next == null) // 该位置处只有一个元素,那比较好办
newTable[idx] = e;
else { // Reuse consecutive sequence at same slot
// e 是链表表头
HashEntry<K,V> lastRun = e;
// idx 是当前链表的头结点 e 的新位置
int lastIdx = idx;
// 下面这个 for 循环会找到一个 lastRun 节点,这个节点之后的所有元素是将要放到一起的
for (HashEntry<K,V> last = next;
last != null;
last = last.next) {
int k = last.hash & sizeMask;
if (k != lastIdx) {
lastIdx = k;
lastRun = last;
}
}
// 将 lastRun 及其之后的所有节点组成的这个链表放到 lastIdx 这个位置
newTable[lastIdx] = lastRun;
// 下面的操作是处理 lastRun 之前的节点,
// 这些节点可能分配在另一个链表中,也可能分配到上面的那个链表中
for (HashEntry<K,V> p = e; p != lastRun; p = p.next) {
V v = p.value;
int h = p.hash;
int k = h & sizeMask;
HashEntry<K,V> n = newTable[k];
newTable[k] = new HashEntry<K,V>(h, p.key, v, n);
}
}
}
}
// 将新来的 node 放到新数组中刚刚的 两个链表之一 的 头部
int nodeIndex = node.hash & sizeMask; // add the new node
node.setNext(newTable[nodeIndex]);
newTable[nodeIndex] = node;
table = newTable;
}
需要注意的是,一旦ConcurrentHashMap
初始化完成之后,segment
数组是不能进行扩容的,扩容的是segment
数组上某个segment
元素内部的HashEntry<K, V>
数组,同HashMap
一样,扩容之后的容量为之前的2
倍。
可以看到最后是连着两个for
循环,如果找到的lastRun
节点在链表中属于靠前的位置,那么这样做一次遍历找到这个节点还是非常有意义的,因为这样的话只需要克隆lastRun
前面的节点,后面的一串节点跟着lastRun
走就行了,不需要做任何操作。
Doug Lea
的这个思想还是非常有意思的,但是如果出现比较坏的情况就是lastRun
都是链表靠后或者最后一个元素时,那么前面的这次for
循环遍历链表就显的优点浪费性能了。不过Doug Lea
也说了,根据数据统计,如果使用默认的阈值,大约只有1 / 6
的节点需要克隆。
2.2get()
过程
相对于put()
来说,这里的get()
就非常简单了。
- 计算
key
的hash
值,找到segment
数组中对应的segment
。 segment
中也是一个HashEntry<K, V>
数组,再根据hash
值和当前数组的长度减1
找到HashEntry<K, V>
的位置。- 遍历链表,依次查找。找到则返回
value
,没找到则返回null
。
public V get(Object key) {
Segment<K,V> s; // manually integrate access methods to reduce overhead
HashEntry<K,V>[] tab;
// 1. 获取key的hash值
int h = hash(key);
// 计算key位于segment数组中的哪个segment上
long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;
// 2. 根据u找到对应的segment
if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) != null &&
(tab = s.table) != null) {
// 3. 找到segment内部数组相应位置的链表遍历
for (HashEntry<K,V> e = (HashEntry<K,V>) UNSAFE.getObjectVolatile
(tab, ((long)(((tab.length - 1) & h)) << TSHIFT) + TBASE);
e != null; e = e.next) {
K k;
if ((k = e.key) == key || (e.hash == h && key.equals(k)))
return e.value;
}
}
return null;
}
可以看到在get()
过程中是没有加锁的,所以在执行get()
操作是存在并发安全问题的。
ConcurrentHashMap
中添加节点的操作put()
和删除节点的操作remove()
都是会在当前segment
上加独占锁的,所以当对于同一个segment
上的元素进行put() / remove()
操作时,是线程安全的。但是获取元素的操作get()
是没有在segment
上加独占锁的。所以在并发情况下,对于同一个segment
上的元素,当一个线程在执行get()
操作时,其它线程执行了put() / remove()
操作,此时就可能存在并发问题。
put()
操作的线程安全性:
- 初始化
segment
时,使用的是CAS
操作来初始化segment
。 - 添加节点到链表中的操作是头插法,所以,如果此时
get()
操作在遍历链表的过程中已经到了链表的中间,是没有任何影响的。当在put()
操作之后立即执行get()
,则需要保证新插入链表头部的元素对其它线程可见,这个依赖于setEntryAt()
方法中使用的UNSAFE.putOrderedObject
。 - 当
segment
内部的数组执行扩容时。首先扩容的步骤是先创建一个扩容后的新HashEntry<K, V>
数组,然后将数据从oldTable
迁移到newTable
中,然后将newTable
赋值给table
成员变量。需要注意的是,只有在执行put()
操作的时候,才有可能会进行扩容操作。如果put()
先行,那么put()
操作的可见性就是table
成员变量使用volatile
关键字修饰的。如果get()
先行,此时的get()
还是在旧的table
上做查询操作,对结果是没有影响的。
remove()
操作的线程安全性:
- 因为
get()
操作需要遍历链表,但是remove()
操作会破坏链表。 - 如果
remove()
破坏的节点get()
操作已经遍历过去了,那么此时是不存在问题的。 - 如果
remove()
破坏了一个节点,需要分两种情况考虑。- 如果此节点是头节点,那么需要将头节点的
next
设置为数组当前位置的元素,即将链表往前推一下。虽然table
成员变量是使用volatile
关键字修饰,但是volatile
关键字并不能提供数组内部操作的可见性,所以在JDK 7
的源码中使用了UNSAFE
来操作数组,具体实现逻辑在setEntryAt
。 - 如果此节点不是头节点,当删除链表中的某一个节点之后,它需要将删除节点的前置节点的
next
指向删除节点的后置节点,这里的并发保证就是next
属性使用volatile
关键字保证内存可见性。
- 如果此节点是头节点,那么需要将头节点的
3.JDK 8 HashMap
JDK 8
中HashMap
的底层数据结构是数组和链表和红黑树。
根据JDK 7
中HashMap
的源码分析,针对JDK 7
中HashMap
的get()
操作。首先会根据key
的值计算出数组的具体下标,找到对应的数组元素之后,需要从前到后依次遍历链表,寻找目标节点。这种情况下,get()
操作的时间复杂度取决于当前链表的长度,所以时间复杂度为O(n)
。
为了降低这部分开销,在JDK 8
中,当链表中的元素达到8
时,会将链表转换为红黑树,当转换为红黑树之后,针对这些位置的查找,时间复杂度可以降低到O(logn)
。
JDK 8
中HashMap
的具体结构如下图所示:
>注意,此图仅为`JDK 8`中的`HashMap`的大致结构示意图。
在JDK 7
中使用的是Entry
来代表每个HashMap
中的数据节点,JDK 8
中使用Node
,基本没有区别。其内部都是key
、value
、hash
和next
四个属性。但是在JDK 8
中Node
只能用于链表的情况,当链表转换为红黑树时使用的是TreeNode
。这一点在源码中也会有体现,会对数组上的第一个节点数据类型做判断,如果是Node
则代表当前位置下是链表,如果是TreeNode
则代表当前节点下是红黑树。
3.1put()
过程
/**
* Associates the specified value with the specified key in this map.
* If the map previously contained a mapping for the key, the old
* value is replaced.
*
* @param key key with which the specified value is to be associated
* @param value value to be associated with the specified key
* @return the previous value associated with <tt>key</tt>, or
* <tt>null</tt> if there was no mapping for <tt>key</tt>.
* (A <tt>null</tt> return can also indicate that the map
* previously associated <tt>null</tt> with <tt>key</tt>.)
*/
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
先翻译注释:
将指定的键值对放入当前的
map
中。如果当前map
中包含映射的键,则替换掉旧值。
调用put()
方法首先会执行hash(key)
,hash(Object key)
的源码如下:
/**
* Computes key.hashCode() and spreads (XORs) higher bits of hash
* to lower. Because the table uses power-of-two masking, sets of
* hashes that vary only in bits above the current mask will
* always collide. (Among known examples are sets of Float keys
* holding consecutive whole numbers in small tables.) So we
* apply a transform that spreads the impact of higher bits
* downward. There is a tradeoff between speed, utility, and
* quality of bit-spreading. Because many common sets of hashes
* are already reasonably distributed (so don't benefit from
* spreading), and because we use trees to handle large sets of
* collisions in bins, we just XOR some shifted bits in the
* cheapest possible way to reduce systematic lossage, as well as
* to incorporate impact of the highest bits that would otherwise
* never be used in index calculations because of table bounds.
*/
static final int hash(Object key) {
int h;
// 如果key为null, 直接返回0, 否则将key的hashCode的高16位与低16位做异或返回
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
先翻译一下注释:
计算
key.hashCode()
并将哈希的较高位传播(XOR)
到较低位。 由于该表使用二次幂掩码,因此仅在当前掩码之上位变化的散列集将始终发生冲突。(
已知的例子是在小表中保存连续整数的Float
键集)
。因此,我们应用了一种变换,将高位的影响向下传播。在位扩展的速度、实用性和质量之间存在折衷。因为许多常见的散列集已经合理分布(
所以不要从传播中受益)
,并且因为我们使用树来处理bin
中的大量冲突,我们只是以最便宜的方式对一些移位的位进行异或,以减少系统损失,以及合并最高位的影响,否则由于表边界,这些最高位将永远不会用于索引计算。
思考一下,为什么要将key
的hashCode
的高16
位与低16
位做异或?
来看看
32
位整型的高16
位与低16
位做异或具体是怎样的一个过程: 假设当前有一个
key
,调用其hashCode()
方法返回的32
位整型值为1124758
,那么其二进制形式为:0000 0000 0001 0001 0010 1001 1001 0110
当执行无符号位右移
h>>>16
之后得到的结果为:0000 0000 0000 0000 0000 0000 0001 0001
然后执行异或
(h = key.hashCode()) ^ (h >>> 16)
之后的结果为:0000 0000 0001 0001 0010 1001 1001 0110 ^ 0000 0000 0000 0000 0000 0000 0001 0001 ------------------------------------------- 0000 0000 0001 0001 0010 1001 1000 0111
即结果为
1124743
。 在二进制中,左移
n
位,相当于乘以2
的n
次方。右移n
位,相当于除以2
的n
次方。
思考一下,这里为什么要用异或,而不用与或非?
这就涉及到概率学的问题了,
hash(Object key)
是散列函数,如果散列得越开,那么后期发生hash
碰撞的概率就越低。 对于
&
来说,二进制位上25%
的概率为1
,75%
的概率为0
。 对于
|
来说,二进制位上75%
的概率为1
,25%
的概率为0
。 对于
^
来说,二进制位上50%
的概率为1
,50%
的概率为0
所以可以看出,为了尽可能做到均匀散列,采用概率对等的
^
。
当key
的hash
散列值计算出来之后,就会进入HashMap
真正的存储数据的方法putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict)
/**
* Implements Map.put and related methods.
*
* @param hash hash for key 第一个参数: key的hash值
* @param key the key 第二个参数:key
* @param value the value to put 第三个参数:value
* @param onlyIfAbsent if true, don't change existing value 第四个参数:当onlyIfAbsent为true时, 在HashMap中存在当前key时不会执行put操作, 即不会进行覆盖
* @param evict if false, the table is in creation mode. 第五个参数:不需要关注
* @return previous value, or null if none 返回值:返回之前key对应的value, 如果不存在key则返回null
*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// 第一次执行put操作时, 会判断数组是否为null或者数组长度为0, 如果是则触发数组的扩容
if ((tab = table) == null || (n = tab.length) == 0)
// 第一次resize()和后续的扩容有些不一样, 因为这次是数组从null初始化到默认值16或自定义的初始容量
n = (tab = resize()).length;
// 将(tab.length - 1) & hash做按位与得到数组下标, 然后获取到数组中的当前下标的值, 如果值为null, 则初始化Node并将其放在数组的当前位置上
if ((p = tab[i = (n - 1) & hash]) == null)
// 初始化Node, 并将初始化的Node放在数组的当前下标位置上
tab[i] = newNode(hash, key, value, null);
// 如果数组该下标的位置上有数据
else {
Node<K,V> e; K k;
// 首先判断数组该下标上的数据与当前需要插入的数据的hash以及key相等, 如果相等, 则取出当前数据
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
// 取出当前数据
e = p;
// 如果数组该下标上的数据类型为TreeNode, 则代表下面是颗红黑树
else if (p instanceof TreeNode)
// 如果是红黑树则调用红黑树的putTreeVal()方法插入值
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
// 否则数组改下表上的数据类型为Node, 则代表下面是一个链表
else {
// 对链表进行遍历
for (int binCount = 0; ; ++binCount) {
// 获取下一个节点, 同时判断下一个节点是否为空
if ((e = p.next) == null) {
// 如果为空则创建一个新的Node, 并将新的Node放到链表的尾部
p.next = newNode(hash, key, value, null);
// 当插入当前数据后链表长度大于8
// 注意这里的减1是因为是从索引0开始的
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
// 将链表转换为红黑树
treeifyBin(tab, hash);
// 退出循环
break;
}
// 如果在链表中找到了与key相等的节点数据
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
// 退出循环
break;
p = e;
}
}
// 如果 e != null, 说明此时HashMap中存在旧的值的key与当前要插入值得key相等
if (e != null) { // existing mapping for key
// 取出老的value
V oldValue = e.value;
// onlyIfAbsent为false || 老的value为null
if (!onlyIfAbsent || oldValue == null)
// 覆盖老的value
e.value = value;
afterNodeAccess(e);
// 返回旧值
return oldValue;
}
}
++modCount;
// 如果插入值之后, HashMap中的元素个数达到了阈值
if (++size > threshold)
// 触发扩容
resize();
afterNodeInsertion(evict);
return null;
}
JDK 7
会在数据插入之前进行判断是否需要对数组进行扩容,JDK 8
是将值插入之后再判断是否需要对数组进行扩容。
不论是JDK 7
还是JDK 8
,HashMap
的扩容都是非常重要的。在JDK 7
中HashMap
数组的初始化和扩容是两个方法。而在JDK 8
中数组的初始化和扩容是同一个方法。并且都是扩容为之前容量的2
倍,然后将数据从oldTable
迁移到newTable
。
/**
* Initializes or doubles table size. If null, allocates in
* accord with initial capacity target held in field threshold.
* Otherwise, because we are using power-of-two expansion, the
* elements from each bin must either stay at same index, or move
* with a power of two offset in the new table.
*
* @return the table
*/
final Node<K,V>[] resize() {
// 获取当前table数组
Node<K,V>[] oldTab = table;
// 获取当前table数组的长度
int oldCap = (oldTab == null) ? 0 : oldTab.length;
// 获取当前的扩容阈值
int oldThr = threshold;
// newCap是扩容之后新table的大小, newThr是扩容之后新的扩容阈值
int newCap, newThr = 0;
// 如果当前table数组长度大于0, 说明HashMap已经正常初始化过了, 是一次正常的扩容操作
if (oldCap > 0) {
// 判断旧的容量是否大于等于HashMap的最大容量, 如果是则无法进行扩容, 并且设置扩容条件为Integer最大值
if (oldCap >= MAXIMUM_CAPACITY) {
// 修改当前HashMap的扩容阈值为Integer.MAX_VALUE
threshold = Integer.MAX_VALUE;
// 无法进行扩容, 直接返回当前table
return oldTab;
}
// 将oldCap左移一位然后赋值给newCap, 即扩容为当前table的2倍
// 如果扩之后的table长度小于HashMap的最大默认长度 && oldCap >= 16
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
// 将扩容阈值扩大一倍
newThr = oldThr << 1; // double threshold
}
// 如果oldThr=0并且边界值大于0, 说明散列表是null
// 1.应对使用new HashMap<K, V>(int initialCapacity, float loadFactor)初始化后, 第一次调用put进行扩容
// 2.应对new HashMap<K, V>(int initialCapacity)初始化后, 第一次调用put进行扩容
// 3.应对new HashMap<K, V>(Map<? extends K, ? extends V> m)初始化后, 第一次调用put进行扩容
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
// 这种情况下oldThr=0;oldCap=0;说明没有经过初始化, 需要初始化HashMap
// 应对使用new HashMap<K, V>()初始化后, 第一次调用put进行扩容
else { // zero initial threshold signifies using defaults
// newCap = DEFAULT_INITIAL_CAPACITY = 16;
newCap = DEFAULT_INITIAL_CAPACITY;
// 根据默认的初始容量计算扩容阈值
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// newThr为0时, 通过newCap和loadFactor计算出一个newThr
if (newThr == 0) {
// newCap * 0.75
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
// 用新的数组大小初始化新的数组
@SuppressWarnings({"rawtypes","unchecked"})
// 如果是第一次调用put存储元素, 那么此时数组初始化完成, 执行到这里就结束了, 直接返回newTab即可
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
// 将table指向newTab
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);
// 如果下面不止一个节点, 并且节点数据类型不是TreeNode, 则此时是链表
else { // preserve order
// 将链表拆成两个链表, 然后放到新数组中, 并且保留当前节点的先后顺序
// loHead和loTail对应一条链表, 称为低位链表lo
// hiHead和hiTail对应一条链表, 称为高位链表hi
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
// 遍历链表中的所有节点
do {
next = e.next;
// oldCap一般都是2的n次方, 使用节点的hash与2的n次方做按位与, 可以得到高位为1还是0
// 如果高位为0则将其放在低位链表中
if ((e.hash & oldCap) == 0) {
// 如果链表尾节点为空
if (loTail == null)
// 将当前节点设为链表的头节点
loHead = e;
// 如果链表不为空
else
// 将当前节点设为链表的尾节点
loTail.next = e;
loTail = e;
}
// 高位为1进入高位链表
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
// 低位链表已成, 将低位链表的loHead指向扩容后的原位
if (loTail != null) {
// 将尾节点的next置为null, 防止链表在切分时是之前链表的中间节点
loTail.next = null;
// 将lo链表放到扩容后数组的j位置, 也就是与扩容之前相同的位置
newTab[j] = loHead;
}
// 高位链表已成, 将高位链表的hiHead指向扩容后的新索引地址
if (hiTail != null) {
// 将尾节点的next置为null, 防止链表在切分时是之前链表的中间节点
hiTail.next = null;
// 将hi链表放到扩容后数组的j + oldCap位置
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
resize()
方法中的代码逻辑和思路还是非常清晰的。
3.2get()
过程
相对于put()
方法来说,get()
方法就非常简单了。
- 计算
key
的hash
值,根据hash & (table.length - 1)
的值找到对应的数组下标。 - 判断数组该位置上的元素是否刚好就是目标元素,如果不是,则继续判断是红黑树还是链表。
- 如果该元素的数据类型是
TreeNode
,则使用红黑树的方式get()
数据。 - 如果该元素的数据类型不是
TreeNode
,则遍历链表,直到找到与key
相等的节点。
/**
* Returns the value to which the specified key is mapped,
* or {@code null} if this map contains no mapping for the key.
*
* <p>More formally, if this map contains a mapping from a key
* {@code k} to a value {@code v} such that {@code (key==null ? k==null :
* key.equals(k))}, then this method returns {@code v}; otherwise
* it returns {@code null}. (There can be at most one such mapping.)
*
* <p>A return value of {@code null} does not <i>necessarily</i>
* indicate that the map contains no mapping for the key; it's also
* possible that the map explicitly maps the key to {@code null}.
* The {@link #containsKey containsKey} operation may be used to
* distinguish these two cases.
*
* @see #put(Object, Object)
*/
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
这里还是会优先执行hash(key)
:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
然后调用getNode(int hash, Object key)
获取目标元素:
/**
* Implements Map.get and related methods.
*
* @param hash hash for key
* @param key the key
* @return the node, or null if none
*/
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
// 主要是判断该数组位置上的元素是否为空, 如果为空代表此时HashMap中不存在该key的值, 直接返回null
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
// 判断第一个节点是不是目标元素, 如果是直接返回
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
// 如果不是, 并且数组当前位置上存在不止一个元素, 如果只存在一个元素, 并且不是目标元素, 那么HashMap中也不存在当前key对应的value
if ((e = first.next) != null) {
// 判断是否是红黑树
if (first instanceof TreeNode)
// 使用红黑树的方式查找元素
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
// 如果是链表, 则遍历链表
do {
// 如果在链表中找到与key相等的节点
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
// 返回该节点
return e;
} while ((e = e.next) != null);
}
}
return null;
}
可以看到get()
也是非常简单的。
4.JDK 8 ConcurrentHashMap
JDK 8
中的ConcurrentHashMap
也引入了红黑树。在结构上JDK 8
的ConcurrentHashMap
和HashMap
的底层结构基本上是一样的,不过ConcurrentHashMap
要保证线程安全。
JDK 8
中ConcurrentHashMap
的具体结构如下图所示:
注意,此图仅为
JDK 8
中的ConcurrentHashMap
的大致结构示意图。其结构与JDK 8
的HashMap
基本上一样。
JDK 8
中的ConcurrentHashMap
中提供了5
个构造器。
/**
* Creates a new, empty map with the default initial table size (16).
*/
public ConcurrentHashMap() {
}
/**
* Creates a new, empty map with an initial table size
* accommodating the specified number of elements without the need
* to dynamically resize.
*
* @param initialCapacity The implementation performs internal
* sizing to accommodate this many elements.
* @throws IllegalArgumentException if the initial capacity of
* elements is negative
*/
public ConcurrentHashMap(int initialCapacity) {
if (initialCapacity < 0)
throw new IllegalArgumentException();
int cap = ((initialCapacity >= (MAXIMUM_CAPACITY >>> 1)) ?
MAXIMUM_CAPACITY :
tableSizeFor(initialCapacity + (initialCapacity >>> 1) + 1));
this.sizeCtl = cap;
}
/**
* Creates a new map with the same mappings as the given map.
*
* @param m the map
*/
public ConcurrentHashMap(Map<? extends K, ? extends V> m) {
this.sizeCtl = DEFAULT_CAPACITY;
putAll(m);
}
/**
* Creates a new, empty map with an initial table size based on
* the given number of elements ({@code initialCapacity}) and
* initial table density ({@code loadFactor}).
*
* @param initialCapacity the initial capacity. The implementation
* performs internal sizing to accommodate this many elements,
* given the specified load factor.
* @param loadFactor the load factor (table density) for
* establishing the initial table size
* @throws IllegalArgumentException if the initial capacity of
* elements is negative or the load factor is nonpositive
*
* @since 1.6
*/
public ConcurrentHashMap(int initialCapacity, float loadFactor) {
this(initialCapacity, loadFactor, 1);
}
/**
* Creates a new, empty map with an initial table size based on
* the given number of elements ({@code initialCapacity}), table
* density ({@code loadFactor}), and number of concurrently
* updating threads ({@code concurrencyLevel}).
*
* @param initialCapacity the initial capacity. The implementation
* performs internal sizing to accommodate this many elements,
* given the specified load factor.
* @param loadFactor the load factor (table density) for
* establishing the initial table size
* @param concurrencyLevel the estimated number of concurrently
* updating threads. The implementation may use this value as
* a sizing hint.
* @throws IllegalArgumentException if the initial capacity is
* negative or the load factor or concurrencyLevel are
* nonpositive
*/
public ConcurrentHashMap(int initialCapacity,
float loadFactor, int concurrencyLevel) {
if (!(loadFactor > 0.0f) || initialCapacity < 0 || concurrencyLevel <= 0)
throw new IllegalArgumentException();
if (initialCapacity < concurrencyLevel) // Use at least as many bins
initialCapacity = concurrencyLevel; // as estimated threads
long size = (long)(1.0 + (long)initialCapacity / loadFactor);
int cap = (size >= (long)MAXIMUM_CAPACITY) ?
MAXIMUM_CAPACITY : tableSizeFor((int)size);
this.sizeCtl = cap;
}
其中第二个有参构造器是特别有意思的:
public ConcurrentHashMap(int initialCapacity) {
// 如果指定ConcurrentHashMap的初始容量小于0
if (initialCapacity < 0)
// 抛出异常
throw new IllegalArgumentException();
// 如果指定的initialCapacity小于ConcurrentHashMap的默认最大容量, 则调用tableSizeFor
int cap = ((initialCapacity >= (MAXIMUM_CAPACITY >>> 1)) ?
MAXIMUM_CAPACITY :
tableSizeFor(initialCapacity + (initialCapacity >>> 1) + 1));
this.sizeCtl = cap;
}
当调用这个构造器初始化一个ConcurrentHashMap
时,需要指定HashMap
的初始容量。通过提供的初始容量initialCapacity
,计算出了sizeCtl
。
这里sizeCtl
的具体计算方式为tableSizeFor(initialCapacity + (initialCapacity >>> 1) + 1)
。也就是[(initialCapacity * 1.5 + 1),
向上取最近的2的n
次方]
。
例如
initialCapacity
为10
,那么sizeCtl
就为16
。
4.2put()
过程
/**
* Maps the specified key to the specified value in this table.
* Neither the key nor the value can be null.
*
* <p>The value can be retrieved by calling the {@code get} method
* with a key that is equal to the original key.
*
* @param key key with which the specified value is to be associated
* @param value value to be associated with the specified key
* @return the previous value associated with {@code key}, or
* {@code null} if there was no mapping for {@code key}
* @throws NullPointerException if the specified key or value is null
*/
public V put(K key, V value) {
return putVal(key, value, false);
}
这里直接调用putVal(key, value, false)
:
/** Implementation for put and putIfAbsent */
final V putVal(K key, V value, boolean onlyIfAbsent) {
// key或value为空, 则抛出异常
if (key == null || value == null) throw new NullPointerException();
// 计算key的hash值
int hash = spread(key.hashCode());
// 用于记录相应链表的长度
int binCount = 0;
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
// 如果数组为空, 则初始化数组
if (tab == null || (n = tab.length) == 0)
// 初始化数组
tab = initTable();
// 如果数组不为空, 根据hash值与数组长度减一做按位与, 获取数组指定下标的元素
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
// 如果数组该位置为空, 则使用一次CAS操作, 如果CAS成功将值设置数组的当前位置, 那么直接break, put方法结束
// 如果CAS失败, 那么证明存在并发, 已经有其它线程抢先将值放上去了, 此时进入下一个循环, 会进入到存在值的分支进行处理
if (casTabAt(tab, i, null,
new Node<K,V>(hash, key, value, null)))
break; // no lock when adding to empty bin
}
// 如果数组当前位置上的元素不为空, 则判断第一个元素的hash是否等于MOVED
else if ((fh = f.hash) == MOVED)
// 帮助数据迁移
tab = helpTransfer(tab, f);
// f是该位置的头节点, 并且f不为空
else {
V oldVal = null;
// 获取数组该位置头节点的对象锁
synchronized (f) {
// 获取头节点并判断此时头节点是否还是f
if (tabAt(tab, i) == f) {
// 头节点的hash值大于0, 说明是链表
if (fh >= 0) {
// 用于累加, 记录链表的长度
binCount = 1;
// 遍历链表
for (Node<K,V> e = f;; ++binCount) {
K ek;
// 如果在链表中找到了与key相等的元素
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
oldVal = e.val;
// 判断是否需要对oldValue进行覆盖
if (!onlyIfAbsent)
e.val = value;
// 退出, put执行完成
break;
}
Node<K,V> pred = e;
// 如果到了链表的尾部
if ((e = e.next) == null) {
// 则将新值放到链表尾部
pred.next = new Node<K,V>(hash, key,
value, null);
// 退出, put执行完成
break;
}
}
}
// 如果是红黑树
else if (f instanceof TreeBin) {
Node<K,V> p;
binCount = 2;
// 调用红黑树的插值方法插入新节点
if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
value)) != null) {
oldVal = p.val;
if (!onlyIfAbsent)
p.val = value;
}
}
}
}
if (binCount != 0) {
// 插入值完成之后, 判断链表中的元素个数是否大于8
if (binCount >= TREEIFY_THRESHOLD)
// 如果大于8则将链表转换为红黑树
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
addCount(1L, binCount);
return null;
}
这里有几个关键的点,第一个是数组的初始化,第二个是数组的扩容,第三个是帮助数据迁移。
第一次put()
值是会触发数组的初始化,initTable()
:
/**
* Initializes table, using the size recorded in sizeCtl.
*/
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
while ((tab = table) == null || tab.length == 0) {
// 初始化的执行被其它线程抢占了
if ((sc = sizeCtl) < 0)
// 调用yield()方法主动退出CPU时间片
Thread.yield(); // lost initialization race; just spin
// 使用CAS将sizeCtl的值由sc设置为-1, 代表争抢到了锁
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
try {
if ((tab = table) == null || tab.length == 0) {
// DEFAULT_CAPACITY 默认初始容量是 16
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
@SuppressWarnings("unchecked")
// 初始化数组, 将数组长度设置为ConcurrentHashMap初始化时指定的值或16
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
table = tab = nt;
// 这里其实就是sc = 0.75 * n
sc = n - (n >>> 2);
}
} finally {
// 重新设置sizeCtl的值
sizeCtl = sc;
}
// 退出
break;
}
}
// 返回初始化完成的数组
return tab;
}
初始化数组大小的方法,主要就是初始化为一个合适的大小的数组,然后设置sizeCtl
的值。在初始化过程中的并发问题是通过对sizeCtl
进行CAS
操作来实现的。
将链表转换为红黑树,但是将链表转换为红黑树也并不是绝对的,有可能也是对数组做扩容。treeifyBin(tab, i)
源码:
/**
* Replaces all linked nodes in bin at given index unless table is
* too small, in which case resizes instead.
*/
private final void treeifyBin(Node<K,V>[] tab, int index) {
Node<K,V> b; int n, sc;
if (tab != null) {
// 如果数组长度小于64, 其实也就是32、16、8或者更小的时候, 则只会进行数组的扩容
if ((n = tab.length) < MIN_TREEIFY_CAPACITY)
tryPresize(n << 1);
// b是头节点
else if ((b = tabAt(tab, index)) != null && b.hash >= 0) {
// 加锁
synchronized (b) {
// 双重验证
if (tabAt(tab, index) == b) {
// 遍历链表, 将链表数据转移到红黑树上
TreeNode<K,V> hd = null, tl = null;
for (Node<K,V> e = b; e != null; e = e.next) {
// 创建一个TreeNode
TreeNode<K,V> p =
new TreeNode<K,V>(e.hash, e.key, e.val,
null, null);
if ((p.prev = tl) == null)
hd = p;
else
tl.next = p;
tl = p;
}
// 将红黑树设置到数组的相应位置
setTabAt(tab, index, new TreeBin<K,V>(hd));
}
}
}
}
}
当ConcurrentHashMap
中链表长度大于8
时,会调用treeifyBin(Node<K,V>[] tab, int index)
将链表转换为红黑树。但是当进入到该方法之后,它首先会判断当前数组的大小是否大于等于64
,如果此时数组的长度大于等于64
,那么它才会将链表转换为红黑树。如果此时数组的长度小于64
,那么此时会调用tryPresize(n << 1)
对数组进行扩容。
/**
* Tries to presize table to accommodate the given number of elements.
*
* @param size number of elements (doesn't need to be perfectly accurate)
*
* 此时的size已经是当前数组长度扩容之后的大小
*
*/
private final void tryPresize(int size) {
// c:扩容后的大小无符号位右移1位, 然后加上size, 其实就是(size * 1.5) + 1然后向上取最近的2的n次方
int c = (size >= (MAXIMUM_CAPACITY >>> 1)) ? MAXIMUM_CAPACITY :
tableSizeFor(size + (size >>> 1) + 1);
int sc;
while ((sc = sizeCtl) >= 0) {
Node<K,V>[] tab = table; int n;
// 如果数组此时还未初始化, 则初始化数组并设置sizeCtl
if (tab == null || (n = tab.length) == 0) {
n = (sc > c) ? sc : c;
if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
try {
if (table == tab) {
@SuppressWarnings("unchecked")
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
table = nt;
sc = n - (n >>> 2);
}
} finally {
sizeCtl = sc;
}
}
}
else if (c <= sc || n >= MAXIMUM_CAPACITY)
break;
else if (tab == table) {
int rs = resizeStamp(n);
if (sc < 0) {
Node<K,V>[] nt;
if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
transferIndex <= 0)
break;
// 2. 使用CAS将sizeCtl的值加1
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
// 如果CAS成功, 然后执行transfer()方法, 此时nextTable不为null
transfer(tab, nt);
}
// 1. 使用CAS将sizeCtl的值设置为(rs << RESIZE_STAMP_SHIFT) + 2)
else if (U.compareAndSwapInt(this, SIZECTL, sc,
(rs << RESIZE_STAMP_SHIFT) + 2))
// 如果CAS成功, 然后执行transfer()方法, 此时nextTable为null
transfer(tab, null);
}
}
}
这个方法的核心在于sizeCtl
值得操作。首先使用CAS
操作将sizeCtl
设置为一个负数(rs << RESIZE_STAMP_SHIFT) + 2)
,CAS
成功则执行transfer(tab, null)
。到下一个循环中使用CAS
将sizeCtl
设置为sizeCtl + 1
,CAS
成功则执行transfer(tab, nt)
。
所以这里可能的操作就是执行1
次transfer(tab, null)
和多次transfer(tab, nt)
。
数组扩容后的数据迁移,调用transfer(Node<K,V>[] tab, Node<K,V>[] nextTab)
:
/**
* Moves and/or copies the nodes in each bin to new table. See
* above for explanation.
*/
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
int n = tab.length, stride;
// stride在单核CPU下直接等于n。 多核模式下为(n >>> 3) / NCPU, 最小值为16
// stride可以理解为步长, 有n个位置是需要进行迁移的
// 将这n个任务分为多个任务包, 每个任务包有stride个任务
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
stride = MIN_TRANSFER_STRIDE; // subdivide range
// 如果nextTab为null, 则先对其进行初始化
// 外围会保证第一个发起迁移的线程调用此方法时, 参数nextTab为null, 之后参与迁移的线程调用此方法时, nextTab不会为null
if (nextTab == null) { // initiating
try {
@SuppressWarnings("unchecked")
// 容量翻倍
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1];
nextTab = nt;
} catch (Throwable ex) { // try to cope with OOME
sizeCtl = Integer.MAX_VALUE;
return;
}
// nextTable是ConcurrentHashMap中的属性
nextTable = nextTab;
// transferIndex也是ConcurrentHashMap中的属性, 用于控制迁移的位置
transferIndex = n;
}
int nextn = nextTab.length;
// ForwardingNode代表正在被迁移的Node
// 这个构造方法会生成一个Node, 其key、value、next属性都为null, 同时会将hash设为 MOVED
// 后面会看到, 原数组中位置i处的节点完成迁移工作后, 就会将位置i处设置为ForwardingNode, 用来告知其它线程该位置已经被处理过了, 所以它其实相当于是一个标志
ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
// advance指的是做完了一个位置的迁移工作, 可以准备做下一个位置的了
boolean advance = true;
boolean finishing = false; // to ensure sweep before committing nextTab
// 这个for循环是最难理解的
// i是位置索引, bound是边界, 从后往前
for (int i = 0, bound = 0;;) {
Node<K,V> f; int fh;
// advance为true代表可以进行下一个位置的迁移了
// i指向了transferIndex, bound指向了transferIndex - stride
while (advance) {
int nextIndex, nextBound;
if (--i >= bound || finishing)
advance = false;
// 将transferIndex赋值给nextIndex
// 当transferIndex一旦小于等0, 说明原数组的所有位置都有相应的线程去处理了
else if ((nextIndex = transferIndex) <= 0) {
i = -1;
advance = false;
}
else if (U.compareAndSwapInt
(this, TRANSFERINDEX, nextIndex,
nextBound = (nextIndex > stride ?
nextIndex - stride : 0))) {
// nextBound是这次迁移任务的边界, 这里是从后往前
bound = nextBound;
i = nextIndex - 1;
advance = false;
}
}
if (i < 0 || i >= n || i + n >= nextn) {
int sc;
if (finishing) {
// 所有的迁移操作已经完成
nextTable = null;
// 将新的nextTab赋值给table属性, 完成迁移
table = nextTab;
// 重新计算sizeCtl: n是原数组的长度。 先将n左移1位, 相当于n * 2。然后将n做无符号位右移1位, 相当于n * 0.5。此时sizeCtl = 2n - 0.5n。所以此时sizeCtl为原数组长度的1.5倍
sizeCtl = (n << 1) - (n >>> 1);
return;
}
// 在迁移之前, 会将sizeCtl的值设置为((rs << RESIZE_STAMP_SHIFT) + 2)
// 然后每有一个线程参与迁移就会将sizeCtl + 1
// 这里使用CAS操作对sizeCtl进行减1,代表线程做完了属于自己的任务
if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
// 任务结束, 方法退出
if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
return;
// 如果代码执行到这里, 就说明(sc - 2) == resizeStamp(n) << RESIZE_STAMP_SHIFT
// 说明当前所有迁移的任务都做完了, 也就会进入到上面的 if(finishing)分支
finishing = advance = true;
i = n; // recheck before commit
}
}
// 如果位置i处是空的, 没有任何节点, 那么放入刚刚初始化的ForwardingNode空节点
else if ((f = tabAt(tab, i)) == null)
advance = casTabAt(tab, i, null, fwd);
// 该位置处是一个ForwardingNode代表该位置已经迁移过了
else if ((fh = f.hash) == MOVED)
// 修改状态, 已经迁移过了
advance = true; // already processed
else {
// 对数组该位置处的节点加锁, 开始处理数组该位置的迁移工作
synchronized (f) {
if (tabAt(tab, i) == f) {
Node<K,V> ln, hn;
// 头节点的hash >= 0, 代表是链表的Node节点
if (fh >= 0) {
// 下面这一块和JDK 7中的ConcurrentHashMap中的迁移是一样的
int runBit = fh & n;
Node<K,V> lastRun = f;
// 先遍历原链表, 找到lastRun节点
for (Node<K,V> p = f.next; p != null; p = p.next) {
int b = p.hash & n;
if (b != runBit) {
runBit = b;
lastRun = p;
}
}
if (runBit == 0) {
ln = lastRun;
hn = null;
}
else {
hn = lastRun;
ln = null;
}
// 找到lastRun节点之后, 将lastRun节点之后的链表一起进行迁移
// lastRun之前的节点需要进行克隆, 将原来的一个链表拆分为两个链表
for (Node<K,V> p = f; p != lastRun; p = p.next) {
int ph = p.hash; K pk = p.key; V pv = p.val;
if ((ph & n) == 0)
ln = new Node<K,V>(ph, pk, pv, ln);
else
hn = new Node<K,V>(ph, pk, pv, hn);
}
// 将lastRun放到新数组的i位置
setTabAt(nextTab, i, ln);
// 将lastRun之前的元素放到新数组的i + n位置
setTabAt(nextTab, i + n, hn);
// 将原数组该位置设置为ForwardingNode, 代表该位置已经处理完毕
// 其它加入迁移的线程看到该位置的hash值为MOVED, 就会不再进行迁移
setTabAt(tab, i, fwd);
// 迁移完成
advance = true;
}
// 如果是红黑树
else if (f instanceof TreeBin) {
// 进行红黑树的迁移
TreeBin<K,V> t = (TreeBin<K,V>)f;
TreeNode<K,V> lo = null, loTail = null;
TreeNode<K,V> hi = null, hiTail = null;
int lc = 0, hc = 0;
for (Node<K,V> e = t.first; e != null; e = e.next) {
int h = e.hash;
TreeNode<K,V> p = new TreeNode<K,V>
(h, e.key, e.val, null, null);
if ((h & n) == 0) {
if ((p.prev = loTail) == null)
lo = p;
else
loTail.next = p;
loTail = p;
++lc;
}
else {
if ((p.prev = hiTail) == null)
hi = p;
else
hiTail.next = p;
hiTail = p;
++hc;
}
}
// 如果将红黑树一分为二之后, 节点数少于8个, 那么将红黑树转换回链表
ln = (lc <= UNTREEIFY_THRESHOLD) ? untreeify(lo) :
(hc != 0) ? new TreeBin<K,V>(lo) : t;
hn = (hc <= UNTREEIFY_THRESHOLD) ? untreeify(hi) :
(lc != 0) ? new TreeBin<K,V>(hi) : t;
// 将ln放置在新数组的i位置
setTabAt(nextTab, i, ln);
// 将hn放置在新数组的i + n位置
setTabAt(nextTab, i + n, hn);
// 将原数组该位置设置为ForwardingNode, 代表该位置已经处理完毕
// 其它加入迁移的线程看到该位置的hash值为MOVED, 就会不再进行迁移
setTabAt(tab, i, fwd);
// 迁移完成
advance = true;
}
}
}
}
}
}
说到底,transfer(Node<K,V>[] tab, Node<K,V>[] nextTab)
这个方法并没有实现所有的数据迁移任务,每次调用这个方法只是实现了transferIndex
往前stride
个位置的迁移工作,其它的需要由外围来控制。也就是说每次调用transfer(Node<K,V>[] tab, Node<K,V>[] nextTab)
方法其实只是迁移了原数组的一部分节点。
4.3get()
过程
无论是哪个版本的JDK
,get()
方法都是最简单的。
- 首先计算
key
的hash
值。 - 根据
hash
做hash & (table.length - 1)
找到数组对应位置的下标。 - 根据该位置的节点类型进行查找。
- 如果该位置为
null
,直接返回null
即可。 - 如果该位置的首节点正好是目标节点,直接返回节点的值即可。
- 如果该位置节点的
hash
值小于0
,说明数组的当前位置正在进行扩容的数据迁移,或者是红黑树。 - 如果以上条件都不满足,那么就是链表,对链表进行遍历查找即可。
- 如果该位置为
/**
* Returns the value to which the specified key is mapped,
* or {@code null} if this map contains no mapping for the key.
*
* <p>More formally, if this map contains a mapping from a key
* {@code k} to a value {@code v} such that {@code key.equals(k)},
* then this method returns {@code v}; otherwise it returns
* {@code null}. (There can be at most one such mapping.)
*
* @throws NullPointerException if the specified key is null
*/
public V get(Object key) {
Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
int h = spread(key.hashCode());
// table是否为null, 或者table数组上的该位置为null
if ((tab = table) != null && (n = tab.length) > 0 &&
(e = tabAt(tab, (n - 1) & h)) != null) {
// 如果数组该位置上的hash相等
if ((eh = e.hash) == h) {
// 如果key也相等, 直接返回value即可
if ((ek = e.key) == key || (ek != null && key.equals(ek)))
return e.val;
}
// 如果hash小于0, 则有可能正在扩容迁移, 或者是红黑树
else if (eh < 0)
return (p = e.find(h, key)) != null ? p.val : null;
// 如果以上都不是, 那么就是链表
while ((e = e.next) != null) {
// 遍历链表依次对比即可
if (e.hash == h &&
((ek = e.key) == key || (ek != null && key.equals(ek))))
return e.val;
}
}
return null;
}
这里也是非常简单的,如果正好遇到数据正在扩容的情况,则是调用ForwardingNode
的find()
方法进行查找:
Node<K,V> find(int h, Object k) {
// loop to avoid arbitrarily deep recursion on forwarding nodes
outer: for (Node<K,V>[] tab = nextTable;;) {
Node<K,V> e; int n;
// 如果key为null || table数组为null || table数组长度为0 || table数组的该位置的元素为null
if (k == null || tab == null || (n = tab.length) == 0 ||
(e = tabAt(tab, (n - 1) & h)) == null)
// 直接返回null
return null;
for (;;) {
int eh; K ek;
// 如果头节点就是目标元素
if ((eh = e.hash) == h &&
((ek = e.key) == k || (ek != null && k.equals(ek))))
// 直接返回
return e;
// 如果头节点的hash值小于0
if (eh < 0) {
// 如果头节点的数据类型是ForwardingNode, 则说明还在执行扩容迁移
if (e instanceof ForwardingNode) {
tab = ((ForwardingNode<K,V>)e).nextTable;
continue outer;
}
// 否则就是红黑树
else
// 搜索红黑树查找结果
return e.find(h, k);
}
if ((e = e.next) == null)
return null;
}
}
}
}
现在回过去看前面,读HashMap
与ConcurrentHashMap
源码也并不难。读源码并不是目的,关键是学习Doug Lea
大师的编程思想。大师就是大师,能够将HashMap
和ConcurrentHashMap
由0
到1
,确实是非常厉害,中间的各种编程思想,值得学习。
备注:此文为笔者学习
Java
的笔记,鉴于本人技术有限,文中难免出现一些错误,感谢大家批评指正。