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
19
20
@RequiredArgsConstructor
public class CategoryController {
private final CategoryService categoryService;

// 在此处做出特别提醒:多实例情况下,此报错会报错
// @Qualifier PS: Qualifier注解会失效,因为lombok不会把注解带到构造入参那个地方。需要做以下处理。
// 在项目的跟目录下新建lombok.config配置文件 和pom.xml同级别
// 内容是:lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Qualifier
// 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 中有很多表示时间的类,比较容易混在一起。其实只要先分清楚三个问题,就不难理解:

  1. 时刻:某个真实发生的瞬间,比如时间戳、Instant
  2. 本地日期时间:不带时区的年月日时分秒,比如 LocalDateTime
  3. 时区规则:同一个时刻在不同时区下会显示成不同的本地时间,比如 ZoneIdTimeZone

一句话总结:

主要含义 是否带时区 推荐程度
Date 一个毫秒级时刻 不直接带,但很多方法隐式用系统默认时区 老代码兼容
Calendar 带日历系统、时区、地区的可变时间对象 老代码兼容
Instant UTC 时间线上的一个绝对时刻 不带时区 推荐
LocalDate 本地日期,只有年月日 不带 推荐
LocalTime 本地时间,只有时分秒纳秒 不带 推荐
LocalDateTime 本地日期时间 不带 推荐,但别直接表示绝对时刻
ZonedDateTime 日期时间 + 时区规则 推荐
OffsetDateTime 日期时间 + 固定偏移量 带偏移,不带完整地区规则 推荐
DateTimeFormatter 新时间 API 的格式化器 可绑定时区 推荐

时区

时区相关的类主要有 ZoneIdZoneOffsetTimeZoneClock

先明确一个概念:时刻是唯一的,但是同一个时刻在不同时区下的描述不一样。

比如同一个 Instant,在中国可能是上午 10 点,在美国可能是前一天晚上。

ZoneId 表示时区 ID,比如:

1
2
3
ZoneId.of("Asia/Shanghai");
ZoneId.of("Europe/London");
ZoneId.of("America/New_York");

ZoneId 大体可以分成两类:

  1. 地区型时区:例如 Asia/ShanghaiEurope/London,背后有完整的时区规则。
  2. 固定偏移量时区:例如 +08:00Z,只表示固定偏移量。
1
2
3
4
5
6
7
8
// 地区型时区,推荐业务中优先使用这种
ZoneId shanghai = ZoneId.of("Asia/Shanghai");

// 固定偏移量,表示 UTC+8
ZoneOffset offset = ZoneOffset.of("+08:00");

// UTC 偏移量
ZoneOffset utc = ZoneOffset.UTC;

注意下面这几个写法很容易误会:

1
2
3
ZoneId.of("UTC");
ZoneId.of("GMT");
ZoneId.of("UT");

它们在 Java 中的规则都等价于 UTC 0 偏移量,但是 ID 本身仍然不同:

1
2
3
System.out.println(ZoneId.of("UTC")); // UTC
System.out.println(ZoneId.of("GMT")); // GMT
System.out.println(ZoneId.of("UT")); // UT

也就是说,在 Java 计算规则上它们等价于 0 偏移量,但是字符串 ID 不一定相同。

再看下面几个:

1
2
3
ZoneId.of("GMT+1");
ZoneId.of("UT-01:22");
ZoneId.of("UTC+05");

它们不是简单地等同于 ZoneOffset,而是带有 GMTUTCUT 前缀的 offset-style ZoneId。如果你想拿到纯粹的 ZoneOffset,可以这样写:

1
2
3
ZoneOffset.of("+01:00");
ZoneOffset.of("-01:22");
ZoneOffset.of("+05:00");

也可以调用 normalized() 尝试规范化:

1
2
3
4
5
ZoneId zoneId = ZoneId.of("UTC+05");
ZoneId normalized = zoneId.normalized();

System.out.println(zoneId); // UTC+05:00
System.out.println(normalized); // +05:00

所以不要通过实现类去判断业务含义,应该通过 ZoneId / ZoneOffset 的语义来理解。

通过地区名获取时区:

1
ZoneId zoneId = ZoneId.of("Asia/Shanghai");

通过简称获取时区:

1
ZoneId est = ZoneId.of("EST", ZoneId.SHORT_IDS);

但是不推荐大量使用简称。像 CST 这种简称本身就有歧义,可能代表 China Standard Time,也可能代表 Central Standard Time。业务系统中最好使用 Asia/Shanghai 这种完整的 IANA 时区 ID。

TimeZone 是老 API 中的时区类,和 ZoneId 可以互转:

1
2
3
4
5
TimeZone timeZone = TimeZone.getTimeZone("Asia/Shanghai");

ZoneId zoneId = timeZone.toZoneId();

TimeZone oldTimeZone = TimeZone.getTimeZone(ZoneId.of("Asia/Shanghai"));

这里有个坑:

1
2
TimeZone timeZone = TimeZone.getTimeZone("xxx");
System.out.println(timeZone.getID()); // GMT

TimeZone.getTimeZone(String) 如果传入非法 ID,不会抛异常,而是返回 GMT。这就很坑,像一个“静默兜底”,bug 也静悄悄地进村了。

如果希望非法时区直接报错,更推荐使用:

1
ZoneId zoneId = ZoneId.of("Asia/Shanghai");

非法时区会抛异常:

1
ZoneId.of("xxx"); // DateTimeException / ZoneRulesException

Clock 也是一个和时区相关的类,它主要有两个作用:

