Java 集合(五)| 集合面试汇总

一、HashMap面试汇总

1.谈一下HashMap的数据结构

  • 在JDK1.8中,HashMap是由数组+链表+红黑树构成
  • 当一个值中要存储到HashMap中的时候会根据Key的值计算出他的hash值 ,通过hash值确认存放到数组中的位置,如果发生了hash冲突就以链表的形式存储,当链表过长的时候就会将链表转化为红黑树
  • HashMap中的哈希桶数组(位桶数组)中存放的是Node对象,它是一个静态内部类,用来表示key-value键值对

2.hash冲突是什么,该如何避免

  • 不同的key值根据其关键码通过哈希函数的运算得到了同一个hash值(散列值),即定位到了散列表中的同一个位置,造成了hash冲突

    如何避免hash冲突有两种思路

  • 预防:比如采用好的hash算法,设计良好的扩容机制

  • 解决:采用链地址法、开放地址法等来解决已经形成的hash冲突

3.HashMap中如何设计的hash算法

  • HashMap中用的是除留余数法当作hash函数,即(哈希桶数组长度-1)&hash,其中hash指的是key经过hash()扰动函数得到的值
  • 扰动函数:将key的hashcode通过无符号右移和异或运算得到一个值,这个值比直接使用hashcode当作hash函数的输入要好,因为它极大的避免了hash冲突
static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }

4.hash()扰动函数为什么将hashcode右移16位?为什么要采用异或运算

  • 16位正好是32位hashcode的一半,自己的高16位和低16位异或,可以使hashcode的所有位都参与运算,使最终运算结果的低16位加大了随机性,而原来的高16位和移位所补的0相异或,最终的结果还是取决于高16位,因此,不会对hashcode的高16位产生影响
  • 至于为什么采用异或(^)运算,在各种0和1的运算中得到1或者0的概率都是1/2,而&(与)运算得到0的概率较大为75%,| (或)运算得到1的概率较大为75%,这个可以自行验证。因此使用异或运算时,只要32位hashcode的一位发生变化,最终的结果就会改变,尽可能的减少碰撞
  • 上面的总结下来就是,它们最终可以使哈希函数的输入更加随机不同,使得我们哈希函数的输出的范围也更加均匀,降低了哈希冲突的概率

关于hash()扰动函数具体参见:Java 集合(三)| Map和HashMap详解 | 底层结构和源码深入分析(JDK1.8)

5.为什么JDK1.8中的HashMap要使用红黑树这个数据结构来存储数据

  • 在JDK1.7中,用链表存储有一个缺点,当数据很多的时候,哈希冲突也会很多,导致链表中存储的数据可能会很长,当我们存元素或取元素的时候如果存在链表,我们就要遍历链表,而链表的硬伤就是遍历慢,因为要移动指针,所以这会严重影响HashMap的存取效率
  • 所以JDK1.8中用了红黑树来优化,当链表长度大于8的时候,会将链表转为红黑树,红黑树是二分查找,提高了查询的效率

6.为什么不直接使用红黑树,而是在达到一定的阈值后才转化

  • 因为红黑树是一种二叉平衡树,而且又是二叉平衡树的改进,主要是在增删方面,为了保持树的平衡,在增删数据后可能需要进行变色和旋转的操作,这种保持平衡是需要付出代价的,这个代价体现在红黑树节点的大小约是链表节点的两倍,但是在链表很长的情况下,该代价所损耗的资源要比遍历线性链表少,所以当链表长度大于8时,会考虑使用红黑树存储,而链表长度很短的情况下,根本用不着转化,转化了反而会使存取效率降低

7.为什么链表转化为红黑树的阈值为8,红黑树转化为链表的阈值为6

  • 由于红黑树节点的大小大约是常规链表节点的两倍,因此我们仅在容器包含足够的节点以保证使用时才使用它们转化为红黑树,而阈值为8是经过科学的分析计算了的,根据源码中的解释,理想情况下,由于存在扩容机制,我们使用随机的哈希码,节点分布在 hash 桶中的频率遵循泊松分布,按照泊松分布的公式计算,链表中节点个数为8时的概率为 0.00000006,这个概率十分小。并且链表长度到8时,红黑树此时的操作效率要高于链表,这个可根据时间复杂度公式来计算验证
  • 至于红黑树转化为链表的阈值为什么是6,中间有个差值7可以防止链表和树之间频繁的转换。假设一下,如果设计成链表个数超过8则链表转换成树结构,链表个数小于8则树结构转换成链表,如果一个HashMap不停的插入、删除元素,链表个数在8左右徘徊,就会频繁的发生树转链表、链表转树,效率会很低

