第十章 Java的集合之从设计思想分析HashMap源码

前言

HashMap 是java集合基础中非常重要的一个知识点,其原因是HashMap拥有高效的性能,为了成为一个重实战原理的Java工程师,而不是只会调用,必须要深入剖析HashMap其原理。本章将不会按照源码的排列顺序来剖析,而是通过诞生原因开始,以故事的形式逐个引出和解析相关变量和方法。介绍背景在JDK 1.8版本基础上。本章内容均为个人理解,如有错误请指正。

探讨问题

  1. hashMap的设计思想是什么
  2. hashMap为什么会设计成key-value形式
  3. hashMap为什么是无序结构
  4. hashMap为什么会有加载因子
  5. hashMap有什么优缺点?

诞生背景

HashMap中最常用的两个方法是get()、和put()。它们代表了hashMap最重要的存取操作,因此hashMap是想被设计成一种方便存取的集合。前面章节分析的存取集合有数组、链表等等。但是在计算机中,数组是随机存取结构中效率最高的一种,通过下标获取元素的时间复杂度为O(1),而链表则为O(n),通过二叉树来获取元素的时间复杂度为O(log n)。因此使用数组作为元素存储结构是最理想的方案,在内存中,数组是一连串单元的集合,只要记住了数组下标,就可以获取到对应的元素。因此下标和值是一对一的关系。但是随着数据增多,下标只是简单的数字,容易混淆,在使用过程中难以记忆。假如能把下标变成能方便记住的符号,就可以直接通过这个符号来获取元素了。

于是,hashMap出现了。

数组诞生

HashMap为了实现成key-value的设计思想,其内部通过维护一个数组,将元素存在数组中,key-value关系通过数组中的(下标—值)来模拟。

table变量

transient Node<K,V>[] table;

table变量,是HashMap内部维护的一个数组,由它来实现数组结构,
HashMap明确定义了数组的初始化长度为16。

/**
 * The default initial capacity - MUST be a power of two.
 * 翻译: 数组的默认初始化容量,数值必须是2的幂(2^n)。
 */
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

在Java中,数组是一种不可变长度的结构,如果要实现扩容,则必须重新创建一个新的数组,有意思的是,上面明确规定了数组的容量必须是2的幂,如4、8、16、32、64…,hashMap在数组扩容上怎么实现的呢?

扩容

tableSizeFor(int cap)方法

/**
 * Returns a power of two size for the given target capacity.
 * 返回一个2的幂的数值
 */
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;
}

上述是hashMap的计算容量方法,通过传入一个值,来计算应该返回的容量。
如果传入一个2的幂和非2的幂,其返回值是多少呢?

传入8

 static final int tableSizeFor(int 8) {
    int n = 8 - 1;   	// 则 n = 7
    
    n |= n >>> 1;       //  00000000 00000000 00000000 00000111
    					// 	00000000 00000000 00000000 00000011
    					//  其或运算得: 00000000 00000000 00000000 00000111
    					
    n |= n >>> 2;	    //  00000000 00000000 00000000 00000111
    					// 	00000000 00000000 00000000 00000001
    					//  其或运算得: 00000000 00000000 00000000 00000111
    					
    n |= n >>> 4;	    //  00000000 00000000 00000000 00000111
    					// 	00000000 00000000 00000000 00000000
    					//  其或运算得: 00000000 00000000 00000000 00000111
    					
    n |= n >>> 8;	    //  00000000 00000000 00000000 00000111
    					// 	00000000 00000000 00000000 00000000
    					//  其或运算得: 00000000 00000000 00000000 00000111
    					
    n |= n >>> 16;	    //  00000000 00000000 00000000 00000111
    					// 	00000000 00000000 00000000 00000000
    					//  其或运算得: 00000000 00000000 00000000 00000111  == 7
    
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
    返回结果: 7+1 = 8
}

如果传入非2的幂数值呢?

