负载因子,数组长度在2的次方,当链表长度>=8时扩容成红黑树?
-
负载因子
当我们将负载因子不定为0.75的时候(两种情况):
1、 假如负载因子定为1(最大值),那么只有当元素填满组长度的时候才会选择去扩容,虽然负载因子定为1可以最大程度的提高空间的利用率,但是会增加hash碰撞,以此可能会增加链表长度,因此查询效率会变得低下(因为链表查询比较慢)。hash表默认数组长度为16,好的情况下就是16个空间刚好一个坑一个,但是大多情况下是没有这么好的情况。
结论:所以当加载因子比较大的时候:节省空间资源,耗费时间资源
2、加入负载因子定为0.5(一个比较小的值),也就是说,直到到达数组空间的一半的时候就会去扩容。虽然说负载因子比较小可以最大可能的降低hash冲突,链表的长度也会越少,但是空间浪费会比较大。
结论:所以当加载因子比较小的时候:节省时间资源,耗费空间资源
但是我们设计程序的时候肯定是会在空间以及时间上做平衡,那么我们能就需要在时间复杂度和空间复杂度上做折中,选择最合适的负载因子以保证最优化。所以就选择了0.75这个值,Jdk那帮工程师一定是做了大量的测试,得出的这个值吧~
-
hash表的数组长度总在2的次方
1:
// WeakHashMap.java 源码: /** * Returns index for hash code h. */ private static int indexFor(int h, int length) { return h & (length-1); }
扩容也是以2的次方进行扩容,是因为2的次方的数的二进制是10..0,在二的次方数进行减1操作之后,二进制都是11...1,那么和hashcode进行与操作时,数组中的每一个空间都可能被使用到。
如果不是2的次方,比如数组长度为17,那么17的二进制是10001,在indexFor方法中,进行减1操作为16,16的二进制是10000,随着进行与操作,很明显,地址二进制数末尾为1的空间,不会得到使用,比如地址为10001,10011,11011这些地址空间永远不会得到使用。因此就会造成大量的空间浪费。
所以必须得是2的次方,可以合理使用数组空间。
2:
扩容临界值 = 负载因子 * 数组长度
负载因子是0.75即3/4,又因为数组长度为2的次方,那么相乘得到的扩容临界值必定是整数,这样更加方便获得一个方便操作的扩容临界值。
-
当链表长度>=8时构建成红黑树
利用泊松分布计算出当链表长度大于等于8时,几率很小很小
当put进来一个元素,通过hash算法,然后最后定位到同一个桶(链表)的概率会随着链表的长度的增加而减少,当这个链表长度为8的时候,这个概率几乎接近于0,所以我们才会将链表转红黑树的临界值定为8。
tips:了解红黑树,请移步至Java数据结构与算法:红黑树 AVL树.md
为什么jdk8,hashmap底层会用红黑树,而不使用AVL树?
首先需要了解什么是红黑树,什么是AVL树。请移步至Java数据结构与算法:红黑树 AVL树.md
红黑树和AVL树增删改查的时间复杂度平均和最坏情况都是在O(lgN),包括但不超过。
红黑树性质:
- 节点不是黑色就是红色
- 根节点必须为黑色
- 不能有两个连续红色节点
- 叶子节点是黑色
- 从根节点到叶子节点经过的黑节点数量相同
特点:最长路径不会超过最短路径的2倍。
AVL性质:
- 任何节点的两个子树的高度最大差别为1
在jdk8中hashmap的hash表桶中的链表长度大于8时,会将链表转为红黑树。虽然红黑树与AVL树的时间复杂度都为O(lgN),但是在调整树上面花费的时间相差很大。因为AVL树是平衡二叉树,要求严苛,任何节点的两个子树的高度最大差别为1,因此每次插入一个数或者删除一个数,最坏情况下,会使得AVL树进行很多次调整,为了保证符合AVL树的规则,调整时间花费较多。而红黑树,在时间复杂度上与AVL树相持平,但是在调整树上没有AVL树严苛,它允许局部很少的不完全平衡,但最长路径不会超过最短路径的2倍,这样以来,最多只需要旋转3次就可以使其达到平衡,调整时间花费较少。
最重要的一点,在JUC中有一个CurrentHashMap
类,该类为线程同步的hashmap类,当高并发时,需要在意的是时间,由于AVL树在调整树上花费的时间相对较多,因此在调整树的过程中,其他线程需要等待的时间就会增长,这样导致效率降低,所以会选择红黑树。
总结:在增加、删除的时间复杂度相同的情况下,调整时间相对花费较少的是红黑树,因此选择红黑树。
既然红黑树那么好,为什么不一来就使用红黑树?
因为经过泊松定律知道,一个在负载因子为0.75时,出现的hash冲突,在一个桶中的链表长度大于8的几率是很少很少几乎为0,如果一来就使用红黑树,由于增删频繁,从而会调整树的结构,反而增加了负担,浪费时间,而直接使用链表增删反而比红黑树快很多,因此为了增加效率,而只是在长度大于8时使用红黑树。
hashmap在get和put的时候为什么使用尾插法,而摒弃了头插法?
这是因为多线程并发操作下,可能形成环化。
比如线程T1
将要添加一个B元素进来,此时线程T2
正在resize,达到了扩容临界值,所以需要重计算,在重计算中,线程T1
的B元素插在了A元素的头上:
由于线程T2
重计算数组长度后,扩容之后,在旧数组中同一条Entry链上的元素,通过重新计算索引位置后,有可能被放到了新数组的不同位置上。所以,元素A可能会插到元素B头上,形成了环状,死循环:
为了解决这个问题,在jdk8之后就使用了尾插法!最终不会形成环化。
虽然尾插法解决了这个问题,为什么在高并发下还是不能使用hashmap呢?
因为在hashmap中,没有锁的化,高并发下一个线程put的值,另一个线程可能不能及时get到最新put的值。所以要使用currentHashMap,用的锁+尾插法
练习:计算一个字符串每个字符出现的次数
public class Test{
public static void mian(String[] args){
forTest01();
}
public static void forTest01(){
Scanner sc = new Scanner(System.in);
String str = sc.nextLine();
char[] charArray = str.toCharArray();
HashMap<Character,Integer> hashMap = new HashMap<>();
for(int i = 0; i < charArray.length(); i++){
char c = charArray.get(i);
if( hashMap.containsKey(c) ){
Integer in = hashMap.get(c);
++in;
hashMap.put(c,in);
}else{
hashMap.put(c,1);
}
}
Set<Map.Entry<Character,Integer>> set = hashMap.entrySet();
for(Map.Entry<Character,Integer> entry : set){
System.out.println(entry.getKey()+"---"+entry.getValue());
}
}
public static void forTest02(){
Scanner sc = new Scanner(System.in);
String str = sc.nextLine();
HashMap<Character,Integer> hashMap = new HashMap<>();
for(char c : str.toCharArray()){
if( hashMap.containsKey(c) ){
Integer in = hashMap.get(c);
++in;
hashMap.put(c,in);
}else{
hashMap.put(c,1);
}
}
for(Character key : hashMap.keySet()){
Integer value = hashMap.get(key);
System.out.println(key+"---"+value);
}
}
}