Java面试题汇总 | 集合容器第二章

目录

为什么 ArrayList 的 elementData 加上 transient 修饰?

List 和 Set 的区别?

Set接口

说一下 HashSet 的实现原理?

HashSet是如何保证数据不可重复的?

以下是HashSet 部分源码:

hashCode()与equals()的相关规定:

==与equals的区别

Queue

BlockingQueue是什么?

在 Queue 中 poll()和 remove()有什么区别?

Map接口

说一下 HashMap 的实现原理?

HashMap在JDK1.7和JDK1.8中有哪些不同?

JDK1.8之前

JDK1.8之后

JDK1.7 与 JDK1.8 比较?

HashMap的put方法的具体流程?


为什么 ArrayList 的 elementData 加上 transient 修饰?

        ArrayList 中的数组定义如下:

private transient Object[] elementData;
        再看一下 ArrayList 的定义:
public class ArrayList<E> extends AbstractList<E>
    implements List<E>, RandomAccess, Cloneable, java.io.Serializable {
    // 类的实现部分
}
        可以看到 ArrayList 实现了 Serializable 接口,这意味着 ArrayList 支持序列化。transient 的作用是不希望 elementData 数组被序列化,而重写了 writeObject 实现:
private void writeObject(java.io.ObjectOutputStream s) throws java.io.IOException {
    // Write out element count, and any hidden stuff
    int expectedModCount = modCount;
    s.defaultWriteObject();

    // Write out array length
    s.writeInt(elementData.length);

    // Write out all elements in the proper order.
    for (int i = 0; i < size; i++) {
        s.writeObject(elementData[i]);
    }

    if (modCount != expectedModCount) {
        throw new ConcurrentModificationException();
    }
}
        每次序列化时,先调用 defaultWriteObject() 方法序列化 ArrayList 中的非 transient 元素,然后遍历 elementData,只序列化已存入的元素,这样既加快了序列化的速度,又减小了序列化之后的文件大小。
加餐小tips:
        首先,我们先来复习一下序列化的概念:在 Java 中,序列化指的是将一个对象转换成一串二进制表示的字节数组,通过保存或转移这些字节数据来达到持久化,实现对象在不同 Java 虚拟机之间的传输。transient 使elementData 数组不被序列化可以避免过多的内存开销,从而提高程序的性能。
        序列化也是实现深拷贝的方式之一,深拷贝不仅复制对象本身,还要复制对象包含的所有子对象。而浅拷贝只复制对象本身,不复制子对象(可通过clone()方法或拷贝构造器实现)。

List 和 Set 的区别?

ListSet
特点有序容器,元素可以重复,可以插入多个null元素,元素都有索引无序容器,不可以存储重复元素,只允许存入一个null元素,必须保证元素唯一性。
常用实现类ArrayList、LinkedList 和 Vector。HashSet、LinkedHashSet 以及 TreeSet。
遍历方式支持for循环,也可以用迭代器。只能用迭代器。
检索元素效率
删除和插入效率低,因为会引起其他元素位置改变高,插入和删除不会引起元素位置改变

Set接口

说一下 HashSet 的实现原理?

         HashSet 是基于 HashMap 实现的,HashSet的值存放于HashMap的key上,HashMap的value统一为PRESENT,因此 HashSet 的实现比较简单,相关 HashSet 的操作,基本上都是直接调用底层 HashMap 的相关方法来完成,HashSet 不允许有重复的值。

HashSet是如何保证数据不可重复的?

        当我们向HashSet中添加元素时,判断元素是否已经存在的依据不仅仅是比较hash值,还需要结合equals方法进行比较。这是因为HashSet的add方法实质上是调用了HashMap的put方法。
在HashMap中,key是唯一的。从源码中可以看出,HashSet添加的元素实际上作为了HashMap的key。当HashMap中两个数据的Key或Value相同时,会使用新的Value覆盖旧的Value,然后返回旧的Value。这种处理方式保证了数据的不可重复性。注意,HashMap在比较key是否相等时,是先比较hashcode,再用equals方法进行比较。

