文章目录
详细介绍一下java集合容器及hashMap工作原理
一、Collection 、Map
java.util.Collection 是一个集合接口(集合类的一个顶级接口)。它提供了对集合对象进行基本操作的通用接口方法。
ArrayList
:底层数组,排列有序可重复,速度快,增删慢,线程不安全,容量不够时,当前容量1.5倍+1;
Vector
:底层数组,排列有序可重复,速度快,增删慢,线程安全,容量不够时,默认扩展一倍容量;
LinkedList
:底层双向循环链表结构,排列有序,可重复,线程不安全,查询慢,增删快;
HashSet
:底层哈希表,无序不可重复,内部是HashMap,存取速度快;
TreeSet
:底层二叉树,排列无序不重复,排序存储,内部是TreeMap的SortedSet。
LinkedHashSet
:底层哈希表,并用双向链表记录插入顺序,内部是
LinkedHashMap;
Queue:在两端出入的list,也可以用数组或者链表实现;
HashMap
:底层哈希表,键不可重复,值可以重复,访问速度快,线程不安全,key、value都可以空,对应的线程安全的有ConcurrentHashMap;
HashTable
:底层哈希表,键不可重复,值可以重复,线程安全,key、value都不可以空,使用了synchronized进行控制;
TreeMap
:底层二叉树,键不可重复,值可以重复;实现了SortMap接口,默认按键值升序排序;也可以指定排序比较器;使用TreeMap时key必须实现Comparable接口或在构造TreeMap传入自定义的Comparator;
LinkedHashMap
:是HashMap的一个子类,保存了记录的插入顺序;用Iterator遍历时,先取到的肯定是先插入的;
小结:tree开头的底层是二叉树;hash开头的底层是哈希表;set的内部其实还是map,link开头的底层是双向循环链表结构。
二、常见的问题
1、HashTable、HashMap、ConcurrentHashMap的区别?
HashTable
:
- 线程安全的,承自Dictionary类;
- 使用synchronized加锁,就是对对象加锁;使用一把锁锁住整个链表结构,处理并发容易阻塞;
- 并发性不如ConcurrentHashMap好,不需要线程安全的场合可以用HashMap替代,所以不建议使用;
- 默认初始化数组大小
默认11
,扩容是2倍+1
。
ConcurrentHashMap
:支持多线程
- jdk1.7中实现:ReentrantLock+Segment+hashEntry数组(分段锁方式,每段一个锁);每个Segment多个哈希表,其中Segment是通过继承ReentrantLock(可重入锁)来进行加锁的,每次锁住一个Segment;
- 默认初始化数组大小
默认16
,即并发数、并行级别、Segment数、并行度默认16,也就是说ConcurrentHashMap中有16个Segment;这个值可以在初始化时设置,一旦初始化是不可以扩容的; - 实际并发度:2n,实际并发度会使用大于等于该值最小2幂指数作为实际并发度,比如用户设置17,实际并发度32;
- jdk1.8中实现:CAS(无锁算法)+synchronized+Node+红黑树(锁力度Node首结点);
HashMap
:
- 线程非安全的;可用Collections的synchronizedMap方法使hashMap具有线程安全的能力,或者使用ConcurrentHashMap;
- 底层数组+链表+红黑树,而不是二叉树。二叉树特殊情况下会变成一条线性结构,造成树很深的问题,而红黑树是平衡二叉树,可能通过左旋、右旋、变色等使其保持平衡,引入红黑树就是为了查找快;
- hashMap根据键的hashcode值存储数据,访问速度快,最多允许一条记录的键为null,允许多条记录的值为null,查找时,我们根据hash值快速定位到数组下标,之后顺着链表一个个比较下去,时间复杂度取决于链表长度;为了降低开销;链表长度超过
8
变成红黑树,低于6
变回链表; - 数组容量保持2n, 默认初始化数组大小
默认16
,扩容后大小为原来的2倍
;负载因子0.75,所以容量=16*0.75=12
,也就是实际大小超过12就需要动态扩展,扩展时调用resize()方法,将table长度变为原来的2倍;
三、ConcurrentHashMap工作原理
在JDK 1.8中,ConcurrentHashMap 的实现进行了显著的优化和改进,主要采用了以下技术:
- CAS(无锁算法):用于更新特定的共享变量(如计数器和状态标志)。
- Synchronized:用于控制对特定段(bucket)的竞争访问。
- Node:用于存储键值对的基本单位。
- 红黑树:用于优化链表过长时的查找效率,减少查找的时间复杂度。
数据结构:
- Node:ConcurrentHashMap 的基本存储单元,包含键、值、哈希值以及指向下一个节点的指针。
- 红黑树:当链表长度超过一定阈值(默认是8)时,链表会转换为红黑树,以提升查找和插入效率。
- 桶数组:ConcurrentHashMap 的主数据结构是一个数组,每个数组项是一个 Node 链表或红黑树。
以下是 ConcurrentHashMap 中数据插入操作的详细运转说明:
1)初始化和哈希计算
插入数据时,首先计算键的哈希值,并根据哈希值找到对应的桶(数组中的位置)。
final int hash = spread(key.hashCode());
final int binCount = 0;
2)桶初始化
如果桶数组 table 还没有初始化,需要进行初始化:
if (table == null || table.length == 0) {
initTable();
}
initTable() 使用 CAS 操作保证桶数组只被初始化一次。
3)桶锁定和插入
根据哈希值找到相应的桶,如果该位置为空,则直接使用 CAS 操作插入一个新的节点。
if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null)))
break;
}
如果桶位置已经存在节点,则使用 synchronized 锁定该桶位置,进行后续操作。
synchronized (f) {
if (tabAt(tab, i) == f) {
if (f.hash == MOVED) {
tab = helpTransfer(tab, f);
continue;
}
binCount = 1;
for (Node<K,V> e = f;; ++binCount) {
K ek;
if (e.hash == hash && ((ek = e.key) == key || (ek != null && key.equals(ek)))) {
V ev = e.val;
if (!onlyIfAbsent) e.val = value;
return ev;
}
Node<K,V> pred = e;
if ((e = e.next) == null) {
pred.next = new Node<K,V>(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1)
treeifyBin(tab, i);
break;
}
}
}
}
4) 链表转换为红黑树
如果链表长度超过一定阈值(默认是8),会将链表转换为红黑树:
if (binCount >= TREEIFY_THRESHOLD - 1)
treeifyBin(tab, i);
treeifyBin 方法将链表转换为红黑树,从而提升查找和插入效率。
四、hashMap工作原理
1、hashMap基本组成(数组+链表+红黑树)
HashMap的主干是一个Entry数组,链表则是主要为了解决哈希冲突而存在的(链表长度超过8
变成红黑树,低于6
变回链表;);Entry是HashMap中的一个静态内部类。代码如下,包含4部分主要信息:
static class Entry<K,V> implements Map.Entry<K,V> {
final K key;
V value;
Entry<K,V> next;//存储指向下一个Entry的引用,单链表结构
int hash;//对key的hashcode值进行hash运算后得到的值,存储在Entry,避免重复计算
/**
* Creates new entry.
*/
Entry(int h, K k, V v, Entry<K,V> n) {
value = v;
next = n;
key = k;
hash = h;
}
注:hashcode是32位的int类型!
2、HashMap工作原理
1)HashMap通过put和get方法存储和获取;
2)存储对象时将K/V传给put();调用hash()方法计算K的hash值,然后结合数组长度(n-1)& hash计算数组下标;
3)调整数组大小, 容器中元素个数大于capacity*loadfactor时,容器自动扩容resize为2n;
4)如果k的hash值在hashmap中不存在,则插入,若存在,则碰撞;
5)如果k的hash值在hashmap中存在,切equals()返回true,则更新键值对,若返回false,则插入链表的尾部(尾插法)或者红黑树中(树的添加方式),jdk1.7前是头插法;
6)获取对象时,将K传给get(),调用hash(K)找到该键值所在的数组下标, 若为链表,则在链表中通过key.equals(k)查找,O(n) ;若为树,则在树中通过key.equals(k)查找,O(logn);
HashMap数组扩容过程:
创建一个新数组,容量为旧数组的两倍,重新计算旧数组中结点的位置,结点在新数组中的位置只有两种:
①原下标位置;②原下标位置+旧数组大小;
3、Hash函数的设计
将key的hashCode与该hashCode的无符号右移16位,异或起来得到的。
因为当table的size比较小时,能影响到table下标的,只有哈希值几个低位bit,这很可能会加剧哈希碰撞。但这样实现后,哈希值的高16位bit保持不变,低16位则受到高16位的“扰动”而发生改变,这样就使得高位bit也能影响table下标,减少哈希碰撞。
异或是因为:
key的hashCode只要有一个bit发生变化,hash函数的返回值也会跟着变化,用以减少哈希碰撞。
4、为什么计算下标用h & (length-1)?
作用上相当于取模mod或者取余%。
这意味着数组下标相同,并不表示hashCode相同。位操作肯定比取余操作快多了。
5、为什么重写了hashCode方法,也应该重写equals方法?
自定义的类作为key,hashCode方法和equals方法要么都不重写,要么都重写。
如果key只重写了hashCode方法,却没有重写equals方法。那么会造成map里会存在重复的我们认为“相同”的键值对在里面(一般是指,两个对象的成员是相同的)。因为如果添加了相同元素,根据put过程则发生哈希碰撞,本来这个相同元素不应该新增,但由于原始Object的equals方法逻辑使用==判断,所以只要地址值不同就肯定能添加进去。
如果key只重写了equals方法,却没有重写hashCode方法。那么也会造成map里会存在重复的我们认为“相同”的键值对在里面。因为使用了原始的hashCode了,有些该发生的哈希碰撞也就不会发生了,都不在一个哈希桶了,即使我们认为是“相同”的,也不会去调用你重写的equals方法的。