Java集合框使用手册

673 阅读29分钟

这篇文章主题是介绍Java集合框架的, 但是首先我们来介绍一下另外几个api, 这几个api也是我们在进行开发时经常需要用到的, 有几个使用时需要注意的地方, 也在这里给大家分享一下.

一.Object类

Object类是Java中所有类的父类, 所有的类都会继承Object类. 子类可以使用Object类的所有方法. 下面逐一介绍Object类的三个比较重要的方法, 也是平时使用的比较多的方法

equals(Object obj)方法

equals(Object obj)方法用来比较对象是否相等, 既要比较对象的值, 也要比较对象的地址, 二者都相等才会返回true. 实际上在它的底层是使用的 "==" 做的判断, 因为使用 "==" 就需要对值和地址均进行比较.

public boolean equals(Object obj) {
    return (this == obj);
}

hashCode()方法

对象调用hashCode()方法会返回该对象的哈希值(十进制的堆地址值), 转换成十六进制就和地址值一模一样了.

Object obj = new Object();
int addr = obj.hashCode();
Integer.toHexString(addr);//将十进制转换为十六进制

toString()方法

toString()方法会返回一个对象的字符串表现形式, 对于我们创建的类, 由于默认会继承Object类, 我们可以重写其toString()方法, 使对象在被输出的时候, 展现其内部的一些信息, 例如:

public class Person{
    //成员变量
    private String name;
    private int age;
    //构造方法
    public Person(String name , int age) {
        this.name = name;
        this.age = age;
    }
    //重写toString()方法
    public String toString(){
        return ("姓名:"+name+",年龄:"+age+",性别:"+sex+",身高:"+height);
    }
}
main(String[] args) {
    Person p = new Person("小明" , 18 , "男" , 180);
    System.out.println(p); // 默认调用 p.toString()方法, 等效于System.out.println(p.toString());
}

二.String类

String, 字符串类型, 是不可变类, 不可变类的意思是指它一旦被初始化, 就不能再被修改了. 但是在我们平常的使用中, 好像String类型的变量可以重新赋值, 我们重新赋值的时候也不会有问题, 这是为什么呢? 以下面这个例子为例:

String str = "abc";
s = "def";

上面这段代码可以发现, 看上去s好像是被修改了, 但是实际上如果我们使用输出s.hashCode()可以发现, 它们的堆地址值已经发生了改变, 即通过赋值, 是将变量s指向了新的地址值. 那么原来的 "abc" 字符串有没有发生改变呢? 我们来看一下String类的源码

public final class String
    implements java.io.Serializable, Comparable<String>, CharSequence {
    // ...
    private final char[] value;
    // ...
}

可以看出来, String类被 final 关键字修饰, 表示它不能再被继承, 另外在它的内部声明了一个 Char[] 类型数组value, 这个 Char[] 类型数组就是用来保存字符串的值的, 可以看出来它也被 final 关键字修饰了, 意味着这个字符串一旦被赋值, 其地址就是不可变的, 也就再也不能被重新赋值.

String类常用方法

  1. equals(String str)和equalsIgnoreCase(String str)方法, String类是重写了Object的equals方法的, 使用它可以比较String类的值, 不会比较地址; 而equalsIgnoreCase(String str)方法则会忽略大小写的不同并且只比较值的大小.
  2. 构造方法: String类使用构造方法时, 是从堆内存中开辟了空间的, 所以只要使用了new, 然后再用 == 再进行比较, 堆内存地址就不一样, 只会返回false.
String str1 = "abc";
String str2 = "abc";
// str1 和str2 都是指向常量池中的'a' 'b' 'c' 组成的字符串的地址
System.out.println(str1==str2); // true
System.out.println(str1.equals(str2)); // true