以下是HashSet 部分源码:
private static final Object PRESENT = new Object();

private transient HashMap<E, Object> map;

public HashSet() {
    map = new HashMap<>();
}

public boolean add(E e) {
    // 调用HashMap的put方法, PRESENT是一个至始至终都相同的虚值
    return map.put(e, PRESENT) == null;
}
hashCode()与equals()的相关规定
1. 如果两个对象相等,则hashcode一定也是相同的
2. 两个对象相等,对两个equals方法返回true
3. 两个对象有相同的hashcode值,它们也不一定是相等的
4. 综上,equals方法被覆盖过,则hashCode方法也必须被覆盖
5. hashCode()的默认行为是对堆上的对象产生独特值。如果没有重写hashCode(),则该class的两个对象无论如何都不会相等(即使这两个对象指向相同的数据)。
==与equals的区别
==equals()
判断两个变量或实例是否指向同一个内存空间判断两个变量或实例所指向的内存空间的值是否相同
对内存地址进行比较对字符串的内容进行比较
引用是否相同值是否相同

HashSet与HashMap的区别

HashMa p
HashSet
实现了Map接口
实现Set接口
存储键值对
仅存储对象
调用put()向map中添加元素
调用add()方法向Set中添加元素
HashMap 使用键(Key)计算Hashcode
HashSet 使用成员对象来计算 hashcode 值,对于两个对象来说 hashcode 可能相同,所以equals()方法用来判断对象的相等性,如果两个对象不同的话,那么返回 false
相对于 HashSet 较快,因为它是使用唯一的键获取对象
较 HashMap 来说效率较低

Queue

BlockingQueue是什么?

        Java.util.concurrent.BlockingQueue是一个队列。在进行检索或移除一个元素的时候,它会等待队列变为非空;当在添加一个元素时,它会等待队列中的可用空间。BlockingQueue接口是Java集合框架的一部分,主要用于实现生产者-消费者模式(23种设计模式之一)。我们不需要担心等待生产者有可用的空间,或消费者有可用的对象,因为它都在BlockingQueue的实现类中被处理了。Java提供了集中BlockingQueue的实现,ArrayBlockingQueue、LinkedBlockingQueue、PriorityBlockingQueue、SynchronousQueue等。

在 Queue 中 poll()和 remove()有什么区别?

相同点:都是返回第一个元素,并在队列中删除返回的对象。

不同点:如果没有元素 poll()会返回 null,而 remove()会直接抛出 NoSuchElementException 异常。

示例代码:

import java.util.LinkedList;
import java.util.Queue;

public class Main {
    public static void main(String[] args) {
        Queue<String> queue = new LinkedList<>();
        queue.offer("string"); // 添加元素

        System.out.println(queue.poll()); // 按照队列的元素顺序返回元素,并在队列中删除该元素
        System.out.println(queue.remove()); // 返回队列中的第一个元素,并在队列中删除该元素
        System.out.println(queue.size()); // 返回队列中的元素个数
    }
}

Map接口

说一下 HashMap 的实现原理?

HashMap概述: HashMap是基于哈希表的Map接口的非同步实现。此实现提供所有可选的映射操作,并允许使用null值和null键。此类不保证映射的顺序,特别是它不保证该顺序恒久不变。
HashMap的数据结构: 在Java编程语言中,最基本的结构就是两种,一个是数组,另外一个是模拟指针(引用),所有的数据结构都可以用这两个基本结构来构造的,HashMap 也不例外。
加餐小tips:
        HashMap实际上是一个“链表散列”的数据结构,即数组和链表的结合体。HashMap 基于 Hash 算法实现的。
1. 当我们往Hashmap中put元素时,利用key的hashCode重新hash计算出当前对象的元素在数组中的下标。
2. 存储时,如果出现hash值相同的key,此时有两种情况。
        (1)如果key相同,则覆盖原始值;
        (2)如果key不同(出现冲突),则将当前的key-value放入链表中。
