说到编译,我猜你一定会想到 .java文件被编译成 .class文件的过程,这个编译我们一般称为前端编译。Java的编译和运行过程非常复杂,除了前端编译,还有运行时编译。由于机器无法直接运行Java生成的字节码,所以在运行时,JIT或解释器会将字节码转换成机器码,这个过程就叫运行时编译。
类文件在运行时被进一步编译,它们可以变成高度优化的机器代码,由于C/C++编译器的所有优化都是在编译期间完成的,运行期间的性能监控仅作为基础的优化措施则无法进行,例如,调用频率预测、分支频率预测、裁剪未被选择的分支等,而Java在运行时的再次编译,就可以进行基础的优化措施。因此,JIT编译器可以说是JVM中运行时编译最重要的部分之一。
然而许多Java开发人员对JIT编译器的了解并不多,不深挖其工作原理,也不深究如何检测应用程序的即时编译情况,线上发生问题后很难做到从容应对。今天我们就来学习运行时编译如何实现对Java代码的优化。

类编译加载执行过程

在这之前,我们先了解下Java从编译到运行的整个过程,为后面的学习打下基础。请看下图:

类编译

在编写好代码之后,我们需要将 .java文件编译成 .class文件,才能在虚拟机上正常运行代码。文件的编译通常是由JDK中自带的Javac工具完成,一个简单的 .java文件,我们可以通过javac命令来生成 .class文件。
下面我们通过javap反编译来看看一个class文件结构中主要包含了哪些信息:

看似一个简单的命令执行,前期编译的过程其实是非常复杂的,包括词法分析、填充符号表、注解处理、语义分析以及生成class文件,这个过程我们不用过多关注。只要从上图中知道,编译后的字节码文件主要包括常量池和方法表集合这两部分就可以了。
常量池主要记录的是类文件中出现的字面量以及符号引用。字面常量包括字符串常量(例如String str=“abc”,其中”abc”就是常量),声明为final的属性以及一些基本类型(例如,范围在-127-128之间的整型)的属性。符号引用包括类和接口的全限定名、类引用、方法引用以及成员变量引用(例如String str=“abc”,其中str就是成员变量引用)等。
方法表集合中主要包含一些方法的字节码、方法访问权限(public、protect、prviate等)、方法名索引(与常量池中的方法引用对应)、描述符索引、JVM执行指令以及属性集合等。

类加载

当一个类被创建实例或者被其它对象引用时,虚拟机在没有加载过该类的情况下,会通过类加载器将字节码文件加载到内存中。
不同的实现类由不同的类加载器加载,JDK中的本地方法类一般由根加载器(Bootstrp loader)加载进来,JDK中内部实现的扩展类一般由扩展加载器(ExtClassLoader )实现加载,而程序中的类文件则由系统加载器(AppClassLoader )实现加载。在类加载后,class类文件中的常量池信息以及其它数据会被保存到JVM内存的方法区中。

类连接

类在加载进来之后,会进行连接、初始化,最后才会被使用。在连接过程中,又包括验证、准备和解析三个部分。

验证:验证类符合Java规范和JVM规范,在保证符合规范的前提下,避免危害虚拟机安全。
准备:为类的静态变量分配内存,初始化为系统的初始值。对于final static修饰的变量,直接赋值为用户的定义值。例如,private final static int value=123,会在准备阶段分配内存,并初始化值为123,而如果是 private static int value=123,这个阶段value的值仍然为0。
解析:将符号引用转为直接引用的过程。我们知道,在编译时,Java类并不知道所引用的类的实际地址,因此只能使用符号引用来代替。类结构文件的常量池中存储了符号引用,包括类和接口的全限定名、类引用、方法引用以及成员变量引用等。如果要使用这些类和方法,就需要把它们转化为JVM可以直接获取的内存地址或指针,即直接引用。

类初始化

类初始化阶段是类加载过程的最后阶段,在这个阶段中,JVM首先将执行构造器clinit方法,编译器会在将 .java 文件编译成 .class 文件时,收集所有类初始化代码,包括静态变量赋值语句、静态代码块、静态方法,收集在一起成为clinit() 方法。初始化类的静态变量和静态代码块为用户自定义的值,初始化的顺序和Java源码从上到下的顺序一致。例如:

1
2
3
4
5
6
7
8
private static int i=1;
static{
i=0;
}
public static void main(String [] args){
System.out.println(i);
}

此时运行结果为:

1
0

再来看看以下代码:

1
2
3
4
5
6
7
static{
i=0;
}
private static int i=1;
public static void main(String [] args){
System.out.println(i);
}

此时运行结果为:

1
1

子类初始化时会首先调用父类的 clinit() 方法,再执行子类的clinit() 方法,运行以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class Parent{
public static String parentStr= "parent static string";
static{
System.out.println("parent static fields");
System.out.println(parentStr);
}
public Parent(){
System.out.println("parent instance initialization");
}
}

public class Sub extends Parent{
public static String subStr= "sub static string";
static{
System.out.println("sub static fields");
System.out.println(subStr);
}

public Sub(){
System.out.println("sub instance initialization");
}

public static void main(String[] args){
System.out.println("sub main");
new Sub();
}
}

运行结果:

1
2
3
4
5
6
7
parent static fields
parent static string
sub static fields
sub static string
sub main
parent instance initialization
sub instance initialization

JVM 会保证 clinit() 方法的线程安全,保证同一时间只有一个线程执行。
JVM在初始化执行代码时,如果实例化一个新对象,会调用init()方法对实例变量进行初始化,并执行对应的构造方法内的代码。

即时编译

初始化完成后,类在调用执行过程中,执行引擎会把字节码转为机器码,然后在操作系统中才能执行。在字节码转换为机器码的过程中,虚拟机中还存在着一道编译,那就是即时编译。
最初,虚拟机中的字节码是由解释器( Interpreter )完成编译的,当虚拟机发现某个方法或代码块的运行特别频繁的时候,就会把这些代码认定为“热点代码”。
为了提高热点代码的执行效率,在运行时,即时编译器(JIT)会把这些代码编译成与本地平台相关的机器码,并进行各层次的优化,然后保存到内存中。

即时编译器类型

在HotSpot虚拟机中,内置了两个JIT,分别为C1编译器和C2编译器,这两个编译器的编译过程是不一样的。
C1编译器是一个简单快速的编译器,主要的关注点在于局部性的优化,适用于执行时间较短或对启动性能有要求的程序,例如,GUI应用对界面启动速度就有一定要求。
C2编译器是为长期运行的服务器端应用程序做性能调优的编译器,适用于执行时间较长或对峰值性能有要求的程序。根据各自的适配性,这两种即时编译也被称为Client Compiler和Server Compiler。
在Java7之前,需要根据程序的特性来选择对应的JIT,虚拟机默认采用解释器和其中一个编译器配合工作。
Java7引入了分层编译,这种方式综合了C1的启动性能优势和C2的峰值性能优势,我们也可以通过参数 “-client”“-server” 强制指定虚拟机的即时编译模式。分层编译将JVM的执行状态分为了5个层次:

  • 第0层:程序解释执行,默认开启性能监控功能(Profiling),如果不开启,可触发第二层编译;
  • 第1层:可称为C1编译,将字节码编译为本地代码,进行简单、可靠的优化,不开启Profiling;
  • 第2层:也称为C1编译,开启Profiling,仅执行带方法调用次数和循环回边执行次数profiling的C1编译;
  • 第3层:也称为C1编译,执行所有带Profiling的C1编译;
  • 第4层:可称为C2编译,也是将字节码编译为本地代码,但是会启用一些编译耗时较长的优化,甚至会根据性能监控信息进行一些不可靠的激进优化。
    在Java8中,默认开启分层编译,-client和-server的设置已经是无效的了。如果只想开启C2,可以关闭分层编译(-XX:-TieredCompilation),如果只想用C1,可以在打开分层编译的同时,使用参数:-XX:TieredStopAtLevel=1。
    除了这种默认的混合编译模式,我们还可以使用“-Xint”参数强制虚拟机运行于只有解释器的编译模式下,这时JIT完全不介入工作;我们还可以使用参数“-Xcomp”强制虚拟机运行于只有JIT的编译模式下。
    通过 java -version 命令行可以直接查看到当前系统使用的编译模式。如下图所示:

热点探测

在HotSpot虚拟机中的热点探测是JIT优化的条件,热点探测是基于计数器的热点探测,采用这种方法的虚拟机会为每个方法建立计数器统计方法的执行次数,如果执行次数超过一定的阈值就认为它是“热点方法” 。

虚拟机为每个方法准备了两类计数器:方法调用计数器(Invocation Counter)和回边计数器(Back Edge Counter)。在确定虚拟机运行参数的前提下,这两个计数器都有一个确定的阈值,当计数器超过阈值溢出了,就会触发JIT编译。

**方法调用计数器:**用于统计方法被调用的次数,方法调用计数器的默认阈值在C1模式下是1500次,在C2模式在是10000次,可通过-XX: CompileThreshold来设定;而在分层编译的情况下,-XX: CompileThreshold指定的阈值将失效,此时将会根据当前待编译的方法数以及编译线程数来动态调整。当方法计数器和回边计数器之和超过方法计数器阈值时,就会触发JIT编译器。

**回边计数器:**用于统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为“回边”(Back Edge),该值用于计算是否触发C1编译的阈值,在不开启分层编译的情况下,C1默认为13995,C2默认为10700,可通过-XX: OnStackReplacePercentage=N来设置;而在分层编译的情况下,-XX: OnStackReplacePercentage指定的阈值同样会失效,此时将根据当前待编译的方法数以及编译线程数来动态调整。

建立回边计数器的主要目的是为了触发OSR(On StackReplacement)编译,即栈上编译。在一些循环周期比较长的代码段中,当循环达到回边计数器阈值时,JVM会认为这段是热点代码,JIT编译器就会将这段代码编译成机器语言并缓存,在该循环时间段内,会直接将执行代码替换,执行缓存的机器语言。

编译优化技术

JIT编译运用了一些经典的编译优化技术来实现代码的优化,即通过一些例行检查优化,可以智能地编译出运行时的最优性能代码。今天我们主要来学习以下两种优化手段:
1.方法内联
调用一个方法通常要经历压栈和出栈。调用方法是将程序执行顺序转移到存储该方法的内存地址,将方法的内容执行完后,再返回到执行该方法前的位置。
这种执行操作要求在执行前保护现场并记忆执行的地址,执行后要恢复现场,并按原来保存的地址继续执行。 因此,方法调用会产生一定的时间和空间方面的开销。
那么对于那些方法体代码不是很大,又频繁调用的方法来说,这个时间和空间的消耗会很大。方法内联的优化行为就是把目标方法的代码复制到发起调用的方法之中,避免发生真实的方法调用。
例如以下方法:

