从零开始学Java_第三篇 Map 的实现原理

 

Map 不属于 Collection 的范畴,它作为 Java 集合框架之外的一部分,在日常开发中使用的也是非常频繁,map 中以键值对的形式储存数据,根据键查找值的效率非常快。

开发中最常用的就是 HashMap 和 ConcurrentHashMap 了,那它们的底层到底是如何实现的呢?带着这个问题我们来开始今天的分享。

 

首先我们看下这张 Map 的类图:

 

从类图中可以看出 HashMap、TreeMap 等都继承了 AbstractMap,而 LinkedHashMap 又继承了 HashMap。

 

那么 HashMap、TreeMap、LinkedHashMap 它们之间有什么区别呢?

 

  • 相同点

    • 都是使用哈希表实现,以键值对形式存储数据

  • 不同点

    • HashMap 存储数据是无序的,进行 put 或 get 可以达到常数时间的性能

    • LinkedHashMap 可以认为是 HashMap + LinkedList ,按 key 的 put 顺序排序

    • TreeMap 按 key 的自然顺序排序或按创建时实现的 Comparator 接口排序,它是基于红黑树实现的 Map ,操作时间复杂度为O(log(n))

 

HashMap 又是如何实现的呢?

 

下面主要从三个方面来分析下 HashMap 的源码:

 

  • HashMap 内部结构基本点分析

 

HashMap 的内部结构可以看成是数组+链表的组合。

数组被分为一个个桶,在桶中通过 key 计算得出哈希值决定键值对在这个数组的位置,哈希值相同的键值对,则以链表形式存储,如下图:

 

 

需要注意的是如果链表大小超过阈值(TREEIFY_THRESHOLD ,默认是 8),链表会改造成树形结构。

 

  • 容量和负载因子

 

 HashMap 提供了三个构造函数:

      HashMap():构造一个具有默认初始容量 (16) 和默认加载因子 (0.75) 的空 HashMap。

      HashMap(int initialCapacity):构造一个带指定初始容量和默认加载因子 (0.75) 的空 HashMap。

      HashMap(int initialCapacity, float loadFactor):构造一个带指定初始容量和加载因子的空 HashMap。

 

在这里提到了两个参数:初始容量,加载因子,这两个参数是影响 HashMap 性能的重要参数。

 

其中容量表示哈希表中桶的数量,初始容量是创建哈希表时的容量,加载因子是哈希表在其容量自动增加之前可以达到多满的一种尺度,它衡量的是一个散列表的空间的使用程度,负载因子越大表示散列表的装填程度越高,反之愈小。

 

对于使用链表法的散列表来说,查找一个元素的平均时间是 O(1+a),因此如果负载因子越大,对空间的利用更充分,然而后果是查找效率的降低;如果负载因子太小,那么散列表的数据将过于稀疏,对空间造成严重浪费。

 

系统默认负载因子为 0.75,一般情况下我们是无需修改的。

 

HashMap 并没有在一开始就初始化好,可能是按照 lazy-load 懒加载的原则,用的时候才去创建。那看看 put 方法:只有一个 putVal 调用,代码如下:

 

public V put(K key, V value) {

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

}

 

final V putVal(int hash, K key, V value, boolean onlyIfAbent,boolean evit) {

    Node<K,V>[] tab; Node<K,V> p; int , i;

    if ((tab = table) == null || (n = tab.length) = 0)

        n = (tab = resize()).length;

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

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

    else {

        // ...

        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for first

           treeifyBin(tab, hash);

        //  ...

     }

}

 

从代码可以看出:

  1. 如果 table 是 null,resize 方法会负责初始化它,这从 tab=resize()可以看出

  2. 放置新的键值对时可能扩容

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

    i = (n - 1) & hash

 

其实哈希值并不是 key 本身的 hashcode,而是调用了 hashmap 内部的 hash 方法。

 

static final int hash(Object kye) {

    int h;

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

}

 

为什么这里需要将高位数据移位到低位进行异或运算呢(h >>>16)?

 

这是因为有些数据计算出的哈希值差异主要在高位,而 HashMap 里的哈希寻址是忽略容量以上的高位的,那么这种处理就可以有效避免类似情况下的哈希碰撞。

 

 resize 方法不仅负责创建初始存储表格,在容量不足时还负责 map 的扩容。代码如下:

 

