[TOC]
[TODO]
- List接口
- ArrayList
- TODO clear()方法
- TODO isEmply()方法
- TODO contains()方法
- TODO retainAll()方法
- TODO toArray()方法
- TODO spliterator()方法
- TODO forEach()方法
- TODO removeIf()方法
- TODO stream()方法
- filter()
- map()
- mapMulti()
- mapMultiToInt()
- mapMultiToLong()
- mapMultiToDouble()
- mapToInt()
- mapToLong()
- mapToDouble()
- flatMap()
- flatMapToInt()
- flatMapToLong()
- flatMapToDouble()
- distinct()
- sorted()
- peek()
- limit()
- skip()
- takeWhile()
- dropWhile()
- forEach()
- forEachOrdered()
- toArray()
- reduce()
- collect()
- min()
- max()
- count()
- anyMatch()
- allMatch()
- noneMatch()
- findFirst()
- findAny()
- gather()
- toList()
- TODO parallelStream()方法
- TODO equals()方法
- TODO hashCode()方法
- TODO clone()方法
- TODO toString()方法
- LinkedList
- (拓展)Vector
- (拓展)Stack
- ArrayList
- Set接口
- HashSet
- TreeSet
- (拓展)LinkedHashSet
- (拓展)ConcurrentSkipListSet
- (拓展)CopyOnWriteArraySet
- (拓展)Queue接口
- ArrayDeque
- LinkedList
- PriorityQueue
- ConcurrentLinkedQueue
- ArrayBlockingQueue
- DelayQueue
- (扩展)配套工具
- 迭代器(Iterator)
- 集成工具类(Collections)
Collection
这一份将介绍有关Collection这个抽象类的相关内容
首先,通过这里的描述我们也清楚了,这个类是一个抽象类,所以是没办法直接使用的
所以我们一般使用的是他的子接口,而他的子接口又有许多具体的实现类
List接口
这个接口是List接口,具体的实现类有如下几个:
1 | ArrayList : 动态数组 |
接下来开始一一介绍
ArrayList
首先是ArrayList
这个属于动态数组,也就是说如果空间不够会自己扩容
如何创建
接下来讲讲如何创建一个ArrayList
最基本的框架如下:
1 | ArrayList<具体类型> lst = new ArrayList<泛型>(构造器参数); |
接下来分别讲一下这里的各个内容
首先第一个,ArrayList 后面跟着的具体类型,这个具体类型的作用为:将泛型类 / 接口具体化
也就是说通过具体的类型来约束集合的操作,使得最后编译的时候类型安全
如何理解呢?类型实参可以给这个参数(lst)赋予一个具体的类型,而编译器会在编译的时候根据类型的实参在编译阶段就检查集合的取和存操作是否符合约定
也就是说,使用类型实参可以有效地防止了添加错误的类型,避免了运行时的转换错误
1 | ArrayList<NewObject> lst = new ArrayList<>(); |
为什么要这样干
有人可能就要问了,那为什么要这样干呢?
我们不妨假设一下,如果不进行这样,而是使用错误的做法那会怎么样
如果我们选择lst.add("This is a String")——也就是传入一个字符串
那么在之后读取lst的元素的时候,由于强制类型转换的原因,程序会抛出ClassCastException的错误:
1 | ArrayList lst = new ArrayList<>(); |
如果我们有通过填写类型实参来检查,那么就可以在编译的阶段解决这个问题
当然,使用参数实参肯定是不止这个的
如果使用类型实参,便可以减少在代码中出现的强制类型转换:
1 | // 此处未填类型实参 |
如果使用类型实参则会是这样的效果:
1 | ArrayList<NewObject> lst = new ArrayList<>(); |
这里便可以不用手动强制类型转换,大大减少了问题的出现
接下来是后面的创建内容,首先是尖括号里面的内容
第一个部分是ArrayList后面跟着的尖括号<>,这个尖括号主要是起到泛型的作用,在尖括号里面填入指定的类型可以起到限定此类才可以传入的效果
不过由于版本更替,如果你在类型实参中已经有填写,那么其实是没必要在后面的尖括号里面填写的
1 | // 指定类型为String |
这里其实把泛型的内容省略的,在Java 7+后,编译器会自动匹配
1 | // 这里泛型没有填写,因为编译器会自己自动匹配 |
特别注意
如果你需要指定泛型为整数类,或者是浮点数类
必须要使用其包装类,而不能使用基本数据类型:
1 | ArrayList<int> list = new ArrayList<int>();// 错误,不应该使用基本数据类型 |
接下来讲讲圆括号内的内容
圆括号主要是调用构造方法,你可以在这里面指定默认的大小,或者是复制一个集合到这里面去
如果你在圆括号里面填写一个数字,便可以直接指定这个集合的默认大小
1 | ArrayList<String> list = new ArrayList<>(10); |
在上面的例子中,我们将这个集合的默认大小指定为10个元素
如果你选择不填写(也就是默认),那么这个集合的长度将会设定为10个的大小
接下来是另一个,复制集合
假设我们之前已经创建过一个集合,并且往这个集合里面填写了元素
1 | ArrayList<String> source = new ArrayList<>(); |
那么我们便可以在新的集合的圆括号里面填写这个集合的名字(例如这里是source)
这样便可以把旧集合的元素复制到新的集合里面
1 | ArrayList<String> source = new ArrayList<>(); |
特点
那么有什么特点呢?首先第一个就是ArrayList是一个动态数组,也就是说,其可以自动的扩容
假设我们一开始的长度为10,将这个10个大小全部用完后,这个数组会自动扩容,默认扩容大小为原大小的1.5倍
为什么是1.5倍呢?如果倍率太小,那么扩容的空间太小,扩容的次数便会大大增加,这意味着会频繁的复制元素
但是复制元素是耗时的,所以倍率太小会导致浪费一些时间
既然这样,那为什么不要选择大一点的倍率呢?
这是因为如果倍率太大,可能会导致占用过多的内存
权衡之下,选择1.5倍是最好的选择
ArrayList还有一些特性
作为一种动态数组,ArrayList支持存放相同元素
ArrayList可以存放null:
1 | ArrayList<String> list = new ArrayList<>(); |
ArrayList具有有序性,意味着元素的插入顺序和访问顺序是一致的
在访问ArrayList的元素的时候,通过索引get(int index)可以很快地找到元素
其时间复杂度为:O(1)
假设需要往这个数组中间插入或者删除元素,那么其时间复杂度为:O(n)
这是因为每次从其中删除或者添加元素的时候需要将这个元素后面的元素移动一个单位
此外,由于ArrayList的所有方法都是没有同步锁的,这就导致了多线程操作的时候可能会出现并发的问题
如果需要线程安全,那么可以使用:
1 | List<String> list = Collections.synchronizedList(new ArrayList<>()); |
使用集成工具类中的synchronizedList方法,便可以解决多线程的问题
当然这里也可以直接使用CopyOnWriteArrayList
使用方法
这里将接下ArrayList的一些具体使用方法
由于这些方法基本上是通用的,所以在这里讲过一次后,接下来的实现类便不会再讲
ArrayList包括了List接口的所有方法
add()add()方法用于往数组末尾添加元素
1 | ArrayList<String> list = new ArrayList<>(); |
如果在添加元素的时候发现数组不够用了,那么会先扩容在添加
此外,add()还可以指定插入的位置
假设我们想要插入的位置为第二项,那么只需要这样填写参数即可
1 | list.add(1,"插入第二项") |
第一个参数为插入的位置,由于数组计数从零开始,所以这里得填写1来代表第二项
而第二个参数代表插入的内容
get()get()为返回指定索引位置的元素
需要注意的一点是,这里返回的内容其实为底层数组elementData[index]
什么是elementData
这里可能就有人要问了,诶,那什么是elementData呢?
在ArrayList.java里面是这样写的:
1 | private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {}; |
可以基本理解,这个elementData便是ArrayList底层的数组,平时丢进去的元素都会存放在这里面
接下来回到正题,get()通过访问底层数组elementData来获取到指定位置的元素
假设此时索引的范围超过底层数组(也就是越界),或者数组的这个位置没有任何元素,那么会抛出这个错误:
1 | ArrayList<String> list = new ArrayList<>(); |
set()
接下来是set()
这个方法的作用是替换指定位置的元素为新的元素
这个方法具有两个参数:
第一个参数为替换的位置,而第二个参数为想要替换的新元素
与上文的get()同理,这里本质也是对底层的数组elementData进行修改
此处也需要先检查是否越界,如果越界一样会报错
1 | ArrayList<String> list = new ArrayList<>(); |
此处为什么不写成这样呢?
1 | ArrayList<String> list = new ArrayList<>(); |
这是由于在ArrayList.java中,set()的实现代码为:
1 | public E set(int index, E element) { |
可以看到,这里现将变量oldValue赋值为旧的值elementData(index)
而后才替换为新的值elementData[index] = element;
而返回的值为oldValue,这意味着返回的结果为替换前的值
remove()
1 | ArrayList<String> list = new ArrayList<>(); |
如果当前位置有元素,那么会返回这个删除的元素:
1 | ArrayList<String> list = new ArrayList<>(); |
诶,在之前也有提到一个点:
假设需要往这个数组中间插入或者删除元素,那么其时间复杂度为:O(n)
这是因为每次从其中删除或者添加元素的时候需要将这个元素后面的元素移动一个单位
这里的意思是,在删除一个中间元素后会将后面的所有元素向前移动一个单位,说白了就是将后面的元素复制一遍
从底层代码我们也可以看到这一点:
1 | public E remove(int index) { |
通过观察这段代码,我们也可以发现一个很有意思的点
remove()移除指定位置的底层实现,其实是覆盖,也就是先将这个元素后面的所有元素移动前一位,而后将原本的最后一位设定为null
为什么要设定为null呢?这里其实在帮助GC回收
举个具体例子
假设我们有个数组为100位
我想要删除第50位,那么底层的实现是这样的:
先复制51~100位的元素到50~99位,之后设定100位为null
之后返回替换前的50位
size()
接下来是size()
size()的作用是返回底层数组所拥有元素的数量
1 | ArrayList<String> list = new ArrayList<>(); |
clear()
接下来是clear()
这个方法比较简单,主要的作用是清空当前数组
1 | ArrayList<String> lst = new ArrayList<>(); |
可以看到,在使用clear()方法后,这个数组中的元素便被清空了,之后输出也是重新添加的元素
这个方法的代码实现也很简单粗暴:
1 | public void clear() { |
首先是将操作数modCount增加一个单位,之后便将es赋值为这个数组
赋值后便是一个简单的for循环,这个for循环中的条件中size为数组长度
这里有个很巧妙的点,我们在使用clear后数组为空,此时数组长度为0
此处在赋值给to后又使用了i = size = 0,这样就使得数组长度被设定为0,符合清空的情况
循环语句的作用为将这个数组的每一项都设定为null值,利用这种方法来实现清空数组
isEmply()
接下来介绍isEmply方法,这个方法的主要作用是检测当前数组是否为空,如果为空则返回true,否则返回false
1 | ArrayList<String> lst = new ArrayList<>(); |
可以看到,在执行clear方法将数组清空后,isEmpty()显示为true
代码实现也很简单:
1 | public boolean isEmpty() { |
可以看到这里的代码实现就是通过检测数组大小是否为0,并且返回结果
contains()
接下来是contains方法
这个方法有一个参数可选:contains(Object o)
其中参数o代表需要检测的元素
这个方法的检测当前数组中是否有括号内的参数
1 | ArrayList<String> lst = new ArrayList<>(); |
假设这个数组内有该元素,那么则会返回true
那么他的代码实现是怎么样的呢?
其实很简单:
1 | public boolean contains(Object o) { |
可以看到,这个方法实际上是复用了indexOf的方法,并通过这个方法的返回值是否大于等于0来判断是否为true
为什么可以这样呢?其实很简单,因为这个方法实际上是返回该元素所在位置
如果没有该元素,则返回-1
通过这种形式,成功精简了代码
此处实现了DRY原则
retainAll()
接下来介绍这个方法
这个方法拥有一个参数:retainAll(Collection<?> c)
这个参数的意思是支持所有实现Collection接口的集合,例如List接口,Set接口
这个方法的主要用法是A.retainAll(B)(A和B都是符合条件的数组)
这里代表将A赋值为A和B的交集
1 | ArrayList<String> arrayList1 = new ArrayList<>(); |
toArray()
接下来来讲讲这个方法
首先这个方法拥有一个参数:toArray(T[] a)
完整的版本如下:<T> T[] toArray(T[] a)
当然也有没参数的版本:Object[] toArray()
这个方法的作用是将一个集合转变为数组
接下来将分别演示带参数和不带参数的版本:
1 | Random random = new Random(); |
可以看到,这里选择了不加参数的版本,对应的类型也是选择了:Object
但是一般来讲并不建议使用这种做法,因为这种做法在使用的时候一般需要进行强制类型转换,如果不进行强制类型转化,那么会导致报错:
1 | Random random = new Random(); |
上面是正确做法,但是不正确会怎么样呢(不正确也就是没有使用强制类型转换)
1 | Random random = new Random(); |
上面便是没有使用强制类型转换的代码,如果没有使用强制类型转化,则会显示出:
运算符 '+' 不能应用于 'java.lang.Object'、'java.lang.Object'
但如果使用带参数的形式,那么便可以十分简单地解决这个问题
1 | Random random = new Random(); |
上面这里将原本的Object[]替换为Integer[],相对应的,后面的参数也要填写对应的类型
在这里例子中,两个项数相加的时候便没有使用强制类型转换
在一般情况下都是建议使用带参数的版本的,一方面这样更加安全,另一方面,不带参的版本属于历史遗留问题
从性能角度看,不带参数每次都需要创建新的数组,而带参数的可能会复用传入的数组
当然,除非你真的需要用到Object
这里我们可以补充一个内容,上文的带参数的形式使用了new Integer[0]
那么这里的[0]是什么意思呢
其实这里是在声明创建数组的大小,那么有人就要问了:诶,0不是代表长度为0吗?
是的,但是Java在创建数组的时候有一个有意思的特性:
如果传入的集合大小是刚好与创建的数组声明的大小是一致的,那么会直接存入并返回
如果是小于(也就是空间不足),那么会创建新数组并返回
如果是大于的,那么则会在存入集合后把多余的空间用null填充
补充点:为什么要使用数组
说了这么多,还没有提及到为什么要使用数组
确实,原先的集合已经很方便了,但是在性能上,数组的是要比集合更加好的:
我们这里就以ArrayList为例,这个集合的底层实现也是数组
首先,我们需要搞懂ArrayList到底是怎么存的
说的简单粗暴一点,其实里面存的东西实际上并不是直接的元素,而是指向对应元素的引用
这一点其实有点像C中的指针
这就导致了什么,每次查询都是间接查询,而不是直接查询
但是数组呢?由于其特性,每个元素都是按顺序排序的
这就导致了在查询的时候时间十分快
并且由于不用间接可以直接查询
这种特性对于CPU的缓存是更好的
在这一点上,数组性能是要高于集合的,尽管这两者实际使用效果差不多
forEach
forEach方法是一个用于批量操作的方法
其带有一个参数:forEach(Consumer<? super E> action)
这个参数的意思是在里面输入相对应的行为:
1 | Random random = new Random(); |
这里的意思的将arrayList的所有元素都使用System.out中的println方法输出
除了这种简单的方法,forEach方法还可以使用代码块来定义行为
1 | Random random = new Random(); |
注意!在使用的过程中不可使用任何会引起modCount改变的方法,否则会抛出报错
1 | Random random = new Random(); |
上面使用了add,这个方法会导致modCount发生改变,所以不可以使用
不过Iterator的remove是可以使用的,原因之前也提及到的
接下来需要补充一个点,就是forEach方法是无法获取到遍历的下标的,如果需要索引下标,则必须自己新增变量自增获得
1 | Random random = new Random(); |
removeIf()
接下来介绍这个方法
这个方法有一个参数:
这个参数代表可以选择的筛选方法
这个方法的作用就是根据筛选的方法来删除指定元素,如果满足条件则删除该元素
筛选方法依旧支持Lambda表达式
1 | Random random = new Random(); |
可以看到,上面的数组一部分满足条件的元素被删除了
这个方法还可以用于删除null值
1 | Random random = new Random(); |
equals()
接下来介绍这个方法
这个方法的作用为判断是否相等
这个方法有一个参数:equals(Object o)
参数o代表一个对象
在这里的用法为判断两个集合的元素是否相等:
1 | ArrayList<String> arrayList = new ArrayList<>(); |
可以看到,这两个集合的元素完全一致,所以返回true
注意,如果两者属于不同接口,那么即使元素相同也会返回false
1 | ArrayList<Integer> arrayList = new ArrayList<>(); |
这里分别为两个不同的接口,所以即使内容相同,也是返回false
但如果接口相同,内容相同,但是实现类不同,那也是会正常返回true的:
1 | ArrayList<String> arrayList = new ArrayList<>(); |
为什么会这样?这是因为在源码中已经写清楚了:
1 | public boolean equals(Object o) { |
可以看到,这里的第二个if语句便是判断是否属于同一接口的方法,上文输出为false的例子一个为List接口,而另一个为Set接口,所以返回为false
补充点:null安全
这里补充一点,equals()方法是null安全的
什么是null安全呢?简单来说就是在处理集合中的null元素的时候不会抛出空指针报错:NullPointerException
那么为什么要特意强调这一点呢?
因为在Java中null指的是没有指向任何对象的引用
如果试图对null调用任何方法,则会抛出NullPointerException
但是equals()是如何处理的呢?
可以在源码里面看到
1 | if (!(o1==null ? o2==null : o1.equals(o2))) |
这里是一个三元表达式,具体的逻辑如下:
如果o1为null,那么则判断o2是否为null
如果也为null,那么条件正确,返回true,但由于为!,所以实际为false,不执行判断内的语句
而如果o2不为null,那么则返回false,进而转变为true,执行语句return false,这就跟我们的目的相一致(因为一个为null一个不为null)
而如果o1不为null,那么就执行o1.equals(o2)
如果两者相等,那么则返回true,转换为false,不执行语句
可以看到,无论怎么样,这里都会正确的比较
那么问题来了,为什么不直接用equals()
我们不妨看看这里(指的是o1.equals(o2))的equals源码是怎么写的:
这里的equals来自这个文件 -> Object.java
1 | public boolean equals(Object obj) { |
可以清楚地看到,这里并没有对null进行处理
不进行处理会导致什么后果呢?很明显,那就是:NullPointerException
hashcode()
接下来是hashcode方法
这个方法没有参数,主要的使用用途为计算指定元素的哈希值:
1 | ArrayList<String> arrayList = new ArrayList<>(); |
一个比较常见的用法是通过比较生成的哈希值来判断两个值是否相等:
1 | ArrayList<String> arrayList = new ArrayList<>(); |
不过需要说明的一点是,上面的比较方法是有点问题的
在Java中经常是有些元素的哈希值是相同的,比如通话和重地
1 | ArrayList<String> arrayList = new ArrayList<>(); |
那么要怎么解决这个问题呢?其实很简单:
1 | ArrayList<String> arrayList = new ArrayList<>(); |
通过先比较哈希值再比较字符串是否相等,这样便可以解决哈希碰撞的问题
更多的内容可以参考后面HashSet的 补充点:底层实现
clone()
接下来讲讲这个方法
在讲这个方法的具体用法之前,需要先补充一下浅拷贝和深拷贝的内容
补充点:浅拷贝与深拷贝
此处可能会使用到C里面的指针来辅助理解
首先是浅拷贝
假设现在有一个数组,你使用浅拷贝拷贝这个数组,那么拷贝后的新数组中是原来的元素吗
虽然说表面上是一样的,但实际上是不一致的
新数组拷贝的是老数组中的对各个元素的引用,这意味了什么?
意味着假设你修改了老数组中引用对应的元素,那么拷贝后的数组也会被修改
1 | ArrayList<StringBuffer> arrayList = new ArrayList<>(); |
可以看到,即使是先复制再修改也没办法改变,因为实际上是复制引用而不是直接复制对应的元素
这里可能有人就要说了:诶,那为什么我的结果跟这里说的不一样:
1 | ArrayList<String> arrayList = new ArrayList<>(); |
可以看到,这里即使修改了原数组复制后的数组依旧没有改变
难道是出问题了吗?不是,其实这里跟所选的类型有关
我们可以看到,在第一个例子中我们选择的类型为:StringBuffer
而第二个例子中我们选择的是String
诶,那这俩有什么区别呢?
很简单,一个可变,一个不可变
说白了就是StringBuffer是可变的,所以当原数组变了复制的数组也就变了
而String是不可变的,该怎么样就怎么样,不管是不是被修改的
那么此处来通过C的例子来简单说明一下浅拷贝
假设我们有一个装满指针的数组,浅拷贝就是复制这个数组
这个数组有什么呢?有指针,指向不同的内存地址
那么,假设我现在修改原数组中某一个指针指向的一个元素,
那么这个指针指向的就是这个新的元素
由于复制的只是指针,所以复制的数组也会指向这个元素(由于内存地址不变,这个指针指向的还是那个地址)
那么要怎么解决这个问题呢?其实很简单,使用深拷贝即可
诶,那深拷贝又是复制什么呢?
准确来讲,深拷贝其实是创建了一个新的对象,然后递归复制所有原先数组引用的底层对象
说白了就是直接复制元素而不是复制引用
不过有一说一,之前举的String的例子其实就有点深拷贝的意味了
不过这玩意由于是误打误撞实现了,所以不能这么记
在讲完浅拷贝和深拷贝之后,就要正式介绍有关clone()方法的内容了
首先这个方法并没有任何的参数:Object clone()
并且在使用的时候还要记得显式类型转换,否则会报错:
1 | ArrayList<String> arrayList = new ArrayList<>(); |
正确的写法是这样的:
1 | ArrayList<String> arrayList = new ArrayList<>(); |
可以看到,这里就正常运行了
补充点:为什么会有警告
如果你使用IntelliJ IDEA并且打出上面那段代码,那么你会发现编译器会给你一个警告:未检查的转换: 'java.lang.Object' 转换为 'java.util.ArrayList<java.lang.String>'
那么这是什么意思呢?简单来说就是编译器没办法在运行的时候判断其正确性
虽然说ArrayList重写了clone()方法,但为了兼容性,返回类型依旧为Object
1 | // 来自ArrayList.java |
那么有什么问题呢?
为了正常运行,我们在使用clone()的时候是有进行显式类型转换的
但是Java的泛型在运行时会被擦除(Type Erasure)
虽然JVM知道这玩意是ArrayList,但是不知道是哪个类型
这就导致了无法确定这玩意是不是真的是装着String的ArrayList
那要怎么办呢?其实很简单:那就是换一种复制方法:
1 | ArrayList<StringBuffer> arrayList = new ArrayList<>(); |
可以看到,这里使用了new ArrayList<>(arrayList),这种方法就不会抛出警告了
顺带一提,这种方式也是浅拷贝(看输出也可以明白)
不过如果你真的很想使用原先的clone()方法,但不想看到警告,那么可以在这个语句的上一行加上这么一句:
1 | ArrayList<StringBuffer> arrayList = new ArrayList<>(); |
这里加上了@SuppressWarnings("unchecked")语句,成功消除了警告
toString()
接下来讲讲toString方法,这个方法有点类似Python中的__str__魔术方法
一般而言,这个方法会被重写成想要的方法来方便输出对应的数据
1 | class Aclass{ |
可以看到,我们这里重写了这个方法:toString(),是的他可以按我们规定的输出方式输出
那么在ArrayList里面又是如何的呢:
1 | ArrayList<String> arrayList = new ArrayList<>(); |
可以看到,这里输出的结果实际上和直接使用该集合作为参数是一致的:
那么具体又是如何的呢?
1 | public String toString() { |
这段代码来自:AbstractCollection.java,ArrayList中toString方法的实现就是来自这里
此处的基本原理是:如果一开始无法用迭代器返回下一个元素,则直接返回:[]
这里其实也就是在处理 null
如果不属于null,那么则处理另一种情况:
首先第一步:是往StringBuilder里面添加:[来模拟数组的输出
之后开始一个一个遍历元素
此处用到了一些很巧妙的点,注意看这里的for循环三个表达式都是没有的,唯一的退出条件就是迭代器无法返回下一个元素(也就是结束的情况)
这样的好处是可以完全遍历这个集合
此处是先检测是否集合遍历结束才来加上逗号和空格的
ArrayList也可以重写这个方法,与之前演示的例子没有太大差异,这里便不多阐述
stream()
接下来是stream()方法,这个方法下面有高达35个方法,这里将依次介绍
但是在这之前我们需要介绍一下stream是来干什么的
首先简单介绍一下,这个方法正如他的名字一样:可以进行流式处理
相当于说把这玩意通过一个流水线一点一点进行处理,最后输出想要的结果
但在正式介绍这个方法之前,我们需要先介绍一下里面的一些小概念:
首先是中间操作和终端操作
中间操作的意思是这个操作可以连接前后的方法,构建一条完整流,而终端操作意味着这个方法作为结尾的方法,只能在结尾使用(因为这种方法无法继续连接其他方法)
那么有什么方法可以判断是哪一种操作呢?其实很简单,只需要看这个操作的返回值即可
一般而言,如果返回值为stream的话,这个方法便为中间操作(例如filter(),distinct()等)
如果返回值不为stream,而是为其他的话,那么这个方法便为终端操作
这里再讲深入一点
中间操作为惰性操作,也就是不会立刻执行
而终端操作是会立刻执行的,进而激活整个流
上文也提及到,终端操作属于结尾的操作,所以只能有一个
而中间操作属于整个流里面的一个组成部分,所以是可以有多个的
filter()
此方法返回类型为stream,为中间操作
filter这个方法的作用是作为一个筛子筛选出对应的元素
比如说我想要筛选出所有第一个字母为a的元素,那么可以这么操作:
1 | String[] itemList = {"banana", "apple", "cherry", "blueberry", "avocado"}; |
可以看到,在上面这个例子中我们通过filter方法成功筛选出所有以字母a开头的元素
虽然这玩意可以筛选指定元素,但是并不会对原来集合进行修改
distinct()
此方法返回类型为stream,为中间操作
接下来介绍一下distinct()这个方法
这个方法的作用是去除所有的重复元素,只保留一个
效果有点像是Set接口的实现类
1 | String[] numbersList = {"1","2","1","2","5","3","2","3","4","3","2","3","3"}; |
从上面便可以看出,在通过distinct()处理之后,输出的元素便没有任何重复的内容了
我们可以利用这个方法去重
这个方法的底层实现也是很有意思的,其去重的原理跟Set接口其实差不多,通过使用equals()和hashcode()方法来实现去重
sorted()
此方法返回类型为stream,故为中间操作
接下来是sorted(),这个方法的作用是对当前流进行排序
有点类似TreeMap或者TreeSet
这里我们依旧以上文distinct()中的例子作为例子
1 | String[] numbersList = {"1","2","1","2","5","3","2","3","4","3","2","3","3"}; |
可以看到,在使用了sorted()之后,输出的结果便不是之前的乱序了,而是以正序排列
既然这个可以实现排序,那么自然也可以定义排序,相关的内容可以参照TreeSet的补充点:定义排序规则
这里简单举几个例子:
1 | String[] numbersList = {"apple", "apricot", "banana", "cherry", "coconut", "avocado", "durian"}; |
这里的排序规则是这样的:Comparator.comparing(String::length).thenComparing(Comparator.naturalOrder())
那么这个是什么意思的,简单来说就是先按字符串长度排序(String::length)
然后再以自然排序的方式排序(Comparator.naturalOrder())
补充点:有关如何排序
sorted()的底层使用了双轴快排(Dual-Pivot Quicksort)
这个东西是传统快排的一个改进版本,使用了三个分区,而传统快排只用了两个分区
使用双轴快排的好处是减小常数因子(两种快排的时间复杂度都是O(n log n))
通过减少常数因子来得到更好的效率,这也就是为什么选择双轴快排的原因
peek()
此方法返回值为stream,故为中间操作
接下来介绍peek()这个方法
这个方法很简单,就是在流的指定的地方来进行一次操作(这个操作不会产生影响)
一般而言,这个东西会用于日志这一类操作里面
不过这里就简单的演示一下:
1 | String[] numbersList = {"apple", "apricot", "banana", "cherry"}; |
可以看到,这里插入了一个打印的操作
需要注意的一个点是,如果没有任何的终端操作,那么是不会执行peek的操作的
limit()
该方法返回类型为stream,故为中间操作
接下来介绍一下这个方法
这个方法的作用是截取原流中的前n个元素并返回作为新的流
举个例子:
1 | String[] numbersList = {"apple", "apricot", "banana", "cherry"}; |
可以看到,这里输出部分就只有两个了
那么这也引出了一个问题,这玩意如果大于小于等于原流长度会怎么样呢?
简单概括一下:
如果原流小于截取长度,那么返回原流
如果原流等于截取长度,那么返回原流
如果原流大于截取长度,那么返回截取的长度
扩展:短路操作(Short-Circuiting Operation)
这里是一个用于扩展的用法,主要说明limit()方法的实际使用
不过由于此处还需一些终端操作,便不多扩展
假设你有一个很长很长的数组,你需要选取这个数组里面的一些元素,于是你打算遍历这个数组来寻找想要的元素
所以你向机器发送了“遍历这个数组”的指令
过了一会后,机器返回给你想要的元素,但是你发现了一个点,所需的元素其实很快就找到的,但是由于要遍历整个数组,所以花费时间和资源很多
那么要怎么解决这个问题呢?其实很简单,只需要使用短路操作即可
所谓短路操作,指的是在实现某个条件后就直接中断(类似短路)
假设我们把这个数组变成无限长度,此时我们想要前5个偶数
假设我们是通过遍历的方法,那么无论如何都得不到输出,因为中间操作为惰性方法,必须有终端操作才行,而数组是无限长的,所以永远达不到
但是如果我们选择短路操作,便可以达到这个目的:
1 | Stream.iterate(1, i -> i + 1) |
可以看到,这里在一个无限数组里面成功输出了前五个偶数
skip()
该方法返回类型为stream,故为中间操作
接下来介绍一下skip()方法,这个方法比较简单,具体的功能为跳过指定的n个元素(或者说从第n + 1个元素开始)
1 | String[] numbersList = {"apple", "apricot", "banana", "cherry"}; |
可以看到,这里输出的内容是从第三项开始的,也就是跳过了两项
不过需要提醒的一点是,如果这个是有序流(例如这里的ArrayList),那么则会正常的跳过前n项
但如果是无序流(例如Set接口里面的集合),由于不知道顺序是什么,所以跳过的元素是随机的
这里同样引出一个问题:如果数组长度大于等于小于原流会怎么样
简单概括如下
如果大于原流,那么什么都不返回
如果等于原流,也是什么都不返回
如果小于原流,那么按正常结果返回
除此之外,如果n小于等于0,那么会抛出这个报错:
1 | String[] numbersList = {"apple", "apricot", "banana", "cherry"}; |
在实际的应用中,你可以利用这个方法实现翻页效果
此处仅限小数据!大规模数据这里花费时间为n,太慢了!
1 | String[] itemList = {"apple", "apricot", "banana", "cherry", "coconut", "avocado", "durian", "damson"}; |
可以看到,这里输出的便是第三页的内容
每页有两个元素,从第三页开始,那么便跳过了 2 * (3 - 1) 个元素
takeWhile()
该方法返回的类型为stream,故为中间操作
该方法有一个可填参数,该参数用于判断
接下来是takeWhile方法,这个方法的作用是按顺序选取流里面的元素,直到出现不满足条件的元素,并返回新流
举个例子:
1 | String[] itemList = {"apple", "banana", "cabbage", "date", "brush", "comb"}; |
可以看到,这里选择的标准为:若该元素长度大于4,则返回为真
我们可以稍微模拟一下:
首先第一次:apple,长度为5,大于4,所以为真
第二次:banana,长度为6,大于4,所以为真
第三次:cabbage,长度为7,大于4,所以为真
第四次:date,长度为4,等于4,不满足条件,退出
可以看到,第四次条件不满足,所以直接退出了,返回的新流为一到三次的结果
与上文的limit一样,这个方法也可以用于短路操作
由于这个方法读取顺序是按顺序读取,所以如果为无序流则会随机读取
dropWhile()
此方法返回类型为stream,故属于中间操作
这个方法的作用与takeWhile差不多,主要效果与上文的takeWhile效果是相反的
换句话说,这个方法的作用是在判断为真时跳过,如果判断为假,则截断并选取后面所有的元素
我们通过一个例子来说明:
1 | String[] itemList = {"apple", "banana", "cabbage", "date", "brush", "comb"}; |
通过上文我们可以知道此处在第四次的时候返回为假,所以返回的结果就是该项后面的各个元素
forEach()
该方法返回类型不为stream,故为终端操作
接下来介绍这个方法,这个方法属于终端操作,所以只能放置在最后一个位置
并且在上文也提及到了,每个操作流中只能有一个终端操作
在简单介绍完终端操作之后,便开始介绍这个方法:
这个方法的作用是对流中的每一个元素执行指定的操作
在上文其实很多例子都使用了这个方法,但是此处还是再次举个例子来说明
1 | String[] itemList = {"banana", "bag", "ball", "comb", "band", "bath"}; |
可以看到,这里forEach语句对所有的元素都使用了System.out::println,也就是将流里面的每一个元素都打印出来
forEachOrdered()
接下来的这个方法与forEach方法是一致的,但是在细节上有些不同,此处重点讲不同,具体的使用方法可以参照forEach()
首先这个方法的一大特点就是必定按顺序输出
诶,这里可能就有人有疑惑了,什么叫做必定按顺序输出呢?
要讲这个的话就得简单引入parallelStream(),并行流
这个流在处理的时候会使用到多线程,在合并的时候可能出现一些合并上的问题,比如说使用forEach()时,如果线程2比线程1先完成,那么会直接执行,不等线程1,举个例子:
假设被操作的流中元素为A B C
其中A耗时最大,C耗时最少
此时若使用parallelStream().forEach()的话,那么会导致耗时较少的C先输出
而耗时较长的A反而慢输出
这很明显不符合我们的预期
所以便有了这个方法来强制按顺序输出
但这样做是有代价的,forEachOrdered()的性能是不如forEach()的,如果你在使用并行流的时候不在意输出的顺序,那么请使用forEach()来提高性能
扩展:为何有序
接下来简单讲一讲这个方法是如何实现有序的:
首先当你在一个并行流里面调用这个方法的时候,这个流会被分割成好几块(forEach也会分割成好几块,但是后面的处理是不一样的)
在分割完之后便分别开始处理,在开始处理的时候会保留这个元素原先在哪个位置
系统会自动创建一个队列用于缓存,每当有一个处理完之后,不会直接执行,而是把这些放到缓存区里面去
最后,便会有一个专门的线程(不一定是新的,有可能是主线程)来按照顺序取出元素并对其使用action
也正因如此,forEachOrdered比forEach多了一系列的操作,所以会更费性能
toArray()
这个方法返回类型为Object,所以为终端操作
接下来简单介绍这个方法,这个方法很简单,作用就是将当前的流转换为数组:
1 | String[] itemList = {"banana", "bag", "ball", "comb", "band", "bath"}; |
可以看到,这里将用takeWhile筛选后的流转换一个新的数组
这里括号内的参数实际为:类型[]::new,是一种方法引用,不过这里不多阐述
注意,如果没有在toArray的括号里面添加说明想要转换成什么类型的话,会默认转换成Object类型,这就导致了想要使用的时候必须进行强制类型转换
补充点:有何意义
在上文的例子中可能有人看到了这一点:Arrays.toString(newList)
很明显,这里是重新转换为字符串了
可能就有人想问这样有什么意义,但事实上这里只是为了简单演示才使用这个转换的
实际上,使用toArray转换为数组可以让我们使用流中的方法简化一系列操作
假设我们想要筛选整个数组,那么原先的做法是使用for语句加上判断来查找,但是如果使用流只需要使用takeWhile就可以了
这也就是为什么要使用toArray的原因:简化操作
min() & max()
这两个操作都属于终端操作,并且由于效果是很接近的,所以放到一起讲
这个方法其实很简单,就是通过括号内的比较规则来比较出最小 / 最大的那个元素
举个很简单的例子:
1 | String[] itemList = {"banana", "bag", "ball", "comb", "band", "bath"}; |
max方法同理,返回的是最大的
此处使用到了两个新东西(Optional和ifPresent),这里只是为了介绍min的使用效果,所以不多介绍
上面的这个代码排序的规则是根据元素字符串的长度来比较的(String::length),此处使用了方法引用
count()
该方法返回类型为long,故为终端操作
接下来介绍count()
count()这个方法十分简单,作用为计数:
1 | String[] itemList = {"banana", "bag", "ball", "comb", "band", "bath"}; |
需要注意的一点是,这个方法返回的类型为long,不是int!!!
这个方法有个很有意思的点,如果这个流来自一个大小已知的源,并且没有执行任何会修改的操作(比如filter或者takeWhile),那么会直接返回底层的size,时间复杂度为O(1)
但如果有的话则需要遍历整个数组,时间复杂度会变为O(n)
anyMatch()
该方法返回类型boolean,所以为终端操作
接下来是这个方法,这个方法的作用是检查是否有元素满足括号内的条件,如果有的话就返回true,如果没有就返回为false
接下来给个具体例子来说明一下:
1 | String[] itemList = {"banana", "bag", "ball", "comb", "band", "bath"}; |
可以看到,这个方法实现了一次短路操作,在判断满足条件的元素后直接返回true,这样就可以不用遍历整个数组
补充点:关于空流
现在假设这个流里面是没有任何东西的(也就是空流),这时对这个流进行操作怎么样呢?
1 | String[] itemList = {}; |
可以看到,这里并没有执行peek的操作,而是直接返回false
allMatch()
该方法依旧为终端操作
这个方法与上文的anyMatch()效果差不多,一个是找到一个就直接输出true,一个是要所有都满足才会输出true
举个简单的例子:
1 | List<String> lst = Arrays.asList("ba", "bag", "ball", "comb", "band", "bath"); |
在上面这里例子中,我们判断该集合是否全部以ba作为开头,但是该集合中含有不以ba开头的元素,所以返回了false
补充点:关于空流
在上文的anyMatch中,对空流执行操作会返回false,那么对空流使用allMatch()会怎么样呢
1 | String[] itemList = {}; |
可以看到,这里返回的是true,与上面的anyMatch是不同的
noneMatch()
该方法依旧为终端操作
这个方法与上文的两个方法效果差不多的,主要的作用依旧为判断,返回类型依旧为boolean
具体的用法是,如果不满足noneMatch条件内,那么返回true:
举个例子:
1 | List<String> lst = Arrays.asList("ba", "bag", "ball", "comb", "band", "bath"); |
可以看到,这里输出为true,这说明没有以s开头的元素
补充点:关于空流
这里补充一下如果是空流的是时候会出现什么情况:
1 | String[] itemList = {}; |
可以看到,这里输出的结果为true,与上文的allMatch是一致的
findFirst()
这个方法为终端操作
接下来介绍findFirst方法,这个方法的作用是返回流里面第一个元素:
1 | List<String> lst = Arrays.asList("apple", "bath", "breath", "basic", "age", "application", "agreement"); |
可以看到,这里输出了筛选后的第一个元素
由于返回类型为Optional,所以不用担心返回为空的情况
findAny()
该方法为终端操作
接下来介绍这个方法,这个方法的作用与上一个方法findFirst()是差不多的,主要的区别是在选取方法上
上一个方法返回的是找到的第一个满足条件的元素,而这个方法返回的虽然也是第一个满足条件的元素,但是是最先被处理的第一个元素
举个简单的例子:
1 | List<String> lst = Arrays.asList("apple", "bath", "breath", "basic", "age", "application", "agreement"); |
可以看到,这里输出的结果十分出人意料
如果这里使用的是findFirst,那么返回的值将会是找到的第一个满足条件的元素,也就是bath
诶,可能就有人会发现了,为什么自己测试的时候结果是相同的?其实很简单,这个方法只有在**并行流(parallelStream)**才有效果
如果是在普通的流(顺序流stream)中,则会像正常一样输出
为什么会这样呢?因为stream是一个有序的流,所有的元素会按照顺序处理,findAny虽然是输出第一个符合条件的处理元素,但由于顺序流是按顺序处理的,根本不会出现不按顺序的情况,所以stream.findAny()的效果其实与stream.findFirst()的效果是一样的
而并行流就不一样了,这个流会把要处理的集合拆分成几分,分给一些线程处理,输出的内容纯粹看谁先处理完,这就导致输出完全随机,findAny()也就会输出最先处理的元素
toList()
这个方法为终端操作
接下来介绍这个方法,这个方法的作用很简单,将这个流打包为一个集合,之后返回给变量
1 | List<Integer> lst = Arrays.asList(15, 73, 128, 4, 196, 88, 51, 142); |
可以看到,这里输出的结果是一个集合,这个集合打包了满足条件的元素
此处返回的集合是不可变对象,所以不可以使用任何修改集合的方法尝试修改这个集合,比如说add(),remove()
如果试图修改,则会抛出报错UnsupportedOperationException
1 | List<Integer> lst = Arrays.asList(15, 73, 128, 4, 196, 88, 51, 142); |
补充点:为什么这里不可变
可能有人就要问了,这里明明是List接口啊,为什么不可变呢?
其实这个问题很简单,从Java 10开始,Java引进了一些不可变集合工厂方法,而toList()便是其中之一
事实上.toList()返回的是java.util.ImmutableCollections.ListN,而这个子类直接继承自AbstractImmutableList,这个又继承了AbstractList<E>,进而实现了List接口
这个抽象类重写了一些可以修改的方法,举个例子:
1 | static UnsupportedOperationException uoe() { return new UnsupportedOperationException(); } |
这里重写了add方法,使其从之前的往集合里面添加元素变为丢出错误UnsupportedOperationException
也就是在这里实现了不可变集合
那么为什么要不可变呢?一方面是保证线程安全,另一方面是防止不小心被修改,最后减少内存开支
map()
这个方法为中间操作
接下来介绍有关函数式编程的核心之一——map()以及有关的一系列方法
首先简单介绍一下map(),这个方法的作用是在不对原先流里面元素产生任何影响的情况下返回一个新的流(无副作用)
什么意思呢,简单来说就是在不对原先产生任何影响的情况下返回一个新的流
举一个具体的例子来说明:
1 | List<Integer> lst = Arrays.asList(2, 3, 5, 6); |
可以看到,这里返回了一个新的流,并且这个流的每一个元素都是由原先的流经过map()产生得来的
当然,这个简单的例子可能没办法说明什么,接下来再举一个例子
1 | List<String> lst = Arrays.asList("apple", "banana", "grape"); |
可以看到,这里使用了一些操作使得每个单词的首字母大写
具体是什么操作呢?首先获取到该单词的首字母n.charAt(0),然后将其大写Character.toUpperCase(),在大写后就要拼接没有大写的部分了,说的直白点就是除去首字母的部分,那么对字符串操作我们可以使用到这个方法n.substring(1),这个方法去除了第一个字母,也就是首字母
最后将这两者拼起来皆可得到我们想要的结果
如果使用传统的命令式编程,则需要对每一个操作都写出来(遍历,拆分,转大写)
而使用函数式编程则直接对想要修改的地方直接修改即可,相对来讲会方便很多
mapToInt() & mapToLong() & mapToDouble()
这些方法均为中间操作,返回类型为IntStream等
接下来介绍一下mapTo系列方法,这些方法与map()其实功能上差不多,都是将流里面的一个东西转变为另一个东西
但是mapToInt()还多了一个转换为int类型的过程,这一细微的处理直接影响了很多操作
举一个例子,假设我们需要返回该流里面所有数字的平方和,那么我们可以这样干:
1 | List<Integer> lst = Arrays.asList(2, 3, 4, 5, 6); |
可以看到,这里使用到了.mapToInt(Integer::intValue),这里的这个方法引用的作用是将Integer类型拆包为int类型
为什么要这么干呢?这是因为原先的map返回对象为Stream,而Stream是没有.sum这个方法的,而.mapToInt(Integer::intValue)将原先的流转换为IntStream,这个类型才有.sum()求和的方法
但是,这样做其实是有点多此一举的,事实上可以直接这样写:
1 | List<Integer> lst = Arrays.asList(2, 3, 4, 5, 6); |
这样直接用.mapToInt()代替原先的.map(),简化了一步转换
剩下的另外两个方法也是一样的道理,转换类型存在差异而已
flatMap()
该方法为中间操作
接下来介绍一下这个方法
这个方法的作用是将一个嵌套流扁平化处理
或者更加准确的讲,是将流里面的元素映射为子流,之后拍平,合并
举个例子:
1 | ArrayList<List<String>> arrayList = new ArrayList<>(); |
可以看到,使用扁平化之后,原先的嵌套结构变成了一个单一的流,成功实现了扁平化
这里可能就有人要问了:“为什么要实现扁平化?”
其实很简单,如果不进行扁平化,那么就无法对原先里面的元素进行处理
因为原先的结构是一个嵌套的结构
flatMapToInt() & flatMapToLong() & flatMapToDouble()
这几个方法都是属于中间操作
这几个跟上文的mapToInt其实作用差不多,这里简单介绍一下
1 | List<Integer> numbers = List.of(2, 3); |
这里的例子将数字平方和立方,然后执行求和操作,由于这里所有类型均为int,没有执行任何装箱的操作,会更加简洁点
虽然说原先这样子也可以:
1 | List<Integer> numbers = List.of(2, 3); |
诶,效果是一样的!但是性能上呢?由于后者需要不断装箱拆包,实际性能要弱于之前一步到位的flatMapToInt()
另外的两个flatMapToLong()和flatMapToDouble()效果是一样的,只不过类型需要稍微改变一下,这里不过多提及
mapMulti()
该方法为中间操作
上文我们介绍了一个十分有用的方法,flatMap(),但是这个方法有一个很明显的缺点——每次使用都创建一个新的流
这样就会导致在大数据使用时大量的性能浪费,所以我们要怎么解决这个问题呢?
其实很简单,可以使用这个方法:mapMulti()
我们使用一下之前的例子来说明:
1 | List<Integer> numbers = List.of(2, 3); |
可以看到,通过这个方法,我们十分简单地实现了我们想要的效果,并且在这一过程中没有创建任何的中间流,大大节省了效率
mapMultiToInt() & mapMultiToLong() & mapMultiToDouble()
这些方法都是中间操作
这些操作的作用跟之前的mapToInt()和flatMapToInt()效果差不多,这里直接通过例子来说明一下:
1 | List<Integer> lst = Arrays.asList(1, 2, 3, 4, 5); |
可以看到,这里的代码与之前的相比便显得相当简便,不用再次使用mapToInt()来转换其中的数
reduce()
这个方法为终端操作
接下来来介绍这个方法
这个方法的作用为整合整理,简单举一个例子来说明
1 | List<Integer> lst = Arrays.asList(2, 3, 4, 5, 6); |
可以看到,此处输出了一个相应的把所有数字相加的内容,其实也就是reduce把所有的数字都整合合并
实际上这个用法并不局限如此,还有很多很有用的用法,但这里限于篇幅所以不多补充
collect()
这个方法为终端操作
接下来介绍一下这个方法
collect()的作用较多,并且该操作可以理解为Stream API的核心
接下来将分别对这几个方法来介绍
首先第一个方法为collect(toList())
这个方法的作用实际与Java 16中出现的方法.toList()是一致的,相关的内容可以参照.toList()的内容,这里简单通过一个例子说明
1 | List<String> list = new ArrayList<>(Arrays.asList("Apple", "Orange", "banana", "strawberry", "Watermelon", "Ackee")); |
我们可以与.toList()稍微对比一下:
1 | List<String> list = new ArrayList<>(Arrays.asList("Apple", "Orange", "banana", "strawberry", "Watermelon", "Ackee")); |
可以看到,这里的结果是完全相同的
从这两个方法的返回类型中可以看出,这两个方法的作用都是将流里面的元素整合到一个新的集合中并返回
接下来是collect(Collectors.toSet())
这个方法的作用是将流里面的所有元素收集起来返回一个Set类型的集合
从这一点上看便可以发现这个集合的特性便是不接受重复元素
接下来通过一个例子来说明
1 | List<String> list = new ArrayList<>(Arrays.asList("Apple", "Orange", "banana", "strawberry", "Watermelon", "Ackee")); |
可以看到这里筛选后的结果并没有重复的元素
接下来是collect(toMap())
这个方法的作用是返回一个Map类型的集合,在介绍这个方法的具体使用之前,需要先对其各个参数进行介绍
这个方法有四个可选参数:
1 | toMap(Function<? super T, ? extends K> keyMapper, |
不过这个版本不是很常用,一般会使用三个参数的版本
事实上可以精简为两个参数,但是这样就不安全了
这里逐一介绍四个参数
首先是第一个参数:Function<? super T, ? extends K> keyMapper
这里使用到了PECS原则,简单讲就是让函数可以接受更通用的输入,让输出可以返回更加具体的类型
不过这里不多介绍,用十分简单的话来讲,第一个参数代表这个Map的键
接下来是第二个参数:Function<? super T, ? extends U> valueMapper
这里代表这个键对应的值
第三个参数是为:BinaryOperator<U> mergeFunction,需要注意的是这个参数相当重要的,用于确保不会抛出IllegalStateException
为什么很重要,当使用toMap()的时候只有两个参数(keyMapper和valueMapper),假设现在有一个键为A,那么接下来又传入一个新的键A,由于键不可以相等,所以此时程序会抛出报错IllegalStateException,代表这一行为是不合法的
但是我们不可能说完全避免这种只有一个参数的情况,这也就是第三个参数存在的意义:合理处理重复的键
例子会在介绍完四个参数后提供
那么接下来是第四个参数Supplier<M> mapFactory)
这个参数的作用是规定返回的Map是什么类型,一般默认为HashMap
那么在介绍完这四个参数后,就可以用一个实际的例子来说明了
返回一个map,使得其键为这个单词的第一个字母,且单词为首字母大写,若键重复则选择最后出现一个
实现如下:
1 | List<String> list = new ArrayList<>(Arrays.asList("blueberry", "Apple", "Orange", "Ackee", "acerola", "Banana")); |
上面这串代码中先用.filter()筛选出首字母大写的单词
接下来的toMap()中,第一个参数提取出首字母,第二个参数为这个单词本身,第三个参数为遇到同键的时候选择后出现的单词,第四个参数规定返回类型为TreeMap
接下来是collect(Collectors.joining())
这个方法的作用是拼接字符串,返回类型为String
这个方法有三个方法重载,参数分别为空参,带一个参数,带三个参数
如果为空参,那么会直接将流里面的字符串全部拼在一起
如果为一个参数:
1 | joining(CharSequence delimiter) |
这个参数将规定每个字符串在拼接的时候用什么拼接
如果为三个参数:
1 | joining(CharSequence delimiter, |
第一个参数与上文相同,而第二个和第三个参数分别规定了前缀和后缀
接下来通过一个例子来说明:
1 | List<String> list = new ArrayList<>(Arrays.asList("blueberry", "Apple", "Orange", "Ackee", "acerola", "Banana")); |
可以看到,这里被规范为一般集合输出的方式
接下来是collect(Collectors.groupingBy)
这个方法的作用是通过一个高效的分组器来对输入的元素进行分组
这个方法有三个不同参数的重载,接下来分别介绍每个重载
首先是第一个重载
1 | groupingBy(Function<? super T, ? extends K> classifier) |
这个重载有一个参数,这个参数决定了其分组的标准
接下来是第二个重载
1 | groupingBy(Function<? super T, ? extends K> classifier, |
第二个重载新增一个了参数用于决定收集到的元素要如何处理
通过源代码可以发现,假设我们这里不填(等价于只有一个参数的形式)
1 | groupingBy(Function<? super T, ? extends K> classifier) { |
那么这里默认的处理方式是转换成一个新的集合toList()
接下来是有三个参数的重载
1 | groupingBy(Function<? super T, ? extends K> classifier, |
这里相较于前文的两个参数多了一个用于规范最后输出类型的参数
需要特别注意的一点是此处原本的第二个参数是放到最后的
接下来通过一个简单的例子来说明一下:
1 | Random random = new Random(); |
这里的运行逻辑是这样的,首先先将元素排序,之后通过groupingBy()分别分组
这里分组的标准是这样的,假设小于200则进入一组,如果大于200并且小于400则进入第二组,如果都不满足(也就是大于400)则进入最后一组
接下来是要求返回一个TreeMap(去重和排序),由于我们并不需要继续对里面的元素进行第二次处理,所以这里默认即可:Collectors.toList()
gather()
这个方法为终端操作
gather的作用是对流里面的元素进行转换
接下来简单说明一下具体的用途:
由于源代码里面写的内容是gather(Gatherer<? super T, ?, R> gatherer)
这提醒我们应该使用Gatherer接口的一些方法
接下来来简单介绍一下里面一些常用的方法
首先是第一个Gatherers.windowFixed()
这个方法的作用是将流里面的元素按照规定的数量进行分割
1 | List<String> list = new ArrayList<>(Arrays.asList("Apple","banana","Grape","Peach", "plum")); |
可以看到,这里规定的数字是3,所以按照每3个为一组的顺序分割,如果最后不满三个也是成一组
具体用途可以用于翻页
假设我们现在想要做出一个简单的翻页效果,那么我们可以直接将这里面的每个东西按数量分别分组,之后直接查询即可
这里简单写一个例子来说明一下:
1 | final int maxElements = 3; |
可以看到,这里使用了Gatherers.windowFixed(maxElements)来实现了将指定数量的元素放到一页的效果
之前在介绍limit()和skip()的时候,也实现了翻页的效果,但是那个翻页原理是通过跳过一定的元素来实现的翻页
而这里是通过创建子集合来实现的翻页效果
接下来介绍Gatherers.windowSliding()
这个方法的作用是,收集n个元素为一组,之后每次减去最前面一个元素,新增后一个元素,并返回一个新的集合
说白了就是以n为单位一点一点往集合最后挪过去
1 | List<String> list = new ArrayList<>(Arrays.asList("Apple", "Apricot", "Blueberry", |
可以看到,这里首先收集两个元素作为一组,之后减去第一个元素Apple,新增后一个元素Blueberry
以此类推直到集合结束
接下来介绍Gatherers.mapConcurrent()
这个方法有两个参数,不过在讲这两个参数的作用之前我们需要先介绍一下这个方法是干什么用的
Gatherers.mapConcurrent()的作用是,并发处理多个元素
简单来说就是一次性处理多个元素
接下来介绍参数:
1 | mapConcurrent( |
第一个参数为maxConcurrency意思是可以同时执行的操作的数量
第二个为mapper,意思是每个元素要执行的操作
接下来通过一个简单的例子来说明:
1 | List<String> ids = Arrays.asList("A", "B", "C", "D", "E"); |
可以看到,这里在每次处理时要求sleep500ms后才执行处理
而这里最大并发数为2,意味着可以同时运行2个任务,而一共5个元素只需要处理3次即可,所以总时长便是~1500ms
假设不使用并发处理呢?
1 | List<String> ids = Arrays.asList("A", "B", "C", "D", "E"); |
可以看到,这里是一个操作结束后才会执行下一个操作,总花费时间便为5 x 500ms
那么这时候就有人要问了,诶,那么这个这么好用,是不是maxConcurrency越大越好呢?
当然不是!越大意味着要同时消耗的资源就越多,所以一般10~100就够用了
并且这个操作只适合I/O 密集型的任务,如果只是为了计算的话,建议使用并行流
parallelStream()
这个方法为中间操作
这个方法为并发流,具体的使用方法与上文的stream()完全一致,所以之类不再花时间介绍
不过需要注意的一点是,由于这是并发流的原因,有些依照顺序处理的方法可能会出现一些小小的偏差
这里简单举一个例子来说明:
1 | List<Integer> list = new ArrayList<>(Arrays.asList(1,2,3,4,5,6)); |
可以看到,这里输出完全不按照顺序,为什么加上一个示例呢?因为这个输出的内容是完全随机的,没办法预测到的
特别需要注意的一点是,使用这个方法必须做到无副作用和线程安全
如果有副作用会怎么样呢?
1 | ArrayList<Integer> arrayList = new ArrayList<>(); |
可以看到,这里无法预测输出是什么结果
但如果真的要这样处理得怎么办呢?
可以使用map和toList:
1 | List<Integer> list = new ArrayList<>(Arrays.asList(1, 2, 3, 4, 5, 6)); |
这里的副作用来自于.add(),由于ArrayList并不是线程安全的,试图并发add可能导致出现一些奇奇怪怪的问题
spliterator()
该方法是一个终端操作
该方法常见便是返回一个spliterator对象,而这个对象提供遍历和分割的能力
这个方法有很多方法可以使用,接下来简单介绍一下:
首先第一个是trySplit()
这个方法的作用是将当前流从中间分割,并且分为两个新的Spliterator
1 | List<Integer> list = new ArrayList<>(Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8)); |
可以看到,这里trySplit将原本的流分割成了两个新的部分
如果这里只有奇数个元素,那么分割出去的newSplit就只有((向下取整)(总数 / 2))个元素,例如总共有7个,那么oldStream会分到4个,而newSplit会分到3个元素
接下来看具体的实现,由于trySplit来自接口Spliterator,所以具体实现得看对应的使用
这里以ArrayList为例子:
1 | public ArrayListSpliterator trySplit() { |
其中hi为集合的边界,lo为一开始的位置,此处使用了>>>1来代替除以2,避免了溢出的情况
接下来是三元表达式判断条件,如果不满足则直接返回null,满足则返回一个新的ArrayListSpliterator(其实也就是Spliterator)
接下来是estimateSize()
这个方法的作用是返回该Spliterator估计还能返回的长度
上文在trySplit的例子中也有使用到这个方法,这里简单举一个例子来说明:
1 | List<Integer> list = new ArrayList<>(Arrays.asList(1, 2, 3, 4, 5, 6, 7)); |
从ArrayList.java也可以很轻松找到对应的实现:
1 | public long estimateSize() { |
可以看到,这里是实际的返回是最右端(getFence())和最开始的差(index)
补充点:精确求值
在ArrayList这里estimateSize是返回精确值的,我们可以称之为SIZED
可以通过如下操作来获取是否为精确的
1 | List<Integer> list = new ArrayList<>(Arrays.asList(1, 2, 3, 4, 5, 6, 7)); |
具体内容请参照hasCharacteristics的内容
如果这里为精确的,那么便可以使用estimateSize得到精确值
如果不为精确的,那么可能会出现一些偏差
例如这里:
1 | Stream<Integer> stream = Stream.iterate(0, n -> n + 1).limit(5); |
可以看到,这里结果为false,也就是说不是精确的,假设我们现在试图用estimateSize获取长度,那么会怎么样?
1 | Stream<Integer> stream = Stream.iterate(0, n -> n + 1).limit(5); |
可以看到,这里返回的结果是 (2^63) - 1,也就是64位整数的极限,这也说明了不是是准确的
接下来是forEachRemaining
这个方法的作用是遍历Spliterator里面的元素
需要注意的一点是,使用forEachRemaining之后,这个Spliterator便会被消费,之后若试图再次使用forEachRemaining便不会输出任何内容
1 | List<Integer> list = new ArrayList<>(Arrays.asList(1, 2, 3, 4, 5, 6)); |
此处可以看到,在第一次输出数的平方后,再次使用试图输出数的立方时是没有任何效果的
这也说明了在使用forEachRemaining后,这个spliterator便直接结束了
接下来是characteristics()
这个方法的作用是获取到该spliterator的位掩码
补充点:位掩码(Bitmask)
那么是什么是位掩码(Bitmask)呢?
简单来讲就是通过一个数字的二进制的0和1来存储指定的布尔信息
Java中characteristics为int类型,该类型为32位
用二进制表示便是
1 | 00000000 00000000 00000000 00000000 |
这里每一位便是一个“开关”,总共可以表示32种布尔状态
我们以spliterator中的SIZED为例,在spliterator.java中可以找到其位掩码所代表的值:
1 | public static final int SIZED = 0x00000040; |
可以看到,这里是一个十六进制数,简单换算一下可以得到为十进制的64和二进制的1000000
这里1在第7位,意味着在32位中第七位代表SIZED的状态
也就是当出现以下情况的时候(其他位不计,只看第七位),就代表这个spliterator有SIZED属性
1 | 00000000 00000000 00000000 01000000 |
在补充完位掩码的相关内容后,便开始介绍这个方法的具体用途
上面的也提及到说这个方法的作用是返回该spliterator的位掩码
我们简单通过一个例子来说明:
1 | List<Integer> list = new ArrayList<>(Arrays.asList(1, 2, 3, 4, 5, 6)); |
很好,我们成功获取到了一个位掩码,那么接下来要怎么计算到这个spliterator有什么属性呢?
从上文我们可以知道,要想判断是否有该属性,只需要在二进制的这个位为1,即可
那么我们便需要找到一个可以满足以下条件的运算符
当a与b同为1时,返回1,当a与b任意一个出现0的时候,返回0
稍微思考一下便可以想起位运算符:按位与(&)
如此,我们便可以这样计算:
1 | List<Integer> list = new ArrayList<>(Arrays.asList(1, 2, 3, 4, 5, 6)); |
可以看到,这里spliterator有以下属性:ORDERED、SIZED、SUBSIZED
补充点:关于属性
虽然我们获取到这个spliterator的属性,但是我们还是不知道这些属性有什么具体的用途
那么接下来便来具体的介绍这些属性的作用,分别代表了该spliterator有什么特点
在介绍的时候会顺带附上其十六进制码,以及对应的二进制位数,来源均来自spliterator.java
ORDERED
ORDERED的十六进制码为:0x00000010,二进制为:10000
这个属性用于表明该spliterator顺序是否会保持插入顺序
假设这个属性为true,那么元素在处理的时候会与插入的顺序保持一致,即使并行处理也会保持顺序
在多次执行的时候顺序均一致
SIZED
SIZED的十六进制码为:0x00000040,二进制为:1000000
这个属性用于表示是否可以查询到精确大小
如果为true,则会返回精确的大小,如果为false,一般在使用estimateSize()返回大小的时候会返回long.MAX_VALUE,当然也有可能返回一个随机的值
具体参照这里:
1 | Returns an estimate of the number of elements that would be |
SUBSIZED
SIZED的十六进制码为:0x00004000,二进制为:100000000000000(1在第15位)
这个属性表示子分割是否知道具体大小
利用这个属性可以预测在并行任务中的处理时间
DISTINCT
DISTINCT的十六进制码为:0x00000001,二进制为:1
这个属性表示spliterator中是否元素唯一
SORTED
SORTED的十六进制码为:0x00000004,二进制为:100
这个属性表示该spliterator是否元素是否已排序
IMMUTABLE
IMMUTABLE的十六进制码为:0x00000400,二进制为:10000000000(1在第11位)
这个属性表示该spliterator是否不可变(也就是能不能使用add、remove这些方法来修改)
CONCURRENT
CONCURRENT的十六进制码为:0x00001000,二进制为:1000000000000(1在第13位)
这个属性表示该spliterator是否并发安全
接下来是hasCharacteristics()
这个方法的作用是帮我们计算是否有该属性
相当于帮我们做了一次按位与的计算
通过源代码中,也可以看到实际上便是如此:
1 | default boolean hasCharacteristics(int characteristics) { |
接下来通过一个具体的例子来说明一下如何使用:
1 | List<Integer> list = new ArrayList<>(Arrays.asList(1, 2, 3, 4, 5, 6)); |
通过这样便可以更加简便地判断出这个spliterator到底有什么属性
接下来是tryAdvance()
这个方法的作用是尝试执行一个操作并返回是否执行成功的布尔值
需要注意的是,每次执行这个操作都会使得这个元素被消费掉,无法再次使用
我们可以借此来简单实现一个遍历的操作:
1 | List<Integer> list = new ArrayList<>(Arrays.asList(1, 2, 3, 4, 5, 6)); |
如果tryAdvance成功执行括号内的操作,那么会返回True,这就导致了可以将这个语句放进判断中,直到该spliterator内元素全部被消耗完
接下来是getExactSizeIfKnown()
这个方法的作用是:如果该spliterator具有SIZED属性,那么返回其精确的数量,如果没有,则返回-1
举一个简单的例子来说明一下:
1 | List<Integer> list = new ArrayList<>(Arrays.asList(1, 2, 3, 4, 5, 6)); |
可以看到,这里输出为精确的数量
但如果没有SIZED这个属性,那么会是这个结果:
1 | Spliterator<Integer> spliterator = Stream.iterate(1,n -> n + 1).limit(100).spliterator(); |
LinkedList
接下来讲讲LinkedList
LinkedList是一个双向链表,那么什么是双向链表呢
双向链表
简单来讲,双向链表是一种线性数据结构,特点是链表中的每个元素都包含两个指针
这两个指针分别指着什么呢?一个指针指向前一个节点,而第二个指针指向后一个节点
这样做的好处是各个元素之间紧密相连
而这意味着什么?以往的单向链表只有一个后驱指针,意味着如果想要查询上一个元素,必须要把整个链表遍历一遍,这大大影响了运行效率
双向链表由于带两个指针,所以在查询上一个元素时会快很多
特性 / 优点
接下来讲讲有关LinkedList的一些特性
在上文也提及到了其作为双向列表,所以其可以快速查询上一个元素的特性有什么作用呢?
由于这个特点,LinkedList在插入元素的效率是很快的
我们可以举个例子:
1 | ArrayList<String> arrayList = new ArrayList<>(); |
在上面这个例子中,我们使用了计时器计时插入1000000个元素的时间
ArrayList花费的时间大概为11毫秒左右
那么LinkedList呢?
1 | LinkedList<String> linkedList = new LinkedList<>(); |
这里同样计时了1000000个元素插入所需要的时间
不同的是,这里使用的是LinkedList,花费的时间大概为4毫秒
在插入元素方面,LinkedList的效率要远快于ArrayList!
具体用法
在讲完LinkedList的一些特点之后,便可以开始介绍其具体的用法了
由于LinkedList本质还是属于Link接口,所以很多方法还是通用的(比如说add()、get())
接下来主要介绍那些特有的方法
offer()
offer()是一个用于插入元素的方法,具体实现效果跟add()差不多
举个例子:
1 | LinkedList<String> linkedList = new LinkedList<>(); |
可以看到,这两个方法都可以实现插入的效果
offer()拥有与add()一样的两个方法,首位插入和末尾插入:offerFirst()、offerLast()
add()与offer()
诶,这里可能就有人要说了,那这样算不算一种设计冗余呢?
其实不然
在LinkedList中,offer()方法实现的是Deque接口 / Queue接口
而add()方法实现了List接口,两者虽然效果上是相等的,但是并不属于同一个接口
那么还有什么区别呢?
首先,add方法一般是作为列表的插入操作,而offer一般用于队列
假设我们现在想要插入一个满空间的列表,使用add方法会导致抛出报错IllegalStateException
但是,offer方法不会抛出报错,只会返回false
不过由于LinkedList是无界的,所以不存在空间不够的情况
(当然内存不够依旧会报错)
说的直白一点就是:add()是不成功就报错,而offer()是不成功就返回false
poll()
接下来讲的方法是poll(),与前文的offer()一样,这个方法归属于Deque接口 / Queue接口
所以,在List接口中等价的方法为:remove()
以下是其基本的使用方法
1 | LinkedList<String> linkedList = new LinkedList<>(); |
poll()的返回值为其删除的元素,这一点与remove()是一致的
同remove()一致,poll()也有用于移除首尾项的方法:pollFirst()、以及pollLast()
poll()与remove()
接下来讲讲这两者的区别
由于这两个功能上基本一致,所以差异的地方只有一些细节的点
假设现在LinkedList是一个空的链表,那么此时使用remove会抛出一个叫做NoSuchElementException的报错
1 | LinkedList<String> linkedList = new LinkedList<>(); |
在IntelliJ IDEA中,如果使用该操作,则会有一个警告:对空集合 ‘linkedList’ 进行的更新操作无效
但如果选择poll()来删除元素,那么就大不相同了:
1 | LinkedList<String> linkedList = new LinkedList<>(); |
可以看到,这里并没有报错,而是选择了返回一个null
从LinkedList.java观察两者的源代码可以发现这一点:
1 | // 这里是poll()的实现代码 |
接下来是remove()
由于remove()的实现方法稍微有点抽象,这里把两个方法都展示出来
1 | public E remove() { |
对比两者可以发现,两者的逻辑是一致的(一个用三元表达式一个用if语句),只是一个选择返回null,而另一个选择抛出异常NoSuchElementException
peek()
接下来介绍的方法是peek()
这个方法的作用是返回头部元素的值:
1 | LinkedList<String> list = new LinkedList<>(); |
可以看到,这个方法成功返回了我们的第一项元素
这里可能有人就会想到一个方法了:get()
但与之前的两个方法不同的是,这个方法并不与get()方法类似
get()可以返回指定的索引,而peek()却只能固定返回一个位置的索引
接下来是peek()的两个额外的方法,分别是返回头部元素的peekFirst()、和返回尾部元素的peekLast()
通过一个简单的例子来说明这两个方法的实际用途:
1 | LinkedList<String> list = new LinkedList<>(); |
一些补充的话
由于LinkedList实现了Deque接口以及Queue接口这些ArrayList没有的接口,所以此处会补充这部分的内容
补充用法
接下来将补充一些Queue和Deque接口有的方法:
一些基本的(如getFirst(),getLast())就不再说明
push()push()方法来源于Deque接口
这个方法等价于addFirst()
1 | LinkedList<String> list = new LinkedList<>(); |
从输出结果来看,这两个方法的结果是完全一致的,那么,这两个方法有没有什么不同呢?
其实是几乎没有的,我们不妨看看这两个方法的源代码:
1 | public void addFirst(E e) { |
可以看到,push方法的实现其实也就是调用了addFirst方法
但一般而言,如果将LinkedList当做栈使用,则会使用push(代表压入栈顶)
这样可以使得代码更加明确
removeFirstOccurrence
这个方法有另外一个版本:removeLastOccurrence
不过核心功能大同小异,主要是索引方向不同
这个方法的作用为从首项开始搜寻是否有存在目标项,如果存在,则移除并返回true
接下来是具体的例子
1 | LinkedList<String> list = new LinkedList<>(); |
我们不妨看看他的源代码是如何实现的:
1 | public boolean removeFirstOccurrence(Object o) { |
可以看到,这里removeFirstOccurrence()其实也就是调用了remove()这个方法
descendingIterator
接下来将说明一下这个方法
这个方法的作用是将当前数组反向
在反向的时候需要新创建一个迭代器对象才可以起到效果:
1 | LinkedList<String> list = new LinkedList<>(); |
Set接口
Set接口是另一个常见的接口,主要分为以下几个实现类:
1 | HashSet:基于哈希表的实现类,可以存储不重复元素,查询效率高(时间复杂度O(1)) |
那么接下来将依次介绍这些实现类
HashSet
首先第一个是HashSet
HashSet是一个经典的实现类,其底层为HashMap,这个实现类支持哈希表,由于这个原因,HashSet查找元素时平均时间复杂度为O(1)
特性
接下来讲讲HashSet的一些特性:
首先第一个便是Set接口的通性:不允许存放相同元素
第二个特性是无序性,简单来说就是传入的顺序与输出的顺序是不同的
举个简单例子:
1 | HashSet<String> list = new HashSet<>(); |
通过这个例子可以充分体现出其无序性,这一点与链表相分开
此外,HashSet是允许null元素的
1 | HashSet<String> list = new HashSet<>(); |
一个需要注意的点是,HashSet并不是一个线程安全的实现类,如果在多线程的情况下,必须要做到外部同步
具体用法
在介绍完这些特性之后,便要来介绍HashSet的具体用法了,与前文的List接口一致,此处介绍的方法如果在下面的实现类中再次出现将不会再次介绍
但由于HashSet的接口Set接口完全继承与Collection接口,而Collection接口的方法我们在前文ArrayList的部分已经讲过了,所以这里便不再复述
也就是说,HashSet并没有任何独立的公共方法
HashSet存在的意义
既然没有独立的方法,那么HashSet存在的意义是什么呢?
在之前我们也提到过了,HashSet存储不重复的元素,这也就导致了如果有相同的元素被相继加入到数组中,那么HashSet只会存储前一个,而后一个并不会存储
1 | HashSet<String> list = new HashSet<>(); |
事实上是,如果你在IntelliJ IDEA里面add()两个相同的元素,那么会抛出一个警告:重复的 Set 元素
接下来是其独特的特性:极快的查询速度
1 | ArrayList<String> arrayList = new ArrayList<>(); |
那么让我们看看HashSet咋以相同代码下的查询速度:
1 | for (int i = 0; i < 99999999; i++) { |
可以看到,这里HashSet查找的速度远远大于ArrayList,这也可以突出其特点
补充点:快速失败(fail-fast)
快速失败是一个很有意思的机制,部分实现类拥有这个特性
会出现这个特性的原因是因为迭代器的问题
如果你在使用迭代器的时候同时用非迭代器方法修改这个集合(比如说add()),那么此时迭代器会瞬间检测到这一修改并且抛出错误:
1 | HashSet<String> hashList = new HashSet<>(); |
可以看到,这里抛出了报错ConcurrentModificationException
那么为什么会这样呢?
首先我们需要知道一个有意思的点:modCount
这个是什么呢?简单来说就是记录你对这个集合操作的次数
我们直接看源码可以发现:
1 | // HashMap.java |
也就是说,如果你对这个集合进行了一些操作,那么这个计数器都会记录操作的次数
那这跟快速失败有什么关联呢
在生成一个迭代器的时候,迭代器会讲modCount的值复制一遍,存储到:expectedModCount中
使用迭代器的方法next()或者remove()的时候,便会将这个值与modCount进行对比,如果两者相等则说明没有进行任何修改操作
则正常进行
但如果修改(例如上面提到的例子一样),则会直接抛出报错:ConcurrentModificationException
特别补充的一点是,modCount这个属性是private的,所以不用担心会被修改
另外,如果需要删除元素可以选择迭代器自带的删除方法:remove()
补充点:底层实现
接下来聊聊HashSet的底层实现
首先第一个点,我们可以思考一下HashSet是如何实现存储不重复元素的
由于源代码putVal巨抽象,这里就简单讲讲
首先从创建讲起,假设你现在创建了一个HashSet集合
那么在底层,会顺带创建一个HashMap数组用于存储,一般而言,这个数组桶个数为16个(这个值是默认值,可以修改的)
假设你往这个HashSet里面添加一个值,那么这个值会先使用hashcode()得到一个值,在经过一些系列处理后得到桶索引,这个索引将告诉我们添加的这个值应该被放到哪个桶里面
至于怎么计算出这个桶索引的,就太复杂了,涉及到一系列杂七杂八乱七八糟的操作,这里就不补充了
那么至此,这个值就得到了两个有意思的东西,分别是它的哈希值(通过hashcode())获得,还有是它的桶索引(通过获取到哈希值后经过一系列操作得到)
假设我们需要往里面再添加一个新的值,则会继续这一步操作
此时如果我们试图添加一个与之前添加的值相同的值,那么会发生什么呢?
依旧进行之前的步骤,首先是计算其哈希值,以及其桶索引,在将要把这个值放进去桶的时候,会先将这个元素的哈希值进行比较,如果这个值与桶里面元素的哈希值都不相同,则添加进桶里面
那么如果相等就可以直接丢掉吗?
当然不是!有时候哈希值相同值却不同,所以这个时候还需要equals()方法来检测其是否相同
如果相同,则不添加;如果不相同,则添加进桶里面去
补充点:哈希冲突后处理
接下来将讲一下哈希冲突
在上面的补充点中我们提到一个点:不同元素的哈希值经过计算转换成桶索引后会出现相同的情况
这就是哈希冲突,说白了就是:一个桶里面塞了不止一个东西
那么HashSet(根本上讲应该是HashMap)是怎么处理的呢?
首先,假设我们在一个桶里面塞了超过7个元素,那么此时HashSet会直接将桶的数量翻倍(假设为默认的16,那么就变成32个桶了)
由于桶索引的计算与桶的个数是相关的,所以这里会重新分配桶
诶,这里就有人要说了,假设又塞满了呢?
如果此处又出现了一个桶里面塞的元素超过7个,那么会继续翻倍桶的数量
那么是不是就这样一直翻倍下去呢?
肯定不是,如果桶的数量大于64个,仍然出现了一个桶装了超过7个,那么会把这个桶从链表转换为红黑树
利用这种方法来增加查找效率
那么是不是意味着只有一个桶里面塞了大于7个元素才会翻倍桶数量呢?
当然不是,HashMap里面有个东西叫做负载因子,这玩意的机制是,如果总元素的数量大于总桶数 * 负载因子,那么也会触发桶翻倍的机制
比如我在一个桶里面塞了4个,另一个桶塞了5个,另一个塞了6个,那么此时总元素个数为15个,大于总桶数 * 负载因子(16 * 0.75 = 12)
那么此时即使任何一个桶没有大于7个元素,也会触发桶数翻倍
而一般默认的负载因子为:0.75,多了少了都不行,少了触发太快,多了的话冲突又过多,查询的时间复杂度直接变为O(n)
所以,这里选择0.75是折中的选择
最后总结一下:
触发桶数翻倍需满足以下两点中的任意一点:
- 总元素数大于
总桶数 * 负载因子(负载因子默认为0.75) - 单个桶的元素数量大于7,并且此时总桶数小于64
触发链表转红黑树(只有满足条件的桶才转换)
- 单个桶的元素数量大于7,并且此时总桶数大于64
TreeSet
接下来讲讲TreeSet
TreeSet是一个很有意思的实现类
这个实现类的特点就是会将这个类中的元素自动排序
1 | TreeSet<Integer> treeSet = new TreeSet<>(); |
可以看到,即使将生成的随机数字填入这个数组中,输出的时候依旧会按顺序输出
这也体现了这个类的特点:对输入的元素自动排序
特有方法
接下来介绍一下TreeSet特有的方法,这些方法主要实现SortedSet接口和NavigableSet接口
首先讲讲SortedSet接口中实现的方法:
first() & last()
这个方法的作用为返回第一个元素(最低)和返回最后一个元素(最高)
1 | Random random = new Random(); |
可以看到,在分别对该数组使用first()方法和last()方法后,相对应的值变成了其最低项和最高项
headSet()
接下来是headSet()方法
这个方法拥有1个可填参数:headSet(E toElement)
其中参数toElement代表规定的范围
这个方法的作用是限制只有小于括号内的参数(toElement)才可以输出
1 | Random random = new Random(); |
tailSet()
既然有小于,那就一定有大于,tailSet()便是返回大于括号内参数的数的方法
这个方法的同样拥有一个参数:tailSet(E fromElement)
与上文的headSet()一致,这里也是用于限制范围
1 | Random random = new Random(); |
subSet()
既然有查找小于的headSet(),也有查找大于的tailSet()
自然而然的便有可以返回指定数字范围的方法:subSet()
这个方法有两个参数:subSet(E fromElement, E toElement)
第一个fromElement,代表最低的元素大小,而第二个参数toElement,代表了最高的元素大小
需要注意的一点是,这个参数的范围为左闭右开区间
接下来给出实例:
1 | Random random = new Random(); |
接下来是NavigableSet接口的内容
lower() & higher()
接下来是这两个方法
首先第一个方法lower()的作用是返回最大不小于该数的数,例如规定数字为10,则返回所有比10小的数中最大的那个数
第二个方法higher()的作用与lower()类似,返回最小不小于该数的数,例如规定数字为10,则返回所有比10大的数中最小的数
这两个方法都拥有一个可填的参数:lower(E e)、higher(E e)
其中参数e分别代表最大不能超过这个数字的数,以及最小不能小于这个数的数
接下来看看具体用法:
1 | Random random = new Random(); |
higher()的用法与之类似,这里不再演示
需要注意的一点是,这里的数字只能小于或大于规定的数,不可以等于,相当于开区间
floor() & ceiling()
接下来是这个方法
这两个方法的参数都只有一个:floor(E e)和ceiling(E e)
其中参数e代表最大或最小可以选取到的数字
floot()和ceiling()方法与上文的lower()和higher()方法类似,不过区别是floot()方法和ceiling()方法是闭区间而lower()方法和higher()方法都是开区间
1 | Random random = new Random(); |
可以看到,在该程序中,floor()选取的标准是大于等于括号内最小(最接近)的数
而ceiling()也是相同的道理
headSet() & tailSet & subSet()
由于NavigableSet接口继承与SortedSet接口,所以这个接口也有SortedSet接口的一些方法
那么重复定义是不是过于冗余呢?其实不然,在NavigableSet接口中,这三个方法要比父接口要更加灵活
接下来介绍NavigableSet接口中的headSet()、tailSet()、subSet()方法
首先这三个方法的具体功能与SortedSet接口是一致的,所以此处不再次介绍
不一样的是,这三个方法都多了一到两个可选参数:
1 | headSet(E toElement, boolean inclusive) |
通过其类型为boolean便可以简单推断这个参数的作用
没错,便是使其可以自由调整为开闭区间
这一点便与其父接口不一致了
1 | Random random = new Random(); |
可以看到,这里设定为true,输出也包括了规定的数字
而subSet()的参数有两个是决定范围的,分别决定前后数字为开闭区间
补充点:源码中有趣的事情
如果你曾阅读过TreeSet.java中有关这部分的内容
那么你会发现一件事情:
1 | public SortedSet<E> headSet(E toElement) { |
可以看到,这里SortedSet<E> headSet(E toElement)返回的结果刚好是下面的NavigableSet<E> headSet(E toElement, boolean inclusive),只不过将第二个决定开闭区间的参数固定为false
而subSet()也有类似的内容:
1 | public SortedSet<E> subSet(E fromElement, E toElement) { |
可以看到,这里默认便为左闭右开区间
补充点:关于多态
在上文提及到:NavigableSet接口继承与SortedSet接口,也就是说,下面这种语法也是正确的:
1 | SortedSet<Integer> headset = treeSet.headSet(10,true); |
这里使用到了面向对象中的多态:父类/接口引用指向子类/实现类对象
pollFirst() & pollLast()pollFirst()和pollLast()是两个相对应的方法,作用为移除并返回首项 / 末项
这两个方法均没有可填的参数
接下来看看具体例子
1 | Random random = new Random(); |
另一个方法pollLast()则为移除并返回末项
descendingSet()
接下来是这个方法
这个方法依旧没有参数可以选择
这个方法的作用是反转数组:
1 | Random random = new Random(); |
补充点:与reverse()方法的区别
看到这个方法可能会有人想到另一个方法:reverse()
这两个方法都可以做到反转数组的功能,那么有什么差别呢?
一个最简单的差别就是加入时间
descendingSet()在Java 6便加入了,而reverse()直到Java 21才加入
不过有意思的一点在源代码上:
1 | default NavigableSet<E> reversed() { |
可以看到,这里的reversed()方法其实就是复用descendingSet()方法
诶,这是为什么呢?其实在Java 21中新增了一个接口SequencedCollection,这个接口又被NavigableSet接口继承,这个接口有个可以返回反转数列的方法(也就是reversed()),但是原本就有一个方法——descendingSet(),再次实现多少有点不太方便,所以便复用了这个方法
补充点:定义排序规则
注意!!!!!
由于自定义排序规则的方法太多了!!!此处仅演示Lambda表达式的方法
接下来是自定义排序规则
诶,那么要怎么自定义排序规则呢
其实很简单,只需要在创建的时候在TreeSet<>()后面的圆括号上加上对应的规则即可:
1 | Random random = new Random(); |
安全的排序规则
在圆括号内部的是lambda表达式,这样存在一个十分严重的问题,如果两者的差过大(一个整数a减一个负数b),可能会导致溢出
1 | TreeSet<Integer> sequentialTreeSet = new TreeSet<>((a,b) -> a - b); |
由于比较规则为前后相减,原本应为4294967295,但这个值溢出了,变成了-1
如果为负数,那么就意味着前面的数比后面的数小,但我们这个情况,正常应该是正数在前
那要怎么解决这个问题呢?其实很简单,我们可以使用到静态工厂方法:
1 | TreeSet<Integer> sequentialTreeSet = new TreeSet<>(Comparator.comparingInt(a -> a)); |
这种写法还有一些类似的写法:
1 | // 使用对应包的compare |
我们还可以给出一些更加自定义的规则:
1 | var comparatorRule = |
可以看到,这里的比较规则是先比较字符串长度,再用自然规则(naturalOrder)进行排序
自定义类排序
除此之外,还可以使用自定义类来排序
1 | class Product{ |
接下来开始解析这段代码:
首先第一个部分,这里新建了一个类:Product
这个类里面定义了两个属性:productName和productPrice
这两个属性分别记录商品的名字和商品的价格
接下来看到声明TreeSet中的自定义排序规则部分:
1 | Comparator.comparingInt(Product::getProductPrice) |
此处使用了这个规则,括号内部是一个匿名函数,意思是使用Product的getProductPrice的返回值进行比较
而前面的Comparator.comparingInt意思是比较的内容为数字
又因为getProductPrice的返回值为数字(productPrice),所以是合法的
当然这里有个小问题,如果价格相等是不会加入的
所以需要在这个规则后面加上一句,如果相等则比较其他的内容:
1 | TreeSet<Product> products = new TreeSet<>(Comparator.comparingInt(Product::getProductPrice) |
此处由于类中只有名字还可以用,所以就使用名字作为第二个比较的标准
实际上还可以再继续比较下去:
Comparator.comparingInt(…).thenComparing(…).thenComparing(…)
不过没有什么意义就对了
这种格式的写法为静态工厂写法,更加安全
处理null值
如果数组存在null值,不加处理会导致报错:
1 | TreeSet<Integer> treeSet = new TreeSet<>(); |
可以看到这里的报错为NullPointerException
那要怎么办呢?其实很简单,只需要在排序规则那里加入如何处理null值即可
1 | TreeSet<Integer> treeSet = new TreeSet<>(Comparator.nullsFirst(Integer::compare)); |
此处使用Integer::compareTo也可以,本质上compareTo就是调用compare
可以看到,这里使用了Comparator.nullsFirst(Integer::compare),用于将null值放到最前面
除此以外还有nullsLast,用于将null值放到最后面