JavaSE面试要点三——Java容器:集合

什么是容器

    在Java当中,如果有一个类专门用来存放其它类的对象,这个类就叫做容器,或者就叫做集合,集合就是将若干性质相同或相近的类对象组合在一起而形成的一个整体。

集合的分类

在这里插入图片描述
在这里插入图片描述

容器与数组的关系

之所以需要容器,是因为:

  1. 数组的长度难以扩充
  2. 数组中数据的类型必须相同

容器与数组的区别与联系:

  1. 容器不是数组,不能通过下标的方式访问容器中的元素
  2. 数组的所有功能通过Arraylist容器都可以实现,只是实现的方式不同
  3. 如果非要将容器当做一个数组来使用,通过toArray方法返回的就是一个数组

Iterable接口

    Java所有集合类的基本接口是Collection接口。而Collection接口继承java.lang.Iterable接口。它是Java中的迭代器,是能够对List这样的集合进行迭代遍历的底层依赖。而Iterable接口里定义了返回Iterator的方法,相当于对Iterator的封装,同时实现了Iterable接口的类还可以支持for each循环。

Iterator接口

    Iterator主要用于遍历(迭代访问)Collection集合中的元素,Iterator对象也被称为迭代器。

List<Integer> list=new ArrayList<>();
     list.add(5);
     list.add(23);
     list.add(42);
Iterator it=list.iterator();        //迭代器遍历
while(it.hasNext()){
	System.out.print(it.next()+",");
}     

    Iterator是遍历集合类的标准访问方法。它可以把访问逻辑从不同类型的集合类中抽象出来,从而避免向客户端暴露集合的内部结构
源码:

public interface Iterator<E> {
	//如果有下一个元素,则返回true
    boolean hasNext();
	//获取下一个元素
    E next();
	//删除迭代器上次返回的元素,默认不支持
    default void remove() {
        throw new UnsupportedOperationException("remove");
    }
	//对剩下的元素执行给定消费器的accept方法
    default void forEachRemaining(Consumer<? super E> action) {
        Objects.requireNonNull(action);
        while (hasNext())
            action.accept(next());
    }
}

    当使用Iterator对集合元素进行迭代时,Iterator并不是把集合元素本身传给迭代变量,而是把集合元素的值传给了迭代变量,所以 修改迭代变量的值对集合元素本身没有任何影响。 故当使用Iterator迭代访问Collection集合元素时,Collection集合里的元素不能被改变。但是可以通过Iterator的remove()方法删除上一个next()方法返回的集合元素。Iterator模式总是用同一种逻辑来遍历集合:

    客户端自身不维护遍历集合的"指针",所有的内部状态(如当前元素位置,是否有下一个元素)都是Iterator来维护,而这个Iterator由集合类通过工厂方法生成,因此,它知道如何遍历整个集合。这样客户端就不用直接和集合类打交道,只要控制Iterator,向它放松"向前",“向后”,"取当前元素"的命令,就可以遍历整个集合。

为什么一定要去实现Iterable这个接口呢?为什么不直接实现Iterator接口呢?

    看一下JDK中的集合类,比如List一族或者Set一族,都是继承了Iterable接口,但并不直接继承Iterator接口。因为Iterator接口的核心方法next()或者hasNext()是依赖于迭代器的当前迭代位置的。如果Collection直接继承Iterator接口,势必导致集合对象中包含当前迭代位置的数据(指针)。当集合在不同方法间被传递时,由于当前迭代位置不可预置,那么next()方法的结果会变成不可预知。除非再为Iterator接口添加一个reset()方法,用来重置当前迭代位置。但即使这样,Collection也只能同时存在一个当前迭代位置。而Iterable则不然,每次调用都会返回一个从头开始计数的迭代器。多个迭代器时互不干扰的。

Iterable

    Iterable接口只有一个方法(默认方法除外),就是iterator()方法,返回集合的Iterator对象所有实现Iterable接口的集合还可以使用foreach循环进行遍历。
源码:

public interface Iterable<T>{
	Iterator<T> iterator();
}

Iterator与Iterable的区别

    Iterator接口是提供了一种统一的遍历集合元素的方式。使用Iterator对象可以不同关心具体集合对象的具体类型和内部实现,统一使用Iterator对象的接口方法就可以遍历集合。

