Wetts's blog

Stay Hungry, Stay Foolish.

0%

转自:https://www.cnblogs.com/cdchencw/p/12924244.html

  1. 运行命令【Java jar demo.jar
    1. (第 1 种方式)可以在当前 Jar 文件目录建 config 同级目录,这个 config 同级目录放入 application.yml
    2. (第 2 种方式)可以在当前 Jar 文件目录直接放入 application.yml
  2. 运行命令(第 3 种方式)【java -jar demo.jar --spring.config.location=路径(application.yml)
    • 只需要将路径配置填入就 ok

转自:https://blog.csdn.net/qq_33591903/article/details/102948344

快速理解 Consumer、Supplier、Predicate 与 Function

前言

这几个接口都处在java.util.function包下,Consumer(消费型),Supplier(供给型)、Predicate(判断型)与Function(转换型),暂时不理解他们的类型没关系。

Consumer

Consumer 是一个消费型的接口

先看 Consumer 接口的源码,有一个未实现的抽象方法,和一个默认方法(jdk1.8 之后,接口里面可以有默认方法和静态方法)。

1
2
3
4
5
6
7
8
9
10
11
@FunctionalInterface
public interface Consumer<T> {

void accept(T t);

default Consumer<T> andThen(Consumer<? super T> after) {
Objects.requireNonNull(after);
return (T t) -> { accept(t); after.accept(t); };
}

}

我们只在意这个accept方法,接收一个泛型参数,不返回任何值。ok,我们来简单实现它

1
2
3
4
5
6
7
8
Consumer<Integer> consumer=new Consumer<Integer>() {
@Override
public void accept(Integer integer) {
System.out.println(integer);
}
};

consumer.accept(1);

接下来我们使用 lambda 表达式来对此匿名内部类进行改写。此时该 lambda 的类型就是 Consumer 类型。

1
consumer = i-> System.out.println(i);

当然我们也可以使用方法引用

1
consumer=System.out::println;

在 Stream 类中,我们发现常用的 forEach 接口接收一个 Consumer 类型的参数,源码如下

1
void forEach(Consumer<? super T> action);

我们将 consumer 传入 forEach 中,来实现遍历集合的操作。

1
2
3
List<Integer> list= Arrays.asList(1,2,3,4,5);
Consumer<Integer> consumer= System.out::println;
list.stream().forEach(consumer);

Consumer 总结:

  • Consumer 接口是一个消费型的接口,只要实现它的 accept 方法,就能作为消费者来输出信息。
  • lambda、方法引用都可以是一个 Consumer 类型,因此他们可以作为 forEach 的参数,用来协助 Stream 输出信息。
  • Consumer 还有很多变种,例如 IntConsumer、DoubleConsumer 与 LongConsumer 等,归根结底,这些变种其实只是指定了 Consumer 中的泛型而已,方法上并无变化。

Supplier

Supplier 是一个供给型的接口,我们可以无条件的从它这里获取东西。

1
2
3
4
5
@FunctionalInterface
public interface Supplier<T> {

T get();
}

我们不需要为 get 方法传入任何参数,就能获得一个结果

1
2
3
4
Supplier<Double> supplier=()->new Random().nextDouble();
//当然也可以使用方法引用
Supplier<Double> supplier1= Math::random;
System.out.println(supplier.get());

我们看看 Supplier 在 Optional 中的应用。

1
2
3
public T orElseGet(Supplier<? extends T> other) {
return value != null ? value : other.get();
}

该方法接收 Supplier 类型的参数,当 Optional 内部的 value 不为空时,才会返回 Supplier 中的值。例如

1
2
3
Optional<Double> optional=Optional.empty();
Supplier<Double> supplier=()->new Random().nextDouble();
optional.orElseGet(supplier);

这必定返回 Supplier 中的随机值,因为 Optional.empty() 包含的值就是 null。

Supplier 总结:

  • Supplier 是一个供给型的接口,其中的 get 方法用于返回一个值。
  • Supplier 也有很多的变种,例如 IntSupplier、LongSupplier 与 BooleanSupplier 等

Predicate

Predicate 是一个判断型接口,看看它的源码。

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
@FunctionalInterface
public interface Predicate<T> {

boolean test(T t);

default Predicate<T> and(Predicate<? super T> other) {
Objects.requireNonNull(other);
return (t) -> test(t) && other.test(t);
}

default Predicate<T> negate() {
return (t) -> !test(t);
}

default Predicate<T> or(Predicate<? super T> other) {
Objects.requireNonNull(other);
return (t) -> test(t) || other.test(t);
}

static <T> Predicate<T> isEqual(Object targetRef) {
return (null == targetRef)
? Objects::isNull
: object -> targetRef.equals(object);
}
}
1
2
Predicate<Integer> predicate=i->i>5;
System.out.println(predicate.test(1));

很明显,输出是 false。等等,既然可以进行判断,那和 Stream.filter() 有没有关系呢?

1
Stream<T> filter(Predicate<? super T> predicate);
1
2
List<Integer> list= Arrays.asList(1,2,3,4,5,6,7,8);
list.stream().filter(i->i>5).forEach(System.out::print);

很简单,输出是678。

Predicate 总结:

  • Predicate 是一个判断型的接口,用一个 test 方法去测试传入的参数。
  • 当然,Predicate 也有对应的变种。

Function

Function 是一个功能型的接口,用于将一种类型的数据转化为另外一种类型的数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@FunctionalInterface
public interface Function<T, R> {

R apply(T t);

default <V> Function<V, R> compose(Function<? super V, ? extends T> before) {
Objects.requireNonNull(before);
return (V v) -> apply(before.apply(v));
}

default <V> Function<T, V> andThen(Function<? super R, ? extends V> after) {
Objects.requireNonNull(after);
return (T t) -> after.apply(apply(t));
}

static <T> Function<T, T> identity() {
return t -> t;
}
}

重点关注它的 apply 方法,现在就去实现它,并将之传入进 Stream.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
25
public class TestFunction {
static class Student{
String name;
Integer id;

public Student(String name, Integer id) {
this.name = name;
this.id = id;
}

public String getName() {
return name;
}

public Integer getId() {
return id;
}
}
public static void main(String[] args) {
List<Student> list= Arrays.asList(new Student("jack",1),new Student("tom",2));
Function<Student,Integer> function= Student::getId;
list.stream().map(function).forEach(System.out::print);
}

}

输出 12,可以看得出,Function 中的 apply 方法将 Student 类型的数据转化为对应 id 的 Integer 类型的数据。

Function 总结:

  • Function 是一个转换型的接口,其中的 apply 可以将一种类型的数据转化成另外一种类型的数据。
  • Function 的变种就更多了。

总结

首先只要记住这四个接口的类型,Consumer(消费型)、Supplier(供给型)、Predicate(判断型)与Function(转换型),再记住他们对应的抽象方法 Consumer(accpet)、Supplier(get)、Predicate(test) 与 Function(apply)。

转自:https://blog.csdn.net/y_k_y/article/details/84633001

概述

Stream 是 Java8 中处理集合的关键抽象概念,它可以指定你希望对集合进行的操作,可以执行非常复杂的查找、过滤和映射数据等操作。使用Stream API 对集合数据进行操作,就类似于使用 SQL 执行的数据库查询。也可以使用 Stream API 来并行执行操作。简而言之,Stream API 提供了一种高效且易于使用的处理数据的方式。

特点:

  1. 不是数据结构,不会保存数据。
  2. 不会修改原来的数据源,它会将操作后的数据保存到另外一个对象中。(保留意见:毕竟 peek 方法可以修改流中元素)
  3. 惰性求值,流在中间处理过程中,只是对操作进行了记录,并不会立即执行,需要等到执行终止操作的时候才会进行实际的计算。

分类

1

  • 无状态:指元素的处理不受之前元素的影响;
  • 有状态:指该操作只有拿到所有元素之后才能继续下去。
  • 非短路操作:指必须处理所有元素才能得到最终结果;
  • 短路操作:指遇到某些符合条件的元素就可以得到最终结果,如 A||B,只要 A 为 true,则无需判断 B 的结果。

具体用法

流的常用创建方法

使用 Collection 下的 stream()parallelStream() 方法

1
2
3
List<String> list = new ArrayList<>();
Stream<String> stream = list.stream(); //获取一个顺序流
Stream<String> parallelStream = list.parallelStream(); //获取一个并行流

使用 Arrays 中的 stream() 方法,将数组转成流

1
2
Integer[] nums = new Integer[10];
Stream<Integer> stream = Arrays.stream(nums);

使用 Stream 中的静态方法:of()iterate()generate()

1
2
3
4
5
6
7
Stream<Integer> stream = Stream.of(1,2,3,4,5,6);

Stream<Integer> stream2 = Stream.iterate(0, (x) -> x + 2).limit(6);
stream2.forEach(System.out::println); // 0 2 4 6 8 10

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

使用 BufferedReader.lines() 方法,将每行内容转成流

1
2
3
BufferedReader reader = new BufferedReader(new FileReader("F:\\test_stream.txt"));
Stream<String> lineStream = reader.lines();
lineStream.forEach(System.out::println);

使用 Pattern.splitAsStream() 方法,将字符串分隔成流

1
2
3
Pattern pattern = Pattern.compile(",");
Stream<String> stringStream = pattern.splitAsStream("a,b,c,d");
stringStream.forEach(System.out::println);

流的中间操作

筛选与切片

  • filter:过滤流中的某些元素
  • limit(n):获取n个元素
  • skip(n):跳过n元素,配合limit(n)可实现分页
  • distinct:通过流中元素的 hashCode() 和 equals() 去除重复元素
1
2
3
4
5
6
7
Stream<Integer> stream = Stream.of(6, 4, 6, 7, 3, 9, 8, 10, 12, 14, 14);

Stream<Integer> newStream = stream.filter(s -> s > 5) //6 6 7 9 8 10 12 14 14
.distinct() //6 7 9 8 10 12 14
.skip(2) //9 8 10 12 14
.limit(2); //9 8
newStream.forEach(System.out::println);

映射

  • map:接收一个函数作为参数,该函数会被应用到每个元素上,并将其映射成一个新的元素。
  • flatMap:接收一个函数作为参数,将流中的每个值都换成另一个流,然后把所有流连接成一个流。
1
2
3
4
5
6
7
8
9
10
11
12
13
List<String> list = Arrays.asList("a,b,c", "1,2,3");

//将每个元素转成一个新的且不带逗号的元素
Stream<String> s1 = list.stream().map(s -> s.replaceAll(",", ""));
s1.forEach(System.out::println); // abc 123

Stream<String> s3 = list.stream().flatMap(s -> {
//将每个元素转换成一个stream
String[] split = s.split(",");
Stream<String> s2 = Arrays.stream(split);
return s2;
});
s3.forEach(System.out::println); // a b c 1 2 3

排序

  • sorted():自然排序,流中元素需实现Comparable接口
  • sorted(Comparator com):定制排序,自定义Comparator排序器
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
List<String> list = Arrays.asList("aa", "ff", "dd");
//String 类自身已实现Compareable接口
list.stream().sorted().forEach(System.out::println);// aa dd ff

Student s1 = new Student("aa", 10);
Student s2 = new Student("bb", 20);
Student s3 = new Student("aa", 30);
Student s4 = new Student("dd", 40);
List<Student> studentList = Arrays.asList(s1, s2, s3, s4);

//自定义排序:先按姓名升序,姓名相同则按年龄升序
studentList.stream().sorted(
(o1, o2) -> {
if (o1.getName().equals(o2.getName())) {
return o1.getAge() - o2.getAge();
} else {
return o1.getName().compareTo(o2.getName());
}
}
).forEach(System.out::println);

消费

  • peek:如同于 map,能得到流中的每一个元素。但 map 接收的是一个 Function 表达式,有返回值;而 peek 接收的是 Consumer 表达式,没有返回值。
1
2
3
4
5
6
7
8
9
10
11
Student s1 = new Student("aa", 10);
Student s2 = new Student("bb", 20);
List<Student> studentList = Arrays.asList(s1, s2);

studentList.stream()
.peek(o -> o.setAge(100))
.forEach(System.out::println);

//结果:
Student{name='aa', age=100}
Student{name='bb', age=100}

流的终止操作

匹配、聚合操作

  • allMatch:接收一个 Predicate 函数,当流中每个元素都符合该断言时才返回true,否则返回false
  • noneMatch:接收一个 Predicate 函数,当流中每个元素都不符合该断言时才返回true,否则返回false
  • anyMatch:接收一个 Predicate 函数,只要流中有一个元素满足该断言则返回true,否则返回false
  • findFirst:返回流中第一个元素
  • findAny:返回流中的任意元素
  • count:返回流中元素的总个数
  • max:返回流中元素最大值
  • min:返回流中元素最小值
1
2
3
4
5
6
7
8
9
10
11
12
List<Integer> list = Arrays.asList(1, 2, 3, 4, 5);

boolean allMatch = list.stream().allMatch(e -> e > 10); //false
boolean noneMatch = list.stream().noneMatch(e -> e > 10); //true
boolean anyMatch = list.stream().anyMatch(e -> e > 4); //true

Integer findFirst = list.stream().findFirst().get(); //1
Integer findAny = list.stream().findAny().get(); //1

long count = list.stream().count(); //5
Integer max = list.stream().max(Integer::compareTo).get(); //5
Integer min = list.stream().min(Integer::compareTo).get(); //1

规约操作

  • Optional<T> reduce(BinaryOperator<T> accumulator):第一次执行时,accumulator 函数的第一个参数为流中的第一个元素,第二个参数为流中元素的第二个元素;第二次执行时,第一个参数为第一次函数执行的结果,第二个参数为流中的第三个元素;依次类推。
  • T reduce(T identity, BinaryOperator<T> accumulator):流程跟上面一样,只是第一次执行时,accumulator 函数的第一个参数为 identity,而第二个参数为流中的第一个元素。
  • <U> U reduce(U identity,BiFunction<U, ? super T, U> accumulator,BinaryOperator<U> combiner):在串行流(stream)中,该方法跟第二个方法一样,即第三个参数 combiner 不会起作用。在并行流(parallelStream)中,我们知道流被 fork join 出多个线程进行执行,此时每个线程的执行流程就跟第二个方法 reduce(identity,accumulator) 一样,而第三个参数 combiner 函数,则是将每个线程的执行结果当成一个新的流,然后使用第一个方法 reduce(accumulator) 流程进行规约。
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
//经过测试,当元素个数小于24时,并行时线程数等于元素个数,当大于等于24时,并行时线程数为16
List<Integer> list = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24);

Integer v = list.stream().reduce((x1, x2) -> x1 + x2).get();
System.out.println(v); // 300

Integer v1 = list.stream().reduce(10, (x1, x2) -> x1 + x2);
System.out.println(v1); //310

Integer v2 = list.stream().reduce(0,
(x1, x2) -> {
System.out.println("stream accumulator: x1:" + x1 + " x2:" + x2);
return x1 - x2;
},
(x1, x2) -> {
System.out.println("stream combiner: x1:" + x1 + " x2:" + x2);
return x1 * x2;
});
System.out.println(v2); // -300

Integer v3 = list.parallelStream().reduce(0,
(x1, x2) -> {
System.out.println("parallelStream accumulator: x1:" + x1 + " x2:" + x2);
return x1 - x2;
},
(x1, x2) -> {
System.out.println("parallelStream combiner: x1:" + x1 + " x2:" + x2);
return x1 * x2;
});
System.out.println(v3); //197474048

收集操作

  • collect:接收一个Collector实例,将流中元素收集成另外一个数据结构。
  • Collector<T, A, R> 是一个接口,有以下5个抽象方法:
    • Supplier<A> supplier():创建一个结果容器A
    • BiConsumer<A, T> accumulator():消费型接口,第一个参数为容器A,第二个参数为流中元素T。
    • BinaryOperator<A> combiner():函数接口,该参数的作用跟上一个方法(reduce)中的combiner参数一样,将并行流中各个子进程的运行结果(accumulator 函数操作后的容器 A)进行合并。
    • Function<A, R> finisher():函数式接口,参数为:容器A,返回类型为:collect方法最终想要的结果R。
    • Set<Characteristics> characteristics():返回一个不可变的Set集合,用来表明该Collector的特征。有以下三个特征:
      • CONCURRENT:表示此收集器支持并发。
      • UNORDERED:表示该收集操作不会保留流中元素原有的顺序。
      • IDENTITY_FINISH:表示finisher参数只是标识而已,可忽略。
