Java基础——十二、容器

19 篇文章 0 订阅
12 篇文章 0 订阅

十二、容器

在Java中,容器(也称为集合)是处理数据集合的核心组件。深入理解Java容器对于处理大规模数据、提高代码效率和编写高性能程序至关重要。Java中提供了许多容器类,这些类位于java.util包中,分为两类:CollectionMap

以下详细介绍ListSetMapQueue这几个主要的Java容器,并通过详细的源码分析和工作中的实际应用,来深入理解这些容器的本质。

注:源码基于JDK1.8。

概览

容器主要包括 CollectionMap 两种:

  • Collection 存储着对象的集合
  • 而 Map 存储着键值对(两个对象)的映射表。
1.Collection
API说明

image-20240916154813243

Collection 是 Java 中集合框架的根接口,是 List、Set 和 Queue 等子接口的公共父接口。Collection 定义了基本的集合操作方法,比如添加、删除、查询等,用于处理一组对象。

1.Collection 接口的说明

Collection 接口位于 java.util 包中,定义了操作集合对象的通用方法。它不能直接实例化,但通过子接口(如 ListSetQueue)来使用。常用方法包括:

  • 添加元素

    • boolean add(E e): 向集合中添加元素,成功返回 true
    • boolean addAll(Collection<? extends E> c): 添加另一个集合的所有元素到当前集合中。
  • 删除元素

    • boolean remove(Object o): 删除集合中的指定元素,成功返回 true
    • boolean removeAll(Collection<?> c): 删除集合中所有与指定集合中匹配的元素。
    • boolean retainAll(Collection<?> c): 只保留集合中与指定集合匹配的元素。
    • void clear(): 清空集合中的所有元素。
  • 查询操作

    • boolean contains(Object o): 判断集合中是否包含指定元素。
    • boolean containsAll(Collection<?> c): 判断当前集合是否包含另一个集合的所有元素。
    • int size(): 返回集合中元素的数量。
    • boolean isEmpty(): 判断集合是否为空。
  • 集合迭代

    • Iterator<E> iterator(): 返回一个用于遍历集合元素的迭代器。
  • 数组转换

    • Object[] toArray(): 将集合转换为一个对象数组。
    • <T> T[] toArray(T[] a): 将集合转换为指定类型的数组。
2.工作中的使用场景
  • List 接口(ArrayListLinkedList):在需要有序存储元素并且允许重复时使用,例如实现员工名单、订单列表等。
  • Set 接口(HashSetTreeSet):在需要保证集合中的元素不重复时使用,比如记录唯一标识(ID)、过滤重复数据等。
  • Queue 接口(LinkedListPriorityQueue):在需要遵循特定顺序处理元素时使用,例如任务调度、消息队列等。

在实际开发中,通常使用子接口(如 ListSet)的实现类实例化集合。例如:

List<String> list = new ArrayList<>();
Set<Integer> set = new HashSet<>();
Queue<String> queue = new LinkedList<>();
3.注意事项
  • 选择合适的子接口:根据实际需求选择合适的集合类型。例如,List 用于有序且可重复的场景,Set 用于存储唯一元素的场景,Queue 用于遵循 FIFO(先入先出)或优先级处理的场景。
  • 线程安全性Collection 接口及其大多数实现类不是线程安全的。在多线程环境下,需要使用同步包装器(如 Collections.synchronizedList)或使用并发集合(如 ConcurrentHashMapCopyOnWriteArrayList)。
  • 效率问题:不同集合在添加、删除、查询等操作上有不同的性能表现。例如,ArrayList 适合随机访问,但插入、删除效率低;LinkedList 插入、删除效率高,但随机访问性能较差。需要根据场景选择合适的集合实现。
  • 避免操作空集合:在调用集合操作方法前,检查集合是否为空 (isEmpty()) 可以避免空指针异常。
4.常用子接口和实现类
  • List

    • 实现类:ArrayListLinkedListVectorStack
    • 特点:允许重复,有序(元素按插入顺序存储),支持通过索引随机访问。
  • Set

    • 实现类:HashSetLinkedHashSetTreeSet
    • 特点:不允许重复,无序(HashSet),有序(LinkedHashSetTreeSet)。
  • Queue

    • 实现类:LinkedListPriorityQueue
    • 特点:用于按特定顺序处理元素,如 FIFO、优先级。

通过灵活运用 Collection 接口及其子接口的各种实现,可以满足不同的编程需求。

2.Map

包含的API

image-20240916155939603

API说明

Map 是 Java 集合框架中的一个重要接口,它用于存储键值对(key-value)映射。Map 不继承自 Collection 接口,因为它表示一组键值对,而不是单独的元素集合。常用的 Map 实现类有 HashMapTreeMapLinkedHashMapHashtableConcurrentHashMap 等。

1. Map 接口的说明

Map 接口提供了操作键值对映射的基本方法,包括插入、删除、查找和遍历键值对等操作。常用的方法包括:

  • 添加和更新键值对

    • V put(K key, V value): 将指定的键值对添加到 Map 中。如果键已经存在,替换对应的值,并返回旧值。
    • void putAll(Map<? extends K, ? extends V> m): 将另一个 Map 中的所有键值对添加到当前 Map 中。
    • V putIfAbsent(K key, V value): 仅当键不存在时,添加键值对。
  • 删除键值对

    • V remove(Object key): 删除指定键对应的键值对,返回被删除的值。
    • boolean remove(Object key, Object value): 只有在键值对匹配时才删除,成功返回 true
  • 查询操作

    • V get(Object key): 返回指定键对应的值,若键不存在则返回 null
    • boolean containsKey(Object key): 判断 Map 中是否包含指定键。
    • boolean containsValue(Object value): 判断 Map 中是否包含指定值。
    • int size(): 返回 Map 中键值对的数量。
    • boolean isEmpty(): 判断 Map 是否为空。
  • 遍历 Map

    • Set<K> keySet(): 返回所有键的 Set 集合。
    • Collection<V> values(): 返回所有值的 Collection 集合。
    • Set<Map.Entry<K, V>> entrySet(): 返回所有键值对的 Set 集合,每个元素是一个 Map.Entry 对象。
2. 工作中的使用场景
  • 存储键值映射关系Map 主要用于存储键值对的映射关系,常见的场景包括存储用户信息(<userId, User>)、缓存数据(<key, value>)等。
  • 计数器:利用 Map 实现某个对象的计数器,比如统计字符出现次数、产品销售统计等。
  • 查找表Map 可作为查找表使用,通过键快速找到对应的值。比如,根据订单号查询订单详情、根据配置项名称获取配置值等。
  • 缓存机制Map 可以用来实现简单的缓存机制(如 HashMap + LinkedHashMap 实现 LRU 缓存),在内存中存储一部分数据,减少重复计算或数据库查询。
3. 注意事项
  • 键的唯一性Map 中的键必须是唯一的。如果插入一个已存在的键,新的值会替换旧的值。

  • null 键和值

    • HashMap 允许一个 null 键和多个 null 值。
    • Hashtable 不允许 null 键或值。
    • TreeMap 允许 null 值,但不允许 null 键(因为需要对键进行比较)。
  • 线程安全HashMapTreeMapLinkedHashMap 等实现类不是线程安全的,在多线程环境中需要通过同步机制或使用并发类(如 ConcurrentHashMap)来保证线程安全。

  • 性能考虑

    • HashMap 基于哈希表实现,查询、插入、删除的平均时间复杂度为 O(1)。
    • TreeMap 基于红黑树实现,键值对是有序的,查询、插入、删除的时间复杂度为 O(log n)。
    • 如果对键值对的顺序有要求,选择 LinkedHashMapTreeMap;若仅追求性能,使用 HashMap
4. 常用实现类
  • HashMap

    • 基于哈希表实现,允许一个 null 键和多个 null 值。
    • 无序,键值对存储顺序不固定。
    • 常用于快速查找,如缓存数据、对象映射等。
  • LinkedHashMap

    • 继承自 HashMap,内部维护了一个双向链表,记录插入顺序。
    • 适用于需要保持插入顺序或访问顺序的场景,例如实现 LRU 缓存。
  • TreeMap

    • 基于红黑树实现,键值对是有序的。
    • 可以根据键的自然顺序(实现 Comparable 接口)或自定义比较器(Comparator)进行排序。
    • 适用于需要对键排序的场景,如统计、排名等。
  • Hashtable

    • 古老的线程安全实现,不允许 null 键和 null 值。
    • 性能较低,通常不推荐使用,推荐用 ConcurrentHashMap 代替。
  • ConcurrentHashMap

    • 线程安全,适用于多线程环境。
    • 通过分段锁(Java 8 后为 CAS + 红黑树)实现高效的并发操作。
5. 示例代码

以下是 HashMap 的一些常用操作示例:

import java.util.HashMap;
import java.util.Map;

public class MapExample {
    public static void main(String[] args) {
        Map<String, Integer> map = new HashMap<>();
        
        // 添加键值对
        map.put("Apple", 3);
        map.put("Banana", 2);
        map.put("Orange", 5);
        
        // 更新键值对
        map.put("Apple", 4);
        
        // 查找值
        int appleCount = map.get("Apple"); // 返回 4
        
        // 判断键是否存在
        boolean hasBanana = map.containsKey("Banana"); // 返回 true
        
        // 删除键值对
        map.remove("Orange");
        
        // 遍历键值对
        for (Map.Entry<String, Integer> entry : map.entrySet()) {
            System.out.println(entry.getKey() + ": " + entry.getValue());
        }
    }
}

在这段代码中,我们创建了一个 HashMap 来存储水果的名称和数量,并演示了添加、更新、查找、删除和遍历键值对的操作。

总结

Map 是 Java 中处理键值对数据的核心接口。选择合适的 Map 实现类是关键:如果需要快速查找,使用 HashMap;如果需要顺序或排序,选择 LinkedHashMapTreeMap;在多线程环境中,使用 ConcurrentHashMap

继承结构图

1.为什么要熟悉?

熟悉继承结构图在实际开发中有以下几个好处:

  1. 加深对类层次结构的理解

继承结构图展示了类与类之间的关系,包括接口、抽象类和具体类。通过掌握这些关系,开发者可以清楚地了解一个类的特性来自于哪些父类或接口,从而更好地理解类的功能和设计意图。

  1. 优化代码复用

通过继承结构,开发者能够更有效地利用继承体系进行代码复用。例如,明白常见容器(如 ArrayListLinkedList)都继承自 List 接口,能够帮助开发者在接口上编写代码,从而提高程序的灵活性和可扩展性。

  1. 便于选择合适的类或接口