而Iterable接口里不仅定义了返回Iterator的方法,相当于对Iterator的封装,同时实现了Iterable接口的类可以支持for each循环。

List列表

    List接口Collection接口的一个子接口,List集合类中元素有序、且可重复,集合中的每个元素都有其对应的顺序索引
    List的实现类有ArrayList、LinkedList、Vector

Vector

    Vactor是基于数组实现,同ArrayList相比Vactor的线程相对安全,他对自己的所有方法都加上了synchronized安全锁,所有效率要远低于ArrayList。还有在扩容上,ArrayList是扩容自己的1.5倍,而Vector会扩容到自己的两倍。

源码分析:

package java.util;

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

    //定义数组,存放元素
    protected Object[] elementData;

    //已经放入数组的元素数量
    protected int elementCount;

    //增长的系数
    protected int capacityIncrement;

    //可序列化版本号
    private static final long serialVersionUID = -2767605614048989439L;

    //构造方法,提供初始大小,和增长系数
    public Vector(int initialCapacity, int capacityIncrement) {
        super();
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal Capacity: "+
                                               initialCapacity);
        this.elementData = new Object[initialCapacity];
        this.capacityIncrement = capacityIncrement;
    }

    //构造方法,提供初始大小,增长系数为零
    public Vector(int initialCapacity) {
        this(initialCapacity, 0);
    }

    //无参构造方法
    public Vector() {
        this(10);
    }

    //构造方法,将指定的集合元素转化为Vector
    public Vector(Collection<? extends E> c) {
        elementData = c.toArray();
        elementCount = elementData.length;
        // c.toArray might (incorrectly) not return Object[] (see 6260652)
        //判断c.toArray是否是Object[]类型
        if (elementData.getClass() != Object[].class)
            elementData = Arrays.copyOf(elementData, elementCount, Object[].class);
    }

    //将elementData中的元素全部拷贝到anArray数组中
    public synchronized void copyInto(Object[] anArray) {
        System.arraycopy(elementData, 0, anArray, 0, elementCount);
    }

    //将数组长度设置为等于vector的个数
    public synchronized void trimToSize() {
        modCount++;
        int oldCapacity = elementData.length;
        if (elementCount < oldCapacity) {
            elementData = Arrays.copyOf(elementData, elementCount);
        }
    }

    //扩充容量
    public synchronized void ensureCapacity(int minCapacity) {
        if (minCapacity > 0) {
            modCount++;
            ensureCapacityHelper(minCapacity);
        }
    }

    //扩充容量帮助函数
    private void ensureCapacityHelper(int minCapacity) {
        // overflow-conscious code
        if (minCapacity - elementData.length > 0)
            grow(minCapacity);
    }

    //最大容量
    private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;
    //扩充容量执行方法
    private void grow(int minCapacity) {
        // overflow-conscious code
        int oldCapacity = elementData.length;
        //根据capacityIncrement进行判断,capacityIncrement> 0 增加capacityIncrement个容量,否则容量扩充当前容量的一倍
        int newCapacity = oldCapacity + ((capacityIncrement > 0) ?
                                         capacityIncrement : oldCapacity);
        if (newCapacity - minCapacity < 0)
            newCapacity = minCapacity;
        if (newCapacity - MAX_ARRAY_SIZE > 0)
            newCapacity = hugeCapacity(minCapacity);
        //扩容操作,生成已给新的数组,容量为newCapacity,并将elementData中的元素全部拷贝到新数组中,并将新生成的数组在赋值给elementData 
        elementData = Arrays.copyOf(elementData, newCapacity);
    }
  }

为什么尽量少使用Vector

    Vector和ArrayList的底层实现方式非常的相近,官方说少使用Vector,还是尽量少使用,通过源码可以看出,每个方法都添加了synchronized关键字来保证同步,所以它是线程安全的,但是正是这些方法的同步,让其效率大大的降低了。比ArrayList的效率要慢。

Vector的扩容机制

    当使用的是无参构造函数创建Vector对象时,默认会初始化容量为10,每次扩容,容量 = 原容量 × 2。

Vector vector = new Vector();

    当使用的是一个参数的有参构造函数创建Vector对象时,初始化容量则为指定的长度,每次扩容,容量 = 原容量 × 2

