Java集合常见面试题

101 阅读13分钟

List Set Map的区别? 有哪些实现类?底层实现是什么?

种类ListSetMap
元素是否有序有序无序无序
元素是否重复可重复唯一key唯一
存储的元素类型key-value键值对
  • List:

    • ArrayList: Object[] 数组
    • LinkedList: 双向循环链表
    • Vector: Object[] 数组
  • Set:

    • HashSet: 基于HashMap实现的
    • LinkedHashSet: 基于链表和哈希表, 元素的插入和取出的顺序满足FIFO
    • TreeSet: 基于红黑树实现的
  • Map:

    • HashMap: 数组+链表+红黑树
    • Hashtable:

ArrayList和LinkedList的区别?

  • 底层实现不同: ArrayList是基于动态数组的数据结构,而LinkedList是基于链表的数据结构
  • 随机访问性能不同: ArrayList 优于 LinkedList, 因为 ArrayList 可以根据下标以 O(1) 时间复杂度对元素进程随机访问。 而LinkedList的访问时间复杂都为O(n), 因为它需要变量整个链表才能找到指定的元素。
  • 插入和删除性能不同: LinkedList 优于 ArrayList ,因为LinkedList的底层物理结构是链表,因此根据索引曾访问效率低,但是插入和删除不需要移动元素,只需要修改前后元素的指向关系即可,而且链表的添加不会升级到扩容问题。 数据索引访问的效率高,但是非末尾位置的插入和删除效率不高,因为涉及到移动元素,另外添加操作时涉及到扩容问题,会增加时空消耗。

ArrayList和Vector的区别?

  • 底层物理结构都是动态数组。
  • ArrayList时新版的动态数组,线程不安全,效率高, Vector是旧版的动态数组,线程安全,效率低。
  • 动态数组的扩容机制不同,ArrayList扩容为原来的1.5倍, Vector扩容增加为原来的2倍。
  • 数组的初始化容量,如果再构建ArrayList和Vector的集合对象时,没有显示指定初始化容量,那么Vector的内部数组的初始容量默认为10,而ArrayList在JDK1.6之前也是10, JDK1.7之后ArrayList初始化长度为0的空数组,之后在添加第一个元素时,再创建长度为10的数组。(用的时候再创建数组,避免浪费。)
  • Vector因为版本古老,支持Enumeration迭代器。但是该迭代器不支持快速失败。而Iterator和ListIterator迭代器支持快速失败。如果再迭代器创建后任意时间从结构上修改了向量(通过迭代器自身的remove和add之外的任何其他方式),则迭代器将抛出 ConcurrentModifiactionException. 因此,面对并发的修改,迭代器很快就完全失败,而不是冒着再将来不确定的时间任意发生不确定性的风险。

ArrayList的扩容机制

  • 快速失败的思想:对可能发生的异常提前表示故障并停止运行,通过提前的发现和停止错误,可以降低故障的级联风险。
  • 初始化ArrayList的长度为0的空数组,当插入第一个元素时,再创建长度为10的数组
  • 插入数据时,判断插入后的最小需要容量是否大于当前数组长度,
    • 如果大于,并且扩容后的新数组容量大于最小需要容量,就创建一个当前大小的1.5倍的空数组,将原来的数据复制过来,并进行插入,
    • 如果大于,并且扩容后新数组的容量小于等于最小需要容量,就创建一个等于插入后的大小的数据,将原始数据复制过来,并进行插入。

Queue和Deque的区别

  • Queue: 单端队列,只能从一端插入,一端删除,遵循FIFO原则。 扩展了Colletions,会因为容量问题导致操作失败而处理的方式不同:一种是操作失败后抛异常,一种是返回特殊值
Queue接口抛出异常返回特殊值
插入末尾add(E e)offer(E e)
删除首位remove()poll()
查看首位element()peek()
  • Deque: 双端队列,首尾两端都可以插入和删除,并且也因为容量问题导致操作失败而处理的方式不同
Deque接口抛出异常返回特殊值
插入首位addFirst(E e)offerFirst(E e)
插入末尾addLast(E e)offerLast(E e)
删除首位removeFirst()pollFirst()
删除末尾removeLast()pollLast()
查看首位getFirst()peekFirst()
查看末尾getLast()peekLast()

ArrayDeque 和 LinkedList的区别:

  • ArrayDeque 和 LinkedList都是实现了 Deque接口,都具有队列的功能。
    • 从结构上:ArrayDeque通过可变长的数组+双指针实现; ListedList通过链表来实现
    • 从扩容角度上说: ArrayDeque基于数组,可以根据插入的数据个数进行动态扩容,扩容之后,插入仍是O(1); LinkedList是每插入一个都会申请新的堆空间。
    • 存储NUll值: ArrayDeque不支持, LinkedList支持

