02_java容器面试

在这里插入图片描述

Java容器面试题

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

注:图中只列举了主要的继承派⽣关系,并没有列举所有关系。比如省略了 AbstractList ,NavigableSet等抽象类以及其他的⼀些辅助类

集合

1. 什么是集合?

在Java中,集合(Collection)是一种用于存储和操作一组对象的数据结构。Java提供了多个集合类,位于java.util包中,用于满足不同的需求和场景。
常见的集合类包括以下几种:

  1. List(列表):List是有序的集合,可以包含重复元素。常见的List实现类有ArrayList和LinkedList。——单列集合
  2. Set(集):Set是无序的集合,不允许包含重复元素。常见的Set实现类有HashSet和TreeSet。——单列集合
  3. Map(映射):Map是一种键值对(key-value)的集合,每个键对应一个值。常见的Map实现类有HashMap和TreeMap。——双列集合

这些集合类提供了一系列方法用于添加、删除、查找和操作集合中的元素。例如,可以使用add()方法向集合中添加元素,使用remove()方法删除元素,使用contains()方法检查元素是否存在等。

Java还提供了集合框架(Collection Framework)来统一和标准化集合的使用方式。集合框架包括接口、实现类和算法等,使得集合的操作更加方便和高效。集合框架中的常见接口包括Collection接口、List接口、Set接口、Map接口等。

使用集合类可以更加灵活地管理和操作数据,适用于各种场景,如数据存储、查找、排序、过滤等。它们是Java中非常重要和常用的工具。

2. 如何选用集合?

主要根据集合的特点来选用

  • 需要根据键值获取到元素值时就选用 Map 接口下的集合
    • 需要排序时选择 TreeMap
    • 不需要排序时就选择 HashMap
    • 需要保证线程安全就选用ConcurrentHashMap 。
  • 当我们只需要存放元素值时,就选择实现 Collection 接口的集合
    • 需要保证元素唯⼀时选择实现Set 接口的集合比如 TreeSet 或 HashSet
    • 不需要就选择实现 List 接口的比如 ArrayList或LinkedList ,然后再根据实现这些接口的集合的特点来选用

3. 为什么要使用集合?

当我们需要保存⼀组类型相同的数据的时候,我们应该是用一个容器来保存,这个容器就是数组,但是,使⽤数组存储对象具有⼀定的弊端,因为我们在实际开发中,存储的数据的类型是多种多样的,于是,就出现了“集合”,集合同样也是用来存储多个数据的。

  • 数组的缺点是⼀旦声明之后,长度就不可变了;
  • 同时,声明数组时的数据类型也决定了该数组存储的数据的类型;
  • 而且,数组存储的数据是有序的、可重复的,特点单⼀

但是集合

  • 提高了数据存储的灵活性
  • Java 集合不仅可以⽤来存储不同类型不同数量的对象
  • 还可以保存具有映射关系的数据。

4.集合的特点

  1. 动态大小:Java集合可以根据需要自动调整大小。
  2. 泛型支持:Java集合框架引入了泛型,可以指定集合中存储的元素类型,以提高类型安全性和代码的可读性。
  3. 高级操作和算法:Java集合框架提供了丰富的操作和算法,例如搜索、排序、过滤、迭代和转换等。
  4. 接口和实现类:Java集合框架提供了一组接口(Collection、List、Set、Map等)和实现类(如ArrayList、HashSet、HashMap等)
  5. 迭代和遍历:Java集合框架提供了迭代器(Iterator)和增强型for循环等遍历方式,可以方便地遍历集合中的元素。迭代器提供了在集合中逐个访问元素的能力,并且允许在遍历过程中进行元素的删除操作。
  6. 可以存储不同类型的元素:Java集合允许存储不同类型的元素,可以混合存储整数、字符串、对象等各种类型。

5.集合和数组的区别?

  1. 大小的固定性:数组在创建时需要指定固定的大小,而且大小在运行时无法改变。而集合的大小是动态的,可以根据需要自动扩展或缩小。
  2. 元素类型的灵活性:数组可以存储任意类型的元素,可以是基本数据类型(如int、double)或引用类型(如对象),但在创建数组时需要指定元素类型。集合框架引入了泛型的概念,可以指定集合存储的元素类型,从而提供类型安全检查和更好的代码可读性。
  3. 存储数据的类型区别:数组可以存储基本数据类型,也可以存储引用类型;集合只能存储引用类型(如存储int,它会自动装箱成Integer)
  4. 增删元素的方便性:数组的大小固定,如果要增加或删除元素,需要手动操作数组元素的移动和重新分配空间。而集合提供了丰富的方法和操作,方便地进行元素的增删操作,无需关心底层实现细节。
  5. 遍历的方式:数组可以通过下标访问和遍历元素,使用循环结构可以依次访问每个元素。集合提供了迭代器(Iterator)、增强型循环(Enhanced For Loop)等遍历方式,可以方便地遍历集合中的元素。
  6. 功能和操作的丰富性:集合框架提供了丰富的操作方法和算法,如排序、查找、过滤等。而数组的功能相对较为有限,需要自己实现相应的算法或使用辅助类来完成特定操作。

6.List,Set,Map, Queue的区别?List、Set、Map 是否继承自 Collection 接口?List、Map、Set 三个接口存取元素时,各有什么特点?

1.List (常用于对付顺序): 存储的元素是有序的、可重复的
2. Set:(注重独一无二的性质): 存储的元素是无序的、不可重复的。
3. Queue (实现排队功能的叫号机): 按特定的排队规则来确定先后顺序,存储的元素是有序的、可重复的。
4. Map:Map (用 key 来搜索的专家): 使⽤键值对(key-value)存储,key 是无序的、不可重复的,value 是无序的、可重复的,每个键最多映射到⼀个值。

关于继承关系,List和Set都继承自Collection接口,而Map接口则是独立的。

在存取元素方面,各接口具有以下特点:

  • List:List接口通过索引(下标)来存取元素,可以根据索引访问和修改元素。它支持重复元素,允许在任意位置插入和删除元素。常用的方法包括get()、set()、add()、remove()等。
  • Set:Set接口不提供直接的索引访问方式,元素的存取是基于元素的唯一性。可以使用add()方法添加元素,contains()方法判断元素是否存在,remove()方法删除元素。Set中不允许包含重复元素。
  • Map:Map接口通过键值对的方式存取元素,使用键来访问和操作值。可以使用put()方法添加键值对,get()方法根据键获取对应的值,remove()方法根据键删除键值对。键是唯一的,值可以重复。

7.集合框架底层数据结构

Java集合框架中的不同实现类使用不同的底层数据结构来支持其功能和性能需求。

Collection 接口下面的集合

List

  • ArrayList : Object[] 数组
  • Vector : Object[] 数组
  • LinkedList : 双向链表(JDK1.6 之前为循环链表,JDK1.7 取消了循环)

Set

  • HashSet (无序,唯⼀): 基于 HashMap 实现的,底层采用HashMap 来保存元素
  • LinkedHashSet : LinkedHashSet 是 HashSet 的子类,并且其内部是通过 LinkedHashMap 来实现的。有点类似于我们之前说的 LinkedHashMap 其内部是基于 HashMap 实现⼀样,不过还是有⼀点点区别的
  • TreeSet (有序,唯⼀): 红黑树(自平衡的排序⼆叉树)

Queue

  • PriorityQueue : Object[] 数组来实现⼆叉堆
  • ArrayQueue : Object[] 数组 + 双指针

Map

  • HashMap : JDK1.8 之前 HashMap 由数组+链表组成的,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的(“拉链法”解决冲突)。JDK1.8 以后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度⼩于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间。
  • LinkedHashMap : LinkedHashMap 继承自HashMap ,所以它的底层仍然是基于拉链式散列结构即由数组和链表或红黑树组成。另外, LinkedHashMap 在上⾯结构的基础上,增加了⼀条双向链表,使得上面的结构可以保持键值对的插入顺序。同时通过对链表进行相应的操作,实现了访问顺序相关逻辑。
  • Hashtable : 数组+链表组成的,数组是 Hashtable 的主体,链表则是主要为了解决哈希冲突存在的
  • TreeMap : 红黑树(自平衡的排序⼆叉树)

以下是常见的集合实现类及其底层数据结构:

  1. ArrayList:使用动态数组(Array)作为底层数据结构。它通过数组实现了有序的、可重复的集合。
  2. LinkedList:使用双向链表(Doubly Linked List)作为底层数据结构。它通过链表实现了有序的、可重复的集合。
  3. HashSet:使用哈希表(Hash Table)作为底层数据结构。它提供了无序的、不可重复的集合。
  4. TreeSet:使用红黑树(Red-Black Tree)作为底层数据结构。它提供了有序的、不可重复的集合。
  5. HashMap:使用哈希表作为底层数据结构,同时使用链表来解决哈希冲突。它提供了键值对的映射关系,键是唯一的。
  6. TreeMap:使用红黑树作为底层数据结构。它提供了基于键的有序映射关系,键是唯一的。

这些底层数据结构在不同的集合实现中具有不同的特点和性能表现。例如,ArrayList和LinkedList都是有序集合,但ArrayList适用于频繁随机访问元素,而LinkedList适用于频繁插入和删除元素。HashSet和TreeSet都是不可重复集合,但HashSet在插入和查找元素的性能上更优,而TreeSet提供了有序的集合。HashMap和TreeMap都是键值对的映射集合,但HashMap提供了更高的插入、查找和删除元素的性能,而TreeMap提供了基于键的有序映射。

8.哪些集合类是线程安全的?

在Java的集合框架中,大多数集合类都不是线程安全的。这意味着在多线程环境下并发地进行读取、写入或修改操作可能导致不一致的结果或数据损坏。

然而,Java提供了一些线程安全的集合类,它们是在多线程环境下安全使用的。以下是一些常见的线程安全的集合类:

  1. ConcurrentHashMap:是线程安全的哈希表实现的Map,支持高并发操作。
  2. ConcurrentSkipListMap:是线程安全的基于跳表实现的有序Map。
  3. CopyOnWriteArrayList:是线程安全的动态数组实现的List。它在进行写操作时会创建一个新的副本,因此对原始数组的修改不会影响正在进行的迭代操作。
  4. CopyOnWriteArraySet:是线程安全的基于CopyOnWriteArrayList的Set实现。
  5. ConcurrentLinkedQueue:是线程安全的无界非阻塞队列实现。
  6. ConcurrentLinkedDeque:是线程安全的无界双端队列实现。

除了以上列出的集合类,还有一些其他的线程安全集合类,如BlockingQueue、BlockingDeque等,它们提供了阻塞操作,可以更好地支持生产者-消费者模型或其他并发场景。