Vector vector = new Vector(8);

    当使用的是两个参数的有参构造函数创建Vector对象时,初始化容量则为指定的长度,每次扩容,容量 = 原容量 + 指定的扩容长度

Vector vector = new Vector(8,2);

ArrayList

    ArrayList是实现List的可扩容数组(动态数组)继承自 AbstractList,底层基于数组实现容量大小动态变化。

/**
* The size of the ArrayList (the number of elements it contains).
*/
private int size;  // 实际元素个数
transient Object[] elementData; 
//上面的 size 是指 elementData 中实际有多少个元素,
// 而 elementData.length 为集合容量,表示最多可以容纳多少个元素。

private static final int DEFAULT_CAPACITY = 10;//默认初始容量大小为 10

     允许 null 的存在。 同时还实现了 RandomAccess、Cloneable、Serializable 接口,所以ArrayList 是支持快速访问、复制、序列化的。查询快,增删慢,线程不安全,效率高。

ArrayList的遍历操作

ArrayList有三种(四种)遍历方式,分别是:

  1. for循环遍历
  2. 增强for循环(foreach)
  3. Iterator迭代器(for、where)

第一种: 因为ArrayList底层是数组,实现了RandomAccess,所以可以通过索引访问到具体的元素。

for (int i = 0; i < list.size(); i++){
     value = list.get(i);
}

第二种: foreach 增强for循环

for (Integer integer : list){
     value = integer;
}

第三种: Iterator迭代器,迭代器是一种模式,它可以使得对于序列类型的数据结构的遍历行为与被遍历的对象分离,即我们无需关心该序列的底层结构是什么样子的。只要拿到这个对象,使用迭代器就可以遍历这个对象的内部。迭代器可以通过for和while来遍历

for (Iterator iterator = list.iterator(); iterator.hasNext();){
     value = iterator.next();
}
Iterator it1 = list.iterator();
       while(it1.hasNext()){
           System.out.println(it1.next());
       }

为什么ArrayList要实现RandomAccess接口

先看看RandomAccess源码的介绍:

/**
 * Marker interface used by <tt>List</tt> implementations to indicate that
 * they support fast (generally constant time) random access.  The primary
 * purpose of this interface is to allow generic algorithms to alter their
 * behavior to provide good performance when applied to either random or
 * sequential access lists.
 *
 * <p>The best algorithms for manipulating random access lists (such as
 * <tt>ArrayList</tt>) can produce quadratic behavior when applied to
 * sequential access lists (such as <tt>LinkedList</tt>).  Generic list
 * algorithms are encouraged to check whether the given list is an
 * <tt>instanceof</tt> this interface before applying an algorithm that would
 * provide poor performance if it were applied to a sequential access list,
 * and to alter their behavior if necessary to guarantee acceptable
 * performance.
 *
 * <p>It is recognized that the distinction between random and sequential
 * access is often fuzzy.  For example, some <tt>List</tt> implementations
 * provide asymptotically linear access times if they get huge, but constant
 * access times in practice.  Such a <tt>List</tt> implementation
 * should generally implement this interface.  As a rule of thumb, a
 * <tt>List</tt> implementation should implement this interface if,
 * for typical instances of the class, this loop:
 * <pre>
 *     for (int i=0, n=list.size(); i &lt; n; i++)
 *         list.get(i);
 * </pre>
 * runs faster than this loop:
 * <pre>
 *     for (Iterator i=list.iterator(); i.hasNext(); )
 *         i.next();
 * </pre>
 *
 * <p>This interface is a member of the
 * <a href="{@docRoot}/../technotes/guides/collections/index.html">
 * Java Collections Framework</a>.
 *
 * @since 1.4
 */
 public interface RandomAccess {
}

     大致的意思就是:该接口的主要目的是在随机访问或顺序访问列表时提供较好的性能,也就是说这个接口只是一个标记类,实现该接口的类在随机访问或顺序访问列表时性能较高。

    实现RandomAccess接口的List可以通过for循环来遍历数据比使用iterator遍历数据更高效,未实现RandomAccess接口的List可以通过iterator遍历数据比使用for循环来遍历数据更高效。表明ArrayList支持随机访问

