前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >Java集合源码浅析

Java集合源码浅析

作者头像
堆栈哲学
发布2022-11-23 13:51:06
3530
发布2022-11-23 13:51:06
举报
文章被收录于专栏:博客·技术专栏

更新日志????

2022-05-26 10:20:23 星期四

  • 修正语言表达逻辑
  • 删除/修改了错别字词
  • 更新了部分配图

2022-08-02

  • 修正错别字
  • 修正语言表达逻辑

2022-08-22

  • 还是修已知的正错别词语

IDEA快捷键

查看源码:F4 进入实现:Ctrl+Alt+B(鼠标点击) 添加实现类:空格 显示图:Ctrl+Alt+Shift+U

概览

说明:以下内容的分析源码,如没有特别说明,均来自JDK8.

  1. 集合主要分为两组:单列集合和双列集合 单列集合一般是指存放单个对象的集合,而双列集合一般是以<k,v>键值对形式存放的数据的集合。
  2. Collection接口下有两个重要的子接口 List,Set,他们的实现子类都是单列集合。
  3. Map接口的实现子类有 HashTableHashMapTreeMap,也都是双列集合。
  4. 以下是集合类下两大主接口的类图关系。

Collection系

在Conllection接口下,派生出了三个主要的子接口,分别为无序集合 Set,队列 Queue和有序集合 List。在三大子接口之下,还有着众多的实现子类或者派生的子接口,其中最常用的有:

  • TreeSet
  • LinkedHashSet
  • HashSet
  • LinkedList
  • ArrayList
  • Stack

Map系

Map集合为双列集合。Map没有直接继承的子接口,主要有三个实现类,分别是 HashMapHashTableSortedMap。在三个主要实现下,比较常用的实现及其实现子类有:

  • HashMap(性能高,非线程安全)
  • Hashtable(性能低,线程安全,但属于老旧的API了)
  • TreeMap(有序map)

细则

Collection

由于 Collection接口直接继承了 Iterable,它是没有实现的,它的所有方法都是由它的子接口的实现类进行实现,所以这里就以 Collection下子接口 List的实现类 ArrayList来讲解。注意 List是有序集合且元素可以重复,而 Set则是无序集合,元素不可重复。

讲解的方法列表

  • add:添加单个元素
  • remove:删除指定元素
  • contains:查找元素是否存在
  • size:获取元素个数
  • isEmpty:判断是否为空
  • clear:清空
  • addAll:添加多个元素
  • containsAll:查找多个元素是否都存在
  • removeAll:删除多个元素

基本用法演示

代码语言:javascript
复制
List list = new ArrayList();
/*添加单个元素*/
list.add("Jack");
//这里其实是一个自动装箱的操作:list.add(new Integer(10))
list.add(10);
list.add(true);
System.out.println("list:"+list);

/*删除元素*/
//删除"Jack"
//list.remove(0);
//指定删除某个元素
list.remove(true);
System.out.println("输出后的[list]:"+list);

/*查找某个元素是否存在*/
//true
System.out.println(list.contains("Jack"));

/*获取元素个数*/
//2
System.out.println(list.size());

/*判断集合是否为空*/
//false
System.out.println(list.isEmpty());

/*清空集合*/
list.clear();
//清空后的[list]:[]
System.out.println("清空后的[list]:"+list);

/*添加多个元素*/
ArrayList list2 = new ArrayList();
list2.add("西游记");
list2.add("西厢记");
list.addAll(list2);
//添加多个元素后的[list]:[西游记, 西厢记]
System.out.println("添加多个元素后的[list]:"+list);

/*判断多个元素是否都存在*/
//true
System.out.println(list.containsAll(list2));

/*删除多个元素*/
list.add("华强北");
//true
System.out.println(list.removeAll(list2));
//华强北
System.out.println(list);

遍历用法

上面的类图已经知道,Collection接口还有一个 Iterable父接口。它的部分实现源码中第一个方法如下:

可以看到,该方法可以返回元素的 iterator对象。只要是实现了接口的所有子类,都有一个 iterator()方法。在对元素的遍历上,都可以采用迭代器的方式进行遍历。所以 Collection及其所有子类实现,我们都可以获取到每个元素的迭代器并用在对元素的遍历操作上。需要注意的是,iterator仅用来遍历集合,本身并不存放任何对象。

迭代器的执行原理

作为 Collection的父接口,Iterator的方法如下:

我们一般在使用迭代器进行遍历的时候,都会用到一个 while(){}循环,循环的条件是 iterator.hasNext(),也就是说,在每次得到遍历元素之前,iterator对象会调用自身的 hasNext()方法,对集合里的元素进行判断,只有当存在下一个元素时,迭代器才会继续往下执行,否则,迭代结束。

可以看到,IteratorhasNext()方法返回一个布尔值,如果该迭代对象还存在元素的情况下。这个方法就相当于一个指向集合元素的指针,每一次调用都会向下移动以检查是否到达集合尾部,在移动的同时,它还会调用 next()方法,该方法会将移动后该指针指向位置上的元素进行返回。为了有效的防止空指针,每次在调用 Next()之前,会先调用 hasNext(),这是有必要的。如果说不存在下一个元素,则会抛出一个 NoSuchElementException异常。

Iterator使用示例

代码语言:javascript
复制
package collection;

import java.util.ArrayList;
import java.util.Collection;
import java.util.Iterator;

/**
 * @author: Tisox
 * @date: 2022/3/20 14:17
 * @description: 演示迭代器[Iterator]的使用
 * @blog:www.waer.ltd
 */
@SuppressWarnings({"all"})
public class CollectionIterator {
    public static void main(String[] args) {
        Collection col = new ArrayList();
        col.add(new Book("C++ Primer Plus","Stephen Prata",57.4));
        col.add(new Book("程序员的数学","结城浩",20.5));
        col.add(new Book("Java疯狂讲义","李刚",80.7));

        System.out.println("集合[col]:"+col);
        /*遍历集合*/
        //1.获取集合的迭代对象
        Iterator iterator = col.iterator();
        //2.while循环遍历数据
        while(iterator.hasNext()){
            //3.注意:iterator返回默认时一个Object类型(除非指定泛型)
            Object o = iterator.next();
            System.out.println("[col]迭代返回:"+o);
        }
        //4.当退出while循环之后,此时的iterator指向最后一个元素,在调用next()方法会报NoSuchElementException异常。
        //如果需要再次遍历,需要重置迭代器。方法如下:
        //IDEA支持快速生成迭代方法,使用[Ctrl+j]快捷键进行查看
        iterator = col.iterator();
        while (iterator.hasNext()) {
            Object o1 =  iterator.next();
            System.out.println("[col]再次迭代:"+o1);
        }
    }
}

/**
 * 内部类
 */
class  Book{
    private String name;
    private String author;
    private double price;

    public Book(String name, String author, double price) {
        this.name = name;
        this.author = author;
        this.price = price;
    }

    @Override
    public String toString() {
        return "Book{" +
                "name='" + name + '\'' +
                ", author='" + author + '\'' +
                ", price=" + price +
                '}';
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getAuthor() {
        return author;
    }

    public void setAuthor(String author) {
        this.author = author;
    }

    public double getPrice() {
        return price;
    }

    public void setPrice(double price) {
        this.price = price;
    }
}

一些需要注意的点,已经写在了注释当中。

增强for

所谓增强for,也就是针对普通for循环的增强。它可以替代 iterator迭代器,相当于一个简化版的 iterator,也正因为如此,增强for只能用于遍历集合或者数组

基本语法:

代码语言:javascript
复制
for(元素类型 元素名:集合或者数组名){
    访问元素;
}
代码语言:javascript
复制
package collection;

import java.util.ArrayList;
import java.util.Collection;

/**
 * @author: Tisox
 * @date: 2022/3/20 14:56
 * @description: 演示增强for的使用
 * @blog:www.waer.ltd
 */
public class CollectionFor {
    @SuppressWarnings({"all"})
    public static void  main(String[] args) {
        Collection col = new ArrayList();
        col.add(new Book("C++ Primer Plus","Stephen Prata",57.4));
        col.add(new Book("程序员的数学","结城浩",20.5));
        col.add(new Book("Java疯狂讲义","李刚",80.7));

        /*使用增强for*/
        /*底层任然是迭代器*/
        for(Object book:col){
            System.out.println("book="+book);
        }
    }
}
List接口

常用实现及其方法一览

List接口是 Collection的子接口,上面讲解的 ArrayList的方法是来自 Collection接口方法。而这些方在 Set子接口中也可以使用。下面讲一下子接口 List中的实现类,也是以 ArrayList实现为例。

