java之HashMap详解

~ 前言

前面已经讲解了ArrayList与LinkedList的源码,大家收获是否有一些呢?在看完文章后请务必自己跟一遍源码哦。
接下来这一章HashMap的源码讲解会超级细!并且有一定难度。所以大家可以分段查看,不用一次性看完的。
我会使用 ~ 符号来标注一些重要的内容。这次依旧是基于JDK8的版本进行解析,废话不多说,here we go!

请添加图片描述


明确一些字段信息

首先我们来说一下HashMap的一些字段含义

// table的默认长度
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 16

// 默认最大table长度
static final int MAXIMUM_CAPACITY = 1 << 30; // 1073741824

// 默认达到这个百分点就会发生扩容,75%
static final float DEFAULT_LOAD_FACTOR = 0.75f;

// table中一个元素链表达到某个长度转化为红黑树的阈值
static final int TREEIFY_THRESHOLD = 8;

// 红黑树转化回链表的阈值
static final int UNTREEIFY_THRESHOLD = 6;

static final int MIN_TREEIFY_CAPACITY = 64;	 

// 存储node的表,hash桶
transient Node<K,V>[] table;

// 记录所有kv数据,遍历使用
transient Set<Map.Entry<K,V>> entrySet;

// 使用长度
transient int size;

// 修改次数
transient int modCount;

// table扩容阈值长度
int threshold;

// 扩容百分比
final float loadFactor;

看完上面的字段是不是感觉概念好多,有点搞不懂呢?后续我们会在方法中使用到,再慢慢讲解其作用。
接下来我们来看一下它的关系图

请添加图片描述

发现HashMap继承了AbstractMap并且实现了Map接口,而AbstractMap又实现了Map接口。
啊这。。不是重复了么?让我们看一下源码中怎么标识的。

public class HashMap<K,V> extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable

是的看到这里我可以很确认这是一个问题点啊。。我们之前讲了List好像也有这个问题点,大家可以回去看一下。
然后我们又发现实现了serializable,可以被序列化,但是table被标识了transient
这个我们在上一章有讲到,接下来留给大家思考一下为什么?

又是一个小细节


~ 超级重要的机制分析与方法源码

我们这里不直接进入HashMap的初始化方法,而先讲解一些HashMap的机制与实现源码。

  • HashMap如字面意思,是一个映射的表,使用什么映射呢?Hash!上面的字段信息我们已经看到了存储的table是一个数组。
    详细展开就是: 表的插槽(index)是原来key先做hash,并和表的长度-1做**与运算(&)**得出的一个位置进行存储。
  • 然后通过static final int DEFAULT_INITIAL_CAPACITY = 1 << 4 //16; 我们知道table默认长度为16。那么,
    这么多key总会有落在同一个插槽的可能啊。那这时候就需要使用链表把他们连在一起作为这个插槽的value了。
  • 说到这里是不是有同学会想到,如果某个插槽的链表超级长,那遍历找值是不是会有性能问题啊?没错,这里如果一直使用
    链表这个数据结构就会有性能问题,所以根据static final int TREEIFY_THRESHOLD = 8;我们可以了解到JDK对这里做了优化,
    如果长度到达这个阈值就会进行树化(红黑树)。
  • 聊到这里就能解决全部问题了吗?还没有!如果使用HashMap是一个超级大的数据量,那么只使用树是不够的,并且维护树的
    高度会有性能的浪费,那么我们这时候可以使用扩容table插槽的方式进行Hash分散。怎么确定要扩容呢?
    int threshold;这个字段就标识了如果我们到达这个阈值就会进行扩容。除此之外我们要对Hash算法本身进行优化。

这里补一张图HashMap结构图:
请添加图片描述

请添加图片描述

我们聊完机制之后就去看看它是怎么实现的吧!


Node

通过分析已知每个table插槽都是由链表或红黑树组成的,那么怎么封装数据和表示出这种数据结构呢?
HashMap这里是唯一原则封装了链表的节点Node与树节点TreeNode。

static class Node<K,V> implements Map.Entry<K,V> {
     final int hash;
     final K key;
     V value;
     Node<K,V> next;
	// ...省略
}

