Java集合—List、Set、Map

ListSetMap 是 Java 集合框架中的三种不同类型的接口,它们在数据存储和访问方面有不同的特点和用法。以下是它们之间的主要区别:

List

  • 接口: java.util.List

  • 特点:

    • 有序: List 维护元素的插入顺序,因此可以通过索引访问元素。
    • 允许重复元素: List 允许存储重复的元素,即同一个元素可以多次出现在列表中。
    • 访问方式: 可以通过索引(例如 list.get(index))访问元素。
  • 常用实现类:

    • ArrayList: 基于动态数组实现,随机访问快,适合频繁的读操作。
    • LinkedList: 基于双向链表实现,插入和删除操作快,适合频繁的增删操作。
    • Vector: 类似 ArrayList,但线程安全(已不常用)。
  • 示例:

    List<String> list = new ArrayList<>();
    list.add("Apple");
    list.add("Banana");
    list.add("Apple");  // 允许重复元素
    System.out.println(list.get(0));  // 通过索引访问元素
    

ArrayList的扩容机制

ArrayList内部基于动态数组实现,根据实际存储的元素动态地扩容或缩容。

ArrayList 的扩容机制是为了应对其底层数组容量不足以存储新增元素的情况。它的扩容机制设计得比较灵活,以平衡性能和内存使用。

(1)初始容量

当创建一个 ArrayList 时,如果使用无参构造函数,ArrayList 会初始化为一个空的数组,当第一次添加元素时,数组容量会扩展到==默认初始容量 ==10。如果在构造时指定了容量,那么 ArrayList 会直接初始化为指定的容量。

(2) 扩容触发条件

每当向 ArrayList 中添加新元素时,如果元素数量超过了当前底层数组的容量ArrayList 就会触发扩容。

(3)扩容过程

ArrayList 需要扩容时,它会执行以下步骤:

  1. 新建一个更大的数组:新数组的容量通常是原来容量的 1.5 倍(具体来说,新容量 = 原容量 + (原容量 >> 1)),即在原容量的基础上增加一半。这种扩容方式能够平衡数组容量的增长速度和内存的使用效率。

    • 例如,当前容量为 10,扩容后的新容量将是 15。
  2. 复制旧数组中的元素到新数组:所有现有的元素会被复制到新数组中,旧数组会被垃圾回收机制回收。

  3. 添加新元素:在新数组中放入新添加的元素。

(4)性能影响

  • 扩容成本:扩容操作是一个耗时的过程,因为它需要分配一个更大的数组,并将旧数组的内容复制到新数组中。因此,如果频繁发生扩容,性能可能会受到影响。
  • 建议:如果可以预见 ArrayList 中会存储大量元素,建议在创建 ArrayList 时指定一个较大的初始容量,以减少扩容次数,从而提高性能。

总结

ArrayList 的扩容机制通过增加数组容量来动态调整数组的大小,默认扩容是原容量的 1.5 倍。虽然扩容操作可能会影响性能,但通过适当地设置初始容量,可以减少扩容的次数,从而提高性能。

Set

  • 接口: java.util.Set

  • 特点:

    • 无序: Set 不保证元素的插入顺序(除非使用有序的 Set 实现)。
    • 不允许重复元素: Set 中的每个元素都是唯一的,不能包含重复元素。
    • 无索引访问: 由于没有顺序概念,Set 不提供通过索引访问元素的方法。
  • 常用实现类:

    • HashSet: 基于哈希表实现,元素无序,常用于需要快速查找的场景。
    • LinkedHashSet: 保持元素的插入顺序,并且基于哈希表实现。
    • TreeSet: 基于红黑树实现,元素按自然顺序排序或使用指定的比较器排序。
  • 示例:

    Set<String> set = new HashSet<>();
    set.add("Apple");
    set.add("Banana");
    set.add("Apple");  // 不允许重复元素,此操作将不会添加新元素
    System.out.println(set.size());  // 输出 2
    

