java

关注公众号 jb51net

关闭
首页 > 软件编程 > java > HashMap的数据结构

对HashMap的数据结构的学习解读

作者:找不到、了

这篇文章主要介绍了对HashMap的数据结构的学习心得,具有很好的参考价值,希望对大家有所帮助,如有错误或未考虑完全的地方,望不吝赐教

1、介绍

JDK1.8之前HashMap由数组+链表组成。数组是HshMap的主体,链表则是主要为了解决哈希冲突。

JDK1.8及以后的版本在解决哈希冲突时有了较大的变化,当链表长度大于阈值(或者红黑树的边界值,默认为8,且数组中桶的数量大于64,此时此索引位置上的所有数据改为使用红黑树存储。

1.1、数据结构

如下图所示:

桶数组是用来存储数据元素,链表是用来解决冲突,红黑树是为了提高查询的效率。

数据元素通过映射关系,也就是散列函数,映射到桶数组对应索引的位置。

1.如果发生冲突,从冲突的位置拉一个链表,插入冲突的元素。

2.但是数组长度小于64,此时并不会将链表变为红黑树。而是选择进行数组扩容。

所以综上所述为了提高性能和减少搜索时间,底层在阈值大于8并且数组长度大于64时,链表才转换为红黑树。

至于为什么要转变从链表转到红黑树,以及红黑树转链表:JDK8 HashMap红黑树退化为链表的机制解读

1.2、红黑树

为什么要使用红黑树:

如果链表非常长(特别是在某些不均匀分布的哈希情况下),查找、插入和删除的性能会降到 O(n) 的水平,因为需要遍历整个链表。

在这里简单介绍下红黑树:

由上图可知:

红黑树本质上是一种二叉查找树,为了保持平衡,它又在二叉查找树的基础上增加了一些规则:

1、每个节点要么是红色,要么是黑色;

2、根节点永远是黑色的;

3、所有的叶子节点都是是黑色的(注意这里说叶子节点其实是图中的 NULL 节点);

4、每个红色节点的两个子节点一定都是黑色;

从任一节点到其子树中每个叶子节点的路径都包含相同数量的黑色节点;

这种方式可以在最坏情况下将查找、插入和删除的时间复杂度降低到 O(log n)。

红黑树怎么保持平衡?

红黑树有两种方式保持平衡:旋转和染色。

旋转分为左旋和右旋。

1、左旋

2、右旋

1.3、参数

查看HashMap源码,可以看到以下常用的变量,因此先在这里进行归类:

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4:默认的table初始容量
static final float DEFAULT_LOAD_FACTOR = 0.75f:默认的负载因子

static final int TREEIFY_THRESHOLD = 8: 链表长度大于等于该参数转红黑树
static final int UNTREEIFY_THRESHOLD = 6: 当树的节点数小于等于该参数转成链表

transient Node<K,V>[] table:这是一个Node类型的数组(也有称作Hash桶)

可以从下面源码中看到静态内部类Node在这边可以看做就是一个节点,多个Node节点构成链表,当链表长度大于8的时候转换为红黑树。

2、成员变量

Node 类

HashMap 内部使用的一个重要结构是 Node,通常定义为一个静态内部类,用于存储键值对。

static class Node<K, V> {
    final int hash;       // 键的哈希值
    final K key;         // 键
    V value;             // 值
    Node<K, V> next;     // 指向下一个节点,处理链表冲突
}

1. Node<K,V>[] table

2. int size

3. int threshold

4. float loadFactor

通过合理的负载因子设置:

不同的负载因子会影响 HashMap 的性能。较低的负载因子(例如 0.5)会使得 HashMap 更频繁地扩容,从而减少冲突并提高快速访问的性能,但会浪费内存。

高负载因子的影响:

较高的负载因子(例如 0.9)可以提高内存使用效率,但同时也增加了哈希冲突的风险,这可能导致访问性能下降(例如,查询、插入和删除的时间复杂度从 O(1) 变为 O(n))。

以下代码示例比较了使用不同负载因子的 HashMap 在大量插入元素时的性能差异:

import java.util.HashMap;

public class HashMapPerformanceTest {
    public static void main(String[] args) {
        int numberOfElements = 1_000_000; // 要插入的元素数量

        // 测试负载因子为默认值 (0.75)
        long timeWithDefaultLoadFactor = testHashMapPerformance(0.75f, numberOfElements);
        System.out.println("Time taken with default load factor (0.75): " + timeWithDefaultLoadFactor + " ms");

        // 测试负载因子为 0.5
        long timeWithLowLoadFactor = testHashMapPerformance(0.5f, numberOfElements);
        System.out.println("Time taken with low load factor (0.5): " + timeWithLowLoadFactor + " ms");

        // 测试负载因子为 0.9
        long timeWithHighLoadFactor = testHashMapPerformance(0.9f, numberOfElements);
        System.out.println("Time taken with high load factor (0.9): " + timeWithHighLoadFactor + " ms");
    }

    private static long testHashMapPerformance(float loadFactor, int numberOfElements) {
        long startTime = System.currentTimeMillis();

        HashMap<Integer, String> map = new HashMap<>(16, loadFactor); // 初始化 HashMap
        
        // 插入元素
        for (int i = 0; i < numberOfElements; i++) {
            map.put(i, "Value " + i);
        }

        long endTime = System.currentTimeMillis();
        return endTime - startTime; // 返回耗时
    }
}

输出结果:

Time taken with default load factor (0.75): 197 ms
Time taken with low load factor (0.5): 716 ms
Time taken with high load factor (0.9): 552 ms

5. int modCount

关于fail-fast机制:可参考JDK8 HashMap红黑树退化为链表的机制解析

3、构造方法

以下有四种不同的初始化方式。

1、无参构造

    //构造一个空的HashMap,默认初始容量16和负载因子0.75
    public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
    }
 