static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
	TreeNode<K,V> parent;
	TreeNode<K,V> left;
	TreeNode<K,V> right;
	TreeNode<K,V> prev;   
	boolean red;
	TreeNode(int hash, K key, V val, Node<K,V> next) {
	    super(hash, key, val, next);
	}
	final TreeNode<K,V> root() {
	    for (TreeNode<K,V> r = this, p;;) {
	        if ((p = r.parent) == null)
	            return r;
	        r = p;
	    }
	}	
	// ...省略
}

这里我们就不展开讲了,Node就是一个链表的表示,之前有讲过。而TreeNode我们后续会开一个分章节详细讲解,毕竟这个属于数据结构的知识。


table长度规则

哎。上面我们分析的四点好像没有讲到HashMap还有table长度的定义规则啊?那到底为什么要在这里插入啊?
首先呢。我们确实没有在分析中提到这个长度定义规则,但是有提到扩容table与Hash分散,这个长度规则就是为了让Hash更加散列而做出的一个定义,所以在讲Hash与定位之前先说明这个规则。

// cap是设置table长度的值
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;
    // 如果到了最大值就返回最大值,否则就返回n+1的值
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

第一句中先将cap-1,这一句我们先跳过,看后面的。然后我们假设cap是7,然后减1就是6。
第二句它让n(6)向右边移动一位在做或操作,赋值给n。
那么结果等于多少呢?我们画图来理解一下。

请添加图片描述

然后第三句是在第二句的结果上面向右移动两位再做或操作,赋值给n。

请添加图片描述

到这里我们可以发现n |= n >>> xx; 这些操作是把数字后面的所有二进制位全部变成了1,说明我们或操作(|)是偏向把二进制位变为1的。
最后n+1的话,返回的n会变成8。

请添加图片描述

所以这一大段想表达的意思是把所有非2次方的数字向前进一个二进制位,后面位数全部变为0,化为2的n次方数,
而本身就是2的n次方数返回本身,什么意思呢?
举个栗子,n=6会返回8, n=8会返回8, n=10会返回16, n=32会返回32, 这样的话大家可以明白了吗?

上面我们还遗留了一个问题,为什么一开始要减1呢?
因为是为了将已经是二次方数的二进制位往后退一位,这样最后+1的话,就会返回本身了。

请添加图片描述

至于为什么使用位移操作而不使用一些数学的方式,是因为位操作性能一般比直接算数会高很多,优化到极致。
说回来为什么数组要2次方数的长度呢?我们继续往后看才能解释。


Hash

通过第一点分析我们了解到,要找到插槽就需要先做Hash再定位。让我们先看源码吧!

// 做Hash
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

首先我们来分析hash()这个方法:

  • 先获取key的hashcode之后向右移动了16位。
  • 与原本的hashcode做异或操作得到一个值。

假设hashcode为高16位为 00011100 ,低16位为 00010110 ,让我们做第一个操作吧。

请添加图片描述

这时我们发现右移16位的操作,会把左边的16位覆盖掉了右边的16个二进制位。
然后我们第二步使用右移的结果与原来的hashcode做了一个异或(^)操作。

请添加图片描述

好了,到这里我们就把hash()这个方法的执行过程都用图的方式演示了一遍。
但是是不是还是一脸懵逼?我高位右移16有啥用啊?我们上面看的为什么table长度一定要是2次方的长度啊?
接下来的使用hash()方法返回值定位table插槽的操作就会帮我们揭开神秘的面纱啦!


table插槽定位

//获取插槽定位,e表示一个节点Node,newCap就是table的长度
e.hash & (newCap - 1)

我们把newCap定义为8,e.hash为上面的hash()方法返回值,那么让我们继续用图的方式来演示。

请添加图片描述

做完这一步我们就可以定位到这个key的位置为0。
好了到这里,我们所有的定位操作就完成了,接下来我们来说一下上面遗留下来的问题吧。

先来看第一个,为什么hash()方法要右移16位然后做异或操作?
根据上面的插槽定位演示图我们可以看到因为我们table的长度太小,与hash做&操作的时候只用到了三位。我们计算一下2的16次方等于65536,也就是说低16位全为1的数组长度是65536,而我们平时使用HashMap的时候是很少扩容到这个或更大的长度的。所以运算时我们的hash高16位是没有参与的。这时为了提高我们的hash分散,我们可以让高16位与低16位进行异或运算,使得低16位的hash更加分散一些。

