1.8JDK
几个重要的属性:
//默认装载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//链表化为树的阈值
static final int TREEIFY_THRESHOLD = 8;
内部Node节点中重要属性:
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
public final K getKey() { return key; }
public final V getValue() { return value; }
public final String toString() { return key + "=" + value; }
}
一些公共属性:
//Node类型的数组
transient Node<K,V>[] table;
//阈值
int threshold;
//装载因子
final float loadFactor;
公共的操作:
两个构造方法:
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR;
}
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
若我们再创建HashMap对象时没有指定容量,则默认容量为0,默认装载因子为0.75
否则容量就按我们指定的容量去赋值
计算key的hash值的方法:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
此方法为取key的hash值,若key为null则hash值为0;否则先调用Object的hashCode方法计算出key的hashCode记为h,然后将h右移16位,并将两次得到的结果通过异或计算得出最终结果
计算HashMap表长/初始阈值:
static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
这个方法传入的参数cap为HashMap的容量,这里分三种情况:
1、 若cap为1,返回值为1;
2、 若cap为不为2的n次方返回比它大的最小的2的n次方;
3、 若cap本身就是2的n次方返回的是它本身
首先将cap-1,这一步主要是为了解决cap本身就是2的n次方这个问题而存在的,然后将所得的值右移1位并与它本身按位或,然后右移两位再与所得值按位或…最后将所得值加1得到比cap大的最小2的n次方
下面是基本的增删查改:
HashMap说白了就是一个数组+链表+树的数据结构,数组用来存放键值对,链表用来解决冲突问题,红黑树用来解决效率问题。
增:
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
具体实现方法就不贴出来了
1、首先会通过键值对中的key计算出hash值
2、若数组为空或数组长度为0,则先进行扩容操作
3、通过除留余数法算出键值对在数组中的位置i = (n - 1) & hash
4、若此位置没有元素则先将此键值对包装成Node节点,并直接将其插入到此位置;若此位置有元素并且此元素的键key和要插入元素的键key相等,则用新值覆盖旧值,并将旧值返回;否则一直遍历此位置的链表,直到最后,然后将要插入的元素插入到链表的最后
5、树的情况暂不讨论
删:
public V remove(Object key) {
Node<K,V> e;
return (e = removeNode(hash(key), key, null, false, true)) == null ?
null : e.value;
}
1、需要我们给出要删除元素的键key,通过此键计算出hash值
2、若数组长度为0或HashMap为空,或者位置index = (n - 1) & hash上的元素为空,返回null
3、定位到hash值和key都相等的元素,若此元素所在链表没有其它元素,则删除它,否则删除它之后,用它的后继节点来占据它的位置
查:
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
1、需要我们给出要删除元素的键key,通过此键计算出hash值
2、若数组长度为0或HashMap为空,或者位置index = (n - 1) & hash上的元素为空,返回null
3、定位到hash值和key都相等的元素,返回
一些讨论性的话题:
HashMap中的树:
当链表长度大于8的时候将链表转换为红黑树
HashMap对 “null” 的处理:
先说结论:
键值都可以为null
当插入元素的键key为空时,
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
可以看到,key为空时计算出的hash值为0。因此计算此元素在数组中位置的表达式i = (n - 1) & hash得出来的结果也为0。当我们将一个键key为null的元素插入到HashMap中时,实际上时将此元素插入到了数组中下标为0的位置上
而对值value没有什么特别的处理
计算hash值的方法:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
取key的hash值,若key为null则hash值为0;否则先调用Object的hashCode方法计算出key的hashCode记为h,然后将h右移16位,并将两次得到的结果通过异或计算得出最终结果
为什么要让y右移16位在异或呢?
定位元素在HashMap中位置时用的是除留余数法,因为表长n的二进制位数有限,所以就算hash值的二进制位数再长,我们用到的也只是它的低位,用不到它的高位;而将hash值的二进制右移16位后,它的低16位没了,留下来的是它的17位开始往上的高位数,这样用hash值的高位与低位相异或,就能良好的利用了hash值所有位置上的数,并且增加了hash值的随机性,从而达到一定的减小冲突作用
负载因子:
负载因子为0.75。如果是0.5, 那么每次达到容量的一半就进行扩容,空间利用率低下,并且执行resize操作的频率会增加,但是冲突的几率减少了; 如果是1,那意味着每次数组空间使用完毕才扩容,空间利用率增加了,但是冲突的几率也相应增加。因此,取0.75也算是一种折中,这个数字是对空间和时间相对都比较友好的一个值。(服从泊松分布,只不过不是我们讨论的话题)
扩容/容量/定位:
当hashMap的数组长度到了一个临界值就会扩容,把所有元素rehash再放到新的HashMap中,容量为之前的2倍
为什么容量要是之前容量的2倍呢?
在构造HashMap后,我们会将其初始阈值用tableSizeFor这个方法调整为2的n次方,并且再第一次扩容时,会将此阈值赋给新容量newCap让HashMap的容量也为2的n次方。计算键值对在数组中位置的时候,我们会使用的是除留余数法,即通过表达式i = (n - 1) & hash来计算。n代指数组容量,hash为键key的hash值。因为初始容量为2的n次方,所以初始n-1得到的二进制数一定是这样的0111111…,即n-1的二进制中除第一位为0后面位置上的数都为1,这样n-1和hash值相与出来的结果即为hash%n的结果,即取余数;若n不为2的n次方,得出来的结果就不是两者的余数了,除留余数法也就不成立了,并且倘若容量为11,n-1取二进制得到1010,来看一下不同的hash值与其相与的结果:
1010 & 101010100101001001000 结果:1000 = 8
1010 & 101000101101001001001 结果:1000 = 8
1010 & 101010101101101001111 结果: 1010 = 8
1010 & 101100100111001101100 结果: 1000 = 8
可以看到,因为由0的存在,hash值相应位置上不管是0还是1和它相与出来的结果都为0,这增加了hash冲突,tab[8]这个位置上的链表长度也相应增加,对于相应位置键值对的增删查速度也会有所影响
扩容方法就不贴出来了,看具体流程:
具体的扩容流程:
1、 若我们一开始创建HashMap对象时没有给定初始容量,即这样构造:
HashMap map = new HashMap();
HashMap内部首先会加载一个装载因子值为0.75f,并且暂时不为容量Cap和阈值Thr赋值,让它们各自拥有默认初值0
接下来我们使用put方法插入一条数据。跟踪进去,HashMap首先会进行一个判断:若Hash表长为0或Hash表为空则会走到resize方法,并将Cap设置为16,Thr设置为16*0.75 = 12;否则直接插入数据。显然,这里首先走到resize方法进行了扩容
接着我们连续插入11个数据,都不会扩容(假定无冲突)
再插入一条数据,现在数组中容量达到了阈值。首先会将数据插入到当前HashMap中,插入完毕,在结束put方法前会判断当前表长size是否超过了Thr,若超过则进行扩容。新Cap为之前的两倍32,Thr也为之前两倍24.
2、 开始创建HashMap对象时给定初始容量,即这样构造:
HashMap map = new HashMap(1);
这里传入的初始容量为1
首先调用HashMap的有参构造方法将默认装载因子0.75f和指定容量Cap = 1传入其中,并算出阈值Thr = 1
接下来插入一个元素,因为HashMap表长为0并且HashMap也为空,所以先进行resize操作,算出newCap = 1,newThr = newCap * loadFactor =0.75,然后插入元素。在put方法结束前判断当前表长size是否超过了newThr,显然size为1大于newThr,所以再次resize,将newCap赋值为2,newThr赋值为1.5
… …
3、 初始容量不为2的n次方
HashMap map = new HashMap(3);
首先调用HashMap的有参构造方法将默认装载因子0.75f和指定容量Cap = 3传入其中,然后调用tableSizeFor方法算出阈值Thr=4
接下来插入一个元素,因为HashMap表长为0并且HashMap也为空,所以先进行resize操作。在这里HashMap会将旧阈值Thr=4赋给新的容量Cap,即newCap = oldThr = 1,newThr = newCap * loadFactor =3,然后插入元素。在put方法结束前判断当前表长size是否超过了newThr,显然不超过
接下来再插入3条.再第3条数据插入完毕后进行容量判断时发现表中数据有4条,而阈值为3,所以进行扩容,结果newCap = 8,newThr = 6
…
画一幅图
假设我们将初始容量赋值为1
每次都要等待表中数组个数超出阈值以后才扩容
总结:
不管有没有为它赋初值,HashMap在第一次使用之前(即第一次插入元素之前)会扩容,
1、 倘若没有为它赋初始容量,HashMap默认容量为0,在第一次使用之前它会被赋予newCap=16,newThr=12
2、 倘若为它赋初始容量1,使用tableSizeFor方法算出来的值任然是1,阈值为0.75;插入第一个元素后会进行二次扩容,newCap=2,newThr=1.5
3、 若初始容量是其它2的n次方以外的数,第一次插入元素之前会将表的容量增加到大于初始容量的最小的2的n次方
4、 若初始容量为2的n次方,则它就会使用这个容量值
每次插入元素后,HashMap会判断表中元素总数是否超过阈值,若超过,则进行扩容,并且HashMap总会等到表中元素总数超过了阈值以后才会扩容
扩容是将容量和阈值都变为之前的两倍
重写hashCode/equals的意义:
equals:
Object的equals方法判断的是两者内存地址是否一致(即源码是用“==”来实现的),我们可以直接用 “ = = ”来代替。很多情况下,我们都只需要判断两者的字面量是否相等,所以我们需要重写equals方法去判断两者的字面量大小
例如String的equals方法
public boolean equals(Object anObject) {
if (this == anObject) {
return true;
}
if (anObject instanceof String) {
String anotherString = (String)anObject;
int n = value.length;
if (n == anotherString.value.length) {
char v1[] = value;
char v2[] = anotherString.value;
int i = 0;
while (n-- != 0) {
if (v1[i] != v2[i])
return false;
i++;
}
return true;
}
}
return false;
}
String的equals方法是通过逐步比较两个字符串中每个字符是否相等来得出结果的。
我们来做个测试:
public class Test{
public int hash(Object key) {
int h;
return (h = key.hashCode()) ^ (h >>> 16);
}
public static void main(String[] args){
Testtest = new Test();
String a = "abc";
String b = new String("abc");
System.out.println(test.hash(a));
System.out.println(test.hash(b));
System.out.println(a == b);
System.out.println(a.equals(b));
}
}
结果:
96355
96355
false
true
假设我们将上述两个字符串a和b作为key插入HashMap,首先它们用hash方法算出来的值是相等的,即两者在同一个槽位上,然后我们肯定是想用b的值来覆盖a的值,因为a、b两者字符串的字面量是相等的,都为“abc”,我们期望使用equals方法来判断,得出结果为true以方便b来覆盖a。倘若我们不重写equals方法,默认调用的就是Object的equals方法,比较的是两个对象在内存中的地址,返回的是false,很明显与我们的与其不符
所以我们需要重写equals方法,让其来判断两个String对象的字面量是否相等
重写equals的意义:
HashMap中比较两个key是否相等用的是两个key所属类的equals方法,如String、Integer等
意义就是处理重复键key(HashMap的put方法中比较两个key是否相等使用的就是equals和hashCode,并且key相同就会用新value覆盖旧value,否则会再次往后定位)
hashCode:
首先,重写hashCode的第一个意义当然是减少冲突
使用hashCode的高16位和低16位相异或,增加了hashCode的随机性,在一定程度上可以起到减少冲突的作用,上面有介绍
这里,我们的重点暂时不放在有关冲突的话题上。
数组中元素的hashCode值是通过key来计算的。我们在向HashMap中插入元素的时候,首先需要通过key计算出它的hashCode值并且通过此值来定位到这个元素在数组中的位置i。
所以hashCode的第一个作用是定位
接下来若此位置无元素,直接插入;若此位置有元素,则使用equals比较此位置中元素的key和要插入元素的key是否相等(如上述equals的使用),若相等,则用新value覆盖旧value;不等,往链表后面搜索合适位置再插入。
所以重写hashCode的意义:
1、减少冲突;
2、定位
HashMap中put方法通过hashCode和equals方法达到了减少冲突、定位和处理重复键key的功能
多线程环境下,HashMap1.7扩容会产生的问题:
1.7resize关键代码:
1、 void transfer(Entry[] newTable, boolean rehash) {
2、 int newCapacity = newTable.length;
3、 for (Entry<K,V> e : table) { //这里才是问题出现的关键..
4、 while(null != e) {
5、 Entry<K,V> next = e.next; //寻找到下一个节点..
6、 if (rehash) {
7、 e.hash = null == e.key ? 0 : hash(e.key);
8、 }
9、 int i = indexFor(e.hash, newCapacity); //重新获取hashcode
10、 e.next = newTable[i];
11、 newTable[i] = e;
12、 e = next;
13、 }
14、 }
15、 }
1.7的resize方法为头插
首先我们构造一个HashMap并赋给它初始容量Cap = 2,阈值Thr = 1.5
我们有两个线程T1和T2,两个线程都欲往里面插入数据
下图是初始状态
假设线程T1执行到上述代码第5行后被挂起
下图是挂起后的标记状态
接下来线程T2向里面插入一条数据后扩容
扩容完毕后所有元素都被放入新的集合里,下图左图为容量Cap = 4的线程T2的新数组
因为T1被挂起,所以标记e和next的指向都没变
为便于观察第三条数据就不画出来了
下一步T1开始执行
元素A,1被标记为e的next
下图右图为容量为Cap = 4的线程T1的新数组
接下来上图左边T2的新数组将它的元素全部插入右边T1新数组中
接下来执行第10行代码e.next = newTable[i]
遍历:
以下代码公用部分:
HashMap<String, Integer> map = new HashMap<String, Integer>();
map.put("A", 1);
map.put("B", 2);
map.put("C", 3);
map.put("D", 4);
一、
for (String key : map.keySet()) {
System.out.println(map.get(key));
}
较下列三种遍历方式,性能较差
二、
for (Entry<String, Integer> entry : map.entrySet()) {
System.out.print(entry.getKey() + " ");
System.out.print(entry.getValue());
System.out.println();
}
最好用这种方法
三、
Set<Entry<String, Integer>> entrySet = map.entrySet();
for (Entry<String, Integer> entry : entrySet) {
System.out.print(entry.getKey() + " ");
System.out.print(entry.getValue());
System.out.println();
}
四、
Iterator<HashMap.Entry<String, Integer>> iterator = map.entrySet().iterator();
while (iterator.hasNext()) {
HashMap.Entry<String, Integer> entry = iterator.next();
System.out.print(entry.getKey() + " ");
System.out.print(entry.getValue());
System.out.println();
}