Java奇技淫巧

欢迎你来读这篇博客,这篇博客主要是关于Java·奇技淫巧
其中包括了关于我的简介和收集的知识分享。

序言

正文

Spring 依赖注入杂谈

先说结论,@Autowired注解是为了扩展,为了灵活设计出来的。会导致循环依赖等报错出现。推荐使用构造器注入方式。Setter 方式注入也不推荐。

至于@Resource注解,这个注解在 jdk11 及之后版本移除了,因为 jdk9 开始进行了模块化,移除了部分和 JavaEE 相关的包,这个注解在
javax.annotation里面。javax 这个包,随着 JavaEE 的严谨,后续也不用了,例如 tomcat10 用的 JavaEE 新版本
jakarta。如果选择的技术选型 JDK 高于 11 或者 tomcat 高于 9,需要引入 jakarta 的包,去用这个注解。

在 Spring3.0 时代,官方还是提倡 set
方法注入的。相关文档

从 Spring4.x
开始,官方就不推荐这种注入方式了,转而推荐构造器注入。相关文档

核心:通过构造方法注入的方式,能够保证注入的组件不可变,并且能够确保需要的依赖不为空。此外,构造方法注入的依赖总是能够在返回客户端(组件)代码的时候保证完全初始化的状态。

  • 依赖不可变:这个好理解,通过构造方法注入依赖,在对象创建的时候就要注入依赖,一旦对象创建成功,以后就只能使用注入的依赖而无法修改了,这就是依赖不可变(通过
    set 方法注入将来还能通过 set 方法修改)。
  • 依赖不为空:通过构造方法注入的时候,会自动检查注入的对象是否为空,如果为空,则注入失败;如果不为空,才会注入成功。
  • 完全初始化:由于获取到了依赖对象(这个依赖对象是初始化之后的),并且调用了要初始化组件的构造方法,因此最终拿到的就是完全初始化的对象了。

在 Spring3.0 文档中,官方说如果构造方法注入的话,属性太多可能会让代码变得非常臃肿,那么在 4.0
文档中,官方对这个说法也做了一些订正:如果用构造方法注入的时候,参数过多以至于代码过于臃肿,那么此时你需要考虑这个类的设计是否合理,这个类是否参杂了太多的其他无关功能,这个类是否做到了单一职责。

注入方式 可靠性 可维护性 可测试性 灵活性 循环关系的检测 性能影响
Field Injection 不可靠 灵活 不检测 启动快
Constructor Injection 可靠 不灵活 自动检测 启动慢
Setter Injection 不可靠 灵活 不检测 启动快
@Autowired @Resource
识别方式 默认按类型,如果需要按名称,使用@Qualifier 注解配合 默认按名称,名称找不到按类型
适用对象 构造器、方法、方法参数、字段 方法、字段
注解来源 Spring 框架提供的注解 Java 标准 JSR-250

如何更好的注入

目前我个人是通过Lombok@RequiredArgsConstructor注解和final关键字配合进行注入的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@RequiredArgsConstructor
public class CategoryController {
private final CategoryService categoryService;

// 在此处做出特别提醒:多实例情况下,此报错会报错
// @Qualifier PS: Qualifier注解会失效,因为lombok不会把注解带到构造入参那个地方
// private final CategoryService categoryService;
}

// 解决方式1:舍弃此注入方式,采用构造器注入+Qualifier注解
// 解决方式2:@Autowired + Qualifier注解注解
// 解决方式3:通过map注入,动态取用

@RequiredArgsConstructor
public class CategoryController {
private final Map<String,CategoryService> categoryServiceMap;
}

Stream

Java 8 引入了Stream API,提供了一种高效、简洁的方式来处理集合数据,它与 java.io 包里的 InputStreamOutputStream
是完全不同的概念。Stream 是一种用于处理数据流的抽象,它允许我们以声明性的方式对数据进行操作,如过滤、排序、转换等。本文将详细介绍 Java
Stream 的基本概念、核心操作、常见用法及其内部工作机制。

Java 8 中的 Stream 是对集合(Collection)对象功能的增强,它专注于对集合对象进行各种非常便利、高效的聚合操作(aggregate
operation),或者大批量数据操作 (bulk data operation)。

Stream API 借助于同样新出现的 Lambda 表达式,极大的提高编程效率和程序可读性。同时它提供串行和并行两种模式进行汇聚操作,并发模式能够充分利用多核处理器的优势,使用
fork/join 并行方式来拆分任务和加速处理过程。

传统的集合操作使用外部迭代(如 for 循环)来遍历集合,而 Stream 使用内部迭代,通过声明性的方法定义需要对数据进行的操作,由 Stream 框架负责具体的迭代过程。这种方式使代码更加简洁和易读。

ParallelStream

parallelStream 默认使用了 fork-join 框架,其默认线程数是 CPU 核心数。

  1. 设置 parallelStream 默认的公用线程池的全局并发数:
    System.setProperty("java.util.concurrent.ForkJoinPool.common.parallelism", "4");
1
2
3
4
5
6
7
8
9
10
int cupNum = Runtime.getRuntime().availableProcessors();
log.info("CPU num:{}",cupNum);
long firstNum = 1;
long lastNum = 10000;
List<Long> aList = LongStream.rangeClosed(firstNum, lastNum).boxed()
.collect(Collectors.toList());
System.setProperty("java.util.concurrent.ForkJoinPool.common.parallelism", "4");
aList.parallelStream().forEach(e->{
log.info("输出:{}",e);
});
  1. 通过 ForkJoinPool 定义私有线程池