String mes1 = new String("abc");
String mes2 = new String("abc");
// mes1 和mes2 的堆内存存储'a' 'b' 'c' 组成的字符串(在常量池中)的地址 , 再将堆内存的地址赋值给mes1和mes2
System.out.println(mes1==mes2); // false
System.out.println(mes1.equals(mes2)); // true
  1. charAt(int index)和codePointAt(int index)方法, charAt(int index)方法获取字符串中指定下标的字符, codePointAt(int index)方法获取到的为对应下标字符的 ascii 码值;
  2. concat(String str)方法, 将两个字符串拼接成一个, 作用和 "+" 一致;
  3. endsWith(String str) 和 startsWith(String str)方法, endsWith(String str)判断是否以某个字符串结尾, startsWith(String str)判断是否以某个字符串开始;
  4. toUpperCase()将字符串全部转换大写, 前提是字符串全部由字母组成; 同理toLowerCase()将字符串全部转换为小写;
  5. getBytes("编码集")方法, 根据指定编码集将字符串转换为对应字节数组;
  6. indexOf(String str, [int fromIndex])方法是找指定字符串从指定位置从左往右起第一次出现的位置, 如果没有传指定下标, 便默认从左第一个位置开始找, 如果不存在则返回-1; lastIndexOf(String str, [int fromIndex])方法是找指定字符串从指定位置起从右往左第一次出现的位置, 如果没有传指定下标, 则默认从右最后一个位置开始查找, 如果不存在则返回-1.
  7. isEmpty()方法, 判断一个字符串是否为空串, 即 "";
  8. length()方法获取字符串长度;
  9. replace(String old, String new)方法, 使用新字符串替换字符串中的旧字符串;
  10. split(String str)方法, 按照指定字符串将原字符串切割开来, 切割后的字符串作为字符数组返回;
  11. substring(int begin, [ing end])方法, 将字符从指定开始位置到结束位置截取出来并返回, 如果没有传结束位置, 则直接截取到字符串末尾;
  12. trim()方法, 去除字符串左右两边的空白并返回结果.

解决字符串乱码

在我们的代码中使用字符串进行二进制转化操作时, 经常碰到本地测试的时候没有问题, 但是一旦放到其他环境的机器上, 就可能会产生乱码的问题. 出现这个的原因是我们在进行二进制转换操作时, 并没有强制规定使用的编码格式, 或者使用的编码格式并非完全支持当前场景, 比如说如下场景:

String str  ="my name is 驖䦂";
// 字符串转化成 byte 数组
byte[] bytes = str.getBytes("ISO-8859-1");
// byte 数组转化成字符串
String s2 = new String(bytes);
log.info(s2);
// 结果打印为:
my name is ??

可以看到对于中文, 打印出来的结果是 ?? , 出现这种问题是因为在进行 new String(bytes) 的时候没有指定编码格式, 那如果将编码格式加上, 改写成 new String(bytes, "ISO-8859-1") 呢, 答案是不一定行, 因为 ISO-8859-1 的编码对中文的支持有限, 所以对于有些中文, 是依旧会乱码的, 这个时候我们应该统一使用 UTF-8 格式的编码来处理, 使用 UTF-8 以后, 打印出来的结果就正常了.

三. 包装类

Java为我们的8种基本数据类型提供了对应的包装类型, 供我们使用. 那这个时候就有人会问了, 为什么我们有了基本数据类型了, 还要给我们提供包装类型呢? 基本数据类型它不香吗? 事实上确实是的, 在某些情况下, 基本数据类型确实不足以满足我们的需求.

byte	Byte
short	Short
int	Integer
long	Long
double	Double
float	Float
boolean	Boolean
char	Character

首先我们来说一下基本数据类型的概念, 由于 Java是一门面向对象的语言, 在我们 new 一个对象的时候, 会把他存储到堆里面, 然后通过栈来引用这些对象. 但是对于一些我们经常使用到的类型如 int, long, char 等, 这些类型都相对的简单并且占用内存很小, 把他们存放到堆里面然后再使用就不是很高效. 这个时候 Java 就决定使用它们不是通过 new 来创建, 而是直接存储在栈中, 在方法执行时创建, 执行完成以后销毁, 以得到更高的效率.

那既然是这样, 为什么还需要包装类呢? 这个是因为 Java 本身是一门面向对象的语言, 但是基本数据类型却不是面向对象的, 在实际使用的时候有些地方是不方便的. 所以 Java 就为基本数据类型提供了包装类, 将基本数据类型 "包装" 起来, 使它们具有对象的特性, 并且为其添加了一些属性和方法, 丰富了基本数据类型的操作. 对于一些基本类型不适用的场景, 例如使用集合的时候, 其存储的数据类型只能是 Object 类型的, 这个时候我们使用基本数据类型就不适用了, 而使用包装类就能够满足需求.

自动装箱和自动拆箱

自动装箱和自动拆箱说白了就是在使用基本数据类型和包装类型的时候, Java 自动为我们将基本数据类型包装成对应的包装类型, 或者将包装类型转换成对应的基本数据类型. 如下所示:

Integer a = 127; // 自动将127包装成Integer类型
int b = a; // 自动将a拆箱, 然后赋值给变量b

在Integer类型中, 存在一个缓冲池, 缓冲池的大小为 -128~127, 如果创建的Integer类型的变量的值在这个范围内, 则会直接从缓冲池中取值, 反之如果不再这个范围内, 则会在堆空间中开辟出新的空间来保存值. 所以在进行Integer类型比较时, 需要注意对地址值的比较, 如下所示:

Integer a = 127;
Integer b = 127;
System.out.println(a==b);
Integer x = 128;//默认调用valueOf(int i)将i包装成对象
Integer y = 128;
System.out.println(x==y);
//结果为ture,false
/*a和b相等时因为两者在-128到127之间,则存在缓冲区中,地址一样,值一样,返回true
x和y不相等时因为超过了-128到127之间 , 则每次新建一个包装类对象,值一样,但地址不一样,返回false.*/

四. 集合框架

说到数据结构, Java 的集合是避不开的, 在我们的工作中都不可避免的要使用到它们, 甚至于说, 只要我们在使用Java 进行开发, 就必须要用到它们. 使用它们可以帮助我们便捷的在内存中处理、传递、和存储数据. 那么, 这些功能它们是怎么做到的呢, 在它们底层又是怎么对数据进行处理的呢, 接下来让我们从源码层面上去看一下它到底是怎么做的. 首先先来看一下 Java 集合框架的体系结构: image.png 上图是 Java 集合框架中常用的一些 api, 实际上在集合框架的体系中并不仅仅是这些, 有很多被我省略掉了, 只列出了比较常见的一些类以及接口, 以及它们之间的关系.

List

ArrayList

ArrayList是我们平时工作过程中使用的最多的, 在进行代码开发的时候几乎都会用到. 下面让我们一起去看一下它的底层源码到底是怎么实现的. 在这之前, 我们需要明确一下ArrayList本身的特点, 然后带着这些特点去看他的源码:

  • ArrayList存在下标, 底层数据结构是数组, 新增删除效率低, 修改查询效率高. ArrayList的整体结构比较简单, 就是一个数组结构, 如下图所示: image.png 图中 elementData 是长度为9的数组(这个长度并不是 ArrayList 的长度), 是 ArrayList 内部存储数据的数组, index 是数组的下标. 下面让我们来看一下 ArrayList 的源码.
public class ArrayList<E> extends AbstractList<E>
        implements List<E>, RandomAccess, Cloneable, java.io.Serializable {

    private static final int DEFAULT_CAPACITY = 10;

    private static final Object[] EMPTY_ELEMENTDATA = {};

    private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};

    transient Object[] elementData;

    private int size;
    
    ......
    
}

可以看到在 ArrayList 内部声明了五个属性:

  • DEFAULT_CAPACITY: 数组的初始化大小;
  • EMPTY_ELEMENTDATA: 空数组, 在实例化的时候用到;
  • DEFAULTCAPACITY_EMPTY_ELEMENTDATA: 空数组, 作用与 EMPTY_ELEMENTDATA 类似, 都是在初始化时使用;
  • elementData: 即 ArrayList 内部保存数据的数组;
  • size: 用来描述 ArrayList 内部保存元素的个数, 也就是 ArrayList 的长度.
初始化

既然 ArrayList 为初始化声明了两个属性, 那么这两个属性是怎么作用的呢, 下面来看下他的构造方法是怎么设计的:

// 构造方法一
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);
    }
}
// 构造方法二
public ArrayList() {
    this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}
// 构造方法三
public ArrayList(Collection<? extends E> c) {
    elementData = c.toArray();
    if ((size = elementData.length) != 0) {
        // c.toArray might (incorrectly) not return Object[] (see 6260652)
        if (elementData.getClass() != Object[].class)
            elementData = Arrays.copyOf(elementData, size, Object[].class);
    } else {
        // replace with empty array.
        this.elementData = EMPTY_ELEMENTDATA;
    }
}

可以看到 ArrayList 提供了三个构造方法:

  • 方法一是通过直接传入容量的大小, 如果穿传入的数大于0的话就初始化一个对应大小的数组赋值给 elementData, 等于0的话就直接把 EMPTY_ELEMENTDATA 赋值给 elementData, 如果小于0, 则抛出异常;
  • 方法二是直接调用无参构造, 将 DEFAULTCAPACITY_EMPTY_ELEMENTDATA赋值给 elementData;
  • 方法三的话是通过传入一个已有的集合, 调用这个集合的 toArray() 方法, 转换为数组然后赋值给 elementData, 然后判断下赋值后 elementData 的长度是否等于零, 如果不等于, 则需要进一步判断 elementData 是否是对象数组类型, 如果不是, 则需要调用 Arrays.copyOf() 方法做一次转换. 如果判断 elementData 的长度等于0, 则直接将 EMPTY_ELEMENTDATA 赋值给elementData即可.
扩容

