java查找一个数等于一组数中哪些数字相加的和_java技能树4——HashMap

本文详细介绍了HashMap的工作原理,包括哈希算法的作用、哈希表的构建、冲突处理方法(如开放寻址法、再哈希法、链地址法等)以及HashMap的数据结构和扩容机制。特别强调了哈希函数的选择和冲突可能导致的问题,以及HashMap在多线程环境中的线程不安全性。此外,还讨论了HashMap和Hashtable的区别,以及在面试中可能会遇到的相关问题。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

1.HashMap

HashMap是基于哈希表的Map接口实现。此实现提供所有可选的映射操作,并允许使用null值和null键(除了不同步和允许使用null之外,HashMap类与Hashtable大致相同)。此类不保证映射的顺序,特别是不保证该顺序恒久不变。

值得注意的是HashMap不是线程安全的,如果想要线程安全的HashMap,可以通过Collections类的静态方法sychronizedMap获得安全的HashMap;

Map map = Collections.sychronizedMap(new HashMap());

1.1 hash

数组和向量都可以存储对象,但对象的存储位置是随机的,也就是说对象本身与其存储位置之间没有必然的联系。当要查找一个对象时,只能以某种顺序(如顺序查找或二分查找)与各个元素进行比较,当数组或向量中的元素数量很多时,查找的效率会明显的降低。

一种有效的存储方式,是不与其他元素进行比较,一次存取便能得到所需要的记录。这就需要在对象的存储位置和对象的关键属性(设为k)之间建立一个特定的对应关系(设为f),使每个对象与一个唯一的存储位置相对应。在查找时,只要根据待查对象的关键属性k计算f(k)的值即可。如果此对象在集合中,则必定在存储位置f(k)上,因此不需要与集合中的其他元素进行比较。称这种对应关系f为哈希(hash)方法,按照这种思想建立的表为哈希表。

1.1.1 hash算法

hash算法接受任意长度的二进制输入值,对输入值做换算,最终给出固定长度的二进制输出值。MD5就是比较著名的hash算法。

1.1.1.1 hash算法作用

1.1.1.1.1 信息安全领域

可用作加密算法,如文件校验:通过对文件换算,可以得到文件的"数字指纹",下载的任何副本的“数字指纹”只要和官方的一致,就保证了文件未经篡改,如MD5;

1.1.1.1.2 数据结构领域

hash算法可以用作快速查找,如哈希表。

1.1.2 哈希表

1.1.2.1 常用查找方式

1.1.2.1.1 非哈希查找

主要值得是线性表和树结构。这些结构中,记录的相对位置是随机的,和记录的关键字之间不存在确定关系,因此,在结构中查找时需要进行一系列和关键字的比较。这一类查找方法建立在“比较”的基础上。在顺序查找时,比较的结果为“=”与“≠”2种可能;在折半查找、二叉排序树查找和B-树查找时,比较的结果为“<”“=”“>”3种可能。查找的效率依赖于查找过程中所进行的比较次数。

1.1.2.1.2 哈希表

理想的情况是希望不经过任何比较,一次存取便能得到所查记录,那就必须在记录的存储位置和它的关键字之间建立一个确定的关系,使每个关键字和结构中一个唯一的存储位置相对应。因而在查找时,只要根据这个对应关系找到给定值的像。若结构中存在关键字和相等的记录,则必定在的存储位置上,反之在这个位置上没有记录。由此,不需要比较便可直接取得所查记录。在此,我们称这个对应关系为哈希(Hash)函数 ,按这个思想建立的表为哈希表 。

1.1.2.2 什么是哈希表

1.1.2.2.1 哈希函数特点

哈希函数具有如下的特点:

  • 1.灵活。哈希函数是一个映像,因此哈希函数的设定非常灵活,只要使得任何关键字都是由此所得的哈希函数值落在表长允许的范围之内即可。
  • 2.冲突。对不同的关键字可能得到同一哈希地址,这种现象称为冲突(collision);冲突只能尽量地少,而不能完全避免。因为,哈希函数是从关键字集合到地址集合的映像。而通常关键字集合比较大,它的元素包括所有可能的关键字,而地址集合的元素仅为哈希表中的地址值。因此,在实现哈希表这种数据结构的时候不仅要设定一个“好”的哈希函数,而且要设定一种处理冲突的方法。

