从HashSet到HashMap

 

 

一、介绍

  由上图所知,在Java的Collection容器下有四类接口,分别代表是不同类型的容器

  Set下的集合有以下几个特点:

1、无序(添加和取出的顺序不一致),没有索引

2、不允许重复元素,最多包含一个null

二、HashSet

  HashSet实现了Set接口,故而Set的特点HashSet同样也拥有:

1、HashSet不能保证存放元素是有序的

2、不能存放重复的元素

3、最多存放一个null值

  我们先看一个例子:

1 class Test{
2     public static void main(String[] args) {
3          HashSet list = new HashSet();
4         for(int i = 1;i < 15; i++){
5             list.add(i);
6         }
7     }
8 }

  这个上向一个HashSet类的集合里面添加元素,我们用Debug去看一下它的执行过程:

 

1、自动装箱 由于list里面要存对象,而我们给的是常量,所以会自动帮我们封装成对应的对象

1 public static Integer valueOf(int i) {
2         if (i >= IntegerCache.low && i <= IntegerCache.high)
3             return IntegerCache.cache[i + (-IntegerCache.low)];
4         return new Integer(i);
5     }

  注意这里之所以要判断,是因为 int 变量在 [-128,127]之间时,JDK就会分配给我们一个已经存在的Integer对象,而在这个范围外才会new一个对象

 

2、添加元素

  这里首先我们看一下添加元素的主要函数

1  public boolean add(E e) {
2         return map.put(e, PRESENT)==null;
3     }

   可以看到这个就是add方法的全貌,它的返回值是boolean,添加成功返回true,失败则返回false,那调用的map对象是什么,这个map其实是HashSet类的一个成员对象,具体如下:

1 private transient HashMap<E,Object> map;

我们可以惊奇的发现这个map居然是一个HashMap类,实际上HashSet底层就是用的就是HashMap,而在add方法里map有两个参数,第一个是我们所要添加的对象,第二个是一个PRESENT,这也是HashSet的一个成员对象,具体定义如下:

1 private static final Object PRESENT = new Object();

那现在我们再来看一下上面的这一行代码的意思:

1   return map.put(e, PRESENT)==null;

这里的map是一个HashMap类的对象,HashMap类对象的一个参数是Key,第二个参数是Value,而我们将第二个参数设为固定,第一个参数来存储我们想要存储的对象,而在HashMap中Key值是不能重复的,也就是保证只会有独一无二的Key,而利用Key值来存储对象的HashSet也就有了不会存储相同对象的特点

 

那我们继续看一下map.put()方法内部:

1  public V put(K key, V value) {
2         return putVal(hash(key), key, value, false, true);
3     }

可以看到put函数内部真正起作用的是putVal方法,putVal有四个参数,第一个参数是根据key值来求一个16位的hash值,第二个则是value值,后两个我们在下面具体讨论。

在看putVal的源码之前,我们需要对HashMap底层的存储结构有一个感官上的认识:图片来源:https://www.cnblogs.com/duodushuduokanbao/p/9492952.html

 

1、这个结构是由一个数组组合链表存储了,链表是为了解决hash冲突(典型的拉链法)

2、在存入数据时首先将利用hashcode方法对加入对象求一个hash值,再利用根据的数组长度对hash进行一个计算(下面会看到具体计算方法),这个计算的结果就是数组的下标,此时将对应的对象存入下标对应的位置,由于数组的长度是有限的,在存入的时候可能会出现数组上已经有元素了(本质是不同对象的hash值求数组下标得到了相同的值,也就是hash冲突),这样我们就在这个数组位置添加一个链表,用于存储两个以上的值,这就是解决hash冲突的拉链法

3、如果冲突的次数过多,也就是同一个数组的位置的链表过长,这时链表的查询速度就会变慢,这里底层就会自动将链表转换为红黑树(一种自适应的二叉平衡树)来提高查询的效率

 了解了基本的存储概念,我们看一下源码:

 