传入9

 static final int tableSizeFor(int 9) {
    int n = 9 - 1;   	// 则 n = 8
    
    n |= n >>> 1;       //  00000000 00000000 00000000 00001000
    					// 	00000000 00000000 00000000 00000100
    					//  其或运算得: 00000000 00000000 00000000 00001100
    					
    n |= n >>> 2;	    //  00000000 00000000 00000000 00001100
    					// 	00000000 00000000 00000000 00000011
    					//  其或运算得: 00000000 00000000 00000000 00001111
    					
    n |= n >>> 4;	    //  00000000 00000000 00000000 00001111
    					// 	00000000 00000000 00000000 00000000
    					//  其或运算得: 00000000 00000000 00000000 00001111
    					
    n |= n >>> 8;	    //  00000000 00000000 00000000 00001111
    					// 	00000000 00000000 00000000 00000000
    					//  其或运算得: 00000000 00000000 00000000 00001111
    					
    n |= n >>> 16;	    //  00000000 00000000 00000000 00001111
    					// 	00000000 00000000 00000000 00000000
    					//  其或运算得: 00000000 00000000 00000000 00001111  == 15
    
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
    返回结果: 15+1 = 16
}

因此,可以通过上述结果得到结论,如果传入2的幂,那么数组容量应该为该值,如果传入非2的幂,则数组容量应该是大于该值并且为离它最近的一个2的幂。这个在初始化hashMap时构造器中经常使用。

构造器

HashMap提供了三类构造器

无参构造器
public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}

无参构造器的初始化容量为16.

指定容量构造器
public HashMap(int initialCapacity) {
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}

指定容量的构造器的HashMap最终容量并不是指定的值,而是通过tableSizeFor(int cap)方法计算后的容量值。具体实现看最后一个构造器:

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);  // 这里根据传入的值,通过tableSizeFor(int cap)计算容量。
}

因此可以得到结论,如果在构造HashMap时传入了一个2的幂(如8,16,32),则数组最终容量也为该值(如8、16、32).如果传入的是非2的幂(如4、5、9、13)等等,则数组最终容量应该为大于该值的最近2的幂(如8、8、16、16)。

现在数组结构有了,要实现key-value形式,那么如何将数组下标转成一个符号呢?或者说将一个符号转换成一个下标?

哈希算法

这里需要引出哈希算法了,hashMap中的数组指定了存储类型为Node对象

transient Node<K,V>[] table;

Node是一个代表一个元素结点,它内部存储了具体的key值和value值,同时维持着一个hash值。
HashMap中提供了一个计算结点hash值的方法,如下。

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

这个hash算法是通过传入的key值来计算的。变量h代表key的原始hashCode值,它是一个int值,也就是说它的hashCode有32位长度,通过无符号右移16位(一半)以后,再与自身进行异或运算。具体的hash算法不是这里的研究重点。反正通过hash算法,将key值计算成一个hash值,维持在每个元素结点中。
这里需要注意的一点就是,hash算法是通过调用入参对象的hashCode方法来计算的,因此hash值的计算依赖于目标对象的hashCode方法,调用的native方法,不同的对象其hashCode都不一样,即使它们内部维持着的对象的hash值是一样的。
实例演示

class Student{
	String name;
	public Student(String name){
		this.name = name;
	}
}
在这个Student类中,现在new两个对象。
Student s1 = new Student("张三");
Student s2 = new Student("张三");

两个Student中,它们都维持着相同的 “”张三“,它们具有相同的hash值,按照HashMap中的设计本意,它们的hash值应该是一样的; 但是由于没有重写hashCode,在虚拟机看来,s1 和 s2 是两个对象,它们的hash值是不一样的。因此就会造成HashMap中的hash算法不能按照我们的设计本意来使用。

但是有一个问题就是,哈希算法虽然好用,但是哈希算法会因为某些原因形成哈希冲突,两个不同的key值计算出来是同一个哈希值。这怎么办呢?

链表诞生

解决哈希冲突的方法目前有几种:
(1) 开放定址法
(2)链地址法
(3)再散列法

通过链地址法,将冲突的哈希元素通过链表的方式连接起来,相同的哈希元素通过不断的在链表后新增结点来解决问题。

Node结点
Node结点,是HashMap的内部类,作为一个结点。hashMap的key—value值就是存储在Node结点中的。同时它内部维护着一个表示该结点的hash值和向后的指针next,防止因hash冲突产生的数据覆盖,造成数据丢失,因此通过next可以形成链表,来解决hash冲突。