PriorityQueue 的特点

  • 元素出队的顺序与优先级相关,优先级越高的越先出队
  • 底层使用二叉堆结构实现的,并用可变长的数组来进行存储数据的。
  • 非线程安全的。
  • 默认是小顶堆,可以通过Comparator构建函数,来修改优先级的先后。

HashMap和Hashtable的区别?

  • 都是实现了Map接口,用于存储键值对的数据结构,底层数据结构都是数组加链表形式。
  • 线程安全:Hashtable是线程安全的,而HashMap是非线程安全的。
  • 性能:因为Hashtable使用synchronized给整个方法加锁,所以相比HashMap来说,它的性能不如HashMap。
  • 存储:HashMap允许key和value为null,而Hashtable不允许存储null键和null值。
  • Hashtable不能存储的原因:key值进行哈希计算,如果为null的话,无法调用该方法,还会抛出空指针异常。而value为null也会主动抛出空指针异常。
  • HashMap允许key和value为null的原因:hash()对null的值进行了特殊处理,如果为null,会把值赋值为0.
  • Hashtable是线程安全,但性能差,不推荐使用。推荐使用ConcurrentHashMap在多线程场景下使用。

HashMap和HashSet的区别?

  • HashSet实现了Set接口,只存储对象; HashMap实现了Map接口,用于存储键值对。
  • HashSet底层用HashMap存储,HashSet封装了一系列HashMap的方法,HashSet将值保存到HashMap的key里。
  • HashSet不允许有重复的值。 而HashMap的键不能重复,值可以重复。

HashMap

解决【index】冲突问题

  • 虽然使用hashCode(),来尽量减少冲突,但仍然存在两个不同对象返回hashCode值相同的情况。
  • JDK1.8之前使用:数组+链表
  • JDK1.8之后使用:数组+链表/红黑树
  • 即hash值相同的元素,存储在同一个桶table[index]中,使用链表或红黑树连接起来。
  • 哈希冲突的键值对以链表的形式存储在同一索引位置,插入新节点时,采用尾插法(JDK8改进,JDK7是头插法(多线程并发扩容时,头头插法导致链表反转,产生循环引用))

为什么1.8会出现红黑树和链表共存

  • 当hash冲突比较严重时,链表长度会很长,导致查询效率降低,而选择二叉树可以大大提高查询效率。但由于二叉树的结构太复杂,节点个数小时,选择链表反而更加简单。所以会出现红黑树与链表共存。

什么时候树化?什么时候反树化?

static final int TREEIFY_THRESHOLD = 8;//树化阈值
static final int UNTREEIFY_THRESHOLD = 6;//反树化阈值
static final int MIN_TREEIFY_CAPACITY = 64;//最小树化容量
  • 当table[index]下的链表节点个数达到8,并且 table.length >= 64, 那么新Entry对象还添加到该table[index]中,那么就会将table[index]的链表进行树化。
  • 当某table[index]下的红黑树节点个数少于6个,此时
    • 当继续删除table[index下]的树结点,最后这个根节点的左右结点有null,或者根节点的左结点为null,会反树化。
    • 当重新添加新的映射关系到map中,导致了map重新扩容了,这个时候如果table[index]下面还是小于等于6个,会反树化。

负载因子为什么是0.75

//初始化容量:
int DEFAULT_INITIAL_CAPACITY = 1 << 4;//16  目的是体现2的n次方
//①默认负载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//②阈值:扩容的临界值
int threshold;
threshold = table.length * loadFactor;
//③负载因子
final float loadFactor;
  • 如果太大,threshold就会很大,那么如果冲突比较严重的话,就会导致table[index]下面的结点个数很多,影响效率。
  • 如果太小,threshold就会很小,那么数据扩容的频率就会提高,数组的使用率也会降低,那么会造成空间浪费。

1.7 put 源码分析

  • 当第一次添加映射关系时,数组初始化为一个长度为16的HashMapEntry的数组,这个HashMapEntry的数组,这个HashMapEntry类型是实现了java.util.Map.Entry接口。
  • 特殊考虑:如果key为null,index直接是[0],hash也是0.
  • 如果key不为null,在计算index之前,会对key的hashCode()值,做一个hash(key)再次哈希的运算,这样可以使得Entry对象更加散列的存储到table中
  • 计算index=table.length-1 & hash
  • 如果table[index]下面,已经有映射关系的key与要添加的映射关系的key相同了,会用新的value替换旧的value
  • 如果没有相同的,会把新映射关系添加到链表的头,原来table[index]下面的Entry对象连接到新的映射关系的next中。
  • 添加之前先判断if(size >= thresold && table[index] != null) 如果该条件为true,①会扩容 ②会重新计算key的hash ③会重新计算index。
  • size++

1.8put源码分析