理解继承结构有助于开发者在面对某些需求时,选择最合适的类或接口。例如,List 提供有序的集合,而 Set 不允许重复项,了解这些接口的继承关系,可以帮助你为不同的应用场景选择正确的数据结构。

  1. 掌握多态性

继承结构是多态性的基础。熟悉继承关系可以帮助开发者利用父类或者接口来实现多态,在实际编程中利用更灵活的方式操作对象,增强代码的可扩展性和维护性。

  1. 阅读源码和设计模式的基础

在阅读 Java 类库的源码或学习设计模式时,继承结构的理解至关重要。很多设计模式(如装饰器模式、模板方法模式等)依赖于继承结构的设计思想。熟悉这种继承图,可以帮助你更快理解和掌握这些模式的实现。

  1. 调试和排查问题

了解继承结构在调试中也非常有用,特别是当你遇到某个方法的行为与预期不符时,可以快速定位到继承链中的哪个类实现了该方法,并理解其行为。

总结:

熟悉继承结构图不仅能帮助开发者理解类之间的设计和关系,还能提高代码复用性、灵活性和可维护性,帮助你做出更合适的设计决策并更高效地调试代码。

2.示例

由于同类型的大多集合继承的内容类似。故此挑选典型容器来加以说明。

image-20240907202930310

由图所示,继承和实现的多个接口和类,每个接口或类在集合框架中都扮演着特定的角色。下面我们来逐一分析:

  1. Iterable<T>接口

    • 用途:Iterable接口是集合框架的根接口,所有实现它的类都可以使用for-each循环。它定义了一个iterator()方法,返回一个Iterator对象,允许遍历集合中的元素。

    • 适用场景:任何需要遍历集合的场景,比如在for-each语句中使用集合。

    • List<Integer> list = new ArrayList<>();
      list.add(1);
      list.add(2);
      Iterator<Integer> iterator = list.iterator();
      while (iterator.hasNext()){
          System.out.println(iterator.next());
      }
      
    • 为什么说使用 for-each 来替换iterator遍历会更强呢?

      • for-each 循环比 Iteratorwhile 循环更强的原因可以总结为以下几点:

        1. 代码简洁性for-each 隐藏了 Iterator 的创建和方法调用,简化了遍历代码。
        2. 可读性for-each 更直观,清楚表达了遍历的意图,容易理解和维护。
        3. 减少出错风险for-each 避免了手动调用 hasNext()next() 可能带来的错误。
        4. 一致性for-each 可以用于遍历数组和集合,保持代码风格一致。
        5. 编译器优化for-each 循环可能经过编译器的底层优化,执行效率更高。

        但在需要修改集合(如删除元素)时,Iterator 仍是必要的工具。

  2. Collection<E>接口

    • 用途:Collection是所有集合类的基接口,定义了集合的一些基础操作,如添加、删除、包含元素等。它还继承了Iterable接口。
    • 使用场景:提供基础集合操作的通用接口,例如List、Set、Queue都是Collection的子接口。
  3. List<E>接口

    • 用途:List是一个有序集合,允许元素重复并可以通过索引来访问集合中的元素List继承了Collection接口,增加了按位置访问、插入、删除等操作。
    • 适用场景:适用于对元素有顺序要求,并且允许重复的场景,例如任务列表,购物车等。
  4. AbstractCollection<E>抽象类

    • 用途:**AbstractCollectionCollection接口的骨架实现,提供了一些常见的集合操作(如:size()isEmpty()toArray()等)的默认实现。**它帮助减少重复代码,使子类只需实现特定的方法即可。
    • 使用场景:作为自定义集合类的基础,减少重复实现常见操作的代码。
  5. AbstractList<E>抽象类

    • 用途:**AbstractListList接口的骨架实现,提供了get(int index)set(int index,E element)等操作的默认实现。**开发者只需要实现一些基础方法,如size()get(),就可以快速构建一个List类。
    • 适用场景:简化List类的实现,为具体的List子类(如ArrayList)提供骨架支持。
  6. RandomAccess接口

    • 用途:RandomAccess是一个标识接口(没有定义任何方法),标识实现类支持快速随机访问(通过索引快速访问元素)。像ArrayList这样的类由于底层是数组实现,因此可以通过RandomAccess来标识支持高效的随机访问
    • 适用场景:在处理List时,如果集合实现了RandomAccess,那么可以优先选择通过索引操作,而不是使用Iterator来遍历。
  7. Cloneable接口

    • 用途:Cloneable接口是一个标识接口,表明一个类的对象可以通过调用clone()方法来生成它的浅拷贝。如果一个类实现了Cloneable接口,它应该覆盖clone()方法,否则会抛出CloneNotSupportedException
    • 适用场景:适用于需要复制对象的场景,如需要生成一个对象的副本用于临时操作。
  8. Serializable接口

    • 用途:Serializable是一个标识接口,表明一个类的实例可以序列化,即可以将对象转换为字节流,随后可以通过反序列化将字节流还原成对象。
    • 适用场景:适用于需要对象持久化的场景,例如将对象保存到文件、数据库或通过网络传输时。
3.总结
  • Iterable<T>Collection<E> 定义了集合操作的基本能力和遍历方法。
  • List<E> 进一步扩展了集合,支持有序、可重复的元素列表操作。
  • AbstractCollection<E>AbstractList<E> 提供了集合的骨架实现,减少了开发者重复实现基础功能的工作量。
  • RandomAccess 是标识接口,表明支持高效随机访问。
  • CloneableSerializable 是用于对象复制和序列化的标识接口。

这些接口和类的组合帮助构建了 Java 强大的集合框架,每个类和接口都有其特定的用途和适用场景。

Collection

img

1. Set
  • TreeSet:基于红黑树实现,支持有序性操作,例如根据一个范围查找元素的操作。但是查找效率不如 HashSetHashSet 查找的时间复杂度为 O(1),TreeSet 则为 O(logN)
  • HashSet:基于哈希表实现,支持快速查找,但不支持有序性操作。并且失去了元素的插入顺序信息,也就是说使用 Iterator 遍历 HashSet 得到的结果是不确定的。
  • LinkedHashSet:具有 HashSet 的查找效率,并且内部使用双向链表维护元素的插入顺序。
2. List
概述
  • ArrayList底层使用动态数组实现,支持随机访问,插入和删除操作在末尾时效率较高,但是在中间位置插入或删除会导致元素移动,性能较差。
  • Vector:和 ArrayList 类似,但它是线程安全的,当底层将整个集合上锁,性能较差。逐渐被淘汰。
  • LinkedList:基于双向链表实现,只能顺序访问,但是可以快速地在链表中间插入和删除元素。不仅如此,LinkedList 还可以用作栈、队列和双向队列。
ArrayList
适用场景

ArrayList是一个基于数组实现的动态数组,它的容量可以自动扩展,适用于频繁读取元素的场景。

工作场景

在需要快速随机访问元素时,ArrayList是一个很好的选择,例如在内存缓存、搜索结果,用户列表等场景中。

3. Queue
  • LinkedList:可以用它来实现双向队列。
  • PriorityQueue:基于堆结构实现,可以用它来实现优先队列。

Map

image-20220611194850033

说明:

  • TreeMap:基于红黑树实现。
  • HashMap:基于哈希表实现。
  • HashTable:和 HashMap 类似,但它是线程安全的,这意味着同一时刻多个线程同时写入 HashTable 不会导致数据不一致。它是遗留类,不应该去使用它,而是使用 ConcurrentHashMap 来支持线程安全ConcurrentHashMap效率会更高,因为 ConcurrentHashMap 引入了分段锁
  • LinkedHashMap:使用双向链表来维护元素的顺序,顺序为插入顺序或者最近最少使用LRU)顺序。

容器中的设计模式

迭代器模式

image-20220611195225982

Collection 继承了 Iterable 接口,其中的 iterator() 方法能够产生一个 Iterator 对象,通过这个对象就可以迭代遍历 Collection 中的元素

JDK 1.5 之后可以使用 foreach 方法来遍历实现了 Iterable 接口的聚合对象。

List<String> list = new ArrayList<>();
list.add("a");
list.add("b");
for (String item : list) {
    System.out.println(item);
}
适配器模式

java.util.Arrays#asList() 可以把数组类型转换为 List 类型。

@SafeVarargs
public static <T> List<T> asList(T... a)

应该注意的是 asList() 的参数为泛型的变长参数,不能使用基本类型数组作为参数,只能使用相应的包装类型数组

Integer[] arr = {1, 2, 3};
List list = Arrays.asList(arr);

也可以使用以下方式调用 asList()

List list = Arrays.asList(1, 2, 3);

源码分析

如果没有特别说明,以下源码分析基于 JDK 1.8。

在 IDEA 中 double shift 调出 Search EveryWhere,查找源码文件,找到之后就可以阅读源码。

ArrayList
1. 概览

因为 ArrayList基于数组实现的,所以支持快速随机访问RandomAccess 接口标识着该类支持快速随机访问

public class ArrayList<E> extends AbstractList<E>
        implements List<E>, RandomAccess, Cloneable, java.io.Serializable
//数组的默认大小为 10。
private static final int DEFAULT_CAPACITY = 10;
2.存储结构

image-20220611195859599

3.扩容

添加元素时:

  1. 使用 ensureCapacityInternal() 方法来保证容量足够
  2. 如果不够时,需要使用 grow() 方法进行扩容,新容量的大小为 oldCapacity + (oldCapacity >> 1),即 oldCapacity+oldCapacity/2。其中 oldCapacity >> 1 需要取整,所以新容量大约是旧容量的 1.5 倍左右。(oldCapacity 为偶数就是 1.5 倍,为奇数就是 1.5 倍-0.5)
  3. 扩容操作需要调用 Arrays.copyOf() 把原数组整个复制到新数组中,这个操作代价很高,因此最好在创建 ArrayList 对象时就指定大概的容量大小,减少扩容操作的次数。
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);
}
4.删除元素

需要调用 System.arraycopy() 将 index+1 后面的元素都复制到 index 位置上,该操作的时间复杂度为 O(N),可以看到 ArrayList 删除元素的代价是非常高的。

public E remove(int index) {
    rangeCheck(index);
    modCount++;
    E oldValue = elementData(index);
    int numMoved = size - index - 1;
    if (numMoved > 0)
        System.arraycopy(elementData, index+1, elementData, index, numMoved);
    elementData[--size] = null; // clear to let GC do its work
    return oldValue;
}
5.序列化

ArrayList 基于数组实现,并且具有动态扩容特性,因此保存元素的数组不一定都会被使用,那么就没必要全部进行序列化

保存元素的数组 elementData 使用 transient 修饰: transient 关键字声明数组默认不会被序列化

