A股上市公司传智教育(股票代码 003032)旗下技术交流社区北京昌平校区

 找回密码
 加入黑马

QQ登录

只需一步,快速开始

本帖最后由 妖精的尾巴 于 2018-5-1 23:29 编辑

很多小伙伴在面试时发现,Hashmap几乎成了面试必问内容。部分兄弟可能提前有所准备,还能应付一二,如果从来没有关注过,那可能会被面试官一套Hashmap组合拳直接打蒙。那么,这个我们日常写代码用的非常多的东西,到底有什么值得大家都来问呢?
1.什么是Hashmap
Hashmap是Java里Map接口的一个实现类,它提供了key-value的存储方式。JDK1.7及以前的版本,通过对put的key做hash运算的方法提高了插入和查找性能。1.8以后则由红黑树做了改写。我们这里仅针对1.7版本的Hashmap来做一些分析和讨论。
第一个问题,为什么要有HashMap?Map这个东西和List比,为什么会快呢?
在回答这个问题前,首先我们来聊一下数组和链表。
数组,众所周知,在内存中是一个连续存储的区域。如
int a[] = new int[10]
一般情况下,我们认为,这句Java代码会在内存中创建一块连续的区域,它的大小是10个int的长度。基于这个特点,数组在访问指定元素时,可以通过下标快速计算出元素在内存中的位置,然后获取元素的值。所以,数组获取指定元素的性能是相当不错的。但是,它的结构也有着先天性的缺点。如果我们要删除某一个指定元素,如 1,2,3,4,5,6,7,8,9,10。如果我们删除了2,那么,你可能要把3,4,5,6,7,8,9,10这些元素迁移来补空位。增加元素也是同理。
链表的实现,通常我们认为是使用指针来做,即第一个元素持有第二个元素的指针,第二个元素持有第三个元素的指针。如果用Java代码来实现,大体如下:
public class Node {            Object value;         
  Node next;            
public Node(Object value) {               
  this.value = value;            }           
public static void main(String[] args) {               
Node a = new Node("a");              
  Node b = new Node("b");               
Node c = new Node("c");               
a.next = b;               
b.next = c;               
System.out.println(a.next.next == c);           
}      
}
由上面的代码我们不难看出,链表的特点在于内存不连续,可以通过节点间的指针操作,来快速的实现元素的插入,删除,例如下面这段代码:
        Node d = new Node("d");      
