HashMap HashTable 常见知识,面试知识

2 篇文章 0 订阅
1 篇文章 0 订阅

HashMap与HashTable介绍:

Hashmap:用key value键值对的方式存储数据的,每一个键值对也称为Entry,他们被存储在链表(LinkedList)中。HashMap可以接受null键值和值;HashMap是非synchronized,非同步的(线程不安全),没有同步锁,所以HashMap很快,但是可以使用Map m = Collections.synchronizeMap(hashMap);实现上锁功能;


HashTable:HashTable是个过时的集合类,存在于java API中很久了,在java 4 中被重写,实现了Map接口,所以自此以后也成为Java集合框架的一部分;HashTable是线程安全的,有synchronized,多个线程可以共享一个HashTable,但是由于每个操作都加了锁,导致其(在单线程中)速度比HashMap慢。Java 5中提供了ConcurrentHashMap(文章第7点有解释区别),它是HashTable的替代,比HashTable扩展性更好。


除了上面介绍的一些功能上的区别,他们之间还有如下区别:


1.HashMap的迭代器(Iterator)是fail-fast迭代器,而Hashtable的enumerator迭代器不是fail-fast的。所以当有其它线程改变了HashMap的结构(增加或者移除元素),将会抛出ConcurrentModificationException,但迭代器本身的remove()方法移除元素则不会抛出ConcurrentModificationException异常。但这并不是一个一定发生的行为,要看JVM。这条同样也是Enumeration和Iterator的区别。

2.HashMap可以将null值作为一个表的条目的key或value。Hashtable不可以

3.hashMap去掉了HashTable 的contains方法,但是加上了containsValue()和containsKey()方法。


下面主要介绍HashMap:

前情铺垫:

数组

数组存储区间是连续的,占用内存严重,故空间复杂的很大。但数组的二分查找时间复杂度小,为O(1);数组的特点是:寻址容易,插入和删除困难;


链表

链表存储区间离散,占用内存比较宽松,故空间复杂度很小,但时间复杂度很大,达O(N)。链表的特点是:寻址困难,插入和删除容易。


哈希表(也即是hashmap的存储形式)

