java 集合面试专题总结

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

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

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

java 集合面试专题总结_第1张图片
容器
主要包括 Collection 和 Map 两种,Collection 存储着对象的集合,而 Map 存储着键值对(两个对象)的映射表。

Collection集合主要有List和Set两大接口

  • List:一个有序容器,元素可以重复,可以插入多个null元素,元素都有索引。常用的实现类有 ArrayList、LinkedList 和 Vector。
  1. Arraylist: Object数组,基于动态数组实现,支持随机访问。
  2. Vector: Object数组,和 ArrayList 类似,但它是线程安全的。
  3. LinkedList: 基于双向链表实现,只能顺序访问,但是可以快速地在链表中间插入和删除元素
  • Set:一个无序容器,不可以存储重复元素,只允许存入一个null元素,必须保证元素唯一性。常用实现类是 HashSet、LinkedHashSet 以及 TreeSet。
  1. HashSet:基于哈希表实现,支持快速查找,但不支持有序性操作。
  2. LinkedHashSet:具有 HashSet 的查找效率,并且内部使用双向链表维护元素的插入顺序。
  3. TreeSet:基于红黑树实现,支持有序性操作,例如根据一个范围查找元素的操作。但是查找效率不如 HashSet,HashSet 查找的时间复杂度为 O(1),TreeSet 则为 O(logN)。
  • Map:是一个键值对集合,存储键值间的映射。 Key无序,唯一;value 不要求有序,允许重复。Map没有继承于Collection接口,从Map集合中检索元素时,只要给出键对象,就会返回对应的值对象。常用实现类:HashMap、TreeMap、HashTable、LinkedHashMap、ConcurrentHashMap
  1. TreeMap:基于红黑树实现。

  2. HashMap:数组+链表组成的,数组是HashMap 的主体,链表则是主要为了解决哈希冲突。DK1.8以后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为8)时,将链表转化为红黑树,以减少搜索时间

  3. HashTable:和HashMap 类似,但它是线程安全的它是遗留类,不应该去使用它,而是使用 ConcurrentHashMap ,ConcurrentHashMap 的效率会更高,因为 ConcurrentHashMap 引入了分段锁。

  4. LinkedHashMap:使用双向链表来维护元素的顺序,顺序为插入顺序或者最近最少使用(LRU)顺序。

2. 快速失败(fail—fast)

在用迭代器遍历一个集合对象时,如果遍历过程中对集合对象的内容进行了修改(增删改),
则会抛出Concurrent Modification Exception;

机制原因:迭代器在遍历时直接访问集合中的内容,并且在遍历过程中使用一个 modCount 变量。集合在被遍历期间如果内容发生变化,就会改变modCount的值。每当迭代器使用hashNext()/next()遍历下一个元素之前,都会检测modCount变量是否为expectedmodCount值,是的话就返回遍历;否则抛出异常,终止遍历。
当多个线程对集合进行结构上的改变的操作时,有可能会产生fail-fast机制。这个机制是防止多线程并发访问list可能带来错误,所以抛出异常提醒一下。

Fail-Fast : List维护一个modCount变量,add、remove、clear等涉及了改变list元素的个数的方法都会
导致modCount的改变。
如果判断出modCount != expectedModCount, 抛出ConcurrentModificationException 异常,
从而产生fail-fast机制。

3. Iterator

Iterator 接口提供遍历任何 Collection 的接口。迭代器取代了 Java 集合框架中的 Enumeration,迭代器允许调用者在迭代过程中移除元素。

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

边遍历边修改 Collection 的正确方式是使用 Iterator.remove() 方法和倒叙边遍历边删除

Iterator<Integer> it = list.iterator();
while(it.hasNext()){
   *// do something*
   it.remove();
}

public static void remove(ArrayList<String> list){
    for(int i=list.size()-1;i>=0;i--){
        String s=list.get(i);
        if(s.equals("bb")){
            list.remove(s);
        }
    }
}

