HashMap原理分析

基本介绍
HashMap采用key-value形式存储元素。允许存储null键和null值,不保证映射的顺序,特别是它不保证该顺序恒久不变。HashMap是非线程安全的。

HashMap是基于哈希表的 Map 接口的实现,使用put(key, value)存储元素到HashMap中,使用get(key)从HashMap中获取元素。元素存储在桶中(key和value都存储在桶中)。虽然看起来存储的内容为key-value,但是真正的内容,在Map.Entry<K,V>中存储。

HashMap中的key值不允许重复,如果在put的过程中,key值重复了,则原有的value会被新的value替换。所以put方法需要判断当前map中是否包含相同的key,具体的步骤为:对key计算hashCode,找到桶中相对应的存储位置,然后再用equals()方法判断是key值否相等。
两个不同的key计算出的hashCode值是有可能相等的。
如果在对key计算hashCode的时候,计算结果相同,则说明有碰撞,HashMap的存储结构为数组+链表。当计算出的hashCode值相等时,则说明两个key在桶中的存放位置是一样的,而这个位置的多个元素会以链表的形式存放,之后再调用key.equals()方法,和链表中的元素依次进行比对,如果equals,则覆盖原有的值。如果没有equals的元素,则会把这个元素放在链表的头部。
在调用get方法获取对象时,也是类似步骤,先计算key的hashCode,然后找到桶中存储的位置,之后根据key.equals()方法找到相等的节点,最终找到正确的value。

结构
数据结构中的存储有两种方式,一种是数组,一种是链表。所有的数据结构都可以用这两个基本结构来构造的,HashMap的结构为数组+链表。
HashMap的底层为一个数组,而数组的每一项又是一个链表。数组中存放的是Map.Entry对象,每次put时,就把元素放在数组中计算出的某个位置,如果存放时发生了冲突,则以链表的形式继续存储。HashMap底层数组的长度总是2的n次方。

Map中定义了一个Entry接口

interface Entry<K,V> {
    K getKey();
    V getValue();
    V setValue(V value);
    boolean equals(Object o);
    int hashCode();
}

在HashMap中,则定义了这个接口的实现类:

static class Entry<K,V> implements Map.Entry<K,V> {
    final K key;
    V value;
    Entry<K,V> next;
    int hash;
    Entry(int h, K k, V v, Entry<K,V> n) {
        value = v;
        next = n;
        key = k;
        hash = h;
    }
    //......
}

源码分析
以jdk1.7为例子

public V put(K key, V value) {
    //先看数组是否为空,如果为空,则需要进行扩容。
    if (table == EMPTY_TABLE) {
        inflateTable(threshold);
    }
    //当key为null时,调用putForNullKey方法,将value放置在数组第一个位置。  
    if (key == null)
        return putForNullKey(value);
    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;
}

在put元素的时候,先看数组是否为空,如果为空,则需要进行扩容。HashMap允许存放null键,当key为null时,调用putForNullKey方法,将value放置在数组第一个位置。
如果key不为null,就通过hash(key)方法计算hash值,根据hash值就可以再进一步通过indexFor(hash, table.length)计算得到需要存放的数组的下标,如果数组该位置上已经存放有其他元素了,那么在这个位置上的元素将以链表的形式存放,新加入的放在链头,最先加入的放在链尾。如果数组该位置上没有元素,就直接将该元素放到此数组中的该位置上。

上面这段代码中,确定数组下标i的hash(key)方法和indexFor(hash, table.length)是比较经典的,也是HashMap的精华所在,下面是这两个方法的实现:

/**
 * Retrieve object hash code and applies a supplemental hash function to the
 * result hash, which defends against poor quality hash functions.  This is
 * critical because HashMap uses power-of-two length hash tables, that
 * otherwise encounter collisions for hashCodes that do not differ
 * in lower bits. Note: Null keys always map to hash 0, thus index 0.
 */
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);
}
/**
 * Returns index for hash code h.
 */
static int indexFor(int h, int length) {
    // assert Integer.bitCount(length) == 1 : "length must be a non-zero power of 2";
    return h & (length-1);
}

hash方法根据key的hashCode重新计算一次散列,然后返回一个int类型的值,indexFor方法根据返回的这个值和数组的长度,通过与运算计算元素要存放的数组下标。
先研究下indexFor方法,hashmap的数组初始长度为16,以length=16为例,length-1等于15,二进制表示为00000000 00000000 00000000 00001111,进行与运算,前面的高位结果全为0,只会保留最低四位,所以结果不会超过15,正好可以作为数组的下标,这里也正好解释了,为什么HashMap底层数组的长度总是2的n次方。因为如果我们想在元素均匀分布的前提下确定元素存放的下标,最先想到的就是对数组长度进行取模运算,但是取模运算的消耗还是比较大的。只要是数组长度为2的n次方,那长度减一之后,二进制表示的有效位数全都是1,再进行与运算,正好可以得到结果,也就是说,当length总是 2 的n次方时,h& (length-1)运算等价于对length取模,也就是h%length,但是&比%具有更高的效率。
接下来还有个问题,元素在数组中存放的下标,是根据hash方法的结果来确定的,如果这个方法设计不好的话,就会造成冲突的频率比较高。这里再贴上JDK1.8的源码:

/**
 * Computes key.hashCode() and spreads (XORs) higher bits of hash
 * to lower.  Because the table uses power-of-two masking, sets of
 * hashes that vary only in bits above the current mask will
 * always collide. (Among known examples are sets of Float keys
 * holding consecutive whole numbers in small tables.)  So we
 * apply a transform that spreads the impact of higher bits
 * downward. There is a tradeoff between speed, utility, and
 * quality of bit-spreading. Because many common sets of hashes
 * are already reasonably distributed (so don't benefit from
 * spreading), and because we use trees to handle large sets of
 * collisions in bins, we just XOR some shifted bits in the
 * cheapest possible way to reduce systematic lossage, as well as
 * to incorporate impact of the highest bits that would otherwise
 * never be used in index calculations because of table bounds.
 */
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

JDK1.8对hash方法进行了修改,原来JDK1.7会进行四次移位,但是JDK1.8只需要一次移位,但原理都是一样的。
JDK1.8中,对h和h右移16位的值进行异或操作,16位正好是32位的一半,也就是自己的高16位和低16位进行异或,这样就混合了原始hashCode值的高位和低位,加大了低位的随机性,而且混合后的低位掺杂了高位的部分特征,这样高位的信息也被变相保留下来,这就使得hash方法返回的值,具有更高的随机性,减少了冲突。

初始容量和加载因子
HashMap的实例有两个参数影响其性能:初始容量 和加载因子。容量是哈希表中桶的数量,初始容量只是哈希表在创建时的容量。加载因子是哈希表在其容量自动增加之前可以达到多满的一种尺度。当哈希表中的条目数超出了加载因子与当前容量的乘积时,则要对该哈希表进行 rehash 操作,从而哈希表将具有大约两倍的桶数。
加载因子越大,说明哈希表装填程度越高,对空间的利用更充分,缺点是冲突的几率增加,查找的效率降低。加载因子越小,说明哈希表装填程度越低,对空间利用率越低,但是冲突的几率较小,查找的效率高。
HashMap默认的加载因子为0.75,也就是说,当一个map填满了桶空间75%的时候,就需要进行扩容了,会创建原来HashMap大小的两倍的bucket数组,来重新调整map的大小,并将原来的对象放入新的bucket数组中。这个过程叫作rehash,因为它调用hash方法找到新的bucket位置。

在多线程情况下进行rehash的时候,可能产生条件竞争。

hashCode和equals:
前面提到过,map中存取元素,需要判断是否有相同的key,再比如Java中的set集合,不允许存在重复的元素,所以都需要判断元素是否相等。
那为什么是先比较hashCode再比较equals,而不直接用equals去判断相等呢?因为如果集合中存储的元素很多的话,equals需要比较很多次,效率太低,所以有人发明了hashCode算法,每个元素的hashCode都可以直接定位到该元素要存储的物理地址。比较之前,先计算hashCode值,找到该元素要存储的物理地址,如果该地址没有存储元素,则说明该元素和目前已存在的其它元素都不相等。如果该地址有存储的元素,则需要对这些元素调用equals方法比较。
hashCode值相等,但并不equals的话,说明产生了冲突。hashMap采用拉链法存储这些发生冲突的元素。
重写equals方法和重写hashCode方法一般是同时的,否则会出现一种尴尬的情况,两个对象虽然equals,但是程序并没有判断为两个对象是相等的,原因就是先计算hashCode值的时候,两个对象得出的不同的结果,所以就没有再比较equals的必要了。

遍历方式:

public class HashMapTest {
    public static void main(String[] args) {
        Map<String, String> testMap = new HashMap<String, String>();
        testMap.put("1", "a");
        testMap.put("2", "b");
        testMap.put("3", "c");
        /**
         * 第一种遍历方式
         * 推荐
         */
        for (Map.Entry<String, String > entry : testMap.entrySet()) {
            System.out.println(entry.getKey());
            System.out.println(entry.getValue());
        }
        /**
         * 第二种遍历方式
         * 推荐
         *
         */
        Iterator<Map.Entry<String, String>> iterator = testMap.entrySet().iterator();
        while (iterator.hasNext()) {
            Map.Entry<String, String> entry = iterator.next();
            System.out.println(entry.getKey());
            System.out.println(entry.getValue());
        }
        /**
         * 第三种遍历方式
         * 不推荐
         */
        for (String key : testMap.keySet()) {
            System.out.println(key);
            System.out.println(testMap.get(key));
        }
        /**
         * 第四种遍历方式
         * 只能遍历所有的value
         */
        for (String value : testMap.values()) {
            System.out.println(value);
        }
    }
}

遍历方式可以分成两类,一类是通过entrySet遍历,还有一类是通过keySet遍历,但是这两种方式遍历的效率是有差别的,entrySet只遍历一次,把key和value都放在Entry中,但是keySet只取了key,而value还需要再一次获取,效率低。

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