static class Node<K,V> implements Map.Entry<K,V> {
    final int hash;					// 结点的hash值
    final K key;					// hashMap中元素的key值就是存放在这里
    V value;						// hashMap中key对应的value值就在这里,key-value 的变量关系是一对一。所以一个key值只有一个value值,
    								// 如果key 或者 value 被设计成数组,那可能是一对多关系了。
    Node<K,V> next;					// 指向下个结点的指针

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

HashMap内部Node结点中维护着一个int的hash变量,上面有描述。hash变量采用的是int类型,因此注定hashMap内部容量允许范围内最大只能为2^31,因此关于容量HashMap中有明确定义变量:

static final int MAXIMUM_CAPACITY = 1 << 30;   即 2^30

这个变量规定了HashMap中最大容量不超过2^30个元素结点。

内部结构图
通过上面组成结构的分析,大概知道了HashMap内部通过Node对象数组来实现数组结构,通过Node来实现链表结构。一个Node对象中存放着key-value值,然后Node对象可以存放在数组中,Node结点之间也可以相互链接。如下图:
hashMap
链表的诞生解决了hash冲突,现在每个元素结点中,都有了一个hash值,那么如何通过hash值来计算数组下标呢?hashMap中的元素为什么是无顺序的呢?上图中的数组为什么有些位置是空的呢?

hash定位

put方法是HashMap最常用最重要的一个方法,由于hashMap内部结构的不确定性,有可能是数组,有可能是数组+链表,也有可能是数组+链表+红黑树。当操作一个put方法时,如何通过hash值来确定数组中的位置,如果出现了哈希碰撞,又如何来处理呢?
这个操作主要是在putVal方法中有一段,

    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
        .
        .

其中 p = tab[i = (n - 1) & hash] 就是通过hash值来定位数组下标的方法,n代表当前数组的容量,那么n-1即为最大下标值,通过key值计算出的结点hash值和最大下标值的与运算中,即使hash值很大,但由于是与运算,所以最大值不会超过n-1.

假设 n=16,n-1即为 00000000 00000000 00000000 00000111
假设 hash为任意数字 01010100 10101010 10101011 10001000
那么与运算过程中,超过n-1的高位部分将全部为0,所以该运算值最大为n-1,最小为0。
这个操作不会导致数组下标越界。但是通过hash值计算出的下标也是没有顺序和规律的。

所以这个就是hashMap中为什么元素是随机存储的,并且不会越界。因为它是根据当前最大容量计算出来的。所以最大容量影响hash碰撞,容量越大,hash碰撞越小。
链表的诞生解决了哈希冲突,诞生链表带来的坏处就是链表索引的时间复杂度为O(n),偏离了O(1)的最初设计思想,所以为了使hashMap尽量变得高效,就要尽量避免链表的产生,也就是减少哈希碰撞的概率。通过hash定位算法可知,数组的容量越大,hash碰撞的概率就会越小,但是由于数组在内存中是连续的存储结构,过大的容量会造成空间浪费,增加空间成本。过小的容量会产生哈希冲突,增加查询时间成本,那么如何在两者之间进行权衡呢?

于是,负载因子产生了。
负载因子

/**
 * The load factor used when none specified in constructor.
 */
static final float DEFAULT_LOAD_FACTOR = 0.75f;

系统中默认的负载因子是0.75,是一种权衡空间与时间的设计,负载因子越大,会增加时间成本,减少空间成本。负载因子越小,会增加空间成本,降低时间成本。这个需要开发者根据业务场景来权衡。

但是即使碰撞概率可以减小,但是哈希碰撞依然会存在。在大量数据场景下,碰撞概率不变但是碰撞数量却会持续增加,造成链表越来越长。链表长度的变长增加了hashMap的索引时间,那么如何优化逐渐变长的链表呢?

红黑树诞生

为了解决这个问题,hashMap在JDK1.8版本中引入了红黑树的数据结构,当链表增加到一定长度时,将链表转换为红黑树。这个临界数值HashMap也有明确的定义:

/**
 * The bin count threshold for using a tree rather than list for a
 * bin.  Bins are converted to trees when adding an element to a
 * bin with at least this many nodes. The value must be greater
 * than 2 and should be at least 8 to mesh with assumptions in
 * tree removal about conversion back to plain bins upon
 * shrinkage.
 * 翻译: 这个变量值是将链表转换为树的阈值,这个值必须大于2并且至少为8,以满足构造成树的基本要求。
 */
static final int TREEIFY_THRESHOLD = 8;

也就是说,当链表长度达到8时,该链表将会被转换成树结构。转换过程中,Node结点将会变成TreeNode结点。TreeNode结点也是HashMap中维护着的一个内部类。

TreeNode树结点

static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
    TreeNode<K,V> parent;   // red-black tree links  红黑树结点
    TreeNode<K,V> left;		//  左结点
    TreeNode<K,V> right;	//  右结点
    TreeNode<K,V> prev;     // needed to unlink next upon deletion
    boolean red;		    //  结点是否是红色
    