1
2
3
4
5
6
private int add1(int x1, int x2, int x3, int x4) {
return add2(x1, x2) + add2(x3, x4);
}
private int add2(int x1, int x2) {
return x1 + x2;
}

最终会被优化为:

1
2
3
private int add1(int x1, int x2, int x3, int x4) {
return x1 + x2+ x3 + x4;
}

JVM会自动识别热点方法,并对它们使用方法内联进行优化。我们可以通过-XX:CompileThreshold来设置热点方法的阈值。但要强调一点,热点方法不一定会被JVM做内联优化,如果这个方法体太大了,JVM将不执行内联操作。而方法体的大小阈值,我们也可以通过参数设置来优化:

  • 经常执行的方法,默认情况下,方法体大小小于325字节的都会进行内联,我们可以通过-XX:MaxFreqInlineSize=N来设置大小值;
  • 不是经常执行的方法,默认情况下,方法大小小于35字节才会进行内联,我们也可以通过-XX:MaxInlineSize=N来重置大小值。
    之后我们就可以通过配置JVM参数来查看到方法被内联的情况:
1
2
3
-XX:+PrintCompilation //在控制台打印编译过程信息
-XX:+UnlockDiagnosticVMOptions //解锁对JVM进行诊断的选项参数。默认是关闭的,开启后支持一些特定参数对JVM进行诊断
-XX:+PrintInlining //将内联方法打印出来

当我们设置VM参数:-XX:+PrintCompilation -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining之后,运行以下代码:

1
2
3
4
5
public static void main(String[] args) {
for(int i=0; i<1000000; i++) {//方法调用计数器的默认阈值在C1模式下是1500次,在C2模式在是10000次,我们循环遍历超过需要阈值
add1(1,2,3,4);
}
}

我们可以看到运行结果中,显示了方法内联的日志:

热点方法的优化可以有效提高系统性能,一般我们可以通过以下几种方式来提高方法内联:

  • 通过设置JVM参数来减小热点阈值或增加方法体阈值,以便更多的方法可以进行内联,但这种方法意味着需要占用更多地内存;
  • 在编程中,避免在一个方法中写大量代码,习惯使用小方法体;
  • 尽量使用final、private、static关键字修饰方法,编码方法因为继承,会需要额外的类型检查。
    2.逃逸分析

逃逸分析(Escape Analysis)是判断一个对象是否被外部方法引用或外部线程访问的分析技术,编译器会根据逃逸分析的结果对代码进行优化。
栈上分配
我们知道,在Java中默认创建一个对象是在堆中分配内存的,而当堆内存中的对象不再使用时,则需要通过垃圾回收机制回收,这个过程相对分配在栈中的对象的创建和销毁来说,更消耗时间和性能。这个时候,逃逸分析如果发现一个对象只在方法中使用,就会将对象分配在栈上。
以下是通过循环获取学生年龄的案例,方法中创建一个学生对象,我们现在通过案例来看看打开逃逸分析和关闭逃逸分析后,堆内存对象创建的数量对比。

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 static void main(String[] args) {
for (int i = 0; i < 200000 ; i++) {
getAge();
}
}

public static int getAge(){
Student person = new Student("小明",18,30);
return person.getAge();
}

static class Student {
private String name;
private int age;

public Student(String name, int age) {
this.name = name;
this.age = age;
}

public String getName() {
return name;
}

public void setName(String name) {
this.name = name;
}

public int getAge() {
return age;
}

public void setAge(int age) {
this.age = age;
}
}

然后,我们分别设置VM参数:Xmx1000m -Xms1000m -XX:-DoEscapeAnalysis -XX:+PrintGC以及 -Xmx1000m -Xms1000m -XX:+DoEscapeAnalysis -XX:+PrintGC,通过之前讲过的VisualVM工具,查看堆中创建的对象数量。然而,运行结果却没有达到我们想要的优化效果,也许你怀疑是JDK版本的问题,然而我分别在1.6~1.8版本都测试过了,效果还是一样的:
(-server -Xmx1000m -Xms1000m -XX:-DoEscapeAnalysis -XX:+PrintGC)

(-server -Xmx1000m -Xms1000m -XX:+DoEscapeAnalysis -XX:+PrintGC)

这其实是因为HotSpot虚拟机目前的实现导致栈上分配实现比较复杂,可以说,在HotSpot中暂时没有实现这项优化。随着即时编译器的发展与逃逸分析技术的逐渐成熟,相信不久的将来HotSpot也会实现这项优化功能。

锁消除

在非线程安全的情况下,尽量不要使用线程安全容器,比如StringBuffer。由于StringBuffer中的append方法被Synchronized关键字修饰,会使用到锁,从而导致性能下降。

但实际上,在以下代码测试中,StringBuffer和StringBuilder的性能基本没什么区别。这是因为在局部方法中创建的对象只能被当前线程访问,无法被其它线程访问,这个变量的读写肯定不会有竞争,这个时候JIT编译会对这个对象的方法锁进行锁消除。

1
2
3
4
5
6
 public static String getString(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb.toString();
}

标量替换

逃逸分析证明一个对象不会被外部访问,如果这个对象可以被拆分的话,当程序真正执行的时候可能不创建这个对象,而直接创建它的成员变量来代替。将对象拆分后,可以分配对象的成员变量在栈或寄存器上,原本的对象就无需分配内存空间了。这种编译优化就叫做标量替换。

我们用以下代码验证:

1
2
3
4
5
6
public void foo() {
TestInfo info = new TestInfo();
info.id = 1;
info.count = 99;
...//to do something
}

逃逸分析后,代码会被优化为:

1
2
3
4
5
6

public void foo() {
id = 1;
count = 99;
...//to do something
}

我们可以通过设置JVM参数来开关逃逸分析,还可以单独开关同步消除和标量替换,在JDK1.8中JVM是默认开启这些操作的。

1
2
3
4
5
6
7
8
-XX:+DoEscapeAnalysis开启逃逸分析(jdk1.8默认开启,其它版本未测试)
-XX:-DoEscapeAnalysis 关闭逃逸分析

-XX:+EliminateLocks开启锁消除(jdk1.8默认开启,其它版本未测试)
-XX:-EliminateLocks 关闭锁消除

-XX:+EliminateAllocations开启标量替换(jdk1.8默认开启,其它版本未测试)
-XX:-EliminateAllocations 关闭就可以了

总结

今天我们主要了解了JKD1.8以及之前的类的编译和加载过程,Java源程序是通过Javac编译器编译成 .class文件,其中文件中包含的代码格式我们称之为Java字节码(bytecode)。

这种代码格式无法直接运行,但可以被不同平台JVM中的Interpreter解释执行。由于Interpreter的效率低下,JVM中的JIT会在运行时有选择性地将运行次数较多的方法编译成二进制代码,直接运行在底层硬件上。

在Java8之前,HotSpot集成了两个JIT,用C1和C2来完成JVM中的即时编译。虽然JIT优化了代码,但收集监控信息会消耗运行时的性能,且编译过程会占用程序的运行时间。

到了Java9,AOT编译器被引入。和JIT不同,AOT是在程序运行前进行的静态编译,这样就可以避免运行时的编译消耗和内存消耗,且 .class文件通过AOT编译器是可以编译成 .so的二进制文件的。

到了Java10,一个新的JIT编译器Graal被引入。Graal 是一个以 Java 为主要编程语言、面向 Java bytecode 的编译器。与用 C++ 实现的 C1 和 C2 相比,它的模块化更加明显,也更容易维护。Graal 既可以作为动态编译器,在运行时编译热点方法;也可以作为静态编译器,实现 AOT 编译。

思考题

我们知道Class.forName和ClassLoader.loadClass都能加载类,你知道这两者在加载类时的区别吗?

  1. 类的初始化行为 (最重要的区别)
    Class.forName(String className) (单参数版本): 默认情况下,此方法在加载类后会立即执行类的静态初始化。这意味着类的 static {} 代码块会被执行,静态变量会按照定义进行初始化。
    ClassLoader.loadClass(String className): 此方法仅加载类(即将类的 .class 文件字节码读入内存,并创建对应的 Class 对象),但不会执行类的静态初始化。类的初始化会推迟到该类第一次被“主动使用”时。
    Class.forName(String name, boolean initialize, ClassLoader loader) (三参数版本): 此方法允许通过 initialize 参数显式控制是否在加载后立即初始化类。如果 initialize 为 true,则会初始化;如果为 false,则不会初始化,行为上更接近 ClassLoader.loadClass()。

  2. 使用的类加载器 (ClassLoader)
    Class.forName(String className) (单参数版本): 它使用调用者(即调用 Class.forName() 的那个类)的类加载器来加载指定的类。如果调用者的类加载器是引导类加载器(Bootstrap ClassLoader,表现为 null),则使用引导类加载器。
    ClassLoader.loadClass(String className): 这是一个实例方法,它是由一个特定的 ClassLoader 对象调用的。因此,它自然使用该 ClassLoader 实例本身(及其双亲委派链)来加载类。
    Class.forName(String name, boolean initialize, ClassLoader loader) (三参数版本): 允许你显式指定一个 ClassLoader 实例来加载类。如果提供的 loader 参数是 null,则会使用引导类加载器。

  3. 主要应用场景和控制级别
    Class.forName(String className): 更常用于需要确保类被初始化的动态类加载场景,例如通过类名字符串加载JDBC驱动(驱动的静态块会注册自身)。它提供了一种相对简单直接的方式加载并初始化类。
    ClassLoader.loadClass(String className): 属于更底层的操作,常被框架和应用服务器用于实现复杂的类加载逻辑,如类的隔离、热部署、插件化等。它提供了对类加载过程更细致的控制,特别是当只需要类的结构信息而不想触发其副作用(即静态初始化)时。
    Class.forName(String name, boolean initialize, ClassLoader loader): 提供了最高的控制级别,允许开发者同时指定加载器和初始化行为,结合了前两者的部分特点,但仍通过静态方法调用

