Java面试准备(二)——Java集合

Java集合框架图

img

img

Java集合类主要由两个根接口CollectionMap派生出来的,

Collection派生出了三个子接口:List、Set、Queue(Java5新增的队列),因此Java集合大致也可分成List、Set、Queue、Map四种接口体系。

一、集合概括

1. 集合理解点

  • 集合中存的是对象的引用,而不是对象本身。

    eg:把同一个对象存在两个集合中,在一个集合中改变该对象的值,另一个集合中存的这个对象值也变了

  • 集合只能存引用数据类型,不能存基本数据类型

2. 集合和数组的区别

  • 数组是固定长度的;集合可变长度的。
  • 数组可以存储基本数据类型,也可以存储引用数据类型;集合只能存储引用数据类型
  • 数组存储的元素必须是同一个数据类型;集合存储的对象可以是不同数据类型

3. List, Set, Queue, Map 四者区别?

  • List (对付顺序的好帮手): 存储的元素是有序的、可重复的。 可直接根据元素的索引来访问
  • Set (注重独一无二的性质): 存储的元素是无序的、不可重复的。只能根据元素本身来访问
  • Queue (实现排队功能的叫号机): 按特定的排队规则来确定先后顺序,存储的元素是有序的、可重复的
  • Map (用 key 来搜索的专家): 使用键值对(key-value)存储,类似于数学上的函数 y=f(x),“x” 代表 key,“y” 代表 value,key 是无序的、不可重复的,value 是无序的、可重复的,每个键最多映射到一个值

4. 集合框架底层数据结构

先来看一下 Collection 接口下面的集合(List,Set,Queue)。

List
  • ArrayListObject[] 数组
  • VectorObject[] 数组
  • LinkedList双向链表(JDK1.6 之前为循环链表,JDK1.7 取消了循环)
Set
  • HashSet(无序,唯一): 基于 HashMap 实现的,底层采用 HashMap 来保存元素
  • LinkedHashSet: LinkedHashSetHashSet 的子类,并且其内部是通过 LinkedHashMap 来实现的。有点类似于我们之前说的 LinkedHashMap 其内部是基于 HashMap 实现一样,不过还是有一点点区别的
  • TreeSet(有序,唯一): 红黑树(自平衡的排序二叉树)
Queue
  • PriorityQueue: Object[] 数组来实现二叉堆
  • ArrayQueue: Object[] 数组 + 双指针

再来看看 Map 接口下面的集合。

Map
  • HashMap

    JDK1.8 之前 HashMap数组+链表组成的,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的(“拉链法”解决冲突)。

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

  • LinkedHashMapLinkedHashMap 继承自 HashMap,所以它的底层仍然是基于拉链式散列结构即由数组和链表或红黑树组成。另外,LinkedHashMap 在上面结构的基础上,增加了一条双向链表,使得上面的结构可以保持键值对的插入顺序。同时通过对链表进行相应的操作,实现了访问顺序相关逻辑。详细可以查看:《LinkedHashMap 源码详细分析(JDK1.8)》open in new window

  • Hashtable数组+链表组成的,数组是 Hashtable 的主体,链表则是主要为了解决哈希冲突而存在的

  • TreeMap红黑树(自平衡的排序二叉树)

二、Collection 子接口之 List

List接口的三个常用实现类:ArrayList, LinkedList, Vector(子类Stack)

1. ArrayList和LinkedList的区别

  • 是否保证线程安全: ArrayListLinkedList 都是不同步的,也就是不保证线程安全
  • 底层数据结构: 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)addFirst(E e)addLast(E e)removeFirst()removeLast()),时间复杂度为 O(1),如果是要在指定位置 i 插入和删除元素的话(add(int index, E element)remove(Object o)), 时间复杂度为 O(n) ,因为需要先移动到指定位置再插入。
  • 是否支持快速随机访问: LinkedList 不支持高效的随机元素访问,而 ArrayList 支持。快速随机访问就是通过元素的序号快速获取元素对象(对应于get(int index)方法)。
  • 内存空间占用: ArrayList 的空 间浪费主要体现在在 list 列表的结尾会预留一定的容量空间,而 LinkedList 的空间花费则体现在它的每一个元素都需要消耗比 ArrayList 更多的空间(因为要存放直接后继和直接前驱以及数据)。
  • ⚠️注意:
    1. 我们在项目中一般是不会使用到 LinkedList 的,需要用到 LinkedList 的场景几乎都可以使用 ArrayList 来代替,并且,性能通常会更好!就连 LinkedList 的作者约书亚 · 布洛克(Josh Bloch)自己都说从来不会使用 LinkedList
    2. 不要下意识地认为 LinkedList 作为链表就最适合元素增删的场景。我在上面也说了,LinkedList 仅仅在头尾插入或者删除元素的时候时间复杂度近似 O(1),其他情况增删元素的时间复杂度都是 O(n)

