这个专题的由来
笔者在工作过程中面试过很多初中级的同学,很少有人能完完全全的理解JDK中的HashMap 和 ConcurrentHashMap,为什么面试会去问这些,我觉得这个部分属于Java的一些高级特性,可以看出应聘者的技术深度 (俗称面试造火箭嘛)
网上有很多博文写的要么不全面,要么不够深入,特别是线程安全部分,我几乎没有看见过分析的全面的文章,我决定写一篇全面而有深度的文章送给一知半解的同学
tips:本专题将分析JDK1.7和1.8中的HashMap和ConcurrentHashMap的底层实现(数据结构&技术细节),线程安全,以及如何解决线程安全问题
JDK1.7 HashMap
哈希表
HashMap的数据结构
实现细节
HashMap为什么线程不安全
JDK1.7的ConcurrentHashMap
ConcurrentHashMap成员变量
ConcurrentHashMap的初始化
PUT方法
Rehash扩容
Get方法
JDK1.7 HashMap
哈希表
先简单说一下哈希表,哈希表也叫散列表,哈希表的主干是数组,元素要想存入这个数组中,先要根据元素的key通过哈希函数计算出一个在数组中位置的值,这样就可以把元素存入数组中,一个好的哈希函数需要具备两个特性
- 计算简单快速
- 保证散列均匀分布
但是再好的哈希函数也可能会导致哈希冲突,怎么解决,一般的解决方案有四种:
- 放开放定址法
- 再哈希法
- 链地址法
- 公共溢出区
HashMap中使用的是第三种,链地址法,其实就是数组+链表的结构,链表用来存储Hash冲突的元素
HashMap的数据结构
从网上找一张图,从图中可以看到HashMap的存储结构是一个Entry数组,每个Entry数组下挂有一个单向链表,链表中的每个节点都会指向下一个节点,当HashMap初始化完成,第一次put值的时候,会建立一个大小为capacity的 Entry数组,先看下这个Entry数组结构:
static class Entry implements Map.Entry {
final K key;
V value;
Entry next;
int hash;
Entry(int h, K k, V v, Entry n) {
value = v;
next = n;
key = k;
hash = h;
}
Entry是一个内部静态类,一共有四个成员变量,其中next变量是指向下一个Entry的引用,hash存储的是key的hashcode值进行hash函数计算后得到的值,就是数组的下标
实现细节
### HashMap的成员变量
先看下HashMap中的所有成员变量有哪些,每个成员变量的解释都在注释中
// 默认初始化Entry数组的大小为16,后面解释为什么是16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
//Entry数组大小的最大值,是2的30次方
static final int MAXIMUM_CAPACITY = 1 << 30;
//默认负载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//一个空的Entry数组
static final Entry,?>[] EMPTY_TABLE = {};
//空的Entry数组
transient Entry[] table = (Entry[]) EMPTY_TABLE;
//实际存储key-value键值对的个数
transient int size;
//阀值大小,如果table等于空,那么初始化一个大小为capacity的Entry数组,如果数组已经有值,那么为capacity * load factor,用于判断该数组需不需要扩容
int threshold;
//负载因子
final float loadFactor;
//记录HashMap结构变化的次数
transient int modCount;
//默认threshold的值
static final int ALTERNATIVE_HASHING_THRESHOLD_DEFAULT = Integer.MAX_VALUE;
HashMap的初始化
public HashMap() {
this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
}
这是HashMap的一个构造方法,他还有其他三个构造方法,进入this另一个构造方法,参数有2个,分别为默认初始化Entry数组大小的值,默认负载因子0.75
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);
this.loadFactor = loadFactor;
threshold = initialCapacity;
init();
}
在这个构造方法中,前面几行在判断传入的参数是否合法,如果不合法,则把参数赋值给loadFactor和leireshold,再调用init方法,在这里init方法为空,但是在子类中比如LinedHashMap中,会有实现。到这里,HashMap的初始化就完成了。
HashMap的 Put() 方法
public V put(K key, V value) {
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
if (key == null)
return putForNullKey(value);
int hash = hash(key);
int i = indexFor(hash, table.length);
for (Entry e = table[i]; e != null; e = e.next) {
Object k;
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
addEntry(hash, key, value, i);
return null;
}
1.如果table是空,那么初始化Entry数组,我们先看下inflateTable方法,参数threshold为16
private void inflateTable(int toSize) {
// Find a power of 2 >= toSize
int capacity = roundUpToPowerOf2(toSize);
threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
table = new Entry[capacity];
initHashSeedAsNeeded(capacity);
}
1.1 这里执行roundUpToPowerOf2方法,这个方法其实就是确保capacity 等于或者大于我们传入的toSize参数的2次幂
private static int roundUpToPowerOf2(int number) {
// assert number >= 0 : "number must be non-negative";
return number >= MAXIMUM_CAPACITY
? MAXIMUM_CAPACITY
: (number > 1) ? Integer.highestOneBit((number - 1) << 1) : 1;
}
比如toSize=15,那么capacity=16,如果toSize=16 ,那么capacity=16,如果如果toSize=18 ,那么capacity=32,为什么数组大小一定要是2幂次方呢,因为2的幂次方有个特点就是,2的幂次方-1得到的二进制的全部是1,元素的key的hashCode值是怎么计算出在数组中位置的呢,是通过java static int indexFor(int h, int length) { return h & (length-1); }
这个indexFor方法来计算出来的,我们来演示一下那么capacity=16和capacity=10的时候,计算出来数组的位置有什么区别
capacity=16
capacity=16 | 值 |
---|---|
hashCode | 101110001110101110 1001 |
length-1 | 1111 |
h & (length-1) | 1001 |
capacity=10
capacity=10 | 值 |
---|---|
hashCode | 101110001110101110 1001 |
length-1 | 1001 |
h & (length-1) | 1001 |
再换一个hashCode
capacity=10|值
---|:--:|---:
hashCode|101110001110101110 1111
length-1|1001
h & (length-1)|1001
可以发现此时的计算结果还是1001,那么得到的数组的下标就没有变,我们想要的结果是元素能够均匀的分布在数组中,尽量减少链表的出现,所以capacity的大小要为2的幂次方,回到inflateTable方法中的第二行
private void inflateTable(int toSize) {
...
threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
table = new Entry[capacity];
initHashSeedAsNeeded(capacity);
}
1.2 这里是为threshold赋值,取capacity*loadFacto和MAXIMUM_CAPACITY + 1的最小值
1.3 初始化一个大小为capacity的Entry数组
回到之前的put方法中去
public V put(K key, V value) {
...
if (key == null)
return putForNullKey(value);
int hash = hash(key);
int i = indexFor(hash, table.length);
for (Entry e = table[i]; e != null; e = e.next) {
Object k;
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
addEntry(hash, key, value, i);
return null;
}
- 如果key==null,那么return putForNullKey,这个方法就是把 value放入table的第一位中
- hash(key),对key求hashcode值
- indexFor方法,我们上面已经分析过了,通过hashcode值和数组长度来计算该元素应该存储在数组下标
- for循环遍历Entry的链表,如果有key值一样的,则用新的value替换掉老的value,变return 老的value
- 执行modCount++,代表结构发生改变过一次
- 执行addEntry
void addEntry(int hash, K key, V value, int bucketIndex) {
if ((size >= threshold) && (null != table[bucketIndex])) {
resize(2 * table.length);
hash = (null != key) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length);
}
createEntry(hash, key, value, bucketIndex);
}
void createEntry(int hash, K key, V value, int bucketIndex) {
Entry e = table[bucketIndex];
table[bucketIndex] = new Entry<>(hash, key, value, e);
size++;
}
7.1 先判断size大小是否大于等于阀值,并且是否即将产生hash碰撞,如果是,则进行扩容,这个放在下面一个小节去分析
7.2 如果不需要扩容,那么获取待插入数组处的元素,可能为空,也可能不为空,然后进行链接操作,把新元素放在链表的头位置,next指向原有的元素
7.3 size自增
HashMap的扩容
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
Entry[] newTable = new Entry[newCapacity];
transfer(newTable, initHashSeedAsNeeded(newCapacity));
table = newTable;
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
7.1.1 如果原先的table的长度等于最大值了,那么不操作直接return
7.1.2 创建一个新的数组,大小为原来的2倍
7.1.3 transfer方法,作用就是将老的数组中的数据拷贝到新的数组中
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry e : table) {
while(null != e) {
Entry next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
7.1.3.1 遍历老的table中的元素,while循环如果e不为null,取出next
7.1.3.2 判断是否需要重新计算hashcode
7.1.3.3 indexFor方法计算hashcode值在新数组中的位置
7.1.3.4 后面三行代码,作用就是把后取出的元素插入链表的头位置,一直到整个链表全部取完
HashMap的get()方法
接下来分析一下get方法:
public V get(Object key) {
if (key == null)
return getForNullKey();
Entry entry = getEntry(key);
return null == entry ? null : entry.getValue();
}
final Entry getEntry(Object key) {
if (size == 0) {
return null;
}
int hash = (key == null) ? 0 : hash(key);
for (Entry e = table[indexFor(hash, table.length)];
e != null;
e = e.next) {
Object k;
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
}
return null;
}
- 如果key == null 那么直接执行getForNullKey方法,应该还记得如果key是null的时候,是放在了table数组的第0个位置,这里直接去table[0]的链表,去取key等于null的value
- 如果 key 不等于null,那么先通过indxFor计算hashcode值在数组中的位置,再取table[]中的链表,循环链表,判断hashcode值相等并且 key的equals方法也相等,那么返回value
这里要注意一点就是,放入HashMap中的对象,如果重写了equals方法,没有重写hashCode方法,那么这个时候可能会存在问题?想一下为什么,可以留言评论哦
HashMap为什么线程不安全
笔者在面试别人的过程中发现一个很有意思的现象,大部分应聘者对于这个问题的回答都是,因为没有加锁,其实面试官更想听到的是HashMap在什么情况下的什么操作或者什么结构导致了他的线程不安全
HashMap在并发下数据读取的情况
出现场景
- 如果一个HashMap的size已经到了阀值,这个时候有1个线程同时要插入一个会产生hash冲突的Entry,同时另一个线程要读取一个key
- 线程2执行到了下面这一步,已经根据hashcode计算完在table中的位置,假设是2,这个时候线程2挂起
for (Entry e = table[indexFor(hash, table.length)];
- 线程1这个时候对table进行了扩容,重新分配原有Entry到新的table中,这个时候线程2要读取的key的位置分配到了table[3]的位置了
- 线程2继续执行,去读取table[2]的值,这个时候,可能读取到空,也可能读取到错误的值,也有可能读到的是正确的值,有读者应该会问,为什么可能是对的值呢,看完下面一种情形,再来思考一下这个问题
HashMap在并发下同时插入数据,不产生扩容的情况
····
static class t10 implements Runnable{
private HashMap hashMap;
public t10(HashMap hashMap){
this.hashMap = hashMap;
}
@Override
public void run() {
for(int i=1; i<= 1000; i++){
System.out.println(i);
hashMap.put(UUID.randomUUID()+ UUID.randomUUID().toString(),i);
}
}
}
public static void main(String[] args) {
HashMap hashMap = new HashMap(32768);
t1 t1 = new t1(hashMap);
···
t10 t10 = new t10(hashMap);
Thread thread1 = new Thread(t1);
···
Thread thread10 = new Thread(t10);
thread1.start();
····
thread10.start();
try {
Thread.sleep(35000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.print("--------------------"+hashMap.size());
}
出现场景
- new 一个HashMap ,初始容量大小为32768,设置这么大就是防止HashMap扩容
- 启动10个线程,每个线程中循环1000次往这个map中插入不重复的key,
如果HashMap是线程安全的,那么最终我们得到的结果map的size大小应该是10000,但是实际结果总是会小于10000
这里要了解JVM内存机制,在JVM中,会给每个线程分配一个私有工作空间,线程会不定时的从主内存中同步最新的数据到自己的工作空间中,也会把自己工作空间的内存数据写入到主内存中,但是,线程之间的数据是不可见的,这会导致有的线程把数据写回主内存的时候覆盖掉别的线程已经写回主内存的数据,导致线程安全问题
HashMap在并发下同时插入数据同时遇到扩容情况
先说结果,这种情况就是大家在网上看到最多的循环链表的情况,大部分应聘的同学应该都在网上看过这些,因为大部分同学都只会提到这一点,网上的文章不一定是全面的,也不一定是对的,看了之后需要自己去思考一下,下面分析一下这种情况出现的过程,过程会比较烧脑
我们先回顾下扩容的代码:
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry e : table) {
while(null != e) {
Entry next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
设置场景
有2个线程,同时要往一个容量为2的HashMap中插入一个hash冲突的Entry,这个时候hashMap会扩容为一个容量为4的hash表,下面我们来分析一下这样的场景
假设上图是单线程环境下扩容前和扩容后的结构对比,在多线程的环境下,我们先看下关键代码
while(null != e) {
Entry next = e.next;
···
e.next = newTable[i];
newTable[i] = e;
e = next;
}
- 假设线程1在Entry
next = e.next;这一步执行完成之后挂起 - 线程2正常执行完成扩容,我们看下此时内存中map的结构
- 这个时候线程2把扩容好的数据同步到主内存,线程1继续执行,先把主内存的数据同步到自己的工作内存
- 此时next指向k2,e指向k1
- 线程1执行e.next = newTable[i],e.next 指向k2
- 执行newTable[i] = e ,此时的i=3,newTable[3]指向k1
- 执行e = next,此时新的e指向k2,同时新的e.next指向k1,那么这个时候线程1中的内存情况如下图
- 此时,继续循环 next = e.next ,那么next指向k1
- e.next = newTable[i],e.next 指向k1
- newTable[i] = e,newTable[3]指向k2
- e = next,此时e指向k1,e.next 指向null,此时线程1的内存如下图
- 继续循环 next = e.next ,那么next指向null
- e.next = newTable[i],e.next 指向k2
- newTable[i] = e,newTable[3]指向k1
- e = next,此时e指向null,此时我们再来看下线程1的内存图
此时newTable[3]下的链表形成了一个环状链表,此时线程1把数据同步到主内存中,如果这个时候有线程来访问newTable[3],那么就进入死循环了。这个也是HashMap线程不安全的一个方面。这段看起来有点绕,可以自己画图理解一下
那有没有线程安全的Map,有,比如HashTable,并发包下的ConcurrentHashMap,下面我们来看下1.7下的ConcurrentHashMap实现
JDK1.7的ConcurrentHashMap
j.u.c包是JDK的并发工具包,提供了很多并发工具类,这里不展开并发的东东,后面打算专门去写一个j.u.c的专题
先看下ConcurrentHashMap的数据结构,从网上找了一张画的很好看的图,底层是一个Segment数组,每个Segment中是一个哈希表,Segment继承了ReentrantLock类,ConcurrentHashMap的并发安全就是通过这把锁实现的
ConcurrentHashMap成员变量
先看下ConcurrentHashMap的成员变量的解释
/* ---------------- Constants -------------- */
//默认capacity大小为16
static final int DEFAULT_INITIAL_CAPACITY = 16;
//默认负载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//默认concurrency level级别大小
static final int DEFAULT_CONCURRENCY_LEVEL = 16;
//最大capacity 2的30次方
static final int MAXIMUM_CAPACITY = 1 << 30;
//最小segment数组大小为2
static final int MIN_SEGMENT_TABLE_CAPACITY = 2;
//最大segments数组大小2的16次方
static final int MAX_SEGMENTS = 1 << 16; // 保守的值
//还不知道干什么todo
static final int RETRIES_BEFORE_LOCK = 2;
//用于定位段,大小等于segments数组的大小减 1,final不可变
final int segmentMask;
//用于定位段,大小等于32(hash值的位数)减去对segments的大小取以2为底的对数值,final不可变
final int segmentShift;
//segments数组
final Segment[] segments;
再来看下Segment内部类的成员变量
```java
static final class Segment
//最大tryLock的最大次数,如果CPU的核心数>1 那么最大次数为64,否则为1
static final int MAX_SCAN_RETRIES =
Runtime.getRuntime().availableProcessors() > 1 ? 64 : 1;
//volatile修饰的HashEntry的链表数组
transient volatile HashEntry[] table;
//Segment中元素的数量,在锁内访问
transient int count;
//对count值造成影响的操作的次数(比如put或者remove操作)
transient int modCount;
//阈值,Segment中元素的数量超过这个值就会进行扩容
transient int threshold;
//Segment的负载因子,其值等同于ConcurrentHashMap的负载因子
final float loadFactor;
}
//HashEntry链表结构
static final class HashEntry
final int hash;
final K key;
volatile V value;
volatile HashEntry
} ### ConcurrentHashMap的初始化
java
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;
// Find power-of-two sizes best matching arguments
int sshift = 0;
int ssize = 1;
while (ssize < concurrencyLevel) {
++sshift;
ssize <<= 1;
}
this.segmentShift = 32 - sshift;
this.segmentMask = ssize - 1;
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
int c = initialCapacity / ssize;
if (c * ssize < initialCapacity)
++c;
int cap = MIN_SEGMENT_TABLE_CAPACITY;
while (cap < c)
cap <<= 1;
// create segments and segments[0]
Segment
new Segment
(HashEntry
Segment
UNSAFE.putOrderedObject(ss, SBASE, s0); // ordered write of segments[0]
this.segments = ss;
}
```
- 初始值initialCapacity=16 loadFactor =0.75L concurrencyLevel =16
- 计算Segments数组的大小,while循环,如果ssize 小于 16那么
- sshift++,ssize <<= 1,ssize进行左移1位,相当于*2,直到ssize=16(Segments数组的大小),此时sshift=4
- segmentShift = 32 - 4 , segmentMask = 16 - 1
- c = initialCapacity / ssize 总的Map大小/Segments数组的大小 = 1,计算每个Segment中可以分到的数组的大小,
- cap的值代表每个Segment下所拥有的数组的大小=2 (2的幂次方),这里cap默认值为2,不是1,因为不至于插入一个数据就要开始扩容
- 创建第一个Segment对象,初始化三个参数 loadFactor 负载因子, (int)(cap * loadFactor) 阀值大小, (HashEntry
[])new HashEntry[cap]) 初始化Segment下的数组,大小为2
PUT方法
public V put(K key, V value) {
Segment s;
if (value == null)
throw new NullPointerException();
int hash = hash(key);
int j = (hash >>> segmentShift) & segmentMask;
if ((s = (Segment)UNSAFE.getObject // nonvolatile; recheck
(segments, (j << SSHIFT) + SBASE)) == null) // in ensureSegment
s = ensureSegment(j);
return s.put(key, hash, value, false);
}
- 判断value是否为空值,
- 计算key的hash值
- 通过hash值计算在Segments数组中的位置,
- hash 是 32 位,无符号右移 segmentShift(28) 位,剩下高4位
- 然后和 segmentMask(15) 做一次与操作,j就是hash值的高4位,也就是Segments的数组下标
- 刚刚只初始化了Segments[0]数据,如果Segments[j]位置为空,这里判断是通过UNSAFE.getObject方法判断的,如果为空,那么调用ensureSegment(j)初始化
- 调用Segment内部类中的put方法保存数据
接下来我们先看下ensureSegment(j)方法
private Segment ensureSegment(int k) {
final Segment[] ss = this.segments;
long u = (k << SSHIFT) + SBASE; // raw offset
Segment seg;
if ((seg = (Segment)UNSAFE.getObjectVolatile(ss, u)) == null) {
Segment proto = ss[0]; // use segment 0 as prototype
int cap = proto.table.length;
float lf = proto.loadFactor;
int threshold = (int)(cap * lf);
HashEntry[] tab = (HashEntry[])new HashEntry[cap];
if ((seg = (Segment)UNSAFE.getObjectVolatile(ss, u))
== null) { // recheck
Segment s = new Segment(lf, threshold, tab);
while ((seg = (Segment)UNSAFE.getObjectVolatile(ss, u))
== null) {
if (UNSAFE.compareAndSwapObject(ss, u, null, seg = s))
break;
}
}
}
return seg;
}
4.1 先判断Segments[j]的位置是否为null
4.2 取Segments[0]位置的Segment对象,取到当时初始化时的三个参数,cap,负载因子,阀值,
4.3 然后new 一个大小为cap大小的HashEntry数组
4.4 接着再次判断Segments[j]位置是否为null
4.5 new 一个Segment对象
4.6 while循环,条件是Segments[j]位置等于null,那么通过CAS把seg对象放入Segments[j]位置
- CAS如果成功,那么break
- 如果失败,那么继续一次while循环取到seg对象,这个就是别的线程创建的了
接下来我们Segment内部类中的put方法是怎么保存数据的
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
HashEntry node = tryLock() ? null :
scanAndLockForPut(key, hash, value);
V oldValue;
try {
HashEntry[] tab = table;
int index = (tab.length - 1) & hash;
HashEntry first = entryAt(tab, index);
for (HashEntry e = first;;) {
if (e != null) {
K k;
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 {
if (node != null)
node.setNext(first);
else
node = new HashEntry(hash, key, value, first);
int c = count + 1;
if (c > threshold && tab.length < MAXIMUM_CAPACITY)
rehash(node);
else
setEntryAt(tab, index, node);
++modCount;
count = c;
oldValue = null;
break;
}
}
} finally {
unlock();
}
return oldValue;
}
5.1 先通过tryLock方法获取锁,如果失败,那么调用scanAndLockForPut继续获取锁,这个方法后面再分析
5.2 根据hash值和Segment中的table大小计算应该放入table的位置下标index
5.3 取出index位置的链表的表头
5.4 这个链表的表头,可能不为空,也可能为空,
5.5 如果不为空,先判断是否为相同的key,如果key相同,那么直接覆盖原有的值,如果是不相同的key,那么e = e.next,取到下一个节点,继续循环
5.6 如果链表的头为空的情况,继续判断node是否为null,如果为null,那么new一个HashEntry设置为链表表头,如果不为null,那么直接设置为表头,把next指向刚刚获取到的first对象
5.7 判断是否超过阀值,如果超过,那么进行rehash,否则,调用setEntryAt,将node放入tab的index位置
5.8 modCount++
5.9 释放锁
接下来看下如果tryLock失败的情况下,如何通过scanAndLockForPut获取锁的
private HashEntry scanAndLockForPut(K key, int hash, V value) {
HashEntry first = entryForHash(this, hash);
HashEntry e = first;
HashEntry node = null;
int retries = -1; // negative while locating node
while (!tryLock()) {
HashEntry f; // to recheck first below
if (retries < 0) {
if (e == null) {
if (node == null) // speculatively create node
node = new HashEntry(hash, key, value, null);
retries = 0;
}
else if (key.equals(e.key))
retries = 0;
else
e = e.next;
}
else if (++retries > MAX_SCAN_RETRIES) {
lock();
break;
}
else if ((retries & 1) == 0 &&
(f = entryForHash(this, hash)) != first) {
e = first = f; // re-traverse if entry changed
retries = -1;
}
}
return node;
}
5.1.1 while循环 tryLock方法,如果获取到了锁,那么直接return node
5.1.2 如果没有获取到锁,判读e是否为null,如果为null表示链表头没有元素,那么new HashEntry,赋值给node,如果e不为null,且key相同,那么直接retries赋值为0,如果e不为null,且key也不相同,那么e = e.next
5.1.3 如果tryLock次数retries已经大于MAX_SCAN_RETRIES次,这个单核为1,多核为64,直接lock,这里Doug Lea的策略是先tryLock,重实次数到了,直接通过lock进入等待队列去获取锁
5.1.4 如果这个时候first已经变化了,那么就要重新把scanAndLockForPut方法再次执行一遍了
到这里,put方法就全部分析完了,ConcurrentHashMap通过Segments数组也就是分段的概念,在每个segment中通过CAS初始化单个的Segment对象,在每个Segment中的put方法 通过继承ReentrantLock的tryLock方法或者是lock方法获取锁来保证线程安全
rehash扩容
扩容的触发点是在put操作中,如果达到阀值,那么进行扩容,因为put操作是线程安全,所以扩容操作也是线程安全的操作
private void rehash(HashEntry node) {
HashEntry[] oldTable = table;
int oldCapacity = oldTable.length;
int newCapacity = oldCapacity << 1;
threshold = (int)(newCapacity * loadFactor);
HashEntry[] newTable =
(HashEntry[]) new HashEntry[newCapacity];
int sizeMask = newCapacity - 1;
for (int i = 0; i < oldCapacity ; i++) {
//老的数组i处链表的第一个元素e
HashEntry e = oldTable[i];
if (e != null) {
HashEntry next = e.next;
//计算应该放置在新数组中的位置
int idx = e.hash & sizeMask;
//只有一个元素的情况下
if (next == null) // Single node on list
newTable[idx] = e;
else { // Reuse consecutive sequence at same slot
//lastRun赋值为e,e是链表的表头,最晚插入的元素
HashEntry lastRun = e;
//lastIdx为新数组中的位置
int lastIdx = idx;
// 下面这个for 循环会找到一个 lastRun节点,这个节点之后的所有元素是将要放到一起的
for (HashEntry last = next;
last != null;
last = last.next) {
int k = last.hash & sizeMask;
if (k != lastIdx) {
lastIdx = k;
lastRun = last;
}
}
// 将lastRun和之后的所有节点组成的这个链表放到放到lastIdx这个位置
newTable[lastIdx] = lastRun;
// Clone remaining nodes
//将lastRun之前的节点数据放入新的table中
for (HashEntry p = e; p != lastRun; p = p.next) {
V v = p.value;
int h = p.hash;
int k = h & sizeMask;
HashEntry n = newTable[k];
newTable[k] = new HashEntry(h, p.key, v, n);
}
}
}
}
//把新的node节点数据放入新的table中
int nodeIndex = node.hash & sizeMask; // add the new node
node.setNext(newTable[nodeIndex]);
newTable[nodeIndex] = node;
table = newTable;
}
- 先用oldTable指向table
- 取出oldCapacity,计算新的capacity,为oldCapacity的2倍
- 计算新的阀值,创建新的数组
- 计算新的sizeMask,
- 接下来遍历老的数组,将数组i位置的链表迁移到新的数组的i的位置和i+cap位置处,循环的部分在代码注释中展示
看完这段代码,发现一个问题,去拿lastRun这个值的for 循环有什么作用,下面的for循环也可以完成迁移,原因是如果lastRun之后有很多节点,那么这次for循环就有很大意义,因为下个for循环只需要处理lastRun之前的数据节点,但是如果lastRun节点是最后一个节点或者后面只有很少的节点,那么这次for循环就不值得了,但是 Doug Lea说,根据统计,如果使用默认阀值,只有六分之一的节点需要克隆,大师写代码水平还是不一样,总是会使用各种方式来提高性能
get方法
public V get(Object key) {
Segment s; // manually integrate access methods to reduce overhead
HashEntry[] tab;
int h = hash(key);
long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;
if ((s = (Segment)UNSAFE.getObjectVolatile(segments, u)) != null &&
(tab = s.table) != null) {
for (HashEntry e = (HashEntry) 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;
}
- 计算key的hash值
- 计算在segments数组的位置
- 找到segment 内部数组相应位置的链表,for循环
get操作中没有加锁,下面分析一下如果get操作的线程安全性
- put 操作的线程安全性。
- 初始化Segment,使用了CAS来初始化Segment中的数组。
- 添加节点到链表的操作是插入到表头的,所以,如果这个时候 get 操作在链表遍历的过程已经到了中间,是不会影响的。当然,另一个并发问题就是 get 操作在 put 之后,需要保证刚刚插入表头的节点被读取,这个依赖于 setEntryAt 方法中使用的 UNSAFE.putOrderedObject。
- 扩容是新创建了数组,然后进行迁移数据,最后面将 newTable 设置给属性 table。所以,如果 get 操作此时也在进行,那么也没关系,如果 get 先行,那么就是在旧的 table 上做查询操作;而 put 先行,那么 put 操作的可见性保证就是table使用了volatile 关键字。
remove 操作的线程安全性。
remove 操作
get 操作需要遍历链表,但是 remove 操作会"破坏"链表。
如果 remove 破坏的节点 get 操作已经过去了,那么这里不存在任何问题。
- 如果 remove 先破坏了一个节点,分两种情况考虑。
- 如果此节点是头结点,那么需要将头结点的 next 设置为数组该位置的元素,table 虽然使用了 volatile 修饰,但是 volatile 并不能提供数组内部操作的可见性保证,所以源码中使用了 UNSAFE 来操作数组,请看方法 setEntryAt。
- 如果要删除的节点不是头结点,它会将要删除节点的后继节点接到前驱节点中,这里的并发保证就是 next属性是volatile 的。
JDK1.8 HashMap
1.8的HashMap在1.7的基础上做了一些改动,结构上的主要改动就是链表中的元素个数达到8的时候,链表结构会变成红黑树结构,这样做的好处就是时间复杂度从原来的O(n)降低为O(logN),从网上找了一张图来看下结构
HashMap的存储结构
static class Node implements Map.Entry {
final int hash;
final K key;
V value;
Node next;
这个Node节点对应1.7中的Entry节点,四个属性其实也是一样的,用来表示链表结构
static final class TreeNode extends LinkedHashMap.Entry {
TreeNode parent; // red-black tree links
TreeNode left;
TreeNode right;
TreeNode prev; // needed to unlink next upon deletion
boolean red;
这个是treeNode节点,用来表示红黑树,我们根据一个元素来判断是链表结构还是红黑树结构
PUT方法
因为有来1.7的基础,这里直接看下1.8的put方法实现,过程在注释中写
public V put(K key, V value) {
// 计算key的hash值
return putVal(hash(key), key, value, false, true);
}
//onlyIfAbsent = false代表只有不存在该key我们才执行put操作
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
Node[] tab; Node p; int n, i;
//判断如果数组为null那么进行resize,在这里是初始化,和1.7类似,
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//如果此处没有元素,那么直接初始化一个node节点
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
//如果此处已经有元素
Node e; K k;
//先判断此处key是否相等,如果相等,那么先取出这个节点
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//如果key不相等,再判断节点是否是红黑树
else if (p instanceof TreeNode)
//如果是红黑树,那么调用红黑树的插入方法
e = ((TreeNode)p).putTreeVal(this, tab, hash, key, value);
else {
//如果是链表节点,那么进入循环
for (int binCount = 0; ; ++binCount) {
//如果p.next 为空
if ((e = p.next) == null) {
//newNode 到p的next上
p.next = newNode(hash, key, value, null);
//如果binCount大于等于7
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
//那么把链表转化为红黑树
treeifyBin(tab, hash);
break;
}
//如果要插入的数据和p.next的key一样,那么直接break
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
//如果e不等于null,那么就是key一样的情况,把新value设置,返回老的value
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
//判断是否需要扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
刚刚上面有出现两次resize方法,一次是初始化数组的时候,一次是数组需要扩容的时候,下面看下这个方法
final Node[] resize() {
Node[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
//如果oldCap大于0代表是扩容不是初始化
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
//将数组大小*2
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
//将阀值也*2
newThr = oldThr << 1; // double threshold
}
//对应使用 new HashMap(int initialCapacity) 初始化后,第一次 put 的时候
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else { // zero initial threshold signifies using defaults
//如果是使用new HashMap()方法
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[] newTab = (Node[])new Node[newCap];
table = newTab;
//如果不是扩容,初始化一个空的数组到这里就结束了
if (oldTab != null) {
//开始遍历
for (int j = 0; j < oldCap; ++j) {
Node 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)e).split(this, newTab, j, oldCap);
else { // preserve order
//处理是链表的情况
//将原有的链表拆分成2个链表,按照原有的顺序保存到新的数组中
Node loHead = null, loTail = null;
Node hiHead = null, hiTail = null;
Node 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;
}
get方法这里就不用分析了,也是非常简单
JDK1.8 ConcuttentHashMap
接下里我们来分析1.8中的ConcurrrentHashMap,相对与1.7,1.8的ConcurrentHashMap改动相对比较大,其结构和1.8的hashMap其实差不多,只是在HashMap的基础上要保证线程安全