Java面试----HashMap

HashMap简介

HashMap是基于哈希表实现的,每一个元素是一个key-value对,其内部通过单链表解决冲突问题,容量不足(超过了阀值)时,同样会自动增长。

HashMap是非线程安全的,只是用于单线程环境下,多线程环境下可以采用concurrent并发包下的concurrentHashMap。

HashMap 实现了Serializable接口,因此它支持序列化,实现了Cloneable接口,能被克隆。

HashMap的实现

在JDK1.6,JDK1.7中,HashMap采用位桶+链表实现,即使用链表处理冲突,同一hash值的链表都存储在一个链表里。但是当位于一个桶中的元素较多,即hash值相等的元素较多时,通过key值依次查找的效率较低。而JDK1.8中,HashMap采用位桶+链表+红黑树实现,当链表长度超过阈值(8)时,将链表转换为红黑树,这样大大减少了查找时间。

HashMap的实现原理:
HashMap的主干是一个Entry数组。Entry是HashMap的基本组成单元,每一个Entry包含一个key-value键值对。(其实Map就是保存了两个对象之间的映射关系的一种集合)

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

解决Hash冲突四种方法

HashMap的重要数值

initialCapacity:指明初始的桶的个数;相当于桶数组的大小。可以通过构造参数传参来修改,默认值为16(2的4次方)。最大值为2的30次方。若传入的参数大于2的30次方,在构造函数中会将initialCapacity赋值成2的30次方。

loadFactor:加载因子,代表了hashmap的填充度有多少,默认是0.75。加载因子存在的原因,是因为减缓哈希冲突,如果初始桶为16,等到满16个元素才扩容的话,某些桶里可能有不止一个元素,所以加载因子默认为0.75,也就是说大小为16的HashMap,到了第13个元素就会扩容成32(双倍扩容)。

threshold:阈值
阈值=(initialCapacity*loadFactory),当发生哈希冲突,且HashMap.size大于阈值的时候,需要进行数组扩容,扩容时,需要新建一个长度为之前数组2倍的新数组,然后将当前的Entry数组中的全部元素全部传输过去,扩容后的新的数组长度为之前的2倍。所以扩容相对来说是个耗资源的操作。

size:实际存储的key-value键值对的个数

HashMap的机制

构造函数

HashMap有4个构造器,如果用户没有再构造器中传入initialCapacity和loadFactor这两个参数的话,会使用它们的默认值。initialCapacity默认值为16,loadFactor默认值为0.75。在常规构造器中,没有为数组table分配内存空间(有一个入参指定Map的构造器例外),而是会在执行put操作的时候才真正构建table数组

put()

在put()中,如果table为空数组,那么会调用inflateTable(threshold)此时threshold为initialCapacity,也就是默认为16。来为数组分配存储空间。在inflateTable()中,会保证table的大小一定是2的次幂

table不为空,或创建完数组后,

  • 如果key为null,存储位置为table[0]或table[0]的链表上
  • 否则,使用hash(key)方法得到key的hashcode,再利用indexFor(hashcode , table.length)来获取在table中的实际位置i,如果对应数据已存在,执行覆盖操作,并返回旧value。否则,调用addEntry(hashcode , key , value , i);来新增一个entry。

addEntry()

void addEntry(int hash, K key, V value, int bucketIndex) {
        if ((size >= threshold) && (null != table[bucketIndex])) {
        //当size超过临界阈值threshold,并且即将发生哈希冲突(table[bucketIndex]不为空)时进行扩容
            resize(2 * table.length);
            hash = (null != key) ? hash(key) : 0;
            bucketIndex = indexFor(hash, table.length);
        }

        createEntry(hash, key, value, bucketIndex);
    }

这里就引入了扩容的概念,在说扩容之前,我们先要了解一下,为什么table的长度一定要是2的幂次方

为什么table的长度一定要是2的幂次方

这里就可以提及到indexFor()这个方法了。

/**
     * 返回数组下标
     */
    static int indexFor(int h, int length) {
        return h & (length-1);
    }

可以看到,这里返回的是hashcode与table长度-1的与的结果。

我们来看看如果table数组长度为2的幂次方和不是2的幂次方的时候会有什么不同。

  • table数组长度为2的幂次方(16为例)
    16 - 1 = 15,15的二进制为1111
    当hashCode与1111进行与运算时,相当于hashCode%16(可以自己思考一下为什么)。
    而&运算的效率是高于%运算的。这样性能会更高一些。

  • table数组长度不是2的幂次方(15为例)
    15 - 1 = 14,14的二进制为1110,
    这样,在进行与运算时,最后一位始终为0,也就是说,数组下标为奇数的位置,将永远无法通过indexFor()计算出来,Entry元素也不能存储在这些位置,这对空间是一种极大的浪费。

数组长度保持2的次幂,length-1的低位都为1,会使得获得的数组索引index更加均匀。