2. ArrayList 和 Vector 的区别?

  • Vector是线程安全的,ArrayList不是线程安全的。其中,Vector在关键性的方法前面都加了synchronized关键字,来保证线程的安全性。如果有多个线程会访问到集合,那最好是使用 Vector,因为不需要我们自己再去考虑和编写线程安全的代码。
  • ArrayList在底层数组不够用时在原来的基础上扩展0.5倍,Vector是扩展1倍,这样ArrayList就有利于节约内存空间。
  • 二者底层都是用 Object[]存储

3. ArrayList源码解析

1)概述

  • ArrayList实现了List接口,是顺序容器,即元素存放的数据与放进去的顺序相同,允许放入null元素,底层通过数组实现。Java泛型只是编译器提供的语法糖,所以这里的数组是一个Object数组,以便能够容纳任何类型的对象。除该类未实现同步外,其余跟Vector大致相同。

  • 每个ArrayList都有一个容量(capacity),表示底层数组的实际大小,容器内存储元素的个数不能多于当前容量。当向容器中添加元素时,如果容量不足,容器会自动增大底层数组的大小。

  • size(), isEmpty(), get(), set()方法均能在常数时间内完成,add()方法的时间开销跟插入位置有关,addAll()方法的时间开销跟添加元素的个数成正比。其余方法大都是线性时间。

    为追求效率,ArrayList没有实现同步(synchronized),如果需要多个线程并发访问,用户可以手动同步,也可使用Vector替代

2)ArrayList类头

public class ArrayList<E> extends AbstractList<E>
        implements List<E>, RandomAccess, Cloneable, java.io.Serializable{
  • RandomAccess 是一个标志接口,表明实现这个这个接口的 List 集合是支持快速随机访问的。 原理类似于Serializable接口,在需要的逻辑中用instanceof来做专门判断处理。在 ArrayList 中,我们即可以通过元素的序号快速获取元素对象,这就是快速随机访问。
  • ArrayList 实现了 Cloneable 接口 ,即覆盖了函数clone(),能被克隆。
  • ArrayList 实现了 java.io.Serializable接口,这意味着ArrayList支持序列化,能通过序列化去传输。

3)底层数据结构和构造函数

    private static final long serialVersionUID = 8683452581122892189L;
	/**
     * 默认初始容量大小
     */
    private static final int DEFAULT_CAPACITY = 10;

    /**
     * 空数组(用于空实例)。
     */
    private static final Object[] EMPTY_ELEMENTDATA = {};

     //用于默认大小空实例的共享空数组实例。
      //我们把它从EMPTY_ELEMENTDATA数组中区分出来,以知道在添加第一个元素时容量需要增加多少。
    private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};

    /**
     * 保存ArrayList数据的数组
     */
    transient Object[] elementData; // non-private to simplify nested class access

    /**
     * ArrayList 所包含的元素个数
     */
    private int size;

    /**
     * 带初始容量参数的构造函数(用户可以在创建ArrayList对象时自己指定集合的初始大小)
     */
    public ArrayList(int initialCapacity) {
        if (initialCapacity > 0) {
            //如果传入的参数大于0,创建initialCapacity大小的数组
            this.elementData = new Object[initialCapacity];
        } else if (initialCapacity == 0) {
            //如果传入的参数等于0,创建空数组
            this.elementData = EMPTY_ELEMENTDATA;
        } else {
            //其他情况,抛出异常
            throw new IllegalArgumentException("Illegal Capacity: "+
                                               initialCapacity);
        }
    }

    /**
     *默认无参构造函数
     *DEFAULTCAPACITY_EMPTY_ELEMENTDATA 为0.初始化为10,也就是说初始其实是空数组 当添加第一个元素的时候数组容量才变成10
     */
    public ArrayList() {
        this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
    }

    /**
     * 构造一个包含指定集合的元素的列表,按照它们由集合的迭代器返回的顺序。
     */
    public ArrayList(Collection<? extends E> c) {
        //将指定集合转换为数组
        elementData = c.toArray();
        //如果elementData数组的长度不为0
        if ((size = elementData.length) != 0) {
            // 如果elementData不是Object类型数据(c.toArray可能返回的不是Object类型的数组所以加上下面的语句用于判断)
            if (elementData.getClass() != Object[].class)
                //将原来不是Object类型的elementData数组的内容,赋值给新的Object类型的elementData数组
                elementData = Arrays.copyOf(elementData, size, Object[].class);
        } else {
            // 其他情况,用空数组代替
            this.elementData = EMPTY_ELEMENTDATA;
        }
    }

ArrayList三种初始化方式

  • 以无参数构造方法创建 ArrayList 时,实际上初始化赋值的是一个空数组。当真正对数组进行添加元素操作时,才真正分配容量。即向数组中添加第一个元素时,数组容量扩为 10。

