Java传家宝:微信公众号(Java传家宝)、Java传家宝-B站、Java传家宝-知乎、Java传家宝-CSND
单列集合
常用的集合分为实现了Collection顶层接口的单列集合,实现了Map顶层接口的双列集合。
先挨个解析一下常用的单列集合,即实现了Collection接口的集合类,包括三大类:Set、List、Queue,如图
![单列集合](https://img-blog.csdnimg.cn/img_convert/c645d2efd9326f5dcc9b1127d8c8b4ca.png)
List
一般是有序的(是指按插入顺序排序)、可重复的。常用的包括了动态数组ArrayList,链表LinkedList,向量集合Vector。Collection接口继承了Iterable接口,说明单列集合都可以进行迭代器迭代。
ArrayList
![ArrayList](https://img-blog.csdnimg.cn/img_convert/588e8fc1a4d9617f5ac95fff8762752b.png)
动态数组,拥有数组的功能,依次添加新的数据,保持添加的顺序,可通过索引取值,动态扩容。特点是查询快,增删慢。在看一下结构图:
![ArayList](https://img-blog.csdnimg.cn/img_convert/cfd1b633233e9cf21f298162fe3e8bb7.png)
通过结构图也能发现其他几个特点:
-
实现了Serializable接口, 可序列化 -
实现了Cloneable接口, 可复制 -
Collection接口继承了Iterable接口,说明单列集合都可以 进行迭代器迭代。
接下来研究一下JDK1.8的源码实现原理,先说结论:
-
空构造创建 默认为一个 空的Object[]数组,后面添加 第一个元素时,扩容默认长度为 10 -
扩容规则为如果 旧容量的1.5倍不足以满足 最小容量的需求,就扩容为最小需要的容量,反之,扩容为原来的1.5倍 -
最大容量为 Integer.MAX_VALUE - 8
旧容量:原有的容量
最小容量:将新元素添加后,最小需要的容量
然后我们跟进ArrayList空构造器的源码,以注释做解释,会省略大部分源码:
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};
// 可以看到 空构造默认为空的Object[]
public ArrayList() {
this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}
然后看一下add添加的源码,(来自多个类,我放在一起了):
// 记录当前数组容量大小
private int size;
// 实际存储数据的数组
transient Object[] elementData;
public boolean add(E e) {
// 1 确保容量足够
ensureCapacityInternal(size + 1); // Increments modCount!!
// 2 在当前索引后一位添加,所以他是有序的
elementData[size++] = e;
return true;
}
// 重要的是这段扩容代码
private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;
private void grow(int minCapacity) {
// 原容量,即数组长度
int oldCapacity = elementData.length;
// 新容量设置位原来的1.5倍
int newCapacity = oldCapacity + (oldCapacity >> 1);
// 新容量如果小于最小容量
if (newCapacity - minCapacity < 0)
// 新容量=最小容量
newCapacity = minCapacity;
// 新容量如果大于最大容量
if (newCapacity - MAX_ARRAY_SIZE > 0)
// 设置为最大容量
newCapacity = hugeCapacity(minCapacity);
// 扩容为新容量
elementData = Arrays.copyOf(elementData, newCapacity);
}
LinkedList
![LinkedList](https://img-blog.csdnimg.cn/img_convert/c8aac5a65cbc88dd144d73b9a669536f.png)
链表,每个节点保存了前后节点的地址,形成双向链表结构。特点是查询慢,增删快。在看一下结构图:
![LinkedList](https://img-blog.csdnimg.cn/img_convert/69efeb5d8b0208daa8d211c8138f9b89.png)
通过结构图也能发现其他几个特点:
-
实现了Serializable接口,可序列化
-
实现了Cloneable接口,可复制
-
实现了Queue接口,说明有LinkedList能够作为队列使用
接下来研究一下JDK1.8的源码实现原理,先说结论:
-
LinkedList保存了 首尾节点,所以在首尾做某些操作会很快 -
查询操作需要通过判断索引位置 从首节点或者尾节点遍历得到 -
添加操作,如果是 普通添加,只需在尾节点直接添加即可, 很快 -
删除操作,如果 指明了某个节点,那么只需要更改前后节点内部的连接即可, 很快
然后我们跟进LinkedLis的源码,看一下他的结构:
transient int size = 0;
// 记录了首为节点
transient Node<E> first;
transient Node<E> last;
// 节点静态内部类
private static class Node<E> {
// 元素
E item;
// 前后节点地址
Node<E> next;
Node<E> prev;
}
再看一下查询操作:
public E get(int index) {
checkElementIndex(index); // 检查索引是否合法
return node(index).item;
}
Node<E> node(int index) {
// 判断索引偏左还是偏右,偏左就按首节点遍历,偏右就按尾节点遍历
if (index < (size >> 1)) {
Node<E> x = first;
for (int i = 0; i < index; i++)
x = x.next;
return x;
} else {
Node<E> x = last;
for (int i = size - 1; i > index; i--)
x = x.prev;
return x;
}
}
在看一下普通添加操作:
public boolean add(E e) {
linkLast(e);
return true;
}
void linkLast(E e) {
// 拿到尾节点
final Node<E> l = last;
// 用要保存的数据创建一个新节点
final Node<E> newNode = new Node<>(l, e, null);
last = newNode;
// 添加
if (l == null)
first = newNode;
else
l.next = newNode;
}
在看一下普通删除操作:
public E remove(int index) {
checkElementIndex(index);
return unlink(node(index));
}
E unlink(Node<E> x) {
// 拿到所有属性
final E element = x.item;
final Node<E> next = x.next;
final Node<E> prev = x.prev;
// 更改连接,达到删除效果
if (prev == null) {
first = next;
} else {
prev.next = next;
x.prev = null;
}
if (next == null) {
last = prev;
} else {
next.prev = prev;
x.next = null;
}
x.item = null;
return element;
}
Vector
向量,线程安全,是遗留的类,一般不使用,所有方法通过Synchronized实现同步,达到线程安全的效果。与ArrayList的区别总结为:
-
空构造直接创建长度为 10的Object数组 -
扩容需要创建时指定扩容大小 capacityIncrement,如果 不指定就扩容为原来的 两倍 -
线程安全,ArrayList线程不安全
先看一下空构造方法:
public Vector() {
// 调用了另一个有参构造,设置默认长度为10
this(10);
}
public Vector(int initialCapacity) {
this(initialCapacity, 0);
}
在看一下扩容规则:
private void grow(int minCapacity) {
// 拿到当前数组容量
int oldCapacity = elementData.length;
// 是否定义了扩容长度capacityIncrement,没有就扩容为两倍
int newCapacity = oldCapacity + ((capacityIncrement > 0) ?
capacityIncrement : oldCapacity);
// 将数组数据拷贝到另一个新的数组中,返回新数组,完成扩容
elementData = Arrays.copyOf(elementData, newCapacity);
}
Set
一般是不可重复的。常用的包括有HashSet,TreeSet,LinkedHashSet等。
HashSet
![HashSet](https://img-blog.csdnimg.cn/img_convert/a6b3c6066f19ea13e01825d5ccfc2b4c.png)
无序,不可重复。可有一个null值,索引为0。底层就是通过HashMap实现的。先看一下结构图:
![HashSet](https://img-blog.csdnimg.cn/img_convert/32c380dc5bd1e5c4fc2cd5fba07b2b89.png)
通过结构图也能发现其他几个特点:
-
实现了Serializable接口, 可序列化 -
实现了Cloneable接口, 可复制
接下来研究一下JDK1.8的源码实现原理,先说结论:
-
创建一个HashSet其实是创建了一个加载因子为0.75的HashMap
-
添加数据时,只是在这个HashMap中添加键,值默认为一个Object对象PRESENT
首先看一下构造器:
public HashSet() {
// 创建HashMap
map = new HashMap<>();
}
// 加载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
在看一下添加的源码:
private static final Object PRESENT = new Object();
public boolean add(E e) {
// 网HashMap添加数据,键为要添加的值,值为一个默认的Object对象
return map.put(e, PRESENT)==null;
}
TreeSet
无序,即不是按照存储顺序排序。也可以说有序,默认通过从小到大的顺序排序,还可以通过传入比较器,自定义排序规则。底层就是HashSet。特点总结为:
-
自然排序:根据传入的对象实现的 Comparable接口规则排序,在Java中有部分类有默认的Comparable接口实现。
类 | 排序规则 |
---|---|
BigDecimal、BigInteger、Byte、 Double、Float、Integer、Long、Short | 数字按照从小到大的顺序 |
Character | 按字符的Unicode值的数字大小排序 |
String | 按字符串中字符的Unicode值的数字大小排序 |
-
自定义排序:可以自己实现一个Comparable比较器,自定义排序规则
![TreeSet](https://img-blog.csdnimg.cn/img_convert/78c4a0c3eab88b9f41426191b83b34a5.png)
通过结构图也能发现其他几个特点:
-
实现了Serializable接口, 可序列化 -
实现了Cloneable接口, 可复制
接下来研究一下JDK1.8的源码实现原理,先说结论:
-
创建一个TreeSet其实是创建了一个没有比较器的TreeMap
-
添加数据时,只是在这个TreeMap中添加键,值默认为一个Object对象PRESENT
源码就不解析了,类似HashSet与HashMap的关系。
LinkedHashSet
特别得,他是有序的,维护了数据存储顺序,不可重复。底层采用LinkedHashMap实现。先看一下结构图:
![LinkedHashSet](https://img-blog.csdnimg.cn/img_convert/f06bfba45967152698a1af0f645a332b.png)
通过结构图也能发现其他几个特点:
-
实现了Serializable接口, 可序列化 -
实现了Cloneable接口, 可复制 -
继承于HashSet,他的 添加方法与HashSet一样, 键添加数据, 值为默认的Object对象
接下来研究一下JDK1.8的源码实现原理,先说结论:
-
空构造默认创建一个 加载因子为0.75, 长度为16的LinkedHashMap
首先看一下空构造函数:
public LinkedHashSet() {
// 调用父类构造
super(16, .75f, true);
}
// HashSet构造,创建LinkedHashMap对象
HashSet(int initialCapacity, float loadFactor, boolean dummy) {
map = new LinkedHashMap<>(initialCapacity, loadFactor);
}
public LinkedHashMap(int initialCapacity, float loadFactor) {
// 调用父类构造 LinkedHashMap的父类是HashMap 创建了一个HashMap对象
super(initialCapacity, loadFactor);
accessOrder = false;
}
Queue
队列,我们更多的是在多线程时使用到。Java实现了很多种队列,这里就说一个优先级队列,之后总结多线程时,会详解各种阻塞队列的实现。
PriorityQueue
优先级队列,采用的是小顶堆的数据结构实现,无论何时进行增删改操作,都会将优先级最小的移动到根节点。典型的应用场景就是任务调度,任务随机添加,每次出队优先级最高的任务。先看一下结构图:
![PriorityQueue](https://img-blog.csdnimg.cn/img_convert/2e417d8477978e1a8a77bb56d4d5b557.png)
通过结构图也能发现其他几个特点:
-
实现了Serializable接口, 可序列化
接下来研究一下JDK1.8的源码实现原理,先说结论:
-
空构造器构造一个默认 长度为11的object数组,无比较器 -
数据结构采用 小顶堆的方式实现 -
旧容量**<64**,扩容为旧容量的 2倍+2(跟可变字符串一样),否 则扩容为旧容量的1.5倍(跟动态数组一样) -
添加到 无比较器的优先级队列的对象必须 实现Comparable接口 -
不可添加null
先看一下构造函数:
// 默认长度为11
private static final int DEFAULT_INITIAL_CAPACITY = 11;
public PriorityQueue() {
// 调用有参构造,比较器为null
this(DEFAULT_INITIAL_CAPACITY, null);
}
public PriorityQueue(int initialCapacity,
Comparator<? super E> comparator) {
// 创建一个长度为11的数组
this.queue = new Object[initialCapacity];
this.comparator = comparator;
}
在看一下添加元素的过程:
public boolean add(E e) {
// 调用offer方法
return offer(e);
}
public boolean offer(E e) {
// 不可添加null元素
if (e == null)
throw new NullPointerException();
int i = size; // 当前使用的容量
if (i >= queue.length)
// 扩容
grow(i + 1);
size = i + 1;
if (i == 0)
// 第一个元素直接添加到根节点
queue[0] = e;
else
// 非第一个元素,输入新长度,和添加的元素
siftUp(i, e);
return true;
}
// 扩容
private void grow(int minCapacity) {
// 旧容量
int oldCapacity = queue.length;
// 旧容量<64,扩容为旧容量的2倍+2,否则扩容为旧容量的1.5倍
int newCapacity = oldCapacity + ((oldCapacity < 64) ?
(oldCapacity + 2) :
(oldCapacity >> 1));
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
queue = Arrays.copyOf(queue, newCapacity);
}
// 非第一个节点的添加过程
private void siftUp(int k, E x) {
if (comparator != null)
// 有比较器的规则
siftUpUsingComparator(k, x);
else
// 无比较器的规则
siftUpComparable(k, x);
}
// 无比较器的规则 向上调整算法
private void siftUpComparable(int k, E x) {
// 添加到无比较器的优先级队列的对象必须实现Comparable接口,这里强转为比较器
Comparable<? super E> key = (Comparable<? super E>) x;
while (k > 0) {
// 逻辑右移1位,相当于除2,目的是找到k索引对应的父亲节点的索引parent
int parent = (k - 1) >>> 1;
// 拿到该位置的元素
Object e = queue[parent];
// 新元素与其比较,如果大于旧元素,直接退出循环
// 小顶堆规则:孩子节点必须大于等于父亲节点
if (key.compareTo((E) e) >= 0)
break;
// 小于父亲节点,则交换两节点的位置,继续网上调整
// 小于旧元素 则将旧元素放在索引k处
queue[k] = e;
// 更新索引k为旧元素的旧索引位置
k = parent;
}
// 比较完成。将新元素设置在索引k上
queue[k] = key;
}
想必没基础的看到这里,优先级队列还是迷迷糊糊的,首先,优先级队列实际是通过数组存储数据,但是逻辑结构是堆,且本身采用小顶堆的实现方式。
说一下向上调整算法:
-
第一步,先通过int parent = (k - 1) >>> 1拿到索引k对应父亲节点,k就是目前就是叶子节点
-
第二步,与父亲节点比较,是否满足小顶堆的条件:孩子节点必须大于等于父亲节点
-
满足可直接退出,添加索引k为新元素
-
不满足,旧交换父亲节点和孩子节点的位置,继续网上比较即可。