8.HashMap中哈希表的长度为什么要设计成2的幂次方

  • 一是当哈希数组的长度为2n时,哈希函数中的取模运算可以转化成位运算:(n-1)&hash(其中,n为哈希表长度,hash为hash()计算结果),提高了运算效率
  • 二是在保证了哈希表长度为2n时,在HashMap 扩容后,同一个索引位置的节点重新hash分布后,最终的索引只会分布在新哈希表的两个位置:“原索引位置” 和 “(原索引 + 旧哈希表容量)位置”
  • 三是在进行哈希函数运算时,如果长度为2n,则长度-1的二进制必定是全1的形式,这样可以保证hash()值所有位都能参与位运算,而如果不是2n,那么其长度-1转化为二进制时其末尾一位必定是0,那么hash()值的最后一位就失去了意义,因为任何数和0相与(&),其结果都会是0,这时会增大hash冲突发生的概率

第二条具体原因参见:Java 集合(三)| Map和HashMap详解 | 底层结构和源码深入分析(JDK1.8)

9.HashMap中的key和value可以为null吗

  • 都允许为null,因为当key为null时hash()方法得到的结果永远为0,经过hash函数定位到数组中位置也是固定的,所以只能有一个key值为null,当多个key的值为null同样会触发覆盖操作

10.除了key和value能为null,HashMap还有什么特点

  • 线程不安全:没有加同步锁,多线程下建议用ConcurrentHashMap
  • 无序:因为是根据key的hash算法来计算在哈希表中的位置,是随机无序的
  • 存储位置随时间变化:当触发了扩容机制时,就会有节点的重hash分布,使得元素的存储位置会随着容量的不断增长而变化

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

  • final类型,不可变,保证了插入和获取时都是同一个hash码,定位到同一个位置
  • String内部会维护一个hash缓存,在第一次使用时才会计算,从而避免每次计算
  • 它们都重写了equals和hashCode方法,保证了不同的对象,它们的hashcode也是不同的,降低了hash冲突发生的概率

不完美、不遵守规范的重写equals和hashCode方法,可能会造成不同的对象的hashcode相同,这会使hash冲突加剧,影响HashMap存取效率和正确性

12.为什么重写了equasl方法还要重写hashCode方法

  • hashcode是为hash存储的系统如HashMap服务的,在HashMap中,通过一个key的hashCode经过一系列运算来决定该key在哈希表中的位置,如果两个不equals的对象有着相同的hashcode,那么就会定位到哈希表的同一位置,造成hash冲突,当数据量多的情况下,会严重影响存取效率
  • 所以java有规定,两个equals的对象必须有相同的hashcode,而两个不equals的对象不要求必须产生不同的hashcode,但是我们应当知道,不equals却有相同的hashcode会降低哈希表的性能,所以我们应当保证重写equals方法有必要重写hashCode方法,使其满足java的规范,降低hash冲突

关于此问题详细参考:Java 中hashCode() 、equals() 和==深入解析

13.说一说HashMap、HashTable、TreeMap、LinkedHashMap的关系

这4个都是Map大家族的,先看一下他们的继承图解:
在这里插入图片描述

HashTable

  • HashTable是JDK1.0提供的,与Vector、Enumeration属于最早的一批动态数组的实现类,后来为了将其继续保存下来,所以让其多实现了一个Map接口
  • 关于他和HashMap的区别:HashTable是线程安全的,加了同步锁,但key和value不能为null。而且HasTable继承自Dictionary类,HashMap继承自AbstractMap类
  • 现在理论上很少使用

LinkedHashMap

  • HashMap虽然是Map集合常用的一个子类,但其本身保存的数据是无序的,如果希望Map集合中保存的顺序为其添加顺序,那么就可以考虑使用LinkedHashMap
  • 通过head和tail来维护有序双向链表,通过Entry的after和before来属性维护节点顺序
  • 它继承了HashMap

TreeMap

  • 通过实现Comparator实现自定义顺序的Map,如果没有指定Comparator,则会按key的升序排序,如果key没有实现Comparable接口,则会抛出异常
  • 当我们需要自定义排序时可以使用此Map

HashMap

  • 最常用的Map结构
  • 是LinkedHashMap的父类
  • 无序、非线程安全、key-value允许为null