transient Object[] elementData; // non-private to simplify nested class access

ArrayList 实现了 writeObject()readObject()来控制只序列化数组中有元素填充那部分内容

private void readObject(java.io.ObjectInputStream s)
    throws java.io.IOException, ClassNotFoundException {
    elementData = EMPTY_ELEMENTDATA;

    // Read in size, and any hidden stuff
    s.defaultReadObject();

    // Read in capacity
    s.readInt(); // ignored

    if (size > 0) {
        // be like clone(), allocate array based upon size not capacity
        ensureCapacityInternal(size);

        Object[] a = elementData;
        // Read in all elements in the proper order.
        for (int i=0; i<size; i++) {
            a[i] = s.readObject();
        }
    }
}
private void writeObject(java.io.ObjectOutputStream s)
    throws java.io.IOException{
    // Write out element count, and any hidden stuff
    int expectedModCount = modCount;
    s.defaultWriteObject();

    // Write out size as capacity for behavioural compatibility with clone()
    s.writeInt(size);

    // Write out all elements in the proper order.
    for (int i=0; i<size; i++) {
        s.writeObject(elementData[i]);
    }

    if (modCount != expectedModCount) {
        throw new ConcurrentModificationException();
    }
}

序列化时:

  1. 需要使用 ObjectOutputStreamwriteObject() 将对象转换为字节流并输出。
  2. writeObject() 方法在传入的对象存在 writeObject() 的时候会去反射调用该对象的 writeObject() 来实现序列化。
  3. 反序列化使用的是 ObjectInputStreamreadObject() 方法,原理类似。
ArrayList list = new ArrayList();
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream(file));
oos.writeObject(list);
6.Fail-Fast

modCount 用来记录 ArrayList 结构发生变化的次数。

结构发生变化是指添加或者删除至少一个元素的所有操作,或者是调整内部数组的大小,仅仅只是设置元素的值不算结构发生变化

在进行序列化或者迭代等操作时:

  1. 需要比较操作前后 modCount 是否改变,
  2. 如果改变了需要抛出 ConcurrentModificationException
  3. 代码参考上节序列化中的 writeObject() 方法。
7.汇总分析

由于源码篇幅较大,主要对ArrayList中最关键的部分进行详细注释,包括构造方法、核心属性、常用方法(如add(),remove(),get()等)。

import java.util.*;

// ArrayList 是一个基于数组实现的动态列表,允许随机访问并支持自动扩容
public class ArrayList<E> extends AbstractList<E> implements List<E>, RandomAccess, Cloneable, java.io.Serializable {

    // 序列化ID,用于在序列化和反序列化时验证版本一致性
    private static final long serialVersionUID = 8683452581122892189L;

    // 默认初始容量,如果用户未指定容量时使用
    private static final int DEFAULT_CAPACITY = 10;

    // 空数组常量,当用户指定初始容量为0时使用此数组
    private static final Object[] EMPTY_ELEMENTDATA = {};

    // 默认容量的空数组常量,ArrayList首次使用时才会初始化为DEFAULT_CAPACITY
    private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};

    // 用于存储ArrayList元素的数组,非transient以便于序列化
    transient Object[] elementData; // non-private,以便于嵌套类访问

    // ArrayList中元素的实际数量
    private int size;

    // 带初始容量的构造方法
    public ArrayList(int initialCapacity) {
        if (initialCapacity > 0) {
            // 根据指定的初始容量创建数组
            this.elementData = new Object[initialCapacity];
        } else if (initialCapacity == 0) {
            // 容量为0,使用空数组常量
            this.elementData = EMPTY_ELEMENTDATA;
        } else {
            // 如果初始容量为负数,抛出异常
            throw new IllegalArgumentException("Illegal Capacity: " + initialCapacity);
        }
    }

    // 无参构造方法,初始化为默认容量空数组
    public ArrayList() {
        this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
    }

    // 返回ArrayList的元素个数
    public int size() {
        return size;
    }

    // 判断ArrayList是否为空
    public boolean isEmpty() {
        return size == 0;
    }

    // 获取指定索引位置的元素
    public E get(int index) {
        // 检查索引是否合法
        rangeCheck(index);
        // 返回指定位置的元素
        return elementData(index);
    }

    // 添加元素到列表末尾
    public boolean add(E e) {
        // 确保数组容量足够,不够时扩容
        ensureCapacityInternal(size + 1);
        // 将元素添加到数组末尾,size加1
        elementData[size++] = e;
        return true;
    }

    // 删除指定位置的元素
    public E remove(int index) {
        // 检查索引是否合法
        rangeCheck(index);
        
        // 修改次数加1,确保在多线程环境中避免数据不一致
        modCount++;
        
        // 获取被删除的元素
        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;
        
        // 返回被删除的元素
        return oldValue;
    }

    // 扩容方法,确保容量至少为minCapacity
    private void ensureCapacityInternal(int minCapacity) {
        // 如果是初次添加元素,取默认容量与minCapacity的最大值
        if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
            minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
        }

        // 判断是否需要扩容
        if (minCapacity - elementData.length > 0) {
            grow(minCapacity);
        }
    }

    // 扩容方法,计算新容量并进行扩容
    private void grow(int minCapacity) {
        // 获取旧数组的长度
        int oldCapacity = elementData.length;
        // 新容量为旧容量的1.5倍(右移1位表示/2)
        int newCapacity = oldCapacity + (oldCapacity >> 1);
        // 如果新容量仍然小于minCapacity,则取minCapacity
        if (newCapacity - minCapacity < 0) {
            newCapacity = minCapacity;
        }
        // 最大数组容量检查
        if (newCapacity - MAX_ARRAY_SIZE > 0) {
            newCapacity = hugeCapacity(minCapacity);
        }
        // 复制旧数组到新数组中
        elementData = Arrays.copyOf(elementData, newCapacity);
    }

    // 返回指定索引位置的元素,内部方法,无需检查索引范围
    @SuppressWarnings("unchecked")
    E elementData(int index) {
        return (E) elementData[index];
    }

    // 检查索引范围是否合法
    private void rangeCheck(int index) {
        if (index >= size) {
            throw new IndexOutOfBoundsException(outOfBoundsMsg(index));
        }
    }

    // 生成索引越界异常的错误信息
    private String outOfBoundsMsg(int index) {
        return "Index: " + index + ", Size: " + size;
    }

    // 超大容量处理,最大为Integer.MAX_VALUE - 8
    private static int hugeCapacity(int minCapacity) {
        if (minCapacity < 0) { // 溢出
            throw new OutOfMemoryError();
        }
        return (minCapacity > MAX_ARRAY_SIZE) ? Integer.MAX_VALUE : MAX_ARRAY_SIZE;
    }

    // 最大数组容量(限制以避免OOM)
    private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;
}
8.执行分析
示例代码
List<Integer> list = new ArrayList();
list.add(1);

为何使用List list = new ArrayList();方式创建对象?

使用List接口的引用(多态)有利于代码的灵活性。这种方式允许我们在后续用其它List实现类(例如LinkedList)来替换ArrayList,而不改变其它代码。

这样一来,我们只要改变创建实例的部分即可,而不需要修改整个代码中的引用类型。此外,代码面向接口编程(多态)是良好编程习惯,因为它使代码更具扩展性和维护性。

执行过程

结合 ArrayList 的源码详细梳理一遍整体流程,包括每个方法的详细说明。流程将逐步跟随代码操作,同时指明每个节点涉及的方法和参数。

  1. List<Integer> list = new ArrayList<>(); 的创建
  • new ArrayList<>() 创建了一个空的 ArrayList 实例,初始化内部存储数组为 DEFAULTCAPACITY_EMPTY_ELEMENTDATA(大小为 0)。
  • 源码:private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};
  • 此时,ArrayList 并没有分配实际容量,而是等待第一次添加元素时再进行扩容。
  1. list.add(1); —— 调用 add() 方法
  • 调用 add(1) 方法,开始添加元素 1。

  • 进入 ArrayList源码的add()

    方法:

    public boolean add(E e) {
        ensureCapacityInternal(size + 1);  // 确保容量足够
        elementData[size++] = e;
        return true;
    }
    
  • add() 方法中首先调用 ensureCapacityInternal(size + 1) 来确保内部数组的容量足以容纳新的元素。

3.ensureCapacityInternal(int minCapacity) 方法

  • 参数:minCapacity 是当前容量加 1(即元素将要增加后的容量)。

  • 进入

    ensureCapacityInternal(int minCapacity)
    

    源码:

    private void ensureCapacityInternal(int minCapacity) {
        if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
            minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
        }
        ensureExplicitCapacity(minCapacity);
    }
    
  • 第一步:检查 elementData 是否为默认空数组 DEFAULTCAPACITY_EMPTY_ELEMENTDATA。如果是,设置 minCapacityMath.max(DEFAULT_CAPACITY, minCapacity),这里 DEFAULT_CAPACITY 通常是 10。对于 list.add(1) 的场景,minCapacity 为 1,因此返回值为 10。

  • 第二步:调用 ensureExplicitCapacity(int minCapacity) 方法,传入确定后的容量大小。

4.ensureExplicitCapacity(int minCapacity) 方法

  • 参数:minCapacity 是经过前面方法处理后的容量大小,在此例中为 10。

  • 进入

    ensureExplicitCapacity(int minCapacity)
    

    源码:

    private void ensureExplicitCapacity(int minCapacity) {
        modCount++;
        if (minCapacity - elementData.length > 0)
            grow(minCapacity);
    }
    
  • 增加 modCount 用于记录修改次数,防止在迭代过程中结构发生变化。

  • 判断minCapacity - elementData.length > 0。当前 elementData.length 为 0(默认空数组),因此 10 - 0 > 0 成立,调用 grow(int minCapacity) 方法进行扩容。

  1. grow(int minCapacity) 方法
  • 参数:minCapacity 是需要的最小容量,此例中为 10。

  • 进入

    grow(int minCapacity)
    

    源码:

    private void grow(int minCapacity) {
        int oldCapacity = elementData.length;
        int newCapacity = oldCapacity + (oldCapacity >> 1);
        if (newCapacity - minCapacity < 0)
            newCapacity = minCapacity;
        if (newCapacity - MAX_ARRAY_SIZE > 0)
            newCapacity = hugeCapacity(minCapacity);
        elementData = Arrays.copyOf(elementData, newCapacity);
    }
    
  • 计算新容量newCapacity = oldCapacity + (oldCapacity >> 1),扩容为原来的 1.5 倍。此时 oldCapacity 为 0,因此 newCapacity 也为 0。

  • 比较新容量和最小容量if (newCapacity - minCapacity < 0),因为 newCapacity 为 0,minCapacity 为 10,所以新容量将被设定为 10。

  • 检查最大容量if (newCapacity - MAX_ARRAY_SIZE > 0) 用于检查新容量是否超过了 MAX_ARRAY_SIZE2147483639),如果超过则调用 hugeCapacity(int minCapacity) 来处理。对于当前情况,容量为 10,不会触发此逻辑。

  • 扩容数组elementData = Arrays.copyOf(elementData, newCapacity),创建一个大小为 10 的新数组,并将原数组元素(当前为空)复制到这个新数组。

  1. Arrays.copyOf(elementData, newCapacity) 方法
  • 参数:elementData 为当前数组,newCapacity 为扩容后的大小(此例中为 10)。
  • Arrays.copyOf() 会创建一个新的数组,并将原数组内容复制到新数组中。旧数组并不会立即删除,它会等待 Java 垃圾回收器回收。
  1. add() 方法的后续操作
  • 回到

    add()
    

    方法:

    elementData[size++] = e;
    
  • 将元素 1 添加到新数组的第一个位置,size 递增。

  • 最终,ArrayList 的内部数组 elementData 现在是一个容量为 10,包含一个元素的数组。