扩容是 ArrayList 设计最复杂也是最重要的一个功能. 扩容这个功能主要体现在 add() 方法中, add() 方法有很多个重载的, 但是其扩容的原理基本类似, 我们以最简单明了的 add(E e) 方法看下其源码是怎么样设计的:

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

private void ensureCapacityInternal(int minCapacity) {
    if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
        minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
    }
    ensureExplicitCapacity(minCapacity);
}

private void ensureExplicitCapacity(int minCapacity) {
    modCount++;
    // overflow-conscious code
    if (minCapacity - elementData.length > 0)
        grow(minCapacity);
}

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);
}

可以看到调用 add(E e) 方法时, 总共调用了4个方法, 来保证 elementData 的容量足够放下新增的数据. 下面我们来分别看下这四个方法:

  1. add(E e): 这个方法首先调用 ensureCapacityInternal(size + 1) 方法, 看字面意思就知道是为了确保容量足够存储新增的数据; 然后是 elementData[size++] = e 这里很明显是将新的数据保存到数组中, 并且让 size+1, 即 ArrayList 的长度+1; 最后返回 true 即新增成功;
  2. ensureCapacityInternal(int minCapacity): 上面提到这个方法是用来确保容量足够的, 在调用这个方法的时候, 传入的是 size + 1 这个参数, 这是由于 add(E e) 这个方法时新增一个数据, 所以新增后整体的 size 最少是需要 +1 的, 才能保证容量足够. 然后在方法中将 elementData 跟 DEFAULTCAPACITY_EMPTY_ELEMENTDATA 做了比较, 如果二者相等则说明数组中目前没有值, 数组长度为0, 需要拿 新增数据后的最小长度 minCapacity 即为 size+1 跟 DEFAULT_CAPACITY 做比较, 取较大值, 然后以这个较大值为目标容量进行扩容;
  3. ensureExplicitCapacity(int minCapacity): 这个方法首先将 modCount++ , 然后比较需要的最小内存和当前 elementData 的长度, 如果 elementData 长度小于最小长度, 就调用 grow(minCapacity) 方法进行扩容;
  4. grow(int minCapacity): 这个方法首先将原来 elementData 的长度赋值给变量 oldCapacity, 然后定义新的长度 newCapacity 为原长度的1.5倍, 将得到的新长度与最小长度 minCapacity比较, 如果新长度不够, 就将需要的最小长度作为新的长度; 最后, newCapacity 还与 MAX_ARRAY_SIZE 进行了一次比较, 如果大于的话, 就把 Integer的最大值赋值给 newCapacity. 最后确定好新的数组长度以后, 就调用 Arrays.copyOf() 方法, 将原来的数据都转移到扩容后的数组中, 完成扩容. 由此可见, 在 ArrayList 扩容的时候, 实际上是新建了一个新的数组来保存数据, 然后将新数组的引用给到 elemenData. 这里其实是由于数组一旦初始化以后, 其大小就不能再被改变了, 所以只能通过创建新的, 容量更大的数组来放下新增的数据.

关于 ArrayList 扩容的大体过程就是上述这些, 由上述源码不难发现, ArrayList扩容的操作, 其时间复杂度是O(n)的, 需要将所有数据都拷贝到新的数组里面, 即与数组长度成正比. 理解了 add(E e) 这个方法扩容的过程, 其余的多个重载的方法也就好理解了, 不管是新增一个还是多个, 其原理大体是类似的, 只是扩容的时候选择的扩容的大小有区别而已.

查询

ArrayList 的查询通过 get(int index) 来实现, 这个方法的实现比较简单, 就是直接通过数组的下标来取出对应的数据然后返回, 时间复杂度为O(1), 只需要一次访问数组即可, 其源码如下所示:

public E get(int index) {
    // 作用是防止取出数据的下标超出数组的长度
    rangeCheck(index);
    // 直接将数组中对应下标的数据返回
    return elementData(index);
}
总结

关于 ArrayList 的源码大体上就先介绍这些了, 其实还有些地方也是比较重要的, 比如说删除数据 remove(int index)方法, 涉及到删除中间数据时移动数据的操作, 这个操作的复杂度是 O(N)的, 需要移动的数据与数组长度成正比. 以及迭代器也设计的比较巧妙, 包括正向反向遍历, 以及并发修改等, 不过由于篇幅原因, 这里就不着重介绍了, 感兴趣的朋友也可以自己点开源码去阅读一下.

LinkedList