使用这些线程安全的集合类可以在多线程环境下安全地进行并发操作,避免数据不一致或损坏的问题。然而,需要注意的是,虽然这些集合类本身是线程安全的,但对于一些复合操作仍然需要额外的同步措施来确保一致性。

List、Set

9.什么是fastfail机制?

“fast-fail”(快速失败)是Java集合框架中的一种机制,用于在多线程并发修改集合时快速检测到并发修改,并在发现并发修改时立即抛出异常。

当一个线程在迭代(遍历)集合时,如果其他线程并发地对集合进行了修改(增加、删除或修改元素),"fast-fail"机制会迅速检测到这个并发修改,并抛出ConcurrentModificationException异常。

这种机制的设计初衷是为了提供更好的错误检测和数据一致性保证。它帮助开发人员在多线程环境下及早发现并发修改导致的潜在问题,避免在不一致的数据状态下继续操作,从而避免出现不可预测的结果。

需要注意的是,"fast-fail"机制并不能保证并发修改一定会抛出异常。它只是在检测到并发修改时尽早抛出异常的一种机制,但并不提供任何线程安全保证。因此,在并发环境下,仍然需要额外的同步措施来确保线程安全性,如使用线程安全的集合类或显式的同步机制(例如使用锁)等。

10.什么是Iterator迭代器?

在Java中,Iterator(迭代器)(也是一种设计模式)是一种用于遍历集合(Collection)中元素的接口。它提供了一种统一的方式来访问集合中的元素,而不依赖于具体集合的内部实现细节。

Iterator接口定义了一些方法来支持集合的遍历操作,包括:

  1. boolean hasNext():检查集合中是否还有下一个元素可以遍历,如果有则返回true,否则返回false。
  2. E next():返回集合中的下一个元素,并将迭代器的指针移动到下一个位置。
  3. void remove():从集合中移除上一次调用next()方法返回的元素。注意,此方法在调用之前必须先调用next()方法。

使用Iterator,可以通过迭代器对象依次遍历集合中的元素,无需关心集合的具体实现方式。迭代器提供了一种安全的遍历方式,可以在遍历过程中对集合进行修改,而不会抛出ConcurrentModificationException异常(前提是使用迭代器自身的remove()方法进行修改)。

Iterator的使用示例:

//高并发推荐
List<String> list = new ArrayList<>();
list.add("Apple");
list.add("Banana");
list.add("Orange");

Iterator<String> iterator = list.iterator();
while (iterator.hasNext()) {
    String element = iterator.next();
    System.out.println(element);
}

在上述示例中,通过调用list的iterator()方法获取迭代器对象,然后使用hasNext()next()方法进行遍历。迭代器会依次返回集合中的每个元素,直到没有元素可遍历为止。

迭代器提供了一种通用且安全的方式来遍历集合,无论是List、Set还是Map等集合类型都可以使用迭代器进行遍历操作。

11.迭代器如何使用?有什么特点?

迭代器(Iterator)在Java中用于遍历集合(Collection)中的元素。使用迭代器可以按顺序访问集合中的每个元素,而不需要了解集合的具体实现细节。下面是迭代器的使用方式和特点:

  1. 使用迭代器步骤:

    • 通过集合的iterator()方法获取迭代器对象。
    • 使用hasNext()方法检查是否还有下一个元素可以遍历。
    • 使用next()方法获取当前元素,并将迭代器指针移动到下一个位置。
    • 可选地,使用remove()方法从集合中移除上一次调用next()方法返回的元素。
  2. 特点:

    • 一次性遍历:迭代器是一次性的,遍历过程中不允许在集合中添加或删除元素(除非使用迭代器自身的remove()方法)。
    • 安全的修改:通过迭代器的remove()方法可以安全地从集合中移除元素,而不会抛出并发修改异常。
    • 有序性:迭代器按照集合中元素的顺序逐个返回,保持了集合的有序性。
    • 失效检测:通过迭代器,可以及时检测到集合在迭代过程中的并发修改,避免出现不一致的数据状态。
  3. 示例代码:

    List<String> list = new ArrayList<>();
    list.add("Apple");
    list.add("Banana");
    list.add("Orange");
    
    Iterator<String> iterator = list.iterator();
    while (iterator.hasNext()) {
        String element = iterator.next();
        System.out.println(element);
    }
    

    在上述示例中,首先通过listiterator()方法获取迭代器对象,然后使用hasNext()next()方法进行遍历。迭代器会依次返回集合中的每个元素,直到没有元素可遍历为止。

12.如何边遍历边移除 Collection 中的元素?

在遍历过程中移除集合(Collection)中的元素需要使用迭代器的remove()方法来完成。迭代器的remove()方法可以安全地从集合中移除上一次调用next()方法返回的元素。以下是边遍历边移除集合元素的示例代码:

List<String> list = new ArrayList<>();
list.add("Apple");
list.add("Banana");
list.add("Orange");

Iterator<String> iterator = list.iterator();
while (iterator.hasNext()) {
    String element = iterator.next();
    if (element.equals("Banana")) {
        iterator.remove(); // 移除当前元素
    }
}

System.out.println(list);

在上述示例中,我们遍历了list集合并使用迭代器的next()方法获取当前元素。如果当前元素是"Banana",我们调用迭代器的remove()方法将该元素从集合中移除。最后,输出集合的内容,可以看到"Banana"已经被移除。

需要注意的是,如果在遍历过程中直接使用集合的remove()方法来移除元素,会导致ConcurrentModificationException异常。这是因为直接修改集合会破坏迭代器的状态。因此,应始终使用迭代器的remove()方法来安全地移除元素。

迭代器提供了一种可靠的方式来边遍历边移除集合中的元素,同时保持迭代器和集合的一致性。这种方式特别适用于需要根据条件动态删除元素的情况。

13. Iterator 和 ListIterator 有什么区别?

  • Iterator和ListIterator是Java集合框架中两种不同的迭代器接口,它们在功能和使用上有一些区别。

    1. Iterator(迭代器):
      • Iterator是最基本的迭代器接口,可以用于遍历List、Set、Queue等集合类型。
      • Iterator只能向前遍历集合,不支持逆向遍历。
      • Iterator提供了hasNext()方法用于检查是否还有下一个元素,以及next()方法用于获取当前元素,并将迭代器指针移动到下一个位置。
      • Iterator的remove()方法可以安全地从集合中移除上一次调用next()方法返回的元素。
    2. ListIterator(列表迭代器):
      • ListIterator是Iterator的子接口,仅适用于List类型的集合,提供了更多的功能。
      • ListIterator支持双向遍历,可以向前或向后遍历集合,并且可以获取当前位置的索引。
      • ListIterator提供了hasNext()hasPrevious()方法用于检查是否还有下一个或上一个元素,以及next()previous()方法用于获取当前元素,并将迭代器指针向前或向后移动。
      • ListIterator的remove()方法和Iterator相同,可以安全地从集合中移除上一次调用next()previous()方法返回的元素。
      • ListIterator还提供了add()方法用于在当前位置插入元素,以及set()方法用于修改当前位置的元素。
    • 总结:
      • Iterator适用于对各种集合进行简单的单向遍历,而ListIterator则专门用于对List集合进行双向遍历和元素插入、修改操作。
      • Iterator较为简单,适用于大部分遍历场景;ListIterator功能更丰富,适用于对List进行更复杂的操作。
      • ListIterator的功能更强大,但也意味着更复杂,使用时需要注意遍历方向和索引的变化。
      • ListIterator可以通过调用listIterator()方法从List集合中获取,而Iterator可以通过调用iterator()方法从各种集合中获取。

14. ListIterator迭代器包含的方法有:

  • add(E e): 将指定的元素插入列表,插入位置为迭代器当前位置之前
  • hasNext():以正向遍历列表
  • hasPrevious():如果以逆向遍历列表,列表迭代器前面还有元素,则返回 true,否则返回false
  • next():返回列表中ListIterator指向位置后面的元素
  • nextIndex():返回列表中ListIterator所需位置后面元素的索引
  • set(E e):从列表中将next()或previous()返回的最后一个元素返回的最后一个元素更改为指定元素e
  • previous():返回列表中ListIterator指向位置前面的元素
  • previousIndex():返回列表中ListIterator所需位置前面元素的索引

15.遍历一个 List 有哪些不同的方式?每种方法的实现原理是什么?Java 中 List 遍历的最佳实践是什么?

在Java中,遍历List(列表)有几种不同的方式,每种方式都有其实现原理和适用场景。以下是常见的List遍历方式:

  1. 使用for循环:

    List<String> list = Arrays.asList("Apple", "Banana", "Orange");
    for (int i = 0; i < list.size(); i++) {
        String element = list.get(i);
        // 处理元素
    }
    

    实现原理:通过循环索引的方式,从0到size()-1遍历List并使用get(i)方法获取元素。

  2. 使用增强for循环(foreach循环):

    List<String> list = Arrays.asList("Apple", "Banana", "Orange");
    for (String element : list) {
        // 处理元素
    }
    

    实现原理:使用Java增强for循环语法,自动遍历List中的每个元素。

  3. 使用Iterator迭代器:

    List<String> list = Arrays.asList("Apple", "Banana", "Orange");
    Iterator<String> iterator = list.iterator();
    while (iterator.hasNext()) {
        String element = iterator.next();
        // 处理元素
    }
    

    实现原理:使用Iterator迭代器遍历List中的元素,调用hasNext()方法检查是否有下一个元素,调用next()方法获取当前元素。

  4. 使用ListIterator列表迭代器(双向遍历):

    List<String> list = Arrays.asList("Apple", "Banana", "Orange");
    ListIterator<String> iterator = list.listIterator();
    while (iterator.hasNext()) {
        String element = iterator.next();
        // 处理元素
    }
    

    实现原理:使用ListIterator列表迭代器遍历List中的元素,调用hasNext()方法检查是否有下一个元素,调用next()方法获取当前元素。

最佳实践:

  • 对于只需简单遍历元素并进行处理的情况,使用增强for循环或普通的for循环是最简洁和易读的方式。
  • 如果需要在遍历过程中修改或移除元素,建议使用Iterator或ListIterator,它们提供了安全的修改操作。
  • 遍历前最好检查List是否为null或为空,以避免NullPointerException异常。
  • 如果对性能要求较高,并且List的实现类支持随机访问(如ArrayList),使用普通的for循环可以更有效地访问元素。
  • 注意在遍历时避免修改List的结构,否则可能会导致ConcurrentModificationException异常。

16.说一下 ArrayList 的优缺点

