Skip to content

JavaGuide

Java 基础

基础知识

面向对象(封装,继承,多态)

JDK > JRE > JVM > JIT

JDK 包含了开发 Java 程序所需的一切工具和库,而 JRE 则只提供了运行 Java 程序所需的最小环境

.java -> javac 编译 -> .class -> 热点代码?-> (yes 解释器) (no JIT) -> 机器

编译型:一次性翻译成机器码,C, C++, GO;解释性:一句一句翻译,Python, JavaScript

Java 编译与解释并存:先编译、后解释。

AOT(Ahead of Time Compilation) :程序被执行前就将其编译成机器码

AOT 更适合当下的云原生场景,对微服务架构的支持也比较友好。除此之外,AOT 编译无法支持 Java 的一些动态特性,如反射、动态代理、动态加载、JNI(Java Native Interface)等。然而,很多框架和库(如 Spring、CGLIB)都用到了这些特性。

Oracle JDK 就是优化版的 Open JDK,但是不开源,收费。

移位操作符:<< :左移运算符,>> :带符号右移,>>> :无符号右移

实际上支持的类型只有intlong

Java 里使用 long 类型的数据一定要在数值后面加上 L,否则将作为整型解析。

char a = 'h'char :单引号,String a = "hello" :双引号。

包装类型的缓存机制是指对于某些范围内的基本数据类型的包装类型,Java使用了一个缓存对象池,以节省内存和提高性能。

Byte,Short,Integer,Long 这 4 种包装类默认创建了数值 [-128,127] 的相应类型的缓存数据

所有整型包装类对象之间值的比较,全部使用 equals 方法比较

什么是自动拆装箱?

  • 装箱:将基本数据类型自动转换为对应的包装类型
  • 拆箱:将包装类型自动转换为对应的基本数据类型

重载就是同一个类中多个同名方法根据不同的传参来执行不同的逻辑处理。

重写就是子类对父类方法的重新改造,外部样子不能改变,内部逻辑可以改变。

  • 面向对象编程(OOP):以对象为中心,将程序分解为多个对象,每个对象包含数据(属性)和操作数据的方法(行为)。强调封装、继承和多态等概念,使得代码更加模块化、可重用性更高。
  • 面向过程编程(POP):以过程或函数为中心,将程序分解为一系列的函数,按照执行顺序依次调用这些函数完成任务。强调对问题的分解和解决方案的逐步求精。

构造方法

构造方法是面向对象编程中的一个特殊方法,用于创建和初始化对象。它通常在对象被实例化时被调用,用来初始化对象的状态。

在大多数面向对象的编程语言中,构造方法的名称通常与类名相同,并且没有返回类型。它的作用是初始化对象的各种属性,确保对象在创建后处于一个合适的状态。

构造方法的特点包括

  1. 命名和语法:构造方法通常与类同名,没有返回类型,并且在对象被实例化时自动调用。
  2. 初始化对象状态:构造方法用于初始化对象的属性,确保对象被创建后处于一个可用的状态。
  3. 重载:有些编程语言支持构造方法的重载,允许定义多个不同参数列表的构造方法,以满足不同的创建对象的需求。
  4. 默认构造方法:如果没有显式定义构造方法,编程语言通常会提供一个默认的构造方法,用于创建对象。

示例:

java
public class Person {
    private String name;
    private int age;

    // 构造方法
    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }

    // 获取姓名
    public String getName() {
        return name;
    }

    // 获取年龄
    public int getAge() {
        return age;
    }

    public static void main(String[] args) {
        // 创建对象时调用构造方法进行初始化
        Person person = new Person("Alice", 30);
        System.out.println("Name: " + person.getName());
        System.out.println("Age: " + person.getAge());
    }
}

在上面的示例中,Person 类有一个构造方法,它接受姓名和年龄作为参数,并在对象被实例化时用这些参数初始化对象的属性。

面向对象编程(OOP)的三大特征是封装、继承和多态

  1. 封装(Encapsulation):封装是指将数据(属性)和行为(方法)封装在一个单元中,以防止外部对其直接访问和修改。通过封装,对象的内部细节被隐藏起来,只暴露出公共接口,使得对象的使用者无需了解其内部实现细节,从而提高了代码的安全性可维护性
  2. 继承(Inheritance):继承是指一个类(子类)可以基于另一个类(父类)的定义来创建自己的定义,并且可以继承父类的属性和方法。通过继承,子类可以重用父类的代码,同时可以在不修改父类的情况下扩展或修改其行为,提高了代码的重用性扩展性
  3. 多态(Polymorphism):多态是指同一个方法名可以在不同的类中具有不同的实现方式,使得程序可以根据对象的实际类型来调用相应的方法。多态性可以通过继承和接口实现,它可以提高代码的灵活性可扩展性,使得代码更加通用和易于维护。

继承是实现多态的一种手段,但并不是唯一的手段,接口也可以实现多态性。继承更强调类之间的层次关系和代码复用,而多态更强调同一个方法名在不同类中的不同实现方式。

java
// 封装
class EncapsulationExample {
    private int num;

    public int getNum() {
        return num;
    }

    public void setNum(int num) {
        this.num = num;
    }
}


// 继承

// 父类
class Animal {
    String name;

    // 父类构造函数
    public Animal(String name) {
        this.name = name;
    }

    // 父类方法
    void eat() {
        System.out.println(name + " is eating");
    }
}

// 子类
class Dog extends Animal {
    // 子类构造函数
    public Dog(String name) {
        // 调用父类构造函数
        super(name);
    }

    // 子类方法,重写了父类方法
    @Override
    void eat() {
        System.out.println(name + " is eating bones");
    }

    // 子类特有方法
    void bark() {
        System.out.println(name + " is barking");
    }
}

public class InheritanceExample {
    public static void main(String[] args) {
        // 创建父类对象
        Animal animal = new Animal("Generic Animal");
        // 调用父类方法
        animal.eat();

        // 创建子类对象
        Dog dog = new Dog("Buddy");
        // 调用子类方法
        dog.eat();  // 调用子类重写的方法
        dog.bark(); // 调用子类特有方法
    }
}

// 多态
class Animal {
    void makeSound() {
        System.out.println("Animal makes a sound");
    }
}

//子类继承父类
class Dog extends Animal {
    @Override
    void makeSound() {  // 子类重写父类方法
        System.out.println("Dog barks");
    }
}

class Cat extends Animal {
    @Override
    void makeSound() {
        System.out.println("Cat meows");
    }
}

public class PolymorphismExample {
    public static void main(String[] args) {
        Animal dog = new Dog();  // 父类引用指向子类对象
        Animal cat = new Cat();

        dog.makeSound(); // Output: Dog barks
        cat.makeSound(); // Output: Cat meows
    }
}

接口:接口是一种完全抽象的类型,它只包含方法的声明而没有实现。

java
public interface Animal {
    void makeSound();
}

public class Dog implements Animal {
    @Override
    public void makeSound() {
        System.out.println("Woof");
    }
}

抽象类:抽象类是一个可以包含抽象方法(没有实现)和普通方法(有实现)的类,被声明为抽象的类。

java
public abstract class Shape {
    protected int x, y;

    // 普通方法
    public Shape(int x, int y) {
        this.x = x;
        this.y = y;
    }
	
    // 抽象方法
    public abstract void draw();
}

public class Circle extends Shape {
    private int radius;

    public Circle(int x, int y, int radius) {
        super(x, y);
        this.radius = radius;
    }

    @Override
    public void draw() {
        System.out.println("Drawing circle at (" + x + ", " + y + ") with radius " + radius);
    }
}

浅拷贝:浅拷贝会在堆上创建一个新的对象(区别于引用拷贝的一点)。如果原对象是引用类型的话会直接复制引用地址,也就是拷贝对象和原对象共用同一个内部对象。创建一个新的对象,其中包含原始对象的所有元素的引用

深拷贝:深拷贝会完全复制整个对象,包括这个对象所包含的内部对象。创建一个全新的对象,其中包含原始对象的所有元素的副本

引用拷贝:两个不同的引用指向同一个对象。

浅拷贝、深拷贝、引用拷贝示意图

Object 类的常见方法:equals(), toString(), hashCode()

  • 对于基本数据类型来说,== 比较的是值。
  • 对于引用数据类型来说,== 比较的是对象的内存地址。(new)

String 中的 equals 方法是被重写过的,因为 Objectequals 方法是比较的对象的内存地址,而 Stringequals 方法比较的是对象的值。

基本类型的值存储在栈内存中,直接存储数值,而不是存储对象的引用。

引用类型包括类(Class)、接口(Interface)、数组(Array)等,它们的值存储在堆内存中,栈内存中存储的是对象的引用(地址)。通过关键字new来创建对象,new关键字会强制在堆内存中创建新的对象。

hashCode()Object类中定义的一个方法,它用于返回对象的哈希码值。哈希码值是根据对象的内部信息计算出来的一个整数,用于在哈希表等数据结构中快速定位对象的位置。

如果两个对象的hashCode 值相等,那这两个对象不一定相等(哈希碰撞)。

如果两个对象的hashCode 值相等并且equals()方法也返回 true,我们才认为这两个对象相等。

如果两个对象的hashCode 值不相等,我们就可以直接认为这两个对象不相等。

重写 equals() 时必须重写 hashCode() : 因为如果重写 equals() 时没有重写 hashCode() 方法的话就可能会导致 equals 方法判断是相等的两个对象,hashCode 值却不相等。

String 不可变,StringBufferStringBuffer可变。

StringBuffer 对方法加了同步锁或者对调用的方法加了同步锁,所以是线程安全的。StringBuilder 并没有对方法进行加同步锁,所以是非线程安全的。

性能:StringBuilder > StringBuffer > String

总结

  1. 操作少量的数据: 适用 String
  2. 单线程操作字符串缓冲区下操作大量数据: 适用 StringBuilder
  3. 多线程操作字符串缓冲区下操作大量数据: 适用 StringBuffer

final关键字用于表示不可变的特性,它可以用于修饰类、方法和变量,具有不同的含义和用途。

修饰类:意味着该类不能被继承,即不能有子类。

修饰方法:意味着该方法不能被子类重写(覆盖)。

修饰变量:意味着该变量的值只能被赋值一次,之后不能再改变。

String 类中使用 final 关键字。

“+”和“+=”是专门为 String 类重载过的运算符,也是 Java 中仅有的两个重载过的运算符。

字符串对象通过“+”的字符串拼接方式,实际上是通过 StringBuilder 调用 append() 方法实现的,拼接完成之后调用 toString() 得到一个 String 对象 。

使用 “+” 进行字符串拼接会产生大量的临时对象的问题在 JDK9 中得到了解决。字符串相加 “+” 改为了用动态方法 makeConcatWithConstants() 来实现,而不是大量的 StringBuilder 了。

String s1 = new String("abc"); 这句话创建了几个字符串对象? 会创建 1 或 2 个字符串对象。字符串常量池中已存在字符串对象的引用,则1。否者2。

java
String s0 = new String("123");  // 没有引用,2
// 字符串常量池中已存在字符串对象“abc”的引用
String s1 = "abc";
// 下面这段代码只会在堆中创建 1 个字符串对象“abc”
String s2 = new String("abc");  // // 有引用,1

String.intern() 是一个 native(本地)方法,其作用是将指定的字符串对象的引用保存在字符串常量池中,可以简单分为两种情况:

  • 如果字符串常量池中保存了对应的字符串对象的引用,就直接返回该引用。
  • 如果字符串常量池中没有保存了对应的字符串对象的引用,那就在常量池中创建一个指向该字符串对象的引用并返回。

对于编译期可以确定值的字符串,也就是常量字符串 ,jvm 会将其存入字符串常量池。并且,字符串常量拼接得到的字符串常量在编译阶段就已经被存放字符串常量池,这个得益于编译器的优化

常量折叠:对于 String str3 = "str" + "ing"; 编译器会给你优化成 String str3 = "string";

引用的值在程序编译期是无法确定的,编译器无法对其进行优化

Java 异常类层次结构图

Exception :程序本身可以处理的异常,可以通过 catch 来进行捕获。Exception 又可以分为 Checked Exception (受检查异常,必须处理) 和 Unchecked Exception (不受检查异常,可以不处理)。

ErrorError 属于程序无法处理的错误 ,不建议通过catch捕获 。例如 Java 虚拟机运行错误(Virtual MachineError)、虚拟机内存不够错误(OutOfMemoryError)、类定义错误(NoClassDefFoundError)等 。这些异常发生时,Java 虚拟机(JVM)一般会选择线程终止

Checked Exception 即 受检查异常 ,Java 代码在编译过程中,如果受检查异常没有被 catch或者throws 关键字处理的话,就没办法通过编译。

Unchecked Exception不受检查异常 ,Java 代码在编译过程中 ,我们即使不处理不受检查异常也可以正常通过编译。

RuntimeException 及其子类都统称为非受检查异常,常见的有(建议记下来,日常开发中会经常用到):

  • NullPointerException(空指针错误)

  • IllegalArgumentException(参数错误比如方法入参类型错误)

  • ArrayIndexOutOfBoundsException(数组越界错误)

  • ClassCastException(类型转换错误)

Throwable 类常用方法有哪些?

  • String getMessage(): 返回异常发生时的简要描述
  • String toString(): 返回异常发生时的详细信息

try块:用于捕获异常。其后可接零个或多个 catch 块,如果没有 catch 块,则必须跟一个 finally 块。

catch块:用于处理 try 捕获到的异常。

finally 块:无论是否捕获或处理异常,finally 块里的语句都会被执行。当在 try 块或 catch 块中遇到 return 语句时,finally 语句块将在方法返回之前被执行。不要在 finally 语句块中使用 return! 否者直接 return finally中的内容