采用自定义的 forkJoinPool 线程池去提交任务,主线程不会参与计算。

forkJoinPool 线程池采用 submit 异步提交任务,通过 get 方法阻塞主线程,直到任务执行完成,再调用 shutdown 方法关闭线程池。

注意,等待提交任务执行完毕不能采用 awaitTermination()方法,该方法是等待指定时间后强制关闭线程池。

效率:针对高密度的 CPU 计算任务,提高线程池的并发数,反而会降低任务的执行效率,因为 CPU 抢占和大量线程频繁切换会增加任务的耗时。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
int cupNum = Runtime.getRuntime().availableProcessors();
log.info("CPU num:{}",cupNum);
long firstNum = 1;
long lastNum = 10000;
List<Long> aList = LongStream.rangeClosed(firstNum, lastNum).boxed()
.collect(Collectors.toList());
ForkJoinPool forkJoinPool = new ForkJoinPool(8);
try{
List<Long> longs = forkJoinPool.submit(() -> aList.parallelStream().map(e->{
return e+1;
}).collect(Collectors.toList())).get();
//通过调用get方法,等待任务执行完毕
System.out.println(longs.size());
System.out.println("执行结束");
// 错误示例 forkJoinPool.awaitTermination(20,TimeUnit.SECONDS);
// 错误示例 ForkJoinTask future = forkJoinPool.submit(() -> aList.parallelStream().forEach(e->{
// log.info("输出:{}",e);
// }));
// future.get(10, TimeUnit.MINUTES); get方法不能起到阻塞主线程、等待任务执行完毕的作用。
}catch (InterruptedException e) {
e.printStackTrace();
} catch (Exception e){
e.printStackTrace();
}finally {
forkJoinPool.shutdown();
}

操作分类

Stream 的操作可以分为两大类:中间操作、终结操作。

  • 中间操作可分为:

    • 无状态(Stateless)操作:指元素的处理不受之前元素的影响
    • 有状态(Stateful)操作:指该操作只有拿到所有元素之后才能继续下去
  • 终结操作可分为:

    • 短路(Short-circuiting)操作:指遇到某些符合条件的元素就可以得到最终结果
    • 非短路(Unshort-circuiting)操作:指必须处理完所有元素才能得到最终结果
  • 中间操作

    • filter:过滤元素
    • map:映射元素
    • flatMap:平铺流
    • distinct:去重
    • sorted:排序
    • peek:查看元素
    • limit:截取前 N 个元素
    • skip:跳过前 N 个元素
  • 终端操作

    • forEach:遍历元素
    • collect:收集结果
    • reduce:归约操作
    • toArray:转换为数组
    • min:最小值
    • max:最大值
    • count:计数
    • anyMatch:是否有任意匹配
    • allMatch:是否全部匹配
    • noneMatch:是否全部不匹配
    • findFirst:找到第一个元素
    • findAny:找到任意一个元素

这是因为流的生命周期有三个阶段:

  • 起始生成阶段。

中间操作:会逐一获取元素并进行处理。可有可无。所有中间操作都是惰性的,因此,流在管道中流动之前,任何操作都不会产生任何影响。Stream 的惰性求值特性使得中间操作不会立即执行,只有在执行终端操作时,整个操作链才会开始计算。这种机制可以有效地减少不必要的计算,提高性能。

  • 终端操作。通常分为 最终的消费 (foreach 之类的)和 归纳 (collect)两类。还有重要的一点就是终端操作启动了流在管道中的流动。

Stream API

创建操作
  • 通过 java.util.Collection.stream() 方法用集合创建流
1
2
3
4
5
List<String> list = Arrays.asList("hello","world","stream");
// 创建顺序流
Stream<String> stream = list.stream();
// 创建并行流
Stream<String> parallelStream = list.parallelStream();
  • 使用 java.util.Arrays.stream(T[] array)方法用数组创建流
1
2
String[] array = {"h", "e", "l", "l", "o"};
Stream<String> arrayStream = Arrays.stream(array);
  • Stream 的静态方法:of()、iterate()、generate()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Stream<Integer> stream1 = Stream.of(1, 2, 3, 4, 5, 6);

Stream<Integer> stream2 = Stream.iterate(0, (x) -> x + 2).limit(3);
stream2.forEach(System.out::println);

Stream<Double> stream3 = Stream.generate(Math::random).limit(3);
stream3.forEach(System.out::println)

//输出结果如下:
0
2
4
0.9620319103852426
0.8303672905658537
0.09203215202737569
  • parallelStream

stream 和 parallelStream 的简单区分:stream 是顺序流,由主线程按顺序对流执行操作,而 parallelStream 是并行流,内部以多线程并行执行的方式对流进行操作,需要注意使用并行流的前提是流中的数据处理没有顺序要求(会乱序,即使用了 forEachOrdered)。

1
2
3
4
5
6
// 直接创建
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
numbers.parallelStream().filter(n -> n % 2 == 0).forEach(System.out::println);

// 通过parallel()把顺序流转换成并行流
Optional<Integer> findFirst = numbers.stream().parallel().filter(x->x>4).findFirst();
无状态操作
  • filter
1
2
3
4
5
6
// 筛选,是按照一定的规则校验流中的元素,将符合条件的元素提取到新的流中的操作。
Stream<T> filter(Predicate<? super T> predicate);

