时间复杂度曲线图
Java集合框架图
为什么集合中只能存放封装类型
因为集合中是使用泛型标明集合中的对象类型的,而泛型则必须是包装类,也就是只能代表引用类型,而不是基本数据类型,比如 long/int/short/byte/double/float/boolean/char
那为什么泛型中只能是包装类型呢?
因为在程序中,有些结果数据可能会返回空值,转化为基本数据类型比如int就会发生异常,因为类似于int这些是没有NULL值可言的,但是基本数据类型对应的比如Integer这些包装类型就可以为NULL。
那么又为什么有基本数据类型呢?全部使用封装类型不可以么?
这主要是基于程序性能的考量,基本数据类型的定义是存放在栈中的,但是我们创建对象而引用出来的实际数值则是放在堆里的,堆的速度远远不如栈。而且基本数据类型变量的创建和销毁都非常快,而类定义的变量还需要JVM去销毁。
Collection
List
list 中有两个实现,分别是ArrayList和LinkedList,其中ArrayList的底层结构是数组,LinkedList底层是双向链表。它们的特点如下:
ArrayList 查找和修改元素比较快
那么为什么ArrayList会在查找和修改元素方面快呢?
因为它的内部数据结构是一个数组,数组具有以下特点:
- 随机访问: 数组中的元素可以通过索引进行随机访问,这意味着您可以快速找到数组中的任何元素,而不需要遍历整个集合。
- 内存连续性:数组中的元素在内存中是连续存储的,这有助于高效的内存访问,因为计算机每次读取都是按页进行数据读取的。比如4k,这样会减少访问内存的开销。
- 低级别数据结构: 数组是一种低级别的数据结构,它们通常比高级别数据结构(如链表)更轻量和快速。
为什么ArrayList增加和删除元素比较慢呢?
同样还是和底层数据结构是数组相关,在ArrayList 添加元素时需要判断是否需要扩容,但是数组扩容是新开辟一块内存空间,将原数组中的数据再复制过去。
具体源码实现:
扩容机制:
- 当向‘ArrayList’中添加元素时,如果数组已满,它将进行扩容。默认情况下,每次扩容会将当前数组的容量增加一半。
- 扩容操作涉及创建一个新的更大的数组,将现有的元素复制到新数组中。基于此,在我们使用ArrayList 时最好是在初始化时确定好容量,避免ArrayList再进行扩容
- 扩容的源码:
private void grow(int minCapacity) {
// 计算新容量,为原容量的 1.5 倍
int oldCapacity = elementData.length;
int newCapacity = oldCapacity + (oldCapacity >> 1);
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity;
// 复制数据到新数组
elementData = Arrays.copyOf(elementData, newCapacity);
}
缩容机制:
- 在‘ArrayList’中,不会自动进行缩容,因为缩容可能会导致浪费系统资源。‘ArrayList’会维持数组的当前大小,即使元素被移除。
- 如果需要手动缩小‘ArrayList’的容量,可以使用‘trimToSize’方法。这个方法将‘ArrayList’的容量调整为当前元素的数量。
- 缩容的源码:
public void trimToSize() {
modCount++;
int oldCapacity = elementData.length;
if (size < oldCapacity) {
elementData = Arrays.copyOf(elementData, size);
}
}
那既然删除元素不会涉及到数组的缩容,为什么数组的删除相较于链表会慢呢?
因为它的删除涉及到元素的移动操作,尤其是在删除元素后,需要将后续元素向前移动,以填补删除元素的空缺。这个移动操作可能导致性能开销。
当从‘ArrayList’中删除一个元素时,通常需要执行以下步骤:
- 查找要删除的元素的索引。
- 移动位于删除元素之后的所有元素,以填补删除元素的位置。
- 更新‘ArrayList’的大小。
总结: 基于上面的了解,可以知道为什么说数组的增加和删除比较慢了
LinkedList 增加和删除比较快快
那么为什么LinkedList在增加和删除比较快呢?
因为它的底层数据结构是一个双向链表,每个元素节点包含指向前一个节点和后一个节点的引用,这种数据结构的特点决定了它在插入和删除元素时的优势:
- 插入和删除效率高: 由于链表中的元素节点只需要调整相邻节点的引用,而无需移动大块内存,因此在插入和删除元素时非常高效。插入或删除元素仅涉及修改相邻节点的引用,而不需要对整个数据结构进行大规模的复制或移动操作。
- 没有元素移动: 与‘ArrayList’不同,‘LinkedList’不需要移动元素来填补删除的空白或腾出空间,因此不会导致大量的数据复制操作。
基于以上两点,我们可以知道‘LinkedList’在插入和删除方面的效率较高,所以它在需要频繁执行这些操作的场景中表现更出色。这包括在列表的中间插入或删除元素,或者需要高度动态性的数据结构。
需要注意的是,虽然‘LinkedList’在插入和删除方面性能较好,但在查找元素和随机访问方面通常比‘ArrayList’慢,因为链表在查找元素时需要遍历链表,而‘ArrayList’可以通过索引直接访问元素。因此,根据具体的应用场景和操作模式来考虑选择数据结构。
Map
HashMap特点
- HashMap 是无序且不安全的数据结构
- HashMap 是以key-value对的形式存储的,key值是唯一的(可以为NULL),一个key只能对应着一个value,但是value是可以重复的。
- 如果要使用自定义对象作为key,需要重写hashCode方法和equals方法,hashCode返回对象的hash码值,equals是比较对象hash码值的方法。
为什么要同时重写hashCode 和 equals 两个方法呢?
- equals()方法: 用于比较两个对象是否相等。当你在‘Map’中使用自定义对象作为键时,‘Map’会使用‘equals()’方法来检查是否已经存在一个与给定键值对象相等的键。因此,如果两个键对象相等,它们的‘equals()’方法应该返回‘true’。
- hashCode方法: 返回对象的的哈希码,用于确定键在‘Map’内部存储结构中的位置。当你将自定义对象作为键放入‘Map’中时,‘Map’使用‘hashCode()’方法计算键的哈希码,并将其用于确定键的存储位置。因此,如果两个键对象相等(根据equals()方法的定义),它们的‘hashCode()’方法应该返回相同的哈希码。
在自定义对象作为‘Map’的键时,确保实现了适当的‘equals()’和‘hashCode()’方法非常重要,否则可能会导致在‘Map’中无法正确查找或更新键值对。如果不重写‘hashCode()’方法,那么‘Map’将无法正确定位存储位置,导致无法访问正确的值。同样,如果不重写‘equals()’方法,‘Map’将无法确定比较键的相等性。
要注意,‘equals()’和‘hashCode()’方法的实现应该保持一致性,即如果两个对象通过‘equals()’方法相等,则它们的‘hashCode()’方法应该返回相同的值。
HashMap添加数据流程:
Q:HashMap如何设定初始容量大小? A:一般如果new HashMap()使用无参构造时,默认大小是16.负载因子是0.75,如果自己传入初始大小k,初始化大小为 大于k的2的整数次方,例如: 传10,初始化容量为16
JDK7与JDK8的HashMap区别
- JDK8中添加了红黑树,当链表长度大于等于8时,链表会变成红黑树。当红黑树的节点数量小于6时会变成链表。定义为8的原因是在负载因子0.75的情况下,单个hash槽内元素个数为8的概率小于百万分之一,将7作为分水岭,等于7时不做转换,大于等于8才转红黑树,小于等于6才转链表,链表中元素个数为8时的概率已经非常小,再多的就更少了,所以原作者在选择链表元素个数时选择了8,是根据概率统计而选择的。
红黑树中的TreeNode是链表中的Node所占空间的2倍,虽然红黑树的查找效率为O(logN),要优于链表的O(N),但是当链表长度比较小的时候,即使全部遍历,时间复杂度也不会太高。所以,要寻找一种时间和空间的平衡,即在链表长度达到一个阈值之后再转换为红黑树。 2. 链表新节点插入链表的顺序不同,JDK7使用头插法,JDK8采用尾插法。 3. hash算法简化 JDK8
HashMap的扩容机制
什么时候触发扩容?
一般情况下,当元素数量超过阈值时便会触发扩容。每次扩容的容量都是之前容量的2倍。 HashMap的容量是有上限的,必须小于1<<30,即1073741824。如果容量超出了这个数,则不再增长,且阈值会被设置为Integer.MAX_VALUE( 2 −1 ,即永远不会超出阈值了)。
阈值的计算: 阈值 = 当前容量 x 负载因子
HashMap扩容后是否需要rehash?
需要,因为要重新计算旧数组元素在新数组地址。HashMap在JDK1.8中的rehash算法(也就是扩容后重新为里面的键值对寻址的算法)进行优化。hash寻址算法是 index =(n - 1) & hash
在JDK1.7的时候,是将数组扩容为两倍,然后将HashMap中所有的key重新进行hash寻址然后再放入到新的位置。在JDK1.8的HashMap的源码中,也将rehash算法最后寻址分为了两种情况:
扩容前,key1和key2的hash值不同,但是通过hash寻址算法后索引相同;扩容后,key1和key2的hash值不同,通过hash寻址算法后索引不同。
元素在重新计算hash(rehash)之后,因为n变为2倍,那么n-1的mask范围在高位多1bit(红色),未扩容时,hash值的这个位置的值无论是0还是1对最终的结果都没有影响,因为对应的 n - 1的这个位置的值是0,进行&操作之后,无论何时都是0,而扩容之后hash值的这个位置,如果是0,那么rehash之后还是在原来的位置index;如果是1,那么rehash之后的位置是原来的位置 + 扩容前的数组容量,即index + oldCap。因此新的index就会发生这样的变化:
为什么jdk1.7的头插法会出现死链
原因:
- jdk1.7中HashMap添加元素使用了头插法
- HashMap线程不安全,在并发情况下触发了扩容
产生过程:
场景:假设有一个已经达到阈值并且再添加一个键值对就会触发扩容的HashMap,现在有两个线程往里面添加新键值对,此时可能会出现死链
死循环是因为并发 HashMap 扩容导致的,并发扩容的第一步,线程 T1 和线程 T2 要对 HashMap 进行扩容操作,此时 T1 和 T2 指向的是链表的头结点元素 A,而 T1 和 T2 的下一个节点,也就是 T1.next 和 T2.next 指向的是 B 节点,如下图所示:
死循环的第二步操作是,线程 T2 时间片用完进入休眠状态,而线程 T1 开始执行扩容操作,一直到线程 T1 扩容完成后,线程 T2 才被唤醒,扩容之后的场景如下图所示:
从上图可知线程 T1 执行之后,因为是头插法,所以 HashMap 的顺序已经发生了改变,但线程 T2 对于发生的一切是不可知的,所以它的指向元素依然没变,如上图展示的那样,T2 指向的是 A 元素,T2.next 指向的节点是 B 元素。
有序的LinkedHashMap
LinkedHashMap 内部维护了一个单链表,有头尾节点,同时 LinkedHashMap 节点 Entry 内部除了继承 HashMap 的 Node 属性,还有 before 和 after 用于标识前置节点和后置节点。可以实现按插入的顺序或访问顺序排序。
有序的TreeMap
TreeMap 是按照 Key 的自然顺序或者 Comprator 的顺序进行排序,内部是通过红黑树来实现。所以要么 key 所属的类实现 Comparable 接口,或者自定义一个实现了 Comparator 接口的比较器,传给 TreeMap 用户 key 的比较。
安全的HashTable
HashTable是直接在操作方法上加synchronized关键字,锁住每个方法,粒度比较大。
Collections.synchronizedmap是使用Collections集合工具的内部类,通过传入Map封装出一个SynchronizedMap对象,内部定义了一个对象锁,方法内通过对象锁实现。
ConcurrentHashMap使用分段锁,降低了锁粒度,让并发度大大提高