02Java集合

136 阅读7分钟

java集合

介绍java中的集合框架 +2

Java 集合, 也叫作容器,主要是由两大接口派生而来:

一个是 Collection接口,主要用于存放单一元素;

另一个是 Map 接口,主要用于存放键值对。

对于Collection 接口,下面又有三个主要的子接口:ListSetQueue

Java集合主要关系

collection接口有哪些实现类,list,set,map底下的常用集合,以及性能

  1. List
  • ArrayList:基于数组实现,随机访问速度快,适合随机读写操作,但在删除、插入等操作时效率相对较低。
  • LinkedList:基于双向链表实现,插入、删除操作效率较高,但随机访问效率较低。
  1. Set
  • HashSet:基于哈希表实现,不允许元素重复,插入、删除操作效率较高,但元素的顺序不是固定的。
  • TreeSet:基于红黑树实现,元素有序,但效率相对较低。
  1. Map
  • HashMap:基于哈希表实现,键值对无序,插入、删除操作效率较高,但元素的顺序不是固定的。
  • TreeMap:基于红黑树实现,键值对有序,但效率相对较低。

List/Set

集合是否能同时遍历和修改?这里回答了会抛出异常 可以用迭代器遍历

在使用迭代器遍历集合时,不要直接使用集合提供的 add/remove 方法修改集合,会引发并发修改异常(ConcurrentModificationException)。如果需要修改集合,应该使用迭代器的 remove 方法。

Java 8 中的 Stream API 提供了一种可以同时遍历和修改集合的方式。可以通过 filter、map、reduce 等方法对集合元素进行处理,最终生成一个新的集合。这种方式不会直接修改原有集合,因此不会引发并发修改异常。

List

arraylist和数组有什么区别

  1. 大小可变性:ArrayList 的大小是可以动态变化的,而数组一旦被创建,大小就是固定的,无法改变。
  2. 级别不同:ArrayList 是集合类的一种,提供了许多方便的操作方法,而数组是一种数据结构。
  3. 对象类型:ArrayList 可以存储任意类型的对象,而数组只能存储相同类型的数据。

ArrayList和LinkedList的区别 +3

(1)数据结构不同

  • ArrayList基于数组实现
  • LinkedList基于双向链表实现

(2) 多数情况下,ArrayList更利于查找,LinkedList更利于增删

  • ArrayList基于数组实现,get(int index)可以直接通过数组下标获取,时间复杂度是O(1);LinkedList基于链表实现,get(int index)需要遍历链表,时间复杂度是O(n);当然,get(E element)这种查找,两种集合都需要遍历,时间复杂度都是O(n)。
  • ArrayList增删如果是数组末尾的位置,直接插入或者删除就可以了,但是如果插入中间的位置,就需要把插入位置后的元素都向前或者向后移动,甚至还有可能触发扩容;双向链表的插入和删除只需要改变前驱节点、后继节点和插入节点的指向就行了,不需要移动元素。

**(3)**是否支持随机访问

  • ArrayList基于数组,所以它可以根据下标查找,支持随机访问,当然,它也实现了RandmoAccess 接口,这个接口只是用来标识是否支持随机访问。
  • LinkedList基于链表,所以它没法根据序号直接获取元素,它没有实现RandmoAccess 接口,标记不支持随机访问。

**(4)**内存占用,ArrayList基于数组,是一块连续的内存空间,LinkedList基于链表,内存空间不连续,它们在空间占用上都有一些额外的消耗:

  • ArrayList是预先定义好的数组,可能会有空的内存空间,存在一定空间浪费
  • LinkedList每个节点,需要存储前驱和后继,所以每个节点会占用更多的空间

ArrayList的扩容机制了解吗?

插入时候,会先检查是否需要扩容,如果当前容量+1超过数组长度,就会进行扩容。

ArrayList的扩容是创建一个1.5倍的新数组,然后把原数组的值拷贝过去。

你讲讲ArrayList的几种遍历方式?有什么问题

  1. for循环遍历:使用普通的for循环可以遍历ArrayList,代码如下:

    cssCopy codefor(int i=0; i<list.size(); i++) {
        // 访问list.get(i)
    }
    
  2. foreach循环遍历:使用foreach语法糖可以遍历ArrayList,代码如下:

    scssCopy codefor(Object obj: list) {
        // 访问obj
    }
    
  3. 迭代器遍历:使用迭代器可以遍历ArrayList,代码如下:

    vbnetCopy codeIterator iterator = list.iterator();
    while(iterator.hasNext()) {
        Object obj = iterator.next();
        // 访问obj
    }
    

其中,第一种for循环遍历是最常见的,但在删除元素时需要注意,如果直接使用普通的for循环进行删除操作,则会出现数组下标越界的问题;第二种foreach循环和第三种迭代器遍历则不会有这个问题。但是,在遍历过程中如果对集合进行增删操作,都会引起ConcurrentModificationException异常,因此需要使用Iterator的remove()方法进行删除。

Map

.hashmap的put流程

10.concurrenthashmap1.8后的优化

遍历集合过程中可以删除元素么?怎么解决的

