Map、HashMap源码分析、ConcurrentHashMap内部实现

Collection集合的最大特点是每次进行单个对象的存储,而Map是进行一对对象的保存,并且这两个对象之间的关系是key=value的关系。这种结构最大的特点是可以通过key值找到value值。

Map接口常用的方法如下:
在这里插入图片描述
当然,Map也是一个接口,要想实例化,也需要子类,Map有四个子类:HashMapHashtableTreeMapConcurrentHashMap

HashMap

范例:

 Map<Integer,String> map=new HashMap<Integer, String>();
        map.put(1,"hello");
        map.put(1,"hello");
        map.put(2,"Java");
        map.put(3,"is");
        map.put(4,"best");
        map.put(null,"!!!");
        map.put(null,"...");
        map.put(5,null);
        map.put(6,null);
        System.out.println(map);
        System.out.println(map.get(1));
        System.out.println(map.get(10));

在这里插入图片描述

HashMap的内部实现原理

对于HashMap最重要的是理解内部实现原理!!!!!
先看两个图
在这里插入图片描述
在这里插入图片描述

HashMap内部可以看作是数组(Node[] table)和链表结合组成的复合结构,数组被分为一个个桶(bucket),通过哈希值决定了键值对在这个数组的寻址;哈希值相同的键值对,则以链表形式存储。如果链表大小超过阈值(TREEIFY_THRESHOLD, 8),图中的链表就会被改造为树形结构。

这里我们需要看着源码来解析:
在这里插入图片描述
在这里插入图片描述
这是构造函数,从这个构造函数的源码中我们可以知道HashMap并不是一开始就初始化好的。只是设置了一些初始值。
再看看放数据的时候是如何存储的,如下图:
在这里插入图片描述
调用了putVal()方法,下面是次方法的源码:

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

从 putVal 方法最初的几行,我们就可以发现几个有意思的地方:
如果表格是 null,resize 方法会负责初始化它,这从 tab = resize() 可以看出。
resize 方法兼顾两个职责,创建初始存储表格,或者在容量不满足需求的时候,进行扩容(resize)

具体键值对在哈希表中的位置(数组 index)取决于下面的位运算:

i=(n-1)&hash

我们会发现,它并不是 key 本身的 hashCode,而是来自于 HashMap内部的另外一个 hash 方法。注意,为
什么这里需要将高位数据移位到低位进行异或运算呢?这除是因为有些数据计算出的哈希值差异主要在高
位,而 HashMap 里的哈希寻址是忽略容量以上的高位的,那么这种处理就可以有效避免类似情况下的哈希
碰撞。

再看看realize()方法:
在这里插入图片描述
依据 resize 源码,不考虑极端情况(容量理论最大极限由 MAXIMUM_CAPACITY 指定,数值为 1<<30,也就是 2的 30 次方),我们可以归纳为:

  • 门限值等于(负载因子)*(容量),如果构建 HashMap 的时候没有指定它们,那么就是依据相应的
    默认常量值。
  • 门限通常是以倍数进行调整 (newThr = oldThr << 1),我前面提到,根据 putVal 中的逻辑,当元素
    个数超过门限大小时,则调整 Map 大小。
  • 扩容后,需要将老的数组中的元素重新放置到新的数组,这是扩容的一个主要开销来源

在上面的讨论中,我们离不开的两个词是负载因子和容量,为什么在这么在乎负载因子和容量呢?

这是因为负载因子和容量关乎可用桶的数量,如果空桶太多会浪费空间,使用的太满则会影响操作的性能。可以假想极端情况下,只有一个桶,那就变成了链表,性能大大降低。可以考虑预先设置合适的容量大小。具体数值我们可以根据扩容发生的条件来做简单预估,根据前面的代码分析,我们知道它需要符合计算条件:

负载因子 * 容量 > 元素数量
所以,预先设置的容量需要满足,大于“预估元素数量 / 负载因子”,同时它是 2 的幂数
当桶的容量小于等于64,链表长度大于8,进行扩容
当桶的容量大于64并且链表长度大于8的时候,进行树化
那么,为什么 HashMap 要树化呢?

本质上这是个安全问题。因为在元素放置过程中,如果一个对象哈希冲突,都被放置到同一个桶里,则会形成一个链表,我们知道链表查询是线性的,会严重影响存取的性