这个在官服源码中也有应用,比如Collections(包裹类)的二分搜索法

public static <T>
    int binarySearch(List<? extends Comparable<? super T>> list, T key) {
        if (list instanceof RandomAccess || list.size()<BINARYSEARCH_THRESHOLD)
            return Collections.indexedBinarySearch(list, key);
        else
            return Collections.iteratorBinarySearch(list, key);
    }

在源码中可以看出,判断list是否是RandomAccess的实例,如果是,则执行indexedBinarySearch方法,如果不是,则执行iteratorBinarySearch方法。接下来看一下这两个方法。


private static <T>
int indexedBinarySearch(List<? extends Comparable<? super T>> list, T key) {
    int low = 0;
    int high = list.size()-1;
 
    while (low <= high) {
        int mid = (low + high) >>> 1;
        Comparable<? super T> midVal = list.get(mid);
        int cmp = midVal.compareTo(key);
 
        if (cmp < 0)
            low = mid + 1;
        else if (cmp > 0)
            high = mid - 1;
        else
            return mid; // key found
    }
    return -(low + 1);  // key not found
}
private static <T>
int iteratorBinarySearch(List<? extends Comparable<? super T>> list, T key)
{
    int low = 0;
    int high = list.size()-1;
    ListIterator<? extends Comparable<? super T>> i = list.listIterator();
 
    while (low <= high) {
        int mid = (low + high) >>> 1;
        Comparable<? super T> midVal = get(i, mid);
        int cmp = midVal.compareTo(key);
 
        if (cmp < 0)
            low = mid + 1;
        else if (cmp > 0)
            high = mid - 1;
        else
            return mid; // key found
    }
    return -(low + 1);  // key not found
}

上述两个方法的源码表示,实现了RandomAccess接口的List使用索引遍历,而未实现RandomAccess接口的List使用迭代器遍历。

Collections是针对集合类的一个包裹类,它提供了一系列静态方法实现对各种集合的搜索、排序以及线程安全化等操作,其中的大多数方法都是用于处理线性表。Collections类不能实例化,如同一个工具类,服务于Collection框架。如果在使用Collections类的方法时,对应的Collection对象null,则这些方法都会抛出NullPointerException。

ArrayList的扩容机制

private static final int DEFAULT_CAPACITY = 10;//数组默认初始容量
 
private static final Object[] EMPTY_ELEMENTDATA = {};//定义一个空的数组实例以供其他需要用到空数组的地方调用 
 
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};//定义一个空数组,跟前面的区别就是这个空数组是用来判断ArrayList第一添加数据的时候要扩容多少。默认的构造器情况下返回这个空数组 
 
transient Object[] elementData;//数据存的地方它的容量就是这个数组的长度,同时只要是使用默认构造器(DEFAULTCAPACITY_EMPTY_ELEMENTDATA )第一次添加数据的时候容量扩容为DEFAULT_CAPACITY = 10 
 
private int size;//当前数组的长度

ArrayList的构造方法有三种,分别是
在这里插入图片描述
第一个构造方法用来返回一个初始容量为10的数组(add之后),第二个用来生成一个带数据的ArrayList,第三个构造方法就是自定义初始容量。

transient Object[] elementData;

private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};

public ArrayList() {
    this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}

    DEFAULTCAPACITY_EMPTY_ELEMENTDATA这个常量在ArrayList源码中表示的是一个空数组,而elementData就是ArrayList实际存储数据的容器。由此可知,ArrayList在调用无参构造方法时创建的是一个长度为0的空数组,当调用add()方法添加元素时,ArrayList才会触发扩容机制:

public boolean add(E e) {
    ensureCapacityInternal(size + 1);  // Increments modCount!!
    elementData[size++] = e;
    return true;
}

可以看到,add的第一行就调用了ensureCapacityInternal方法,并把size+1的值传了进去,继续看 ensureCapacityInternal()

private void ensureCapacityInternal(int minCapacity) {
    ensureExplicitCapacity(calculateCapacity(elementData, minCapacity));
}

ensureExplicitCapacity方法会先计算扩容容量

private static int calculateCapacity(Object[] elementData, int minCapacity) {
    if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
        return Math.max(DEFAULT_CAPACITY, minCapacity);
    }
    return minCapacity;
}

