JAVA容器基础知识整理

目录

一  常见的集合框架

二 List

2.1 ArrayList和LinkedList有什么差别

2.2 ArrayList的扩容机制是什么

2.3 ArrayList如何序列化的

2.4 对CopyOnWriteArrayList了解多少

三 Map

3.1 能说一下HashMap的底层原理吗?

3.2 你对红黑树了解多少?为什么不用二叉树/平衡树呢?

为什么不用二叉树?

#为什么不用平衡二叉树?

3.3 红黑叔怎么保持平衡的

3.4 HashMap的put流程知道吗

3.5 HashMap如何查找元素的

3.6  HashMap的Hash函数是如何设计的

3.7 为什么HashMap的容量是2的倍数呢

hashCode 对数组长度取模定位数组下标,这块有没有优化策略?

3.8 如果初始化HashMap,传入一个17容量,它会怎么处理

初始化 HashMap 的时候需要传入容量值吗?

3.9 解决hash冲突的有哪些方法呢

3.10 HashMap的扩容机制了解吗

3.11 JDK8对HashMap主要做了哪些优化呢?为什么?

3.12 HashMap是线程安全的吗?多线程下会有什么问题?

3.13 有什么办法能解决 HashMap 线程不安全的问题呢?

3.14 能说一下ConcurrentHashMap的实现吗

3.15 说一下JDK7中ConcurrentHashMap的实现原理

说一下 JDK 8 中的 ConcurrentHashMap 的实现原理

四 Set

4.1 HashSet的底层实现


一  常见的集合框架

JAVA中常见的集合框架有collection和Map

collection主要由List、Queue、Set组成

collection接口提供了add、remove、clear基本操作,有三个子接口list,set,queue

Map,代表键值对的集合,典型代表是HashMap

二 List

2.1 ArrayList和LinkedList有什么差别

ArrayList基于数组实现,LinkedList基于链表实现

Arraylist的get(int index)方法的时间复杂度是O(1)、LinkedList是O(n)

ArrayList 如果增删的是数组的尾部,直接插入或者删除就可以了,时间复杂度是 O(1);如果 add 的时候涉及到扩容,时间复杂度会提升到 O(n)。

但如果插入的是中间的位置,就需要把插入位置后的元素向前或者向后移动,甚至还有可能触发扩容,效率就会低很多,O(n)。

LinkedList 因为是链表结构,插入和删除只需要改变前置节点、后置节点和插入节点的引用就行了,不需要移动元素。

如果是在链表的头部插入或者删除,时间复杂度是 O(1);如果是在链表的中间插入或者删除,时间复杂度是 O(n),因为需要遍历链表找到插入位置;如果是在链表的尾部插入或者删除,时间复杂度是 O(1)。

LinkedList 更利于增删不是体现在时间复杂度上,因为二者增删的时间复杂度都是 O(n),都需要遍历列表;而是体现在增删的效率上,因为 LinkedList 的增删只需要改变引用,而 ArrayList 的增删可能需要移动元素。

ArrayList是基于数组的、实现了RandomAccess接口,所以它支持随机访问

2.2 ArrayList的扩容机制是什么

ArrayList 确切地说,应该叫做动态数组,因为它的底层是通过数组来实现的,当往 ArrayList 中添加元素时,会先检查是否需要扩容,如果当前容量+1 超过数组长度,就会进行扩容

扩容后的新数组长度是原来的 1.5 倍,然后再把原数组的值拷贝到新数组中。

2.3 ArrayList如何序列化的

ArrayList 的序列化不太一样,它使用transient修饰存储元素的elementData的数组,transient关键字的作用是让被修饰的成员属性不被序列化。

为什么最 ArrayList 不直接序列化元素数组呢?

出于效率的考虑,数组可能长度 100,但实际只用了 50,剩下的 50 不用其实不用序列化,这样可以提高序列化和反序列化的效率,还可以节省内存空间

那 ArrayList 怎么序列化呢?

ArrayList 通过两个方法readObject、writeObject自定义序列化和反序列化策略,实际直接使用两个流ObjectOutputStreamObjectInputStream来进行序列化和反序列化。

2.4 对CopyOnWriteArrayList了解多少

原理是写时复制

CopyOnWriteArrayList 容器允许并发读,读操作是无锁的,性能较高。至于写操作,比如向容器中添加一个元素,则首先将当前容器复制一份,然后在新副本上执行写操作,结束之后再将原容器的引用指向新容器。

