java系列【HashMap让你起飞的一篇】(面试必问)

前言

Hello,大家好!今天的分享的是面试中常问到的集合内容,其中HashMap是集合中的重点,如果是对HashMap感兴趣可以直接到跳到这【我是HashMap】,希望这篇内容能给你带来收获

目录

    • 前言
    • 存储单列数据的集合:
      • 首先说一下list的家族
      • 再来说一下set的家族
    • 存储键和值的集合
      • TreeMap
      • Hashtable
      • ConCurrentHashMap
      • HashMap
    • 那么HashMap的容量为什么要取2的n次幂
    • HashMap8为什么引入红黑树
    • hashcode 和 equals的区别
    • 为什么 HashMap 中 String、Integer 这样的包装类适合作为 key 键
    • HashMap1.7中的死循环问题

先上个java集合体系图(简略版)
java系列【HashMap让你起飞的一篇】(面试必问)_第1张图片

其实,java中的集合分为存储单列数据的集合和存储键和值这样的双列数据的集合

存储单列数据的集合:

其中就有list和set,list有序允许重复set无序不允许重复

首先说一下list的家族

ArrayListLinkedListVector就是list的三大实现类

ArrayList

基于数组、有序的、允许重复的list集合,但是线程不同步

LinkedList

基于双向链表,有序,允许重复的list集合,但是线程不同步

Vector

基于数组、有序的、允许重复的list集合,并且线程同步