初始elementData就是一个空数组,条件成立,它会从DEFAULT_CAPACITY和minCapacity中选择一个最大值返回,其中DEFAULT_CAPACITY表示默认的初始容量,它的值为10。calculateCapacity()方法将返回10,之后调用ensureExplicitCapacity()方法:

private void ensureExplicitCapacity(int minCapacity) {
    modCount++;

    // overflow-conscious code
    if (minCapacity - elementData.length > 0)
        grow(minCapacity);
}

此时minCapacity的值为10,elementData.length的值为0,条件成立,执行grow()方法:

private void grow(int minCapacity) {
    // overflow-conscious code
    int oldCapacity = elementData.length;
    int newCapacity = oldCapacity + (oldCapacity >> 1);
    if (newCapacity - minCapacity < 0)
        newCapacity = minCapacity;
    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);

这一步就是扩容的核心操作。先将旧容量右移1位,再加上旧容量就得到了新容量,正数右移1位相当于除以2,在该基础上加旧容量,则等价于新容量 = 旧容量 * 1.5,所以才有ArrayList每次扩容为旧容量的1.5倍的说法。

LinkedList

    LinkedList是一个双向链表,可以存储任何元素(包括null), LinkedList底层的链表结构使它支持高效的插入和删除操作,但是要查询的话只能遍历查询,因此查询的效率低下。双向链表的每个节点用内部类Node表示。LinkedList通过first和last引用分别指向链表的第一个和最后一个元素。
    LinkedList同时实现了List接口和Deque对口,也就是收它既可以看作一个顺序容器,又可以看作一个队列(Queue),同时又可以看作一个栈(stack)。

ArrayList和LinkedList的区别

  1. ArrayList 的底层是顺序表(数组),LinkedList 底层是链表
  2. 对于随机访问get和set,ArrayList要优于LinkedList,因为LinkedList要移动指针;
  3. ArrayList实现了RandomAccess接口,所以ArrayList用for循环遍历的速度要比迭代器快。
  4. 在查找、删除元素的时候,如果元素基数大,ArrayList的效率要比LinkedList快一点,这取决于二者之间的实现方式。

    ArrayList想要get(int index)元素时,直接返回index位置上的元素,而LinkedList需要通过for循环进行查找,虽然LinkedList已经在查找方法上做了优化,比如index < size / 2,则从左边开始查找,反之从右边开始查找,但是还是比ArrayList要慢。ArrayList想要在指定位置插入或删除元素时,主要耗时的是System.arraycopy动作,会移动index后面所有的元素;LinkedList主耗时的是要先通过for循环找到index,然后直接插入或删除。

Set集合

    set作为Collection接口的子接口,他与list同级,set存储无序元素且不能重复,实现类主要是HashSet、LinkedHashSet和TreeSet

HashSet

    HashSet和LinkedHashSet底层数据结构是哈希表。hashSet不是同步的,如果多个线程同时访问一个Set,只要有一个线程修改了Set中的值,就必须进行同步处理,通常通过同步封装这个Set对象来完成同步,如果不存在这样的对象,可以使用Collections.synchronizedSet()方法完成。

Set集合取出元素的方式可以采用:迭代器、增强for、流遍历。

    HashSet是根据对象的哈希值来确定元素在集合中的存储位置,因此具有良好的存取和查找性能。保证元素唯一性的方式依赖于:hashCode与equals方法。

哈希表底层结构

    在JDK1.8之前,哈希表底层采用 数组+链表 ,所以使用链表解决哈希冲突,同一hash值的链表都存储在一个链表里。但是当位于一个桶中的元素较多,即hash值相等的元素较多时,通过key值依次查找的效率较低。
    在JDK1.8之后,哈希表的底层结构是 数组+链表+红黑树 。当链表长度超过阈值(8)时,将链表转换为红黑树,这样大大减少了查找时间。

