JAVA基础

面向对象特征

  • 封装:封装对象的行为和属性。
  • 继承:从已有类中派生出新的类,新的类能够吸收已有类的属性和行为,并能进一步拓展。可以继承父类 private 属性和行为,但只对父类可见。
  • 多态:必备三要素:继承、重写、父类引用指向子类对象。指一个行为具有多个不同的表现形式。

访问修饰符

修饰符 同一个类 同一个包 不同包的子类 不同包的非子类
public 可访问 可访问 可访问 可访问
protected 可访问 可访问 可访问 不可访问
default 可访问 可访问 不可访问 不可访问
private 可访问 不可访问 不可访问 不可访问

接口与抽象类

抽象类:被 abstract 关键字修饰的类称为抽象类,被 abstract 关键字修饰的方法称为抽象方法,抽象方法只有方法的声明,没有方法体。抽象类的特点:

  1. 抽象类不能被实例化只能被继承
  2. 包含抽象方法的一定是抽象类,但是抽象类不一定含有抽象方法;
  3. 抽象类中的抽象方法的修饰符只能为 public 或者 protected,默认为 public
  4. 一个子类继承一个抽象类,则子类必须实现父类抽象方法,否则子类也必须定义为抽象类;
  5. 抽象类可以包含属性、方法、构造方法,但是构造方法不能用于实例化,主要用途是被子类调用;
  6. 抽象类可以包含静态方法

接口:使用 interface 关键字修饰,特点为:

  1. 接口可以包含变量、方法,变量被隐式指定为 public static final,方法被隐式指定为 public abstract

  2. 接口支持多继承,即一个接口可以 extends 多个接口,间接的解决了 Java 中类的单继承问题;

  3. 一个类可以实现多个接口;

  4. JDK1.8 中对接口增加了新的特性:

    1)默认方法(default method):JDK 1.8允许给接口添加非抽象的方法实现,但必须使用 default 关键字修饰;定义了 default 的方法可以不被实现子类所实现,但只能被实现子类的对象调用;如果子类实现了多个接口,并且这些接口包含一样的默认方法,则子类必须重写默认方法;

    2)静态方法(static method):JDK 1.8中允许使用 static 关键字修饰一个方法,并提供实现,称为接口静态方法。接口静态方法只能通过接口调用(接口名.静态方法名)。

泛型

泛型的本质是参数化类型,即给类型指定一个参数,然后在使用时再指定此参数具体的类型。这种参数类型可以用在接口方法中,分别被称为泛型类、泛型接口、泛型方法。

为什么使用泛型:

  1. 保证了类型的安全性

    1
    2
    3
    4
    5
    public static void noGeneric() {
    ArrayList names = new ArrayList();
    names.add("123");
    names.add(123); // 编译正常
    }
    1
    2
    3
    4
    5
    public static void useGeneric() {
    ArrayList<String> names = new ArrayList<>();
    names.add("123");
    names.add(123); // 编译不通过
    }
  2. 消除强制转换

    1
    2
    3
    List list = new ArrayList();
    list.add("hello");
    String s = (String) list.get(0);
    1
    2
    3
    List<String> list = new ArrayList<String>();
    list.add("hello");
    String s = list.get(0); // no cast
  3. 提高了代码的重用性

注意事项:泛型类型必须是引用类型(非基本数据类型),这是事实,但是 Java 编译器对此进行了自动装箱(autoboxing)和自动拆箱(unboxing)的操作,使得在使用基本数据类型时不会出现编译错误。例如,如果定义了一个 List<int> 类型的变量,Java 编译器会将其转换为 List<Integer> 类型。在获取元素时,如果使用 int 类型的变量来接收返回值,此时 Java 编译器会自动将 Integer 类型的值拆箱为 int 类型。

泛型规范:

标记符 场景
E Element(在集合中使用,因为集合中存放的是元素)
T Type(Java 类)
K Key(键)
V Value(值)
N Number(数值类型)
? 表示不确定的 java 类型

以泛型接口为例:

1
2
3
public interface Comparator<T> {
int compare(T a, T b);
}
1
2
3
4
5
public class MyComparator implements Comparator<Integer> {
public int compare(Integer a, Integer b) {
return a - b;
}
}
1
2
3
MyComparator mc = new MyComparator();
int result = mc.compare(2, 1);
System.out.println("Result of comparison is " + result);

泛型通配符:

  1. 使用 ? extends 通配符表示上界限制:类型参数必须是 Number 或者是 Number 的子类

    1
    2
    3
    4
    5
    6
    7
    public static double sum(List<? extends Number> list) {
    double sum = 0.0;
    for (Number n : list) {
    sum += n.doubleValue();
    }
    return sum;
    }
  2. 使用 ? super 通配符表示下界限制:类型参数必须是 Integer 或者是 Integer 的父类

    1
    2
    3
    public static void addToList(List<? super Integer> list, Integer num) {
    list.add(num);
    }
  3. 使用 ? 通配符表示无界限制:类型参数可以是任何类型

    1
    List<?> list = new ArrayList<>();

泛型擦除:是指在编译时期将泛型类型的参数擦除掉,使用原始类型来代替。

自动装/拆箱

它们可以让基本类型和其对应的包装类型之间进行自动转换,使得代码更加简洁和易读。

  • 自动装箱(Autoboxing):是指将基本类型自动转换为对应的包装类型。
  • 自动拆箱(Unboxing):是指将包装类型自动转换为对应的基本类型。
1
2
3
List<Integer> list = new ArrayList<>();
list.add(1); // 自动装箱
int i = list.get(0); // 自动拆箱

不需要显式地调用Integer.valueOf()/Integer.intValue()方法来进行/箱。

static

static 可修饰方法代码块变量不可以修饰构造器

  • 静态类:可以在不创建实例的情况下访问它的静态方法或静态成员变量,而其实例方法或实例成员变量只能通过其实例对象来访问。
  • 静态变量:静态变量属于类,而不是属于类的实例对象。它被所有实例对象共享,只会被初始化一次,不会在每个对象实例化时都分配一次内存。可以通过类名直接访问静态变量。
  • 静态方法:静态方法属于类,而不是属于类的实例对象。它不需要实例化对象即可被调用,可以通过类名直接调用静态方法。静态方法中不能直接使用非静态变量和非静态方法,因为这些成员是属于对象的。
  • 静态代码块:静态代码块是在类加载时执行的代码块,用于初始化静态变量。静态代码块只会被执行一次。

final

用来修饰方法变量,具体含义如下:

  1. 修饰类:该类不能被继承。如果一个类被 final 修饰,那么它的方法也都隐式地被修饰为 final 方法,因为不能被重写。
  2. 修饰方法:方法不能被子类重写。
  3. 修饰变量:如果是基本变量则值不能再改变,如果是引用变量则引用地址不能改变,但值可以改变。

引用类型

引用可理解为指针。

  • 强引用:大部分情况下使用的都是强引用,如下 objectstr 都是强引用。

    1
    2
    Object object = new Object();
    String str = "123";

    如果一个对象具有强引用,那就不会被垃圾回收器回收。当内存空间不足,JVM 宁愿抛出 OOM 错误,使程序异常终止,也不回收这种对象。

  • 软引用:用来描述有用但并不是必需的对象。对于软引用关联着的对象,只有在内存不足的时候 JVM 才会回收该对象。因此,这一点可以很好地用来解决 OOM 的问题,并且这个特性很适合用来实现缓存:比如网页缓存、图片缓存等。

    1
    SoftReference<byte[]> sr = new SoftReference<>(new byte[1024 * 1024 * 10]);

    其中 sr 为强引用,指向 SoftReference 对象,而 SoftReference 对象里又包装了软引用指向 10M 的字节数组。 使用 sr.get() 可拿到字节数组。

  • 弱引用:也是用来描述非必需对象的,当 JVM 进行垃圾回收时,无论内存是否充足,都会回收被弱引用指向的对象。可使用弱引用来解决 ThreadLocal 内存泄漏。

    1
    WeakReference<String> wr = new WeakReference<String>(new String("hello"));
  • 虚引用:不影响对象的生命周期,如果一个对象与虚引用关联,则跟没有引用与之关联一样,通过 get 方法也不能获取到对象,在任何时候都可能被垃圾回收器回收。主要用于 JVM 跟踪对象被垃圾回收的活动。

    1
    2
    private static final ReferenceQueue<String> QUEUE = new ReferenceQueue<>();
    PhantomReference<String> pr = new PhantomReference<String>(new String("hello"),QUEUE);

==、equals()

  • ==:对于基本数据类型(如 int、double 等),比较两个变量值是否相等。对于引用类型(如 String、Object 等),比较的是两个变量所引用的对象地址是否相同。

    1
    2
    3
    4
    5
    6
    7
    8
    int a = 1;
    int b = 1;
    System.out.println(a == b); // true
    String str1 = "hello";
    String str2 = "hello";
    String str3 = new String("hello");
    System.out.println(str1 == str2); // true,因为 str1 和 str2 引用同一个对象
    System.out.println(str1 == str3); // false,因为 str1 和 str3 引用不同的对象
  • equals():在默认情况下,equals() 方法比较的是两个对象的地址是否相同,即与 == 操作符的作用相同。但是,大部分引用类型都重写了 equals() 方法,改为比较两个对象的内容是否相同。例如,String 类重写了 equals() 方法,改为比较两个字符串的内容是否相同。

    1
    2
    3
    4
    5
    String str1 = "hello";
    String str2 = "hello";
    String str3 = new String("hello");
    System.out.println(str1.equals(str2)); // true,因为 str1 和 str2 的内容相同
    System.out.println(str1.equals(str3)); // true,因为 str1 和 str3 的内容相同

hashCode()、equals()

hashCode()equals() 都是 Object 类中的方法,它们的作用分别是:

  • hashCode() 方法返回对象的哈希码,用于支持基于哈希表的集合类,如 HashMapHashSet 等。
  • equals() 方法用于比较两个对象是否相等。

equals() 默认比较两个对象的引用是否相同,如果需要比较对象的内容是否相等,就需要重写 equals() 方法。 在 Java 中,重写equals() 方法的同时,也应该重写 hashCode() 方法,因为这两个方法是相互关联的。

如果只重写了 equals() 方法,而没有重写 hashCode() 方法,就会导致两个相等的对象在哈希表中却被视为不相等的对象,这会导致一些问题,例如无法正确地从哈希表中获取对象等。 因此,当重写 equals() 方法时,也应该重写 hashCode() 方法,保证两个方法的一致性。

约定:如果两个对象在 equals() 方法中被认为相等,则它们的哈希码应该相等;反之,如果两个对象的哈希码相等,则它们不一定相等,还需要通过 equals() 方法来判断。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class Person {
private String name;
private int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
@Override
public boolean equals(Object obj) {
if (obj == this) {
return true;
}
if (!(obj instanceof Person)) {
return false;
}
Person other = (Person) obj;
return this.name.equals(other.name) && this.age == other.age;
}
@Override
public int hashCode() {
return Objects.hash(name, age); // 先拼接成字符串再求字符串哈希码,避免两个Person对象的属性值相同而引用不同
}
}

重写 hashCode() 方法的目的是保证两个相等的对象具有相同的哈希码,这样它们才能够被正确地存储在哈希表中。因为hashCode()方法默认实现会返回对象的内存地址的哈希码,如果两个对象的引用不同,那么它们的哈希码就不同。

String、StringBuffer、StringBuilder

  1. String 类是不可变的,即一旦创建了一个 String 对象,就不能再修改它的值。每次对 String 对象的修改都会创建一个新的 String 对象,旧的对象则会被垃圾回收。因此,如果需要频繁地修改字符串,使用 String 类会产生大量的临时对象,导致性能下降。

  2. StringBufferStringBuilder 类都是可变的,即可以对对象的值进行修改。它们的区别在于线程安全性和性能。

    StringBuffer线程安全的,即多个线程可以同时对一个 StringBuffer 对象进行操作而不会产生冲突,但是性能比较差。

    StringBuilder 则是非线程安全的,即多个线程同时对一个 StringBuilder 对象进行操作会产生冲突,但是性能比较好。

1
2
3
4
5
6
7
8
9
10
StringBuffer sbf = new StringBuffer();
sbf.append("Hello");
sbf.append(" ");
sbf.append("world");
String str = sbf.toString(); // "Hello world"
StringBuilder sbd = new StringBuilder();
sbd.append("Hello");
sbd.append(" ");
sbd.append("world");
String str = sbd.toString(); // "Hello world"

HashMap、HashTable、ConcurrentHashMap

HashMap HashTable ConcurrentHashMap
null 键 允许 (一个) 不允许 不允许
null 值 允许 (多个) 不允许 不允许
线程安全 不安全 安全 安全
效率 非常高
数据结构 数组+链表+红黑树 数组+链表 数组+链表+红黑树
实现 哈希 哈希 哈希

解决 HashMap 非线程安全办法:

  1. 继承 HashMap,重写或者按要求编写自己的方法,这些方法要写成 synchronized,在这些 synchronized 的方法中调用 HashMap 的方法。
  2. 使用 Collections.synchronizedMap()。底层同样使用 synchronized,效率较低。
  3. 使用 ConcurrentHashMap 替代,并不推荐使用 HashTable,HashTable 继承于 Dictionary,任意时间只有一个线程能写 Hashtable,并发性能不如 ConcurrentHashMap,因为 ConcurrentHashMap 引入了分段锁。