1.1.2.2.2 哈希表定义

哈希表:根据设定的Hash函数和处理冲突的方法,将一组关键字映象到一个有限的连续的地址集(区间)上,并以关键字在地址集中的映像作为记录在表中的存储位置,这样的表便称为Hash表;哈希表能够实现满足数据的查找方便,同时不占用太多的内容空间,使用方便。

6cbd62da0147fb9fb24186f7b9b89635.png

1.1.2.2.3 哈希表数据存储

如图所示,哈希表是由数组+链表实现,一个长度为16的数组内,每个元素存储的是一个链表的头结点。这些元素通过hash(key)%len,即元素的key的哈希值对数组长度取模得到。如上表,12%16=12,28%16=12,108%16=12,140%16=12。所以12、28、108以及140都存储在数组下标为12的位置.

1.1.2.3 哈希函数

1.1.2.3.1 选用考虑因素

  • hash函数执行时间
  • 关键字长度
  • hash表的大小
  • 关键字的分布情况
  • 记录的查找频率

1.1.2.3.2 常用构造方法

1.1.2.3.2.1 直接寻址法

取k或者k的某个线性函数为hash地址。

特点:由于直接地址法相当于有多少个关键字就必须有多少个相应地址去对应,所以不会产生冲突,也正因为此,所以实际中很少使用这种构造方法。

1.1.2.3.2.2 数字分析法

首先分析待存的一组关键字,比如是一个班级学生的出生年月日,我们发现他们的出生年 大体相同,那么我们肯定不能用他们的年来作为存储地址,这样出现冲突的几率很大;但是,我们发现月日的具体数字差别很大,如果我们用月日来作为Hash地址,则会明显降低冲突几率。因此,数字分析法就是找出关键字的规律,尽可能用差异数据来构造Hash地址 ;

特点:需要提前知道所有可能的关键字,才能分析运用此种方法,所以不太常用。

1.1.2.3.2.3 平方取中法

先求出关键字的平方值,然后按需要取平方值的中间几位作为哈希地址。这是因为:平方后中间几位和关键字中每一位都相关,故不同关键字会以较高的概率产生不同的哈希地址。

特点:常用,英文单词以及某些关键字使用该种方法。

1.1.2.3.2.4 折叠法

将关键字分割成位数相同的几部分(最后一部分位数可以不同),然后取这几部分的叠加和(去除进位)作为散列地址。数位叠加可以有移位叠加和间界叠加两种方法。移位叠加是将分割后的每一部分的最低位对齐,然后相加;间界叠加是从一端向另一端沿分割界来回折叠,然后对齐相加。

1.1.2.3.2.5 随机数法

选择一个随机函数,取关键字的随机函数值作为Hash地址,通常用于关键字长度不同的场合。即

特点:通常,关键字长度不相等时,采用此法构建Hash函数 较为合适。

1.1.2.3.2.6 除留取余法

取关键字被某个不大于Hash表 长m 的数p 除后所得的余数为Hash地址 。

特点:这是最简单也是最常用的Hash函数构造方法。可以直接取模,也可以在平法法、折叠法之后再取模。

值得注意的是,在使用除留取余法时,对p的选择很重要,如果p选的不好会容易产生同义词 。由经验得知:p最好选择不大于表长m的一个质数、或者不包含小于20的质因数的合数。

1.1.2.4 处理冲突

冲突是指由关键字得到的哈希地址为的位置上已存有记录,则“处理冲突”就是为该关键字的记录找到另一个“空”的哈希地址。在处理冲突的过程中可能得到一个地址序列。即在处理哈希地址的冲突时,若得到的另一个哈希地址仍然发生冲突,则再求下一个地址,若仍然冲突,再求,依次类推,直至不发生冲突为止,则为记录在表中的地址。(需要注意此定义不太适合链地址法)

1.1.2.4.1 开放定址法