3) 自动扩容机制

  • 每当向数组中添加元素时,都要去检查添加后元素的个数是否会超出当前数组的长度,如果超出,数组将会进行扩容,以满足添加数据的需求。

  • 数组扩容通过一个公开的方法 ensureCapacity(int minCapacity) 来实现(这个方法ArrayList内部没有调用,显然是给用户调用的)。在实际添加大量元素前,我也可以使用ensureCapacity()来手动增加ArrayList实例的容量,以减少递增式再分配的数量。

  • 数组进行扩容时,会将老数组中的元素重新拷贝一份到新的数组中,每次数组容量的增长大约是其原容量的1.5倍。这种操作的代价是很高的,因此在实际使用时,我们应该尽量避免数组容量的扩张。当我们可预知要保存的元素的多少时,要在构造ArrayList实例时,就指定其容量,以避免数组扩容的发生。或者根据实际需求,通过调用ensureCapacity方法来手动增加ArrayList实例的容量。

    ( 以无参构造函数创建的ArrayList为例一步一步分析)

  • 1. add方法
        /**
         * 将指定的元素追加到此列表的末尾。
         */
        public boolean add(E e) {
       		//添加元素之前,先调用ensureCapacityInternal方法
            ensureCapacityInternal(size + 1);  // Increments modCount!!
            //这里看到ArrayList添加元素的实质就相当于为数组赋值
            elementData[size++] = e;
            return true;
        }
    

    注意这里看的是1.8( JDK11 移除了 ensureCapacityInternal() 和 ensureExplicitCapacity() 方法 )

  • 2. 再来看看 ensureCapacityInternal() 方法
    private void ensureCapacityInternal(int minCapacity) {
        ensureExplicitCapacity(calculateCapacity(elementData, minCapacity));
    }
    
    //得到最小扩容量
    private static int calculateCapacity(Object[] elementData, int minCapacity) {
        if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
            // 获取默认的容量和传入参数的较大值
            return Math.max(DEFAULT_CAPACITY, minCapacity);
        }
        return minCapacity;
    }
    
    //判断是否需要扩容
    private void ensureExplicitCapacity(int minCapacity) {
        modCount++;
    
        // overflow-conscious code
        if (minCapacity - elementData.length > 0)
            //调用grow方法进行扩容,调用此方法代表已经开始扩容了
            grow(minCapacity);
    }
    

    分析:

    • 当我们要 add 进第 1 个元素到 ArrayList 时,elementData.length 为 0 (因为还是一个空的 list),因为执行了 ensureCapacityInternal() 方法 ,所以 minCapacity 此时为 10。此时,minCapacity - elementData.length > 0成立,所以会进入 grow(minCapacity) 方法。

    • 当 add 第 2 个元素时,minCapacity 为 2,此时 e lementData.length(容量)在添加第一个元素后扩容成 10 了。此时,minCapacity - elementData.length > 0 不成立,所以不会进入 (执行)grow(minCapacity) 方法。

    • 添加第 3、4···到第 10 个元素时,依然不会执行 grow 方法,数组容量都为 10。

    • 直到添加第 11 个元素,minCapacity(为 11)比 elementData.length(为 10)要大。进入 grow 方法扩容

  • 3. grow() 方法
        /**
         * 要分配的最大数组大小
         */
        private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;
    
        /**
         * ArrayList扩容的核心方法。
         */
        private void grow(int minCapacity) {
            // oldCapacity为旧容量,newCapacity为新容量
            int oldCapacity = elementData.length;
            //将oldCapacity 右移一位,其效果相当于oldCapacity /2,
            //我们知道位运算的速度远远快于整除运算,整句运算式的结果就是将新容量更新为旧容量的1.5倍,
            int newCapacity = oldCapacity + (oldCapacity >> 1);
            //然后检查新容量是否大于最小需要容量,若还是小于最小需要容量,那么就把最小需要容量当作数组的新容量,
            if (newCapacity - minCapacity < 0)
                newCapacity = minCapacity;
           // 如果新容量大于 MAX_ARRAY_SIZE,进入(执行) `hugeCapacity()` 方法来比较 minCapacity 和 MAX_ARRAY_SIZE,
           //如果minCapacity大于最大容量,则新容量则为`Integer.MAX_VALUE`,否则,新容量大小则为 MAX_ARRAY_SIZE 即为 `Integer.MAX_VALUE - 8`。
            if (newCapacity - MAX_ARRAY_SIZE > 0)
                newCapacity = hugeCapacity(minCapacity);
            // minCapacity is usually close to size, so this is a win:
            elementData = Arrays.copyOf(elementData, newCapacity);
        }
    
    • int newCapacity = oldCapacity + (oldCapacity >> 1),所以 ArrayList 每次扩容之后容量都会变为原来的 1.5 倍左右(oldCapacity 为偶数就是 1.5 倍,否则是 1.5 倍左右)! 奇偶不同,比如 :10+10/2 = 15, 33+33/2=49。如果是奇数的话会丢掉小数.

    • “>>”(移位运算符):>>1 右移一位相当于除 2,右移 n 位相当于除以 2 的 n 次方。这里 oldCapacity 明显右移了 1 位所以相当于 oldCapacity /2。对于大数据的 2 进制运算,位移运算符比那些普通运算符的运算要快很多, 因为程序仅仅移动一下而已,不去计算,这样提高了效率,节省了资源

    • 分析:

      • 当 add 第 1 个元素时,oldCapacity 为 0,经比较后第一个 if 判断成立,newCapacity = minCapacity(为 10)。但是第二个 if 判断不会成立,即 newCapacity 不比 MAX_ARRAY_SIZE 大,则不会进入 hugeCapacity 方法。数组容量为 10,add 方法中 return true,size 增为 1。
      • 当 add 第 11 个元素进入 grow 方法时,newCapacity 为 15,比 minCapacity(为 11)大,第一个 if 判断不成立。新容量没有大于数组最大 size,不会进入 hugeCapacity 方法。数组容量扩为 15,add 方法中 return true,size 增为 11。
    • ⚠️一个区分点注意:

      • java 中的 length属性是针对数组说的,比如说你声明了一个数组,想知道这个数组的长度则用到了 length 这个属性.
      • java 中的 length() 方法是针对字符串说的,如果想看这个字符串的长度则用到 length() 这个方法.
      • java 中的 size() 方法是针对泛型集合说的,如果想看这个泛型有多少个元素,就调用此方法来查看!
  • 4. hugeCapacity() 方法
    private static int hugeCapacity(int minCapacity) {
        if (minCapacity < 0) // overflow
            throw new OutOfMemoryError();
        //对minCapacity和MAX_ARRAY_SIZE进行比较
        //若minCapacity大,将Integer.MAX_VALUE作为新数组的大小
        //若MAX_ARRAY_SIZE大,将MAX_ARRAY_SIZE作为新数组的大小
        //MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;
        return (minCapacity > MAX_ARRAY_SIZE) ?
            Integer.MAX_VALUE :
        MAX_ARRAY_SIZE;
    }
    

    【至此,一次add()的扩容机制即完成

  • 5. ensureCapacity方法

    ArrayList 源码中有一个 ensureCapacity 方法,这个方法 ArrayList 内部没有被调用过,所以很显然是提供给用户调用的, 向 ArrayList 添加大量元素之前使用ensureCapacity 方法可以提升性能

    /**
      如有必要,增加此 ArrayList 实例的容量,以确保它至少可以容纳由minimum capacity参数指定的元素数。
    *
    * @param   minCapacity   所需的最小容量
    */
    public void ensureCapacity(int minCapacity) {
        int minExpand = (elementData != DEFAULTCAPACITY_EMPTY_ELEMENTDATA)
            // any size if not default element table
            ? 0
            // larger than default for default empty table. It's already
            // supposed to be at default size.
            : DEFAULT_CAPACITY;
    
        if (minCapacity > minExpand) {
            ensureExplicitCapacity(minCapacity);
        }
    }
    
    ArrayList_grow