image-20230308124511196

put 流程:基于哈希算法来确定元素位置,当向集合存入数据时,他会计算传入的 key 的哈希值,并利用哈希值取绝对值再根据集合长度取余来确定元素的位置,如果这个位置已经存在其他元素了,就会发生哈希碰撞,则 hashmap 就会通过链表将这些元素组织起来,如果链表的长度达到 8 时,就会转化为红黑树,从而提高查询速度。

  1. 高效的CRUD操作:因为它通过哈希函数将键映射到数组中的位置,不需要遍历整个数组。
  2. 动态扩容:当 HashMap 中元素的数量超过了负载因子和数组长度的乘积时,就需要进行扩容操作,以保证哈希表的效率。在 JDK8 中默认的负载因子是 0.75。创建一个新的数组,长度为原数组的两倍。将原数组中的元素重新哈希到新数组中,重新计算它们在新数组中的位置。将新数组作为 HashMap 的数组,并将原数组释放,以便进行垃圾回收。为了减少扩容的次数,可以在创建 HashMap 对象时,指定一个合适的初始容量大小。如果能够预估元素的数量,可以使用以下公式计算出一个合适的初始容量大小: initialCapacity = (需要存储的元素个数 / 负载因子) + 1
  3. 遍历无序:HashMap 的遍历顺序是无序的,因为它是基于哈希函数的,无法保证元素的顺序。

ConcurrentHashMap 和 Hashtable 也支持扩容,它们的扩容方式是类似的,都是在哈希表中的元素数量超过负载因子和数组长度的乘积时进行扩容。

ArrayList、LinkedList

  • ArrayList 底层基于动态数组实现,数组是一组连续的内存单元,读取快(使用索引),插入删除慢,因为需要重新排序数组中的元素。
  • LinkedList 底层基于双向链表实现,读取慢,插入删除快。相比 ArrayList 更耗内存,因为每个节点存储了两个引用,一个指向前面的元素,另一个指向后面的元素。
  • ArrayList 和 LinkedList 都是非线程安全的。

ArrayList:

它的内部封装了一个 Object 数组,在 jdk7.0 的时候,容器创建时会先在底层创建一个长度为 10 的数组,在jdk8.0 的时候,会先初始化一个空数组,之后在第一次调用 add 方法的时候才会创建一个长度为 10 的数组,如果数据量超出数组长度会自动进行扩容,默认情况下,扩容为原来容量的 1.5 倍,扩容使用数据拷贝,将原有数组中的值复制到新的数组中。ArrayList 支持缩容但不会自动缩容,需要自己调用 trimToSize() 方法,届时数组将按照元素的实际个数进行缩减。

ArrayList 和 LinkedList 都提供了 iterator 方法,增强了迭代能力,并且还提供了 listIterator 方法,支持双向迭代。

解决非线程安全办法:

  1. 继承 ArrayList 或者 LinkedList,然后重写按要求编写自己的方法,这些方法要写成 synchronized。在这些 synchronized 的方法中调用 ArrayList 或者 LinkedList 的方法。

  2. 使用 Vector,使用 synchronized 保证线程安全,但是效率低下。

  3. 使用 Collections.synchronizedList(),但是效率同样较低。

  4. 使用 CopyOnWriteArrayList

    CopyOnWrite 即写时复制的容器。通俗的理解是当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先复制一份当前容器,然后往新容器添加元素,添加完元素之后,再将原容器的引用指向新的容器。这样做的好处是可以对 CopyOnWrite 容器进行并发的读,而不需要加锁。当需要进行写操作时才进行加锁。但同时也存在内存占用数据一致性问题

HashSet、TreeSet、LinkedHashSet

  1. 底层实现:HashSet 底层采用散列表(哈希表)实现;TreeSet 底层采用红黑树实现;LinkedHashSet 底层采用散列表和双向链表实现。
  2. 元素顺序:HashSet 和 TreeSet 不保证元素的顺序,而 LinkedHashSet 保证元素按照插入顺序排列。
  3. 元素去重:HashSet 和 LinkedHashSet 都是去重的,通过元素的 hashCode 值和 equals 方法来判断元素是否相同;TreeSet 也是去重的,但它通过元素的比较方法 Comparable 或 Comparator 来判断元素是否相同。
  4. 性能差异:HashSet 的查询和插入操作效率很高,但遍历操作效率较低;TreeSet 的查询、插入和遍历操作效率都比较稳定,但相对较低;LinkedHashSet 的查询和插入操作效率较高,遍历操作效率也较高,但相对于 HashSet 略低。
  5. 线程安全性:都是非线程安全的。

解决非线程安全办法:

  1. 使用 Collections.synchronizedSet() 方法,该方法返回一个线程安全的 Set,在多线程环境下可以保证对 Set 的操作是同步的。需要使用该方法时,可以先创建一个普通的 Set,然后使用该方法将其转换为线程安全的 Set,例如:

    1
    2
    Set<String> set = new HashSet<>();
    Set<String> synchronizedSet = Collections.synchronizedSet(set);
  2. 使用 ConcurrentSkipListSet 类,该类是 Java 中的线程安全的有序集合实现,它基于 ConcurrentSkipListMap 实现,支持高并发访问。该类的元素是有序的,可以通过自然顺序或者 Comparator 进行排序。空间和时间复杂度都比较高,因此它的性能相对较低。

  3. 使用 CopyOnWriteArraySet 类,该类是 Java 中的线程安全的集合实现,它基于 CopyOnWriteArrayList 实现,支持高并发访问,并且保证遍历时不会出现 ConcurrentModificationException 异常,该类的元素是无序的。在修改、添加、删除元素时需要对底层数组进行复制,因此它的内存占用较高。

List、Set

  1. 数据结构:List 是一种有序可重复的集合类型,可以存储重复的元素;Set 是一种无序不可重复的集合类型。
  2. 接口继承关系:List 和 Set 接口都继承自 Collection 接口。
  3. 常用实现类:List 的常用实现类包括 ArrayListLinkedListVector;Set 的常用实现类包括 HashSetTreeSetLinkedHashSet
  4. 遍历方式:List 可以使用 for循环foreach循环迭代器等多种方式进行遍历;Set 只能使用迭代器进行遍历。

排序方式:

  • Collections工具类

    Collections.sort():对 List 进行排序。可以使用默认的自然排序方式,也可以通过实现 Comparator 接口来自定义排序规则。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    List<Integer> list = new ArrayList<>();
    list.add(3);
    list.add(1);
    list.add(2);
    Collections.sort(list); // 默认按照自然排序方式排序
    System.out.println(list); // [1, 2, 3]
    List<String> list2 = new ArrayList<>();
    list2.add("java");
    list2.add("python");
    list2.add("ruby");
    Collections.sort(list2, (s1, s2) -> s1.compareTo(s2)); // 使用自定义的排序规则排序
    System.out.println(list2); // [java, python, ruby]

    如果需要对 Set 中的元素进行排序,可以先将 Set 转换为 List。

    1
    2
    3
    4
    5
    6
    7
    Set<Integer> set = new HashSet<>();
    set.add(3);
    set.add(1);
    set.add(2);
    List<Integer> list = new ArrayList<>(set);
    Collections.sort(list); // 对Set中的元素进行排序
    System.out.println(list); // [1, 2, 3]
  • Stream API

    Stream.sorted():对 List 或 Set 进行排序。可以使用默认的自然排序方式,也可以通过实现 Comparator 接口来自定义排序规则。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    List<Integer> list = new ArrayList<>();
    list.add(3);
    list.add(1);
    list.add(2);
    List<Integer> sortedList = list.stream().sorted().collect(Collectors.toList()); // 默认按照自然排序方式排序
    System.out.println(sortedList); // [1, 2, 3]
    Set<String> set = new HashSet<>();
    set.add("java");
    set.add("python");
    set.add("ruby");
    List<String> sortedSet = set.stream().sorted().collect(Collectors.toList()); // 默认按照自然排序方式排序
    System.out.println(sortedSet); // [java, python, ruby]
    List<String> list2 = new ArrayList<>();
    list2.add("java");
    list2.add("python");
    list2.add("ruby");
    List<String> sortedList2 = list2.stream().sorted((s1, s2) -> s1.compareTo(s2)).collect(Collectors.toList()); // 使用自定义的排序规则排序
    System.out.println(sortedList2); // [java, python, ruby]
  • 以上是常用的排序方法,还可以使用其他一些排序算法,例如快速排序、归并排序等。Java 中也提供了 Arrays.sort() 方法用于对数组进行排序。

如何将 List 和 Set 转换为数组?如何将数组转换为 List 和 Set?

  • 可以使用 List.toArray() 方法和 Set.toArray() 方法将 List 和 Set 转换为数组;
  • 将数组转换为 List 可以使用 Arrays.asList() 方法,将数组转换为 Set 可以使用 Arrays.stream() 方法和 Collectors.toSet() 方法。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 将List转换为数组
List<String> list = new ArrayList<>();
list.add("A");
list.add("B");
list.add("C");
String[] arr1 = list.toArray(new String[0]);
System.out.println(Arrays.toString(arr1)); // [A, B, C]
// 将Set转换为数组
Set<String> set = new HashSet<>();
set.add("A");
set.add("B");
set.add("C");
String[] arr2 = set.toArray(new String[0]);
System.out.println(Arrays.toString(arr2)); // [A, B, C]
// 将数组转换为List
String[] arr3 = {"A", "B", "C"};
List<String> list2 = Arrays.asList(arr3);
System.out.println(list2); // [A, B, C]
// 将数组转换为Set
String[] arr4 = {"A", "B", "C"};
Set<String> set2 = Arrays.stream(arr4).collect(Collectors.toSet());
System.out.println(set2); // [A, B, C]

需要注意的是,List 和 Set 转换为数组时,需要指定数组的类型,并且数组的长度要大于等于 List 或 Set 的大小,如果将数组长度设置为 0 时,toArray() 方法将返回一个新的零长度的数组,这个数组将根据传递的运行时类型动态创建,可以正确地存储元素。。另外,Arrays.asList()方法返回的 List 是不可变的列表,不能进行添加、删除等操作。

for、增强for、forEach、迭代器

  • for:每次遍历都需要访问 list 长度,故可以在循环遍历中对元素进行增删操作。

    1
    2
    3
    4
    5
    6
    list.add("aa");
    list.add("bb");
    for(int i=0;i<list.size();i++) {
    list.add("cc");
    list.remove(i);
    }
  • 增强for:在进入循环前已经确定好个数,所以不可以在循环体里面增加或删除。

    1
    2
    3
    4
    for(String item:list) {
    // list.add("aa"); // 报错
    // list.remove("aa"); // 报错
    }
  • 另外 jdk8 新增 forEach 方法,使用增强 for 实现,同样只读。

  • 迭代器:在进入迭代器循环前,集合长度已经确认了,同样不支持增删。

    1
    2
    3
    4
    5
    6
    7
    8
    Iterator<String> it =list.iterator();
    while(it.hasNext()) {
    String str =it.next();
    // System.out.println(it.next()); // 不要重复使用,否则可能报错,自行动手测试
    // list.add("aa"); // 报错
    // list.remove("aa"); // 报错
    it.remove(); // 可用迭代器进行删除,不会报错
    }

说明:有时候仅仅是需要数组中元素的值,如果使用 for 就需要提前获取数组长度,声明索引变量等,尤其当多个循环嵌套的时候,更需要使用多个索引变量,代码的复杂度就会大大增加,此时可选择使用迭代器去遍历。迭代器的好处是无论使用什么结构(链表也好、数组也好、数也好、图也好、hash表也好),总之, 可以不关心任何遍历细节,Next() 表示下一个元素、Pre() 表示上一个元素。

ThreadLocal

实现每一个线程都有自己的局部变量,只有当前自身线程可以访问,别的线程都访问不了。基本用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
private static ThreadLocal<Integer> tl = new ThreadLocal<>();
// private static ThreadLocal<Integer> tl2 = new ThreadLocal<>();

public static void main(String[] args) {

new Thread(()->{
tl.set(1);
Thread.currentThread().sleep(1000); // 睡眠 1 秒
tl.get(); // 获取得到
}).start();

tl.get(); // 获取不到
}

每个线程内部都有一个 ThreadLocalMap,使用 tl.set() 时,会取出当前线程的 ThreadLocalMap 并存入 keytl指向的ThreadLocal对象value传入值的一个 Entry 键值对。所以可以通过再 new 一个 ThreadLocal 对象实现往线程的 ThreadLocalMap 存入更多的数据。

有意思的地方:

在上面的代码中,new 了一个 ThreadLocal 对象并使用 tl 去指向它,此时指向 ThreadLocal 对象的引用为强引用。当我们调用 tl.set() 时会 new 一个 Entry 对象来存储数据,而这个 Entry 它继承了 WeakReference 并调用 WeakReference 的构造函数将 key 指向 ThreadLocal 对象。此时强引用和弱引用同时指向了 ThreadLocal 对象。 使用弱引用的原因很简单,如果将 tl 设为 null, 就表示不再需要 ThreadLocal 对象,此时 ThreadLocal 对象只剩下一个弱引用指向它,而弱引用会被 JVM 垃圾回收,能够避免一些内存泄漏。为什么说一些呢?key 确实是一个弱引用,但 value 可是一个强引用,如果使用线程池,线程一直存在,value指向的传入值不会被自动回收,需使用 tl.remove() 去移除。