Collector 工具库:Collectors
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
Student s1 = new Student("aa", 10,1);
Student s2 = new Student("bb", 20,2);
Student s3 = new Student("cc", 10,3);
List<Student> list = Arrays.asList(s1, s2, s3);

//装成list
List<Integer> ageList = list.stream().map(Student::getAge).collect(Collectors.toList()); // [10, 20, 10]

//转成set
Set<Integer> ageSet = list.stream().map(Student::getAge).collect(Collectors.toSet()); // [20, 10]

//转成map,注:key不能相同,否则报错
Map<String, Integer> studentMap = list.stream().collect(Collectors.toMap(Student::getName, Student::getAge)); // {cc=10, bb=20, aa=10}

//字符串分隔符连接
String joinName = list.stream().map(Student::getName).collect(Collectors.joining(",", "(", ")")); // (aa,bb,cc)

//聚合操作
//1.学生总数
Long count = list.stream().collect(Collectors.counting()); // 3
//2.最大年龄 (最小的minBy同理)
Integer maxAge = list.stream().map(Student::getAge).collect(Collectors.maxBy(Integer::compare)).get(); // 20
//3.所有人的年龄
Integer sumAge = list.stream().collect(Collectors.summingInt(Student::getAge)); // 40
//4.平均年龄
Double averageAge = list.stream().collect(Collectors.averagingDouble(Student::getAge)); // 13.333333333333334
// 带上以上所有方法
DoubleSummaryStatistics statistics = list.stream().collect(Collectors.summarizingDouble(Student::getAge));
System.out.println("count:" + statistics.getCount() + ",max:" + statistics.getMax() + ",sum:" + statistics.getSum() + ",average:" + statistics.getAverage());

//分组
Map<Integer, List<Student>> ageMap = list.stream().collect(Collectors.groupingBy(Student::getAge));
//多重分组,先根据类型分再根据年龄分
Map<Integer, Map<Integer, List<Student>>> typeAgeMap = list.stream().collect(Collectors.groupingBy(Student::getType, Collectors.groupingBy(Student::getAge)));

//分区
//分成两部分,一部分大于10岁,一部分小于等于10岁
Map<Boolean, List<Student>> partMap = list.stream().collect(Collectors.partitioningBy(v -> v.getAge() > 10));

//规约
Integer allAge = list.stream().map(Student::getAge).collect(Collectors.reducing(Integer::sum)).get(); //40
Collectors.toList() 解析
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
//toList 源码
public static <T> Collector<T, ?, List<T>> toList() {
return new CollectorImpl<>((Supplier<List<T>>) ArrayList::new, List::add,
(left, right) -> {
left.addAll(right);
return left;
}, CH_ID);
}

//为了更好地理解,我们转化一下源码中的lambda表达式
public <T> Collector<T, ?, List<T>> toList() {
Supplier<List<T>> supplier = () -> new ArrayList();
BiConsumer<List<T>, T> accumulator = (list, t) -> list.add(t);
BinaryOperator<List<T>> combiner = (list1, list2) -> {
list1.addAll(list2);
return list1;
};
Function<List<T>, List<T>> finisher = (list) -> list;
Set<Collector.Characteristics> characteristics = Collections.unmodifiableSet(EnumSet.of(Collector.Characteristics.IDENTITY_FINISH));

return new Collector<T, List<T>, List<T>>() {
@Override
public Supplier supplier() {
return supplier;
}

@Override
public BiConsumer accumulator() {
return accumulator;
}

@Override
public BinaryOperator combiner() {
return combiner;
}

@Override
public Function finisher() {
return finisher;
}

@Override
public Set<Characteristics> characteristics() {
return characteristics;
}
};

}

参考:https://www.cnblogs.com/ywjy/p/7771042.html

前置条件

代码如下:

1
2
3
4
5
6
7
package com.hikvision.demo;

public class HelloWorld {
public static void main(String[] args) {
System.out.println("Hello World");
}
}

目录结构如下:

1
2
3
./
├─ HelloWorld.java
├─ target/

Java 原生打包

通过 jar 命令打包。JAR 包是 Java 中所特有一种压缩文档,其实大家就可以把它理解为 .zip 包。当然也是有区别的,JAR 包中有一个 META-INF\MANIFEST.MF 文件,当你打成 JAR 包时,它会自动生成。

jar 命令

jar 命令格式:jar {c t x u f }[ v m e 0 M i ][-C 目录]文件名...

  • -c 创建一个 jar 包
  • -t 显示 jar 中的内容列表
  • -x 解压 jar 包
  • -u 添加文件到 jar 包中
  • -f 指定 jar 包的文件名
  • -v 生成详细的报造,并输出至标准设备
  • -m 指定 manifest.mf 文件.(manifest.mf 文件中可以对 jar 包及其中的内容作一些一设置)
  • -0 产生 jar 包时不对其中的内容进行压缩处理
  • -M 不产生所有文件的清单文件(Manifest.mf)。这个参数与忽略掉-m参数的设置
  • -i 为指定的 jar 文件创建索引文件
  • -C 表示转到相应的目录下执行 jar 命令,相当于 cd 到那个目录,然后不带 -C 执行 jar 命令

jar 使用范例

  • 创建 jar 包
    • jar cf hello.jar hello
      • 利用 test 目录生成 hello.jar 包,如 hello.jar 存在,则覆盖
  • 创建并显示打包过程
    • jar cvf hello.jar hello
      • 利用 hello 目录创建 hello.jar 包,并显示创建过程
  • 显示 jar 包
    • jar tvf hello.jar
      • 查看 hello.jar 包的内容。
      • 指定的 jar 包必须真实存在,否则会发生 FileNoutFoundException。
  • 解压 jar 包
    • jar xvf hello.jar
      • 解压 hello.jar 至当前目录
  • jar 中添加文件
    • jar uf hello.jar HelloWorld.java
      • 将 HelloWorld.java 添加到 hello.jar 包中
  • 创建不压缩内容 jar 包
    • jar cvf0 hello.jar *.class
      • 利用当前目录中所有的 .class 文件生成一个不压缩 jar 包
  • 创建带 manifest.mf 文件的 jar 包
    • jar cvfm hello.jar manifest.mf hello
      • 创建的 jar 包多了一个 META-INF 目录,META-INF 子目录下多了一个 manifest.mf 文件
  • 忽略 manifest.mf 文件
    • jar cvfM hello.jar hello
      • 生成的 jar 包中不包括 META-INF 目录及 manifest.mf 文件
  • 加 -C 应用
    • jar cvfm hello.jar mymanifest.mf -C hello/
      • 表示在切换到 hello 目录下然后再执行 jar 命令
  • -i 为 jar 文件生成索引列表
    • jar i hello.jar
      • 执行完这条命令后,它会在 hello.jar 包的 META-INF 文件夹下生成一个名为 INDEX.LIST 的索引文件,它会生成一个列表,最上边为 jar 包名。
  • 导出解压列表
    • jar tvf hello.jar > hello.txt
      • 如果你想查看解压一个 jar 的详细过程,而这个 jar 包又很大,屏幕信息会一闪而过,这时你可以把列表输出到一个文件中

Manifest.mf 文件编写规则

  1. 一般属性

    1. Manifest-Version
      • 用来定义manifest文件的版本,例如:Manifest-Version: 1.0
    2. Created-By
      • 声明该文件的生成者,一般该属性是由jar命令行工具生成的,例如:Created-By: Apache Ant 1.5.1
    3. Signature-Version
      • 定义jar文件的签名版本
    4. Class-Path
      • 应用程序或者类装载器使用该值来构建内部的类搜索路径
  2. 应用程序相关属性

    1. Main-Class
      • 定义jar文件的入口类,该类必须是一个可执行的类,一旦定义了该属性即可通过 java -jar x.jar来运行该jar文件。
  3. 小程序(Applet)相关属性

    1. Extendsion-List
      • 该属性指定了小程序需要的扩展信息列表,列表中的每个名字对应以下的属性
    2. -Extension-Name
    3. -Specification-Version
    4. -Implementation-Version
    5. -Implementation-Vendor-Id
    6. -Implementation-URL
  4. 扩展标识属性

    1. Extension-Name
      • 该属性定义了jar文件的标识,例如Extension-Name: Struts Framework
  5. 包扩展属性

    1. Implementation-Title 定义了扩展实现的标题
    2. Implementation-Version 定义扩展实现的版本
    3. Implementation-Vendor 定义扩展实现的组织
    4. Implementation-Vendor-Id 定义扩展实现的组织的标识
    5. Implementation-URL : 定义该扩展包的下载地址(URL)
    6. Specification-Title 定义扩展规范的标题
    7. Specification-Version 定义扩展规范的版本
    8. Specification-Vendor 声明了维护该规范的组织
    9. Sealed 定义jar文件是否封存,值可以是true或者false (这点我还不是很理解)
  6. 签名相关属性

    • 签名方面的属性我们可以来参照 JavaMail 所提供的 mail.jar 中的一段

      • ```
        Name: javax/mail/Address.class

      Digest-Algorithms: SHA MD5

      SHA-Digest: AjR7RqnN//cdYGouxbd06mSVfI4=

      MD5-Digest: ZnTIQ2aQAtSNIOWXI1pQpw==

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
         - 这段内容定义类签名的类名、计算摘要的算法名以及对应的摘要内容(使用 BASE64 方法进行编码)
      7. 自定义属性
      - 除了前面提到的一些属性外,你也可以在 MANIFEST.MF 中增加自己的属性以及响应的值,例如 J2ME 程序 jar 包中就可能包含着如下信息
      - ```
      MicroEdition-Configuration: CLDC-1.0

      MIDlet-Name: J2ME_MOBBER Midlet Suite

      MIDlet-Info-URL: http://www.javayou.com/

      MIDlet-Icon: /icon.png

      MIDlet-Vendor: Midlet Suite Vendor

      MIDlet-1: mobber,/icon.png,mobber

      MIDlet-Version: 1.0.0

      MicroEdition-Profile: MIDP-1.0

      MIDlet-Description: Communicator

打包

编译

命令:javac HelloWorld.java -d target。目录结构变为:

1
2
3
4
5
./
├─ HelloWorld.java
├─ target/
├─ com/hikvision/demo/
├─ HelloWorld.class

打包

命令:jar cvf demo-algorithm.jar -C target/ .。目录结构变为:

1
2
3
4
5
6
7
./
├─ HelloWorld.java
├─ target/
│ └─ com/hikvision/demo/
│ └─ HelloWorld.class
├─ demo-algorithm.jar

打包的结果 demo-algorithm.jar,其内部结构为:

1
2
3
4
5
6
7
demo-algorithm.jar
├─ com
│ └─ hikvision
│ └─ demo
│ └─ HelloWorld.class
└─ META-INF
└─ MANIFEST.MF

其中,MANIFEST.MF 的内容为:

1
2
Manifest-Version: 1.0
Created-By: 1.8.0_144 (Oracle Corporation)

运行

命令:java -cp demo-algorithm.jar com.hikvision.demo.HelloWorld

留意上面的 jar 包的结构,如果我们希望以 java -cp 的方式运行 jar 包中的某一个类的 main 方法,class 的 package 必须对应 jar 包内部的一级目录。

java -cp 和 -classpath 一样,是指定类运行所依赖其他类的路径,通常是类库,jar 包之类,需要全路径到 jar 包,window 上分号“;” 分隔,linux 上是分号“:”分隔。不支持通配符,需要列出所有 jar 包,用一点“.”代表当前路径。
格式:
java -cp .;myClass.jar packname.mainclassname
表达式支持通配符,例如:
java -cp .;c:\classes01\myClass.jar;c:\classes02\*.jar packname.mainclassname

相关阅读
java -jar myClass.jar
执行该命令时,会用到目录 META-INF\MANIFEST.MF 文件,在该文件中,有一个叫 Main-Class 的参数,它说明了 java -jar 命令执行的类。

这种结构我们称之为 java 标准 jar 包结构。

Maven 原生打包

我一般使用 mvn clean package 命令打包。

maven 打包的结果,jar 包名称是根据 artifactId 和 version 来生成的,比如对于 com.hikvision.algorithm:demo-algorithm:0.0.1-SNAPSHOT 的打包结果是:demo-algorithm-0.0.1-SNAPSHOT.jar

分析这个 jar 包的结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
.
├─com
│ └─hikvision
│ └─algorithm
│ └─HelloWorld.class
├─META-INF
│ ├─maven
│ │ └─com.hikvision.algorithm
│ │ └─demo-algorithm
│ │ ├─pom.properties
│ │ └─pom.xml
│ └─MANIFEST.MF
└─application.properties

除 META-INF 目录之外,其他的都是 class path,这一点符合 java 标准 jar 结构。不同的是 META-INF 有一级子目录 maven,放置项目的 maven 信息。

对于 maven 原生的打包结果,可以使用 java -cp 的方式执行其中某个主类。但是需要注意它并没有包含所依赖的 jar 包,这需要另外提供。

使用 Maven shade 插件打包

Maven 打包插件应该不止一种,这里使用的是 maven-shade-plugin

在 pom 文件中添加插件配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>2.4.3</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
</execution>
</executions>
</plugin>

根据上面的配置,在 package 阶段,会自动执行插件的 shade 目标,这个目标负责将项目的 class 文件,以及项目依赖的 class 文件都会统一打到一个 jar 包里。

我们可以执行 mvn clean package 来自动触发 shade,或者直接执行 mvn shade:shade。

target 目录会生成 2 个 jar 包,一个是 maven 原生的 jar 包,一个是插件的 jar 包:

1
2
3
target/
├─ original-demo-algorithm-0.0.1-SNAPSHOT.jar (4KB)
└─ demo-algorithm-0.0.1-SNAPSHOT.jar (6.24MB)

original-demo-algorithm-0.0.1-SNAPSHOT.jar 是原生的 jar 包,不包含任何依赖,只有 4KB。demo-algorithm-0.0.1-SNAPSHOT.jar 是包含依赖的 jar 包,有 6.24MB。

对照上文可以猜测 shade 插件对 maven 原生打包结果进行重命名之后,使用这个名字又打出一个集成了依赖的 jar 包。

注意,这表示如果执行了 mvn install,最终被安装到本地仓库的是插件打出的 jar 包,而不是 maven 原生的打包结果。可以配置插件,修改打包结果的名称:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>2.4.3</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<finalName>demo-algorithm-0.0.1-SNAPSHOT-assembly</finalName>
</configuration>
</execution>
</executions>
</plugin>

使用这个配置,最终的打包结果:

1
2
3
target/
├─ demo-algorithm-0.0.1-SNAPSHOT.jar (4KB)
└─ demo-algorithm-0.0.1-SNAPSHOT-assembly.jar (6.24MB)

此时,demo-algorithm-0.0.1-SNAPSHOT.jar 是 maven 原生的打包结果,demo-algorithm-0.0.1-SNAPSHOT-assembly.jar 是插件的打包结果。

