Java集合面试复习_数据结构

1. List,Set,Map 三者的区别

List(链表): 存储的元素是有序的、可重复的。

Set(集合): 存储的元素是无序的、不可重复的。

Map(图、映射): 使用键值对(key-value)存储,类似于数学上的函数 y=f(x),“x”代表 key,"y"代表 value,Key 是无序的、不可重复的,value 是无序的、可重复的,每个键最多映射到一个值。
每个钥匙最多开一把锁。

1.1 List

Arraylist: Object[]数组

Vector:Object[]数组

LinkedList: 双向链表(JDK1.6 之前为循环链表,JDK1.7 取消了循环)

Arraylist 与 LinkedList 区别

是否保证线程安全: ArrayList 和 LinkedList 都是不同步的,也就是不保证线程安全;

底层数据结构:
Arraylist 底层使用的是 Object 数组;LinkedList 底层使用的是 双向链表 数据结构(JDK1.6 之前为循环链表,JDK1.7 取消了循环。注意双向链表和双向循环链表的区别)

插入和删除是否受元素位置的影响:
① ArrayList 采用数组存储,所以插入和删除元素的时间复杂度受元素位置的影响。 比如:执行add(E e)方法的时候, ArrayList 会默认在将指定的元素追加到此列表的末尾,这种情况时间复杂度就是 O(1)。但是如果要在指定位置 i 插入和删除元素的话(add(int index, E element))时间复杂度就为 O(n-i)。因为在进行上述操作的时候集合中第 i 和第 i 个元素之后的(n-i)个元素都要执行向后位/向前移一位的操作。
② LinkedList 采用链表存储,所以对于add(E e)方法的插入,删除元素时间复杂度不受元素位置的影响,近似 O(1),如果是要在指定位置i插入和删除元素的话((add(int index, E element)) 时间复杂度近似为o(n))因为需要先移动到指定位置再插入。

是否支持快速随机访问: LinkedList 不支持高效的随机元素访问,而 ArrayList 支持。快速随机访问就是通过元素的序号快速获取元素对象(对应于get(int index)方法)。

内存空间占用: ArrayList 的空 间浪费主要体现在在 list 列表的结尾会预留一定的容量空间,而 LinkedList 的空间花费则体现在它的每一个元素都需要消耗比 ArrayList 更多的空间(因为要存放直接后继和直接前驱以及数据)。

1.2 Set

HashSet(无序,唯一): 基于 HashMap 实现的,底层采用 HashMap 来保存元素

LinkedHashSet: LinkedHashSet 是 HashSet 的子类,并且其内部是通过 LinkedHashMap 来实现的。有点类似于我们之前说的 LinkedHashMap 其内部是基于 HashMap 实现一样,不过还是有一点点区别的

TreeSet(有序,唯一): 红黑树(自平衡的排序二叉树)

HashSet、LinkedHashSet 和 TreeSet 三者的异同

围绕着Hash、LinkedHash、Tree来回答,

HashSet 是 Set 接口的主要实现类 ,HashSet 的底层是 HashMap,线程不安全的,可以存储 null 值;

LinkedHashSet 是 HashSet 的子类,能够按照添加的顺序遍历(有序化);

TreeSet 底层使用红黑树,能够按照添加元素的顺序进行遍历,排序的方式有自然排序和定制排序。

HashSet 如何检查重复

克隆人不让进啊:每个元素都有自己独特的id,检录的时候我们先看看咱们的系统有没有相同身份证的,如果有,也没啥,equals看看是否真的相同,相同那没有商量

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

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

如果两个对象相等,则 hashcode 一定也是相同的

两个对象相等,对两个 equals() 方法返回 true

两个对象有相同的 hashcode 值,它们也不一定是相等的

综上,equals() 方法被覆盖过,则 hashCode() 方法也必须被覆盖

hashCode() 的默认行为是对堆上的对象产生独特值。**如果没有重写 hashCode(),则该 class 的两个对象无论如何都不会相等(即使这两个对象指向相同的数据)。**不重写hashCode那就废了!形同虚设你懂的。

