聊聊Java系列-集合之HashMap底层结构原理

前言

          由于HashMap在我们的工作和面试中会经常遇到,所以搞懂HashMap的底层结构原理就显得十分有必要了。在JDK1.8之前,HashMap的底层采用的数据结构是数组+链表,而在JDK1.8及以后,HashMap的底层采用的数据结构是数组+链表+红黑树。因此想要弄懂HashMap的底层结构原理,需要先弄懂数组、链表、红黑树这三种数据结构。

一、数据结构之数组详解

          数组定义:采用一段连续的存储单元来存储数据。(看图说话)

 

          数组特点:   查询O(1),删除插入O(N)。这也就意味着数组查询快,插入慢。(这里O指的是时间复杂度,而评判一个算法的好坏主要是看时间复杂度和空间复杂度。)

          可能有人会好奇这里的查询O(1)是什么意思?这个意味着这里的查询为常数级别的,其中O(1)中的1并不是说只查询一次,这个也是可能会查询2次、3次、多次的,这个1只是代表是常数。比如说下图,将数值2赋值给下标为4的整数数组,然后进行输出。那么程序只需要查询该数组下标为4的数据,然后便可以得到数值2了,这个就是时间复杂度为O(1)。

public class Array {

    // 数组: 采用一段连续的存储单元来存储数据
    // 特点: 指定下标O(1),删除插入O(N) 数组:查询快,插入慢
    public static void main(String[] args) {
        Integer integers[] = new Integer[10];
        integers[0] = 0;
        integers[1] = 1;
        integers[4] = 2;

        // 下面这行代码将会输出2
        System.out.println(integers[4]);
    }
}

         而删除插入O(N)又是什么意思呢?假设现在有n个元素,而我现在要删除下标为2的值,那么之前下标为2后面的元素都需要依次向前移一位(看图说话)。如果删除的下标是0的元素,那么后面的元素都需要向前移动n次,那么时间复杂度是O(n)。如果删除的下标是n-1的元素,那么元素不需要移动,那么时间复杂度度是O(1),如果删除的元素是中间的,那么时间复杂度O(n/2)。而我们看时间复杂度一般也是以最坏的时间复杂度为标准的,所以说删除插入为O(n)。插入亦如此。

       经典实现: Java里面的ArrayList就是再次基础上实现的。

二、数据结构之链表详解

        链表定义:链表是一种物理存储单元上非连续、非顺序的存储结构。(看图说话)

        链表特点:  插入、删除时间复杂度O(1) ,查找遍历时间复杂度O(N)。 插入快,查找慢。

       这里可能有人会好奇什么说链表的插入、删除快,而查询慢呢?比如下面这个图,假设我需要将删除第二个元素,我只需要将next引用指向第三个元素就可以了。而插入的话,只需要插入元素的前一个元素的引用指向插入元素,同时插入元素的引用指向下一个元素,那么就可以插入了。这样看下来链表的插入、删除是不是很快呀,因为插入和删除只需要改变引用。

       那么插入删除的时间复杂度为O(1)。而为什么又说查询很慢呢,因为是从头节点开始进行查询,然后看是否有查询的目标,没有就根据next引用继续往下查找,指导遍历找到为止。比如说我现在要查询下图的第一个链表里面的小胜,那么我就需要先从头节点开始查询,发现头节点只有小海而没有小胜,那么就继续根据next引用继续往下查询,结果发现只有小李而没有小胜,那么就继续根据next引用往下查询,最后发现了查询到了小胜,那么就返回结果。在这个过程中,其实前面两次查询是没有意义的,但是还是继续查询了,所以说链表的查询慢。如果链表有n个元素,那么最坏情况下查找的元素在最后一个,那么查询的时间复杂度就是O(n)。

       经典实现: Java里面的LinkList就是再次基础上实现的。