Map

  • 接口: java.util.Map

  • 特点:

    • 键值对存储: Map 用来存储键值对(key-value pair),每个键唯一地映射到一个值。
    • 键不能重复: Map 中的键是唯一的,但值可以重复。
    • 无索引访问: Map 不提供基于索引的访问,而是通过键来访问对应的值。
  • 常用实现类:

    • HashMap: 基于哈希表实现,键无序,常用于快速查找键值对。
    • LinkedHashMap: 保持键的插入顺序,基于哈希表实现。
    • TreeMap: 基于红黑树实现,键按自然顺序排序或使用指定的比较器排序。
  • 示例:

    Map<String, Integer> map = new HashMap<>();
    map.put("Apple", 10);
    map.put("Banana", 20);
    map.put("Apple", 15);  // "Apple" 键对应的值被更新为 15
    System.out.println(map.get("Apple"));  // 通过键访问值,输出 15
    

补充:

HashMap

HashMap 是基于哈希表(Hash Table)实现的。它的底层是一个数组,数组中的每个元素被称为桶(Bucket)。为了应对哈希冲突(即不同的键有相同的哈希值),每个桶实际上是一个链表或树。

(1).哈希表的工作原理

  • 哈希函数:当你将一个键值对存储在 HashMap 中时,首先会对键调用 hashCode() 方法。这会生成一个整数值,这个值通常是对象的内存地址的一个散列结果。

  • 数组索引:然后通过对 hashCode() 结果进行处理(通常是取模运算或与运算),将其转化为数组的索引位置,这个索引指示了键值对应该存储在数组的哪个位置(即桶中)。(n-1)&hash

  • 存储数据:在计算出数组索引后,HashMap 会将键值对存储在该位置的桶中。如果该位置为空,键值对就直接放入;如果已有数据,则通过链表或红黑树来处理冲突。

(2)处理哈希冲突

哈希冲突 是指多个键有相同的哈希值导致它们映射到同一个桶中的情况。HashMap 使用以下两种方式来处理冲突:

  • 链表法:如果冲突发生,HashMap 会将新元素作为链表节点追加到对应桶的链表中。多个具有相同哈希值的键值对将以链表的形式存储在同一个桶中。

  • 红黑树:从 Java 8 开始,当单个桶中的链表长度超过一定阈值==(默认为8)==时,HashMap 将链表转换为红黑树,以提高查找效率。

(3)查找过程

查找某个键对应的值时,HashMap 会根据键的 hashCode 计算出桶的位置,然后遍历桶中的链表或树以查找匹配的键。如果找到匹配的键,返回相应的值;如果没有找到,返回 null

(4)HashMap的扩容机制

HashMap 的扩容机制是为了在存储的键值对数量增加时,确保查找、插入和删除操作的性能不显著下降。扩容是通过增加底层数组的大小(即增加桶的数量)来减少哈希冲突,从而保持较好的操作效率。

HashMap 的底层数组有一个初始容量,当存储的元素数量超过一定比例(即负载因子,默认值为 0.75)时,HashMap 会进行扩容。扩容时,它会创建一个新的、更大的数组,然后将所有现有的键值对重新哈希并存储到新的数组中。这一过程被称为 rehashing

1. 初始容量与负载因子
  • 初始容量: HashMap 的初始容量是底层数组的大小,默认值为 16。容量是 2 的幂。
  • 负载因子: 负载因子是一个用于衡量 HashMap 何时需要扩容的指标。默认的负载因子是 0.75。这意味着当 HashMap 中存储的键值对数量达到当前容量的 75% 时,HashMap 会触发扩容操作。