==与 equals 的区别

【1】双等于号比较的是栈帧中(局部变量表)值,在局部变量表里面,基本数据类型的值就是字面值,引用类型的值就是地址;
【2】equals 本质上就是双等于号,为什么呢?equals实际上是所有类的父类——Object类的方法其实就是==实现的,他们的区别在于一个可以重写一个不能够重写

【例子】常见的String类就重写了equals方法,我们在比较字符串的时候其实是在看堆中字符串常量池里面的对象指向是否相同。

【常量池】有特殊情况,Integer有缓存-128~127的数字,所以会出现1000!=1000的情况。

1.3 Map

HashMap 和 Hashtable 的区别

ConcurrentHashMap

线程是否安全: HashMap 是非线程安全的,HashTable 是线程安全的,因为 HashTable 内部的方法基本都经过synchronized 修饰。(如果你要保证线程安全的话就使用 ConcurrentHashMap 吧!);

效率: 因为线程安全的问题,HashMap 要比 HashTable 效率高一点。另外,HashTable 基本被淘汰,不要在代码中使用它;

对 Null key 和 Null value 的支持: HashMap 可以存储 null 的 key 和 value,但 null 作为键只能有一个,null 作为值可以有多个;HashTable 不允许有 null 键和 null 值,否则会抛出 NullPointerException。空指针异常

初始容量大小和每次扩充容量大小的不同 : ① 创建时如果不指定容量初始值,Hashtable 默认的初始大小为 11,之后每次扩充,容量变为原来的 2n+1。HashMap 默认的初始化大小为 16。之后每次扩充,容量变为原来的 2 倍。② 创建时如果给定了容量初始值,那么 Hashtable 会直接使用你给定的大小,而 HashMap 会将其扩充为 2 的幂次方大小(HashMap 中的tableSizeFor()方法保证,下面给出了源代码)。也就是说 HashMap 总是使用 2 的幂作为哈希表的大小,后面会介绍到为什么是 2 的幂次方。

底层数据结构: JDK1.8 以后的 HashMap 在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间。Hashtable 没有这样的机制。

HashMap 中带有初始容量的构造函数:

    public HashMap(int initialCapacity, float loadFactor) {
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: " +initialCapacity);
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: " +
                                               loadFactor);
        this.loadFactor = loadFactor;
        this.threshold = tableSizeFor(initialCapacity);
    }
     public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }

下面这个方法保证了 HashMap 总是使用 2 的幂作为哈希表的大小。

    /**
     * Returns a power of two size for the given target capacity.
     */
    
    static final int tableSizeFor(int cap) {
    
        int n = cap - 1;
        n |= n >>> 1;
        n |= n >>> 2;
        n |= n >>> 4;
        n |= n >>> 8;
        n |= n >>> 16;
        return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
    }

HashMap 和 TreeMap 区别

TreeMap 和HashMap 都继承自AbstractMap ,但是需要注意的是TreeMap它还实现了NavigableMap接口和SortedMap 接口。

实现 NavigableMap 接口让 TreeMap 有了对集合内元素的搜索的能力。

实现SortMap接口让 TreeMap 有了对集合中的元素根据键排序的能力。默认是按 key 的升序排序,不过我们也可以指定排序的比较器。示例代码如下:

public class Person {
    private Integer age;

    public Person(Integer age) {
        this.age = age;
    }

    public Integer getAge() {
        return age;
    }

    public static void main(String[] args) {
        TreeMap<Person, String> treeMap = new TreeMap<>(new Comparator<Person>() {
            @Override
            public int compare(Person person1, Person person2) {
                int num = person1.getAge() - person2.getAge();
                return Integer.compare(num, 0);
            }
        });
        treeMap.put(new Person(3), "person1");
        treeMap.put(new Person(18), "person2");
        treeMap.put(new Person(35), "person3");
        treeMap.put(new Person(16), "person4");
        treeMap.entrySet().stream().forEach(personStringEntry -> {
            System.out.println(personStringEntry.getValue());
        });
    }
}

输出:

person1
person4
person2
person3