线程、进程

  1. 资源占用:进程是操作系统资源分配的最小单位,包含内存空间、文件句柄、设备的打开句柄等系统资源,而线程是进程中的一个实体,不拥有系统资源,只拥有一点在运行中必不可少的资源(如程序计数器、一组寄存器和栈)。
  2. 调度:进程与进程之间是相互独立的,调度和切换需要操作系统的介入,而线程是进程内的一个实体,它的调度和切换是由 CPU 完成的,不需要操作系统的介入,因此线程的调度和切换更加轻量级、高效。
  3. 通信:进程之间的通信需要使用如管道、消息队列、信号量、共享内存等,而线程之间共享进程的内存空间,可以使用共享变量、信号量等方式进行通信,因此线程之间的通信更加方便、快捷。
  4. 处理能力:由于进程拥有独立的内存空间和系统资源,进程之间的切换开销较大,因此在同一时间内,进程的处理能力较低,而线程之间共享进程的内存空间和系统资源,切换开销较小,因此可以提高处理能力。

总之,线程是进程的一部分,进程是资源分配的最小单位,而线程是操作系统能够进行运算调度的最小单位,线程的调度和切换更加轻量级、高效,线程之间的通信更加方便、快捷。

线程创建

  1. 继承 Thread 类,重写 run() 方法。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    class MyThread extends Thread {
    @Override
    public void run() {
    // 线程执行的代码
    }
    }
    // 创建线程并启动
    MyThread myThread = new MyThread();
    myThread.start();
  2. 实现 Runnable 接口,重写 run() 方法。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    class MyRunnable implements Runnable {
    @Override
    public void run() {
    // 线程执行的代码
    }
    }
    // 创建线程并启动
    MyRunnable myRunnable = new MyRunnable();
    Thread thread = new Thread(myRunnable);
    thread.start();
  3. 实现 Callable 接口,使用 FutureTask 包装 Callable 对象。

    Callable 接口与 Runnable 接口类似,但是 Callable 接口可以返回执行结果。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    class MyCallable implements Callable<Integer> {
    @Override
    public Integer call() throws Exception {
    // 线程执行的代码,返回一个整数
    return 1;
    }
    }
    // 创建Callable对象
    MyCallable myCallable = new MyCallable();
    // 创建FutureTask对象,将Callable对象传递给FutureTask构造函数
    FutureTask<Integer> futureTask = new FutureTask<>(myCallable);
    // 创建线程并启动
    Thread thread = new Thread(futureTask);
    thread.start();
    // 获取线程执行结果
    int result = futureTask.get();

当线程的执行逻辑比较简单时,可以使用 Lambda 表达式或匿名内部类来创建线程,代码更加简洁。

  1. 使用匿名内部类创建线程

    1
    2
    3
    4
    5
    6
    7
    Thread thread = new Thread(new Runnable() {
    @Override
    public void run() {
    // 线程执行的代码
    }
    });
    thread.start();
  2. 使用 Lambda 表达式创建线程

    注意以下代码创建的是 Runnable 线程。

    1
    2
    3
    4
    Thread thread = new Thread(() -> {
    // 线程执行的代码
    });
    thread.start();

同时可以使用线程池去管理和控制线程:

  1. FixedThreadPool

    1
    2
    3
    4
    5
    6
    7
    8
    ExecutorService executorService = Executors.newFixedThreadPool(10); // 创建一个包含10个线程的线程池
    executorService.execute(new Runnable() {
    @Override
    public void run() {
    // 线程执行的代码
    }
    });
    executorService.shutdown(); // 关闭线程池
  2. ScheduledThreadPool

    可以定时执行某个任务,也可以延迟一定时间后执行某个任务。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(10); // 创建一个包含10个线程的线程池
    scheduledExecutorService.schedule(new Runnable() {
    @Override
    public void run() {
    // 线程执行的代码
    }
    }, 10, TimeUnit.SECONDS); // 延迟10秒后执行
    scheduledExecutorService.scheduleAtFixedRate(new Runnable() {
    @Override
    public void run() {
    // 线程执行的代码
    }
    }, 0, 10, TimeUnit.SECONDS); // 每隔10秒执行一次
    scheduledExecutorService.shutdown(); // 关闭线程池
  3. ThreadPoolExecutor

    1
    2
    3
    4
    5
    6
    7
    8
    ThreadPoolExecutor executor = new ThreadPoolExecutor(5, 10, 60, TimeUnit.SECONDS, new LinkedBlockingQueue<Runnable>());
    executor.execute(new Runnable() {
    @Override
    public void run() {
    // 线程执行的代码
    }
    });
    executor.shutdown(); // 关闭线程池

各个线程池之间的区别:

  1. FixedThreadPool:该线程池中的线程数量是固定的,一旦创建就不能改变,如果线程池中的线程都处于忙碌状态,后续的任务将会进入等待队列中等待执行。性能稳定,但不能应对突发大量任务的情况。
  2. ScheduledThreadPool:该线程池可以按照指定的频率执行任务,也可以延迟指定的时间后执行任务。适用于需要定时执行任务的场景,比如定时任务、周期性任务等。
  3. ThreadPoolExecutor:该线程池是一个通用的线程池实现类,可以通过构造函数设置线程池的各种参数,如核心线程数、最大线程数、任务队列等。适用于各种场景,可以根据实际情况来灵活配置。

使用线程池的好处:

线程池是一种线程管理机制,它包含了一组线程,可以自动地创建、销毁和管理线程,以及调度和执行提交给它的任务。使用线程池的好处有以下几点:

  1. 降低线程创建和销毁的开销:线程的创建和销毁是比较耗时的操作,使用线程池可以重用已经创建的线程,减少这些开销。
  2. 提高系统的响应速度:在并发情况下,如果每个请求都需要创建一个新的线程来处理,可能会导致系统的负载过大,甚至崩溃。使用线程池可以控制并发线程的数量,避免系统负载过高。
  3. 提高线程的处理效率:线程池可以重用已经创建的线程,避免了重复创建和销毁的开销,从而提高线程的处理效率。
  4. 提高代码的可读性:将线程的创建、销毁和管理都封装在线程池中,可以提高代码的可读性和可维护性。

线程状态

Java线程状态包括以下几种:

  1. 新建状态(New):当使用 new 关键字创建一个线程时,线程处于新建状态。
  2. 就绪状态(Runnable):当线程调用 start() 方法后,线程处于就绪状态。此时,线程已经准备好运行,但是还没有得到 CPU 的执行权。
  3. 运行状态(Running):当线程获得 CPU 的执行权时,线程处于运行状态。此时,线程正在执行任务。
  4. 阻塞状态(Blocked):线程阻塞的原因可能是等待 I/O 操作等待锁或者调用了 sleep() 方法。当阻塞状态结束时,线程重新进入就绪状态。
  5. 等待状态(Waiting):当线程调用了 wait()join()park() 方法时,线程处于等待状态。此时线程正在等待其他线程的通知或中断。
  6. 超时等待状态(Timed Waiting):当线程调用了 sleep()wait(long)join(long)parkNanos() 或者 parkUntil() 方法时,线程处于超时等待状态。在 Waiting 的基础上增加了超时时间,在达到一定时间后会被自动唤醒。
  7. 终止状态(Terminated):当线程执行完任务或者抛出异常时,线程处于终止状态。此时,线程已经结束了执行。

sleep()、wait()