List<Integer> list = Arrays.asList(6, 7, 3, 8, 1, 2);
Stream<Integer> stream = list.stream();
stream.filter(x -> x > 5).forEach(System.out::println);
  • map
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// map 一个元素类型为 T 的流转换成元素类型为 R 的流,这个方法传入一个Function的函数式接口,接收一个泛型T,返回泛型R,map函数的定义,返回的流,表示的泛型是R对象;
// 将集合中的元素A转换成想要得到的B
<R> Stream<R> map(Function<? super T, ? extends R> mapper);
List<String> output = wordList.stream().map(String::toUpperCase).collect(Collectors.toList());

// flatMap 接收一个函数作为参数,将流中的每个值都换成另一个流,然后把所有流连接成一个流。
// 与Map功能类似,区别在于将结合A的流转换成B流
<R> Stream<R> flatMap(Function<? super T, ? extends Stream<? extends R>> mapper)

List<String> list1 = Arrays.asList("m,k,l,a", "1,3,5,7");
List<String> listNew = list1.stream().flatMap(s -> {
// 将每个元素转换成一个stream
String[] split = s.split(",");
Stream<String> s2 = Arrays.stream(split);
return s2;
}).collect(Collectors.toList());

System.out.println("处理前的集合:" + list1);
System.out.println("处理后的集合:" + listNew);
// 处理前的集合:["m,k,l,a", "1,3,5,7"]
// 处理后的集合:["m", "k", "l", "a", "1", "3", "5", "7"]

// peek 操作接收的是一个 Consumer<T> 函数。顾名思义 peek 操作会按照 Consumer<T> 函数提供的逻辑去消费流中的每一个元素,同时有可能改变元素内部的一些属性。
Stream<T> peek(Consumer<? super T> action);
  • peek 操作 一般用于不想改变流中元素本身的类型或者只想元素的内部状态时;
  • 而 map 则用于改变流中元素本身类型,即从元素中派生出另一种类型的操作。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// mapToInt、mapToLong、mapToDouble、flatMapToDouble、flatMapToInt、flatMapToLong
// 以上这些操作是map和flatMap的特例版,也就是针对特定的数据类型进行映射处理。其对应的方法接口如下:
IntStream mapToInt(ToIntFunction<? super T> mapper);

LongStream mapToLong(ToLongFunction<? super T> mapper);

DoubleStream mapToDouble(ToDoubleFunction<? super T> mapper);

IntStream flatMapToInt(Function<? super T, ? extends IntStream> mapper);

LongStream flatMapToLong(Function<? super T, ? extends LongStream> mapper);

DoubleStream flatMapToDouble(Function<? super T, ? extends DoubleStream> mapper);


Stream<String> stream = Stream.of("hello", "felord.cn");
stream.mapToInt(s->s.length()).forEach(System.out::println)

// 除此之外 还有封装好的stream 如 intstream、longstream、doublestream

无序化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
unordered()操作不会执行任何操作来显式地对流进行排序。它的作用是消除了流必须保持有序的约束,从而允许后续操作使用不必考虑排序的优化。

对于顺序流,顺序的存在与否不会影响性能,只影响确定性。如果流是顺序的,则在相同的源上重复执行相同的流管道将产生相同的结果;
如果是非顺序流,重复执行可能会产生不同的结果。 对于并行流,放宽排序约束有时可以实现更高效的执行。
在流有序时, 但用户不特别关心该顺序的情况下,使用 unordered 明确地对流进行去除有序约束可以改善某些有状态或终端操作的并行性能。

Stream.of(5, 1, 2, 6, 3, 7, 4).unordered().forEach(System.out::println);
Stream.of(5, 1, 2, 6, 3, 7,4).unordered().parallel().forEach(System.out::println);

//两次输出结果对比(方便比较,写在一起)
第一遍: 第二遍:
//第一行代码输出 //第一行代码输出
5 5
1 1
2 2
6 6
3 3
7 7
4 4

//第二行代码输出 //第二行代码输出
3 3
6 6
4 7
7 5
2 4
1 1
5 2
有状态操作
  • distinct
1
2
3
4
5
// 返回由该流的不同元素组成的流(根据 Object.equals(Object));distinct()使用hashCode()和equals()方法来获取不同的元素。因此,我们的类必须实现hashCode()和equals()方法。
Stream<T> distinct();

Stream<String> stream = Stream.of("1", "3","4","10","4","6","23","3");
stream.distinct().forEach(System.out::println);
  • sorted
1
2
3
4
5
6
// 返回由该流的元素组成的流,并根据自然顺序排序
Stream<T> sorted();
Stream<T> sorted(Comparator<? super T> comparator);

Stream<Integer> stream = Stream.of(3,1,10,16,8,4,9);
stream.sorted().forEach(System.out::println);
  • limit
1
2
3
4
5
// 获取流中n个元素返回的流
Stream<T> limit(long maxSize);

Stream<Integer> stream = Stream.of(3,1,10,16,8,4,9);
stream.limit(3).forEach(System.out::println);
  • skip
1
2
3
4
5
// 在丢弃流的第一个n元素之后,返回由该流的其余元素组成的流。
Stream<T> skip(long n);

Stream<Integer> stream = Stream.of(3,1,10,16,8,4,9);
stream.skip(3).forEach(System.out::println);

短路(Short-circuiting)操作

  • anyMatch
1
2
3
4
5
6
7
8
// Stream 中只要有一个元素符合传入的 predicate,返回 true;
boolean anyMatch(Predicate<? super T> predicate);

Stream<Integer> stream = Stream.of(3,1,10,16,8,4,9);
System.out.println("result="+stream.anyMatch(s->s==2));

//输出
result=false
  • allMatch
1
2
3
4
5
6
7
8
// Stream 中全部元素符合传入的 predicate,返回 true;
boolean allMatch(Predicate<? super T> predicate);