在Web和移动应用的业务场景中,我们经常需要保存这样一种信息:一个key对应了一个数据集合。我举几个例子。
手机App中的每天的用户登录信息:一天对应一系列用户ID或移动设备ID;
电商网站上商品的用户评论列表:一个商品对应了一系列的评论;
用户在手机App上的签到打卡信息:一天对应一系列用户的签到记录;
应用网站上的网页访问信息:一个网页对应一系列的访问点击。
我们知道,Redis集合类型的特点就是一个键对应一系列的数据,所以非常适合用来存取这些数据。但是,在这些场景中,除了记录信息,我们往往还需要对集合中的数据进行统计,例如:
在移动应用中,需要统计每天的新增用户数和第二天的留存用户数;
在电商网站的商品评论中,需要统计评论列表中的最新评论;
在签到打卡中,需要统计一个月内连续打卡的用户数;
在网页访问记录中,需要统计独立访客(Unique Visitor,UV)量。
通常情况下,我们面临的用户数量以及访问量都是巨大的,比如百万、千万级别的用户数量,或者千万级别、甚至亿级别的访问信息。所以,我们必须要选择能够非常高效地统计大量数据(例如亿级)的集合类型。
要想选择合适的集合,我们就得了解常用的集合统计模式。这节课,我就给你介绍集合类型常见的四种统计模式,包括聚合统计、排序统计、二值状态统计和基数统计。我会以刚刚提到的这四个场景为例,和你聊聊在这些统计模式下,什么集合类型能够更快速地完成统计,而且还节省内存空间。掌握了今天的内容,之后再遇到集合元素统计问题时,你就能很快地选出合适的集合类型了。

聚合统计

我们先来看集合元素统计的第一个场景:聚合统计。
所谓的聚合统计,就是指统计多个集合元素的聚合结果,包括:统计多个集合的共有元素(交集统计);把两个集合相比,统计其中一个集合独有的元素(差集统计);统计多个集合的所有元素(并集统计)。
在刚才提到的场景中,统计手机App每天的新增用户数和第二天的留存用户数,正好对应了聚合统计。
要完成这个统计任务,我们可以用一个集合记录所有登录过App的用户ID,同时,用另一个集合记录每一天登录过App的用户ID。然后,再对这两个集合做聚合统计。我们来看下具体的操作。
记录所有登录过App的用户ID还是比较简单的,我们可以直接使用Set类型,把key设置为user:id,表示记录的是用户ID,value就是一个Set集合,里面是所有登录过App的用户ID,我们可以把这个Set叫作累计用户Set,如下图所示:
需要注意的是,累计用户Set中没有日期信息,我们是不能直接统计每天的新增用户的。所以,我们还需要把每一天登录的用户ID,记录到一个新集合中,我们把这个集合叫作每日用户Set,它有两个特点:
key是 user:id 以及当天日期,例如 user:id:20200803;
value是Set集合,记录当天登录的用户ID。
在统计每天的新增用户时,我们只用计算每日用户Set和累计用户Set的差集就行。
我借助一个具体的例子来解释一下。
假设我们的手机App在2020年8月3日上线,那么,8月3日前是没有用户的。此时,累计用户Set是空集,当天登录的用户ID会被记录到 key为user:id:20200803的Set中。所以,user:id:20200803这个Set中的用户就是当天的新增用户。
然后,我们计算累计用户Set和user:id:20200803 Set的并集结果,结果保存在user:id这个累计用户Set中,如下所示:

1
SUNIONSTORE  user:id  user:id  user:id:20200803 

此时,user:id这个累计用户Set中就有了8月3日的用户ID。等到8月4日再统计时,我们把8月4日登录的用户ID记录到user:id:20200804 的Set中。接下来,我们执行SDIFFSTORE命令计算累计用户Set和user:id:20200804 Set的差集,结果保存在key为user:new的Set中,如下所示:

1
SDIFFSTORE  user:new  user:id:20200804 user:id  

可以看到,这个差集中的用户ID在user:id:20200804 的Set中存在,但是不在累计用户Set中。所以,user:new这个Set中记录的就是8月4日的新增用户。
当要计算8月4日的留存用户时,我们只需要再计算user:id:20200803 和 user:id:20200804两个Set的交集,就可以得到同时在这两个集合中的用户ID了,这些就是在8月3日登录,并且在8月4日留存的用户。执行的命令如下:

1
SINTERSTORE user:id:rem user:id:20200803 user:id:20200804

当你需要对多个集合进行聚合计算时,Set类型会是一个非常不错的选择。不过,我要提醒你一下,这里有一个潜在的风险。
Set的差集、并集和交集的计算复杂度较高,在数据量较大的情况下,如果直接执行这些计算,会导致Redis实例阻塞。所以,我给你分享一个小建议:你可以从主从集群中选择一个从库,让它专门负责聚合计算,或者是把数据读取到客户端,在客户端来完成聚合统计,这样就可以规避阻塞主库实例和其他从库实例的风险了。

排序统计

接下来,我们再来聊一聊应对集合元素排序需求的方法。我以在电商网站上提供最新评论列表的场景为例,进行讲解。
最新评论列表包含了所有评论中的最新留言,这就要求集合类型能对元素保序,也就是说,集合中的元素可以按序排列,这种对元素保序的集合类型叫作有序集合。
在Redis常用的4个集合类型中(List、Hash、Set、Sorted Set),List和Sorted Set就属于有序集合。
List是按照元素进入List的顺序进行排序的,而Sorted Set可以根据元素的权重来排序,我们可以自己来决定每个元素的权重值。比如说,我们可以根据元素插入Sorted Set的时间确定权重值,先插入的元素权重小,后插入的元素权重大。看起来好像都可以满足需求,我们该怎么选择呢?
我先说说用List的情况。每个商品对应一个List,这个List包含了对这个商品的所有评论,而且会按照评论时间保存这些评论,每来一个新评论,就用LPUSH命令把它插入List的队头。在只有一页评论的时候,我们可以很清晰地看到最新的评论,但是,在实际应用中,网站一般会分页显示最新的评论列表,一旦涉及到分页操作,List就可能会出现问题了。
假设当前的评论List是{A, B, C, D, E, F}(其中,A是最新的评论,以此类推,F是最早的评论),在展示第一页的3个评论时,我们可以用下面的命令,得到最新的三条评论A、B、C:

1
2
3
4
LRANGE product1 0 2
1) "A"
2) "B"
3) "C"

然后,再用下面的命令获取第二页的3个评论,也就是D、E、F。

1
2
3
4
LRANGE product1 3 5
1) "D"
2) "E"
3) "F"

但是,如果在展示第二页前,又产生了一个新评论G,评论G就会被LPUSH命令插入到评论List的队头,评论List就变成了{G, A, B, C, D, E, F}。此时,再用刚才的命令获取第二页评论时,就会发现,评论C又被展示出来了,也就是C、D、E。

1
2
3
4
LRANGE product1 3 5
1) "C"
2) "D"
3) "E"

之所以会这样,关键原因就在于,List是通过元素在List中的位置来排序的,当有一个新元素插入时,原先的元素在List中的位置都后移了一位,比如说原来在第1位的元素现在排在了第2位。所以,对比新元素插入前后,List相同位置上的元素就会发生变化,用LRANGE读取时,就会读到旧元素。
和List相比,Sorted Set就不存在这个问题,因为它是根据元素的实际权重来排序和获取数据的。
我们可以按评论时间的先后给每条评论设置一个权重值,然后再把评论保存到Sorted Set中。Sorted Set的ZRANGEBYSCORE命令就可以按权重排序后返回元素。这样的话,即使集合中的元素频繁更新,Sorted Set也能通过ZRANGEBYSCORE命令准确地获取到按序排列的数据。
假设越新的评论权重越大,目前最新评论的权重是N,我们执行下面的命令时,就可以获得最新的10条评论:

1
ZRANGEBYSCORE comments N-9 N

所以,在面对需要展示最新列表、排行榜等场景时,如果数据更新频繁或者需要分页显示,建议你优先考虑使用Sorted Set。

二值状态统计

现在,我们再来分析下第三个场景:二值状态统计。这里的二值状态就是指集合元素的取值就只有0和1两种。在签到打卡的场景中,我们只用记录签到(1)或未签到(0),所以它就是非常典型的二值状态,
在签到统计时,每个用户一天的签到用1个bit位就能表示,一个月(假设是31天)的签到情况用31个bit位就可以,而一年的签到也只需要用365个bit位,根本不用太复杂的集合类型。这个时候,我们就可以选择Bitmap。这是Redis提供的扩展数据类型。我来给你解释一下它的实现原理。
Bitmap本身是用String类型作为底层数据结构实现的一种统计二值状态的数据类型。String类型是会保存为二进制的字节数组,所以,Redis就把字节数组的每个bit位利用起来,用来表示一个元素的二值状态。你可以把Bitmap看作是一个bit数组。
Bitmap提供了GETBIT/SETBIT操作,使用一个偏移值offset对bit数组的某一个bit位进行读和写。不过,需要注意的是,Bitmap的偏移量是从0开始算的,也就是说offset的最小值是0。当使用SETBIT对一个bit位进行写操作时,这个bit位会被设置为1。Bitmap还提供了BITCOUNT操作,用来统计这个bit数组中所有“1”的个数。
那么,具体该怎么用Bitmap进行签到统计呢?我还是借助一个具体的例子来说明。
假设我们要统计ID 3000的用户在2020年8月份的签到情况,就可以按照下面的步骤进行操作。
第一步,执行下面的命令,记录该用户8月3号已签到。

1
SETBIT uid:sign:3000:202008 2 1 

第二步,检查该用户8月3日是否签到。

1
GETBIT uid:sign:3000:202008 2 

第三步,统计该用户在8月份的签到次数。

1
BITCOUNT uid:sign:3000:202008

这样,我们就知道该用户在8月份的签到情况了,是不是很简单呢?接下来,你可以再思考一个问题:如果记录了1亿个用户10天的签到情况,你有办法统计出这10天连续签到的用户总数吗?
在介绍具体的方法之前,我们要先知道,Bitmap支持用BITOP命令对多个Bitmap按位做“与”“或”“异或”的操作,操作的结果会保存到一个新的Bitmap中。我以按位“与”操作为例来具体解释一下。从下图中,可以看到,三个Bitmap bm1、bm2和bm3,对应bit位做“与”操作,结果保存到了一个新的Bitmap中(示例中,这个结果Bitmap的key被设为“resmap”)。
回到刚刚的问题,在统计1亿个用户连续10天的签到情况时,你可以把每天的日期作为key,每个key对应一个1亿位的Bitmap,每一个bit对应一个用户当天的签到情况。
接下来,我们对10个Bitmap做“与”操作,得到的结果也是一个Bitmap。在这个Bitmap中,只有10天都签到的用户对应的bit位上的值才会是1。最后,我们可以用BITCOUNT统计下Bitmap中的1的个数,这就是连续签到10天的用户总数了。
现在,我们可以计算一下记录了10天签到情况后的内存开销。每天使用1个1亿位的Bitmap,大约占12MB的内存(10^8/8/1024/1024),10天的Bitmap的内存开销约为120MB,内存压力不算太大。不过,在实际应用时,最好对Bitmap设置过期时间,让Redis自动删除不再需要的签到记录,以节省内存开销。
所以,如果只需要统计数据的二值状态,例如商品有没有、用户在不在等,就可以使用Bitmap,因为它只用一个bit位就能表示0或1。在记录海量数据时,Bitmap能够有效地节省内存空间。