sleep() 和 wait() 都可以让线程进入等待状态,但是它们有以下不同点:

  1. 调用对象不同sleep() 方法是 Thread 类的静态方法,可以直接调用;而 wait() 方法是 Object 类的实例方法只能在同步代码块或同步方法中调用,并且必须在锁对象上调用
  2. 锁的释放不同:当线程调用 sleep() 方法时,线程不会释放锁,线程仍然持有锁;而当线程调用 wait() 方法时,线程会释放锁,并且进入对象的等待池中,等待被唤醒。
  3. 被唤醒的方式不同sleep() 方法被等待的时间到达后会自动返回,并且线程会重新进入就绪状态,等待 CPU 调度执行;而 wait() 方法只能通过 notify()notifyAll() 方法被唤醒。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class WaitExample {
private final Object lock = new Object();
public void doSomething() {
synchronized (lock) {
try {
// 等待其他线程通知
lock.wait();
// 执行其他操作
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
public void doNotify() {
synchronized (lock) {
// 通知其他线程
lock.notifyAll();
}
}
}

线程间通信

Java 中的 wait 和 notify 方法是用于实现线程间通信的机制。wait 方法会使当前线程进入等待状态,并释放当前线程持有的对象锁,直到其他线程调用该对象的 notify 方法或 notifyAll 方法来唤醒它;而 notify 方法则会随机唤醒等待该对象的一个线程。 具体来说,wait 方法和 notify 方法需要满足以下条件:

  1. wait 方法只能在同步代码块或同步方法中调用,否则会抛出 IllegalMonitorStateException 异常;
  2. 调用 wait 方法会使当前线程释放对象锁,进入等待状态,直到其他线程调用该对象的 notify 方法或 notifyAll 方法;
  3. 调用 notify 方法会随机唤醒等待该对象的一个线程;
  4. 调用 notifyAll 方法会唤醒等待该对象的所有线程,让它们竞争对象锁;

唤醒线程的顺序是随机的,不保证先唤醒等待时间最长的线程。 在实际编程中,waitnotify 方法通常与 synchronized 关键字一起使用,以确保线程安全。例如,在多线程环境下,一个线程可以等待另一个线程完成某个操作后才能继续执行,可以使用 wait 方法等待,另一个线程完成操作后可以调用 notify 方法唤醒等待的线程。 需要注意的是,wait 和 notify 方法必须在同一个对象上调用,否则会出现 IllegalMonitorStateException 异常。同时,wait 方法和 notify 方法也可能会存在死锁等问题,需要谨慎使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class WaitExample {
public synchronized void doSomething() {
try {
// 等待其他线程通知
this.wait();
// 执行其他操作
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public synchronized void doNotify() {
// 通知其他线程
this.notifyAll();
}
}

而在使用 lock 机制时,可以使用 await() 方法使线程等待某个条件,并使用 signal()signalAll() 方法来通知等待线程。各个方法的作用同上。

volatile

volatile 是 Java 中的一个关键字,用来声明变量是易变的(volatile variable),即该变量的值随时可能被其他线程修改。当一个变量被声明为 volatile 时,Java 会确保每次读取该变量时,都是从主存中读取最新的值,而不是从线程的本地缓存中读取。volatile 关键字保证了变量的可见性和有序性,但并不保证变量的原子性

可见性:在多线程编程中,当一个线程修改了一个变量的值后,其他线程可能无法立即看到该变量的新值,这就是线程间通信的问题。使用 volatile 关键字可以解决这个问题,因为它可以保证变量的可见性,即在一个线程修改了变量的值后,其他线程可以立即看到该变量的新值。

有序性: 对一个 volatile 变量的读,总是能看到(任意线程)对这个 volatile 变量最后的写入。

注意:使用 volatile 关键字并不能保证多线程程序的正确性,因为 volatile 只能保证变量的可见性和有序性,但并不保证操作的原子性。如果需要保证操作的原子性,应该使用 synchronized 或者使用 Java.util.concurrent.atomic 包中提供的原子类。

1
2
3
4
5
6
7
8
9
10
11
12
public class VolatileDemo {
private volatile boolean flag = false;
public void setFlag() {
flag = true; // 对 flag 进行写操作
}
public void waitForFlag() {
while (!flag) {
// 对 flag 进行读操作
}
System.out.println("Flag is now true");
}
}

在上面的示例中,由于 waitForFlag 方法中对 flag 进行了循环读取,因此在不使用 volatile 关键字的情况下,可能会出现无限循环的问题,因为线程可能无法看到 flag 的新值。

为什么不能保证原子性呢?请看代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class Test{
public volatile int inc = 0;

public void increase(){
inc++;
}

public static void main(String[] args){
final Test test = new Test();
for(int i=0;i<3;i++){
new Thread(()->{
for(int j=0;j<1000;j++){
test.increase();
};
}).start();
}
while(Thread.activeCount()>1) // 保证所有线程都执行完
Thread.yield();
System.out.println(test.inc); // inc的输出结果比3000小
}
}

线程安全

线程安全是指多个线程访问同一个对象时,不会出现数据不一致、数据丢失等问题,保证程序的正确性和稳定性。 保证线程安全的方法有以下几种:

  1. 使用同步方法或同步块:使用 synchronized 关键字同步方法或同步块,可以保证同一时间只有一个线程访问共享资源,避免出现多个线程同时访问修改共享资源导致的问题。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    public class ThreadSafeExample {
    private int count = 0;
    public synchronized void incrementCount() {
    count++;
    }
    public synchronized int getCount() {
    return count;
    }
    }
  2. 使用原子类:Java 提供了一些原子类,如 AtomicIntegerAtomicLong 等,这些原子类的方法具有原子性,可以保证多个线程访问时的数据安全。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    public class ThreadSafeExample {
    private AtomicInteger count = new AtomicInteger(0);
    public void incrementCount() {
    count.incrementAndGet();
    }
    public int getCount() {
    return count.get();
    }
    }
  3. 使用线程安全的集合类:Java 提供了一些线程安全的集合类,如 ConcurrentHashMapCopyOnWriteArrayList 等,这些集合类的方法都是线程安全的,可以保证多个线程访问时的数据安全。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    public class ThreadSafeExample {
    private List<String> list = new CopyOnWriteArrayList<>();
    public void addToList(String str) {
    list.add(str);
    }
    public List<String> getList() {
    return list;
    }
    }
  4. Lock 锁:Java 提供了 Lock 接口和 ReentrantLock 实现类,使用 Lock 锁可以手动控制线程的同步访问,实现线程安全。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    public class ThreadSafeExample {
    private int count = 0;
    private Lock lock = new ReentrantLock();
    public void incrementCount() {
    lock.lock();
    try {
    count++;
    } finally {
    lock.unlock();
    }
    }
    public int getCount() {
    lock.lock();
    try {
    return count;
    } finally {
    lock.unlock();
    }
    }
    }
  5. 线程局部变量:Java 提供了 ThreadLocal 类,可以将某个变量绑定到当前线程上,每个线程都拥有自己的变量副本,互不干扰,从而实现线程安全。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    public class ThreadSafeExample {
    private ThreadLocal<Integer> count = new ThreadLocal<Integer>() {
    // 创建线程局部变量的初始值
    @Override
    protected Integer initialValue() {
    return 0;
    }
    };
    public void incrementCount() {
    count.set(count.get() + 1);
    }
    public int getCount() {
    return count.get();
    }
    }
  6. CountDownLatch:是 Java 中的一个同步辅助类,可以让一个或多个线程等待其他线程完成执行后再继续执行。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    import java.util.concurrent.CountDownLatch;
    public class ThreadSafeDemo {
    private static final int THREAD_NUM = 10;
    private static CountDownLatch latch = new CountDownLatch(THREAD_NUM);
    public static void main(String[] args) throws InterruptedException {
    for (int i = 0; i < THREAD_NUM; i++) {
    new Thread(() -> {
    Thread.sleep(1000);
    latch.countDown();
    }).start();
    }
    latch.await(); // 等待所有线程执行完毕
    }
    }
  7. Semaphore:是 Java 中的一个同步辅助类,可以控制同时访问某个资源的线程数量。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    import java.util.concurrent.Semaphore;
    public class SemaphoreExample {
    public static void main(String[] args) {
    Semaphore semaphore = new Semaphore(3);
    for (int i = 0; i < 6; i++) {
    new Thread(() -> {
    try {
    semaphore.acquire(); // 获取许可证,如未获取到则等待
    System.out.println("Thread " + Thread.currentThread().getName() + " is running.");
    Thread.sleep(2000);
    System.out.println("Thread " + Thread.currentThread().getName() + " is done.");
    } catch (InterruptedException e) {
    e.printStackTrace();
    } finally {
    semaphore.release(); // 释放许可证
    }
    }).start();
    }
    }
    }

CAS

CAS 是 Compare and Swap(比较并交换)的缩写,是一种用于实现多线程同步的算法。

CAS 操作在 Java 中广泛应用于实现原子操作,即在不使用锁的情况下,保证对变量的操作是原子性的。它通过比较内存中的值和期望的值是否相等来确定是否需要进行更新操作。通常用于实现线程安全的计数器、队列等数据结构。

AtomicInteger 类为例,它提供了三个方法来实现对变量的原子操作,分别是 get()set()compareAndSet()。其中,get() 和 set() 方法分别用于获取和设置变量的值,而 compareAndSet() 方法用于实现 CAS 操作。compareAndSet() 方法接受两个参数,分别是期望值新值,如果当前值等于期望的值,则将新值写入变量,并返回 true,否则不进行任何操作,并返回 false。这样就可以保证对变量的操作是原子性的。 原子类的实现是基于 CPU 的硬件支持,这种方式可以避免线程间的竞争和死锁问题,从而实现高效的原子操作。

AQS

AQS 是 AbstractQueuedSynchronizer 的缩写,是 Java 并发包中非常重要的一个基础类。它提供了一种用于实现同步器的框架,包括 ReentrantLock、Semaphore、CountDownLatch、ReentrantReadWriteLock 等常见的同步类都是基于 AQS 实现的。

主要思想:通过一个先进先出的队列来实现线程的等待和唤醒。具体来说,AQS 维护一个双向队列,队列中的每个节点都代表一个等待线程。当某个线程需要获取同步器的锁时,它会尝试将自己加入到队列的尾部,然后进入等待状态。当同步器的锁被释放时,AQS 会从队列的头部取出一个节点,将其对应的线程唤醒。 除了队列之外,AQS 还维护了一个 int 类型的变量 state,用于表示同步器的状态。具体来说,state 的值为 0 表示同步器未被占用,大于 0 表示同步器被占用,小于 0 表示同步器被占用且有等待线程。

核心方法:是 acquire()release(),它们用于获取和释放同步器的锁,具体的实现则由子类来完成。子类需要实现 tryAcquire()tryRelease() 方法,用于尝试获取和释放锁。另外,AQS 还提供了许多辅助方法,如 hasQueuedPredecessors()、getState()、compareAndSetState() 等。

Java锁

锁是一种同步机制,它用于控制多个线程对共享资源的访问。当一个线程获取了锁之后,其他线程就不能访问该共享资源,直到该线程释放了锁,其他线程才能获取锁并访问共享资源。 在 Java 中,主要有以下几种锁:

  1. synchronized锁:synchronized 关键字可以用于同步方法或同步块,它实现了对象级别的锁,可以保证同一时间只有一个线程访问同一个对象的同步方法或同步块。
  2. ReentrantLock锁:ReentrantLock 是 Java 提供的一个可重入的互斥锁,它提供了更强大、更灵活的锁机制。与 synchronized 锁相比,ReentrantLock 锁具有更高的可扩展性和可定制性,它可以实现公平锁和非公平锁、可重入锁等多种锁类型。
  3. ReadWriteLock锁:ReadWriteLock 是 Java 提供的一种读写锁机制,读写锁可以同时允许多个线程读资源,但只允许一个线程写资源。与普通的互斥锁相比,读写锁可以提高系统的并发性能。
  4. StampedLock锁:StampedLock 是 Java8 中新增的一种锁机制,它提供了一种乐观锁的机制,可以提高读操作的并发性能。StampedLock 支持三种模式:写锁(排它锁)、悲观读锁(共享锁)和乐观读锁(无锁),并且支持锁降级,即从写锁降级为悲观读锁或乐观读锁。
  5. synchronized关键字和Lock接口中的Condition接口:Java 提供了 Condition 接口,它只能与显示锁(如 synchronized 锁和 ReentrantLock 锁)一起使用,用于实现线程之间的等待和唤醒机制。Condition 接口提供了 await()、signal() 和 signalAll() 等方法,可以实现更灵活的等待/唤醒机制。

synchronized、Lock

synchronized 和 Lock 都是 Java 中用于实现线程同步的关键字和类,它们的主要区别如下:

  1. 使用方式:synchronized 是一种内置锁,使用方式比较简单,只需在方法或代码块前加上 synchronized 关键字即可,而 Lock 是一种显式锁,需要手动获取和释放,需要使用 Lock 对象的 lock() 方法获取锁,unlock() 方法释放锁。
  2. 能否中断等待:synchronized 不支持线程中断,即一个线程获取到了锁,其他线程只能等待它释放锁才能继续执行,无法中断等待;而 Lock 支持线程中断,可以在等待锁的过程中中断等待。
  3. 是否可重入:synchronized 是可重入锁,即一个线程可以多次获取同一个锁,而不会死锁;而 Lock 也是可重入锁,但需要手动实现。
  4. 是否公平:synchronized 是非公平锁,即线程获取锁的顺序是随机的,不考虑等待时间;而 Lock 可以是非公平锁或公平锁,公平锁保证线程获取锁的顺序按照等待时间的先后顺序执行。
  5. 性能:synchronized 是 JVM 实现的内置锁,可以在某些情况下比 Lock 更高效;而 Lock 是基于 Java 实现的锁,需要手动获取和释放,相对来说会有更多的开销。

一般来说,如果只是简单的线程同步,使用 synchronized 更为方便;如果需要更多的同步功能,例如可中断等待、公平锁等,使用 Lock 更为灵活。

死锁避免

死锁是指两个或多个进程(线程)互相持有对方所需资源的锁,导致所有进程(线程)都无法继续执行,陷入无限等待的状态。 避免死锁的一些方法:

  1. 避免使用多个锁。如果可能的话,尽量使用单个锁来保护多个资源。
  2. 保持锁的顺序。如果必须使用多个锁来保护多个资源,那么保持获取锁的顺序一致,可以避免死锁。
  3. 使用超时机制。在获取锁时,使用超时机制,如果在指定时间内无法获取到锁,就放弃锁并释放资源。
  4. 使用死锁检测。在程序运行时,定期检测是否存在死锁,如果存在,则通过释放资源或撤销进程来解决死锁。

反射机制

Java 反射是指在程序运行时动态地获取类的信息。通过反射机制。可以实现如下的操作:

  • 程序运行时,可以通过反射获得任意一个类的 Class 对象,并通过这个对象查看这个类的信息。

    1
    2
    3
    1. 调用对象的 getClass() 方法。
    2. 使用 Class.forName() 方法。
    3. 使用 类名.class 语法。
  • 程序运行时,可以通过反射创建任意一个类的实例,并访问该实例的成员。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    public class MyClass {
    private String name;
    public MyClass() {
    }
    public MyClass(String name) {
    this.name = name;
    }
    public String getName() {
    return name;
    }
    }
    public class Test {
    public static void main(String[] args) throws Exception {
    // 获取MyClass类的Class对象
    Class<?> clazz = Class.forName("MyClass");
    // 使用无参构造方法创建MyClass实例
    MyClass myClass1 = (MyClass) clazz.newInstance();
    System.out.println(myClass1.getName()); // 输出null
    // 使用有参构造方法创建MyClass实例
    Constructor<?> constructor = clazz.getConstructor(String.class);
    MyClass myClass2 = (MyClass) constructor.newInstance("hello");
    System.out.println(myClass2.getName()); // 输出hello
    }
    }

    如果该类的构造方法是私有的,需要使用 setAccessible() 方法将其设置为可访问,才能使用反射机制创建实例。

    1
    2
    3
    4
    5
    6
    7
    // 获取MyClass类的私有构造方法
    Constructor<?> constructor = clazz.getDeclaredConstructor(String.class);
    // 将构造方法设置为可访问
    constructor.setAccessible(true);
    // 使用私有构造方法创建MyClass实例
    MyClass myClass = (MyClass) constructor.newInstance("hello");
    System.out.println(myClass.getName()); // 输出hello
  • 程序运行时,可以通过反射机制生成一个类的动态代理对象,实现对目标对象的代理操作。实现步骤如下:

    1、定义一个实现了 InvocationHandler 接口的类来实现代理逻辑;

    2、使用 Proxy 类的 newProxyInstance() 方法创建代理对象;

    3、将代理对象转换为目标对象的类型,然后使用代理对象调用目标对象的方法即可。

    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
    public interface MyInterface {
    void sayHello();
    }
    public class MyInterfaceImpl implements MyInterface {
    @Override
    public void sayHello() {
    System.out.println("Hello");
    }
    }
    public class MyInvocationHandler implements InvocationHandler {
    private Object target;
    public MyInvocationHandler(Object target) {
    this.target = target;
    }
    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
    System.out.println("Before method invoke");
    Object result = method.invoke(target, args);
    System.out.println("After method invoke");
    return result;
    }
    }
    public class Test {
    public static void main(String[] args) {
    // 创建目标对象
    MyInterface myInterface = new MyInterfaceImpl();
    // 创建代理对象
    MyInvocationHandler myInvocationHandler = new MyInvocationHandler(myInterface);
    MyInterface myProxy = (MyInterface) Proxy.newProxyInstance(
    MyInterfaceImpl.class.getClassLoader(),
    MyInterfaceImpl.class.getInterfaces(),
    myInvocationHandler);
    // 调用代理对象的方法
    myProxy.sayHello();
    }
    }

反射的应用场景:

  • XML 配置文件中配置的类解析出来的是字符串,需要利用反射机制实例化。

    1
    2
    3
    4
    5
    // 从xml配置中获取类名
    String className = "com.example.MyClass";
    // 加载类并创建实例
    Class<?> clazz = Class.forName(className);
    Object instance = clazz.newInstance();
  • 面向切面编程(AOP)的实现方案,是在程序运行时创建目标对象的代理类,这必须由反射机制来实现。

静态/动态代理

静态代理和动态代理都是代理模式的实现方式,它们的区别在于创建代理对象的时机和方式不同。

  • 静态代理的代理类是在编译时就已经存在的,代理类的代码是开发人员手动编写的。代理类实现了被代理对象的接口,并持有一个实际对象的引用,代理类中的方法在调用实际对象的方法前后添加一些自己的逻辑,从而实现代理的功能。
  • 动态代理是在运行时动态创建代理对象的方式,代理类的代码是自动生成的。动态代理可以根据需要代理不同的对象,无需手动编写代理类。Java 中提供了一个 Proxy 类,可以用于创建动态代理对象。

动态代理已经在上文实现过了,以下为静态代理实现流程:

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
import java.util.List;
interface UserDao {
void addUser(String name);
}
class UserDaoImpl implements UserDao {
@Override
public void addUser(String name) {
...
}
}
class UserDaoProxy implements UserDao {
// 持有目标对象的引用
private UserDao target;

public UserDaoProxy(UserDao target) {
this.target = target;
}
// 增强
@Override
public void addUser(String name) {
System.out.println("UserDaoProxy.addUser() before");
target.addUser(name);
System.out.println("UserDaoProxy.addUser() after");
}
}
public class Main {
public static void main(String[] args) {
UserDao userDao = new UserDaoImpl();
UserDao userDaoProxy = new UserDaoProxy(userDao);
userDaoProxy.addUser("Alice");
}
}

单例模式

一个类只对外提供一个实例。

1
2
3
4
5
6
7
8
9
10
public class Singleton {
private static Singleton instance; // 私有静态变量,用于保存单例实例
private Singleton() {} // 私有构造函数,禁止外部实例化
public static Singleton getInstance() { // 公有静态方法,获取单例实例
if (instance == null) { // 如果实例不存在,则创建一个新实例
instance = new Singleton();
}
return instance; // 返回单例实例
}
}

工厂模式

不暴露创建对象的具体逻辑,而是将逻辑封装在一个函数中,那么这个函数就可以被视为一个工厂。

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
// 图形接口
public interface Shape {
void draw();
}
// 圆形实现类
public class Circle implements Shape {
@Override
public void draw() {
System.out.println("Drawing a Circle");
}
}
// 矩形实现类
public class Rectangle implements Shape {
@Override
public void draw() {
System.out.println("Drawing a Rectangle");
}
}
// 工厂类
public class ShapeFactory {
public Shape createShape(String shapeType) {
if (shapeType == null) {
return null;
} else if (shapeType.equals("Circle")) {
return new Circle();
} else if (shapeType.equals("Rectangle")) {
return new Rectangle();
} else {
return null;
}
}
}
// 客户端代码
public class Client {
public static void main(String[] args) {
ShapeFactory factory = new ShapeFactory();
Shape shape1 = factory.createShape("Circle");
shape1.draw();
Shape shape2 = factory.createShape("Rectangle");
shape2.draw();
}
}

MySQL

ACID

  • 原子性 (A):指一个事务中的操作要么都做,要么都不做。由 undolog 日志来保证,它记录了需要回滚的日志信息,事务回滚时撤销已经执行成功的 sql
  • 一致性 (C):事务追求的最终目标,由其他三大特性保证。
  • 隔离性 (I):保证事务执行尽可能不受其他事务影响。
    • (一个事务)写操作对(另一个事务)写操作的影响:锁机制保证隔离性
    • (一个事务)写操作对(另一个事务)读操作的影响:MVCC保证隔离性
  • 持久性 (D):保证事务提交后不会因为宕机等原因导致数据丢失。由 redolog 保证,mysql 修改数据的时候会在 redolog 中记录一份日志数据,就算数据没有修改成功,只要日志保存成功了,数据仍然不会丢失。

MVCC

全称 Multi-Version Concurrency Control,即多版本并发控制,主要是为了提高数据库读写时的并发性能

同一行数据平时发生读写请求时,会上锁阻塞住。但 mvcc 用更好的方式去处理读写请求,做到在发生读写请求冲突时不用加锁。这个读指快照读,可能是之前历史版本的数据。

它主要是由版本链undologRead View来实现。

版本链

数据库中的每行数据,除了肉眼可见的数据,还有几个隐藏字段。分别是 db_trx_iddb_roll_pointerdb_row_id

  • db_trx_id

    记录创建这条记录或者最后一次修改该记录的事务ID

  • db_roll_pointer

    回滚指针,指向这条记录上一个版本

  • db_row_id

    隐含的自增ID(隐藏主键),如果数据表没有主键,InnoDB 会自动以 db_row_id 产生一个聚簇索引

  • 实际还有一个删除flag隐藏字段, 记录被更新删除并不代表真的删除,而是删除flag变了。

每次对数据库记录进行改动,都会记录一条 undolog,每条 undolog 也都有一个 roll_pointer 属性(INSERT 操作对应的 undolog 没有该属性,因为该记录并没有更早的版本),可以将这些undolog都连起来串成一个链表

Undo Log

undolog 主要用于记录数据被修改之前的日志,在表信息修改之前先会把数据拷贝到undolog里。当事务进行回滚时可以通过 undolog 里的日志进行数据还原

  • 保证事务进行 rollback 时的原子性,当事务进行回滚的时候可以用 undolog 的数据进行恢复
  • 用于 MVCC 快照读的数据,通过读取 undolog历史版本数据可以实现不同事务版本号都拥有自己独立的快照数据版本

undolog 主要分为两种:

  • insert undo log

    代表事务在 insert 新记录时产生的 undolog , 只在事务回滚时需要,并且在事务提交后可以被立即丢弃。

  • update undo log

    事务在进行 updatedelete 时产生的 undolog,不仅在事务回滚时需要,在快照读时也需要。只有在快速读或事务回滚不涉及该日志时才会被清除。

Read View

事务进行快照读操作的时候生产的读视图(Read View),在该事务执行快照读的那一刻,会生成数据库系统当前的一个快照。记录并维护系统当前活跃事务的ID(当每个事务开启时,都会被分配一个 ID, 这个 ID 是递增的,所以越新的事务 ID 值越大),即还没有提交的事务。

某个事务执行快照读的时候,对该记录创建一个 Read View 读视图,把它比作条件用来判断当前事务能够看到哪个版本的数据,既可能是当前最新的数据,也有可能是该行记录的 undolog 里面的某个版本的数据。

Read View 的几个属性:

  • trx_ids:当前系统活跃(未提交)事务 ID 集合。
  • low_limit_id:当前系统活跃的最大事务ID + 1
  • up_limit_id:当前系统活跃的最小事物ID
  • creator_trx_id:当前事务ID

Read View 的可见性判断:

  • db_trx_id < up_limit_id || db_trx_id == creator_trx_id (显示)

    如果数据事务ID小于最小活跃事务ID,则可以肯定该数据是在当前事务启之前就已经存在了的,所以可以显示

    或者数据事务ID等于当前事务ID ,那么说明这个数据就是当前事务自己生成的,自己生成的数据自己当然能看见,所以这种情况下此数据也是可以显示的。

  • db_trx_id >= low_limit_id(不显示)

    如果数据事务ID大于当前系统的最大活跃事务ID,则说明该数据是在当前事务之后才产生的,所以数据不显示。如果小于则进入下一个判断。

  • db_trx_id 是否在活跃事务(trx_ids)中

    • 不存在:则说明 read view 产生的时候事务已经提交了,这种情况数据则可以显示
    • 已存在:则代表 read view 生成时刻,这个事务还在活跃,还没有提交,所以修改的数据当前事务看不见,不显示

MySQL隔离级别

  1. read uncommitted:读未提交(最低隔离级别)

    事务A可以读取事务B未提交的数据。存在脏读现象。一般不用。

  2. read committed:读已提交(Oracle默认隔离级别)

    事务A只能读取事务B提交之后的数据。解决了脏读现象。但是存在不可重复读现象。

    eg:A事务开启,读取了某条数据,此时事务B提交了事务改变了这条数据,事务A之后读取到这条数据的内容就出现了不一致现象,A事务结束

  3. repeatable read:可重复读(MySQL默认隔离级别)

    事务A开启后,不管多久,每次事务A读取的数据都是一致的,即使事务B将数据修改了。解决了不可重复读,但存在幻读

    eg:事务A查询数据库查询出来了20条数据,然后事务B删除了2条数据,这时候事务A再去查询发现只有18条了。

  4. serializable:序列化/串行化(最高隔离级别)

    效率最低,但是解决了所有问题。表示事务排队,不能并发。

隔离级别 脏读 不可重复读 幻读
读未提交 可能 可能 可能
读已提交 不可能 可能 可能
可重复读 不可能 不可能 可能
串行化 不可能 不可能 不可能

RC 隔离级别下,每个快照读都会生成并获取最新Read View,解决了脏读,而在 RR 隔离级别下,则是同一个事务中第一个快照读才会创建Read View, 之后的快照读获取的都是同一个Read View,之后的查询就不会重复生成了,解决了不可重复读

以下举例 RR 隔离级别下产生幻读的一些场景:

  1. eg:事务A开启事务,期间事务B删除了某条数据并提交,之后事务A查询的数据的区间如果包含了事务B删除的记录,那么事务A将拿不到删除的记录,出现了幻读。
  2. eg:事务A开启事务,期间如果事务A修改了某条原本对事务A不可见的数据(巧合),之后事务A查询的数据的区间如果包含了这条记录,那么事务A将拿到这条记录。出现了幻读。

不可重复读幻读的差别:

不可重复读指的是数据不一致问题,例如两次查找的数据内容不一致。而幻读指的是两次查找出来的记录数不同。

MySQL索引结构

MySQL 的 InnoDB 存储引擎默认使用 B+ 树作为索引底层数据结构。

各种数据结构的比较:

  • 哈希表 (Hash)

    哈希算法:把值通过哈希函数变换为固定长度的地址,通过这个地址进行数据的存储。只需要计算一次就能获取对应的数据,单一查找非常快,但是没法高效的做范围查找。

  • 二叉树(BST)

    极端情况下会退化成线性链表,这时只能遍历查找,检索性能急剧下降。

  • 红黑树

    当二叉树处于不平衡状态时,红黑树会自动调整树的形态,使其保持基本的平衡状态。但是当数据量够多时同样会造成右倾。

  • AVL 树

    是一个绝对平衡的二叉树,所以在调整二叉树的形态上消耗的性能会比红黑树更多,并且每个节点只存储一个数据,会造成树太深,增加磁盘 IO。

  • B 树

    每个节点存多个数据,降低树的高度,减少磁盘 IO。

  • B+ 树

    非叶子节点存多个索引,所以单个节点的存储量比 B 树更多,树的高度比 B 树还低,更能减少磁盘 IO。叶子节点是数据存储的地方,叶子之间使用链表连接,这个链表本身就是有序的,在数据范围查找时,更具备效率。

MySQL索引类型

  • 普通索引:允许被索引的数据列包含重复值。
  • 唯一索引:被索引的数据列不能包含重复值。
  • 主键索引:用于唯一标识一条记录。
  • 复合索引:索引可以覆盖多个数据列。
  • 全文索引:只有 MyISAM 引擎支持,对文本的内容进行分词搜索。是为了解决 WHERE name LIKE '%word%' 这类针对文本的模糊查询效率较低的问题。

通过使用索引,可以提高数据的查询速度,但是会降低插入、删除、修改的速度,因为需要更新索引。

Innodb 中,一旦主键索引改变,其他非主键索引都要跟着改变。

MySQL索引设计

  1. 选择唯一性索引

    唯一性索引的值是唯一的,可以更快速的通过该索引来确定某条记录。

  2. 为经常需要排序、分组和联合操作的字段建立索引

    经常需要 ORDER BY、GROUP BY、DISTINCT 和 UNION 等操作的字段,排序操作会浪费很多时间。如果为其建立索引,可以有效地避免排序操作。

  3. 为常作为查询条件的字段建立索引

  4. 限制索引的数目

    索引的数目不是越多越好。每个索引都需要占用磁盘空间,索引越多,需要的磁盘空间就越大。修改表时,对索引的重构和更新很麻烦。越多的索引,会使更新表变得很浪费时间。

  5. 尽量使用数据量少的索引

    如果索引的值很长,那么查询的速度会受到影响。例如,对一个 CHAR(100) 类型的字段进行全文检索需要的时间肯定要比对 CHAR(10) 类型的字段需要的时间要多。

  6. 尽量使用前缀来索引

    如果索引字段的值很长,最好使用值的前缀来索引。例如,TEXTBLOG 类型的字段,进行全文检索会很浪费时间。如果只检索字段的前面的若干个字符,这样可以提高检索速度。

  7. 删除不再使用或者很少使用的索引

    表中的数据被大量更新,或者数据的使用方式被改变后,原有的一些索引可能不再需要。数据库管理员应当定期找出这些索引,将它们删除,从而减少索引对更新操作的影响。

  8. 最左前缀匹配原则,非常重要

    mysql 会一直向右匹配直到遇到范围查询 (>、<、between、like) 就停止匹配,比如 a ='1' and b='2' c > 3 and d = 4 如果建立(a,b,c,d) 顺序的索引,d 是用不到索引的,如果建立 (a,b,d,c) 的索引则都可以用到,a,b,d 的顺序可以任意调整。

  9. = 和 in 可以乱序

    比如a = 1 and b = 2 and c = 3 建立 (a,b,c) 索引可以任意顺序,mysql 的查询优化器会帮你优化成索引可以识别的形式。

  10. 尽量选择区分度高的列作为索引

    区分度的公式是 count(distinct col)/count(*),表示字段不重复的比例,比例越大需要扫描的记录数越少,唯一键的区分度是1,而一些状态、性别字段可能在大数据面前区分度就是 0

  11. 索引列不能参与计算,保持列干净

    比如 from_unixtime(create_time) = '2014-05-29' 就不能使用到索引,原因很简单,b+树中存的都是数据表中的字段值,在进行检索时,需要把所有元素都应用函数才能比较,显然成本太大。所以语句应该写成 create_time = unix_timestamp('2014-05-29')

  12. 尽量的扩展索引,不要新建索引

    比如表中已经有 a 的索引,现在要加(a,b) 索引,那么只需要修改原来的索引即可。

  13. 当单个索引字段查询数据很多,区分度都不是很大时,则需要考虑建立联合索引来提高查询效率

MySQL优化查询

  1. 应尽量避免在 where 子句中对字段进行 null 值判断,否则将导致引擎放弃使用索引而进行全表扫描

    如:select id from t where num is null 可以在 num 上设置默认值 0,确保表中 num 列没有 null 值,然后这样查询:select id from t where num=0

  2. 应尽量避免在 where 子句中使用 !=<> 操作符,否则引擎将放弃使用索引而进行全表扫描。

  3. 应尽量避免在 where 子句中使用 or 来连接条件,否则将导致引擎放弃使用索引而进行全表扫描

    如:select id from t where num=10 or num=20 可以这样查询:select id from t where num=10 union all select id from t where num=20

  4. innot in 也要慎用,否则会导致全表扫描

    如:select id from t where num in(1,2,3) 对于连续的数值,能用 between 就不要用 inselect id from t where num between 1 and 3

  5. 很多时候用 exists 代替 in 是一个好的选择

    exists 用于检查子查询是否至少会返回一行数据,该子查询实际上并不返回任何数据,而是返回值 truefalse

    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)

  6. 任何地方都不要使用 select * from t,用具体的字段列表代替 '*',不要返回用不到的任何字段。

  7. >= 替代 >

    高效: SELECT * FROM EMP WHERE DEPTNO >= 4

    低效: SELECT * FROM EMP WHERE DEPTNO >3

    两者的区别在于,前者 DBMS 将直接跳到第一个 DEPTNO=4 的记录,而后者将首先定位到 DEPTNO=3 的记录并且向前扫描到第一个 DEPTNO 大于 3 的记录。

  8. Where 子句替换 having 子句

  9. 尽量使用数字型字段

    若只含数值信息的字段尽量不要设计为字符型,这会降低查询和连接的性能,并会增加存储开销。这是因为引擎在处理查询和连接时会逐个比较字符串中每一个字符,而对于数字型而言只需要比较一次就够了。

  10. 尽可能的使用 varchar/nvarchar 代替 char/nchar

    首先变长字段存储空间小,可以节省存储空间,其次对于查询来说,在一个相对较小的字段内搜索效率显然要高些。

MySQL锁

基于锁模式分类:乐观锁悲观锁

  • 乐观锁

    在数据进行提交更新的时候,才会正式对数据的冲突与否进行检测。适用于读多写少,因为如果出现大量的写操作,写冲突的可能性就会增大,业务层需要不断重试,会大大降低系统性能。

  • 悲观锁

    每次处理数据的时候都会将数据进行锁定。适用于并发量不大、写入操作多的场景。

基于锁粒度分类:行级锁(innodb)、页级锁(innodb)、表级锁(innodb、myisam)、全局锁

  • 行级锁

    锁住表中一行或多行记录,行级锁是粒度最低的锁,发生锁冲突的概率也最低、并发度最高。但是加锁慢、开销大,容易发生死锁现象。只有 InnoDB 支持行级锁,行级锁分为共享锁排他锁

  • 页级锁

    表级锁速度快,但冲突多,行级锁冲突少,但速度慢。因此,采取了折中的页级锁,一次锁定相邻的一组记录。

  • 表级锁

    对整张表加锁,用于给表加字段、修改字段、加索引等。

  • 全局锁

    对整个数据库实例加锁,用于全库备份。业务基本上就停止了。

基于锁属性分类:共享锁排它锁

  • 共享锁

    又称为读锁,简称 S 锁,当数据被加上读锁后,其他事务只能对该数据加读锁,不能做任何修改操作,也就是不能添加写锁。只有当读锁被释放后,其他事务才能对其添加写锁。共享锁主要是为了支持并发的读取数据而出现的,读取数据时,不允许其他事务对当前数据进行修改,从而避免不可重复读的问题的出现。

  • 排他锁

    又称为写锁,简称 X 锁,当对数据加上写锁后,其他事务不能对该数据添加读锁或写锁。只有写锁被释放后,其他事务才能对其添加写锁或者是读锁。写锁主要是为了解决在修改数据时,不允许其他事务对当前数据进行修改和读取操作,从而可以有效避免脏读问题的产生。

基于锁状态分类:意向共享锁意向排它锁

  • InnoDB 支持多粒度锁,它允许行级锁与表级锁共存,而意向锁就是其中的一种表锁。意向锁是有数据引擎自己维护的,用户无法手动操作意向锁,在为数据行加共享 / 排他锁之前,InooDB 会先获取该数据行所在在数据表的对应意向锁。如果另一事务试图在该表级别上应用共享或排它锁,则将受到由第一个事务控制的表级别意向锁的阻塞。第二个事务在确定能不能加锁前不必检查各个页或行,而只需检查表上的意向锁,提高性能。

基于锁算法分类:记录锁间隙锁临键锁

  • 记录锁

    记录锁是封锁记录,也叫行锁。只不过只能锁住一条记录。

  • 间隙锁

    属于行级锁,它锁定一段范围内的记录。使用间隙锁锁住的是一个左开右闭区间,是为了解决幻读引入的锁机制。只存在于 RR 隔离级别中。

  • 临键锁

    属于行级锁,记录锁与间隙锁的组合,它的封锁范围是一个闭区间。同样用于避免幻读。只存在于 RR 隔离级别中。

Myisam、Innodb

  1. InnoDB 支持事务,MyISAM 不支持,对于 InnoDB 每一条 SQL 语言都默认封装成事务,自动提交,这样会影响速度,所以最好把多条 SQL 语言放在 begin 和 commit 之间,组成一个事务;

  2. InnoDB 支持外键,而 MyISAM 不支持。对一个包含外键的 InnoDB 表转为 MYISAM 会失败;

  3. InnoDB 是聚簇索引,使用 B+Tree 作为索引结构,数据文件是和(主键)索引绑在一起的,必须要有主键,通过主键索引效率很高。但是辅助索引需要两次查询(辅助索引也是按B+Tree组织的一个索引结构),先查询到主键,然后再通过主键查询到数据。

#{} 和 ${}

  • #{} 是预编译处理,${} 是字符串替换。
  • 在处理 #{} 时,会将 sql 中的 #{} 替换为 ? 号,调用 PrepareStatementset 方法进行赋值。
  • 在处理 ${} 时,就是把 ${} 替换成变量的值。
  • 使用 #{} 可以有效的防止 SQL 注入

eg:以 "admin' or 1=1" 举例:

1
2
select * from user where username = '${username}' --> select * from user where username = 'admin' or 1=1'
select * from user where username = #{username} --> select * from user where username = 'admin\' or 1=1'

Spring生态

SpringBoot自动装配

Spring Boot 中,通过引入对应的 starter 即可引入第三方依赖。通过少量注解和一些简单的配置就能使用第三方组件提供的功能。

Spring Boot 通过@EnableAutoConfiguration开启自动装配,通过 SpringFactoriesLoader 最终加载 META-INF/spring.factories 中的自动配置类 @Configuration 实现自动装配,自动配置类通过@Conditional按需加载配置。

SpringMVC工作流程

image-20230301213258246

  1. 用户发送请求至前端控制器 DispatcherServlet
  2. DispatcherServlet 收到请求调用处理器映射器 HandlerMapping
  3. 处理器映射器根据请求 url 找到具体的处理器,生成处理器执行链 HandlerExecutionChain (包括处理器对象和处理器拦截器)返回给 DispatcherServlet
  4. DispatcherServlet 根据 Handler 种类获取对应的处理器适配器 HandlerAdapter 并执行其中的 handle 方法,也就是调用处理器 Handler (Controller,也叫页面控制器),执行完成返回 ModelAndView
  5. HandlerAdapterHandler 执行结果 ModelAndView 返回到 DispatcherServlet
  6. DispatcherServletModelAndView 传给 ViewReslover 视图解析器;
  7. ViewReslover 解析后返回具体 View
  8. DispatcherServletView 进行渲染视图(即将模型数据 model 填充至视图中),响应用户。

SpringMVC九大组件

  1. HandlerMapping

    根据请求找到相应的处理器,可以是类也可以是方法。比如,标注了@RequestMapping 的每个 method 都可以看成是一个Handler,由 Handler 来负责实际的请求处理。

  2. HandlerAdapter

    因为 SpringMVC 中的 Handler 有多种实现方式(Controller,HttpRequestHandler,Servlet等),但是 Servlet 需要的处理方法的结构却是固定的,都是以 requestresponse 为参数的方法。如何让固定的 Servlet 处理方法调用灵活的 Handler 来进行处理,这就是 HandlerAdapter要做的事情。

  3. HandlerExceptionResolver

    用来处理其他组件产生的异常情况。

  4. ViewResolver

    视图解析器,这个接口只有一个 resolveViewName(String viewName, Locale locale) 方法,将 Controller 返回的 String 类型视图名和 Locale 解析为 View 类型的视图。View 是用来渲染页面的,它会将程序返回的参数和数据填入模板中,最终生成 html 文件。

  5. RequestToViewNameTranslator

    这个组件的作用,在于从 Request 中获取 viewName。因为有的 Handler 处理完成之后,没有设置 View 也没有设置 ViewName, 这时便要通过这个组件从 Request 中查找 viewName

  6. LocaleResolver

    在上面 ViewResolverresolveViewName() 方法中,需要两个参数。第二个参数 Locale 是从哪来的呢,这就是 LocaleResolver 要做的事了。 LocaleResolver 用于从 request 中解析出 Locale,在中国大陆地区,Locale 当然就会是 zh-CN 之类,用来表示一个区域。也就是 i18n

  7. ThemeResolver

    用于解析主题。SpringMVC 中一个主题对应一个 properties 文件,里面存放着跟当前主题相关的所有资源,如图片,css 等。

    创建主题非常简单,只需准备好资源,然后新建一个 "主题名.properties" 并将资源设置进去,放在 classpath 下,便可以在页面中使用了。Spring MVC 中跟主题有关的类有 ThemeResolverThemeSourceThemeThemeResolver 负责从 request 中解析出主题名,ThemeSource 则根据主题名找到具体的主题,之后通过 Theme 来获取主题和具体的资源。

  8. MultipartResolver

    用于处理上传请求。处理方法是将普通的 request 包装成 MultipartHttpServletRequest,后者可以直接调用 getFile 方法获取 File,如果上传多个文件,还可以调用 getFileMap 得到 FileName->File 结构的 Map。此组件中一共有三个方法,作用分别是判断是不是上传请求、将 request 包装成 MultipartHttpServletRequest、处理完后清理上传过程中产生的临时资源。

  9. FlashMapManager

    用来管理 FlashMapFlashMap 主要用在 redirect 中传递参数。

Spring核心

  • IOC

    控制反转。将对象的创建交给 IOC 去处理并管理它,当需要某个对象时,去 IOC 容器中获取即可。控制指的是对象创建的权力,反转指的是控制权交给了 IOC 容器。降低耦合。IOC 它是一种思想不是一个技术实现。实现方式为 DI (依赖注入),有三种注入方式:构造器setter接口注入

    1)构造器:通过构造函数将依赖注入到对象中,主要用于注入强制性的依赖关系,即必须注入才能创建对象的依赖关系。

    2)setter:通过 Setter 方法将依赖注入到对象中,主要用于注入可选的依赖关系,即可以不注入也能创建对象的依赖关系。Setter 方法注入可以使代码更加灵活,但是也会使对象处于不稳定的状态,容易出现空指针异常等问题。

    3)接口注入:通过接口将依赖注入到对象中,主要用于在多个实现类中选择合适的实现注入到对象中。接口注入需要在实现类中实现接口,并且通过实现接口中的方法来实现依赖项的注入。

  • AOP

    面向切面编程。在不改变原有业务逻辑情况下,增强横切逻辑代码,降低耦合,避免代码重复。允许将一些通用任务如安全、事务、日志等进行集中处理,从而复用代码。AOP 的实现依赖于代理技术,通常有两种代理方式:静态代理动态代理