3. 获取时,直接找到hash值对应的下标,再进一步判断key是否相同,从而找到对应值。
4. 理解了以上过程就不难明白HashMap是如何解决hash冲突的问题,核心就是使用了数组的存储方式,然后将冲突的key的对象放入链表中,一旦发现冲突就在链表中 做进一步的对比。
         需要注意Jdk 1.8中对HashMap的实现做了优化,当链表中的节点数据超过八个之后,该
链表会转为红黑树来提高查询效率,复杂度从原来的O(n)到O(logn)。

HashMap在JDK1.7和JDK1.8中有哪些不同?

        在Java中,保存数据有两种比较简单的数据结构:数组和链表。数组的特点是:寻址容 易,插入和删除困难;链表的特点是:寻址困难,但插入和删除容易; 所以我们将数组和链表结合在一起,发挥两者各自的优势,使用一种叫做拉链法的方式可以解决哈希冲突。
JDK1.8之前
         JDK1.8之前采用的是拉链法。
        拉链法:将链表和数组相结合。也就是说创建一个链表数组,数组中每一格就是一个链表。若遇到哈希冲突,则将冲突的值加到链表中即可。
jdk1.7 HashMap 数据结构

 

JDK1.8之后
        相比于之前的版本,jdk1.8在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默
认为8)时,将链表转化为红黑树,以减少搜索时间。

 jdk1.8HashMap数据结构

 

JDK1.7 与 JDK1.8 比较?

JDK1.8主要解决或优化了一下问题:
        1. resize 扩容优化
        2. 引入了红黑树,目的是避免单条链表过长而影响查询效率,红黑树算法请参考:数据结构与算法(十)-红黑树(RedBlackTree)_red-black tree-CSDN博客
        3. 解决了多线程死循环问题,但仍是非线程安全的,多线程时可能会造成数据丢失问题。
不同
JDK 1.7
JDK 1.8
存储结构
数组 + 链表
数组 + 链表 + 红黑树
初始化方式单独函数:inflateTable()直接集成到了扩容函数resize()中
hash值的计算方式
扰动处理
= 9次扰动 = 4次位运算 + 5次异或运算
扰动处理
= 2次扰动 = 1次位运算 + 1次异或运算
存放数据的规则
无冲突时,存放数组;冲突时,存放链表
无冲突时,存放数组;
冲突 & 链表长度< 8:存放单链表;冲突 & 链表长度 > 8:树化并存放红黑树
插入数据 方式
头插法
(先将原位置的数据移到后1 位,再插入数据到该位置)
尾插法
(直接插入到链表尾部/红黑树)
扩容后存
储位置的 计算方式
全部按照原来方法进行计算
(即 hashCode ->> 扰动函数 ->> (h&length-1))
按照扩容后的规律计算
(即扩容后的位置=原位置 or 原位置 + 旧容量)

HashMap的put方法的具体流程?

        当我们put的时候,首先计算 key  的  hash  值,这里调用了 hash  方法, hash  方法实际是让 key.hashCode()  与  key.hashCode()>>>16  进行异或操作,高16bit补0,一个数和0异或不变,所以 hash 函数大概的作用就是:高16bit不变,低16bit和高16bit做了一个异或运算,目的是 减少 碰撞
        按照函数注释,因为bucket数组大小是2的幂,计算下标  index = (table.length - 1) & hash ,如果不做 hash 处理,相当于散列生效的只有几个低 bit 位,为了减少散列的 碰撞,设计者综合考虑了速度、作用、质量之后,使用高16bit和低16bit异或来简单处理减少碰撞,而且JDK8中用了复杂度 O(logn)的树结构来提升碰撞下的性能。
