写在前面

本篇文章学习自【韩顺平讲Java】Java集合专题

总览

Java集合学习总览图:

Java集合学习总览图

集合框架体系

  1. 单列集合

单列集合

  1. 双列集合

双列集合

Collection

Colection接口常用方法

  1. add:添加单个元素
  2. remove:删除指定元素
  3. contains:查找元素是否存在
  4. size:获取元素个数
  5. isEmpty:判断是否为空
  6. clear:清空
  7. addAIl:添加多个元素
  8. containsAll:查找多个元素是否都存在
  9. removeAll: 删除多个元素

遍历方式

  1. 迭代器:iterator,遍历完还想遍历要重置迭代器,否则会抛异常
  2. 集合的增强for:底层原理还是迭代器

关于JVM学习后的补充:

  • 对于集合的增强for的底层实现,通过编译期得到的字节码文件得知是使用的迭代器;

  • 但是对于普通数组的增强for,他的底层实现其实是最普通的带索引的for循环;

  • 所以不能说增强for的底层是迭代器,是要分情况的。

List

List接口基本介绍

List 接口是 Collection 接口的子接口

  1. List .javaList集合类中元素有序(即添加顺序和取出顺序一致)、且可重复[案例]

  2. List集合中的每个元素都有其对应的顺序索引,即支持索引。

  3. List容器中的元素都对应一个整数型的序号记载其在容器中的位置,可以根据序号存取容器中的元素

List接口常用方法

  1. void add(int index, Object ele):在index位置插入ele元素
  2. boolean addAll(int index, Collection eles):从index位置开始将eles中的所有元素添加进来
  3. Object get(int index):获取指定index位置的元素
  4. int indexOf(Object obi):返回obi在集合中首次出现的位置
  5. int lastlndexof(Object obj):返回obj在当前集合中末次出现的位置
  6. Object remove(int index):移除指定index位置的元素,并返回此元素
  7. Object set(int index, Object ele):设置指定index位置的元素为ele相当于是替换
  8. List subList(int fromlndex, int tolndex):返回从fromIndex到tolndex位置的子集合

ArrayList源码分析⭐⭐⭐

  1. ArrayList中维护了一个Object类型的数组elementData
    transient Objectll elementData:

transient:被该关键字修饰的对象等,表示该属性不会被序列化

  1. 当创建ArrayList对象时,如果使用的是无参构造器,则初始elementData容量为0,第1次添加,则扩容elementData为10,如需要再次扩容,则扩容elementData为1.5倍。

三种构造器

  1. 如果使用的是指定大小的构造器,则初始elementData容量为指定大小,如果需要扩容则直接扩容elementData为1.5倍

Vector

  • Vector底层也是一个对象数组,protected Objectp[ ] elementData;

  • Vector是线程同步的,即线程安全,Vector类的操作方法带有synchronized

1
2
3
4
5
6
public synchronized E get(int index){
if(index >= elementCount){
throw new ArrayIndexOutBoundsException(index);
}
return elementData(index);
}
  • 在开发中,需要线程同步安全时,考虑使用Vector

ArrayList是线程不安全的,没有加synchronized,在多线程的情况下不建议使用ArrayList,而是使用Vector

类型 底层结构 线程安全(同步) 效率 扩容倍数
ArrayList 可变数组 不安全,效率高 无参默认0个,第一次扩容10个,后续1.5倍扩
Vector 可变数组 安全,效率不高 无参默认10个,后续2倍扩

LinkedList

  • LinkedList底层实现了双向链表双端队列特点
  • 可以添加任意元素(元素可以重复),包括null
  • 线程不安全,没有实现同步

LinkedList 为什么不能实现 RandomAccess 接口

RandomAccess 是一个标记接口,用来表明实现该接口的类支持随机访问(即可以通过索引快速访问元素)。由于 LinkedList 底层数据结构是链表,内存地址不连续,只能通过指针来定位,不支持随机快速访问,所以不能实现 RandomAccess 接口。

CopyOnWriteArrayList

线程安全 List 实现就是 CopyOnWriteArrayList

CopyOnWriteArrayList 到底有什么厉害之处?

对于大部分业务场景来说,读取操作往往是远大于写入操作的。由于读取操作不会对原有数据进行修改,因此,对于每次读取都进行加锁其实是一种资源浪费。相比之下,我们应该允许多个线程同时访问 List 的内部数据,毕竟对于读取操作来说是安全的。