  • List集合类中的元素是有序(添加和取出顺序一致)的,且是可重复的。
  • List集合中的每一个元素都有其对应的顺序索引,即他是支持索引的一类集合。
  • List中的元素都对应一个整数型的序号记载其在容器中的位置,可以根据序号存取容器中的元素。
  • List子接口的主要常用实现类有 ArrayListLinkedListVector

List的一些方法

代码语言:javascript
复制
package List;

import java.util.ArrayList;
import java.util.List;

/**
 * @author: Tisox
 * @date: 2022/3/20 16:57
 * @description: List的方法演示
 * @blog:www.waer.ltd
 */
public class ListMethod {
    @SuppressWarnings({"all"})
    public static void main(String[] args) {
        List list =new ArrayList();
        list.add("张无忌");
        list.add("张天志");
        /*在index位置插入元素e*/
        /*注意:这里如果不指定下标的话,默认是以尾部追加的方式进行元素插入的*/
        list.add(1,"Tisox");
        //list=[张无忌, Tisox, 张天志]
        System.out.println("list="+list);

        /*addAll(inr index,Collection e):从index位置开始将元素e中的所有元素添加进来*/
        List list2 =new ArrayList();
        list2.add("蜘蛛侠");
        list2.add("钢铁侠");
        list.addAll(1,list2);
        //list=[张无忌, 蜘蛛侠, 钢铁侠, Tisox, 张天志]
        System.out.println("list="+list);
        /*int intdexOf(Object obj):返回obj在当前集合中首次出现的位置*/
        System.out.println(list.indexOf("蜘蛛侠"));

        /*int lastIndexOf(Object obj):返回obj在当前集合中最后一次出现的位置*/
        list.add("凋残");
        System.out.println(list.lastIndexOf("凋残"));

        /*remove(int index):移除指定index位置的元素,并返回此元素*/
        list.remove(0);
        System.out.println("list="+list);

        /*set(int index,Object ele):设置指定index位置出的元素为ele,相当于是替换元素*/
        list.set(1,"新的名字");
        System.out.println("list="+list);

        /*subList(int fromIndex,int toIndex):返回从fromIndex到toIndex位置的子集合*/
        //返回一个左闭右开的区间
        List reslist = list.subList(0, 2);
        System.out.println("relist="+reslist);
    }
}

List的三种遍历方式

由于 ArrayListLinkedListVector都是 List的实现子类,以下方法可以无缝切换,效果是一样的。

代码语言:javascript
复制
package List;

import java.util.*;

/**
 * @author: Tisox
 * @date: 2022/3/20 19:18
 * @description: List的三种遍历方式
 * @blog:www.waer.ltd
 */
public class ListFor {
    @SuppressWarnings({"all"})
    public static void main(String[] args) {
        //List list =new Vector();
        List list = new LinkedList();
        //List list = new ArrayList();
        list.add("jack");
        list.add("tom");
        list.add("回锅肉");
        list.add("鱼香肉丝");
        list.add("砂锅粉");

        /*1.迭代器遍历*/
        Iterator iterator = list.iterator();
        while (iterator.hasNext()) {
            Object next = iterator.next();
            System.out.println("[list]的[迭代器iterator]遍历="+list);
        }
        System.out.println("====================================");
        /*2.增强for遍历*/
        for (Object o : list) {
            System.out.println("[list]的[增强for]遍历="+list);
        }
        System.out.println("====================================");
        /*3.普通for循环遍历*/
        for(int i=0;i<list.size();i++){
            System.out.println("[list]的[普通for循环]遍历="+list.get(i));
        }
    }
}
ArrayList
  • ArrayList可以允许存入 null值。
代码语言:javascript
复制
ArrayList arrayList = new ArrayList();
arrayList.add(null);
arrayList.add("");
arrayList.add("Java");
System.out.println(arrayList);
  • 底层采用数组实现。
  • ArrayList线程不安全

通过它的源码可以看到,他是没有 synchronized关键字修饰的。也正是因为如此,它的效率是比较高的,所以如果需要保证线程安全,不建议使用 ArrayList

源码分析

ArrayList中维护了一个 Object类型的数组 elementData[]。源码如下

代码语言:javascript
复制
transient Object[] elementData;

这里的 elementData[]数组的类型是 Object类型,也就是说,它可以存放任意类型的数据,因为 Object类是所有类的父类,也就是顶级父类。 关键字 transient的作用是去除序列化,当某个属性被加上该关键字即表示它在进行序列化时会被忽略,不能被序列化。

底层扩容原理

ArrayList底层采用数组这种数据结构来实现,必然会有容量得限制,那么,在它的底层,是如何实现自动扩容的呢?这里以其中的 add()方法进行简单分析。

ArrayList有两个构造方法,分别是无参数构造和有参构造。下面是源码

代码语言:javascript
复制
//无参构造
public ArrayList() {
    this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}
//有参构造
public ArrayList(int initialCapacity) {
    if (initialCapacity > 0) {
        this.elementData = new Object[initialCapacity];
    } else if (initialCapacity == 0) {
        this.elementData = EMPTY_ELEMENTDATA;
    } else {
        throw new IllegalArgumentException("Illegal Capacity: "+
                                           initialCapacity);
    }
}

两个构造方法不仅在参数上有所区别,他们的底层扩容原理也是不一样的,先看一下无参数的 ArrayList()构造。

可以看到,在无参构造的方法中,它将数组的初始容量设为 DEFAULTCAPACITY_EMPTY_ELEMENTDATA。也就是一个空对象数组。这一点可以从下面的源码得知。

下面尝试在集合中添加元素,来分析add方法的执行过程。

代码语言:javascript
复制
//使用无参构造对集合进行初始化
ArrayList list = new ArrayList();
//向其中添加10个元素
for (int i = 1; i <= 10; i++) {
    list.add(i);
}

执行过程和扩容原理

  • 在初始化完成后,当我们触发add()时,它会先调用 valueOf()方法对添加的元素进行一个装箱操作,这不是本次分析的重点,不再赘述。注意下面这个自动装箱的源码来自JDK11

装箱结束后,进入 add(E e)这个方法,该方法是集合中的一个重载方法,接收一个泛型参数,源码如下

代码语言:javascript
复制
public boolean add(E e) {
    ensureCapacityInternal(size + 1);  // Increments modCount!!
    elementData[size++] = e;
    return true;
}

首先,在执行正式的添加操作之前,会先执行 ensureCapacityInternal()方法,该非法主要是用来确认集合的容量情况,决定是否需要扩容。再调用添加方法进行元素的添加。源码如下:

代码语言:javascript
复制
private void ensureCapacityInternal(int minCapacity) {
    if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
        minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
    }
    ensureExplicitCapacity(minCapacity);
}

方法传入一个名为 minCapacity的int类型变量,表示数组最小容量。接着判断 elementData是否是 DEFAULTCAPACITY_EMPTY_ELEMENTDATA默认值,显然,由于我们选择的是无参构造,所以 if语句中的条件是成立的。接下来 Math.max(DEFAULT_CAPACITY, minCapacity)默认容量最小容量之间取一个最大值并赋给 minCapacity,也就是更新 minCapacity的值。关于默认容量 DEFAULT_CAPACITY,下面是它的定义:

代码语言:javascript
复制
private static final int DEFAULT_CAPACITY = 10;

执行之后,minCapacity的值将更新为10;也就是说,这个方法目的是为了确认 minCapacity的值,而在if之后,又出现了一个 ensureExplicitCapacity(minCapacity);方法,它的参数就是上面更新后的 minCapacity,可以猜测,这个方法应该也是对是否需要扩容进行一个判断的算法。

代码语言:javascript
复制
private void ensureExplicitCapacity(int minCapacity) {
    modCount++;
    // overflow-conscious code
    if (minCapacity - elementData.length > 0)
        grow(minCapacity);
}