可以看出,TreeMap 中的元素已经是按照 Person 的 age 字段的升序来排列了。

上面,我们是通过传入匿名内部类的方式实现的,你可以将代码替换成 Lambda 表达式实现的方式:

TreeMap<Person, String> treeMap = new TreeMap<>((person1, person2) -> {
  int num = person1.getAge() - person2.getAge();
  return Integer.compare(num, 0);
});

综上,相比于HashMap来说 TreeMap 主要多了对集合中的元素根据键排序的能力以及对集合内元素的搜索的能力。

HashMap 的底层实现【重点】

JDK1.8 之前

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

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

JDK 1.8 HashMap 的 hash 方法源码:

JDK 1.8 的 hash 方法 相比于 JDK 1.7 hash 方法更加简化,但是原理不变。

    static final int hash(Object key) {
      int h;
      // key.hashCode():返回散列值也就是hashcode
      // ^ :按位异或
      // >>>:无符号右移,忽略符号位,空位都以0补齐
      return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
  }

对比一下 JDK1.7 的 HashMap 的 hash 方法源码.

static int hash(int h) {
    // This function ensures that hashCodes that differ only by
    // constant multiples at each bit position have a bounded
    // number of collisions (approximately 8 at default load factor).
    h ^= (h >>> 20) ^ (h >>> 12);
    return h ^ (h >>> 7) ^ (h >>> 4);
}

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

所谓 “拉链法” 就是:将链表和数组相结合。也就是说创建一个链表数组,数组中每一格就是一个链表。若遇到哈希冲突,则将冲突的值加到链表中即可。

JDK1.8 之后

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

TreeMap、TreeSet 以及 JDK1.8 之后的 HashMap 底层都用到了红黑树。红黑树就是为了解决二叉查找树的缺陷,因为二叉查找树在某些情况下会退化成一个线性结构。

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

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

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

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

为什么1.8后链表插入使用尾插法

数据结构

对 HashMap 的底层数据结构,相信大家都有所了解,不同的版本,底层数据结构会有所不同

1.7 的底层数据结构

/**

  • An empty table instance to share when the table is not inflated.

*/

static final Entry<?,?>[] EMPTY_TABLE = {};

/**

  • The table, resized as necessary. Length MUST Always be a power of two.

*/

transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;

static class Entry<K,V> implements Map.Entry<K,V> {

final K key;

V value;

Entry<K,V> next;

int hash;



...

}

1.8 的底层数据结构

/**

  • The table, initialized on first use, and resized as

  • necessary. When allocated, length is always a power of two.

  • (We also tolerate length zero in some operations to allow

  • bootstrapping mechanics that are currently not needed.)

*/

transient Node<K,V>[] table;

static class Node<K,V> implements Map.Entry<K,V> {

final int hash;

final K key;

V value;

Node<K,V> next;



...

}

/**

  • Entry for Tree bins. Extends LinkedHashMap.Entry (which in turn

  • extends Node) so can be used as extension of either regular or

  • linked node.

*/

static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {

TreeNode<K,V> parent;  // red-black tree links

TreeNode<K,V> left;

TreeNode<K,V> right;

TreeNode<K,V> prev;    // needed to unlink next upon deletion

boolean red;



...

}

但基础结构还是: 数组 + 链表 ,称作 哈希表 或 散列表

只是 1.8 做了优化,引进了 红黑树 ,来提升链表中元素获取的速度

JDK1.7 头插

只有元素添加的时候,才会出现链表元素的插入,那么我们先来看看 put 方法

put - 添加元素

源码如下

/**

 * Associates the specified value with the specified key in this map.

 * If the map previously contained a mapping for the key, the old

 * value is replaced.

 *

 * @param key key with which the specified value is to be associated

 * @param value value to be associated with the specified key

 * @return the previous value associated with <tt>key</tt>, or

 *         <tt>null</tt> if there was no mapping for <tt>key</tt>.

 *         (A <tt>null</tt> return can also indicate that the map

 *         previously associated <tt>null</tt> with <tt>key</tt>.)

 */