    TreeNode(int hash, K key, V val, Node<K,V> next) {
        super(hash, key, val, next);
    }
    ...

从这些变量中,可以看到关于红黑树的具体设计。红黑树中提供了hashMap上层API的操作支撑。这部分暂不详解。
回到链表转换为红黑树的具体方法。

treeifyBin方法
提供一个hash值和数组,定位数组中该hash位置上的链表。如果满足条件,则将链表转换为红黑树。

final 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) {  	// 如果满足条件
        TreeNode<K,V> hd = null, tl = null;
        
        // 循环遍历链表中的结点, 这一步只是将结点Node转为TreeNode,并形成一个双向循环链表。
        do {
            TreeNode<K,V> p = replacementTreeNode(e, null);  // 从第一个结点开始转换为树结点
            if (tl == null)
                hd = p;
            else {
                p.prev = tl;
                tl.next = p;
            }
            tl = p;
        } while ((e = e.next) != null);
        
    // 把转换后的双向链表,替换原来位置上的单链表
        if ((tab[index] = hd) != null)
            hd.treeify(tab); // 另外详解
    }
}

整个设计思想大概就是这样了。下面分析一下常用的put、get、remove等方法

put()方法

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

put方法主要是调用的putVal()方法。

 * @param onlyIfAbsent if true, don't change existing value
 * 翻译: 如果该值为true,则不覆盖已存在的value值
 * 
 * @param evict if false, the table is in creation mode.
 * 翻译:  如果该值为false,则该表处于创建模式
 * 
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,boolean evict) {
   
   	// tab 是当前数组table,
   	// 结点p是数组上hash定位到的Node结点。
   	// n是数组tab的容量长度
    // i是hash定位到的数组下标
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    
    
    if ((tab = table) == null || (n = tab.length) == 0)		// 如果执行操作时是空数组
        n = (tab = resize()).length;						// 调用resize方法初始化。
        
    if ((p = tab[i = (n - 1) & hash]) == null)				// 如果定位到的数组上的下标i的位置,还没有存放元素,p结点就是定位到的头结点。
        tab[i] = newNode(hash, key, value, null);			// 则创建一个结点存入该处
    else {													// 如果已经有元素了,则还不清楚目前是链表结构还是红黑树结构
        Node<K,V> e; K k;
        
        // 如果p结点已经有元素了,并且key值就是这个p结点key,则将要替换p结点的value值
        if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))		// 这里使用了equals方法,如果不重写目标对象key的equlas方法,将会使用 == 判断两个key,偏离比较key内部的设计本意。
            e = p;
           
        // 如果p作为头结点是一个树的根结点,
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            
        // 如果p作为头结点是一个链表头结点
        else {
            for (int binCount = 0; ; ++binCount) {						// binCount 是一个结点计数变量
			
                if ((e = p.next) == null) {								// 遍历直到最后一个结点
                    p.next = newNode(hash, key, value, null);			// 到了最后一个结点时,如果binCount >=7 当前已经有7个结点了,则新结点添加后需要转换为红黑树了。
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
                // 如果遍历过程中找到了key值对应的结点,则结束遍历。
                if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        
        // 最后处理这个e结点,如果e结点为空,则上面操作是添加的新结点,putVal操作结束。如果e结点有值,则代表找到了key值对应的结点
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            
            if (!onlyIfAbsent || oldValue == null)   // 通过onlyIfAbsent  判断,是否需要替换key值对应的value值。
                e.value = value;
                
            afterNodeAccess(e);
            return oldValue;
        }
    }
    
    ++modCount;
    // 如果添加后,触发了hashMap扩容阈值,则要进行resize()操作。
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

综上所述,通过putVal方法大概知道了,在hashMap中放入一个元素的过程中,需要考虑以下几点:

  1. 通过key值计算得到的数组位置上,是否空着。如果空着,则填充。否则进入第二步。
  2. 如果该位置不为空,是否key值对应的结点?如果不是key值对应的结点,则进入第三步。
  3. 如果不是key对应的结点,那么该位置上的数据结构是红黑树,还是链表?
  4. 如果是红黑树,则按红黑树的方式添加一个新结点或者替换value值。如果是链表呢?进入第四步。
  5. 如果是链表,添加结点后是继续保持链表结构,还是需要转换为红黑树?
  6. 如果在遍历链表过程中,发现了key值对应的结点,是否必须替换value值?
  7. 结点添加或者值替换完成后,是否触发HashMap扩容阈值,如果是,则需要resize()。

resize()方法
这个方法是用来重新计算hashMap容量,并且重新定位元素位置的方法。

/**
 * Initializes or doubles table size.  If null, allocates in
 * accord with initial capacity target held in field threshold.
 * Otherwise, because we are using power-of-two expansion, the
 * elements from each bin must either stay at same index, or move
 * with a power of two offset in the new table.
 *
 * @return the table
 */
final Node<K,V>[] resize() {
	
	// 第一部分初始化变量
    Node<K,V>[] oldTab = table;
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;
    int newCap, newThr = 0;
    
    // 第二部分算出新的容量和扩容阈值
    if (oldCap > 0) {
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&			// 从这里可以看出newCap = oldCap << 1,扩容是按2倍扩容
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // double threshold					// 在装载因子不变的情况下,阈值也是按2倍扩
    }
    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) {			// 遍历旧数组中的下标 j 指定的位置上,可能为空,可能有一个,可能有多个(链表或者树)
            Node<K,V> e;
            if ((e = oldTab[j]) != null) {			// 如果有结点,则要判断有几个,是什么数据结构
                oldTab[j] = null;

				// 如果这已经是最后一个了,也就是下标 j 位置上只有一个结点。
                if (e.next == null)					
                    newTab[e.hash & (newCap - 1)] = e;			// 从这里可以看出定位方法是一样的,不过容量变了。
                
				// 如果这里有多个结点,并且是红黑树结构
				else if (e instanceof 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;
				
					// 遍历链表过程中,根据(e.hash & oldCap) == 0 作为条件拆分链表。
                    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);
	
					// 如果拆完后,loTail代表的短链表不为空,则把这个链表还是放在新数组下标 j 位置上。
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    
					// 如果拆完后,hiTail代表的短链表不为空,则把这个链表放在 j + oldCap 的下标位置上。
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

在看这个方法过后,可能疑惑和上面的tableSizeFor方法的不同,两者的功能是不一样的。
如果把hashMap看成是一个客栈,则
tableSizeFor重在计算具体容量上,hashMap的数组容量应该是多大,也就是客栈应该修建多少间客房。
resize主要重新拆分和定位数组元素,hashMap扩容后,原来的结点怎么安排,也就是在新客栈修好后,原来住在客房里面的客人,怎么重新安排住下。resize也有扩容,不过没有使用tableSizeFor来计算,因为扩容是在原来基础上进行的,原来容量已经是2的幂了,新容量扩容2倍后还是2的幂,因此无需tableSizeFor来提供计算。

get() 方法

public V get(Object key) {
    Node<K,V> e;
    return (e = getNode(hash(key), key)) == null ? null : e.value;   // hash算法是通过计算key的hashCode,如果对象key的hashCode方法没有重写,将会调用默认的native方法,即使key内部是一样的,由于不同对象的原因,也可能造成hashCode不一样。
}

/**
 * Implements Map.get and related methods
 *
 * @param hash hash for key
 * @param key the key
 * @return the node, or null if none
 */
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 && ((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);
                
              // 如果是链表,则遍历。这也是为什么会尽量避免链表出现的原因。因为通过它寻找结点的时间复杂度是O(n)
            do {
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
            
        }
    }
    return null;
}

最后

目前小猿只研究到了这么深,红黑树方面没有做太深入的了解,目前的大多数场景也没用上。其他的等待后续有研究后再补充。

猜你喜欢

转载自blog.csdn.net/weixin_43901067/article/details/105052640