目录
集合框架的结构
Iterable(迭代器)的作用:遍历集合、删除集合、支持多线程操作。
Collection:单列集合;
Collections:Collection与Collections的区别,Collections是操作集合的工具类,而Collection是存放数据的集合。
Map:Key-Value结构,通过Key去取值。通常通过Hash运算去计算Key的位置。
迭代器(Iterable接口)
什么是迭代器,作用是什么
迭代器是Java集合中的顶级接口,迭代器之下的所有接口类都实现了迭代器。什么是迭代器:他本质上是对容器遍历的算法。迭代器的作用:遍历集合、删除集合、支持多线程操作。
迭代器如何进行遍历
List<String> list = new ArrayList<>();
Collections.addAll(list, "篮球", "足球", "羽毛球", "乒乓球", "排球", "游泳", "跑步", "健身");
Iterator<String> iterator = list.iterator();
//第一种方式,通过List调用iterator()方法
//并且可在迭代过程中调用iterator.remove()安全移除当前元素
while (iterator.hasNext()){ //判断是否有下一个值
System.out.println(iterator.next()); //获取下一个值
}
//第二种方式,通过List调用listIterator()方法。
//使用ListIterator的优势是可以进行正向遍历和逆向遍历,还可以在遍历过程中添加,修改,删除元素
ListIterator<String> listIterator = list.listIterator();
while (listIterator.hasNext()){
System.out.println(listIterator.next());
}
遍历集合还可以使用普通for循环,增强for循环,forEach这么五种方式。
其中增强for循环的本质就是获取集合的iterator迭代器对象进行迭代遍历。
如何安全的删除元素
如果我们在listIterable和Iterable中遍历集合,使用list.remove()来删除元素,就会报ConcurrentModificationException。这是什么原因那?
List集合中维护了一个ModCount用来统计list修改次数,Iterable接口中维护了一个expectModeCount,他们在修改之后会比对两者值是否相等。如果在迭代器遍历的过程中使用list.remove()删除,就会导致两者值不想当,出现问题。
- 不能使用增强For遍历的时候删除元素
- 不能在Iterable和ListIterable遍历中使用List.remove()删除元素,可以使用迭代器自带的删除方法删除。
- 每当删除一个元素时,集合的size方法的值都会减小1,这将直接导致集合中元素的索引重新排序,进一步说,就是剩余所有元素的索引值都减1,而for循环语句的局部变量i仍然在递增,这将导致删除操作发生跳跃。从而导致没循环一次,就少删除一个元素。可以通过补偿机制进行i--
List<String> list = new ArrayList<>();
Collections.addAll(list, "篮球", "足球", "羽毛球", "乒乓球", "排球", "游泳", "跑步", "健身");
for (int i = 0; i < list.size(); i++) {
list.remove(i);
}
for (String s : list) {
System.out.println(s);
}
控制台输出结果
Collection
List(有序可重复)
ArrayList:底层数据结构为数组。他的特点跟数组几乎一致,查询快(可以直接根据索引进行查询),插入慢(插入一个数据,该索引之后的数据全部向后移位)。
LinkedList:底层数据结构为链表。他的特点跟链表一致,查询慢(需要遍历链表),插入快(只需要修改前后指针就可以)。
ArrayList与LinkedList的区别
- 数据结构不一样,ArrayList是数组,LinkedList是双向链表。
- 插入与删除受元素位置影响关系,这里我直接上结论,百分之99的情况下用ArrayList更好。原因是ArrayList如果插入尾部时间复杂度是O(1),LinkedList采用头插或者尾差也是O(1)。但是如果根据元素位置删除元素的时候,是先查询位置再进行删除或者添加操作。ArrayList查找元素效率是O(1),但是添加删除操作需要移位所以他整体就是O(n),而LinkedList查找元素的时间复杂度是O(n),所以整体都是O(n)。这么说来,两者好像没什么区别。但是ArrayList在查询操作上时间复杂度为O(1),所以ArrayList的性能优势更加明显。
- 内存占用,ArrayList会在结尾留出一段空间,LinkedList是每个节点占据的空间大。
Vector
Vector底层的数据结构是数组,他是线程安全的,通过Sync关键字。(Vector已经被淘汰)
Set(无序不可重复)
Queue(单向队列,先进先出)
Deque(双向队列)
Map(重点)
equals()与HashCode
提及Map我们必需先来聊聊equals方法,提及equals方法我们又不可避免的需要了解HashCode。
为什么我们要提及equals?
当我们插入元素到Map当中,如果Key值重复了会出现什么情况,后一个key的value值会把前一个key的value值覆盖掉。判断key值是否重复,Map中的做法就是使用equals方法。
为什么重写equals方法必须重写HashCode?
重写equals方法,是为了判断两个对象的值是否相等。
如果重写了equals方法而未重写HashCode方法,就会导致在Map中查询Key值的时候,明明两个值相等的对象,确在Map中没有找到。
原因是什么?每一个对象的HashCode都是根据地址值进行计算的,每new出来一个对象,地址值都不一样,所以Hash值也就不一样,那么储存在Map中hash表中的位置就会不一样。那么我们拿着Key去Hash表中去找对应值相同地址值不同的Key时就会找不到Key,导致误判。
所以重写equals方法必须重写HashCode
Map如何判断Key是否重复
Map为了避免Key重复是不是就需要一个个元素去比较去equals,这样时间复杂度是O(n),这里采用Hash算法进行优化。
每一个Map都维护了一个Hash表,表里面将key值进行Hash处理然后取模放到对应Hash表的位置中,当检查元素是否重复时会先去比较Hash值,如果Hash值不相等那么也没有必要进行equals比较了,只有当Hash值相等的时候才会调用equals方法比较。这样就优化了Map查询Key是否重复。
HashMap
HashMap数据结构
JDK1.7之前
采用数组+链表,采用这种数据结构的目的是为了解决Hash冲突(拉链法)。
解决Hash冲突的其他方法:
- 拉链法(Hash冲突后,将冲突数据添加到对应链表中)
- 再Hash法(使用其他Hash算法,再次计算)
- 再散列法(将Hash后的值加某个值或者减某个值,将下次Hash位置放到本次位置的两边空闲位置,也被称为开放定址法)
- 简历公共溢出区(将Hash表分为基本表和溢出表,将发生冲突的放到溢出表中)
JDK1.8
采用数组+链表+加红黑树(原因是,当链表过长查询速度变慢,这个时候需要将链表进行树化)
链表树化的条件:
- 数组长度大于64
- 链表长度大于8
这里有一个有趣的点是,树化条件是链表长度大于8;而当元素减少,将红黑树变为链表的条件是,红黑树高度小于6.
原因:如果树化和逆转树化都设置为8,那么刚好在8这个长度进行添加和删除,就会一直处于树化和逆转树化的过程极为浪费资源。
常见问题
1.什么是红黑树?
红黑树是一种数据结构,他是由二叉树等树结构演化出来的。(小编也不是很了解红黑树,在这里我就简单介绍一下红黑树的特点把!)
- 根节点是黑色的
- 红黑树的节点只有两种,黑色节点和红色节点
- 红色节点的子节点都是黑色节点
- 最尾端节点(该节点没有再次向下的分支,被称为叶子节点)都是黑色节点,储存的值为null
- 每一个节点到其分支节点的最尾端节点,经过的黑色节点数目相同
2.为什么在解决 hash 冲突的时候,不直接用红黑树?而选择先用链表,再转红黑树?
红黑树相对于链表来说,在存储和维护上需要更多的空间和时间消耗。因此只有在链表过长的时候我们才会使用红黑树代替链表。
3.不用红黑树,用二叉查找树可以么?
理论上是可以的,但是如何维持二叉树的平衡,如何放置二叉树退化成链表那?
HashMap中如何通过Key找到对应值
首先要了解HashMap的内部存储结构
数据在HashMap中存储,其实存储的是一个个的Entry对象。
key是键,Value是值
next(1.7是链表,next代表指针),node(1.8因为会转换为红黑树,所以叫做节点更加贴切)
hash就是hash值
如何通过Key去找到对应Value
举个例子,我们都知道数组是如何取值的,Map跟数组取值挺像的。
arr[i]=value
通过下标去找到对应的Value,Map也一样,知道了Key我们就可以通过运算去找到对应的下标。
Key->Hash->&->index
这个流程是,将key变为Hash值,然后对Hash值进行& (length-1),最后得到index下标。
通过下标就成功找到对应值了,成功完成任务。
为什么是&运算,而不是取模?
什么是&运算,即将两个数转变为二进制,然后数字都为1结果为1,有一个不为1就会0.
这样和取模一起都可以得到0-对应数值大小的值。
那为什么要使用&,因为计算机能够更好的处理位运算,用人话说就是计算机在进行&(位运算)时效率更高。
为什么HashMap扩容一定是2的幂次方数
上文中我们提到了HashMap通过Key去找值的过程,其中用到了一个操作就是&(位操作)。
这个时候我们就需要来介绍一下&运算的特点。
当HashMap扩容是2的次幂,那么length-1得到的值低位全为1,举个例子
#16 二进制
0001 0000
#length-1
0000 1111
#如果此时有一个17对15进行&运算
0001 0001
0000 1111
0000 0001
看出来什么妙的地方了吗,如果是2的幂次方,低位有值的地方会被全部保留,这个就是我们所需要的索引。但是如果不是2的幂次方就会出现低位有0的情况,这样就会把进行&运算的值可能有的位不会进行计算。(有问题的,自己举个例子就明白了,我也是脑袋尖尖的,全靠举例子)
兄弟们,这个解释细节不细节,我也是在网上看了好长时间资料才理解的!!!
HashMap1.7使用头插法,1.8改为尾插法的原因
1.7使用头插法的优势在于(官方说法,我也没理解啥意思)1.7使用头插法的原因最近访问到的数据下次访问概率变大,这样可以提高效率。(这里因为要遍历链表中的每一个元素,保证Key唯一所以不会出现,使用头插法就不用遍历链表的现象)
缺点:多线程出现死循环(这个问题我理解了好几遍但还是忘了,就不细讲了,问我那我就说不会,摆烂了)
1.8使用尾插法原因,我们需要判断Key是否重复,必然会遍历,所以直接使用尾插法了。(小伙伴们可能会误认为是不是要判断链表长度是不是8,然后导致必须遍历。其实不是的,内部有一个变量在记录链表长度的)
常见线程安全的Map
ConcurrentHashMap(重点)
ConcurrentHashMap的Key不能为null
HashMap的Key可以为null,并且永远存在索引为0的数组中
ConcurrentHashMap实现线程安全的方式(重点)
ConcurrentHashMap1.7保证线程安全的方式
了解ReentrantLock(可重入锁);这个知识点我在面试的过程中被问过,当时还不会尴尬死了,这里带大家了解一下吧
可重入锁ReentrantLock指的是一个线程可以获取多次同一把锁,不会出现线程阻塞等待,这样就不会出现死锁问题。
注意事项:
- 但是获取几次锁就需要释放几次锁
- 可重入锁不是指,没有获取到锁就会等待重新获取锁(小编当时就是回答这个,被面试官狠狠批斗了一次,血的教训啊!!!)
1.7保证线程安全的措施,采用分段数组,分段加synchronized锁的方式
1.7将一整段数组分割为很多个桶(Sagment),目的是在使用synchronized加锁的时候,减少锁的颗粒度。
1.7最多可以用16个桶(Sagment),Sagement继承ReentrantLock。也就是说可以承载16个并发量。当我们要对ConcurrentHashMap进行并发写的操作时,先去获取Sagment中的锁,然后再进行增加删除操作
ConcurrentHashMap1.8保证线程安全的方式
Node+CAS+synchronized方式保证线程安全。
ConcurrentHashMap1.8的数据结构和HashMap1.8的数据结构都是数组+链表+红黑树
Node指的就是对每一个数组个体进行加锁(即链表表头,树的根节点),粒度更细。
CAS :Compare and Swap(比较并交换),他是一个逻辑锁。
原理是,程序会先计算出一个期望值,然后执行出来实际值,对比实际值与期望值,如果数据一致,那么就不用加锁继续向下执行,如果不一致就对Node加锁
synchronized:java关键字,重锁。
HashTable
数据结构:数组+链表
HashTable实现线程安全的方式:删除添加都是在同一把synchronized下进行的,虽然保证了线程安全,但是效率低下
集合扩容
ArrayList扩容
我们知道ArrayList的底层是数组,是数组就必然需要考虑数据量变大之后带来的扩容问题,正常数组的做法是创建一个容量更大的数组,将原来数组复制到新数组当中。我们的ArrayList也是如此实现的。
当我们去创建一个ArrayList的时候,初始化容量为10,,如果你设置10以下的容量,默认还是会创建10这个容量。
先判断容量是否超过10,如果超过扩容为原来的1.5倍,就是原始容量+原始容量向右移一位(二进制表达方式,就是除以2),然后调用Arrays.copyOf(原始数据,新长度)方法创建一个新的数组,并且扩展容量到原来的1.5倍。
HashMap扩容
先给大家梳理一下思路,帮助大家更好的去理解源码,当然也可以背下来当八股答案^_^。
- 第一步:根据HashMap的初始化情况,确定新数组的数组大小与下一次扩容的阀值。
- 第二步:根据数组节点采用的数据结构,将老数组的值迁移到新数组中。
注意事项:
在链表数据迁移的时候为什么要进行链表拆分为高低位链表那?
原因就是:老数组已经到达阈值,查询速度变慢。拆分可以提高速度,减少Hash碰撞。高位链表的位置一定是老索引+扩容大小(可以自己举例验证)
废话不多说,直接上HashMap1.8源码,下面请欣赏resieze()吧!!!
final Node<K,V>[] resize() {
//第一步:确认newCap,newThr
//oldTab 老Hash表 oldCap老数组容量 oldThr 老Hash表下次扩容的阀值
//newCap 新数组容量 newThr 新数组下次扩容的阀值 threshold 扩容的阀值
Node<K,V>[] oldTab = table;
//HashMap采用懒加载方式,这里表示的就是如果没有数据就不分配内存空间
//有数据就将Hash表长度赋值给oldCap
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {//数组进行初始化过之后扩容动作,进行的操作
if (oldCap >= MAXIMUM_CAPACITY) {//老数组已经到达Map存储极限,无法扩容
threshold = Integer.MAX_VALUE;
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1;
//再不超过Map存储极限的情况下,newCap,newThr进行左移一位(位运算),表示乘2
}
else if (oldThr > 0) //Map未被初始化,但是通过构造方法设置了Map容量的情况
//例如:new Hash(initCap,loadFactor) 、new Hash(initCap)、new Hash(Map)
newCap = oldThr;//因为确定了容量大小,就会将阈值当做数组容量
else { // 相当于new Hash(),这里就是默认初始化HashMap的地方
newCap = DEFAULT_INITIAL_CAPACITY; //默认HashMap数组大小为16
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
//当oldThr > 0时,可能设置了loadFactor,所以就不需要预设阈值,所以就抽出来一个if
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
//第二步:根据数据结构进行扩容
@SuppressWarnings({"rawtypes","unchecked"})
//newTab的大小就是上文中的newCap,Node代表节点,就是每一个数据块
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {
//循环数组
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {//e可能是链表的头节点,也可以是树的根节点
oldTab[j] = null;
//当数组里面只是数组结构时,直接将数据放到新数组里面即可
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
//结构为红黑树时进行数据转移,小编不了解红黑树,就不讲解了
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // 当数据结构为数组+链表时数据的迁移方式
// loHead 低位链表表头 loTail 低位链表表尾
Node<K,V> loHead = null, loTail = null;
// hiHead 高位链表表头 hiHead 高位链表表尾
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {//遍历链表
next = e.next;
//只有0或者oldCap的次方数 Hash值&oldCap==0
//这就是用来拆分链表的依据
if ((e.hash & oldCap) == 0) {//操作低位链表
//第一次循环尾节点没值,就代表这就是头节点
if (loTail == null)
loHead = e;
else
//这个时刻,尾节点为loTail,即上个循环的值
loTail.next = e;
loTail = e;
}
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
//找到头结点之后,只需要把头结点传递给信数组就行了
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
Concurrent扩容
Java8中ConcurrentHashMap是怎样扩容的?(借鉴Alex大哥的视频,简短但不简单的让我认识到了ConcurrentHashMap的扩容机制)
参数介绍:
通过Volatile修饰的sizeCtl、transferIndex共享变量,用来保证线程间的可见性。并且采用
自旋+CAS进行修改保证原子性
sizeCtl:
sizeCtl在扩容前表示:扩容前阈值
siezeCtl在扩容中的含义:siezeCtl=-1表示扩容完毕,siezeCtl=-2表示有一个线程正在扩容,每多一个线程进行扩容就会去对siezeCtl进行-1操作
sizeCtl在扩容后表示:扩容后阈值
transferIndex:
划分区间,保证每一个区间最多只有一个线程进行扩容。
扩容过程
第一步:新建一个新的Table用来储存数据,并且将sizeCtl设置为-2,表示当前有一个线程在进行扩容
第二步:线程A扩容的时候,transferIndex向前移动两位,划分出线程A扩容的区间,其他线程如果再去参加扩容,就是从transferIndex开始,不会再去影响ThreadA扩容区间了。
第三步:节点迁移,将正在扩容区间的数据发,放到newTable中。(这个过程是可以并发进行的,这就是提高效率的原因)
第四步:标记原节点。被标记位置,如果有查数据请求,就会让他去newTable中找数据;如果是增删操作,就会先暂停该请求,把该线程用来扩容。
添加一个线程进行扩容,sizeCtl会减一
第五步:所有线程扩容操作完成,sizeCl=-1,完成扩容
第六步:table = newTable,sizeCtl=扩容后阈值,完成扩容
注意:
在扩容过程中,新的线程非查操作,都会先帮助扩容然后再去进行原操作,多个线程操作过程是并发进行的。
总结
这里直接使用Alex大佬的总结图了。