HashMap、LinkedHashMap、TreeMap、HashTable、ConcurrentHashMap原理解析

来源:互联网 发布:怎样优化win7开机时间 编辑:程序博客网 时间:2024/05/22 13:11

一、Map家族特点收集

集合 有序性 安全性 速度 是否支持null fail-fast还是fail-safe HashMap 无序 线程不安全 快 key/value可为null fail-fast LinkedHashMap 插入/访问顺序 线程不安全 快 key/value可为null fail-fast TreeMap 有序 线程不安全 快 key/value可为null fail-fast HashTable 无序 线程安全 慢 key/value不可为null fail-safe ConcurrentHashMap 无序 线程安全 快 key/value可为null fail-safe

下面,我们从数据结构和算法两个方面一一揭晓每一种Map的实现原理。

二、HashMap

HashMap原理:HashMap使用的数据结构是数组,每次put一个元素时,先计算key的hashCode,然后根据哈希函数找到key在数组中的位置i,如果i位置没有元素,直接插入,否则采用单链表存储。
这里写图片描述
HashMap的实现原理中,关键在下面两个点:

  1. 采用数组作为数据结构
    由于选择了数组来存储数据,所以需要解决下面的2个问题:
    (1) 数组的初始容量,最大容量
    (2)如何扩容

  2. 使用了哈希算法
    在使用哈希算法时,需要解决下面的2个问题:
    (1)哈希函数
    (2)处理冲突方法

下面,我们从原理角度来分析HashMap如何解决我们上面说的几个问题。

1、数组的初始容量
HashMap中每一个元素使用内部类Entry< K,V>来存储,并且内部维护了一个Entry的数组。

static class Entry<K,V> implements Map.Entry<K,V> {    final K key;    V value;    Entry<K,V> next;    int hash;}

数组的初始容量可以通过HashMap的构造方法看出来,HashMap共提供了3个构造方法。

//默认的数组大小是16(HashMap要求数组容量必须为2的幂)。有兴趣的读者,可以考虑一下为什么默认大小是16,而且为什么一定要为2的幂?static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16//最大的数组大小是2^30。static final int MAXIMUM_CAPACITY = 1 << 30;//默认的装载因子是0.75。有兴趣的读者,可以考虑一下为什么默认的装载因子是0.75static final float DEFAULT_LOAD_FACTOR = 0.75f;//数组transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;public HashMap() {    this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);}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;    threshold = initialCapacity;    init();}

最核心的方法就是HashMap(int initialCapacity, float loadFactor)中的init()方法,但是查看init()方法时,发现只是一个空实现,那么table数组是在什么时候初始化的呢?只有一种可能,就是第一次put操作时进行初始化的。