注意,这里有一行为 modCount++;的语句,他主要是记录当前集合被修改的次数,主要是为例防止被多个线程操作,否则会抛异常。第4行中if的条件 minCapacity - elementData.length > 0表示最小容量与当前数组元素容量的一个差值大于0是否成立,将会直接调用下一个方法进行扩容,也就是grow()方法。比方说,此时的 minCapacity=10elementData=0,显然 10-0>0,也即是说,数组需要一个最小容量为10空间,而此时的容量为0,显然需要进行扩容操作。

下面是 grow()方法,也是扩容的核心实现。

代码语言:javascript
复制
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);
}

可以看到,方法开始会先将数组容量 elementData.length赋值给一个中间变量 oldCapacity。接着为变量 newCapacity,进行赋值,算法是将 oldCapacity旧的容量+旧容量的二分之一。注意这里 (oldCapacity >> 1)表将 oldCapacity右移一位,等同于除以2。反过来,如果是左移的话,代表乘以2。

又由于前面已经知道 elemenatData其实是等于0的,那么直接导致这条赋值语句结果为0,也就是 newCapacity==0,所以它后面紧接着出现了两个判断。

  1. 如果新的容量小于最小容量,那么将最小容量赋给这个新容量,完成一次扩容,此时数组的容量由0变为10.
  2. 如果 newCapacity > MAX_ARRAY_SIZE ,那么 newCpapcity的值由方法 hugeCapacity()决定。这个后面再说,我们继续当前的分析,在执行完上面的判断语句之后,最后对 elemantData进行重新赋值,核心方法 Arrays.copyOf(elementData, newCapacity),该方法的作用是将 newCapacity的值复制给 elementData。之后 elementData里面将会存在10个null值。

就是说,当我们首次使用该集合的无参构造初始化集合时,其实并不会触发1.5倍的底层扩容机制。注意,这里使用 copyOf()方法的作用也是为了保留扩容之前已经存在集合中的元素,换句话说,每次扩容,并不会导致已存在的元素丢失,而是在这些元素之后添加N个值为 null的元素空间。比如:

代码语言:javascript
复制
//null值得位置就是扩容的容量
{1,2,3,4,5,6,7,8,9,10,null,null,null,null,null}

当以上扩容操作完成之后,执行会返回到之前的add()方法:

代码语言:javascript
复制
public boolean add(E e) {
    ensureCapacityInternal(size + 1);  // Increments modCount!!
    elementData[size++] = e;
    return true;
}

此时的 elementData已经由最初的空数组扩容为大小为10的容量,当执行完 elementData[size++] = e;之后,新的容量中第一个位置会被替换为元素1,比如:

代码语言:javascript
复制
[1,null,null,null,null,null,null,null,null,null]

注意理解minCapacityelementData的含义。前者的意思时我们用这个集合存放某些元素最少需要的空间,而后者表示此时这个集合本身拥有的空间,所以,扩容的目标在于扩elementData的大小。以满足存放minCapacity所需。

现在来看一下上面那的**hugeCapacity(minCapacity)**方法。源码如下:

代码语言:javascript
复制
private static int hugeCapacity(int minCapacity) {
 if (minCapacity < 0) // overflow
     throw new OutOfMemoryError();
 return (minCapacity > MAX_ARRAY_SIZE) ?
     Integer.MAX_VALUE :
 MAX_ARRAY_SIZE;
}
//注意:以下是MAX_ARRAY_SIZE的常量定义。
private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;
//2147483647是Integer.MAX_VALUE

这个方法其实就是对数组大小边界进行一个限制,要求数组大小在0到MAX_VALUE之间。如果<0直接抛出一个 OutOfMemoryError异常,否则返回一个值作为数组容量的上限,这里用了一个三元表达式作为返回语句。

如果最小容量大于MAX_ARRAY_SIZE,则将Integer.MAX_VALUE;的值赋给它,否则还是用MAX_ARRAY_SIZE。

再看一下为什么这里MAX_ARRAY_SIZEInteger.MAX_VALUE-8,也即是2147483647-8=2,147,483,639而不是减其他数值?关于这个问题,其实在源码的注释中就已经写清楚了

大致意思就是如果直接使用Integer.MAX_VALUE的话,在某些虚拟机中,可能会出现溢出的问题。不过,一般情况下,我们还是认为,它得值可以直接看作是与Integer.MAX_VALUE相同。当然,以下是来自 stackoverflow的一个解答,可以参考一下。

Why the maximum array size of ArrayList is Integer.MAX_VALUE - 8?

有参构造的扩容原理

上面分析了调用无参构造器创建集合后,它底层的扩容原理,其实只要理解了之后。关于有参构造的扩容,就很容易理解了。下面是它的有参构造器源码

代码语言:javascript
复制
/**
     * Constructs an empty list with the specified initial capacity.
     *
     * @param  initialCapacity  the initial capacity of the list
     * @throws IllegalArgumentException if the specified initial capacity
     *         is negative
     */
public ArrayList(int initialCapacity) {
    if (initialCapacity > 0) {
        this.elementData = new Object[initialCapacity];
    } else if (initialCapacity == 0) {
        this.elementData = EMPTY_ELEMENTDATA;
    } else {
        throw new IllegalArgumentException("Illegal Capacity: "+
                                           initialCapacity);
    }
}

这段代码很容易理解,我们在调用该构造器进行初始化时传入一个初始大小 initialCapacity作为数组的初始容量。如果该容量大于0,此时elementData数组会直接以该值作为数组长度创建一个新的Object数组,完成初始化。否则如果传入的初始值为0,会对elementData进行一个常量赋值操作,将数组初始化为 EMPTY_ELEMENTDATA大小的数组,该常量定义如下:

代码语言:javascript
复制
private static final Object[] EMPTY_ELEMENTDATA = {};

也就是创建一个空数组。如果不在以上两种情况之外的,直接抛一个IllegalArgumentException异常结束。但是一般情况下,既然我们都决定调用了该构造器,一般不会直接甩个0进去,这样做的意义不大。

在初始化完成后,进入添加方法,方法会先对现有的数组容量进行检查,如果发现所需最小容量大于当前初始化传入的容量,则会先进入 grow()方法完成扩容,这里扩容不会进入第一个if判断,因为初始化传入的elementData必然是大于0的,程序会直接执行源码中的 int newCapacity = oldCapacity + (oldCapacity >> 1);这行语句,直接采取1.5倍扩容的机制对数组进行扩容后,将扩容后的整个数组空间直接复制一份,该操作会在原有元素的基础上追加扩容部分的空间,该部分的值默认使用null来填充,此时再返回添加方法内部执行添加,添加成功之后之前扩容的null部分会被刚添加的元素取代,以此类推,直到下一次容量不够时,又再一次触发1.5被的扩容机制。

代码语言:javascript
复制
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);
}

实例演示

通过一个具体的例子,来解释帮助理解上面所说的扩容原理。

  • 无参构造器

假设我们调用 了 ArrayList()对集合list进行了初始化并尝试向其中添加元素,下面模拟这个大致过程:

  1. 初始化完成,创建一个空的对象数组elementData[] = {}。
  2. 进入add()方法,根据当前添加元素所需空间对已有空间进行判断,显然我们添加第一个元素时,minCapacity=1,而elementData=0。
  3. 此时不忙着执行添加,而是调用ensureCapacityInternal()方法:
    1. 该方法发现,初始化的elementData=DEFAULTCAPACITY_EMPTY_ELEMENTDATA,则执行一个Math.max()方法,该方法直接将minCapacity的值改为10。此时我们的minCapacity=10,而elementData还是0;
    2. 进入**ensureExplicitCapacity()**方法,满足判断条件发现,所需最小容量>当前容量,需要扩容,触发grow()方法。
      1. 检查并记录elementData的长度,发现此时该值为0,由于0的1.5倍还是0,此时扩容算法无意义。
      2. 进入第一个if判断,发现条件满足,直接将minCapacity的值赋给一个新的变量newCapacity=10
      3. 执行数组的copyOf()方法,将会开辟一个容量为10的数组。
      4. 程序跳回add()方法,执行元素的添加。

