【八股学习】Java集合

List

ArrayList和LinkedList的区别

简单的数据结构内容,先不在此阐述…

Queue

ArrayDeque与LinkedList的区别

在Java做算法题的时候,我们时常使用Deque通过多态实现队列和栈,其具体实现 LinkedList 和 ArrayDeque又有什么区别呢?
简单来说:

  • ArrayDeque 是基于可变长的数组和双指针来实现,而 LinkedList 则通过链表来实现
  • ArrayDeque 不支持存储 NULL 数据,但 LinkedList 支持
  • ArrayDeque 插入时可能存在扩容过程, 不过均摊后的插入操作依然为 O(1)。虽然 LinkedList 不需要扩容,但是每次插入数据时均需要申请新的堆空间,均摊性能相比更慢

综上,选用 ArrayDeque 实现队列和栈性能更好一些。

Map⭐

HashMap

JDK1.8之前的底层实现

数组+链表,即链表散列。
HashMap通过 key 的 hashcode,经过扰动函数处理后得到hash值,通过(n - 1) & hash (n = 数组长度),如果存在元素,则判断该元素和要存入的元素hash值以及key是否相同,相同则直接覆盖不同则拉链法解决冲突
在这里插入图片描述

JDK1.8之后的底层实现

当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间。

从链表转换为红黑树的源码很容易理解:
1、putVal方法中遍历链表来判断是否需要转换

// 遍历链表
for (int binCount = 0; ; ++binCount) {
    // 遍历到链表最后一个节点
    if ((e = p.next) == null) {
        p.next = newNode(hash, key, value, null);
        // 如果链表元素个数大于TREEIFY_THRESHOLD(8)
        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 因为binCount是从0开始计数的
            // 红黑树转换(并不会直接转换成红黑树)
            treeifyBin(tab, hash);
        break;
    }
    if (e.hash == hash &&
        ((k = e.key) == key || (key != null && key.equals(k))))
        break;
    p = e;
}

2、treeifyBin方法中判断是否真的转换为红黑树
数组长度是否小于64,如果小于就进行数组扩容。

final void treeifyBin(Node<K,V>[] tab, int hash) {
    int n, index; Node<K,V> e;
    // 判断当前数组的长度是否小于 64
    if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
        // 如果当前数组的长度小于 64,那么会选择先进行数组扩容
        resize();
    else if ((e = tab[index = (n - 1) & hash]) != null) {
        // 否则才将列表转换为红黑树

        TreeNode<K,V> hd = null, tl = null;
        do {
            TreeNode<K,V> p = replacementTreeNode(e, null);
            if (tl == null)
                hd = p;
            else {
                p.prev = tl;
                tl.next = p;
            }
            tl = p;
        } while ((e = e.next) != null);
        if ((tab[index] = hd) != null)
            hd.treeify(tab);
    }
}
HashMap数组长度为什么是2的幂次方

哈希值在Java中使用int来表示,也就是一个长度为40亿的空间映射。但是肯定存放不下这么长的数组,所以要对数组进行取余 %

取余(%)操作中如果除数是 2 的幂次则等价于与其除数减一的与(&)操作,也就是说如果保证了数组长度为2^n,那么我们就可以使用更有效率的位运算 & ,hash%length==hash&(length-1),来提高运算效率。

此外,在HashMap扩容的时候,可以更加均匀,例如:

  • length = 8 时,length - 1 = 7 的二进制位0111
  • length = 16 时,length - 1 = 15 的二进制位1111

原本在HashMap中的元素重新计算数组位置后,只要关心hash的第四个二进制数是不是1就可以了,是1则存放在扩容数组中(原位置+原容量),是0则数组位置不变

为什么不直接采用红黑树?
  • 时间:链表长度小的时候,遍历查找O(n)时间复杂度也是很快的,只有链表长度变大(>8)之后,才有必要提升为O(log(n))
  • 空间:红黑树的构造更复杂一些,并且TreeNode占用空间为Node的两倍。
为什么转换阈值选为8

理想情况下随机hashCode算法下所有bin中节点的分布频率会遵循泊松分布,链表中达到8个元素的概率几乎不可能,如果达到了那么说明链表性能很差,未来还有可能超过这个数量,所以需要红黑树提升性能。

红黑树的平均查找长度是log(n),如果长度为8,平均查找长度为log(8)=3,链表的平均查找长度为n/2,当长度为8时,平均查找长度为8/2=4,这才有转换成树的必要。长度<=6,性能提升就不是很明显了

为什么退化阈值选为6

如果阈值同样为8的话,那么添加和删除会频繁导致转换,效率低下

但是由于树节点占用的空间更多,根据上一题答案中**“长度<=6,性能提升就不是很明显了”**,可以知道这个阈值可以进行转换。

线程安全问题⭐

JDK1.7中会出现数据死链、死循环和数据丢失问题

在扩容的时候,使用的是头插法。
在这里插入图片描述
在多线程的情况下,粉色线程进行扩容操作,在将 one 复制到新的位置31后,蓝色线程的e指针指向了扩容前后的两个 one 结点。

