zoukankan      html  css  js  c++  java
  • 深入理解Java容器——HashMap

    存储结构

    JDK1.8前是数组+链表,JDK1.8之后是数组+链表+红黑树。本文分析基于JDK1.8源代码。
    HashMap的基础结构是Node ,它存着hash、键值对,Node类型的指针next。
    主干是桶数组,链表bin用于解决hash冲突,当链表的Node超过阈值(8),执行树化操作,将该链表改造成红黑树。
    在这里插入图片描述

    图片来源:Java核心技术36讲

    初始化

    HashMap有4个构造器,其他构造器如果用户没有传入initialCapacity (容量)和loadFactor(负载因子)这两个参数,
    会使用默认值 ,initialCapacity默认为16,loadFactory默认为0.75。
    基于lazy-load原则,主干数组table的内存空间分配不在初始化中,而是在put中。

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

    put

    public V put(K key, V value) {
            return putVal(hash(key), key, value, false, true);
        }
    final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                       boolean evict) {
            Node<K,V>[] tab; Node<K,V> p; int n, i;
    		//如果数组为空,resize为数组分配存储空间
            if ((tab = table) == null || (n = tab.length) == 0)
                n = (tab = resize()).length;
    		//插入位置未被占用,直接创建节点。p是链表头节点
            if ((p = tab[i = (n - 1) & hash]) == null)
                tab[i] = newNode(hash, key, value, null);
    		//插入位置已存在数据,则选择覆盖或插入
            else {
                Node<K,V> e; K k;
    			//与头结点p相等
                if (p.hash == hash &&
                    ((k = p.key) == key || (key != null && key.equals(k))))
                    e = p;
    			//链表已树化,执行树节点插入
                else if (p instanceof TreeNode)
                    e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
                else {
    				//未树化,沿着链表查找是否有跟要插入的key相等的节点
                    for (int binCount = 0; ; ++binCount) {
    					//不存在,则插入链表尾
                        if ((e = p.next) == null) {
                            p.next = newNode(hash, key, value, null);
    						//链表节点超过阈值,执行树化
                            if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                                treeifyBin(tab, hash);
                            break;
                        }
                        if (e.hash == hash &&
                            ((k = e.key) == key || (key != null && key.equals(k))))
                            break;
                        p = e;
                    }
                }
    			
                if (e != null) { // existing mapping for key
                    V oldValue = e.value;
                    if (!onlyIfAbsent || oldValue == null)
                        e.value = value;
                    afterNodeAccess(e);
                    return oldValue;
                }
            }
            ++modCount;//保证并发访问时,若HashMap内部结构发生变化,快速响应失败,抛出异常
    		//插入后,如果元素个数超过size门限,则扩容
            if (++size > threshold)
                resize();
            afterNodeInsertion(evict);
            return null;
        }
    

    null处理

    HashMap 允许插入键为 null 的键值对。但是因为无法调用 null 的 hashCode() 方法,也就无法确定该键值对的桶下标,
    只能通过强制指定一个桶下标来存放。HashMap 使用第 0 个桶存放键为 null 的键值对。

    确定数组下标

    //这是一个神奇的函数,用了异或,移位等运算来保证最终获取的存储位置尽量分布均匀
     static final int hash(Object key) {
            int h;
            return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
     }
    

    为什么要做异或运算?
    主要目的是使hash结果平均化,因为有些数据计算出的哈希值差异主要在高位,而HashMap的哈希寻址是忽略容量以上高位的(取模),这样就可以避免哈希碰撞。

    插入

    //未树化,沿着链表查找是否有跟要插入的key相等的节点
                    for (int binCount = 0; ; ++binCount) {
    					//不存在,则插入链表尾
                        if ((e = p.next) == null) {
                            p.next = newNode(hash, key, value, null);
    						//链表节点超过阈值,执行树化
                            if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                                treeifyBin(tab, hash);
                            break;
                        }
                        if (e.hash == hash &&
                            ((k = e.key) == key || (key != null && key.equals(k))))
                            break;
                        p = e;
                    }
    

    resize

    resize有两个职责:

    • 初始化存储数组table
    • 容量不足时扩容

    几个重要字段

    //实际存储的key-value键值对的个数
    transient int size;
    //size门限
    int threshold;
    //负载因子,代表了table的填充度有多少,默认是0.75
    final float loadFactor;
    //用于快速失败,由于HashMap非线程安全,在对HashMap进行迭代时,如果期间其他线程的参与导致HashMap的结构发生变化了(比如put,remove等操作),需要抛出异常ConcurrentModificationException
    transient int modCount;
    
    final Node<K,V>[] resize() {
    		//旧table数组的镜像
            Node<K,V>[] oldTab = table;
            int oldCap = (oldTab == null) ? 0 : oldTab.length;//容量
            int oldThr = threshold;//size门限
            int newCap, newThr = 0;
            if (oldCap > 0) {
    			//原有容量超过最大容量
                if (oldCap >= MAXIMUM_CAPACITY) {
                    threshold = Integer.MAX_VALUE;
                    return oldTab;
                }
    			//旧数组容量的两倍小于最大容量,且数组容量大于默认容量
                else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                         oldCap >= DEFAULT_INITIAL_CAPACITY)
                    newThr = oldThr << 1; // double threshold
            }
    		//新数组的容量调整为旧数组的size门限
            else if (oldThr > 0) // initial capacity was placed in threshold
                newCap = oldThr;
            else {               // zero initial threshold signifies using defaults
                newCap = DEFAULT_INITIAL_CAPACITY;
    			//size门限值 = 负载因子 * 容量 
                newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
            }
            if (newThr == 0) {
                float ft = (float)newCap * loadFactor;
                newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                          (int)ft : Integer.MAX_VALUE);
            }
            threshold = newThr;
            @SuppressWarnings({"rawtypes","unchecked"})
            Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
            table = newTab;
    		//一般情况下,容量*2,负载因子不变,则size门限值*2
    		//将旧的数据移到新的数组
            if (oldTab != null) {
            ...略
            }
            return newTab;
        }
    

    扩容可以归纳为:

    • 一般情况下,门限值 = 负载因子 * 容量
    • 门限值以倍数调整 newThr = oldThr << 1
    • 扩容后,要把旧数组的元素重新放入新数组

    容量初始化
    由于频繁扩容影响效率,所以初始化HashMap时要选择好初始容量,要大于预估元素数量/负载因子,且为2的幂数。

    负载因子
    负载因子小于1,目的是减少哈希碰撞,默认值0.75一般不需要修改。

    树化

    fnal void treeifyBin(Node<K,V>[] tab, int hash) {
    	int n, index; Node<K,V> e;
    	if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
    		resize();
    	else if ((e = tab[index = (n - 1) & hash]) != null) {
    	//树化改造逻辑
    	}
    }
    

    当binCount(链表中节点个数)大于TREEIFY_THRESHOLD时,执行树化逻辑。
    如果容量小于MIN_TREEIFY_CAPACITY,只会进行简单的resize。如果容量大于MIN_TREEIFY_CAPACITY ,则会进行树化改造。

    get

    通过key值返回对应value,如果key为null,直接去table[0]处检索。
    key(hashcode)-->hash-->取模,找到对应位置table[i],再查看是否有链表,遍历链表,通过key的equals方法比对查找对应的记录。

        final Node<K,V> getNode(int hash, Object key) {
            Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
            if ((tab = table) != null && (n = tab.length) > 0 &&
                (first = tab[(n - 1) & hash]) != null) {
                if (first.hash == hash && // always check first node
                    ((k = first.key) == key || (key != null && key.equals(k))))
                    return first;
                if ((e = first.next) != null) {
                    if (first instanceof TreeNode)
                        return ((TreeNode<K,V>)first).getTreeNode(hash, key);
                    do {
                        if (e.hash == hash &&
                            ((k = e.key) == key || (key != null && key.equals(k))))
                            return e;
                    } while ((e = e.next) != null);
                }
            }
            return null;
        }
    

    为什么equals和hashCode要同时重写?

    equals()未重写:equals()继承自Object,未重写时其作用与==相同,只判断比较对象存储的值是否相等,当比较对象是引用时,若引用地址相同则返回true,否则,即使两个对象存储的内容是一样的(逻辑上是相等的),依然返回false。
    重写后:通过自定义,使某些值逻辑上相等也会返回true,只有引用地址不同且存储内容不同时,才返回false。

    hashcode()重写前:Object 对象的 hashCode() 方法会根据不同的对象生成不同的哈希值,默认情况下为了确保这个哈希值的唯一性,是通过将该对象的内部地址转换成一个整数来实现的。
    重写后:hashcode 就不再是默认的对象内部地址了,而是自己定义的一个值,保证逻辑上相等。

    使用hashcode的目的:相比equlas,它是一种粗粒度的比较,且速度较快。用于初步筛选,当hashcode不同时,其存储内容一定不同,就不需要用equals比较了。

    hashcode与equals的基本约定:

    • equals相等,则hascode一定相等
    • 两者必须同时重写

    两者同时重写,并不准确,应该说重写了equlas就一定要重写hascode,否则会出问题。参考https://www.cnblogs.com/skywang12345/p/3324958.html
    这是为了保证,当equals返回true时,hashcode一定相同。当hashcode相同时,equals不一定返回true。

    如果不同时重写
    下面的例子重写了equals,但没重写hashcode。

    import java.util.HashMap;
    public class Test {
        private static class Person{
            int id;
            String name;
    
            public Person(int id, String name) {
                this.id = id;
                this.name = name;
            }
            @Override
            public boolean equals(Object o) {
                if (this == o) {
                    return true;
                }
                if (o == null || getClass() != o.getClass()){
                    return false;
                }
                Person person = (Person) o;
                //两个对象是否等值,通过id来确定
                return this.id == person.id;
            }
        }
        public static void main(String []args){
            HashMap<Person,String> map = new HashMap<Person, String>();
            Person p1 = new Person(1,"张三");
            Person p2 = new Person(1,"张三");
            map.put(p1,"一班");
            //get取出,从逻辑上讲应该能输出“一班”
            System.out.println("结果:"+map.get(p2));
        }
    }
    

    上述代码返回null。对于重写的equals,p1 和 p2 是相等的,但因为没有重写hashcode,导致get时出现问题。再看一下get的代码,需要hashcode相同且key逻辑上相同。本例中虽然key p1 和 p2的equals返回true,但由于hashcode未重写,导致get失败。

    if (e.hash == hash && 
         ((k = e.key) == key || (key != null && key.equals(k))))
    
    

    为何HashMap的数组长度一定是2的次幂?

    获取数组下标要对h取模

    n = (tab = resize()).length;
    tab[i = (n - 1) & hash]
    

    h & (length-1)等价于 h % lenght,但是位运算操作比取模运算代价小。

    如令 x = 1<<4,即 x 为 2 的 4 次方
    在这里插入图片描述
    y 与 x-1 做与运算,其作用是将y的前4位清零,结果与 y 对 x 取模相同
    在这里插入图片描述
    在这里插入图片描述
    判断是否存在相同key的节点

    //判断是否已经存在
    if (p.hash == hash &&
        ((k = p.key) == key || (key != null && key.equals(k))))
    

    上文已提到,hash是hashcode(key)后经过一些列移位操作的结果,如果两个Entry的hash相同且key的equals()返回true(逻辑上相等),则用新的覆盖旧的。

    线程安全

    hashmap是非线程安全的,jdk1.7版本的hashmap在多线程并发扩容时,有可能会形成循环链表,再次插入链表会陷入死循环。同时,jdk1.8版本中,会因为其他原因陷入死循环,因为hashmap本来就不是卖你想多线程的,如有需要还是使用ConcurrentHashMap

    参考

    https://zhuanlan.zhihu.com/p/21673805
    https://www.cnblogs.com/chengxiao/p/6059914.html
    《Java核心技术36讲》 杨晓峰

  • 相关阅读:
    编译原理-第二章 一个简单的语法指导编译器-2.4 语法制导翻译
    编译原理-第二章 一个简单的语法指导编译器-2.3 语法定义
    编译原理-第二章 一个简单的语法指导编译器-2.2 词法分析
    LeetCode 1347. Minimum Number of Steps to Make Two Strings Anagram
    LeetCode 1348. Tweet Counts Per Frequency
    1349. Maximum Students Taking Exam(DP,状态压缩)
    LeetCode 1345. Jump Game IV(BFS)
    LeetCode 212. Word Search II
    LeetCode 188. Best Time to Buy and Sell Stock IV (动态规划)
    LeetCode 187. Repeated DNA Sequences(位运算,hash)
  • 原文地址:https://www.cnblogs.com/ChengzhiYang/p/12402615.html
Copyright © 2011-2022 走看看