public V put(K key, V value) {

    if (table == EMPTY_TABLE) {

        inflateTable(threshold);

    }

    if (key == null)

        return putForNullKey(value);

    int hash = hash(key);

    int i = indexFor(hash, table.length);

    for (Entry<K,V> e = table[i]; e != null; e = e.next) {

        Object k;

        if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {

            V oldValue = e.value;

            e.value = value;

            e.recordAccess(this);

            return oldValue;

        }

    }



    modCount++;

    addEntry(hash, key, value, i);

    return null;

}

直接看代码可能不够直观,我们结合流程图来看

那我们就结合具体案例来看下这个流程

假设 HashMap 初始状态

然后依次往里面添加元素:(2,b), (3,w), (5,e), (9,t), (16,p)

再利用断点调试,我们来看看真实情况

一切都对得上,进展的也挺顺利

resize - 数组扩容

上述提到了扩容,但是没细讲,我们来看看扩容的实现

关键代码如下

/**

  • Rehashes the contents of this map into a new array with a

  • larger capacity. This method is called automatically when the

  • number of keys in this map reaches its threshold.

  • If current capacity is MAXIMUM_CAPACITY, this method does not

  • resize the map, but sets threshold to Integer.MAX_VALUE.

  • This has the effect of preventing future calls.

  • @param newCapacity the new capacity, MUST be a power of two;

  •    must be greater than current capacity unless current
    
  •    capacity is MAXIMUM_CAPACITY (in which case value
    
  •    is irrelevant).
    

*/

void resize(int newCapacity) {

Entry[] oldTable = table;

int oldCapacity = oldTable.length;

if (oldCapacity == MAXIMUM_CAPACITY) {

    threshold = Integer.MAX_VALUE;

    return;

}



Entry[] newTable = new Entry[newCapacity];

transfer(newTable, initHashSeedAsNeeded(newCapacity));

table = newTable;

threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);

}

/**

  • Transfers all entries from current table to newTable.

*/

void transfer(Entry[] newTable, boolean rehash) {

int newCapacity = newTable.length;

for (Entry<K,V> e : table) {

    while(null != e) {

        Entry<K,V> next = e.next;

        if (rehash) {

            e.hash = null == e.key ? 0 : hash(e.key);

        }

        int i = indexFor(e.hash, newCapacity);

        e.next = newTable[i];

        newTable[i] = e;

        e = next;

    }

}

}

主要做了两件事:1、创建一个新的 Entry 空数组,长度是原数组的 2 倍,2、遍历原数组,对每个元素重新计算新数组的索引值,然后放入到新数组的对应位置

有意思的是这个转移方法:transfer,我们结合案例来仔细看看

假设扩容之前的状态如下图所示

扩容过程如下

利用断点调试,我们来看看真实情况

链表元素的转移,还是采用的头插法

链表成环

不管是元素的添加,还是数组扩容,只要涉及到 hash 冲突,就会采用头插法将元素添加到链表中

上面讲了那么多,看似风平浪静,实则暗流涌动;单线程下,确实不会有什么问题,那多线程下呢 ?我们接着往下看

将设扩容之前的的状态如下所示

然后,线程 1 添加 (1,a) ,线程 2 添加 (19,n),线程 1 会进行扩容,线程 2 也进行扩容,那么 transfer 的时候就可能出现如下情况

哦豁,链表成环了,这就会导致:Infinite Loop

JDK1.8 尾插

1.8就不讲那么详细了,我们主要来看看 resize 中的元素转移部分