插件打包结果的内部结构如下:

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
├─ch
│ └─qos
│ └─logback
│ ├─classic
│ │ ├─boolex
│ │ ├─db
│ │ │ ├─names
│ │ │ └─script
│ │ ├─encoder
│ │ └─util
│ └─core
│ ├─boolex
│ ├─db
│ │ └─dialect
│ ├─encoder
│ ├─joran
│ │ ├─action
│ │ ├─conditional
│ │ ├─event
│ │ │ └─stax
│ │ ├─node
│ │ ├─spi
│ │ └─util
│ │ └─beans
│ ├─subst
│ └─util
├─com
│ └─hikvision
│ └─algorithm
├─META-INF
│ ├─maven
│ │ ├─ch.qos.logback
│ │ │ ├─logback-classic
│ │ │ └─logback-core
│ │ ├─com.hikvision.algorithm
│ │ │ └─demo-algorithm
│ │ ├─org.slf4j
│ │ │ ├─jcl-over-slf4j
│ │ │ ├─jul-to-slf4j
│ │ │ ├─log4j-over-slf4j
│ │ │ └─slf4j-api
│ │ ├─org.springframework.boot
│ │ │ ├─spring-boot
│ │ │ ├─spring-boot-autoconfigure
│ │ │ ├─spring-boot-starter
│ │ │ └─spring-boot-starter-logging
│ │ └─org.yaml
│ │ └─snakeyaml
│ ├─org
│ │ └─apache
│ │ └─logging
│ │ └─log4j
│ │ └─core
│ │ └─config
│ │ └─plugins
│ └─services
└─org
├─apache
│ ├─commons
│ │ └─logging
│ │ └─impl
│ └─log4j
│ ├─helpers
│ ├─spi
│ └─xml
├─slf4j
│ ├─bridge
│ ├─event
│ ├─helpers
│ ├─impl
│ └─spi
├─springframework
│ ├─boot
│ │ ├─admin
│ │ ├─ansi
│ │ ├─web
│ │ │ ├─client
│ │ │ ├─filter
│ │ │ ├─servlet
│ │ │ └─support
│ │ └─yaml
│ └─validation
│ ├─annotation
│ ├─beanvalidation
│ └─support
└─yaml
└─snakeyaml
├─error
├─tokens
└─util

这里省略了所有的文件,以及大部分的子目录。

除 META-INF 目录外的其他所有目录,都是 classpath,结构和 Maven 原生的打包结构相同。不同的是 shade 插件将所有的依赖 jar 解压缩之后,和项目的 class 文件一起重新打成 jar 包;并且在 META-INF/maven 下包含了项目本身及所依赖的项目的 pom 信息。

如果在 pom 文件中,声明某个依赖是 provided 的,它就不会被集成到 jar 包里。

总的来说,使用 maven-shade-plugin 打出的 jar 包的结构依然符合 java 标准 jar 包结构,所以我们可以通过 java -cp 的方式运行 jar 包中的某一个类的 main 方法。

使用 spring-boot-maven-plugin 插件打包

项目首先必须是 spring-boot 项目,即项目直接或间接继承了 org.springframework.boot:spring-boot-starter-parent。

在 pom 文件中配置 spring-boot-maven-plugin 插件:

1
2
3
4
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>

这个插件默认将打包绑定在了 maven 生命周期的 package 阶段,即执行 package 命令会自动触发插件打包。

插件会将 Maven 原生的打包结果重命名,然后将自己的打包结果使用之前那个名字。比如:

1
2
3
4
target/
├─ ...
├─ demo-algorithm-0.0.1-SNAPSHOT.jar.original
└─ demo-algorithm-0.0.1-SNAPSHOT.jar

如上,demo-algorithm-0.0.1-SNAPSHOT.jar.original 是 Maven 原生的打包结果,被重命名之后追加了 .original 后缀。demo-algorithm-0.0.1-SNAPSHOT.jar 是插件的打包结果。

这里需要注意,如果运行了 mvn install,会将这个大一统的 jar 包安装到本地仓库。这一点可以配置,使用下面的插件配置,可以确保安装到本地仓库的是原生的打包结果:

1
2
3
4
5
6
7
8
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<!--将原始的包作为install和deploy的对象,而不是包含了依赖的包-->
<attach>false</attach>
</configuration>
</plugin>

spring-boot-maven-plugin 打包的结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
.
├─BOOT-INF
│ ├─classes
│ │ └─com
│ │ └─hikvision
│ │ └─algorithm
│ └─lib
├─META-INF
│ └─maven
│ └─com.hikvision.algorithm
│ └─demo-algorithm
└─org
└─springframework
└─boot
└─loader
├─archive
├─data
├─jar
└─util

这里忽略了所有的文件。

分析这个结构,spring-boot 插件将项目本身的 class 放到了目录 BOOT-INF/classes 下,将所有依赖的 jar 放到了 BOOT-INF/lib 下。在 jar 包的顶层有一个子目录 org,是 spring-boot loader 相关的 classes。

所以,这个与 java 标准 jar 包结构是不同的,和 maven 原生的打包结构也是不同的。

另外,需要注意的是,即使设置为 provided 的依赖,依然会被集成到 jar 包里,这一点与上文的 shade 插件不同。

分析 META-INF/MANIFEST.MF 文件内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Manifest-Version: 1.0
Implementation-Title: demo-algorithm
Implementation-Version: 0.0.1-SNAPSHOT
Archiver-Version: Plexus Archiver
Built-By: lijinlong9
Implementation-Vendor-Id: com.hikvision.algorithm
Spring-Boot-Version: 1.5.8.RELEASE
Implementation-Vendor: Pivotal Software, Inc.
Main-Class: org.springframework.boot.loader.JarLauncher
Start-Class: com.hikvision.algorithm.HelloWorld
Spring-Boot-Classes: BOOT-INF/classes/
Spring-Boot-Lib: BOOT-INF/lib/
Created-By: Apache Maven 3.3.9
Build-Jdk: 1.8.0_144
Implementation-URL: http://projects.spring.io/spring-boot/demo-algorithm/

注意,这里配置了 Main-Class,这表示我们可以以 java -jar 的方式执行这个 jar 包。Main-Class 对应的值为 org.springframework.boot.loader.JarLauncher,这表示具体的加载过程是由 spring-boot 定义的。

因为不符合 Java 标准 jar 包结构,所以无法通过 java -cp <package>.<MainClass> 的方式运行 jar 包里的某个类,因为按照标准的 jar 包结构是找不到这个类的。

总结

  1. Java 原生打包、Maven 原生打包、shade 插件打包的结果,其结构都是一致的。可以使用 java -cp 的方式执行,一般无法直接使用 java -jar 的方式执行。
  2. 使用 spring-boot 插件打包,其结构和上述的结构不同。不能使用 java -cp 的方式执行,可以使用 java -jar 的方式执行。
  3. shade 插件会忽略 provided 的依赖,不集成到 jar 包里;spring-boot 插件会将所有的依赖都集成到 jar 包里。
  4. 默认的情况下,shade 插件和 spring-boot 插件的打包结果,会代替 Maven 原生打包结果被安装到本地仓库(执行 mvn install 时),可以通过配置改变这一点。