final Node<K,V>[] resize() {

    // ...

    else if ((newCap = oldCap << 1) < MAXIMUM_CAPACIY &&

                oldCap >= DEFAULT_INITIAL_CAPAITY)

        newThr = oldThr << 1; // double there

       // ...

    else if (oldThr > 0) // initial capacity was placed in threshold

        newCap = oldThr;

    else { 

        // zero initial threshold signifies using defaultsfults

        newCap = DEFAULT_INITIAL_CAPAITY;

        newThr = (int)(DEFAULT_LOAD_ATOR* DEFAULT_INITIAL_CAPACITY;

    }

    if (newThr ==0) {

        float ft = (float)newCap * loadFator;

        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?(int)ft : Integer.MAX_VALUE);

    }

    threshold = neThr;

    Node<K,V>[] newTab = (Node<K,V>[])new Node[newap];

    table = n;

    // 移动到新的数组结构 e 数组结构

   }

 

从代码可以看出:

  1. 门限值(newThr )等于负载因子*容量,如果创建 HashMap 没有指定他们,就是默认值 16*0.75

  2. 门限值通常以倍数进行调整,(newThr = oldThr << 1)

  3. 扩容后将老数组数据放到新数组中 这是扩容的主要开销

 

  • 树化

 

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) {

        // 树化改造逻辑

    }

}

 

上面是精简的 treeifyBin 示意,综合 treeifyBin() 和 putVal()当 bin(链表)的数量大于TREEIFY_THRESHOLD:

  • 如果容量小于 MIN_TREEIFY_CAPACITY , 扩容

  • 如果容量大于 MIN_TREEIFY_CAPACITY,树化

 

那为什么要树化呢?

如果一个对象 hash 值与 Map 中存储的对象 hash 值冲突,那这个新对象就会被放在同一个桶里形成一个链表,当多个 hash 冲突的对象都放进一个桶时,形成一个大链表时链表查询是很慢的,这样会严重影响存取效率。

 

HashMap 的底层原理我们主要从 内部结构、容量和负载因子、树化 三个方面进行了分析。

 

接着我们讨论下ConcurrentHashMap的底层原理

 

 ConcurrentHashMap 是如何实现的呢?

 

 ConcurrentHashMap 的实现随着技术的更新迭代,也是在不断改进的。

 

  • 早期的 ConcurrentHashMap

 

此时 ConcurrentHashMap 的三大结构:整个 Hash表、Segment、HashEntry。

 

    • Segment

      Segment 继承了 ReetrantLock,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问

 

    • HashEntry 

       HashEntry 内使用 volatile 保证可见性,如用于统计当前 Segment 大小的 count 字段和用于存储值的 HashEntry 的 value 字段。所以ConcurrentHashMap 的 get 方法是不需要加锁的。

      之所以不会读到过期的值 因为 volatile 字段的写入操作有限读操作,这是 Java 内存模型的 happen before原则,即使两个线程同时修改和获取 volatile 变量,get 到的也是新值

 

实现主要基于锁分段技术,将数据分成一段一段的存储,给每段数据配一把锁,内部进行分段 就是 Segment ,Segment 里面是 HashEntry 数组,与HashMap 类似,哈希相同的条目也是以链表形式存放的。结构如下图:

 

ConcurrentHashMap 当然也有扩容的问题,不过是段内扩容(段内元素超过该段对应Entry数组长度的 75% 触发扩容,不会对整个 Map 进行扩容),插入数据前前检测需不需要扩容,有效避免无效扩容。

 

由于 ConcurrentHashMap 在并发时只锁定段,所以效率要提高很多。

 

  • JDK7 的 ConcurrentHashMap

 

JDK7 做了一些优化,对于 put 操作,首先通过二次hash避免 hash 冲突,然后以 UnSafe 调用方式获取相应的 Segment,然后进行线程安全的 put 操作。

 

Unsafe 类的作用:Java 不能直接访问操作系统底层,而是通过本地方法来访问。Unsafe 类提供了硬件级别的原子操作。

 

put 方法代码如下:

 

public V put(K key, V value) {

        Segment<K,V> s;

        if (value == null)

            throw new NullPointerException();

        // 二次哈希,以保证数据的分散性,避免哈希冲突

        int hash = hash(key.hashCode());

        int j = (hash >>> segmentShift) & segmentMask;

        if ((s = (Segment<K,V>)UNSAFE.getObject          //nonvolatile; recheck

             (segments, (j << SSHIFT) + SBASE)) == null) //  in ensureSegment

            s = ensureSegment(j);

        return s.put(key, hash, value, false);

    }

 

//  s.put(key, hash, value, false) 方法逻辑如下

 

