List
核心特点
- 存取有序
- 可以重复
- 有索引
常见实现类
ArrayList和LinkedList是Java集合框架中List接口的两个常见实现类,它们在底层实现和性能特点上有以下几点区别:
- 底层数据结构: ArrayList使用数组 来存储元素,而LinkedList使用双向链表来存储元素。
- 随机访问性能: ArrayList支持高效的随机访问(根据索引获取元素),因为它可以通过下标计算元素在数组中的位置。而LinkedList在随机访问方面性能较差,获取元素需要从头或尾部开始遍历链表找到对应位置。
- 插入和删除性能: ArrayList在尾部添加或删除元素的性能较好,因为它不涉及数组的移动。而在中间插入或删除元素时,ArrayList涉及到元素的移动,性能相对较低。LinkedList在任意位置进行插入和删除操作的性能较好,因为只需要调整链表中的指针即可。
综上所述,如果需要频繁进行随机访问操作或在尾部进行插入和删除操作,可以选择ArrayList。如果需要频繁进行中间位置的插入和删除操作,可以选择LinkedList。
Set
核心特点
- 不可以存储重复元素
- 没有索引,不能使用普通for循环遍历
常见实现类
- HashSet
- 底层数据结构是哈希表 (在 JDK 1.8 中,由数组 + 链表 + 红黑树组成)。它利用
HashMap的 key 机制来保证元素的唯一性,但不保证顺序。 - 存取无序
- 不可以存储重复元素
- 没有索引,不能使用普通for循环遍历
- 底层数据结构是哈希表 (在 JDK 1.8 中,由数组 + 链表 + 红黑树组成)。它利用
- TreeSet
- 底层数据结构是 红黑树(一种自平衡的二叉搜索树)。它保证元素的唯一性,并且根据元素的自然排序或指定的比较器进行有序存储
- 不可以存储重复元素
- 没有索引
- 可以将元素按照规则进行排序
- TreeSet():根据其元素的自然排序进行排序
- TreeSet(Comparator comparator) :根据指定的比较器进行排序
Queue
核心特点
Queue以及Deque都是继承于Collection,Deque是Queue的子接口。
Queue是FIFO的单向队列,Deque是双向队列。
Queue有一个直接子类PriorityQueue,而Deque中直接子类有两个:LinkedList以及ArrayDeque。
常见实现类
-
LinkedList
-
底层结构:基于双向链表实现
-
功能特性:
- 既可作为列表,也可作为队列或双端队列(Deque)使用
- 非线程安全,性能较高
- 允许存储
null元素
-
使用示例:
javaQueue<String> queue = new LinkedList<>(); queue.offer("A"); queue.offer("B"); System.out.println(queue.poll()); // 输出 A
-
-
ArrayDeque
-
底层结构:基于动态数组实现的双端队列
-
功能特性:
- 比
LinkedList更高效(尤其是随机访问和内存占用) - 不支持
null元素 - 非线程安全
- 比
-
使用示例:
javaQueue<String> queue = new ArrayDeque<>(); queue.offer("X"); System.out.println(queue.poll()); // 输出 X
-
-
PriorityQueue
-
底层结构:基于优先堆(二叉堆)实现的无界优先级队列
-
功能特性:
- 元素按自然顺序 或自定义
Comparator排序 - 出队顺序为优先级最高者(最小值优先,自然排序)
- 不支持
null元素 - 非线程安全
- 元素按自然顺序 或自定义
-
使用示例:
javaQueue<Integer> pq = new PriorityQueue<>(); pq.offer(3); pq.offer(1); pq.offer(2); System.out.println(pq.poll()); // 输出 1(最小值)
-
Map
核心特点
- 双列集合,一个键对应一个值
- 键不可以重复,值可以重复
常见实现类
- HashMap
- HashMap底层是哈希表结构的
- 依赖hashCode方法和equals方法保证键的唯一
- 如果键要存储的是自定义对象,需要重写hashCode和equals方法
- TreeMap
- reeMap底层是红黑树结构
- 依赖自然排序或者比较器排序,对键进行排序
- 如果键存储的是自定义对象,需要实现Comparable接口或者在创建TreeMap对象时候给出比较器排序规则
HashMap集合
简介
HashMap 是 非线程安全 的键值对存储结构,JDK 1.8 通过 链表→红黑树 优化哈希冲突场景,仅在链表>8 且数组>64 时 转换,确保查询效率最大化。
数据结构
在JDK1.8 之前 HashMap 由 数组+链表 数据结构组成的。
在JDK1.8 之后 HashMap 由 数组+链表 +红黑树数据结构组成的。
java
Map<String, Integer> map = new HashMap<>();
map.put("Apple", 10);
map.get("Apple")
HashMap成员变量
-
集合的初始化容量( 必须是二的n次幂 )
java//默认的初始容量是16 -- 1<<4相当于1*2的4次方---1*16 static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;问题: 为什么必须是2的n次幂?如果输入值不是2的幂比如10会怎么样?
HashMap构造方法还可以指定集合的初始化容量大小。
- 当我们根据key的hash确定其在数组的位置时,如果
n为2的幂次方,可以保证数据的均匀插入,如果n不是2的幂次方,可能数组的一些位置永远不会插入数据,浪费数组的空间,加大hash冲突。 - 另一方面,一般我们可能会想通过 % 求余来确定位置,这样也可以,只不过性能不如 & 运算。而且当n是2的幂次方时:hash & (length - 1) == hash % length
- 因此,HashMap 容量为2次幂的原因,就是为了数据的的均匀分布,减少hash冲突,毕竟hash冲突越大,代表数组中一个链的长度越大,这样的话会降低hashmap的性能
- 如果创建HashMap对象时,输入的数组长度是10,不是2的幂,HashMap通过一通位移运算和或运算得到的肯定是2的幂次数,并且是离那个数最近的数字。
- 当我们根据key的hash确定其在数组的位置时,如果
-
默认的负载因子,默认值是0.75
javastatic final float DEFAULT_LOAD_FACTOR = 0.75f; -
当链表的值超过8则会转红黑树(1.8新增)
java//当桶(bucket)上的结点数大于这个值时会转成红黑树 static final int TREEIFY_THRESHOLD = 8;问题:为什么Map桶中节点个数超过8才转为红黑树?
HashMap选择8作为链表转红黑树的阈值,是基于泊松分布的科学概率计算(链表长度≥8的概率仅6e-8),在空间(红黑树节点占用2倍内存)和时间(链表查询O(n) vs 红黑树O(log n))之间取得最佳平衡。同时要求数组长度≥64,是因为小数组扩容比转红黑树更高效,确保整体性能最优。
-
当链表的值小于6则会从红黑树转回链表
java//当桶(bucket)上的结点数小于这个值时树转链表 static final int UNTREEIFY_THRESHOLD = 6; -
当Map里面的数量超过这个值时,表中的桶才能进行树形化 ,否则桶内元素太多时会扩容,而不是树形化
java//桶中结构转化为红黑树对应的数组长度最小的值 static final int MIN_TREEIFY_CAPACITY = 64; -
table用来初始化(必须容量是二的n次幂)(重点)
java//存储元素的数组 transient Node<K,V>[] table; -
HashMap中存放元素的个数(重点)
java//存放元素的个数,注意这个不等于数组的长度。 transient int size; -
用来记录HashMap的修改次数
JAVA// 每次扩容和更改map结构的计数器 transient int modCount; -
用来调整大小下一个容量的值计算方式为(容量*负载因子)
JAVA// 临界值 当实际大小(容量*负载因子)超过临界值时,会进行扩容 int threshold; -
哈希表的加载因子(重点)
java// 加载因子 final float loadFactor;-
loadFactor 加载因子,是用来衡量 HashMap 满的程度,
表示HashMap的疏密程度,影响hash操作到同一个数组位置的概率loadFactor的默认值为0.75f是官方给出的一个比较好的临界值。
当HashMap里面容纳的元素已经达到HashMap数组长度的75%时,表示HashMap太挤了,需要扩容,而扩容这个过程涉及到 rehash、复制数据等操作,非常消耗性能。,所以开发中尽量减少扩容的次数,可以通过创建HashMap集合对象时指定初始容量来尽量避免。
-
为什么加载因子设置为0.75,初始化临界值是12?
loadFactor越趋近于1,那么 数组中存放的数据(entry)也就越多,也就越密,也就是会让链表的长度增加,loadFactor越小,也就是趋近于0,数组中存放的数据(entry)也就越少,也就越稀疏。
例如: 加载因子是0.4。 那么16*0.4--->6 如果数组中满6个空间就扩容会造成数组利用率太低了。 加载因子是0.9。 那么16*0.9---->14 那么这样就会导致链表有点多了。导致查找元素效率低。所以既兼顾数组利用率又考虑链表不要太多,经过大量测试0.75是最佳方案。
threshold计算公式:capacity(数组长度默认16) * loadFactor(负载因子默认0.75)。这个值是当前已占用数组长度的最大值。当Size>=threshold的时候,那么就要考虑对数组的resize(扩容),也就是说,这个的意思就是 衡量数组是否需要扩增的一个标准 。 扩容后的 HashMap 容量是之前容量的两倍。
-
new HashMap()
new HashMap() 的时候发生了什么?
值初始化了一个负载因子 this.loadFactor = DEFAULT_LOAD+FACTOR (0.75)
负载因子:数组大小不会改变,当到达某个阈值的时候,就需要进行扩容。(阈值 = 当前数组大小 * 负载因子)
HashMap采用的是一种"懒加载"模式,当向HashMap种put第一个元素的时候,HashMap的数组才会建立。
put
源码中put方法的过程:
-
调用
hash()(扰动函数)函数,将拿到key的hash值,table就是当前HashMap的结构,table是Node的数组对象。 -
判断table是否为空(第一次put值肯定为空),如果为空则调用
resize()新建一个table对象,默认的新建数组的长度是16。resize()方法十分复杂,但是做的事很简单(新建、扩容)
- 新建:如果table为空,则新建一个数组长度为16的table
- 扩容:如果是扩容操作,则新建一个数组长度为原数组长度两倍的新数组。把原数组的值放到新数组里,放的过程要对所有元素的Key的hash值与当前数组的长度-1取与(&)
-
判断
( n-1 ) & hash(数组索引计算,这种方式确保了索引在数组的有效范围内)这个位置上是否有值-
如果没有值:将 k , v 封装到Node对象当中(节点),赋值到 ( n-1 ) & hash 这个位置上
-
如果有值:
-
如果
hash值相同 && (key是同一个对象 || key值相同)则取代当前节点的值 -
节点p是不是
红黑树的节点,如果是,调用红黑树的存储方式 -
从
链表头开始查找比较key的值,如果next为null则新建一个Node放在next上。如果找到"一样的"就覆盖,如果都不一样,则添加到最后。添加的时候比较目前的链表长度是否 ≥ 8,如果是,则(扩容数组或改用红黑树的存储方式)注意:如果某个位置的链表长度 >= 8,但是整个HashMap的长度小于64,是扩充数组,而不是转为红黑树。
-
-
问答
-
HashMap中hash函数(扰动函数)是怎么实现的?
对于key的hashCode做hash操作,无符号右移16位然后做异或运算。这种处理方式的目的是将高位和低位的信息结合起来,从而减少哈希冲突,提高哈希表的性能。
java(h = key.hashCode()) ^ (h >>> 16) -
当两个对象的hashCode相等时会怎么样?
会产生哈希碰撞,若key值内容相同则替换旧的value.不然连接到链表后面,链表长度超过阈值8就转换为红黑树存储。
-
何时发生哈希碰撞和什么是哈希碰撞,如何解决哈希碰撞?
只要两个元素的key计算的哈希码值相同就会发生哈希碰撞。jdk8前使用链表解决哈希碰撞。jdk8之后使用链表+红黑树解决哈希碰撞。
-
如果两个键的hashcode相同,如何存储键值对?
hashcode相同,通过equals比较内容是否相同。
相同:则新的value覆盖之前的value
不相同:则将新的键值对添加到哈希表中
-
在不断的添加数据的过程中,会涉及到扩容问题,当超出临界值(且要存放的位置非空)时,扩容。默认的扩容方式:扩容为原来容量的2倍,并将原有的数据复制过来。threshold( 临界值) = capacity(容量) * loadFactor( 加载因子 )
-
JDK1.8为什么引入红黑树?这样结构的话不是更麻烦了吗
JDK 1.8 以前 HashMap 的实现是 数组+链表,即使哈希函数取得再好,也很难达到元素百分百均匀分布。当 HashMap 中有大量的元素都存放到同一个桶中时,这个桶下有一条长长的链表,这个时候 HashMap 就相当于一个单链表,假如单链表有 n 个元素,遍历的时间复杂度就是
O(n),完全失去了它的优势。针对这种情况,JDK 1.8 中引入了 红黑树(查找时间复杂度为O(logn))来优化这个问题。 当链表长度很小的时候,即使遍历,速度也非常快,但是当链表长度不断变长,肯定会对查询性能有一定的影响,所以才需要转成树。
get
源码中get方法的过程:
- 调用hash()方法将拿到key的hash值
- 拿到
( n-1 ) & hash这个位置的值- 如果如果hash值相同 && (key是同一个对象 || key值相同),直接返回
- 否则,链表中有下一个值。如果下一个值是红黑树的值,则调用红黑树获取节点的方式。反之则从链表头开始查找比较key的值,如果找到"一样的"就返回,如果都不一样返回null。
扩容机制
什么时候才需要扩容
当HashMap中的元素个数超过数组大小(数组长度)*loadFactor(负载因子)时,就会进行数组扩容,loadFactor的默认值(DEFAULT_LOAD_FACTOR)是0.75,这是一个折中的取值。也就是说,默认情况下,数组大小为16,那么当HashMap中的元素个数超过16×0.75=12(这个值就是阈值或者边界值threshold值)的时候,就把数组的大小扩展为2×16=32,即扩大一倍,然后重新计算每个元素在数组中的位置,而这是一个非常耗性能的操作,所以如果我们已经预知HashMap中元素的个数,那么预知元素的个数能够有效的提高HashMap的性能。
HashMap的扩容是什么
进行扩容,会伴随着一次重新hash分配,并且会遍历hash表中所有的元素,是非常耗时的。在编写程序中,要尽量避免resize。
HashMap在进行扩容时,使用的rehash方式非常巧妙,因为每次扩容都是翻倍,与原来计算的 (n-1)&hash的结果相比,只是多了一个bit位,所以我们在扩充HashMap的时候,不需要重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就可以了,是0的话索引没变,是1的话索引变成"原索引+oldCap(**原位置+旧容量**)"。
例如我们从16扩展为32时,具体的变化如下所示:
n:16 0000 0000 0000 0000 0000 0000 0001 0000
n-1:15 0000 0000 0000 0000 0000 0000 0000 1111
假设hashCode生成的值:
(n-1)&hash
hash1(key1): 1111 1111 1111 1111 0000 1111 0000 0101
hash2(key2): 1111 1111 1111 1111 0000 1111 0001 0101
--------------------------------------------------------------
0000 0000 0000 0000 0000 0000 0000 0101 计算出的索引是5
扩容 16*2 => 32
n:32 0000 0000 0000 0000 0000 0000 0010 0000
n-1:31 0000 0000 0000 0000 0000 0000 0001 1111
假设hashCode生成的值:
(n-1)&hash
hash1(key1): 1111 1111 1111 1111 0000 1111 0000 0101
hash2(key2): 1111 1111 1111 1111 0000 1111 0001 0101
--------------------------------------------------------------
0000 0000 0000 0000 0000 0000 0000 0101 计算出的索引是5
0000 0000 0000 0000 0000 0000 0001 0101 计算出的索引是5+16=21
说明:5是假设计算出来的原来的索引。这样就验证了上述所描述的:扩容之后所以节点要么就在原来的位置,要么就被分配到"原位置+旧容量"这个位置。