14.与JDK1.7相比,JDK1.8对HashMap作了哪些优化

  • 底层数据结构从“数组+链表”改成“数组+链表(+红黑树)”,主要是当hash 冲突较严重时,优化了链表过长的查找性能:O(n) -> O(logn)
  • 插入方式从“头插法”改成“尾插法”,避免了JDK1.7在并发下的死循环
  • 扩容时计算节点在新表的索引位置方式从“h & (length-1)”改成“hash & oldCap”,更加简洁巧妙
  • hash()方法的计算方式上也优化了:JDK1.7进行了多次扰动处理:用了多次移位位运算和异或(看起来很复杂,然而只是为了增大随机性),而JDK1.8只用了2次扰动处理:1次移位运算+1次异或。
// JDK 1.7.0
static int hash(int h) {
    h ^= (h >>> 20) ^ (h >>> 12);
    return h ^ (h >>> 7) ^ (h >>> 4);
}
// JDK 1.8.0_191
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
  • 优化了table初始化容量的计算方式(保证容量一定是2的幂次方):JDK1.7是从1开始不断向左进行移位运算,直到找到大于等于入参容量的值;新的方式则是通过“5个移位+或等于运算”来计算。
// JDK 1.7.0
public HashMap(int initialCapacity, float loadFactor) {
    // 省略
    // Find a power of 2 >= initialCapacity
    int capacity = 1;
    while (capacity < initialCapacity)
        capacity <<= 1;
    // ... 省略
}
// JDK 1.8.0_191
static final int tableSizeFor(int cap) {
    int n = cap - 1;
    n |= n >>> 1;
    n |= n >>> 2;
    n |= n >>> 4;
    n |= n >>> 8;
    n |= n >>> 16;
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

15.HashMap的扩容机制是怎样的?JDK1.7和JDK1.8有什么不同

  • JDK1.7的扩容条件是:当哈希桶数组的长度大于扩容阈值当前key值定位到的位置存在hash冲突,就会发生扩容
void addEntry(int hash, K key, V value, int bucketIndex) {
     //数组长度大于阈值且存在哈希冲突(即当前数组下标有元素),就将数组扩容至2倍
        if ((size >= threshold) && (null != table[bucketIndex])) {
            resize(2 * table.length);
            hash = (null != key) ? hash(key) : 0;
            bucketIndex = indexFor(hash, table.length);
        }
        createEntry(hash, key, value, bucketIndex);
    }
  • JDK1.8的扩容条件是:当哈希桶数组的长度大于扩容阈值链表转红黑树时当前数组的长度小于64时就会发生扩容
//数组长度大于阈值,就扩容
if (++size > threshold)
      resize();

//链表转为红黑树时,若此时数组长度小于64,扩容数组
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
      resize();

16.HashMap和HashTable有什么区别

  • HashTable加了同步锁,是线程安全的,HashMap非线程安全,所以效率相对要高些
  • HashTable的键和值均不能为null,HashMap的键和值均可以为null

二、List面试汇总

1.ArrayList和LinkedList的区别和适用场景

  • ArrayList内部用的动态数组实现,LinkedList内部用的双向循环链表实现,所以他们的使用场景是不同的
  • 对于元素的随机访问,ArrayList优于LinkedList,因为LinkedList要移动指针;对于元素要频繁新增和删除,LinkedList要优于ArrayList,因为ArrayList要移动数据
  • 对 ArrayList 和 LinkedList 而言,在列表末尾增加一个元素所花的开销都是固定的。对 ArrayList 而言,主要是在内部数组中增加一项,指向所添加的元素,偶尔可能会导致对数组重新进行分配;而对 LinkedList 而言,这个开销是统一的,分配一个内部 Entry 对象
  • 在 ArrayList 的中间插入或删除一个元素意味着这个列表中剩余的元素都会被移动;而在 LinkedList 的中间插入或删除一个元素的开销是固定的
  • LinkedList 不支持高效的随机元素访问。
  • ArrayList 的空间浪费主要体现在在 list 列表的结尾预留一定的容量空间(扩容机制决定),而 LinkedList 的空间花费则体现在它的每一个元素都需要消耗相当的空间。
  • 可以这样说:当操作是在一列数据的后面添加数据而不是在前面或中间,并且需要随机地访问其中的元素时,使用ArrayList 会提供比较好的性能;当你的操作是在一列数据的前面或中间添加或删除数据,并且按照顺序访问其中的元素时,就应该使用LinkedList了。

2.ArrayList自动扩容流程是怎样的

  • 当我们在add元素时,当容量不足时,便会进行自动扩容
  • 首先看add元素后,ArrayList现在的长度是否超过了现在的最大容量
  • 超过了便会触发扩容,一般情况下会将新容量设置为旧容量的1.5倍
  • 如果扩容后的新容量还是小于所需容量,那么就会将新容量设置为所需的最小容量
  • 如果扩容后的新容量大于MAX_ARRAY_SIZE,那么就将所需的最小容量和MAX_ARRAY_SIZE比较,如果小于,则新容量设置为MAX_ARRAY_SIZE,否则新容量设置为Integer.MAX_VALUE(其中,MAX_ARRAY_SIZE=Integer.MAX_VALUE-8)

3.ArrayList如何删除元素的

  • ArrayList的删除有根据下标删除和根据指定元素删除两个方法,但第二种方法最终还是根据下标删除
  • 首先根据要删除元素的下标确定要移动的元素个数,如果为0说明直接删除末尾,如果大于0,则要调用 System.arraycopy()方法
  • System.arraycopy()方法是数组的拷贝,这里用作删除的原理是将待删除元素后面的所有元素向前移动一位,使其覆盖待删除元素,最后将最后一位元素置为null,size数减一,删除完成

下面的图帮助理解一下:
在这里插入图片描述

4.ArrayList的默认初始化容量是多少?如何进行的初始化

  • 当我们构造ArrayList对象没有指定容量大小时(即调用无参构造),就会设置默认的初始化容量为10,但此时构造出的ArrayList的容量还是0,只有当我们第一次调用add方法时,会进行扩容处理,这时才会将ArrayList的容量设置为10
  • 当我们构造ArrayList对象指定容量大小时,且容量大于0,那么就会生成指定容量大小的ArrayList
  • 当我们构造ArrayList对象指定容量大小时,且容量等于0,那么会创建空容量的ArrayList
  • 当我们构造ArrayList对象指定容量大小时,且容量小于0,会报异常

5.ArrayList是线程安全的吗

  • ArrayList不是线程安全的,只能用在单线程环境下
  • 多线程环境下可以考虑用Collections.synchronizedList(List l)函数返回一个线程安全的ArrayList类,也可以使用concurrent并发包下的CopyOnWriteArrayList类

三、补充

1.List 和 Map、Set 的区别

  • 结构特点:List 和 Set 是存储单列数据的集合,Map 是存储键和值这样的双列数据的集合;List 中存储的数据是有顺序,并且允许重复;Map 中存储的数据是没有顺序的,其键是不能重复的,它的值是可以有重复的,Set 中存储的数据是无序的,且不允许有重复,但元素在集合中的位置由元素的 hashCode 决定,位置是固定的(Set 集合根据 hashCode 来进行数据的存储,所以位置是固定的,但是位置不是用户可以控制的,所以对于用户来说 Set 中的元素还是无序的);
  • 实现类:List 接口下的实现类(LinkedList:基于链表实现,链表内存是散乱的,每一个元素存储本身内存地址的同时还存储下一个元素的地址。链表增删快,查找慢;ArrayList:基于数组实现,非线程安全的,效率高,便于索引,但不便于插入删除;Vector:基于数组实现,线程安全的,效率低)。Map 接口下的实现类(HashMap:基于 hash 表的 Map 接口实现,非线程安全,高效,支持 null 值和 null 键;Hashtable:线程安全,低效,不支持 null 值和 null 键;LinkedHashMap:是HashMap 的一个子类,保存了记录的插入顺序;SortedMap 接口:TreeMap,能够把它保存的记录根据键排序,默认是键值的升序排序)。Set 接口下的实现类(HashSet:底层是由 HashMap 实现,不允许集合中有重复的值,使用该方式时需要重写 equals()和 hashCode()方法;LinkedHashSet继承与 HashSet,同时又基于LinkedHashMap 来进行实现,底层使用的是LinkedHashMp)。
  • 区别:List集合中对象按照索引位置排序,可以有重复对象,允许按照对象在集合中的索引位置检索对象,例如通过list.get(i)方法来获取集合中的元素;Map中的每一个元素包含一个键和一个值,成对出现,键对象不可以重复,值对象可以重复;Set集合中的对象不按照特定的方式排序,并且没有重复对象,但它的实现类能对集合中的对象按照特定的方式排序,例如 TreeSet类,可以按照默认顺序,也可以通过实现 java.util.Comparator接口来自定义排序方式。

2.并发集合和普通集合的区别

  • 并发集合常见的有ConcurrentHashMap、ConcurrentLinkedQueue、ConcurrentLinkedDeque等。并发集合位于java.util.concurrent包下,是jdk1.5之后才有的,在 java 中有普通集合、同步(线程安全)的集合、并发集合。
  • 普通集合通常性能最高,但是不保证多线程的安全性和并发的可靠性。线程安全集合仅仅是给集合添加了 synchronized 同步锁,严重牺牲了性能,而且对并发的效率就更低了,并发集合则通过复杂的策略不仅保证了多线程的安全又提高的并发时的效率。

并发集合拓展:ConcurrentHashMap 是线程安全的 HashMap 的实现,默认构造同样有 initialCapacity 和 loadFactor 属性, 不过还多了一个 concurrencyLevel 属性,三属性默认值分别为 16、0.75 及 16。其内部使用锁分段技术,维持这锁Segment 的数组,在 Segment 数组中又存放着 Entity[]数组,内部 hash 算法将数据较均匀分布在不同锁中。put 操作:并没有在此方法上加上 synchronized,首先对 key.hashcode 进行 hash 操作,得到 key 的 hash 值。hash 操作的算法和map 也不同,根据此hash 值计算并获取其对应的数组中的Segment 对象(继承自ReentrantLock),接着调用此 Segment 对象的 put 方法来完成当前操作。ConcurrentHashMap 基于 concurrencyLevel 划分出了多个 Segment 来对 key-value 进行存储,从而避免每次 put 操作都得锁住整个数组。在默认的情况下,最佳情况时可允许 16 个线程并发无阻塞的操作集合对象,尽可能地减少并发时的阻塞现象。get(key)首先对 key.hashCode 进行 hash 操作,基于其值找到对应的 Segment 对象,调用其 get 方法完成当前操作。而 Segment 的 get 操作首先通过 hash 值和对象数组大小减1的值进行按位与操作来获取数组上对应位置的HashEntry。在这个步骤中,可能会因为对象数组大小的改变,以及数组上对应位置的 HashEntry 产生不一致性,那么 ConcurrentHashMap 是如何保证的?对象数组大小的改变只有在 put 操作时有可能发生,由于 HashEntry 对象数组对应的变量是 volatile 类型的,因此可以保证如 HashEntry 对象数组大小发生改变,读操作可看到最新的对象数组大小。在获取到了 HashEntry 对象后,怎么能保证它及其 next 属性构成的链表上的对象不会改变呢?这点ConcurrentHashMap 采用了一个简单的方式,即 HashEntry 对象中的 hash、key、next 属性都是 final 的,这也就意味着没办法插入一个 HashEntry 对象到基于 next 属性构成的链表中间或末尾。这样就可以保证当获取到 HashEntry 对象后,其基于 next 属性构建的链表是不会发生变化的。ConcurrentHashMap 默认情况下采用将数据分为 16 个段进行存储,并且 16 个段分别持有各自不同的锁Segment,锁仅用于 put 和 remove 等改变集合对象的操作,基于 volatile 及 HashEntry 链表的不变性实现了读取的不加锁。这些方式使得 ConcurrentHashMap 能够保持极好的并发支持,尤其是对于读远比插入和删除频繁的 Map 而言,而它采用的这些方法也可谓是对于 Java 内存模型、并发机制深刻掌握的体现。

3.关于fast-fail机制

“fast-fail”的解释:

In systems design, a fail-fast system is one which immediately reports at its interface any condition that is likely to indicate a failure. Fail-fast systems are usually designed to stop normal operation rather than attempt to continue a possibly flawed process. Such designs often check the system’s state at several points in an operation, so any failures can be detected early. The responsibility of a fail-fast module is detecting errors, then letting the next-highest level of the system handle them.
即:在系统设计中,快速失效系统一种可以立即报告任何可能表明故障的情况的系统。快速失效系统通常设计用于停止正常操作,而不是试图继续可能存在缺陷的过程。这种设计通常会在操作中的多个点检查系统的状态,因此可以及早检测到任何故障。快速失败模块的职责是检测错误,然后让系统的下一个最高级别处理错误。

  • 在Java集合类中很多地方都用到了该机制进行设计,一旦使用不当,触发fail-fast机制设计的代码,就会发生非预期情况。我们通常说的Java中的fail-fast机制,默认指的是Java集合的一种错误检测机制。当多个线程对部分集合进行结构上的改变的操作时,有可能会触发该机制时,之后就会抛出并发修改异常ConcurrentModificationException.当然如果不在多线程环境下,如果在foreach遍历的时候使用add/remove方法,也可能会抛出该异常
  • 在增强for循环中,集合遍历是通过iterator进行的,但是元素的add/remove却是直接使用的集合类自己的方法。这就导致iterator在遍历的时候,会发现有一个元素在自己不知不觉的情况下就被删除/添加了,就会抛出一个异常,用来提示可能发生了并发修改

当调用Iterator自身的remove方法时,不会触发fast-fail,具体参见:Java 集合(四)| 各种常用集合类型的遍历输出 | Iterator接口讲解

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值