加餐小tips:
        “碰撞”是一个很模糊的概念,这里详细解释一下:
        在哈希表中,“碰撞"指的是不同的键(key)具有相同的哈希值(hash)。由于哈希表的存储空间是有限的,每个哈希值对应一个存储位置,因此当多个不同的键具有相同的哈希值时,这些键在哈希表中将存储在同一个位置,导致数据混乱,这种情况被称为"碰撞”。
        在上文中的hash函数中,通过对哈希值的高16位和低16位进行异或运算,可以使得哈希值的分布更加均匀,从而减少碰撞的发生。这是因为,异或运算可以使得哈希值的每一位都发生改变,从而使得不同的键更有可能产生不同的哈希值,减少了在哈希表中碰撞的可能性。
参考代码如下:
public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

// 实现Map.put和相关方法
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
    Node<K, V>[] tab;
    Node<K, V> p;
    int n, i;

    // 步骤 ①:tab为空则创建
    // table未初始化或者长度为0,进行扩容
    if ((tab = table) == null || (n = tab.length) == 0) {
        n = (tab = resize()).length;
    }

    // 步骤②:计算index,并对null做处理
    // (n - 1) & hash 确定元素存放在哪个桶中,桶为空,新生成结点放入桶中(此时,这个结点是放在数组中)
    if ((p = tab[i = (n - 1) & hash]) == null) {
        tab[i] = newNode(hash, key, value, null);
    } else {
        Node<K, V> e;
        K k;

        // 步骤③:节点key存在,直接覆盖value
        // 比较桶中第一个元素(数组中的结点)的hash值相等,key相等
        if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) {
            // 将第一个元素赋值给e,用e来记录
            e = p;
        } else {
            // 步骤④:判断该链为红黑树
            // hash值不相等,即key不相等;为红黑树结点
            if (p instanceof TreeNode) {
                // 放入树中
                e = ((TreeNode<K, V>) p).putTreeVal(this, tab, hash, key, value);
            } else {
                // 步骤⑤:该链为链表
                // 为链表结点
                for (int binCount = 0;; ++binCount) {
                    // 到达链表的尾部
                    if ((e = p.next) == null) {
                        // 在尾部插入新结点
                        p.next = newNode(hash, key, value, null);
                        // 判断链表的长度是否达到转化红黑树的临界值,临界值为8
                        if (binCount >= TREEIFY_THRESHOLD - 1) {
                            // 链表结构转树形结构
                            treeifyBin(tab, hash);
                            break;
                        }
                    }

                    // 判断链表中结点的key值与插入的元素的key值是否相等
                    if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) {
                        break;
                    }

                    // 用于遍历桶中的链表,与前面的e = p.next组合,可以遍历链表
                    p = e;
                }
            }
        }

        // 步骤⑥:判断当前的key已经存在的情况下,再来一个相同的hash值、key值时,返回新来的value这个值
        if (e != null) {
            V oldValue = e.value;

            if (!onlyIfAbsent || oldValue == null) {
                // 用新值替换旧值
                e.value = value;
                // 访问后回调
                afterNodeAccess(e);
            }

            return oldValue;
        }
    }

    // 结构性修改
    ++modCount;

    // 步骤⑦:超过最大容量就扩容
    // 实际大小大于阈值则扩容
    if (++size > threshold) {
        resize();
    }

    // 插入后回调
    afterNodeInsertion(evict);

    return null;
}
解析:
①.判断键值对数组table[i]是否为空或为null,否则执行resize()进行扩容;
②.根据键值key计算hash值得到插入的数组索引i,如果table[i]==null,直接新建节点添加,转⑥,如果table[i]不为空,转③;
③.判断table[i]的首个元素是否和key一样,如果相同直接覆盖value,否则转④,这里的相同指的是hashCode以及equals;
④.判断table[i] 是否为treeNode,即table[i] 是否是红黑树,如果是红黑树,则直接在树中插入键值对,否则转⑤;
⑤.遍历table[i],判断链表长度是否大于8,大于8的话把链表转换为红黑树,在红黑树中执行插入操作,否则进行链表的插入操作;遍历过程中若发现key已经存在直接覆盖value即可;
⑥.插入成功后,判断实际存在的键值对数量size是否超多了最大容量threshold,如果超过,进行扩容。
  • 19
    点赞
  • 21
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值