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 扩容时的性能瓶颈。

相关推荐
啥都想学点2 小时前
第18天:Springboot 项目搭建
java·spring boot·后端
AI成长日志2 小时前
【笔面试算法学习专栏】链表操作专题:反转、环形检测与合并
学习·算法·面试
liulilittle2 小时前
TC Hairpin NAT 驱动使用手册(个人版)
服务器·开发语言·网络·c++·网络协议·tcp/ip·tc
福运常在2 小时前
股票数据API(21)如何获取股票指数最新分时交易数据
java·python·maven
njidf2 小时前
C++与量子计算模拟
开发语言·c++·算法
老鼠只爱大米2 小时前
LeetCode经典算法面试题 #70:爬楼梯(朴素递归、记忆化递归、动态规划等六种实现方案详解)
算法·leetcode·动态规划·递归·斐波那契·矩阵快速幂·爬楼梯
计算机徐师兄2 小时前
Java基于微信小程序的青少年科普教学系统【附源码、文档说明】
java·微信小程序·青少年科普教学系统小程序·java青少年科普教学小程序·青少年科普教学微信小程序·青少年科普教学小程序·科普教学微信小程序
爱学习的程序媛2 小时前
【Web前端】深入解析JavaScript异步编程
开发语言·前端·javascript·ecmascript·web
IAUTOMOBILE2 小时前
两大王者-Laravel vs ThinkPHP:PHP 框架终极对决,谁更适合团队或者个人!
开发语言·php·laravel