一、Java集合面试题

219 阅读10分钟

Set,List,Queue

  1. Set
  • TreeSet:基于红黑树实现,支持有序性操作,例如根据一个范围查找元素的操作。但是查找效率不如 HashSet,HashSet 查找的时间复杂度为 O(1),TreeSet 则为 O(logN)。
  • HashSet:基于哈希表实现,支持快速查找,但不支持有序性操作。并且失去了元素的插入顺序信息,也就是说使用 Iterator 遍历 HashSet 得到的结果是不确定的。
  • LinkedHashSet:具有 HashSet 的查找效率,并且内部使用双向链表维护元素的插入顺序。
  1. List
  • ArrayList:基于动态数组实现,支持随机访问。
  • Vector:和 ArrayList 类似,但它是线程安全的。
  • LinkedList:基于双向链表实现,只能顺序访问,但是可以快速地在链表中间插入和删除元素。不仅如此,LinkedList 还可以用作栈、队列和双向队列。
  1. Queue
  • LinkedList:可以用它来实现双向队列。
  • PriorityQueue:基于堆结构实现,可以用它来实现优先队列。

Map

  • TreeMap:基于红黑树实现。
  • HashMap:基于哈希表实现。
  • HashTable:和 HashMap 类似,但它是线程安全的,这意味着同一时刻多个线程同时写入 HashTable 不会导致数据不一致。它是遗留类,不应该去使用它,而是使用 ConcurrentHashMap 来支持线程安全,ConcurrentHashMap 的效率会更高,因为 ConcurrentHashMap 引入了分段锁。
  • LinkedHashMap:使用双向链表来维护元素的顺序,顺序为插入顺序或者最近最少使用(LRU)顺序。

ArrayList

因为 ArrayList 是基于数组实现的,所以支持快速随机访问。RandomAccess 接口标识着该类支持快速随机访问。

public class ArrayList<E> extends AbstractList<E>
        implements List<E>, RandomAccess, Cloneable, java.io.Serializable

初始化默认大小是10。

  • 扩容

    添加元素时使用 ensureCapacityInternal() 方法来保证容量足够,如果不够时,需要使用 grow() 方法进行扩容,新容量的大小为 oldCapacity + (oldCapacity >> 1),也就是旧容量的 1.5 倍。

    扩容操作需要调用 Arrays.copyOf() 把原数组整个复制到新数组中,这个操作代价很高,因此最好在创建 ArrayList 对象时就指定大概的容量大小,减少扩容操作的次数。

  • 删除

    需要调用 System.arraycopy() 将 index+1 后面的元素都复制到 index 位置上,该操作的时间复杂度为 O(N),可以看到 ArrayList 删除元素的代价是非常高的。

    public E remove(int index) {
        rangeCheck(index);
        modCount++;
        E oldValue = elementData(index);
        int numMoved = size - index - 1;
        if (numMoved > 0)
            System.arraycopy(elementData, index+1, elementData, index, numMoved);
        elementData[--size] = null; // clear to let GC do its work
        return oldValue;
    }
    
  • 序列化

    ArrayList 基于数组实现,并且具有动态扩容特性,因此保存元素的数组不一定都会被使用,那么就没必要全部进行序列化。

    保存元素的数组 elementData 使用 transient 修饰,该关键字声明数组默认不会被序列化。

    ArrayList 实现了 writeObject() 和 readObject() 来控制只序列化数组中有元素填充那部分内容。

Vector

  • 扩容:

    它的实现与 ArrayList 类似,但是使用了 synchronized 进行同步。

Vector与ArrayList 的比较

  • Vector 是同步的,因此开销就比 ArrayList 要大,访问速度更慢。最好使用 ArrayList 而不是 Vector,因为同步操作完全可以由程序员自己来控制;
  • Vector 每次扩容请求其大小的 2 倍(也可以通过构造函数设置增长的容量),而 ArrayList 是 1.5 倍。

LinkedList与 ArrayList 的比较

​ ArrayList 基于动态数组实现,LinkedList 基于双向链表实现。ArrayList 和 LinkedList 的区别可以归结为数组和链表的区别:

  • 数组支持随机访问,但插入删除的代价很高,需要移动大量元素;
  • 链表不支持随机访问,但插入删除只需要改变指针。

