Java集合框架主要分为了两个体系,分别对应了两个顶层接口:
- Collection:存放单个数据,主要有List、Set、Queue等分支
- Map:存放键值对数据,主要有hashMap,treeMap、ConcurrentHashMap等分支
Collection
List
List的特点是元素有序可重复
- ArrayList:基于动态数组实现,随机存取,查找速度快O(1),但是在中间增删元素较慢;线程不安全。
- LinkedList:基于双向链表实现,增删速度快,但是查找较慢,由于是实现了
Deque接口,所以可以当作栈和队列来使用。 - Vector:线程安全数组,但是性能较差,现在已经弃用。
- CopyOnWriteArrayList:写时复制数组,写操作时会复制一份原来的数组,写操作完成后,会将原来的指针指向新数组。完全无锁,性能高。
Set
Set的特点是元素无序且唯一
- HashSet:基于
HashMap实现,元素无序且唯一。存入元素实际上是把元素作为hashMap的key存入,存入时,先通过hashCode()定位桶,然后再通过equals()判断元素是否相同。查找、删除的时间复杂度都为O(1)。 - LinkedHashSet:内部维护了一个双向链表,元素唯一并且按照插入的顺序进行排序。
- TreeSet:基于红黑树实现,可以从小到大或者按照自定义进行排序。查找、删除、插入的时间复杂度均为Ologn。不允许空值。
Map
- HashMap:基于哈希表实现,元素无序,允许一个键为null,允许多个值为null。
- LinkedHashMap:基于哈希表和双向链表实现,元素按照插入时的顺序进行排序。
- TreeMap:基于红黑树实现,元素按照键值进行排序,键不允许为null。
- HashTable:类似hashMap,但是线程安全,性能较低,不建议使用。
- 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
- CopyOnWriteArrayList
- 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 = B且B.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 扩容时的性能瓶颈。