ArrayList是Java集合框架中的一个常用类,它实现了List接口,并且基于动态数组实现。下面是ArrayList的优点和缺点:

优点:

  1. 高效的随机访问:ArrayList内部使用数组来存储元素,因此可以通过索引快速访问和修改元素。具有O(1)的随机访问时间复杂度。
  2. 快速的插入和删除操作:对于在末尾进行插入和删除操作,ArrayList具有较好的性能,具有O(1)的时间复杂度。这是因为ArrayList使用动态数组实现,不需要进行元素的迁移。
  3. 支持动态调整容量:ArrayList的容量会根据需要自动增长,可以根据实际情况动态调整大小。当元素数量超过容量时,ArrayList会自动重新分配更大的内部数组。
  4. 支持快速的迭代操作:ArrayList提供了迭代器和增强for循环等便利的迭代方式,可以快速遍历列表中的元素。

缺点:

  1. 插入和删除中间元素的效率低:由于ArrayList底层使用数组存储元素,当需要在中间位置插入或删除元素时,需要将后续元素向后移动,因此具有较高的时间复杂度,平均为O(n)。
  2. 频繁的插入和删除操作导致性能下降:如果需要频繁执行插入和删除操作,可能会导致大量的元素移动,从而影响性能。
  3. 内存浪费:ArrayList的内部数组的容量可能会大于实际元素数量,导致内存的浪费。尤其当列表容量预估不准确时,可能会占用过多的内存空间。

综上所述,ArrayList在随机访问和末尾插入、删除等操作上具有较好的性能。但在频繁的中间插入和删除操作、对内存占用有严格要求的场景下,可能不是最优选择。因此,在选择数据结构时应根据具体需求和使用场景综合考虑ArrayList的优点和缺点。

17. ArrayList的扩容机制

ArrayList 是一个数组结构的存储容器,默认情况下,设置数组长度是 10。
扩容流程:

  1. 首先,创建一个新的数组,这个新数组的长度是原来数组长度的 1.5 倍。
  2. 然后使用 Arrays.copyOf 方法把老数组里面的数据拷贝到新的数组里面。
  3. 扩容完成后再把当前要添加的元素加入到新的数组里面,从而完成动态扩容的过程
  4. ArrayList 的扩容是在添加第11个元素时触发的,新的容量为原来的 1.5 倍。当然也可以在创建 ArrayList 对象时指定其初始容量,以避免频繁的扩容操作。

注意: ArrayList 中的数组无法动态地调整大小,因此每次扩容都需要创建新的数组和复制元素,这可能会带来一些性能损失。为了避免频繁扩容,我们可以在使用 ArrayList 时尽量预估元素数量,初始化时指定一个合适的初始容量

18. 如何实现数组和 List 之间的转换?

在Java中,可以使用java.util.Arrays类和java.util.List接口提供的方法来实现数组和List之间的转换。

  1. 数组转换为List:

    String[] array = {"apple", "banana", "orange"};
    List<String> list = Arrays.asList(array);
    
  2. List转换为数组:

    List<String> list = new ArrayList<>();
    list.add("apple");
    list.add("banana");
    list.add("orange");
    
    String[] array = list.toArray(new String[list.size()]);
    

    注意,在这种方式中,需要创建一个具有适当大小的数组作为参数传递给toArray()方法。

需要注意的是,Arrays.asList()方法返回的是一个固定大小的List,它基于原始数组,所以对原始数组的修改会反映在List中,反之亦然。此外,当使用List转换为数组时,返回的数组类型将与指定的数组类型相同。

另外,如果你使用的是Java 8或更高版本,还可以使用stream()collect()方法进行转换,如下所示:

  1. 数组转换为List:

    String[] array = {"apple", "banana", "orange"};
    List<String> list = Arrays.stream(array).collect(Collectors.toList());
    
  2. List转换为数组:

    List<String> list = new ArrayList<>();
    list.add("apple");
    list.add("banana");
    list.add("orange");
    
    String[] array = list.stream().toArray(String[]::new);
    

这种方式利用了Java 8的流式操作和方法引用来简化代码。

19.ArrayList 和 LinkedList 的区别是什么?

ArrayList 和 LinkedList 都是不同步的,也就是不保证线程安全

  1. 内部数据结构:
    • ArrayList:基于动态数组实现(底层用的是Object数组),使用数组来存储元素,通过索引进行快速访问。
    • LinkedList:基于双向链表实现,每个元素都包含一个指向前一个和后一个元素的引用。
  2. 随机访问性能:
    • ArrayList支持高效的随机访问,可以通过索引快速访问元素,时间复杂度为O(1)。对应于 get(int index) 方法
    • LinkedList的随机访问性能较差,需要从头或尾部开始遍历链表,时间复杂度为O(n)。
  3. 插入和删除性能:
    • ArrayList在末尾进行插入和删除操作具有较好的性能,时间复杂度为O(1)。但在中间位置插入和删除操作需要移动后续元素,时间复杂度为O(n)。
    • LinkedList在任意位置进行插入和删除操作具有较好的性能,时间复杂度为O(1)。由于基于链表结构,不需要移动元素,只需修改相邻节点的引用。
  4. 内存占用:
    • ArrayList在内存占用上相对较低,因为它仅存储元素本身和少量的控制信息以及预留⼀定的容量空间
    • LinkedList在内存占用上相对较高,因为除了存储元素本身外,还需要存储每个元素的前后节点的引用。
  5. 迭代性能:
    • ArrayList通过索引可以快速访问元素,因此在迭代过程中具有较好的性能。
    • LinkedList通过遍历链表的方式访问元素,迭代性能较ArrayList略差。

根据上述区别,可以总结ArrayList和LinkedList的适用场景:

  • ArrayList适合需要频繁随机访问元素、对内存占用敏感或有大量末尾插入、删除操作的场景。
  • LinkedList适合需要频繁在中间位置进行插入、删除操作,对随机访问性能要求不高的场景。

我们在项目中⼀般是不会使用到 LinkedList 的,需要用到 LinkedList 的场景几乎都可以使用 ArrayList 来代替,并且,性能通常会更好!
另外,不要下意识地认为 LinkedList 作为链表就最适合元素增删的场景。 LinkedList 仅仅在头尾插入或者删除元素的时候时间复杂度近似 O(1),其他情况增删元素的时间复杂度都是 O(n)

20. 双向链表和双向循环链表

双向链表: 包含两个指针,⼀个 prev 指向前⼀个节点,⼀个 next 指向后⼀个节点。
在这里插入图片描述
双向循环链表: 最后⼀个节点的 next 指向 head,而 head 的 prev 指向最后⼀个节点,构成⼀个环。
在这里插入图片描述

21. ArrayList 和 Vector 的区别是什么?

ArrayList和Vector是Java中两种常见的可调整大小的动态数组实现,它们之间的主要区别如下:

  1. 线程安全性:Vector是线程安全的,而ArrayList不是。在多线程环境下,多个线程可以同时对Vector进行操作,而不会出现数据不一致或冲突的问题。Vector通过使用同步方法(synchronized)来实现线程安全。相反,ArrayList在多线程环境下不是线程安全的,如果多个线程同时修改ArrayList,可能会导致数据不一致或抛出ConcurrentModificationException异常。
  2. 性能:由于Vector是线程安全的,它在执行每个操作时需要进行同步处理,这会导致一定的性能开销。相比之下,ArrayList不需要进行同步处理,因此在单线程环境下,ArrayList的性能通常比Vector更好。
  3. 增长策略: 当容量不足时,Vector和ArrayList都可以自动增长其内部数组的大小。然而,它们的增长策略略有不同。Vector的增长策略是加倍当前容量,即每次扩容时将容量翻倍。而ArrayList的增长策略是增加50%的容量,即每次扩容时将容量增加当前容量的一半。
  4. 初始容量:Vector的初始容量为10,而ArrayList的初始容量为0。在添加元素时,如果容量不足,它们都会自动增加容量。

总结:

  • ArrayList 是 List 的主要实现类,底层使用 Object[ ] 存储,适用于频繁的查找工作,线程不安全 ;
  • Vector 是 List 的古老实现类,底层使用 Object[ ] 存储,线程安全的。

22. 多线程场景下如何使用 ArrayList?

在多线程场景下,如果需要使用动态数组,可以采取以下两种方法来确保ArrayList的线程安全性:

  1. 使用同步控制: 在使用ArrayList时,可以使用Java的synchronized关键字或对象级别的锁来实现同步控制,确保在多个线程之间对ArrayList的操作是互斥的。可以创建一个对象作为锁,并在访问ArrayList之前获取该锁,以确保同一时间只有一个线程能够修改ArrayList。

    ArrayList<String> arrayList = new ArrayList<>();
    
    // 在多线程环境下使用ArrayList时需要同步控制
    synchronized (arrayList) {
        // 进行对ArrayList的操作,如添加、删除、修改等
    }
    

    请注意,这种方式可以确保线程安全,但会带来性能上的开销,因为每个线程都需要等待锁释放才能执行操作。

  2. 使用线程安全的替代类: 如果在多线程环境下需要高效地使用可调整大小的动态数组,可以考虑使用线程安全的替代类,如java.util.concurrent.CopyOnWriteArrayList。CopyOnWriteArrayList是一个线程安全的类,它通过在修改操作时创建底层数组的副本来实现线程安全,从而避免了显式的同步控制。

    CopyOnWriteArrayList<String> threadSafeList = new CopyOnWriteArrayList<>();
    
    // 在多线程环境下使用线程安全的动态数组
    threadSafeList.add("item");
    // ...
    

    CopyOnWriteArrayList的一个特点是,当进行修改操作(如添加、删除、修改等)时,它会创建底层数组的副本,并在副本上进行修改,而不影响原始数组。这种机制适用于读多写少的场景,因为每次修改都会涉及复制整个数组。

无论选择哪种方法,都需要根据具体的应用场景和需求来确定使用哪种方式来保证ArrayList的线程安全性。

23. 为什么 ArrayList 的 elementData 加上 transient 修饰?

在Java的ArrayList类中,elementData字段被声明为transient,这是为了实现序列化机制时的一种考虑。

当一个类实现Serializable接口时,它可以被序列化和反序列化,即可以在网络上传输或保存到磁盘中。然而,有时候某些字段可能不希望被序列化,因为它们可能包含敏感信息、临时数据或不需要持久化的数据。在这种情况下,可以使用transient关键字来标记这些字段,告诉序列化机制不要将它们包含在序列化过程中。

ArrayList中,elementData是用于存储实际元素的数组。由于数组的内容是需要被序列化的,而不是ArrayList对象本身,因此将elementData字段标记为transient可以防止在序列化时将整个数组写入序列化流中。这是因为在ArrayList的序列化过程中,只需序列化其中的元素,而不需要序列化整个底层数组。