BeanFactory、ApplicationContext

相同点:

  • Spring 提供了两种不同的 IOC 容器,分别是 BeanFactoryApplicationContext,它们都是 Java 接口,ApplicationContext 继承于 BeanFactory
  • 都可以用来配置 XML 属性,支持属性的自动注入。
  • 都可以使用 getBean("beanName") 获取 bean

不同点:

  • 调用 getBean() 方法时,BeanFactory 才实例化 bean,而 ApplicationContext 在启动容器的时候就实例化了单例 bean
  • BeanFactory 不支持国际化,即 i18n。而 ApplicationContext 提供了对它的支持。
  • … …
  • 简而言之,ApplicationContext 包含 BeanFactory 的所有特性,除非系统对内存消耗严苛,否则首选 ApplicationContext

BeanFactorySpring 框架 IOC 容器的顶层接口,用于定义一些基础功能和规范,ApplicationContext 是它的一个子接口。通常称 BeanFactorySpring IOC 的基础容器,ApplicationContext 是容器的高级接口,比 BeanFactory 拥有更多功能。

BeanFactory、FactoryBean

  • BeanFactory:是一个接口,提供了一种统一的方式来创建和管理对象,它是 Spring IoC 容器的基础。
  • FactoryBean:也是一个接口,为 IOC 容器中 Bean 的实现提供了更加灵活的方式。