Node temp = b.next;      
b.next = d;      
d.next = temp;
非常简单的实现了新节点d插入到b和c之间的操作。
尽管链表和数组各有千秋,但是他们对于查找指定元素都没有什么特别好的办法,很多时候可能还是需要顺序查找(或者二分查找),依然是O(n)的。
那有没有什么办法,可以让我们再获取指定元素的时候,速度再快一点呢?
什么是Hashmap呢?为什么它能更快呢?
我们定义一个新的结构,它的简易代码如下:
public class MyHashMap {   
Entry table[];   
  class Entry {      
  String key;        
Object value;        
Entry next;    }}
在这里为了简化演示效果,我们把key定义为String类型。可以发现,我们先定义了一个链表节点的类,然后在MyHashMap里维护了一个链表的数组。如果用图形展示,它的结构如下图:
HashMap,怎么能没有Hash呢?我们来看看如何使用hash算法来做put和get。因为JDK的源码实现有了太多性能优化的地方,对于基础薄弱的同学来说,阅读难度较大,我这里写一个简单的demo,做一个演示:
public class MyHashMap {   
Entry table[] = new Entry[16];   
  class Entry {      
String key;      
Object value;        
Entry next;        
public Entry(String key, Object value) {            
this.key = key;           
this.value = value;      
  }  
  }   
public void put(String key, Object value) {     
   Entry newOne = new Entry(key, value);      
int hash = key.hashCode();        
int index = hash & (table.length - 1);        
Entry oldOne = table[index];        
if (oldOne == null) {         
  table[index] = newOne;      
} else {            
Entry start = oldOne;           
for (Entry curr = start; curr != null; curr = curr.next) {
               if (curr.key.hashCode() == hash && ((key = curr.key) == key || key.equals(key))) {            
        curr.value = value;            
       return;              
}         
  }        
    table[index] = newOne;         
   newOne.next = oldOne;      
}
  }   
public Object get(String key) {     
   int hash = key.hashCode();      
  int index = hash % (table.length - 1);      
  Entry start = table[index];     
   if (start == null) {         
  return null;        } else {         
   for (Entry curr = start; curr != null; curr = curr.next) {              
  if (curr.key.hashCode() == hash && ((key = curr.key) == key || key.equals(key))) {                  
  return curr.value;              
  }           
}      
}      
return null;    }}
从代码中,我们不难看出,在put元素时,我们先对key做了hash计算(这里偷懒用了String的hash),然后做一个按位与的计算,确定key在Entry数组中的位置。
如果该位置没有存放元素,我们可以直接构建元素,存放到这个位置。
但是,如果该位置已经有了元素,即table[index]!=null,我们就有了两种情况,key存在,和key不存在。有的兄弟可能会疑惑,你刚才不是对key做了比较吗?为什么这里又会有存在和不存在两种情况?
hash算法算出的结果是一个数字,假设,我们现在使用的hash算法计算出的结果是16位的,那我我们一个100位的数字,如果做hash计算,就相当于100位被压缩到了16位,肯定会丢失很多精度。如果一匹100位的数字做16位的hash输出,那么很可能回出现几个数字压缩后得到的hashcode是相同的情况,这种现象,我们叫做hash冲突
所以,hashmap实际上,在你put元素的时,很有可能会出现,两个key,他们的hashcode一样,但是两个key的值不一样的情况。
可以通过代码看到,这我们对Entry链表做了一个简单的遍历,逐个比较每个Entry的key和我们put的key是否equals,如果相同,我们用新的value直接覆盖旧的。如果遍历一轮也没有发现雷同的key,我们直接把新的Entry放到数组里,然后把旧的节点,放到Entry.next中,从而维护了链表的连续性。
不难看出,这里Hashmap其实可以简单的想像成一个链表的数组,或者说一个二维数组。二维数组的行坐标是,是根据key的hashcode来确定,然后再横向遍历,确定具体的key到底存在不存。这个结构不难理解,但是工程中的Hashmap有很多值得我们说道的地方。
2.值得挖掘的细节
实际上,你会发现,我们初始化的Entry数组大小是有限的,只有16个位置,这里必然会出现问题。假设,我现在加入1600个元素,最理想的情况下,它也会劣化成16个链表,每个链表有100个节点。查询性能依然非常不乐观。
我们不难想象,如果,每个key进来,都能把他分配到一个全新的数组节点,不维护链表,那么,查找性能无疑是最理想的。但是,这里会面临一个扩容的问题。即,16个元素的数组,当你放入第17个元素时,我们该怎么办?
我们看JDK的源码中,当需要扩容时,它实际上是new了一个新的数组,然后把旧数组的元素逐个拷贝到新数组中。这里就产生了一个新问题,扩容,扩多少?扩大了浪费,扩小了下次还要扩,扩的不合适,位移操作多,都很麻烦。
而Hashmap的实现中,扩容倍数是2倍。我们画个图来尝试理解下,为什么是2倍。假设我现在有一个Hashmap,它的Entry数组只有4个元素,但是我存了8个key进去,那么假设它的分部情况如下图:
假设我们现在扩容一倍,变成8个元素,那么之前的元素可能要重新计算它在数组中的位置。对于1来说,1在4个元素时排序在第一个,它在8个元素时还是排序在第一个。但是对于7来说,它在4个元素时排在第三位,在8个元素时,就需要排在第七位,但是这里有个很巧合的事情,7从3~迁移到7,刚好移动了4个位置,刚好是你扩容一倍的位置。所以,对于扩容2倍的情况来说,所有的元素,它的位置要么是不变,要么是newIndex = oldIndex + size。可以说,是非常的巧妙了。当然,实际上这里的操作是二进制的位运算,这里只是拿十进制举例,帮助部分基础差的兄弟们理解为什么要这样做,具体的实现请以源码为准。
3.看了这么多,这个东西到底有什么鸟用?
实际上,hash散列这个思想,是非常常用的。类比一下,你就会发现很多项目场景上的问题都有可能用到:
nginx负载均衡,现在我们的项目部署了3台服务器a、b、c,一个用户的请求进来后,你要确定,用户的这个请求到底该发送给三台服务器中的哪一台。
有的兄弟第一时间会想到轮询,均匀的把请求分配给3个服务器,但是这里牵扯到一个问题。假设,3台服务器(集群),都各自维护了缓存,那么,你把同一个用户的请求轮询到不同的服务器上,势必会造成缓存的浪费。
如果按照用户ID取模,又有可能产生热点问题。所以,nginx中提供了hash分配的方法。把请求中某个标识计算hash,在取模服务器数量来做分配,即避免了热点问题,也可以保证同一个标识的请求总会被分配给同一个服务器的需求。
可以说,这里是hash碰撞的一个变形应用。
同样的,这个一算法/思想的变形应用,还会出现在缓存分部,数据库分库分表的拆分等场景中。可以说,hash散列是现行code环境下,使用的非常多的一个思路,所以,也不难理解为什么大家都会问你对于Hashmap的了解。
甚至于,这种抽象出hash来加快查找的思想,我们还可以类比到之前在MYSQL的索引分析中的B+tree和Redis随手记中的跳表,从而加深我们对于空间换时间这一思想的理解。
我们学习一门语言的使用,那么它的源码(尤其Java这种自己实现自己的语言)无疑是我们最好的教材,不是说需要你对源码背的滚瓜乱熟,而是说,它里面有很多值得借鉴,学习的地方,可以扩展我们解决业务问题时的思路。
与君共勉。
本文由 董航 创作,采用 知识共享署名4.0 国际许可协议进行许可
此文章除注明转载/出处外,均为http://dongh.net原创或翻译,转载前请务必署名


6 个回复

倒序浏览
诗酒趁年华 来自手机 中级黑马 2018-5-2 00:11:08
沙发
6666666666y6
回复 使用道具 举报
嗯...占个坑
回复 使用道具 举报
( ̄︶ ̄)↗ 涨姿势
回复 使用道具 举报
666
六六六
回复 使用道具 举报
保存学习
回复 使用道具 举报
刘铮 来自手机 中级黑马 2018-5-2 13:20:49
7#
6666666666
回复 使用道具 举报
您需要登录后才可以回帖 登录 | 加入黑马