有备而来——Java容器面试题总结

前言

  • 主要是在 javaGuide 以及 CYC2018 的基础上做了修改以及补充。
  • 其它参考资料都在文末给出,建议阅读。
  • ⭐️内容较多,点赞收藏不迷路 ⭐️

i++ 和 ++i

  • i++ 是先使用,后自增。

  • ++i 是先自增,后使用。

  • 自增与使用两个操作不是原子性的,所以在多并发环境下会出现问题,比如值覆盖。

说说List,Set,Map三者的区别?

  • List 和 Set 都是继承自 Collection。

  • List 可以有多个元素引用相同的对象,Set 不允许重复的集合。不会有多个元素引用相同的对象。

  • Map 使用键值对存储。Map会维护与Key有关联的值。两个Key可以引用相同的对象,但Key不能重复,典型的Key是String、Integer类型,但也可以是任何对象。

  • Set

    • SortedSet–>TreeSet \ HashSet \ LinkedHashSet
  • List

    • ArrayList \ Vector \ LinkedList
  • Map

    • TreeMap \ HashMap \ HashTable \ LinkedHashMap

Arraylist 与 LinkedList 区别?

  • ArrayList 基于动态数组实现,LinkedList 基于双向链表(JDK1.6之前为循环链表,JDK1.7取消了循环。)实现。ArrayList 和 LinkedList 的区别可以归结为数组和链表的区别。
  • 数组支持随机访问,但插入删除的代价很高,需要移动大量元素;
  • 链表不支持随机访问,但插入删除只需要改变指针。
  • 两者都是非同步,也即不保证线程安全。
  • ArrayList的空 间浪费主要体现在在list列表的结尾会预留一定的容量空间,而LinkedList的空间花费则体现在它的每一个元素都需要消耗比ArrayList更多的空间(因为要存放直接后继和直接前驱以及数据)。
list的遍历选择
  • 实现了 RandomAccess 接口的list,优先选择普通 for 循环 ,其次 foreach。
  • 未实现 RandomAccess接口的list,优先选择iterator遍历(foreach遍历底层也是通过iterator实现的),大size的数据,千万不要使用普通for循环。

ArrayList 与 Vector 区别呢?为什么要用Arraylist取代Vector呢?

Vector类的所有方法都是同步的。可以由两个线程安全地访问一个Vector对象、但是一个线程访问Vector的话代码要在同步操作上耗费大量的时间。

Arraylist不是同步的,所以在不需要保证线程安全时建议使用Arraylist。

说一说 ArrayList 的扩容机制吧

  • 以无参数构造方法创建 ArrayList 时,实际上初始化赋值的是一个空数组。当真正对数组进行添加元素操作时,才真正分配容量。即向数组中添加第一个元素时,数组容量扩为10。
  • 添加新元素时,都会让实际容量 size + 1 ,然后调用 ensureCapacityInternal 函数确保有足够的容量使插入成功。
  • ensureCapacityInternal中,会先判断当前的数组容量是否是默认数组,如果是的话就取 max(默认数组大小,传入的指定最小大小)。
  • 再调用 ensureExplicitCapacity确定具体大小,在这之前会让 modCount ++ ,以此来支持实现 fail-fast,再判断最小值是否大于了数组长度,如果是的话就实施扩容。
  • 调用 grow 来实现扩容,oldCapacity + (oldCapacity >> 1) 新数组长度是旧数组长度的 1.5 倍。
  • 如果新数组长度够用了,就用新数组长度,如果不够用,就将新数组长度设为指定的最小长度,而不再计算1.5倍的扩容。
  • 判断当前新数组长度是否超过了最大容量,如果超过了就使用最大容量。
  • 将旧数组的内容复制到新数组,然后使对象指针指向新的数组。

