集合框架-HashMap源码阅读

HashMap

参考文章:Java集合:HashMap详解(JDK 1.8)【面试+工作】

JDK1.7和JDK1.8的变化

  1. JDK7中采用数组+链表,JDK8中采用数组+链表/红黑树(默认链表长度大于8时转为树),树长度为6退化为链表。
  2. JDK7中扩容需要重新计算哈希值和索引位置,JDK8不重新计算哈希值,而是巧妙的采用扩容后的容量进行&操作来计算新的索引位置。
  3. JDK7采用头插法,JDK8采用尾插法
  4. JDK7采用头插法会导致扩容时有可能出现链表成环问题。JDK8则无此问题。
  5. JDK7使用Entry,JDK8使用Node(node本质上也是继承Entry)

哈希冲突

  1. 开放地址法

    • 线性探测
    • 再平方探测
    • 伪随机探测
  2. 再散列法

  3. 链地址法

  4. 建立公共溢出区

    建立公共溢出区存储所有哈希冲突的数据。

默认值

/**
 * 默认初始容量16(必须是2的幂次方)
 */
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;

/**
 * 最大容量,2的30次方
 */
static final int MAXIMUM_CAPACITY = 1 << 30;

/**
 * 默认加载因子,用来计算threshold
 */
static final float DEFAULT_LOAD_FACTOR = 0.75f;

/**
 * 链表转成树的阈值,当桶中链表长度大于8时转成树 
   threshold = capacity * loadFactor
 */
static final int TREEIFY_THRESHOLD = 8;

/**
 * 进行resize操作时,若桶中数量少于6则从树转成链表
 */
static final int UNTREEIFY_THRESHOLD = 6;

/**
 * 桶中结构转化为红黑树对应的table的最小大小

 当需要将解决 hash 冲突的链表转变为红黑树时,
 需要判断下此时数组容量,
 若是由于数组容量太小(小于 MIN_TREEIFY_CAPACITY )
 导致的 hash 冲突太多,则不进行链表转变为红黑树操作,
 转为利用 resize() 函数对 hashMap 扩容
 */
static final int MIN_TREEIFY_CAPACITY = 64;
/**
 保存Node<K,V>节点的数组
 该表在首次使用时初始化,并根据需要调整大小。 分配时,
 长度始终是2的幂。
 */
transient Node<K,V>[] table;

/**
 * 存放具体元素的集
 */
transient Set<Map.Entry<K,V>> entrySet;

/**
 * 记录 hashMap 当前存储的元素的数量
 */
transient int size;

/**
 * 每次更改map结构的计数器
 */
transient int modCount;

/**
 * 临界值 当实际大小(容量*填充因子)超过临界值时,会进行扩容
 */
int threshold;

/**
 * 负载因子:要调整大小的下一个大小值(容量*加载因子)。
 */
final float loadFactor;

Node

hash

static final int hash(Object key) {
    int h;
    // 从这里的key == null返回0可以看出,hashmap允许key为null(但只允许一个,因为hash为0最多只有一个) 
    // (h = key.hashCode()) ^ (h >>> 16)其实是高16位与低16位异或
    // 因为a^0 = a, h>>>16就导致原来的高16位跑到了低16位,举个例子
    // hashcode原本是 01010101 00001111 11110000 10101010
    // h >>> 16无符号右移:00000000 00000000 01010101 00001111   --- 高16位跑到低16位了
    //          hashcode ^01010101 00001111 11110000 10101010
    //                   =01010101 00001111 10100101 10100101
    // 就相当于高16位不变,低16位变成:高16位与低16位的异或
    // 好处当然是减少hash冲突、其它的好处暂时不知道,等知道了再补充
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

tableSizeFor

/**
 * 对于给定的目标容量,返回两倍大小的幂(返回的容量必须是2的幂次)
 * 该算法让最高位的1后面的位全变为1,然后加个1,就是2次幂了
 * 找到大于等于原值的最低2的次幂的数
 * Returns a power of two size for the given target capacity.
 */
static final int tableSizeFor(int cap) {
    // 这里减1是为了处理cap本身就是2的次幂的情况
    // 比如1000 0000 计算后为 1111 1111 再加1就变成1 0000 0000了,显然不符合
    int n = cap - 1;
    // 假设cap为 0100 1000 = 72, n = 0100 0111 (这里只用8位做例子,和32位情况差不多)
    // n>>>1 = 0010 0011, n |= (n>>>1) = 0110 0111
    // n>>>2 = 0001 1001, n |= (n>>>2) = 0111 1111
    // n>>>4 = 0000 1111, n |= (n>>>2) = 0111 1111
    // n + 1 = 1000 0000 = 128 (具体72最小的次幂为2的数)
    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;
}

构造方法

/**
     * Constructs an empty <tt>HashMap</tt> with the specified initial
     * capacity and load factor.
     *
     * @param  initialCapacity the initial capacity
     * @param  loadFactor      the load factor
     * @throws IllegalArgumentException if the initial capacity is negative
     *         or the load factor is nonpositive
     */
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;
    // 这里要调整一下阈值的大小为2的次幂,比如传进来的是15,就会调整为16
    this.threshold = tableSizeFor(initialCapacity);
}