转自:https://zhuanlan.zhihu.com/p/438672745

  1. 对查询进行优化,应尽量避免全表扫描,首先应考虑在 WHERE 及 ORDER BY 涉及的列上建立索引。
  2. 应尽量避免在 WHERE 子句中对字段进行 NULL 值判断,创建表时 NULL 是默认值,但大多数时候应该使用 NOT NULL,或者使用一个特殊的值,如 0,-1 作为默认值。
  3. 应尽量避免在 WHERE 子句中使用 != 或 <> 操作符。MySQL 只有对以下操作符才使用索引:<,<=,=,>,>=,BETWEEN,IN,以及某些时候的 LIKE。
  4. 应尽量避免在 WHERE 子句中使用 OR 来连接条件,否则将导致引擎放弃使用索引而进行全表扫描,可以使用 UNION 合并查询:select id from t where num=10 union all select id from t where num=20
  5. IN 和 NOT IN 也要慎用,否则会导致全表扫描。对于连续的数值,能用 BETWEEN 就不要用 IN:select id from t where num between 1 and 3。
  6. 下面的查询也将导致全表扫描:select id from t where name like‘%abc%’ 或者 select id from t where name like‘%abc’ 若要提高效率,可以考虑全文检索。而 select id from t where name like‘abc%’ 才用到索引。
  7. 如果在 WHERE 子句中使用参数,也会导致全表扫描。
  8. 应尽量避免在 WHERE 子句中对字段进行表达式操作,应尽量避免在 WHERE 子句中对字段进行函数操作。
  9. 很多时候用 EXISTS 代替 IN 是一个好的选择:select num from a where num in(select num from b)。用下面的语句替换:select num from a where exists(select 1 from b where num=a.num)
    • 具体说明查看附录
  10. 索引固然可以提高相应的 SELECT 的效率,但同时也降低了 INSERT 及 UPDATE 的效。因为 INSERT 或 UPDATE 时有可能会重建索引,所以怎样建索引需要慎重考虑,视具体情况而定。一个表的索引数最好不要超过 6 个,若太多则应考虑一些不常使用到的列上建的索引是否有必要。
  11. 应尽可能的避免更新 clustered 索引数据列, 因为 clustered 索引数据列的顺序就是表记录的物理存储顺序,一旦该列值改变将导致整个表记录的顺序的调整,会耗费相当大的资源。若应用系统需要频繁更新 clustered 索引数据列,那么需要考虑是否应将该索引建为 clustered 索引。
  12. 尽量使用数字型字段,若只含数值信息的字段尽量不要设计为字符型,这会降低查询和连接的性能,并会增加存储开销。
  13. 尽可能的使用 varchar, nvarchar 代替 char, nchar。因为首先变长字段存储空间小,可以节省存储空间,其次对于查询来说,在一个相对较小的字段内搜索效率显然要高些。
  14. 最好不要使用返回所有:select from t ,用具体的字段列表代替 “*”,不要返回用不到的任何字段。
  15. 尽量避免向客户端返回大数据量,若数据量过大,应该考虑相应需求是否合理。
  16. 使用表的别名(Alias):当在 SQL 语句中连接多个表时,请使用表的别名并把别名前缀于每个 Column 上。这样一来,就可以减少解析的时间并减少那些由 Column 歧义引起的语法错误。
  17. 使用“临时表”暂存中间结果:
    • 简化 SQL 语句的重要方法就是采用临时表暂存中间结果。但是临时表的好处远远不止这些,将临时结果暂存在临时表,后面的查询就在 tempdb 中了,这可以避免程序中多次扫描主表,也大大减少了程序执行中“共享锁”阻塞“更新锁”,减少了阻塞,提高了并发性能。
  18. 一些 SQL 查询语句应加上 nolock,读、写是会相互阻塞的,为了提高并发性能。对于一些查询,可以加上 nolock,这样读的时候可以允许写,但缺点是可能读到未提交的脏数据。
    • 使用 nolock 有3条原则:
      1. 查询的结果用于“插、删、改”的不能加 nolock;
      2. 查询的表属于频繁发生页分裂的,慎用 nolock ;
      3. 使用临时表一样可以保存“数据前影”,起到类似 Oracle 的 undo 表空间的功能,能采用临时表提高并发性能的,不要用 nolock。
  19. 常见的简化规则如下:
    • 不要有超过 5 个以上的表连接(JOIN),考虑使用临时表或表变量存放中间结果。少用子查询,视图嵌套不要过深,一般视图嵌套不要超过 2 个为宜。
  20. 将需要查询的结果预先计算好放在表中,查询的时候再Select。这在SQL7.0以前是最重要的手段,例如医院的住院费计算。
  21. 用 OR 的字句可以分解成多个查询,并且通过 UNION 连接多个查询。他们的速度只同是否使用索引有关,如果查询需要用到联合索引,用 UNION all 执行的效率更高。多个 OR 的字句没有用到索引,改写成 UNION 的形式再试图与索引匹配。一个关键的问题是否用到索引。
  22. 在 IN 后面值的列表中,将出现最频繁的值放在最前面,出现得最少的放在最后面,减少判断的次数。
  23. 尽量将数据的处理工作放在服务器上,减少网络的开销,如使用存储过程。
    • 存储过程是编译好、优化过、并且被组织到一个执行规划里、且存储在数据库中的 SQL 语句,是控制流语言的集合,速度当然快。反复执行的动态 SQL,可以使用临时存储过程,该过程(临时表)被放在 Tempdb 中。
  24. 当服务器的内存够多时,配制线程数量 = 最大连接数+5,这样能发挥最大的效率;否则使用配制线程数量< 最大连接数,启用 SQL SERVER 的线程池来解决,如果还是数量 = 最大连接数+5,严重的损害服务器的性能。
  25. 查询的关联同写的顺序:
    • select a.personMemberID, * from chineseresume a,personmember b where personMemberID = b.referenceid and a.personMemberID = 'JCNPRH39681' (A = B, B = '号码')
    • select a.personMemberID, * from chineseresume a,personmember b where a.personMemberID = b.referenceid and a.personMemberID = 'JCNPRH39681' and b.referenceid = 'JCNPRH39681' (A = B, B = '号码', A = '号码')
    • select a.personMemberID, * from chineseresume a,personmember b where b.referenceid = 'JCNPRH39681' and a.personMemberID = 'JCNPRH39681' (B = '号码', A = '号码')
  26. 尽量使用 EXISTS 代替 select count(1) 来判断是否存在记录。count 函数只有在统计表中所有行数时使用,而且 count(1) 比 count(*) 更有效率。
    • 具体说明查看附录
  27. 尽量使用 “>=”,不要使用 “>”。
  28. 索引的使用规范:
    • 索引的创建要与应用结合考虑,建议大的 OLTP 表不要超过 6 个索引;
    • 尽可能的使用索引字段作为查询条件,尤其是聚簇索引,必要时可以通过 index index_name 来强制指定索引;
    • 避免对大表查询时进行 table scan,必要时考虑新建索引;
    • 在使用索引字段作为条件时,如果该索引是联合索引,那么必须使用到该索引中的第一个字段作为条件时才能保证系统使用该索引,否则该索引将不会被使用;
    • 要注意索引的维护,周期性重建索引,重新编译存储过程。
  29. 下列 SQL 条件语句中的列都建有恰当的索引,但执行速度却非常慢:
    • SELECT * FROM record WHERE substrINg(card_no, 1, 4) = '5378' --13秒
    • SELECT * FROM record WHERE amount/30 < 1000 --11秒 SELECT * FROM record WHERE convert(char(10), date, 112) = '19991201' --10秒
    • 分析:
    • WHERE 子句中对列的任何操作结果都是在 SQL 运行时逐列计算得到的,因此它不得不进行表搜索,而没有使用该列上面的索引。
    • 如果这些结果在查询编译时就能得到,那么就可以被 SQL 优化器优化,使用索引,避免表搜索,因此将 SQL 重写成下面这样:
      • SELECT * FROM record WHERE card_no like '5378%' -- < 1秒
      • SELECT * FROM record WHERE amount < 1000*30 -- < 1秒
      • SELECT * FROM record WHERE date = '1999/12/01' -- < 1秒
  30. 当有一批处理的插入或更新时,用批量插入或批量更新,绝不会一条条记录的去更新。
  31. 在所有的存储过程中,能够用 SQL 语句的,我绝不会用循环去实现。
    • 例如:列出上个月的每一天,我会用 connect by 去递归查询一下,绝不会去用循环从上个月第一天到最后一天。
  32. 选择最有效率的表名顺序(只在基于规则的优化器中有效):
    • Oracle 的解析器按照从右到左的顺序处理 FROM 子句中的表名,FROM 子句中写在最后的表(基础表 driving table)将被最先处理,在 FROM 子句中包含多个表的情况下,你必须选择记录条数最少的表作为基础表。
    • 如果有 3 个以上的表连接查询,那就需要选择交叉表(intersection table)作为基础表,交叉表是指那个被其他表所引用的表。
  33. 提高 GROUP BY 语句的效率,可以通过将不需要的记录在 GROUP BY 之前过滤掉。下面两个查询返回相同结果,但第二个明显就快了许多。
    • 低效:
      • SELECT JOB, AVG(SAL) FROM EMP GROUP BY JOB HAVING JOB = 'PRESIDENT' OR JOB = 'MANAGER'
    • 高效:
      • SELECT JOB, AVG(SAL) FROM EMP WHERE JOB = 'PRESIDENT' OR JOB = 'MANAGER' GROUP BY JOB
  34. SQL 语句用大写,因为 Oracle 总是先解析 SQL 语句,把小写的字母转换成大写的再执行。
  35. 别名的使用,别名是大型数据库的应用技巧,就是表名、列名在查询中以一个字母为别名,查询速度要比建连接表快 1.5 倍。
  36. 避免死锁,在你的存储过程和触发器中访问同一个表时总是以相同的顺序;事务应经可能地缩短,在一个事务中应尽可能减少涉及到的数据量;永远不要在事务中等待用户输入。
  37. 避免使用临时表,除非却有需要,否则应尽量避免使用临时表,相反,可以使用表变量代替。大多数时候(99%),表变量驻扎在内存中,因此速度比临时表更快,临时表驻扎在 TempDb 数据库中,因此临时表上的操作需要跨数据库通信,速度自然慢。
  38. 最好不要使用触发器:
    • 触发一个触发器,执行一个触发器事件本身就是一个耗费资源的过程;
    • 如果能够使用约束实现的,尽量不要使用触发器;
    • 不要为不同的触发事件(Insert、Update 和 Delete)使用相同的触发器;
    • 不要在触发器中使用事务型代码。
  39. 索引创建规则:
    • 表的主键、外键必须有索引;
    • 数据量超过 300 的表应该有索引;
    • 经常与其他表进行连接的表,在连接字段上应该建立索引;
    • 经常出现在 WHERE 子句中的字段,特别是大表的字段,应该建立索引;
    • 索引应该建在选择性高的字段上;
    • 索引应该建在小字段上,对于大的文本字段甚至超长字段,不要建索引;
    • 复合索引的建立需要进行仔细分析,尽量考虑用单字段索引代替;
    • 正确选择复合索引中的主列字段,一般是选择性较好的字段;
    • 复合索引的几个字段是否经常同时以 AND 方式出现在 WHERE 子句中?单字段查询是否极少甚至没有?如果是,则可以建立复合索引;否则考虑单字段索引;
    • 如果复合索引中包含的字段经常单独出现在 WHERE 子句中,则分解为多个单字段索引;
    • 如果复合索引所包含的字段超过 3 个,那么仔细考虑其必要性,考虑减少复合的字段;
    • 如果既有单字段索引,又有这几个字段上的复合索引,一般可以删除复合索引;
    • 频繁进行数据操作的表,不要建立太多的索引;
    • 删除无用的索引,避免对执行计划造成负面影响;
    • 表上建立的每个索引都会增加存储开销,索引对于插入、删除、更新操作也会增加处理上的开销。另外,过多的复合索引,在有单字段索引的情况下,一般都是没有存在价值的;相反,还会降低数据增加删除时的性能,特别是对频繁更新的表来说,负面影响更大。
    • 尽量不要对数据库中某个含有大量重复的值的字段建立索引。
  40. MySQL 查询优化总结:
    • 使用慢查询日志去发现慢查询,使用执行计划去判断查询是否正常运行,总是去测试你的查询看看是否他们运行在最佳状态下。
    • 久而久之性能总会变化,避免在整个表上使用 count(*),它可能锁住整张表,使查询保持一致以便后续相似的查询可以使用查询缓存,在适当的情形下使用 GROUP BY 而不是 DISTINCT,在 WHERE、GROUP BY 和 ORDER BY 子句中使用有索引的列,保持索引简单,不在多个索引中包含同一个列。
    • 有时候 MySQL 会使用错误的索引,对于这种情况使用 USE INDEX,检查使用 SQL_MODE=STRICT 的问题,对于记录数小于5的索引字段,在 UNION 的时候使用LIMIT不是是用OR。
    • 为了避免在更新前 SELECT,使用 INSERT ON DUPLICATE KEY 或者 INSERT IGNORE;不要用 UPDATE 去实现,不要使用 MAX;使用索引字段和 ORDER BY子句 LIMIT M,N 实际上可以减缓查询在某些情况下,有节制地使用,在 WHERE 子句中使用 UNION 代替子查询,在重新启动的 MySQL,记得来温暖你的数据库,以确保数据在内存和查询速度快,考虑持久连接,而不是多个连接,以减少开销。
    • 基准查询,包括使用服务器上的负载,有时一个简单的查询可以影响其他查询,当负载增加在服务器上,使用 SHOW PROCESSLIST 查看慢的和有问题的查询,在开发环境中产生的镜像数据中测试的所有可疑的查询。
  41. MySQL 备份过程:
    • 从二级复制服务器上进行备份;
    • 在进行备份期间停止复制,以避免在数据依赖和外键约束上出现不一致;
    • 彻底停止 MySQL,从数据库文件进行备份;
    • 如果使用 MySQL dump 进行备份,请同时备份二进制日志文件 – 确保复制没有中断;
    • 不要信任 LVM 快照,这很可能产生数据不一致,将来会给你带来麻烦;
    • 为了更容易进行单表恢复,以表为单位导出数据——如果数据是与其他表隔离的。
    • 当使用 mysqldump 时请使用 –opt;
    • 在备份之前检查和优化表;
    • 为了更快的进行导入,在导入时临时禁用外键约束。;
    • 为了更快的进行导入,在导入时临时禁用唯一性检测;
    • 在每一次备份后计算数据库,表以及索引的尺寸,以便更够监控数据尺寸的增长;
    • 通过自动调度脚本监控复制实例的错误和延迟;
    • 定期执行备份。
  42. 查询缓冲并不自动处理空格,因此,在写 SQL 语句时,应尽量减少空格的使用,尤其是在 SQL 首和尾的空格(因为查询缓冲并不自动截取首尾空格)。
  43. member 用 mid 做标准进行分表方便查询么?一般的业务需求中基本上都是以 username 为查询依据,正常应当是 username 做 hash 取模来分表。而分表的话 MySQL 的 partition 功能就是干这个的,对代码是透明的;在代码层面去实现貌似是不合理的。
  44. 我们应该为数据库里的每张表都设置一个 ID 做为其主键,而且最好的是一个 INT 型的(推荐使用 UNSIGNED),并设置上自动增加的 AUTO_INCREMENT 标志。
  45. 在所有的存储过程和触发器的开始处设置 SET NOCOUNT ON,在结束时设置 SET NOCOUNT OFF。无需在执行存储过程和触发器的每个语句后向客户端发送 DONE_IN_PROC 消息。
  46. MySQL 查询可以启用高速查询缓存。这是提高数据库性能的有效MySQL优化方法之一。当同一个查询被执行多次时,从缓存中提取数据和直接从数据库中返回数据快很多。
  47. EXPLAIN SELECT 查询用来跟踪查看效果:
    • 使用 EXPLAIN 关键字可以让你知道 MySQL 是如何处理你的 SQL 语句的。这可以帮你分析你的查询语句或是表结构的性能瓶颈。EXPLAIN 的查询结果还会告诉你你的索引主键被如何利用的,你的数据表是如何被搜索和排序的。
  48. 当只要一行数据时使用 LIMIT 1 :
    • 当你查询表的有些时候,你已经知道结果只会有一条结果,但因为你可能需要去fetch游标,或是你也许会去检查返回的记录数。
    • 在这种情况下,加上 LIMIT 1 可以增加性能。这样一来,MySQL 数据库引擎会在找到一条数据后停止搜索,而不是继续往后查少下一条符合记录的数据。
  49. 选择表合适存储引擎:
    • myisam:应用时以读和插入操作为主,只有少量的更新和删除,并且对事务的完整性,并发性要求不是很高的。
    • InnoDB:事务处理,以及并发条件下要求数据的一致性。除了插入和查询外,包括很多的更新和删除。(InnoDB 有效地降低删除和更新导致的锁定)。
    • 对于支持事务的 InnoDB类 型的表来说,影响速度的主要原因是 AUTOCOMMIT 默认设置是打开的,而且程序没有显式调用 BEGIN 开始事务,导致每插入一条都自动提交,严重影响了速度。可以在执行 SQL 前调用 begin,多条 SQL 形成一个事物(即使 autocommit 打开也可以),将大大提高性能。
  50. 优化表的数据类型,选择合适的数据类型:
    • 原则:更小通常更好,简单就好,所有字段都得有默认值,尽量避免 NULL。
    • 例如:数据库表设计时候更小的占磁盘空间尽可能使用更小的整数类型。(mediumint 就比 int 更合适)
    • 比如时间字段:datetime 和 timestamp。datetime 占用8个字节,timestamp 占用4个字节,只用了一半。而 timestamp 表示的范围是 1970—2037 适合做更新时间。
    • MySQL可以很好的支持大数据量的存取,但是一般说来,数据库中的表越小,在它上面执行的查询也就会越快。
    • 因此,在创建表的时候,为了获得更好的性能,我们可以将表中字段的宽度设得尽可能小。
    • 例如:在定义邮政编码这个字段时,如果将其设置为 CHAR(255),显然给数据库增加了不必要的空间。甚至使用VARCHAR 这种类型也是多余的,因为 CHAR(6) 就可以很好的完成任务了。
    • 同样的,如果可以的话,我们应该使用 MEDIUMINT 而不是 BIGIN 来定义整型字段,应该尽量把字段设置为 NOT NULL,这样在将来执行查询的时候,数据库不用去比较 NULL 值。
    • 对于某些文本字段,例如“省份”或者“性别”,我们可以将它们定义为 ENUM 类型。因为在 MySQL 中,ENUM 类型被当作数值型数据来处理,而数值型数据被处理起来的速度要比文本类型快得多。这样,我们又可以提高数据库的性能。
  51. 字符串数据类型:char, varchar, text 选择区别。
  52. 任何对列的操作都将导致表扫描,它包括数据库函数、计算表达式等等,查询时要尽可能将操作移至等号右边。

转自:https://www.it610.com/article/1295770092814016512.htm

  • in 内的子查询的结果集会被存储到临时表,而 exists 主要是做了内外表的 loop 循环;
  • exists 与 in 均不会使索引失效,外表大内表小推荐 in,外表小内表大推荐 exists,大小相当两种均可用;
  • not in 会使索引失效,而 not exists 仍旧使索引生效,不管何种情况推荐使用 not exists。
  • 总之,区分 in 和 exists 主要是造成了驱动顺序的改变(这是性能变化的关键),如果是 exists,那么以外层表为驱动表,先被访问,如果是 IN,那么先执行子查询。所以 IN 适合于外表大而内表小的情况;EXISTS 适合于外表小而内表大的情况。

转自:https://blog.csdn.net/pyzfirst/article/details/108521334

MySQL官方文档是怎么说的:

InnoDB handles SELECT COUNT(*) and SELECT COUNT(1) operations in the same way. There is no performance difference.

对于 COUNT(1)COUNT(*),MySQL 的优化是完全一样的,根本不存在谁比谁快!

那既然 COUNT(*)COUNT(1) 一样,建议用哪个呢?

建议使用 COUNT(*)!因为这个是 SQL92 定义的标准统计行数的语法

最后,就是我们一直还没提到的 COUNT(字段),他的查询就比较简单粗暴了,就是进行全表扫描,然后判断指定字段的值是不是为 NULL,不为 NULL 则累加。

相比 COUNT(*)COUNT(字段) 多了一个步骤就是判断所查询的字段是否为 NULL,所以他的性能要比 COUNT(*) 慢。

转自:https://www.cnblogs.com/shuiyj/p/12640692.html

1

挤满新生代的最后一个对象

我们应当知道,新创建的对象一般会被分配在新生代中。常用的新生代的垃圾回收器是 ParNew 垃圾回收器,它按照 8:1:1 将新生代分成 Eden 区,以及两个 Survivor 区。

某一时刻,我们创建的对象将 Eden 区全部挤满,这个对象就是「挤满新生代的最后一个对象」。此时,Minor GC 就触发了。

正式 Minor GC 前的检查

在正式 Minor GC 前,JVM 会先检查新生代中对象,是比老年代中剩余空间大还是小。为什么要做这样的检查呢?原因很简单,假如 Minor GC 之后 Survivor 区放不下剩余对象,这些对象就要进入到老年代,所以要提前检查老年代是不是够用。这样就有两种情况:

  1. 老年代剩余空间大于新生代中的对象大小,那就直接 Minor GC,GC 完 survivor 不够放,老年代也绝对够放
  2. 老年代剩余空间小于新生代中的对象大小,这个时候就要查看是否启用了「老年代空间分配担保规则」,具体来说就是看 -XX:-HandlePromotionFailure 参数是否设置了(一般都会设置)

老年代空间分配担保规则是这样的。如果老年代中剩余空间大小,大于历次 Minor GC 之后剩余对象的大小,那就允许进行 Minor GC。因为从概率上来说,以前的放的下,这次的也应该放的下。那就有两种情况:

  1. 老年代中剩余空间大小,大于历次 Minor GC 之后剩余对象的大小,进行 Minor GC
  2. 老年代中剩余空间大小,小于历次 Minor GC 之后剩余对象的大小,进行 Full GC,把老年代空出来再检查

Minor GC 后的处境

前面说了,开启老年代空间分配担保规则只能说是大概率上来说,Minor GC 剩余后的对象够放到老年代,所以当然也会有万一,Minor GC 后会有这样三种情况:

  1. Minor GC 之后的对象足够放到 Survivor 区,皆大欢喜,GC 结束
  2. Minor GC 之后的对象不够放到 Survivor 区,接着进入到老年代,老年代能放下,那也可以,GC 结束
  3. Minor GC 之后的对象不够放到 Survivor 区,老年代也放不下,那就只能 Full GC

实在不行只能 OOM

前面都是成功 GC 的例子,还有 3 种情况,会导致 GC 失败,报 OOM:

  1. 紧接上一节 Full GC 之后,老年代任然放不下剩余对象,就只能 OOM
  2. 未开启老年代分配担保机制,且一次 Full GC 后,老年代任然放不下剩余对象,也只能 OOM
  3. 开启老年代分配担保机制,但是担保不通过,一次 Full GC 后,老年代任然放不下剩余对象,也是能 OOM

转自:https://www.modb.pro/db/26526

堆内存结构

我们以 Java 官方的 HotSpot JVM 为例,在描述 GC 过程前,先了解一下堆内存的结构。

2
JVM 将堆内存分为了三部分:新生代(Young Generation),老年代(Old Generation),永久代(Permanent Generation)。其中新生代又分为三部分:伊甸园区(Eden),和两个幸存区 S0 和 S1。

注:JDK1.8 之后,Java 官方的 HotSpot JVM 去掉了永久代,取而代之的是元数据区 Metaspace。Metaspace 使用的是本地内存,而不是堆内存,也就是说在默认情况下 Metaspace 的大小只与本地内存的大小有关。因此 JDK1.8 之后,就见不到 java.lang.OutOfMemoryError: PermGen space 这种由于永久代空间不足导致的内存溢出的问题了。

垃圾回收全过程

3
新创建的对象会先被分配到到 Eden 区。JVM 刚启动时,Eden 区对象数量较少,两个 Survivor 区 S0、S1 几乎是空的。

4
随着时间的推移,Eden 区的对象越来越多。当 Eden 区放不下时(占用空间达到容量阈值),新生代就会发生垃圾回收,我们称之为 Minor GC 或者 Young GC。

5
发生 GC 时,第一步会通过可达性分析算法找到可达对象。如上图,蓝色为可达对象,其他紫色为不可达对象。第二步,被标示的可达对象会被转移到 S0(此时 S0 是 From Survivor),此时存活对象年龄加 1,三个对象年龄都变为 1。第三步,清除 Eden 区所有对象。

6
GC 后各区域对象占用情况,如上图所示。

7
程序继续运行,Eden 区再次达到容量阈值时,会再次发生 GC。这时 S0(From Survivor)已经有了对象。还是同样的步骤,通过可达性分析算法找到可达对象,然后再将 Eden 和 S0 中的可达对象转移到 S1(To Survivor),各存活对象年龄加 1。最后将 Eden 和 S0 中的所有对象清除。

8
GC 后 S0 区域被清空。如上图所示。S0 和 S1 发生了互换,S1 变成了 From Survivor,S0 变成了 To Survivor。