三者的区别

  • ArrayListVector底层用的数组LinkedList使用的是链表`,
  • 数组的优点是查询指定的比较快,因为有索引,而增删改比较慢,因为数组在内存中是一块连续的数据集合,每次删除和添加都会影响索引,所以性能上弱点儿
  • 链表是当前元素中存放下一个或者上一个的元素的地址,但是如果你查询时候需要从开头一个一个的找,效率比较低,而插入时不需要移动内存,只需要改变引用指向即可,所以链表增删改效率高
  • Vector是线程同步的,其他两个线程不同步
  • ArrayList每次扩容是其大小的1.5倍,而Vector是其大小的2倍

再来说一下set的家族

HashSetTreeSetLinkedHashSet是set集合的主要三个实现类

  • 但要知道一点的就是如果你把map集合搞懂了,set也就迎刃而解了,因为这三个其实底层都用到了map集合的实现类
  • HashSet对应着HashMap、TreeSet对应着TreeMap、LinkedHashSet对应LinkedHashMap

HashSet

  • 基于HashMap的Set集合,无序不允许重复且线程是非同步的,底层是哈希表(数组+单链表)结构。
  • 保证元素唯一性的原理:判断元素的hashcode值是否相同。如果相同,还会继续判断元素的equals方法,是否为true
  • HashSet的add()就是往一个HashMap里面put(),只是key一直不同,而value是一直相同的就是上面的那个伪值PRESENT允许有null值
  • HashSet的值是存储在一个HashMap的key里面,而HashMap的key是不能重复的;详细的请看下面的map集合

LinkedHashSet

  • 基于LinkedHashMap的Set集合,不允许重复底层是哈希表和双链表结构
  • 本身就继承HashSet,所以它也根据元素hashCode值来决定元素存储位置,但它同时使用双链表维护元素的次序,这样使得元素看起来是以插入的顺序保存的

TreeSet

  • 基于TreeMap并且不重复的set集合,底层就是二叉树结构 (即红黑树结构,也是一种自平衡的二叉树),注意底层没有hash算法
  • 可以对set集合中的元素进行排序,如果是引用数据类型一定要实现Comparable接口重写compareTo方法,不然会抛出异常。
  • comareTo返回正数,负数,0,都代表着结果为 正序,倒序,和只有根元素
  • 不重复或者元素唯一性就是 compareTo 方法结果为0,

这里面就要说下两种排序方式了:
1、让元素自身具有比较性(自然排序): 元素实现Comparable接口,重写compareTo方法

2、让容器自身具备比较性(比较器): 定义类去实现Comparator接口,重写compare(Object,Object)方法,然后作为TreeSet的构造参数

那他俩之间的区别是什么嘞?

  • 自然排序 因为实现了Comparable接口,重写方法后,这些元素就由内部的排序规则了,比入Integer,String等等
  • 比较器 则是这种自身的排序规则不是我想要的,我想要创建自己的排序规则,但是String是改不了的,所以就有了Comparator接口,来创建外部排序规则

存储键和值的集合

Map 集合可谓是java中相当重要的一个角色了,他涵盖了list和set,其中最熟悉的就是HashMap了,不过还有Hashtable,TreeMap,LinkedHashMap,当然还有ConCurrentHashMap这个角色,听我11道来

TreeMap

TreeMap底层是二叉树(红黑树)线程不同步。可以用于给map集合中的键进行排序,上面的TreeSet底层就是用的TreeMap,只不过TreeMap是一个K-V集合

Hashtable

Hashtable底层是哈希表数据结构 (数组+链表)无序的不可以存入null键和null值,线程同步

ConCurrentHashMap

JDK1.7底层实现:

  • 首先将数据分为一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据时,其他段的数据也能被其他线程访问。
  • 在JDK1.7中,ConcurrentHashMap采用Segment + HashEntry的方式进行实现,结构如下:
    java系列【HashMap让你起飞的一篇】(面试必问)_第2张图片
  • 一个 ConcurrentHashMap 里包含一个 Segment 数组。Segment 的结构和HashMap类似,是一种数组和链表结构,一个 Segment 包含一个 HashEntry 数组,每个 HashEntry 是一个链表结构的元素,每个 Segment 守护着一个HashEntry数组里的元素,当对 HashEntry 数组的数据进行修改时,必须首先获得对应的 Segment的锁。
  • 该类包含两个静态内部类 HashEntry 和 Segment ;前者用来封装映射表的键值对,后者用来充当锁的角色;
  • Segment 是一种可重入的锁 ReentrantLock,每个 Segment 守护一个HashEntry 数组里得元素,当对 HashEntry 数组的数据进行修改时,必须首先获得对应的 Segment 锁。

HashMap

  • HashMap底层是哈希表数据结构 (数组+链表)无序的可以存入null键和null值,线程非同步,jdk1.8 HashMap底层又加入了红黑树(why?)
  • 因为HashMap为面试中重点,这里多介绍介绍,先附上HashMap的部分源码图

// 数组的默认初始长度,java规定hashMap的数组长度必须是2的次方
// 扩展长度时也是当前长度 << 1。
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

// 数组的最大长度
static final int MAXIMUM_CAPACITY = 1 << 30;

// 默认负载因子,当元素个数超过这个比例则会执行数组扩充操作。
static final float DEFAULT_LOAD_FACTOR = 0.75f;

// 树形化阈值,当链表节点个大于等于TREEIFY_THRESHOLD - 1时,
// 会将该链表换成红黑树。
static final int TREEIFY_THRESHOLD = 8;

// 解除树形化阈值,当链表节点小于等于这个值时,会将红黑树转换成普通的链表。
static final int UNTREEIFY_THRESHOLD = 6;

// 最小树形化的容量,即:当内部数组长度小于64时,不会将链表转化成红黑树,而是优先扩充数组。
static final int MIN_TREEIFY_CAPACITY = 64;

// 这个就是hashMap的内部数组了,而Node则是链表节点对象。
transient Node<K,V>[] table;

// 下面三个容器类成员,作用相同,实际类型为HashMap的内部类KeySet、Values、EntrySet。
// 他们的作用并不是缓存所有的key或者所有的value,内部并没有持有任何元素。
// 而是通过他们内部定义的方法,从三个角度(视图)操作HashMap,更加方便的迭代。
// 关注点分别是键,值,映射。
transient Set<K>        keySet;  // AbstractMap的成员
transient Collection<V> values; // AbstractMap的成员
transient Set<Map.Entry<K,V>> entrySet;

// 元素个数,注意和内部数组长度区分开来。
transient int size;

// 再上一篇文章中说过,是容器结构的修改次数,fail-fast机制。
transient int modCount;

// 阈值,超过这个值时扩充数组。 threshold = capacity * loadfactor,具体看上面的静态常量。
int threshold;

// 装在因子,具体看上面的静态常量。
final float loadFactor;

  • 没有阅读过源码的同学可能慌了,没事~跟着博主走,什么都会有
  • 根据这个代码图,然后给大家溜下HashMap的运行流程和一些内部机制,可能有的地方不是很对,请大家谅解
  • 其实上面就是HashMap1.8版本的 一些重要的成员变量
  • 都知道HashMap底层是数组和链表,这个数组 Node[] table的初始容量为16,而容量是以2的次方扩充的为什么呢?,一是为了提高性能使用足够大的数组,二是为了能使用位运算代替取模预算效率更高
  • 数组是否需要扩充是通过负载因子判断的,如果当前元素个数为数组容量的0.75时,就会扩充数组。这个0.75就是默认的负载因子,可由构造传入。我们也可以设置大于1的负载因子,这样数组就不会扩充,牺牲性能,节省内存。
  • 为了解决碰撞,数组中的元素是单向链表类型。当链表长度到达一个阈值时(7或8),会将链表转换成红黑树提高性能。而当链表长度缩小到另一个阈值时(6),又会将红黑树转换回单向链表提高性能,这里是一个平衡点。
  • 对于第三点补充说明,检查链表长度转换成红黑树之前,还会先检测当前数组数组是否到达一个阈值(64),如果没有到达这个容量,会放弃转换,先去扩充数组。所以上面也说了链表长度的阈值是7或8,因为会有一次放弃转换的操作。
  • HashMap的原理:因为是Hash表结构,Hash表的做法其实很简单,就是把Key通过一 个固定的算法函数既所谓的哈希函数转换成一个整型数字,然后就将该数字对数组长度进行取余,取余结果就当作数组的下标,将value存储在以该数字为下标 的数组空间里,而当使用哈希表进行查询的时候,就是再次使用哈希函数将key转换为对应的数组下标,并定位到该空间获取value,如此一来,就可以充分利用到数组的定位性能进行数据定位
  • 然后根据put()get()这两个重要的方法来具体分析一下(1.8)

put方法底层实现及分析

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node<K,V>[] tab;
        Node<K,V> p;
        int n, i;
        //判断当前桶是否为空
        if ((tab = table) == null || (n = tab.length) == 0)
            //空的就需要初始化(resize 中会判断是否进行初始化)。
            n = (tab = resize()).length;
        //根据当前 key 的 hashcode 定位到具体的桶中并判断是否为空,
        if ((p = tab[i = (n - 1) & hash]) == null)
        	//为空表明没有 Hash 冲突就直接在当前位置创建一个新桶即可。    
            tab[i] = newNode(hash, key, value, null);
        else {
            Node<K,V> e; K k;
            //如果当前桶有值( Hash 冲突),那么就要比较当前桶中的 key、key 的 hashcode 与写入的 key 是否相等,
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                //相等就赋值给 e,然后统一返回。
                e = p;
            //如果当前桶为红黑树,
            else if (p instanceof TreeNode)
            	//那就要按照红黑树的方式写入数据。
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            //如果是个链表,
            else {
                for (int binCount = 0; ; ++binCount) {
                    if ((e = p.next) == null) {
                    	//就需要将当前的 key、value 封装成一个新节点写入到当前桶的后面(形成链表)。
                        p.next = newNode(hash, key, value, null);
                        //接着判断当前链表的大小是否大于预设的阈值,大于时就要转换为红黑树。
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash);
                        break;
                    }
                    //如果在遍历过程中找到 key 相同时直接退出遍历。
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            //如果 e != null 就相当于存在相同的 key,那就需要将值覆盖。
            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;
        //最后判断是否需要进行扩容。
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }

总结一下:

  1. 判断当前桶是否为空,空的就需要初始化(resize 中会判断是否进行初始化)。
  2. 根据当前 key 的 hashcode 定位到具体的桶中并判断是否为空,为空表明没有 Hash 冲突就直接在当前位置创建一个新桶即可。
  3. 如果当前桶有值( Hash 冲突),那么就要比较当前桶中的 key、key 的 hashcode 与写入的 key 是否相等,相等就赋值给 e,在第 8 步的时候会统一进行赋值及返回。
  4. 如果当前桶为红黑树,那就要按照红黑树的方式写入数据。
  5. 如果是个链表,就需要将当前的 key、value 封装成一个新节点写入到当前桶的后面(形成链表)。
  6. 接着判断当前链表的大小是否大于预设的阈值,大于时就要转换为红黑树。
  7. 如果在遍历过程中找到 key 相同时直接退出遍历。
  8. 如果 e != null 就相当于存在相同的 key,那就需要将值覆盖。
  9. 最后判断是否需要进行扩容。

get方法底层实现及分析

public V get(Object key) {
        Node<K,V> e;
        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;
        //首先将 key hash 之后取得所定位的桶。如果桶为空则直接返回 null 
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (first = tab[(n - 1) & hash]) != null) {
            //判断桶的第一个位置(有可能是链表、红黑树)的 key 是否为查询的 key,是就直接返回 value。
            if (first.hash == hash && // always check first node
                ((k = first.key) == key || (key != null && key.equals(k))))
                return first;
            //如果第一个不匹配,
            if ((e = first.next) != null) {
            	//则判断它的下一个是红黑树还是链表。
                if (first instanceof TreeNode)
                	//红黑树就按照树的查找方式返回值。
                    return ((TreeNode<K,V>)first).getTreeNode(hash, key);
                //不然就按照链表的方式遍历匹配返回值。
                do {
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        return e;
                } while ((e = e.next) != null);
            }
        }
        return null;
    }

总结一下:

  1. 首先将 key hash 之后取得所定位的桶。
  2. 如果桶为空则直接返回 null 。
  3. 否则判断桶的第一个位置(有可能是链表、红黑树)的 key 是否为查询的 key,是就直接返回 value。
  4. 如果第一个不匹配,则判断它的下一个是红黑树还是链表。
  5. 红黑树就按照树的查找方式返回值。
  6. 不然就按照链表的方式遍历匹配返回值。

接下来给大家说几个面试中经常问到有关HashMap的问题

那么HashMap的容量为什么要取2的n次幂

  • 因为如果是2的n次幂 那数据应该是这样的
    二进制 :10 / 100 / 1000 / 10000 / 100000 / 1000000 / …
    十进制 :2 / 4 / 8 / 16 / 32 / 64 / …
  • 而当他们减1的时候二进制结果是这样的
    01 / 011 / 0111 / 01111 / 011111 / 0111111 / …
  • 而我门看到的HashMap源码中计算或分配数据在数组中的位置用的是hash & (n - 1),其实等价于hash % n
  • &这个符号是位运算中的位与,只有当对应位置的数据都为1时,运算结果也为1,看如下:
// 例子:就用HashMap默认的初始容量 16 来说  hash  & (16 - 1) ==》 hash % 16
16的二进制为:1000010000 - 1 =  1111
那结过就成: hash & 1111  ;
这样能快速得到 hash值二进制的后四位,这后四位就是余数
  • 总结下:计算机中直接求余 a % b效率不如位移运算 a &(b - 1),但是这需要b是2的n次幂才有效,所以为了存取高效,要尽量较少碰撞,HashMap就这样做了

HashMap8为什么引入红黑树

  • 看一下HashMap1.7版本的底层结构图吧,
    java系列【HashMap让你起飞的一篇】(面试必问)_第3张图片
    数组+单链表;这其中其实就能看出来当 Hash 冲突严重时,这个链表会变的很长的话,这样在查询时的效率就会越来越低;时间复杂度为 O(N),所以1.8就引入了红黑树。
    java系列【HashMap让你起飞的一篇】(面试必问)_第4张图片
  • 那有的就会问了,既然红黑树这么好,为什么HashMap不直接采用红黑树,而用链表呢?
  • 因为红黑树需要进行左旋,右旋操作, 而单链表不需要,
    以下都是单链表与红黑树结构对比。
    如果元素小于8个,红黑树查询成本高,新增成本低
    如果元素大于8个,红黑树查询成本低,新增成本高

hashcode 和 equals的区别

hashCode()方法和equal()方法的作用其实一样,在Java里都是用来对比两个对象是否相等一致,那么equal()既然已经能实现对比的功能了,为什么还要hashCode()呢?

因为重写的equal()里一般比较的比较全面比较复杂,这样效率就比较低,而利用hashCode()进行对比,则只要生成一个hash值进行比较就可以了,效率很高,那么hashCode()既然效率这么高为什么还要equal()呢?

  • equal()相等的两个对象他们的hashCode()肯定相等
  • hashCode()相等的两个对象他们的equal()不一定相等

所以对于需要大量并且快速的对比的话如果都用equal()去做显然效率太低,解决方式是,每当需要对比的时候,首先用hashCode()去对比,如果hashCode()不一样,则表示这两个对象肯定不相等(也就是不必再用equal()去再对比了),如果hashCode()相同,此时再对比他们的equal(),如果equal()也相同,则表示这两个对象是真的相同了,这样既能大大提高了效率也保证了对比的绝对正确性!那么再回过头来看看HashMap源码是不是更清晰了呢

为什么 HashMap 中 String、Integer 这样的包装类适合作为 key 键

  • String,Integer等这些类都被final修饰,具有不变性;也保证了key的不变性,并且内部重写了equals和hashCode方法,不容易出现hash计算错误等。
  • 所以String,Integer等包装类的特性保证了Hash值的不可变性和准确性有效减少了hash碰撞
  • 所以作为HashMap的key一定要重写equals和hashCode方法

HashMap1.7中的死循环问题

HashMap1.7中的扩容机制实际上就是用一个新的2倍长度的数组来替代旧的数组,使用具体就是resize方法,里面调用的是transfer方法,这个方法用来转移数组数据,采用的就是头插法,但是存在一个问题:并发操作容易在一个桶上形成环形链表;这样当获取一个不存在的 key 时,计算出的 index 正好是环形链表的下标就会出现死循环。

结言:最后,感谢你能够看到这里,相信你也是拼搏道路上的追梦人,也欢迎评论区留言 ,喜欢博文可以给个呦,更多文章在这里【jar壳虫】,后续会有更多分享哦

你可能感兴趣的:(java系列)