面试之HashMap(一)

大家好!我是CSRobot,从今天开始,我将会发布一些技术文章,内容就是结合春招以来的面试所遇到的问题进行分享,首先会对知识点进行一个探讨和整理,在最后会给出一些面试题并作出解答,希望可以帮助到大家!今天知识点是HashMap,这个知识点会分为两个部分,会分JDK1.7和1.8两个版本进行讲解,今天会分从以下几个方面进行探讨:

  • 哈希表概述
  • jdk1.7下HashMap重要属性
  • 构造方法
  • put过程
  • 扩容死循环问题
  • 面试题分享

一、哈希表

哈希表(HashTable)也叫散列表,是一种非常重要的数据结构,可以运用到非常多的场景业务中,很多的组件都使用了哈希表作为底层的数据结构以提高性能,本文将对JDK7和JDK8中的HashMap实现原理进行讲解,并对其两者进行对比

在讨论哈希表之前,我们首先对其他基础数据结构进行回顾,并了解其新增和查找的性能:

数组:采用连续的存储单元来存储数据 ,支持随机访问,可以通过下标进行查找,时间复杂度为O(1)。

链表: 对于链表的新增,删除等操作,仅需处理结点间的引用即可,时间复杂度为O(1),而查找操作需要遍历链表逐一进行比对,复杂度为O(n)

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

哈希表:哈希表由key-value键值对组成, 所以在哈希表中进行添加,删除,查找等操作,性能十分之高,不考虑哈希冲突的情况下(后面会探讨下哈希冲突的情况),仅需一次定位即可完成,时间复杂度为O(1)

二、HashMap

1、HashMap概述

在这里插入图片描述

一个HashMap的结构如上图所示:由数组+链表组成(在JDK1.8之后增加了红黑树的结构),当往HashMap中添加元素的时候,首先对key值进行hash运算,寻找要插入的位置;如果发生hash冲突,采用链表法扩展;再根据数组位置上的链表,选择具体的插入位置。

public void HashMapTs(){
    Map<String,String> map = new HashMap<>();
    map.put("Jack","Titanic");
    map.put("Rose","Titanic");
    map.put("Kobe","24");
    map.put("Wade","3");

    System.out.println(map.get("Jack"));

}
2、JDK1.7下的HashMap
1)先看几个重要的参数
  • Entry数组,存储HashMap节点,容量为2的幂次方
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
  • 默认初始化数组大小,必须是2的幂次方,原因的话大家可以先思考一下。
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
  • 最大容量,2^30次方
static final int MAXIMUM_CAPACITY = 1 << 30;
  • 初始化默认的装载因子:0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;
  • map中键值对的数量
transient int size;
  • HashMap的阙值,它的值等于capacity * loadfactor,当size大于等于此值的时候,HashMap进行两倍扩容
int threshold;
2)构造方法

HashMap的构造方法有三种,分别是无参构造,带容量的有参构造,带容量和loadfactor的有参构造;而在我们使用场景下一般都是使用无参构造。选择无参构造其实也是调用了默认的容量大小和默认的装载因子

public HashMap() {
     this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
}

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;
    //阙值初始化等于默认容量大小,在HashMap初始化时重新计算
    threshold = initialCapacity;
    init();
}
3)put过程
  • 如果Table为空,初始化HashMap
  • 如果key为null,插入key为null的节点,也说明HashMap可以插入key为null的节点
  • 计算key的hash值,使用hash值与数组容量,计算得到插入元素的数组下标i
  • 如果在table[i]为null,那么直接将该节点插入到table[i]位置
  • 如果table[i]位置不为null,那么遍历table[i]上的链表,如果链表上有该节点的key的重复值,那么使用新节点替换旧节点,并返回旧节点的value;否则使用头插法将新节点插入table[i]的链表上