final V put(K key, int hash, V value, boolean onlyIfAbsent) {

            // scanAndLockForPut 会去查找是否有 key 相同 Node

            // 无论如何,确保获取锁

            HashEntry<K,V> node = tryLock() ? null :

                scanAndLockForPut(key, hash, value);

            V oldValue;

            try {

                HashEntry<K,V>[] tab = table;

                int index = (tab.length - 1) & hash;

                HashEntry<K,V> first = entryAt(tab, index);

                for (HashEntry<K,V> e = first;;) {

                    if (e != null) {

                        K k;

                        // 更新已有 value...

                    }

                    else {

                        // 放置 HashEntry 到特定位置,如果超过阈值,进行 rehash

                        // ...

                    }

                }

            } finally {

                unlock();

            }

            return oldValue;

        }

 

从代码可以看出在 JDK7 中,ConcurrentHashMap 进行并发写操作时:

 

1.先获取锁,保证数据一致性,在并发修改时 Segment 锁定

2.最初阶段进行重复性扫描,确定相应的 key 值是否已经在数组里,进而决定是更新还是放置

3.concurrent 也有扩容问题,扩容只针对 Segment

 

有些方法需要跨段,比如 size() 和 containsValue() ,它们可能需要锁定整个表而而不仅仅是某个段,这需要按顺序锁定所有段,操作完毕后,又按顺序释放所有段的锁

 

  • JDK8 的 ConcurrentHashMap

 

JDK8 的优化如下:

 

  • 总体结构上内部存储更像 HashMap,同样是大的桶数组,内部也是一个个链表

  • 内部仍然有 Segment,但仅仅为了保证序列化的兼容,没有实质意义

  • 由于不再使用 Segment,初始化操作大大简化,初始化修改为 lazy-load 模式,避免初始开销

  • 数据存储利用 volatile 来保证可见性

  • 使用 CAS 等操作,在特定场景进行无锁并发

  • 使用 UnSafe ,LongAddr 等底层手段

 

put 方法的初始化操作在 initTable 方法里,这是典型 CAS 场景。利用 CAS 的 sizeCTl 作为互斥手段,发现竞争就 spin(自旋),等待条件回复,否则利用 CAS 设置排他标志。如果成功则进行初始化;否则重试。

 

 

当 bin 链表为空,利用 CAS 去进行无锁的安全操作。

 

当有同步逻辑时使用的 syncronized 而不是 ReentrantLock 因为在 JDK8 synchronized 已经被不断优化,可以不再过分担心性能差异。另外,相比于 ReentrantLock,它可以减少内存消耗。

 

代码如下:(sizeCTl 是用于多线程之间同步的一个互斥变量)

 

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) {

            // 利用 CAS 去进行无锁线程安全操作,如果 bin 是空的

            if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value)))

                break;

        }

        else if ((fh = f.hash) == MOVED)

            tab = helpTransfer(tab, f);

        else if (onlyIfAbsent // 不加锁,进行检查

                 && fh == hash

                 && ((fk = f.key) == key || (fk != null && key.equals(fk)))

                 && (fv = f.val) != null)

            return fv;

        else {

            V oldVal = null;

            synchronized (f) {

                   // 细粒度的同步修改操作...

                }

            }

            // Bin 超过阈值,进行树化

            if (binCount != 0) {

                if (binCount >= TREEIFY_THRESHOLD)

                    treeifyBin(tab, i);

                if (oldVal != null)

                    return oldVal;

                break;

            }

        }

    }

    addCount(1L, binCount);

    return null;

}

 

private final Node<K,V>[] initTable() {

    Node<K,V>[] tab; int sc;

    while ((tab = table) == null || tab.length == 0) {

        // 如果发现冲突,进行 spin 等待

        if ((sc = sizeCtl) < 0)

            Thread.yield();

        // CAS 成功返回 true,则进入真正的初始化逻辑

        else if (U.compareAndSetInt(this, SIZECTL, sc, -1)) {

            try {

                if ((tab = table) == null || tab.length == 0) {

                    int n = (sc > 0) ? sc : DEFAULT_CAPACITY;

                    @SuppressWarnings("unchecked")

                    Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];

                    table = tab = nt;

                    sc = n - (n >>> 2);

                }

            } finally {

                sizeCtl = sc;

            }

            break;

        }

    }

    return tab;

}

 

以上便是 ConcurrentHashMap 的底层原理,以及演变过程。 

 

今天我们分享了 HashMap 和 ConcurrentHashMap 的底层实现逻辑,可能有很多细节还是没有讨论到,以后还要用心去研究。

 

在日常开发中我们应当做到知其然也要知其所以然,这样才能举一反三,更好的吸收知识,更快的让自己成长起来。

 

以后的章节,我们开始并发知识的分享,敬请期待。

 

关注一下,我写的就更来劲儿啦 ~

 

 

posted @ 2019-03-06 23:27  大数据江湖  阅读(414)  评论(0编辑  收藏  举报