eg:以下代码首先定义 DataSourceFactoryBean 类实现了 FactoryBean 接口,并实现其中的 getObject() 方法,在方法中使用 JDBC API 来创建一个 DataSource 对象,并在 xml 配置它的相关属性。由于同时实现了 isSingleton() 方法,可以指定是否是单例。 之后通过 BeanFactorygetBean() 方法获取 DataSourceFactoryBean 实例,因为 DataSourceFactoryBean 是一个 FactoryBean,因此 Spring 会调用它的 getObject() 方法来获取实际的 DataSource 对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class DataSourceFactoryBean implements FactoryBean<DataSource> {
private String url;
private String username;
private String password;
@Override
public DataSource getObject() throws Exception {
DriverManagerDataSource dataSource = new DriverManagerDataSource();
dataSource.setDriverClassName("com.mysql.jdbc.Driver");
dataSource.setUrl(url);
dataSource.setUsername(username);
dataSource.setPassword(password);
return dataSource;
}
@Override
public Class<?> getObjectType() {
return DataSource.class;
}
@Override
public boolean isSingleton() {
return true;
}
// getter和setter方法省略
}
1
2
3
4
5
<bean id="dataSourceFactory" class="com.example.DataSourceFactoryBean">
<property name="url" value="jdbc:mysql://localhost:3306/test"/>
<property name="username" value="root"/>
<property name="password" value="password"/>
</bean>
1
2
BeanFactory factory = new XmlBeanFactory(new ClassPathResource("applicationContext.xml"));
DataSource dataSource = (DataSource) factory.getBean("dataSourceFactory");

