深入理解HashMap底层结构

深入理解HashMap

知识点:

  • HashMap的几个重要组成?
  • HashMap的存储结构?
  • HashMap的扩容机制?
  • HashMap的使用?

一、HashMap(https://www.bilibili.com/video/BV1Z54y1e7id?p=1学习视频)

1、概述

  • HashMap以键值对的方式进行存储,键不能重复,值可以重复但一个键只能对应一个值。底层采用数组+链表+红黑树(JDK1.8及之后)的数据结构。
    • 数组:底层实际是存储一个节点Node的地址;
    • 链表:引入链表是为了解决哈希冲突问题;
    • 红黑树:引入红黑树是为了解决当链表长度大于8且数组长度大于64时查询慢的问题。
  • 哈希冲突:在HashMap要添加元素时会先调用底层本地的方法计算Key的hashCode(),当计算的hashCode一致时即为哈希冲突(计算的索引值也一样)。

二、Map继承图继承关系

image

public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable
  • public abstract class AbstractMap<K,V> implements Map<K,V>与HashMap<K,V>同时实现Map<K,V>为什么呢?
    • 这是由于集合框架的创建者在编写的时候为了能够有一定的作用,后来发现没有什么用,但不删除也没什么影响就没删除了。仔细发现ArrayList、LinkedList也是这样。

三、源码剖析Hash的存储结构

1、HashMap数据结构

image

2、HashMap类成员变量

2.1、序列化版本号:集合可序列化
private static final long serialVersionUID = 362498820763181265L;
2.2、 默认初始化容量(构造方法中可修改):16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
  • 创建时可指定初始容量,但必须是2的n次幂,如果不是则出现下面的处理方法:HashMap通过一通位移运算和或运算(tableSizeFor(int cap))得到的肯定是2的幂次数,并且是离指定容量数更大的、最近的(最小的)2的n次幂的数字。
    • 索引 = (n - 1) & hash这个算法计算索引值和索引 = (n - 1)%hash(取余数)在n(容量)是2的m次幂时两个是相等的(计算机使用位移运算的速度比较快)。
    • 为什么要修改位为2的n次幂
      • 减少哈希碰撞,使空间更加均匀分配(可以试想当容量不为2的n次幂时,经过使用8和9对比,9时的哈希碰撞(计算出的索引值相同)比较多)。
      • 使用时可以位运算,提高性能。
    • 如何减少哈希碰撞的次数?
      • 容量为2的n次幂(求索引时减少碰撞)
      • 使用hashCode()的返回值进行左移16位并异或运算(这样异或运算就结合的高、低位的特征得到的哈希值)
  • HashMap的扩容操作会影响性能,在实际的开发中,我们如果把容量建小的话会导致多次扩容的操作,但如果建的太大又可能浪费,一般进行一下操作:
    • 已知数据的容量:由阿里巴巴推荐的公式,
2.3、 集合的最大容量:2的30次幂
static final int MAXIMUM_CAPACITY = 1 << 30;
2.4、 默认负载因子(构造方法中可修改):决定已经使用容量达到多少时进行扩容,例如已使用的容量达到总容量的75%时进行扩容。
static final float DEFAULT_LOAD_FACTOR = 0.75f;
  • 负载因子大小的利与弊
    • 负载因子过大:负载因子过大,虽然数组的空间利用率会提升,但会数组比较拥挤,碰撞率会变高,导致产生较多的链表甚至红黑树结构,最终导致HashMap的效率低下。
    • 负载因子过小:负载因子过小,即数组的使用都还没到总容量的75%时就扩容,碰撞率低且链表变短,数组的空间利用率低,扩容又开辟的内存而浪费资源,而且扩容操作同时也影响运行效率。
  • 实际工程的负载因子确定?
    • 负载因子0.75是经过开发者大量的测试得到的一个最佳值,实际中如果没有什么特别的需要可以不改变。
    • 负载因子尽可能小:需求链表尽可能少,不考虑内存消耗的问题。
    • 负载因子尽可能大:需求减少扩容次数,尽量使用完数组内存。
  • 当负载因子大于1时会发生什么
    • 当负载因子大于1时,数组的容量小于临界值threshold,由于存储数据时是哈希值对数组取余数,所以数组的长度达不到临界值,如果一直添加元素,哈希表中就不断出现哈希冲突,导致链表不断增加,最后红黑树也不断增大,HashMap的效率变得极低。
2.5、 临界值threshold:当使用的容量达到该值时进行扩容。
int threshold;
  • 计算公式:threshold = 容量 * 负载因子,注意在初始化时并不是这样计算的,通过tableSizeFor方法初始化,不过后面在扩容时会使用回来该公式,后面扩容时再介绍。
2.6、 红黑树节点小于6时转为链表
static final int UNTREEIFY_THRESHOLD = 6;
2.7、 数组大于该值且链表值大于阈值时将链表转为红黑树
static final int MIN_TREEIFY_CAPACITY = 64;

3、构造方法

HashMap的构造方法一共有4个,下面分别介绍:

3.1、空参构造(默认初始容量为16,默认负载因子为0.75)
public HashMap() {
   this.loadFactor = DEFAULT_LOAD_FACTOR; 
}
  • 解析:只是对负载因子loadFactor赋予默认值。
3.2、构造指定初始容量与默认负载因子。
public HashMap(int initialCapacity) {
  this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
  • 解析:传递一个指定的容量,同时传递默认的负载因子。
3.3、构造指定容量和指定负载因子。
/**
 * initialCapacity:指定的容量
 * loadFactor:负载因子
 */ 
public HashMap(int initialCapacity, float loadFactor) {
    //传递的初始容量小于0时则抛出异常
    if (initialCapacity < 0)
        throw new IllegalArgumentException("Illegal initial capacity: " +
                                           initialCapacity);
    //指定的容量大于HashMap的最大容量2的30次幂 
    if (initialCapacity > MAXIMUM_CAPACITY)
        //超过最大容量则将最大容量赋予初始容量。
        initialCapacity = MAXIMUM_CAPACITY;
    //判断负载因子是否小于0或者负载因子是一个非数值,如果true则抛异常。
    if (loadFactor <= 0 || Float.isNaN(loadFactor))
        throw new IllegalArgumentException("Illegal load factor: " +
                                           loadFactor);
    //初始化负载因子
    this.loadFactor = loadFactor;
    //这个方法是个重点,下面展开介绍。(将指定容量变为2的n次幂)
    this.threshold = tableSizeFor(initialCapacity);
    //在tableSizeFor返回的值因为初始化容量,为什么传递给临界值threshold?
    //解析:在jdk8以后的构造方法中,并没有对table这个成员变量进行初始化,table的初始化被推迟到了put方法中,在put方法中会对threshold重新计算,在put方法介绍。
}
  • this.threshold = tableSizeFor(initialCapacity);:对不规范的指定容量进行初始化,这样做的原因是再计算索引index = (n-1)&hash时尽量减少哈希碰撞,简单的举例初始容量为8和9(9不进行规范化)时即可对比出来。
        static final int tableSizeFor(int cap) {
        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;
    }
    
    • 上面一顿的位移、或操作指定的容量,其实在自己找个数手动计算之后,即可知道其中的用途。总结起来就一句话:如果指定容量是2的n次幂,运算后依然是该数,如果指定容量不是2的n次幂,则运算将指定容量改为大于指定容量的最小的2的n次幂(例:如果是9,改为16;如果是25,改为32)。
3.4、构造参数为Map的实例。
public HashMap(Map<? extends K, ? extends V> m) {
    this.loadFactor = DEFAULT_LOAD_FACTOR;
    putMapEntries(m, false);
}
  • 解析:负载因子依然为默认值0.75,同时调用putMapEntries将原有的Map集合放入HashMap中。
  • putMapEntries(m, false);:将原有的Map集合放入新的HashMap中,m为原Map集合,false代表放入的是初始化的Map集合中。
    /**
     * m :传递的原集合
     * evict:使用该方法时是否是向新的(初始化)Map集合中操作数据。
    final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
        //获取原集合的长度
        int s = m.size();
        //原集合中有元素是长度大于0
        if (s > 0) {
            //判断table数组是否被初始化(第一次在构造方法中调用肯定没有被初始化,因为都是在put方法中构造数组的)
            if (table == null) { // pre-size
                //由已知元素的个数求容量,这个是阿里巴巴推荐的容量计算公式。
                float ft = ((float)s / loadFactor) + 1.0F;
                int t = ((ft < (float)MAXIMUM_CAPACITY) ?
                         (int)ft : MAXIMUM_CAPACITY);
                if (t > threshold)
                    threshold = tableSizeFor(t);
            }
            //如果已经有初始化好的table数组(这一步一般在put(Map map)中运行,而不是在构造的时候运行)
            //并且如果原集合的元素个数大于阈值,则进行扩容处理(resize();扩容后面单独介绍)
            else if (s > threshold)
                resize();
            //增强for循环遍历m集合中的元素并将键值对存入新的集合中(hash值重新计算)    
            for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) {
                K key = e.getKey();
                V value = e.getValue();
                putVal(hash(key), key, value, false, evict);
            }
        }
    }
    
    • float ft = ((float)s / loadFactor) + 1.0F;:该行为什么要加1.0F呢?直接s/loadFactor得到的容量不是够了吗?
      • 实际上是为了尽可能获取更大的容量,减少扩容的次数。试想一下,s为6,负载因子为0.75,则容量为8,当存储m的元素达到6个时又要去扩容一次到16,降低了效率。

4、put(key,value)增加方法(重点:HashMap的结构、哈希冲突、扩容、红黑树的处理)

4.1 增加方法的整体解析

HashMap的put方法中涉及的知识点比较多,下面简要说下流程:

  1. 如果是第一次插入则需要调用resize产生存储空间(第一次的threshold临界值就是在这里被改变为容量 * 负载因子)。
  2. 传递了key与value值以后,通过key计算hash值然后计算索引值。
  3. 如果没有哈希碰撞则直接插入。
  4. 如果发生碰撞(索引值一样),则需要对其进行处理:
    • 先判断数组中的key是否一致,如果一致则替换数组(桶)中的元素。
    • 如果是链表则采用传统的链式方法插入。如果链的长度达到临界值,则把链转变为红黑树;
    • 如果该桶使用红黑树处理冲突,则调用红黑树的方法插入数据;
  5. 发现存入的元素个数达到临界值,则扩容。
  • 下面是源码的详细解析:
public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}
/**
 * hash:key计算出的哈希值
 * key:存储的键
 * value:存储的值
 *  onlyIfAbsent:如果是true则不改变集合中已存在的元素
 *  evict:如果是false,该集合则处在创建/构造阶段。
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,table 被延迟到插入新数据时再进行初始化。
    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;
        //如果与数组(桶)中的key相同,则e指向该元素。
        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;
            }
        }
        //判断如果HashMap中有相同的键时执行
        //所以这里就是把该键的值变为新的值,并返回旧值
        if (e != null) { // existing mapping for key
            //记录旧值
            V oldValue = e.value;
            //onlyIfAbsent为false或者旧值为null
            if (!onlyIfAbsent || oldValue == null)
                //用新值替换旧值
                e.value = value;
            //访问后回调
            afterNodeAccess(e);
            //返回旧值
            return oldValue;
        }
    }
    //记录修改次数
    ++modCount;
    //判断实际使用的大小是否大于threshold阈值,如果超过则扩容
    if (++size > threshold)
        resize();
    // 插入后回调方法
    afterNodeInsertion(evict);
    return null;
}
总结:
  • 插入操作的主要逻辑在于final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict)方法。上述操作主要完成的几件事:
    • 当桶数组 table 为空时,通过扩容的方式初始化 table。
    • 查找要插入的键值对是否已经存在,存在的话根据条件判断是否用新值替换旧值。
    • 如果不存在,则将键值对链入链表中,并根据链表长度决定是否将链表转为红黑树。
    • 判断键值对数量是否大于阈值,大于的话则进行扩容操作。
  • 以上操作的逻辑不难理解,主要查找对应键的操作和插入后的扩容、链表转为红黑树的操作。
4.2 扩容机制(resize())

HashMap在扩容时会按数组长度的2倍进行扩容,阈值同样也会变为原来的2倍。

4.2.1 HashMap什么时候会进行扩容?
  • 当第一次使用put方法存储元素时进行扩容(数组的初始化)。
  • 当HashMap中的元素个数超过数组大小(容量)*loadFactor(负载因子)时,就会进行数组扩容,扩大一倍容量。
  • 当HashMap中存在一个链表的元素个数达到了8个且数组的容量小于64时进行扩容。
  • 注意:HashMap进行扩容,JDK1.8之后元素的哈希值并不会重新计算,只是利用原有的哈希值计算出新的索引值。
4.2.2 HashMap怎样进行扩容?

HashMap进行扩容,会伴随着一次重新hash分配(1.8之后的源码并没有这样做的),同时计算索引值,并且会遍历hash表中所有的元素,是非常耗时的。在编写程序中,要尽量避免resize。下面是resize()扩容源码解读:

final Node<K,V>[] resize() {
    /**
     * 第一步:计算新数组(桶)的新容量(newCap)和新阈值(newThr)
     */
    Node<K,V>[] oldTab = table;
    //判断当前数组是否为空并得到旧数组容量
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;
    int newCap, newThr = 0;
    //旧数组容量是否大于0
    if (oldCap > 0) {
        //判断如果旧数组的长度大于最大容量,说明该数组不用扩容了,直接返回旧数组就行了,但threshold阈值为Integer.MAX_VALUE。
        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
        //这个是在构造方法时指定了容量时运行,构造方法中tableSizeFor(int cap)才会出现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) {
                oldTab[j] = null;
                if (e.next == null)
                    newTab[e.hash & (newCap - 1)] = e;
                else if (e instanceof TreeNode)
                    //如果节点是 TreeNode 类型,则需要拆分红黑树。
                    ((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;
                    //遍历链表重新计算元素索引值,并将旧链表分成两个链表(此do..while已经拆分了链表成两个链表)。
                    do {
                        next = e.next;
                        if ((e.hash & oldCap) == 0) {
                            if (loTail == null)
                                loHead = e;
                            else
                                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;
}

resize()源码的解读主要分成三个步骤:

  • 第一步:计算新数组(桶)的新容量(newCap)和新阈值(newThr)。
  • 第二步:根据新容量创建新的数组。
  • 第三步:如果旧的数组不为空,则遍历数组,重新计算旧数组各个元素在新数组里的新位置(索引)。
4.2.3 下面对三个步骤难点部分的补充
  • 第一步:计算新数组(桶)的新容量(newCap)和新阈值(newThr)的条件语句。
条件作用覆盖情况备注
oldCap > 0计算并赋值给新容量和新阈值数组已经被初始化已经使用过put方法的情况
oldThr > 0获取新的容量threshold > 0,且桶数组未被初始化调用 HashMap(int) 和 HashMap(int, float) 构造方法时会产生这种情况
oldCap == 0 && oldThr == 0赋予容量和阈值默认值桶数组未被初始化,且 threshold 为 0调用 HashMap() 构造方法会产生这种情况。
  • oldCap > 0分成两部分:
条件作用覆盖情况备注
oldCap >= 2的30次幂(oldCap >= MAXIMUM_CAPACITY)阈值修改为最大值且返回旧数组桶数组容量大于或等于最大桶容量 2的30次幂这种情况下不再扩容
newCap < 230 && oldCap > 16扩大容量、阈值为原来的2倍新桶数组容量小于最大值,且旧桶数组容量大于 16
  • oldThr > 0就解释了我们使用指定容量后,为什么是threshold = tableSizeFor(initialCapacity)?原来是在构造方法中先保存在threshold中,在put方法再进行计算threshold和容量的值。

  • 第三步:怎样重新计算旧数组各个元素在新数组里的新位置(索引)?

    • 如果只有数组中的一个元素就直接计算(索引 = hash&(新容量-1) )插入即可。
    • 对于树形节点,需先拆分红黑树再插入。(待分析)
    • 对于链表类型节点,则需先对链表进行分组(就是索引分成两组(原位置或者原位置+旧容量两条链表)),然后再插入。
    • 举个例子:当容量=8变为16时计算索引,
      image
      经过上面的计算将链表(包括数组的的那个元素)分成不同的链表,
      image
      由于哈希值与旧容量进行与运算,并且元素的哈希值的第四位是0或者1都是随机的,所以计算分链表可能只有一条,也可能有两条。
4.2.4 红黑树与链表的拆分与转化学习中…学完再来总结吧!!!

5、删除方法(remove)

HashMap的主要知识点在于put方法中,学完put方法再来学remove方法就比较好理解了,remove方法主要分成下面三步完成:

  • 第一:找到对应数组的位置(索引)。
  • 第二:如果是链表就遍历链表找到对应的元素删除。
  • 第三:如果是红黑树就遍历树找到对应的节点删除,当树节点小于6时再转化为链表。
5.1、 源码解析
public V remove(Object key) {
    Node<K,V> e;
    //如果删除了对应的节点就返回该节点的值value
    return (e = removeNode(hash(key), key, null, false, true)) == null ?
        null : e.value;
}

final Node<K,V> removeNode(int hash, Object key, Object value, boolean matchValue, boolean movable) {
    Node<K,V>[] tab; Node<K,V> p; int n, index;
    //第一步:根据hash找到位置,如果当前key映射到的桶不为空。--start
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (p = tab[index = (n - 1) & hash]) != null) {
        //定义一个node,这个node指向最后要删除的节点。
        Node<K,V> node = null, e; K k; V v;
        //判断数组中的这个元素是否是要删除的元素
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            node = p;
        else if ((e = p.next) != null) {
            if (p instanceof TreeNode)
                // 如果是 TreeNode 类型,调用红黑树的查找逻辑定位待删除节点
                //注意:TreeNode是Node的子类(HashMap.TreeNode -> LinkedHashMap.Entery ->HashMap.Node)
                node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
            else {
                //如果是链表结构就通过遍历链表来查找要删除的节点。
                do {
                    if (e.hash == hash &&
                        ((k = e.key) == key ||
                         (key != null && key.equals(k)))) {
                        node = e;
                        break;
                    }
                    p = e;
                } while ((e = e.next) != null);
            }
        }
        
        //第二步:删除对应元素
        if (node != null && (!matchValue || (v = node.value) == value ||
                             (value != null && value.equals(v)))) {
            if (node instanceof TreeNode)
                //调用红黑树的方法删除节点(第三步在这方法内部:转化为链表)
                ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
            else if (node == p)
                //在数组中删除
                tab[index] = node.next;
            else
                //在链表中删除
                p.next = node.next;
            //记录修改次数
            ++modCount;
            //元素个数减一
            --size;
            afterNodeRemoval(node);
            return node;
        }
    }
    return null;
}

四、遍历HashMap的方式

HashMap的使用方法有很多,其中的遍历方式需要重点掌握:

4.1、 分别遍历Key和Values

已定义HashMap集合(map)
1. 获取所有键的Set集合:map.keySet();
2. 获取所有值的Set集合:map.values();
最后使用增强for循环遍历即可取出各个键与值。
4.2、 使用Iterator迭代器
//获取键值对Set集合的迭代器
Iterator<Map.Entry<String, String>> iterator = hashMap.entrySet().iterator();
while (iterator.hasNext()) {
    //获取其中一个键值对
    Map.Entry<String, String> next = iterator.next();
    //获取键
    next.getKey();
    //获取值
    next.getValue();
}
4.3、 通过get方式(不建议使用)

因为迭代两次,keySet获取Iterator一次,还有通过get又迭代一次。降低性能。

//先获取全部的键的Set集合
Set<String> set1 = hashMap.keySet();
for (String string : set1) {
    //根据键获取对应的值
    hashMap.get(string);
}

4.jdk8以后使用Map接口中的默认方法forEach():

default void forEach(BiConsumer<? super K,? super V> action) 
BiConsumer接口中的方法:
	void accept(T t, U u) 对给定的参数执行此操作。  
		参数 :
            t - 第一个输入参数 
            u - 第二个输入参数 
  • 遍历实例:
public class Demo02 {
    public static void main(String[] args) {
        HashMap<String,String> m1 = new HashMap();
        m1.put("001", "zhangsan");
        m1.put("002", "lisi");
        m1.forEach((key,value)->{
            System.out.println(key+"---"+value);
        });
    }
}

五、HashMap的细节问题

5.1、初始化容量问题

问题:

我们都知道HashMap的扩容操作是比较影响性能的,所以在开发过程中我们如果想要提高HashMap的性能可以尽量减少HashMap的扩容操作。我们怎样能够减少扩容操作呢?

解析:而减少扩容操作可以从初始化容量入手;
  • 举个例子:当我们的数据个数为6时,6/0.75=8,我们设置容量为2的n次幂:8,这时的临界值为6,当我们插入第6个值时竟然又要扩容了,这就很影响性能。当我们使用公式 (需求容量/0.75 +1)向上取整时,我们的容量就是6/0.75+1=9,经过处理后就会设置初始容量为16,存储过程就不用扩容了。
  • 因此,在开发过程中当我们知道了存储数据的大小时,建议把默认容量的数字设置成initialCapacity/ 0.75F + 1.0F。

5.2、 被 transient 所修饰 table 变量

问题:

细心阅读 HashMap 的源码,会发现桶数组 table 被申明为 transient。transient 表示易变的意思,在 Java 中,被该关键字修饰的变量不会被默认的序列化机制序列化。回到源码中考虑一个问题:桶数组 table 是 HashMap 底层重要的数据结构,不能够序列化怎么能够进行还原呢?

解析:

HashMap 并没有使用默认的序列化机制,而是通过实现readObject/writeObject两个方法自定义了序列化的内容。HashMap的重要信息是键值对,认为只要直接序列化键值对就可以了,这样是错误的想法。序列化table数组有两个问题待解决:

  • HashMap的数组大多数情况下是不可能装满的,序列化未存储的空间同样浪费资源。
  • 同一个键值对在不同的JVM下所处的数组位置不一样,在不同JVM下反序列化可能出现错误。
    • HashMap需要根据计算哈希值来找到索引值,如果键的hashCode方法没有覆盖重写,计算时会调用Object的hashCode方法,而Object的hashCode方法是调用了本地的(即JVM中),不同的JVM对该方法有不同的实现,因此可能产生不同的hash值,所以如果对table序列化的话可能出现不同的存储哈希表导致错误。
  • 因此,HashMap不能序列化数组table,采用了自定义的其他方法。

5.3、 HashMap不能在多线程中使用?(可能要结合扩容机制讲解)

问题:HashMap为什么是线程不安全的,主要问题出现在哪里?

HashMap在多线程中在put操作时会引起扩容,在扩容过程后将元素放到新的HashMap中容易出现环形链表,出现了环形链表时,因为我们在遍历该Map时使用判断一个链表的完整是结尾为null,出现环形就没有了该null值了,无限循环下去。

解析:

假设在put方法时出现了扩容,而且假设其中的一个链表从旧map到新的map时不会分成两条链(多了可能有两条)。
image

六、参考文献

1.黑马程序员出品的教学视频资料 深入解读大厂java面试必考基本功-HashMap集合https://www.bilibili.com/video/BV1Z54y1e7id?from=search&seid=12428668374426906310

2.segmentfault的coolblog:HashMap 源码详细分析(JDK1.8) https://segmentfault.com/a/1190000012926722#

终于对HashMap有新的理解,花了我一个多星期呀!!!我太难了!!记录不易,先留个赞吧!!!!

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值