Java 集合框架超全解 · 底层源码|集合对比|HashMap 扩容原理

Java集合框架

Java 集合框架可以分为两条大的支线:

①、Collection,主要由 List、Set、Queue 组成:

  • List 代表有序、可重复的集合,典型代表就是封装了动态数组的 ArrayList 和封装了链表的 LinkedList
  • Set 代表无序、不可重复的集合,典型代表就是 HashSet 和 TreeSet;
  • Queue 代表队列,典型代表就是双端队列 ArrayDeque,以及优先级队列 PriorityQueue

②、Map,代表键值对的集合,典型代表就是 HashMap

一、初识

1. List

有序、可重复

1.1 ArrayList

由数组实现的,支持随机存取

尾部插入和删除元素会比较快捷,从中间插入和删除元素会比较低效,因为涉及到数组元素的复制和移动

内部数组的容量不足时会自动扩容,因此当元素非常庞大的时候,效率会比较低

java 复制代码
// 创建一个集合
ArrayList<String> list = new ArrayList<String>();
// 添加元素
list.add("王二");
list.add("沉默");
list.add("陈清扬");

// 遍历集合 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, "王二狗");
// 遍历集合
for (String s : list) {
    System.out.println(s);
}

1.2 LinkedList

由双向链表实现的,不支持随机存取,只能从一端开始遍历,直到找到需要的元素后返回

任意位置插入和删除元素都很方便,因为只需要改变前一个节点和后一个节点的引用即可,不像 ArrayList 那样需要复制和移动数组元素

每个元素都存储了前一个和后一个节点的引用,所以相对来说,占用的内存空间会比 ArrayList 多一些。

curd和 ArrayList 几乎没什么差别。

1.3 Vector 和 Stack

ArrayList 和 Vector 非常相似,只不过 Vector 是线程安全的,像 get、set、add 这些方法都加了 synchronized 关键字,就导致执行效率会比较低,所以现在已经很少用了。

synchronized重量级锁 。当线程申请不到锁时,操作系统会将该线程挂起。这个过程涉及用户态(User Mode)内核态(Kernel Mode)的转换。上下文切换开销巨大,涉及寄存器状态保存、堆栈切换等。

java 复制代码
// 比如add方法
public synchronized boolean add(E e) {
    elementData[elementCount++] = e;
    return true;
}
Stack 是 Vector 的一个子类,本质上也是由动态数组实现的,只不过还实现了先进后出的功能。

不过,由于 Stack 执行效率比较低(方法上同样加了 synchronized 关键字),就被双端队列 ArrayDeque 取代了(下面会介绍)。

增删改查,和 ArrayList 和 LinkedList 几乎一样。

2. Set

无序、不重复

2.1 HashSet

HashSet 其实是由 HashMap 实现的,只不过值由一个固定的 Object 对象填充,而键用于操作。
java 复制代码
public class HashSet<E>
    extends AbstractSet<E>
    implements Set<E>, Cloneable, java.io.Serializable
{
    private transient HashMap<E,Object> map;

    // Dummy value to associate with an Object in the backing Map
    private static final Object PRESENT = new Object();

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

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

    public boolean remove(Object o) {
        return map.remove(o)==PRESENT;
    }
}
A. 为什么需要那个 PRESENT
  • HashMap 是存键值对(Key-Value)的,而 HashSet 只需要存元素(Key)。

  • 逻辑: 为了能复用 HashMapHashSet 把你存入的元素作为 mapKey ,而 Value 则统一用这个名为 PRESENT 的虚拟对象填充。

  • 为什么用 static final? 因为 PRESENT 只是个占位符,所有元素共享同一个对象,省内存

B. add(E e) 的逻辑
  • 底层: 调用了 map.put(e, PRESENT)
  • 返回值奥秘: * 在 HashMap 中,如果 put 一个新 Key,会返回 null
  • 如果 Key 已经存在,会返回旧的 Value(也就是 PRESENT)。
  • 结论: 如果返回 null,说明是新插入,add 成功返回 true;如果返回的不是 null,说明元素重复了,add 失败返回 false这就是 HashSet 保证元素唯一的原理。
C. remove(Object o) 的逻辑
  • 同理,调用 map 的删除方法。如果删掉的 Key 对应的 Value 是 PRESENT,说明删除成功。
面经:

既然 HashSet 用的就是 HashMap,那它的无序性是怎么来的?

  • 答: 因为 HashMap 的 Key 是根据 hashCode 分布在数组桶位里的,所以遍历出来的顺序和插入顺序不一致。

HashSet 允许存 null 吗?

  • 答: 允许。因为 HashMap 的 Key 允许为 null(放在数组的 0 号位置)。

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

为什么 map 要加 transient 关键字?

  • 答: 因为 HashSet 想要自己控制序列化的逻辑,而不是简单地把整个 HashMap 序列化,这样可以提高效率。

HashSet 并不常用,比如,如果我们需要按照顺序存储一组元素,那么 ArrayList 和 LinkedList 可能更适合;如果我们需要存储键值对并根据键进行查找,那么 HashMap 可能更适合。

HashSet 主要用于去重,比如,我们需要统计一篇文章中有多少个不重复的单词,就可以使用 HashSet 来实现。因为它是用 HashMap 实现的,HashMap的键是唯一的(哈希值),相同键的值会覆盖掉原来的值

java 复制代码
HashSet<String> set = new HashSet<>();

// 添加元素
set.add("沉默");
set.add("王二");
set.add("陈清扬");
set.add("沉默");

// 输出 HashSet 的元素个数
System.out.println("HashSet size: " + set.size()); // output: 3

// 遍历 HashSet
for (String s : set) {
    System.out.println(s);
}

2.2 LinkedHashSet

它继承自 HashSet,并且使用链表维护了元素的插入顺序。因此,它既具有 HashSet 的快速查找、插入和删除操作的优点,又可以维护元素的插入顺序。
其实是由 LinkedHashMap 实现的。

这是 LinkedHashSet 的无参构造方法:

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

super 的意思是它将调用父类的 HashSet 的一个有参构造方法:

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

LinkedHashSet<String> set = new LinkedHashSet<>();
// 添加元素
set.add("沉默");
set.add("王二");
set.add("陈清扬");

// 删除元素
set.remove("王二");

// 修改元素
set.remove("沉默");
set.add("沉默的力量");

// 查找元素
boolean hasChenQingYang = set.contains("陈清扬");
System.out.println("set包含陈清扬吗?" + hasChenQingYang);

2.3 TreeSet

由TreeMap实现,同样的键位,值由一个固定的Object对象填充。

需要注意的是,TreeSet 不允许插入 null 元素,否则会抛出 NullPointerException 异常。(底层是 TreeMap,它是一棵 红黑树(一种自平衡的二叉搜索树)。)

java 复制代码
// 创建一个 TreeSet 对象
TreeSet<String> set = new TreeSet<>();

// 添加元素
set.add("沉默");
set.add("王二");
set.add("陈清扬");
System.out.println(set); // 输出 [沉默, 王二, 陈清扬]

// 删除元素
set.remove("王二");
System.out.println(set); // 输出 [沉默, 陈清扬]

// 修改元素:TreeSet 中的元素不支持直接修改,需要先删除再添加
set.remove("陈清扬");
set.add("陈青阳");
System.out.println(set); // 输出 [沉默, 陈青阳]

// 查找元素
System.out.println(set.contains("沉默")); // 输出 true

Set 集合不是关注的重点,因为底层都是由 Map 实现的,为什么要用 Map 实现呢?

答: Map 的键不重复、无序

3. Queue

3.1 ArrayDque

基于数组实现的双端队列,为了满足可以同时在数组两端插入或删除元素的需求,数组必须是循环的,也就是说数组的任何一点都可以被看作是起点或者终点。
注意: 因为 没实现 List 接口,所以你在代码里不能直接用下标取值,这是设计上的权衡。

硬要转下标: 先用 toArray() 转成普通数组,或者干脆直接改用 ArrayList

head 指向队首的第一个有效的元素,tail 指向队尾第一个可以插入元素的空位,因为是循环数组,所以 head 不一定从是从 0 开始,tail 也不一定总是比 head 大。

java 复制代码
// 创建一个ArrayDeque
ArrayDeque<String> deque = new ArrayDeque<>();

// 添加元素
deque.add("沉默");
deque.add("王二");
deque.add("陈清扬");

// 删除元素
deque.remove("王二");

// 修改元素
deque.remove("沉默");
deque.add("沉默的力量");

// 查找元素
boolean hasChenQingYang = deque.contains("陈清扬");
System.out.println("deque包含陈清扬吗?" + hasChenQingYang);