最大容量的判断

  • 源码中出现的

    2147483639
    

    2147483647
    

    是和 Java 中数组的最大长度相关的两个值:

    • 2147483639ArrayList 能够支持的最大容量,与 Java 数组的最大索引有关。

    • 这两个值用于限制数组的大小,以防止在扩容过程中超出内存限制或导致系统崩溃。

    • 2147483647Integer.MAX_VALUE,即 Java 中 int 类型的最大值。

    • 2147483639ArrayList 能够支持的最大容量,通常比 Integer.MAX_VALUE 略小,因为内部实现中需要考虑数组头部的元数据、管理开销等。

  • hugeCapacity(int minCapacity) 方法会在 grow() 方法中被调用,用于处理极大容量的情况。如果 minCapacity 超过 2147483639,则最大容量会设定为 Integer.MAX_VALUE,否则设定为 2147483639

  • 无论 ArrayList 中的元素类型是什么,内部数组的最大容量始终遵循上述逻辑。ArrayList 不会扩容到超过 2147483639 的大小;若请求的容量超过这个值,数组大小将被限制在 Integer.MAX_VALUE

Arrays.copyOf() 的功能

  • Arrays.copyOf(this.elementData, newCapacity)
    • 创建一个新的数组,长度为 newCapacity,
    • 并将原数组的内容复制到这个新数组中。
    • 原数组不会被自动删除,而是等待 Java 垃圾回收机制回收。
    • 也就是说,Java 会在没有引用指向原数组时,将其视为垃圾并回收。这样做的目的是节省内存资源。
整体流程小结
  1. 创建 ArrayList 对象时,初始化一个空数组 DEFAULTCAPACITY_EMPTY_ELEMENTDATA
  2. 调用 add() 方法,判断内部数组容量是否足够,若不够则调用 ensureCapacityInternal() 进行容量调整。
  3. ensureCapacityInternal() 通过 calculateCapacity() 确定扩容容量,如果当前为默认空数组,设定默认容量为 10。
  4. ensureExplicitCapacity() 判断当前容量是否满足最小容量需求,若不满足则调用 grow() 扩容。
  5. grow() 方法计算新容量,扩容为原容量的 1.5 倍,若扩容后仍小于所需容量,则取最小容量。若超出最大容量,调用 hugeCapacity() 处理。
  6. 扩容后通过 Arrays.copyOf() 创建新数组并复制旧数组内容。

这就是 ArrayList 添加元素和扩容的详细流程。你之前的描述基本正确,我在这里对一些细节进行了补充。

Vector
1. 同步

它的实现与 ArrayList 类似,但是使用了 synchronized 进行同步

public synchronized boolean add(E e) {
    modCount++;
    ensureCapacityHelper(elementCount + 1);
    elementData[elementCount++] = e;
    return true;
}

public synchronized E get(int index) {
    if (index >= elementCount)
        throw new ArrayIndexOutOfBoundsException(index);

    return elementData(index);
}
2. 扩容

Vector 的构造函数可以传入 capacityIncrement 参数:

它的作用是在扩容时使容量 capacity 增长 capacityIncrement

如果这个参数的值小于等于 0,扩容时每次都令 capacity 为原来的两倍

public Vector(int initialCapacity, int capacityIncrement) {
    super();
    if (initialCapacity < 0)
        throw new IllegalArgumentException("Illegal Capacity: "+
                                           initialCapacity);
    this.elementData = new Object[initialCapacity];
    this.capacityIncrement = capacityIncrement;
}
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 的构造函数时,capacityIncrement 值被设置为 0,也就是说默认情况下 Vector 每次扩容时容量都会翻倍

public Vector(int initialCapacity) {
    this(initialCapacity, 0);
}

public Vector() {
    this(10);
}
3. 与 ArrayList 的比较
  • Vector 是同步的,因此开销就比 ArrayList 要大,访问速度更慢。最好使用 ArrayList 而不是 Vector,因为同步操作完全可以由程序员自己来控制;
  • Vector 每次扩容请求其大小的 2 倍(也可以通过构造函数设置增长的容量),而 ArrayList 是 1.5 倍。
4. 替代方案

可以使用 Collections.synchronizedList(); 得到一个线程安全的 ArrayList

List<String> list = new ArrayList<>();
List<String> synList = Collections.synchronizedList(list);

也可以使用 concurrent 并发包下的 CopyOnWriteArrayList 类。

List<String> list = new CopyOnWriteArrayList<>();
CopyOnWriteArrayList
1. 读写分离

写操作在一个复制的数组上进行,读操作还是在原始数组中进行,读写分离,互不影响。

写操作需要加锁,防止并发写入时导致写入数据丢失。

写操作结束之后需要把原始数组指向新的复制数组

public boolean add(E e) {
    final ReentrantLock lock = this.lock;
    lock.lock();
    try {
        Object[] elements = getArray();
        int len = elements.length;
        Object[] newElements = Arrays.copyOf(elements, len + 1);
        newElements[len] = e;
        setArray(newElements);
        return true;
    } finally {
        lock.unlock();
    }
}

final void setArray(Object[] a) {
    array = a;
}
@SuppressWarnings("unchecked")
private E get(Object[] a, int index) {
    return (E) a[index];
}
2. 适用场景

CopyOnWriteArrayList写操作的同时允许读操作,大大提高了读操作的性能,因此很适合读多写少的应用场景。

但是 CopyOnWriteArrayList 有其缺陷:

  • 内存占用:在写操作时需要复制一个新的数组,使得内存占用为原来的两倍左右;
  • 数据不一致读操作不能读取实时性的数据,因为部分写操作的数据还未同步到读数组中。

所以 CopyOnWriteArrayList 不适合内存敏感以及对实时性要求很高的场景

LinkedList
1. 概览

基于双向链表实现,使用 Node 存储链表节点信息。

private static class Node<E> {
    E item;
    Node<E> next;
    Node<E> prev;
}

每个链表存储了 first 和 last 指针:

transient Node<E> first;
transient Node<E> last;
2.存储结构

image-20220611203910309

3. 与 ArrayList 的比较

底层实现

ArrayList 基于动态数组实现,LinkedList 基于双向链表实现。ArrayListLinkedList 的区别可以归结为数组和链表的区别

  • 数组支持随机访问,但插入删除的代价很高,需要移动大量元素;
  • 链表不支持随机访问,但插入删除只需要改变指针。

使用场景

  • ArrayList:适用于需要频繁随机访问和按索引遍历的场景,因为其基于数组,支持O(1)时间复杂度的随机访问。
  • LinkedList:适用于需要频繁插入和删除元素的场景,尤其是当插入和删除操作在列表中间或前端发生时,因为链表在这些操作中不涉及元素的移动。

总计

ArrayListLinkedList的底层机制和执行过程有显著不同。**ArrayList是基于动态数组的,需要考虑容量的扩展和数组元素的移动。而LinkedList基于双向链表,不需要扩容,但在添加、删除操作时需要修改节点的引用。**两者适用于不同的使用场景,选择时需根据具体需求和性能考虑。

4.汇总分析
/**
 * LinkedList类是一个基于双向链表的列表实现,允许存储重复的元素,包括null。
 * 它实现了List、Deque、Cloneable和Serializable接口,支持快速插入、删除操作。
 * 适用于频繁在列表中间插入和删除元素的场景。该类是线程不安全的。
 * 
 * @param <E> 元素的类型
 */
