0X JavaSE-- 集合框架【Collection(List、Set、Queue)、Map】

-每一个集合类底层采用的数据结构不同。例如 ArrayList 集合底层采用了数组,LinkedList 集合底层采用了双向链表,HashMap 集合底层采用了哈希表,TreeMap 集合底层采用了红黑树。

  • 集合中存储的是引用。即:集合中存放的是对象的地址,而不是把堆中的实例对象存储。
  • 如果不使用泛型的话,集合中可以存储任何类型的引用,只要是 Object 的子类都可以存储。
  • Java集合框架相关的类都在 java.util 包下。
  • Java 集合框架有两大支线:
    • Collection,可进一步细分为
      • List:可重复的集合。具体实现有: ArrayList(动态数组)LinkedList(双向链表)
      • Set:不可重复的集合,典型代表就是 HashSet(哈希表)TreeSet(红黑树)
      • Queue:队列。具体实现有:双端队列 ArrayDeque,以及优先级队列 PriorityQueue
    • Map:键值对的集合。具体实现有: HashMapLinkedHashMap(双向链表 + 哈希表)TreeMap(红黑树 + 哈希表)
  • 这些具体实现中,只有 TreeSet、LinkedHashMap、TreeMap 是有序的(键有序

List

List 的特点是存取有序,可以存放重复的元素,可以用下标对元素进行操作。

List 接口 的 API

List 接口继承自 Collection。自然,Collection 定义的 API,List 也有。

以下给出 List 特有的 API:

void add​(int index, E element) 在指定索引处插入元素

E set​(int index, E element); 修改索引处的元素

E get​(int index); 根据索引获取元素(通过这个方法List集合具有自己特殊的遍历方式:根据下标遍历)

E remove​(int index); 删除索引处的元素

int indexOf​(Object o); 获取对象o在当前集合中第一次出现时的索引。

int lastIndexOf​(Object o); 获取对象o在当前集合中最后一次出现时的索引。

List<E> subList​(int fromIndex, int toIndex); 截取子List集合生成一个新集合(对原集合无影响)。[fromIndex, toIndex)

static List<E> of​(E... elements); 静态方法,返回包含任意数量元素的不可修改列表。(获取的集合是只读的,不可修改的。)

ArrayList

  • 如果 ArrayList 有中文名,应当是:动态数组。因为 AL 基于数组实现了 List 接口,即 ArrayList 内部就是一个 Object[] 数组,配合一个变量记录当前分配空间,就可以充当可变数组
    • Java 原生数组的大小是固定的;AL 提供自动扩容功能,更加方便。
    • Java 原生数组不支持直接删除数组中的一个元素;AL 支持,并且在删除后会像链表一样,后续的元素向前补位,并且 AL 自动缩小一个单位。
        LinkedList<Object> namesList = new LinkedList<>();
        namesList.add("陈一");
        namesList.add("王二");

        System.out.println(namesList.get(0));

        namesList.remove("陈一");
        System.out.println(namesList.get(0));// out:王二
  • 既然是数组,AL 支持随机存取,也就是可以通过下标直接存取元素;
  • 从尾部插入和删除元素会快,从中间插入和删除元素会慢,这是由数组的特点决定的;
  • 内部数组的容量不足时会自动扩容。因此频繁插入元素导致扩容的时候,效率会比较低。
  • 需要频繁的检索元素,并且很少的进行随机增删元素时使用 AL。

1、Constructor

// 使用无参数构造器创建 ArrayList,并提前指定泛型为 String
ArrayList<String> list = new ArrayList<>();

// 使用无参数构造器创建 ArrayList,将泛型留给调用者确认类型
ArrayList<E> list = new ArrayList<>();

// 尽管会自动扩容,但仍可以使用带有固定容量参数创建 ArrayList
ArrayList<String> list = new ArrayList<>(int capacity);

// 使用带有集合参数的构造器创建 ArrayList
ArrayList<Integer> list = new ArrayList<>(Arrays.asList(1, 2, 3, 4, 5));

// 运用多态。由于 ArrayList 实现了 List 接口,所以 list 变量的类型可以是 List 类型
List<String> list = new ArrayList<>();

2、API

// E,即泛型,即可以传入任何引用类型。
boolean add(E e);// 添加元素到列表末尾
void add(int index, E element);// 在指定位置插入元素

E get(int index);// 获取指定位置的元素
E set(int index, E element);// 修改指定位置的元素,返回值是指定位置上原本的元素。

E remove(int index);// 移除指定位置的元素
boolean remove(Object o);// 移除首次出现的指定元素

void clear();// 清空列表

int size();// 获取列表的大小

boolean isEmpty();// 检查列表是否为空

int indexOf(Object o);// 获取某个元素第一次出现的索引
int lastIndexOf(Object o);// 获取某个元素最后一次出现的索引
boolean contains(Object o);// 检查列表是否包含某个元素,其内部就是用 indexOf 实现的

boolean addAll(Collection<? extends E> c);// 添加一个集合中的所有元素到列表末尾
boolean addAll(int index, Collection<? extends E> c);// 在指定位置插入一个集合中的所有元素

boolean removeAll(Collection<?> c);// 从列表中移除指定集合中包含的所有元素
boolean retainAll(Collection<?> c);// 仅保留列表中包含在指定集合中的元素

Object[] toArray();// 将列表转换为数组

<T> T[] toArray(T[] a);// 将列表转换为指定类型的数组

Iterator<E> iterator();// 获取列表的迭代器
ListIterator<E> listIterator();// 获取列表的列表迭代器(从开始处)
ListIterator<E> listIterator(int index);// 获取列表的列表迭代器(从指定位置开始)

void sort(Comparator<? super E> c);// 使用自然顺序对列表进行排序

void replaceAll(UnaryOperator<E> operator);// 用新值替换列表中所有旧值

3、源码解读

3.1 add(E e)
// 堆栈过程示意:
add(element)
└── if (size == elementData.length) // 判断是否需要扩容。elementData 是实际存储元素的数组。
    ├── grow(minCapacity) // 扩容
    │   └── newCapacity = oldCapacity + (oldCapacity >> 1) // 新数组容量 = 老数组容量 + 老数组容量的一半
    │   └── Arrays.copyOf(elementData, newCapacity) // 将老数组
    ├── elementData[size++] = element; // 添加新元素
    └── return true; // 添加成功

add 方法源码如下:

public boolean add(E e) {
    ensureCapacityInternal(size + 1);  // 确保 ArrayList 能够容纳新的元素
    // 在 ArrayList 的末尾添加指定元素。ArrayList 底层就是一个叫 elementData 的数组
    elementData[size++] = e;return true;
}

add 方法非常简洁,主要代码被封装于静态方法 ensureCapacityInternal 中。

/**
 * 确保 ArrayList 能够容纳指定容量的元素
 * @param minCapacity 指定容量的最小值
 */
private void ensureCapacityInternal(int minCapacity) {
    if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) { // 如果 elementData 还是默认的空数组
        minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity); // 使用 DEFAULT_CAPACITY 和指定容量的最小值中的较大值
    }
    ensureExplicitCapacity(minCapacity); // 确保容量能够容纳指定容量的元素
}