  1. 获取当前时刻。
  2. 让时间来源可替换,方便测试。
1
2
3
Clock.systemDefaultZone();
Clock.systemUTC();
Clock.system(ZoneId.of("Asia/Shanghai"));

注意:Clock 里面的时区不是说 Instant 本身有时区,而是说当需要把 Instant 解释成日期时间时,使用哪个时区。

1
2
3
4
5
Instant i1 = Clock.systemDefaultZone().instant();
Instant i2 = Clock.systemUTC().instant();

System.out.println(i1);
System.out.println(i2);

这两个拿到的都是当前时刻,理论上没有“8 小时时差”。因为 Instant 表示的是绝对时刻,和时区无关。

下面两个也是同理:

1
2
Clock.systemDefaultZone().millis();
Clock.systemUTC().millis();

它们拿到的都是当前时刻的 epoch millis,不会因为时区不同就差 8 小时。

Clock 在测试中特别好用,比如固定一个时间:

1
2
3
4
5
6
7
Clock fixedClock = Clock.fixed(
Instant.parse("2022-01-01T00:00:00Z"),
ZoneId.of("Asia/Shanghai")
);

LocalDateTime now = LocalDateTime.now(fixedClock);
System.out.println(now); // 2022-01-01T08:00

或者构造一个偏移后的时间:

1
2
3
4
Clock baseClock = Clock.systemUTC();
Clock tomorrowClock = Clock.offset(baseClock, Duration.ofDays(1));

Instant tomorrow = tomorrowClock.instant();

Date

Java 中常见的 Date 有两个:

1
2
java.util.Date
java.sql.Date

java.util.Date 是老 API 中的日期时间类,但它的本质不是“年月日时分秒”,而是:

从 1970-01-01 00:00:00 UTC 到当前时刻的毫秒数。

也就是说,Date 本质上表示的是一个毫秒级的绝对时刻

1
2
3
4
Date date = new Date();

System.out.println(date.getTime());
System.out.println(System.currentTimeMillis());

new Date() 内部大体就是基于当前系统时间构造的:

1
2
new Date();      // 当前时间
new Date(long); // 使用 epoch millis 构造

Date 有一堆老的构造方法和 getter,但是都已经不推荐使用:

1
2
3
4
new Date(2022 - 1900, 1, 25, 11, 19, 22);

new Date(2022 - 1900, 1, 25, 11, 19, 22).getYear(); // 122,不是 2022
new Date(2022 - 1900, 1, 25, 11, 19, 22).getMonth(); // 1,表示 2 月

这里有两个经典坑:

  1. 年份是从 1900 开始偏移的。
  2. 月份从 0 开始,0 表示 1 月,1 表示 2 月。

所以这些 API 非常反直觉,实际开发中不要再写新代码使用它们。

DateInstant 可以互转:

1
2
3
4
5
Date date = new Date();

Instant instant = date.toInstant();

Date newDate = Date.from(instant);

java.sql.Date 则是 JDBC 里面对应 SQL DATE 类型的类,语义上只表示年月日,不表示时分秒。现在如果使用 JDBC 4.2 之后的能力,更推荐直接和 LocalDateLocalDateTimeInstant 这些新 API 转换。

1
2
3
4
5
LocalDate localDate = LocalDate.now();

java.sql.Date sqlDate = java.sql.Date.valueOf(localDate);

LocalDate back = sqlDate.toLocalDate();

综上,Date 的核心问题是:

  1. 类名叫 Date,但实际上表示的是一个毫秒级时刻。
  2. 老构造方法和 getter 设计反直觉。
  3. 很多方法已经废弃。
  4. 和默认时区、默认日历系统耦合较深。
  5. 可读性差,不适合新代码继续扩展。

现在新代码中,通常用:

1
2
3
4
5
Instant        // 表示绝对时刻
LocalDate // 表示日期
LocalTime // 表示时间
LocalDateTime // 表示本地日期时间
ZonedDateTime // 表示带时区的日期时间

Calendar

Calendar 也是老的 JDK 时间 API,位于 java.util 包中。

它是一个抽象类,平时一般通过下面方式创建:

1
Calendar calendar = Calendar.getInstance();

默认情况下,通常返回的是 GregorianCalendar

Calendar 相比 Date 增加了几个能力:

  1. 支持时区。
  2. 支持地区。
  3. 支持日历字段计算。
  4. 支持时间加减。
  5. 可以获取一年中的第几周、一月中的第几天等字段。

例如:

1
2
3
4
5
6
Calendar calendar = Calendar.getInstance();

int year = calendar.get(Calendar.YEAR);
int month = calendar.get(Calendar.MONTH); // 注意:月份仍然从 0 开始
int day = calendar.get(Calendar.DAY_OF_MONTH);
int hour = calendar.get(Calendar.HOUR_OF_DAY);

Calendar 的月份同样是从 0 开始:

1
2
Calendar.JANUARY  // 0
Calendar.FEBRUARY // 1

这个设计依然很坑。

Builder 创建:

1
2
3
4
5
Calendar calendar = new Calendar.Builder()
.setTimeZone(TimeZone.getTimeZone("Asia/Shanghai"))
.setLocale(Locale.CHINA)
.setInstant(System.currentTimeMillis())
.build();

用毫秒数构造:

1
2
3
Calendar calendar = new Calendar.Builder()
.setInstant(System.currentTimeMillis())
.build();

获取毫秒数:

1
2
3
Calendar calendar = Calendar.getInstance();

long millis = calendar.getTimeInMillis();

刚创建出来时,它大体等价于当前时间的毫秒数:

1
2
Calendar.getInstance().getTimeInMillis();
System.currentTimeMillis();

设置时区:

1
2
3
4
5
6
7
8
9
Calendar calendar = Calendar.getInstance();

long before = calendar.getTimeInMillis();

calendar.setTimeZone(TimeZone.getTimeZone("UTC"));

long after = calendar.getTimeInMillis();

System.out.println(before == after); // true

setTimeZone 不会改变这个对象表示的本质时刻,只是改变后续计算年月日时分秒时使用的时区。

比如同一个毫秒值:

1
2
3
4
5
6
7
8
Calendar calendar = Calendar.getInstance();
calendar.setTimeInMillis(0L);

calendar.setTimeZone(TimeZone.getTimeZone("UTC"));
System.out.println(calendar.get(Calendar.HOUR_OF_DAY)); // 0

calendar.setTimeZone(TimeZone.getTimeZone("Asia/Shanghai"));
System.out.println(calendar.get(Calendar.HOUR_OF_DAY)); // 8

Calendar 也支持时间加减:

1
2
3
4
Calendar calendar = Calendar.getInstance();

calendar.add(Calendar.DAY_OF_MONTH, 1); // 加一天
calendar.add(Calendar.MONTH, -1); // 减一个月

注意 addroll 不一样:

1
2
calendar.add(Calendar.MONTH, 1);  // 会影响年份
calendar.roll(Calendar.MONTH, 1); // 只滚动月份字段,不进位年份

综上,CalendarDate 强一些,它引入了时区、地区、日历字段和时间加减能力。但是它仍然是老 API,有几个明显问题:

  1. 可变对象,容易被意外修改。
  2. 月份从 0 开始。
  3. API 繁琐。
  4. 默认宽松解析,容易出现奇怪结果。
  5. 不如 java.time 包清晰。

所以新代码优先使用 java.time,老代码交互时再使用 Date / Calendar

Instant

Instant 表示 UTC 时间线上的一个绝对时刻。

它和 System.currentTimeMillis() 类似,都是表示 epoch 之后的时间。但是 Instant 的表达能力更强,内部由两部分组成:

  1. seconds:从 epoch 开始的秒数。
  2. nanos:当前秒内的纳秒偏移。

所以它可以表达纳秒级精度:

1
2
3
Instant now = Instant.now();

System.out.println(now);

常见用法:

1
2
3
4
5
Instant.now();

Instant.ofEpochMilli(System.currentTimeMillis());

Instant.ofEpochSecond(4, -999_999_999);

Instant 没有时区。它的 toString() 默认按照 ISO-8601 格式输出,并使用 Z 表示 UTC:

1
2
3
Instant instant = Instant.parse("2022-01-01T00:00:00Z");

System.out.println(instant); // 2022-01-01T00:00:00Z

注意:Z 不是说 Instant 存了一个 UTC 时区,而是说输出时用 UTC 方式展示这个绝对时刻。

时间截断:

1
2
3
Instant instant = Instant.now();

Instant truncated = instant.truncatedTo(ChronoUnit.HOURS);

这个表示截断到小时,比如:

1
2022-01-01T10:23:45Z

截断后变成:

1
2022-01-01T10:00:00Z

Instant 转换成带时区的时间:

1
2
3
4
5
Instant instant = Instant.now();

ZonedDateTime shanghaiTime = instant.atZone(ZoneId.of("Asia/Shanghai"));

ZonedDateTime utcTime = instant.atZone(ZoneOffset.UTC);

InstantDate 互转:

1
2
3
4
5
Date date = new Date();

Instant instant = date.toInstant();

Date newDate = Date.from(instant);

Instant 适合表示:

  1. 创建时间。
  2. 更新时间。
  3. 事件发生时间。
  4. 日志时间。
  5. 数据库时间戳。
  6. 跨系统传输的绝对时间。

比如数据库里存“订单创建时间”,推荐使用 Instant 或者明确使用 UTC 的时间戳,而不是裸 LocalDateTime

LocalDateTime

LocalDateTime 表示本地日期时间,它由两部分组成:

1
2
LocalDate // 年月日
LocalTime // 时分秒纳秒

例如:

1
2
3
LocalDateTime localDateTime = LocalDateTime.of(2022, 1, 1, 1, 1, 1);

System.out.println(localDateTime); // 2022-01-01T01:01:01

LocalDateTime 的月份从 1 开始,这一点比 DateCalendar 正常很多:

1
LocalDateTime.of(2022, 1, 1, 1, 1, 1); // 2022-01-01 01:01:01

获取当前本地日期时间:

1
LocalDateTime.now();

指定时区获取当前本地日期时间:

1
2
LocalDateTime.now(ZoneId.of("UTC"));
LocalDateTime.now(ZoneId.of("Asia/Shanghai"));

这两个结果会不同,因为它们是在不同的时区下观察同一个“当前时刻”。

1
2
LocalDateTime utc = LocalDateTime.now(ZoneId.of("UTC"));
LocalDateTime shanghai = LocalDateTime.now(ZoneId.of("Asia/Shanghai"));

通常上海时间会比 UTC 时间大约多 8 小时。

但是下面这两个写法含义完全不同:

1
2
3
LocalDateTime.now(ZoneId.of("UTC"));

LocalDateTime.now().atZone(ZoneId.of("UTC"));

第一个含义是:

当前这个时刻,在 UTC 时区下对应的本地日期时间。

第二个含义是:

先拿系统默认时区下的本地日期时间,然后强行给它贴上 UTC 时区。

假设系统默认时区是 Asia/Shanghai,当前时间是:

1
2022-01-01 10:00:00

那么:

1
LocalDateTime.now(ZoneId.of("UTC"))

可能得到:

1
2022-01-01T02:00:00

而:

1
LocalDateTime.now().atZone(ZoneId.of("UTC"))

可能得到:

1
2022-01-01T10:00:00Z

这两个表示的绝对时刻不是一个东西。

所以要记住:

LocalDateTime 本身不表示一个全球唯一的时刻,它只表示一个“墙上挂钟看到的时间”。

它适合表示:

  1. 生日。
  2. 会议时间。
  3. 表单输入的日期时间。
  4. 不关心时区的业务时间。
  5. 本地展示时间。

它不适合直接表示:

