Map详解
1、HashMap
1.1、JDK1.7中的HashMap
1.1.1、底层结构
HsahMap在JDK1.7中底层是由数组+链表实现的,数组又被分为一个一个的桶(bucket),数据通过哈希值确定键值对在这个数组中存放的位置。当哈希值相同时,会以链表的方式存储。每一个键值对会用一个Entry实例对象进行封装,Entry对象里面包含四个属性:key、value、hash值、用于单向链表的指针next。
Entry对象
HashMap初始化
-
hashMap初始化时要传递两个参数initialCapacity(初始化容量),loadFactor(负载因子)
-
当用户调用HashMap中无参的构造方法时,初始化容量为:16,负载因子为:0.75
//HashMap的无参构造方法
public HashMap() {
this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
}
//定义的初始化容量常量
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
//定义的初始化负载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
HashMap添加数据的过程(put方法)
//初始化数组
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);
}
//HsahMap中的put方法
public V put(K key, V value) {
//当第一个插入数据时,初始化数组和计算出扩容阈值
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
//如果key==null,调用添加存储键为空的方法进行存储
if (key == null)
return putForNullKey(value);
//通过key计算出hash值
int hash = hash(key);
//计算出数据存储在数组的索引,计算方法为:h & (length-1)
int i = indexFor(hash, table.length);
//如果位置上已经存在该key,那么修改此key的值
for (Entry<K,V> 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;
}
//HashMap添加key为null的方法
private V putForNullKey(V value) {
for (Entry<K,V> e = table[0]; e != null; e = e.next) {
if (e.key == null) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
addEntry(0, null, value, 0);
return null;
}
在插入第一个元素到HashMap时数组初始化,先确定数组的大小,然后计算出扩容的阈值。(公式为:数组容量x扩容因子);
如果key为null,则调用存储key==null的方法,如果key不为空,使用key先计算出Hash值,然后通过(n-1)&hash判断当前元素存放的位置(n为数组的长度),用于判断该元素存放在哪一个Bucket中;
找到Bucket后,如果当前位置存在元素的话,要判断该位置的元素与要存入的元素的hash值以及key是否相同,如果没有重复,则将此Entry放入链表的头部;如果出现重复,则将此Entry放入链表的尾部,同时建立与前一个节点的连接;
在插入新值时,如果当前Bucket数组的大小到了阈值,则触发扩容。扩容后,为原来大小的2倍,扩容时会产生一个新数组替换原来的数组,并将原来数组中的值迁移到新数组中;
1.1.2、扩容原理
执行过程
- 当调用HashMap中的put方法时,内部会调用addEntry方法添加元素;
- addEntry方法首先会判断是否需要扩容,如果满足扩容条件,则调用内部的resize方法进行扩容,扩容为原来的2倍;
- 在resize方法中,创建一个新数组,通过transfer方法将以前数组中的Entry迁移到新数组中;
-
在transfer方法中会循环遍历原数组的Entry,并且重新计算Entry在新数组中的位置,并通过链表的方式连接;
-
当执行完毕,将table变为新数组;
图解扩容
-
假设HashMap原始数组大小为2,有三个元素,位置计算公式为:key%数组长度
-
现在数组进行扩容,扩容为原来的2倍;
-
transfer方法为新数组添加元素;
-
迁移第一个key=3的元素,根据公式计算出在新数组的位置3%4=3,插入完成,指针后移;
-
迁移第二个key=7的元素,根据公式计算出在新数组的位置7%4=3,插入完成,指针后移;
-
迁移第二个key=5的元素,根据公式计算出在新数组的位置5%4=3,插入完成,遍历结束;
1.1.3、死循环解析
1.2、JDK1.8中的HashMap
1.2.1、底层结构
JDK1.8对HashMap进行了存储结构的优化,底层由数组+链表+红黑树组成。加快了数据查询的速度;
在JDK1.8中,如果链表的元素大于等于8个,那么链表会转化为红黑树(注意:前提是桶的大小达到64,否则会先对桶进行扩容);当红黑树中的元素小于等于6个,则红黑树会自动转化为链表
1.2.2、源码解析
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
//判断数组是否为空
if ((tab = table) == null || (n = tab.length) == 0)
//为空,对数组扩容
n = (tab = resize()).length;
//判断获取到的要插入的位置是否为空
if ((p = tab[i = (n - 1) & hash]) == null)
//为空,直接插入数据
tab[i] = newNode(hash, key, value, null);
else {//不为空
Node<K,V> e; K k;
//判断要插入元素的key是否已经存在
if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
//存在,直接替换值
e = p;
//不存在,判断数组该位置是否为红黑树
else if (p instanceof TreeNode)
//是红黑树,插入节点
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
//不是红黑树,遍历链表
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
//采用尾插法将数据插入到链表中
p.next = newNode(hash, key, value, null);
//判断链表长度是否大于等于8
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;
}
}
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;
}
HashMap中put执行图:
2、ConcurrentHashMap
ConcurrentHashMap是一个线程安全且高效的HashMap。在并发下,推荐使用其替换HashMap。对于它的使用也非常的简单,除了提供了线程安全的get和put之外,它还提供了一个非常有用的方法putIfAbsent,如果传入的键值对已经存在,则返回存在的value,不进行替换; 如果不存在,则添加键值对,返回null。
2.1、JDK1.7中的ConcurrentHashMap
2.1.1、底层实现
一个ConCurrentHashMap里包含一个Segment数组,一个Segment中又包含一个HashEntry数组,每个HashEntry就是链表的元素。
ConcurrentHashMap是如何保证线程安全的呢?Segment对象继承了ReentrantLock,在ConcurrentHashMap中相当于锁的角色,在多线程操作时,不同线程操作不同的Segment。只要锁住一个Segment,其他的Segment依然可以操作,这样只要保证每个 Segment 是线程安全的,我们就实现了全局的线程安全。
Segment
构造方法里面要传入HashEntry数组
HashEntry
ConcurrentHashMap构造
根据其构造函数可知,map的容量默认为16,负载因子为0.75。这两个都与原HashMap相同,但不同的在于,其多个参数concurrencyLevel(并发级别),通过该参数可以用来确定Segment数组的长度并且不允许扩容,默认为16。
并发度设置过小会带来严重的锁竞争问题;如果过大,原本位于一个segment内的访问会扩散到不同的segment中,导致查询命中率降低,引起性能下降。
get方法
-
根据key计算出对应的segment
-
获取segment下的HashEntry数组
-
遍历获取每一个HashEntry进行比对。
注意:整个get过程没有加锁,而是通过volatile保证可以拿到最新值。
put方法
-
向下调用ensureSegment方法,其内部可以通过cas保证线程安全,让多线程下只有一个线程可以成功。
-
在put方法中当初始化完Segment后,会调用 方法进行键值对存放。首先会调用tryLock()尝试获取锁,node为null进入到后续流程进行键值对存放;如果没有获取到锁,则调用**scanAndLockForPut()**自旋等待获得锁。
-
在**scanAndLockForPut()**方法中首先会根据链表进行遍历,如果遍历完毕仍然找不到与key相同的HashEntry,则提前创建一个HashEntry。当tryLock一定次数后仍然无法获得锁,则主动通过lock申请锁。
-
在获得锁后,segment对链表进行遍历,如果某个 HashEntry 节点具有相同的 key,则更新该 HashEntry 的 value 值,否则新建一个节点将其插入链表头部。如果节点总数超过阈值,则调用rehash()进行扩容。
2.2、 JDK1.8的ConcurrentHashMap
get方法
put方法
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException();
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)
//如果table为空,初始化table
tab = initTable();
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
//CAS向Node数组中存值
if (casTabAt(tab, i, null,new Node<K,V>(hash, key, value, null)))
break; // no lock when adding to empty bin
}
else if ((fh = f.hash) == MOVED)
//扩容
tab = helpTransfer(tab, f);
else {
V oldVal = null;
//通过synchronized锁住数组中的元素
synchronized (f) {
if (tabAt(tab, i) == f) {
//是链表中的节点
if (fh >= 0) {
binCount = 1;
for (Node<K,V> e = f;; ++binCount) {
K ek;
//存放数据
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
oldVal = e.val;
if (!onlyIfAbsent)
e.val = value;
break;
}
Node<K,V> pred = e;
//尾插法
if ((e = e.next) == null) {
pred.next = new Node<K,V>(hash, key,value, null);
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) {
//达到阈值,链表转化为红黑树
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
//map数量加1,检查是否需要扩容
addCount(1L, binCount);
return null;
}
2.2.1、与hashTable的区别
- Hashtable的任何操作都会把整个表锁住,是阻塞的。好处是总能获取最实时的更新,比如说线程A调用putAll写入大量数据,期间线程B调用get,线程B就会被阻塞,直到线程A完成putAll,因此线程B肯定能获取到线程A写入的完整数据。坏处是所有调用都要排队,竞争越激烈效率越低。 更注重安全。
- ConcurrentHashMap 是设计为非阻塞的。在更新时会局部锁住某部分数据,但不会把整个表都锁住。同步读取操作则是完全非阻塞的。好处是在保证合理的同步前提下,效率很高。坏处 是严格来说读取操作不能保证反映最近的更新。例如线程A调用putAll写入大量数据,期间线程B调用get,则只能get到目前为止已经顺利插入的部分数据。更注重性能。