4. LinkedList

1)概述

  • LinkedList同时实现了List接口和Deque接口,也就是说它既可以看作一个顺序容器,又可以看作一个队列(Queue),同时又可以看作一个栈(Stack)。这样看来,LinkedList简直就是个全能冠军。当你需要使用栈或者队列时,可以考虑使用LinkedList,一方面是因为Java官方已经声明不建议使用Stack类,更遗憾的是,Java里根本没有一个叫做Queue的类(它是个接口名字)。

  • 关于栈或队列,现在的首选是ArrayDeque,它有着比LinkedList(当作栈或队列使用时)有着更好的性能。

  • LinkedList的实现方式决定了所有跟下标相关的操作都是线性时间,而在首段或者末尾删除元素只需要常数时间。为追求效率LinkedList没有实现同步(synchronized),如果需要多个线程并发访问,可以先采用Collections.synchronizedList()方法对其进行包装。

    LinkedList_base

2)底层数据结构和构造函数

  • LinkedList底层通过双向链表实现,双向链表的每个节点用内部类Node表示。LinkedList通过**firstlast引用分别指向链表的第一个和最后一个元素**。当链表为空的时候firstlast都指向null

    public class LinkedList<E>
        extends AbstractSequentialList<E>
        implements List<E>, Deque<E>, Cloneable, java.io.Serializable
    {
        transient int size = 0;
    
    
        transient Node<E> first;
    
        transient Node<E> last;
    
        /**
         * Constructs an empty list.
         */
        public LinkedList() {
        }
    
        /**
         * Constructs a list containing the elements of the specified
         * collection, in the order they are returned by the collection's
         * iterator.
         *
         * @param  c the collection whose elements are to be placed into this list
         * @throws NullPointerException if the specified collection is null
         */
        public LinkedList(Collection<? extends E> c) {
            this();
            addAll(c);
        }
    

    其中Node是内部私有类

    private static class Node<E> {
        E item;
        Node<E> next;
        Node<E> prev;
    
        Node(Node<E> prev, E element, Node<E> next) {
            this.item = element;
            this.next = next;
            this.prev = prev;
        }
    }
    

3)常见方法

  • public E getFirst() :返回此列表的第一个元素。

  • public E getLast() :返回此列表的最后一个元素。

  • removeFirst(), removeLast(), remove(e), remove(index)

    • 删除元素remove(e) - 指的是删除第一次出现的这个元素, 如果没有这个元素,则返回false;判断的依据是equals方法, 如果equals,则直接unlink这个node;由于LinkedList可存放null元素,故也可以删除第一次出现null的元素;
    • remove(int index)用的是下标计数, 只需要判断该index是否有元素,如果有则直接unlink这个node
  • add()方法有两个版本,

    • 一个是add(E e),该方法在 LinkedList 的末尾插入元素,因为有last指向链表末尾,在末尾插入元素的花费是常数时间。只需要简单修改几个相关引用即可;
    • 另一个是add(int index, E element),该方法是在指定下表处插入元素,需要先通过线性查找找到具体位置,然后修改相关引用完成插入操作。
  • addAll(index, c) 实现方式并不是直接调用add(index,e)来实现

  • clear():为了让GC更快可以回收放置的元素,需要将node之间的引用关系赋空。

  • public E pop() :从此列表所表示的堆栈处弹出一个元素。
    public void push(E e) :将元素推入此列表所表示的堆栈。

