Java语言03之集合体系

1 集合基础

1.1 集合体系

Java集合,也叫作容器,主要是由两大接口派生而来:一个是Collection接口,主要用于存放单值元素。另一个是Map接口,主要用于存放键值对。

对于Collection接口,下面又有三个主要的子接口:List、Set和Queue。List表示列表,存储的元素是有序的、可重复的。Set代表的是集合,存储的元素是无序的、不可重复的。Queue是按特定的排队规则来确定先后顺序,存储的元素是有序的、可重复的。每个接口都有一些具体的实现类,常用的有ArrayList、LinkedList、ArrayDeque、PriorityQueue、HashSet、LinkedHashSet、 TreeSet等等。

Map接口用于保存key-value数据,key不允许重复,通过指定的key,能够找到对应的value。Map接口的实现类主要有HashMap、LinkedHashMap和TreeMap。

1.2 容器和数组

如果没有容器,当我们需要保存一组类型相同的数据的时候,只能选择数组,数组的缺点是一旦声明之后,长度就不可变了。此外,集合还提供了数组无法提供的功能,比如数据的有序性,不重复性,映射关系等。

选择容器主要根据容器的特点来选用,比如我们需要根据键值获取到元素值时就选用Map接口下的集合,需要排序时选TreeMap,不需要排序时就选HashMap。当我们只需要存放元素的值时,就选择实现Collection接口的集合,需要保证元素唯一时选择实现Set 接口的集合比如TreeSet或HashSet,不需要就选择实现List接口的比如ArrayList或LinkedList。此外,如果需要保证线程安全的话,那么就可以选择Vector、Stack、HashTable、JUC包下集合。

1.3 集合遍历

单列集合遍历:for循环、增强for、迭代器
双列集合遍历:

  • 使用迭代器(Iterator)EntrySet+ while…hasNext…next。
  • 使用迭代器(Iterator)KeySet+ while…hasNext…next。
  • 使用迭代器(Iterator)EntrySet+for循环。
  • 使用迭代器(Iterator)KeySet+for循环。
  • 使用Lambda表达式的方式进行遍历。
  • 使用迭代器(Iterator)EntrySet+Streams API单线程的方式进行遍历。
  • 使用迭代器(Iterator)EntrySet+Streams API多线程的方式进行遍历。

entrySet性能更好,因此遍历map对象时应该尽量使用entrySet。使用entrySet的性能更好是因为KeySet在循环时需要再使用map.get(key)查询key所对应的值。然而,EntrySet只遍历了一次map对象,之后通过代码“Entry<Integer, String> entry = iterator.next()”把对象的key和value值都放入到了Entry对象中,因此再获取key和value值时就无需再查询map对象,只需要从Entry对象中取值就可以了。所以,理论上遍历完整一次map对象,EntrySet的性能比KeySet的性能高出了一倍,因为KeySet相当于循环了两遍Map集合,而EntrySet只循环了一遍。

2 单列集合对比

2.1 Arraylist、Vector、LinkedList

  • 线程安全性:Vector底层使用Object数组存储,线程安全的。ArrayList底层使用Object数组存储,适用于频繁的查找工作,不保证线程安全。LinkedList底层使用的是双向链表数据结构,不保证线程安全。
  • 效率对比:ArrayList和Vector采用数组存储,随机插入与删除慢,但是查找快。LinkedList采用链表存储,所以随机插入与删除快,但是查找慢。

2.2 Deque和LinkedList

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

  • ArrayDeque和LinkedList都实现了Deque接口,两者都具有双端队列的功能。
  • ArrayDeque是基于可变长的数组和双指针来实现,而LinkedList则通过链表来实现。
  • ArrayDeque不支持存储NULL数据,但LinkedList支持。
  • ArrayDeque插入时可能存在扩容过程,而LinkedList不需要扩容。

2.3 基于Deque实现栈与队列

栈顶是Deque的first端,也就是索引为0:

栈方法Deque方法说明备注
push(e)addFirst(e)入栈,失败则抛出异常
offerFirst(e)入栈,失败则返回false推荐
pop()removeFirst()获取并删除栈顶元素,失败则抛出异常
pollFirst()获取并删除栈顶元素,失败则返回false推荐
peek()getFirst()获取栈顶元素,失败则抛出异常
peekFirst()获取栈顶元素,失败则返回false推荐

队头是Deque的first端,也就是索引为0:

队列方法Deque方法说明备注
add(e)addLast(e)入队,失败则抛出异常
offer(e)offerLast(e)入队,失败则返回false推荐
remove()removeFirst()获取并删除队首元素,失败则抛出异常
poll()pollFirst()获取并删除队首元素,失败则返回false推荐
element()getFirst()获取队首元素,失败则抛出异常
peek()peekFirst()获取队首元素,失败则返回false推荐

2.4 PriorityQueue

优先队列PriorityQueue与Queue的区别,在于PriorityQueue的元素的具有优先级,且优先级更高的先出队。PriorityQueue在面试中可能更多的会出现在手撕算法的时候,典型例题包括堆排序、求第K大的数、带权图的遍历等,所以需要熟练使用。

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

3 Map集合对比

3.1 HashSet

HashSet底层的实现其实是使用一个HashMap,只是将value固定了,把key作为HashSet的值。每次添加元素,使用的value都是同一个object对象。

3.2 TreeSet

TreeSet底层是红黑树,可以对对象元素进行排序,但是自定义类需要实现comparable接口,重写comparaTo()方法。TreeSet可以保证对象元素的唯一性(并不是一定保证唯一性,需要根据重写的comparaTo方法来确定)。

自定义类型的元素
public class Student implements Comparable{
	private int age;

    private String name;
	
    @Override
    public int compareTo(Object o) {
        Student student = (Student)o;
        return this.age - student.age;
    }
}

3.3 HashMap和Hashtable

  • HashMap是非线程安全的,Hashtable是线程安全的。因为Hashtable的方法基本都是同步方法,所以执行效率低一点。
  • HashMap可以存储null的key和value,但null作为键只能有一个,null值可以有多个。Hashtable不允许有null键和null值,否则会抛出空指针异常。
  • 容器初始容量:
    • 创建时如果没给定初始值:Hashtable默认的初始大小为11,之后每次扩充,容量变为原来的2n+1。HashMap默认的初始化大小为16,之后每次扩充,容量变为原来2倍
    • 创建时如果给定初始值:Hashtable会直接使用给定的大小,而HashMap会将其扩充为2的幂次方大小,也就是说HashMap总是使用2的幂作为哈希表的大小。
  • 底层数据结构:HashMap的底层是数组+链表或者红黑树,Hashtable的底层是数组+链表。

3.4 HashMap、LinkedHashMap和TreeMap

  • HashMap
    • JDK 7中,HashMap采用数组+链表。
    • JDK 8中,HashMap采用数组+链表/红黑树。
  • LinkedHashMap:LinkedHashMap继承自HashMap,所以它的底层仍然是基于数组和链表或红黑树组成。另外,LinkedHashMap在上面结构的基础上,增加了一条双向链表,使得上面的结构可以保持键值对的插入顺序。同时通过对链表进行相应的操作,实现了访问顺序相关逻辑。
  • TreeMap:TreeMap底层是利用红黑树实现的Map结构,底层实现是一棵平衡的排序二叉树,由于红黑树的插入、删除、遍历时间复杂度都为O(logN),所以性能上低于哈希表。但是哈希表无法提供键值对的有序输出,红黑树可以按照键的值的大小有序输出。

4 ArrayList源码分析

4.1 ArrayList构造函数(默认大小为10)

private static final int DEFAULT_CAPACITY = 10;	// 默认初始容量大小
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};

// 默认构造函数,使用初始容量10构造一个空列表(无参数构造)
public ArrayList() {
	this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}

public ArrayList(int initialCapacity) {
	if (initialCapacity > 0) {//初始容量大于0
		//创建initialCapacity大小的数组
		this.elementData = new Object[initialCapacity];
	} else if (initialCapacity == 0) {//初始容量等于0
		//创建空数组
		this.elementData = EMPTY_ELEMENTDATA;
	} else {//初始容量小于0,抛出异常
		throw new IllegalArgumentException("Illegal Capacity: "+ initialCapacity);
	}
}

4.2 为什么elementData数组加上transient修饰

transient关键字的作用是被修饰的变量不参与序列化的过程。

ArrayList在进行序列化时会调用重写后的writeObject()方法,该方法直接将elementData数组大小和所有元素写入ObjectOutputStream。在进行反序列化时调用重写后的readObject()方法,从ObjectInputStream获取数组大小和所有元素,再写入到elementData数组中。

这样做的好处是:因为elementData是一个缓存数组,它通常会预留一些容量,等容量不足时再进行扩容。采用上诉的方式来序列化,只需要序列化实际存储的元素,从而节省空间和时间。

4.3 ArrayList扩容机制

