面试知识点梳理及相关面试题(六)-- 集合

1. RandomAccess接口:

一个空接口,表明实现这个接口的list集合是支持快速随机访问的
如果实现了这个接口的list,使用for循环的速度是要快于使用iterator迭代器循环的

例如ArrayList实现了这个接口,所以for循环更快;LinkedList没有,所以迭代器循环更快。

2. 集合和数组的区别:

  • 数组是固定长度的,集合的长度是可变的
  • 数组存储的必须是同一种类型数据,集合可以是不同类型数据
  • 数组可以存放基本类型数据和引用类型数据,集合只能存放引用类型数据

3. 常用的集合类有哪些?

Map接口和Collection接口是所有集合框架的父接口:

  • Collection接口的子接口包括:Set接口和List接口
    • Set接口的实现类主要有:HashSet、TreeSet、LinkedHashSet等
    • List接口的实现类主要有:ArrayList、LinkedList、Stack以及Vector等
  • Map接口的实现类主要有:HashMap、TreeMap、Hashtable、ConcurrentHashMap以及Properties等

在这里插入图片描述

4. fail-fast机制:

是java集合的一种错误检测机制,当多个线程对集合进行结构上的改变的操作时,有可能会产生 fail-fast 机制。

例如:假设存在两个线程(线程1、线程2),线程1通过Iterator在遍历集合A中的元素,在某个时候线程2修改了集合A的结构(是结构上面的修改,而不是简单的修改集合元素的内容),那么这个时候程序就会抛出 ConcurrentModificationException 异常,从而产生fail-fast机制。

原因:迭代器在遍历时直接访问集合中的内容,并且在遍历过程中使用一个 modCount 变量。集合在被遍历期间如果内容发生变化,就会改变modCount的值。每当迭代器使用hashNext()/next()遍历下一个元素之前,都会检测modCount变量是否为expectedmodCount值,是的话就返回遍历;否则抛出异常,终止遍历。

解决办法:

  • 在遍历过程中,所有涉及到改变modCount值得地方全部加上synchronized。
  • 使用CopyOnWriteArrayList来替换ArrayList

5. Collections工具类

  1. sort(list);sort(list, Comparator):排序集合
  2. shuffle():使集合排序随机排序(打乱集合排序)
  3. reverse():反转集合中元素顺序
  4. binarySearch(List,Object), binarySearch(List,Object, Comparator):查找指定元素在集合中的位置,前者在算法比较时采用自然比较法,后者采用自定义的比较方法
  5. swap(Collection/Object[],int,int):交换集合/数组中指定元素索引的位置
  6. fill(Collection,Object):用某个对象替换集合中所有元素
  7. copy(List a,List b):将集合b中的元素全部复制到a中,并且覆盖相应索引的元素
  8. rotate(List, int) :将集合中的元素向右移动指定位,超长的元素循环到元素的最前方,正数向右移,负数向左移
  9. replaceAll(List,Object,Object):替换指定元素为新元素,如果替换成功返回true否则返回false

6. Collection接口下集合类

6.1 iterator迭代器:

Iterator 接口提供遍历任何 Collection 的接口。我们可以从一个 Collection 中使用迭代器方法来获取迭代器实例。迭代器取代了 Java 集合框架中的 Enumeration,迭代器允许调用者在迭代过程中移除元素。

使用:

List<String> list = new ArrayList<>();
Iterator<String> it = list. iterator();
while(it. hasNext()){
  String obj = it. next();
  System. out. println(obj);
}

Iterator 的特点是只能单向遍历,但是更加安全,因为它可以确保,在当前遍历的集合元素被更改的时候,就会抛出 ConcurrentModificationException 异常。

删除元素请使用迭代器删除,在for循环内删除会报ConcurrentModificationException 异常。因为当使用 foreach(for(Integer i : list)) 语句时,会自动生成一个iterator 来遍历该 list,但同时该 list 正在被 Iterator.remove() 修改。Java 一般不允许一个线程在遍历 Collection 时另一个线程修改它。

