Java面试题之HashMap阿里面试必问知识点,你会吗?

面试官Q1:你用过HashMap,你能跟我说说它的数据结构吗?程序员

HashMap做为一种容器类型,不管你是否了解过其内部的实现原理,它的大名已经频频出如今各类互联网Java面试题中了。从基本的使用角度来讲,它很简单,但从其内部的实现来看,它又并不是想象中那么容易。若是你必定要问了解其内部实现与否对于写程序究竟有多大影响,我不能给出一个确切的答案。可是做为一名合格程序员,对于这种遍地都在谈论的技术不该该不为所动。下面咱们将本身实现一个简易版HashMap,而后经过阅读HashMap的源码逐步来认识HashMap的底层数据结构。面试

 

简易HashMap V1.0版本算法

V1.0版本咱们须要实现Map的几个重要的功能:数组

  • 能够存放键值对安全

  • 能够根据键查找到值数据结构

  • 键不能重复app

 1public class CustomHashMap {
2    CustomEntry[] arr = new CustomEntry[990];
3    int size;
4
5    public void put(Object key, Object value{
6        CustomEntry e = new CustomEntry(key, value);
7        for (int i = 0; i < size; i++) {
8            if (arr[i].key.equals(key)) {
9                //若是有key值相等,直接覆盖value
10                arr[i].value = value;
11                return;
12            }
13        }
14        arr[size++] = e;
15    }
16
17    public Object get(Object key{
18        for (int i = 0; i < size; i++) {
19            if (arr[i].key.equals(key)) {
20                return arr[i].value;
21            }
22        }
23        return null;
24    }
25
26    public boolean containsKey(Object key{
27        for (int i = 0; i < size; i++) {
28            if (arr[i].key.equals(key)) {
29                return true;
30            }
31        }
32        return false;
33    }
34
35    public static void main(String[] args{
36        CustomHashMap map = new CustomHashMap();
37        map.put("k1""v1");
38        map.put("k2""v2");
39        map.put("k2""v4");
40        System.out.println(map.get("k2"));
41    }
42
43}
44
45class CustomEntry {
46    Object key;
47    Object value;
48
49    public CustomEntry(Object key, Object value{
50        super();
51        this.key = key;
52        this.value = value;
53    }
54
55    public Object getKey() {
56        return key;
57    }
58
59    public void setKey(Object key{
60        this.key = key;
61    }
62
63    public Object getValue() {
64        return value;
65    }
66
67    public void setValue(Object value{
68        this.value = value;
69    }
70
71}

上面就是咱们自定义的简单Map实现,能够完成V1.0提出的几个功能点,可是你们有木有发现,这个Map是基于数组实现的,不论是put仍是get方法,每次都要循环去作数据的对比,可想而知效率会很低,如今数组长度只有990,那若是数组的长度很长了,岂不是要循环不少次。既然问题出现了,咱们有没有更好的办法作改进,使得效率提高,答案是确定,下面就是V2.0版本升级。函数

 

简易HashMap V2.0版本性能

V2.0版本须要处理问题以下:this

  • 减小遍历次数,提高存取数据效率

在作改进以前,咱们先思考一下,有没有什么方式能够在咱们放数据的时候,经过一次定位,就能将这个数放到某个位置,而再咱们获取数据的时候,直接经过一次定位就能找到咱们想要的数据,那样咱们就减小了不少迭代遍历次数。

接下来,咱们须要介绍一下哈希表的相关知识

在讨论哈希表以前,咱们先大概了解下其余数据结构在新增,查找等基础操做执行性能

数组:采用一段连续的存储单元来存储数据。对于指定下标的查找,时间复杂度为O(1);经过给定值进行查找,须要遍历数组,逐一比对给定关键字和数组元素,时间复杂度为O(n),固然,对于有序数组,则可采用二分查找,插值查找,斐波那契查找等方式,可将查找复杂度提升为O(logn);对于通常的插入删除操做,涉及到数组元素的移动,其平均复杂度也为O(n)

线性链表:对于链表的新增,删除等操做(在找到指定操做位置后),仅需处理结点间的引用便可,时间复杂度为O(1),而查找操做须要遍历链表逐一进行比对,复杂度为O(n)

二叉树:对一棵相对平衡的有序二叉树,对其进行插入,查找,删除等操做,平均复杂度均为O(logn)。

哈希表:相比上述几种数据结构,在哈希表中进行添加,删除,查找等操做,性能十分之高,不考虑哈希冲突的状况下,仅需一次定位便可完成,时间复杂度为O(1),接下来咱们就来看看哈希表是如何实现达到惊艳的常数阶O(1)的。

咱们知道,数据结构的物理存储结构只有两种:顺序存储结构和链式存储结构(像栈,队列,树,图等是从逻辑结构去抽象的,映射到内存中,也这两种物理组织形式),而在上面咱们提到过,在数组中根据下标查找某个元素,一次定位就能够达到,哈希表利用了这种特性,哈希表的主干就是数组。

好比咱们要新增或查找某个元素,咱们经过把当前元素的关键字 经过某个函数映射到数组中的某个位置,经过数组下标一次定位就可完成操做。

存储位置 = f(关键字)

 

其中,这个函数f通常称为哈希函数,这个函数的设计好坏会直接影响到哈希表的优劣。举个例子,好比咱们要在哈希表中执行插入操做:

查找操做同理,先经过哈希函数计算出实际存储地址,而后从数组中对应地址取出便可。既然思路有了,那咱们继续改进呗!

 1public class CustomHashMap {
2    CustomEntry[] arr = new CustomEntry[999];
3
4    public void put(Object key, Object value{
5        CustomEntry entry = new CustomEntry(key, value);
6        //使用Hash码对999取余数,那么余数的范围确定在0到998之间
7        //你可能也发现了,无论怎么取余数,余数也会有冲突的时候(暂时先不考虑,后面慢慢道来)
8        //至少如今咱们存数据的效率明显提高了,key.hashCode() % 999 相同的key算出来的结果确定是同样的
9        int a = key.hashCode() % 999;
10        arr[a] = entry;
11    }
12
13    public Object get(Object key{
14        //取数的时候也经过一次定位就找到了数据,效率明显获得提高
15        return arr[key.hashCode() % 999].value;
16    }
17
18    public static void main(String[] args{
19        CustomHashMap map = new CustomHashMap();
20        map.put("k1""v1");
21        map.put("k2""v2");
22        System.out.println(map.get("k2"));
23    }
24
25}
26
27class CustomEntry {
28    Object key;
29    Object value;
30
31    public CustomEntry(Object key, Object value{
32        super();
33        this.key = key;
34        this.value = value;
35    }
36
37    public Object getKey() {
38        return key;
39    }
40
41    public void setKey(Object key{
42        this.key = key;
43    }
44
45    public Object getValue() {
46        return value;
47    }
48
49    public void setValue(Object value{
50        this.value = value;
51    }
52}

经过上面的代码,咱们知道余数也有冲突的时候,不同的key计算出相同的地址,那么这个时候咱们又要怎么处理呢?

 

哈希冲突

若是两个不一样的元素,经过哈希函数得出的实际存储地址相同怎么办?也就是说,当咱们对某个元素进行哈希运算,获得一个存储地址,而后要进行插入的时候,发现已经被其余元素占用了,其实这就是所谓的哈希冲突,也叫哈希碰撞。前面咱们提到过,哈希函数的设计相当重要,好的哈希函数会尽量地保证 计算简单和散列地址分布均匀,可是,咱们须要清楚的是,数组是一块连续的固定长度的内存空间,再好的哈希函数也不能保证获得的存储地址绝对不发生冲突。那么哈希冲突如何解决呢?哈希冲突的解决方案有多种:开放定址法(发生冲突,继续寻找下一块未被占用的存储地址),再散列函数法,链地址法,而HashMap便是采用了链地址法,也就是数组+链表的方式

 

经过上面的说明知道,HashMap的底层是基于数组+链表的方式,此时,咱们须要再对V2.0的Map再次升级

 

简易HashMap V3.0版本

V3.0版本须要处理问题以下:

  • 存取数据的结构改进

代码以下:

 1public class CustomHashMap {
2    LinkedList[] arr = new LinkedList[999];
3
4    public void put(Object key, Object value{
5        CustomEntry entry = new CustomEntry(key, value);
6        int a = key.hashCode() % arr.length;
7        if (arr[a] == null) {
8            LinkedList list = new LinkedList();
9            list.add(entry);
10            arr[a] = list;
11        } else {
12            LinkedList list = arr[a];
13            for (int i = 0; i < list.size(); i++) {
14                CustomEntry e = (CustomEntry) list.get(i);
15                if (entry.key.equals(key)) {
16                    e.value = value;// 键值重复须要覆盖
17                    return;
18                }
19            }
20            arr[a].add(entry);
21        }
22    }
23
24    public Object get(Object key{
25        int a = key.hashCode() % arr.length;
26        if (arr[a] != null) {
27            LinkedList list = arr[a];
28            for (int i = 0; i < list.size(); i++) {
29                CustomEntry entry = (CustomEntry) list.get(i);
30                if (entry.key.equals(key)) {
31                    return entry.value;
32                }
33            }
34        }
35        return null;
36    }
37
38    public static void main(String[] args{
39        CustomHashMap map = new CustomHashMap();
40        map.put("k1""v1");
41        map.put("k2""v2");
42        map.put("k2""v3");
43        System.out.println(map.get("k2"));
44    }
45
46}
47
48class CustomEntry {
49    Object key;
50    Object value;
51
52    public CustomEntry(Object key, Object value{
53        super();
54        this.key = key;
55        this.value = value;
56    }
57
58    public Object getKey() {
59        return key;
60    }
61
62    public void setKey(Object key{
63        this.key = key;
64    }
65
66    public Object getValue() {
67        return value;
68    }
69
70    public void setValue(Object value{
71        this.value = value;
72    }
73
74}

最终的数据结构以下:

 

简单来讲,HashMap由数组+链表组成的,数组是HashMap的主体,链表则是主要为了解决哈希冲突而存在的,若是定位到的数组位置不含链表(当前entry的next指向null),那么对于查找,添加等操做很快,仅需一次寻址便可;若是定位到的数组包含链表,对于添加操做,其时间复杂度为O(n),首先遍历链表,存在即覆盖,不然新增;对于查找操做来说,仍需遍历链表,而后经过key对象的equals方法逐一比对查找。因此,性能考虑,HashMap中的链表出现越少,性能才会越好。

 

HashMap源码

从上面的推导过程,咱们逐渐清晰的认识了HashMap的实现原理,下面咱们经过阅读部分源码,来看看HashMap(基于JDK1.7版本)

1transient Entry[] table;  
2
3static class Entry<K,Vimplements Map.Entry<K,V{  
4    final K key;  
5    V value;  
6    Entry<K,V> next;  
7    final int hash;  
8    ...
9}  

 

能够看出,HashMap中维护了一个Entry为元素的table,transient修饰表示不参与序列化。每一个Entry元素存储了指向下一个元素的引用,构成了链表。

put方法实现

 1public V put(K key, V value{  
2    // HashMap容许存放null键和null值。  
3    // 当key为null时,调用putForNullKey方法,将value放置在数组第一个位置。  
4    if (key == null)  
5        return putForNullKey(value);  
6    // 根据key的keyCode从新计算hash值。  
7    int hash = hash(key.hashCode());  
8    // 搜索指定hash值在对应table中的索引。  
9    int i = indexFor(hash, table.length);  
10    // 若是 i 索引处的 Entry 不为 null,经过循环不断遍历 e 元素的下一个元素。  
11    for (Entry<K,V> e = table[i]; e != null; e = e.next) {  
12        Object k;  
13        if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {  
14            V oldValue = e.value;  
15            e.value = value;  
16            e.recordAccess(this);  
17            return oldValue;  
18        }  
19    }  
20    // 若是i索引处的Entry为null,代表此处尚未Entry。  
21    modCount++;  
22    // 将key、value添加到i索引处。  
23    addEntry(hash, key, value, i);  
24    return null;  
25}  

从源码能够看出,大体过程是,当咱们向HashMap中put一个元素时,首先判断key是否为null,不为null则根据key的hashCode,从新得到hash值,根据hash值经过indexFor方法获取元素对应哈希桶的索引,遍历哈希桶中的元素,若是存在元素与key的hash值相同以及key相同,则更新原entry的value值;若是不存在相同的key,则将新元素从头部插入。若是数组该位置上没有元素,就直接将该元素放到此数组中的该位置上。

看一下重hash的方法:

1static int hash(int h) {  
2    h ^= (h >>> 20) ^ (h >>> 12);  
3    return h ^ (h >>> 7) ^ (h >>> 4);  
4}  

此算法加入了高位计算,防止低位不变,高位变化时,形成的hash冲突。在HashMap中,咱们但愿元素尽量的离散均匀的分布到每个hash桶中,所以,这边给出了一个indexFor方法:

1static int indexFor(int h, int length) {  
2    return h & (length-1);  
3}  

这段代码使用 & 运算代替取模(上面咱们本身实现的方式就是取模),效率更高。 

再来看一眼addEntry方法:

 1void addEntry(int hash, K key, V valueint bucketIndex{  
2    // 获取指定 bucketIndex 索引处的 Entry   
3    Entry<K,V> e = table[bucketIndex];  
4    // 将新建立的 Entry 放入 bucketIndex 索引处,并让新的 Entry 指向原来的 Entry  
5    table[bucketIndex] = new Entry<K,V>(hash, key, value, e);  
6    // 若是 Map 中的 key-value 对的数量超过了极限  
7    if (size++ >= threshold)  
8    // 把 table 对象的长度扩充到原来的2倍。  
9        resize(2 * table.length);  
10}   

很明显,这边代码作的事情就是从头插入新元素;若是size超过了阈值threshold,就调用resize方法扩容两倍,至于,为何要扩容成原来的2倍,请参考,此节不是咱们要说的重点。

 

get方法实现

 1public V get(Object key{  
2    if (key == null)  
3        return getForNullKey();  
4    int hash = hash(key.hashCode());  
5    for (Entry<K,V> e = table[indexFor(hash, table.length)];  
6        e != null;  
7        e = e.next) {  
8        Object k;  
9        if (e.hash == hash && ((k = e.key) == key || key.equals(k)))  
10            return e.value;  
11    }  
12    return null;  
13}   

这段代码很容易理解,首先根据key的hashCode计算hash值,根据hash值肯定桶的位置,而后遍历。

 

如今,你们都应该对HashMap的底层结构有了更深入的认识吧,下面笔者对于面试时可能出现的关于HashMap相关的面试题,作了一下梳理,大体以下:

  • 你了解HashMap的底层数据结构吗?(本文已作梳理)

  • 为什么HashMap的数组长度必定是2的次幂?

  • HashMap什么时候扩容以及它的扩容机制?

  • HashMap的键通常使用的String类型,还能够用别的对象吗?

  • HashMap是线程安全的吗,如何实现线程安全?

相关文章
相关标签/搜索