注意,To Survivor 区永远都为空。这实际上是垃圾回收算法-复制算法在年轻代的实际应用。把年轻代分为 Eden、S0、S1 三个区域,每次垃圾回收时把可达对象复制到 S0 或 S1,然后再清除掉 Eden 和(S1 或 S0)中的所有对象。由于每次 GC 时,新生代的可达对象非常少(绝大部分对象要被回收掉),一般不会超过新生代总体空间的 10%,所以搜寻可达对象以及复制对象的成本都会非常低。而且这种复制的方式还能避免产生堆内存碎片,提高内存利用率。很多年轻代垃圾收集器都采用复制算法,如 ParNew。

9
在程序运行过程中,新生代 GC 会反复发生,长寿对象会在 S0 和 S1 之间反复交换,年龄也会越来越大,当对象达到年龄上限时,会被晋升到老年代。这个年龄上限默认是 15,可以通过参数 -XX:MaxTenuringThreshold 设置。如下图,有些年轻代对象年龄达到了上限 15,被转移到了老年代。

10
其他晋升方式。新生代对象晋升到老年代,除了根据年龄正常晋升外。为了提高 JVM 的性能,JVM 设计者还考虑了其他晋升方式。

11
大对象直接晋升。大对象会跨过年轻代直接分配到老年代。可以通过 -XX:PretenureSizeThreshold 参数设置对象大小。如果参数被设置成 5MB,超过 5MB 的大对象会直接分配到老年代。这样做的目的,是为了避免大对象在Eden区及两个 Survivor 区之间大量的内存复制,大对象的内存复制耗时比普通对象要高很多。

注意:PretenureSizeThreshold参数只对Serial和ParNew两种回收器有效。

12
动态对象年龄判定。如果在 Survivor 空间中相同年龄对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象会直接进入老年代,而不用等到 MaxTenuringThreshold 中设置的年龄上限。上图,年龄为1的对象超过了 Survivor 空间的一半,所以这几个对象会直接进入老年代。

13
实际上,上面对动态对象年龄判定的描述并不精确。上图的场景也会导致相关对象晋升到老年代。年龄为 1 的对象加上年龄为 2 的对象超过了半数,这时包括年龄为 2 的对象以及年龄更大的对象都会被晋升到老年代。所以上图中年龄为 2 和 3 的对象都会被晋升到老年代。

老年代垃圾回收。随着年轻代对象的不断晋升,老年代的对象变得越来越多,达到容量阈值后老年代也会发生垃圾回收,我们称之为 Major GC 或者 Full GC,Full GC 并不是全局 GC,它只发生在老年代。

虽然年轻代和老年代都会发生GC,但是每次GC的时间和成本却大不相同。由于老年代空间大小一般是年轻代的几倍,再加上老年代对象存活率很高,所以整个标记过程比较慢,GC 成本也非常高。我们经常说的JVM调优,主要是为了尽量减少老年代Full GC的时间和频次。

老年代垃圾回收器,很少使用复制算法,主要为了避免大量对象的内存复制带来的时间和空间上的开销,一般采用标记清除、标记整理算法,就地标记回收。例如,老年代垃圾收集器 CMS 就采用了标记清除算法。对于标记清除算法带来的内存碎片问题,CMS 提供了两个参数做碎片整理,-XX:+UseCMSCompactAtFullCollection和-XX:CMSFullGCsBeforeCompaction。

转自:https://www.jianshu.com/p/12544c0ad5c1

前言

本文主要介绍了三色标记法的基本思路、多标导致的浮动垃圾、漏标的处理方案(读写屏障)等。

垃圾回收的简单回顾

关于垃圾回收算法,基本就是那么几种:标记-清除、标记-复制、标记-整理。在此基础上可以增加分代(新生代/老年代),每代采取不同的回收算法,以提高整体的分配和回收效率。

无论使用哪种算法,标记总是必要的一步。这是理算当然的,你不先找到垃圾,怎么进行回收?

垃圾回收器的工作流程大体如下:

  1. 标记出哪些对象是存活的,哪些是垃圾(可回收);
  2. 进行回收(清除/复制/整理),如果有移动过对象(复制/整理),还需要更新引用。

本文着重来看下标记的部分。

三色标记法

基本算法

要找出存活对象,根据可达性分析,从GC Roots开始进行遍历访问,可达的则为存活对象:

1
最终结果:A/D/E/F/G 可达

我们把遍历对象图过程中遇到的对象,按“是否访问过”这个条件标记成以下三种颜色:

  • 白色:尚未访问过。
  • 黑色:本对象已访问过,而且本对象 引用到 的其他对象 也全部访问过了。
  • 灰色:本对象已访问过,但是本对象 引用到 的其他对象 尚未全部访问完。全部访问后,会转换为黑色。

2
三色标记遍历过程

假设现在有白、灰、黑三个集合(表示当前对象的颜色),其遍历访问过程为:

  1. 初始时,所有对象都在 【白色集合】中;
  2. 将GC Roots 直接引用到的对象 挪到 【灰色集合】中;
  3. 从灰色集合中获取对象:
    1. 将本对象 引用到的 其他对象 全部挪到 【灰色集合】中;
    2. 将本对象 挪到 【黑色集合】里面。
  4. 重复步骤3,直至【灰色集合】为空时结束。
  5. 结束后,仍在【白色集合】的对象即为GC Roots 不可达,可以进行回收。

注:如果标记结束后对象仍为白色,意味着已经“找不到”该对象在哪了,不可能会再被重新引用。

当Stop The World (以下简称 STW)时,对象间的引用是不会发生变化的,可以轻松完成标记。

而当需要支持并发标记时,即标记期间应用线程还在继续跑,对象间的引用可能发生变化,多标和漏标的情况就有可能发生

多标-浮动垃圾

假设已经遍历到E(变为灰色了),此时应用执行了 objD.fieldE = null

3
D > E 的引用断开

此刻之后,对象E/F/G是“应该”被回收的。然而因为E已经变为灰色了,其仍会被当作存活对象继续遍历下去。最终的结果是:这部分对象仍会被标记为存活,即本轮GC不会回收这部分内存

这部分本应该回收 但是 没有回收到的内存,被称之为“浮动垃圾”。浮动垃圾并不会影响应用程序的正确性,只是需要等到下一轮垃圾回收中才被清除。

另外,针对并发标记开始后的新对象,通常的做法是直接全部当成黑色,本轮不会进行清除。这部分对象期间可能会变为垃圾,这也算是浮动垃圾的一部分。

漏标-读写屏障

假设GC线程已经遍历到E(变为灰色了),此时应用线程先执行了:

1
2
3
var G = objE.fieldG; 
objE.fieldG = null; // 灰色E 断开引用 白色G
objD.fieldG = G; // 黑色D 引用 白色G

4
E > G 断开,D引用 G

此时切回GC线程继续跑,因为E已经没有对G的引用了,所以不会将G放到灰色集合;尽管因为D重新引用了G,但因为D已经是黑色了,不会再重新做遍历处理。

最终导致的结果是:G会一直停留在白色集合中,最后被当作垃圾进行清除。这直接影响到了应用程序的正确性,是不可接受的。

不难分析,漏标只有同时满足以下两个条件时才会发生:

  1. 条件一:灰色对象 断开了 白色对象的引用(直接或间接的引用);即灰色对象 原来成员变量的引用 发生了变化。
  2. 条件二:黑色对象 重新引用了 该白色对象;即黑色对象 成员变量增加了 新的引用。

从代码的角度看:

1
2
3
var G = objE.fieldG; // 1.读
objE.fieldG = null; // 2.写
objD.fieldG = G; // 3.写
  1. 读取 对象E的成员变量fieldG的引用值,即对象G;
  2. 对象E 往其成员变量fieldG,写入 null值。
  3. 对象D 往其成员变量fieldG,写入 对象G ;

我们只要在上面这三步中的任意一步中做一些“手脚”,将对象G记录起来,然后作为灰色对象再进行遍历即可。比如放到一个特定的集合,等初始的GC Roots遍历完(并发标记),该集合的对象 遍历即可(重新标记)。

重新标记通常是需要STW的,因为应用程序一直在跑的话,该集合可能会一直增加新的对象,导致永远都跑不完。当然,并发标记期间也可以将该集合中的大部分先跑了,从而缩短重新标记STW的时间,这个是优化问题了。

写屏障用于拦截第二和第三步;而读屏障则是拦截第一步。

它们的拦截的目的很简单:就是在读写前后,将对象G给记录下来。

写屏障(Store Barrier)

给某个对象的成员变量赋值时,其底层代码大概长这样:

1
2
3
4
5
6
7
/**
* @param field 某对象的成员变量,如 D.fieldG
* @param new_value 新值,如 null
*/
void oop_field_store(oop* field, oop new_value) {
*field = new_value; // 赋值操作
}

所谓的写屏障,其实就是指在赋值操作前后,加入一些处理(可以参考AOP的概念):

1
2
3
4
5
void oop_field_store(oop* field, oop new_value) {  
pre_write_barrier(field); // 写屏障-写前操作
*field = new_value;
post_write_barrier(field, value); // 写屏障-写后操作
}
写屏障 + SATB

当对象E的成员变量的引用发生变化时(objE.fieldG = null;),我们可以利用写屏障,将E原来成员变量的引用对象G记录下来:

1
2
3
4
void pre_write_barrier(oop* field) {
oop old_value = *field; // 获取旧值
remark_set.add(old_value); // 记录 原来的引用对象
}

【当原来成员变量的引用发生变化之前,记录下原来的引用对象】

这种做法的思路是:尝试保留开始时的对象图,即原始快照(Snapshot At The Beginning,SATB),当某个时刻 的GC Roots确定后,当时的对象图就已经确定了。

比如 当时 D是引用着G的,那后续的标记也应该是按照这个时刻的对象图走(D引用着G)。如果期间发生变化,则可以记录起来,保证标记依然按照原本的视图来。

值得一提的是,扫描所有GC Roots 这个操作(即初始标记)通常是需要STW的,否则有可能永远都扫不完,因为并发期间可能增加新的GC Roots。

SATB破坏了条件一:【灰色对象 断开了 白色对象的引用】,从而保证了不会漏标。

一点小优化:如果不是处于垃圾回收的并发标记阶段,或者已经被标记过了,其实是没必要再记录了,所以可以加个简单的判断:

1
2
3
4
5
6
7
void pre_write_barrier(oop* field) {
// 处于GC并发标记阶段 且 该对象没有被标记(访问)过
if($gc_phase == GC_CONCURRENT_MARK && !isMarkd(field)) {
oop old_value = *field; // 获取旧值
remark_set.add(old_value); // 记录 原来的引用对象
}
}
写屏障 + 增量更新

当对象D的成员变量的引用发生变化时(objD.fieldG = G;),我们可以利用写屏障,将D新的成员变量引用对象G记录下来:

1
2
3
4
5
void post_write_barrier(oop* field, oop new_value) {  
if($gc_phase == GC_CONCURRENT_MARK && !isMarkd(field)) {
remark_set.add(new_value); // 记录新引用的对象
}
}

【当有新引用插入进来时,记录下新的引用对象】

这种做法的思路是:不要求保留原始快照,而是针对新增的引用,将其记录下来等待遍历,即增量更新(Incremental Update)。

增量更新破坏了条件二:【黑色对象 重新引用了 该白色对象】,从而保证了不会漏标。

读屏障(Load Barrier)

1
2
3
4
oop oop_field_load(oop* field) {
pre_load_barrier(field); // 读屏障-读取前操作
return *field;
}

读屏障是直接针对第一步:var G = objE.fieldG;,当读取成员变量时,一律记录下来:

1
2
3
4
5
6
void pre_load_barrier(oop* field, oop old_value) {  
if($gc_phase == GC_CONCURRENT_MARK && !isMarkd(field)) {
oop old_value = *field;
remark_set.add(old_value); // 记录读取到的对象
}
}

这种做法是保守的,但也是安全的。因为条件二中【黑色对象 重新引用了 该白色对象】,重新引用的前提是:得获取到该白色对象,此时已经读屏障就发挥作用了。

三色标记法与现代垃圾回收器

现代追踪式(可达性分析)的垃圾回收器几乎都借鉴了三色标记的算法思想,尽管实现的方式不尽相同:比如白色/黑色集合一般都不会出现(但是有其他体现颜色的地方)、灰色集合可以通过栈/队列/缓存日志等方式进行实现、遍历方式可以是广度/深度遍历等等。

对于读写屏障,以Java HotSpot VM为例,其并发标记时对漏标的处理方案如下:

  • CMS:写屏障 + 增量更新
  • G1:写屏障 + SATB
  • ZGC:读屏障

工程实现中,读写屏障还有其他功能,比如写屏障可以用于记录跨代/区引用的变化,读屏障可以用于支持移动对象的并发执行等。功能之外,还有性能的考虑,所以对于选择哪种,每款垃圾回收器都有自己的想法。

值得注意的是,CMS中使用的增量更新,在重新标记阶段,除了需要遍历 写屏障的记录,还需要重新扫描遍历GC Roots(当然标记过的无需再遍历了),这是由于CMS对于astore_x等指令不添加写屏障的原因,具体可参考这里

转自:https://mp.weixin.qq.com/s/s8xjm1ZCKIoTGT3DCVA4aw

1

为什么需要分布式锁?

在开始讲分布式锁之前,有必要简单介绍一下,为什么需要分布式锁?

与分布式锁相对应的是「单机锁」,我们在写多线程程序时,避免同时操作一个共享变量产生数据问题,通常会使用一把锁来「互斥」,以保证共享变量的正确性,其使用范围是在「同一个进程」中。

如果换做是多个进程,需要同时操作一个共享资源,如何互斥呢?

例如,现在的业务应用通常都是微服务架构,这也意味着一个应用会部署多个进程,那这多个进程如果需要修改 MySQL 中的同一行记录时,为了避免操作乱序导致数据错误,此时,我们就需要引入「分布式锁」来解决这个问题了。

2

想要实现分布式锁,必须借助一个外部系统,所有进程都去这个系统上申请「加锁」。

而这个外部系统,必须要实现「互斥」的能力,即两个请求同时进来,只会给一个进程返回成功,另一个返回失败(或等待)。

这个外部系统,可以是 MySQL,也可以是 Redis 或 Zookeeper。但为了追求更好的性能,我们通常会选择使用 Redis 或 Zookeeper 来做。

下面我就以 Redis 为主线,由浅入深,带你深度剖析一下,分布式锁的各种「安全性」问题,帮你彻底理解分布式锁。

分布式锁怎么实现?

我们从最简单的开始讲起。

想要实现分布式锁,必须要求 Redis 有「互斥」的能力,我们可以使用 SETNX 命令,这个命令表示SET if Not eXists,即如果 key 不存在,才会设置它的值,否则什么也不做。

两个客户端进程可以执行这个命令,达到互斥,就可以实现一个分布式锁。

客户端 1 申请加锁,加锁成功:

1
2
127.0.0.1:6379> SETNX lock 1
(integer) 1 // 客户端1,加锁成功

客户端 2 申请加锁,因为它后到达,加锁失败:

1
2
127.0.0.1:6379> SETNX lock 1
(integer) 0 // 客户端2,加锁失败

此时,加锁成功的客户端,就可以去操作「共享资源」,例如,修改 MySQL 的某一行数据,或者调用一个 API 请求。

操作完成后,还要及时释放锁,给后来者让出操作共享资源的机会。如何释放锁呢?

也很简单,直接使用 DEL 命令删除这个 key 即可:

1
2
127.0.0.1:6379> DEL lock // 释放锁
(integer) 1

这个逻辑非常简单,整体的路程就是这样:
3
但是,它存在一个很大的问题,当客户端 1 拿到锁后,如果发生下面的场景,就会造成「死锁」:

  1. 程序处理业务逻辑异常,没及时释放锁
  2. 进程挂了,没机会释放锁

这时,这个客户端就会一直占用这个锁,而其它客户端就「永远」拿不到这把锁了。