HashMap

  • put

    public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
    }
    

    hash方法,计算出该存放的在数组中的位置(除以16求余道理一样):

    hashCode() 方法用于返回字符串的哈希码。

    字符串对象的哈希码根据以下公式计算:

    s[0]*31^(n-1) + s[1]*31^(n-2) + ... + s[n-1]
    

    使用 int 算法,这里 s[i] 是字符串的第 i 个字符,n 是字符串的长度,^ 表示求幂。空字符串的哈希值为 0。

    h无符号位右移动 16位,相当于获取高16位,低16位舍去,

    与h进行异或运算,则一定获取的是一个32位的数字。

    //h无符号位右移动 16位与h进行异或运算
    static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }
    

    在get和put的过程中,计算下标时,先对hashCode进行hash操作,然后再通过hash值进一步计算下标,如下图所示:

    高16bit不变,低16bit和高16bit做了一个异或。

    image

  • 列表转换红黑树阈值时8,红黑树转换列表阈值是6。为什么?

    通过源码我们得知HashMap源码作者通过泊松分布算出,当桶中结点个数为8时,出现的几率是亿分之6的,因此常见的情况是桶中个数小于8的情况,此时链表的查询性能和红黑树相差不多,因为转化为树还需要时间和空间,所以此时没有转化成树的必要。

    既然个数为8时发生的几率这么低,我们为什么还要当链表个数大于8时来树化来优化这几乎不会发生的场景呢?

    首先我们要知道亿分之6这个几乎不可能的概率是建立在什么情况下的 答案是:建立在良好的hash算法情况下,例如String,Integer等包装类的hash算法、如果一旦发生桶中元素大于8,说明是不正常情况,可能采用了冲突较大的hash算法,此时桶中个数出现超过8的概率是非常大的,可能有n个key冲突在同一个桶中,此时再看链表的平均查询复杂度和红黑树的时间复杂度,就知道为什么要引入红黑树了,

    举个例子,若hash算法写的不好,一个桶中冲突1024个key,使用链表平均需要查询512次,但是红黑树仅仅10次,红黑树的引入保证了在大量hash冲突的情况下,HashMap还具有良好的查询性能。

    红黑树的时间复杂度:

    红黑树的插入、删除和遍历的最坏时间复杂度都是log(n),

    列表的时间复杂度:n

  • hashMap的扩容过程是怎么样子的,扩容的大小是什么样的?

    hashmap什么时候进行扩容呢?当hashmap中的元素个数超过数组大小 x loadFactor时,就会进行数组扩容,loadFactor的默认值为0.75,也就是说,默认情况下,数组大小为16,那么当hashmap中元素个数超过16 x 0.75=12的时候,就把数组的大小扩展为2 x 16=32,即扩大一倍,然后重新计算每个元素在数组中的位置,而这是一个非常消耗性能的操作,所以如果我们已经预知hashmap中元素的个数,那么预设元素的个数能够有效的提高hashmap的性能。比如说,我们有1000个元素new HashMap(1000), 但是理论上来讲new HashMap(1024)更合适,不过上面annegu已经说过,即使是1000,hashmap也自动会将其设置为1024。 但是new HashMap(1024)还不是更合适的,因为0.75*1000 < 1000, 也就是说为了让0.75 * size > 1000, 我们必须这样new HashMap(2048)才最合适,既考虑了&的问题,也避免了resize的问题。

  • hash的冲突是如何处理的?

    如果persons.put(“1”,”jack”);persons.put(“2”,”john”); 同时计算到的hash值都为123,那么jack先放在第一列的第一个位置Node-jack,persons.put(“2”,”john”);执行时会将Node-jack的next(Node) = Node(john),Jack的下个节点将指向Node(john)。

    那么取的时候呢,persons.get(“2”),这个时候取得的hash值是123,即table[123],这时table[123]其实是Node-jack,Key值不相等,取Node-jack的next下个Node,即Node-John,这时Key值相等了,然后返回对应的person。

  • hashMap的多线程的环境下会引发什么样的情况?(列表环路,为什么?画图说明)

    1.put的时候导致的多线程数据不一致。

    2.多线程put后可能导致get死循环:

    void transfer(Entry[] newTable) {
         Entry[] src = table;                   //src引用了旧的Entry数组
         int newCapacity = newTable.length;
         for (int j = 0; j < src.length; j++) { //遍历旧的Entry数组
             Entry<K,V> e = src[j];             //取得旧Entry数组的每个元素
             if (e != null) {
                 src[j] = null;//释放旧Entry数组的对象引用(for循环后,旧的Entry数组不再引用任何对象)
                 do {
                     Entry<K,V> next = e.next;
                     int i = indexFor(e.hash, newCapacity); //!!重新计算每个元素在数组中的位置
                     e.next = newTable[i]; //标记[1]
                     newTable[i] = e;      //将元素放在数组上
                     e = next;             //访问下一个Entry链上的元素
                 } while (e != null);
             }
         }
     }
    
    1. 对索引数组中的元素遍历

    2. 对链表上的每一个节点遍历:用 next 取得要转移那个元素的下一个,将 e 转移到新 Hash 表的头部,使用头插法插入节点。

    3. 循环2,直到链表节点全部转移

    4. 循环1,直到所有索引数组全部转移

    经过这几步,我们会发现转移的时候是逆序的。假如转移前链表顺序是1->2->3,那么转移后就会变成3->2->1。这时候就有点头绪了,死锁问题不就是因为1->2的同时2->1造成的吗?所以,HashMap 的死锁问题就出在这个transfer()函数上。

  • hashMap的初始化大小是多少?如果自定义初始化大小为会如何?

    初始化大小是:16,

    最大定义是2的30次方。超过这个大小则为2的30次方,

    自定义为3 的话,则初始化大小为4,为最近的2的n次方。

    如果HashMap需要放置1024个元素,由于没有设置容量初始大小,随着元素不断增加,容量7次被迫扩大,resize需要重建hash表,严重影响性能。

  • 为什么hashMap的初始化大小会设置为2的n次方?

    为了减少hash碰撞,因为tab的存放位置是(n - 1) & hash,2的n次方发生hash碰撞的几率要小,能均匀分布。

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