HashMap 及其类似数据结构 原理分析对比

HashMap简述

HashMap 是数组+ 链表 的数据结构。时间复杂度(理想状态)O(1)。(是数组 查找时间复杂度O(1),插入时间复杂度O(n),链表 查找时间复杂度O(n),插入时间复杂度O(1) 的 中庸选择)

  • 链表使用一个next指针维护链表结构的,它的插入和删除的效率比较高,再插入和删除时,不用挪动后面的数据。但是查找每次都得从头结点遍历,所以效率不高
  • 数组使用下标维护数据的,所以查找起来,效率会很高。插入和删除,需要移动后面的数据,效率不高。
  • 所以,在只需要查找的时候,建议使用数组,而经常需要插入和删除数据,建议使用链表

HashMap主要原理

1、put

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 onlyIfAbsent,
           boolean evict) {

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

    // 1. 若哈希表的数组tab为空,则 通过resize() 创建
    // 所以,初始化哈希表的时机 = 第1次调用put函数时,即调用resize() 初始化创建
    // 关于resize()的源码分析将在下面讲解扩容时详细分析,此处先跳过
    if ((tab = table) == null || (n = tab.length) == 0)
    n = (tab = resize()).length;

    // 2. 计算插入存储的数组索引i:根据键值key计算的hash值 得到
    // 此处的数组下标计算方式 = i = (n - 1) & hash,同JDK 1.7中的indexFor(),上面已详细描述

    // 3. 插入时,需判断是否存在Hash冲突:
    // 若不存在(即当前table[i] == null),则直接在该数组位置新建节点,插入完毕
    // 否则,代表存在Hash冲突,即当前存储位置已存在节点,则依次往下判断:a. 当前位置的key是否与需插入的key相同、b. 判断需插入的数据结构是否为红黑树 or 链表
    if ((p = tab[i = (n - 1) & hash]) == null)
    tab[i] = newNode(hash, key, value, null);  // newNode(hash, key, value, null)的源码 = new Node<>(hash, key, value, next)

else {
    Node<K,V> e; K k;

    // a. 判断 table[i]的元素的key是否与 需插入的key一样,若相同则 直接用新value 覆盖 旧value
    // 判断原则:equals()
    if (p.hash == hash &&
        ((k = p.key) == key || (key != null && key.equals(k))))
        e = p;

    // b. 继续判断:需插入的数据结构是否为红黑树 or 链表
    // 若是红黑树,则直接在树中插入 or 更新键值对
    else if (p instanceof TreeNode)
        e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); ->>分析3

    // 若是链表,则在链表中插入 or 更新键值对
    // i.  遍历table[i],判断Key是否已存在:采用equals() 对比当前遍历节点的key 与 需插入数据的key:若已存在,则直接用新value 覆盖 旧value
    // ii. 遍历完毕后仍无发现上述情况,则直接在链表尾部插入数据
    // 注:新增节点后,需判断链表长度是否>8(8 = 桶的树化阈值):若是,则把链表转换为红黑树
    
    else {
        for (int binCount = 0; ; ++binCount) {
            // 对于ii:若数组的下1个位置,表示已到表尾也没有找到key值相同节点,则新建节点 = 插入节点
            // 注:此处是从链表尾插入,与JDK 1.7不同(从链表头插入,即永远都是添加到数组的位置,原来数组位置的数据则往后移)
            if ((e = p.next) == null) {
                p.next = newNode(hash, key, value, null);

                // 插入节点后,若链表节点>数阈值,则将链表转换为红黑树
                if (binCount >= TREEIFY_THRESHOLD - 1) 
                    treeifyBin(tab, hash); // 树化操作
                break;
            }

            // 对于i
            if (e.hash == hash &&
                ((k = e.key) == key || (key != null && key.equals(k))))
                break;

            // 更新p指向下一个节点,继续遍历
            p = e;
        }
    }

    // 对i情况的后续操作:发现key已存在,直接用新value 覆盖 旧value & 返回旧value
    if (e != null) { 
        V oldValue = e.value;
        if (!onlyIfAbsent || oldValue == null)
            e.value = value;
        afterNodeAccess(e); // 替换旧值时会调用的方法(默认实现为空)
        return oldValue;
    }
}

++modCount;

// 插入成功后,判断实际存在的键值对数量size > 最大容量threshold
if (++size > threshold)
    resize();