怎么解决这个问题呢?

如何避免死锁?

我们很容易想到的方案是,在申请锁时,给这把锁设置一个「租期」。

在 Redis 中实现时,就是给这个 key 设置一个「过期时间」。这里我们假设,操作共享资源的时间不会超过 10s,那么在加锁时,给这个 key 设置 10s 过期即可:

1
2
3
4
127.0.0.1:6379> SETNX lock 1    // 加锁
(integer) 1
127.0.0.1:6379> EXPIRE lock 10 // 10s后自动过期
(integer) 1

这样一来,无论客户端是否异常,这个锁都可以在 10s 后被「自动释放」,其它客户端依旧可以拿到锁。

但这样真的没问题吗?

还是有问题。

现在的操作,加锁、设置过期是 2 条命令,有没有可能只执行了第一条,第二条却「来不及」执行的情况发生呢?例如:

  1. SETNX 执行成功,执行 EXPIRE 时由于网络问题,执行失败
  2. SETNX 执行成功,Redis 异常宕机,EXPIRE 没有机会执行
  3. SETNX 执行成功,客户端异常崩溃,EXPIRE 也没有机会执行
    总之,这两条命令不能保证是原子操作(一起成功),就有潜在的风险导致过期时间设置失败,依旧发生「死锁」问题。

怎么办?

在 Redis 2.6.12 版本之前,我们需要想尽办法,保证 SETNX 和 EXPIRE 原子性执行,还要考虑各种异常情况如何处理。

但在 Redis 2.6.12 之后,Redis 扩展了 SET 命令的参数,用这一条命令就可以了:

1
2
3
// 一条命令保证原子性执行
127.0.0.1:6379> SET lock 1 EX 10 NX
OK

这样就解决了死锁问题,也比较简单。

我们再来看分析下,它还有什么问题?

试想这样一种场景:

  1. 客户端 1 加锁成功,开始操作共享资源
  2. 客户端 1 操作共享资源的时间,「超过」了锁的过期时间,锁被「自动释放」
  3. 客户端 2 加锁成功,开始操作共享资源
  4. 客户端 1 操作共享资源完成,释放锁(但释放的是客户端 2 的锁)

看到了么,这里存在两个严重的问题:

  1. 锁过期:客户端 1 操作共享资源耗时太久,导致锁被自动释放,之后被客户端 2 持有
  2. 释放别人的锁:客户端 1 操作共享资源完成后,却又释放了客户端 2 的锁

导致这两个问题的原因是什么?我们一个个来看。

第一个问题,可能是我们评估操作共享资源的时间不准确导致的。

例如,操作共享资源的时间「最慢」可能需要 15s,而我们却只设置了 10s 过期,那这就存在锁提前过期的风险。

过期时间太短,那增大冗余时间,例如设置过期时间为 20s,这样总可以了吧?

这样确实可以「缓解」这个问题,降低出问题的概率,但依旧无法「彻底解决」问题。

为什么?

原因在于,客户端在拿到锁之后,在操作共享资源时,遇到的场景有可能是很复杂的,例如,程序内部发生异常、网络请求超时等等。

既然是「预估」时间,也只能是大致计算,除非你能预料并覆盖到所有导致耗时变长的场景,但这其实很难。

有什么更好的解决方案吗?

别急,关于这个问题,我会在后面详细来讲对应的解决方案。

我们继续来看第二个问题。

第二个问题在于,一个客户端释放了其它客户端持有的锁。

想一下,导致这个问题的关键点在哪?

重点在于,每个客户端在释放锁时,都是「无脑」操作,并没有检查这把锁是否还「归自己持有」,所以就会发生释放别人锁的风险,这样的解锁流程,很不「严谨」!

如何解决这个问题呢?

锁被别人释放怎么办?

解决办法是:客户端在加锁时,设置一个只有自己知道的「唯一标识」进去。

例如,可以是自己的线程 ID,也可以是一个 UUID(随机且唯一),这里我们以 UUID 举例:

1
2
3
// 锁的VALUE设置为UUID
127.0.0.1:6379> SET lock $uuid EX 20 NX
OK

这里假设 20s 操作共享时间完全足够,先不考虑锁自动过期的问题。

之后,在释放锁时,要先判断这把锁是否还归自己持有,伪代码可以这么写:

1
2
3
// 锁是自己的,才释放
if redis.get("lock") == $uuid:
redis.del("lock")

这里释放锁使用的是 GET + DEL 两条命令,这时,又会遇到我们前面讲的原子性问题了。

  1. 客户端 1 执行 GET,判断锁是自己的
  2. 客户端 2 执行了 SET 命令,强制获取到锁(虽然发生概率比较低,但我们需要严谨地考虑锁的安全性模型)
  3. 客户端 1 执行 DEL,却释放了客户端 2 的锁

4由此可见,这两个命令还是必须要原子执行才行。

怎样原子执行呢?Lua 脚本。

我们可以把这个逻辑,写成 Lua 脚本,让 Redis 来执行。

因为 Redis 处理每一个请求是「单线程」执行的,在执行一个 Lua 脚本时,其它请求必须等待,直到这个 Lua 脚本处理完成,这样一来,GET + DEL 之间就不会插入其它命令了。

4
安全释放锁的 Lua 脚本如下:

1
2
3
4
5
6
7
// 判断锁是自己的,才释放
if redis.call("GET",KEYS[1]) == ARGV[1]
then
return redis.call("DEL",KEYS[1])
else
return 0
end

好了,这样一路优化,整个的加锁、解锁的流程就更「严谨」了。

这里我们先小结一下,基于 Redis 实现的分布式锁,一个严谨的的流程如下:

  1. 加锁:SET lock_key $unique_id EX $expire_time NX
  2. 操作共享资源
  3. 释放锁:Lua 脚本,先 GET 判断锁是否归属自己,再 DEL 释放锁

5
好,有了这个完整的锁模型,让我们重新回到前面提到的第一个问题。

锁过期时间不好评估怎么办?

锁过期时间不好评估怎么办?

前面我们提到,锁的过期时间如果评估不好,这个锁就会有「提前」过期的风险。

当时给的妥协方案是,尽量「冗余」过期时间,降低锁提前过期的概率。

这个方案其实也不能完美解决问题,那怎么办呢?

是否可以设计这样的方案:加锁时,先设置一个过期时间,然后我们开启一个「守护线程」,定时去检测这个锁的失效时间,如果锁快要过期了,操作共享资源还未完成,那么就自动对锁进行「续期」,重新设置过期时间。

这确实一种比较好的方案。

如果你是 Java 技术栈,幸运的是,已经有一个库把这些工作都封装好了:Redisson

Redisson 是一个 Java 语言实现的 Redis SDK 客户端,在使用分布式锁时,它就采用了「自动续期」的方案来避免锁过期,这个守护线程我们一般也把它叫做「看门狗」线程。

6
除此之外,这个 SDK 还封装了很多易用的功能:

  • 可重入锁
  • 乐观锁
  • 公平锁
  • 读写锁
  • Redlock(红锁,下面会详细讲)

这个 SDK 提供的 API 非常友好,它可以像操作本地锁的方式,操作分布式锁。如果你是 Java 技术栈,可以直接把它用起来。

这里不重点介绍 Redisson 的使用,大家可以看官方 Github 学习如何使用,比较简单。

到这里我们再小结一下,基于 Redis 的实现分布式锁,前面遇到的问题,以及对应的解决方案:

  • 死锁:设置过期时间
  • 过期时间评估不好,锁提前过期:守护线程,自动续期
  • 锁被别人释放:锁写入唯一标识,释放锁先检查标识,再释放

还有哪些问题场景,会危害 Redis 锁的安全性呢?

之前分析的场景都是,锁在「单个」Redis 实例中可能产生的问题,并没有涉及到 Redis 的部署架构细节。

而我们在使用 Redis 时,一般会采用主从集群 + 哨兵的模式部署,这样做的好处在于,当主库异常宕机时,哨兵可以实现「故障自动切换」,把从库提升为主库,继续提供服务,以此保证可用性。

那当「主从发生切换」时,这个分布锁会依旧安全吗?

试想这样的场景:

  1. 客户端 1 在主库上执行 SET 命令,加锁成功
  2. 此时,主库异常宕机,SET 命令还未同步到从库上(主从复制是异步的)
  3. 从库被哨兵提升为新主库,这个锁在新的主库上,丢失了!

7
可见,当引入 Redis 副本后,分布锁还是可能会受到影响。

怎么解决这个问题?

为此,Redis 的作者提出一种解决方案,就是我们经常听到的 Redlock(红锁)

它真的可以解决上面这个问题吗?

Redlock 真的安全吗?

好,终于到了这篇文章的重头戏。啊?上面讲的那么多问题,难道只是基础?

是的,那些只是开胃菜,真正的硬菜,从这里刚刚开始。

如果上面讲的内容,你还没有理解,我建议你重新阅读一遍,先理清整个加锁、解锁的基本流程。

如果你已经对 Redlock 有所了解,这里可以跟着我再复习一遍,如果你不了解 Redlock,没关系,我会带你重新认识它。

值得提醒你的是,后面我不仅仅是讲 Redlock 的原理,还会引出有关「分布式系统」中的很多问题,你最好跟紧我的思路,在脑中一起分析问题的答案

现在我们来看,Redis 作者提出的 Redlock 方案,是如何解决主从切换后,锁失效问题的。

Redlock 的方案基于 2 个前提:

  1. 不再需要部署从库哨兵实例,只部署主库
  2. 但主库要部署多个,官方推荐至少 5 个实例

也就是说,想用使用 Redlock,你至少要部署 5 个 Redis 实例,而且都是主库,它们之间没有任何关系,都是一个个孤立的实例。

注意:不是部署 Redis Cluster,就是部署 5 个简单的 Redis 实例。

8
Redlock 具体如何使用呢?

整体的流程是这样的,一共分为 5 步:

  1. 客户端先获取「当前时间戳T1」
  2. 客户端依次向这 5 个 Redis 实例发起加锁请求(用前面讲到的 SET 命令),且每个请求会设置超时时间(毫秒级,要远小于锁的有效时间),如果某一个实例加锁失败(包括网络超时、锁被其它人持有等各种异常情况),就立即向下一个 Redis 实例申请加锁
  3. 如果客户端从 >=3 个(大多数)以上 Redis 实例加锁成功,则再次获取「当前时间戳T2」,如果 T2 - T1 < 锁的过期时间,此时,认为客户端加锁成功,否则认为加锁失败
  4. 加锁成功,去操作共享资源(例如修改 MySQL 某一行,或发起一个 API 请求)
  5. 加锁失败,向「全部节点」发起释放锁请求(前面讲到的 Lua 脚本释放锁)

我简单帮你总结一下,有 4 个重点:

  1. 客户端在多个 Redis 实例上申请加锁
  2. 必须保证大多数节点加锁成功
  3. 大多数节点加锁的总耗时,要小于锁设置的过期时间
  4. 释放锁,要向全部节点发起释放锁请求

第一次看可能不太容易理解,建议你把上面的文字多看几遍,加深记忆。

然后,记住这 5 步,非常重要,下面会根据这个流程,剖析各种可能导致锁失效的问题假设。

好,明白了 Redlock 的流程,我们来看 Redlock 为什么要这么做。

1) 为什么要在多个实例上加锁?

本质上是为了「容错」,部分实例异常宕机,剩余的实例加锁成功,整个锁服务依旧可用。

2) 为什么大多数加锁成功,才算成功?

多个 Redis 实例一起来用,其实就组成了一个「分布式系统」。

在分布式系统中,总会出现「异常节点」,所以,在谈论分布式系统问题时,需要考虑异常节点达到多少个,也依旧不会影响整个系统的「正确性」。

这是一个分布式系统「容错」问题,这个问题的结论是:如果只存在「故障」节点,只要大多数节点正常,那么整个系统依旧是可以提供正确服务的。

这个问题的模型,就是我们经常听到的「拜占庭将军」问题,感兴趣可以去看算法的推演过程。

3) 为什么步骤 3 加锁成功后,还要计算加锁的累计耗时?

因为操作的是多个节点,所以耗时肯定会比操作单个实例耗时更久,而且,因为是网络请求,网络情况是复杂的,有可能存在延迟、丢包、超时等情况发生,网络请求越多,异常发生的概率就越大。

所以,即使大多数节点加锁成功,但如果加锁的累计耗时已经「超过」了锁的过期时间,那此时有些实例上的锁可能已经失效了,这个锁就没有意义了。

4) 为什么释放锁,要操作所有节点?

在某一个 Redis 节点加锁时,可能因为「网络原因」导致加锁失败。

例如,客户端在一个 Redis 实例上加锁成功,但在读取响应结果时,网络问题导致读取失败,那这把锁其实已经在 Redis 上加锁成功了。

所以,释放锁时,不管之前有没有加锁成功,需要释放「所有节点」的锁,以保证清理节点上「残留」的锁。

好了,明白了 Redlock 的流程和相关问题,看似 Redlock 确实解决了 Redis 节点异常宕机锁失效的问题,保证了锁的「安全性」。

但事实真的如此吗?

Redlock 的争论谁对谁错?

Redis 作者把这个方案一经提出,就马上受到业界著名的分布式系统专家的质疑!

这个专家叫 Martin,是英国剑桥大学的一名分布式系统研究员。在此之前他曾是软件工程师和企业家,从事大规模数据基础设施相关的工作。它还经常在大会做演讲,写博客,写书,也是开源贡献者。

9

他马上写了篇文章,质疑这个 Redlock 的算法模型是有问题的,并对分布式锁的设计,提出了自己的看法。

之后,Redis 作者 Antirez 面对质疑,不甘示弱,也写了一篇文章,反驳了对方的观点,并详细剖析了 Redlock 算法模型的更多设计细节。

而且,关于这个问题的争论,在当时互联网上也引起了非常激烈的讨论。

二人思路清晰,论据充分,这是一场高手过招,也是分布式系统领域非常好的一次思想的碰撞!双方都是分布式系统领域的专家,却对同一个问题提出很多相反的论断,究竟是怎么回事?

下面我会从他们的争论文章中,提取重要的观点,整理呈现给你。

提醒:后面的信息量极大,可能不宜理解,最好放慢速度阅读。

分布式专家 Martin 对于 Relock 的质疑

在他的文章中,主要阐述了 4 个论点:

1) 分布式锁的目的是什么?

Martin 表示,你必须先清楚你在使用分布式锁的目的是什么?

他认为有两个目的。

第一,效率。

使用分布式锁的互斥能力,是避免不必要地做同样的两次工作(例如一些昂贵的计算任务)。如果锁失效,并不会带来「恶性」的后果,例如发了 2 次邮件等,无伤大雅。

第二,正确性。

使用锁用来防止并发进程互相干扰。如果锁失效,会造成多个进程同时操作同一条数据,产生的后果是数据严重错误、永久性不一致、数据丢失等恶性问题,就像给患者服用了重复剂量的药物,后果很严重。

他认为,如果你是为了前者——效率,那么使用单机版 Redis 就可以了,即使偶尔发生锁失效(宕机、主从切换),都不会产生严重的后果。而使用 Redlock 太重了,没必要。

而如果是为了正确性,Martin 认为 Redlock 根本达不到安全性的要求,也依旧存在锁失效的问题!

2) 锁在分布式系统中会遇到的问题

Martin 表示,一个分布式系统,更像一个复杂的「野兽」,存在着你想不到的各种异常情况。

这些异常场景主要包括三大块,这也是分布式系统会遇到的三座大山:NPC。

  • N:Network Delay,网络延迟
  • P:Process Pause,进程暂停(GC)
  • C:Clock Drift,时钟漂移

Martin 用一个进程暂停(GC)的例子,指出了 Redlock 安全性问题:

  1. 客户端 1 请求锁定节点 A、B、C、D、E
  2. 客户端 1 的拿到锁后,进入 GC(时间比较久)
  3. 所有 Redis 节点上的锁都过期了
  4. 客户端 2 获取到了 A、B、C、D、E 上的锁
  5. 客户端 1 GC 结束,认为成功获取锁
  6. 客户端 2 也认为获取到了锁,发生「冲突」