线性探测再散列,二次探测再散列,伪随机探测再散列

1.1.2.4.2 再哈希法

1.1.2.4.3 链地址法

java中hashmap解决办法就是采用链地址法。

1.1.2.4.4 建立一个公共溢出区

1.2 HashMap数据结构

1.2.1 数组和链表特点

1.2.1.1 数组

数组存储区间是连续的,占用内存严重,故空间复杂度大,但是数组的二分查找时间复杂度小,为O(1),数组的特点是:寻址容易,插入和删除困难。

1.2.1.2 链表

链表存储区间离散,占用内存比较松散,故空间复杂度小,但时间复杂度大为O(N)。链表特点是:寻址困难,但是插入和删除容易。

1.2.2 HashMap数据结构

HashMap底层主要是基于数组和链表实现的,通过计算散列码来决定存储的位置因此可以有相当快的查询速度。HashMap主要是通过key的hashCode来计算hash值,只要hashCode相同,计算出来的hash值就一样。HashMap底层通常通过链表解决hash冲突。

c954f1b028be823ca9cf2df4152f803a.png


图中,紫色部分即代表哈希表,也称为哈希数组,数组的每个元素都是一个单链表的头节点,链表是用来解决冲突的,如果不同的key映射到了数组的同一位置处,就将其放入单链表中。

/** Entry是单向链表。    
 * 它是 “HashMap链式存储法”对应的链表。    
 *它实现了Map.Entry 接口,即实现getKey(), getValue(), setValue(V value), equals(Object o), hashCode()这些函数  
**/  
static class Entry<K,V> implements Map.Entry<K,V> {    
    final K key;    
    V value;    
    // 指向下一个节点    
    Entry<K,V> next;    
    final int hash;    

    // 构造函数。    
    // 输入参数包括"哈希值(h)", "键(k)", "值(v)", "下一节点(n)"    
    Entry(int h, K k, V v, Entry<K,V> n) {    
        value = v;    
        next = n;    
        key = k;    
        hash = h;    
    }    

    public final K getKey() {    
        return key;    
    }    

    public final V getValue() {    
        return value;    
    }    

    public final V setValue(V newValue) {    
        V oldValue = value;    
        value = newValue;    
        return oldValue;    
    }    

    // 判断两个Entry是否相等    
    // 若两个Entry的“key”和“value”都相等,则返回true。    
    // 否则,返回false    
    public final boolean equals(Object o) {    
        if (!(o instanceof Map.Entry))    
            return false;    
        Map.Entry e = (Map.Entry)o;    
        Object k1 = getKey();    
        Object k2 = e.getKey();    
        if (k1 == k2 || (k1 != null && k1.equals(k2))) {    
            Object v1 = getValue();    
            Object v2 = e.getValue();    
            if (v1 == v2 || (v1 != null && v1.equals(v2)))    
                return true;    
        }    
        return false;    
    }    

    // 实现hashCode()    
    public final int hashCode() {    
        return (key==null   ? 0 : key.hashCode()) ^    
               (value==null ? 0 : value.hashCode());    
    }    

    public final String toString() {    
        return getKey() + "=" + getValue();    
    }    

    // 当向HashMap中添加元素时,绘调用recordAccess()。    
    // 这里不做任何处理    
    void recordAccess(HashMap<K,V> m) {    
    }    

    // 当从HashMap中删除元素时,绘调用recordRemoval()。    
    // 这里不做任何处理    
    void recordRemoval(HashMap<K,V> m) {    
    }    
}

HashMap其实就是一个Entry数组,Entry对象中包含了键和值,其中next也是一个Entry对象,它就是用来处理hash冲突的,形成一个链表。

1.2.3 HashMap原理

hashmap基于hash table(哈希表)。哈希表最大的优点,就是把数据的存储和查找消耗的时间大大降低,几乎可以看成是常数时间;而代价仅仅是消耗比较多的内存。然而在当前可利用内存越来越多的情况下,用空间换时间的做法是值得的。另外,编码比较容易也是它的特点之一。