get

public V get(Object key) {
    Node<K,V> e;
    // 如果为节点为null,返回null
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}
// 真正获取Node的方法
final Node<K,V> getNode(int hash, Object key) {
    Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
    // table不为null 且 table的length大于0 且 该hash对应得位置上有node
    // (length - 1) & hash计算下标
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {
        if (first.hash == hash && // always check first node 总是先检查第一个元素
            // 根据hash和key确定唯一
            // 先比较地址,地址不一样再比较equals(存在重写equals情况,地址不一样不代表不是同一个key)
            ((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;
}

containsKey

// 实际上是调用了getNode
public boolean containsKey(Object key) {
    return getNode(hash(key), key) != null;
}

remove

如果移除成功,则返回移除前的值。否则返回null

// 如果移除成功,则返回移除前的值。否则返回null
public V remove(Object key) {
    Node<K,V> e;
    return (e = removeNode(hash(key), key, null, false, true)) == null ?
        null : e.value;
}

put

public V put(K key, V value) {
    return  putVal(hash(key), key, value, false, true);
}

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-dNIajKJH-1637210850980)(HashMap.assets/HashMap-1231231231.png)]

/**
 * Implements Map.put and related methods.
 *
 * @param hash hash for key
 * @param key the key
 * @param value the value to put
 * @param onlyIfAbsent if true, don't change existing value
 * @param evict if false, the table is in creation mode.
 * @return previous value, or null if none
 */
// onlyIfAbsent为false,说明如果已经存在相同(== 、equals)的key,则覆盖并返回旧值。
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    // 如果table为空,resize
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    // p接收对应table的hash位置的node
    // 如果table表对应的位置没有hash冲突,直接放入到table就行
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    // 否则解决链表冲突
    else {
        Node<K,V> e; K k;
        // 如果table里面对应的hash位置头节点就是需要put的节点,直接跳到后面覆盖旧值
        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里面,
                // 则说明整个链表都找不到与需要插入节点相同的key,这时需要新插入值了
                if ((e = p.next) == null) {
                    // 尾部添加一个节点
                    p.next = newNode(hash, key, value, null);
                    // 链表长度大于8,转换为红黑树
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
                // 如果找到与需要put节点键一样的节点,不用管,直接退出
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        // e不为null说明之前的hashmap中是存在该键的,这是需要根据onlyIfAbsent判断是否需要覆盖旧值
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                // 覆盖
                e.value = value;
            afterNodeAccess(e); // do nothing
            // 这里直接返回了,因为没有插入新节点,只是修改值,所以不需要执行下面逻辑
            return oldValue;
        }
    }
    ++modCount;
    // 跑到这一步,说明是新插入了一个节点,所以size要自增,同时判断容量是否超过阈值
    if (++size > threshold)
        // resize扩容
        resize();
    afterNodeInsertion(evict);
    return null;
}

事实上,new HashMap();完成后,如果没有put操作,是不会分配存储空间的。

  1. 当桶数组 table 为空时,通过扩容的方式初始化 table
  2. 查找要插入的键值对是否已经存在,存在的话根据条件判断是否用新值替换旧值
  3. 如果不存在,则将键值对链入链表中,并根据链表长度决定是否将链表转为红黑树
  4. 判断键值对数量是否大于阈值,大于的话则进行扩容操作

扩容机制

在 HashMap 中,桶数组的长度均是2的幂,阈值大小为桶数组长度与负载因子的乘积。当 HashMap 中的键值对数量超过阈值时,进行扩容。

  1. 计算新桶数组的容量 newCap 和新阈值 newThr
  2. 根据计算出的 newCap 创建新的桶数组,桶数组 table 也是在这里进行初始化的
  3. 将键值对节点重新映射到新的桶数组里。如果节点是 TreeNode 类型,则需要拆分红黑树。如果是普通节点,则节点按原顺序进行分组。

总结起来,一共有三种扩容方式

  1. 使用默认构造方法初始化HashMap。从前文可以知道HashMap在一开始初始化的时候会返回一个空的table,并且thershold为0。因此第一次扩容的容量为默认值DEFAULT_INITIAL_CAPACITY也就是16。同时threshold = DEFAULT_INITIAL_CAPACITY * DEFAULT_LOAD_FACTOR = 12
  2. 指定初始容量的构造方法初始化HashMap。那么从下面源码可以看到初始容量会等于threshold,接着threshold = 当前的容量(threshold) * DEFAULT_LOAD_FACTOR
  3. HashMap不是第一次扩容。如果HashMap已经扩容过的话,那么每次table的容量以及threshold量为原有的两倍。
resize
final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table;
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;
    int newCap, newThr = 0;
    // 已经经历过put或者在构造方法里面传入map后,oldCap才大于0,否则都是=0
    if (oldCap > 0) {
        // 旧容量超过MAXIMUM_CAPACITY时,阈值为整型最大值
        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
    }
    else if (oldThr > 0) // initial capacity was placed in threshold
        // 第一次扩容且初始化的时候传入了初始容量的情况才会进入到这里
        // 新容量被赋予旧的阈值
        newCap = oldThr;
    else {               // zero initial threshold signifies using defaults
        // 第一次扩容且初始化的时候使用默认构造方法的情况
        // 赋予默认值
        newCap = DEFAULT_INITIAL_CAPACITY;
        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;
    if (oldTab != null) {
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            if ((e = oldTab[j]) != null) {
                // 置空,方便GC
                oldTab[j] = null;
                // 该位置没有hash冲突(没有next)
                if (e.next == null)
                    newTab[e.hash & (newCap - 1)] = e;
                else if (e instanceof TreeNode)
                    // yi
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                else { // preserve order
                    Node<K,V> loHead = null, loTail = null;
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    // 这里是把链表分为两个链表(根据e.hash & oldCap)
                    // 这里好好说一下,为什么能通过e.hash & oldCap就能确定位置
                    // 因为之前是通过e.hash & (oldCap - 1)来计算下标的
                    // 那么resize之后,是要通过遍历e.hash & (newCap - 1)来计算新下标的
                    // 但是这里有个技巧,我用oldCap=16作为一个例子
                    // 得:oldCap - 1 = 15 = 0000 1111
                    //     oldCap = 16     = 0001 0000
                    //     newCap - 1 = 31 = 0001 1111
                    // 我们可以看到,它们的区别就是第五位,新容量多了一个1
                    // 因为链表元素的下标都是一样的(一样才有冲突),所以关键是第五位的 & 1
                    // 那么e.hash & oldCap 其实就是e.hash & 0001 0000 &上第五位,
                    // 如果e.hash & oldCap等于0,说明hash的第五位是0,hash&(newCap - 1)也和hash&(oldCap - 1),位置不变
                    // 如果不为0,说明hash&(newCap - 1)要多出oldCap
                    do {
                        next = e.next;
                        if ((e.hash & oldCap) == 0) {
                            if (loTail == null)
                                // head保留着头部
                                loHead = e;
                            else
                                // tail一一直往后走
                                loTail.next = e;
                            loTail = e;
                        }
                        else {
                            if (hiTail == null)
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    // 拼接
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}
3. 重写对象的Equals方法时,要重写hashCode方法,为什么?跟HashMap有什么关系?

equals与hashcode间的关系:

  1. 如果两个对象相同(即用equals比较返回true),那么它们的hashCode值一定要相同;
  2. 如果两个对象的hashCode相同,它们并不一定相同(即用equals比较返回false)

因为在 HashMap 的链表结构中遍历判断的时候,特定情况下重写的 equals 方法比较对象是否相等的业务逻辑比较复杂,循环下来更是影响查找效率。所以这里把 hashcode 的判断放在前面,只要 hashcode 不相等就玩儿完,不用再去调用复杂的 equals 了。很多程度地提升 HashMap 的使用效率。

所以重写 hashcode 方法是为了让我们能够正常使用 HashMap 等集合类,因为 HashMap 判断对象是否相等既要比较 hashcode 又要使用 equals 比较。而这样的实现是为了提高 HashMap 的效率。

HashMap和Hashtable的区别:

  1. HashMap允许key和value为null,HashMap的key为null时,值为null,此时hash(key)为0,Hashtable不允许。
  2. HashMap的默认初始容量为16,Hashtable为11。
  3. HashMap的扩容为原来的2倍,Hashtable的扩容为原来的2倍加1。
  4. HashMap是非线程安全的,Hashtable是线程安全的。
  5. HashMap的hash值重新计算过,Hashtable直接使用hashCode。
  6. HashMap去掉了Hashtable中的contains方法。
  7. HashMap继承自AbstractMap类,Hashtable继承自Dictionary类。

经典面试题

1. 为什么hashmap的容量是2的次幂

因为hashmap计算数组下标的方式是(n - 1) & hash, n为数组容量。如果n是2的次幂,那么2的次幂-1就会等于00111111的形式,也就是二进制是连续的1。又加上a&1 = a,这样元素对应的下标只由自己的hash的后几位决定,所以数据会更平均一些。如果n-1是00001000的形式,那么&的时候后面三位都为0了,数据分布会很不平衡。

总结:

  1. HashMap的底层是个Node数组(Node<K,V>[] table),在数组的具体索引位置,如果存在多个节点,则可能是以链表或红黑树的形式存在。
  2. 增加、删除、查找键值对时,定位到哈希桶数组的位置是很关键的一步,源码中是通过下面3个操作来完成这一步:1)拿到key的hashCode值;2)将hashCode的高位参与运算,重新计算hash值;3)将计算出来的hash值与(table.length - 1)进行&运算。
  3. HashMap的默认初始容量(capacity)是16,capacity必须为2的幂次方;默认负载因子(load factor)是0.75;实际能存放的节点个数(threshold,即触发扩容的阈值)= capacity * load factor。
  4. HashMap在触发扩容后,阈值会变为原来的2倍,并且会进行重hash,重hash后索引位置index的节点的新分布位置最多只有两个:原索引位置或原索引+oldCap位置。例如capacity为16,索引位置5的节点扩容后,只可能分布在新报索引位置5和索引位置21(5+16)。
  5. 导致HashMap扩容后,同一个索引位置的节点重hash最多分布在两个位置的根本原因是:1)table的长度始终为2的n次方;2)索引位置的计算方法为“(table.length - 1) & hash”。HashMap扩容是一个比较耗时的操作,定义HashMap时尽量给个接近的初始容量值。
  6. HashMap有threshold属性和loadFactor属性,但是没有capacity属性。初始化时,如果传了初始化容量值,该值是存在threshold变量,并且Node数组是在第一次put时才会进行初始化,初始化时会将此时的threshold值作为新表的capacity值,然后用capacity和loadFactor计算新表的真正threshold值。
  7. 当同一个索引位置的节点在增加后达到9个时,会触发链表节点(Node)转红黑树节点(TreeNode,间接继承Node),转成红黑树节点后,其实链表的结构还存在,通过next属性维持。链表节点转红黑树节点的具体方法为源码中的treeifyBin(Node<K,V>[] tab, int hash)方法。
  8. 当同一个索引位置的节点在移除后达到6个时,并且该索引位置的节点为红黑树节点,会触发红黑树节点转链表节点。红黑树节点转链表节点的具体方法为源码中的untreeify(HashMap<K,V> map)方法。
  9. HashMap在JDK1.8之后不再有死循环的问题,JDK1.8之前存在死循环的根本原因是在扩容后同一索引位置的节点顺序会反掉。
  10. HashMap是非线程安全的,在并发场景下使用ConcurrentHashMap来代替。
  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值