Bean自动装配

Spring 中提供了向 bean 中自动注入依赖的功能,这个过程就是自动装配,当向 bean 中注入的内容非常多的时候,自动注入依赖的功能将极大的节省注入程序的时间。

Bean 的自动装配有两类:

  • 基于 xml 文件的自动装配:byType(类型)、byName(名称)、 constructor(根据构造函数)
  • 基于注解的自动装配:@Autowired、@Qualifier、@Resource、@Value。注解注入是通过 Java 反射机制实现的
  1. byType

    根据属性类型自动装配。

  2. byName

    根据属性名称自动装配。

  3. constructor

    找到类型与构造函数中的参数类型相匹配的 bean 注入。相当于 byType

  4. @Autowired/@Qualifier

    可以用在构造函数Setter方法成员变量方法参数上,完成自动装配的工作,标注到成员变量的时候不需要有 set 方法。Autowired 默认按照 byType 进行匹配,如果需要按照名称进行匹配的话,可以使用 @Qualifier@Autowired 相结合的方式。@Qualifier:指定注入 Bean 的名称,用于解决多个同类型 Bean 的注入问题。

    1
    2
    3
    @Autowired
    @Qualifier("userDao")
    private UserDao userDao;
  5. @Resource

    @Resource 默认使用的是 byName 的方式进行注入。可以用在成员变量方法参数上,但是不能用在构造函数Setter方法上。@Resource 有两个重要属性,分别是 nametype,所以如果使用 name 属性,则使用 byName 注入,如果使用 type 属性则使用 byType 注入。

    1
    2
    3
    4
    5
    6
    7
    @Resource(name="userDao")   // 用于成员变量
    private UserDao userDao;

    @Resource(type="UserDao") // 用于方法参数上
    public void setUserDao(UserDao userDao){
    this.userDao=userDao;
    }
  6. @Value

    @Autowired@Resource 进行自动装配注入的依赖都是对象类型,一般情况下 @Valueproperties 文件配合使用进行值类型的注入。可以用在成员变量方法参数上。

Bean生命周期

bean 是由 Spring IOC 容器实例化、组装和管理的对象。

对于普通的 Java 对象,当 new 的时候创建对象,然后该对象就能够使用了。一旦该对象不再被使用,则由 Java 自动进行垃圾回收。而 Spring 中的对象是 bean,bean 和普通的 Java 对象没啥大的区别,只不过 Spring 不再自己去 new 对象了,而是由 IOC 容器去帮助我们实例化对象并且管理它,需要哪个对象,去向 IOC 容器要即可。IOC 其实就是解决对象之间的耦合问题,Spring Bean 的生命周期完全由容器控制。

对于普通的 Java 对象来说,它们的生命周期就是:

  1. 实例化
  2. 该对象不再被使用时通过垃圾回收机制进行回收

而对于 Spring Bean 的生命周期来说:

20210707225212729

  1. 如果 Bean 实现了 InstantiationAwareBeanPostProcessor 接口,Spring 将调用 postProcessBeforeInstantiation() 方法。
  2. 通过反射的方式进行实例化的创建,此时的创建只是在堆空间中申请空间,属性都是默认值。在作用域为 singleton 的时候,bean 是随着容器一起被创建好并且实例化, 在作用域为 prototype 的时候,bean 被调用的时候才创建和实例化。
  3. 如果 Bean 实现了 InstantiationAwareBeanPostProcessor 接口,Spring 将调用 postProcessAfterInstantiation() 方法。
  4. 如果 Bean 实现了 InstantiationAwareBeanPostProcessor 接口,Spring 将调用 postProcessProperties() 方法。
  5. 进行 Bean 的属性注入
  6. 如果 Bean 实现了 BeanNameAware 接口,Spring 将 Bean 的 Id 传递给 setBeanName() 方法。
  7. 如果 Bean 实现了 BeanFactoryAware 接口,Spring 将调用 setBeanFactory() 方法,将 BeanFactory 容器实例传入。
  8. 如果 Bean 实现了 ApplicationContextAware 接口的话,Spring 将调用 Bean 的 setApplicationContext() 方法,将 bean 所在应用上下文引用传入。
  9. 如果 Bean 实现了 BeanPostProcessor 接口,Spring 将调用 postProcessBeforeInitialization() 方法。
  10. 如果 Bean 实现了 InitializingBean 接口,Spring 将调用 afterPropertiesSet() 方法。
  11. 如果 Bean 使用 init-method 声明了初始化方法,该方法也会被调用。
  12. 如果 Bean 实现了 BeanPostProcessor 接口,Spring 将调用 postProcessAfterInitialization() 方法。
  13. 此时,Bean 已经准备就绪,可以被应用程序使用了。将一直驻留在应用上下文中,直到应用上下文被销毁。
  14. 如果 Bean 实现了 DisposableBean 接口,Spring将调用它的 destory() 方法。
  15. 如果 Bean 使用了destory-method 声明销毁方法,该方法也会被调用。

6-8:如果 Bean 需要引用容器内部的对象,那么需要调用 Aware 的子类方法进行设置。

9:对生成的 Bean 对象进行前置处理。

10:进行属性设置等基本工作。

12:对生成的 Bean 对象进行后置处理。

整个过程是由 Spring 容器自动管理的,大体分为 实例化 --> 属性赋值 --> 初始化 --> 销毁,其中有两个环节可以进行干预:

  1. 可以自定义初始化方法,在该方法前增加 @PostConstruct 注解,届时 Spring 容器将在调用 SetBeanFactory 方法之后调用该方法。
  2. 可以自定义销毁方法,在该方法前增加 @PreDestroy 注解,届时 Spring 容器将在自身销毁前,调用这个方法。

Spring单例Bean线程安全?

Spring 容器本身并没有提供 Bean 的线程安全策略,因此可以说 Spring 容器中的 Bean 本身不具备线程安全的特性,但是还是要结合具体 scope 的 Bean 去研究。

Spring 的 bean 作用域(scope)类型

  1. singleton:单例,默认作用域。
  2. prototype:原型,每次创建一个新对象。
  3. request:请求,每次 Http 请求创建一个新对象。
  4. session:会话,同一个会话共享一个实例,不同会话使用不用的实例。
  5. global-session:全局会话,所有会话共享一个实例。

线程安全这个问题,要从单例与原型 Bean 分别进行说明:

  • 原型 Bean

    对于原型 Bean,每次创建一个新对象,也就是线程之间并不存在 Bean 共享,自然是不会有线程安全的问题。

  • 单例 Bean

    如果单例 Bean 是一个无状态 Bean,也就是线程中的操作不会对 Bean 的成员执行查询以外的操作,那么这个单例 Bean 是线程安全的。比如 SpringMVC 的 Controller、Service、Dao 等,这些 Bean 大多是无状态的,只关注于方法本身。

Spring设计模式

  1. 工厂模式

    提供了一种创建对象的最佳方式。eg:BeanFactory、ApplicationContext。

  2. 单例模式

    保证一个类仅有一个实例,并提供一个访问它的全局访问点。eg:单例 Bean。

  3. 代理模式

    为对象提供一种代理,以控制对这个对象的访问。主要有两个目的:保护目标对象、增强目标对象。eg:AOP。

  4. 适配器模式

    将一个类的转接口转换成客户希望的另外一个接口,在不修改原有接口的情况下来满足新的需求。eg:HandlerAdatper。

  5. 策略模式、观察者模式、装饰者模式、模版模式等。

Spring事务传播机制

为 Spring 自带,数据库本身不包含这些传播行为。

多个事务方法相互调用时,事务如何在这些方法之间进行传播?Spring 提供了 7 种不同的传播行为,来保证事务的正常执行:

  1. REQUIRED

    默认传播行为,如果当前没有事务,则新建一个事务;如果当前存在事务,则加入该事务。

  2. SUPPORTS

    如果当前存在事务,则加入当前事务;如果当前没有事务,则以非事务方式执行。

  3. MANDATORY

    如果当前存在事务,则加入当前事务;如果当前没有事务,则抛出异常。

  4. REQUIRED_NEW

    创建一个新事物,如果当前存在事务,则挂起当前事务。

  5. NOT_SUPPORTED

    以非事务方式执行,如果当前存在事务,则挂起当前事务。

  6. NEVER

    不使用事务,如果当前存在事务,则抛出异常。

  7. NESTED

    如果当前没有事务,则新建一个事务;如果当前存在事务,则在嵌套事务中执行。

NESTED 和 REQUIRED_NEW 区别:

REQUIRED_NEW 是新建一个事务并且新开始的这个事务与原有事务无关,而 NESTED 则是当前存在事务时会开启一个嵌套事务,在 NESTED 情况下,父事务回滚,子事务也会回滚,而 REQUIRED_NEW 情况下,原有事务回滚,不会影响新开启的事务。

NESTED 和 REQUIRED 的区别:

REQUIRED 情况下,调用方存在事务时,则被调用方和调用方使用同一个事务,那么被调用方出现异常时,由于共用一个事务,所以无论是否 catch 异常,事务都会回滚,而在 NESTED 情况下,被调用方发生异常时,调用方可以 catch 其异常,这样只有子事务回滚,父事务不会回滚。

Spring事务隔离级别

Spring 中的事务隔离级别跟数据库的隔离级别一样。如果数据库于 Spring 的隔离级别不一致,以 Spring 为主。

Spring事务实现原理

Spring 中有两种事务的实现方式:

1、编程式事务:用户自行编写代码实现

2、声明式事务:@Transactional

一般多用声明式事务,一个方法添加 @Transactional 注解后,Spring 会基于这个类生成一个代理对象,当使用这个代理对象方法的时候,如果有事务处理,会先将事务的自动提交关闭,然后去执行具体的业务逻辑,执行后如果没有出现异常,那么代理逻辑会自动提交。如果出现了异常,则进行回滚操作。

Spring事务失效

  1. bean 对象没有被 Spring 容器管理

  2. 方法的访问修饰符不是 public

  3. 自身调用问题,因为没走代理

    解决:不使用 this,而在本类中注入自身,使之走代理。也可以使用 aspectj 创建本类代理对象,之后使用代理对象调用本类的方法。

  4. 数据源没有配置事务管理器

  5. 数据库不支持事务

  6. 异常被捕获

  7. 异常类型配置错误

分布式

分布式ID

特性:

  • 唯一性:确保生成的 ID 是全网唯一的。
  • 有序递增性:确保生成的 ID 是对于某个用户或者业务是按一定的数字有序递增的。
  • 高可用性:确保任何时候都能正确的生成 ID。
  • 高性能:在高并发的环境下依然表现良好。
  1. UUID

    算法的核心思想是结合机器的网卡、当地时间、一个随记数来生成 UUID。

    • 优点:本地生成,生成简单,性能好,没有高可用风险
    • 缺点:长度过长,存储冗余,且无序不可读,查询效率低
  2. 数据库自增 ID

    使用数据库的id自增策略,如 MySQL 的 auto_increment。并且可以使用两台数据库分别设置不同步长,生成不重复 ID 的策略来实现高可用。

    • 优点:数据库生成的 ID 绝对有序,高可用实现方式简单
    • 缺点:需要独立部署数据库实例,成本高,有性能瓶颈
  3. 批量生成 ID

    一次按需批量生成多个 ID,每次生成都需要访问数据库,将数据库修改为最大的 ID 值,并在内存中记录当前值及最大值。

    • 优点:避免了每次生成 ID 都要访问数据库并带来压力,提高性能
    • 缺点:属于本地生成策略,存在单点故障,服务重启造成 ID 不连续
  4. Redis 生成 ID

    Redis 的所有命令操作都是单线程的,本身提供像 incr 和 increby 这样的自增原子命令,所以能保证生成的 ID 肯定是唯一有序的。

    • 优点:不依赖于数据库,灵活方便,且性能优于数据库;数字 ID 天然排序,对分页或者需要排序的结果很有帮助。
    • 缺点:如果系统中没有Redis,还需要引入新的组件,增加系统复杂度;需要编码和配置的工作量比较大。
  5. SnowFlake

    雪花算法,它是 Twitter 开源的由 64 位整数组成分布式 ID,性能较高,并且在单机上递增。

  6. UidGenerator

    UidGenerator 是百度开源的分布式 ID 生成器,基于雪花算法实现。

  7. Leaf

    Leaf 是美团开源的分布式 ID 生成器,能保证全局唯一,趋势递增,但需要依赖关系数据库、Zookeeper 等中间件。