Hash的存储原理

  1. 调用对象的 hashCode() 方法,获得要存储元素的哈希值。
  2. 将哈希值与表的长度(即数组的长度)进行求余运算得到一个整数值,该值就是新元素要存放的位置(即是索引值)。
  3. 遍历该位置上的所有旧元素,依次比较每个旧元素的哈希值和新元素的哈希值是否相同。
  4. 比较新元素和旧元素的地址是否相同。如果地址值相同则用新的元素替换老的元素,停止比较。如果地址值不同,则新元素调用equals方法与旧元素比较内容是否相同。
  5. 说明没有重复,则将新元素存放到该位置上并让新元素记住之前该位置的元素。

如何保证元素的唯一性

HashSet调用add()方法添加元素时其实底层是调用了HashMap的put()方法,元素唯一性实现的方法还是有HashMap来实现,因为HashMap的key值也不能相同。其本质还是与哈希表的存储原理一致。

LinkedHashSet

    LinkedHashSet是HashSet的子类,继承了set集合的所有功能和特点,底层维护了一个哈希表+双向链表,同时把其中的重要特点给修改,把无序变为有序

特点:

  1. LinkedHashSet是具有可预知迭代顺序的Set接口的哈希表和链接列表实现。此实现与HashSet的不同之处在于,后者维护着一个运行于所有条目的双重链接列表
  2. LinkedHashSet底层使用LinkedHashMap来保存所有元素,它继承与HashSet,其所有的方法操作上又与HashSet相同。
  3. LinkedHashSet也是非线程安全的。可以存储null。
  4. LinkedHashSet根据元素的hashCode值来决定元素的存储位置,同时使用链表维护元素的次序,所以他是有序的。

TreeSet

     TreeSet的底层是TreeMap。TreeSet 集合是 Set 集合的实现类,因此它也具有元素唯一性和无序性,它的底层数据结构是 TreeMap 树结构,它确保元素的唯一性是通过 Compareable 接口的 compareto 方法,另外,它既是无序的,又是有序的。无序指的是存储元素顺序和输出顺序不同,有序指的是遍历集合时,输出的所有元素是有序的。所有其存储元素和输出元素的顺序是不同的。

TreeSet集合的有序性

    添加元素到 TreeSet 集合时,TreeSet 集合会调用元素的 compareTo() 方法来比较元素之间的大小关系,并将集合元素按升序排列,这就是它的有序性

TreeSet的保证元素的唯一性

    TreeSet类的add()方法中会把存入的对象提升为Comparable类型。调用对象的compareTo()方法和集合中的对象比较(当前存入的是谁,谁就会调用compareTo方法),根据compareTo()方法返回的结果进行存储。

HashSet与TreeSet

相同点:都是单列集合,数据不能重复。
不同点:

  1. 底层使用的储存数据结构不同,Hashset底层使用的是哈希表结构储存,而Treeset底层用的是TreeMap树结构储存。
  2. 储存的数据保存唯一方式不用。 Hashset是通过复写hashCode()方法和equals()方法来保证的。而Treeset是通过Compareable接口的compareto方法来保证的。
  3. hashset无序 Treeset有序

Map映射

    Map,与Collection接口平级,用于保存具有映射关系的数据,Map集合里保存着两组值,一组用于保存Map的key,另一组保存着Map的value。主要实现类有HashMap、HashTable、TreeMap。

HashMap