也就是说,如果我们调用无参构造器初始化集合,首次扩容并不会按照1.5倍的机制来,而是直接给你开一个大小为10的数组,只有当这10个空间全部用完之后,今后的每一次扩容,都会采用1.5倍的机制进行扩容,因此首次调用的方法栈是比较绕的,但是从第二次开始,或者使用有参构造器初始化的时候就会少一些判断,空间不够,直接开始1.5倍扩容机制。

1.5倍扩容怎么算?

假设当前容量值为8,下一次扩容的值就是12,算法过程很简单: 12 = 8+8/2 = 8+4 =12 只不过,在源代码中,算法使用右移>>代替除法,要知道,位运算的速度是远快于四则运算的。由此,如果需要再次扩容的话,12的容量会扩容为12+6 = 18。

Vector
基本结构

Vector类的定义,它实现自 List接口。

代码语言:javascript
复制
public class Vector<E>
    extends AbstractList<E>
    implements List<E>, RandomAccess, Cloneable, java.io.Serializable

它的底层实现也是基于对象数组,它由protected修饰符修饰。参考:

代码语言:javascript
复制
protected Object[] elementData;

Vector是线程安全的,它的操作方法都有 synchronized修饰,该关键字可以实现线程同步和互斥,所以他是线程安全的。比如其源码中的 indexOf()方法,因此,一般在开发中,如果有线程安全的需要,可以考虑使用 Vector。当然,这也并非是必须的,关于线程安全的集合或者说实现,还有专门的类去管理,Vector在JDK1.0版本中就有的,算是一个老前辈了,尽管它线程安全,但也不一定就是最佳的选择。

代码语言:javascript
复制
public synchronized int indexOf(Object o, int index) {
    if (o == null) {
        for (int i = index ; i < elementCount ; i++)
            if (elementData[i]==null)
                return i;
    } else {
        for (int i = index ; i < elementCount ; i++)
            if (o.equals(elementData[i]))
                return i;
    }
    return -1;
}
源码分析
扩容机制

默认10满后,按照2倍扩容。如果指定大小,则每次按2倍扩容。

创建一个无参的vector之后,它会默认直接给你一个大小为10的空间。直截了当。

代码语言:javascript
复制
public Vector() {
    this(10);
}

接着执行添加操作,跳转到 add()方法(这里就不再提自动装箱的操作),源码如下,咋一看是不是和前面分析的 ArrayList的源码如出一辙?除了一个 modCount++之外,还是会在添加元素之前先执行一个名为 ensureCapacityHelper的方法,基于前面 ArrayList源码的阅读理解,这里不用多想也能猜到,这个方法的作用,无非就是对目前的数组容量进行判断,看看是不是需要扩容。

代码语言:javascript
复制
public synchronized boolean add(E e) {
    modCount++;
    ensureCapacityHelper(elementCount + 1);
    elementData[elementCount++] = e;
    return true;
}

进入 ensureCapacityHelper的源码看看,可以看到这实现和 ArrayList中的实现几乎一样,还是判断最小所需空间和当前数组的容量关系,显然,这里elementData=10,而minCapacity=1,不满足扩容的条件,因此这里不会进入 grow()方法。直接返回 add()执行元素的添加,一次添加执行结束。

代码语言:javascript
复制
private void ensureCapacityHelper(int minCapacity) {
    // overflow-conscious code
    if (minCapacity - elementData.length > 0)
        grow(minCapacity);
}

下面我们假设,要添加第11个元素,此时原来的10个空间已经不够,自然会触发扩容机制,下面是该扩容方法的实现源码:

代码语言:javascript
复制
// 扩容方法
private void grow(int minCapacity) {
    // overflow-conscious code
    int oldCapacity = elementData.length;
    int newCapacity = oldCapacity + ((capacityIncrement > 0) ?
                                     capacityIncrement : oldCapacity);
    if (newCapacity - minCapacity < 0)
        newCapacity = minCapacity;
    if (newCapacity - MAX_ARRAY_SIZE > 0)
        newCapacity = hugeCapacity(minCapacity);
    elementData = Arrays.copyOf(elementData, newCapacity);
}
// 关于capacityIncrement的定义:
   /**
     * The amount by which the capacity of the vector is automatically
     * incremented when its size becomes greater than its capacity.  If
     * the capacity increment is less than or equal to zero, the capacity
     * of the vector is doubled each time it needs to grow.
     *
     * @serial
     */
    protected int capacityIncrement;

根据源码了解到,它会先将 elementData的长度放到一个名为 oldCapacity的变量中并创建一个新的容量 newCapacity,该变量的值就是扩容的核心原理,其中 int newCapacity = oldCapacity + ((capacityIncrement > 0) ? capacityIncrement : oldCapacity);这行代码,用了一个三元表达式,表达式会先判断 capacityIncrement的值是否>0,如果成立,那么 capacityIncrement的值保持不变,那么整个表达式就是 newCapacity = oldCapacity+capacityIncrement

否则将会是 newCapacity =oldCapacity+oldCapactity,也就 newCapacity 会变为原来两倍的容量,最后依旧是采用 copyOf()方法将扩容后的空间复制到原空间,完成扩容。关于其中两个if判断的逻辑和之前对 ArrayList的分析是类似的,不再赘述。通过,这个源码也发现了,这个2倍扩容的算法中,有一个名为 capacityIncrement的容量增量,具体作用面会在下面无参构造器中进行分析。

有参构造器源码分析

该构造器的源码如下,可以看到,这个构造器是有两个参数的,其中一个便是上面提到的容量增量参数 capacityIncrement

代码语言:javascript
复制
public Vector(int initialCapacity, int capacityIncrement) {
    super();
    if (initialCapacity < 0)
        throw new IllegalArgumentException("Illegal Capacity: "+
                                           initialCapacity);
    this.elementData = new Object[initialCapacity];
    this.capacityIncrement = capacityIncrement;
}

首先,方法体首先会先调用父类的无参构造。如果我们不指定 capacityIncrement的值,它默认是0,也就是无增量,一般在调用无参构造器时就是属于这种情况,在没有明确容量增量时,扩容会按照原容量的2两倍进行,如果指定具体的值,我们在 grow()方法中看到, int newCapacity = oldCapacity + ((capacityIncrement > 0) ? capacityIncrement : oldCapacity);这个表达式将产生一个新的容量值,该值的大小由原来的容量+指定的容量增量决定。那么可能会开始疑惑, 既然说了是2倍扩容,那么加一个容量增量算怎么回事?如果指定了该增量的值,不就改变了2倍扩容的机制了吗?其实不完全是,在 vector源码中,其实存在三个构造器,上面这个便是可以指定扩容增量的构造器,如果你不需要指定第二个参数,那么还可以看到,它还有一个普通的带一个参数的构造,源码如下:

代码语言:javascript
复制
public Vector(int initialCapacity) {
    this(initialCapacity, 0);
}

该构造器尽管只需要一个参数,但它在创建之后会默认给 capacityIncrement赋值为0,这也就是不管你是空参构造还是带参构造对 Vector进行初始化,在扩容时都会用到 capacityIncrement这样一个参数,这也是扩容算法中三元表达式的意义,你可以不写,但我必须得用,这是不冲突的。

代码语言:javascript
复制
/*无参构造*/
Vector vector = new Vector();
/*指定一个参数:默认为集合的初始大小*/
Vector vector2 = new Vector(6);
/*指定两个参数:依次时集合大小和扩容时的容量增量*/
Vector vector3= new Vector(6,3);
LinkedList
基本结构
  • LinkedList底层实现采用了双向链表双端队列
  • 可以添加任意元素且元素可以重复(因为实现自List接口),同时包括 null
  • 非线程安全的集合,没有实现同步。
  • 在其中维护的两个属性 firstlast分别指向首尾节点,prev指向前驱节点,next指向后继节点。
  • 因此 LinkedList的元素删除和添加的操作效率相对较高。
源码分析

[待更新……]

如何选择

如何选择使用 ArrayListLinkedList?根据我们实际的使用场景或者需求

  • 如果涉及改查操作比较多,建议 ArrayList
  • 如果增删操作比较多,建议 LinkedList
  • 一般来说,在程序中80%~90%都是查询操作,因此大部分情况下会选择使用 ArrayList
  • 当然,选择哪一个并非一成不变,在实际的项目中,甚至可能出现 ArrayListLinkedList同时使用的情况,也是正常的,所以要求最好都会使用。
Set接口

下面主要讲解 Set子接口下的主要实现类。