if (oldTab != null) {

    // 从索引 0 开始逐个遍历旧 table

    for (int j = 0; j < oldCap; ++j) {

        Node<K,V> e;

        if ((e = oldTab[j]) != null) {

            oldTab[j] = null;

            if (e.next == null)    // 链表只有一个元素

                newTab[e.hash & (newCap - 1)] = e;

            else if (e instanceof TreeNode)    // 红黑树,先不管

                ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);

            else { // preserve order

                // 拆链表,拆成两个子链表:索引不变的元素链表和有相同偏移量的元素链表

                // 每个链表都保持原有顺序

                Node<K,V> loHead = null, loTail = null;

                Node<K,V> hiHead = null, hiTail = null;

                Node<K,V> next;

                do {

                    next = e.next;

                    if ((e.hash & oldCap) == 0) {

                        // 索引不变的元素链表

                        if (loTail == null)

                            loHead = e;

                        else    // 通过尾部去关联 next,维持了元素原有顺序

                            loTail.next = e;

                        loTail = e;

                    }

                    else {

                        // 相同偏移量的元素链表

                        if (hiTail == null)

                            hiHead = e;

                        else    // 通过尾部去关联 next,维持了元素原有顺序

                            hiTail.next = e;

                        hiTail = e;

                    }

                } while ((e = next) != null);

                if (loTail != null) {

                    loTail.next = null;

                    newTab[j] = loHead;

                }

                if (hiTail != null) {

                    hiTail.next = null;

                    newTab[j + oldCap] = hiHead;

                }

            }

        }

    }

}

通过尾插法,维护了链表元素的原有顺序

在扩容时,头插法会改变链表中元素原本的顺序,以至于在并发场景下导致链表成环的问题,而尾插法,在扩容时会保持链表元素原本的顺序,就不会出现链表成环的问题

相关疑惑

1、JDK 1.7及之前,为什么采用头插法

呃… 这个可能需要问头插法的实现者了;

但有种说法,我觉得挺有道理:缓存的时间局部性原则,最近访问过的数据下次大概率会再次访问,把刚访问过的元素放在链表最前面可以直接被查询到,减少查找次数

2、既然头插法有链表成环的问题,为什么直到 1.8 才采用尾插法来替代头插法

只有在并发情况下,头插法才会出现链表成环的问题,多线程情况下,HashMap 本就非线程安全,这就相当于你在它的规则之外出了问题,那能怪谁?

1.8 采用尾插,是对 1.7 的优化

3、既然 1.8 没有链表成环的问题,那是不是说明可以把 1.8 中的 HashMap 用在多线程中

链表成环只是并发问题中的一种,1.8 虽然解决了此问题,但是还是会有很多其他的并发问题,比如:上秒 put 的值,下秒 get 的时候却不是刚 put 的值;因为操作都没有加锁,不是线程安全的

总结

1、JDK 1.7 采用头插法来添加链表元素,存在链表成环的问题,1.8 中做了优化,采用尾插法来添加链表元素

2、HashMap 不管在哪个版本都不是线程安全的,出了并发问题不要怪 HashMap,从自己身上找原因

ConcurrentHashMap 和 Hashtable 的区别

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

2. comparable 和 Comparator 的区别 3. Collections 工具类

Collections 工具类常用方法:

  • 排序

  • 查找,替换操作

  • 同步控制(不推荐,需要线程安全的集合类型时请考虑使用 JUC 包下的并发集合)

3.1 排序操作

void reverse(List list)//反转

void shuffle(List list)//随机排序

void sort(List list)//按自然排序的升序排序

void sort(List list, Comparator c)//定制排序,由Comparator控制排序逻辑

void swap(List list, int i , int j)//交换两个索引位置的元素

void rotate(List list, int distance)//旋转。当distance为正数时,将list后distance个元素整体移到前面。当distance为负数时,将 list的前distance个元素整体移到后面

3.2 查找,替换操作

int binarySearch(List list, Object key)//对List进行二分查找,返回索引,注意List必须是有序的

int max(Collection coll)//根据元素的自然顺序,返回最大的元素。 类比int min(Collection coll)

int max(Collection coll, Comparator c)//根据定制排序,返回最大元素,排序规则由Comparatator类控制。类比int min(Collection coll, Comparator c)

void fill(List list, Object obj)//用指定的元素代替指定list中的所有元素。

int frequency(Collection c, Object o)//统计元素出现次数

int indexOfSubList(List list, List target)//统计target在list中第一次出现的索引,找不到则返回-1,类比int lastIndexOfSubList(List source, list target).

boolean replaceAll(List list, Object oldVal, Object newVal), 用新元素替换旧元素