HashMap原理以及面试问题

什么时候开始有HashMap的?

从JDK1.2开始才有HashMap

什么是hash冲突

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

1、HashMap中主要是通过key的hashCode来计算hash值的,只要hashCode相同,计算出来的hash值就一样。如果存储的对象对多了,就有可能不同的对象所算出来的hash值是相同的,这就出现了所谓的hash冲突。解决hash冲突的方法有很多,JDK1.7及以前,采用链表(LinkedList)解决hash冲突;JDK1.8及以后,采用链表+红黑树解决hash冲突。
2、当发生hash冲突时,则将存放在数组中的Entry设置为新值的next(这里要注意的是,比如A和B都hash后都映射到下标i中,之前已经有A了,当map.put(B)时,将B放到下标i中,A则为B的next,所以新值存放在数组中,旧值在新值的链表上)

底层数据结构

1、在JDK1.6,JDK1.7中,HashMap采用位桶+链表实现,即使用链表处理冲突,同一hash值的键值对会被放在同一个位桶里,当桶中元素较多时,通过key值查找的效率较低。
2、而JDK1.8中,HashMap采用位桶+链表+红黑树实现,当链表长度超过阈值(8),时,将链表转换为红黑树,这样大大减少了查找时间。
3、位桶其实也就是一个数组

所以说,一直到JDK7为止,HashMap的结构都是这么简单,基于一个数组以及多个链表的实现,hash值冲突的时候,就将对应节点以链表的形式存储。

Node是单项链表,实现了Map.Entry<K,V>接口,重写以下方法:
getKey();
getValue();
toString();
hashCode();
setValue(V newValue);
equals(Object o); // 判断两个Entry是否相等,若两个Entry的“key”和“value”都相等,则返回true,否则,返回false

public class HashMap<k,v> extends AbstractMap<k,v> implements Map<k,v>, Cloneable, Serializable {  
    private static final long serialVersionUID = 362498820763181265L;  
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16  
    static final int MAXIMUM_CAPACITY = 1 << 30;//最大容量  
    static final float DEFAULT_LOAD_FACTOR = 0.75f;//填充比  
    //当add一个元素到某个位桶,其链表长度达到8时将链表转换为红黑树  
    static final int TREEIFY_THRESHOLD = 8;  
    static final int UNTREEIFY_THRESHOLD = 6;  
    static final int MIN_TREEIFY_CAPACITY = 64;  
    transient Node<k,v>[] table;//存储元素的数组  
    transient Set<map.entry<k,v>> entrySet;  
    transient int size;//存放元素的个数  
    transient int modCount;//被修改的次数fast-fail机制  
    int threshold;//临界值 当实际大小(容量*填充比)超过临界值时,会进行扩容   
    final float loadFactor;//填充比
	
	(......后面略)  

	/**
	 * initialCapacity 初始容量
	 * loadFactor 加载因子
	 */
    public HashMap(int initialCapacity, float loadFactor) {
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: " +initialCapacity);
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: " +
                                               loadFactor);
        this.loadFactor = loadFactor;
        this.threshold = tableSizeFor(initialCapacity);
    }

    
    public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }

    
    public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
    }

    
    public HashMap(Map<? extends K, ? extends V> m) {
        this.loadFactor = DEFAULT_LOAD_FACTOR;
        putMapEntries(m, false);
    }



    /**
     * 链表节点
     */
    static class Node<K,V> implements Map.Entry<K,V> {
	    final int hash;
	    final K key;
	    V value;
	    // 下一个节点
	    Node<K,V> next;
	
	    省略代码...
    }



    /**
     * 红黑树
     */ 
    static final class TreeNode<k,v> extends LinkedHashMap.Entry<k,v> {  
        TreeNode<k,v> parent;  // 父节点  
        TreeNode<k,v> left; //左子树  
        TreeNode<k,v> right;//右子树  
        TreeNode<k,v> prev;    // needed to unlink next upon deletion  
        boolean red;    //颜色属性  
        TreeNode(int hash, K key, V val, Node<k,v> next) {  
            super(hash, key, val, next);  
        }  

        //返回当前节点的根节点  
        final TreeNode<k,v> root() {  
            for (TreeNode<k,v> r = this, p;;) {  
                if ((p = r.parent) == null)  
                    return r;  
                r = p;  
            }  
        }  
    }
}