常用方法和实现

Set的基本介绍

  • 无序(元素的添加与取出的顺序不一致),无索引。
    • 注意理解这里的无序的含义,并不是说,每一次取出的顺序都是随机的,而是指当执行了一次取出之后,今后的每一次相同的操作,它取出的元素顺序都与第一次相同,但这个顺序又和添加进去的顺序不保持一致。
  • 不允许重复元素。
  • 还有一点,由于它是 Collection的子接口,自然也支持其父接口中的特性,比如迭代对象,增强for等等。
HashSet
基本结构

HashSet作为 Set典型的实现类之一,拥有 Set的全部属性,这里不再赘述。

源码解读
初始化与基本原理

我们先看一下 HashSet的基本用法,其实,HashSet在实现上,就是一个 HashMap,这一点可以从它的构造器说起。

代码语言:javascript
复制
Set hashSet = new HashSet();

以下是 HashSet的无参构造器源码,可以看到,当我们创建一个 HashSet对象时,它在底层直接 new了一个 HashMap,这又不得不说一下 HashMap的底层,它是由数组+链表+红黑树构成,所以相对来说是比较复杂的。

代码语言:javascript
复制
public HashSet() {
    map = new HashMap<>();
}

换句话说,要分析 HashSet的源码,其实就是分析 HashMap的原理。HashSet一个明显的特点就是不能添加重复的元素,但这里的重复也许不是你想象中的那么简单。

下面开始分析一下其中的添加元素的 add()方法在底层是如何实现的。

  • 添加一个元素时,会先得到一个 hash值,根据该值转成一个索引值。
  • 找到存储数据表 table,检查该索引是否已在 table中存在有元素
    • 如果没有,直接将该元素加入。
    • 如果有,会调用 equals方法进行比较操作,如果比较结果为 true,添加失败,否则,将会在末尾添加元素。
    • Java8中,如果一条链表的元素个数达到 TREEIFY_THRESHOLD,且 table的大小 >=MIN_TREEIFY_CAPACITY,就会触发树化机制,即会由单链表结构转换为一棵红黑树。

为例更好的理解它的执行过程和原理,我们按照下面这几行代码来讲解:

代码语言:javascript
复制
HashSet hashSet = new HashSet();
hashSet.add("Java");
hashSet.add("C++");
hashSet.add("Java");
System.out.println("hashSet="+hashSet);

代码逻辑很简单,就是向 HashSet中添加三个元素,其中有两个元素是重复的,这是为了理解它底层是如何判断元素重复的。

执行代码,首先会进入 HashSet的构造器,直接创建一个值为空的 HashMap,这点在上面已经说过。进入 add()方法,它的实现源码如下:

代码语言:javascript
复制
//HashSet中add方法的源码实现
public boolean add(E e) {
    return map.put(e, PRESENT)==null;
}

方法很简单,直接调用了 mapput()方法,注意到,这个方法除了我们需要添加的元素 e之外,它还有一个名为 PRESENT的常量参数,关于这个参数的理解,源码中是这样介绍的:

代码语言:javascript
复制
// Dummy value to associate with an Object in the backing Map
private static final Object PRESENT = new Object();

PRESENT其实是一个 static final修饰的对象,在上面的方法中作为 put(k,v)value的占位,并没有其他实际的作用。这里不必深究。不知道有没有注意到,add方法中将 map.put(e,PRESENT)==null作为返回值,为什么这里会是 null呢?这和 HashMap底层实现有关系,我们先继续往下,后面自然就会明白了。下面进入 put方法内部看看。

代码语言:javascript
复制
//HashSet中map.put(e,PERSENT)方法源码
public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

方法中有一个 putVal的方法,可以看到其中的第一个参数 hash(key)表示通过这个方法获取 key的hash值,这里的 key,也就是我们传入的待添加的元素,进入该方法:果然,hash方法的作用就是计算 keyhash值,算法都在这条三元表达式当中了,可以简单看一下。

代码语言:javascript
复制
//HashMap中的hash()方法实现
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

核心算法 (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16),意思就是如果传入的 keynull,那么直接返回0,也就是不执行任何实际操作。否则会使用 hashCode()方法获取 key的哈希码和无符号右移16位之后的值进行一个异或操作,将该异或的结果返回作为 key最终的 hash值。这样作主要是为 了保证高16位和低16未的特征,减少碰撞,减低 hash冲突的几率。

注意,hashhashCode并不是一回事。

这里的hash仅仅是用来在HashMap中计算key对应的散列码,它的算法中用到了hashCode()这个方法,hashCode是在Objct中定义的,用来获取每个元素对应的散列值,底层使用的C语言作为实现。换句话说,使用hashCode()方法可以计算Java中每一个元素的一个哈希值,它的作用合理不再深入,而hash()方法在这里的作就相对局限,使用的算法也相对简单很多,具体的关于hash和hashCode()的内容,可以自己研究,这里不作张开。

理解了 hash的计算方式之后,继续往后看,在获取到 keyhash,方法会返回进入到 putVal()方法中,这是整个添加操作的底层实现的核心源码,也是一个难点。

代码语言:javascript
复制
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {
        Node<K,V> e; K k;
        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) {
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount;
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

在方法体的开始,定义了几个局部变量,以备后面使用(废话)。继续看下面的代码,这是方法体的第一个if判断,主要的作用是判断并创建一个 table,注意,这里的 table是一个数组+链表形式的结构,也就是数组每一个索引出的元素都是一条单链表的形式。这一点前面有提到。具体的逻辑是,

代码语言:javascript
复制
if ((tab = table) == null || (n = tab.length) == 0){
     n = (tab = resize()).length;
}

首先,对于 if中的 (tab = table) == null 条件,程序先将 table赋值给 tab变量,判断集合中是否已经存在 table数据,或者说该数组的长度是否为0。如果上述条件有一个成立,则表示这是第一次向集合中添加元素,hashMap会自动调用 resize()方法对 table[]进行首次扩容,以用来存放接下来的元素,所以,明白了这个判断的作用,也就不难推测,为什么这条 if判断语句会放在方法的开始了,也可以推测,只要不是首次添加元素,就不再会进入该判断,直接走后面的逻辑。那么现在的关注点就该转移到这个 resize()方法中,看一下它的源码:

代码语言:javascript
复制
final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table;
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;
    int newCap, newThr = 0;
    if (oldCap > 0) {
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // double threshold
    }
    else if (oldThr > 0) // initial capacity was placed in threshold
        newCap = oldThr;
    else {               // zero initial threshold signifies using defaults
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
    threshold = newThr;
    @SuppressWarnings({"rawtypes","unchecked"})
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;
    if (oldTab != null) {
        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
                                loTail.next = e;
                            loTail = e;
                        }
                        else {
                            if (hiTail == null)
                                hiHead = e;
                            else
                                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;
                    }
                }
            }
        }
    }
    return newTab;
}

那就看下它的扩容原理吧。首先呢,如前面说所,它开始就创建了一个 table[]。将该数组的引用赋给 oldTab,

代码语言:javascript
复制
Node<K,V>[] oldTab = table;

注意这里这个数组首次定义并非在这个方法中,而是在 HashMap源码中有做的一个定义,如下:它也是不可被序列化的。接着会先判断该 table是否是首次创建,如果是,直接初始化为0,否则就是 oldTab的大小,为什么会这么说呢,因为这个 resize()方法可不只是执行这一次,在 putVal()方法的后续的逻辑中还会用到,也就是会出现再次扩容的情况,那么存在一个 oldTab的值也就不难理解了吧。

如果 oldCap>0,进一步判断它是否>=最大容量 MAXNUM_CAPACITY,关于 MAXNUM_CAPACITY的定义如下

代码语言:javascript
复制
//MAXIMUM_CAPACITY定义
static final int MAXIMUM_CAPACITY = 1 << 30;

如果该条件成立,会给 threshod重新赋一个新的容量值,即 Integer的上限,反之进入下一个判断 ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&oldCap >= DEFAULT_INITIAL_CAPACITY),将 oldCap左移1位,也就是两倍的 oldCap赋给一个新的变量 newCap,如果该值小于 MAXNUM_CAPACITY并且原来的容量 oldCap大于等于初始默认容量值 DEFAULT_INITIAL_CAPACITY的话,就将新的 newThr扩为原来(oldThr)的两倍大小。