三 Map

3.1 能说一下HashMap的底层原理吗?

hashmap的底层数据结构是数组+链表+红黑树

其解决哈希冲突的方法是拉链法

不过,链表过长时,查询效率会比较低,于是当链表的长度超过 8 时(且数组的长度大于 64),链表就会转换为红黑树。红黑树的查询效率是 O(logn),比链表的 O(n) 要快。数组的查询效率是 O(1)。

HashMap 的初始容量是 16,随着元素的不断添加,HashMap 的容量(也就是数组大小)可能不足,于是就需要进行扩容,阈值是capacity * loadFactor,capacity 为容量,loadFactor 为负载因子,默认为 0.75。

3.2 你对红黑树了解多少?为什么不用二叉树/平衡树呢?

红黑树是一种自平衡二叉查找树,定义为:

每个节点不是红色就是黑色

根节点和叶子节点都是黑色

红色节点的子节点都是黑色

从任一节点到其每个叶子节点的所有简单路径包含相同数目的黑色节点

为什么不用二叉树?

二叉树是最基本的树结构,每个节点最多有两个子节点,但是二叉树容易出现极端情况,比如插入的数据是有序的,那么二叉树就会退化成链表,查询效率就会变成 O(n)。

#为什么不用平衡二叉树?

平衡二叉树比红黑树的要求更高,每个节点的左右子树的高度最多相差 1,这种高度的平衡保证了极佳的查找效率,但在进行插入和删除操作时,可能需要频繁地进行旋转来维持树的平衡,这在某些情况下可能导致更高的维护成本。

红黑树是一种折中的方案,它在保证了树平衡的同时,插入和删除操作的性能也得到了保证,查询效率是 O(logn)。

3.3 红黑叔怎么保持平衡的

旋转和染色

3.4 HashMap的put流程知道吗

1.根据key的HashCode计算hash值

2.是否需要初始化扩容,大小是16

3.计算数组下标

4.数组索引处为空,则放入,若key值相同则覆盖其值,若有元素则是否是树节点,是插入,否遍历链表,尾部插入元素,是否达到树化阈值,达到则链表树化(数组长度64,链表长度8)

5.插入后判断是否到达扩容阈值,达到则进行扩容

3.5 HashMap如何查找元素的

根据key值计算hashcode,如果数组为空结束,不为空计算索引值,遍历链表或数组查找相同的key。

3.6  HashMap的Hash函数是如何设计的

它将key值的hashcode,int型32位整数,先进行高16位和低1天位异或处理,这么设计是为了减少哈希碰撞

为什么能减少hash碰撞呢

HashMap 在两方面下足了功夫,第一个就是数组的长度必须是 2 的整数次幂,这样可以保证 hash & (n-1) 的结果能均匀地分布在数组中。& 操作的结果就是哈希值的高位全部归零,只保留 n 个低位,用来做数组下标访问

如果原先16位的一个哈希值,也只取最后 4 位,不就等于哈希值的高位都丢弃了吗?这时候 hash 函数 (h = key.hashCode()) ^ (h >>> 16) 就派上用场了。这样处理既考虑了高位的信息,又没有完全忽视低位原本的信息,尝试达到一个平衡状态。

尽可能地让元素均匀地分布在数组当中。

3.7 为什么HashMap的容量是2的倍数呢

HashMap的容量是2的整数次幂是为了快速定位下标

数组的长度是2的整数次幂减1,这样&操作才有意义,保证了最低位可以是0也可以是1,保证hash值的均匀分布

hashCode 对数组长度取模定位数组下标,这块有没有优化策略?

当数组的长度是2的整数次幂时,hash&(n-1)=hash%n

因为计算机是二进制机器所以与操作要比取%更快。

3.8 如果初始化HashMap,传入一个17容量,它会怎么处理

它会将这个值转化成大于或等于17的最小的2的幂,传入17的化会被初始化成大小为32的哈希表

初始化 HashMap 的时候需要传入容量值吗?

在创建 HashMap 时可以指定初始容量值。这个容量是指 Map 内部用于存储数据的数组大小,如果不指定初始容量,HashMap 将使用默认的初始容量 16。

3.9 解决hash冲突的有哪些方法呢

再哈希法、开放定址法、拉链法

