Java集合面试

76 阅读7分钟

1.ArrayList和LinkedList的区别

 ArrayList 用来存储数据的格式是Object[]数组
 Array获取数据的时间复杂度是O(1)
 有很好的遍历性能
 但是删除操作的开销很大 因为需要重排数组中的元素
​
 add() 方法
    1.考虑是否要进行扩容 每次扩容当前容量的 1/2
    2.直接将 array[size] 赋值为当前元素 size再加一。
 remove() 方法
    1.判断移除的是否为末尾元素
    2.不是末尾元素需要做 元素的重排
    3.array[size-1] 置为null size 再减一
 get() 方法
    1.取当前数组array[i] 值
​
 ArrayList 的三种循环删除元素的方式
    1. for i 不会进行报错
          但是可能会出现有的元素未被删除 因为每一次删除操作后都会有一次元素重排 导致后面的元素前移
    2. forEach 会进行报错
          forEach内部会调用 iterator操作 iterator内部维护了一个 expectedModCount 操作次数。
          如果不是通过Iterator的remove方法取调用的话 expectedModCount不会更新 expectedModCount!=modCount 就会报错
    3. Iterator 不会进行报错
          注意 每次调用next() 使得游标右移
          Iterator的remove方法会对expectedModCount进行更新
          
 LinkedList 是采用双向链表的方式来对元素进行存储
 维护了一个 first 头结点和last尾节点
 通过要操作的数据下标来判断要从 first还是last进行遍历数据
 add() 方法
    1.判断当前last是否为null
    2.null设置当前元素为 firstlast节点 不为null的时候设置当前元素为last节点 并且原last节点的next指向当前元素
 remove() 方法
    对对应的链表的 next 和 prev 属性进行重新绑定。把该元素的所有属性设置为null
 get() 方法
     1.判断下标靠近first还是last节点
     2.根据first向下遍历 或者 last向上遍历
 
​
和LinkedList的区别
     遍历和修改速度: ArrayList>LinkedList
     删除速度: ArrayList<LinkedList
     插入速度:
             尾部插入:在尾部插入数据,数据量较小时LinkedList比较快,
             因为ArrayList要频繁扩容,当数据量大时ArrayList比较快,
             因为ArrayList扩容是当前容量*1.5,大容量扩容一次就能提供很多空间,当ArrayList不需扩容时效率明显比LinkedList高,因为直接数组元素赋值不需new Node
​
             首部插入:在首部插入数据,LinkedList较快,因为LinkedList遍历插入位置花费时间很小,而ArrayList需要将原数组所有元素进行一次System.arraycopy
​
             插入位置越往中间,LinkedList效率越低,因为它遍历获取插入位置是从两头往中间搜,index越往中间遍历越久,因此ArrayList的插入效率可能会比LinkedList高
​
             插入位置越往后,ArrayList效率越高,因为数组需要复制后移的数据少了,那么System.arraycopy就快了,因此在首部插入数据LinkedList效率比ArrayList高,尾部插入数据ArrayList效率比LinkedList高
             
​
ArrayListLinkedListHashSet
数据结构Object[] 容量达到一半时扩容1/2双向链表维护了一个 first 头结点和last尾节点数组+链表+红黑树 和HashMap一致
遍历速度
修改速度
插入速度:头部插入
插入速度:尾部插入数据量小的时候涉及扩容操作 慢,数据量大的时候不需要扩容 快
插入速度:中间插入
删除速度:随机删除
重复数据可以可以不可以 因为HashSet使用的事HashMap的key 不能重复

2.HashMap和HashTable的区别

HashMapHashTable
数据结构数组+链表+红黑树Entry数组
null值key,value都可以为nullkey,value都不可以为null
线程安全不安全安全
初始容量扩容参数16 0.7511 0.75

3.说说List,Set,Map三者的区别

List集合存储的元素是有序的,且允许相同的元素存在。 Set存储的是不相同的元素,也就是不允许存在相同的元素。 Map存储的是Key—Value键值对,一个Key只能够对应一个Value。而且它不允许存在相同的Key,但是可以存在相同的Values。

4.说说HashMap

1.HashMap的数据结构

jdk1.7 : 数组+链表

jdk1.8 : 数组+链表+红黑树 当链表过长时会影响访问效率 所以引入了红黑树(二叉树在特殊情况下会变成一条线性结构,这就跟原来的链表结构一样了,平衡二叉树在追求完全平衡的过程中 会使得插入性能下降 综合考虑使用红黑树)

2.什么情况下转为树 ,什么情况下转为链表

1.转为红黑树
  • 当链表超过 8 且数据总量超过 64 才会转红黑树。
  • 将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树,以减少搜索时间