HashMap的底层数据结构+为什么?

    HashMap的底层数据结构在JDK8之前是由数组+链表组成的,而在JDK8之后,由数组+链表+红黑树组成。

     那为什么要改成数组+链表+红黑树呢? 是因为要提升解决hash冲突时(list只能从next不断访问,所有list过长导致性能低下)的查找性能,使用链表的时间复杂度为O(n),而红黑树则为O(log n)

     那什么时候用链表,什么时候用红黑树呢? 如果插入元素,默认情况下使用的是链表节点。当同一个索引位置的节点在大于8的时候,链表会转为红黑树。阈值为8。 而对于移除,当索引位置的节点小于6的时候,则会吧红黑树再转为链表。

     那为什么不直接用红黑树而是要和链表相互转换呢? 因为我们在设计方案的时候,要考虑到时间和空间,当然HashMap也不例外,阈值为8这是时间和空间权衡后的结果。在时间的消耗上红黑树的时间复杂度是O(log n),要比链表的O(n)快,但是在存储元素的空间上,红黑树需要进行左旋和右旋 而单链表不需要,所以在空间上TreeNodes是链表Nodes的两倍。 当存储的元素过少时,红黑树查找性能的优势并不明显,付出两倍的空间有点得不偿失。

     那为什么阈值要设置成8? 如果 hashCode 分布良好,也就是 hash 计算的结果离散好的话,那么红黑树这种形式是很少会被用到的,因为各个值都均匀分布,很少出现链表很长的情况。在理想情况下,链表长度符合泊松分布,各个长度的命中概率依次递减,当长度为 8 的时候,概率仅为 0.00000006(6E-8)。 这个概率足够低了,并且到8个节点时,红黑树的性能优势也会开始展现出来,因此8是一个较合理的数字。

     那为什么转回链表节点的阈值是6而不是8 如果我们设置节点多于8个转红黑树,少于8个就马上转链表,当节点个数在8徘徊时,就会频繁进行红黑树和链表的转换,造成性能的损耗。

     HashMap还有那些重要的属性,都是做什么的 用来储存节点的数组table、用来储存map长度的size、还有扩容的阈值threshold(当 HashMap 的个数达到该值,触发扩容)和负载因子loadFactor。其中扩容的阈值就等于 负载因子*容量。在我们新建 HashMap 对象时,threshold 除了用于存放扩容阈值还会被用来存初始化时的容量,HashMap 直到我们第一次插入节点时,才会对 table 进行初始化,避免不必要的空间浪费。

     HashMap 的默认初始容量是多少?HashMap 的容量有什么限制吗? HashMap的默认初始容量是16。对于HashMap而言,他的容量必须是2的N次方,HashMap 会根据我们传入的容量计算一个大于等于该容量的最小的2的N次方,例如传 9,容量为16。传17,容量为32。

     为什么容量必须为2的N次方 因为计算索引位置的公示为(n - 1) & hash (位运算,必须同为1才是1,否则为0)。当 n 为 2 的 N 次方时,n - 1 为低位全是 1 的值,此时高位任何值跟 n - 1 进行 & 运算的结果为该值的0(既然我们的 n-1 永远都是 1,那 ( n - 1 ) & hash 的计算结果就是 低位的hash 值。),不仅达到了和取模同样的效果,实现了均匀分布,减少了hash冲突。比%(取模)快了很多。

    为什么负载因子的值为0.75 这个也是在时间和空间上权衡的结果,如果负载因子值比较高,(比如是1) 虽然会减少空间开销,但是哈希冲突的概率会增大,增加查找的成本。而如果比较低,(比如是0.5)虽然哈希冲突的概率会减小,但是会浪费掉一半的空间,得不偿失。权衡之下,0.75是最合适的,他 * 2的N次方还都是整数,很人性化。

HashMap的插入流程

  1. 如果哈希表没有初始化,首先进行初始化,默认长度为16。
  2. 然后计算出key的哈希值,找出在在节点数组中对应的下标 i。
  3. 判断table[i]是否为空,若为空,直接插入;
  4. 若不为空,则判断当前的key 与 table[i] 保存的key 是否相同,若相同则直接覆盖;
  5. 若不同,则需看改Map的头结点是不是红黑树节点(判断红黑树还是链表)。
  6. 如果是红黑树,找到红黑树的根节点,然后开始遍历,找到key相同节点的位置,然后插入最后进行平衡调整。
  7. 如果是链表,则遍历链表,使用equals() 方法判断key是否存在,若存在则直接覆盖。否则插入链表末尾。如果节点个数超过8,则链表转红黑树。

HashMap的扩容机制

  1. 空参数的构造函数:实例化的HashMap默认内部数组是null,即没有实例化。第一次调用put方法时,则会开始第一次初始化扩容,长度为16。
  2. 如果长度达到12的时候(负载因子*容量)会扩容,扩容到其当前容量的2倍

在这里插入图片描述

HashMap的扰动函数

源码:

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

    HashMap 中的扰动函数是一个通过对 key 值类型自带的哈希函数生成的散列值进行位移计算来扰乱散列值,以达到降低哈希碰撞的概率的方法。源码中对应的是 hash(),
    key.hash() 调用的是key类型自带的哈希函数,返回的是 int 类型的散列值。 如果没有扰动函数的情况下,我们拿着散列值作为下标找到 hashmap 中对应的桶位存下即可(不发送哈希冲突的情况下),但 int 类型是 32 位,很少有Hashmap的数组有40亿这么大,所以, key 类型自带的哈希函数返回的散列值不能拿来直接用。 如果取低几位的 hash 值来做数组映射,但是如果低位相同,高位不同的 hash 值就碰撞了,如:

// Hash 碰撞示例:
00000000 00000000 00000000 00000101 & 1111 = 0101 // H1
00000000 11111111 00000000 00000101 & 1111 = 0101 // H2

    为了解决这个问题,HashMap 想了个办法,用扰动函数降低碰撞的概率。 将 hash 值右移16位(hash值的高16位)与 原 hash 值做异或运算(^),从而得到一个新的散列值。 如:

00000000 00000000 00000000 00000101 // H1
00000000 00000000 00000000 00000000 // H1 >>> 16
00000000 00000000 00000000 00000101 // hash = H1 ^ (H1 >>> 16) = 5

00000000 11111111 00000000 00000101 // H2
00000000 00000000 00000000 11111111 // H2 >>> 16
00000000 00000000 00000000 11111010 // hash = H2 ^ (H2 >>> 16) = 250

    H1,H2 两个 hash 值经过扰动后,很明显不会发生碰撞。所以 不论是HashMap的容量为2的N次方还是扰动函数,都是为了降低哈希冲突的概率,从而使 HashMap 性能得到优化。

死循环问题+为什么线程不安全

    死循环问题是在JDK1.7和JDK1.7之前遇到的情况。在多线程的操作下,HashMap可能会引起死循环。原因是在HashMap扩容时,链表转移后,前后链表顺序倒置(头插法导致),在转移过程中修改了原来链表中节点的引用关系,导致链表结点互相引用,即形成了环, 这种情况下,当我们使用get曹操获取到环形链表处的数据,就会发生死循环。

    通俗点说就是 HashMap在扩容进行resize时,将节点从旧的table[i]移动newTable[j]时,使用的是头插法。如多个HashMap在并发的情况下进行扩容:
    假设原来在table[i]的链表中,A节点指向了B节点。
    在线程1进行扩容的时候,newTable[j]的链表中B节点指向了A节点。
    在线程1进行扩容的时候, newTable[j]的链表中A节点又指向了B节点。
这样就导致了死循环

哈希冲突和解决方式

两个不同元素的哈希值一样

// Hash 碰撞示例:
00000000 00000000 00000000 00000101 & 1111 = 0101 // H1
00000000 11111111 00000000 00000101 & 1111 = 0101 // H2

解决思路:链式地址法

    对于相同的值,使用链表进行连接。使用数组存储每一个链表。

HashTable

  1. 两者最主要的区别在于Hashtable是线程安全,而HashMap则非线程安全。Hashtable的实现方法里面都添加了synchronized关键字来确保线程同步,因此相对而言HashMap性能会高一些
  2. HashMap可以使用null作为key,而Hashtable则不允许null作为key
  3. HashMap是对Map接口的实现,HashTable实现了Map接口和Dictionary抽象类
  4. HashMap的初始容量为16,Hashtable初始容量为11,两者的填充因子默认都是0.75
  5. 两者计算hash的方法不同。Hashtable计算hash是直接使用key的hashcode对table数组的长度直接进行取模。HashMap计算hash对key的hashcode进行了二次hash,以获得更好的散列值,然后对table数组长度取摸
  6. Hashtable的底层实现是数组+链表结构实现

TreeMap

    在Map集合框架中,除了HashMap以外,TreeMap也是常用到的集合对象之一。与HashMap相比,TreeMap是一个能比较元素大小的Map集合,会对传入的key进行了大小排序。其中,可以使用元素的自然顺序,也可以使用集合中自定义的比较器来进行排序;不同于HashMap的哈希映射,TreeMap实现了红黑树的结构,形成了一颗二叉树。

    TreeMap继承于AbstractMap,实现了Map, Cloneable, NavigableMap, Serializable接口。
TreeMap具有如下特点:

  1. 不允许出现重复的key;
  2. 可以插入null键,null值;
  3. 可以对元素进行排序;
  4. 无序集合(插入和遍历顺序不一致);
  • 3
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值