public class LinkedList<E> extends AbstractSequentialList<E> 
                            implements List<E>, Deque<E>, Cloneable, java.io.Serializable {
    
    // 序列化的UID,用于对象的序列化和反序列化
    private static final long serialVersionUID = 876323262645176354L;

    /**
     * 列表的第一个节点。可以为null,表示链表为空。
     */
    transient Node<E> first;

    /**
     * 列表的最后一个节点。可以为null,表示链表为空。
     */
    transient Node<E> last;

    /**
     * 链表的大小,即链表中元素的个数。
     */
    transient int size = 0;

    /**
     * 无参构造方法,初始化一个空的LinkedList。
     */
    public LinkedList() {
    }

    /**
     * Node类是LinkedList的内部类,表示链表中的一个节点。
     * 每个节点包含元素值和前后节点的引用,用于构建双向链表。
     */
    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;
        }
    }

    /**
     * 在链表的最后插入指定元素。该操作等同于add(E e)。
     * 
     * @param e 要添加的元素
     * @return true (LinkedList总是允许插入操作)
     */
    public boolean add(E e) {
        linkLast(e); // 调用linkLast方法,将元素添加到链表末尾
        return true;
    }

    /**
     * 将指定元素添加到链表的末尾。是一个私有方法,仅在内部使用。
     * 
     * @param e 要添加的元素
     */
    private void linkLast(E e) {
        final Node<E> l = last;  // 获取当前最后一个节点的引用
        final Node<E> newNode = new Node<>(l, e, null); // 创建新节点,prev为当前最后节点,next为null
        last = newNode;  // 更新last指向新节点
        if (l == null)   // 如果链表为空(即first也是null)
            first = newNode;  // 将first指向新节点
        else
            l.next = newNode; // 更新原最后节点的next指向新节点
        size++;  // 更新链表大小
        modCount++;  // 更新修改计数器,用于迭代器快速失败机制
    }

    /**
     * 获取链表中第一个元素的值。若链表为空,抛出NoSuchElementException异常。
     * 
     * @return 链表中的第一个元素
     * @throws NoSuchElementException 如果链表为空
     */
    public E getFirst() {
        final Node<E> f = first;  // 获取第一个节点的引用
        if (f == null)  // 如果第一个节点为null,表示链表为空
            throw new NoSuchElementException();
        return f.item;  // 返回第一个节点的元素值
    }

    /**
     * 获取链表中最后一个元素的值。若链表为空,抛出NoSuchElementException异常。
     * 
     * @return 链表中的最后一个元素
     * @throws NoSuchElementException 如果链表为空
     */
    public E getLast() {
        final Node<E> l = last;  // 获取最后一个节点的引用
        if (l == null)  // 如果最后一个节点为null,表示链表为空
            throw new NoSuchElementException();
        return l.item;  // 返回最后一个节点的元素值
    }

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

    /**
     * 删除链表中的最后一个元素,并返回该元素。若链表为空,抛出NoSuchElementException异常。
     * 
     * @return 被删除的最后一个元素
     * @throws NoSuchElementException 如果链表为空
     */
    public E removeLast() {
        final Node<E> l = last;  // 获取最后一个节点的引用
        if (l == null)  // 如果最后一个节点为null,表示链表为空
            throw new NoSuchElementException();
        return unlinkLast(l);  // 调用unlinkLast方法,删除最后一个节点
    }

    /**
     * 从链表中删除指定的第一个节点。是一个私有方法,仅在内部使用。
     * 
     * @param f 要删除的第一个节点
     * @return 被删除的节点的元素值
     */
    private E unlinkFirst(Node<E> f) {
        final E element = f.item;  // 获取第一个节点的元素值
        final Node<E> next = f.next;  // 获取第二个节点的引用
        f.item = null;  // 清空第一个节点的元素
        f.next = null;  // 将第一个节点的next引用置为null,便于GC
        first = next;  // 更新first为第二个节点
        if (next == null)  // 如果第二个节点为null,表示链表为空
            last = null;  // 更新last为null
        else
            next.prev = null;  // 将第二个节点的prev引用置为null
        size--;  // 更新链表大小
        modCount++;  // 更新修改计数器
        return element;  // 返回被删除的元素值
    }

    /**
     * 从链表中删除指定的最后一个节点。是一个私有方法,仅在内部使用。
     * 
     * @param l 要删除的最后一个节点
     * @return 被删除的节点的元素值
     */
    private E unlinkLast(Node<E> l) {
        final E element = l.item;  // 获取最后一个节点的元素值
        final Node<E> prev = l.prev;  // 获取倒数第二个节点的引用
        l.item = null;  // 清空最后一个节点的元素
        l.prev = null;  // 将最后一个节点的prev引用置为null,便于GC
        last = prev;  // 更新last为倒数第二个节点
        if (prev == null)  // 如果倒数第二个节点为null,表示链表为空
            first = null;  // 更新first为null
        else
            prev.next = null;  // 将倒数第二个节点的next引用置为null
        size--;  // 更新链表大小
        modCount++;  // 更新修改计数器
        return element;  // 返回被删除的元素值
    }

    // 省略了大量的方法,如addFirst(), addLast(), remove(), iterator()等,
    // 它们的实现原理与上面的方法类似,基于双向链表进行元素的插入、删除和访问。
}

关键思路总结

  • LinkedList 是一个基于双向链表的数据结构,节点类 Node 包含前后指针,用于实现快速的插入和删除操作。
  • 头节点 first 和尾节点 last 提供了在链表两端进行操作的支持。
  • 链表的大小由 size 维护,修改操作计数器 modCount 用于快速失败机制,确保在迭代器操作期间检测到结构性修改。

核心操作方法包括 add(), removeFirst(), removeLast(), 以及私有的 linkLast()unlinkFirst(),这些方法是构建链表操作的基础。

5.执行分析1(add)
示例代码
List<Integer> list = new ArrayList();
list.add(1);
执行过程
  1. LinkedList 构造方法
/**
 * 构造一个空的链表。
 */
public LinkedList() {
}

// 或者带集合参数的构造方法
/**
 * 构造一个包含指定集合中元素的链表,按照集合的迭代器返回的顺序。
 *
 * @param c 要包含在此链表中的集合
 */
public LinkedList(Collection<? extends E> c) {
    this(); // 调用无参构造,初始化空链表
    addAll(c); // 添加集合中的所有元素到新链表中
}

当执行 new LinkedList<>() 时,无参构造方法只是初始化了一个空的链表,并没有进行其他特殊处理。带参数的构造方法会按照参数集合的迭代顺序,将所有元素添加到新链表中。

  1. add() 方法

add() 方法用于将指定元素添加到链表的末尾,实际上是调用了 addLast() 方法。

/**
 * 将指定元素添加到此链表的末尾。
 *
 * @param e 要添加到此链表的元素
 * @return 总是返回 true
 */
public boolean add(E e) {
    linkLast(e); // 调用 linkLast 方法将元素添加到链表末尾
    return true;
}

3.linkLast(E e) 方法

linkLast() 方法是 LinkedList 添加元素的核心方法,用于在链表末尾插入新节点。

/**
 * 将元素链接为链表的最后一个元素。
 *
 * @param e 要添加的元素
 */
void linkLast(E e) {
    final Node<E> l = last; // 当前链表的最后一个节点
    final Node<E> newNode = new Node<>(l, e, null); // 创建一个新节点,前驱为当前最后一个节点,后继为 null

    last = newNode; // 将新节点赋值为链表的最后一个节点
    if (l == null) // 如果链表之前为空
        first = newNode; // 则新节点也是第一个节点
    else
        l.next = newNode; // 否则,将之前的最后一个节点的 next 指针指向新节点

    size++; // 链表大小加 1
    modCount++; // 修改次数加 1
}
  1. 方法解读
  1. linkLast() 方法

    • final Node<E> l = last;:将当前链表的最后一个有效节点赋值给临时变量 l
    • final Node<E> newNode = new Node<>(l, e, null);:新建一个节点,将参数按照顺序传入构造方法,实际是给新节点的前驱指针指向当前链表的最后一个节点,当前节点的值为要添加的元素,后继指针为 null(因为它将是链表的最后一个节点)。
  2. 条件判断

    • if (l == null) 判断链表是否为空。如果为空(即 lastnull),则新节点既是 first,也是 last
    • 否则,将当前链表最后一个节点的 next 指针指向新节点。
  3. 链表大小和修改次数

    • size++:更新链表大小。
    • modCount++:用于记录链表的结构修改次数,主要用于迭代器快速失败机制。
流程小结

add() 方法通过 linkLast() 将指定元素添加到链表的末尾,主要过程包括:

  • 创建新节点并连接到链表的末尾。
  • 更新链表的头尾指针及链表大小。
  • 确保链表结构的完整性。

这种操作的时间复杂度为 O(1),因为直接操作了链表的尾节点。

整理后的源码与注释

/**
 * LinkedList 的无参构造方法,构造一个空链表。
 */
public LinkedList() {
}

/**
 * 构造一个包含指定集合中元素的链表,按照集合的迭代器返回的顺序。
 *
 * @param c 要包含在此链表中的集合
 */
public LinkedList(Collection<? extends E> c) {
    this(); // 初始化空链表
    addAll(c); // 按照集合的迭代器顺序添加所有元素到链表中
}

/**
 * 将指定元素添加到链表的末尾。
 *
 * @param e 要添加到链表的元素
 * @return 总是返回 true
 */
public boolean add(E e) {
    linkLast(e); // 将元素添加到链表的末尾
    return true;
}

/**
 * 将元素链接为链表的最后一个元素。
 *
 * @param e 要添加的元素
 */
void linkLast(E e) {
    final Node<E> l = last; // 当前链表的最后一个节点
    final Node<E> newNode = new Node<>(l, e, null); // 创建一个新节点,前驱为当前最后一个节点,后继为 null

    last = newNode; // 更新链表的最后一个节点为新节点
    if (l == null) // 如果链表为空
        first = newNode; // 新节点也是链表的第一个节点
    else
        l.next = newNode; // 否则,将之前的最后一个节点的 next 指针指向新节点

    size++; // 链表大小加 1
    modCount++; // 修改次数加 1
}

以上是 LinkedListadd() 方法和 linkLast() 方法的完整源码流程,并包含了详细的中文注释。

6.执行分析2(remove)
示例代码
List<Integer> list = new ArrayList();
list.remove(1);
执行过程

接下来进行逐步讲解 LinkedListremove(int index) 方法的逻辑,最终会给出完整的源码及注释。


remove(int index) 方法

/**
 * 移除链表中指定索引位置的元素
 * @param index 需要移除的元素的索引
 * @return 被移除的元素
 * @throws IndexOutOfBoundsException 如果索引超出链表范围
 */
public E remove(int index) {
    // 检查索引是否合法
    checkElementIndex(index);
    // 通过 unlink 方法移除指定索引位置的节点并返回被移除的元素
    return unlink(node(index));
}

该方法首先通过 checkElementIndex(index) 检查索引的合法性,然后通过 node(index) 获取指定索引位置的节点,最后通过 unlink() 方法移除该节点。


checkElementIndex(int index) 方法

/**
 * 检查索引是否合法,如果索引无效则抛出异常
 * @param index 待检查的索引
 * @throws IndexOutOfBoundsException 如果索引超出范围
 */
private void checkElementIndex(int index) {
    if (!isElementIndex(index)) {
        // 抛出索引越界异常
        throw new IndexOutOfBoundsException(outOfBoundsMsg(index));
    }
}

/**
 * 判断索引是否为有效索引
 * @param index 待检查的索引
 * @return 如果索引在有效范围内则返回 true,否则返回 false
 */
private boolean isElementIndex(int index) {
    // 有效索引为 [0, size) 范围内
    return index >= 0 && index < size;
}

checkElementIndex 方法通过调用 isElementIndex 判断索引是否在有效范围内。如果索引无效,则抛出 IndexOutOfBoundsException


node(int index) 方法

/**
 * 根据索引获取链表中的节点
 * @param index 需要获取的节点的索引
 * @return 指定索引位置的节点
 */
