0.前言
java集合实现两大接口,coliection与map,其中list与set接口实现coliection接口,而hashmap等具体类实现map接口,除此之外,coliections为集合的工具类,提供了更多的方法
参考博文:集合复习
整体结构:
collection:
map:
1.Collection
1.List
Java的List是非常常用的数据类型。List是有序的Conllection。List主要有三个实现类:ArrayList,Vector和LinkedList。
1. ArrayList(数组)
- 内部是通过数组实现的,使用无参构造生成时,默认容量为0,第一次添加扩容为10,随后再次扩容机制为新List长度=原List长度*1.5(原list长度1.5倍)
- 允许对元素进行快速随机访问。数组的缺点是每个元素之间不能有间隔,当数组的大小不满足时需要增加存储能力,就是要将已经有的数据复制到新的存储空间中。
- 当从 ArrayList 的中间位置插入或删除元素时,需要对数组进行复制,移动,代价比较高。因此,它更适合随机查找和遍历,不适合插入和删除。
- 内部使用的是transient Object[] elementData; 来存储数组,transient 瞬间修饰,表示属性不会被序列化
1.扩容机制
当添加新元素时,会先判断当前数组中的元素个数是否==数组大小,如果满足条件,则对数组扩容
调用数组扩容函数,随后将原数组数据复制到新数组中
对数组扩容时将原数组长度右移1位(原值/(2*n)),并加上原数组长度,使得新数组长度位原数组长度的1.5倍,如果调用的是无参构造生成的list,数组为初始数组,第一次就就将其扩大到10,否则返回新数组的长度
2. Vector(数组实现,线程安全)
- 和ArrayList 一样也是通过数组实现的,默认容量为10,扩容机制为新Vector长度=原Vector长度*2,源码与ArrayList类似
- 支持线程的同步,即某一刻只有一个线程能够写 Vector,避免多线程同时写而引起的不一致性,但实现同步需要很高的花费
-
3. LinkedList(链表)
LinkedList 使用链表结构存储数据的,很适合数据的动态插入和删除,随机访问和遍历速度比较慢。
LinkedList 还提供了 List 接口中没有定义的方法,专门用于操作表头和表尾的元素,可以当作堆栈、队列和双向队列使用。
2.Set
存储的元素无序且不可重复,所以最多只允许存在一个null
对象的相等性本质是对象的 hashCode 值(Java是依据对象的内存地址计算出的此序号)判断的,如果想要让两个不同的对象是为相等的,就必须覆盖 Object 的 hashCode() 方法和 equals() 方法。
1. HashSet(Hash表)
哈希表存放的是哈希值,底层是HashMap(使用key值存数据,value为空)
- HashSet 存储元素的顺序并不是按照存入的顺序(和 List 显然不同)而是按照哈希值来对所有数据进行存取
- 元素的哈希值是通过元素的 hashCode()方法来获取的,HashSet 首先判断两个元素的哈希值,如果哈希值相同,接着会比较 equals() 方法,如果 equals() 结果为true,HashSet 就会将这两个元素视为同一个元素。如果 equals() 为false就不是同一个元素。
- HashSet 会通过 hashCode 值来确定元素的位置。当元素的 hashCode 相同,但 equals 不同,则会在这一个hashCode 的位置上存放多个元素。(就是在同样的哈希值下顺延,也就是哈希值一样的存一列。)
- 创建Set实际上就是创建HashMap
添加与扩容机制
因为HashSet底层是HashMap,所以其使用的方法与逻辑与Map是一致的
整体流程:
Set添加方法时,调用的时Map的添加方法,因为map是键值对形式,所以还需要传入一个空的对象占位
(hashMap部分源码)
由源码可知,根据hashcode()计算出的hash并不是最终的hash
代码太长,所以直接复制
:::danger
ps: equals()不一定是判断内容,每个类equals()标准都可能不一样
ps: 此处size指的是map中存放元素的个数,临界值则是节点数组的临界值,即不是占用了75%的数组索引才扩容,而是只要总数据超过就扩容(添加完第13个才扩容);size并不是占用新的数组索引才增加,而是每存放一个元素就增加
:::
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i; //创建辅助变量
//1.如果节点数组为空,就初始化数组,大小为16
if ((tab = table) == null || (n = tab.length) == 0) //table为节点数组
n = (tab = resize()).length;
//2.根据hash值计算在数组中的索引,且如果计算出的索引为空(意味着节点没有值),就直接存入
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
//3.如果数组中索引位置已有节点,就执行以下代码
//3.1先判断该处索引位置的第一个节点的hash值是否与要插入的hash值相等
//然后再满足以下条件之一:
//1.两个节点的地址是否相等(==)
//2.两个节点的equals对比结果是否相等(如不重写,则仍然是地址)
//如果满足,则证明是相同的,就不插入
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//3.2判断节点是否为一颗树(即map是否已经转变为红黑树),是就调用树的添加节点方法
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
//3.3如果不是相同节点且不是一颗树,则循环找到该数组索引位置的最后一个节点,向其next
//添加一个新节点
else {
for (int binCount = 0; ; ++binCount) {
//3.3.1当遍历到最后节点时
if ((e = p.next) == null) {
//将最后节点的next指向新节点
p.next = newNode(hash, key, value, null);
//判断该数组中索引位置链表节点个数是否大于等于8(从0开始计算,所以要-1),
//还必须满足节点数组>=64,就将链表转换为红黑树,否则对数组扩容
//TREEIFY_THRESHOLD:8
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
//3.3.2在循环中依次判断每个节点是否和要插入的节点相同,相同就结束循环
//(意味着已经存在要插入的值),退出
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
//将p指向下个节点(因为在开始使e=p.next了),即e=p.next,p=e,两条语句交替执行
//就会遍历节点
p = e;
}
}
//4.当相同时,不会执行添加,返回不为空,在map中这里是替换相同key的值
//即当key相同时,e存放的是链表中相同key的旧值,e.value会被新值替代
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
//新值替代旧值
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
//5.判断size是否超过了临界值,true就扩容(添加完第13个才扩容)
//注意:此处size指的是map中存放元素的个数,临界值则是节点数组的临界值
//即不是占用了75%的数组索引才扩容,而是只要总数据超过就扩容
if (++size > threshold)
resize();
//这是留给子类去实现的空方法
afterNodeInsertion(evict);
return null;
}
扩容的方法
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
//1.当数组长度>0时,扩容
if (oldCap > 0) {
//如果过超出了允许的最大值,不扩容
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
//扩容为原来的2倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
//2.长度=0时,即第一次扩容时
else { // zero initial threshold signifies using defaults
//第一次调用时,将table初始化为16
newCap = DEFAULT_INITIAL_CAPACITY;
//计算临界值,当数据达到临界值时就提前扩容,而不是满了才扩容
//0.75*16,0.75是负载因子
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
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 { // preserve order
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) {
if (loTail == null)
loHead = e;
else
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;
}
}
}
}
}
return newTab;
}
转换为红黑树的方法
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
//如果数组小于64,不会转树,而是扩容
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
else if ((e = tab[index = (n - 1) & hash]) != null) {
TreeNode<K,V> hd = null, tl = null;
do {
TreeNode<K,V> p = replacementTreeNode(e, null);
if (tl == null)
hd = p;
else {
p.prev = tl;
tl.next = p;
}
tl = p;
} while ((e = e.next) != null);
if ((tab[index] = hd) != null)
hd.treeify(tab);
}
}
2. TreeSet(二叉树)
- TreeSet()是使用二叉树的原理对新的add()的对象按照指定的顺序排序(升序、降序),每增加一个对象都会进行排序,将对象插入二叉树指定的位置。
- Integer 和String 对象都可以进行默认的 TreeSet 排序,而自定义类的对象是不可以的,自定义的类必须实现Comparable 接口,并覆写相应的compareTo()函数(或则向set构造器传参匿名内部类/lambda),才可以正常使用排序。
- 在覆写compare()函数时,要返回相应的值才能使 TreeSet 按照一定的规则来排序。
- 比较此对象与指定对象的顺序。如果该对象小于、等于或大于指定对象,则分别返回负整数,零或正整数。
-
3. LinkHashSet(HashSet+LinkedHashMap)
对于 LinkedHashSet 而言,它继承与 HashSet、又基于 LinkedHashMap 来实现的。
- LinkedHashSet 底层使用数组+双向链表, LinkedHashMap 来保存所有元素,它继承于HashSet,其所有的方法操作上又与 HashSet 相同,因此 LinkedHashSet 的实现上非常简单,只提供了四个构造方法,并通过传递一个标识参数,调用父类的构造器,底层构造一个 LinkedHashMap 来实现,在相关操作上与父类 HashSet 的操作相同,直接调用父类 HashSet 的方法即可。
- 对链表的链接操作在LinkedHashMap的newNode()中,Entry是继承了HashMap.Node的内部类
2.Map
1. HashMap(数组+链表+红黑树)
- HashMap 根据键的 hashCode 值存储数据,大多数情况下可以直接定位到它的值,因而具有很快的访问速度,但遍历顺序却是不确定的。
- 初始化大小为0,第一次扩容为16,加载因子为0.75,当map存放的元素数量超过当前数组长度的75%时,或者链表长度>=8而数组长度<64时,会触发扩容,扩容机制为原长度的2倍,只有当链表长度>=8且数组长度>=64时,才将链表转换为红黑树(源码参照set部分的笔记)
- HashMap 最多只允许一条记录的键为 null,允许多条记录的值为 null。
- HashMap 非线程安全,即任一时刻可以有多个线程同时写 HashMap,可能会导致数据的不一致。如果需要满足线程安全,可以用 Collections() 的 synchronizedMap() 方法使HashMap 具有线程安全的能力,或者使用 ConcurrentHashMap()。
- 线程不安全的原因:
- 多个线程并发执行put(),当重新调整HashMap大小的时候,就会存在条件竞争,因为如果两个线程都发现HashMap需要重新调整大小了,它们会同时试着调整大小。在调整大小的过程中,存储在链表中的元素的次序会反过来,因为移动到新的bucket位置的时候,HashMap并不会将元素放在链表的尾部,而是放在头部,这是为了避免尾部遍历(不然每插入一次就要遍历一次)。如果条件竞争发生了,那么就会产生死循环了(称为循环链表)。
:::danger
此处元素数量指的是map中存放元素的个数,75%则是节点数组的临界值,即不是占用了75%的数组索引才扩容,而是只要总数据量超过就扩容;元素数量并不是占用新的数组索引才增加,而是每存放一个元素就增加
:::
例:
在节点数组长度16的map中,扩容临界值为12,如果索引为1的位置存放了12个节点的链表,当在其他索引存放新的数据时,就会触发扩容机制(13>12)。而此时索引数组中只占用了2个索引位置,所以扩容机制判断的是map中总数据量>节点数组长度*75%,而不是节点数组内索引的占用个数。
- 多个线程并发执行put(),当重新调整HashMap大小的时候,就会存在条件竞争,因为如果两个线程都发现HashMap需要重新调整大小了,它们会同时试着调整大小。在调整大小的过程中,存储在链表中的元素的次序会反过来,因为移动到新的bucket位置的时候,HashMap并不会将元素放在链表的尾部,而是放在头部,这是为了避免尾部遍历(不然每插入一次就要遍历一次)。如果条件竞争发生了,那么就会产生死循环了(称为循环链表)。
:::danger
此处元素数量指的是map中存放元素的个数,75%则是节点数组的临界值,即不是占用了75%的数组索引才扩容,而是只要总数据量超过就扩容;元素数量并不是占用新的数组索引才增加,而是每存放一个元素就增加
:::
例:
map底层数据实际是存放在table数组的Node中的,但是因为map没有迭代器,所以为了方便遍历集合,会创建一个EntrySet
Java 7 实现:大方向上,HashMap 里面是一个数组,然后数组中每个元素是一个单向链表。
JAVA 8 实现:Java8 对 HashMap 进行了一些修改,最大的不同就是利用了红黑树,所以其由 数组+链表+红黑树 组成。
在 Java 7 HashMap 中,查找的时候,根据 hash 值能够快速定位到数组的具体下标,但是之后的话,需要顺着链表一个个比较下去才能找到我们需要的,时间复杂度取决于链表的长度,为 O(n)。为了降低这部分的开销,在 Java 8 中,当链表中的元素超过了 8 个以后,会将链表转换为红黑树,在这些位置进行查找的时候可以降低时间复杂度为 O(logN)。
2. ConcurrentHashMap
1.Segment 段
ConcurrentHashMap 和 HashMap 思路是差不多的,但是因为它支持并发操作,所以要复杂一
些。整个 ConcurrentHashMap 由一个个 Segment 组成,Segment 代表”部分“或”一段“的
意思,所以很多地方都会将其描述为分段锁。注意,行文中,我很多地方用了 “槽” 来代表一个
segment。
2.线程安全(Segment 继承 ReentrantLock 加锁)
简单理解就是,ConcurrentHashMap 是一个 Segment 数组,Segment 通过继承ReentrantLock 来进行加锁,所以每次需要加锁的操作锁住的是一个 segment,这样只要保证每个 Segment 是线程安全的,也就实现了全局的线程安全。
3.并行度(默认 16)
concurrencyLevel:并行级别、并发数、Segment 数,怎么翻译不重要,理解它。默认是 16,也就是说ConcurrentHashMap 有 16 个 Segments,所以理论上,这个时候,最多可以同时支持 16 个线程并发写,只要它们的操作分别分布在不同的 Segment 上。这个值可以在初始化的时候设置为其他值,但是一旦初始化以后,它是不可以扩容的。再具体到每个 Segment 内部,其实每个 Segment 很像之前介绍的 HashMap,不过它要保证线程安全,所以处理起来要麻烦些。
4.Java8 实现 (引入了红黑树)
Java8 对 ConcurrentHashMap 进行了比较大的改动,Java8 也引入了红黑树。
3. HashTable(线程安全)
- Hashtable 是遗留类,很多映射的常用功能与 HashMap 类似,不同的是它承自 Dictionary 类,并且是线程安全的,任一时间只有一个线程能写 Hashtable,并发性不如 ConcurrentHashMap,因为 ConcurrentHashMap 引入了分段锁。
- Hashtable 不建议在新代码中使用,不需要线程安全的场合可以用 HashMap 替换,需要线程安全的场合可以用 ConcurrentHashMap 替换。
- 不允许键和值存在Null
-
4. TreeMap(可排序)
TreeMap 实现 SortedMap 接口,能够把它保存的记录根据键排序,默认是按键值的升序排序,也可以指定排序的比较器,当用 Iterator 遍历 TreeMap 时,得到的记录是排过序的。
- 如果使用排序的映射,建议使用 TreeMap。
在使用 TreeMap 时,key 必须实现 Comparable 接口或者在构造 TreeMap 传入自定义的Comparator,否则会在运行时抛出 java.lang.ClassCastException 类型的异常。
5. LinkHashMap(记录插入顺序)
LinkedHashMap继承于HashMap,它在HashMap的基础上,通过维护一条双向链表,解决了HashMap不能随时保持遍历顺序和插入顺序一致的问题。在实现上,LinkedHashMap很多方法直接继承自HashMap,仅为维护双向链表重写了部分方法。
6. Properties
继承自HashTable类并实现了map接口,特点和HashTable类似
可以用于从xxx.properties配置文件中读取属性放入properties对象,进行读取和修改
3.Collections
collection的工具类,提供了更多的操作方法
reverse(list):反转list集合
- shuffle(list):对list随机排序
- sort():自然排序,也有重载的指定比较器排序
- swap(List,int,int):将集合中指定位置元素交换
- frequency(collection i):返回集合指定元素的出现次数
- synchronizedxxxxx():将指定的集合类型包装为线程安全