当使用 foreach(for(Integer i : list)) 语句时,会报 ConcurrentModificationException 异常。它会自动生成一个iterator 来遍历该 list,但同时该 list 正在被 Iterator.remove() 修改。Java 一般不允许一个线程在遍历 Collection 时另一个线程修改它。
for循环删除ArrayList重复元素会导致漏删情况。

4. 遍历一个 List 不同的方式?

  1. for 循环遍历,基于计数器。在集合外部维护一个计数器,然后依次读取每一个位置的元素,当读取到最后一个元素后停止。

  2. 迭代器遍历,Iterator。Iterator 是面向对象的一个设计模式,目的是屏蔽不同数据集合的特点,统一遍历集合的接口。Java 在 Collections 中支持了 Iterator 模式。

  3. foreach 循环遍历。foreach 内部也是采用了 Iterator 的方式实现,使用时不需要显式声明 Iterator 或计数器。优点是代码简洁,不易出错;缺点是只能做简单的遍历,不能在遍历过程中操作数据集合,例如删除、替换。

Java Collections 框架中提供了一个 RandomAccess 接口,用来标记 List 实现是否支持 Random Access。支持 Random Access 的列表可用 for 循环遍历,否则建议用 Iterator 或 foreach 遍历。

5. ArrayList、LinkedList、Vector

  • ArrayList
  1. 底层实现是Object数组(transient Object[] elementData),支持快速随机访问。RandomAccess 接口标识着该类支持快速随机访问。
  2. 默认长度是10:int newCapacity = oldCapacity + (oldCapacity >> 1);
  3. 扩容是变成1.5倍,扩容是创建新的数组,操作代价很高,最好在创建 ArrayList 对象时就指定大概的容量大小,减少扩容操作的次数。
    elementData = Arrays.copyOf(elementData, newCapacity)
  4. 增删操作,会造成数组元素的移动,使用System.arraycopy(),操作的时间复杂度为 O(N)。
  5. ArrayList 保存元素的数组 elementData 使用 transient 修饰,该关键字声明数组默认不会被序列化,实现了 writeObject() 和 readObject() 来控制只序列化数组中有元素填充那部分内容。
序列化时需要使用 ObjectOutputStream 的 writeObject() 将对象转换为字节流并输出。
而 writeObject() 方法在传入的对象存在 writeObject() 的时候会去反射调用该对象的
 writeObject() 来实现序列化。反序列化使用的是 ObjectInputStream 的 readObject() 方法.

ArrayList list = new ArrayList();
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream(file));
oos.writeObject(list);
  • Vector
  1. 底层实现也是Object数组,线程安全,支持随机访问
  2. 默认长度是10, capacityIncrement增长系数,如果设置,每次扩展都是增加这个数,否则扩容为2倍。
  3. Vector 是同步的,因此开销就比 ArrayList 要大,访问速度更慢。最好使用 ArrayList 而不是 Vector,因为同步操作完全可以由程序员自己来控制;
  4. 替换方案:可以使用 Collections.synchronizedList(); 得到一个线程安全的 ArrayList,也可以使用 concurrent 并发包下的 CopyOnWriteArrayList 类。

CopyOnWriteArrayList

  1. 读写分离
    写操作在一个复制的数组上进行,读操作还是在原始数组中进行,读写分离,互不影响。
    写操作需要加锁,防止并发写入时导致写入数据丢失。
    写操作结束之后需要把原始数组指向新的复制数组。
  2. 适用场景
    CopyOnWriteArrayList 在写操作的同时允许读操作,大大提高了读操作的性能,因此很适合读多写少的应用场景。
  3. 缺陷:
    内存占用:在写操作时需要复制一个新的数组,使得内存占用为原来的两倍左右;
    数据不一致:读操作不能读取实时性的数据,因为部分写操作的数据还未同步到读数组中。
    所以 CopyOnWriteArrayList 不适合内存敏感以及对实时性要求很高的场景。
  • LinkedList
  1. LinkedList 实际上是通过双向链表去实现的,内部类Node(val,next,pre)是链表的节点;
  2. 它也可以被当作堆栈、队列或双端队列进行操作
  3. 不支持随机访问,但删除、插入元素时间不受元素位置影响,近似O(1),而ArryList是O(n);