基数统计

最后,我们再来看一个统计场景:基数统计。基数统计就是指统计一个集合中不重复的元素个数。对应到我们刚才介绍的场景中,就是统计网页的UV。
网页UV的统计有个独特的地方,就是需要去重,一个用户一天内的多次访问只能算作一次。在Redis的集合类型中,Set类型默认支持去重,所以看到有去重需求时,我们可能第一时间就会想到用Set类型。
我们来结合一个例子看一看用Set的情况。
有一个用户user1访问page1时,你把这个信息加到Set中:

1
SADD page1:uv user1

用户1再来访问时,Set的去重功能就保证了不会重复记录用户1的访问次数,这样,用户1就算是一个独立访客。当你需要统计UV时,可以直接用SCARD命令,这个命令会返回一个集合中的元素个数。
但是,如果page1非常火爆,UV达到了千万,这个时候,一个Set就要记录千万个用户ID。对于一个搞大促的电商网站而言,这样的页面可能有成千上万个,如果每个页面都用这样的一个Set,就会消耗很大的内存空间。
当然,你也可以用Hash类型记录UV。
例如,你可以把用户ID作为Hash集合的key,当用户访问页面时,就用HSET命令(用于设置Hash集合元素的值),对这个用户ID记录一个值“1”,表示一个独立访客,用户1访问page1后,我们就记录为1个独立访客,如下所示:
HSET page1:uv user1 1
即使用户1多次访问页面,重复执行这个HSET命令,也只会把user1的值设置为1,仍然只记为1个独立访客。当要统计UV时,我们可以用HLEN命令统计Hash集合中的所有元素个数。
但是,和Set类型相似,当页面很多时,Hash类型也会消耗很大的内存空间。那么,有什么办法既能完成统计,还能节省内存吗?
这时候,就要用到Redis提供的HyperLogLog了。
HyperLogLog是一种用于统计基数的数据集合类型,它的最大优势就在于,当集合元素数量非常多时,它计算基数所需的空间总是固定的,而且还很小。
在Redis中,每个 HyperLogLog只需要花费 12 KB 内存,就可以计算接近 2^64 个元素的基数。你看,和元素越多就越耗费内存的Set和Hash类型相比,HyperLogLog就非常节省空间。
在统计UV时,你可以用PFADD命令(用于向HyperLogLog中添加新元素)把访问页面的每个用户都添加到HyperLogLog中。

1
PFADD page1:uv user1 user2 user3 user4 user5

接下来,就可以用PFCOUNT命令直接获得page1的UV值了,这个命令的作用就是返回HyperLogLog的统计结果。

1
PFCOUNT page1:uv

关于HyperLogLog的具体实现原理,你不需要重点掌握,不会影响到你的日常使用,我就不多讲了。如果你想了解一下,课下可以看看这条链接。
不过,有一点需要你注意一下,HyperLogLog的统计规则是基于概率完成的,所以它给出的统计结果是有一定误差的,标准误算率是0.81%。这也就意味着,你使用HyperLogLog统计的UV是100万,但实际的UV可能是101万。虽然误差率不算大,但是,如果你需要精确统计结果的话,最好还是继续用Set或Hash类型。
小结
这节课,我们结合统计新增用户数和留存用户数、最新评论列表、用户签到数以及网页独立访客量这4种典型场景,学习了集合类型的4种统计模式,分别是聚合统计、排序统计、二值状态统计和基数统计。为了方便你掌握,我把Set、Sorted Set、Hash、List、Bitmap、HyperLogLog的支持情况和优缺点汇总在了下面的表格里,希望你把这张表格保存下来,时不时地复习一下。
可以看到,Set和Sorted Set都支持多种聚合统计,不过,对于差集计算来说,只有Set支持。Bitmap也能做多个Bitmap间的聚合计算,包括与、或和异或操作。
当需要进行排序统计时,List中的元素虽然有序,但是一旦有新元素插入,原来的元素在List中的位置就会移动,那么,按位置读取的排序结果可能就不准确了。而Sorted Set本身是按照集合元素的权重排序,可以准确地按序获取结果,所以建议你优先使用它。
如果我们记录的数据只有0和1两个值的状态,Bitmap会是一个很好的选择,这主要归功于Bitmap对于一个数据只用1个bit记录,可以节省内存。
对于基数统计来说,如果集合元素量达到亿级别而且不需要精确统计时,我建议你使用HyperLogLog。
当然,Redis的应用场景非常多,这张表中的总结不一定能覆盖到所有场景。我建议你也试着自己画一张表,把你遇到的其他场景添加进去。长久积累下来,你一定能够更加灵活地把集合类型应用到合适的实践项目中

通过上一讲的讲解,相信你对上下文切换已经有了一定的了解了。如果是单个线程,在 CPU 调用之后,那么它基本上是不会被调度出去的。如果可运行的线程数远大于 CPU 数量,那么操作系统最终会将某个正在运行的线程调度出来,从而使其它线程能够使用 CPU ,这就会导致上下文切换。
还有,在多线程中如果使用了竞争锁,当线程由于等待竞争锁而被阻塞时,JVM 通常会将这个线程挂起,并允许它被交换出去。如果频繁地发生阻塞,CPU 密集型的程序就会发生更多的上下文切换。
那么问题来了,我们知道在某些场景下使用多线程是非常必要的,但多线程编程给系统带来了上下文切换,从而增加的性能开销也是实打实存在的。那么我们该如何优化多线程上下文切换呢?这就是我今天要和你分享的话题,我将重点介绍几种常见的优化方法。

竞争锁优化

大多数人在多线程编程中碰到性能问题,第一反应多是想到了锁。
多线程对锁资源的竞争会引起上下文切换,还有锁竞争导致的线程阻塞越多,上下文切换就越频繁,系统的性能开销也就越大。由此可见,在多线程编程中,锁其实不是性能开销的根源,竞争锁才是。
1.减少锁的持有时间
我们知道,锁的持有时间越长,就意味着有越多的线程在等待该竞争资源释放。如果是Synchronized同步锁资源,就不仅是带来线程间的上下文切换,还有可能会增加进程间的上下文切换。例如,可以将一些与锁无关的代码移出同步代码块,尤其是那些开销较大的操作以及可能被阻塞的操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
优化前
public synchronized void mySyncMethod(){
businesscode1();
mutextMethod();
businesscode2();
}
优化后
public void mySyncMethod(){
businesscode1();
synchronized(this)
{
mutextMethod();
}
businesscode2();
}

2.降低锁的粒度
同步锁可以保证对象的原子性,我们可以考虑将锁粒度拆分得更小一些,以此避免所有线程对一个锁资源的竞争过于激烈。具体方式有以下两种:
锁分离
与传统锁不同的是,读写锁实现了锁分离,也就是说读写锁是由“读锁”和“写锁”两个锁实现的,其规则是可以共享读,但只有一个写。
这样做的好处是,在多线程读的时候,读读是不互斥的,读写是互斥的,写写是互斥的。而传统的独占锁在没有区分读写锁的时候,读写操作一般是:读读互斥、读写互斥、写写互斥。所以在读远大于写的多线程场景中,锁分离避免了在高并发读情况下的资源竞争,从而避免了上下文切换。
锁分段
我们在使用锁来保证集合或者大对象原子性时,可以考虑将锁对象进一步分解。例如,我之前讲过的 Java1.8 之前版本的 ConcurrentHashMap 就使用了锁分段。
3.非阻塞乐观锁替代竞争锁
volatile关键字的作用是保障可见性及有序性,volatile的读写操作不会导致上下文切换,因此开销比较小。 但是,volatile不能保证操作变量的原子性,因为没有锁的排他性。而 CAS 是一个原子的 if-then-act 操作,CAS 是一个无锁算法实现,保障了对一个共享变量读写操作的一致性。CAS 操作中有 3 个操作数,内存值 V、旧的预期值 A和要修改的新值 B,当且仅当 A 和 V 相同时,将 V 修改为 B,否则什么都不做,CAS 算法将不会导致上下文切换。Java 的 Atomic 包就使用了 CAS 算法来更新数据,就不需要额外加锁。
上面我们了解了如何从编码层面去优化竞争锁,那么除此之外,JVM内部其实也对Synchronized同步锁做了优化,我在12讲中有详细地讲解过,这里简单回顾一下。
在JDK1.6中,JVM将Synchronized同步锁分为了偏向锁、轻量级锁、自旋锁以及重量级锁,优化路径也是按照以上顺序进行。JIT 编译器在动态编译同步块的时候,也会通过锁消除、锁粗化的方式来优化该同步锁。
wait/notify优化
在 Java 中,我们可以通过配合调用 Object 对象的 wait()方法和 notify()方法或 notifyAll() 方法来实现线程间的通信。