数据存储(put)

1、JDK1.7及以前是Entry,JDK1.8中Entry的名字变成了Node,原因是和红黑树的实现TreeNode相关联。
2、我们向 HashMap 中所放置的对象实际上是存储在该数组当中,而Map中的key、value则以Entry的形式存放在数组中。而这个Entry应该放在数组哪一个位置上(这个位置通常称为位桶或者hash桶,即hash值相同的Entry会放在同一位置,用链表相连),是通过key的hashCode来计算的。通过hash计算出来的值将会使用indexFor方法找到它应该所在的哈希表下标。
3、JDK1.7以前当向 HashMap 中 put 一对键值时,它会根据 key的 hashCode 值计算出一个位置(一般都为hash值对桶数求模),该位置就是此对象准备往数组中存放的位置。如果该位置没有对象存在,就将此对象直接放进数组当中;如果该位置已经有对象存在了,则顺着此存在的对象的链开始寻找(为了判断是否是否值相同,map不允许有相同的key)。JDK1.8以后,多了一层判断,就是该位置已经有对象存在,会判断当前数组中处理hash冲突的方式为链表还是红黑树(通过instanceof判断第一个节点类型),分别处理。
4、注意:
jdk1.7以前是直接存链表,
jdk1.8以后会先判断类型,如果是红黑树,则采用putTreeVal(this, tab, hash, key, value) 方法,如果不是,则存在对应的链表当中。当然,中间会加一个判断,也就是链表里面的数据(是添加当前数据以后的)如果超过8个,
if (binCount >= TREEIFY_THRESHOLD - 1) {
    // -1 for 1st
    treeifyBin(tab, hash);
    break;
}                           
会执行上面的方法,将链表转换成红黑树。
当然,在方法的最后,会判断当前当前哈希数组的数据是否超过threshold,如果超过就会扩容。

获取数据(get)

JDK1.7及之前从HashMap中get数据的时候,先通过key计算hash值,通过hash值得到链表在哈希数组中的位置,先判断链表中first的key是否与参数key相等,相等就返回,不相等就遍历后面的链表找到相同的key值并返回对用的value值。
JDK1.8及之后,会先判断第一个Node的类型是不是红黑树(TreeNode),如果是红黑树就通过getTreeNode(hash, key)方法返回value,否则就是遍历链表。

加载因子(默认0.75):为什么需要使用加载因子,为什么需要扩容呢?

1、不扩容,数据的查找效率会很低。
2、如果加载因子越大,对空间的利用更充分,但是查找效率会降低(链表长度会越来越长);如果加载因子太小,那么表中的数据将过于稀疏(很多空间还没用,就开始扩容了),对空间造成严重浪费。如果我们在构造方法中不指定,则系统默认加载因子为0.75,这是一个比较理想的值,一般情况下我们是无需修改的。
3、当我们构造hash表示,如果不指名初始值大小,默认大小为16,也就是Node数组的大小是16,如果Node数组中的元素达到
(填充比*Node.length)重新调整HashMap大小,变为原来的两倍,扩容很耗时。

是否存在数据覆盖的情况?

HashMap通过key值得到的index相同,是不会存在数据覆盖的情况的。

因为Entry类里面有一个next属性,作用是指向下一个Entry。打个比方, 第一个键值对A进来,通过计算其key的hash得到的index=0,记做:Entry[0] = A。一会后又进来一个键值对B,通过计算其index也等于0,现在怎么办?HashMap会这样做:B.next = A,Entry[0] = B,如果又进来C,index也等于0,那么C.next = B,Entry[0] = C;这样我们发现index=0的地方其实存取了A,B,C三个键值对,他们通过next这个属性链接在一起。也就是说哈希数组中存储的是最后插入的元素,其它元素都在后面链表。

使用红黑树解决hash冲突的优点?