2.转为链表
  • 当子数的节点数少于6个的时候红黑树退化为链表

5.为什么在解决 hash 冲突的时候,不直接用红黑树?而选择先用链表,再转红黑树?

  • 元素过少时。链表的查询效率和红黑树相差不大 红黑树搜索时间复杂度是 O(logn),而链表是 O(n)
  • 红黑树进行插入操作时需要进行左旋右旋 变色操作来进行平衡 而链表不需要

6.为什么链表改为红黑树的阈值是 8?

是因为泊松分布,我们来看作者在源码中的注释:

 Because TreeNodes are about twice the size of regular nodes, we
 use them only when bins contain enough nodes to warrant use
 (see TREEIFY_THRESHOLD). And when they become too small (due to
 removal or resizing) they are converted back to plain bins.  In
 usages with well-distributed user hashCodes, tree bins are
 rarely used.  Ideally, under random hashCodes, the frequency of
 nodes in bins follows a Poisson distribution
 (http://en.wikipedia.org/wiki/Poisson_distribution) with a
 parameter of about 0.5 on average for the default resizing
 threshold of 0.75, although with a large variance because of
 resizing granularity. Ignoring variance, the expected
 occurrences of list size k are (exp(-0.5) pow(0.5, k) /
 factorial(k)). The first values are:
 0:    0.60653066
 1:    0.30326533
 2:    0.07581633
 3:    0.01263606
 4:    0.00157952
 5:    0.00015795
 6:    0.00001316
 7:    0.00000094
 8:    0.00000006
 more: less than 1 in ten million

翻译过来大概的意思是:理想情况下使用随机的哈希码,容器中节点分布在 hash 桶中的频率遵循**泊松分布**,按照泊松分布的计算公式计算出了桶中元素个数和概率的对照表,可以看到链表中元素个数为 8 时的概率已经非常小,再多的就更少了,所以原作者在选择链表元素个数时选择了 8,是根据概率统计而选择的。

7.默认加载因子是多少?为什么是 0.75,不是 0.6 或者 0.8 ?

Node[] table的初始化长度length(默认值是16),Load factor为负载因子(默认值是0.75),threshold是HashMap所能容纳键值对的最大值。threshold = length * Load factor。也就是说,在数组定义好长度之后,负载因子越大,所能容纳的键值对个数越多。

默认的loadFactor是0.75,0.75是对空间和时间效率的一个平衡选择,一般不要修改,除非在时间和空间比较特殊的情况下 :

  • 如果内存空间很多而又对时间效率要求很高,可以降低负载因子Load factor的值 。
  • 相反,如果内存空间紧张而对时间效率要求不高,可以增加负载因子loadFactor的值,这个值可以大于1。

我们来追溯下作者在源码中的注释(JDK1.7):

As a general rule, the default load factor (.75) offers a good tradeoff between time and space costs. Higher values decrease the space overhead but increase the lookup cost (reflected in most of the operations of the HashMap class, including get and put). The expected number of entries in the map and its load factor should be taken into account when setting its initial capacity, so as to minimize the number of rehash operations. If the initial capacity is greater than the maximum number of entries divided by the load factor, no rehash operations will ever occur.

翻译过来大概的意思是:作为一般规则,默认负载因子(0.75)在时间和空间成本上提供了很好的折衷。较高的值会降低空间开销,但提高查找成本(体现在大多数的HashMap类的操作,包括get和put)。设置初始大小时,应该考虑预计的entry数在map及其负载系数,并且尽量减少rehash操作的次数。如果初始容量大于最大条目数除以负载因子,rehash操作将不会发生。

8.HashMap 中 key 的存储索引是怎么计算的?

  1. 取key的haasCode值与与高16为进行异或操作
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

2.(n - 1) & hash 再将数组的长度-1和hash值进行与操作得到一个[0,n-1]的值作为数组的下标

9.HashMap数组的长度为什么是 2 的幂次方?

这样做效果上等同于取模,在速度、效率上比直接取模要快得多。除此之外,2 的 N 次幂有助于减少碰撞的几率。如果 length 为2的幂次方,则 length-1 转化为二进制必定是11111……的形式,在与h的二进制与操作效率会非常的快,而且空间不浪费。

10.put方法的流程

  1. 给key计算一个hash值,将key的hashCode和hashCode的低十六位进行异或操作(相同为0不同为1)
  2. 判断HashMap的数据节点table 是否为null HashMap是懒加载的。只有第一次put才会去初始化table
  3. (n - 1) & hash 操作取得一个数组下标。判断数组该下标下是否有节点(红黑树或者链表节点),没有则创建链表节点
  4. 判断是否出现hash冲突。如果出现 覆盖掉原来的值。没有出现的话 则把key添加道链表的尾端(判断是否需要由链表结构转为红黑树结构 链表长度>8 元素数量大于64) 或者红黑树上。

image-20230209192222808.png

11.JDK1.7 和1.8 的put方法区别是什么?

  • jdk1.7的数据结构时数组+链表 少了判断要转为红黑树的过程
  • 链表元素插入的时候jdk1.7采用的是头插法。jdk1.8采用的是尾插法 在并发场景下 容易产生死循环。

12.HashMap扩容机制

Hashmap 在容量超过负载因子所定义的容量之后,就会扩容。Java 里的数组是无法自动扩容的,方法是将 Hashmap 的大小扩大为原来数组的两倍,并将原来的对象放入新的数组中。

  1. 将原来的hash和旧的table的length()进行 & 操作 得到的值为 0或者 1
  2. 如果是0的话说明扩容之后该元素还是在当前位置不需要移动。如果是1的话移到 乘以2的下标位置。红黑树移动之后需要考虑 树转链表的操作

13.ConcurrentHashMap的实现原理是什么

JDK1.7 中的 ConcurrentHashMap 是由 Segment 数组结构和 HashEntry 数组结构组成,即 ConcurrentHashMap 把哈希桶数组切分成小数组(Segment ),每个小数组有 n 个 HashEntry 组成。

如下图所示,首先将数据分为一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一段数据时,其他段的数据也能被其他线程访问,实现了真正的并发访问。

img

Segment 是 ConcurrentHashMap 的一个内部类,主要的组成如下:

img

其中,用 volatile 修饰了 HashEntry 的数据 value 和 下一个节点 next,保证了多线程环境下数据获取时的可见性

再来看下JDK1.8

在数据结构上, JDK1.8 中的ConcurrentHashMap 选择了与 HashMap 相同的Node数组+链表+红黑树结构;在锁的实现上,抛弃了原有的 Segment 分段锁,采用CAS + synchronized实现更加细粒度的锁。

将锁的级别控制在了更细粒度的哈希桶数组元素级别,也就是说只需要锁住这个链表头节点(红黑树的根节点),就不会影响其他的哈希桶数组元素的读写,大大提高了并发度。 img

JDK1.8 中为什么使用内置锁 synchronized替换 可重入锁 ReentrantLock?

  • 在 JDK1.6 中,对 synchronized 锁的实现引入了大量的优化,并且 synchronized 有多种锁状态,会从无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁一步步转换。
  • 减少内存开销 。假设使用可重入锁来获得同步支持,那么每个节点都需要通过继承 AQS 来获得同步支持。但并不是每个节点都需要获得同步支持的,只有链表的头节点(红黑树的根节点)需要同步,这无疑带来了巨大内存浪费。

14.ConcurrentHashMap的put过程

www.processon.com/view/link/6…

15.ConcurrentHashMap get方法的执行逻辑是什么 get方法是否要加锁 为什么

  1. 根据 key计算出hash值
  2. 判断 数组为null 或者 对应的 hash & (n-1) 对应的数组下标为null 则直接返回null
  3. 如果链表第一个元素 的key 和 传入key相等 返回value 不是就遍历链表
  4. 如果元素节点是树结构 遍历红黑树找到对应的value

get 方法不需要加锁。因为 Node 的元素 value 和指针 next 是用 volatile 修饰的,在多线程环境下线程A修改节点的 value 或者新增节点的时候是对线程B可见的。

16.ConcurrentHashMap 不支持key 和value为null的原因

我们先来说value 为什么不能为 null。因为 ConcurrentHashMap 是用于多线程的 ,如果ConcurrentHashMap.get(key)得到了 null ,这就无法判断,是映射的value是 null ,还是没有找到对应的key而为 null ,就有了二义性。

而用于单线程状态的 HashMap 却可以用containsKey(key) 去判断到底是否包含了这个 null 。

我们用反证法来推理:

假设 ConcurrentHashMap 允许存放值为 null 的 value,这时有A、B两个线程,线程A调用ConcurrentHashMap.get(key)方法,返回为 null ,我们不知道这个 null 是没有映射的 null ,还是存的值就是 null 。

假设此时,返回为 null 的真实情况是没有找到对应的 key。那么,我们可以用 ConcurrentHashMap.containsKey(key)来验证我们的假设是否成立,我们期望的结果是返回 false 。

但是在我们调用 ConcurrentHashMap.get(key)方法之后,containsKey方法之前,线程B执行了ConcurrentHashMap.put(key, null)的操作。那么我们调用containsKey方法返回的就是 true 了,这就与我们的假设的真实情况不符合了,这就有了二义性。