HashMap 和 Hashtable 的区别

  1. 线程是否安全: HashMap 是非线程安全的,HashTable 是线程安全的;HashTable 内部的方法基本都经过synchronized 修饰。多线程下一般使用 ConcurrentHashMap
  2. 效率: 因为线程安全的问题,HashMap 要比 HashTable 效率高一点。另外,HashTable 基本被淘汰,不要在代码中使用它;
  3. 对Null key 和Null value的支持: HashMap 中,null 可以作为键,这样的键只有一个,可以有一个或多个键所对应的值为 null。。但是在 HashTable 中 put 进的键值只要有一个 null,直接抛出 NullPointerException。
  4. 初始容量大小和每次扩充容量大小的不同 : ①创建时如果不指定容量初始值,Hashtable 默认的初始大小为11,之后每次扩充,容量变为原来的2n+1。HashMap 默认的初始化大小为16,但是会在第一次添加时才真正初始化大小。之后每次扩充,容量变为原来的2倍。②创建时如果给定了容量初始值,那么 Hashtable 会直接使用你给定的大小,而 HashMap 会将其扩充为2的幂次方大小。
  5. 底层数据结构: JDK1.8 以后的 HashMap 在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间,当红黑树长度小于 6 时会自动转化成链表。Hashtable 没有这样的机制。

HashMap 和 HashSet区别

HashSet 底层就是基于 HashMap 实现的。(HashSet 的源码非常非常少,因为除了 clone()writeObject()readObject()是 HashSet 自己不得不实现之外,其他方法都是直接调用 HashMap 中的方法。

ashMap HashSet
实现了Map接口 实现Set接口
存储键值对 仅存储对象
调用 put()向map中添加元素 调用 add()方法向Set中添加元素
HashMap使用键(Key)计算Hashcode HashSet使用成员对象来计算hashcode值,对于两个对象来说hashcode可能相同,所以equals()方法用来判断对象的相等性,

HashSet如何检查重复

当你把对象加入HashSet时,HashSet会先计算对象的hashcode值来判断对象加入的位置,同时也会与其他加入的对象的hashcode值作比较,如果没有相符的hashcode,HashSet会假设对象没有重复出现。但是如果发现有相同hashcode值的对象,这时会调用equals()方法来检查hashcode相等的对象是否真的相同。如果两者相同,HashSet就不会让加入操作成功。

HashMap的底层实现

JDK1.8 之前 HashMap 底层是 数组和链表 结合在一起使用也就是 链表散列。HashMap 通过 key 的 hashCode 经过扰动函数处理过后得到 hash 值,然后通过 (n - 1) & hash 判断当前元素存放的位置(这里的 n 指的是数组的长度),如果当前位置存在元素的话,就判断该元素与要存入的元素的 hash 值以及 key 是否相同,如果相同的话,直接覆盖,不相同就通过拉链法解决冲突。

所谓扰动函数指的就是 HashMap 的 hash 方法。使用 hash 方法也就是扰动函数是为了防止一些实现比较差的 hashCode() 方法 换句话说使用扰动函数之后可以减少碰撞。

相比于 JDK1.8 的 hash 方法 ,JDK 1.7 的 hash 方法的性能会稍差一点点,因为毕竟扰动了 4 次。

相比于之前的版本, JDK1.8之后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树,长度小于8时会退化成链表)时,将链表转化为红黑树,以减少搜索时间。

HashMap 的长度为什么是2的幂次方

为了能让 HashMap 存取高效,尽量较少碰撞,也就是要尽量把数据分配均匀。我们上面也讲到了过了,Hash 值的范围值-2147483648到2147483647,前后加起来大概40亿的映射空间,只要哈希函数映射得比较均匀松散,一般应用是很难出现碰撞的。但问题是一个40亿长度的数组,内存是放不下的。所以这个散列值是不能直接拿来用的。用之前还要先做对数组的长度取模运算,得到的余数才能用来要存放的位置也就是对应的数组下标。这个数组下标的计算方法是“ (n - 1) & hash”。(n代表数组长度)。这也就解释了 HashMap 的长度为什么是2的幂次方。

这个算法应该如何设计呢?

我们首先可能会想到采用%取余的操作来实现。但是,重点来了:“取余(%)操作中如果除数是2的幂次则等价于与其除数减一的与(&)操作(也就是说 hash%length==hash&(length-1)的前提是 length 是2的 n 次方;)。” 并且 采用二进制位操作 &,相对于%能够提高运算效率,这就解释了 HashMap 的长度为什么是2的幂次方。

HashMap 多线程下容易出现死循环

两个线程同时需要扩容,扩容下 transfer() 转移部分容易出现死循环。