这种思路与 ReentrantReadWriteLock 读写锁的设计思想非常类似,即读读不互斥、读写互斥、写写互斥(只有读读不互斥)。CopyOnWriteArrayList 更进一步地实现了这一思想。为了将读操作性能发挥到极致,CopyOnWriteArrayList 中的读取操作是完全无需加锁的。更加厉害的是,写入操作也不会阻塞读取操作,只有写写才会互斥。这样一来,读操作的性能就可以大幅度提升。

CopyOnWriteArrayList 线程安全的核心在于其采用了 写时复制(Copy-On-Write) 的策略,从 CopyOnWriteArrayList 的名字就能看出了。

Copy-On-Write 的思想是什么?

写入时复制(英语:Copy-on-write,简称 COW)是一种计算机程序设计领域的优化策略。其核心思想是,如果有多个调用者(callers)同时请求相同资源(如内存或磁盘上的数据存储),他们会共同获取相同的指针指向相同的资源,直到某个调用者试图修改资源的内容时,系统才会真正复制一份专用副本(private copy)给该调用者,而其他调用者所见到的最初的资源仍然保持不变。这过程对其他的调用者都是透明的。此作法主要的优点是如果调用者没有修改该资源,就不会有副本(private copy)被创建,因此多个调用者只是读取操作时可以共享同一份资源。

这里再以 CopyOnWriteArrayList为例介绍:当需要修改( addsetremove 等操作) CopyOnWriteArrayList 的内容时,不会直接修改原数组,而是会先创建底层数组的副本,对副本数组进行修改,修改完之后再将修改后的数组赋值回去,这样就可以保证写操作不会影响读操作了。

add方法

  • add方法内部用到了 ReentrantLock加锁,保证了同步,避免了多线程写的时候会复制出多个副本出来。锁被修饰保证了锁的内存地址肯定不会被修改,并且,释放锁的逻辑放在 finally 中,可以保证锁能被释放。
  • CopyOnWriteArrayList 通过复制底层数组的方式实现写操作,即先创建一个新的数组来容纳新添加的元素,然后在新数组中进行写操作,最后将新数组赋值给底层数组的引用,替换掉旧的数组。这也就证明了我们前面说的:CopyOnWriteArrayList 线程安全的核心在于其采用了 写时复制(Copy-On-Write) 的策略。
  • 每次写操作都需要通过 Arrays.copyOf 复制底层数组,时间复杂度是 O(n) 的,且会占用额外的内存空间。因此,CopyOnWriteArrayList 适用于读多写少的场景,在写操作不频繁且内存资源充足的情况下,可以提升系统的性能表现。

Set

Set接口基本介绍

  • 无序 (添加和取出的顺序不一致),没有索引

  • 不允许重复无素,所以最多包含一个null

注意:虽然取出元素时是无序的,但每次取出却都是固定的。

HashSet

HashSet底层其实就是HashMap,而HashMap底层又是邻接链表,当效率不够时会变为红黑树

构造器源码如下:

1
2
3
public HashSet() {
map = new HashMap<>();
}

HashSet不保证元素是有序的,取决于hash后,再确定索引的结果

总体结论

  1. 添加一个元素时,先得到hash值,会转成索引值
  2. 找到存储数据表table,看这个索引位置是否已经存放的有元素
  3. 如果没有,直接加入
  4. 如果有,调用 equals 比较,如果相同,就放弃添加,如果不相同,则添加到最后
  5. 在Java8中,如果一条链表的元素个数到达TREEIFY THRESHOLD(默认是 8),并且table的大小 >=MIN TREEIFY CAPACITY(默认64)就会进行树化(红黑树)

image-20230907160829758


image-20230907225342640

源码详细解释:⭐⭐⭐⭐⭐

  1. HashSet底层就是HashMap,所以会以值为key,然后一个通用的占位符为value进行调用map的put方法

  2. 在put方法调用后进入putVaule方法中

  3. 在该方法中根据key得到的hash值并不是最终结果,还要经过计算得到一个h的常量,最终再通过h来判断出索引位

  4. 然后先对存放在数组中的元素进行等值判断,若该处没元素则直接存入,若存在且相同则退出

  5. 第二个if就是如果数组中存放的是红黑树,则进行红黑树的添加

  6. 第三种情况就是数组中存放的是链表,那么将进入for循环依次对比各个元素,要么链表中全部元素都不相同则排到链表末尾,如果碰到有一个相同的就直接退出

  7. 在末尾添加时会统计该链表的长度,若到达8则会考虑树化,树化的条件不仅是链表长度到达8,而且表长度要到达64