3.2 LinkedList

应该归在 List 下,只不过,它也实现了 Deque 接口,可以作为队列来使用。等于说,LinkedList 同时实现了 Stack、Queue。
java 复制代码
public class LinkedList<E>
    extends AbstractSequentialList<E>
    implements List<E>, Deque<E>, Cloneable, java.io.Serializable

换句话说,LinkedList 和 ArrayDeque 都是 Java 集合框架中的双向队列(deque),它们都支持在队列的两端进行元素的插入和删除操作。不过,LinkedList 和 ArrayDeque 在实现上有一些不同:

  1. 底层实现方式不同:LinkedList 是基于链表实现的,而 ArrayDeque 是基于数组实现的。

  2. 随机访问的效率不同:由于底层实现方式的不同,LinkedList 对于随机访问的效率较低,时间复杂度为 O(n),而 ArrayDeque 可以通过下标随机访问元素,时间复杂度为 O(1)。

    解释:

    LinkedList (O(n)):如果你想找第 5 个人,你必须从第 1 个开始问:"谁是你后面那个?",问 4 次才能找到。

    ArrayDeque (O(1)) :虽然 ArrayDeque 并没有提供 get(i) 这样的公共 API(因为它主要为了队列操作设计),但从底层逻辑上说,它计算位置只需要一个公式:(head + index) % array.length。)

  3. 内存占用不同:由于 LinkedList 是基于链表实现的,它在存储元素时需要额外的空间来存储链表节点,因此内存占用相对较高,而 ArrayDeque 是基于数组实现的,内存占用相对较低。

一段 LinkedList 作为队列时候的增删改查吧,注意和它作为 List 的时候有很大的不同。

复制代码
// 创建一个 LinkedList 对象
LinkedList<String> queue = new LinkedList<>();

// 添加元素
queue.offer("沉默");
queue.offer("王二");
queue.offer("陈清扬");
System.out.println(queue); // 输出 [沉默, 王二, 陈清扬]

// 删除元素
queue.poll();
System.out.println(queue); // 输出 [王二, 陈清扬]

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

// 查找元素:LinkedList 中的元素可以使用 get() 方法进行查找
System.out.println(queue.get(0)); // 输出 陈清扬
System.out.println(queue.contains("沉默")); // 输出 false

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

3.3 PriorityQueue

出队顺序与元素的优先级有关,执行 remove 或者 poll 方法,返回的总是优先级最高的元素。
java 复制代码
// 创建一个 PriorityQueue 对象
PriorityQueue<String> queue = new PriorityQueue<>();

// 添加元素
queue.offer("沉默");
queue.offer("王二");
queue.offer("陈清扬");
System.out.println(queue); // 输出 [沉默, 王二, 陈清扬]

// 删除元素
queue.poll();
System.out.println(queue); // 输出 [王二, 陈清扬]

// 修改元素:PriorityQueue 不支持直接修改元素,需要先删除再添加
String first = queue.poll();
queue.offer("张三");
System.out.println(queue); // 输出 [张三, 陈清扬]

// 查找元素:PriorityQueue 不支持随机访问元素,只能访问队首元素
System.out.println(queue.peek()); // 输出 张三
System.out.println(queue.contains("陈清扬")); // 输出 true

// 通过 for 循环的方式查找陈清扬
for (String element : queue) {
    if (element.equals("陈清扬")) {
        System.out.println("找到了:" + element);
        break;
    }
}
java 复制代码
class StudentComparator implements Comparator<Student> {
    @Override
    public int compare(Student s1, Student s2) {
        // 比较总成绩
        return Integer.compare(s2.getChineseScore() + s2.getMathScore(),
                s1.getChineseScore() + s1.getMathScore());
        //compare(o1, o2) 约定中,有一个核心逻辑:如果返回正数,前面的元素(o1)就会排在后面。
    }
}
 // 创建一个按照总成绩排序的优先级队列
  PriorityQueue<Student> queue = new PriorityQueue<>(new StudentComparator());
// Collections.sort(students, new StudentComparator());

3. Map

保存的是键值对,键要求保持唯一性,值可以重复

3.1 HashMap

实现了 Map 接口,可以根据键快速地查找对应的值------通过哈希函数将键映射到哈希表中的一个索引位置,从而实现快速访问。

这里先大致了解一下 HashMap 的特点

  • HashMap 中的键和值都可以为 null。如果键为 null,则将该键映射到哈希表的第一个位置。
  • 可以使用迭代器或者 forEach 方法遍历 HashMap 中的键值对。
  • HashMap 有一个初始容量和一个负载因子。初始容量是指哈希表的初始大小,负载因子是指哈希表在扩容之前可以存储的键值对数量与哈希表大小的比率。默认的初始容量是 16,负载因子是 0.75。
java 复制代码
// 创建一个 HashMap 对象
HashMap<String, String> hashMap = new HashMap<>();

// 添加键值对
hashMap.put("沉默", "cenzhong");
hashMap.put("王二", "wanger");
hashMap.put("陈清扬", "chenqingyang");

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

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

// 删除指定键的键值对
hashMap.remove("王二");

// 遍历 HashMap
for (String key : hashMap.keySet()) {
    String value = hashMap.get(key);
    System.out.println(key + " 对应的值为:" + value);
}

3.2 LinkedHashMap

HashMap 已经非常强大了,但它是无序的,没有维持键值对的插入顺序。如果我们需要一个有序的 Map,就要用到 LinkedHashMap。LinkedHashMap 是 HashMap 的子类,它使用链表来记录插入/访问元素的顺序。

LinkedHashMap 可以看作是 HashMap + LinkedList 的合体,它使用了哈希表来存储数据,又用了双向链表来维持顺序。

java 复制代码
// 创建一个 LinkedHashMap,插入的键值对为 沉默 王二 陈清扬
LinkedHashMap<String, String> linkedHashMap = new LinkedHashMap<>();
linkedHashMap.put("沉默", "cenzhong");
linkedHashMap.put("王二", "wanger");
linkedHashMap.put("陈清扬", "chenqingyang");

// 遍历 LinkedHashMap
for (String key : linkedHashMap.keySet()) {
    String value = linkedHashMap.get(key);
    System.out.println(key + " 对应的值为:" + value);
}
//输出
沉默 对应的值为:cenzhong
王二 对应的值为:wanger
陈清扬 对应的值为:chenqingyang

3.3 TreeMap

实现了 SortedMap 接口,可以自动将键按照自然顺序或指定的比较器顺序排序,并保证其元素的顺序。内部使用红黑树来实现键的排序和查找。
java 复制代码
// 创建一个 TreeMap 对象
Map<String, String> treeMap = new TreeMap<>();

// 向 TreeMap 中添加键值对
treeMap.put("沉默", "cenzhong");
treeMap.put("王二", "wanger");
treeMap.put("陈清扬", "chenqingyang");

// 查找键值对
String name = "沉默";
if (treeMap.containsKey(name)) {
    System.out.println("找到了 " + name + ": " + treeMap.get(name));
} else {
    System.out.println("没有找到 " + name);
}

// 修改键值对
name = "王二";
if (treeMap.containsKey(name)) {
    System.out.println("修改前的 " + name + ": " + treeMap.get(name));
    treeMap.put(name, "newWanger");
    System.out.println("修改后的 " + name + ": " + treeMap.get(name));
} else {
    System.out.println("没有找到 " + name);
}

// 删除键值对
name = "陈清扬";
if (treeMap.containsKey(name)) {
    System.out.println("删除前的 " + name + ": " + treeMap.get(name));
    treeMap.remove(name);
    System.out.println("删除后的 " + name + ": " + treeMap.get(name));
} else {
    System.out.println("没有找到 " + name);
}

// 遍历 TreeMap
for (Map.Entry<String, String> entry : treeMap.entrySet()) {
    System.out.println(entry.getKey() + ": " + entry.getValue());
}

与 HashMap 不同的是,TreeMap 会按照键的顺序来进行排序。

既然要准备面试,光背分类是不够的,面试官常会顺着分类问你:"那你平时怎么操作这些集合?"

掌握以下这些 核心 API,不仅能应付面试,写代码时也能得心应手。


1. List 接口常用 API (有序、可重复)

List 是开发中最常用的,重点在于下标操作排序

  • 添加: add(E e) (末尾添加), add(int index, E element) (指定位置插入)。
  • 获取/修改: get(int index) (按索引查), set(int index, E element) (按索引改)。
  • 删除: remove(int index), remove(Object o)
  • 判断: size(), isEmpty(), contains(Object o)
  • 排序 (Java 8+): list.sort(Comparator.naturalOrder())
  • 转换: toArray() (转数组)。