在线程中调用 wait()方法,将阻塞等待其它线程的通知(其它线程调用notify()方法或notifyAll()方法),在线程中调用 notify()方法或 notifyAll()方法,将通知其它线程从 wait()方法处返回。
下面我们通过wait() / notify()来实现一个简单的生产者和消费者的案例,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
public class WaitNotifyTest {
public static void main(String[] args) {
Vector<Integer> pool=new Vector<Integer>();
Producer producer=new Producer(pool, 10);
Consumer consumer=new Consumer(pool);
new Thread(producer).start();
new Thread(consumer).start();
}
}
/**
* 生产者
* @author admin
*
*/
class Producer implements Runnable{
private Vector<Integer> pool;
private Integer size;
public Producer(Vector<Integer> pool, Integer size) {
this.pool = pool;
this.size = size;
}

public void run() {
for(;;){
try {
System.out.println("生产一个商品 ");
produce(1);
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
}
private void produce(int i) throws InterruptedException{
while(pool.size()==size){
synchronized (pool) {
System.out.println("生产者等待消费者消费商品,当前商品数量为"+pool.size());
pool.wait();//等待消费者消费
}
}
synchronized (pool) {
pool.add(i);
pool.notifyAll();//生产成功,通知消费者消费
}
}
}


/**
* 消费者
* @author admin
*
*/
class Consumer implements Runnable{
private Vector<Integer> pool;
public Consumer(Vector<Integer> pool) {
this.pool = pool;
}

public void run() {
for(;;){
try {
System.out.println("消费一个商品");
consume();
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
}

private void consume() throws InterruptedException{
synchronized (pool) {
while(pool.isEmpty()) {
System.out.println("消费者等待生产者生产商品,当前商品数量为"+pool.size());
pool.wait();//等待生产者生产商品
}
}
synchronized (pool) {
pool.remove(0);
pool.notifyAll();//通知生产者生产商品

}
}

wait/notify的使用导致了较多的上下文切换
结合以下图片,我们可以看到,在消费者第一次申请到锁之前,发现没有商品消费,此时会执行 Object.wait() 方法,这里会导致线程挂起,进入阻塞状态,这里为一次上下文切换。
当生产者获取到锁并执行notifyAll()之后,会唤醒处于阻塞状态的消费者线程,此时这里又发生了一次上下文切换。
被唤醒的等待线程在继续运行时,需要再次申请相应对象的内部锁,此时等待线程可能需要和其它新来的活跃线程争用内部锁,这也可能会导致上下文切换。如果有多个消费者线程同时被阻塞,用notifyAll()方法,将会唤醒所有阻塞的线程。而某些商品依然没有库存,过早地唤醒这些没有库存的商品的消费线程,可能会导致线程再次进入阻塞状态,从而引起不必要的上下文切换。
优化wait/notify的使用,减少上下文切换
首先,我们在多个不同消费场景中,可以使用 Object.notify() 替代 Object.notifyAll()。 因为Object.notify() 只会唤醒指定线程,不会过早地唤醒其它未满足需求的阻塞线程,所以可以减少相应的上下文切换。
其次,在生产者执行完 Object.notify() / notifyAll()唤醒其它线程之后,应该尽快地释放内部锁,以避免其它线程在唤醒之后长时间地持有锁处理业务操作,这样可以避免被唤醒的线程再次申请相应内部锁的时候等待锁的释放。
最后,为了避免长时间等待,我们常会使用Object.wait (long)设置等待超时时间,但线程无法区分其返回是由于等待超时还是被通知线程唤醒,从而导致线程再次尝试获取锁操作,增加了上下文切换。
这里我建议使用Lock锁结合Condition 接口替代Synchronized内部锁中的 wait / notify,实现等待/通知。这样做不仅可以解决上述的Object.wait(long) 无法区分的问题,还可以解决线程被过早唤醒的问题。
Condition 接口定义的 await 方法 、signal 方法和 signalAll 方法分别相当于 Object.wait()、 Object.notify()和 Object.notifyAll()。
合理地设置线程池大小,避免创建过多线程
线程池的线程数量设置不宜过大,因为一旦线程池的工作线程总数超过系统所拥有的处理器数量,就会导致过多的上下文切换。更多关于如何合理设置线程池数量的内容,我将在第18讲中详解。
还有一种情况就是,在有些创建线程池的方法里,线程数量设置不会直接暴露给我们。比如,用 Executors.newCachedThreadPool() 创建的线程池,该线程池会复用其内部空闲的线程来处理新提交的任务,如果没有,再创建新的线程(不受 MAX_VALUE 限制),这样的线程池如果碰到大量且耗时长的任务场景,就会创建非常多的工作线程,从而导致频繁的上下文切换。因此,这类线程池就只适合处理大量且耗时短的非阻塞任务。
使用协程实现非阻塞等待
相信很多人一听到协程(Coroutines),马上想到的就是Go语言。协程对于大部分 Java 程序员来说可能还有点陌生,但其在 Go 中的使用相对来说已经很成熟了。
协程是一种比线程更加轻量级的东西,相比于由操作系统内核来管理的进程和线程,协程则完全由程序本身所控制,也就是在用户态执行。协程避免了像线程切换那样产生的上下文切换,在性能方面得到了很大的提升。协程在多线程业务上的运用。
减少Java虚拟机的垃圾回收
我们在上一讲讲上下文切换的诱因时,曾提到过“垃圾回收会导致上下文切换”。
很多 JVM 垃圾回收器(serial收集器、ParNew收集器)在回收旧对象时,会产生内存碎片,从而需要进行内存整理,在这个过程中就需要移动存活的对象。而移动内存对象就意味着这些对象所在的内存地址会发生变化,因此在移动对象前需要暂停线程,在移动完成后需要再次唤醒该线程。因此减少 JVM 垃圾回收的频率可以有效地减少上下文切换。
总结
上下文切换是多线程编程性能消耗的原因之一,而竞争锁、线程间的通信以及过多地创建线程等多线程编程操作,都会给系统带来上下文切换。除此之外,I/O阻塞以及JVM的垃圾回收也会增加上下文切换。

总的来说,过于频繁的上下文切换会影响系统的性能,所以我们应该避免它。另外,我们还可以将上下文切换也作为系统的性能参考指标,并将该指标纳入到服务性能监控,防患于未然

初识上下文切换

我们首先得明白,上下文切换到底是什么。
其实在单个处理器的时期,操作系统就能处理多线程并发任务。处理器给每个线程分配 CPU 时间片(Time Slice),线程在分配获得的时间片内执行任务。CPU 时间片是 CPU 分配给每个线程执行的时间段,一般为几十毫秒。在这么短的时间内线程互相切换,我们根本感觉不到,所以看上去就好像是同时进行的一样。
时间片决定了一个线程可以连续占用处理器运行的时长。当一个线程的时间片用完了,或者因自身原因被迫暂停运行了,这个时候,另外一个线程(可以是同一个线程或者其它进程的线程)就会被操作系统选中,来占用处理器。这种一个线程被暂停剥夺使用权,另外一个线程被选中开始或者继续运行的过程就叫做上下文切换(Context Switch)。
具体来说,一个线程被剥夺处理器的使用权而被暂停运行,就是“切出”;一个线程被选中占用处理器开始或者继续运行,就是“切入”。在这种切出切入的过程中,操作系统需要保存和恢复相应的进度信息,这个进度信息就是“上下文”了。
那上下文都包括哪些内容呢?具体来说,它包括了寄存器的存储内容以及程序计数器存储的指令内容。CPU 寄存器负责存储已经、正在和将要执行的任务,程序计数器负责存储CPU 正在执行的指令位置以及即将执行的下一条指令的位置。
在当前 CPU 数量远远不止一个的情况下,操作系统将 CPU 轮流分配给线程任务,此时的上下文切换就变得更加频繁了,并且存在跨 CPU 上下文切换,比起单核上下文切换,跨核切换更加昂贵。

多线程上下文切换诱因

在操作系统中,上下文切换的类型还可以分为进程间的上下文切换和线程间的上下文切换。而在多线程编程中,我们主要面对的就是线程间的上下文切换导致的性能问题,下面我们就重点看看究竟是什么原因导致了多线程的上下文切换。开始之前,先看下系统线程的生命周期状态。
线程主要有“新建”(NEW)、“就绪”(RUNNABLE)、“运行”(RUNNING)、“阻塞”(BLOCKED)、“死亡”(DEAD)五种状态。到了Java层面它们都被映射为了NEW、RUNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINADTED等6种状态。在这个运行过程中,线程由RUNNABLE转为非RUNNABLE的过程就是线程上下文切换。一个线程的状态由 RUNNING 转为 BLOCKED ,再由 BLOCKED 转为 RUNNABLE ,然后再被调度器选中执行,这就是一个上下文切换的过程。当一个线程从 RUNNING 状态转为 BLOCKED 状态时,我们称为一个线程的暂停,线程暂停被切出之后,操作系统会保存相应的上下文,以便这个线程稍后再次进入 RUNNABLE 状态时能够在之前执行进度的基础上继续执行。当一个线程从 BLOCKED 状态进入到 RUNNABLE 状态时,我们称为一个线程的唤醒,此时线程将获取上次保存的上下文继续完成执行。通过线程的运行状态以及状态间的相互切换,我们可以了解到,多线程的上下文切换实际上就是由多线程两个运行状态的互相切换导致的。那么在线程运行时,线程状态由 RUNNING 转为 BLOCKED 或者由 BLOCKED 转为 RUNNABLE,这又是什么诱发的呢?我们可以分两种情况来分析,一种是程序本身触发的切换,这种我们称为自发性上下文切换,另一种是由系统或者虚拟机诱发的非自发性上下文切换。
自发性上下文切换指线程由 Java 程序调用导致切出,在多线程编程中,执行调用以下方法或关键字,常常就会引发自发性上下文切换。sleep(),wait(),yield(),join(),park(),synchronized,lock
非自发性上下文切换指线程由于调度器的原因被迫切出。常见的有:线程被分配的时间片用完,虚拟机垃圾回收导致或者执行优先级的问题导致。
这里重点说下“虚拟机垃圾回收为什么会导致上下文切换”。在 Java 虚拟机中,对象的内存都是由虚拟机中的堆分配的,在程序运行过程中,新的对象将不断被创建,如果旧的对象使用后不进行回收,堆内存将很快被耗尽。Java 虚拟机提供了一种回收机制,对创建后不再使用的对象进行回收,从而保证堆内存的可持续性分配。而这种垃圾回收机制的使用有可能会导致 stop-the-world 事件的发生,这其实就是一种线程暂停行为。

发现上下文切换

我们总说上下文切换会带来系统开销,那它带来的性能问题是不是真有这么糟糕呢?我们又该怎么去监测到上下文切换?上下文切换到底开销在哪些环节?接下来我将给出一段代码,来对比串联执行和并发执行的速度,然后一一解答这些问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
public class DemoApplication {
public static void main(String[] args) {
//运行多线程
MultiThreadTester test1 = new MultiThreadTester();
test1.Start();
//运行单线程
SerialTester test2 = new SerialTester();
test2.Start();
}


static class MultiThreadTester extends ThreadContextSwitchTester {
@Override
public void Start() {
long start = System.currentTimeMillis();
MyRunnable myRunnable1 = new MyRunnable();
Thread[] threads = new Thread[4];
//创建多个线程
for (int i = 0; i < 4; i++) {
threads[i] = new Thread(myRunnable1);
threads[i].start();
}
for (int i = 0; i < 4; i++) {
try {
//等待一起运行完
threads[i].join();
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
long end = System.currentTimeMillis();
System.out.println("multi thread exce time: " + (end - start) + "s");
System.out.println("counter: " + counter);
}
// 创建一个实现Runnable的类
class MyRunnable implements Runnable {
public void run() {
while (counter < 100000000) {
synchronized (this) {
if(counter < 100000000) {
increaseCounter();
}

}
}
}
}
}

//创建一个单线程
static class SerialTester extends ThreadContextSwitchTester{
@Override
public void Start() {
long start = System.currentTimeMillis();
for (long i = 0; i < count; i++) {
increaseCounter();
}
long end = System.currentTimeMillis();
System.out.println("serial exec time: " + (end - start) + "s");
System.out.println("counter: " + counter);
}
}

//父类
static abstract class ThreadContextSwitchTester {
public static final int count = 100000000;
public volatile int counter = 0;
public int getCount() {
return this.counter;
}
public void increaseCounter() {

this.counter += 1;
}
public abstract void Start();
}
}

通过数据对比我们可以看到:串联的执行速度比并发的执行速度要快。这就是因为线程的上下文切换导致了额外的开销,使用 Synchronized 锁关键字,导致了资源竞争,从而引起了上下文切换,但即使不使用 Synchronized 锁关键字,并发的执行速度也无法超越串联的执行速度,这是因为多线程同样存在着上下文切换。Redis、NodeJS的设计就很好地体现了单线程串行的优势。
在 Linux 系统下,可以使用 Linux 内核提供的 vmstat 命令,来监视 Java 程序运行过程中系统的上下文切换频率,
如果是监视某个应用的上下文切换,就可以使用 pidstat命令监控指定进程的 Context Switch 上下文切换。

1
pidstat -w -p <PID>

至于系统开销具体发生在切换过程中的哪些具体环节,总结如下:
操作系统保存和恢复上下文;
调度器进行线程调度;
处理器高速缓存重新加载;
上下文切换也可能导致整个高速缓存区被冲刷,从而带来时间开销。

总结

上下文切换就是一个工作的线程被另外一个线程暂停,另外一个线程占用了处理器开始执行任务的过程。系统和 Java 程序自发性以及非自发性的调用操作,就会导致上下文切换,从而带来系统开销。
线程越多,系统的运行速度不一定越快。那么我们平时在并发量比较大的情况下,什么时候用单线程,什么时候用多线程呢?
一般在单个逻辑比较简单,而且速度相对来非常快的情况下,我们可以使用单线程。例如,我们前面讲到的 Redis,从内存中快速读取值,不用考虑 I/O 瓶颈带来的阻塞问题。而在逻辑相对来说很复杂的场景,等待时间相对较长又或者是需要大量计算的场景,我建议使用多线程来提高系统的整体性能。例如,NIO 时期的文件读写操作、图像处理以及大数据分析等

Linux常用命令

文件上传下载:

1
2
3
4
5
scp -i <private_key> <local_file> <user>@<server>:<remote_path>
scp -i <private_key> <user>@<server>:<remote_file> <local_path>
rsync -avz --progress -e "ssh -i ~/yqzh-server.pem" /home/user/bigfile.zip root@8.133.205.1:/root/Uploads/
rsync -avz --progress -e "ssh -i ~/yqzh-server.pem" root@8.133.205.1:/root/Uploads/bigfile.zip /home/user/downloads/

1. ping:用于测试网络连接的连通性。可以通过发送ICMP Echo请求到指定IP地址来检查是否能够收到响应。

2. ifconfig:用于配置和显示网络接口的信息,包括IP地址、子网掩码、MAC地址等。也可以使用ifconfig来启用或禁用网络接口。

3. netstat:用于显示网络连接信息、路由表、网络接口统计信息等。常用的选项有-a(显示所有连接)、-n(以数字形式显示地址和端口)、-r(显示路由表)。

4. nslookup:用于进行域名解析。可以用来查询特定域名的IP地址或反向查询IP地址对应的域名。

5. traceroute:用于追踪数据包从源地址到目的地址的路径。通过显示经过的每个路由器的IP地址以及响应时间,可以帮助定位网络延迟或故障点。

6. dig:用于域名查询和信息收集。可以查找特定域名的DNS记录、查询DNS服务器、获取域名的详细信息等。

7. wget:用于从网络上下载文件。可以通过URL下载文件,并支持断点续传、限速、代理等功能。

8. curl:功能类似于wget,可以用于从网络上获取文件。它还支持更多的协议和操作选项,可以发送不同类型的请求(GET、POST等)。

9. ssh:用于远程登录到其他计算机。通过安全的加密协议,可以远程管理和操作其他计算机。

10. ftp:用于在本地和远程计算机之间传输文件。可以使用ftp命令登录到远程服务器,上传和下载文件,以及管理文件和目录。

1
2
3
4
5
6
7
8
9
10
11
root@yuy-Nuvo-8208GC-Series:~/heygem_data# cat /etc/docker/daemon.json
{
"registry-mirrors": [
"https://docker.1panel.dev",
"https://docker.fxxk.dedyn.io",
"https://docker.xn--6oq72ry9d5zx.cn",
"https://docker.m.daocloud.io",
"https://a.ussh.net",
"https://docker.zhai.cm"
]
}

自动远程连接服务器,执行运维命令脚本

两个服务,服务a,服务b

  1. a服务生成密钥,上传到远程服务
1
2
ssh-keygen -t rsa -b 4096
ssh-copy-id root@47.107.226.36
  1. a服务编写shell脚本内容如下
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
#!/bin/bash

# 设置远程服务器的 IP 地址和用户名
REMOTE_IP="47.107.226.36"
REMOTE_USERNAME="root"

# 连接到远程服务器
ssh -o StrictHostKeyChecking=no "$REMOTE_USERNAME@$REMOTE_IP" << 'EOF'
# 确保 /root/yjb 目录存在
mkdir -p /opt/project/JUYUAN-AI-WEB-HTPPS/api/yjb

# 在 /root/yjb 目录下创建一个文本文件
output_file="/opt/project/JUYUAN-AI-WEB-HTPPS/api/yjb/output.txt"

# 写入内容到文件
echo "This is a test file created by the script aaa bbb ccc ddd." > "$output_file"

# 输出文件内容
if [ -f "$output_file" ]; then
cat "$output_file"
echo "File created successfully at $output_file"
else
echo "Error: Failed to create file $output_file"
exit 1
fi
EOF

# 检查 SSH 命令是否成功
if [ $? -eq 0 ]; then
echo "Script executed successfully on $REMOTE_IP"
else
echo "Error: Failed to connect or execute commands on $REMOTE_IP"
exit 1
fi

  1. 授予脚本执行权限
1
chmod 7777 a.sh
  1. 执行

Apache Kafka 操作命令详尽文档

Apache Kafka 是一个分布式流处理平台,设计用于处理大规模实时数据流。它以高吞吐量、低延迟和高可靠性著称,广泛应用于日志收集、事件驱动架构、数据管道等领域。本文档将全面介绍 Kafka 的核心组件、常用命令及其在生产环境中的应用,帮助您快速掌握 Kafka 的管理和使用。


1. Kafka 核心概念详解

在深入命令之前,先详细了解 Kafka 的核心概念:

  • 生产者(Producer):将数据写入 Kafka 的客户端,支持同步或异步发送。
  • 消费者(Consumer):从 Kafka 读取数据的客户端,可单个运行或以消费者组形式协作。
  • 主题(Topic):数据的逻辑分类,类似于数据库中的表。
  • 分区(Partition):主题的物理分片,用于并行处理和扩展性,每个分区是一个有序日志。
  • 副本(Replica):分区的备份,用于高可用性,分领导者(Leader)和跟随者(Follower)。
  • 代理(Broker):Kafka 集群中的服务器,负责存储分区数据和管理客户端请求。
  • 消费者组(Consumer Group):一组消费者协同消费主题的分区,保证每个分区只被组内一个消费者消费。
  • 偏移量(Offset):消费者在分区中读取数据的标记,用于追踪消费进度。
  • ZooKeeper:Kafka 的元数据管理组件,存储 Broker、主题和分区状态。

这些概念是理解 Kafka 命令和操作的基础。


2. Kafka 命令分类与详细说明

Kafka 提供了一系列命令行工具(位于 bin 目录下),用于管理集群、主题、生产者、消费者等。以下是详细分类和说明。

2.1 生产者(Producer)相关命令

生产者负责将数据发送到 Kafka 的主题。以下是常用命令及其详细说明:

命令 描述 参数说明 示例 扩展说明 生产应用场景
kafka-console-producer.sh 从命令行读取输入并发送到指定主题 --broker-list--bootstrap-server: 指定 Broker 地址
--topic: 指定目标主题
--property: 配置生产者属性(如 key.separator
kafka-console-producer.sh --bootstrap-server localhost:9092 --topic my-topic 可通过 Ctrl+D 或 Ctrl+C 结束输入,支持键值对格式(需配置 parse.key=truekey.separator)。 日志推送:将日志实时推送到 Kafka。
kafka-producer-perf-test.sh 测试生产者性能,测量吞吐量和延迟 --topic: 测试主题
--num-records: 发送的总记录数
--record-size: 每条记录字节数
--throughput: 每秒发送速率
--producer-props: 生产者配置
kafka-producer-perf-test.sh --topic test --num-records 100000 --record-size 100 --throughput 1000 --producer-props bootstrap.servers=localhost:9092 输出包括吞吐量(MB/s 和 records/s)、延迟等指标,可用于性能调优。 性能基准测试:评估集群吞吐量。

生产应用场景详解

  • 日志收集:生产者将应用日志(如 Nginx 日志)推送到 Kafka,消费者将其存储到 Elasticsearch 或 HDFS。
  • 事件流:推送用户点击事件到 Kafka,供实时推荐系统使用。
  • 参数调优建议:调整 batch.size(批量大小)和 linger.ms(延迟发送时间)以优化吞吐量。

2.2 消费者(Consumer)相关命令

消费者从 Kafka 读取数据,支持实时消费或从历史偏移量开始。

命令 描述 参数说明 示例 扩展说明 生产应用场景
kafka-console-consumer.sh 从指定主题读取数据并输出到控制台 --bootstrap-server: Broker 地址
--topic: 消费主题
--from-beginning: 从最早偏移量开始
--group: 指定消费者组
kafka-console-consumer.sh --bootstrap-server localhost:9092 --topic my-topic --from-beginning 默认从最新偏移量消费,--from-beginning 可回溯历史数据,支持 --max-messages 限制读取数量。 调试验证:检查主题数据是否正确。
kafka-consumer-groups.sh 管理消费者组,包括列出、描述、重置偏移量 --bootstrap-server: Broker 地址
--list: 列出所有组
--describe: 查看组详情
--group: 指定组名
--reset-offsets: 重置偏移量
kafka-consumer-groups.sh --bootstrap-server localhost:9092 --group my-group --describe 可重置偏移量到 earliestlatest 或特定值(如 --to-offset 100),需配合 --execute 生效。 偏移量管理:修复消费异常。

生产应用场景详解

  • 实时分析:消费 Kafka 数据到流处理引擎(如 Flink),生成实时报表。
  • 数据同步:将 Kafka 数据消费到 MySQL 或 Redis,支持断点续传。
  • 消费组管理:使用 kafka-consumer-groups.sh 检查组内消费者分配情况,解决消费滞后问题。

2.3 主题(Topic)管理命令

主题是 Kafka 中数据的逻辑容器,管理主题是运维核心。

命令 描述 参数说明 示例 扩展说明 生产应用场景
kafka-topics.sh 创建、删除、修改或查看主题信息 --create: 创建主题
--delete: 删除主题
--list: 列出主题
--describe: 查看详情
--partitions: 分区数
--replication-factor: 副本数
kafka-topics.sh --create --bootstrap-server localhost:9092 --replication-factor 2 --partitions 4 --topic my-topic 分区数不可减少,副本数受限于 Broker 数量,--describe 显示分区分布和副本状态。 主题规划:按业务划分主题。
kafka-configs.sh 动态修改主题配置(如保留时间、压缩策略) --entity-type topics: 指定主题类型
--entity-name: 主题名
--alter: 修改配置
--add-config: 添加配置项
kafka-configs.sh --bootstrap-server localhost:9092 --entity-type topics --entity-name my-topic --alter --add-config retention.ms=604800000 支持配置 retention.ms(保留时间)、compression.type(压缩类型,如 gzip)等。 存储优化:控制数据保留周期。

生产应用场景详解

  • 主题创建:为不同业务(如订单、支付)创建主题,分区数根据吞吐量规划(如 QPS / 单分区处理能力)。
  • 数据保留:设置 retention.ms 为 7 天(604800000ms),过期数据自动清理。
  • 压缩优化:启用 compression.type=gzip,节省存储空间。

2.4 分区(Partition)管理命令

分区是主题的物理分片,支持并行性和高可用性。

命令 描述 参数说明 示例 扩展说明 生产应用场景
kafka-reassign-partitions.sh 重新分配分区到指定 Broker --bootstrap-server: Broker 地址
--reassignment-json-file: 分配计划 JSON 文件
--execute: 执行分配
kafka-reassign-partitions.sh --bootstrap-server localhost:9092 --reassignment-json-file plan.json --execute JSON 文件需指定主题、分区和目标 Broker ID,可用 --generate 生成模板。 负载均衡:调整分区分布。
kafka-preferred-replica-election.sh 触发首选副本选举,优化领导者分布 --bootstrap-server: Broker 地址 kafka-preferred-replica-election.sh --bootstrap-server localhost:9092 确保首选副本(Preferred Replica)成为 Leader,提升性能和稳定性。 高可用性:优化副本领导权。

生产应用场景详解

  • 负载均衡:当某个 Broker 负载过高时,重新分配分区到其他 Broker。
  • 故障恢复:Broker 宕机后,触发副本选举恢复服务。
  • 扩展建议:分区数规划需考虑消费者并行度,分区过多可能增加管理开销。

2.5 其他管理命令

这些命令用于权限控制、日志检查和调试。

命令 描述 参数说明 示例 扩展说明 生产应用场景
kafka-acls.sh 管理访问控制列表(ACL) --allow-principal: 授权用户
--operation: 操作类型(如 Read、Write)
--topic: 目标主题
kafka-acls.sh --bootstrap-server localhost:9092 --add --allow-principal User:Bob --operation Read --topic my-topic 支持 --deny-principal 拒绝访问,需启用 Kafka 的 ACL 功能(配置 authorizer.class.name)。 权限控制:限制主题访问。
kafka-log-dirs.sh 查询 Broker 的日志目录使用情况 --bootstrap-server: Broker 地址
--describe: 显示详细信息
kafka-log-dirs.sh --bootstrap-server localhost:9092 --describe 显示每个 Broker 的磁盘使用量和分区分布,便于容量规划。 存储监控:检查磁盘使用。
kafka-dump-log.sh 转储 Kafka 日志文件内容,用于调试 --files: 日志文件路径
--print-data-log: 显示消息内容
kafka-dump-log.sh --files /kafka-logs/my-topic-0/00000000000000000000.log --print-data-log 可查看消息的偏移量、键、值及元数据,适合排查数据问题。 问题排查:分析日志文件。

生产应用场景详解

  • 安全管理:为不同团队分配主题读写权限,避免误操作。
  • 容量规划:通过 kafka-log-dirs.sh 监控磁盘使用,提前扩容。
  • 数据验证:使用 kafka-dump-log.sh 检查消息是否正确存储。

3. 生产中的典型应用场景与解决方案

以下是 Kafka 在生产环境中的详细应用场景及其实现方式:

3.1 日志收集与处理

  • 需求:实时收集分布式系统日志。
  • 实现:生产者推送日志到主题(如 logs),消费者将其写入 ELK(Elasticsearch、Logstash、Kibana)或 HDFS。
  • 配置建议:设置 replication-factor=3 确保高可用,retention.ms=604800000(7 天)控制存储。

3.2 实时数据流处理

  • 需求:实时分析用户行为生成推荐。
  • 实现:生产者推送点击事件,消费者组结合 Kafka Streams 或 Flink 进行流处理。
  • 优化建议:分区数匹配消费者并行度,启用 compression.type=snappy 提高吞吐量。

3.3 数据集成与同步

  • 需求:将数据库变更同步到其他系统。
  • 实现:使用 Kafka Connect 集成 Debezium(捕获数据库变更)到 Kafka,消费者写入目标系统(如 Redis)。
  • 注意事项:确保 Connect 的容错性,配置 tasks.max 控制并行任务数。

3.4 分布式消息队列

  • 需求:替代传统消息队列(如 RabbitMQ)。
  • 实现:生产者发送任务到主题,消费者组消费并处理,偏移量由 Kafka 管理。
  • 优势:支持高吞吐量和持久化,适合大规模任务调度。

4. 生产中的高级需求与解决方案

以下是 Kafka 在生产环境中的高级需求及其详细解决方案:

4.1 Exactly-Once 语义

  • 问题:避免消息重复或丢失。
  • 解决方案
    1. 启用幂等生产者:enable.idempotence=true
    2. 使用事务生产者:配置 transactional.id,调用 beginTransaction()commitTransaction()
    3. 消费者端设置 isolation.level=read_committed
  • 场景:金融系统中的订单处理。

4.2 数据备份与容灾

  • 问题:确保数据不丢失并支持跨数据中心复制。
  • 解决方案
    1. 设置 replication-factor >= 3,保证副本冗余。
    2. 使用 MirrorMaker 2.0 实现跨集群同步。
  • 配置:调整 min.insync.replicas=2 确保至少两个副本同步。

4.3 性能优化

  • 问题:满足高吞吐量和低延迟需求。
  • 解决方案
    1. 生产者:增大 batch.size(如 16384)和 linger.ms(如 5ms)。
    2. 消费者:调整 fetch.max.bytes(如 50MB)和 max.partition.fetch.bytes(如 1MB)。
    3. Broker:优化 num.io.threadsnum.network.threads
  • 测试:使用 kafka-producer-perf-test.shkafka-consumer-perf-test.sh 验证效果。

4.4 监控与告警

  • 问题:实时监控集群状态并及时响应异常。
  • 解决方案
    1. 集成 JMX 导出指标到 Prometheus。
    2. 使用 Grafana 可视化关键指标(如 UnderReplicatedPartitions、BytesInPerSec)。
    3. 设置告警(如分区未同步超过 5 分钟)。
  • 推荐指标:Leader 选举频率、分区 Lag、Broker 磁盘使用率。

5. 总结

Apache Kafka 是一个强大的分布式流处理平台,提供了丰富的命令行工具来管理生产者、消费者、主题和集群状态。本文档详细列出了 Kafka 的核心命令,包含参数说明、示例和生产场景,帮助您全面掌握其功能。无论是日志收集、实时处理还是数据集成,Kafka 都能通过其高吞吐量和可靠性满足需求。结合生产中的高级解决方案,您可以构建健壮、高效的 Kafka 系统。

Redis 操作命令文档

Redis 是一个高性能的键值存储数据库,支持多种数据结构,包括字符串(String)、哈希(Hash)、列表(List)、集合(Set)、有序集合(Sorted Set),以及位图(Bitmap)、HyperLogLog 和地理空间索引(Geospatial)等扩展数据结构。本文档将详细列出这些数据结构的常用命令,并探讨它们在生产环境中的典型应用场景。


1. 字符串(String)

字符串是 Redis 最基本的数据类型,可以存储文本、数字或二进制数据。常用于缓存、计数器、分布式锁等场景。

命令 描述 示例 生产应用场景
SET key value 设置键值对 SET name "Alice" 缓存用户信息、配置项
GET key 获取键的值 GET name 读取缓存数据
APPEND key value 追加值到键的末尾 APPEND name " Smith" 日志记录、消息拼接
INCR key 将键的值加 1(适用于整数) INCR counter 计数器(如页面访问量)
DECR key 将键的值减 1 DECR counter 库存扣减
INCRBY key increment 增加指定增量 INCRBY counter 10 批量增加计数
DECRBY key decrement 减少指定减量 DECRBY counter 5 批量减少计数
GETRANGE key start end 获取子字符串 GETRANGE name 0 4 提取部分数据
SETNX key value 仅在键不存在时设置值 SETNX name "Bob" 分布式锁
MSET key1 value1 ... 批量设置键值对 MSET k1 "v1" k2 "v2" 批量缓存数据
MGET key1 key2 ... 批量获取键的值 MGET k1 k2 批量读取缓存
STRLEN key 获取值的长度 STRLEN name 验证数据完整性

生产应用场景

  • 缓存:存储数据库查询结果、API 响应等,减少数据库压力。
  • 分布式锁:使用 SETNX 实现互斥锁,防止并发冲突。
  • 计数器:如网站访问量、点赞数,Redis 的原子操作保证计数的准确性。

2. 哈希(Hash)

哈希适合存储对象,键值对形式类似于字段和值的映射。常用于用户信息、配置信息等。

命令 描述 示例 生产应用场景
HSET key field value 设置哈希字段的值 HSET user:1 name "Alice" 存储用户对象
HGET key field 获取哈希字段的值 HGET user:1 name 读取用户属性
HMSET key field1 value1 ... 批量设置字段值 HMSET user:1 name "Alice" age "25" 批量更新对象
HMGET key field1 field2 ... 批量获取字段值 HMGET user:1 name age 批量读取属性
HGETALL key 获取哈希所有字段和值 HGETALL user:1 获取完整对象
HDEL key field1 ... 删除指定字段 HDEL user:1 age 删除对象属性
HEXISTS key field 检查字段是否存在 HEXISTS user:1 name 验证属性存在
HKEYS key 获取哈希所有字段名 HKEYS user:1 列出对象字段
HVALS key 获取哈希所有值 HVALS user:1 列出对象值
HLEN key 获取哈希字段数量 HLEN user:1 获取对象字段数

生产应用场景

  • 用户 Session:存储用户会话数据,支持快速访问和更新。
  • 配置管理:存储应用的配置项,支持动态修改。
  • 购物车:字段为商品 ID,值为数量,便于管理。

3. 列表(List)

列表是一个有序的字符串集合,支持从两端操作(双端队列)。常用于消息队列、任务队列等。

命令 描述 示例 生产应用场景
LPUSH key value1 ... 从左侧插入元素 LPUSH list "a" "b" 消息队列入队
RPUSH key value1 ... 从右侧插入元素 RPUSH list "c" "d" 任务队列入队
LPOP key 从左侧弹出元素 LPOP list 消息队列出队
RPOP key 从右侧弹出元素 RPOP list 任务队列出队
LRANGE key start end 获取指定范围的元素 LRANGE list 0 -1 获取队列内容
LLEN key 获取列表长度 LLEN list 检查队列长度
LINDEX key index 获取指定索引的元素 LINDEX list 1 访问特定元素
LREM key count value 删除指定值的元素 LREM list 1 "a" 移除重复消息
LSET key index value 设置指定索引的值 LSET list 0 "x" 更新队列元素
LTRIM key start end 修剪列表到指定范围 LTRIM list 1 2 保留最新 N 条记录

生产应用场景

  • 消息队列:使用 LPUSHRPOP 实现 FIFO 队列。
  • 任务队列:存储待处理任务,工作者从队列中取任务。
  • 最新列表:如最新评论,保持固定长度,移除旧数据。

4. 集合(Set)

集合是无序、不重复的字符串集合。常用于去重、关系运算、随机抽取等。

命令 描述 示例 生产应用场景
SADD key member1 ... 添加元素到集合 SADD set "a" "b" 添加用户标签
SREM key member1 ... 删除集合中的元素 SREM set "a" 移除用户标签
SMEMBERS key 获取集合所有元素 SMEMBERS set 列出用户标签
SISMEMBER key member 检查元素是否在集合中 SISMEMBER set "b" 检查用户标签
SCARD key 获取集合元素数量 SCARD set 统计标签数量
SINTER key1 key2 ... 求多个集合的交集 SINTER set1 set2 共同好友
SUNION key1 key2 ... 求多个集合的并集 SUNION set1 set2 合并标签
SDIFF key1 key2 ... 求多个集合的差集 SDIFF set1 set2 独有标签
SPOP key 随机弹出一个元素 SPOP set 随机抽奖
SRANDMEMBER key [count] 随机获取元素 SRANDMEMBER set 2 随机推荐

生产应用场景

  • 去重:存储用户 ID,防止重复操作。
  • 关系运算:计算共同关注、共同好友等。
  • 随机抽取:用于抽奖系统或随机推荐。

5. 有序集合(Sorted Set)

有序集合是有序、不重复的字符串集合,每个元素关联一个分数(score)。常用于排行榜、延迟任务等。

命令 描述 示例 生产应用场景
ZADD key score1 member1 ... 添加元素及分数 ZADD zset 1 "a" 2 "b" 添加用户分数
ZRANGE key start end [WITHSCORES] 按分数升序获取元素 ZRANGE zset 0 -1 WITHSCORES 获取排行榜
ZREVRANGE key start end [WITHSCORES] 按分数降序获取元素 ZREVRANGE zset 0 -1 获取倒序排行
ZREM key member1 ... 删除元素 ZREM zset "a" 移除用户
ZCARD key 获取元素数量 ZCARD zset 统计用户数
ZSCORE key member 获取元素的分数 ZSCORE zset "b" 查询用户分数
ZINCRBY key increment member 增加元素的分数 ZINCRBY zset 1.5 "b" 更新用户分数
ZRANK key member 获取元素升序排名 ZRANK zset "b" 查询用户排名
ZREVRANK key member 获取元素降序排名 ZREVRANK zset "b" 查询倒序排名
ZRANGEBYSCORE key min max [WITHSCORES] 按分数范围获取元素 ZRANGEBYSCORE zset 1 5 范围查询

生产应用场景

  • 排行榜:如游戏积分榜、商品销量榜。
  • 延迟任务:score 为执行时间,定时轮询。
  • 范围查询:按时间或分数段查询数据。

6. 位图(Bitmap)

位图通过字符串的位操作实现,适合存储大量布尔值,节省空间。常用于在线状态、签到记录等。

命令 描述 示例 生产应用场景
SETBIT key offset value 设置指定偏移量的位值 SETBIT bitmap 7 1 设置用户在线状态
GETBIT key offset 获取指定偏移量的位值 GETBIT bitmap 7 检查用户在线状态
BITCOUNT key [start end] 统计值为 1 的位数 BITCOUNT bitmap 统计在线用户数
BITOP operation destkey key1 ... 位运算(如 AND、OR) BITOP AND result k1 k2 用户行为分析

生产应用场景

  • 在线状态:offset 为用户 ID,记录在线状态。
  • 签到记录:offset 为天数,记录签到情况。
  • 布隆过滤器:实现布隆过滤器,快速判断元素是否存在。

7. HyperLogLog

HyperLogLog 用于基数统计(近似去重计数),占用固定空间,适合大数据量去重。

命令 描述 示例 生产应用场景
PFADD key element1 ... 添加元素 PFADD hll "a" "b" 记录用户访问
PFCOUNT key1 key2 ... 获取基数估计值 PFCOUNT hll 统计独立用户数
PFMERGE destkey key1 ... 合并多个 HyperLogLog PFMERGE hll3 hll1 hll2 合并统计数据

生产应用场景

  • UV 统计:统计网站独立访客数。
  • 去重计数:如独立 IP 数、设备数。
  • 大数据去重:在内存受限时进行大规模去重。

8. 地理空间索引(Geospatial)

用于存储地理位置并计算距离,适合 LBS(基于位置的服务)应用。

命令 描述 示例 生产应用场景
GEOADD key longitude latitude member 添加地理位置 GEOADD cities 13.36 52.52 "Berlin" 添加城市位置
GEOPOS key member1 ... 获取坐标 GEOPOS cities "Berlin" 查询城市坐标
GEODIST key member1 member2 [unit] 计算两点距离 GEODIST cities "Berlin" "Paris" km 计算城市间距离
GEORADIUS key longitude latitude radius unit 查找范围内的位置 GEORADIUS cities 13.36 52.52 100 km 查找附近城市

生产应用场景

  • 附近的人:查找附近的用户。
  • 门店推荐:根据位置推荐附近门店。
  • 路径规划:计算两点距离,辅助规划。

9. 通用命令(适用于所有数据结构)

这些命令用于管理 Redis 键的生命周期和基本操作。

命令 描述 示例 生产应用场景
DEL key1 key2 ... 删除键 DEL name 清理缓存
EXISTS key 检查键是否存在 EXISTS name 验证数据存在
TYPE key 获取键的数据类型 TYPE name 调试和监控
EXPIRE key seconds 设置键的过期时间 EXPIRE name 60 设置缓存过期
TTL key 获取键的剩余生存时间 TTL name 检查缓存有效期
PERSIST key 移除键的过期时间 PERSIST name 取消过期设置
RENAME key newkey 重命名键 RENAME name new_name 更新键名
KEYS pattern 查找匹配模式的键 KEYS user:* 批量操作键

生产应用场景

  • 缓存管理:设置过期时间,自动清理数据。
  • 数据清理:定期删除无用数据。
  • 监控:检查键的状态和类型。

生产中的高级需求和解决方案

以下是 Redis 在生产环境中常见的高级需求及其解决方案:

1. 缓存穿透

  • 问题:恶意请求查询不存在的键,导致数据库压力过大。
  • 解决方案:使用布隆过滤器(基于位图实现)预先过滤不存在的键。

2. 缓存雪崩

  • 问题:大量缓存同时过期,导致数据库压力剧增。
  • 解决方案:设置随机过期时间,避免同时失效;使用多级缓存。

3. 分布式锁

  • 问题:分布式系统中需要互斥访问资源。
  • 解决方案:使用 SETNX 实现锁,结合 EXPIRE 设置超时。

4. 消息队列

  • 问题:需要异步处理任务或消息。
  • 解决方案:使用列表实现队列,支持阻塞操作(如 BLPOP)。

5. 排行榜

  • 问题:需要实时更新和查询排行榜。
  • 解决方案:使用有序集合,score 作为排名依据。

6. 限流

  • 问题:限制接口访问频率,防止攻击。
  • 解决方案:使用计数器或滑动窗口算法实现。

7. 会话管理

  • 问题:分布式系统中管理用户会话。
  • 解决方案:将 session 存储在 Redis 中,支持过期管理。

基于SpringCloud最新版的微服务可视化平台

Spring Cloud 可视化组件!以下是它的核心亮点:
Spring Boot 3.4.2:采用最新版本的 Spring Boot,确保系统高效稳定。
JDK 17+:兼容 Java 17 及以上版本,充分利用现代 Java 的强大特性。
Spring Cloud 2024.0.0:集成 Spring Cloud 2024.0.0,带来前沿的微服务支持。
Docker Compose 一键部署:只需一条命令,即可轻松完成部署,省时省力。
轻量镜像编译:优化后的镜像体积更小,编译和运行更快,节省资源。
自定义服务第三方服务,完成oauth认证方案
在线体验:立即访问 https://www.techkid.top/echo-admin/login OAuth 认证模块。由于服务器资源有限,目前仅部署了核心功能

0%