Java HashMap

一些数据结构的操作性能

数组:查找快,新增、删除慢

  • 采用一段连续的存储单元来存储数据
  • 指定下标的查找,时间复杂度为 O(1)
  • 通过给定值进行查找,需要遍历数组,逐一比对给定关键字和数组元素,时间复杂度为O(n)。当然,对于有序数组,可采用二分查找,插值查找,斐波那契查找等方式,可将查找复杂度提高为 O(logn)
  • 对于一般的插入删除操作,涉及到数组元素的移动,其平均复杂度为 O(n)

线性链表:新增、删除快,查找慢

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

二叉树:自平衡的话,新增、删除、查找都不快不慢

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

哈希表:添加,删除,查找等操作都很快 (数组+链表)

  • 相比上述几种数据结构,在哈希表中进行添加,删除,查找等操作,性能十分之高,不考虑哈希冲突的情况下,仅需一次定位即可完成,时间复杂度为 O(1)

数据结构的物理存储结构只有两种:顺序存储结构和链式存储结构(像栈,队列,树,图等是从逻辑结构去抽象的,映射到内存中,也这两种物理组织形式)

 

HashMap 结构

哈希表(hash table)也叫散列表,是一种非常重要的数据结构,应用场景及其丰富,许多缓存技术(如 memcached)的核心其实就是在内存中维护一张大的哈希表。

HashMap 包括几个重要的成员变量

/**
 * JDK_1.7.0_80
 * AbstractMap 已经实现了 Map 接口,HashMap 可以不用实现 Map 接口
 */
public class HashMap<K, V> extends AbstractMap<K, V> implements Map<K, V>, Cloneable, Serializable {
    // 支持序列化
    private static final long serialVersionUID = 362498820763181265L;

    // HashMap 的主干数组,一个 Entry 类型数组,而 Entry 实际上就是一个单向链表。哈希表的 key-value 键值对都是存储在 Entry 数组中的
    // 初始值为空数组 {},主干数组的长度一定是 2 的次幂,方便 indexFor 高效率的位运算得到最终的下标值
    transient Entry<K, V>[] table = (Entry<K, V>[]) EMPTY_TABLE;
    static final Entry<?, ?>[] EMPTY_TABLE = {};

    // HashMap 实际存储的 key-value 键值对的数量
    transient int size;

    // HashMap 的存储阈值,当 HashMap 中存储数据的数量达到 threshold 时,就扩容 HashMap 的容量。
    // 当 table == {} 时,该值为初始容量(初始容量默认为 16)
    // 为 table 分配内存空间后,threshold 一般为 capacity(容量) * loadFactory(加载因子)
    int threshold;

    // 负载因子,代表了 table 的填充度有多少,默认是 0.75,既兼顾数组利用率,又考虑链表不要太多
    final float loadFactor;

    // 更改次数,用来实现 fail-fast 机制
    // 由于 HashMap 非线程安全,在对 HashMap 进行迭代时,如果期间其它线程导致 HashMap 的结构发生变化了(put,remove 等),需要抛出 ConcurrentModificationException
    transient int modCount;

    // 默认最小初始化数组的容量
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
    // 最大数组的容量
    static final int MAXIMUM_CAPACITY = 1 << 30;
    // 默认加载因子
    static final float DEFAULT_LOAD_FACTOR = 0.75f;


    transient int hashSeed = 0;
    static final int ALTERNATIVE_HASHING_THRESHOLD_DEFAULT = Integer.MAX_VALUE;
    private transient Set<Entry<K, V>> entrySet = null;

哈希表的主干就是数组

如何找到要插入元素的位置?

  • 把当前元素(value)的关键字(key)通过某个函数(hash 算法取模:存储位置 = F(关键字))映射到数组中的某个位置,通过数组下标一次定位就可完成操作。这个函数 F 一般称为哈希函数,这个函数的设计好坏会直接影响到哈希表的优劣。

哈希冲突(哈希碰撞):如果两个不同的元素,通过哈希函数得出的实际存储地址相同怎么办?

  • 也就是说,对某个元素进行哈希运算,得到一个存储地址,然后要进行插入的时候,发现已经被其他元素占用了,这就哈希冲突,也叫哈希碰撞。
  • 哈希函数的设计至关重要,好的哈希函数会尽可能地保证计算简单和散列地址分布均匀,但是,需要清楚的是,数组是一块连续且固定长度的内存空间,再好的哈希函数也不能保证得到的存储地址绝对不发生冲突。

哈希冲突如何解决?

  • 哈希冲突的解决方案有多种:开放定址法(发生冲突,继续寻找下一块未被占用的存储地址)、再散列函数法、链地址法。HashMap 就是采用了链地址法,也就是数组 + 链表的方式。

HashMap 的主干是一个 Entry 数组。Entry 是 HashMap 的基本组成单元,每一个 Entry 包含一个 key-value 键值对。

/**
 * JDK_1.7.0_80
 */
public class HashMap<K, V> extends AbstractMap<K, V> implements Map<K, V>, Cloneable, Serializable {
    static class Entry<K, V> implements Map.Entry<K, V> {
        final K key;
        V value;
        Entry<K, V> next; // 存储指向下一个 Entry 的引用,单链表结构
        int hash; // 对 key 的 hashcode 值进行 hash 运算后得到的值,存储在 Entry,避免重复计算

