欢迎大家关注我的微信公众号:吾城十二楼,文章是从公众号直接复制粘贴过来的,排版不太美观。
10、JAVA集合
集合相关类和接⼝都在java.util中,主要分为3种:List(列表)、Map(映射)、Set(集)。
其中 Collection 是集合 List 、 Set 的⽗接⼝,它主要有两个⼦接⼝:
List :存储的元素有序,可重复。
Set :存储的元素⽆序,不可重复。
Map 是另外的接⼝,是键值对映射结构的集合。
1、List
(1)ArrayList:底层数据结构是数组,查询快,增删慢,线程不安全,效率高,可以存储重复元素。
ArrayList 实现了RandomAccess 接口, RandomAccess 是一个标志接口,表明实现这个这个接口的 List 集合是支持快速随机访问的。在 ArrayList 中,我们即可以通过元素的序号快速获取元素对象,这就是快速随机访问。get(int index)可以直接通过数组下标获取,时间复杂度是O(1)。
ArrayList增删如果是数组末尾的位置,直接插⼊或者删除就可以了,但是如果插⼊中间的位置,就需要把插⼊位置后的元素都向前或者向后移动,甚⾄还有可能触发扩容,这就是为什么它增删慢的原因。
ArrayList 的底层是数组队列,相当于动态数组。与 Java 中的数组相比,它的容量能动态增长。在添加大量元素前,应用程序可以使用 ensureCapacity 操作来增加 ArrayList 实例的容量。
在内存占⽤方面,ArrayList基于数组,是⼀块连续的内存空间,由于是预先定义好的数组,可能会有空的内存空间,存在⼀定空间浪费。
扩容技术核心源码:
/**
* ArrayList扩容的核心方法。
*/
private void grow(int minCapacity) {
// oldCapacity为旧容量,newCapacity为新容量
int oldCapacity = elementData.length;
//将oldCapacity 右移一位,其效果相当于oldCapacity /2,
//我们知道位运算的速度远远快于整除运算,整句运算式的结果就是将新容量更新为旧容量的1.5倍,
int newCapacity = oldCapacity + (oldCapacity >> 1);
//然后检查新容量是否大于最小需要容量,若还是小于最小需要容量,那么就把最小需要容量当作数组的新容量,
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity;
//再检查新容量是否超出了ArrayList所定义的最大容量,
//若超出了,则调用hugeCapacity()来比较minCapacity和 MAX_ARRAY_SIZE,
//如果minCapacity大于MAX_ARRAY_SIZE,则新容量则为Interger.MAX_VALUE,否则,新容量大小则为 MAX_ARRAY_SIZE。
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);
}
ArrayList 实现了Cloneable 接口,即覆盖了函数 clone(),能被克隆。
ArrayList 实现java.io.Serializable 接口,这意味着ArrayList支持序列化,能通过序列化去传输。
ArrayList 中的操作不是线程安全的!所以,建议在单线程中才使用 ArrayList,而在多线程中可以选择 Vector 或者 CopyOnWriteArrayList。
(2)LinkedList 底层数据结构是链表,查询慢,增删快,线程不安全,效率高,可以存储重复元素。
LinkedList基于链表的数据结构,地址是任意的,所以在开辟内存空间的时候不需要等一个连续的地址,对于新增和删除操作add和remove,LinedList比较占优势。LinkedList 适用于要头尾操作或插入指定位置的场景
缺点:因为LinkedList要移动指针,所以查询操作性能比较低。LinkedList基于链表,所以它没法根据序号直接获取元素,它没有实现RandmoAccess 接⼝,标记不⽀持随机访问。
它在空间占⽤上都有⼀些额外的消耗,LinkedList每个节点,需要存储前驱和后继,所以每个节点会占⽤更多的空间。
LinkedList类中的一个内部私有类Node
private static class Node<E> {
E item;//节点值
Node<E> next;//后继节点
Node<E> prev;//前驱节点
Node(Node<E> prev, E element, Node<E> next) {
this.item = element;
this.next = next;
this.prev = prev;
}
}
这个类就代表双端链表的节点Node。这个类有三个属性,分别是前驱节点,本节点的值,后继节点。
add(E e) 方法:将元素添加到链表尾部
public boolean add(E e) {
linkLast(e);
return true;
}
/**
* 链接使e作为最后一个元素。
*/
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;//指向后继元素也就是指向下一个元素
size++;
modCount++;
}
add(int index,E e):在指定位置添加元素
public void add(int index, E element) {
checkPositionIndex(index); //检查索引是否处于[0-size]之间
if (index == size)//添加在链表尾部
linkLast(element);
else//添加在链表中间
linkBefore(element, node(index));
}
void linkBefore(E e, Node<E> succ) {
final Node<E> pred = succ.prev;
final Node<E> newNode = new Node<>(pred, e, succ);
succ.prev = newNode;
if (pred == null)
first = newNode;
else
pred.next = newNode;
size++;
modCount++;
}
LinkedList不是线程安全的,如果想使LinkedList变成线程安全的,可以调用静态类Collections类中的synchronizedList方法:
List list=Collections.synchronizedList(new LinkedList(...));
(3)Vector:底层数据结构是数组,查询快,增删慢,线程安全,效率低,可以存储重复元素
Vector的数据结构和ArrayList差不多,它包含了3个成员变量:elementData , elementCount, capacityIncrement。
elementData 是"Object[]类型的数组",它保存了添加到Vector中的元素。elementData是个动态数组,如果初始化Vector时,没指定动态数组的>大小,则使用默认大小10。
capacityIncrement是与容量增长相关的增长系数,当Vector容量不足以容纳全部元素时,Vector的容量会增加。若容量增加系数 >0,则将容量的值增加“容量增加系数”;否则,将容量大小增加一倍。
elementCount 是动态数组的实际大小。
它支持线程的同步,即某一时刻只有一个线程能够写 Vector,避免多线程同时写而引起的不一致性,但实现同步需要很高的花费,因此,访问它比访问 ArrayList 慢。
(4)CopyOnWriteArrayList是Java集合框架中的一种并发容器。它是ArrayList的线程安全版本,采用了一种特殊的“写时复制”策略,即每次修改操作都会先复制一份当前集合,然后再对新的副本进行修改,最后再用新的副本替换旧的副本。这种策略能够保证并发操作时不会影响到其他线程的读操作,从而保证线程安全性。
并发访问时不需要使用额外的同步机制,因为每个线程访问的都是不同的副本,读取操作不需要加锁,所以CopyOnWriteArrayList在读取操作上具有很高的性能。
CopyOnWriteArrayList适合于读多写少的场景,例如日志、观察者模式等。每次修改都需要复制整个数组导致写入操作会产生较大的开销,所以在高并发写入的场景下不适合使用。CopyOnWriteArrayList的写入操作有一定的延迟,因此不适用于实时数据的场景,例如股票、期货等实时数据的更新。
在使用CopyOnWriteArrayList时要注意规避一些常见的性能陷阱:
public class ListUsageGuide {
private final CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>();
// 避免频繁修改操作
public void wrongUsage() {
// 错误示范:频繁修改导致性能问题
for (int i = 0; i < 10000; i++) {
list.add("item" + i); // 每次add都会复制整个数组
}
}
// 正确的使用方式
public void correctUsage() {
// 正确示范:批量操作
ArrayList<String> temp = new ArrayList<>();
for (int i = 0; i < 10000; i++) {
temp.add("item" + i);
}
list.addAll(temp); // 只复制一次数组
}
}
2、Set
是一种不允许包含重复元素的集合。它继承自Collection接口,Set的主要实现类有HashSet、LinkedHashSet和TreeSet。
-
不允许重复:Set中的元素是唯一的,如果添加重复元素,Set会忽略该操作。
-
无序性:Set中的元素没有特定的顺序(除了LinkedHashSet和TreeSet)。
-
允许null元素:Set允许包含一个null元素。
(1)HashSet
-
基于哈希表实现,元素无序。
-
允许null元素。
-
不是线程安全的。
-
使用场景:需要快速查找元素,且不关心元素的顺序
HashSet如何检查重复?
HashSet会通过元素的hashcode()和equals方法进行判断元素是否重复。
当你试图把对象加入HashSet时,HashSet会使用对象的hashCode来判断对象加入的位置。同时也会与其他已经加入的对象的hashCode进行比较,如果没有相等的hashCode,HashSet就会假设对象没有重复出现。
简单一句话,如果对象的hashCode值是不同的,那么HashSet会认为对象是不可能相等的。
因此我们自定义类的时候需要重写hashCode,来确保对象具有相同的hashCode值。
如果元素(对象)的hashCode值相同,是不是就无法存入HashSet中了? 当然不是,会继续使用equals 进行比较.如果 equals为true 那么HashSet认为新加入的对象重复了,所以加入失败。如果equals 为false那么HashSet 认为新加入的对象没有重复.新元素可以存入.
(2) LinkedHashSet
-
继承自HashSet,基于哈希表和链表实现。
-
元素按照插入顺序排列。
-
允许null元素。
-
不是线程安全的。
-
使用场景:需要快速查找元素,且需要保持插入顺序。
(3) TreeSet
-
基于红黑树实现,元素有序。
-
不允许null元素。
-
不是线程安全的。
-
使用场景:需要元素有序且快速查找。
Java提供了以下几种线程安全的Set实现:
1) Collections.synchronizedSet:将普通Set转换为线程安全的Set
Set<String> synchronizedSet = Collections.synchronizedSet(new HashSet<>());
2) CopyOnWriteArraySet:线程安全的Set实现,适合读多写少的场景
import java.util.concurrent.CopyOnWriteArraySet;
Set<String> set = new CopyOnWriteArraySet<>();
3、Map
Map用于保存具有映射关系的数据,Map集合里保存着两组值,一组用于保存Map的ley,另一组保存着Map的value。
Map中包括一个内部类Entry,该类封装一个键值对,常用方法:
-
Object getKey():返回该Entry里包含的key值;
-
Object getvalue():返回该Entry里包含的value值;
-
Object setValue(V value):设置该Entry里包含的value值,并设置新的value值。
(1)HashMap
JDK1.8 之前 HashMap 由 数组+链表 组成的,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的.
横向是一个数组,然后数组中每个元素是一个单向链表。上图中,每个绿色的实体是嵌套类 Entry 的实例,Entry 包含四个属性:key, value, hash 值和用于单向链表的 next。
查找的时候,根据 hash 值我们能够快速定位到数组的具体下标,但是之后的话,需要顺着链表一个个比较下去才能找到我们需要的,时间复杂度取决于链表的长度,为 O(n)。为了降低这部分的开销,在 Java8 中,当链表中的元素超过了 8 个以后,会将链表转换为红黑树,在这些位置进行查找的时候可以降低时间复杂度为 O(logN)。
Java8 由 数组+链表+红黑树 组成。数组是⽤来存储数据元素,链表是⽤来解决冲突,红⿊树是为了提⾼查询的效率。
-
数据元素通过映射关系,也就是散列函数,映射到桶数组对应索引的位置
-
如果发⽣冲突,从冲突的位置拉⼀个链表,插⼊冲突的元素
-
如果链表⻓度>8 &并且数组⼤⼩>=64,链表转为红⿊树
-
如果红⿊树节点个数<6 ,转为链表
【重要】在JDK8中,HashMap中的链表转换为红黑树需要满足以下两个条件:
1)链表长度条件:
数组 arr[i] 处存放的链表长度大于8。当链表长度增长到这个阈值时,就有可能需要转换为红黑树结构,这是因为较长的链表会导致查询效率降低,而红黑树结构可以提高查询性能。
2)数组长度条件:
数组的长度大于64。如果数组长度小于64,即使链表长度大于8,也不会直接将链表转换为红黑树,而是先进行扩容操作。这是因为在数组长度较小时,扩容可能会使链表长度降低,从而避免不必要的红黑树转换,因为红黑树的操作相对链表来说较为复杂,在数据量较小时链表可能已经足够高效。
当同时满足这两个条件时,数组 arr[i] 处的链表将自动转化为红黑树,而其他位置如 arr[i + 1] 处的数组元素仍为链表,不受影响。
HashMap 的 put
方法具体流程
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
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. 初始化数组
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 2. 定位桶,若桶空则新建节点
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
// 3. 桶首节点匹配
if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 4. 红黑树处理
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
// 5. 遍历链表
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
// 链表转红黑树
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;
return oldValue;
}
}
++modCount;
// 6. 检查扩容
if (++size > threshold)
resize();
return null;
}
HashMap 的扩容机制
当前元素数量超过 threshold
(扩容阈值,即 容量 × 负载因子
)时,触发扩容。
首先会创建新数组:新数组容量为旧数组的 2 倍,例如旧容量 16,新容量 32。然后,迁移元素,遍历旧数组每个桶,重新计算元素在新数组的位置并复制。由于容量是 2 的幂次,元素新位置要么在原位置,要么在原位置 + 旧容量。
假设初始容量为 16,负载因子 0.75,阈值为 12。当添加第 13 个元素时:触发扩容,创建容量为 32 的新数组。遍历旧数组,每个元素重新计算哈希:若元素原桶索引为 i,新索引可能是 i 或 i + 16。例如,旧数组中某元素哈希值与 15(16-1)按位与得 5,扩容后与 31(32-1)按位与,若高位变化,新索引为 5 + 16 = 21。
HashMap不是线程安全的
多线程的 put 可能导致元素的丢失。多线程同时执行 put 操作,如果计算出来的索引位置是相同的,那会造成前一个 key 被后一个 key 覆盖,从⽽而导致元素的丢失。
put 和 get 并发时,可能导致 get 为 null。线程 1 执⾏行行 put 时,因为元素个数超出 threshold 而导致rehash,线程 2 此时执⾏行行 get,有可能导致这个问题。
线程安全的 Map
-
HashTable 是直接在操作方法上加 synchronized 关键字,锁住整个table数组,粒度比较大;
-
Collections.synchronizedMap 是使⽤用 Collections 集合工具的内部类,通过传入 Map 封装出一个SynchronizedMap 对象,内部定义了一个对象锁,方法内通过对象锁实现;
-
ConcurrentHashMap 在jdk1.7中使用分段锁,在jdk1.8中使用CAS+synchronized。
HashMap是无序的
根据 hash 值随机插入。如果想使用有序的Map,可以使用LinkedHashMap 或者TreeMap。
(2)ConcurrentHashmap
ConcurrentHashmap线程安全在jdk1.7版本是基于分段锁实现,在jdk1.8是基于 CAS+synchronized 实现。
1.7版本的ConcurrentHashMap采用分段锁机制,里面包含一个Segment数组,每个Segment都是一个HashMap,默认的Segment长度是16,也就是支持16个线程的并发写,Segment之间相互不会受到影响。
1.8 对 ConcurrentHashMap 也引入了红黑树,和HashMap是一样的,数组+链表+红黑树,它实现线程安全的关键点在于put流程。
(3)HashTable
Hashtable 是遗留类,很多映射的常用功能与 HashMap 类似,且是线程安全的,任一时间只有一个线程能写 Hashtable,并发性不如 ConcurrentHashMap,因为 ConcurrentHashMap 引入了分段锁。Hashtable 不建议在新代码中使用,不需要线程安全的场合可以用 HashMap 替换,需要线程安全的场合可以用 ConcurrentHashMap 替换。
(4)LinkedHashMap
通过维护一个运行于所有条目的双向链表,LinkedHashMap保证了元素迭代的顺序。该迭代顺序可以是插入顺序或者是访问顺序。LinkedHashMap可以认为是HashMap+LinkedList,即它既使用HashMap操作数据结构,又使用LinkedList维护插入元素的先后顺序。
循环双向链表的头部存放的是最久访问的节点或最先插入的节点,尾部为最近访问的或最近插入的节点,迭代器遍历方向是从链表的头部开始到链表尾部结束,在链表尾部有一个空的header节点,该节点不存放key-value内容,为LinkedHashMap类的成员属性,循环双向链表的入口。
关于LinkedHashMap的源码解读,详细查看下面这篇文章,很不错。
https://www.cnblogs.com/xiaoxi/p/6170590.html
它可以实现按插入的顺序或访问顺序排序。
LinkedHashMap只定义了两个属性:
/**
* The head of the doubly linked list.
* 双向链表的头节点
*/
private transient Entry<K,V> header;
/**
* The iteration ordering method for this linked hash map: true
* for access-order, false for insertion-order.
* true表示最近最少使用次序,false表示插入顺序
*/
private final boolean accessOrder;
就是这个accessOrder,它表示:
(1)false,所有的Entry按照插入的顺序排列
(2)true,所有的Entry按照访问的顺序排列
LinkedHashMap重写了父类HashMap的get方法,实际在调用父类getEntry()方法取得查找的元素后,再判断当排序模式accessOrder为true时(即按访问顺序排序),先将当前节点从链表中移除,然后再将当前节点插入到链表尾部。由于的链表的增加、删除操作是常量级的,故并不会带来性能的损失。
/**
* 通过key获取value,与HashMap的区别是:当LinkedHashMap按访问顺序排序的时候,会将访问的当前节点移到链表尾部(头结点的前一个节点)
*/
public V get(Object key) {
// 调用父类HashMap的getEntry()方法,取得要查找的元素。
Entry<K,V> e = (Entry<K,V>)getEntry(key);
if (e == null)
return null;
// 记录访问顺序。
e.recordAccess(this);
return e.value;
}
/**
* 在HashMap的put和get方法中,会调用该方法,在HashMap中该方法为空
* 在LinkedHashMap中,当按访问顺序排序时,该方法会将当前节点插入到链表尾部(头结点的前一个节点),否则不做任何事
*/
void recordAccess(HashMap<K,V> m) {
LinkedHashMap<K,V> lm = (LinkedHashMap<K,V>)m;
//当LinkedHashMap按访问排序时
if (lm.accessOrder) {
lm.modCount++;
//移除当前节点
remove();
//将当前节点插入到头结点前面
addBefore(lm.header);
}
}
/**
* 移除节点,并修改前后引用
*/
private void remove() {
before.after = after;
after.before = before;
}
private void addBefore(Entry<K,V> existingEntry) {
after = existingEntry;
before = existingEntry.before;
before.after = this;
after.before = this;
}
如果accessOrder为false。那么默认就是插入顺序,直接通过链表的特点就能依次找到插入元素,不用做特殊处理。