代码语言:javascript
复制
//DEFAULT_INITIAL_CAPACITY定义
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
//1<<4等价于1X2^4=2x2x2x2=16

这里先对上面源码中涉及到的几个变量简单说明一下不然频繁的 =赋值操作一波又一波,可能会给整懵圈。

  1. oldCap:数组原先(准备扩容之前)的容量。
  2. oldThr:其实就是 threshold的一个暂存局部变量,用来暂存 threshold的值。
  3. threshold:这是一个定义在 HashMap的的全局变量(可以这么说,实际上 Java中没有全局变量这种概念),它用来存放 table[]的一个容量值,或者说阈值。所以最终决定是否需要扩容取决于这个全局变量来判断。
  4. newCap:同理于 oldCap

继续回到最外层 if判断的 else if逻辑中,这里先是对 newThr是否大于0作了判断,如果 >0成立,那么新的容量 newCap的值沿用 oldThr,否则将会执行下面这段代码,newCap的值默认设置为 DEFAULT_INITIAL_CAPACITY也就是 16,并且 newThr的值更新为 (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);。这里参与 *运算的除了初始默认容量 DEFAULT_INITIAL_CAPACITY外,还有一个重要的常量参数 DEFAULT_LOAD_FACTOR,我们称为负载因子,换句话说,这个因子的值决定了你每次扩容的具体大小。它是默认值为 0.75,也就是说当我们数组占用量达到本身容量的75%时,就会触发首次扩容(resize)操作。当然,最后还进行了强制类型转换为 int

所以不难理解,如果我们是首次使用 HashMap进行 put操作,方法会直接进入这一步进行初始化。

代码语言:javascript
复制
else {// zero initial threshold signifies using defaults
    newCap = DEFAULT_INITIAL_CAPACITY;
    newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}

具体一点说,当阈值达到 16*0.75时,也即是16大小的容量用掉了12个大小时就会触发首次 resize

这个负载因子不是固定不变的,而且有一点需要说明的是,这个 resize()方法中,负载因子是可以手动传入的,这一点在 HashMap的另一个构造方法中有体现,当然,这个后面再说,这里主要讲的还是无参构造器的执行原理,你需要理解 resize()方法的两个主要作用,第一个就是上面巴拉巴拉这一堆,主要是用来对数组进行初始化工作(当然,你也可以理解为首次扩容,这只是一种说法而已,一般我们会将首次扩容称为初始化,因为其实扩容的概念是建立在已有容量的基础上的),而此后再调用 resize()就执行的是扩容工作了,但它的扩容工作可没有初始化这么简单。

但为了能更清晰的理解,我们还是继续首次 put操作的主线进行分析。接着上面说,初始化结束之后,会得到一个初始的阈值 newThr=16,并将该阈值重新赋给全局 threshold保存。计算出 table[]的一个初始大小之后,利用该值直接创建一个大小为 newCap的新的 newTabtable返回,有了这个 table,我们就可以在里面存放元素了,比如存放一个字符串 Java

代码语言:javascript
复制
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;

但光是初始化一个16大小的 table是远远不够的。我们知道,既然是数组里面存放元素,是需要一个索引的,根据这个索引去找到一个对应的位置,再将该元素覆盖上去,完成元素的添加。

所以我们先回到上一个方法 putVal()方法:接着上面切入进来的 resize()方法之后讲解。

代码语言:javascript
复制
if ((tab = table) == null || (n = tab.length) == 0)
    n = (tab = resize()).length;

上面这两行代码,也就是我们上面刚刚讲完的初始化操作的部分。

看一下,在对 table进行了初始化,并计算得到 keyhash之后,后续的代码逻辑分解:

代码语言:javascript
复制
if ((p = tab[i = (n - 1) & hash]) == null)
    tab[i] = newNode(hash, key, value, null);

这里就是根据 hash值计算一个索引 i。方法是 (n-1) & hash,在获得索引之后,检查该索引位置的值 tab[i]赋给变量 p并判断是否为 null,如果为 null表示没有被使用,后面一句 tab[i] = newNode(hash, key, value, null);直接将元素 key存进去,当然,存入的元素除了我们自己传入的数据之外,还有计算出来的 hash和一个 value,传入 hash主要是为了下一次计算,用来确定下次传入的值是否为重复元素。至于其中还有一个值为 null的值,表示链表的下一个结点指向,当然,这里是首次 put,所以不存在 next是不存在的,也就是 null。当上面这段代码执行完毕之后,元素就被成功添加到 table中了。

通过 debug可以看到,key计算出的 hash=2301537.那么这个索引就可以根据 (16-1)&2301537计算出来,它的值是为 1的,也就是数组中第二个位置的索引。

元素添加之后,程序逻辑会直接执行到下面的代码

代码语言:javascript
复制
++modCount;
if (++size > threshold)
    resize();
afterNodeInsertion(evict);
return null;

其中的 ++modCount我在 ArrayList源码分析的文章中已经提过,他们的作用是一样的。判断 if (++size > threshold),如果添加元素之后的数组容量 >目前的阈值 threshold,会触发 resize()。关于 afterNodeInsertion(evict);方法,是 HashMap留给它的子类去实现的一个方法,所以它是个空的方法。类似的方法还有:

代码语言:javascript
复制
// Callbacks to allow LinkedHashMap post-actions
void afterNodeAccess(Node<K,V> p) { }
void afterNodeInsertion(boolean evict) { }
void afterNodeRemoval(Node<K,V> p) { }

接着 putVal()方法最后返回一个 null作为方法的结束。所以还记得前面留的一个问题吗?在 HashSet源码中的 add()方法的方法体里面,它的返回值是判断是否为 null,再看一下吧还是。

代码语言:javascript
复制
public boolean add(E e) {
    return map.put(e, PRESENT)==null;
}

表示执行 HashSet中的 add()方法添加一个元素,它底层实际上调用了 HashMap中的 put()方法去实现,能否添加成功的依据就是该 put()方法是否返回 null,如果是,HashSetadd()方法就返回一个 true,最终表示着我们利用 HashSet成功的添加了一个元素。否则,添加失败!!

去重原理

在理解了 HashMap底层 table[]的初始化逻辑之后,当我们向其中 put()第二个元素时,它的底层是如何判断元素是否重复的呢?下面就以这个问题为主线开始分析。

由于同样是添加的操作,前面的的几个步骤就不再赘述,比如底层调用 map.put(),然后是 hash的计算。直接进入 putVal()方法开始看。这里再贴一遍它的源码:

代码语言:javascript
复制
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {
        Node<K,V> e; K k;
        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) {
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount;
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

鉴于前面我们在添加第一个元素 Java的时候,已经完成了 table[]的初始化工作,所以下面这段代码不会再执行;

代码语言:javascript
复制
if ((tab = table) == null || (n = tab.length) == 0)
    n = (tab = resize()).length;

而是直接带着前面计算得来得 Hash通过与之前同样得算法计算出元素 C++(假设这是我们第二个添加的元素)在数组中的索引,代码如下:

代码语言:javascript
复制
if ((p = tab[i = (n - 1) & hash]) == null)
    tab[i] = newNode(hash, key, value, null);

注意哈,这里的 n在初始化的时候已经计算出来,还是等于16的,改变的是 hash值,假设为 65762。那么根据上述算法计算得到它的索引为 15&65762=2

好了,既然索引也有了,并且我们添加的这个元素和第一个元素 Java明显是不相等的,所以不会进入到 else if判断中,因为 if已经成立,后面的逻辑就是将该元素值直接添加到数组中索引为 2的位置,当然,元素也是一个 Node<k,v>类型。注意,这里存入参数中的最后一个值依旧还是 null,因为前后两个元素并没有存放在同一条链表上,自然不会出现在尾部挂载的情况。

下面将会进行第三个元素的添加,假设我们添加的元素是 Java,是的,和首次添加的元素是相同的,看一下底层将会如何处理吧。

同样我们直接跳到 putVal()方法中。程序首先会进入到第二个 if判断里,开始计算索引并作判断,也就是下面这段代码:

代码语言:javascript
复制
if ((p = tab[i = (n - 1) & hash]) == null)
    tab[i] = newNode(hash, key, value, null);

注意了,由于首次计算得出 Java对应的索引为 2,那么这次的结果也是相同的值,所以 if中的条件显然不可能成立,因为索引为2的位置已经被占用,自然不会为 null。所以程序将会进入下面的逻辑中:

代码语言:javascript
复制
else {
    Node<K,V> e; K k;
    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) {
            if ((e = p.next) == null) {
                p.next = newNode(hash, key, value, null);
                if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                    treeifyBin(tab, hash);
                break;
            }
            if (e.hash == hash &&
                ((k = e.key) == key || (key != null && key.equals(k))))
                break;
            p = e;
        }
    }
    if (e != null) { // existing mapping for key
        V oldValue = e.value;
        if (!onlyIfAbsent || oldValue == null)
            e.value = value;
        afterNodeAccess(e);
        return oldValue;
    }
}