6.2 遍历集合的几种方式:

  • for 循环遍历,基于计数器。在集合外部维护一个计数器,然后依次读取每一个位置的元素,当读取到最后一个元素后停止。
  • 迭代器遍历,Iterator。Iterator 是面向对象的一个设计模式,目的是屏蔽不同数据集合的特点,统一遍历集合的接口。Java 在 Collections 中支持了 Iterator 模式。
  • foreach 循环遍历。foreach 内部也是采用了 Iterator 的方式实现,使用时不需要显式声明 Iterator 或计数器。优点是代码简洁,不易出错;缺点是只能做简单的遍历,不能在遍历过程中操作数据集合,例如删除、替换。

如果类实现了RandomAccess接口,使用for循环效率最高。

6.3 List接口

在这里插入图片描述

6.3.1 ArrayList:
  • 实现了RandomAccess接口,表明支持随机访问

  • 底层是一个动态数组,动态数组代表数组大小并不是固定的

  • 是一个顺序容器

  • 默认初始容量为10

  • list的插入和删除中间元素的操作其实都是基于数组复制

    • 往list中插入元素时,实际上是把插入位置后面的元素复制了一份,并向后移动一位,最后把元素赋给插入位置
      在这里插入图片描述
  • 扩容:将数组扩容为原来的1.5倍

    • 为什么要选取1.5倍:
      • 如果一次性扩容扩得太大,必然造成内存空间的浪费
      • 如果一次性扩容扩得不够,那么下一次扩容的操作必然比较快地会到来,这会降低程序运行效率,要知道扩容还是比较耗费性能的一个操作
    • 调用Arrays的复制方法,将元素组里面的内容复制到新的数组
      在这里插入图片描述
// 移位运算符,相当于除以2,这里相当于扩容为原来的1.5倍
int newCapacity = oldCapacity + (oldCapacity >> 1);
  • 为什么ArrayList的elementData是用transient修饰的?
    • 因为数组并不一定会被全部使用,比如初始大小为10,可能只占用了3个,那么全部序列化会造成资源浪费,Arraylist重写了writeObject方法,只序列化有的元素
6.3.2 Vector:
  • 方法都添加了synchronized锁
  • 扩容为原来的2倍
6.3.3 LinkedList:
双向链表Node:
  • 链表中任意一个存储单元都可以通过向前或者向后寻址的方式获取到其前一个存储单元和其后一个存储单元
  • 链表的尾节点的后一个节点是链表的头结点,链表的头结点的前一个节点是链表的尾节点
  • 维护了一个头节点和一个尾节点个实例变量
  • LinkedList内部维护双向链表的静态内部类:
private static class Node<E> {
        E item;
        Node<E> next;
        Node<E> prev;

        Node(Node<E> prev, E element, Node<E> next) {
            this.item = element;
            this.next = next;
            this.prev = prev;
        }
    }
链表的操作:
  • 添加:
    • 添加到最后:会将添加的元素设置为last,然后将原来的最后的Node的后节点设置为新添加的,将新添加的节点的前节点设置为原来的后节点
    • 添加到中间:
      • 通过二分查找法先找到当前index位置的节点,
      • 然后判断是不是头节点或者是尾节点
      • 然后将它的前节点设置为新节点,将它原来的前节点的尾节点设置为新节点
  • 查找:
    • 通过二分查找法,先判断区域前半段还是处于后半段,
    • 然后再遍历查找
  • 删除:不多说了,无非是先找到要删除的元素,然后断开元素再链表中的连接,将前后节点连接起来
6.3.4 ArrayList 和 LinkedList 的区别是什么?

读多时用ArrayList,插入和删除多时用LinkedList

  • 数据结构实现:ArrayList 是动态数组的数据结构实现,而 LinkedList 是双向链表的数据结构实现。
  • 随机访问效率:ArrayList 比 LinkedList 在随机访问的时候效率要高,因为 LinkedList 是线性的数据存储方式,所以需要移动指针从前往后依次查找。
  • 增加和删除效率:在非首尾的增加和删除操作,LinkedList 要比 ArrayList 效率要高,因为 ArrayList 增删操作要影响数组内的其他数据的下标。
  • 内存空间占用:LinkedList 比 ArrayList 更占内存,因为 LinkedList 的节点除了存储数据,还存储了两个引用,一个指向前一个元素,一个指向后一个元素。
  • 线程安全:ArrayList 和 LinkedList 都是不同步的,也就是不保证线程安全;