10
Martin 认为,GC 可能发生在程序的任意时刻,而且执行时间是不可控的。

注:当然,即使是使用没有 GC 的编程语言,在发生网络延迟、时钟漂移时,也都有可能导致 Redlock 出现问题,这里 Martin 只是拿 GC 举例。

3) 假设时钟正确的是不合理的

又或者,当多个 Redis 节点「时钟」发生问题时,也会导致 Redlock 锁失效。

  1. 客户端 1 获取节点 A、B、C 上的锁,但由于网络问题,无法访问 D 和 E
  2. 节点 C 上的时钟「向前跳跃」,导致锁到期
  3. 客户端 2 获取节点 C、D、E 上的锁,由于网络问题,无法访问 A 和 B
  4. 客户端 1 和 2 现在都相信它们持有了锁(冲突)

Martin 觉得,Redlock 必须「强依赖」多个节点的时钟是保持同步的,一旦有节点时钟发生错误,那这个算法模型就失效了。

即使 C 不是时钟跳跃,而是「崩溃后立即重启」,也会发生类似的问题。

Martin 继续阐述,机器的时钟发生错误,是很有可能发生的:

  1. 系统管理员「手动修改」了机器时钟
  2. 机器时钟在同步 NTP 时间时,发生了大的「跳跃」

总之,Martin 认为,Redlock 的算法是建立在「同步模型」基础上的,有大量资料研究表明,同步模型的假设,在分布式系统中是有问题的。

在混乱的分布式系统的中,你不能假设系统时钟就是对的,所以,你必须非常小心你的假设。

4) 提出 fecing token 的方案,保证正确性

相对应的,Martin 提出一种被叫作 fecing token 的方案,保证分布式锁的正确性。

这个模型流程如下:

  1. 客户端在获取锁时,锁服务可以提供一个「递增」的 token
  2. 客户端拿着这个 token 去操作共享资源
  3. 共享资源可以根据 token 拒绝「后来者」的请求

11
这样一来,无论 NPC 哪种异常情况发生,都可以保证分布式锁的安全性,因为它是建立在「异步模型」上的。

而 Redlock 无法提供类似 fecing token 的方案,所以它无法保证安全性。

他还表示,一个好的分布式锁,无论 NPC 怎么发生,可以不在规定时间内给出结果,但并不会给出一个错误的结果。也就是只会影响到锁的「性能」(或称之为活性),而不会影响它的「正确性」

Martin 的结论:

  1. Redlock 不伦不类:它对于效率来讲,Redlock 比较重,没必要这么做,而对于正确性来说,Redlock 是不够安全的。
  2. 时钟假设不合理:该算法对系统时钟做出了危险的假设(假设多个节点机器时钟都是一致的),如果不满足这些假设,锁就会失效。
  3. 无法保证正确性:Redlock 不能提供类似 fencing token 的方案,所以解决不了正确性的问题。为了正确性,请使用有「共识系统」的软件,例如 Zookeeper。

好了,以上就是 Martin 反对使用 Redlock 的观点,看起来有理有据。

下面我们来看 Redis 作者 Antirez 是如何反驳的。

Redis 作者 Antirez 的反驳

在 Redis 作者的文章中,重点有 3 个:

1) 解释时钟问题

首先,Redis 作者一眼就看穿了对方提出的最为核心的问题:时钟问题。

Redis 作者表示,Redlock 并不需要完全一致的时钟,只需要大体一致就可以了,允许有「误差」。

例如要计时 5s,但实际可能记了 4.5s,之后又记了 5.5s,有一定误差,但只要不超过「误差范围」锁失效时间即可,这种对于时钟的精度要求并不是很高,而且这也符合现实环境。

对于对方提到的「时钟修改」问题,Redis 作者反驳到:

  1. 手动修改时钟:不要这么做就好了,否则你直接修改 Raft 日志,那 Raft 也会无法工作…
  2. 时钟跳跃:通过「恰当的运维」,保证机器时钟不会大幅度跳跃(每次通过微小的调整来完成),实际上这是可以做到的

为什么 Redis 作者优先解释时钟问题?因为在后面的反驳过程中,需要依赖这个基础做进一步解释。

2) 解释网络延迟、GC 问题

之后,Redis 作者对于对方提出的,网络延迟、进程 GC 可能导致 Redlock 失效的问题,也做了反驳:

我们重新回顾一下,Martin 提出的问题假设:

  1. 客户端 1 请求锁定节点 A、B、C、D、E
  2. 客户端 1 的拿到锁后,进入 GC
  3. 所有 Redis 节点上的锁都过期了
  4. 客户端 2 获取节点 A、B、C、D、E 上的锁
  5. 客户端 1 GC 结束,认为成功获取锁
  6. 客户端 2 也认为获取到锁,发生「冲突」

10
Redis 作者反驳到,这个假设其实是有问题的,Redlock 是可以保证锁安全的。

这是怎么回事呢?

还记得前面介绍 Redlock 流程的那 5 步吗?这里我再拿过来让你复习一下。

  1. 客户端先获取「当前时间戳T1」
  2. 客户端依次向这 5 个 Redis 实例发起加锁请求(用前面讲到的 SET 命令),且每个请求会设置超时时间(毫秒级,要远小于锁的有效时间),如果某一个实例加锁失败(包括网络超时、锁被其它人持有等各种异常情况),就立即向下一个 Redis 实例申请加锁
  3. 如果客户端从 3 个(大多数)以上 Redis 实例加锁成功,则再次获取「当前时间戳T2」,如果 T2 - T1 < 锁的过期时间,此时,认为客户端加锁成功,否则认为加锁失败
  4. 加锁成功,去操作共享资源(例如修改 MySQL 某一行,或发起一个 API 请求)
  5. 加锁失败,向「全部节点」发起释放锁请求(前面讲到的 Lua 脚本释放锁)

注意,重点是 1-3,在步骤 3,加锁成功后为什么要重新获取「当前时间戳T2」?还用 T2 - T1 的时间,与锁的过期时间做比较?

Redis 作者强调:如果在 1-3 发生了网络延迟、进程 GC 等耗时长的异常情况,那在第 3 步 T2 - T1,是可以检测出来的,如果超出了锁设置的过期时间,那这时就认为加锁会失败,之后释放所有节点的锁就好了!

Redis 作者继续论述,如果对方认为,发生网络延迟、进程 GC 是在步骤 3 之后,也就是客户端确认拿到了锁,去操作共享资源的途中发生了问题,导致锁失效,那这不止是 Redlock 的问题,任何其它锁服务例如 Zookeeper,都有类似的问题,这不在讨论范畴内

这里我举个例子解释一下这个问题:

  1. 客户端通过 Redlock 成功获取到锁(通过了大多数节点加锁成功、加锁耗时检查逻辑)
  2. 客户端开始操作共享资源,此时发生网络延迟、进程 GC 等耗时很长的情况
  3. 此时,锁过期自动释放
  4. 客户端开始操作 MySQL(此时的锁可能会被别人拿到,锁失效)

Redis 作者这里的结论就是:

  • 客户端在拿到锁之前,无论经历什么耗时长问题,Redlock 都能够在第 3 步检测出来
  • 客户端在拿到锁之后,发生 NPC,那 Redlock、Zookeeper 都无能为力

所以,Redis 作者认为 Redlock 在保证时钟正确的基础上,是可以保证正确性的。

3) 质疑 fencing token 机制

Redis 作者对于对方提出的 fecing token 机制,也提出了质疑,主要分为 2 个问题,这里最不宜理解,请跟紧我的思路。

第一,这个方案必须要求要操作的「共享资源服务器」有拒绝「旧 token」的能力。

例如,要操作 MySQL,从锁服务拿到一个递增数字的 token,然后客户端要带着这个 token 去改 MySQL 的某一行,这就需要利用 MySQL 的「事物隔离性」来做。

1
2
3
// 两个客户端必须利用事物和隔离性达到目的
// 注意 token 的判断条件
UPDATE table T SET val = $new_val WHERE id = $id AND current_token < $token

但如果操作的不是 MySQL 呢?例如向磁盘上写一个文件,或发起一个 HTTP 请求,那这个方案就无能为力了,这对要操作的资源服务器,提出了更高的要求。

也就是说,大部分要操作的资源服务器,都是没有这种互斥能力的。

再者,既然资源服务器都有了「互斥」能力,那还要分布式锁干什么?

所以,Redis 作者认为这个方案是站不住脚的。

第二,退一步讲,即使 Redlock 没有提供 fecing token 的能力,但 Redlock 已经提供了随机值(就是前面讲的 UUID),利用这个随机值,也可以达到与 fecing token 同样的效果。

如何做呢?

Redis 作者只是提到了可以完成 fecing token 类似的功能,但却没有展开相关细节,根据我查阅的资料,大概流程应该如下,如有错误,欢迎交流~

  1. 客户端使用 Redlock 拿到锁
  2. 客户端在操作共享资源之前,先把这个锁的 VALUE,在要操作的共享资源上做标记
  3. 客户端处理业务逻辑,最后,在修改共享资源时,判断这个标记是否与之前一样,一样才修改(类似 CAS 的思路)

还是以 MySQL 为例,举个例子就是这样的:

  1. 客户端使用 Redlock 拿到锁
  2. 客户端要修改 MySQL 表中的某一行数据之前,先把锁的 VALUE 更新到这一行的某个字段中(这里假设为 current_token 字段)
  3. 客户端处理业务逻辑
  4. 客户端修改 MySQL 的这一行数据,把 VALUE 当做 WHERE 条件,再修改
    1
    UPDATE table T SET val = $new_val WHERE id = $id AND current_token = $redlock_value

可见,这种方案依赖 MySQL 的事物机制,也达到对方提到的 fecing token 一样的效果。

但这里还有个小问题,是网友参与问题讨论时提出的:两个客户端通过这种方案,先「标记」再「检查+修改」共享资源,那这两个客户端的操作顺序无法保证啊?

而用 Martin 提到的 fecing token,因为这个 token 是单调递增的数字,资源服务器可以拒绝小的 token 请求,保证了操作的「顺序性」!

Redis 作者对这问题做了不同的解释,我觉得很有道理,他解释道:分布式锁的本质,是为了「互斥」,只要能保证两个客户端在并发时,一个成功,一个失败就好了,不需要关心「顺序性」

前面 Martin 的质疑中,一直很关心这个顺序性问题,但 Redis 的作者的看法却不同。

综上,Redis 作者的结论:

  1. 作者同意对方关于「时钟跳跃」对 Redlock 的影响,但认为时钟跳跃是可以避免的,取决于基础设施和运维。
  2. Redlock 在设计时,充分考虑了 NPC 问题,在 Redlock 步骤 3 之前出现 NPC,可以保证锁的正确性,但在步骤 3 之后发生 NPC,不止是 Redlock 有问题,其它分布式锁服务同样也有问题,所以不在讨论范畴内。

是不是觉得很有意思?

在分布式系统中,一个小小的锁,居然可能会遇到这么多问题场景,影响它的安全性!

不知道你看完双方的观点,更赞同哪一方的说法呢?

别急,后面我还会综合以上论点,谈谈自己的理解。

好,讲完了双方对于 Redis 分布锁的争论,你可能也注意到了,Martin 在他的文章中,推荐使用 Zookeeper 实现分布式锁,认为它更安全,确实如此吗?

基于 Zookeeper 的锁安全吗?

如果你有了解过 Zookeeper,基于它实现的分布式锁是这样的:

  1. 客户端 1 和 2 都尝试创建「临时节点」,例如 /lock
  2. 假设客户端 1 先到达,则加锁成功,客户端 2 加锁失败
  3. 客户端 1 操作共享资源
  4. 客户端 1 删除 /lock 节点,释放锁

你应该也看到了,Zookeeper 不像 Redis 那样,需要考虑锁的过期时间问题,它是采用了「临时节点」,保证客户端 1 拿到锁后,只要连接不断,就可以一直持有锁。

而且,如果客户端 1 异常崩溃了,那么这个临时节点会自动删除,保证了锁一定会被释放。

不错,没有锁过期的烦恼,还能在异常时自动释放锁,是不是觉得很完美?

其实不然。

思考一下,客户端 1 创建临时节点后,Zookeeper 是如何保证让这个客户端一直持有锁呢?

原因就在于,客户端 1 此时会与 Zookeeper 服务器维护一个 Session,这个 Session 会依赖客户端「定时心跳」来维持连接

如果 Zookeeper 长时间收不到客户端的心跳,就认为这个 Session 过期了,也会把这个临时节点删除。

12
同样地,基于此问题,我们也讨论一下 GC 问题对 Zookeeper 的锁有何影响:

  1. 客户端 1 创建临时节点 /lock 成功,拿到了锁
  2. 客户端 1 发生长时间 GC
  3. 客户端 1 无法给 Zookeeper 发送心跳,Zookeeper 把临时节点「删除」
  4. 客户端 2 创建临时节点 /lock 成功,拿到了锁
  5. 客户端 1 GC 结束,它仍然认为自己持有锁(冲突)

可见,即使是使用 Zookeeper,也无法保证进程 GC、网络延迟异常场景下的安全性。

这就是前面 Redis 作者在反驳的文章中提到的:如果客户端已经拿到了锁,但客户端与锁服务器发生「失联」(例如 GC),那不止 Redlock 有问题,其它锁服务都有类似的问题,Zookeeper 也是一样!

所以,这里我们就能得出结论了:一个分布式锁,在极端情况下,不一定是安全的

如果你的业务数据非常敏感,在使用分布式锁时,一定要注意这个问题,不能假设分布式锁 100% 安全。

好,现在我们来总结一下 Zookeeper 在使用分布式锁时优劣:

Zookeeper 的优点:

  1. 不需要考虑锁的过期时间
  2. watch 机制,加锁失败,可以 watch 等待锁释放,实现乐观锁

但它的劣势是:

  1. 性能不如 Redis
  2. 部署和运维成本高
  3. 客户端与 Zookeeper 的长时间失联,锁被释放问题

我对分布式锁的理解

好了,前面详细介绍了基于 Redis 的 Redlock 和 Zookeeper 实现的分布锁,在各种异常情况下的安全性问题,下面我想和你聊一聊我的看法,仅供参考,不喜勿喷。

1) 到底要不要用 Redlock?

前面也分析了,Redlock 只有建立在「时钟正确」的前提下,才能正常工作,如果你可以保证这个前提,那么可以拿来使用。

但保证时钟正确,我认为并不是你想的那么简单就能做到的。

第一,从硬件角度来说,时钟发生偏移是时有发生,无法避免。

例如,CPU 温度、机器负载、芯片材料都是有可能导致时钟发生偏移的。

第二,从我的工作经历来说,曾经就遇到过时钟错误、运维暴力修改时钟的情况发生,进而影响了系统的正确性,所以,人为错误也是很难完全避免的。

所以,我对 Redlock 的个人看法是,尽量不用它,而且它的性能不如单机版 Redis,部署成本也高,我还是会优先考虑使用主从+ 哨兵的模式 实现分布式锁。

那正确性如何保证呢?第二点给你答案。

2) 如何正确使用分布式锁?

在分析 Martin 观点时,它提到了 fecing token 的方案,给我了很大的启发,虽然这种方案有很大的局限性,但对于保证「正确性」的场景,是一个非常好的思路。

所以,我们可以把这两者结合起来用:

  1. 使用分布式锁,在上层完成「互斥」目的,虽然极端情况下锁会失效,但它可以最大程度把并发请求阻挡在最上层,减轻操作资源层的压力。
  2. 但对于要求数据绝对正确的业务,在资源层一定要做好「兜底」,设计思路可以借鉴 fecing token 的方案来做。

两种思路结合,我认为对于大多数业务场景,已经可以满足要求了。

总结

好了,总结一下。

这篇文章,我们主要探讨了基于 Redis 实现的分布式锁,究竟是否安全这个问题。