LinkedList 也是我们常用的一种数据结构, 其底层的结构是由一个一个的链节点链接而成的, 我们先来看一下它的特点:

  • LinkedList底层数据结构是双向链表, 有序, 但是没有下标, 新增删除数据效率高, 但是查询数据效率低. 下面来看一下 LinkedList 的结构图示: image.png 从上图不难看出 LinkedList 有以下特点:
  • 链表是由一个一个的 Node 组成的, 每个 Node 有两个指针, 分别是 next 指向下一个 Node , 和 prev 指向上一个 Node;
  • 链表的头结点 first 的 prev 指针指向为 null, 尾节点的 next 指向也为 null; 基于以上了解, 下面让我们来看一下LinkedList的源码, 首先来看一下组成 LinkedList 的几个重要属性:
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;
    
    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;
        }
    }
    
    ......
    
}

由上述代码不难看出, LinkedList 有三个重要属性, 一个是 size, 表示 LinkedList 的长度, 第二个是 first 即头结点, 第三个 last 为尾节点. 之前我们说到 LinkedList 是由一个一个的 Node 组成的, 这里就可以发现, 原来 Node 是 LinkedList 的一个内部类, 其带有 prev 和 next 属性, 这两个属性也是 Node 类型的, 很明显这两个就是指向前一个节点和下一个节点的指针; 还有一个 item 属性, 这个属性就是用来保存数据的; Node的构造方法参数有三个, 即前节点, 当前存储元素以及后节点.

初始化

LinkedList的初始化比较简单, 因为不用像数组一样固定空间, 其大小是随意可变的, 并且它的长度也不固定, 只要机器的内存足够大, 其大小是没有限制的.

// 构造方法
public LinkedList() {}

public LinkedList(Collection<? extends E> c) {
    this();
    addAll(c);
}
插入数据

LinkedList 插入数据是很快的, 其时间复杂度为O(1), 由于设计成链表的结构, 所以它不像数组那样插入数据时要移动平均一半的数据, 而只需要改变插入位置的节点的前后指针指向即可 (这里选择最典型的 add(int index, E element) 方法进行介绍), 让前一个节点的 next 指针以及后一个节点的 prev 指针指向新的节点, 以及让新节点的指针指向前后节点, 即可完成插入. 下面来看一下对应的源码:

public void add(int index, E element) {
    // 检查插入位置 index 是否超出链表的长度范围, 如果超出则抛出 IndexOutOfBoundsException 的异常
    checkPositionIndex(index);
    // 判断插入的位置是否正好是末尾
    if (index == size)
        // 如果是则调用末尾新增节点方法新增
        linkLast(element);
    else
        // 不是则调用中间插入节点的方法插入数据, node(index)方法的目的是返回链表当前插入元素位置的旧的节点
        linkBefore(element, node(index));
}

// 末尾新增节点的方法
void linkLast(E e) {
    // 使用一个中间变量 l 保存当前尾节点
    final Node<E> l = last;
    // 使用新增元素构造新的节点, 新节点即为尾节点, 其前一个节点为 l, 下一个节点为 null
    final Node<E> newNode = new Node<>(l, e, null);
    // 将 last 尾节点指向新的节点
    last = newNode;
    // 如果原尾节点是否为空
    if (l == null)
        // 为空则说明是原来是空的链表, 新增的节点也会是头结点
        first = newNode;
    else
        // 不为空则将原来尾节点的 next 指针指向新增的节点
        l.next = newNode;
    size++;
    modCount++;
}

// 在链表中间插入新节点的方法
void linkBefore(E e, Node<E> succ) { // succ为插入位置的旧的节点
    // 首先使用 pred 保存下插入位置的前一个节点
    final Node<E> pred = succ.prev;
    // 使用新增元素构造新的 Node 节点
    final Node<E> newNode = new Node<>(pred, e, succ);
    // 将旧的节点的 prev 指针指向新增节点
    succ.prev = newNode;
    // 判断插入位置前一个节点是否为空
    if (pred == null)
        // 为空则说明是空链表, 将头节点也指向新增节点
        first = newNode;
    else
        // 不为空则将前一个节点的 next 指针指向新增节点
        pred.next = newNode;
    size++;
    modCount++;
}

以上就是新增节点的源码, 从上述源码可以看出, 链表新增节点是非常简单的, 只要改变前后指针的指向即可, 与之相对应的删除数据也很快. 但是需要注意的是, LinkedList在找到插入位置时, 其查找需要的时间是O(n)的, 这里涉及到的就是 node(int index) 这个方法了, 它的源码如下:

Node<E> node(int index) {
    if (index < (size >> 1)) {
        Node<E> x = first;
        for (int i = 0; i < index; i++)
            x = x.next;
        return x;
    } else {
        Node<E> x = last;
        for (int i = size - 1; i > index; i--)
            x = x.prev;
        return x;
    }
}