1)DEFAULT_INITIAL_CAPACITY:默认的初始容量 162)MAXIMUM_CAPACITY:最大容量  1 << 303)DEFAULT_LOAD_FACTOR:默认加载因子 0.754)TREEIFY_THRESHOLD:默认树化阈值8,当链表的长度达到这个值后,要考虑树化
(5)UNTREEIFY_THRESHOLD:默认反树化阈值6,当树中的结点的个数达到这个阈值后,要考虑变为链表
(6)MIN_TREEIFY_CAPACITY:最小树化容量64
		当单个的链表的结点个数达到8,并且table的长度达到64,才会树化。
		当单个的链表的结点个数达到8,但是table的长度未达到64,会先扩容
(7)Node<K,V>[] table:数组
(8)size:记录有效映射关系的对数,也是Entry对象的个数
(9int threshold:阈值,当size达到阈值时,考虑扩容
(10double loadFactor:加载因子,影响扩容的频率
  • 先计算key的hash值,如果key是null,hash值就是0,如果不为null,使用((h=key.hashCode)^(h >> 16)) 得到hash值。
  • 若table是空的,先初始化table数组。
  • 通过hash值计算存储的索引位置index = hash & (table.length - 1)
  • 如果table[index]==null, 那么直接创建一个Node结点存储到table[index]中即可
  • 如果table[index] != null
    • 判断table[index]的根节点的key是否与新的key 相同(hash值相同并且满足key地址相同或key的equals返回true),如果是那么用一个Node结点变量e记录这个根结点
    • 如果table[index]的根结点的key与新key 不相同,而且table[index]是一个TreeNode结点,说明table[index]下是一棵红黑树,如果该树的某个结点的key与新的key相同(hash值相同并且满足key的地址相同或者key的equals返回true),那么用一个Node结点变量e记录这个相同的结点,否则将(key,value)封装为一个TreeNode结点,连接到红黑树中
    • 如果table[index]的根结点的key与新key不相同,并且table[index]不是一个TreeNode结点,说明table[index]下是一个链表,如果该链表中的某个结点的key与新的key相同,那么用一个Node结点变量e来记录这个相同的结点,否则将新的映射关系封装为一个Node的结点直接链接到链表尾部,并且判断table[index]下结点个数达到 TREEIFY_THRESHOLD(8) 个,如果table[index]下的结点个数达到了8个, 那么判断talbe.length 是否达到 MIN_TREEIFY_CAPACITY(64) 个,如果没有达到,那么先扩容,扩容会导致所有元素重新计算index,并且调整位置,如果table[index]下结点个达到8个,并且table.length的个数达到64,那么会将该链表转为一颗自平衡的红黑树。
  • 如果table[index]下找到了新key相同的结点,即e不为空,那么用新value替换原来的value,并返回旧value,结束put的方法。
  • 如果新增结点而不是替换,那么size++,并且还要重新判断size是否达到threshold阈值,如果达到,还要扩容。

image.png

ConcurrentHashMap和Hashtable的区别?

  • 底层结构:
    • ConcurrentHashMap的1.7版本采用 分段的数组+链表; 1.8后采用数组+链表/红黑树的结构
    • Hashtable 采用数组+链表的结构
  • 实现线程安全的方式:
    • ConcurrentHashMap:

      • 1.7 采用segment分段锁,将整个桶数组分割分段(分段锁),每把锁只锁定一部分数据,不同线程访问不同段的数据,就不会存在锁竞争,提高并发访问效率
      • 1.8 摒弃分段锁,采用Node数组+链表+红黑树,采用synchronized 和 CAS 来实现线程安全的。
    • Hashtable(同一把锁): 使用synchronized进行修饰, 当一个线程进行访问同步方法,其他线程也进行访问,就会导致进入阻塞或轮询状态。效率低下,一个线程使用put,其他线程就不能访问put或get.

ConcurrentHashMap的线程安全实现方案和具体底层实现方案?

  • 1.8底层采用Node数组+链表/红黑树,当Hash冲突到一定地步时,就会将链表转换为红黑树。
  • put的流程:
    • 1.通过key计算出哈希值
    • 2.判断是否需要初始化
    • 3.通过key的哈希值,定位Node数组的位置,如果为空,通过CAS写入,失败则自旋保证成功。
    • 4.如果 hashCode == MOVED == -1 的值,则需要扩容。
    • 5.如果不满足,通过synchronized锁定写入数据
      1. 如果数量大于 树化临界值就会执行树化方法 ,treeifyBin会判断数组长度>=64,会将链表转化为红黑树。

image.png

为什么ConcurrentHashMap不能添加null?

  • 在HashMap中,Key和value值都可以为null
  • 在ConcurrentHashMap中,key或者value值都不能为null。由于底层实现的时候,如果key和value若为null,会抛出NullPointerException的异常。更深层次的原因:如果允许ConcurrentHashMap的key或者value为null的情况下,就会存在经典的“二义性问题”
  • 对应以上null的二义性:①这个值null表示一种具体的“null”值状态 ② null还表示“没有”的意思,因为没有设置。hashMap没有这个问题,因为单线程。 而ConcurrentHashMap是在多线程下,多个进程会改动数据,有这样的问题。