6.4.5 ArrayList 和 Vector 的区别是什么?
  • 线程安全:Vector 使用了 Synchronized 来实现线程同步,是线程安全的,而 ArrayList 是非线程安全的。
  • 性能:ArrayList 在性能方面要优于 Vector。
  • 扩容:ArrayList 和 Vector 都会根据实际的需要动态的调整容量,只不过在 Vector 扩容每次会增加 1 倍,而 ArrayList 只会增加 50%。

6.4 Set接口

HashSet 是基于 HashMap 实现的,HashSet的值存放于HashMap的key上,HashMap的value统一为PRESENT,因此 HashSet 的实现比较简单,相关 HashSet 的操作,基本上都是直接调用底层 HashMap 的相关方法来完成,HashSet 不允许重复的值。

Set可以说就是一个HashMap,只不过只用到了Key。所有的value都是PRESENT

6.5 hashCode()与equals()的相关规定:

  1. 如果两个对象相等,则hashcode一定也是相同的
  2. 两个对象相等,对两个equals方法返回true
  3. 两个对象有相同的hashcode值,它们也不一定是相等的
  4. 综上,equals方法被覆盖过,则hashCode方法也必须被覆盖
  5. hashCode()的默认行为是对堆上的对象产生独特值。如果没有重写hashCode(),则该class的两个对象无论如何都不会相等(即使这两个对象指向相同的数据)

6.6 List和Set的区别:

  • List:有序,可以有多个null,可以重复,支持for循环,和迭代,查询效率高
  • Set:无序,只能有一个null,不能重复,只支持迭代遍历,插入删除效率高

7. Map

7.1 HashMap

东西重要,直接看原来的学习笔记吧:HashMap详解

  • 默认数组长度为16,数组长度必须是2的幂次
    • 为了降低hash值的碰撞率,如果数组长度是2的幂次,那么在计算元素在数组中的下标时,就和自己的hash值有关系,不会被数组长度所影响
    • 计算下标时,先把数组长度-1,再去进行与运算,这样导致数组的二进制码除了最高位0,后面的都是1,进行与运算时,就几乎就是原数据的hash的余数
  • 加载因子0.75,为什么设置0.75?
    • 通过泊松分布计算出的,为了更好的利用空间,不让每个数组节点都有元素,并且有链表,造成遍历复杂度增加;也不让还有很多节点空着的时候就进行扩容,造成空间浪费。
  • 数组+链表+红黑树实现
  • 链表转红黑树8
  • 红黑树转链表6
  • 如果数组的长度小于64,则优先进行扩容,不会将链表转为红黑树
  • key为null会被放再数组下标为0的位置
  • 扩容阙值threshold=数组长度乘以扩容因子,size大于threshold就会进行扩容
  • 实例变量size代表的是map的键值对的长度
  • 数组扩容时,是扩容为原来的2倍
7.1.1 简述一下put的步骤:
  1. 判断hash桶(数组)是否为空,如果为空则调用resize方法新建
  2. case1:判断key经过hash方法运算后的结果对数组长度进行取余的值,即key在数组中的下标,如果这个下标处已经有数据了,就进行case3添加到链表,或case4添加到红黑树
  3. case2:该下标出没有数据,则直接调用newNode方法创建新节点被放在该位置
  4. case3:如果该下标已经有元素,并且仍为链表,遍历链表通过equals判断是否由相同的key,如果有则覆盖;如果没有,就把需要插入的节点插入到链表尾部,如果长度超过8就转为红黑树
  5. 如果上述操作是更新操作,就更新value并返回旧的value
  6. 如果是插入操作,计算器+1,如果需要进行扩容,调用resize方法进行扩容。