为什么采用头插法?

头插法和尾插法是两种常见的链表插入方式。相比于尾插法,头插法在插入节点时不需要遍历整个链表找到最后一个节点,因此插入操作的时间复杂度为 O(1)。而尾插法则需要遍历整个链表,时间复杂度为 O(n)。因此,对于频繁插入的场景,采用头插法能够更加高效。而对于频繁删除的场景,则可以采用尾插法。

HashMap中JDK1.6/1.7的区别(√)

JDK8中HashMap的底层实现相比于JDK7做了哪些优化?

  1. 数据结构:数组 + 链表改成了数组 + 链表或红黑树

    原因:发生 hash 冲突,元素会存入链表,链表过长转为红黑树,将时间复杂度由O(n)降为O(logn)

  2. 链表插入方式:链表的插入方式从头插法改成了尾插法

    简单说就是插入时,如果数组位置上已经有元素,1.7 将新元素放到数组中,原始节点作为新节点的后继节点,1.8 遍历链表,将元素放置到链表的最后。

    原因:因为 1.7 头插法扩容时,头插法会使链表发生反转,多线程环境下会产生环。

  3. 扩容rehash:扩容的时候 1.7 需要对原数组中的元素进行重新 hash 定位在新数组的位置,1.8 采用更简单的判断逻辑,不需要重新通过哈希函数计算位置,新的位置不变或索引 + 新增容量大小。

    原因:提高扩容的效率,更快地扩容。

  4. 扩容时机:在插入时,1.7 先判断是否需要扩容,再插入,1.8 先进行插入,插入完成再判断是否需要扩容;

  5. 散列函数:1.7 做了四次移位和四次异或,jdk1.8只做一次。

    原因:做 4 次的话,边际效用也不大,改为一次,提升效率。

红黑树的特点?HashMap中为什么使用红黑树?

红黑树是一种自平衡二叉查找树,具有以下特点:

  1. 每个节点要么是黑色,要么是红色。
  2. 根节点是黑色。
  3. 每个叶子节点(NIL节点,空节点)是黑色。
  4. 如果一个节点是红色,则它的两个子节点都是黑色。
  5. 对于每个节点,从该节点到其所有后代叶子节点的简单路径上,均包含相同数目的黑色节点。

红黑树

HashMap中使用红黑树是为了解决哈希冲突导致链表过长的问题。在JDK8之前,HashMap的底层实现是数组+链表。当哈希冲突比较严重时,会导致链表过长,从而影响查询效率。为了解决这个问题,JDK8中使用了红黑树来代替链表,当链表长度超过一定阈值时(默认为8),链表就会转换为红黑树。这样可以保证HashMap的查询、插入和删除操作都可以在O(log n)的时间内完成,大大提高了HashMap的性能。

平衡二叉树是比红黑树更严格的平衡树,为了保持保持平衡,需要旋转的次数更多,平衡二叉树保持平衡的效率更低。

hashmap底层实现

JDK1.7的数据结构是数组+链表

JDK1.8的数据结构是数组+链表+红黑树

  • 数据元素通过映射关系,也就是散列函数,映射到桶数组对应索引的位置
  • 如果发生冲突,从冲突的位置拉一个链表,插入冲突的元素
  • 如果链表长度>8&数组大小>=64,链表转为红黑树
  • 如果红黑树节点个数<6 ,转为链表

HashMap线程安全吗,为什么不安全 +2

非线程安全,其底层实现使用了数组和链表或红黑树的结构,

在多线程环境下,如果两个线程同时进行插入或者删除操作,可能会导致链表或红黑树的结构被破坏,进而引发一系列错误。

举个例子,当两个线程同时调用put方法,且要插入的元素的哈希值相同,那么它们就可能会同时在数组的同一个位置上插入元素,那会造成前一个 key 被后一个 key 覆盖,从而导致元素的丢失。

因此,如果需要在多线程环境下使用HashMap,需要采取一些措施来保证其线程安全性,比如使用ConcurrentHashMap或者在代码中使用synchronized关键字等。

ConcurrentHashMap怎么保证线程安全的?

concurrentHashMap 的底层实现 +6

ConcurrentHashmap线程安全在jdk1.7版本是基于分段锁实现,在jdk1.8是基于CAS+synchronized实现。

从结构上说,1.7版本的ConcurrentHashMap采用分段锁机制,里面包含一个Segment数组,Segment继承于ReentrantLock,Segment则包含HashEntry的数组,HashEntry本身就是一个链表的结构,具有保存key、value的能力能指向下一个节点的指针。

实际上就是相当于每个Segment都是一个HashMap,默认的Segment长度是16,也就是支持16个线程的并发写,Segment之间相互不会受到影响。

1.7ConcurrentHashMap示意图

分段锁具体底层怎么实现的