2. 扩容的过程
  1. 新数组的创建: 当触发扩容时,HashMap 会创建一个容量为当前容量两倍的新数组(即 newCapacity = 2 * oldCapacity)。

  2. 重新计算哈希值: 在新数组中重新计算每个键的哈希值,并将键值对重新分配到新的桶中。

    • 在 Java 8 及以后,扩容时不会对所有键值对的哈希值重新计算,而是利用扩容前后的桶数量关系,通过简单的二进制操作来判断键值对是在原位置还是移动到新位置。
    • 假设原有数组的大小为 16 时,一个元素的位置是由哈希值的低 4 位决定的。现在数组大小变为 32 后,哈希值的第 5 位就会参与计算新位置。
    • 如果哈希值的第 5 位是 0,那么元素会保留在原来的桶位置上。如果第 5 位是 1,那么元素会被重新分配到新数组中的新位置(通常是原位置加上新数组的大小的一半)。
  3. 迁移键值对: HashMap 会将原数组中的所有键值对按照重新计算的哈希值进行迁移,放入新数组中。

(5) 性能影响

  • 扩容成本: 扩容是一项耗时操作,因为它涉及到创建新数组并重新分配所有现有的键值对。为了减小扩容带来的性能影响,HashMap 在设计时选择了较为保守的负载因子(默认是 0.75),以平衡空间利用率和性能。

  • 容量的二次幂: 为了优化哈希值的计算和取模操作,HashMap 总是将容量设置为 2 的幂。这样可以使用位运算来代替取模运算,从而提高效率。

    为什么HashMap的长度是2的幂次方:

    HashMap 的容量总是选择为 2 的幂(如 16、32、64 等),而不是任意数字。这种设计有几个关键原因,主要是为了优化哈希值的计算和提高性能。

    1. 哈希值的计算:
      HashMap 中,每个键值对需要存放在底层数组中的某个位置(称为桶),这个位置是通过键的哈希值决定的。通常,哈希值会很大,无法直接作为数组的索引,所以需要通过取模运算(hash % capacity)将哈希值映射到数组的索引范围内。

    2. 为什么选择 2 的幂:
      如果容量(capacity)是 2 的幂(比如 16, 32, 64),取模运算可以简化为位运算。这是因为在二进制表示中,2 的幂总是形如 1000...0,即只有一位为 1,其他位为 0。比如,16 是 2^4,它的二进制形式是 10000

      • 当容量为 2 的幂时,hash % capacity 可以通过 hash & (capacity - 1) 来实现,这里的 & 是按位与操作。
      • 例如,如果容量为 16(2^4),hash & (16 - 1) 相当于 hash & 15,这个操作仅保留哈希值的低 4 位(因为 15 的二进制是 1111),有效地将哈希值映射到数组索引 0 到 15 之间。
    3. 性能优化:
      取模运算通常比按位与操作要慢,尤其是在大型系统中,频繁的取模运算可能会影响性能。通过选择 2 的幂作为容量,HashMap== 可以用更快的位运算来代替取模运算,从而提高哈希表的性能。==

(6)总结

  • HashMap 的核心是一个数组,数组中的每个元素是一个桶。
  • 通过键的 hashCode 计算出数组索引,将键值对存储到对应的桶中。
  • 当出现哈希冲突时,HashMap 使用链表或红黑树来存储多个键值对。
  • 扩容操作通过重新哈希来重新分布键值对,以保持效率。

ConcurrentHashMap

并发环境推荐使用ConcurrentHashMap

  • ConcurrentHashMap 允许多个线程并发地读取和写入,而不会发生数据竞争(Race Condition)。它通过使用分段锁(Segmented Locking)或自 JDK 8 以来的 CAS(Compare-And-Swap)机制来实现线程安全。

JDK1.8取消了Segment分段锁,采用CAS(Compare-And-Swap)和synchronized来保证并发安全, synchronized只锁定当前链表或红黑树的首节点,这样只要hash不冲突,就不会产生并发。

总结:

  • List: 有序、允许重复、基于索引访问。适合存储需要频繁访问和操作的有序列表数据。
  • Set: 无序、不允许重复、基于哈希或树结构实现。适合存储不重复的元素集合,特别是需要快速查找的场景。
  • Map: 键值对存储、键唯一、通过键访问。适合存储和查找基于键的值,特别是需要根据键快速查找值的场景。