2、有初始化容量构造

//构造一个指定初始容量的HashMap,默认负载因子0.75。
   public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }

3、指定容量和负载因子

//构造一个指定的HashMap,指定默认初始容量和负载因子
   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);
    }

4、映射Map

   //构造一个映射的map 
    public HashMap(Map<? extends K, ? extends V> m) {
        this.loadFactor = DEFAULT_LOAD_FACTOR;
        putMapEntries(m, false);
    }

HashMap和HashSet都允许你指定负载因子的构造器,表示当负载情况达到负载因子水平的时候,容器会自动扩容,HashMap默认使用的负载因子值为0.75f(当容量达到四分之三进行再散列(扩容))。当负载因子越大的时候能够容纳的键值对就越多但是查找的代价也会越高。

4、常用方法

4.1、put方法

在使用默认构造器初始化一个HashMap对象的时候,首次Put键值对的时候会先计算对应Key的hash值通过hash值来确定存放的地址。

原理:

计算哈希值:

关于更多的哈希值的介绍可参考:HashMap中哈希值与数组坐标的关联

检查桶(bucket):

处理冲突:

更新大小和阈值:

每当元素数量增加到阈值时(即 size >= threshold),HashMap 会调用 resize() 方法来扩大底层数组的大小。

以下是put的简化版代码示例:

import java.util.Arrays;

class MyHashMap<K, V> {
    static class Node<K, V> {
        final int hash; // 存储哈希值
        final K key;    // 存储键
        V value;        // 存储值
        Node<K, V> next; // 指向下一个节点
        
        Node(int hash, K key, V value, Node<K, V> next) {
            this.hash = hash;
            this.key = key;
            this.value = value;
            this.next = next;
        }
    }

    private Node<K, V>[] table; // 存储节点的数组
    private int size;            // 当前大小
    private int threshold;       // 扩容阈值
    private float loadFactor;    // 负载因子

    public MyHashMap(int initialCapacity, float loadFactor) {
        this.loadFactor = loadFactor;
        this.table = new Node[initialCapacity];
        this.threshold = (int)(initialCapacity * loadFactor);
    }

    public void put(K key, V value) {
        int hash = key == null ? 0 : key.hashCode();
        int index = (hash & (table.length - 1)); // 计算桶的索引
        Node<K, V> existingNode = table[index];

        // 检查桶是否为空
        if (existingNode == null) {
            // 如果桶为空,创建一个新节点
            table[index] = new Node<>(hash, key, value, null);
            size++;
        } else {
            // 遍历链表,检查是否已有相同的键
            Node<K, V> currentNode = existingNode;
            while (true) {
                if (currentNode.hash == hash && (currentNode.key == key || (key != null && key.equals(currentNode.key)))) {
                    // 找到相同的键,更新值
                    currentNode.value = value;
                    return;
                }
                if (currentNode.next == null) {
                    // 没有找到,添加新节点
                    currentNode.next = new Node<>(hash, key, value, null);
                    size++;
                    break;
                }
                currentNode = currentNode.next;
            }
        }

        // 检查是否需要扩容
        if (size >= threshold) {
            resize();
        }
    }