7.1.2 resize方法:
final Node<K,V>[] resize() {
     Node<K,V>[] oldTab = table; //将当前hash桶数组,设为老的hash桶数组
     int oldCap = (oldTab == null) ? 0 : oldTab.length; //老的hash桶容量
     int oldThr = threshold;	// 老的扩容阀值设置
     int newCap, newThr = 0;	// 新hash桶的容量,新hash桶的扩容阀值都初始化为0
     // 接下来这段if else目的主要是给新的hash桶设置容量和扩容阙值
     // case1:
     if (oldCap > 0) {	// 如果老hash桶容量大于0,说明已经存在元素
         if (oldCap >= MAXIMUM_CAPACITY) { // 如果hash桶元素个数大于等于限定的最大容量(2的30次方)
             // 扩容阀值设置为int最大值(2的31次方 -1 ),因为oldCap再乘2就溢出了。
             threshold = Integer.MAX_VALUE;	
             return oldTab;	// 返回老的hash桶
         }

        /*
         * 如果hash桶数组元素个数在正常范围内,那么新的数组容量为老的数组容量的2倍(左移1位相当于乘以2)
         * 如果扩容之后的新容量小于最大容量  并且老的数组容量大于等于默认初始化容量(16),那么新数组的扩容阀值设置为老阀值的2倍。(老的数组容量大于16意味着:要么构造函数指定了一个大于16的初始化容量值,要么已经经历过了至少一次扩容)
         */
         else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                  oldCap >= DEFAULT_INITIAL_CAPACITY)
             newThr = oldThr << 1; // double threshold
     }

     // case2:
     // 运行到这个else if  说明老数组没有任何元素
     // 如果老数组的扩容阀值大于0,那么设置新数组的容量为该阀值
     // 这一步也就意味着构造该map的时候,指定了初始化容量。
     else if (oldThr > 0) // initial capacity was placed in threshold
         newCap = oldThr;
     else {               // zero initial threshold signifies using defaults
         // 能运行到这里的话,说明是调用无参构造函数创建的该map,并且第一次添加元素
         newCap = DEFAULT_INITIAL_CAPACITY;	// 设置新数组容量 为 16
         newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); // 设置新数组扩容阀值为 16*0.75 = 12。0.75为负载因子(当元素个数达到容量了4分之3,那么扩容)
     }

     // 如果新扩容阀值为0 (case2的情况),设置一下新的扩容阙值
     if (newThr == 0) {
         float ft = (float)newCap * loadFactor;
         newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                   (int)ft : Integer.MAX_VALUE);  // 参见:PS2
     }
     threshold = newThr; // 设置map的扩容阀值为 新的阀值
     @SuppressWarnings({"rawtypes","unchecked"})
     // 创建新的hash桶数组(对于第一次添加元素,那么这个数组就是第一个数组;对于存在oldTab的时候,那么这个数组就是要需要扩容到的新数组)
     Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
     // 将该map的table属性指向到该新hash桶数组
     table = newTab;	
     // 如果老hash桶数组不为空,说明是扩容操作,那么涉及到元素的转移操作
     if (oldTab != null) {	
         for (int j = 0; j < oldCap; ++j) { // 遍历老hash桶数组
             Node<K,V> e;
             if ((e = oldTab[j]) != null) { // 如果当前位置元素不为空,那么需要转移该元素到新数组
                 oldTab[j] = null; // 释放掉老数组对于要转移走的元素的引用(主要为了使得数组可被回收)
                 if (e.next == null) // 如果元素没有有下一个节点,说明该元素不存在hash冲突
                     // case3
                     // 把元素存储到新的数组中,存储到数组的哪个位置需要根据hash值和数组长度来进行取模
                     // hash值 % 数组长度】 = 【hash值 & 数组长度-1】
                     // 这种与运算求模的方式要求  数组长度必须是2的N次方,
                     // 但是可以通过构造函数随意指定初始化容量呀,如果指定了17,15这种,岂不是出问题了就?
                     // 没关系,最终会通过tableSizeFor方法将用户指定的转化为大于其并且最相近的2的N次方。
                     // 15 -> 16、17-> 32        
                     newTab[e.hash & (newCap - 1)] = e;

                 // 如果该元素有下一个节点,那么说明该位置上存在一个链表了(hash相同的多个元素以链表的方式存储到了老数组的这个位置上了)
                 // 例如:数组长度为16,那么hash值为1(1%16=1)的和hash值为17(17%16=1)的两个元素都是会存储在数组的第2个位置上(对应数组下标为1),
                 // 当数组扩容为32(1%32=1)时,hash值为1的还应该存储在新数组的第二个位置上,但是hash值为17(17%32=17)的就应该存储在新数组的第18个位置上了。
                 // 所以,数组扩容后,所有元素都需要重新计算在新数组中的位置。
                 
                 // 如果该节点为TreeNode类型,说明这个节点上的数据已经转为红黑树
                 else if (e instanceof TreeNode)  
                     ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);  // 此处单独展开讨论
                 // 说明该节点还没有转换为红黑树仍未链表形式
                 else { // preserve order
                     Node<K,V> loHead = null, loTail = null;  // 按命名来翻译的话,应该叫低位首尾节点
                     Node<K,V> hiHead = null, hiTail = null;  // 按命名来翻译的话,应该叫高位首尾节点
                     // 以上的低位指的是新数组的 0  到 oldCap-1 、高位指定的是oldCap 到 newCap - 1
                     Node<K,V> next;
                     // 遍历链表
                     do {  
                         next = e.next;
                         // 这一步拿元素的hash值和老数组的长度做与运算
                         // case3里曾说到,数组的长度一定是2的N次方(例如16),如果hash值和该长度做与运算,那么该hash值可参与计算的有效二进制位就是和长度二进制对等的后几位,如果结果为0,说明hash值中参与计算的对等的二进制位的最高位一定为0.
                         // 因为数组长度的二进制有效最高位是1(例如16对应的二进制是10000),
                         // 只有*..0**** 和 10000 进行与运算结果才为00000(*..表示不确定的多个二进制位)。
                         // 又因为定位下标时的取模运算是以hash值和长度减1进行与运算,所以下标 = (*..0**** & 1111) 也= (*..0**** & 11111) 。
                         // 1111是15的二进制、11111是16*2-1 也就是31的二进制(2倍扩容)。
                         // 所以该hash值再和新数组的长度取摸的话mod值也不会放生变化,也就是说该元素的在新数组的位置和在老数组的位置是相同的,所以该元素可以放置在低位链表中。
                         // 举个反推例子:
                         // 现在hash桶数组长度为l=16,l-1的二进制为1111;某个key的hash值为k=*..00101,
                         // 根据寻址的运算k & (l-1),得到的结果为5,说明该key需要被存放在table[4]中
                         // 现在hash桶长度扩容到l=32;那么l-1的二进制变成了11111,此时k & (l-1)得到的结果仍为5
                         // 而只有满足在hash桶数组长度左移一位的最高位处,该key的值为0,才能满足在扩容后与计算的结果与之前一样
                         // 否则假如说key的hash值k=*..10101,则扩容前应存放在table[4],而扩容后存放在table[21]
                         // 当key满足如上所述的条件时,(e.hash & oldCap)一定是等于0的
                         // 所以当满足(e.hash & oldCap) == 0 时,扩容前后该key在数组中位置不回放生变化
                         // 这个判断的本质时将原链表一分为二,一部分存放在原来的位置,一部分存放到原位置加上原数组长度的位置上去
                         if ((e.hash & oldCap) == 0) {  
                             // case4
                             if (loTail == null) // 如果没有尾,说明链表为空
                                 loHead = e; // 链表为空时,头节点指向该元素
                             else
                                 loTail.next = e; // 如果有尾,那么链表不为空,把该元素挂到链表的最后。
                             loTail = e; // 把尾节点设置为当前元素
                         }

                         // 如果与运算结果不为0,说明hash值大于老数组长度(例如hash值为17)
                         // 此时该元素应该放置到新数组的高位位置上
                         // 例:老数组长度16,那么新数组长度为32,hash为17的应该放置在数组的第17个位置上,也就是下标为16,那么下标为16已经属于高位了,低位是[0-15],高位是[16-31]
                         else {  // 以下逻辑同case4
                             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;
                         // 例:hash为 17 在老数组放置在0下标,在新数组放置在16下标;
                         //    hash为 18 在老数组放置在1下标,在新数组放置在17下标; 
                         newTab[j + oldCap] = hiHead;                   
                     }
                 }
             }
         }
     }
     return newTab; // 返回新数组
}