public V put(K key, V value) {    if (table == EMPTY_TABLE) {        inflateTable(threshold);    }    //......}//初始化数组private void inflateTable(int toSize) {    // Find a power of 2 >= toSize    //HashMap要求数组容量必须为2的幂,所以需要找到大于等于初始容量的最小的2的幂    int capacity = roundUpToPowerOf2(toSize);    threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);    //数组初始化    table = new Entry[capacity];    initHashSeedAsNeeded(capacity);}

至于这个里面的装载因子loadFactor与hashSeed,在后面将数组扩容和哈希函数时会讲到。

initHashSeedAsNeeded方法用于初始化hashSeed参数,其中hashSeed用于计算key的hash值,它与key的hashCode进行按位异或运算。这个hashSeed是一个与实例相关的随机值,主要用于解决hash冲突。

2、哈希函数
前面我们讲过,HashMap的容量一定要为2的指数倍(默认是16),这是为什么呢?了解完HashMap中哈希函数的设计原理,你就清楚了。

final int hash(Object k) {    int h = hashSeed;    if (0 != h && k instanceof String) {        return sun.misc.Hashing.stringHash32((String) k);    }    h ^= k.hashCode();    h ^= (h >>> 20) ^ (h >>> 12);    return h ^ (h >>> 7) ^ (h >>> 4);}/*** Returns index for hash code h.*/static int indexFor(int h, int length) {    // assert Integer.bitCount(length) == 1 : "length must be a non-zero power of 2";    return h & (length-1);}

在哈希表容量为length的情况下,为使key能在冲突最小的情况下映射到[0,length)(注意是左闭右开)内,一般有两种做法:
(1)让length为素数,然后用hashCode(key) mod length的方法得到数组索引
(2)让length为2的指数倍,然后用hashCode(key) & (length-1)的方法得到数组索引

HashTable用的是方法1,HashMap用的是方法2。

因为我们讨论的话题是HashMap,所以下面我们重点说一下方法2。方法2不难理解,因为length为2的指数倍,所以length-1的二进制位全部为1,然后再与hashCode(key)做与运算,就能得到[0,length)内的索引。但是存在一个问题,如果hashCode(key)大于length 的值,并且低位变化不大,那么冲突就会很多。

HashMap中的hash函数就是用来解决这个问题的:
首先有一个随机的hashSeed,来降低冲突发生的几率,然后如果是字符串,使用了sun.misc.Hashing.stringHash32((String) k)来获取索引,最后,通过一系列的无符号右移操作,来把高位与地位进行异或操作,从而降低冲突发生的几率。

至于无符号右移时,为什么使用20、12、7、4,可以参考下面的文章:
https://www.cnblogs.com/killbug/p/4560000.html

3、处理哈希冲突
当不同的key经过哈希函数计算出来的索引相同时,也就是产生冲突时,HashMap使用链地址法来处理冲突。
这里写图片描述

void addEntry(int hash, K key, V value, int bucketIndex) {    if ((size >= threshold) && (null != table[bucketIndex])) {        resize(2 * table.length);        hash = (null != key) ? hash(key) : 0;        bucketIndex = indexFor(hash, table.length);    }    createEntry(hash, key, value, bucketIndex);}void createEntry(int hash, K key, V value, int bucketIndex) {    //获取当前的冲突链    Entry<K,V> e = table[bucketIndex];    //将<K,V>放到冲突链的头部。也就是说,后插入的反而在前面    table[bucketIndex] = new Entry<>(hash, key, value, e);    size++;}

4、数组扩容

HashMap扩容条件:size ≥ capacity * loadFactor
HashMap扩容算法:容量变为当前容量的2倍

举个简单的例子:初始容量为16,装载因子为0.75,当元素的个数达到12时,HashMap就会将容量扩充到32。

//因为Java中的数组是不能扩容的,所以HashMap使用一个新的数组代替原来的数组,然后所有元素重新计算索引,插入到新数组中void resize(int newCapacity) {    Entry[] oldTable = table;    int oldCapacity = oldTable.length;    if (oldCapacity == MAXIMUM_CAPACITY) {        threshold = Integer.MAX_VALUE;    return;    }    Entry[] newTable = new Entry[newCapacity];    transfer(newTable, initHashSeedAsNeeded(newCapacity));    table = newTable;    threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);}void transfer(Entry[] newTable, boolean rehash) {    int newCapacity = newTable.length;    //遍历当前的table,将里面的元素添加到新的newTable中    for (Entry<K,V> e : table) {        while(null != e) {            Entry<K,V> next = e.next;            if (rehash) {                e.hash = null == e.key ? 0 : hash(e.key);            }            int i = indexFor(e.hash, newCapacity);            e.next = newTable[i];            newTable[i] = e;            e = next;        }    }}

三、LinkedHashMap

LinkedHashMap继承自HashMap,与HashMap唯一的区别就在于LinkedHashMap是有序的,而HashMap是无序的。
在理解了HashMap的基础上,再来看LinkedHashMap的原理,就很简单的,只需要弄清楚LinkedHashMap是怎么实现有序的就可以了。
为了保证有序性,LinkedHashMap主要做了以下几方面的改造:

  1. 将Entry设计为双向循环链表
  2. 重写addEntry和createEntry方法
  3. 添加accessOrder,用于标识是按插入顺序还是访问顺序排序

1、将Entry设计为双向循环链表
这里写图片描述
LinkedHashMap.Entry继承自HashMap.Entry,并且添加了before和after两个指针,分别指向前继节点与后继节点

private static class Entry<K,V> extends HashMap.Entry<K,V> {    Entry<K,V> before, after;    Entry(int hash, K key, V value, HashMap.Entry<K,V> next) {        super(hash, key, value, next);    }    //删除一个节点时,前继节点的后继指针指向要删除节点的后继节点,后继节点的前继指针指向要删除节点的前继节点    private void remove() {        before.after = after;        after.before = before;    }    //在existingEntry节点前插入节点    private void addBefore(Entry<K,V> existingEntry) {        after  = existingEntry;        before = existingEntry.before;        before.after = this;        after.before = this;    }    //如果是按照插入的顺序排序,什么都不干    void recordAccess(HashMap<K,V> m) {        LinkedHashMap<K,V> lm = (LinkedHashMap<K,V>)m;        if (lm.accessOrder) {            lm.modCount++;            remove();            addBefore(lm.header);        }    }    void recordRemoval(HashMap<K,V> m) {        remove();    }}

2、重写addEntry和createEntry方法
LinkedHashMap的put方法直接继承自HashMap,下面是put方法的核心流程:

put->addEntry ->createEntry

void addEntry(int hash, K key, V value, int bucketIndex) {    super.addEntry(hash, key, value, bucketIndex);    Entry<K,V> eldest = header.after;    if (removeEldestEntry(eldest)) {        removeEntryForKey(eldest.key);    }}void createEntry(int hash, K key, V value, int bucketIndex) {    //创建新的Entry,并将其插入到数组对应槽的单链表的头结点处,这点与HashMap中相同      HashMap.Entry<K,V> old = table[bucketIndex];    Entry<K,V> e = new Entry<>(hash, key, value, old);    table[bucketIndex] = e;    //每次插入Entry时,都将其移到双向链表的尾部,这便会按照Entry插入LinkedHashMap的先后顺序来迭代元素,      //同时,新put进来的Entry是最近访问的Entry,把其放在链表末尾 ,符合LRU算法的实现      e.addBefore(header);    size++;}

3、添加accessOrder,用于标识是按插入顺序还是访问顺序排序

LinkedHashMap提供了下面两种排序模式:

  1. accessOrder为false,表示按插入顺序排序。
  2. accessOrder为true,表示按访问顺序排序。

从构造函数可以看出,LinkedHashMap默认按照插入顺序排序。

public LinkedHashMap(int initialCapacity, float loadFactor) {    super(initialCapacity, loadFactor);    accessOrder = false;}public LinkedHashMap(int initialCapacity) {    super(initialCapacity);    accessOrder = false;}//使用该构造函数可以自定义排序模式public LinkedHashMap(int initialCapacity,float            loadFactor,boolean accessOrder) {    super(initialCapacity, loadFactor);    this.accessOrder = accessOrder;}

按照插入顺序排序比较好理解,唯一需要注意的就是同一个key多次进行put操作时,顺序会不会改变。
查看HashMap.put()的源码,可以看出,在插入的key已存在的情况下,会调用Entry.recordAccess()方法,在插入的key不存在的情况下,要调用addEntry插入新的Entry 。Entry.recordAccess()方法在accessOrder =false时,什么都不干,所以同一个key多次进行put操作时,顺序是不会改变的。

public V put(K key, V value) {    if (table == EMPTY_TABLE) {        inflateTable(threshold);    }    if (key == null)        return putForNullKey(value);    int hash = hash(key);    int i = indexFor(hash, table.length);    for (Entry<K,V> e = table[i]; e != null; e = e.next) {        Object k;        if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {            V oldValue = e.value;            e.value = value;            //在插入的key已存在的情况下,调用Entry.recordAccess方法            e.recordAccess(this);            return oldValue;        }    }    modCount++;    addEntry(hash, key, value, i);    return null;}

当accessOrder =true时,也就是按照访问顺序排序时,LinkedHashMap采用的是LRU(Least recently used,最近最少使用)算法。
与LRU算法的常规实现不同的是,如果某个key最近被访问过,那么LinkedHashMap认为它后面访问的几率更低。所以,当某个key被访问后,会将它移动到双向循环链表的尾部。
主要体现在下面两个操作:

  1. get操作
  2. put操作
public V get(Object key) {     Entry<K,V> e = (Entry<K,V>)getEntry(key);     if (e == null)         return null;     //如果链表中元素的排序规则是按照访问的先后顺序排序的话,则将e移到链表的末尾处。     e.recordAccess(this);     return e.value; }//put操作体现在createEntry方法中,每次将新加入的节点放在双向链表尾部//……
阅读全文
0 0
原创粉丝点击