1 java集合
存储的数据集合,三要素
- 在没有的泛型限制的前提下可存储任意数据类型,但不能存储基本数据类型,因为集合底层维护的是一个Obejct[]数组,要将基本数据类型转化为包装类再存入集合
- 可动态扩容,且扩容效率远高于数组
- 可以执行增删改查变量的数据结构
2 集合和数组的区别
- 存储容量:数组是固定长度,集合长度不固定,可以动态增长 => 确定长度用数据,否则用集合
- 存储类型:数组可以存储基本数据类型,也可以存储引用数据类型,但只能存储一种类型;集合只能存储引用数据类型不能存储基本数据类型(集合存储基本数据前会先自动装箱),但没有泛型的前提下能存储多种类型
3 List与Array的异同
- 区别同上面第二条
- 都是顺序存储的 (添加和取出顺序一致)=> 基于索引,能快速检索,但是不擅长插入删除,都是可重复的
3 常用集合类
Collection
- Set:
- HashSet:底层是HashMap,也得是不可重复性,无序性,无序性不同于随机性,HashSet元素存储的位置是基于元素的哈希值得出的结论,它的子类LinkedHashSet也是不可重复的,但确实有序的
- TreeSet:底层是红黑树,也是不可重复的有序
- Lisk:
- ArrayList:底层是Object[]
- LinkedList:底层是双向循环链表
- Stack:底层是Object[]
- Vector:底层是Object[]
Map
- HashMap:如下
- HashTable:链表+数组组成,线程安全
- TreeMap
- Property
重点说一下HashMap
4 那些是安全的集合
HashTable Stack Vector
5 Java集合的快速失败机制 “fail-fast”?
是java集合的一种错误检测机制,当多个线程对集合进行结构上的改变的操作时,有可能会产生 fail-fast 机制。
例如:假设存在两个线程(线程1、线程2),线程1通过Iterator在遍历集合A中的元素,在某个时候线程2修改了集合A的结构(是结构上面的修改,而不是简单的修改集合元素的内容),那么这个时候程序就会抛出 ConcurrentModificationException 异常,从而产生fail-fast机制。
原因:迭代器在遍历时直接访问集合中的内容,并且在遍历过程中使用一个 modCount 变量。集合在被遍历期间如果内容发生变化,就会改变modCount的值。每当迭代器使用hashNext()/next()遍历下一个元素之前,都会检测modCount变量是否为expectedmodCount值,是的话就返回遍历;否则抛出异常,终止遍历。
arrayList解决办法:
在遍历过程中,所有涉及到改变modCount值得地方全部加上synchronized。
使用CopyOnWriteArrayList来替换ArrayList
6 怎么确保一个集合不能被修改?
可以使用 Collections. unmodifiableCollection(Collection c) 方法来创建一个只读集合,这样改变集合的任何操作都会抛出 Java. lang. UnsupportedOperationException 异常。
List<String> list = new ArrayList<>();
list. add("x");
Collection<String> clist = Collections. unmodifiableCollection(list);
clist. add("y"); // 运行时此行报错
System. out. println(list. size());
7 iterator是什么
iteraror是Collection中迭代器接口,是面向对象中的一个行为型设计模式
- 迭代器对象可以直接从集合类Collection中获取,屏蔽不同数据集合间数据结构的差异,提供给开发者一个统一遍历集合的接口
- 通过这个对象可以在不对外暴露集合内部数据的前提下顺序的访问集合中的每一个元素
iteator的特征,通过是通过while来遍历
- iterator的指针(指针初始化在集合第一个元素的上一位置),next()是挪动指针到集合下一个元素并返回挪动后所指向的元素
- iterator只能单向遍历,如果需要再次遍历需要重置迭代器
- iterator但是能保证安全性,因为变量过程中每一次指针移动都伴随着安全性检查hasNext()
遍历过程中还可以删除当前变量元素(通过remove) - iterator每次遍历集合前都会调用hasNext()判断集合元素是否遍历完成,返回false说明遍历完成,停止遍历,否则继续
- 两方法的关系,每次执行next()前都要先执行hasNext()方法进行合法性验证,所以特别适合用while结构来使用迭代器遍历集合
8 集合元素变量方式
- for循环:内部原理是基于计数器,集合外部维护一个计数器,顺序读取直到最后一个元素,基于这种特点我们可以在循环过程中操作集合元素,可以在集合的任意位置进行增删改查
- 迭代器iterator遍历
- foreach遍历:内部实现也是基于iterator,但只能做简单的遍历,不能在遍历过程中操作数据集合,例如删除、替换
最佳实践:Java Collections 框架中提供了一个 RandomAccess 接口,用来标记 List 实现是否支持 Random Access。
如果一个数据集合实现了该接口,就意味着它支持 Random Access,按位置读取元素的平均时间复杂度为 O(1),如ArrayList。
如果没有实现该接口,表示不支持 Random Access,如LinkedList。
推荐的做法就是,支持 Random Access 的列表可用 for 循环遍历,否则建议用 Iterator 或 foreach 遍历。
9 说一下ArrayList的优缺点
优点:
- 有序性,有索引且实现了RandomAccess 接口,查找和添加效率高
缺点:
- 容量不确定,经常性的扩容会影响该效果使用效率
- 在没有泛型的条件下内部的数据类型没有限制,可以存储任意任意类型,会影响效率和安全性
- 有序性导致插入和删除效率很低,因为插入点需要挪动元素,删除虽然能根据索引快速删除但是为了保存有序性还需要挪动元素填补空缺
综上:ArrayList适合随机访问和查找,但是不擅长删除和插入,这些个特点和数组一样,因为该结构的底层其实就是数组
10 ArrayList和LinkedList的区别
- 结构方面,ArrayList底层是连续存储的动态数组,LinkedList底层是非连续存储的双向链表。
- 性能方面:ArrayList擅长查找/更新,因为可以借助索引,所以查询速度很快、O(1)级别、顺序添加O(1)级别,但不适合插入和删除(插入和删除这需要对数组元素做一个整体性挪动,所以复杂度是O(n));LinkedArray擅长(首位处和链表差距不大,因为插入实际上也需要遍历)插入删除O(1),但是不擅长查找O(n),查找中有优化也就是先判断索引下标是在前半部分还是后半部分,定位后再遍历,这样最终实际上最坏的情况也只是遍历一半即可,因为查找操作需要先遍历一遍
- 内存方面:后者占用更多的内存,因为有大量的指针需要存储,前者存储是连续的,后者是分散的,所以前者内存利用率高于后者
- 遍历,根据结构及内存特征推断:遍历数组用三种方式均可,链表的话不能使用普通for循环,因为它本身没有索引,根据它的内存分布特征推荐使用迭代器遍历,少使用增强for循环
- ArrayList需要考虑扩容,默认容量是10,也可以自己指定容量大小,扩容策略是按当前容量的1.5被扩容,LinkedList不考虑扩容
总之:在查询和添加频繁的情况下推荐使用ArrayList,在插入和删除频繁的情况下推荐使用LinkedList
12 Array和List的转换
Arrays.atList(List list); //调用数据工具类的方法实现数组 => 列表
list.toArray();//List类型自带toArray();有些类似toString()
多线程场景下如何使用 ArrayList?
ArrayList 不是线程安全的,如果遇到多线程场景
- 使用Vector,并不推荐
- 可以通过 Collections 的 synchronizedList 方法将其转换成线程安全的容器后再使用。
- 使用CopyAndWriteArrayList结构
14 对比List和Set
二者同属Collection下的接口
- 特点上List可以重复,有序(有索引,存入和取出的顺序是一致的)的集合;Set不可重复,无序但不随机
- 底层结果List底层是object[],,底层是HashMap
- 性能上List擅长查找,不擅长插入和删除;Set删除插入和删除,不擅长查找
- List能同时使用三种变量方法变量,Set无索引,不支持for循环
ArrayList的底层扩容机制
底层维护一个elementData数组,默认大小是10,初始化时不加载,后续首次向结构中添加add数据时进行才加载,扩容至1.5倍,如果显示指定了大小,扩容依然是扩到1.5倍。原理是add方法内部每次都要进行扩容判断,如果容量不够进行扩容grow,grow除了做合法性判断,核心方法就是通过Arrays.copyOf(arr,newLength),本质是新建一个大小为newLength的数组,然后将原数组arr传入进去
14 多线程下如何使用ArrayList
List<String> synchronizedList = Collections.synchronizedList(list);
synchronizedList.add("aaa");
synchronizedList.add("bbb");
for (int i = 0; i < synchronizedList.size(); i++) {
System.out.println(synchronizedList.get(i));
}
如何保证多线程下一个集合不被修改
List<String> list = new ArrayList<>();
list. add("x");
Collection<String> clist = Collections. unmodifiableCollection(list);
clist. add("y"); // 运行时此行报错
System. out. println(list. size());
HashSet
15 说一下 HashSet 的实现原理?
HashSet 是基于 HashMap 实现的,HashSet的值存放于HashMap的key上,HashMap的value统一为PRESENT,因此 HashSet 的实现比较简单,相关 HashSet 的操作,基本上都是直接调用底层 HashMap 的相关方法来完成,HashSet 不允许重复的值。
16 HashSet如何检查重复?HashSet是如何保证数据不可重复的?
- HashSet底层是一个HashMap,该结构中每一个map的key-value中的value都是一个固定的常量PRESENT,是一个object型对象,而真正存入HashSet的值都存在了map的key的位置上,通过这个特点也保证了唯一性,实现步骤如下
HashSet首先通过计算插入元素的哈希值得到对应的底层数组位置
如果位置上是空的,那么直接插入元素
如果不为空在在此位置(链表或红黑树形式的数据,如果数红黑树那么就有一套特殊的查重方法)进行哈希值对比
循环比较下都不相同那么就插入成功
如果有相同的哈希值在通过equals方法对比
返回true,那么元素a添加成功过
返回false,那么元素添加失败,尾插法插入(jdk7是头插法)
这种设计在避免出现重复元素的同时大大提高了效率
源码调试总结:
1 如果当前链表数组不存在就先初始化map结构
2 如果当前索引的数组空间是空,那么直接将元素存入,当做链表头
否则
1 判断当前链表头和插入元素,如果哈希值相同的前提下这两个对象是同一个对象或者这两个对象通过equals比较相同,那么将新插入的键值对的value值替换旧键值对的value值
2 在1不满足条件下且当前链表已被转为了红黑树,判断能否在红黑树插入
3 1和2都不满足,那么就循环遍历当前链表,如果找到了符合1判断的元素就退出,不允许插入,否则就插入到最后面
17 HashSet和HashMap区别
- 前者是Set类型,后者是Map类型
- 前者存储对象,使用存储的对象计算hashcode;后者存储键值对,使用HashMap使用键(Key)计算Hashcode(本质一样)
- 前者使用add添加对象,后者使用put添加键值对
- 前者擅长插入删除,不擅长查找;后者各类操作都均擅长,因为数组擅长检索,不擅长增删,链表擅长增删而不擅长检索,而HashMap是数组和链表的结合体
18 JDK1.7 VS JDK1.8 比较
结构不同,扩容方法不同,初始化方法不同,元素插入方法不同,哈希值计算方式不同
HashMap
结构?
结构:jdk前是数据 + 链表,jdk8后是数据 + 链表 + 红黑树,链表长度超过8且数组长度为64才转为红黑树,主体是一个Node数组,数据基本单位是Node类型的数据节点,kv键值对结构,一个next指针,这种结构在后续hashmap添加元素时出现hash碰撞时比较容易形成单链表结构,链表长度和hashmap数组长度达到一定程序又会树化成红黑树,k-v位置都允许出null,但k位置只能有一个null,是无序的存储键值对(映射关系)的集合,HashMap中的key是Set类型,无序不可重复;Value是Collection类型,推荐重写equals方法,,map中的entry无序,不可重复,用Set存储
插入流程
Map map = new HashMap(); //初始化对象时底层没有变化
put数据的过程中首先判断table是否为null,由于是懒加载所以会调用resize扩容,底层默认会创建一个大小为16位的node数组,根据插入键值对的key值计算哈希值
根据哈希值推算出待插入位置的数组
如果数组位置是空就将kv封装为Node直接插入,不为空意味着出现了hash碰撞,判断数组当前位置的节点和带插入节点的key是否一致,一致(判断一致的标准是两个对象的哈希值想等的基础上,通过==判断结果为true,即两个引用指向的是同一个对象,如果==判断结果不为true,也可以通过比较equals是否为true,这里不考虑数值类型),那么在判断key想等的情况下,用新的value替换旧的value
判断节点类型,如果插入位置是红黑树,遍历红黑树判断是否已存在,存在则进行value值的替换,否则将其封装为一个红黑树节点那么首先将其插入到红黑树,插入成功后再判断是否需要扩容
如果插入位置有链表形式的数据,那么遍历链表
如果哈希值都不相同那么用尾插法插入到链表最后面,同时判断是否达到了树化的阈值,达到则进行树化
已存在,那么在key不变的情况下,用新的value替换旧的value
插入元素注意点:
1 初始设容量为16,加载因素0.75,加载因子就是12,也就是说只要加入的元素个数超过12个就会进行扩容,
无论这个元素就加到链表头(数组第一个)或者是加在链表上或者加载了红黑树上,都算是添加元素的个数,扩容到原
来的二倍然后得出新的临界值
2 插入到数组统一位置的元素开始是链表结构,只有当数组容量>=64且当前数组位置链表格个数>=8,当前链表会转为红黑树结构存储
3 hashmap初始化容量为为2的幂,这是通过构造方法内不断进行左移运算实现的,而且扩容默认位2倍,也就是
hashmap容量始终为2的幂,插入元素时,如果使用%计算元素的落脚点效率很低,使用哈希值和map的length-1进行与运算这种算法效率更高,而且这样可以减少hash碰撞的概率
HashMap vs HashTable
效率,线程安全,对null的支持,扩容策略,底层结构
哈希冲突的定义
当两个不同的对象经过哈希函数计算后得到了同一个结果,即他们会被映射到哈希表的同一个位置时,即称为发生了哈希冲突。简单来说就是哈希函数算出来的地址被别的元素占用了
HashMap是使用了哪些方法来有效解决哈希冲突的
在hashmap中进行插入的时候,发现已经被其他元素占用了,其实这就是所谓的哈希冲突,hashMap中的链表出现越少,性能才会越好,解决方案主要是
- hashCode函数设计的巧妙,降低不同对象得到相同哈希值(冲突)的概率
- HashMap采用链地址法(数组+链表),使得相同哈希值的对象能够出现在同一数组位置的链表或红黑树上,查询和插入效率都比较高
- HashMap内部引用红黑树结构,能够更快的遍历,降低哈希冲突带来的危害
- HashMap的扩容机制,hashmap中存储元素数量大于阈值(数组大小*负载因子)的时候会按它的扩容策略进行扩容,防止由于数组小导致链表堆积太长
hash函数是如何设计的
根据hashCode方法得到key的hash值,但原生hashCode方法出现hash碰撞的概率比较大,这里的哈希算法是原生对象key的hash值的高16bit不变,用它hash值的低16bit和高16bit做了一个异或(两位相同返回1,两位不同返回0),这种情况下32位hash值中只要有一位发生变化即整个key的hash值就会改变,尽量使得节点插入均匀
扩容因子
hashmap中存储元素数量大于阈值(数组大小*负载因子)的时候会按它的扩容策略进行扩容,防止由于数组小导致链表堆积太长,所以折中设为0.75
为何总是2的n次方
- 初始化时默认值是16,如果不是2的n次方,那么在初始化时把这个数转换成二进制树,然后把这个数的非1的位置,通过这个数和它本身左移后的数值做或运算,也就把这个二进制转化为全1的二进制,然后+1,数组即为2的n次方
- 扩容时也是按2的n次方去扩容的
扩容
条件:超过阈值,0.75*当前数组长度
特点:较为耗费性能,开发者可以通过调节初始值避免频繁的扩容
细节:扩容过程伴随着一次新的hash分配,扩容是创建一个数据大小为之前2倍的数据,①底层原理其实是从二进制数组长度的角度看,数据长度二进制数组的高位+1,而后hash值和数组长度与操作时,扩容高出来的那一位对应的hash值的位数值是1,那么会受到扩容的影响,计算出来的数据插入位置即相比于之前多出一段距离,这段距离即扩容的增量/原数组的大小,对应的hash值为0那么这个元素不受扩容元素影响,还是不变的,也就是是否挪到位置全看hash对应位数是0还是1,完全随机的 => 扩容的过程中,会一定程度上把之前的元素进行分散,分散到扩容后多出的那部分位置上②这种情况下避免了重复计算元素hash值的流程
ConcurrentHashmap
jdk8前
分段锁,Segment本质也是一个Entry数组,也就是相当于ConcurrentHashmap把它的主体数组分割一个个Segemnt,Segemnt继承了可重入锁,主体数组默认大小为16,也就是一个Segment在插入时只会对自己加锁,而其它Segment正常读写
jdk8后采用CAS+synchronize更细粒度的锁,如果元素插入的上没有元素,那么通过CAS的方式插入,CAS是保证插入的原子性,Node数组中的Node节点的value,判断是否其它线程在进行扩容
能否使用任何类作为 Map 的 key?
理论上可以,实际开发时
1 最好使用泛型,存储固定类型的键值对
2 最好重写equals和hasCode方法
3 最好是不可变类,这样可以将它的hash值缓存起来,提高使用效率
综上所述:String,Integer等保证类是最优选择
向hashmap插入两个相同key的entry
HashMap<Object, Object> objectHashMap = new HashMap<>();
Object o = new Object();
objectHashMap.put(o,1001);
objectHashMap.put(o,1002);
for (Object o1 : objectHashMap.keySet()) {
System.out.println(objectHashMap.get(o1));
}
ConcurrentHashMap 的区别
- jdk7:采用的Reentrolock + Segement + HashEntry数组的形式(一个Segement包含一个HashEntry数组,这个数组上由多个节点),通过插入元素的哈希值计算插入位置,随后在插入位置上上锁,其它部分不上锁,所以在保证线程安全的同时兼顾效率的提升
- jdk8:采用synchronized + Node + CAS + 红黑树的结构,Node的val和next,以及存储node的数组都采用validate修饰,保证变量的可见性,保证数组扩容的被感知;整个结构最初的插入、修改、替换都是采用的CAS,特殊的情况下如数组扩容,哈希冲突,使用的是synchronized,锁的策略是锁链表的head节点(锁一个链表),这样锁的细粒度更高,效率又有了进一步提示,而且扩容的话是阻塞全部
TreeMap 和 TreeSet 在排序时如何比较元素?
TreeSet 要求存放的对象所属的类必须实现 Comparable 接口,该接口提供了比较元素的 compareTo()方法,当插入元素时会回调该方法比较元素的大小。TreeMap 要求存放的键值对映射的键必须实现 Comparable 接口从而根据键对元素进 行排 序。
Collections 工具类中的 sort()方法如何比较元素?
Collections 工具类的 sort 方法有两种重载的形式,
第一种要求传入的待排序容器中存放的对象比较实现 Comparable 接口以实现元素的比较;
第二种不强制性的要求容器中的元素必须可比较,但是要求传入第二个参数,参数是Comparator 接口的子类型(需要重写 compare 方法实现元素的比较),相当于一个临时定义的排序规则,其实就是通过接口注入比较元素大小的算法,也是对回调模式的应用(Java 中对函数式编程的支持)。
HashMap HashTable CurrentHashMap
底层结构:HashMap 底层是链表+数组+红黑树,后者都是数组+链表
效率 HashMap> CurrentHashMap > HashTable
安全性:HashMap安全,HashTable和CurrentHashMap不安全
实现:HashTable是将方法设为synchronized,这样的话只保证安全,无法考虑性能, 但CurrentHashMap 即考虑安全又兼顾性能,采用分段式segment设计,思路是通过插入键值对的key计算哈希值,根据哈希值计算出键值对待插入的位置,即具体是哪个分段的那个位置,这样只对当前分段上锁尽量不影响其他位置的并发性
选择:不用HashTable,使用后两个
hashMap算法汇总
根据hash值计算插入位置(减少hash冲突、两个细节增加运算效率)
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
说明:
- key.hashCode();返回散列值也就是hashcode,简单理解为根据对象信息生成的,每个对象的hash值都是独一无二的。
- n表示数组长度,初始化的长度是16。
- &:按位与运算,^:按位异或运算
- '>>>'右移运算符, >>>16代表数组右移16位
给变量h赋值key.hashCode(),而后 h >>> 16后做异或操作,相当于原先h的前16位不变,后面的16位通过h的高16为和低16位做异或得出
好处:
- 最终计算插入位置时本身需要用hash%tableSize取余数组长度做运算,但这里由于是把数组大小设置始终是为2的n放的,此时hash值和数组长度进行&运算代替了取模运算,大大提升了运算效率(位运算直接对内存数据进行操作,不需要转成十进制,所以位运算要比取模运算的效率更高);
- 根据HashMap中的hash算法计算得到的对象hash值,这个算法是通过对象原生的hash值右移运算符16位和它的元素hash值再异或运算得出的,而这种算法相当于hash值整个32位全部参与了运算,防止一种情况,在数组长度较小,比如只有16,相当于只有后4位参与运算,如果遇到hash值高位变化大,低位变化下的时候,hash冲突概率会加大,这种设计主要用来避免此类情况;
hashmap的加载因子为何必须是0.75(hash冲突、空间利用率、防止频繁扩容)
- 加载因子过高,例如为1,虽然减少了空间开销,提高了空间利用率,但同时也增加了查询时间成本,因为hashmap的添加过程需要不断的进行节点key的比对来防止插入重复元素;
- 加载因子过低,例如0.5,虽然可以减少查询时间成本,但是空间利用率很低,同时提高了扩容更频繁,造成性能低下
- 0.75是经过精密的运算得到,主要是符合概率学中的泊松分布
红黑树(对比avl)
比如某些人通过找到你的hash碰撞值,来让你的HashMap不断地产生碰撞,那么相同key位置的链表就会不断增长,当你需要对这个HashMap的相应位置进行查询的时候,就会去循环遍历这个超级大的链表,性能及其地下。java8使用红黑树来替代超过8个节点数的链表后,查询方式性能得到了很好的提升,从原来的是O(n)到O(logn)。
HashMap 的底层数组长度为何总是2的n次方(hash冲突、空间利用率、方便扩容)
- 当length为2的n次方时,h&(length - 1) 就相当于对length取模,而且在速度、效率上比直接取模要快得多,运算起来比较方便
- 使数据分布均匀,减少碰撞,体现在插入和扩容时
太小了就有可能频繁发生扩容,影响效率。太大了又浪费空间,不划算。所以从规范性和实用性角度出发,我们初始化hashmap指定数组大小时需要用一个2的n次方的
扩容流程(省略hash值重新计算、使得数组中的元素分散更加均匀、进一步避免hash冲突)
扩容等价于new一个新数组,长度是之前的2被倍,而后遍历旧的数组,把旧数组的元素重新插入到新的数,重新插入的流程和之前添加新元素是一样的,唯一的区别就是此时用数组长度做取余运算,新的插入位置就是原位置+旧容量(数组扩容多出来的那一位是0就边,是1就原位置+旧容量),这样巧妙运用了数组元素和插入算法之间的关系,省去了重新计算hash的时间,同时旧数据中的一个链表中的元素有可能不变或者挪到新的为准,这是完全随机的,也就是扩容结束后链表的节点分散更加的均匀
comparator vs compareable
- 前者是java.lang.utils中的,后者是java.util包
- 前者核心方法是compare,后者是compareTo
- 一个类实现Compareable接口,意味着该类支持排序,然后可以在程序中通过Collections.sort()或Arrays.sort()进行排序,comparetor意味着类外需要临时进行对象排序,总之Comparable代表类内排序器,Compreator代表类外排序器
Collection和Collections
- 前者是集合类最顶级接口,为集合类提供通用的标准和方法,提供最大化统一操作集合的规范,其直接继承接口有List与Set。
- 后者是工具类,提供一些列工具类,用于一系列的排序,搜索,转换等操作
hashmap线程安全吗
不安全,典型线程安全问题有
- jdk8会出现数据元素被覆盖导致丢数据问题,导致推算位置算法,多线程环境下,插入两个不同元素时,根据对应的算法计算插入元素位置时,可能导致计算这两个元素得到相同的插入点后又判断插入点还没有数组元素时,后面的元素会覆盖之前插入的元素导致元素丢失,但实际上我们想要得到的效果是第一个插入到了数组的相关位置,第二个尾插法插入到这个第一个元素后面形成一个链表
- jdk7扩容问题
- 链表出现环状,遍历时会出现死循环,因为是头插法,多线程环境下,如果一个线程已经是把当前链表/节点元素插入到它的next后,准备将其替代之前数据元素的时候,线程挂起,另一个线程率先完成了扩容,再切换回去后,