其基本原理是:使用一个下标范围比较大的数组来存储元素。可以设计一个函数(哈希函数,也叫做散列函数),使得每个元素的关键字都与一个函数值(即数组下标,hash值)相对应,于是用这个数组单元来存储这个元素;也可以简单的理解为,按照关键字为每一个元素“分类”,然后将这个元素存储在相应“类”所对应的地方,称为桶。

但是,不能够保证每个元素的关键字与函数值是一一对应的,因此极有可能出现对于不同的元素,却计算出了相同的函数值,这样就产生了“冲突”,换句话说,就是把不同的元素分在了相同的“类”之中。总的来说,“直接定址”与“解决冲突”是哈希表的两大特点。

hash_map,首先分配一大片内存,形成许多桶。是利用hash函数,对key进行映射到不同区域(桶)进行保存。其插入过程是:

  1. 得到key
  2. 通过hash函数得到hash值
  3. 得到桶号(一般都为hash值对桶数求模)
  4. 存放key和value在桶内。

其取值过程是:

  1. 得到key
  2. 通过hash函数得到hash值
  3. 得到桶号(一般都为hash值对桶数求模)
  4. 比较桶的内部元素是否与key相等,若都不相等,则没有找到。
  5. 取出相等的记录的value。

hash_map中直接地址用hash函数生成,解决冲突,用比较函数解决。这里可以看出,如果每个桶内部只有一个元素,那么查找的时候只有一次比较。当许多桶内没有值时,许多查询就会更快了(指查不到的时候).

由此可见,要实现哈希表,和用户相关的是:hash函数(hashcode)和比较函数(equals)。

1.3 源码分析

1.3.1 关键属性

transient Entry[] table;//存储元素的实体数组
//存放元素个数
transient int size;

//临界值,当实际大小超过临界值时,会进行扩容threshold = 加载因子*容量
int threshold;

//加载因子,表示Hash表中元素的填满程度
final float loadFactor;

//被修改次数
transient int modCount;

1.3.1.1 加载因子

加载因子越大,填满的元素越多,好处是空间利用率高,但冲突机会加大。链表长度会越来越长,查找效率降低,查找成本越来越高;

加载因子越小,填满的元素越少,冲突机会降低,但空间浪费。

如果机器内存足够,并且想要提高查询速度的话可以将加载因子设置小一点;相反如果机器内存紧张,并且对查询速度没有什么要求的话可以将加载因子设置大一点。不过一般我们都不用去设置它,让它取默认值0.75就好了。

1.3.2 构造方法

public HashMap(int initialCapacity, float loadFactor){
    //确保数字合法
    if(initialCapacity < 0){
        throw new IllegalArgumentException("Illegal initialCapacity:" + initialCapacity);
    }
    if(initialCapacity > MAXIMUM_CAPACITY){
        initialCapacity = MAXIMUM_CAPACITY;
    }
    if(initialCapacity <= 0 || Float.isNaN(loadFactor)){
        throw new IllegalArgumentException("Illegal load factor:" + loadFactor); 
    }
    
    //Find a power of 2 >= initialCapacity
    int capacity = 1;
    while(capacity < initialCapacity){
        //确保容量为2的n次幂,使capacity为大于initialCapacity的最小的2的n次幂
        capacity <<= 1;
    }
    
    this.loadFactor = loadFactor;
    threshold = (int)(capacity * loadFactor);
    table = new Entry[capacity];
    init();
}

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

public HashMap(){
    this.loadFactor = DEFAULT_LOAD_FACTOR;
    thresload = (int)(DEFAULT_INITIAL_CAPACITY * DEFAULT_LOAD_FACTOR);
    table = new Entry[DEFAULT_INITIAL_CAPACITY];
    init();
}

默认初始容量为16,默认加载因子为0.75。我们可以看到上面代码中13-15行,这段代码的作用是确保容量为2的n次幂,使capacity为大于initialCapacity的最小的2的n次幂,是为了使不同hash值发生碰撞的概率较小,这样就能使元素在哈希表中均匀地散列。

1.3.3 put方法

