目录
四、集合框架
1.集合概述
集合的作用
数组长度是固定,当添加的元素超过了数组的长度时需要对数组重新定义,太麻烦
因此,java内部给我们提供了集合类,能存储任意对象,长度是可以改变的,随着元素的增加而增加,随着元素的减少而减少
集合和数组的区别
元素不同
数组既可以存储基本数据类型,又可以存储引用数据类型,基本数据类型存储的是值,引用数据类型存储的是地址值
集合只能存储引用数据类型(对象),基本数据类型在存储的时候会自动装箱变成对象
长度不同
数组长度是固定的,不能自动增长
集合长度可变,可以根据元素个数增减
数组和集合什么时候用
如果元素个数是固定的推荐用数组
如果元素个数不是固定的推荐用集合
集合继承体系
数组和链表
数组集合
查询快修改也快,直接通过索引找到值,进行修改;增删慢
原因:
数组一旦被初始化,长度就不会被改变
初始长度是10,每次add的时候 都会先判断一下 size+1是否超过了数组的长度,一旦超过,那么就创建一个新数组,长度增加int oldCapacity /2,将数据复制到新数组中,原数组就作废了
在某个索引位置增加时,要将包括该元素的后面的每个元素都往后移动
在某个索引位置删除时,要将包括该元素的后面的每个元素都向前移动,被移动的最后位置置null
数组实现的集合:ArrayList
链表集合
查询慢,修改也慢;增删快
原因:
每个存储单元,会记住链中前后存储单元的地址,从而形成链
查询时,先判断是从前还是从后找(二分判断离头尾哪个近),然后依次挨个存储单元找,遍历
指定索引插入元素时,只需要插入元素记住该索引前后单元的地址,就插入成功
删除也是,拿出一个元素,前后索引修改记忆的前后单元的地址
链表实现的集合:LinkedList
2.Collection
集合的根接口
方法
boolean add(E e) //增加
boolean remove(Object o) //删除
void clear() //清空
boolean contains(Object o) //判断是否包含
boolean isEmpty() //判断是否为空
int size() //获取元素个数
boolean addAll(Collection c) 添加所有元素
boolean removeAll(Collection c) 删除的是交集
boolean containsAll(Collection c) 判断是否包含c中的每个元素(重复的也算包含)
boolean retainAll(Collection c) 判断C是否包含调用者集合
集合遍历
迭代器直接遍历集合元素
Collection c = new ArrayList();
c.add("a");
c.add("b");
c.add("c");
c.add("d");
Iterator it = c.iterator(); //获取迭代器的引用
while(it.hasNext()) { //it.hasNext()判断集合中是否仍有元素可以迭代
//it.next()返回迭代的下一个元素,且移动迭代器的指针到下一个元素
//迭代的过程中,不可以对集合的元素进行增删,即不可以改变集合的结构,因为迭代器无法知晓集合的结构变化,会造成并发修改异常
System.out.println(it.next());
}
//增强for,简化数组和Collection集合的遍历(底层是迭代器实现),所以实际开发一般不用迭代器遍历元素
for(元素数据类型 变量 : 数组或者Collection集合) {
使用变量即可,该变量就是元素
}
并发修改异常
fail-fast,快速失败机制,并发修改异常
是Java集合的一种错误检查机制,当多个线程对集合进行结构上的改变的操作时,有可能会产生 fail-fast 机制,注意只是有可能,不是一定,单线程的情况下,也可能产生
多线程:线程1通过Iterator在遍历集合A中的元素,在某个时候线程2修改了集合A的结构,这个时候程序就会抛出 ConcurrentModificationException 异常,从而产生fail-fast机制
单线程:在集合迭代的过程中,对集合结构进行了改变(增删元素)
public static void main(String[] args) {
ArrayList<Integer> arrayList = new ArrayList<>();
arrayList.add(1);
arrayList.add(2);
for (Integer integer : arrayList) {
arrayList.add(1); //报错,ConcurrentModificationException
}
}
3.List
特点:元素有索引,有序,可重复
List集合的特有功能(核心是索引)
void add(int index,E element) 指定索引位置添加元素
E remove(int index) 删除指定索引位置元素,并返回该元素
E get(int index) 获取,显然可以通过get(int index)方法遍历
E set(int index,E element) 修改
default void sort(Comparator c) 排序,可以给定比较器
List<Map<String, Object>> demoList = xxMapper.getList(xx); demoList.sort(Comparator.comparingInt((Map o) -> Integer.parseInt(o.get("XH").toString())));
集合遍历
通过size()和get()方法结合使用遍历
List list = new ArrayList();
list.add(new Student("张三", 18));
list.add(new Student("李四", 18));
list.add(new Student("王五", 18));
list.add(new Student("赵六", 18));
for(int i = 0; i < list.size(); i++) {
Student s = (Student)list.get(i);
System.out.println(s.getName() + "," + s.getAge());
}
并发修改异常产生解决方案ListIterator
ListIterator lit = list.listiterator()
方法
boolean hasNext()是否有下一个
boolean hasPrevious()是否有前一个
Object next()返回下一个元素
Object previous();返回上一个元素
//判断集合里面有没有"world"这个元素,如果有,添加一个"javaee"元素
List list = new ArrayList();
list.add("a");
list.add("b");
list.add("world");
list.add("d");
list.add("e");
Iterator it = list.iterator();
while(it.hasNext()) {
String str = (String)it.next();
if(str.equals("world")) {
list.add("javaee");//这里会抛出ConcurrentModificationException并发修改异常,原因是在迭代的时候进行了集合的增删改操作,但是迭代器并不知道,这会影响迭代
}
}
解决方案
如果想在遍历的过程中添加元素,可以用ListIterator中的add方法
ListIterator lit = list.listIterator();
while(lit.hasNext()) {
String str = (String)lit.next();
if(str.equals("world")) {
lit.add("javaee");
}
}
List的三个子类的特点
ArrayList
底层数据结构是数组,查询快,增删慢
线程不安全,效率高(异步)
LinkedList
底层数据结构是链表,查询慢,增删快
线程不安全,效率高
Vector(不用了)
底层数据结构是数组,查询快,增删慢
线程安全,效率低(同步)
Vector,ArrayList,LinkedList区别
数据结构
ArrayList,Vector:数组,查询修改快
LinkedList:链表,增删快,查询修改慢
线程安全
Vector:线程安全,效率低
ArrayList,LinkedList:线程不安全,效率高
Vector是线程安全的,效率低
4.ArrayList
//ArrayList去重
public static ArrayList getSingle (ArrayList list){
ArrayList newList = new ArrayList(); //创建一个新集合
Iterator it = list.iterator(); //获取迭代器
while (it.hasNext()) { //判断老集合中是否有元素
String temp = (String) it.next(); //将每一个元素临时记录住
if (!newList.contains(temp)) { //如果新集合中不包含该元素
newList.add(temp); //将该元素添加到新集合中
}
}
return newList; //将新集合返回
}
5.Vector
vector实现了list接口,但已经被ArrayList取代了
Vector的特有功能
public void addElement(E obj)
public E elementAt(int index)
public Enumeration elements()
//Vector的迭代
Vector v = new Vector();
v.addElement("a");
v.addElement("b");
v.addElement("c");
v.addElement("d");
Enumeration en = v.elements(); //获取枚举,这不是迭代,是枚举
while(en.hasMoreElements()) { //判断集合中是否有元素
System.out.println(en.nextElement()); //获取集合中的元素
}
6.LinkedList
LinkedList类特有功能
public void addFirst(E e)及addLast(E e)
public E getFirst()及getLast()
public E removeFirst()及public E removeLast()
7.Set
特点:元素无索引,无序(指的是存放并不是按add的顺序),不可重复
如何保证元素的唯一性
存入时通过对象的hashCode()和equals()比较元素,已经存在的不存入Set
jdk提供的类,比如基本数据类型包装类,jdk已经对equals()做了重写
自定义的类,也需要重写equals(),给定比较的规则
比较的逻辑
在hashCode()值相同时,才会进一步调用equals()进行比较,否则直接认定对象不一样
为什么不直接使用equals()进行比较
因为hashCode()效率高,而equals()中的操作一般都比较复杂,效率较低
为什么还需要equals()
因为hashCode()是一个算法,并不完全可靠,当hashCode()不同,则两个对象肯定不同,但当hashCode()相同,两个对象不一定相同,采用这样的组合比较方式,可以兼顾效率和可靠性
为什么重写equals()就一定要重写hashCode()
1.因为hashCode()在equals()之前调用,如果不重写,很可能永远调用不到equals()
2.java约定两个对象如果equals()判定相同,那么hashCode()也必须判定相同
通过Set去重
public static void getSingle(List<String> list) {
LinkedHashSet<String> lhs = new LinkedHashSet<>();
lhs.addAll(list); //将list集合中的所有元素添加到lhs
list.clear(); //清空原集合
list.addAll(lhs); //将去除重复的元素添回到list中,可以直接修改
}
8.HashSet
常用的Set子类
9.LinkedHashSet
常用的Set子类
链表结构使元素能保持有序,即可以保证怎么存就怎么取,存进去是a,b,c,d,取出来还是a,b,c,d
10.TreeSet
元素排序
排序的原理:底层是二叉树结构
怎么实现排序
方式一
元素的类implments Comparable,重写compareTo()
TreeSet类的add()方法中会把存入的对象提升为Comparable类型,调用对象的compareTo()方法和集合中的对象比较,根据compareTo()方法返回的结果进行存储
基本数据类型包装类默认已经实现了Comparable接口重写过compareTo();对于自定义对象,可以实现Comparable接口并重写compareTo()
对于compareTo()
return 0 那么集合中只存一个元素,因为每次返回0都被TreeSet认为是一样的
return 正数 那么集合中怎么存怎么取
return 负数 那么集合中倒序存储
方式二
比较器顺序(Comparator)
比较器类implments Comparator,重写compare()方法,将Comparator的实现类对象传给TreeeSet()对象构造方法,TreeSet就会按照比较器中的顺序排序
add()方法内部会自动调用Comparator接口中compare()方法排序
对于compare()
调用的对象是compare方法的第一个参数,集合中的对象是compare方法的第二个参数
11.Map
定义:将键映射到值的对象
特点
键具有唯一性,键可以是对象,需要重写hashCode()和equals()保证键的唯一性
Set集合的底层是Map,隐藏了值,展示的是键
Map和Collection区别
Map是双列的,Collection是单列的
Map子类的数据结构指的是键的数据结构,比如HashMap,TreeMap的Hash和Tree针对的都是键;Collection集合的数据结构是针对元素有效
方法
添加
V put(K key,V value):添加元素,返回的是被覆盖的值
V putIfAbsent(K key, V value):如果Map中已经有当前key,不会覆盖
删除
void clear():移除所有的键值对元素
V remove(Object key):根据键删除键值对元素,并把值返回
判断
boolean containsKey(Object key):判断集合是否包含指定的键
boolean containsValue(Object value):判断集合是否包含指定的值
boolean isEmpty():判断集合是否为空
获取
Set> entrySet():获取所有键值对
V get(Object key):根据键获取值
Set keySet():获取集合中所有键的集合
Collection values():获取集合中所有值的集合
int size():返回集合中的键值对的个数
Map嵌套
Map可以嵌套,即Map map = new HashMap
Map集合遍历
//先拿到键,根据键查找值
Set<String> keySet = hm.keySet(); //获取集合中所有的键
Iterator<String> it = keySet.iterator(); //获取迭代器
while(it.hasNext()) { //判断单列集合中是否有元素
String key = it.next(); //获取集合中的每一个元素,其实就是双列集合中的键
Integer value = hm.get(key); //根据键获取值
System.out.println(key + "=" + value); //打印键值对
}
for(String key :hm.keySet()) { //增强for循环迭代双列集合第一种方式
System.out.println(key + "=" + hm.get(key));
}
//直接获取键值对象,Map.Entry是Map的内部接口Entry,将键值对封装成Entry对象,存储在Set集合中
Set<Map.Entry<String, Integer>> entrySet = hm.entrySet(); //获取所有的键值对象的集合
Iterator<Entry<String, Integer>> it = entrySet.iterator(); //获取迭代器
while(it.hasNext()){
Entry<String, Integer> en = it.next(); //获取键值对对象
String key = en.getKey(); //根据键值对对象获取键
Integer value = en.getValue(); //根据键值对对象获取值
System.out.println(key + "=" + value);
}
for(Entry<String, Integer> en :hm.entrySet()){
System.out.println(en.getKey() + "=" + en.getValue());
}
在类中声明Map成员时给定初始值
Map<Integer, Integer> map = new HashMap<Integer, Integer>() {
{
put(1,0);
...
}
}
双花括号的含义
第一个括号是定义了一个匿名内部类
第二个括号是在这个匿名内部类中定义了一个初始化代码块
put相当于this.put,this指的是这个匿名内部类的对象本身
12.LinkedHashMap
链表结构,可以保证怎么存就怎么取
13.TreeMap
键有序
统计字符串中每个字符出现的次数
String str = "aaaabbbcccccccccc";
char[] arr = str.toCharArray(); //将字符串转换成字符数组
HashMap<Character, Integer> hm = new HashMap<>(); //创建双列集合存储键和值
for(char c : arr) { //遍历字符数组
if(!hm.containsKey(c)) { //如果不包含这个键,就将键和值为1添加
hm.put(c, 1);
}else { //如果包含这个键,就将值加1添加进来
hm.put(c, hm.get(c) + 1);
}
hm.put(c, !hm.containsKey(c) ? 1 : hm.get(c) + 1);
Integer i = !hm.containsKey(c) ? hm.put(c, 1) : hm.put(c, hm.get(c) + 1);
for (Character key : hm.keySet()) { //遍历双列集合
System.out.println(key + "=" + hm.get(key));
}
14.HashMap
概念
key-value 键值对的形式存放元素(并封装成 Node 对象)
允许使用 null 键和 null 值,但只允许存在一个键为 null,并且存放在 Node[0] 的位置
线程不安全:采用 Fail-Fast 机制,底层通过一个 modCount 值记录修改的次数,对 HashMap 的修改操作都会增加这个值。迭代器在初始过程中会将这个值赋给 exceptedModCount ,在迭代的过程中,如果发现 modCount 和 exceptedModCount 的值不一致,代表有其他线程修改了Map,就立刻抛出异常
数据结构
JDK7:HashMap由 数组,链表 组成
JDK8:HashMap由 数组,链表,红黑树 组成
红黑树
可以把红黑树简单理解为接近平衡的二叉树
特点
每个节点或者是黑色,或者是红色
根节点是黑色
每个叶子节点(NIL)是黑色,注意:这里叶子节点,是指为空(NIL或NULL)的叶子节点
如果一个节点是红色的,则它的子节点必须是黑色的
从一个节点到该节点的子孙节点的所有路径上包含相同数目的黑节点,这样确保没有一条路径会比其他路径长出俩倍
为什么需要红黑树
在Java 8中如果桶数组的同一个位置上的链表数量超过TREEIFY_THRESHOLD默认是8,链表会转为一棵红黑树
AVL更平衡,但在频繁增删的情况下,为了维持平衡会进行很多的旋转操作,此时红黑树的性能更高,相对的,对于增删较少,查询频繁的情况,AVL更具优势
假如客户端实现了一个性能拙劣的hashCode方法,元素较多的情况下,采用红黑树可以保证HashMap的读写复杂度不会低于O(lgN)
红黑树如何保持平衡
通过旋转和重新着色
黑高bh(x):从某个结点 x 出发(不包含该结点)到达一个叶结点的任意一条简单路径上包含的黑色结点的数目,显然,黑高最多也就是h/2
哈希算法
任意长度的输入,通过散列算法,变换成固定长度的输出,这个输出是一个地址值,通过这个地址可以访问
哈希函数的评价标准
计算出来的哈希值足够散列,能够有效减少哈希碰撞
本身能够快速计算得出,因为HashMap每次调用get和put的时候都会调用hash方法
哈希表
根据关键码值(Key value)而直接进行访问的数据结构
通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度
这个映射函数叫做散列函数,存放记录的数组叫做散列表
初始化
HashMap的实现的基础数据结构是数组,每一对key->value的键值对组成Entity类以双向链表的形式存放到这个数组中
初始化时,不会占用内存,第一次put时会调用inflateTable计算bucket数组的长度,开辟bucket数组,占用内存
数组长度是2的整数幂,初始size为16,扩容:newsize = oldsize*2
为什么一定是2的整数幂:因为这样可以通过构造位运算快速寻址定址
数据存储-put方法
计算hash值
将key-value封装成Node,拿到key.hashCode()
调用hash()重新计算hash值,防止拙劣hashCode(),从而使hash值相对分散,jdk8之后,对hash()进行了优化,使hashCode的高16位参与运算,保证了数组较小时的hash值分散度
计算元素存放在数组的位置
将hash值与tablel.length-1进行位与运算,得到元素存放位置
此处就可以理解为什么HashMap的底层数组长度总是2的n次方幂:因为当 length 为2的n次方时,h & (length - 1) 就相当于对 length 取模,而且速度比直接取模要快得多,二者等价不等效,这是HashMap在性能上的一个优化
存储
如果计算出的数组位置上为空,那么直接将node放到该位置中
如果数组该位置上已经存在链表,即有多个key的hash值通过哈希算法得到的数组下标相同,即发生了哈希碰撞,此时:
挨个节点通过equals()对比key,如果返回true,则覆盖此节点,如果都返回false,则树形-挂到树上,链表-添加到末尾(Jdk1.7及以前的版本使用的头插法)
如果插入元素后,如果链表的节点数是否超过8个,则调用 treeifyBin() 将链表节点转为红黑树节点
最后判断 HashMap 总容量是否超过阈值 threshold,则调用 resize() 方法进行扩容,扩容后数组的长度变成原来的2倍
哈希冲突(碰撞)
通过hash计算出的hash值相同,继而导致存放位置相同即为hash冲突
hashMap的处理方式是拉链法,即将所有hash值相同的元素放在同一个链表中
数据寻址-hash方法
先调用k的hashCode()方法得出哈希值,并通过hash()算法转换成数组的下标
通过数组下标快速定位到某个位置上
如果这个位置上什么都没有,则返回null
如果这个位置上有链表,那么它就会拿着K和链表上的每一个Node的K进行equals()
如果所有equals方法都返回false,则get方法返回null
如果其中一个节点的K和参数K进行equals返回true,那么此时该节点的value就是我们要找的value了,get方法最终返回这个要找的value
扩容-resize方法
为什么需要扩容:减少哈希碰撞,从而减小链表长度/树高,让value分配更均匀,从而提升读取效率
何时扩容
HashMap 有两个影响性能的关键参数:初始容量,加载因子
容量 capacity:就是哈希表中数组的数量,默认初始容量是16,容量必须是2的N次幂
加载因子 loadfactor:在 HashMap 扩容之前,容量可以达到多满的程度,默认值为 0.75
扩容阈值 threshold = capacity * loadfactor
扩容形式:扩容针对整个Map,每次扩容时,原来数组中的元素依次重新计算存放位置,并重新插入,如果扩容两倍,则有一半的节点需要存到扩容的部分中
扩容过程
重新建立一个新的数组,长度为原数组的两倍
遍历旧数组的每个数据,重新计算每个元素在新数组中的存储位置。使用节点的hash值与旧数组长度进行位与运算,如果运算结果为0,表示元素在新数组中的位置不变;否则,则在新数组中的位置下标=原位置+原数组长度
将旧数组上的每个数据使用尾插法逐个转移到新数组中,并重新设置扩容阈值
为什么扩容时节点重 hash 只可能分布在原索引位置或者 原索引长度+oldCap 位置:由确定存址的位运算特征决定的
总结
为何随机增删、查询效率都很高:增删是在链表上完成的,而查询只需扫描部分,则效率高
HashMap在JDK7和8中的区别
①数据结构:
JDK7及之前的版本:数组+链表
JDK8及之后的版本:数组+链表+红黑树,当链表的长度超过8时,链表就会转换成红黑树,从而降低时间复杂度(由O(n) 变成了 O(logN))
②对数据重哈希:
JDK8 及之后的版本,对 hash() 方法进行了优化,重新计算 hash 值时,让 hashCode 的高16位参与异或运算,目的是在 table 的 length较小的时候,在进行计算元素存储位置时,也让高位也参与运算
③插入元素的方式:
在 JDK7 及之前的版本,在添加元素的时候,采用头插法,所以在扩容的时候,会导致之前元素相对位置倒置了,在多线程环境下扩容可能造成环形链表而导致死循环的问题
DK1.8之后使用的是尾插法,扩容是不会改变元素的相对位置
④扩容时重新计算元素的存储位置的方式:
JDK7 及之前的版本重新计算存储位置是直接使用 hash & (table.length-1);
JDK8 使用节点的hash值与旧数组长度进行位与运算,如果运算结果为0,表示元素在新数组中的位置不变;否则,则在新数组中的位置下标=原位置+原数组长度
⑤扩容决策不同
JDK7 是先扩容后插入,这就导致无论这次插入是否发生hash冲突都需要进行扩容,但如果这次插入并没有发生Hash冲突的话,那么就会造成一次无效扩容;
JDK8是先插入再扩容的,优点是减少这一次无效的扩容,原因就是如果这次插入没有发生Hash冲突的话,那么其实就不会造成扩容
HashMap和Hashtable的区别
线程安全
HashMap线程不安全,效率高
HashTable线程安全,效率低
null键值
HashMap可以存储null键和null值
HashTable不可以存储null键和null值
数据结构
HashMap使用数组+链表+红黑树
HashTable使用数组+链表
初始容量和扩容方式
HashMap默认初始容量为16,每次扩容为原来的2倍
HashTable默认初始容量为11,每次扩容为原来的2倍+1
元素的Hash值
HashMap会重计算
HashTable直接使用object.hashCode()
继承的父类
HashMap继承自AbstractMap类
HashTable继承自Dictionary类
HashMap线程不安全
线程不安全的体现
头插法导致同一位置节点顺序相反,导致出现死循环
多线程操作HashMap插入元素,数据可能丢失
线程不安全的解决方案
使用HashTable
使用Collections.synchronizedMap()方法来获取一个线程安全的集合,底层原理是使用synchronized来保证线程同步
使用ConcurrentHashMap
15.ConcurrentHashMap,线程安全
原理
JDK8相比JDK7,抛弃了 JDK7 版本的 Segment分段锁的概念,而是采用了 synchronized + CAS 算法来保证线程安全,可以大大减少使用加锁造成的性能消耗
JDK7中,ConcurrentHashMap使用分段锁机制,数据结构可以看成是Segment锁+Entry数组+链表,一个ConcurrentHashMap实例中包含若干个Segment实例组成的数组,每个Segment实例又包含由若干个桶,每个桶中都是由若干个HashEntry对象链接起来的链表
JDK8 降低了锁的粒度,采用table数组元素作为锁,从而实现对每行数据进行加锁,进一步减少并发冲突的概率,并使用synchronized来代替ReentrantLock,因为在低粒度的加锁方式中,synchronized并不比ReentrantLock差,在粗粒度加锁中ReentrantLock可以通过Condition来控制各个低粒度的边界,更加的灵活,而在低粒度中,Condition的优势就没有了
数据结构
JDK7:Segment数组+HashEntry数组+链表
JDK8:HashEntry数组+链表+红黑树
核心过程
存储时对key进行重哈希,通过segmentFor()计算出元素属于哪个Segment,插入前,使用lock()进行加锁,之后使用头插法插入元素,锁是基于Segment,其他插入操作只要Segment没有被锁,不受影响
插入元素之前,会检测本次操作会不会超过Segment元素数量超过扩容阈值,超过则执行扩容操作后再插入
其他
size():
JDK7:ConCurrentHashMap没有使用全局计数器,而是给每个Segment定义自己的计数器,执行size()时,先尝试不对Segment加锁统计,如果统计过程中元素个数发生了变化,再对所有的Segment加锁统计
JDK8:扩容和addCount()中先行处理,等到调用size()时直接返回元素的个数