可以看到, LinkedList 为了找到当前插入位置的节点, 需要通过从头结点向后遍历到 index 位置, 或者通过尾节点向前遍历到 index 位置. 这里消耗的时间相对较多, 不过由于不需要像数组那样拷贝数据, 所以综合来说其插入效率还是要高一些的.

查询数据

LinkedList查询数据的方式也比较简单, 由于没有下标, 所以需要向前或者向后遍历链表到指定位置, 其实就是调用了 node(int index) 方法:

public E get(int index) {
    // 检查下标是否越界
    checkElementIndex(index);
    // 遍历找到目标节点并取出元素值
    return node(index).item;
}
总结

关于 LinkedList 就先介绍到这里, 其实 LinkedList 总体来说理解起来比较简单, 对于链表相关的数据结构, 其变换其实总是在于它的前后指针指向的变换, 把这个弄清楚了就好理解了, 万变不离其踪.

Map

HashMap

HashMap作为一个优秀的集合, 其插入数据, 删除, 以及查询数据的效率都是非常高的, 其时间复杂度都是O(1). 下面来看下 HashMap 的特点:

  • 底层数据结构是 数组 + 链表 + 红黑树, 存储数据是以 key-value 的方式存储的, key 和 value 的值都可以是 null; key 值不可以重复, value 值可以重复. 了解了以上特点以后, 首先我们通过下图先来看一下 HashMap 的整体结构: image.png 上图所示, 顶部的是 HashMap 的数组结构, 每个 Node 通过 key 的 hash 值落到数组对应的位置, 如果多个 Node 的 key 通过 hash 后得到数组相同位置, 则会以链表或者红黑树的形式存储这多个 Node. 如图左侧的是链表结构, 右侧则是红黑树结构.

下面我们通过源码来看一下 HashMap 数据存储的细节是怎么实现的, 首先来看下 HashMap 的组成有哪些属性:

public class HashMap<K,V> extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable {
    // 初始容量为 16
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
    // 最大容量
    static final int MAXIMUM_CAPACITY = 1 << 30;
    // 默认的装载因子为 0.75, 数组扩容的条件为 当前数组容量 * 装载因子 < 需要的容量
    static final float DEFAULT_LOAD_FACTOR = 0.75f;
    // 链表转换为红黑树的长度, 即为 8
    static final int TREEIFY_THRESHOLD = 8;
    // 红黑树转换为链表时链表的长度, 为 6
    static final int UNTREEIFY_THRESHOLD = 6;
    // 链表转换为红黑树的另一个条件, 数组长度大于 64
    static final int MIN_TREEIFY_CAPACITY = 64;
    // HashMap的数组
    transient Node<K,V>[] table;
    // HashMap 的实际大小
    transient int size;
    // 扩容的门槛 = 当前数组容量 * 装载因子 > 需要的数组大小
    int threshold;
    
    transient int modCount;

    transient Set<Map.Entry<K,V>> entrySet;

    final float loadFactor;
    
    ......
}
初始化

首先来看一下 HashMap 的构造方法

// 手动配置初始容量以及装载因子值 
public HashMap(int initialCapacity, float loadFactor) {
    // 如果初始容量小于 0, 则抛出异常
    if (initialCapacity < 0)
        throw new IllegalArgumentException("Illegal initial capacity: " +
                                           initialCapacity);
    // 如果初始容量大于最大容量, 则等于最大容量
    if (initialCapacity > MAXIMUM_CAPACITY)
        initialCapacity = MAXIMUM_CAPACITY;
    // 如果装载因子小于等于 0, 或者非数字, 则抛出异常
    if (loadFactor <= 0 || Float.isNaN(loadFactor))
        throw new IllegalArgumentException("Illegal load factor: " +
                                           loadFactor);
    this.loadFactor = loadFactor;
    // 根据初始容量计算 threshold 的值, 其值是大于初始容量的2的幂次方
    this.threshold = tableSizeFor(initialCapacity);
}

public HashMap(int initialCapacity) {
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}

public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}

public HashMap(Map<? extends K, ? extends V> m) {
    this.loadFactor = DEFAULT_LOAD_FACTOR;
    // 
    putMapEntries(m, false);
}

可以发现所有的构造方法, 无非是为了确定两个属性的值, 一个是 loadFactor, 另一个是 threshold, 这两个值都是后续 HashMap 在进行扩容时需要使用到的.

插入数据

HashMap 插入数据的速度很快, 但是其过程却相当复杂, 首先是判断相同数据如何进行保存的处理, 然后是对于不同数据但经过 hash 后落到数组同一位置时使用链表保存的处理, 以及链表达到一定长度以后需要转换为红黑树的处理. 以上这些步骤处理都是比较麻烦的, 其代码的处理细节如下:

public V put(K key, V value) {
    // 调用 putVal()方法保存数据, 通过hash()方法获得 hash 值;
    return putVal(hash(key), key, value, false, true);
}
// onlyIfAbsent 为 true 时表示如果 key 相同的情况下, 不会替换 value 值, false 时会替换;
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
    Node<K,V>[] tab;
    Node<K,V> p;
    int n, i;
    // 判断 table 是否初始化;
    if ((tab = table) == null || (n = tab.length) == 0) {
        // 如果没有初始化则调用 resize() 方法进行初始化, 并将初始化后的长度赋值给 n;
        n = (tab = resize()).length;
    }
    // 判断数组在当前位置的值 p 是否为空;
    if ((p = tab[i = (n - 1) & hash]) == null) {
        // 如果为空则直接生成一个新的节点到到当前位置;
        tab[i] = newNode(hash, key, value, null);
    } else { // 如果不为空
        Node<K,V> e; // 临时变量, 用于保存当节点 p 和新增节点的 key 一致时的 p 节点, 用于后续判断是否替换 value 值;
        K k;
        // 首先判断 hash 值, 新增数据的 key 值是否和 p 的 key 是否相等;
        if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) {
            // 判断相等, 则直接将 p 赋值给 e;
            e = p;
        } else if (p instanceof TreeNode) { // 反之则说明 key 不相等, 判断当前的 p 是否是红黑树节点;
            // 是则使用红黑树的方法新增数据, 并如果碰到相同 key, 则将 p 赋值给 e;
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        } else { // 反之不是红黑树, 则是链表;
            // 自旋链表;
            for (int binCount = 0; ; ++binCount) {
                // p.next == null, 则说明是当前链表最后一个节点;
                if ((e = p.next) == null) {
                    // 将新节点链接到链表末尾;
                    p.next = newNode(hash, key, value, null);
                    // 判断当前链表长度是否达到 7;
                    if (binCount >= TREEIFY_THRESHOLD - 1) {
                        // 如果达到 7 了, 说明再新增一个节点就长度为8, 转换为红黑树;
                        treeifyBin(tab, hash);
                    }
                    // 新增节点成功直接结束循环
                    break;
                }
                // 判断 e 的值是否跟新增节点的一致;
                if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) {
                        // 是则直接结束自旋;
                        break;
                    }
                // 此时将 e 赋值给 p, 即将 p 指向链表下一个位置 p.next;
                p = e;
            }
        }
        // 判断 e 不为空, 则说明在当前位置找到了相同的 key;
        if (e != null) {
            V oldValue = e.value;
            // 判断 onlyIfAbsent 为 false 时或者 oldValue 为 null 时, 替换 value 值;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            // 对e的后处理, 供继承类去重写;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount;
    if (++size > threshold) {
        // 判断新增后的 size 是否大于 threshold, 大于则进行扩容;
        resize();
    }
    // 后处理, 供继承类重写;
    afterNodeInsertion(evict);
    return null;
}

以上就是 HashMap 新增数据的过程了, 总体来说还是很复杂的, 关于红黑树新增数据的过程 putTreeVal(), 以及链表转换为红黑树的过程 treeifyBin() 由于篇幅的原因这里我没有展开来讲, 感兴趣的朋友也可以自己去看一下源码.

查询数据

查询数据对应的 HashMap的 get(key) 方法, 查找数据相对来说就比新增要简单许多了, 首先是通过 key 的 hash 找到对应的位置, 然后判断 key 是否相等, 如果相等则直接返回, 反之判断当前节点是否有 next 值, 如果有的话判断是链表类型还是红黑树类型, 然后按照各自的方法去查找对应数据即可, 没有找到则返回 null. 其对应的源码细节如下:

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) {
        // 不为空则判断 key 值是否相等;
        if (first.hash == hash && 
            ((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 {
                // 如果找到 key 相等的节点, 则直接返回
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k)))) {
                        return e;
                    }
            } while ((e = e.next) != null);
        }
    }
    return null;
}
总结

关于 HashMap 暂时就介绍到这里了, 对于 HashMap 的理解, 首先要知道它的整体结构是怎么样的 数组 + 链表 + 红黑树, 其次要知道新增数据时, HashMap 是如何去判断是以红黑树新增还是以链表方式新增, 二者之间转换的条件是怎么样的, 以及对重复 key 的数据是如何去进行处理的; 最后HashMap扩容是怎么样去进行的, 以及 loadFactor 和 threshold 是如何去影响扩容的, 了解了这些, 去阅读 HashMap 的源码也就更加轻松了.