6. HashSet 的实现原理?

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

  • HashSet如何检查重复?HashSet是如何保证数据不可重复的?
    向HashSet 中add ()元素时,判断元素是否存在的依据,不仅要比较hash值,同时还要结合equles 方法比较。
    HashSet 中的add ()方法会使用HashMap 的put()方法。

HashMap 的 key 是唯一的,由源码可以看出 HashSet 添加进去的值就是作为HashMap 的key,并且在HashMap中如果K相同时,会用新的V覆盖掉旧的V,然后返回旧的V。所以不会重复( HashMap 比较key是否相等是先比较hashcode 再比较equals )。

  • hashCode()与equals()的相关规定:
  1. 如果两个对象相等,则hashcode一定也是相同的
  2. 两个对象相等,对两个equals方法返回true
  3. 两个对象有相同的hashcode值,它们也不一定是相等的

所以equals方法被覆盖过,则hashCode方法也必须被覆盖
hashCode()的默认行为是对堆上的对象产生独特值。如果没有重写hashCode(),则该class的两个对象无论如何都不会相等(即使这两个对象指向相同的数据)。

  • ==与equals的区别
    ==是指对内存地址进行比较 ,equals()是对字符串的内容进行比较
    ==指引用是否相同,equals()指的是值是否相同

7. HashMap 的实现原理?

  1. 存储结构
  • 底层是数组+链表+红黑树。内部是table数组,每个数组存放的是链表,链表的每一个节点都是型的Node节点。包含四个属性: key, value, hash 值和用于单向链表的 next。
    允许使用null值和null键,最多只允许一条记录的键为 null,允许多条记录的值为 null。此类不保证映射的顺序。

  • 默认加载因子是 0.75, 默认的初始容量是16,容量始终保持 2^n,如果size大于容量*加载因子,就进行扩容,容量扩容成两倍;

  • JDK1.8之后,当链表长度超过阈值,默认为8时,为加快检索速度,将链表转化成红黑树,提高查询效率,从原来的O(n)到O(logn)

  1. 底层实现
  • 保存数据有两种比较简单的数据结构:数组和链表。数组的特点是:寻址容易,插入和删除困难;链表的特点是:寻址困难,但插入和删除容易;所以我们将数组和链表结合在一起,发挥两者各自的优势,使用一种叫做拉链法的方式可以解决哈希冲突。
  • HashMap 通过 key 的 hashCode 经过扰动函数处理过后得到 hash 值,然后通过 (n - 1) & hash 判断当前元素存放的位置,如果当前位置存在元素的话,就判断该元素与要存入的元素的 hash 值以及 key 是否相同,如果相同的话,直接覆盖,不相同就通过拉链法解决冲突,将冲突的值加到链表中即可。
  • JDK1.8之后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为8)时,将链表转化为红黑树,以减少搜索时间。

8. JDK1.7 VS JDK1.8

java 集合面试专题总结_第2张图片
JDK 1.8 HashMap 的 hash 方法源码

    static final int hash(Object key) {
      int h;
      // key.hashCode():返回散列值也就是hashcode
      // ^ :按位异或
      // >>>:无符号右移,忽略符号位,空位都以0补齐
      //hash 函数大概的作用就是:高16bit不变,低16bit和高16bit做了一个异或,目的是减少碰撞。
      return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
  }

JDK1.7的 HashMap 的 hash 方法源码

static int hash(int h) {
    // 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);
}

简单总结一下HashMap是使用了哪些方法来有效解决哈希冲突的:

  1. 使用链地址法(使用散列表)来链接拥有相同hash值的数据;
  2. 使用2次扰动函数(hash函数)来降低哈希冲突的概率,使得数据分布更平均;
  3. 引入红黑树进一步降低遍历的时间复杂度,使得遍历更快;

9. HashMap的put方法的具体流程?

①.判断键值对数组table[i]是否为空或为null,为空则执行resize()进行扩容;

②.根据键值key计算hash值得到插入的索引i,如果table[i]==null,直接新建节点添加,转向⑥,如果table[i]不为空,转向③;