  1. 日志发生时间。
  2. 订单创建时间。
  3. 支付成功时间。
  4. 跨服务传输时间。
  5. 跨时区比较时间。

如果要表示绝对时刻,用 Instant。如果要表示带时区的日期时间,用 ZonedDateTime

LocalDateTimeInstant 必须补充时区:

1
2
3
4
5
LocalDateTime localDateTime = LocalDateTime.now();

Instant instant = localDateTime
.atZone(ZoneId.of("Asia/Shanghai"))
.toInstant();

InstantLocalDateTime 也必须指定时区:

1
2
3
4
5
6
Instant instant = Instant.now();

LocalDateTime localDateTime = LocalDateTime.ofInstant(
instant,
ZoneId.of("Asia/Shanghai")
);

ZonedDateTime

ZonedDateTime 表示:

1
LocalDateTime + ZoneId + ZoneOffset

也就是:

  1. 本地日期时间。
  2. 时区 ID。
  3. 根据时区规则解析出来的实际偏移量。

创建当前系统时区时间:

1
ZonedDateTime.now();

创建 UTC 时间:

1
2
3
ZonedDateTime.now(ZoneId.of("UTC"));

ZonedDateTime.now(Clock.systemUTC());

创建上海时间:

1
ZonedDateTime.now(ZoneId.of("Asia/Shanghai"));

ZonedDateTimeLocalDateTime

1
2
3
ZonedDateTime zonedDateTime = ZonedDateTime.now(ZoneId.of("Asia/Shanghai"));

LocalDateTime localDateTime = zonedDateTime.toLocalDateTime();

这个操作会直接拿出本地日期时间部分,时区信息会丢失。

ZonedDateTimeInstant

1
2
3
ZonedDateTime zonedDateTime = ZonedDateTime.now(ZoneId.of("Asia/Shanghai"));

Instant instant = zonedDateTime.toInstant();

这个操作会把带时区的时间转换成绝对时刻。

跨时区转换时,推荐使用:

1
2
3
ZonedDateTime shanghaiTime = ZonedDateTime.now(ZoneId.of("Asia/Shanghai"));

ZonedDateTime utcTime = shanghaiTime.withZoneSameInstant(ZoneId.of("UTC"));

withZoneSameInstant 的意思是:

保持同一个绝对时刻不变,只是换一个时区展示。

比如:

1
2022-01-01T10:00+08:00[Asia/Shanghai]

转换成 UTC 后是:

1
2022-01-01T02:00Z[UTC]

这两个表示的是同一个瞬间。

还有一个容易混淆的方法:

1
withZoneSameLocal

它的意思是:

保持本地年月日时分秒不变,只是换一个时区。

这个会改变实际表示的绝对时刻,业务上要慎用。

比较时间时,InstantLocalDateTimeZonedDateTime 都有类似方法:

1
2
3
isBefore()
isAfter()
isEqual()

但是要注意:

1
LocalDateTime

不带时区,所以它比较的是本地日期时间,不一定能代表真实时刻的先后。

比如:

1
2
LocalDateTime.of(2022, 1, 1, 10, 0);
LocalDateTime.of(2022, 1, 1, 9, 0);

单看 LocalDateTime,10 点当然比 9 点晚。

但是如果 10 点是上海时间,9 点是纽约时间,它们对应的真实时刻就完全不一样。

所以跨时区、跨系统比较时,推荐先转成:

1
Instant

再比较。

格式化

老 API 使用 SimpleDateFormat

1
2
3
4
5
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

Date date = sdf.parse("2022-01-01 11:11:11");

String str = sdf.format(date);

但是 SimpleDateFormat 是可变对象,多线程环境下不安全。

以前常见的解决方案有几种:

1
2
3
4
5
6
7
8
9
10
11
// 方式一:每次 new
new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

// 方式二:ThreadLocal
private static final ThreadLocal<SimpleDateFormat> SDF =
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));

// 方式三:外部加锁,不太推荐
synchronized (sdf) {
sdf.format(new Date());
}

Java 8 之后推荐使用 DateTimeFormatter

1
2
3
4
5
DateTimeFormatter dtf = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");

String str = dtf.format(LocalDateTime.now());

LocalDateTime localDateTime = LocalDateTime.parse("2022-01-01 11:11:11", dtf);