Stream<Integer> stream = Stream.of(3,1,10,16,8,4,9);
System.out.println("result="+stream.noneMatch(s -> s>=17 ));

//输出
result=true
  • findFirst
1
2
3
4
5
6
7
8
9
10
11
// 用于返回满足条件的第一个元素(但是该元素是封装在Optional类中)
Optional<T> findFirst();

Stream<Integer> stream = Stream.of(3,1,10,16,8,4,9);
System.out.println("result="+stream.findFirst().get());
//输出
result=3

System.out.println("result="+stream.filter(s-> s > 3).findFirst().get());
//输出
result=10
  • findAny
1
2
3
4
5
6
7
8
9
// 返回流中的任意元素(但是该元素也是封装在Optional类中)
Optional<T> findAny();

List<String> strAry = Arrays.asList( "Jhonny", "David", "Jack", "Duke", "Jill","Dany","Julia","Jenish","Divya");
String result = strAry.parallelStream().filter(s->s.startsWith("J")).findAny().get();
System.out.println("result = " + result);

//输出
result = Jill

非短路(Unshort-circuiting)操作

  • forEach
1
2
3
4
5
6
7
8
// 接收一个Lambda表达式,然后在Stream的每一个元素上执行该表达式
void forEach(Consumer<? super T> action);

List<String> strAry = Arrays.asList( "Jhonny", "David", "Jack", "Duke", "Jill","Dany","Julia","Jenish","Divya");

strAry.stream().forEach(s-> {
if("Jack".equalsIgnoreCase(s)) System.out.println(s);
});
  • forEachOrdered
1
2
3
4
5
6
7
// 接收一个Lambda表达式,然后按顺序在Stream的每一个元素上执行该表达式
// forEachOrdered是可以保证循环时元素是按原来的顺序逐个循环的
// 但是,也不尽其然!因为有的时候,forEachOrdered也是不能百分百保证有序!
// 在并行流时,由于是多线程处理,其实还是无法保证有序操作的!
void forEachOrdered(Consumer<? super T> action);

Stream.of("AAA,","BBB,","CCC,","DDD,").parallel().forEach(System.out::print);
  • toArray
1
2
3
4
5
6
7
8
// 返回包含此流元素的数组;当有参数时,则使用提供的generator函数分配返回的数组,以及分区执行或调整大小可能需要的任何其他数组
Object [] toArray();

<A> A[] toArray(IntFunction<A[]> generator);

List<String> strList = Arrays.asList( "Jhonny", "David", "Jack", "Duke", "Jill","Dany","Julia","Jenish","Divya");
Object [] strAryNoArg = strList.stream().toArray();
String [] strAry = strList.stream().toArray(String[]::new);
  • reduce
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
// 接收一个函数作为累加器,数组中的每个值(从左到右)开始缩减,最终计算为一个值
Optional<T> reduce(BinaryOperator<T> accumulator);
T reduce(T identity, BinaryOperator<T> accumulator);

<U> U reduce(U identity,BiFunction<U, ? super T, U> accumulator,BinaryOperator<U> combiner);

//以及参数的定义结构
@FunctionalInterface
public interface BinaryOperator<T> extends BiFunction<T,T,T> {
//两个静态方法,先进行忽略
}

@FunctionalInterface
public interface BiFunction<T, U, R> {
R apply(T t, U u);
//一个默认方法,先进行忽略
}


// 1
List<Integer> num = Arrays.asList(1, 2, 4, 5, 6, 7);

*原接口一比一原汁原味写法*
Integer integer = num.stream().reduce(new BinaryOperator<Integer>() {
@Override
public Integer apply(Integer a, Integer b) {
System.out.println("x:"+a);
return a + b;
}
}).get();
System.out.println("resutl:"+integer);


*等效写法一*
Integer result = num.stream().reduce((x, y) -> {
System.out.println("x:"+x);
return x + y;
}).get();
System.out.println("resutl:"+result);


*等效的普通写法*
boolean flag = false;
int temp = 0;
for (Integer integer : num) {
if(!flag){
temp = integer;
flag = true;
}else {
System.out.println("x:"+temp);
temp += integer;
}
}

System.out.println("resutl:"+temp);

// 2
List<Integer> num = Arrays.asList(1, 2, 4, 5, 6, 7);

*一比一原汁原味写法*
Integer integer = num.stream().reduce(1,new BinaryOperator<Integer>() {
@Override
public Integer apply(Integer a, Integer b) {
System.out.println("a="+a);
return a + b;
}
});
System.out.println("resutl:"+integer);


*普通for循环写法*
int temp = 1;
for (Integer integer : num) {
System.out.println("a="+temp);
temp += integer;
}

System.out.println("resutl:"+temp);

// 3 串行
List<Integer> num = Arrays.asList(1, 2, 3, 4, 5, 6);
List<Integer> other = new ArrayList<>();
other.addAll(Arrays.asList(7,8,9,10));

num.stream().reduce(other,
(x, y) -> { //第二个参数
System.out.println(JSON.toJSONString(x));
x.add(y);
return x;
},
(x, y) -> { //第三个参数
System.out.println("并行才会出现:"+JSON.toJSONString(x));
return x;
});




//输出结果:
[7,8,9,10,1]
[7,8,9,10,1,2]
[7,8,9,10,1,2,3]
[7,8,9,10,1,2,3,4]
[7,8,9,10,1,2,3,4,5]
[7,8,9,10,1,2,3,4,5,6]