此外,ArrayList类通过自定义的序列化方法writeObject()readObject()来控制序列化和反序列化过程。在writeObject()方法中,elementData数组会被复制到一个新的数组,然后只将实际元素写入序列化流中。在readObject()方法中,将从序列化流中读取元素,并重新构建ArrayList对象。

因此,通过将elementData字段标记为transientArrayList类能够控制序列化和反序列化过程,避免不必要的数据写入和读取,从而提高序列化的效率和灵活性。

24. List 和 Set 的区别

List和Set是Java集合框架中两种不同的接口,它们之间有以下几点区别:

  1. **重复元素:**List允许存储重复的元素,而Set不允许。List中的元素可以按照插入顺序进行访问,并且可以通过索引来访问特定位置的元素。相反,Set中的元素是无序的,且不允许存在重复元素。
  2. 顺序性:List是有序的集合,可以维护元素的插入顺序。元素在List中按照它们被添加的顺序排列,并且可以通过索引进行访问。Set是无序的,它不保持元素的特定顺序。
  3. 数据结构:List通常以动态数组(如ArrayList)或链表(如LinkedList)的形式实现,这使得元素的访问和插入操作具有较好的性能。Set通常以哈希表(如HashSet)或树(如TreeSet)的形式实现,这使得元素的查找和去重操作具有较好的性能。
  4. 查找效率:由于Set使用哈希表或树等数据结构来存储元素,它提供了更快的查找性能。对于大型数据集,使用Set可以更快地判断元素是否存在。相比之下,List需要进行线性搜索来查找元素,因此查找性能较低。

总的来说,如果你需要按照插入顺序存储元素并允许重复值,可以选择List。如果你需要快速地判断元素是否存在且不允许重复值,可以选择Set。选择哪种接口取决于你的需求和操作的性能要求。

25. 说一下 HashSet 的实现原理?

HashSet是Java集合框架中的一个实现了Set接口的类,它基于哈希表(Hash Table)实现。以下是HashSet的实现原理:

  1. 哈希表:HashSet内部使用一个HashMap来存储元素。HashMap是基于哈希表的数据结构,它提供了快速的查找、插入和删除操作。
  2. 哈希函数:当元素被添加到HashSet时,HashSet会使用元素的哈希函数来计算其哈希码(hash code)。哈希码是一个整数,用于确定元素在哈希表中的存储位置。
  3. 存储位置:根据元素的哈希码,HashSet将元素存储在哈希表的特定位置上。如果不同元素具有相同的哈希码(哈希冲突),则它们会存储在同一个位置上,形成一个链表。
  4. 冲突解决:当发生哈希冲突时,HashSet使用链表或红黑树(从Java 8开始)来解决冲突。对于链表,元素被添加到链表的末尾。而对于红黑树,当链表长度达到一定阈值时,链表会转换为红黑树,以提高查找和删除操作的性能。
  5. 哈希表的负载因子:HashSet在内部使用一个负载因子(load factor)来控制哈希表的填充程度。当哈希表的容量达到负载因子与当前大小的乘积时,哈希表会进行扩容操作,以保持较低的冲突率和更好的性能。

通过使用哈希表作为底层数据结构,HashSet提供了常数时间复杂度(O(1))的查找、插入和删除操作。但是,哈希表的性能取决于哈希函数的质量和负载因子的选择,当负载因子过高时,哈希冲突可能会增加,导致性能下降。

需要注意的是,HashSet对元素的存储顺序是不保证的,因为它是基于哈希表实现的无序集合。如果需要有序的集合,请使用TreeSet等实现了SortedSet接口的类。

26. HashSet如何检查重复?HashSet是如何保证数据不可重复的?

HashSet通过使用哈希函数和哈希表来检查重复项并确保数据的唯一性。

当你向HashSet添加一个元素时,HashSet首先使用元素的哈希函数来计算其哈希码(hash code)。哈希函数将元素转换为一个整数值,该值通常与元素的内容相关。然后,HashSet将该哈希码与内部的哈希表进行比较。

  • 如果两个元素具有相同的哈希码,
  • 进一步使用元素的equals()方法来比较
  • 只有当两个元素的哈希码相等且equals()方法返回true时
  • HashSet才认为它们是重复的元素。
    以下是HashSet 部分源码:
private static final Object PRESENT = new Object();
private transient HashMap<E,Object> map;

public HashSet() {
    map = new HashMap<>();
}

public boolean add(E e) {
    // 调用HashMap的put方法,PRESENT是一个至始至终都相同的虚值
	return map.put(e, PRESENT)==null;
}

27. hashCode()与equals()的相关规定:

  1. hashCode()和equals()是Java中Object类的两个方法,它们在哈希集合(如HashSet)和哈希映射(如HashMap)等数据结构中起到重要作用。以下是它们的相关规定:

    1. hashCode()方法:
      • hashCode()方法返回对象的哈希码(hash code),它是一个整数值。
      • 如果两个对象通过equals()方法相等(即equals()返回true),那么它们的hashCode()方法必须返回相同的整数值。
      • 如果两个对象通过equals()方法不相等,它们的hashCode()方法可以返回相同的整数值,但更好的做法是尽量使它们的hashCode()返回不同的值,以提高哈希表的性能。
    2. equals()方法:
      • equals()方法用于比较两个对象是否相等。
      • equals()方法具有以下性质:
        • 自反性:对于任何非null的引用值x,x.equals(x)必须返回true。
        • 对称性:对于任何非null的引用值x和y,如果x.equals(y)返回true,则y.equals(x)也必须返回true。
        • 传递性:对于任何非null的引用值x、y和z,如果x.equals(y)返回true,并且y.equals(z)也返回true,则x.equals(z)也必须返回true。
        • 一致性:对于任何非null的引用值x和y,只要对象的比较信息没有被修改,多次调用x.equals(y)应该始终返回相同的结果。
        • 对于任何非null的引用值x,x.equals(null)必须返回false。

    这些规定保证了在哈希集合和哈希映射等数据结构中使用hashCode()和equals()方法时的一致性和正确性。当你在自定义类中重写这两个方法时,应当遵循上述规定来保证正确的比较和哈希处理。

28. HashSet与HashMap的区别

HashSet和HashMap是Java集合框架中两个不同的类,它们有以下区别:

  1. 数据结构:
    • HashSet是基于哈希表实现的集合,它使用哈希函数来存储和访问元素。
    • HashMap是基于哈希表实现的映射,它使用键值对的方式存储和访问元素。
  2. 存储方式:
    • HashSet存储唯一的元素(不允许重复),它使用元素的哈希码来确定元素在集合中的位置。
    • HashMap存储键值对,其中键是唯一的,而值可以重复。
  3. 元素访问:
    • HashSet通过元素本身来访问和操作集合,它提供了添加、删除和查询元素的方法。
    • HashMap通过键来访问和操作映射中的键值对,它提供了根据键添加、删除和获取值的方法。
  4. 存储顺序:
    • HashSet不保证元素的存储顺序,它通常是无序的。元素在哈希表中的位置由哈希码决定。
    • HashMap也不保证键值对的存储顺序,它同样是无序的。键值对的存储位置也由哈希码决定。
  5. 迭代方式:
    • HashSet提供了迭代器(Iterator)来遍历集合中的元素。
    • HashMap提供了迭代器(Iterator)来遍历键值对,或者可以遍历键集合(keySet())、值集合(values())或键值对集合(entrySet())。

29. 比较 HashSet、LinkedHashSet 和 TreeSet 三者的异同

  • HashSet 、 LinkedHashSet 和 TreeSet 都是 Set 接口的实现类,都能保证元素唯一,并且都不是线程安全的。
  • HashSet 、 LinkedHashSet 和 TreeSet 的主要区别在于底层数据结构不同。 HashSet 的底层数据结构是哈希表(基于 HashMap 实现)。 LinkedHashSet 的底层数据结构是链表和哈希表,元素的插入和取出顺序满足 FIFO。 TreeSet 底层数据结构是红黑树,元素是有序的,排序的方式有自然排序和定制排序
  • 底层数据结构不同又导致这三者的应用场景不同。 HashSet 用于不需要保证元素插入和取出顺序的场景, LinkedHashSet 用于保证元素的插入和取出顺序满足 FIFO 的场景, TreeSet 用于支持对元素自定义排序规则的场景

Queue

30. Queue 与 Deque 的区别

Queue 是单端队列,只能从⼀端插入元素,另⼀端删除元素,实现上⼀般遵循 先进先出(FIFO)规则
Queue 扩展了 Collection 的接口,根据 因为容量问题而导致操作失败后处理方式的不同 可以分为两类方法: ⼀种在操作失败后会抛出异常,另⼀种则会返回特殊值
在这里插入图片描述
Deque 是双端队列,在队列的两端均可以插入或删除元素。
Deque 扩展了 Queue 的接口, 增加了在队首和队尾进行插入和删除的方法,同样根据失败后处理方式的不同分为两类
在这里插入图片描述

事实上, Deque 还提供有 push() 和 pop() 等其他⽅法,可⽤于模拟栈。

31. ArrayDeque 与 LinkedList 的区别

ArrayDeque 和 LinkedList 都实现了 Deque 接口,两者都具有队列的功能,但两者有什么区别呢?

  • ArrayDeque 是基于可变长的数组和双指针来实现,而 LinkedList 则通过链表来实现。
  • ArrayDeque 不支持存储 NULL 数据,但 LinkedList ⽀持。
  • ArrayDeque 是在 JDK1.6 才被引⼊的,而 LinkedList 早在 JDK1.2 时就已经存在。
  • ArrayDeque 插入时可能存在扩容过程, 不过均摊后的插⼊操作依然为 O(1)。虽然 LinkedList不需要扩容,但是每次插⼊数据时均需要申请新的堆空间,均摊性能相比更慢。

从性能的角度上,选用ArrayDeque 来实现队列要比 LinkedList 更好。此外, ArrayDeque 也可以用于实现栈。

32. BlockingQueue是什么?

BlockingQueue是Java编程语言中的一个接口,用于实现在多线程环境下安全地传递和处理数据的队列。它位于java.util.concurrent包中。

Queue(队列)是一种常见的数据结构,遵循先进先出(FIFO)的原则。BlockingQueue扩展了Queue接口,并添加了一些额外的特性,使得它适用于多线程编程场景。

BlockingQueue的特点是当队列为空时,获取(取出)操作将被阻塞,直到队列中有可用的元素;当队列已满时,插入操作将被阻塞,直到队列有空闲位置。这种阻塞行为使得线程可以安全地等待队列中的数据,从而避免了手动实现线程同步和等待/通知机制。

