(争取)一篇搞懂 HashMap 相关


二.HashMap相关
1、HashMap的实现原理,HashMap数据结构?HashMap源码理解,HashMap如何put数据(从HashMap源码角度讲解)?HashMap怎么手写实现?

6、ConcurrentHashMap的实现原理

7、ArrayMap和HashMap的对比

8、HashTable实现原理

9、TreeMap具体实现

10、HashMap和HashTable的区别

11、HashMap与HashSet的区别

12、HashSet与HashMap怎么判断集合元素重复?

13、二叉树的深度优先遍历和广度优先遍历的具体实现

14、堆和栈在内存中的区别是什么(解答提示:可以从数据结构方面以及实际实现方面两个方面去回答)?

15、讲一下对树,B+树的理解

16、讲一下对图的理解

17、判断单链表成环与否?

18、链表翻转(即:翻转一个单项链表)

19、合并多个单有序链表(假设都是递增的)

https://blog.csdn.net/liushengxi_root/article/details/86438816

HashMap的实现原理,HashMap数据结构?HashMap源码理解,HashMap如何put数据(从HashMap源码角度讲解)?HashMap怎么手写实现?

HashMap的重要属性

从HashMap的源码中,我们可以发现,HashMap是由一个Node数组构成,每个Node包含了一个key-value键值对。
(** TODO:transient 关键字的理解**)

  /**
     * The table, initialized on first use, and resized as
     * necessary. When allocated, length is always a power of two.
     * (We also tolerate length zero in some operations to allow
     * bootstrapping mechanics that are currently not needed.)
     */
    transient Node<K,V>[] table;

Node类作为HashMap中的一个内部类,除了key、value两个属性外,还定义了一个next指针。当有哈希冲突时,HashMap会用之前数组当中相同哈希值对应存储的Node对象,通过指针指向新增的相同哈希值的Node对象的引用。

static class Node implements Map.Entry {
        final int hash;
        final K key;
        V value;
        Node next;

        Node(int hash, K key, V value, Node next) {
            this.hash = hash;
            this.key = key;
            this.value = value;
            this.next = next;
        }
}

HashMap还有两个重要的属性:加载因子(loadFactor)和边界值(threshold)。在初始化HashMap时,就会涉及到这两个关键初始化参数。

    /**
     * The next size value at which to resize (capacity * load factor).
     *
     * @serial
     */
    // (The javadoc description is true upon serialization.
    // Additionally, if the table array has not been allocated, this
    // field holds the initial array capacity, or zero signifying
    // DEFAULT_INITIAL_CAPACITY.)
    int threshold; //就是哈希表的容量,默认是:1 << 4(为啥源码不直接写16呐?奇怪)

    /**
     * The load factor for the hash table.
     *
     * @serial
     */
    final float loadFactor; //装载因子 = 填入表中的元素个数 / 散列表的长度,默认是:0.75

HashMap添加元素优化

初始化完成后,HashMap就可以使用put()方法添加键值对了。从下面源码可以看出,当程序将一个key-value对添加到HashMap中,程序首先会根据该key的hashCode()返回值,再通过hash()方法计算出hash值,再通过putVal方法中的(n - 1) & hash决定该Node的存储位置。

 public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
    }
 static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }
  if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        //通过putVal方法中的(n - 1) & hash决定该Node的存储位置
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);

我们先来了解下hash()方法中的算法。如果我们没有使用hash()方法计算hashCode,而是直接使用对象的hashCode值,会出现什么问题呢?

假设要添加两个对象a和b,如果数组长度是16,这时对象a和b通过公式(n - 1) & hash运算,也就是(16-1)&a.hashCode和(16-1)&b.hashCode,15的二进制为0000000000000000000000000001111,假设对象 A 的 hashCode 为 1000010001110001000001111000000,对象 B 的 hashCode 为 0111011100111000101000010100000,你会发现上述与运算结果都是0。这样的哈希结果就太让人失望了,很明显不是一个好的哈希算法。但如果我们将 hashCode 值右移 16 位(h >>> 16代表无符号右移16位),也就是取 int 类型的一半,刚好可以将该二进制数对半切开,即将 key 的 hashCode 与 所得 hashCode 的高十六位进行 ^ 运算,让 hashCode 高十六位也加入到了运算中,把高位的特征和低位的特征组合起来,降低哈希冲突的概率,这样的话,就能避免上面的情况发生。这就是 hash() 方法的具体实现方式。简而言之,就是尽量打乱hashCode真正参与运算的低16位

(n - 1) & hash 是怎么设计的,这里的n代表哈希表的长度,哈希表习惯将长度设置为2的n次方,这样恰好可以保证(n - 1) & hash的计算得到的索引值总是位于table数组的索引之内。例如:hash=15,n=16时,结果为15;hash=17,n=16时,结果为15。举例就是:恰好可以保证在数组范围内的原因。 举例n=16,i=n-1,i=15,那么二进制为 0000 0000 0000 0000 0000 0000 0000 1111 因为是按位与&运算(&,同为1则1,否则为0),无论i = (n - 1) & hash中hash值是多少,与i的前28运算结果都为0。其变化区间被限制在后4位,即15以内。故可以有效保证在数组范围内。