③.判断table[i]的首个元素是否和key一样,如果相同直接覆盖value,否则转向④,这里的相同指的是hashCode以及equals;

④.判断table[i] 是否为treeNode,即table[i] 是否是红黑树,如果是红黑树,则直接在树中插入键值对,否则转向⑤;

⑤.遍历table[i],判断链表长度是否大于8,大于8的话把链表转换为红黑树,在红黑树中执行插入操作,否则进行链表的插入操作;遍历过程中若发现key已经存在直接覆盖value即可;

⑥.插入成功后,判断实际存在的键值对数量size是否超多了最大容量threshold,如果超过,进行扩容。
java 集合面试专题总结_第3张图片

10. HashMap 的长度为什么是2的幂次方

为了能让 HashMap 存取高效,尽量较少碰撞,也就是要尽量把数据分配均匀。我们上面也讲到了过了,Hash 值的范围值-2147483648到2147483647(-(2 ^ 31)~(2 ^ 31 - 1)),前后加起来大概40亿的映射空间,只要哈希函数映射得比较均匀松散,一般应用是很难出现碰撞的。但问题是一个40亿长度的数组,内存是放不下的。所以这个散列值是不能直接拿来用的。用之前还要先做对数组的长度取模运算,得到的余数才能用来要存放的位置也就是对应的数组下标。

首先可能会想到采用%取余的操作来实现。而“取余(%)操作中如果除数是2的幂次则等价于与其除数减一的与(&)操作(也就是说 hash%length==hash&(length-1)的前提是 length 是2的 n 次方;)

在保证数组长度为2的幂次方的时候,使用hash()运算之后的值与运算(&)(数组长度 - 1)来获取数组下标的方式进行存储,这样一来是二进制方式比取余操作更加有效率,二来也是因为只有当数组长度为2的幂次方时,h&(length-1)才等价于h%length,三来解决了 哈希值与数组大小范围不匹配的问题;

11. HashMap 与 HashTable 有什么区别?

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

12. HashMap 和 ConcurrentHashMap 的区别

  • ConcurrentHashMap对整个桶数组进行了分割分段(Segment),然后在每一个分段上都用lock锁进行保护,相对于HashTable的synchronized锁的粒度更精细了一些,并发性能更好,而HashMap没有锁机制,不是线程安全的。(JDK1.8之后ConcurrentHashMap启用了一种全新的方式实现,利用CAS算法。)
  • JDK1.8之后,ConcurrentHashMap 抛弃了segment的概念,直接用Node数组+链表+红黑树的数据结构实现,并发控制采用CAS+synchronize;
    put的时候, 判断是否k-v为null, hash定位桶位置之后,如果为空,cas写入,否则synchronize加锁写入(更新或插入),synchronize只锁定当前链表或红黑二叉树的首节点
    如果链表长度达到8,将链表转化成红黑树;
    get的时候,没有并发控制,在链表查询的时候,会判断是红黑树还是链表
  • HashMap的键值对允许有null,但是ConCurrentHashMap都不允许。
  • HashMap多线程情况下,可能造成死循环,当扩容时,头插法+没有同步控制,很可能造成循环链表,造成死循环

13. ConcurrentHashMap 和 Hashtable 的区别?

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

  1. 底层数据结构
    JDK1.7的 ConcurrentHashMap 底层采用 分段的数组+链表 实现,JDK1.8 采用的数据结构跟HashMap1.8的结构一样,数组+链表/红黑二叉树。
    Hashtable 和 JDK1.8 之前的 HashMap 的底层数据结构类似都是采用 数组+链表 的形式,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的;
  2. 实现线程安全的方式(重要)
    ① 在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,竞争会越来越激烈效率越低。

总结:HashMap 没有考虑同步,HashTable 考虑了同步的问题。但是 HashTable 在每次同步执行时都要锁住整个结构。 ConcurrentHashMap 锁的方式是稍微细粒度的, 结合了 HashMap 和 HashTable 二者的优势。