后他问hashmap还有什么值得注意的地方

  1. 线程不安全:HashMap不是线程安全的,多线程环境下需要考虑并发访问的问题。
  2. 初始容量和负载因子:HashMap的构造方法中可以指定初始容量和负载因子。如果不指定,默认初始容量是16,负载因子是0.75。初始容量指的是HashMap中桶的数量,负载因子是指HashMap在达到容量阈值之前可以允许的最大负载因子。当HashMap中元素的数量超过容量阈值(初始容量 * 负载因子)时,HashMap会进行扩容。
  3. 键的哈希值和equals方法:HashMap中键的哈希值和equals方法决定了键值对的存储和查找方式。在使用自定义对象作为HashMap的键时,需要注意实现对象的hashCode方法和equals方法,保证hashCode方法返回的哈希值相同的对象在equals方法比较时也应该相等。
  4. 链表转化为红黑树:当HashMap中的链表长度达到8时,链表就会转化为红黑树,以提高查找的效率。但是,当红黑树中的节点数量小于6时,红黑树会转化回链表。这个转化的过程会造成一定的性能损失。
  5. 迭代器的快速失败:在迭代HashMap时,如果在迭代过程中HashMap被修改了,就会抛出ConcurrentModificationException异常,从而保证了迭代器的快速失败。

Map的哪种遍历方式效率比较高?

在使用 Map 进行遍历时,推荐使用 entrySet() 方法返回的集合进行遍历,因为 entrySet() 方法返回的是 Map.Entry<K,V> 的集合,包含了键值对中的键和值,因此在遍历时不需要通过键再去获取值,可以提高效率。下面是使用 entrySet() 进行遍历的示例代码:

goCopy codeMap<String, Integer> map = new HashMap<>();
// 添加键值对
map.put("A", 1);
map.put("B", 2);
map.put("C", 3);

// 使用 entrySet() 方法遍历
for (Map.Entry<String, Integer> entry : map.entrySet()) {
    System.out.println("Key: " + entry.getKey() + ", Value: " + entry.getValue());
}

另外,如果只需要遍历 Map 中的键或者值,也可以使用 keySet() 或者 values() 方法返回的集合进行遍历,但需要注意,在遍历过程中需要通过键或值去获取对应的值或键,可能会影响效率。

为什么hashmap后面链表>8会自动转换成红黑树

当元素数量较多时,链表的查询效率会下降,而红黑树的查询效率要更高一些,这样就能更快地查找元素。另外,当桶中元素数量小于 8 个时,使用链表结构可以更快地进行操作,避免了建立红黑树的开销。因此,HashMap 采用了这种自适应的存储方式,能够在不同的元素数量范围内采用最优的存储方式。

Set

HashSet底层

HashSet 底层就是基于 HashMap 实现的。

HashSet的add方法,直接调用HashMap的put方法,将添加的元素作为key,new一个Object作为value,直接调用HashMap的put方法,它会根据返回值是否为空来判断是否插入元素成功。

JAVA中常用的数据结构说几种

  1. 数组(Array):一种线性结构,用于存储一组相同数据类型的元素。
  2. 链表(Linked List):一种非线性结构,由节点组成,每个节点包含一个数据项和指向下一个节点的指针。
  3. 栈(Stack):一种线性结构,具有后进先出的特点。
  4. 队列(Queue):一种线性结构,具有先进先出的特点。
  5. 堆(Heap):一种完全二叉树结构,具有最大值或最小值总是在堆的根节点的特点。
  6. 树(Tree):一种非线性结构,由节点组成,每个节点最多有两个子节点。
  7. 图(Graph):一种非线性结构,由节点和边组成,用于表示不同节点之间的关系。
  8. 散列表(Hash Table):一种以键值对形式存储数据的数据结构,通过哈希函数计算键的索引,实现高效的数据查找和插入。

对于解决哈希冲突来说,常见的方法

那Java中用来解决hash冲突的方法是什么,底层的实现细节(关于为什么是8,我说的是因为泊松分布,面试官说其实按魔法数理解会合适一些)

对于解决哈希冲突,常见的方法包括:

  1. 链地址法(Separate Chaining):每个桶都存储一个链表,哈希值相同的元素会被放到同一个桶中,如果发生哈希冲突,则将新元素插入到链表中。这是一种简单的方法,但是需要维护链表,而且当链表过长时,会影响查询效率。
  2. 开放地址法(Open Addressing):当发生哈希冲突时,通过某种规则在散列表中寻找下一个空槽来存储元素,具体规则包括:线性探测(Linear Probing)、二次探测(Quadratic Probing)和双重哈希(Double Hashing)等。这种方法不需要维护链表,而且可以充分利用空间,但是当散列表元素较多时,探测时间会增加。
  3. 建立公共溢出区:当哈希冲突发生时,将元素存储到一个公共溢出区中,通过一个指针链表将所有溢出的元素连接起来。这种方法相对简单,但需要一个额外的空间来存储溢出元素。

在 Java 中,HashMap 采用的是链地址法,当链表长度大于一定阈值时,会将链表转化为红黑树。同时,JDK 1.8 中也增加了一种新的解决哈希冲突的方法,称为尝试插入当前元素,如果插入失败就重新计算 hash 值并重新插入的“链表 + 红黑树 + 原地扩容”算法,进一步提高了 HashMap 的性能。