ensureExplicitCapacity 方法的作用是:确保 ArrayList 内部数组有足够的容量来存储新添加的元素。如果容量不足,它会增加内部数组的大小。

调用的顺序是:add --> ensureCapacityInternal --> ensureExplicitCapacity,以下分析二者的不同:

  • ensureCapacityInternal 用于计算初始容量。在指定容量和默认容量 10 之间取更大者,作为初始容量,然后将这个初始容量参数传入 ensureExplicitCapacity。
  • ensureExplicitCapacity 来实际执行容量检查和扩容。当容量不足时,调用 grow 方法进行扩容。
3.2 add(int index, E e)

add(int index, E element) 方法把元素添加到 ArrayList 的指定位置。

public void add(int index, E element) {
    rangeCheckForAdd(index); // 检查索引是否越界

    ensureCapacityInternal(size + 1);  // 确保容量足够,如果需要扩容就扩容
    System.arraycopy(elementData, index, elementData, index + 1,
            size - index); // 将 index 及其后面的元素向后移动一位
    elementData[index] = element; // 将元素插入到指定位置
    size++; // 元素个数加一
}

add(int index, E element) 和普通 add 的区别就是:会调用到一个本地方法 System.arraycopy()

该方法,从宏观上看,将数组指定下标以后的元素集体后移 1 个单位。
从微观上看,实际上是将数组的一部分复制到数组的指定位置(即退后一个单位)

System.arraycopy(Object src, int srcPos, Object dest, int destPos, int length);
System.arraycopy(elementData, index, elementData, index + 1, size - index);

在这里插入图片描述

3.3 indexOf(Object o)
  • 该方法返回指定元素第一次出现的索引
  • 与此类似的还有
    • int lastIndexOf(Object o) // 返回指定元素最后一次出现的索引
    • boolean contains(Object o) // 检查列表是否包含某个元素,其内部就是用 indexOf 实现的
  • 三种方法,其本质都是遍历数组,比对元素是否相等。

LinkedList

  • LinkedList 如果有中文名,应当是:具有双端队列功能的双向链表
  • 尽管 ArrayList 提供了自动扩容功能,但自动扩容功能底层实现是数组的复制,这是非常耗费时间的。
  • 需要频繁进行随机增/删/改时使用 LL。
  • LL 是由双向链表实现的,不支持随机存取,只能从一端开始遍历,直到找到需要的元素后返回;
  • 任意位置插入和删除元素都很方便,因为只需要改变前一个节点和后一个节点的引用即可,不像 AL 那样需要复制和移动数组元素,这是由链表的特点决定的;
  • 因为每个元素都存储了前一个和后一个节点的引用,所以相对来说,占用的内存空间会比 AL 多一些。
// CRUD 实例
// 创建一个集合
LinkedList<String> list = new LinkedList<String>();
// 添加元素
list.add("apple");
list.add("avocado");

// 遍历集合 for 循环
for (int i = 0; i < list.size(); i++) {
    String s = list.get(i);
    System.out.println(s);
}
// 遍历集合 for each
for (String s : list) {
    System.out.println(s);
}