    private void resize() {
        // 扩容逻辑,目前省略具体实现
    }
}

4.2、get方法

扩展:

在 Java 中,hashCode() 函数的返回值是一个 int 类型,意味着哈希码的值占用 32 位(4 字节)。hashCode() 函数计算出的值可以在范围 [-2,147,483,648, 2,147,483,647] 之间。

高位与低位的解释

先右移异或

首先会根据hashcode函数的到hash值,然后右移16位,然后将原始值和右移后的值进行异或(相同为0,不同为1)。

int hash = key.hashCode();
// 分两次右移,并进行异或,减少碰撞
hash ^= (hash >>> 16);

再计算哈希值:

int index = (hash & (table.length - 1));

查找桶(bucket):

根据计算出的索引,从内部数组 table 中获取相应的桶(Node[])。如果桶是空的,直接返回 null,表示所查询的键不存在。

遍历链表或红黑树 (处理哈希冲突):

如果桶中有多个元素(即形成了链表或红黑树),则需要遍历链表或树来查找对应的键。

使用 equals() 方法比较每个节点的键,找到匹配的键时返回对应的值。

返回值:

如果找到了匹配的键,返回其对应的值;如果未找到,则返回 null

以下是get方法的代码示例图:

class Node<K, V> {
    final int hash;
    final K key;
    V value;
    Node<K, V> next;

    Node(int hash, K key, V value, Node<K, V> next) {
        this.hash = hash;
        this.key = key;
        this.value = value;
        this.next = next;
    }
}

public class MyHashMap<K, V> {
    private Node<K, V>[] table; // 存储节点的数组
    private int size;

    public MyHashMap(int initialCapacity) {
        this.table = new Node[initialCapacity];
    }

    public V get(K key) {
        if (key == null) {
            return null; // 处理空键
        }
        
        int hash = key.hashCode(); // 计算哈希值
        int index = (hash & (table.length - 1)); // 计算索引

        Node<K, V> node = table[index]; // 获取对应的桶

        // 遍历链表或红黑树
        while (node != null) {
            if (node.hash == hash && (node.key.equals(key))) {
                return node.value; // 找到键,返回对应的值
            }
            node = node.next; // 移动到下一个节点
        }

        return null; // 未找到
    }
}

小结:

时间复杂度:

处理哈希冲突:

5、扩容

关于HashMap的原理图。

主要用于存储键值对。其内部实现包含了数组和链表(或红黑树)以处理哈希冲突。HashMap 在使用过程中会发生扩容,以确保其存储性能及均匀分布。

下面是 HashMap 扩容的详细原理。

5.1、扩容介绍

HashMap 的扩容是指当 HashMap 的当前容量不足以存储新的键值对时,自动调整其容量和结构,以提高其性能。

扩容通常会在 put() 方法中触发,这个方法用于将键值对添加到哈希表中。

5.2、扩容的触发条件

扩容会在以下情况下触发:

默认为 0.75 的负载因子。

举个例子:

  1. 初始容量: 默认 HashMap 初始容量为 16
  2. 负载因子: 默认负载因子为 0.75
  3. 阈值: 计算得出的阈值为 16×0.75=12。

所以,当插入的元素数量达到 12 时,HashMap 会触发扩容。

5.3、扩容过程

当需要进行扩容时,HashMap 的扩容过程如下:

当前容量翻倍:

重新计算哈希:

元素迁移:

示例:

public class HashMapDemo {
    public static void main(String[] args) {
        MyHashMap<String, Integer> map = new MyHashMap<>(4, 0.75f); // 初始化容量为4,负载因子为0.75
        
       map.put("one", 1); → 当前大小为 1(没有触发扩容)。
       map.put("two", 2); → 当前大小为 2(没有触发扩容)。
       map.put("three", 3); → 当前大小为 3(这时已达到阈值,因此将触发扩容)。
       map.put("four", 4); → 由于已经扩容,实际中会发生元素的重新分配,现在当前数组的大小为 4。
       map.put("five", 5); → 当前大小为 5 (但是不会再触发扩容,因为数组容量已增加到 8)。
        
        // 你可以扩展上面代码来实现 get 方法等
    }
}