参考文章:
http://www.importnew.com/7099.html
http://www.importnew.com/16301.html

<think>好的,我现在需要详细解释哈希映射(HashMap)的数据结构原理。首先,我得回想一下自己对HashMap的理解,并确保覆盖所有关键点。用户提供的引用内容中提到了哈希表作为底层结构、哈希函数、冲突处理等,这些都是需要涵盖的。 首先,HashMap的基本结构是什么?它基于哈希表,使用数组来存储键值对。哈希函数将键转换为数组的索引,这样可以直接访问对应位置的数据。这里要解释哈希函数的作用,比如将任意大小的数据映射到固定大小的值,作为数组下标。 接下来,哈希冲突是不可避免的,因为不同的键可能生成相同的哈希值。如何处理冲突?常见的方法有开放寻址法和链地址法。根据引用中的内容,用户可能已经提到过链地址法,即每个数组位置是一个链表,存放所有哈希冲突的键值对。需要详细说明这个过程,比如当发生冲突时,将新元素添加到链表中。 然后,需要讨论哈希函数的设计。好的哈希函数应该均匀分布键,以减少冲突。可能提到取模运算,比如用键的哈希值对数组长度取模得到索引。但数组的长度通常选择质数,以减少重复,比如引用中可能提到的。 扩容机制也是一个关键点。当元素数量超过负载因子(比如0.75)时,数组需要扩容,通常翻倍,并重新哈希所有元素到新的数组中。这会影响到性能,但能保持低冲突率。需要解释负载因子的作用和扩容的过程。 另外,时间复杂度方面,理想情况下,插入、删除、查找都是O(1),但最坏情况下(比如所有键冲突)退化为O(n)。所以强调良好的哈希函数和扩容策略的重要性。 可能还需要提到HashMap的应用场景,比如缓存、字典实现等,但用户主要问的是原理,这部分可以简要带过或者放在相关问题里。 现在检查是否有遗漏的部分。引用1提到C语言实现,引用2讲工作原理,引用3提到哈希表和HashMap的实现方法。所以需要综合这些信息,确保覆盖哈希表结构、哈希函数、冲突处理、扩容机制。 需要确保使用正确的中文术语,比如“链地址法”而不是“链表法”。另外,按照系统级指令,行内数学表达式用$...$,比如哈希函数h(key) = key % capacity,这里的公式需要用符号表示,可能需要用LaTeX格式。 在解释过程中,要自然引用提供的引用内容,比如在提到哈希表作为底层结构时引用[^1],在解释哈希函数时引用[^2],在冲突处理时引用[^3]。 最后,生成相关问题,这些问题应该与HashMap原理相关,比如哈希函数设计、冲突处理比较、扩容机制的影响、线程安全等,确保问题有针对性,帮助用户进一步学习。 总结思考步骤: 1. 定义和基本结构:哈希表、键值对存储。 2. 哈希函数的作用和设计。 3. 冲突处理方法,特别是链地址法。 4. 扩容机制和负载因子。 5. 时间复杂度分析。 6. 引用相关文献。 7. 生成相关问题。 现在将这些点组织成连贯的解释,确保符合用户的要求,结构清晰,分点说明,并正确使用格式。</think>### 哈希映射(HashMap)数据结构原理分步解释 #### 1. **基本结构** 哈希映射的核心是**哈希表**,它是一个数组结构,每个位置称为一个“桶”(Bucket)。每个桶存储键值对(Key-Value Pair)的集合。例如,一个容量为10的数组,索引为$0$到$9$,每个索引对应一个桶。 #### 2. **哈希函数** 哈希函数将键(Key)转换为数组索引,公式为: $$ \text{index} = h(\text{key}) \% \text{capacity} $$ 其中$h(\text{key})$是键的哈希值,$\text{capacity}$是数组容量。例如,若键为`"apple"`,哈希值为$1234$,容量为$10$,则索引为$1234 \% 10 = 4$。 **设计要求**: - **均匀分布**:减少冲突概率。 - **高效计算**:时间复杂度为$O(1)$。 #### 3. **冲突处理** 不同键可能产生相同的索引(冲突),常用**链地址法**解决: - 每个桶维护一个链表(或红黑树)。 - 冲突时,新键值对追加到链表末尾。 例如,键`"apple"`和`"banana"`同时映射到索引$4$,则链表存储这两个键值对[^3]。 #### 4. **扩容机制** 当元素数量与容量的比值(负载因子,默认$0.75$)超过阈值时,触发扩容: 1. 新建一个容量翻倍的数组。 2. 重新计算所有键的哈希值并分配到新桶。 此过程保证桶的负载降低,维持$O(1)$操作效率[^3]。 #### 5. **时间复杂度** - **理想情况**:插入、查找、删除均为$O(1)$(无冲突)。 - **最坏情况**:所有键冲突,退化为链表遍历$O(n)$。 优化手段包括使用红黑树(Java 8+)将链表操作优化至$O(\log n)$。 ---
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值