Java入门系列之集合HashMap源码分析

简介: HashMap在Java中是使用比较频繁的键值对数据类型,所以我们非常有必要详细去分析背后的具体实现原理

我们知道在Java 8中对于HashMap引入了红黑树从而提高操作性能,由于在上一节我们已经通过图解方式分析了红黑树原理,所以在接下来我们将更多精力投入到解析原理而不是算法本身,HashMap在Java中是使用比较频繁的键值对数据类型,所以我们非常有必要详细去分析背后的具体实现原理,无论是C#还是Java原理解析,从不打算一行行代码解释,我认为最重要的是设计思路,重要的地方可能会多啰嗦两句。

HashMap原理分析
我们由浅入深,循序渐进,首先了解下在HashMap中定义的几个属性,稍后会进一步讲解为何要定义这个值,难道是靠拍脑袋吗。

public class HashMap extends AbstractMap

implements Map, Cloneable, Serializable {

//默认初始化容量
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;

}
构造函数分析

public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR; 
}

当实例化HashMap时,我们不指定任何参数,此时定义负载因子为0.75f

public HashMap(int initialCapacity) {

    this(initialCapacity, DEFAULT_LOAD_FACTOR);

}
当实例化HashMap时,我们也可以指定初始化容量,此时默认负载因子仍为0.75f。

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

当实例化HashMap时,我们既指定默认初始化容量,也可指定负载因子,很显然初始化容量不能小于0,否则抛出异常,若初始化容量超过定义的最大容量,则将定义的最大容量赋值与初始化容量,对于负载因子不能小于或等于0,否则抛出异常。接下来根据提供的初始化容量设置阈值,我们接下来看看上述tableSizeFor方法实现。

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的次幂大于初始化容量的最小值。 到学习java目前为止,我们接触到了模运算【%】、按位左移【<<】、按位右移【>>】,这里我们将学习到按位或运算【|】、无符号按位右移【>>>】。按位或运算就是二进制有1,结果就为1,否则为0,而无符号按位右移只是高位无正负之分而已。不要看到上述【n | = n >>> 1】一脸懵,实际上就是【n = n | n >>> 1】,和我们正常进行四则运算一个道理,只不过是逻辑运算和位运算符号不同而已罢了。我们通过如下例子来说明上述结论,假设初始化容量为5,接下来我们进行如上运算。

   0000 0000 0000 0000 0000 0000 0000 0101                                                      cap = 5

   0000 0000 0000 0000 0000 0000 0000 0100                                                      n = cap - 1
   

    0000 0000 0000 0000 0000 0000 0000 0010 n >>> 1

    0000 0000 0000 0000 0000 0000 0000 0110 n |= n >>> 1

    0000 0000 0000 0000 0000 0000 0000 0001 n >>> 2

    0000 0000 0000 0000 0000 0000 0000 0111 n |= n >>> 2

    0000 0000 0000 0000 0000 0000 0000 0000 n >>> 4

    0000 0000 0000 0000 0000 0000 0000 0111 n |= n >>> 4

    0000 0000 0000 0000 0000 0000 0000 0000 n >>> 8

    0000 0000 0000 0000 0000 0000 0000 0111 n |= n >>> 8

    0000 0000 0000 0000 0000 0000 0000 0000 n >>> 16

    0000 0000 0000 0000 0000 0000 0000 0111 n |= n >>> 16
如上最终算出来结果为7,然后加上最初计算时减去的1,所以对于初始化容量为5的最小2次幂为8,也就是阈值为8,要是初始化容量为8,那么阈值也为8。接下来到了我们的重点插入操作。

插入原理分析
public V put(K key, V value) {

    return putVal(hash(key), key, value, false, true);

}
上述插入操作简短一行代码,只不过是调用了putVal方法,但是我们注意到首先计算了键的哈希值,我们看看该方法实现。

