Java面试系列之HashMap大扫盲汇总

PS:整理的稍微有点急,不足之处,望各路道友指正,List相关可以查看前一篇随笔!

HashMap的工作原理是近年来常见的Java面试题,几乎每个Java程序员都知道HashMap,都知道哪里要用HashMap,知道HashTable和HashMap之间的区别,那么为何这道面试题如此特殊呢?是因为这道题考察的深度很深,关于HashMap的相关题目经常出现在java各层次(低级、中级、中高级或高级)面试中,甚至有些公司会要求你实现HashMap来考察你的编程能力。ConcurrentHashMap和其它同步集合的引入让这道题变得更加复杂!说到HashMap,在这里首先得了解哈希表的结构:
看过了哈希表的结构之后,我们再回到HashMap,HashMap是基于哈希表实现的Map,所以我们首先要了解到,HashMap里面实现一个静态内部类Entry,其重要的属性有 key , value, next,从属性key,value我们就能很明显的看出来Entry就是HashMap键值对实现的一个基础bean,我们上面说到HashMap的基础就是一个线性数组,这个数组就是Entry[],Map里面的内容都保存在Entry[]里面。好了,摘来一些前面的话,接下来我们来回到HashMap面试题的这个问题上来!

1、“你用过HashMap吗?”

答:在这里,相信几乎所有人的回答都会是 yes!

2、 “HashMap的数据结构?”“什么是HashMap?你为什么用到它?”

答:查看百科,我们得知,HashMap是基于哈希表的 Map 接口的实现。此实现提供所有可选的映射操作,并允许使用 null 值和 null 键(除了非同步和允许使用 null 之外,HashMap 类与 Hashtable 大致相同)此类不保证映射的顺序,特别是它不保证该顺序恒久不变!好,说到这,那么你为何使用HashMap呢,相信大多数人在这里都会回答HashMap的一些特性,诸如上面提到的HashMap可以允许null键值和value(这里切记,HashMap的key为null的情况只能出现一个,而value为null可以有多个),而hashtable不能;HashMap是非synchronized(非线程安全);HashMap很快;以及HashMap存储的是键值对等等(hashtable线程安全)。是的,回答上面这些关于HashMap和hashtable之间的区别就基本上够了,为什么用到,无外乎就是因为他提供了一些hashtable所没有的而已,好的,到这里,已经能够显示出你已经用过HashMap,而且对它相当的熟悉了。那么好了,说了这么多,HashMap的数据结构又是怎样的呢?相信不少人对这个问题并没有深入的了解,要知道,在java编程语言中,最基本的结构就是两种,一个是数组,另外一个是模拟指针(引用),所有的数据结构都可以用这两个基本结构来构造,HashMap也不例外。Hashmap实际上是一个数组和链表的结合体(在数据结构中,一般称之为“链表散列“)。

当我们往HashMap中put元素的时候,先根据key的hash值得到这个元素在数组中的位置(即下标),然后就可以把这个元素放到对应的位置中了。如果这个元素所在的位子上已经存放有其他元素了,那么在同一个位子上的元素将以链表的形式存放,新加入的放在链头,最先加入的放在链尾。从HashMap中get元素时,首先计算key的hashcode,找到数组中对应位置的某一元素,然后通过key的equals方法在对应位置的链表中找到需要的元素。从这里我们可以想象得到,如果每个位置上的链表只有一个元素,那么HashMap的get效率将是最高的。。。。

3、“你知道HashMap的工作原理吗?” “你知道HashMap的get()方法的工作原理吗?”

答:其实就这个问题,在上一题中已经有了简单的涉猎,好了,这里,我们大致可以做出这样的回答,HashMap是基于hashing的原理,我们使用put(key, value)存储对象到HashMap中,使用get(key)从HashMap中获取对象(value)。当我们给put()方法传递键和值时,我们先对键调用hashCode()方法,返回的hashCode用于找到bucket位置来储存Entry对象。这里关键点在于指出,HashMap是在bucket中储存键对象和值对象,作为Map.Entry。嗯,如此回答,基本上算得上相当正确了,也显示出面试者确实知道hashing以及HashMap的工作原理,那么接下来我们还需要知道的是HashMap与别的Map之间的区别以及一些涉及场景的问题了!

嗯,既然说到HashMap的get()方法,那么我在这里顺带就拿来put()和get()方法的源代码分析分析:

首先看看put()方法的实现,

public V put(K key, V value) {
        if (key == null)
            return putForNullKey(value); //null总是放在数组的第一个链表中
        int hash = hash(key.hashCode());
        int i = indexFor(hash, table.length);
        //遍历链表
        for (Entry e = table[i]; e != null; e = e.next) {
            Object k;
            //如果key在链表中已存在,则替换为新value
            if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                return oldValue;
            }
        }
        modCount++;
        addEntry(hash, key, value, i);
        return null;
    }

void addEntry(int hash, K key, V value, int bucketIndex) {
    Entry e = table[bucketIndex];
    table[bucketIndex] = new Entry(hash, key, value, e); //参数e, 是Entry.next
    //如果size超过threshold,则扩充table大小。再散列
    if (size++ >= threshold)
            resize(2 * table.length);
}

  再看看get()方法:

public V get(Object key) {
        if (key == null)
            return getForNullKey();
        int hash = hash(key.hashCode());
        //先定位到数组元素,再遍历该元素处的链表
        for (Entry 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;
}

  上面的两个方法都是常规的时候,相信用过HashMap的小伙伴都知道HashMap可以存储null键值对,我们知道,null key总是存放在Entry[]数组第一个元素,好,接下来我们来看看,HashMap中是如何存取null键值对的:

private V putForNullKey(V value) {
       for (Entry e = table[0]; e != null; e = e.next) {
           if (e.key == null) {
               V oldValue = e.value;
               e.value = value;
               e.recordAccess(this);
               return oldValue;
           }
       }
       modCount++;
       addEntry(0, null, value, 0);
       return null;
   }

   private V getForNullKey() {
       for (Entry e = table[0]; e != null; e = e.next) {
           if (e.key == null)
               return e.value;
       }
       return null;
   }

  根据源代码我可知,在HashMap中,null可以作为键,这样的键只有一个;可以有一个或多个键所对应的值为null。当get()方法返回null值时,即可以表示HashMap中没有该键,也可以表示该键所对应的值为null。因此,在HashMap中不能由get()方法来判断HashMap中是否存在某个键,而应该用containsKey()方法来判断。

4、“HashMap Hashtable LinkedHashMap 和TreeMap?”

答:首先我们知道,java为数据结构中的映射定义了一个接口java.util.Map;它有四个实现类,分别是HashMap Hashtable LinkedHashMap 和TreeMap.

Map主要用于存储健值对,根据键得到值,因此不允许键重复(重复了覆盖了),但允许值重复!

Hashmap 是一个最常用的Map,它根据键的HashCode值存储数据,根据键可以直接获取它的值,具有很快的访问速度,遍历时,取得数据的顺序是完全随机的。 HashMap最多只允许一条记录的键为Null,允许多条记录的值为 Null;HashMap不支持线程的同步,即任一时刻可以有多个线程同时写HashMap,可能会导致数据的不一致。如果需要同步,可以用 Collections的synchronizedMap方法使HashMap具有同步的能力,或者使用ConcurrentHashMap。

Hashtable与 HashMap类似,它继承自Dictionary类,不同的是:它不允许记录的键或者值为空;它支持线程的同步,即任一时刻只有一个线程能写Hashtable,因此也导致了 Hashtable在写入时会比较慢。(主要区别就是以上两点是相反的,HashMap进一步改进了)

LinkedHashMap 是HashMap的一个子类,保存了记录的插入顺序,在用Iterator遍历LinkedHashMap时,先得到的记录肯定是先插入的。也可以在构造时用带参数,按照应用次数排序。在遍历的时候会比HashMap慢,不过有种情况例外,当HashMap容量很大,实际数据较少时,遍历起来可能会比 LinkedHashMap慢,因为LinkedHashMap的遍历速度只和实际数据有关,和容量无关,而HashMap的遍历速度和他的容量有关。

TreeMap实现了SortMap接口,能够把它保存的记录根据键排序,默认是按键值的升序排序,也可以指定排序的比较器,当用Iterator 遍历TreeMap时,得到的记录是排过序的。

一般情况下,我们用的最多的是HashMap,在Map 中插入、删除和定位元素,HashMap 是最好的选择。但如果您要按自然顺序或自定义顺序遍历键,那么TreeMap会更好。如果需要输出的顺序和输入的相同,那么用LinkedHashMap 可以实现,它还可以按读取顺序来排列。

4、“SynchronizedMap和ConcurrentHashMap的区别?”

答:在上面我们已经提到过,HashMap和hashtable之间的一大区别就是是否线程安全,而在上一题也说到过,如果HashMap需要同步,可以使用Collections类中提供的SynchronizedMap方法使HashMap具有同步的能力,或者也可以使用ConcurrentHashMap方法,好,既然我们知道可以用这两个方法实现,那么我们也应该了解到这两个方法的区别所在:

首先来看下java中Collections工具类中的SynchronizedMap方法:

public static  Map synchronizedMap(Map m) {
        return new SynchronizedMap(m);
 }

  该方法返回的是一个SynchronizedMap的实例。SynchronizedMap类是定义在Collections中的一个静态内部类,它实现了Map接口,并对其中的每一个方法实现,通过synchronized关键字进行了同步控制。(PS:hashtable容器就是使用的Synchronized方法进行同步控制来保证线程安全的,效率十分低下)

显而易见,在这个类中,需要对每个方法进行同步控制,当需要迭代时,这种操作效率无疑是十分低下的,所以我们不得不考虑别的方法了,于是就有了更好的选择 ConcurrentHashMap,对于这个具体不赘述,总结如下。

Collections.synchronizedMap()与ConcurrentHashMap主要区别是:Collections.synchronizedMap()和Hashtable一样,实现上在调用map所有方法时,都对整个map进行同步,而ConcurrentHashMap的实现却更加精细,它对map中的所有桶加了锁。所以,只要要有一个线程访问map,其他线程就无法进入map,而如果一个线程在访问ConcurrentHashMap某个桶时,其他线程,仍然可以对map执行某些操作。这样,ConcurrentHashMap在性能以及安全性方面,明显比Collections.synchronizedMap()更加有优势。另外,ConcurrentHashMap必然是个HashMap,而Collections.synchronizedMap()可以接收任意Map实例,实现Map的同步。

4、“在HashMap中,当两个不同的键对象的hashcode相同会发生什么?”

答:这里我们首先要知道的是,HashMap中有hashcode()和equals()两个方法,所以两个对象就算hashcode相同,但是它们可能并不相等,在HashMap的处理中,因为hashcode相同,所以它们的bucket位置相同,‘碰撞’就会发生。因为HashMap使用LinkedList存储对象,这个Entry(包含有键值对的Map.Entry对象)会存储在LinkedList中。【这里提供一个标准的回答,当两个不同的键对象的hashcode相同时,它们会储存在同一个bucket位置的LinkedList中。键对象的equals()方法可以用来找到键值对!】接着这个问题,面试官可能还会更一步问下去,“如果两个键的hashcode相同,你如何获取值对象?” 这里,我们可以尝试这样回答:当我们调用get()方法,HashMap会使用键对象的hashcode找到bucket位置,然后获取值对象。(面试官提醒他如果有两个值对象储存在同一个bucket),我们可以补充回答,在找到bucket位置之后,会调用keys.equals()方法去找到链表(LinkedList)中正确的节点,将会遍历链表直到找到值对象,最终找到要找的值对象!

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

答:首先看看java定义负载因子:

static final float DEFAULT_LOAD_FACTOR = 0.75F;

可以看出,默认的负载因子大小为0.75,也就是说,当一个map填满了75%的bucket时候,和其它集合类(如ArrayList等)一样,将会创建原来HashMap大小的两倍的bucket数组,来重新调整map的大小,并将原来的对象放入新的bucket数组中。这个过程叫作rehashing,因为它调用hash方法找到新的bucket位置。

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

答:(当多线程的情况下,可能产生条件竞争(race condition))

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

最后再摘抄一些网络上比较多的相关题目放在这里,也供自己参考:

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

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

我们可以使用自定义的对象作为键吗? 这是前一个问题的延伸。当然你可能使用任何对象作为键,只要它遵守了equals()和hashCode()方法的定义规则,并且当对象插入到Map中之后将不会再改变了。如果这个自定义对象时不可变的,那么它已经满足了作为键的条件,因为当它创建之后就已经不能改变了。

我们可以使用CocurrentHashMap来代替HashTable吗?这是另外一个很热门的面试题,因为ConcurrentHashMap越来越多人用了。我们知道HashTable是synchronized的,但是ConcurrentHashMap同步性能更好,因为它仅仅根据同步级别对map的一部分进行上锁。ConcurrentHashMap当然可以代替HashTable,但是HashTable提供更强的线程安全性。

**能否让HashMap同步?**HashMap可以通过下面的语句进行同步:

Map m = Collections.synchronizeMap(hashMap);

结束语:关于HashMap的问题当然不是这么一篇小小的随笔和汇总能够说清楚的,更多的相关知识还需要我们不停的在实践中使用,去比较才能发现,这里关于List和Set并没有做过多的涉猎,因为在之前面试的总结中有基本内容的涉及,大家可以稍微借鉴,当然如果要阅读源代码的同道可以自己查看相关源代码即可了,这里最后对HashMap的工作原理稍微做个简单的总结:

HashMap是基于hashing原理的,它是一种数组和链表的结合体,在实现对象的存取时,我们通过put()和get()方法储存和获取对象。当我们将键值对传递给put()方法时,它调用键对象的hashCode()方法来计算hashcode,然后找到bucket位置来储存值对象。当获取对象时,通过键对象的equals()方法找到正确的键值对,然后返回值对象。HashMap使用LinkedList来解决碰撞问题,当发生碰撞了,对象将会储存在LinkedList的下一个节点中。 HashMap在每个LinkedList节点中储存键值对对象。

在最后再补充一个问题:

什么是hash,什么是碰撞,什么是equals ?

Hash:是一种信息摘要算法,它还叫做哈希,或者散列。我们平时使用的MD5,SHA1都属于Hash算法,通过输入key进行Hash计算,就可以获取key的HashCode(),比如我们通过校验MD5来验证文件的完整性。

碰撞:好的Hash算法可以出计算几乎出独一无二的HashCode,如果出现了重复的hashCode,就称作碰撞;就算是MD5这样优秀的算法也会发生碰撞,即两个不同的key也有可能生成相同的MD5。

HashCode,它是一个本地方法,实质就是地址取样运算;

==是用于比较指针是否在同一个地址;

equals与==是相同的。

如何减少碰撞?

使用不可变的、声明作final的对象,并且采用合适的equals()和hashCode()方法的话,将会减少碰撞的发生,提高效率。不可变性使得能够缓存不同键的hashcode,这将提高整个获取对象的速度,使用String,Interger这样的wrapper类作为键是非常好的选择

你可能感兴趣的:(Java菜鸟面试突破系列)