又是一堆 if else if套娃操作。按照它的顺序,我们先分析第一个 if的逻辑:

代码语言:javascript
复制
if (p.hash == hash &&((k = p.key) == key || (key != null && key.equals(k))))
    e = p;

鉴于 ()中涉及到了三处逻辑运算,方便理解,我们将它逐层进行拆分讲解。首先分析其中的

代码语言:javascript
复制
(k = p.key) == key || (key != null && key.equals(k))

首先看 ||的左边 (k=p.key)==key:意思是先将 p中的 key值赋给变量 k,再与 key进行一个比较,判断是否为同一个 key值(对象),注意了,这里的两个 key的意思,前一个 key(也就是 k)代表的是数组之前已经存在数组中的元素,而后一个 key就是当前传入的元素,具体的也就是指我们第一次存入的 Java和本次存入的 Java

再看 ||右边 (key != null && key.equals(k),这是一个 &&操作需要操作符两边的条件同时成立,整个条件才会成立。首先判断存入的 key是否为 null,再判断 keyk是否为相同(注意这里用了 equals()方法,该方法可被重写),判断是否为相同的内容。回到外层的 p.hash==hash这个判断,就是将已有索引处对应的元素(元素是存在 Node上的)的 hash值取出与当前元素的 Hash进行比较。

所以归纳起来也就是当二者 hash相同并且 key也相同(同一个对象)的情况下,执行 e=p赋值操作,将原位置的值进行覆盖。

如果上面的条件不成立,会判断 p是否是红黑树,如果是,就调用对应的添加方法 putTreeVal()进行添加,也就是下面的代码:这里的 instanceof关键字用来判断一个对象是否为一个类的实例。另外,putTreeVal()方法内部涉及到大量红黑树的代码,相对复杂很多,如果跳进去的话,估计一时半会出不来,所以这里暂时不作探究,会另外分开来学习,还是围绕着主线继续分析。

代码语言:javascript
复制
else if (p instanceof TreeNode)
    e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);

否则,进入 else逻辑中:

代码语言:javascript
复制
else {
    for (int binCount = 0; ; ++binCount) {
        if ((e = p.next) == null) {
            p.next = newNode(hash, key, value, null);
            if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                treeifyBin(tab, hash);
            break;
        }
        if (e.hash == hash &&
            ((k = e.key) == key || (key != null && key.equals(k))))
            break;
        p = e;
    }
}

开局一个 for,目的明确,既然上面两种情况都不成立,那么说明该元素可能会在某一条链表节点上出现,比如下面这样:

代码语言:javascript
复制
Java->C++->Javascript->Java

所以我们需要以遍历的方式去检查链表上的每一个节点,循环内部,通过 pe两个指针不停的循环比较

如果过程中发现有一个和当前元素重复的元素,循环会立即结束,元素添加失败,否则就将当前元素直接挂到节点后面,完成添加。注意其中这段代码:

代码语言:javascript
复制
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
    treeifyBin(tab, hash);
//TREEIFY_THRESHOLD的定义
static final int TREEIFY_THRESHOLD = 8;

这是在进行添加之后对当前这条链表进行一个判断,如果长度 >=(TREEIFY_THRESHOLD=8)-1的话,会调用 treeifBin()方法对当前链表进行树化(转红黑树),但是注意,光是这个条件满足还不足以开始树化,在这个方法的实现中,还添加了其他的添加用来判断,treeifBin()源码:

代码语言:javascript
复制
final void treeifyBin(Node<K,V>[] tab, int hash) {
    int n, index; Node<K,V> e;
    if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
        resize();
    else if ((e = tab[index = (n - 1) & hash]) != null) {
        TreeNode<K,V> hd = null, tl = null;
        do {
            TreeNode<K,V> p = replacementTreeNode(e, null);
            if (tl == null)
                hd = p;
            else {
                p.prev = tl;
                tl.next = p;
            }
            tl = p;
        } while ((e = e.next) != null);
        if ((tab[index] = hd) != null)
            hd.treeify(tab);
    }
}

就是说,就算前面的条件(>=8)已经成立,这里还会进行一个判断,具体逻辑如下:

代码语言:javascript
复制
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
    resize();

它还会判断当前这个 table的大小是否 <MIN_TREEIFY_CAPACITY也就是是否 <64。如果这个条件成立,那么会先对数组进行一个 resize()扩容操作,而不是直接转红黑树。最后如果添加失败,会返回一个之前元素的 value值。

代码语言:javascript
复制
if (e != null) { // existing mapping for key
    V oldValue = e.value;
    if (!onlyIfAbsent || oldValue == null)
        e.value = value;
    afterNodeAccess(e);
    return oldValue;
    }
}
扩容原理

前面对整个流程有了大致的了解之后,下面主要针对它的扩容原理进行一个简单的总结。

关于扩容的原理,先说结论:

  • HashSet底层是 HashMap,首次添加时,table数组的容量扩为16,初始临界值为12:
    • threshold(阈值) = table.size()(table数组大小) * loadFactor(加载因子) =16*0.75 =12
  • 如果 table数组使用的部分达到了阈值,就会触发扩容,具体的扩容为 16*2=32,也就是会按照两倍的扩容方式进行,基于这个容量,再次计算新的扩容阈值:32*0.75=24,也就是如果本次扩容后的容量(32)使用达到24之后,就会再次触发下一次的2倍扩容机制,以此类推。

简单来说,以上就是 HashSet(本质HashMap)的扩容原理,具体的,看下面源码分析。

在resize()方法中有这样一段代码

代码语言:javascript
复制
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
         oldCap >= DEFAULT_INITIAL_CAPACITY)
    newThr = oldThr << 1; // double threshold

可以看到,新的容量是在原有容量的基础作了一个左移的操作,也就是个乘2是等效的,但用位运算效率会快很多。

这是在pustVal()源码的部分代码:

代码语言:javascript
复制
++modCount;
if (++size > threshold)
    resize();
afterNodeInsertion(evict);
return null;

resize();就是触发扩容时调用的扩容方法。具体的源码前面有讲过,不再赘述。

在Java8中,如果一条链表的元素个数达到 TREEIFY_THRESHOLD且此时 table的大小>=MIN_TREEIFY_CAPACITY

时就会触发链表转红黑树的操作。提高性能。

上面涉及到的两个常量在源代码中的定义如下:

代码语言:javascript
复制
static final int TREEIFY_THRESHOLD = 8;
static final int MIN_TREEIFY_CAPACITY = 64;

转红黑树的方法源码如下,这里只需要看看大致的执行逻辑就好,关于红黑树具体的实现,不是本章的主要内容。

代码语言:javascript
复制
final void treeifyBin(Node<K,V>[] tab, int hash) {
    int n, index; Node<K,V> e;
    if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
        resize();
    else if ((e = tab[index = (n - 1) & hash]) != null) {
        TreeNode<K,V> hd = null, tl = null;
        do {
            TreeNode<K,V> p = replacementTreeNode(e, null);
            if (tl == null)
                hd = p;
            else {
                p.prev = tl;
                tl.next = p;
            }
            tl = p;
        } while ((e = e.next) != null);
        if ((tab[index] = hd) != null)
            hd.treeify(tab);
    }
}

注意如果只是满足链表长度达到8的条件时,它还是会采用 resize()方法对数组扩容,而不是直接转红黑树。

注意了!!