三、哈希(散列)算法详解

      讲到这里在JDK1.8之前,HashMap的底层采用的数据结构是数组+链表也就讲完了,大家也对HashMap有了进一步了解了。但是HashMap的实现除了涉及到数据结构,其实还涉及到哈希算法(也叫散列)。

      定义:哈希算法(也叫散列),也就是把任意长度值(Key)通过散列算法变换成固定长度的key(地址),通过这个地址进行访问的数据结构。它通过把关键码值映射到表中一个位置来访问记录,以加快擦好像的速度。

      这个定义什么意思呢?(看图说话)比如说将key为John Simth的数据通过哈希散算法存到到哈希表下标为152的位置上。

    而这个时候恐怕大家又会很好奇这个值是如何算出来的了?这就涉及到了hashcode了,那么什么是hashcode呢?hashcode又是怎么算的呢?

    hashcode定义:通过字符串算出它的ascii码,进行mod(取模),算出哈希表中的下标。注意:这里的取模多少,具体是跟数组的长度相关的。

 

   这里就是对429进行取模10,从而得到9。大家现在明白了hashcode是如何算出来的了,恐怕就会问,那么这个ascii码值该如何得到了,这里提供一个程序。

/**
 * @Auther: limingwu
 * @Date: 2021/3/7 18:05
 * @Description:
 */
public class AsciiCode {

    public static void main(String[] args) {
        char c[] = "lies".toCharArray();
        for (int i = 0; i < c.length; i++) {
            System.out.println((c[i]) + ":" + (int) c[i]);
        }
    }
}

   讲到这里是不是觉得哈希(散列)算法很强大,但是这个算法也容易引发一个问题,那就是容易发生哈希冲突(碰撞)。这是什么意思呢?(看下图说话) 比如"lies"和"foes"两个字符串的ascii码值是一样的,那么     通过哈希算法的出来的hashcode值肯定也是一样的,所以但是他们在哈希表中的位置是一样的。但是同一位置又不能存储两个元素,那么怎么办呢?于是聪明的工程师想到了用链表这个结构来存储。首         先"lies"通过哈希算法存储到下标为9的数组里面去,然后“foes”进行存储的时候,只需要将“lies”的引用指向"foes"就好了。看到这里,大家也明白了JDK1.8之前的HashMap存储和数据结构了吧,它就是这       么存储的。

四、HashMap底层关键原理分析

    再讲hashMap的底层关键原理分析之前,我们先来看一段代码。

/**
 * @Auther: limingwu
 * @Date: 2021/3/9 10:43
 * @Description:
 */
public class App {
    public static void main(String[] args) {
        Map<String, String> map = new HashMap<>();
        map.put("张三", "张三");
        map.put("李四", "李四");
        map.put("王五", "王五");
        map.put("孙七", "孙七");
        map.put("小武", "阿武刚巴得");
        // 下面这行输出阿武刚巴得
        System.out.println(map.get("小武"));
    }
}

     通过这段代码我们知道了,hashMap都是put存储键值对,get查询。那么问题来了,这个put是如何存储值的呢?这里我们可以通过模拟一个put方法来分析一下。

/**
 * @Auther: limingwu
 * @Date: 2021/3/9 10:43
 * @Description:
 */
public class App {
    public static void main(String[] args) {
        App map = new App();
        map.put("张三", "张三");
        map.put("李四", "李四");
        map.put("王五", "王五");
        map.put("孙七", "孙七");
        map.put("小武", "阿武刚巴得");
        // 下面这行输出阿武刚巴得
        // System.out.println(map.get("小武"));
    }

    public void put(String key, String value) {
        System.out.printf("key:%s:::::::::::hash值:%s:::::::::::存储位置:%s\r\n", key, key.hashCode(), Math.abs(key.hashCode() % 15));
    }
}

  程序执行完以后会输出以下数据