过程为:

  • 对key的hashCode做hash操作,然后计算在bucket中的Index;
  • 如果没有碰撞直接放在bucket中;
  • 如果碰撞了,以链表的形式存在buckets后;
  • 如果结点已经存在就替换old value(保证key的唯一性);
  • 如果bucket满了(超过阈值,阈值=loadfactor*current capacity, load factor默认是0.75),就要resize;
public V put(K key, V value){
    // 若“key为null”,则将该键值对添加到table[0]中
    if(key == null){
        return putForNullKey(value);
    }
    
    // 若“key不为null”,则计算该key的哈希值,然后将其添加到该哈希值对应的链表中。
    int hash = hash(key.hashCode());
    //搜索指定hash值在对应table中的索引
    int i = indexFor(hash, table.length);
    // 循环遍历Entry数组,若“该key”对应的键值对已经存在,则用新的value取代旧的value。然后退出!
    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))){
            //如果key相同则覆盖并返回旧值
            V oldValue = e.value;
            e.value = value;
            e.recordAccess(this);
            return oldValue;
        }
    }
    modCount++;
    //将key-value添加到table[i]处
    addEntry(hash, key, value, i);
    return null;
}

上面程序中用到了一个重要的内部接口:Map.Entry,每个 Map.Entry 其实就是一个 key-value 对。从上面程序中可以看出:当系统决定存储 HashMap 中的 key-value 对时,完全没有考虑 Entry 中的 value,仅仅只是根据 key 来计算并决定每个 Entry 的存储位置。这也说明了前面的结论:我们完全可以把 Map 集合中的 value 当成 key 的附属,当系统决定了 key 的存储位置之后,value 随之保存在那里即可。

private V putForNullKey(V value){
    for(Entry<K, V> e = table[0]; e != null; e = e.next){
        //如果有key为null的对象存在,则覆盖掉
        if(e.key == null){
            V oldValue = e.value;
            e.value = value;
            e.recordAccess(this);
            return oldValue;
        }
    }
    modCount++;
    //如果键为null的话,则hash值为0
    addEntry(0, null, value, 0);
    return null;
}

如果key为null的话,hash值为0,对象存储在数组中索引为0的位置。即table[0]

//计算hash值的方法 通过键的hashCode来计算
static int hash(int h) {
    // 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);
}

得到hash码之后就会通过hash码去计算出应该存储在数组中的索引,计算索引的函数如下:

static int indexFor(int h, int length) { //根据hash值和数组长度算出索引值
    return h & (length-1);  //这里不能随便算取,用hash&(length-1)是有原因的,这样可以确保算出来的索引是在数组大小范围内,不会超出
}

我们一般对哈希表的散列很自然地会想到用hash值对length取模(即除法散列法),Hashtable中也是这样实现的,这种方法基本能保证元素在哈希表中散列的比较均匀,但取模会用到除法运算,效率很低,HashMap中则通过h&(length-1)的方法来代替取模,同样实现了均匀的散列,但效率要高很多,这也是HashMap对Hashtable的一个改进。

根据上面 put 方法的源代码可以看出,当程序试图将一个key-value对放入HashMap中时,程序首先根据该 key 的 hashCode() 返回值决定该 Entry 的存储位置:如果两个 Entry 的 key 的 hashCode() 返回值相同,那它们的存储位置相同。如果这两个 Entry 的 key 通过 equals 比较返回 true,新添加 Entry 的 value 将覆盖集合中原有 Entry 的 value,但key不会覆盖。如果这两个 Entry 的 key 通过 equals 比较返回 false,新添加的 Entry 将与集合中原有 Entry 形成 Entry 链,而且新添加的 Entry 位于 Entry 链的头部——具体说明继续看 addEntry() 方法的说明。

void addEntry(int hash, K key, V value, int bucketIndex) {//参数bucketIndex就是indexFor函数计算出来的索引值
    //取得数组中索引为bucketIndex的Entry对象
    Entry<K,V> e = table[bucketIndex]; //如果要加入的位置有值,将该位置原先的值设置为新entry的next,也就是新entry链表的下一个节点
    table[bucketIndex] = new Entry<>(hash, key, value, e);
    if (size++ >= threshold) //如果大于临界值就扩容
        resize(2 * table.length); //以2的倍数扩容
}