resize方法可以主要分为2部分:

  • 首先是将hash桶数组扩容,分为三种情况:
    • 桶容量大于0,说明是扩容,在原长度没有超过最大长度的情况下,将数组长度和加载因子乘以2
    • 桶容量等于0,但是扩容因子大于0,说明初始化时赋值了
    • 都为0,说明时创建新桶
  • 原hash桶中的元素转移工作,也是分为三种情况:
    • 第一种是原数组节点位置没有下一个节点,说明该位置只有这么一个节点,直接hash计算一下在新数组中的位置,并设置就行
    • 第二种是原数组节点已经转变为红黑树,这里我们暂时不讨论
    • 第三种是原数组节点为链表,这里通过(e.hash & oldCap) == 0将链表中元素分为了2类,
      • 一部分放在原节点中,
      • 一部分放在了原节点+原数组长度位置处。
7.1.3 jdk7和jdk8的区别:
  1. 数据结构的修改:数组+链表 --> 数组+链表+红黑树这个就不做细说了

  2. 初始化时,扩容阙值赋值不同:
    1.7是讲初始容量赋值给扩容阙值,而1.8是将大于等于初始容量最接近的2的幂次赋值给扩容阙值

  3. hash算法的变化:

//1.8的hash算法:
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

// 1.7的
final int hash(Object k) {
    int h = hashSeed;//默认为0
    if (0 != h && k instanceof String) {
        return sun.misc.Hashing.stringHash32((String) k);
    }

    h ^= k.hashCode();

    // This function ensures that hashCodes that differ only by
    // constant multiples at each bit position have a bounded
    // number of collisions (approximately 8 at default load factor).
    h ^= (h >>> 20) ^ (h >>> 12);
    return h ^ (h >>> 7) ^ (h >>> 4);
}
  1. 扩容方法的修改:
    1. 在判断是否扩容时,1.8是判断容量是否超过扩容阙值,而1.7是判断容量是否超过扩容阙值同时判断该节点是否为空
    2. JDK1.8中resize()方法在表为空时,创建表,在表不为空时,扩容;而JDK1.7中resize()方法负责扩容,inflateTable()负责创建表
    3. 1.8中没有区分键为null的情况,而1.7版本中对于键为null的情况调用putForNullKey()方法。但是两个版本中如果键为null,那么调用hash()方法得到的都将是0,所以键为null的元素都始终位于哈希表中第一个桶中,这一点两个版本是相同的。
    4. 当1.8中的桶中元素处于链表的情况,遍历的同时最后如果没有匹配的,直接将节点添加到链表了尾部,而1.7在遍历的同时没有添加数据,而是另外调用了addEntry()方法。
    5. addEntry中默认将新加的节点作为链表的头节点,而1.8中会将新加的结点添加到链表末尾
    6. 1.7中是通过更改hashSeed值修改节点的hash值从而达到rehash时的分散;而1.8中键的hash值不会改变,rehash时根据hash&cap==0将链表一分为二,一部分在原位置,一部分在原位置+原长度的位置
    7. 1.8rehash时保证原链表的顺序,而1.7中rehash时将改变链表的顺序