三、Collection 子接口之 Queue

1. Queue 与 Deque 的区别

  • Queue单端队列,只能从一端插入元素,另一端删除元素,实现上一般遵循 先进先出(FIFO) 规则。

  • Queue 扩展了 Collection 的接口,根据 因为容量问题而导致操作失败后处理方式的不同 可以分为两类方法: 一种在操作失败后会抛出异常,另一种则会返回特殊值。

    Queue 接口抛出异常返回特殊值
    插入队尾add(E e)offer(E e)
    删除队首remove()poll()
    查询队首元素element()peek()
  • Deque双端队列,在队列的两端均可以插入或删除元素。

  • Deque 扩展了 Queue 的接口, 增加了在队首和队尾进行插入和删除的方法,同样根据失败后处理方式的不同分为两类

    Deque 接口抛出异常返回特殊值
    插入队首addFirst(E e)offerFirst(E e)
    插入队尾addLast(E e)offerLast(E e)
    删除队首removeFirst()pollFirst()
    删除队尾removeLast()pollLast()
    查询队首元素getFirst()peekFirst()
    查询队尾元素getLast()peekLast()

    事实上,Deque 还提供有 push()pop() 等其他方法,可用于模拟栈

2. ArrayDeque 与 LinkedList 的区别

ArrayDequeLinkedList 都实现了 Deque 接口,两者都具有队列的功能,但两者有什么区别呢?

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

从性能的角度上,选用 ArrayDeque 来实现队列要比 LinkedList 更好。此外,ArrayDeque 也可以用于实现栈

3. 说一说 PriorityQueue

PriorityQueueQueue 的区别在于元素出队顺序是与优先级相关的,即总是优先级最高的元素先出队。是在 JDK1.5 中被引入的

  • PriorityQueue 利用了二叉堆的数据结构来实现的,底层使用可变长的数组来存储数据
  • PriorityQueue 通过堆元素的上浮和下沉,实现了在 O(logn) 的时间复杂度内插入元素和删除堆顶元素。
  • PriorityQueue非线程安全的,且不支持存储 NULLnon-comparable 的对象。
  • PriorityQueue 默认是小顶堆,但可以接收一个 Comparator 作为构造参数,从而来自定义元素优先级

PriorityQueue 在面试中可能更多的会出现在手撕算法的时候,包括堆排序、求第K大的数、带权图的遍历等。

四、Collection 子接口之 Set

  • Set接口的常用集合类:HashSet,TreeSet, LinkedHashSet

  • Set集合底层实现的是Map集合,其中Set实现的是Map中的Key值。所以Set集合中的元素是不允许重复的,同时也是只能有一个null值。

1. HashSet

  • HashSet是对HashMap的简单包装** (适配器模式),对HashSet的函数调用都会转换成合适的HashMap方法。

    //HashSet是对HashMap的简单包装
    public class HashSet<E>
        extends AbstractSet<E>
        implements Set<E>, Cloneable, java.io.Serializable
    {
    	......
    	private transient HashMap<E,Object> map;//HashSet里面有一个HashMap
        // Dummy value to associate with an Object in the backing Map
        private static final Object PRESENT = new Object();
        
        public HashSet() {
            map = new HashMap<>();
        }
        ......
        public boolean add(E e) {//简单的方法转换
            return map.put(e, PRESENT)==null;
        }
        ......
    }
    
  • HashMap 和 HashSet 区别

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

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

    • 在 JDK1.8 中,HashSetadd()方法只是简单的调用了HashMapput()方法,并且判断了一下返回值以确保是否有重复元素。
    • 在 JDK1.8 中,实际上无论HashSet中是否已经存在了某元素,HashSet都会直接插入,只是会在add()方法的返回值处告诉我们插入前是否存在相同元素。 返回true即代表没有重复值,false有重复值。