在获得Node的存储位置后,如果判断Node不在哈希表中,就新增一个Node,并添加到哈希表中,整个流程我将用一张图来说明:
在这里插入图片描述

put 操作的实现源码:

 final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node[] tab; Node p; int n, i;
        if ((tab = table) == null || (n = tab.length) == 0)
//1、判断当table为null或者tab的长度为0时,即table尚未初始化,此时通过resize()方法得到初始化的table
            n = (tab = resize()).length;
        if ((p = tab[i = (n - 1) & hash]) == null)
//1.1、此处通过(n - 1) & hash 计算出的值作为tab的下标i,并另p表示tab[i],也就是该链表第一个节点的位置。并判断p是否为null
            tab[i] = newNode(hash, key, value, null);
//1.1.1、当p为null时,表明tab[i]上没有任何元素,那么接下来就new第一个Node节点,调用newNode方法返回新节点赋值给tab[i]
        else {
//2.1下面进入p不为null的情况,有三种情况:p为链表节点;p为红黑树节点;p是链表节点但长度为临界长度TREEIFY_THRESHOLD,再插入任何元素就要变成红黑树了。
            Node e; K k;
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
//2.1.1HashMap中判断key相同的条件是key的hash相同,并且符合equals方法。这里判断了p.key是否和插入的key相等,如果相等,则将p的引用赋给e

                e = p;
            else if (p instanceof TreeNode)
//2.1.2现在开始了第一种情况,p是红黑树节点,那么肯定插入后仍然是红黑树节点,所以我们直接强制转型p后调用TreeNode.putTreeVal方法,返回的引用赋给e
                e = ((TreeNode)p).putTreeVal(this, tab, hash, key, value);
            else {
//2.1.3接下里就是p为链表节点的情形,也就是上述说的另外两类情况:插入后还是链表/插入后转红黑树。另外,上行转型代码也说明了TreeNode是Node的一个子类
                for (int binCount = 0; ; ++binCount) {
//我们需要一个计数器来计算当前链表的元素个数,并遍历链表,binCount就是这个计数器

                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                        if (binCount >= TREEIFY_THRESHOLD - 1) 
// 插入成功后,要判断是否需要转换为红黑树,因为插入后链表长度加1,而binCount并不包含新节点,所以判断时要将临界阈值减1
                            treeifyBin(tab, hash);
//当新长度满足转换条件时,调用treeifyBin方法,将该链表转换为红黑树
                        break;
                    }
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }

所以真正的下标是:i = (n-1)&hash,n是哈希表容量,hash是 通过hash()方法计算出hash值,hash()方法一般是:key.hashCode()) ^ (h >>> 16 与运算。

在这里插入图片描述

在这里插入图片描述

QA

实际应用中,我们设置初始容量,一般得是 2 的整数次幂。你知道原因吗?

A:2的幂次方减1后每一位都是1,让数组每一个位置都能添加到元素。 例如十进制8,对应二进制1000,减1是0111,这样在&hash值使数组每个位置都是可以添加到元素的,如果有一个位置为0,那么无论hash值是多少那一位总是0,例如0101,&hash后第二位总是0,也就是说数组中下标为2的位置总是空的。 如果初始化大小设置的不是2的幂次方,hashmap也会调整到比初始化值大且最近的一个2的幂作为capacity。 作者回复: 回答正确,就是减少哈希冲突,均匀分布元素。

并发下还有什么问题?为什么?需要搞定它

5.HashMap是非线程安全的,并发情况下会形成环形链表与覆盖

ConcurrentHashMap 的实现原理

ConcurrentHashMap JDK1.7的实现

在这里插入图片描述
Segment数组的意义就是将一个大的table分割成多个小的table来进行加锁,也就是锁分离技术(分段锁技术),而每一个Segment元素存储的是 HashEntry数组+链表,这个和 JDK1.7 HashMap的数据存储结构一样

Segment继承了ReentrantLock,所以它就是一种可重入锁(ReentrantLock)。在ConcurrentHashMap,一个Segment就是一个子哈希表,Segment里维护了一个HashEntry数组,所以,并发环境下,对于同一个Segment的操作才需考虑线程同步,不同的Segment则无需考虑。

在 put 同一位置的值的时候,会对整个 Segment 加锁,保证线程安全。(但是这里其实并不用这么锁的范围这么大,因为在同一个Segment 中插入的位置也可能不同,那么为什么不一个Entry一个锁呐?有点奇怪哦

  • 是如何获取 ConcurrentHashMap 的size() 的?
    计算ConcurrentHashMap的元素大小是一个有趣的问题,因为他是并发操作的,就是在你计算size的时候,他还在并发的插入数据,可能会导致你计算出来的size和你实际的size有相差(在你return size的时候,插入了多个数据),要解决这个问题,JDK1.7版本用两种方案。

第一种方案他会使用不加锁的模式去尝试多次计算ConcurrentHashMap的size,最多三次,比较前后两次计算的结果,结果一致就认为当前没有元素加入,计算的结果是准确的;
第二种方案是如果第一种方案不符合,他就会给每个Segment加上锁,然后计算ConcurrentHashMap的size返回。

ConcurrentHashMap JDK1.8的实现

JDK1.8的实现已经摒弃了Segment的概念,而是直接用 Node数组+链表+红黑树 的数据结构来实现,并发控制使用Synchronized和CAS来操作,整个看起来就像是优化过且线程安全的HashMap,虽然在JDK1.8中还能看到Segment的数据结构,但是已经简化了属性,只是为了兼容旧版本。
在这里插入图片描述

TreeBin:管理红黑树的 manager
TreeNode:红黑树节点

put 操作
 public V put(K key, V value) {
        return putVal(key, value, false);
    }

    /** Implementation for put and putIfAbsent */
    final V putVal(K key, V value, boolean onlyIfAbsent) {
        if (key == null || value == null) throw new NullPointerException();
        int hash = spread(key.hashCode());
        int binCount = 0;
        for (Node<K,V>[] tab = table;;) {
            Node<K,V> f; int n, i, fh; K fk; V fv;
            if (tab == null || (n = tab.length) == 0)
                tab = initTable();
            else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
            	// CSA 插入
                if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value)))
                    break;                   // no lock when adding to empty bin
            }
            //如果在进行扩容,则先进行扩容操作
            else if ((fh = f.hash) == MOVED)
                tab = helpTransfer(tab, f);
            else if (onlyIfAbsent // check first node without acquiring lock
                     && fh == hash
                     && ((fk = f.key) == key || (fk != null && key.equals(fk)))
                     && (fv = f.val) != null)
                return fv;
            else {
                V oldVal = null;
                synchronized (f) {
                    if (tabAt(tab, i) == f) {
                        if (fh >= 0) {
                            binCount = 1;
                            for (Node<K,V> e = f;; ++binCount) {
                                K ek;
                                if (e.hash == hash &&
                                    ((ek = e.key) == key ||
                                     (ek != null && key.equals(ek)))) {
                                    oldVal = e.val;
                                    if (!onlyIfAbsent)
                                        e.val = value;
                                    break;
                                }
                                Node<K,V> pred = e;
                                if ((e = e.next) == null) {
                                    pred.next = new Node<K,V>(hash, key, value); //尾插法
                                    break;
                                }
                            }
                        }
                        else if (f instanceof TreeBin) { //如果是红黑树结构
                            Node<K,V> p;
                            binCount = 2;
                            // 旋转插入
                            if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
                                                           value)) != null) {
                                oldVal = p.val;
                                if (!onlyIfAbsent)
                                    p.val = value;
                            }
                        }
                        else if (f instanceof ReservationNode)
                            throw new IllegalStateException("Recursive update");
                    }
                }
                if (binCount != 0) {
                //链表转红黑树
                    if (binCount >= TREEIFY_THRESHOLD)
                        treeifyBin(tab, i);
                    if (oldVal != null)
                        return oldVal;
                    break;
                }
            }
        }
        addCount(1L, binCount);
        return null;
    }