// 3 并行
List<Integer> num = Arrays.asList( 4, 5, 6);
List<Integer> other = new ArrayList<>();
other.addAll(Arrays.asList(1,2,3));

num.parallelStream().reduce(other,
(x, y) -> { //第二个参数
x.add(y);
System.out.println(JSON.toJSONString(x));
return x;
},
(x, y) -> { //第三个参数
x.addAll(y);
System.out.println("结合:"+JSON.toJSONString(x));
return x;
});


//输出结果
//第一遍
[1,2,3,4,5,6]
[1,2,3,4,5,6]
[1,2,3,4,5,6]
结合:[1,2,3,4,5,6,1,2,3,4,5,6]
结合:[1,2,3,4,5,6,1,2,3,4,5,6,1,2,3,4,5,6,1,2,3,4,5,6]

//第二遍
[1,2,3,4,6]
[1,2,3,4,6]
[1,2,3,4,6]
结合:[1,2,3,4,6,1,2,3,4,6]
结合:[1,2,3,4,6,1,2,3,4,6,1,2,3,4,6,1,2,3,4,6]

//第三遍
[1,2,3,5,4,6]
[1,2,3,5,4,6]
[1,2,3,5,4,6]
结合:[1,2,3,5,4,6,1,2,3,5,4,6]
结合:[1,2,3,5,4,6,1,2,3,5,4,6,1,2,3,5,4,6,1,2,3,5,4,6]

每个结果都是乱序的,并且多执行几次,都会出现不同的结果。并且第三个参数组合器内的代码也得到了执行!
这就是因为并行时,使用多线程时顺序性没有保障所产生的结果。通过实践,可以看到:组合器的作用,其实是对参数2中的各个线程,产生的结果进行了再一遍的归约操作!

并且仔细看第二遍的执行结果:每一组都少了一1个值!!!

所以,对于并行流parallelStream操作,必须慎用!!
  • collect
1
2
3
4
5
6
7
8
9
// 收集器,是一个终端操作,它接收的参数是将流中的元素累积到汇总结果的各种方式
<R, A> R collect(Collector<? super T, A, R> collector);

<R> R collect(Supplier<R> supplier,
BiConsumer<R, ? super T> accumulator,
BiConsumer<R, R> combiner);

第一种方式会比较经常使用到,也比较方便使用,现在先看一看里面常用的一些方法:
toList、toSet、toCollection、Counting、SummingInt、averagingInt、Joining、maxBy、minBy、Reducing、collectingAndThen、groupingBy、partitioningBy
  • max
1
2
3
4
5
// 根据提供的Comparator返回此流的最大元素
Optional<T> max(Comparator<? super T> comparator);

List<Integer> num = Arrays.asList( 4, 5, 6);
num.stream().max(Integer::compareTo).ifPresent(System.out::println);
  • min
1
2
3
4
5
// 根据提供的Comparator返回此流的最小元素
Optional<T> min(Comparator<? super T> comparator);

List<Integer> num = Arrays.asList( 4, 5, 6);
num.stream().min(Integer::compareTo).ifPresent(System.out::println);
  • count
1
2
3
4
5
// 返回此流中的元素计数
long count();

List<Integer> num = Arrays.asList( 4, 5, 6);
System.out.println(num.stream().count());

分片上传

普通上传

调用接口一次性完成一个文件的上传。

  • 缺点:文件无法续传、大文件上传太慢。

解决方案:分片上传

分片上传

将源文件切分成很多分片,进行上传,待所有分片上传完毕之后,将所有分片合并,便可得到源文件。这里面的分片可以采用并行的方式上传,提示大文件上传的效率。

分片上传主要的过程(3 步)

  1. 创建分片上传任务(分片数量、每个分片文件大小、文件 md5 值)
  2. 上传所有分片
  3. 待所有分片上传完成后,合并文件,便可得到源文件

实现分片上传需要两张表

分片上传任务表(t_shard_upload):每个分片任务会在此表创建一条记录

1
2
3
4
5
6
7
8
9
10
11
12
create table if not exsits t_shard_upload
(
id
varchar
(
32
) primary key comment '文件名称',
file_name int not null comment ‘分片数量’,
part_num int not null comment '分片数量',
md5 varchar(128) comment ‘文件md5值’,
file_full_path varchar(512) comment '文件完整路径'
) comment = '分片上传任务表';

分片文件表(t_shard_upload_part):这个表和上面的表是一对多的关系,用于记录每个分片的信息,比如一个文件被切分成十个分片,那么此表会产生十条记录。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
create table if not t_shard_upload_part
(
id
varchar
(
32
) primary key,
shard_upload_id varchar
(
32
) not null comment '分片任务id',
part_order int not null comment ‘第几个分片,从1开始’,
file_full_path varchar(512) comment '文件完整路径',
UNIQUE KEY 'uq_part_order' (`shard_upload_id`,`part_order`)
) comment = '分片文件表,每个分片文件对应一条记录';

服务端需要提供四个接口

  1. 创建分片上传任务(/shardUpload/init) 返回分片任务 id,后续三个接口都要用
  2. 上传分片文件(/shardUpload/uploadPart)
  3. 合并分片、完成上传(shardUpload/complete)
  4. 获取分片任务详细信息(/shardUpload/detail) 可以得到分片任务的状态信息,如分片任务是否上传完毕,哪些分片已上传,网络出现故障可以借助此接口恢复上传

多线程任务批处理通用工具类

todo

java 日期

java 中有很多表示时间的类,我们一起看一下他们的区别和使用场景。