1.3.4 调整大小(resize)

当哈希表的容量超过默认容量时,必须调整table的大小。当容量已经达到最大可能值时,那么该方法就将容量调整到Integer.MAX_VALUE返回,这时,需要创建一张新表,将原表的映射到新表中。

void resize(int newCapacity) {
    Entry[] oldTable = table;
    int oldCapacity = oldTable.length;
    if (oldCapacity == MAXIMUM_CAPACITY) {
        threshold = Integer.MAX_VALUE;
        return;
   }

    Entry[] newTable = new Entry[newCapacity];
    transfer(newTable);//用来将原先table的元素全部移到newTable里面
    table = newTable;  //再将newTable赋值给table
    threshold = (int)(newCapacity * loadFactor);//重新计算临界值
}

新建了一个HashMap的底层数组,上面代码中第10行为调用transfer方法,将HashMap的全部元素添加到新的HashMap中,并重新计算元素在新的数组中的索引位置

当HashMap中的元素越来越多的时候,hash冲突的几率也就越来越高,因为数组的长度是固定的。所以为了提高查询的效率,就要对HashMap的数组进行扩容,数组扩容这个操作也会出现在ArrayList中,这是一个常用的操作,而在HashMap数组扩容之后,最消耗性能的点就出现了:原数组中的数据必须重新计算其在新数组中的位置,并放进去,这就是resize。

那么HashMap什么时候进行扩容呢?当HashMap中的元素个数超过数组大小loadFactor时,就会进行数组扩容,loadFactor的默认值为0.75,这是一个折中的取值。也就是说,默认情况下,数组大小为16,那么当HashMap中元素个数超过160.75=12的时候,就把数组的大小扩展为2*16=32,即扩大一倍,然后重新计算每个元素在数组中的位置,扩容是需要进行数组复制的,复制数组是非常消耗性能的操作,所以如果我们已经预知HashMap中元素的个数,那么预设元素的个数能够有效的提高HashMap的性能。

1.3.5 数据读取