static final int hash(Object key) {

    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);

}
直接理解方法大意是:若传入的键为空,则哈希值为0,否则直接调用键的本地hashCode方法获取哈希值,然后对其按位向右移16位,最后进行按位异或(只要不同结果就为1)操作。好像还是不懂,我们暂且搁置一下,我们继续看看插入方法具体实现。

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,

               boolean evict) {
    Node[] tab; Node p; int n, i;
    
    // 步骤【1】:tab为空扩容
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
        
    // 步骤【2】:计算index,并对null做处理     
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {
        Node e; K k;
        
        // 步骤【3】:键存在,直接覆盖值    
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
            
        // 步骤【4】:若为红黑树    
        else if (p instanceof TreeNode)
            e = ((TreeNode)p).putTreeVal(this, tab, hash, key, value);
        else {
            
            // 步骤【5】:若为链表
            for (int binCount = 0; ; ++binCount) {
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    
                    //若链表长度大于8则转换为红黑树进行处理
                    if (binCount >= TREEIFY_THRESHOLD - 1)
                        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;
    
    // 步骤【6】:超过最大容量进行扩容
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

我们首先来看来步骤【2】,我们待会再来看步骤【1】实现,我们首先摘抄上述获取键的索引逻辑

if ((p = tab[i = (n - 1) & hash]) == null)

        tab[i] = newNode(hash, key, value, null);

上述通过计算出键的哈希值并与数组的长度按位与运算,散列算法直接决定键的存储是否分布均匀,否则会发生冲突或碰撞,严重影响性能,所以上述【 (n - 1) & hash 】是发生碰撞的关键所在,难道我们直接调用键的本地hashCode方法获取哈希值不就可以了吗,肯定是不可以的,我们来看一个例子。假设我们通过调用本地的hashCode方法,获取几个键的哈希值为31、63、95,同时默认初始化容量为16。然后调用(n-1 & hash),计算如下:

0000 0000 0000 0000 0000 0000 0001 1111 hash = 31
0000 0000 0000 0000 0000 0000 0000 1111 n - 1
0000 0000 0000 0000 0000 0000 0000 1111 => 15

0000 0000 0000 0000 0000 0000 0011 1111 hash = 63
0000 0000 0000 0000 0000 0000 0000 1111 n - 1
0000 0000 0000 0000 0000 0000 0000 1111 => 15

0000 0000 0000 0000 0000 0000 0111 1111 hash = 95
0000 0000 0000 0000 0000 0000 0000 1111 n - 1
0000 0000 0000 0000 0000 0000 0000 1111 => 15
因为(2 ^ n-1)的低位始终都是1,再按照按位运算(0-1始终为0)所有最终结果都有1111,这就是为什么返回相同索引的原因,因此,尽管我们具有不同的哈希值,但结果却是存储到哈希桶数组相同索引位置。所以为了解决低位根本就没有参与到运算中的问题:通过调用上述hash方法,按位右移16位并异或,解决因低位没有参与运算导致冲突,提高性能。我们继续回到上述步骤【1】,当数组为空,内部是如何进行扩容的呢?我们来看看resize方法实现。

final Node[] resize() {

    Node[] 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 &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1;
    }
    else if (oldThr > 0) 
        newCap = oldThr;
    else {               
        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;
    ......
}

由上可知:当实例化HashMap并无参时,此时默认初始化容量为16,默认阈值为12,负载因子为0.75f,当指定参数(初始化容量比如为5),此时初始化容量为8,阈值为8,负载因子为0.75f。否则也指定了负载因子,则以指定负载因子为准。同时当超过容量时,扩容后的容量为原容量的2倍。到这里我们发现一个问题:hashTable中的容量可为奇或偶数,而HashMap中的容量永远都为2的次幂即偶数,为何要这样设计呢?

int index = (n - 1) & hash;
如上为HashMap计算在哈希桶数组中的索引位置,若HashMap中的容量不为2的次幂,此时通过按与运算,索引只能为16或0,这也就意味着将发生更多冲突,也将导致性能很差,本可通过O(1)进行检索,现在需要O(log n),因为发生冲突时,给定存储桶中的所有节点都将存储在红黑树中,若容量为2的次幂,此时按与运算符将和hashTable中计算索引存储位置的方式等同,如下:

int index = (hash & 0x7FFFFFFF) % tab.length;
按照HashMap计算索引的方式,当我们从2的次幂中减去1时,得到的是一个二进制末位全为1的数字,例如默认初始化容量为16,如果从中减去1,则得到15,其二进制表示形式是1111,此时如果对1111进行任意数字的按位与运算,我们将得到整数的最后4位,换句话说,等价于对16取模,但是除法运算通常是昂贵的运算,也就是说按位运算比取模运算效率更高。到此我们知道HashMap中容量为2的次幂的原因在于:哈希桶数组索引存储采取按位运算而非取模运算,因其效率比取模运算更高。进行完上述扩容后容量、阈值重新计算后,接下来则需要对哈希桶数组重新哈希(rehash),请继续往下看。

影响HashMap性能因素分析
在讲解上述重新哈希之前,我们需要重头开始进行叙述,直到这里,我们知道HashMap默认初始化容量为16,假如我们有16个元素放入到HashMap中,如果实现了很好的散列算法,那么在哈希桶数组中将在每个存储桶中放入1个元素,在此种情况下,查找元素仅需要1次,如果是HashMap中有256元素,如果实现了很好的散列算法,那么在哈希桶数组中将在每个存储桶中放入16个元素,同理,在此种情况下,查找任何一个元素,最多也只需要16次,到这里我们可以知道,如果HashMap中的哈希桶数组存储的元素增加一倍或几倍,那么在每个存储桶中查找元素的最大时间成本并不会很大,但是,如果持续维持默认容量即16不变,如果每个存储桶中有大量元素,此时,HashMap的映射性能将开始下降。比如现在HashMap中有一千六百万条数据,如果实现了很好的散列算法,将在每个存储桶中分配一百万个元素,也就是说,查找任意元素,最多需要查找一百万次。很显然,我们将存储的元素放大后,将严重影响HashMap性能,那么对此我们有何解决方案呢?让我们回到最初的话题,因为默认存储桶大小为16且当存储的元素条目少时,HashMap性能并未有什么改变,但是当存储桶的数量持续增加时,将影响HashMap性能,这是由于什么原因导致的呢?主要是我们一直在维持容量固定不变,我们却一直增加HashMap中哈希桶数组中存储元素的大小,这完全影响到了时间复杂度。如果我们增加存储桶大小,则当每个存储桶中的总项开始增加时,我们将能够使得每个存储桶中的元素个数保持恒定,并对于查询和插入操作保持O(1)的时间复杂度。那么增加存储桶大小也就是容量的时机是什么时候呢?存储桶的大小(容量)由负载因子决定,负载因子是一种度量,它决定着何时增加存储桶的大小(容量),以便针对查询和插入操作保持O(1)的时间复杂度,因此,何时增加容量的大小取决于乘积(初始化容量 负载因子),所以容量和负载因子是影响HashMap性能的根本因素。我们知道默认负载因子是0.75,也就是百分之75,所以增加容量大小的值为(16 0.75)= 12,这个值我们称之为阈值,也就意味着,在HashMap中存储直到第12个键值对时,都将保持容量为16,等到第13个键值对插入到HashMap中时,其容量大小将由默认的16变为( 16 * 2)= 32。通过上述计算增加容量大小即阈值的公式,我们从反向角度思考:负载因子比率 = 哈希桶数组中元素个数 / 哈希桶数组桶大小,举个栗子,若默认桶大小为16,当插入第一个元素时,其负载因子比率 = 1 / 16 = 0.0625 > 0.75 吗?若为否无需增加容量,当插入第13个元素时,其负载因子比率 = 13 / 16 = 0.81 > 0.75吗?若为是则需增加容量。讲完这里,我们再来看看重哈希,在讲解为什么要进行重哈希之前,我们需要了解重哈希的概念:重新计算已存储在哈希桶数组中元素的哈希码的过程,当达到阈值时,将其移动到另外一个更大的哈希桶数组中。当存储到哈希桶数组中的元素超过了负载因子的限制时,此时将容量增加一倍并进行重哈希。那么为何要进行重哈希呢?因为容量增加一倍后,如若不处理已存在于哈希桶数组中键值对,那么将容量增加一倍则没有任何意义,同时呢,也是为了保持每一个存储桶中元素保持均匀分布,因为只有将元素均匀的分布到每一个存储桶中才能实现O(1)时间复杂度。接下来我们继续进行重哈希源码分析

重哈希源码分析
Node[] newTab = (Node[])new Node[newCap];

    table = newTab;
    if (oldTab != null) {
        for (int j = 0; j < oldCap; ++j) {
            Node 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)e).split(this, newTab, j, oldCap);
                else { // preserve order
                    Node loHead = null, loTail = null;
                    Node hiHead = null, hiTail = null;
                    Node 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;
}

从整体上分析扩容后进行重哈希分为三种情况: ① 哈希桶数组元素为非链表即数组中只存在一个元素 ②哈希桶数组元素为红黑树进行转换 ③哈希桶数组元素为链表。关于①②情况就不用我再叙述,我们接下来重点看看对链表的优化处理。也就是如下这一段代码:

                    Node loHead = null, loTail = null;
                    Node hiHead = null, hiTail = null;
                    Node 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;
                    }

看到上述代码我们不禁疑惑为貌似声明了两个链表,一个低位链表(lower),一个高位链表(high),我们暂且不是很理解哈,接下来我们进入 do {} while () 循环,然后重点来了这么一句 e.hash & oldCap == 0 ,这是干啥玩意,根据此行代码来分别进入到低位链表和高位链表。好了,我们通过一例子就很好理解了:假设按照默认初始化容量为16,然后我们插入一个为21的元素,根据我们上面的叙述,首先计算出哈希值,然后计算出索引位置,为了便于很直观的理解,我们还是一步步来计算下。

static final int hash(21) {

    int h;
    return (21 == null) ? 0 : (h = 21.hashCode()) ^ (h >>> 16);

}
调用如上hash方法计算出键21的值仍为21,接下来通过如下按与运算计算出存储到哈希桶数组中的索引位置。

i = (16 - 1) & 21
最终我们计算其索引位置即i等于5,因为初始化容量为16,此时阈值为12,当插入第13个元素开始进行扩容,容量变为32,此时若再次按照上述方式计算索引存储位置为 i = (32 - 1) & 21 ,结果为21。从这里我们得出结论:当容量为16时,插入元素21的索引位置为5,而扩容后容量为32,此时插入元素21的索引位置为21,也就是说【扩容后的新的索引 = 原有索引 + 原有容量】。同理,若插入元素为5,容量为16,那么索引位置为5,若扩容后容量为32,索引位置同样也为5,也就是说【扩容后的索引 = 原有索引】。因为容量始终为原有容量的2倍(比如16到32即从0000 0000 0000 0000 0000 0000 0001 0000 =》0000 0000 0000 0000 0000 0000 0010 0000)从按位考虑则是高位由0变为1,也就是说我们通过计算出元素的哈希值与原有容量按位与运算,若结果等于0,则扩容后索引等于原索引,否则等于原有索引加上原有容量,也就是通过哈希值与原容量按位与运算即 e.hash & oldCap == 0 来判断新索引位置是否发生了改变,说的更加通俗易懂一点,比如(5 & 16 = 0),那么元素5扩容后的索引位置为【新索引 = 原索引 + 0】,同理比如(21 & 16 = 16),那么元素21的扩容后的索引位置为【新索引 = 原索引 + 16】。由于容量始终为2次幂,如此而节省了之前版本而重新计算哈希的时间从而达到优化。到这里,我们可以进一步总结出容量始终为2次幂的意义:①哈希桶数组索引存储采取按位运算而非取模运算,因其效率比取模运算更高 ②优化重新计算哈希而节省时间。最终将索引不变链表即低位链表和索引改变链表即高位链表分别放入扩容后新的哈希桶数组中,最终重新哈希过程到此结束。接下来我们分析将元素如何放入到红黑树中的呢?

将值插入红黑树保持树平衡

        // 步骤【4】:若为红黑树    
        else if (p instanceof TreeNode)
            e = ((TreeNode)p).putTreeVal(this, tab, hash, key, value);

然后我们看看上述将值放入到红黑树中具体方法实现,如下:

    final TreeNode putTreeVal(HashMap map, Node[] tab,
                                   int h, K k, V v) {
        Class kc = null;
        boolean searched = false;
        TreeNode root = (parent != null) ? root() : this;
        for (TreeNode p = root;;) {
            int dir, ph; K pk;
            if ((ph = p.hash) > h)
                dir = -1;
            else if (ph < h)
                dir = 1;
            else if ((pk = p.key) == k || (k != null && k.equals(pk)))
                return p;
            else if ((kc == null &&
                      (kc = comparableClassFor(k)) == null) ||
                     (dir = compareComparables(kc, k, pk)) == 0) {
                if (!searched) {
                    TreeNode q, ch;
                    searched = true;
                    if (((ch = p.left) != null &&
                         (q = ch.find(h, k, kc)) != null) ||
                        ((ch = p.right) != null &&
                         (q = ch.find(h, k, kc)) != null))
                        return q;
                }
                dir = tieBreakOrder(k, pk);
            }

            TreeNode xp = p;
            if ((p = (dir <= 0) ? p.left : p.right) == null) {
                Node xpn = xp.next;
                TreeNode x = map.newTreeNode(h, k, v, xpn);
                if (dir <= 0)
                    xp.left = x;
                else
                    xp.right = x;
                xp.next = x;
                x.parent = x.prev = xp;
                if (xpn != null)
                    ((TreeNode)xpn).prev = x;
                moveRootToFront(tab, balanceInsertion(root, x));
                return null;
            }
        }
    }

我们需要思考的是:(1)待插入元素在红黑树中的具体位置是在哪里呢?(2)找到插入具体位置后,然后需要知道的到底是左边还是右边呢?。我们按照正常思路理解的话还是非常容易想明白,我们从根节点开始遍历树,通过每一个节点的哈希值与待插入节点哈希值比较,若待插入元素位于其父节点的左边,则看父节点的左边是否已存在元素,如果不存在则将其父节点的左边节点留给待插入节点,同理对于父节点的右边也是如此,但是如果父节点的左边和右边都有其引用,那么就继续遍历,直到找到待插入节点的具体位置。这就是我们在写代码或进行代码测试时的一般思路,但是我们还要考虑边界问题,否则说明考虑不完全,针对待插入元素插入到红黑树中的边界问题是什么呢?当遍历的节点和待插入节点的哈希值相等,那么此时我们应该确定元素的顺序来保持树的平衡呢?也就是上述中的如下代码:

            else if ((kc == null &&
                      (kc = comparableClassFor(k)) == null) ||
                     (dir = compareComparables(kc, k, pk)) == 0) {
                if (!searched) {
                    TreeNode q, ch;
                    searched = true;
                    if (((ch = p.left) != null &&
                         (q = ch.find(h, k, kc)) != null) ||
                        ((ch = p.right) != null &&
                         (q = ch.find(h, k, kc)) != null))
                        return q;
                }
                dir = tieBreakOrder(k, pk);
            }

为了解决将元素插入到红黑树中,如何确定元素顺序的问题。通过两种方案来解决:①实现Comprable接口 ②突破僵局机制。接下来我们来看实现Comprable例子,如下:

public class PersonComparable implements Comparable {
int age;

public PersonComparable(int age) {
    this.age = age;
}

@Override
public boolean equals(Object obj) {
    if (this == obj) {
        return true;
    }

    if (obj instanceof PersonComparable) {
        PersonComparable p = (PersonComparable) obj;
        return (this.age == p.age);
    }

    return false;
}

@Override
public int hashCode() {
    return 42;
}

@Override
public int compareTo(PersonComparable o) {
    return this.age - o.age;
}

}
然后我们在控制台中,调用如下代码进行测试:

    HashMap hashMap = new HashMap();

    Person p1 = new Person(1);
    Person p2 = new Person(2);
    Person p3 = new Person(3);
    Person p4 = new Person(4);
    Person p5 = new Person(5);
    Person p6 = new Person(6);
    Person p7 = new Person(7);
    Person p8 = new Person(8);
    Person p9 = new Person(9);
    Person p10 = new Person(10);
    Person p11 = new Person(11);
    Person p12 = new Person(12);
    Person p13 = new Person(13);

    hashMap.put(p1, "1");
    hashMap.put(p2, "2");
    hashMap.put(p3, "3");
    hashMap.put(p4, "4");
    hashMap.put(p5, "5");
    hashMap.put(p6, "6");
    hashMap.put(p7, "7");
    hashMap.put(p8, "8");
    hashMap.put(p9, "9");
    hashMap.put(p10, "10");
    hashMap.put(p11, "11");
    hashMap.put(p12, "12");
    hashMap.put(p13, "13");

反观上述代码,我们实现了Comprable接口并且直接重写了hashcode为常量值,此时将产生冲突,插入到HashMap中每一个元素的哈希值都相等即索引位置一样,也就是最终将由链表转换为红黑树,既然哈希值一样,那么我们如何确定其顺序呢?,此时我们回到上述 comparableClassFor 方法和 compareComparables 方法(二者具体实现就不一一解释了)

// 若实现Comparable接口返回其具体实现类型,否则返回空
static Class comparableClassFor(Object x) {

    if (x instanceof Comparable) {
        Class c; Type[] ts, as; Type t; ParameterizedType p;
        if ((c = x.getClass()) == String.class) // bypass checks
            return c;
        if ((ts = c.getGenericInterfaces()) != null) {
            for (int i = 0; i < ts.length; ++i) {
                if (((t = ts[i]) instanceof ParameterizedType) &&
                    ((p = (ParameterizedType)t).getRawType() ==
                     Comparable.class) &&
                    (as = p.getActualTypeArguments()) != null &&
                    as.length == 1 && as[0] == c) // type arg is c
                    return c;
            }
        }
    }
    return null;
}

//调用自定义实现Comprable接口比较器,从而确定顺序
static int compareComparables(Class kc, Object k, Object x) {

    return (x == null || x.getClass() != kc ? 0 :
            ((Comparable)k).compareTo(x));
}

但是要是上述我们实现的Person类没有实现Comprable接口,此时将使用突破僵局机制(我猜测作者对此方法的命名是否是来自于维基百科《https://en.wikipedia.org/wiki/Tiebreaker》,比较贴切【突破僵局制(英语:tiebreaker),是一种延长赛的制度,主要用于棒球与垒球运动,特别是采淘汰制的季后赛及国际赛,以避免比赛时间过久仍无法分出胜负。】),也就是对应如下代码:

dir = tieBreakOrder(k, pk);

   static int tieBreakOrder(Object a, Object b) {
        int d;
        if (a == null || b == null ||
            (d = a.getClass().getName().
             compareTo(b.getClass().getName())) == 0)
            d = (System.identityHashCode(a) <= System.identityHashCode(b) ?
                 -1 : 1);
        return d;
    }

因为哈希值相等,同时也没有实现Comparable接口,但是我们又不得不解决这样实际存在的问题,可以说是最终采取“迫不得已“的解决方案,通过调用上述 System.identityHashCode 来获取对象唯一且恒定的哈希值从而确定顺序。好了,那么问题来了对于实现Comparable接口的键插入到树中和未实现接口的键插入到树中,二者有何区别呢?如果键实现Comparable接口,查找指定元素将会使用树特性快速查找,如果键未实现Comparable接口,查找到指定元素将会使用遍历树方式查找。问题又来了,既然通过实现Comparable接口的比较器来确定顺序,那为何不直接使用突破僵局机制来作为比较器?我们来看看那如下例子:

Person person1 = new Person(1);
Person person2 = new Person(1);
System.out.println(System.identityHashCode(person1) == System.identityHashCode(person2));

到这里我们知道,即使是两个相同的对象实例其identityHashCode都是不同的,所以不能使用identityHashCode作为比较器。问题又来了,既然使用identityHashCode确定元素顺序,当查找元素时是采用遍历树的方式,完全没有利用到树的特性,那么为何还要构造树呢?因为HashMap能够包含不同对象实例的键,有些可能实现了Comparable接口,有些可能未实现。我们来看如下混合不同类例子:

public class Person {

int age;

public Person(int age) {
    this.age = age;
}

@Override
public boolean equals(Object obj) {
    if (this == obj) {
        return true;
    }

    if (obj instanceof Person) {
        Person p = (Person) obj;
        return (this.age == p.age);
    }

    return false;
}

@Override
public int hashCode() {
    return 42;
}

}

    HashMap hashMap = new HashMap();

    PersonComparable  p1 = new PersonComparable (1);
    PersonComparable  p2 = new PersonComparable (2);
    PersonComparable  p3 = new PersonComparable (3);
    PersonComparable  p4 = new PersonComparable (4);
    PersonComparable  p5 = new PersonComparable (5);
    PersonComparable  p6 = new PersonComparable (6);

    Person p7 = new Person(7);
    Person p8 = new Person(8);
    Person p9 = new Person(9);
    Person p10 = new Person(10);
    Person p11 = new Person(11);
    Person p12 = new Person(12);
    Person p13 = new Person(13);

    hashMap.put(p1, "1");
    hashMap.put(p2, "2");
    hashMap.put(p3, "3");
    hashMap.put(p4, "4");
    hashMap.put(p5, "5");
    hashMap.put(p6, "6");
    hashMap.put(p7, "7");
    hashMap.put(p8, "8");
    hashMap.put(p9, "9");
    hashMap.put(p10, "10");
    hashMap.put(p11, "11");
    hashMap.put(p12, "12");
    hashMap.put(p13, "13");

当使用混合模式时即实现了Comparable接口和未实现Comparable接口的对象实例可以基于类名来比较键。

总结
本节我们详细讲解了HashMap实现原理细节,一些比较简单的地方就没有再一一分析,文中若有叙述不当或理解错误之处,还望指正,感谢您的阅读

目录
相关文章
|
13天前
|
监控 安全 Java
Java中的多线程编程:从入门到实践####
本文将深入浅出地探讨Java多线程编程的核心概念、应用场景及实践技巧。不同于传统的摘要形式,本文将以一个简短的代码示例作为开篇,直接展示多线程的魅力,随后再详细解析其背后的原理与实现方式,旨在帮助读者快速理解并掌握Java多线程编程的基本技能。 ```java // 简单的多线程示例:创建两个线程,分别打印不同的消息 public class SimpleMultithreading { public static void main(String[] args) { Thread thread1 = new Thread(() -> System.out.prin
|
15天前
|
Java
Java 8 引入的 Streams 功能强大,提供了一种简洁高效的处理数据集合的方式
Java 8 引入的 Streams 功能强大,提供了一种简洁高效的处理数据集合的方式。本文介绍了 Streams 的基本概念和使用方法,包括创建 Streams、中间操作和终端操作,并通过多个案例详细解析了过滤、映射、归并、排序、分组和并行处理等操作,帮助读者更好地理解和掌握这一重要特性。
25 2
|
15天前
|
安全 Java
Java多线程集合类
本文介绍了Java中线程安全的问题及解决方案。通过示例代码展示了使用`CopyOnWriteArrayList`、`CopyOnWriteArraySet`和`ConcurrentHashMap`来解决多线程环境下集合操作的线程安全问题。这些类通过不同的机制确保了线程安全,提高了并发性能。
|
19天前
|
存储 Java
判断一个元素是否在 Java 中的 Set 集合中
【10月更文挑战第30天】使用`contains()`方法可以方便快捷地判断一个元素是否在Java中的`Set`集合中,但对于自定义对象,需要注意重写`equals()`方法以确保正确的判断结果,同时根据具体的性能需求选择合适的`Set`实现类。
|
19天前
|
Java 大数据 API
14天Java基础学习——第1天:Java入门和环境搭建
本文介绍了Java的基础知识,包括Java的简介、历史和应用领域。详细讲解了如何安装JDK并配置环境变量,以及如何使用IntelliJ IDEA创建和运行Java项目。通过示例代码“HelloWorld.java”,展示了从编写到运行的全过程。适合初学者快速入门Java编程。
|
19天前
|
存储 Java 开发者
在 Java 中,如何遍历一个 Set 集合?
【10月更文挑战第30天】开发者可以根据具体的需求和代码风格选择合适的遍历方式。增强for循环简洁直观,适用于大多数简单的遍历场景;迭代器则更加灵活,可在遍历过程中进行更多复杂的操作;而Lambda表达式和`forEach`方法则提供了一种更简洁的函数式编程风格的遍历方式。
|
19天前
|
Java 开发者
|
19天前
|
存储 Java 开发者
Java中的集合框架深入解析
【10月更文挑战第32天】本文旨在为读者揭开Java集合框架的神秘面纱,通过深入浅出的方式介绍其内部结构与运作机制。我们将从集合框架的设计哲学出发,探讨其如何影响我们的编程实践,并配以代码示例,展示如何在真实场景中应用这些知识。无论你是Java新手还是资深开发者,这篇文章都将为你提供新的视角和实用技巧。
18 0
|
9天前
|
Java 开发者
Java多线程编程中的常见误区与最佳实践####
本文深入剖析了Java多线程编程中开发者常遇到的几个典型误区,如对`start()`与`run()`方法的混淆使用、忽视线程安全问题、错误处理未同步的共享变量等,并针对这些问题提出了具体的解决方案和最佳实践。通过实例代码对比,直观展示了正确与错误的实现方式,旨在帮助读者构建更加健壮、高效的多线程应用程序。 ####
|
17天前
|
安全 Java 测试技术
Java并行流陷阱:为什么指定线程池可能是个坏主意
本文探讨了Java并行流的使用陷阱,尤其是指定线程池的问题。文章分析了并行流的设计思想,指出了指定线程池的弊端,并提供了使用CompletableFuture等替代方案。同时,介绍了Parallel Collector库在处理阻塞任务时的优势和特点。
下一篇
无影云桌面