1、JDK1.8HashMap的红黑树是这样解决的:
如果某个桶中的记录过大的话(当前是TREEIFY_THRESHOLD = 8),HashMap会动态的使用一个专门的treemap实现来替换掉它。这样做的结果会更好,是O(logn),而不是糟糕的O(n)。
2、它是如何工作的?
前面产生冲突的那些KEY对应的记录只是简单的追加到一个链表后面,这些记录只能通过遍历来进行查找。但是超过这个阈值后HashMap开始将列表升级成一个二叉树,使用哈希值作为树的分支变量,如果两个哈希值不等,但指向同一个桶的话,较大的那个会插入到右子树里。如果哈希值相等,HashMap希望key值最好是实现了Comparable接口的,这样它可以按照顺序来进行插入。这对HashMap的key来说并不是必须的,不过如果实现了当然最好。如果没有实现这个接口,在出现严重的哈希碰撞的时候,你就并别指望能获得性能提升了。

HashMap与Hashtable的区别:

1、继承的父类不同 
Hashtable继承自Dictionary类,而HashMap继承自AbstractMap类。但二者都实现了Map接口。 
2、线程安全性不同
Hashtable 线程安全:因为它每个方法中都加入了Synchronize,对整个table加锁 。HashMap是线程不安全的: 
3、key和value是否允许null值 
其中key和value都是对象,并且不能包含重复key,但可以包含重复的value。 HashMap中,null可以作为键,这样的键只有一个;可以有一个或多个键所对应的值为null。当get()方法返回null值时,可能是 HashMap中没有该键,也可能使该键所对应的值为null。因此,在HashMap中不能由get()方法来判断HashMap中是否存在某个键,而应该用containsKey()方法来判断。 
Hashtable中,key和value都不允许出现null值。但是如果在Hashtable中有类似put(null,null)的操作,编译同样可以通过,因为key和value都是Object类型,但运行时会抛出NullPointerException异常,这是JDK的规范规定的。
4、两个遍历方式的内部实现上不同 
HashMap使用 Iterator。 
Hashtable使用Iterator,还使用了Enumeration的方式 。 
5、hash值获取方式不同 
HashTable直接使用对象的hashCode。而HashMap重新计算hash值。
6、内部实现使用的数组初始化和扩容方式不同 
HashTable初始默认容量为11,Hashtable不要求 底层数组的容量一定要为2的整数次幂, Hashtable扩容时,将容量变为原来的2倍加1。 而HashMap初始默认容量为为16,而HashMap则要求一定为2的整数次幂,而HashMap扩容时,将容量变为原来的2倍。

接下来,我们分析下为什么哈希表的容量一定要是2的整数次幂

1、首先,length为2的整数次幂的话,h&(length-1)就相当于对length取模,这样便保证了散列的均匀,同时也提升了效率;
2、其次,length为2的整数次幂的话,为偶数,这样length-1为奇数,奇数的最后一位是1,这样便保证了h&(length-1)的最后一位可能为0,也可能为1(这取决于h的值),即与后的结果可能为偶数,也可能为奇数,这样便可以保证散列的均匀性,而如果length为奇数的话,很明显length-1为偶数,它的最后一位是0,这样h&(length-1)的最后一位肯定为0,即只能为偶数,这样任何hash值都只会被散列到数组的偶数下标位置上,这便浪费了近一半的空间,因此,length取2的整数次幂,是为了使不同hash值发生碰撞的概率较小,这样就能使元素在哈希表中均匀地散列。
3、所以说,当数组长度为2的n次幂的时候,不同的key算得得index相同的几率较小,那么数据在数组上分布就比较均匀,也就是说碰撞的几率小,相对的,查询的时候就不用遍历某个位置上的链表,这样查询效率也就较高了。

面试提问


1)介绍HashMap: 
按照特性来说明一下:储存的是键值对,线程不安全,非Synchronied,储存的比较快,能够接受null。
按照工作原理来叙述一下:Map的put(key,value)来储存元素,通过get(key)来得到value值,通过hash算法来计算hascode值,用hashCode标识Entry在bucket中存储的位置,储存结构就算哈希表。 