afterNodeInsertion(evict);// 插入成功时会调用的方法(默认实现为空)
return null;

}
  • 首次table数组为null,调用resize方法,生成一个 默认16 大小的数组
  • 对 Entry(Node) key的hashCode()做hash,并计算在储蓄数组中的index
  • 存储时,判断hash冲突,如果当前index没有值(即当前table[i] == null),直接存value。
  • 否则,代表hash冲突。
    • 1、如果当前key与需存储key相同(equals),覆盖当前value。
    • 2、判断存储的数据是红黑树 或 链表
  • 若是链表,则在链表中插入 or 更新键值对
    • 遍历table[i],判断Key是否已存在:采用equals() 对比当前遍历节点的key 与 需插入数据的key:若已存在,则直接用新value 覆盖 旧value
    • 遍历完毕后仍无发现上述情况,则直接在链表尾部插入数据
    • 注:新增节点后,需判断链表长度是否>8(8 = 桶的树化阈值):若是,则把链表转换为红黑树

2、get

public V get(Object key) {
    Node<K,V> e;
    // 1. 计算需获取数据的hash值
    // 2. 通过getNode()获取所查询的数据
    // 3. 获取后,判断数据是否为空
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}

 
final Node<K,V> getNode(int hash, Object key) {
    Node<K,V>[] tab; Node<K,V> first, e; int n; K k;

    // 1. 计算存放在数组table中的位置
    if ((tab = table) != null && (n = tab.length) > 0 &&
    (first = tab[(n - 1) & hash]) != null) {

    // 4. 通过该函数,依次在数组、红黑树、链表中查找(通过equals()判断)
    // a. 先在数组中找,若存在,则直接返回
    if (first.hash == hash && // always check first node
        ((k = first.key) == key || (key != null && key.equals(k))))
        return first;

    // b. 若数组中没有,则到红黑树中寻找
    if ((e = first.next) != null) {
        // 在树中get
        if (first instanceof TreeNode)
            return ((TreeNode<K,V>)first).getTreeNode(hash, key);

        // c. 若红黑树中也没有,则通过遍历,到链表中寻找
        do {
            if (e.hash == hash &&
                ((k = e.key) == key || (key != null && key.equals(k))))
                return e;
        } while ((e = e.next) != null);
    }
}
    return null;
}
  • bucket里查找节点,命中返回
  • 如果有冲突,则通过key.equals(k)去查找对应的entry
    若为树,则在树中通过key.equals(k)查找,O(logn);
    若为链表,则在链表中通过key.equals(k)查找,O(n)。

3、resize
存储键值时,发现容量不足,需要扩容,生成一个当前 2倍 的新数组。

/**
 * 分析4:resize()
 * 该函数有2种使用情况:1.初始化哈希表 2.当前数组容量过小,需扩容
 */
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table; // 扩容前的数组(当前数组)
int oldCap = (oldTab == null) ? 0 : oldTab.length; // 扩容前的数组的容量 = 长度
int oldThr = threshold;// 扩容前的数组的阈值
int newCap, newThr = 0;

// 针对情况2:若扩容前的数组容量超过最大值,则不再扩充
if (oldCap > 0) {
    if (oldCap >= MAXIMUM_CAPACITY) {
        threshold = Integer.MAX_VALUE;
        return oldTab;
    }

    // 针对情况2:若无超过最大值,就扩充为原来的2倍
    else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
             oldCap >= DEFAULT_INITIAL_CAPACITY)
        newThr = oldThr << 1; // 通过右移扩充2倍
}

// 针对情况1:初始化哈希表(采用指定 or 默认值)
else if (oldThr > 0) // initial capacity was placed in threshold
    newCap = oldThr;

else {               // zero initial threshold signifies using defaults
    newCap = DEFAULT_INITIAL_CAPACITY;
    newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}

// 计算新的resize上限
if (newThr == 0) {
    float ft = (float)newCap * loadFactor;
    newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
              (int)ft : Integer.MAX_VALUE);
}

threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;

if (oldTab != null) {
    // 把每个bucket都移动到新的buckets中
    for (int j = 0; j < oldCap; ++j) {
        Node<K,V> e;
        if ((e = oldTab[j]) != null) {
            oldTab[j] = null;

            if (e.next == null)
                newTab[e.hash & (newCap - 1)] = e;
            else if (e instanceof TreeNode)
                ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);

            else { // 链表优化重hash的代码块
                Node<K,V> loHead = null, loTail = null;
                Node<K,V> hiHead = null, hiTail = null;
                Node<K,V> next;
                do {
                    next = e.next;
                    // 原索引
                    if ((e.hash & oldCap) == 0) {
                        if (loTail == null)
                            loHead = e;
                        else
                            loTail.next = e;
                        loTail = e;
                    }
                    // 原索引 + oldCap
                    else {
                        if (hiTail == null)
                            hiHead = e;
                        else
                            hiTail.next = e;
                        hiTail = e;
                    }
                } while ((e = next) != null);
                // 原索引放到bucket里
                if (loTail != null) {
                    loTail.next = null;
                    newTab[j] = loHead;
                }
                // 原索引+oldCap放到bucket里
                if (hiTail != null) {
                    hiTail.next = null;
                    newTab[j + oldCap] = hiHead;
                }
            }
        }
    }
}
    return newTab;
}