那么我们能不能综合两者的特性,做出一种寻址容易,插入删除也容易的数据结构?答案是肯定的,这就是我们要提起的哈希表。哈希表((Hash table)既满足了数据的查找方便,同时不占用太多的内容空间,使用也十分方便。


1.     HashMap的底层实现原理:

真正初始化哈希表(初始化存储数组table)是在第1次添加键值对时,即第1次调用put()


调用put方法时:

每次要存储一个key时,会用一个hash函数算出这个key存储在hashmap的Table数组中的哪个位置(HashMap是用数组table的形式存储数据,这个数组默认初始长度为16,其每个数组的位置bucket又是一个链表,当产生index冲突时就用链表LinkedList存储)。


例如index=hash(“apple”),算出index=5,

index=hash(“orange”),算出index=1,


这时他们就分别存储到table数组中的不同位置上了。


如果出现了index冲突,即另外一个 key=“banana” 经过hash()函数之后出现的index=5与之前插入过的 key=”apple” 的index=5产生了冲突(也可以称为hashCode冲突),则调用equals比较要插入的key与之前插入的key是否一样:


若不一样(equals返回false),则将这个值存储到之前该index=5位置对应的链表中(这是一种解决hash冲突的方式称为链地址法),并将其置于链表头,原来的key=”apple”的entry就被推后一位,为什么这样设计呢?是因为设计者觉得后插入的值被查询的几率更大,位于表头以后查询的速度就快。


若一样(equals返回true),则将key对应的新value覆盖掉原来的value

 

调用get方法时

算出要get的key=”banana”值对应的index位置(使用put中的那个hash()函数),算出以后到这个位置去查询链表,一个一个往下找(遍历)直到找到对应的key(用equals对比key,若相同则算是找到)

 


2.为什么hashmap默认长度为16?

初始长度为16,每次自动扩展或手动初始化的时候,长度必须是2的幂。这是位了服务于从key映射到index的hash算法

其公式为(这里的hashcode()和前面的hash()不一样,前面的hash()函数是一种简写形式)

index =  HashCode(Key) &  (Length -1)   (类似除留余数法形式的hash函数)

 

下面我们以值为“book”的Key来演示整个过程:

1.计算book的hashcode,结果为十进制的3029737,二进制的101110001110101110 1001。

2.假定HashMap长度是默认的16,计算Length-1的结果为十进制的15,二进制的1111。

3.把以上两个结果做与运算,1011100011101011101001 & 1111 = 1001,十进制是9,所以 index=9。

可以说,Hash算法最终得到的index结果,完全取决于Key的Hashcode值的最后几位。

 

如果长度不是16会发生什么情况呢?


如果长度是10,那么&运算就是与10-1=9=>1001进行运算,那么对于hashcode以后后四位为1001与后四位为1111的hashcode值而言,他们的算出的index位置是一样的,这样有些位置就很容易出现index冲突,故长度为16可以合理分配index位置,而扩展时长度得是2的幂则使得 length-1 以后的二进制值为这种类型的值(1111,11111,111111……),从而使得与hashcode 与运算以后的index值分配的更加均匀

 

3、“如果HashMap的大小超过了负载因子(loadfactor)定义的容量,怎么办?”


注:负载因子默认为0.75(也称加载因子)

 
(即 DEFAULT_LOAD_FACTOR = 0.75f), 这是在时间和空间成本上寻求一种折衷。加载因子过高虽然减少了空间开销,但同时也增加了查询成本(在大多数 HashMap 类的操作中,包括 get 和 put 操作,都反映了这一点)。在设置容量时应该考虑到映射中所需的条目数及其加载因子,以便最大限度地减少 resize操作次数。如果容量大于最大条目数除以加载因子,则不会发生 rehash 操作。


当一个map填满了75%的bucket时候(如初始为16,被用了12),和其它集合类(如ArrayList等)一样,将会创建原来HashMap大小的两倍的bucket数组(也就是上面说的2的幂),来重新调整map的大小,并将原来的对象放入新的bucket数组中。这个过程叫作rehashing(resize),因为它调用hash方法找到新的bucket位置。


注意:rehashing对于存储位置为链表的值,他们是先取出链表头的值,然后去存放在新index位置上的链表,这个过程仍然是置于链表头,故如果原来链表中的两个key值重新hash以后的index位置在新链表中的index位置仍然一样,那么他们的位置会被颠倒,即原来在表头的变成在表尾,原来表尾的在表头

 

4、“你了解重新调整HashMap大小存在什么问题吗?”(转)


  当重新调整HashMap大小的时候,确实存在条件竞争,因为如果两个线程都发现HashMap需要重新调整大小了,它们会同时试着调整大小。在调整大小的过程中,存储在LinkedList中的元素的次序会反过来(如第三点所述),因为移动到新的bucket位置的时候,HashMap并不会将元素放在LinkedList的尾部,而是放在头部,这是为了避免尾部遍历(tailtraversing)。如果条件竞争发生了,那么就死循环了。这个时候,你可以质问面试官,为什么这么奇怪,要在多线程的环境下使用HashMap呢?

 

5.“为什么String, Interger这样的wrapper类适合作为键?”


 String, Interger这样的wrapper类作为HashMap的键是再适合不过了,而且String最为常用。因为String是不可变的,也是final的,而且已经重写了equals()和hashCode()方法了。其他的wrapper类也有这个特点,。不可变性是必要的,因为为了要计算hashCode(),就要防止键值改变,如果键值在放入时和获取时返回不同的hashcode的话,那么就不能从HashMap中找到你想要的对象。所以如果是要用自定义的类对象作为key,不如一个person类,那么这个person类内部必须要重写hashcode、equals方法。不可变性还有其他的优点如线程安全。如果你可以仅仅通过将某个field声明成final就能保证hashCode是不变的,那么请这么做吧。因为获取对象的时候要用到equals()和hashCode()方法,那么键对象正确的重写这两个方法是非常重要的。如果两个不相等的对象返回不同的hashcode的话,那么碰撞的几率就会小些,这样就能提高HashMap的性能。


       上面斜线下划线部分分析解释:参考链接:http://blog.jobbole.com/68096/


       解析:HashMap 存放的是引用类型,我们在外面把 key 更新了,那也就是说 HashMap 里面的 key 也更新了,也就是这个 key 的 hashCode 返回值也会发生变化。这个时候 key 的 hashCode 和HashMap 对于元素的 hashCode 肯定一样,equals也肯定返回true,因为本来就是同一个对象,那为什么不能返回正确的值呢?


参考get方法的源码

public V get(Object key) {

    if (key ==null)

        return getForNullKey();

    int hash = hash(key.hashCode());

    for (Entry<K,V> e = table[indexFor(hash, table.length)];

         e != null;

         e = e.next) {

        Object k;

        if (e.hash == hash && ((k = e.key) == key || key.equals(k)))

            return e.value;

    }

    return null;

}


注意红色字体标注处,虽然key.hashCode返回的值也随着我们更改key属性变化了,但是源码计算hash值的时候是将这个得出的hashCode值再进行hash,由于这个hashcode值相比于原来的hashcode值是变化的了,所以最后算出的hash值肯定也就不同了,即指向了不同的index,故我们调用get方法传入从外面更改过的key,其返回结果为null

 

由上面分析可以引出下面第六点:


6.我们可以使用自定义的对象作为键吗?


这是前一个问题的延伸。当然你可能使用任何对象作为键,只要它遵守了equals()和hashCode()方法的定义规则,并且当对象插入到Map中之后将不会再改变了。如果这个自定义对象是不可变的,那么它已经满足了作为键的条件,因为当它创建之后就已经不能改变了。这里要知道Hashmap存储的是引用对象,如果我们改变key的属性,那么其对应的key的hashcode值也会变化,那么当我们用这个变化了的key去查询时,其hashcode也会变化

 

7.Hashtable和ConcurrentHashMap有什么分别呢?


它们都可以用于多线程的环境,但是当Hashtable的大小增加到一定的时候,性能会急剧下降,因为迭代时需要被锁定很长的时间。因为ConcurrentHashMap引入了分割(segmentation),不论它变得多么大,仅仅需要锁定map的某个部分,而其它的线程不需要等到迭代完成才能访问map。简而言之,在迭代的过程中,ConcurrentHashMap仅仅锁定map的某个部分,而Hashtable则会锁定整个map。

 

8.hashmap的哪种遍历方式比较好:

第一种:利用entrySet()进行遍历

Map map = new HashMap();

  Iterator iter = map.entrySet().iterator();

  while (iter.hasNext()) {

  Map.Entry entry = (Map.Entry) iter.next();

  Object key = entry.getKey();

  Object val = entry.getValue();

  }

  效率高,以后一定要使用此种方式!

 

第二种:利用keySet进行遍历

  Map map = new HashMap();

  Iterator iter = map.keySet().iterator();

  while (iter.hasNext()) {

  Object key = iter.next();

  Object val = map.get(key);

  }

  效率低,以后尽量少使用!

可是为什么第一种比第二种方法效率更高呢?


HashMap这两种遍历方法是分别对keyset及entryset来进行遍历,但是对于keySet其实是遍历了2次,一次是转为iterator,一次就从hashmap中取出key所对于的value。而entryset只是遍历了第一次,它把key和value都放到了entry中,即键值对,所以就快了。

 

以上内容的HashMap为JDK7版本,到了JDK8有如下区别:

1.存储形式为:数组+链表+红黑树(当数组长度大于8时自动转换为红黑树存储)

2.重新扩容HashMap时,若原有红黑树处的entry组少于6个,则将红黑树转换为数组

3.数组的插入形式在JDK7为尾插法,JDK8为头插法

4.扩容后存储位置的计算方式不同,JDK7为全部重新计算,JDK8为原位置(新增参与运算的位为0)或者原位置+旧容量(新增参与运算的位为1)

5.插入数据发现容量不足要扩容时:JDK8为扩容前插入数据,转移数据时统一计算hash位置,JDK7为扩容后插入,再单独计算此数据的hash值,即转移数据时无统一计算

6.由于JDK8转移数据操作 = 按旧链表的正序遍历链表、在新链表的尾部一次插入,所以不会出现链表逆序、倒置的情况,故不容易出现环形链表的情况。但它还是线程不安全的,因为无加同步锁保护。


先总结到这里,以后有遇到hashmap相关的知识点再做更新

第一次做此类总结,文章不足之处,欢迎各位拍砖指示

 

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值