2)你知道HashMap的工作原理吗?你知道HashMap的get()方法的工作原理吗?
HashMap是基于hashing的原理,我们使用put(key, value)存储对象到HashMap中,使用get(key)从HashMap中获取对象。 当我们给put()方法传递键和值时,我们先对键调用hashCode()方法,返回的hashCode用于找到bucket位置来储存Entry对象。 这里关键点在于指出,HashMap是在bucket中储存键对象和值对象,作为Map.Entry。 这一点有助于理解获取对象的逻辑。如果你没有意识到这一点,或者错误的认为仅仅只在bucket中存储值的话,你将不会回答如何从HashMap中获取对象的逻辑。这个答案相当的正确,也显示出面试者确实知道hashing以及HashMap的工作原理。

3)两个hashcode相同的时候会发生说明?  
hashcode相同,bucket的位置会相同,也就是说会发生碰撞,哈希表中的结构其实有链表(LinkedList),这种冲突通过将元素储存到LinkedList中,解决碰撞。储存顺序是放在表头。 JDK1.8以后加入红黑树来解决冲突。

4)如果两个键的hashcode相同,如何获取值对象?  
如果两个键的hashcode相同,即找到bucket位置之后,我们通过key.equals()找到链表LinkedList中正确的节点,最终找到要找的值对象。  一些优秀的开发者会指出使用不可变的、声明作final的对象,并且采用合适的equals()和hashCode()方法的话,将会减少碰撞的发生,提高效率。不可变性使得能够缓存不同键的hashcode,这将提高整个获取对象的速度,使用String,Interger这样的wrapper类作为键是非常好的选择。

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

6)重新调整HashMap大小的话会出现什么问题?  
多线程情况下会出现竞争问题,因为你在调节的时候,LinkedList储存是按照顺序储存,调节的时候回将原来最先储存的元素(也就是最下面的)遍历,多线程就好试图重新调整,这个时候就会出现死循环。 当多线程的情况下,可能产生条件竞争(race condition)。 当重新调整HashMap大小的时候,确实存在条件竞争,因为如果两个线程都发现HashMap需要重新调整大小了,它们会同时试着调整大小。在调整大小的过程中,存储在链表中的元素的次序会反过来,因为移动到新的bucket位置的时候,HashMap并不会将元素放在链表的尾部,而是放在头部,这是为了避免尾部遍历(tail traversing)。如果条件竞争发生了,那么就死循环了。

7)HashMap在并发执行put操作,会引起死循环,为什么? 
是因为多线程会导致hashmap的node链表形成环形链表,一旦形成环形链表,node 的next节点永远不为空,就会产生死循环获取node。从而导致CPU利用率接近100%。

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

9)使用CocurrentHashMap代替Hashtable?  
可以,但是Hashtable提供的线程更加安全。 
Hashtable是synchronized的,但是ConcurrentHashMap同步性能更好,因为它仅仅根据同步级别对map的一部分进行上锁。ConcurrentHashMap当然可以代替HashTable,但是HashTable提供更强的线程安全性。 
10)hashing的概念 
散列法(Hashing)或哈希法是一种将字符组成的字符串转换为固定长度(一般是更短长度)的数值或索引值的方法,称为散列法,也叫哈希法。由于通过更短的哈希值比用原始值进行数据库搜索更快,这种方法一般用来在数据库中建立索引并进行搜索,同时还用在各种解密算法中。

11)扩展:为什么equals()方法要重写? 
判断两个对象在逻辑上是否相等,如根据类的成员变量来判断两个类的实例是否相等,而继承Object中的equals方法只能判断两个引用变量是否是同一个对象。这样我们往往需要重写equals()方法。我们向一个没有重复对象的集合中添加元素时,集合中存放的往往是对象,我们需要先判断集合中是否存在已知对象,这样就必须重写equals方法。

12)怎样重写equals()方法?
重写equals方法的注意点: 
1、自反性:对于任何非空引用x,x.equals(x)应该返回true。 
2、对称性:对于任何引用x和y,如果x.equals(y)返回true,那么y.equals(x)也应该返回true。 
3、传递性:对于任何引用x、y和z,如果x.equals(y)返回true,y.equals(z)返回true,那么x.equals(z)也应该返回true。 
4、一致性:如果x和y引用的对象没有发生变化,那么反复调用x.equals(y)应该返回同样的结果。 
5、非空性:对于任意非空引用x,x.equals(null)应该返回false。

感恩生活,保持希望

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值