Loading

和HashMap谈对象

”双肩包“、“格子衫”、“秃秃头”、“单身狗”,作为一名伟大而光荣的程序员,为啥给我贴这么多标签?宝宝心里苦呀!别人笑我太疯癫,我笑别人看不穿。我微微一笑,心中轻蔑的想:“无知的人类,其他我都忍了,敢说我们单身狗,知道我们每天创建多少个对象吗? 什么?不服气,今天就给你们介绍介绍魔鬼身材,温柔贤惠的HashMap”。

aa

一、HashMap的底层数据结构

先给你瞟一眼HashMap这个妮儿的样子,唉!唉!唉!你别抢我的照片呀!

hashmap结构

主要有数组、链表和红黑树组成,数组中每个元素称为一个桶,每个桶中通过链表的方式存放多个节点,当链表的长度到达一定的阈值后,链表就会转换为红黑树。

可别觉得花里胡哨的,我们的HashMap可不是花瓶,虽然没吃一个Key-Value都会变胖(链表长度变长),但是我能利用他们变成大长腿(HashMap的resize,数组长度边长),并且让横向生长的肉肉长的慢一点(链表的树化)。

二、HashMap的属性

先给你说说HashMap这个妮儿的长相(属性),“肤白貌美大长腿,腰细齿白樱桃嘴。勤俭持家解人意,深入了解不后悔。”

//这个数组就是用来存储键值对的表。每一个元素都是Node类型。Node类的定义如下:
transient Node<K,V>[] table;

//hashMap的节点,用来存储键值对
class Node<K,V> implements Map.Entry<K,V> {
        final int hash; //key的hash值
        final K key; //键
        V value; //值
        Node<K,V> next; //下一个节点的引用
}

//你可能轻蔑的一笑说,就这还是美女呢?别着急呀,这些才是皮毛,皮毛懂吗?

//默认初始容量。就是默认table数组的长度。
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
//最大容量。table数组的最大长度。
static final int MAXIMUM_CAPACITY = 1 << 30;

// 看到没!看到没!她身高能长到1 << 30 cm,(悄悄告诉你,吃的多才长这么高)

//默认负载因子。
static final float DEFAULT_LOAD_FACTOR = 0.75f;

//桶树化的阈值
static final int TREEIFY_THRESHOLD = 8;

//桶解除树化的阈值
static final int UNTREEIFY_THRESHOLD = 6;

//树化的最小容量。即table的长度达到64并且桶中的节点数量大于8才会进行树化
static final int MIN_TREEIFY_CAPACITY = 64;

//下一次resize的阈值(capacity * load factor).如果尚未为分配数组table,那么这个值表示初始容量
int threshold;

//负载因子
final float loadFactor;

//懵了懵了!怎么那么多属性呀这哪能分得清呀,然我们结合HashMap的秘籍来更深入的了解她

三、HashMap的重要方法

3.1 HashMap的构造函数

知道HashMap这个妮儿是怎么来的吗?你鄙夷的看了我一眼慢慢说:“羞羞的事情之后,形成受精卵,然后........”。

NO! No! No! 我摇着手指。什么羞羞的事,什么受精卵,都是通过构造函数new出来的。

//无参构造函数。只为负载因子赋了个默认值,其他什么都没有做。
public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}

//参数为初始化容量。然后调用最后一个构造函数。
public HashMap(int initialCapacity) {
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
//参数为初始容量和负载因子
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);
}
//通过给出的容量计算table的长度。这个值只能是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;
}

3.2 HashMap的put方法

HashMap这个妞最重要方法了,key-value键值对是怎么存的,什么时候进行resize,什么时候进行链表到树的转换都能在这个方法中看到。

//传入key-value,这个经常使用
public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}
// 通过hashcode的高16位和低16位异或运算计算得到hash值,增加hash值的差异性。
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

