对HashMap的一次简单总结(源码分析)

简单概述

  • jdk1.8以前,数组(Entry)+链表
  • jdk1.8及以后,数组(Node)+链表+红黑树

基本知识:

属性:

  1. 默认的初始数组长度:16。最大容量:2的30次幂。容量长度总是为2的次幂(1转为2进制然后左移4位后转为10进制就是16。或者:1<<4 == 2的4次幂。至于为什么是2的次幂。(因为在使用不是2的次幂的数字的时候,Length-1后得出来的二进制位全为1.这种情况下Index的结果会等同于hashcode后几位的值,不利于分布均匀——来自摘抄) 这样可以方便位运算,对象均匀分布,减少哈希碰撞。为什么初始值是16.而不是14 ,12 ? 好像也是为了减少哈希冲突,因为Index的取值运算是跟你的容量长度有关的:index = hashcode(key) & length-1 )
  2. 默认的加载因子:0.75。意思就是当你的HashMap里存放的对象数量达到当前容量的75%的时候就开始扩容,扩容为原来的2倍。这里的数量是指的数组元素个数,而不单纯是你存入的数量。可能有点绕,就是说如果没有发生哈希碰撞,那么当你默认创建HashMap实例后,当你存入的对象数量大于16*0.75。也就是12个时,HashMap才会触发扩容机制 。但是当发生了哈希碰撞,你存入的所有对象都碰撞到了1个·或者2个数组元素当中(哈希碰撞的解决方法是数组转链表),所以HashMap中的数组元素依然只有2个,不会触发扩容。
  3. 树化的阈值:8(链表长度大于8转红黑树)。接上面数组转链表问题——当HashMap发生碰撞。将一些对Key经过运算后得到一样值的对象会存入同一个数组内。这个时候HashMap底层就会将这个数组转为一个单向链表。所有运算后得到相同值得对象就会存入这个链表中,先存入的在链表的最底端。链表的顶部永远是最后一个存入的对象 (jdk1.8以前是头插。jdk1.8及以后是尾插,尾插就是新加入的元素在链表最下面。这样可以防止在扩容后由于重新分配了位置导致查询的时候的死循环。)。然后当单个链表的长度超过8时,对于查找对象来说性能会有所下降,这个时候就会转为红黑树来维持查找性能(只有jdk1.8之后才会转红黑树)
  4. 树恢复的阈值:6(红黑树长度小于6转链表)。这个树恢复又是什么意思呢?。首先因为链表太长导致树化,然后到后面存入的对象越来越多,开始触及到扩容。扩容一次后,该HashMap里的所有对象要重新计算存储位置。这个时候当原红黑树下存的对象数量小于6时。红黑树会自动退化为链表,因为维护红黑树的平衡也需要消耗性能。
  5. 树化的最小容量阈值:64 树化的另一个前置条件就是必须要当前的数组长度,也就是容量要大于64。
静态属性:
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 默认容量
static final int MAXIMUM_CAPACITY = 1 << 30; //最大容量
static final float DEFAULT_LOAD_FACTOR = 0.75f; //默认加载因子
static final int TREEIFY_THRESHOLD = 8; //链表转树的阈值
static final int UNTREEIFY_THRESHOLD = 6; //树转链表的阈值
static final int MIN_TREEIFY_CAPACITY = 64; //树化的最小容量要求
普通属性:
transient Node<K,V>[] table; //存放数据的数组,也就是上面提到过的数组
transient Set<Map.Entry<K,V>> entrySet; //存储key-value,调用entrySet()方法能获取到所有的key-value。详情看下图
transient int size; //存入map对象的个数
transient int modCount; //记录修改次数
int threshold; //阈值,threshold=capacity*loadFactor
final float loadFactor; //加载因子 默认值为0.75f。计算HashMap的实时装载因子的方法为:size/capacity

(有点需要注意的是,上面几个普通的成员属性前面的 transient 关键字的意思是——被此关键字修饰的属性不进行序列化)
图1-1,测试entrySet()方法
构造方法:

  1. 使用默认的构造。默认初始化容量,默认加载因子
  2. 指定初始化容量,默认加载因子
  3. 指定初始化容量和加载因子
  4. 带另一个Map对象参数的构造方法。使用默认的加载因子,将对象参数的Map放入新的Map
public HashMap() {}
public HashMap(int initialCapacity) {}
public HashMap(int initialCapacity, float loadFactor){}
public HashMap(Map<? extends K, ? extends V> m) {}

高级知识:

首先,上面一直提到过的,数组,链表,都不是很规范。先来看下面这段源码:

static class Node<K,V> implements Map.Entry<K,V> { //存放你单个的key-value。你在对map进行get()、put()的都是这个对象
        final int hash;//哈希值
        final K key; //key
        V value; //value
        Node<K,V> next; //指向下一个的Node<K,V> 链表形式就是这么来的(很关键)

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