Set

HashSet

HashSet 是属于 Set 接口的实现类, 也是我们开发中比较常用的集合, 特别是对于数据没有顺序要求, 以及不允许重复的时候. HashSet 具有以下特点:

  • 底层是 HashMap, 所以在进行插入数据时是不能保证元素顺序的; 并且由于底层是 HashMap 其对数据的操作也特别快, 时间复杂度都是 O(1) 的; 插入 HashSet 的元素是不能重复的.

知道 HashSet 的以上特点后, 下面我们来看下它的源码细节是怎么样的, 怎么样利用 HashMap 来作为底层进行数据存储, 首先来看下 HashSet 中有哪些属性:

public class HashSet<E>
    extends AbstractSet<E>
    implements Set<E>, Cloneable, java.io.Serializable {

    private transient HashMap<E,Object> map;

    private static final Object PRESENT = new Object();
    
    ......

可以看到 HashSet 内部的属性组成是比较简单的, 仅有一个变量 map 和一个最终量 PRESENT, 显然 map 是 HashSet 底层用来存储数据 HashMap, 那么 PRESENT 又有什么用呢? 这个在下面会给大家解答.

初始化

下面来看一下 HashSet 的初始化方法:

public HashSet() {
    map = new HashMap<>();
}

public HashSet(Collection<? extends E> c) {
    map = new HashMap<>(Math.max((int) (c.size()/.75f) + 1, 16));
    addAll(c);
}

public HashSet(int initialCapacity, float loadFactor) {
    map = new HashMap<>(initialCapacity, loadFactor);
}

public HashSet(int initialCapacity) {
    map = new HashMap<>(initialCapacity);
}

HashSet(int initialCapacity, float loadFactor, boolean dummy) {
    map = new LinkedHashMap<>(initialCapacity, loadFactor);
}

既然底层存储数据是用的 HashMap, 那么这里可以看到, HashSet 在进行初始化的时候, 其实主要目的就是初始化底层存储数据的 HashMap, 在构造方法执行时创建好 HashMap 对象.

插入数据

本身 HashSet 是存储单个数据的, 但是其底层 HashMap 却是以 key-value 方式进行数据存储的, 为什么到了HashSet 这里却变成了存储单个数据了, 这中间是怎么处理的呢? 这里就要用到之前提到的最终量 PRESENT 了, 下面来看下 HashSet 插入数据的源码细节:

public boolean add(E e) {
    return map.put(e, PRESENT)==null;
}

可以看到, 这个处理过程其实很简单, 其实就是调用了 map 变量的 put(key, value) 方法, 将需要保存到 HashSet 中的数据作为 map 的 key, 然后将最终量 PRESENT 作为 value 值存入到 map 中, 数据就完成插入了. 如此就完成了 HashSet 到底层 HashMap 存储的转换, 也能理解为什么插入的数据是无序的, 对 HashSet 数据的操作时间复杂度都是 O(1), 以及为什么 HashSet 存储数据不能重复了, 因为 HashMap 的 key 是不允许重复的.

总结

对于 HashSet 的介绍就上面这些了, 其实可以看到 HashSet 本身是特别简单的, 主要理解了 HashMap, 对于 HashSet 的组成以及特点就一目了然了, 这也是我为什么会把更难的 HashMap 放在前面介绍的原因.

五. 后记

集合框架这一篇总算写的差不多了, 这篇文章零零散散写了两周多, 花的时间确实有点长. 一边是因为自己比较懒, 经常写两下就放一边去了, 另一边可能是因为自己才开始写吧, 有时候经常会陷入咬文嚼字的窘境, 很多东西不知道怎么去写, 然后也挺纠结的, 源码这个东西也真比较难啃, 有时候一句代码可能需要花好长时间去理解. 其实这篇刚开始之前我打算的是不仅仅介绍这几个 api 的, 懂的人都明白, 集合框架里面根本不止这些东西, 还有像 Map 里面的 TreeMap, LinkedHashMap, Set 里面的 TreeSet等, 这些其实都有可能会在工作中用到, 不过碍于自己目前水平的问题, 这几个集合的源码也还没有去深入了解, 所以先不做介绍, 后续等自己有时间了解了再把它们补上, 到时候这一篇也可能会拆分成很多篇, 看起来也没这么累了. 最后总的来说还是挺欣慰的, 毕竟还是写完了, 没有弃坑. 还是那句话, 这些东西更多的会像是我自己的笔记, 偶尔忘了就回来浏览下, 加深印象. 有朋友们看到了, 觉得有需要改善的, 也可以私聊我, 都是相互成长的过程, 感谢各位大佬们的指点.