• 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

  • 相关阅读:
    Redis杂谈
    General mistakes in parallel computing
    life of a NPTL pthread
    casting in C++
    function calling convention
    How exception works ?
    How `delete’ works ?
    How `new’ operator works ?
    老白的JAVA课程17 集合
    老白的JAVA课程16 卡片布局 javaBean
  • 原文地址:https://www.cnblogs.com/jhxxb/p/14426238.html
Copyright © 2020-2023  润新知