// 删除元素
list.remove(1);
// 遍历集合
for (String s : list) {
    System.out.println(s);
}

// 修改元素
list.set(1, "apricot");
## Constructor
```java
// 无参构造方法:用于创建一个空的 LinkedList。
LinkedList<String> list = new LinkedList<>();

// 使用指定 LinkedList 创建新的 LinkedList
List<String> initialList = new ArrayList<>();
initialList.add("first");
LinkedList<String> list = new LinkedList<>(initialList);

1.、API

boolean add(E e);//在链表末尾插入元素
void add(int index, E element);// 在指定位置插入元素
void addFirst(E e);// 在链表开头插入元素
void addLast(E e);// 在链表末尾插入元素(等同于 add(E e))
boolean addAll(Collection<? extends E> c);// 在链表末尾插入指定集合中的所有元素
boolean addAll(int index, Collection<? extends E> c);// 从指定位置开始,插入指定集合中的所有元素

boolean remove(Object o);// 删除指定元素的第一个匹配项
E remove(int index);// 删除指定位置的元素
E remove();// 删除链表第一个元素
E removeFirst();// 删除链表的第一个元素
E removeLast();// 删除链表的最后一个元素
void clear();// 清空链表

E get(int index);// 获取指定位置的元素
E getFirst();// 获取链表的第一个元素
E getLast();// 获取链表的最后一个元素
E set(int index, E element);// 替换指定位置的元素
boolean contains(Object o);// 检查链表是否包含某个元素
int size();// 获取链表的大小

// 队列操作
E peek();// 获取但不删除链表的第一个元素(若链表为空则返回 null)
E element();// 获取但不删除链表的第一个元素(若链表为空则抛出 NoSuchElementException)
E poll();// 获取并删除链表的第一个元素(若链表为空则返回 null
E remove();// 获取并删除链表的第一个元素(若链表为空则抛出 NoSuchElementException)
boolean offer(E e);// 添加元素到链表末尾(等同于 add(E e))
E removeFirst();// 获取并删除链表的第一个元素(若链表为空则抛出 NoSuchElementException)
E removeLast();// 获取并删除链表的最后一个元素(若链表为空则抛出 NoSuchElementException)

// 双端队列操作
void push(E e);// 添加元素到链表开头(等同于 addFirst(E e))
E pop();// 获取并删除链表的第一个元素(等同于 removeFirst())
E peekFirst();// 获取但不删除链表的第一个元素(等同于 getFirst())
E peekLast();// 获取但不删除链表的最后一个元素(等同于 getLast())
E pollFirst();// 获取并删除链表的第一个元素(若链表为空则返回 null)
E pollLast();// 获取并删除链表的最后一个元素(若链表为空则返回 null)

// 迭代器
Iterator<E> iterator();// 获取链表的迭代器
Iterator<E> descendingIterator();// 获取链表的降序迭代器
ListIterator<E> listIterator(int index);// 获取链表从指定位置开始的列表迭代器

2、 源码

2.1、boolean add(E e) 和 void addLast(E e)

add 方法内部调用的是 linkLast 方法,这两个 API 实质上是等价的。

/**
 * 将指定的元素添加到列表的尾部。
 * @param e 要添加到列表的元素
 * @return 始终为 true(根据 Java 集合框架规范)
 */
public boolean add(E e) {
    linkLast(e); // 在列表的尾部添加元素
    return true; // 添加元素成功,返回 true
}

linkLast:顾名思义,就是在链表的尾部添加元素:

/**
 * 在列表的尾部添加指定的元素。
 * @param e 要添加到列表的元素
 */
void linkLast(E e) {
    final Node<E> l = last; // 获取链表的最后一个节点
    final Node<E> newNode = new Node<>(l, e, null); // 创建一个新的节点,并将其设置为链表的最后一个节点
    last = newNode; // 将新的节点设置为链表的最后一个节点
    if (l == null) // 如果链表为空,则将新节点设置为头节点
        first = newNode;
    else
        l.next = newNode; // 否则将新节点链接到链表的尾部
    size++; // 增加链表的元素个数
}

添加第一个元素的时候,first 和 last 都为 null。
然后新建一个节点 newNode,它的 prev 和 next 也为 null。
然后把 last 和 first 都赋值为 newNode。
此时还不能称之为链表,因为前后节点都是断裂的。

添加第二个元素的时候,first 和 last 都指向的是第一个节点。
然后新建一个节点 newNode,它的 prev 指向的是第一个节点,next 为 null。
然后把第一个节点的 next 赋值为 newNode。
此时的链表还不完整。

添加第三个元素的时候,first 指向的是第一个节点,last 指向的是最后一个节点。
然后新建一个节点 newNode,它的 prev 指向的是第二个节点,next 为 null。
然后把第二个节点的 next 赋值为 newNode。
此时的链表已经完整了。

2.2、E remove() 和 E removeFirst()

remove() 内部调用的是 removeFirst()。这两个 API 本质上是等价的。


ArrayList 和LinkedList

通常,我们总是优先使用 ArrayList。
在这里插入图片描述

  • 当需要频繁随机访问元素的时候,例如对数据进行排序\查找,使用 ArrayList。
  • 当需要频繁插入和删除元素的时候,例如实现队列或栈,或者需要在中间插入或删除元素的场景,可以使用 LinkedList。

在一些特殊场景下,可能需要同时支持随机访问和插入/删除操作。
例如一个在线游戏系统,需要实现一个玩家列表,需要支持快速查找和遍历玩家,同时也需要支持玩家的加入和离开。在这种情况下,可以使用 LinkedList 和 ArrayList 的组合,例如使用 LinkedList 存储玩家,以便快速插入和删除玩家,同时使用 ArrayList 存储玩家列表,以便快速查找和遍历玩家。

1、增元素的速度对比

以下讨论的基础是,二者的元素规模一样大。

  • 从集合的头部新增元素:ArrayList 花费的时间,因为其需要对头部以后的元素进行整体迁移。
  • 从集合的中间位置新增元素:ArrayList 花费的时间。ArrayList 需要整体迁移元素不假,但 LinkedList 也需要从头开始遍历链表。
  • 从集合的尾部新增元素,ArrayList 花费的时间。因为此时 ArrayList 不需要迁移数组;LinkedList 看似拥有尾指针可以直接定位到集合尾部,但需要在链表中创建新的对象,前后引用也要重新排列。
  • 尽管 ArrayList 在中间、末尾插入时的速度都领先于 LinkedList,但需要注意的是,ArrayList 可能经常需要扩容,扩容是非常浪费时间的。
// 以下代码测试第二种情况,即向集合中间位置新增元素
// 透过该测试,可以顺便学习如何为代码运行计时.
    public static void main(String[] args) {
        addFromMidTest(10000);
        addFromMidTest1(10000);
    }

    public static void addFromMidTest(int num) {
        ArrayList<String> list = new ArrayList<String>(num);
        int i = 0;

        long timeStart = System.currentTimeMillis();
        while (i < num) {
            int temp = list.size();
            list.add(temp / 2, i + "apple");
            i++;
        }
        long timeEnd = System.currentTimeMillis();

        System.out.println("ArrayList从集合中间位置新增元素花费的时间" + (timeEnd - timeStart));
    }


    public static void addFromMidTest1(int num) {
        LinkedList<String> list = new LinkedList<String>();
        int i = 0;
        long timeStart = System.currentTimeMillis();
        while (i < num) {
            int temp = list.size();
            list.add(temp / 2, i + "apple");
            i++;
        }
        long timeEnd = System.currentTimeMillis();

        System.out.println("LinkedList从集合中间位置新增元素花费的时间" + (timeEnd - timeStart));
    }

2、删除元素的速度对比

  • ArrayList 删除元素的时候,有两种方式。
    • 按照内容删除元素(remove(Object)),需要直先遍历数组,找到元素对应的索引;
    • 按照索引删除元素(remove(int))。
    • 本质上讲,两个方法是一样的,它们最后调用的都是 fastRemove(Object, int) 方法。
  • ArrayList 只要删除的不是最后一个元素,都需要重新移动数组。删除的元素位置越靠前,代价就越大。
  • LinkedList 在删除比较靠前和比较靠后的元素时,非常高效,但如果删除的是中间位置的元素,效率就比较低了。
  • 综合来看,删除元素时,仍然是 ArrayList 速度更快。
    • 从集合头部删除元素时,ArrayList 花费的时间比 LinkedList 多很多;
    • 从集合中间位置删除元素时,ArrayList 花费的时间比 LinkedList 少很多;
    • 从集合尾部删除元素时,ArrayList 花费的时间比 LinkedList 少一点。

3、遍历元素速度对比

  • 遍历 LinkedList 的时候,千万不要使用 for 循环,要使用迭代器。
  • for 循环遍历的时候,ArrayList 花费的时间远小于 LinkedList;迭代器遍历的时候,两者性能差不多。
  • 如果使用的是 for 循环,LinkedList 在 get 的时候性能会非常差,因为每一次外层的 for 循环,都要执行一次 node(int) 方法进行前后半段的遍历。

Vector 和 Stack

List 的实现类还有一个 Vector,是一个元老级的类,比 ArrayList 出现得更早。

ArrayList 和 Vector 非常相似,只不过 Vector 是线程安全的,像 get、set、add 这些方法都加了 synchronized 关键字,就导致执行效率会比较低,所以现在已经很少用了。
这种加了同步方法的类,注定会被淘汰掉,就像StringBuilder 取代 StringBuffer那样。

Stack 是 Vector 的一个子类,本质上也是由动态数组实现的,只不过还实现了先进后出的功能(在 get、set、add 方法的基础上追加了 pop「返回并移除栈顶的元素」、peek「只返回栈顶元素」等方法),所以叫栈。
由于 Stack 执行效率比较低(方法上同样加了 synchronized 关键字),就被双端队列 ArrayDeque 取代了(下面会介绍)。

Set

Queue

ArrayDeque

  • ArrayDeque 是一个基于数组实现的双端队列。
  • 实际应用中,常常用 ArrayDeque 替代 Stack。

为了满足可以同时在数组两端插入或删除元素的需求,数组必须是循环的,也就是说数组的任何一点都可以被看作是起点或者终点。

3.2 LinkedList

LinkedList 一般应该归在 List 下,只不过,它也实现了 Deque 接口,可以作为队列来使用。

实际上,LinkedList 同时实现了 Stack、Queue、PriorityQueue 的所有功能。

// 演示 LinkedList 的队列特性
LinkedList<String> queue = new LinkedList<>();

// 区别于 add, offer元素用于向队尾添加元素
queue.offer("apple");
queue.offer("avocado");
System.out.println(queue); // 输出 [apple, avocado]

// 删除元素
queue.poll();
System.out.println(queue); // 输出 [avocado]

// 修改元素:LinkedList 中的元素不支持直接修改,需要先删除再添加
String first = queue.poll();
queue.offer("apple");
System.out.println(queue); // 输出 [apple]

// 查找元素:既可以查找下标,也可以查找内容
System.out.println(queue.get(0)); // 输出 apple
System.out.println(queue.contains("apricot")); // 输出 false

// 查找元素:使用迭代器的方式查找陈清扬
// 使用迭代器依次遍历元素并查找
Iterator<String> iterator = queue.iterator();
while (iterator.hasNext()) {
    String element = iterator.next();
    if (element.equals("apple")) {
        System.out.println("找到了:" + element);
        break;
    }
}
3.2.1 add–remove / offer–poll

LinkedList 同时实现了 List 和 Deque(双端队列)接口,因此,其同时拥有两组插入/删除的方法:

  • add–remove:List 接口中定义的方法
    • 在 LinkedList 实例的尾部进行插入和删除
  • offer–poll:Deque 接口中定义的方法
    • 在 LinkedList 实例的尾部进行插入,头部进行删除(即队列的特征)

3.3 ArrayDeque 和 LinkedList

LinkedList 和 ArrayDeque 都是 Java 集合框架中的双向队列(deque),它们都支持在队列的两端进行元素的插入和删除操作。

不过,LinkedList 和 ArrayDeque 在实现上有一些不同:

  • 底层实现方式不同:LinkedList 是基于链表实现的,而 ArrayDeque 是基于数组实现的。
  • 随机访问的效率不同:由于底层实现方式的不同,LinkedList 对于随机访问的效率较低,时间复杂度为 O(n),而 ArrayDeque 可以通过下标随机访问元素,时间复杂度为 O(1)。
  • 迭代器的效率不同:LinkedList 对于迭代器的效率比较低,因为需要通过链表进行遍历,时间复杂度为 O(n),而 ArrayDeque 的迭代器效率比较高,因为可以直接访问数组中的元素,时间复杂度为 O(1)。
  • 内存占用不同:由于 LinkedList 是基于链表实现的,它在存储元素时需要额外的空间来存储链表节点,因此内存占用相对较高,而 ArrayDeque 是基于数组实现的,内存占用相对较低。
  • 如何选择?
    • 如果需要在双向队列的两端进行频繁的插入和删除操作,并且需要随机访问元素,可以考虑使用 ArrayDeque;
    • 如果需要在队列中间进行频繁的插入和删除操作,可以考虑使用 LinkedList。‘

3.4 PriorityQueue

PriorityQueue 是一种优先级队列,它的出队顺序与元素的优先级有关,执行 remove 或者 poll 方法,返回的总是优先级最高的元素。

Collection 接口的 API

Collection 接口中定义了如下 API。自然,所有实现了 Collection 接口的类(List、Set、Queue)都能使用这些方法。

boolean add(E e);			向集合中添加元素
int size();					获取集合中元素个数
boolean addAll(Collection c);	将参数集合中所有元素全部加入当前集合
boolean contains(Object o);	判断集合中是否包含对象o
boolean remove(Object o);		从集合中删除对象o
void clear();				清空集合
boolean isEmpty();			判断集合中元素个数是否为0
Object[] toArray();			将集合转换成一维数组

5、遍历 Collection

5.1 for

for 循环适合遍历底层为数组的集合如 ArrayList

5.2 for-each

  • 增强型 for 循环底层实际上是 Iterator,尽管如此:。
  • 仍不应在 for-each 中执行删/改操作。在遍历中有详细解释。
List<String> list = Arrays.asList("a", "b", "c");
for (String element : list) {
    System.out.println(element);
}

5.3 Iterator

迭代器存在的目的,就是为了遍历数组和集合。
使用迭代器时,尽管代码会稍显复杂,但功能与安全性比 for-each 强大很多。

        Collection<Object> collection = new LinkedList<>();
        // 1. 获取当前集合依赖的迭代器对象,使用泛型指定将来遍历返回的元素,避免遍历到错误的类型
        Iterator<Object> it  = collection.iterator();
        // 2. 编写循环,循环条件是:当前光标指向的位置是否存在元素。
        while(it.hasNext())
        {
        	// 3. 如果有,将光标指向的当前元素返回,并且将光标向下移动一位。
            Object obj = it.next();
        }

6、删除 Collection 元素

使用 remove 方法删除集合元素时:

  • 不得使用 for-each/对象.remove。因为可能导致 ConcurrentModificationException 异常。
  • 应使用迭代器对象.remove(元素)

for-each 能删除元素吗?

// 以下程序会抛出 ConcurrentModificationException 异常
     ArrayList<String> namesList = new ArrayList<>();

        namesList.add("陈一");
        namesList.add("王二");
        namesList.add("张三");
        namesList.add("李四");

        Iterator<String> it = namesList.iterator();
        while (it.hasNext()) {
            String name = it.next();
            if ("王二".equals(name)) {
                namesList.remove(name);
            }
        }

分析以上代码出错的原因,不难发现:删除了 lisi 之后,进入下一次循环时检测到两个标志不等 ,于是抛出异常。

那么我们不妨在删除语句后,直接退出循环,以防止 fail-fast 机制检测标志。

        while (it.hasNext()) {
            String name = it.next();
            if ("王二".equals(name)) {
                namesList.remove(name);
                break;
            }
        }

这个方式看似可行,实际上并不可取。
因为其并不能删除重复元素。

for 循环能删除元素吗?

  • for 循环不会触发 fail-fast 机制,所以理论上也永远不会抛出异常。
for (int i = 0; i < list.size(); i++) {
	String str = list.get(i);
	if ("王二".equals(str)) {
		list.remove(str);
	}
}

这段程序是有问题的:被删除元素的下一元素会被程序跳过。

第一次循环的时候,i = 0
第二次循环的时候,i = 1
第三次循环的时候,i = 2

第二次循环开始时(remove 之前),陈一 – 王二 – 张三 – 李四
第三次循环开始时(remove 之后),由于 AL 删除元素后自动前移的特性,陈一 – 张三 – 李四。

但我们知道,第三次循环的时候,i = 2 ,即指向李四,这意味着:张三被忽略了。

解决办法有两个:

// 从后向前遍历,可避免程序跳过元素
 for (int i = list.size() - 1; i >= 0; i--) {
            if ("王二".equals(list.get(i))) {
                list.remove(i);
            }
        }
        
// 从前向后遍历,但删除元素后立刻调整索引
      for (int i = 0; i < list.size(); i++) {
            if ("王二".equals(list.get(i))) {
                list.remove(i);
                i--; // 删除后将索引提前一位,避免跳过元素
            }
        }

另辟蹊径:Stream 流

采用 Stream 流的filter() 方法来过滤集合中的元素,然后再通过 collect() 方法将过滤后的元素收集到一个新的集合中。

List<String> list = new ArrayList<>(Arrays.asList("陈一", "王二", "张三"));
list = list.stream().filter(s -> !s.equals("王二")).collect(Collectors.toList());

SequencedCollection 接口

  • 所有的有序集合都实现了 SequencedCollection 接口。
  • SequencedCollection 接口是 JDK21 版本新增的。
SequencedCollection接口中的方法:
void addFirst(Object o):向头部添加
void addLast(Object o):向末尾添加
Object removeFirst():删除头部
Object removeLast():删除末尾
Object getFirst():获取头部节点
Object getLast():获取末尾节点
SequencedCollection reversed(); 反转集合中的元素
ArrayListLinkedListVectorLinkedHashSetTreeSet,Stack 都可以调用这个接口中的方法。

Map

  • Map 保存的是键值对,键要求保持唯一性,值可以重复。
  • Map 接口的常见实现类有两个:
    • HashMap
    • TreeMap

2. LinkedHashMap

  • LinkedHashMap 是 HashMap 的子类,。也就是说,只要是 LinkedHashMap ,那么必然也是一个 HashMap。
  • LinkedHashMap 可以看作是 HashMap + LinkedList 的合体。它使用哈希表来存储数据;使用双向链表来记录插入/访问元素的顺序,维持顺序。
  • 这里的顺序,指的是维持插入的顺序。
// LinkedHashMap 实例
LinkedHashMap<String, String> linkedHashMap = new LinkedHashMap<>();
linkedHashMap.put("苹果", "apple");
linkedHashMap.put("牛油果", "avocado");
linkedHashMap.put("香蕉","Banana");

// 遍历 LinkedHashMap
for (String key : linkedHashMap.keySet()) {
    String value = linkedHashMap.get(key);
    System.out.println(key + " 对应的值为:" + value);
}
// linkedHashMap 保证顺序,输出为 苹果--牛油果--香蕉,可对比 HashMap 的无序

3. TreeMap

  • TreeMap 实现了 SortedMap 接口,可以自动将键按照自然顺序或指定的比较器顺序排序,并保证其元素的顺序。
  • 内部使用红黑树来实现键的排序和查找。
  • 这里的顺序,指的是以键值为依据,按照某种规则排序。
// TreeMap 实例
Map<String, String> treeMap = new TreeMap<>();

// 向 TreeMap 中添加键值对
treeMap.put("c", "cat");
treeMap.put("a", "apple");
treeMap.put("b", "banana");

// 遍历 TreeMap
for (Map.Entry<String, String> entry : treeMap.entrySet()) {
    System.out.println(entry.getKey() + ": " + entry.getValue());
}
// 按照默认顺序对键进行了排序,输出是 a--b--c

// 查找键值对
String name = "c";
if (treeMap.containsKey(name)) {
    System.out.println("找到了 " + name + ": " + treeMap.get(name));// 输出 c:cat
} 

// 修改键值对
name = "a";
if (treeMap.containsKey(name)) {
    System.out.println("修改前的 " + name + ": " + treeMap.get(name));// 输出 a:apple
    treeMap.put(name, "aovcado");
    System.out.println("修改后的 " + name + ": " + treeMap.get(name));// 输出 a:aovcado
} 

// 删除键值对
name = "b";
if (treeMap.containsKey(name)) {
    System.out.println("删除前的 " + name + ": " + treeMap.get(name));
    treeMap.remove(name);
    System.out.println("删除后的 " + name + ": " + treeMap.get(name));// 输出 b:null
} 

HashMap

  • HashMap 在遍历时是无序的,因此如果需要有序遍历,可以使用 TreeMap。
  • HashMap 是线程不安全的,因此在多线程环境下需要使用 ConcurrentHashMap 来保证线程安全。
  • 可以使用迭代器或 forEach 方法遍历 HashMap 中的键值对。

Constructor

// 无参构造方法
 HashMap<String, Integer> map = new HashMap<>();

// 指定初始容量的构造方法
HashMap<String, Integer> map = new HashMap<>(10);

// 指定初始容量和加载因子的构造方法
HashMap<String, Integer> map = new HashMap<>(10, 0.75f);

// 使用指定 Map 创建新的 HashMap
 Map<String, Integer> initialMap = new HashMap<>();
 initialMap.put("one", 1);
 initialMap.put("two", 2);

 HashMap<String, Integer> map = new HashMap<>(initialMap);

API

// 添加元素
V put(K key, V value);// 将指定的键与值关联
V putIfAbsent(K key, V value);// 如果键尚未与值关联,则将键和值关联
void putAll(Map<? extends K, ? extends V> m);// 将指定 map 中的所有映射关系复制到此 map 中

// 删除元素
V remove(Object key);// 移除键所对应的映射关系
boolean remove(Object key, Object value);// 只有在键被映射到指定值时,才移除该键的映射关系
void clear();// 移除所有映射关系

// 访问元素
V get(Object key);// 返回与指定键对应的值
V getOrDefault(Object key, V defaultValue);// 返回与指定键对应的值,如果不存在,则返回默认值
boolean containsKey(Object key);// 如果此映射包含指定键的映射关系,则返回 true
boolean containsValue(Object value);// 如果此映射将一个或多个键映射到指定值,则返回 true

// 大小和空检查
int size();// 返回此映射中的键-值映射关系数
boolean isEmpty();// 如果此映射不包含键-值映射关系,则返回 true

// 键和值的集合视图
Set<K> keySet();// 返回此映射中包含的键的 Set 视图
Collection<V> values();// 返回此映射中包含的值的 Collection 视图
Set<Map.Entry<K, V>> entrySet();// 返回此映射中包含的映射关系的 Set 视图
// HashMap 实例
HashMap<String, String> hashMap = new HashMap<>();

// 添加键值对
hashMap.put("苹果", "apple");
hashMap.put("牛油果", "avocado");
hashMap.put("香蕉","Banana");

// 遍历 HashMap
for (String key : hashMap.keySet()) {
    String value = hashMap.get(key);
    System.out.println(key + " 对应的值为:" + value);
}
// HashMap 中,键的顺序是根据键的哈希码计算结果来决定的,因此不保证有序
// 输出为 苹果 -- 香蕉--牛油果

// 获取指定键的值
String value1 = hashMap.get("苹果");
System.out.println("苹果对应的值为:" + value1);

// 修改键对应的值
hashMap.put("苹果", "apple1");
String value2 = hashMap.get("苹果");
System.out.println("修改后苹果对应的值为:" + value2);

// 删除指定键的键值对
hashMap.remove("牛油果");

HashMap 底层

在这里插入图片描述

  • HashMap 的底层是数组+链表/红黑树(取决于桶内元素的数量),能够在 O(1)的时间复杂度内实现元素的添加、删除、查找。
  • 哈希表中的每个位置(数组的每个数组项)都称为一个,每个桶中会存储着一个链表(或者红黑树),装载哈希值相同的键值对(没有相同哈希值的话就只存储一个键值对)。
  • HashMap 在添加元素的时候,需要通过key的哈希码(哈希值)在数组中确定一个位置(索引)。
  • 哈希码是 int 类型的,即大概 40亿的映射空间,只要哈希函数映射得当,应当能有效避免冲突。
  • HashMap 中的 key 是唯一的。如果要存储重复的 key,则后面的值会覆盖前面的值。
  • HashMap 可以根据key快速地查找对应的值——通过哈希函数将键映射到哈希表中的一个索引位置,从而实现快速访问。
  • HashMap 中的 keyvalue 都可以为 null。如果 key 为 null,则将该键值对映射到哈希表的第一个位置。
  • HashMap 有一个初始容量和一个负载因子。初始容量是指哈希表的初始大小,负载因子是指哈希表在扩容之前可以存储的键值对数量与哈希表大小的比率。默认的初始容量是 16,负载因子是 0.75。

Hash 方法

  • hash 方法的作用:基于键的 hashCode 计算出一个哈希值,该哈希值用于确定键值对在哈希表中的存储位置。这种哈希机制能够有效地分散存储位置,从而实现快速访问。

要分析 Hash 方法,先要知道什么方法中调用了 Hash 方法。

以下是 Put 方法的源码,该方法调用了 putVal 方法,传入的参数中正是 Hash 方法

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

hash 方法源码:

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

下面是对该方法的一些解释:

  • 参数 key:需要计算哈希码的键值。
  • key == null ? 0 : (h = key.hashCode()) ^ (h >>> 16):这是一个三目运算符,如果键值为 null,则哈希码为 0(实际效果是:如果键为 null,则存放在数组第一个位置);否则,通过调用 hashCode() 方法获取键的哈希码,并将其与右移 16 位的哈希码进行异或运算。
  • ^ 异或运算符:是 Java 中的一种运算符,它用于将两个数的二进制位进行比较,如果相同则为 0,不同则为 1。
  • h >>> 16:将哈希码向右移动 16 位,相当于将原来的哈希码分成了两个 16 位的部分。
    最终返回的是经过异或运算后得到的哈希码值。

前面提到,int 类型的哈希码有效范围约 40亿,即可以标识长度约为 40亿的数组。

然而,内存中显然中无法存放那么大的数组(实际上,哈希表的初始长度为 16)。

因此,键值对最终存放位置,无法直接按照哈希码对应下标存放,而是通过对哈希码取余,得到的下标即存放位置。

扩容机制

如果要扩容的话,就需要新建一个大的数组,然后把之前小的数组的元素复制过去,并且要重新计算哈希值和重新分配桶(重新散列),这个过程比较耗时

为什么要扩容

Java 数组一旦初始化后大小就无法改变了,所以就有了 ArrayList 这种可以自动扩容的"动态数组"。
HashMap 的底层用的也是数组。

但前面提到,其数组内每个元素,都是一个链表(J8 中,链表长度超过 8 自动转换为红黑树),这么一个链表称为桶,理论上,尽管数组本身初始长度是 16,但桶的容量是无限的。

然而,这并不意味着 HashMap 不需要扩容。

首先明确规则:Hash 表查找时间为 O(1),但链表查找时间为 O(n)。然后举一个极端的例子:
假设数组长度为 1,n 个元素全部存进桶内,由于桶内链表查找时间为 O(n),Hash 表实际上并没有起到任何加速查找的作用。

此时就需要将数组扩容。

load factor 加载因子

HashMap 的加载因子是指哈希表中填充元素的个数与桶的数量的比值,当元素个数达到负载因子与桶的数量的乘积时,就需要进行扩容。这个值一般选择 0.75。

  • 如果负载因子过大:填充因子较多,那么哈希表中的元素就会越来越多地聚集在少数的桶中,这就导致了冲突的增加,这些冲突会导致查找、插入和删除操作的效率下降。同时,这也会导致需要更频繁地进行扩容,进一步降低了性能。
  • 如果负载因子过小:那么桶的数量会很多,虽然可以减少冲突,但是在空间利用上面也会有浪费。

总之,选择 0.75 这个值是为了在时间和空间成本之间达到一个较好的平衡点,既可以保证哈希表的性能表现,又能够充分利用空间。

潜在问题:线程不安全

具体来说,如果 A线程正在遍历 HashMap 的链表时,B线程对该链表进行了修改(比如添加了一个节点),那么就会导致链表的结构发生变化,从而破坏了当前线程正在进行的遍历操作,就可能导致遍历失败或者出现死循环等问题。

为了解决这个问题,Java 提供了线程安全的 HashMap 实现类 ConcurrentHashMap
ConcurrentHashMap 内部采用了分段锁(Segment),将整个 Map 拆分为多个小的 HashMap,每个小的 HashMap 都有自己的锁,不同的线程可以同时访问不同的小 Map,从而实现了线程安全。在进行插入、删除和扩容等操作时,只需要锁住当前小 Map,不会对整个 Map 进行锁定,提高了并发访问的效率。

多线程下扩容会死循环

但是 J8 已经修复了这个问题。

多线程下 put 会导致元素丢失

多线程同时执行 put 操作时,如果计算出来的索引位置是相同的,可能造成前一个 key 被后一个 key 覆盖,进而导致元素的丢失。
这个很好理解,不详细叙述。

put 和 get 并发时会导致 get 到 null

线程 A 执行 put 时,因为元素个数超出阈值而导致出现扩容,线程 B 此时执行 get,就有可能出现这个问题。

根本的原因是:数组扩容的过程,老数组中的内容是 transfer 到新数组(而不是 copy)。

因此当线程 A 开始扩容,其步骤是:

  1. 创建新的数组 newTable。
  2. 开始将旧数组中的元素转移到 newTable。

如果线程 B 执行 get 操作,那么在线程 A 更新 table 引用之前,线程 B 看到的是旧的 table,并尝试读取数据。但元素还没有被完全转移到新的数组中,这就可能导致数据不一致。

  • 3
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值