三、总结与思考
其实可以看出JDK1.8版本的ConcurrentHashMap的数据结构已经接近HashMap,相对而言,ConcurrentHashMap只是增加了同步的操作来控制并发,从JDK1.7版本的 ReentrantLock+Segment+HashEntry,到JDK1.8版本中synchronized+CAS+HashEntry+红黑树,相对而言,总结如下思考:

1、JDK1.8的实现降低锁的粒度,JDK1.7版本锁的粒度是基于Segment的,包含多个HashEntry,而JDK1.8锁的粒度就是 HashEntry(首节点)
2、JDK1.8版本的数据结构变得更加简单,使得操作也更加清晰流畅,因为已经使用synchronized来进行同步,所以不需要分段锁的概念,也就不需要Segment这种数据结构了,由于粒度的降低,实现的复杂度也增加了
3、JDK1.8使用红黑树来优化链表,基于长度很长的链表的遍历是一个很漫长的过程,而红黑树的遍历效率是很快的,代替一定阈值的链表,这样形成一个最佳拍档
4、JDK1.8为什么使用内置锁synchronized来代替重入锁ReentrantLock,我觉得有以下几点:

  • 因为粒度降低了,在相对而言的低粒度加锁方式,synchronized并不比ReentrantLock差,在粗粒度加锁中ReentrantLock可能通过Condition来控制各个低粒度的边界,更加的灵活,而在低粒度中,Condition的优势就没有了
  • JVM的开发团队从来都没有放弃synchronized,而且基于JVM的synchronized优化空间更大,使用内嵌的关键字比使用API更加自然
  • 在大量的数据操作下,对于JVM的内存压力,基于API的ReentrantLock会开销更多的内存,虽然不是瓶颈,但是也是一个选择依据

HashMap7 和HashMap8为什么一个头插一个尾插?为什么HashMap7扩容后插入,HashMap8插入后扩容?

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值