上面提到的触发数组扩容的条件中,size的大小大于负载因子才会触发,这里的size指的是数组和链表中元素的和,也就是只要我们向其中添加一个元素,不论这个元素是存在数组第一个位置,还是存在链表中某个位置,size都会自增1,这是一个比较容易搞错的地方,不要认为size就是指数组的长度,这是错误的。

为什么不直接使用 hash来计算索引,而是要进行取模运算?

如果将哈希码映射到数组中的一个索引。可能会因为 hash值过大而因此导致索引超出范围。所以一个最简单的方法是对哈希码和数组的长度进行模运算,如hash(key) % n。如此可以确保索引i总是在0和n之间。

但是Java在实现的时候,用的并不是上面说的算法,而是将数组的长度n减去1之后再与 hash&运算得到,实现代码如下:

代码语言:javascript
复制
i = (n - 1) & hash;
LinkedHashSet
概述
  • LinkedHashSetSet接口的一个实现子类,也是 HashSet的子类。
  • LinkedHashSet的底层是一个 LinkedHashMap,底层维护了一个数组+双向链表。
  • LinkedHashSet根据元素的 hashCode值来决定元素的存储位置,同时使用链表来维护元素的次序,这就使得元素看起来是以插入的顺序保存的。
  • 其次,LinkedHashSet也不允许添加重复元素。

LinkedHashSet中维护了一个hash表和双向链表,每一个节点有pre和next属性,这样可以形成双向链表,在添加元素时,先求 hash值,再求索引,确定该元素再哈希表中的位置,然后将添加的元素加入到双向链表(如果已经存在,不添加(原理和 hashset类似))

源码解读
  • 通过下面的示例来配合讲解:
代码语言:javascript
复制
Set set = new LinkedHashSet();
set.add("A");
set.add(120);
set.add(120);
set.add(new User("李",1001));
set.add(123);
set.add(new String("hello"));
System.out.println(set);

如果断点的方式,我们可以看到,LinkedHashSet的一个基本结构如下:

通过上图可以发现,其中存在一个 tailhead的属性,这是典型的双向链表中才会用到的两个引用,或者指针(c/c++),代表双向链表的头尾指针。即进一步验证了前面提到的 LinkedHashSet的是一个 HashTable和双向链表的组合。

其中的 table类型其实是一个 HashMapNode[]类型,而每一个节点又是维护的 LinkedHashMapEntry[]类型。

为什么数组为 HashMapNode[]数组类型而存放的元素却是 LinkedHashMapEntry[]类型? 说明 LinkedHashMapEntry[]肯定继承或者实现了 HashMapNode[]的,即通过数组多态的方式实现。注意这里的 符号标识之后的类作为之前的一个静态内部类,也即表示在LinkedHashMapEntry 中,Entry 是LinkedHashMap的一个静态内部类。

LinkdeHashMap中我们可以找到对应的源码验证。

代码语言:javascript
复制
static class Entry<K,V> extends HashMap.Node<K,V> {
    Entry<K,V> before, after;
    Entry(int hash, K key, V value, Node<K,V> next) {
        super(hash, key, value, next);
    }
}

从上述的源码中不仅说明 Entry是 LinkedHashMap的内部类,也说明 LinkedHashMapEntry[]继承了 HashMapNode[]。

其中有两个 Entry<K,V>类型的属性:beforeafter,可以理解为两个引用,主要用来完成各节点之间的连接。

  • 同样,我们也可以通过查看 HashMap的源码,验证上面的说法:Node同样作为其一个静态的内部类实现。并且该类还实现了其父接口 Map中的 Entry<K,V>
代码语言:javascript
复制
static class Node<K,V> implements Map.Entry<K,V> {
    final int hash;
    final K key;
    V value;
    Node<K,V> next;

    Node(int hash, K key, V value, Node<K,V> next) {
        this.hash = hash;
        this.key = key;
        this.value = value;
        this.next = next;
    }

在存储元素时,每一个元素中依然是使用的 key来存储,而 value只是一个 object类型的占位符,这里没有实际的意义。因为在 LinkedHashSet中,我们不需要显式的去像Map中存一个 K,V形式的值。

  • 当我们添加一个重复元素时,LinkedHashSet会直接调用父类 HashSet中的比较方法,对重复元素进行一个判断并去重,其实这里的原理和之前讲的 HashSet原理是一样的,当添加元素是,LinkedHashSet还是会直接调用父类 HashSet中的 add()方法(该方法本质还是调用 HashMap中的 put()方法),接着是 putVal()关于这两个方法的源码在前面讲 HashSet源码的时候就已经讲过,这里不再赘述。所以说,经管是不同的结构实现,但在元素判重的原理上其实使用的还是同一个逻辑。

说白了,LinkedHashSet本质上大部分还是HashMap

  • LinkedHashset底层维护了一个 LinkedHashMap结构,这一点可以类比于 HashSet底层维护一个 HashMap来进行对比记忆。而前面我们已经知道,LinkedHashMap其实就是 HashMap的一个子类。
  • 对于 LinkedHashSet我们在理解了前面 HashSet源码的基础上,只需要理解它底层的一个实现结构即可,也就是数组+双向链表的结构,回到一开始的哪个示例中,我们向set集合中添加了:

“A”,120, User,123

之后,通过断点的方式可以看到他们之间的一个指向关系如下

上图展示了内部元素节点中 afterbefore的引用关系。仔细观察每一个 LinkedHashMap$Entry后都会跟一个 @number的标识,这是用来标识该位置元素的一个唯一标记,或者你也可以理解为该元素在该结构中的一个地址,他们,因此,我们可以用该标识来唯一性的代表一元素值,注意其中每一各 after或者 before的指向关系,具体在后面我回画个图帮助理解。

  • 再跳出元素内部 Entry,我们看到在 table中有两个名为 headtail的引用属性。用来标识该双向链表的头尾节点。

将上面的逻辑以图片的形式展示出来大概就是下面这样:

简单捋一下:

  • 每向 LinkedHashSet中添加一个元素,首先回根据该元素计算一个 hash值,用来确定它在上面图中 table数组中的索引位置。
  • 通过上面的步骤添加多个元素之后,元素内部是一个 Entry[]类型的结构,其中每一个元素都有一个 afterbefore属性,用来指向它的前一个元素和后一个元素的位置。
  • 再通过两个属性 headtail来指向整个链表的头和尾,从而构成一个完整的含有头尾指针(引用)的双向链表。
  • 将该链表具象化出来可以大致表示为图中右边部分。换句话说,这里的 afterbefore其实就相当于平时常用的 prenext指针,即前驱后继指针,只不过命名不同而已,没什么高深莫测的。
  • 注意,和前面 HashSet的数组+单链表的结构类似,每一个索引位都可以是一条完整的双向链表,就像图中索引为7的位置一样,而不是每个索引为只能有一个链表节点,这取决于元素计算出来的 hash
  • 正是由于双向链表的特性,使得我们添加的元素顺序是相对有序的,也就是添加的顺序和打印出来的顺序是一样的。

关于扩容

首先,LinkedHashSet如果使用无参数构造器初始化,那么它默认回开辟一个16大小的空间,负载因子依旧是 0.74,首次扩容的阈值为12。这写数值是不是很眼熟?如果你看了前面 HashSet的源码分析的话。

代码语言:javascript
复制
public LinkedHashSet() {
    super(16, .75f, true);
}

虽然讲的是 LinkdeHashSet,但本质上分析的还是 HashSet,再本质就是 LinkedHashMap,再继续套娃你会发现,就是讲的 HashMap,可见这家伙才是主角。

本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2022/08/25 ,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 更新日志????
  • IDEA快捷键
  • 概览
    • Collection系
      • Map系
      • 细则
        • Collection
          • List接口
          • ArrayList
          • Vector
          • LinkedList
          • 如何选择
          • Set接口
          • HashSet
          • LinkedHashSet
      相关产品与服务
      容器服务
      腾讯云容器服务(Tencent Kubernetes Engine, TKE)基于原生 kubernetes 提供以容器为核心的、高度可扩展的高性能容器管理服务,覆盖 Serverless、边缘计算、分布式云等多种业务部署场景,业内首创单个集群兼容多种计算节点的容器资源管理模式。同时产品作为云原生 Finops 领先布道者,主导开源项目Crane,全面助力客户实现资源优化、成本控制。
      领券
      问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档