每次扩容,新容量都会变为原来的1.5倍。然后检查新容量是否大于最小所需容量,若还是小于最小所需容量,那么就把最小需要容量当作数组的新容量。然后根据新容量创建一个新数组,最后将旧数组进行复制到新数组。具体过程为:

  1. 添加元素时需要调用add()和addAll()
  2. 在add()或addAll()方法中,会去调用ensureCapacityInternal()计算当前的最小所需容量(最小所需容量并不是简单地计算当前数组容量+新增元素的个数,而是还需要和ArrayList的默认初试容量DEFAULT_CAPACITY进行比较)
  3. 在ensureCapacityInternal()方法中,计算最小所需容量后,最后还会调用ensureExplicitCapacity比较最小所需容量和当前数组容量,来判断是否需要扩容
  4. 如果需要扩容就调用grow()进行扩容,在这个方法中,会将容量变为原来的1.5倍。如果此时,新容量还是小于最小所需容量,那么就会将新容量更新为最小所需容量。最后,将数组进行复制到一个新的扩容后的数组。
// 步骤1:添加元素时调用add()
public boolean add(E e) {
	ensureCapacityInternal(size + 1);  // Increments modCount!!
	elementData[size++] = e;
	return true;
}
// 步骤1:添加元素时调用addAll ()
public boolean addAll(int index, Collection<? extends E> c) {ensureCapacityInternal(size + numNew);  // Increments modCount}

// 步骤2:调用ensureCapacityInternal()计算当前的最小所需容量
private void ensureCapacityInternal(int minCapacity) {
	// calculateCapacity是去计算最小所需容量的
	ensureExplicitCapacity(calculateCapacity(elementData, minCapacity));
}

// 步骤3:调用ensureExplicitCapacity比较最小所需容量和当前数组容量,来判断是否需要扩容
private void ensureExplicitCapacity(int minCapacity) {
	modCount++;
	if (minCapacity - elementData.length > 0)
		grow(minCapacity); // 调用grow方法进行扩容,调用此方法代表已经开始扩容了
}
private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;
// 需要扩容就调用grow()进行扩容,在这个方法中,会将容量变为原来的1.5倍。
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);
}

4.4 迭代器Iterator和fail-fast快速失败机制

迭代器是用来获取之后实现集合的遍历的,特点是只能单向遍历,但是更加安全。因为它有一种称为快速失败fail-fast的机制,可以确保,在当前遍历的集合中有元素被更改的时候,就会抛出并发修改异常。

快速失败fail-fast的机制是一种错误检测机制,当线程1在遍历集合的时候,线程2修改了集合,这时就会产生fail-fast机制抛出并发修改异常。

实现原理:迭代器在遍历时直接访问集合中的内容,并且在遍历过程中使用一个modCount变量。集合在被遍历期间如果内容发生变化,就会改变modCount的值。每当迭代器使用hashNext()/next()遍历下一个元素之前,都会检测modCount变量是否为expectedmodCount值,是的话就返回遍历。否则抛出异常,终止遍历。

final void checkForComodification() {
	if (modCount != expectedModCount)
		throw new ConcurrentModificationException();
}

5 HashMap源码

5.1 HashMap

JDK 7采用的是拉链法,也就是说创建一个链表数组table,数组中每个元素都是一个链表,而链表的每一个元素是一个node对象。若遇到哈希冲突,则将冲突的值加到链表中即可。

JDK 8在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认8)时,会调用 treeifyBin ()方法来决定是否转换为红黑树。只有当数组长度大于或者等于64的情况下,才会执行转换红黑树操作。否则,就是只是执行resize()方法对数组扩容。

5.2 HashMap扩容机制

扩容函数resize()主要是在调用put函数时调用到

  1. 首先判断数组table是否为null,如果是的话那么调用resize()创建Node数组。
  2. 否则根据key进行哈希运算得到插入的数组索引i:
    • 如果table[i]为null,说明这个位置没有链表,那么直接新建节点添加,插入成功。
    • 如果table[i]不为null,说明这个位置有链表或者红黑树了。
  3. 那么判断key是否存在,如果存在直接覆盖value。
  4. 如果key不存在的话,判断这个节点是否为红黑树节点,如果是红黑树,则直接在树中插入键值对。否则判断链表长度是否大于8,大于8的话会调用树化treeifyBin ()方法,在treeifyBin ()方法中,只有当数组长度大于或者等于64的情况下,才会执行转换红黑树操作。否则,就是只是执行resize()方法对数组扩容。如果链表长度小于8,直接进行链表的插入操作。
  5. 插入成功后,判断实际存在的键值对数量是否超多了最大容量(也就是数组长度*负载因子),如果超过,还需要进行扩容resize()。

5.3 resize()和rehash()