key:张三:::::::::::hash值:774889:::::::::::存储位置:4
key:李四:::::::::::hash值:842061:::::::::::存储位置:6
key:王五:::::::::::hash值:937065:::::::::::存储位置:0
key:孙七:::::::::::hash值:744906:::::::::::存储位置:6
key:小武:::::::::::hash值:758071:::::::::::存储位置:1

  现在我们根据输出结果以及上面的存储代码来一步一步分析。(看下图说话)首先我们看到"张三"这个key通过哈希算法算出来的值是774889,存储的位置是数组下标为4的位置。那么就会将这个键值存储到下    表为4的数组里面,那么问题来了,这个是用字符串存储这个键值存储的呢还是用对象存储的了?答案是一般多个字符串的都会用对象来存储,这个对象我们可以看成它是一个Entry,这个Entry里面就存储我们的key和value以及存储一个hash和next。比如说下图的map会依次往下存储,"张三"就会存储到下标为4的数组里面,同时存储进来的还有value,hash,next。可能有人会好奇这个next是干嘛用的,其实这个next就是为了解决哈希碰撞而设计的,上面也提到过。现在开始存储"李四",也是一样的过程,李四会存储在数组下标为6的位置上。后面map也是按照这种形式存储,直到存储"孙七"这个key,就开始稍微不一样了。有啥不一样呢?因为"孙七"这个key,通过哈希算法算出来的值和”李四“通过哈希算法算出来的值是一样的,那么他们所存储在数组中的位置也是一样的。那么这个时候怎么办呢?如果这个时候”孙七“也存储在这个数组下标为6的位置上,就会将之前存储的"李四"给覆盖掉。

那么这里我能不能将这个给覆盖掉了,如果覆盖掉了的话,这个"李四"就无法查询了。很明显这就不符合hashMap的设计理念了,因为hashMap不同的key,其实是可以查询出来的。那既然这里这个"李四"不能被覆盖,那么这个地方该怎么办呢?(看图说话) 这个时候可以让之前存储下标一样的对象让一个位置,这里就是让"李四"让个位置。然后新存储的对象,也就是这里的”孙七“存储进来。最后让新进来的对象的next引用指向刚刚让出位置的对象,也就是说让"孙七"的next引用指向"李四"。这个地方其实就是一个链表结构了,这个也是为什么hashMap既用数组结构,又用链表结构的原因了。

存储完”孙七“,以后就是存储"小武"了,这个地方还是按照之前的方式存储。所以下图就是hashMap存储值的一个方式了。

现在讲完了hashMap存储(put),那么hashMap查询(get)是如何查询的呢?条条大路通罗马,其实hashMap查询和hashMap存储也是一样的道理。(看图说话)比如说我们现在要查询key为"李四"的值,那么我们就会通过hash算法得到数组下标为6,找到key为”孙七“的元素。这个时候会去比较key和hashCode,发现"李四"和”孙七“的key以及hashCode不一样,那么就会去看判断"孙七"有没有next引用,如果有next引用就取出来,然后判断key和hashCode值是否一样,如果一样那么就返回这个对象的value,如果不一样就返回null,这个就是我们查询(get)的一个逻辑。

上面的图是HashMap的一个查询和存储的思想,通过这个思想我们可以自定义属于我们的HashMap,以下就是自定义HashMap。

package com.awu.hashmap;

public interface Map<K, V> {

    V put(K k, V v);

    V get(K k);

    int size();

    interface Entry<K, V> {
        K getKey();

        V getValue();

    }
}
package com.awu.hashmap;

/**
 * @Auther: limingwu
 * @Date: 2021/3/9 16:18
 * @Description:
 */
public class HashMap<K, V> implements Map<K, V> {
    private Entry<K, V>[] table = null;

    private int size = 0;

    public HashMap() {
        // 这里数组长度定义为16,是因为HashMap的默认容量就是16
        this.table = new Entry[16];
    }