以下 3 种特殊情况下,finally 块的代码不会被执行:

  • finally 之前虚拟机被终止运行(System.exit(1); // 退出码为1即为程序非正常退出,为0为正常退出
  • 程序所在的线程死亡。
  • 关闭 CPU。

try-with-resources 语句是一种用于自动关闭资源的特殊语法。它用于确保在代码块结束时自动关闭实现了AutoCloseable接口的资源,无论代码块是正常执行完成还是因为异常而退出。这种语法可以有效地避免资源泄漏问题,并简化代码。

Java 泛型(Generics) 是 JDK 5 中引入的一个新特性。使用泛型参数,可以增强代码的可读性以及稳定性。

泛型一般有三种使用方式:泛型类泛型接口泛型方法

反射:赋予了我们在运行时分析类以及执行类中方法的能力。通过反射你可以获取任意一个类的所有属性和方法,你还可以调用这些方法和属性。

缺点:增加了安全问题,性能稍微差一点。

注解动态代理 的实现也用到了反射。

Annotation (注解) 可以看作是一种特殊的注释,主要用于修饰类、方法或者变量,提供某些信息供程序在编译或者运行时使用。

注解只有被解析之后才会生效,常见的解析方法 有两种:

  • 编译期直接扫描@Override
  • 运行期通过反射处理@Value@Component

SPI 即 Service Provider Interface ,字面意思就是:“服务提供者的接口”,我的理解是:专门提供给服务提供者或者扩展框架功能的开发者去使用的一个接口。

API是实现方提供接口和实现。

SPI是调用方确定接口规则,实现方然后实现。

img

SPI 的优缺点

优点:大大地提高接口设计的灵活性。

缺点:

  • 需要遍历加载所有的实现类,不能做到按需加载,这样效率还是相对较低的。

  • 当多个 ServiceLoader 同时 load 时,会有并发问题。

  • 序列化:将数据结构或对象转换成二进制字节流的过程

  • 反序列化:将在序列化过程中所生成的二进制字节流转换成数据结构或者对象的过程

序列化和反序列化常见应用场景:对象在网络传输、存储到文件、存储到数据库、存储到内存。

序列化的主要目的是通过网络传输对象或者说是将对象存储到文件系统、数据库、内存中

属于 OSI 七层协议中的表示层,对应 TCP/IP 协议的应用层。

TCP/IP 四层模型是:网络接口层(比特)、网络层(数据帧)、传输层(数据包)、应用层(数据段)。

TCP/IP 四层模型

对于不想进行序列化的变量,使用 transient 关键字修饰。它只能修饰变量,且在反序列化后会被设置成默认值。

IO 流在 Java 中分为输入流和输出流,而根据数据的处理方式又分为字节流和字符流。

  • InputStream/Reader: 所有输入流的基类,前者是字节输入流,后者是字符输入流。
  • OutputStream/Writer: 所有输出流的基类,前者是字节输出流,后者是字符输出流。

为什么要分为字节流和字符流呢?这是因为在处理数据时,有时候需要考虑数据的语义和编码方式。对于文本数据,字符流更适合,因为它能够正确地处理字符编码、字符集和换行符等文本特性。而对于二进制数据或者与硬件设备进行交互时,字节流更适合,因为它可以直接操作字节数据,不需要考虑字符编码和字符集的问题。

Java将I/O流分为字节流和字符流,以便程序员根据实际情况选择合适的流来处理不同类型的数据。

语法糖(Syntactic sugar) 代指的是编程语言为了方便程序员开发程序而设计的一种特殊语法,这种语法对编程语言的功能并没有影响。实现相同的功能,基于语法糖写出来的代码往往更简单简洁且更易阅读。Java 中真正支持语法糖的是 Java 编译器而不是 JVM

Java 中的 for-each 就是一个常用的语法糖,其原理其实就是基于普通的 for 循环和迭代器。

java
String[] strs = {"JavaGuide", "公众号:JavaGuide", "博客:https://javaguide.cn/"};
for (String s : strs) {
    System.out.println(s);
}

Java 中最常用的语法糖主要有泛型、自动拆装箱、变长参数、枚举、内部类、增强 for 循环、try-with-resources 语法、lambda 表达式等。

重要知识点

值传递

  • 实参(实际参数,Arguments):用于传递给函数/方法的参数,必须有确定的值。
  • 形参(形式参数,Parameters):用于定义函数/方法,接收实参,不需要有确定的值。
java
String hello = "Hello!";
// hello 为实参
sayHello(hello);
// str 为形参
void sayHello(String str) {
    System.out.println(str);
}
  • 值传递:方法接收的是实参值的拷贝,会创建副本。(在 Java 中只有值传递。)
  • 引用传递:方法接收的直接是实参所引用的对象在堆中的地址,不会创建副本,对形参的修改将影响到实参。

Java 中将实参传递给方法(或函数)的方式是 值传递

  • 如果参数是基本类型的话,很简单,传递的就是基本类型的字面量值的拷贝,会创建副本。
  • 如果参数是引用类型,传递的就是实参所引用的对象在堆中地址值的拷贝,同样也会创建副本。

序列化

  • 序列化:将数据结构或对象转换成二进制字节流的过程
  • 反序列化:将在序列化过程中所生成的二进制字节流转换成数据结构或者对象的过程

序列化和反序列化常见应用场景:对象在网络传输、存储到文件、存储到数据库、存储到内存。

序列化的主要目的是通过网络传输对象或者说是将对象存储到文件系统、数据库、内存中

属于 OSI 七层协议中的表示层,对应 TCP/IP 协议的应用层。

TCP/IP 四层模型是:网络接口层(比特)、网络层(数据帧)、传输层(数据包)、应用层(数据段)。

TCP/IP 四层模型

对于不想进行序列化的变量,使用 transient 关键字修饰。它只能修饰变量,且在反序列化后会被设置成默认值。

不推荐使用 JDK 自带的序列化:不支持跨语言调用、性能差、存在安全问题

Kryo 是专门针对 Java 语言序列化方式并且性能非常好,如果你的应用是专门针对 Java 语言的话可以考虑使用。

Dubbo(读音[ˈdʌbəʊ])是阿里巴巴公司开源的一个高性能优秀的服务框架,使得应用可通过高性能的 RPC 实现服务的输出和输入功能,可以和 Spring 框架无缝集成。Dubbo是一款高性能、轻量级的开源Java RPC框架,它提供了三大核心能力:面向接口的远程方法调用,智能容错和负载均衡,以及服务自动注册和发现。

反射 invoke

反射之所以被称为框架的灵魂,主要是因为它赋予了我们在运行时分析类以及执行类中方法的能力

通过反射你可以获取任意一个类的所有属性和方法,你还可以调用这些方法和属性。

正是因为反射,你才能这么轻松地使用各种框架。像 Spring/Spring Boot、MyBatis 等等框架中都大量使用了反射机制。

动态代理和注解的实现也依赖反射

动态代理提供了一种灵活且非侵入式的方式,可以对对象的行为进行定制和扩展。

代理模式

我们使用代理对象来代替对真实对象(real object)的访问,这样就可以在不修改原目标对象的前提下,提供额外的功能操作,扩展目标对象的功能

代理模式的主要作用是扩展目标对象的功能,比如说在目标对象的某个方法执行前后你可以增加一些自定义的操作

静态代理:我们对目标对象的每个方法的增强都是手动完成的,非常不灵活且麻烦。从 JVM 层面来说, 静态代理在编译时就将接口、实现类、代理类这些都变成了一个个实际的 class 文件

动态代理:我们不需要针对每个目标类都单独创建一个代理类,并且也不需要我们必须实现接口,我们可以直接代理实现类。从 JVM 角度来说,动态代理是在运行时动态生成类字节码,并加载到 JVM 中的

在 Java 动态代理机制中 InvocationHandler 接口和 Proxy 类是核心

动态代理类使用步骤

  1. 定义一个接口及其实现类;
  2. 自定义 InvocationHandler 并重写invoke方法,在 invoke 方法中我们会调用原生方法(被代理类的方法)并自定义一些处理逻辑;
  3. 通过 Proxy.newProxyInstance(ClassLoader loader,Class<?>[] interfaces,InvocationHandler h) 方法创建代理对象;

JDK 动态代理有一个最致命的问题是其只能代理实现了接口的类

为了解决这个问题,我们可以用 CGLIB (Code Generation Library)动态代理机制来避免,需要引入对应的依赖

在 CGLIB 动态代理机制中 MethodInterceptor 接口和 Enhancer 类是核心

静态代理和动态代理的对比

  1. 灵活性:动态代理更加灵活,不需要必须实现接口,可以直接代理实现类,并且可以不需要针对每个目标类都创建一个代理类。另外,静态代理中,接口一旦新增加方法,目标对象和代理对象都要进行修改,这是非常麻烦的!
  2. JVM 层面:静态代理在编译时就将接口、实现类、代理类这些都变成了一个个实际的 class 文件。而动态代理是在运行时动态生成类字节码,并加载到 JVM 中的。

BigDecimal

创建BigDecimal(String val)构造方法或者 BigDecimal.valueOf(double val) 静态方法来创建对象。

java
BigDecimal g = new BigDecimal(0.1F);  // 不好,存在精度丢失
BigDecimal recommand1 = new BigDecimal("0.1");  // 推荐方法,其实用到了 Double 的 toString
BigDecimal recommand2 = BigDecimal.valueOf(0.1);  // 按Double实际能表达精度对尾数截断

比较大小用compareTo(),这是因为 equals() 方法不仅仅会比较值的大小(value)还会比较精度(scale),而 compareTo() 方法比较的时候会忽略精度。

Unsafe

Unsafe 是位于 sun.misc 包下的一个类,主要提供一些用于执行低级别、不安全操作的方法。Unsafe 提供的这些功能的实现需要依赖本地方法(Native Method)。

为什么要使用本地方法 native method 呢?

  1. 需要用到 Java 中不具备的依赖于操作系统的特性,Java 在实现跨平台的同时要实现对底层的控制,需要借助其他语言发挥作用。
  2. 对于其他语言已经完成的一些现成功能,可以使用 Java 直接调用。
  3. 程序对时间敏感或对性能要求非常高时,有必要使用更加底层的语言,例如 C/C++甚至是汇编。

Unsafe 类实现功能可以被分为下面 8 类:

  1. 内存操作
  2. 内存屏障
  3. 对象操作
  4. 数据操作
  5. CAS 操作
  6. 线程调度
  7. Class 操作
  8. 系统信息

SPI机制详解

Java SPI 就是提供了这样一个机制:为某个接口寻找服务实现的机制。这有点类似 IoC 的思想,将装配的控制权移交到了程序之外

SPI 即 Service Provider Interface ,字面意思就是:“服务提供者的接口”,我的理解是:专门提供给服务提供者或者扩展框架功能的开发者去使用的一个接口。对应API

SLF4J (Simple Logging Facade for Java)是 Java 的一个日志门面(接口),其具体实现有几种,比如:Logback、Log4j、Log4j2 等等,而且还可以切换,在切换日志具体实现的时候我们是不需要更改项目代码的,只需要在 Maven 依赖里面修改一些 pom 依赖就好。

SPI 机制的具体实现本质上还是通过反射完成的。即:我们按照规定将要暴露对外使用的具体实现类在 META-INF/services/ 文件下声明

通过 SPI 机制能够大大地提高接口设计的灵活性,但是 SPI 机制也存在一些缺点,比如:

  1. 遍历加载所有的实现类,这样效率还是相对较低的;
  2. 当多个 ServiceLoader 同时 load 时,会有并发问题。

语法糖

语法糖让程序更加简洁,有更高的可读性。

Java 虚拟机并不支持这些语法糖。这些语法糖在编译阶段就会被还原成简单的基础语法结构,这个过程就是解语法糖。(javac

常见的语法糖:

  • Switch支持String与枚举

  • 泛型

    • 类型擦除的主要过程如下:1.将所有的泛型参数用其最左边界(最顶级的父类型)类型替换。 2.移除所有的类型参数。
    • List<Integer> listList<Stirng> list在重载时类型擦除变得一模一样;无法用在catch语句中;泛型的静态变量共享
  • 自动装箱与拆箱

    • 自动装箱就是 Java 自动将原始类型值转换成对应的对象,比如将 int 的变量转换成 Integer 对象,这个过程叫做装箱valueOf(int),反之将 Integer 对象转换成 int 类型值,这个过程叫做拆箱intValue()。
    • :自动装箱Integer在-128 至 +127范围内有缓存,值相等;超出范围值不等。
  • 可变长参数

    • java
      public static transient void print(String... strs) {}  // 创建数组 String strs[]
  • 枚举

    • 关键字enum可以将一组具名的值的有限集合创建为一种新的类型

    • 枚举类型不能被继承

    • java
      enum Day {
          SUNDAY, MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY
      }  // 枚举的定义
  • 内部类

    • 内部类的名字完全可以和它的外部类名字相同,因为一旦编译成功,就会生成两个完全不同的.class文件了,分别是outer.classouter$inner.class。当尝试对outer.class反编译时,会把两个文件一起进行反编译。
  • 条件编译

    • 根据 if 判断条件的真假,编译器直接把分支为 false 的代码块消除。有总比没有强,虽然只能在方法体内实现。
  • 断言

    • 其实断言的底层实现就是 if 语言,如果断言结果为 true,则什么都不做,程序继续执行,如果断言结果为 false,则程序抛出 AssertError 来打断程序的执行
  • 数值字面量

    • 整数和浮点数都允许在数字之间插入任意多个下划线,方便阅读。
  • for-each

    • for-each 的实现原理其实就是使用了普通的 for 循环和迭代器
    • :不能remove(),需要使用Iterator.remove()
  • try-with-resource

    • java
      //我们没有做的关闭资源的操作,编译器都帮我们做了。
      try (BufferedReader br = new BufferedReader(new FileReader("d:\\ hollischuang.xml"))) { } catch () {}
  • lambda 表达式

    • Lambda 表达式不是匿名内部类的语法糖,但是他也是一个语法糖。实现方式其实是依赖了几个 JVM 底层提供的 lambda 相关 api

集合 Collection

基础知识

Java 集合框架概览

List, Set, Queue, Map 四者的区别?

  • List(对付顺序的好帮手): 存储的元素是有序的、可重复的。
  • Set(注重独一无二的性质): 存储的元素不可重复的。
  • Queue(实现排队功能的叫号机): 按特定的排队规则来确定先后顺序,存储的元素是有序的、可重复的。
  • Map(用 key 来搜索的专家): 使用键值对(key-value)存储,类似于数学上的函数 y=f(x),"x" 代表 key,"y" 代表 value,key 是无序的、不可重复的,value 是无序的、可重复的,每个键最多映射到一个值。

ArrayList 动态数组,Array数组

ArrayList 数组存储(Object 数组),LinkedList 链表存储(双向链表)

HashMap的底层实现

JDK1.8之前

HashMap 通过 key 的 hashcode 经过扰动函数处理过后得到 hash 值,然后通过 (n - 1) & hash 判断当前元素存放的位置(这里的 n 指的是数组的长度),如果当前位置存在元素的话,就判断该元素与要存入的元素的 hash 值以及 key 是否相同,如果相同的话,直接覆盖,不相同就通过拉链法解决冲突。

所谓 “拉链法” 就是:将链表和数组相结合。也就是说创建一个链表数组,数组中每一格就是一个链表。若遇到哈希冲突,则将冲突的值加到链表中即可。

jdk1.8 之前的内部结构-HashMap

JDK1.8之后

相比于之前的版本, JDK1.8 之后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间。

jdk1.8之后的内部结构-HashMap

红黑树就是为了解决二叉查找树的缺陷,因为二叉查找树在某些情况下会退化成一个线性结构。

“取余(%)操作中如果除数是 2 的幂次则等价于与其除数减一的与(&)操作(也就是说 hash%length==hash&(length-1)的前提是 length 是 2 的 n 次方;)。” 并且采用二进制位操作 &,相对于%能够提高运算效率,这就解释了 HashMap 的长度为什么是 2 的幂次方

java
// 高位也就是2的n次方中的 n 位
1101 % 0100 = 0001  // 高位不用管,后面的就是余数
1101 & 0011 = 0001  // 与操作,结果就是hash的地位

HashMap 在JDK 1.8之前的版本用的是头插法,之后用的是尾插法,避免了环形结构。但还是建议在多线程操作下使用。推荐使用ConcurrentHashMap

集合注意事项

判断所有集合内部的元素是否为空,使用 isEmpty() 方法,而不是 size()==0 的方式

在使用 java.util.stream.Collectors 类的 toMap() 方法转为 Map 集合时,一定要注意当 value 为 null 时会抛 NPE 异常tomap()调用了merge()merge()会先调用Object.requireNonNull()判空。

不要在 foreach 循环里进行元素的 remove/add 操作。remove 元素请使用 Iterator 方式,如果并发操作,需要对 Iterator 对象加锁。fail-fast 机制:多个线程对 fail-fast 集合进行修改的时候,可能会抛出ConcurrentModificationException

可以利用 Set 元素唯一的特性,可以快速对一个集合进行去重操作,避免使用 Listcontains() 进行遍历去重或者判断包含操作。一个 O(n), 一个 O(1)

使用集合转数组的方法,必须使用集合的 toArray(T[] array),传入的是类型完全一致、长度为 0 的空数组

使用工具类 Arrays.asList() 把数组转换成集合时,不能使用其修改集合相关的方法, 它的 add/remove/clear 方法会抛出 UnsupportedOperationException 异常

1、Arrays.asList()是泛型方法,传递的数组必须是对象数组,而不是基本类型

java
int[] myArray = {1, 2, 3};  // 不正确
Integer[] myArray = {1, 2, 3};  // 正确,但仍不是正确将数组转换成集合的方式
List myList = Arrays.asList(myArray);  // 此时myList.size() = 1
List<Integer> myList = Arrays.stream(myArray).collect(Collectors.toList());  // 数组转集合的正确方式,myList.size() = 3

2、使用集合的修改方法: add()remove()clear()会抛出异常

Arrays.asList() 方法返回的并不是 java.util.ArrayList ,而是 java.util.Arrays 的一个内部类,这个内部类并没有实现集合的修改方法或者说并没有重写这些方法。

源码分析

ArrayList

ArrayList 的底层是数组队列,相当于动态数组。不保证线程安全。

int newCapacity = oldCapacity + (oldCapacity >> 1),所以 ArrayList 每次扩容之后容量都会变为原来的 1.5 倍左右(oldCapacity 为偶数就是 1.5 倍,否则是 1.5 倍左右)!

ArrayList 与 LinkedList 区别

  • 是否保证线程安全ArrayListLinkedList 都是不同步的,也就是不保证线程安全;
  • 底层数据结构ArrayList 底层使用的是 Object 数组LinkedList 底层使用的是 双向链表 数据结构(JDK1.6 之前为循环链表,JDK1.7 取消了循环。注意双向链表和双向循环链表的区别,下面有介绍到!)
  • 插入和删除是否受元素位置的影响:
    • ArrayList 采用数组存储,所以插入和删除元素的时间复杂度受元素位置的影响。 比如:执行add(E e)方法的时候, ArrayList 会默认在将指定的元素追加到此列表的末尾,这种情况时间复杂度就是 O(1)。但是如果要在指定位置 i 插入和删除元素的话(add(int index, E element)),时间复杂度就为 O(n)。因为在进行上述操作的时候集合中第 i 和第 i 个元素之后的(n-i)个元素都要执行向后位/向前移一位的操作。
    • LinkedList 采用链表存储,所以在头尾插入或者删除元素不受元素位置的影响(add(E e)addFirst(E e)addLast(E e)removeFirst()removeLast()),时间复杂度为 O(1),如果是要在指定位置 i 插入和删除元素的话(add(int index, E element)remove(Object o),remove(int index)), 时间复杂度为 O(n) ,因为需要先移动到指定位置再插入和删除。
  • 是否支持快速随机访问LinkedList 不支持高效的随机元素访问,而 ArrayList(实现了 RandomAccess 接口) 支持。快速随机访问就是通过元素的序号快速获取元素对象(对应于get(int index)方法)。
  • 内存空间占用ArrayList 的空间浪费主要体现在在 list 列表的结尾会预留一定的容量空间,而 LinkedList 的空间花费则体现在它的每一个元素都需要消耗比 ArrayList 更多的空间(因为要存放直接后继和直接前驱以及数据)

LinkedList

LinkedList 是一个基于双向链表实现的集合类,经常被拿来和 ArrayList 做比较。

不过,我们在项目中一般是不会使用到 LinkedList 的,需要用到 LinkedList 的场景几乎都可以使用 ArrayList 来代替,并且,性能通常会更好!

HashMap

HashMap 主要用来存放键值对,它基于哈希表的 Map 接口实现,是常用的 Java 集合之一,是非线程安全的。

HashMap 可以存储 null 的 key 和 value,但 null 作为键只能有一个,null 作为值可以有多个。

HashMap的底层实现

JDK1.8之前

HashMap 通过 key 的 hashcode 经过扰动函数处理过后得到 hash 值,然后通过 (n - 1) & hash 判断当前元素存放的位置(这里的 n 指的是数组的长度),如果当前位置存在元素的话,就判断该元素与要存入的元素的 hash 值以及 key 是否相同,如果相同的话,直接覆盖,不相同就通过拉链法解决冲突。

所谓 “拉链法” 就是:将链表和数组相结合。也就是说创建一个链表数组,数组中每一格就是一个链表。若遇到哈希冲突,则将冲突的值加到链表中即可。

jdk1.8 之前的内部结构-HashMap

JDK1.8之后

相比于之前的版本, JDK1.8 之后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间。

jdk1.8之后的内部结构-HashMap

红黑树就是为了解决二叉查找树的缺陷,因为二叉查找树在某些情况下会退化成一个线性结构。

“取余(%)操作中如果除数是 2 的幂次则等价于与其除数减一的与(&)操作(也就是说 hash%length==hash&(length-1)的前提是 length 是 2 的 n 次方)” 并且采用二进制位操作 &,相对于%能够提高运算效率,这就解释了 HashMap 的长度为什么是 2 的幂次方。

java
// 高位也就是2的n次方中的 n 位
1101 % 0100 = 0001  // 高位不用管,后面的就是余数
1101 & 0011 = 0001  // 与操作,结果就是hash的地位

HashMap 在JDK 1.8之前的版本用的是头插法,之后用的是尾插法,避免了环形结构。但还是建议在多线程操作下使用。推荐使用ConcurrentHashMap

并发编程

基础知识

进程是程序的一次执行过程,是系统运行程序的基本单位,因此进程是动态的。系统运行一个程序即是一个进程从创建,运行到消亡的过程。

线程与进程相似,但线程是一个比进程更小的执行单位。一个进程在其执行的过程中可以产生多个线程。与进程不同的是同类的多个线程共享进程的方法区资源,但每个线程有自己的程序计数器虚拟机栈本地方法栈,所以系统在产生一个线程,或是在各个线程之间做切换工作时,负担要比进程小得多,也正因为如此,线程也被称为轻量级进程。

一个 Java 程序的运行是 main 线程和多个其他线程同时运行

现在的 Java 线程的本质其实就是操作系统的线程

  • 用户线程:由用户空间程序管理和调度的线程,运行在用户空间(专门给应用程序使用)。
  • 内核线程:由操作系统内核管理和调度的线程,运行在内核空间(只有内核程序可以访问)。

线程模型是用户线程和内核线程之间的关联方式,常见的线程模型有这三种:

  1. 一对一(一个用户线程对应一个内核线程)
  2. 多对一(多个用户线程映射到一个内核线程)
  3. 多对多(多个用户线程映射到多个内核线程)

在 Windows 和 Linux 等主流操作系统中,Java 线程采用的是一对一的线程模型。

常见的三种线程模型

总结:线程是进程划分成的更小的运行单位。线程和进程最大的不同在于基本上各进程是独立的,而各线程则不一定,因为同一进程中的线程极有可能会相互影响。线程执行开销小,但不利于资源的管理和保护;而进程正相反

为什么 程序计数器虚拟机栈本地方法栈 是线程私有的呢?

程序计数器私有主要是为了线程切换后能恢复到正确的执行位置

为了保证线程中的局部变量不被别的线程访问到,虚拟机栈和本地方法栈是线程私有的。

  • 并发(Parallel):两个及两个以上的作业在同一 时间段 内执行。
  • 并行(Concurrent):两个及两个以上的作业在同一 时刻 执行。

最关键的点是:是否是 同时 执行。

  • 同步:发出一个调用之后,在没有得到结果之前, 该调用就不可以返回,一直等待。
  • 异步:调用在发出之后,不用等待返回结果,该调用直接返回。

线程安全指的是在多线程环境下,对于同一份数据,不管有多少个线程同时访问,都能保证这份数据的正确性和一致性。

线程不安全则表示在多线程环境下,对于同一份数据,多个线程同时访问时可能会导致数据混乱、错误或者丢失

对于单核 CPU 来说,如果任务是 CPU 密集型的,那么开很多线程会影响效率;如果任务是 IO 密集型的,那么开很多线程会提高效率。当然,这里的“很多”也要适度,不能超过系统能够承受的上限。

严格来说,Java 就只有一种方式可以创建线程,那就是通过new Thread().start()创建。不管是哪种方式,最终还是依赖于new Thread().start()

Java 线程在运行的生命周期中的指定时刻只可能处于下面 6 种不同状态的其中一个状态:

  • NEW: 初始状态,线程被创建出来但没有被调用 start()
  • RUNNABLE: 运行状态,线程被调用了 start()等待运行的状态。(包括running和ready,不区分是因为线程切换快。)
  • BLOCKED:阻塞状态,需要等待锁释放。
  • WAITING:等待状态,表示该线程需要等待其他线程做出一些特定动作(通知或中断)。
  • TIME_WAITING:超时等待状态,可以在指定的时间后自行返回而不是像 WAITING 那样一直等待。
  • TERMINATED:终止状态,表示该线程已经运行完毕。

线程在执行过程中会有自己的运行条件和状态(也称上下文)。线程切换意味着需要保存当前线程的上下文,留待线程下次占用 CPU 的时候恢复现场。并加载下一个将要占用 CPU 的线程上下文。这就是所谓的 上下文切换

产生死锁的四个必要条件:

  1. 互斥条件:该资源任意一个时刻只由一个线程占用。
  2. 请求与保持条件:一个线程因请求资源而阻塞时,对已获得的资源保持不放。
  3. 不剥夺条件:线程已获得的资源在未使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源。
  4. 循环等待条件:若干线程之间形成一种头尾相接的循环等待资源关系。

如何预防死锁? 破坏死锁的产生的必要条件即可:

  1. 破坏请求与保持条件:一次性申请所有的资源。
  2. 破坏不剥夺条件:占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。
  3. 破坏循环等待条件:靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。破坏循环等待条件。

如何避免死锁?

避免死锁就是在资源分配时,借助于算法(比如银行家算法)对资源分配进行计算评估,使其进入安全状态。

安全状态 指的是系统能够按照某种线程推进顺序(P1、P2、P3……Pn)来为每个线程分配所需资源,直到满足每个线程对资源的最大需求,使每个线程都可顺利完成。称 <P1、P2、P3.....Pn> 序列为安全序列。

sleep() 方法没有释放锁,而 wait() 方法释放了锁 。两者都可以暂停线程的执行。

wait()不定义在Thread中,sleep()定义在Thread

调用 start() 方法方可启动线程并使线程进入就绪状态,直接执行 run() 方法的话不会以多线程的方式执行

volatile 关键字通常用于多线程编程中标记共享变量

volatile 关键字可以保证变量的可见性,如果我们将变量声明为 volatile ,这就指示 JVM,这个变量是共享且不稳定的,每次使用它都到主存中进行读取。

volatile 关键字能保证数据的可见性,但不能保证数据的原子性synchronized 关键字两者都能保证。

在 Java 中,volatile 关键字除了可以保证变量的可见性,还有一个重要的作用就是防止 JVM 的指令重排序

双重校验锁实现对象单例(线程安全)

java
public class Singleton {

    private volatile static Singleton uniqueInstance;

    private Singleton() {
    }

    public  static Singleton getUniqueInstance() {
       //先判断对象是否已经实例过,没有实例化过才进入加锁代码
        if (uniqueInstance == null) {
            //类对象加锁
            synchronized (Singleton.class) {
                if (uniqueInstance == null) {
                    uniqueInstance = new Singleton();
                }
            }
        }
        return uniqueInstance;
    }
}

但是由于 JVM 具有指令重排的特性,执行顺序有可能变成 1->3->2。指令重排在单线程环境下不会出现问题,但是在多线程环境下会导致一个线程获得还没有初始化的实例。例如,线程 T1 执行了 1 和 3,此时 T2 调用 getUniqueInstance() 后发现 uniqueInstance 不为空,因此返回 uniqueInstance,但此时 uniqueInstance 还未被初始化。

悲观锁:共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程。synchronized和ReentrantLock

高并发下系统开销大,且存在死锁问题。

乐观锁总是假设最好的情况,认为共享资源每次被访问的时候不会出现问题,线程可以不停地执行,无需加锁也无需等待,只是在提交修改的时候去验证对应的资源是否被其它线程修改。AtomicInteger 和 LongAdder

写占比比较多时,冲突频繁发生,影响性能。

乐观锁使用版本号机制或 CAS 算法实现。后者相对更多。

版本号机制:线程读取和提交时版本号相同才会更新,否则重试。

CAS算法(Compare And Swap):CAS 是一个原子操作,底层依赖于一条 CPU 的原子指令。

CAS 涉及到三个操作数:

  • V:要更新的变量值(Var)
  • E:预期值(Expected)
  • N:拟写入的新值(New)

当且仅当 V 的值等于 E 时,CAS 通过原子方式用新值 N 来更新 V 的值。如果不等,说明已经有其它线程更新了 V,则当前线程放弃更新。

ABA 问题是 CAS 算法最常见的问题。即当 V 的值等于 E 时,也不能说明没有其他线程修改过资源,因为可能从a改成b又改成a。

ABA 问题的解决思路是在变量前面追加上版本号或者时间戳

悲观锁通常多用于写比较多的情况(多写场景,竞争激烈)

乐观锁通常多用于写比较少的情况(多读场景,竞争较少)

synchronized 主要解决的是多个线程之间访问资源的同步性,可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。

使用方式主要有下面 3 种:

  1. 修饰实例方法
  2. 修饰静态方法
  3. 修饰代码块

总结

  • synchronized 关键字加到 static 静态方法和 synchronized(class) 代码块上都是是给 Class 类上锁;
  • synchronized 关键字加到实例方法上是给对象实例上锁;
  • 尽量不要使用 synchronized(String a) 因为 JVM 中,字符串常量池具有缓存功能。

构造方法不能使用 synchronized 关键字修饰。构造方法本身就属于线程安全的,不存在同步的构造方法一说。

synchronized 同步语句块的实现使用的是 monitorentermonitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。

synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法。

不过两者的本质都是对对象监视器 monitor 的获取

synchronized 和 volatile 有什么区别?

synchronized 关键字和 volatile 关键字是两个互补的存在,而不是对立的存在!

  • volatile 关键字是线程同步的轻量级实现,所以 volatile性能肯定比synchronized关键字要好 。但是 volatile 关键字只能用于变量而 synchronized 关键字可以修饰方法以及代码块 。
  • volatile 关键字能保证数据的可见性(一个线程对共享变量的修改其他线程也能看到),但不能保证数据的原子性。synchronized 关键字两者都能保证。
  • volatile关键字主要用于解决变量在多个线程之间的可见性,而 synchronized 关键字解决的是多个线程之间访问资源的同步性。

ReentrantLock 实现了 Lock 接口,是一个可重入且独占式的锁,和 synchronized 关键字类似。不过,ReentrantLock 更灵活、更强大,增加了轮询、超时、中断、公平锁和非公平锁等高级功能。

公平锁 : 锁被释放之后,先申请的线程先得到锁。性能较差一些,因为公平锁为了保证时间上的绝对顺序,上下文切换更频繁。

非公平锁:锁被释放之后,后申请的线程可能会先获取到锁,是随机或者按照其他优先级排序的。性能更好,但可能会导致某些线程永远无法获取到锁。

synchronizedReetrantLock 都是可重入锁。可重入锁 也叫递归锁,指的是线程可以再次获取自己的内部锁。比如一个线程获得了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的,如果是不可重入锁的话,就会造成死锁。

可重入锁的一个典型应用场景是线程递归调用。这种锁的主要特性是重入性,同一个线程可以多次获得该锁,而不会被自己所持有的锁所阻塞。

synchronized 依赖于 JVM 而 ReentrantLock 依赖于 API

可中断锁:获取锁的过程中可以被中断,不需要一直等到获取锁之后 才能进行其他逻辑处理。ReentrantLock 就属于是可中断锁。

不可中断锁:一旦线程申请了锁,就只能等到拿到锁以后才能进行其他的逻辑处理。 synchronized 就属于是不可中断锁。

ReentrantReadWriteLock 其实是两把锁,一把是 WriteLock (写锁),一把是 ReadLock(读锁) 。读锁是共享锁,写锁是独占锁。读锁可以被同时读,可以同时被多个线程持有,而写锁最多只能同时被一个线程持有。只有读读不互斥

  • 共享锁:一把锁可以被多个线程同时获得。
  • 独占锁:一把锁只能被一个线程获得。

在线程持有读锁的情况下,该线程不能取得写锁(因为获取写锁的时候,如果发现当前的读锁被占用,就马上获取失败,不管读锁是不是被当前线程持有)。

在线程持有写锁的情况下,该线程可以继续获取读锁(获取读锁时如果发现写锁被占用,只有写锁没有被当前线程占用的情况才会获取失败)。

写锁可以降级为读锁,但是读锁却不能升级为写锁。

ThreadLocal类主要解决的就是让每个线程绑定自己的值,可以将ThreadLocal类形象的比喻成存放数据的盒子,盒子中可以存储每个线程的私有数据

每个Thread中都具备一个ThreadLocalMap,而ThreadLocalMap可以存储以ThreadLocal为 key ,Object 对象为 value 的键值对

ThreadLocalMap 中使用的 key 为 ThreadLocal 的弱引用,而 value 是强引用。所以,如果 ThreadLocal 没有被外部强引用的情况下,在垃圾回收的时候,key 会被清理掉,而 value 不会被清理掉。可能导致内存泄漏

image-20240405201825802

  1. 强引用(Strong Reference)
    • 强引用是最常见的引用类型,也是默认的引用类型。
    • 当一个对象被一个强引用所引用时,垃圾回收器不会回收该对象,即使内存不足时也不会回收,直到该对象没有任何强引用指向它时,才会被回收。
  2. 弱引用(Weak Reference)
    • 弱引用是一种比较弱的引用类型,它不会阻止对象被垃圾回收器回收。
    • 当一个对象只被弱引用所引用时,垃圾回收器会在下一次垃圾回收时将该对象回收,即使内存充足也会回收。
    • 弱引用通常用于建立缓存或映射表等数据结构,当对象不再被其他强引用引用时,就可以自动清理这些无用的缓存项。

线程池就是管理一系列线程的资源池。当有任务要处理时,直接从线程池中获取线程来处理,处理完之后线程并不会立即被销毁,而是等待下一个任务。

使用线程池的好处

  1. 降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。

  2. 提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立即执行。

  3. 提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。

池化技术是一种常见的资源管理技术,它旨在通过预先分配和重复使用资源,来提高系统的性能和资源利用率。在池化技术中,资源被预先创建并放置在一个池中,当需要使用资源时,不是直接创建新的资源,而是从池中获取已有的资源。使用完毕后,资源不被销毁,而是放回到池中以便后续重复利用。这样可以避免频繁地创建和销毁资源,提高了系统的性能和响应速度,减少了资源的浪费。

线程池创建方式

  1. 通过ThreadPoolExecutor构造函数来创建(推荐)
  2. 通过 Executor 框架的工具类 Executors 来创建。(OOM问题)

核心参数:corePoolSize, maximumPoolSize, workQueue

核心线程池满了,放到任务队列,任务队列满了,放到最大线程池帮忙处理。处理完空闲时间超时后销毁

image-20240405204149197

任务从保存到再加载的过程就是一次上下文切换

如何设定线程池大小:

  1. CPU密集型任务(N+1)
  2. I/O密集型任务(2N)

动态设定更好,通过修改三个核心参数。

假如我们需要实现一个优先级任务线程池的话,那可以考虑使用 PriorityBlockingQueue (优先级阻塞队列)作为任务队列。

多线程中经典的 Future 模式,你可以将其看作是一种设计模式,核心思想是异步调用,主要用在多线程领域,并非 Java 语言独有。

Future的功能:取消任务;判断任务是否被取消;判断任务是否已经执行完成;获取任务执行结果。

AQS 的全称为 AbstractQueuedSynchronizer ,翻译过来的意思就是抽象队列同步器。AQS 就是一个抽象类,主要用来构建锁和同步器。

AQS 核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制 AQS 是用 CLH 队列锁 实现的,即将暂时获取不到锁的线程加入到队列中。

AQS 使用 int 成员变量 state 表示同步状态,通过内置的 线程等待队列 来完成获取资源线程的排队工作。

synchronizedReentrantLock 都是一次只允许一个线程访问某个资源,而Semaphore(信号量)可以用来控制同时访问特定资源的线程数量。

Semaphore 是共享锁的一种实现,它默认构造 AQS 的 state 值为 permits,你可以将 permits 的值理解为许可证的数量,只有拿到许可证的线程才能执行。

CountDownLatch 允许 count 个线程阻塞在一个地方,直至所有线程的任务都执行完毕。

CountDownLatch 是共享锁的一种实现,它默认构造 AQS 的 state 值为 count

CountDownLatch 的作用就是 允许 count 个线程阻塞在一个地方,直至所有线程的任务都执行完毕。场景:六个无序任务,使用 count = 6 的CountDownLatch,count= 0 的时候执行后续逻辑。

CyclicBarrier 的字面意思是可循环使用(Cyclic)的屏障(Barrier)。它要做的事情是:让一组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续干活。

CyclicBarrierCountDownLatch 非常类似,它也可以实现线程间的技术等待,但是它的功能比 CountDownLatch 更加复杂和强大。主要应用场景和 CountDownLatch 类似。

乐观锁和悲观锁详解

悲观锁:共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程synchronizedReentrantLock

高并发下系统开销大,且存在死锁问题。

乐观锁总是假设最好的情况,认为共享资源每次被访问的时候不会出现问题,线程可以不停地执行,无需加锁也无需等待,只是在提交修改的时候去验证对应的资源是否被其它线程修改。AtomicInteger LongAdder

写占比比较多时,冲突频繁发生,影响性能。

乐观锁使用版本号机制或 CAS 算法实现。后者相对更多。

版本号机制:线程读取和提交时版本号相同才会更新,否则重试。

CAS算法(Compare And Swap):CAS 是一个原子操作,底层依赖于一条 CPU 的原子指令。

CAS 涉及到三个操作数:

  • V:要更新的变量值(Var)
  • E:预期值(Expected)
  • N:拟写入的新值(New)

当且仅当 V 的值等于 E 时,CAS 通过原子方式用新值 N 来更新 V 的值。如果不等,说明已经有其它线程更新了 V,则当前线程放弃更新。

ABA 问题是 CAS 算法最常见的问题。即当 V 的值等于 E 时,也不能说明没有其他线程修改过资源,因为可能从a改成b又改成a。

ABA 问题的解决思路是在变量前面追加上版本号或者时间戳

悲观锁通常多用于写比较多的情况(多写场景,竞争激烈)

乐观锁通常多用于写比较少的情况(多读场景,竞争较少)

总结

  • 高并发的场景下,激烈的锁竞争会造成线程阻塞,大量阻塞线程会导致系统的上下文切换,增加系统的性能开销。并且,悲观锁还可能会存在死锁问题,影响代码的正常运行。乐观锁相比悲观锁来说,不存在锁竞争造成线程阻塞,也不会有死锁的问题,在性能上往往会更胜一筹。不过,如果冲突频繁发生(写占比非常多的情况),会频繁失败和重试,这样同样会非常影响性能,导致 CPU 飙升。
  • 乐观锁一般会使用版本号机制或 CAS 算法实现,CAS 算法相对来说更多一些,这里需要格外注意。
  • CAS 的全称是 Compare And Swap(比较与交换) ,用于实现乐观锁,被广泛应用于各大框架中。CAS 的思想很简单,就是用一个预期值和要更新的变量值进行比较,两值相等才会进行更新
  • CAS 算法的问题:ABA 问题、循环时间长开销大、只能保证一个共享变量的原子操作。

JMM(Java 内存模型)详解

JMM(Java 内存模型)主要定义了对于一个共享变量,当另一个线程对这个共享变量执行写操作后,这个线程对这个共享变量的可见性

CPU缓存模型

CPU Cache 缓存的是内存数据用于解决 CPU 处理速度和内存不匹配的问题,内存缓存的是硬盘数据用于解决硬盘访问速度过慢的问题

CPU 为了解决内存缓存不一致性问题可以通过制定缓存一致协议(比如 MESI 协议)或者其他手段来解决

image-20240406140306163

指令重排序

什么是指令重排序? 简单来说就是系统在执行代码的时候并不一定是按照你写的代码的顺序依次执行。

常见的指令重排序有下面 2 种情况:

  • 编译器优化重排:编译器(包括 JVM、JIT 编译器等)在不改变单线程程序语义的前提下,重新安排语句的执行顺序。
  • 指令并行重排:现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。

另外,内存系统也会有“重排序”,但又不是真正意义上的重排序。在 JMM 里表现为主存和本地内存的内容可能不一致,进而导致程序在多线程下执行可能出现问题。

Java 源代码会经历 编译器优化重排 —> 指令并行重排 —> 内存系统重排 的过程,最终才变成操作系统可执行的指令序列。

指令重排序可以保证串行语义一致,但是没有义务保证多线程间的语义也一致 ,所以在多线程下,指令重排序可能会导致一些问题。

JMM (Java Memory Model)

Java 语言是跨平台的,它需要自己提供一套内存模型以屏蔽系统差异。

JMM 说白了就是定义了一些规范来解决这些问题,开发者可以利用这些规范更方便地开发多线程程序。对于 Java 开发者说,你不需要了解底层原理,直接使用并发相关的一些关键字和类(比如 volatilesynchronized、各种 Lock)即可开发出并发安全的程序。

Java 内存模型(JMM) 抽象了线程和主内存之间的关系,就比如说线程之间的共享变量必须存储在主内存中。

  • 主内存是所有线程共享的物理存储空间,用于存储程序的数据和指令。
  • 本地内存是每个线程私有的内存空间,用于存储线程的本地变量和方法调用信息。

image-20240406141227650

Java 内存区域和内存模型是完全不一样的两个东西

happens-before 原则的设计思想其实非常简单:

  • 为了对编译器和处理器的约束尽可能少,只要不改变程序的执行结果(单线程程序和正确执行的多线程程序),编译器和处理器怎么进行重排序优化都行。
  • 对于会改变程序执行结果的重排序,JMM 要求编译器和处理器必须禁止这种重排序。

happens-before 原则表达的意义其实并不是一个操作发生在另外一个操作的前面,虽然这从程序员的角度上来说也并无大碍。更准确地来说,它更想表达的意义是前一个操作的结果对于后一个操作是可见的,无论这两个操作是否在同一个线程里

并发编程的三个重要特征:

原子性:操作要么都执行,要么都不执行。

可见性:共享变量的修改对于其他线程都是可见的。

有序性:代码的执行顺序按照编写代码时候的顺序。

Java线程池详解

线程池一般用于执行多个不相关联的耗时任务,没有多线程的情况下,任务顺序执行,使用了线程池的话可让多个不相关联的任务同时执行

Executor 框架是 Java5 之后引进的,在 Java 5 之后,通过 Executor 来启动线程比使用 Threadstart 方法更好,除了更易管理,效率更好(用线程池实现,节约开销)外,还有关键的一点:有助于避免 this 逃逸问题。

线程池实现类 ThreadPoolExecutorExecutor 框架最核心的类。

线程池创建方式

  1. 通过ThreadPoolExecutor构造函数来创建(推荐)
  2. 通过 Executor 框架的工具类 Executors 来创建。(OOM问题)

核心参数:corePoolSize, maximumPoolSize, workQueue

过程原理(重要):核心线程池满了,放到任务队列,任务队列满了,放到最大线程池帮忙处理。处理完空闲时间超时后销毁

image-20240405204149197

Java 线程池最佳实践

线程池必须手动通过 ThreadPoolExecutor 的构造函数来声明,避免使用Executors 类创建线程池,会有 OOM 风险

使用有界队列,控制线程创建数量

不同类型的业务用不同的线程池,父子任务用同一个线程池可能死锁

别忘记给线程池命名,关闭线程池。尽量不要放耗时任务

正确配置线程池参数

任务从保存到再加载的过程就是一次上下文切换

如何设定线程池大小:

  1. CPU密集型任务(N+1)
  2. I/O密集型任务(2N)

动态设定更好,通过修改三个核心参数。

假如我们需要实现一个优先级任务线程池的话,那可以考虑使用 PriorityBlockingQueue (优先级阻塞队列)作为任务队列。

Java常见并发容器总结

ConcurrentHashMap : 线程安全的 HashMap

CopyOnWriteArrayList : 线程安全的 List,在读多写少的场合性能非常好,远远好于 Vector。写时复制(Copy-On-Write) 的策略

ConcurrentLinkedQueue : 高效的并发队列,使用链表实现。可以看做一个线程安全的 LinkedList,这是一个非阻塞队列。阻塞队列可以通过加锁来实现,非阻塞队列可以通过 CAS 操作实现

BlockingQueue : 这是一个接口,JDK 内部通过链表、数组等方式实现了这个接口。表示阻塞队列,非常适合用于作为数据共享的通道。

ConcurrentSkipListMap : 跳表的实现。这是一个 Map,使用跳表的数据结构进行快速查找。跳表是一种利用空间换时间的算法

AQS 详解

AQS 的全称为 AbstractQueuedSynchronizer ,翻译过来的意思就是抽象队列同步器。.

AQS 核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制 AQS 是基于 CLH 锁 (Craig, Landin, and Hagersten locks) 实现的。

AQS 使用 int 成员变量 state 表示同步状态,通过内置的 FIFO 线程等待/等待队列 来完成获取资源线程的排队工作。

基于 AQS 的常见同步工具类:semaphore, CountDownLatch, CyclicBarrier

Atomic 原子类总结

所谓原子类说简单点就是具有原子/原子操作特征的类。

基本类型

使用原子的方式更新基本类型

  • AtomicInteger:整型原子类
  • AtomicLong:长整型原子类
  • AtomicBoolean:布尔型原子类

数组类型

使用原子的方式更新数组里的某个元素

  • AtomicIntegerArray:整型数组原子类
  • AtomicLongArray:长整型数组原子类
  • AtomicReferenceArray:引用类型数组原子类

引用类型

  • AtomicReference:引用类型原子类
  • AtomicMarkableReference:原子更新带有标记的引用类型。该类将 boolean 标记与引用关联起来,也可以解决使用 CAS 进行原子更新时可能出现的 ABA 问题。
  • AtomicStampedReference:原子更新带有版本号的引用类型。该类将整数值与引用关联起来,可用于解决原子的更新数据和数据的版本号,可以解决使用 CAS 进行原子更新时可能出现的 ABA 问题。

ThreadLocal 详解

ThreadLocal对象可以提供线程局部变量,每个线程Thread拥有一份自己的副本变量,多个线程互不干扰。

CompletableFuture 详解

虚拟线程极简入门

IO

Java IO 基础知识总结

Java IO 流的 40 多个类都是从如下 4 个抽象类基类中派生出来的。

  • InputStream/Reader: 所有的输入流的基类,前者是字节输入流(文件读取到内存,原始字节),后者是字符输入流(文本)。
  • OutputStream/Writer: 所有输出流的基类,前者是字节输出流(数据写入到文件,原始字节),后者是字符输出流(文本)。

字节缓冲流:IO 操作是很消耗性能的,缓冲流将数据加载至缓冲区,一次性读取/写入多个字节,从而避免频繁的 IO 操作,提高流的传输效率。

BufferedInputStream (字节缓冲输入流)从源头(通常是文件)读取数据(字节信息)到内存的过程中不会一个字节一个字节的读取,而是会先将读取到的字节存放在缓存区,并从内部缓冲区中单独读取字节。这样大幅减少了 IO 次数,提高了读取效率。

BufferedOutputStream (字节缓冲输出流)将数据(字节信息)写入到目的地(通常是文件)的过程中不会一个字节一个字节的写入,而是会先将要写入的字节存放在缓存区,并从内部缓冲区中单独写入字节。这样大幅减少了 IO 次数,提高了读取效率

字符缓冲流BufferedReader (字符缓冲输入流)和 BufferedWriter(字符缓冲输出流)类似于 BufferedInputStream(字节缓冲输入流)和BufferedOutputStream(字节缓冲输入流),内部都维护了一个字节数组作为缓冲区。不过,前者主要是用来操作字符信息。

打印流:System.out.print("Hello world!");用到的是 PrintStream 对象,它是 OutputStream 的子类

随机访问流指的是支持随意跳转到文件的任意位置进行读写的 RandomAccessFile

Java IO 设计模式总结

装饰器(Decorator)模式 可以在不改变原有对象的情况下拓展其功能。

适配器(Adapter Pattern)模式 主要用于接口互不兼容的类的协调工作,你可以将其联想到我们日常经常使用的电源适配器。

装饰器模式 更侧重于动态地增强原始类的功能,装饰器类需要跟原始类继承相同的抽象类或者实现相同的接口。

适配器模式 更侧重于让接口不兼容而不能交互的类可以一起工作

工厂模式用于创建对象,NIO 中大量用到了工厂模式。(New IO)

NIO 中的文件目录监听服务使用到了观察者模式

Java IO 模式详解

根据冯.诺依曼结构,计算机结构分为 5 大部分:输入设备、输出设备、控制器、运算器、存储器。

从计算机结构的视角来看的话, I/O 描述了计算机系统与外部设备之间通信的过程

为了保证操作系统的稳定性和安全性,一个进程的地址空间划分为 用户空间(User space)内核空间(Kernel space )

在平常开发过程中接触最多的就是 磁盘 IO(读写文件)网络 IO(网络请求和响应)

从应用程序的视角来看的话,我们的应用程序对操作系统的内核发起 IO 调用(系统调用),操作系统负责的内核执行具体的 IO 操作。也就是说,我们的应用程序实际上只是发起了 IO 操作的调用而已,具体 IO 的执行是由操作系统的内核来完成的。

UNIX 系统下, IO 模型一共有 5 种

  1. 同步阻塞 I/O:调用IO操作时,程序会一直等待直到IO操作完成才返回结果。

  2. 同步非阻塞 I/O:调用IO操作后,会立即返回结果,无论IO操作是否完成。轮询检查 IO 是否完成。

  3. I/O 多路复用:监听多个IO操作的完成状态。完成后被唤醒。

  4. 信号驱动 I/O:发起IO操作后,会向操作系统注册一个信号处理函数。完成后会收到系统信号。

  5. 异步 I/O:发起IO操作后,无需等待操作完成,可以继续执行其他任务。完成后系统会收到通知。

Java中的3种常见 IO 模型

BIO (Blocking I/O)属于同步阻塞 IO 模型 。应用程序发起 read 调用后,会一直阻塞,直到内核把数据拷贝到用户空间。只能应对低并发。

NIO (Non-blocking/New I/O)属于 I/O 多路复用模型。有一个非常重要的选择器 ( Selector ) 的概念,也可以被称为 多路复用器

AIO (Asynchronous I/O)也就是 NIO 2,是异步 IO 模型

image-20240406163435260

Java NIO 核心知识总结

NIO 主要包括以下三个核心组件:

  • Buffer(缓冲区):NIO 读写数据都是通过缓冲区进行操作的。读操作的时候将 Channel 中的数据填充到 Buffer 中,而写操作时将 Buffer 中的数据写入到 Channel 中。

  • Channel(通道):Channel 是一个双向的、可读可写的数据传输通道,NIO 通过 Channel 来实现数据的输入输出。通道是一个抽象的概念,它可以代表文件、套接字或者其他数据源之间的连接。

  • Selector(选择器):允许一个线程处理多个 Channel,基于事件驱动的 I/O 多路复用模型。所有的 Channel 都可以注册到 Selector 上,由 Selector 来分配线程来处理事件。

    image-20240406172852932

Buffer

在传统的 BIO 中,数据的读写是面向流的, 分为字节流和字符流。

在 Java 1.4 的 NIO 库中,所有数据都是用缓冲区处理的,这是新库和之前的 BIO 的一个重要区别,有点类似于 BIO 中的缓冲流。NIO 在读取数据时,它是直接读到缓冲区中的。在写入数据时,写入到缓冲区中。 使用 NIO 在读写数据时,都是通过缓冲区进行操作。

Buffer 有读模式和写模式这两种模式,分别用于从 Buffer 中读取数据或者向 Buffer 中写入数据。Buffer 被创建之后默认是写模式,调用 flip() 可以切换到读模式。如果要再次切换回写模式,可以调用 clear() 或者 compact() 方法。

  1. 容量(capacity):Buffer可以存储的最大数据量,Buffer创建时设置且不可改变;

  2. 界限(limit):Buffer 中可以读/写数据的边界。写模式下,limit 代表最多能写入的数据,一般等于 capacity(可以通过limit(int newLimit)方法设置);读模式下,limit 等于 Buffer 中实际写入的数据大小。

  3. 位置(position):下一个可以被读写的数据的位置(索引)。从写操作模式到读操作模式切换的时候(flip),position 都会归零,这样就可以从头开始读写了。

  4. 标记(mark):Buffer允许将位置直接定位到该标记处,这是一个可选属性;

Buffer 最核心的两个方法:

  1. get : 读取缓冲区的数据
  2. put :向缓冲区写入数据

flip :将缓冲区从写模式切换到读模式,它会将 limit 的值设置为当前 position 的值,将 position 的值设置为 0。

clear: 清空缓冲区,将缓冲区从读模式切换到写模式,并将 position 的值设置为 0,将 limit 的值设置为 capacity 的值。

image-20240406185241611

Channel

Channel 是一个通道,它建立了与数据源(如文件、网络套接字等)之间的连接。我们可以利用它来读取和写入数据,就像打开了一条自来水管,让数据在 Channel 中自由流动。(File - Channel - Buffer

Channel 与前面介绍的 Buffer 打交道,读操作的时候将 Channel 中的数据填充到 Buffer 中,而写操作时将 Buffer 中的数据写入到 Channel 中。

Channel 最核心的两个方法:

  1. read :读取数据并写入到 Buffer 中。
  2. write :将 Buffer 中的数据写入到 Channel 中。

Selector

elector(选择器) 是 NIO 中的一个关键组件,它允许一个线程处理多个 Channel。Selector 是基于事件驱动的 I/O 多路复用模型,主要运作原理是:通过 Selector 注册通道的事件,Selector 会不断地轮询注册在其上的 Channel。

零拷贝是提升 IO 操作性能的一个常用手段,像 ActiveMQ、Kafka 、RocketMQ、QMQ、Netty 等顶级开源项目都用到了零拷贝。

零拷贝是指计算机执行 IO 操作时,CPU 不需要将数据从一个存储区域复制到另一个存储区域,从而可以减少上下文切换以及 CPU 的拷贝时间。也就是说,零拷贝主主要解决操作系统在处理 I/O 操作时频繁复制数据的问题。零拷贝的常见实现技术有: mmap+writesendfilesendfile + DMA gather copy

如果我们需要使用 NIO 构建网络程序的话,不建议直接使用原生 NIO,编程复杂且功能性太弱,推荐使用一些成熟的基于 NIO 的网络编程框架比如 Netty。Netty 在 NIO 的基础上进行了一些优化和扩展比如支持多种协议、支持 SSL/TLS 等等。

JVM

Java 内存区域详解(重点)

如果没有特殊说明,都是针对的是 HotSpot 虚拟机。

常见面试题:

  • 介绍下 Java 内存区域(运行时数据区)
  • Java 对象的创建过程(五步,建议能默写出来并且要知道每一步虚拟机做了什么)
  • 对象的访问定位的两种方式(句柄和直接指针两种方式)

Java 虚拟机在执行 Java 程序的过程中会把它管理的内存划分成若干个不同的数据区域

线程私有的

  • 程序计数器:程序计数器是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。
  • 虚拟机栈:它的生命周期和线程相同,随着线程的创建而创建,随着线程的死亡而死亡。栈由一个个栈帧组成,而每个栈帧中都拥有:局部变量表、操作数栈、动态链接、方法返回地址。和数据结构上的栈类似,两者都是先进后出的数据结构,只支持出栈和入栈两种操作。
    • 栈帧随着方法调用而创建,随着方法结束而销毁。无论方法正常完成还是异常完成都算作方法结束
  • 本地方法栈
    • 虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务

线程共享的

  • 堆:此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存
  • 方法区:存储已被虚拟机加载的 类信息、字段信息、方法信息、常量、静态变量、即时编译器编译后的代码缓存等数据
  • 直接内存 (非运行时数据区的一部分):一种特殊的内存缓冲区,并不在 Java 堆或方法区中分配的,而是通过 JNI 的方式在本地内存上分配的。

image-20240406194549442

运行时常量池(Runtime Constant Pool)是一种用于存储编译时常量和运行时生成的一些常量的内存区域。

字符串常量池 是 JVM 为了提升性能和减少内存消耗针对字符串(String 类)专门开辟的一块区域,主要目的是为了避免字符串的重复创建。

Java 对象的创建过程

  1. 类加载检查:加载对象所属的类。
  2. 分配内存:分配方式有 “指针碰撞” 和 “空闲列表” 两种。以上两种方式中的哪一种,取决于 Java 堆内存是否规整(规整:指针碰撞;不规整:空闲列表)。而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。虚拟机采用两种方式来保证线程安全:CAS + 失败重试;TLAB。
  3. 初始化零值:对对象进行初始化。
  4. 设置对象头:虚拟机要对对象进行必要的设置。
  5. 执行 init 方法:把对象按照程序员的意愿进行初始化。

对象就是类的实例化

对象的内存布局

包括3个区域:

  1. 对象头:包括两部分信息:第一部分用于存储对象自身的运行时数据,另一部分是指针。

  2. 实例数据:对象真正存储的有效信息。

  3. 对象填充:不是必然存在的,也没有什么特别的含义,仅仅起站占位作用。对象的大小必须是 8 字节的整数倍。

对象的访问方式由虚拟机实现而定,目前主流的访问方式有:使用句柄直接指针(HotSpot主要使用)。

  • reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与对象类型数据各自的具体地址信息。
  • reference 中存储的直接就是对象的地址。

JVM 垃圾回收详解(重点)

常见面试题:

  • 如何判断对象是否死亡(两种方法)。
  • 简单的介绍一下强引用、软引用、弱引用、虚引用(虚引用与软引用和弱引用的区别、使用软引用能带来的好处)。
  • 如何判断一个常量是废弃常量
  • 如何判断一个类是无用的类
  • 垃圾收集有哪些算法,各自的特点?
  • HotSpot 为什么要分为新生代和老年代?
  • 常见的垃圾回收器有哪些?
  • 介绍一下 CMS,G1 收集器。
  • Minor Gc 和 Full GC 有什么不同呢?

Java 的自动内存管理主要是针对对象内存的回收和对象内存的分配。同时,Java 自动内存管理最核心的功能是 内存中对象的分配与回收。

Java 堆是垃圾收集器管理的主要区域,因此也被称作 GC 堆(Garbage Collected Heap)

JDK 1.7 之前堆内存:新生代、老生代、永久代。

JDK 1.8 之后堆内存:新生代、老生代、元空间(使用的是直接内存,不在堆内存)

针对 HotSpot VM 的实现,它里面的 GC 其实准确分类只有两大种:

部分收集 (Partial GC):

  • 新生代收集(Minor GC / Young GC):只对新生代进行垃圾收集;
  • 老年代收集(Major GC / Old GC):只对老年代进行垃圾收集。需要注意的是 Major GC 在有的语境中也用于指代整堆收集;
  • 混合收集(Mixed GC):对整个新生代和部分老年代进行垃圾收集。

整堆收集 (Full GC):收集整个 Java 堆和方法区。

空间分配担保是为了确保在 Minor GC 之前老年代本身还有容纳新生代所有对象的剩余空间。

堆中几乎放着所有的对象实例,对堆垃圾回收前的第一步就是要判断哪些对象已经死亡。

  • 引用计数器:有引用加1,引用失效减1。实现简单,效率高,但是目前主流的虚拟机中并没有选择这个算法来管理内存,其最主要的原因是它很难解决对象之间循环引用的问题
  • 可达性分析算法:通过一系列的称为 “GC Roots” 的对象作为起点,从这些节点开始向下搜索,节点所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连的话,则证明此对象是不可用的,需要被回收。

引用类型:(引用强度逐渐减弱)

  • 强引用:使用最普通的引用。
  • 软引用:可有可无的生活用品。内存空间足够,就不会回收。
  • 弱引用:可有可无的生活用品。垃圾回收器线程发现就回收。
  • 虚引用:任何时候都有可能被垃圾回收。主要用来跟踪对象被垃圾回收的活动。

如何判断一个常量是废弃常量:没有任何引用的常量。

如何判断一个类是无用的类:(先要满足以下三个条件)

  1. 该类的所有实例都已经被回收
  2. 加载该类的 ClassLoader 已经被回收
  3. 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

垃圾收集算法

  • 标记-清除算法:首先标记出所有不需要回收的对象,在标记完成后统一回收掉所有没有被标记的对象。(存在算法的效率和内存碎片问题)
  • 标记-复制算法:它可以将内存分为大小相同的两块,每次使用其中的一块。当这一块的内存使用完后,就将还存活的对象复制到另一块去,然后再把使用的空间一次清理掉。这样就使每次的内存回收都是对内存区间的一半进行回收。(可用内存减半,不适合老年代)
  • 标记-整理算法:标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象回收,而是让所有存活的对象向一端移动,然后直接清理掉端边界以外的内存。(效率不高,适合老年代这种回收频率不高的场景)
  • 分代收集算法:新生代用复制算法,老年代用标记-清除或者标记-整理算法。

一般将 Java 堆分为新生代和老年代,这样我们就可以根据各个年代的特点选择合适的垃圾收集算法。

如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现

没有万能的垃圾收集器,我们能做的就是根据具体应用场景选择适合自己的垃圾收集器

  • Serial收集器:新生代采用标记-复制算法,老年代采用标记-整理算法。单线程,简单而高效。
  • ParNew收集器:Serial的多线程版本。
  • Parallel Scavenge收集器:和 ParNew 也差不多,但是提供了很多参数供用户找到最合适的停顿时间或最大吞吐量。
  • Serial Old收集器:Serial收集器的老年代版本。
  • Parallel Old收集器:Parallel Scavenge 收集器的老年代版本。
  • CMS收集器:一种以获取最短回收停顿时间为目标的收集器。它非常符合在注重用户体验的应用上使用。第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。并发收集、低停顿
  • G1收集器:一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器。以极高概率满足 GC 停顿时间要求的同时,还具备高吞吐量性能特征。在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的 Region(这也就是它的名字 Garbage-First 的由来)
  • ZGC收集器:可以将暂停时间控制在几毫秒以内,且暂停时间不受堆内存大小的影响,出现 Stop The World 的情况会更少,但代价是牺牲了一些吞吐量。ZGC 最大支持 16TB 的堆内存。

类加载器详解(重点)

  • 类加载过程:加载->连接->初始化
  • 连接过程又可分为三步:验证->准备->解析

Linux

一切被操作系统管理的资源,如网络接口卡、磁盘驱动器、打印机、输入输出设备、普通文件或目录等,都被视为文件。这是 Linux 系统中一个重要的概念,即"一切都是文件"。

存储文件元信息的区域就叫 inode,译为索引节点:i(index)+node每个文件都有一个唯一的 inode,存储文件的元信息

  • 硬链接:硬链接和源文件的 inode 节点号相同,两者对文件系统来说是完全平等的(可以看作是互为硬链接,源头是同一份文件)
  • 软链接:软链接和源文件的 inode 节点号不同,而是指向一个文件路径。可以理解为windows的快捷方式。

Shell 编程就是对一堆 Linux 命令的逻辑化处理

运行脚本:./helloworld.sh (注意不要只用helloworld.sh

开发工具

Maven

Apache Maven 的本质是一个软件项目管理和理解工具。基于项目对象模型 (Project Object Model,POM) 的概念,Maven 可以从一条中心信息管理项目的构建、报告和文档。

pom.xml 文件,位于根目录中,包含项目构建生命周期的详细信息。通过 pom.xml 文件,我们可以定义项目的坐标、项目依赖、项目信息、插件信息等等配置。

Maven的主要作用:项目构建、依赖管理和统一开发结构

项目中依赖的第三方库以及插件可统称为构件。每一个构建都有坐标唯一标识:

  • groupId(必须): 定义了当前 Maven 项目隶属的组织或公司。

  • artifactId(必须):定义了当前 Maven 项目的名称,项目的唯一的标识符,对应项目根目录的名称。

  • version(必须):定义了 Maven 项目当前所处版本。

xml
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>easyexcel</artifactId>
    <version>3.1.1</version>
</dependency>

如果使用 Maven 构建产生的构件(例如 Jar 文件)被其他的项目引用,那么该构件就是其他项目的依赖

classpath 用于指定 .class 文件存放的位置,类加载器会从该路径中加载所需的 .class 文件到内存中。

Maven 在编译、执行测试、实际运行有着三套不同的 classpath:

  • 编译 classpath:编译主代码有效
  • 测试 classpath:编译、运行测试代码有效
  • 运行 classpath:项目运行时有效

依赖冲突

  1. 对于 Maven 而言,同一个 groupId 同一个 artifactId 下,只能使用一个 version。(两个版本存在于同一个pom文件,引入后者)

  2. 项目的两个依赖同时引入了某个依赖

    1. 路径最短优先:选择依赖链路短的。
    2. 声明顺序优先:在依赖路径长度相等的前提下,顺序最前的那个依赖优先。

单存依赖Maven来进行依赖调解,可能会有问题,所以需要用到 exclusion 标签来手动排除。一般排除低版本。但如果高版本修改了低版本的类或者方法时,就应该考虑优化上层依赖。(升级上层依赖版本)

Maven 仓库分为:

  • 本地仓库:运行 Maven 的计算机上的一个目录,它缓存远程下载的构件并包含尚未发布的临时构件。settings.xml 文件中可以看到 Maven 的本地仓库路径配置,默认本地仓库路径是在 ${user.home}/.m2/repository
  • 远程仓库:官方或者其他组织维护的 Maven 仓库。

Maven 依赖包寻找顺序:

  1. 先去本地仓库找寻,有的话,直接使用。
  2. 本地仓库没有找到的话,会去远程仓库找寻,下载包到本地仓库。
  3. 远程仓库没有找到的话,会报错。

Maven 的生命周期就是为了对所有的构建过程进行抽象和统一,包含了项目的清理、初始化、编译、测试、打包、集成测试、验证、部署和站点生成等几乎所有构建步骤

Maven 定义了 3 个生命周期META-INF/plexus/components.xml

  • default 生命周期:在没有任何关联插件的情况下定义的,是 Maven 的主要生命周期,用于构建应用程序,共包含 23 个阶段。
  • clean生命周期:目的是清理项目,共包含 3 个阶段:pre-claen, clean, post-clean
  • site生命周期:目的是建立和发布项目站点,共包含 4 个阶段:pre-site, site, post-site, site-deploy.

Maven 本质上是一个插件执行框架,所有的执行过程,都是由一个一个插件独立完成的。像咱们日常使用到的 install、clean、deploy 等命令,其实底层都是一个一个的 Maven 插件。

Maven多模块管理简单地来说就是将一个项目分为多个模块,每个模块只负责单一的功能实现。直观的表现就是一个 Maven 项目中不止有一个 pom.xml 文件,会在不同的目录中有多个 pom.xml 文件,进而实现多模块管理。

多模块管理除了可以更加便于项目开发和管理,还有如下好处

  1. 降低代码之间的耦合性(从类级别的耦合提升到 jar 包级别的耦合);
  2. 减少重复,提升复用性;
  3. 每个模块都可以是自解释的(通过模块名或者模块文档);
  4. 模块还规范了代码边界的划分,开发者很容易通过模块确定自己所负责的内容。

Maven项目的标准目录结构:

bash
project

├── src
   ├── main
   ├── java        # Java 源代码目录
   ├── resources   # 资源文件目录(例如application.yml配置文件、XML 文件等)

   └── test
       ├── java        # 测试用例 Java 源代码目录
       └── resources   # 测试资源文件目录

├── target              # 编译输出目录,包括编译生成的 class 文件、打包文件等
├── pom.xml             # Maven 项目配置文件
└── (其他项目相关文件和目录)

在父模块的pom.xml文件中使用<dependencyManagement>标签来定义子模块中的版本。

Maven 配置文件允许我们配置不同环境的构建设置,例如开发、测试和生产。在 pom.xml 文件中定义配置文件并使用命令行参数激活它们。

维护干净的 pom.xml 的一些技巧

  • 将相似的依赖项和插件组合在一起。
  • 使用注释来描述特定依赖项或插件的用途。
  • 将插件和依赖项的版本号保留在 <properties> 标签内以便于管理。

Maven Wrapper 是一种简单的方法,可以确保 Maven 构建的用户拥有运行 Maven 构建所需的一切

Git

版本控制是一种记录一个或若干文件内容变化,以便将来查阅特定版本修订情况的系统

Git 就是一个典型的分布式版本控制系统

这类系统,客户端并不只提取最新版本的文件快照,而是把代码仓库完整地镜像下来。 这么一来,任何一处协同工作用的服务器发生故障,事后都可以用任何一个镜像出来的本地仓库恢复。 因为每一次的克隆操作,实际上都是一次对代码仓库的完整备份。

Git 采用的是直接记录快照的方式,而非差异比较。我后面会详细介绍这两种方式的差别

  • 其他:将它们保存的信息看作是一组基本文件和每个文件随时间逐步累积的差异。(增量,内容多的话浪费时间和性能)
  • Git:把数据看作是对小型文件系统的一组快照

Git 有三种状态,你的文件可能处于其中之一:

  1. 已提交(committed):数据已经安全的保存在本地数据库中。
  2. 已修改(modified):已修改表示修改了文件,但还没保存到数据库中。
  3. 已暂存(staged):表示对一个已修改文件的当前版本做了标记,使之包含在下次提交的快照中。

基本的 Git 工作流程如下

  1. 在工作目录中修改文件。
  2. 暂存文件,将文件的快照放入暂存区域。
  3. 提交更新,找到暂存区域的文件,将快照永久性存储到 Git 仓库目录。

有两种取得 Git 项目仓库的方法。

  1. 在现有目录中初始化仓库: 进入项目目录运行 git init 命令,该命令将创建一个名为 .git 的子目录。
  2. 从一个服务器克隆一个现有的 Git 仓库: git clone [url] 自定义本地仓库的名字: git clone [url] directoryname

推送改动到远程仓库

  • 如果你还没有克隆现有仓库,并欲将你的仓库连接到某个远程服务器,你可以使用如下命令添加:git remote add origin <server> ,比如我们要让本地的一个仓库和 GitHub 上创建的一个仓库关联可以这样git remote add origin https://github.com/Snailclimb/test.git

  • 将这些改动提交到远端仓库:git push origin master (可以把 master 换成你想要推送的任何分支)

    如此你就能够将你的改动推送到所添加的服务器上去了。

分支是用来将特性开发绝缘开来的。在你创建仓库的时候,master 是“默认”的分支。在其他分支上进行开发,完成后再将它们合并到主分支上。

使用分支其实就相当于在说:“我想基于这个提交以及它所有的父提交进行新的工作。”

我们通常在开发新功能、修复一个紧急 bug 等等时候会选择创建分支。单分支开发好还是多分支开发好,还是要看具体场景来说。

常用框架

Spring 常见面试题总结

Spring 是一款开源的轻量级 Java 开发框架,旨在提高开发人员的开发效率以及系统的可维护性。

Spring 最核心的思想就是不重新造轮子,开箱即用,提高开发效率

语言的流行通常需要一个杀手级的应用,Spring 就是 Java 生态的一个杀手级的应用框架

Spring 提供的核心功能主要是 IoC 和 AOP

Spring,Spring MVC,Spring Boot 之间什么关系?

Spring 包含了多个功能模块,其中最重要的是 Spring-Core(主要提供 IoC 依赖注入功能的支持) 模块, Spring 中的其他模块(比如 Spring MVC)的功能实现基本都需要依赖于该模块。

Spring MVC 是 Spring 中的一个很重要的模块,主要赋予 Spring 快速构建 MVC 架构的 Web 程序的能力。MVC 是模型(Model)、视图(View)、控制器(Controller)的简写,其核心思想是通过将业务逻辑、数据、显示分离来组织代码。

Spring Boot 旨在简化 Spring 开发(减少配置文件,开箱即用!)

Spring Boot 只是简化了配置,如果你需要构建 MVC 架构的 Web 程序,你还是需要使用 Spring MVC 作为 MVC 框架,只是说 Spring Boot 帮你简化了 Spring MVC 的很多配置,真正做到开箱即用!

总结来说,Spring Framework 是整个 Spring 生态系统的核心和基础,Spring MVC 是 Spring Framework 中用于构建 Web 应用程序的模块,而 Spring Boot 是 Spring Framework 的一个扩展,用于简化和加速 Spring 应用程序的开发和部署。Spring Boot 可以与 Spring MVC 结合使用,也可以与其他 Spring 模块(如 Spring Data、Spring Security 等)结合使用,以构建完整的企业级应用程序。

IoC(Inversion of Control:控制反转) 是一种设计思想,而不是一个具体的技术实现。IoC 的思想就是将原本在程序中手动创建对象的控制权,交由 Spring 框架来管理。不过, IoC 并非 Spring 特有,在其他语言中也有应用。

为什么叫控制反转?

  • 控制:指的是对象创建(实例化、管理)的权力
  • 反转:控制权交给外部环境(Spring 框架、IoC 容器)

在 Spring 中, IoC 容器是 Spring 用来实现 IoC 的载体, IoC 容器实际上就是个 Map(key,value),Map 中存放的是各种对象。

Spring 时代我们一般通过 XML 文件来配置 Bean,后来开发人员觉得 XML 文件来配置不太好,于是 SpringBoot 注解配置就慢慢开始流行起来。

Bean 代指的就是那些被 IoC 容器所管理的对象

将一个类声明为 Bean 的注解有哪些?

  • @Component:通用的注解,可标注任意类为 Spring 组件。如果一个 Bean 不知道属于哪个层,可以使用@Component 注解标注。
  • @Repository : 对应持久层即 Dao 层,主要用于数据库相关操作。
  • @Service : 对应服务层,主要涉及一些复杂的逻辑,需要用到 Dao 层。
  • @Controller : 对应 Spring MVC 控制层,主要用于接受用户请求并调用 Service 层返回数据给前端页面。

@Component 和 @Bean 的区别是什么?@Component 注解作用于类,而@Bean注解作用于方法。@Component通常是通过类路径扫描来自动侦测以及自动装配到 Spring 容器中(我们可以使用 @ComponentScan 注解定义要扫描的路径从中找出标识了需要装配的类自动装配到 Spring 的 bean 容器中)。@Bean 注解通常是我们在标有该注解的方法中定义产生这个 bean,@Bean告诉了 Spring 这是某个类的实例,当我需要用它的时候还给我。@Bean 注解比 @Component 注解的自定义性更强,而且很多地方我们只能通过 @Bean 注解来注册 bean。比如当我们引用第三方库中的类需要装配到 Spring容器时,则只能通过 @Bean来实现。

@Component 和 @Bean 的区别是什么?

  • @Component 注解作用于类,而@Bean注解作用于方法
  • @Component通常是通过类路径扫描来自动侦测以及自动装配到 Spring 容器中(我们可以使用 @ComponentScan 注解定义要扫描的路径从中找出标识了需要装配的类自动装配到 Spring 的 bean 容器中)。@Bean 注解通常是我们在标有该注解的方法中定义产生这个 bean,@Bean告诉了 Spring 这是某个类的实例,当我需要用它的时候还给我。
  • @Bean 注解比 @Component 注解的自定义性更强,而且很多地方我们只能通过 @Bean 注解来注册 bean。比如当我们引用第三方库中的类需要装配到 Spring容器时,则只能通过 @Bean来实现。

Spring 内置的 @Autowired 以及 JDK 内置的 @Resource@Inject 都可以用于注入 Bean。@Autowired@Resource使用的比较多一些。

Bean的生命周期:

  1. 创建 Bean 的实例
  2. Bean 属性赋值/填充
  3. Bean 初始化
  4. 销毁 Bean

AOP(Aspect-Oriented Programming:面向切面编程)能够将那些与业务无关,却为业务模块所共同调用的逻辑或责任(例如事务处理、日志管理、权限控制等)封装起来,便于减少系统的重复代码,降低模块间的耦合度,并有利于未来的可拓展性和可维护性

MVC 是模型(Model)、视图(View)、控制器(Controller)的简写,其核心思想是通过将业务逻辑、数据、显示分离来组织代码

MVC 是一种设计模式,Spring MVC 是一款很优秀的 MVC 框架。Spring MVC 可以帮助我们进行更简洁的 Web 层的开发,并且它天生与 Spring 框架集成。Spring MVC 下我们一般把后端项目分为 Service 层(处理业务)、Dao 层(数据库操作)、Entity 层(实体类)、Controller 层(控制层,返回数据给前台页面)

@Transactional 注解作用于类上时,该类的所有 public 方法将都具有该类型的事务属性,同时,我们也可以在方法级别使用该标注来覆盖类级别的定义。

Spring&SpringBoot 常用注解总结

  1. @SpringBootApplication:放在启动类上。可以看做以下三者的集合。

    1. @Configuration:允许在 Spring 上下文中注册额外的 bean 或导入其他配置类
    2. @EnableAutoConfiguration:启用 SpringBoot 的自动配置机制
    3. @ComponentScan:扫描被@Component (@Repository,@Service,@Controller)注解的 bean,注解默认会扫描该类所在的包下所有的类。
  2. Spring Bean 相关

    1. @Autowired:自动导入对象到类中

      1. java
        @Service
        public class UserService {
          ......
        }
        
        @RestController
        @RequestMapping("/users")
        public class UserController {
           @Autowired
           private UserService userService;
           ......
        }
    2. @Component,@Repository,@Service, @Controller

      1. 要想把类标识成可用于 @Autowired 注解自动装配的 bean 的类,可以采用以下注解实现:
        • @Component:通用的注解,可标注任意类为 Spring 组件。如果一个 Bean 不知道属于哪个层,可以使用@Component 注解标注。
        • @Repository : 对应持久层即 Dao 层,主要用于数据库相关操作。
        • @Service : 对应服务层,主要涉及一些复杂的逻辑,需要用到 Dao 层。
        • @Controller : 对应 Spring MVC 控制层,主要用于接受用户请求并调用 Service 层返回数据给前端页面。
    3. @RestController:是@Controller@ResponseBody的合集,表示这是个控制器 bean,并且是将函数的返回值直接填入 HTTP 响应体中,是 REST 风格的控制器。

    4. @Scope:声明 Spring Bean 的作用域

      1. 四种常见的 Spring Bean 的作用域
        • singleton : 唯一 bean 实例,Spring 中的 bean 默认都是单例的。
        • prototype : 每次请求都会创建一个新的 bean 实例。
        • request : 每一次 HTTP 请求都会产生一个新的 bean,该 bean 仅在当前 HTTP request 内有效。
        • session : 每一个 HTTP Session 会产生一个新的 bean,该 bean 仅在当前 HTTP session 内有效。
    5. @Configration:声明配置类

  3. 处理常见的 HTTP 请求类型

    1. GET请求:请求从服务器获取特定资源。(查)

      1. @GetMapping("users") 等价于@RequestMapping(value="/users",method=RequestMethod.GET)
    2. POST请求:在服务器上创建一个新的资源。(客户端提供更新后的整个资源)(增)

      1. @PostMapping("users")等价于@RequestMapping(value="/users",method=RequestMethod.POST)
    3. PUT请求:更新服务器上的资源。(改)

      1. @PutMapping("/users/{userId}") 等价于@RequestMapping(value="/users/{userId}",method=RequestMethod.PUT)
    4. DELETE请求:从服务器删除特定的资源。(删)

      1. @DeleteMapping("/users/{userId}")等价于@RequestMapping(value="/users/{userId}",method=RequestMethod.DELETE)
    5. PATCH请求:更新服务器上的资源(客户端提供更改的属性,可以看做作是部分更新)

      1. 一般实际项目中,我们都是 PUT 不够用了之后才用 PATCH 请求去更新数据。
  4. 前后端传值

    1. @PathVariable用于获取路径参数,@RequestParam用于获取查询参数。
    2. @RequestBody用于读取 Request 请求(可能是 POST,PUT,DELETE,GET 请求)的 body 部分并且Content-Type 为 application/json 格式的数据,接收到数据之后会自动将数据绑定到 Java 对象上去。
  5. 读取配置信息

    1. @Value("${xxx}")读取简单配置信息。
    2. @ConfigurationProperties读取配置信息并与 bean 绑定。
  6. 参数校验

    1. 即使在前端对数据进行校验的情况下,我们还是要对传入后端的数据再进行一遍校验,避免用户绕过浏览器直接通过一些 HTTP 工具直接向后端请求一些违法数据

    2. 引入 spring-boot-starter-validation 依赖

    3. 一些常用的字段验证的注解

      • @NotEmpty 被注释的字符串的不能为 null 也不能为空
      • @NotBlank 被注释的字符串非 null,并且必须包含一个非空白字符
      • @Null 被注释的元素必须为 null
      • @NotNull 被注释的元素必须不为 null
      • @AssertTrue 被注释的元素必须为 true
      • @AssertFalse 被注释的元素必须为 false
      • @Pattern(regex=,flag=)被注释的元素必须符合指定的正则表达式
      • @Email 被注释的元素必须是 Email 格式。
      • @Min(value)被注释的元素必须是一个数字,其值必须大于等于指定的最小值
      • @Max(value)被注释的元素必须是一个数字,其值必须小于等于指定的最大值
      • @DecimalMin(value)被注释的元素必须是一个数字,其值必须大于等于指定的最小值
      • @DecimalMax(value) 被注释的元素必须是一个数字,其值必须小于等于指定的最大值
      • @Size(max=, min=)被注释的元素的大小必须在指定的范围内
      • @Digits(integer, fraction)被注释的元素必须是一个数字,其值必须在可接受的范围内
      • @Past被注释的元素必须是一个过去的日期
      • @Future 被注释的元素必须是一个将来的日期
    4. 验证请求体(RequestBody)

    5. 验证请求参数(Path Variables 和 Request Parameters):一定一定不要忘记在类上加上 @Validated 注解了,这个参数可以告诉 Spring 去校验方法参数

  7. 全局处理 Controller 层异常

    1. @ControllerAdvice :注解定义全局异常处理类
    2. @ExceptionHandler :注解声明异常处理方法
  8. JPA 相关(Java Persistence API,即 Java 持久化 API)

    1. 创建表:@Entity声明一个类对应一个数据库实体;@Table 设置表名

    2. 创建主键:@Id:声明一个字段为主键。还可以使用 @GeneratedValue 指定主键生成策略。

    3. 设置字段类型:@Column 声明字段。

    4. 指定不持久化特定字段:@Transient:声明不需要与数据库映射的字段,在保存的时候不需要保存进数据库 。

      1. 在对象关系映射(ORM)领域中,持久化通常指将内存中的对象状态转换为永久存储的状态,以便在应用程序重新启动或重新加载时恢复对象的状态。
    5. 声明大字段:@Lob:声明某个字段为大字段。

    6. 创建枚举类型的字段:枚举字段要用@Enumerated注解修饰。

    7. 增加审计功能

    8. 删除/修改数据:@Modifying 注解提示 JPA 该操作是修改操作,注意还要配合@Transactional注解使用。

    9. 关联关系

  9. 事务 @Transactional注解一般可以作用在或者方法上。

  10. json 数据处理

    1. 过滤 json 数据:

      1. @JsonIgnoreProperties 作用在类上用于过滤掉特定字段不返回或者不解析
      2. @JsonIgnore一般用于类的属性上,作用和上面的@JsonIgnoreProperties 一样
    2. 格式化 json 数据:@JsonFormat一般用来格式化 json 数据。

    3. 扁平化对象

  11. 测试相关

    1. @ActiveProfiles一般作用于测试类上, 用于声明生效的 Spring 配置文件
    2. @Test声明一个方法为测试方法
    3. @Transactional被声明的测试方法的数据会回滚,避免污染测试数据
    4. @WithMockUser Spring Security 提供的,用来模拟一个真实用户,并且可以赋予权限

短链接中的常见注解

@RequiredArgsConstructor是 Lombok 提供的一个注解,用于自动生成一个包含所有未初始化(即未被赋值)属性的构造函数。它可以减少代码量,并且使得代码更加简洁

java
import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor
public class MyClass {
    private final int id;
    private String name;
}

@Override 是 Java 中的一个注解,用于标识方法重写(Override)父类或接口中的方法。当一个方法被 @Override 注解标记时,编译器会检查该方法是否确实覆盖了父类或接口中的方法,如果没有覆盖成功则会报编译错误

serviceImpl文件中比较多

java
public class Animal {
    public void eat() {
        System.out.println("Animal is eating");
    }
}

public class Dog extends Animal {
    @Override
    public void eat() {
        System.out.println("Dog is eating");
    }
}

@Slf4j 是 Lombok 提供的一个注解,用于自动生成日志记录器(Logger)的代码。它可以减少代码量,并且使得添加日志记录功能更加方便

java
import lombok.extern.slf4j.Slf4j;

@Slf4j
public class MyClass {
    public void myMethod() {
        log.info("This is a log message");
    }
}

IoC & AOP 详解(快速搞懂)

  • 什么是 IoC?
  • IoC 解决了什么问题?
  • IoC 和 DI 的区别?
  • 什么是 AOP?
  • AOP 解决了什么问题?
  • AOP 的应用场景有哪些?
  • AOP 为什么叫做切面编程?
  • AOP 实现方式有哪些?

IoC (Inversion of Control )即控制反转/反转控制。它是一种思想不是一个技术实现。描述的是:Java 开发领域对象的创建以及管理的问题。

  • 传统的开发方式 :往往是在类 A 中手动通过 new 关键字来 new 一个 B 的对象出来

  • 使用 IoC 思想的开发方式 :不通过 new 关键字来创建对象,而是通过 IoC 容器(Spring 框架) 来帮助我们实例化对象。我们需要哪个对象,直接从 IoC 容器里面去取即可

为什么叫控制反转?

  • 控制 :指的是对象创建(实例化、管理)的权力
  • 反转 :控制权交给外部环境(IoC 容器)

IoC 的思想就是两方之间不互相依赖,由第三方容器来管理相关资源。这样有什么好处呢?

  1. 对象之间的耦合度或者说依赖程度降低;
  2. 资源变的容易管理;比如你用 Spring 容器提供的话很容易就可以实现一个单例。

IoC(Inverse of Control:控制反转)是一种设计思想或者说是某种模式。这个设计思想就是 将原本在程序中手动创建对象的控制权交给第三方比如 IoC 容器

IoC 最常见以及最合理的实现方式叫做依赖注入(Dependency Injection,简称 DI)

AOP(Aspect Oriented Programming)即面向切面编程,AOP 是 OOP(面向对象编程)的一种延续,二者互补,并不对立

AOP 的目的是将横切关注点(如日志记录、事务管理、权限控制、接口限流、接口幂等等)从核心业务逻辑中分离出来,通过动态代理、字节码操作等技术,实现代码的复用和解耦,提高代码的可维护性和可扩展性

OOP 的目的是将业务逻辑按照对象的属性和行为进行封装,通过类、对象、继承、多态等概念,实现代码的模块化和层次化(也能实现代码的复用),提高代码的可读性和可维护性

横切关注点(cross-cutting concerns) :多个类或对象中的公共行为(如日志记录、事务管理、权限控制、接口限流、接口幂等等)。

系统设计

RestFul API

API Application Programming Interface :应用程序编程接口

REST Resource Representational State Transfer:资源代表状态转移

RESTful API 可以通过 URL + HTTP Method 知道请求要干嘛,可以通过 HTTP 状态码知道请求的结果如何。

常见状态码

1xx:提示信息,表示中间状态

2xx:成功,报文已经被正确处理

3xx:重定向(301 永久重定向,302 临时重定向,304资源未修改走缓存)

4xx:客户端错误

5xx:服务器错误

软件工程

软件开发过程:需求分析、软件设计、编码、测试、交付、维护

软件开发模型

  • 瀑布模型:通过设计一系列阶段顺序展开的
  • 敏捷开发:以人为核心、迭代、循序渐进,把一个大项目分为多个相互联系,但也可独立运行的小项目。

代码命名指南

  1. 大驼峰命名法:类名 UpperCamelCase
  2. 小驼峰命名法:方法名、参数名、成员变量、局部变量 lowerCamelCase
  3. 蛇形命名法: 下划线链接_
    1. 测试方法名:全部小写 xxx_is_valid
    2. 常量、枚举名称 CLIENT_CONNECT_SERVER_FAILURE
  4. 串式命名法:项目文件夹 dubbo-registry
  5. 包名尽量使用单个名词单数小写,通过 .

软件重构

重构就是利用设计模式、软件设计原则和重构手段来让代码更容易理解,更易于修改。

重构的最终目标是 提高软件开发速度和质量

设计模式 23 种

  • 创建型:单例、工厂、建造者、原型
  • 结构性:代理、桥接、门面、享元、适配器、装饰器、组合
  • 中介者:模版、观察者、责任链、策略、命令、迭代器、访问者、备忘录、解释器、状态

软件设计原则 7个(SOLID 5个)

  • 单一职责原则:强调一个类或模块应该只有一个原因来发生变化
  • 开闭原则:对修改关闭、对拓展开放
  • 里氏替换原则:强调子类应该能够替代其基类而不引起错误
  • 接口隔离原则:将大型接口拆分为多个小型接口,以便客户端只需依赖于其需要的接口
  • 依赖反转原则:强调高级模块不应该依赖于低级模块,都应该依赖于抽象

单元测试

单元测试(Unit Testing)是针对程序模块进行的正确性检验测试工作。

TDD 即 Test-Driven Development( 测试驱动开发),原理是在开发功能代码之前,先编写测试用例代码,然后针对测试用例编写功能代码,使其能够通过。

分布式

分布式锁

为什么需要分布式锁?

在多线程环境中,如果多个线程同时访问共享资源(例如商品库存、外卖订单),会发生数据竞争,可能会导致出现脏数据或者系统问题,威胁到程序的正常运行。

如何才能实现共享资源的互斥访问呢?

锁是一个比较通用的解决方案,更准确点来说是悲观锁(共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程

对于单机多线程来说,在 Java 中,我们通常使用 ReentrantLock 类、synchronized 关键字这类 JDK 自带的 本地锁 来控制一个 JVM 进程内的多个线程对本地共享资源的访问。

分布式系统下,不同的服务/客户端通常运行在独立的 JVM 进程上。如果多个 JVM 进程共享同一份资源的话,使用本地锁就没办法实现资源的互斥访问了。于是,分布式锁 就诞生了。

一个最基本的分布式锁需要满足

  • 互斥:任意一个时刻,锁只能被一个线程持有。
  • 高可用:锁服务是高可用的,当一个锁服务出现问题,能够自动切换到另外一个锁服务。并且,即使客户端的释放锁的代码逻辑出现问题,锁最终一定还是会被释放,不会影响其他线程对共享资源的访问。这一般是通过超时机制实现的。
  • 可重入:一个节点获取了锁之后,还可以再次获取锁。

除了上面这三个基本条件之外,一个好的分布式锁还需要满足下面这些条件

  • 高性能:获取和释放锁的操作应该快速完成,并且不应该对整个系统的性能造成过大影响。
  • 非阻塞:如果获取不到锁,不能无限期等待,避免对系统正常运行造成影响。

常见分布式锁实现方案如下:

  • 基于关系型数据库比如 MySQL 实现分布式锁。
  • 基于分布式协调服务 ZooKeeper 实现分布式锁。
  • 基于分布式键值存储系统比如 Redis 、Etcd 实现分布式锁。

这篇文章我们主要介绍了

  • 分布式锁的用途:分布式系统下,不同的服务/客户端通常运行在独立的 JVM 进程上。如果多个 JVM 进程共享同一份资源的话,使用本地锁就没办法实现资源的互斥访问了。
  • 分布式锁的应该具备的条件:互斥、高可用、可重入、高性能、非阻塞。
  • 分布式锁的常见实现方式:关系型数据库比如 MySQL、分布式协调服务 ZooKeeper、分布式键值存储系统比如 Redis 、Etcd 。

不论是本地锁还是分布式锁,核心都在于“互斥”

基于 Redis 实现分布式锁:SETNX(SET if Not eXists)加锁,基于 lua 脚本释放锁

为了避免锁无法被释放,我们可以想到的一个解决办法就是:给这个 key(也就是锁) 设置一个过期时间

一定要保证设置指定 key 的值和过期时间是一个原子操作!!!

为什么要给锁设置一个过期时间

如果操作共享资源的时间大于过期时间,就会出现锁提前过期的问题,进而导致分布式锁直接失效。如果锁的超时时间设置过长,又会影响到性能

如何实现锁的优雅续期?

Redisson 中的分布式锁自带自动续期机制,使用起来非常简单,原理也比较简单,其提供了一个专门用来监控和续期锁的 Watch Dog( 看门狗),如果操作共享资源的线程还未执行完成的话,Watch Dog 会不断地延长锁的过期时间,进而保证锁不会因为超时而被释放。

如何实现可重入锁?

可重入锁指的是在一个线程中可以多次获取同一把锁。

不可重入的分布式锁基本可以满足绝大部分业务场景了,一些特殊的场景可能会需要使用可重入的分布式锁

可重入分布式锁的实现核心思路是线程在获取锁的时候判断是否为自己的锁

具体实现:Redisson,其内置了多种类型的锁比如可重入锁(Reentrant Lock)、自旋锁(Spin Lock)、公平锁(Fair Lock)、多重锁(MultiLock)、 红锁(RedLock)、 读写锁(ReadWriteLock)。

Redis如何解决集群情况下分布式锁的可靠性?

Redlock 算法的思想是让客户端向 Redis 集群中的多个独立的 Redis 实例依次请求申请加锁,如果客户端能够和半数以上的实例成功地完成加锁操作,那么我们就认为,客户端成功地获得分布式锁,否则加锁失败。

高性能

数据库优化

读写分离主要是为了将对数据库的读写操作分散到不同的数据库节点上

一般情况下,我们都会选择一主多从,也就是一台主数据库负责写,其他的从数据库负责读。主库和从库之间会进行数据同步,以保证从库中数据的准确性。这样的架构实现起来比较简单,并且也符合系统的写少读多的特点。

实现读写分离一般包含如下几步

  1. 部署多台数据库,选择其中的一台作为主数据库,其他的一台或者多台作为从数据库。
  2. 保证主数据库和从数据库之间的数据是实时同步的,这个过程也就是我们常说的主从复制
  3. 系统将写请求交给主数据库处理,读请求交给从数据库处理。

具体实现:代理方式,组件方式(推荐方式,sharding-jdbc)

主从复制原理

MySQL binlog(binary log 即二进制日志文件) 主要记录了 MySQL 数据库中数据的所有变化(数据库执行的所有 DDL 和 DML 语句)。因此,我们根据主库的 MySQL binlog 日志就能够将主库的数据同步到从库中

一般看到 binlog 就要想到主从复制。当然,除了主从复制之外,binlog 还能帮助我们实现数据恢复

分布式缓存组件 Redis 也是通过主从复制实现的读写分离。

MySQL 主从复制是依赖于 binlog 。另外,常见的一些同步 MySQL 数据到其他数据源的工具(比如 canal)的底层一般也是依赖 binlog

主库和从库的数据存在延迟,比如你写完主库之后,主库的数据同步到从库是需要时间的,这个时间差就导致了主库和从库的数据不一致性问题。这也就是我们经常说的 主从同步延迟

解决方案:

  • 强制将读请求路由到主库处理
  • 延迟读取

MySQL 主从同步延时是指从库的数据落后于主库的数据,这种情况可能由以下两个原因造成

  1. 从库 I/O 线程接收 binlog 的速度跟不上主库写入 binlog 的速度,导致从库 relay log 的数据滞后于主库 binlog 的数据;
  2. 从库 SQL 线程执行 relay log 的速度跟不上从库 I/O 线程接收 binlog 的速度,导致从库的数据滞后于从库 relay log 的数据。

什么情况下会出现出从延迟呢?

  • 从库机器性能比主库差
  • 从库处理的读请求过多
  • 大事务:运行时间比较长,长时间未提交的事务就可以称为大事务。
  • 从库太多
  • 网络延迟
  • 单线程复制
  • 复制模式

分库 就是将数据库中的数据分散到不同的数据库上,可以垂直分库,也可以水平分库。

垂直分库 就是把单一数据库按照业务进行划分,不同的业务使用不同的数据库,进而将一个数据库的压力分担到多个数据库。

水平分库 是把同一个表按一定规则拆分到不同的数据库中,每个库可以位于不同的服务器上,这样就实现了水平扩展,解决了单表的存储和性能瓶颈的问题。

分表 就是对单表的数据进行拆分,可以是垂直拆分,也可以是水平拆分。

垂直分表 是对数据表列的拆分,把一张列比较多的表拆分为多张表。

水平分表 是对数据表行的拆分,把一张行比较多的表拆分为多张表,可以解决单一表数据量过大的问题。

遇到下面几种场景可以考虑分库分表:

  • 单表的数据达到千万级别以上,数据库读写速度比较缓慢。
  • 数据库中的数据占用的空间越来越大,备份时间越来越长。
  • 应用的并发量太大(应该优先考虑其他性能优化方法,而非分库分表)。

分片算法主要解决了数据被水平分片之后,数据究竟该存放在哪个表的问题

常见的分片算法有:

  • 哈希分片:求指定分片键的哈希,然后根据哈希值确定数据应被放置在哪个表中。哈希分片比较适合随机读写的场景,不太适合经常需要范围查询的场景。哈希分片可以使每个表的数据分布相对均匀,但对动态伸缩(例如新增一个表或者库)不友好。
  • 范围分片:按照特定的范围区间(比如时间区间、ID 区间)来分配数据,比如 将 id1~299999 的记录分到第一个表, 300000~599999 的分到第二个表。范围分片适合需要经常进行范围查找且数据分布均匀的场景,不太适合随机读写的场景(数据未被分散,容易出现热点数据的问题)。
  • 映射表分片:使用一个单独的表(称为映射表)来存储分片键和分片位置的对应关系。
  • 一致性哈希分片:将哈希空间组织成一个环形结构,将分片键和节点(数据库或表)都映射到这个环上,然后根据顺时针的规则确定数据或请求应该分配到哪个节点上,解决了传统哈希对动态伸缩不友好的问题。
  • 地理位置分片:很多 NewSQL 数据库都支持地理位置分片算法,也就是根据地理位置(如城市、地域)来分配数据。
  • 融合算法分片:灵活组合多种分片算法,比如将哈希分片和范围分片组合

分片键(Sharding Key)是数据分片的关键字段。分片键的选择非常重要,它关系着数据的分布和查询效率。一般来说,分片键应该具备以下特点:

  • 具有共性,即能够覆盖绝大多数的查询场景,尽量减少单次查询所涉及的分片数量,降低数据库压力;

  • 具有离散性,即能够将数据均匀地分散到各个分片上,避免数据倾斜和热点问题;

  • 具有稳定性,即分片键的值不会发生变化,避免数据迁移和一致性问题;

  • 具有扩展性,即能够支持分片的动态增加和减少,避免数据重新分片的开销。

引入分库分表之后,会给系统带来什么挑战呢?

  • join 操作:同一个数据库中的表分布在了不同的数据库中,导致无法使用 join 操作。这样就导致我们需要手动进行数据的封装,比如你在一个数据库中查询到一个数据之后,再根据这个数据去另外一个数据库中找对应的数据。不过,很多大厂的资深 DBA 都是建议尽量不要使用 join 操作。因为 join 的效率低,并且会对分库分表造成影响。对于需要用到 join 操作的地方,可以采用多次查询业务层进行数据组装的方法。不过,这种方法需要考虑业务上多次查询的事务性的容忍度。
  • 事务问题:同一个数据库中的表分布在了不同的数据库中,如果单个操作涉及到多个数据库,那么数据库自带的事务就无法满足我们的要求了。这个时候,我们就需要引入分布式事务了。
  • 分布式 ID:分库之后, 数据遍布在不同服务器上的数据库,数据库的自增主键已经没办法满足生成的主键唯一了。我们如何为不同的数据节点生成全局唯一主键呢?这个时候,我们就需要为我们的系统引入分布式 ID 了。
  • 跨库聚合查询问题:分库分表会导致常规聚合查询操作,如 group by,order by 等变得异常复杂。这是因为这些操作需要在多个分片上进行数据汇总和排序,而不是在单个数据库上进行。为了实现这些操作,需要编写复杂的业务代码,或者使用中间件来协调分片间的通信和数据传输。这样会增加开发和维护的成本,以及影响查询的性能和可扩展性。

ShardingSphere 绝对可以说是当前分库分表的首选!ShardingSphere 的功能完善,除了支持读写分离和分库分表,还提供分布式事务、数据库治理、影子库、数据加密和脱敏等功能。

分库分表后数据迁移:停机迁移,双写方案,或者借助数据同步工具Cannal做增量数据迁移(依赖binlog)

总结

  • 读写分离主要是为了将对数据库的读写操作分散到不同的数据库节点上。这样的话,就能够小幅提升写性能,大幅提升读性能。

  • 读写分离基于主从复制,MySQL 主从复制是依赖于 binlog

  • 分库 就是将数据库中的数据分散到不同的数据库上。分表 就是对单表的数据进行拆分,可以是垂直拆分,也可以是水平拆分。

  • 引入分库分表之后,需要系统解决事务、分布式 id、无法 join 操作问题

  • 现在很多公司都是用的类似于 TiDB 这种分布式关系型数据库,不需要我们手动进行分库分表(数据库层面已经帮我们做了),也不需要解决手动分库分表引入的各种问题,直接一步到位,内置很多实用的功能(如无感扩容和缩容、冷热存储分离)!如果公司条件允许的话,个人也是比较推荐这种方式!

  • 如果必须要手动分库分表的话,ShardingSphere 是首选!ShardingSphere 的功能完善,除了支持读写分离和分库分表,还提供分布式事务、数据库治理等功能。另外,ShardingSphere 的生态体系完善,社区活跃,文档完善,更新和发布比较频繁。

热数据是指经常被访问和修改且需要快速访问的数据,冷数据是指不经常访问,对当前项目价值较低,但需要长期保存的数据

区分方法:时间维度区分,访问频率区分。

冷热分离的思想非常简单,就是对数据进行分类,然后分开存储。

消息队列

基础知识

消息队列的作用:

  • 异步:订票
  • 削峰:秒杀
  • 解耦:使用发布-订阅模式
    • 消息队列还有 点对点模式(一个消息只有一个消费者)

消息队列带来的问题:

  • 可用性降低
  • 复杂度提高
  • 一致性问题

消息模型:

  • 点对点模式:使用队列作为消息通信载体

  • 发布-订阅模式:使用 主题(Topic)作为消息通信载体,类似于广播模式

Kafka

Kafka 是一个分布式流式处理平台

  • 三个关键功能

    • 消息队列:发布和订阅消息流

    • 容错的持久方式存储记录消息流:消息持久化到磁盘

    • 流式处理平台:提供了完整的流式处理类库

  • 两大应用场景

    • 消息队列
    • 数据处理
  • 对比其他消息队列,主要的优势:极致的性能、生态系统兼容性强

Tips:RocketMQ 的消息模型和 Kafka 基本是完全一样的(发布订阅模式)。唯一的区别是 Kafka 中没有队列这个概念,与之对应的是 Partition(分区)

名称解释
Producer消息生产者
Consumer消息消费者
ConsumerGroup每个 Consumer 属于一个特定的 Consumer Group,一条消息可以被多个不同的 Consumer Group 消费,但是一个 Consumer Group 中只能有一个 Consumer 能够消费该消息
Broker可以看做 Kafka 实例,一个或者多个 Broker 可以组成一个 Kafka 集群
TopicKafka 根据 topic 对消息进行归类,发布到 Kafka 集群的每条消息都需要指定一个 Topic
Partition每个 partition 中的消息是有序的,一个 topic 可以分为多个 partition,partition 也有主从,主 挂了选举 从 顶上(提高容灾能力)
ZookeeperKafka 的注册中心,监控整个 Kafka 集群的状态(心跳机制),同时实现负载均衡

多分区(Partition)机制:Topic 可以对应多个 Partition, Partition 又可以分布在不同的 Broker 上, 提高并发能力(负载均衡)

多副本(Replica)机制:Partition 可以指定对应的副本数,提高了容灾能力,不过也相应的增加了所需要的存储空间。

Kafka 发送消息的时候可以指定:topic, partition, key, data

  • 消息保序:kafka 保证同一个 partition 中顺序执行
    • 发送消息的时候指定 key(这样消息就会被放入同一个 partition)
  • 消息不丢失
    • 生产者:使用回调函数异步获取结果(get 也可以获取结果,但是是同步)
    • 消费者:关闭自动提交 offset,等真正消费完消息之后再自己手动提交 offset (仍存在重复消费,消费完成还未提交offset时挂掉)
    • broker:从副本 升级为 主副本
  • 不重复消费:没有成功提交 offset
    • 幂等校验:通过 Redis 的 set 或者 MySQL 的主键 实现幂等

Kafka的重试机制:Kafka 在消息消费异常时会进行重试,重试多次后跳过当前消息(放入死信队列),执行后续消息。(默认情况下重试10次,异常后立即重试)

可以通过 重写 DefaultErrorHandler 的 handleRemaining 函数 实现自定义重试失败后的告警逻辑

死信队列:消费者处理失败,或者超过一定的重试次数仍无法被成功处理,消息会被发送到死信队列中,而不是被丢弃。

RocketMQ

RocketMQ 具有高性能、高可靠、高实时、分布式 的特点。

名称解释
Producer消息生产者
ProducerGroup生产者组
Consumer消息消费者
ConsumerGroup消费者组
Broker消息队列服务器,生产者发送消息到 Broker,消费者从 Broker 中拿消息
Topic根据 topic 对消息进行归类,包含多个队列(和 Broker 是多对多的关系)
NameServer注册中心,提供了 Broker 管理 和 路由信息管理

Tips:主题模型的实现:Kafka 用 分区 Partition ,RocketMQ 用 队列。

RocketMQ 通过使用在一个 Topic 中配置多个队列并且每个队列维护每个消费者组的消费位置 实现了 主题模式/发布订阅模式

  • 消息保序:在队列层面上是有序的
    • 通过 重写 MessageQueueSelector 的 select 方法来 自定义队列选择,将要顺序执行的消息放到同一队列中
      • RocketMQ 提供的队列选择算法:
        • 轮询算法:各队列依次发送消息(默认)
        • 最小投递延迟算法:选择消息延时小的队列投递
  • 不重复消费:没有成功提交 offset
    • 幂等校验:通过 Redis 的 set 或者 MySQL 的主键 实现幂等
  • 分布式事务:要么都执行,要么都不执行
    • 事务消息(half 半消息机制) + 事务反查机制
      • half 消息:在事务提交之前,对于消费者来说是不可见的(收到half消息后会备份原消息队列,并改名放入)
      • 事务反差机制:事务执行结果 commit/rollback 如果没有正常发送给消息队列,消息队列会主动发送消息回查
  • 消息堆积
    • 生产者端:限流降级
    • 消费者端:增加消费者和队列数量(一个队列只会被一个消费者消费,所以也需要增加队列数量)

RocketMQ 的特性:

  • 支持回溯消费:消费之前的消息
  • 高性能读写:基于 mmap 实现的零拷贝
    • mmap:共享内核缓冲区和应用缓冲区(减少了一次 IO 拷贝)