        public final K getKey()        { return key; }
        public final V getValue()      { return value; }
        public final String toString() { return key + "=" + value; }

        public final int hashCode() {//重写了hashcode方法(因为减少哈希冲突,所以要重写hash算法)
            return Objects.hashCode(key) ^ Objects.hashCode(value);
        }

        public final V setValue(V newValue) {
            V oldValue = value;
            value = newValue;
            return oldValue;
        }

        public final boolean equals(Object o) {
            if (o == this)//这里也是,重写了equals方法
                return true;
            if (o instanceof Map.Entry) {
                Map.Entry<?,?> e = (Map.Entry<?,?>)o;
                if (Objects.equals(key, e.getKey()) &&
                    Objects.equals(value, e.getValue()))
                    return true;
            }
            return false;
        }
    }

这个Node<K,V>它是HashMap的一个静态内部类,专门用来存储咱们需要存的数据。可以注意到,它是通过一个 Node<K,V> next; 来形成一个单向链表的形式;另外还有一点,这个类它重写了hashcode方法以及equals方法。这里涉及到一点:重写了hashcode方法后一定要重写equals方法。重写了equals方法后就一定要重写hashcode方法。这样才能保证相同的对象返回相同的hash值,不同的对象返回不同的值而在HashMap中我们在找到key对应的index后,不管是要进行put还是get操作。都要用到Node<K,V>的equals()方法去比对。

存之——put()方法:

问:HashMap到底是如何put元素的呢?

答:我们大概讲下具体思路流程,再去根据源码分析。首先插入之前要先确定所要插入的位置,也就是索引值。得到索引值后再判断该位置是已有数据在此如果为空,则直接存入。不为空就要继续判断,当前位置的数据(对象)是否跟即将要插入的数据一致(hashcode值、key值、以及equals),如果一致。则替换数据返回旧value。如果不一致,接下来就再判断当前的Node<K,V>。是否已经转为TreeNode<K,V>。(也就是当前是否已经转红黑树了),如果已 经转了,则直接调用对应方法插入数据,如果没转,则往下遍历当前链表节点,同时再进行比对,如果有跟当前要插入的数据一致的话,替换返回旧值。 直到链表末端再插入数据。这个时候假如说遍历到链表节点大于或等于8。那么就会将当前链表Node<K,V>转为红黑树TreeNode<K,V>。最后无论是那种方式插入。都会比对一下当前是否需要扩容。如果扩容后就会重新计算索引并分配位置,因为容量长度发生了改变。

附上源码:


public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
    }//put方法调用了下面的putVal方法
    
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;//获取当前容量的长度
        if ((p = tab[i = (n - 1) & hash]) == null)//判断当前索引位是否有数据
            tab[i] = newNode(hash, key, value, null);//没有。直接存入
        else {
            Node<K,V> e; K k;
            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 {//没转
                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);//将前面新建的Node<K,V>插入保存
                return oldValue;//返回旧值
            }
        }
        ++modCount;
        if (++size > threshold)//比较当前数量是否已经达到扩容阈值
            resize();//调用扩容方法
        afterNodeInsertion(evict);//将数据重新计算分配位置
        return null;
    }

取之——get()方法:

问:HashMap又是如何get()数据的呢?

答:get()方法相对于put来说就要简单很多,大概思路是这样的:直接根据你当前的这个map容量计算出该key所在的数组索引位置,然后比对该位置的数据是否和你要get()的数据一致(方法同上,根据hashcode,key,equlas),如果一致,直接返回该位置上的Node<K,V>。如果不一致,则再判断当前是否已经树化TreeNode<K,V>,如果已树化,直接调用方法返回对象。如果没有树化,则一直往下遍历,对比。直到找到对应的数据为止。

附上源码:

public V get(Object key) {
        Node<K,V> e;
        return (e = getNode(hash(key), key)) == null ? null : e.value;
    } //调用下面的getNode(),两个参数,一个当前key的hash值,一个当前key
    
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) {//首先,当前map、以及该key所被分配的数组位置,肯定是不能为空的,否则直接返回null
            if (first.hash == hash && // always check first node
                ((k = first.key) == key || (key != null && key.equals(k))))//先检查索引位置头部的对象是否是需要get()的对象。如果是,直接返回索引处的第一个Node<K,V>
                return first;
            if ((e = first.next) != null) {//判断是否到了索引处往下的最后一个对象
                if (first instanceof TreeNode)//没到,再判断当前是否已树化
                    return ((TreeNode<K,V>)first).getTreeNode(hash, key);//直接调用getTreeNode()方法返回所需要的对象
                do {//没有树化,直接遍历比较
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))//直到找到所需的对象,直接return
                        return e;
                } while ((e = e.next) != null);//通过e.next赋值给e也就是自己后才能一直往下遍历
            }
        }
        return null;//否则返回空
    }

结尾:

上面大概介绍了HashMap的所有字段属性、底层结构以及如何存取。总体总结下来,个人收获良多。继续加油!!!

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值