2. Set 接口常用 API (无序、唯一)

Set 的 API 和 List 类似,但没有下标操作(因为它是无序的)。

  • 添加: add(E e) (如果元素已存在,返回 false)。
  • 删除: remove(Object o)
  • 判断: contains(Object o) (这是 Set 的强项,查询极快)。
  • 交集/并集: retainAll (交集), addAll (并集)。

3. Queue 接口常用 API (先进先出)

面试时如果提到 Queue,一定要分清"抛异常"和"返回特殊值"的两组方法。

  • 入队: offer(E e) (推荐,失败返回 false) vs add(e) (失败抛异常)。
  • 出队: poll() (推荐,队空返回 null) vs remove() (队空抛异常)。
  • 看队头: peek() (推荐,队空返回 null) vs element() (队空抛异常)。

4. Map 接口常用 API (键值对)

Map 不属于 Collection 接口,它的操作逻辑完全不同。

  • 存取: put(K key, V value), get(Object key)
  • 安全取值: getOrDefault(key, defaultValue) (防空指针的神器)。
  • 判断: containsKey(key), containsValue(value)
  • 删除: remove(key)
  • 遍历 (面试高频):
    • keySet():获取所有键。
    • values():获取所有值。
    • entrySet():获取所有键值对(性能最高)。
  • 修改: putIfAbsent(key, value) (不存在才存)。

💡 面试突击:集合类关系图

为了方便你记忆它们之间的继承关系,可以参考这张经典的结构图:


🚀 避坑指南(面试常问)

  1. ArrayList vs LinkedList:
    • ArrayList 查改快(数组下标直达 O ( 1 ) O(1) O(1)),增删慢(涉及元素移动)。
    • LinkedList 增删快(改指针即可 O ( 1 ) O(1) O(1)),查询慢(需要从头遍历)。
  2. HashMap 的底层:
    • 数组 + 链表 + 红黑树(Java 8 改进)。
  3. 线程安全:
    • 以上提到的 API 大多是线程不安全 的。如果面试官问线程安全,记得提 Vector (老古董) 或 ConcurrentHashMap (现代方案)。

二、源码分析

1. ArrayList

ArrayList 实现了 List 接口,并且是基于数组实现的。

ArrayList 在数组的基础上实现了自动扩容,并且提供了比数组更丰富的预定义方法(各种增删改查),非常灵活。
ArrayList 的底层是 Object 数组,允许存储 null 元素

ArrayList 可以称得上是集合框架方面最常用的类了,可以和 HashMap 一较高下。

java 复制代码
List<String> alist = new ArrayList<>();

由于 ArrayList 实现了 List 接口,所以 alist 变量的类型可以是 List 类型;new 关键字声明后的尖括号中可以不再指定元素的类型,因为编译器可以通过前面尖括号中的类型进行智能推断。

此时会调用无参构造方法(见下面的代码)创建一个空的数组,常量DEFAULTCAPACITY_EMPTY_ELEMENTDATA的值为 {}

java 复制代码
// 有参构造(容量=0)专用:空数组
private static final Object[] EMPTY_ELEMENTDATA = {};
// 无参构造专用:默认空数组
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};


public ArrayList() {
    this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}
// 有参构造(0)
public ArrayList(int initialCapacity) {
    if (initialCapacity > 0) {
        // ... 创建指定大小数组
    } else if (initialCapacity == 0) {
        this.elementData = EMPTY_ELEMENTDATA; // 赋值第二个空数组!!!
    }
}

如果非常确定 ArrayList 中元素的个数,在创建的时候还可以指定初始大小。

java 复制代码
List<String> alist = new ArrayList<>(20);

这样做的好处是,可以有效地避免在添加新的元素时进行不必要的扩容

1.1 添加元素

java 复制代码
alist.add("frank");

堆栈过程图示:

add(element)

└── if (size == elementData.length) // 判断是否需要扩容

├── grow(minCapacity) // 扩容

│ └── newCapacity = oldCapacity + (oldCapacity >> 1) // 计算新的数组容量

│ └── Arrays.copyOf(elementData, newCapacity) // 创建新的数组

├── elementData[size++] = element; // 添加新元素

└── return true; // 添加成功

来具体看一下,先是 add() 方法的源码(已添加好详细地注释)

java 复制代码
/**
 * 将指定元素添加到 ArrayList 的末尾
 * @param e 要添加的元素
 * @return 添加成功返回 true
 */
public boolean add(E e) {
    ensureCapacityInternal(size + 1);  // 确保 ArrayList 能够容纳新的元素
    elementData[size++] = e; // 在 ArrayList 的末尾添加指定元素
    return true;
}

当你调用 add(e) 时,入参是 size + 1(即:我最少需要能装下这么多元素的空间)。它的内部逻辑通常遵循这三个步骤:

第一步:计算容量
java 复制代码
/**
 * @param minCapacity 指定容量的最小值
 */
private void ensureCapacityInternal(int minCapacity) {
    if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) { // 如果 elementData 还是默认的空数组
        minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity); // 使用 DEFAULT_CAPACITY 和指定容量的最小值中的较大值
    }

    ensureExplicitCapacity(minCapacity); // 确保容量能够容纳指定容量的元素
}
  • 参数 minCapacity 为 1(size+1 传过来的)
  • elementData 为存放 ArrayList 元素的底层数组,前面声明 ArrayList 的时候讲过了,此时为空 {}
  • DEFAULTCAPACITY_EMPTY_ELEMENTDATA 前面也讲过了,为 {}

所以,if 条件此时为 true,if 语句minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity)要执行。

DEFAULT_CAPACITY 为 10(见下面的代码),所以执行完这行代码后,minCapacity 为 10,Math.max() 方法的作用是取两个当中最大的那个。

java 复制代码
// JDK 固定定义:默认容量就是 10
private static final int DEFAULT_CAPACITY = 10;

接下来执行 ensureExplicitCapacity() 方法,来看一下源码:

java 复制代码
/**
 * 检查并确保集合容量足够,如果需要则增加集合容量。
 *
 * @param minCapacity 所需最小容量
 */
private void ensureExplicitCapacity(int minCapacity) {
    // 检查是否超出了数组范围,确保不会溢出
    if (minCapacity - elementData.length > 0)
        // 如果需要增加容量,则调用 grow 方法
        grow(minCapacity);
}

此时:

  • 参数 minCapacity 为 10
  • elementData.length 为 0(数组为空)

所以 10-0>0,if 条件为 true,进入 if 语句执行 grow() 方法,来看源码:

复制代码
/**
 * 扩容 ArrayList 的方法,确保能够容纳指定容量的元素
 * @param minCapacity 指定容量的最小值
 */
private void grow(int minCapacity) {
    // 检查是否会导致溢出,oldCapacity 为当前数组长度
    int oldCapacity = elementData.length;
    int newCapacity = oldCapacity + (oldCapacity >> 1); // 扩容至原来的1.5倍
    if (newCapacity - minCapacity < 0) // 如果还是小于指定容量的最小值
        newCapacity = minCapacity; // 直接扩容至指定容量的最小值
    if (newCapacity - MAX_ARRAY_SIZE > 0) // 如果超出了数组的最大长度
        newCapacity = hugeCapacity(minCapacity); // 扩容至数组的最大长度
    // 将当前数组复制到一个新数组中,长度为 newCapacity
    elementData = Arrays.copyOf(elementData, newCapacity);
}

新数组 = Arrays.copyOf(旧数组, 新数组的长度);

此时:

  • 参数 minCapacity 为 10
  • 变量 oldCapacity 为 0

所以 newCapacity 也为 0,于是 newCapacity - minCapacity 等于 -10 小于 0,于是第一个 if 条件为 true,执行第一个 if 语句 newCapacity = minCapacity,然后 newCapacity 为 10。

紧接着执行 elementData = Arrays.copyOf(elementData, newCapacity);,也就是进行数组的第一次扩容,长度为 10。

回到 add() 方法:

复制代码
public boolean add(E e) {
    ensureCapacityInternal(size + 1);
    elementData[size++] = e;
    return true;
}

执行 elementData[size++] = e

此时:

  • size 为 0
  • e 为 "沉默王二"

所以数组的第一个元素(下标为 0) 被赋值为"沉默王二",接着返回 true,第一次 add 方法执行完毕。

PS:add 过程中会遇到一个令新手感到困惑的右移操作符 >>,借这个机会来解释一下。

ArrayList 在第一次执行 add 后会扩容为 10,那 ArrayList 第二次扩容发生在什么时候呢?