14. ConcurrentHashMap 底层具体实现知道吗?实现原理是什么?

  • JDK1.7

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

  1. 一个 ConcurrentHashMap 里包含一个 Segment 数组。Segment 的结构和HashMap类似,是一种数组和链表结构,一个 Segment 包含一个 HashEntry 数组,每个 HashEntry 是一个链表结构的元素。
    该类包含两个静态内部类 HashEntry 和 Segment ;前者用来封装映射表的键值对,后者用来充当锁的角色;
  2. Segment 是一种可重入的锁 ReentrantLock,每个 Segment 守护一个HashEntry 数组里得元素,当对 HashEntry 数组的数据进行修改时,必须首先获得对应的 Segment 锁。
    JDK1.8
  • JDK1.8
    放弃了Segment臃肿的设计,取而代之的是采用Node + CAS + Synchronized来保证并发安全进行实现,synchronized只锁定当前链表或红黑二叉树的首节点,这样只要hash不冲突,就不会产生并发,效率又提升N倍。

① 判断存储的key、value是否为空,若为空,则抛出异常,否则,进入步骤②
② 计算key的hash值,随后进入无限循环,该无限循环可以确保成功插入数据,若table表为空或者长度为0,则初始化table表,否则,进入步骤③
③ 根据key的hash值取出table表中的结点元素,若取出的结点为空(该桶为空),则使用CAS将key、value、hash值生成的结点放入桶中。否则,进入步骤④
④ 若该结点的的hash值为MOVED,则对该桶中的结点进行转移,否则,进入步骤⑤
⑤ 对桶中的第一个结点(即table表中的结点)进行加锁,对该桶进行遍历,桶中的结点的hash值与key值与给定的hash值和key值相等,则根据标识选择是否进行更新操作(用给定的value值替换该结点的value值),若遍历完桶仍没有找到hash值与key值和指定的hash值与key值相等的结点,则直接新生一个结点并赋值为之前最后一个结点的下一个结点。进入步骤⑥
⑥ 若binCount值达到红黑树转化的阈值,则将桶中的结构转化为红黑树存储,最后,增加binCount的值。

15. comparable 和 Comparator的区别

  • comparable接口实际上是出自java.lang包 它有一个 compareTo(Object obj)方法用来排序
  • comparator接口实际上是出自 java.util 包它有一个compare(Object obj1, Object obj2)方法用来排序

在Collections工具类中有一个排序方法sort,前者可以只传一个集合,后者重载是传入集合和比较器,前者默认使用的就是Comparable中的compareTo方法,后者使用的便是我们传入的外部比较器Comparator,java的很多类已经实现了Comparable接口,比如说String,Integer等类,而我们其实也是基于这些实现了Comparator或者Comparable接口的原生类来比较我们自己的类,比如说自定义一个类User,属性有name和age,俩个user之间的比较无非就是比较name和age的大小。在设计初时有需求就选择Comparable,若后期需要扩展或增加排序需求是,再增加一个比较器Comparator。

总结一下,两种比较器Comparable和Comparator,后者相比前者有如下优点:

1、如果实现类没有实现Comparable接口,又想对两个类进行比较(或者实现类实现了Comparable接口,但是对compareTo方法内的比较算法不满意),那么可以实现Comparator接口,自定义一个比较器,写比较算法
2、实现Comparable接口的方式比实现Comparator接口的耦合性要强一些,如果要修改比较算法,要修改Comparable接口的实现类,而实现Comparator的类是在外部进行比较的,不需要对实现类有任何修改。

红黑树的特性
(1)每个节点或者是黑色,或者是红色。
(2)根节点是黑色。
(3)每个叶子节点(NIL)是黑色。 [注意:这里叶子节点,是指为空(NIL或NULL)的叶子节点!]
(4)如果一个节点是红色的,则它的子节点必须是黑色的。
(5)从一个节点到该节点的子孙节点的所有路径上包含相同数目的黑节点。(确保没有一条路径会比其他路径长出俩倍。因而,红黑树是相对是接近平衡的二叉树。)
它的时间复杂度是O(lgn),一棵含有n个节点的红黑树的高度至多为2log(n+1).

你可能感兴趣的:(java 集合面试专题总结)