DateTimeFormatter 是不可变、线程安全的,可以定义成常量复用:

1
2
private static final DateTimeFormatter DATE_TIME_FORMATTER =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");

格式化:

1
String text = DATE_TIME_FORMATTER.format(LocalDateTime.now());

解析:

1
LocalDateTime time = LocalDateTime.parse("2022-01-01 11:11:11", DATE_TIME_FORMATTER);

如果格式里有时区,可以使用 ZonedDateTime

1
2
3
4
5
6
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss VV");

ZonedDateTime zonedDateTime = ZonedDateTime.parse(
"2022-01-01 11:11:11 Asia/Shanghai",
formatter
);

常见格式符号:

符号 含义 示例
yyyy 2022
MM 01
dd 01
HH 24 小时制小时 23
mm 分钟 59
ss 59
SSS 毫秒/秒内小数部分 123
VV 时区 ID Asia/Shanghai
XXX 偏移量 +08:00

注意:

1
yyyy-MM-dd

和:

1
YYYY-MM-dd

不是一回事。

yyyy 是普通年份,YYYY 是 week-based-year,也就是基于周的年份。跨年那几天很容易出问题。

比如一些年份的 12 月 31 日,按照周历可能已经属于下一年的第一周,这时候用 YYYY 就会出现看起来“年份提前”的情况。

所以普通日期格式化,优先使用:

1
yyyy-MM-dd

不要写成:

1
YYYY-MM-dd

还有一个 JDK 8 的老坑:

1
2
3
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMMddHHmmssSSS");

LocalDateTime time = LocalDateTime.parse("20130812214600025", formatter);

在部分 JDK 8 版本中,解析这种没有分隔符、直接把毫秒拼在秒后面的格式时,可能会失败。这是 JDK 8 DateTimeFormatter 的一个历史解析问题。

可以改成带分隔符:

1
2
3
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMMddHHmmss.SSS");

LocalDateTime time = LocalDateTime.parse("20130812214600.025", formatter);

也可以使用 DateTimeFormatterBuilder 明确指定毫秒字段:

1
2
3
4
5
6
DateTimeFormatter formatter = new DateTimeFormatterBuilder()
.appendPattern("yyyyMMddHHmmss")
.appendValue(ChronoField.MILLI_OF_SECOND, 3)
.toFormatter();

LocalDateTime time = LocalDateTime.parse("20130812214600025", formatter);

但是这个问题不要理解成 DateTimeFormatter 线程不安全。DateTimeFormatter 本身是线程安全的,这里是 JDK 8 特定格式解析的问题。

还有一个实际开发中更常见的坑:用时间格式生成文件名。

1
2
3
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");

String fileName = formatter.format(LocalDateTime.now());

这个格式只有秒级精度。如果一秒内生成多个文件名,就一定可能重复。

比如:

1
2
20220101111111
20220101111111

这不是线程安全问题,而是精度不够。

可以加毫秒:

1
2
3
4
5
6
DateTimeFormatter formatter = new DateTimeFormatterBuilder()
.appendPattern("yyyyMMddHHmmss")
.appendValue(ChronoField.MILLI_OF_SECOND, 3)
.toFormatter();

String fileName = formatter.format(LocalDateTime.now());

但是即使加到毫秒,也不保证高并发下绝对唯一。因为同一毫秒内仍然可能生成多个文件名。

更稳的做法是拼接 UUID、雪花 ID、AtomicLong 或者直接使用临时文件 API。

1
2
3
String fileName = LocalDateTime.now().format(formatter)
+ "-"
+ UUID.randomUUID();

或者:

1
Path tempFile = Files.createTempFile("upload-", ".jpg");

综上,格式化部分可以记住几条规则:

  1. 新代码优先使用 DateTimeFormatter
  2. DateTimeFormatter 可以定义成 static final 常量。
  3. 普通年份用 yyyy,不要误用 YYYY
  4. 跨时区格式化时,明确使用 ZonedDateTime 或者给 formatter 绑定 zone。
  5. 时间字符串生成文件名时,不要只靠秒级时间保证唯一性。
  6. JDK 8 下解析 yyyyMMddHHmmssSSS 这种紧凑格式要注意历史 bug。
  7. 绝对时间比较、存储、传输优先使用 Instant

字符串拼接

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

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

  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/java_trick/
作者
AtLuoFu
发布于
2024年10月10日
许可协议