总结

HashMap 采用数组和链表的方式解决hash冲突,(Java 8 添加红黑树)。允许存储null。

hashmap流程图

参考:

动态扩容问题详见: HashMap扩容

扩展

LinkedHashMap

基本和HashMap实现类似,多了一个链表来维护元素插入的顺序,因此维护的效率会比HashMap略低。但是因为有链表的存在,遍历效率会高于HashMap。
get()操作后,会把当前数据移动到链表尾部。所以基于这个属性,可以实现 Lru缓存

详见:

ConCurrentHashMap

结构图

线程安全,而且采用分段锁的方式进行数据同步,因此相对于Hashtable来说,效率要高。但是因为引入了段的概念,所以ConcurrentHashMap定位一个元素的过程需要进行两次Hash操作,第一次Hash定位到Segment,第二次Hash定位到元素所在的链表的头部,因此效率会低于HashMap。不过在多线程情况下,这种性能的牺牲换取数据安全是非常值得的。因此在多线程的情况下应该首选ConcurrentHashMap。(HashMap ConCurrentHashMap 对比

详见:ConCurrentHashMap

SparseArray

private int[] mKeys;//int 类型key数组
private Object[] mValues;//value数组


public SparseArray() {
    this(10);
}

public SparseArray(int initialCapacity) {
    if (initialCapacity == 0) {
        mKeys = EmptyArray.INT;
        mValues = EmptyArray.OBJECT;
    } else {
        mValues = ArrayUtils.newUnpaddedObjectArray(initialCapacity);
        mKeys = new int[mValues.length];
    }
    mSize = 0;
}

SparseArray 如果不设置置顶容量大小,会默认初始一个 容量10 的大小内容。

内部通过 两个数组 来存储数据,一个存储key,一个存储value。key 只支持int类型,避免了装箱

put get 时,都会使用二分查找。所以在数据量大的情况下性能并不明显,将降低至少50%

扩容 当currentSize(当前存储的值的个数)小于等于4的时候,扩容至8,否则扩容至两倍的currentSize

所以用 SparseArray 代替 HashMap 最好是:

  • 数据量不大,最好在千级以内
  • key必须为int类型,这中情况下的HashMap可以用SparseArray代替

ArrayMap

public class ArrayMap<K, V> extends SimpleArrayMap<K, V> implements Map<K, V> {
MapCollections<K, V> mCollections;  


int[] mHashes;
Object[] mArray;

ArrayMap继承自SimpleArrayMap实现了Map接口。ArrayMap内部是两个数组(都为 空数组),一个存放hash值,一个存放键值对 key value。

put
首先就是判段key是否null,是null,hash值直接置为0,如果不为null,通过Obejct的hashCode()方法计算出hash值。然后通过indexfOf方法计算出index的值。indexfOf 内部则是使用二分查找。

扩容

  • 如果我们的osize(已经存储的多少value个数)大于等于两倍的BASE_SIZE(常量为4)我们就在原来osize的基础上扩容0.5倍

  • 如果我们的osize小于8(两个BASE_SIZE)并且大于4(一个BASE_SIZE),我们将数组扩容到8,否则我们将数组大小扩容到4

ArrayMap 与 HashMap 对比:

  • 插入基于二分查找,效率不如HashMap
  • 比HashMap占用内存少,更省空间, 时间换空间

选择使用:

  • 在数据量小的时候一般认为1000以下,当你的key为int的时候,使用 SparseArray 确实是一个很不错的选择,内存大概能节省30%,相比用HashMap,因为它key值不需要装箱,所以时间性能平均来看也优于HashMap,建议使用
  • ArrayMap相对于SparseArray,特点就是key值类型不受限,任何情况下都可以取代HashMap,但是通过研究和测试发现,ArrayMap的内存节省并不明显,也就在10%左右,但是时间性能确是最差的,当然了,1000以内的如果key不是int 可以选择 ArrayMap
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 218,122评论 6 505
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 93,070评论 3 395
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 164,491评论 0 354
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,636评论 1 293
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,676评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,541评论 1 305
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,292评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,211评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,655评论 1 314
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,846评论 3 336
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,965评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,684评论 5 347
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,295评论 3 329
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,894评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,012评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,126评论 3 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,914评论 2 355

推荐阅读更多精彩内容