Java集合详解
Java集合
集合体系
Collection接口
Collection接口没有直接的实现子类,它的子接口list(有序容器,可以重复)和set(无序容器,不可重复)是两个重要的子接口,接口实现子类都是单列集合。
Collection接口常用方法
Collection接口定义了有很多常用的方法,它的实现子类都可以使用这些方法
方法 | 作用 |
---|---|
add | 向集合添加元素,放回类型为boolean |
addAll | 向集合添加多个元素,参数为接口对象 |
remove | 删除指定的对象,实现子类还重载了更多方法 |
removeAll | 删除多个元素,参数为接口对象 |
contains | 是否包含某个元素 |
isEmpty | 判断集合是否为空 |
size | 返回集合元素个数 |
iterator | 返回迭代器对象,用于遍历 |
clear | 清空集合 |
Collection遍历
因为一些集合的实现子类不支持索引,所以集合类统一使用迭代器进行遍历,Collection继承了Iterable接口,Iterable接口定义了iterator()方法,所有实现了Collection接口的子类都实现了iterator()方法。
遍历方式一:
while (iterator.hasNext()) {//判断是否还有数据
Object obj = iterator.next();//获得下一个元素
System.out.println("obj=" + obj);
}
遍历方式二:
使用增强for循环也可以对集合进行遍历,底层的执行还是在使用迭代器
for (Object o : list) {
System.out.println("obj=" + obj);
}
Iterator接口
在看源码时,发现Collection接口及其实现子类都没有实现Itrator接口,因此没有next()和hasNext()方法,那为什么能用迭代器遍历呢?实际上(以ArrayList为例)是它定义了一个内部类Itr实现了Iterator接口,并实现了next()和hasNext()方法,而Iterator()方法返回的是Itr对象。
List接口
List接口是有序容器,即添加元素的顺序和取出元素的顺序是一致的,且可以重复,可以添加空对象。List支持索引。
List接口方法
除了Collention接口的方法外,List接口还定义了以下方法。
方法 | 功能 |
---|---|
get | 按照索引获取某个元素 |
set | 按照索引修改某个元素 |
inexOf | 获取某个元素第一次出现的位置 |
lastIndexOf | 获取某个元素最后一次出现的位置 |
subList | 根据索引位置返回集合的子集合 |
sort | 对集合进行排序,可以自定义排序规则 |
ArrayList
- ArrayList使用数组来实现数据存储。
- ArrayList等同于Vector,但是ArrayList线程不安全。
ArrayList扩容机制
- ArrayList维护了一个Object对象数组 transient Object[] elementData
- 创建ArrayList对象使用无参构造器时,则初始化容量为0,第一次创建时会扩容为10,之后每次需要扩容则扩大为原来的1.5倍。
- 创建ArrayList对象使用指定大小的构造器时,初始化容量为指定大小,之后需要扩容则扩大1.5倍。
思考题一:
如果用0指定大小来扩容,然后添加元素发生什么?当一次性添加多个元素,扩容后空间也不够怎么办?
实际上扩容时会先计算容纳当前元素和添加元素的的所需最小空间大小,如果只添加一个元素则minCapacity = size + 1,添加多个元素,minCapacity = size + numNew(新添加元素的数量),在进行扩容时,如果扩容后的容量小于minCapacity,则使用minCapacity进行扩容 否则还是扩容为当前容量的1.5倍。
思考题二
ArrayList中的modCount是什么,有什么作用?
modCount记录集合被修改的次数,包括删除,添加和修改等操作,那么为什么要记录这个修改次数呢?这是因为ArrayList是线程不安全的,在迭代器遍历的过程中是允许对集合进行修改,这在多线程下会引发严重的问题。
modCount就是为了解决这样的问题:迭代器对象初始化时,属性expecedModCount的值初始化为modCount,每次迭代过程中都会检查两者是否相等,一旦在某个线程中对集合进行修改,检测出两者不相等就会抛出ConcurrentModificationException异常。
Vector
- Vector也使用数组来实现数据存储。
- Vector是线程同步的,多线程场景下使用Vector
- Vector扩容机制等同于ArrayList,不过扩容大小为2倍
Stack
Stack是Vector的子类,实现了后进后出,除了Vector定义的方法,Stack还定义了自己的方法,如下。
方法 | 说明 |
---|---|
empty | 测试堆栈是否为空 |
peek | 返回栈顶对象, |
pop | 返回栈顶对象,并从栈中移出 |
push | 将一个对象压入栈中 |
search | 返回一个对象在栈中的索引,1为基数 |
LinkedList
- LinkedList实现了双向链表和双端队列
- LinkedList是线程不安全的。
LinkedList和ArrayList的比较
底层结构 | 增删的效率 | 改查的效率 | |
---|---|---|---|
ArrayList | 数组 | 数组扩容,效率低 | 连续存储,效率高 |
LikedList | 双向链表 | 动态扩容,效率高 | 链式存储,效率低 |
Deque
因为LinkedList实现了Deque(Double ended Queue)接口,因此LinkedList可以当作双端队列来使用,同时也可以作为栈来使用。
//普通队列(一端进另一端出):
Queue queue = new LinkedList()
//双端队列(两端都可进出),或者作为栈来使用
Deque deque = new LinkedList()
在Deque中提供了两套添加和修改的方法,一种在操作抛出异常,一种是返回一个特殊值(null或false)。
抛出异常 | 特殊值 | 抛出异常 | 特殊值 | |
---|---|---|---|---|
插入 | addFirst(e) | offerFirst(e) | addLast(e) | offerLast(e) |
删除 | removeFirst() | pollFirst() | removeLast() | pollLast() |
获取 | getFirst() | peekFirst() | getLast() | peekLast() |
Deque原文链接:https://blog.csdn.net/devnn/article/details/82716447
set接口
set是无序容器,存取的顺序不一致,不允许存放重复元素,不支持索引
HashSet
- Hashset的底层是HashMap,是一个链表数组结构,即数组中的每一个元素都是一个链表,当一条链表上的元素有过多时,该链表会转化为一颗红黑树来提高查找效率。
- 为了保证set没有重复元素,添加的该对象对应的类必须重写equals()方法。
- 重写hashCode()方法能由程序员来决定对象属性的哪些值一致会被映射到对象数组的同一位置中。
向HashSet添加元素时,先计算元素的hash值再映射到数组中的位置,如果这个位置已经由元素了,会用equals()方法比较,如果相同则退出,不会添加到HashSet中,然后遍历链表,最后挂到链表的末尾上。
HashSet元素添加的底层执行过程
- 在HashSet()构造器中,使用HashMap创建对象
public HashSet() {
map = new HashMap<>();
}
- 添加元素,使用HashMap的put方法
因为HashMap一个节点存放的是键值对,而HashSet是单列集合,所以值用PRESENT代替(实际上是一个空对象)。
public boolean add(E e) {
return map.put(e, PRESENT)==null;
}
- 执行 put()
该方法会执行 hash(key) 得到 key 对应的 hash 值 算法 h = key.hashCode()) ^ (h >>> 16)
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
- 执行putVal
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
//table 就是 HashMap 的一个数组,类型是 Node[]
//if 语句表示如果当前 table 是 null, 或者 大小=0
//就是第一次扩容到 16 个空间.
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//(1)根据 key(key是添加的这个对象),得到 hash 去计算 key 应该存放到 table 表哪个个索引位置。
//并把索引位置赋给i,这个位置存放的对象引用赋给 p
//(2)判断 p 是否为 null
//(2.1) 如果 p 为 null, 表示这个位置还没有存放元素, 就创建一个 Node 放在该位置
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
//如果当前索引位置对应的链表的第一个元素和准备添加的 key 的 hash 值一样
//并且满足 下面两个条件之一:
//(1) 准备加入的 key 和 p 指向的 Node 结点的 key 是同一个对象
//(2) p 指向的 Node 结点的 key 的 equals() 和准备加入的 key 比较后相同
//就不能加入
if (p.hash == hash &&((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//再判断 p 是不是一颗红黑树, //如果是一颗红黑树,就调用 putTreeVal , 来进行添加
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
//使用for循环遍历链表
//(1) 依次和该链表的每一个元素比较后,都不相同, 则加入到该链表的最后。
//把元素添加到链表后,立即判断 该链表是否已经达到 8 个结点,如果是就调用 treeifyBin() 对当前这个链表进行树化(转成红黑树)
// 在treeifyBin()中进行如下判断是否对链表树化
// if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY(64))
// 如果上面条件成立,先 table 扩容. // 只有上面条件不成立时,才进行转成红黑树
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
//if语句内部让e指向p的下一个
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD -1)
treeifyBin(tab, hash);
break;
}
//(2) 依次和该链表的每一个元素比较过程中,如果有相同情况,就直接 break
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;//因为e指向p的下一个,这里p指向e实际上实现了对链表的遍历
}
}
//这里e实际上指向了在哈希表中与我们要添加的对象相等的那个对象。
//if语句块的内容是向HashMap加入同一个键,不同值的对象时,值被替换的原理。
//但是HashSet的value都是空对象,对HashSet不起作用。
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
//size 就是我们每加入一个结点 Node(k,v,h,next), size++
//threshold是临界值,大小为当前容量的0.75倍
if (++size > threshold)
resize();//扩容
afterNodeInsertion(evict);
return null;
}
HashSet扩容机制
- 使用无参构造器时,初始化为空。第一次添加元素时,table扩容为16,临界值threshold = 16 * 加载因子(0.75) = 12。
- 到了临界值后会继续扩容,容量扩充为原来两倍,注意这里达到临近不是指数组中有12个被占据了,而是HashSet添加了12个元素。
- Java8中,如果一个链表的元素达到了8并且table的大小超过了64就会进行树化,否则对数组进行扩容。
LinkedHashSet
LinkedHashSet底层维护了一个hash表和一个双向链表。向LinkedHashSet添加元素时,虽然数据不再同一个数组的索引位置上,但是双向链表把所有的数据串联了起来,因此实现了存取顺序是一样的
Map接口
- Map 与 Collection 并列存在。用于保存具有映射关系的数据:Key-Value(双列元素)
- Map 中的 key 和 value 可以是任何引用类型的数据,会封装到 HashMap$Node 对象中
- Map 中的 key 不允许重复,可以为 null, value 可以重复,也可以为 null。key 为 null,只能有一个, value 为 null ,可以多个
- key 和 value 之间存在单向一对一关系,即通过指定的 key 总能找到对应的 value
Map接口方法
方法 | 说明 |
---|---|
put | 向Map添加元素 |
remove | 根据键删除映射关系 |
get | 根据键获得值 |
size | 返回Map元素个数 |
isempty | Map是否为空 |
containsKey | 是否存在某个键 |
containsValue | 是否存在某个值 |
HashMap
- HashMap底层结构在前面HashSet中已经有详细的说明,底层是数据+链表+红黑树。
- HashMap添加相同的Key,Value会被替换,在前面的源码分析提到过。
- HashMap没有实现同步,线程不安全。
HashMap遍历机制-Entry
为了方便对Map的遍历,Map接口内部定义了Entry接口,Map接口也定义了entrySet()方法。
以HashMap为例 内部类Node实现了Map.Entry接口,实现了getKey(),getValue()等方法
实现了entrySet()方法,返回的是一个EntrySet对象,EntrySet是HashMap的一个内部类,在这个EntrySet类中,定义了iterator()方法,返回的是一个EntryIterator对象。
而EntryIterator也是HashMap定义的一个内部类,EntryIterator定义了next()方法,通过这个我们得以获得下一个对象。
再深一步挖掘,这个next方法返回的是一个nextNode(),而这个nextNode()方法也正是在EntryIterator的父类HashEntryIterator中定义的,除此之外还定义了hasNext()方法,到此为止,迭代器的两个核心方法hasNext()和next()终于显出原型了。
我们看到nextNode()返回的是一个Node对象,而这个Node对象实现了Entry接口的getKey(),getValue()以及toString()方法,这两个方法让我们能够访问到键值对,因此就实现了遍历Map的所有键值对。
如果只需要遍历键或者值可以使用Values()和KeySet()方法,它们遍历的底层逻辑和EntrySet是一样的。
进一步思考:EntrySet对象
再回过头来思考,entrySet对象到底是什么?它实际上是一个空对象,因为在类内部根本没有定义任何字段,也没有构造器,按照本人的理解,正如这个对象名,很形象的告诉了我们它只是一个入口,通过这个这个入口我们得以访问到Map里所有的键值对。这个设计非常的精妙,没有任何多余空间的开销我们就实现了堆Map的遍历。
最后一个问题,使用IDEA在Debug的时候发现这个entrySet存放了对象,这与我们之前认为他是空有冲突,这是什么原因呢?
这是因为IDEA在Debug是会调用toString()方法对象来显示对象的具体信息,它是一个视图,即对本身的一个映射,内部并没有存放数据
遍历所有键
//得到键,还可以用get方法得到值
Set keyset = map.keySet();
//(1) 增强 for
for (Object key : keyset) {
System.out.println(key + "-" + map.get(key));
}
//(2) 迭代器
Iterator iterator = keyset.iterator();
while (iterator.hasNext()) {
Object key = iterator.next();
System.out.println(key + "-" + map.get(key));
}
遍历所有值
//获得所有值
Set values = map.values();
//(1) 增强 for
for (Object value : values) {
System.out.println(value);
}
//(2) 迭代器
Iterator iterator2 = values.iterator();
while (iterator2.hasNext()) {
Object value = iterator2.next();
System.out.println(value);
}
遍历所有键值对
Set entrySet = map.entrySet();
//(1) 增强 for
for (Object entry : entrySet) {
//将 entry 转成 Map.Entry,这样才能调用getKey()方法
Map.Entry m = (Map.Entry) entry;
System.out.println(m.getKey() + "-" + m.getValue());
}
//(2) 迭代器
Iterator iterator3 = entrySet.iterator();
while (iterator3.hasNext()) {
Object entry = iterator3.next();
Map.Entry m = (Map.Entry) entry;
System.out.println(m.getKey() + "-" + m.getValue());
}
HashMap扩容机制
扩容机制在HashSet已经分析总结过。
LinkedHashMap
- LinkedHashMap是HashMap的子类,实现了存取顺序是一致的,如果业务上需求存取是一致的,可以使用LinkedHashMap。
HashTable
- HashTable不能存放空对象,否则会抛出空指针异常
- HashTable实现了线程同步,是线程安全的
- HashTable的底层是一个Entry数组,Entry是HashTable的内部类,是一个链表的结构,这个Entry类实现了Map.Entry接口。
Hashtable遍历机制
遍历机制与和HashTable基本类似,也是通过entrySet方法返回EntrySet对象,调用EntrySet的迭代器方法进行遍历
Hashtable扩容机制
- 使用无参构造器时,初始化容量为11.加载因子为0.75。
- 当达到扩容的边界条件时,容量扩充为原来的2被 + 1。
- Hashtable不会进行树化。
Properties
Properties继承了HashTable,主要用于从.properties配置文件中读取数据到Properties对象中,这个在IO中再给出具体的使用说明。
树集
树集比较特殊,包括TreeSet(Set的实现类)和TreeMap(Map的实现类),其中TreeSet底层使用和TreeMap来实现,树集保证了整个集合是按照自然规则或者自定义规则排好序的,其中TreeMap按照键来排序,每添加一个元素,它都能帮你在集合中插入到正确的位置上。
树集的使用注意事项
- 用构造器创建一个对象时,如果没有传入一个实例化的Comparator接口,将会使用默认方法排序,并且必须要求对象实现Comparable接口,否则会抛出异常。
- 当指定了排序规则后,TreeSet和TreeMap对重复元素的判定是按照排序规则,两者若应该在同一个位置,那么这个元素插入不成功。
public class CollectionTest {
public static void main(String[] args) {
TreeSet<String> set = new TreeSet<String>((s1,s2)
->{return s1.length()-s2.length();});
set.add("meiguolao");
set.add("taihaizhanzheng");
set.add("dazhonghua");
set.add("xiaoriben");
System.out.println(set);
}
}
输出结果:
[meiguolao, dazhonghua, taihaizhanzheng]
如上,"xiaoriben" 没有被插入set集合中,因外它和"meiguolao"字符串长度是一样的。
Collections
Collections是Java提供的一个操作list,set和map集合的工具类,在Collections中提供了一系列的静态方法用来对集合进行排序,修改和查看。
方法太多了,直接用IDEA的Structure查看吧,写累了,啊🥱🥱🥱🥱🥱🥱