答案是添加第 11 个元素时,大家可以尝试分析一下这个过程。

为什么要这么设计?

如果数组每次只增加 1 个容量,那么每次 add 都要进行 Arrays.copyOf(内存拷贝),性能会极其低下。

  • 1.5 倍的艺术: 这是一个平衡点。扩容太快(比如 2 倍、4 倍)会浪费大量内存;扩容太慢(比如 1.1 倍)会导致频繁触发扩容操作。
  • 均摊时间复杂度: 虽然单次 grow 操作是 O ( n ) O(n) O(n),但由于它不是每次都触发,均摊到每一次 add 上的时间复杂度依然是 O(1)

1.2 向指定位置添加元素

除了 add(E e) 方法,还可以通过 add(int index, E element) 方法把元素添加到 ArrayList 的指定位置:

复制代码
alist.add(0, "沉默王三");

add(int index, E element) 方法的源码如下:

java 复制代码
/**
 * 在指定位置插入一个元素。
 *
 * @param index   要插入元素的位置
 * @param element 要插入的元素
 * @throws IndexOutOfBoundsException 如果索引超出范围,则抛出此异常
 */
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)方法会调用到一个非常重要的本地方法 System.arraycopy(),它会对数组进行复制(要插入位置上的元素往后复制)。
  • elementData:表示要复制的源数组,即 ArrayList 中的元素数组。
  • index:表示源数组中要复制的起始位置,即需要将 index 及其后面的元素向后移动一位。
  • elementData:表示要复制到的目标数组,即 ArrayList 中的元素数组。
  • index + 1:表示目标数组中复制的起始位置,即将 index 及其后面的元素向后移动一位后,应该插入到的位置。
  • size - index:表示要复制的元素个数,即需要将 index 及其后面的元素向后移动一位,需要移动的元素个数为 size - index。

1.3 更新元素

java 复制代码
alist.set(0, "小高");
复制代码
/**
 * 用指定元素替换指定位置的元素。
 *
 * @param index   要替换的元素的索引
 * @param element 要存储在指定位置的元素
 * @return 先前在指定位置的元素
 * @throws IndexOutOfBoundsException 如果索引超出范围,则抛出此异常
 */
public E set(int index, E element) {
    rangeCheck(index); // 检查索引是否越界

    E oldValue = elementData(index); // 获取原来在指定位置上的元素
    elementData[index] = element; // 将新元素替换到指定位置上
    return oldValue; // 返回原来在指定位置上的元素
}
set() 方法会先对指定的下标进行检查,看是否越界,然后替换新值并返回旧值。

1.4 删除元素

remove(int index) 方法用于删除指定下标位置上的元素,remove(Object o) 方法用于删除指定值的元素。
java 复制代码
alist.remove(1);
alist.remove("沉默王四");

先来看 remove(int index) 方法的源码:

复制代码
/**
 * 删除指定位置的元素。
 *
 * @param index 要删除的元素的索引
 * @return 先前在指定位置的元素
 * @throws IndexOutOfBoundsException 如果索引超出范围,则抛出此异常
 */
public E remove(int index) {
    rangeCheck(index); // 检查索引是否越界

    E oldValue = elementData(index); // 获取要删除的元素

    int numMoved = size - index - 1; // 计算需要移动的元素个数
    if (numMoved > 0) // 如果需要移动元素,就用 System.arraycopy 方法实现
        System.arraycopy(elementData, index+1, elementData, index,
                numMoved);
    elementData[--size] = null; // 将数组末尾的元素置为 null,让 GC 回收该元素占用的空间

    return oldValue; // 返回被删除的元素
}
删除元素时,需要将删除位置后面的元素向前移动一位,以填补删除位置留下的空缺。如果需要移动元素,则需要使用 System.arraycopy 方法将删除位置后面的元素向前移动一位。最后,将数组末尾的元素置为 null,以便让垃圾回收机制回收该元素占用的空间。

再来看 remove(Object o) 方法的源码:

java 复制代码
/**
 * 删除列表中第一次出现的指定元素(如果存在)。
 *
 * @param o 要删除的元素
 * @return 如果列表包含指定元素,则返回 true;否则返回 false
 */
public boolean remove(Object o) {
    if (o == null) { // 如果要删除的元素是 null
        for (int index = 0; index < size; index++) // 遍历列表
            if (elementData[index] == null) { // 如果找到了 null 元素
                fastRemove(index); // 调用 fastRemove 方法快速删除元素
                return true; // 返回 true,表示成功删除元素
            }
    } else { // 如果要删除的元素不是 null
        for (int index = 0; index < size; index++) // 遍历列表
            if (o.equals(elementData[index])) { // 如果找到了要删除的元素
                fastRemove(index); // 调用 fastRemove 方法快速删除元素
                return true; // 返回 true,表示成功删除元素
            }
    }
    return false; // 如果找不到要删除的元素,则返回 false
}

该方法通过遍历的方式找到要删除的元素,null 的时候使用 == 操作符判断,非 null 的时候使用 equals() 方法,然后调用 fastRemove() 方法。

  • 有相同元素时,只会删除第一个。

  • 判断两个元素是否相等,可以参考Java如何判断两个字符串是否相等

    /**

    • 快速删除指定位置的元素。
    • @param index 要删除的元素的索引
      */
      private void fastRemove(int index) {
      int numMoved = size - index - 1; // 计算需要移动的元素个数
      if (numMoved > 0) // 如果需要移动元素,就用 System.arraycopy 方法实现
      System.arraycopy(elementData, index+1, elementData, index,
      numMoved);
      elementData[--size] = null; // 将数组末尾的元素置为 null,让 GC 回收该元素占用的空间
      }

同样是调用 System.arraycopy() 方法对数组进行复制和移动。

1.5 查找元素

正序查找一个元素,可以使用 indexOf() 方法;如果要倒序查找一个元素,可以使用 lastIndexOf() 方法。
复制代码
alist.indexOf("沉默王二");
alist.lastIndexOf("沉默王二");

indexOf() 方法的源码:

复制代码
/**
 * 返回指定元素在列表中第一次出现的位置。
 * 如果列表不包含该元素,则返回 -1。
 *
 * @param o 要查找的元素
 * @return 指定元素在列表中第一次出现的位置;如果列表不包含该元素,则返回 -1
 */
public int indexOf(Object o) {
    if (o == null) { // 如果要查找的元素是 null
        for (int i = 0; i < size; i++) // 遍历列表
            if (elementData[i]==null) // 如果找到了 null 元素
                return i; // 返回元素的索引
    } else { // 如果要查找的元素不是 null
        for (int i = 0; i < size; i++) // 遍历列表
            if (o.equals(elementData[i])) // 如果找到了要查找的元素
                return i; // 返回元素的索引
    }
    return -1; // 如果找不到要查找的元素,则返回 -1
}
如果元素为 null 的时候使用"=="操作符,否则使用 equals() 方法。

lastIndexOf() 方法和 indexOf() 方法类似,不过遍历的时候从最后开始。

contains() 方法可以判断 ArrayList 中是否包含某个元素,其内部就是通过 indexOf() 方法实现的:
复制代码
public boolean contains(Object o) {
    return indexOf(o) >= 0;
}

1.6 二分查找

如果 ArrayList 中的元素是经过排序的,就可以使用二分查找法,效率更快。

Collections 类的 sort() 方法可以对 ArrayList 进行排序,该方法会按照字母顺序对 String 类型的列表进行排序。如果是自定义类型的列表,还可以指定 Comparator 进行排序。

这里先简单地了解一下,后面会详细地讲。

复制代码
List<String> copy = new ArrayList<>(alist);
copy.add("a");
copy.add("c");
copy.add("b");
copy.add("d");

Collections.sort(copy);
int index = Collections.binarySearch(copy, "b");

1.7 CRUD的时间复杂度

查询: 时间复杂度为 O(1)

因为 ArrayList 内部使用数组来存储元素,所以可以直接根据索引来访问元素。

复制代码
/**
 * 返回列表中指定位置的元素。
 *
 * @param index 要返回的元素的索引
 * @return 列表中指定位置的元素
 * @throws IndexOutOfBoundsException 如果索引超出范围(index < 0 || index >= size())
 */
public E get(int index) {
    rangeCheck(index); // 检查索引是否合法
    return elementData(index); // 调用 elementData 方法获取元素
}

/**
 * 返回列表中指定位置的元素。
 * 此方法不进行边界检查,因此只应由内部方法和迭代器调用。
 *
 * @param index 要返回的元素的索引
 * @return 列表中指定位置的元素
 */