补充:CAS

CAS(Compare-And-Swap,比较并交换)是一种用于实现多线程并发控制的原子操作。它能够帮助避免传统锁机制带来的性能开销,是实现无锁算法的基础。CAS 操作通常在底层硬件支持下直接实现,在 Java 中通过 sun.misc.Unsafe 类提供。

CAS 的基本原理

CAS 操作需要三个操作数:

  1. 内存位置(V):要操作的变量的内存地址。
  2. 预期值(E):该变量的期望值,表示希望变量当前的值是什么。
  3. 新值(N):希望将该变量更新为的新值。

CAS 操作的工作流程如下:

  • 比较:检查内存位置 V 的当前值是否与预期值 E 相等。
    • 如果相等,则将内存位置 V 的值更新为新值 N
    • 如果不相等,则说明该内存位置 V 的值在操作过程中被其他线程修改过,CAS 操作不执行更新,而是返回当前值,通常表示更新失败。

这种机制可以确保在多线程环境下,只有一个线程能够成功更新变量值,而其他线程需要重新尝试。

CAS 的优点

  1. 无锁并发:CAS 操作不需要锁,可以避免由于锁竞争导致的线程上下文切换和性能瓶颈。
  2. 高性能:由于没有锁的开销,CAS 能在高并发情况下提供更好的性能表现。
  3. 原子性:CAS 操作是一个原子操作,能够确保操作的完整性和一致性。

CAS 的缺点

  1. ABA 问题

    • 在 CAS 操作中,可能出现“ABA”问题,即一个值从 A 变成 B,然后又变回 A,导致 CAS 操作无法察觉这个变化。
    • 为了解决 ABA 问题,可以引入版本号或使用 AtomicStampedReference 这样的带有标记的原子引用。
  2. 自旋等待

    • 如果一个线程多次尝试 CAS 操作失败(因为其他线程在不断修改目标变量),可能会导致自旋等待(spin-wait),消耗 CPU 资源。
  3. 只能操作单个变量

    • CAS 操作只能针对单个内存位置进行,无法直接对多个变量进行原子操作。因此,在需要多个变量同时修改的场景下,仍然需要使用锁机制或更复杂的无锁算法。

CAS 在 Java 中的应用

在 Java 中,CAS 主要应用于 java.util.concurrent 包下的原子类(如 AtomicIntegerAtomicLong 等)和无锁数据结构(如 ConcurrentHashMapConcurrentLinkedQueue 等)中。

示例:使用 AtomicInteger 进行 CAS 操作
import java.util.concurrent.atomic.AtomicInteger;

public class CASExample {
    public static void main(String[] args) {
        AtomicInteger atomicInteger = new AtomicInteger(0);

        // 假设有多个线程在同时执行以下操作
        int expectedValue = atomicInteger.get(); // 期望值
        int newValue = expectedValue + 1; // 新值

        // CAS 操作:尝试将值从 expectedValue 更新为 newValue
        boolean success = atomicInteger.compareAndSet(expectedValue, newValue);

        if (success) {
            System.out.println("CAS 操作成功,值已更新为: " + atomicInteger.get());
        } else {
            System.out.println("CAS 操作失败,当前值为: " + atomicInteger.get());
        }
    }
}

在这个示例中,compareAndSet() 方法就是一个典型的 CAS 操作。如果 atomicInteger 的当前值与 expectedValue 相等,则更新为 newValue,否则操作失败。

CAS 的硬件支持

CAS 操作通常由 CPU 提供硬件级支持。许多现代处理器都提供了原子性的 CAS 指令,例如在 x86 架构中有 CMPXCHG 指令。这些硬件指令是 CAS 操作高效实现的基础。

总的来说,CAS 是并发编程中一种重要的机制,能够在无锁的情况下实现线程安全的数据操作,并且在高并发场景下性能表现良好。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值