雪花算法:

SnowFlake 是 Twitter 公司采用的一种算法,目的是在分布式系统中产生全局唯一且趋势递增的 ID。

image-20230305115431363

  1. 第一位:占用 1bit,其值始终是 0,没有实际作用。
  2. 时间戳:占用 41bit,精确到毫秒,总共可以容纳约 69 年的时间。
  3. 工作机器 ID:占用 10bit,其中高位 5bit数据中心 ID,低位 5bit工作节点 ID,做多可以容纳 1024 个节点。
  4. 序列号:占用 12bit,每个节点每毫秒 0 开始不断累加,最多可以累加到 4095,一共可以产生 4096 个ID。

SnowFlake 算法在同一毫秒内最多可以生成的全局唯一 ID 数:1024 X 4096 = 4194304

分布式锁

  1. 使用 Lua 脚本(包含 SETNX + EXPIRE 两条指令):

    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
    public String getData(){

    String data = stringRedisTemplate.opsForValue().get("data");
    if (StringUtils.isEmpty(data)){
    // 缓存中无数据
    return getDataWithLock();
    }
    // 缓存命中
    return data;
    }

    /**
    * redis 实现分布式锁
    */
    public String getDataWithLock() {

    // 需添加唯一的lock值,避免删除其他请求的锁
    String uuid = UUID.randomUUID().toString();
    // 占锁并设置过期时间,为了避免程序执行过程中出现了异常,导致没有将锁删掉,最后造成死锁
    Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent("lock", uuid, 30, TimeUnit.SECONDS);
    if (lock){ // 占锁成功
    String result;
    try{
    // 如果缓存存在,直接返回
    ...
    // 数据库查询数据
    ...
    // 存入缓存
    ...
    }finally { // 解锁
    // lua脚本能够保证原子性的原因就是取值判断的过程在redis中执行
    String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
    stringRedisTemplate.execute(new DefaultRedisScript<>(script, Long.class),
    Arrays.asList("lock"),
    uuid);
    }
    // 返回结果
    return result;
    }else{
    // 占锁失败,重试
    Thread.sleep(200); // 设置睡眠时间,调用太快会报错
    return getDataWithLock();
    }
    }
  2. 开源框架:Redisson

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    @ResponseBody
    @GetMapping("/hello")
    public String hello(){
    // 获取一把锁,只要锁名一致,就是同一把锁
    // 1、默认存活时间30秒,每10秒过后,会自动将存活时间续期到30秒。(看门狗机制)
    // 2、业务只要运行完成,就不再给锁续期,所以如果因为服务宕掉没有解锁,过期时间到了锁一样会被释放
    RLock lock = redissonClient.getLock("lock");
    // 加锁
    lock.lock(); // 阻塞式等待
    try {
    // 业务代码
    System.out.println("加锁成功,执行业务"+Thread.currentThread().getId());
    }catch (Exception e){
    System.out.println("异常错误");
    }finally {
    // 解锁
    System.out.println("释放锁"+Thread.currentThread().getId());
    lock.unlock();
    }
    return "hello";
    }
  3. 数据库实现

    在数据库创建一张表,设置一个主键 ID 或者唯一索引,加锁的形式就是向表中插入一条数据,解锁的形式就是将表中该字段进行删除。以下为加锁和解锁伪代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    def lock:
    exec sql: insert into lockTable (id) values (xxx)
    if result == true:
    return true
    else:
    return false

    def unlock:
    exec sql: delete from lockTable where id = 'xxx'

    缺点:需要进行 IO,速度慢;如果数据库宕机将导致不能解锁。

Session共享

  • 存储在 Cookie

    用户每次请求,都会携带,浪费网络带宽;cookie 长度限制 4kb,不能保存大量信息;存在篡改、窃取等安全隐患。

  • Session 同步

    session 同步需要数据传输,占用网络带宽,降低服务器业务处理能力;每台服务器都需要保存相同的数据,浪费资源。

  • hash 一致性

    如果后端服务水平扩展,之后 session 将会重新分布;每个用户只能固定访问一台服务器,失去了负载均衡的意义。

  • 统一存储

    例如存储在 redis,可整合 SpringSession 实现。增加了一次网络调用;获取数据比直接从 Session 获取慢。

Redis

数据类型

  1. 字符串 (string): 字符串是 Redis 最基本的数据类型,可以存储任何类型的数据,例如数字、二进制数据和 JSON 数据等。
  2. 列表 (list): 是一个有序的字符串列表,可以在头部或尾部添加元素,也可以删除元素。可用于实现队列、栈等数据结构。
  3. 集合 (set): 是一个无序的字符串集合,不允许重复元素。集合支持交集、并集、差集等操作。
  4. 有序集合 (zset): 是一个有序不可重复的字符串集合,每个元素都有一个分数(score),可以根据分数排序。有序集合支持按分数范围获取元素,也可以用于实现排行榜等功能。
  5. 哈希表 (hash):哈希表是一个键值对集合,每个键对应一个值。哈希表支持添加、删除和修改键值对,也可以用于存储一些复杂的数据结构。
  6. 除了上述基本数据类型,Redis 还支持一些高级数据类型,例如地理位置(Geospatial)、流(Stream)等。这些数据类型可以用于处理一些特定的应用场景,例如地理位置定位、消息队列等。

持久化机制

RDB 和 AOF。

  • RDB

    在指定时间间隔内把数据以快照的形式存储在磁盘上。也是默认的持久化方式,这种方式是就是将内存中数据以快照的方式写入到二进制文件中,默认的文件名为 dump.rdbRedis 提供了两个命令来生成 RDBsavebgsave

    1)save:该命令会阻塞当前 Redis,执行 save 命令期间,Redis 不能处理其他命令,直到 RDB 过程完成为止。执行完成后如果存在老 RDB 文件,就进行替换。

    2)bgsave:执行该命令时,Redis 会在后台异步进行快照操作,快照同时还可以响应客户端请求。具体操作是 fork 一个子进程,RDB 持久化过程由子进程负责,完成后自动结束。阻塞只发生在 fork 阶段,一般时间很短。

    可在配置文件中配置实现 rdb 持久化的条件以实现自动触发,例如 save m n。表示 m 秒内数据集存在 n 次修改时,自动触发 bgsave

    优势RDB 文件紧凑,全量备份,非常适合用于灾难恢复;恢复大数据集时的速度比 AOF 的恢复速度要快。

    劣势:在快照持久化期间修改的数据不会被保存,可能丢失数据;全量备份所耗时间较长。

  • AOF

    将每一个写命令都追加到文件中,但是持久化文件会变的越来越大,所以提供了文件重写机制,即 fork 出一条新进程将内存中的数据以命令的方式保存到临时文件中。重写 aof 文件的操作,并没有读取旧文件,而是将整个内存中的数据库内容用命令的方式重写了一个新的 aof 文件,这点和快照有点类似。AOF 有三种触发机制:alwayseverysecno

    1)always:同步持久化,每次发生数据变更会被立即记录到磁盘,性能较差但数据完整性比较好。此操作是在主线程中进行的,此期间无法服务新的请求,因而吞吐量下降。

    2)everysec:异步持久化,每秒记录,如果宕机,最多丢失 1s 内的数据。此操作是通过后台 I/O 线程进行的,不会造成阻塞。

    3)no:由操作系统控制同步操作。不阻塞主线程,但是数据一致性可能会偏差很大。

    优势:可以更好的保护数据不丢失;没有任何磁盘寻址的开销,写入性能高;文件过大时,支持重写。

    劣势:对于同一份数据,AOF 日志文件通常比 RDB 数据快照文件更大;写 QPS 会比 RDBQPS 低;

Redis 支持两种持久化方式同时存在,重启的时候会优先载入 AOF 文件来恢复原始数据。因为 AOF 保存的数据相对于 RDB 来说更完整。

单线程架构

redis 采用单线程 + IO多路复用技术。

  • 单线程指的是 redis 中读写操作和网络 IO 只用一个线程来完成,但是其他操作是有其他线程完成,例如持久化操作。
  • IO 多路复用使得在单线程下能够同时处理多个 IO 请求。

redis 为什么这么快?

  1. 单线程进行读写操作,避免线程切换和锁竞争带来的消耗。
  2. redis 操作是在内存中进行的。
  3. 采用了 IO 多路复用技术,能够处理并发请求,实现高吞吐率。

过期键删除策略

  • 定时删除

    在设置键过期时间的同时,创建一个定时器,让定时器在键的过期时间来临时来执行对键的删除操作。

    • 优点:对内存非常友好
    • 缺点:对 CPU 非常不友好

    eg:如果服务器将大量的 CPU 时间用来删除过期键,那么服务器的响应时间和吞吐量就会受到影响。因此 Redis 目前没有使用定时删除策略。

  • 惰性删除

    放任过期键不管,每次从键空间中获取键时,检查该键是否过期,如果过期,就删除该键。

    • 优点:对 CPU 非常友好
    • 缺点:对内存非常不友好

    如果数据库的过期键一直没有被访问到,那这些过期键就会一直占用内存资源,造成资源浪费。

  • 定期删除

    每隔一段时间,程序对数据库进行一次检查,删除里面的过期键,至于要删除哪些数据库的哪些过期键,则由算法决定。定期删除策略是定时删除策略和惰性删除策略的一种整合折中方案。

其中定时删除和定期删除为主动删除策略,惰性删除为被动删除策略。Redis 使用惰性删除定期删除策略。

缓存穿透/击穿/雪崩

  • 缓存穿透:访问不存在的数据,使得请求直达存储层,导致负载过大。可能是因为误删了缓存和库中的数据或者有人恶意访问不存在的数据。解决方式:

    1)存储层未命中后,将空值存入缓存。

    2)将数据存入布隆过滤器,访问缓存之前经过滤器拦截,若请求的数据不存在则直接返回空值。

  • 缓存击穿:在缓存失效的瞬间,大量请求直达存储层,导致服务崩溃。解决方案:

    1)永不过期,对热点数据不设置过期时间。

    2)在数据库添加互斥锁,当一个线程访问该数据时,其他线程只能等待,这个线程访问之后,将获取的数据存入缓存,之后其他线程就可以从缓存中取值。

  • 缓存雪崩:大量数据同时过期、或是 redis 故障导致服务不可用,缓存层无法提供服务,所有的请求直达存储层,造成数据库宕机。解决方案:

    1)在原有的失效时间上加上一个随机值,比如随机 1-5 分钟。

    2)当流量到达一定的阈值时,启用降级或者熔断。属于兜底措施。

    3)设置热点数据永不过期。

    4)搭建 redis 集群,提高容灾性。

布隆过滤器

image-20230306170323405

可以把布隆过滤器理解成一个二进制数组,当往布隆过滤器增加元素时,这个元素需要根据 khash 函数计算得到多个 hash 值,然后对数组长度进行取模得到数组下标的位置,并将对应数组下标位置的值置为 1。布隆过滤器可以告诉我们某个元素一定不存在或者可能存在

优点

  • 时间复杂度低,增加和查询元素的时间复杂为O(N)N 为哈希函数的个数。
  • 保密性强,布隆过滤器不存储元素本身。
  • 存储空间小,如果允许存在一定的误判,布隆过滤器非常节省空间。

缺点

  • 有点一定的误判率,但是可以通过调整参数来降低。
  • 无法获取元素本身。
  • 很难删除元素,在业务场景里正常也不需要。

查询流程

  1. 通过 khash 函数计算得到 khash 值;
  2. 依次取模数组长度,得到数组索引;
  3. 判断索引处的值是否全部为 1,如果全部为 1 则存在(可能是误判),如果存在一个 0 则必定不存在。

双写一致性

共有四种同步策略:

  1. 先更新缓存再更新数据库

    如果数据需要经过复杂的计算再写入缓存的话,频繁的更新缓存会影响到服务器的性能。如果是写入数据比较频繁的场景,可能会导致频繁的更新缓存却没有业务来读取该数据。

  2. 先更新数据库再更新缓存

    1
    2
    3
    4
    1.线程A更新了数据库
    2.线程B更新了数据库
    3.线程B更新了缓存
    4.线程A更新了缓存

    请求A更新缓存应该比请求B更新缓存早才对,但是因为网络等原因,B却比A更早更新了缓存。这就导致了脏数据,因此不考虑。

  3. 先删除缓存再更新数据库

    1
    2
    3
    4
    5
    1.请求A进行写操作,删除缓存
    2.请求B查询发现缓存不存在
    3.请求B去数据库查询得到旧值
    4.请求B将旧值写入缓存
    5.请求A将新值写入数据库 上述情况就会导致不一致的情形出现。而且,如果不采用给缓存设置过期时间策略,该数据永远都是脏数据。

    导致数据不一致,不考虑。

  4. 先更新数据库再删除缓存

    1
    2
    3
    4
    5
    1.缓存刚好失效
    2.请求A查询数据库,得一个旧值
    3.请求B将新值写入数据库
    4.请求B删除缓存
    5.请求A将查到的旧值写入缓存

    虽然可能出现上诉情况,但是发生概率相对较小。可使用重试机制解决。也就是请求B删除缓存后,隔一段时间后再去删除缓存。