public V put(K key, V value) {
    //当table为空时,初始化HashMap,具体见源码①
    if (table == EMPTY_TABLE) {
        inflateTable(threshold);
    }
    //如果key为null,插入节点
    if (key == null)
        return putForNullKey(value);
    //计算key的hash值,具体见源码②
    int hash = hash(key);
    //计算插入的位置,具体见源码③
    int i = indexFor(hash, table.length);
    for (Entry<K,V> e = table[i]; e != null; e = e.next) {
        Object k;
        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;
}
  • 源码①—inflateTable(),初始化HashMap

    此方法用户HashMap的初始化,其中比较重要的方法为roundUpToPowerOf2(toSize),此方法会返回toSize的最小的2的次方的数,例如当toSize=10,那么大于10的最小2次方数就为16;当num=17,返回32。这么做的目的一是保证table的容量始终是2的幂次方,其二是保证了table容量的足够大小,而不频繁扩容。

    private void inflateTable(int toSize) {
        // 传入toSize,返回大于toSize的最小的2的次方的数
        int capacity = roundUpToPowerOf2(toSize);
    
        threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
        table = new Entry[capacity];
        initHashSeedAsNeeded(capacity);
    }
    
    private static int roundUpToPowerOf2(int number) {
        // assert number >= 0 : "number must be non-negative";
        return number >= MAXIMUM_CAPACITY
            ? MAXIMUM_CAPACITY
            : (number > 1) ? Integer.highestOneBit((number - 1) << 1) : 1;
    }
    
  • 源码②—hash(key),计算key值的hash

在JDK1.7中,计算key值的hashcode比较复杂,对每次的hashcoude进行异或运算,保证每次的hashcode散列性;同时对hashcode进行多次右移位运算,进程容错处理,保证hashcode的散列性防止每次put的key值都在table数组的一条链上。

final int hash(Object k) {
    int h = hashSeed;
    if (0 != h && k instanceof String) {
        return sun.misc.Hashing.stringHash32((String) k);
    }

    h ^= k.hashCode();

    // This function ensures that hashCodes that differ only by
    // constant multiples at each bit position have a bounded
    // number of collisions (approximately 8 at default load factor).
    h ^= (h >>> 20) ^ (h >>> 12);
    return h ^ (h >>> 7) ^ (h >>> 4);
}
  • 源码 ③ —indexFor

大家可以思考一下为什么table容量必须是2的幂次方?为什么计算index需要length-1?

static int indexFor(int h, int length) {
	return h & (length-1);
}
  • 源码④—addEntry

当插入新节点的时候,需要判断是否需要扩容,当size大于threshold阙值并且当前插入链表不为空时进行两倍扩容。

void addEntry(int hash, K key, V value, int bucketIndex) {
    if ((size >= threshold) && (null != table[bucketIndex])) {
        //resize扩容方法,具体见源码⑤
        resize(2 * table.length);
        hash = (null != key) ? hash(key) : 0;
        bucketIndex = indexFor(hash, table.length);
    }

    createEntry(hash, key, value, bucketIndex);
}
//插入新节点,头插法
void createEntry(int hash, K key, V value, int bucketIndex) {
    Entry<K,V> e = table[bucketIndex];
    table[bucketIndex] = new Entry<>(hash, key, value, e);
    size++;
}
  • 源码⑤—HashMap扩容
    • 首先判断hashMap中的节点个数是否大于threshold阙值并且要插入数组节点的位置是否为空,如果满足条件进行扩容
    • 创建一个新的数组,容量是原来数组的两倍,按原来数组的两倍进行扩容
    • 将原来的链表节点倒序转移到新的数组中
    • rehash后新的index为oldCap+index
    • 同时更新threshold = newCapaticy*loadfactory
void resize(int newCapacity) {
    Entry[] oldTable = table;
    int oldCapacity = oldTable.length;
    if (oldCapacity == MAXIMUM_CAPACITY) {
        threshold = Integer.MAX_VALUE;
        return;
    }
	//新建一个两倍数组的Table
    Entry[] newTable = new Entry[newCapacity];
    //将元素转移到新的两倍数组中
    transfer(newTable, initHashSeedAsNeeded(newCapacity));
    table = newTable;
    //更新threshold阙值
    threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}

void transfer(Entry[] newTable, boolean rehash) {
    int newCapacity = newTable.length;
    for (Entry<K,V> e : table) {
        while(null != e) {
            Entry<K,V> next = e.next;
            if (rehash) {
                e.hash = null == e.key ? 0 : hash(e.key);
            }
            //重新计算hashcode
            int i = indexFor(e.hash, newCapacity);
            //将原来的链表节点倒序转移到新的数组中
            e.next = newTable[i];
            newTable[i] = e;
            e = next;
        }
    }
}
4)JDK1.7下HashMap扩容死循环的问题
  • 一个正常的HashMap如下:

在这里插入图片描述

  • 此时进行HashMap进行扩容,根据上文transfer的代码,我们使用e定位,其扩容的步骤如下所示:
    在这里插入图片描述

  • 在多线程下使用HashMap,如果进行扩容,那么将有可能发生死循环的问题

假设有两个线程同时进行HashMap的扩容,那么假设当前线程二已经扩容完成,线程一才刚刚开始初始化一个两倍数组进行扩容,假设线程1此时刚刚进入while循环

while(null != e) {
    Entry<K,V> next = e.next;
    if (rehash) {
    e.hash = null == e.key ? 0 : hash(e.key);
}

那么此时线程一的的e和next则有可能直接指向线程二的key(2)和key(3),如下图所示:

在这里插入图片描述

这个时候线程一接着执行,首先执行了 newTalbe[i] = e;

然后是e = next,导致了e指向了key(3)

而下一次循环的next = e.next导致了next指向了key(2)

如下图所示:
在这里插入图片描述

接着线程一使用头插法,将key(3)放在当前链表的首位, 然后把e和next往下移

这时候,原来的线程二里面的key3的e和key2的next没了,e=key2,next=null

在这里插入图片描述

此时线程一下一步扩容又需要把e节点头插到table[3]位置,此时就形成了环形链表:

在这里插入图片描述
所以总结来说,就是多线程环境下对于HashMap的扩容,e和next的情况影响到了其他线程的e和next链,所以导致了扩容时候的死循环问题。

三、总结

  • 初始容量16

  • 最大容量2^30

  • 默认装载因子loadfactor 0.75

  • threshold这个成员变量是阈值,它的值为capacity * loadfactor,当size>thresholdHashMap两倍扩容

  • 底层是数组+链表

  • 并发扩容使用transfer()方法时容易发生死锁

  • 当插入一个新值,put方法为头插法

四、面试题

1、HashMap中计算桶位置为什么不直接取余?

这个问题比较简单,HashMap中计算桶位置是使用hashcode和(容量-1)做与运算,二进制的位运算的效率远远大于取余运算。

2、为什么HashMap的容量都是2的幂次方?以及为什么计算桶位置的时候使用hash与length-1?

其实HashMap容量是2的幂次方和计算桶位置的息息相关的,当数组容量是2的幂次方,那么length-1的二进制就一定是“1111”的形式。

例如以32容量的HashMap为例,

32的二进制为:100000

length-1的二进制为:11111

那么使用以上那个值和hashcode进行与运算的结果更散列?那么很明显就是全是1的值进行位运算更加散列,HashMap中更不容易发生hash冲突。

假设容量不是2的幂次方,那么假设当length=33的时候,

length-1 = 100000,那么此时不论你的hashcode计算出来是多少,

制的位运算的效率远远大于取余运算。

2、为什么HashMap的容量都是2的幂次方?以及为什么计算桶位置的时候使用hash与length-1?

其实HashMap容量是2的幂次方和计算桶位置的息息相关的,当数组容量是2的幂次方,那么length-1的二进制就一定是“1111”的形式。

例如以32容量的HashMap为例,

32的二进制为:100000

length-1的二进制为:11111

那么使用以上那个值和hashcode进行与运算的结果更散列?那么很明显就是全是1的值进行位运算更加散列,HashMap中更不容易发生hash冲突。

假设容量不是2的幂次方,那么假设当length=33的时候,

length-1 = 100000,那么此时不论你的hashcode计算出来是多少,

使用hashcode & (length-1)的结果都只能是0或是32,那么就不能达到散列的目的,put的节点全部都插入到了0位置和最后一个位置。

在这里插入图片描述
在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值