BlockingQueue接口定义了几种常用的实现类,包括:

  1. ArrayBlockingQueue:基于数组实现的有界阻塞队列。
  2. LinkedBlockingQueue:基于链表实现的可选有界(如果指定容量)或无界阻塞队列。
  3. PriorityBlockingQueue:基于优先级堆实现的无界阻塞队列,元素按照优先级进行排序。
  4. SynchronousQueue:不存储元素的阻塞队列,每个插入操作必须等待对应的删除操作,反之亦然。
  5. DelayQueue:带有延迟时间的无界阻塞队列,用于按照指定延迟时间对元素进行排序。

通过使用BlockingQueue,开发者可以更方便地实现生产者-消费者模型、任务调度和线程池等多线程编程场景,避免了手动处理线程同步和等待的复杂性。

33. 说⼀说 PriorityQueue

PriorityQueue 是在 JDK1.5 中被引入的, 其与 Queue 的区别在于元素出队顺序是与优先级相关的,即总是优先级最高的元素先出队。
这里列举其相关的⼀些要点:

  • PriorityQueue 利用了⼆叉堆的数据结构来实现的,底层使用可变长的数组来存储数据
  • PriorityQueue 通过堆元素的上浮和下沉,实现了在 O(logn) 的时间复杂度内插入元素和删除堆顶元素。
  • PriorityQueue 是非线程安全的,且不支持存储 NULL 和 non-comparable 的对象。
  • PriorityQueue 默认是小顶堆,但可以接收⼀个 Comparator 作为构造参数,从而来自定义元素优先级的先后。

PriorityQueue 在面试中可能更多的会出现在⼿撕算法的时候,典型例题包括堆排序、求第K大的数、带权图的遍历等,所以需要会熟练使用才行。

34. 在 Queue 中 poll()和 remove()有什么区别?

在队列(Queue)中,poll()remove()是两种常用的方法,它们的主要区别在于对于空队列的处理方式。

  1. poll(): 这是一个用于检索和删除队列头部元素的方法。如果队列为空,poll()方法会返回null。使用poll()方法时,如果队列中没有元素可供检索,则不会抛出异常。
  2. remove(): 这也是一个用于检索和删除队列头部元素的方法。如果队列为空,remove()方法会抛出NoSuchElementException异常。使用remove()方法时,如果队列为空,它会立即抛出异常。

下面是使用示例,演示了poll()remove()方法的区别:

import java.util.LinkedList;
import java.util.Queue;
import java.util.NoSuchElementException;

public class QueueExample {
    public static void main(String[] args) {
        Queue<Integer> queue = new LinkedList<>();

        // 添加元素到队列
        queue.add(1);
        queue.add(2);

        // 使用 poll() 方法
        System.out.println(queue.poll());  // 输出: 1
        System.out.println(queue.poll());  // 输出: 2
        System.out.println(queue.poll());  // 输出: null

        // 使用 remove() 方法
        try {
            System.out.println(queue.remove());  // 抛出 NoSuchElementException 异常
        } catch (NoSuchElementException e) {
            System.out.println("队列为空,无法执行 remove() 方法。");
        }
    }
}

在上述示例中,当队列为空时,使用poll()方法会返回null,而使用remove()方法会抛出异常。因此,如果不确定队列是否为空,使用poll()方法更安全,因为它可以返回一个特殊值(null),而不会中断程序的执行。而remove()方法更适合在明确知道队列非空时使用,因为它会立即抛出异常以指示问题。

Map接口

35. 说一下 HashMap 的实现原理?

  • HashMap是Java中常用的散列(哈希)映射数据结构,它基于数组和链表(或红黑树)实现。
  • 通过 (n - 1) & hash 判断当前元素存放的位置(n 指的是数组的长度)
    • 如果当前位置存在元素的话,
    • 就判断该元素与要存⼊的元素的hash 值以及 key 是否相同
static final int hash(Object key) {
int h;
// key.hashCode():返回散列值也就是hashcode
// ^ :按位异或
// >>>:⽆符号右移,忽略符号位,空位都以0补⻬
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
 }

下面是HashMap的主要实现原理:

  1. 内部数组和桶:HashMap内部使用一个数组来存储元素,每个数组位置被称为桶(bucket)。每个桶可以存储一个或多个键值对。桶的数量是固定的,通常为2的幂,这样可以通过位运算计算桶的索引,实现快速访问。
  2. 哈希函数:HashMap使用哈希函数将键映射到桶的索引位置。哈希函数接收键作为输入,并计算出一个整数的哈希码(hash code)。哈希码通过内部算法将键的信息压缩成一个整数,用于确定桶的索引位置。
  3. 解决哈希冲突:由于不同的键可能映射到相同的哈希码,称为哈希冲突。HashMap使用链表(JDK7及之前版本)或红黑树(JDK8及之后版本)来解决哈希冲突。当多个键映射到同一个桶时,它们被组织成一个链表或红黑树结构。链表适用于较小的冲突,而红黑树适用于较大的冲突,这样可以提高查找、插入和删除操作的效率。
  4. 键的存储:HashMap的键对象存储在桶中,通过哈希函数计算出的索引决定了键值对在数组中的位置。当发生哈希冲突时,新的键值对会被添加到链表或红黑树的末尾。键对象使用equals()方法比较相等性,当两个键的equals()方法返回true时,它们被视为相等的键。
  5. 性能优化:为了提高HashMap的性能,一些优化措施被采用。其中包括加载因子(load factor)的概念,它表示在扩容之前,HashMap可以存储的键值对数量的上限。当HashMap中的键值对数量达到加载因子的上限时,会触发扩容操作,将数组容量增加一倍,并重新计算哈希码。这样可以保持桶中链表或红黑树的平均长度较短,提高操作效率。

36.HashMap在JDK1.7和JDK1.8中有哪些不同?HashMap的底层实现

在JDK 1.7和JDK 1.8中,HashMap的实现有一些不同之处。

在JDK 1.7中,HashMap的底层实现使用了数组和链表的组合来解决哈希冲突。当发生哈希冲突时,新的元素将会链接到链表的末尾。这种实现方式称为"链表法"。然而,当链表过长时,查找效率会降低,因为需要遍历链表来找到目标元素。

在JDK 1.8中,HashMap的底层实现引入了一个新的概念,称为"红黑树"。当链表的长度超过一定阈值(默认为8)时,链表将会转换为红黑树,以提高查找效率。红黑树是一种自平衡二叉搜索树,具有更好的查找性能,时间复杂度为O(log n)。这种实现方式在解决哈希冲突时称为"树化"。

JDK 1.8还引入了一个新的概念,称为"扩容"。在JDK 1.7中,当HashMap的元素数量达到容量的75%时,会触发扩容操作。而在JDK 1.8中,扩容的触发条件变为元素数量达到容量的两倍。扩容操作会重新计算元素的哈希值和位置,并重新分配存储空间,以保证哈希表的负载因子在可接受范围内。

总结一下,在JDK 1.7中,HashMap的底层实现使用了数组和链表的组合来解决哈希冲突;而在JDK 1.8中,引入了红黑树来优化链表过长的情况,并引入了扩容操作来提高存储效率。
在这里插入图片描述

TreeMap、TreeSet 以及 JDK1.8 之后的 HashMap 底层都⽤到了红⿊树。红⿊树就是为了解决⼆叉查找树的缺陷,因为⼆叉查找树在某些情况下会退化成⼀个线性结构

37. JDK1.7 VS JDK1.8 比较

JDK 1.7和JDK 1.8是Java开发工具包(Java Development Kit)的两个主要版本。它们之间有一些重要的区别,包括语言特性、性能改进、库和工具的更新等方面。

  1. 语言特性改进:
    • JDK 1.7引入了一些语言级别的改进,如钻石操作符(Diamond Operator)和try-with-resources语句。这些改进简化了代码编写和异常处理。
    • JDK 1.8引入了Lambda表达式和函数式接口。Lambda表达式提供了更简洁和灵活的函数式编程方式,并使得编写并行代码更加容易。
  2. 性能改进:
    • JDK 1.8在性能方面进行了一些优化。例如,引入了新的JIT编译器(JIT Compiler),称为JIT编译器(JIT Compiler),以提高运行时性能。
    • JDK 1.8还引入了一种新的数据结构,称为"红黑树",用于优化HashMap和TreeMap等集合类的性能。
  3. 库和工具更新:
    • JDK 1.8包含了对Java类库的更新和改进,包括新的日期和时间API(java.time包)、新的并发工具(如CompletableFuture)以及对JavaScript的支持(Nashorn引擎)。
    • JDK 1.8还引入了新的工具,如Java Stream API,用于处理集合数据的流式操作。
  4. 其他改进:
    • JDK 1.8引入了一种新的启动方式,称为"Metaspace",用于替代JDK 1.7中的"永久代"(Permanent Generation)。Metaspace具有更高的灵活性和性能,并且可以动态调整内存大小。
    • JDK 1.8还提供了对新的字节码指令和调试功能的支持。

38. HashMap 和 Hashtable 的区别

  • 线程是否安全: HashMap 是非线程安全的, Hashtable 是线程安全的,因为 Hashtable 内部的方法基本都经过synchronized 修饰。(如果你要保证线程安全的话就使用ConcurrentHashMap吧!);
  • 效率: 因为线程安全的问题, HashMap 要比 Hashtable 效率高⼀点。另外, Hashtable 基本被淘汰,不要在代码中使用它
  • 对 Null key 和 Null value 的⽀持: HashMap 可以存储 null 的 key 和 value,但 null 作为键只能有⼀个,null 作为值可以有多个;Hashtable 不允许有 null 键和 null 值,否则会抛出NullPointerException 。
  • 初始容量⼤小和每次扩充容量大小的不同 : ① 创建时如果不指定容量初始值, Hashtable 默认的初始大小为 11,之后每次扩充,容量变为原来的 2n+1。 HashMap 默认的初始化大小为16。之后每次扩充,容量变为原来的 2 倍。② 创建时如果给定了容量初始值,那么 Hashtable会直接使用你给定的大小,而HashMap 会将其扩充为 2 的幂次方大小( HashMap 中的tableSizeFor() 方法保证,下面给出了源代码)。也就是说 HashMap 总是使用 2 的幂作为哈希表的大小,后面会介绍到为什么是 2 的幂次方。
  • 底层数据结构: JDK1.8 以后的 HashMap 在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)时,将链表转化为红⿊树(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树),以减少搜索时间(后文中我会结合源码对这⼀过程进行分析)。 Hashtable 没有这样的机制