看源码写注释理解:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
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)为空,就去调用resize扩容方法
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//如果得到的索引值处的位置为空,则直接将Node结点存入该处
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
//否则第一种情况:如果此时hash值相同且equal比较后相同,那么就会替换value值
//在HashMap中是替换value值,在HashSet中也是替换value值,只不过值相同而已
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//第二种情况:发现数组中存放的红黑树结构,则进行红黑树的后续添加操作
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
//第三种情况:剩下的情况就是在链表中查看是否有hash值相同且equal比较后相同的
else {
//一直循环遍历整条链表,只会有下面两种情况退出
for (int binCount = 0; ; ++binCount) {
//第一种情况:找到最后都没有找到,直接把元素添加到链表末尾
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
//添加后要判断链表长度是不是到达8,可能进行相应的树化操作
if (binCount >= TREEIFY_THRESHOLD - 1)
treeifyBin(tab, hash);
break;
}
//第二种情况:找到了相同的,那么就替换值即可
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
//这里是替换值的操作
if (e != null) {
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
//如果此时添加
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}

HashSet扩容和转成红黑树机制⭐⭐⭐⭐⭐

  1. HashSet底层是HashMap,第一次添加时,table 数组扩容到 16, 临界值threshold是 16乘以加载因子loadFactor0.75 = 12
  2. 如果table数组使用到了临界值 12,就会扩容到16* 2 = 32,新的临界值就是32*0.75 = 24,依次类推

补充:根据下面的源码可知,每添加一个元素都算是size加一,而不是要等到表中每个位置到达临界值,即包括链表中的元素在内的元素个数到达临界值就会扩容;

1
2
if(++size > threshold)
resize();

举例:就算全部元素都加到同一个链表中,只要元素个数到达临界值就要扩容

  1. 在Java8中,如果一条链表的元素个数到达 TREEIFY THRESHOLD(默认是 8)并且table的大小 >=MIN TREEIFY CAPACITY(默认64)就会进行树化(红黑树),否则仍然采用数组扩容机制

利用下面这段代码进行debug查看:

因为要hash值相同才会挂到同一个地方,所以重写了他们的hashCode都返回一样的值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class Main {
public static void main(String[] args) {
HashSet<A> hashSet = new HashSet<>();
for (int i = 1; i <= 15; i++) {
hashSet.add(new A(i));
}
}
}
class A {
int n;
public A(int n) {
this.n = n;
}
@Override
public int hashCode() {
return 100;
}
}

一直遍历到i=8时(包括i=8),此时都是存放在一条链表上的

image-20230907231345653

此时到i=9时,因为链表长度到达了8,此时就出发了扩容机制,表长度就会变为16*2=32,而第9个元素会被存放在原链表的末尾,此时长度为9,且索引值会重新进行hash计算,不一定还是原来的索引值了

image-20230907231658335

此时运行到i=10的时候,会再次触发扩容机制,则第10个元素会再次被添加到链表末尾,链表长度为10,然后表再次扩容为64

image-20230907232000347

此时到i=11的时候,再次添加元素就同时满足链表长度到达8且表长度到达64的两个条件了,则会将链表转换为红黑树

image-20230907231052305

LinkedHashSet

  1. LinkedHashSet 是 HashSet 的子类

  2. LinkedHashSet 底层是一个 LinkedHashMap,底层维护了一个 数组+ 双向链表

  3. LinkedHashSet 根据元素的 hashCode 值来决定元素的存储位置,同时使用链表维护元素的次序(图),这使得元素看起来是以插入顺序保存的。

  4. LinkedHashSet 不允许添重复元素

执行下面代码,所维护的双向链表如图:

1
2
3
4
5
6
7
LinkedHashSet<Object> set = new LinkedHashSet<>();
set.add(new String("AA"));
set.add(456);
set.add(456);
set.add(new Customer("刘", 1001));
set.add(123);
set.add("HSP");

image-20230908154435929


在LinkedHashSet中的table中存放的是LinkedHashMap$Entry,他继承了HashMap$Node

image-20230908160006697

🔴🟡🟢总结:

其实只是增加了一个双向链表而已,其他什么都没变HashSet,包括原来的next也是保留了的

TreeSet

  1. TreeSet底层其实是TreeMap

  2. key不能为null

  3. 底层维护了一个红黑树

image-20230908224358603


在发现比较相同的时候,会重置value的值,在TreeSet情况下,相对于key来说,其实是加不进去的

1
2
3
4
5
6
7
8
9
10
11
12
if (cpr != null){
do {
parent = t;
cmp = cpr.compare(key, t.key);
if (cmp < 0)
t = t.left;
else if (cmp > 0)
t = t.right;
else //在发现比较相同的时候,会重置value的值,但是相对于key来说,其实是加不进去的
return t.setValue(value);
}while (t != null);
}

比如在运行下面这段代码时字符串php其实是加不进去的,比较判断逻辑是比较字符串长度

1
2
3
4
5
6
7
8
9
10
public class Main {
public static void main(String[] args) {
TreeSet<String> treeSet = new TreeSet<>((o1, o2) -> o1.length() - o2.length());
treeSet.add("java");
treeSet.add("c++");
treeSet.add("php");
treeSet.add("python");
System.out.println(treeSet);
}
}

运行结果如下:

1
[c++, java, python]

Queue

Queue 与 Deque 的区别

Queue 是单端队列,只能从一端插入元素,另一端删除元素,实现上一般遵循 先进先出(FIFO) 规则。

Deque 是双端队列,在队列的两端均可以插入或删除元素。

PriorityQueue

PriorityQueue 是在 JDK1.5 中被引入的, 其与 Queue 的区别在于元素出队顺序是与优先级相关的,即总是优先级最高的元素先出队。

这里列举其相关的一些要点:

  • PriorityQueue 利用了二叉堆的数据结构来实现的,底层使用可变长的数组来存储数据
  • PriorityQueue 通过堆元素的上浮和下沉,实现了在 O(logn) 的时间复杂度内插入元素和删除堆顶元素。
  • PriorityQueue 是非线程安全的,且不支持存储 NULLnon-comparable 的对象。
  • PriorityQueue 默认是小顶堆,但可以接收一个 Comparator 作为构造参数,从而来自定义元素优先级的先后。

PriorityQueue 在面试中可能更多的会出现在手撕算法的时候,典型例题包括堆排序、求第 K 大的数、带权图的遍历等,所以需要会熟练使用才行

Map

Map接口特点

  1. Map与Collection并列存在,用于保存具有映射关系的数据:Key-Value
  2. Map 中的 key 和 value 可以是任何引用类型的数据,会封装到HashMap$Node对象中
  3. Map 中的 key 不允许重复,原因和HashSet 一样,前面分析过源码
  4. Map 中的 value 可以重复
  5. Map 的key 可以为 null,value 也可以为null,注意 key 为null,只能有一个value为null,可以多个。
  6. 常用String类作为Map的 key
  7. key 和 value 之间存在单向一对一关系,即通过指定的 key 总能找到对应的 value
  1. 一对 k-v 是放在一个HashMap$Node中的,又因为Node实现了Entry 接口,有些书上也说 一对k-v就是一个Entry

第8点补充:其实就是为了方便遍历,所以把Node类型的数据转成entry类型的数据,在使用一个entrySet集合来进行保存各个entry。

Map接口常用方法

  1. put:添加
  2. remove:根据键删除映射关系
  3. get: 根据键获取值
  4. size:获取元素个数
  5. isEmpty:判断个数是否为0
  6. clear:清除
  7. containsKey:查找键是否存在

HashMap

HashMap是线程不安全的

源码分析

  • key相同是值替换,而不是整个键值对替换

查看源码可知:

1
2
3
4
5
6
7
if(e != null){
V oldValue = e.value;
if(!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
  • 具体源码分析

具体可看上面的HashSet讲解,因为HashSet底层就是HashMap

  1. HashMap底层维护了Node类型的数组table,默认为null当创建对象时,将加载因子loadfactor初始化为0.75
  2. 当添加key-val时,通过key的哈希值得到在table的索引,然后判断该索引处是否有元素
  3. 如果没有元素直接添加。如果该索引处有元素,继续判断该元素的key和准备加入的key相是否等,如果相等,则直接替换val;如果不相等需要判断是树结构还是链表结构,做出相应处理。如果添加时发现容量不够,则需要扩容。
  4. 第1次添加,则需要扩容table容量为16,临界值threshold为12(16*0.75)
  5. 以后再扩容,则需要扩容table容量为原来的2倍(32),临界值为原来的2倍,即24,依次类推
  6. 在Java8中,如果一条链表的元素个数超过TREEIFY_THRESHOLD(默认是 8),并且table的大小 >= MIN TREEIFY CAPACITY(默认64),就会进行树化(红黑树)

⭐⭐⭐补充:头插法的不安全性

文章参考自:https://juejin.cn/post/7236009910147825719?searchId=202307221616567C1AAB8045817A64ED43

jdk8以前,hashmap使用的是头插法,头插法的好处就是在哈希冲突的时候,不用依次遍历链表中的各个元素去进行尾插到末尾,即时间复杂度从O(n)转变为了O(1)。

但是头插法有可能会出现循环链表的情况,导致死循环。

HashMap 死循环发生在 JDK 1.8 之前的版本中,它是指在并发环境下,因为多个线程同时进行 put 操作,导致链表形成环形数据结构,一旦形成环形数据结构,在 get(key) 的时候就会产生死循环。如下图所示: image.png

死循环原因

HashMap 导致死循环的原因是由以下条件共同导致的:

  1. HashMap 使用头插法进行数据插入(JDK 1.8 之前);
  2. 多线程同时添加;
  3. 触发了 HashMap 扩容。

头插法+扩容导致链表翻转

头插法是指新来的值会取代原有的值,插入到链表的头部,如下图所示。

原链表如下图所示: img 此时使用头插入插入一个元素 Z,如下图所示: img 头插法会导致 HashMap 在进行扩容时,链表的顺序发生反转,如下图所示: img 因为在 HashMap 扩容时,会先从旧 HashMap 的头节点读取并插入到新 HashMap 节点中,旧节点的读取顺序是 A -> B -> C,于是插入到新 HashMap 中的顺序就变成了 C -> B -> A,这样就破坏了链表的顺序,导致了链表反转。

死循环产生过程

  1. 死循环执行步骤1

死循环是因为并发 HashMap 扩容导致的,并发扩容的第一步,线程 T1 和线程 T2 要对 HashMap 进行扩容操作,此时 T1 和 T2 指向的是链表的头结点元素 A,而 T1 和 T2 的下一个节点,也就是 T1.next 和 T2.next 指向的是 B 节点,如下图所示: image.png

  1. 死循环执行步骤2

死循环的第二步操作是,线程 T2 时间片用完进入休眠状态,而线程 T1 开始执行扩容操作,一直到线程 T1 扩容完成后,线程 T2 才被唤醒,扩容之后的场景如下图所示: image.png 从上图可知线程 T1 执行之后,因为是头插法,所以 HashMap 的顺序已经发生了改变,但线程 T2 对于发生的一切是不可知的,所以它的指向元素依然没变,如上图展示的那样,T2 指向的是 A 元素,T2.next 指向的节点是 B 元素。

  1. 死循环执行步骤3

当线程 T1 执行完,而线程 T2 恢复执行时,死循环就建立了,如下图所示: image.png 因为 T1 执行完扩容之后 B 节点的下一个节点是 A,而 T2 线程指向的首节点是 A,第二个节点是 B,这个顺序刚好和 T1 扩完容完之后的节点顺序是相反的。T1 执行完之后的顺序是 B 到 A,而 T2 的顺序是 A 到 B,这样 A 节点和 B 节点就形成死循环了,这就是 HashMap 死循环导致的原因。

解决方案

HashMap 死循环的常用解决方案有以下几个:

  1. 升级到高版本 JDK(JDK 1.8 以上),高版本 JDK 使用的是尾插法插入新元素的,所以不会产生死循环的问题;
  2. 使用线程安全容器 ConcurrentHashMap 替代(推荐使用此方案);
  3. 使用线程安全容器 Hashtable 替代(性能低,不建议使用);
  4. 使用 synchronized 或 Lock 加锁 HashMap 之后,再进行操作,相当于多线程排队执行(比较麻烦,也不建议使用)。

小结

HashMap 死循环发生在 JDK 1.7 版本中,形成死循环的原因是 HashMap 在 JDK 1.7 使用的是头插法,头插法 + 多线程并发操作 + HashMap 扩容,这几个点加在一起就形成了 HashMap 的死循环,解决死循环可以采用线程安全容器 ConcurrentHashMap 替代。

JDK官方文档说hashmap本身就是让单线程去操作的,要是多线程还是使用ConcurrentHashMap

⭐⭐⭐为什么HashMap 总是使用 2 的幂作为哈希表的大小

为了能让 HashMap 存取高效,尽量较少碰撞,也就是要尽量把数据分配均匀。

HashMap 中的桶(数组)长度为 2 的幂次方,这是为了高效且均匀地分配数据,减少碰撞,优化内存使用和查找速度。以下是详细解释:

  1. 均匀分配与碰撞减少

哈希表的关键在于哈希函数的质量,即如何将键(key)均匀地分配到数组的各个位置(桶)中。哈希函数的目标是尽可能地减少碰撞(即不同的键映射到相同的位置)。理想情况下,哈希值的分布应该足够均匀,使得每个键的存取操作都可以在接近常数时间内完成。

  1. 哈希值的范围和内存限制

哈希值的范围是 -2147483648 到 2147483647(总共约 40 亿个值)。直接使用这么大的数组显然是不现实的,因为内存无法容纳。因此,哈希值需要进行一定的处理,以适应较小的数组长度。

  1. 数组下标计算方法

在实际使用中,哈希值需要映射到数组下标,这个下标的计算方式是通过对数组长度取模(mod)运算得到的。数组下标的计算方法是:

1
index = hash % length

但在长度为 2 的幂次方时,可以用更高效的位操作代替取模运算,即:

1
index = hash & (length - 1)

这种方法比直接取模运算更快,这是因为位操作比取模运算的计算效率更高。

HashMap 为什么线程不安全?

多个线程对 HashMapput 操作会导致线程不安全,具体来说会有数据覆盖的风险。

线程 1 执行 if(++size > threshold) 判断时,假设获得 size 的值为 10,由于时间片耗尽挂起。

线程 2 也执行 if(++size > threshold) 判断,获得 size 的值也为 10,并将元素插入到该桶位中,并将 size 的值更新为 11。

随后,线程 1 获得时间片,它也将元素放入桶位中,并将 size 的值更新为 11。

线程 1、2 都执行了一次 put 操作,但是 size 的值只增加了 1,也就导致实际上只有一个元素被添加到了 HashMap 中。

ConcurrentHashMap

待补充。。。。。。

由于 HashMap 的干扰,很多人认为 ConcurrentHashMap 是可以置入 null 值,而事实上,存储 null 值时会抛出 NPE 异常。

Hashtable

  1. 线程安全,因为方法上有加synchronized
  2. Hashtable中key和value都不能是null,否则会抛异常
  3. 底层是Hashtable&Entry[ ] 数组,第一次默认扩容大小为11
  4. 加载因子loadfactor初始化为0.75
  5. 第一次临界值threshold为8(11*0.75)
  6. 进行扩容时是按**两倍加一*来扩容,例如112+1=23,第二次扩容大小就为23

Propoties

  1. Propoties是Hashtable的子类
  2. Properties 还可以用于从xxx.properties文件中,加载数据到Properties类对象并进行读取和修改

TreeMap

  1. 底层维护了一个红黑树

  2. key不能为null

1
2
3
TreeMap<Object, Object> treeMap = new TreeMap<>();
treeMap.put("java",null);
treeMap.put(null,"java");

  1. 在发现比较相同的时候,会重置value的值,因为key会被认为是同一个对象,所以只会重置value值

例如下面例子中c++先被添加,再添加php时因为字符串长度一致所以被认为是同一个对象,所以只会修改值

1
2
3
4
5
6
7
8
9
10
public class Main {
public static void main(String[] args) {
TreeMap<String, Integer> treeMap = new TreeMap<>((o1, o2) -> o1.length() - o2.length());
treeMap.put("java", 1);
treeMap.put("c++", 1);
treeMap.put("python", 1);
treeMap.put("php", 2);
System.out.println(treeMap);
}
}

运行结果如下:

1
{c++=2, java=1, python=1}

Collections

常用API

  1. reverse(List): 反转 List 中元素的顺序
  2. shuffle(List): 对 List 集合元素进行随机排序
  3. sort(List): 根据元素的自然顺序对指定 List 集合元素按升序排序
  4. sort(List,Comparator): 根据指定的 Comparator 产生的顺序对 List 集合元素进行排序
  5. swap(List,int, int): 将指定 list 集合中的i 处元素和j处元素进行交换
  6. Object max(Collection): 根据元素的自然顺序,返回给定集合中的最大元素
  7. Object max(Collection,Comparator): 根据 Comparator 指定的顺序返回给定集合中的最大元素
  8. Object min(Collection)
  9. Object min(Collection, Comparator)
  10. int frequency(Collection,Object): 返回指定集合中指定元素的出现次数
  11. void copy(List dest,List src): 将src中的内容复制到dest中
  12. boolean replaceAll(List list,Object oldVal,object newVal): 使用新值替换 List 对象的所有旧值

补充

  1. 使用 entrySet 遍历 Map 类集合 KV,而不是 keySet 方式进行遍历。

说明:keySet 其实是遍历了 2 次,一次是转为 Iterator 对象,另一次是从 hashMap 中取出 key 所对应的 value。而 entrySet 只是遍历了一次就把 key 和 value 都放到了 entry 中,效率更高。如果是 JDK8,使用 Map.forEach 方法。

正例:values()返回的是 V 值集合,是一个 list 集合对象;keySet()返回的是 K 值集合,是一个 Set 集合对象;entrySet()返回的是 K-V 值组合集合。

  1. 【强制】使用集合转数组的方法,必须使用集合toArray(T[] array),传入的是类型完全一致、长度为 0 的空数组

反例:直接使用 toArray 无参方法存在问题,此方法返回值只能是 Object[]类,若强转其它类型数组将出现 ClassCastException 错误。

正例:

List list = new ArrayList<>(2);

list.add(“guan”);

list.add(“bao”);

String[] array = list.toArray(new String[0]);

说明:使用 toArray 带参方法,数组空间大小的 length:

  1. 等于 0,动态创建与 size 相同的数组,性能最好。

  2. 大于 0 但小于 size,重新创建大小等于 size 的数组,增加 GC 负担

  3. 等于 size,在高并发情况下,数组创建完成之后,size 正在变大的情况下,负面影响与上相同。

  4. 大于 size,空间浪费,且在 size 处插入 null 值,存在 NPE 隐患。

  1. 不要在 foreach 循环里进行元素的 remove/add 操作。remove 元素请使用 Iterator 方式,如果并发操作,需要对 Iterator 对象加锁。

在Java中,当你在一个集合(如List或Set)上进行迭代时,同时修改正在遍历的集合可能会导致ConcurrentModificationException异常。这是因为Java的集合类维护了一个叫做“modCount”的内部计数器,每次调用集合的add、remove等方法修改了集合的内容时,这个计数器就会增加。当创建一个Iterator对象时,它会记录当前的modCount值。在迭代过程中,如果集合的modCount与Iterator创建时的值不匹配,就说明有其他线程或代码修改了集合,这将触发ConcurrentModificationException异常,以防止数据结构的不一致和潜在的错误。

因此,为了避免在foreach循环中直接对集合进行修改而导致的异常,推荐使用Iterator的方式进行元素的移除:

1
2
3
4
5
6
7
8
9
>List<Integer> list = new ArrayList<>();
>// 填充list...
>Iterator<Integer> iterator = list.iterator();
>while (iterator.hasNext()) {
Integer item = iterator.next();
if (someCondition(item)) {
iterator.remove(); // 安全地从list中移除item
}
>}

在这个例子中,iterator.remove()方法允许你在迭代过程中安全地移除元素,而不会抛出ConcurrentModificationException异常。

对于并发场景,如果你在多个线程中使用同一个Iterator,那么确实需要对Iterator对象进行同步,以避免竞态条件和不一致的状态。但是,在单线程环境中,通常不需要对Iterator加锁。

此外,如果你在多线程环境中需要频繁地添加和删除元素,可以考虑使用CopyOnWriteArrayListConcurrentHashMap等线程安全的集合类型,这些集合类型设计时已经考虑到了并发访问和修改的问题。但是请注意,这些集合类型可能在性能上不如非线程安全的集合类型,因此在选择集合类型时需要根据具体的应用场景权衡。