Hashtable

        Map<Integer,String > map=new Hashtable<Integer, String>();
        map.put(1,"hello");
        map.put(1,"hello");
        map.put(2,"Java");
        map.put(3,"is");
        map.put(4,"best");
//        map.put(null,"!!!");//异常
//        map.put(null,"...");//异常
//        map.put(5,null);//异常
//        map.put(6,null);//异常
        System.out.println(map.get(1));
        System.out.println(map.get(10));

事实是,Hashtable里面的key值和value值都不允许为空,否则会抛出异常。

HashMap与Hashtable的区别:

在这里插入图片描述

ConcurrentHashMap

Hashtable本身比较低效,因为它的实现基本上是将put(),get(),size()方法加上了synchronized,这就导致了所有并发操作都要竞争同一把锁,也就是说一个线程获得这个锁的时候,其他线程都在等待,这就是能效率大大降低。
早期 ConcurrentHashMap,其实现是基于:

分离锁,也就是将内部进行分段(Segment),里面则是HashEntry 的数组,和 HashMap类似,哈希
相同的条目也是以链表形式存放。
HashEntry 内部使用 volatile 的 value 字段来保证可见性,也利用了不可变对象的机制以改进利用Unsafe 提供的底层能力,比如 volatile access,去直接完成部分操作,以最优化性能,毕竟 Unsafe 中的很多操作都是 JVM intrinsic 优化过的。

可以参考下面这个早期 ConcurrentHashMap 内部结构的示意图,其核心是利用分段设计,在进行并发操作的时
候,只需要锁定相应段,这样就有效避免了类似 Hashtable 整体同步的问题,大大提高了性能:
在这里插入图片描述
在构造的时候,Segment 的数量由所谓的 concurrentcyLevel 决定,默认是 16,也可以在相应构造函数直接指
定。注意,Java 需要它是 2 的幂数值,如果输入是类似 15 这种非幂值,会被自动调整到 16 之类 2 的幂数值。

  • ConcurrentHashMap 会获取再入锁,以保证数据一致性,Segment 本身就是基于ReentrantLock 的
    扩展实现,所以,在并发修改期间,相应 Segment 是被锁定的。
  • 在最初阶段,进行重复性的扫描,以确定相应 key 值是否已经在数组里面,进而决定是更新还是放置操
    作。重复扫描、检测冲突是ConcurrentHashMap 的常见技巧。
  • 在 ConcurrentHashMap中扩容同样存在。不过有一个明显区别,就是它进行的不是整体的扩容,而是
    单独对 Segment 进行扩容

另外一个 Map 的 size 方法同样需要关注,它的实现涉及分离锁的一个副作用。

试想,如果不进行同步,简单的计算所有 Segment 的总值,可能会因为并发 put,导致结果不准确,但是直接锁定所有 Segment 进行计算,就会变得非常昂贵。其实,分离锁也限制了 Map的初始化等操作。所以,ConcurrentHashMap 的实现是通过重试机制(RETRIES_BEFORE_LOCK,指定重试次数 2),来试图获得可靠值。如果没有监控到发生变化(通过对比 Segment.modCount),就直接返回,否则获取锁进行操作。

下面来对比一下,在 Java 8 和之后的版本中,ConcurrentHashMap 发生了哪些变化呢?

  • 总体结构上,它的内部存储变得和HashMap 结构非常相似,同样是大的桶(bucket)数组,然后内部
    也是一个个所谓的链表结构(bin),同步的粒度要更细致一些。
  • 其内部仍然有 Segment 定义,但仅仅是为了保证序列化时的兼容性而已,不再有任何结构上的用处。
  • 因为不再使用 Segment,初始化操作大大简化,修改为 lazy-load 形式,这样可以有效避免初始开销,解决了老版本很多人抱怨的这一点。
  • 数据存储利用 volatile 来保证可见性。
  • 使用 CAS 等操作,在特定场景进行无锁并发操作。
  • 使用 Unsafe、LongAdder 之类底层手段,进行极端情况的优化。

TreeMap

TreeMap是一个可以排序的子类,它是按照key的内容进行排序的。

Map<Integer,String> map=new TreeMap<Integer, String>();
        map.put(2,"is");
        map.put(1,"Java");
        map.put(3,"best");
        map.put(0,"hello");
        System.out.println(map);

在这里插入图片描述

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章