上一篇:java集合:(二)ArrayList、LinkedList、Vector源码原理分析以及FailFast机制
HashMap、ConcurrentHashMap源码分析以及与Hashtable、TreeMap的区别
一、HashMap概念
1. 为什么会有HashMap
首先当我们需要存储数据的时候,动态数组虽然能够自动扩容,但是必须在初始时刻指定初始容量。而对于那些在编译时无法确定具体的数量即动态增长的数据,就需要用到Java集合类了。对于ArrayList 和 LinkedList,还有 Vector它们都有一些缺点,要么插入删除速度慢、要么就是遍历速度慢。那么有没有一种插入、删除、遍历都比较不错的集合类呢?于是 HashMap 就出现了。HashMap,它存储的是一组键值对(key-value)的集合,并实现快速的查找,而且在某些业务场景下HashMap比List更加好用。
2. HashMap底层原理
HashMap底层是由动态数组+单向链表或动态数组+红黑树构成的(单向链表和红黑树的概念请自行百度
),只是这样说可能不能理解,这里有这个概念就行,详细的后面会讲。
HashMap中储存数据的是个数组,在数组的每一个元素当中存储的形式可以是单向链表也可以是红黑树的结构。如果是单向链表的话对应的则是Node<K,V>
这个类,如果是红黑树的话对应的是TreeNode<K,V>
这个类,这两个类都是HashMap中的内部静态类。即单向链表和红黑树是数据结构,数据结构是计算机存储、组织数据的方式,每种数据结构都有各自的特点,而Node<K,V>
和TreeNode<K,V>
是对数据结构的具体实现。
HashMap如果是数组+单向链表则可以理解为Node<K,V>[]
,数组+红黑树的话则可以理解为TreeNode<K,V>[]
二、HashMap源码分析(jdk1.8)
HashMap继承关系图
1. 类中属性
我们先看下HashMap中有哪些重要的属性
transient Node<K,V>[] table;
//一个set集合,集合中的元素类型是Map.Entry<K,V>,可以自行看下这个类的源码
transient Set<Map.Entry<K,V>> entrySet;
//当前HashMap中元素的个数
transient int size;
//修改版本号
transient int modCount;
//当HashMap的size大于threshold时会执行resize(扩容)操作。
//threshold=capacity*loadFactor 即 数组容量*负载因子
int threshold;
//默认的数组长度大小,即 transient Node<K,V>[] table 的默认大小,并且HashMap规定这个值必须是2的n次幂
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
//负载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//链表转换红黑树的临界值 8
static final int TREEIFY_THRESHOLD = 8;
//红黑树转换链表的临界值 6
static final int UNTREEIFY_THRESHOLD = 6;
Node<K,V>[] table:
这个就是我们HashMap中储存数据的数组,在这个数组中可以保存Node<K,V> 和 TreeNode<K,V>,因为在源码中可以看到TreeNode<K,V>最终继承了Node<K,V>。
DEFAULT_LOAD_FACTOR:
负载因子的大小对HashMap的影响以及作用:
- 负载因子的大小决定了HashMap的数据密度
- 负载因子越大密度越大,发生碰撞的几率越高,数组中的链表越容易长,造成查询或插入时比较次数增多,性能会下降
- 负载因子越小,就越容易触发扩容,数据密度也越小,意味着发生碰撞的几率越小,数组中链表也就越短,查询和插入时比较的次数也越小,性能会更高。但是会浪费一定的内存空间。而且经常扩容也会影响性能,建议初始化预设大一点的空间
- 按照其他语言的参考及研究经验,会考虑将负载因子设置为0.7~0.75,此时平均检索长度接近于常数
TREEIFY_THRESHOLD、UNTREEIFY_THRESHOLD :
当我们数组中某个元素挂载的链表的个数超过了TREEIFY_THRESHOLD(8),就会把链表转化成红黑树;当我们红黑树中的元素进行删减时,如果删减后的元素个数低于了UNTREEIFY_THRESHOLD(6),就会再次把我们的红黑树转化为单向链表。(TREEIFY_THRESHOLD、UNTREEIFY_THRESHOLD其实是不准确的,在真正转换的时候也不是只看这两个参数的,在下面的总结中会说到。)
设置成8和6的原因
2. HashMap所用算法、构造函数
https://blog.csdn.net/weixin_41565013/article/details/93070794
https://www.cnblogs.com/loading4/p/6239441.html
3. 核心方法
3.1 public V put(K key, V value)
向HashMap中插入数据
根据上面的内容,HashMap的成员属性和底层的数据结构我们已经有了一定的了解,那HashMap是怎么通过代码组织数据的存储的?
再看下底层分析图
HashMap整个插入数据的过程,我们可以分为两个重要的步骤
1.寻找插入的下标位置
2.动态扩容
我们根据下面的例子追踪下源码分析整个过程:
public static void main(String[] args) {
Map<String, String> a = new HashMap();
a.put("上海市", "青浦区");
a.put("山东省", "菏泽市");
System.out.println(a.get("上海市"));
}
调用a.put("上海市", "青浦区");
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
hash(Object key)
这个方法我们可以先理解成,通过key(也就是上海市
)获得一个int类型的值,然后我们进入putVal()
方法
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方法,所以table符合该条件,则就会走这个if条件的逻辑。
*/
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
进入resize()
,该方法起了一个扩容或者是初始化的作用
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
//因为是第一次进入,所以oldCap算出来是0
int oldCap = (oldTab == null) ? 0 : oldTab.length;
//threshold也是0
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
//把16赋给了newCap
newCap = DEFAULT_INITIAL_CAPACITY;
//0.75*16=12
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等于了12
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
//实例化一个Node<K,V>[]的数组对象,数组长度为DEFAULT_INITIAL_CAPACITY,即16
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
//把newTab赋值给了HashMap储存数据的table数组对象
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;
}
resize()
走完后我们接着回到putVal()
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方法,所以table符合该条件,则就会走这个if条件的逻辑。
*/
if ((tab = table) == null || (n = tab.length) == 0)
//完成了数组的初始化完后,n的值为16
n = (tab = resize()).length;
//这一步是为了算出这个元素对应的数组的下标位,然后把元素放进数组里
if ((p = tab[i = (n - 1) & hash]) == null)
//该下标位还没有数据插入,则根据要储存的元素直接生成一个链表放入数组中
tab[i] = newNode(hash, key, value, null);
else {
***
***
***
}
//增加修改记录
++modCount;
//判断数组是否需要扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
tab[i = (n - 1) & hash]
这块代码就是为了获取元素对应的数组下标,即元素应该放在数组的哪个位置。用这种计算逻辑计算出的下标一定会在数组容量范围内。
这种计算方式并非是我们能理解的,或者说是我们不容易理解的,我们前面说的数组的大小必须是2的n次幂也是为了保证用这种计算方式算出的下标不会越界(知道大致原理以及作用就行,不需要太过于纠结。)
参考:https://blog.csdn.net/q2365921/article/details/96031412
至此 a.put("上海市", "青浦区");已经执行完毕存入HashMap中,接下来看看a.put("山东省", "菏泽市");
由上图中的源码我们可以分析:
- 如果由
山东省
这key确定的下标中没有元素的话,就直接生成一个链表对象放入数组中。 - 如果该下标下已经有元素:
2.1 如果已经存在元素的key和我们这次要插入的key(山东省)相等的话则直接用这次要插入的元素替换已经存在的元素
2.2 上面我们说过,如果链表的长度大于了8则自动转化为红黑树结构,如果已经存在的元素是红黑树结构,则我们把这次要插入的数据按照红黑树的特性插入。(本人对红黑树不太了解所以没有再进行更深的剖析)
2.3 遍历该下标的链表,如果key与本次的相等则替换,不等则插入尾部,然后再判断需不需要转化为红黑树结构。
3.2 public V get(Object key)
根据key获取相应元素
- 对输入的key的值计算hash值,
- 首先判断hashmap中的数组是否为空和数组的长度是否为0,如果为空和为0,则直接放回null
- 如果不为空和0,计算key对应的数组下标,判断对应位置上的第一个node是否满足条件,如果满足条件,直接返回
- 如果不满足条件,判断当前node是否是最后一个,如果是,说明不存在key,则返回null
- 如果不是最后一个,判断是否是红黑树,如果是红黑树,则使用红黑树的方式获取对应的key,
- 如果不是红黑树,遍历链表是否有满足条件的,如果有,直接放回,否则返回null
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
final Node<K,V> getNode(int hash, Object key) {
// 设置一些局部变量
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
// 首先获取hashmap中的数组和长度,并判断是否为空,如果为空,返回null
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
// 获取key对应的下标对应的链表对象, 并比较第一个是否满足条件
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
// 第一个如果满足条件,则直接返回
return first;
// 判断当前对象是否是最后一个,如果是,说明没有找到对应的key的值
if ((e = first.next) != null) {
// 如果不为空,判断是否是红黑树,如果是红黑树,使用红黑树获取对应key的值
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
// 如果不是红黑树, 遍历链表,找到对应hash和key的node对象
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
三、HashMap总结
-
HashMap的扩容 Resize
扩容的话,这里有一个值叫做loadFactor(阈值),默认值为0.75;
当数组的 元素数量>数组大小(默认16)* loadFactor(默认0.75)
就会触发扩容,扩容是二倍扩容的 (默认是16扩容后就是32)
这时原来每个元素的下标也会改变的(因为数组的长度变了)
然后就要把每个元素重新分配下标,重新加入链表或者红黑树 -
为什么HashMap是线程不安全的,实际会如何体现?
第一,如果多个线程同时使用put方法添加元素
假设正好存在两个put的key发生了碰撞(hash值一样),那么根据HashMap的实现,这两个key会添加到数组的同一个位置,这样最终就会发生其中一个线程的put的数据被覆盖。
第二,如果多个线程同时检测到元素个数超过数组大小*loadFactor,这样会发生多个线程同时对hash数组进行扩容,都在重新计算元素位置以及复制数据,但是最终只有一个线程扩容后的数组会赋给table,也就是说其他线程的都会丢失,并且各自线程put的数据也可能丢失。 -
为什么需要链表?
为了解决hash冲突,两个不同的元素通过哈希函数计算而得到的地址可能相同。
解决哈希冲突的方式有许多,有:开放定址法(发生冲突,继续寻找下一块未被占用的存储地址),再散列函数法,链地址法,而HashMap即是采用了链地址法(拉链法),也就是数组+链表的方式。 -
链表红黑树相互转换规则
首先是一个数组,然后数组的类型是链表,
当链表长度大于8并且数组长度大于64时,才会转换为红黑树。如果链表长度大于8,但是数组长度小于64时,还是会进行扩容操作,不会转换为红黑树。因为数组的长度较小,应该尽量避开红黑树。因为红黑树需要进行左旋,右旋,变色操作来保持平衡,所以当数组长度小于64,使用数组加链表比使用红黑树查询速度要更快、效率要更高。
在红黑树的元素小于6的时候不一定会变成链表。
只有resize(扩容)的时候才会根据UNTREEIFY_THRESHOLD 进行转换。
-
默认负载因子为0.75的原因
负载因子越大,填满的元素越多,空间利用率越高,但发生冲突的机会变大了。
负载因子越小,填满的元素越少,冲突发生的机会减小,但空间浪费了更多了,而且还会提高扩容rehash操作的次数。
所以,选择0.75作为默认的负载因子,完全是时间和空间成本上寻求的一种折中选择
四、HashMap和Hashtable区别
Hashtable继承关系图
-
数据结构
HashMap底层是数组+链表或数组+红黑树,Hashtable底层是数组+链表 -
KEY 和 Value 限制
HashMap的key和value都可以为null但是Hashtable不可以。
-
线程安全和性能
Hashtable所有的元素操作都是synchronized修饰的,而HashMap并没有,即Hashtable是线程安全的,HashMap不是线程安全的。因为Hashtable每个方法都要阻塞其他线程,所以Hashtable性能较差,HashMap性能较好。 -
扩容
HashMap在put时才进行初始化,初始化大小为16,元素数量>数组容量(默认16)* loadFactor(默认0.75)时才进行扩容,每次扩容两倍。Hashtable在实例化时就进行了初始化,初始化大小为11,元素数量>=数组容量时进行扩容,每次扩容两倍+1。
-
下标计算方式
HashMap计算下标方式:(n - 1) & hash
Hashtable计算下标方式:(hash & 0x7FFFFFFF) % tab.length
五、HashMap和TreeMap区别
TreeMap最主要的功能就是可以根据key进行排序,有默认比较器和自定义比较器概念。主要方法的代码可以自己看下源码,这里就不细说了。
TreeMap继承关系图
- 数据结构
HashMap底层是数组+链表或数组+红黑树,TreeMap底层是红黑树。 - 线程安全
都不是线程安全的。 - KEY 和 Value 限制
HashMap: Key和 Value 都可以为 null ( 如果key 为 null 的话,hashCode = 0 )
TreeMap: Key 不能为 null(因为要根据key进行排序), Value 可以为 null - 使用
HashMap:适用于在Map中插入、删除和定位元素。
TreeMap:适用于按自然顺序或自定义顺序遍历键(key)。
HashMap通常比TreeMap快一点(树和哈希表的数据结构使然),建议多使用HashMap,在需要排序的Map时候才用TreeMap。
六、ConcurrentHashMap源码分析
1. 实现方式
在jdk8中采用了volatile+CAS+synchronized的方式来实现ConcurrentHashMap保证了并发安全性,底层数据结构和HashMap一样也是数组+链表或红黑树。在jdk7中采用的是分段锁技术(Segment),这块我没有了解过,感兴趣的人可自行去了解。
了解了volatile、CAS、synchronized(synchronized)就能更好的去理解ConcurrentHashMap是怎么实现的。
2. synchronized
同步块大家应该都比较熟悉,这里就不过多解释了,就是通过 synchronized 关键字来实现,所有加上synchronized的块语句,在多线程访问的时候,同一时刻只能有一个线程能够用。
3. volatile
4. CAS
CAS是英文单词Compare And Swap的缩写,翻译过来就是比较并替换。
CAS机制当中使用了3个基本操作数:内存地址V,旧的预期值A,要修改的新值B。
更新一个变量的时候,只有当变量的预期值A和内存地址V当中的实际值相同时,才会将内存地址V对应的值修改为B。
这样说或许有些抽象,我们来看一个例子:
(1)在内存地址V当中,存储着值为10的变量。
(2)此时线程1想要把变量的值增加1。对线程1来说,旧的预期值A=10,要修改的新值B=11。
(3)在线程1要提交更新之前,另一个线程2抢先一步,把内存地址V中的变量值率先更新成了11。
(4)线程1开始提交更新,首先进行A和地址V的实际值比较(Compare),发现A不等于V的实际值,提交失败。
(5)线程1重新获取内存地址V的当前值,并重新计算想要修改的新值。此时对线程1来说,A=11,B=12。这个重新尝试的过程被称为自旋。
(6)这一次比较幸运,没有其他线程改变地址V的值。线程1进行Compare,发现A和地址V的实际值是相等的。
(7)线程1进行SWAP,把地址V的值替换为B,也就是12。
从思想上来说,Synchronized属于悲观锁,悲观地认为程序中的并发情况严重,所以严防死守。CAS属于乐观锁,乐观地认为程序中的并发情况不那么严重,所以让线程不断去尝试更新,可以理解成一个无阻塞多线程争抢资源的模型。
volatile保证一个线程更新,另一个线程可以立马看到(可见性)。
cas保证只能一个线程去更新(不能保证可见性)。
Synchronized保证一个线程更新和可见性。
ConcurrentHashMap配合使用了他们,所以高效率的保证了线程安全
5. 源码分析
ConcurrentHashMap和HashMap最大的区别就是线程安全问题(即对volatile、CAS、synchronized)的使用,很多细节的地方我没有看,在网上找了篇文章可参考下。
https://www.codercto.com/a/57430.html