为什么要让hash更分散一些呢?
如果hash可以分散一些可以提高每个table的插槽都能分到节点并且更平均一些,不用怕某一个插槽的节点过多导致链表变的很长或者树结构变得很大。

为什么Hash()方法要做异或运算(^)而不是与运算(&)或者是或运算(|)呢?
我们这里做位运算是为了让hash更加分散,所以不能偏向于1或者0。接下来我会用一个例子来说明这三种运算的偏向。
对于1和0两位,我们做或运算(|)。只要有一个1就返回1,我们理解一下就可以发现这是偏向返回1。
我们做与运算(&)的话,只有两个都是1才能返回1,否则就是0,我们可以发现这是偏向返回0。
最后我们做异或运算(^),无论是0或者1,相同两个数字就会返回0,不同就是返回1,我们可以发现这是更偏向随机性的。
所以根据以上分析,我们要更散列一点就要让返回值更随机一些,所以这里使用了异或的操作。

为什么table长度一定要是2次方的长度?
在做完上面几步之后我们可以认为此时的hash散列随机已经很高了,所以我们最后定位插槽的index的时候尽量不要在这里基础上面去影响hash散列的走向了。那么我们是做与操作(&),为了不影响hash的数值那我们的长度二进制位必须全部都是1,所以我们的长度必须是2次方的数字。

请添加图片描述


初始化

经过上面一些烧脑的内容让我们看一下简单的初始化源码放松一下吧:

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


2)    public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }
    
    
3)    public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR;
    }


4)    public HashMap(Map<? extends K, ? extends V> m) {
        this.loadFactor = DEFAULT_LOAD_FACTOR;
        putMapEntries(m, false);
    }

我们可以看到第1-3个的初始化方法是简单的给loadFactor或threshold进行赋值,并且在给threshold赋值时会先经过table长度规则的判断。最后一个初始化方法是直接传了一个Map作为参数进行初始化,并将内容读取做hash放到table中。接下来我们直接看putMapEntries这个方法。

final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
    int s = m.size();
    // 如果传进来的map有内容
    if (s > 0) {
    	// 当前Map的table没有内容,也就是初始化时。
        if (table == null) { 
            float ft = ((float)s / loadFactor) + 1.0F;
            // table长度赋值
            int t = ((ft < (float)MAXIMUM_CAPACITY) ?
                     (int)ft : MAXIMUM_CAPACITY);
            if (t > threshold)
            	// 转换为2次方的数
                threshold = tableSizeFor(t);
        }
        // 超出阈值需要扩容
        else if (s > threshold)
            resize();
        // 将传进来的Map节点存储
        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);
        }
    }
}

注释已经很详细的描述了每个代码的作用,最后我们直接来看resize()是如何扩容的,而putVal()会放到后面简单API方法中讲解。


~ table扩容

经过简单的解析初始化方法后是不是恢复了一点脑力呢?那么接下来这个是重量级的!集中注意力冲!

请添加图片描述