        Entry(int h, K k, V v, Entry<K, V> n) {
            value = v;
            next = n;
            key = k;
            hash = h;
        }

综上,HashMap 的整体结构:

简单来说,HashMap 由数组+链表组成的,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的。

如果定位到的数组位置不含链表(当前 entry 的 next 指向 null),那么对于查找,添加等操作很快,仅需一次寻址即可。

如果定位到的数组包含链表,对于添加操作,其时间复杂度为 O(n),首先遍历链表,存在即覆盖,否则新增。

对于查找操作来讲,仍需遍历链表,然后通过 key 对象的 equals 方法逐一比对查找。所以,性能考虑,HashMap 中的链表出现越少,性能才会越好。

构造器

/**
 * JDK_1.7.0_80
 */
public class HashMap<K, V> extends AbstractMap<K, V> implements Map<K, V>, Cloneable, Serializable {
    public HashMap() {
        this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
    }

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

    /**
     * HashMap 有 4 个构造器,如果用户没有传入 initialCapacity 和 loadFactor 这两个参数,会使用默认值 initialCapacity,默认为 16,loadFactory 默认为 0.75
     */
    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;
        threshold = initialCapacity;
        init();
    }

    public HashMap(Map<? extends K, ? extends V> m) {
        this(Math.max((int) (m.size() / DEFAULT_LOAD_FACTOR) + 1, DEFAULT_INITIAL_CAPACITY), DEFAULT_LOAD_FACTOR);
        inflateTable(threshold);

        putAllForCreate(m);
    }

    void init() {
    }

 

HashMap 操作

put 添加元素

当发生哈希冲突并且 size 大于阈值的时候,需要进行数组扩容。

扩容时,需要新建一个长度为之前数组 2 倍的新数组,然后将当前的 Entry 数组中的元素全部传输过去。

扩容后的新数组长度为之前的 2 倍,所以扩容相对来说是个耗资源的操作。

/**
 * JDK_1.7.0_80
 */
public class HashMap<K, V> extends AbstractMap<K, V> implements Map<K, V>, Cloneable, Serializable {
    public V put(K key, V value) {
        if (table == EMPTY_TABLE) { // 数组为空进行扩容
            inflateTable(threshold);
        }
        if (key == null) // 判断 key 为 null 的情况,如果为 null,那么 key 的 hash 值为 0,之后返回 null
            return putForNullKey(value); // null 总是放在数组的第一个链表中
        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))) { // 如果 key 在链表中已存在,则替换为新 value
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                return oldValue;
            }
        }

        modCount++;
        addEntry(hash, key, value, i); // 如果 key 在链表中不存在则插入
        return null;
    }

    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;
    }

    private void inflateTable(int toSize) {
        // Find a power of 2 >= toSize
        int capacity = roundUpToPowerOf2(toSize); // 得到大于等于最接近 toSize 的 2 的幂值

        threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1); // 对 threshold 进行重新赋值
        table = new Entry[capacity]; // 创建一个长度为 capacity 的数组
        initHashSeedAsNeeded(capacity); // 初始化 Hash 种子,即 rehash
    }

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

        createEntry(hash, key, value, bucketIndex); // 将元素加入到数组中
    }

resize 扩容机制

为了便于理解这里仍然使用 JDK1.7 的代码,本质上区别不大。JDK8 以后引入了红黑树对查询性能进行了优化。当 Hash 桶里面的数量大于 8 且总容量大于 64,就会转为红黑树。

/**
 * JDK_1.7.0_80
 */
public class HashMap<K, V> extends AbstractMap<K, V> implements Map<K, V>, Cloneable, Serializable {
    void resize(int newCapacity) { // 传入新的容量
        Entry[] oldTable = table; // 引用扩容前的 Entry 数组
        int oldCapacity = oldTable.length;
        if (oldCapacity == MAXIMUM_CAPACITY) { // 扩容前的数组大小如果已经达到最大(2^30)了
            threshold = Integer.MAX_VALUE; // 修改阈值为 int 的最大值(2^31-1),这样以后就不会扩容了
            return;
        }

        Entry[] newTable = new Entry[newCapacity]; // 初始化一个新的 Entry 数组
        transfer(newTable, initHashSeedAsNeeded(newCapacity)); // 将数据转移到新的 Entry 数组里
        table = newTable; // HashMap 的 table 属性引用新的 Entry 数组
        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) { // 遍历旧的 Entry 数组
            while (null != e) {
                Entry<K, V> next = e.next; // 取得旧 Entry 数组的每个元素
                if (rehash) {
                    e.hash = null == e.key ? 0 : hash(e.key);
                }
                int i = indexFor(e.hash, newCapacity); // 重新计算每个元素在数组中的位置
                e.next = newTable[i]; // 标记[i]
                newTable[i] = e; // 将元素放在数组上
                e = next; // 访问下一个 Entry 链上的元素
            }
        }
    }

    static int indexFor(int h, int length) { // 在旧数组中同一条 Entry 链上的元素,通过重新计算索引位置后,有可能被放到了新数组的不同位置上
        return h & (length - 1);
    }