时区相关的类 ZoneId、TimeZone,时刻是唯一的,但是对同一时刻,不同的时区描述是不一样的,比如中国早上 10 点在美国可能是晚上 10 点。

先来看一下 ZoneId 的用法,抽象类 ZoneId 有两个子类,ZoneRegion 和 ZoneOffset

1
2
3
4
5
6
7
8
9
// 这三个返回的就是ZoneRegion,他们三个本质的偏移都是0
ZoneId.of("UTC");
ZoneId.of("GMT");
ZoneId.of("UT");

// 下面返回的是ZoneOffset
ZoneId.of("GMT+1");
ZoneId.of("UT-01:22");
ZoneId.of("UTC+05");

有人会问 GMT 和 UTC 还有 UT 应该是有区别的但是为啥说一样呢?

因为这是 java 里是一样的,但是实际追究历史的话,是三种计算时间的方式,他们在现实世界有着细微的区别,但是在 java 中是一样的。
对于一些常见的时区简称和哪些地区使用可以参考 https://www.timeanddate.com/time/zones/

通过地区名获取时区的方法如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 通过ZoneId名称全程来创建ZoneId对象
ZoneId.of("Asia/Shanghai");

// 通过下面方法可以通过简称来创建ZoneId对象
ZoneId.of("EST", ZoneId.SHORT_IDS);

// TimeZone和ZoneId类似,也是表示时区的,两者可以简单的互转。
TimeZone.getTimeZone("UTC"); //! 注意该方法如果传入字符串不合法返回的是GMT+0
TimeZone.getTimeZone("xx", false); // 不合法则返回null
TimeZone.getTimeZone(ZoneId.of("UTC")); // zoneId -> timeZone
TimeZone.getTimeZone("UTC").toZoneId(); // timeZone -> zoneId

// Clock也是一个时区相关的类,他主要有俩作用,记录时区和创建Instant,注意这里有个理解的偏差,就是老想着创建出来的Instant是在这个时区下面的,但是其实Instant是没有时区的绝对值。
Clock.systemDefaultZone();
Clock.systemUTC();
Clock.offset(baseClock, duration);

// 下面两个写法结果一致,并没有时差,因为Instant没有时区
Clock.systemDefaultZone().instant();
Clock.systemUTC().instant();

// 下面结果一致
Clock.systemDefaultZone().millis();
Clock.systemUTC().millis();

Date

有两个 Date 类,分别是 java.util.Date 和 java.sql.Date。后者是 sql 中的 date,他是只有年月日没有更细粒度的时间信息的。所以一般前者用的较多。Date 类主要包含了年,月,日,时,分,秒信息。可以通过这些信息构造 Date 对象,也可以通过一个 Date 对象获取这些信息。

因为 Date 本来就是表示日期的类,所以也在内部计算出了星期信息。

原则上 Date 是不需要毫秒信息的,但是 Date 中有个 fastTime 字段记录了毫秒信息,其他信息的计算也都基于这个毫秒信息。所以可以通过 getTime 方法拿到毫秒信息。

1
2
3
4
5
6
new Date(); // 当前时间,实际上市基于System.currentTimeMillis
new Date(long); // 用毫秒数构造,这个方法决定了其他时间对象,只要转为毫秒数,就可以很容易转为Date对象
new Date(2022 - 1900, 1, 25, 11, 19, 22); // 年是1900的偏移量,月是0-11
new Date(2022 - 1900, 1, 25, 11, 19, 22).getYear(); // 122而不是2022
new Date(2022 - 1900, 1, 25, 11, 19, 22).getMonth(); // 1 指的是2月,0才是1月
new Date().getTime(); // 等价于System.currentTimeMillis

综上,Date 是基于系统的毫秒偏移,来表示时间,并提供了日期相关的内在计算,可以快速的获取年月日星时分秒等信息,计算时候的时区则是使用了系统默认时区 TimeZone.getDefaultRef()。
Date 在设计上存在诸多问题,例如 1900 之前的年份没法表示;0 表示 1 月;日期的含义竟然包括了时分秒等信息;格式化工具线程不安全;隐式的使用了系统默认时区;隐式的使用了默认的日历系统。

Calendar

Calendar 也是老的 jdk 中的时间表示,也位于 java.util 包中,此类没有提供构造方法,可以通过 Calendar.getInstance()
创建当前时间的日历对象,也可以使用 builder 来构建。默认返回的是 GregorianCalendar,这也是世界上绝大多数国家都在使用的日历系统,但是有些国家比如日本、东南亚一些国家像泰国等没有使用,需要指定历法来创建。

Calendar 引入了可选历法,同时也引入了地区和时区的成员变量,弥补了 Date 的这些缺陷。比起 Date,还提供了时间的加法函数 add

1
2
3
4
5
6
7
8
new Calendar.Builder()
.setLocale(locale)
.setTimeZone(zone)
.setInstant(System.currentTimeMillis())
.build(); // builder创建Calendar
new Calendar.Builder().setInstant(long).build(); // 用毫秒数构造,这个方法决定了其他时间对象,只要转为毫秒数,就可以很容易转为Calendar对象
Calendar.getInstance().getTimeInMillis(); // 等价于System.currentTimeMillis
Calendar.getInstance().setTimeZone(xxx); // 不改变本质的时间毫秒数,只改变用于计算用的时区,是对于当前对象的改动,不是返回新对象

综上,Calendar 也是基于毫秒数进行时间计算的一个类,他比 Date 强的地方是提供更细的日历值计算,比如位于一年的第几周,还有时间加法运算,但是时间的格式化类只能作用于 date 类。

Instant