Node<E> node(int index) {
    // 判断从链表头还是链表尾开始遍历,以提升性能
    if (index < (size >> 1)) { // size >> 1 等价于 size / 2,用于确定从头还是从尾遍历
        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 代表 size / 2,这个逻辑用于判断从链表的头部还是尾部开始遍历,以减少遍历的节点数量,从而提升效率。
  • 如果 index 小于链表长度的一半,说明距离头部更近,则从头部开始遍历;否则从尾部开始遍历。

问:为什么size >> 1代表size / 2,用size / 2不是更加容易理解吗?

  1. 位运算原理

    • 在计算机的底层,数字是以二进制形式存储的。右移操作size >> 1将数字的二进制表示向右移动一位,相当于将数字/2
    • 例如,对于一个整数size = 8,其二进制形式为1 0 0 0。执行size >> 1后,结果变为0 1 0 0 ,即4,相当于size / 2。这种转换非常高效,因为位移运算在硬件层面上执行,比一般的除法操作快得多。
  2. 为什么size >> 1等价于size / 2

    • 右移一位是将原始二进制的每一位向右移动一格,最高位补0,因此,数字的值缩减一半。对于正整数来说,这与除以2的结果相同。
  3. 在链表中使用size >> 1的场景

    • 在链表操作中,选择从链表的头部或尾部进行遍历的关键在于遍历的效率。如果我们知道链表的长度size,通过size >> 1可以快速得到链表的一半长度,从而决定是从头部还是尾部开始遍历,以减少遍历的节点数
    • 例如,链表长度为10,想访问第7个节点。通过size >> 1 = 10 >> 1 = 5,知道7大于5,因此从尾部开始遍历会更快;反之,如果想要访问第3个节点,由于3 < 5,直接从头部遍历效率更高。
  4. 为什么位移运算更高效

    • 性能:位移运算在硬件上仅需一个 CPU 指令完成,除法运算则可能涉及更多步骤,尤其对于较大的数除法,计算开销较大。位移运算能在更短的时间内处理大数据集。
    • 代码简洁性:在需要高效运算的场景下,位移操作更符合性能优化的需求,例如在遍历链表时减少分支判断、简化逻辑。

    深层次的优化思想

    • 使用 size >> 1 是典型的性能优化手段,背后蕴含了时间复杂度优化的思维。在链表这种线性数据结构中,遍历是常见操作。通过选择合适的遍历方向,可以将最坏情况下 O(n) 的遍历次数减少一半,特别是对双向链表这种结构,合理的遍历策略可以极大提升效率。
  • 减少冗余操作通过简单位移运算替代除法计算,减少了 CPU 的负载,优化了代码的执行效率,这在高频链表操作中是很有意义的。

    总结

    size >> 1 之所以能够代表 size / 2源自于位移运算在二进制层面的性质,它能将数值有效地减半。通过这种操作,遍历链表时可以通过较少的计算量快速决定从头部还是尾部开始遍历,从而减少遍历节点数,提升整体操作效率。


unlink(Node<E> x) 方法

/**
 * 将链表中指定节点从链表中移除
 * @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;

    // 如果当前节点的前驱节点为 null,说明它是链表的第一个节点
    if (prev == null) {
        first = next; // 更新头节点为当前节点的后继节点
    } else {
        prev.next = next; // 前驱节点的 next 指向当前节点的后继节点
        x.prev = null; // 断开当前节点与前驱节点的连接
    }

    // 如果当前节点的后继节点为 null,说明它是链表的最后一个节点
    if (next == null) {
        last = prev; // 更新尾节点为当前节点的前驱节点
    } else {
        next.prev = prev; // 后继节点的 prev 指向当前节点的前驱节点
        x.next = null; // 断开当前节点与后继节点的连接
    }

    // 断开当前节点的元素引用,帮助 GC
    x.item = null;
    // 更新链表的大小
    size--;
    // 结构修改计数器,记录链表的结构变化(用于迭代器快速失败)
    modCount++;
    // 返回被移除节点中的元素
    return element;
}

解释:

  1. 如果 prev == null,表示要删除的节点是链表的第一个节点,此时更新 first 指向下一个节点(即 next)。
  2. 如果 next == null,表示要删除的节点是链表的最后一个节点,此时更新 last 指向前一个节点(即 prev)。
  3. x.item = null;:断开该节点与存储的元素的引用,方便垃圾回收。
  4. size--:链表大小减一,更新结构修改计数器 modCount,用于快速失败机制。

整体流程小结

整体流程梳理

  • remove(int index) 方法先检查索引的合法性。
  • 调用 node(int index) 方法找到指定位置的节点。
  • 调用 unlink(Node<E> x) 方法移除节点,并更新链表结构。
  • 最终返回被移除节点的元素值。
  1. remove(int index) 方法是通过 checkElementIndex(index) 检查索引合法性,再通过 node(index) 找到对应的节点,最后通过 unlink() 方法将节点从链表中移除并返回元素值。
  2. node(int index) 方法根据索引位置从链表头或尾部进行遍历,提升了查找效率。
  3. unlink() 方法核心处理逻辑是断开当前节点与前驱、后继节点的连接,并更新链表的头尾节点和链表大小。
HashMap

为了便于理解,以下源码分析以 JDK 1.7 为主。

1. 存储结构

内部包含了一个 Entry 类型的数组 table

Entry 存储着键值对。它包含了四个字段:

  1. next 字段我们可以看出 Entry 是一个链表。即数组中的每个位置被当成一个桶,一个桶存放一个链表
  2. HashMap 使用拉链法来解决冲突,同一个链表中:存放**哈希值和散列桶取模运算结果相同的 Entry**。

image-20220613165232652

transient Entry[] table;
static class Entry<K,V> implements Map.Entry<K,V> {
    final K key;
    V value;
    Entry<K,V> next;
    int hash;

    Entry(int h, K k, V v, Entry<K,V> n) {
        value = v;
        next = n;
        key = k;
        hash = h;
    }

    public final K getKey() {
        return key;
    }

    public final V getValue() {
        return value;
    }

    public final V setValue(V newValue) {
        V oldValue = value;
        value = newValue;
        return oldValue;
    }

    public final boolean equals(Object o) {
        if (!(o instanceof Map.Entry))
            return false;
        Map.Entry e = (Map.Entry)o;
        Object k1 = getKey();
        Object k2 = e.getKey();
        if (k1 == k2 || (k1 != null && k1.equals(k2))) {
            Object v1 = getValue();
            Object v2 = e.getValue();
            if (v1 == v2 || (v1 != null && v1.equals(v2)))
                return true;
        }
        return false;
    }

    public final int hashCode() {
        return Objects.hashCode(getKey()) ^ Objects.hashCode(getValue());
    }

    public final String toString() {
        return getKey() + "=" + getValue();
    }
}
2. 拉链法的工作原理
HashMap<String, String> map = new HashMap<>();
map.put("K1", "V1");
map.put("K2", "V2");
map.put("K3", "V3");
  • 新建一个 HashMap默认大小为 16
  • 插入 <K1,V1> 键值对,先计算 K1 的 hashCode 为 115,使用除留余数法得到所在的桶下标 115%16=3。
  • 插入 <K2,V2> 键值对,先计算 K2 的 hashCode 为 118,使用除留余数法得到所在的桶下标 118%16=6。
  • 插入 <K3,V3> 键值对,先计算 K3 的 hashCode 为 118,使用除留余数法得到所在的桶下标 118%16=6,插在 <K2,V2> 前面。

应该注意到链表的插入是以头插法方式进行的,例如上面的 <K3,V3> 不是插在 <K2,V2> 后面,而是插入在链表头部。

查找需要分成两步进行:

  • 计算键值对所在的桶
  • 在链表上顺序查找,时间复杂度显然和链表的长度成正比

image-20220613165929550

3. put 操作
public V put(K key, V value) {
    if (table == EMPTY_TABLE) {
        inflateTable(threshold);
    }
    // 键为 null 单独处理
    if (key == null)
        return putForNullKey(value);
    int hash = hash(key);
    // 确定桶下标
    int i = indexFor(hash, table.length);
    // 先找出是否已经存在键为 key 的键值对,如果存在的话就更新这个键值对的值为 value
    for (Entry<K,V> e = table[i]; e != null; e = e.next) {
        Object k;
        if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
            V oldValue = e.value;
            e.value = value;
            e.recordAccess(this);
            return oldValue;
        }
    }

    modCount++;
    // 插入新键值对
    addEntry(hash, key, value, i);
    return null;
}

put操作有一个极端,那就是key和value是否允许为null?

Map<Integer,Integer> map = new HashMap<>();
//1.第一次添加:key和value都为null
map.put(null,null);
//2.第二次添加:key为null,value为1
map.put(null,1);

//输出长度理想为2,但是实际输出为1:key可以为null值,论证:
//key可以为null,但是只能保存一个,再次添加则对应value的值新value的值覆盖
System.out.println(map.size());

//获取的value为1,论证value部分被新的value部分覆盖
System.out.println(map.get(null));

value部分设置为null有无限制?

map.put(1,null);
map.put(2,null);
//以下输出都为null,可见HashMap对于添加的value部分的值并没有什么限制
System.out.println(map.get(1));
System.out.println(map.get(2));

HashMap 允许插入键为 null 的键值对。但是因为无法调用 null 的 hashCode() 方法,也就无法确定该键值对的桶下标,只能通过强制指定一个桶下标来存放HashMap 使用第 0 个桶存放键为 null 的键值对。

private V putForNullKey(V value) {
    for (Entry<K,V> e = table[0]; e != null; e = e.next) {
        if (e.key == null) {
            V oldValue = e.value;
            e.value = value;
            e.recordAccess(this);
            return oldValue;
        }
    }
    modCount++;
    addEntry(0, null, value, 0);
    return null;
}

hashMap允许key为null的原因及调用null的hashCode()方法:https://blog.csdn.net/weixin_46984636/article/details/120606095

使用链表的头插法,也就是新的键值对插在链表的头部,而不是链表的尾部。

void addEntry(int hash, K key, V value, int bucketIndex) {
    if ((size >= threshold) && (null != table[bucketIndex])) {
        resize(2 * table.length);
        hash = (null != key) ? hash(key) : 0;
        bucketIndex = indexFor(hash, table.length);
    }

    createEntry(hash, key, value, bucketIndex);
}

void createEntry(int hash, K key, V value, int bucketIndex) {
    Entry<K,V> e = table[bucketIndex];
    // 头插法,链表头部指向新的键值对
    table[bucketIndex] = new Entry<>(hash, key, value, e);
    size++;
}
Entry(int h, K k, V v, Entry<K,V> n) {
    value = v;
    next = n;
    key = k;
    hash = h;
}
4. 确定桶下标

很多操作都需要先确定一个键值对所在的桶下标。

int hash = hash(key);
int i = indexFor(hash, table.length);
4.1 计算 hash 值
final int hash(Object k) {
    int h = hashSeed;
    if (0 != h && k instanceof String) {
        return sun.misc.Hashing.stringHash32((String) k);
    }

    h ^= k.hashCode();

    // This function ensures that hashCodes that differ only by
    // constant multiples at each bit position have a bounded
    // number of collisions (approximately 8 at default load factor).
    //???
    h ^= (h >>> 20) ^ (h >>> 12);
    return h ^ (h >>> 7) ^ (h >>> 4);
}
public final int hashCode() {
    return Objects.hashCode(key) ^ Objects.hashCode(value);
}

4.2 取模

???

x = 1<<4,即 x 为 2 的 4 次方,它具有以下性质:

x   : 00010000
x-1 : 00001111

令一个数 y 与 x-1 做与运算,可以去除 y 位级表示的第 4 位以上数:

y       : 10110010
x-1     : 00001111
y&(x-1) : 00000010

这个性质和 y 对 x 取模效果是一样的:

y   : 10110010
x   : 00010000
y%x : 00000010

我们知道,位运算的代价比求模运算小的多,因此在进行这种计算时用位运算的话能带来更高的性能。

确定桶下标的最后一步是将 key 的 hash 值对桶个数取模:hash%capacity,如果能保证 capacity 为 2 的 n 次方,那么就可以将这个操作转换为位运算。

static int indexFor(int h, int length) {
    return h & (length-1);
}
5. 扩容-基本原理

HashMap 的 table 长度为 M,需要存储的键值对数量为 N,如果哈希函数满足均匀性的要求,那么每条链表的长度大约为 N/M,因此查找的复杂度为 O(N/M)。

为了让查找的成本降低,应该使 N/M 尽可能小,因此需要保证 M 尽可能大,也就是说 table 要尽可能大。HashMap 采用动态扩容来根据当前的 N 值来调整 M 值,使得空间效率和时间效率都能得到保证。

和扩容相关的参数主要有:capacity、size、threshold 和 load_factor。

参数含义
capacitytable 的容量大小,默认为 16。需要注意的是 capacity 必须保证为 2 的 n 次方。
size键值对数量。
thresholdsize 的临界值,当 size 大于等于 threshold 就必须进行扩容操作。
loadFactor装载因子,table 能够使用的比例,threshold = (int)(capacity* loadFactor)。
static final int DEFAULT_INITIAL_CAPACITY = 16;

static final int MAXIMUM_CAPACITY = 1 << 30;

static final float DEFAULT_LOAD_FACTOR = 0.75f;

transient Entry[] table;

transient int size;

int threshold;

final float loadFactor;

transient int modCount;

从下面的添加元素代码中可以看出,当需要扩容时,令 capacity 为原来的两倍。

void addEntry(int hash, K key, V value, int bucketIndex) {
    Entry<K,V> e = table[bucketIndex];
    table[bucketIndex] = new Entry<>(hash, key, value, e);
    if (size++ >= threshold)
        resize(2 * table.length);
}

扩容使用 resize() 实现,需要注意的是,扩容操作同样需要把 oldTable 的所有键值对重新插入 newTable 中,因此这一步是很费时的。

void resize(int newCapacity) {
    Entry[] oldTable = table;
    int oldCapacity = oldTable.length;
    if (oldCapacity == MAXIMUM_CAPACITY) {
        threshold = Integer.MAX_VALUE;
        return;
    }
    Entry[] newTable = new Entry[newCapacity];
    transfer(newTable);
    table = newTable;
    threshold = (int)(newCapacity * loadFactor);
}

void transfer(Entry[] newTable) {
    Entry[] src = table;
    int newCapacity = newTable.length;
    for (int j = 0; j < src.length; j++) {
        Entry<K,V> e = src[j];
        if (e != null) {
            src[j] = null;
            do {
                Entry<K,V> next = e.next;
                int i = indexFor(e.hash, newCapacity);
                e.next = newTable[i];
                newTable[i] = e;
                e = next;
            } while (e != null);
        }
    }
}
6. 扩容-重新计算桶下标

在进行扩容时,需要把键值对重新计算桶下标,从而放到对应的桶上。在前面提到,HashMap 使用 hash%capacity 来确定桶下标。HashMap capacity 为 2 的 n 次方这一特点能够极大降低重新计算桶下标操作的复杂度。

假设原数组长度 capacity 为 16,扩容之后 new capacity 为 32:

capacity     : 00010000
new capacity : 00100000

对于一个 Key,它的哈希值 hash 在第 5 位:

  • 为 0,那么 hash%00010000 = hash%00100000,桶位置和原来一致;
  • 为 1,hash%00010000 = hash%00100000 + 16,桶位置是原位置 + 16。
7. 计算数组容量

HashMap 构造函数允许用户传入的容量不是 2 的 n 次方,因为它可以自动地将传入的容量转换为 2 的 n 次方。

先考虑如何求一个数的掩码,对于 10010000,它的掩码为 11111111,可以使用以下方法得到:

mask |= mask >> 1    11011000
mask |= mask >> 2    11111110
mask |= mask >> 4    11111111

mask+1 是大于原始数字的最小的 2 的 n 次方。

num     10010000
mask+1 100000000

以下是 HashMap 中计算数组容量的代码:

static final int tableSizeFor(int cap) {
    int n = cap - 1;
    n |= n >>> 1;
    n |= n >>> 2;
    n |= n >>> 4;
    n |= n >>> 8;
    n |= n >>> 16;
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
8. 链表转红黑树

从 JDK 1.8 开始,一个桶存储的链表长度大于等于 8将链表转换为红黑树

9. 与 Hashtable 的比较
  • Hashtable 使用 synchronized 来进行同步。
  • HashMap 可以插入键为 null 的 Entry。
  • HashMap 的迭代器是 fail-fast 迭代器。
  • HashMap 不能保证随着时间的推移 Map 中的元素次序是不变的。
10.执行分析1(创建)
创建未指定大小的HashMap执行源码分析
  1. 问题背景

在Java中,当我们使用new HashMap<>()创建一个未指定初始容量的HashMap时,系统并不会立即为内部存储结构分配空间,而是延迟到第一次插入元素时才进行初始化。此过程包括设置默认的负载因子和计算容量扩容的阈值。为了优化性能,HashMap的容量始终是2的幂次方,以便通过位运算快速定位元素位置。下面我们将通过源码分析,详细梳理整个流程,了解其底层设计和执行机制。

  1. 整体流程概述

  1. 调用HashMap()默认构造函数:在执行new HashMap<>()时,调用无参构造函数,设置默认负载因子为0.75,但并不分配存储空间,只有在第一次插入元素时才会进行初始化。
  2. 设置默认的负载因子:构造函数内部会将负载因子设置为默认值 0.75,这是在没有指定负载因子的情况下的标准值。
  3. 延迟初始化:当首次插入元素时,才会触发对存储空间的初始化操作。此时,会根据当前的容量计算扩容阈值(初始容量为16,阈值为12)。
  4. 容量为2的幂次方优化:在进行初始化时,HashMap的容量通过位运算确保为大于等于指定容量的最小2的幂次方,从而优化了哈希查找的效率。

下面我们结合具体源码,逐步分析每个环节的实现。


  1. 详细流程解析

3.1 调用HashMap()默认构造函数

在执行 new HashMap<>() 时,会调用HashMap的无参构造函数。我们来看它的源码:

/**
 * 构造一个空的 HashMap,默认初始容量为 16,默认负载因子为 0.75。
 */
public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR; // 默认负载因子设为 0.75
}

分析:

  • 在这个构造方法中,HashMap 的默认负载因子被设定为DEFAULT_LOAD_FACTOR = 0.75。这个值表示,当哈希表中元素数量达到容量的75%时,HashMap会触发扩容操作。
  • 这里没有分配存储空间(即 tablenull)。只有当首次插入元素时,才会触发对 table 的初始化。

3.2 等待第一次插入元素

HashMap 在默认构造时并不会立即分配存储空间,而是通过延迟加载的方式,当有元素插入时才会初始化 table。这是一种性能优化手段,用以避免提前占用内存资源。

  • 首次插入元素:当首次调用put()方法时,HashMap会通过resize()方法对内部的数组进行初始化,分配存储空间。
3.3 负载因子的作用

默认负载因子为0.75,表示当元素数量超过容量的75%时,HashMap将进行扩容操作。这样设计是为了在空间和时间复杂度之间取得平衡:容量过小会导致频繁扩容,过大会浪费空间。

this.loadFactor = DEFAULT_LOAD_FACTOR; // 默认负载因子 0.75
  • 扩容阈值:在首次插入元素时,HashMap会根据当前容量计算出扩容的临界值 threshold,这个值等于 容量 * 负载因子。例如,默认初始容量为16,则 threshold = 16 * 0.75 = 12。当元素数量达到12时,HashMap将进行扩容。

3.4 容量为2的幂次方优化

HashMap的容量必须是2的幂次方,这是为了在计算哈希表中元素位置时能更高效地使用位运算。

当我们调用带参数的构造函数 HashMap(int initialCapacity, float loadFactor) 时,会对初始容量进行检查,并通过tableSizeFor()方法将其调整为2的幂次方。

/**
 * 返回大于等于给定目标容量的最小 2 的幂次方数。
 */
static final int tableSizeFor(int cap) {
    int n = cap - 1;
    n |= n >>> 1;
    n |= n >>> 2;
    n |= n >>> 4;
    n |= n >>> 8;
    n |= n >>> 16;
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

分析:

  • 通过位运算,将容量调整为不小于指定容量的最小2的幂次方。

    • cap - 1 是为了处理刚好是2的幂次方的情况,防止进入不必要的计算。
    • n |= n >>> 1 等一系列位移操作是为了将较低位的1逐渐向高位传播,确保最终结果为大于或等于 cap 的最小2的幂次方。
  • 返回结果

    • 如果cap小于等于0,则返回1,作为最小容量。
    • 如果cap超过了MAXIMUM_CAPACITY(即 1 << 30),则返回最大容量 2^30,以防止容量过大而导致性能问题。
    • 否则,返回比 cap 大或等于的最小2的幂次方。
3.5 HashMap(int initialCapacity, float loadFactor) 构造函数
/**
 * 构造一个指定初始容量和负载因子的空 HashMap。
 *
 * @param initialCapacity 初始容量
 * @param loadFactor      负载因子
 * @throws IllegalArgumentException 如果初始容量为负数,或者负载因子为非正数。
 */
public HashMap(int initialCapacity, float loadFactor) {
    if (initialCapacity < 0)
        throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity);
    if (initialCapacity > MAXIMUM_CAPACITY)
        initialCapacity = MAXIMUM_CAPACITY;
    if (loadFactor <= 0 || Float.isNaN(loadFactor))
        throw new IllegalArgumentException("Illegal load factor: " + loadFactor);
    this.loadFactor = loadFactor;
    this.threshold = tableSizeFor(initialCapacity);
}

分析:

  • 参数校验:首先检查 initialCapacity 是否为负值,如果是负值则抛出非法参数异常。其次检查 initialCapacity 是否超过了最大容量(MAXIMUM_CAPACITY = 2^30),如果超过了则将其设为最大容量。最后,确保 loadFactor 是正数且不为 NaN
  • 计算扩容阈值:通过 tableSizeFor() 方法计算出合适的容量,并设置扩容阈值 threshold

完整流程总结
  1. 调用无参构造函数
    • 执行 new HashMap<>() 时,会调用无参构造函数,仅设置默认的负载因子为 0.75,没有为内部数组 table 分配空间,延迟到第一次插入元素时进行初始化。
  2. 首次插入元素时初始化
    • 当首次插入元素时,会通过 resize() 方法初始化内部数组 table,默认初始容量为 16,阈值 threshold1216 * 0.75)。
  3. 容量为2的幂次方
    • 通过 tableSizeFor() 方法,确保 HashMap 的容量始终为2的幂次方,从而在计算哈希表位置时可以使用位运算,提高查找和插入效率。
  4. 带参构造函数
    • 如果指定了初始容量和负载因子,首先检查其有效性,然后通过 tableSizeFor() 方法调整容量为2的幂次方,最后计算扩容阈值。