经过观测可以发现,我们使用的是 2 次幂的扩展(指长度扩为原来 2 倍),所以,经过 rehash 之后,元素的位置要么是在原位置,要么是在原位置再移动 2 次幂的位置。

 

JDK1.8 对 HashMap 优化

构造器

public class HashMap<K, V> extends AbstractMap<K, V> implements Map<K, V>, Cloneable, Serializable {
    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); // 初始化时就计算 threshold 值,JDK7 是在 put 时
    }

    static final int tableSizeFor(int cap) { // 类似 JDK7 的 roundUpToPowerOf2 方法,返回大于等于最接近 cap 的 2 的冪数
        // 例如 12->16,7->8,8->8,17->32
        int n = cap - 1;
        n |= n >>> 1;
        n |= n >>> 2;
        n |= n >>> 4;
        n |= n >>> 8;
        n |= n >>> 16;
        return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
    }

红黑树

public class HashMap<K, V> extends AbstractMap<K, V> implements Map<K, V>, Cloneable, Serializable {
    static final int TREEIFY_THRESHOLD = 8; // 由链表转换成树的阈值,值与泊松分布有关,基于空间复杂度和时间复杂度的一个权衡
    static final int UNTREEIFY_THRESHOLD = 6; // 由树转换成链表的阈值
    // 当哈希表中的容量大于这个值时,表中的桶才能进行树形化,否则桶内元素太多时会 resize 扩容,而不是树形化
    // 为了避免进行扩容、树形化选择的冲突,这个值不能小于 4 * TREEIFY_THRESHOLD
    static final int MIN_TREEIFY_CAPACITY = 64;

 

Equals 和 HashCode

HashCode 用于寻找当前元素存在数组的那个位置,Equals 用于判断当前元素与要存入位置上的元素是否相等(如果要插入位置正好有元素的话)

public class Test {
    public static void main(String[] args) {
        HashMap<Person, String> map = new HashMap<>();
        Person person = new Person("jhxxb");
        map.put(person, "jhxxb");
        // 应该能输出 jhxxb?
        System.out.println("result: " + map.get(new Person("jhxxb")));

        System.out.println(person.hashCode());
        System.out.println(new Person("jhxxb").hashCode());
    }

    @AllArgsConstructor
    // @EqualsAndHashCode
    private static class Person {
        String name;

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (o == null || getClass() != o.getClass()) return false;
            Person person = (Person) o;
            return Objects.equals(name, person.name);
        }

        // @Override
        // public int hashCode() {
        //     return Objects.hash(name);
        // }
    }
}
View Code

 

P3C

【推荐】集合初始化时,指定集合初始值大小。

  • 说明:HashMap 使用 HashMap(int initialCapacity) 初始化,如果暂时无法确定集合大小,那么指定默认值(16)即可。
  • 正例:initialCapacity = (需要存储的元素个数 / 负载因子) + 1。注意负载因子(即 loader factor)默认为 0.75,如果暂时无法确定初始值大小,请设置为 16(即默认值)。
  • 反例: HashMap 需要放置 1024 个元素,由于没有设置容量初始大小,随着元素增加而被迫不断扩容,resize()方法总共会调用 8 次,反复重建哈希表和数据迁移。当放置的集合元素个数达千万级时会影响程序性能。

【推荐】使用 entrySet 遍历 Map 类集合 KV,而不是 keySet 方式进行遍历。

  • 说明:keySet 其实是遍历了 2 次,一次是转为 Iterator 对象,另一次是从 hashMap 中取出 key 所对应的 value。而 entrySet 只是遍历了一次就把 key 和 value 都放到了 entry 中,效率更高。如果是 JDK8,使用 Map.forEach 方法。
  • 正例:values()返回的是 V 值集合,是一个 list 集合对象;keySet()返回的是 K 值集合,是一个 Set 集合对象;entrySet()返回的是 K-V 值组合集合。

【推荐】高度注意 Map 类集合 K/V 能不能存储 null 值的情况,如下表格:

  • 反例:由于 HashMap 的干扰,很多人认为 ConcurrentHashMap 是可以置入 null 值,而事实上,存储 null 值时会抛出 NPE 异常。

 


https://blog.csdn.net/f641385712/article/details/81147941

https://tech.meituan.com/2016/06/24/java-hashmap.html

https://www.javadoop.com/post/hashmap

https://www.bilibili.com/video/BV1FE411t7M7

posted @ 2022-02-10 18:33  江湖小小白  阅读(126)  评论(0编辑  收藏  举报