//参数:
/*
hash:key的hash值
key: key
value: value
onlyIfAbsent: 如果为true则不改变已存在的值
evict:如果是false,则table是创建模式
*/
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数组进行初始化,在第一次put的时候table需要进行初始化,
    //这里会调用resize()进行初始化,这个待会儿再介绍
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    //通过(n - 1) & hash算出再哪个桶中。因为数组的长度为2的整数次方通过&运算计算出位置。
    //如果这个值是null,直接放入这个数组中
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {
        //这个里面是key的hash值冲突的情况,桶中可能已经存在其他元素。
        //变量e表示已经存在相同的key的节点。
        Node<K,V> e; K k;
        //判断第一个节点是不是和要插入的key相同。
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        else if (p instanceof TreeNode)
            //如果p是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);
                    //如果到达的树化的阈值,则执行treeifyBin进行树化。
                    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 != null 说明存在和要插入的key相同的节点
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            //通过onlyIfAbsent判断是否要覆盖已经存在的值
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount;
    //如果size到达了rehash的阈值则执行resize方法
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

我们通过一个流程图梳理一下整个put的过程。

bb

3.3 HashMap的resize方法

分析完HashMap的整体过程后,我们接下来就详细的分析一下resize方法的代码,看看HashMap是如何进行自动扩容的。

//哇。。。。 好长呀! 别着急,耐心看下去
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;
        }
        //新容量就是将oldCap*2
        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
        //初始阈值是0的时候使用默认值
        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"})
    //初始化table,如果第一次插入key-value,则执行到这里就初始化结束,返回结果了,下面的if中代码不会执行。
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;
    //这个if中是真正的扩容,将老table中的数据迁移到新的table中
    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)
                    //桶中只有一个节点,重新计算hash值放入新的table中
                    newTab[e.hash & (newCap - 1)] = e;
                else if (e instanceof TreeNode)
                    //如果是树节点则对数节点进行rehash
                    ((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;
                    //这个循环中就是遍历老的链表,然后重新计算hash值后移动到新的table中
                    do {
                        next = e.next;
                        if ((e.hash & oldCap) == 0) {
                            //这里表示这些节点不需要迁移,重新计算后仍然处于这个桶中
                            if (loTail == null)
                                loHead = e;
                            else
                                loTail.next = e;
                            loTail = e;
                        }
                        else {
                            //这里表示需要迁移到j + oldCap位置的桶中的节点
                            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;
                    }
                }
            }
        }
    }
    //返回新table
    return newTab;
}

建议大家可以对照着源码,按照注释来慢慢分析resize的过程,相信你们聪明的小脑袋一定能够读懂这段逻辑。

3.4 HashMap的get方法

接下来可以放轻松了,介绍一下简单的get方法,看看历尽千辛万苦吃进去的的key-value,怎么才能吐出来。

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

//hash:key的hash值
//key:key
final Node<K,V> getNode(int hash, Object key) {
    Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {
        //计算出hash值对应的桶的下标
        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);
        }
    }
    //没有查到则返回null
    return null;
}

//简单吧,这就是小意思

四、HashMap不是线程安全的

爱了!爱了!HashMap这个妞儿这么好呀!先别着急呀!俗话说:“金无足赤,人无完人”。HashMap也是有不足之处的,那就是HashMap不是线程安全的,因此在并发场景下,可能会出现问题哦!那么在多线程情况下会出现什么情况呢?

1、多线程put操作后,get操作导致死循环。(1.8 通过将头插法改成尾插法,解决了这个问题)
2、多线程put非NULL元素后,get操作得到NULL值。
3、多线程put操作,导致元素丢失。

我总结了以上三个线程不安全的场景,如果你有补充欢迎留言。

五、JDK 1.7 和 1.8 HashMap的区别

JDK1.8 对HashMap进行了优化,提供更好的性能和安全性。主要有一下区别:

  1. 存储结构不同。1.7是数组+链表的存储结构;1.8是数组+链表+红黑树的形式存储元素。因为当链表的长度过长的时候,查询的效率就直线下降,所以在JDK1.8之后将其设计为当链表的长度达到一定的阈值的时候,就会将链表结构转换为红黑树结构,红黑树是一种自平衡的二叉搜索树,提高查询效率。
  2. 插入数据的方式不同。1.7及之前采用的是链表头部插入数据;1.8及之后采用的是链表尾部插入数据;扩容后转换数据不需要遍历到链表的尾部再插入,最近添加的元素可能马上就要被获取,头插的方式只需要遍历到链表的头部就能匹配到了。扩容后链表可能会倒序,并发扩容可能会产生循环链。
  3. Hash运算不同
  4. 扩容方式不同。1.7及之前首先检查是否需要进行扩容,再插入数据(扩容为原来的两倍);1.8及之后首先插入数据,再检查是否需要扩容,并优化了扩容算法。

六、结语

相信看完这篇文章之后能对你认识HashMap这个妮儿有帮助,只能帮到这里了,能不能成就靠你自己多多的去了解了,要是有帮助的话别忘了点个关注哈!

posted @ 2022-06-09 00:12  Charming-Boy  阅读(29)  评论(0编辑  收藏  举报