Instant 与时区无关的绝对的时间,和 System.currentTimeMillis 类似,但是 Instant 是精确到纳秒。

因为纳秒的精度一个 long 存不过来,所以分为两个字段分别存 seconds 和 nanos。

1
2
3
4
5
Instant.now();
Instant.ofEpochMilli(millis);
Instant.ofEpochSecond(4, -999_999_999); // s + ns
instant.truncatedTo(ChronoUnit.HOURS); // 取整到小于等于当前时间的整秒时间
Instant.now().atZone(ZoneId.of("UTC")); // 转换成UTC下的ZonedDateTime

java.time 包下的时间都是基于纳秒精度为计算核心的,所以脱离了 System.currentTimeMillis

LocalDateTime

Instant 没有时区,是绝对的时间值,但是 toString,显示的是 UTC0 的时间,也不能处理日期相关的东西,例如当前是几月几号。
LocalDateTime 就是对 Date 的替代品,后者核心是基于一个毫秒,前者则更精细了到了纳秒。两个重要属性是 LocalDate(年月日),LocalTime(
时分秒纳秒)

1
2
3
4
5
6
7
8
9
10
// 下面2个的时间,有8小时时差
LocalDateTime.now();
LocalDateTime.now(ZoneId.of("UTC"));

// 以下两者不同,前者表示当前时间的UTC表示,后者表示当前时间的年月日时分秒不变,时区换UTC
LocalDateTime.now(ZoneId.of("UTC"));
LocalDateTime.now().atZone(ZoneId.of("UTC"));

// LocalDateTime的月从1开始,下面表示2022-01-01 01:01:01
LocalDateTime.of(2022, 1, 1, 1, 1, 1);

ZonedDateTime

包含了 LocalDateTime 和 Zone 两部分信息。

1
2
3
4
5
6
ZonedDateTime.now(); //系统时区
ZonedDateTime.now(ZoneId.of("UTC")); //UTC0时区
ZonedDateTime.now(Clock.systemUTC()); // 同上

ZonedDateTime.now(Clock.systemUTC()).toLocalDateTime(); // 直接拿出localDateTime部分
ZonedDateTime.now(Clock.systemUTC()).toInstant(); // 转换为Instant

345 都提供了 isBefore 这样的比较方法,但是不建议在 localDateTime 类使用该方法,因为可能是不同时区。

格式化

old : SimpleDateFormat 线程不安全

1
2
3
4
5
6
SimpleDateFormat;
sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
Date;
d = sdf.parse("2022-01-01 11:11:11");
String;
str = sdf.format(d);

new : DateTimeFormatter 线程安全 jdk 1.8

1
2
3
4
DateTimeFormatter;
dtf = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
dtf.format(LocalDateTime.now());
LocalDateTime.parse("2022-01-01 11:11:11", dtf);

字符串拼接

字符串拼接【性能对比以及优缺点】

字符串拼接性能调优:最重要的就是内存的申请

  1. StringBuilder 不知道长度最快 不安全,指针不在乎安全
  2. += 耗内存
  3. $
  4. String.Formt
  5. String.Concat 知道长度时做快
  • 速度第一:String.Concat
  • 速度第二:StringBuilder
  • 速度第三:$ String.Formt 差不多并列
  • 速度第四:+= 最耗内存 不建议使用
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// StringBuilder 使用方法

StringBuilder
strB = new StringBuilder();

// 1、append(String str)/append(Char c):字符串连接
System.out.println("StringBuilder:" + strB.append("hello").append("world"));
//return "StringBuilder:helloworld"

// 2、toString():返回一个与构建起或缓冲器内容相同的字符串
System.out.println("String:" + strB.toString());
//return "String:helloworld"

// 3、appendcodePoint(int cp):追加一个代码点,并将其转换为一个或两个代码单元并返回this
System.out.println("StringBuilder.appendCodePoint:" + strB.appendCodePoint(2));
//return "StringBuilder.appendCodePoint:helloworld "

// 4、setCharAt(int i, char c):将第 i 个代码单元设置为 c(可以理解为替换)
strB.setCharAt(2, 'd');
System.out.println("StringBuilder.setCharAt:" + strB);
//return "StringBuilder.setCharAt:hedloworld"

// 5、insert(int offset, String str)/insert(int offset, Char c):在指定位置之前插入字符(串)
System.out.println("StringBuilder.insertChar:" + strB.insert(2, 'A'));
//return "StringBuilder.insertChar:heAlloworld"

// 6、delete(int startIndex,int endIndex):删除起始位置(含)到结尾位置(不含)之间的字符串
System.out.println("StringBuilder.delete:" + strB.delete(2, 4));
//return "StringBuilder.delete:heloorld"


// +=使用方法
Stirng str = "a";
str = "a" + "b";


// $使用方法
String str="hello";
String str2="world";
var ccb = $"Hi! {str}{str2}";


// String.Format使用方法
string str= String.Format("{0}{1}","hello","world");//str="helloworld";

// string.Concat使用方法
string str=string.Concat("hello","world"); //str="helloworld";

利用 String format 方法及占位符优雅拼接字符串

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package net.xiaogd.core.java.basic;

import org.junit.Assert;
import org.junit.Test;

public class StringFormatTest {
@Test
public void stringFormat() {
String name = "小明";
int age = 20;
String str = String.format("他的名字叫%s, 他今年%d岁.", name, age);
Assert.assertEquals("他的名字叫小明, 他今年20岁.", str);
}
}

