Java集合

Java集合框架主要分为了两个体系,分别对应了两个顶层接口:

  1. Collection:存放单个数据,主要有List、Set、Queue等分支
  2. Map:存放键值对数据,主要有hashMap,treeMap、ConcurrentHashMap等分支

Collection

List

List的特点是元素有序可重复

  1. ArrayList:基于动态数组实现,随机存取,查找速度快O(1),但是在中间增删元素较慢;线程不安全。
  2. LinkedList:基于双向链表实现,增删速度快,但是查找较慢,由于是实现了Deque接口,所以可以当作栈和队列来使用。
  3. Vector:线程安全数组,但是性能较差,现在已经弃用。
  4. CopyOnWriteArrayList:写时复制数组,写操作时会复制一份原来的数组,写操作完成后,会将原来的指针指向新数组。完全无锁,性能高。

Set

Set的特点是元素无序且唯一

  1. HashSet:基于HashMap实现,元素无序且唯一。存入元素实际上是把元素作为hashMap的key存入,存入时,先通过hashCode()定位桶,然后再通过equals()判断元素是否相同。查找、删除的时间复杂度都为O(1)。
  2. LinkedHashSet:内部维护了一个双向链表,元素唯一并且按照插入的顺序进行排序。
  3. TreeSet:基于红黑树实现,可以从小到大或者按照自定义进行排序。查找、删除、插入的时间复杂度均为Ologn。不允许空值。

Map

  1. HashMap:基于哈希表实现,元素无序,允许一个键为null,允许多个值为null。
  2. LinkedHashMap:基于哈希表和双向链表实现,元素按照插入时的顺序进行排序。
  3. TreeMap:基于红黑树实现,元素按照键值进行排序,键不允许为null。
  4. HashTable:类似hashMap,但是线程安全,性能较低,不建议使用。
  5. ConcurrentHashMap:线程安全并且性能高效版的HashMap。

ArrayList详解

ArrayList实现原理

ArrayList的底层实现原理就是四个字:动态数组

在ArrayList内部,维护了两个成员变量:存储数据的数组Object[] elementData和当前数组内部的元素个数int size

为了节约内存,用了懒加载机制:在内存中维护了一个空数组DEFAULTCAPACITY_EMPTY_ELEMENTDATA,这是所有初始化后并且为空的ArrayList共享的空数组,防止过多的空ArrayList占用太多空间。

每当我们List<Integer> list = new ArrayList<>()时,就会将他内部的elementData 指向DEFAULTCAPACITY_EMPTY_ELEMENTDATA

java 复制代码
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};

public ArrayList() {  
    this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;  
}

在插入元素时,我们会将当前数组大小和插入元素的个数相加,这也就是当前数组的最小所需容量,叫做minCapacity;这里我们使用add方法,每次添加的元素个数为1。

现在我们开始在ArrayList中添加元素,首先他会去计算所需最小容量:如果是第一次添加元素------即内部的数组还在指向共享空数组,那么就会将最小需求容量直接设置为10。如果不是,就返回当前元素个数和插入元素个数的和。

java 复制代码
private static final int DEFAULT_CAPACITY = 10;

private static int calculateCapacity(Object[] elementData, int minCapacity) {  
    if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {  
        return Math.max(DEFAULT_CAPACITY, minCapacity);  
    }  
    return minCapacity;  
}

紧接着拿到最小扩容大小后,我们会审核当前扩容是否合法,如果容量不足------即所需最小容量大于当前数组大小,就会执行扩容grow方法。

java 复制代码
private void ensureExplicitCapacity(int minCapacity) {  
    //这是数组扩容的版本号,每次扩容就加一
    modCount++;  
  
    // overflow-conscious code  
    if (minCapacity - elementData.length > 0)  
        grow(minCapacity);  
}

如果是空扩容,就将数组的大小设置为10;如果不为空,就将数组的大小通过位运算符右移一位,也就是扩容为1.5倍。

ArrayList和LinkedList

ArrayList的实现是基于动态数组,随机存取使得它每次查找元素的时间复杂度为O1;插入元素和删除元素时,如果元素是头尾节点,那么时间复杂度为O1,但是插入或者删除位置在数组中间时,为了保证元素在内存上的连续性,每次都需要移动其他元素,就使得他的时间复杂度为On。

LinkedList的实现是基于双向链表,每次插入或者删除元素只需要修改指针即可,时间复杂度为O1;但是每次查找元素时都需要遍历整个链表,所以时间复杂度为On。