2. TreeSet

  • TreeSet是对TreeMap的简单包装,对TreeSet的函数调用都会转换成合适的TreeMap方法

    // TreeSet是对TreeMap的简单包装
    public class TreeSet<E> extends AbstractSet<E>
        implements NavigableSet<E>, Cloneable, java.io.Serializable
    {
    	......
        private transient NavigableMap<E,Object> m;
        // Dummy value to associate with an Object in the backing Map
        private static final Object PRESENT = new Object();
        
        public TreeSet() {
            this.m = new TreeMap<E,Object>();// TreeSet里面有一个TreeMap
        }
        ......
        public boolean add(E e) {
            return m.put(e, PRESENT)==null;
        }
        ......
    }
    
    

3. LinkedHashSet

  • LinkedHashSet里面有一个LinkedHashMap(适配器模式) , 对LinkedHashSet的函数调用都会转换成合适的LinkedHashMap方法

    public class LinkedHashSet<E>
        extends HashSet<E>
        implements Set<E>, Cloneable, java.io.Serializable {
        ......
        // LinkedHashSet里面有一个LinkedHashMap
        public LinkedHashSet(int initialCapacity, float loadFactor) {
            map = new LinkedHashMap<>(initialCapacity, loadFactor);
        }
    	......
        public boolean add(E e) {//简单的方法转换
            return map.put(e, PRESENT)==null;
        }
        ......
    }
    

4. 比较 HashSet、LinkedHashSet 和 TreeSet 三者的异同

  • HashSetLinkedHashSetTreeSet 都是 Set 接口的实现类,都能保证元素唯一,并且都不是线程安全的
  • HashSetLinkedHashSetTreeSet 的主要区别在于底层数据结构不同
    • HashSet 的底层数据结构是哈希表(基于 HashMap 实现)。
    • LinkedHashSet 的底层数据结构是链表和哈希表,元素的插入和取出顺序满足 FIFO。
    • TreeSet 底层数据结构是红黑树,元素是有序的,排序的方式有自然排序和定制排序。
  • 底层数据结构不同又导致这三者的应用场景不同
    • HashSet 用于不需要保证元素插入和取出顺序的场景,
    • LinkedHashSet 用于保证元素的插入和取出顺序满足 FIFO 的场景,
    • TreeSet 用于支持对元素自定义排序规则的场景

五、Map接口

1. HashMap

1)概述

  • HashMap实现了Map接口,即允许放入keynull的元素,也允许插入valuenull的元素;

  • HashMap和Hashtable,TreeMap的区别:
    • HashMap未实现同步外,其余跟Hashtable大致相同;
    • TreeMap不同,HashMap不保证元素顺序,根据需要HashMap可能会对元素重新哈希,元素的顺序也会被重新打散,因此不同时间迭代同一个HashMap的顺序可能会不同。 根据对冲突的处理方式不同,哈希表有两种实现方式,一种开放地址方式(Open addressing),另一种是冲突链表方式(Separate chaining with linked lists)。Java7 HashMap采用的是冲突链表方式

2)底层实现

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

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

  • Java8 对 HashMap 进行了一些修改,最大的不同就是利用了红黑树,所以其由 数组+链表+红黑树 组成。

    根据 Java7 HashMap 的介绍,查找的时候,根据 hash 值我们能够快速定位到数组的具体下标,但是之后的话,需要顺着链表一个个比较下去才能找到我们需要的,时间复杂度取决于链表的长度,为 O(n)。

    为了降低这部分的开销,在 Java8 中,当链表中的元素达到了 8 个时,会将链表转换为红黑树,在这些位置进行查找的时候可以降低时间复杂度为 O(logN)。

    img

    (以下的源码主要看java8的实现)

3)put方法

  • Java8 先插值再扩容

  • resize() 方法用于初始化数组或数组扩容,每次扩容后,容量为原来的 2 倍,并进行数据迁移
    public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
    }
    
    // 第四个参数 onlyIfAbsent 如果是 true,那么只有在不存在该 key 时才会进行 put 操作
    // 第五个参数 evict 我们这里不关心
    // 返回值:如果插入位置没有元素返回null,否则返回上一个元素
    final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        // 第一次 put 值的时候,会触发下面的 resize(),类似 java7 的第一次 put 也要初始化数组长度
        // 第一次 resize 和后续的扩容有些不一样,因为这次是数组从 null 初始化到默认的 16 或自定义的初始容量
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        // 找到具体的数组下标,如果此位置没有值,那么直接初始化一下 Node 并放置在这个位置就可以了
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
    
        else {// 数组该位置有数据
            Node<K,V> e; K k;
            // 首先,判断该位置的第一个数据和我们要插入的数据,key 是不是"相等",如果是,取出这个节点
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            // 如果该节点是代表红黑树的节点,调用红黑树的插值方法
            else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else {
                // 到这里,说明数组该位置上是一个链表
                for (int binCount = 0; ; ++binCount) {
                    // 插入到链表的最后面(Java7 是插入到链表的最前面)
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                        // TREEIFY_THRESHOLD 为 8,所以,如果新插入的值是链表中的第 8 个
                        // 会触发下面的 treeifyBin,也就是将链表转换为红黑树
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash);
                        break;
                    }
                    // 如果在该链表中找到了"相等"的 key(== 或 equals)
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        // 此时 break,那么 e 为链表中[与要插入的新值的 key "相等"]的 node
                        break;
                    p = e;
                }
            }
            // e!=null 说明存在旧值的key与要插入的key"相等"
            // 对于我们分析的put操作,下面这个 if 其实就是进行 "值覆盖",然后返回旧值
            if (e != null) {
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;
        // 如果 HashMap 由于新插入这个值导致 size 已经超过了阈值,需要进行扩容
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }
    

