热门标签 | HotTags
当前位置:  开发笔记 > 编程语言 > 正文

当我们在聊HashMap

本文出自:https:blog.csdn.netDT235201314articledetails80452010一丶概述最近研究了下HashMap(Android开发确
本文出自:https://blog.csdn.net/DT235201314/article/details/80452010 一丶概述

最近研究了下HashMap(Android开发确实用的少),不难理解为什么面试经常问HashMap,太有意思了。

二丶目录概述图


三丶正文

1.HashMap数据结构

先看两张图:



这里Entry和Node是一个东西,jdk1.8给Entry换了个马甲改为Node

HashMap数据结构可以看做是:数组+单链表+红黑树(jdk1.8新增),数组存储链表头。

2.实现思路

上面说了HashMap数据结构,下面就是说说怎么增删改查存数据

几个概念:

K:key用于计算hash值,可用于查找,不可相同,相同时替换

V: 值,我们要的东西

Entry next ,链表上的值,next指向下一个,没有时指向null

hashcode:特定算法分配Node在数组中的index位置,可以相同

hash:K.hash得到的值。

扩容:跟ArrayList一样,HashMap的扩容是当size大于阀值 *2,

阀值:size(当前大小)*loadFactor(负载因子,默认0.75),过小浪费空间,过大影响查找速度

rehash:扩容后重新计算hash值分配占位

add添加值:

1.根据hashcode分配数组上的占位;

2.碰撞:分配位置相同,存入链表头后(1.7是存表头);

3.链表长度大于8转为红黑树 提升查找速度;

4.扩容,扩容后大小 x2,重新分配占位

put方法流程图(图片移到上面来,先看图方便理解):



3.JDK1.7源码分析

对应上面结构,源码实现

//HashMap里的数组
transient Entry, V>[] table = (Entry, V>[]) EMPTY_TABLE;
//Entry对象,存key、value、hash值以及下一个节点
static class Entry<K, V> implements Map.Entry<K, V> {
    final K key;
    V value;
    Entry<K, V> next;
    int hash;
}
//默认数组大小,二进制1左移4位为16
static final int DEFAULT_INITIAL_CAPACITY = 1 <<4//负载因子默认值
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//当前存的键值对数量
transient int size;
//阀值 = 数组大小 * 负载因子
int threshold;
//负载因子变量
final float loadFactor;

//默认new HashMap数组大小16,负载因子0.75
public HashMap() {
    this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
}

public HashMap( int initialCapacity){
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
//可以指定数组大小和负载因子
public HashMap( int initialCapacity, float loadFactor){
    //省略一些逻辑判断
    this.loadFactor = loadFactor;
    threshold = initialCapacity;
    //空方法
    init();
}

再看看put方法(本文只说put方法)

public V put(K key, V value) {
    //数组为空时创建数组
    if (table == EMPTY_TABLE) {
        inflateTable(threshold);
    }
    //key为空单独对待
    if (key == null)
        return putForNullKey(value);
    //①根据key计算hash值
    int hash = hash(key);
    //②根据hash值和当前数组的长度计算在数组中的索引
    int i = indexFor(hash, table.length);
    //遍历整条链表
    for (Entry,V> e = table[i]; e != null; e = e.next) {
        Object k;
        //③情况1.hash值和key值都相同的情况,替换之前的值
        if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
            V oldValue = e.value;
            e.value = value;
            e.recordAccess(this);
            //返回被替换的值
            return oldValue;
        }
    }

    modCount++;
    //③情况2.坑位没人,直接存值或发生hash碰撞都走这
    addEntry(hash, key, value, i);
    return null;
}
先看上面key为空的情况,执行 putForNullKey() 方法单独处理,会把该键值对放在index0,null的hash值也为“0”,所以HashMap中是允许key为空的情况。再看下主流程:
步骤①.根据键值算出hash值 — > hash(key)
步骤②.根据hash值和当前数组的长度计算在数组中的索引 — > indexFor(hash, table.length)

static int indexFor(int h, int length) {
    //hash值和数组长度-1按位与操作,听着费劲?其实相当于h%length;取余数(取模运算)
    //如:h = 17,length = 16;那么算出就是1
    //&运算的效率比%要高
    return h & (length-1);
}


步骤③情况1.hash值和key值都相同,替换原来的值,并将被替换的值返回。

步骤③情况2.坑位没人或发生hash碰撞 — > addEntry(hash, key, value, i)
void addEntry(int hash, K key, V value, int bucketIndex) {
    //当前hashmap中的键值对数量超过阀值
    if ((size >= threshold) && (null != table[bucketIndex])) {
        //扩容为原来的2倍
        resize(2 * table.length);
        hash = (null != key) ? hash(key) : 0;
        //计算在新表中的索引
        bucketIndex = indexFor(hash, table.length);
    }
    //创建节点
    createEntry(hash, key, value, bucketIndex);
}

如果put的时候超过阀值,会调用 resize() 方法将数组大小扩大为原来的2倍,并且根据新表的长度计算在新表中的索引(如之前17%16 =1,现在17%32=17),看下resize方法

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
    table = newTable;
    //步骤④修改阀值
    threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
上面的重点是步骤②,看下它具体的转移操作
void transfer(Entry[] newTable, boolean rehash) {
    //获取新数组的长度
    int newCapacity = newTable.length;
    //遍历旧数组中的键值对
    for (Entry,V> e : table) {
        while(null != e) {
            Entry,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;
        }
    }
}
这段for循环的遍历会使得转移前后键值对的顺序颠倒(Java7和Java8的区别),画个图就清楚了


最后再看下创建节点的方法

void createEntry(int hash, K key, V value, int bucketIndex) {
    Entry,V> e = table[bucketIndex];
    table[bucketIndex] = new Entry<>(hash, key, value, e);
    size++;
}
创建节点时,如果找到的这个坑里面没有存值,那么直接把值存进去就行了,然后size++;如果是碰撞的情况,


put流程图(见上图)

4.JDK1.8源码

换马甲

//定义长度超过8的链表转化成红黑树
static final int TREEIFY_THRESHOLD = 8;
//换了个马甲还是认识你!!!
static class Node<K, V> implements Map.Entry<K, V> {
    final int hash;
    final K key;
    V value;
    Node<K, V> next;
}
put源码

public V put(K key, V value) {
    //根据key计算hash值
    return putVal(hash(key), key, value, false, true);
}

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
    Node,V>[] tab; Node,V> p; int n, i;
    //步骤1.数组为空或数组长度为0,则扩容(咦,看到不一样咯)
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    //步骤2.根据hash值和数组长度计算在数组中的位置
    //如果"坑"里没人,直接创建Node并存值
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {
        Node,V> e; K k;
        //步骤3."坑"里有人,且hash值和key值都相等,先获取引用,后面会用来替换值
        if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
            //步骤4.该链是红黑树
        else if (p instanceof TreeNode)
            e = ((TreeNode,V>)p).putTreeVal(this, tab, hash, key, value);
            //步骤5.该链是链表
        else {
            for (int binCount = 0; ; ++binCount) {
                if ((e = p.next) == null) {
                    //步骤5.1注意这个地方跟Java7不一样,是插在链表尾部!!!
                    p.next = newNode(hash, key, value, null);
                    //链表长度超过8,转化成红黑树
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
                //步骤5.2链表中已存在且hash值和key值都相等,先获取引用,后面用来替换值
                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;
    //步骤6.键值对数量超过阀值,扩容
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}
对比和Java7的区别,Java8一视同仁,管你key为不为空(null,hashcode为0放在index0)的统一处理,多了一步链表长度的判断以及转红黑树的操作,并且比较重要的一点,新增Node是插在尾部而不是头部。当然上面的主角还是扩容resize操作

final Node,V>[] resize() {
    //旧数组的引用
    Node,V>[] oldTab = table;
    //旧数组长度
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    //旧数组阀值
    int oldThr = threshold;
    //新数组长度、新阀值
    int newCap, newThr = 0;
    if (oldCap > 0) {
        //极端情况,旧数组爆满了
        if (oldCap >= MAXIMUM_CAPACITY) {
            //阀值改成最大,放弃治疗直接返回旧数组
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        //扩容咯,这里采用左移运算左移1位,也就是旧数组*2
        else if ((newCap = oldCap <<1) = DEFAULT_INITIAL_CAPACITY)
            //同样新阀值也是旧阀值*2
            newThr = oldThr <<1; // double threshold
    }
    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);
    }
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        newThr = (newCap float)MAXIMUM_CAPACITY ?
                (int)ft : Integer.MAX_VALUE);
    }
    //更新阀值
    threshold = newThr;
    @SuppressWarnings({"rawtypes","unchecked"})
    //创建新数组
            Node,V>[] newTab = (Node,V>[])new Node[newCap];
    table = newTab;
    if (oldTab != null) {
        for (int j = 0; j ; ++j) {
            Node,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,V>)e).split(this, newTab, j, oldCap);
                    //链表长度大于1,小于8的情况,下面高能,单独拿出来分析
                else { // preserve order
                    Node,V> loHead = null, loTail = null;
                    Node,V> hiHead = null, hiTail = null;
                    Node,V> next;
                    do {
                        next = e.next;
                        if ((e.hash & oldCap) == 0) {
                            if (loTail == null)
                                loHead = e;
                            else
                                loTail.next = e;
                            loTail = e;
                        }
                        else {
                            if (hiTail == null)
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}
下面我们讲解下JDK1.8做了哪些优化。
经过观测可以发现,我们使用的是2次幂的扩展(指长度扩为原来2倍),所以,元素的位置要么是在原位置,要么是在原位置再移动2次幂的位置。看下图可以明白这句话的意思,n为table的长度,图(a)表示扩容前的key1(5)和key2(21)两种key确定索引位置的示例,图(b)表示扩容后key1和key2两种key确定索引位置的示例,其中hash1是key1对应的哈希与高位运算结果。


图a中key1(5)和key(21)计算出来的都是5,元素在重新计算hash之后,因为n变为2倍,那么n-1的mask范围在高位多1bit(红色),因此新的index就会发生这样的变化:


图b中计算后key1(5)的位置还是5,而key2(21)已经变成了21,因此,我们在扩充HashMap的时候,不需要像JDK1.7的实现那样重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引+oldCap”。

回来看下源码

else { // preserve order
     //定义两条链
     //原来的hash值新增的bit为0的链,头部和尾部
     Node,V> loHead = null, loTail = null;
     //原来的hash值新增的bit为1的链,头部和尾部
     Node,V> hiHead = null, hiTail = null;
     Node,V> next;
     //循环遍历出链条链
     do {
         next = e.next;
         if ((e.hash & oldCap) == 0) {
             if (loTail == null)
                 loHead = e;
             else
                 loTail.next = e;
             loTail = e;
         }
         else {
             if (hiTail == null)
                 hiHead = e;
             else
                 hiTail.next = e;
             hiTail = e;
         }
     } while ((e = next) != null);
     //扩容前后位置不变的链
     if (loTail != null) {
         loTail.next = null;
         newTab[j] = loHead;
     }
     //扩容后位置加上原数组长度的链
     if (hiTail != null) {
         hiTail.next = null;
         newTab[j + oldCap] = hiHead;
     }
 }
假设一个hash算法刚好算出来的的存储是这样的,在存第13个元素时要扩容


那么流程应该是这样的(只关注5号坑键值对的情况)




两条链找出来后,最后转移一波,大功告成

//扩容前后位置不变的链
if (loTail != null) {
    loTail.next = null;
    newTab[j] = loHead;
}
//扩容后位置加上原数组长度的链
if (hiTail != null) {
    hiTail.next = null;
    newTab[j + oldCap] = hiHead;
}

1.8 put流程图(见上图)

5.1.7和1.8对比

1.发生hash冲突时,Java7会在链表头部插入,Java8会在链表尾部插入
2.扩容后转移数据,Java7转移前后链表顺序会倒置,Java8还是保持原来的顺序
3.关于性能对比可以参考美团技术博客,引入红黑树的Java8大程度得优化了HashMap的性能

四丶面试题加深

问题1:HashMap是基于key的hashcode的存储的,如果两个不同的key产生的hashcode一样取值怎么办? 

看了上面的分析,你肯定知道,再数组里面有链表结构的Entry来实现,通过遍历所有的Entry,比较key来确定到底是哪一个value;
问题2:HashMap是基于key的hashcode的存储的,如果两个key一样产生的hashcode一样怎么办? 
在put操作的时候会遍历所有Entry,如果有key相等的则替换。所以get的时候只会有一个
问题3、我们总是习惯用一个String作为HashMap的key,这是为什么呢?其它的类可以做为HashMap的key吗? 
这里因为String是不可以变的,并且java为它实现了hashcode的缓存技术。我们在put和get中都需要获取key的hashcode,这些方法的效率很大程度上取决于获取hashcode的,所以用String的原因:1、它是不可变的。2、它实现了hashcode的缓存,效率更高。
问题4:可变的对象能作为HashMap的key吗? 
可变的对象是可以当做HashMap的key的,只是你要确保你可变变量的改变不会改变hashcode。比如以下代码

public class TestMemory {

    public static void main(String[] args) {
        HashMap hashMap = new HashMap();
        TestKey testKey = new TestKey();
        testKey.setAddress("sdfdsf");//line3
        hashMap.put(testKey,"hello");
        testKey.setAddress("sdfsdffds");//line5
        System.out.println(hashMap.get(testKey));
    }
}

public class TestKey {
    String name;
    String address;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getAddress() {
        return address;
    }

    public void setAddress(String address) {
        this.address = address;
    }

    @Override
    public int hashCode() {
        if (name==null){
            return 0;
        }
        return name.hashCode();
    }
}

上面的代码line3到line5对象里的address做了改变,但是由于hashCode是基于name来生成的,name没变,所以依然能够正常找到value。但是如果把setAdress换成name,get就会返回null。这就是为什么我们选择String的原因。

参考文章:

Java 8系列之重新认识HashMap

图解HashMap(一)

Java你可能不知道的事(3)HashMap


推荐阅读
  • Java集合详解5:深入理解LinkedHashMap和LRU缓存
    Java集合详解5:深入理解LinkedHashMap和LRU缓存今天我们来深入探索一下LinkedHashMap的底层原理,并且使用linkedhashmap来实现LRU缓存。具体代码在我的 ... [详细]
  • 缓存这个东西就是为了提高运行速度的,由于缓存是在寸土寸金的内存里面,不是在硬盘里面,所以容量是很有限的。LRU这个算法就是把最近一次使用时间离现在时间最远的数据删除掉。先说说List:每 ... [详细]
  • 在Android开发中,使用Picasso库可以实现对网络图片的等比例缩放。本文介绍了使用Picasso库进行图片缩放的方法,并提供了具体的代码实现。通过获取图片的宽高,计算目标宽度和高度,并创建新图实现等比例缩放。 ... [详细]
  • 转载自:http:www.blogjava.netCarpenterLeearchive20160427430268.html总体介绍之所以把HashSet和HashMa ... [详细]
  • 01Map集合概述A:Map集合概述:我们通过查看Map接口描述,发现Map接口下的集合与Collection接口下的集合,它们存储数据的形式不同a:Collection中的集合 ... [详细]
  • Java太阳系小游戏分析和源码详解
    本文介绍了一个基于Java的太阳系小游戏的分析和源码详解。通过对面向对象的知识的学习和实践,作者实现了太阳系各行星绕太阳转的效果。文章详细介绍了游戏的设计思路和源码结构,包括工具类、常量、图片加载、面板等。通过这个小游戏的制作,读者可以巩固和应用所学的知识,如类的继承、方法的重载与重写、多态和封装等。 ... [详细]
  • Java中包装类的设计原因以及操作方法
    本文主要介绍了Java中设计包装类的原因以及操作方法。在Java中,除了对象类型,还有八大基本类型,为了将基本类型转换成对象,Java引入了包装类。文章通过介绍包装类的定义和实现,解答了为什么需要包装类的问题,并提供了简单易用的操作方法。通过本文的学习,读者可以更好地理解和应用Java中的包装类。 ... [详细]
  • 本文介绍了Swing组件的用法,重点讲解了图标接口的定义和创建方法。图标接口用来将图标与各种组件相关联,可以是简单的绘画或使用磁盘上的GIF格式图像。文章详细介绍了图标接口的属性和绘制方法,并给出了一个菱形图标的实现示例。该示例可以配置图标的尺寸、颜色和填充状态。 ... [详细]
  • 欢乐的票圈重构之旅——RecyclerView的头尾布局增加
    项目重构的Git地址:https:github.comrazerdpFriendCircletreemain-dev项目同步更新的文集:http:www.jianshu.comno ... [详细]
  • Android系统源码分析Zygote和SystemServer启动过程详解
    本文详细解析了Android系统源码中Zygote和SystemServer的启动过程。首先介绍了系统framework层启动的内容,帮助理解四大组件的启动和管理过程。接着介绍了AMS、PMS等系统服务的作用和调用方式。然后详细分析了Zygote的启动过程,解释了Zygote在Android启动过程中的决定作用。最后通过时序图展示了整个过程。 ... [详细]
  • Week04面向对象设计与继承学习总结及作业要求
    本文总结了Week04面向对象设计与继承的重要知识点,包括对象、类、封装性、静态属性、静态方法、重载、继承和多态等。同时,还介绍了私有构造函数在类外部无法被调用、static不能访问非静态属性以及该类实例可以共享类里的static属性等内容。此外,还提到了作业要求,包括讲述一个在网上商城购物或在班级博客进行学习的故事,并使用Markdown的加粗标记和语句块标记标注关键名词和动词。最后,还提到了参考资料中关于UML类图如何绘制的范例。 ... [详细]
  • 本文介绍了H5游戏性能优化和调试技巧,包括从问题表象出发进行优化、排除外部问题导致的卡顿、帧率设定、减少drawcall的方法、UI优化和图集渲染等八个理念。对于游戏程序员来说,解决游戏性能问题是一个关键的任务,本文提供了一些有用的参考价值。摘要长度为183字。 ... [详细]
  • Android源码中的Builder模式及其作用
    本文主要解释了什么是Builder模式以及其作用,并结合Android源码来分析Builder模式的实现。Builder模式是将产品的设计、表示和构建进行分离,通过引入建造者角色,简化了构建复杂产品的流程,并且使得产品的构建可以灵活适应变化。使用Builder模式可以解决开发者需要关注产品表示和构建步骤的问题,并且当构建流程发生变化时,无需修改代码即可适配新的构建流程。 ... [详细]
  • HashTable与ConcurrentHashMap均可实现HashMap的功能,对外提供了键值对存储的数据结构。但是在内部结构及实现上有何区别,性能上的差异到底在哪里又是如何导致的 ... [详细]
  • 从相邻元素对还原数组的解题思路和代码
    本文介绍了从相邻元素对还原数组的解题思路和代码。思路是使用HashMap存放邻接关系,并找出起始点,然后依次取。代码使用了HashMap来存放起始点所在的adjacentPairs中的位置,并对重复的起始点进行处理。 ... [详细]
author-avatar
唯爱WE创丶
这个家伙很懒,什么也没留下!
PHP1.CN | 中国最专业的PHP中文社区 | DevBox开发工具箱 | json解析格式化 |PHP资讯 | PHP教程 | 数据库技术 | 服务器技术 | 前端开发技术 | PHP框架 | 开发工具 | 在线工具
Copyright © 1998 - 2020 PHP1.CN. All Rights Reserved | 京公网安备 11010802041100号 | 京ICP备19059560号-4 | PHP1.CN 第一PHP社区 版权所有