Java基础——常用Map的实现细节

Java基础——Map

HashMap

  1. 数据结构:

    数组 + 单链表

    transient Entry[] table; // 数组
    static class Entry<K,V> implements Map.Entry<K,V> {
        final K key;
        V value;
        Entry<K,V> next;// 单链表,存储hash冲突的对象
        final int hash;
    
  2. hash桶的计算:

    首先把hash桶的个数适配到2的n次方

    int capacity = 1;
    while (capacity < initialCapacity)
        capacity <<= 1;//把容量适配到2^n,方便后面的
    
    this.loadFactor = loadFactor;
    threshold = (int)(capacity * loadFactor);
    table = new Entry[capacity];
    

    hash桶的查找通过hashcode与(桶个数-1)进行按位与操作,相当于取模,但是效率要高。

    static int indexFor(int h, int length) {
        return h & (length-1);
    }
    

    为什么要把hashmap的容量适配到2的n次方呢?因为2^n-1正好各个位都是1,这样在按位与操作时其结果完全取决于hashcode,只要hashcode算法得当,就可以使得hash桶的数据分布比较均匀。如果容量不是2的n次方的话,就会出现0的位,会导致进行与操作后有些桶就一直放不进数据的情况。

    旋转hash:在原有hashcode的基础上再hash一次,充分利用高位进行计算,减少因低位相同的情况导致的hash碰撞。

    static int hash(int h) {
        h ^= (h >>> 20) ^ (h >>> 12);
        return h ^ (h >>> 7) ^ (h >>> 4);
    }
    
  3. 扩容:

    当数据量达到阀值(capacity * loadFactor)时,为了减少数据量增加带来的hash碰撞,需要对HashMap进行扩容。需要把所有的entry移动一次,代价较大,所以在可以预估容量的时候尽量在初始化时指定容量。

    void transfer(Entry[] newTable) {
        Entry[] src = table;
        int newCapacity = newTable.length;
        for (int j = 0; j < src.length; j++) {
            Entry<K,V> e = src[j];
            if (e != null) {
                src[j] = null;
                do {
                    Entry<K,V> next = e.next;
                    int i = indexFor(e.hash, newCapacity);
                    e.next = newTable[i];
                    newTable[i] = e;
                    e = next;
                } while (e != null);
            }
        }
    }
    
  4. fail-fast:

    HashMap是非线程安全的集合,在进行HashMap遍历(HashIterator)操作时,如果map有修改操作,都会增加modCount的值,通过modCount与expectModCount进行比较,如果两者不相等,立即抛出ConcurrentModificationException。

LinkedHashMap

  1. 数据结构:

    数组 + 单链表 + 双向链表

    LinkedHashMap是在HashMap的基础上添加了一个双向链表结构,来按一定的顺序维护里面的所有entry。

    Entry<K,V> before, after;//双向链表结构
    header = new Entry<K,V>(-1, null, null, null);
    header.before = header.after = header;
    
  2. 顺序性:

    提供两种顺序方式来维护entry:

    按插入有序:Insertion-Ordered,所有entry按插入的顺序排序,读取的时候总是从最先插入的那个entry开始读取。

    按访问有序:Access-Ordered(所有entry从least-recently到most-recently再到header排列)。entry每次被访问都要调整它的顺序,重新放到header结点前面,这样一直不被访问的entry就离header越来越远。

    这是怎么做到的呢?LinkedHashMap的添加结点操作都是addBefore,而且每次都是在header结点之前进行插入(其实这里面的head结点是个尾结点)离尾结点最远的就是最老的结点(header.after指向),这是个逆向链,所以遍历的时候从header.after开始,就能取到按插入有序的数据。

    private void addBefore(Entry<K,V> existingEntry) {        
        after  = existingEntry;
        before = existingEntry.before;
        before.after = this;
        after.before = this;
    }
    

    遍历操作,从header.after开始遍历。

    Entry<K,V> nextEntry    = header.after;
    Entry<K,V> nextEntry() {
        if (modCount != expectedModCount)
            throw new ConcurrentModificationException();
        if (nextEntry == header)
            throw new NoSuchElementException();
    
        Entry<K,V> e = lastReturned = nextEntry;
        nextEntry = e.after;
        return e;
    }
    
  3. LRU

    LinkedHashMap天然支持LRU操作,即Access-Ordered,默认不开启。

    protected boolean removeEldestEntry(Map.Entry eldest) {
        return false;// 默认是返回false,我们可以实现这个方法来支持LRU
    }
    
  4. 其他

    非线程安全,fast-fail(同hashmap)

ConcurrentHashMap

  1. ConcurrentHashMap采用了锁分离的技术来实现确保线程安全的情况下达到较好的性能。它把整个hash table分成好多个小的hash table(即Segment),每个Segment都有自己的锁来保证线程安全,这样就使得各个Segment都可以独立地进行管理,而不需要争用锁。

  2. 两个重要的结构:HashEntry和Segment

    HashEntry中,value被申明为volatile,这样保证了value的可见性,并发访问时不会出现脏数据。next被申请为final,保证了链表的中间和结尾部分都不会改变,进行读操作时就不需要加锁,这样可以提高并发性。

    final K key;
    final int hash;
    volatile V value;
    final HashEntry<K,V> next;
    
    // 下面这句是put操作的行为,也就是每次put都是往头节点前面插入新节点,不影响原来的链表结构。
    tab[index] = new HashEntry<K,V>(key, hash, first, value);
    

    Segment继承了ReentrantLock,天生具有锁的功能,所以在put或remove操作时可以直接加锁使用。

  3. 扩容(rehash)操作

    如果原hash桶的链表里的所有结点rehash值都一样,直接把链表连接到新桶上即可;

    否则就找到链表尾部相同rehash值的子链表,直接连接到新桶上(代码片段一),这样保证rehash到同一个桶的多个节点不会出现链接顺序反转的情况,也避免了像HashMap那样在高并发下rehash出现死循环的现象(http://coolshell.cn/articles/9606.html)。

    最后把当前子链表前面的那部分节点正常计算rehash并添加到新桶的位置(代码片段二)。

    // 代码片段一
    // Reuse trailing consecutive sequence at same slot
    HashEntry<K,V> lastRun = e;
    int lastIdx = idx;
    for (HashEntry<K,V> last = next;
        last != null;
        last = last.next) {
        int k = last.hash & sizeMask;
        if (k != lastIdx) {
            lastIdx = k;
            lastRun = last;
        }
    }
    newTable[lastIdx] = lastRun;
    

这里解释一下上面这行代码newTable[lastIdx] = lastRun, 刚开始在怀疑直接给新桶赋值的话会不会被后面的entry给覆盖掉?仔细想想,完全没必要担心这个。举个例子,segment(小hash表)容量从32扩充到64的情况,也就是容量从25=100000(掩码是:011111)扩充到26=1000000(掩码是:[1]11111)这个[]里的1就是扩充后新增的位,可以想象,在原容量下的entry,大部分都不会rehash到新桶里,只有[]指示的位是1的情况才会rehash到新桶里面,所以rehash操作移动链表上一半的元素到新桶里。另外,原容量下不同桶里面的元素,rehash后也不会出现在相同的桶里面,其位置还是取决于非[]指示的位置,跟原容量下的一样。所以上面这个操作可以直接链接过去,不必担心重复被覆盖的情况。

    // 代码片段二
    // Clone all remaining nodes
    for (HashEntry<K,V> p = e; p != lastRun; p = p.next)        {
        int k = p.hash & sizeMask;
        HashEntry<K,V> n = newTable[k];
        newTable[k] = new HashEntry<K,V>(p.key, p.hash, n, p.value);
    }
  1. size()操作

    size操作会先尝试两次不加锁的情况下计算所有segment的size总数,如果两次计算的结果相等,说明size是正确的,直接返回这个结果。如果不相等,则所有segment加锁做一次计算。

推荐阅读:

Java基础——同步与锁

MySQL使用与优化总结

你可能感兴趣的:(java,基础,HashMap,LinkedHashMap)