    /**
     * 首先通过key进行hash,%数组下标长度得到对应下标Entry。然后判断是否为空,如果为空直接存放当前下标数组。如果不为空,
     * 判断next是否为空,如果next为空,直接赋值。如果不为空,再判断当前next是否为空,最后重复上述操作。
     *
     * @param k
     * @param v
     * @return
     */
    @Override
    public V put(K k, V v) {
        int index = hash(k);
        Entry<K, V> entry = table[index];
        if (entry == null) {
            size++;
            table[index] = new Entry<>(k, v, index, null);
        } else {
            table[index] = new Entry<>(k, v, index, entry);
        }
        return table[index].getValue();
    }

    private int hash(K k) {
        int i = k.hashCode() % 16;
        return i >= 0 ? i : -i;
    }

    /**
     * 首先对Key进行hash,取得下标index。然后判断这个下标对应得Entry是否为空,如果为空,说明没有内容,直接返回null。如果不为空,
     * 比较查询出来得key和取出来得key是否相等,如果相等,直接返回该Entry。如果不相等,判断该Entry的next指向的Entry是否为空,
     * 如果为空,说明没有找到,直接返回null。如果不为空,比较查询出来得key和取出来得key是否相等,最后重复上述操作。
     *
     * @param k
     * @return
     */
    @Override
    public V get(K k) {
        if (size == 0) {
            return null;
        }

        int index = hash(k);
        Entry<K, V> entry = findValue(table[index], k);
        return entry == null ? null : entry.getValue();
    }

    public Entry<K, V> findValue(Entry<K, V> entry, K k) {
        if (entry.getKey().equals(k) || k == entry.getKey()) {
            return entry;
        } else {
            if (entry.next != null) {
                findValue(entry.next, k);
            }
        }
        return null;
    }

    @Override
    public int size() {
        return 0;
    }

    class Entry<K, V> implements Map.Entry<K, V> {
        K k;
        V v;
        int hash;
        Entry<K, V> next;

        public Entry(K k, V v, int hash, Entry<K, V> next) {
            this.k = k;
            this.v = v;
            this.hash = hash;
            this.next = next;
        }

        @Override
        public K getKey() {
            return k;
        }

        @Override
        public V getValue() {
            return v;
        }
    }
}
package com.awu.hashmap;


/**
 * @Auther: limingwu
 * @Date: 2021/3/9 10:43
 * @Description:
 */
public class App {
    public static void main(String[] args) {
        HashMap<String, String> map = new HashMap<String, String>();
        map.put("张三", "张三");
        map.put("李四", "李四");
        map.put("王五", "王五");
        map.put("孙七", "孙七");
        map.put("小武", "阿武刚巴得");
        // 下面这行输出阿武刚巴得
        System.out.println(map.get("小武"));
    }

    public void put(String key, String value) {
        System.out.printf("key:%s:::::::::::hash值:%s:::::::::::存储位置:%s\r\n", key, key.hashCode(), Math.abs(key.hashCode() % 15));
    }
}

这里我们自定义的HashMap也创建好了,并且运行正常。但是这里存在一个问题,由于我们的数组长为16,随着存储的数据量的增多,数组会先存满,然后剩余的数据只好存储到链表了,那么这个时候就会显得链表超级长。而链表太长了,就会导致我们的查询速度变慢。那这个地方如何解决了,Java是通过引入红黑树来提供查询效率的,众所周知红黑树的时间复杂度为O(lgn),而链表的时间复杂度为O(n),所以使用红黑数会比使用链表更加有效率。这个也是为什么JDK1.8及1.8以后HashMap的底层数据结构是由数组、链表、红黑树组成的原因了。那么这里可能会有人好奇了,既然红黑树比链表更加有效率,为啥还用链表呢?这是因为黑红树在插入的时候,需要去维护“小、中、大”,“左、根、右”的这样的一个结构,也就是树的旋转。所以Java里面根据大量实验证明,当数据长度小于等于7的时候用链表,当大于7的时候用红黑树,这也是最有效率的。下图是Java HashMap里的源码,可以看见是和我们说的是一样的。

                                                                       

到此,集合之HashMap底层结构原理讲完,觉得有帮助的给个赞吧,谢谢😄。

  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值