Java集合
1 集合
1.1 Collection
1.1.1 ArrayList 数据结构
- 数组是一种用连续的内存空间存储相同数据类型数据的线性数据结构。
- 寻址公式:
baseAddress + i * dataTypeSize,计算下标的内存地址效率较高。 - 插入和删除的时候,为了保证数组的内存连续性,需要挪动数组元素,平均时间复杂度为 O(n)
1.1.2 ArrayList 源码分析
源码基于 jdk 1.8。
- JDK1.7:像饿汉式,直接创建一个 初始容量为 10 的数组
- JDK1.8:像懒汉式,一开始创建一个 长度为 0 的数组,当添加 add 第一个元素时再创建一个初始容量为 10 的数组
底层数据结构如下:
//集合默认容量10;
private static final int DEFAULT_CAPACITY = 10;
//空数组
private static final Object[] EMPTY_ELEMENTDATA = {};
//默认容量的空的数组
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};
// 集合中真实存储数据的数组
transient Object[] elementData; // non-private to simplify nested class access
//集合中元素的个数,注意,这里不是数组的长度
private int size;
-
构造函数
public ArrayList() { //将属性中默认的空的数组赋值给了 存储数据的变量 this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA; //等价于this.elementData = {} } //有参构造 public ArrayList(int initialCapacity) { //给定初始容量,就创建一个这个容量大小的数组 if (initialCapacity > 0) { this.elementData = new Object[initialCapacity]; } else if (initialCapacity == 0) { //如果传递的是0 就将{}赋值给elementData this.elementData = EMPTY_ELEMENTDATA; //等价于this.elementData = {} } else { //如果传递的是负数,就会抛异常 //java.lang.IllegalArgumentException: Illegal Capacity: -20 throw new IllegalArgumentException("Illegal Capacity: "+ initialCapacity); } } -
自动扩容
每当向数组中添加元素时,都要去检查添加后元素的个数是否会超出当前数组的长度,如果超出,数组将会进行扩容,以满足添加数据的需求。
private void grow(int minCapacity) { // overflow-conscious code int oldCapacity = elementData.length; // 动态扩容,扩容为原来的1.5倍,右移一位即原来的 1/2 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);// 老的数组拷贝到新数组 }扩容流程:
- 首先获取数组长度
- 将数组新容量扩容为原数组容量的 1.5 倍取整
- 将新容量和当前所需最小容量做对比,(最小容量是在 add 方法中得到的,minCapacity = size+1,即原数组中元素数量加 1),而 newCapacity = elementData.length*1.5,一般来说肯定是 1.5 倍比+1 的大。
1.1.3 示例问题
-
ArrayList 底层的实现原理是什么?
- ArrayList 底层是用动态的数组实现的
- ArrayList 初始容量为 0,当第一次添加数据的时候才会初始化容量为 10
- ArrayList 在进行扩容的时候是原来容量的 1.5 倍,每次扩容都需要拷贝数组
- ArrayList 在添加数据的时候
- 确保数组已使用长度(size)加 1 之后足够存下下一个数据
- 计算数组的容量,如果当前数组已使用长度 +1 后的大于当前的数组长度,则调用 grow 方法扩容(原来的 1.5 倍)
- 确保新增的数据有地方存储之后,则将新元素添加到位于 size 的位置上。
- 返回添加成功布尔值。
-
ArayyList list = new ArrayList(10)中的 list 扩容几次?
该语句只是声明和实例了一个 ArrayList,指定了容量为 10,未扩容。
-
如何实现数组和 List 之间的转换?
-
数组转 List,使用 java.util.Arrays 工具类的 asList()方法
-
List 转数组,使用 List 的 toArray()方法。无参则返回 Object 数组,传入初始化长度的数组对象,返回该对象数组。
但是要注意,数组转 List,如果修改了数组内容,List也会随之修改。List 转数组,如果修改了 List 内容,数组不会跟着修改。理由如下:
-
Arrays.asList()转换 list 之后,因为它的底层使用的 Arrays 类中的一个内部类 ArrayList 来构造的集合,在这个集合的构造器中,把我们传入的这个集合进行了包装而已,最终指向的都是同一个内存地址。
-
List 用了 toArray() 转数组后,如果修改了 List 内容,数组不会影响,当调用了 toArray() 以后,在底层是它是进行了数组的拷贝,跟原来的元素就没关系了,所以即使 List 修改了以后,数组也不受影响。
-
1.1.4 ArrayList 和 LinkedList 的区别
-
底层数据结构
ArrayList 底层是数组,LinkedList 底层基于双向链表。
-
操作数据效率
ArrayList 按照下标查询的时间复杂度 O(1)【内存是连续的,根据寻址公式】,LinkedList 不支持下标查询查找;ArrayList 需要遍历,链表也需要链表,时间复杂度都是 O(n);
新增和删除:
ArrayList 尾部插入和删除,时间复杂度是 O(1);其他部分增删需要挪动数组,时间复杂度是 O(n);
LinkedList 头尾节点增删时间复杂度是 O(1),其他都需要遍历链表,时间复杂度是 O(n)。 -
内存空间占用
ArrayList 底层是数组,内存连续,节省内存;
LinkedList 是双向链表需要存储数据,和两个指针,更占用内存。 -
两者都不是线程安全的。
1.2 Map
1.2.1 HashMap 数据结构
红黑树(Red Black Tree):一种自平衡的二叉搜索树(BST),之前叫做平衡二叉 B 树(Symmetric Binary B-Tree)。
- 性质 1:节点要么是红色,要么是黑色
- 性质 2:根节点是黑色
- 性质 3:叶子节点都是黑色的空节点
- 性质 4:红黑树中红色节点的子节点都是黑色
- 性质 5:从任一节点到叶子节点的所有路径都包含相同数目的黑色节点
查找:
红黑树也是一棵 BST(二叉搜索树)树,查找操作的时间复杂度为:O(logn)
添加:
添加先要从根节点开始找到元素添加的位置,时间复杂度 O(logn),添加完成后涉及到复杂度为 O(1)的旋转调整操作,故整体复杂度为:O(logn)
删除:
首先从根节点开始找到被删除元素的位置,时间复杂度 O(logn),删除完成后涉及到复杂度为 O(1)的旋转调整操作,故整体复杂度为:O(logn)
散列表(Hash Table):又名哈希表,是根据键(Key)直接访问在内存存储位置值(Value)的数据结构,它是由数组演化而来的,利用了数组支持按照下标进行随机访问数据的特性。
散列表可能退化为链表,查询的时间复杂度就从 O(1)退化为 O(n);将链表法中的链表改造为其他高效的动态数据结构,比如红黑树,查询的时间复杂度是 O(logn)。将链表改为红黑树还有一个重要的原因就是可以防止 DDos 攻击。因为如果攻击者能构造大量 哈希值相同或冲突频繁的键,会导致哈希桶退化成一个长链表。
1.2.2 HashMap 的实现原理
在 1.8 之前,HashMap 的底层是数组加链表,在 1.8 之后是数组+链表+红黑树; 它的 put 流程是:基于哈希算法来确定元素位置,当我们向集合存入数据时,他会计算传入的 key 的哈希值,并利用哈希值来确定元素的位置,如果这个位置已经存在其他元素了,就会发生哈希碰撞,则 hashmap 就会通过链表将这些元素组织起来,如果 链表的长度达到 8 并且数组长度大于 64 时,就会转化为红黑树,从而提高查询速度。
1.2.3 HashMap 的 put 操作
添加数据流程图
源代码:
//1. 调用 putVal 方法并计算哈希值
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
/*该方法通过对 key 的哈希码进行位运算(右移16位和异或),优化了哈希值的分布,
减少了哈希冲突的概率,为HashMap 中键值对的高效存储和查找奠定了基础。
对 null 键的特殊处理(返回 0)也保证了 HashMap 对null 键的兼容性。*/
static final int hash(Object key) {
int h;
// 使用了右移16位和异或
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// 2.在 putVal 中,首先检查 table 是否为空或未初始化。
// 若为真,调用 resize() 初始化数组(默认容量 16,负载因子 0.75):(首次put)
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 3.确定键值对存储位置
//通过 (n - 1) & hash 计算键值对在数组中的索引 i。若该位置为空,直接创建新节点存入:
if ((p = tab[i = (n - 1) & hash]) == null)
// (n - 1) & hash 和 hash % n 的结果是一样的,但是效率更高( % 使用了除法)
tab[i] = newNode(hash, key, value, null);
// 4.处理hash冲突
else {
Node<K,V> e; K k;
// 4.1覆盖值
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 4.2 若节点是 TreeNode(红黑树),调用 putTreeVal 方法在树中插入或更新键值对
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
// 4.3 遍历链表,若找到相同 key 则跳出循环(后续覆盖值);若到链表末尾无相同 key,则
// 通过尾插法插入新节点。插入后检查链表长度,若 ≥ 8 且数组容量 ≥ 64,调用
// treeifyBin 将链表转为红黑树(若容量不足 64,优先扩容):
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
// 5.若遍历过程中找到匹配 key 的节点(e != null),则覆盖其 value(根据
// onlyIfAbsent标记判断是否覆盖):
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
}
总结流程
- 计算哈希:对 key 计算优化后的哈希值。
- 初始化数组:首次 put 时初始化 table。
- 定位存储位置:通过哈希值确定数组索引,无冲突则直接插入。
- 处理冲突:
- 若 key 已存在,覆盖 value。
- 若为红黑树,调用树插入逻辑。
- 若为链表,尾插新节点,检查链表长度满足条件时转红黑树。
- 检查扩容:元素数量超过阈值时,对数组扩容。
1.2.4 HashMap 的扩容机制
HashMap 的扩容是由其负载因子(Load Factor)控制的。负载因子是一个表示 HashMap 允许装满的程度的系数,默认值为 0.75。这意味着当 HashMap 中填满了 75% 的桶时,就会触发扩容操作。

final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
// 计算新容量和新阈值
if (oldCap > 0) {
newCap = oldCap << 1; // 容量翻倍
newThr = oldCap * loadFactor;
} else {
// 数组未初始化的情况,将阈值和扩容因子都设置为默认值
newCap = DEFAULT_INITIAL_CAPACITY; // 默认16
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 创建新数组
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
// 迁移元素,非空数组
if (oldTab != null) {
// 遍历数组
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
if (e.next == null) // 单个元素
// 计算在新数组中的下标并放进去
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode) // 红黑树的逻辑
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // 链表逻辑
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
if ((e.hash & oldCap) == 0) {
// 低位尾部为null,说明当前数组位置为空,没有数据
if (loTail == null)
// 将e值放入低位头
loHead = e;
else
// 数据放入next节点
loTail.next = e;
// 记录低位尾部数据
loTail = e;
} else {
if (hiTail == null) hiHead = e;
else hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
// 高位尾如果记录的有数据,说明是链表
if (hiTail != null) {
// 将下一个元素置空
hiTail.next = null;
// 将高位头放入新数组(原下标+原数组容量)位置
newTab[j + oldCap] = hiHead;
}
}
}
}
}
threshold = newThr;
return newTab;
}
- 在添加元素或初始化的时候需要调用 resize 方法进行扩容,第一次添加数据初始化数组长度为 16,以后每次每次扩容都是达到了扩容阈值(数组长度 * 0.75)
- 每次扩容的时候,都是扩容之前容量的 2 倍;
- 扩容之后,会新创建一个数组,需要把老数组中的数据挪动到新的数组中
- 没有 hash 冲突的节点,则直接使用 e.hash &(newCap-1)计算新数组的索引位置
- 如果是红黑树,走红黑树的添加
- 如果是链表,则需要遍历链表,可能需要拆分链表,判断 (e.hash& oldCap)是否为 0,该元素的位置要么停留在原始位置,要么移动到原始位置 + 增加的数组大小这个位置上
1.2.5 HashMap 相关面试题
-
HashMap 的寻址算法?
- 计算对象的 hashCode()
- 再进行调用 hash() 方法进行二次哈希,hashcode 值右移 16 位再异或运算,让哈希分布更均匀
- 最后(capcity-1)&hash 得到索引
以上可以在 HashMap 的实现原理小节看到
-
为什么 HashMap 的数组长度一定是 2 的 n 次幂?
- 计算索引时效率更高:如果是 2 的 n 次幂可以使用位与运算代替取模
- 扩容时重新计算索引(链表中)效率更高:hash&oldCap == 0 的元素留在原来的位置,否则新位置 = 旧位置 + oldCap
1.2.6 多线程死循环问题
jdk7 的的数据结构是:数组+链表
在数组进行扩容的时候,因为链表是头插法,在进行数据迁移的过程中,有可能导致死循环。

假设现在有两个线程
线程一:读取到当前的 hashmap 数据,数据中一个链表,在准备扩容时,线程二介入
线程二:也读取 hashmap,直接进行扩容。因为是头插法,链表的顺序会进行颠倒过来。比如原来的顺序是 AB,扩容后的顺序是 BA,线程二执行结束。此时线程 1 的临时变量 e 和 next 的指向还是顺序颠倒之前。

线程一:继续执行的时候就会出现死循环的问题。线程一先将 A 移入新的链表,再将 B 插入到链头,得到线程一自己的 B-> A。由于另外一个线程的原因,B 的 next 指向了 A,所以 A-> B-> A-> B, 形成循环。如下图所示,还会再将 A 头插到链表中

当然,JDK8 将扩容算法做了调整,不再将元素加入链表头(而是保持与扩容前一样的顺序),尾插法,就避免了 jdk7 中死循环的问题

浙公网安备 33010602011771号