ArrayList适合查多删少的场景,而LinkedList适合删多查少的场景。

有哪些线程安全的List

  1. CopyOnWriteArrayList
  2. Vector

HashMap详解

HashMap底层实现原理

HashMap是一个HashMap底层是基于动态数组、红黑树和链表实现的。

HashMap的主体是一个Node数组,而Node数组中的每一个节点我们称其为哈希桶,同一个桶中的Node元素哈希值相同。

每当我们存入元素时,会先将其封装成Node,一个Node中包含四个元素:

  • 哈希值:元素的哈希值
  • key:就是存入元素的key值
  • value:就是存入元素的value值
  • next:这是指向同一个桶中的下一个Node中的指针

HashMap的put过程

当我们使用put来添加元素时,会有以下的过程:

二次计算Hash

我们会通过HashMap的哈希算法进行二次取哈希,即取第一次哈希值的右移16位并进行异或运算,得到二次哈希的值。

java 复制代码
hash = (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);

定位桶位置

计算出的二次哈希值,为了将其映射到数组的0~n-1的位置上,我们对二次哈希值进行与运算,这样就定位到具体的哈希桶的位置了。

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

检查桶并处理哈希冲突

定位到哈希桶的位置之后,我们先判断这个桶是否为空。如果为空,那么就不需要处理哈希冲突,直接在这个桶的位置上创建一个Node节点并将节点信息塞入即可。

如果哈希桶不为空,也就是发生了哈希冲突,我们就需要进行处理:在hash相同的情况下,桶中所有Node的key值进行equals()判断。如果有key值相同,那么就认为是更新这个Node的信息,覆盖即可;如果与所有的key值不同,那么就认为是添加新的Node,我们找到最后一个Node,并将它的next指针挂在新Node上。

从整体上看,hashMap的主体还是一个数组,但是每个数组节点又是一个链表。

HashMap扩容

当拥有元素的桶达到一定数量后,hashMap就会进行扩容;我们设置了一个负载因子load factor默认值为0.75,当非空桶达到初始容量 * load factor时,就会触发扩容。hashMap默认的初始容量是16。

每次扩容都会将容量扩大为原来的两倍,每个桶对应的索引位置也需要重新进行计算,计算公式为:

java 复制代码
newIndex = hash & (2 * n - 1)
         = (hash & (n - 1)) |(hash & n)

在HashMap中的桶的位置,本质上使用二进制进行表示的:如果桶的位置是1,那二进制就是0000;如果位置是16,那二进制就是1111。

现在我们对HashMap扩容到32,依照二进制进判断:如果桶的位置在前16位,那么它的二进制就一定是0XXXX,如果是后16位,那么就一定是1XXXX。

运算式中的hash & n就是在计算新的二进制最高位是1还是0,我们拿到这个新的二进制数之后,再与就索引进行或运算:0开头,桶的位置保持不变;1开头,二进制数值增加16,实现了桶位置的迁移。

链表转红黑树

在理想情况下,hashMap中的桶分布十分均匀:即每个桶中的节点数都差不多,我们查找每个节点都多时间复杂度是O1。

但是当发生了很严重的hash冲突时,所有节点都挤在同一个桶中,这是查找一个元素就会退化到查找链表,时间复杂度为On。

当某个链表的长度大于8时,hashMap就会尝试扩容,增加桶的数量;如果桶数量已经大于64了,那么就会将链表转换成红黑树。

JDK7对HashMap对改变

在JDK7之后,引入了红黑树;链表从头插法改成了尾插法。

为什么HashMap线程不安全

HashMap在实现时,没有处理任何有关线程安全的问题,有可能导致以下问题:

  • 脏读(Dirty Read):当线程 A 正在更新某个桶位的 Value,且由于 HashMap 的写操作不是原子的(涉及计算、定位、覆盖),线程 B 此时读取,可能读到修改了一半的中间值,或者由于 可见性(Visibility)问题,根本读不到最新的值。

  • 丢失更新(Lost Update):如果线程 A 和线程 B 同时向同一个空的桶位(Bucket)插入不同的 Key,它们可能同时判定该位置为 null。结果 A 插入后,B 的插入操作会覆盖 A 的 Node,导致 A 的数据无声无息地消失了。

  • 指针成环(Infinite Loop):在 Java 7 的"头插法"中,线程 A 准备搬运链表,刚记录下 next 指针就被挂起;线程 B 完成了整个扩容,将链表顺序倒转了。当 A 恢复时,它会按照过时的信息继续搬运,最终导致 A.next = BB.next = A。当你后续调用 get 访问这个桶时,程序会陷入 CPU 100% 的死循环。

  • 数据丢失:在 Java 8 中,虽然尾插法解决了成环,但多线程同时触发 resize 时,多个新数组会被创建。最终只有一个新数组会被赋值给 table,其他线程辛苦搬运的数据会随着旧数组一起被丢弃。