7.1.4 为什么由头插法改为尾插法:

头插法的问题:

  • 破坏了链表元素的插入顺序:由于头插法是将新插入的元素插入到链表的头部,这样就导致链表的顺序与元素插入的顺序相反,不利于一些需要按照插入顺序遍历的场景。
  • 容易引起链表环形问题:由于头插法需要修改链表头,这会导致在并发环境下,多个线程同时修改链表头,可能会引起链表环形问题,使得链表无法正确遍历或者出现死循环的情况。
  • 不利于链表长度的平衡:在长时间运行的情况下,头插法可能会导致链表的长度不平衡,即某些链表的长度会很长,而某些链表的长度很短,这会降低HashMap的性能。

7.2 HashMap的key的选取:

7.2.1 能否使用任何类作为 Map 的 key?

可以使用任何类作为 Map 的 key,然而在使用之前,需要考虑以下几点:

  1. 如果类重写了 equals() 方法,也应该重写 hashCode() 方法。
  2. 类的所有实例需要遵循与 equals() 和 hashCode() 相关的规则。
  3. 如果一个类没有使用 equals(),不应该在 hashCode() 中使用它。
  4. 用户自定义 Key 类最佳实践是使之为不可变的,这样 hashCode() 值可以被缓存起来,拥有更好的性能。不可变的类也可以确保 hashCode() 和 equals() 在未来不会改变,这样就会解决与可变相关的问题了。
7.2.2 为什么HashMap中String、Integer这样的包装类适合作为K?

