一、前言
集合是java的基础。
我们有了集合,在我们开发过程中,事半功倍。我们常用的集合有这几类:Array,Map,Set,Queue等,他们每一类在java迭代升级的过程中,也是有不同的升级优化。
二、集合全局观
这个是小编画的一个集合全家福,整体上是 Map和Collection;
collection 包含我们常用的Set + Queue + List
Map 包含常用的HashMap + Hashtable + treeMap
三、依次看一下
List类
我们依次梳理一下,数组中常用的几种:
ArrayList
ArrayList可以说是我们最常用的了,基本写代码都会用到。有几个特点
- 线程不安全
- 基于数组,需要连续内存
- 随机访问快,(根据下标直接访问)
- 尾部插入、尾部删除性能可以;其他部分插入、删除都会移动数据,因此性能低
- 可以利用cpu缓存, 局部性原理
扩容机制是什么样的?
数组扩容均是建立一个新的数组,大小会计算好的, 然后把旧数组中的数据copy过去。
-
ArrayList() 会初始化 长度为0的数组
-
ArrayList(int initialCapacity) 会初始化指定容量的数组
-
public ArrayList(Collection<? extends E> c) 会使用c的大小做为容量
-
add方法,默认是尾插法,首次扩容为10,再次扩容为上次的1.5倍,底层是通过x + x>>1 ,(向右移1位,等于x/2)。
比如,当前数组大小是15,再次扩容的时候,会计算增量 15>>1=7,最终扩容到 15+7=22
/**
* Appends the specified element to the end of this list.
*
* @param e element to be appended to this list
* @return <tt>true</tt> (as specified by {@link Collection#add})
*/
public boolean add(E e) {
ensureCapacityInternal(size + 1); // Increments modCount!!
elementData[size++] = e;
return true;
}
- addAll方法,比较 下一次扩容容量 和元素个数较大的。
初始空时,扩容为Math.max(10,实际元素个数);
有元素时,Math.max(原容量1.5倍,实际元素个数);
比如:
当前数组为空,addall了一个6个元素的list,那么此时元素是6个,6<10,数组容量是10个。
再addall 一个6个元素的list, 现在10个装不下,要扩容,下次扩容量为 10+10>>1 = 15,12<15,所以扩容到15.
如果我们插入6个后,我们再插入10个,那么我们就有16个元素,16>15,所以会扩容到16.
fail-fast 和 fail-safe
- fail-fast
ArrayList是典型代表,多线程操作,遍历的同时修改数组,立即抛出异常。
过程:在遍历之前,会把当前list的元素个数modCount记录下来,迭代的时候会记录迭代器修改的次数expectedModCount,两者会比较,如果不相等,证明被修改了,抛出ConcurrentModificationException异常。
优化方案:使用juc下的包代替java.util,如CopyOnWriteArrayList。
- fail-safe
CopyOnWriteArrayList是典型代表,遍历的同时可以修改,原理是读写分离。
会copy一份数据,牺牲一致性,让遍历完成。
LinkedList
特点:
- 基于双向链表,无需连续内存
- 随机访问慢,(要沿着链表遍历)
- 头部插入删除性能高
- 占内存多 (双向链表有更多的成员变量)
Vector
特点:
- 数组实现,内存连续
- 线程安全,Synchronized修饰
- 扩容方式通过扩容因子判断
- fail-fast
说一说面试题:
1.ArrayList 和 LinkedList 区别
2.ArrayList 和 Vector 区别
3.ArrayList如何扩容的?
。。。。
Map
map也是我们常用的数据结构,也是面试最经常问的,小编理解了Map的设计后,其实也是很佩服这个设计的。而且我们jdk1.7和jdk1.8结构是有所不同的,其中优化的理念,还是值得借鉴的。比如二次hash,链表转红黑树,以及为什么初始大小是16,扩容大小是2^n?
map这里呢,我们也挑选几个典型:
HashMap
HashMap,在1.7 和1.8 结构有所升级。
1.7 中的HashMap:
- 结构:数组 + 链表
- 线程不安全
- 允许 null 作为 key和value
1.8 中的HashMap
- 结构:数组 + 链表 or 红黑树
- 线程不安全
- 允许 null 作为 key和value
提问环节:
- 1.8 和1.7 的Hashmap有什么区别?
数据结构不一样,1.7 是数组+链表,1.8 是数组+链表 or 红黑树;
链表插入方式:1.7是头插法,1.8是尾插法
- 什么是红黑树??为什么用红黑树?
红黑树是特殊的平衡二叉树,她比平衡二叉树性能好一些,查询时间复杂度为 O(log2^n)。而链表的查询复杂度是O(1),当链表过长的时候,查询会很慢。
1.8 中转换红黑树的条件是 1.数组长度大于等于64,2.链表长度大于8;
使用红黑树就是为了优化长链表查询慢的问题。
- 为什么不一上来就用红黑树?
链表短的时候,查询性能很快,没有必要直接用红黑树
存储方面:链表节点是Node,红黑树的节点是treeNode,treeNode成员变量比node多,所以内存会占用多,也没有必要。
- 1.8转红黑树的阈值为什么是8 ?
阈值为8 ,也是综合考虑的。是为了尽量不要转成红黑树,除非链表真的很长了。
官方的一个demo:如果hash值够随机,在hash表内按泊松分布,在负载因子为0.75的情况下,统计了长度超过8的链表出现的概率是0.00000006 (亿分之6),选8,就是为了让树化 的概率足够小。链表转树,树转链表的开销也很大。
- 红黑树如何退化为链表?
1.当链表长度小于等于6
2.红黑树的节点,如果 删除节点前,红黑树的 根节点,根的左节点,根的右节点,根的左孙子,有一个为null,就会转为链表。
如何防止hash碰撞?hash如何计算的?
hashMap 做了二次hash操作
第一次,根据key获取到对应的hashCode值;
第二次,根据hashCode 进行二次hash;
最后,用二次hash的值与数组容量进行取余运算。
根据key计算了hashcode,为什么还要进行二次hash?
保证hash结果更加的均匀,防止长链表产生。
二次hash是通过 hashcode ^ (hashcode>>>16) 计算的,目的是为了分配更加的均匀,防止链表过长
从这个图可以看到,二次hash结果分布更加均匀。
数组容量为什么是2^n?
为了提高整体性能,两个地方用到了这个数据
1.取余计算桶的时候,如果数组长度是2^n,那么可以直接用位运算取代取模
eg:
97 % 16 =1
97 & (16-1) = 1
2.方便扩容移动数据
扩容移动数据的时候,根据扩容后桶的长度,计算每个key对应的新桶的位置 A,
如果 A & oldCap == 0, 那就留在原位,否则,新位置 = 旧位置 + oldCap;可以直接移动。
不用2^n 可以吗?
可以,但是综合考虑性能
hashtable 就不是用的2^n
get的流程是什么样的?
首先根据 key 获取hashcode
再 根据hashcode进行二次hash
最后 按位与得到桶的位置
如果桶的位置没有数据,就直接返回null。
如果桶有数据,就依次遍历链表,通过equals()判断key是否相等,相等的话返回对应的value,没有的话返回null。
put流程是什么样的?1.7和1.8 区别?
首先 根据key 获取hashcode
在根据hashcode 进行二次hash
最后 按位与得到桶的位置
如果桶没有数据,就做成node节点,插入
如果桶有数据,判断数据是否存在?通过equals判断存在
存在,更新数据
不存在,插入数据
====如果是treeNode,走红黑树添加逻辑
====如果是普通node,走链表添加逻辑,1.7 头部插入node节点,1.8尾部插入Node节点
添加完后判断是否转红黑树
返回前检查容量是否超过阈值,一旦超过,进行扩容。(先插入,再判断树化,再判断扩容)
1.7并发扩容死链问题?死循环问题?
首先,hashmap是线程不安全的,所以在高并发的时候,会有问题。
其次,1.7是通过头插法完成的,当扩容的时候,a–b,会变为 b–a。node节点还是node节点,只是改变了前后的链接。
比如当前有两个线程来操作
其中一个线程2,已经完成了上面的扩容。现在链表的顺序是b–a。
线程1开始迁移数据,第一轮循环,把a先迁移,然后e的指针指到b,next的指针指到null。
第二轮循环,next指针,指向b的下一个,是a。e把b迁移走,e指向next,指到a。
第三次循环,next指向null,a要用头插法插到头部,就形成了 第一个node是a,a的next是b,b的next又是a,这样就出现了死链。
负载因子为什么是0.75?
综合条件考虑的,从空间和时间考虑
大于这个值,空间节省了,但是链表会变长,影响效率。
小于这个值,扩容次数多了,hash冲突少了,空间多了。
hashMap出现HashDos问题,如何解决的?
通过红黑树解决,防止链表太长,性能急剧下降。
ConcurrentHashMap
ConcurrentHashMap ,在1.7 和1.8 结构有所升级
1.7的ConcurrentHashMap
- 结构 : segment + 数组 + 链表
- 线程安全,使用ReentrantLock ,用自旋锁来保证线程安全
1.8的ConcurrentHashMap
- 结构 : 数组 + 链表 or 红黑树
- 线程安全,使用 CAS + Synchronized保证线程安全
提问时间:
1.7和1.8 ConcurrentHashMap 有什么区别?
1.数据结构不一样
====1.7 segment + 数组 + 链表
====1.8 数组 + 链表or红黑树
2.初始化时机不一样
====1.7,饿汉式初始化,初始化的时候,就初始化好segment,以及segment0的数组,数组大小根据容量和并发度来计算。
====1.8,懒汉式初始化,真正put数据的时候创建
3. 插入方式不同,1.7头插法,1.8尾插法。
4.扩容时机不同
====1.7 当超过 容量负载因子大小,才扩容
====1.8 当 >= 容量负载因子,就扩容,eg 12 个就扩容了
5.锁的对象不一样
====1.7锁的是segment
1.8 锁的是链表的第一个Node
ConcurrentHashMap如何保证线程安全的?
ConcurrentHashMap 和 hashMap的区别?
ConcurrentHashMap 和 hashtable的区别?
HashTable
- 结构 : 数组 + 链表 or 红黑树
- 线程安全,所有方法通过 Synchronized修饰
- 不允许 null 作为 key和value,否则报空指针错误
四、小结
集合这个还是很值得研究的。包括里的设计思想。