E elementData(int index) {
    return (E) elementData[index]; // 返回指定索引位置上的元素
}
插入:调用 add() 方法的时间复杂度最好情况为 O(1),最坏情况为 O(n)。
  • 如果在列表末尾添加元素,时间复杂度为 O(1)。
  • 如果要在列表的中间或开头插入元素,则需要将插入位置之后的元素全部向后移动一位,时间复杂度为 O(n)。
删除: 调用remoce(object)方法的时间复杂度最好情况为 O(1),最坏情况为 O(n)。
  • 如果要删除列表末尾的元素,时间复杂度为 O(1)。
  • 如果要删除列表中间或开头的元素,则需要将删除位置之后的元素全部向前移动一位,时间复杂度为 O(n)。
修改:调用 set()方法与查询操作类似,可以直接根据索引来访问元素,时间复杂度为 O(1)。
总结:

ArrayList,如果有个中文名的话,应该叫动态数组,也就是可增长的数组,可调整大小的数组。动态数组克服了静态数组的限制,静态数组的容量是固定的,只能在首次创建的时候指定。而动态数组会随着元素的增加自动调整大小,更符合实际的开发需求。

学习集合框架,ArrayList 是第一课,也是新手进阶的重要一课。要想完全掌握 ArrayList,扩容这个机制是必须得掌握,也是面试中经常考察的一个点。

2. LinkedList

  • 第一层叫做"单向链表",我只有一个后指针,指向下一个数据;
  • 第二层叫做"双向链表",我有两个指针,后指针指向下一个数据,前指针指向上一个数据。
  • 第三层叫做"二叉树",把后指针去掉,换成左右指针。
java 复制代码
/**
 * 链表中的节点类。
 */
private static class Node<E> {
    E item; // 节点中存储的元素
    Node<E> next; // 指向下一个节点的指针
    Node<E> prev; // 指向上一个节点的指针

    /**
     * 构造一个新的节点。
     *
     * @param prev 前一个节点
     * @param element 节点中要存储的元素
     * @param next 后一个节点
     */
    Node(Node<E> prev, E element, Node<E> next) {
        this.item = element; // 存储元素
        this.next = next; // 设置下一个节点
        this.prev = prev; // 设置上一个节点
    }
}

和师兄 ArrayList 一样,我的招式也无外乎"增删改查"这 4 种。在此之前,我们都必须得初始化。

复制代码
LinkedList<String> list = new LinkedList();

师兄在初始化的时候可以指定大小,也可以不指定,等到添加第一个元素的时候进行第一次扩容。而我,没有大小,只要内存够大,我就可以无穷大

1)招式一:增

可以调用 add 方法添加元素:

复制代码
list.add("沉默王二");
list.add("沉默王三");
list.add("沉默王四");

add 方法内部其实调用的是 linkLast 方法:

复制代码
/**
 * 将指定的元素添加到列表的尾部。
 *
 * @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。
2)招式二:删

我这个删的招式还挺多的:

  • remove():删除第一个节点
  • remove(int):删除指定位置的节点
  • remove(Object):删除指定元素的节点
  • removeFirst():删除第一个节点
  • removeLast():删除最后一个节点

remove() 内部调用的是 removeFirst(),所以这两个招式的功效一样。

remove(int) 内部其实调用的是 unlink 方法。

复制代码
/**
 * 删除指定位置上的元素。
 *
 * @param index 要删除的元素的索引
 * @return 从列表中删除的元素
 * @throws IndexOutOfBoundsException 如果索引越界(index &lt; 0 || index &gt;= size())
 */
public E remove(int index) {
    checkElementIndex(index); // 检查索引是否越界
    return unlink(node(index)); // 删除指定位置的节点,并返回节点的元素
}

unlink 方法其实很好理解,就是更新当前节点的 next 和 prev,然后把当前节点上的元素设为 null。

复制代码
/**
 * 从链表中删除指定节点。
 *
 * @param x 要删除的节点
 * @return 从链表中删除的节点的元素
 */
E unlink(Node<E> x) {
    final E element = x.item; // 获取要删除节点的元素
    final Node<E> next = x.next; // 获取要删除节点的下一个节点
    final Node<E> prev = x.prev; // 获取要删除节点的上一个节点

    if (prev == null) { // 如果要删除节点是第一个节点
        first = next; // 将链表的头节点设置为要删除节点的下一个节点
    } else {
        prev.next = next; // 将要删除节点的上一个节点指向要删除节点的下一个节点
        x.prev = null; // 将要删除节点的上一个节点设置为空
    }

    if (next == null) { // 如果要删除节点是最后一个节点
        last = prev; // 将链表的尾节点设置为要删除节点的上一个节点
    } else {
        next.prev = prev; // 将要删除节点的下一个节点指向要删除节点的上一个节点
        x.next = null; // 将要删除节点的下一个节点设置为空
    }

    x.item = null; // 将要删除节点的元素设置为空
    size--; // 减少链表的元素个数
    return element; // 返回被删除节点的元素
}

remove(Object) 内部也调用了 unlink 方法,只不过在此之前要先找到元素所在的节点:

元素为 null 的时候,必须使用 == 来判断;元素为非 null 的时候,要使用 equals 来判断。
复制代码
/**
 * 从链表中删除指定元素。
 *
 * @param o 要从链表中删除的元素
 * @return 如果链表包含指定元素,则返回 true;否则返回 false
 */
public boolean remove(Object o) {
    if (o == null) { // 如果要删除的元素为 null
        for (Node<E> x = first; x != null; x = x.next) { // 遍历链表
            if (x.item == null) { // 如果节点的元素为 null
                unlink(x); // 删除节点
                return true; // 返回 true 表示删除成功
            }
        }
    } else { // 如果要删除的元素不为 null
        for (Node<E> x = first; x != null; x = x.next) { // 遍历链表
            if (o.equals(x.item)) { // 如果节点的元素等于要删除的元素
                unlink(x); // 删除节点
                return true; // 返回 true 表示删除成功
            }
        }
    }
    return false; // 如果链表中不包含要删除的元素,则返回 false 表示删除失败
}

元素为 null 的时候,必须使用 == 来判断;元素为非 null 的时候,要使用 equals 来判断。

removeFirst 内部调用的是 unlinkFirst 方法:

java 复制代码
/**
 * 从链表中删除第一个元素并返回它。
 * 如果链表为空,则抛出 NoSuchElementException 异常。
 *
 * @return 从链表中删除的第一个元素
 * @throws NoSuchElementException 如果链表为空
 */
public E removeFirst() {
    final Node<E> f = first; // 获取链表的第一个节点
    if (f == null) // 如果链表为空
        throw new NoSuchElementException(); // 抛出 NoSuchElementException 异常
    return unlinkFirst(f); // 调用 unlinkFirst 方法删除第一个节点并返回它的元素
}

unlinkFirst 负责的就是把第一个节点毁尸灭迹,并且捎带把后一个节点的 prev 设为 null。

3)招式三:改

可以调用 set() 方法来更新元素:

复制代码
list.set(0, "沉默王五");

来看一下 set() 方法:

复制代码
/**
 * 将链表中指定位置的元素替换为指定元素,并返回原来的元素。
 *
 * @param index 要替换元素的位置(从 0 开始)
 * @param element 要插入的元素
 * @return 替换前的元素
 * @throws IndexOutOfBoundsException 如果索引超出范围(index < 0 || index >= size())
 */
public E set(int index, E element) {
    checkElementIndex(index); // 检查索引是否超出范围
    Node<E> x = node(index); // 获取要替换的节点
    E oldVal = x.item; // 获取要替换节点的元素
    x.item = element; // 将要替换的节点的元素设置为指定元素
    return oldVal; // 返回替换前的元素
}

来看一下node方法:

复制代码
/**
 * 获取链表中指定位置的节点。
 *
 * @param index 节点的位置(从 0 开始)
 * @return 指定位置的节点
 * @throws IndexOutOfBoundsException 如果索引超出范围(index < 0 || index >= size())
 */
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; // 返回指定位置的节点
    }
}

size >> 1:也就是右移一位,相当于除以 2。对于计算机来说,移位比除法运算效率更高,因为数据在计算机内部都是以二进制存储的。

换句话说,node 方法会对下标进行一个初步判断,如果靠近前半截,就从下标 0 开始遍历;如果靠近后半截,就从末尾开始遍历,这样可以提高效率,最大能提高一半的效率。

找到指定下标的节点就简单了,直接把原有节点的元素替换成新的节点就 OK 了,prev 和 next 都不用改动。

4)招式四:查

我这个查的招式可以分为两种:

indexOf(Object):查找某个元素所在的位置
get(int):查找某个位置上的元素

来看一下 indexOf 方法的源码。

复制代码
/**
 * 返回链表中首次出现指定元素的位置,如果不存在该元素则返回 -1。
 *
 * @param o 要查找的元素
 * @return 首次出现指定元素的位置,如果不存在该元素则返回 -1
 */
public int indexOf(Object o) {
    int index = 0; // 初始化索引为 0
    if (o == null) { // 如果要查找的元素为 null
        for (Node<E> x = first; x != null; x = x.next) { // 从头节点开始向后遍历链表
            if (x.item == null) // 如果找到了要查找的元素
                return index; // 返回该元素的索引
            index++; // 索引加 1
        }
    } else { // 如果要查找的元素不为 null
        for (Node<E> x = first; x != null; x = x.next) { // 从头节点开始向后遍历链表
            if (o.equals(x.item)) // 如果找到了要查找的元素
                return index; // 返回该元素的索引
            index++; // 索引加 1
        }
    }
    return -1; // 如果没有找到要查找的元素,则返回 -1
}

get 方法的内核其实还是 node 方法,node 方法之前已经说明过了,这里略过。

复制代码
public E get(int index) {
    checkElementIndex(index);
    return node(index).item;
}

其实,查这个招式还可以演化为其他的一些,比如说:

  • getFirst() 方法用于获取第一个元素;
  • getLast() 方法用于获取最后一个元素;
  • poll()pollFirst() 方法用于删除并返回第一个元素(两个方法尽管名字不同,但方法体是完全相同的);
  • pollLast() 方法用于删除并返回最后一个元素;
  • peekFirst() 方法用于返回但不删除第一个元素。
LinkedList 的挑战

说句实在话,我不是很喜欢和师兄 ArrayList 拿来比较,因为我们各自修炼的内功不同,没有孰高孰低。

虽然师兄经常喊我一声师弟,但我们之间其实挺和谐的。但我知道,在外人眼里,同门师兄弟,总要一较高下的。

比如说,我们俩在增删改查时候的时间复杂度。

也许这就是命运吧,从我进入师门的那天起,这种争论就一直没有停息过。

无论外人怎么看待我们,在我眼里,师兄永远都是一哥,我敬重他,他也愿意保护我。

好戏在后头,等着瞧吧。

我这里先简单聊一下,权当抛砖引玉。

想象一下,你在玩一款游戏,游戏中有一个道具栏,你需要不断地往里面添加、删除道具。如果你使用的是我的师兄 ArrayList,那么每次添加、删除道具时都需要将后面的道具向后移动或向前移动,这样就会非常耗费时间。但是如果你使用的是我 LinkedList,那么只需要将新道具插入到链表中的指定位置,或者将要删除的道具从链表中删除即可,这样就可以快速地完成道具栏的更新。

除了游戏中的道具栏,我 LinkedList 还可以用于实现 LRU(Least Recently Used)缓存淘汰算法。LRU 缓存淘汰算法是一种常用的缓存淘汰策略,它的基本思想是,当缓存空间不够时,优先淘汰最近最少使用的缓存数据。在实现 LRU 缓存淘汰算法时,你可以使用我 LinkedList 来存储缓存数据,每次访问缓存数据时,将该数据从链表中删除并移动到链表的头部,这样链表的尾部就是最近最少使用的缓存数据,当缓存空间不够时,只需要将链表尾部的缓存数据淘汰即可。

总之,各有各的好,且行且珍惜。

Java HashMap详解:源码分析、hash 原理、扩容机制、加载因子、线程不安全

用于存储键值对。在 HashMap 中,每个键都映射到一个唯一的值,可以通过键来快速访问对应的值,算法时间复杂度可以达到 O(1)。

在实际应用中,HashMap 可以用于缓存、索引等场景。例如,可以将用户 ID 作为键,用户信息作为值,将用户信息缓存到 HashMap 中,以便快速查找。又如,可以将关键字作为键,文档 ID 列表作为值,将文档索引缓存到 HashMap 中,以便快速搜索文档。

实现原理是基于哈希表的,它的底层是一个数组,数组的每个位置可能是一个链表或红黑树,也可能只是一个键值对(后面会讲)。当添加一个键值对时,HashMap 会根据键的哈希值计算出该键对应的数组下标(索引),然后将键值对插入到对应的位置。

hash 方法的主要作用是将 key 的 hashCode 值进行处理,得到最终的哈希值。由于 key 的 hashCode 值是不确定的,可能会出现哈希冲突,因此需要将哈希值通过一定的算法映射到 HashMap 的实际存储位置上。

1. hash方法原理

hash 方法的原理是,先获取 key 对象的 hashCode 值,然后将其高位与低位进行异或操作,得到一个新的哈希值。为什么要进行异或操作呢?因为对于 hashCode 的高位和低位,它们的分布是比较均匀的,如果只是简单地将它们加起来或者进行位运算,容易出现哈希冲突,而异或操作可以避免这个问题。
然后将新的哈希值取模(mod),得到一个实际的存储位置。这个取模操作的目的是将哈希值映射到桶(Bucket)的索引上,桶是 HashMap 中的一个数组,每个桶中会存储着一个链表(或者红黑树),装载哈希值相同的键值对(没有相同哈希值的话就只存储一个键值对)。
总的来说,HashMap 的 hash 方法就是将 key 对象的 hashCode 值进行处理,得到最终的哈希值,并通过一定的算法映射到实际的存储位置上。这个过程决定了 HashMap 内部键值对的查找效率。
java 复制代码
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

2. Hashmap扩容机制

数组 + 链表 + 红黑树

数组一旦初始化后大小就无法改变了,所以就有了 ArrayList这种"动态数组",可以自动扩容。

HashMap 的底层用的也是数组。向 HashMap 里不停地添加元素,当数组无法装载更多元素时,就需要对数组进行扩容,以便装入更多的元素;除此之外,容量的提升也会相应地提高查询效率,因为"桶(坑)"更多了嘛,原来需要通过链表存储的(查询的时候需要遍历),扩容后可能就有自己专属的"坑位"了(直接就能查出来)。

threshold = 容量 capacity × 负载因子 loadFactor

它是 HashMap 触发扩容的临界值

当 HashMap 中存储的键值对数量 size ≥ threshold 时,就会执行扩容操作。

复制代码
 // 初始化一个新的数组(大容量) 

   Entry[] newTable = new Entry[newCapacity];    

// 把小数组的元素转移到大数组中    

transfer(newTable, initHashSeedAsNeeded(newCapacity));   

 // 引用新的大数组    table = newTable;  

  // 重新计算阈值  

  threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);

1. 新容量 newCapacity

那 newCapacity 是如何计算的呢?

复制代码
int newCapacity = oldCapacity * 2;
if (newCapacity < 0 || newCapacity >= MAXIMUM_CAPACITY) {
    newCapacity = MAXIMUM_CAPACITY;
} else if (newCapacity < DEFAULT_INITIAL_CAPACITY) {
    newCapacity = DEFAULT_INITIAL_CAPACITY;
}

新容量 newCapacity 被初始化为原容量 oldCapacity 的两倍。然后,如果 newCapacity 超过了 HashMap 的容量限制 MAXIMUM_CAPACITY(2^30),就将 newCapacity 设置为 MAXIMUM_CAPACITY。如果 newCapacity 小于默认初始容量 DEFAULT_INITIAL_CAPACITY(16),就将 newCapacity 设置为 DEFAULT_INITIAL_CAPACITY。这样可以避免新容量太小或太大导致哈希冲突过多或者浪费空间。

Java 8 的时候,newCapacity 的计算方式发生了一些细微的变化。

复制代码
int newCapacity = oldCapacity << 1;

2. transfer 方法

该方法用来转移,将旧的小数组元素拷贝到新的大数组中。

jdk1.7

首先获取新哈希表(数组)的长度 newCapacity,然后遍历旧哈希表中的每个 Entry。对于每个 Entry,使用拉链法将相同 key 值的不同 value 值存储在同一个链表中。如果 rehash 为 true,则需要重新计算键的哈希值,并将新的哈希值存储在 Entry 的 hash 属性中。

接着,根据新哈希表的长度和键的哈希值,计算 Entry 在新数组中的位置 i,然后将该 Entry 添加到新数组的 i 位置上。由于新元素需要被放在链表的头部,因此将新元素的下一个元素设置为当前数组位置上的元素。

遍历完旧哈希表中的所有元素后,转移工作完成,新的哈希表 newTable 已经包含了旧哈希表中的所有元素。