resize方法是在进行初始化或者hashmap中的键值对大于最大容量(也就是数组长度*负载因子)时,就调用resize方法进行扩容。resize方法每次对table数组进行扩展的时候,都是扩展2倍。resize方法调用后会伴随着一次rehash,也就是说会遍历所有的元素进行重新创建链表,扩展后节点要么在原位置,要么移动到原偏移量两倍的位置。因为进行rehash非常耗时,所以要尽量避免。如果提前预估存储的容量大小,可以设置大点的容量,这样可以少扩容几次,或者设计合理的加载因子,尽可能避免频繁的扩容。

5.4 HashMap哈希算法

  • JDK 7实现:4次位运算,5次异或运算(9次扰动)。
  • JDK 8实现:1次位运算和1次异或运算(2次扰动)
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
  • 为什么右移16位:如果哈希值的高位变化很大,低位变化很小,这样就很容易造成哈希冲突了。所以将哈希值右移16位再进行与运算是把哈希值的高低位都利用起来,从而解决这个问题。
  • 1次位运算和1次异或运算(2次扰动):加大哈希值低位的随机性,使得分布更均匀,从而提高对应数组存储下标位置的随机性&均匀性,最终减少Hash冲突。

5.5 HashMap哈希冲突

  • 使用2次扰动函数(hash函数)来降低哈希冲突的概率,使得数据分布更平均。
  • 使用链地址法来链接拥有相同table数组索引的数据。
  • 引入红黑树进一步降低遍历的时间复杂度,使得遍历更快。

5.6 HashMap的长度为什么是2的幂次方

这其实是一个数学计算上的优化,如果HashMap的长度是2的n次方,那么与key的hash值对长度取余的操作等价于key的hash值与长度减一的与操作,可以加快计算速度。

如果长度为2的幂次方,则长度减一转化为二进制必定是11111……的形式。如果长度不是2的次幂,比如为 15,则长度减一为14,对应的二进制为1110,在与key的hash值进行与操作时,最后一位都为 0,而 0001、0011、0101、1001、1011、0111和1101这几个位置永远都不能存放元素了,空间浪费相当大,同时会增加了碰撞的几率,使元素无法均匀分布。

5.7 HashMap、HashTable和ConcurrentHashMap

底层数据结构:

  • JDK 7中,HashMap采用数组+链表。JDK 8中,HashMap采用数组+链表/红黑树。
  • JDK 7中,ConcurrentHashMap采用分段的Segment数组+HashEntry链表实现。JDK 8中,ConcurrentHashMap采用数组+链表/红黑树。
  • HashTable采用数组+链表。

其他:

  • HashMap是非线程安全的。HashTable和ConcurrentHashMap是线程安全
  • HashMap的键值对允许有null,但是HashTable和ConcurrentHashMap都不允许。

5.8 ConcurrentHashMap in JDK 7

ConcurrentHashMap里包含一个Segment数组,**一个Segment对象和HashMap类似,也是一种数组和链表结构。**一个Segment对象管理一个HashEntry 数组,每个HashEntry是一个链表节点,当对 HashEntry的数据进行修改时,必须首先获得对应的Segment的锁。

本质上,Segment继承了ReentrantLock,每个Segment管理一个HashEntry数组。所以,如果想对HashEntry数组的数据进行修改时,必须首先获得对应的Segment锁。
在这里插入图片描述

5.9 ConcurrentHashMap in JDK 8

JDK 8中:放弃了Segment臃肿的设计,数据结构采用Node数组+链表+红黑树(在这一点上,结构又类似于HashMap了)。在并发控制上,ConcurrentHashMap采用CAS + Synchronized,synchronized只锁定当前链表或红黑二叉树的首节点,只要hash不冲突,就不会产生并发。
在这里插入图片描述

5.10 ConcurrentHashMap的put()

  • 插入时首先需要判断是否是首次添加元素,也就是判断node数组是否为空,如果为空的话需要初始化node数组,其大小默认为16。初始化node数组时,是通过自旋和CAS操作完成的。根据sizeCtl是否为小于0,则让出CPU。否则对node进行初始化,默认数组大小为16。
  • 如果不是首次添加元素,则判断链表头部是否为空,如果为空则使用CAS初始化链表头部,然后放入元素后直接break跳出方法。如果链表头部不为空,则判断其hash是否为-1,如果为-1表明正在扩容,此时会调用helpTransfer()协助扩容。
  • 如果不需要扩容,那么直接加锁进行插入数据。如果链表长度大于阈值8并且数组长度大于64则需要调用treeifyBin将链表转化为为红黑树。

在这里插入图片描述

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值