HashMap 中带有初始容量的构造函数:

public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
 }
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
 }

下⾯这个方法保证了 HashMap 总是使用 2 的幂作为哈希表的大小

/**
* Returns a power of two size for the given target capacity.
*/
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;
 }

39. HashMap 和 HashSet 区别

如果你看过 HashSet 源码的话就应该知道: HashSet 底层就是基于 HashMap 实现的。( HashSet 的源码非常少,因为除了 clone() 、 writeObject() 、 readObject() 是 HashSet 自己不得不实现之外,其他方法都是直接调用 HashMap 中的方法
在这里插入图片描述

40. HashMap 和 TreeMap 区别

TreeMap 和 HashMap 都继承⾃ AbstractMap ,但是需要注意的是 TreeMap 它还实现了NavigableMap 接口和 SortedMap 接口。
在这里插入图片描述
实现 NavigableMap 接口让 TreeMap 有了对集合内元素的搜索的能力。
实现 SortedMap 接口让 TreeMap 有了对集合中的元素根据键排序的能。默认是按 key 的升序排序,不过我们也可以指定排序的比较器。示例代码如下:

/**
* @author shuang.kou
* @createTime 2020年06⽉15⽇ 17:02:00
*/
public class Person {
private Integer age;
public Person(Integer age) {
this.age = age;
 }
public Integer getAge() {
return age;
 }
public static void main(String[] args) {
TreeMap<Person, String> treeMap = new TreeMap<>(new Comparator<Person>()
{
@Override
public int compare(Person person1, Person person2) {
int num = person1.getAge() - person2.getAge();
return Integer.compare(num, 0);
 }
 });
treeMap.put(new Person(3), "person1");
treeMap.put(new Person(18), "person2");
treeMap.put(new Person(35), "person3");
treeMap.put(new Person(16), "person4");
treeMap.entrySet().stream().forEach(personStringEntry -> {
System.out.println(personStringEntry.getValue());
 });
 }
}
//輸出
//person1
//person4
//person2
//person3

可以看出, TreeMap 中的元素已经是按照 Person 的 age 字段的升序来排列了。
上⾯,我们是通过传⼊匿名内部类的方式实现的,你可以将代码替换成 Lambda 表达式实现的方式:

TreeMap<Person, String> treeMap = new TreeMap<>((person1, person2) -> {
int num = person1.getAge() - person2.getAge();
return Integer.compare(num, 0);
});

综上,相比于 HashMap 来说 TreeMap 主要多了对集合中的元素根据键排序的能力以及对集合内元素的搜索的能力

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

HashMap的put方法用于将键值对存储到HashMap中。下面是HashMap的put方法的一般流程:

  1. 首先,根据要插入的键值对的键计算哈希值。HashMap使用键的哈希值来确定键值对在内部数组中的位置。
  2. 然后,通过哈希值的高位来确定键值对应该存储在内部数组的哪个桶中。每个桶是一个链表或树结构,用于解决哈希冲突(即不同的键具有相同的哈希值)。
  3. 如果指定的桶为空,即没有冲突,则直接将键值对插入到该桶中,并增加HashMap的大小(size)。
  4. 如果指定的桶非空,即存在冲突,那么需要进行进一步的处理。首先,遍历桶中已有的键值对,检查要插入的键是否已经存在于HashMap中。
  5. 如果找到了相同的键,那么用新的值替换旧的值,并结束插入操作。
  6. 如果没有找到相同的键,将新的键值对添加到桶中,并增加HashMap的大小。
  7. 如果桶的大小达到某个阈值(通常是根据负载因子来确定),则可能触发桶的转换操作,将链表转换为树或者树转换为链表,以提高查找效率。
  8. 插入完成后,更新修改次数(modCount)来支持迭代器的正确操作。

代码 如下:

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

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

//实现Map.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;
    // 步骤①:tab为空则创建 
    // table未初始化或者长度为0,进行扩容
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    // 步骤②:计算index,并对null做处理  
    // (n - 1) & hash 确定元素存放在哪个桶中,桶为空,新生成结点放入桶中(此时,这个结点是放在数组中)
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    // 桶中已经存在元素
    else {
        Node<K,V> e; K k;
        // 步骤③:节点key存在,直接覆盖value 
        // 比较桶中第一个元素(数组中的结点)的hash值相等,key相等
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
                // 将第一个元素赋值给e,用e来记录
                e = p;
        // 步骤④:判断该链为红黑树 
        // hash值不相等,即key不相等;为红黑树结点
        // 如果当前元素类型为TreeNode,表示为红黑树,putTreeVal返回待存放的node, e可能为null
        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) {
                    // 在尾部插入新结点
                    p.next = newNode(hash, key, value, null);
                    //判断链表的长度是否达到转化红黑树的临界值,临界值为8
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        //链表结构转树形结构
                        treeifyBin(tab, hash);
                    // 跳出循环
                    break;
                }
                // 判断链表中结点的key值与插入的元素的key值是否相等
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    // 相等,跳出循环
                    break;
                // 用于遍历桶中的链表,与前面的e = p.next组合,可以遍历链表
                p = e;
            }
        }
        //判断当前的key已经存在的情况下,再来一个相同的hash值、key值时,返回新来的value这个值
        if (e != null) { 
            // 记录e的value
            V oldValue = e.value;
            // onlyIfAbsent为false或者旧值为null
            if (!onlyIfAbsent || oldValue == null)
                //用新值替换旧值
                e.value = value;
            // 访问后回调
            afterNodeAccess(e);
            // 返回旧值
            return oldValue;
        }
    }
    // 结构性修改
    ++modCount;
    // 步骤⑥:超过最大容量就扩容 
    // 实际大小大于阈值则扩容
    if (++size > threshold)
        resize();
    // 插入后回调
    afterNodeInsertion(evict);
    return null;
}

①.判断键值对数组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,如果超过,进行扩容。

42.HashMap的扩容操作是怎么实现的?

HashMap的性能和效率。当HashMap中的键值对数量达到一定阈值时,就会触发扩容操作。下面是HashMap的扩容操作的一般流程:

  1. 当HashMap中的键值对数量超过阈值(容量乘以负载因子),即达到临界点时,HashMap会自动进行扩容操作。
  2. 扩容操作会创建一个更大的内部数组(buckets),新数组的大小通常是原数组的两倍。
  3. 然后,HashMap会遍历原数组中的每个桶,将桶中的键值对重新分配到新数组的对应桶中。这个过程称为重新哈希(rehashing)。
  4. 重新哈希的过程包括以下步骤:
    • 对于每个非空桶,将桶中的键值对逐个取出。
    • 根据新数组的大小计算新的哈希值,并将键值对插入到新数组的对应桶中。
  5. 扩容完成后,原来的数组会被丢弃,HashMap的内部数组引用会指向新的数组。
  6. 扩容操作完成后,HashMap的容量会增加,从而减少哈希冲突的概率,提高了HashMap的性能和效率。

需要注意的是,扩容操作可能会比较耗时,因为需要重新计算哈希值并重新分配键值对。因此,在设计HashMap时,需要根据实际情况合理选择初始容量和负载因子,以避免频繁的扩容操作。

实现如下:

final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table;//oldTab指向hash桶数组
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;
    int newCap, newThr = 0;
    if (oldCap > 0) {//如果oldCap不为空的话,就是hash桶数组不为空
        if (oldCap >= MAXIMUM_CAPACITY) {//如果大于最大容量了,就赋值为整数最大的阀值
            threshold = Integer.MAX_VALUE;
            return oldTab;//返回
        }//如果当前hash桶数组的长度在扩容后仍然小于最大容量 并且oldCap大于默认值16
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // double threshold 双倍扩容阀值threshold
    }
    // 旧的容量为0,但threshold大于零,代表有参构造有cap传入,threshold已经被初始化成最小2的n次幂
    // 直接将该值赋给新的容量
    else if (oldThr > 0) // initial capacity was placed in threshold
        newCap = oldThr;
    // 无参构造创建的map,给出默认容量和threshold 16, 16*0.75
    else {               // zero initial threshold signifies using defaults
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    // 新的threshold = 新的cap * 0.75
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
    threshold = newThr;
    // 计算出新的数组长度后赋给当前成员变量table
    @SuppressWarnings({"rawtypes","unchecked"})
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];//新建hash桶数组
    table = newTab;//将新数组的值复制给旧的hash桶数组
    // 如果原先的数组没有初始化,那么resize的初始化工作到此结束,否则进入扩容元素重排逻辑,使其均匀的分散
    if (oldTab != null) {
        // 遍历新数组的所有桶下标
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            if ((e = oldTab[j]) != null) {
                // 旧数组的桶下标赋给临时变量e,并且解除旧数组中的引用,否则就数组无法被GC回收
                oldTab[j] = null;
                // 如果e.next==null,代表桶中就一个元素,不存在链表或者红黑树
                if (e.next == null)
                    // 用同样的hash映射算法把该元素加入新的数组
                    newTab[e.hash & (newCap - 1)] = e;
                // 如果e是TreeNode并且e.next!=null,那么处理树中元素的重排
                else if (e instanceof TreeNode)
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                // e是链表的头并且e.next!=null,那么处理链表中元素重排
                else { // preserve order
                    // loHead,loTail 代表扩容后不用变换下标,见注1
                    Node<K,V> loHead = null, loTail = null;
                    // hiHead,hiTail 代表扩容后变换下标,见注1
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    // 遍历链表
                    do {             
                        next = e.next;
                        if ((e.hash & oldCap) == 0) {
                            if (loTail == null)
                                // 初始化head指向链表当前元素e,e不一定是链表的第一个元素,初始化后loHead
                                // 代表下标保持不变的链表的头元素
                                loHead = e;
                            else                                
                                // loTail.next指向当前e
                                loTail.next = e;
                            // loTail指向当前的元素e
                            // 初始化后,loTail和loHead指向相同的内存,所以当loTail.next指向下一个元素时,
                            // 底层数组中的元素的next引用也相应发生变化,造成lowHead.next.next.....
                            // 跟随loTail同步,使得lowHead可以链接到所有属于该链表的元素。
                            loTail = e;                           
                        }
                        else {
                            if (hiTail == null)
                                // 初始化head指向链表当前元素e, 初始化后hiHead代表下标更改的链表头元素
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    // 遍历结束, 将tail指向null,并把链表头放入新数组的相应下标,形成新的映射。
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

43.HashMap是怎么解决哈希冲突的?

HashMap使用哈希冲突解决方法来处理不同键具有相同哈希值的情况。当发生哈希冲突时,HashMap采用链表或树结构来存储冲突的键值对。下面是HashMap解决哈希冲突的一般方法:

  1. 在HashMap的内部数组中,每个桶可以存储一个或多个键值对。每个桶实际上是一个链表或树结构,用于存储发生哈希冲突的键值对。
  2. 当要插入一个键值对时,首先计算键的哈希值,并根据哈希值的高位确定应该存储在哪个桶中。
  3. 如果指定桶为空,即没有冲突,则将键值对直接插入到该桶中。
  4. 如果指定桶非空,即发生了哈希冲突,那么HashMap会遍历该桶中已存在的键值对,以确定要插入的键是否已经存在于HashMap中。
  5. 如果找到了相同的键,则用新的值替换旧的值。插入操作完成。
  6. 如果没有找到相同的键,HashMap会将新的键值对插入到桶的末尾(链表的尾部),并保持键值对的插入顺序。
  7. 当一个桶中的链表长度达到一定阈值(默认为8),HashMap会将链表转换为树结构。这个操作可以提高在大型链表中查找的效率。
  8. 当桶中的树结构的节点数量小于阈值(默认为6),HashMap会将树结构转换回链表结构,以节省内存。

通过使用链表和树结构来存储冲突的键值对,HashMap能够有效地解决哈希冲突,确保不同键具有相同哈希值的键值对能够正确地存储和检索。

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

    1. 在 Java 中,可以使用任何类作为 Map 的键(Key),但是要正确使用自定义类作为键,需要满足以下条件:

      1. 实现 hashCode() 方法:自定义类必须正确实现 hashCode() 方法。hashCode() 方法返回的哈希码用于确定键的存储位置和查找路径。为了保证 HashMap 的性能,哈希码应该尽可能均匀地分布在哈希表的桶中。因此,hashCode() 方法的实现应该满足对象相等的情况下返回相同的哈希码,以及对象不相等的情况下尽量返回不同的哈希码。
      2. 实现 equals() 方法:自定义类还必须正确实现 equals() 方法。equals() 方法用于比较两个键是否相等。如果两个键相等(equals() 方法返回 true),它们应该具有相同的哈希码。equals() 方法的实现应该遵循对象相等的定义,并且与 hashCode() 方法一致。
      3. 不可变性(可选):虽然不是必需的,但是如果自定义类是不可变的,即创建后不能被修改,那么它更适合作为键。不可变的键保证了在 HashMap 中使用时不会发生意外的修改,确保了键的一致性和哈希码的稳定性。

      需要注意的是,如果自定义类作为键,但没有正确实现 hashCode() 和 equals() 方法,可能导致 HashMap 无法正确地存储和检索键值对,甚至在某些情况下会导致键冲突和数据丢失。

      因此,当使用自定义类作为 Map 的键时,需要确保正确实现 hashCode() 和 equals() 方法,以及适当地处理类的可变性,以确保正确的功能和性能。

45.为什么HashMap中String、Integer这样的包装类适合作为K?

  1. HashMap 是 Java 中常用的数据结构之一,它通过键值对的方式存储和访问数据。在 HashMap 中,键(Key)用于唯一标识值(Value),因此选择适合作为键的数据类型很重要。

    String 和 Integer 等包装类适合作为 HashMap 的键(Key)的原因有以下几点:

    1. 不可变性:String 和 Integer 对象是不可变的,即它们的值在创建后不可修改。这种不可变性使得它们非常适合作为 HashMap 的键,因为在 HashMap 中键的不可变性是必要的,以确保哈希码的稳定性。如果键在使用后被修改,那么它的哈希码也会改变,导致无法正确地检索或删除对应的值。
    2. 哈希码的计算效率:String 和 Integer 类在 Java 中已经重写了 hashCode() 方法,使得计算哈希码的过程非常高效。HashMap 在存储和检索数据时使用键的哈希码来确定存储位置和查找路径,因此计算哈希码的效率对于 HashMap 的性能至关重要。由于 String 和 Integer 的哈希码计算高效,它们可以快速定位到对应的存储位置,提高了 HashMap 的性能。
    3. 唯一性:String 和 Integer 都具有唯一性的特点。在 HashMap 中,键必须是唯一的,不能存在重复的键。String 对象的唯一性通过比较字符串的内容来确定,而 Integer 对象的唯一性是通过比较它们的值来确定。这种唯一性使得 String 和 Integer 适合作为 HashMap 的键,可以有效地区分不同的键值对。

    总之,String 和 Integer 等包装类适合作为 HashMap 的键,是因为它们的不可变性、哈希码计算效率高和唯一性特点,能够满足 HashMap 对键的要求,并提高 HashMap 的性能。

46.如果使用Object作为HashMap的Key,应该怎么办呢?

在使用对象作为 HashMap 的键时,需要确保对象具备以下特征:

  1. 可变性:作为 HashMap 的键,对象在被用作键的过程中不应该发生变化。因为如果键发生变化,它们在哈希表中的位置也将发生变化,导致无法正确地检索到对应的值。
  2. hashCode() 方法的正确实现:对象必须正确地实现 hashCode() 方法,以便在哈希表中获取和存储值时能够生成正确的哈希码。hashCode() 方法应该根据对象的内容生成哈希码,保证相等的对象返回相同的哈希码。
  3. equals() 方法的正确实现:对象必须正确地实现 equals() 方法,以便在 HashMap 进行键的查找时能够比较对象的内容是否相等。equals() 方法应该比较对象的内容而非引用,确保相等的对象被判定为相等。
  4. 不可变字段:如果对象作为键存储在 HashMap 中,它的字段应该是不可变的。如果字段的值发生变化,它可能导致无法正确地检索到对应的值。

当使用对象作为 HashMap 的键时,请确保遵循以上规则。如果你使用的对象已经是 Java 标准库中的类(如 String、Integer 等),那么这些类已经正确地实现了 hashCode() 和 equals() 方法,你可以直接使用它们作为键。如果你使用的是自定义的类,需要自行实现 hashCode() 和 equals() 方法来确保正确性。

47.HashMap为什么不直接使用hashCode()处理后的哈希值直接作为table的下标?

虽然哈希表使用对象的 hashCode() 方法来计算哈希码,但它并不直接将 hashCode() 处理后的值作为哈希表的下标。这是因为哈希码的范围可能远远大于实际哈希表的容量,而且不同对象的哈希码也可能相同。直接使用 hashCode() 处理后的值作为下标可能导致下标越界或发生碰撞。

为了解决这个问题,哈希表会对 hashCode() 的结果进行进一步的处理,通常是使用取模运算将哈希码映射到合适的范围内。这样可以确保哈希码在哈希表的有效范围内,并且能够均匀地分布在哈希表的各个位置上,减少碰撞的概率。

哈希表内部使用一个数组(通常称为 table)来存储键值对。当插入或查找一个键值对时,哈希表会根据键的哈希码计算出一个索引,然后在对应的索引位置上进行插入或查找操作。如果发生碰撞(即多个键具有相同的索引),哈希表会使用一定的策略(如链表、红黑树等)来处理碰撞,保证数据的准确性和性能。

因此,哈希表不直接使用 hashCode() 处理后的值作为 table 的下标,而是通过额外的处理来映射哈希码到合适的索引位置,从而提高哈希表的性能和准确性。

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

HashMap 的长度通常选择为2的幂次方,这是因为在使用哈希表实现时,采用了一种称为“取模和掩码”的技术。
为了能让 HashMap 存取高效,尽量少碰撞,也就是要尽量把数据分配均匀。Hash 值的范围值-2147483648 到 2147483647,前后加起来大概 40 亿的映射空间,只要哈希函数映射得比较均匀松散,⼀般应用是很难出现碰撞的。但问题是⼀个 40 亿长度的数组,内存是放不下的。所以这个散列值是不能直接拿来用的。⽤之前还要先做对数组的⻓度取模运算,得到的余数才能用来要存放的位置也就是对应的数组下标。这个数组下标的计算⽅法是“ (n - 1) & hash ”。(n代表数组长度)。这也就解释了 HashMap 的长度为什么是 2 的幂次方。(位与)

我们⾸先可能会想到采⽤%取余的操作来实现。但是,重点来了:“取余(%)操作中如果除数是 2 的幂次则等价于与其除数减⼀的与(&)操作(也就是说hash%length==hash&(length-1)的前提是length 是 2 的 n 次⽅;)。” 并且 采⽤⼆进制位操作 &,相对于%能够提⾼运算效率,这就解释了HashMap 的⻓度为什么是 2 的幂次⽅。

49. HashMap 多线程操作导致死循环问题

因为Java的HashMap是非线程安全的,所以在并发下必然出现问题

主要原因在于并发下的 Rehash 会造成元素之间会形成⼀个循环链表。不过,jdk 1.8 后解决了这个问题,但是还是不建议在多线程下使⽤ HashMap,因为多线程下使用HashMap 还是会存在其他问题如数据丢失。并发环境下推荐使用ConcurrentHashMap

50. HashMap 有哪几种常见的遍历方式

  • 使用迭代器(Iterator)EntrySet 的方式进行遍历;
  • 使用迭代器(Iterator)KeySet 的方式进行遍历;
  • 使用 For Each EntrySet 的方式进行遍历;
  • 使用 For Each KeySet 的方式进行遍历;
  • 使用 Lambda 表达式的方式进行遍历;
  • 使用 Streams API 单线程的方式进行遍历;
  • 使用 Streams API 多线程的方式进行遍历。

51.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的幂作为哈希表的大小,后面会介绍到为什么是2的幂次方。
  5. 底层数据结构: JDK1.8 以后的 HashMap 在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为8)时,将链表转化为红黑树,以减少搜索时间。Hashtable 没有这样的机制。
  6. 推荐使用:在 Hashtable 的类注释可以看到,Hashtable 是保留类不建议使用,推荐在单线程环境下使用 HashMap 替代,如果需要多线程使用则用 ConcurrentHashMap 替代。

52.如何决定使用 HashMap 还是 TreeMap?

对于在Map中插入、删除和定位元素这类操作,HashMap是最好的选择。然而,假如你需要对一个有序的key集合进行遍历,TreeMap是更好的选择。基于你的collection的大小,也许向HashMap中添加元素会更快,将map换为TreeMap进行有序key的遍历。

53.HashMap 和 ConcurrentHashMap 的区别

  1. ConcurrentHashMap对整个桶数组进行了分割分段(Segment),然后在每一个分段上都用lock锁进行保护,相对于HashTable的synchronized锁的粒度更精细了一些,并发性能更好,而HashMap没有锁机制,不是线程安全的。(JDK1.8之后ConcurrentHashMap启用了一种全新的方式实现,利用CAS算法。)
  2. HashMap的键值对允许有null,但是ConCurrentHashMap都不允许。

54.ConcurrentHashMap 和 Hashtable 的区别?

ConcurrentHashMap 和 Hashtable 都是用于在多线程环境下操作的数据结构,它们之间有几个主要的区别:

  1. 线程安全性: ConcurrentHashMap 是在 JDK 1.5 引入的,专门为并发环境设计的高效线程安全的哈希表。它使用了分段锁(Segment Locking)的机制,将整个数据结构分成多个段(Segment),每个段都可以被独立地锁定,不同的线程可以同时访问不同的段,从而实现更高的并发性能。而 Hashtable 是在早期的 Java 版本中引入的线程安全的哈希表,它使用了全局锁(Global Lock),在每个操作上都需要锁定整个数据结构,因此在高并发情况下性能较差。
  2. 迭代器支持: ConcurrentHashMap 提供了弱一致性的迭代器(Weakly Consistent Iterator)支持,即在迭代过程中,可以看到某些更新操作之前的数据,但不能保证迭代器后续返回的数据是最新的。而 Hashtable 则没有提供弱一致性的迭代器支持。
  3. Null 值和键: ConcurrentHashMap 不允许使用 null 值和 null 键。如果尝试将 null 值或 null 键存储到 ConcurrentHashMap 中,将会抛出 NullPointerException。而 Hashtable 则允许使用 null 值和 null 键。
  4. 扩容机制: ConcurrentHashMap 的扩容机制更加高效。它使用了分段锁定的方式,在进行扩容时只需要锁定部分段,其他段仍然可以被并发地访问。而 Hashtable 的扩容机制需要锁定整个数据结构,会导致其他线程的阻塞。
  5. 底层数据结构: JDK1.7 的 ConcurrentHashMap 底层采⽤ 分段的数组+链表 实现,JDK1.8 采⽤的数据结构跟 HashMap1.8 的结构⼀样,数组+链表/红黑⼆叉树。 Hashtable 和 JDK1.8 之前的HashMap 的底层数据结构类似都是采用数组+链表 的形式,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的。
  6. 实现线程安全的方式(重要):
    在 JDK1.7 的时候, ConcurrentHashMap 对整个桶数组进行了分割分段( Segment ,分段锁),每⼀把锁只锁容器其中⼀部分数据(下⾯有示意图),多线程访问容器⾥不同数据段的数据,就不会存在锁竞争,提高并发访问率。到了 JDK1.8 的时候,ConcurrentHashMap 已经摒弃了 Segment 的概念,而是直接用Node 数组+链表+红黑树的数据结构来实现,并发控制使用 synchronized 和 CAS 来操作。(JDK1.6 以后 synchronized 锁做了很多优化) 整个看起来就像是优化过且线程安全的 HashMap ,虽然在 JDK1.8 中还能看到 Segment 的数据结构,但是已经简化了属性,只是为了兼容旧版本;Hashtable (同⼀把锁) :使⽤ synchronized 来保证线程安全,效率非常低下。当⼀个线程访问同步方法时,其他线程也访问同步方法,可能会进入阻塞或轮询状态,如使用 put 添加元素,另⼀个线程不能使⽤ put 添加元素,也不能使用 get,竞争会越来越激烈效率越低。

总的来说,如果需要在多线程环境下使用哈希表,推荐使用 ConcurrentHashMap,因为它提供了更好的并发性能和可扩展性。Hashtable 在现代的 Java 程序中已经不太常用,可以考虑使用 ConcurrentHashMap 或者更高级的并发集合类,如 ConcurrentSkipListMap 或 ConcurrentHashMap 的并发版本。

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

ConcurrentHashMap 的底层实现是基于散列(Hashing)和链表/红黑树(Linked List/Red-Black Tree)的组合数据结构。它使用了分段锁(Segment Locking)的机制来实现高效的并发访问。

具体实现原理如下:

  1. 分段数组:ConcurrentHashMap 内部维护了一个分段数组,每个段(Segment)都是一个类似于 HashMap 的哈希表,包含若干个哈希桶(Hash Bucket)。默认情况下,分段数组的大小为 16,并且可以根据需要进行动态扩容。
  2. 散列算法:ConcurrentHashMap 使用键的哈希码(Hash Code)来确定它应该被存储在哪个分段的哪个哈希桶中。通过对键的哈希码进行取模运算,可以快速地确定要访问的分段和哈希桶。
  3. 分段锁:每个分段都有一个独立的锁,通过对分段进行锁定,可以实现并发访问时的线程安全性。当一个线程访问一个分段时,只有该分段会被锁定,其他分段仍然可以被其他线程并发地访问。这样可以提高并发性能,减少线程之间的竞争。
  4. 键值存储:在每个哈希桶中,键值对被存储为链表或红黑树的节点。当节点数量达到一定阈值时,链表会转换为红黑树,以提高查找、插入和删除的性能。
  5. 扩容机制:当 ConcurrentHashMap 的负载因子(Load Factor)超过阈值时,会触发扩容操作。扩容时,每个分段都会根据需要进行扩容,新的分段数组会被创建,并且原来的键值对会重新分配到新的分段中。在扩容过程中,对不同分段的操作可以并发进行,只有涉及同一个分段的操作会被串行化。

通过分段锁和散列的结合,ConcurrentHashMap 实现了高效的并发访问,使得不同线程可以同时读取和写入不同的分段,从而提高了并发性能。同时,它还通过链表和红黑树的结合来提高键值存储的性能,以及动态扩容机制来适应数据的变化。
在这里插入图片描述

ConcurrentHashMap 是由 Segment 数组结构和 HashEntry 数组结构组成。
Segment 数组中的每个元素包含⼀个 HashEntry 数组,每个 HashEntry 数组属于链表结构

在这里插入图片描述

Java 8 ⼏乎完全重写了 ConcurrentHashMap ,代码量从原来 Java 7 中的 1000 多⾏,变成了现在的6000 多⾏。
ConcurrentHashMap 取消了 Segment 分段锁,采⽤ Node + CAS + synchronized 来保证并发安全。数据结构跟 HashMap 1.8 的结构类似,数组+链表/红⿊⼆叉树。Java 8 在链表⻓度超过⼀定阈值(8)时将链表(寻址时间复杂度为 O(N))转换为红⿊树(寻址时间复杂度为 O(log(N)))。Java 8 中,锁粒度更细, synchronized 只锁定当前链表或红⿊⼆叉树的⾸节点,这样只要 hash 不冲突,就不会产⽣并发,就不会影响其他 Node 的读写,效率⼤幅提升。

56. JDK 1.7 和 JDK 1.8 的 ConcurrentHashMap 实现有什么不同?

线程安全实现⽅式 : JDK 1.7 采⽤ Segment 分段锁来保证安全, Segment 是继承自ReentrantLock 。JDK1.8 放弃了 Segment 分段锁的设计,采用 Node + CAS + synchronized 保证线程安全,锁粒度更细, synchronized 只锁定当前链表或红黑二叉树的首节点。
Hash 碰撞解决⽅法 : JDK 1.7 采用拉链法,JDK1.8 采用拉链法结合红黑树(链表长度超过⼀定阈值时,将链表转换为红黑树)。
并发度 : JDK 1.7 最大并发度是 Segment 的个数,默认是 16。JDK 1.8 最大并发度是 Node 数组的大小,并发度更大。

57. Array 和 ArrayList 有何区别?

  • Array 可以存储基本数据类型和对象,ArrayList 只能存储对象。
  • Array 是指定固定大小的,而 ArrayList 大小是自动扩展的。
  • Array 内置方法没有 ArrayList 多,比如 addAll、removeAll、iteration 等方法只有 ArrayList 有。

对于基本类型数据,集合使用自动装箱来减少编码工作量。但是,当处理固定大小的基本数据类型的时候,这种方式相对比较慢。

58. 如何实现 Array 和 List 之间的转换?

  • Array 转 List: Arrays. asList(array) ;
  • List 转 Array:List 的 toArray() 方法。

59. comparable 和 comparator的区别?

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

一般我们需要对一个集合使用自定义排序时,我们就要重写compareTo方法或compare方法,当我们需要对某一个集合实现两种排序方式,比如一个song对象中的歌名和歌手名分别采用一种排序方法的话,我们可以重写compareTo方法和使用自制的Comparator方法或者以两个Comparator来实现歌名排序和歌星名排序,第二种代表我们只能使用两个参数版的Collections.sort().

Arrays.sort(int[] a)
这种形式是对一个数组的所有元素进行排序,并且是按从小到大的顺序
有两种方法可以解决多参数排序的问题

  • 第一种是继承comparable接口,并复写compareto方法,这样就可以直接使用Collections.sort()方法进行排序
  • 第二种方法是不继承任何接口,直接使用Collections.sort(数组,new Comparator{}),在new的comparator中复写compare方法即可

60. Collection 和 Collections 有什么区别?

  • java.util.Collection 是一个集合接口(集合类的一个顶级接口)。它提供了对集合对象进行基本操作的通用接口方法。Collection接口在Java 类库中有很多具体的实现。Collection接口的意义是为各种具体的集合提供了最大化的统一操作方式,其直接继承接口有List与Set。
  • Collections则是集合类的一个工具类/帮助类,其中提供了一系列静态方法,用于对集合中元素进行排序、搜索以及线程安全等各种操作。

61. TreeMap 和 TreeSet 在排序时如何比较元素?Collections 工具类中的 sort()方法如何比较元素?

TreeSet 要求存放的对象所属的类必须实现 Comparable 接口,该接口提供了比较元素的 compareTo()方法,当插入元素时会回调该方法比较元素的大小。TreeMap 要求存放的键值对映射的键必须实现 Comparable 接口从而根据键对元素进 行排 序。

Collections 工具类的 sort 方法有两种重载的形式,

第一种要求传入的待排序容器中存放的对象比较实现 Comparable 接口以实现元素的比较;

第二种不强制性的要求容器中的元素必须可比较,但是要求传入第二个参数,参数是Comparator 接口的子类型(需要重写 compare 方法实现元素的比较),相当于一个临时定义的排序规则,其实就是通过接口注入比较元素大小的算法,也是对回调模式的应用(Java 中对函数式编程的支持)。

62. 无序性和不可重复性的含义是什么

  • 无序性不等于随机性 ,无序性是指存储的数据在底层数组中并非按照数组索引的顺序添加 ,而是根据数据的哈希值决定的。
  • 不可重复性是指添加的元素按照 equals() 判断时 ,返回 false,需要同时重写 equals() 方法和hashCode() 方法。
  • 10
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

我是二次元穿越来的

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值