从最简单分布式锁的实现,到处理各种异常场景,再到引出 Redlock,以及两个分布式专家的辩论,得出了 Redlock 的适用场景。

最后,我们还对比了 Zookeeper 在做分布式锁时,可能会遇到的问题,以及与 Redis 的差异。

这里我把这些内容总结成了思维导图,方便你理解。

13

后记

这篇文章的信息量其实是非常大的,我觉得应该把分布锁的问题,彻底讲清楚了。

如果你没有理解,我建议你多读几遍,并在脑海中构建各种假定的场景,反复思辨。

在写这篇文章时,我又重新研读了两位大神关于 Redlock 争辩的这两篇文章,可谓是是收获满满,在这里也分享一些心得给你。

  1. 在分布式系统环境下,看似完美的设计方案,可能并不是那么「严丝合缝」,如果稍加推敲,就会发现各种问题。所以,在思考分布式系统问题时,一定要谨慎再谨慎
  2. 从 Redlock 的争辩中,我们不要过多关注对错,而是要多学习大神的思考方式,以及对一个问题严格审查的严谨精神。

最后,用 Martin 在对于 Redlock 争论过后,写下的感悟来结尾:

“前人已经为我们创造出了许多伟大的成果:站在巨人的肩膀上,我们可以才得以构建更好的软件。无论如何,通过争论和检查它们是否经得起别人的详细审查,这是学习过程的一部分。但目标应该是获取知识,而不是为了说服别人,让别人相信你是对的。有时候,那只是意味着停下来,好好地想一想。”

共勉。

转自:https://blog.csdn.net/zzti_erlie/article/details/79964590

前言

JUC 是 java.util.concurrent 包的简称,JUC 有 2 大核心,CAS 和 AQS,CAS 是 java.util.concurrent.atomic 包的基础,即A tomicInteger 和 AtomicLong 等是用 CAS 实现的

volatile 只能保证可见性,不能保证原子性。

但原子类(AtomicInteger 等可以保证原子性),原子类利用 volatile+CAS 来保证原子性,来看看怎么做到的吧。

开 5 个线程,每个线程将 count 加 1000

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
@NotThreadSafe
public class CountTest {

public static int count = 0;

public static void main(String[] args) {

//新建一个线程池
ExecutorService service = Executors.newCachedThreadPool();
//Java8 lambda表达式执行runnable接口
for (int i = 0; i < 5; i++) {
service.execute(() -> {
for (int j = 0; j < 1000; j++) {
count++;
}
});
}
//关闭线程池
service.shutdown();
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("count = " + count);
}
}

由于这个代码是线程不安全的(因为 count++ 不是原子操作),所以最终结果有可能小于 5000,我们可以用 synchronized 保证操作的原子性和可见性

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
@ThreadSafe
public class CountTest {

public static int count = 0;

public static void main(String[] args) {

ExecutorService service = Executors.newCachedThreadPool();
for (int i = 0; i < 5; i++) {
service.execute(() -> {
for (int j = 0; j < 1000; j++) {
synchronized (CountTest.class) {
count++;
}
}
});
}
service.shutdown();
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("count = " + count);
}
}

synchronized 属于悲观锁,它有一个明显的缺点,它不管数据存不存在竞争都加锁,随着并发量增加,且如果锁的时间比较长,其性能开销将会变得很大。有没有办法解决这个问题?答案是基于冲突检测的乐观锁。这种模式下,已经没有所谓的锁概念了,每个线程都直接先去执行操作,检测是否与其他线程存在共享数据竞争,如果没有则让此操作成功,如果存在共享数据竞争则不断地重新执行操作,直到成功为止,重新尝试的过程叫自旋

java.util.concurrent.atomic 包就用到了 CAS,如 AtomicInteger 可以用于 Integer 类型的原子性操作,可将上述代码改为如下,也是线程安全的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@ThreadSafe
public class CountTest {

public static AtomicInteger count = new AtomicInteger(0);

public static void main(String[] args) {

ExecutorService service = Executors.newCachedThreadPool();
for (int i = 0; i < 5; i++) {
service.execute(() -> {
for (int j = 0; j < 1000; j++) {
count.getAndIncrement();
}
});
}
service.shutdown();
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("count = " + count);
}
}

CAS介绍

CAS(Compare and Swap), 翻译成比较并交换。

CAS 有 3 个操作数,内存值 V,旧的预期值 A,要修改的新值 B。当且仅当预期值 A 和内存值 V 相同时,将内存值 V 修改为 B,否则什么都不做。

1

仔细看图,CAS原理就是这么简单,看源码加深一下印象。

源码分析

基于 jdk1.8.0_20

1
2
3
4
5
6
7
8
9
10
11
private static final Unsafe unsafe = Unsafe.getUnsafe();
private static final long valueOffset;

static {
try {
valueOffset = unsafe.objectFieldOffset
(AtomicInteger.class.getDeclaredField("value"));
} catch (Exception ex) { throw new Error(ex); }
}

private volatile int value;

AtomicInteger 的值保存在 value 中,通过 volatile 保证操作的可见性,通过一个静态代码块来保证,类被加载时 valueOffset 已经有值了

Unsafe 是一个不安全的类,提供了一些对底层的操作,我们是不能使用这个类的,valueOffset 是 AtomicInteger 对象 value 成员变量在内存中的偏移量

1
2
3
public final int getAndIncrement() {
return unsafe.getAndAddInt(this, valueOffset, 1);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
//第一个参数为当前这个对象,如count.getAndIncrement(),则这个参数则为count这个对象
//第二个参数为AtomicInteger对象value成员变量在内存中的偏移量
//第三个参数为要增加的值
public final int getAndAddInt(Object var1, long var2, int var4) {
int var5;
do {
//调用底层方法得到value值
var5 = this.getIntVolatile(var1, var2);
//通过var1和var2得到底层值,var5为当前值,如果底层值=当前值,则将值设为var5+var4,并返回true,否则返回false
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

return var5;
}

这个方法是由其他语言实现的,就不再分析

1
public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);

并发比较低的时候用 CAS 比较合适,并发比较高用 synchronized 比较合适

CAS的缺点

  1. 只能保证对一个变量的原子性操作
    当对一个共享变量执行操作时,我们可以使用循环 CAS 的方式来保证原子操作,但是对多个共享变量操作时,循环 CAS 就无法保证操作的原子性,这个时候就可以用锁来保证原子性。

  2. 长时间自旋会给 CPU 带来压力
    我们可以看到 getAndAddInt 方法执行时,如果 CAS 失败,会一直进行尝试。如果 CAS 长时间一直不成功,可能会给 CPU 带来很大的开销。

  3. ABA 问题
    如果内存地址V初次读取的值是A,并且在准备赋值的时候检查到它的值仍然为 A,那我们就能说它的值没有被其他线程改变过了吗?

如果在这段期间它的值曾经被改成了 B,后来又被改回为 A,那 CAS 操作就会误认为它从来没有被改变过。这个漏洞称为 CAS 操作的“ABA”问题。Java 并发包为了解决这个问题,提供了一个带有标记的原子引用类“AtomicStampedReference”,它可以通过控制变量值的版本来保证 CAS 的正确性。因此,在使用 CAS 前要考虑清楚“ABA”问题是否会影响程序并发的正确性,如果需要解决 ABA 问题,改用传统的互斥同步可能会比原子类更高效。

转自:https://blog.csdn.net/zzti_erlie/article/details/105322946

前言

ThreadLocal 主要有如下 2 个作用

  • 保证线程安全
  • 在线程级别传递变量

保证线程安全

最近一个小伙伴把项目中封装的日期工具类用在多线程环境下居然出了问题,来看看怎么回事吧

日期转换的一个工具类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class DateUtil {

private static final SimpleDateFormat sdf =
new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

public static Date parse(String dateStr) {
Date date = null;
try {
date = sdf.parse(dateStr);
} catch (ParseException e) {
e.printStackTrace();
}
return date;
}
}

然后将这个工具类用在多线程环境下

1
2
3
4
5
6
7
8
9
10
11
public static void main(String[] args) {

ExecutorService service = Executors.newFixedThreadPool(20);

for (int i = 0; i < 20; i++) {
service.execute(()->{
System.out.println(DateUtil.parse("2019-06-01 16:34:30"));
});
}
service.shutdown();
}

结果报异常了,因为部分线程获取的时间不对
1

这个异常就不从源码的角度分析了,写一个小 Demo,理解了这个小 Demo,就理解了原因

一个将数字加 10 的工具类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class NumUtil {

public static int addNum = 0;

public static int add10(int num) {
addNum = num;
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
return addNum + 10;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
public static void main(String[] args) {

ExecutorService service = Executors.newFixedThreadPool(20);

for (int i = 0; i < 20; i++) {
int num = i;
service.execute(()->{
System.out.println(num + " " + NumUtil.add10(num));
});
}
service.shutdown();
}

然后代码的一部分输出为

1
2
3
4
5
0 28
3 28
7 28
11 28
15 28

什么鬼,不是加 10 么,怎么都输出了 28?这主要是因为线程切换的原因,线程陆续将 addNum 值设置为 0,3,7 但是都没有执行完(没有执行到 return addNum+10 这一步)就被切换了,当其中一个线程将 addNum 值设置为 18 时,线程陆续开始执行 addNum+10 这一步,结果都输出了 28。SimpleDateFormat 的原因和这个类似,那么我们如何解决这个问题呢?

解决方案

解决方案1:每次来都new新的,空间浪费比较大

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class DateUtil {

public static Date parse(String dateStr) {
SimpleDateFormat sdf =
new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
Date date = null;
try {
date = sdf.parse(dateStr);
} catch (ParseException e) {
e.printStackTrace();
}
return date;
}
}

解决方案2:方法用synchronized修饰,并发上不来

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class DateUtil {

private static final SimpleDateFormat sdf =
new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

public static synchronized Date parse(String dateStr) {
Date date = null;
try {
date = sdf.parse(dateStr);
} catch (ParseException e) {
e.printStackTrace();
}
return date;
}
}

解决方案3:用jdk1.8中的日期格式类DateFormatter,DateTimeFormatter

1
2
3
4
5
6
7
8
9
public class DateUtil {

private static DateTimeFormatter formatter =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");

public static LocalDateTime parse(String dateStr) {
return LocalDateTime.parse(dateStr, formatter);
}
}

解决方案4:用ThreadLocal,一个线程一个SimpleDateFormat对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class DateUtil {

private static ThreadLocal<DateFormat> threadLocal = ThreadLocal.withInitial(
()-> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));

public static Date parse(String dateStr) {
Date date = null;
try {
date = threadLocal.get().parse(dateStr);
} catch (ParseException e) {
e.printStackTrace();
}
return date;
}
}

上面的加 10 的工具类可以改成如下形式(主要为了演示 ThreadLocal 的使用)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class NumUtil {

private static ThreadLocal<Integer> addNumThreadLocal = new ThreadLocal<>();

public static int add10(int num) {
addNumThreadLocal.set(num);
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
return addNumThreadLocal.get() + 10;
}
}

现在2个工具类都能正常使用了,这是为啥呢?

原理分析

当多个线程同时读写同一共享变量时存在并发问题,如果不共享不就没有并发问题了,一个线程存一个自己的变量,类比原来好几个人玩同一个球,现在一个人一个球,就没有问题了,如何把变量存在线程上呢?其实 Thread 类内部已经有一个 Map 容器用来存变量了。它的大概结构如下所示
2

ThreadLocalMap 是一个 Map,key 是 ThreadLocal,value 是 Object

映射到源码就是如下所示:ThreadLocalMap 是 ThreadLocal 的一个静态内部类

1
2
3
public class Thread implements Runnable {
ThreadLocal.ThreadLocalMap threadLocals = null;
}

往 ThreadLocalMap 里面放值`

1
2
3
4
5
6
7
8
9
// ThreadLocal类里面的方法,将源码整合了一下
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = t.threadLocals;
if (map != null)
map.set(this, value);
else
t.threadLocals = new ThreadLocalMap(this, firstValue);
}

从 ThreadLocalMap 里面取值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// ThreadLocal类里面的方法,将源码整合了一下
public T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = t.threadLocals;
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
return setInitialValue();
}

从 ThreadLocalMap 里面删除值

1
2
3
4
5
6
// ThreadLocal类里面的方法,将源码整合了一下
public void remove() {
ThreadLocalMap m = Thread.currentThread().threadLocals;
if (m != null)
m.remove(this);
}

执行如下代码

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
public class InfoUtil {

private static ThreadLocal<String> nameInfo = new ThreadLocal<>();
private static ThreadLocal<Integer> ageInfo = new ThreadLocal<>();

public static void setInfo(String name, Integer age) {
nameInfo.set(name);
ageInfo.set(age);
}

public static String getName() {
return nameInfo.get();
}

public static void main(String[] args) {
new Thread(() -> {
InfoUtil.setInfo("张三", 10);
// 张三
System.out.println(InfoUtil.getName());
}, "thread1").start();
new Thread(() -> {
InfoUtil.setInfo("李四", 20);
// 李四
System.out.println(InfoUtil.getName());
}, "thread2").start();
}
}

变量的结构如下图
3

在线程级别传递变量

假设有如下一个场景,method1()调用method2(),method2()调用method3(),method3()调用method4(),method1()生成了一个变量想在method4()中使用,有如下2种解决办法

  1. method 2 3 4的参数列表上都写上method4想要的变量
  2. method 1 往ThreadLocal中put一个值,method4从ThreadLocal中get出来

哪种实现方式比较优雅呢?相信我不说你也能明白了

我在生产环境中一般是这样用的,如果一个请求在系统中的处理流程比较长,可以对请求的日志打一个相同的前缀,这样比较方便处理问题

这个前缀的生成和移除可以配置在拦截器中,切面中,当然也可以在一个方法的前后

1
2
3
4
5
6
7
8
9
10
11
public class Main {

public static final ThreadLocal<String> SPANID =
ThreadLocal.withInitial(() -> UUID.randomUUID().toString());

public static void start() {
SPANID.set(UUID.randomUUID().toString());
// 方法调用过程中可以在日志中打印SPANID表明一个请求的执行链路
SPANID.remove();
}
}

当然Spring Cloud已经有现成的链路追踪组件了。

ThreadLocal使用注意事项

ThreadLocal如果使用不当会造成如下问题

  1. 脏数据
  2. 内存泄露

脏数据

线程复用会造成脏数据。由于线程池会复用 Thread 对象,因此 Thread 类的成员变量 threadLocals 也会被复用。如果在线程的 run() 方法中不显示调用 remove() 清理与线程相关的 ThreadLocal 信息,并且下一个线程不调用 set() 设置初始值,就可能 get() 到上个线程设置的值

内存泄露

1
2
3
4
5
6
7
8
9
10
11
static class ThreadLocalMap {

static class Entry extends WeakReference<ThreadLocal<?>> {
Object value;

Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
}

ThreadLocalMap 使用 ThreadLocal 的弱引用作为 key,如果一个 ThreadLocal 没有外部强引用来引用它,那么系统 GC 的时候,这个 ThreadLocal 势必会被回收,这样一来,ThreadLocalMap 中就会出现 key 为 null 的 Entry,就没有办法访问这些key为 null 的 Entry 的 value,如果当前线程再迟迟不结束的话,这些key为 null 的 Entry 的 value 就会一直存在一条强引用链:Thread Ref -> Thread -> ThreaLocalMap -> Entry -> value 永远无法回收,造成内存泄漏

大白话一点,ThreadLocalMap 的 key 是弱引用,GC时会被回收掉,那么就有可能存在 ThreadLocalMap<null, Object> 的情况,这个 Object 就是泄露的对象

其实,ThreadLocalMap 的设计中已经考虑到这种情况,也加上了一些防护措施:在 ThreadLocal的get(),set(),remove() 的时候都会清除线程 ThreadLocalMap 里所有 key 为 null 的 value

解决办法

解决以上两个问题的办法很简单,就是在每次用完 ThreadLocal 后,及时调用 remove() 方法清理即可