线程一已经扩容完毕,此时线程一的新 HashMap 的链表是倒序的。线程二开始扩容,但是它的初始指针指向的内容以及顺序还是旧链表的,但是此时这些东西已经到了线程一创建的新链表,所以后面线程二会去改变线程一的新链表结构,按照头插法的特点,最后线程二形成的链表会形成一个死循环,而且可能会造成数据丢失。

当执行 get 时,当key 正好被分到那个 table[i] 上时,遍历链表就会产生循环。

因此多线程情况下建议使用ConcurrentHashMap。

循环的产生是因为新链表的顺序跟旧的链表是完全相反的,所以只要保证建新链时还是按照原来的顺序的话就不会产生循环。

JDK8是用 head 和 tail 来保证链表的顺序和之前一样,这样就不会产生循环引用。但 1.8 还是没有解决数据丢失的问题。

ConcurrentHashMap 和 Hashtable 的区别

  • **底层数据结构:**DK1.7的 ConcurrentHashMap 底层采用 分段的数组+链表 实现,JDK1.8 采用的数据结构跟HashMap1.8的结构一样,数组+链表/红黑二叉树。Hashtable 和 JDK1.8 之前的 HashMap 的底层数据结构类似都是采用 数组+链表 的形式,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的;
  • 实现线程安全的方式(重要):
    • 在JDK1.7的时候,ConcurrentHashMap(分段锁) 对整个桶数组进行了分割分段(Segment),每一把锁只锁容器其中一部分数据,多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。 到了 JDK1.8 的时候已经摒弃了Segment的概念,而是直接用 Node 数组+链表+红黑树的数据结构来实现,并发控制使用 synchronized 和 CAS 来操作。(JDK1.6以后 对 synchronized锁做了很多优化) 整个看起来就像是优化过且线程安全的 HashMap,虽然在JDK1.8中还能看到 Segment 的数据结构,但是已经简化了属性,只是为了兼容旧版本;
    • Hashtable(同一把锁) :使用 synchronized 来保证线程安全,效率非常低下。当一个线程访问同步方法时,其他线程也访问同步方法,可能会进入阻塞或轮询状态,如使用 put 添加元素,另一个线程不能使用 put 添加元素,也不能使用 get,竞争会越来越激烈效率越低。

有备而来——Java容器面试题总结_第1张图片

JDK1.7的ConcurrentHashMap:

有备而来——Java容器面试题总结_第2张图片

JDK1.8的ConcurrentHashMap(TreeBin: 红黑二叉树节点 Node: 链表节点):

有备而来——Java容器面试题总结_第3张图片

ConcurrentHashMap线程安全的具体实现方式/底层具体实现

  • 1.7

    首先将数据分为一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据时,其他段的数据也能被其他线程访问。

    ConcurrentHashMap 是由 Segment 数组结构和 HashEntry 数组结构组成

    Segment 实现了 ReentrantLock,所以 Segment 是一种可重入锁,扮演锁的角色。HashEntry 用于存储键值对数据。

    static class Segment<K,V> extends ReentrantLock implements Serializable {
           
    }
    

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

  • 1.8

    ConcurrentHashMap取消了Segment分段锁,采用CAS和synchronized来保证并发安全。数据结构跟HashMap1.8的结构类似,数组+链表/红黑二叉树。Java 8在链表长度超过一定阈值(8)时将链表(寻址时间复杂度为O(N))转换为红黑树(寻址时间复杂度为O(log(N)))

    synchronized只锁定当前链表或红黑二叉树的首节点,这样只要hash不冲突,就不会产生并发,效率又提升N倍。

comparable 和 Comparator的区别

  • comparable接口实际上是出自java.lang包 它有一个 compareTo(Object obj)方法用来排序
  • comparator接口实际上是出自 java.util 包它有一个compare(Object obj1, Object obj2)方法用来排序
        Collections.sort(arrayList, new Comparator<Integer>() {
     

            @Override
            public int compare(Integer o1, Integer o2) {
     
                return o2.compareTo(o1);
            }
        });
    @Override
    public int compareTo(Person o) {
     
        // TODO Auto-generated method stub
        if (this.age > o.getAge()) {
     
            return 1;
        }
        if (this.age < o.getAge()) {
     
            return -1;
        }
        return 0;
    }

集合框架底层数据结构总结

Collection

1. List

  • Arraylist: Object数组
  • Vector: Object数组
  • LinkedList: 双向链表(JDK1.6之前为循环链表,JDK1.7取消了循环)