public V get(Object key) {   
    if (key == null)   
        return getForNullKey();   
    int hash = hash(key.hashCode());   
    for (Entry<K,V> 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;   
}

1.3.6 线程不安全

HashMap 在并发执行 put 操作时会引起死循环,导致 CPU 利用率接近100%。因为多线程会导致 HashMap 的 Node 链表形成环形数据结构,一旦形成环形数据结构,Node 的 next 节点永远不为空,就会在获取 Node 时产生死循环。

1.3.7 面试题目

1.3.7.1 你用过HashMap嘛?

该问题还可以以如下提问“什么是HashMap?你为什么用到它?”

HashMap可以接受null作为键值和值,而Hashtable不能;HashMap是非sychronized;查找速度快;HashMap以数组底层实现,以键值对进行存储。

1.3.7.2 HashMap和get()工作原理

HashMap是基于hashing的原理,使用put(key, value)存储对象到HashMap中,使用get(key)从HashMap中获取对象。当给put()方法传递键和值时,先对键调用hashCode()方法,返回的hashCode用于找到bucket()位置来存储Entry对象。

1.3.7.2.1 Map.Entry

Map.Entry是Map声明的一个内部接口,接口为泛型,定义为Entry<K, V>.它表示Map的一个实体(一个key-value对)。接口中有getKey()和getValue()方法。

1.3.7.2.2 bucket

对于HashMap及其子类而言,它们采用Hash算法来决定集合中元素的存储位置。当系统开始初始化HashMap时,系统会创建一个长度为capacity的Entry数组,这个数组里可以存储元素的位置被称为桶(bucket),每个bucket都有其指定索引,系统可以根据其索引快速访问该Bucket里存储的元素。

无论何时,HashMap的每个桶都存储一个元素(也就是一个Entry),由于Entry对象可以包含一个引用变量(就是entry构造器的最后一个参数)用于指定下一个Entry,因此可能出现的情况是:HashMap的bucket中只有一个Entry,但这个Entry指向另一个Entry链。

7e563fb48689177c2a38cfdcac8d62c9.png

1.3.7.2.3 get()工作原理

当HashMap的每个bucket里存储的Entry只是单个的Entry,即,没有通过指针产生Entry链,此时的HashMap具有最好的性能:当程序通过key取出对应的value时,系统只要先计算出该key的hashCode()返回值,再根据该hashCode返回值找出该key在table数组中的索引,然后取出该索引处的Entry,最后返回该key对应value。

public V get(Object key){
    //如果 key 是 null,调用 getForNullKey 取出对应的 value
    if(key == null){
        return getForNullKey();
    }
    // 根据该 key 的 hashCode 值计算它的 hash 码
    int hash = hash(key.hashCode());
    // 直接取出 table 数组中指定索引处的值
    for(Entry<K, V> e = table[indexFor(hash, table.length)]; e != null; e = e.next;){
        Object k;
        // 如果该 Entry 的 key 与被搜索 key 相同 
        if(e.hash == hash && ((k = e.key) == key || key.equals(k))){
            return e.value;
        }
    }
    return null;
}

从上面代码中可以看出,如果 HashMap的每个bucket里只有一个Entry时,HashMap 可以根据索引、快速地取出该bucket里的Entry;在发生“Hash冲突”的情况下,单个 bucket里存储的不是一个Entry,而是一个Entry链,系统只能必须按顺序遍历每个Entry,直到找到想搜索的Entry为止——如果恰好要搜索的Entry位于该Entry链的最末端(该 Entry 是最早放入该bucket中),那系统必须循环到最后才能找到该元素。

归纳起来简单地说,HashMap在底层将key-value当成一个整体进行处理,这个整体就是一个Entry对象。HashMap底层采用一个Entry[] 数组来保存所有的 key-value 对,当需要存储一个 Entry对象时,会根据Hash算法来决定其存储位置;当需要取出一个 Entry 时,也会根据Hash算法找到其存储位置,直接取出该Entry。由此可见:HashMap 之所以能快速存、取它所包含的 Entry.

1.3.7.3 当两个对象的hashCode相同会发生什么

1.3.7.3.1 equals()和hashCode()区别

1.3.7.3.1.1 ==

==比较的是两个对象在JVM中的地址,如果只是基本数据类型,那么比较的就是单纯的值;如果是引用变量,那么比较的就是引用对象在内存中的存放地址。因此,除非是同一个new出来的对象,他们的比较结果为true,否则为false;

1.3.7.3.1.2 equals

equals是根类Object中的方法。

public boolean equals(Object obj){
    return(this == obj);
}

默认的equals方法直接调用==,比较对象地址。

1.3.7.3.1.3 String类源更改equals源码

public boolean equals(Object anObject){
    if(this == anObject){
        return true;
    }
    
    if(anObject instanceof String){
        String anotherString = (String)anObject;
        int n = value.length;
        if(n == anotherString.value.length){
            char v1[] = value;
            char v2[] = anotherString.value;
            int i = 0;
            while(n-- != 0){
                if(v1[i] != v2[i]){
                    return false;
                }
                i++;
            }
            return true;
        }
    }
    return false;
}

(1)String类中的equals首先比较地址,如果是同一个对象的引用,可知对象相等,返回true。

(2)若果不是同一个对象,equals方法挨个比较两个字符串对象内的字符,只有完全相等才返回true,否则返回false。

1.3.7.3.1.4 hashCode()

hashCode是根类Obeject中的方法。默认情况下,Object中的hashCode()返回对象的32位jvm内存地址。也就是说如果对象不重写该方法,则返回相应对象的32为JVM内存地址。

1.3.7.3.1.5 String类源码中重写的hashCode方法

public int hashCode(){
    int h = hash;//Default to 0
    
    if(h == 0 && value.length > 0){
        char val[] = value;
        for(int i = 0; i < value.length; i++){
            h = 31 * h + val[i];
        }
        hash = h;
    }
    return h;
}

String源码中使用private final char value[];保存字符串内容,因此String是不可变的。

1.3.7.3.1.6 总结

1)绑定:当equals方法被重写时,通常有必要重写hashCode()方法,以维护hashCode()方法的常规规定,该协定声明相等对象必须具有相等的哈希码;