答:String、Integer等包装类的特性能够保证Hash值的不可更改性和计算准确性,能够有效的减少Hash碰撞的几率

  • 都是final类型,即不可变性,保证key的不可更改性,不会存在获取hash值不同的情况
  • 内部已重写了equals()、hashCode()等方法,遵守了HashMap内部的规范(不清楚可以去上面看看putValue的过程),不容易出现Hash值计算错误的情况;
7.2.3 如果使用Object作为HashMap的Key,应该怎么办呢?

答:重写hashCode()和equals()方法

  • 重写hashCode():因为需要计算存储数据的存储位置
  • 重写equals()方法:保证key在哈希表中的唯一性

7.3 HashMap和HashTable的区别:

  1. 线程安全: HashMap 是非线程安全的,HashTable 是线程安全的;HashTable 内部的方法基本都经过 synchronized 修饰。(如果你要保证线程安全的话就使用 ConcurrentHashMap 吧!);
  2. 效率: 因为线程安全的问题,HashMap 要比 HashTable 效率高一点。另外,HashTable 基本被淘汰,不要在代码中使用它;
  3. 对Null key 和Null value的支持: hashmap中允许null的key,hashtable中不允许null的key
  4. 初始容量大小和每次扩充容量大小的不同: ①创建时如果不指定容量初始值,Hashtable 默认的初始大小为11,之后每次扩充,容量变为原来的2n+1。HashMap 默认的初始化大小为16。之后每次扩充,容量变为原来的2倍。②创建时如果给定了容量初始值,那么 Hashtable 会直接使用你给定的大小,而 HashMap 会将其扩充为2的幂次方大小。也就是说 HashMap 总是使用2的幂作为哈希表的大小,后面会介绍到为什么是2的幂次方。
  5. 底层数据结构: JDK1.8 以后的 HashMap 在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为8)时,将链表转化为红黑树,以减少搜索时间。Hashtable 没有这样的机制。
  6. 推荐使用:在 Hashtable 的类注释可以看到,Hashtable 是保留类不建议使用,推荐在单线程环境下使用 HashMap 替代,如果需要多线程使用则用 ConcurrentHashMap 替代。

7.4 TreeMap:

  • 有序的Map
  • HashMap的局限性:不具备统计性能;而通过TreeMap可以实现Entry的排序,我们可以直接获取范围内的Entry
  • HashMap的增删改查都是O(1)的开销,而TreeMap由于时基于红黑树,所示O(logn)的时间开销
  • TreeMap可以根据其键的自然顺序进行排序,或者根据创建映射时提供的 Comparator 进行排序
  • Node中维护了左子节点、右子节点和父节点
static final class Entry<K,V> implements Map.Entry<K,V> {
    K key;
    V value;
    Entry<K,V> left;	// 左子节点
    Entry<K,V> right; 	// 右子节点
    Entry<K,V> parent;	// 父节点
    boolean color = BLACK; // 颜色,黑色是true,红色是false
    // get,set,hashcode
}
  • 可以把TreeMap看成一个红黑树,不过红黑树的节点中存放的都是一个个Entry,包含了key和value,插入,删除,遍历规则都符合红黑树的规则

7.5 LinkedHashMap:

  • 使用HashMap+LinkedList来实现Map接口
  • 基于HashMap实现,还是使用HashMap的结构,在维护Entry的同时还维护了一个before和一个after的Entry来实现双向链表
  • 构造方法为LinkedHashMap(int,float,boolean)的可用来创建一个按照访问顺序迭代的LinkedHashMap,按照最少访问到最多访问的顺序链接结点,这种LinkedHashMap可用来实现LRU缓存
7.5.1 LRU缓存:

LRU即Least Recently Used,最近最少使用,也就是说,当缓存满了,会优先淘汰那些最近最不常访问的数据,最近访问的数据会被排到最后。比如:我们的链表最多只能维持100个元素,当插入第101个时,我们就需要把最老的那个元素删除。

我们要想实现一个LRU缓存,只需要继承LinkedHashMap,并重写removeEldestEntry方法。

public class LRUCacheByLinkedHashMap extends LinkedHashMap {
    private int count;