通过这种机制,HashMap能够有效管理内部存储空间,保证在不同负载情况下的性能表现。

ConcurrentHashMap
1. 存储结构

image-20220615183441176

static final class HashEntry<K,V> {
    final int hash;
    final K key;
    volatile V value;
    volatile HashEntry<K,V> next;
}

volatile:https://www.cofu.ltd/archives/327

ConcurrentHashMapHashMap 实现上类似,最主要的差别是 ConcurrentHashMap 采用了分段锁(Segment),每个分段锁维护着几个桶(HashEntry)多个线程可以同时访问不同分段锁上的桶,从而使其并发度更高并发度就是 Segment 的个数)。

Segment 继承自 ReentrantLock

static final class Segment<K,V> extends ReentrantLock implements Serializable {

    private static final long serialVersionUID = 2249069246763182397L;

    static final int MAX_SCAN_RETRIES =
        Runtime.getRuntime().availableProcessors() > 1 ? 64 : 1;

    transient volatile HashEntry<K,V>[] table;

    transient int count;

    transient int modCount;

    transient int threshold;

    final float loadFactor;
}
final Segment<K,V>[] segments;

默认的并发级别为 16,也就是说默认创建 16 个 Segment。

static final int DEFAULT_CONCURRENCY_LEVEL = 16;
2. size 操作

