集合框架源码深入分析(基于jdk1.8)
java中的集合框架可以说无论在实际开发还是工作面试中都是十分重要的知识点,日常开发中使用集合框架是一件十分容易的事情,但是如果希望真正的了解集合框架,在面对业务要求严格的开发任务情形下,就可以极大的提高程序的合理性和严谨性,并且在学习原理的过程中,可以获取到更多的知识,这些是潜移默化的,只有真正花心思经历过才会感受到,下面我们就来通过一些问题认真的逐一了解一下java中的集合框架的原理
一.HashMap预习知识
想了解hashmap的原理,其实最好的帮助就是源码中的注释,虽然是英文的,借助翻译工具和我三年级英语的水平还是可以理解的,但是在这之前,我为大家画了一张图,方便后续理解
二.HashMap Jdk1.8 源码介绍
1. HashMap实现的接口
Map
Cloneable
Serializable
下面是map接口中的方法:
这里可以看出,实现了map接口后即具备了基本的数据操作和迭代方法
另外实现了Cloneable接口,这说明,hashmap是可以调用clone方法的,关于clone方法和Cloneable接口可以查看这篇文章 java中的对象拷贝
2. HashMap中的属性
默认容量 DEFAULT_INITIAL_CAPACITY
(指的是元素的个数)
/**
* The default initial capacity - MUST be a power of two.
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
hashmap默认容量是16,并且注释中指出,初始容量必须是2的n次幂(后面解释)
最大容量 MAXIMUM_CAPACITY
(指的是元素的个数)
/**
* The maximum capacity, used if a higher value is implicitly specified
* by either of the constructors with arguments.
* MUST be a power of two <= 1<<30.
*/
static final int MAXIMUM_CAPACITY = 1 << 30;
hashmap的容量是有限的,必须小于最大值
加载因子DEFAULT_LOAD_FACTOR
/**
* The load factor used when none specified in constructor.
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
加载因子默认值为3/4
关于加载因子和初始容量,hashmap源码中这段注释我们一起来看下:
* <p>An instance of <tt>HashMap</tt> has two parameters that affect its
* performance: <i>initial capacity</i> and <i>load factor</i>. The
* <i>capacity</i> is the number of buckets in the hash table, and the initial
* capacity is simply the capacity at the time the hash table is created. The
* <i>load factor</i> is a measure of how full the hash table is allowed to
* get before its capacity is automatically increased. When the number of
* entries in the hash table exceeds the product of the load factor and the
* current capacity, the hash table is <i>rehashed</i> (that is, internal data
* structures are rebuilt) so that the hash table has approximately twice the
* number of buckets.
hashmap的性能因素有两个参数决定,一个是初始容量一个是加载因子,加载因子在这里做出了解释: 加载因子和当前容量的乘积即为当前hashmap的阈值,假设这个乘积是A,那么当hashmap中的元素超过A,则开始扩容,具体的扩容机制在下面介绍
转树的阈值 TREEIFY_THRESHOLD
static final int TREEIFY_THRESHOLD = 8;
转链表的阈值
static final int UNTREEIFY_THRESHOLD = 6;
转树的hash表容量阈值
/**
* The smallest table capacity for which bins may be treeified.
* (Otherwise the table is resized if too many nodes in a bin.)
* Should be at least 4 * TREEIFY_THRESHOLD to avoid conflicts
* between resizing and treeification thresholds.
*/
static final int MIN_TREEIFY_CAPACITY = 64;
hashmap中存储数据的元素Node
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
可以看出这是一个单向链表的结点,其中的属性包括:
该元素的hash值,key值,value值,和下一个节点
元素数组的长度
/**
* 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;
这个属性十分重要,通过上面的注释我们可以明确下面几个重要的点
- 前面的默认初始化容量指的是hash表初始化的数组的长度
hash表
元素数组
某位置上冲突的元素个数
hashmap中元素个数
hashmap当前的容量
hashmap阈值
这几个名词需要有一个认识:
1.hash表: 指的是hashmap中的那个数组
2.元素数组: 也就是上面说的数组
其实也就是这里的数组,如果hash函数足够合理,没有出现hash冲突,那么也就不会有右边那些链表元素,就是一张完美的hash表
3.hashmap中元素个数,这其实指的是所有桶的数量,也就是上图中蓝色加上橙色的方块个数,需要区别的是hashmap当前的容量
4.hashmap当前容量可能是扩容后的容量大小也可能是默认的初始化大小(2的n次幂) 5.hashmap阈值指的上个值也就是hashmap的容量乘以加载因子得出的,这个值和元素个数比较,也就是和后面的size值比较,这个比较会用来作为扩容的判断依据
总的来说,最终要的是要知道,最开始介绍的初始默认容量 DEFAULT_INITIAL_CAPACITY
这指的是hashmap中hash表(数组)的长度
hashmap中包含的元素个数
/**
* The number of key-value mappings contained in this map.
*/
transient int size;
3.源码分析
(1).构造方法
hashmap的构造方法有好几个,这里以一个典型的来介绍:
从下面的构造方法可以看出来,使用构造方法就算指定初始化容量(没有指定就是默认初始化容量,然后调用的还是下面的方法),可以看到 在构造方法的逻辑里面只是初始化了阈值的大小,并且这次初始化的阈值没有乘加载因子,所以后面还是会修改的,这里的阈值其实 赋值的是hash数组容量大小;
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;
this.threshold = tableSizeFor(initialCapacity);
}
如果构造方法使用的没有指定加载因子和初始容量的,那么这里加载因子使用默认的,并且没有对初始的阈值设置,所以这种情况初始的阈值为int的默认值0
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
(2).put添加操作 源码分析
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
/**
* Implements Map.put and related methods
*
* @param hash hash for key
* @param key the key
* @param value the value to put
* @param onlyIfAbsent if true, don't change existing value
* @param evict if false, the table is in creation mode.
* @return previous value, or null if none
*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
//如果hashmap中数组没有初始化,数组为空或者数组长度为0
if ((tab = table) == null || (n = tab.length) == 0)
//扩容(详细逻辑后面有分析)
n = (tab = resize()).length;
//没有出现hash冲突,即当前插入元素hash计算后得到的槽位的数据是空的,当前需要插入的槽位是空的
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
//出现了hash冲突,p是当前冲突的位置上已有的元素
else {
//e:存放当前定位到的节点的元素 上面的if将当前的位置的元素赋值给了p
Node<K,V> e; K k;
// 当前槽位上现有的数据和待插入的数据键是一样的,这里为e赋值后,e!=null成立了,那么直接看后面,会进行value值的替换
//ps:这里也就是常说的为啥对象作为键值需要重写hashCode和equals方法了,详细见我的[另一篇博客](http://54zh.cn/article/151)
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//如果冲突的元素对象和现有元素对象不同,如果是树节点 e不为null
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
//如果冲突的元素对象和现有元素对象不同,如果是链表节点
else {
for (int binCount = 0; ; ++binCount) {
//如果已经是最后一个链表节点 e为null,这里也很巧妙,如果已经遍历到最后一个节点了,那么肯定前面都没出现一样的,
//那么这里就最后刚好尾节点指向null,刚好后面是统一判断是否e为null,来看是否需要替换覆盖
if ((e = p.next) == null) {
//节点尾插法
p.next = newNode(hash, key, value, null);
//判断插入后的数量是否需要转树
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
//直接退出循环
break;
}
//链表还有节点,并且当前节点和待插入元素一样,则直接替换 这里没有执行e = p 是因为上面的一个if已经做了
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
//如果元素不一样,则继续遍历链表
p = e;
}
}
//只有出现元素重复才会导致e != null ,才会进入这里进行元素的替换
if (e != null) { // existing mapping for key
V oldValue = e.value;
//这里的onlyIfAbsent如果为true则不会修改覆盖已经存在的键值对(也就是是否),而put给出的值是false,也就是会修改已经存在的键值对,所以这里!onlyIfAbsent为true,所以会进入修改
if (!onlyIfAbsent || oldValue == null)
e.value = value;
//覆盖插入之后的操作,hashmap中没有实现,在hashmap的子类LinkedHashMap中实现了
afterNodeAccess(e);
return oldValue;
}
}
//这个参数见下面的解释
++modCount;
//插入后当前当前元素个数+1,并且判断当前元素个数是否达到阈值,如果达到阈值就扩容
if (++size > threshold)
resize();
//正常插入之后的操作,hashmap中没有实现,在hashmap的子类LinkedHashMap中实现了
afterNodeInsertion(evict);
return null;
}
ps:关于modCount这个参数我已经在之前的博客中详细说明了,参考这篇文章
(2).扩容操作 源码分析
/**
* 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() {
//原hash数组
Node<K,V>[] oldTab = table;
//如果原hash数组为空(为初始化),旧容量就是0,否则旧容量就是旧数组的长度
int oldCap = (oldTab == null) ? 0 : oldTab.length;
//旧阈值
int oldThr = threshold;
//定义新数组容量和新阈值
int newCap, newThr = 0;
//1.如果数组已经初始化了,即旧容量不是0
if (oldCap > 0) {
//如果当前的hash数组容量已经达到最大值了,那么就不扩容了
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
//已经扩容到最大容量限制了,这里直接返回现有的hash表数组
return oldTab;
}
//正常扩容,新数组容量是旧数组容量的2倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
//2.如果数组没有初始化(oldCap <= 0),但是旧阈值是有的,这就是前面我们说的构造方法中阈值设置为容量的原因
else if (oldThr > 0) // initial capacity was placed in threshold
//那么就让新的hash数组(其实这是第一次初始化)的容量为阈值大小
newCap = oldThr;
//3.如果使用的是默认的构造方法,或者是不手动指定初始容量和加载因子的构造方法,那么新hash数组(其实这是第一次初始化)的阈值和容量就使用默认的
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
//这里就是帮助上面2的情况,计算好真正的新数组的阈值,可以看到这里是容量乘以了加载因子,哈哈哈,写的真够深的
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"})
//创建一个新容量大小的新hash数组
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
//为hash表赋值为新的hash表数组
table = newTab;
//如果不是第一次初始化,那么就需要进行重新hash,将旧的hash表中的数据转移到新的hash表中
if (oldTab != null) {
//遍历旧hash数组
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
//如果当前槽位有数据,数据赋值给e,即e就是当前遍历的非空槽位的数据
//ps:这里不用担心都用链表节点Node来接收当前节点元素,那么如果当前槽位如果已经转为树了呢?没事,树节点被封装为是Node的子类了
if ((e = oldTab[j]) != null) {
//使旧数组的当前槽位为空
oldTab[j] = null;
//a1.如果当前槽位只有一个节点数据
if (e.next == null)
//直接重新hash这一个节点数据到新的hash数组中,这样就完成了,因为当前槽位只有这一个节点元素
newTab[e.hash & (newCap - 1)] = e;
//a2.如果当前槽位的数据是树节点
else if (e instanceof TreeNode)
//树节点转移到新数组中
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
//a3.如果当前槽位的数据是多个节点的链表,这里很关键,并且也非常巧妙
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;
//如果当前节点元素hash值与上旧数组容量为0
/**
* 这里解释下,我之前博客已经介绍过很多次,为什么hashmap中需要保证数组的容量长度大小是2的n次幂(数组容量减1再和当前hash值与可以保证结果在容量长度内)
* 我假设你已经知道2的n次幂的原因了,注意下面的&操作不是和数组容量-1进行,而是直接和数组容量进行&,所以类似下面的效果:
* oldCap: 000100 (假设当前hash数组的容量是4,这里只是假设演示)
* hash: 101010
* &: 000000
* 所以上面这样的hash值得到的结果就是0(oldCap是2的n次幂,所以只有一个位上为1,而&的hash值对应的这个1的位上的刚好为0)
*
* oldCap: 000100 (假设当前hash数组的容量是4,这里只是假设演示)
* hash: 100110
* &: 000100
* 所以上面这样的hash值得到的结果就是4
*
* 上面的第一种节点元素的hash值的情况,这个节点元素重新hash(在新hash数组中)后,其实位置还是一样的,因为新数组的长度就是旧数组的2倍,也就是这样:
* oldCap: 000100
* newCap: 001000 (扩容了,变为原来的2倍,很明显左移1位(oldCap<<1)即可)
* newCap: 000111 (实际的HashMap中的hash函数计算是和当前hash数组的长度减1后的结果进行&操作的)
* hash: 101010 (可以看到虽然新的数组中第三位上的数据变为1了,但是因为当前元素的hash值第三位上的数据是0,所以就算重新hash了结果还是和扩容之前&得到的结果是一样的)
* &: 000000
*
* 总结: 其实这里的(e.hash & oldCap) == 0就是在判断,当前元素节点的hash值对应的新数组的新加的那个位上的数据是0还是1,如果是0,那么就算扩容后&操作了也没有变化(参考后下面源码的b1部分逻辑)(因为&需要两边
* 都是0).但如果是1,那么&之后就发生变化了,也就是当前库容后的容量的最高位上的数据起作用了(还是减1后),也就是加上当前重新hash后的位置需要在原来的位置上加上原来的旧的数组的长度(参考后下面源码的b2部分逻辑)
*/
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);
//b1.低位链表,这里的链表数据不需要在新数组中重新计算hash槽位置
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
//b2.高位链表,这里的链表数据需要重新计算hash槽的位置,并且新的位置就是原来位置加上旧数组的长度
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
//最后扩容后返回新的hash表数组
return newTab;
}
- size()方法
public int size() {
return size;
}
这个方法是获取当前hashmap中键值对的数量,这个方法的效率非常高,因为这个值是在每次增加删除操作后直接维护的,可以看到调用时不需要执行任何遍历统计操作,直接 返回size参数即可
Q1: 为什么加载因子是0.75,加载因子的值会影响什么?
threshold是hashmap发生扩容的重要参考数值,这个值是由loadFactor * capacity 容量,但是从 上面的构造方法可以看出来,阈值threshold是由tableSizeFor(生成大于给定初始容量的最小的2的n次幂)生成的,但是不要受这 个影响后面我们再介绍,加载因子如果过小,那么hashmap的空间将很大,基本不会发生hash冲突,这样可以保证查询效率,但是造成空间的浪费; 如果加载因子设置过大,那么会增加出现hash冲突的概率,空间会占用较小,这样会造成查询效率变低(冲突后会接链表); 所以为了在空间和时间直接平衡,在大量的测试后这里使用0.75
Q2: 为什么转树的阈值设置为8?
Ideally, under random hashCodes, the frequency of nodes in bins follows a Poisson distribution with a parameter of about 0.5 on average for the default resizing threshold of 0.75, although with a large variance because of resizing granularity. Ignoring variance, the expected occurrences of list size k are (exp(-0.5) * pow(0.5, k) / factorial(k)).
0: 0.60653066
1: 0.30326533
2: 0.07581633
3: 0.01263606
4: 0.00157952
5: 0.0001579
6: 0.00001316
7: 0.00000094
8: 0.00000006
more: less than 1 in ten million
在理想情况下,使用随机哈希码,节点出现的频率在 hash 桶中遵循泊松分布。 对照桶中元素个数和概率的表,可以看到当用 0.75 作为加载因子时,桶中元素到达 8 个的时候,概率已经变得非常小, 因此每个碰撞位置的链表长度超过 8 个是几乎不可能的,因此在链表节点到达 8 时才开始转化为红黑树。
ps:以上只是我个人对于jdk源码的理解,因水平有限,有不对或者疏漏之处,欢迎下方留言交流哦
本站文章除注明转载/出处外,均为本站原创或翻译,转载前请务必署名,转载请标明出处
最后编辑时间为:
2021/09/17 17:04