再哈希法:准备两套哈希算法,当发生哈希冲突的时候,使用另外一种哈希算法,直到找到空槽为止。对哈希算法的设计要求比较高。

开放定址法:遇到哈希冲突的时候,就去寻找下一个空的槽。

3.10 HashMap的扩容机制了解吗

扩容时,HashMap 会创建一个新的数组,其容量是原数组容量的两倍。然后将键值对放到新计算出的索引位置上。一部分索引不变,另一部分索引为“原索引+旧容量”

JDK7时还未规定数组长度是2的倍数,且采用的是头插法,在 JDK 8 的新 hash 算法下,数组扩容后的索引位置,要么就是原来的索引位置,要么就是“原索引+原来的容量”,遵循一定的规律。具体来说,就是判断原哈希值的高位中新增的那一位是否为 1,如果是,该元素会被移动到原位置加上旧容量的位置;如果不是,则保持在原位置。

3.11 JDK8对HashMap主要做了哪些优化呢?为什么?

底层数据结构由数组+链表改成了数组+链表+红黑树的结构

链表的插入方式由头插法改成了尾插法

头插法虽然简单但容易改变原来链表的顺序

扩容由插入前判断改为插入后判断,避免了不必要的扩容检查

优化了哈希算法

3.12 HashMap是线程安全的吗?多线程下会有什么问题?

1.JDK1.7中HashMap使用的是头插法插入元素,在多线程下容易造成死循环,JDK8改掉了这个问题

2.多线程put会导致元素丢失。因为计算出来的位置可能会被其他线程put覆盖,多线程由于没有加锁,相同位置的元素可能就被干掉了

3.put和get并发时,导致get为null。put导致扩容会导致get不到

3.13 有什么办法能解决 HashMap 线程不安全的问题呢?

Hashtable 也是线程安全的,但它的使用已经不再推荐使用,因为 ConcurrentHashMap 提供了更高的并发性和性能。

3.14 能说一下ConcurrentHashMap的实现吗

在 JDK 7 时采用的是分段锁机制(Segment Locking),整个 Map 被分为若干段,每个段都可以独立地加锁。因此,不同的线程可以同时操作不同的段,从而实现并发访问。在 JDK 8 及以上版本中,ConcurrentHashMap 的实现进行了优化,不再使用分段锁,而是使用了一种更加精细化的锁——桶锁,以及 CAS 无锁算法。每个桶(Node 数组的每个元素)都可以独立地加锁,从而实现更高级别的并发访问。对于读操作,通常不需要加锁,可以直接读取,因为ConcurrentHashMap 内部使用了 volatile 变量来保证内存可见性。对于写操作,ConcurrentHashMap 使用 CAS 操作来实现无锁的更新,这是一种乐观锁的实现,因为它假设没有冲突发生,在实际更新数据时才检查是否有其他线程在尝试修改数据,如果有,采用悲观的锁策略,如 synchronized 代码块来保证数据的一致性。

3.15 说一下JDK7中ConcurrentHashMap的实现原理

由Segment数组机构和HashEntry组成,Segment是一种可重入的所ReentrantLock,HashEntry用于存储键值对。

一个 ConcurrentHashMap 里包含一个 Segment 数组,Segment 的结构和 HashMap 类似,是一种数组和链表结构,一个 Segment 里包含一个 HashEntry 数组,每个 HashEntry 是一个链表结构的元素,每个 Segment 守护着一个 HashEntry 数组里的元素,当对 HashEntry 数组的数据进行修改时,必须首先获得它对应的 Segment 锁。

说一下 JDK 8 中的 ConcurrentHashMap 的实现原理

采用 CAS + synchronized 来保证并发安全性,整个容器只分为一个 Segment,即 table 数组。

使用 volatile 关键字,保证多线程操作时,变量的可见性。

总结一下HashMap和ConcurrentHashMap的区别

HashMap 是非线程安全的,多线程环境下应该使用 ConcurrentHashMap。

由于 HashMap 仅在单线程环境下使用,所以不需要考虑同步问题,因此效率高于 ConcurrentHashMap。

四 Set

4.1 HashSet的底层实现

HashSet 其实是由 HashMap 实现的,只不过值由一个固定的 Object 对象填充,而键用于操作。HashSet 主要用于去重,比如,我们需要统计一篇文章中有多少个不重复的单词,就可以使用 HashSet 来实现。

  • 11
    点赞
  • 29
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值