2)绑定原因:Hashtable实现一个哈希表,为了成功地在哈希表中存储和检索对象,用作键的对象必须实现 hashCode方法和equals方法。同(1),必须保证equals相等的对象,hashCode 也相等。因为哈希表通过hashCode检索对象。

3)默认:

  • == 默认比较对象在JVM中的地址
  • hashCode默认返回对象在JVM中的存储位置
  • equal比较对象,默认也是比较对象在JVM中的地址

1.3.7.4 如果两个键的hashcode相同,你如何获取值对象

通过对key的hashCode()进行hashing,并计算下标( n-1 & hash),从而获得buckets的位置。两个键的hashcode相同会产生碰撞,则利用key.equals()方法去链表或树(java1.8)中去查找对应的节点。

1.3.7.5 针对 HashMap 中某个 Entry 链太长,查找的时间复杂度可能达到 O(n),怎么优化?

将链表转为红黑树,实现 O(logn) 时间复杂度内查找

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

扩容。这个过程也叫作rehashing,因为它重建内部数据结构,并调用hash方法找到新的bucket位置。大致分两步:

  • 1.扩容:容量扩充为原来的两倍(2 * table.length);
  • 2.移动:对每个节点重新计算哈希值,重新计算每个元素在数组中的位置,将原来的元素移动到新的哈希表中。

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

因为String对象是不可变的,而且已经重写了equals()和hashCode()方法了。

  • 1.不可变性是必要的,因为为了要计算hashCode(),就要防止键值改变,如果键值在放入时和获取时返回不同的hashcode的话,那么就不能从HashMap中找到你想要的对象。不可变性还有其他的优点如线程安全。
  • 2.因为获取对象的时候要用到equals()和hashCode()方法,那么键对象正确的重写这两个方法是非常重要的。如果两个不相等的对象返回不同的hashcode的话,那么碰撞的几率就会小些,这样就能提高HashMap的性能。

1.3.7.8 HashMap与HashTable区别

Hashtable可以看做是线程安全版的HashMap,两者几乎“等价”(当然还是有很多不同)。Hashtable几乎在每个方法上都加上synchronized(同步锁),实现线程安全。

1.3.7.8.1 区别

  • 1.HashMap继承于AbstractMap,而Hashtable继承于Dictionary;
  • 2.线程安全不同。Hashtable的几乎所有函数都是同步的,即它是线程安全的,支持多线程。而HashMap的函数则是非同步的,它不是线程安全的。若要在多线程中使用HashMap,需要我们额外的进行同步处理;
  • 3.null值。HashMap的key、value都可以为null。Hashtable的key、value都不可以为null;
  • 4.迭代器(Iterator)。HashMap的迭代器(Iterator)是fail-fast迭代器,而Hashtable的enumerator迭代器不是fail-fast的。所以当有其它线程改变了HashMap的结构(增加或者移除元素),将会抛出ConcurrentModificationException。
  • 5.容量的初始值和增加方式都不一样:HashMap默认的容量大小是10;增加容量时,每次将容量变为“原始容量x2”。Hashtable默认的容量大小是11;增加容量时,每次将容量变为“原始容量x2 + 1”;
  • 6.添加key-value时的hash值算法不同:HashMap添加元素时,是使用自定义的哈希算法。Hashtable没有自定义哈希算法,而直接采用的key的hashCode()。
  • 7.速度。由于Hashtable是线程安全的也是synchronized,所以在单线程环境下它比HashMap要慢。如果你不需要同步,只需要单一线程,那么使用HashMap性能要好过Hashtable。

1.3.7.8.2 能否让HashMap同步?

HashMap可以通过下面的语句进行同步:Map m = Collections.synchronizeMap(hashMap);

1.3.7.9 你了解重新调整HashMap大小存在什么问题

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

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

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

1.3.7.11 我们可以使用CocurrentHashMap来代替Hashtable吗?

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

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值