这里写目录标题
二.HashMap相关
1、HashMap的实现原理,HashMap数据结构?HashMap源码理解,HashMap如何put数据(从HashMap源码角度讲解)?HashMap怎么手写实现?
6、ConcurrentHashMap的实现原理
7、ArrayMap和HashMap的对比
8、HashTable实现原理
9、TreeMap具体实现
10、HashMap和HashTable的区别
11、HashMap与HashSet的区别
12、HashSet与HashMap怎么判断集合元素重复?
13、二叉树的深度优先遍历和广度优先遍历的具体实现
14、堆和栈在内存中的区别是什么(解答提示:可以从数据结构方面以及实际实现方面两个方面去回答)?
15、讲一下对树,B+树的理解
16、讲一下对图的理解
17、判断单链表成环与否?
18、链表翻转(即:翻转一个单项链表)
19、合并多个单有序链表(假设都是递增的)
https://blog.csdn.net/liushengxi_root/article/details/86438816
HashMap的实现原理,HashMap数据结构?HashMap源码理解,HashMap如何put数据(从HashMap源码角度讲解)?HashMap怎么手写实现?
HashMap的重要属性
从HashMap的源码中,我们可以发现,HashMap是由一个Node数组构成,每个Node包含了一个key-value键值对。
(** TODO:transient 关键字的理解**)
/**
* The table, initialized on first use, and resized as
* necessary. When allocated, length is always a power of two.
* (We also tolerate length zero in some operations to allow
* bootstrapping mechanics that are currently not needed.)
*/
transient Node<K,V>[] table;
Node类作为HashMap中的一个内部类,除了key、value两个属性外,还定义了一个next指针。当有哈希冲突时,HashMap会用之前数组当中相同哈希值对应存储的Node对象,通过指针指向新增的相同哈希值的Node对象的引用。
static class Node implements Map.Entry {
final int hash;
final K key;
V value;
Node next;
Node(int hash, K key, V value, Node next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
}
HashMap还有两个重要的属性:加载因子(loadFactor)和边界值(threshold)。在初始化HashMap时,就会涉及到这两个关键初始化参数。
/**
* The next size value at which to resize (capacity * load factor).
*
* @serial
*/
// (The javadoc description is true upon serialization.
// Additionally, if the table array has not been allocated, this
// field holds the initial array capacity, or zero signifying
// DEFAULT_INITIAL_CAPACITY.)
int threshold; //就是哈希表的容量,默认是:1 << 4(为啥源码不直接写16呐?奇怪)
/**
* The load factor for the hash table.
*
* @serial
*/
final float loadFactor; //装载因子 = 填入表中的元素个数 / 散列表的长度,默认是:0.75
HashMap添加元素优化
初始化完成后,HashMap就可以使用put()方法添加键值对了。从下面源码可以看出,当程序将一个key-value对添加到HashMap中,程序首先会根据该key的hashCode()返回值,再通过hash()方法计算出hash值,再通过putVal方法中的(n - 1) & hash决定该Node的存储位置。
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);
}
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//通过putVal方法中的(n - 1) & hash决定该Node的存储位置
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
我们先来了解下hash()方法中的算法。如果我们没有使用hash()方法计算hashCode,而是直接使用对象的hashCode值,会出现什么问题呢?
假设要添加两个对象a和b,如果数组长度是16,这时对象a和b通过公式(n - 1) & hash运算,也就是(16-1)&a.hashCode和(16-1)&b.hashCode,15的二进制为0000000000000000000000000001111,假设对象 A 的 hashCode 为 1000010001110001000001111000000,对象 B 的 hashCode 为 0111011100111000101000010100000,你会发现上述与运算结果都是0。这样的哈希结果就太让人失望了,很明显不是一个好的哈希算法。但如果我们将 hashCode 值右移 16 位(h >>> 16代表无符号右移16位),也就是取 int 类型的一半,刚好可以将该二进制数对半切开,即将 key 的 hashCode 与 所得 hashCode 的高十六位进行 ^ 运算,让 hashCode 高十六位也加入到了运算中,把高位的特征和低位的特征组合起来,降低哈希冲突的概率,这样的话,就能避免上面的情况发生。这就是 hash() 方法的具体实现方式。简而言之,就是尽量打乱hashCode真正参与运算的低16位。
(n - 1) & hash
是怎么设计的,这里的n代表哈希表的长度,哈希表习惯将长度设置为2的n次方,这样恰好可以保证(n - 1) & hash的计算得到的索引值总是位于table数组的索引之内。例如:hash=15,n=16时,结果为15;hash=17,n=16时,结果为15。举例就是:恰好可以保证在数组范围内的原因。 举例n=16,i=n-1,i=15,那么二进制为 0000 0000 0000 0000 0000 0000 0000 1111 因为是按位与&运算(&,同为1则1,否则为0),无论i = (n - 1) & hash中hash值是多少,与i的前28运算结果都为0。其变化区间被限制在后4位,即15以内。故可以有效保证在数组范围内。
在获得Node的存储位置后,如果判断Node不在哈希表中,就新增一个Node,并添加到哈希表中,整个流程我将用一张图来说明:
put 操作的实现源码:
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node[] tab; Node p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
//1、判断当table为null或者tab的长度为0时,即table尚未初始化,此时通过resize()方法得到初始化的table
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)
//1.1、此处通过(n - 1) & hash 计算出的值作为tab的下标i,并另p表示tab[i],也就是该链表第一个节点的位置。并判断p是否为null
tab[i] = newNode(hash, key, value, null);
//1.1.1、当p为null时,表明tab[i]上没有任何元素,那么接下来就new第一个Node节点,调用newNode方法返回新节点赋值给tab[i]
else {
//2.1下面进入p不为null的情况,有三种情况:p为链表节点;p为红黑树节点;p是链表节点但长度为临界长度TREEIFY_THRESHOLD,再插入任何元素就要变成红黑树了。
Node e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
//2.1.1HashMap中判断key相同的条件是key的hash相同,并且符合equals方法。这里判断了p.key是否和插入的key相等,如果相等,则将p的引用赋给e
e = p;
else if (p instanceof TreeNode)
//2.1.2现在开始了第一种情况,p是红黑树节点,那么肯定插入后仍然是红黑树节点,所以我们直接强制转型p后调用TreeNode.putTreeVal方法,返回的引用赋给e
e = ((TreeNode)p).putTreeVal(this, tab, hash, key, value);
else {
//2.1.3接下里就是p为链表节点的情形,也就是上述说的另外两类情况:插入后还是链表/插入后转红黑树。另外,上行转型代码也说明了TreeNode是Node的一个子类
for (int binCount = 0; ; ++binCount) {
//我们需要一个计数器来计算当前链表的元素个数,并遍历链表,binCount就是这个计数器
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1)
// 插入成功后,要判断是否需要转换为红黑树,因为插入后链表长度加1,而binCount并不包含新节点,所以判断时要将临界阈值减1
treeifyBin(tab, hash);
//当新长度满足转换条件时,调用treeifyBin方法,将该链表转换为红黑树
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;
}
所以真正的下标是:i = (n-1)&hash
,n是哈希表容量,hash是 通过hash()方法计算出hash值,hash()方法一般是:key.hashCode()) ^ (h >>> 16
与运算。
QA
实际应用中,我们设置初始容量,一般得是 2 的整数次幂。你知道原因吗?
A:2的幂次方减1后每一位都是1,让数组每一个位置都能添加到元素。 例如十进制8,对应二进制1000,减1是0111,这样在&hash值使数组每个位置都是可以添加到元素的,如果有一个位置为0,那么无论hash值是多少那一位总是0,例如0101,&hash后第二位总是0,也就是说数组中下标为2的位置总是空的。 如果初始化大小设置的不是2的幂次方,hashmap也会调整到比初始化值大且最近的一个2的幂作为capacity。 作者回复: 回答正确,就是减少哈希冲突,均匀分布元素。
并发下还有什么问题?为什么?需要搞定它
5.HashMap是非线程安全的,并发情况下会形成环形链表与覆盖
ConcurrentHashMap 的实现原理
ConcurrentHashMap JDK1.7的实现
Segment数组的意义就是将一个大的table分割成多个小的table来进行加锁,也就是锁分离技术(分段锁技术),而每一个Segment元素存储的是 HashEntry数组+链表,这个和 JDK1.7 HashMap的数据存储结构一样
Segment继承了ReentrantLock,所以它就是一种可重入锁(ReentrantLock)。在ConcurrentHashMap,一个Segment就是一个子哈希表,Segment里维护了一个HashEntry数组,所以,并发环境下,对于同一个Segment的操作才需考虑线程同步,不同的Segment则无需考虑。
在 put 同一位置的值的时候,会对整个 Segment 加锁,保证线程安全。(但是这里其实并不用这么锁的范围这么大,因为在同一个Segment 中插入的位置也可能不同,那么为什么不一个Entry一个锁呐?有点奇怪哦)
- 是如何获取 ConcurrentHashMap 的size() 的?
计算ConcurrentHashMap的元素大小是一个有趣的问题,因为他是并发操作的,就是在你计算size的时候,他还在并发的插入数据,可能会导致你计算出来的size和你实际的size有相差(在你return size的时候,插入了多个数据),要解决这个问题,JDK1.7版本用两种方案。
第一种方案他会使用不加锁的模式去尝试多次计算ConcurrentHashMap的size,最多三次,比较前后两次计算的结果,结果一致就认为当前没有元素加入,计算的结果是准确的;
第二种方案是如果第一种方案不符合,他就会给每个Segment加上锁,然后计算ConcurrentHashMap的size返回。
ConcurrentHashMap JDK1.8的实现
JDK1.8的实现已经摒弃了Segment的概念,而是直接用 Node数组+链表+红黑树
的数据结构来实现,并发控制使用Synchronized和CAS
来操作,整个看起来就像是优化过且线程安全的HashMap,虽然在JDK1.8中还能看到Segment的数据结构,但是已经简化了属性,只是为了兼容旧版本。
TreeBin:管理红黑树的 manager
TreeNode:红黑树节点
put 操作
public V put(K key, V value) {
return putVal(key, value, false);
}
/** Implementation for put and putIfAbsent */
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; K fk; V fv;
if (tab == null || (n = tab.length) == 0)
tab = initTable();
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
// CSA 插入
if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value)))
break; // no lock when adding to empty bin
}
//如果在进行扩容,则先进行扩容操作
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
else if (onlyIfAbsent // check first node without acquiring lock
&& fh == hash
&& ((fk = f.key) == key || (fk != null && key.equals(fk)))
&& (fv = f.val) != null)
return fv;
else {
V oldVal = null;
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); //尾插法
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;
}
}
else if (f instanceof ReservationNode)
throw new IllegalStateException("Recursive update");
}
}
if (binCount != 0) {
//链表转红黑树
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
addCount(1L, binCount);
return null;
}
三、总结与思考
其实可以看出JDK1.8版本的ConcurrentHashMap的数据结构已经接近HashMap,相对而言,ConcurrentHashMap只是增加了同步的操作来控制并发,从JDK1.7版本的 ReentrantLock+Segment+HashEntry
,到JDK1.8版本中synchronized+CAS+HashEntry+红黑树,
相对而言,总结如下思考:
1、JDK1.8的实现降低锁的粒度,JDK1.7版本锁的粒度是基于Segment的,包含多个HashEntry,而JDK1.8锁的粒度就是 HashEntry(首节点)
2、JDK1.8版本的数据结构变得更加简单,使得操作也更加清晰流畅,因为已经使用synchronized来进行同步,所以不需要分段锁的概念,也就不需要Segment这种数据结构了,由于粒度的降低,实现的复杂度也增加了
3、JDK1.8使用红黑树来优化链表,基于长度很长的链表的遍历是一个很漫长的过程,而红黑树的遍历效率是很快的,代替一定阈值的链表,这样形成一个最佳拍档
4、JDK1.8为什么使用内置锁synchronized来代替重入锁ReentrantLock,我觉得有以下几点:
- 因为粒度降低了,在相对而言的低粒度加锁方式,synchronized并不比ReentrantLock差,在粗粒度加锁中ReentrantLock可能通过Condition来控制各个低粒度的边界,更加的灵活,而在低粒度中,Condition的优势就没有了
- JVM的开发团队从来都没有放弃synchronized,而且基于JVM的synchronized优化空间更大,使用内嵌的关键字比使用API更加自然
- 在大量的数据操作下,对于JVM的内存压力,基于API的ReentrantLock会开销更多的内存,虽然不是瓶颈,但是也是一个选择依据
HashMap7 和HashMap8为什么一个头插一个尾插?为什么HashMap7扩容后插入,HashMap8插入后扩容?