JAVA集合,也称作容器,主要是由两大接口派生出来的:
Collection 和 Map
顾名思义,容器就是用来存放数据的。那么Collection 和Map 这两大接口的不同之处在于:
- Collection 存放单一元素
- Map 存放key-value 键值对
Colleciton 接口
Collection 接口有3个子接口:
- List 接口
- Queue 接口
- Set 接口
List 接口
List 最大的特点就是 **有序 ,可重复 **
List 接口有3个实现:
ArrayList 实现类
-
底层数据结构是:数组
-
优点:
- 随机访问性强,可以根据下标快速定位数据
- 查找速度快
-
缺点:
-
插入和删除效率低,因为需要移动数据,特别操作的数据在数组中间的时候
-
大小固定,不方便动态扩展
-
可能浪费内存(因为数组连续的,每次申请数组之前必须规定数组的大小,如果不合适就会浪费内存
- 数组初始大小默认是 10 ,需要扩容时,在原数组大小上增加 1/2
LinkedList 实现类
- 底层数据结构:链表 ,准确的时候是双链表 ,不仅实现了 List 接口,还实现了 Queue接口
- 优点:
- 插入速度快
- 大小不固定,扩展灵活
- 内存利用率高,不会浪费内存
-
缺点
- 不能随机查找,需要进行遍历查找(使用的是二分法进行查找)
Vector
-
数据结构跟ArrayList 基本一致
-
里面大部分方法都用sychronized 修饰,所以这是一个线程安全的类
-
扩容的时候跟 ArrayList 是不一样的 ,Vector 是可以指定扩容时新增的数组大小,如果没有指定的话,每次扩容时就在原数组基础上增加一倍
-
Stack 是Vector 的子类,也就是栈
扩展问题
-
为什么使用ForEach 遍历集合时进行元素删除会报错? 而使用迭代器时不会报错?
AbstractList 中有个重要的属性叫 modCount ,从名字上就可以得知 这叫 修改计数器 ,只要你对集合中的元素进行了添加/删除操作 ,没有修改元素的方法啊 。只要对元素进行了添加/删除操作,这个修改计数器就会加 1
同时不论是foreach 遍历 还是迭代器遍历,在内部都维护了一个 expectedModCount ,初始值就是 集合的modCount
在for循环中,如果进行了元素的添加或者删除,会导致这个计数器加 1 ,等到下一次遍历的时候就会出现modCount 和 exceptedModCount 不一致,于是系统就会认为出现了并发修改的情况,报一个 concurrentModifyException
为什么迭代器可以在遍历的时候删除元素呢?
迭代器每迭代一次就会将 modCount 重新赋值给 expectedModCount -
ArrayList 和 LinkedList 的存储效率?
ArrayList 底层是数组,也就是每个元素就是直接的数据
LinkedList 底层是链表,准确的说是双链表,每个节点维护了3个东西:当前节点的值,上一个节点的地址,下一个节点的地址 。
从数据结构上来看,同样多的元素,LinkedList 需要更大的内存,以32位系统来算的话,n个元素就要多出 4 (4个字节)* 2n 的大小
- 为什么数组可以实现随机访问? 为什么数组下标是从0开始?
数组本质上是一种线性表的结构,它是一组连续的空间,来存储相同类型的数据
计算机在分配内存时会对应地分配一个内存地址,连续的内存空间对应的就是连续的内存地址,而计算机是通过内存地址或者内存中的值
所以数组这个线性表中,只要记住第一个元素的内存地址,也叫首地址
如果想访问第 n 个元素,在没有越界的情况下,只需要用首地址 + (n -1) *数据类型占用的空间 就可以得到第n 个元素的内存地址,进而得到相应的值
下标为什么从0开始呢?
这其实是一种优化,我们刚才提到了在计算第n个元素的地址时使用的是 n-1 .,那么如果我们约定第一个元素的下标为 0,第n个元素的下标为 n-1 ,那么在算地址的时候是不是可以省略掉 减 1 这个步骤。比如查第5个元素,如果下标从1开始,那么送个5进去 ,数组内部在查找元素就要做 5-1 这个操作,如果下标从0开始,要查第5个元素,我们送进去的就是个 4,数组内部在查找时就省掉了 -1 这个操作
Set接口
- HashSet
使用 hashMap的 key来存储元素,主要特点是无序,查找速度很快 - LinkedHashSet
底层数据结构是数组加链表 ,数组存元素,链表存元素顺序 - TreeSet
采用红黑树的方式,特点是有序,可以采用自然排序,也就是元素对象自身的排序,这种需要元素实现comparable 接口 ;另一种方式是初始化时送一个实现了compator 接口的比较器,由这个比较器进行排序。排序的时候,值大的放后面,值小的放前面,相等的认为是同一个值就不往里面放了。
Queue
是一种一端进,一端出的线性数据结构,从语义上来看队列就是先进先出的。
ArrayList
Deque
两端都可以进出
- ArrayQueue
PriorityQueue
元素取出的时候,按规定的优先级出去
还有一些有容量限制的队列,比如线程中常用的BlockingQueue
Map接口
HashMap
-
底层实现:数组 + 链表 ,允许有一个key 为null, value 也可以为null
-
元素插入过程
- 先算出key 的hash 值,然后对hash值做一个二次hash 获得一个最终的hash值
- 用最终的hash值 对数组长度取模,得到对应数组的下标,也就是确定了key的位置
- 如果当前没有数组位置没有元素,直接创建一个节点存入数组
- 如果当前位置已经有元素了,这种情况就叫做hash碰撞/hash冲突,这时候需要进去equals判断,如果equals相同,则认为是两个相同的元素,直接用后面的value覆盖之前的value;
- 如果equals算下来不相等,则判断当前节点是否为树节点,是的话直接插入树节点中;
- 不是的话,那就是个链表了 。检查下链表的高度是否达到8 ,数组长度是否达到64 ,如果达到了,则将链表转换为红黑树,再将元素插入;如果没有达到,就正常在链表最后端加一个节点
- 这个过程中会出现链表转红黑树的情况,也会出现红黑树转回链表的情况,当红黑树高度小于等于6时就会转为链表。
允许 key 为 null ,那这个元素放什么位置呢 ?
key为null的算出来的hash 为0 ,放在数组第一个位置二次hash 是怎么做的? 有什么作用
二次hash 是使用了一个扰动函数进行处理,就是将hash值右移动1 6位 然后与原hash值进行异或运算,这样就混合了原hash值中高位和地位,增大了随机性,可以让数据元素均衡的散列,减少碰撞。 - 先算出key 的hash 值,然后对hash值做一个二次hash 获得一个最终的hash值
-
默认初始容量是 16 ,加载因子为0.75 ,可以指定初始容量,但不一定是
-
扩容: 在数组的位置达到阈值的时候就会可能执行扩容,但是是先放置元素再判断是否要扩容。所以可能出现无效扩容的情况,放置这个元素之后我发现要扩容,我给他翻一倍,后面又不加元素,那就有空间浪费了。
LinkedHashMap
有序的map ,维护了一个双向链表用来记录元素顺序的
TreeMap
可以自然排序 也可以选择排序
ConcurrentHashMap
这里面用到了一个分段的思维,就是把map里的数组分为了16部分,那么理论上最多可以同时有16个线程对这个map进行操作。就刚好每个线程操作一个分段。比如有两个线程要进行数据插入,通过hash算出来,key的位置属于两个分段,如果是hashMap 同时有2个线程要插入元素,那么肯定是先把整个map锁住,然后看谁先获得锁进行操作 。ConcurrentHashMap就不是这么做的了,如果发现key需要放置的是位置属于两个分段,那么这两个线程只要分别去竞争这2个分段的所就行了 ,他们之间是没有竞争的,只是锁住了每一个分段。当然了有一些操作,比如contains sie 这些操作,还是需要锁住整个map 的,但我们一般认为size contains这种属于不是那么重要的操作