    public LRUCacheByLinkedHashMap(int count) {
        super(count, 0.75f, true);
        this.count = count;
    }

    // 判断长度是否超过限制,超过返回true,否则返回false
    @Override
    protected boolean removeEldestEntry(Map.Entry eldest) {
        return size() > count;
    }

    public static void main(String args[]) {
        LRUCacheByLinkedHashMap cache = new LRUCacheByLinkedHashMap(5);
        for (Integer i = 0; i < 5; i ++) {
            cache.put(i, i);
        }
        System.out.println("插入后:");
        System.out.println(cache);
        cache.get(2);
        System.out.println("读取2后:");
        System.out.println(cache);
        cache.put(5,5);
        System.out.println("插入5后:");
        System.out.println(cache);
    }
}

7.6 ConcurrentHashMap:

  • 内部有2个table,nextTable主要用于扩容时,并不是直接扩容,而是通过CAS
  • 整个get流程中都没有加锁,这是因为使用了volatile关键字
  • 插入通过锁定数组节点(链表或红黑树头节点)来控制并发,并通过CAS来实现数据的插入,如果插入失败,就是yield释放cpu等待重试
7.6.1 ConcurrentHashMap和Hashtble的区别:

ConcurrentHashMap 和 Hashtable 的区别主要体现在实现线程安全的方式上不同。

  • 底层数据结构
    • JDK1.7的 ConcurrentHashMap 底层采用 分段的数组+链表 实现,JDK1.8 采用的数据结构跟HashMap1.8的结构一样,数组+链表/红黑二叉树
    • Hashtable 和 JDK1.8 之前的 HashMap 的底层数据结构类似都是采用 数组+链表 的形式,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的;
  • 实现线程安全的方式(重要):
    • 在JDK1.7的时候,ConcurrentHashMap(分段锁) 对整个桶数组进行了分割分段(Segment),每一把锁只锁容器其中一部分数据,多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。(默认分配16个Segment,比Hashtable效率提高16倍。) 到了 JDK1.8 的时候已经摒弃了Segment的概念,而是直接用 Node 数组+链表+红黑树的数据结构来实现,并发控制使用 synchronized 和 CAS 来操作。(JDK1.6以后 对 synchronized锁做了很多优化) 整个看起来就像是优化过且线程安全的 HashMap,虽然在JDK1.8中还能看到 Segment 的数据结构,但是已经简化了属性,只是为了兼容旧版本;
    • Hashtable(同一把锁) :使用 synchronized 来保证线程安全,效率非常低下。当一个线程访问同步方法时,其他线程也访问同步方法,可能会进入阻塞或轮询状态,如使用 put 添加元素,另一个线程不能使用 put 添加元素,也不能使用 get,竞争会越来越激烈效率越低。

HashTable:
在这里插入图片描述
1.8之前的ConcurrentHashMap:
在这里插入图片描述
1.8及之后的:
在这里插入图片描述

7.6.2 ConcurrentHashMap底层实现:
1. JDK1.7

首先将数据分为一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据时,其他段的数据也能被其他线程访问。

在JDK1.7中,ConcurrentHashMap采用Segment + HashEntry的方式进行实现,结构如下:

一个 ConcurrentHashMap 里包含一个 Segment 数组。Segment 的结构和HashMap类似,是一种数组和链表结构,一个 Segment 包含一个 HashEntry 数组,每个 HashEntry 是一个链表结构的元素,每个 Segment 守护着一个HashEntry数组里的元素,当对 HashEntry 数组的数据进行修改时,必须首先获得对应的 Segment的锁。
在这里插入图片描述

  1. 该类包含两个静态内部类 HashEntry 和 Segment ;前者用来封装映射表的键值对,后者用来充当锁的角色;
  2. Segment 是一种可重入的锁 ReentrantLock,每个 Segment 守护一个HashEntry 数组里得元素,当对 HashEntry 数组的数据进行修改时,必须首先获得对应的 Segment 锁。
2. JDK1.8

在JDK1.8中,放弃了Segment臃肿的设计,取而代之的是采用Node + CAS + Synchronized来保证并发安全进行实现,synchronized只锁定当前链表或红黑二叉树的首节点这样只要hash不冲突,就不会产生并发,效率又提升N倍。

结构如下:

在这里插入图片描述

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值