HashMap是先插入还是先扩容:HashMap是先插入数据再进行扩容的,但是如果是刚刚初始化容器的时候是先扩容再插入数据。

5.4、扩容失败

通常会导致 OutOfMemoryError 异常,当前的操作(例如添加元素)将会失败。之后,HashMap 仍然保持不变,已经加入的元素也不会被丢失。

扩容属于原子操作,不会重试,因为扩容是同步操作,失败意味着资源不足,无法继续。

当扩容成功后的代码示例如下:

package com.example.test;

import java.lang.reflect.Field;
import java.util.HashMap;

public class HashMapTest {
    public static void main(String[] args) {
        HashMap<String, Object> map = new HashMap<>(4,0.75f);
        map.put("one",1);
        map.put("two",2);
        map.put("three",3);
        map.put("four",4);
        try {
            // 获取底层数组长度
            Field tableField = HashMap.class.getDeclaredField("table");
            tableField.setAccessible(true);
            Object[] table = (Object[]) tableField.get(map);

            System.out.println("键值对数量(map.size): " + map.size());
            System.out.println("底层数组长度: " + (table != null ? table.length : 0));
        } catch (NoSuchFieldException e) {
            throw new RuntimeException(e);
        } catch (IllegalAccessException e) {
            throw new RuntimeException(e);
        }
        System.out.println("map.size="+map.size());
    }
}

结果输出:

键值对数量(map.size): 4
底层数组长度: 8
map.size=4

6、迭代异常

迭代器的remove()可以安全使用,而直接操作Map会触发异常。通过fast-fail机制可以有效的进行检测。

代码示例如下:

// 正确方式(使用迭代器的remove)
Iterator<String> it = map.keySet().iterator();
while (it.hasNext()) {
    if (it.next().equals("remove")) {
        it.remove();  // 不会增加modCount
    }
} 这个是正常的,  Map<String, Integer> map = new HashMap<>();
map.put("A", 1);
map.put("B", 2);
Iterator<String> it = map.keySet().iterator();
while (it.hasNext()) {
    String key = it.next();
    map.put("C", 3);  // 这里会触发fail-fast
} 而这个会触发fail-fast

6.1、区别对比

6.2、源码解析

1. 迭代器remove实现

// HashMap.HashIterator.remove()
public final void remove() {
    Node<K,V> p = current;
    if (p == null)
        throw new IllegalStateException();
    if (modCount != expectedModCount)
        throw new ConcurrentModificationException();
    
    current = null;
    K key = p.key;
    removeNode(hash(key), key, null, false, false);
    expectedModCount = modCount; // 关键点:同步更新!
}

关键步骤

2. 直接put操作

// HashMap.put()
public V put(K key, V value) {
    // ...省略其他代码...
    ++modCount; // 修改计数器增加
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

此时expectedModCount不会更新,导致下次迭代检查时:

// HashIterator.nextNode()
final Node<K,V> nextNode() {
    if (modCount != expectedModCount) // 此时不相等!
        throw new ConcurrentModificationException();
    // ...
}

6.3、结构图

比较expectedModCount和ModCount值是否相等。通常在hashmap的clear(),remove(),put()方法里面都会进行modcount++;

如下图所示:

6.4、线程安全

即使单线程环境,两种操作也有本质区别:

迭代器remove

直接修改Map

6.5、开发建议

1. 安全删除模式

// 安全删除示例
List<String> toRemove = new ArrayList<>();
for (Entry<String, Integer> entry : map.entrySet()) {
    if (entry.getValue() < 0) {
        toRemove.add(entry.getKey());
    }
}
toRemove.forEach(map::remove); // 迭代完成后再批量删除

2. 多线程替代方案

// 使用ConcurrentHashMap
ConcurrentHashMap<String, Integer> safeMap = new ConcurrentHashMap<>();
// 迭代时修改不会抛异常
for (String key : safeMap.keySet()) {
    if (key.startsWith("test")) {
        safeMap.remove(key); // 安全操作
    }
}

6.6、底层设计

契约式设计

防御式编程

最小惊讶原则

这种设计确保了集合在迭代过程中的状态可控性,虽然限制了灵活性,但大幅提高了代码的可靠性。理解这一机制可以帮助开发者避免常见的并发修改错误。

总结

以上为个人经验,希望能给大家一个参考,也希望大家多多支持脚本之家。

您可能感兴趣的文章:
阅读全文