4)get方法

  • 计算 key 的 hash 值,根据 hash 值找到对应数组下标: hash & (length-1)

  • 判断数组该位置处的元素是否刚好就是我们要找的,如果不是,走第三步

  • 判断该元素类型是否是 TreeNode,如果是,用红黑树的方法取数据,如果不是,走第四步

  • 遍历链表,直到找到相等(==或equals)的 key

    public V get(Object key) {
        Node<K,V> e;
        return (e = getNode(hash(key), key)) == null ? null : e.value;
    }
    final Node<K,V> getNode(int hash, Object key) {
        Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (first = tab[(n - 1) & hash]) != null) {
            // 判断第一个节点是不是就是需要的
            if (first.hash == hash && // always check first node
                ((k = first.key) == key || (key != null && key.equals(k))))
                return first;
            if ((e = first.next) != null) {
                // 判断是否是红黑树
                if (first instanceof TreeNode)
                    return ((TreeNode<K,V>)first).getTreeNode(hash, key);
    
                // 链表遍历
                do {
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        return e;
                } while ((e = e.next) != null);
            }
        }
        return null;
    }
    

5)HashMap的七种遍历方式

  • 四大类:1Iterator方式 2foreach方式 3lambda表达式 4stream api方式
  1. 使用迭代器(Iterator)EntrySet 的方式进行遍历;

  2. 使用迭代器(Iterator)KeySet 的方式进行遍历;

  3. 使用 For Each EntrySet 的方式进行遍历;

  4. 使用 For Each KeySet 的方式进行遍历;

  5. 使用 Lambda 表达式的方式进行遍历;

  6. 使用 Streams API 单线程的方式进行遍历;

  7. 使用 Streams API 多线程的方式进行遍历。

    public class HashMapTest {
        public static void main(String[] args) {
            // 创建并赋值 HashMap
            Map<Integer, String> mapData = new HashMap();
            mapData.put(1, "Java");
            mapData.put(2, "JDK");
            mapData.put(3, "Spring Framework");
            mapData.put(4, "MyBatis framework");
            mapData.put(5, "Java中文社群");
            
            // 1.使用迭代器(Iterator)EntrySet 遍历
            Iterator<Map.Entry<Integer, String>> iterator = mapData.entrySet().iterator();
            while (iterator.hasNext()) {
                Map.Entry<Integer, String> entry = iterator.next();
                System.out.println(entry.getKey());
                System.out.println(entry.getValue());
            }
            
            //2.使用迭代器(Iterator)KeySet 遍历
            Iterator<Integer> iterator = mapData.keySet().iterator();
            while (iterator.hasNext()) {
                Integer key = iterator.next();
                System.out.println(key);
                System.out.println(mapData.get(key));
            }
            
            //3.使用 For Each EntrySet 遍历
            for (Map.Entry<Integer, String> entry : mapData.entrySet()) {
                System.out.println(entry.getKey());
                System.out.println(entry.getValue());
            }
            
            //4.使用 For Each KeySet 遍历
            for (Integer key : mapData.keySet()) {
                System.out.println(key);
                System.out.println(map.get(key));
            }
            
            //5.用 Lambda 表达式的方式进行遍历
            mapData.forEach((key, value) -> {
                System.out.println(key);
                System.out.println(value);
            });
            
            //6.Streams API 单线程
            mapData.entrySet().stream().forEach((entry) -> {
                System.out.println(entry.getKey());
                System.out.println(entry.getValue());
            });
            
            //7.Streams API 多线程
            mapData.entrySet().parallelStream().forEach((entry) -> {
                System.out.println(entry.getKey());
                System.out.println(entry.getValue());
            });
        }
    }
    
理解 mapData.entrySet():/ keySet()