原来数组中的同一个桶中对应的索引index相同,hash不同。重新计算索引后,如果撞了,就继续拉链;如果不撞,就独自占领新桶。
java 复制代码
void transfer(Entry[] newTable, boolean rehash) {
    // 新的容量
    int newCapacity = newTable.length;
    // 遍历小数组
    for (Entry<K,V> e : table) {
        while(null != e) {
            // 拉链法,相同 key 上的不同值
            Entry<K,V> next = e.next;
            // 是否需要重新计算 hash
            if (rehash) {
                e.hash = null == e.key ? 0 : hash(e.key);
            }
            // 根据大数组的容量,和键的 hash 计算元素在数组中的下标
            int i = indexFor(e.hash, newCapacity);

            // 同一位置上的新元素被放在链表的头部
            e.next = newTable[i];

            // 放在新的数组上
            newTable[i] = e;

            // 链表上的下一个元素
            e = next;
        }
    }
}

e.next = newTable[i],也就是使用了单链表的头插入方式,同一位置上新元素总会被放在链表的头部位置;这样先放在一个索引上的元素最终会被放到链表的尾部,这就会导致在旧数组中同一个链表上的元素,通过重新计算索引位置后,有可能被放到了新数组的不同位置上

1.7的扩容: 高并发下 → 产生环形链表,程序死循环;破坏了插入顺序的一致性

1.8的扩容及流程:

(1)扩容前:数组某下标 j 上挂了一条链表

​ 顺序:A → B → C → D(先来 A,然后 B,然后 C,最后 D)

(2)扩容后数组变大一倍,这条链上的所有人只有两个归宿:

  • 归宿 1:还留在原位置 j → 起名低位
  • 归宿 2:去j + 旧数组长度 的新位置 → 起名高位
  • 判断高低位: if ((e.hash & oldCap(旧数组容量,二进制只1位是1)) == 0)只看单独某一位二进制位。

(3)JDK1.8 只干一件事:

  • 把原来 A→B→C→D 这一队人,分到两个小队 ,并且不打乱原来的先后顺序

先把代码 4 个变量翻译成大白话

复制代码
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
  • loHead:低位小队第一个人(队头)
  • loTail:低位小队最后一个人(队尾)
  • hiHead:高位小队第一个人
  • hiTail:高位小队最后一个人

尾插法大白话

新人来了,直接站到队伍最后面,不插队。

现在开始挨个遍历节点:A、B、C、D

原链表顺序:A → B → C → D

第一步:遍历到 A

判断:A 属于低位小队

  1. 低位小队现在没人(loTail=null)

  2. 那 A 既当队长,也是队尾

    低位小队:

    A

第二步:遍历到 B

判断:B 属于高位小队

  1. 高位小队现在没人

  2. B 既当队长,也是队尾

    高位小队:

    B

第三步:遍历到 C

判断:C 属于低位小队

  1. 低位小队已有:A

  2. 把 C 站到队伍最后(尾插,不插队)

  3. 现在低位队尾变成 C

    低位小队:

    A → C

第四步:遍历到 D

判断:D 属于高位小队

  1. 高位小队已有:B

  2. 把 D 站到队伍最后

  3. 现在高位队尾变成 D

    高位小队:

    B → D

遍历完所有节点,结果出炉

  • 低位小队:A → C (保持原来先后顺序
  • 高位小队:B → D (保持原来先后顺序

最后把两个小队挂到新数组

复制代码
// 低位小队留在原位置 j
newTab[j] = loHead;
// 高位小队去 j+旧容量 的位置
newTab[j + oldCap] = hiHead;

现在对应到代码核心逻辑(极简对应)

复制代码
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;
}
  • loTail.next = e → 让当前节点接在队伍屁股后面
  • 这就是尾插法:永远排最后,不插队
  • 不插队 → 原来什么顺序,拆分后还是什么顺序

当我们往 HashMap 中不断添加元素时,HashMap 会自动进行扩容操作(条件是元素数量达到负载因子(load factor)乘以数组长度时),以保证其存储的元素数量不会超出其容量限制。

在进行扩容操作时,HashMap 会先将数组的长度扩大一倍,然后将原来的元素重新散列到新的数组中。
由于元素的位置是通过 key 的 hash 和数组长度进行与运算得到的,因此在数组长度扩大后,元素的位置也会发生一些改变。一部分索引不变,另一部分索引为"原索引+旧容量"。(将键的hashCode()返回的 32 位哈希值与这个哈希值无符号右移 16 位的结果进行异或。和这个hash方法 + jdk1.8尾插法有关)

核心差异

  • JDK1.7:重新算索引hash() + 头插法 → 顺序颠倒、环形链表;
  • JDK1.8:高低位判断 + 尾插法 → 顺序不变、安全高效。

面试:jdk1.8扩容后还能保证顺序的原因

"在 JDK 1.8 中,HashMap 扩容时能保持顺序,主要是因为采用了尾插法替代了 1.7 的头插法。

具体实现上,它通过 e.hash & oldCap 这一位运算,将原有的一个桶位里的链表拆分成'高位(hi)'和'低位(lo)'两个子链表。低位链表留在原下标位置,高位链表平移到 原下标 + 旧容量 的位置。

这种方式不仅保证了扩容后元素的相对顺序不乱,还避免了 1.7 中高并发扩容可能导致的**死循环(环形链表)**问题,同时也由于省去了重新哈希的计算,显著提升了扩容效率。"

03、加载因子为什么是 0.75

上一个问题提到了加载因子(或者叫负载因子),那么这个问题我们来讨论为什么加载因子是 0.75 而不是 0.6、0.8。

我们知道,HashMap 是用数组+链表/红黑树实现的,我们要想往 HashMap 中添加数据(元素/键值对)或者取数据,就需要确定数据在数组中的下标(索引)。

先把数据的键进行一次 hash:

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

再做一次取模运算确定下标:

复制代码
i = (n - 1) & hash

那这样的过程容易产生两个问题:

  • 数组的容量过小,经过哈希计算后的下标,容易出现冲突;
  • 数组的容量过大,导致空间利用率不高。

加载因子是用来表示 HashMap 中数据的填满程度:

加载因子 = 填入哈希表中的数据个数 / 哈希表的长度

这就意味着:

  • 阈值 = 数组容量 * 加载因子, 阈值越小, size >= 阈值越容易出发扩容机制。

  • 加载因子越小,填满的数据就越少,哈希冲突的几率就减少了,但浪费了空间,而且还会提高扩容的触发几率;

  • 加载因子越大,填满的数据就越多,空间利用率就高,但哈希冲突的几率就变大了。

Java 8 之前,HashMap 使用链表来解决冲突,即当两个或者多个键映射到同一个桶时,它们被放在同一个桶的链表上。当链表上的节点(Node)过多时,链表会变得很长,查找的效率(LinkedList 的查找效率为 O(n))就会受到影响。

Java 8 中,当链表的节点数超过一个阈值(8)时,链表将转为红黑树(节点为 TreeNode),红黑树(在讲TreeMap时会细说)是一种高效的平衡树结构,能够在 O(log n) 的时间内完成插入、删除和查找等操作。这种结构在节点数很多时,可以提高 HashMap 的性能和可伸缩性。

小结

HashMap 的加载因子(load factor,直译为加载因子,意译为负载因子)是指哈希表中填充元素的个数与桶的数量的比值,当元素个数达到负载因子与桶的数量的乘积时,就需要进行扩容。这个值一般选择 0.75,是因为这个值可以在时间和空间成本之间做到一个折中,使得哈希表的性能达到较好的表现。

如果负载因子过大,填充因子较多,那么哈希表中的元素就会越来越多地聚集在少数的桶中,这就导致了冲突的增加,这些冲突会导致查找、插入和删除操作的效率下降。同时,这也会导致需要更频繁地进行扩容,进一步降低了性能。

如果负载因子过小,那么桶的数量会很多,虽然可以减少冲突,但是在空间利用上面也会有浪费,因此选择 0.75 是为了取得一个平衡点,即在时间和空间成本之间取得一个比较好的平衡点。

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

04、线程不安全

三方面原因:

  • 多线程下扩容会死循环
  • 多线程下 put 会导致元素丢失
  • put 和 get 并发时会导致 get 到 null
1)多线程下扩容会死循环
线程间,资源是共享的,同步困难。

众所周知,HashMap 是通过拉链法来解决哈希冲突的,也就是当哈希冲突时,会将相同哈希值的键值对通过链表的形式存放起来。

JDK 7 时,采用的是头部插入的方式来存放链表的,也就是下一个冲突的键值对会放在上一个键值对的前面(讲扩容的时候讲过了)。扩容的时候就有可能导致出现环形链表,造成死循环。