每个 Segment 维护了一个 count 变量来统计该 Segment 中的键值对个数

/**
 * The number of elements. Accessed only either within locks
 * or among other volatile reads that maintain visibility.
 */
transient int count;

在执行 size 操作时,需要遍历所有 Segment 然后把 count 累计起来。

ConcurrentHashMap 在执行 size 操作时

  1. 先尝试不加锁,
  2. 如果连续两次不加锁操作得到的结果一致,
  3. 那么可以认为这个结果是正确的。

尝试次数使用 RETRIES_BEFORE_LOCK 定义,该值为 2,retries 初始值为 -1,因此尝试次数为 3

如果尝试的次数超过 3 次,就需要对每个 Segment 加锁

/**
 * Number of unsynchronized retries in size and containsValue
 * methods before resorting to locking. This is used to avoid
 * unbounded retries if tables undergo continuous modification
 * which would make it impossible to obtain an accurate result.
 */
static final int RETRIES_BEFORE_LOCK = 2;

public int size() {
    // Try a few times to get accurate count. On failure due to
    // continuous async changes in table, resort to locking.
    final Segment<K,V>[] segments = this.segments;
    int size;
    boolean overflow; // true if size overflows 32 bits
    long sum;         // sum of modCounts
    long last = 0L;   // previous sum
    int retries = -1; // first iteration isn't retry
    try {
        for (;;) {
            // 超过尝试次数,则对每个 Segment 加锁
            if (retries++ == RETRIES_BEFORE_LOCK) {
                for (int j = 0; j < segments.length; ++j)
                    ensureSegment(j).lock(); // force creation
            }
            sum = 0L;
            size = 0;
            overflow = false;
            for (int j = 0; j < segments.length; ++j) {
                Segment<K,V> seg = segmentAt(segments, j);
                if (seg != null) {
                    sum += seg.modCount;
                    int c = seg.count;
                    if (c < 0 || (size += c) < 0)
                        overflow = true;
                }
            }
            // 连续两次得到的结果一致,则认为这个结果是正确的
            if (sum == last)
                break;
            last = sum;
        }
    } finally {
        if (retries > RETRIES_BEFORE_LOCK) {
            for (int j = 0; j < segments.length; ++j)
                segmentAt(segments, j).unlock();
        }
    }
    return overflow ? Integer.MAX_VALUE : size;
}
3. JDK 1.8 的改动

JDK 1.7 使用分段锁机制来实现并发更新操作,核心类为 Segment,它继承自重入锁 ReentrantLock,并发度与 Segment 数量相等

JDK 1.8 使用了 CAS 操作来支持更高的并发度,在 CAS 操作失败时使用内置锁 synchronized

并且 JDK 1.8 的实现也在链表过长时会转换为红黑树

LinkedHashMap
存储结构

继承自 HashMap,因此具有和 HashMap 一样的快速查找特性。

public class LinkedHashMap<K,V> extends HashMap<K,V> implements Map<K,V>

内部维护了一个双向链表,用来维护插入顺序或者 LRU 顺序。

/**
 * The head (eldest) of the doubly linked list.
 */
transient LinkedHashMap.Entry<K,V> head;

/**
 * The tail (youngest) of the doubly linked list.
 */
transient LinkedHashMap.Entry<K,V> tail;

accessOrder 决定了顺序,默认为 false,此时维护的是插入顺序。

final boolean accessOrder;

LinkedHashMap 最重要的是以下用于维护顺序的函数,它们会在 put、get 等方法中调用。

void afterNodeAccess(Node<K,V> p) { }
void afterNodeInsertion(boolean evict) { }
afterNodeAccess()

当一个节点被访问时:

  1. 如果 accessOrder 为 true,则会将该节点移到链表尾部。
  2. 也就是说指定为 LRU 顺序之后,在每次访问一个节点时,会将这个节点移到链表尾部,
  3. 保证链表尾部是最近访问的节点,那么链表首部就是最近最久未使用的节点
void afterNodeAccess(Node<K,V> e) { // move node to last
    LinkedHashMap.Entry<K,V> last;
    if (accessOrder && (last = tail) != e) {
        LinkedHashMap.Entry<K,V> p =
            (LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;
        p.after = null;
        if (b == null)
            head = a;
        else
            b.after = a;
        if (a != null)
            a.before = b;
        else
            last = b;
        if (last == null)
            head = p;
        else {
            p.before = last;
            last.after = p;
        }
        tail = p;
        ++modCount;
    }
}
afterNodeInsertion()

在 put 等操作之后执行,当 removeEldestEntry() 方法返回 true 时会移除最晚的节点,也就是链表首部节点 first。

evict 只有在构建 Map 的时候才为 false,在这里为 true。

void afterNodeInsertion(boolean evict) { // possibly remove eldest
    LinkedHashMap.Entry<K,V> first;
    if (evict && (first = head) != null && removeEldestEntry(first)) {
        K key = first.key;
        removeNode(hash(key), key, null, false, true);
    }
}

removeEldestEntry() 默认为 false,如果需要让它为 true,需要继承 LinkedHashMap 并且覆盖这个方法的实现,这在实现 LRU 的缓存中特别有用,通过移除最近最久未使用的节点,从而保证缓存空间足够,并且缓存的数据都是热点数据。

protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
    return false;
}
LRU 缓存

以下是使用 LinkedHashMap 实现的一个 LRU 缓存:

  • 设定最大缓存空间 MAX_ENTRIES 为 3;
  • 使用 LinkedHashMap 的构造函数将 accessOrder 设置为 true,开启 LRU 顺序;
  • 覆盖 removeEldestEntry() 方法实现,在节点多于 MAX_ENTRIES 就会将最近最久未使用的数据移除
class LRUCache<K, V> extends LinkedHashMap<K, V> {
    private static final int MAX_ENTRIES = 3;

    protected boolean removeEldestEntry(Map.Entry eldest) {
        return size() > MAX_ENTRIES;
    }

    LRUCache() {
        super(MAX_ENTRIES, 0.75f, true);
    }
}
public static void main(String[] args) {
    LRUCache<Integer, String> cache = new LRUCache<>();
    cache.put(1, "a");
    cache.put(2, "b");
    cache.put(3, "c");
    cache.get(1);
    cache.put(4, "d");
    System.out.println(cache.keySet());
}
[3, 1, 4]
WeakHashMap
存储结构

WeakHashMap 的 Entry 继承自 WeakReference,被 WeakReference 关联的对象在下一次垃圾回收时会被回收。

WeakHashMap 主要用来实现缓存,通过使用 WeakHashMap 来引用缓存对象,由 JVM 对这部分缓存进行回收。

private static class Entry<K,V> extends WeakReference<Object> implements Map.Entry<K,V>
ConcurrentCache

Tomcat 中的 ConcurrentCache 使用了 WeakHashMap 来实现缓存功能。

ConcurrentCache 采取的是分代缓存

  • 经常使用的对象放入 eden 中,eden 使用 ConcurrentHashMap 实现,不用担心会被回收(伊甸园)
  • 不常用的对象放入 longtermlongterm 使用 WeakHashMap 实现,这些老对象会被垃圾收集器回收。
  • 当调用 get() 方法时,会先从 eden 区获取,如果没有找到的话再到 longterm 获取,当从 longterm 获取到就把对象放入 eden 中,从而保证经常被访问的节点不容易被回收。
  • 当调用 put() 方法时:如果 eden 的大小超过了 size,那么就将 eden 中的所有对象都放入 longterm 中,利用虚拟机回收掉一部分不经常使用的对象
public final class ConcurrentCache<K, V> {

    private final int size;

    private final Map<K, V> eden;

    private final Map<K, V> longterm;

    public ConcurrentCache(int size) {
        this.size = size;
        this.eden = new ConcurrentHashMap<>(size);
        this.longterm = new WeakHashMap<>(size);
    }

    public V get(K k) {
        V v = this.eden.get(k);
        if (v == null) {
            v = this.longterm.get(k);
            if (v != null)
                this.eden.put(k, v);
        }
        return v;
    }

    public void put(K k, V v) {
        if (this.eden.size() >= size) {
            this.longterm.putAll(this.eden);
            this.eden.clear();
        }
        this.eden.put(k, v);
    }
}

参考资料

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值