如果不是2的次幂,也就是低位不是全为1,如111101此时,如要使得index=21,hashCode的低位部分可以为010101或010110,此时不再具有唯一性了,哈希冲突的几率会变的更大,同时,index对应的这个bit位无论如何不会等于1了,而对应的那些数组位置也就被白白浪费了。

HashMap的扩容机制

构造hash表时,如果不指名初始大小,默认initialCapacity大小为16(初始的桶的个数为16),如当map中包含的Entry的数量大于threshold = loadFactor * capacity(16*0.75 = 12)的时候,且新建的Entry刚好落在一个非空的桶上,此刻触发扩容机制,将其容量扩大为2倍。

扩容很耗时,因为数组长度发生变化,存储位置index = h&(length - 1),index也可能会发生变化,需要重新计算index,对元素进行重新定位,获取新的数组索引位置。

数组长度为2的幂次方在这里有有所帮助,每次扩大两倍,可以看做数组长度左移一位,例如16变32也就是010000变成100000,在indexFor()中,-1操作后,为001111(15)变成011111(31),仅仅只有一位的变化。也就是多出了最左边的那个1,这样在通过h%=&(length - 1)的时候,只要h对应的那个差异位的值为0,就能保证得到的新的数组索引和老数组索引一致(减少了很多元素的重新调换位置)

JDK1.8后对HashMap的性能优化

如果一个数组槽位上链表里的数据过多,即拉链过长时,可能会导致性能下降。于是JDK1.8在JDK1.7的基础上针对增加了红黑树来进行优化。当链表长度超过8时,链表就转换为红黑树了。然后利用红黑树快速增删改查的特点提高HashMap的性能。

为什么String、Integer这样的wrapper类适合作为键?

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

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

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

我们可以使用ConcurrentHashMap来代替HashTable吗?

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

HashMap是否线程安全

首先给出答案:
HashMap是线程不安全的。

为什么?表现在何处
在hashmap进行put或delete操作的时候会调用addEntry()方法。假如A线程和B线程同时对同一个数组位置调用addEntry,两个线程会同时得到现在的头结点,然后A写入新的头结点之后,B也写入新的头结点,那B的写入操作就会覆盖A的写入操作,从而造成A的写入操作丢失。

在addEntry中,如果当加入新的键值对总数量超过门限值的时候,会调用一个resize()函数,该函数会将HashMap的容量扩充两倍,如果多个线程同时检测到超过门限值,就会同时进行resize()操作,各自生成新的数组赋给该map,结果就是只有一个线程生成的新数组会赋值给map,而且如果某些线程已经完成赋值,而某些线程才开始,这时候这些刚开始的线程就会以扩容过的数组作为原始数组,这样也会有问题。

所以:
如果多个线程同时访问一个哈希映射,而其中至少一个线程从结构上修改了该映射,则它必须 保持外部同步。(结构上的修改是指添加或删除一个或多个映射关系的任何操作;仅改变与实例已经包含的键关联的值不是结构上的修改。)这一般通过对自然封装该映射的对象进行同步操作来完成。如果不存在这样的对象,则应该使用 Collections.synchronizedMap 方法来“包装”该映射。最好在创建时完成这一操作,以防止对映射进行意外的非同步访问,如下所示:
Map m = Collections.synchronizedMap(new HashMap(…));

线程安全的Map

  1. Hashtable
    Map<String,Object> hashtable=new Hashtable<String,Object>();这是所有人最先想到的,那为什么它是线程安全的?那就看看它的源码,我们可以看出我们常用的put,get,containsKey等方法都是同步的,所以它是线程安全的

  2. Map<String,Object> synchronizedMap= Collections.synchronizedMap(new Hashtable<String,Object>());
    它其实就是加了一个对象锁,每次操作hashmap都需要先获取这个对象锁,这个对象锁有加了synchronized修饰,锁性能跟hashtable差不多。

3.Map<String,Object> concurrentHashMap=new ConcurrentHashMap<String,Object>();

这个是目前使用最多,而且也是最推荐的一个集合,实现也是比较复杂的一个。我们看源码其实是可以发现里面的线程安全是通过cas+synchronized+volatile来实现的,其中也可看出它的锁是分段锁,所以它的性能相对来说是比较好的。整体实现还是比较复杂的

总结:

HashMap的底层通过位桶(Entry数组)实现,位桶里面存的是链表(1.7以前)或者红黑树(有序,1.8开始) ,其实就是数组加链表(或者红黑树)的格式,通过判断hashCode定位位桶中的下标,通过equals定位目标值在链表中的位置,所以如果你使用的key使用可变类(非final修饰的类),那么你在自定义hashCode和equals的时候一定要注意要满足:如果两个对象equals那么一定要hashCode相同,如果是hashCode相同的话不一定要求equals!所以一般来说不要自定义hashCode和equls,推荐使用不可变类对象做key,比如Integer、String等等。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值