Map接口
Map就是Java里面的另一个集合接口(另外一个是Collection)
- Map是一对对数据存储的
- Collection是一个个数据存储的
由于Map是一对对数据进行存储的,也就是键值对存储,所以需要一个对象来封装这些键值对信息
所以在Map接口里面还有另外一个接口,也就是嵌套接口,该接口名为Entry
AbstractMap
与Collection接口有AbstractCollection方法一样,Map接口也有自己一个抽象默认实现类——AbstractMap
这个抽象类也是默认实现了一些Map接口里面的方法,同时有两个静态内部类实现了Entry接口
这两个静态内部类的不同之处就在于,SimpleEntry实现了setValue方法(这个方法用来修改当前节点的value),而SimpleImmutableEntry没有实现setValue方法,单纯只是抛出了一个UnSupport异常出来
注释的意思就是,使用这个节点会不支持改变值,那么就可以保证线程安全,线程不安全可能是因为获取的那个oldValue会丢失掉上一个线程的修改,即A、B两个线程同时setValue,因为同时进入,还没修改,拿的oldValue都是一样的,但假设A先执行完setValue,那么B应该获取的oldValue是A修改的值才对。
SimpleEntry的setValue方法
SimpleImmutableEntry的setValue方法
![还有一个区别就是
对于K,V属性,两者的修饰是不一样的
SimpleEntry的key是不可变的,而value是可变的
而SimpleImmuntableEntry的key和value都是不可变的
Jdk1.7的HashMap
接下来我们就进入HashMap吧
HashMap继承了AbstractMap和实现了Map(与前面的ArrayList一样,都出现了多余的操作,即AbstractMap已经实现了Map,后面实现Map就多余了)
JDK1.7的HashMap,估计都知道底层是数组加链表
常量和变量
可以看到,HashMap拥有的变量和常量还挺多
逐个逐个来看
ALTERNATIVE_HASHING_THRESHOLD_DEFAULT
从注释上可以看出,这个静态常量是当使用字符串来代替hashcode的时候,HashMap默认的阈值,当size(HashMap里面元素个数)超过这个阈值就要进行扩容
而且可以看到,这个扩容条件是元素个数大于Integer的最大值
这样设置可能是因为hashcode是一个整形,所以当超出整形的范围才会使用字符串来代替hashcode,所以扩容条件也是大于Integer的最大值
DEFAULT_INITIAL_CAPACITY
这个静态变量是HashMap底层主数组的容量,默认值是
2
4
2^4
24,也就是16,而且注释上还有一个很重要的信息,即默认的容量必须是2的幂次方,至于为什么,后面再进行解释
DEFAULT_LOAD_FACTOR
这个就是负载因为,当数组的使用率超过这个负载因子时就要进行扩容,默认是0.75
这里要记住的是:负载因子越小,空间利用率越高,但效率低,负载因子越大,空间利用率越低,但效率高
EMPTY_TABLE
HashMap的主数组其实就是一个Entry数组,因为存储的是键值对对象,而键值对对象是封装在Entry里面的,这个Entry数组是未进行扩容时候使用的数组
table
可以看到table是将EMPTY_TABLE进行强转得到的,也就是强转成指定泛型的Empty数组,这个其实就是HashMap的底层主数组,也可以看到,注释里面标明了数组的长度必须是2的幂次方,与前面的默认容量对应了起来
在HashMap底层API进行操控的是table这个数组,而EMPTY_TABLE其实不会被操作,单纯用来赋值给table
size、threshold与loadFactor
意义分别如下
-
size其实就是表示HashMap当前键值对的数量
-
threshold其实就是HashMap的阈值,超过这个阈值就会进行扩容,计算方式为底层主数组长度与负载因子相乘
-
loadFactor:其实也是一个负载因子,虽然前面已经有一个默认的负载因子,但实际还是按照loadFactor为主
MAXIMUM_CAPACITY
这个静态常量是代表HashMap底层主数组的最大长度为
2
30
2^{30}
230
hashSeed
这个是关联HashMap的随机值,用来减少哈希冲突的,如果是0的话就代表禁用替代哈希(替代哈希就是前面提到的用字符串来替代哈希值)
构造方法
接下来我们看构造方法
总共有三个构造方法
- 无参构造
- 有参构造,只用容量进行构造
- 有参构造,使用容量和负载因子进行构造
可以看到前面两个的构造方法都是调用最后一个构造方法,可见开发者并不太想我们直接调用第三种构造方法,也就是不想让我们指定容量和负载因子
所以,我们只需要看第三个构造方法即可
步骤如下
- 判断指定容量是否小于0,如果小于0抛出异常
- 判断指定容量是否大于前面提到的最大容量,如果大于,让容量为最大容量
- 判断负载因子是否小于0或是否为Nan(Nan是Not a number的简写,也就是0作分母的数)
- 然后将指定负载因子赋值给loadFactor(实际操控的负载因子)
- 然后将指定容量赋值给threshold????(这里只是先用着threshold记录着容量)
- 最后调用init方法,这个方法看起来什么都没有做,没什么用
我们来看看他的注释
原来这个方法是跟构造函数和伪构造函数挂钩的,所以构造函数之所以没有调用super来构造继承的AbstractMap是因为调用了init方法吗?
总结构造方法
- 作者不想让我们使用自定义的容量和负载因子
- 默认的容量为16,负载因子为0.75
- 无参构造方法就是使用默认参数来调用有参构造方法
- 构造方法并没有马上去给table数组设置容量,而是将阈值设为了容量
put方法
我们来看看方法上的注释
从注释上就可以得知,当再次put相同key时会发生value的替换
从构造方法上可以知道,构造方法并没有立即给底层空数组立即扩上默认的容量,而是将门限值设为了默认容量
所以在put方法的开头,判断操作的table是不是EMPTY_TABLE,即针对第一次插入要对底层数组进行操作
调用inflateTable方法进行操作,这个参数是使用threshold作为参数的,也就是作为了下面的toSize,
inflateTable
下面来看看inflateTable方法做了什么
步骤如下
- 找到指定容量最近的一个2的幂次方数(要大于等于指定容量),作为真正的容量
- 然后对threshold进行计算,取最大的容量+1和容量乘于负载因子的最小值
- 然后重新给底层主数组table赋值,容量为指定容量最近的一个2的幂次方数
- 然后初始化hashSeed(前面提到过,这个hashSeed是关联哈希算法的)
所以,可以得知,构造方法将threshold设为指定容量,只是用来暂时存储指定的容量,底层主数组真正有容量是在第一次put之后
接下来我们看看怎么获取最近的一个2的幂次方数(该2的幂次方数大于等于指定容量)的,也就是roundUpToPowerOf2方法
步骤如下
- 比较指定容量是否大于等于最大允许的容量
- 如果大于指定容量就取最大容量( 2 30 2^{30} 230)
- 如果小于指定容量就再判断指定容量是否大于1
- 如果小于等于1,容量就取1(1也是2的幂次方,为零次幂)
- 如果大于1,那么就调用Integer的highestOneBit方法
Integer的highestOneBit方法是用来获取指定参数的最小2次幂
现在我们分析一下,为什么要取指定容量减一的两倍的最小2次幂,也就是为什么参数为(nuber-1) << 1
前面已经提过,设置的容量是最近的大于或等于指定容量的2次幂,我们就围绕他来论证
2次幂,也就是0、1、2、4、8、16。。。。。这些二次幂数会形成一个个区间,如同下面所示
那么指定容量就会出现两种情况
- 指定容量在区间内
- 指定容量就是一个2次幂
当指定容量在区间内时,代表这个容量大于左边的二次幂数,小于右边的二次幂数,因此就有这样一个表达式 2 ( n − 1 ) < x < 2 n 2^{(n-1)} < x < 2^n 2(n−1)<x<2n(x代表容量),那么我们对条表达式进行乘2变成 2 n < 2 x < 2 ( n + 1 ) 2^n < 2x < 2^{(n+1)} 2n<2x<2(n+1),所以2倍的容量的最近的小二次幂就是容量的最近的大二次幂
当指定容量就是一个2次幂,也就是不落在上述的区间内,那么就需要进行减1,来让其落在区间内(而且这个减1操作对于本就在区间内的数没有影响),当然,比如2和1,如果指定容量为2,那么减一就变成了1,但这里要注意的是必须要大于1,才会执行这个方法,所以我们不用考虑1的情况
至于Integer的highestOneBit方法的细节,能力不足,看不懂,这里就贴上源码自己看吧
最后就是进行initHashSeedAsNeeded,这个方法是用来使用代替散列才使用的, 不太重要,因为我们一般使用hashMap都不会使用字符串来代替散列
inflateTable方法到此结束
总结一下inflateTable干了什么东西
- 确定容量,容量大小为指定容量的最近的大二次幂
- 修改扩容门限,扩容门限为扩容因子乘上容量
- 如果要使用代替散列,对hashseed变量进行修改
添加Key不为null元素
先贴上代码
我们将其分为两部分
- 第一部分是形成链表时独有的操作
- 第二部分是没有形成链表和形成链表时都有的操作
第一个部分步骤如下
-
先判断要添加的键值对的key是否为null,如果为空,就调用putForNullKey方法
-
如果key不为空,调用hash方法来计算key的hash值
-
调用indexFor来找到key的hash值对应的索引
-
最后根据索引找到位置,判断这个位置是否已经有键值对
-
如果有,代表整个位置形成了之前发生过哈希冲突,形成了链表,就需要遍历这个链表看是否出现相同的键
-
判断相同的键是先根据链表中的键值对的hash值是否与当前要添加的键值对相等,如果不相等,再比较key是不是相同,判断是否是相同的key是根据是不是同一个与equals方法进行综合考虑的
-
如果出现相同的键,那么就记录旧的键值对的value,将旧的键值对的value修改成新的value(也就是键不变,值替换)
-
调用recordAccess方法
-
最后将旧的value返回
-
第二个部分步骤如下
- 让modCount自增(之前研究ArrayList也有这个变量,是用来记录年代的,也就是这个底层数组的版本变化)
- 最后调用addEntry方法,参数为哈希值,键,值,索引
总体的架构就是如上,现在来看调用的各个方法
hash方法
先来看看hash值是怎么计算的
上面那部分不用管,是替代散列时使用的(使用字符串替代散列值)
步骤如下
- 先调用key自己拥有的hashCode方法
- 最后使用一序列的扰乱函数
所以键值对的hash值生成是跟key有关的,而且细节来说是跟key的hashCode有关(这也就是为什么使用集合来存储对象时,一定要重写hashCode方法,而且hashCode要跟对象的属性值有关,这样就可以将相同属性值的对象视为同一个对象)
接下来我们看看扰乱函数是干什么的,从注释来看
总的来说这个扰乱函数就是用来减少哈希冲突的
indexFor方法
这个方法就是用来根据哈希值寻找到底层主数组的索引的
可以看到这个方法单纯是用哈希值与上了底层数组的最大索引(也就是长度减一)
这里是利用了与运算的特点,前面已经提到过,底层数组的长度一定是2的幂次方,那么减一操作就会让其变成全为1的二进制数,也就确保了底层数组的索引数量化成二进制里面所有数字一定全部都为1
那么此时这个算法就会等效成用哈希值对最大索引进行取余,也就是h % ( length - 1 )
所以索引的计算方法其实就是哈希值对最大索引进行取余操作,保证不会超过底层数组的最大索引
这也是为什么长度必须为2的幂次方
recordAccess
可以看到这个方法什么都没干,可能是为了以后方便拓展
我们再看下注释
注释上提到这个方法是出现重复key的时候都会调用
addEntry
最后我们再看看addEntry的方法
我们可以看到,addEntry方法里面竟然包含了扩容操作即resize方法
步骤如下
- 判断当前HashMap的元素数量是否大于门限
- 如果大于,再进行比较当前要添加键值对的位置是否为空
- 如果也为空,那就会进行扩容操作
- 所以,要扩容必须满足两个条件
- 一个是HashMap的元素数量大于门限值
- 另一个是当前添加键值对的索引位置必须发生冲突
- 下面进行扩容,扩容调用的是resize方法,而且传的参数是当前底层数组长度的两倍
- 扩容了之后,首先进行再次hash,即重新计算当前键值对的key的hash值
- 然后再用新的hash值计算出新的索引位置
- 最后就是调用createEntry方法来正式添加元素
扩容
我们来看看resize是如何进行扩容的
可以看到,扩容操作也是会进行判断
- 先判断旧的容量,也就是当前主数组的长度,是否等于最大容量
- 如果已经等于,就不可以继续扩了
- 然后将扩容门限设为Integer的最大值
- 结束
- 如果主数组的长度,不等于最大长度,那么就代表一定小于最大容量
- 那么就会创建一个新容量的数组(前面已经知道容量为旧容量的两倍)
- 然后调用transfer方法
- 让底层的table数组指向新容量的数组
- 最后就是重置了门限值,让门限值为最大的容量+1和新的容量乘上负载因子的最小值
可以看到,是不是少了就是将旧的数据放在新数组上的步骤,所以,可以推测到,这一步是放在了transfer上去完成的
可以看到,对于旧数组,不是像ArrayList那样单纯的进行复制过去,而是要重新散列的
可以看到,使用了增强for来桥套while循环,来遍历所有元素(增强for遍历数组,while循环来遍历每个项的链表),然后对所有键值对的hash(键值对不仅保存键值,还保存着哈希值和下一个键值对的指向)进行了重新计算,这一步称为rehash,当然,计算的规则依然是使用key来计算的,重新计算了hash当然也要重新计算索引,然后根据重新计算的索引,对应存放在新创建的扩容数组里面
不过这里有一个问题?如果rehash了之后重新装填又发生了哈希冲突会怎样?
根据源码来看,如果重新装填的过程发生了哈希冲突,那么后面的键值对就会替代前面的键值对。
至此,整个扩容过程就结束了
总结一下
- 扩容要先判断旧的容量是否已经最大,最大的话,只是单纯改变了门限值为Integer最大值
- 扩容后的容量为旧的容量的2倍
- 扩容后要将旧的数据重新装填进新的数组里面,重新装填并不是单纯的复制,而是要遍历所有项的链表进行重新hash,重新计算索引值,然后根据新的索引值放在数组里面
- 假如重新装填过程中发生哈希冲突,产生的索引值一样,那么就会进行数据替换,会产生数据丢失
- 最后将底层table指向新扩容后的数组
- 将门限改为新的容量乘上负载因子
createEntry
最后我们来看看createEntry方法
这个方法也就是真正底层的添加操作
- 首先获取底层数组指定索引位置处的旧键值对
- 让后新建一个键值对,键值对存要添加的键值、哈希值和指向下一个的旧键值对
可以知道,底层的插入使用的是一个头插法
补充:当添加的是key为null的元素
前面put方法源码上,是会判断key是否为null的,假如为null就会执行putForNullKey的方法
这样拆开是因为hash值是根据key的hashCode来生成的,如果key为null就不能生成hashCode,所以需要进行拆开
可以判断,putForNullKey的执行跟put方法后面的操作差不多
可以看到for循环里面,遍历的是底层table数组的第一个项,也就是第一个项的链表
那么就可以知道,key为null的键值对都是放置在底层table数组第一个项里面来形成链表的,当然里面还存在key不为null的键值对对象,然后如果发现有key也为null,而且value相等的,就会发生value替换,返回旧的value
可以看到,addEntry里面的bucketIndex变为了0,hash值也变成了0
所以,key为null的元素的hash为0,即使判断要进行扩容,重新hash的时候,因为key为null,索引也是为0的,因为bucketIndex为0,所以是存储在底层table数组里面的第一个项里面
get方法
接下来看看get方法是如何执行的
步骤如下
- 先判断key是否为null
- 如果为null就要在执行getForNullKey方法
- 如果Key不为null,执行getEntry方法
- 然后判断是否找到
- 通过比对获得的entry是否为Null来判断
- 如果为null就返回null
- 如果不为null,就返回entry的value属性
getEntry
我们先来看看如果key不为null时执行的getEntry方法
步骤如下
-
先判断HashMap里面的元素数量是否为0,如果为0就直接返回null
-
如果不为0,就要根据给的key计算哈希值
- 如果Key为null,那么对应的哈希值就为0
- 如果key不为null,对应调用实现的hashCode去获取hash值
-
使用indexFor方法获取哈希值的索引(0余上长度-1依然为0,所以不用担心0对索引的影响)
-
遍历底层table数组指定索引的链表,通过hash和key比较
-
首先比较哈希值是否相同
-
如果哈希值相同,再比较key是不是相同
- 先比较key是否是同一个对象,如果是,就代表key相同
- 如果key不是同一个对象,使用equals方法比较key值是否相同,如果是,就表示key相同
-
只要哈希值不同,就不会比较key是否相同
-
如果最后判断hash值相同,同时key也相同,就代表找到那个键值对了,返回键值对的value属性
-
-
如果遍历完了都还找不到,就返回Null
getForNullKey
接下来我们看看如果key为Null时会怎样
key为Null时,调用的是getForNullKey
可以看到,这个方法相比于getEntry没有那么复杂
因为key为null时只需遍历底层table数组的第一个项的链表即可。
注意这里返回的是遇到的第一个key为null的键值对,因为采用头插法,也就是最新插入的一个key为null的键值对
remove方法
最后我们看下删除方法
步骤如下
- 调用removeEntryForKey的方法
- 判断返回的entry是不是为空
- 如果为空,返回null
- 如果不为空,返回键值对的value属性
removeEntryForKey方法
源码如下
final Entry<K,V> removeEntryForKey(Object key) {
//判断底层数组元素数量是否为0
//如果为0直接返回null
if (size == 0) {
return null;
}
//计算hash值
int hash = (key == null) ? 0 : hash(key);
//根据hash值计算索引值
int i = indexFor(hash, table.length);
//prev记录的是上一个节点
Entry<K,V> prev = table[i];
//用e来记录当前节点
Entry<K,V> e = prev;
//下面对e进行while遍历
while (e != null) {
//next记录当前节点的下一个节点
Entry<K,V> next = e.next;
Object k;
//判断key是否一致的方法是跟前面一样的
//先判断hash值是否一样
//再判断key是否是同一个对象
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k)))) {
//匹配成功
//记录底层数组的版本,又更新了一次
modCount++;
//让数量减一
size--;
//如果链表第一个就匹配成功
if (prev == e)
//直接断开第一个,让下一个成为数组里面的节点
//也就是下一个成为第一个
table[i] = next;
else
//如果不是第一个
//让上一个的next为当前节点的下一个节点
prev.next = next;
//每次删除都要记录recordRemoval方法
e.recordRemoval(this);
//返回倍删除的节点
return e;
}
prev = e;
e = next;
}
return e;
}
可以看到,删除方法一次只会删除第一个匹配的键值对,删除第一个就直接返回了
看一下recordRemoval干了什么
可以看到,其什么都没干,跟前面的recordAccess一样,都是简单的标记会使用而已
clear方法
clear方法更加简单,步骤如下
- 版本+1
- 让底层table数组的值全部换为null
- 让键值对数量为0
所以clear方法并不会去回收底层table数组的空间,也就是不会进行缩容
而是直接设为Null,让gc收集器去回收那些不要的引用