diff --git a/docs/Java/JUC.md b/docs/Java/JUC.md new file mode 100644 index 0000000..f1f9a1e --- /dev/null +++ b/docs/Java/JUC.md @@ -0,0 +1,4354 @@ +## 死锁 + +Java 死锁产生的四个必要条件: + +1. 互斥条件,即当资源被一个线程使用(占有)时,别的线程不能使用 +2. 不可剥夺条件,资源请求者不能强制从资源占有者手中夺取资源,资源只能由资源占有者主动释放 +3. 请求和保持条件,即当资源请求者在请求其他的资源的同时保持对原有资源的占有 +4. 循环等待条件,即存在一个等待循环队列:p1 要 p2 的资源,p2 要 p1 的资源,形成了一个等待环路 + + + +定位死锁: + +检测死锁可以使用 **jconsole**工具,或者使用 **jps 定位进程 id,再用 jstack 定位死锁** + +Linux 下可以通过 top 先定位到 CPU 占用高的 Java 进程,再利用 `top -Hp 进程id` 来定位是哪个线程,最后再用 jstack 的输出来看各个线程栈 + + + + + +## synchronized + +Java 中的每一个对象都可以作为锁。 具体表现为以下 3 种形式: + +1. 对于普通同步方法,锁是当前实例对象。 +2. 对于静态同步方法,锁是当前类的 Class 对象。 +3. 对于同步方法块,锁是 Synchonized 括号里配置的对象。 + + + +JVM 基于进入和退出 Monitor 对象来实现方法同步和代码块同步,代码块同步是使用 monitorenter 和 monitorexit 指令实现的,方法的同步同样可以使用这两个指令来实现。monitorenter 指令是在编译后插入到同步代码块的开始位置,而 monitorexit 是插入到方法结束处和异常处,JVM 要保证每个 monitorenter 必须有对应的 monitorexit 与之配对。任何对象都有一个 monitor 与之关联,当且一个monitor 被持有后,它将处于锁定状态。线程执行到 monitorenter 指令时,将会尝试获取对象所对应的 monitor 的所有权,即尝试获得对象的锁。 + + + +### 对象头 + +在`JVM`中,一个`Java`对象在内存的布局,会分为三个区域:对象头、实例数据以及对齐填充。 + +synchronized 用的锁是存在 Java 对象头里的。如果对象是数组类型,则虚拟机用 3个字宽(Word)存储对象头,如果对象是非数组类型,则用 2 字宽存储对象头。在 32 位虚拟机中,1 字宽等于 4 字节,即 32bit;64位虚拟机下,一个字宽大小为 8 字节。 + +![](JUC\对象头.jpeg) + +Java 对象头里的 Mark Word 里默认存储对象的 HashCode、分代年龄和锁标记位。 + +![](JUC\对象头存储结构.jpeg) + +在运行期间,Mark Word 里存储的数据会随着锁标志位的变化而变化。 + +![](JUC\锁状态变化.jpeg) + +当对象状态为偏向锁时,Mark Word存储的是偏向的线程ID。 + +当状态为轻量级锁时,Mark Word 存储的是指向线程栈中 Lock Record 的指针。 + +`LockRecord`是什么呢?由于`MarkWord`的空间有限,随着对象状态的改变,原本存储在对象头里的一些信息,如`HashCode`、对象年龄等,就没有足够的空间存储。这时为了保证这些数据不丢失,就会拷贝一份原本的`MarkWord`放到线程栈中,这个拷贝过去的`MarkWord`叫作`Displaced Mark Word`,同时会配合一个指向对象的指针,形成`LockRecord`(锁记录),而原本对象头中的`MarkWord`,就只会存储一个指向`LockRecord`的指针。 + + + +### 锁升级 + +锁一共有 4 种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,这几个状态会随着竞争情况逐渐升级。锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率。 + +#### 偏向锁 + +大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。 + +当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程 ID,以后该线程在进入和退出同步块时不需要进行 CAS 操作来加锁和解锁,只需简单地测试一下对象头的 Mark Word 里是否存储着指向当前线程的偏向锁。如果测试成功,表示线程已经获得了锁。如果测试失败,则需要再测试一下 Mark Word 中偏向锁的标识是否设置成 1 (表示当前是偏向锁):如果没有设置,则使用 CAS 竞争锁;如果设置了,则尝试使用 CAS 将对象头的偏向锁指向当前线程。 + + + +**偏向锁的撤销**: + +偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。 + +1. 在一个安全点(在这个时间点上没有正在执行的字节码)停下拥有锁的线程; + +2. 遍历线程栈,如果存在锁记录的话,需要修复锁记录和Mark Word,使其变成无锁状态。 + +3. 唤醒当前线程,将当前锁升级成轻量级锁。 + +![](JUC\偏向锁的获得和撤销.jpeg) + +**关闭偏向锁**: + +偏向锁是默认是延迟的,不会在程序启动时立即生效,如果想避免延迟,可以加 VM 参数 `-XX:BiasedLockingStartupDelay=0` 来禁用延迟。JDK 8 延迟 4s 开启偏向锁原因:在刚开始执行代码时,会有好多线程来抢锁,如果开偏向锁就会造成偏向锁不断的进行锁撤销和锁升级的操作,效率反而降低。 + +禁用偏向锁,运行时在添加 VM 参数 `-XX:-UseBiasedLocking=false` 禁用偏向锁。 + + + +#### 轻量级锁 + +线程在执行同步块之前,JVM 会先在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的 Mark Word 复制到锁记录中,称为 `Displaced Mark Word`。然后线程尝试使用 CAS 将对象头中的 Mark Word 替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。 + +1. 如果自旋后还未等到锁,则说明目前竞争较重,进入锁膨胀过程 +2. 如果是自己执行了 synchronized 锁重入,那么再添加一条 mark word 设置为null 的锁记录作为重入的计数 + + + +轻量级解锁时,会使用原子的 CAS 操作将 Displaced Mark Word 替换回到对象头, 如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。 + +![](JUC\轻量级锁.jpeg) + + + +| 锁 | 优点 | 缺点 | 适用场景 | +| -------- | ------------------------------------------------------------ | ---------------------------------------------- | ---------------------------------------- | +| 偏向锁 | 加锁解锁不需要额外消耗,和执行非同步方法相比仅存在纳秒级差距 | 如果线程间存在锁竞争,会带来额外的锁撤销的消耗 | 适用于只有一个线程访问同步块场景 | +| 轻量级锁 | 竞争的线程不会阻塞,提高了响应速度 | 始终得不到锁的线程,使用自旋会消耗CPU | 追求响应时间,同步块执行速度非常快的场景 | +| 重量级锁 | 线程竞争不会使用自旋,不会消耗CPU | 线程阻塞,响应时间慢 | 追求吞吐量,同步块执行速度较慢 | + + + + + +## 原子操作 + +CAS 实现原子操作的三大问题: + +1. ABA 问题。因为 CAS 需要在操作值的时候,检查值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是 A,变成了 B,又变成了 A,那么使用 CAS 进行检查时会发现它的值没有发生变化,但是实际上却变化了。 + + ABA 问题的解决思路就是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加 1,那么 A→B→A 就会变成 1A→2B→3A。从 Java 1.5 开始,JDK 的 Atomic 包里提供了一个类 AtomicStampedReference 来解决 ABA 问题。这个类的 compareAndSet 方法的作用是首先检查当前引用是否等于预期引用,并且检查当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。 + +2. 循环时间长开销大。自旋 CAS 如果长时间不成功,会给 CPU 带来非常大的执行开销。如果 JVM 能支持处理器提供的 pause 指令,那么效率会有一定的提升。pause 指令有两个作用:第一,它可以延迟流水线执行指令(de-pipeline),使 CPU 不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零;第二, 它可以避免在退出循环的时候因内存顺序冲突(Memory Order Violation)而引起 CPU 流水线被清空(CPU Pipeline Flush),从而提高 CPU 的执行效率。 + +3. 只能保证一个共享变量的原子操作。当对一个共享变量执行操作时,我们可以使用循环 CAS 的方式来保证原子操作,但是对多个共享变量操作时,循环 CAS 就无法保证操作的原子性,这个时候就可以用锁。还有一个取巧的办法,就是把多个共享变量合并成一个共享变量来操作。从 Java 1.5 开始, JDK 提供了 AtomicReference 类来保证引用对象之间的原子性,就可以把多个变量放在一个对象里来进行 CAS 操作。 + + + +## Java内存模型 + +线程之间的共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),本地内存中存储了该线程以读/写共享变量的副本。本地内存是 JMM 的一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。 + +![](.\JUC\JMM.png) + +### 三大特性 + +**可见性** + +指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。 + +单线程程序不会出现内存可见性问题。但在多线程环境中可就不一定了,由于线程对共享变量的操作,都是拷贝到各自的工作内存运算的,运算完成后才刷回主内存中。另外指令重排以及编译器优化也可能导致可见性问题。 + +JMM 通过限制编译器和处理器的重排序来为程序员提供内存可见性保证。 + +**原子性** + +一个或者多个操作在 CPU 执行的过程中不被中断的特性称为原子性。 + +在Java中使用了原子类和synchronized 来确保原子性。 + +**有序性** + +JMM 允许编译器和 CPU 优化指令顺序,但通过内存屏障机制和 `volatile` 等关键字可以保证线程间的执行顺序。 + +有序性通过 `happens-before` 规则来保证。 + +### volatile + +volatile 是轻量级的 synchronized,它在多处理器开发中保证了共享变量的**可见性**。可见性的意思是当一个线程修改一个共享变量时,另外一个线程能读到这个修改的值。**主要作用是保证可见性和禁止指令重排优化。** + +使用 volatile 修饰的共享变量,底层通过汇编 lock 前缀指令进行缓存锁定: + +1. 将当前处理器(线程)缓存行的数据写回到系统内存。 +2. 这个写回内存的操作会使在其他 CPU 里缓存了该内存地址的数据无效。 + +为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的数据读到内部缓存后再进行操作,但操作完不知道何时会写到内存。如果对声明了 volatile 的变量进行写操作,JVM 就会向处理器发送一条 Lock 前缀的指令,将这个变量所在缓存行的数据写回到系统内存。但是,就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题。所以,在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。 + + + +当写一个 volatile 变量时,JMM 会把该线程对应的本地内存中的共享变量值刷新到主内存。 + +当读一个 volatile 变量时,JMM 会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。 + +线程 A 写一个 volatile 变量,随后线程 B 读这个 volatile 变量,这个过程实质上是线程 A 通过主内存向线程 B 发送消息。 + + + +volatile重排序规则: + +![](JUC\volatile重排序规则表.jpeg) + +* 当第二个操作是 volatile 写时,不管第一个操作是什么,都不能重排序。这个规则确保 volatile 写之前的操作不会被编译器重排序到 volatile 写之后。 +* 当第一个操作是 volatile 读时,不管第二个操作是什么,都不能重排序。这个规则确保 volatile 读之后的操作不会被编译器重排序到 volatile 读之前。 + +为了实现 volatile 的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。 + +* 在每个 volatile 写操作的前面插入一个 StoreStore 屏障。 + +* 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障。 +* 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障。 +* 在每个 volatile 读操作的后面插入一个 LoadStore 屏障。 + +| 屏障类型 | 指令示例 | 说明 | +| ---------- | --------------------------- | ------------------------------------------------------------ | +| LoadLoad | Load1; LoadLoad; Load2; | 确保`Load1`指令数据的装载,发生于`Load2`及后续所有装载指令的数据装载之前。 | +| StoreStore | Store1; StoreStore; Store2; | 确保`Store1`数据的存储对其他处理器可见(刷新到内存中),并发生于`Store2`及后续所有存储指令的数据写入之前。 | +| LoadStore | Load1; LoadStore; Store2; | 确保`Load1`指令数据的装载,发生于`Store2`及后续所有存储指令的数据写入之前。 | +| StoreLoad | Store1; StoreLoad; Load2; | 确保`Store1`数据的存储对其他处理器可见(刷新到内存中),并发生于`Load2`及后续所有装载指令的数据装载之前。`StoreLoad Barriers`会使该屏障之前的所有内存访问指令(存储和装载)完成之后,才执行该屏障之后的内存访问指令。 | + + + +synchronized 无法禁止指令重排和处理器优化,为什么可以保证有序性可见性? + +1. 加了锁之后,只能有一个线程获得到了锁,获得不到锁的线程就要阻塞,所以同一时间只有一个线程执行,相当于单线程,由于数据依赖性的存在,单线程的指令重排是没有问题的 + +2. 线程加锁前,将清空工作内存中共享变量的值,使用共享变量时需要从主内存中重新读取最新的值;线程解锁前,必须把共享变量的最新值刷新到主内存中 + + + +### 锁 + +当线程释放锁时,JMM 会把该线程对应的本地内存中的共享变量刷新到主内存中。 + +当线程获取锁时,JMM 会把该线程对应的本地内存置为无效。从而使得被监视器保护的临界区代码必须从主内存中读取共享变量。 + +线程 A 释放锁,随后线程 B 获取这个锁,这个过程实质上是线程 A 通过主内存 向线程 B 发送消息。 + + + +ReentrantLock 的实现依赖于 Java 同步器框架 AbstractQueuedSynchronizer( AQS)。AQS 使用一个整型的 volatile 变量(命名为 state)来维护同步状态。 + +公平锁和非公平锁释放时,最后都要写一个 volatile 变量 state。 + +公平锁获取时,首先会去读 volatile 变量。 + +非公平锁获取时,首先会用 CAS 更新 volatile 变量,这个操作同时具有 volatile 读 和 volatile 写的内存语义。 + +根据 volatile 的 happens- before 规则,释放锁的线程在写 volatile 变量之前可见的共享变量,在获取锁的线程读取同一个 volatile 变量后将立即变得对获取锁的线程可见。 + +### final + +对于 final 域,编译器和处理器要遵守两个重排序规则: + +1. 在构造函数内,编译器不能将 `final` 域的赋值操作重排序到构造函数之外。 +2. 在构造函数外,读取包含 `final` 域的对象引用和读取该对象的 `final` 域之间不能重排序。这意味着当一个线程获得一个对象引用时,它能够立即读取到 `final` 域的值。 + +这两个重排序规则确保: + +1. 构造函数在初始化 `final` 域后才发布对象引用,避免线程看到未初始化的 `final` 值。 +2. 线程在获得对象引用后,能够立即看到 `final` 域的正确值,避免由于重排序导致读取到错误的 `final` 值。 + +### happens-before + +happens-before 原则的诞生是为了程序员和编译器、处理器之间的平衡。只要不改变程序的执行结果(单线程程序和正确执行的多线程程序),编译器和处理器怎么进行重排序优化都行。对于会改变程序执行结果的重排序,JMM 要求编译器和处理器必须禁止这种重排序。 + +如果 A happens-before B,那么 Java 内存模型将向程序员保证——A 操作的结果将对 B 可见,且 A 的执行顺序排在 B 之前。 + +happens-before规则: + +1. 程序顺序原则:指在一个线程中,按照程序顺序,前面的操作对后续的任意操作可见。 +2. 监视器锁规则:解锁于后续对这个锁的加锁可见。 +3. volatile 变量规则:对一个 volatile 变量的写操作相对于后续对这个 volatile 变量的读操作可见。 +4. 传递性规则:指如果 A Happens-Before B,且 B Happens-Before C,那么 A Happens-Before C。 +5. 线程 start() 规则:指主线程 A 启动子线程 B 后,子线程 B 能够看到主线程A在启动子线程 B 前的操作。 +6. 线程 join() 规则:指主线程 A 等待子线程 B 完成(主线程 A 通过调用子线程 B 的 join() 方法),当子线程 B 完成后(主线程 A 中 join() 方法返回),主线程能够看到子线程B的操作。 + + + +### 双检锁单例模式 + +```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; + } +} +``` + +采用 `volatile` 关键字修饰也是很有必要的,`uniqueInstance = new Singleton();` 这段代码其实是分为三步执行: + +1. 为 `uniqueInstance` 分配内存空间 +2. 初始化 `uniqueInstance` +3. 将 `uniqueInstance` 指向分配的内存地址 + +但是由于 JVM 具有指令重排的特性,执行顺序有可能变成 1->3->2。指令重排在单线程环境下不会出现问题,但是在多线程环境下会导致一个线程获得还没有初始化的实例。例如,线程 T1 执行了 1 和 3,此时 T2 调用 `getUniqueInstance`() 后发现 `uniqueInstance` 不为空,因此返回 `uniqueInstance`,但此时 `uniqueInstance` 还未被初始化。 + + + +## Java线程 + +### 创建和运行线程 + +**方法一:继承Thread** + +```java +// 创建线程对象 +Thread t = new Thread() { + public void run() { + // 要执行的任务 + } +}; +// 启动线程 +t.start(); +``` + +**方法二:实现Runnable 接口** + +```java +Runnable runnable = new Runnable() { + public void run(){ + // 要执行的任务 + } +}; +// 创建线程对象 +Thread t = new Thread( runnable ); +// 启动线程 +t.start(); +``` + +- 方法1 是把线程和任务合并在了一起,方法2 是把线程和任务分开了 +- 用 Runnable 更容易与线程池等高级 API 配合 +- 用 Runnable 让任务类脱离了 Thread 继承体系,更灵活 + +**方法三:实现Callable接口与FutureTask** + +java.util.concurrent.Callable接口类似于Runnable,但Callable的call()方法可以有返回值并且可以抛出异常。要执行Callable任务,需将它包装进一个FutureTask,因为Thread类的构造器只接受Runnable参数,而FutureTask实现了Runnable接口。 + +```java +// 创建任务对象 +FutureTask task3 = new FutureTask<>(() -> { + log.debug("hello"); + return 100; +}); + +// 参数1 是任务对象; 参数2 是线程名字,推荐 +new Thread(task3, "t3").start(); + +// 主线程阻塞,同步等待 task 执行完毕的结果 +Integer result = task3.get(); +log.debug("结果是:{}", result); +``` + +**方法四:使用线程池** + + + + + +### 查看进程线程方法 + +**windows** + +- 任务管理器可以查看进程和线程数,也可以用来杀死进程 +- `tasklist` 查看进程 +- `taskkill` 杀死进程 + + + +**linux** + +- `ps -fe` 查看所有进程 +- `ps -fT -p ` 查看某个进程(PID)的所有线程 +- `kill`杀死进程 +- `top` 按大写 H 切换是否显示线程 +- `top -H -p ` 查看某个进程(PID)的所有线程 + + + +**Java** + +- `jps` 命令查看所有 Java 进程 +- `jstack ` 查看某个 Java 进程(PID)的所有线程状态 +- `jconsole` 来查看某个 Java 进程中线程的运行情况(图形界面) + + + +jconsole 远程监控配置 + +- 需要以如下方式运行你的 java 类 + +```plain +java -Djava.rmi.server.hostname=`ip地址` -Dcom.sun.management.jmxremote - +Dcom.sun.management.jmxremote.port=`连接端口` -Dcom.sun.management.jmxremote.ssl=是否安全连接 - +Dcom.sun.management.jmxremote.authenticate=是否认证 java类 +``` + +- 修改 /etc/hosts 文件将 127.0.0.1 映射至主机名 + +如果要认证访问,还需要做如下步骤 + +- 复制 jmxremote.password 文件 +- 修改 jmxremote.password 和 jmxremote.access 文件的权限为 600 即文件所有者可读写 +- 连接时填入 controlRole(用户名),R&D(密码) + + + +### 线程上下文切换(Thread Context Switch) + +因为以下一些原因导致 cpu 不再执行当前的线程,转而执行另一个线程的代码 + +- 线程的 cpu 时间片用完 +- 垃圾回收 +- 有更高优先级的线程需要运行 +- 线程自己调用了 sleep、yield、wait、join、park、synchronized、lock 等方法 主动让出CPU + + + +当 Context Switch 发生时,需要由操作系统保存当前线程的状态,并恢复另一个线程的状态,Java 中对应的概念就是程序计数器(Program Counter Register),它的作用是记住下一条 jvm 指令的执行地址,是线程私有的 + +- 状态包括程序计数器、虚拟机栈中每个栈帧的信息,如局部变量、操作数栈、返回地址等 +- Context Switch 频繁发生会影响性能 + + + +### 线程方法 + +Thread 类 API: + +| 方法 | 说明 | 注意 | +| ----------------- | ------------------------------------------------------------ | ------------------------------------------------------------ | +| start() | 启动一个新线程,Java虚拟机调用此线程的 run 方法 | start方法只是让线程进入就绪,里面的代码不一定立刻运行。每个线程的start方法只能调用一次。 | +| run() | 线程启动后调用该方法 | 如果构造Thread对象时传递了Runnable参数,线程启动时会调用Runnable中的run方法,否则默认不执行任何操作。但可以创建Thread子类对象重写run方法 | +| sleep(long time) | 让当前线程休眠多少毫秒,休眠时让出CPU的时间片给其他线程 | | +| yield() | 提示线程调度器让出当前线程对 CPU 的使用 | | +| interrupt() | 打断这个线程,异常处理机制 | 正在sleep、wait、join会抛出异常并清除打断标记;如果打断正在运行的线程则会设置打断标记 | +| interrupted() | 判断当前线程是否被打断 | 清除打断标记 | +| isInterrupted() | 判断当前线程是否被打断 | 不清除打断标记 | +| join() | 等待线程结束 | | +| join(long millis) | 等待这个线程结束,最多 millis 毫秒,0 意味着永远等待 | | +| wait() | 当前线程进入等待状态,直到被 `notify()` 或 `notifyAll()` 唤醒。必须在同步块或同步方法中调用。 | | +| notify() | 醒一个正在等待该对象监视器的线程。被唤醒的线程会进入 **Runnable** 状态,但不会立即获得锁。 | | +| notifyAll() | 唤醒所有正在等待该对象监视器的线程。 | | + +### **run和start** + +- 直接调用 run 是在主线程中执行了 run,没有启动新的线程 +- 使用 start 是启动新的线程,通过新的线程间接执行 run 中的代码 + +### **sleep和yield** + +sleep + +- 调用 sleep 会让当前线程从 *Running*进入 *Timed Waiting* 状态(阻塞) +- 其它线程可以使用 interrupt 方法打断正在睡眠的线程,这时 sleep 方法会抛出 InterruptedException + +* 睡眠结束后的线程未必会立刻得到执行 + +* 建议用 TimeUnit 的 sleep 代替 Thread 的 sleep 来获得更好的可读性 + +yield + +- 调用 yield 会让当前线程从 *Running* 进入 *Runnable* 就绪状态,然后调度执行其它线程 +- 具体的实现依赖于操作系统的任务调度器 + + + +### 限制CPU使用 + +在没有利用 cpu 来计算时,不要让 while(true) 空转浪费 cpu,这时可以使用 yield 或 sleep 来让出 cpu的使用权给其他程序 + +**sleep实现** + +```java +while(true) { + try { + Thread.sleep(50); + } catch (InterruptedException e) { + e.printStackTrace(); + } +} +``` + +- 可以用 wait 或 条件变量达到类似的效果 +- 不同的是,后两种都需要加锁,并且需要相应的唤醒操作,一般适用于要进行同步的场景 +- sleep 适用于无需锁同步的场景 + +**wait实现** + +```java +synchronized(锁对象) { + while(条件不满足) { + try { + 锁对象.wait(); + } catch(InterruptedException e) { + e.printStackTrace(); + } + } + // do sth... +} +``` + +**条件变量实现** + +```java +lock.lock(); +try { + while(条件不满足) { + try { + 条件变量.await(); + } catch (InterruptedException e) { + e.printStackTrace(); + } + } + // do sth... +} finally { + lock.unlock(); +} +``` + + + +### 停止线程 + +**异常法停止**:线程调用interrupt()方法后,在线程的run方法中判断当前对象的interrupted()状态,如果是中断状态则抛出异常,达到中断线程的效果。 + +**在沉睡中停止**:先将线程sleep,然后调用interrupt标记中断状态,interrupt会将阻塞状态的线程中断。会抛出中断异常,达到停止线程的效果。 + +**stop()暴力停止**:强制让线程停止有可能使一些请理性的工作得不到完成,如果线程持有 JUC 的互斥锁可能导致锁来不及释放,造成死锁。不推荐 + +**使用return停止线程**:调用interrupt标记为中断状态后,在run方法中判断当前线程状态,如果为中断状态则return,能达到停止线程的效果。 + + + +在一个线程 T1 中如何“优雅”终止线程 T2? + +错误思路: + +1. 使用线程对象的 stop() 方法停止线程 :stop 方法会真正杀死线程,如果这时线程锁住了共享资源,那么当它被杀死后就再也没有机会释放锁,其它线程将永远无法获取锁 + +2. 使用 System.exit(int) 方法停止线程 :目的仅是停止一个线程,但这种做法会让整个程序都停止 + + + +正确思路:两阶段终止模式 + +1. 利用 isInterrupted + + ```java + public class Test { + public static void main(String[] args) throws InterruptedException { + TwoPhaseTermination tpt = new TwoPhaseTermination(); + tpt.start(); + Thread.sleep(3500); + tpt.stop(); + } + } + class TwoPhaseTermination { + private Thread monitor; + // 启动监控线程 + public void start() { + monitor = new Thread(new Runnable() { + @Override + public void run() { + while (true) { + Thread thread = Thread.currentThread(); + if (thread.isInterrupted()) { + System.out.println("后置处理"); + break; + } + try { + Thread.sleep(1000); // 睡眠 + System.out.println("执行监控记录"); // 在此被打断不会异常 + } catch (InterruptedException e) { // 在睡眠期间被打断,进入异常处理的逻辑 + e.printStackTrace(); + // 重新设置打断标记,打断 sleep 会清除打断状态 + thread.interrupt(); + } + } + } + }); + monitor.start(); + } + // 停止监控线程 + public void stop() { + monitor.interrupt(); + } + } + ``` + +2. 利用停止标记 + + ```java + // 停止标记用 volatile 是为了保证该变量在多个线程之间的可见性 + // 我们的例子中,即主线程把它修改为 true 对 t1 线程可见 + class TPTVolatile { + private Thread thread; + private volatile boolean stop = false; + + public void start(){ + thread = new Thread(() -> { + while(true) { + //Thread current = Thread.currentThread(); + if(stop) { + log.debug("料理后事"); + break; + } + try { + Thread.sleep(1000); + log.debug("将结果保存"); + } catch (InterruptedException e) { + + } + // 执行监控操作 + } + },"监控线程"); + thread.start(); + } + + public void stop() { + stop = true; + thread.interrupt(); + } + } + ``` + + + + +### 不推荐 + +不推荐使用的方法,这些方法已过时,容易破坏同步代码块,造成线程死锁: + +- `public final void stop()`:停止线程运行 + + 废弃原因:方法粗暴,除非可能执行 finally 代码块以及释放 synchronized 外,线程将直接被终止,如果线程持有 JUC 的互斥锁可能导致锁来不及释放,造成其他线程永远等待的局面 + +- `public final void suspend()`:挂起(暂停)线程运行 + + 废弃原因:如果目标线程在暂停时对系统资源持有锁,则在目标线程恢复之前没有线程可以访问该资源,如果恢复目标线程的线程在调用 resume 之前会尝试访问此共享资源,则会导致死锁 + +- `public final void resume()`:恢复线程运行 + + + +### 线程状态 + +| 线程状态 | 导致状态发生条件 | +| -------------------------- | ------------------------------------------------------------ | +| NEW(新建) | 线程刚被创建,但是并未启动,还没调用 start 方法,只有线程对象,没有线程特征 | +| Runnable(可运行) | 线程可以在 Java 虚拟机中运行的状态,可能正在运行自己代码,也可能没有,这取决于操作系统处理器,调用了 t.start() 方法 | +| Blocked(阻塞) | 当一个线程试图获取一个对象锁,而该对象锁被其他的线程持有,则该线程进入 Blocked 状态;当该线程持有锁时,该线程将变成 Runnable 状态。 | +| Waiting(无限等待) | 一个线程在等待另一个线程执行一个(唤醒)动作时,该线程进入 Waiting 状态,进入这个状态后不能自动唤醒,必须等待另一个线程调用 notify 或者 notifyAll 方法才能唤醒。在这种状态下,线程将不会消耗CPU资源 | +| Timed Waiting (限期等待) | 有几个方法有超时参数,调用将进入 Timed Waiting 状态,这一状态将一直保持到超时期满或者接收到唤醒通知。带有超时参数的常用方法有 Thread.sleep 、Object.wait | +| Teminated(终止) | run 方法正常退出而死亡,或者因为没有捕获的异常终止了 run 方法而死亡 | + + + +**NEW → RUNNABLE**: + +当调用 t.start() 方法时,由 NEW → RUNNABLE + +**RUNNABLE <--> WAITING**: + +1. t 线程用 `synchronized(obj)` 获取了对象锁后, + + 1. 调用 obj.wait() 方法时: RUNNABLE --> WAITING + + 2. 调用 obj.notify()、obj.notifyAll()、t.interrupt(): + + 1. 竞争锁成功,t 线程从 WAITING → RUNNABLE + + 2. 竞争锁失败,t 线程从 WAITING → BLOCKED + +2. 当前线程调用 t.join() 方法时,**当前线程**从 RUNNABLE --> WAITING ;t 线程运行结束,或调用了当前线程的 interrupt() 时,当前线程从 WAITING --> RUNNABLE + +3. 当前线程调用 LockSupport.park() 方法会让**当前线程**从 RUNNABLE --> WAITING ;调用 LockSupport.unpark(目标线程) 或调用了线程 的 interrupt() ,会让目标线程从 WAITING -->RUNNABLE + +**RUNNABLE <--> TIMED_WAITING**: + +1. t 线程用 synchronized(obj) 获取了对象锁后 , + + 1. 调用 obj.wait(long n) 方法时,t 线程从 RUNNABLE --> TIMED_WAITING + + 2. t 线程等待时间超过了 n 毫秒,或调用 obj.notify() , obj.notifyAll() , t.interrupt() 时 + + 1. 竞争锁成功,t 线程从TIMED_WAITING --> RUNNABLE + + 2. 竞争锁失败,t 线程从TIMED_WAITING --> BLOCKED + +2. 当前线程调用 t.join(long n) 方法时,**当前线程**从 RUNNABLE --> TIMED_WAITING ; 当前线程等待时间超过了 n 毫秒,或t 线程运行结束,或调用了当前线程的 interrupt() 时,当前线程从 TIMED_WAITING --> RUNNABLE + +3. 当前线程调用 Thread.sleep(long n) ,**当前线程**从 RUNNABLE --> TIMED_WAITING ; 当前线程等待时间超过了 n 毫秒,当前线程从TIMED_WAITING --> RUNNABLE + +4. 当前线程调用 LockSupport.parkNanos(long nanos) 或 LockSupport.parkUntil(long millis) 时,**当前线程**从 RUNNABLE --> TIMED_WAITING;调用 LockSupport.unpark(目标线程) 或调用了线程 的 interrupt() ,或是等待超时,会让目标线程从 TIMED_WAITING--> RUNNABLE + +**RUNNABLE <--> BLOCKED**: + +t 线程用 synchronized(obj) 竞争对象锁失败时,从RUNNABLE --> BLOCKED + +持 obj 锁线程的同步代码块执行完毕,会唤醒该对象上所有 BLOCKED的线程重新竞争,如果其中 t 线程竞争成功,从 BLOCKED --> RUNNABLE ,其它失败的线程仍然BLOCKED + +**RUNNABLE --> TERMINATED** : + +当前线程所有代码运行完毕,进入 TERMINATED + +## 管程/监视器(Monitor) + +所谓**管程,指的是管理共享变量以及对共享变量的操作过程,让他们支持并发** + + + +### 竞态条件 Race Condition + +多个线程在临界区内执行,由于代码的**执行序列不同**而导致结果无法预测,称之为发生了**竞态条件** + +为了避免临界区的竞态条件发生,有多种手段可以达到目的: + +- 阻塞式的解决方案:synchronized,Lock +- 非阻塞式的解决方案:原子变量 + + + +### 变量的线程安全 + +**成员变量和静态变量是否线程安全?** + +- 如果它们没有共享,则线程安全 +- 如果它们被共享了,根据它们的状态是否能够改变,又分两种情况 + - 如果只有读操作,则线程安全 + - 如果有读写操作,则这段代码是临界区,需要考虑线程安全 + + +**局部变量是否线程安全?** + +- 局部变量是线程安全的 +- 但局部变量引用的对象则未必 + - 如果该对象没有逃离方法的作用范围,它是线程安全的 + - 如果该对象逃离方法的作用范围,需要考虑线程安全 + + +```java +public static void test1() { + int i = 10; + i++; +} +``` + +每个线程调用 test1() 方法时局部变量 i,会在每个线程的栈帧内存中被创建多份,因此不存在共享 + + + +### 常见线程安全类 + +- String +- Integer +- StringBuffer +- Random +- Vector +- Hashtable +- java.util.concurrent 包下的类 + +String、Integer 等都是不可变类,因为其内部的状态不可以改变,因此它们的方法都是线程安全的 + + + +### 案例分析 + +案例一: + +```java +public class MyServlet extends HttpServlet { + // 是否安全? 不安全 servlet是运行在tomcat下,只有一个实例,所以这些成员变量也只有一份,需要考虑线程安全 + Map map = new HashMap<>(); + // 是否安全? 安全 + String S1 = "..."; + // 是否安全? 安全 + final String S2 = "..."; + // 是否安全? 不安全 + Date D1 = new Date(); + // 是否安全? 不安全 引用不能变,但是属性值是可变的 + final Date D2 = new Date(); + + public void doGet(HttpServletRequest request, HttpServletResponse response) { + // 使用上述变量 + } +} +``` + +案例二: + +```java +public class MyServlet extends HttpServlet { + // 是否安全? 不安全 userService也只有一份 + private UserService userService = new UserServiceImpl(); + + public void doGet(HttpServletRequest request, HttpServletResponse response) { + userService.update(...); + } +} + +public class UserServiceImpl implements UserService { + // 记录调用次数 + private int count = 0; + + public void update() { + // ... + count++; + } +} +``` + +案例三: + +```java +@Aspect +@Component +public class MyAspect { + // 是否安全? 不安全 Spring的对象默认是单例的,所以成员变量是共享的,存在线程安全问题。 + // 使用环绕通知将其变成局部变量解决线程安全问题。 + private long start = 0L; + + @Before("execution(* *(..))") + public void before() { + start = System.nanoTime(); + } + + @After("execution(* *(..))") + public void after() { + long end = System.nanoTime(); + System.out.println("cost time:" + (end-start)); + } +} +``` + +案例四: + +```java +public class MyServlet extends HttpServlet { + // 是否安全 安全 + private UserService userService = new UserServiceImpl(); + + public void doGet(HttpServletRequest request, HttpServletResponse response) { + userService.update(...); + } +} + +public class UserServiceImpl implements UserService { + // 是否安全 安全 + private UserDao userDao = new UserDaoImpl(); + + public void update() { + userDao.update(); + } +} + +public class UserDaoImpl implements UserDao { + public void update() { + String sql = "update user set password = ? where username = ?"; + // 是否安全 安全 + try (Connection conn = DriverManager.getConnection("","","")){ + // ... + } catch (Exception e) { + // ... + } + } +} +``` + +案例五: + +```java +public class MyServlet extends HttpServlet { + // 是否安全 不安全 + private UserService userService = new UserServiceImpl(); + + public void doGet(HttpServletRequest request, HttpServletResponse response) { + userService.update(...); + } +} + +public class UserServiceImpl implements UserService { + // 是否安全 不安全 + private UserDao userDao = new UserDaoImpl(); + + public void update() { + userDao.update(); + } +} + +public class UserDaoImpl implements UserDao { + // 是否安全 不安全 这里Connection放在了成员变量中,由于UserDaoImpl是独一份的,所以Connection是可能被多个线程修改的,所以是不安全的 + private Connection conn = null; + public void update() throws SQLException { + String sql = "update user set password = ? where username = ?"; + conn = DriverManager.getConnection("","",""); + // ... + conn.close(); + } +} +``` + +案例六: + +```java +public class MyServlet extends HttpServlet { + // 是否安全 安全 + private UserService userService = new UserServiceImpl(); + + public void doGet(HttpServletRequest request, HttpServletResponse response) { + userService.update(...); + } +} + +public class UserServiceImpl implements UserService { + public void update() { + UserDao userDao = new UserDaoImpl(); + userDao.update(); + } +} + +public class UserDaoImpl implements UserDao { + // 是否安全 安全 + private Connection = null; + public void update() throws SQLException { + String sql = "update user set password = ? where username = ?"; + conn = DriverManager.getConnection("","",""); + // ... + conn.close(); + } +} +``` + + + +### Monitor + +**Java对象头** + +普通对象: + +![](JUC\普通对象头.png) + +Mark Word 主要用来存储对象自身的运行时数据 + +Klass Word 指向Class对象 + +数组对象: + +![](JUC\数组对象头.png) + +**Mark Word 结构** + +![](JUC\mark_word结构.png) + +**Monitor 结构** + +Monitor 被翻译为监视器或管程 + +每个 Java 对象都可以关联一个 Monitor 对象,如果使用 synchronized 给对象上锁(重量级)之后,该对象头的Mark Word 中就被设置指向 Monitor 对象的指针 + + + +![](JUC\monitor.png) + +- 刚开始 Monitor 中 Owner 为 null +- 当 Thread-2 执行 synchronized(obj) 就会将 Monitor 的所有者 Owner 置为 Thread-2,Monitor中只能有一个 Owner +- 在 Thread-2 上锁的过程中,如果 Thread-3,Thread-4,Thread-5 也来执行 synchronized(obj),就会进入EntryList BLOCKED +- Thread-2 执行完同步代码块的内容,然后唤醒 EntryList 中等待的线程来竞争锁,竞争的时是非公平的 +- 图中 WaitSet 中的 Thread-0,Thread-1 是之前获得过锁,但条件不满足进入 WAITING 状态的线程(wait-notify 机制) + +**注意:** + +- synchronized 必须是进入同一个对象的 monitor 才有上述的效果 +- 不加 synchronized 的对象不会关联监视器,不遵从以上规则 + + + +### **synchronized** + +**对象锁,保证了临界区内代码的原子性**,采用互斥的方式让同一时刻至多只有一个线程能持有对象锁,其它线程获取这个对象锁时会阻塞,保证拥有锁的线程可以安全的执行临界区内的代码,不用担心线程上下文切换 + +synchronized 实际是用**对象锁**保证了**临界区内代码的原子性**,临界区内的代码对外是不可分割的,不会被线程切换所打断。 + +互斥和同步都可以采用 synchronized 关键字来完成,区别: + +- 互斥是保证临界区的竞态条件发生,同一时刻只能有一个线程执行临界区代码 +- 同步是由于线程执行的先后、顺序不同、需要一个线程等待其它线程运行到某个点 + +语法: + +```java +synchronized(对象) // 线程1, 线程2(blocked) +{ + 临界区 +} +``` + +**方法上的synchronized** + +```java +// 锁住 this 对象,只有在当前实例对象的线程内是安全的,如果有多个实例就不安全 +class Test{ + public synchronized void test() { + + } +} +等价于 +class Test{ + public void test() { + synchronized(this) { + + } + } +} +``` + +```java +// 锁住类对象,所有类的实例的方法都是安全的,类的所有实例都相当于同一把锁 +class Test{ + public synchronized static void test() { + + } +} +等价于 +class Test{ + public static void test() { + synchronized(Test.class) { + + } + } +} +``` + + + +### synchronized 原理 + +synchronized 的原理其实就是基于一个锁对象和锁对象相关联的一个 **monitor 对象**。 + +synchronized 同步语句块的情况: + +使用synchronized之后,会在编译之后在同步的代码块前后加上monitorenter和monitorexit字节码指令,执行monitorenter指令时会尝试获取对象锁,如果对象没有被锁定或者已经获得了锁,锁的计数器+1。执行monitorexit指令时则会把计数器-1,当计数器值为0时,则锁释放,处于等待队列中的线程再继续竞争锁。 + +synchronized 修饰方法的的情况: + +`synchronized` 修饰的方法并没有 `monitorenter` 指令和 `monitorexit` 指令,取得代之的确实是 `ACC_SYNCHRONIZED` 标识,该标识指明了该方法是一个同步方法。JVM 通过该 `ACC_SYNCHRONIZED` 访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。 + + + +![](JUC\synchronized.webp) + +1. 当多个线程进入同步代码块时,首先进入entryList +2. 有一个线程获取到monitor锁后,就赋值给当前线程,并且计数器+1 +3. 如果线程调用wait方法,将释放锁,当前线程置为null,计数器-1,同时进入waitSet等待被唤醒,调用notify或者notifyAll之后又会进入entryList竞争锁 +4. 如果线程执行完毕,同样释放锁,计数器-1,当前线程置为null + + + + + +#### JVM对Synchornized的优化 + +锁膨胀 + +锁消除 + +锁粗话 + +自适应自旋锁 + + + +#### 轻量级锁 + +1. 创建 锁记录(Lock Record)对象,每个线程的栈帧都会包含一个锁记录的结构,内部可以存储锁定对象的Mark Word + +2. 让锁记录中 Object reference 指向锁对象,并尝试用 cas 替换 Object 的 Mark Word,将 Mark Word 的值存入锁记录 + + 1. 如果 cas 替换成功,对象头中存储了 `锁记录地址和状态 00 `,表示由该线程给对象加锁,这时图示如下 + 2. 如果 cas 失败,有两种情况 + 1. 如果是其它线程已经持有了该 Object 的轻量级锁,这时表明有竞争,进入锁膨胀过程 + 2. 如果是自己执行了 synchronized 锁重入,那么再添加一条 Lock Record 作为重入的计数 + +3. 当退出 synchronized 代码块(解锁时)如果有取值为 null 的锁记录,表示有重入,这时重置锁记录,表示重入计数减一 + +4. 当退出 synchronized 代码块(解锁时)锁记录的值不为 null,这时使用 cas 将 Mark Word 的值恢复给对象头 + 1. 成功,则解锁成功 + + 2. 失败,说明轻量级锁进行了锁膨胀或已经升级为重量级锁,进入重量级锁解锁流程 + + +| | | +| ------------------------------------------------------------ | ------------------------------------------------------------ | +| | | + + + +#### 锁膨胀 + +如果在尝试加轻量级锁的过程中,CAS 操作无法成功,这时一种情况就是有其它线程为此对象加上了轻量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁。 + +1. 当 Thread-1 进行轻量级加锁时,Thread-0 已经对该对象加了轻量级锁 +2. Thread-1 加轻量级锁失败,进入锁膨胀流程:为 Object 对象申请 Monitor 锁,**通过 Object 对象头获取到持锁线程**,将 Monitor 的 Owner 置为 Thread-0,将 Object 的对象头指向重量级锁地址,然后自己进入 Monitor 的 EntryList BLOCKED +3. 当 Thread-0 退出同步块解锁时,使用 CAS 将 Mark Word 的值恢复给对象头失败,这时进入重量级解锁流程,即按照 Monitor 地址找到 Monitor 对象,设置 Owner 为 null,唤醒 EntryList 中 BLOCKED 线程 + +| ![](.\JUC\锁膨胀0.png) | ![](.\JUC\锁膨胀.png) | +| ---------------------- | --------------------- | + + + +#### 自旋优化 + +**重量级锁**竞争时,尝试获取锁的线程不会立即阻塞,可以使用**自旋**(默认 10 次)来进行优化,采用循环的方式去尝试获取锁 + +注意: + +- 自旋占用 CPU 时间,单核 CPU 自旋就是浪费时间,因为同一时刻只能运行一个线程,多核 CPU 自旋才能发挥优势 +- 自旋失败的线程会进入阻塞状态 + +优点:不会进入阻塞状态,**减少线程上下文切换的消耗** + +缺点:当自旋的线程越来越多时,会不断的消耗 CPU 资源 + +自旋锁说明: + +- 在 Java 6 之后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,比较智能 +- Java 7 之后不能控制是否开启自旋功能,由 JVM 控制 + + + +#### 偏向锁 + + + +**撤销偏向锁: ** + +**1. 调用对象 hashCode** + +调用了对象的 hashCode,但偏向锁的对象 MarkWord 中存储的是线程 id,如果调用 hashCode 无法存储hashCode,会导致偏向锁被撤销 + +- 轻量级锁会在锁记录中记录 hashCode +- 重量级锁会在 Monitor 中记录 hashCode + +**2. 没有锁竞争下,其它线程使用对象** + +当有其它线程使用偏向锁对象时,会将偏向锁升级为轻量级锁 + +```java + +private static void test2() throws InterruptedException { + + Dog d = new Dog(); + + Thread t1 = new Thread(() -> { + + log.debug(ClassLayout.parseInstance(d).toPrintableSimple(true)); + synchronized (d) { + log.debug(ClassLayout.parseInstance(d).toPrintableSimple(true)); + } + log.debug(ClassLayout.parseInstance(d).toPrintableSimple(true)); + + synchronized (TestBiased.class) { + TestBiased.class.notify(); + } + }, "t1"); + t1.start(); + + Thread t2 = new Thread(() -> { + synchronized (TestBiased.class) { + try { + TestBiased.class.wait(); + } catch (InterruptedException e) { + e.printStackTrace(); + } + } + + log.debug(ClassLayout.parseInstance(d).toPrintableSimple(true)); + synchronized (d) { + log.debug(ClassLayout.parseInstance(d).toPrintableSimple(true)); + } + log.debug(ClassLayout.parseInstance(d).toPrintableSimple(true)); + + }, "t2"); + t2.start(); +} +``` + +**3. 调用 wait/notify** + +重量级锁才支持 wait/notify + + + +**批量撤销**: + +如果对象被多个线程访问,但没有竞争,这时偏向了线程 T1 的对象仍有机会重新偏向 T2,重偏向会重置对象的 Thread ID + +- 批量重偏向:当撤销偏向锁阈值超过 20 次后,JVM 会觉得是不是偏向错了,于是在给这些对象加锁时重新偏向至加锁线程 +- 批量撤销:当撤销偏向锁阈值超过 40 次后,JVM 会觉得自己确实偏向错了,根本就不该偏向,于是整个类的所有对象都会变为不可偏向的,新建的对象也是不可偏向的 + +```java +private static void test3() throws InterruptedException { + + Vector list = new Vector<>(); + + Thread t1 = new Thread(() -> { + for (int i = 0; i < 30; i++) { + Dog d = new Dog(); + list.add(d); + synchronized (d) { + log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintableSimple(true)); + } + } + synchronized (list) { + list.notify(); + } + }, "t1"); + t1.start(); + + Thread t2 = new Thread(() -> { + synchronized (list) { + try { + list.wait(); + } catch (InterruptedException e) { + e.printStackTrace(); + } + } + log.debug("===============> "); + for (int i = 0; i < 30; i++) { + Dog d = list.get(i); + log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintableSimple(true)); + synchronized (d) { + log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintableSimple(true)); + } + log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintableSimple(true)); + } + }, "t2"); + t2.start(); +} + +``` + + + +#### 锁消除 + +锁消除是指对于被检测出不可能存在竞争的共享数据的锁进行消除,这是 JVM **即时编译器的优化** + +锁消除主要是通过**逃逸分析**来支持,如果堆上的共享数据不可能逃逸出去被其它线程访问到,那么就可以把它们当成私有数据对待,也就可以将它们的锁进行消除 + + + +#### 锁粗化 + +对相同对象多次加锁,导致线程发生多次重入,频繁的加锁操作就会导致性能损耗,可以使用锁粗化方式优化 + +如果虚拟机探测到一串的操作都对同一个对象加锁,将会把加锁的范围扩展(粗化)到整个操作序列的外部 + + + +### wait / notify + +![](.\JUC\wait-notify.png) + +- Owner 线程发现条件不满足,调用 wait 方法,即可进入 WaitSet 变为 WAITING 状态 +- BLOCKED 和 WAITING 的线程都处于阻塞状态,不占用 CPU 时间片 +- BLOCKED 线程会在 Owner 线程释放锁时唤醒 +- WAITING 线程会在 Owner 线程调用 notify 或 notifyAll 时唤醒,但唤醒后并不意味者立刻获得锁,仍需进入EntryList 重新竞争 + +```java +public final void notify():唤醒正在等待对象监视器的单个线程。 +public final void notifyAll():唤醒正在等待对象监视器的所有线程。 +public final void wait():导致当前线程等待,直到另一个线程调用该对象的 notify() 方法或 notifyAll()方法。 +public final native void wait(long timeout):有时限的等待, 到n毫秒后结束等待,或是被唤醒 +``` + +它们都是线程之间进行协作的手段,都属于 Object 对象的方法。**必须先获得此对象的锁,才能调用这几个方法** + + + +**`sleep(long n)` 和 `wait(long n)` 的区别**: + +1. sleep 是 Thread 方法,而 wait 是 Object 的方法 + +2. sleep 不需要强制和 synchronized 配合使用,但 wait 需要和 synchronized 一起用 + +3. **`sleep()` 方法没有释放锁,而 `wait()` 方法释放了锁** ,但都会释放CPU + +4. 它们状态 TIMED_WAITING + +5. `wait()` 通常被用于线程间交互/通信,`sleep()`通常被用于暂停执行。 + +6. `wait()` 方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的 `notify()`或者 `notifyAll()` 方法。`sleep()`方法执行完成后,线程会自动苏醒,或者也可以使用 `wait(long timeout)` 超时后线程会自动苏醒。 + + + +```java +synchronized(lock) { + while(条件不成立) { + lock.wait(); + } + // 干活 +} + +//另一个线程 +synchronized(lock) { + lock.notifyAll(); +} +``` + + + +### Park & Unpark + + LockSupport 类中的方法:每个线程都有自己的一个(C代码实现的) Parker 对象 + +```java +// 暂停当前线程 +LockSupport.park(); +// 恢复某个线程的运行 +LockSupport.unpark(暂停线程对象) +``` + + + +```java +public static void main(String[] args) { + Thread t1 = new Thread(() -> { + System.out.println("start..."); //1 + Thread.sleep(1000);// Thread.sleep(3000) + // 先 park 再 unpark 和先 unpark 再 park 效果一样,都会直接恢复线程的运行 + System.out.println("park..."); //2 + LockSupport.park(); + System.out.println("resume...");//4 + },"t1"); + t1.start(); + Thread.sleep(2000); + System.out.println("unpark..."); //3 + LockSupport.unpark(t1); +} +``` + +先调用park,后调用unpark: + +| ![](.\JUC\park.png) | ![](.\JUC\unpark.png) | +| ------------------------------------------------------------ | ------------------------------------------------------------ | +| 1. 当前线程调用 Unsafe.park() 方法
2. 检查 _counter ,本情况为 0,这时,获得 _mutex 互斥锁
3. 线程进入 _cond 条件变量阻塞
4. 设置 _counter = 0 | 1. 调用 Unsafe.unpark(Thread_0) 方法,设置 _counter 为 1
2. 唤醒 _cond 条件变量中的 Thread_0
3. Thread_0 恢复运行
4. 设置 _counter 为 0 | + +先调用unpark 再调用park: + +1. 调用 Unsafe.unpark(Thread_0) 方法,设置 _counter 为 1 + +2. 当前线程调用 Unsafe.park() 方法 + +3. 检查 _counter ,本情况为 1,这时线程无需阻塞,继续运行 + +4. 设置 _counter 为 0 + +![](.\JUC\unpark-park.png) + + + +与 Object 的 wait & notify 相比 : + +- wait,notify 和 notifyAll 必须配合 Object Monitor 一起使用,而 park,unpark 不必 +- park & unpark 是以线程为单位来【阻塞】和【唤醒(指定)】线程,而 notify 只能随机唤醒一个等待线程,notifyAll是唤醒所有等待线程 +- park & unpark 可以先 unpark,而 wait & notify 不能先 notify +- wait 会释放锁资源进入WAITING队列,**park 不会释放锁资源**,只负责阻塞当前线程,会释放 CPU + + + +### ReentrantLock + +`ReentrantLock` 实现了 `Lock` 接口,是一个可重入且独占式的锁,和 `synchronized` 关键字类似。不过,`ReentrantLock` 更灵活、更强大,增加了轮询、超时、中断、公平锁和非公平锁等高级功能。 + + + +ReentrantLock 相对于 synchronized 具备如下特点: + +1. 锁的实现:synchronized 是 JVM 实现的,而 ReentrantLock 是 JDK 实现的 +2. 性能:新版本 Java 对 synchronized 进行了很多优化,synchronized 与 ReentrantLock 大致相同 +3. 使用:ReentrantLock 需要手动解锁,synchronized 执行完代码块自动解锁 +4. **可中断**:ReentrantLock 可中断,而 synchronized 不行。这里的可中断是指在等待锁的过程中,可以被中断(即取消获取锁的请求) +5. **公平锁**:公平锁是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁 + - ReentrantLock 可以设置公平锁,synchronized 中的锁是非公平的 + - 不公平锁的含义是阻塞队列内公平,队列外非公平 +6. **锁超时**:尝试获取锁,超时获取不到直接放弃,不进入阻塞队列 + - ReentrantLock 可以设置超时时间,synchronized 会一直等待 +7. 锁绑定多个条件:一个 ReentrantLock 可以同时绑定多个 Condition 对象,更细粒度的唤醒线程 +8. 两者都是可重入锁 + +```java +// 获取锁 +reentrantLock.lock(); +try { + // 临界区 +} finally { + // 释放锁 + reentrantLock.unlock(); +} +``` + + + +#### 可重入 + +可重入是指同一个线程如果首次获得了这把锁,那么它是这把锁的拥有者,因此有权利再次获取这把锁,如果不可重入锁,那么第二次获得锁时,自己也会被锁挡住,直接造成死锁。 + +原理:通过state计数的增加减少来实现可重入 + +```java +static final class NonfairSync extends Sync { + // ... + + // Sync 继承过来的方法, 方便阅读, 放在此处 + final boolean nonfairTryAcquire(int acquires) { + final Thread current = Thread.currentThread(); + int c = getState(); + if (c == 0) { + if (compareAndSetState(0, acquires)) { + setExclusiveOwnerThread(current); + return true; + } + } + // 如果已经获得了锁, 线程还是当前线程, 表示发生了锁重入 + else if (current == getExclusiveOwnerThread()) { + // state++ + int nextc = c + acquires; + if (nextc < 0) // overflow + throw new Error("Maximum lock count exceeded"); + setState(nextc); + return true; + } + return false; + } + + // Sync 继承过来的方法, 方便阅读, 放在此处 + protected final boolean tryRelease(int releases) { + // state-- + int c = getState() - releases; + if (Thread.currentThread() != getExclusiveOwnerThread()) + throw new IllegalMonitorStateException(); + boolean free = false; + // 支持锁重入, 只有 state 减为 0, 才释放成功 + if (c == 0) { + free = true; + setExclusiveOwnerThread(null); + } + setState(c); + return free; + } +} +``` + + + +#### 可打断 + +打断即取消获取锁的请求 + +`public void lockInterruptibly()`:获得可打断的锁 + +- 如果没有竞争此方法就会获取 lock 对象锁 +- 如果有竞争就进入阻塞队列,可以被其他线程用 interrupt 打断 +- 如果是不可中断模式,那么即使使用了 interrupt 也不会让等待中断 + +```java +ReentrantLock lock = new ReentrantLock(); + +Thread t1 = new Thread(() -> { + log.debug("启动..."); + + try { + //没有竞争就会获取锁 + //有竞争就进入阻塞队列等待,但可以被打断 + lock.lockInterruptibly(); + //lock.lock(); //不可打断 + } catch (InterruptedException e) { + e.printStackTrace(); + log.debug("等锁的过程中被打断"); + return; + } + + try { + log.debug("获得了锁"); + } finally { + lock.unlock(); + } +}, "t1"); + +lock.lock(); +log.debug("获得了锁"); +t1.start(); + +try { + sleep(1); + log.debug("执行打断"); + t1.interrupt(); +} finally { + lock.unlock(); +} + +/* +18:02:40.520 [main] c.TestInterrupt - 获得了锁 +18:02:40.524 [t1] c.TestInterrupt - 启动... +18:02:41.530 [main] c.TestInterrupt - 执行打断 +java.lang.InterruptedException +at +java.util.concurrent.locks.AbstractQueuedSynchronizer.doAcquireInterruptibly(AbstractQueuedSynchr +onizer.java:898) + at + java.util.concurrent.locks.AbstractQueuedSynchronizer.acquireInterruptibly(AbstractQueuedSynchron + izer.java:1222) + at java.util.concurrent.locks.ReentrantLock.lockInterruptibly(ReentrantLock.java:335) + at cn.itcast.n4.reentrant.TestInterrupt.lambda$main$0(TestInterrupt.java:17) + at java.lang.Thread.run(Thread.java:748) +18:02:41.532 [t1] c.TestInterrupt - 等锁的过程中被打断 +/* +``` + +#### 锁超时 + +`public boolean tryLock()`:尝试获取锁,获取到返回 true,获取不到直接放弃,不进入阻塞队列 + +`public boolean tryLock(long timeout, TimeUnit unit)`:在给定时间内获取锁,获取不到就退出 + +注意:tryLock 期间也可以被打断 + + + +#### 公平锁 + +ReentrantLock 默认是不公平的 + +公平锁一般没有必要,会降低并发度 + +如果是公平锁,唤醒的策略就是谁等待的时间长,就唤醒谁,很公平;如果是非公平锁,则不提供这个公平保证,有可能等待时间短的线程反而先被唤醒。 + + + +#### 条件变量 + +synchronized 中也有条件变量,就是我们讲原理时那个 waitSet 休息室,当条件不满足时进入 waitSet 等待 + +ReentrantLock 的条件变量比 synchronized 强大之处在于,它是支持多个条件变量的,这就好比 + +- synchronized 是那些不满足条件的线程都在一间休息室等消息 +- 而 ReentrantLock 支持多间休息室,有专门等烟的休息室、专门等早餐的休息室、唤醒时也是按休息室来唤醒 + + + +ReentrantLock 类获取 Condition 对象:`public Condition newCondition()` + +Condition 类 API: + +- `void await()`:当前线程从运行状态进入等待状态,释放锁 +- `void signal()`:唤醒一个等待在 Condition 上的线程,但是必须获得与该 Condition 相关的锁 + + + +使用要点: + +- **await / signal 前需要获得锁** +- await 执行后,会释放锁,进入 conditionObject 等待 +- await 的线程被唤醒(或打断、或超时)去重新竞争 lock 锁 +- 竞争 lock 锁成功后,从 await 后继续执行 + +```java +static ReentrantLock lock = new ReentrantLock(); + +static Condition waitCigaretteQueue = lock.newCondition(); +static Condition waitbreakfastQueue = lock.newCondition(); + +static volatile boolean hasCigrette = false; +static volatile boolean hasBreakfast = false; + +public static void main(String[] args) { + + new Thread(() -> { + try { + lock.lock(); + while (!hasCigrette) { + try { + waitCigaretteQueue.await(); + } catch (InterruptedException e) { + e.printStackTrace(); + } + } + log.debug("等到了它的烟"); + } finally { + lock.unlock(); + } + }).start(); + + new Thread(() -> { + try { + lock.lock(); + while (!hasBreakfast) { + try { + waitbreakfastQueue.await(); + } catch (InterruptedException e) { + e.printStackTrace(); + } + } + log.debug("等到了它的早餐"); + } finally { + lock.unlock(); + } + }).start(); + + sleep(1); + sendBreakfast(); + sleep(1); + sendCigarette(); +} + +private static void sendCigarette() { + lock.lock(); + try { + log.debug("送烟来了"); + hasCigrette = true; + waitCigaretteQueue.signal(); + } finally { + lock.unlock(); + } +} + +private static void sendBreakfast() { + lock.lock(); + try { + log.debug("送早餐来了"); + hasBreakfast = true; + waitbreakfastQueue.signal(); + } finally { + lock.unlock(); + } +} +``` + + + + + +## 无锁 + +### CAS + +CAS 的全称是 Compare-And-Swap,是 CPU 并发原语,作为一条 CPU 指令,CAS 指令本身是能够保证原子性的,线程安全。 + +作用:比较当前工作内存中的值和主物理内存中的值,如果相同则执行指定操作,否则继续比较直到主内存和工作内存的值一致为止 + +CAS 特点: + +- CAS 体现的是**无锁并发、无阻塞并发**,线程不会陷入阻塞,线程不需要频繁切换状态(上下文切换,系统调用) +- CAS 是基于乐观锁的思想 + +CAS 缺点: + +- 执行的是循环操作,如果比较不成功一直在循环,最差的情况某个线程一直取到的值和预期值都不一样,就会无限循环导致饥饿,**使用 CAS 线程数不要超过 CPU 的核心数**,采用分段 CAS 和自动迁移机制 +- 只能保证一个共享变量的原子操作 + - 对于一个共享变量执行操作时,可以通过循环 CAS 的方式来保证原子操作 + - 对于多个共享变量操作时,循环 CAS 就无法保证操作的原子性,这个时候**只能用锁来保证原子性** +- ABA 问题:主线程仅能判断出共享变量的值与最初值 A 是否相同,不能感知到这种从 A 改为 B 又 改回 A 的情况,使用AtomicStampedReference解决 + +CAS 与 synchronized 总结: + +- synchronized 是从悲观的角度出发:总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞(共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程),因此 synchronized 也称之为悲观锁,ReentrantLock 也是一种悲观锁,性能较差 +- CAS 是从乐观的角度出发:总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据。**如果别人修改过,则获取现在最新的值,如果别人没修改过,直接修改共享数据的值**,CAS 这种机制也称之为乐观锁,综合性能较好 + +注意:**CAS 必须借助 volatile 才能读取到共享变量的最新值来实现【比较并交换】的效果** + +CAS 底层实现是在一个循环中不断地尝试修改目标值,直到修改成功。如果竞争不激烈修改成功率很高,否则失败率很高,失败后这些重复的原子性操作会耗费性能(导致大量线程**空循环,自旋转**) + + + +### Atomic 原子类 + +```java +// setup to use Unsafe.compareAndSwapInt for updates(更新操作时提供“比较并替换”的作用) +private static final Unsafe unsafe = Unsafe.getUnsafe(); +private static final long valueOffset; + +static { + try { + valueOffset = unsafe.objectFieldOffset + (AtomicInteger.class.getDeclaredField("value")); + } catch (Exception ex) { throw new Error(ex); } +} + +private volatile int value; +``` + +`AtomicInteger` 类主要利用 CAS (compare and swap) + volatile 和 native 方法来保证原子操作,从而避免 synchronized 的高开销,执行效率大为提升。 + +CAS 的原理是拿期望的值和原本的一个值作比较,如果相同则更新成新的值。UnSafe 类的 `objectFieldOffset()` 方法是一个本地方法,这个方法是用来拿到“原来的值”的内存地址。另外 value 是一个 volatile 变量,在内存中可见,因此 JVM 可以保证任何时刻任何线程总能拿到该变量的最新值 + + + +**基本类型** + +常见原子类:AtomicInteger、AtomicBoolean、AtomicLong + +常用API: + +| 方法 | 作用 | +| ------------------------------------- | ------------------------------------------------------------ | +| public final int get() | 获取 AtomicInteger 的值 | +| public final int getAndIncrement() | 以原子方式将当前值加 1,返回的是自增前的值 | +| public final int incrementAndGet() | 以原子方式将当前值加 1,返回的是自增后的值 | +| public final int getAndSet(int value) | 以原子方式设置为 newValue 的值,返回旧值 | +| public final int addAndGet(int data) | 以原子方式将输入的数值与实例中的值相加并返回 实例:AtomicInteger 里的 value | + + + +**原子引用** + +原子引用:对 Object 进行原子操作,提供一种读和写都是原子性的对象引用变量 + +原子引用类:AtomicReference(存在ABA问题)、AtomicStampedReference(维护版本号解决ABA问题)、AtomicMarkableReference(维护是否修改过标记解决ABA问题) + +AtomicReference 类: + +- 构造方法:`AtomicReference atomicReference = new AtomicReference()` +- 常用 API: + - `public final boolean compareAndSet(V expectedValue, V newValue)`:CAS 操作 + - `public final void set(V newValue)`:将值设置为 newValue + - `public final V get()`:返回当前值 + + + +**原子数组** + +原子数组类:AtomicIntegerArray、AtomicLongArray、AtomicReferenceArray + +```java +/** + 参数1,提供数组、可以是线程不安全数组或线程安全数组 + 参数2,获取数组长度的方法 + 参数3,自增方法,回传 array, index + 参数4,打印数组的方法 +*/ +// supplier 提供者 无中生有 ()->结果 +// function 函数 一个参数一个结果 (参数)->结果 , BiFunction (参数1,参数2)->结果 +// consumer 消费者 一个参数没结果 (参数)->void +private static void demo( + Supplier arraySupplier, + Function lengthFun, + BiConsumer putConsumer, + Consumer printConsumer ) { + + List ts = new ArrayList<>(); + T array = arraySupplier.get(); + int length = lengthFun.apply(array); + for (int i = 0; i < length; i++) { + // 每个线程对数组作 10000 次操作 + ts.add(new Thread(() -> { + for (int j = 0; j < 10000; j++) { + putConsumer.accept(array, j%length); + } + })); + } + ts.forEach(t -> t.start()); // 启动所有线程 + + ts.forEach(t -> { + try { + t.join(); + } catch (InterruptedException e) { + e.printStackTrace(); + } + }); // 等所有线程结束 + printConsumer.accept(array); +} + +// 使用 +demo( + ()-> new AtomicIntegerArray(10), + (array) -> array.length(), + (array, index) -> array.getAndIncrement(index), + array -> System.out.println(array) +); +``` + + + +**原子更新器** + +原子更新器类:AtomicReferenceFieldUpdater、AtomicIntegerFieldUpdater、AtomicLongFieldUpdater + +利用字段更新器,可以针对对象的某个域(Field)进行原子操作,只能配合 volatile 修饰的字段使用,否则会出现异常 `IllegalArgumentException: Must be volatile type` + +常用 API: + +- `static AtomicIntegerFieldUpdater newUpdater(Class c, String fieldName)`:构造方法 +- `abstract boolean compareAndSet(T obj, int expect, int update)`:CAS + + + +**原子累加器** + +原子累加器类:LongAdder、DoubleAdder、LongAccumulator、DoubleAccumulator + +原子累加器在有竞争时,**设置多个累加单元**,Therad-0 累加 Cell[0],而 Thread-1 累加Cell[1]... 最后将结果汇总。这样它们在累加时操作的不同的 Cell 变量,因此**减少了 CAS 重试失败,从而提高性能**。 + + + +### LongAdder + +LongAdder 类有几个关键域: + +```java +// 累加单元数组, 懒惰初始化 +transient volatile Cell[] cells; + +// 基础值, 如果没有竞争, 则用 cas 累加这个域 +transient volatile long base; + +// 在 cells 创建或扩容时, 置为 1, 表示加锁 +transient volatile int cellsBusy; + +``` + +Cell需要防止缓存行伪共享问题:一个缓存行容纳多个cell数据就叫做伪共享 + +```java +// 防止缓存行伪共享 +@sun.misc.Contended +static final class Cell { + + volatile long value; + + Cell(long x) { + value = x; + } + + // 最重要的方法, 用来 cas 方式进行累加, prev 表示旧值, next 表示新值 + final boolean cas(long prev, long next) { + return UNSAFE.compareAndSwapLong(this, valueOffset, prev, next); + } +// 省略不重要代码 +} +``` + +> Cell 是数组形式,**在内存中是连续存储的**,64 位系统中,一个 Cell 为 24 字节(16 字节的对象头和 8 字节的 value),每一个 cache line 为 64 字节,因此缓存行可以存下 2 个的 Cell 对象,当 Core-0 要修改 Cell[0]、Core-1 要修改 Cell[1],无论谁修改成功都会导致当前缓存行失效,从而导致对方的数据失效,需要重新去主存获取,影响效率 +> +> @sun.misc.Contended:防止缓存行伪共享,在使用此注解的对象或字段的前后各增加 128 字节大小的 padding,使用 2 倍于大多数硬件缓存行让 CPU 将对象预读至缓存时**占用不同的缓存行**,这样就不会造成对方缓存行的失效 + + + +LongAdder 是 Java8 提供的类,跟 AtomicLong 有相同的效果,但对 CAS 机制进行了优化,尝试使用分段 CAS 以及自动分段迁移的方式来大幅度提升多线程高并发执行 CAS 操作的性能 + +优化核心思想:数据分离,将 AtomicLong 的**单点的更新压力分担到各个节点,空间换时间**,在低并发的时候直接更新,可以保障和 AtomicLong 的性能基本一致,而在高并发的时候通过分散减少竞争,提高了性能 + + + +### Unsafe + +Unsafe 是 CAS 的核心类,Unsafe 对象提供了非常底层的,操作内存、线程的方法,Unsafe 对象不能直接调用,只能通过反射获得 + +Unsafe 类存在 sun.misc 包,其中所有方法都是 native 修饰的,都是直接调用**操作系统底层资源**执行相应的任务,基于该类可以直接操作特定的内存数据,其内部方法操作类似 C 的指针. + +```java +import lombok.Data; +import sun.misc.Unsafe; + +import java.lang.reflect.Field; + +public class TestUnsafeCAS { + + public static void main(String[] args) throws NoSuchFieldException, IllegalAccessException { + +// Field theUnsafe = Unsafe.class.getDeclaredField("theUnsafe"); +// theUnsafe.setAccessible(true); +// Unsafe unsafe = (Unsafe) theUnsafe.get(null); + Unsafe unsafe = UnsafeAccessor.getUnsafe(); + System.out.println(unsafe); + + // 1. 获取域的偏移地址 + long idOffset = unsafe.objectFieldOffset(Teacher.class.getDeclaredField("id")); + long nameOffset = unsafe.objectFieldOffset(Teacher.class.getDeclaredField("name")); + + Teacher t = new Teacher(); + System.out.println(t); + + // 2. 执行 cas 操作 + unsafe.compareAndSwapInt(t, idOffset, 0, 1); + unsafe.compareAndSwapObject(t, nameOffset, null, "张三"); + + // 3. 验证 + System.out.println(t); + } +} + +@Data +class Teacher { + volatile int id; + volatile String name; +} +``` + + + +模拟实现原子整数: + +```java +public static void main(String[] args) { + MyAtomicInteger atomicInteger = new MyAtomicInteger(10); + if (atomicInteger.compareAndSwap(20)) { + System.out.println(atomicInteger.getValue()); + } +} + +class MyAtomicInteger { + private static final Unsafe UNSAFE; + private static final long VALUE_OFFSET; + private volatile int value; + + static { + try { + //Unsafe unsafe = Unsafe.getUnsafe()这样会报错,需要反射获取 + Field theUnsafe = Unsafe.class.getDeclaredField("theUnsafe"); + theUnsafe.setAccessible(true); + UNSAFE = (Unsafe) theUnsafe.get(null); + // 获取 value 属性的内存地址,value 属性指向该地址,直接设置该地址的值可以修改 value 的值 + VALUE_OFFSET = UNSAFE.objectFieldOffset( + MyAtomicInteger.class.getDeclaredField("value")); + } catch (NoSuchFieldException | IllegalAccessException e) { + e.printStackTrace(); + throw new RuntimeException(); + } + } + + public MyAtomicInteger(int value) { + this.value = value; + } + public int getValue() { + return value; + } + + public boolean compareAndSwap(int update) { + while (true) { + int prev = this.value; + int next = update; + // 当前对象 内存偏移量 期望值 更新值 + if (UNSAFE.compareAndSwapInt(this, VALUE_OFFSET, prev, update)) { + System.out.println("CAS成功"); + return true; + } + } + } +} +``` + + + +## 不可变 + +### 不可变设计 + +**将一个类所有的属性都设置成 final 的,并且只允许存在只读方法,那么这个类基本上就具备不可变性了**。更严格的做法是**这个类本身也是 final 的**,也就是不允许继承。因为子类可以覆盖父类的方法,有可能改变不可变性. + +Java SDK 里很多类都具备不可变性,例如经常用到的 String 和 Long、Integer、Double 等基础类型的包装类都具备不可变性,这些对象的线程安全性都是靠不可变性来保证的. + +```java +public final class String implements java.io.Serializable, Comparable, CharSequence { + /** The value is used for character storage. */ + private final char value[]; + /** Cache the hash code for the string */ + private int hash; // Default to 0 + + // ... + +} +``` + +如果具备不可变性的类,需要提供类似修改的功能,具体该怎么操作呢? + +**保护性拷贝 (defensive copy)** + +通过创建副本对象来避免共享的手段称之为【保护性拷贝(defensive copy)】 + +```java +public String substring(int beginIndex) { + if (beginIndex < 0) { + throw new StringIndexOutOfBoundsException(beginIndex); + } + int subLen = value.length - beginIndex; + if (subLen < 0) { + throw new StringIndexOutOfBoundsException(subLen); + } + return (beginIndex == 0) ? this : new String(value, beginIndex, subLen); +} + +public String(char value[], int offset, int count) { + if (offset < 0) { + throw new StringIndexOutOfBoundsException(offset); + } + if (count <= 0) { + if (count < 0) { + throw new StringIndexOutOfBoundsException(count); + } + if (offset <= value.length) { + this.value = "".value; + return; + } + } + if (offset > value.length - count) { + throw new StringIndexOutOfBoundsException(offset + count); + } + this.value = Arrays.copyOfRange(value, offset, offset+count); +} +``` + + + +### 享元模式 + +享元模式(Flyweight pattern): 用于减少创建对象的数量,通过重用数量有限的同一类对象,以减少内存占用和提高性能,这种类型的设计模式属于结构型模式,它提供了减少对象数量从而改善应用所需的对象结构的方式。体现: + +**1. 包装类** + +在JDK中 Boolean,Byte,Short,Integer,Long,Character 等包装类提供了 valueOf 方法,例如 Long 的valueOf 会缓存 -128~127 之间的 Long 对象,在这个范围之间会重用对象,大于这个范围,才会新建 Long 对象: + +```java +public static Long valueOf(long l) { + final int offset = 128; + if (l >= -128 && l <= 127) { // will cache + return LongCache.cache[(int)l + offset]; + } + return new Long(l); +} +``` + +> **注意:** +> +> Byte, Short, Long 缓存的范围都是 -128~127 +> +> Character 缓存的范围是 0~127 +> +> Integer的默认范围是 -128~127 +> +> - 最小值不能变 +> +> - 但最大值可以通过调整虚拟机参数 `-Djava.lang.Integer.IntegerCache.high` 来改变 +> +> Boolean 缓存了 TRUE 和 FALSE + +**2. String 串池** + +**3. BigDecimal BigInteger** + + + +### final + +```java +public class TestFinal { + final int a = 20; +} +``` + +字节码: + +```java +0: aload_0 +1: invokespecial #1 // Method java/lang/Object."":()V +4: aload_0 +5: bipush 20 // 将值直接放入栈中 +7: putfield #2 // Field a:I +<-- 写屏障 +10: return +``` + + + +final 变量的赋值通过 putfield 指令来完成,在这条指令之后也会加入写屏障,保证在其它线程读到它的值时不会出现为 0 的情况 + +其他线程访问 final 修饰的变量: + +- **复制一份放入栈中**直接访问,效率高 +- 大于 short 最大值会将其复制到类的常量池,访问时从常量池获取 + + + +### 无状态 + +无状态:成员变量保存的数据也可以称为状态信息,无状态就是没有成员变量 + +Servlet 为了保证其线程安全,一般不为 Servlet 设置成员变量,这种没有任何成员变量的类是线程安全的 + + + +## 线程池 + +线程池:一个容纳多个线程的容器,容器中的线程可以重复使用,省去了频繁创建和销毁线程对象的操作 + +线程池作用: + +1. 降低资源消耗,减少了创建和销毁线程的次数,每个工作线程都可以被重复利用,可执行多个任务 +2. 提高响应速度,当任务到达时,如果有线程可以直接用,不会出现系统僵死 +3. 提高线程的可管理性,如果无限制的创建线程,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控 + +线程池的核心思想:**线程复用**,同一个线程可以被重复使用,来处理多个任务 + +池化技术 (Pool) :一种编程技巧,核心思想是资源复用,在请求量大时能优化应用性能,降低系统频繁建连的资源开销 + + + +### 自定义线程池 + +![](.\JUC\自定义线程池.png) + +```java +/** + * 任务拒绝策略 + * + * @param + */ +@FunctionalInterface +interface MyRejectPolicy { + void reject(MyBlockingQueue queue, T task); +} + +``` + +```java +import lombok.extern.slf4j.Slf4j; + +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.Condition; +import java.util.concurrent.locks.ReentrantLock; + +/** + * 任务队列 + * + * @param + */ +@Slf4j(topic = "c.MyBlockingQueue") +class MyBlockingQueue { + + // 1. 任务队列 + private Deque queue = new ArrayDeque<>(); + + // 2. 锁 + private ReentrantLock lock = new ReentrantLock(); + + // 3. 生产者条件变量 + private Condition fullWaitSet = lock.newCondition(); + + // 4. 消费者条件变量 + private Condition emptyWaitSet = lock.newCondition(); + + // 5. 容量 + private int capcity; + + /** + * 构造方法 + * @param capcity 线程池容量 + */ + public MyBlockingQueue(int capcity) { + log.info("构造BlockingQueue"); + this.capcity = capcity; + } + + + /** + * 获取队列头部一个元素, 阻塞至 获取到元素 或 超时时长 + * + * @param timeout + * @param unit + * @return + */ + public T poll(long timeout, TimeUnit unit) { + lock.lock(); + try { + // 将 timeout 统一转换为 纳秒 + long nanos = unit.toNanos(timeout); + + //如果队列为空,当前线程在 emptyWaitSet 上等待 + while (queue.isEmpty()) { + try { + // 返回值是剩余时间 + if (nanos <= 0) { + //等待超时后队列仍为空,返回null + return null; + } + nanos = emptyWaitSet.awaitNanos(nanos); + } catch (InterruptedException e) { + e.printStackTrace(); + } + } + + //获取队列头部元素并将其从队列中移除 + T t = queue.removeFirst(); + //唤醒 等待在fullWaitSet上的任 意一个线程 + fullWaitSet.signal(); + + return t; + } finally { + lock.unlock(); + } + } + + + /** + * 获取队列头部一个元素, 阻塞至获取到任务为止 + * @return + */ + public T take() { + lock.lock(); + try { + + while (queue.isEmpty()) { + try { + emptyWaitSet.await(); + } catch (InterruptedException e) { + e.printStackTrace(); + } + } + + T t = queue.removeFirst(); + fullWaitSet.signal(); + return t; + } finally { + lock.unlock(); + } + } + + /** + * 向队列尾部添加一个元素, 阻塞至 加入到队列 或 超时时长 + * @param task + * @param timeout + * @param timeUnit + * @return + */ + public boolean offer(T task, long timeout, TimeUnit timeUnit) { + lock.lock(); + try { + long nanos = timeUnit.toNanos(timeout); + while (queue.size() == capcity) { + try { + if(nanos <= 0) { + return false; + } + log.debug("等待加入任务队列 {} ...", task); + nanos = fullWaitSet.awaitNanos(nanos); + } catch (InterruptedException e) { + e.printStackTrace(); + } + } + + log.debug("加入任务队列 {}", task); + queue.addLast(task); + //添加任务成功后,唤起 等待在emptyWaitSet上的 一个线程 + emptyWaitSet.signal(); + + return true; + } finally { + lock.unlock(); + } + } + + + /** + * 向队列尾部添加一个元素, 阻塞至 加入到队列为止 + * @param task + */ + public void put(T task) { + lock.lock(); + try { + while (queue.size() == capcity) { + try { + log.debug("等待加入任务队列 {} ...", task); + fullWaitSet.await(); + } catch (InterruptedException e) { + e.printStackTrace(); + } + } + + log.debug("加入任务队列 {}", task); + queue.addLast(task); + emptyWaitSet.signal(); + } finally { + lock.unlock(); + } + } + + + /** + * 返回队列当前size + * @return 返回队列当前size + */ + public int size() { + lock.lock(); + try { + return queue.size(); + } finally { + lock.unlock(); + } + } + + /** + * 尝试将任务task放入队列, 如果队列已满,则执行拒绝策略rejectPolicy + * + * @param rejectPolicy + * @param task + */ + public void tryPut(MyRejectPolicy rejectPolicy, T task) { + lock.lock(); + try { + // 判断队列是否满 + if(queue.size() == capcity) { + log.info("队列已满,按照拒绝策略处理任务 {}",task); + rejectPolicy.reject(this, task); + } else { // 有空闲 + log.debug("队列未满,任务 {} 加入到队列中 ", task); + queue.addLast(task); + emptyWaitSet.signal(); + } + } finally { + lock.unlock(); + } + } + +} +``` + +```java +import lombok.extern.slf4j.Slf4j; + +import java.util.HashSet; +import java.util.concurrent.TimeUnit; + +@Slf4j(topic = "c.MyThreadPool") +class MyThreadPool { + + // 任务队列 + private MyBlockingQueue taskQueue; + + //队列已满时的拒绝策略 + private MyRejectPolicy rejectPolicy; + + // 线程集合 + private HashSet workers = new HashSet<>(); + + // 核心线程数 + private int coreSize; + + // 获取任务时的超时时间 + private long timeout; + private TimeUnit timeUnit; + + // 执行任务 + public void execute(Runnable task) { + log.info("接收到任务需要执行: "+task); + + // 当任务数没有超过 coreSize 时,直接交给 worker 对象执行 + // 如果任务数超过 coreSize 时,加入任务队列暂存 + synchronized (workers) { + if(workers.size() < coreSize) { + Worker worker = new Worker(task,"worker--"+workers.size()); + workers.add(worker); + log.info("coreSize未满,新增 worker {} 来执行任务 {}", worker, task); + worker.start(); + + } else { + log.info("coreSize已经满了!!!!!,需要先尝试将任务{} 放到等待队列中 ",task); + taskQueue.tryPut(rejectPolicy, task); + + } + } + } + + /** + * 构造函数 + * + * @param coreSize 线程池最大核心线程数 + * @param timeout 和timeUnit一起指定超时时长 + * @param timeUnit 和timeout一起指定超时时长 + * @param queueCapcity 任务队列容量 + * @param rejectPolicy 任务队列满时针对添加操作的拒绝策略 + */ + public MyThreadPool(int coreSize, long timeout, TimeUnit timeUnit, int queueCapcity, MyRejectPolicy rejectPolicy) { + log.info("构造ThreadPool"); + this.coreSize = coreSize; + this.timeout = timeout; + this.timeUnit = timeUnit; + this.taskQueue = new MyBlockingQueue<>(queueCapcity); + this.rejectPolicy = rejectPolicy; + } + + /** + * 线程池中的工作线程 + */ + class Worker extends Thread{ + /** + * 执行任务主体 + */ + private Runnable task; + + public Worker(Runnable task,String workerName) { + this.task = task; + this.setName(workerName); + } + + /** + * 执行已有任务或从队列中获取一个任务执行. + * 如果都执行完了,就结束线程 + */ + @Override + public void run() { + log.info("worker跑run了,让我看看有没有task来做"); + + // 执行任务 + // 1) 当 task 不为空,执行任务 + // 2) 当 task 执行完毕,再接着从任务队列获取任务并执行 +// while(task != null || (task = taskQueue.take()) != null) { + while(task != null || (task = taskQueue.poll(timeout, timeUnit)) != null) { + try { + log.debug("获取到任务了,正在执行...{}", task); + task.run(); + } catch (Exception e) { + e.printStackTrace(); + } finally { + log.info("搞定一个任务 {},尝试获取新任务执行",task); + task = null; + } + } + + synchronized (workers) { + log.debug("当前worker {} 因长时间没有获取到可执行任务 将被释放", this); + workers.remove(this); + } + + } + + } + +} +``` + +```java +import lombok.extern.slf4j.Slf4j; +import java.util.concurrent.TimeUnit; + +@Slf4j(topic = "c.TestCustomThreadPool") +public class TestMyThreadPool { + + public static void main(String[] args) { + + MyThreadPool threadPool = new MyThreadPool(1, + 3000, + TimeUnit.MILLISECONDS, + 1, + (queue, task)->{ + // 1. 死等 +// queue.put(task); + // 2) 带超时等待 + queue.offer(task, 1500, TimeUnit.MILLISECONDS); + // 3) 让调用者放弃任务执行 +// log.debug("放弃{}", task); + // 4) 让调用者抛出异常 +// throw new RuntimeException("任务执行失败 " + task); + // 5) 让调用者自己执行任务 +// log.info("当前拒绝策略: 让调用线程池的调用者自己执行任务,没有开新线程,直接调用的run()"); +// task.run(); + }); + + int total =4; + for (int i = 1; i <= total; i++) { + int j = i; + threadPool.execute(() -> { + try { + log.debug("开始执行第 {}/{} 个任务 ", j,total); + Thread.sleep(1000L); + } catch (InterruptedException e) { + e.printStackTrace(); + } + log.debug("第 {}/{} 个任务 执行结束", j,total); + }); + } + } + +} +``` + + + +### Executor框架 + +`Executor` 框架结构主要由三大部分组成: + +**1、任务(`Runnable` /`Callable`)** + +执行任务需要实现的 **`Runnable` 接口** 或 **`Callable`接口**。**`Runnable` 接口**或 **`Callable` 接口** 实现类都可以被 **`ThreadPoolExecutor`** 或 **`ScheduledThreadPoolExecutor`** 执行。 + +**`Runnable` 接口**不会返回结果或抛出检查异常,但是 **`Callable` 接口**可以 + +**2、任务的执行(`Executor`)** + +如下图所示,包括任务执行机制的核心接口 **`Executor`** ,以及继承自 `Executor` 接口的 **`ExecutorService` 接口。`ThreadPoolExecutor`** 和 **`ScheduledThreadPoolExecutor`** 这两个关键类实现了 **`ExecutorService`** 接口。 + +![](.\JUC\executor-class-diagram.png) + +**3、异步计算的结果(`Future`)** + +**`Future`** 接口以及 `Future` 接口的实现类 **`FutureTask`** 类都可以代表异步计算的结果。 + +当我们把 **`Runnable`接口** 或 **`Callable` 接口** 的实现类提交给 **`ThreadPoolExecutor`** 或 **`ScheduledThreadPoolExecutor`** 执行。(调用 `submit()` 方法时会返回一个 **`FutureTask`** 对象) + +**`Executor` 框架的使用示意图** + +![](.\JUC\Executor框架的使用示意图-8GKgMC9g.png) + +1. 主线程首先要创建实现 `Runnable` 或者 `Callable` 接口的任务对象。 + +2. 把创建完成的实现 `Runnable`/`Callable`接口的 对象直接交给 `ExecutorService` 执行: `ExecutorService.execute(Runnable command)`)或者也可以把 `Runnable` 对象或`Callable` 对象提交给 `ExecutorService` 执行(`ExecutorService.submit(Runnable task)`或 `ExecutorService.submit(Callable task)`)。 + +3. 如果执行 `ExecutorService.submit(…)`,`ExecutorService` 将返回一个实现`Future`接口的对象(我们刚刚也提到过了执行 `execute()`方法和 `submit()`方法的区别,`submit()`会返回一个 `FutureTask` 对象)。由于 `FutureTask` 实现了 `Runnable`,我们也可以创建 `FutureTask`,然后直接交给 `ExecutorService` 执行。 + +4. 最后,主线程可以执行 `FutureTask.get()`方法来等待任务执行完成。主线程也可以执行 `FutureTask.cancel(boolean mayInterruptIfRunning)`来取消此任务的执行 + + + +### ThreadPoolExecutor + +线程池实现类 `ThreadPoolExecutor` 是 `Executor` 框架最核心的类 + +#### 线程池状态 + +ThreadPoolExecutor 使用 **int 的高 3 位来表示线程池状态,低 29 位表示线程数量** + +| 状态 | 高3位 | 接收新任务 | 处理阻塞任务队列 | 说明 | +| ---------- | ----- | ---------- | ---------------- | ----------------------------------------- | +| RUNNING | 111 | Y | Y | | +| SHUTDOWN | 000 | N | Y | 不接收新任务,但处理阻塞队列剩余任务 | +| STOP | 001 | N | N | 中断正在执行的任务,并抛弃阻塞队列任务 | +| TIDYING | 010 | - | - | 任务全执行完毕,活动线程为 0 即将进入终结 | +| TERMINATED | 011 | - | - | 终止状态 | + +从数字上比较,TERMINATED > TIDYING > STOP > SHUTDOWN > RUNNING . + +因为第一位是符号位,RUNNING 是负数,所以最小. + + + +这些信息存储在一个原子变量 ctl 中,目的是将线程池状态与线程个数合二为一,这样就可以用一次 cas 原子操作进行赋值 + +```java +// c 为旧值, ctlOf 返回结果为新值 +ctl.compareAndSet(c, ctlOf(targetState, workerCountOf(c)))); + +// rs 为高 3 位代表线程池状态, wc 为低 29 位代表线程个数,ctl 是合并它们 +private static int ctlOf(int rs, int wc) { return rs | wc; } +``` + + + +#### 参数 + +```java +public ThreadPoolExecutor(int corePoolSize, + int maximumPoolSize, + long keepAliveTime, + TimeUnit unit, + BlockingQueue workQueue, + ThreadFactory threadFactory, + RejectedExecutionHandler handler) +``` + +参数: + +- corePoolSize 核心线程数目。 默认情况下,线程池中线程的数量如果 <= corePoolSize,那么即使这些线程处于空闲状态,那也不会被销毁。 + +- maximumPoolSize 最大线程数目 。当队列中存放的任务达到队列容量时,当前可以同时运行的数量变为最大线程数,创建线程并立即执行最新的任务,与核心线程数之间的差值又叫非核心线程数。 + +- keepAliveTime 空闲存活时间 - 当线程池中线程的数量大于corePoolSize,并且某个线程的空闲时间超过了keepAliveTime,那么这个线程就会被销毁。 + +- unit 时间单位 + +- workQueue 工作队列 ,存放被提交但尚未被执行的任务 + +- threadFactory 线程工厂 - 可以为线程创建时起个好名字 + +- handler 拒绝策略 + + RejectedExecutionHandler 下有 4 个实现类: + + - AbortPolicy:让调用者抛出 RejectedExecutionException 异常,**默认策略** + - CallerRunsPolicy:让调用者线程执行 + - DiscardPolicy:直接丢弃任务,不予任何处理也不抛出异常 + - DiscardOldestPolicy:放弃队列中最早的任务,把当前任务加入队列中尝试再次提交当前任务 + + 补充:其他框架拒绝策略 + + - Dubbo:在抛出 RejectedExecutionException 异常前记录日志,并 dump 线程栈信息,方便定位问题 + - Netty:创建一个新线程来执行任务 + - ActiveMQ:带超时等待(60s)尝试放入队列 + - PinPoint:它使用了一个拒绝策略链,会逐一尝试策略链中每种拒绝策略 + +有没有办法既能保证任务不被丢弃且在服务器有余力时及时处理呢? + +**任务持久化**的思路,这里所谓的任务持久化,包括但不限于: + +1. 设计一张任务表间任务存储到 MySQL 数据库中。 +2. `Redis`缓存任务。 +3. 将任务提交到消息队列中。 + + + +#### 工作方式 + +```java +// 存放线程池的运行状态 (runState) 和线程池内有效线程的数量 (workerCount) +private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0)); + +private static int workerCountOf(int c) { + return c & CAPACITY; +} +//任务队列 +private final BlockingQueue workQueue; + +public void execute(Runnable command) { + // 如果任务为null,则抛出异常。 + if (command == null) + throw new NullPointerException(); + // ctl 中保存的线程池当前的一些状态信息 + int c = ctl.get(); + + // 下面会涉及到 3 步 操作 + // 1.首先判断当前线程池中执行的任务数量是否小于 corePoolSize + // 如果小于的话,通过addWorker(command, true)新建一个线程,并将任务(command)添加到该线程中;然后,启动该线程从而执行任务。 + if (workerCountOf(c) < corePoolSize) { + if (addWorker(command, true)) + return; + c = ctl.get(); + } + // 2.如果当前执行的任务数量大于等于 corePoolSize 的时候就会走到这里,表明创建新的线程失败。 + // 通过 isRunning 方法判断线程池状态,线程池处于 RUNNING 状态并且队列可以加入任务,该任务才会被加入进去 + if (isRunning(c) && workQueue.offer(command)) { + int recheck = ctl.get(); + // 再次获取线程池状态,如果线程池状态不是 RUNNING 状态就需要从任务队列中移除任务,并尝试判断线程是否全部执行完毕。同时执行拒绝策略。 + if (!isRunning(recheck) && remove(command)) + reject(command); + // 如果当前工作线程数量为0,新创建一个线程并执行。 + else if (workerCountOf(recheck) == 0) + addWorker(null, false); + } + //3. 通过addWorker(command, false)新建一个线程,并将任务(command)添加到该线程中;然后,启动该线程从而执行任务。 + // 传入 false 代表增加线程时判断当前线程数是否少于 maxPoolSize + //如果addWorker(command, false)执行失败,则通过reject()执行相应的拒绝策略的内容。 + else if (!addWorker(command, false)) + reject(command); +} +``` + +1. 创建线程池,这时没有创建线程(**懒惰**),等待提交过来的任务请求,调用 execute 方法才会创建线程 +2. 当调用 execute() 方法添加一个请求任务时,线程池会做如下判断: + - 如果正在运行的线程数量小于 corePoolSize,那么马上创建线程运行这个任务 + - 如果正在运行的线程数量大于或等于 corePoolSize,那么将这个任务放入队列 + - 如果这时队列满了且正在运行的线程数量还小于 maximumPoolSize,那么会创建非核心线程**立刻运行这个任务**,对于阻塞队列中的任务不公平。 + - 如果队列满了且正在运行的线程数量大于或等于 maximumPoolSize,那么线程池会启执行**拒绝策略** +3. 当一个线程完成任务时,会从队列中取下一个任务来执行 +4. 当一个线程空闲超过空闲存活时间(keepAliveTime)时,线程池会判断:如果当前运行的线程数大于 corePoolSize,那么这个线程就被停掉,所以线程池的所有任务完成后最终会收缩到 corePoolSize 大小 + + + +#### JDK Executors类中提供的典型线程池实现 + +##### **newFixedThreadPool** + +```java +public static ExecutorService newFixedThreadPool(int nThreads) { + return new ThreadPoolExecutor(nThreads, nThreads, + 0L, TimeUnit.MILLISECONDS, + new LinkedBlockingQueue()); +} +``` + +特点: + +- 核心线程数 == 最大线程数(没有救急线程被创建),因此也无需空闲存活时间 +- 阻塞队列是无界的,可以放任意数量的任务 +- 可能出现OOM,因为队列是无界的,所以任务可能挤爆内存 +- 适用于任务量已知,相对耗时的任务 + + + +##### **newCachedThreadPool** + +```java +public static ExecutorService newCachedThreadPool() { + return new ThreadPoolExecutor(0, Integer.MAX_VALUE, + 60L, TimeUnit.SECONDS, + new SynchronousQueue()); +} +``` + +特点 : + +- 核心线程数是 0,最大线程数是 Integer.MAX_VALUE,救急线程的空闲生存时间是 60s,意味着 + - 全部都是救急线程(60s 后可以回收) + - 救急线程可以无限创建 + +- 队列采用了 SynchronousQueue 同步队列实现,特点是它**没有容量**,没有线程来取是放不进去的,每来个任务就必须有线程接着(类似一手交钱、一手交货) + +* 适合任务数比较密集,但每个任务执行时间较短的情况 + + + +##### **newSingleThreadExecutor** + +```java +public static ExecutorService newSingleThreadExecutor() { + return new FinalizableDelegatedExecutorService + (new ThreadPoolExecutor(1, 1, + 0L, TimeUnit.MILLISECONDS, + new LinkedBlockingQueue())); +} +``` + +特点: + +* 线程数固定为 1,任务数多于 1 时,会放入无界队列排队。 + +* 任务执行完毕,这唯一的线程也不会被释放。 + +* 适合希望多个任务顺序执行场景。 + + + +和自己创建一个线程来工作的区别? + +自己创建一个单线程串行执行任务,如果任务执行失败而终止那么没有任何补救措施,而线程池还会新建一个线程,保证池的正常工作 + + + +和Executors.newFixedThreadPool(1)的区别? + +Executors.newSingleThreadExecutor() 线程个数始终为1,不能修改 + +Executors.newFixedThreadPool(1) 初始时为1,以后还可以修改,对外暴露的是 ThreadPoolExecutor 对象,可以强转后调用 setCorePoolSize 等方法进行修改。 + + + +##### newWorkStealingPool + +```java +public static ExecutorService newWorkStealingPool() { + return new ForkJoinPool + (Runtime.getRuntime().availableProcessors(), + ForkJoinPool.defaultForkJoinWorkerThreadFactory, + null, true); +} +``` + +特点: + +* 线程数会参照当前可用的处理核心数 +* 每个线程都有自己的双端队列,当自己的任务处理完毕后,会去别的线程的任务队列尾部拿任务来执行,加快执行速率 +* 工作窃取池不保证提交任务的执行顺序 + +JDK8引入,返回的就是ForkJoinPool,1.8用的并行流就是这个线程池。 + + + +##### newScheduledThreadPool + +```java +public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) { + return new ScheduledThreadPoolExecutor(corePoolSize); +} +public ScheduledThreadPoolExecutor(int corePoolSize) { + super(corePoolSize, Integer.MAX_VALUE, + DEFAULT_KEEPALIVE_MILLIS, MILLISECONDS, + new DelayedWorkQueue()); + } +``` + +创建一个定长线程池,支持定时及周期性任务执行。 + +#### 开发要求 + +阿里巴巴 Java 开发手册要求: + +- **线程资源必须通过线程池提供,不允许在应用中自行显式创建线程** + + - 使用线程池的好处是减少在创建和销毁线程上所消耗的时间以及系统资源的开销,解决资源不足的问题 + - 如果不使用线程池,有可能造成系统创建大量同类线程而导致消耗完内存或者过度切换的问题 + +- 线程池**不允许使用 Executors 去创建,而是通过 ThreadPoolExecutor 的方式**,这样的处理方式更加明确线程池的运行规则,规避资源耗尽的风险 + + Executors 返回的线程池对象弊端如下: + + - FixedThreadPool 和 SingleThreadExecutor:请求队列长度为 Integer.MAX_VALUE,可能会堆积大量的请求,从而导致 OOM(内存溢出) + - CacheThreadPool 和 ScheduledThreadPool:允许创建线程数量为 Integer.MAX_VALUE,可能会创建大量的线程,导致 OOM + +创建多大容量的线程池合适? + +- 一般来说池中**总线程数是核心池线程数量两倍**,确保当核心池有线程停止时,核心池外有线程进入核心池 +- 过小会导致程序不能充分地利用系统资源、容易导致饥饿 +- 过大会导致更多的线程上下文切换,占用更多内存 + +核心线程数常用公式: + +- **CPU 密集型任务 (N+1):** 这种任务消耗的是 CPU 资源,可以将核心线程数设置为 **N (CPU 核心数) + 1**,比 CPU 核心数多出来的一个线程是为了防止线程发生缺页中断,或者其它原因导致的任务暂停而带来的影响。一旦任务暂停,CPU 某个核心就会处于空闲状态,而在这种情况下多出来的一个线程就可以充分利用 CPU 的空闲时间 + +- **I/O 密集型任务(2N+1):** 这种系统 CPU 处于阻塞状态,用大部分的时间来处理 I/O 交互,而线程在处理 I/O 的时间段内不会占用 CPU 来处理,这时就可以将 CPU 交出给其它线程使用,因此在 I/O 密集型任务的应用中,我们可以多配置一些线程,具体的计算方法是 **2N+1 或 CPU 核数/ (1-阻塞系数),阻塞系数在 0.8~0.9 之间** + +- 经验公式如下 + + ``` + 线程数 = 核数 * 期望 CPU 利用率 * 总时间(CPU计算时间+等待时间) / CPU 计算时间 + ``` + +#### 提交方法 + +```java +// 执行任务 +void execute(Runnable command); + +// 提交任务 task,用返回值 Future 获得任务执行结果 + Future submit(Callable task); + Future submit(Runnable task, T result); +Future submit(Runnable task); + +// 提交 tasks 中所有任务 + List> invokeAll(Collection> tasks) + throws InterruptedException; + +// 提交 tasks 中所有任务,带超时时间 + List> invokeAll(Collection> tasks, + long timeout, TimeUnit unit) + throws InterruptedException; + +// 提交 tasks 中所有任务,哪个任务先成功执行完毕,返回此任务执行结果,其它任务取消 + T invokeAny(Collection> tasks) + throws InterruptedException, ExecutionException; + +// 提交 tasks 中所有任务,哪个任务先成功执行完毕,返回此任务执行结果,其它任务取消,带超时时间 + T invokeAny(Collection> tasks, + long timeout, TimeUnit unit) + throws InterruptedException, ExecutionException, TimeoutException; +``` + +Future 接口有 5 个方法,它们分别是取消任务的方法 cancel()、判断任务是否已取消的方法 isCancelled()、判断任务是否已结束的方法 isDone()以及2 个获得任务执行结果的 get() 和 get(timeout, unit) + +`execute()`方法用于提交不需要返回值的任务,所以无法判断任务是否被线程池执行成功与否; + +`submit()`方法用于提交需要返回值的任务。线程池会返回一个 `Future` 类型的对象,通过这个 `Future` 对象可以判断任务是否执行成功,并且可以通过 `Future` 的 `get()`方法来获取返回值,`get()`方法会阻塞当前线程直到任务完成,而使用 `get(long timeout,TimeUnit unit)`方法的话,如果在 `timeout` 时间内任务还没有执行完,就会抛出 `java.util.concurrent.TimeoutException` + + + +#### 关闭方法 + +ExecutorService 类 API: + +| 方法 | 说明 | +| ----------------------------------------------------- | ------------------------------------------------------------ | +| void shutdown() | 线程池状态变为 SHUTDOWN,线程池不再接受新任务了,但是队列里的任务得执行完毕,不会阻塞调用线程的执行 | +| List shutdownNow() | 线程池状态变为 STOP,用 interrupt 中断正在执行的任务,线程池会终止当前正在运行的任务,并停止处理排队的任务并返回正在等待执行的 List | +| boolean isShutdown() | 调用 `shutdown()` 方法后返回为 true。 | +| boolean isTerminated() | 当调用 `shutdown()` 方法后,并且所有提交的任务完成后返回为 true | +| boolean awaitTermination(long timeout, TimeUnit unit) | 调用 shutdown 后,由于调用线程不会等待所有任务运行结束,如果它想在线程池 TERMINATED 后做些事情,可以利用此方法等待 | + + + +#### 任务调度 + +##### Timer + +Timer 可以实现延时功能和周期性任务,Timer 的优点在于简单易用,但由于所有任务都是由同一个线程来调度,因此所有任务都是串行执行的,同一时间只能有一个任务在执行,前一个任务的延迟或异常都将会影响到之后的任务。 + +核心就是一个优先队列和封装的执行任务的线程。维持一个小根堆,最快需要执行的任务排在优先队列的第一个,然后有个TimerThread线程不断拿排在第一个的任务和当前时间对比,时间到了就执行,时间未到就调用 wait() 等待 + +##### Scheduled + +任务调度线程池 ScheduledThreadPoolExecutor 继承 ThreadPoolExecutor: + +构造方法:`Executors.newScheduledThreadPool(int corePoolSize)` + +```java +public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) { + return new ScheduledThreadPoolExecutor(corePoolSize); +} +public ScheduledThreadPoolExecutor(int corePoolSize) { + super(corePoolSize, Integer.MAX_VALUE, + DEFAULT_KEEPALIVE_MILLIS, MILLISECONDS, + new DelayedWorkQueue()); + } +``` + +ScheduledThreadPoolExecutor 和 Timer 对比: + +- `Timer` 对系统时钟的变化敏感,`ScheduledThreadPoolExecutor`不是; +- `Timer` 只有一个执行线程,因此长时间运行的任务可以延迟其他任务。 `ScheduledThreadPoolExecutor` 可以配置任意数量的线程。 此外,如果你想(通过提供 `ThreadFactory`),你可以完全控制创建的线程; +- 在`TimerTask` 中抛出的运行时异常会杀死一个线程,从而导致 `Timer` 死机即计划任务将不再运行。`ScheduledThreadExecutor` 不仅捕获运行时异常,还允许您在需要时处理它们(通过重写 `afterExecute` 方法`ThreadPoolExecutor`)。抛出异常的任务将被取消,但其他任务将继续运行。 + + + +#### 正确处理异常 + +1. 主动捕获 + +2. 在执行Future的get()时会获取到异常栈信息 + + ```java + ExecutorService pool = Executors.newFixedThreadPool(1); + + Future f = pool.submit(() -> { + log.debug("task1"); + int i = 1 / 0; + return true; + }); + log.debug("result:{}", f.get()); + ``` + + + + +#### 线程池中线程异常后,销毁还是复用? + +先说结论,需要分两种情况: + +- **使用`execute()`提交任务**:当任务通过`execute()`提交到线程池并在执行过程中抛出异常时,如果这个异常没有在任务内被捕获,那么该异常会导致当前线程终止,并且异常会被打印到控制台或日志文件中。线程池会检测到这种线程终止,并创建一个新线程来替换它,从而保持配置的线程数不变。 +- **使用`submit()`提交任务**:对于通过`submit()`提交的任务,如果在任务执行中发生异常,这个异常不会直接打印出来。相反,异常会被封装在由`submit()`返回的`Future`对象中。当调用`Future.get()`方法时,可以捕获到一个`ExecutionException`。在这种情况下,线程不会因为异常而终止,它会继续存在于线程池中,准备执行后续的任务。 + +简单来说:使用`execute()`时,未捕获异常导致线程终止,线程池创建新线程替代;使用`submit()`时,异常被封装在`Future`中,线程继续复用。 + +这种设计允许`submit()`提供更灵活的错误处理机制,因为它允许调用者决定如何处理异常,而`execute()`则适用于那些不需要关注执行结果的场景。 + +#### 线程池命名 + +1. **利用 guava 的 `ThreadFactoryBuilder`** + + ```java + ThreadFactory threadFactory = new ThreadFactoryBuilder() + .setNameFormat(threadNamePrefix + "-%d") + .setDaemon(true).build(); + ExecutorService threadPool = new ThreadPoolExecutor(corePoolSize, maximumPoolSize, keepAliveTime, TimeUnit.MINUTES, workQueue, threadFactory); + ``` + +2. **自己实现 `ThreadFactory`。** + + ```java + import java.util.concurrent.ThreadFactory; + import java.util.concurrent.atomic.AtomicInteger; + + /** + * 线程工厂,它设置线程名称,有利于我们定位问题。 + */ + public final class NamingThreadFactory implements ThreadFactory { + + private final AtomicInteger threadNum = new AtomicInteger(); + private final String name; + + /** + * 创建一个带名字的线程池生产工厂 + */ + public NamingThreadFactory(String name) { + this.name = name; + } + + @Override + public Thread newThread(Runnable r) { + Thread t = new Thread(r); + t.setName(name + " [#" + threadNum.incrementAndGet() + "]"); + return t; + } + } + ``` + + + +### ForkJoin + +Fork/Join:线程池的实现,体现是**分治思想**,适用于能够进行任务拆分的 CPU 密集型运算,用于并行计算 + +将一个大任务拆分为算法上相同的小任务,然后将这些小任务并行执行 + +**Fork 对应的是分治任务模型里的任务分解,Join 对应的是结果合并**。Fork/Join 计算框架主要包含两部分,一部分是**分治任务的线程池 ForkJoinPool**,另一部分是**分治任务 ForkJoinTask** + +ForkJoinTask 是一个抽象类,最核心的是 fork() 方法和 join() 方法,其中 fork() 方法会异步地执行一个子任务,而 join() 方法则会阻塞当前线程来等待子任务的执行结果。ForkJoinTask 有两个子类——**RecursiveAction 和 RecursiveTask**,通过名字你就应该能知道,它们都是用递归的方式来处理分治任务的。这两个子类都定义了抽象方法 compute(),不过区别是 RecursiveAction 定义的 compute() 没有返回值,而 RecursiveTask 定义的 compute() 方法是有返回值的。这两个子类也是抽象类,在使用的时候,需要你定义子类去扩展。 + +```java +static void main(String[] args){ + // 创建分治任务线程池 + ForkJoinPool fjp = + new ForkJoinPool(4); + // 创建分治任务 + Fibonacci fib = + new Fibonacci(30); + // 启动分治任务 + Integer result = + fjp.invoke(fib); + // 输出结果 + System.out.println(result); +} +// 递归任务 +static class Fibonacci extends + RecursiveTask{ + final int n; + Fibonacci(int n){this.n = n;} + protected Integer compute(){ + if (n <= 1) + return n; + Fibonacci f1 = + new Fibonacci(n - 1); + // 创建子任务 + f1.fork(); + Fibonacci f2 = + new Fibonacci(n - 2); + // 等待子任务结果,并合并结果 + return f2.compute() + f1.join(); + } +} +``` + +ForkJoinPool 实现了**工作窃取算法**来提高 CPU 的利用率: + +- 每个线程都维护了一个**双端队列**,用来存储需要执行的任务 +- 工作窃取算法允许空闲的线程从其它线程的双端队列中窃取一个任务来执行 +- 窃取的必须是最晚的任务,避免和队列所属线程发生竞争,但是队列中只有一个任务时还是会发生竞争 + + + +## JUC + +### AQS + +AQS:AbstractQueuedSynchronizer,**是阻塞式锁和相关的同步器工具的框架**,AQS 就是一个抽象类,主要用来构建锁和同步器。 + +AQS 用 state 状态属性来表示资源的状态(分**独占模式和共享模式**),子类需要定义如何维护这个状态,控制如何获取锁和释放锁 + +- 独占模式是只有一个线程能够访问资源,如 ReentrantLock +- 共享模式允许多个线程访问资源,如 Semaphore,ReentrantReadWriteLock 是组合式 + +AQS 核心思想: + +- 如果被请求的共享资源 state 空闲,则将当前请求资源的线程设置为有效的工作线程,并将共享资源设置锁定状态 +- 请求的共享资源被占用,AQS 用队列实现线程阻塞等待以及被唤醒时锁分配的机制,将暂时获取不到锁的线程加入到队列中 +- 等待队列:使用了CLH 队列,并不支持优先级队列,**同步队列是双向链表**,类似于 Monitor 的 EntryList +- 条件变量:条件变量来实现等待、唤醒机制,支持多个条件变量,类似于 Monitor 的 WaitSet ,**条件队列是单向链表** + +![](.\JUC\AQS.png) + +同步器的设计是基于**模板方法模式**,该模式是基于继承的,主要是为了在不改变模板结构的前提下在子类中重新定义模板中的内容以实现复用代码 + +- 使用者继承 `AbstractQueuedSynchronizer` 并重写指定的方法 +- 将 AQS 组合在自定义同步组件的实现中,并调用其模板方法,这些模板方法会调用使用者重写的方法 + +AQS 使用了模板方法模式,自定义同步器时需要重写下面几个 AQS 提供的模板方法: + +```java +isHeldExclusively() //该线程是否正在独占资源。只有用到condition才需要去实现它 +tryAcquire(int) //独占方式。尝试获取资源,成功则返回true,失败则返回false +tryRelease(int) //独占方式。尝试释放资源,成功则返回true,失败则返回false +tryAcquireShared(int) //共享方式。尝试获取资源。负数表示失败;0表示成功但没有剩余可用资源;正数表示成功且有剩余资源 +tryReleaseShared(int) //共享方式。尝试释放资源,成功则返回true,失败则返回false +``` + +- 默认情况下,每个方法都抛出 `UnsupportedOperationException` +- 这些方法的实现必须是内部线程安全的 +- AQS 类中的其他方法都是 final ,所以无法被其他类使用,只有这几个方法可以被其他类使用 + + + +**CLH锁** + +CLH 锁是对自旋锁的一种改进,有效的解决了自旋锁竞争激烈时饥饿和性能较差问题。首先它将线程组织成一个队列,保证先请求的线程先获得锁,避免了饥饿问题。其次锁状态去中心化,让每个线程在不同的状态变量中自旋,这样当一个线程释放它的锁时,只能使其后续线程的高速缓存失效,缩小了影响范围,从而减少了 CPU 的开销。 + +CLH 锁数据结构很简单,类似一个链表队列,所有请求获取锁的线程会排列在链表队列中,自旋访问队列中前一个节点的状态。当一个节点释放锁时,只有它的后一个节点才可以得到锁。每一个 CLH 节点有两个属性:所代表的线程和标识是否持有锁的状态变量。 + + + +### ReentrantLock 原理 + +ReentrantLock 是基于 AQS 实现的可重入锁,支持公平和非公平两种方式。 + +内部实现依靠一个state 变量和两个等待队列:同步队列和等待队列。 + +利用 CAS 和 修改 state 来争抢锁,争抢不到就进入同步队列等待,同步队列是一个双向链表;每个条件变量对应着一个等待队列。 + +是否公平的区别是:线程获取锁时是加入到队列尾部还是直接利用 CAS 争抢锁。公平锁先检查 AQS 队列中是否有前驱节点,没有才去 CAS 竞争。 + + + +![](.\JUC\ReentrantLock.png) + +先从构造器开始看,默认为**非公平锁**实现 + +```java +public ReentrantLock() { + sync = new NonfairSync(); +} +``` + +#### 加解锁流程 + +**没有竞争**:ExclusiveOwnerThread 属于 Thread-0,state 设置为 1 + +```java +// ReentrantLock.NonfairSync#lock +final void lock() { + // 用 cas 尝试(仅尝试一次)将 state 从 0 改为 1, 如果成功表示【获得了独占锁】 + if (compareAndSetState(0, 1)) + // 设置当前线程为独占线程 + setExclusiveOwnerThread(Thread.currentThread()); + else + acquire(1);//失败进入 +} +``` + +**第一个竞争出现**: + +```java +// AbstractQueuedSynchronizer#acquire +public final void acquire(int arg) { + // tryAcquire 尝试获取锁失败时, 会调用 addWaiter 将当前线程封装成node入队,acquireQueued 阻塞当前线程, + // acquireQueued 返回 true 表示挂起过程中线程被中断唤醒过,false 表示未被中断过 + if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) + // 如果线程被中断了逻辑来到这,完成一次真正的打断效果 + selfInterrupt(); +} +``` + +1. Thread-0 持有锁,Thread-1 执行,CAS 尝试将 state 由 0 改为 1,结果失败(第一次),进入 acquire 逻辑 + +2. 进入 tryAcquire 再次尝试获取锁逻辑,这时 state 已经是1,结果仍然失败(第二次) + +3. 进入 addWaiter 逻辑,构造 Node 队列 + + * 图中黄色三角表示该 Node 的 waitStatus 状态,其中 0 为默认**正常状态** + + * Node 的创建是懒惰的,其中第一个 Node 称为 **Dummy(哑元)或哨兵**,用来占位,并不关联线程 + +4. 当前线程进入 acquireQueued 逻辑 + + 1. acquireQueued 会在一个死循环中不断尝试获得锁,失败后进入 park 阻塞 + + 2. 如果自己是紧邻着 head(排第二位),那么再次 tryAcquire 尝试获取锁,state 仍为 1 则失败(第三次) + + 3. 进入 shouldParkAfterFailedAcquire 逻辑,**将前驱 node 的 waitStatus 改为 -1**,返回 false;waitStatus 为 -1 的节点用来唤醒下一个节点 + 4. shouldParkAfterFailedAcquire 执行完毕回到 acquireQueued ,再次 tryAcquire 尝试获取锁,这时 state 仍为 1 获取失败(第四次) + 5. 当再次进入 shouldParkAfterFailedAcquire 时,这时其前驱 node 的 waitStatus 已经是 -1 了,返回 true + 6. 进入 parkAndCheckInterrupt, Thread-1 park(灰色表示) + +| | ![](.\JUC\acquireQueued.png) | +| ------------------------------------ | ---------------------------- | +| ![](.\JUC\parkAndCheckInterrupt.png) | ![](.\JUC\多个失败.png) | + +```java +final boolean acquireQueued(final Node node, int arg) { + // true 表示当前线程抢占锁失败,false 表示成功 + boolean failed = true; + try { + // 中断标记,表示当前线程是否被中断 + boolean interrupted = false; + for (;;) { + // 获得当前线程节点的前驱节点 + final Node p = node.predecessor(); + // 前驱节点是 head, FIFO 队列的特性表示轮到当前线程可以去获取锁 + if (p == head && tryAcquire(arg)) { + // 获取成功, 设置当前线程自己的 node 为 head + setHead(node); + p.next = null; // help GC + // 表示抢占锁成功 + failed = false; + // 返回当前线程是否被中断 + return interrupted; + } + // 判断是否应当 park,返回 false 后需要新一轮的循环,返回 true 进入条件二阻塞线程 + if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt()) + // 条件二返回结果是当前线程是否被打断,没有被打断返回 false 不进入这里的逻辑 + // 【就算被打断了,也会继续循环,并不会返回】 + interrupted = true; + } + } finally { + // 【可打断模式下才会进入该逻辑】 + if (failed) + cancelAcquire(node); + } +} +``` + + + +**原OwnerThread释放锁时**: + +Thread-0 释放锁,进入 release 流程 + +1. 进入 tryRelease,设置 exclusiveOwnerThread 为 null,state = 0 + +2. 当前队列不为 null,并且 head 的 waitStatus = -1,进入 unparkSuccessor +3. 找到队列中距离 head 最近的一个没取消的 Node,unpark 恢复其运行,本例中即为 Thread-1 +4. 回到 Thread-1 的 acquireQueued 流程 +5. 唤醒的线程会从 park 位置开始执行,如果加锁成功(没有竞争),会设置 + - exclusiveOwnerThread 为 Thread-1,state = 1 + - head 指向刚刚 Thread-1 所在的 Node,该 Node 会清空 Thread + - 原本的 head 因为从链表断开,而可被垃圾回收 + +![](.\JUC\release.png) + +但是,如果这时有其它线程来竞争**(非公平)**,例如这时 Thread-4 来了并抢占了锁 + +- Thread-4 被设置为 exclusiveOwnerThread,state = 1 +- Thread-1 再次进入 acquireQueued 流程,获取锁失败,重新进入 park 阻塞 + +![](.\JUC\非公平锁.png) + + + +#### 可重入原理 + +通过state计数的增加减少来实现可重入 + +```java +static final class NonfairSync extends Sync { + // ... + + // Sync 继承过来的方法, 方便阅读, 放在此处 + final boolean nonfairTryAcquire(int acquires) { + final Thread current = Thread.currentThread(); + int c = getState(); + if (c == 0) { + if (compareAndSetState(0, acquires)) { + setExclusiveOwnerThread(current); + return true; + } + } + // 如果已经获得了锁, 线程还是当前线程, 表示发生了锁重入 + else if (current == getExclusiveOwnerThread()) { + // state++ + int nextc = c + acquires; + if (nextc < 0) // overflow + throw new Error("Maximum lock count exceeded"); + setState(nextc); + return true; + } + return false; + } + + // Sync 继承过来的方法, 方便阅读, 放在此处 + protected final boolean tryRelease(int releases) { + // state-- + int c = getState() - releases; + if (Thread.currentThread() != getExclusiveOwnerThread()) + throw new IllegalMonitorStateException(); + boolean free = false; + // 支持锁重入, 只有 state 减为 0, 才释放成功 + if (c == 0) { + free = true; + setExclusiveOwnerThread(null); + } + setState(c); + return free; + } +} +``` + + + +#### 可打断原理 + +**不可打断模式** + +在此模式下,即使它被打断,仍会驻留在 AQS 队列中,一直要等到获得锁后方能得知自己被打断了. + +被打断时只是记录下被打断过,等获得锁以后才继续向下运行 + +```java +// Sync 继承自 AQS +static final class NonfairSync extends Sync { + // ... + + private final boolean parkAndCheckInterrupt() { + // 如果打断标记已经是 true, 则 park 会失效 + LockSupport.park(this); + // interrupted 会清除打断标记 + return Thread.interrupted(); + } + + final boolean acquireQueued(final Node node, int arg) { + boolean failed = true; + try { + boolean interrupted = false; + for (;;) { + final Node p = node.predecessor(); + if (p == head && tryAcquire(arg)) { + setHead(node); + p.next = null; + failed = false; + // 还是需要获得锁后, 才能返回打断状态 + return interrupted; + } + if ( + shouldParkAfterFailedAcquire(p, node) && + parkAndCheckInterrupt() + ) { + // 如果是因为 interrupt 被唤醒, 返回打断状态为 true + interrupted = true; + } + } + } finally { + if (failed) + cancelAcquire(node); + } + } + + public final void acquire(int arg) { + if ( + !tryAcquire(arg) && + acquireQueued(addWaiter(Node.EXCLUSIVE), arg) + ) { + // 如果打断状态为 true + selfInterrupt(); + } + } + + static void selfInterrupt() { + // 重新产生一次中断 + Thread.currentThread().interrupt(); + } +} +``` + +**可打断模式** + +被打断时直接抛异常 InterruptedException + +```java +static final class NonfairSync extends Sync { + public final void acquireInterruptibly(int arg) throws InterruptedException { + if (Thread.interrupted()) + throw new InterruptedException(); + // 如果没有获得到锁, 进入 ㈠ + if (!tryAcquire(arg)) + doAcquireInterruptibly(arg); + } + + // ㈠ 可打断的获取锁流程 + private void doAcquireInterruptibly(int arg) throws InterruptedException { + final Node node = addWaiter(Node.EXCLUSIVE); + boolean failed = true; + try { + for (;;) { + final Node p = node.predecessor(); + if (p == head && tryAcquire(arg)) { + setHead(node); + p.next = null; // help GC + failed = false; + return; + } + if (shouldParkAfterFailedAcquire(p, node) && + parkAndCheckInterrupt()) { + // 在 park 过程中如果被 interrupt 会进入此 + // 这时候抛出异常, 而不会再次进入 for (;;) + throw new InterruptedException(); + } + } + } finally { + if (failed) + cancelAcquire(node); + } + } +} + +``` + + + +#### 公平原理 + +与非公平锁主要区别在于 tryAcquire 方法:先检查 AQS 队列中是否有前驱节点,没有才去 CAS 竞争 + +```java +static final class FairSync extends Sync { + private static final long serialVersionUID = -3000897897090466540L; + final void lock() { + acquire(1); + } + + // AQS 继承过来的方法, 方便阅读, 放在此处 + public final void acquire(int arg) { + if ( + !tryAcquire(arg) && + acquireQueued(addWaiter(Node.EXCLUSIVE), arg) + ) { + selfInterrupt(); + } + } + // 与非公平锁主要区别在于 tryAcquire 方法的实现 + protected final boolean tryAcquire(int acquires) { + final Thread current = Thread.currentThread(); + int c = getState(); + if (c == 0) { + // 先检查 AQS 队列中是否有前驱节点, 没有才去竞争 + if (!hasQueuedPredecessors() && + compareAndSetState(0, acquires)) { + setExclusiveOwnerThread(current); + return true; + } + } + else if (current == getExclusiveOwnerThread()) { + int nextc = c + acquires; + if (nextc < 0) + throw new Error("Maximum lock count exceeded"); + setState(nextc); + return true; + } + return false; + } + + // ㈠ AQS 继承过来的方法, 方便阅读, 放在此处 + public final boolean hasQueuedPredecessors() { + Node t = tail; + Node h = head; + Node s; + // h != t 时表示队列中有 Node + return h != t && + ( + // (s = h.next) == null 表示队列中还有没有老二 + (s = h.next) == null || + // 或者队列中老二线程不是此线程 + s.thread != Thread.currentThread() + ); + } +} +``` + + + +#### 条件变量原理 + +每个条件变量其实就对应着一个等待队列,其实现类是 ConditionObject + +**await 流程** : + +总体流程是将 await 线程包装成 node 节点放入 ConditionObject 的条件队列,如果被唤醒就将 node 转移到 AQS 的执行阻塞队列,等待获取锁,**每个 Condition 对象都包含一个等待队列** + +1. 开始 Thread-0 持有锁,调用 await,线程进入 ConditionObject 的 addConditionWaiter 流程 ,直到被唤醒或打断 +2. 创建新的 Node ,状态为 -2(Node.CONDITION),关联 Thread-0,加入等待队列 ConditionObject 尾部 +3. 接下来 Thread-0 进入 AQS 的 fullyRelease 流程,释放同步器上的锁,fullyRelease 中会 unpark AQS 队列中的下一个节点竞争锁 +4. park 阻塞 Thread-0 + +| ![](.\JUC\await.png) | ![](.\JUC\await1.png) | +| -------------------- | --------------------- | + + + +**signal 流程** + +1. 假设 Thread-1 要来唤醒 Thread-0 +2. 进入 ConditionObject 的 doSignal 流程,取得等待队列中第一个 Node,即 Thread-0 所在 Node +3. 执行 transferForSignal 流程,将该 Node 加入 AQS 队列尾部,将 Thread-0 的 waitStatus 改为 0,Thread-3 的waitStatus 改为 -1 +4. Thread-1 释放锁,进入 unlock 流程 + +| ![](.\JUC\sihnal.png) | ![](.\JUC\isignal1.png) | +| --------------------- | ----------------------- | + + + +### ReentrantReadWriteLock + +独占锁:指该锁一次只能被一个线程所持有,对 ReentrantLock 和 Synchronized 而言都是独占锁 + +共享锁:指该锁可以被多个线程锁持有 + +ReentrantReadWriteLock 其**读锁是共享锁,写锁是独占锁** + +​ + +注意: + +- 读-读能共存、读-写不能共存、写-写不能共存 +- 读锁不支持条件变量,写锁支持 +- **重入时升级不支持**:持有读锁的情况下去获取写锁会导致获取写锁永久等待,需要先释放读,再去获得写 +- **重入时降级支持**:持有写锁的情况下去获取读锁,造成只有当前线程会持有读锁,因为写锁会互斥其他的锁 + +读锁不能升级为写锁:本线程在释放读锁之前,想要获取写锁是不一定能获取到的,因为其他线程可能持有读锁(读锁共享),可能导致阻塞较长的时间,所以java干脆直接不支持读锁升级为写锁。 + +写锁可以降级为读锁:本线程在释放写锁之前,获取读锁一定是可以立刻获取到的,不存在其他线程持有读锁或者写锁(读写锁互斥),所以java允许锁降级。 + + + +构造方法: + +- `public ReentrantReadWriteLock()`:默认构造方法,非公平锁 +- `public ReentrantReadWriteLock(boolean fair)`:true 为公平锁 + +常用API: + +- `public ReentrantReadWriteLock.ReadLock readLock()`:返回读锁 +- `public ReentrantReadWriteLock.WriteLock writeLock()`:返回写锁 +- `public void lock()`:加锁 +- `public void unlock()`:解锁 +- `public boolean tryLock()`:尝试获取锁 + + + +#### 实现原理 + +读写锁用的是同一个 Sycn 同步器,因此等待队列、state 等也是同一个,原理与 ReentrantLock 加锁相比没有特殊之处,不同是**写锁状态占了 state 的低 16 位,而读锁使用的是 state 的高 16 位** + + + +**t1 线程:w.lock(写锁),成功上锁 state = 0_1** + +```java +// lock() -> sync.acquire(1); +public void lock() { + sync.acquire(1); +} +public final void acquire(int arg) { + // 尝试获得写锁,获得写锁失败,将当前线程关联到一个 Node 对象上, 模式为独占模式 + if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) + selfInterrupt(); +} +``` + +```java +protected final boolean tryAcquire(int acquires) { + Thread current = Thread.currentThread(); + int c = getState(); + // 获得低 16 位, 代表写锁的 state 计数 + int w = exclusiveCount(c); + // 说明有读锁或者写锁 + if (c != 0) { + // c != 0 and w == 0 表示有读锁,【读锁不能升级】,直接返回 false + // w != 0 说明有写锁,写锁的拥有者不是自己,获取失败 + if (w == 0 || current != getExclusiveOwnerThread()) + return false; + + // 执行到这里只有一种情况:【写锁重入】,所以下面几行代码不存在并发 + if (w + exclusiveCount(acquires) > MAX_COUNT) + throw new Error("Maximum lock count exceeded"); + // 写锁重入, 获得锁成功,没有并发,所以不使用 CAS + setState(c + acquires); + return true; + } + + // c == 0,说明没有任何锁,判断写锁是否该阻塞,是 false 就尝试获取锁,失败返回 false + if (writerShouldBlock() || !compareAndSetState(c, c + acquires)) + return false; + // 获得锁成功,设置锁的持有线程为当前线程 + setExclusiveOwnerThread(current); + return true; +} +// 非公平锁 writerShouldBlock 总是返回 false, 无需阻塞 +final boolean writerShouldBlock() { + return false; +} +// 公平锁会检查 AQS 队列中是否有前驱节点, 没有(false)才去竞争 +final boolean writerShouldBlock() { + return hasQueuedPredecessors(); +} +``` + +**t2线程: r.lock(读锁),进入 tryAcquireShared 流程** + +```java +public void lock() { + sync.acquireShared(1); +} +public final void acquireShared(int arg) { + // tryAcquireShared 返回负数, 表示获取读锁失败 + if (tryAcquireShared(arg) < 0) + doAcquireShared(arg); +} +``` + +```java +// 尝试以共享模式获取 +protected final int tryAcquireShared(int unused) { + Thread current = Thread.currentThread(); + int c = getState(); + // exclusiveCount(c) 代表低 16 位, 写锁的 state,成立说明有线程持有写锁 + // 写锁的持有者不是当前线程,则获取读锁失败,【写锁允许降级】 + if (exclusiveCount(c) != 0 && getExclusiveOwnerThread() != current) + return -1; + + // 高 16 位,代表读锁的 state,共享锁分配出去的总次数 + int r = sharedCount(c); + // 读锁是否应该阻塞 + if (!readerShouldBlock() && r < MAX_COUNT && + compareAndSetState(c, c + SHARED_UNIT)) { // 尝试增加读锁计数 + // 加锁成功 + // 加锁之前读锁为 0,说明当前线程是第一个读锁线程 + if (r == 0) { + firstReader = current; + firstReaderHoldCount = 1; + // 第一个读锁线程是自己就发生了读锁重入 + } else if (firstReader == current) { + firstReaderHoldCount++; + } else { + // cachedHoldCounter 设置为当前线程的 holdCounter 对象,即最后一个获取读锁的线程 + HoldCounter rh = cachedHoldCounter; + // 说明还没设置 rh + if (rh == null || rh.tid != getThreadId(current)) + // 获取当前线程的锁重入的对象,赋值给 cachedHoldCounter + cachedHoldCounter = rh = readHolds.get(); + // 还没重入 + else if (rh.count == 0) + readHolds.set(rh); + // 重入 + 1 + rh.count++; + } + // 读锁加锁成功 + return 1; + } + // 逻辑到这 应该阻塞,或者 cas 加锁失败 + // 会不断尝试 for (;;) 获取读锁, 执行过程中无阻塞 + return fullTryAcquireShared(current); +} +// 非公平锁 readerShouldBlock 偏向写锁一些,看 AQS 阻塞队列中第一个节点是否是写锁,是则阻塞,反之不阻塞 +// 防止一直有读锁线程,导致写锁线程饥饿 +// true 则该阻塞, false 则不阻塞 +final boolean readerShouldBlock() { + return apparentlyFirstQueuedIsExclusive(); +} +final boolean readerShouldBlock() { + return hasQueuedPredecessors(); +} +``` + +获取读锁失败,进入 sync.doAcquireShared(1) 流程开始阻塞,首先也是调用 addWaiter 添加节点,不同之处在于节点被设置为 Node.SHARED 模式而非 Node.EXCLUSIVE 模式,注意此时 t2 仍处于活跃状态 + +```java +private void doAcquireShared(int arg) { + // 将当前线程关联到一个 Node 对象上, 模式为共享模式 + final Node node = addWaiter(Node.SHARED); + boolean failed = true; + try { + boolean interrupted = false; + for (;;) { + // 获取前驱节点 + final Node p = node.predecessor(); + // 如果前驱节点就头节点就去尝试获取锁 + if (p == head) { + // 再一次尝试获取读锁 + int r = tryAcquireShared(arg); + // r >= 0 表示获取成功 + if (r >= 0) { + //【这里会设置自己为头节点,唤醒相连的后序的共享节点】 + setHeadAndPropagate(node, r); + p.next = null; // help GC + if (interrupted) + selfInterrupt(); + failed = false; + return; + } + } + // 是否在获取读锁失败时阻塞 park 当前线程 + if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt()) + interrupted = true; + } + } finally { + if (failed) + cancelAcquire(node); + } +} +``` + +如果没有成功,在 doAcquireShared 内 for (;;) 循环一次,shouldParkAfterFailedAcquire 内把前驱节点的 waitStatus 改为 -1,再 for (;;) 循环一次尝试 tryAcquireShared,不成功在 parkAndCheckInterrupt() 处 park + +![](.\JUC\读写锁.png) + +**t1线程: w.unlock, 写锁解锁** + +```java +public void unlock() { + // 释放锁 + sync.release(1); +} +public final boolean release(int arg) { + // 尝试释放锁 + if (tryRelease(arg)) { + Node h = head; + // 头节点不为空并且不是等待状态不是 0,唤醒后继的非取消节点 + if (h != null && h.waitStatus != 0) + unparkSuccessor(h); + return true; + } + return false; +} +protected final boolean tryRelease(int releases) { + if (!isHeldExclusively()) + throw new IllegalMonitorStateException(); + int nextc = getState() - releases; + // 因为可重入的原因, 写锁计数为 0, 才算释放成功 + boolean free = exclusiveCount(nextc) == 0; + if (free) + setExclusiveOwnerThread(null); + setState(nextc); + return free; +} +``` + +唤醒流程 sync.unparkSuccessor,这时 t2 在 doAcquireShared 的 parkAndCheckInterrupt() 处恢复运行,继续循环,执行 tryAcquireShared 成功则让读锁计数加一 + +接下来 t2 调用 setHeadAndPropagate(node, 1),它原本所在节点被置为头节点;还会检查下一个节点是否是 shared,如果是则调用 doReleaseShared() 将 head 的状态从 -1 改为 0 并唤醒下一个节点,这时 t3 在 doAcquireShared 内 parkAndCheckInterrupt() 处恢复运行,**唤醒连续的所有的共享节点** + +![](.\JUC\读写锁共享锁.png) + +```java +private void setHeadAndPropagate(Node node, int propagate) { + Node h = head; + // 设置自己为 head 节点 + setHead(node); + // propagate 表示有共享资源(例如共享读锁或信号量),为 0 就没有资源 + if (propagate > 0 || h == null || h.waitStatus < 0 || + (h = head) == null || h.waitStatus < 0) { + // 获取下一个节点 + Node s = node.next; + // 如果当前是最后一个节点,或者下一个节点是【等待共享读锁的节点】 + if (s == null || s.isShared()) + // 唤醒后继节点 + doReleaseShared(); + } +} +``` + +```java +private void doReleaseShared() { + // 如果 head.waitStatus == Node.SIGNAL ==> 0 成功, 下一个节点 unpark + // 如果 head.waitStatus == 0 ==> Node.PROPAGATE + for (;;) { + Node h = head; + if (h != null && h != tail) { + int ws = h.waitStatus; + // SIGNAL 唤醒后继 + if (ws == Node.SIGNAL) { + // 因为读锁共享,如果其它线程也在释放读锁,那么需要将 waitStatus 先改为 0 + // 防止 unparkSuccessor 被多次执行 + if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0)) + continue; + // 唤醒后继节点 + unparkSuccessor(h); + } + // 如果已经是 0 了,改为 -3,用来解决传播性 + else if (ws == 0 && !compareAndSetWaitStatus(h, 0, Node.PROPAGATE)) + continue; + } + // 条件不成立说明被唤醒的节点非常积极,直接将自己设置为了新的 head, + // 此时唤醒它的节点(前驱)执行 h == head 不成立,所以不会跳出循环,会继续唤醒新的 head 节点的后继节点 + if (h == head) + break; + } +} +``` + + + +### StampedLock + +StampedLock:读写锁,该类自 JDK 8 加入,是为了进一步优化 ReentrantReadWriteLock **读性能** + +ReadWriteLock 支持两种模式:一种是读锁,一种是写锁。而 StampedLock 支持三种模式,分别是:**写锁**、**悲观读锁**和**乐观读**。 + +**乐观读这个操作是无锁的**,是允许一个线程获取写锁的。 + +特点: + +- 在使用读锁、写锁时都必须配合**戳**使用 +- StampedLock **不支持条件变量** +- StampedLock **不支持重入** + +基本用法 + +- 加解读锁: + + ```java + long stamp = lock.readLock(); + lock.unlockRead(stamp); // 类似于 unpark,解指定的锁 + ``` + +- 加解写锁: + + ```java + long stamp = lock.writeLock(); + lock.unlockWrite(stamp); + ``` + +- 乐观读,StampedLock 支持 `tryOptimisticRead()` 方法,读取完毕后做一次**戳校验**,如果校验通过,表示这期间没有其他线程的写操作,数据可以安全使用,如果校验没通过,需要重新获取读锁,保证数据一致性 + + ```java + long stamp = lock.tryOptimisticRead(); + // 验戳 + if(!lock.validate(stamp)){ + // 锁升级 + } + ``` + +注意: + +使用 StampedLock 一定不要调用中断操作,如果需要支持中断功能,一定使用可中断的悲观读锁 readLockInterruptibly() 和写锁 writeLockInterruptibly(),否则会导致 CPU 飙升。 + +```java +class Point { + private int x, y; + final StampedLock sl = + new StampedLock(); + // 计算到原点的距离 + int distanceFromOrigin() { + // 乐观读 + long stamp = + sl.tryOptimisticRead(); + // 读入局部变量, + // 读的过程数据可能被修改 + int curX = x, curY = y; + // 判断执行读操作期间, + // 是否存在写操作,如果存在, + // 则 sl.validate 返回 false + if (!sl.validate(stamp)){ + // 升级为悲观读锁 + stamp = sl.readLock(); + try { + curX = x; + curY = y; + } finally { + // 释放悲观读锁 + sl.unlockRead(stamp); + } + } + return Math.sqrt( + curX * curX + curY * curY); + } +} +``` + +在上面这个代码示例中,如果执行乐观读操作的期间,存在写操作,会把乐观读升级为悲观读锁。这个做法挺合理的,否则你就需要在一个循环里反复执行乐观读,直到执行乐观读操作的期间没有写操作(只有这样才能保证 x 和 y 的正确性和一致性),而循环读会浪费大量的 CPU。 + + + +### Semaphore + +信号量,用来限制能同时访问共享资源的线程上限。 + +初始化state一个值,如果来了一个线程则state-1,如果state的值小于 0,则当前线程将被阻塞,否则当前线程可以继续执行。 + +当一个线程执行完毕后将state+1,并唤醒阻塞队列中的一个等待线程。 + +构造方法: + +- `public Semaphore(int permits)`:permits 表示许可线程的数量(state) +- `public Semaphore(int permits, boolean fair)`:fair 表示公平性,如果设为 true,下次执行的线程会是等待最久的线程 + +常用API: + +- `public void acquire()`:表示获取许可 +- `public void release()`:表示释放许可,acquire() 和 release() 方法之间的代码为同步代码 + +```java +public static void main(String[] args) { + // 1.创建Semaphore对象 + Semaphore semaphore = new Semaphore(3); + + // 2. 10个线程同时运行 + for (int i = 0; i < 10; i++) { + new Thread(() -> { + try { + // 3. 获取许可 + semaphore.acquire(); + sout(Thread.currentThread().getName() + " running..."); + Thread.sleep(1000); + sout(Thread.currentThread().getName() + " end..."); + } catch (InterruptedException e) { + e.printStackTrace(); + } finally { + // 4. 释放许可 + semaphore.release(); + } + }).start(); + } +} +``` + + + +**实现原理** + +Semaphore 的 permits(state)为 3,这时 5 个线程来获取资源 + +```java +Sync(int permits) { + setState(permits); // 实际就是使用AQS的state +} +``` + +调用`semaphore.acquire()` ,线程尝试获取许可证,如果 `state >= 0` 的话,则表示可以获取成功。如果获取成功的话,使用 CAS 操作去修改 `state` 的值 `state=state-1`。如果 `state<0` 的话,则表示许可证数量不足。此时会创建一个 Node 节点加入阻塞队列,挂起当前线程。 + +调用`semaphore.release();` ,线程尝试释放许可证,并使用 CAS 操作去修改 `state` 的值 `state=state+1`。释放许可证成功之后,同时会唤醒同步队列中的一个线程。被唤醒的线程会重新尝试去修改 `state` 的值 `state=state-1` ,如果 `state>=0` 则获取令牌成功,否则重新进入阻塞队列,挂起线程。 + + + +### CountDownLatch + +`CountDownLatch` 允许 `count` 个线程阻塞在一个地方,直至所有线程的任务都执行完毕。 + +`CountDownLatch` 是一次性的,计数器的值只能在构造方法中初始化一次,之后没有任何机制再次对其设置值,当 `CountDownLatch` 使用完毕后,它不能再次被使用。 + + + +构造器: + +- `public CountDownLatch(int count)`:初始化唤醒需要的 down 几步 + +常用API: + +- `public void await() `:让当前线程等待,必须 down 完初始化的数字才可以被唤醒,否则进入无限等待 +- `public void countDown()`:计数器进行减 1(down 1) + +应用:等待多线程准备完毕、同步等待多个 Rest 远程调用结束 + +```java +AtomicInteger num = new AtomicInteger(0); + +ExecutorService service = Executors.newFixedThreadPool(10, (r) -> { + return new Thread(r, "t" + num.getAndIncrement()); +}); + +CountDownLatch latch = new CountDownLatch(10); + +String[] all = new String[10]; +Random r = new Random(); +for (int j = 0; j < 10; j++) { + int x = j; + service.submit(() -> { + for (int i = 0; i <= 100; i++) { + try { + Thread.sleep(r.nextInt(100)); + } catch (InterruptedException e) { + } + all[x] = Thread.currentThread().getName() + "(" + (i + "%") + ")"; + System.out.print("\r" + Arrays.toString(all)); + } + latch.countDown(); + }); +} + +latch.await(); +System.out.println("\n游戏开始..."); +service.shutdown(); + +/* +中间输出 +[t0(52%), t1(47%), t2(51%), t3(40%), t4(49%), t5(44%), t6(49%), t7(52%), t8(46%), t9(46%)] +最后输出 +[t0(100%), t1(100%), t2(100%), t3(100%), t4(100%), t5(100%), t6(100%), t7(100%), t8(100%), t9(100%)] +游戏开始... +*/ +``` + + + +**实现原理** + +`CountDownLatch` 是共享锁的一种实现,它默认构造 AQS 的 `state` 值为 `count`。 + +当线程使用 `countDown()` 方法时,其实使用了`tryReleaseShared`方法以 CAS 的操作来减少 `state`,直至 `state` 为 0 。 + +当调用 `await()` 方法的时候,如果 `state` 不为 0,那就证明任务还没有执行完毕,`await()` 方法就会一直阻塞。直到`count` 个线程调用了`countDown()`使 state 值被减为 0,或者调用`await()`的线程被中断,该线程才会从阻塞中被唤醒,`await()` 方法之后的语句得到执行。 + + + +### CyclicBarrier + +CyclicBarrier:循环屏障,用来进行线程协作,等待线程满足某个计数,才能触发自己执行. + +`CountDownLatch` 的实现是基于 AQS 的,而 `CycliBarrier` 是基于 `ReentrantLock`(`ReentrantLock` 也属于 AQS 同步器)和 `Condition` 的。 + +首先设置了屏障数量,当线程调用 await 的时候计数器会减一,如果计数器不等于0的时候,线程会调用 condition.await 进行阻塞等待。如果计数器值等于0,调用 condition.signalAll 唤醒等待的线程,并且重置计数器,然后开启下一代。 + +常用方法: + +- `public CyclicBarrier(int parties, Runnable barrierAction)`:用于在线程到达屏障 parties 时,执行 barrierAction + - parties:代表多少个线程到达屏障开始触发线程任务 + - barrierAction:线程任务 +- `public int await()`:线程调用 await 方法通知 CyclicBarrier 本线程已经到达屏障 + +与 CountDownLatch 的区别: + +1. CountDownLatch 是一个线程阻塞等待其他线程到达一个节点之后才能继续执行,这个过程其他线程不会阻塞;CyclicBarrier是各个线程阻塞等待所有线程都达到一个节点后,所有线程继续执行。 + +2. CyclicBarrier 是可以重用的 + +应用: + +可以实现多线程中,某个任务在等待其他线程执行完毕以后触发 + +```java +public static void main(String[] args) { + ExecutorService service = Executors.newFixedThreadPool(2); + CyclicBarrier barrier = new CyclicBarrier(2, () -> { + System.out.println("task1 task2 finish..."); + }); + + for (int i = 0; i < 3; i++) { // 循环重用 + service.submit(() -> { + System.out.println("task1 begin..."); + try { + Thread.sleep(1000); + barrier.await(); // 2 - 1 = 1 + } catch (InterruptedException | BrokenBarrierException e) { + e.printStackTrace(); + } + }); + + service.submit(() -> { + System.out.println("task2 begin..."); + try { + Thread.sleep(2000); + barrier.await(); // 1 - 1 = 0 + } catch (InterruptedException | BrokenBarrierException e) { + e.printStackTrace(); + } + }); + } + service.shutdown(); +} +``` + + + +### CompletableFuture + +更简洁的编写异步代码。如果任务之间有聚合关系,无论是 AND 聚合还是 OR 聚合,都可以通过 CompletableFuture 来解决 + +#### CompletableFuture 对象 + +```java +// 使用默认线程池 +// Runnable 接口的 run() 方法没有返回值,而 Supplier 接口的 get() 方法是有返回值的。 +static CompletableFuture runAsync(Runnable runnable) +static CompletableFuture supplyAsync(Supplier supplier) +// 可以指定线程池参数 +static CompletableFuture runAsync(Runnable runnable, Executor executor) +static CompletableFuture supplyAsync(Supplier supplier, Executor executor) +``` + +默认情况下 CompletableFuture 会使用公共的 ForkJoinPool 线程池,这个线程池默认创建的线程数是 CPU 的核数。 + +创建完 CompletableFuture 对象之后,会自动地异步执行 runnable.run() 方法或者 supplier.get() 方法。 + +#### CompletionStage 接口 + +CompletableFuture 类还实现了 CompletionStage 接口。任务是有时序关系的,比如有**串行关系、并行关系、汇聚关系**等,CompletionStage 接口可以清晰地描述任务之间的时序关系。 + +**串行关系** + +主要是 thenApply、thenAccept、thenRun 和 thenCompose 这四个系列的接口。 + +```java +CompletionStage thenApply(fn); +CompletionStage thenApplyAsync(fn); +CompletionStage thenAccept(consumer); +CompletionStage thenAcceptAsync(consumer); +CompletionStage thenRun(action); +CompletionStage thenRunAsync(action); +CompletionStage thenCompose(fn); +CompletionStage thenComposeAsync(fn); +``` + +thenApply 系列函数里参数 fn 的类型是接口 Function,这个接口里与 CompletionStage 相关的方法是 `R apply(T t)`,这个方法既能接收参数也支持返回值,所以 thenApply 系列方法返回的是`CompletionStage`。 + +而 thenAccept 系列方法里参数 consumer 的类型是接口`Consumer`,这个接口里与 CompletionStage 相关的方法是 `void accept(T t)`,这个方法虽然支持参数,但却不支持回值,所以 thenAccept 系列方法返回的是`CompletionStage`。 + +thenRun 系列方法里 action 的参数是 Runnable,所以 action 既不能接收参数也不支持返回值,所以 thenRun 系列方法返回的也是`CompletionStage`。 + +thenCompose 系列方法,这个系列的方法会新创建出一个子流程,最终结果和 thenApply 系列是相同的。 + +```java +CompletableFuture f0 = + CompletableFuture.supplyAsync( + () -> "Hello World") //① + .thenApply(s -> s + " QQ") //② + .thenApply(String::toUpperCase);//③ + +System.out.println(f0.join()); +// 输出结果 任务①②③却是串行执行的,②依赖①的执行结果,③依赖②的执行结果。 +HELLO WORLD QQ +``` + +**AND 汇聚关系** + +主要是 thenCombine、thenAcceptBoth 和 runAfterBoth 系列的接口,这些接口的区别也是源自 fn、consumer、action 这三个核心参数不同 + +```java +CompletionStage thenCombine(other, fn); +CompletionStage thenCombineAsync(other, fn); +CompletionStage thenAcceptBoth(other, consumer); +CompletionStage thenAcceptBothAsync(other, consumer); +CompletionStage runAfterBoth(other, action); +CompletionStage runAfterBothAsync(other, action); +``` + +**OR 汇聚关系** + +主要是 applyToEither、acceptEither 和 runAfterEither 系列的接口,这些接口的区别也是源自 fn、consumer、action 这三个核心参数不同。 + +```java +CompletionStage applyToEither(other, fn); +CompletionStage applyToEitherAsync(other, fn); +CompletionStage acceptEither(other, consumer); +CompletionStage acceptEitherAsync(other, consumer); +CompletionStage runAfterEither(other, action); +CompletionStage runAfterEitherAsync(other, action); +``` + +**异常处理** + +```java +CompletionStage exceptionally(fn); +CompletionStage whenComplete(consumer); +CompletionStage whenCompleteAsync(consumer); +CompletionStage handle(fn); +CompletionStage handleAsync(fn); +``` + +exceptionally() 的使用非常类似于 try{}catch{}中的 catch{},但是由于支持链式编程方式,所以相对更简单。 + +whenComplete() 和 handle() 系列方法就类似于 try{}finally{}中的 finally{},无论是否发生异常都会执行 whenComplete() 中的回调函数 consumer 和 handle() 中的回调函数 fn。whenComplete() 和 handle() 的区别在于 whenComplete() 不支持返回结果,而 handle() 是支持返回结果的。 + +```java +CompletableFuture + f0 = CompletableFuture + .supplyAsync(()->7/0)) + .thenApply(r->r*10) + .exceptionally(e->0); +System.out.println(f0.join()); +``` + + + +#### 使用建议 + +1. 使用自定义线程池: + +```java +private ThreadPoolExecutor executor = new ThreadPoolExecutor(10, 10, + 0L, TimeUnit.MILLISECONDS, + new LinkedBlockingQueue()); + +CompletableFuture.runAsync(() -> { + //... +}, executor); +``` + +2. `CompletableFuture`的`get()`方法是阻塞的,尽量避免使用。如果必须要使用的话,需要添加超时时间,否则可能会导致主线程一直等待,无法执行其他任务。 +3. 正确进行异常处理 +4. 合理组合多个任务 + +[asyncTool: 解决任意的多线程并行、串行、阻塞、依赖、回调的并行框架,可以任意组合各线程的执行顺序,带全链路执行结果回调。多线程编排一站式解决方案。来自于京东主App后台。 (gitee.com)](https://gitee.com/jd-platform-opensource/asyncTool) + +### CompletionService + +CompletionService 的实现原理也是内部维护了一个阻塞队列,当任务执行结束就把任务的执行结果的 Future 对象加入到阻塞队列中. + +CompletionService的实现目标是**任务先完成可优先获取到,即结果按照完成先后顺序排序。** + +CompletionService 接口的实现类是 ExecutorCompletionService,这个实现类的构造方法有两个,分别是: + +1. `ExecutorCompletionService(Executor executor)`; +2. `ExecutorCompletionService(Executor executor, BlockingQueue> completionQueue)`。 + +这两个构造方法都需要传入一个线程池,如果不指定 completionQueue,那么默认会使用无界的 LinkedBlockingQueue + +```java +public interface CompletionService { + // 提交 + Future submit(Callable task); + Future submit(Runnable task, V result); + // 获取 + Future take() throws InterruptedException; + Future poll(); + Future poll(long timeout, TimeUnit unit) throws InterruptedException; +} +``` + + + +### 线程安全集合类 + +1. 遗留的线程安全集合如 Hashtable, Vector,Stack:每个方法都用 synchronized 保证线程安全 + +2. 使用 Collections 装饰的线程安全集合类:接收一个线程不安全集合,使用装饰器模式添加一个mutex 成员变量,本质还是方法上添加 synchronized +3. java.util.concurrent.* + +java.util.concurrent.* 下的线程安全集合类,可以发现它们有规律,里面包含三类关键词: `Blocking、CopyOnWrite、Concurrent ` + +- Blocking 大部分实现基于锁,并提供用来阻塞的方法 +- CopyOnWrite 之类容器修改开销相对较重 +- Concurrent 类型的容器 + - 内部很多操作使用 cas 优化,一般可以提供较高吞吐量 + - 弱一致性 + - 遍历时弱一致性,例如,当利用迭代器遍历时,如果容器发生修改,迭代器仍然可以继续进行遍历,这时内容是旧的 + - 求大小弱一致性,size 操作未必是 100% 准确 + - 读取弱一致性 + + + +## ThreadLocal + +实现每一个线程都有自己专属的本地变量副本来避免共享,从而避免了线程安全问题。 + +### **ThreadLocal 的工作原理** + +ThreadLocal 的目标是让不同的线程有不同的变量 V,那最直接的方法就是创建一个 Map,它的 Key 是线程,Value 是每个线程拥有的变量 V,ThreadLocal 内部持有这样的一个 Map 就可以了。 + + + +`Thread`类有一个类型为`ThreadLocal.ThreadLocalMap`的实例变量`threadLocals`,也就是说每个线程有一个自己的`ThreadLocalMap`。 + +`ThreadLocalMap`有自己的独立实现,可以简单地将它的`key`视作`ThreadLocal`,`value`为代码中放入的值(实际上`key`并不是`ThreadLocal`本身,而是它的一个**弱引用**)。 + +每个线程在往`ThreadLocal`里放值的时候,都会往自己的`ThreadLocalMap`里存,读也是以`ThreadLocal`作为引用,在自己的`map`里找对应的`key`,从而实现了**线程隔离**。 + +我们还要注意`Entry`, 它的`key`是`ThreadLocal k` ,继承自`WeakReference`, 也就是我们常说的弱引用类型。 + + + +### **内存泄露** + +`ThreadLocalMap` 中使用的 key 为 `ThreadLocal` 的弱引用,而 value 是强引用。所以,如果 `ThreadLocal` 没有被外部强引用的情况下,在垃圾回收的时候,Entry.key 会被清理掉,而 Entry.value 不会被清理掉。 + +这样一来,`ThreadLocalMap` 中就会出现 key 为 null 的 Entry。假如我们不做任何措施的话,value 永远无法被 GC 回收,这个时候就可能会产生内存泄露。`ThreadLocalMap` 实现中已经考虑了这种情况,在调用 `set()`、`get()`、`remove()` 方法的时候,会清理掉 key 为 null 的记录。使用完 `ThreadLocal`方法后最好手动调用`remove()`方法 + +```java +ExecutorService es; +ThreadLocal tl; +es.execute(()->{ + //ThreadLocal 增加变量 + tl.set(obj); + try { + // 省略业务逻辑代码 + }finally { + // 手动清理 ThreadLocal + tl.remove(); + } +}); +``` + + + +### ThreadLocalMap Hash算法 + +既然是`Map`结构,那么`ThreadLocalMap`当然也要实现自己的`hash`算法来解决散列表数组冲突问题。 + +```java +int i = key.threadLocalHashCode & (len-1); +``` + +`ThreadLocalMap`中`hash`算法很简单,这里`i`就是当前 key 在散列表中对应的数组下标位置。 + +这里最关键的就是`threadLocalHashCode`值的计算,`ThreadLocal`中有一个属性为`HASH_INCREMENT = 0x61c88647` + +```java +public class ThreadLocal { + private final int threadLocalHashCode = nextHashCode(); + + private static AtomicInteger nextHashCode = new AtomicInteger(); + + private static final int HASH_INCREMENT = 0x61c88647; + + private static int nextHashCode() { + return nextHashCode.getAndAdd(HASH_INCREMENT); + } + + static class ThreadLocalMap { + ThreadLocalMap(ThreadLocal firstKey, Object firstValue) { + table = new Entry[INITIAL_CAPACITY]; + int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1); + + table[i] = new Entry(firstKey, firstValue); + size = 1; + setThreshold(INITIAL_CAPACITY); + } + } +} +``` + +每当创建一个`ThreadLocal`对象,这个`ThreadLocal.nextHashCode` 这个值就会增长 `0x61c88647` 。 + +这个值很特殊,它是**斐波那契数** 也叫 **黄金分割数**。`hash`增量为 这个数字,带来的好处就是 `hash` **分布非常均匀**。 + + + +### ThreadLocalMap Hash 冲突 + +`HashMap`中解决冲突的方法是在数组上构造一个**链表**结构,冲突的数据挂载到链表上,如果链表长度超过一定数量则会转化成红黑树。 + +而 `ThreadLocalMap` 中并没有链表结构,所以这里不能使用拉链法了,使用的是**线性探测法**。 + + + +### ThreadLocalMap.set() + +**第一种情况:** 通过`hash`计算后的槽位对应的`Entry`数据为空:直接将数据放到槽位即可 + +**第二种情况:** 槽位数据不为空,`key`值与当前`ThreadLocal`通过`hash`计算获取的`key`值一致:直接更新该槽位的数据。 + +**第三种情况:** 槽位数据不为空,往后遍历过程中,在找到`Entry`为`null`的槽位之前,没有遇到`key`过期的`Entry`: 遍历散列数组,线性往后查找,如果找到`Entry`为`null`的槽位,则将数据放入该槽位中,或者往后遍历过程中,遇到了**key 值相等**的数据,直接更新即可。 + +**第四种情况:** 槽位数据不为空,往后遍历过程中,在找到`Entry`为`null`的槽位之前,遇到`key`过期的`Entry`: + +![](.\JUC\set.png) + +散列数组下标为 7 位置对应的`Entry`数据`key`为`null`,表明此数据`key`值已经被垃圾回收掉了,此时就会执行`replaceStaleEntry()`方法,进行探测式数据清理工作。 + +数据清理工作: + +1)以当前`staleSlot`开始 向前迭代查找,找其他过期的数据,然后更新过期数据起始扫描下标`slotToExpunge`。`for`循环迭代,直到碰到`Entry`为`null`结束。`slotToExpunge`是为了更新探测清理过期数据的起始下标`slotToExpunge`的值 + +![](.\JUC\set12.png) + +2)然后从当前节点`staleSlot`向后查找`key`值相等的`Entry`元素,找到后更新`Entry`的值并交换`staleSlot`元素的位置(`staleSlot`位置为过期元素),更新`Entry`数据。如果没有找到相同 key 值的 Entry 数据:创建新的`Entry`,替换`table[stableSlot]`位置 + +![](.\JUC\set3.png) + +3)然后开始进行过期`Entry`的清理工作:`expungeStaleEntry()`探测式清理和`cleanSomeSlots()`启发式清理工作 + +![](.\JUC\set4.png) + + + +### 清理工作 + +`ThreadLocalMap`的两种过期`key`数据清理方式:**探测式清理**和**启发式清理**。 + +探测式清理,也就是`expungeStaleEntry`方法,遍历散列数组,向后探测清理过期数据,将过期数据的`Entry`设置为`null`,沿途中碰到未过期的数据则将此数据`rehash`后重新在`table`数组中定位,如果定位的位置已经有了数据,则会将未过期的数据放到最靠近此位置的`Entry=null`的桶中,使`rehash`后的`Entry`数据距离正确的桶的位置更近一些。 + +`set`和`get`到都会触发**探测式清理**操作。 + + + +启发式清理:执行logn次探测式清理 + + + +### 扩容机制 + +在`ThreadLocalMap.set()`方法的最后,如果执行完启发式清理工作后,未清理到任何数据,且当前散列数组中`Entry`的数量已经达到了列表的扩容阈值`(len*2/3)`,就开始执行`rehash()`逻辑: + +`rehash()`首先是会进行探测式清理工作,从`table`的起始位置往后清理。清理完成之后,`table`中可能有一些`key`为`null`的`Entry`数据被清理掉,所以此时通过判断`size >= threshold - threshold / 4` 也就是`size >= threshold * 3/4` 来决定是否扩容。 + +扩容后的`tab`的大小为`oldLen * 2`,然后遍历老的散列表,重新计算`hash`位置,然后放到新的`tab`数组中,如果出现`hash`冲突则往后寻找最近的`entry`为`null`的槽位,遍历完成之后,`oldTab`中所有的`entry`数据都已经放入到新的`tab`中了。 + + + +### ThreadLocalMap.get() + +**第一种情况:** 通过查找`key`值计算出散列表中`slot`位置,然后该`slot`位置中的`Entry.key`和查找的`key`一致,则直接返回 + +**第二种情况:** `slot`位置中的`Entry.key`和要查找的`key`不一致:往后遍历查找,如果时遇到`key=null`,触发一次探测式数据回收操作,执行`expungeStaleEntry()`方法,直到Entry为null或找到匹配值。 + + + diff --git "a/docs/Java/JUC/64\344\275\215\351\224\201\347\212\266\346\200\201.jpeg" "b/docs/Java/JUC/64\344\275\215\351\224\201\347\212\266\346\200\201.jpeg" new file mode 100644 index 0000000..d1352d5 Binary files /dev/null and "b/docs/Java/JUC/64\344\275\215\351\224\201\347\212\266\346\200\201.jpeg" differ diff --git a/docs/Java/JUC/AQS.png b/docs/Java/JUC/AQS.png new file mode 100644 index 0000000..08ceae5 Binary files /dev/null and b/docs/Java/JUC/AQS.png differ diff --git a/docs/Java/JUC/DCL.png b/docs/Java/JUC/DCL.png new file mode 100644 index 0000000..8d2e317 Binary files /dev/null and b/docs/Java/JUC/DCL.png differ diff --git "a/docs/Java/JUC/Executor\346\241\206\346\236\266\347\232\204\344\275\277\347\224\250\347\244\272\346\204\217\345\233\276-8GKgMC9g.png" "b/docs/Java/JUC/Executor\346\241\206\346\236\266\347\232\204\344\275\277\347\224\250\347\244\272\346\204\217\345\233\276-8GKgMC9g.png" new file mode 100644 index 0000000..c6d135b Binary files /dev/null and "b/docs/Java/JUC/Executor\346\241\206\346\236\266\347\232\204\344\275\277\347\224\250\347\244\272\346\204\217\345\233\276-8GKgMC9g.png" differ diff --git a/docs/Java/JUC/JMM.png b/docs/Java/JUC/JMM.png new file mode 100644 index 0000000..f3c2bef Binary files /dev/null and b/docs/Java/JUC/JMM.png differ diff --git a/docs/Java/JUC/ReentrantLock.png b/docs/Java/JUC/ReentrantLock.png new file mode 100644 index 0000000..8d312c0 Binary files /dev/null and b/docs/Java/JUC/ReentrantLock.png differ diff --git a/docs/Java/JUC/ThreadLocal.png b/docs/Java/JUC/ThreadLocal.png new file mode 100644 index 0000000..bb1ffac Binary files /dev/null and b/docs/Java/JUC/ThreadLocal.png differ diff --git a/docs/Java/JUC/ThreadPoolExecutor1.png b/docs/Java/JUC/ThreadPoolExecutor1.png new file mode 100644 index 0000000..de5869b Binary files /dev/null and b/docs/Java/JUC/ThreadPoolExecutor1.png differ diff --git a/docs/Java/JUC/ThreadPoolExecutorState.png b/docs/Java/JUC/ThreadPoolExecutorState.png new file mode 100644 index 0000000..1352413 Binary files /dev/null and b/docs/Java/JUC/ThreadPoolExecutorState.png differ diff --git a/docs/Java/JUC/acquireQueued.png b/docs/Java/JUC/acquireQueued.png new file mode 100644 index 0000000..5a2cb2b Binary files /dev/null and b/docs/Java/JUC/acquireQueued.png differ diff --git a/docs/Java/JUC/addWaiter.png b/docs/Java/JUC/addWaiter.png new file mode 100644 index 0000000..4d501b3 Binary files /dev/null and b/docs/Java/JUC/addWaiter.png differ diff --git a/docs/Java/JUC/await.png b/docs/Java/JUC/await.png new file mode 100644 index 0000000..0e6b386 Binary files /dev/null and b/docs/Java/JUC/await.png differ diff --git a/docs/Java/JUC/await1.png b/docs/Java/JUC/await1.png new file mode 100644 index 0000000..43e4dca Binary files /dev/null and b/docs/Java/JUC/await1.png differ diff --git a/docs/Java/JUC/executor-class-diagram.png b/docs/Java/JUC/executor-class-diagram.png new file mode 100644 index 0000000..01c91d8 Binary files /dev/null and b/docs/Java/JUC/executor-class-diagram.png differ diff --git a/docs/Java/JUC/isignal1.png b/docs/Java/JUC/isignal1.png new file mode 100644 index 0000000..cdb1c8c Binary files /dev/null and b/docs/Java/JUC/isignal1.png differ diff --git "a/docs/Java/JUC/mark_word\347\273\223\346\236\204.png" "b/docs/Java/JUC/mark_word\347\273\223\346\236\204.png" new file mode 100644 index 0000000..2b27f9b Binary files /dev/null and "b/docs/Java/JUC/mark_word\347\273\223\346\236\204.png" differ diff --git "a/docs/Java/JUC/mark_word\347\273\223\346\236\204_64.png" "b/docs/Java/JUC/mark_word\347\273\223\346\236\204_64.png" new file mode 100644 index 0000000..74b44fc Binary files /dev/null and "b/docs/Java/JUC/mark_word\347\273\223\346\236\204_64.png" differ diff --git a/docs/Java/JUC/monitor.png b/docs/Java/JUC/monitor.png new file mode 100644 index 0000000..a91a581 Binary files /dev/null and b/docs/Java/JUC/monitor.png differ diff --git a/docs/Java/JUC/park.png b/docs/Java/JUC/park.png new file mode 100644 index 0000000..1ef84c4 Binary files /dev/null and b/docs/Java/JUC/park.png differ diff --git a/docs/Java/JUC/parkAndCheckInterrupt.png b/docs/Java/JUC/parkAndCheckInterrupt.png new file mode 100644 index 0000000..4081084 Binary files /dev/null and b/docs/Java/JUC/parkAndCheckInterrupt.png differ diff --git a/docs/Java/JUC/release.png b/docs/Java/JUC/release.png new file mode 100644 index 0000000..0c2576d Binary files /dev/null and b/docs/Java/JUC/release.png differ diff --git a/docs/Java/JUC/set.png b/docs/Java/JUC/set.png new file mode 100644 index 0000000..50d3248 Binary files /dev/null and b/docs/Java/JUC/set.png differ diff --git a/docs/Java/JUC/set12.png b/docs/Java/JUC/set12.png new file mode 100644 index 0000000..aeb4ed6 Binary files /dev/null and b/docs/Java/JUC/set12.png differ diff --git a/docs/Java/JUC/set3.png b/docs/Java/JUC/set3.png new file mode 100644 index 0000000..1076d86 Binary files /dev/null and b/docs/Java/JUC/set3.png differ diff --git a/docs/Java/JUC/set4.png b/docs/Java/JUC/set4.png new file mode 100644 index 0000000..b9e71fd Binary files /dev/null and b/docs/Java/JUC/set4.png differ diff --git a/docs/Java/JUC/sihnal.png b/docs/Java/JUC/sihnal.png new file mode 100644 index 0000000..7650caa Binary files /dev/null and b/docs/Java/JUC/sihnal.png differ diff --git a/docs/Java/JUC/synchronized.webp b/docs/Java/JUC/synchronized.webp new file mode 100644 index 0000000..025a05b Binary files /dev/null and b/docs/Java/JUC/synchronized.webp differ diff --git a/docs/Java/JUC/unpark-park.png b/docs/Java/JUC/unpark-park.png new file mode 100644 index 0000000..c4aef49 Binary files /dev/null and b/docs/Java/JUC/unpark-park.png differ diff --git a/docs/Java/JUC/unpark.png b/docs/Java/JUC/unpark.png new file mode 100644 index 0000000..f77024d Binary files /dev/null and b/docs/Java/JUC/unpark.png differ diff --git "a/docs/Java/JUC/volatile\345\206\231.jpeg" "b/docs/Java/JUC/volatile\345\206\231.jpeg" new file mode 100644 index 0000000..c75b0a3 Binary files /dev/null and "b/docs/Java/JUC/volatile\345\206\231.jpeg" differ diff --git "a/docs/Java/JUC/volatile\350\257\273.jpeg" "b/docs/Java/JUC/volatile\350\257\273.jpeg" new file mode 100644 index 0000000..8f48b24 Binary files /dev/null and "b/docs/Java/JUC/volatile\350\257\273.jpeg" differ diff --git "a/docs/Java/JUC/volatile\351\207\215\346\216\222\345\272\217\350\247\204\345\210\231\350\241\250.jpeg" "b/docs/Java/JUC/volatile\351\207\215\346\216\222\345\272\217\350\247\204\345\210\231\350\241\250.jpeg" new file mode 100644 index 0000000..e1f39ad Binary files /dev/null and "b/docs/Java/JUC/volatile\351\207\215\346\216\222\345\272\217\350\247\204\345\210\231\350\241\250.jpeg" differ diff --git a/docs/Java/JUC/wait-notify.png b/docs/Java/JUC/wait-notify.png new file mode 100644 index 0000000..e6cbfdd Binary files /dev/null and b/docs/Java/JUC/wait-notify.png differ diff --git "a/docs/Java/JUC/\345\201\217\345\220\221\351\224\201\347\232\204\350\216\267\345\276\227\345\222\214\346\222\244\351\224\200.jpeg" "b/docs/Java/JUC/\345\201\217\345\220\221\351\224\201\347\232\204\350\216\267\345\276\227\345\222\214\346\222\244\351\224\200.jpeg" new file mode 100644 index 0000000..3b9fc2f Binary files /dev/null and "b/docs/Java/JUC/\345\201\217\345\220\221\351\224\201\347\232\204\350\216\267\345\276\227\345\222\214\346\222\244\351\224\200.jpeg" differ diff --git "a/docs/Java/JUC/\345\244\232\344\270\252\345\244\261\350\264\245.png" "b/docs/Java/JUC/\345\244\232\344\270\252\345\244\261\350\264\245.png" new file mode 100644 index 0000000..c5eb658 Binary files /dev/null and "b/docs/Java/JUC/\345\244\232\344\270\252\345\244\261\350\264\245.png" differ diff --git "a/docs/Java/JUC/\345\257\271\350\261\241\345\244\264.jpeg" "b/docs/Java/JUC/\345\257\271\350\261\241\345\244\264.jpeg" new file mode 100644 index 0000000..5af79fd Binary files /dev/null and "b/docs/Java/JUC/\345\257\271\350\261\241\345\244\264.jpeg" differ diff --git "a/docs/Java/JUC/\345\257\271\350\261\241\345\244\264\345\255\230\345\202\250\347\273\223\346\236\204.jpeg" "b/docs/Java/JUC/\345\257\271\350\261\241\345\244\264\345\255\230\345\202\250\347\273\223\346\236\204.jpeg" new file mode 100644 index 0000000..ee00048 Binary files /dev/null and "b/docs/Java/JUC/\345\257\271\350\261\241\345\244\264\345\255\230\345\202\250\347\273\223\346\236\204.jpeg" differ diff --git "a/docs/Java/JUC/\345\267\245\344\275\234\345\216\237\347\220\206.png" "b/docs/Java/JUC/\345\267\245\344\275\234\345\216\237\347\220\206.png" new file mode 100644 index 0000000..aa94a2e Binary files /dev/null and "b/docs/Java/JUC/\345\267\245\344\275\234\345\216\237\347\220\206.png" differ diff --git "a/docs/Java/JUC/\346\225\260\347\273\204\345\257\271\350\261\241\345\244\264.png" "b/docs/Java/JUC/\346\225\260\347\273\204\345\257\271\350\261\241\345\244\264.png" new file mode 100644 index 0000000..55989e9 Binary files /dev/null and "b/docs/Java/JUC/\346\225\260\347\273\204\345\257\271\350\261\241\345\244\264.png" differ diff --git "a/docs/Java/JUC/\346\231\256\351\200\232\345\257\271\350\261\241\345\244\264.png" "b/docs/Java/JUC/\346\231\256\351\200\232\345\257\271\350\261\241\345\244\264.png" new file mode 100644 index 0000000..68eae0d Binary files /dev/null and "b/docs/Java/JUC/\346\231\256\351\200\232\345\257\271\350\261\241\345\244\264.png" differ diff --git "a/docs/Java/JUC/\347\272\277\347\250\213\347\212\266\346\200\201.png" "b/docs/Java/JUC/\347\272\277\347\250\213\347\212\266\346\200\201.png" new file mode 100644 index 0000000..658ead4 Binary files /dev/null and "b/docs/Java/JUC/\347\272\277\347\250\213\347\212\266\346\200\201.png" differ diff --git "a/docs/Java/JUC/\350\207\252\345\256\232\344\271\211\347\272\277\347\250\213\346\261\240.png" "b/docs/Java/JUC/\350\207\252\345\256\232\344\271\211\347\272\277\347\250\213\346\261\240.png" new file mode 100644 index 0000000..8f3e8e3 Binary files /dev/null and "b/docs/Java/JUC/\350\207\252\345\256\232\344\271\211\347\272\277\347\250\213\346\261\240.png" differ diff --git "a/docs/Java/JUC/\350\257\273\345\206\231\351\224\201.png" "b/docs/Java/JUC/\350\257\273\345\206\231\351\224\201.png" new file mode 100644 index 0000000..68aaa56 Binary files /dev/null and "b/docs/Java/JUC/\350\257\273\345\206\231\351\224\201.png" differ diff --git "a/docs/Java/JUC/\350\257\273\345\206\231\351\224\201\345\205\261\344\272\253\351\224\201.png" "b/docs/Java/JUC/\350\257\273\345\206\231\351\224\201\345\205\261\344\272\253\351\224\201.png" new file mode 100644 index 0000000..dc0c704 Binary files /dev/null and "b/docs/Java/JUC/\350\257\273\345\206\231\351\224\201\345\205\261\344\272\253\351\224\201.png" differ diff --git "a/docs/Java/JUC/\350\275\273\351\207\217\347\272\247\351\224\201.jpeg" "b/docs/Java/JUC/\350\275\273\351\207\217\347\272\247\351\224\201.jpeg" new file mode 100644 index 0000000..1c12733 Binary files /dev/null and "b/docs/Java/JUC/\350\275\273\351\207\217\347\272\247\351\224\201.jpeg" differ diff --git "a/docs/Java/JUC/\350\275\273\351\207\217\347\272\247\351\224\2011.png" "b/docs/Java/JUC/\350\275\273\351\207\217\347\272\247\351\224\2011.png" new file mode 100644 index 0000000..80ea08c Binary files /dev/null and "b/docs/Java/JUC/\350\275\273\351\207\217\347\272\247\351\224\2011.png" differ diff --git "a/docs/Java/JUC/\350\275\273\351\207\217\347\272\247\351\224\2012.png" "b/docs/Java/JUC/\350\275\273\351\207\217\347\272\247\351\224\2012.png" new file mode 100644 index 0000000..f50d436 Binary files /dev/null and "b/docs/Java/JUC/\350\275\273\351\207\217\347\272\247\351\224\2012.png" differ diff --git "a/docs/Java/JUC/\350\275\273\351\207\217\347\272\247\351\224\2013.png" "b/docs/Java/JUC/\350\275\273\351\207\217\347\272\247\351\224\2013.png" new file mode 100644 index 0000000..d93ce65 Binary files /dev/null and "b/docs/Java/JUC/\350\275\273\351\207\217\347\272\247\351\224\2013.png" differ diff --git "a/docs/Java/JUC/\350\275\273\351\207\217\347\272\247\351\224\2014.png" "b/docs/Java/JUC/\350\275\273\351\207\217\347\272\247\351\224\2014.png" new file mode 100644 index 0000000..b352f93 Binary files /dev/null and "b/docs/Java/JUC/\350\275\273\351\207\217\347\272\247\351\224\2014.png" differ diff --git "a/docs/Java/JUC/\350\275\273\351\207\217\347\272\247\351\224\2015.png" "b/docs/Java/JUC/\350\275\273\351\207\217\347\272\247\351\224\2015.png" new file mode 100644 index 0000000..ef4c1bd Binary files /dev/null and "b/docs/Java/JUC/\350\275\273\351\207\217\347\272\247\351\224\2015.png" differ diff --git "a/docs/Java/JUC/\351\224\201\347\212\266\346\200\201\345\217\230\345\214\226.jpeg" "b/docs/Java/JUC/\351\224\201\347\212\266\346\200\201\345\217\230\345\214\226.jpeg" new file mode 100644 index 0000000..0bb6282 Binary files /dev/null and "b/docs/Java/JUC/\351\224\201\347\212\266\346\200\201\345\217\230\345\214\226.jpeg" differ diff --git "a/docs/Java/JUC/\351\224\201\350\206\250\350\203\200.png" "b/docs/Java/JUC/\351\224\201\350\206\250\350\203\200.png" new file mode 100644 index 0000000..8b81c30 Binary files /dev/null and "b/docs/Java/JUC/\351\224\201\350\206\250\350\203\200.png" differ diff --git "a/docs/Java/JUC/\351\224\201\350\206\250\350\203\2000.png" "b/docs/Java/JUC/\351\224\201\350\206\250\350\203\2000.png" new file mode 100644 index 0000000..80047ba Binary files /dev/null and "b/docs/Java/JUC/\351\224\201\350\206\250\350\203\2000.png" differ diff --git "a/docs/Java/JUC/\351\235\236\345\205\254\345\271\263\351\224\201.png" "b/docs/Java/JUC/\351\235\236\345\205\254\345\271\263\351\224\201.png" new file mode 100644 index 0000000..4c2eb72 Binary files /dev/null and "b/docs/Java/JUC/\351\235\236\345\205\254\345\271\263\351\224\201.png" differ diff --git a/docs/Java/Java.md b/docs/Java/Java.md new file mode 100644 index 0000000..a8b08cd --- /dev/null +++ b/docs/Java/Java.md @@ -0,0 +1,1776 @@ +## 基本数据类型 + +用 new 创建对象(特别是小的、简单的变量)并不是非常有效,因为new 将对象置于“堆”里。对于这些类型,Java 采纳了与 C 和 C++相同的方法。也就是说,不是用 new 创建变量,而是创建一个并非句柄的“自动”变量。这个变量容纳了具体的值,并置于堆栈中,能够更高效地存取。 + +boolean(1) + +char(16) + +byte(8)、short(16)、int(32)、long(64) + +float(32)、double(64) + + + +数值类型全都是有符号(正负号)的,所以不必费劲寻找没有符号的类型。 + + + +## BigInteger 和 BigDecimal + +BigInteger 适合保存比较大的整数 + +BigDecimal 适合保存精度更高的浮点数 + +能对int 或 float 做的事情,对BigInteger 和BigDecimal 一样可以做。只是必须使用方法调用,不能使用运算符。例如`add, subtract, multiply, divide `,不能直接使用`+ - * /` + + + +**BigDecimal** + +我们在使用 `BigDecimal` 时,为了防止精度丢失,推荐使用它的`BigDecimal(String val)`构造方法或者 `BigDecimal.valueOf(double val)` 静态方法来创建对象,valueOf内部执行toString方法。 + +使用 `divide` 方法的时候尽量使用 3 个参数版本,并且`RoundingMode` 不要选择 `UNNECESSARY`,否则很可能会遇到 `ArithmeticException`(无法除尽出现无限循环小数的时候),其中 `scale` 表示要保留几位小数,`roundingMode` 代表保留规则。 + +```java +public BigDecimal divide(BigDecimal divisor, int scale, RoundingMode roundingMode) { + return divide(divisor, scale, roundingMode.oldMode); +} +``` + + + +BigDecimal大小比较应使用compareTo()方法,而不是equals()方法。因为 `equals()` 方法不仅仅会比较值的大小(value)还会比较精度(scale),而 `compareTo()` 方法比较的时候会忽略精度。 + + + +## 作用域 + +作用域是由花括号的位置决定的。Java 对象不具备与基本类型一样的存在时间,用 new 关键字创建一个Java 对象的时候,它会超出作用域的范围之外。 + +```java +{ + String s = new String("a string"); +} /* 作用域的终点 */ +``` + +句柄s会在作用域的终点处消失。然而,s 指向的 String 对象依然占据着内存空间。在上面这段代码里,我们没有办法访问对象,因为指向它的唯一一个句柄已超出了作用域的边界。 + +Java 有一个特别 的“垃圾收集器”,它会查找用new 创建的所有对象,并辨别其中哪些不再被引用。随后,它会自动释放由那些闲置对象占据的内存,以便能由新对象使用。 + + + +## 类型转换 + +自动类型转换 + +* 精度小的类型自动转换为精度大的类型 + +* **byte、short、char不会相互转换** + +* **byte, short, char 三者之间可以计算,在计算时首先转换为int类型** + +* boolean类型不参与自动转换 + +强制类型转换: 使用强制转换符`()` + +* 强制符号只针对最近的操作数有效,可以使用小括号提升优先级 + +基本数据类型和字符串之间转换 + +* 基本数据类型->字符串:基本数据类型 + "" + +* 字符串->基本数据类型:通过基本数据类型的包装类调用parseXXX方法即可 + + ```java + int num = Integer.parseInt(s); + ``` + + + +## 运算符 + +**赋值**: + +对基本数据类型的赋值是非常直接的。由于基本类型容纳了实际的值,而且并非指向一个对象的句柄,所以在为其赋值的时候,可将来自一个地方的内容复制到另一个地方。 + +但在为对象“赋值”的时候,情况却发生了变化。对一个对象进行操作时,我们真正操作的是它的句柄。所以倘若“从一个对象到另一个对象”赋值,实际就是将句柄从一个地方复制到另一个地方。 + +**算术运算符**: + +中包括加号(+)、减号(-)、除号 (/)、乘号(*)以及模数(%,从整数除法中获得余数) + +取模:本质:a%b = a - a / b * b。a为小数,则:a%b = a - (int)a / b * b + +**自动递增、递减**: + +对于前递增和前递减(如++A 或--A),会先执行运算,再生成值。而对于后递增和后递减(如A++或A--), 会先生成值,再执行运算。 + +**关系运算符**: + +关系运算符包括小于(<)、大于 (>)、小于或等于(<=)、大于或等于(>=)、等于(==)以及不等于(!=)。等于和不等于适用于所有内建的数据类型,但其他比较不适用于boolean 类型。 + +若想对比两个对象的实际内容是否相同,又该如何操作呢?此时,必须使用所有对象都适用的特殊方法 equals()。但这个方法不适用于“主类型”,那些类型直接使用==和!=即可。 + +**逻辑运算符**: + +逻辑运算符 AND(&&)、OR(||)以及 NOT(!)能生成一个布尔值(true 或 false)。操作逻辑运算符时,我们会遇到一种名为“短路”的情况。因此,一个逻辑表达式的所有部分都有可能不进行求值。 + +**按位运算符**: + +与(&)、或(|)、非(~)、异或(^) + +**移位运算符**: + +左移位运算符(<<)能将运算符左边的运算对象向左移动运算符右侧指定的位数(在低位补 0)。 + +“有符号”右移位运算符(>>)则将运算符左边的运算对象向右移动运算符右侧指定的位数。“有符号”右移位运算符使用了 “符号扩展”:若值为正,则在高位插入 0;若值为负,则在高位插入1。 + +Java 也添加了一种“无符号”右移位运算符(>>>),它使用了“零扩展”:无论正负,都在高位插入0。 + + + +若对char,byte 或者short 进行移位处理,那么在移位进行之前,它们会自动转换成一个 int。只有移位数字右侧的 5 个低位才会用到。这样可防止我们在一个 int 数里移动不切实际的位数。 + +若对一个 long 值进行处理,最后得到的结果也是long。此时只会用到移位数字右侧的 6 个低位,防止移动超过 long 值里现成的位数。 + +**三元运算符**: + +布尔表达式 ? 值 0:值 1 + +**字符运算符+**: + +有一方为字符串时,连接不同的字串。 + +**造型运算符**: + +“造型”(Cast)的作用是“与一个模型匹配”。在适当的时候,Java 会将一种数据类型自动转换成另一 种。例如,假设我们为浮点变量分配一个整数值,计算机会将 int 自动转换成 float。通过造型,我们可明确设置这种类型的转换,或者在一般没有可能进行的时候强迫它进行。 为进行一次造型,要将括号中希望的数据类型(包括所有修改符)置于其他任何值的左侧。 + +Java 允许我们将任何主类型“造型”为其他任何一种主类型,但布尔值(bollean)要除外,后者根本不允许进行任何造型处理。“类”不允许进行造型。 + +对主数据类型执行任何算术或按位运算,只要它们“比int 小”(即 char,byte 或者 short),那么在正式执行运算之前,那些值会自动转换成int。这样一来,最终生成的值就是int 类型。所以只要把一个值赋回较小的类型,就必须使用“造型”。此外,由于是将值赋回给较小的类型,所以可能出现信息丢失的情况。通常,表达式中最大的数据类型是决定了表达式最终结果大小的那个类型。若将一个 float 值与一个double 值相乘,结果就是 double;如将一个 int 和一个 long 值相加,则结果为 long。 + + + +## 可变参数 + +* java中允许将同一个类中**多个同名同功能但参数个数不同**的方法,封装成一个方法。通过可变参数实现 + + ``` + 修饰符 返回类型 方法名(数据类型... 形参名){} + public int sum(int... nums){}//nums当作数组使用 + ``` + +* 可变参数的实参可以是0个或任意多个 + +* 可变参数实参可以是数组,可变参数的本质就是数组 + +* 可变参数可以和普通类型的参数一起放在形参列表,但必须保证**可变参数在最后** + +* 一个形参列表**只能出现一个**可变参数 + + + +## 访问修饰符 + +| 访问级别 | 修饰符 | 本类 | 同包 | 子类 | 不同包 | +| -------- | --------- | ---- | ---- | ---- | ------ | +| 公开 | public | √ | √ | √ | √ | +| 受保护 | protected | √ | √ | √ | × | +| 默认 | | √ | √ | × | × | +| 私有 | private | √ | × | × | × | + +修饰符 public 表示对所有类可⻅。 + +修饰符 protected 表示对同⼀包内的类和所有⼦类可⻅。⼦类可以访问⽗类中声明为 protected 的成员, ⽽不管⼦类与⽗类是否在同⼀包中。 + +如果没有使⽤任何访问修饰符(即没有写 public 、 protected 、 private ),则默认为包级别访问。这意 味着只有同⼀包中的类可以访问。 + +修饰符 private 表示对同⼀类内可⻅。私有成员只能在声明它们的类中访问 + +修饰符可以用来修饰属性,成员方法和类,只有默认和`public`才能修饰类 + + + +## 面向对象 + +面向对象:把类或对象作为基本单元来组织代码。 + +**封装** + +将对象的字段和方法封装在一个类中,并通过访问控制来隐藏对象的内部实现细节。外部不能直接访问对象的内部数据,只能通过提供的公共方法来操作数据。 + +**继承** + +继承是一种机制,允许子类继承父类的属性和方法,通过继承,子类可以复用父类的代码,并可以在子类中扩展或重写父类的方法。 + +```java +class 子类 extends 父类{ + // +} +``` + +* 子类继承了所有属性和方法,但是私有属性和方法不能在子类直接访问,要通过父类公共的方法 +* 子类必须调用父类的构造器完成父类的初始化 +* 当创建子类的对象时,默认总会去调用父类的无参构造器,如果父类没有提供无参构造器,则必须在子类的构造器中用`super(参数列表);`去指定使用父类的哪个构造器完成父类的初始化工作,否则编译不会通过。 +* `super()`和`this()`都只能放在构造器第一行,因此不能共存在一个构造器 +* 所有类都是`Object`类的子类 +* 子类只能继承一个父类,即单继承机制 + + + +构建器的调用遵照下面的顺序: + +(1) 调用基础类构建器。这个步骤会不断重复下去,首先得到构建的是分级结构的根部,然后是下一个衍生类,等等。直到抵达最深一层的衍生类。 + +(2) 按声明顺序调用成员初始化模块。 + +(3) 调用衍生构建器的主体。 + + + +this的两个含义: + +1. 指示隐式参数的引用,可为已调用了其方法的那个对象生成相应的句柄 +2. 调用该类的其他构造 + +super的两个含义: + +1. 调用超类的方法 +2. 调用超类的构造器 + +调用构造器的语句只能作为另一个构造器的第一条语句出现 + +| 区别点 | this | super | +| ---------- | ---------------------------------------- | ---------------------------- | +| 访问属性 | 访问本类中属性,如果没有从父类中继续查找 | 从父类开始查找属性 | +| 调用方法 | 访问本类中方法,如果没有从父类中继续查找 | 从父类开始查找方法 | +| 调用构造器 | 调用本类构造器,必须放在首行 | 调用父类构造器,必须放在首行 | +| 特殊 | 表示当前对象 | 子类中访问父类对象 | + + + +**多态** + +指同一个方法或对象在不同场景下可以表现出不同的行为。Java中主要通过重载和重写实现。 + +多态的具体表现: + +* 方法的多态: + 1. 重写:子类重写父类的方法 + 2. 重载:同一个类中可以有多个同名方法,但参数列表不同 +* 对象的多态: + 1. 一个对象的编译类型(对象类型)和运行类型(引用类型)可以不一致 + 2. 编译类型在定义对象时就确定了,不能改变 + 3. 运行类型是可以变化的 + 4. 编译类型看等号左边,运行类型看等号右边 + + + +重写: + +* 子父类中的重写方法在对应的class文件常量池的位置相同,一旦子类没有重写,那么子类的实例就会沿着这个位置往上找,直到找到父类的同名方法。 +* 重写只发生在可见的实例方法中:静态方法不存在重写;私有方法不存在重写; +* 重写满足一个规则:两同 两小 一大 + * 两同:方法名、参数列表相同 + * 两小:重写方法的返回值和抛出异常类型要和被重写方法的相同或是其子类。 + * 一大:修饰符 >= 被重写方法的修饰符 + + + +## 深拷贝和浅拷贝 + +**浅拷贝**:浅拷⻉创建⼀个新对象,然后将原对象的⾮静态字段复制到新对象。如果字段是基本数据类型,那么就复制其值;如果字段是引⽤类型,复制的就是引⽤地址,和原对象共用同一个内部对象 + +**深拷贝**:创建⼀个新对象,并递归复制原对象中的所有引⽤类型的字段指向的对象,⽽不是共享引⽤。因此, 新对象和原对象中的引⽤类型字段引⽤的是两组不同的对象。 + +**引用拷贝**:引用拷贝就是两个不同的引用指向同一个对象。 + +![](.\Java\shallow&deep-copy.png) + + + +## Object类 + +==运算符 + +* 对于基本数据类型来说,`==` 比较的是值。 +* 对于引用数据类型来说,`==` 比较的是对象的内存地址。 + +equals方法 + +* 不能用于判断基本数据类型的变量,只能用来判断两个对象是否相等。 +* 默认判断地址是否相等,子类往往重写该方法,用于判断内容是否相等 + +hashCode + +* 哈希值主要根据地址号来的,但不能将哈希值等价子地址 + +toString + +* 默认返回:全类名@哈希值的十六进制 + +finalize + +* 当对象被回收时,系统自动调用该对象的finalize方法,子类可以重写该方法用于一些资源释放操作 +* 当对象没有任何引用时,jvm就认为该对象是一个垃圾对象,就会使用垃圾回收机制销毁对象 +* 垃圾回收机制的调用,由系统决定,也可以通过 `System.gc()` 主动触发垃圾回收机制 + + + +## static + +**类变量/静态变量** + +* `static`变量被对象所共享,在**类加载的时候就生成** +* 静态变量保存在class实例的尾部,而class对象**保存在堆中** + +* 生命周期为类加载到类消亡 + + + +**类方法/静态方法** + +静态方法是不在对象上执行的方法,没有隐式参数,没有this(非静态方法中,this指向这个方法的隐式参数)。不能在对象上执行操作,但是对象可以调用静态方法,建议使用类名调用静态方法 + +当方法中不涉及任何对象相关的成员或一些通用的方法,可以设计成静态方法提高开发效率 + +* 类方法和普通方法都随着类加载而加载,将结构信息存储到方法区 +* 类方法中**不允许使用和对象有关的关键字**,如`this`和`super` +* **静态方法只能访问静态变量或静态方法**,非静态方法可以访问静态成员和非静态成员 + + + +**静态代码块** + +类加载时执行并且只执行一次 + + + +**静态内部类** + +静态成员可以被类直接访问,而不需要创建类实例;而静态成员无法直接访问非静态类,因为非静态成员依赖类实例。 + + + +## main方法 + +* main方法是虚拟机调用,访问权限必须是public +* 执行main方法时不必创建对象,所以必须是static +* 接收String类型的数组形参,保存运行时传递的参数 +* main方法中可以直接使用所在类的静态属性和静态方法;访问非静态成员,必须创建对象去调用 + + + +## 代码块 + +代码块又叫初始化块,在加载类或创建对象时隐式调用 + +``` +[修饰符]{ + 代码 +} +``` + +* 修饰符只能选`static`,分别称为静态代码块和普通代码块。**静态代码块随类的加载而执行,只执行一次,而普通代码块每创建对象都会执行** + +* 静态代码块只能调用静态成员,普通代码块可以调用任意成员 +* 好处: + * 相当于另一种形式的构造器,可以做初始化操作,代码块的**调用优先于构造器** + * 如果多个构造器中都有重复语句,可以抽取到代码块中,提高复用性 + +**类什么时候加载?** + +1. 创建对象实例(new) + +2. 创建子类对象实例,父类也会被加载 + +3. 使用类的静态成员时 + +**创建一个对象时,在一个类调用顺序** + +1. 调用静态代码块和静态属性初始化(多个则按照定义顺序) +2. 普通代码块和普通属性初始化 +3. 调用构造方法。构造器的最前面其实隐藏了`super()`和调用普通代码块 + +**创建一个子类对象时,调用顺序** + +1. 父类的静态代码块和静态属性 +2. 子类的静态代码块和静态属性 +3. 父类的普通代码块和普通属性初始化 +4. 父类的构造方法 +5. 子类的普通代码块和普通属性初始化 +6. 子类的构造方法 + + + +## final + +**final数据**: + +许多程序设计语言都有自己的办法告诉编译器某个数据是“常数”。常数主要应用于下述两个方面: + +(1) 编译期常数,它永远不会改变 + +(2) 在运行期初始化的一个值,我们不希望它发生变化 + +对于编译期的常数,编译器(程序)可将常数值“封装”到需要的计算过程里。也就是说,计算可在编译期间提前执行,从而节省运行时的一些开销。在 Java 中,这些形式的常数必须属于基本数据类型(Primitives),而且要用 final 关键字进行表达。在对这样的一个常数进行定义的时候,必须给出一个值。 + +无论static 还是 final 字段,都只能存储一个数据,而且不得改变。 + +对于基本数据类型,final 会将值变成一个常数;但对于对象句柄,final 会将句柄变成一个常数。进行声明时,必须将句柄初始化到一个具体的对象。而且永远不能将句柄变成指向另一个对象。然而,对象本身是可以修改的。Java 对此未提供任何手段,可将一个对象直接变成一个常数(但是,我们可自己编写一个类,使其中的对象具有 “常数”效果)。这一限制也适用于数组,它也属于对象。 + +final数据必须赋初值,以后不能修改,初始化位置: + +1. 定义时 +2. 在构造器中 +3. 在代码块中 + + + +**final方法**: + +之所以要使用final 方法,可能是出于对两方面理由的考虑。第一个是为方法“上锁”,防止任何继承类改变它的本来含义。设计程序时,若希望一个方法的行为在继承期间保持不变,而且不可被覆盖或改写,就可以采取这种做法。 + +采用final 方法的第二个理由是程序执行的效率。将一个方法设成 final 后,编译器就可以把对那个方法的所有调用都置入“嵌入”调用里。只要编译器发现一个final 方法调用,它会用方法主体内实际代码的一个副本来替换方法调用。这样做可避免方法调用时的系统开销。当然,若方法体积太大,那么程序也会变得雍肿,可能受到到不到嵌入代码所带来的任何性能提升。因为任何提升都被花在方法内部的时间抵消了。Java 编译器能自动侦测这些情况,并颇为“明智”地决定是否嵌入一个 final 方法。 + +通常,只有在方法的代码量非常少,或者想明确禁止方法被覆盖的时候,才应考虑将一个方法设为 final。 + +类内所有private 方法都自动成为final。由于我们不能访问一个 private 方法,所以它绝对不会被其他方法覆盖。 + + + +**final类**: + +将类定义成 final 后,结果只是禁止进行继承——没有更多的限制。然 而,由于它禁止了继承,所以一个 final 类中的所有方法都默认为final。因为此时再也无法覆盖它们。所以与我们将一个方法明确声明为final 一样,编译器此时有相同的效率选择。 + + + + +## 抽象类 + +某些方法不确定实现时可以声明为抽象方法让子类来实现,含抽象方法的类称为抽象类。 + +抽象类本质是一个类(包含属性、方法、构造器、代码块、内部类),只不过是一种特殊的类,即使有构造器这种类也不能被实例化为对象,只能被子类继承。 + +声明为抽象方法:`public abstract void eat();` 没有方法体,此时类也必须声明为`abstract`类 + +注意: + +* 抽象类不能被实例化 +* 抽象类不一定要包含`abstract`方法,一旦包含`abstract`方法就必须声明为抽象类 +* `abstract`只能用于声明类和方法 +* 如果一个类继承了抽象类,则它必须实现抽象类的所有抽象方法,除非它自己也声明为抽象类 + +* 抽象方法不能用`private`, `final`, `static`来修饰,因为这些关键字于重写相违背。静态方法可以被子类继承,但不能被重写 + + + +## 接口 + +“interface”(接口)关键字使抽象的概念更深入了一层。我们可将其想象为一个“纯”抽象类。它允许创建者规定一个类的基本形式:方法名、自变量列表以及返回类型,但不规定方法主体。接口也包含了基本数据类型的数据成员,但它们都默认为static 和final。接口只提供一种形式,并不提供实施的细节。 + +可决定将一个接口中的方法声明明确定义为“public”。但即便不明确定义,它们也会默认为 public。所以在实现一个接口的时候,来自接口的方法必须定义成public。 + +注意: + +* 接口方法默认public,接口字段默认 public static final +* `JDK7.0`之前 接口里的所有方法都没有方法体,即都是抽象方法;`JDK8.0`后接口可以有静态方法(加`static`),默认方法(加`default`关键字修饰),也就是说接口中可以有方法的具体实现 +* 接口不能被实例化 +* 接口中所有方法都是 public 方法和 abstract 方法,接口中抽象方法可以不用abstract修饰 +* 一个普通类实现接口就必须将该接口的所有方法都实现 +* 抽象类实现接口可以不用实现接口的方法 +* 一个类可以同时实现多个接口 +* 接口不能继承类,但可以继承别的接口 +* 接口的修饰符只能是public和默认,和类一样 + + + +**接口和抽象类有什么共同点和区别**? + +1. 抽象类主要用于代码复用,是一种is-a关系。接口仅仅是对方法的抽象,是一种 has-a 关系,表示具有某一组行为特性,是为了解决解耦问题,隔离接口和具体的实现,提高代码的扩展性。 + +2. 接⼝⽀持多继承,⼀个类可以实现多个接口,⼀个类只能继承⼀个抽象类。 + +3. 接⼝中的⽅法默认是 `public abstract` 的。接⼝中的变量默认是 `public static final` 的。 抽象类中的抽象⽅法默认是 `protected` 的,具体⽅法的访问修饰符可以是 public 、 protected 或 private + +4. 接口不含构造器,抽象类可以包含构造器 + + + +## 内部类 + +一个类的内部又完整的嵌套了另一个类结构,被嵌套的类称为内部类。内部类最大的特点是可以直接访问外部类的属性,并且可以直接体现类之间的包含关系。 + +实际上内部类是一个编译层面的概念,像一个语法糖一样,经过编译器之后其实内部类会提升为外部顶级类,和外部类没有区别。 + +### 局部内部类 + +类似局部变量,定义在外部类的局部位置,在方法、代码块中,有类名 + +* **不能添加访问修饰符,因为它的地位是一个局部变量**,局部变量是不能使用修饰符的。但可以使用final修饰 +* 只能访问final变量和形参 +* 作用域:仅仅在定义它的方法或代码块中 +* 如果外部类和内部类重名时,默认遵守就近原则,如果想访问外部类的成员,则可以用`外部类名.this.成员`访问 + +### 匿名内部类 + +定义在外部类的局部位置,比如方法中,没有类名,同时还是一个对象 + +一个接口/类的方法的某个实现方式在程序中**只会执行一次**,但为了使用它,我们需要创建它的实现类/子类去实现重写。此时可以使用匿名内部类的方式,可以**无需创建新的类,减少代码冗余**。 + +* 匿名内部类既是一个类的定义,同时也是一个对象,只能创建匿名内部类的一个实例 + +* **没有构造器**,没有静态资源 + +* 可以访问外部类的所有成员,包括私有的 +* **不能添加修饰符和static,因为它的地位就是一个局部变量** +* 作用域:仅仅是在定义它的方法或代码块中 +* 外部其他类不能访问匿名类 +* 如果外部类和内部类重名时,默认遵守就近原则,如果想访问外部类的成员,则可以用`外部类名.this.成员`访问 + +* 可以当作实参直接传递,简洁高效 + +### 成员内部类 + +* 定义在外部类的成员位置,并且**没有static修饰** +* 可以直接使用外部类的所有成员,包括私有成员 +* **可以添加任意的访问修饰符,因为它的地位是一个成员** +* **本身内部不能有静态属性**,因为自己本身就需要依靠外部类的实例化 +* 作用域:和外部类的其他成员一样,为整个类体 +* 外部其他类访问内部类 + * `外部类名.内部类名 对象名 = 外部类名.new 内部类名();` + * `外部类名.内部类名 对象名 = 外部对象.get();` + * `new 外部类名().new 内部类();` + +### 静态内部类 + +类似类的静态成员属性 + +* 定义在外部类的成员位置,**有static修饰** +* 可以**访问外部类所有静态成员**,不能访问非静态成员 +* 可以添加任意修饰符 +* 作用域:整个类体 +* 外部其他类访问内部类 + * 通过类名直接访问,但要满足访问权限 + * get方法 + +* 如果外部类和内部类重名时,默认遵守就近原则,如果想访问外部类的成员,则可以用`外部类名.成员`访问 + + + +## 枚举类 + +实现: + +1. 构造器私有化 +2. 对外暴露对象 `public final static ` +3. 提供get方法,不提供set方法 + +细节: + +* 使用`enum`代替`class`声明枚举类,就不能继承其他类了(因为**隐式继承`Enum`类**,`java`是单继承机制),但是可以继承接口 + +* 使用`enum`关键字开发一个枚举类时,默认会继承`Enum`类, 而且是一个final类 +* 枚举简化为`枚举对象(参数列表)` +* 使用无参构造器创建枚举对象,则实参和小括号都可以省略 +* 当有多个枚举对象时,使用`,`分隔,最后一个`;`结尾 +* 枚举对象必须放在枚举类首行 + +```java +enum Season +{ + SPRING("1","1"),WINTER("2","2"); + private String name; + private String desc; + private Season(String name, String desc){} +} +``` + + + +## 异常 + +![](Java\异常.jpeg) + +执行过程中发生的异常分为两大类 + +1. Error(错误):Java虚拟机无法解决的严重问题,如`JVM`系统内部错误、资源耗尽等。比如`StackOverflowError`和`OOM(out of memory)` + +2. Exception:其他因编程或偶然的外在因素导致的一般问题,可以使用针对性的代码处理,分为两大类:运行时异常和编译时异常。编译异常程序中必须处理,运行时异常程序中没有处理默认是`throws` + 1. 运行时异常: + 1. 空指针异常 NullPointerException + 2. 数学运算异常 ArithmeticException + 3. 数组下标越界异常 ArrayIndexOutOfBoundsException + 4. 类型转换异常 ClassCastException + 5. 数字格式不正确异常 NumberFormatException + + 2. 编译时异常: + 1. 数据库操作异常 SQLException + 2. 文件操作异常 IOException + 3. 文件不存在 FileNotFoundException + 4. 类不存在 ClassNotFoundException + 5. 文件末尾发生异常 EOPException + 6. 参数异常 IllegalArguementException + + + + +`try-catch-finally`:捕获异常,自行处理 + +```java +try{ + // +}catch(EXception e){ + // 当异常发生时异常后面的代码不执行,直接进入catch,系统将异常封装成Exception对象e传递给catch,得到异常后程序员自己处理 + // 异常没有发生,catch不执行 + // 在实际开发中,通常将编译异常转换为允许异常抛出,调用者可以捕获或抛出 throw new RuntimeException(e); +}finally{ + // 不管代码是否有异常,finally都要执行 + // 通常将释放资源的代码放在finally,保证关闭 +} +``` + +可以有多个`catch`语句,捕获不同的异常,要求父类异常在后,子类异常在前,比如`Exception`在后,`NullPointException`在前,如果发生异常,只会匹配一个`catch` + +可以进行`try-finally`配合使用,相当于没有捕获异常,因此程序会直接奔溃。应用场景:执行一段代码不管是否发生异常都必须执行某个业务逻辑 + +finally子句中包含return语句时肯产生意想不到的结果,finally中的return语句将会覆盖原来的返回值。不要把改变控制流的语句(return, throw, break, continue)放在finally子句中 + + + +`throws`:将异常抛出,交给调用者处理,最顶级的处理者是`JVM` + +编译异常程序中必须处理,运行时异常程序中没有处理默认是`throws` + +子类重写父类的方法时,对抛出异常的规定:子类重写的方法抛出的异常类型要么和父类抛出的异常一致,要么为父类抛出异常类型的子类型 + +在`throws`过程中,如果有`try-catch`,相当于处理异常,就可以不必`throws` + + + +自定义异常:`异常类名 extends Exception/RuntimeException`如果继承`Exception`,属于编译异常;如果继承`RuntimeException`,属于运行异常,一般继承`RuntimeException` + +| | 意义 | 位置 | 后面跟的东西 | +| ------ | ---------------------- | ---------- | ------------ | +| throws | 异常处理的一种方式 | 方法声明处 | 异常类型 | +| throw | 手动生成异常对象关键字 | 方法体中 | 异常对象 | + + + +try-with-resources语句: + +```java +try (Resource res = ...) { // 可以指定多个资源, ';'间隔 + //work with res +} +``` + +当try退出时会自动调用 res.close() + + + +**finally总是会被执⾏吗?** + +⼀般来说, finally 块都会在 try 或 catch 块执⾏完毕后被执⾏,即使发⽣了异常。然⽽,有⼀些情况下 finally 块可能不会执⾏,主要是在以下情况: + +1. 在 try 或 catch 块中调⽤了 System.exit() : 调⽤ System.exit() 会导致Java虚拟机(JVM)退出, 此时 finally 块中的代码不会被执⾏。 +2. 在 try 块中发⽣了死循环: 如果在 try 块中发⽣了⽆限循环或者其他永远不会结束的操作, finally 块 可能⽆法执⾏。 +3. 程序所在线程死亡或关闭CPU + + + +注意: + +1. 尽量不要捕获类似Exception这样的异常,而是捕获特定的异常。 +2. 不要吞了异常,最好输入到日志中 +3. 不要延迟处理异常,否则堆栈信息会很多 +4. try-catch范围尽可能小 +5. 不要通过异常来控制程序流程 +6. 不要在finally代码块中处理返回值或者直接return + + + +## String、StringBuffer、StringBuilder + +**String** + +有属性`private final char value[]`, value赋值后不可以修改,指不能指向新的地址,但单个字符内容可以改变。在 Java 9 之后,`String`、`StringBuilder` 与 `StringBuffer` 的实现改用 `byte` 数组存储字符串,不同编码占用字节不同-为了节省空间。 + +`String` 真正不可变有下面几点原因: + +1. 保存字符串的数组被 `final` 修饰且为私有的,并且`String` 类没有提供/暴露修改这个字符串的方法。 +2. `String` 类被 `final` 修饰导致其不能被继承,进而避免了子类破坏 `String` 不可变。 + +`String` 中的 `equals` 方法是被重写过的,比较的是 String 字符串的值是否相等。 `Object` 的 `equals` 方法是比较的对象的内存地址。 + +**StringBuffer** + +解决了String大量拼接字符串时产生许多无用的中间对象问题,提供append和add等方法,可以将字符串添加到已有序列的末尾或指定位置。 + +本质是一个线程安全的可修改的字符序列,把所有修改数据的方法都加上`synchronized` ,保证了线程安全。 + +**StringBuilder** + +和StringBuffer本质上没什么区别,就是去掉了保证线程安全的那部分,减少了开销。 + +`StringBuilder` 与 `StringBuffer` 都继承自 `AbstractStringBuilder` 类,在 `AbstractStringBuilder` 中使用char数组保存字符串(JDK9以后是byte数组),不过没有使用 `final` 和 `private` 关键字修饰,是**可变的**, + + + +**字符串常量池**: + +```java +String s1 = "str"; +// 先从常量池查看是否有"str"的引用,如果有直接返回引用;如果没有则堆中创建然后将引用保存到常量池中。s1最终指向的是常量池的空间地址 + +String s2 = new String("str"); +// 先在堆中创建对象,里面维护的value属性保存常量池"str"的引用。如果常量池有"str"引用,直接返回;如果没有则堆中创建然后将引用保存到常量池中。s2最终指向的是堆中的空间地址。 +``` + +```java +String str1 = "str"; +String str2 = "ing"; +String str3 = "str" + "ing";//常量相加: 编译器会优化,常量池中只有"string", str3指向常量池中的"string" +String str4 = str1 + str2;//变量相加: str4指向堆再通过堆中value指向常量池中的"string", 常量池中有”str", "ing", "string" +String str5 = "string"; +System.out.println(str3 == str4);//false +System.out.println(str3 == str5);//true +System.out.println(str4 == str5);//false + +final String str1 = "str"; +final String str2 = "ing"; +// 下面两个表达式其实是等价的 +String c = "str" + "ing";// 常量池中的对象 +String d = str1 + str2; // 常量池中的对象 +System.out.println(c == d);// true 字符串使用 final 关键字声明之后,可以让编译器当做常量来处理。 +``` + + + +**String.intern()** + +`String.intern()` 是一个 native(本地)方法,其作用是将指定的字符串对象的引用保存在字符串常量池中,可以简单分为两种情况: + +1. 如果字符串常量池中保存了对应的字符串对象的引用,就直接返回该引用。 + +2. 如果字符串常量池中没有保存了对应的字符串对象的引用,那就在常量池中创建一个指向该字符串对象的引用并返回 + + + +总结: + +* String(不可变,线程安全) +* StringBuffer(可变、线程安全) +* StringBuilder(可变、⾮线程安全) + +操作少量的数据: 适用 `String` + +单线程操作字符串缓冲区下操作大量数据: 适用 `StringBuilder` + +多线程操作字符串缓冲区下操作大量数据: 适用 `StringBuffer` + + + +## 泛型 + +泛型又称参数化类型,解决数据的安全性问题 + +* 编译时,检查添加元素的类型,提高了安全性 + +* 减少了类型转换的次数,提高效率 + +* 可以在类声明时通过一个标识表示类中某个属性的类型,或者是某个方法的返回值类型,或是参数类型 + +* 泛型指定数据类型时要求是引用类型,不能是基本数据类型 + +* 给泛型指定类型后,可以传入该类型或其子类类型 + +* 如果不指定泛型,默认是Object + +* 泛型没有继承性 `List list = new ArrayList();//错误` + + + +**泛型类** + +```java +class 类名{//泛型标识符可以有多个,一般单个大写字母 + // +} +``` + +注意细节: + +1. 普通成员可以使用泛型(属性, 方法) +2. 使用泛型的数组,不能初始化。例如不能:`T[] t = new T[8]`; //因为数组在new时不能确定T的类型无法在内存开辟空间。 +3. 静态方法和属性中不能使用泛型。因为静态是和类相关的,类加载时对象还没有创建 +4. 泛型类的类型,是在创建对象时确定的 +5. 如果创建对象时没有指定类型,默认Object + +**泛型接口** + +```java +interface 接口名{} +``` + +注意细节: + +1. 接口中静态成员也不能使用泛型 +2. 泛型接口的类型,是在继承接口或实现接口时确定的 +3. 没有指定泛型默认Object + +**泛型方法** + +```java +修饰符 返回类型 方法名(参数列表){} +``` + +注意细节: + +1. 泛型方法可以定义在普通类中,也可以定义在泛型类中 +2. 当泛型方法被调用时,类型会确定 +3. public void eat(E e){}不是泛型方法,而是使用了泛型 + +**通配符** + +```java +: 支持任意泛型类型 +: 支持A类和A类的子类,规定了泛型的上限 +: 支持A类和A类的父类,不限于直接父类,规定了下限 +``` + + + +**泛型擦除:** + +泛型是一个语法糖,Java虚拟机不认识泛型,泛型会在编译阶段通过泛型擦除的方式进行解语法糖. + +擦除类型参数: + +在泛型类或泛型方法中,所有的类型参数都会在编译时被擦除: + +- 如果泛型类型参数没有指定上界,编译器会将其替换为`Object`。 +- 如果泛型类型参数指定了上界(例如``),编译器会将其替换为上界类型(这里是`Number`)。 + + + +类型检查与类型转换: + +虽然在运行时泛型类型信息被擦除,但在编译时,编译器会进行类型检查,以确保泛型的使用符合类型安全的要求。在编译后的字节码中,会插入类型转换代码,以确保获取到正确的类型: + +```java +Box stringBox = new Box(); +stringBox.set("Hello"); +String item = (String) stringBox.get(); +``` + + + +擦除方法的重载: + +由于泛型类型在编译时被擦除,可能导致方法签名的冲突。例如: + +```java +public class Example { + public void method(List list) {} + public void method(List list) {} +} +``` + +这两个方法在类型擦除后,其方法签名都变为`method(List list)`,从而引发编译错误。Java不允许在同一类中声明这些方法。 + + + +## 序列化和方序列化 + +序列化:将数据结构或对象转换成二进制字节流的过程 + +反序列化:将在序列化过程中所生成的二进制字节流转换成数据结构或者对象的过程 + +序列化的主要目的是通过网络传输对象或者说是将对象存储到文件系统、数据库、内存中。 + +比较常用的序列化协议有 Hessian、[Kryo]([EsotericSoftware/kryo: Java binary serialization and cloning: fast, efficient, automatic (github.com)](https://github.com/EsotericSoftware/kryo))、[Protobuf]([protocolbuffers/protobuf: Protocol Buffers - Google's data interchange format (github.com)](https://github.com/protocolbuffers/protobuf))、[ProtoStuff]([protocolbuffers/protobuf: Protocol Buffers - Google's data interchange format (github.com)](https://github.com/protocolbuffers/protobuf)),这些都是基于二进制的序列化协议。JDK 自带的序列化方式一般不会用 ,因为序列化效率低并且存在安全问题。Kryo 是专门针对 Java 语言序列化方式并且性能非常好 + +**JDK自带的序列化** + +JDK 自带的序列化,只需实现 `java.io.Serializable`接口即可。 + + + +**serialVersionUID 有什么作用?** + +```java +private static final long serialVersionUID = 1905122041950251207L; +``` + +序列化号 `serialVersionUID` 用来验证序列化和反序列化对象的ID是否一致的。反序列化时,会检查 `serialVersionUID` 是否和当前类的 `serialVersionUID` 一致。如果 `serialVersionUID` 不一致则会抛出 `InvalidClassException` 异常。强烈推荐每个序列化类都手动指定其 `serialVersionUID`,如果不手动指定,那么编译器会动态生成默认的 `serialVersionUID`。 + + + +**Java序列化不包含静态变量?** + +Java序列化机制只会保存对象的实例变量的状态,而不会保存静态变量的状态。 + +静态变量是类级别的变量,它们被所有类的实例共享。序列化的主要目的是保存和恢复对象的实例状态,以便在不同的时间或不同的环境中能够重建对象。由于静态变量不是实例的一部分,它们的值在类加载时就已经初始化并存在,因此它们不需要被序列化和恢复。 + + + +**serialVersionUID 不是被 static 变量修饰了吗?为什么还会被“序列化”?** + +`static` 修饰的变量是静态变量,本身是不会被序列化的。但是,`serialVersionUID` 的序列化做了特殊处理,在序列化时,会将 `serialVersionUID` 的值写入到序列化的二进制字节流中;在反序列化时,也会解析它并做一致性判断。 + + + +**如果有些字段不想进行序列化怎么办?** + +对于不想进行序列化的变量,可以使用 `transient` 关键字修饰。 + +`transient` 关键字的作用是:阻止实例中那些用此关键字修饰的的变量序列化;当对象被反序列化时,被 `transient` 修饰的变量值不会被持久化和恢复。 + +关于 `transient` 还有几点注意: + +- `transient` 只能修饰变量,不能修饰类和方法。 +- `transient` 修饰的变量,在反序列化后变量值将会被置成类型的默认值。例如,如果是修饰 `int` 类型,那么反序列后结果就是 `0`。 +- `static` 变量因为不属于任何对象(Object),所以无论有没有 `transient` 关键字修饰,均不会被序列化。 + + + +## IO流 + +### 字节流 + +**InputStream(字节输入流)**: + +* `FileInputStream` 是一个比较常用的字节输入流对象,可直接指定文件路径,可以直接读取单字节数据,也可以读取至字节数组中。不过,一般我们是不会直接单独使用 `FileInputStream` ,通常会配合 `BufferedInputStream` + +* `DataInputStream` 用于读取指定类型数据,不能单独使用,必须结合其它流,比如 `FileInputStream` 。 +* `ObjectInputStream` 用于从输入流中读取 Java 对象(反序列化),`ObjectOutputStream` 用于将对象写入到输出流(序列化)。用于序列化和反序列化的类必须实现 `Serializable` 接口,对象中如果有属性不想被序列化,使用 `transient` 修饰。 + +**OutputStream(字节输出流)**: + +* `FileOutputStream` 是最常用的字节输出流对象,可直接指定文件路径,可以直接输出单字节数据,也可以输出指定的字节数组。类似于 `FileInputStream`,`FileOutputStream` 通常也会配合 `BufferedOutputStream`(字节缓冲输出流,后文会讲到)来使用。 +* `DataOutputStream` 用于写入指定类型数据,不能单独使用,必须结合其它流,比如 `FileOutputStream` 。 +* `ObjectInputStream` 用于从输入流中读取 Java 对象(`ObjectInputStream`,反序列化),`ObjectOutputStream`将对象写入到输出流(`ObjectOutputStream`,序列化) + + + +### 字符流 + +**Reader(字符输入流)**: + +`InputStreamReader` 是字节流转换为字符流的桥梁,其子类 `FileReader` 是基于该基础上的封装,可以直接操作字符文件。 + +**Writer(字符输出流)**: + +`OutputStreamWriter` 是字符流转换为字节流的桥梁,其子类 `FileWriter` 是基于该基础上的封装,可以直接将字符写入到文件。 + +### 字节缓冲流 + +`BufferedInputStream` 从源头(通常是文件)读取数据(字节信息)到内存的过程中不会一个字节一个字节的读取,而是会先将读取到的字节存放在缓存区,并从内部缓冲区中单独读取字节。这样大幅减少了 IO 次数,提高了读取效率。 + +`BufferedInputStream` 内部维护了一个缓冲区,这个缓冲区实际就是一个字节数组。缓冲区的大小默认为 **8192** 字节 + + + +`BufferedOutputStream` 类似于 `BufferedInputStream` + + + +### 字符缓冲流 + +`BufferedReader` (字符缓冲输入流)和 `BufferedWriter`(字符缓冲输出流)类似于 `BufferedInputStream`(字节缓冲输入流)和`BufferedOutputStream`(字节缓冲输入流),内部都维护了一个字节数组作为缓冲区。 + +### 打印流 + +`System.out` 实际是用于获取一个 `PrintStream` 对象,`print`方法实际调用的是 `PrintStream` 对象的 `write` 方法。 + +`PrintStream` 属于字节打印流,与之对应的是 `PrintWriter` (字符打印流)。`PrintStream` 是 `OutputStream` 的子类,`PrintWriter` 是 `Writer` 的子类。 + +### 随机访问流 + +随机访问流指的是支持随意跳转到文件的任意位置进行读写的 `RandomAccessFile` 。 + +`RandomAccessFile` 比较常见的一个应用就是实现大文件的 **断点续传** + + + +### I/O模型 + +1. **BIO(Blocking I/O)**:读取或写⼊数据时,线程将⼀直等待,直到数据准备就绪或者写⼊操作完成, 但在⾼ 并发环境下可能导致性能问题,因为线程在等待 I/O 操作完成时被阻塞,⽆法执⾏其他任务。 + +2. **NIO(Non-blocking I/O)**: 在⾮阻塞 I/O 模型中,线程执⾏⼀个 I/O 操作时不会等待,⽽是继续执⾏其他任务, 这需要通过轮询(polling)或 者使⽤回调函数等机制来检查 I/O 操作是否完成。 +3. **IO多路复⽤**:I/O 多路复⽤模型使⽤了操作系统提供的选择器(Selector)机制,例如 Java 中的 Selector 类。通过选择器,⼀ 个线程可以监听多个通道上的 I/O 事件,从⽽在单线程中处理多个连接。 +4. **AIO(Asynchronous I/O)**:AIO 允许程序发起⼀个I/O操作,并在操作完成时得到通知。在这个过程中,程序可以继续执⾏其他任务⽽⽆需等 待I/O操作完成,当操作完成之后,进⾏回调。 + + + +## 反射机制 + +反射机制允许程序在执行期间借助于ReflectionAPI取得任何类的内部信息,并能操作对象的属性及方法。 + +加载完类之后,在堆中产生一个Class类型的对象(一个类只有一个Class对象),这个对象包含类的完整结构信息。 + +反射相关的主要类: + +```java +java.lang.Class //代表一个类,Class对象表示某个类加载后在堆中的对象 +java.lang.reflect.Method //代表类方法 +java.lang.reflect.Field //代表类成员变量 +java.lang.reflect.Constructor//代表类构造方法 +``` + + + +**优缺点** + +​ 优点:可以动态的创建和使用对象(框架底层核心),使用灵活 + +​ 缺点:使用反射是解释执行,对执行速度有影响,安全问题 + + + +**反射调用优化:关闭访问检查** + +Method和Field、Constructor对象都有setAccessible()方法,作用是启动和禁用访问安全检查,参数为true表示反射的对象在使用时取消访问检查,提高反射效率 + + + +**获取 Class 对象的四种方式** + +如果我们动态获取到这些信息,我们需要依靠 Class 对象。Class 类对象将一个类的方法、变量等信息告诉运行的程序。Java 提供了四种方式获取 Class 对象: + +1. 知道具体类的情况下可以使用: + + ```java + Class clazz = TargetObject.class; + ``` + + 但是我们一般是不知道具体类的,基本都是通过遍历包下面的类来获取 Class 对象,通过此方式获取 Class 对象不会进行初始化 + +2. 通过 `Class.forName()`传入类的全路径获取: + + 这种方式会触发类的初始化,静态代码块会被执行 + + ```java + Class clazz = Class.forName("com.javaguide.TargetObject"); + ``` + +3. 通过对象实例`instance.getClass()`获取: + + ```java + TargetObject o = new TargetObject(); + Class clazz = o.getClass(); + ``` + +4. 通过类加载器`xxxClassLoader.loadClass()`传入类路径获取: + + ```java + ClassLoader.getSystemClassLoader().loadClass("com.javaguide.TargetObject"); + ``` + + 通过类加载器获取 Class 对象不会进行初始化,意味着不进行包括初始化等一系列步骤,静态代码块和静态对象不会得到执行 + + + +**通过反射获取类结构信息**: + +```java +getName:获取全类名 +getSimpleName:获取简单类名 +getFields:获取所有public属性,包含本类和父类 +getDeclaredFields:获取所有属性 +getMethods:获取所有public方法 +getDeclaredMethods:获取所有方法 +getModifiers:以int形式返回修饰符(默认修饰符是0,public是1,private是2,protected是4,static是8,final是16) +getType:以Class形式返回类型 +... +``` + + + +**通过反射创建对象方式** + +1. 获取类的构造器 + + ```java + Class clazz = Class.forName("cn.javaguide.TargetObject"); + Constructor constructor = clazz.getDeclaredConstructor(参数列表.class);//f即为对应属性 + ``` + +2. 使用newInstance(): 调用构造器 + + ```java + Object obj = constructor.newInstance(object); + ``` + + + +**通过反射访问类中成员** + +1. 根据属性名获取Field对象 //getField获取public属性, getDeclaredField获取所有类型属性 + + ```java + Class clazz = Class.forName("cn.javaguide.TargetObject"); + Field f = clazz.getDeclaredField(属性名);//f即为对应属性 + ``` + +2. 爆破:`f.setAccessible(true)` + +3. 访问: + + ``` + f.set(targetObject, 值);//object为对象 + f.get(targetObject); + //如果是静态属性,set和get中的参数object,可以写成null + ``` + + + +**通过反射访问方法** + +1. 根据方法名和参数列表获取Method方法对象 + + ```java + Class clazz = Class.forName("cn.javaguide.TargetObject"); + Method m = clazz.getDeclaredMethod(方法名, 参数列表.class); + ``` + +2. 获取对象 :`Object targetObject = clazz.newInstance();` + +3. 爆破:`m.setAccessible(true);` //私有的需要爆破 + +4. 访问:`Object returnValue = m.invoke(targetObject, 实参列表); //如果是静态方法,o可以写成null` + + + +## 代理模式 + +### 静态代理 + +需要对每个目标类都单独写一个代理类,不灵活且麻烦 + +### 动态代理 + +动态代理是在运行时动态生成类字节码,并加载到 JVM 中 + +#### JDK动态代理 + +基于接口的,代理类一定是有定义的接口,在 Java 动态代理机制中 `InvocationHandler` 接口和 `Proxy` 类是核心。 + +`Proxy` 类中使用频率最高的方法是:`newProxyInstance()` ,这个方法主要用来生成一个代理对象。 + +```java +public static Object newProxyInstance(ClassLoader loader, + Class[] interfaces, + InvocationHandler h) + throws IllegalArgumentException +{ + ...... +} +``` + +这个方法一共有 3 个参数: + +1. **loader** :类加载器,用于加载代理对象。 +2. **interfaces** : 被代理类实现的一些接口; +3. **h** : 实现了 `InvocationHandler` 接口的对象; + +要实现动态代理的话,还必须需要实现`InvocationHandler` 来自定义处理逻辑。 当我们的动态代理对象调用一个方法时,这个方法的调用就会被转发到实现`InvocationHandler` 接口类的 `invoke` 方法来调用。 + +```java +public interface InvocationHandler { + + /** + * 当使用代理对象调用方法的时候实际会调用到这个方法 + */ + public Object invoke(Object proxy, Method method, Object[] args) + throws Throwable; +} +``` + +1. **proxy** :动态生成的代理类 +2. **method** : 与代理类对象调用的方法相对应 +3. **args** : 当前 method 方法的参数 + +**通过`Proxy` 类的 `newProxyInstance()` 创建的代理对象在调用方法的时候,实际会调用到实现`InvocationHandler` 接口的类的 `invoke()`方法。** 可以在 `invoke()` 方法中自定义处理逻辑,比如在方法执行前后做什么事情。 + + + +**JDK 动态代理类使用步骤:** + +1. 定义一个接口及其实现类; +2. 自定义代理类实现 `InvocationHandler`接口并重写`invoke`方法,在 `invoke` 方法中我们会调用原生方法(被代理类的方法)并自定义一些处理逻辑; +3. 通过 `Proxy.newProxyInstance(ClassLoader loader,Class[] interfaces,InvocationHandler h)` 方法创建代理对象; + + + +#### CGLIB 动态代理 + +CGLIB基于ASM字节码生成工具,它通过继承的方式实现代理类,所以不需要接口,可以代理普通类,但需要注意 final 方法(不可继承)。 + +在 CGLIB 动态代理机制中 `MethodInterceptor` 接口和 `Enhancer` 类是核心。 + +你需要自定义 `MethodInterceptor` 并重写 `intercept` 方法,`intercept` 用于拦截增强被代理类的方法。 + +```java +public class ServiceMethodInterceptor implements MethodInterceptor{ + // 拦截被代理类中的方法 + @Override + public Object intercept(Object obj, java.lang.reflect.Method method, Object[] args,MethodProxy proxy) throws Throwable { + + } +} +``` + +1. **obj** : 被代理的对象(需要增强的对象) +2. **method** : 被拦截的方法(需要增强的方法) +3. **args** : 方法入参 +4. **proxy** : 用于调用原始方法 + +可以通过 `Enhancer`类来动态获取被代理类,当代理类调用方法的时候,实际调用的是 `MethodInterceptor` 中的 `intercept` 方法。 + +```java +Enhancer enhancer = new Enhancer(); +enhancer.setSuperclass(SampleClass.class); +enhancer.setCallback(new ServiceMethodInterceptor()); + +SampleClass proxy = (SampleClass) enhancer.create(); +proxy.test(); +``` + + + +**CGLIB 动态代理类使用步骤:** + +1. 引入依赖 +2. 定义类 + +3. 自定义 `MethodInterceptor` 并重写 `intercept` 方法,`intercept` 用于拦截增强被代理类的方法,和 JDK 动态代理中的 `invoke` 方法类似; + +4. 通过 `Enhancer` 类的 `create()`创建代理类; + + + +## 注解 + +Java中的注解(Annotation)是一种标记,是一种用于为代码元素(如类、方法、字段等)添加元数据的机制。这些元数据可以在编译时、类加载时或运行时被读取,并用于各种目的,如代码生成、运行时行为控制、配置管理等。 + +注解可以通过不同的保留策略来决定其在何时可见: + +- `RetentionPolicy.SOURCE`:注解仅存在于源代码中,在编译成字节码后丢弃。 +- `RetentionPolicy.CLASS`:注解保留在编译后的字节码中,但在运行时不可见(默认)。 +- `RetentionPolicy.RUNTIME`:注解保留在字节码中,并且在运行时可通过反射访问。 + + + +定义注解: + +```java +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface MyAnnotation { + String value(); + int count() default 1; +} +``` + +**`@Retention`**:指定注解的保留策略。上例中,注解在运行时可见。 + +**`@Target`**:指定注解可以应用的代码元素。上例中,注解只能用于方法。 + +**属性**:注解的属性类似于无参方法,可以有默认值(如`count`),也可以不设置默认值(如`value`)。 + + + +获取注解: + +```java +public class Test { + + @MyAnnotation(value = "Hello", count = 3) + public void annotatedMethod() { + // method logic + } +} + + +public class AnnotationProcessor { + public static void main(String[] args) throws Exception { + Method method = Test.class.getMethod("annotatedMethod"); + + if (method.isAnnotationPresent(MyAnnotation.class)) { + MyAnnotation annotation = method.getAnnotation(MyAnnotation.class); + System.out.println("Value: " + annotation.value()); + System.out.println("Count: " + annotation.count()); + } + } +} +``` + + + +## Class类 + +Class类也是类,因此继承Object类 + +Class类对象不是new出来的,而是系统创建的 + +对于某个类的Class对象,在内存只有一份,因为类只加载一次 + +每个类的实例都会记得自己是由哪个Class实例所产生 + +通过Class对象可以完整的得到一个类的完整结构,通过一系列的API + +Class对象存放在堆中 + +类的字节码二进制数据,是放在方法区的,有的地方称为类的元数据(包括方法代码、变量名、方法名、访问权限等) + + + +**获取Class对象的方法 :** + +```java +// 1. 已知一个类的全类名,且在该类的路径下,可以通过Class类的静态方法forName()获取。 +// 应用场景:多用于读取配置文件,读取类全路径加载类 +Class cls = Class.forName("java.lang.Cat"); + +// 2. 已知具体类,通过类的class获取,该方式最为安全可靠,性能最高 +// 多用于参数传递,比如通过反射得到对应构造器对象 +Class cls = Cat.class; + +// 3. 已知某个类的实例,调用该实例的getClass()方法获取Class对象 +// 通过创建好的对象获取Class对象 +Class cls = 对象.getClass(); + +// 4. 通过类加载器得到Class对象 +// +Car car = new Car(); +// 1) 先得到类加载器 +ClassLoader classLoader = car.getClass().getClassLoader(); +// 2) 通过类加载器得到Class对象 +Class aclass = classLoader.loadClass(classAllPath); + +``` + + + +## SPI机制 + +SPI(Service Provider Interface)是Java提供的一种服务发现机制,它允许框架或应用程序动态地加载服务的实现。在Java生态系统中,SPI常用于开发可扩展、可插拔的框架和库,例如Java中的`JDBC`、`JAXP`、`JNDI`等。 + +SPI和API区别: + +API是实现方提供接口和实现,我们可以通过调用实现方的接口从而拥有实现方给我们提供的能力 ,这种接口和实现都是放在实现方的。 + +SPI是接口存在于调用方这边 ,由接口调用方确定接口规则,然后由不同的厂商去根据这个规则对这个接口进行实现,从而提供服务。 + + + +SPI 机制的具体实现本质上还是通过反射完成的。即:**我们按照规定将要暴露对外使用的具体实现类在 `META-INF/services/` 文件下声明。** + + + +使用步骤: + +1. 定义服务接口 + + ```java + public interface MyService { + void execute(); + } + ``` + +2. 提供服务实现 + + ```java + public class MyServiceImpl implements MyService { + @Override + public void execute() { + System.out.println("Executing MyServiceImpl"); + } + } + ``` + +3. 创建服务配置文件:在服务实现的JAR包中,创建`META-INF/services`目录,并在其中添加一个配置文件,文件名为服务接口的全限定名,内容为服务实现类的全限定名: + + ```bash + META-INF/services/com.example.MyService + ``` + + 文件内容: + + ``` + com.example.MyServiceImpl + ``` + +4. 加载和使用 + + ```java + import java.util.ServiceLoader; + + public class ServiceLoaderExample { + public static void main(String[] args) { + ServiceLoader serviceLoader = ServiceLoader.load(MyService.class); + + for (MyService service : serviceLoader) { + service.execute(); + } + } + } + ``` + + + +## 语法糖 + +语法糖的存在主要是方便开发人员使用。 Java 虚拟机并不支持这些语法糖,这些语法糖在编译阶段就会被还原成简单的基础语法结构,这个过程就是解语法糖。 + +Java 中最常用的语法糖主要有: + +switch支持String与枚举:Java 中的`switch`自身原本就支持基本类型,字符串的 switch 是通过`equals()`和`hashCode()`方法来实现的 + +泛型:所有泛型类的类型参数在编译时都会被擦除 + +变长参数:数组 + +条件编译:根据 if 判断条件的真假,编译器直接把分支为 false 的代码块消除。 + +自动拆装箱:装箱过程是通过调用包装器的 valueOf 方法实现的,而拆箱过程是通过调用包装器的 xxxValue 方法实现的 + +内部类:编译后会生成两个不同class文件 + +枚举:当我们使用`enum`来定义一个枚举类型的时候,编译器会自动帮我们创建一个`final`类型的类继承`Enum`类,所以枚举类型不能被继承 + +断言:断言的底层实现就是 if 语言 + +数值字面量:编译器并不认识在数字字面量中的`_`,需要在编译阶段把他去掉 + +for-each:实现原理就是普通for循环和迭代器 + +try-with-resource:编译器帮助关闭资源 + +Lambda表达式:lambda 表达式的实现其实是依赖了一些底层的 api,在编译阶段,编译器会把 lambda 表达式进行解糖,转换成调用内部 api 的方式。 + + + +## 函数式编程 + +代码简洁,开发快速,易于理解,易于并发编程 + +### Lambda表达式 + +只关注参数列表和方法体 + +``` +(参数列表) -> {代码} +``` + +* 参数类型可以省略 +* 方法体中只有一句代码时,大括号、return和代码分号可以省略 +* 方法只有一个参数时,小括号可以省略 + +### Stream流 + +用来对集合或数组进行链状流式的操作 + +**1. 创建流** + +* 单列集合(Collection):`集合对象.stream()` +* 数组:`Arrays.stream(数组)` 或 `Stream.of(数组)` +* 双列集合(Map):转换为单列集合后再创建 `map.entrySet().stream()` +* 使用`Stream.builder()` 创建流,然后使用add方法添加元素,最后使用build方法构建流。使用build后不能再add元素 + +**2. 中间操作**:筛选、映射、排序 + +* filter:对流中的元素进行条件过滤,符合过滤条件的才能继续留在流中 + +* map:对流中的元素进行计算或转换 + +* distinct:去除流中的重复元素。**依赖Object的equals和hashCode方法判断是否是相同对象** + +* sorted:对流中的元素进行排序 + +* limit:可以设置流的最大长度,超出的部分将被舍弃 + +* skip:跳过流中的前n个元素,返回剩下的元素 + +* flatMap:map适合单层结构的流,进行一对一转换。flatMap不仅能够执行map的转换功能,还能扁平化多层数据结构,将其转换合并为一个单层流。 + + ```java + // 打印书籍分类 + author.stream() // 创建流 + .flatMap(author -> author.getBooks().stream()) // 获取书籍的流 + .distinct() + .flatMap(book -> Arrays.stream(book.getCategory().split(","))) // 书籍分类得到数组,再获取数组流 + .distinct() + .forEach(category -> System.out.println(category)); + ``` + +**3. 终结操作**:查找与匹配、聚合操作、规约操作、收集操作、迭代操作。终结操作会强制执行中间的惰性操作 + +* 迭代操作: + + * forEach:对流中元素进行任意顺序遍历操作,通过传入参数指定遍历到的元素的具体操作 + + + * forEachOrdered:按照流中的顺序遍历元素 + + +* 聚合操作:规约操作的特殊形式 + + * count:获取当前流中元素个数 + + * max,min:获取最值 + + * average + + * sum + +* 收集操作:collect:把当前流转换为一个集合,使用Collectors工具类 + + * List:`Collectors.toList()` + * Set:`Collectors.toSet()` + * Map:`Collectors.toMap()` + * 连接操作收集流中所有字符:`Collectors.joining()` + * 统计总和、平均值、数量、最值:`Collectors.summarizing(Int|Long|Double)` + * 分组:`Collectors.grouopingBy()`,分类函数是断言函数时(返回boolean的函数)`partitioningBy` 更高效 + +* 查找与匹配操作:短路操作,匹配一个后就会返回 + + * anyMatch:判断是否有任意符合匹配条件的元素,结果为boolean类型 + + * allMatch:判断是否都符合匹配条件,结果为boolean + + * noneMatch:判断是否都不符合匹配条件,结果为boolean + + * findAny:获取流中的任意一个元素。无法保证一定获取流中的第一个元素。并行处理流时很有效 + + * findFirst:获取流中第一个元素 + +* 规约操作:reduce:对流中的数据按照自己定制的计算方式计算出一个结果 + + * reduce的作用是把stream中的元素给组合起来,我们可以传入一个初始值,它会按照我们定制的计算方式依次拿流中的元素进行计算,结果继续与后面元素计算 + + ```java + values.stream().reduce(0, (x, y) -> x+y); //第一个元素为幺元值,流为空则返回幺元值 + ``` + + * 内部的计算方式 + + ```java + T result = identity;// identity为传入的参数 + for(T element : this stream) + result = accumulator.apply(result, element) + return result; + ``` + + * 要使用并行流,则操作必须是可结合的。且要提供第二个函数来合并处理 + +**注意事项:** + +* 惰性求值:如果没有终结操作,中间操作是不会执行的 +* 流是一次性的:一个流对象经过终结操作后,这个流就不能再被使用 +* 不会影响原数据 + +### Optional + +可以写出更优雅的代码,避免空指针异常 + +**创建对象**:`Optional.of(对象) `对象不能为空; `Optional.ofNullable(对象)`,无论传入参数是否为null都不会出问题 + +**安全消费值**:`ifPresent()`接受一个函数,如果可选值存在就传递给该函数,否则不发生任何事情;`ifPresentOrElse`() 接收两个函数 + +**获取值**:`get()` ,获取数据为空时,会空指针异常 + +**安全获取值:** + +* `orElse()`,是否为空都会执行括号里的内容 + +* `orElseGet()`,数据不为空返回数据,数据为空则根据传入的默认值参数返回,为空才执行括号里的内容 +* `orElseThrow()`,数据不为空返回数据,数据为空根据传入的参数抛出异常 + +**过滤:**`filter()`,数据不符合要求,返回value为null的Optional对象 + +**判断:**`isPresent()`,更推荐使用`ifPresent()` + +**数据转换:**`map()` + +### 基本类型流 + +用来直接存储基本类型值,无需使用包装类型 + +存储short、char、byte、boolean可以使用IntStream;float使用DoubleStream。 + +对象流可以转换为基本类型流:mapToInt、mapToLong、mapToDouble;基本类型流转换为对象流需要使用boxed方法 + +### 函数式接口 + +接口中只有一个抽象方法 + +JDK函数式接口都加上了`@FunctionalInterface`注解进行标识,但无论是否有该注解,只要接口中只有一个抽象方法都是函数式接口 + +**常用的默认方法** + +* and:相当于&&来拼接判断条件 +* or:|| +* negate:!,取反 + +### 方法引用 + +``` +类名或对象名::方法名 +``` + +使用lambda表达式时,如果方法体中只有一个方法的调用的话(包括构造方法),可以使用方法引用进一步简化代码 + +**引用类的静态方法** + +``` +类名::方法名 +``` + +重写方法的时候,方法体中只有一行代码,这行代码是调用了某个类的静态方法,并且重写的抽象方法中所有参数按照顺序传入了调用的静态方法中 + +**引用对象的实例方法** + +``` +对象名::方法名 +``` + +重写方法的时候,方法体中只有一行代码,这行代码是调用了某个对象的成员方法,并且重写的抽象方法中所有参数按照顺序传入了调用的成员方法中 + +**引用类的实例方法** + +``` +类名::方法名 +``` + +重写方法的时候,方法体中只有一行代码,这行代码是调用了第一个参数的成员方法,并且重写的抽象方法中剩余的所有参数按照顺序传入了调用的成员方法中 + +**构造器引用** + +``` +类名::new +``` + +重写方法的时候,方法体中只有一行代码,这行代码是调用了某个类的构造器,并且重写的抽象方法中所有参数按照顺序传入了调用的构造方法中 + +### 并行流 + +`parallel()` :将任意的顺序流转换为并行流 + +`parallerStream()`:从集合中获取一个并行流 + + + +## 正则表达式 + +```java +String content = ""; + +// 1. \\d表示任意一个数字 +String regexp = "\\d\\d\\d\\d"; + +// 2. 创建模式对象 Pattern.CASE_INSENSITIVE表示忽略大小写,标志可以设置一个或多个 +Pattern pattern = Pattern.compile(regexp,Pattern.CASE_INSENSITIVE); + +// 3. 创建匹配器 +Matcher matcher = pattern.matcher(content); + +// 4. 开始匹配 +/** +* matcher.find() 完成的任务 +* 1. 根据指定规则,定位满足规则的子字符串 +* 2. 找到后将子字符串的开始索引记录到 matcher 对象的属性 int[] groups; +* groups[0]记录开始索引, 把结束索引+1的值记录到 groups[1]中 +* 3. 同时记录 oldLast 的值为:子字符串结束索引+1的值. 下次执行 find() 从此开始匹配 +*/ +/** +* 考虑分组 正则表达式中含小括号即表示分组: String regexp = "(\\d\\d)(\\d\\d)"; +* groups[0]记录开始索引, 把结束索引+1的值记录到 groups[1]中 +* groups[2]记录第一组开始索引, groups[3]记录第一组结束索引+1的值 +* 依次类推... +*/ +/** +* public String group(int group) { +* checkMatch(); +* checkGroup(group); +* if ((groups[group*2] == -1) || (groups[group*2+1] == -1)) +* return null; +* return getSubSequence(groups[group * 2], groups[group * 2 + 1]).toString(); +* } +*/ +while (matcher.find()){ + System.out.println(matcher.group(0)); +} +``` + + + +``` +转义:\\ 在java的正则表达式中\\代表其他语言中的一个\ + 需要用到转义符号的字符:., *, +, (, ), /, \, ?, [, ], ^, {, } +``` + +**匹配符** + +| 符号 | 意义 | 示例 | 解释 | +| -------------- | ------------------------------------------------------------ | ------------------ | ------------------------------------------------------ | +| [] | 可接收的字符列表 | [efgh] | efgh中的任意字符 | +| [^] | 不可接收的字符列表 | [^abc] | abc除外的任意字符 | +| - | 连字符 | A-Z | 任意大写字母 | +| . | 匹配除\n之外的任何字符 | a..b | a开头b结尾中间任意两个字符的字符串 | +| \\\d | 匹配单个数字字符,相当于[0-9] | \\\d{3}(\\\d)? | 包含3个或4个数字的字符串 | +| \\\D | 匹配单个非数字字符 | \\\D(\\\d)* | 以单个非数字字符开头,后接任意个数字字符的字符串 | +| \\\w | 匹配单个数字、大小写字母字符、下划线
相当于[0-9a-zA-Z] | \\\d{3}\\\w{4} | 以3个数字字符开头长度为7的数字字符串 | +| \\\W | 匹配单个非数字、大小写字母字符、下划线
相当于[ ^0-9a-zA-Z] | \\\W+\\\d{2} | 以至少1个非数字字母字符开头,2个数字字符结尾的字符串 | +| \\\s | 匹配任何空白字符(空格、制表符等) | | | +| \\\S | 匹配任何非空白字符 | | | +| **选择匹配符** | | | | +| \| | 选择匹配符,匹配之前或之后的 | ab\|cd | ab或cd | +| **限定符** | | | | +| * | 指定字符重复0次或n次 | (abc)* | 包含任意个abc的字符串 | +| + | 指定字符重复1次或n次 | m+(abc)* | 以至少1个m开头,后接任意个abc的字符串 | +| ? | 指定字符重复0次或1次 | m+abc? | 以m开头,后接ab或abc的字符串 | +| {n} | 只能输入n个字符 | [abcd]{3} | 由abcd中字母组成的任意长度为3的字符串 | +| {n, } | 指定至少n个匹配 | [abcd]{3, } | 由abcd中字母组成的任意长度至少为3的字符串 | +| {n, m} | 指定至少n个但不多于m个匹配,尽可能匹配多的(贪婪匹配) | [abcd]{3, 5} | 由abcd中字母组成的任意长度至少为3但不大于5的字符串 | +| ? | 非贪婪匹配,限定符后加 ? | \\\d+? | 至少一个数字字符的字符串,尽可能匹配少的 | +| **定位符** | | | | +| ^ | 指定起始字符 | ^[0-9]+[a-z]* | 以至少1个数字开头,后接任意个小写字母的字符串 | +| $ | 指定结束字符 | ^[0-9]\\\\-[a-z]+$ | 以1个数字开头后接'-',并以至少一个小写字母结尾的字符串 | +| \\\b | 匹配目标字符串的边界 | han\\\b | 边界指子串间有空格或是目标字符串的结束位置 | +| \\\B | 匹配目标字符串的非边界 | han\\\B | | + + + +**java正则表达式默认区分大小写,实现不区分大小写:** + +``` +// 1. +(?i)abc 表示abc都不区分大小写 +a(?i)bc 表示bc不区分大小写 +a((?i)b)c 表示b不区分大小写 + +// 2. +Pattern pattern = Pattern.compile(regexp,Pattern.CASE_INSENSITIVE); +``` + + + +**分组** + +| 常用分组构造形式 | 说明 | +| ------------------ | ------------------------------------------------------------ | +| (pattern) | 非命名捕获。捕获匹配的子字符串,编号为0的第一个捕获是整个正则表达式模式匹配的文本,其他捕获结果根据左括号的顺序从1开始自动编号 | +| (?\pattern) | 命名捕获。将匹配子字符串捕获到一个组名称过编号名称中。用于name的字符串不能包含任何标点符号,并且不能以数字开头,可以使用单引号替代尖括号,例如 ?'name' | +| (?:pattern) | 匹配但是不捕获子字符串,是一个非捕获匹配,不存储供以后使用的匹配。这对于用or字符(\|)组合模式部件的情况很有用。例如 'industr(?:y\|ies)' 是比 'industry\|industries' 更经济的表达式 | +| (?=pattern) | 非捕获匹配。例如,'Windows (?=95\|98\|NT\|2000)' 匹配 "Windows 2000" 中的 "Windows",但是不匹配 "Windows 3.1" 中的 "Windows" | +| (?!pattern) | 非捕获匹配。例如,'Windows (!=95\|98\|NT\|2000' 匹配 "Windows 3.1" 中的"Windows", 但是不匹配"Windows 2000" 中的"Windows" | + + + +**反向引用** + +圆括号的内容被捕获后,可以在这个括号后被使用,从而写出一个比较实用的匹配模式,这个我们称为反向引用,这种引用既可以是正则表达式内部,也可以是在正则表达式外部,内部反向引用`\\分组号`,外部反向引用`$分组号` diff --git a/docs/Java/Java/SpringMVC.png b/docs/Java/Java/SpringMVC.png new file mode 100644 index 0000000..612a671 Binary files /dev/null and b/docs/Java/Java/SpringMVC.png differ diff --git a/docs/Java/Java/collection.jpeg b/docs/Java/Java/collection.jpeg new file mode 100644 index 0000000..c6b92b4 Binary files /dev/null and b/docs/Java/Java/collection.jpeg differ diff --git a/docs/Java/Java/java-collection-hierarchy.png b/docs/Java/Java/java-collection-hierarchy.png new file mode 100644 index 0000000..346b020 Binary files /dev/null and b/docs/Java/Java/java-collection-hierarchy.png differ diff --git a/docs/Java/Java/map.jpeg b/docs/Java/Java/map.jpeg new file mode 100644 index 0000000..2996ec7 Binary files /dev/null and b/docs/Java/Java/map.jpeg differ diff --git a/docs/Java/Java/shallow&deep-copy.png b/docs/Java/Java/shallow&deep-copy.png new file mode 100644 index 0000000..1f30a37 Binary files /dev/null and b/docs/Java/Java/shallow&deep-copy.png differ diff --git "a/docs/Java/Java/\345\274\202\345\270\270.jpeg" "b/docs/Java/Java/\345\274\202\345\270\270.jpeg" new file mode 100644 index 0000000..daafa3a Binary files /dev/null and "b/docs/Java/Java/\345\274\202\345\270\270.jpeg" differ diff --git "a/docs/Java/Java\351\233\206\345\220\210.md" "b/docs/Java/Java\351\233\206\345\220\210.md" new file mode 100644 index 0000000..b117ebf --- /dev/null +++ "b/docs/Java/Java\351\233\206\345\220\210.md" @@ -0,0 +1,385 @@ +## Collection + +Collection接口没有直接的实现子类,是通过它的子接口Set、List和Queue实现的 + +![](Java\collection.jpeg) + +### List + +有序,可重复,支持索引,常用的有ArrayList,LinkedList,Vector + +#### ArrayList + +基本等同于Vector,效率高,但是**线程不安全** + +底层是`Object[]` 数组 + + + +和Array区别? + +1. 大小和自动扩容 +2. 支持泛型 +3. 存储对象 +4. 集合功能 + + + +**扩容机制:** + +以无参数构造方法创建 `ArrayList` 时,实际上初始化赋值的是一个空数组。当真正对数组进行添加元素操作时,才真正分配容量。即向数组中添加第一个元素时,数组容量扩为 10,到达当前容量后扩容为1.5倍。 + +如果使用指定大小的构造器,初始容量为指定大小,如果需要扩容则扩容为**1.5倍。** + + + +#### Vector + +底层是`Object[]` 数组。 + +线程同步的,即线程安全, 操作方法带 `synchronized` + +| | 底层结构 | 线程安全 效率 | 扩容机制 | +| --------- | -------- | -------------- | ---------------------------------------------------------- | +| ArrayList | 可变数组 | 不安全,效率高 | 有参扩容1.5倍
无参默认0,第一次扩容为10,后面扩容1.5倍 | +| Vector | 可变数组 | 安全,效率不高 | 有参扩容2倍
无参默认是10,后面扩容2倍 | + + + +#### LinkedList + +同时实现了 List、Queue 和 Deque 接⼝。底层是基于双向链表的。 + +线程不安全,需要用到 `LinkedList` 的场景几乎都可以使用 `ArrayList` 来代替,并且,性能通常会更好 + +| | 底层结构 | 增删效率 | 改查效率 | 线程安全 | 随机访问 | 占用内存 | +| ---------- | -------- | -------- | -------- | -------- | -------- | -------- | +| ArrayList | 可变数组 | 较低 | 较高 | 不安全 | 支持 | 小 | +| LinkedList | 双向链表 | 较高 | 较低 | 不安全 | 不支持 | 大 | + + + +### Set + +**Comparable 和 Comparator 的区别** + +`Comparable` 接口和 `Comparator` 接口都是 Java 中用于排序的接口,它们在实现类对象之间比较大小、排序等方面发挥了重要作用: + +- `Comparable` 接口实际上是出自`java.lang`包 它有一个 `compareTo(Object obj)`方法用来排序 +- `Comparator`接口实际上是出自 `java.util` 包它有一个`compare(Object obj1, Object obj2)`方法用来排序 + + + +| | 线程安全 | 底层数据结构 | 应用场景 | +| ------------- | -------- | ------------ | ------------------------ | +| HashSet | 不安全 | HashMap | 无需保证插入取出顺序场景 | +| LinkedHashSet | 不安全 | 链表+哈希表 | 需要保证插入取出顺序场景 | +| TreeSet | 不安全 | 红黑树 | 元素自定义排序场景 | + + + +#### HashSet + +无序,唯一 + +HashSet实际上是HashMap , HashMap底层是(数组+链表+红黑树) + +HashSet如何检查键值重复?`HashSet`的`add()`方法直接调用`HashMap`的`put()`方法:先比较hashcode,如果发现有相同 `hashcode` 值的对象,这时会调用`equals()`方法来检查 `hashcode` 相等的对象是否真的相同。 + +```java +public HashSet() { + map = new HashMap<>(); +} +``` + +扩容机制 + +``` +// 第一次添加时,table数组扩容到16,临界值是16*loadFactor(0.75)=12, 如果table数组使用到了临界值,就会扩容2倍,依次类推 +1. 添加一个元素时,先得到hash值然后转换为索引值 +2. 找到存储数据的table,看索引位置是否有元素 +3. 如果没有,直接加入 +4. 如果有,调用equals比较,如果相同放弃添加,如果不同添加到最后。equals不能简单的认为是比较内容或是地址,程序员可以进行重写 +5. 在java8中,如果一条链表的元素个数 >= TREEIFY_THRESHOLD(默认8),并且table大小 >= MIN_TREEIFY_CAPACITY(默认64),就会进行树化(红黑树) +``` + +| HashMap | HashSet | +| -------------------- | -------------------- | +| 实现Map接口 | 实现Set接口 | +| 存储键值对 | 存储对象 | +| put 添加元素 | add 添加元素 | +| 使用key计算 hashCode | 使用对象计算hashCode | + + + +#### LinkedHashSet + +HashSet的子类 + +底层是一个LinkedHashMap,底层维护了一个数组+双向链表 + +根据元素的hashCode值决定元素的存储位置,同时使用链表维护元素的次序,使得元素看起来是以插入顺序保存的 + +不允许元素重复 + + + +#### TreeSet + +底层是TreeMap,红黑树 + +可以实现排序,构造器可以传入一个比较器(匿名内部类)对TreeSet进行排序 + + + +### Queue + +`Queue` 是单端队列,`Deque` 是双端队列 + +| `Queue` 接口 | 抛出异常 | 返回特殊值 | +| ------------ | --------- | ---------- | +| 插入队尾 | add(E e) | offer(E e) | +| 删除队首 | remove() | poll() | +| 查询队首元素 | element() | peek() | + +| `Deque` 接口 | 抛出异常 | 返回特殊值 | +| ------------ | ------------- | --------------- | +| 插入队首 | addFirst(E e) | offerFirst(E e) | +| 插入队尾 | addLast(E e) | offerLast(E e) | +| 删除队首 | removeFirst() | pollFirst() | +| 删除队尾 | removeLast() | pollLast() | +| 查询队首元素 | getFirst() | peekFirst() | +| 查询队尾元素 | getLast() | peekLast() | + + + +#### PriorityQueue + +`Object[]` 数组来实现小顶堆。 + +线程不安全 + +当没有传入数组容量的时候,默认是11 + +如果容量小于64时,是按照oldCapacity的2倍方式扩容的;如果容量大于等于64,是按照oldCapacity的1.5倍方式扩容的 + + + +#### BlockingQueue + +BlockingQueue 主要⽤于在多线程之间安全地传递数据,并提供了阻塞操作,以便在队列为空或队列已满时进⾏ 等待或阻塞 + +BlockingQueue的实现类: + +`ArrayBlockingQueue`:基于数组实现的有界队列。 + +`LinkedBlockingQueue`:基于链表实现的有界或⽆界队列。 + +`PriorityBlockingQueue`:基于优先级的⽆界队列。 + +`DelayQueue`:⽤于实现延迟任务的⽆界队列。 + + + +#### DelayQueue + +`DelayQueue` 底层是使用优先队列 `PriorityQueue` 来存储元素,而 `PriorityQueue` 采用二叉小顶堆的思想确保值小的元素排在最前面,这就使得 `DelayQueue` 对于延迟任务优先级的管理就变得十分方便。 + +`DelayQueue` 为了保证线程安全还用到了可重入锁 `ReentrantLock`,确保单位时间内只有一个线程可以操作延迟队列。 + +最后,为了实现多线程之间等待和唤醒的交互效率,`DelayQueue` 还用到了 `Condition`,通过 `Condition` 的 `await` 和 `signal` 方法完成多线程之间的等待唤醒。 + +#### ArrayDeque + +基于动态数组的双端队列。底层使⽤循环数组实现 + +`ArrayDeque` 是基于可变长的数组和双指针来实现,而 `LinkedList` 则通过链表来实现。 + +## Map + +![](E:\笔记\notes\Java\map.jpeg) + +### HashMap + +线程不安全,保证线程安全就选用 `ConcurrentHashMap`。 + +`HashMap` 可以存储 null 的 key 和 value,但 null 作为键只能有一个,null 作为值可以有多个 + +JDK1.8 之前 `HashMap` 由数组+链表组成的,数组是 `HashMap` 的主体,链表则是主要为了解决哈希冲突而存在的(“拉链法”解决冲突)。JDK1.8 以后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间。 + +**`HashMap` 默认的初始化大小为 16。到达临界值(临界值是16*loadFactor(0.75)=12)之后,容量变为原来的 2 倍。并且, `HashMap` 总是使用 2 的幂作为哈希表的大小。**因为对长度取模的操作可以用位运算来替代(` hash%length==hash&(length-1)`),能够有效保留hashcode低位并且提高效率。 + +初始化传的不是2的幂时,会向上寻找离得近的2的幂作为初始化大小。 + +添加元素: + +``` +1. 添加一个元素时,先得到hash值然后转换为索引值( (n - 1) & hash ) +2. 找到存储数据的table,看索引位置是否有元素 +3. 如果没有,直接插入(该节点直接放在数组中) +4. 如果有,调用equals比较判断该元素与要存入的元素的 hash 值以及 key 是否相同,如果相同则覆盖,如果不同插入到链表末端。equals不能简单的认为是比较内容或是地址,程序员可以进行重写 +5. 在java8中,如果一条链表的元素个数 >= TREEIFY_THRESHOLD-1(默认8),并且table大小 >= MIN_TREEIFY_CAPACITY(默认64),就会进行树化(红黑树) +``` + + + +**`HashMap` 的长度是 2 的幂次方的原因:** + +1. 位运算效率更高:位运算(&)比取余运算(%)更高效。当长度为 2 的幂次方时,`hash % length` 等价于 `hash & (length - 1)`。 +2. 可以更好地保证哈希值的均匀分布:扩容之后,在旧数组元素 hash 值比较均匀的情况下,新数组元素也会被分配的比较均匀,最好的情况是会有一半在新数组的前半部分,一半在新数组后半部分。 +3. 扩容机制变得简单和高效:扩容后只需检查哈希值高位的变化来决定元素的新位置,要么位置不变(高位为 0),要么就是移动到新位置(高位为 1,原索引位置+原容量)。 + + + +**HashMap为什么线程不安全?** + +死循环和数据丢失 + +1. JDK1.7 及之前版本的 `HashMap` 在多线程环境下**扩容操作可能存在死循环问题**,这是由于当一个桶位中有多个元素需要进行扩容时,多个线程同时对链表进行操作,头插法可能会导致链表中的节点指向错误的位置,从而形成一个环形链表,进而使得查询元素的操作陷入**死循环**无法结束。[JDK 1.7 hashmap循环链表的产生(图文并茂,巨详细)_hashmap循环链表是如何产生的-CSDN博客](https://blog.csdn.net/qq_44833552/article/details/125575981) + +2. 多个线程对 `HashMap` 的 `put` 操作会有**数据覆盖**的风险。并发环境下,推荐使用 `ConcurrentHashMap` 。 + + + +**HashMap遍历方式:** + +1. 使用迭代器(Iterator)EntrySet 的方式进行遍历; +2. 使用迭代器(Iterator)KeySet 的方式进行遍历; +3. 使用 For Each EntrySet 的方式进行遍历; +4. 使用 For Each KeySet 的方式进行遍历; +5. 使用 Lambda 表达式的方式进行遍历; +6. 使用 Streams API 单线程的方式进行遍历; +7. 使用 Streams API 多线程的方式进行遍历。 + +`entrySet` 的性能比 `keySet` 的性能高出了一倍之多,因此我们应该尽量使用 `entrySet` 来实现 Map 集合的遍历。 + +`EntrySet` 的性能比 `KeySet` 的性能高出了一倍,因为 `KeySet` 相当于循环了两遍 Map 集合,而 `EntrySet` 只循环了一遍。 + + + +不能在遍历中使用集合 `map.remove()` 来删除数据,这是非安全的操作方式,但我们可以使用迭代器的 `iterator.remove()` 的方法来删除数据,这是安全的删除集合的方式。同样的我们也可以使用 Lambda 中的 `removeIf` 来提前删除数据,或者是使用 Stream 中的 `filter` 过滤掉要删除的数据进行循环,这样都是安全的,当然我们也可以在 `for` 循环前删除数据在遍历也是线程安全的。 + + + +### ConcurrentHashMap + +Java 7 中 `ConcurrnetHashMap` 由很多个 `Segment` 组合,而每一个 `Segment` 是一个类似于 `HashMap` 的结构,所以每一个 `HashMap` 的内部可以进行扩容。但是 `Segment` 的个数一旦**初始化就不能改变**,默认 `Segment` 的个数是 16 个,你也可以认为 `ConcurrentHashMap` 默认支持最多 16 个线程并发。 + +Java 8 中 不再是之前的 **Segment 数组 + HashEntry 数组 + 链表**,而是 **Node 数组 + 链表 / 红黑树**。当冲突链表达到一定长度时,链表会转换成红黑树。 + + + +ConcurrentHashMap 为什么 key 和 value 不能为 null? + +`ConcurrentHashMap` 的 key 和 value 不能为 null 主要是为了避免二义性。null 是一个特殊的值,表示没有对象或没有引用。如果你用 null 作为键,那么你就无法区分这个键是否存在于 `ConcurrentHashMap` 中,还是根本没有这个键。同样,如果你用 null 作为值,那么你就无法区分这个值是否是真正存储在 `ConcurrentHashMap` 中的,还是因为找不到对应的键而返回的。 + + + +### LinkedHashMap + +`LinkedHashMap` 继承自 `HashMap`,所以它的底层仍然是基于拉链式散列结构即由数组和链表或红黑树组成。另外,`LinkedHashMap` 在上面结构的基础上,增加了一条双向链表,使得上面的结构可以保持键值对的插入顺序。同时通过对链表进行相应的操作,实现了访问顺序相关逻辑。 + +LRU缓存: + +1. 继承 `LinkedHashMap`; + +2. 构造方法中指定 `accessOrder` 为 true ,这样在访问元素时就会把该元素移动到链表尾部,链表首元素就是最近最少被访问的元素; + +3. 重写`removeEldestEntry` 方法,该方法会返回一个 boolean 值,告知 `LinkedHashMap` 是否需要移除链表首元素(缓存容量有限)。 + +```java +public class LRUCache extends LinkedHashMap { + private final int capacity; + + public LRUCache(int capacity) { + super(capacity, 0.75f, true); + this.capacity = capacity; + } + + /** + * 判断size超过容量时返回true,告知LinkedHashMap移除最老的缓存项(即链表的第一个元素) + */ + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > capacity; + } +} +``` + +```java +LRUCache cache = new LRUCache<>(3); +cache.put(1, "one"); +cache.put(2, "two"); +cache.put(3, "three"); +cache.put(4, "four"); +cache.put(5, "five"); +for (int i = 1; i <= 5; i++) { + System.out.println(cache.get(i)); +} +``` + + + +### Hashtable + +* 键和值都不能为空 +* 使用方法基本和HashMap一样 +* Hashtable是线程安全的,通过在每个⽅法上添加同步关键字来实现的,但这也可能 导致性能下降。 + +``` +底层数组Hashtable$Entry[] 初始化大小 11 +临界值 threshold = 8 (11*0.75) +扩容机制 +``` + +| | 线程安全 | 效率 | 对null key/value的支持 | 扩容机制 | 底层数据结构 | +| --------- | ---------- | ---------------------- | ---------------------- | -------------------------------------- | ----------------------- | +| HashMap | 线程不安全 | 高 | 允许 | 默认初始化大小16,每次扩容为原来的2倍 | 数组+链表+红黑树 | +| HashTable | 线程安全 | 基本被淘汰,不建议使用 | 不允许 | 默认初始化大小11,每次扩容为原来的2n+1 | 数组+链表,没有树化机制 | + + + +**ConcurrentHashMap 和 Hashtable 的区别:** + +1. 底层数据结构:ConcurrentHashMap和HashMap一样,使用数组+链表/红黑树,HashTable使用数组+链表,没有树化机制 +2. 实现线程安全的方式:`ConcurrentHashMap` 取消了 `Segment` 分段锁,采用 `Node + CAS + synchronized` 来保证并发安全,`synchronized` 只锁定当前链表或红黑二叉树的首节点。HashTable使用 `synchronized` 来保证线程安全,效率非常低下 + +### TreeMap + +基于红黑树数据结构的实现的 + +实现 `NavigableMap` 接口让 `TreeMap` 有了对集合内元素的搜索的能力。 + +`NavigableMap` 接口提供了丰富的方法来探索和操作键值对: + +1. **定向搜索**: `ceilingEntry()`, `floorEntry()`, `higherEntry()`和 `lowerEntry()` 等方法可以用于定位大于、小于、大于等于、小于等于给定键的最接近的键值对。 +2. **子集操作**: `subMap()`, `headMap()`和 `tailMap()` 方法可以高效地创建原集合的子集视图,而无需复制整个集合。 +3. **逆序视图**:`descendingMap()` 方法返回一个逆序的 `NavigableMap` 视图,使得可以反向迭代整个 `TreeMap`。 +4. **边界操作**: `firstEntry()`, `lastEntry()`, `pollFirstEntry()`和 `pollLastEntry()` 等方法可以方便地访问和移除元素。 + +实现`SortedMap`接口让 `TreeMap` 有了对集合中的元素根据键排序的能力。默认是按 key 的升序排序,不过我们也可以指定排序的比较器。 + +**相比于`HashMap`来说, `TreeMap` 主要多了对集合中的元素根据键排序的能力以及对集合内元素的搜索的能力。** + + + +**集合框架底层使⽤了什么数据结构?** + +1. List接⼝的实现 + 1. ArrayList: 基于动态数组实现。底层使⽤数组作为存储结构。 + 2. LinkedList: 基于双向链表实现。底层使⽤节点(Node)连接形成链表结构。 + 3. Vector: 类似于 ArrayList,但是是线程安全的。底层也是使⽤数组实现。 +2. Set接⼝ + 1. HashSet: 基于哈希表实现。底层使⽤⼀个数组和链表/红⿊树的结构来存储元素。 + 2. LinkedHashSet: 在 HashSet 的基础上加⼊了链表,使得迭代顺序可预测。 + 3. TreeSet: 基于红⿊树实现。底层使⽤⾃平衡的⼆叉搜索树存储元素,以保持有序性。 +3. Queue接⼝ + 1. LinkedList: 同时实现了 List、Queue 和 Deque 接⼝。底层是基于双向链表的。 + 2. ArrayDeque: 基于动态数组的双端队列。底层使⽤循环数组实现。 + 3. PriorityQueue: 基于优先级堆实现的队列。底层使⽤数组表示的⼆叉堆。 +4. Map接⼝ + 1. HashMap: 基于哈希表实现。底层使⽤⼀个数组和链表/红⿊树的结构来存储键值对。 + 2. LinkedHashMap: 在 HashMap 的基础上加⼊了链表,使得迭代顺序可预测。 + 3. TreeMap: 基于红⿊树实现。底层使⽤⾃平衡的⼆叉搜索树存储键值对,以保持有序性。 + 4. Hashtable: 类似于 HashMap,但是是线程安全的。底层也是使⽤哈希表。 + diff --git a/docs/MySQL/MySQL.md b/docs/MySQL/MySQL.md new file mode 100644 index 0000000..f00251b --- /dev/null +++ b/docs/MySQL/MySQL.md @@ -0,0 +1,1462 @@ +## select 语句的执行过程 + +![](MySQL\基础架构.png) + + + +### 连接器 + +作用: + +1. 与客户端进行TCP三次握手建立连接 +2. 校验用户名、密码 +3. 校验权限 + + + +`MySQL`也会“安排”一条线程维护当前客户端的连接,这条线程也会时刻标识着当前连接在干什么工作,可以通过`show processlist;`命令查询所有正在运行的线程。 + + + +MySQL的最大线程数可以通过参数`max-connections`来控制,如果到来的客户端连接超出该值时,新到来的连接都会被拒绝,关于最大连接数的一些命令主要有两条: + +- `show variables like '%max_connections%';`:查询目前`DB`的最大连接数。默认151 +- `set GLOBAL max_connections = 200;`:修改数据库的最大连接数为指定值。 + + + +MySQL 定义了空闲连接的最大空闲时长,由 `wait_timeout` 参数控制的,默认值是 8 小时(28880秒),如果空闲连接超过了这个时间,连接器就会自动将它断开。 + +一个处于空闲状态的连接被服务端主动断开后,这个客户端并不会马上知道,等到客户端在发起下一个请求的时候,才会收到这样的报错“ERROR 2013 (HY000): Lost connection to MySQL server during query”。 + + + +MySQL 的连接也跟 HTTP 一样,有短连接和长连接的概念。长连接的好处就是可以减少建立连接和断开连接的过程,但是,使用长连接后可能会占用内存增多。有两种解决方式: + +第一种,**定期断开长连接**。既然断开连接后就会释放连接占用的内存资源,那么我们可以定期断开长连接。 + +第二种,**客户端主动重置连接**。MySQL 5.7 版本实现了 `mysql_reset_connection()` 函数的接口,注意这是接口函数不是命令,那么当客户端执行了一个很大的操作后,在代码里调用 mysql_reset_connection 函数来重置连接,达到释放内存的效果。这个过程不需要重连和重新做权限验证,但是会将连接恢复到刚刚创建完时的状态。 + + + +### 查询缓存 + +连接器的工作完成后,客户端就可以向 MySQL 服务发送 SQL 语句了,MySQL 服务收到 SQL 语句后,就会解析出 SQL 语句的第一个字段,看看是什么类型的语句。 + +如果 SQL 是查询语句(select 语句),MySQL 就会先去查询缓存( Query Cache )里查找缓存数据,看看之前有没有执行过这一条命令,这个查询缓存是以 key-value 形式保存在内存中的,key 为 SQL 查询语句,value 为 SQL 语句查询的结果。 + +MySQL 8.0 版本已经将查询缓存删掉。 + + + +### 解析SQL + +第一件事情,**词法分析**。MySQL 会根据你输入的字符串识别出关键字出来,例如,SQL语句 select username from userinfo,在分析之后,会得到4个Token,其中有2个Keyword,分别为select和from。 + +第二件事情,**语法分析**。根据词法分析的结果,语法解析器会根据语法规则,判断你输入的这个 SQL 语句是否满足 MySQL 语法,如果没问题就会构建出 SQL 语法树,这样方便后面模块获取 SQL 类型、表名、字段名、 where 条件等等。 + + + +`SQL`语句分为五大类: + +- `DML`:数据库操作语句,比如`update、delete、insert`等都属于这个分类。 +- `DDL`:数据库定义语句,比如`create、alter、drop`等都属于这个分类。 +- `DQL`:数据库查询语句,比如最常见的`select`就属于这个分类。 +- `DCL`:数据库控制语句,比如`grant、revoke`控制权限的语句都属于这个分类。 +- `TCL`:事务控制语句,例如`commit、rollback、setpoint`等语句属于这个分类。 + + + +存储过程:是指提前编写好的一段较为常用或复杂`SQL`语句,然后指定一个名称存储起来,然后先经过编译、优化,完成后,这个“过程”会被嵌入到`MySQL`中。 + + + +触发器则是一种特殊的存储过程,但[触发器]与[存储过程]的不同点在于:**存储过程需要手动调用后才可执行,而触发器可由某个事件主动触发执行**。在`MySQL`中支持`INSERT、UPDATE、DELETE`三种事件触发,同时也可以通过`AFTER、BEFORE`语句声明触发的时机,是在操作执行之前还是执行之后。 + + + +### 执行SQL + +#### 预处理阶段 + +检查 SQL 查询语句中的表或者字段是否存在; + +将 `select *` 中的 `*` 符号,扩展为表上的所有列; + +#### 优化阶段 + +**优化器主要负责将 SQL 查询语句的执行方案确定下来**,比如在表里面有多个索引的时候,优化器会基于查询成本的考虑,来决定选择使用哪个索引。 + +`MySQL`优化器的一些优化准则如下: + +- 多条件查询时,重排条件先后顺序,将效率更好的字段条件放在前面。 + +- 当表中存在多个索引时,选择效率最高的索引作为本次查询的目标索引。 + +- 使用分页`Limit`关键字时,查询到对应的数据条数后终止扫表。 + +- 多表`join`联查时,对查询表的顺序重新定义,同样以效率为准。 + +- 对于`SQL`中使用函数时,如`count()、max()、min()...`,根据情况选择最优方案。 + +- - `max()`函数:走`B+`树最右侧的节点查询(大的在右,小的在左)。 + - `min()`函数:走`B+`树最左侧的节点查询。 + - `count()`函数:如果是`MyISAM`引擎,直接获取引擎统计的总行数。 + +- 对于`group by`分组排序,会先查询所有数据后再统一排序,而不是一开始就排序。 + + + +#### 执行阶段 + +在执行的过程中,执行器就会和存储引擎交互,交互是以记录为单位的。 + + + +## update 语句的执行过程 + +查询语句的那一套流程,更新语句也是同样会走一遍: + +1. 客户端先通过连接器建立连接,连接器自会判断用户身份、权限校验; +2. 因为这是一条 update 语句,所以不需要经过查询缓存,但是表上有更新语句,是会把整个表的查询缓存清空的,所以说查询缓存很鸡肋,在 MySQL 8.0 就被移除这个功能了; +3. 解析器会通过词法分析识别出关键字 update,表名等等,构建出语法树,接着还会做语法分析,判断输入的语句是否符合 MySQL 语法; +4. 预处理器会判断表和字段是否存在; +5. 优化器确定执行计划; +6. 执行器负责具体执行。 + +与查询流程不一样的是,更新流程还涉及两个重要日志模块:redo log(重做日志)和 bin log(归档日志)。 + +update执行流程:` update T set c=c+1 where ID=2;` + +7. 执行器先找引擎取ID=2这一行。如果ID=2这一行所在的数据页本来就在内存中,就直接返回给执行器;否则,需要先从磁盘读入内存,然后再返回。 + +8. 执行器拿到引擎给的行数据,把这个值加上1,比如原来是N,现在就是N+1,得到新的一行数据,再调用引擎接口写入这行新数据。 + +9. 引擎将这行新数据更新到内存中,同时将这个更新操作记录到 undo-log 和 redo log(prepare状态)里面。然后告知执行器执行完成了,随时可以提交事务。 + +10. 执行器生成这个操作的binlog,并把binlog写入磁盘。 + +11. 执行器调用引擎的提交事务接口,引擎把刚刚写入的redo log改成提交(commit)状态,更新完成。 + +![](MySQL\update.png) + +图中浅色框表示是在InnoDB内部执行的,深色框表示是在执行器中执行的。 + +将 redo log 的写入拆成了两个步骤: prepare 和 commit,这就是"两阶段提交"。为了使两个日志之间保持一致: + +1. 当在写bin log之前崩溃时:此时 binlog 还没写,redo log 也还没提交,事务会回滚。 日志保持一致 + +2. 当在写bin log之后崩溃时: 重启恢复后虽没有commit,但满足prepare和binlog完整,自动commit。日志保持一致 + +溃恢复时的判断规则: + +1. 如果 redo log 里面的事务是完整的,也就是已经有了 commit 标识,则直接提交; +2. 如果 redo log 里面的事务只有完整的 prepare,则判断对应的事务 binlog 是否存在并完整: + 1. 如果是,则提交事务; + 2. 否则,回滚事务。 + + + +## MySQL一行记录的存储结构 + +先来看看 MySQL 数据库的文件存放在哪个目录? + +```sh +mysql> SHOW VARIABLES LIKE 'datadir'; ++---------------+-----------------+ +| Variable_name | Value | ++---------------+-----------------+ +| datadir | /var/lib/mysql/ | ++---------------+-----------------+ +1 row in set (0.00 sec) +``` + +我们每创建一个 database(数据库) 都会在 /var/lib/mysql/ 目录里面创建一个以 database 为名的目录,然后保存表结构和表数据的文件都会存放在这个目录里。 + +- db.opt,用来存储当前数据库的默认字符集和字符校验规则。 +- 表名.frm ,数据库表的**表结构**会保存在这个文件。在 MySQL 中建立一张表都会生成一个.frm 文件,该文件是用来保存每个表的元数据信息的,主要包含表结构定义。 +- 表名.ibd,数据库表的**表数据**会保存在这个文件。 MySQL 中每一张表的数据都存放在一个独立的 .ibd 文件。 + + + +### 表空间 + +**表空间由段(segment)、区(extent)、页(page)、行(row)组成**,InnoDB存储引擎的逻辑存储结构大致如下图: + +![](.\MySql\表空间结构.drawio.webp) + +> 记录是按照行来存储的 +> +> InnoDB 的数据是按「页」为单位来读写的,默认每个页的大小为 16KB。一次最少从磁盘中读取 16K 的内容到内存中,一次最少把内存中的 16K 内容刷新到磁盘中。 + + + +**区(extent)** + +我们知道 InnoDB 存储引擎是用 B+ 树来组织数据的。 + +B+ 树中每一层都是通过双向链表连接起来的,如果是以页为单位来分配存储空间,那么链表中相邻的两个页之间的物理位置并不是连续的,可能离得非常远,那么磁盘查询时就会有大量的随机I/O,随机 I/O 是非常慢的。 + +解决这个问题也很简单,就是让链表中相邻的页的物理位置也相邻,这样就可以使用顺序 I/O 了,那么在范围查询(扫描叶子节点)的时候性能就会很高。 + +那具体怎么解决呢? + +**在表中数据量大的时候,为某个索引分配空间的时候就不再按照页为单位分配了,而是按照区(extent)为单位分配。每个区的大小为 1MB,对于 16KB 的页来说,连续的 64 个页会被划为一个区,这样就使得链表中相邻的页的物理位置也相邻,就能使用顺序 I/O 了**. + + + +表空间是由各个段(segment)组成的,段是由多个区(extent)组成的。段一般分为数据段、索引段和回滚段等。 + + + +### InnoDB 行格式 + +InnoDB 提供了 4 种行格式,分别是 Redundant、Compact、Dynamic和 Compressed 行格式。 + +Compact 行格式: + +![](.\MySql\COMPACT.drawio.webp) + +#### 记录的额外信息 + +##### **变长字段长度列表** + +varchar(n) 和 char(n) 的区别是char 是定长的,varchar 是变长的,变长字段实际存储的数据的长度(大小)是不固定的。 + +所以,在存储数据的时候,也要把数据占用的大小存起来,存到「变长字段长度列表」里面,读取数据的时候才能根据这个「变长字段长度列表」去读取对应长度的数据。其他 TEXT、BLOB 等变长字段也是这么实现的。 + +![](.\MySql\t_test.webp) + +第一条记录: + +- name 列的值为 a,真实数据占用的字节数是 1 字节,十六进制 0x01; +- phone 列的值为 123,真实数据占用的字节数是 3 字节,十六进制 0x03; +- age 列和 id 列不是变长字段,所以这里不用管。 + +这些变长字段的真实数据占用的字节数会按照列的顺序**逆序存放**,所以「变长字段长度列表」里的内容是「 03 01」,而不是 「01 03」。 + +![](.\MySql\变长字段长度列表1.webp) + + + +> **为什么「变长字段长度列表」的信息要按照逆序存放?** + +这个设计是有想法的,主要是因为「记录头信息」中指向下一个记录的指针,指向的是下一条记录的「记录头信息」和「真实数据」之间的位置,这样的好处是向左读就是记录头信息,向右读就是真实数据,比较方便。 + +「变长字段长度列表」中的信息之所以要逆序存放,是因为这样可以**使得位置靠前的记录的真实数据和数据对应的字段长度信息可以同时在一个 CPU Cache Line 中,这样就可以提高 CPU Cache 的命中率**。 + + + +> **每个数据库表的行格式都有「变长字段字节数列表」吗?** + +**当数据表没有变长字段的时候,比如全部都是 int 类型的字段,这时候表里的行格式就不会有「变长字段长度列表」了**,因为没必要,不如去掉以节省空间。 + + + +##### NULL值列表 + +表中的某些列可能会存储 NULL 值,如果把这些 NULL 值都放到记录的真实数据中会比较浪费空间,所以 Compact 行格式把这些值为 NULL 的列存储到 NULL值列表中。 + +如果存在允许 NULL 值的列,则每个列对应一个二进制位(bit),二进制位按照列的顺序**逆序排列**。 + +- 二进制位的值为`1`时,代表该列的值为NULL。 +- 二进制位的值为`0`时,代表该列的值不为NULL。 + +另外,NULL 值列表必须用**整数个字节**的位表示(1字节8位),如果使用的二进制位个数不足整数个字节,则在字节的高位补 `0`。 + +第三条记录 phone 列 和 age 列是 NULL 值,所以,对于第三条数据,NULL 值列表用十六进制表示是 0x06。 + +![](.\MySql\null值列表4.webp) + +> **每个数据库表的行格式都有「NULL 值列表」吗?** + +**当数据表的字段都定义成 NOT NULL 的时候,这时候表里的行格式就不会有 NULL 值列表了**。 + +所以在设计数据库表的时候,通常都是建议将字段设置为 NOT NULL,这样可以至少节省 1 字节的空间 + + + +##### 记录头信息 + +记录头信息中包含的内容很多: + +- delete_mask :标识此条数据是否被删除。从这里可以知道,我们执行 detele 删除记录的时候,并不会真正的删除记录,只是将这个记录的 delete_mask 标记为 1。 +- next_record:下一条记录的位置。从这里可以知道,记录与记录之间是通过链表组织的。在前面我也提到了,指向的是下一条记录的「记录头信息」和「真实数据」之间的位置,这样的好处是向左读就是记录头信息,向右读就是真实数据,比较方便。 +- record_type:表示当前记录的类型,0表示普通记录,1表示B+树非叶子节点记录,2表示最小记录,3表示最大记录 + + + +#### 记录的真实数据 + +记录真实数据部分除了我们定义的字段,还有三个隐藏字段,分别为:row_id、trx_id、roll_pointer。 + +* row_id:如果我们建表的时候指定了主键或者唯一约束列,那么就没有 row_id 隐藏字段了。如果既没有指定主键,又没有唯一约束,那么 InnoDB 就会为记录添加 row_id 隐藏字段。row_id不是必需的,占用 6 个字节。 + +- trx_id:事务id,表示这个数据是由哪个事务生成的。 trx_id是必需的,占用 6 个字节。 + +- roll_pointer:这条记录上一个版本的指针。roll_pointer 是必需的,占用 7 个字节。 + + + +### varchar(n) 中 n 最大取值为多少? + +**MySQL 规定除了 TEXT、BLOBs 这种大对象类型之外,其他所有的列(不包括隐藏列和记录头信息)占用的字节长度加起来不能超过 65535 个字节**。 + +要算 varchar(n) 最大能允许存储的字节数,还要看数据库表的字符集,因为字符集代表着,1个字符要占用多少字节,比如 ascii 字符集, 1 个字符占用 1 字节。 + +存储字段类型为 varchar(n) 的数据时,其实分成了三个部分来存储: + +- 真实数据 +- 真实数据占用的字节数 +- NULL 标识,如果不允许为NULL,这部分不需要 + +所以,我们在算 varchar(n) 中 n 最大值时,需要减去 「变长字段长度列表」和 「NULL 值列表」所占用的字节数的。 + + + +```sql +CREATE TABLE test ( +`name` VARCHAR(65532) NULL +) ENGINE = InnoDB DEFAULT CHARACTER SET = ascii ROW_FORMAT = COMPACT; +``` + +上述例子,在数据库表只有一个 varchar(n) 字段且字符集是 ascii 的情况下,varchar(n) 中 n 最大值 = 65535 - 2 - 1 = 65532。 + + + +### 行溢出后,MySQL 是怎么处理的? + +MySQL 中磁盘和内存交互的基本单位是页,一个页的大小一般是 `16KB`,也就是 `16384字节`,而一个 varchar(n) 类型的列最多可以存储 `65532字节`,一些大对象如 TEXT、BLOB 可能存储更多的数据,这时一个页可能就存不了一条记录。这个时候就会**发生行溢出,多的数据就会存到另外的「溢出页」中**。 + +当发生行溢出时,在记录的真实数据处只会保存该列的一部分数据,而把剩余的数据放在「溢出页」中,然后真实数据处用 20 字节存储指向溢出页的地址,从而可以找到剩余数据所在的页. + + + +## 索引 + +索引分类: + +按「数据结构」分类:**B+tree索引、Hash索引、Full-text索引**。 + +按「物理存储」分类:**聚簇索引(主键索引)、二级索引(辅助索引)**。 + +按「字段特性」分类:**主键索引、唯一索引、普通索引、前缀索引**。 + +按「字段个数」分类:**单列索引、联合索引**。 + +### B+Tree索引 + +***1、B+Tree vs B Tree*** + +B+Tree 只在叶子节点存储数据,而 B 树 的非叶子节点也要存储数据,所以 B+Tree 的单个节点的数据量更小,在相同的**磁盘 I/O 次数**下,就能查询更多的节点。 + +另外,B+Tree 叶子节点采用的是双链表连接,适合 MySQL 中常见的**范围查询**,而 B 树无法做到这一点。 + +B+Tree**插入和删除效率更高**,不会涉及复杂的树的变形 + +***2、B+Tree vs 二叉树*** + +对于有 N 个叶子节点的 B+Tree,其搜索复杂度为`O(logdN)`,其中 d 表示节点允许的最大子节点个数为 d 个。 + +在实际的应用当中, d 值是大于100的,这样就保证了,即使数据达到千万级别时,B+Tree 的高度依然维持在 3~4 层左右,也就是说一次数据查询操作只需要做 3~4 次的磁盘 I/O 操作就能查询到目标数据。 + +而二叉树的每个父节点的儿子节点个数只能是 2 个,意味着其搜索复杂度为 `O(logN)`,这已经比 B+Tree 高出不少,因此二叉树检索到目标数据所经历的磁盘 I/O 次数要更多。如果索引的字段值是按顺序增长的,二叉树会转变为链表结构,检索的过程和全表扫描无异。 + +**3、B+Tree vs 红黑树** + +虽然对比二叉树来说,树高有所降低,但数据量一大时,依旧会有很大的高度。每个节点中只存储一个数据,节点之间还是不连续的,依旧无法利用局部性原理。 + +***4、B+Tree vs Hash*** + +Hash 在做等值查询的时候效率贼快,搜索复杂度为 O(1)。 + +但是 Hash 表不适合做范围查询,它更适合做等值的查询,这也是 B+Tree 索引要比 Hash 表索引有着更广泛的适用场景的原因。 + +### 聚集索引和二级索引 + +![](MySQL\聚集索引和二级索引.webp) + +所以,在查询时使用了二级索引,如果查询的数据能在二级索引里查询的到,那么就不需要回表,这个过程就是覆盖索引。如果查询的数据不在二级索引里,就会先检索二级索引,找到对应的叶子节点,获取到主键值后,然后再检索主键索引,就能查询到数据了,这个过程就是回表。 + +### 主键索引和唯一索引 + +一张表最多只有一个主键索引,索引列的值不允许有空值。 + +唯一索引建立在 UNIQUE 字段上的索引,一张表可以有多个唯一索引,索引列的值必须唯一,但是允许有空值。 + +### 联合索引 + +使用联合索引时,存在**最左匹配原则**,也就是按照最左优先的方式进行索引的匹配。在使用联合索引进行查询的时候,如果不遵循「最左匹配原则」,联合索引会失效。 + +联合索引有一些特殊情况,并不是查询过程使用了联合索引查询,就代表联合索引中的所有字段都用到了联合索引进行索引查询。联合索引的最左匹配原则会一直向右匹配直到遇到「范围查询」就会停止匹配。**也就是范围查询的字段可以用到联合索引,但是在范围查询字段的后面的字段无法用到联合索引**。 + + + +> **`select * from t_table where a > 1 and b = 2`,联合索引(a, b)哪一个字段用到了联合索引的 B+Tree?** + +由于联合索引(二级索引)是先按照 a 字段的值排序的,所以符合 a > 1 条件的二级索引记录肯定是相邻,于是在进行索引扫描的时候,可以定位到符合 a > 1 条件的第一条记录,然后沿着记录所在的链表向后扫描,直到某条记录不符合 a > 1 条件位置。所以 a 字段可以在联合索引的 B+Tree 中进行索引查询。 + +**但是在符合 a > 1 条件的二级索引记录的范围里,b 字段的值是无序的**。所以 b 字段无法利用联合索引进行索引查询。 + +**这条查询语句只有 a 字段用到了联合索引进行索引查询,而 b 字段并没有使用到联合索引**。 + + + +> **`select * from t_table where a >= 1 and b = 2`,联合索引(a, b)哪一个字段用到了联合索引的 B+Tree?** + +由于联合索引(二级索引)是先按照 a 字段的值排序的,所以符合 >= 1 条件的二级索引记录肯定是相邻,于是在进行索引扫描的时候,可以定位到符合 >= 1 条件的第一条记录,然后沿着记录所在的链表向后扫描,直到某条记录不符合 a>= 1 条件位置。所以 a 字段可以在联合索引的 B+Tree 中进行索引查询。 + +虽然在符合 a>= 1 条件的二级索引记录的范围里,b 字段的值是「无序」的,**但是对于符合 a = 1 的二级索引记录的范围里,b 字段的值是「有序」的**. + +于是,在确定需要扫描的二级索引的范围时,当二级索引记录的 a 字段值为 1 时,可以通过 b = 2 条件减少需要扫描的二级索引记录范围(b 字段可以利用联合索引进行索引查询的意思)。也就是说,从符合 a = 1 and b = 2 条件的第一条记录开始扫描,而不需要从第一个 a 字段值为 1 的记录开始扫描。 + +所以,**Q2 这条查询语句 a 和 b 字段都用到了联合索引进行索引查询**。 + + + +> **`SELECT * FROM t_table WHERE a BETWEEN 2 AND 8 AND b = 2`,联合索引(a, b)哪一个字段用到了联合索引的 B+Tree?** + +在 MySQL 中,BETWEEN 包含了 value1 和 value2 边界值,类似于 >= and =<,所以**这条查询语句 a 和 b 字段都用到了联合索引进行索引查询**。 + + + +> **`SELECT * FROM t_user WHERE name like 'j%' and age = 22`,联合索引(name, age)哪一个字段用到了联合索引的 B+Tree?** + +由于联合索引(二级索引)是先按照 name 字段的值排序的,所以前缀为 ‘j’ 的 name 字段的二级索引记录都是相邻的, 于是在进行索引扫描的时候,可以定位到符合前缀为 ‘j’ 的 name 字段的第一条记录,然后沿着记录所在的链表向后扫描,直到某条记录的 name 前缀不为 ‘j’ 为止。 + +虽然在符合前缀为 ‘j’ 的 name 字段的二级索引记录的范围里,age 字段的值是「无序」的,**但是对于符合 name = j 的二级索引记录的范围里,age字段的值是「有序」的** + +所以,**这条查询语句 a 和 b 字段都用到了联合索引进行索引查询**。 + + + +**联合索引的最左匹配原则,在遇到范围查询(如 >、<)的时候,就会停止匹配,也就是范围查询的字段可以用到联合索引,但是在范围查询字段的后面的字段无法用到联合索引。注意,对于 >=、<=、BETWEEN、like 前缀匹配的范围查询,并不会停止匹配**。 + + + +**索引下推:** + +对于联合索引(a, b),在执行 `select * from table where a > 1 and b = 2` 语句的时候,只有 a 字段能用到索引,那在联合索引的 B+Tree 找到第一个满足条件的主键值后,还需要判断其他条件是否满足(看 b 是否等于 2),那是在联合索引里判断?还是回主键索引去判断呢? + +- 在 MySQL 5.6 之前,只能从 ID2 (主键值)开始一个个回表,到「主键索引」上找出数据行,再对比 b 字段值。 +- 而 MySQL 5.6 引入的**索引下推优化**(index condition pushdown), **可以在联合索引遍历过程中,对联合索引中包含的字段先做判断,直接过滤掉不满足条件的记录,减少回表次数**。 + + + +**联合索引进行排序**: + +这里出一个题目,针对针对下面这条 SQL,你怎么通过索引来提高查询效率呢? + +```sql +select * from order where status = 1 order by create_time asc +``` + +有的同学会认为,单独给 status 建立一个索引就可以了。 + +但是更好的方式给 status 和 create_time 列建立一个联合索引,因为这样可以避免 MySQL 数据库发生文件排序。 + +因为在查询时,如果只用到 status 的索引,但是这条语句还要对 create_time 排序,这时就要用文件排序 filesort,也就是在 SQL 执行计划中,Extra 列会出现 Using filesort。 + +所以,要利用索引的有序性,在 status 和 create_time 列建立联合索引,这样根据 status 筛选后的数据就是按照 create_time 排好序的,避免在文件排序,提高了查询效率。 + + + + + +### 索引设计原则 + +什么时候适合索引? + +1. 针对数据量较大,且查询比较繁琐的表建立索引; + +2. 针对于常作为查询条件(where),排序(order by),分组(group by)操作的字段,建立索引; + +3. 尽量选择区分度高的列作为索引,尽量建立唯一索引,区分度越高使用索引的效率越高; + +4. 如果是字符串类型的字段,字段的长度过长,可以针对字段的特点,建立前缀索引; + +5. 尽量使用联合索引,减少单列索引,查询时,联合索引很多时候可以覆盖索引,节省存储空间,避免回表,提高查询效率; + +6. 要控制索引的数量,索引并不是多多益善,索引越多,维护索引结构的代价也就越大,会影响增删改的效率; + +7. 如果索引列不能存储null值,在创建表时使用not null约束它。当优化器知道每列是否包含null值时,它可以更好地确定哪个索引最有效地用于查询。 + +8. 表的主外键或连表字段,必须建立索引,因为能很大程度提升连表查询的性能。 + + + +什么时候不适合索引? + +1. 大量重复值的字段 +2. 当表的数据较少,不应当建立索引,因为数据量不大时,维护索引反而开销更大。 +3. 经常更新的字段,因为索引字段频繁修改,由于要维护 B+Tree的有序性,那么就需要频繁的重建索引,这个过程是会影响数据库性能。 +4. 索引不能参与计算,因此经常带函数查询的字段,并不适合建立索引。 +5. 一张表中的索引数量并不是越多越好,一般控制在`3`,最多不能超过`5`。 +6. 索引的字段值无序时,不推荐建立索引,因为会造成页分裂,尤其是主键索引。 + + + +### 索引优化方法 + +#### 前缀索引优化 + +当字段类型为字符串(varchar, text, longtext等)时,有时候需要索引很长的字符串,导致索引较大,查询是浪费大量的磁盘IO,影响查询效率。此时可以只对字符串的一部分前缀建立索引,节约索引空间,提高索引效率。 + +不过,前缀索引有一定的局限性,例如: + +- order by 就无法使用前缀索引; +- 无法把前缀索引用作覆盖索引; + + + +#### 覆盖索引优化 + +SQL 中 query 的所有字段,在索引 B+Tree 的叶子节点上都能得到,从而不需要通过聚簇索引查询获得,可以避免回表的操作。 + +使用覆盖索引的好处就是,不需要查询出包含整行记录的所有信息,也就减少了大量的 I/O 操作。 + + + +#### 主键索引自增 + +**如果我们使用自增主键**,那么每次插入的新数据就会按顺序添加到当前索引节点的位置,不需要移动已有的数据,当页面写满,就会自动开辟一个新页面。因为每次**插入一条新记录,都是追加操作,不需要重新移动数据**,因此这种插入数据的方法效率非常高。 + +**如果我们使用非自增主键**,由于每次插入主键的索引值都是随机的,因此每次插入新的数据时,就可能会插入到现有数据页中间的某个位置,这将不得不移动其它数据来满足新数据的插入,甚至需要从一个页面复制数据到另外一个页面,我们通常将这种情况称为**页分裂**。**页分裂还有可能会造成大量的内存碎片,导致索引结构不紧凑,从而影响查询效率**。 + + + +主键字段的长度不要太大,因为**主键字段长度越小,意味着二级索引的叶子节点越小(二级索引的叶子节点存放的数据是主键值),这样二级索引占用的空间也就越小**。 + + + +#### 索引最好设置为 NOT NULL + +为了更好的利用索引,索引列要设置为 NOT NULL 约束。有两个原因: + +- 第一原因:索引列存在 NULL 就会导致优化器在做索引选择的时候更加复杂,更加难以优化,因为可为 NULL 的列会使索引、索引统计和值比较都更复杂,比如进行索引统计时,count 会省略值为NULL 的行。 +- 第二个原因:NULL 值是一个没意义的值,但是它会占用物理空间,所以会带来的存储空间的问题,因为 InnoDB 存储记录的时候,如果表中存在允许为 NULL 的字段,那么行格式中**至少会用 1 字节空间存储 NULL 值列表** + + + +### 索引失效 + +1. 左或左右模糊查询 `like %x 或者 like %x%`。 因为索引 B+ 树是按照「索引值」有序排列存储的,只能根据前缀进行比较。 +2. 查询中对索引做了计算、函数、类型转换操作。因为索引保存的是索引字段的原始值,而不是经过函数计算后的值,自然就没办法走索引了。 +3. 联合索引要遵循最左匹配原则 +4. 联合索引中,出现范围查询(>,<),范围查询右侧的列索引失效。 +5. 在 WHERE 子句中,如果在 OR 前的条件列是索引列,而在 OR 后的条件列不是索引列,那么索引会失效。因为 OR 的含义就是两个只要满足一个即可,只要有条件列不是索引列,就会进行全表扫描。 +6. 隐式类型转换 + + + +**索引隐式类型转换**: + +如果索引字段是字符串类型,但是在条件查询中,输入的参数是整型的话,你会在执行计划的结果发现这条语句会走全表扫描; + +但是如果索引字段是整型类型,查询条件中的输入参数即使字符串,是不会导致索引失效,还是可以走索引扫描。 + +MySQL 在遇到字符串和数字比较的时候,会自动把字符串转为数字,然后再进行比较。 + + + +### SQL提示 + +我们在查询时,可以使用mysql的sql提示,加入一些人为的提示来达到优化操作的目的: + +- user index:建议mysql使用哪一个索引完成此次查询(仅仅是建议,mysql内部还会再次进行评估); +- ignore index:忽略指定的索引; +- force index:强制使用索引。 + +```sql +explain select * from tb_user use index(idx_user_pro) where profession=’软件工程’; +explain select * from tb_user use index(idx_user_pro) where profession=’软件工程’; +explain select * from tb_user force index(idx_user_pro) where profession=’软件工程’; +``` + + + +## 事务 + +> **事务的特性** + +* **原子性**:一个事务中的所有操作,要么全部完成,要么全部不完成 + +* **一致性**:是指事务操作前和操作后,数据满足完整性约束,数据库保持一致性状态。 + +* **隔离性**:多个事务同时使用相同的数据时,不会相互干扰,每个事务都有一个完整的数据空间,对其他并发事务是隔离的 + +* **持久性**:事务处理结束后,对数据的修改就是永久的,即便系统故障也不会丢失。 + + + +> **InnoDB 引擎通过什么技术来保证事务的这四个特性的呢?** + +- 持久性是通过 redo log (重做日志)来保证的,宕机后能数据恢复; +- 原子性是通过 undo log(回滚日志) 来保证的,事务能够进行回滚; +- 隔离性是通过 MVCC(多版本并发控制) 或锁机制来保证的; +- 一致性则是通过持久性+原子性+隔离性来保证; + + + +> **并行事务会引发的问题?** + +* **脏读**:读到其他事务未提交的数据; + +* **不可重复读**:一个事务内,前后读取的数据不一致; + +* **幻读**:一个事务中,前后读取的记录数量不一致。事务中同一个查询在不同的时间产生不同的结果集。 + +严重性:脏读 > 不可重读读 > 幻读 + + + +> **隔离级别** + +- **读未提交(read uncommitted)**,指一个事务还没提交时,它做的变更就能被其他事务看到; +- **读提交(read committed)**,指一个事务提交之后,它做的变更才能被其他事务看到; +- **可重复读(repeatable read)**,指一个事务执行过程中看到的数据,一直跟这个事务启动时看到的数据是一致的,**MySQL InnoDB 引擎的默认隔离级别**; +- **串行化(serializable)**;会对记录加上读写锁,在多个事务对这条记录进行读写操作时,如果发生了读写冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行; + + + + + +> **这四种隔离级别具体是如何实现的呢?** + +- 对于「读未提交」隔离级别的事务来说,因为可以读到未提交事务修改的数据,所以直接读取最新的数据就好了; +- 对于「串行化」隔离级别的事务来说,通过加读写锁的方式来避免并行访问; +- 对于「读提交」和「可重复读」隔离级别的事务来说,它们是通过 **Read View 来实现的,它们的区别在于创建 Read View 的时机不同,大家可以把 Read View 理解成一个数据快照,就像相机拍照那样,定格某一时刻的风景。「读提交」隔离级别是在「每个语句执行前」都会重新生成一个 Read View,而「可重复读」隔离级别是「启动事务时」生成一个 Read View,然后整个事务期间都在用这个 Read View**。 + + + +> **MVCC** + +* Read View 中的字段:活跃事务(启动但还未提交的事务)id列表、活跃事务中的最小事务id、下一个事务id、创建RV的事务id +* 聚簇索引记录中两个跟事务有关的隐藏列:事务id、旧版本指针 + +![](.\MySql\mvcc.webp) + +一个事务去访问记录的时候,除了自己的更新记录总是可见之外,还有这几种情况: + +- 如果记录的 trx_id 值小于 Read View 中的 `min_trx_id` 值,表示这个版本的记录是在创建 Read View **前**已经提交的事务生成的,所以该版本的记录对当前事务**可见**。 +- 如果记录的 trx_id 值大于等于 Read View 中的 `max_trx_id` 值,表示这个版本的记录是在创建 Read View **后**才启动的事务生成的,所以该版本的记录对当前事务**不可见**。 +- 如果在二者之间,需要判断 trx_id 是否在 m_ids 列表中: + - 如果记录的 trx_id **在** `m_ids` 列表中,表示生成该版本记录的活跃事务依然活跃着(还没提交事务),所以该版本的记录对当前事务**不可见**。 + - 如果记录的 trx_id **不在** `m_ids`列表中,表示生成该版本记录的活跃事务已经被提交,所以该版本的记录对当前事务**可见**。 + + + +> **可重复读如何工作的?** + +**可重复读隔离级别是启动事务时生成一个 Read View,然后整个事务期间都在用这个 Read View**。 + +如果记录中的 trx_id 小于 Read View中的最小事务id,或者在最小事务id和下一个事务id之间并且不在活跃事务id中,则直接读取记录值; + +如果记录中的 trx_id 在最小事务id和下一个事务id之间并且在活跃事务id中,则**沿着 undo log 旧版本指针往下找旧版本的记录,直到找到 trx_id 「小于」当前事务 的 Read View 中的 min_trx_id 值的第一条记录**。 + + + +> **读已提交如何工作的?** + +**读提交隔离级别是在每次读取数据时,都会生成一个新的 Read View**。 + + + +> **MySQL 可重复读和幻读** + +MySQL InnoDB 引擎的默认隔离级别虽然是「可重复读」,但是它很大程度上避免幻读现象(并不是完全解决了),解决的方案有两种: + +- 针对**快照读**(普通 select 语句),是**通过 MVCC 方式解决了幻读**,因为可重复读隔离级别下,事务执行过程中看到的数据,一直跟这个事务启动时看到的数据是一致的,即使中途有其他事务插入了一条数据,是查询不出来这条数据的,所以就很好了避免幻读问题。 +- 针对**当前读**(select ... for update 等语句,会读取最新的数据),是**通过 next-key lock(记录锁+间隙锁)方式解决了幻读**,因为当执行 select ... for update 语句的时候,会加上 next-key lock,如果有其他事务在 next-key lock 锁范围内执行增、删、改时,就会阻塞,所以就很好了避免幻读问题。 + + + +> **MySQL Innodb 中的 MVCC 并不能完全避免幻读现象** + +第一个发生幻读现象的场景: + +在可重复读隔离级别下,事务 A 第一次执行普通的 select 语句时生成了一个 ReadView,之后事务 B 向表中新插入了一条 id = 5 的记录并提交。接着,事务 A 对 id = 5 这条记录进行了更新操作,在这个时刻,这条新记录的 trx_id 隐藏列的值就变成了事务 A 的事务 id,之后事务 A 再使用普通 select 语句去查询这条记录时就可以看到这条记录了,于是就发生了幻读。 + +![](.\MySql\幻读发生.drawio.webp) + +第二个发生幻读现象的场景: + +T1 时刻:事务 A 先执行「快照读语句」:select * from t_test where id > 100 得到了 3 条记录。 + +T2 时刻:事务 B 往插入一个 id= 200 的记录并提交; + +T3 时刻:事务 A 再执行「当前读语句」 select * from t_test where id > 100 for update 就会得到 4 条记录,此时也发生了幻读现象。 + +**要避免这类特殊场景下发生幻读的现象的话,就是尽量在开启事务之后,马上执行 select ... for update 这类当前读的语句**,因为它会对记录加 next-key lock,从而避免其他事务插入一条新记录。 + + + +## 锁 + +### 全局锁 + +使用全局锁 `flush tables with read lock` 后数据库处于只读状态,`unlock tables` 释放全局锁,会话断开全局锁自动释放。 + +应用场景:全库逻辑备份 + +如果数据库的引擎支持的事务支持**可重复读的隔离级别**,那么在备份数据库之前先开启事务,会先创建 Read View,然后整个事务执行期间都在用这个 Read View,而且由于 MVCC 的支持,备份期间业务依然可以对数据进行更新操作。 + +备份数据库的工具是 mysqldump,在使用 mysqldump 时加上 `–single-transaction` 参数的时候,就会在备份数据库之前先开启事务。这种方法只适用于支持「可重复读隔离级别的事务」的存储引擎。 + + + +### 表级锁 + +MySql表级锁:表锁、元数据锁、意向锁、AUTO-INC锁 + +**表锁** + +```sh +//表级别的共享锁,也就是读锁; +lock tables t_student read; + +//表级别的独占锁,也就是写锁; +lock tables t_stuent write; + +// 释放会话所有表锁,会话退出后,也会释放所有表锁 +unlock tables; +``` + +表锁除了会限制别的线程的读写外,也会限制本线程接下来的读写操作。表锁的颗粒度太大,尽量避免使用。 + + + +**元数据锁(MDL)** + +我们不需要显示的使用 MDL,因为当我们对数据库表进行操作时,会自动给这个表加上 MDL。MDL 是为了保证当用户对表执行 CRUD 操作时,防止其他线程对这个表结构做了变更。 + +- 对一张表进行 CRUD 操作时,加的是 MDL 读锁; +- 对一张表做结构变更操作的时候,加的是 MDL 写锁; + +MDL 是在事务提交后才会释放,这意味着**事务执行期间,MDL 是一直持有的**。 + +申请 MDL 锁的操作会形成一个队列,队列中**写锁获取优先级高于读锁**,一旦出现 MDL 写锁等待,会阻塞后续该表的所有 CRUD 操作。 + + + +**意向锁** + +如果没有「意向锁」,那么加「独占表锁」时,就需要遍历表里所有记录,查看是否有记录存在独占锁,这样效率会很慢。 + +那么有了「意向锁」,由于在对记录加独占锁前,先会加上表级别的意向独占锁,那么在加「独占表锁」时,直接查该表是否有意向独占锁,如果有就意味着表里已经有记录被加了独占锁,这样就不用去遍历表里的记录。 + +所以,**意向锁的目的是为了快速判断表里是否有记录被加锁**。 + +- 在使用 InnoDB 引擎的表里对某些记录加上「共享锁」之前,需要先在表级别加上一个「意向共享锁」; +- 在使用 InnoDB 引擎的表里对某些纪录加上「独占锁」之前,需要先在表级别加上一个「意向独占锁」; + +**意向共享锁和意向独占锁是表级锁,不会和行级的共享锁和独占锁发生冲突,而且意向锁之间也不会发生冲突,只会和共享表锁和独占表锁发生冲突。** + + + +**AUTO-INC 锁** + +声明 `AUTO_INCREMENT` 属性的字段数据库自动赋递增的值,主要是通过 AUTO-INC 锁实现的。 + +AUTO-INC 锁是特殊的表锁机制,锁**不再是一个事务提交后才释放,而是再执行完插入语句后就会立即释放**。 + +在插入数据时,会加一个表级别的 AUTO-INC 锁,然后为被 `AUTO_INCREMENT` 修饰的字段赋值递增的值,等插入语句执行完成后,才会把 AUTO-INC 锁释放掉。 + +在 MySQL 5.1.22 版本开始,InnoDB 存储引擎提供了一种**轻量级的锁**来实现自增。一样也是在插入数据的时候,会为被 `AUTO_INCREMENT` 修饰的字段加上轻量级锁,**然后给该字段赋值一个自增的值,就把这个轻量级锁释放了,而不需要等待整个插入语句执行完后才释放锁**。 + + + +### 行级锁 + +共享锁(S锁)满足读读共享,读写互斥。独占锁(X锁)满足写写互斥、读写互斥。 + +在读已提交隔离级别下,行级锁的种类只有记录锁,也就是仅仅把一条记录锁上。 + +在可重复读隔离级别下,行级锁的种类除了有记录锁,还有间隙锁(目的是为了避免幻读) + +**Record Lock** + +记录锁,也就是仅仅把一条记录锁上; + + + +**Gap Lock** + +间隙锁,锁定一个范围,但是不包含记录本身; + +间隙锁,只存在于可重复读隔离级别,**目的是为了解决可重复读隔离级别下幻读的现象**。 + +**间隙锁之间是兼容的,即两个事务可以同时持有包含共同间隙范围的间隙锁,并不存在互斥关系,因为间隙锁的目的是防止插入幻影记录而提出的**。 + + + +**Next-Key Lock** + +临键锁,Record Lock + Gap Lock 的组合,锁定一个范围,并且锁定记录本身。 + +**如果一个事务获取了 X 型的 next-key lock,那么另外一个事务在获取相同范围的 X 型的 next-key lock 时,是会被阻塞的。** + + + +**插入意向锁** + +一个事务在插入一条记录的时候,需要判断插入位置是否已被其他事务加了间隙锁(next-key lock 也包含间隙锁)。 + +如果有的话,插入操作就会发生**阻塞**,直到拥有间隙锁的那个事务提交为止(释放间隙锁的时刻),在此期间会生成一个**插入意向锁**,表明有事务想在某个区间插入新记录,但是现在处于等待状态。 + + + +### 怎么加行级锁 + +> **什么SQL语句会加行级锁?** + +1. 普通的 select 语句是不会对记录加锁的(除了串行化隔离级别),因为它属于快照读,是通过 MVCC(多版本并发控制)实现的。如果要在查询时对记录加行级锁,可以使用下面这两个方式,这两种查询会加锁的语句称为**锁定读**。 + + ```sql + //对读取的记录加共享锁(S型锁) + select ... lock in share mode; + + //对读取的记录加独占锁(X型锁) + select ... for update; + ``` + + 这两条语句必须在一个事务中,因为当事务提交了,锁就会被释放,所以在使用这两条语句的时候,要加上 begin 或者 start transaction 开启事务的语句。 + +2. update 和 delete 操作都会加行级锁,且锁的类型都是独占锁(X型锁)。 + + + +> **行级锁有哪些?** + +在读已提交隔离级别下,行级锁的种类只有记录锁,也就是仅仅把一条记录锁上。 + +在可重复读隔离级别下,行级锁的种类除了有记录锁,还有间隙锁(目的是为了避免幻读),所以行级锁的种类主要有三类: + +- Record Lock,记录锁,也就是仅仅把一条记录锁上; +- Gap Lock,间隙锁,锁定一个范围,但是不包含记录本身; +- Next-Key Lock:Record Lock + Gap Lock 的组合,锁定一个范围,并且锁定记录本身。 + + + +> **有什么命令可以分析加了什么锁?** + +`select * from performance_schema.data_locks\G;` + +LOCK_TYPE 中的 RECORD 表示行级锁,而不是记录锁的意思。 + +LOCK_MODE 可以确认是 next-key 锁,还是间隙锁,还是记录锁: + +- 如果 LOCK_MODE 为 `X`,说明是 next-key 锁; +- 如果 LOCK_MODE 为 `X, REC_NOT_GAP`,说明是记录锁; +- 如果 LOCK_MODE 为 `X, GAP`,说明是间隙锁; + +![](.\MySql\事务a加锁分析.webp) + + + +**加锁的对象是索引,加锁的基本单位是 next-key lock**,它是由记录锁和间隙锁组合而成的,**next-key lock 是前开后闭区间,而间隙锁是前开后开区间**。 + +但是,next-key lock 在一些场景下会退化成记录锁或间隙锁。 + +那到底是什么场景呢?总结一句,**在能使用记录锁或者间隙锁就能避免幻读现象的场景下, next-key lock 就会退化成记录锁或间隙锁**。 + + + +#### 唯一索引等值查询 + +当我们用唯一索引进行等值查询的时候,查询的记录存不存在,加锁的规则也会不同: + +- 当查询的记录是「存在」的,在索引树上定位到这一条记录后,将该记录的索引中的 next-key lock 会**退化成「记录锁」**。 +- 当查询的记录是「不存在」的,在索引树找到第一条大于该查询记录的记录后,将该记录的索引中的 next-key lock 会**退化成「间隙锁」**。 + + + +> **为什么唯一索引等值查询并且查询记录存在的场景下,该记录的索引中的 next-key lock 会退化成记录锁?** + +原因就是在唯一索引等值查询并且查询记录存在的场景下,仅靠记录锁也能避免幻读的问题。 + +幻读的定义就是,当一个事务前后两次查询的结果集,不相同时,就认为发生幻读。所以,要避免幻读就是避免结果集某一条记录被其他事务删除,或者有其他事务插入了一条新记录,这样前后两次查询的结果集就不会出现不相同的情况。 + +- 由于主键具有唯一性,所以**其他事务插入 id = 1 的时候,会因为主键冲突,导致无法插入 id = 1 的新记录**。这样事务 A 在多次查询 id = 1 的记录的时候,不会出现前后两次查询的结果集不同,也就避免了幻读的问题。 +- 由于对 id = 1 加了记录锁,**其他事务无法删除该记录**,这样事务 A 在多次查询 id = 1 的记录的时候,不会出现前后两次查询的结果集不同,也就避免了幻读的问题。 + + + +> **为什么唯一索引等值查询并且查询记录「不存在」的场景下,在索引树找到第一条大于该查询记录的记录后,要将该记录的索引中的 next-key lock 会退化成「间隙锁」?** + +原因就是在唯一索引等值查询并且查询记录不存在的场景下,仅靠间隙锁就能避免幻读的问题。 + +- 为什么 id = 5 记录上的主键索引的锁不可以是 next-key lock?如果是 next-key lock,就意味着其他事务无法删除 id = 5 这条记录,但是这次的案例是查询 id = 2 的记录,只要保证前后两次查询 id = 2 的结果集相同,就能避免幻读的问题了,所以即使 id =5 被删除,也不会有什么影响,那就没必须加 next-key lock,因此只需要在 id = 5 加间隙锁,避免其他事务插入 id = 2 的新记录就行了。 +- 为什么不可以针对不存在的记录加记录锁?锁是加在索引上的,而这个场景下查询的记录是不存在的,自然就没办法锁住这条不存在的记录。 + + + +#### 唯一索引范围查询 + +当唯一索引进行范围查询时,**会对每一个扫描到的索引加 next-key 锁,然后如果遇到下面这些情况,会退化成记录锁或者间隙锁**: + +- 情况一:针对「大于等于」的范围查询,因为存在等值查询的条件,那么如果等值查询的记录是存在于表中,那么该记录的索引中的 next-key 锁会**退化成记录锁**。 +- 情况二:针对「小于或者小于等于」的范围查询,要看条件值的记录是否存在于表中: + - 当条件值的记录不在表中,那么不管是「小于」还是「小于等于」条件的范围查询,**扫描到终止范围查询的记录时,该记录的索引的 next-key 锁会退化成间隙锁**,其他扫描到的记录,都是在这些记录的索引上加 next-key 锁。 + - 当条件值的记录在表中,如果是「小于」条件的范围查询,**扫描到终止范围查询的记录时,该记录的索引的 next-key 锁会退化成间隙锁**,其他扫描到的记录,都是在这些记录的索引上加 next-key 锁;如果「小于等于」条件的范围查询,扫描到终止范围查询的记录时,该记录的索引 next-key 锁不会退化成间隙锁。其他扫描到的记录,都是在这些记录的索引上加 next-key 锁。 + + + +#### 非唯一索引等值查询 + +当我们用非唯一索引进行等值查询的时候,**因为存在两个索引,一个是主键索引,一个是非唯一索引(二级索引),所以在加锁时,同时会对这两个索引都加锁,但是对主键索引加锁的时候,只有满足查询条件的记录才会对它们的主键索引加锁**。 + +针对非唯一索引等值查询时,查询的记录存不存在,加锁的规则也会不同: + +- 当查询的记录「存在」时,由于不是唯一索引,所以肯定存在索引值相同的记录,于是**非唯一索引等值查询的过程是一个扫描的过程,直到扫描到第一个不符合条件的二级索引记录就停止扫描,然后在扫描的过程中,对扫描到的二级索引记录加的是 next-key 锁,而对于第一个不符合条件的二级索引记录,该二级索引的 next-key 锁会退化成间隙锁。同时,在符合查询条件的记录的主键索引上加记录锁**。 +- 当查询的记录「不存在」时,**扫描到第一条不符合条件的二级索引记录,该二级索引的 next-key 锁会退化成间隙锁。因为不存在满足查询条件的记录,所以不会对主键索引加锁**。 + + + +> **针对非唯一索引等值查询时,查询的值不存在的情况。** + +执行 `select * from user where age = 25 for update;` 定位到第一条不符合查询条件的二级索引记录,即扫描到 age = 39,于是**该二级索引的 next-key 锁会退化成间隙锁,范围是 (22, 39)**。 + +![](.\MySql\非唯一索引等值查询age=25.drawio.webp) + +其他事务无法插入 age 值为 23、24、25、26、....、38 这些新记录。不过对于插入 age = 22 和 age = 39 记录的语句,在一些情况是可以成功插入的,而一些情况则无法成功插入。 + +**插入语句在插入一条记录之前,需要先定位到该记录在 B+树 的位置,如果插入的位置的下一条记录的索引上有间隙锁,才会发生阻塞**。 + +插入 age = 22 记录的成功和失败的情况分别如下: + +- 当其他事务插入一条 age = 22,id = 3 的记录的时候,在二级索引树上定位到插入的位置,而该位置的下一条是 id = 10、age = 22 的记录,该记录的二级索引上没有间隙锁,所以这条插入语句可以执行成功。 +- 当其他事务插入一条 age = 22,id = 12 的记录的时候,在二级索引树上定位到插入的位置,而该位置的下一条是 id = 20、age = 39 的记录,正好该记录的二级索引上有间隙锁,所以这条插入语句会被阻塞,无法插入成功。 + +插入 age = 39 记录的成功和失败的情况分别如下: + +- 当其他事务插入一条 age = 39,id = 3 的记录的时候,在二级索引树上定位到插入的位置,而该位置的下一条是 id = 20、age = 39 的记录,正好该记录的二级索引上有间隙锁,所以这条插入语句会被阻塞,无法插入成功。 +- 当其他事务插入一条 age = 39,id = 21 的记录的时候,在二级索引树上定位到插入的位置,而该位置的下一条记录不存在,也就没有间隙锁了,所以这条插入语句可以插入成功。 + +所以,**当有一个事务持有二级索引的间隙锁 (22, 39) 时,插入 age = 22 或者 age = 39 记录的语句是否可以执行成功,关键还要考虑插入记录的主键值,因为「二级索引值(age列)+主键值(id列)」才可以确定插入的位置,确定了插入位置后,就要看插入的位置的下一条记录是否有间隙锁,如果有间隙锁,就会发生阻塞,如果没有间隙锁,则可以插入成功**。 + + + +> **针对非唯一索引等值查询时,查询的值存在的情况。** + +执行 `select * from user where age = 22 for update;` + +![](.\MySql\非唯一索引等值查询存在.drawio.webp) + +在 age = 22 这条记录的二级索引上,加了范围为 (21, 22] 的 next-key 锁,意味着其他事务无法更新或者删除 age = 22 的这一些新记录,不过对于插入 age = 21 和 age = 22 新记录的语句,在一些情况是可以成功插入的,而一些情况则无法成功插入。 + +- 是否可以插入 age = 21 的新记录,还要看插入的新记录的 id 值,**如果插入 age = 21 新记录的 id 值小于 5,那么就可以插入成功**,因为此时插入的位置的下一条记录是 id = 5,age = 21 的记录,该记录的二级索引上没有间隙锁。**如果插入 age = 21 新记录的 id 值大于 5,那么就无法插入成功**,因为此时插入的位置的下一条记录是 id = 10,age = 22 的记录,该记录的二级索引上有间隙锁。 +- 是否可以插入 age = 22 的新记录,还要看插入的新记录的 id 值,从 `LOCK_DATA : 22, 10` 可以得知,其他事务插入 age 值为 22 的新记录时,**如果插入的新记录的 id 值小于 10,那么插入语句会发生阻塞;如果插入的新记录的 id 大于 10,还要看该新记录插入的位置的下一条记录是否有间隙锁,如果没有间隙锁则可以插入成功,如果有间隙锁,则无法插入成功**。 + +在 age = 39 这条记录的二级索引上,加了范围 (22, 39) 的间隙锁。意味着其他事务无法插入 age 值为 23、24、..... 、38 的这一些新记录。不过对于插入 age = 22 和 age = 39 记录的语句,在一些情况是可以成功插入的,而一些情况则无法成功插入。 + +- 是否可以插入 age = 22 的新记录,还要看插入的新记录的 id 值,**如果插入 age = 22 新记录的 id 值小于 10,那么插入语句会被阻塞,无法插入**,因为此时插入的位置的下一条记录是 id = 10,age = 22 的记录,该记录的二级索引上有间隙锁( age = 22 这条记录的二级索引上有 next-key 锁)。**如果插入 age = 22 新记录的 id 值大于 10,也无法插入**,因为此时插入的位置的下一条记录是 id = 20,age = 39 的记录,该记录的二级索引上有间隙锁。 +- 是否可以插入 age = 39 的新记录,还要看插入的新记录的 id 值,从 `LOCK_DATA : 39, 20` 可以得知,其他事务插入 age 值为 39 的新记录时,**如果插入的新记录的 id 值小于 20,那么插入语句会发生阻塞,如果插入的新记录的 id 大于 20,则可以插入成功**。 + + + +#### 非唯一索引范围查询 + +**非唯一索引范围查询,索引的 next-key lock 不会有退化为间隙锁和记录锁的情况**,也就是非唯一索引进行范围查询时,对二级索引记录加锁都是加 next-key 锁。 + +执行 `select * from user where age >= 22 for update;` + +![](.\MySql\非唯一索引范围查询age大于等于22.drawio.webp) + +是否可以插入age = 21、age = 22 和 age = 39 的新记录,还需要看新记录的 id 值。 + +> **在 age >= 22 的范围查询中,明明查询 age = 22 的记录存在并且属于等值查询,为什么不会像唯一索引那样,将 age = 22 记录的二级索引上的 next-key 锁退化为记录锁?** + +因为 age 字段是非唯一索引,不具有唯一性,所以如果只加记录锁(记录锁无法防止插入,只能防止删除或者修改),就会导致其他事务插入一条 age = 22 的记录,这样前后两次查询的结果集就不相同了,出现了幻读现象。 + +#### 没有索引的查询 + +**如果锁定读查询语句,没有使用索引列作为查询条件,或者查询语句没有走索引查询,导致扫描是全表扫描。那么,每一条记录的索引上都会加 next-key 锁,这样就相当于锁住的全表,这时如果其他事务对该表进行增、删、改操作的时候,都会被阻塞**。 + +不只是锁定读查询语句不加索引才会导致这种情况,update 和 delete 语句如果查询条件不加索引,那么由于扫描的方式是全表扫描,于是就会对每一条记录的索引上都会加 next-key 锁,这样就相当于锁住的全表。 + +因此,**在线上在执行 update、delete、select ... for update 等具有加锁性质的语句,一定要检查语句是否走了索引,如果是全表扫描的话,会对每一个索引加 next-key 锁,相当于把整个表锁住了**,这是挺严重的问题。 + + + +### Insert语句怎么加行级锁 + +Insert 语句在正常执行时是不会生成锁结构的,它是靠聚簇索引记录自带的 trx_id 隐藏列来作为**隐式锁**来保护记录的。 + +> 什么是隐式锁? + +当事务需要加锁的时,如果这个锁不可能发生冲突,InnoDB会跳过加锁环节,这种机制称为隐式锁。隐式锁是 InnoDB 实现的一种延迟加锁机制,其特点是只有在可能发生冲突时才加锁,从而减少了锁的数量,提高了系统整体性能。 + +隐式锁就是在 Insert 过程中不加锁,只有在特殊情况下,才会将隐式锁转换为显示锁,这里我们列举两个场景。 + +- 如果记录之间加有间隙锁,为了避免幻读,此时是不能插入记录的; +- 如果 Insert 的记录和已有记录存在唯一键冲突,此时也不能插入记录; + +**记录之间加有间隙锁:** + +每插入一条新记录,都需要看一下待插入记录的下一条记录上是否已经被加了间隙锁,如果已加间隙锁,此时会生成一个插入意向锁,然后锁的状态设置为等待状态(*PS:MySQL 加锁时,是先生成锁结构,然后设置锁的状态,如果锁状态是等待状态,并不是意味着事务成功获取到了锁,只有当锁状态为正常状态时,才代表事务成功获取到了锁*),现象就是 Insert 语句会被阻塞。 + +**遇到唯一键冲突:** + +如果在插入新记录时,插入了一个与「已有的记录的主键或者唯一二级索引列值相同」的记录(不过可以有多条记录的唯一二级索引列的值同时为NULL,这里不考虑这种情况),此时插入就会失败,然后对于这条记录加上了 **S 型的锁**。 + +- 如果主键索引重复,插入新记录的事务会给已存在的主键值重复的聚簇索引记录**添加 S 型记录锁**。 +- 如果唯一二级索引重复,插入新记录的事务都会给已存在的二级索引列值重复的二级索引记录**添加 S 型 next-key 锁** + + + +> **两个事务执行过程中,执行了相同的 insert 语句的场景** + +![](.\MySql\唯一索引加锁.drawio.webp) + +两个事务的加锁过程: + +- 事务 A 先插入 order_no 为 1006 的记录,可以插入成功,此时对应的唯一二级索引记录被「隐式锁」保护,此时还没有实际的锁结构(执行完这里的时候,你可以看查 performance_schema.data_locks 信息,可以看到这条记录是没有加任何锁的); +- 接着,事务 B 也插入 order_no 为 1006 的记录,由于事务 A 已经插入 order_no 值为 1006 的记录,所以事务 B 在插入二级索引记录时会遇到重复的唯一二级索引列值,此时事务 B 想获取一个 S 型 next-key 锁,但是事务 A 并未提交,**事务 A 插入的 order_no 值为 1006 的记录上的「隐式锁」会变「显示锁」且锁类型为 X 型的记录锁,所以事务 B 向获取 S 型 next-key 锁时会遇到锁冲突,事务 B 进入阻塞状态**。 + +并发多个事务的时候,第一个事务插入的记录,并不会加锁,而是会用隐式锁保护唯一二级索引的记录。 + +但是当第一个事务还未提交的时候,有其他事务插入了与第一个事务相同的记录,第二个事务就会**被阻塞**,**因为此时第一事务插入的记录中的隐式锁会变为显示锁且类型是 X 型的记录锁,而第二个事务是想对该记录加上 S 型的 next-key 锁,X 型与 S 型的锁是冲突的**,所以导致第二个事务会等待,直到第一个事务提交后,释放了锁。 + + + +## 日志 + +**undo log(回滚日志)**:是 Innodb 存储引擎层生成的日志,实现了事务中的**原子性**,主要**用于事务回滚和 MVCC**。 + +**redo log(重做日志)**:是 Innodb 存储引擎层生成的日志,实现了事务中的**持久性**,主要**用于掉电等故障恢复**; + +**binlog (归档日志)**:是 Server 层生成的日志,主要**用于数据备份和主从复制**; + + + +> **redo log 和 undo log 区别在哪?** + +这两种日志是属于 InnoDB 存储引擎的日志,它们的区别在于: + +- redo log 记录了此次事务「**完成后**」的数据状态,记录的是更新**之后**的值; +- undo log 记录了此次事务「**开始前**」的数据状态,记录的是更新**之前**的值; + +事务提交之前发生了崩溃,重启后会通过 undo log 回滚事务,事务提交之后发生了崩溃,重启后会通过 redo log 恢复事务 + + + +> **redo log 要写到磁盘,数据也要写磁盘,为什么要多此一举?** + +写入 redo log 的方式使用了追加操作, 所以磁盘操作是**顺序写**,而写入数据需要先找到写入位置,然后才写到磁盘,所以磁盘操作是**随机写**。 + +磁盘的「顺序写 」比「随机写」 高效的多,因此 redo log 写入磁盘的开销更小。 + +可以说这是 WAL 技术的另外一个优点:**MySQL 的写操作从磁盘的「随机写」变成了「顺序写」**,提升语句的执行性能。这是因为 MySQL 的写操作并不是立刻更新到磁盘上,而是先记录在日志上,然后在合适的时间再更新到磁盘上 。 + +至此, 针对为什么需要 redo log 这个问题我们有两个答案: + +- **实现事务的持久性,让 MySQL 有 crash-safe(奔溃恢复) 的能力**,能够保证 MySQL 在任何时间段突然崩溃,重启后之前已提交的记录都不会丢失; +- **将写操作从「随机写」变成了「顺序写」**,提升 MySQL 写入磁盘的性能。 + + + +> **缓存在 redo log buffer 里的 redo log 还是在内存中,它什么时候刷新到磁盘?** + +主要有下面几个时机: + +- MySQL 正常关闭时; +- 当 redo log buffer 中记录的写入量大于 redo log buffer 内存空间的一半时,会触发落盘; +- InnoDB 的后台线程每隔 1 秒,将 redo log buffer 持久化到磁盘。 +- 每次事务提交时都将缓存在 redo log buffer 里的 redo log 直接持久化到磁盘 + + + +>**redo log 和 binlog 有什么区别?** + +1. 适用对象不同: + * binlog 是 MySQL 的 Server 层实现的日志,所有存储引擎都可以使用; + * redo log 是 Innodb 存储引擎实现的日志; + +2. 文件格式不同: + + - binlog 有 3 种格式类型,分别是 STATEMENT(默认格式)、ROW、 MIXED,区别如下: + - STATEMENT:每一条修改数据的 SQL 都会被记录到 binlog 中(相当于记录了逻辑操作,所以针对这种格式, binlog 可以称为逻辑日志),主从复制中 slave 端再根据 SQL 语句重现。但 STATEMENT 有动态函数的问题,比如你用了 uuid 或者 now 这些函数,你在主库上执行的结果并不是你在从库执行的结果,这种随时在变的函数会导致复制的数据不一致; + - ROW:记录行数据最终被修改成什么样了(这种格式的日志,就不能称为逻辑日志了),不会出现 STATEMENT 下动态函数的问题。但 ROW 的缺点是每行数据的变化结果都会被记录,比如执行批量 update 语句,更新多少行数据就会产生多少条记录,使 binlog 文件过大,而在 STATEMENT 格式下只会记录一个 update 语句而已; + - MIXED:包含了 STATEMENT 和 ROW 模式,它会根据不同的情况自动使用 ROW 模式和 STATEMENT 模式; + + - redo log 是物理日志,记录的是在某个数据页做了什么修改,比如对 XXX 表空间中的 YYY 数据页 ZZZ 偏移量的地方做了AAA 更新; + +3. 写入方式不同: + + - binlog 是追加写,写满一个文件,就创建一个新的文件继续写,不会覆盖以前的日志,保存的是全量的日志。 + + - redo log 是循环写,日志空间大小是固定,全部写满就从头开始,保存未被刷入磁盘的脏页日志。 + +4. 用途不同: + + - binlog 用于备份恢复、主从复制; + + - redo log 用于掉电等故障恢复。 + + + +> **binlog 什么时候刷盘?** + +在事务提交的时候,执行器把 binlog cache 里的完整事务写入到 binlog 文件中,但是并没有把数据持久化到磁盘,因为数据还缓存在文件系统的 page cache 里,后续由sync_binlog 参数来控制数据库的 binlog 刷到磁盘上的频率。 + + + +### 两阶段提交 + + + +> **为什么需要两阶段提交?** + +redo log 影响主库的数据,binlog 影响从库的数据,所以 redo log 和 binlog 必须保持一致才能保证主从数据一致。 + +MySQL 为了避免出现两份日志之间的逻辑不一致的问题,使用了「两阶段提交」来解决。 + + + +> **两阶段提交的过程是怎样的?** + +两个阶段提交就是**将 redo log 的写入拆成了两个步骤:prepare 和 commit,中间再穿插写入binlog**,具体如下: + +- **prepare 阶段**: 将 redo log 持久化到磁盘(innodb_flush_log_at_trx_commit = 1 的作用),将 redo log 对应的事务状态设置为 prepare; +- **commit 阶段**:将 binlog 持久化到磁盘(sync_binlog = 1 的作用),然后将 redo log 状态设置为 commit。 + +当在写bin log之前崩溃时:此时 binlog 还没写,redo log 也还没提交,事务会回滚。 日志保持一致 + +当在写bin log之后崩溃时: 重启恢复后虽没有commit,但满足prepare和binlog完整,自动commit。日志保持一致 + + + +> **两阶段提交有什么问题?** + +* **磁盘IO次数高**:对于“双1”配置,每个事务提交都会进行两次 fsync(刷盘),一次是 redo log 刷盘,另一次是 binlog 刷盘。 +* **锁竞争激烈**:两阶段提交虽然能够保证「单事务」两个日志的内容一致,但在「多事务」的情况下,却不能保证两者的提交顺序一致,因此,在两阶段提交的流程基础上,还需要加一个锁来保证提交的原子性,从而保证多事务的情况下,两个日志的提交顺序一致。 + + + +### 组提交 + +MySQL 引入了 binlog 组提交(group commit)机制,当有多个事务提交的时候,会将多个 binlog 刷盘操作合并成一个,从而减少磁盘 I/O 的次数。 + +引入了组提交机制后,prepare 阶段不变,只针对 commit 阶段,将 commit 阶段拆分为三个过程: + +- **flush 阶段**:多个事务按进入的顺序将 binlog 从 cache 写入文件(不刷盘); +- **sync 阶段**:对 binlog 文件做 fsync 操作(多个事务的 binlog 合并一次刷盘); +- **commit 阶段**:各个事务按顺序做 InnoDB commit 操作; + +上面的**每个阶段都有一个队列**,每个阶段有锁进行保护,因此保证了事务写入的顺序,第一个进入队列的事务会成为 leader,leader领导所在队列的所有事务,全权负责整队的操作,完成后通知队内其他事务操作结束。 + + + +## SQL优化 + +### 主键优化 + +1. 满足业务需求的情况下,尽量降低主键的长度; + +2. 插入数据时,尽量选择顺序插入,选择使用AUTO_INCREMENT自增主键; + +3. 尽量不要使用UUID做主键或者是其他自然主键,如身份证号; + +4. 业务操作时,避免对主键的修改。 + +### order by优化 + +MySQL的排序,有两种方式: + +![](MySql\orderby优化.webp) + +对于以上的两种排序方式,Using index的性能高,而Using filesort的性能低,我们在优化排序操作时,尽量要优化为 Using index。 + +order by优化原则: + +1. 根据排序字段建立合适的索引,多字段排序时,也遵循最左前缀法则; + +2. 尽量使用覆盖索引; + +3. 多字段排序, 一个升序一个降序,此时需要注意联合索引在创建时的规则(ASC/DESC); + +4. 如果不可避免的出现filesort,大数据量排序时,可以适当增大排序缓冲区大小sort_buffer_size(默认256k)。 + +### group by优化 + +在分组操作中,我们需要通过以下两点进行优化,以提升性能: + +1. 在分组操作时,可以通过索引来提高效率; + +2. 分组操作时,索引的使用也是满足最左前缀法则的。 + +### limit优化 + +在数据量比较大时,如果进行limit分页查询,在查询时,越往后,分页查询效率越低。 + +因为,当在进行分页查询时,如果执行 limit 2000000,10 ,此时需要MySQL排序前2000010 记录,仅仅返回 2000000 - 2000010 的记录,其他记录丢弃,查询排序的代价非常大 。 + +优化思路: 一般分页查询时,通过创建覆盖索引能够比较好地提高性能,可以通过覆盖索引加子查询形式进行优化。 + +```sql +explain select * from tb_sku t , (select id from tb_sku order by id limit 2000000,10) a where t.id = a.id; +``` + + + +### count优化 + +如果数据量很大,在执行count操作时,是非常耗时的。InnoDB 引擎中,它执行 count(*) 的时候,需要把数据一行一行地从引擎里面读出来,然后累积计数。 + + + +count() 是一个聚合函数,对于返回的结果集,一行行地判断,如果 count 函数的参数不是 NULL,累计值就加 1,否则不加,最后返回累计值。 + +![](MySql\count.webp) + +性能: + +```sql +count(*) = count(1) > count(主键字段) > count(字段) +``` + +count(1)、 count(*)、 count(主键字段)在执行的时候,如果表里存在二级索引,优化器就会选择二级索引进行扫描。因为二级索引记录比聚簇索引记录占用更少的存储空间。 + +count(1)时, server 层每从 InnoDB 读取到一条记录,就将 count 变量加 1,不会读取任何字段。 + +**count(`*`) 其实等于 count(`0`)**,也就是说,当你使用 count(`*`) 时,MySQL 会将 `*` 参数转化为参数 0 来处理。**count(\*) 执行过程跟 count(1) 执行过程基本一样的** + +count(字段) 来统计记录个数,它的效率是最差的,会采用全表扫描的方式来统计。 + + + +优化思路: + +1. 近似值:使用 show table status 或者 explain 命令来表进行估算。 +2. 额外表保存计数值(redis或mysql) + + + +### update优化 + +我们主要需要注意一下update语句执行时的注意事项。 + +update course set name = 'javaEE' where id = 1 ; + +当我们在执行删除的SQL语句时,会锁定id为1这一行的数据,然后事务提交之后,行锁释放。 + +当我们开启多个事务,再执行如下SQL时: + +update course set name = 'SpringBoot' where name = 'PHP' ; + +我们发现行锁升级为了表锁。导致该update语句的性能大大降低。 + +Innodb的行锁是针对索引加的锁,不是针对记录加的锁,并且该索引不能失效,否则会从行锁升级成表锁。 + + + +## SQL性能分析 + +### sql执行频率 + +Mysql客户端链接成功后,通过以下命令可以查看当前数据库的 insert/update/delete/select 的访问频次: + +show [session|global] status like ‘com_____’; + +session: 查看当前会话; + +global: 查看全局数据; + +com insert: 插入次数; + +com select: 查询次数; + +com delete: 删除次数; + +com updat: 更新次数; + +通过查看当前数据库是以查询为主,还是以增删改为主,从而为数据库优化提供参考依据,如果以增删改为主,可以考虑不对其进行索引的优化;如果以查询为主,就要考虑对数据库的索引进行优化 + +### 慢查询日志 + +慢查询日志记录了所有执行时间超过指定参数(long_query_time,单位秒,默认10秒)的所有sql日志: + +开启慢查询日志前,需要在mysql的配置文件中(/etc/my.cnf)配置如下信息: + +1. 开启mysql慢日志查询开关: + + ``` + slow_query_log = 1 + ``` + + + +2. 设置慢日志的时间,假设为2秒,超过2秒就会被视为慢查询,记录慢查询日志: + + ``` + long_query_time=2 + ``` + + + +3. 配置完毕后,重新启动mysql服务器进行测试: + + ``` + systemctl restarmysqld + ``` + + + +4. 查看慢查询日志的系统变量,是否打开: + + ``` + show variables like “slow_query_log”; + ``` + + + +5. 查看慢日志文件中(/var/lib/mysql/localhost-slow.log)记录的信息: + + ``` + Tail -f localhost-slow.log + ``` + + + +最终发现,在慢查询日志中,只会记录执行时间超过我们预设时间(2秒)的sql,执行较快的sql不会被记录。 + + + +### Profile 详情 + +show profiles 能够在做SQL优化时帮助我们了解时间都耗费到哪里去了。 + +1. 通过 have_profiling 参数,可以看到mysql是否支持profile 操作: + + ``` + select @@have_profiling; + ``` + + + +2. 通过set 语句在session/global 级别开启profiling: + + ``` + set profiling =1; + ``` + + + +​ 开关打开后,后续执行的sql语句都会被mysql记录,并记录执行时间消耗到哪儿去了。比如执行以下几条sql语句: + +​ select * from tb_user; + +​ select * from tb_user where id = 1; + +​ select * from tb_user where name = '白起'; + +​ select count(*) from tb_sku; + + + +3. 查看每一条sql的耗时基本情况: + + ``` + show profiles; + ``` + + + +4. 查看指定的字段的sql 语句各个阶段的耗时情况: + + ``` + show profile for query Query_ID; + ``` + + + +5. 查看指定字段的sql语句cpu 的使用情况: + + ``` + show profile cpu for query Query_ID; + ``` + + + +### explain 详情 + +EXPLAIN 或者 DESC命令获取 MySQL 如何执行 SELECT 语句的信息,包括在 SELECT 语句执行过程中,表如何连接和连接的顺序。 + +语法 :直接在 select 语句之前加上关键字 explain/desc; + +![](MySQL\explain.webp) + +extra 几个重要的参考指标: + +- Using filesort :当查询语句中包含 group by 操作,而且无法利用索引完成排序操作的时候, 这时不得不选择相应的排序算法进行,甚至可能会通过文件排序,效率是很低的,所以要避免这种问题的出现。 +- Using temporary:使了用临时表保存中间结果,MySQL 在对查询结果排序时使用临时表,常见于排序 order by 和分组查询 group by。效率低,要避免这种问题的出现。 +- Using index:所需数据只需在索引即可全部获得,不须要再到表中取数据,也就是使用了覆盖索引,避免了回表操作,效率不错。 + + + +## 范式 + +第一范式:确保原子性,表中每一个列数据都必须是不可再分的字段。 + +第二范式:确保唯一性,每张表都只描述一种业务属性,一张表只描述一件事。 + +第三范式:确保独立性,表中除主键外,每个字段之间不存在任何依赖,都是独立的。 + +巴斯范式:主键字段独立性,联合主键字段之间不能存在依赖性。 + + + +### 第一范式 + +所有的字段都是基本数据字段,不可进一步拆分。 + +### 第二范式 + +在满足第一范式的基础上,还要满足数据表里的每一条数据记录,都是可唯一标识的。而且所有字段,都必须完全依赖主键,不能只依赖主键的一部分。 + +把只依赖于主键一部分的字段拆分出去,形成新的数据表。 + +### 第三范式 + +在满足第二范式的基础上,不能包含那些可以由非主键字段派生出来的字段,或者说,不能存在依赖于非主键字段的字段。 + +### 巴斯-科德范式(BCNF) + +巴斯-科德范式也被称为`3.5NF`,至于为何不称为第四范式,这主要是由于它是第三范式的补充版,第三范式的要求是:任何非主键字段不能与其他非主键字段间存在依赖关系,也就是要求每个非主键字段之间要具备独立性。而巴斯-科德范式在第三范式的基础上,进一步要求:**任何主属性不能对其他主键子集存在依赖**。也就是规定了联合主键中的某列值,不能与联合主键中的其他列存在依赖关系。 + + + +```sh ++-------------------+---------------+--------+------+--------+ +| classes | class_adviser | name | sex | height | ++-------------------+---------------+--------+------+--------+ +| 计算机-2201班 | 熊竹老师 | 竹子 | 男 | 185cm | +| 金融-2201班 | 竹熊老师 | 熊猫 | 女 | 170cm | +| 计算机-2201班 | 熊竹老师 | 子竹 | 男 | 180cm | ++-------------------+---------------+--------+------+--------+ +``` + +例如这张学生表,此时假设以`classes`班级字段、`class_adviser`班主任字段、`name`学生姓名字段,组合成一个联合主键。在这张表中,一条学生信息中的班主任,取决于学生所在的班级,因此这里需要进一步调整结构: + +```sh +SELECT * FROM `zz_classes`; ++------------+-------------------+---------------+ +| classes_id | classes_name | class_adviser | ++------------+-------------------+---------------+ +| 1 | 计算机-2201班 | 熊竹老师 | +| 2 | 金融-2201班 | 竹熊老师 | ++------------+-------------------+---------------+ + +SELECT * FROM `zz_student`; ++------------+--------+------+--------+ +| classes_id | name | sex | height | ++------------+--------+------+--------+ +| 1 | 竹子 | 男 | 185cm | +| 2 | 熊猫 | 女 | 170cm | +| 1 | 子竹 | 男 | 180cm | ++------------+--------+------+--------+ +``` + +经过结构调整后,原本的学生表则又被拆为了班级表、学生表两张,在学生表中只存储班级`ID`,然后使用`classes_id`班级`ID`和`name`学生姓名两个字段作为联合主键。 + diff --git a/docs/MySQL/MySQL/COMPACT.drawio.webp b/docs/MySQL/MySQL/COMPACT.drawio.webp new file mode 100644 index 0000000..f7598e3 Binary files /dev/null and b/docs/MySQL/MySQL/COMPACT.drawio.webp differ diff --git a/docs/MySQL/MySQL/count.webp b/docs/MySQL/MySQL/count.webp new file mode 100644 index 0000000..9aa2cfa Binary files /dev/null and b/docs/MySQL/MySQL/count.webp differ diff --git a/docs/MySQL/MySQL/explain.webp b/docs/MySQL/MySQL/explain.webp new file mode 100644 index 0000000..9720af6 Binary files /dev/null and b/docs/MySQL/MySQL/explain.webp differ diff --git a/docs/MySQL/MySQL/mvcc.webp b/docs/MySQL/MySQL/mvcc.webp new file mode 100644 index 0000000..045cffb Binary files /dev/null and b/docs/MySQL/MySQL/mvcc.webp differ diff --git "a/docs/MySQL/MySQL/mysql\346\237\245\350\257\242\346\265\201\347\250\213.webp" "b/docs/MySQL/MySQL/mysql\346\237\245\350\257\242\346\265\201\347\250\213.webp" new file mode 100644 index 0000000..386c6ec Binary files /dev/null and "b/docs/MySQL/MySQL/mysql\346\237\245\350\257\242\346\265\201\347\250\213.webp" differ diff --git "a/docs/MySQL/MySQL/null\345\200\274\345\210\227\350\241\2504.webp" "b/docs/MySQL/MySQL/null\345\200\274\345\210\227\350\241\2504.webp" new file mode 100644 index 0000000..80829b3 Binary files /dev/null and "b/docs/MySQL/MySQL/null\345\200\274\345\210\227\350\241\2504.webp" differ diff --git "a/docs/MySQL/MySQL/orderby\344\274\230\345\214\226.webp" "b/docs/MySQL/MySQL/orderby\344\274\230\345\214\226.webp" new file mode 100644 index 0000000..83c763b Binary files /dev/null and "b/docs/MySQL/MySQL/orderby\344\274\230\345\214\226.webp" differ diff --git a/docs/MySQL/MySQL/read_view.png b/docs/MySQL/MySQL/read_view.png new file mode 100644 index 0000000..7a07e90 Binary files /dev/null and b/docs/MySQL/MySQL/read_view.png differ diff --git a/docs/MySQL/MySQL/redo_log.png b/docs/MySQL/MySQL/redo_log.png new file mode 100644 index 0000000..fdf0b9a Binary files /dev/null and b/docs/MySQL/MySQL/redo_log.png differ diff --git a/docs/MySQL/MySQL/t_test.webp b/docs/MySQL/MySQL/t_test.webp new file mode 100644 index 0000000..94145dd Binary files /dev/null and b/docs/MySQL/MySQL/t_test.webp differ diff --git a/docs/MySQL/MySQL/update.png b/docs/MySQL/MySQL/update.png new file mode 100644 index 0000000..6b88c1e Binary files /dev/null and b/docs/MySQL/MySQL/update.png differ diff --git "a/docs/MySQL/MySQL/\344\270\273\345\244\207\346\265\201\347\250\213.png" "b/docs/MySQL/MySQL/\344\270\273\345\244\207\346\265\201\347\250\213.png" new file mode 100644 index 0000000..2676ad1 Binary files /dev/null and "b/docs/MySQL/MySQL/\344\270\273\345\244\207\346\265\201\347\250\213.png" differ diff --git "a/docs/MySQL/MySQL/\344\272\213\345\212\241a\345\212\240\351\224\201\345\210\206\346\236\220.webp" "b/docs/MySQL/MySQL/\344\272\213\345\212\241a\345\212\240\351\224\201\345\210\206\346\236\220.webp" new file mode 100644 index 0000000..97e1f01 Binary files /dev/null and "b/docs/MySQL/MySQL/\344\272\213\345\212\241a\345\212\240\351\224\201\345\210\206\346\236\220.webp" differ diff --git "a/docs/MySQL/MySQL/\345\217\214M\347\273\223\346\236\204\344\270\273\345\244\207.png" "b/docs/MySQL/MySQL/\345\217\214M\347\273\223\346\236\204\344\270\273\345\244\207.png" new file mode 100644 index 0000000..8c9871d Binary files /dev/null and "b/docs/MySQL/MySQL/\345\217\214M\347\273\223\346\236\204\344\270\273\345\244\207.png" differ diff --git "a/docs/MySQL/MySQL/\345\217\230\351\225\277\345\255\227\346\256\265\351\225\277\345\272\246\345\210\227\350\241\2501.webp" "b/docs/MySQL/MySQL/\345\217\230\351\225\277\345\255\227\346\256\265\351\225\277\345\272\246\345\210\227\350\241\2501.webp" new file mode 100644 index 0000000..ff1d58f Binary files /dev/null and "b/docs/MySQL/MySQL/\345\217\230\351\225\277\345\255\227\346\256\265\351\225\277\345\272\246\345\210\227\350\241\2501.webp" differ diff --git "a/docs/MySQL/MySQL/\345\224\257\344\270\200\347\264\242\345\274\225\345\212\240\351\224\201.drawio.webp" "b/docs/MySQL/MySQL/\345\224\257\344\270\200\347\264\242\345\274\225\345\212\240\351\224\201.drawio.webp" new file mode 100644 index 0000000..7569b67 Binary files /dev/null and "b/docs/MySQL/MySQL/\345\224\257\344\270\200\347\264\242\345\274\225\345\212\240\351\224\201.drawio.webp" differ diff --git "a/docs/MySQL/MySQL/\345\237\272\347\241\200\346\236\266\346\236\204.png" "b/docs/MySQL/MySQL/\345\237\272\347\241\200\346\236\266\346\236\204.png" new file mode 100644 index 0000000..ae014d6 Binary files /dev/null and "b/docs/MySQL/MySQL/\345\237\272\347\241\200\346\236\266\346\236\204.png" differ diff --git "a/docs/MySQL/MySQL/\345\271\266\350\241\214\345\244\215\345\210\266.png" "b/docs/MySQL/MySQL/\345\271\266\350\241\214\345\244\215\345\210\266.png" new file mode 100644 index 0000000..bbe4ee1 Binary files /dev/null and "b/docs/MySQL/MySQL/\345\271\266\350\241\214\345\244\215\345\210\266.png" differ diff --git "a/docs/MySQL/MySQL/\345\271\273\350\257\273\345\217\221\347\224\237.drawio.webp" "b/docs/MySQL/MySQL/\345\271\273\350\257\273\345\217\221\347\224\237.drawio.webp" new file mode 100644 index 0000000..623a9bc Binary files /dev/null and "b/docs/MySQL/MySQL/\345\271\273\350\257\273\345\217\221\347\224\237.drawio.webp" differ diff --git "a/docs/MySQL/MySQL/\346\236\266\346\236\204.jpeg" "b/docs/MySQL/MySQL/\346\236\266\346\236\204.jpeg" new file mode 100644 index 0000000..2dc942f Binary files /dev/null and "b/docs/MySQL/MySQL/\346\236\266\346\236\204.jpeg" differ diff --git "a/docs/MySQL/MySQL/\346\237\245\350\257\242\346\205\242.png" "b/docs/MySQL/MySQL/\346\237\245\350\257\242\346\205\242.png" new file mode 100644 index 0000000..564bde3 Binary files /dev/null and "b/docs/MySQL/MySQL/\346\237\245\350\257\242\346\205\242.png" differ diff --git "a/docs/MySQL/MySQL/\346\241\210\344\276\213\344\270\200.png" "b/docs/MySQL/MySQL/\346\241\210\344\276\213\344\270\200.png" new file mode 100644 index 0000000..133056b Binary files /dev/null and "b/docs/MySQL/MySQL/\346\241\210\344\276\213\344\270\200.png" differ diff --git "a/docs/MySQL/MySQL/\346\241\210\344\276\213\344\270\203.png" "b/docs/MySQL/MySQL/\346\241\210\344\276\213\344\270\203.png" new file mode 100644 index 0000000..87a8026 Binary files /dev/null and "b/docs/MySQL/MySQL/\346\241\210\344\276\213\344\270\203.png" differ diff --git "a/docs/MySQL/MySQL/\346\241\210\344\276\213\344\270\211.png" "b/docs/MySQL/MySQL/\346\241\210\344\276\213\344\270\211.png" new file mode 100644 index 0000000..271103b Binary files /dev/null and "b/docs/MySQL/MySQL/\346\241\210\344\276\213\344\270\211.png" differ diff --git "a/docs/MySQL/MySQL/\346\241\210\344\276\213\344\271\235.png" "b/docs/MySQL/MySQL/\346\241\210\344\276\213\344\271\235.png" new file mode 100644 index 0000000..83b4539 Binary files /dev/null and "b/docs/MySQL/MySQL/\346\241\210\344\276\213\344\271\235.png" differ diff --git "a/docs/MySQL/MySQL/\346\241\210\344\276\213\344\272\214.png" "b/docs/MySQL/MySQL/\346\241\210\344\276\213\344\272\214.png" new file mode 100644 index 0000000..fa203da Binary files /dev/null and "b/docs/MySQL/MySQL/\346\241\210\344\276\213\344\272\214.png" differ diff --git "a/docs/MySQL/MySQL/\346\241\210\344\276\213\344\272\224.png" "b/docs/MySQL/MySQL/\346\241\210\344\276\213\344\272\224.png" new file mode 100644 index 0000000..60f98b3 Binary files /dev/null and "b/docs/MySQL/MySQL/\346\241\210\344\276\213\344\272\224.png" differ diff --git "a/docs/MySQL/MySQL/\346\241\210\344\276\213\345\205\253.png" "b/docs/MySQL/MySQL/\346\241\210\344\276\213\345\205\253.png" new file mode 100644 index 0000000..f1d576a Binary files /dev/null and "b/docs/MySQL/MySQL/\346\241\210\344\276\213\345\205\253.png" differ diff --git "a/docs/MySQL/MySQL/\346\241\210\344\276\213\345\205\255.png" "b/docs/MySQL/MySQL/\346\241\210\344\276\213\345\205\255.png" new file mode 100644 index 0000000..4c73bfd Binary files /dev/null and "b/docs/MySQL/MySQL/\346\241\210\344\276\213\345\205\255.png" differ diff --git "a/docs/MySQL/MySQL/\346\241\210\344\276\213\345\233\233.png" "b/docs/MySQL/MySQL/\346\241\210\344\276\213\345\233\233.png" new file mode 100644 index 0000000..4b171dd Binary files /dev/null and "b/docs/MySQL/MySQL/\346\241\210\344\276\213\345\233\233.png" differ diff --git "a/docs/MySQL/MySQL/\350\201\232\351\233\206\347\264\242\345\274\225\345\222\214\344\272\214\347\272\247\347\264\242\345\274\225.webp" "b/docs/MySQL/MySQL/\350\201\232\351\233\206\347\264\242\345\274\225\345\222\214\344\272\214\347\272\247\347\264\242\345\274\225.webp" new file mode 100644 index 0000000..0761647 Binary files /dev/null and "b/docs/MySQL/MySQL/\350\201\232\351\233\206\347\264\242\345\274\225\345\222\214\344\272\214\347\272\247\347\264\242\345\274\225.webp" differ diff --git "a/docs/MySQL/MySQL/\350\241\250\347\251\272\351\227\264\347\273\223\346\236\204.drawio.webp" "b/docs/MySQL/MySQL/\350\241\250\347\251\272\351\227\264\347\273\223\346\236\204.drawio.webp" new file mode 100644 index 0000000..9500c59 Binary files /dev/null and "b/docs/MySQL/MySQL/\350\241\250\347\251\272\351\227\264\347\273\223\346\236\204.drawio.webp" differ diff --git "a/docs/MySQL/MySQL/\351\232\224\347\246\273\347\272\247\345\210\253.webp" "b/docs/MySQL/MySQL/\351\232\224\347\246\273\347\272\247\345\210\253.webp" new file mode 100644 index 0000000..46d0b35 Binary files /dev/null and "b/docs/MySQL/MySQL/\351\232\224\347\246\273\347\272\247\345\210\253.webp" differ diff --git "a/docs/MySQL/MySQL/\351\235\236\345\224\257\344\270\200\347\264\242\345\274\225\347\255\211\345\200\274\346\237\245\350\257\242age=25.drawio.webp" "b/docs/MySQL/MySQL/\351\235\236\345\224\257\344\270\200\347\264\242\345\274\225\347\255\211\345\200\274\346\237\245\350\257\242age=25.drawio.webp" new file mode 100644 index 0000000..1dd715e Binary files /dev/null and "b/docs/MySQL/MySQL/\351\235\236\345\224\257\344\270\200\347\264\242\345\274\225\347\255\211\345\200\274\346\237\245\350\257\242age=25.drawio.webp" differ diff --git "a/docs/MySQL/MySQL/\351\235\236\345\224\257\344\270\200\347\264\242\345\274\225\347\255\211\345\200\274\346\237\245\350\257\242\345\255\230\345\234\250.drawio.webp" "b/docs/MySQL/MySQL/\351\235\236\345\224\257\344\270\200\347\264\242\345\274\225\347\255\211\345\200\274\346\237\245\350\257\242\345\255\230\345\234\250.drawio.webp" new file mode 100644 index 0000000..72bf3ad Binary files /dev/null and "b/docs/MySQL/MySQL/\351\235\236\345\224\257\344\270\200\347\264\242\345\274\225\347\255\211\345\200\274\346\237\245\350\257\242\345\255\230\345\234\250.drawio.webp" differ diff --git "a/docs/MySQL/MySQL/\351\235\236\345\224\257\344\270\200\347\264\242\345\274\225\350\214\203\345\233\264\346\237\245\350\257\242age\345\244\247\344\272\216\347\255\211\344\272\21622.drawio.webp" "b/docs/MySQL/MySQL/\351\235\236\345\224\257\344\270\200\347\264\242\345\274\225\350\214\203\345\233\264\346\237\245\350\257\242age\345\244\247\344\272\216\347\255\211\344\272\21622.drawio.webp" new file mode 100644 index 0000000..635616b Binary files /dev/null and "b/docs/MySQL/MySQL/\351\235\236\345\224\257\344\270\200\347\264\242\345\274\225\350\214\203\345\233\264\346\237\245\350\257\242age\345\244\247\344\272\216\347\255\211\344\272\21622.drawio.webp" differ diff --git a/docs/Redis/Redis.md b/docs/Redis/Redis.md new file mode 100644 index 0000000..c9621bb --- /dev/null +++ b/docs/Redis/Redis.md @@ -0,0 +1,2574 @@ +https://github.com/redis/redis + +https://redis.io/ + +https://redis.com.cn/ + +http://doc.redisfans.com/ + +## Redis基础 + +### Linux版安装 + +1. Linux环境安装Redis必须先具备gcc编译环境 `yum -y install gcc-c++` + +2. /opt 目录下安装redis + + ```sh + wget https://download.redis.io/releases/redis-7.0.0.tar.gz + tar -zxvf redis-7.0.0.tar.gz + cd redis-7.0.0 + make && make install + ``` + +3. 查看默认安装目录:/usr/local/bin,Linux下的/usr/local类似我们Windows系统的C:\Program Files,安装完成后,去/usr/local/bin下查看 + + redis-benchmark:性能测试工具,服务启动后运行该命令,看看自己电脑性能如何 + + redis-check-aof:修复有问题的AOF文件 + + redis-check-dump:修复有问题的dump.rdb文件 + + redis-cli:客户端操作入口 + + redis-sentinel:redis集群使用 + + reids-server:redis服务器启动命令 + +4. 将默认的redis.conf拷贝到自己定义好的一个路径下,比如/myredis ,cp redis.conf /myredis/redis.conf + + 修改redis.conf文件 + + ``` + redis.conf配置文件,改完后确保生效,记得重启 + 1 默认daemonize no 改为 daemonize yes + 2 默认protected-mode yes 改为 protected-mode no + 3 默认bind 127.0.0.1 改为 直接注释掉(默认bind 127.0.0.1只能本机访问)或改成本机IP地址,否则影响远程IP连接 + 4 添加redis密码 改为 requirepass 你自己设置的密码 + ``` + +5. 启动服务 `redis-server 配置文件` + +6. 连接服务 `redis-cli -a 密码 -p 6379` + +7. 关闭服务 单例模式 `redis-cli -a 密码 shutdown` ;多例模式 `redis-cli -p 6379 shutdown` + + + +### 数据结构 + +![](.\Redis\五种数据类型.webp) + +应用场景: + +- String 类型的应用场景:缓存对象、常规计数、分布式锁、共享 session 信息等。 +- List 类型的应用场景:消息队列(但是有两个问题:1. 生产者需要自行实现全局唯一 ID;2. 不能以消费组形式消费数据)等。 +- Hash 类型:缓存对象、购物车等。 +- Set 类型:聚合计算(并集、交集、差集)场景,比如点赞、共同关注、抽奖活动等。 +- Zset 类型:排序场景,比如排行榜、电话和姓名排序等。 + +- BitMap(2.2 版新增):二值状态统计的场景,比如签到、判断用户登陆状态、连续签到用户总数等; +- HyperLogLog(2.8 版新增):海量数据基数统计的场景,比如百万级网页 UV 计数等; +- GEO(3.2 版新增):存储地理位置信息的场景,比如滴滴叫车; +- Stream(5.0 版新增):消息队列,相比于基于 List 类型实现的消息队列,有这两个特有的特性:自动生成全局唯一消息ID,支持以消费组形式消费数据。 + + + +#### String + +String是redis最基本的数据类型,一个key对应一个value。value可以保存字符串和数字,value最多可以容纳 **512 MB** + +String 类型的底层的数据结构实现主要是 **SDS(简单动态字符串)** + +**应用场景** + +1. 缓存对象 + +2. 常规计数:因为 Redis 处理命令是单线程,所以执行命令的过程是原子的。访问次数、点赞、转发、库存量等(`INCR key`) + +3. 分布式锁(`setnx key value`)setnx (set if not exist) + +4. 共享Session信息:分布式系统中将Session保存到redis中 + + + +#### List + +Redis列表是最简单的字符串列表,按照插入顺序排序。List 类型的底层数据结构是由**双向链表或压缩列表**,最多可以包含`2^32-1`个元素 + +**内部实现** + +List 类型的底层数据结构是由**双向链表或压缩列表**实现的: + +- 如果列表的元素个数小于 `512` 个(默认值,可由 `list-max-ziplist-entries` 配置),列表每个元素的值都小于 `64` 字节(默认值,可由 `list-max-ziplist-value` 配置),Redis 会使用**压缩列表**作为 List 类型的底层数据结构; +- 如果列表的元素不满足上面的条件,Redis 会使用**双向链表**作为 List 类型的底层数据结构; + +但是**在 Redis 3.2 版本之后,List 数据类型底层数据结构就只由 quicklist 实现了,替代了双向链表和压缩列表**。 + + + +**应用场景**:消息队列 + +消息队列必须满足三个要求:消息保序、处理重复消息、消息可靠 + +- 消息保序:使用 LPUSH + RPOP;阻塞读取:使用 BRPOP; +- 重复消息处理:生产者自行实现全局唯一 ID; +- 消息的可靠性:使用 BRPOPLPUSH,作用是让消费者程序从一个 List 中读取消息,同时,Redis 会把这个消息再插入到另一个 List(可以叫作备份 List)留存 + +List 作为消息队列有什么缺陷?不支持多个消费者消费同一个消息 + + + +#### Hash + +Redis Hash是一个string类型的field(字段)和value(值)的映射表,Hash特别适合用户存储对象。Redis中每个Hash可以存储2^32-1个键值对 + +**内部实现:** + +Hash 类型的底层数据结构是由**压缩列表或哈希表**实现的: + +- 如果哈希类型元素个数小于 `512` 个(默认值,可由 `hash-max-ziplist-entries` 配置),所有值小于 `64` 字节(默认值,可由 `hash-max-ziplist-value` 配置)的话,Redis 会使用**压缩列表**作为 Hash 类型的底层数据结构; +- 如果哈希类型元素不满足上面条件,Redis 会使用**哈希表**作为 Hash 类型的 底层数据结构。 + +**在 Redis 7.0 中,压缩列表数据结构已经废弃了,交由 listpack 数据结构来实现了**。 + + + +**应用场景:** + +1. 缓存对象 + + String + Json也是存储对象的一种方式,那么存储对象时,到底用 String + json 还是用 Hash 呢? + + 一般对象用 String + Json 存储,对象中某些频繁变化的属性可以考虑抽出来用 Hash 类型存储。 + +2. 购物车 + + + +#### Set + +Set 类型是一个无序并唯一的键值集合,它的存储顺序不会按照插入的先后顺序进行存储。 + +一个集合最多可以存储 `2^32-1` 个元素。概念和数学中个的集合基本类似,可以交集,并集,差集等等,所以 Set 类型除了支持集合内的增删改查,同时还支持多个集合取交集、并集、差集。 + +**内部实现:** + +Set 类型的底层数据结构是由**哈希表或整数集合**实现的: + +- 如果集合中的元素都是整数且元素个数小于 `512` (默认值,`set-maxintset-entries`配置)个,Redis 会使用**整数集合**作为 Set 类型的底层数据结构; +- 如果集合中的元素不满足上面条件,则 Redis 使用**哈希表**作为 Set 类型的底层数据结构 + +**应用场景:** + +集合的主要几个特性,**无序、不可重复、支持并交差**等操作。因此 Set 类型比较适合用来数据去重和保障数据的唯一性,还可以用来统计多个集合的交集、差集和并集等,当我们存储的数据是无序并且需要去重的情况下,比较适合使用集合类型进行存储。 + +**Set 的差集、并集和交集的计算复杂度较高,在数据量较大的情况下,如果直接执行这些计算,会导致 Redis 实例阻塞**。 + +在主从集群中,为了避免主库因为 Set 做聚合计算(交集、差集、并集)时导致主库被阻塞,我们可以选择一个从库完成聚合统计,或者把数据返回给客户端,由客户端来完成聚合统计 + +1. 抽奖:去重功能。key为抽奖活动名,value为员工名称(`spop key 3 或者 SRANDMEMBER key 3 `) + +2. 点赞:一个用户点一次赞。key 是文章id,value 是用户id + +3. 共同好友:交集运算(`sinter key1 key2`) + +#### ZSet + +zset(sorted set:有序集合) + +Redis zset 和 set 一样也是string类型元素的集合,且不允许重复的成员。 + +不同的是**每个元素都会关联一个double类型的分数**,redis正是通过分数来为集合中的成员进行从小到大的排序。 + +zset的成员是唯一的,但分数(score)却可以重复。 + +**内部实现:** + +Zset 类型的底层数据结构是由**压缩列表或跳表**实现的: + +- 如果有序集合的元素个数小于 `128` 个,并且每个元素的值小于 `64` 字节时,Redis 会使用**压缩列表**作为 Zset 类型的底层数据结构; +- 如果有序集合的元素不满足上面的条件,Redis 会使用**跳表**作为 Zset 类型的底层数据结构; + +**在 Redis 7.0 中,压缩列表数据结构已经废弃了,交由 listpack 数据结构来实现了。** + +**应用场景:** + +1. 排行榜 + +2. 电话、姓名排序 + + 使用有序集合的 `ZRANGEBYLEX` 或 `ZREVRANGEBYLEX` 可以帮助我们实现电话号码或姓名的排序,我们以 `ZRANGEBYLEX` (返回指定成员区间内的成员,按 key 正序排列,分数必须相同)为例。 + + **注意:不要在分数不一致的 SortSet 集合中去使用 ZRANGEBYLEX和 ZREVRANGEBYLEX 指令,因为获取的结果会不准确。** + + 例如,获取132、133开头的电话 + + ```sh + > ZADD phone 0 13300111100 0 13210414300 0 13252110901 + > ZRANGEBYLEX phone [132 (134 + 1) "13200111100" + 2) "13210414300" + 3) "13252110901" + 4) "13300111100" + 5) "13310414300" + 6) "13352110901" + ``` + +3. 延迟队列:使用有序集合(ZSet)的方式来实现延迟消息队列的,ZSet 有一个 Score 属性可以用来存储延迟执行的时间。 + + 使用 zadd score1 value1 命令就可以一直往内存中生产消息。再利用 zrangebysocre 查询符合条件的所有待处理的任务, 通过循环执行队列任务即可。 + +#### 地理空间(GEO) + +Redis GEO主要用于存储地理位置信息,并对存储的信息进行操作,包括:添加地理位置的坐标、获取地理位置的坐标、计算两个位置之间的距离、根据用户给定的经纬度坐标来获取指定范围内的地址位置集合。 + +**内部实现:** + +GEO 本身并没有设计新的底层数据结构,而是直接使用了 **Sorted Set** 集合类型。 + +GEO 类型使用 GeoHash 编码方法实现了经纬度到 Sorted Set 中元素权重分数的转换,这其中的两个关键机制就是「对二维地图做区间划分」和「对区间进行编码」。一组经纬度落在某个区间后,就用区间的编码值来表示,并把编码值作为 Sorted Set 元素的权重分数。 + +这样一来,我们就可以把经纬度保存到 Sorted Set 中,利用 Sorted Set 提供的“按权重进行有序范围查找”的特性,实现 LBS 服务中频繁使用的“搜索附近”的需求。 + +**应用场景**:导航定位、打车 + +#### 基数统计(HyperLogLog) + +HyperLogLog 是用来做基数统计的算法,HyperLogLog 的优点是,在输入元素的数量或者体积非常非常大时,计算基数所需的空间总是固定且是很小的(使用12KB就能计算2^64个不同元素的基数)。但要注意,HyperLogLog 是统计规则是基于概率完成的,不是非常准确,标准误算率是 0.81%。 + +应用场景:去重、计数 + +1. UV:unique visitor 独立访客数 +2. PV:page view 页面浏览量 +3. DAU:daily active user 日活 +4. MAU:月活 + +#### 位图(bitmap) + +Bitmap,即位图,是一串连续的二进制数组(0和1),可以通过偏移量(offset)定位元素。BitMap通过最小的单位bit来进行`0|1`的设置,表示某个元素的值或者状态,时间复杂度为O(1)。 + +由于 bit 是计算机中最小的单位,使用它进行储存将非常节省空间,特别适合一些数据量大且使用**二值统计的场景**。 + +**内部实现** + +Bitmap 本身是用 String 类型作为底层数据结构实现的一种统计二值状态的数据类型。 + +String 类型是会保存为二进制的字节数组,所以,Redis 就把字节数组的每个 bit 位利用起来,用来表示一个元素的二值状态,你可以把 Bitmap 看作是一个 bit 数组。 + +**应用场景** + +1. 签到统计 +2. 判断用户登录态。将用户 ID 作为 offset,在线就设置为 1,下线设置 0。通过 `GETBIT`判断对应的用户是否在线。 5000 万用户只需要 6 MB 的空间。 +3. 连续签到用户总数。把每天的日期作为 Bitmap 的 key,userId 作为 offset,对应的 bit 位做 『与』运算 + +#### 位域(bitfield) + +通过bitfield命令可以一次性操作多个比特位,它会执行一系列操作并返回一个响应数组,这个数组中的元素对应参数列表中的相应操作的执行结果。主要功能是: + +* 位域修改 +* 溢出控制 + * WRAP:使用回绕(wrap around)方法处理有符号整数和无符号整数溢出情况 + * SAT:使用饱和计算(saturation arithmetic)方法处理溢出,下溢计算的结果为最小的整数值,而上溢计算的结果为最大的整数值 + * fail:命令将拒绝执行那些会导致上溢或者下溢情况出现的计算,并向用户返回空值表示计算未被执行 + +#### Redis流(Stream) + +Redis Stream 主要用于消息队列(MQ,Message Queue) + +* Redis 本身是有一个 **Redis 发布订阅 (pub/sub)** 来实现消息队列的功能,但它有个缺点就是**消息无法持久化**,如果出现网络断开、Redis 宕机等,消息就会被丢弃。 + +* List 实现消息队列的方式不能重复消费,一个消息消费完就会被删除,而且生产者需要自行实现全局唯一 ID,**不支持多播,分组消费** + +而 Redis Stream 支持消息的持久化、支持自动生成全局唯一ID、支持ack确认消息的模式、支持消费组模式等,让消息队列更加的稳定和可靠 + + + +### 常用命令 + +```shell +keys * # 查看当前库所有的key +set key value +get key +exists key # 判断某个key是否存在 +type key # 查看你的key是什么类型 +del key # 删除指定的key数据 是原子的删除,只有删除成功了才会返回删除结果 +unlink key # 非阻塞删除,仅仅将keys从keyspace元数据中删除,真正的删除会在后续异步中操作。 +ttl key # 查看还有多少秒过期,-1表示永不过期,-2表示已过期 +expire key 秒钟 # 为给定的key设置过期时间 +move key dbindex[0-15]# 将当前数据库的key移动到给定的数据库DB当中 +select dbindex # 切换数据库【0-15】,默认为0 +dbsize # 查看当前数据库key的数量 +flushdb # 清空当前库 +flushall # 通杀全部库 +``` + +帮助命令: **`help @类型`** 。例如,`help @string` + + + +### 持久化 + +AOF 文件的内容是操作命令; + +RDB 文件的内容是二进制数据。 + +#### RDB (Redis Database) + +在指定的时间间隔内将内存中的数据集快照写入磁盘,恢复时再将硬盘快照文件直接读回到内存里。 + +Redis的数据都在内存中,保存备份时它执行的是**全量快照**,也就是说,把内存中的所有数据都记录到磁盘中。 + +RDB保存的是dump.rdb文件。 + +**持久化方式** + +* 自动触发:修改 redis.conf 里配置的 `save `。自动执行 bgsave 命令,会创建子进程来生成 RDB 快照文件。 + +* 手动触发:使用`save`或者`bgsave`命令。save在主程序中执行会**阻塞**当前redis服务器,直到持久化工作完成, 执行save命令期间,Redis不能处理其他命令,**线上禁止使用**。bgsave会在后台异步进行快照操作,**不阻塞**。该方式会fork一个子进程由子进程完成持久化过程 + + + +**优势**: + +1. 适合大规模的数据恢复 +2. 按照业务定时备份 +3. 父进程不会执行磁盘I/О或类似操作,会fork一个子进程完成持久化工作,性能高。 +4. RDB文件在内存中的**加载速度要比AOF快很多** + +**劣势**: + +1. 在一定间隔时间做一次备份,所以如果redis意外down掉的话,就会丢失从当前至最近一次快照期间的数据,**快照之间的数据会丢失** +2. 内存数据的全量同步,如果数据量太大会导致IO严重影响服务器性能。因为RDB需要经常fork()以便使用子进程在磁盘上持久化。如果数据集很大,fork()可能会很耗时,并且如果数据集很大并且CPU性能不是很好,可能会导致Redis停止为客户端服务几毫秒甚至一秒钟。 + + + +如何检查修复dump.rdb文件? + +进入到redis安装目录,执行redis-check-rdb命令 `redis-check-rdb ./redisconfig/dump.rdb` + + + +哪些情况会触发RDB快照? + +1. 配置文件中默认的快照配置 +2. 手动save/bgsave命令 +3. 执行flushdb/fulshall命令也会产生dump.rdb文件,但是也会将命令记录到dump.rdb文件中,恢复后依旧是空,无意义 +4. 执行shutdown且没有设置开启AOF持久化 +5. 主从复制时,主节点自动触发 + + + +如何禁用快照? + +1. 动态所有停止RDB保存规则的方法:redis-cli config set value "" +2. 手动修改配置文件 + + + +RDB 在执行快照的时候,数据能修改吗? + +可以的,执行 bgsave 过程中,Redis 依然**可以继续处理操作命令**的,也就是数据是能被修改的,关键的技术就在于**写时复制技术(Copy-On-Write, COW)。** + +执行 bgsave 命令的时候,会通过 fork() 创建子进程,此时子进程和父进程是共享同一片内存数据的,因为创建子进程的时候,会复制父进程的页表,但是页表指向的物理内存还是一个,此时如果执行读操作,则主进程和 bgsave 子进程互相不影响。 + +如果主进程执行写操作,则被修改的数据会复制一份副本,主线程在这个数据副本进行修改操作,然后 bgsave 子进程会把原来的数据写入 RDB 文件。 + + + +#### AOF (Append Only File) + +**以日志的形式来记录每个写操作**,将Redis执行过的所有写指令记录下来(读操作不记录),只许追加文件但是不可以改写文件,恢复时,以逐一执行命令的方式来进行数据恢复。 + +默认情况下,redis是没有开启AOF的。 + +**开启:** + +开启AOF功能需要设置配置:appendonly yes + +AOF保存的是 appendonly.aof 文件 + + + +**AOF持久化流程**: + +**先执行写命令,然后记录命令到AOF日志。** + +1. 日志并不是直接写入AOF文件,会将其这些命令先放入AOF缓存中进行保存。这里的AOF缓冲区实际上是内存中的一片区域,存在的目的是当这些命令达到一定量以后再写入磁盘,避免频繁的磁盘IO操作。 + +2. AOF缓冲会根据AOF缓冲区**同步文件的三种写回策略**将命令写入磁盘上的AOF文件。 + +3. 随着写入AOF内容的增加为避免文件膨胀,会根据规则进行命令的合并(**AOF重写**),从而起到AOF文件压缩的目的。 + +4. 当Redis Server服务器重启的时候会对AOF文件载入数据。 + + + +**为什么先执行命令,再把数据写入日志呢?** + +Reids 是先执行写操作命令后,才将该命令记录到 AOF 日志里的,这么做其实有两个好处。 + +- **避免额外的检查开销**:因为如果先将写操作命令记录到 AOF 日志里,再执行该命令的话,如果当前的命令语法有问题,那么如果不进行命令语法检查,该错误的命令记录到 AOF 日志里后,Redis 在使用日志恢复数据时,就可能会出错。 +- **不会阻塞当前写操作命令的执行**:因为当写操作命令执行成功后,才会将命令记录到 AOF 日志。 + +当然,这样做也会带来风险: + +- **数据可能会丢失:** 执行写操作命令和记录日志是两个过程,那当 Redis 在还没来得及将命令写入到硬盘时,服务器发生宕机了,这个数据就会有丢失的风险。 +- **可能阻塞其他操作:** 由于写操作命令执行成功后才记录到 AOF 日志,所以不会阻塞当前命令的执行,但因为 AOF 日志也是在主线程中执行,所以当 Redis 把日志文件写入磁盘的时候,还是会阻塞后续的操作无法执行。 + + + +**aof文件**: + +* redis6及之前:appendonly.aof + +* Redis7 Multi Part AOF的设计, 将AOF分为三种类型: + + * BASE: 表示基础AOF,它一般由子进程通过重写产生,该文件最多只有一个。 + * INCR:表示增量AOF,它一般会在AOFRW开始执行时被创建,该文件可能存在多个。 + * HISTORY:表示历史AOF,它由BASE和INCR AOF变化而来,每次AOFRW成功完成时,本次AOFRW之前对应的BASE和INCR AOF都将变为HISTORY,HISTORY类型的AOF会被Redis自动删除。 + + 为了管理这些AOF文件,我们引入了一个manifest (清单)文件来跟踪、管理这些AOF。 + + +异常修复命令:`redis-check-aof --fix incr文件` + + + +**AOF写回策略** + +AOF缓冲会根据AOF缓冲区**同步文件的三种写回策略**将命令写入磁盘上的AOF文件。写回策略: + +1. **ALways**:同步写回,每个写命令执行完立刻同步地将日志写回磁盘。 +2. **everysec**(默认):每秒写回,每个写命令执行完,只是先把日志写到AOF文件的内存缓冲区,每隔1秒把缓冲区中的内容写入到磁盘 +3. **no**:操作系统控制的写回,每个写命令执行完,只是先把日志写到AOF文件的内存缓冲区,由操作系统决定何时将缓冲区内容写回磁盘 + + + +**AOF重写机制** + +随着写入AOF内容的增加为避免文件膨胀,会根据规则进行命令的合并(**又称AOF重写**),从而起到**AOF文件压缩的目的。** + +配置项:auto-aof-rewrite-percentage 和 auto-aof-rewrite-min-size 同时满足两个条件时触发重写 + +* 自动触发: 满足配置文件中的选项后,Redis会记录上次重写时的AOF大小,默认配置是当AOF文件大小是上次rewrite后大小的一倍且文件大于64M时 +* 手动触发: `bgrewriteaof` + +**AOF文件重写并不是对AOF件进行重新整理,而是直接读取服务器数据库中现有的键值对,然后将每一个键值对用一条命令记录到「新的 AOF 文件」,等到全部记录完后,就将新的 AOF 文件替换掉现有的 AOF 文件。** + +**重写原理:** + +1. 触发重写机制后,主进程会创建一个“重写子进程 **bgrewriteaof**”,这个子进程会携带主进程的数据副本(fork子进程时复制页表,父子进程在写操作之前都共享物理内存空间,从而实现数据共享。主进程第一次写时发生写时复制才会进行物理内存的复制)。重写子进程只会对这个内存进行只读,重写 AOF 子进程会读取数据库里的所有数据,并逐一把内存数据的键值对转换成一条命令,再将命令记录到重写日志(新的 AOF 文件)。 + +2. 与此同时,主进程依然能正常处理命令。但是执行写命令时怎么保证数据一致性呢?在重写 AOF 期间,当 Redis 执行完一个写命令之后,它会**同时将这个写命令写入到 「AOF 缓冲区」和 「AOF 重写缓冲区」**。 + + ![](.\Redis\aofewwrite.png) + +3. 当“重写子进程”完成重写工作后,它会给父进程发一个信号,父进程收到信号后将 AOF 重写缓冲区中的所有内容追加到新的 AOF 的文件中 + +4. 当追加结束后,redis就会用新AOF文件来代替旧AOF文件,之后再有新的写指令,就都会追加到新的AOF文件中 + + + +Redis 的**重写 AOF 过程是由后台子进程 bgrewriteaof 来完成的**,这么做可以达到两个好处: + +1. 子进程进行 AOF 重写期间,主进程可以继续处理命令请求,从而避免阻塞主进程; + +2. 子进程带有主进程的数据副本,这里使用子进程而不是线程,因为如果是使用线程,多线程之间会共享内存,那么在修改共享内存数据的时候,需要通过加锁来保证数据的安全,而这样就会降低性能。而使用子进程,创建子进程时,父子进程是共享内存数据的,不过这个共享的内存只能以只读的方式,而当父子进程任意一方修改了该共享内存,就会发生「写时复制」,于是父子进程就有了独立的数据副本,就不用加锁来保证数据安全。 + + + +#### 混合持久化 + +RDB 优点是数据恢复速度快,但是快照的频率不好把握。频率太低,丢失的数据就会比较多,频率太高,就会影响性能。 + +AOF 优点是丢失数据少,但是数据恢复不快。 + +混合持久化,既保证了 Redis 重启速度,又降低数据丢失风险。 + + + +开启:设置 appendonly 为 yes、aof-use-rdb-preamble 为 yes + + + +当开启了混合持久化时,在 AOF 重写日志时,`fork` 出来的重写子进程会先将与主线程共享的内存数据以 **RDB 方式写入到 AOF 文件**,然后主线程处理的操作命令会被记录在**重写缓冲区**里,重写缓冲区里的增量命令会以 **AOF 方式写入到 AOF 文件**,写入完成后通知主进程将新的含有 RDB 格式和 AOF 格式的 AOF 文件替换旧的的 AOF 文件。 + +也就是说,使用了混合持久化,AOF 文件的**前半部分是 RDB 格式的全量数据,后半部分是 AOF 格式的增量数据**。 + + + +#### 纯缓存模式 + +同时关闭RDB+AOF,专心做缓存 + +1. save "" -- 禁用RDB + + 禁用RDB持久化模式下,我们仍然可以使用命令save、bgsave生成RDB文件 + +2. appendonly no -- 禁用AOF + + 禁用AOF持久化模式下,我们仍然可以使用命令bgrewriteaof生成AOF文件 + + + +AOF 重写机制和 RDB 快照(bgsave 命令)的过程,都会分别通过 `fork()` 函数创建一个子进程来处理任务。会有两个阶段会导致阻塞父进程(主线程): + +- 创建子进程的途中,由于要复制父进程的页表等数据结构,阻塞的时间跟页表的大小有关,页表越大,阻塞的时间也越长; +- 创建完子进程后,如果父进程修改了共享数据中的大 Key,就会发生写时复制,这期间会拷贝物理内存,由于大 Key 占用的物理内存会很大,那么在复制物理内存这一过程,就会比较耗时,所以有可能会阻塞父进程。 + + + +### 事务 + +redis事务就是一次性、顺序性、排他性的执行一个队列中的一系列命令。**Redis 中并没有提供回滚机制 ,并不一定保证原子性** + +1. 开启:以multi开始一个事务 + +2. 入队:将多个命令入队到事务中,接到这些命令并不会立即执行,而是放到等待执行的事务队列里面 + +3. 执行:exec命令触发事务 + +| 性质 | 解释 | +| ---------------- | ------------------------------------------------------------ | +| **不保证原子性** | Redis的事务不保证原子性,也就是不保证所有指令同时成功或同时失败,只有决定是否开始执行全部指令的能力,没有执行到一半进行回滚的能力 | +| 一致性 | redis事务可以保证命令失败的情况下得以回滚,数据能恢复到没有执行之前的样子,是保证一致性的,除非redis进程意外终结 | +| 隔离性 | redis事务是严格遵守隔离性的,原因是redis是单进程单线程模式(v6.0之前),可以保证命令执行过程中不会被其他客户端命令打断 | +| 不保证持久性 | redis持久化策略中不管是RDB还是AOF都是异步执行的,不保证持久性是出于对性能的考虑 | + +* 正常执行:MULTI 标记事务开始 、EXEC 执行事务 +* 放弃事务:MULTI、DISCARD 取消事务 +* 事务全部失败:MULTI后的命令直接报错,EXEC执行也会报错,事务失败,命令全部失效。类似编译错误 +* 事务部分失败:MULTI后的命令没有直接报错(例如,INCR email),EXEC时报错,该条命令失败,其余命令成功。类似运行错误 +* watch监控:使用watch提供乐观锁定 + + + +### 管道 + +管道(pipeline)可以一次性发送多条命令给服务端,**服务端依次处理完毕后,通过一 条响应一次性将结果返回,通过减少客户端与redis的通信次数来实现降低往返延时时间**。pipeline实现的原理是队列,先进先出特性就保证数据的顺序性 + +`cat cmd.txt | redis-cli -a 密码 --pipe` + +pipeline与原生批量命令对比 + +1. 原生批量命令是原子性(例如:mset、mget),pipeline是非原子性的 +2. 原生批量命令一次只能执行一种命令,pipeline支持批量执行不同命令 +3. 原生批量命令是服务端实现,而pipeline需要服务端与客户端共同完成 + +pipeline与事务对比 + +1. 事务具有原子性,管道不具有原子性 +2. 管道一次性将多条命令发送到服务器,事务是一条一条的发,事务只有在接收到exec命令后才会执行 +3. 执行事务时会阻塞其他命令的执行,而执行管道中的命令时不会 + +使用pipeline注意事项 + +1. pipeline缓冲的指令只是会依次执行,不保证原子性,如果执行中指令发生异常,将会继续执行后续的指令 +2. 使用pipeline组装的命令个数不能太多,不然数量过大客户端阻塞的时间可能过久,同时服务端此时也被迫回复一个队列答复,占用很多内存 + + + +### 主从复制(replication) + +主从复制,读写分离,master可以读写,slave以读为主,当master数据变化的时候,自动将新的数据异步同步到其他的slave数据库。 + + + +**配置**:(配从不配主) + +配置从机: + +1. master 如果配置了 `requirepass` 参数,需要密码登录 ,那么slave就要配置 `masterauth` 来设置校验密码,否则的话master会拒绝slave的访问请求; +2. `replicaof 主库IP 主库端口` + + + +**基本操作命令**: + +`info replication` :可以查看节点的主从关系和配置信息 + +`replicaof 主库IP 主库端口` :一般写入进Redis.conf配置文件内,重启后依然生效 + +`slaveof 主库IP 主库端口 `:每次与master断开之后,都需要重新连接,除非你配置进了redis.conf文件;在运行期间修改slave节点的信息,如果该数据库已经是某个主数据库的从数据库,那么会停止和原主数据库的同步关系,转而和新的主数据库同步 + +`slaveof no one` :使当前数据库停止与其他数据库的同步,转成主数据库 + + + +**主从问题演示** + +1. Q:从机可以执行写命令吗? + + A:**不可以,从机只能读** + +2. Q:从机切入点问题?slave是从头开始复制还是从切入点开始复制? + + A: 首次接入全量复制,后续增量复制 + +3. Q:主机shutdown后,从机会上位吗? + + A:**从机不动,原地待命,从机数据可以正常使用,等待主机重启归来** + +4. Q:主机shutdown后,重启后主从关系还在吗?从机还能否顺利复制? + + A:主从关系依然存在,从机依旧是从机,可以顺利复制 + +5. Q:某台从机down后,master继续,从机重启后它能跟上大部队吗? + + A:可以,类似于从机切入点问题 + + + +**复制原理和工作流程:** + +1. 建立链接、协商同步 + + slave启动成功连接到master后会发送一个psync命令(包含主服务器的runID和复制进度offset),表示要进行数据同步 + + master收到psync后,会用 `FULLRESYNC`(包含主服务器runID和复制进度offset) 作为响应命令返回给对方。从服务器收到响应后,会记录这两个值。 + +2. 同步数据,全量复制 + + 主服务器会执行 bgsave 命令来生成 RDB 文件,然后把文件发送给从服务器。 + + 从服务器收到 RDB 文件后,会先清空当前的数据,然后载入 RDB 文件。 + + 这期间的写操作命令并没有记录到刚刚生成的 RDB 文件中,这时主从服务器间的数据就不一致了。那么为了保证主从服务器的数据一致性,**主服务器在下面这三个时间间隙中将收到的写操作命令,写入到 replication buffer 缓冲区里**: + + - 主服务器生成 RDB 文件期间; + - 主服务器发送 RDB 文件给从服务器期间; + - 「从服务器」加载 RDB 文件期间; + +3. master 发送新写命令给 slave + + slave完成 RDB 的载入后,会回复一个确认消息给主服务器。 + + 主服务器将 replication buffer 缓冲区里所记录的写操作命令发送给从服务器,从服务器执行来自主服务器 replication buffer 缓冲区里发来的命令,这时主从服务器的数据就一致了。 + +4. TCP长连接进行命令传播、通信 + + 主从服务器在完成第一次同步后,双方之间就会维护一个 TCP 连接。后续主服务器可以通过这个连接继续将写操作命令传播给从服务器,然后从服务器执行该命令,使得与主服务器的数据库状态相同。 + +5. 增量复制 + + 从服务器在恢复网络后,会发送 psync 命令给主服务器,此时的 psync 命令里的 offset 参数不是 -1; + + 主服务器收到该命令后,然后用 CONTINUE 响应命令告诉从服务器接下来采用增量复制的方式同步数据; + + 然后主服务将主从服务器断线期间,所执行的写命令发送给从服务器,然后从服务器执行这些命令。 + +6. 从机下线,重连续传 + + master会检查backlog里面的offset,master和slave都会保存一个复制的offset还有一个masterId,offset是保存在backlog中的。master只会把已经缓存的offset后面的数据复制给slave,类似断点续传 + + + +**怎么判断 Redis 某个节点是否正常工作?** + +Redis 判断节点是否正常工作,基本都是通过互相的 ping-pong 心态检测机制,如果有一半以上的节点去 ping 一个节点的时候没有 pong 回应,集群就会认为这个节点挂掉了,会断开与这个节点的连接。 + +Redis 主从节点发送的心态间隔是不一样的,而且作用也有一点区别: + +- Redis 主节点默认每隔 10 秒对从节点发送 ping 命令,判断从节点的存活性和连接状态,可通过参数repl-ping-slave-period控制发送频率。 +- Redis 从节点每隔 1 秒发送 replconf ack{offset} 命令,给主节点上报自身当前的复制偏移量,目的是为了: + - 实时监测主从节点网络状态; + - 上报自身复制偏移量, 检查复制数据是否丢失, 如果从节点数据丢失, 再从主节点的复制缓冲区中拉取丢失数据。 + + + +### 哨兵(Sentinel) + +巡查监控后台master主机是否故障,如果故障了根据投票数自动将某一个从库转换为新主库,继续对外服务 + +哨兵会每隔 1 秒给所有主从节点发送 PING 命令,当主从节点收到 PING 命令后,会发送一个响应命令给哨兵,这样就可以判断它们是否在正常运行。 + +**功能:** + +主从监控:监控主从redis库运行是否正常 + +消息通知:哨兵可以将故障转移的结果发送给客户端 + +故障转移:如果master异常,则会进行主从切换,将其中一个slave作为新master + +配置中心:客户端通过连接哨兵来获得当前Redis服务的主节点地址 + + + +**配置:**sentinel.conf + +``` +bind:服务监听地址,用于客户端连接,默认本机地址 +daemonize:是否以后台daemon方式运行 +protected-model:安全保护模式 +port:端口 +logfile:日志文件路径 +pidfile:pid文件路径 +dir:工作目录 +``` + +`sentinel monitor ` : 设置要监控的master服务器,quorum表示最少有几个哨兵认可客观下线,同意故障迁移的法定票数 + +`sentinel auth-pass ` + +其他参数: + +``` +sentinel down-after-milliseconds :指定多少毫秒之后,主节点没有应答哨兵,此时哨兵主观上认为主节点下线 +sentinel parallel-syncs :表示允许并行同步的slave个数,当Master挂了后,哨兵会选出新的Master,此时,剩余的slave会向新的master发起同步数据 +sentinel failover-timeout :故障转移的超时时间,进行故障转移时,如果超过设置的毫秒,表示故障转移失败 +sentinel notification-script :配置当某一事件发生时所需要执行的脚本 +sentinel client-reconfig-script :客户端重新配置主节点参数脚本 +``` + +注意:主机后续可能会变成从机,所以也需要设置 masterauth 项访问密码,不然后续可能报错master_link_status:down + +​ 哨兵可以同时监控多个master,一行一个配置 + + + +**启动哨兵方式:** + +1. `redis-sentinel /path/to/sentinel.conf` +2. `redis-server /path/to/sentinel.conf --sentinel` + + + +文件的内容,在运行期间会被sentinel动态进行更改。Master-Slave切换后,master_redis.conf、slave_redis.conf和sentinel.conf的内容都会发生改变,即master_redis.conf中会多一行slaveof的配置,sentinel.conf的监控目标会随之调换 + + + +**哨兵运行流程和选举原理:** + +当一个主从配置中master失效后,哨兵可以选举出一个新的master用于自动接替原master的工作,主从配置中的其他redis服务器自动指向新的master同步数据 + +* **主观下线**(Subjectively Down, 简称 SDOWN):指的是**单个Sentinel实例对服务器做出的下线判断**,即单个sentinel认为某个服务下线(有可能是接收不到订阅,之间的网络不通等等原因)。如果主节点或者从节点没有在规定的时间内响应哨兵的 PING 命令,哨兵就会将它们标记为「**主观下线**」。 + +* **客观下线**(Objectively Down,简称 ODOWN):当一个哨兵判断主节点为「主观下线」后,就会向其他哨兵发起命令,其他哨兵收到这个命令后,就会根据自身和主节点的网络状况,做出赞成投票或者拒绝投票的响应。当这个哨兵的**赞同票数达到哨兵配置文件中的 quorum 配置项设定的值后**,这时主节点就会被该哨兵标记为「客观下线」。 + + + +1. 当主节点被判断客观下线后,各个哨兵节点会进行协商,用**raft算法先选举出一个领导者哨兵leader**节点进行failover(故障转移)。**选举的票数大于等于num(sentinels)/2+1时,将成为领导者,如果没有超过,继续选举** +2. 领导者哨兵推动故障切换流程选出一个**新master** + 1. 选举某个slave为新的master,选举规则为:1. **优先级**slave-priority或者replica-priority最高的,2. **复制偏移位置offset最大的**,3. **最小Run ID**的从节点 + 2. sentinel leader会对选举出的新master执行slaveof on one操作,将其提升为master节点。 + 3. sentinel leader向其他slave发送命令,让剩余的slave成为新的master节点的slave + 4. 通过发布者/订阅者机制通知客户端主节点已更换 + 5. sentinel leader会让重新上线的master降级为slave并恢复正常工作 + + + +**整体流程**: + +*1、第一轮投票:判断主节点下线* + +当哨兵集群中的某个哨兵判定主节点下线(主观下线)后,就会向其他哨兵发起命令,其他哨兵收到这个命令后,就会根据自身和主节点的网络状况,做出赞成投票或者拒绝投票的响应。 + +当这个哨兵的赞同票数达到哨兵配置文件中的 quorum 配置项设定的值后,这时主节点就会被该哨兵标记为「客观下线」。 + +*2、第二轮投票:选出哨兵 leader* + +某个哨兵判定主节点客观下线后,该哨兵就会发起投票,告诉其他哨兵,它想成为 leader,想成为 leader 的哨兵节点,要满足两个条件: + +- 第一,拿到半数以上的赞成票; +- 第二,拿到的票数同时还需要大于等于哨兵配置文件中的 quorum 值。 + +*3、由哨兵 leader 进行主从故障转移* + +选举出了哨兵 leader 后,就可以进行主从故障转移的过程了。该操作包含以下四个步骤: + +- 第一步:在已下线主节点(旧主节点)属下的所有「从节点」里面,挑选出一个从节点,并将其转换为主节点,选择的规则: + - 过滤掉已经离线的从节点; + - 过滤掉历史网络连接状态不好的从节点; + - 将剩下的从节点,进行三轮考察:优先级、复制进度、ID 号。在每一轮考察过程中,如果找到了一个胜出的从节点,就将其作为新主节点。 +- 第二步:让已下线主节点属下的所有「从节点」修改复制目标,修改为复制「新主节点」; +- 第三步:将新主节点的 IP 地址和信息,通过「发布者/订阅者机制」通知给客户端; +- 第四步:继续监视旧主节点,当这个旧主节点重新上线时,将它设置为新主节点的从节点; + + + +### 集群(Cluster) + +Redis集群是一个提供在多个Redis节点间共享数据的程序集,Redis集群可以支持多个master, 每个master又可以挂载多个slave + +由于Cluster自带Sentinel的故障转移机制,内置了高可用的支持,无需再去使用哨兵功能 + +客户端与Redis的节点连接,不再需要连接集群中所有的节点,只需要任意连接集群中的一个可用节点即可 + +槽位slot 负责分配到各个物理服务节点,由对应的集群来负责维护数据、插槽和节点之间的关系 + +集群的密钥空间被分为16384个槽,集群大小上限是16384,建议最大节点约为1000 + + + +**槽位** + +数据和节点之间的抽象出来的,数据映射到槽位,槽位绑定到节点。 + +Redis集群有16384个哈希槽每个key通过CRC16校验后对16384取模来决定放置哪个槽,集群的每个节点负责一部分hash槽 + +![](.\Redis\slot.jpg) + + + +**分片** + +使用Redis集群时我们会将存储的数据分散到多台redis机器上,这称为分片。简言之,集群中的每个Redis实例都被认为是整个数据的一个分片。 + +为了找到给定key的分片,我们对key进行CRC16(key)算法处理并通过对总分片数量取模。然后,使用**确定性哈希函数**,这意味着给定的key**将多次始终映射到同一个分片**,我们可以推断将来读取特定key的位置。 + +槽位和分片的优势是:方便扩缩容和数据分派查找 + + + +**slot槽位映射3种解决方案**: + +* 哈希取余分区: hash(key)%N 在服务器个数固定不变时没有问题,如果需要弹性扩容或故障停机的情况下,取模公式就会发生变化 + +* 一致性哈希算法分区: + + 1. 构建一致性哈希环: 一致性Hash算法是对2^32取模,简单来说,一致性Hash算法将整个哈希值空间组织成一个虚拟的圆环 + 2. 将redis服务器 IP节点映射到哈希环某个位置 + 3. key落到服务器的落键规则:当我们需要存储一个kv键值对时,首先将这个key使用相同的函数Hash计算出哈希值并确定此数据在环上的位置,**从此位置沿环顺时针“行走”**,**第一台遇到的服务器就是其应该定位到的服务器**,并将该键值对存储在该节点上。 + + 优点: + + 1. 容错性:在一致性Hash算法中,如果一台服务器不可用,则受影响的数据仅仅是此服务器到其环空间中前一台服务器(即沿着逆时针方向行走遇到的第一台服务器)之间数据,其它不会受到影响 + 2. 扩展性:数据量增加了,需要增加一台节点NodeX,X的位置在A和B之间,那收到影响的也就是A到X之间的数据,重新把A到X的数据录入到X上即可,不会导致hash取余全部数据重新洗牌。 + + 缺点:一致性Hash算法在服务节点太少时,容易因为节点分布不均匀而造成**数据倾斜**(集中存储在某台服务器上) + +* 哈希槽分区:为解决数据倾斜问题,在数据和节点之间又加入了一层,把这层称为哈希槽(slot)。Redis集群中内置了16384个哈希槽,redis 会根据节点数量大致均等的将哈希槽映射到不同的节点,数据映射到哈希槽,根据操作分配到对应节点。集群会记录节点和槽的对应关系,哈希槽的计算:HASH_SLOT = CRC16(key) mod 16384。 + + + +**为什么集群的最大槽数是16384个?** + +![](Redis\slot.png) + +1. 如果槽位为65536,发送心跳信息的消息头达8k,发送的心跳包过于庞大。 + + 在消息头中最占空间的是myslots[CLUSTER_SLOTS/8]。 当槽位为65536时,这块的大小是: 65536÷8÷1024=8kb + + 在消息头中最占空间的是myslots[CLUSTER_SLOTS/8]。 当槽位为16384时,这块的大小是: 16384÷8÷1024=2kb + + 因为每秒钟,redis节点需要发送一定数量的ping消息作为心跳包,如果槽位为65536,这个ping消息的消息头太大了,浪费带宽。 + +2. redis的集群节点数量基本不可能超过1000个。 + + 集群节点越多,心跳包的消息体内携带的数据越多。如果节点过1000个,也会导致网络拥堵。因此redis作者不建议redis cluster节点数量超过1000个。 那么,对于节点数在1000以内的redis cluster集群,16384个槽位够用了。没有必要拓展到65536个。 + +3. 槽位越小,节点少的情况下,压缩比高,容易传输 + + Redis主节点的配置信息中它所负责的哈希槽是通过一张bitmap的形式来保存的,在传输过程中会对bitmap进行压缩,但是如果bitmap的填充率slots / N很高的话(N表示节点数),bitmap的压缩率就很低。 如果节点数很少,而哈希槽数量很多的话,bitmap的压缩率就很低。 + + + +**集群脑裂导致数据丢失怎么办?** + +脑裂: + +在 Redis 主从架构中,如果主节点的网络突然发生了问题,它与所有的从节点都失联了,但是此时的主节点和客户端的网络是正常的,这个客户端并不知道 Redis 内部已经出现了问题,还在照样的向这个失联的主节点写数据(过程A),此时这些数据被旧主节点缓存到了缓冲区里,因为主从节点之间的网络问题,这些数据都是无法同步给从节点的。 + +这时,哨兵也发现主节点失联了,它就认为主节点挂了(但实际上主节点正常运行,只是网络出问题了),于是哨兵就会在「从节点」中选举出一个 leader 作为主节点,这时集群就有两个主节点了 —— **脑裂出现了**。 + +然后,网络突然好了,哨兵因为之前已经选举出一个新主节点了,它就会把旧主节点降级为从节点(A),然后从节点(A)会向新主节点请求数据同步,**因为第一次同步是全量同步的方式,此时的从节点(A)会清空掉自己本地的数据,然后再做全量同步。所以,之前客户端在过程 A 写入的数据就会丢失了,也就是集群产生脑裂数据丢失的问题**。 + +解决方案: + +当主节点发现从节点下线或者通信超时的总数量小于阈值时,那么**禁止主节点进行写数据**,直接把错误返回给客户端。 + + + +**使用:** + +1. 编写集群配置文件 :redisCluster6381.conf + + ``` + bind 0.0.0.0 + daemonize yes + protected-mode no + port 6381 + logfile "/myredis/cluster/cluster6381.log" + pidfile /myredis/cluster6381.pid + dir /myredis/cluster + dbfilename dump6381.rdb + appendonly yes + appendfilename "appendonly6381.aof" + requirepass 123456 + masterauth 123456 + + cluster-enabled yes + cluster-config-file nodes-6381.conf + cluster-node-timeout 5000 + ``` + +2. 启动主机节点: `redis-server /myredis/cluster/redisCluster6381.conf` + +3. 构建集群关系 + + ```SH + # --cluster-replicas 1 master slave :表示为每个master创建一一个slave节点 + redis-cli -a 123456 --cluster create --cluster-replicas 1 192.168.111.175:6381 192.168.111.175:6382 192:168.111.172:6383 192.168.111.172:6384 192.168.111.174:6385 192.168.111.174:6386 + ``` + +4. 查看集群状态 `cluster nodes` + +5. 配置集群后需要注意槽位,所以操作redis需要防止路由失效 加参数-c `redis-cli -a 123456 -p 6381 -c` + +6. 查看某个key该属于对应的槽位值 `cluster keyslot key` + + + +**主从容错切换**:主机宕机后再次上线不会以主机身份上位,而是以**从节点形式回归**,可以手动故障转移or节点从属调整: `cluster failover` + +**主从扩容**: + +1. 编写配置文件 +2. 启动主机节点 +3. 主节点加入集群: `redis-cli -a 密码 --cluster add-node 节点IP:PORT 集群IP:PORT` +4. 检查集群状况 :`redis-cli -a 密码 --cluster check 集群IP:PORT` +5. 重新分派槽位 :`redis-cli -a 密码 --cluster reshard 集群IP:PORT` ,重新分配成本太高,所以是之前集群节点各自匀出来一部分 +6. 为主节点分配从节点 :`redis-cli -a 密码 --cluster add-node 新slaveIP:PORT 新masterIP:PORT --cluster-slave --cluster-master-id master节点ID` + +**主从缩容**: + +1. 获取从节点节点ID:`redis-cli -a 密码 --cluster check 从节点IP:PORT` +2. 集群中删除从节点:`redis-cli -a 密码 --cluster del-node 从节点IP:PORT 从节点ID` +3. 主节点槽号清空,重新分配:`redis-cli -a 密码 --cluster reshard 预分配节点IP:PORT` +4. 删除主节点:`redis-cli -a 密码 --cluster del-node 主节点IP:PORT 主节点ID` +5. 检查 + + + +集群不保证数据一致性100%OK,是会有数据丢失的情况 + +Redis集群不保证强一致性这意味着在特定的条件下,Redis集群可能会丢掉一些被系统收到的写入请求命令 + +不在同一个slot槽位下的键值无法使用mset、mget等多键操作,可以通过{}来定义同一个组的概念,使key中{}内相同内容的键值对放到一个slot槽位去 + + + +### RedisTemplate + +redis客户端:Redisson、Jedis、lettuce等等,官方推荐使用Redisson。 + +**连接单机** + +1. 依赖 + + ```xml + + + org.springframework.boot + spring-boot-starter-data-redis + + + org.apache.commons + commons-pool2 + + ``` + +2. 配置 + + ```properties + spring.redis.database=0 + # 修改为自己真实IP + spring.redis.host=192.168.111.185 + spring.redis.port=6379 + spring.redis.password=111111 + spring.redis.lettuce.pool.max-active=8 + spring.redis.lettuce.pool.max-wait=-1ms + spring.redis.lettuce.pool.max-idle=8 + spring.redis.lettuce.pool.min-idle=0 + ``` + +3. 配置类 + + ```java + @Configuration + public class RedisConfig + { + /** + * redis序列化的工具配置类,下面这个请一定开启配置 + * @param lettuceConnectionFactory + * @return + */ + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory) + { + RedisTemplate redisTemplate = new RedisTemplate<>(); + + redisTemplate.setConnectionFactory(redisConnectionFactory); + //设置key序列化方式string + redisTemplate.setKeySerializer(new StringRedisSerializer()); + //设置value的序列化方式json,使用GenericJackson2JsonRedisSerializer替换默认序列化 + redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer()); + + redisTemplate.setHashKeySerializer(new StringRedisSerializer()); + redisTemplate.setHashValueSerializer(new GenericJackson2JsonRedisSerializer()); + + redisTemplate.afterPropertiesSet(); + + return redisTemplate; + } + } + ``` + +4. 使用 + + ```java + @Resource + private RedisTemplate redisTemplate; + ``` + + + +**连接集群** + +配置 + +```properties +spring.redis.password=111111 +# 获取失败 最大重定向次数 +spring.redis.cluster.max-redirects=3 +spring.redis.lettuce.pool.max-active=8 +spring.redis.lettuce.pool.max-wait=-1ms +spring.redis.lettuce.pool.max-idle=8 +spring.redis.lettuce.pool.min-idle=0 +#支持集群拓扑动态感应刷新,自适应拓扑刷新是否使用所有可用的更新,默认false关闭 +spring.redis.lettuce.cluster.refresh.adaptive=true +#定时刷新 +spring.redis.lettuce.cluster.refresh.period=2000 +spring.redis.cluster.nodes=192.168.111.175:6381,192.168.111.175:6382,192.168.111.172:6383,192.168.111.172:6384,192.168.111.174:6385,192.168.111.174:6386 +``` + + + + + +## Redis高级 + +### Redis单线程 + +redis单线程主要是指Redis的**网络IO和键值对读写是由一个线程来完成**的,Redis在处理客户端的请求时包括**获取 (socket 读)、解析、执行、内容返回 (socket 写**) 等都由一个顺序串行的主线程处理,这就是所谓的“单线程”。这也是Redis对外提供键值存储服务的主要流程。 + +但Redis的其他功能,比如**关闭文件、AOF 刷盘、释放内存**等,其实是由额外的线程执行的。 + +**Redis命令工作线程是单线程的,但是,整个Redis来说,是多线程的;** + + + +之所以 Redis 为「关闭文件、AOF 刷盘、释放内存」这些任务创建单独的线程来处理,是因为这些任务的操作都是很耗时的,如果把这些任务都放在主线程来处理,那么 Redis 主线程就很容易发生阻塞,这样就无法处理后续的请求了。 + +后台线程相当于一个消费者,生产者把耗时任务丢到任务队列中,消费者(BIO)不停轮询这个队列,拿出任务就去执行对应的方法即可。 + + + +#### Redis 采用单线程为什么还这么快? + +1. **基于内存操作**: Redis 的所有数据都存在内存中,因此所有的运算都是内存级别的,所以他的性能比较高; +2. **数据结构简单**: Redis 的数据结构是专门设计的,而这些简单的数据结构的查找和操作的时间大部分复杂度都是 0(1),因此性能比较高; +3. Redis **采用单线程模型可以避免了多线程之间的竞争**,省去了多线程切换带来的时间和性能上的开销,而且也不会导致死锁问题。 +4. **多路复用和非阻塞 I/O**: Redis使用 I/O多路复用功能来监听多个 socket连接客户端,这样就可以使用一个线程连接来处理多个请求,减少线程切换带来的开销,同时也避免了 I/O 阻塞操作; + + + +#### Redis6.0之前一直采用单线程的主要原因 + +1. 使用单线程模型是 Redis 的开发和维护更简单,因为单线程模型方便开发和调试; +2. 即使使用单线程模型也并发的处理多客户端的请求,主要使用的是IO多路复用和非阻塞IO; +3. 对于Redis系统来说,主要的性能瓶颈是内存或者网络带宽而并非 CPU。 + + + +#### 为什么逐渐加入多线程特性? + +删除一个很大的数据时,因为是单线程原子命令操作,这就会导致 Redis 服务卡顿,于是在 Redis 4.0 中就新增了多线程的模块,主要是为了解决删除数据效率比较低的问题的。 + +使用惰性删除可以有效的解决性能问题, 在Redis4.0就引入了多个线程来实现数据的异步惰性删除等功能 + +```sh +unlink key +flushdb async +flushall async +``` + +**但是其处理读写请求的仍然只有一个线程**,所以仍然算是狭义上的单线程。 + +在Redis6/7中引入了I/0多线程的读写,这样就可以更加高效的处理更多的任务了,Redis只是将I/O读写变成了多线程,而命令的执行依旧是由主线程串行执行的,因此在多线程下操作 Redis不会出现线程安全的问题。 + + + +多线程开启: + +1. 设置`io-thread-do-reads`配置项为yes,表示启动多线程。 +2. 设置线程个数 `io-threads`。关于线程数的设置,官方的建议是如果为4核的CPU,建议线程数设置为2或3,如果为8核CPU建议线程数设置为6,安程数一定要小于机器核数,线程数并不是越大越好。 + + + +因此, Redis 6.0 版本之后,Redis 在启动的时候,默认情况下会**额外创建 6 个线程**(*这里的线程数不包括主线程*): + +- Redis-server : Redis的主线程,主要负责执行命令; +- bio_close_file、bio_aof_fsync、bio_lazy_free:三个后台线程,分别异步处理关闭文件任务、AOF刷盘任务、释放内存任务; +- io_thd_1、io_thd_2、io_thd_3:三个 I/O 线程,io-threads 默认是 4 ,所以会启动 3(4-1)个 I/O 多线程,用来分担 Redis 网络 I/O 的压力。 + + + +### BigKey + +**多大算BigKey?** + +通常我们说的BigKey,不是在值的Key很大,而是指的Key对应的value很大 + +![](.\Redis\阿里云Redis开发规范.jpg) + + + +**大 key 会带来以下四种影响:** + +1. **客户端超时阻塞**。由于 Redis 执行命令是单线程处理,然后在操作大 key 时会比较耗时,那么就会阻塞 Redis,从客户端这一视角看,就是很久很久都没有响应。 + +2. **引发网络阻塞**。每次获取大 key 产生的网络流量较大,如果一个 key 的大小是 1 MB,每秒访问量为 1000,那么每秒会产生 1000MB 的流量,这对于普通千兆网卡的服务器来说是灾难性的。 + +3. **阻塞工作线程**。如果使用 del 删除大 key 时,会阻塞工作线程,这样就没办法处理后续的命令。 + +4. **内存分布不均**。集群模型在 slot 分片均匀情况下,会出现数据和查询倾斜情况,部分有大 key 的 Redis 节点占用内存多,QPS 也会比较大。 + + + +**如何发现BigKey?** + +redis-cli --bigkey + +``` +加上 -i 参数,每隔100 条 scan指令就会休眠0.1s. ops就不会剧烈抬升,但是扫描的时间会变长 +redis-cli -h 127.0.0.1 -p 6379 --bigkeys -i 0.1 +``` + +最好选择在从节点上执行该命令。因为主节点上执行时,会阻塞主节点; + +如果没有从节点,那么可以选择在 Redis 实例业务压力的低峰阶段进行扫描查询,以免影响到实例的正常运行;或者可以使用 -i 参数控制扫描间隔,避免长时间扫描降低 Redis 实例的性能。 + +想查询大于10kb的所有key,--bigkeys参数就无能为力了,需要用到memory usage来计算每个键值的字节数 + +`memory usage 键` + + + +**BigKey如何删除?** + +分批次删除和异步删除 + +- String:一般用del,如果过于庞大使用unlink key 删除 + +- hash + + 使用 hscan 每次获取少量field-value,再使用 hdel 删除每个field, 最后删除field-value + +- list + + 使用 ltrim 渐进式逐步删除,直到全部删除完成 + +- set + + 使用 sscan 每次获取部分元素,在使用 srem 命令删除每个元素 + +- zset + + 使用 zscan 每次获取部分元素,在使用 zremrangebyrank 命令删除每个元素 + + + +**大批量往redis里面插入2000W测试数据key** + +Linux Bash下面执行,插入100W数据 + +1. 生成100W条redis批量设置kv的语句(key=kn,value=vn)写入到/tmp目录下的redisTest.txt文件中 + + ```sh + for((i=1;i<=100*10000;i++)); do echo "set ksi v$i" >> /tmp/redisTest.txt ;done; + ``` + +2. 通过redis提供的管道-pipe命令插入100W大批量数据 + + ```sh + cat /tmp/redisTest.txt | /opt/redis-7.0.0/src/redis-cli -h 127.0.0.1 -p 6379-a 111111 --pipe + ``` + + + +**生产上限制 keys * /flushdb/flushall等危险命令以防止误删误用?** + +通过配置设置禁用这些命令,redis.conf在SECURITY这一项中 + +``` +rename-command keys "" +rename-command flushdb "" +rename-command flushall "" +``` + + + +**不用keys *避免卡顿,那该用什么?** `scan, sscan, hscan, zscan` + +``` +scan cursor [MATCH pattern] [COUNT count] +``` + +* cursor : 游标 +* pattern:匹配的模式 +* count:指定数据集返回多少数据,默认10 + +SCAN 命令是一个基于游标的迭代器,每次被调用之后, 都会向用户返回一个新的游标, 用户在下次迭代时需要使用这个新游标作为 SCAN 命令的游标参数, 以此来延续之前的迭代过程。 + +SCAN的遍历顺序非常特别,它不是从第一维数组的第零位一直遍历到末尾,而是采用了高位进位加法来遍历。之所以使用这样特殊的方式进行遍历,是考虑到字典的扩容和缩容时避免槽位的遍历重复和遗漏。 + + + +**分别说说三种写回策略,在持久化 BigKey 的时候,会影响什么?** + +- Always 策略就是每次写入 AOF 文件数据后,就执行 fsync() 函数; +- Everysec 策略就会创建一个异步任务来执行 fsync() 函数; +- No 策略就是永不执行 fsync() 函数; + +**当使用 Always 策略的时候,如果写入是一个大 Key,主线程在执行 fsync() 函数的时候,阻塞的时间会比较久,因为当写入的数据量很大的时候,数据同步到硬盘这个过程是很耗时的**。 + +当使用 Everysec 策略的时候,由于是异步执行 fsync() 函数,所以大 Key 持久化的过程(数据同步磁盘)不会影响主线程。 + +当使用 No 策略的时候,由于永不执行 fsync() 函数,所以大 Key 持久化的过程不会影响主线程 + + + +AOF 重写机制和 RDB 快照(bgsave 命令)的过程,都会分别通过 `fork()` 函数创建一个子进程来处理任务。会有两个阶段会导致阻塞父进程(主线程): + +- 创建子进程的途中,由于要复制父进程的页表等数据结构,阻塞的时间跟页表的大小有关,页表越大,阻塞的时间也越长; +- 创建完子进程后,如果父进程修改了共享数据中的大 Key,就会发生写时复制,这期间会拷贝物理内存,由于大 Key 占用的物理内存会很大,那么在复制物理内存这一过程,就会比较耗时,所以有可能会阻塞父进程。 + + + +### 缓存双写一致性(缓存更新策略) + +#### 常见的缓存更新策略 + +- Cache Aside(旁路缓存)策略; +- Read/Write Through(读穿 / 写穿)策略; +- Write Back(写回)策略; + + + +**Cache Aside(旁路缓存)** + +Cache Aside(旁路缓存)策略是最常用的,应用程序直接与「数据库、缓存」交互,并负责对缓存的维护,该策略又可以细分为「读策略」和「写策略」。 + +写策略的步骤: + +- 先更新数据库中的数据,再删除缓存中的数据。 + +读策略的步骤: + +- 如果读取的数据命中了缓存,则直接返回数据; +- 如果读取的数据没有命中缓存,则从数据库中读取数据,然后将数据写入到缓存,并且返回给用户。 + + + +**Read/Write Through(读穿 / 写穿)策略** + +Read/Write Through(读穿 / 写穿)策略原则是应用程序只和缓存交互,不再和数据库交互,而是由缓存和数据库交互,相当于更新数据库的操作由缓存自己代理了。 + +Read Through 策略: + +先查询缓存中数据是否存在,如果存在则直接返回,如果不存在,则由缓存组件负责从数据库查询数据,并将结果写入到缓存组件,最后缓存组件将数据返回给应用。 + +Write Through 策略: + +当有数据更新的时候,先查询要写入的数据在缓存中是否已经存在: + +- 如果缓存中数据已经存在,则更新缓存中的数据,并且由缓存组件同步更新到数据库中,然后缓存组件告知应用程序更新完成。 +- 如果缓存中数据不存在,直接更新数据库,然后返回; + + + +**Write Back(写回)策略** + +Write Back(写回)策略在更新数据的时候,只更新缓存,同时将缓存数据设置为脏的,然后立马返回,并不会更新数据库。对于数据库的更新,会通过批量异步更新的方式进行。 + +Write Back 是计算机体系结构中的设计,比如 CPU 的缓存、操作系统中文件系统的缓存都采用了 Write Back(写回)策略。 + +**Write Back 策略特别适合写多的场景**,因为发生写操作的时候, 只需要更新缓存,就立马返回了。比如,写文件的时候,实际上是写入到文件系统的缓存就返回了,并不会写磁盘。**但是带来的问题是,数据不是强一致性的,而且会有数据丢失的风险**。 + + + +#### **数据库和缓存一致性的几种更新策略** + +##### 1. 先更新数据库,再更新缓存 + +问题: + +1. 更新数据库成功,更新缓存失败,读到redis脏数据 + +2. 多线程下 + + ``` + 【正常逻辑】 + 1 A update mysql 100 + 2 A update redis 100 + 3 B update mysql 80 + 4 B update redis 80 + ============================= + 【异常逻辑】多线程环境下,A、B两个线程有快有慢,有前有后有并行 + 1 A update mysql 100 + 3 B update mysql 80 + 4 B update redis 80 + 2 A update redis 100 + ============================= + 最终结果,mysql和redis数据不一致: mysql80,redis100 + ``` + +##### 2. 先更新缓存,再更新数据库 + +问题: + +1. 多线程下 + + ``` + 【正常逻辑】 + 1 A update redis 100 + 2 A update mysql 100 + 3 B update redis 80 + 4 B update mysql 80 + ==================================== + 【异常逻辑】多线程环境下,A、B两个线程有快有慢有并行 + A update redis 100 + B update redis 80 + B update mysql 80 + A update mysql 100 + ==================================== + ----mysql100,redis80 + ``` + +##### 3. 先删除缓存,再更新数据库 + +问题: + +1. 多线程下 + + ``` + A删除缓存后,B查询操作没有命中缓存,B先把老数据读出来后放到缓存中,然后A更新操作更新了数据库。 + 于是,在缓存中的数据还是老的数据,数据库的数据是新数据 + ``` + +解决方案: + +1. 延时双删策略: + + ``` + #删除缓存 + redis.delKey(X) + #更新数据库 + db.update(X) + #睡眠 + Thread.sleep(N) + #再删除缓存 + redis.delKey(X) + + 线程A删除并更新数据库后等待一段时间,B将数据库数据写入缓存后,A再删除。 + 等待时间大于B读取并写入时间。如何获取?评估耗时;后台监控程序(WatchDog) + 第二次删除可以使用异步删除,可以增加吞吐量 + ``` + +##### 4. 先更新数据库,再删除缓存 + +问题: + +1. 假如缓存删除失败或者来不及,导致请求再次访问redis时缓存命中,读取到的是缓存旧值。 + +2. 多线程下 + + ``` + 假如某个用户数据在缓存中不存在,请求 A 读取数据时从数据库中查询到年龄为 20,在未写入缓存中时另一个请求 B 更新数据。它更新数据库中的年龄为 21,并且清空缓存。这时请求 A 把从数据库中读到的年龄为 20 的数据写入到缓存中。 + ``` + + 但是在实际中,这个问题出现的概率并不高。因为缓存的写入通常要远远快于数据库的写入 + + + +解决方案: + +1. 重试机制:引入消息队列把删除缓存要操作的数据加入消息队列,删除缓存失败则从队列中重新读取数据再次删除,删除成功就从队列中移除 +2. 订阅MySql binlog,再操作缓存:更新数据库成功,就会产生一条变更日志,记录在 binlog 里。订阅 binlog 日志,拿到具体要操作的数据,然后再执行缓存删除。 + + + +优先使用**先更新数据库,再删除缓存的方案(先更库→后删存)**。理由如下: + +1. 先删除缓存值再更新数据库,有可能导致请求因缓存缺失而访问数据库,给数据库带来压力导致打满mysql。 + +2. 如果业务应用中读取数据库和写缓存的时间不好估算,那么,延迟双删中的等待时间就不好设置。 + + + +#### cannal + +[alibaba/canal: 阿里巴巴 MySQL binlog 增量订阅&消费组件 (github.com)](https://github.com/alibaba/canal) + +canal 模拟 MySQL slave 的交互协议,伪装自己为 MySQL slave ,向 MySQL master 发送 dump 协议 + +MySQL master 收到 dump 请求,开始推送 binary log 给 slave (即 canal ) + +canal 解析 binary log 对象(原始为 byte 流) + + + +[QuickStart · alibaba/canal Wiki (github.com)](https://github.com/alibaba/canal/wiki/QuickStart) + +1. mysql + + 1. 查看MySql binlog是否开启: show variables like 'log_bin'; + + 2. 开启 Binlog 写入功能 :my.cnf 中配置 + + ``` + [mysqld] + log-bin=mysql-bin # 开启 binlog + binlog-format=ROW # 选择 ROW 模式 + server_id=1 # 配置 MySQL replaction 需要定义,不要和 canal 的 slaveId 重复 + ``` + + * ROW模式 除了记录sql语句之外,还会记录每个字段的变化情况,能够清楚的记录每行数据的变化历史,但会占用较多的空间。 + + * STATEMENT模式只记录了sql语句,但是没有记录上下文信息,在进行数据恢复的时候可能会导致数据的丢失情况; + * MIX模式比较灵活的记录,理论上说当遇到了表结构变更的时候,就会记录为statement模式。当遇到了数据更新或者删除情况下就会变为row模式; + + 3. 重启mysql + + 4. 授权 canal 链接 MySQL 账号具有作为 MySQL slave 的权限, 如果已有账户可直接 grant + +2. cannal服务端 + + 1. 下载cannal + + 2. 修改配置,主机、账户、密码等 + + 3. 启动 + +3. cannal客户端 [ClientExample · alibaba/canal Wiki (github.com)](https://github.com/alibaba/canal/wiki/ClientExample) + + + + + +### 布隆过滤器 Bloom Filter + +布隆过滤器由「初始值都为 0 的 bit 数组」和「 N 个哈希函数」两部分组成,用来快速判断集合是否存在某个元素。 + +当我们在写入数据库数据时,在布隆过滤器里做个标记,这样下次查询数据是否在数据库时,只需要查询布隆过滤器,如果查询到数据没有被标记,说明不在数据库中。 + +布隆过滤器会通过 3 个操作完成标记: + +- 第一步,使用 N 个哈希函数分别对数据做哈希计算,得到 N 个哈希值; +- 第二步,将第一步得到的 N 个哈希值对位图数组的长度取模,得到每个哈希值在位图数组的对应位置。 +- 第三步,将每个哈希值在位图数组的对应位置的值设置为 1; + +一个元素如果判断结果:存在--元素可能存在;不存在--元素一定不存在 + +布隆过滤器只能添加元素,不能删除元素,因为布隆过滤器的bit位可能是共享的,删掉元素会影响其他元素导致误判率增加 + + + +应用场景: + +1. 解决缓存穿透问题 +2. 黑白名单校验 + + + +为了解决布隆过滤器不能删除元素的问题,布谷鸟过滤器横空出世。https://www.cs.cmu.edu/~binfan/papers/conext14_cuckoofilter.pdf#:~:text=Cuckoo%20%EF%AC%81lters%20support%20adding%20and%20removing%20items%20dynamically,have%20lower%20space%20overhead%20than%20space-optimized%20Bloom%20%EF%AC%81lters. + + + +### 缓存预热/缓存雪崩/缓存击穿/缓存穿透 + +#### 缓存预热 + +将热点数据提前加载到redis缓存中 + +#### 缓存雪崩 + +redis**故障或**者redis中**大量的缓存数据同时失效,大量请求直接访问数据库,从而导致数据库压力骤增** + +和缓存击穿不同的是,缓存击穿指并发查同一条数据,缓存雪崩是不同数据都过期了,很多数据都查不到从而查数据库。 + +解决: + +1. 大量数据同时过期: + + 1. 均匀设置过期时间或不过期:设置过期时间时可以加上一个随机数 + + 2. 互斥锁:保证同一时间只有一个请求访问数据库来构建缓存 + 3. 后台更新缓存:业务线程不再负责更新缓存,缓存也不设置有效期,而是让缓存“永久有效”,并将更新缓存的工作交由后台线程定时更新。 + +2. redis故障 + + 1. 缓存集群高可用:哨兵、集群、持久化 + + 2. 服务降级、熔断 + +#### 缓存穿透 + +**缓存和数据库中都没有数据** + +解决: + +1. 空对象缓存或缺省值:如果发生了缓存穿透,我们可以针对要查询的数据,在Redis里存一个和业务部门商量后确定的缺省值(比如,零、负数、defaultNull等)。 +2. 布隆过滤器:Google布隆过滤器Guava解决缓存穿透 [guava](https://github.com/google/guava/blob/master/guava/src/com/google/common/hash/BloomFilter.java) +3. 非法请求限制:在 API 入口处我们要判断求请求参数是否合理 +4. 增强id复杂度,避免被猜测id规律(可以采用雪花算法) +5. 做好数据的基础格式校验 +6. 加强用户权限校验 +7. 做好热点参数的限流 + +#### 缓存击穿 + +**缓存中没有但数据库中有数据** + +热点key突然失效,此时大量的请求访问了该热点数据,就无法从缓存中读取,直接访问数据库,数据库很容易就被高并发的请求冲垮,这就是缓存击穿的问题。 + +解决: + +1. 差异失效时间:开辟两块缓存,设置不同的缓存过期时间,主A从B,先更新B再更新A,先查询A没有再查询B +2. 加锁策略,保证同一时间只有一个业务线程更新缓存 +3. 不给热点数据设置过期时间 +4. 后台更新缓存 +5. 接口限流、熔断与降级 + + + +### 缓存过期删除/内存淘汰策略 + +**redis默认内存多少可用?** + +如果不设置最大内存或者设置最大内存大小为0,在64位操作系统下不限制内存大小,在32位操作系统下最多使用3GB内存 + +注意:在64bit系统下,maxmemory设置为0表示不限制redis内存使用 + +一般推荐Redis设置内存为最大物理内存的3/4 + +**如何修改redis内存设置**? + +1. 通过修改文件配置 `maxmemory` + +2. 通过命令修改,但是redis重启后会失效 `config set maxmemory SIZE` + +什么命令查看redis内存使用情况 + +info memory + +config get maxmemory + + + +#### 过期删除策略 + +1. **立即/定时删除**:在设置 key 的过期时间时,同时创建一个定时事件,当时间到达时,由事件处理器自动执行 key 的删除操作。对内存友好,对CPU不友好 +2. **惰性删除**:不主动删除过期键,每次从数据库访问 key 时,都检测 key 是否过期,如果过期则删除该 key。对内存不友好,对CPU友好。开启惰性删除淘汰,lazyfree-lazy-eviction=yes +3. **定期删除**:每隔一段时间「随机」从数据库中取出一定数量的 key 进行检查,并删除其中的过期key。超过一定比例则重复此操作。Redis 为了保证定期删除不会出现循环过度,导致线程卡死现象,为此增加了定期删除循环流程的时间上限,默认不会超过 25ms。缺点是难以确定删除操作执行的时长和频率。 + + **Redis 选择「惰性删除+定期删除」这两种策略配和使用**,以求在合理使用 CPU 时间和避免内存浪费之间取得平衡。 + + + +#### 内存淘汰策略 + +超过redis设置的最大内存,就会使用内存淘汰策略删除符合条件的key + +LRU:最近最少使用页面置换算法,淘汰**最长时间未被使用**的页面,看页面最后一次被使用到发生调度的时间长短,首先淘汰最长时间未被使用的页面。 + +LFU:最近最不常用页面置换算法,淘汰**一定时期内被访问次数最少**的页面,看一定时间段内页面被使用的频率,淘汰一定时期内被访问次数最少的页 + + + +淘汰策略有哪些(Redis7版本): + +1. noeviction:不淘汰任何key,表示即使内存达到上限也不进行置换,所有能引起内存增加的命令都会返回error +2. 对设置了过期时间的数据中进行淘汰 + 1. LRU + 2. LFU + 3. random + 4. TTL:优先淘汰更早过期的key +3. 全部数据进行淘汰 + 1. random + 2. LRU + 3. LFU + + + + +**如何修改 Redis 内存淘汰策略?** + +1. `config set maxmemory-policy <策略>` 设置之后立即生效,不需要重启 Redis 服务,重启 Redis 之后,设置就会失效。 + +2. 通过修改 Redis 配置文件修改,设置“`maxmemory-policy <策略>`”,它的优点是重启 Redis 服务后配置不会丢失,缺点是必须重启 Redis 服务,设置才能生效。 + + + +**Redis 是如何实现 LRU 算法的?** + +传统 LRU 算法的实现是基于「链表」结构,链表中的元素按照操作顺序从前往后排列,最新操作的键会被移动到表头,当需要内存淘汰时,只需要删除链表尾部的元素即可,因为链表尾部的元素就代表最久未被使用的元素。 + +Redis 并没有使用这样的方式实现 LRU 算法,因为传统的 LRU 算法存在两个问题: + +- 需要用链表管理所有的缓存数据,这会带来额外的空间开销; +- 当有数据被访问时,需要在链表上把该数据移动到头端,如果有大量数据被访问,就会带来很多链表移动操作,会很耗时,进而会降低 Redis 缓存性能。 + +Redis 实现的是一种**近似 LRU 算法**,目的是为了更好的节约内存,它的**实现方式是在 Redis 的对象结构体中添加一个额外的字段,用于记录此数据的最后一次访问时间**。 + +当 Redis 进行内存淘汰时,会使用**随机采样的方式来淘汰数据**,它是随机取 N 个值,然后**淘汰最久没有使用的那个**。 + +Redis 实现的 LRU 算法的优点: + +- 不用为所有的数据维护一个大链表,节省了空间占用; +- 不用在每次数据访问时都移动链表项,提升了缓存的性能; + + + +**Redis 是如何实现 LFU 算法的?** + +LFU 算法相比于 LRU 算法的实现,多记录了「数据的访问频次」的信息。 + +```c +typedef struct redisObject { + ... + + // 24 bits,用于记录对象的访问信息 + unsigned lru:24; + ... +} robj; +``` + +Redis 对象头中的 lru 字段,在 LRU 算法下和 LFU 算法下使用方式并不相同。 + +**在 LRU 算法中**,Redis 对象头的 24 bits 的 lru 字段是用来记录 key 的访问时间戳,因此在 LRU 模式下,Redis可以根据对象头中的 lru 字段记录的值,来比较最后一次 key 的访问时间长,从而淘汰最久未被使用的 key。 + +**在 LFU 算法中**,Redis对象头的 24 bits 的 lru 字段被分成两段来存储,高 16bit 存储 ldt(Last Decrement Time),用来记录 key 的访问时间戳;低 8bit 存储 logc(Logistic Counter),用来记录 key 的访问频次。 + + + +### 分布式锁 + +基于 Redis 节点实现分布式锁时,对于加锁操作,我们需要满足三个条件。 + +- 加锁包括了读取锁变量、检查锁变量值和设置锁变量值三个操作,但需要以原子操作的方式完成,所以,我们使用 SET 命令带上 NX 选项来实现加锁; +- 锁变量需要设置过期时间,以免客户端拿到锁后发生异常,导致锁一直无法释放,所以,我们在 SET 命令执行时加上 EX/PX 选项,设置其过期时间; +- 锁变量的值需要能区分来自不同客户端的加锁操作,以免在释放锁时,出现误释放操作,所以,我们使用 SET 命令设置锁变量值时,每个客户端设置的值是一个唯一值,用于标识客户端; + +满足这三个条件的分布式命令如下: + +```sh +SET lock_key unique_value NX PX 10000 +``` + +而解锁的过程就是将 lock_key 键删除(del lock_key),但不能乱删,要保证执行操作的客户端就是加锁的客户端。所以,解锁的时候,我们要先判断锁的 unique_value 是否为加锁客户端,是的话,才将 lock_key 键删除。 + +可以看到,解锁是有两个操作,这时就需要 Lua 脚本来保证解锁的原子性,因为 Redis 在执行 Lua 脚本时,可以以原子性的方式执行,保证了锁释放操作的原子性。 + +```c +// 释放锁时,先比较 unique_value 是否相等,避免锁的误释放 +if redis.call("get",KEYS[1]) == ARGV[1] then + return redis.call("del",KEYS[1]) +else + return 0 +end +``` + +基于 Redis 实现分布式锁的**优点**: + +1. 性能高效(这是选择缓存实现分布式锁最核心的出发点)。 +2. 实现方便。因为 Redis 提供了 setnx 方法,实现分布式锁很方便。 +3. 避免单点故障(因为 Redis 是跨集群部署的,自然就避免了单点故障)。 + +基于 Redis 实现分布式锁的**缺点**: + +1. 超时时间不好设置。可以基于续约的方式设置超时时间:先给锁设置一个超时时间,然后启动一个守护线程,让守护线程在一段时间后,重新设置这个锁的超时时间 + +2. Redis 主从复制模式中的数据是异步复制的,这样导致分布式锁的不可靠性。如果在 Redis 主节点获取到锁后,在没有同步到其他节点时,Redis 主节点宕机了,此时新的 Redis 主节点依然可以获取锁,所以多个应用服务就可以同时获取到锁。 + + + +#### RedLock + +它是基于**多个 Redis 节点**的分布式锁,即使有节点发生了故障,锁变量仍然是存在的,客户端还是可以完成锁操作。官方推荐是至少部署 5 个 Redis 节点,而且都是主节点,它们之间没有任何关系,都是一个个孤立的节点。 + +Redlock 算法的基本思路,**是让客户端和多个独立的 Redis 节点依次请求申请加锁,如果客户端能够和半数以上的节点成功地完成加锁操作,那么我们就认为,客户端成功地获得分布式锁,否则加锁失败**。 + +这样一来,即使有某个 Redis 节点发生故障,因为锁的数据在其他节点上也有保存,所以客户端仍然可以正常地进行锁操作,锁的数据也不会丢失。 + +Redlock 算法加锁三个过程: + +1. 第一步是,客户端获取当前时间(t1)。 + +2. 第二步是,客户端按顺序依次向 N 个 Redis 节点执行加锁操作: + + - 加锁操作使用 SET 命令,带上 NX,EX/PX 选项,以及带上客户端的唯一标识。 + + - 如果某个 Redis 节点发生故障了,为了保证在这种情况下,Redlock 算法能够继续运行,我们需要给「加锁操作」设置一个超时时间(不是对「锁」设置超时时间,而是对「加锁操作」设置超时时间),加锁操作的超时时间需要远远地小于锁的过期时间,一般也就是设置为几十毫秒。 + +3. 第三步是,一旦客户端从超过半数(大于等于 N/2+1)的 Redis 节点上成功获取到了锁,就再次获取当前时间(t2),然后计算计算整个加锁过程的总耗时(t2-t1)。如果 t2-t1 < 锁的过期时间,此时,认为客户端加锁成功,否则认为加锁失败。 + +客户端只有在满足下面的这**两个条件**时,才能认为是加锁成功: + +1. 客户端从超过半数(大于等于N/2+1)的Redis节点上成功获取到了锁; + +2. 客户端获取锁的总耗时没有超过锁的过期时间。 + + + + + +### 过期键 + +#### Redis 持久化时,对过期键会如何处理的? + +Redis 持久化文件有两种格式:RDB(Redis Database)和 AOF(Append Only File),下面我们分别来看过期键在这两种格式中的呈现状态。 + +RDB 文件分为两个阶段,RDB 文件生成阶段和加载阶段。 + +- **RDB 文件生成阶段**:从内存状态持久化成 RDB(文件)的时候,会对 key 进行过期检查,**过期的键「不会」被保存到新的 RDB 文件中**,因此 Redis 中的过期键不会对生成新 RDB 文件产生任何影响。 + +- RDB 加载阶段 + + :RDB 加载阶段时,要看服务器是主服务器还是从服务器,分别对应以下两种情况: + + - **如果 Redis 是「主服务器」运行模式的话,在载入 RDB 文件时,程序会对文件中保存的键进行检查,过期键「不会」被载入到数据库中**。所以过期键不会对载入 RDB 文件的主服务器造成影响; + - **如果 Redis 是「从服务器」运行模式的话,在载入 RDB 文件时,不论键是否过期都会被载入到数据库中**。但由于主从服务器在进行数据同步时,从服务器的数据会被清空。所以一般来说,过期键对载入 RDB 文件的从服务器也不会造成影响。 + +AOF 文件分为两个阶段,AOF 文件写入阶段和 AOF 重写阶段。 + +- **AOF 文件写入阶段**:当 Redis 以 AOF 模式持久化时,**如果数据库某个过期键还没被删除,那么 AOF 文件会保留此过期键,当此过期键被删除后,Redis 会向 AOF 文件追加一条 DEL 命令来显式地删除该键值**。 +- **AOF 重写阶段**:执行 AOF 重写时,会对 Redis 中的键值对进行检查,**已过期的键不会被保存到重写后的 AOF 文件中**,因此不会对 AOF 重写造成任何影响。 + + + +#### Redis 主从模式中,对过期键会如何处理? + +当 Redis 运行在主从模式下时,**从库不会进行过期扫描,从库对过期的处理是被动的**。也就是即使从库中的 key 过期了,如果有客户端访问从库时,依然可以得到 key 对应的值,像未过期的键值对一样返回。 + +从库的过期键处理依靠主服务器控制,**主库在 key 到期时,会在 AOF 文件里增加一条 del 指令,同步到所有的从库**,从库通过执行这条 del 指令来删除过期的 key。 + + + +### 数据结构 + +#### SDS + +Redis 是用 C 语言实现的,但是它没有直接使用 C 语言的 char* 字符数组来实现字符串,而是自己封装了一个名为简单动态字符串(simple dynamic string,SDS) 的数据结构来表示字符串 + +char*缺陷: + +1. 获取字符串长度的时间复杂度为 O(N); + +2. 字符串的结尾是以 “\0” 字符标识,字符串里面不能包含有 “\0” 字符,因此不能保存二进制数据; + +3. 字符串操作函数不高效且不安全,比如有缓冲区溢出的风险,有可能会造成程序运行终止; + +![](.\Redis\SDS.webp) + +SDS改进: + +* Redis 的 SDS 结构因为加入了 len 成员变量,**获取字符串长度的时间复杂度为O(1)**。 +* SDS 不需要用 “\0” 字符来标识字符串结尾,**不仅可以保存文本数据,还可以保存二进制数据**,SDS 的所有 API 都会以处理二进制的方式来处理 SDS 存放在 `buf[]` 数组里的数据。 +* 不会发生缓冲区溢出。**当判断出缓冲区大小不够用时,Redis 会自动将扩大 SDS 的空间大小** +* 节省内存空间。设计了 5 种sds类型,分别是 sdshdr5、sdshdr8、sdshdr16、sdshdr32 和 sdshdr64。**能灵活保存不同大小的字符串,从而有效节省内存空间**。**取消结构体在编译过程中的优化对齐,按照实际占用字节数进行对齐** + + + +#### 链表 + + + +list 结构为链表提供了链表头指针 head、链表尾节点 tail、链表节点数量 len、以及可以自定义实现的 dup(节点值复制)、free(节点值释放)、match(节点值比较) 函数。 + + + +#### 压缩列表 + +压缩列表的最大特点,就是它被设计成一种内存紧凑型的数据结构,占用一块连续的内存空间,不仅可以利用 CPU 缓存,而且会针对不同长度的数据,进行相应编码,这种方法可以有效地节省内存开销。 + +但是,压缩列表的缺陷也是有的: + +- **不能保存过多的元素**,否则查询效率就会降低; +- **会记录前一个结点的大小**,新增或修改某个元素时,压缩列表占用的内存空间需要重新分配,甚至可能引发**连锁更新**的问题。 + + + +压缩列表是 Redis 为了节约内存而开发的,它是**由连续内存块组成的顺序型数据结构**,有点类似于数组。 + + + +> ***zlbytes***,记录整个压缩列表占用对内存字节数; +> +> ***zltail***,记录压缩列表「尾部」节点距离起始地址由多少字节,也就是列表尾的偏移量; +> +> ***zllen***,记录压缩列表包含的节点数量; +> +> ***zlend***,标记压缩列表的结束点,固定值 0xFF(十进制255)。 +> +> ***prevlen***,记录了「前一个节点」的长度,**目的是为了实现从后向前遍历**; +> +> ***encoding***,记录了当前节点实际数据的「类型和长度」,类型主要有两种:字符串和整数。 +> +> ***data***,记录了当前节点的实际数据,类型和长度都由 `encoding` 决定; + + + +在压缩列表中,如果我们要查找定位第一个元素和最后一个元素,可以通过表头三个字段(zllen)的长度直接定位,复杂度是 O(1)。而**查找其他元素时,就没有这么高效了,只能逐个查找,此时的复杂度就是 O(N) 了,因此压缩列表不适合保存过多的元素**。 + + + +##### 连锁更新问题 + +压缩列表里的每个节点中的 prevlen 属性都记录了「前一个节点的长度」,而且 prevlen 属性的空间大小跟前一个节点长度值有关,比如: + +- 如果**前一个节点的长度小于 254 字节**,那么 prevlen 属性需要用 **1 字节的空间**来保存这个长度值; +- 如果**前一个节点的长度大于等于 254 字节**,那么 prevlen 属性需要用 **5 字节的空间**来保存这个长度值;其中第一个字节设置为254,后面4个字节用来存储长度值。如果仅使用2字节编码方案,当长度超过65535字节时,仍然需要设计新的编码方式,这样会增加复杂性。通过统一使用5字节编码来处理长度较大的情况,可以避免这种复杂性,同时保持了设计的一致性。 + + + +压缩列表新增某个元素或修改某个元素时,如果空间不不够,压缩列表占用的内存空间就需要重新分配。**而当新插入的元素较大时,可能会导致后续元素的 prevlen 占用空间都发生变化**,从而引起「连锁更新」问题,导致每个元素的空间都要重新分配,造成访问压缩列表性能的下降。 + +1. 假设一个压缩列表中有多个连续的、长度在 250~253 之间的节点,因为这些节点长度值小于 254 字节,所以 prevlen 属性需要用 1 字节的空间来保存这个长度值。 +2. 这时,如果将一个长度大于等于 254 字节的新节点加入到压缩列表的表头节点,即新节点将成为 e1 的前置节点。 +3. 因为 e1 节点的 prevlen 属性只有 1 个字节大小,无法保存新节点的长度,此时就需要对压缩列表的空间重分配操作,并将 e1 节点的 prevlen 属性从原来的 1 字节大小扩展为 5 字节大小。 +4. 导致后面节点的 prevlen 属性也必须从 1 字节扩展至 5 字节大小。 + +**连锁更新一旦发生,就会导致压缩列表占用的内存空间要多次重新分配,这就会直接影响到压缩列表的访问性能**。**压缩列表只会用于保存的节点数量不多的场景**,只要节点数量足够小,即使发生连锁更新,也是能接受的。 + + + +#### 哈希表 + + + +哈希表是一个数组(dictEntry **table),数组的每个元素是一个指向「哈希表节点(dictEntry)」的指针。 + +Redis 采用了「**链式哈希**」的方法来解决哈希冲突。 + +```c +typedef struct dictEntry { + //键值对中的键 + void *key; + + //键值对中的值 + union { + void *val; + uint64_t u64; + int64_t s64; + double d; + } v; + //指向下一个哈希表节点,形成链表 + struct dictEntry *next; +} dictEntry; +``` + +dictEntry 结构里键值对中的值是一个「联合体 v」定义的,因此,键值对中的值可以是一个指向实际值的指针,或者是一个无符号的 64 位整数或有符号的 64 位整数或double 类的值。这么做的好处是可以节省内存空间,因为当「值」是整数或浮点数时,就可以将值的数据内嵌在 dictEntry 结构里,无需再用一个指针指向实际的值,从而节省了内存空间。 + + + +##### rehash + +在实际使用哈希表时,Redis 定义一个 dict 结构体,这个结构体里定义了**两个哈希表(ht[2])**。 + + + +在正常服务请求阶段,插入的数据,都会写入到「哈希表 1」,此时的「哈希表 2 」 并没有被分配空间。 + +随着数据逐步增多,触发了 rehash 操作,这个过程分为三步: + +1. 给「哈希表 2」 分配空间,一般会比「哈希表 1」 大一倍(两倍的意思); + +2. 将「哈希表 1 」的数据迁移到「哈希表 2」 中; + +3. 迁移完成后,「哈希表 1 」的空间会被释放,并把「哈希表 2」 设置为「哈希表 1」,然后在「哈希表 2」 新创建一个空白的哈希表,为下次 rehash 做准备。 + +##### 渐进式 rehash + +为了避免 rehash 在数据迁移过程中,因拷贝数据的耗时,影响 Redis 性能的情况,所以 Redis 采用了**渐进式 rehash**,也就是将数据的迁移的工作不再是一次性迁移完成,而是分多次迁移。 + +渐进式 rehash 步骤如下: + +1. 给「哈希表 2」 分配空间; + +2. **在 rehash 进行期间,每次哈希表元素进行新增、删除、查找或者更新操作时,Redis 除了会执行对应的操作之外,还会顺序将「哈希表 1 」中索引位置上的所有 key-value 迁移到「哈希表 2」 上**; + +3. 随着处理客户端发起的哈希表操作请求数量越多,最终在某个时间点会把「哈希表 1 」的所有 key-value 迁移到「哈希表 2」,从而完成 rehash 操作。 + +在渐进式 rehash 进行期间,哈希表元素的删除、查找、更新等操作都会在这两个哈希表进行。 + + + +触发 rehash 操作的条件,主要有两个: + +- **当负载因子大于等于 1 ,并且 Redis 没有在执行 bgsave 命令或者 bgrewiteaof 命令,也就是没有执行 RDB 快照或没有进行 AOF 重写的时候,就会进行 rehash 操作。** +- **当负载因子大于等于 5 时,此时说明哈希冲突非常严重了,不管有没有有在执行 RDB 快照或 AOF 重写,都会强制进行 rehash 操作。** + + + +#### 整数集合 + +整数集合是 Set 对象的底层实现之一。当一个 Set 对象只包含整数值元素,并且元素数量不大时,就会使用整数集这个数据结构作为底层实现。 + +整数集合本质上是一块连续内存空间。 + +```c +typedef struct intset { + //编码方式 + uint32_t encoding; + //集合包含的元素数量 + uint32_t length; + //保存元素的数组 + int8_t contents[]; +} intset; +``` + +虽然 contents 被声明为 int8_t 类型的数组,但是实际上 contents 数组并不保存任何 int8_t 类型的元素,contents 数组的真正类型取决于 intset 结构体里的 encoding 属性的值。比如int16_t, int32_t, int64_t. + +##### 升级操作 + +整数集合会有一个升级规则,就是当我们将一个新元素加入到整数集合里面,如果新元素的类型(int32_t)比整数集合现有所有元素的类型(int16_t)都要长时,整数集合需要先进行升级,也就是按新元素的类型(int32_t)扩展 contents 数组的空间大小,然后才能将新元素加入到整数集合里,当然升级的过程中,也要维持整数集合的有序性。 + +整数集合升级的过程不会重新分配一个新类型的数组,而是在原本的数组上扩展空间,然后在将每个元素按间隔类型大小分割,如果 encoding 属性值为 INTSET_ENC_INT16,则每个元素的间隔就是 16 位。 + + + +整数集合升级的好处是**节省内存资源**。**不支持降级操作**。 + +#### 跳表 + +Redis 只有 Zset 对象的底层实现用到了跳表,跳表的优势是能支持平均 O(logN) 复杂度的节点查找。 + +zset 结构体里有两个数据结构:一个是跳表,一个是哈希表。这样的好处是既能进行高效的范围查询,也能进行高效单点查询。 + +Zset 对象能支持范围查询(如 ZRANGEBYSCORE 操作),这是因为它的数据结构设计采用了跳表,而又能以常数复杂度获取元素权重(如 ZSCORE 操作),这是因为它同时采用了哈希表进行索引。 + + struct zset 中的哈希表只是用于以常数复杂度获取元素权重,大部分操作都是跳表实现的。 + + + +##### 结构设计 + +**跳表是在链表基础上改进过来的,实现了一种「多层」的有序链表**,这样的好处是能快读定位数据。 + + + +> 如果我们要在链表中查找节点 4 这个元素,只能从头开始遍历链表,需要查找 4 次,而使用了跳表后,只需要查找 2 次就能定位到节点 4,因为可以在头节点直接从 L2 层级跳到节点 3,然后再往前遍历找到节点 4。 + +```c +typedef struct zskiplistNode { + //Zset 对象的元素值 + sds ele; + //元素权重值 + double score; + //后向指针 指向前一个节点,目的是为了方便从跳表的尾节点开始访问节点,这样倒序查找时很方便。 + struct zskiplistNode *backward; + + //节点的level数组,保存每层上的前向指针和跨度 + struct zskiplistLevel { + // 指向下一个跳表节点的指针 + struct zskiplistNode *forward; + // 跨度 跨度实际上是为了计算这个节点在跳表中的排位 + unsigned long span; + } level[]; +} zskiplistNode; +``` + +```c +typedef struct zskiplist { + struct zskiplistNode *header, *tail; + unsigned long length; + int level; +} zskiplist; +``` + + + +##### 查询过程 + +查找一个跳表节点的过程时,**跳表会从头节点的最高层开始,逐一遍历每一层**。在遍历某一层的跳表节点时,会用跳表节点中的 SDS 类型的元素和元素的权重来进行判断,共有两个判断条件: + +- 如果当前节点的权重「小于」要查找的权重时,跳表就会访问该层上的下一个节点。 +- 如果当前节点的权重「等于」要查找的权重时,并且当前节点的 SDS 类型数据「小于」要查找的数据时,跳表就会访问该层上的下一个节点。 + +如果上面两个条件都不满足,或者下一个节点为空时,跳表就会使用目前遍历到的节点的 level 数组里的下一层指针,然后沿着下一层指针继续查找,这就相当于跳到了下一层接着查找。 + +![](.\Redis\3层跳表-跨度.drawio.webp) + +如果要查找「元素:abcd,权重:4」的节点,查找的过程是这样的: + +1. 先从头节点的最高层开始,L2 指向了「元素:abc,权重:3」节点,这个节点的权重比要查找节点的小,所以要访问该层上的下一个节点; + +2. 但是该层的下一个节点是空节点( leve[2]指向的是空节点),于是就会跳到「元素:abc,权重:3」节点的下一层去找,也就是 leve[1]; + +3. 「元素:abc,权重:3」节点的 leve[1] 的下一个指针指向了「元素:abcde,权重:4」的节点,然后将其和要查找的节点比较。虽然「元素:abcde,权重:4」的节点的权重和要查找的权重相同,但是当前节点的 SDS 类型数据「大于」要查找的数据,所以会继续跳到「元素:abc,权重:3」节点的下一层去找,也就是 leve[0]; + +4. 「元素:abc,权重:3」节点的 leve[0] 的下一个指针指向了「元素:abcd,权重:4」的节点,该节点正是要查找的节点,查询结束。 + +##### 层数设置 + +**跳表的相邻两层的节点数量最理想的比例是 2:1,查找复杂度可以降低到 O(logN)**。 + +Redis的 **跳表在创建节点的时候,随机生成每个节点的层数**,并没有严格维持相邻两层的节点数量比例为 2 : 1 的情况。 + +具体的做法是,**跳表在创建节点时候,会生成范围为[0-1]的一个随机数,如果这个随机数小于 0.25(相当于概率 25%),那么层数就增加 1 层,然后继续生成下一个随机数,直到随机数的结果大于 0.25 结束,最终确定该节点的层数**。 + +这样的做法,相当于每增加一层的概率不超过 25%,层数越高,概率越低,层高最大限制是 64。 + +虽然我前面讲解跳表的时候,图中的跳表的「头节点」都是 3 层高,但是其实**如果层高最大限制是 64,那么在创建跳表「头节点」的时候,就会直接创建 64 层高的头节点**。 + + + +#### quicklist + +quicklist 就是「**双向链表 + 压缩列表**」组合,因为一个 quicklist 就是一个链表,而链表中的每个元素又是一个压缩列表。 + +quicklist **通过控制每个链表节点中的压缩列表的大小或者元素个数,来规避连锁更新的问题。因为压缩列表元素越少或越小,连锁更新带来的影响就越小,从而提供了更好的访问性能。** + + + +在向 quicklist 添加一个元素的时候,不会像普通的链表那样,直接新建一个链表节点。而是会检查插入位置的压缩列表是否能容纳该元素,如果能容纳就直接保存到 quicklistNode 结构里的压缩列表,如果不能容纳,才会新建一个新的 quicklistNode 结构。 + +quicklist 会控制 quicklistNode 结构里的压缩列表的大小或者元素个数,来规避潜在的连锁更新的风险。 + + + +#### listpack + +quicklist 虽然通过控制 quicklistNode 结构里的压缩列表的大小或者元素个数,来减少连锁更新带来的性能影响,但是并没有完全解决连锁更新的问题。压缩列表连锁更新的问题,来源于它的结构设计,所以要想彻底解决这个问题,需要设计一个新的数据结构。 + +Redis 在 5.0 新设计一个数据结构叫 listpack,**目的是替代压缩列表**,它最大特点是 listpack 中每个节点不再包含前一个节点的长度了,压缩列表每个节点正因为需要保存前一个节点的长度字段,就会有连锁更新的隐患。 + + + +> encoding,定义该元素的编码类型,会对不同长度的整数和字符串进行编码; +> +> data,实际存放的数据; +> +> len,encoding+data的总长度; + +**listpack 没有压缩列表中记录前一个节点长度的字段了,listpack 只记录当前节点的长度,当我们向 listpack 加入一个新元素的时候,不会影响其他节点的长度字段的变化,从而避免了压缩列表的连锁更新问题**。 + +listpack 一样可以支持从后往前遍历的。详细的算法可以看:https://github.com/antirez/listpack/blob/master/listpack.c 里的lpDecodeBacklen函数,lpDecodeBacklen 函数就可以从当前列表项起始位置的指针开始,向左逐个字节解析,得到前一项的 entry-len 值。 + + + +## 实战 + +### 分布式Session + +将用户数据保存到redis,并且生成token作为redis的key,当我们校验用户是否登录时,会去携带着token进行访问,从redis中取出token对应的value,判断是否存在这个数据,如果没有则拦截。 + +对于 Spring Boot 应用,可以使用 `spring-session-data-redis` 库来实现会话共享 + +1. 添加`Spring Session`和 `redis`的相关依赖 + + ```xml + + org.springframework.session + spring-session-data-redis + + + org.springframework.boot + spring-boot-starter-data-redis + + ``` + +2. 配置 + + ```yml + spring: + redis: + # redis库 + database: 0 + # redis 服务器地址 + host: localhost + # redis 端口号 + port: 6379 + # redis 密码 + password: + # session 使用redis存储 + session: + store-type: redis + ``` + +3. 在 Spring Boot 应用的主类上添加 `@EnableRedisHttpSession` 注解: + + + +### 缓存 + +优点: + +1. 降低后端负载 +2. 提高读写效率,降低响应时间 + +缺点: + +1. 数据一致性问题 +2. 维护成本 + +**先操作数据库,再删除缓存,同时设置缓存时添加过期时间** + +#### 缓存穿透问题 + +缓存穿透 :缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库。 + +常见的解决方案有两种: + +1. 缓存空对象 + + - 优点:实现简单,维护方便 + + - 缺点:额外的内存消耗、可能造成短期的不一致 + + +如果这个数据不存在,将这个数据写入到Redis中,并且将value设置为空字符串,然后设置一个较短的TTL,返回错误信息。当再次发起查询时,先去Redis中判断value是否为空字符串,如果是空字符串,则说明是刚刚我们存的不存在的数据,直接返回错误信息。 + +```java +//先从Redis中查,这里的常量值是固定的前缀 + 店铺id +String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id); +//如果不为空(查询到了),则转为Shop类型直接返回 +if (StrUtil.isNotBlank(shopJson)) { + Shop shop = JSONUtil.toBean(shopJson, Shop.class); + return Result.ok(shop); +} +//如果查询到的是空字符串,则说明是我们缓存的空数据 +if (shopJson != null) { + return Result.fail("店铺不存在!!"); +} +//否则去数据库中查 +Shop shop = getById(id); +//查不到返回一个错误信息或者返回空都可以,根据自己的需求来 +if (shop == null){ + //这里的常量值是2分钟 + stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, "", CACHE_NULL_TTL, TimeUnit.MINUTES); + return Result.fail("店铺不存在!!"); +} +//查到了则转为json字符串 +String jsonStr = JSONUtil.toJsonStr(shop); +//并存入redis +stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, jsonStr,CACHE_SHOP_TTL,TimeUnit.MINUTES); + +//最终把查询到的商户信息返回给前端 +return Result.ok(shop); +``` + +2. 布隆过滤 + + - 优点:内存占用较少,没有多余key + + + - 缺点:实现复杂、存在误判可能 + + - 使用谷歌的guava + +布隆过滤器其实采用的是哈希思想来解决这个问题,通过一个庞大的二进制数组,走哈希思想去判断当前这个要查询的这个数据是否存在,如果布隆过滤器判断存在,则放行,这个请求会去访问redis,哪怕此时redis中的数据过期了,但是数据库中一定存在这个数据,在数据库中查询出来这个数据后,再将其放入到redis中;假设布隆过滤器判断这个数据不存在,则直接返回。 + +这种方式优点在于节约内存空间,但存在误判,误判原因在于:布隆过滤器走的是哈希思想,只要哈希思想,就可能存在哈希冲突 + +#### 缓存雪崩问题 + +缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。 + +解决方案: + +1. 给不同的Key的TTL添加随机值 + +2. 利用Redis集群提高服务的可用性 + +3. 给缓存业务添加降级限流策略 + +4. 给业务添加多级缓存 + +#### 缓存击穿问题 + +缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。 + +常见的解决方案有两种: + +1. 互斥锁 + +2. 逻辑过期 + +| 解决方案 | 优点 | 缺点 | +| -------- | -------------------------------------- | ---------------------------------------- | +| 互斥锁 | 没有额外内存消耗;保证一致性;实现简单 | 线程需要等待,性能受影响;可能死锁 | +| 逻辑过期 | 线程无需等待、性能好 | 不能保证一致性;有额外内存消耗;实现复杂 | + +**互斥锁思路**: + +进行查询之后,如果没有从缓存中查询到数据,则进行互斥锁的获取,获取互斥锁之后,判断是否获取到了锁,如果没获取到,则休眠一段时间,过一会儿再去尝试,直到获取到锁为止,才能进行查询; +如果获取到了锁的线程,则进行查询,将查询到的数据写入Redis,再释放锁,返回数据,利用互斥锁就能保证只有一个线程去执行数据库的逻辑,防止缓存击穿。 + +```java +public Shop solveCacheMutex(Long id){ + // 查询redis中有无数据 + String key = "cache:shop:" + id; + String shopCache = stringRedisTemplate.opsForValue().get(key); + if(StrUtil.isNotBlank(shopCache)){ + // 命中缓存 + return JSONUtil.toBean(shopCache, Shop.class); + } + // 判断缓存穿透问题 - shopCaache如果为“” 命中空缓存 如果为null 需要查询数据库 + if(shopCache != null){ + // 命中空缓存 + return null; + } + // 2.1未命中缓存 尝试获取互斥锁 + String lockKey = "lock:shop:" + id; + Shop shop = null; + try { + boolean lock = tryLock(lockKey); + if(!lock){ + // 获取锁失败 + Thread.sleep(50); + return solveCacheMutex(id); + } + // 获取锁成功 + // 再次检查Redis是否有缓存 + shopCache = stringRedisTemplate.opsForValue().get(key); + if(StrUtil.isNotBlank(shopCache)){ + return JSONUtil.toBean(shopCache, Shop.class); + } + // 查询数据库 + shop = getById(id); + // 店铺不存在 + if(shop == null){ + // 将空值写入Redis + stringRedisTemplate.opsForValue().set(key, "", RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES); + return null; + } + // 存储Redis + stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } finally { + // 释放互斥锁 + unLock(lockKey); + } + return shop; +} +// 获取锁 +private boolean tryLock(String key){ + Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS); + return BooleanUtil.isTrue(flag); +} +// 释放锁 +private void unLock(String key){ + stringRedisTemplate.delete(key); +} +``` + + + +**逻辑过期思路**: + +我们把过期时间设置在redis的value中。假设线程1去查询缓存,然后从value中判断当前数据已经过期了,此时线程1去获得互斥锁,那么其他线程会进行阻塞,**获得了锁的进程他会开启一个新线程去进行重建缓存数据的逻辑,直到新开的线程完成这个逻辑之后,才会释放锁**,而线程1直接进行返回,假设现在线程3过来访问,由于线程2拿着锁,所以线程3无法获得锁,线程3也直接返回数据(但只能返回旧数据,牺牲了数据一致性,换取性能上的提高),只有等待线程2重建缓存数据之后,其他线程才能返回正确的数据。 +这种方案巧妙在于,异步构建缓存数据,缺点是在重建完缓存数据之前,返回的都是脏数据。 + + + +当用户开始查询redis时,判断是否命中 + +1. 如果没有命中则直接返回空数据,不查询数据库 +2. 如果命中,则将value取出,判断value中的过期时间是否满足 + 1. 如果没有过期,则直接返回redis中的数据 + 2. 如果过期,则在开启独立线程后,直接返回之前的数据,独立线程去重构数据,重构完成后再释放互斥锁 + +```java +private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10); +public Shop solveCacheLogicalExpire(Long id){ + // 查询redis中有无数据 + String key = "cache:shop:" + id; + String shopCache = stringRedisTemplate.opsForValue().get(key); + if(StrUtil.isBlank(shopCache)){ + // 未命中返回null + return null; + } + // 命中缓存 检查是否过期 + // 未过期 直接返回 注意这里类型转换 + RedisData redisData = JSONUtil.toBean(shopCache, RedisData.class); + JSONObject jsonObject = (JSONObject) redisData.getData(); // 此处是将Bean对象转ObjectJson + Shop shop = JSONUtil.toBean(jsonObject, Shop.class); + LocalDateTime expireTime = redisData.getExpireTime(); + if(expireTime.isAfter(LocalDateTime.now())){ + return shop; + } + // 过期 + // 获取锁 + String lockKey = "lock:shop:" + id; + boolean lock = tryLock(lockKey); + if(lock){ + // 成功 + // 再次检查Redis缓存是否逻辑过期 + if(expireTime.isAfter(LocalDateTime.now())){ + // 没过期 + return shop; + } + // 开启新线程 + CACHE_REBUILD_EXECUTOR.submit(()->{ + try { + // 重建缓存 + this.saveShop2Redis(id, 20L); + } catch (Exception e) { + throw new RuntimeException(e); + } finally { + unLock(lockKey); + } + }); + + } + // 返回数据 + return shop; +} + +public void saveShop2Redis(Long id, Long expireSeconds){ + RedisData redisData = new RedisData(); + Shop shop = getById(id); + redisData.setData(shop); + redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds)); + stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData)); +} +``` + + + +### 分布式锁 + +基于redis的分布式锁的实现思路: + +* 利用set nx ex 获取锁,并设置TTL,保存线程标识; +* 释放锁时先判断线程标识是否和自己保存一致,一致则删除锁 + +特性: + +* 利用set nx 满足互斥性 +* 利用set ex 保证故障时锁依然能释放,避免死锁,提高安全性 +* 利用redis集群保证高可用和高并发性 + +基于SETNX实现的分布式锁存在以下问题: + +* 不可重入 +* 不可重试 +* 超时释放 +* 主从一致性 + +```java +public boolean tryLock(Long timeSec) { + // 获取线程标识 + String threadId = ID_PREFIX + Thread.currentThread().getId(); + // 获取锁 + Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, threadId , timeSec, TimeUnit.SECONDS); + + return Boolean.TRUE.equals(success); +} +// 解决误删问题-先判断是不是自己的 +public void unlock() { + // 获取线程标识 + String threadId = ID_PREFIX + Thread.currentThread().getId(); + // 获取锁中的标识 + String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name); + // 判断标识是否一致 + if (id.equals(threadId)) { + // 释放锁 + stringRedisTemplate.delete(KEY_PREFIX + name); + } +} +// 解决上述原子性问题-Lua脚本实现拿锁、判断、删锁是原子操作 +private static final DefaultRedisScript UNLOCK_SCRIPT; +static { + UNLOCK_SCRIPT = new DefaultRedisScript<>(); + UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));//读文件 方便后期维护 + UNLOCK_SCRIPT.setResultType(Long.class); +} +public void unlock() { + // 调用lua脚本 + stringRedisTemplate.execute(UNLOCK_SCRIPT, + Collections.singletonList(KEY_PREFIX + name), + ID_PREFIX + Thread.currentThread().getId() + ); +} +``` + +```lua +-- 这里的 KEYS[1] 就是锁的key,这里的ARGV[1] 就是当前线程标示 +-- 获取锁中的标示,判断是否与当前线程标示一致 +if (redis.call('GET', KEYS[1]) == ARGV[1]) then + -- 一致,则删除锁 + return redis.call('DEL', KEYS[1]) +end +-- 不一致,则直接返回 +return 0 +``` + + + +#### Redisson + +**Redisson分布式锁原理** + +可重入:利用hash结构记录线程id和重入次数 + +可重试:利用信号量和PubSub功能实现等待、唤醒,获取锁失败的重试机制 + +超时续约:利用watchDog,每个一段时间(releaseTime),重置超时时间。 + +![](Redis\redisson分布式锁.png) + +1. 依赖 + + ```xml + + org.redisson + redisson + 3.13.6 + + ``` + +2. 配置类 + + ```java + @Configuration + public class RedissonConfig { + @Bean + public RedissonClient redissonClient() { + Config config = new Config(); + config.useSingleServer() + .setAddress("redis://localhost:6379"); + return Redisson.create(config); + } + } + ``` + +3. 使用分布式锁 + + ```java + @Resource + private RedissonClient redissonClient; + + @Test + void testRedisson() throws InterruptedException { + //获取可重入锁 + RLock lock = redissonClient.getLock("anyLock"); + //尝试获取锁,三个参数分别是:获取锁的最大等待时间(期间会重试),锁的自动释放时间,时间单位 + boolean success = lock.tryLock(1,10, TimeUnit.SECONDS); + //判断获取锁成功 + if (success) { + try { + System.out.println("执行业务"); + } finally { + //释放锁 + lock.unlock(); + } + } + } + ``` + + + +**Redisson可重入锁原理** + +在分布式锁中,采用hash结构来存储锁,其中外层key表示这把锁是否存在,内层key则记录当前这把锁被哪个线程持有。 + +``` +lock -> {thread: state} #如果持有这把锁的人(同一线程下)再次持有这把锁,那么state会+1 +``` + +获取锁的逻辑: + +```lua +local key = KEYS[1]; -- 锁的key +local threadId = ARGV[1]; -- 线程唯一标识 +local releaseTime = ARGV[2]; -- 锁的自动释放时间 +-- 锁不存在 +if (redis.call('exists', key) == 0) then + -- 获取锁并添加线程标识,state设为1 + redis.call('hset', key, threadId, '1'); + -- 设置锁有效期 + redis.call('expire', key, releaseTime); + return 1; -- 返回结果 +end; +-- 锁存在,判断threadId是否为自己 +if (redis.call('hexists', key, threadId) == 1) then + -- 锁存在,重入次数 +1,这里用的是hash结构的incrby增长 + redis.call('hincrby', key, thread, 1); + -- 设置锁的有效期 + redis.call('expire', key, releaseTime); + return 1; -- 返回结果 +end; +return 0; -- 代码走到这里,说明获取锁的不是自己,获取锁失败 +``` + +释放锁的逻辑: + +```lua +local key = KEYS[1]; +local threadId = ARGV[1]; +local releaseTime = ARGV[2]; +-- 如果锁不是自己的 +if (redis.call('HEXISTS', key, threadId) == 0) then + return nil; -- 直接返回 +end; +-- 锁是自己的,锁计数-1,还是用hincrby,不过自增长的值为-1 +local count = redis.call('hincrby', key, threadId, -1); +-- 判断重入次数为多少 +if (count > 0) then + -- 大于0,重置有效期 + redis.call('expire', key, releaseTime); + return nil; +else + -- 否则直接释放锁 + redis.call('del', key); + return nil; +end; +``` + +**MutiLock锁** + +Redisson提出来了MutiLock锁,使用这把锁的话,那我们就不用主从了,每个节点的地位都是一样的,都可以当做是主机,那我们就需要将加锁的逻辑写入到每一个主从节点上,只有所有的服务器都写入成功,此时才是加锁成功,假设现在某个节点挂了,那么他去获取锁的时候,只要有一个节点拿不到,都不能算是加锁成功,就保证了加锁的可靠性. + + + +### 点赞及点赞排行榜 + +同一个用户只能对同一篇笔记点赞一次,再次点击则取消点赞 + +利用Redis中的set集合来判断是否点赞过,未点赞则点赞数+1,已点赞则点赞数-1 + +```java +//1. 获取当前用户信息 +User loginUser = userService.getLoginUser(request); +Long userId = loginUser.getId(); +//2. 如果当前用户未点赞,则点赞数 +1,同时将用户加入set集合 +String key = APP_LIKED_KEY + id; +Boolean isLiked = stringRedisTemplate.opsForSet().isMember(key, userId.toString()); +if (BooleanUtil.isFalse(isLiked)) { + //点赞数 +1 + boolean success = update().setSql("liked_number = liked_number + 1").eq("id", id).update(); + //将用户加入set集合 + if (success) { + stringRedisTemplate.opsForSet().add(key, userId.toString()); + } + //3. 如果当前用户已点赞,则取消点赞,将用户从set集合中移除 +} else { + //点赞数 -1 + boolean success = update().setSql("liked_number = liked_number - 1").eq("id", id).update(); + if (success){ + //从set集合移除 + stringRedisTemplate.opsForSet().remove(key, userId.toString()); + } +} +``` + +之前的点赞是放到Set集合中,但是Set集合又不能排序,所以这个时候,我们就可以改用SortedSet(Zset) + +由于ZSet没有isMember方法,所以这里只能通过查询score来判断集合中是否有该元素,如果有该元素,则返回值是对应的score,如果没有该元素,则返回值为null + diff --git "a/docs/Redis/Redis/3\345\261\202\350\267\263\350\241\250-\350\267\250\345\272\246.drawio.webp" "b/docs/Redis/Redis/3\345\261\202\350\267\263\350\241\250-\350\267\250\345\272\246.drawio.webp" new file mode 100644 index 0000000..5f95af2 Binary files /dev/null and "b/docs/Redis/Redis/3\345\261\202\350\267\263\350\241\250-\350\267\250\345\272\246.drawio.webp" differ diff --git a/docs/Redis/Redis/SDS.webp b/docs/Redis/Redis/SDS.webp new file mode 100644 index 0000000..1648e7f Binary files /dev/null and b/docs/Redis/Redis/SDS.webp differ diff --git a/docs/Redis/Redis/aofewwrite.png b/docs/Redis/Redis/aofewwrite.png new file mode 100644 index 0000000..2484f3d Binary files /dev/null and b/docs/Redis/Redis/aofewwrite.png differ diff --git a/docs/Redis/Redis/lazyfree.png b/docs/Redis/Redis/lazyfree.png new file mode 100644 index 0000000..2ac8e2a Binary files /dev/null and b/docs/Redis/Redis/lazyfree.png differ diff --git a/docs/Redis/Redis/listpack.webp b/docs/Redis/Redis/listpack.webp new file mode 100644 index 0000000..486575d Binary files /dev/null and b/docs/Redis/Redis/listpack.webp differ diff --git a/docs/Redis/Redis/quicklist.webp b/docs/Redis/Redis/quicklist.webp new file mode 100644 index 0000000..c1f721c Binary files /dev/null and b/docs/Redis/Redis/quicklist.webp differ diff --git "a/docs/Redis/Redis/redisson\345\210\206\345\270\203\345\274\217\351\224\201.png" "b/docs/Redis/Redis/redisson\345\210\206\345\270\203\345\274\217\351\224\201.png" new file mode 100644 index 0000000..29921d8 Binary files /dev/null and "b/docs/Redis/Redis/redisson\345\210\206\345\270\203\345\274\217\351\224\201.png" differ diff --git a/docs/Redis/Redis/rehash.webp b/docs/Redis/Redis/rehash.webp new file mode 100644 index 0000000..782a1bc Binary files /dev/null and b/docs/Redis/Redis/rehash.webp differ diff --git a/docs/Redis/Redis/scan.png b/docs/Redis/Redis/scan.png new file mode 100644 index 0000000..a67266d Binary files /dev/null and b/docs/Redis/Redis/scan.png differ diff --git a/docs/Redis/Redis/slot.jpg b/docs/Redis/Redis/slot.jpg new file mode 100644 index 0000000..c9d81dc Binary files /dev/null and b/docs/Redis/Redis/slot.jpg differ diff --git a/docs/Redis/Redis/slot.png b/docs/Redis/Redis/slot.png new file mode 100644 index 0000000..281a146 Binary files /dev/null and b/docs/Redis/Redis/slot.png differ diff --git a/docs/Redis/Redis/stream.png b/docs/Redis/Redis/stream.png new file mode 100644 index 0000000..316e449 Binary files /dev/null and b/docs/Redis/Redis/stream.png differ diff --git "a/docs/Redis/Redis/\344\272\224\347\247\215\346\225\260\346\215\256\347\261\273\345\236\213.webp" "b/docs/Redis/Redis/\344\272\224\347\247\215\346\225\260\346\215\256\347\261\273\345\236\213.webp" new file mode 100644 index 0000000..0b8bf7d Binary files /dev/null and "b/docs/Redis/Redis/\344\272\224\347\247\215\346\225\260\346\215\256\347\261\273\345\236\213.webp" differ diff --git "a/docs/Redis/Redis/\345\215\207\347\272\247\346\223\215\344\275\234.webp" "b/docs/Redis/Redis/\345\215\207\347\272\247\346\223\215\344\275\234.webp" new file mode 100644 index 0000000..e18619c Binary files /dev/null and "b/docs/Redis/Redis/\345\215\207\347\272\247\346\223\215\344\275\234.webp" differ diff --git "a/docs/Redis/Redis/\345\216\213\347\274\251\345\210\227\350\241\250.webp" "b/docs/Redis/Redis/\345\216\213\347\274\251\345\210\227\350\241\250.webp" new file mode 100644 index 0000000..e5b524a Binary files /dev/null and "b/docs/Redis/Redis/\345\216\213\347\274\251\345\210\227\350\241\250.webp" differ diff --git "a/docs/Redis/Redis/\345\220\216\345\217\260\347\272\277\347\250\213.webp" "b/docs/Redis/Redis/\345\220\216\345\217\260\347\272\277\347\250\213.webp" new file mode 100644 index 0000000..0d52bbe Binary files /dev/null and "b/docs/Redis/Redis/\345\220\216\345\217\260\347\272\277\347\250\213.webp" differ diff --git "a/docs/Redis/Redis/\345\223\210\345\270\214\350\241\250.webp" "b/docs/Redis/Redis/\345\223\210\345\270\214\350\241\250.webp" new file mode 100644 index 0000000..0b397d1 Binary files /dev/null and "b/docs/Redis/Redis/\345\223\210\345\270\214\350\241\250.webp" differ diff --git "a/docs/Redis/Redis/\346\225\260\346\215\256\347\273\223\346\236\204.webp" "b/docs/Redis/Redis/\346\225\260\346\215\256\347\273\223\346\236\204.webp" new file mode 100644 index 0000000..99cf70c Binary files /dev/null and "b/docs/Redis/Redis/\346\225\260\346\215\256\347\273\223\346\236\204.webp" differ diff --git "a/docs/Redis/Redis/\350\267\263\350\241\250.webp" "b/docs/Redis/Redis/\350\267\263\350\241\250.webp" new file mode 100644 index 0000000..ace15d0 Binary files /dev/null and "b/docs/Redis/Redis/\350\267\263\350\241\250.webp" differ diff --git "a/docs/Redis/Redis/\351\200\273\350\276\221\350\277\207\346\234\237.png" "b/docs/Redis/Redis/\351\200\273\350\276\221\350\277\207\346\234\237.png" new file mode 100644 index 0000000..c659b8d Binary files /dev/null and "b/docs/Redis/Redis/\351\200\273\350\276\221\350\277\207\346\234\237.png" differ diff --git "a/docs/Redis/Redis/\351\223\276\350\241\250.webp" "b/docs/Redis/Redis/\351\223\276\350\241\250.webp" new file mode 100644 index 0000000..03affca Binary files /dev/null and "b/docs/Redis/Redis/\351\223\276\350\241\250.webp" differ diff --git "a/docs/Redis/Redis/\351\230\277\351\207\214\344\272\221Redis\345\274\200\345\217\221\350\247\204\350\214\203.jpg" "b/docs/Redis/Redis/\351\230\277\351\207\214\344\272\221Redis\345\274\200\345\217\221\350\247\204\350\214\203.jpg" new file mode 100644 index 0000000..b38001a Binary files /dev/null and "b/docs/Redis/Redis/\351\230\277\351\207\214\344\272\221Redis\345\274\200\345\217\221\350\247\204\350\214\203.jpg" differ diff --git a/docs/about.md b/docs/about.md deleted file mode 100644 index 65a46da..0000000 --- a/docs/about.md +++ /dev/null @@ -1,44 +0,0 @@ -# Amore quamquam ambiguis Mopsum esse - -## Vivit retinentibus crines vallibus ingenti Cadmeida baculi - -Lorem markdownum ecce ferrum. Catenis e super lauroque Aeacidae gladiis animo -Semeleia, rapuere tamen. Vix cruorem maligne [Cerbere -illis](http://www.fuitet.com/), servat tecta dant angues: ad cognita gaudia iam -stabula, Phoebe Hymetti. In ad dixi paludem *umeros* est: est, alii peraget me -quaque! - -Reparata torum ne omnes est. Nullaque sequi, non passim **leonis opibus** -hostibus officiis inprobe. - -## Flumina numina desere rati iactat patruo - -Scelus parum forma populos litora, flamine ministri dolentem maximus, saxoque et -insigne siccare inpensaque est hic litora. Innixus saepe fas auctor hostibus -Pergama illos. Quas sunt inpune, tu victor manat ulla *indicat regnum*. **Nate -victus** infecere fecundo in sibi et concepta iuvenes furores? - -- Ars et visi pronaque pariter -- Vectus in tota acies vidi signa referebat -- Mensis quamvis uno erunt obscura frontem murmura -- Diurnis concutiensque scopulis -- Fruge mactare quondam quidque limine caelique Io -- Preces causas et illi amasse meque - -## Nec repperit inde non - -Utraque dabatur tamen **suffuderat videntur voluistis** ingens cornaque; bono -Hector, infractaque [rata](http://annos-domo.com/) et uteri. Conceperat [Bacchi -Battum](http://cum.org/), et, ut adfecit avidae vivere, tabo pelle Iove vestem -tibi **visus** et eloquio flagellis. Mota et amantibus, tibi cava cum alta -*ipsa* vobis oves bracchia Nesso Cephisos Ut tyranni. Illa Syrtis neque iuvenes -habet ensis terrae subito me deae quascumque quiescere mater. - -Congelat Grai; aquis saltem **rictus quoque**, amnis facias instabilis -**aliquem** in adhuc: quae fratrum! In venis cuncta torto illic tyranni, ni -signa **videtur**! [Limine confiteor](http://ostendit-una.net/adunca-nunc) -criminis quoque non inerti *declinat* eundi exsiliantque *venisses* Phoebi -colebat insilit tamen amentia Achilles [verbaque](http://www.iuvenem.org/). - -Care actae Thespiades nomen! Non viro [regia](http://aede-in.net/modo.aspx), ut -terga quoque nec solacia artus, memorantur plano, omne. diff --git a/docs/index.md b/docs/index.md deleted file mode 100644 index 000ea34..0000000 --- a/docs/index.md +++ /dev/null @@ -1,17 +0,0 @@ -# Welcome to MkDocs - -For full documentation visit [mkdocs.org](https://www.mkdocs.org). - -## Commands - -* `mkdocs new [dir-name]` - Create a new project. -* `mkdocs serve` - Start the live-reloading docs server. -* `mkdocs build` - Build the documentation site. -* `mkdocs -h` - Print help message and exit. - -## Project layout - - mkdocs.yml # The configuration file. - docs/ - index.md # The documentation homepage. - ... # Other markdown pages, images and other files. diff --git "a/docs/\350\256\276\350\256\241\346\250\241\345\274\217/\350\256\276\350\256\241\346\250\241\345\274\217.md" "b/docs/\350\256\276\350\256\241\346\250\241\345\274\217/\350\256\276\350\256\241\346\250\241\345\274\217.md" index 5a3f3dc..8db92b9 100644 --- "a/docs/\350\256\276\350\256\241\346\250\241\345\274\217/\350\256\276\350\256\241\346\250\241\345\274\217.md" +++ "b/docs/\350\256\276\350\256\241\346\250\241\345\274\217/\350\256\276\350\256\241\346\250\241\345\274\217.md" @@ -1,3 +1,5 @@ +设计模式要干的事情就是解耦。创建型模式是将创建和使用代码解耦,结构型模式是将不同功能代码解耦,行为型模式是将不同的行为代码解耦。 + **创建型:** 常用的有:单例模式、工厂模式(工厂方法和抽象工厂)、建造者模式。 @@ -1250,22 +1252,667 @@ public class Directory extends FileSystemComponent { ### **观察者模式** +在对象之间定义一个一对多的依赖,当一个对象状态改变的时候,所有依赖的对象都会自动收到通知。一般情况下,被依赖的对象叫作被观察者(Observable),依赖的对象叫作观察者(Observer)。 + + + +基本结构: + +* **被观察者(Subject)**:也称为目标或者主题,维护一组观察者对象,提供注册和删除观察者的接口,以及通知观察者的方法。 + +* **观察者(Observer)**:定义一个更新接口,用于接收被观察者状态的变化。 + +* **具体被观察者(ConcreteSubject)**:实现被观察者接口,维护其状态的变化,并在状态变化时通知观察者。 + +* **具体观察者(ConcreteObserver)**:实现观察者接口,定义具体的更新方法,以便在被观察者状态变化时更新自身状态或执行相应操作。 + + + +```java +// 主题接口 (主题) +interface Subject { + // 注册观察者 + void registerObserver(Observer observer); + // 移除观察者 + void removeObserver(Observer observer); + // 通知观察者 + void notifyObservers(); +} +// 观察者接口 (观察者) +interface Observer { + // 更新方法 + void update(String message); +} + +// 具体主题实现 +class ConcreteSubject implements Subject { + // 观察者列表 + private List observers = new ArrayList<>(); + // 状态 + private String state; + + // 注册观察者 + @Override + public void registerObserver(Observer observer) { + observers.add(observer); + } + // 移除观察者 + @Override + public void removeObserver(Observer observer) { + observers.remove(observer); + } + // 通知观察者 + @Override + public void notifyObservers() { + for (Observer observer : observers) { + // 观察者根据传递的信息进行处理 + observer.update(state); + } + } + // 更新状态 + public void setState(String state) { + this.state = state; + notifyObservers(); + } +} + +// 具体观察者实现 +class ConcreteObserver implements Observer { + // 更新方法 + @Override + public void update(String message) { + } +} + +public class ObserverPatternDemo { + public static void main(String[] args) { + // 创建被观察者 + ConcreteSubject subject = new ConcreteSubject(); + + // 创建观察者 + Observer observer1 = new ConcreteObserver("观察者1"); + Observer observer2 = new ConcreteObserver("观察者2"); + Observer observer3 = new ConcreteObserver("观察者3"); + + // 注册观察者 + subject.registerObserver(observer1); + subject.registerObserver(observer2); + subject.registerObserver(observer3); + + // 发送通知 + subject.setMessage("第一个通知"); + + // 移除一个观察者 + subject.removeObserver(observer2); + + // 发送另一个通知 + subject.setMessage("第二个通知"); + } +} +``` + +观察者模式有几种不同的实现方式,包括:同步阻塞、异步非阻塞、进程内、进程间的实现方式。 同步阻塞是最经典的实现方式,主要是为了代码解耦;异步非阻塞除了能实现代码解耦之 外,还能提高代码的执行效率;进程间的观察者模式解耦更加彻底,一般是基于消息队列来实现,用来实现不同进程间的被观察者和观察者之间的交互。 + + + ### **模板模式** +模板模式主要是用来解决复用和扩展两个问题。 + +模板方法模式在一个方法中定义一个算法骨架,并将某些步骤推迟到子类中实现。模板方法模式可以让子类在不改变算法整体结构的情况下,重新定义算法中的某些步骤。 + + + +基本结构: + +- 模板类`AbstractClass`:由一个模板方法和若干个基本方法构成,模板方法定义了逻辑的骨架,按照顺序调用包含的基本方法,基本方法通常是一些抽象方法,这些方法由子类去实现。基本方法还包含一些具体方法,它们是算法的一部分但已经有默认实现,在具体子类中可以继承或者重写。 +- 具体类`ConcreteClass`:继承自模板类,实现了在模板类中定义的抽象方法,以完成算法中特定步骤的具体实现。 + + + +```java +// 模板类 +abstract class AbstractClass { + // 模板方法,定义了算法的骨架, final避免被重写 + public final void templateMethod() { + step1(); + step2(); + step3(); + } + + // 抽象方法,强迫子类重写 + protected abstract void step1(); + protected abstract void step2(); + protected abstract void step3(); +} + +// 具体类 +class ConcreteClass extends AbstractClass { + @Override + protected void step1() { + System.out.println("Step 1 "); + } + + @Override + protected void step2() { + System.out.println("Step 2 "); + } + + @Override + protected void step3() { + System.out.println("Step 3"); + } +} + +public class Main { + public static void main(String[] args) { + AbstractClass concreteTemplate = new ConcreteClass(); + // 触发整个算法的执行 + concreteTemplate.templateMethod(); + } +} +``` + + + ### **策略模式** +定义一族算法类,将每个算法分别封装起来,让它们可以互相替换。策略模式可以使算法的变化独立于使用它们的客户端(这里的客户端代指使用算法的代码)。 + +工厂模式是解耦对象的创建和使用,观察者模式是解耦观察者和被观察者。策略模式跟两者类似,也能起到解耦的作用,不过,它解耦的是策略的定义、创建、使用这三部分。 + + + +基本结构: + +- 策略类`Strategy`: 定义所有支持的算法的公共接口。 +- 具体策略类`ConcreteStrategy`: 实现了策略接口,提供具体的算法实现。 +- 上下文类`Context`: 包含一个策略实例,并在需要时调用策略对象的方法。 + + + +策略的定义: + +```java +public interface Strategy { + void algorithmInterface(); +} + +class ConcreteStrategyA implements Strategy { + @Override + public void algorithmInterface() { + // 具体的策略1执行逻辑 + } +} + +class ConcreteStrategyB implements Strategy { + @Override + public void algorithmInterface() { + // 具体的策略2执行逻辑 + } +} + +``` + + + +策略的创建: + +```java +public class StrategyFactory { + private static final Map strategies = new HashMap<>(); + static { + strategies.put("A", new ConcreteStrategyA()); + strategies.put("B", new ConcreteStrategyB()); + } + public static Strategy getStrategy(String type) { + if (type == null || type.isEmpty()) { + throw new IllegalArgumentException("type should not be empty."); + } + return strategies.get(type); + } +} +``` + +一般来讲,如果策略类是无状态的,不包含成员变量,只是纯粹的算法实现,这样的策略对象是可以被共享使用的,不需要在每次调用 getStrategy() 的时候,都创建一个新的策略对象。针对这种情况,我们可以使用上面这种工厂类的实现方式,事先创建好每个策略对象, 缓存到工厂类中,用的时候直接返回。 + +相反,如果策略类是有状态的,根据业务场景的需要,我们希望每次从工厂方法中,获得的都是新创建的策略对象,而不是缓存好可共享的策略对象,那我们就需要按照如下方式来实现策略工厂类。 + +```java +public class StrategyFactory { + public static Strategy getStrategy(String type) { + if (type == null || type.isEmpty()) { + throw new IllegalArgumentException("type should not be empty."); + } + if (type.equals("A")) { + return new ConcreteStrategyA(); + } else if (type.equals("B")) { + return new ConcreteStrategyB(); + } + return null; + } +} +``` + + + +策略的使用: + +我们事先并不知道会使用哪个策略,而是在程序运行期间, 根据配置、用户输入、计算结果等这些不确定因素,动态决定使用哪种策略。 + + + ### **责任链模式** +将请求的发送和接收解耦,让多个接收对象都有机会处理这个请求。将这些接收对象串成一条链,并沿着这条链传递这个请求,直到链上的某个接收对象能够处理它 为止。 + +在职责链模式中,多个处理器(也就是“接收对象”)依次处理同一个请求。一个请求先经过 A 处理器处理,然后再把请求传递给 B 处理器,B 处理器处理完后再传递给 C 处理器,以此类推,形成一个链条。链条上的每个处理器各自承担各自的处理职责,所以叫作职责链模式。 + +```java +public interface IHandler { + boolean handle(); +} +public class HandlerA implements IHandler { + @Override + public boolean handle() { + boolean handled = false; + //... + return handled; + } +} +public class HandlerB implements IHandler { + @Override + public boolean handle() { + boolean handled = false; + //... + return handled; + } +} +public class HandlerChain { + private List handlers = new ArrayList<>(); + public void addHandler(IHandler handler) { + this.handlers.add(handler); + } + public void handle() { + for (IHandler handler : handlers) { + boolean handled = handler.handle(); + if (handled) { + break; + } + } + } +} +// 使用举例 +public class Application { + public static void main(String[] args) { + HandlerChain chain = new HandlerChain(); + chain.addHandler(new HandlerA()); + chain.addHandler(new HandlerB()); + chain.handle(); + } +} +``` + +在 GoF 给出的定义中,如果处理器链上的某个处理器能够处理这个请求,那就不会继续往下传递请求。实际上,职责链模式还有一种变体,那就是请求会被所有的处理器都处理一 遍,不存在中途终止的情况。 + ### **状态模式** +适用于一个对象在在不同的状态下有不同的行为时,比如说电灯的开、关状态,状态不同时,对应的行为也不同,在没有状态模式的情况下,为了添加新的状态或修改现有的状态,往往需要修改已有的代码,这违背了开闭原则,而且如果对象的状态切换逻辑和各个状态的行为都在同一个类中实现,就可能导致该类的职责过重,不符合单一职责原则。 + +而状态模式将每个状态的行为封装在一个具体状态类中,使得每个状态类相对独立,并将对象在不同状态下的行为进行委托,从而使得对象的状态可以在运行时动态改变,每个状态的实现也不会影响其他状态。 + +基本结构: + +- `State`(状态): 定义一个接口,用于封装与Context的一个特定状态相关的行为。 +- `ConcreteState`(具体状态): 负责处理Context在状态改变时的行为, 每一个具体状态子类实现一个与`Context`的一个状态相关的行为。 +- `Context`(上下文): 维护一个具体状态子类的实例,这个实例定义当前的状态。 + +```java +// State interface 定义状态行为 +interface State { + void pressSwitch(LightContext context); +} + +// ConcreteState On 具体状态:电灯开 +class OnState implements State { + @Override + public void pressSwitch(LightContext context) { + System.out.println("Switching light off."); + context.setState(new OffState()); + } +} + +// ConcreteState Off 具体状态:电灯关 +class OffState implements State { + @Override + public void pressSwitch(LightContext context) { + System.out.println("Switching light on."); + context.setState(new OnState()); + } +} + +// Context 电灯上下文 +class LightContext { + private State currentState; + + public LightContext() { + // 初始状态为关 + currentState = new OffState(); + } + + public void setState(State state) { + currentState = state; + } + + public void pressSwitch() { + currentState.pressSwitch(this); + } +} + +// 测试代码 +public class StatePatternExample { + public static void main(String[] args) { + LightContext light = new LightContext(); + + // 按下开关,打开灯 + light.pressSwitch(); // Output: Switching light on. + + // 再次按下开关,关闭灯 + light.pressSwitch(); // Output: Switching light off. + + // 再次按下开关,打开灯 + light.pressSwitch(); // Output: Switching light on. + } +} +``` + + + ### **迭代器模式** +迭代器模式(Iterator Design Pattern),也叫作游标模式(Cursor Design Pattern)。 它用来遍历集合对象。这里说的“集合对象”也可以叫“容器”“聚合对象”,实际上就是包含一组对象的对象,比如数组、链表、树、图、跳表。迭代器模式将集合对象的遍历操作从集合类中拆分出来,放到迭代器类中,让两者的职责更加单一。 + +遍历集合一般有三种方式:for 循环、foreach 循环、迭代器遍历。后两种本质上属于一种,都可以看作迭代器遍历。相对于 for 循环遍历,利用迭代器来遍历有下面三个优势: + +1. 迭代器模式封装集合内部的复杂数据结构,开发者不需要了解如何遍历,直接使用容器提供的迭代器即可; +2. 迭代器模式将集合对象的遍历操作从集合类中拆分出来,放到迭代器类中,让两者的职责更加单一; +3. 迭代器模式让添加新的遍历算法更加容易,更符合开闭原则。除此之外,因为迭代器都实现自相同的接口,在开发中,基于接口而非实现编程,替换迭代器也变得更加容易。 + +基本结构: + +- 迭代器接口:定义访问和遍历元素的接口, 通常会包括`hasNext()`方法用于检查是否还有下一个元素,以及`next()`方法用于获取下一个元素。有的还会实现获取第一个元素以及获取当前元素的方法`currentItem()`。 +- 迭代器实现类:实现迭代器接口,实现遍历逻辑对聚合对象进行遍历。 +- 容器接口:定义了创建迭代器的接口,包括一个`createIterator`方法用于创建一个迭代器对象。 +- 容器实现类:实现在抽象聚合类中声明的`createIterator() `方法,返回一个与具体聚合对应的具体迭代器 + +```java +public interface Iterator { + boolean hasNext(); + void next(); + E currentItem(); +} +public class ArrayIterator implements Iterator { + private int cursor; + private ArrayList arrayList; + public ArrayIterator(ArrayList arrayList) { + this.cursor = 0; + this.arrayList = arrayList; + } + @Override + public boolean hasNext() { + return cursor != arrayList.size(); + } + @Override + public void next() { + cursor++; + } + @Override + public E currentItem() { + if (cursor >= arrayList.size()) { + throw new NoSuchElementException(); + } + return arrayList.get(cursor); + } +} +public interface List { + Iterator iterator(); + //...省略其他接口函数... +} +public class ArrayList implements List { + //... + public Iterator iterator() { + return new ArrayIterator(this); + } + //...省略其他代码 +} +public class Demo { + public static void main(String[] args) { + List names = new ArrayList<>(); + names.add("xzg"); + names.add("wang"); + names.add("zheng"); + Iterator iterator = names.iterator(); + while (iterator.hasNext()) { + System.out.println(iterator.currentItem()); + iterator.next(); + } + } +} +``` + + + +在 Java 中,如果在使用迭代器的同时删除容器中的元素,会导致迭代器报错,这是为什么呢?如何来解决这个问题呢? + +在通过迭代器来遍历集合元素的同时,增加或者删除集合中的元素,有可能会导致某个元素被重复遍历或遍历不到。有两种比较干脆利索的解决方案,来避免出现这种不可预期的运行结果。一种是遍历的时候不允许增删元素,另一种是增删元素之后让遍历报错。第一种解决方案比较难实现,因为很难确定迭代器使用结束的时间点。第二种解决方案更加合理。 + +Java 语言就是采用的第二种解决方案。增删元素之后让遍历操作直接抛出 `ConcurrentModificationException` 运行时异常。当创建一个迭代器时,迭代器会记录下集合的 `modCount` 值(表示集合修改的次数)。在遍历过程中,迭代器会不断检查集合的 `modCount` 是否发生了变化。如果在迭代器运行期间,集合的 `modCount` 发生了变化(例如直接通过集合对象删除元素),迭代器就会认为集合的结构发生了变化,于是抛出 `ConcurrentModificationException`。 + +像 Java 语言,迭代器类中除了前面提到的几个最基本的方法之外,还定义了一个 remove() 方法,能够在遍历集合的同时,安全地删除集合中的元素。Java 迭代器中提供的 remove() 方法作用有限。它只能删除游标指向的前一个元素,而且一个 next() 函数之后,只能跟着最多一个 remove() 操作,多次调用 remove() 操作会报错。 + + + ### **访问者模式** +应用它会导致代码的可读性、可维护性变差,所以,访问者模式在实际的软件开发中很少被用到,在没有特别必要的情况下,建议你不要使用访问者模式。 + +允许一个或者多个操作应用到一组对象上,解耦操作和对象本身。 + +不同场景下,我们需要对一组对象进行一系列不相关的业务操作 (抽取文本、压缩等),但为了避免不断添加功能导致类不断膨胀,职责越来越不单一,以及避免频繁地添加功能导致的频繁代码修改,我们使用访问者模式,将对象与操作解耦,将这些业务操作抽离出来,定义在独立细分的访问者类中。 + +```java +public abstract class ResourceFile { + protected String filePath; + public ResourceFile(String filePath) { + this.filePath = filePath; + } + abstract public void accept(Visitor vistor); +} +public class PdfFile extends ResourceFile { + public PdfFile(String filePath) { + super(filePath); + } + @Override + public void accept(Visitor visitor) { + visitor.visit(this); + } + //... +} +//...PPTFile、WordFile跟PdfFile类似,这里就省略了... + +public interface Visitor { + void visit(PdfFile pdfFile); + void visit(PPTFile pdfFile); + void visit(WordFile pdfFile); +} +public class Extractor implements Visitor { + @Override + public void visit(PPTFile pptFile) { + //... + System.out.println("Extract PPT."); + } + @Override + public void visit(PdfFile pdfFile) { + //... + System.out.println("Extract PDF."); + } + @Override + public void visit(WordFile wordFile) { + //... + System.out.println("Extract WORD."); + } +} +public class Compressor implements Visitor { + @Override + public void visit(PPTFile pptFile) { + //... + System.out.println("Compress PPT."); + } + @Override + public void visit(PdfFile pdfFile) { + //... + System.out.println("Compress PDF."); + } + @Override + public void visit(WordFile wordFile) { + //... + System.out.println("Compress WORD."); + } +} +public class ToolApplication { + public static void main(String[] args) { + Extractor extractor = new Extractor(); + List resourceFiles = listAllResourceFiles(args[0]); + for (ResourceFile resourceFile : resourceFiles) { + resourceFile.accept(extractor); + } + Compressor compressor = new Compressor(); + for(ResourceFile resourceFile : resourceFiles) { + resourceFile.accept(compressor); + } + } + private static List listAllResourceFiles(String resourceDirecto + List resourceFiles = new ArrayList<>(); + //...根据后缀(pdf/ppt/word)由工厂方法创建不同的类对象(PdfFile/PPTFile/WordFile) + resourceFiles.add(new PdfFile("a.pdf")); + resourceFiles.add(new WordFile("b.word")); + resourceFiles.add(new PPTFile("c.ppt")); + return resourceFiles; + } +} +``` + +实际上,我们还有其他的实现方法,比如,我们还可以利用工厂 模式来实现,定义一个 Extractor 接口。PdfExtractor、 PPTExtractor、WordExtractor 类实现 Extractor 接口,分别实现 Pdf、PPT、Word 格式文件的文本内容抽取。ExtractorFactory 工厂类根据 不同的文件类型,返回不同的 Extractor。 + + + +支持双分派的语言不需要访问者模式。Single Dispatch 之所以称为“Single”,是因为执行哪个对象的哪个方法,只跟“对象”的运行时类型有关。Double Dispatch 之所以称为“Double”,是因为执行哪个对象的哪个方法,跟“对象”和“方法参数”两者的运行时类型有关。当前主流的面向对象编程语言(比如,Java、C++、C#)都只支持 Single Dispatch,不支持 Double Dispatch。 + +Java 支持多态特性,代码可以在运行时获得对象的实际类型,然后根据实际类型决定调用哪个方法。尽管 Java 支持函数重载,但 Java 设计的函数重载的语法规则时,并不是在运行时根据传递进函数的参数的实际类型来决定调用哪个重载函数,而是在编译时根据传递进函数的参数的声明类型来决定调用哪个重载函数。也就是说,具体执行哪个对象的哪个方法,只跟对象的运行时类型有关,跟参数的运行时类型无关。所以,Java 语言只支持 Single Dispatch。 + ### **备忘录模式** +备忘录模式也叫快照模式,具体来说,就是在不违背封装原则的前提下,捕获一个对象的内部状态,并在该对象之外保存这个状态,以便之后恢复对象为先前的状态。这个模式的定义表达了两部分内容:一部分是,存储副本以便后期恢复;另一部分是,要在不违背封装原则 的前提下,进行对象的备份和恢复。 + +备忘录模式的应用场景也比较明确和有限,主要是用来防丢失、撤销、恢复等。它跟平时我们常说的“备份”很相似。两者的主要区别在于,备忘录模式更侧重于代码的设计和实现,备份更侧重架构设计或产品设计。 + ### **命令模式** +命令模式将请求(命令)封装为一个对象,这样可以使用不同的请求参数化其他对象(将不同请求依赖注入到其他对象),并且能够支持请求(命令)的排队执行、记录日志、撤销等 (附加控制)功能。 + +落实到编码实现,命令模式用的最核心的实现手段,是将函数封装成对象。我们知道,C 语言支持函数指针,我们可以把函数当作变量传递来传递去。但是,在大部分编程语言中,函数没法儿作为参数传递给其他函数,也没法儿赋值给变量。借助命令模式,我们可以将函数 封装成对象。具体来说就是,设计一个包含这个函数的类,实例化一个对象传来传去,这样就可以实现把函数像对象一样使用。 + +当我们把函数封装成对象之后,对象就可以存储下来,方便控制执行。所以,命令模式的主要作用和应用场景,是用来控制命令的执行,比如,异步、延迟、排队执行命令、撤销重做命令、存储命令、给命令记录日志等等,这才是命令模式能发挥独一无二作用的地方。 + +```java +// 命令接口 +public interface Command { + void execute(); +} + +// 具体命令类(打开灯) +class TurnOnLightCommand implements Command { + private Light light; + + public TurnOnLightCommand(Light light) { + this.light = light; + } + + @Override + public void execute() { + light.turnOn(); + } +} + +// 具体命令类(关灯) +class TurnOffLightCommand implements Command { + private Light light; + + public TurnOffLightCommand(Light light) { + this.light = light; + } + + @Override + public void execute() { + light.turnOff(); + } +} + +// 接收者类 +class Light { + public void turnOn() { + System.out.println("The light is on"); + } + + public void turnOff() { + System.out.println("The light is off"); + } +} + +// 调用者类 +class RemoteControl { + private Command command; + + public void setCommand(Command command) { + this.command = command; + } + + public void pressButton() { + command.execute(); + } +} + +// 客户端 +public class Client { + public static void main(String[] args) { + // 创建接收者 + Light light = new Light(); + + // 创建具体命令并指定接收者 + Command turnOn = new TurnOnLightCommand(light); + Command turnOff = new TurnOffLightCommand(light); + + // 创建调用者并设置命令 + RemoteControl remote = new RemoteControl(); + + // 执行命令 + remote.setCommand(turnOn); + remote.pressButton(); // 输出: The light is on + + remote.setCommand(turnOff); + remote.pressButton(); // 输出: The light is off + } +} +``` + + + ### **解释器模式** -### **中介模式** \ No newline at end of file +解释器模式为某个语言定义它的语法(或者叫文法)表示,并定义一个解释器用来处理这个语法。解释器模式只在一些特定的领域会被用到,比如编译器、规则引擎、正则表达式。 + +### **中介模式** + +中介模式定义了一个单独的(中介)对象,来封装一组对象之间的交互。将这组对象之间的交互委派给与中介对象交互,来避免对象之间的直接交互。 + +实际上,中介模式的设计思想跟中间层很像,通过引入中介这个中间层,将一组对象之间的交互关系(或者说依赖关系)从多对多(网状关系)转换为一对多(星状关系)。原来一个对象要跟 n 个对象交互,现在只需要跟一个中介对象交互,从而最小化对象之间的交互关系,降低了代码的复杂度,提高了代码的可读性和可维护性。 \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index cfc531f..4a06b2b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -11,8 +11,18 @@ repo_url: https://github.com/lbwanga/lbwanga.github.io # Contents nav: - - Home: index.md - - About: about.md + - Java: + - Java基础: Java/Java.md + - Java集合: Java/Java集合.md + - JUC: Java/JUC.md +# - JVM: JVM/JVM.md +# - Spring: Spring/Spring6.md +# - SpringBoot: SpringBoot/SpringBoot3.md + - MySQL: MySQL/MySQL.md +# - Redis: Redis/Redis.md +# - MQ: +# - RabbitMQ: MQ/RabbitMQ.md +# - RocketMQ: MQ/RocketMQ.md - 设计模式: 设计模式/设计模式.md # Theme