final Node<K,V>[] resize() {
   	Node<K,V>[] oldTab = table;
   	
   	// 等于0说明刚进来初始化,不等于零就是某个put方法调用的逻辑
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;
    int newCap, newThr = 0;
    
    // table有内容,非初始化
    if (oldCap > 0) {
    
    	// 最大容量返回
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        
        // 将新table的容量扩容一倍,然后判断不是最大值并且旧table容量没有大于等于16,
        // 就将新数组扩容阈值扩大一倍
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1;
    }
    
    // 初始化传入threshold就会大于0
    else if (oldThr > 0)
        newCap = oldThr;
        
    // 初始化无参
    else {               
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    
    // 进入这个方法说明上面经过 if (oldCap > 0)处理但没有进入else if中,
    // newCap > MAXIMUM_CAPACITY 或者 oldCap < 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;
    
    // 初始化就直接返回了(初始化oldTable是空的),这里需要重定位
    if (oldTab != null) {
	
		// 遍历所有table插槽
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            
            if ((e = oldTab[j]) != null) {

				// 清空这个节点使gc能够及时回收,这个节点已经赋值给变量e
                oldTab[j] = null;
	
				// 这个插槽只有只有一个节点,重定位放入新table
                if (e.next == null)
                    newTab[e.hash & (newCap - 1)] = e;
                    
                // 这个节点已经转换为树结构了,
                else if (e instanceof TreeNode)

					// 红黑树拆分,拆分后的高低位树过小(节点小于等于6个),则取消树化,将其转为链表结构。
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                else { 

					// 链表结构,拆分成新table需要重定位与不需要的
                    Node<K,V> loHead = null, loTail = null;
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    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;
}

上面代码的注释已经详细表现出扩容的基本流程了,但我们还是来简单整理一下流程吧。

  • 如果当前table没有内容说明是初始化,那确定新table它的容量与扩容阈值后直接返回。
  • 如果table有内容,需要先判断是否还能继续扩容(达到最大容量就会直接返回)。没有达到最大容量就直接增加一倍,然后根据旧table的容量是否大于等于16来确定新table的扩容阈值。
  • 如果旧table的容量大于等于16,那么新table的扩容阈值就是旧table容量的两倍,否则就是新table容量*0.75。
  • 到了这里就需要分三种情况来处理旧table的节点转移了。
    ** 只有一个节点就直接定位新数组位置转移即可。
    ** 如果是树节点就确定是否可以取消树化,转换链表转移
    ** 最后一种就是链表结构了,那就确定每个节点是否要转移?然后进行转移处理即可。

梳理完扩容的逻辑后,对于树节点转移这块没有展开来讲,这个后续会开一篇新文章专门来讲这个树。
除了树以外还有几个知识点没有详细讲解,例如为什么扩容因子是0.75?为什么树化是节点为8?为什么(e.hash & oldCap) == 0这句话就可以判断不用重定位呢?那么接下来我们来看一下吧。


扩容方法的一些逻辑难点


为什么扩容因子是0.75?为什么树化是节点为8?

其实这些在HashMap源码中已经标识出来,只是一堆英文我们并没有这么多耐心去一个个看,我也是使用了翻译软件。。说明我们英语都有待提高,接下来我把它贴出来并简单说一下吧。

    /*
     * Implementation notes.
     *
     * This map usually acts as a binned (bucketed) hash table, but
     * when bins get too large, they are transformed into bins of
     * TreeNodes, each structured similarly to those in
     * java.util.TreeMap. Most methods try to use normal bins, but
     * relay to TreeNode methods when applicable (simply by checking
     * instanceof a node).  Bins of TreeNodes may be traversed and
     * used like any others, but additionally support faster lookup
     * when overpopulated. However, since the vast majority of bins in
     * normal use are not overpopulated, checking for existence of
     * tree bins may be delayed in the course of table methods.
     *
     * Tree bins (i.e., bins whose elements are all TreeNodes) are
     * ordered primarily by hashCode, but in the case of ties, if two
     * elements are of the same "class C implements Comparable<C>",
     * type then their compareTo method is used for ordering. (We
     * conservatively check generic types via reflection to validate
     * this -- see method comparableClassFor).  The added complexity
     * of tree bins is worthwhile in providing worst-case O(log n)
     * operations when keys either have distinct hashes or are
     * orderable, Thus, performance degrades gracefully under
     * accidental or malicious usages in which hashCode() methods
     * return values that are poorly distributed, as well as those in
     * which many keys share a hashCode, so long as they are also
     * Comparable. (If neither of these apply, we may waste about a
     * factor of two in time and space compared to taking no
     * precautions. But the only known cases stem from poor user
     * programming practices that are already so slow that this makes
     * little difference.)
     *
     * Because TreeNodes are about twice the size of regular nodes, we
     * use them only when bins contain enough nodes to warrant use
     * (see TREEIFY_THRESHOLD). And when they become too small (due to
     * removal or resizing) they are converted back to plain bins.  In
     * usages with well-distributed user hashCodes, tree bins are
     * rarely used.  Ideally, under random hashCodes, the frequency of
     * nodes in bins follows a Poisson distribution
     * (http://en.wikipedia.org/wiki/Poisson_distribution) with a
     * parameter of about 0.5 on average for the default resizing
     * threshold of 0.75, although with a large variance because of
     * resizing granularity. Ignoring variance, the expected
     * occurrences of list size k are (exp(-0.5) * pow(0.5, k) /
     * factorial(k)). The first values are:
     *
     * 0:    0.60653066
     * 1:    0.30326533
     * 2:    0.07581633
     * 3:    0.01263606
     * 4:    0.00157952
     * 5:    0.00015795
     * 6:    0.00001316
     * 7:    0.00000094
     * 8:    0.00000006
     * more: less than 1 in ten million
     *
     * The root of a tree bin is normally its first node.  However,
     * sometimes (currently only upon Iterator.remove), the root might
     * be elsewhere, but can be recovered following parent links
     * (method TreeNode.root()).
     *
     * All applicable internal methods accept a hash code as an
     * argument (as normally supplied from a public method), allowing
     * them to call each other without recomputing user hashCodes.
     * Most internal methods also accept a "tab" argument, that is
     * normally the current table, but may be a new or old one when
     * resizing or converting.
     *
     * When bin lists are treeified, split, or untreeified, we keep
     * them in the same relative access/traversal order (i.e., field
     * Node.next) to better preserve locality, and to slightly
     * simplify handling of splits and traversals that invoke
     * iterator.remove. When using comparators on insertion, to keep a
     * total ordering (or as close as is required here) across
     * rebalancings, we compare classes and identityHashCodes as
     * tie-breakers.
     *
     * The use and transitions among plain vs tree modes is
     * complicated by the existence of subclass LinkedHashMap. See
     * below for hook methods defined to be invoked upon insertion,
     * removal and access that allow LinkedHashMap internals to
     * otherwise remain independent of these mechanics. (This also
     * requires that a map instance be passed to some utility methods
     * that may create new nodes.)
     *
     * The concurrent-programming-like SSA-based coding style helps
     * avoid aliasing errors amid all of the twisty pointer operations.
     */

其实上面是基于泊松分布描述了我们hashcode的hash碰撞概率,发现到8已经非常低了,但是如果节点数达到8说明冲突非常严重了,后续可能有更多元素会被加载进这个插槽变成很长的链表,所以我们先转换为树的结构增加查找的速度。

     * 0:    0.60653066
     * 1:    0.30326533
     * 2:    0.07581633
     * 3:    0.01263606
     * 4:    0.00157952
     * 5:    0.00015795
     * 6:    0.00001316
     * 7:    0.00000094
     * 8:    0.00000006

扩容因子是0.75,为什么不能是0.5或者1?其实官方的解释简单理解是0.75在扩容的空间与查找时间上做了一个很好的平衡。扩容因子是0.5话扩容就太频繁了,如果是1的话呢?每次扩容都需要把所有元素重定位到新表插槽中,尽管hash碰撞几率低,但是如果在每次数据增多的情况下冲突几率也会上升,那么我降低重定位的数据量其实也算是降低碰撞的概率吧。这样如果都能每一个值都平均分到每一个桶中那么查找时间会有一个很明显的优化。


为什么(e.hash & oldCap) == 0这句话就可以判断不用重定位呢?

我们直接用图来表示,首先我们假设oldTable=8,newTable=16。
再来回顾一些我们的求index公式 hashcode & (tableLenth-1)

那我们先来看看oldTable与hash做与操作获取index

请添加图片描述

发现index=0,那我们来看看newTable对与相同hash求新index

请添加图片描述

发现index变了为8,但是根据两个结果我们发现无论是newTable或者oldTable,它做了&操作后三位的二进制位都为0,不同的只有第四位的结果,newTable为1,oldTable为0。所以我们的结果不一致。
然后我们再来看一下(e.hash & oldCap) == 0这句的结果。

请添加图片描述

发现这个结果与newTable求index的一致,因为他们都只受长度的第四位二进制位的影响。
那么到这里,我们想一下如果hash第四位是0的话是不是三者的结果都是一样的了呢?

请添加图片描述

所以说影响到我们求index结果的最重要因素就是hash的不同。求实了之后我们可以总结出来

  • 我们扩容是在oldTable的长度基础上向左边移动了一位
  • newTable求index,-1操作就是往后移动了一位
  • 求index受影响是oldTable长度的那一个二进制位“1”(8,也就是二进制位第4位),也是newTable长度-1最高的那一个二进制位"1"

那么我们带入公式 (e.hash & oldCap), 确认的就是hash在(newTable长度-1)中,求index受影响的那一位是不是0。这里例子就是看第4个二进制位是不是为0。


~ 基本方法解析

看完之前那些难的东西之后,再看基本api方法的解析就十分容易了,我们快速过一下吧。

请添加图片描述


put

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


// onlyIfAbsent 不修改存在的值? 
// evict 不处于创建阶段
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) {
            
            	// 到了尾巴还是没有要找的key,就往这个节点后面创建并插入这个节点
                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) { 
            V oldValue = e.value;

			// 根据参数查看是否要修改存在的值
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
                
			// 看用户实现,空
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount;
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

以上就是put()方法的源码与注释,那么我们来整理一下:

  • 确定是否已经初始化table,没有就先初始化一下。
  • 定位插槽后确定是否有节点存在,没有就创建一个节点插入。
  • 如果有一个节点并且就是我要找的那一个,就将值进行覆盖,如果不是我要找的就根据这个节点判断是树结构还是链表结构,根据数据结构不同进行遍历查找。
  • 如果都查不到就新建一个节点插入,如果找到就覆盖,在链表插入后需要判断当前是否到达树化的阈值,判断到达阈值进行树化。

remove
public V remove(Object key) {
    Node<K,V> e;
    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是否在table里面
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (p = tab[index = (n - 1) & hash]) != null) {
        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)
                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;
}

梳理:

  • 根据hash查找到table插槽,如果第一个节点就是要找的,将当前节点的下一个放在table[index]的位置
  • 如果插槽第一个不是我要找的,判断数据类型分开处理,树节点就进入树的处理方法,遍历树并移除,进行平衡并查看是否到达解除树化的阈值。
  • 如果是链表就移除元素并维护好指针信息。

get
public V get(Object key) {
   Node<K,V> e;
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}

final Node<K,V> getNode(int hash, Object key) {
    Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
	// 确定表格不为空并能通过hash找到插槽
    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;
}

这里的处理其实与remove()方法的查找类似,所以这里就不细讲了。


~ 最后

讲到这里我们就已经把HashMap的大部分内容讲完了,树的那部分会在后续更新一章来讲哦。其实这里最难的应该是那些位移运算,阿哈哈哈,我第一次看的时候也是要裂开的。但是各位看到这里不要以为很快就能掌握了,我们要随时回顾一下,这样才不会忘记哦。最后给各位和我自己布置一个作业: 自己抽出HashMap可以借鉴的点,可以在工作中用到的点。


  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
Java HashMap是一个散列表,用于存储键值对的映射关系。它实现了Map接口,根据键的HashCode值来存储数据,具有非常快速的访问速度。HashMap允许一条记录的键为null,但是键是唯一的,即同一个键只能对应一个值。 例如,下面的代码展示了如何使用Java HashMap: Map<Integer, String> map = new HashMap<>(); map.put(1, "a"); map.put(2, "b"); map.put(3, "c"); 这段代码创建了一个HashMap对象,并将键值对存储在其中。键的类型是Integer,值的类型是String。通过put()方法可以向HashMap中添加键值对。 此外,Java HashMap还提供了其他常用的方法,如get()方法用于通过键获取对应的值,remove()方法用于移除指定键对应的键值对等等。HashMap的内部实现使用了哈希表来实现高效的数据存储和访问。 需要注意的是,HashMap是非线程安全的,如果在多线程环境中使用,需要采取额外的措施来保证线程安全性。另外,在序列化和反序列化HashMap对象时需要特殊处理,可以通过自定义readObject()和writeObject()方法来实现。<span class="em">1</span><span class="em">2</span><span class="em">3</span> #### 引用[.reference_title] - *1* [Java HashMap](https://download.csdn.net/download/weixin_38588592/13705053)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v93^chatsearchT3_2"}}] [.reference_item style="max-width: 50%"] - *2* *3* [JavaHashMap 详解](https://blog.csdn.net/java1527/article/details/126850576)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v93^chatsearchT3_2"}}] [.reference_item style="max-width: 50%"] [ .reference_list ]

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值