我们来看一下putVal(即在map里面添加元素)的全貌:代码比较长我们直接在代码上进行分析

 1 final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
 2                    boolean evict) {
/**这里是函数的几个参数:
  int hash 是待添加对象的hash值
  key、value分别是需要存储的两个值
  onlyIfAbsent
*/
3 Node<K,V>[] tab; Node<K,V> p; int n, i;
//这里的Node是HashMap的一个内部类,实现了Map.Entry接口,本质上实现是一个k,v的映射关系
4 if ((tab = table) == null || (n = tab.length) == 0) 5 n = (tab = resize()).length;
//这里是判断此时的table数组未初始化或者长度为0,就进行扩容
6 if ((p = tab[i = (n - 1) & hash]) == null) 7 tab[i] = newNode(hash, key, value, null);
//这里是求值应该存储的位置 n是table的长度,(n-1)&hash就可以求出对象应该存储的数组下标(下面会细说为什么)
8 else {
//这里的eles是当当前位置已经存在对象:要分下列几种情况
9 Node<K,V> e; K k; 10 if (p.hash == hash && 11 ((k = p.key) == key || (key != null && key.equals(k)))) 12 e = p;
//情况一,想要存入的key值与已经存入的key值相等,则更新对应的value值
13 else if (p instanceof TreeNode)
//如果此时存入的位置已经树化(链表变成红黑树)
14 e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
//情况二,对象存入到红黑树中
15 else {
//如果为链表
16 for (int binCount = 0; ; ++binCount) {
//到达链表尾部
17 if ((e = p.next) == null) { 18 p.next = newNode(hash, key, value, null);
//情况三,插入到链表尾部
19 if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st 20 treeifyBin(tab, hash); 21 break;
//如果此时的链表节点数量达到阙值(8),则将链表树化,转换为红黑树
22 } 23 if (e.hash == hash && 24 ((k = e.key) == key || (key != null && key.equals(k)))) 25 break; 26 p = e; 27 }
//判断插入元素的key值是否与链表元素相等,相等则覆盖value值
28 } 29 if (e != null) { // existing mapping for key 30 V oldValue = e.value; 31 if (!onlyIfAbsent || oldValue == null) 32 e.value = value; 33 afterNodeAccess(e); 34 return oldValue; 35 }
//这里就是实际key值相等时进行覆盖value的操作
36 } 37 ++modCount;
//操作次数增加
38 if (++size > threshold) 39 resize();
//实际存储对象(数组和链表和红黑树)大于阙值则扩容
40 afterNodeInsertion(evict); 41 return null; 42 }

 

 看完主要源码,可能已经大概清楚了hashmap的存储机制,这里再进行一些总结:

 

1、关于hashmap的扩容机制:

这里扩容分为两个方面,一个是table的扩容(也就是数组的扩容),第二个是链表转为红黑树(严格来算应该是转换)

  先讲第一个方面,链表什么时候转换红黑树:首先第一个条件是链表的长度必须大于等于8,第二个条件是数组的最大长度必须大于等于64,满足这两个条件链表才会树化为红黑树。注意而如果只满足第一个条件,链表不会树化,而是会进行数组的扩容,一次扩容成原来的两倍。

 

  第二个方面数组扩容,数组从0开始第一次默认扩容到16,以后每一次扩容都会扩容至两倍,而触发这种扩容的有两种方式:

  • 第一种就是数组上的数量达到一个阙值,就会执行扩容,而这个阙值就是 此时数组最大的容量*0.75,拿16来举例子,当数组上12个元素(注意:这个12包括数组和链表上的,也就是所有加入map的值)都有值的时候,数组会立刻扩容成 16*2 = 32
  • 第二种前面讲的当链表的长度大于等于8时,如果此时的数组长度小于64,则会进行数组的扩容

 

思考一个问题 为什么扩容因子是0.75,也就是在数组没装满的时候就进行扩容了呢?

  因为在数组快要装满的时候,hash冲突会越来越多,会严重降低查找效率,所以选择在最大容量*0.75的时候进行扩充,降低hash冲突

 

思考第二个问题 为什么每次要扩容两倍,并且初始长度也是2的倍数,数组的长度一直是2的n次方?

   这里就要再说一下(上面原码有),有hash值如何得到在数组中存入的位置,底层用的方法是取余,也就是 hash值 % 数组的最大长度得到数据应该存到那一块空间,而对于java来说,取余运算用运算符的速度比位运算慢,所以底层将其优化为 (数组的最大长度 - 1) & hash,得到的结果是和取余运算是一样的,运算速度会加快

  而为什么数组的长度要保持为2的倍数呢?本质其实也是为了降低hash冲突:我们都知道2的n次方二进制的表示方式就是1后面跟着n个0,而2的n次方减1就是n个1

  那对于(数组的最大长度-1)& hash 我们期望它对于每一个hash值尽可能得出一个独一无二的值,就不会发生hash冲突,那我们来看一个例子:

  当数组的长度为 7 时 (7-1) & 2 转化为2进制就是 110 & 010 = 010

           (7-1)& 3 转化为2进制就是 110 & 011 = 010 发现与上述的结果是一致的,所以会出现hash冲突

  当数组长度为 8 时 (8-1)& 2 转化为2进制就是 111 & 010 = 010

          (8-1)& 3 转化为2进制就是 111 & 011 = 011 与上面的结果不一样,不会出现hash冲突

看了这个例子后,应该会发现当进行与运算时 由于0与上任何数都是0 没有区分度,所以将数组长度设为2的倍数就是想尽量都与1进行相与能减少hash冲突

  

2、hash值怎么来的?

   看一下hashmap的源码:

1  static final int hash(Object key) {
2         int h;
3         return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
4     }

  从上面可以看到 当key的null时hash值为0,也就是如果填入null对象,一定是存在数组的第一个位置。

  当key不为null时,返回的hash值是有key对象的hashCode异或这个值无符号右移16位,问题来了,为什么要进行这样一个运算呢,其实本质上还是为了减少hash冲突,我们回来看一下 (数组长度-1)& hash 这个表达式,通常在起初的时候数组长度不会很大,所以可能会出现这样的情况:

 

0000 0000 0000 0000 0000 0000 0000 1111(数组长度-1)

1001 1010 1100 1111 1100 1101 1100 0000(hash)

此时由于数组长度还很小,所以相与的结果就只与后面四位相关,而如果此时hash后面四位相等就会造成hash冲突,所以为了应对这种情况,采用了异或前16的方式:

 

1001 1010 1100 1110 1100 1101 1100 0000(原本的hashcode值)

0000 0000 0000 0000 1001 1010 1100 1110(hashcode无符号右移16位)

1001 1010 1100 1111 0101 0111 0000 1110 (两者相异或后的结果) 利用这个结果就与用原本的hashcode好很多

  在数组长度还很小的时候,(数组长度-1)& hash ,总会由数组长度的二进制后面几位决定,为了防止hashcode后几位0过多,使用与前16位相与的操作,让hashcode前16位也参与运算,使hash冲突的可能性进一步降低

 

3、还有哪些hash算法

  hash算法往往是尝试将一个大范围的数据放入一个小范围的空间,而这种建立映射关系的算法就称为hash算法,可以节省数据存储的空间

这里额外复习一下字符串hash:

 1 public int hashCode() {
 2     int h = hash;
 3     if (h == 0 && value.length > 0) {
 4         char val[] = value;
 5 
 6         for (int i = 0; i < value.length; i++) {
 7             h = 31 * h + val[i];
 8         }
 9         hash = h;
10     }
11     return h;
12 }

String类中的hashCode计算方法还是比较简单的,就是以31为权,每一位为字符的ASCII值进行运算,用自然溢出来等效取模。

哈希计算公式可以计为s[0]*31^(n-1) + s[1]*31^(n-2) + ... + s[n-1]


那为什么以31为质数呢?
主要是因为31是一个奇质数,所以31*i=32*i-i=(i<<5)-i,这种位移与减法结合的计算相比一般的运算快很多。

 

4、为什么链表元素在达到8之后会变成红黑树

 

问题1: JDK 1.8 中hashmap做了哪些改动?

  • 数组+链表的结构改为数组+链表+红黑树
     
  • 优化了高位运算的hash算法:h^(h>>>16)(前面已经讨论
     
  • 扩容后,元素要么是在原位置,要么是在原位置再移动2次幂的位置,且链表顺序不变。

最后一条是重点,因为最后一条的变动,hashmap在1.8中,不会在出现死循环问题。

 

问题2:在解决hash冲突的时候,为什么不直接用红黑树,而是先使用链表,后面转化为红黑树?

  这就要先解释为什么使用红黑树,红黑树是一种自适应的二叉平衡树,它能自动调整元素的位置,使查找效率稳定在log2 n,其中n是节点的个数(这个可以去了解红黑树的构成),而对于链表而言,任何时候都是 O(n)的查找效率,n越大查找效率越低。

  所以在n较小的时候,红黑色的查找效率与链表的查找效率是相似的,所以直接采用链表的形式,可以省略建立红黑树以及之后红黑树的自我调整的步骤,提高效率,在n较大的时候,就转换为红黑树,提高查找效率。

 

问题3:可以用二叉查找树么?

  红黑树与二叉查找树的区别在于,红黑色可以在添加元素或者取出元素后自动进行调整,使自己平衡,而二叉查找树在建立之初可能是平衡的,但经历过加入元素或者取出元素,很可能就变成了线型结构,查找效率大大降低。

 

问题4:为什么阙值是8

  这个得问写代码的人,一定是经过精密的计算,在8树化带来的利益最大

 

问题5:树什么时候退化成链表

  当树的元素变为6的时候退化成链表,为什么不是7,是因为如果设置成7,这个位置频繁的加入/取出的话,就会造成频繁的树化和退化,造成严重的资源浪费,所以设置成6,给了一定的缓冲,防止这样的情况发生。

 

5、HashMap的并发问题?

 

此题可以组成如下连环炮来问

  • HashMap在并发编程环境下有什么问题啊?
  • 在jdk1.8中还有这些问题么?
  • 你一般怎么解决这些问题的?

HashMap在并发编程环境下有什么问题啊?

  • (1)多线程扩容,引起的死循环问题
  • (2)多线程put的时候可能导致元素丢失
  • (3)put非null元素后get出来的却是null

在jdk1.8中还有这些问题么?

在jdk1.8中,死循环问题已经解决。其他两个问题还是存在。

你一般怎么解决这些问题的?

比如ConcurrentHashmap,Hashtable等线程安全等集合类。

 

6、一般用什么作为hashmap的key值

 

问题1: key值可以为NULL吗?

 这里看一下源码:

 

1  static final int hash(Object key) {
2         int h;
3         return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
4     }

 

可以看到,当key值为null的时候,return的位置就是0,所以如果存储key值为null,位置一定是数组的0号位置,且不能存入多个null

 

问题2: 一般用什么当key值?

  因为key值是决定查找的位置的,所以key值不能变,所以一般用具有不可改变性的类,如String类

  • (1)因为字符串是不可变的,所以在它创建的时候hashcode就被缓存了,不需要重新计算。这就使得字符串很适合作为Map中的键,字符串的处理速度要快过其它的键对象。这就是HashMap中的键往往都使用字符串。
  • (2)因为获取对象的时候要用到equals()和hashCode()方法,那么键对象正确的重写这两个方法是非常重要的,这些类已经很规范的覆写了hashCode()以及equals()方法

 

问题3: 如何自定义一个类作为key值

 

   这里设计到两个问题:一方面是需要重写hashcode()和equals()方法,另外一方面是保证这个类的对象不改变

 

针对问题一,记住下面四个原则即可
(1)两个对象相等,hashcode一定相等
(2)两个对象不等,hashcode不一定不等
(3)hashcode相等,两个对象不一定相等
(4)hashcode不等,两个对象一定不等

 


针对问题二,记住如何写一个不可变类
(1)类添加final修饰符,保证类不被继承。
如果类可以被继承会破坏类的不可变性机制,只要继承类覆盖父类的方法并且继承类可以改变成员变量值,那么一旦子类以父类的形式出现时,不能保证当前类是否可变。

(2)保证所有成员变量必须私有,并且加上final修饰
通过这种方式保证成员变量不可改变。但只做到这一步还不够,因为如果是对象成员变量有可能再外部改变其值。所以第4点弥补这个不足。

(3)不提供改变成员变量的方法,包括setter
避免通过其他接口改变成员变量的值,破坏不可变特性。

(4)通过构造器初始化所有成员,进行深拷贝(deep copy)
如果构造器传入的对象直接赋值给成员变量,还是可以通过对传入对象的修改进而导致改变内部变量的值。例如:

1 public final class ImmutableDemo {  
2     private final int[] myArray;  
3     public ImmutableDemo(int[] array) {  
4         this.myArray = array; // wrong  
5     }  
6 }

 

这种方式不能保证不可变性,myArray和array指向同一块内存地址,用户可以在ImmutableDemo之外通过修改array对象的值来改变myArray内部的值。
为了保证内部的值不被修改,可以采用深度copy来创建一个新内存保存传入的值。正确做法:

1 public final class MyImmutableDemo {  
2     private final int[] myArray;  
3     public MyImmutableDemo(int[] array) {  
4         this.myArray = array.clone();   
5     }   
6 }

(5)在getter方法中,不要直接返回对象本身,而是克隆对象,并返回对象的拷贝
这种做法也是防止对象外泄,防止通过getter获得内部可变成员对象后对成员变量直接操作,导致成员变量发生改变。

 

参考:

http://rjzheng.cnblogs.com/

《Java编程思想》

https://blog.csdn.net/moneywenxue/article/details/110457302

 

 

 

 

  

posted @ 2021-12-25 19:36  空心小木头  阅读(327)  评论(0编辑  收藏  举报