Java 常见集合重点解析
1. 什么是算法时间复杂度?
- 时间复杂度表示了算法的 执行时间 和 数据规模 之间的增长关系;
什么是算法的空间复杂度?
- 表示了算法占用的额外 存储空间 与 数据规模 之间的增长关系;
常见的复杂度:
2. ArrayList 底层的数据结构是什么?
推荐博客
ArrayList 的底层是动态数组(Array),是一种用 连续的内存空间 存储 相同数据类型 数据的现象数据结构(单列),并且会随着数据的增加而扩容;
数组下标为什么从 0 开始?
- 数组通过下标访问的原理就是通过数组的首元素地址与下标去计算对应下标元素的地址,从而访问对应的元素;
- 寻址公式:
baseAddress + i * dataTypeSize
,计算下标的内存地址效率较高; - 如果是从 1 开始:寻址公式则为
baseAddress + (i - 1) * dataTypeSize
,这样计算下标的时候还多了一步减法;- 而这个高频的操作,“日积月累”,如 几亿次的操作,这个减法也挺消耗 CPU 资源的,要尽可能的提高效率;
查找的时间复杂度?
- 随机查询(通过下标)的时间复杂度是 O(1);
- 查找元素(未知下标的值查询)的时间复杂度是 O(n);
- 查找元素(未知下标但是已排序)通过二分的时间复杂度是 O(logn);
插入、修改、删除的时间复杂度?
- 修改的时候,通过下标访问并修改即可,所以是O(1);
- 按下标插入和删除的时候,为了保证数组的内存连续性,需要挪动数组元素,平均时间复杂度是 O(n);
- 插入的时候甚至可能会扩容:O(n);
3. ArrayList 源码(底层原理)有了解过吗?
构造方法:
扩容机制:
- 空集合但是容量为0,即空数组;
- 第一次添加元素的时候,如果 需要的空间大小 minCapacity 小于默认容量,则扩容至默认容量;
- 空集合但是容量不为0,即数组元素都为空;
- 第一次添加元素的时候,与默认容量无关,按正常的情况走;
-
添加之前要判断本此次添加 需要的空间大小 minCapacity 是否小于当前的容量,是则无需扩容,否则需要扩容;
-
原数组扩容为原来的 1.5 倍(如果还是达不到 minCapacity,则取 minCapacity);
-
如果扩容后的空间大于最大的数组大小,则调用一个方法计算
private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;
根据 minCapacity 计算出一个合理的巨大容量;
-
然后
elementData[size++] = e
,在有效数组尾添加节点;
-
ArrayList list = new ArrayList(10) 中 ArrayList 扩容了几次?
- 0 次,调用构造方法,只是实例了一个指定容量的 ArrayList,未扩容;
4. 如何实现数组和 List 之间的转化?
- 数组 => List
使用 JDK 中 Arrays 数组工具类的 asList 方法(可变参数),将其转化为 List;
不难看出,如果修改原数组指向的元素,是会影响 List 的,当然,触发了扩容之后 List 就换了个数组,就不受影响;
如果要不受影响的话,就用遍历或者 stream 流去转化 List;
- List => 数组
使用 List 对象的 toArray 方法转化为数组:
- 无参的返回的是 Object 数组;
- 有参的则是指定类型的数组,并且如果数组足够大,数据会拷贝到数组里,反正以返回值为主就行了;
不难看出,每次转化数组都是拷贝一份 List 中的数据,所以修改 List 中的数据不会影响转化后的数组;
5. 链表操作数据的时间复杂度是多少?
推荐博客
单向链表只有一个方向(只有头节点),每个节点有一个后继节点 next;
- 头插法,O(1)
- 指定节点的后面插入,O(1)
- 指定节点的前面插入,O(n)
- 指定节点的删除,O(n)
- 指定下标的增删查改,O(n)
- 查找指定值,O(n)
双向链表则有两个方向(有头节点和尾节点),每个节点有一个后继节点 next 还有一个 前驱节点 prev;
- 支持双向遍历,提高灵活性;
- 头插法、尾插法,O(1)
- 指定节点的前面/后面插入,指定节点的删除,O(1)
- 指定下标的增删查改,O(n)
- 查找指定值,O(n)
6. ArrayList 和 LinkedList 的区别是什么?
6.1 底层数据结构
ArrayList 是动态数组,LinkedList 是双写链表;
6.2 操作数据效率
- 下标访问
ArrayList 按照下标查询的时间复杂度是 O(1),LinkedList 则不支持下标查询,如果从逻辑上的下标查询的话,则时间复杂度是 O(n);
- 值查询
ArrayList 和 LinkedList 都是 O(n);
- 新增/删除
ArrayList 从尾部插入/删除是 O(1),但是其他部分增删需要挪动数组,甚至触发扩容,时间复杂度是 O(n);
- 只有给定下标的 O(n);
LinkedList 从头尾节点插入都是 O(1),其他都需要遍历链表,没有扩容的情况,时间复杂度为 O(n);
- 给定下标是需要遍历的 O(n),但是给定节点则不需要遍历的 O(1);
6.3 内存空间占用
ArrayList 底层是数组,内存连续,节省空间;
LinkedList 底层是双向链表,除了 value 还存了两个指针,更占用内存;
6.4 线程安全
ArrayList 和 LinkedList 都不是线程安全的;
要保证线程安全两种方案:
-
在方法内使用,局部变量是线程安全的(让多线程不同时修改同一个对象);
-
套壳加锁返回线程安全的 List 实例;
7. 什么是二叉树?什么是二叉搜索树?什么是红黑树?
二叉树
-
二叉搜索树(Binary Search Tree,BST),又叫二叉查找树,有序二叉树;
-
在树中的任意一个节点,其左子树中的每个节点都小于这个节点,右子树则都大于;
-
没有键相等的节点;
-
通常情况下二叉搜索树的增删查改,时间复杂度是 O(logn);
恶劣情况的二叉搜索树会退化成链表(时间复杂度退化为 O(n)):
而我们需要的是平衡度高的二叉搜索树,才能保证查找效率,AVL树和红黑树都是自平衡二叉搜索树。
AVL树通过旋转操作来保持平衡,并且在平衡性上有更严格的要求。
红黑树则使用颜色标记和旋转操作来维护平衡(维持一些规则),相对而言更灵活一些。
在AVL树中,任意节点的左右子树的高度差不超过1,而红黑树的规则则是:
- 红黑树(Red Black Tree):也是一种自平衡的二叉搜索树;
- 所有的红黑规则都是希望红黑树能够保证平;
- 红黑树的时间复杂度:增删查改都是 O(logn);
- 旋转调整是 O(1);
8. 什么是散列表(哈希表)?
推荐博客
什么是散列表?
- 散列表(Hash Table)又名哈希表 / Hash 表;
- 根据键直接访问内存存储位置的数据结构;
- 由数组演化而来,利用了数组支持按照下标进行随机访问数据的性质;
- 数组的每个位置不一定都有值,存储的比较松散,一般会保留一定的空位置;
散列冲突是什么?
- 散列冲突又叫哈希冲突、哈希碰撞;
- 指的是多个 key 映射到同一个数组的下标位置;
散列冲突其实无法完全避免,即使是 md5 这样的算法,也无法绝对的让 key 和 hashValue 一对一,更何况我们没有那么大的数组来存;
- 要预防或者减少哈希冲突,可以用一些均匀化的算法,让哈希冲突不要集中在某个下标,适当扩充数组…
散列冲突,如何数组同个下标的存储数据?
- 开散列的方式:
- 数组的每个下标称之为一个哈希桶(bucket)或者哈希槽(slot);
- 每个哈希桶会对应一个链表/一颗红黑树;
- 散列冲突后的元素都放到对应哈希桶内的链表/红黑树中;
9. 说一下 HashMap 的实现原理?
- 底层使用 Hash 表数据结构,即数组 + 链表/红黑树;
- 添加数据时,计算 key 的值确定元素在数组中的下标;
- key 相同则替换;
- 不同则存入链表或红黑树;
- 获取数据通过 key 的 hash 计算下标,查询链表/红黑树下标元素,一般是可以看成是 O(1) 的;
HashMap 的 jdk1.7 和 jdk1.8 的区别:
- jdk1.8 之前,数组 + 链表(头插);
- jdk 1.8 及之后,数组 + 链表(尾插) + 红黑树,链表长度和数组长度各达到一定值(链表长于 8 并且数组长于 64)则会从链表转化尾红黑树;
10. HashMap 的 put 方法的具体流程?
哈希表被无参实例化的时候,数组没有被实例化,第一次 put 的时候,再 resize 为 16;
(因为可以由其他构造方法去构造 HashMap,所以第一次 put 时数组不为 null,可能数组长度不会以 16 开始,但是数组的长度一定是 2 的倍数)
-
判断键值对数组 table 是否为空或为 null,否则执行 resize() 进行扩容(初始化)
-
根据键值 key 计算 hash 值得到数组索引
-
判断 table[i] == null,条件成立,直接新建节点添加
-
如果 table[i] == null,不成立
4.1 判断 table[i] 的首个元素是否和 key 一样,如果相同直接覆盖 value
4.2 判断 table[i] 是否为 treeNode,即 table[i] 是否是红黑树,如果是红黑树,则直接在树中插入键值对
4.3 遍历 table[i],链表的尾部插入数据,然后判断链表长度是否大于 8,大于 8 的话把链表转换为红黑树,在红黑树中执行插入操 作,遍历过程中若发现 key 已经存在直接覆盖 value
-
插入成功后,判断实际存在的键值对数量 size 是否超多了最大容量 threshold(数组长度* 0.75),如果超过,进行扩容。
11. HashMap 的扩容机制?
参考回答:
在添加元素或初始化的时候需要调用 resize 方法进行扩容,第一次添加数据初始化数组长度为 16,以后每次每次扩容都是达到了扩容阈值(数组长度 * 0.75)
每次扩容的时候,都是扩容之前容量的 2 倍;
扩容之后,会新创建一个数组,需要把老数组中的数据挪动到新的数组中
- 没有hash冲突的节点,则直接使用 e.hash & (newCap - 1) 计算新数组的索引位置
- 如果是红黑树,走红黑树的添加
- 如果是链表,则需要遍历链表,可能需要拆分链表,判断 (e.hash & oldCap) 是否为0,该元素的位置要么停留在原始位置,要么移动到原始位置+增加的数组大小这个位置上
12 HashMap 的寻址方式?
(h = key.hashCode()) ^ (h >>> 16)
,称为 二次哈希
- 首先获取 key 的 hashCode 值;
- 然后右移 16 位异或运算原来的 hashCode 值
- 主要作用就是使原来的 hash 值更加均匀,减少 hash 冲突,例如 17 或者 33 模 16 都等于 1,二次哈希后再取模大概率就不哈希冲突了;
(n - 1) & hash
,代替取模,性能更好一些,n 即数组长度,必须是 2 的整数倍才等价于取模;
- 例如 16 => 15 的二进制
1111
,按位与就是保留后面四个比特位,就是取模;
为何 HashMap 的数组长度一定是 2 的整数倍?
- 计算索引时效率更高:
(n - 1) & hash
,代替取模,n 即数组长度,必须是 2 的整数倍才等价于取模; - 扩容时重新计算索引效率更高:
hash & oldCap == 0
的元素留在原地,否则newPos = oldPos + oldCap
;- 例如 16 => 32:
- 两次计算索引的结果一样 <=> hash 的后 4 位 和 后 5 位 相等 <=> 那么就是从右到左第 5 位必须是 0;
- 也就是
10000
(即原数组长度) 按位与 hash,得知其是否为 0;
- 也就是
- 所以,
hash & oldCap == 0
,并且在**数组为 2 的整数倍的情况下**是可以推理出来两次计算索引的结果一样(充要条件);
13. HashMap 在 jdk1.7 下,多线程死循环问题
单线程情况下:
多线程的情况下(两个线程同时让一个 HashMap 扩容):
线程 1 完成扩容:
线程 2 执行:
- 现在 cur2 指向 3,会将 3 头插到扩容数组中,由于这里节点 3 跟线程 1 扩容后的节点 3 是同一个,所以就会出现这种情况:
- 所以这样就会导致成环不断循环下去!
问题原因:
- 头插法,导致插入的顺序和原来链表的顺序相反的;
- table 是共享的,table 里面的元素也是共享的,while 循环都直接修改 table 里面的元素的 next 指向,导致指向混乱;
而 jdk 8 扩容算法做出了调整,使用了尾插法还有红黑树;还可以用线程安全的 ConcurrentHashMap;
- 其中还有 高低位拆分转移方式,可以查询资料去了解,在这里不讨论;
14. HashSet 有了解过吗?
- HashSet 实现了 Set 接口,仅存储对象;
- HashMap 实现了 Map 接口,存储的是键值对;
- HashSet 底层其实是用 HashMap 实现存储的;HashSet 封装了一系列 HashMap 的方法;依靠 HashMap 来存储元素值(利用 hashMap 的 key 键进行存储), 而 value 值默认为 Object 对象;
- 所以 HashSet 也不允许出现重复值,判断标准和 HashMap 判断标准相同, 两个元素的 hashCode 相等并且通过 equals() 方法返回 true;
15. HashTable 有了解过吗?
区别 | HashTable | HashMap |
---|---|---|
数据结构 | 数组 + 链表 | 数组 + 链表 + 红黑树 |
是否可以为null | key 和 value 都不能为 null | 可以为 null |
hash算法 | key 的 hashCode() | 二次 hash |
扩容方式 | 当前容量翻倍 + 1 | 当前容量翻倍 |
线程安全 | 同步 (synchronized 加在方法上) 的,线程安全 | 非线程安全 |
在实际开中不建议使用 HashTable,在多线程环境下可以使用 ConcurrentHashMap 类
- 推荐文章