resize 方法的源码:

复制代码
// newCapacity为新的容量
void resize(int newCapacity) {
    // 小数组,临时过度下
    Entry[] oldTable = table;
    // 扩容前的容量
    int oldCapacity = oldTable.length;
    // MAXIMUM_CAPACITY 为最大容量,2 的 30 次方 = 1<<30
    if (oldCapacity == MAXIMUM_CAPACITY) {
        // 容量调整为 Integer 的最大值 0x7fffffff(十六进制)=2 的 31 次方-1
        threshold = Integer.MAX_VALUE;
        return;
    }

    // 初始化一个新的数组(大容量)
    Entry[] newTable = new Entry[newCapacity];
    // 把小数组的元素转移到大数组中
    transfer(newTable, initHashSeedAsNeeded(newCapacity));
    // 引用新的大数组
    table = newTable;
    // 重新计算阈值
    threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}

transfer 方法用来转移,将小数组的元素拷贝到新的数组中。

java 复制代码
void transfer(Entry[] newTable, boolean rehash) {
    // 新的容量
    int newCapacity = newTable.length;
    // 遍历小数组
    for (Entry<K,V> e : table) {
        while(null != e) {
            // 拉链法,相同 key 上的不同值
            Entry<K,V> next = e.next;
            // 是否需要重新计算 hash
            if (rehash) {
                e.hash = null == e.key ? 0 : hash(e.key);
            }
            // 根据大数组的容量,和键的 hash 计算元素在数组中的下标
            int i = indexFor(e.hash, newCapacity);

            // 同一位置上的新元素被放在链表的头部
            e.next = newTable[i];

            // 放在新的数组上
            newTable[i] = e;

            // 链表上的下一个元素
            e = next;
        }
    }
}

注意 e.next = newTable[i]newTable[i] = e 这两行代码,它们会将同一位置上的新元素放在链表的头部。

扩容前的样子假如是下面这样子。

那么正常扩容后就是下面这样子。

假设现在有两个线程同时进行扩容,线程 A 在执行到 e.next = newTable[i] 被挂起,此时线程 A 中:e=3、next=7、e.next=null

线程 B 开始执行,并且完成了数据转移。

此时,7 的 next 为 3,3 的 next 为 null。

随后线程 A 获得 CPU 时间片继续执行 e.next = newTable[i];newTable[i] = e,将 3 放入新数组对应的位置,执行完此轮循环后线程 A 的情况如下:

执行下一轮循环,此时 e=7,原本线程 A 中 7 的 next 为 5,但由于 table 是线程 A 和线程 B 共享的,而线程 B 顺利执行完后,7 的 next 变成了 3,那么此时线程 A 中,7 的 next 也为 3 了。

采用头部插入的方式,变成了下面这样子:

好像也没什么问题,此时 next = 3,e = 3。

进行下一轮循环,但此时,由于线程 B 将 3 的 next 变为了 null,所以此轮循环应该是最后一轮了。

接下来当执行完 e.next=newTable[i] 即 3.next=7 后,3 和 7 之间就相互链接了,执行完 newTable[i]=e 后,3 被头插法重新插入到链表中,执行结果如下图所示:

套娃开始,元素 5 也就成了弃婴,惨~~~

这里再插入一名球友小灰飞的分析:"线程A是在8行之后、17行之前挂起"。

小灰飞

不过,JDK 8 时已经修复了这个问题,扩容时会保持链表原来的顺序(嗯,等于说了半天白说了,哈哈,这个面试题确实是这样,很水,但有些面试官又确实比较装逼)。

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

覆盖了

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

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

4)小结
java 复制代码
void resize(int newCapacity) {
    Entry[] oldTable = table;
    Entry[] newTable = new Entry[newCapacity]; // 1. 创建新数组
    transfer(newTable);                        // 2. 搬运数据(耗时操作)
    table = newTable;                          // 3. 将成员变量 table 指向新数组
}

情况 A:数据还没搬完,新表就提前曝光了(指令重排序)

虽然代码逻辑上是先 transfer 再赋值 table = newTable,但在多线程环境下,编译器或 CPU 可能会为了性能进行指令重排序

  • 线程 1 可能先执行了 table = newTable,然后再去执行 transfer 搬数据。
  • 线程 2 此时进来 get(A),它访问的是 table 成员变量。因为它拿到的是刚初始化完、全是 null 的新数组 ,所以无论搜哪个位置,结果都是 null

情况 B:槽位被置为 null(这是你疑惑的核心)

transfer 搬运的过程中,JDK 1.7 的代码是这样处理原桶位的:

Java 复制代码
void transfer(Entry[] newTable) {
    Entry[] src = table; 
    for (int j = 0; j < src.length; j++) {
        Entry<K,V> e = src[j];
        if (e != null) {
            src[j] = null; // 【关键点】把旧数组的这个桶位清空了!
            do {
                // ... 搬运 A, B, C 到新表 ...
            } while (e != null);
        }
    }
}
  1. 线程 1 正在搬运 index = 1 位置的链表。它执行了 src[j] = null,也就是把旧数组里的引用给断开了,准备把里面的元素一个一个塞到新数组里去。
  2. 线程 2 此时执行 get(A)。由于线程 1 还没执行完 table = newTable,线程 2 依然在旧数组里查找。
  3. 结果 :线程 2 看到 table[1] 已经是 null 了(被线程 1 抹掉的),所以它认为这个 Key 不存在,直接返回 null

HashMap 是线程不安全的主要是因为它在进行**插入、删除和扩容等操作时可能会导致链表的结构发生变化,从而破坏了 HashMap 的不变性。**具体来说,如果在一个线程正在遍历 HashMap 的链表时,另外一个线程对该链表进行了修改(比如添加了一个节点),那么就会导致链表的结构发生变化,从而破坏了当前线程正在进行的遍历操作,可能导致遍历失败或者出现死循环等问题。

  • 旧桶被清空 :扩容过程中,旧桶的元素正在被逐个移动,get 可能访问到已经处理完的空桶。
  • 可见性问题 :由于没有 volatile 或锁 的保护,线程 2 可能看到一个极其不稳定的中间状态(比如已经创建但全是 null 的新数组)。

如何选择 Map

在学习 TreeMap 之前,我们已经学习了 HashMapLinkedHashMap ,那如何从它们三个中间选择呢?

需要考虑以下因素:

  • 是否需要按照键的自然顺序或者自定义顺序进行排序。如果需要按照键排序,则可以使用 TreeMap;如果不需要排序,则可以使用 HashMap 或 LinkedHashMap。
  • 是否需要保持插入顺序。如果需要保持插入顺序,则可以使用 LinkedHashMap;如果不需要保持插入顺序,则可以使用 TreeMap 或 HashMap。
  • 是否需要高效的查找。如果需要高效的查找,则可以使用 LinkedHashMap 或 HashMap,因为它们的查找操作的时间复杂度为 O(1),而是 TreeMap 是 O(log n)。

LinkedHashMap 内部使用哈希表来存储键值对,并使用一个双向链表来维护插入顺序,但查找操作只需要在哈希表中进行,与链表无关,所以时间复杂度为 O(1)

来个表格吧,一目了然。

特性 TreeMap HashMap LinkedHashMap
排序 支持 不支持 不支持
插入顺序 不保证 不保证 保证
查找效率 O(log n) O(1) O(1)
空间占用 通常较大 通常较小 通常较大
适用场景 需要排序的场景 无需排序的场景 需要保持插入顺序
相关推荐
兰令水3 小时前
topcode【随机算法题】【2026.5.24打卡-java版本】
java·开发语言·算法
LCG元4 小时前
Istio - 服务网格流量治理深度解析:灰度发布 / 故障注入配置实践
java·数据库·istio
hef2884 小时前
Java Switch和Break语句用法详解:从入门到实战
java·开发语言
ABCDEEE74 小时前
3.RAG
java·linux·服务器
SuniaWang4 小时前
《Agentx专栏》03-架构设计:AgentX的六层架构是如何生长出来的
java·数据库·redis·docker·ai·架构
Refrain_zc4 小时前
Android开发在线音频播放器之章节一 AudioPlayerManager
java
Refrain_zc4 小时前
Android开发Room数据库使用(可复制)
java
大波V54 小时前
claude-code cli 跳过登录
java·服务器·前端
小江的记录本4 小时前
【Kafka核心】Kafka 3.0+ KRaft模式(替代ZooKeeper)核心原理与优势
java·数据库·分布式·后端·zookeeper·kafka·rabbitmq