ConcurrentHashMap

ConcurrentHashMap是高效的、线程安全的HashMap实现。

在JDK7之前和JDK7之后,ConcurrentHashMap有两种实现方式。

JDK7之前的实现方式

JDK7 的核心思想是:既然锁住整个表太慢,那我就把表拆成多个小表,每个小表允许一个线程进行写操作。

在Concurrent内部维护了一个segment数组,每个segment本质上是一个小的HashMap,它通过继承ReentrantLock获得了锁,当有一个线程想要在segment[i]上写操作时,它会先拿到segment[i]的锁,然后才能进行写操作,其他的线程只能等待锁释放。

java 复制代码
class ConcurrentHashMap<K,V> {
    final Segment<K,V>[] segments;

    static final class Segment<K,V> extends ReentrantLock {
        // 继承锁,控制该段的并发访问
        HashEntry<K,V>[] table; // 该段内部的hash桶
        // 其他segment相关字段和方法...
    }
}

但是Segment的数量,也就是线程并发数也在一开始就确定好了,如果一个Segment中的桶数量过多,还是会争抢一个锁。

JDK7之后的实现方式

Java 8 彻底抛弃了 Segment,改成了和 HashMap 一样的 数组 + 链表 + 红黑树 结构,但加锁方式进化到了极致。

当线程尝试插入数据时,首先探测目标桶位是否为空,若为空则直接利用 CAS(无锁算法) 这一硬件级原子指令尝试挂载新节点。避免了传统的锁开销,使得在无哈希冲突时的并发效率达到极致。

而一旦探测到桶内已有数据(发生哈希冲突),程序会立即升级防御,使用 synchronized 关键字锁住该桶的头节点。而其他桶不会受到影响。可以说,有多少桶,最多就能有多少线程并发执行。

多线程协同

在JDK8之后,ConcurrentHashMap的多线程协同也是它实现高性能的重点。

当ConcurrentHashMap需要扩容时,会动态地给桶打上迁移标记。某个线程执行 put 操作发现桶位已被标记为正在迁移的 ForwardingNode 时,它不会原地阻塞等待,而是立即调用 helpTransfer 方法加入扩容大军,根据全局指针领走一段尚未迁移的桶位进行并发搬运,这种"众人拾柴"的设计极大地缩短了扩容造成的停顿时间。

在整个协同搬迁过程中,数据的可见性与安全性得到了严密保护,每一个被迁移完的旧桶都会放置一个指向新数组的"转发告示"。这意味着即便扩容尚未完全结束,读取操作(get)在遇到已搬迁的桶位时,依然能顺着 ForwardingNode 的引导去新数组中精准获取数据,实现了扩容期间读写操作的高度并行与零阻塞,完美解决了传统 Map 扩容时的性能瓶颈。

相关推荐
多加点辣也没关系8 分钟前
数据结构与算法|第二十三章:高级数据结构
数据结构·算法
庞轩px1 小时前
第七篇:Spring扩展点——如何优雅地介入Bean的创建流程
java·后端·spring·bean·aware·扩展点
代钦塔拉1 小时前
Qt4 vs Qt5 带参数信号槽的连接方式详解
开发语言·数据库·qt
tongluowan0072 小时前
一个请求在Spring MVC 中是怎么流转的
java·spring·mvc
hoiii1872 小时前
孤立森林 (Isolation Forest) 快速异常检测系统
算法
夜郎king3 小时前
Spring AI 对接大模型开发易错点总结与实战解决办法
java·人工智能·spring
InfinteJustice3 小时前
踩坑分享C 语言文件操作全攻略:从基础读写到随机访问与缓冲区原理
c语言·开发语言·microsoft
码云数智-大飞3 小时前
滥用Lombok的@EqualsAndHashCode导致线上事故复盘
开发语言
yong99903 小时前
C# 实时查看硬件使用率(CPU 内存 硬盘 网络)
开发语言·网络·c#
oradh3 小时前
Oracle数据库中的Java概述
java·数据库·oracle·sql基础·oracle数据库java概述