集合类中很经典很常用的一个:HashMap。
public
class
HashMap
extends
AbstractMap
implements
Map, Cloneable, Serializab
以上是HashMap 的一个继承实现关系 ,其父类中主要是将Map 定义好及实现一些基本的操作,HashMap是在此基础上的增强。
以上是HashMap 中定义的一些属性。
值为16 的是默认的创建HashMap 时默认的大小 显式地写出来就是
new
HashMap(16)
值为1073741824 的是这个HashMap 最大的大小不能超过此值
值为0.75F 的是一个扩容因子,当空间用到75%的时候就会触发扩容方法,自动为已创建 的HashMap 扩容
大家注意到 table entrySet 及其后面两个值的修饰关键字都是 transient 瞬态的,也就是序列化不会序列到这几个属性。
下面以问答的形式来学习这个集合类
- 该集合存取数据的结构是什么样的?
- put操作如何实现?
- get操作如何实现?
- remove操作如何实现?
- size表示的是什么?
- modCount 有什么作用?
- 数组大小为什么设为2的次方数?
- 加载因子是什么?
- 为什么说该集合为数据不安全的,因此慎用于多线程中?
- 扩容操作做了什么?
回答:
1、2、3、4
该集合是成对地存储对象 K-V,K为V的唯一识别,也就是所谓的键,在每一个HashMap对象中,K都是不会重复的, 允许为NULL。
HashMap 中保存值(Entry)的常用方法 是 put(k,v)。
存储是先调用K的hashCode() 方法计算出K的hashCode,然后再调用HahMap自己的hash(int code)方法进而计算出一个
哈希值 i(这个可以叫做正在put的键值对的hash值)
int
i
=
hash
(
paramK
.hashCode());
接下来取这i值和HashMap中的 table数组的长度 length-1 进行 &(与)运算,进而得出该Entry 在数组中的位置 j
int
j
=
indexFor
(
i
,
this
.
table
.
length
);
static
int
indexFor
(
int
paramInt1
,
int
paramInt2
)
{
return
paramInt1
&
paramInt2
- 1;
}
并不能直接就这样放进去,因为可能数组中的j这个位置已经有值了。由前面可以看到,定位j是靠运算后的hash值来再次运算得到的,就算K不一样,但是由于算法的原因得到一样的j也是不奇怪的,但是我们必需处理这种叫做“冲突”或是“碰撞”的情况。
发现j中有值后,我们就取出j中存在的值,看它里面的k是不是与我们将要put的这个一样:
一样,则将新的值替换原来的值(这就解释了为什么HashMap中K都是唯一不重复的);
不一样,那么我就看你j中原来值的next 是不是还有东西。没有的话,那我就new 一个新的Entry 放到table的j位置,新Entry 的next 将指向原来的在j这个位置;next还有东西,那我就继续循环判断下去,存在则替换,不存在new 一个新的放到table 的j 的位置,新Entry 的next 将指向原来J这个位置上的Entry 对象。
所以说,HashMap存储的样子就是个数组与链表的合体
HashMap 取值的常用方法则是 get(K key)
该方法与put方法基本相同的操作,对K进行运算出 j 再查table 中j 位置的Entry 中的 key 是不是与get(K)中的K相同,相同则返回V,不同就遍历 J所在的整个链,找到即返回,找不到返回null。
HashMap 中的 remove(K k)方法中,删除Entry 之前得先找到要删除的那个Entry ,所以前半部分与get(K k)完全一样。
定位到要删除的对象之后,如果j位置只有一个Entry 非链表,直接清空,如果在链表中,将其从链表关系中去掉,然后再将链接拼接好。最后返回被删除的那个 Entry 。由此我们可以知道看remove后的返回值就可以知道我们删除了什么。
由HashMap 中的几个主要操作方法可以知道,查询无非主要就两种 数组查询 O(1)与链表查询 O(n),所以这里可以知道,影响查询速度的是链表的长度。
5
size表示的是什么?
size 中保存的是 HashMap 中当前存储的Entry 对象的个数,由于是数组+链表 存储的,则size 不一定是table 中存储的个数。
6 modCount 有什么作用?
modCount 这个属性记录的当前 HashMap 对象添加或删除的 操作次数 ,添加一次 +1 ,删除一次 +1。
为什么要保存这么个东西呢,这是由于在迭代的时候每次都会检查前后两次的 modCount 是不是相等的,不相等则会
报ConcurrentModificationException 异常
意思就是不能在迭代的时候进行 前面提到的三个对HashMap 的操作。(但是我们可以用Iterator 中的remove()方法进行删除)
7
数组大小为什么设为2的次方数?
当我们 new 一个HashMap给它指定长度为4时,那就是说我们初始化 table 的长度只有4。
首先我们要明白一件事:
table 的4个位置并非全部都会用到,因为我们使用的下标的是通过计算得到的,如果某个下标永远也计算得不到那它永远也不会有值存进去。为了能尽可能多地使用table 中的位置,所以我们计算的算法要尽可能地设计好。那么我们接下来就看计算下标的方法:
int
i
=
hash
(
paramK
.hashCode());
int
j
=
indexFor
(
i
,
this
.
table
.
length
);
static
int
indexFor
(
int
paramInt1
,
int
paramInt2
)
{
return
paramInt1
&
paramInt2
- 1;
}
indexFor 这一计算数组下标的方法中,是将计算得到的hash值和数组长度-1 进行与运算。
为什么是数组的长度-1呢,这就要先看数组的大小设计了。
public
HashMap(
int
paramInt
,
float
paramFloat
)
{
if
(
paramInt
< 0) {
throw
new
IllegalArgumentException(
"Illegal initial capacity: "
+
paramInt
);
}
if
(
paramInt
> 1073741824) {
paramInt
= 1073741824;
}
if
((
paramFloat
<= 0.0F) || (Float.
isNaN
(
paramFloat
))) {
throw
new
IllegalArgumentException(
"Illegal load factor: "
+
paramFloat
);
}
int
i
= 1;
while
(
i
<
paramInt
) {
i
<<= 1;
}
this
.
loadFactor
=
paramFloat
;
this
.
threshold
= ((
int
)(
i
*
paramFloat
));
this
.
table
=
new
Entry[
i
];
init();
}
以上是HashMap 的构造方法,由此我们可以看到数组的大小永远在 1-1073741824之间,
while
(
i
<
paramInt
) {
i
<<= 1;
}
这一段代码是取i 的值的代码,构造HashMap 是并不是直接取传入的值当table 的大小,而是取的i 。
i由1开始,不停地由左位移,也就是每次乘2,所以最后得到的的i 肯定是2的倍数,也就是偶数,那么table的长度-1 得到的肯定是奇数。
我们的定位数组下标的方法其实就是个取模(取余)运算,为啥取余?因为如果数组总长为4,那么对4取模,得到的肯定比4小,不会超下标。而对数组长度如果是2的次方数的进行取模运算刚好可以用,还是效率很好的运算(位运算就是机器的源本计算方法)
那我们为什么要限定数组长度为2的次方呢。注意,的倍数-1肯定得到奇数,所以现在的问题是,对奇数进行&运算有什么好处呢。请看下面:
奇数与偶数转化为二进制最大 的区别是最后一位数:奇数最后一位永远是1,偶数最后一位高远是0;
那么任意数与奇数进行&运算时可以得到任意小于等于两者中最小的数;而任意数与偶数进行&运算时,只能得到任意小于等于两者中最小的数中的偶数,最后一位永远不可能得出一个1来,这就是奇数的好处,使得运算结果的范围扩大了很多。
知道上面的原理是第一步。为了得到奇数,我完全可以将数组的长度设为偶数就行啦,何必要那么严格,为偶数不止,还必需是2的N次方,刚才我们看的是最后一位,现在我们看下其他位。6-1=5 0101 8-1=7 0111 10-1=9 1001 16-1=15 1111
有没有发现,2的次方数-1后得到的奇数都是每位都有1占满的,而普通偶数则有部分位为0的情况,由此可以知道,2的次方数-1得到的奇数进行&运算时比普通的范围又要大很多。
这就是为什么数组的大小要设为2 的次方数的原来,为了尽可能利用数组 的位置,为什么要尽可能利用数组的位置?因为数组的位置上查询快啊。对了,如果new HashMap 的时候如果不设定大小,是有个默认值的,看前面截图可以知道它为16(2^4)
8 加载因子是什么?
接着7中说到的
数组中只能存4个Entry ,如果存入HashMap 中的的数据超过4条,那么将会存到链表中,数据越多,链表越长。前面我们已经知道,链表的长度是影响查询速度的因素。因此,为了不影响性能,我们必需得给它设置一个限度值。比如,可以设为最大存储数量为数组的长度的2倍、1.5倍、3倍、0.75倍、0.5倍,等,反正是要设置的。一开始的截图中我们可以看到,HashMap 的默认最大存储数量是数组长度的0.75倍,0.75这个属性就 是加载因子。当存储数量大于 table.length * 0.75 时,HashMap 就会自动扩容为原来的2倍。
至于为什么是0.75,我们先来分析这个加载因子的大小影响到什么,我们要时刻对事件抱着一种蝴蝶效应的想法才能找出原因。
打个比方,数组的原来长度为10,如果加载因子是0.75,那当存储数量达到8的时候就会自动扩空,即使前面存储的全部存储到了数组中,没有产生链表,那还有三个空着,扩容了,就浪费了三个。如果加载因子是0.5,那就会浪费5个。如果加载因子是1.5,那么就会最少有5个是在链表中的,查询性能下降了。有人说那设为1啊,全用完再加载。太天真了,我前面假设的是在先全部填满数组的理想情况。但实际中这种状态发生的概率太小了。如果我们想把长度为10的数组全部填满,说不定要存储100个数才能达到,这样碰撞的次数是太大了。如果我们要填满5个,说不定10个就行了,这就是差别。
由此我们可以总结得到,加载因子影响着性能和存储空间的浪费程度。两都不能兼得,我们要平衡。
然而,为什么是0.75 ,这个值,我只能告诉你们,是经验值.....前人总结的..
9 为什么说该集合为数据不安全的,因此慎用于多线程中?
10 扩容操作做了什么?
先看下面一个HashMap 在内存中存储的样子(本人的理解,key 的存储样子忽略,请注意在value 和next上,因为key 如果是个对象或字符串的话也不是下而显示的样子,存储的应该也是引用,为了简化而为之)
那么扩容的话我们要做什么呢,扩容我们是扩大数组的长度,也就是对HashMap 中的table 属性操作,所以首先可以知道 的是02、03是不会动到的,01是肯定要动的。比如由长度为4扩为长度为8,那么它需要在另一块地方找出连续的8个位置给新的table。
然后需要做的就是,1.把在原来table的三位住户搬到新的地方去,当然是一个一个地取出来,重新按新的table长度计算出新的数组下标(hash值还是和原来一样的,虽然源码中还是对其计算了一次,可能是保个底),然后在计算出的位置上存入该存的Entry 的引用(房间地址),依次存完。2.将原来栈中的存储原来table 的引用改为新table 的引用。
由此可见扩容要做的事是很多的,事实上扩容是特别耗时的。一般的话最好的情况就是能预知大概的存储量是多少,然后new 的时间给定大小,以免在持续的添加过程中自动产生扩容操作,影响性能。
HashMap
hm
=
new
HashMap(4);
hm
.put(
"key1"
, 1020);
hm
.put(
"key2"
,
new
Person(
"小王"
,
"21"
));
hm
.put(
"key3"
, 50.2);
上面的问题解决 完了,那么最后来个有意思的东西吧。
序列化问题。
一开篇的时候我是不是特别提醒了有好几个属性是瞬态(
transient)的,瞬态是什么意思呢,简单粗暴地说就是不支持序列化的。就是你要序列化一个HashMap 对象时那几个属性是不会到序列化后的地方去的。大家可以发现,不能序列化的这几个属性都是一个HashMap 中最重要的部分了。
为什么不能序列化 呢,我们看这四个属性 table EntrySet size modCount ,这四个中首要的应该是table 我们new 的时候也是开辟table 的空间,其他三个都中在此基础上才有得以存在的意义,故我们就来看为什么table 不能序列化。我们由前面都知道了,每一个Entry 与table 的下标联系是靠计算得到,其中的计算就用到了哈希算法,我们都知道K中的hashCode()这个算法若没有特别的覆写实现,那它就是用Object 中hashCode()方法,而这一方法是本地方法,也就是和Java 环境有关,若环境一变就有可能得到不同的值,那么我们这个定位方法在序列化后 就有可能失效了。