2. Set

  • HashSet(无序,唯一): 基于 HashMap 实现的,底层采用 HashMap 来保存元素
  • LinkedHashSet: LinkedHashSet 继承于 HashSet,并且其内部是通过 LinkedHashMap 来实现的。有点类似于我们之前说的LinkedHashMap 其内部是基于 HashMap 实现一样,不过还是有一点点区别的
  • TreeSet(有序,唯一): 红黑树(自平衡的排序二叉树)
Map
  • HashMap: JDK1.8之前HashMap由数组+链表组成的,数组是HashMap的主体,链表则是主要为了解决哈希冲突而存在的(“拉链法”解决冲突)。JDK1.8以后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间
  • LinkedHashMap: LinkedHashMap 继承自 HashMap,所以它的底层仍然是基于拉链式散列结构即由数组和链表或红黑树组成。另外,LinkedHashMap 在上面结构的基础上,增加了一条双向链表,使得上面的结构可以保持键值对的插入顺序。同时通过对链表进行相应的操作,实现了访问顺序相关逻辑。
  • Hashtable: 数组+链表组成的,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的
  • TreeMap: 红黑树(自平衡的排序二叉树)

如何选用集合?

主要根据集合的特点来选用。

比如我们需要根据键值获取到元素值时,就选用Map接口下的集合,需要排序时选择TreeMap,不需要排序时就选择HashMap,需要保证线程安全就选用ConcurrentHashMap。

当我们只需要存放元素值时,就选择实现Collection接口的集合,需要保证元素唯一时选择实现Set接口的集合比如TreeSet或HashSet,不需要就选择实现List接口的比如ArrayList或LinkedList,然后再根据实现这些接口的集合的特点来选用。

为什么 HashMap 1.8 都不是线程安全的?

  • 会出现数据覆盖情况。
  • 假设两个线程A、B都在进行put操作,并且hash函数计算出的插入下标是相同的,当线程A执行完第六行代码后由于时间片耗尽导致被挂起,而线程B得到时间片后在该下标处插入了元素,完成了正常的插入,然后线程A获得时间片,由于之前已经进行了hash碰撞的判断,所有此时不会再进行判断,而是直接进行插入,这就导致了线程B插入的数据被线程A覆盖了,从而线程不安全。

哈希表如何解决Hash冲突

有备而来——Java容器面试题总结_第4张图片

HashMap扩容

  • 插入后检查容量是否达到阈值,发现已达到
  • 保存旧数组
  • 遍历旧数组的每个数据
  • 重新计算每个数据在新数组中的存储位置
  • 将旧数据逐一转移到新数组
  • 新数组 table 重新引用新数组
  • 重新设置阈值 threshold
  • 扩容结束。

为什么不直接使用 hashCode() ?

哈希码的范围比 HashMap 的范围大,容易出现范围不匹配。

2.3 为什么采用 哈希码 与运算(&) (数组长度-1) 计算数组下标?

根据HashMap的容量大小(数组长度),按需取 哈希码一定数量的低位 作为存储的数组下标位置,从而 解决 “哈希码与数组大小范围不匹配” 的问题。

2.4 为什么在计算数组下标前,需对哈希码进行二次处理:扰动处理?

hold

  • 扩容结束。

为什么不直接使用 hashCode() ?

哈希码的范围比 HashMap 的范围大,容易出现范围不匹配。

2.3 为什么采用 哈希码 与运算(&) (数组长度-1) 计算数组下标?

根据HashMap的容量大小(数组长度),按需取 哈希码一定数量的低位 作为存储的数组下标位置,从而 解决 “哈希码与数组大小范围不匹配” 的问题。

2.4 为什么在计算数组下标前,需对哈希码进行二次处理:扰动处理?

加大哈希码低位的随机性,使得分布更均匀,从而提高对应数组存储下标位置的随机性 & 均匀性,最终减少Hash冲突。

拓展阅读

《有备而来——Java基础面试题全总结》
《Java 热点基础》


参考资料
《HashMap 多线程下死循环分析及JDK8修复》
《Java 8系列之重新认识HashMap》
《老生常谈,HashMap的死循环》
《Java:手把手带你源码分析 HashMap 1.7》

⭐️ 如果对你有帮助,麻烦点个

你可能感兴趣的:(Java基础)