面试题:HashMap中entrySet()源码+思想详解_ZJH’blog的博客-CSDN博客_entryset源码分析

  • 这个方法返回一个Set对象,看起来返回的是一个包含了Map里面所有键值对的一个集合对象,但是这个理解不对。通过这个Set对象,我们确实可以获取到Map里面存放的所有键值对。但是这个集合对象本身是不存放数据的, entrySet()表面上获取了一个set对象,实际上这个set对象是空的, 它只是助于我们遍历Map中的数据,类似于Iterator。 几乎所有的方法都是先直接获取迭代器入口,节约内存的同时性能大幅提升

  • 那么entrySet对象调用方法时为何有值?

    因为 EntrySet类重写的iterator()方法可以使得指针正确得指向下一个节点

  • entrySet()和keySet()都是懒汉式,调用方法,new对象的时候并没有对其进行赋值,并且其size()方法也是一个虚假的size,EntrySet类和KeySet类也都没有构造器能进行初始化

  • 而是在使用hashMap.entrySet().toString()、hashMap.entrySet(). toArray() 等方法的时候才调用iterator()来获取迭代器

2. HashMap 和 Hashtable 的区别

  • 线程是否安全:

    • 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 的幂作为哈希表的大小。
  • 底层数据结构: JDK1.8 以后的 HashMap 在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)时,将链表转化为红黑树(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树),以减少搜索时间(后文中我会结合源码对这一过程进行分析)。Hashtable 没有这样的机制。

3. HashMap 和 TreeMap 区别

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

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

  • 实现SortedMap接口让 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对象,在定义的时候指定Comparator按age字段排序
            //法一:通过传入匿名内部类的方式实现
            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);
                }
            });
            
            //法二:lambda方式实现排序
            TreeMap<Person, String> treeMap = new TreeMap<>((person1, 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());
            });
        }
    }
    

4. ConcurrentHashMap 和 Hashtable 的区别

ConcurrentHashMapHashtable 的区别主要体现在实现线程安全的方式上不同

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

5. ConcurrentHashMap 线程安全的具体实现

  1. Jdk8之前:

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

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

    • 一个 ConcurrentHashMap 里包含一个 Segment 数组Segment 的个数一旦初始化就不能改变Segment 数组的大小默认是 16,也就是说默认可以同时支持 16 个线程并发写。

    • 一个 Segment 包含一个 HashEntry 数组,每个 HashEntry 是一个链表结构的元素,每个 Segment 守护着一个 HashEntry 数组里的元素,当对 HashEntry 数组的数据进行修改时,必须首先获得对应的 Segment 的锁。也就是说,对同一 Segment 的并发写入会被阻塞,不同 Segment 的写入是可以并发执行的。

      Java7 ConcurrentHashMap 存储结构
  2. Java 8 几乎完全重写了 ConcurrentHashMap,代码量从原来 Java 7 中的 1000 多行,变成了 6000 多行。

    ConcurrentHashMap 取消了 Segment 分段锁,采用 Node + CAS + synchronized 来保证并发安全

    数据结构跟 HashMap 1.8 的结构类似,数组+链表/红黑二叉树。

    Java 8 中,锁粒度更细,synchronized 只锁定当前链表或红黑二叉树的首节点,这样只要 hash 不冲突,就不会产生并发,就不会影响其他 Node 的读写,效率大幅提升

    Java8 ConcurrentHashMap 存储结构

六、Iterator迭代器

  • 经常需要遍历集合中的所有元素。针对这种需求,JDK专门提供了一个接口java.util.Iterator

  • Iterator接口也是Java集合中的一员,但它与CollectionMap接口有所不同,Collection接口与Map接口主要用于存储元素,而**Iterator主要用于迭代访问(即遍历)Collection中的元素**,因此Iterator对象也被称为迭代器。

  • Iterator迭代器对象在遍历集合时,内部采用指针的方式来跟踪集合中的元素

  • 想要遍历Collection集合,那么就要获取该集合迭代器完成迭代操作,下面介绍一下获取迭代器的方法

    public Iterator iterator(); 	// 获取集合对应的迭代器,用来遍历集合中的元素的。
    
  • Iterator接口的常用方法如下:

    • public E next():返回迭代的下一个元素。
    • public boolean hasNext():如果仍有元素可以迭代,则返回 true。
    public class IteratorDemo {
      	public static void main(String[] args) {
    
            Collection<String> coll = new ArrayList<String>();
    
            coll.add("串串星人");
            coll.add("吐槽星人");
            coll.add("汪星人");
    
            //1.通过coll.iterator()获取集合的迭代器
            Iterator<String> it = coll.iterator();
            //2.使用迭代器进行迭代
            while(it.hasNext()){ //判断是否有迭代元素
                String s = it.next();//获取迭代出的元素
                System.out.println(s);
            }
      	}
    }
    
    //注意:如果集合中已经没有元素了,还继续使用迭代器的next方法,将会发生java.util.NoSuchElementException没有集合元素的错误。
    
    • for each循环是JDK1.5以后出来的一个高级for循环,专门用来遍历数组和集合的。它的内部原理其实是个Iterator迭代器,所以在遍历的过程中,不能对集合中的元素进行增删操作。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值