// String.format 还支持更多的格式化的操作, 这个具体就要读者自己去看它的文档了.
// 其实它内部也是使用了 Formatter 这个类去实现的, 所以应该看的是 java.util.Formatter 这个类的文档, 里面有更多的用法介绍, 包括格式化小数, 日期, 货币等等的操作.

其实正如 format 这个方法名所暗示的, 它主要是关于格式化字符串的, 但它正好也给出了一种拼接字符串的较为优雅的方式, 所以,
即便你不关心字符串的格式化, 依然可以利用它的这个特性去做字符串常量与变量的拼接, 从而提高代码的可读性.

日志中优雅使用

1
2
3
4
5
6
7
8
9
//  直接拼接,不优雅
log.info("username: " + user.getUsername() + " , age: " + user.getAge());

// 占位符拼接
log.info(
String.format("username: %s, age: %d"),
user.getUsername(),
user.getAge()
);

这种比前一种方式要优雅一点, 不过呢, 却还是要用到 String.format 方法, 并且需要注意变量的类型, 而现在基本上很多的日志框架都直接支持一种占位符的写法,
与 String.format 中的不同, 可能相对来说还更简单一点, 只是它必须依赖于日志框架相关代码, 且只能用于日志输出中,
这样的方式就是利用大括号 {} 这种占位符(也称为 格式化锚点(formatting anchor))的写法:

1
log.info("username: {}, age: {}", user.getUsername(), user.getAge());

这种写法用户也不需要关注变量的类型, 如果有三个或更多的变量值要输出, 就相应多写几个 {}, 以及多传几个对应变量即可:

注: 方法的第二个参数是可变长参数, 因此可以传多个.

有了这种写法的支持, 就可以一口气写完要输出的日志信息, 而要拼接的变量就用 {} 暂时代替, 避免了用 + 号拼接的繁琐.

这样的使用占位符的写法还有一个潜在的好处, 就是性能的提升. 如果配置的日志级别是不输出 info 级别的日志,
那么相应的字符串的拼接与替换就不会发生, 可变长参数数组也不会生成.

而如果使用 + 号式之类的写法, 就不能得到这个好处了, 字符串传入之前就已经被拼接了, 最后因为日志级别的原因又没有输出的话,
无疑是一种浪费.

最后, 如果是 error 日志, 还支持一种特别的写法, 可以把异常传给最后一个参数, 除了替换占位符输出你的异常信息外,
它还会把异常栈整个打印出来, 避免了你自己去调用 e.printStackTrace:

1
2
3
4
5
6
7
8
9
10
11
public List<User> fetchUserList() {
String fileName = "user-list.txt";
InputStreamReader reader = new InputStreamReader(getClass().getResourceAsStream(fileName), StandardCharsets.UTF_8);
try {
int c = reader.read();
} catch (IOException e) {
log.error("error while reading {}", fileName, e);
}
// todo
return null;
}

注意以上, 错误信息只有一个占位符, 但后面跟了两个变量, 其中最后的变量是 Throwable 类型的, 只有第一个变量值才参与字符串的拼接.

当最后的变量是 Throwable 类型时, log 框架会将其当作异常栈输出, 类似于这个调用 public void error(String msg, Throwable
t);, 异常变量需置于最后.

另注: 此用法需要相应的日志版本的支持, 参见这里: http://www.slf4j.org/faq.html#paramException, 适用 1.6.0 版本及以上,
相应的日志实现也需支持.

以上例子说明了在 log 异常时, 同样可以使用占位符的写法, 同时兼容原有的打印异常栈的写法, 这些都算是方便我们输出异常日志的一些技巧,
关于使用 {} 占位符输出日志的介绍就到这里.

为什么说 log 用占位符比用字符串连接比较好

我看的是 logback 的源码

如果在日志等级符合输出条件的情况下,两个是没有什么大区别的。

但如果是在日志等级不符合输出条件的情况下:

由于字符串拼接是作为一个方法参数的,意味着它进入 logback 的内部判断的时候,就已经是拼接成功了。而在这一步的拼接成功,涉及到 String 是一个 final 变量的问题,这个拼接是耗时了,创建了 String,但是进入判断之后又完全没什么用。

这两种是有区别的:

1
2
log.info("a" + "b"); // 没有影响,因为在编译时已经是常量了,一共1个变量
log.info(a + "b"); // 有影响,a变量是1个,常量"b"是一个,拼接后的有事一个,一共三个变量。

而如果是占位符的话,它直接在 logback 的内部判断了日志等级是否足以输出,不行就直接 return 了。

再有就是
isDebugEnabled()
这种方法如果要封装,但是参数是需要逻辑处理的话,是没有什么用处的,需要显示声明才行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public void aa() {
// ①
cc("cc");
// ②
if (log.isDebugEnabled()) {
cc("cc");
}
// ③
cc(bb());
// ④
if (log.isDebugEnabled()) {
cc(bb());
}
}

public String bb() {
StringBuilder stringBuilder = new StringBuilder();
for (int i = 0; i < 1000; i++) {
stringBuilder.append(i);
}
return String.valueOf(stringBuilder);
}

public void cc(String aa) {
if (log.isDebugEnabled()) {
log.debug(aa);
}
}

① 和 ②,是没有什么区别的,但是 ③ 和 ④ 就用区别了。

④ 不会进行 StringBuilder 的拼接,但是 ③ 会。

参考资料

启示录

富贵岂由人,时会高志须酬。

能成功于千载者,必以近察远。


Java奇技淫巧
https://allendericdalexander.github.io/2024/10/10/java_trick/
作者
AtLuoFu
发布于
2024年10月10日
许可协议