随后,粉色线程再次进行头插操作,此时可以发现,指针关系变为了,2 -> 1 -> null
在这里插入图片描述
这时粉色线程结束,蓝色线程开始进行扩容,首先,会将自己e指向的one结点放置于扩容后的第一个位置,和前面粉色线程的扩容操作没有什么不同。随后,e指向了之前早已设置好的next,也就是two结点。
在这里插入图片描述
执行又一次头插法,指针关系为 2 -> 1,这个时候的e又指向了next,也就是结点one,所以就又要插入一次结点one:
在这里插入图片描述
至此,也就形成了循环,1->2->1
在这里插入图片描述
JDK1.8中使用了尾插法,解决了死循环的问题,但是数据丢失的问题还是没有解决。

这个问题很容易解释,一共两种情况:
也就是当线程1和线程2发生了哈希冲突,同时到了判断hash桶是否为空时,两者都判断为空,但是线程1在判断完后时间片耗尽,线程2进行判断和插入新节点,由于桶为空,所以这个结点直接就放置在数组的 tab[i] 中,这时线程1苏醒,执行插入结点的逻辑,由于之前判断为空,所以也是直接将结点放入数组中,也就直接替换了线程2的结点。

还有一种情况是if(++size > threshold)判断中,size实际应该+2 ,但由于自增并不是原子操作,所以size只加了1,导致实际上也只有一个元素被加入。

为了解决并发问题,引入了ConcurrentHashMap!

ConcurrentHashMap

JDK1.7的底层实现

Segment 数组 + HashEntry 数组 + 链表
在这里插入图片描述
给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据时,其他段的数据也能被其他线程访问。

Segment继承了ReentrantLock,其数组大小初始化后就没法改变,其大小也就是可以同时支持并发写的线程数

JDK1.8之后的底层实现

采用 Node + CAS + synchronized 来保证并发安全,和HashMap1.8一样,链表长度超过阈值(8)即转换为红黑树。

锁粒度更细。synchronized 只锁定当前链表或红黑二叉树的首节点,也就是说只要hash不冲突就不会产生并发。最大并发线程数为Node数组的大小
在这里插入图片描述

ConcurrentHashMap 为什么 key 和 value 不能为 null?

如果你用 null 作为键,那么你就无法区分这个键是否存在于 ConcurrentHashMap 中,还是根本没有这个键。
同样,如果你用 null 作为值,那么你就无法区分这个值是否是真正存储在 ConcurrentHashMap 中的,还是因为找不到对应的键而返回的。

在多线程中,多个线程进行操作的时候,containsKey(key)的二义性是无法接受和容忍的。而HashMap可以设置为null,也是因为单线程操作,不存在二义性的问题。

ConcurrentHashMap 能保证复合操作的原子性吗?

多个基本操作(如put、get、remove、containsKey等)组成的操作是不可以保证原子性的!

但是提供了如下的原子性复合操作:
如 putIfAbsent、compute、computeIfAbsent 、computeIfPresent、merge等

### Java集合框架常见面试问题 #### 1. **什么是Java集合框架?** Java集合框架是一个用于表示和操作集合的标准库,它提供了许多接口和类来存储对象并对其进行高效的操作。这些接口和实现类位于`java.util`包下[^2]。 #### 2. **Collection与Collections的区别是什么?** - `Collection` 是一个接口,它是所有集合类的根接口,定义了许多通用方法。 - `Collections` 是一个工具类,提供了一系列静态方法来操作或返回集合。 #### 3. **List、Set、Map之间的区别是什么?** - `List` 接口允许重复元素,并且按照插入顺序保存元素。 - `Set` 接口不允许重复元素,具体行为取决于其实现类(如`HashSet`基于哈希表,`TreeSet`基于红黑树)。 - `Map` 不继承自`Collection`接口,而是键值对的映射关系容器,其中键唯一而值可以重复。 #### 4. **ArrayList与LinkedList的主要区别有哪些?** | 特性 | ArrayList | LinkedList | |-----------------|------------------------------------|-----------------------------------| | 底层数据结构 | 数组 | 双向链表 | | 插入性能 | 较慢(需移动后续元素) | 较快 | | 随机访问性能 | 快速 | 缓慢 | | 内存占用 | 少 | 多 | 上述差异使得在频繁随机访问场景下更适合使用`ArrayList`,而在频繁增删节点的情况下更推荐使用`LinkedList`。 #### 5. **HashMap的工作原理是什么?** `HashMap`通过哈希函数计算键的索引位置,并将键值对存储到对应的桶中。当发生哈希冲突时,默认采用拉链法解决(即每个桶维护一个链表)。从JDK 1.8开始,在链表长度超过一定阈值时会转换为红黑树以提高查找效率。 #### 6. **如何保证线程安全?** 对于非同步集合类来说,可以通过以下方式实现多线程环境下的安全性: - 使用`Collections.synchronizedXxx()`系列方法包装原有实例; - 或者直接选用专门设计成线程安全版本的数据结构,像`ConcurrentHashMap`替代普通`HashMap`。 #### 7. **Iterator的作用及其特性?** 迭代器(`Iterator`)用来遍历集合中的每一个元素而不暴露其内部细节。它的主要优点在于支持fail-fast机制——如果检测到集合被修改,则立即抛出异常终止操作;另外还具备删除当前指向项的能力。 ```java // 迭代器示例代码 List<String> list = new ArrayList<>(); list.add("A"); list.add("B"); Iterator<String> it = list.iterator(); while (it.hasNext()) { String item = it.next(); System.out.println(item); } ``` #### 8. **泛型的意义何在?** 引入泛型之后能够避免强制类型转换带来的风险以及提升程序可读性和健壮性。例如声明了一个只接受字符串类型的列表后就无需再担心误加入其他种类的对象。 --- ###
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值