阿里P8整理的Java面试资料, 助你轻松进大厂^_^

MIC资料总结
一.111111111111111111111 Java

  1. final关键字
    面试题:用过final关键字吗?它有什么作用
    回答: final关键字表示不可变,它可以修饰在类、方法、成员变量中。

  2. 如果修饰在类上,则表示该类不可以被继承

  3. 修饰在方法上,表示该方法无法被重写

  4. 修饰在变量上,表示该变量无法被修改,而且JVM会隐性定义为一个常量。
    另外,final修饰的关键字,还可以避免因为指令重排序带来的可见性问题,因为final遵循两个重排序规则

  5. 构造函数内,对一个 final 变量的写入,与随后把这个被构造对象的引用赋值给一个变量,这两个操作之间不可重排序。

  6. 首次读一个包含 final 变量的对象,与随后首次读这个 final 变量,这两个操作之间不可以重排序。
    就这个问题来说,在面试时的考察点太多了,比如:

  7. 如何破坏final规则

  8. 带static和final修饰的属性,可以被修改吗?

  9. final是否可以解决可见性问题,以及它是如何解决的?

  10. HashMap基础
    底层原理
    HashMap的底层是数组+链表/红黑树. HashMap 通过 key 的 hashCode 经过扰动函数处理过后得到 hash 值,然后通过 (n - 1) & hash 判断当前元素存放的位置,如果当前位置存在元素的话,就判断两者的hash值是否相同,如果相同的话,直接覆盖,不相同就通过拉链法解决冲突。
    扩容
    当链表长度大于阈值(默认为 8时,)1.如果当前数组的长度小于 64,那么会选择先进行数组扩容2. 当数组长度超过64时, 将链表转化为红黑树,以减少搜索时间。
    HashMap 的长度为什么是 2 的幂次方
    计算位置是通过取余操作实现的, 也就是说 hash%len 等价于 hash&(len-1) 的前提是 len是 2 的 n 次方

  11. currnetHashmap

  12. HashMap解决哈希冲突
    1.要了解Hash冲突,那首先我们要先了解Hash算法和Hash表。

通常解决hash冲突的方法有4种。
(1)开放寻址法,也称为线性探测法,就是从发生冲突的那个位置开始,按照一定的次序从hash表中找到一个空闲的位置,然后把发生冲突的元素存入到这个空闲位置中。ThreadLocal就用到了线性探测法来解决hash冲突的。
向这样一种情况

在hash表索引1的位置存了一个key=name,当再次添加key=hobby时,hash计算得到的索引也是1,这个就是hash冲突。而开放定址法,就是按顺序向前找到一个空闲的位置来存储冲突的key。
(2)链式寻址法,这是一种非常常见的方法,简单理解就是把存在hash冲突的key,以单向链表的方式来存储,比如HashMap就是采用链式寻址法来实现的。
向这样一种情况

存在冲突的key直接以单向链表的方式进行存储。
hash表有一个指标来表示hash冲突严重程度:
hash表装载因子 = 填入表中的元素个数 / hash表长度;
“hash表装载因子” 这个值越大表明hash冲突越严重,hash表的性能就会下降的。
(3)再hash法,就是当通过某个hash函数计算的key存在冲突时,再用另外一个hash函数对这个key做hash,一直运算直到不再产生冲突。这种方式会增加计算时间,性能影响较大。
(4)建立公共溢出区, 就是把hash表分为基本表和溢出表两个部分,凡事存在冲突的元素,一律放入到溢出表中。
4.HashMap在JDK1.8版本中,通过链式寻址法+红黑树的方式来解决hash冲突问题,其中红黑树是为了优化Hash表链表过长导致时间复杂度增加的问题。当链表长度大于8并且hash表的容量大于64的时候,再向链表中添加元素就会触发转化。
以上就是我对这个问题的理解!

  1. TreeMap
    TreeMap基础
    TreeMap 是一个有序的key-value集合,它是通过红黑树实现的。
    TreeMap 继承于AbstractMap,所以它是一个Map,即一个key-value集合。
    TreeMap 实现了NavigableMap接口,意味着它支持一系列的导航方法。比如返回有序的key集合。
    TreeMap 实现了Cloneable接口,意味着它能被克隆。
    TreeMap 实现了java.io.Serializable接口,意味着它支持序列化。
    TreeMap基于红黑树(Red-Black tree)实现。该映射根据其键的自然顺序进行排序,或者根据创建映射时提供的
    Comparator 进行排序,具体取决于使用的构造方法。
    TreeMap的基本操作 containsKey、get、put 和 remove 的时间复杂度是 log(n) 。
    另外,TreeMap是非同步的。 它的iterator 方法返回的迭代器是fail-fastl的。
    TreeMap和HashMap的对比
    1、HashMap无序,TreeMap有序。
    2、HashMap覆盖了equals()方法和hashcode()方法,这使得HashMap中两个相等的映射返回相同的哈希值;
    TreeMap则是实现了SortedMap接口,使其有序。
    3、HashMap的工作效率更高,而TreeMap则是基于树的增删查改。更推荐使用HashMap。
    4、HashMap基于哈希桶实现,TreeMap是基于红黑树实现。
    5、两者都不是线性安全的。
  2. Hash槽动态扩容法 - rehash
    当装载因子过大时,会自动启动扩容,需要重新申请内存空间,重新计划哈希值位置,并且需要搬移数据。
    装载因子设置一个阈值,当大于阈值时启动扩容,这个阈值设置需要权衡好,太大容易造成哈希冲突严重,太小容易造成内存空间的浪费,申请的内存大小时原来的2倍。
    假如一个数据量很大的数据,当达到设置的阈值时,需要进行数据搬移,但是数据太大了,一次性搬移会很耗时也会造成阻塞,所以在需要插入新数据时就会把数据插入到hash表中,并且从从旧的hash表中迁移一部分数据到新的hash表中,一直重复,直到旧的hash数据全部迁移到新的hash中,这样就不会一次性迁移了,插入的数据的速度会更快。
    查询数据过程中,如果处于时新旧hash表迁移数据,需要先查询旧的hash表,没有查到再次查询新的hash表
  3. ThreadLocal
    1.ThreadLocal是一种线程隔离机制,它提供了多线程环境下对于共享变量访问的安全性。

2.在多线程访问共享变量的场景中,一般的解决办法是对共享变量加锁,但是加锁会带来性能的下降,所以ThreadLocal用了一种空间换时间的设计思想,也就是说在每个线程里面,都有一个容器来存储共享变量的副本,然后每个线程只对自己的变量副本来做更新操作,这样既解决了线程安全问题,又避免了多加锁的开销。

4.ThreadLocal的具体实现原理是,在Thread类里面有一个成员变量ThreadLocalMap,它专门来存储当前线程的共享变量副本,后续这个线程对于共享变量的操作,都是从这个ThreadLocalMap里面进行变更,不会影响全局共享变量的值。
以上就是我对这个问题的理解。
总结
ThreadLocal使用场景比较多,比如在数据库连接的隔离、对于客户端请求会话的隔离等等。
在ThreadLocal中,除了空间换时间的设计思想以外,还有一些比较好的设计思想,比如线性探索解决hash冲突,数据预清理机制、弱引用key设计尽可能避免内存泄漏等。
1.ThreadLocal是什么?
我们可以得知ThreadLocal的作用是: 线程内的局部变量, 不同的线程之间不会相互干扰, 这种变量在线程的生命周期内起作用, 减少同一个线程内多个函数或组件之间一些公共变量传递的复杂度.
总结:
1.线程并发: 在多线程并发的场景下
2.传递数据: 我们可以通过ThreadLocal在现线程, 不同组件中传递公共变量
3.线程隔离: 每个线程的变量都是独立的, 不会互相影响(ThreadLocal]核心)
2.ThreadLocal有哪些方法:
无参(), set(), get(), remove()
3.ThreadLocal的使用场景?
ThreadLocal可以在同一个线程内多个函数或组件之间传递一些公共变量
比如, 以前java代码开启事务的话, 要保证原子性, service层和dao层就要共用这个conn, service层从jdbcutil中获取conn, 然后把conn传递给dao层. 并且在service层的方法代码要加上synchronized关键字修饰. 这样的话就可以保证原子性. 但是这样的效率太低了.
如果使用ThreadLocal的话, 就以threadLocal为key, conn为value存入到ThreadLocalMap里边去, 这样的话, 就可以提高效率

从上述的案例中我们可以看到, 在一些特定场景下,ThreadLocal方案有两个突出的优势:
1.传递数据 : 保存每个线程绑定的数据,在需要的地方可以直接获取, 避免参数直接传递带来的代码耦合问题
2.线程隔离 : 各线程之间的数据相互隔离却又具备并发性,避免同步方式带来的性能损失
4.JDK8 ThreadLocal的设计
Thread来维护ThreadLocalMap

这样设计的好处
这个设计与我们一开始说的设计刚好相反,这样设计有如下两个优势:
这样设计之后每个Map存储的Entry数量就会变少。因为之前的存储数量由Thread的数量决定,现在是由ThreadLocal的数量决定。在实际运用当中,往往ThreadLocal的数量要少于Thread的数量。
当Thread销毁之后,对应的ThreadLocalMap也会随之销毁,能减少内存的使用。
5.内存泄漏

  • Memory leak: 内存泄漏是指程序中已动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果。内存泄漏的堆积终将导致内存溢出。
    发生内存泄漏有两个前提
  1. 没有手动删除这个Entry
  2. CurrentThread依然运行
    6.为什么使用弱引用
    使用弱引用能够更好地防止内存泄漏
    使用完ThreadLocal,CurrentThread依然运行的前提下,就算忘记调用remove方法,弱引用比强引用可以多一层保障:弱引用的ThreadLocal会被回收,对应的value在下一次ThreadLocalMap调用set,get,remove中的任一方法的时候会被清除,从而避免内存泄漏。
    7.如何解决Hash冲突的
    ThreadLocalMap使用
    线性探测法来解决哈希冲突的。也就是使用环形数组和+1操作来解决hash冲突的
    低层是一个Entry数组, 如果得的索引位置已经有value了, 那么就会执行nextHashCode(), 也就是断续判断下一个位置是否有值了. 这种方法一次探测下一个地址,直到有空的地址后插入,若整个空间都找不到空余的地址,则产生溢出。
  3. 怎么理解线程安全?
    高手:
    多线程环境下, 在程序中不做任何同步干预操作的情况下,这个方法或者对象的执行/修改都能按照预期的结果来反馈,那么这个类就是线程安全的。
    实际上,线程安全问题的具体表现体现在原子性、可见性、有序性
    1.原子性呢,是指当一个线程执行一系列程序指令操作的时候,它应该是不可中断的(因为一旦出现中断,站在多线程的视角来看,这一系列的程序指令会出现前后执行结果不一致的问题。)
    CPU的上下文切换,是导致原子性问题的核心,而JVM里面提供了Synchronized关键字来解决原子性问题。

2.可见性,就是说在多线程环境下,由于读和写是发生在不同的线程里面,导致可见性问题
导致可见性问题的原因有很多,比如CPU的高速缓存、CPU的指令重排序、编译器的指令重排序。
3.有序性,指的是程序编写的指令顺序和最终CPU运行的指令顺序可能出现不一致的现象,这种现象也可以称为指令重排序,所以有序性也会导致可见性问题。可见性和有序性可以通过JVM里面提供了一个Volatile关键字来解决。
//在我看来,导致有序性、原子性、可见性问题的本质,是计算机工程师为了最大化提升CPU利用率导致的。比如为了提升CPU利用率,设计了三级缓存、设计了StoreBuffer、设计了缓存行这种预读机制、在操作系统里面,设计了线程模型、在编译器里面,设计了编译器的深度优化机制。
一上就是我对这个问题的理解。
11. 反射
// 了解每个类都有一个 Class 对象,包含了与类有关的信息。当编译一个新类时,会产生一个同名的 .class 文件,该文件内容保存着 Class 对象。
类加载相当于 Class 对象的加载,类在第一次使用时才动态加载到 JVM 中。也可以使用 Class.forName(“com.mysql.jdbc.Driver”) 这种方式来控制类的加载,该方法会返回一个 Class 对象。
从这里开始
反射可以提供运行时的类信息,并且这个类可以在运行时才加载进来,甚至在编译时期该类的 .class 不存在也可以加载进来。
Class 和 java.lang.reflect 一起对反射提供了支持,java.lang.reflect 类库主要包含了以下三个类:

  • Field :可以使用 get() 和 set() 方法读取和修改 Field 对象关联的字段;
  • Method :可以使用 invoke() 方法调用与 Method 对象关联的方法;
  • Constructor :可以用 Constructor 的 newInstance() 创建新的对象。
    反射的优点:
  • 可扩展性 :应用程序可以利用全限定名创建可扩展对象的实例,来使用来自外部的用户自定义类。
  • 类浏览器和可视化开发环境 :一个类浏览器需要可以枚举类的成员。可视化开发环境(如 IDE)可以从利用反射中可用的类型信息中受益,以帮助程序员编写正确的代码。
  • 调试器和测试工具 : 调试器需要能够检查一个类里的私有成员。测试工具可以利用反射来自动地调用类里定义的可被发现的 API 定义,以确保一组测试中有较高的代码覆盖率。
    反射的缺点:
    尽管反射非常强大,但也不能滥用。如果一个功能可以不用反射完成,那么最好就不用。在我们使用反射技术时,下面几条内容应该牢记于心。
  • 性能开销 :反射涉及了动态类型的解析,所以 JVM 无法对这些代码进行优化。因此,反射操作的效率要比那些非反射操作低得多。我们应该避免在经常被执行的代码或对性能要求很高的程序中使用反射。
  • 安全限制 :使用反射技术要求程序必须在一个没有安全限制的环境中运行。如果一个程序必须在有安全限制的环境中运行,如 Applet,那么这就是个问题了。
  • 内部暴露 :由于反射允许代码执行一些在正常情况下不被允许的操作(比如访问私有的属性和方法),所以使用反射可能会导致意料之外的副作用,这可能导致代码功能失调并破坏可移植性。反射代码破坏了抽象性,因此当平台发生改变的时候,代码的行为就有可能也随着变化。
  1. 泛型
    Java 泛型(generics)是 JDK 5 中引入的一个新特性, 泛型提供了编译时类型安全检测机制,该机制允许程序员在编译时检测到非法的类型。

泛型的本质是参数化类型,即给类型指定一个参数,然后在使用时再指定此参数具体的值,那样这个类型就可以在使用时决定了。这种参数类型可以用在类、接口和方法中,分别被称为泛型类、泛型接口、泛型方法。
为什么使用泛型
泛型的好处是在编译的时候检查类型安全,并且所有的强制转换都是自动和隐式的,提高代码的重用率。

泛型擦除
Java的泛型是伪泛型,Java的泛型基本上都是在编译器这个层次上实现的,在生成的字节码中是不包含泛型中的类型信息的,使用泛型的时候加上类型参数,在编译器编译的时候会去掉,这个过程称为类型擦除。

  1. IO
    Java 的 I/O 大概可以分成以下几类:
    磁盘操作:File
    字节操作:InputStream 和 OutputStream
    字符操作:Reader 和 Writer
    对象操作:Serializable
    网络操作:Socket
    新的输入/输出:NIO
    磁盘操作
    File 类可以用于表示文件和目录的信息,但是它不表示文件的内容。
    递归地列出一个目录下所有文件:
    从 Java7 开始,可以使用 Paths 和 Files 代替 File。
    字节操作
    实现文件复制
    装饰者模式
    Java I/O 使用了装饰者模式来实现。以 InputStream 为例,
  • InputStream 是抽象组件;
  • FileInputStream 是 InputStream 的子类,属于具体组件,提供了字节流的输入操作;
  • FilterInputStream 属于抽象装饰者,装饰者用于装饰组件,为组件提供额外的功能。例如 BufferedInputStream 为 FileInputStream 提供缓存的功能。
  • 实例化一个具有缓存功能的字节流对象时,只需要在 FileInputStream 对象上再套一层 BufferedInputStream 对象即可。
  • DataInputStream 装饰者提供了对更多数据类型进行输入的操作,比如 int、double 等基本类型。
  • 字符操作
    编码与解码
    编码就是把字符转换为字节,而解码是把字节重新组合成字符。
    如果编码和解码过程使用不同的编码方式那么就出现了乱码。
  • GBK 编码中,中文字符占 2 个字节,英文字符占 1 个字节;
  • UTF-8 编码中,中文字符占 3 个字节,英文字符占 1 个字节;
  • UTF-16be 编码中,中文字符和英文字符都占 2 个字节。
    UTF-16be 中的 be 指的是 Big Endian,也就是大端。相应地也有 UTF-16le,le 指的是 Little Endian,也就是小端。
    Java 的内存编码使用双字节编码 UTF-16be,这不是指 Java 只支持这一种编码方式,而是说 char 这种类型使用 UTF-16be 进行编码。char 类型占 16 位,也就是两个字节,Java 使用这种双字节编码是为了让一个中文或者一个英文都能使用一个 char 来存储。
    在调用无参数 getBytes() 方法时,默认的编码方式不是 UTF-16be。双字节编码的好处是可以使用一个 char 存储中文和英文,而将 String 转为 bytes[] 字节数组就不再需要这个好处,因此也就不再需要双字节编码。getBytes() 的默认编码方式与平台有关,一般为 UTF-8。
    Reader 与 Writer
    不管是磁盘还是网络传输,最小的存储单元都是字节,而不是字符。但是在程序中操作的通常是字符形式的数据,因此需要提供对字符进行操作的方法。
  • InputStreamReader 实现从字节流解码成字符流;
  • OutputStreamWriter 实现字符流编码成为字节流
    对象操作
    #序列化
    序列化就是将一个对象转换成字节序列,方便存储和传输。
  • 序列化:ObjectOutputStream.writeObject()
  • 反序列化:ObjectInputStream.readObject()
    不会对静态变量进行序列化,因为序列化只是保存对象的状态,静态变量属于类的状态。
    #Serializable
    序列化的类需要实现 Serializable 接口,它只是一个标准,没有任何方法需要实现,但是如果不去实现它的话而进行序列化,会抛出异常。
    transient
    transient 关键字可以使一些属性不会被序列化。
    ArrayList 中存储数据的数组 elementData 是用 transient 修饰的,因为这个数组是动态扩展的,并不是所有的空间都被使用,因此就不需要所有的内容都被序列化。通过重写序列化和反序列化方法,使得可以只序列化数组中有内容的那部分数据。
    网络操作
    Java 中的网络支持:
  • InetAddress:用于表示网络上的硬件资源,即 IP 地址;
  • URL:统一资源定位符;
  • Sockets:使用 TCP 协议实现网络通信;
  • Datagram:使用 UDP 协议实现网络通信。
    #InetAddress
    没有公有的构造函数,只能通过静态方法来创建实例
    URL
    可以直接从 URL 中读取字节流数据
    Sockets
  • ServerSocket:服务器端类
  • Socket:客户端类
  • 服务器和客户端通过 InputStream 和 OutputStream 进行输入输出
  1. NIO
  • 新的输入/输出 (NIO) 库是在 JDK 1.4 中引入的,弥补了原来的 I/O 的不足,提供了高速的、面向块的 I/O。
  • 流与块
  • I/O 与 NIO 最重要的区别是数据打包和传输的方式,I/O 以流的方式处理数据,而 NIO 以块的方式处理数据。
  • 面向流的 I/O 一次处理一个字节数据:一个输入流产生一个字节数据,一个输出流消费一个字节数据。为流式数据创建过滤器非常容易,链接几个过滤器,以便每个过滤器只负责复杂处理机制的一部分。不利的一面是,面向流的 I/O 通常相当慢。
  • 面向块的 I/O 一次处理一个数据块,按块处理数据比按流处理数据要快得多。但是面向块的 I/O 缺少一些面向流的 I/O 所具有的优雅性和简单性。
  • I/O 包和 NIO 已经很好地集成了,java.io.* 已经以 NIO 为基础重新实现了,所以现在它可以利用 NIO 的一些特性。例如,java.io.* 包中的一些类包含以块的形式读写数据的方法,这使得即使在面向流的系统中,处理速度也会更快。
    通道与缓冲区
    #1. 通道
    通道 Channel 是对原 I/O 包中的流的模拟,可以通过它读取和写入数据。
    通道与流的不同之处在于,流只能在一个方向上移动(一个流必须是 InputStream 或者 OutputStream 的子类),而通道是双向的,可以用于读、写或者同时用于读写。
    通道包括以下类型:
  • FileChannel:从文件中读写数据;
  • DatagramChannel:通过 UDP 读写网络中数据;
  • SocketChannel:通过 TCP 读写网络中数据;
  • ServerSocketChannel:可以监听新进来的 TCP 连接,对每一个新进来的连接都会创建一个 SocketChannel。
    #2. 缓冲区
    发送给一个通道的所有数据都必须首先放到缓冲区中,同样地,从通道中读取的任何数据都要先读到缓冲区中。也就是说,不会直接对通道进行读写数据,而是要先经过缓冲区。
    缓冲区实质上是一个数组,但它不仅仅是一个数组。缓冲区提供了对数据的结构化访问,而且还可以跟踪系统的读/写进程。
    缓冲区包括以下类型:
  • ByteBuffer
  • CharBuffer
  • ShortBuffer
  • IntBuffer
  • LongBuffer
  • FloatBuffer
  • DoubleBuffer
    #缓冲区状态变量
  • capacity:最大容量;
  • position:当前已经读写的字节数;
  • limit:还可以读写的字节数。
    状态变量的改变过程举例:
    ① 新建一个大小为 8 个字节的缓冲区,此时 position 为 0,而 limit = capacity = 8。capacity 变量不会改变,下面的讨论会忽略它。

② 从输入通道中读取 5 个字节数据写入缓冲区中,此时 position 为 5,limit 保持不变。

③ 在将缓冲区的数据写到输出通道之前,需要先调用 flip() 方法,这个方法将 limit 设置为当前 position,并将 position 设置为 0。

④ 从缓冲区中取 4 个字节到输出缓冲中,此时 position 设为 4。

⑤ 最后需要调用 clear() 方法来清空缓冲区,此时 position 和 limit 都被设置为最初位置。

#文件 NIO 实例
以下展示了使用 NIO 快速复制文件的实例:
选择器
NIO 常常被叫做非阻塞 IO,主要是因为 NIO 在网络通信中的非阻塞特性被广泛使用。
NIO 实现了 IO 多路复用中的 Reactor 模型,一个线程 Thread 使用一个选择器 Selector 通过轮询的方式去监听多个通道 Channel 上的事件,从而让一个线程就可以处理多个事件。
通过配置监听的通道 Channel 为非阻塞,那么当 Channel 上的 IO 事件还未到达时,就不会进入阻塞状态一直等待,而是继续轮询其它 Channel,找到 IO 事件已经到达的 Channel 执行。
因为创建和切换线程的开销很大,因此使用一个线程来处理多个事件而不是一个线程处理一个事件,对于 IO 密集型的应用具有很好地性能。
应该注意的是,只有套接字 Channel 才能配置为非阻塞,而 FileChannel 不能,为 FileChannel 配置非阻塞也没有意义。

  1. 创建选择器
  2. 将通道注册到选择器上
    通道必须配置为非阻塞模式,否则使用选择器就没有任何意义了,因为如果通道在某个事件上被阻塞,那么服务器就不能响应其它事件,必须等待这个事件处理完毕才能去处理其它事件,显然这和选择器的作用背道而驰。
    在将通道注册到选择器上时,还需要指定要注册的具体事件,主要有以下几类:
  • SelectionKey.OP_CONNECT
  • SelectionKey.OP_ACCEPT
  • SelectionKey.OP_READ
  • SelectionKey.OP_WRITE
    它们在 SelectionKey 的定义如下:
    可以看出每个事件可以被当成一个位域,从而组成事件集整数。例如:
  1. 监听事件
    使用 select() 来监听到达的事件,它会一直阻塞直到有至少一个事件到达
  2. 事件循环
    因为一次 select() 调用不能处理完所有的事件,并且服务器端有可能需要一直监听事件,因此服务器端处理事件的代码一般会放在一个死循环内。
    内存映射文件
    内存映射文件 I/O 是一种读和写文件数据的方法,它可以比常规的基于流或者基于通道的 I/O 快得多。
    向内存映射文件写入可能是危险的,只是改变数组的单个元素这样的简单操作,就可能会直接修改磁盘上的文件。修改数据与将数据保存到磁盘是没有分开的。
    下面代码行将文件的前 1024 个字节映射到内存中,map() 方法返回一个 MappedByteBuffer,它是 ByteBuffer 的子类。因此,可以像使用其他任何 ByteBuffer 一样使用新映射的缓冲区,操作系统会在需要时负责执行映射。
    对比
    NIO 与普通 I/O 的区别主要有以下两点:
  • NIO 是非阻塞的;
  • NIO 面向块,I/O 面向流。
  1. 大端存储和小端存储
    大端存储模式:是指 数据的低位 保存在 内存的高地址 中,数据的高位 保存在 内存的低地址 中。
    这样的存储模式有点儿类似于把数据当作字符串顺序处理:地址由小向大增加,而数据从高位往低位放;这和我们的阅读习惯一致。
    小端存储模式:是指 数据的低位 保存在 内存的低地址 中, 数据的高位 保存在 内存的高地址 中。
    这种存储模式将地址的高低和数据位权有效地结合起来,高地址部分权值高,低地址部分权值低。
    两种模式出现原因
    为什么会有大小端模式之分呢? 这是因为:
    有些变量类型的大小大于一个字节,如:16bit的short型,32bit的long型(要看具体的编译器)。
    有些处理器的位数大于8位,例如16位或者32位的处理器。那么其中的寄存器的宽度必定大于一个字节。
    总而言之,就是一个字节无法满足我们的存储需求,因此就诞生了大端小端这两种存储模式。
    两种模式的优劣
    大端小端没有谁优谁劣,各自优势便是对方劣势:
    小端模式 :
    强制转换数据不需要调整字节内容,1、2、4字节的存储方式一样。
    CPU做数值运算时从内存中依次从低位到高位取数据进行运算,直到最后刷新高位的符号位,这样的运算方式会更高效。
    大端模式 :符号位的判定固定为第一个字节,容易判断正负。
    大小端的应用情景
    一般操作系统都是小端,而JAVA、通讯协议是大端的。
    对于处理器而言:
    小端模式:Intel的80X86系列芯片,ARM处理器默认采用小端、但可以切换成大端。
    大端模式:KEIL C51、PowerPC、IBM、Sun。
  2. socekt 五大 I/O 模型
    一个输入操作通常包括两个阶段:
  • 等待数据准备好
  • 从内核向进程复制数据
    对于一个套接字上的输入操作,第一步通常涉及等待数据从网络中到达。当所等待数据到达时,它被复制到内核中的某个缓冲区。第二步就是把数据从内核缓冲区复制到应用进程缓冲区。
    Unix 有五种 I/O 模型:
  • 阻塞式 I/O
  • 非阻塞式 I/O
  • I/O 复用(select 和 poll)
  • 信号驱动式 I/O(SIGIO)
  • 异步 I/O(AIO)
    #阻塞式 I/O
    应用进程被阻塞,直到数据从内核缓冲区复制到应用进程缓冲区中才返回。
    应该注意到,在阻塞的过程中,其它应用进程还可以执行,因此阻塞不意味着整个操作系统都被阻塞。因为其它应用进程还可以执行,所以不消耗 CPU 时间,这种模型的 CPU 利用率会比较高。
    下图中,recvfrom() 用于接收 Socket 传来的数据,并复制到应用进程的缓冲区 buf 中。这里把 recvfrom() 当成系统调用。
    ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr *src_addr, socklen_t *addrlen);

#非阻塞式 I/O
应用进程执行系统调用之后,内核返回一个错误码。应用进程可以继续执行,但是需要不断的执行系统调用来获知 I/O 是否完成,这种方式称为轮询(polling)。
由于 CPU 要处理更多的系统调用,因此这种模型的 CPU 利用率比较低。

#I/O 复用
使用 select 或者 poll 等待数据,并且可以等待多个套接字中的任何一个变为可读。这一过程会被阻塞,当某一个套接字可读时返回,之后再使用 recvfrom 把数据从内核复制到进程中。
它可以让单个进程具有处理多个 I/O 事件的能力。又被称为 Event Driven I/O,即事件驱动 I/O。
如果一个 Web 服务器没有 I/O 复用,那么每一个 Socket 连接都需要创建一个线程去处理。如果同时有几万个连接,那么就需要创建相同数量的线程。相比于多进程和多线程技术,I/O 复用不需要进程线程创建和切换的开销,系统开销更小。
#信号驱动 I/O
应用进程使用 sigaction 系统调用,内核立即返回,应用进程可以继续执行,也就是说等待数据阶段应用进程是非阻塞的。内核在数据到达时向应用进程发送 SIGIO 信号,应用进程收到之后在信号处理程序中调用 recvfrom 将数据从内核复制到应用进程中。
相比于非阻塞式 I/O 的轮询方式,信号驱动 I/O 的 CPU 利用率更高。

#异步 I/O
应用进程执行 aio_read 系统调用会立即返回,应用进程可以继续执行,不会被阻塞,内核会在所有操作完成之后向应用进程发送信号。
异步 I/O 与信号驱动 I/O 的区别在于,异步 I/O 的信号是通知应用进程 I/O 完成,而信号驱动 I/O 的信号是通知应用进程可以开始 I/O。

#五大 I/O 模型比较

  • 同步 I/O:将数据从内核缓冲区复制到应用进程缓冲区的阶段(第二阶段),应用进程会阻塞。
  • 异步 I/O:第二阶段应用进程不会阻塞。
    同步 I/O 包括阻塞式 I/O、非阻塞式 I/O、I/O 复用和信号驱动 I/O ,它们的主要区别在第一个阶段。
    非阻塞式 I/O 、信号驱动 I/O 和异步 I/O 在第一阶段不会阻塞。
  1. I/O 复用 (select poll epoll)
    select/poll/epoll 都是 I/O 多路复用的具体实现,select 出现的最早,之后是 poll,再是 epoll。
    select
    int select(int n, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

select 允许应用程序监视一组文件描述符,等待一个或者多个描述符成为就绪状态,从而完成 I/O 操作。

  • fd_set 使用数组实现,数组大小使用 FD_SETSIZE 定义,所以只能监听少于 FD_SETSIZE 数量的描述符。有三种类型的描述符类型:readset、writeset、exceptset,分别对应读、写、异常条件的描述符集合。
  • timeout 为超时参数,调用 select 会一直阻塞直到有描述符的事件到达或者等待的时间超过 timeout。
  • 成功调用返回结果大于 0,出错返回结果为 -1,超时返回结果为 0。

#poll
poll 的功能与 select 类似,也是等待一组描述符中的一个成为就绪状态。
poll 中的描述符是 pollfd 类型的数组,pollfd 的定义如下:
#比较
#1. 功能
select 和 poll 的功能基本相同,不过在一些实现细节上有所不同。

  • select 会修改描述符,而 poll 不会;
  • select 的描述符类型使用数组实现,FD_SETSIZE 大小默认为 1024,因此默认只能监听少于 1024 个描述符。如果要监听更多描述符的话,需要修改 FD_SETSIZE 之后重新编译;而 poll 没有描述符数量的限制;
  • poll 提供了更多的事件类型,并且对描述符的重复利用上比 select 高。
  • 如果一个线程对某个描述符调用了 select 或者 poll,另一个线程关闭了该描述符,会导致调用结果不确定。
    #2. 速度
    select 和 poll 速度都比较慢,每次调用都需要将全部描述符从应用进程缓冲区复制到内核缓冲区。
    #3. 可移植性
    几乎所有的系统都支持 select,但是只有比较新的系统支持 poll。
    #epoll
    int epoll_create(int size);
    int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
    int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);

epoll_ctl() 用于向内核注册新的描述符或者是改变某个文件描述符的状态。已注册的描述符在内核中会被维护在一棵红黑树上,通过回调函数内核会将 I/O 准备好的描述符加入到一个链表中管理,进程调用 epoll_wait() 便可以得到事件完成的描述符。
从上面的描述可以看出,epoll 只需要将描述符从进程缓冲区向内核缓冲区拷贝一次,并且进程不需要通过轮询来获得事件完成的描述符。
epoll 仅适用于 Linux OS。
epoll 比 select 和 poll 更加灵活而且没有描述符数量限制。
epoll 对多线程编程更有友好,一个线程调用了 epoll_wait() 另一个线程关闭了同一个描述符也不会产生像 select 和 poll 的不确定情况。

#工作模式
epoll 的描述符事件有两种触发模式:LT(level trigger)和 ET(edge trigger)。
#1. LT 模式
当 epoll_wait() 检测到描述符事件到达时,将此事件通知进程,进程可以不立即处理该事件,下次调用 epoll_wait() 会再次通知进程。是默认的一种模式,并且同时支持 Blocking 和 No-Blocking。
#2. ET 模式
和 LT 模式不同的是,通知之后进程必须立即处理事件,下次再调用 epoll_wait() 时不会再得到事件到达的通知。
很大程度上减少了 epoll 事件被重复触发的次数,因此效率要比 LT 模式高。只支持 No-Blocking,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。
#应用场景
很容易产生一种错觉认为只要用 epoll 就可以了,select 和 poll 都已经过时了,其实它们都有各自的使用场景。
#1. select 应用场景
select 的 timeout 参数精度为微秒,而 poll 和 epoll 为毫秒,因此 select 更加适用于实时性要求比较高的场景,比如核反应堆的控制。
select 可移植性更好,几乎被所有主流平台所支持。
#2. poll 应用场景
poll 没有最大描述符数量的限制,如果平台支持并且对实时性要求不高,应该使用 poll 而不是 select。
#3. epoll 应用场景
只需要运行在 Linux 平台上,有大量的描述符需要同时轮询,并且这些连接最好是长连接。
需要同时监控小于 1000 个描述符,就没有必要使用 epoll,因为这个应用场景下并不能体现 epoll 的优势。
需要监控的描述符状态变化多,而且都是非常短暂的,也没有必要使用 epoll。因为 epoll 中的所有描述符都存储在内核中,造成每次需要对描述符的状态改变都需要通过 epoll_ctl() 进行系统调用,频繁系统调用降低效率。并且 epoll 的描述符存储在内核,不容易调试
二.222222222222222222222 JUC并发

  1. volatile
    volatile关键字有两个作用,第一个,它可以在多线和环境下保证共享变量的可见性, 当一个线程对于变量的修改,其他线程可以立刻看到修改后的值, 这个可见性的问题,是由几个方面来造成的.第一个是CPU的三级缓存去解决cpu运算效率和IO运算效率的问题,但是他也带来的就是缓存一致性的一个问题缓存一致性问题就会导致可见性问题. 被volatile修饰就表明这个变量是不稳定且易变的, 每次都要去主内存去读取.
    第二个是通过增加内存屏障防止多个指令之间的重排序,因为我们的代码的编写顺序和执行顺序可能是不一致的.从而导致可见性问题.指令重排序是一种性能优化的手段.它来自于几个方面,首先第一个方面是cpu层面,针对于MESI协议的更进一步的优化去提升cpu的一个利用率, 第二个编译器层面的优化,编译器编译过程中在不改变间线程语义和程序正确执行的前提下对指令进行合理的重排序,从而优化整体的一个性能.所以啊,对于共享变量增加了volatile关键字,那么在编译器层面,就不会去触发编译器的优化,同时在jvm层面呢他会插入内存屏障(load,store)指令,来去避免指令重排序的问题.

  2. lock和synchronized区别
    或者: synchronized和lock的阻塞有什么区别?
    下面我从3个方面来回答
    1.从功能角度来看,Lock和Synchronized都能保证线程安全
    2.从特性来看
    (1)Synchronized是Java中的同步关键字,Lock是J.U.C包中提供的接口,这个接口有很多实现类,其中就包括ReentrantLock重入锁
    (2)Synchronized可以通过两种方式来控制锁的粒度
    一种是把synchronized关键字修饰在方法层面
    另一种是修饰在代码块上,并且我们可以通过Synchronized加锁对象的声明周期来控制锁的作用范围,比如锁对象是静态对象或者类对象,那么这个锁就是全局锁。
    如果锁对象是普通实例对象,那这个锁的范围取决于这个实例的声明周期。
    Lock锁的粒度是通过它里面提供的lock()和unlock()方法决定的

包裹在这两个方法之间的代码能够保证线程安全性。而锁的作用域取决于Lock实例的生命周期。
(3)Lock比Synchronized的灵活性更高,Lock可以自主决定什么时候加锁,什么时候释放锁,只需要调用lock()和unlock()这两个方法就行,同时Lock还提供了非阻塞的竞争锁方法tryLock()方法,这个方法通过返回true/false来告诉当前线程是否已经有其他线程正在使用锁。
Synchronized由于是关键字,所以它无法实现非阻塞竞争锁的方法,另外,Synchronized锁的释放是被动的,就是当Synchronized同步代码块执行完以后或者代码出现异常时才会释放。
(4)Lock提供了公平锁和非公平锁的机制,公平锁是指线程竞争锁资源时,如果已经有其他线程正在排队等待锁释放,那么当前竞争锁资源的线程无法插队。而非公平锁,就是不管是否有线程在排队等待锁,它都会尝试去竞争一次锁。 Synchronized只提供了一种非公平锁的实现。
3.从性能方面来看,Synchronized和Lock在性能方面相差不大,在实现上会有一些区别,Synchronized引入了偏向锁、轻量级锁、重量级锁以及锁升级的方式来优化加锁的性能,而Lock中则用到了自旋锁的方式来实现性能优化。
3. CAS
CAS就是compareAndSwap,比较并交换的意思, CAS机制会比较预期值和内存的实际值是否相同, 如果相同的话, 就返回true, 否则返回false. 这个过程它是一个原子的, 不会存在任何线程安全问题, CAS主要是应用在一些并发场景里面, 比较典型的使用场景主要有两个, 第一个是JUC里面的Atomic包里边的原子性实现, 比如AtomicInteger和AtomicLong. 第二个是用于多线程对于共享资源的竞争的, 比如AQS

CAS是Java中Unsafe类里面的一个方法, 它的全称是CompareAndSwap, 比较并交换的意思, 它的主要功能是能够去保证在多线程的环境下对于共享变量修改的一个原子性. 比如像这样的一个场景

成员变量是state, 它的默认值是0, 其中定义了一个方法叫doSomething(), 这个方法的逻辑是先判断state是否为0, 如果为0就修改成1, 这个逻辑在单线程情况下看起来没有任何问题, 但是在多线程环境下会存在原子性的问题, 因为这是一个典型的读-写操作, 一般情况下, 我们会在doSomething()这个方法去加一个synchronized同步锁来解决这样的一个原子性问题, 但是加同步锁一定会带来性能上的损耗, 所以对于样一类场景我们可以使用CAS机制来进行优化
这是优化后的一个代码,

在doSomething()这个方法中我们调用了Unsafe类里边的compareAndSwapInt()方法来达到同样的目的, 这个方法有四个参数, 分别是当前对象实例,state在内存地址中的一个偏移量, 预期值0和期望更改之后的值1, CAS机制会比较state内存地址偏移量对应的值和传入的值0是否相等, 如果相等就直接修改内存地址中state的值等于1, 否则返回false, 表示修改失败, 而这个过程它是一个原子的,
// 不会存在任何线程安全问题, compareAndSwap是一个native方法, 实际上它最终还是会面临同样的问题, 就是先从内存地址中读取state值, 然后再去比较, 最后再去修改. 这个过程不管在什么层面去实现, 都会存在原子性问题, 所以, 在conpareAndSwap的底层实现里面呢, 如果是在多核cpu环境下会增加一个lock指令, 来对或者总线去加锁, 从而去保证比较并替换这两个原子操作的原子性, CAS主要是应用在一些并发场景里面, 比较典型的使用场景主要有两个, 第一个是JUC里面的Atomic包里边的原子性实现, 比如AtomicInteger和AtomicLong. 第二个是病多线程对于共享资源竞争 的一个互斥特性, 比如AQS, ConcurrentHashMap, 以及ConcurrentLinkedQueue等, 以上就是我对于这个问题的理解.
4. AQS
AQS总结-MIC
AQS是多线程同步器, 它是JUC包包中多个组件的底层实现, 比如Lock,CountDownLatch,Semaphore到用到AQS.
从本质上来说, AQS提供两种锁的机制, 分别是排它锁和共享锁.
排它锁 同一时刻只允许一个线程去访问这样一个共享资源, 比如Lock中的ReentrantLock重入锁, 它就是用到了AQS中的一个排它锁的功能.
共享锁也称为读锁, 就是在同一个时刻允许多个线程同时获得这样一个锁的资源, 比如CountDownLatch以及Semaphore都用到了AQS中共享锁的功
// AQS作为互斥锁来说, 它要保证如何保证多线程同时更新互斥变量的时候线程的安全性, 第二个, 未竞争到锁资源的线程的等待, 以及竞争到锁的资源释放锁之后的唤醒, 第三个锁竞争的公平性和非公平性,
AQS采用了一个int类型的互斥变量state, 用来记录锁竞争的状态, 0表示当前没有任何线程竞争锁资源, 而>=1表示已经有线程正在持有锁资源. 一个线程来获取锁资源的时候, 首先会判断state是否等于0, 也就是说他是无锁状态, 如果是,则把这个state更新成1, 表示占用到锁, 而这个过程中, 因此AQS采用了CAS机制, 去保证state变量更新的一个原子性; 未获得锁的线程通过Unsafe类中的park方法去进行阻塞, 把阻塞的线程按照先进先出的原则去加入到一个双向链表的一个结构中, 当获得锁资源的线程释放锁之后, 会从这样一个双向链表的头部去唤醒下一个等待的线程, 再去竞争锁,
最后关于锁竞争的公平性和非公平性的问题, AQS的处理方法是在竞争锁资源的时候, 公平锁需要去判断双向链表中是否有阻塞的线程, 如果有则需要去排队等待, 而非公平锁的处理方式是不管双向链表中是否存在等待竞争锁的线程, 那么他都会直接去尝试更改互斥变量state去竞争锁. 两个的主要区别就是hasProcessors()方法
// 假设在一个临界点获得锁的线程释放锁, 此时state等于0, 而当前这个线程去抢占锁的时候, 刚好可以把state修改为1, 那么这个时候就表示他可以拿到锁, 而这个过程是非公平的.以上就是我对AQS的理解.
AQS-我总结
AQS是一个抽象的同步队列. 他是一种管理阻塞线程的机制. 因为有锁就会有线程阻塞, 有阻塞就要有一种等待管理机制来管理这些线程.而aqs就是做这个工作的. AQS维护了一个volatile修饰的state变量. 当state为0时, 就代表资源为可用, 这时通过CAS返回false, 将state修改为1, 并且将exclusiveThread设为当前线程. 当为1时, 说明已经被别的线程抢占了. 如果下一次获取锁 的是同一个线程, 那么state会进行一个累加, 这是可重入锁的机制. 如果一个线程进来请求锁时发现state!= 0, 那么他会进入队列等待,并且使用cas+自旋的方式进行锁的获取.
AQS内部有一个内部类Node, 他里边有waitStatus变量, 前后指针, 以及线程引用. 它会将请求线程封装成一个node节点, 通过CAS,自旋以及LockSupport.park()的方式, 维护state变量的状态,使并发达到同步的控制效果.以前后指针来形成一个虚拟的双向队列. 这个队列的第一节点是一个点位节点, 是一个null节点. 并且head指向这个占位节点. 当队列中第一个线程拿到锁之后, head就会指向原先第一个任务线程所在的节点, 并且将它的值置为null, 成为新的占位节点. 而原先的占位节点则将会被GC.
线程进来会抢一次, 加入队列之前还再会抢一次,抢不到才park()
aqs是锁的实现, 而具体锁的话则是锁的使用, 它屏蔽了具体的实现细节.
aqs的实现类主要有ReentrantLock(独占的), Semaphore(共享), CountDownLatch, ReentranReadLock(共享和独占, 它的读是共享的, 写的独占的)
5. CountDownLatch (倒计数插销)
用来控制一个或者多个线程等待其他多个线程。
维护了一个计数器 cnt,每次调用 countDown() 方法会让计数器的值减 1,减到 0 的时候,那些因为调用 await() 方法而在等待的线程就会被唤醒。

  • CountDownLatch主要有两个方法, 当一个或多个线程调用await方法时, 这些线程会阻塞
  • 其它线程调用countDown方法会将计数器减1(调用countDown方法的线程不会阻塞)
  • 当计数器的值变为0时, 因await方法阻塞的线程会被唤醒, 继续执行

public class CountDownLatchDemo {
public static void main(String[] args) throws InterruptedException {
CountDownLatch countDownLatch = new CountDownLatch(6);

    for (int i = 0; i < 6; i++) {
        new Thread(() -> {
            System.out.println(Thread.currentThread().getName() + "\t离开教室");
            countDownLatch.countDown();

        }, String.valueOf(i)).start();
    }

    countDownLatch.await();
    System.out.println(Thread.currentThread().getName() + "\t班长关门走人");

}

}

控制台:
3 离开教室
4 离开教室
5 离开教室
1 离开教室
0 离开教室
2 离开教室
main 班长关门走人

  1. CyclicBarrier 循环屏障?
    用来控制多个线程互相等待,只有当多个线程都到达时,这些线程才会继续执行。
    和 CountdownLatch 相似,都是通过维护计数器来实现的。线程执行 await() 方法之后计数器会减 1,并进行等待,直到计数器为 0,所有调用 await() 方法而在等待的线程才能继续执行。
    CyclicBarrier 和 CountdownLatch 的一个区别是,CyclicBarrier 的计数器通过调用 reset() 方法可以循环使用,所以它才叫做循环屏障。
    CyclicBarrier 有两个构造函数,其中 parties 指示计数器的初始值,barrierAction 在所有线程都到达屏障的时候会执行一次。

  2. Semaphore
    Semaphore 类似于操作系统中的信号量,可以控制对互斥资源的访问线程数。
    以下代码模拟了对某个服务的并发请求,每次只能有 3 个客户端同时访问,请求总数为 10

  3. FutureTask
    在介绍 Callable 时我们知道它可以有返回值,返回值通过 Future 进行封装。FutureTask 实现了 RunnableFuture 接口,该接口继承自 Runnable 和 Future 接口,这使得 FutureTask 既可以当做一个任务执行,也可以有返回值。

FutureTask 可用于异步获取执行结果或取消执行任务的场景。当一个计算任务需要执行很长时间,那么就可以用 FutureTask 来封装这个任务,主线程在完成自己的任务之后再去获取结果。

  1. ForkJoin
    主要用于并行计算中,和 MapReduce 原理类似,都是把大的计算任务拆分成多个小任务并行计算。

ForkJoin 使用 ForkJoinPool 来启动,它是一个特殊的线程池,线程数量取决于 CPU 核数。
public class ForkJoinPool extends AbstractExecutorService

ForkJoinPool 实现了工作窃取算法来提高 CPU 的利用率。每个线程都维护了一个双端队列,用来存储需要执行的任务。工作窃取算法允许空闲的线程从其它线程的双端队列中窃取一个任务来执行。窃取的任务必须是最晚的任务,避免和队列所属线程发生竞争。例如下图中,Thread2 从 Thread1 的队列中拿出最晚的 Task1 任务,Thread1 会拿出 Task2 来执行,这样就避免发生竞争。但是如果队列中只有一个任务时还是会发生竞争。

10.Java 内存模型
Java 内存模型试图屏蔽各种硬件和操作系统的内存访问差异,以实现让 Java 程序在各种平台下都能达到一致的内存访问效果。
#主内存与工作内存
处理器上的寄存器的读写的速度比内存快几个数量级,为了解决这种速度矛盾,在它们之间加入了高速缓存。
加入高速缓存带来了一个新的问题:缓存一致性。如果多个缓存共享同一块主内存区域,那么多个缓存的数据可能会不一致,需要一些协议来解决这个问题。

所有的变量都存储在主内存中,每个线程还有自己的工作内存,工作内存存储在高速缓存或者寄存器中,保存了该线程使用的变量的主内存副本拷贝。
线程只能直接操作工作内存中的变量,不同线程之间的变量值传递需要通过主内存来完成。

#内存间交互操作
Java 内存模型定义了 8 个操作来完成主内存和工作内存的交互操作。

  • read:把一个变量的值从主内存传输到工作内存中
  • load:在 read 之后执行,把 read 得到的值放入工作内存的变量副本中
  • use:把工作内存中一个变量的值传递给执行引擎
  • assign:把一个从执行引擎接收到的值赋给工作内存的变量
  • store:把工作内存的一个变量的值传送到主内存中
  • write:在 store 之后执行,把 store 得到的值放入主内存的变量中
  • lock:作用于主内存的变量
  • unlock
  1. 内存模型三大特性
    #1. 原子性
    Java 内存模型保证了 read、load、use、assign、store、write、lock 和 unlock 操作具有原子性,例如对一个 int 类型的变量执行 assign 赋值操作,这个操作就是原子性的。但是 Java 内存模型允许虚拟机将没有被 volatile 修饰的 64 位数据(long,double)的读写操作划分为两次 32 位的操作来进行,即 load、store、read 和 write 操作可以不具备原子性。
    AtomicInteger 能保证多个线程修改的原子性。
  2. 可见性
    可见性指当一个线程修改了共享变量的值,其它线程能够立即得知这个修改。Java 内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值来实现可见性的。
    主要有三种实现可见性的方式:
  • volatile
  • synchronized,对一个变量执行 unlock 操作之前,必须把变量值同步回主内存。
  • final,被 final 关键字修饰的字段在构造器中一旦初始化完成,并且没有发生 this 逃逸(其它线程通过 this 引用访问到初始化了一半的对象),那么其它线程就能看见 final 字段的值。
    对前面的线程不安全示例中的 cnt 变量使用 volatile 修饰,不能解决线程不安全问题,因为 volatile 并不能保证操作的原子性。
  1. 有序性
    有序性是指:在本线程内观察,所有操作都是有序的。在一个线程观察另一个线程,所有操作都是无序的,无序是因为发生了指令重排序。在 Java 内存模型中,允许编译器和处理器对指令进行重排序,重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。
    volatile 关键字通过添加内存屏障的方式来禁止指令重排,即重排序时不能把后面的指令放到内存屏障之前。
    也可以通过 synchronized 来保证有序性,它保证每个时刻只有一个线程执行同步代码,相当于是让线程顺序执行同步代码
  2. 线程安全
    多个线程不管以何种方式访问某个类,并且在主调代码中不需要进行同步,都能表现正确的行为。
    线程安全有以下几种实现方式:
    不可变(Immutable)的对象一定是线程安全的,不需要再采取任何的线程安全保障措施。只要一个不可变的对象被正确地构建出来,永远也不会看到它在多个线程之中处于不一致的状态。多线程环境下,应当尽量使对象成为不可变,来满足线程安全。
    不可变的类型:
  • final 关键字修饰的基本数据类型
  • String
  • 枚举类型
  • Number 部分子类,如 Long 和 Double 等数值包装类型,BigInteger 和 BigDecimal 等大数据类型。但同为 Number 的原子类 AtomicInteger 和 AtomicLong 则是可变的。
    对于集合类型,可以使用 Collections.unmodifiableXXX() 方法来获取一个不可变的集合。
    Collections.unmodifiableXXX() 先对原始的集合进行拷贝,需要对集合进行修改的方法都直接抛出异常。
    互斥同步
    synchronized 和 ReentrantLock。
    非阻塞同步
    互斥同步最主要的问题就是线程阻塞和唤醒所带来的性能问题,因此这种同步也称为阻塞同步。
    互斥同步属于一种悲观的并发策略,总是认为只要不去做正确的同步措施,那就肯定会出现问题。无论共享数据是否真的会出现竞争,它都要进行加锁(这里讨论的是概念模型,实际上虚拟机会优化掉很大一部分不必要的加锁)、用户态核心态转换、维护锁计数器和检查是否有被阻塞的线程需要唤醒等操作。
    随着硬件指令集的发展,我们可以使用基于冲突检测的乐观并发策略:先进行操作,如果没有其它线程争用共享数据,那操作就成功了,否则采取补偿措施(不断地重试,直到成功为止)。这种乐观的并发策略的许多实现都不需要将线程阻塞,因此这种同步操作称为非阻塞同步。
  1. CAS
    乐观锁需要操作和冲突检测这两个步骤具备原子性,这里就不能再使用互斥同步来保证了,只能靠硬件来完成。硬件支持的原子性操作最典型的是:比较并交换(Compare-and-Swap,CAS)。CAS 指令需要有 3 个操作数,分别是内存地址 V、旧的预期值 A 和新值 B。当执行操作时,只有当 V 的值等于 A,才将 V 的值更新为 B。
    #2. AtomicInteger
    J.U.C 包里面的整数原子类 AtomicInteger 的方法调用了 Unsafe 类的 CAS 操作。
    以下代码使用了 AtomicInteger 执行了自增的操作。

  2. ABA
    如果一个变量初次读取的时候是 A 值,它的值被改成了 B,后来又被改回为 A,那 CAS 操作就会误认为它从来没有被改变过。
    J.U.C 包提供了一个带有标记的原子引用类 AtomicStampedReference 来解决这个问题,它可以通过控制变量值的版本来保证 CAS 的正确性。大部分情况下 ABA 问题不会影响程序并发的正确性,如果需要解决 ABA 问题,改用传统的互斥同步可能会比原子类更高效。

  3. 栈封闭
    多个线程访问同一个方法的局部变量时,不会出现线程安全问题,因为局部变量存储在虚拟机栈中,属于线程私有的。

  4. 线程本地存储(Thread Local Storage)
    如果一段代码中所需要的数据必须与其他代码共享,那就看看这些共享数据的代码是否能保证在同一个线程中执行。如果能保证,我们就可以把共享数据的可见范围限制在同一个线程之内,这样,无须同步也能保证线程之间不出现数据争用的问题。
    符合这种特点的应用并不少见,大部分使用消费队列的架构模式(如“生产者-消费者”模式)都会将产品的消费过程尽量在一个线程中消费完。其中最重要的一个应用实例就是经典 Web 交互模型中的“一个请求对应一个服务器线程”(Thread-per-Request)的处理方式,这种处理方式的广泛应用使得很多 Web 服务端应用都可以使用线程本地存储来解决线程安全问题。
    可以使用 java.lang.ThreadLocal 类来实现线程本地存储功能。
    对于以下代码,thread1 中设置 threadLocal 为 1,而 thread2 设置 threadLocal 为 2。过了一段时间之后,thread1 读取 threadLocal 依然是 1,不受 thread2 的影响。
    ThreadLocal 从理论上讲并不是用来解决多线程并发问题的,因为根本不存在多线程竞争。
    在一些场景 (尤其是使用线程池) 下,由于 ThreadLocal.ThreadLocalMap 的底层数据结构导致 ThreadLocal 有内存泄漏的情况,应该尽可能在每次使用 ThreadLocal 后手动调用 remove(),以避免出现 ThreadLocal 经典的内存泄漏甚至是造成自身业务混乱的风险。
    #3. 可重入代码(Reentrant Code)
    这种代码也叫做纯代码(Pure Code),可以在代码执行的任何时刻中断它,转而去执行另外一段代码(包括递归调用它本身),而在控制权返回后,原来的程序不会出现任何错误。
    可重入代码有一些共同的特征,例如不依赖存储在堆上的数据和公用的系统资源、用到的状态量都由参数中传入、不调用非可重入的方法等。

  5. 锁优化
    这里的锁优化主要是指 JVM 对 synchronized 的优化。
    #自旋锁
    互斥同步进入阻塞状态的开销都很大,应该尽量避免。在许多应用中,共享数据的锁定状态只会持续很短的一段时间。自旋锁的思想是让一个线程在请求一个共享数据的锁时执行忙循环(自旋)一段时间,如果在这段时间内能获得锁,就可以避免进入阻塞状态。
    自旋锁虽然能避免进入阻塞状态从而减少开销,但是它需要进行忙循环操作占用 CPU 时间,它只适用于共享数据的锁定状态很短的场景。
    在 JDK 1.6 中引入了自适应的自旋锁。自适应意味着自旋的次数不再固定了,而是由前一次在同一个锁上的自旋次数及锁的拥有者的状态来决定。
    锁消除
    锁消除是指对于被检测出不可能存在竞争的共享数据的锁进行消除。
    锁消除主要是通过逃逸分析来支持,如果堆上的共享数据不可能逃逸出去被其它线程访问到,那么就可以把它们当成私有数据对待,也就可以将它们的锁进行消除。
    对于一些看起来没有加锁的代码,其实隐式的加了很多锁。例如下面的字符串拼接代码就隐式加了锁:
    锁粗化
    如果一系列的连续操作都对同一个对象反复加锁和解锁,频繁的加锁操作就会导致性能损耗。
    上一节的示例代码中连续的 append() 方法就属于这类情况。如果虚拟机探测到由这样的一串零碎的操作都对同一个对象加锁,将会把加锁的范围扩展(粗化)到整个操作序列的外部。对于上一节的示例代码就是扩展到第一个 append() 操作之前直至最后一个 append() 操作之后,这样只需要加锁一次就可以了。
    #轻量级锁
    JDK 1.6 引入了偏向锁和轻量级锁,从而让锁拥有了四个状态:无锁状态(unlocked)、偏向锁状态(biasble)、轻量级锁状态(lightweight locked)和重量级锁状态(inflated)。
    以下是 HotSpot 虚拟机对象头的内存布局,这些数据被称为 Mark Word。其中 tag bits 对应了五个状态,这些状态在右侧的 state 表格中给出。除了 marked for gc 状态,其它四个状态已经在前面介绍过了。

下图左侧是一个线程的虚拟机栈,其中有一部分称为 Lock Record 的区域,这是在轻量级锁运行过程创建的,用于存放锁对象的 Mark Word。而右侧就是一个锁对象,包含了 Mark Word 和其它信息。

轻量级锁是相对于传统的重量级锁而言,它使用 CAS 操作来避免重量级锁使用互斥量的开销。对于绝大部分的锁,在整个同步周期内都是不存在竞争的,因此也就不需要都使用互斥量进行同步,可以先采用 CAS 操作进行同步,如果 CAS 失败了再改用互斥量进行同步。
当尝试获取一个锁对象时,如果锁对象标记为 0 01,说明锁对象的锁未锁定(unlocked)状态。此时虚拟机在当前线程的虚拟机栈中创建 Lock Record,然后使用 CAS 操作将对象的 Mark Word 更新为 Lock Record 指针。如果 CAS 操作成功了,那么线程就获取了该对象上的锁,并且对象的 Mark Word 的锁标记变为 00,表示该对象处于轻量级锁状态。

如果 CAS 操作失败了,虚拟机首先会检查对象的 Mark Word 是否指向当前线程的虚拟机栈,如果是的话说明当前线程已经拥有了这个锁对象,那就可以直接进入同步块继续执行,否则说明这个锁对象已经被其他线程线程抢占了。如果有两条以上的线程争用同一个锁,那轻量级锁就不再有效,要膨胀为重量级锁。
#偏向锁
偏向锁的思想是偏向于让第一个获取锁对象的线程,这个线程在之后获取该锁就不再需要进行同步操作,甚至连 CAS 操作也不再需要。
当锁对象第一次被线程获得的时候,进入偏向状态,标记为 1 01。同时使用 CAS 操作将线程 ID 记录到 Mark Word 中,如果 CAS 操作成功,这个线程以后每次进入这个锁相关的同步块就不需要再进行任何同步操作。
当有另外一个线程去尝试获取这个锁对象时,偏向状态就宣告结束,此时撤销偏向(Revoke Bias)后恢复到未锁定状态或者轻量级锁状态。

  1. 线程池
    线程池有七大参数, 分别是核心线程数, 最大线程数, 线程保持存货时间, 时间单位, 阻塞队列, 线程工厂, 拒绝策略.
    一个任务进来,先判断当前线程池中的核心线程数是否小于corePoolSize。小于的话会直接创建一个核心线程去提交业务。如果核心线程数达到限制,那么接下来的任务会被放入阻塞队列中排队等待执行。当核心线程数达到限制且阻塞队列已满,开始创建非核心线程来执行阻塞队列中的 业务。当线程数达到了maximumPoolSize且阻塞队列已满,那么会采用拒绝策略处理后来的业务。

CPU密集型业务:N+1
IO密集型业务:2N+1

  1. 可重入锁? 它用来解决什么问题?
    可重入是多线程并发编程里面一个比较重要的概念,
    简单来说,就是在运行的某个函数或者代码,因为抢占资源或者中断等原因导致函数或者代码的运行中断,
    等待中断程序执行结束后,重新进入到这个函数或者代码中运行,并且运行结果不会受到影响,那么这个函数或者代码就是可重入的。
    而可重入锁,简单来说就是一个线程如果抢占到了互斥锁资源,在锁释放之前再去竞争同一把锁的时候,不需要等待,只需要记录重入次数。
    在多线程并发编程里面,绝大部分锁都是可重入的,比如Synchronized、ReentrantLock等,但是也有不支持重入的锁,比如JDK8里面提供的读写锁StampedLock。

锁的可重入性,主要解决的问题是避免线程死锁的问题。
因为一个已经获得同步锁X的线程,在释放锁X之前再去竞争锁X的时候,相当于会出现自己要等待自己释放锁,这很显然是无法成立的。
以上就是我对这个问题的理解。
16. ConcurrentHashMap
ConcurrentHashMap是底层是由链表和红黑树来构成的,当我们去初始化一个ConcurrentHashMap实例的时候, 默认会初始化一个长度等于16的数组, 由于ConcurrentHashMap的核心仍然是hash表, 所以也会存在hash冲突的问题, 所以呢, ConcurrentHashMap采用链式寻址的方式来解决哈希表的冲突, 当hash冲突比较多的时候, 会造成链表长度较长的问题, 所以这种情况下会使得ConcurrentHashMap中的一个数组元素的查询复杂度会增加, 所以在jdk8中引入了红黑树这样一个机制, 当数组长度大于64, 并且链表的长度大于等于8的时候, 单向链表就会转化成红黑树, 另外呢?随着ConcurrentHashMap的动态扩容, 一旦链表的长度小于8, 红黑树会退化成单向链表,
2.ConcurrentHashMap的基本功能: ConcurrentHashMap的本质是一个HashMap,因此功能和HashMap是一样的, 但是, ConcurrentHashMap在HashMap的基础上提供了并发安全的一个实现, 并发安全的实现主要是通过对于Node节点去加锁来保证数据更新的安全性, 当数组长度不够的时候, ConcurrentHashMap需要对数组进行扩容, 而在扩容的时间上, ConcurrentHashMap引入了多线程并发扩容的一个实现, 简单来说就是多个线程对原始数组进行分片, 每个线程去负责一个分片的数据迁移, 从而在整体上提升了在扩容过程的数据迁移的一个效率,
第三, ConcurrentHashMap它有一个size()方法来获取部的元素个数, 而在多线程并发场景中在保证原子性的前提下, 去实现元素个数的累加, 性能是非常低的, 所以ConcurrentHashMap在这个方面, 做了两个点的优化, 第一个点是当线程竞争不激烈的时候, 直接采用CAS的方式来实现元素个数的一个原子递增. 第二, 如果线程竞争比较激烈的情况下, 使用一个数组来维护元素个数, 如果要增加部的元素个数的时候, 直接从数组中随机选择一个, 再通过CAS算法来实现原子递增, 它的核心思想是引入数组来实现对并发更新的一个负载, 以上呢就是我对这个问题的理解 .

  1. WeakHashMap
    存储结构
    WeakHashMap 的 Entry 继承自 WeakReference,被 WeakReference 关联的对象在下一次垃圾回收时会被回收。
    WeakHashMap 主要用来实现缓存,通过使用 WeakHashMap 来引用缓存对象,由 JVM 对这部分缓存进行回收。
    private static class Entry<K,V> extends WeakReference implements Map.Entry<K,V>

ConcurrentCache
Tomcat 中的 ConcurrentCache 使用了 WeakHashMap 来实现缓存功能。
ConcurrentCache 采取的是分代缓存:

  • 经常使用的对象放入 eden 中,eden 使用 ConcurrentHashMap 实现,不用担心会被回收(伊甸园);
  • 不常用的对象放入 longterm,longterm 使用 WeakHashMap 实现,这些老对象会被垃圾收集器回收。
  • 当调用 get() 方法时,会先从 eden 区获取,如果没有找到的话再到 longterm 获取,当从 longterm 获取到就把对象放入 eden 中,从而保证经常被访问的节点不容易被回收。
  • 当调用 put() 方法时,如果 eden 的大小超过了 size,那么就将 eden 中的所有对象都放入 longterm 中,利用虚拟机回收掉一部分不经常使用的对象。
  1. 使用线程的方法
    有三种使用线程的方法:
  • 实现 Runnable 接口;
  • 实现 Callable 接口;
  • 继承 Thread 类。
    实现 Runnable 和 Callable 接口的类只能当做一个可以在线程中运行的任务,不是真正意义上的线程,因此最后还需要通过 Thread 来调用。可以理解为任务是通过线程驱动从而执行的。
    实现 Runnable 接口
    需要实现接口中的 run() 方法。
    使用 Runnable 实例再创建一个 Thread 实例,然后调用 Thread 实例的 start() 方法来启动线程。
    实现 Callable 接口
    与 Runnable 相比,Callable 可以有返回值,返回值通过 FutureTask 进行封装。
    继承 Thread 类
    同样也是需要实现 run() 方法,因为 Thread 类也实现了 Runable 接口。
    当调用 start() 方法启动一个线程时,虚拟机会将该线程放入就绪队列中等待被调度,当一个线程被调度时会执行该线程的 run() 方法。
    实现接口 VS 继承 Thread
    实现接口会更好一些,因为:
  • Java 不支持多重继承,因此继承了 Thread 类就无法继承其它类,但是可以实现多个接口;
  • 类可能只要求可执行就行,继承整个 Thread 类开销过大。
  1. Executor
    Executor 管理多个异步任务的执行,而无需程序员显式地管理线程的生命周期。这里的异步是指多个任务的执行互不干扰,不需要进行同步操作。
    主要有三种 Executor:
  • CachedThreadPool:一个任务创建一个线程;
  • FixedThreadPool:所有任务只能使用固定大小的线程;
  • SingleThreadExecutor:相当于大小为 1 的 FixedThreadPool。
    Daemon
    守护线程是程序运行时在后台提供服务的线程,不属于程序中不可或缺的部分。
    当所有非守护线程结束时,程序也就终止,同时会杀死所有守护线程。
    main() 属于非守护线程。
    在线程启动之前使用 setDaemon() 方法可以将一个线程设置为守护线程。

sleep()
Thread.sleep(millisec) 方法会休眠当前正在执行的线程,millisec 单位为毫秒。
sleep() 可能会抛出 InterruptedException,因为异常不能跨线程传播回 main() 中,因此必须在本地进行处理。线程中抛出的其它异常也同样需要在本地进行处理。

yield()
对静态方法 Thread.yield() 的调用声明了当前线程已经完成了生命周期中最重要的部分,可以切换给其它线程来执行。该方法只是对线程调度器的一个建议,而且也只是建议具有相同优先级的其它线程可以运行

  1. 中断
    InterruptedException
    通过调用一个线程的 interrupt() 来中断该线程,如果该线程处于阻塞、限期等待或者无限期等待状态,那么就会抛出 InterruptedException,从而提前结束该线程。但是不能中断 I/O 阻塞和 synchronized 锁阻塞。
    对于以下代码,在 main() 中启动一个线程之后再中断它,由于线程中调用了 Thread.sleep() 方法,因此会抛出一个 InterruptedException,从而提前结束线程,不执行之后的语句。

interrupted()
如果一个线程的 run() 方法执行一个无限循环,并且没有执行 sleep() 等会抛出 InterruptedException 的操作,那么调用线程的 interrupt() 方法就无法使线程提前结束。
但是调用 interrupt() 方法会设置线程的中断标记,此时调用 interrupted() 方法会返回 true。因此可以在循环体中使用 interrupted() 方法来判断线程是否处于中断状态,从而提前结束线程。

Executor 的中断操作
调用 Executor 的 shutdown() 方法会等待线程都执行完毕之后再关闭,但是如果调用的是 shutdownNow() 方法,则相当于调用每个线程的 interrupt() 方法。
以下使用 Lambda 创建线程,相当于创建了一个匿名内部线程。

如果只想中断 Executor 中的一个线程,可以通过使用 submit() 方法来提交一个线程,它会返回一个 Future<?> 对象,通过调用该对象的 cancel(true) 方法就可以中断线程

  1. 线程之间的协作
    join()
    在线程中调用另一个线程的 join() 方法,会将当前线程挂起,而不是忙等待,直到目标线程结束。
    对于以下代码,虽然 b 线程先启动,但是因为在 b 线程中调用了 a 线程的 join() 方法,b 线程会等待 a 线程结束才继续执行,因此最后能够保证 a 线程的输出先于 b 线程的输出。
    wait() notify() notifyAll()
    调用 wait() 使得线程等待某个条件满足,线程在等待时会被挂起,当其他线程的运行使得这个条件满足时,其它线程会调用 notify() 或者 notifyAll() 来唤醒挂起的线程。
    它们都属于 Object 的一部分,而不属于 Thread。
    只能用在同步方法或者同步控制块中使用,否则会在运行时抛出 IllegalMonitorStateException。
    使用 wait() 挂起期间,线程会释放锁。这是因为,如果没有释放锁,那么其它线程就无法进入对象的同步方法或者同步控制块中,那么就无法执行 notify() 或者 notifyAll() 来唤醒挂起的线程,造成死锁。
    wait() 和 sleep() 的区别
  • wait() 是 Object 的方法,而 sleep() 是 Thread 的静态方法;
  • wait() 会释放锁,sleep() 不会。
    await() signal() signalAll()
  • java.util.concurrent 类库中提供了 Condition 类来实现线程之间的协调,可以在 Condition 上调用 await() 方法使线程等待,其它线程调用 signal() 或 signalAll() 方法唤醒等待的线程。
  • 相比于 wait() 这种等待方式,await() 可以指定等待的条件,因此更加灵活。
  • 使用 Lock 来获取一个 Condition 对象。

三.3333333333333333333333 JVM

  1. 请介绍类加载过程,什么是双亲委派?
    这个问题需要从几个方面来回答, 首先我简单说下类的加载这样一个机制, 就是我们自己写的java文件,
    到最终运行它心房肥大要经过编译和类加载这两个阶段, 而编译的过程就是把.java文件编译成.class文件, 而类的加载就是把.class文件加载到JVM内存里面, 装载完成以后会得到一个Class对象, 我们就可以使用New关键字来实例化这个对象,
    而类的加载过程需要涉及到类加载器, JVM在运行的时候会产生三个类加载器, 这三个类加载器呢组成了一个层级关系, 每一个类加载器分别去加载不同作用范围的jar包,比如像Bootstrap ClassLoader, 它主要是负责Java核心类库的加载, 也就是lib包下面的一个rt.jar和resources.jar等等, Ext ClassLoader主要是负责lib/ext目录下的一个jar包和class文件.Application ClassLoader主要是负责当前应用里面classpath下面的所有jar包和类文件, 除了系统自己提供的类加载器以外, 还可以通过ClassLoader类来实现自定义加载器去满足一些特殊的场景需求,
    而父委托模型呢 就是按照类加载器的层级关系去逐层进行委派, 比如当我们要加载一个class文件的时候, 首先会去把这个class文件的查询, 和加载委派给父加载器去执行, 如果父加载器都无法加载, 姥尝试自己来加载这位一个class. 这样的设计好处我认为有两个, 第一个是安全性, 因为这种层级关系, 实际上代表的一只优先级, 也就是说所有的类加载优先要给到Bootstrap ClassLoader. 那么对于核心类库中的一些类呢就没有办法被破坏, 比如自己写一个java.lang.String最终还是会交给启动类加载器,再加上每个类加载器的本身的一个作用范围, 那么自己写的java.lang.String就没有办法去覆盖类库中的类. 第二个, 我认为这种层级关系的设计呢, 可以避免重复加载导致程序混乱的一些问题, 因为如果父加载器已经加载过了, 那么子加载器就没有必要再去加载了. 以上就是我对这个问题的理解.
  2. 对象的创建过程

在实例化对象的时候呢, JVM首先会去检查目标对象是否已经被加载并初始化, 如果没有, jvm需要去做的就是立刻去加载目标类, 然后去调用目标类的构造器完成目标类的初始化,
目标类的加载是通过类加载器来实现的, 主要就是把一个类加载到内存里面, 然后是初始化的过程, 这个步骤主要是对目标类里面的静态变量,成员变量,静态代码块进行初始化.
当目标类初始化以后, 就可以从常量池里面去找到对应的类元信息, 并且目标对象在大小在类加载完成之后呢就已经确定了,
所以这个时候就需要去为新创建的对象根据目标对象的大小在堆内存里面分配内存空间, 内存分配的方式一般有两种, 第一种是指针碰撞,第二各是空闲列表, jvm会根据java堆内存是否规整来决定分配方式, 接下来, jvm会把目标对象里边的普通成员变量初始为0值, 比如int初始化为0, String类型初始化为null,
然后, jvm还需要对目标对象的对象头做一些设置, 比如对象所属的类元信息, 对象的GC分代年龄,hashcode,锁标记等等.完成这些步骤以后对于jvm来说, 新对象的创建工作已经完成了,
但是对于java语言来说, 对象创建才算刚刚开始, 接下来要做的就是执行目标对象内部生成的init方法, 初始值成员变量的值, 执行构成块, 最后调用目标对象的构造方法, 去完成对象的创建, 其中init()方法是java文件编译之后在字节码文件里面去生成的, 它是一个实例构造器会把构造块变量初始化, 调用父类构造器等这样一些操作组织在一起, 所以调用init方法能够去完成这一系列的初始化动作, 以上就是我对这个问题的理解 .
3. 内存分配策略
#1. 对象优先在 Eden 分配
大多数情况下,对象在新生代 Eden 上分配,当 Eden 空间不够时,发起 Minor GC。
#2. 大对象直接进入老年代
大对象是指需要连续内存空间的对象,最典型的大对象是那种很长的字符串以及数组。
经常出现大对象会提前触发垃圾收集以获取足够的连续空间分配给大对象。
-XX:PretenureSizeThreshold,大于此值的对象直接在老年代分配,避免在 Eden 和 Survivor 之间的大量内存复制。
#3. 长期存活的对象进入老年代
为对象定义年龄计数器,对象在 Eden 出生并经过 Minor GC 依然存活,将移动到 Survivor 中,年龄就增加 1 岁,增加到一定年龄则移动到老年代中。
-XX:MaxTenuringThreshold 用来定义年龄的阈值。
#4. 动态对象年龄判定
虚拟机并不是永远要求对象的年龄必须达到 MaxTenuringThreshold 才能晋升老年代,如果在 Survivor 中相同年龄所有对象大小的总和大于 Survivor 空间的一半,则年龄大于或等于该年龄的对象可以直接进入老年代,无需等到 MaxTenuringThreshold 中要求的年龄。
#5. 空间分配担保
在发生 Minor GC 之前,虚拟机先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果条件成立的话,那么 Minor GC 可以确认是安全的。
如果不成立的话虚拟机会查看 HandlePromotionFailure 的值是否允许担保失败,如果允许那么就会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次 Minor GC;如果小于,或者 HandlePromotionFailure 的值不允许冒险,那么就要进行一次 Full GC。
4. Full GC 的触发条件
对于 Minor GC,其触发条件非常简单,当 Eden 空间满时,就将触发一次 Minor GC。而 Full GC 则相对复杂,有以下条件:
#1. 调用 System.gc()
只是建议虚拟机执行 Full GC,但是虚拟机不一定真正去执行。不建议使用这种方式,而是让虚拟机管理内存。
#2. 老年代空间不足
老年代空间不足的常见场景为前文所讲的大对象直接进入老年代、长期存活的对象进入老年代等。
为了避免以上原因引起的 Full GC,应当尽量不要创建过大的对象以及数组。除此之外,可以通过 -Xmn 虚拟机参数调大新生代的大小,让对象尽量在新生代被回收掉,不进入老年代。还可以通过 -XX:MaxTenuringThreshold 调大对象进入老年代的年龄,让对象在新生代多存活一段时间。
#3. 空间分配担保失败
使用复制算法的 Minor GC 需要老年代的内存空间作担保,如果担保失败会执行一次 Full GC。具体内容请参考上面的第 5 小节。
#4. JDK 1.7 及以前的永久代空间不足
在 JDK 1.7 及以前,HotSpot 虚拟机中的方法区是用永久代实现的,永久代中存放的为一些 Class 的信息、常量、静态变量等数据。
当系统中要加载的类、反射的类和调用的方法较多时,永久代可能会被占满,在未配置为采用 CMS GC 的情况下也会执行 Full GC。如果经过 Full GC 仍然回收不了,那么虚拟机会抛出 java.lang.OutOfMemoryError。
为避免以上原因引起的 Full GC,可采用的方法为增大永久代空间或转为使用 CMS GC。
#5. Concurrent Mode Failure
执行 CMS GC 的过程中同时有对象要放入老年代,而此时老年代空间不足(可能是 GC 过程中浮动垃圾过多导致暂时性的空间不足),便会报 Concurrent Mode Failure 错误,并触发 Full GC。
5. JVM参数调优
-Xms:初始堆大小,JVM启动的时候,给定堆空间大小。
-Xms3550m,设置JVM初始内存为3550m。此值可以设置与-Xmx相同,以避免每次垃圾回收完成后JVM重新分配内存。

-Xmx:最大堆大小,JVM运行过程中,如果初始堆空间不足的时候,最大可以扩展到多少。
-Xmx3550m:设置JVM最大可用内存为3550M。

-Xmn:设置年轻代大小。
整个堆大小 = 年轻代大小 + 老年代大小 + 持久代大小。持久代一般固定大小为64m,所以增大年轻代后,将会减小年老代大小。此值对系统性能影响较大,Sun官方推荐配置为整个堆的3/8。
-Xmn2g:设置年轻代大小为2G。

-Xss: 设置每个线程的Java栈大小。
JDK5.0以后每个线程Java栈大小为1M,以前每个线程堆栈大小为256K。根据应用的线程所需内存大小进行调整。在相同物理内存下,减小这个值能生成更多的线程。但是操作系统对一个进程内的线程数还是有限制的,不能无限生成,经验值在3000~5000左右。
-Xss128k:设置每个线程的堆栈大小为128k。

-XX:NewSize=n:设置年轻代大小。

-XX:NewRatio=n:设置年轻代(包括Eden和两个Survivor区)与年老代的比值。
比如设置为4,则年轻代与年老代所占比值为1:4,年轻代占整个堆栈的1/5

-XX:SurvivorRatio=n:年轻代中Eden区与两个Survivor区的比值。
注意Survivor区有两个。如:3,表示Eden:Survivor=3:2,一个Survivor区占整个年轻代的1/5。

-XX:MaxPermSize=n:设置永久代大小
-XX:MaxPermSize=16m:设置持久代大小为16m。

-XX:MaxTenuringThreshold=n:设置垃圾最大年龄。

四. 4444444444444444444444 Spring
1.循环依赖
A引用了B, B引用了A
/**
*一级缓存:
*单例对象的缓存: beanName - bean实例, 即所谓的单例池
*表示已经经历了完整的生命周期的Bean对象
/
private final Map<String, Object> singletonObjects = new ConcurrentHashMap<>(256);
/

*二级缓存:
*时期的单例对象的缓存: beanName - bean实例
*表示Bean的生命周期还没走完(Bean的属性还未填充)就把这个Bean存入二级缓存中
*也就是二级缓存存的是实例化但未初始化的bean
/
private final Map<String, Object> earlySingletonObjects = new HashMap<>(16);
/

*三级缓存:
*单例工厂的缓存: beanName - ObjectFactory
*表示存放生成bean的工厂
**/
private final Map<String, ObjectFactory<?>> singletonFactoris = new HashMap<>(16);

过程:

  1. A创建过程中需要B, 于是A将自己放到三级缓存里面, 然后去实例化B
  2. B实例化的时候发现需要A, 于是B先查一级缓存, 没有, 再查二级缓存, 还是没有, 再查三级缓存, 找到了A, 然后把三级缓存里面的这个A放到二级缓存里面, 并删除三级缓存里面的A
  3. B顺利初始化完毕, 将自己放到一级缓存里面(此时B里面的A依然是创建状态), 然后回来接着创建A, 此时B已经创建结束, 直接从一级缓存里面拿到B, 注入到A, 然后完成创建, 并且将A放到一级缓存里面.
    Spring为什么采用三级缓存的方式解决循环依赖?用二级缓存不可以吗?
    原文链接:https://blog.csdn.net/qq_33468007/article/details/107814203
    假如A和B都是被AOP增强的对象, 现在的A进行实例化, 并用A对B进行赋值, (这里的A的话是原生对象), 此时B开始创建并对A赋值, 此时, B注入的原生的A对象, B注入完成后被AOP增强, 所以B成为了代理对象, 然后A注入B的代理对象. 这里A注入完毕了, A通过AOP增强后也成为了代理对象. 这里就有很大的问题, 我们的A是代理对象, 而B注入的却是A的原生对象.
    假如我们的user1、user2都是被AOP增强的对象,(AOP的操作周期是在依赖注入完成之后)现在user1进行实例化,并且user1对user2赋值,注意这里的user1是原生的对象,此时user2开始创建并对user1赋值,此时按照我们二级缓存的方式,user1会脱离循环执行earlySingletonObjects.get方法,然后user2注入的对象是原生的user1对象,user2注入完后由于被AOP增强,所以User2成为了代理对象,所以此时的user1注入的是user2的代理对象,此时User1注入完毕了,user1按照AOP增强也成为了代理对象,这里就会有很大的问题,我们的user1是代理对象,而user2注入的user1确实原生对象,那么Spring之如何解决的呢?
    三级缓存的精妙
    如果一个类是需要被增强的类,那么这个类会被提前增强,而且这里为什么会有AOP的缓存?道理也很简单,因此如果SpringAOP提前对user进行了增强,那么在依赖注入后的增强就会通过这个缓存判断是否已经被增强,这样就可以实现增强代码只实现一次,不重复增强user类,那么这就实现了注入的对象和原来的类是同一个。
    2.Spring-MVC执行流程

首先客户发起请求http:localhost:80/hello, DispatcherServlet收到请求后, 通过HandlerMapping(处理器映射器)查找合适的处理器,
HandlerMapping返回处理器执行链HandlerExecutionChain(里边包含了Handler以及各种HandlerInterceptor.)
在前端控制器收到处理器执行链后, 就去找处理器适配器(HandlerAdapter), 处理器适配器开始执行处理器(又叫Controller)里边的处理器方法, 返回ModelAndView对象, 处理器适配器收到后又将ModelAndView返回给前端控制器,
前端控制器将其分发给视图解析器(ViewResolver)处理,
然后视图解析器返回View给前端控制器, 前端控制器收到后将Modle中数据填充到View中渲染视图. 最后就返回响应给客户端.
3.SpringBoot自动装配原理
自动装配, 简单来说, 就是自动去把第三方组件的Bean到ioc加载到容器里边, 不需要开发再去写bean相关的一个配置,在springboot 应用里面呢, 只需要在启动类上去加上@SpringbootApplication注解就可以去实现自动装配. 它是一个复合注解, 真正去实现自动装配的注解是@EnableAutoConfiguration.
自动装配的核心主要依靠三个关键技术, 第一个, 引入starter, 启动依赖组件的时候,这个组件里面必须要包含一个@Configuration配置类, 而在配置类里面, 我们需要通过@Bean这个注解去声明需要装配到ioc容器里面的Bean对象
第二个呢, 这个配置类是放在第三方的jar包里面,然后通过springboot中约定优于配置的这样的一个理念,去把这个配置类的全路径放在classpath:meta-inf/spring.factories文件里面, 这样的话springboot就可以知道第三方jar包里面这个配置类的位置,这个步骤主要是用到了spring里面SpringFactoriesLoader来完成的.
第三个, springboot拿到所有第三方jar包里面声明的配置类以后, 再通过spring提供的ImportSelecotr这样一个接口来实现对这些配置类的动态加载,从而去完成自动装配这样一个动作.
// 在我看来呢, springboot是约定优于配置这一理念下的一个产物, 所以在很多的地方,都会看到这一类的思想,它的出现可以让开发人员更加聚集在业务代码的编写上,而不需要去关心和业务无关的配置. 这种自动装配的思想在SpringFrameWork3.x版本的@Enable注解就已经有了实现的一个雏形. Enable注解是一个模块驱动的意思,也就是说我们只需要增加@Enable注解就能自动打开某个功能,而不需要针对这个功能去做Bean的配置, Enable的底层呢也是去帮我们自动去完成这样一个模块相关Bean的注入的, 以上就是我对springboot自动装配原理的一个理解.
springboot启动流程
一、SpringBoot启动的时候,会构造一个SpringApplication的实例,构造SpringApplication的时候会进行初始化的工作,初始化的时候会做以下几件事:

1、把参数sources设置到SpringApplication属性中,这个sources可以是任何类型的参数.
2、判断是否是web程序,并设置到webEnvironment的boolean属性中.
3、创建并初始化ApplicationInitializer,设置到initializers属性中 。
4、创建并初始化ApplicationListener,设置到listeners属性中 。
5、初始化主类mainApplicatioClass。

二、SpringApplication构造完成之后调用run方法,启动SpringApplication,run方法执行的时候会做以下几件事:

1、构造一个StopWatch计时器,用来记录SpringBoot的启动时间 。
2、初始化监听器,获取SpringApplicationRunListeners并启动监听,用于监听run方法的执行。
3、创建并初始化ApplicationArguments,获取run方法传递的args参数。
4、创建并初始化ConfigurableEnvironment(环境配置)。封装main方法的参数,初始化参数,写入到 Environment中,发布 ApplicationEnvironmentPreparedEvent(环境事件),做一些绑定后返回Environment。
5、打印banner和版本。
6、构造Spring容器(ApplicationContext)上下文。先填充Environment环境和设置的参数,如果application有设置beanNameGenerator(bean)、resourceLoader(加载器)就将其注入到上下文中。调用初始化的切面,发布ApplicationContextInitializedEvent(上下文初始化)事件。
7、SpringApplicationRunListeners发布finish事件。
8、StopWatch计时器停止计时,日志打印总共启动的时间。
9、发布SpringBoot程序已启动事件(started())
10、调用ApplicationRunner和CommandLineRunner
11、最后发布就绪事件ApplicationReadyEvent,标志着SpringBoot可以处理就收的请求了(running())
原文链接:https://blog.csdn.net/zsh2050/article/details/124514882
4.@Resource 和 @Autowired 的区别
@Autowired是根据type来匹配,@Resource可以根据name和type来匹配,默认是name匹配。
@Autowired是Spring定义的注解,@Resource是JSR 250规范里面定义的注解,而Spring对JSR 250规范提供了支持。
@Autowired如果需要支持name匹配,就需要配合@Primary或者@Qualifier来实现。

首先,@Autowired是Spring里面提供的一个注解,默认是根据类型来实现Bean的依赖注入。
@Autowired注解里面有一个required属性默认值是true,表示强制要求bean实例的注入,
在应用启动的时候,如果IOC容器里面不存在对应类型的Bean,就会报错。
当然,如果不希望自动注入,可以把这个属性设置成false。

其次呢, 如果在Spring IOC容器里面存在多个相同类型的Bean实例。由于@Autowired注解是根据类型来注入Bean实例的

所以Spring启动的时候,会提示一个错误,大概意思原本只能注入一个单实例Bean,
但是在IOC容器里面却发现有多个,导致注入失败。

当然,针对这个问题,我们可以使用 @Primary或者@Qualifier这两个注解来解决。
@Primary表示主要的bean,当存在多个相同类型的Bean的时候,优先使用声明了@Primary的Bean。
@Qualifier的作用类似于条件筛选,它可以根据Bean的名字找到需要装配的目标Bean。

[@Resource的作用详解 ] 几个字。
接下来,我再解释一下@Resource注解。
@Resource是JDK提供的注解,只是Spring在实现上提供了这个注解的功能支持。
它的使用方式和@Autowired完全相同,最大的差异于@Resource可以支持ByName和ByType两种注入方式。
如果使用name,Spring就根据bean的名字进行依赖注入,如果使用type,Spring就根据类型实现依赖注入。
如果两个属性都没配置,就先根据定义的属性名字去匹配,如果没匹配成功,再根据类型匹配。两个都没匹配到,就报错。

最后,我再总结一下。

  • @Autowired是根据type来匹配,@Resource可以根据name和type来匹配,默认是name匹配。
  • @Autowired是Spring定义的注解,@Resource是JSR 250规范里面定义的注解,而Spring对JSR 250规范提供了支持。
  • @Autowired如果需要支持name匹配,就需要配合@Primary或者@Qualifier来实现。
    以上就是我对这个问题的理解。
    5.Spring IoC的工作流程
    好的,这个问题我会从几个方面来回答。
  • IOC是什么
  • Bean的声明方式
  • IOC的工作流程
    IOC的全称是Inversion Of Control, 也就是控制反转,它的核心思想是把对象的管理权限交给容器。
    应用程序如果需要使用到某个对象实例,直接从IOC容器中去获取就行,这样设计的好处是降低了程序里面对象与对象之间的耦合性。
    使得程序的整个体系结构变得更加灵活。

Spring里面很多方式去定义Bean,比如XML里面的标签、@Service、@Component、@Repository、@Configuration配置类中的@Bean注解等等。
Spring在启动的时候,会去解析这些Bean然后保存到IOC容器里面。

Spring IOC的工作流程大致可以分为两个阶段。
第一个阶段,就是IOC容器的初始化
这个阶段主要是根据程序中定义的XML或者注解等Bean的声明方式
通过解析和加载后生成BeanDefinition,然后把BeanDefinition注册到IOC容器。

通过注解或者xml声明的bean都会解析得到一个BeanDefinition实体,实体中包含这个bean中定义的基本属性。
最后把这个BeanDefinition保存到一个Map集合里面,从而完成了IOC的初始化。
IoC容器的作用就是对这些注册的Bean的定义信息进行处理和维护,它IoC容器控制反转的核心。
第二个阶段,完成Bean初始化及依赖注入
然后进入到第二个阶段,这个阶段会做两个事情

  1. 通过反射针对没有设置lazy-init属性的单例bean进行初始化。
  2. 完成Bean的依赖注入。

第三个阶段,Bean的使用
通常我们会通过@Autowired或者BeanFactory.getBean()从IOC容器中获取指定的bean实例。
另外,针对设置layy-init属性以及非单例bean的实例化,是在每次获取bean对象的时候,调用bean的初始化方法来完成实例化的,并且Spring IOC容器不会去管理这些Bean。

以上就是我对这个问题的理解。

  1. 事务的传播机制
    在开发的时候,不仅需要考虑事务的隔离级别,还需要考虑事务的传播机制。
    在 spring 中,使用 @Transactional 将对应方法加入事务管理,如果在一个已经存在事务的方法中调用另一个有事务的方法
    /**
  • @author Peng Tao
  • @since 11.22.2021
    */
    public class TestServiceImpl implements TestService{
    @Override
    @Transactional(propagation = Propagation.REQUIRED)
    public void m1() {
    // 调用其他有 @Transactional 注解的方法
    // xxService.m2();
    }
    }

那么是使用的哪个方法的事务,也就是在事务在多个方法的调用中是如何传递的,是重新创建事务还是使用父方法的事务,父方法的回滚对子方法的事务是否有影响,子方法的回滚对父方法的事务又是否有影响?这些都是可以通过事务传播机制来决定的。
spring中事务传播7机制一共有七种,默认为 REQUIRED。
无法复制加载中的内容
常见使用场景有三种:
(1)父方法和子方法只要有一个抛出异常,两个方法都需要回滚。
(2)父方法的回滚不影响子方法的提交。
(3)当父方法回滚时,子方法也需要回滚;反之,如果子方法回滚,不影响父方法的提交。
这三种场景都有对应的隔离级别,使用这三种隔离级别能解决大部分的需求。
(1)REQUIRED
当两个方法的传播机制都是REQUIRED时,如果一旦发生回滚,两个方法都会回滚
(2)REQUIRES_NEW
当子方法传播机制为REQUIRES_NEW,会开启一个新的事务,并单独提交方法,所以父方法的回滚并不影响子方法事务提交
(3)NESTED
当父方法为REQUIRED,子方法为NESTED时,子方法开启一个嵌套事务;
当父方法回滚时,子方法也会回滚;反之,如果子方法回滚,则并不影响父方法的提交
7. 事务的失效
解答一:
三.Spring事务的失效场景

  1. 非 public 方法 事务的实现原理是代理增强,非 public 不能进行代理增强,不能进行 JDK 或者 CGLIB 代理。
    2.调用本类的方法(没有经过 Spring 的代理类,默认只有在外部调用时事务才会生效 )
    3.异常被吃
    4.默认捕捉RunTimeException, 如果别的异常也rollbake需要自己指定
  2. 我们知道事务就是依赖于数据库的,所以数据库的存储引擎不支持肯定也是失效的!!比如 myIsam
    解答二:
    (1)spring 默认取决于是否抛出runtimeException决定是否会滚。所以如果在方法中有 try、catch 处理,那么try里面的代码块就脱离了事务的管理,若要事务生效需要在 catch 中throw new RuntimeException。
    (2)因为 spring 通过代理来实现事务的管理,所以直接调用同类中的其他方法导致的自调用,没有调用到代理对象,第二个方法的 @Transactional 注解会失效,可以直接通过拆分类、通过从容器获取 bean 来调用方法、自己注入自己来解决自调用 @Transactional 注解失效。
  3. Bean的生命周期
    如果bean有后置处理器,bean生命周期有七步:
    1.通过构造方法创建bean实例(无参构造);
    2.为bean的属性设置值和对其它bean引用(调用set方法);
    3.把bean实例传递bean后置处理器的方法postProcessBeforeInitialization;
    4.调用bean的初始化方法(需要进行配置初始化的方法);
    5.把bean实例传递bean后置处理的方法postProcessAfterInitialization;
    6.bean可以使用了(对象获取到了);
    7.当容器关闭的时候,调用bean的销毁的方法(需要进行配置销毁的方法)。
  4. Beanfactory和Factorybean
    BeanFactory:Bean 工厂,是一个工厂(Factory),我们Spring IOC 容器的最顶层接口就是这个BeanFactory,它的作用是管理Bean,即实例化、定位、配置应用程序中的对象及建立这些对象间的依赖。

FactoryBean:工厂Bean,是一个Bean,作用是产生其他bean 实例。通常情况下,这种Bean 没有什么特别的要求,仅需要提供一个工厂方法,该方法用来返回其他Bean 实例。通常情况下,Bean 无须自己实现工厂模式,Spring 容器担任工厂角色;但少数情况下,容器中的Bean 本身就是工厂,其作用是产生其它Bean 实例。
————————————————
版权声明:本文为CSDN博主「Leon_Jinhai_Sun」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/Leon_Jinhai_Sun/article/details/109751728
五. 555555555555555555555 Redis
1.数据一致性
作者:跟着Mic学架构
链接:https://zhuanlan.zhihu.com/p/504105962

高手:
一般情况下,Redis用来实现应用和数据库之间读操作的缓存层,主要目的是减少数据库IO,还可以提升数据的IO性能。
这是它的整体架构。
当应用程序需要去读取某个数据的时候,首先会先尝试去Redis里面加载,如果命中就直接返回。如果没有命中,就从数据库查询,查询到数据后再把这个数据缓存到Redis里面。

在这样一个架构中,会出现一个问题,就是一份数据,同时保存在数据库和Redis里面,当数据发生变化的时候,需要同时更新Redis和Mysql,由于更新是有先后顺序的,并且它不像Mysql中的多表事务操作,可以满足ACID特性。所以就会出现数据一致性问题。

在这种情况下,能够选择的方法只有几种。

  1. 先更新数据库,再更新缓存
  2. 先删除缓存,再更新数据库
    如果先更新数据库,再更新缓存,如果缓存更新失败,就会导致数据库和Redis中的数据不一致。

如果是先删除缓存,再更新数据库,理想情况是应用下次访问Redis的时候,发现Redis里面的数据是空的,就从数据库加载保存到Redis里面,那么数据是一致的。但是在极端情况下,由于删除Redis和更新数据库这两个操作并不是原子的,所以这个过程如果有其他线程来访问,还是会存在数据不一致问题。

所以,如果需要在极端情况下仍然保证Redis和Mysql的数据一致性,就只能采用最终一致性方案。
比如基于RocketMQ的可靠性消息通信,来实现最终一致性。

还可以直接通过Canal组件,监控Mysql中binlog的日志,把更新后的数据同步到Redis里面。

因为这里是基于最终一致性来实现的,如果业务场景不能接受数据的短期不一致性,那就不能使用这个方案来做。
以上就是我对这个问题的理解。

2.缓存雪崩
缓存雪崩,就是存储在缓存里面的大量数据,在同一个时刻全部过期,
原本缓存组件抗住的大部分流量全部请求到了数据库。
导致数据库压力增加造成数据库服务器崩溃的现象。

导致缓存雪崩的主要原因,我认为有两个:

  1. 缓存中间件宕机,当然可以对缓存中间件做高可用集群来避免。
  2. 缓存中大部分key都设置了相同的过期时间,导致同一时刻这些key都过期了。对于这样的情况,可以在失效时间上增加一个1到5分钟的随机值。
    缓存穿透问题,表示是短时间内有大量的不存在的key请求到应用里面,而这些不存在的key在缓存里面又找不到,从而全部穿透到了数据库,造成数据库压力。
    我认为这个场景的核心问题是针对缓存的一种攻击行为,因为在正常的业务里面,即便是出现了这样的情况,由于缓存的不断预热,影响不会很大。
    而攻击行为就需要具备时间是的持续性,而只有key确实在数据库里面也不存在的情况下,才能达到这个目的,所以,我认为有两个方法可以解决:
  3. 把无效的key也保存到Redis里面,并且设置一个特殊的值,比如“null”,这样的话下次再来访问,就不会去查数据库了。
  4. 但是如果攻击者不断用随机的不存在的key来访问,也还是会存在问题,所以可以用布隆过滤器来实现,在系统启动的时候把目标数据全部缓存到布隆过滤器里面,当攻击者用不存在的key来请求的时候,先到布隆过滤器里面查询,如果不存在,那意味着这个key在数据库里面也不存在。
    布隆过滤器还有一个好处,就是它采用了bitmap来进行数据存储,占用的内存空间很少。

不过,在我看来,您提出来的这个问题,有点过于放大了它带来的影响。
首先,在一个成熟的系统里面,对于比较重要的热点数据,必然会有一个专门缓存系统来维护,同时它的过期时间的维护必然和其他业务的key会有一定的差别。而且非常重要的场景,我们还会设计多级缓存系统。
其次,即便是触发了缓存雪崩,数据库本身的容灾能力也并没有那么脆弱,数据库的主从、双主、读写分离这些策略都能够很好的缓解并发流量。
最后,数据库本身也有最大连接数的限制,超过限制的请求会被拒绝,再结合熔断机制,也能够很好的保护数据库系统,最多就是造成部分用户体验不好。
另外,在程序设计上,为了避免缓存未命中导致大量请求穿透到数据库的问题,还可以在访问数据库这个环节加锁。虽然影响了性能,但是对系统是安全的。

总而言之,我认为解决的办法很多,具体选择哪种方式,还是看具体的业务场景。
以上就是我对这个问题的理解。
3.RDB 和 AOF 的实现原理、优缺点

跟着Mic学架构
面试资料@公众号:跟着Mic学架构
Hi,大家好,我是Mic。
一个工作了5年的粉丝私信我,最近面试碰到很多Redis相关的问题。
其中一个面试官问他Redis里面的持久化机制,没有回答得很好。
希望我帮他系统回答一下。
关于Redis里面的RDB和AOF两种持久化机制的原理和优缺点这个问题。
下面看看普通人和高手的回答。
普通人:
RDB是一种快照的方式然后AOF是一种就是指令追加的方式。
它们两个都是Redis里面的一种数据持久化的一个机制。
RDB它是快照嘛,快照的话它的那个时间间隔它会有一个配置但是这种配置过程中就是有可能会导致说我的数据丢失的一个问题。
但是AOF它是那种就是追加的方式嘛,所以它的一个数据安全性可能会比RDB会好一点。
高手:
好的,关于这个问题,我从几个点来回答。
首先,Redis本身是一个基于Key-Value结构的内存数据库,为了避免Redis故障导致数据丢失的问题,所以提供了RDB和AOF两种持久化机制。
RDB是通过快照的方式来实现持久化的,也就是说会根据快照的触发条件,把内存里面的数据快照写入到磁盘,
以二进制的压缩文件进行存储。

RDB快照的触发方式有很多,比如

  • 执行bgsave命令触发异步快照,执行save命令触发同步快照,同步快照会阻塞客户端的执行指令。
  • 根据redis.conf文件里面的配置,自动触发bgsave
  • 主从复制的时候触发
    AOF持久化,它是一种近乎实时的方式,把Redis Server执行的事务命令进行追加存储。简单来说,
    就是客户端执行一个数据变更的操作,Redis Server就会把这个命令追加到aof缓冲区的末尾,
    然后再把缓冲区的数据写入到磁盘的AOF文件里面,至于最终什么时候真正持久化到磁盘,是根据刷盘的策略来决定的。

另外,因为AOF这种指令追加的方式,会造成AOF文件过大,带来明显的IO性能问题,所以Redis针对这种情况提供了
AOF重写机制,也就是说当AOF文件的大小达到某个阈值的时候,就会把这个文件里面相同的指令进行压缩。

因此,基于对RDB和AOF的工作原理的理解,我认为RDB和AOF的优缺点有两个。

  • RDB是每隔一段时间触发持久化,因此数据安全性低,AOF可以做到实时持久化,数据安全性较高
  • RDB文件默认采用压缩的方式持久化,AOF存储的是执行指令,所以RDB在数据恢复的时候性能比AOF要好
    在我看来,所谓优缺点,本质上其实是哪种方案更适合当前的应用场景而已。
    以上就是我对这个问题的理解!
  1. 再Hash
    dictht 是一个散列表结构,使用拉链法解决哈希冲突。
    Redis 的字典 dict 中包含两个哈希表 dictht,这是为了方便进行 rehash 操作。在扩容时,将其中一个 dictht 上的键值对 rehash 到另一个 dictht 上面,完成之后释放空间并交换两个 dictht 的角色。
    rehash 操作不是一次性完成,而是采用渐进方式,这是为了避免一次性执行过多的 rehash 操作给服务器带来过大的负担。
    渐进式 rehash 通过记录 dict 的 rehashidx 完成,它从 0 开始,然后每执行一次 rehash 都会递增。例如在一次 rehash 中,要把 dict[0] rehash 到 dict[1],这一次会把 dict[0] 上 table[rehashidx] 的键值对 rehash 到 dict[1] 上,dict[0] 的 table[rehashidx] 指向 null,并令 rehashidx++。
    在 rehash 期间,每次对字典执行添加、删除、查找或者更新操作时,都会执行一次渐进式 rehash。
    采用渐进式 rehash 会导致字典中的数据分散在两个 dictht 上,因此对字典的查找操作也需要到对应的 dictht 去执行。
  2. zset的底层数据结构
    有压缩列表和跳表他
    压缩列表
    压缩列表本质上就是一个数组, 只不是它正价了尾部的偏移量, 列表元素个数, 以及列表的结尾的标识,这样的话就有利于快速的寻找列表的首位的节点, 但寻找其他的元素, 仍然没有很高效, 只能一个个遍历

那什么时候会采用压缩列表400自己就在一方面。表呢?有序集合保存的元素数量小于128个,或者元素的长度小于64个字节。 就采跳是什么跳表在微表记上增加了过去。
跳表是什么?
跳表在链表9的基础上增加了多级索引,通过多级索引位置的一个跳转,实现了快速查找元素。
为什么用跳表而不用红黑树或者是二叉树呢?
原因一,因为它有一个很核心的操作叫做范围查找。跳表范围查找的效率比红黑树要高到,可以找到区间的起点,然后依次往后便利就行了。
原因二,T2表的实现比红黑树或者是平衡二叉树要简单的多,更容易实现。可以控制跳表的层级来有效的控制内存的消耗。

  1. 跳表
    跳表全称为跳跃列表,它允许快速查询,插入和删除一个有序连续元素的数据链表。跳跃列表的平均查找和插入时间复杂度都是O(logn)。快速查询是通过维护一个多层次的链表,且每一层链表中的元素是下一层链表元素的子集(见右边的示意图)。一开始时,算法在最稀疏的层次进行搜索,直至需要查找的元素在该层两个相邻的元素中间。这时,算法将跳转到下一个层次,重复刚才的搜索,直到找到需要查找的元素为止。

查询时间复杂度 logn, 插入删除logn
如果一个链表有 n 个结点,如果每两个结点抽取出一个结点建立索引的话,那么第一级索引的节点数大约就是 n/2,第二级索引的结点数大约为 n/4,以此类推第 m 级索引的节点数大约为 n/(2^m)。
假如一共有 m 级索引,第 m 级的节点数为两个,通过上边我们找到的规律,那么得出 n/(2^m)=2,从而求得 m=log(n)-1。如果加上原始链表,那么整个跳表的高度就是 log(n)。我们在查询跳表的时候,如果每一层都需要遍历 k 个结点,那么最终的时间复杂度就为 O(k*log(n))。
那这个 k 值为多少呢,按照我们每两个结点提取一个基点建立索引的情况,我们每一级最多需要遍历两个个结点,所以 k=2。为什么每一层最多遍历两个节点呢?
插入或者删除
我们想要为跳表插入或者删除数据,我们首先需要找到插入或者删除的位置,然后执行插入或删除操作,前边我们已经知道了,跳表的查询的时间复杂度为 O(logn),因为找到位置之后插入和删除的时间复杂度很低,为 O(1),所以最终插入和删除的时间复杂度也为 O(longn)。
随机函数
如果我们不停的向跳表中插入元素,就可能会造成两个索引点之间的结点过多的情况。结点过多的话,我们建立索引的优势也就没有了。所以我们需要维护索引与原始链表的大小平衡,也就是节点增多了,索引也相应增加,避免出现两个索引之间结点过多的情况,查找效率降低。
跳表是通过一个随机函数来维护这个平衡的,当我们向跳表中插入数据的的时候,我们可以选择同时把这个数据插入到索引里,那我们插入到哪一级的索引呢,这就需要随机函数,来决定我们插入到哪一级的索引中。
这样可以很有效的防止跳表退化,而造成效率变低。
7. 内存管理机制
在 Redis 中,并不是所有数据都一直存储在内存中,可以将一些很久没用的 value 交换到磁盘,而 Memcached 的数据则会一直在内存中。
Memcached 将内存分割成特定长度的块来存储数据,以完全解决内存碎片的问题。但是这种方式会使得内存的利用率不高,例如块的大小为 128 bytes,只存储 100 bytes 的数据,那么剩下的 28 bytes 就浪费掉了。
8.Redis延迟消息队列
延迟队列的使用场景
延迟队列的常见使用场景有以下几种:
超过 30 分钟未支付的订单,将会被取消
外卖商家超过 5 分钟未接单的订单,将会被取消
在平台注册但 30 天内未登录的用户,发短信提醒
等类似的应用场景,都可以使用延迟队列来实现。
常见实现方式
Redis 延迟队列实现的思路、优点:目前市面上延迟队列的实现方式基本分为三类,
第一类是通过程序的方式实现,例如 JDK 自带的延迟队列 DelayQueue,
第二类是通过 MQ 框架来实现,例如 RabbitMQ 可以通过 rabbitmq-delayed-message-exchange 插件来实现延迟队列,
第三类就是通过 Redis 的方式来实现延迟队列。
程序实现方式
优点
开发比较方便,可以直接在代码中使用
代码实现比较简单
缺点
不支持持久化保存
不支持分布式系统
MQ 实现方式
RabbitMQ 本身并不支持延迟队列,但可以通过添加插件 rabbitmq-delayed-message-exchange 来实现延迟队列。
优点
支持分布式
支持持久化
缺点
框架比较重,需要搭建和配置 MQ。
Redis 实现方式
Redis 是通过有序集合(ZSet)的方式来实现延迟消息队列的,ZSet 有一个 Score 属性可以用来存储延迟执行的时间。
优点
灵活方便,Redis 是互联网公司的标配,无序额外搭建相关环境;
可进行消息持久化,大大提高了延迟队列的可靠性;
分布式支持,不像 JDK 自身的 DelayQueue;
高可用性,利用 Redis 本身高可用方案,增加了系统健壮性。
缺点
需要使用无限循环的方式来执行任务检查,会消耗少量的系统资源。
结合以上优缺点,我们决定使用 Redis 来实现延迟队列,具体实现代码如下。
————————————————
版权声明:本文为CSDN博主「一起学python吧」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/myli_binbin/article/details/122327122
六. 666666666666666666666 MySQL
1.谈谈你对B树和B+树的理解
B树一种多路平衡查找树, 它满足二叉查找树规则, 同时它也可以有多个子树, 子树的数量取决于它的关键字的数量. (比如这个图中根节点有两个关键字3和5, 那么它能够拥有的子路量等于关键字的数量加上1, 因此从这个特征来看, 在存储同样数据量的情况下, 平衡二叉树的高度一定是要大于B树的.)
而B++树其实是在B树的基础上做了增强, 最大的区别主要有两点, 第一个, B树的数据存储在每个节点上, 而B+树中的数据是存储在叶子节点上, 并且通过链表的方式把叶子节点的所有数据进行一个连接.
第二个B+树的子路数量是等于它的关键字的数量,
B的存储结构中每个节点都会存储数据, B+树的所有数据是存储在叶子节点上的, 并且, 叶子结点的数据是用双向链表来关联,这个是属于InnoDB里面的一个特征, 第二, B树和B+树一般是应用在文件系统和数据库系统中, 用来去减少磁盘IO所带来的性能损耗的一个机制的, 以MySQL中的InnoDB为 例, 当我们去通过Select语句去查询一条数据的时候, InnoDB需要从磁盘上去读取数据, 而这个过程会涉及到磁盘IO以及磁盘的随机IO,
磁盘IO的性能是特别低的, 特别是随机磁盘的IO,
磁盘的工作原理: 首先, 系统会把数据的逻辑地址传给磁盘, 磁盘控制线路按照寻址的逻辑, 把逻辑地址翻译成物理地址, 也就是确定要读取的数据在哪个磁道, 哪个扇区. 为了读取这个扇区的数据 , 需要把磁头放在这个扇区上面, 为了实现这样一个点, 磁盘会不断的去旋转, 把目标的扇区转到磁头下面,
使得磁头能够找到对应的磁道, 这里会涉及到寻道的时间, 以及旋转时间的一个损耗, 很明显磁盘IO这个过程的性能开销是特别在的, 特别是查询的数据量比较多的情况下, 所以在InnoDB里面, 干脆对存储在磁盘上的数据建立一个索引, 然后把索引数据以及索引列对应的磁盘地址以B+树的方式来进行存储,
当我们需要查找目标数据的时候, 根据索引从B+树中去查找目标数据就行了, 由于B+树的子路比较多, 所以只需要较少次数的磁盘IO就能够查到目标数据. 第三个, 为什么要用B树或B+树来做索引结构呢?原因是AVL树的高度要比B树 或B+树高度要更高, 而高度就意味着磁盘IO的数量.所以为了减少磁盘IO的次数, 文件系统或者数据库才会使用B树或者B+树来做索引结构. 以上就是我对B树和B+树的一个理解
B+ 树原理
#1. 数据结构
B Tree 指的是 Balance Tree,也就是平衡树。平衡树是一颗查找树,并且所有叶子节点位于同一层。
B+ Tree 是基于 B Tree 和叶子节点顺序访问指针进行实现,它具有 B Tree 的平衡性,并且通过顺序访问指针来提高区间查询的性能。
在 B+ Tree 中,一个节点中的 key 从左到右非递减排列,如果某个指针的左右相邻 key 分别是 keyi 和 keyi+1,且不为 null,则该指针指向节点的所有 key 大于等于 keyi 且小于等于 keyi+1。

#2. 操作
进行查找操作时,首先在根节点进行二分查找,找到一个 key 所在的指针,然后递归地在指针所指向的节点进行查找。直到查找到叶子节点,然后在叶子节点上进行二分查找,找出 key 所对应的 data。
插入删除操作会破坏平衡树的平衡性,因此在进行插入删除操作之后,需要对树进行分裂、合并、旋转等操作来维护平衡性。
#3. 与红黑树的比较
红黑树等平衡树也可以用来实现索引,但是文件系统及数据库系统普遍采用 B+ Tree 作为索引结构,这是因为使用 B+ 树访问磁盘数据有更高的性能。
(一)B+ 树有更低的树高
平衡树的树高 O(h)=O(logdN),其中 d 为每个节点的出度。红黑树的出度为 2,而 B+ Tree 的出度一般都非常大,所以红黑树的树高 h 很明显比 B+ Tree 大非常多。
(二)磁盘访问原理
操作系统一般将内存和磁盘分割成固定大小的块,每一块称为一页,内存与磁盘以页为单位交换数据。数据库系统将索引的一个节点的大小设置为页的大小,使得一次 I/O 就能完全载入一个节点。
如果数据不在同一个磁盘块上,那么通常需要移动制动手臂进行寻道,而制动手臂因为其物理结构导致了移动效率低下,从而增加磁盘数据读取时间。B+ 树相对于红黑树有更低的树高,进行寻道的次数与树高成正比,在同一个磁盘块上进行访问只需要很短的磁盘旋转时间,所以 B+ 树更适合磁盘数据的读取。
(三)磁盘预读特性
为了减少磁盘 I/O 操作,磁盘往往不是严格按需读取,而是每次都会预读。预读过程中,磁盘进行顺序读取,顺序读取不需要进行磁盘寻道,并且只需要很短的磁盘旋转时间,速度会非常快。并且可以利用预读特性,相邻的节点也能够被预先载入。
2.MVCC
对mvcc的理解, 我觉得可以从数据库的三种并发场景来说, 第一种读和读的并发, 第二种是读和写的并发, 这种情况下, 可能会对数据库的数据造成一些问题, 第一个是事务隔离性问题, 第二个是会出现脏读,不可重复读,幻读的问题, 第三种是写和写的并发, 这种情况下可能会出数据更新丢失的问题, 而mvcc就是为了解决事务操作中并发安全问题的多版本控制技术.
//它通过数据库记录中的 隐式字段, Undo_log和Read view来实现的. MVCC主要解决三个问题, 第一个是读写并发阻塞的问题, 从而提高数据的并发处理能力, 第二个MVCC采用的乐观锁的实现, 降低了死锁的概率. 第三个解决了一致性读的问题, 也就事务启动的时候, 根据某个条件去读取到数据, 直到事务结束的时候再去执行相同的条件还是读到同一分数据, 不会发生变化, 而我们在使用MVCC的时候, 一般是根据业务场景来选择组合搭配, 乐观锁或悲观锁. 这两个个组合中, MVCC用来解决读写冲突, 乐观锁或者悲观锁用来解决写和写的冲突, 从而最大程度的去提高数据库的并发性能.
我的:
MVCC的实现主要依靠 隐藏字段(tr_id, roll_pointer) undo_log 和 Read_view
tr_id: 当前事务id
roll_pointer: 回溯指针 指向数据更新前的版本. 通过回滚指针, 就可以形成版本链. 因为第一个版本都有更早的版本(insert是没有的)
undo_log: 回溯日志
Read_view: 可读视图, 用来控制当前事务对不同版本的可见性.
ReadView主要有4个比较重要的内容:
1.creator_trx_id: 创建这个readview 的事务id(说明:只有在对表中的记录做改动时(执行INSERT、DELETE、UPDATE这些语句时)才会为事务分配事务id,否则在一个只读事务中的事务id值都默认为0。
2.trx_ids:活跃事务id列表. 表示在生成ReadView时系统中活跃的读写事务的事务id列表
3.up_limit_id: 活跃事务列表中最小的事务id
4.low_limit_id: 活跃事务列表中最大的事务id+1. 表示生成readView时系统中应该分配给下一个事务的id值
ReadView的规则
4.3 ReadView的规则
有了这个ReadView,这样在访问某条记录时,只需要按照下边的步骤判断记录的某个版本是否可见。

  • 如果被访问版本的trx_id属性值与ReadView中的 creator_trx_id 值相同,意味着当前事务在访问
    它自己修改过的记录,所以该版本可以被当前事务访问。
  • 如果被访问版本的trx_id属性值小于ReadView中的 up_limit_id 值,表明生成该版本的事务在当前
    事务生成ReadView前已经提交,所以该版本可以被当前事务访问。
  • 如果被访问版本的trx_id属性值大于或等于ReadView中的 low_limit_id 值,表明生成该版本的事
    务在当前事务生成ReadView后才开启,所以该版本不可以被当前事务访问。
  • 如果被访问版本的trx_id属性值在ReadView的 up_limit_id 和 low_limit_id 之间,那就需要判
    断一下trx_id属性值是不是在 trx_ids 列表中。
    a.如果在,说明创建ReadView时生成该版本的事务还是活跃的,该版本不可以被访问。
    b.如果不在,说明创建ReadView时生成该版本的事务已经被提交,该版本可以被访问。
    我的理解:
    在可重复读的隔离级别下, 一个事务内的select语句, 只会生成一次readView, 所以它解决了不可重复读的问题. 其次, 通过活跃事务id列表以及readView的版本可见性规则, 解决了幻读的一个问题
    在读已提交的隔离级别下, 一个事务内每次执行select语句都会重新获取readView, 所以他解决了脏读, 但是会有不可重复读的问题.
    事务分配事务id,否则在一个只读事务中的事务id值都默认为0。)

csnote的版本:
多版本并发控制
多版本并发控制(Multi-Version Concurrency Control, MVCC)是 MySQL 的 InnoDB 存储引擎实现隔离级别的一种具体方式,用于实现提交读和可重复读这两种隔离级别。而未提交读隔离级别总是读取最新的数据行,要求很低,无需使用 MVCC。可串行化隔离级别需要对所有读取的行都加锁,单纯使用 MVCC 无法实现。
#基本思想
在封锁一节中提到,加锁能解决多个事务同时执行时出现的并发一致性问题。在实际场景中读操作往往多于写操作,因此又引入了读写锁来避免不必要的加锁操作,例如读和读没有互斥关系。读写锁中读和写操作仍然是互斥的,而 MVCC 利用了多版本的思想,写操作更新最新的版本快照,而读操作去读旧版本快照,没有互斥关系,这一点和 CopyOnWrite 类似。
在 MVCC 中事务的修改操作(DELETE、INSERT、UPDATE)会为数据行新增一个版本快照。
脏读和不可重复读最根本的原因是事务读取到其它事务未提交的修改。在事务进行读取操作时,为了解决脏读和不可重复读问题,MVCC 规定只能读取已经提交的快照。当然一个事务可以读取自身未提交的快照,这不算是脏读。
#版本号

  • 系统版本号 SYS_ID:是一个递增的数字,每开始一个新的事务,系统版本号就会自动递增。
  • 事务版本号 TRX_ID :事务开始时的系统版本号。
    #Undo 日志
    MVCC 的多版本指的是多个版本的快照,快照存储在 Undo 日志中,该日志通过回滚指针 ROLL_PTR 把一个数据行的所有快照连接起来。
    例如在 MySQL 创建一个表 t,包含主键 id 和一个字段 x。我们先插入一个数据行,然后对该数据行执行两次更新操作。
    INSERT INTO t(id, x) VALUES(1, “a”);
    UPDATE t SET x=“b” WHERE id=1;
    UPDATE t SET x=“c” WHERE id=1;

因为没有使用 START TRANSACTION 将上面的操作当成一个事务来执行,根据 MySQL 的 AUTOCOMMIT 机制,每个操作都会被当成一个事务来执行,所以上面的操作总共涉及到三个事务。快照中除了记录事务版本号 TRX_ID 和操作之外,还记录了一个 bit 的 DEL 字段,用于标记是否被删除。

INSERT、UPDATE、DELETE 操作会创建一个日志,并将事务版本号 TRX_ID 写入。DELETE 可以看成是一个特殊的 UPDATE,还会额外将 DEL 字段设置为 1。
#ReadView
MVCC 维护了一个 ReadView 结构,主要包含了当前系统未提交的事务列表 TRX_IDs {TRX_ID_1, TRX_ID_2, …},还有该列表的最小值 TRX_ID_MIN 和 TRX_ID_MAX。

在进行 SELECT 操作时,根据数据行快照的 TRX_ID 与 TRX_ID_MIN 和 TRX_ID_MAX 之间的关系,从而判断数据行快照是否可以使用:

  • TRX_ID < TRX_ID_MIN,表示该数据行快照时在当前所有未提交事务之前进行更改的,因此可以使用。
  • TRX_ID > TRX_ID_MAX,表示该数据行快照是在事务启动之后被更改的,因此不可使用。
  • TRX_ID_MIN <= TRX_ID <= TRX_ID_MAX,需要根据隔离级别再进行判断:
    • 提交读:如果 TRX_ID 在 TRX_IDs 列表中,表示该数据行快照对应的事务还未提交,则该快照不可使用。否则表示已经提交,可以使用。
    • 可重复读:都不可以使用。因为如果可以使用的话,那么其它事务也可以读到这个数据行快照并进行修改,那么当前事务再去读这个数据行得到的值就会发生改变,也就是出现了不可重复读问题。
      在数据行快照不可使用的情况下,需要沿着 Undo Log 的回滚指针 ROLL_PTR 找到下一个快照,再进行上面的判断。
      #快照读与当前读
      #1. 快照读
      MVCC 的 SELECT 操作是快照中的数据,不需要进行加锁操作。
      SELECT * FROM table …;

#2. 当前读
MVCC 其它会对数据库进行修改的操作(INSERT、UPDATE、DELETE)需要进行加锁操作,从而读取最新的数据。可以看到 MVCC 并不是完全不用加锁,而只是避免了 SELECT 的加锁操作。
INSERT;
UPDATE;
DELETE;

在进行 SELECT 操作时,可以强制指定进行加锁操作。以下第一个语句需要加 S 锁,第二个需要加 X 锁。
SELECT * FROM table WHERE ? lock in share mode;
SELECT * FROM table WHERE ? for update;

3.Mysql语句执行
二.执行流程
1.客户端发送一条查询给服务器:
客户端发起一条Query请求,服务器端的‘连接管理模块’接收请求
将请求转发到‘连接进/线程模块’
调用‘用户模块’来进行授权检查
通过检查后,‘连接进/线程模块’从‘线程连接池’中取出空闲的被缓存的连接线程和客户端请求对接,如果失败则创建一个新的连接请求
2.处理请求:
1.服务器先检查查询缓存, 通过一个大小写敏感的哈希查找判断查询是否命中查询缓存的数据
如果命中了缓存,用户权限没有问题,MySQL直接从缓存中拿结果返回给客户端
如果没有命中缓存,则进入下一阶段
2.查询优化处理(解析SQL、预处理、优化SQL的执行计划),将SQL转化成一个执行计划
解析和预处理:生成一棵解析树(《编译原理》的知识),MySQL按照其语法对解析树进行验证和解析查询。判断语法是否合法。这里可以对比一下存储过程和 PHP 或者 Java 的预处理过程,它们就是因为存储了预处理过程的结果,所以可以达到 SQL 的拼接和提高一些效率。
优化器和执行计划:将语法树转化为执行计划(子任务),并选择成本尽量小的执行计划。
执行计划 MySQL会生成一个指令树,然后通过存储引擎完成这棵树并返回结果
3.查询执行引擎
查询执行引擎则根据执行计划来完成整个查询。在执行计划时,存储引擎通过调用实现的接口来完成
3.返回结果
如果查询可以被缓存,MySQL将结果存放到查询缓存里。
MySQL将结果集返回给客户端是一个逐步返回的过程;数据库开始产生第一个结果时,就可以开始向服务器返回结果集
使用MySQL客户端、服务器通信协议进行封包
通过Tcp协议传输数据
实际上mysql执行的每一步都比较复杂,具体的过程如下:
1.查询状态
对于mysql连接,任何时刻都有一个状态,该状态表示了mysql当前正在做什么。使用show full processlist命令查看当前状态。在一个查询生命周期中,状态会变化很多次,下面是这些状态的解释:

sleep:线程正在等待客户端发送新的请求;
query:线程正在执行查询或者正在将结果发送给客户端;
locked:在mysql服务器层,该线程正在等待表锁。在存储引擎级别实现的锁,例如InnoDB的行锁,并不会体现在线程状态中。对于MyISAM来说这是一个比较典型的状态。
analyzing and statistics:线程正在收集存储引擎的统计信息,并生成查询的执行计划;
copying to tmp table:线程在执行查询,并且将其结果集复制到一个临时表中,这种状态一般要么是做group by操作,要么是文件排序操作,或者union操作。如果这个状态后面还有on disk标记,那表示mysql正在将一个内存临时表放到磁盘上。
sorting Result:线程正在对结果集进行排序。
sending data:线程可能在多个状态间传送数据,或者在生成结果集,或者在想客户端返回数据。
2.查询缓存
在解析一个查询语句之前,如果查询缓存是打开的,那么mysql会优先检查这个查询是否命中查询缓存中的数据。这个检查是通过一个对大小写敏感的哈希查找实现的。查询和缓存中的查询即使只有一个字节不同,那也不会匹配缓存结果,这种情况下查询就会进入下一阶段的处理。

如果当前的查询恰好命中了查询缓存,那么在返回查询结果之前mysql会检查一次用户权限。这仍然是无须解析查询SQL语句的,因为在查询缓存中已经存放了当前 查询需要访问的表信息。如果权限没有问题,mysql会跳过所有其他阶段,直接从缓存中拿到结果并返回给客户端。这种情况下,查询不会被解析,不用生成执行计划,不会被执行。

3.查询优化处理
查询的生命周期的下一步是将一个SQL转换成一个执行计划,mysql在依照这个执行计划和存储引擎进行交互。这包含多个子阶段:解析SQL、预处理、优化SQL执行计划。这个过程中任何错误都可能终止查询。

语法解析器和预处理:首先mysql通过关键字将SQL语句进行解析,并生成一颗对应的“解析树”。mysql解析器将使用mysql语法规则验证和解析查询;预处理器则根据一些mysql规则进一步检查解析数是否合法。
查询优化器:当语法树被认为是合法的了,并且由优化器将其转化成执行计划。一条查询可以有很多种执行方式,最后都返回相同的结果。优化器的作用就是找到这其中最好的执行计划。
执行计划:mysql不会生成查询字节码来执行查询,mysql生成查询的一棵指令树,然后通过存储引擎执行完成这棵指令树并返回结果。最终的执行计划包含了重构查询的全部信息。
4.查询执行引擎
在解析和优化阶段,mysql将生成查询对应的执行计划,mysql的查询执行引擎则根据这个执行计划来完成整个查询。这里执行计划是一个数据结构,而不是和很多其他的关系型数据库那样对应的字节码。

mysql简单的根据执行计划给出的指令逐步执行。在根据执行计划逐步执行的过程中,有大量的操作需要通过调用存储引擎实现的接口来完成。为了执行查询,mysql只需要重复执行计划中的各个操作,知道完成所有的数据查询。

5.返回结果给客户端
查询执行的最后一个阶段是将结果返回给客户端。即使查询不需要返回结果给客户端,mysql仍然会返回这个查询的一些信息,如该查询影响到的行数。如果查询可以被缓存,那么mysql在这个阶段也会将结果放到查询缓存中。

mysql将结果集返回客户端是一个增量、逐步返回的过程。这样有两个好处:服务器端无须存储太多的结果,也就不会因为返回太多结果而消耗太多的内存;这样处理也让msyql客户端第一时间获得返回的结果。

结果集中的每一行都会以一个满足mysql客户端/服务器通信协议的包发送,再通过tcp协议进行传输,在tcp传输的过程中,可能对mysql的封包进行缓存然后批量传输。

————————————————
版权声明:本文为CSDN博主「zhoupenghui168」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/zhoupenghui168/article/details/122885278
4.事务的应用场景
1.什么情况下用事务:
事务的提出主要是为了解决并发情况下保持数据一致性的问题(类似于多线程)
事务(Transaction)是并发控制的基本单位。所谓的事务,这些操作要么都执行,要么都不执行,它是一个不可分割的工作单位。例如,银行转账工作:从一个账号扣款并使另一个账号增款,这两个操作要么都执行,要么都不执行,在关系数据库中,一个事务可以是一条SQL语句、一组SQL语句或整个程序。 。所以,应该把它们看成一个事务。事务是数据库维护数据一致性的单位,在每个事务结束时,都能保持数据一致性。
2.事务的语句
在关系数据库中,一个事务可以是一条SQL语句、一组SQL语句或整个程序。 开始事务:BEGIN TRANSACTION(事务)
提交事务:COMMIT TRANSACTION(事务)
回滚事务:ROLLBACK TRANSACTION(事务)3.事务的4个属性
Atomic(原子性):事务中包含的操作被看做一个逻辑单元,这个逻辑单元中的操作要么全部成功,要么全部失败(减款,增款必须一起完成)。
● Consistency(一致性):只有合法的数据可以被写入数据库,否则事务应该将其回滚到最初状态。事务的运行并不改变数据的一致性.例如,完整性约束了a+b=10,一个事务改变了a,那么b也应该随之改变。
● Isolation(隔离性):事务允许多个用户对同一个数据进行并发访问,而不破坏数据的正确性和完整性。同时,并行事务的修改必须与其他并行事务的修改相互独立。
● Durability(持久性):事务完成之后,它对于 系统的影响是永久的,该修改即使出现系统故障也将一直保留,真实的修改了数据库

  1. 事务的实现原理
    事务的实现原理
    仔细想了一下不说日志的话是没有办法把事务的原理性讲明白的,所以在这里还是加上了。

原子性的实现
首先我们先来看看原子性的实现原理,上面讲事务的特性知道,原子性的核心也就是支持回滚操作的,但事务不提交的过程中能够将数据回退到原始数据,那么数据库怎么知道原始数据是多少的呢,这里我们就要依靠于undo log了,但我们更新一行数据的时候,mysql会把之前的原始数据写入到undo log中,用于事务提交失败时,数据能够得到回滚。
undo log就是保证事务原子性的关键所在。

持久性的实现
这里我们还是从数据更新的步骤看看:
这里的redo log日志就是为了数据持久化的目的,刚刚上面提到的undo log是为了记录数据修改后的原始数据,那么这里的redo log就是为了记录数据修改后的数据,当数据库发生宕机就可以根据redo log日志将数据进行恢复。

有的小伙伴可能就有疑问,记得自己项目中mysql数据库的数据恢复是通过binlog日志的,那这里的redo log日志是不是重复了么,mysql需要存储两份日志文件么? 哈哈 当然不是,这个是数据库的版本机制造成的原因。
binlog日志是所有mysql都有的,但是redo log日志是Innodb所特有的,所以如果你的数据库的存储引擎不是用的Innodb的话,是不存在redo log的
其次redo log和bin log所存储的内容也是有所区别的
redo log:存储的是物理修改,比如:某某字段值进行 了修改为88
bin log: 存储的是逻辑修改,是修改对应的sql语句
同时 redo的存储大小是有大小限制的,可以看作一个环,当数据满的时候,会强制io把数据刷新到磁盘,再将已经刷新到磁盘的数据空间清空。记住一点,当发生数据更新发时候,是先写日志,并更新内存,后面到了合适的时机再写入磁盘,这个时间,mysql是可以通过参数来进行设置的。
而bin log是没有大小限制的,它的大小限制可以追加扩展。
还有一点是 ,如果两个日志都存在的情况下,先写redo log,后写 bin log
这里的redo log和bin log就是事务持久化的关键所在。
隔离性的实现
相比来说,隔离性的实现是最为复杂的,数据库为了能够支持事务的并发执行,做了很多设计,其中视图就是重中之重。
在数据库中有两种视图:
一种是普通的视图(view): 在我项目中的应用场景是,因为项目架构是微服务的,每个服务都有自己的独立数据库,在本服务中可能需要操作其他数据库,当然配置多数据源可以解决,但创建视图的成本更小。
另外就是这里要说的基于MVCC的视图(read view),用于支持上面所说的RC和RR的实现。
在可重复提交的隔离级别中,在开始事务的时候,会新建一个mysql数据库的数据快照,然后事务在这个快照中进行数据操作,有的小伙伴又有疑问了,数据库要是很大,达到几百g,那么这个快照不是要很久么,哈哈 ,并不是,这里的快照并不是全量复制数据库的数据,当我们开启一个事务的时候,事务都会有一个事务id,并且这个id是递增的,当事务更新了一行数据之后,会将当前事务的id与这个行数据进行绑定,从而数据库中的数据就会有多个版本,这个多个版本的实现就是我们刚刚所将的undo log,它会存储每个版本的数据。 同时在开启一个数据的时候,mysql会为每个事务创建一个数组,数组中存储的是活跃事务,这里的活跃事务就是启动还没提交的事务,由于事务id是递增的特点,这里我们就可以通过排序,找到事务的低水位和高水位,低水位之前的就是已经提交成功的事务。如下图所示:
这样当前事务就会更根据事务的id去找到当前版本数据已经成功提交的版本,对于还未提交的版本是不可见的。

RC的实现:
在读已提交的隔离级别中,视图的创建是每次sql语句创建的过程中,每次sql的创建都是用的最新的视图,所以如果在两次查询的间隙,有其他的事务修改了数据,再次读取就会发生两次数据不一致的情况。
上面说的都是读取数据的情况,那么对于更新数据来说,实际又是怎么样的呢?
对于写数据来说,每次得到的数据都是最新的版本,然后在最新的版本之上进行数据操作,如果有两个数据同时更新同一行数据时,必须要等其中一个事务释放行锁之后再获取行锁进行数据的更新,这样才能保证数据的更新是安全一致的。
Innodb中的行数据很多个版本,每个版本都有自己对应的事务id(row trx_id),每个事务都会有自己对应的视图,会根据事务id来确定事务的可见性。
对于RR可重复读,查询只承认在事务创建之前已经成功提交的数据。
对于RC读已提交,查询只承认在查询语句创建之前已经成功提交的数据。
对于当前读,总是读取当前已经成功提交的版本。
一致性的实现:
这里一致性的保证就是通过上面的原子性,持久性和隔离性来实现的。
好了,关于事务部分就讲到这里了,其实这里还有很多没有仔细的深入,比如redo log的数据接口,写入磁盘的时机,事务的失效场景,事务的嵌套,事务是怎么来防止幻读的等等,希望小伙伴可以自己好好思考,当然了,后面我也会进行讲解。
5.MySQL的锁应用场景
Mysql共享锁、排他锁、悲观锁、乐观锁及其使用场景
一、相关名词
|–表级锁(锁定整个表)
|–页级锁(锁定一页)
|–行级锁(锁定一行)
|–共享锁(S锁,MyISAM 叫做读锁)
|–排他锁(X锁,MyISAM 叫做写锁)
|–悲观锁(抽象性,不真实存在这个锁)
|–乐观锁(抽象性,不真实存在这个锁)

二、InnoDB与MyISAM
Mysql 在5.5之前默认使用 MyISAM 存储引擎,之后使用 InnoDB 。查看当前存储引擎:
show variables like ‘%storage_engine%’;
MyISAM 操作数据都是使用的表锁,你更新一条记录就要锁整个表,导致性能较低,并发不高。当然同时它也不会存在死锁问题。
而 InnoDB 与 MyISAM 的最大不同有两点:一是 InnoDB 支持事务;二是 InnoDB 采用了行级锁。也就是你需要修改哪行,就可以只锁定哪行。
在 Mysql 中,行级锁并不是直接锁记录,而是锁索引。索引分为主键索引和非主键索引两种,如果一条sql 语句操作了主键索引,Mysql 就会锁定这条主键索引;如果一条语句操作了非主键索引,MySQL会先锁定该非主键索引,再锁定相关的主键索引。
InnoDB 行锁是通过给索引项加锁实现的,如果没有索引,InnoDB 会通过隐藏的聚簇索引来对记录加锁。也就是说:如果不通过索引条件检索数据,那么InnoDB将对表中所有数据加锁,实际效果跟表锁一样。因为没有了索引,找到某一条记录就得扫描全表,要扫描全表,就得锁定表。

三、共享锁与排他锁
1.首先说明:数据库的增删改操作默认都会加排他锁,而查询不会加任何锁。
|–共享锁:对某一资源加共享锁,自身可以读该资源,其他人也可以读该资源(也可以再继续加共享锁,即 共享锁可多个共存),但无法修改。要想修改就必须等所有共享锁都释放完之后。语法为:
select * from table lock in share mode
|–排他锁:对某一资源加排他锁,自身可以进行增删改查,其他人无法进行任何操作。语法为:
select * from table for update

2.下面援引例子说明(援自:http://blog.csdn.net/samjustin1/article/details/52210125):
这里用T1代表一个数据库执行请求,T2代表另一个请求,也可以理解为T1为一个线程,T2 为另一个线程。

例1:-------------------------------------------------------------------------------------------------------------------------------------
T1:select * from table lock in share mode(假设查询会花很长时间,下面的例子也都这么假设)
T2:update table set column1=‘hello’

过程:
T1运行(并加共享锁)
T2运行
If T1还没执行完
T2等…
else 锁被释放
T2执行
end if

T2 之所以要等,是因为 T2 在执行 update 前,试图对 table 表加一个排他锁,而数据库规定同一资源上不能同时共存共享锁和排他锁。所以 T2 必须等 T1 执行完,释放了共享锁,才能加上排他锁,然后才能开始执行 update 语句。

例2:-------------------------------------------------------------------------------------------------------------------------------------
T1:select * from table lock in share mode
T2:select * from table lock in share mode

这里T2不用等待T1执行完,而是可以马上执行。

分析:
T1运行,则 table 被加锁,比如叫lockA,T2运行,再对 table 加一个共享锁,比如叫lockB,两个锁是可以同时存在于同一资源上的(比如同一个表上)。这被称为共享锁与共享锁兼容。这意味着共享锁不阻止其它人同时读资源,但阻止其它人修改资源。

例3:-------------------------------------------------------------------------------------------------------------------------------------
T1:select * from table lock in share mode
T2:select * from table lock in share mode
T3:update table set column1=‘hello’

T2 不用等 T1 运行完就能运行,T3 却要等 T1 和 T2 都运行完才能运行。因为 T3 必须等 T1 和 T2 的共享锁全部释放才能进行加排他锁然后执行 update 操作。

例4:(死锁的发生)-----------------------------------------------------------------------------------------------------------------
T1:begin tran
select * from table lock in share mode
update table set column1=‘hello’
T2:begin tran
select * from table lock in share mode
update table set column1=‘world’

假设 T1 和 T2 同时达到 select,T1 对 table 加共享锁,T2 也对 table 加共享锁,当 T1 的 select 执行完,准备执行 update 时,根据锁机制,T1 的共享锁需要升级到排他锁才能执行接下来的 update。在升级排他锁前,必须等 table 上的其它共享锁(T2)释放,同理,T2 也在等 T1 的共享锁释放。于是死锁产生了。

例5:-------------------------------------------------------------------------------------------------------------------------------------
T1:begin tran
update table set column1=‘hello’ where id=10
T2:begin tran
update table set column1=‘world’ where id=20

这种语句虽然最为常见,很多人觉得它有机会产生死锁,但实际上要看情况
|–如果id是主键(默认有主键索引),那么T1会一下子找到该条记录(id=10的记录),然后对该条记录加排他锁,T2,同样,一下子通过索引定位到记录,然后对id=20的记录加排他锁,这样T1和T2各更新各的,互不影响。T2也不需要等。
|–如果id是普通的一列,没有索引。那么当T1对id=10这一行加排他锁后,T2为了找到id=20,需要对全表扫描。但因为T1已经为一条记录加了排他锁,导致T2的全表扫描进行不下去(其实是因为T1加了排他锁,数据库默认会为该表加意向锁,T2要扫描全表,就得等该意向锁释放,也就是T1执行完成),就导致T2等待。

死锁怎么解决呢?一种办法是,如下:
例6:-------------------------------------------------------------------------------------------------------------------------------------
T1:begin tran
select * from table for update
update table set column1=‘hello’
T2:begin tran
select * from table for update
update table set column1=‘world’

这样,当 T1 的 select 执行时,直接对表加上了排他锁,T2 在执行 select 时,就需要等 T1 事物完全执行完才能执行。排除了死锁发生。但当第三个 user 过来想执行一个查询语句时,也因为排他锁的存在而不得不等待,第四个、第五个 user 也会因此而等待。在大并发情况下,让大家等待显得性能就太友好了。
所以,有些数据库这里引入了更新锁(如Mssql,注意:Mysql不存在更新锁)。

例7:-------------------------------------------------------------------------------------------------------------------------------------
T1:begin tran
select * from table [加更新锁操作]
update table set column1=‘hello’
T2:begin tran
select * from table [加更新锁操作]
update table set column1=‘world’
更新锁其实就可以看成排他锁的一种变形,只是它也允许其他人读(并且还允许加共享锁)。但不允许其他操作,除非我释放了更新锁。T1 执行 select,加更新锁。T2 运行,准备加更新锁,但发现已经有一个更新锁在那儿了,只好等。当后来有 user3、user4…需要查询 table 表中的数据时,并不会因为 T1 的 select 在执行就被阻塞,照样能查询,相比起例6,这提高了效率。
后面还有意向锁和计划锁:

  • 计划锁,和程序员关系不大,就没去了解。
  • 意向锁(innodb特有)分意向共享锁和意向排他锁。
    • 意向共享锁:表示事务获取行共享锁时,必须先得获取该表的意向共享锁;
    • 意向排他锁:表示事务获取行排他锁时,必须先得获取该表的意向排他锁;
      我们知道,如果要对整个表加锁,需保证该表内目前不存在任何锁。
      因此,如果需要对整个表加锁,那么就可以根据:检查意向锁是否被占用,来知道表内目前是否存在共享锁或排他锁了。而不需要再一行行地去检查每一行是否被加锁。
      四、乐观锁与悲观锁
      首先说明,乐观锁和悲观锁都是针对读(select)来说的。
      案例:
      某商品,用户购买后库存数应-1,而某两个或多个用户同时购买,此时三个执行程序均同时读得库存为“n”,之后进行了一些操作,最后将均执行update table set 库存数=n-1,那么,很显然这是错误的。

解决:

  1. 使用悲观锁(其实说白了也就是排他锁)
    |-- 程序A在查询库存数时使用排他锁(select * from table where id=10 for update)
    |-- 然后进行后续的操作,包括更新库存数,最后提交事务。
    |-- 程序B在查询库存数时,如果A还未释放排他锁,它将等待……
    |-- 程序C同B……
  2. 使用乐观锁(靠表设计和代码来实现)
    |-- 一般是在该商品表添加version版本字段或者timestamp时间戳字段
    |-- 程序A查询后,执行更新变成了:
    update table set num=num-1 where id=10 and version=23
    这样,保证了修改的数据是和它查询出来的数据是一致的(其他执行程序肯定未进行修改)。当然,如果更新失败,表示在更新操作之前,有其他执行程序已经更新了该库存数,那么就可以尝试重试来保证更新成功。为了尽可能避免更新失败,可以合理调整重试次数(阿里巴巴开发手册规定重试次数不低于三次)。
    总结:对于以上,可以看得出来乐观锁和悲观锁的区别:
  • 悲观锁实际使用了排他锁来实现(select **** for update)。文章开头说到,innodb加行锁的前提是:必须是通过索引条件来检索数据,否则会切换为表锁。

因此,悲观锁在未通过索引条件检索数据时,会锁定整张表。导致其他程序不允许“加锁的查询操作”,影响吞吐。故如果在查询居多的情况下,推荐使用乐观锁。

“加锁的查询操作”:加过排他锁的数据行在其他事务中是不能修改的,也不能通过for update或lock in share mode的加锁方式查询,但可以直接通过select …from…查询数据,因为普通查询没有任何锁机制。

  • 乐观锁更新有可能会失败,甚至是更新几次都失败,这是有风险的。所以如果写入居多,对吞吐要求不高,可使用悲观锁。
    也就是一句话:读用乐观锁,写用悲观锁。

6.for update的锁表
InnoDB默认是行级别的锁,当有明确指定的主键时候,是行级锁。否则是表级别。
例子: 假设表foods ,存在有id跟name、status三个字段,id是主键,status有索引。
例1: (明确指定主键,并且有此记录,行级锁)SELECT * FROM foods WHERE id=1 FOR UPDATE;
SELECT * FROM foods WHERE id=1 and name=’咖啡色的羊驼’ FOR UPDATE;
例2: (明确指定主键/索引,若查无此记录,无锁)SELECT * FROM foods WHERE id=-1 FOR UPDATE;
例3: (无主键/索引,表级锁)SELECT * FROM foods WHERE name=’咖啡色的羊驼’ FOR UPDATE;
例4: (主键/索引不明确,表级锁)SELECT * FROM foods WHERE id<>’3’ FOR UPDATE;
SELECT * FROM foods WHERE id LIKE ‘3’ FOR UPDATE;
for update的注意点1.for update 仅适用于InnoDB,并且必须开启事务,在begin与commit之间才生效。
2.要测试for update的锁表情况,可以利用MySQL的Command Mode,开启二个视窗来做测试。
for update的疑问点当开启一个事务进行for update的时候,另一个事务也有for update的时候会一直等着,直到第一个事务结束吗?
答:会的。除非第一个事务commit或者rollback或者断开连接,第二个事务会立马拿到锁进行后面操作。如果没查到记录会锁表吗?
答:会的。表级锁时,不管是否查询到记录,都会锁定表
7. NULL的存储
MySql是如何通过二进制来存储NULL值的?
其实NULL值是以二进制bit位来存储的,Compact 格式数据中的NULL值列表就是用来存储NULL值的。若有某个字段值为 null,将将其 bit 位置为 1 说明值为 NULL,bit为 0 说明该字段值不为空。
8. MySQL 数据存储底层原理剖析
在互联网应用场景中,存储我们经常都会遇到的问题,随着业务的发展,数据量的膨胀,原先的数据存储超过了原有的预期,此时就会需要关注到一些数据存储的原理。
本文中我将主要以 MySQL 数据库介绍一些在存储方面的底层细节。
9. MySQL 不同字段类型的存储大小
这里我通过整理了一张常用类型字段占用空间的数据供大家参考:

  1. 数据库三范式

11.封锁
封锁粒度
MySQL 中提供了两种封锁粒度:行级锁以及表级锁。
应该尽量只锁定需要修改的那部分数据,而不是所有的资源。锁定的数据量越少,发生锁争用的可能就越小,系统的并发程度就越高。
但是加锁需要消耗资源,锁的各种操作(包括获取锁、释放锁、以及检查锁状态)都会增加系统开销。因此封锁粒度越小,系统开销就越大。
在选择封锁粒度时,需要在锁开销和并发程度之间做一个权衡。
封锁类型

  1. 读写锁
  • 互斥锁(Exclusive),简写为 X 锁,又称写锁。
  • 共享锁(Shared),简写为 S 锁,又称读锁。
    有以下两个规定:
  • 一个事务对数据对象 A 加了 X 锁,就可以对 A 进行读取和更新。加锁期间其它事务不能对 A 加任何锁。
  • 一个事务对数据对象 A 加了 S 锁,可以对 A 进行读取操作,但是不能进行更新操作。加锁期间其它事务能对 A 加 S 锁,但是不能加 X 锁。
    锁的兼容关系如下:
  1. 意向锁
    使用意向锁(Intention Locks)可以更容易地支持多粒度封锁。
    在存在行级锁和表级锁的情况下,事务 T 想要对表 A 加 X 锁,就需要先检测是否有其它事务对表 A 或者表 A 中的任意一行加了锁,那么就需要对表 A 的每一行都检测一次,这是非常耗时的。
    意向锁在原来的 X/S 锁之上引入了 IX/IS,IX/IS 都是表锁,用来表示一个事务想要在表中的某个数据行上加 X 锁或 S 锁。有以下两个规定:
  • 一个事务在获得某个数据行对象的 S 锁之前,必须先获得表的 IS 锁或者更强的锁;
  • 一个事务在获得某个数据行对象的 X 锁之前,必须先获得表的 IX 锁。
    通过引入意向锁,事务 T 想要对表 A 加 X 锁,只需要先检测是否有其它事务对表 A 加了 X/IX/S/IS 锁,如果加了就表示有其它事务正在使用这个表或者表中某一行的锁,因此事务 T 加 X 锁失败。
    各种锁的兼容关系如下:

解释如下:

  • 任意 IS/IX 锁之间都是兼容的,因为它们只表示想要对表加锁,而不是真正加锁;
  • 这里兼容关系针对的是表级锁,而表级的 IX 锁和行级的 X 锁兼容,两个事务可以对两个数据行加 X 锁。(事务 T1 想要对数据行 R1 加 X 锁,事务 T2 想要对同一个表的数据行 R2 加 X 锁,两个事务都需要对该表加 IX 锁,但是 IX 锁是兼容的,并且 IX 锁与行级的 X 锁也是兼容的,因此两个事务都能加锁成功,对同一个表中的两个数据行做修改。)
    封锁协议
  1. 三级封锁协议
    一级封锁协议
    事务 T 要修改数据 A 时必须加 X 锁,直到 T 结束才释放锁。
    可以解决丢失修改问题,因为不能同时有两个事务对同一个数据进行修改,那么事务的修改就不会被覆盖。

二级封锁协议
在一级的基础上,要求读取数据 A 时必须加 S 锁,读取完马上释放 S 锁。
可以解决读脏数据问题,因为如果一个事务在对数据 A 进行修改,根据 1 级封锁协议,会加 X 锁,那么就不能再加 S 锁了,也就是不会读入数据。

三级封锁协议
在二级的基础上,要求读取数据 A 时必须加 S 锁,直到事务结束了才能释放 S 锁。
可以解决不可重复读的问题,因为读 A 时,其它事务不能对 A 加 X 锁,从而避免了在读的期间数据发生改变。

  1. 两段锁协议
    加锁和解锁分为两个阶段进行。
    可串行化调度是指,通过并发控制,使得并发执行的事务结果与某个串行执行的事务结果相同。串行执行的事务互不干扰,不会出现并发一致性问题。
    事务遵循两段锁协议是保证可串行化调度的充分条件。例如以下操作满足两段锁协议,它是可串行化调度。
    lock-x(A)…lock-s(B)…lock-s©…unlock(A)…unlock©…unlock(B)

但不是必要条件,例如以下操作不满足两段锁协议,但它还是可串行化调度。
lock-x(A)…unlock(A)…lock-s(B)…unlock(B)…lock-s©…unlock©

MySQL 隐式与显式锁定
MySQL 的 InnoDB 存储引擎采用两段锁协议,会根据隔离级别在需要的时候自动加锁,并且所有的锁都是在同一时刻被释放,这被称为隐式锁定。
InnoDB 也可以使用特定的语句进行显示锁定:
SELECT … LOCK In SHARE MODE;
SELECT … FOR UPDATE;

  1. 慢查询
    一、什么是慢查询
    慢查询,顾名思义,执行很慢的查询。有多慢?超过long_query_time参数设定的时间阈值(默认10s),就被认为是慢的,是需要优化的。慢查询被记录在慢查询日志里。慢查询日志默认是不开启的。如果需要优化SQL语句,就可以开启这个功能,它可以让你很容易地知道哪些语句是需要优化的。

1️⃣show variables like ‘slow_query_log’;查询是否开启慢查询日志
【开启慢查询sql:set global slow_query_log = 1/on;】
【关闭慢查询sql:set global slow_query_log = 0/off;】
2️⃣show variables like ‘log_queries_not_using_indexes’;查询未使用索引是否开启记录慢查询日志
【开启记录未使用索引sql:set global log_queries_not_using_indexes=1/on】
【关闭记录未使用索引sql:set global log_queries_not_using_indexes=1/off】
3️⃣show variables like ‘long_query_time’;查询超过多少秒的记录到慢查询日志中
【设置超1秒就记录慢查询sql:set global long_query_time= 1;设置超1秒就记录】

二、慢查询的配置文件 my.cnf
在MySQL的配置文件my.cnf中写上:

long_query_time = 10
log-slow-queries = /var/lib/mysql/mysql-slow.log
1
2
long_query_time是指执行超过多久的SQL会被日志记录下来,这里是10 秒。
log-slow-queries设置把日志写在哪里。为空的时候,系统会给慢查询日志赋予主机名,并加上slow.log。如果设置了参数log-long-format,那么所有没有使用索引的查询也将被记录。

这是一个非常有用的日志。它对于性能的影响不大(假设所有查询都很快),并且强调了那些最需要注意的查询(丢失了索引或索引没有得到最佳应用)。

三、慢查询解读
第一行:记录时间
第二行:用户名 、用户的IP信息、线程ID号
第三行:执行花费的时间【单位:毫秒】、执行获得锁的时间、获得的结果行数、扫描的数据行数
第四行:这SQL执行的时间戳
第五行:具体的SQL语句
————————————————
版权声明:本文为CSDN博主「Just-ForStudy」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/ChineseSoftware/article/details/123200916
13. sql查询慢优化
原因分析
后台数据库中数据过多,未做数据优化
数据请求-解析-展示处理不当
网络问题
提高数据库查询的速度方案
SQL 查询速度慢的原因有很多,常见的有以下几种:
1、没有索引或者没有用到索引(查询慢最常见的问题,是程序设计的缺陷)
2、I/O吞吐量小,形成了瓶颈效应。
3、没有创建计算列导致查询不优化。
4、内存不足
5、网络速度慢
6、查询出的数据量过大(可以采用多次查询,其他的方法降低数据量)
7、锁或者死锁(这也是查询慢最常见的问题,是程序设计的缺陷)
8、sp_lock,sp_who,活动的用户查看,原因是读写竞争资源。
9、返回了不必要的行和列
10、查询语句不好,没有优化

SQL优化
1、恰当地使用索引
必要时建立多级索引,分析执行计划,通过表数据统计等方式协助数据库走正确的查询方式,该走索引就走索引,该走全表扫描就走全表扫描;
2、对查询进行优化,尽可能避免全表扫描
首先考WHERE 及ORDER BY涉及列上建立索引
3、数据库表的大字段剥离
假如一个表的字段数有100多个,拆分字段,保证单条记录的数据量很小
4、字段冗余
减少跨库查询或多表连接操作
5、表的拆分
表分区和拆分,无论是业务逻辑上的拆分(如一个月一张报表、分库)还是无业务含义的分区
6、查询时不要返回不需要的行、列
7、减少SQL中函数运算与其它计算
8、升级硬件
9、提高网速
10、扩大服务器的内存
11、增加服务器 CPU个数
代码过程优化
1、缓存,在持久层或持久层之上做缓存
使用ehcache缓存,这个一般用于持久层的缓存,提供持久层、业务层的快速缓存,或其它缓存
2、放弃关系数据库的某些特性,引入NoSQL数据库
3、复杂业务数据通过代码层实现组合
其它
读写分离

  1. explain
    本文主要讲述如何通过 explain 命令获取 select 语句的执行计划,通过 explain 我们可以知道以下信息:表的读取顺序,数据读取操作的类型,哪些索引可以使用,哪些索引实际使用了,表之间的引用,每张表有多少行被优化器查询等信息。
    下面是使用 explain 的例子:
    在 select 语句之前增加 explain 关键字,MySQL会在查询上设置一个标记,执行查询时,会返回执行计划的信息,而不是执行这条SQL(如果 from 中包含子查询,仍会执行该子查询,将结果放入临时表中)。
    mysql> explain select * from actor;
    ±—±------------±------±-----±--------------±-----±--------±-----±-----±------+
    | id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
    ±—±------------±------±-----±--------------±-----±--------±-----±-----±------+
    | 1 | SIMPLE | actor | ALL | NULL | NULL | NULL | NULL | 2 | NULL |
    ±—±------------±------±-----±--------------±-----±--------±-----±-----±------+

在查询中的每个表会输出一行,如果有两个表通过 join 连接查询,那么会输出两行。表的意义相当广泛:可以是子查询、一个 union 结果等。
explain 有两个变种:
1)explain extended:会在 explain 的基础上额外提供一些查询优化的信息。紧随其后通过 show warnings 命令可以 得到优化后的查询语句,从而看出优化器优化了什么。额外还有 filtered 列,是一个半分比的值,rows * filtered/100 可以估算出将要和 explain 中前一个表进行连接的行数(前一个表指 explain 中的id值比当前表id值小的表)。
mysql> explain extended select * from film where id = 1;
±—±------------±------±------±--------------±--------±--------±------±-----±---------±------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
±—±------------±------±------±--------------±--------±--------±------±-----±---------±------+
| 1 | SIMPLE | film | const | PRIMARY | PRIMARY | 4 | const | 1 | 100.00 | NULL |
±—±------------±------±------±--------------±--------±--------±------±-----±---------±------+

mysql> show warnings;
±------±-----±-------------------------------------------------------------------------------+
| Level | Code | Message |
±------±-----±-------------------------------------------------------------------------------+
| Note | 1003 | /* select#1 */ select ‘1’ AS id,‘film1’ AS name from test.film where 1 |
±------±-----±-------------------------------------------------------------------------------+

2)explain partitions:相比 explain 多了个 partitions 字段,如果查询是基于分区表的话,会显示查询将访问的分区。
15. 触发器原理
每个触发器有插入和删除两个特殊的表,它有以下几个特点:((1)这两个表是逻辑表,并且是由系统管理的,存储在内存中,不是存储在数据库中,因此不允许用户直接对其修改。
(2)这两个表的结构总是与被该触发器作用的表有相同的表结构。
(3)这两个表动态驻留在内存中,当触发器工作完成时,这两个表也被删除。这两个表主要保存因用户操作而被影响到的原数据值或新数据值。
(4)这两个表是只读的,且只在触发器内部可读,即用户不能向这两个表写入内容,但可以在触发器中引用表中的数据。
对一个定义了插入类型触发器的表来讲,一旦对该表执行了插入操作,那么对该表插入的所有行来说,都有一个相应的副本级存放到插入表中,即插入表就是用来存储原表插入的新数据行。
对一个定义了删除类型触发器的表来讲,一旦对该表执行了删除操作,则将所有的被删除的行存放至删除表中。这样做的目的是,一旦触发器遇到了强迫它中止的语句被执行时,删除的那些行可以从删除表中得以还原。需要特别注意的是,更新操作包括两个部分动作:先将旧的内容删除,然后将新值插入。
因此,对一个定义了更新类型触发器的表来讲,当执行更新操作时,在删除表中存放修改之前的旧值,然后在插入表中存放修改之后的新值。
触发器对数据约束的原理是逐行检查。当在数据表上执行插入、删除和更新操作,触发器将会被触发执行时,每条SQL语句的操作哪怕只影响数据库中的一条记录,其也会检查此次操作对其他记录的影响,会对表中的所有记录按触发器上所定义的约束进行全方位的检查。

  1. 给字段加索引

加主关键字的索引
mysql> alter table 表名 add primary key (字段名);
例子: mysql> alter table employee add primary key(id);

1、添加普通索引
ALTER TABLE table_name ADD INDEX index_name ( column )案例:ALTER TABLE ts_storage_partinfo_order_batch ADD INDEX IDX_ISB (id_source_bill);
2、添加主键索引
ALTER TABLE table_name ADD PRIMARY KEY ( column )
3、添加唯一索引 (UNIQUE)
ALTER TABLE table_name ADD UNIQUE ( column ) 唯一索引在此处可以保证数据记录的唯一性,在许多场合,创建唯一索引并不是为了加快访问速度,而是为了限制数据的唯一性。
4、全文索引 (FULLTEXT)
ALTER TABLE table_name ADD FULLTEXT ( column)
5、多列索引
ALTER TABLE table_name ADD INDEX index_name ( column1, column2, column3 )

  1. Unsafe
    Unsafe是什么?
    Unsafe是位于sun.misc包下的一个类,主要提供一些用于执行低级别、不安全操作的方法,如直接访问系统内存资源、自主管理内存资源等,这些方法在提升Java运行效率、增强Java语言底层资源操作能力方面起到了很大的作用。但由于Unsafe类使Java语言拥有了类似C语言指针一样操作内存空间的能力,这无疑也增加了程序发生相关指针问题的风险。在程序中过度、不正确使用Unsafe类会使得程序出错的概率变大,使得Java这种安全的语言变得不再“安全”,因此对Unsafe的使用一定要慎重。

Unsafe类为一单例实现,提供静态方法getUnsafe获取Unsafe实例,当且仅当调用getUnsafe方法的类为引导类加载器所加载时才合法,否则抛出SecurityException异常。

如何获取Unsafe实例?
1、从getUnsafe方法的使用限制条件出发,通过Java命令行命令-Xbootclasspath/a把调用Unsafe相关方法的类A所在jar包路径追加到默认的bootstrap路径中,使得A被引导类加载器加载,从而通过Unsafe.getUnsafe方法安全的获取Unsafe实例。
2、通过反射获取单例对象theUnsafe。
Unsafe功能介绍
Unsafe提供的API大致可分为内存操作、CAS、Class相关、对象操作、线程调度、系统 信息获取、内存屏障、数组操作等几类,下面将对其相关方法和应用场景进行详细介绍。

通常,我们在Java中创建的对象都处于堆内内存(heap)中,堆内内存是由JVM所管控的Java进程内存,并且它们遵循JVM的内存管理机制,JVM会采用垃圾回收机制统一管理堆内存。与之相对的是堆外内存,存在于JVM管控之外的内存区域,Java中对堆外内存的操作,依赖于Unsafe提供的操作堆外内存的native方法。

七.7777777777777777777777 计算机网络
1.状态码

  • 100 Continue:表明到目前为止都很正常,客户端可以继续发送请求或者忽略这个响应
  • 200 OK
  • 204 No Content :请求已经成功处理,但是返回的响应报文不包含实体的主体部分。一般在只需要从客户端往服务器发送信息,而不需要返回数据时使用。
  • 206 Partial Content :表示客户端进行了范围请求,响应报文包含由 Content-Range 指定范围的实体内容。
  • 301 Moved Permanently :永久性重定向
  • 302 Found :临时性重定向
  • 303 See Other :和 302 有着相同的功能,但是 303 明确要求客户端应该采用 GET 方法获取资源。
  • 注:虽然 HTTP 协议规定 301、302 状态下重定向时不允许把 POST 方法改成 GET 方法,但是大多数浏览器都会在 301、302 和 303 状态下的重定向把 POST 方法改成 GET 方法。
  • 304 Not Modified :如果请求报文首部包含一些条件,例如:If-Match,If-Modified-Since,If-None-Match,If-Range,If-Unmodified-Since,如果不满足条件,则服务器会返回 304 状态码。
  • 307 Temporary Redirect :临时重定向,与 302 的含义类似,但是 307 要求浏览器不会把重定向请求的 POST 方法改成 GET 方法。
  • 400 Bad Request :请求报文中存在语法错误。
  • 401 Unauthorized :该状态码表示发送的请求需要有认证信息(BASIC 认证、DIGEST 认证)。如果之前已进行过一次请求,则表示用户认证失败。
  • 403 Forbidden :请求被拒绝。
  • 404 Not Found
  • 500 Internal Server Error :服务器正在执行请求时发生错误。
  • 503 Service Unavailable :服务器暂时处于超负载或正在进行停机维护,现在无法处理请求。
    2.Http
    HTTP请求报文

【请求行】:
①是请求方法,GET和POST是最常见的HTTP方法,除此以外还包括DELETE、HEAD、OPTIONS、PUT、TRACE。
②为请求对应的URL地址,它和报文头的Host属性组成完整的请求URL。
③是协议名称及版本号。

【请求头】:
④是HTTP的报文头,报文头包含若干个属性,格式为“属性名:属性值”,服务端据此获取客户端的信息。
与缓存相关的规则信息,均包含在header中

【请求体】:
⑤是报文体,它将一个页面表单中的组件值通过param1=value1&param2=value2的键值对形式编码成一个格式化串,它承载多个请求参数的数据。不但报文体可以传递请求参数,请求URL也可以通过类似于“/chapter15/user.html? param1=value1&param2=value2”的方式传递请求参数。
http1.1 http1.1 http2.0
1.HTTP/1.x 缺陷
HTTP/1.x 实现简单是以牺牲性能为代价的:

  • 客户端需要使用多个连接才能实现并发和缩短延迟;
  • 不会压缩请求和响应首部,从而导致不必要的网络流量;
  • 不支持有效的资源优先级,致使底层 TCP 连接的利用率低下。
    2.HTTP 1.0 和 HTTP 1.1 的主要区别是什么?
    1.长短连接 2.错误状态响应码 3.缓存处理 4.带宽优化及网络连接的使用.
    3.http2.0的特性
    二进制分帧层
    HTTP/2.0 将报文分成 HEADERS 帧和 DATA 帧,它们都是二进制格式的。
    在通信过程中,只会有一个 TCP 连接存在,它承载了任意数量的双向数据流(Stream)。
  • 一个数据流(Stream)都有一个唯一标识符和可选的优先级信息,用于承载双向信息。
  • 消息(Message)是与逻辑请求或响应对应的完整的一系列帧。
  • 帧(Frame)是最小的通信单位,来自不同数据流的帧可以交错发送,然后再根据每个帧头的数据流标识符重新组装。
    #服务端推送
    HTTP/2.0 在客户端请求一个资源时,会把相关的资源一起发送给客户端,客户端就不需要再次发起请求了。例如客户端请求 page.html 页面,服务端就把 script.js 和 style.css 等与之相关的资源一起发给客户端。
    首部压缩
    HTTP/1.1 的首部带有大量信息,而且每次都要重复发送。
    HTTP/2.0 要求客户端和服务器同时维护和更新一个包含之前见过的首部字段表,从而避免了重复传输。不仅如此,HTTP/2.0 也使用 Huffman 编码对首部字段进行压缩。
    3.HTTPS
    http 缺点
    使用明文进行通信, 内容可能会被窃听. 不验证通信方的身份,通信方的身份有可能遭遇伪装; 无法证明报文的完整性,报文有可能遭篡改。
    https介绍:
  • 让 HTTP 先和 SSL(Secure Sockets Layer)通信,再由 SSL 和 TCP 通信,也就是说 HTTPS 使用了隧道进行通信。目前ssl已被淘汰, https使用的协议是tls了
  • 通过使用 SSL,HTTPS 具有了加密(防窃听)、认证(防伪装)和完整性保护(防篡改)
    https加密
  • 1.对称与非对称加密
    • 对称加密: 加密解密是同一套秘钥, 运算速度快, 但不安全
    • 非对称加密: 加密解密使用不同的秘钥. 接收方把公钥分享给发送方, 发送方使用公钥加密后将通信内容传输给接收方, 接收方收到后再使用自己的私秘钥对内容进行解密, 这样就可以保证安全性, 但是效率不高.
  • 2.https采用混合的加密机制
    • 1.使用非对称加密方式, 传输对称加密方式所需要的秘钥给发送方, 从而保证安全性
      1. 发送方收到秘钥后, 使用对称加密方式进行通信, 从而保证效率
  • 3.如何保证数据的完整性
    • 1.http采用MD5报文摘要, 但不安全. 比如内容被篡改,同时重新计算MD5值, 接受方就无法发现数据被篡改.
    • 2.https结合了加密和认证两个操作. 发送方通过使用hash函数对原文进行加密(这一过程是不可逆的), 得到摘要.然后将原文与摘要一起发给接收方, 接收方收到后使用同样的hash函数对原文进行加密得到新摘要, 然后将传输过来的摘要与新摘要进行比对, 如果不同就说明数据被篡改了. 这样就可以保证数据的完整性.
  • 4.如何进行身份认证(如何保证数据就是从淘宝发来的, 而不是钓鱼网站)
    • 一.CA机构签名过程
      CA机构对服务端进行验证后(CA有自己的验证方式,线上或者线下都有),对服务器的明文信息先进行hash,得到摘要,然后用自己的私钥对摘要进行加密,得到数字签名。将签名和证书一起发放给服务器。
    • 二.证书链
      • 现实中不可能所有的证书都是由少数几个CA签发的,那样CA就太忙了。因此产生了证书链,根CA发布证书给中间CA,中间CA再发布证书给各个服务器。验证的时候,从下往上,最终验证到根CA。
    • 三.验证过程
      • 假设有两个中间CA,CA1和CA2,整个证书链如下所示
        根CA -》CA1 -》CA2 -》 服务器
      • 那么此时,服务器发给客户端的证书链中不仅包含自己的证书,还要包含各个中间CA的证书,本例子中也即CA1和CA2以及服务器自己的证书。
      • 客户端首先验证 CA2 ->服务器这一环,分为以下步骤:
        1. 从CA2的证书中,拿出CA2的公钥,对服务器证书中的CA2的签名进行解密。
          对服务器证书中的明文信息进行hash,使用的hash算法与CA2所使用的hash算法一致。
          对比前两步的结果,看其是否相等,若相等则验证通过。
          验证通过后,客户端继续验证证书链的下一个环节,即 CA1 -> CA2,与上一个环节大同小异,分为以下步骤:
      • 2.从CA1的证书中,拿出CA1的公钥,对CA2证书中的CA1的签名进行解密。
        对CA2证书中的明文信息进行hash,使用的hash算法与CA1所使用的hash算法一致。
        对比前两步的结果,看其是否相等,若相等则验证通过。
        验证通过后,客户端继续验证证书链的最后一个环节,即 根CA -> CA1,注意第一步的不同之处:
      • 3.从自己本地的缓存中,拿出根CA的公钥,对CA1证书中的根CA的签名进行解密。
        对CA1证书中的明文信息进行hash,使用的hash算法与根CA所使用的hash算法一致。
        对比前两步的结果,看其是否相等,若相等则验证通过。
        至此证书验证过程结束。客户端本地会缓存一些少量的,全球都认可的根CA的证书,根CA的证书是自签名的,其中也会包含根CA的公钥。
        无论有多少中间CA(零个或者多个),只要沿着证书链进行验证,每一个环节都验证通过的话,服务器就是可信的。其中有一个环节验证不通过,服务器就是不可信的。
        4.Socket IO
        http://www.cyc2018.xyz/%E8%AE%A1%E7%AE%97%E6%9C%BA%E5%9F%BA%E7%A1%80/Socket/Socket.html#%E4%BF%A1%E5%8F%B7%E9%A9%B1%E5%8A%A8-i-o
  1. TCP报文格式

字段详解
第一行:源端口和目的端口字段
TCP源端口(Source Port):源计算机上的应用程序的端口号,占16 位(bit)。
TCP目的端口(Destination Port):目标计算机的应用程序端口号,占16 位(bit)。
第二行:序列号字段
CP序列号(Sequence Number):占(32bit)它表示本报文段所发送数据的第一个字节的编号。在 TCP 连接中,所传送的字节流的每一个字节都会按顺序编号。
当SYN标记不为1时,这是当前数据分段第一个字母的序列号;
如果SYN的值是1时,这个字段的值就是初始序列值(ISN),用于对序列号进行同步。这时,第一个字节的序列号比这个字段的值大1,也就是ISN加1。
第三行:确认号字段
TCP 确认号:(Acknowledgment Number,ACK Number):占 32 位。它表示接收方期望收到发送方下一个报文段的第一个字节数据的编号。其值是接收计算机即将接收到的下一个序列号,也就是下一个接收到的字节的序列号加1。
第四行
数据偏移字段
TCP 首部长度(Header Length):数据偏移是指数据段中的“数据”部分起始处距离 TCP 数据段起始处的字节偏移量,占 4 位。其实这里的“数据偏移”也是在确定 TCP 数据段头部分的长度,告诉接收端的应用程序,数据从何处开始。
理解:“数据偏移”是指TCP 数据段头部分的长度,告诉接收端的应用程序,需要处理的数据从何处开始(去掉头部)
保留字段
保留(Reserved):占 4 位。为 TCP 将来的发展预留空间,目前必须全部为 0。
标志位8字段
1.CWR(Congestion Window Reduce):拥塞窗口减少标志,用来表明它接收到了设置 ECE 标志的 TCP 包。并且,发送方收到消息之后,通过减小发送窗口的大小来降低发送速率

理解:告知发送方,我方拥堵,你降低一下窗口发送速率
2.ECE(ECN Echo):用来在 TCP 三次握手时表明一个 TCP 端是具备 ECN (显式拥塞通告)功能的。在数据传输过程中,它也用来表明接收到的 TCP 包的 IP 头部的 ECN 被设置为 11,即网络线路拥堵。
理解:数据传输过程中,TCP包头部的ECN字段设置为11,表示网络线路拥堵
3.URG(Urgent):表示本报文段中发送的数据是否包含紧急数据。URG=1 时表示有紧急数据。当 URG=1 时,后面的紧急指针字段才有效

理解:标记是否有紧急数据,URG=1后面的紧急指针字段(Urgent Pointer)才有效
5.ACK:表示前面的确认号字段是否有效。ACK=1 时表示有效。只有当 ACK=1 时,前面的确认号字段才有效。TCP 规定,连接建立后,ACK 必须为 1
理解:ACK 必须为 1,表示TCP 确认号(Acknowledgment Number,ACK Number)有效
6.PSH(Push):告诉对方收到该报文段后是否立即把数据推送给上层。如果值为 1,表示应当立即把数据提交给上层,而不是缓存起来
理解:告知接收方是否马上将数据提交给上层,PSH=1,马上提交不是缓存起来
RST:表示是否重置连接。如果 RST=1,说明 TCP 连接出现了严重错误(如主机崩溃),必须释放连接,然后再重新建立连接。

理解:是否释放连接,重新建立连接, RST=1需要重新连接
7.SYN:在建立连接时使用,用来同步序号。
当 SYN=1,ACK=0 时,表示这是一个请求建立连接的报文段;
当 SYN=1,ACK=1 时,表示对方同意建立连接。
SYN=1 时,说明这是一个请求建立连接或同意建立连接的报文。只有在前两次握手中 SYN 才为 1。
8.FIN:标记数据是否发送完毕。如果 FIN=1,表示数据已经发送完成,可以释放连接。
窗口大小字段

窗口大小(Window Size):占 16 位。它表示从 Ack Number 开始还可以接收多少字节的数据量,也表示当前接收端的接收窗口还有多少剩余空间。该字段可以用于 TCP 的流量控制。
理解:TCP流量控制,表示目前还有多少空间,能接收多少数据量
第五行
1.TCP 校验和字段
校验位(TCP Checksum):占 16 位。它用于确认传输的数据是否有损坏。发送端基于数据内容校验生成一个数值,接收端根据接收的数据校验生成一个值。两个值必须相同,才能证明数据是有效的。如果两个值不同,则丢掉这个数据包。Checksum 是根据伪头 + TCP 头 + TCP 数据三部分进行计算的。
理解:校验传输过程中数据是否损坏,丢包等
2.紧急指针字段
紧急指针(Urgent Pointer):仅当前面的 URG 控制位为 1 时才有意义。它指出本数据段中为紧急数据的字节数,占 16 位。当所有紧急数据处理完后,TCP 就会告诉应用程序恢复到正常操作。即使当前窗口大小为 0,也是可以发送紧急数据的,因为紧急数据无须缓存。
理解:URG 控制位为 1 ,表示紧急数据字节数,此时会优先处理紧急数据,窗口大小为 0,也是可以发送紧急数据的,因为紧急数据无须缓存。
第六行:可选项字段
选项(Option):可以自定义头部字段,长度不定,但长度必须是 32bits 的整数倍
第七行:TCP数据
数据部分(可以不发送任何数据)
6. 短网址
短网址通常使用“比较少字符的网址”+“/”+“代码”,打开短网址网页通常会直接跳转到你要缩短的网址(常见),或者几秒广告后在跳转。比如向百度短网址可以自定义后缀,有些短网址还可以进行泛域名解析,十分方便大家使用。
应用: 短信, 微博等限制字数的场合, 笔试链接
算法原理
算法一
1)将长网址md5生成32位签名串,分为4段, 每段1个字节(即8位);
2)对这四段循环处理, 取4个字节(32位), 将他看成16进制串与0x3fffffff(30位1)与操作, 即超过30位的忽略处理;
3)这30位分成6段, 每5位的数字作为字母表的索引取得特定字符, 依次进行获得6位字符串;
4)总的md5串可以获得4个6位串; 取里面的任意一个就可作为这个长url的短url地址;
算法二
把数字和字符组合做一定的映射,就可以产生唯一的字符串,如第62个组合就是aaaaa9,第63个组合就是aaaaba,再利用洗牌算法,把原字符串打乱后保存,那么对应位置的组合字符串就会是无序的组合。
把长网址存入数据库,取返回的id,找出对应的字符串,例如返回ID为1,那么对应上面的字符串组合就是bbb,同理 ID为2时,字符串组合为bba,依次类推,直至到达62种组合后才会出现重复的可能,所以如果用上面的62个字符,任意取6个字符组合成字符串的话,你的数据存量达到500多亿后才会出现重复的可能。
八. 888888888888888888888 设计模式
1.单例模式

  1. 分类:
    饿汉式:类加载就会导致该单实例对象被创建
  • 1.静态变量

  • 2.静态代码块

  • 3.枚举

懒汉式:首次使用该对象时才会创建实例对象

  • 单重检查(线程不安全)

  • 单重检查锁(效率低)

  • 双重检查锁

  • 静态内部类

  1. 角色:
    单例类。只能创建一个实例的类
    访问类。使用单例类

  2. 单例模式的实现
    饿汉式-方式1(静态变量方式)
    该方式在成员位置声明Singleton类型的静态变量,并创建Singleton类的对象instance。 instance对象是随着类的加载而创建的。如果该对象足够大的话,而一直没有使用就会造成内存 的浪费。

  3. 饿汉式-方式2(静态代码块方式)
    该方式在成员位置声明Singleton类型的静态变量,而对象的创建是在静态代码块中,也是对着 类的加载而创建。所以和饿汉式的方式1基本上一样,当然该方式也存在内存浪费问题。

  4. 懒汉式-方式1(线程不安全)
    从上面代码我们可以看出该方式在成员位置声明Singleton类型的静态变量,并没有进行对象的 赋值操作,那么什么时候赋值的呢?当调用getInstance()方法获取Singleton类的对象的时 候才创建Singleton类的对象,这样就实现了懒加载的效果。但是,如果是多线程环境,会出现 线程安全问题。

  5. 懒汉式-方式2(线程安全)
    该方式也实现了懒加载效果,同时又解决了线程安全问题。但是在getInstance()方法上添加了 synchronized关键字,导致该方法的执行效果特别低。从上面代码我们可以看出,其实就是在 初始化instance的时候才会出现线程安全问题,一旦初始化完成就不存在了。

  6. 懒汉式-方式3(双重检查锁)
    再来讨论一下懒汉模式中加锁的问题,对于 getInstance() 方法来说,绝大部分的操作都是读操作,读操作是线程安全的,所以我们没必让每个线程必须持有锁才能调用该方法,我们需要调整加锁的时机。由此也产生了一种新的实现模式:双重检查锁模式

双重检查锁模式是一种非常好的单例实现模式,解决了单例、性能、线程安全问题,上面的双重检测锁模式看上去完美无缺,其实是存在问题,在多线程的情况下,可能会出现空指针问题,出现问题的原因是JVM在实例化对象的时候会进行优化和指令重排序操作。要解决双重检查锁模式带来空指针异常的问题,只需要使用 volatile 关键字, volatile 关键字可以保证可见性和有序性。

  1. 懒汉式-方式4(静态内部类方式)
    静态内部类单例模式中实例由内部类创建,由于 JVM 在加载外部类的过程中, 是不会加载静态 内部类的, 只有内部类的属性/方法被调用时才会被加载, 并初始化其静态属性。静态属性由于被 static 修饰,保证只被实例化一次,并且严格保证实例化顺序。
    第一次加载Singleton类时不会去初始化INSTANCE,只有第一次调用getInstance,虚拟机加载SingletonHolder并初始化INSTANCE,这样不仅能确保线程安全,也能保证 Singleton 类的唯一性。
    静态内部类单例模式是一种优秀的单例模式,是开源项目中比较常用的一种单例模式。在没有加任
    何锁的情况下,保证了多线程下的安全,并且没有任何性能影响和空间的浪费

  2. 枚举方式
    枚举类实现单例模式是极力推荐的单例实现模式,因为枚举类型是线程安全的,并且只会装载一 次,设计者充分的利用了枚举的这个特性来实现单例模式,枚举的写法非常简单,而且枚举类型是 所用单例实现中唯一一种不会被破坏的单例实现模式。

九. 99999999999999999999 分布式系统
1.CAP理论
当发生网络分区的时候,如果我们要继续服务,那么强一致性和可用性只能 2 选 1。也就是说当网络分区之后 P 是前提,决定了 P 之后才有 C 和 A 的选择。也就是说分区容错性(Partition tolerance)我们是必须要实现的。
简而言之就是:CAP 理论中分区容错性 P 是一定要满足的,在此基础上,只能满足可用性 A 或者一致性 C。
为啥不可能选择 CA 架构呢? 举个例子:若系统出现“分区”,系统中的某个节点在进行写操作。为了保证 C, 必须要禁止其他节点的读写操作,这就和 A 发生冲突了。如果为了保证 A,其他节点的读写操作正常的话,那就和 C 发生冲突了。
如果系统没有发生“分区”的话,节点间的网络连接通信正常的话,也就不存在 P 了。这个时候,我们就可以同时保证 C 和 A 了。
总结:如果系统发生“分区”,我们要考虑选择 CP 还是 AP。如果系统没有发生“分区”的话,我们要思考如何保证 CA
2.BASE 理论
BASE 是 Basically Available(基本可用) 、Soft-state(软状态) 和 Eventually Consistent(最终一致性) 三个短语的缩写。BASE 理论是对 CAP 中一致性 C 和可用性 A 权衡的结果,其来源于对大规模互联网系统分布式实践的总结,是基于 CAP 定理逐步演化而来的,它大大降低了我们对系统的要求。
因此,AP 方案只是在系统发生分区的时候放弃一致性,而不是永远放弃一致性。在分区故障恢复后,系统应该达到最终一致性。这一点其实就是 BASE 理论延伸的地方
#1. 基本可用
基本可用是指分布式系统在出现不可预知故障的时候,允许损失部分可用性。但是,这绝不等价于系统不可用。
什么叫允许损失部分可用性呢?

  • 响应时间上的损失: 正常情况下,处理用户请求需要 0.5s 返回结果,但是由于系统出现故障,处理用户请求的时间变为 3 s。
  • 系统功能上的损失:正常情况下,用户可以使用系统的全部功能,但是由于系统访问量突然剧增,系统的部分非核心功能无法使用。
    #2. 软状态
    软状态指允许系统中的数据存在中间状态(CAP 理论中的数据不一致),并认为该中间状态的存在不会影响系统的整体可用性,即允许系统在不同节点的数据副本之间进行数据同步的过程存在延时。
    #3. 最终一致性
    最终一致性强调的是系统中所有的数据副本,在经过一段时间的同步后,最终能够达到一个一致的状态。因此,最终一致性的本质是需要系统保证最终数据能够达到一致,而不需要实时保证系统数据的强一致性。
    分布式一致性的 3 种级别:
  1. 强一致性 :系统写入了什么,读出来的就是什么。
  2. 弱一致性 :不一定可以读取到最新写入的值,也不保证多少时间之后读取到的数据是最新的,只是会尽量保证某个时刻达到数据一致的状态。
  3. 最终一致性 :弱一致性的升级版,系统会保证在一定时间内达到数据一致的状态。
    业界比较推崇是最终一致性级别,但是某些对数据一致要求十分严格的场景比如银行转账还是要保证强一致性。
    那实现最终一致性的具体方式是什么呢? 《分布式协议与算法实战》open in new window 中是这样介绍:
  • 读时修复 : 在读取数据时,检测数据的不一致,进行修复。比如 Cassandra 的 Read Repair 实现,具体来说,在向 Cassandra 系统查询数据的时候,如果检测到不同节点 的副本数据不一致,系统就自动修复数据。
  • 写时修复 : 在写入数据,检测数据的不一致时,进行修复。比如 Cassandra 的 Hinted Handoff 实现。具体来说,Cassandra 集群的节点之间远程写数据的时候,如果写失败 就将数据缓存下来,然后定时重传,修复数据的不一致性。
  • 异步修复 : 这个是最常用的方式,通过定时对账检测副本数据的一致性,并修复。
    比较推荐 写时修复,这种方式对性能消耗比较低。
    #总结
    ACID 是数据库事务完整性的理论,CAP 是分布式系统设计理论,BASE 是 CAP 理论中 AP 方案的延伸。

3.Paxos算法
兰伯特当时提出的 Paxos 算法主要包含 2 个部分:

  • Basic Paxos 算法 : 描述的是多节点之间如何就某个值(提案 Value)达成共识。
  • Multi-Paxos 思想 : 描述的是执行多个 Basic Paxos 实例,就一系列值达成共识。Multi-Paxos 说白了就是执行多次 Basic Paxos ,核心还是 Basic Paxos 。
    Basic Paxos 中存在 3 个重要的角色:
  1. 提议者(Proposer):也可以叫做协调者(coordinator),提议者负责接受客户端发起的提议,然后尝试让接受者接受该提议,同时保证即使多个提议者的提议之间产生了冲突,那么算法都能进行下去;
  2. 接受者(Acceptor):也可以叫做投票员(voter),负责对提议者的提议投票,同时需要记住自己的投票历史;
  3. 学习者(Learner):如果有超过半数接受者就某个提议达成了共识,那么学习者就需要接受这个提议,并就该提议作出运算,然后将运算结果返回给客户端。
    Multi-Paxos 只是一种思想,这种思想的核心就是通过多个 Basic Paxos 实例就一系列值达成共识。
    二阶段提交是达成共识常用的方式,Basic Paxos 就是通过二阶段提交的方式来达成共识。Basic Paxos 还支持容错,少于一般的节点出现故障时,集群也能正常工作。
  4. 网关
    一般情况下,网关都会提供请求转发、安全认证(身份/权限认证)、流量控制、负载均衡、容灾、日志、监控这些功能。网关主要做了一件事情:请求过滤 。
    常见的网关: Netflix Zuul, Spring Cloud Gateway, APISIX(APISIX 是一款基于 Nginx 和 etcd 的高性能、云原生、可扩展的网关系统。), Shenyu
  5. 分布式 ID
    不同的数据节点生成全局唯一主键
    分布式 ID 需要满足哪些要求?
    分布式 ID 作为分布式系统中必不可少的一环,很多地方都要用到分布式 ID。
    一个最基本的分布式 ID 需要满足下面这些要求:
  • 全局唯一 :ID 的全局唯一性肯定是首先要满足的!
  • 高性能 : 分布式 ID 的生成速度要快,对本地资源消耗要小。
  • 高可用 :生成分布式 ID 的服务要保证可用性无限接近于 100%。
  • 方便易用 :拿来即用,使用方便,快速接入!
    除了这些之外,一个比较好的分布式 ID 还应保证:
  • 安全 :ID 中不包含敏感信息。
  • 有序递增 :如果要把 ID 存放在数据库的话,ID 的有序性可以提升数据库写入速度。并且,很多时候 ,我们还很有可能会直接通过 ID 来进行排序。
  • 有具体的业务含义 :生成的 ID 如果能有具体的业务含义,可以让定位问题以及开发更透明化(通过 ID 就能确定是哪个业务)。
  • 独立部署 :也就是分布式系统单独有一个发号器服务,专门用来生成分布式 ID。这样就生成 ID 的服务可以和业务相关的服务解耦。
    分布式 ID 常见解决方案

数据库主键自增
这种方式就比较简单直白了,就是通过关系型数据库的自增主键产生来唯一的 ID。

  • 优点 :实现起来比较简单、ID 有序递增、存储消耗空间小
  • 缺点 : 支持的并发量不大、存在数据库单点问题(可以使用数据库集群解决,不过增加了复杂度)、ID 没有具体业务含义、安全问题(比如根据订单 ID 的递增规律就能推算出每天的订单量,商业机密啊! )、每次获取 ID 都要访问一次数据库(增加了对数据库的压力,获取速度也慢)

数据库号段模式
数据库主键自增这种模式,每次获取 ID 都要访问一次数据库,ID 需求比较大的时候,肯定是不行的。
如果我们可以批量获取,然后存在在内存里面,需要用到的时候,直接从内存里面拿就舒服了!这也就是我们说的 基于数据库的号段模式来生成分布式 ID。
数据库号段模式的优缺点:

  • 优点 :ID 有序递增、存储消耗空间小
  • 缺点 :存在数据库单点问题(可以使用数据库集群解决,不过增加了复杂度)、ID 没有具体业务含义、安全问题(比如根据订单 ID 的递增规律就能推算出每天的订单量,商业机密啊! )

NoSQL
NoSQL 方案使用 Redis 多一些。我们通过 Redis 的
incr 命令即可实现对 id 原子顺序递增。
127.0.0.1:6379> set sequence_id_biz_type 1
OK
127.0.0.1:6379> incr sequence_id_biz_type
(integer) 2
127.0.0.1:6379> get sequence_id_biz_type
“2”

Redis 方案的优缺点:

  • 优点 : 性能不错并且生成的 ID 是有序递增的
  • 缺点 : 和数据库主键自增方案的缺点类似

UUID
UUID 是 Universally Unique Identifier(通用唯一标识符) 的缩写。UUID 包含 32 个 16 进制数字(8-4-4-4-12)。
JDK 就提供了现成的生成 UUID 的方法,一行代码就行了。
//输出示例:cb4a9ede-fa5e-4585-b9bb-d60bce986eaa
UUID.randomUUID()

UUID 可以做到全局唯一性,但是,我们一般很少会使用它。
比如使用 UUID 作为 MySQL 数据库主键的时候就非常不合适:

  • 数据库主键要尽量越短越好,而 UUID 的消耗的存储空间比较大(32 个字符串,128 位)。
  • UUID 是无顺序的,InnoDB 引擎下,数据库主键的无序性会严重影响数据库性能。
    最后,我们再简单分析一下 UUID 的优缺点 (面试的时候可能会被问到的哦!) :
  • 优点 :生成速度比较快、简单易用
  • 缺点 : 存储消耗空间大(32 个字符串,128 位) 、 不安全(基于 MAC 地址生成 UUID 的算法会造成 MAC 地址泄露)、无序(非自增)、没有具体业务含义、需要解决重复 ID 问题(当机器时间不对的情况下,可能导致会产生重复 ID)

Snowflake(雪花算法)
Snowflake 是 Twitter 开源的分布式 ID 生成算法。Snowflake 由 64 bit 的二进制数字组成,这 64bit 的二进制被分成了几部分,每一部分存储的数据都有特定的含义:

  • 第 0 位: 符号位(标识正负),始终为 0,没有用,不用管。
  • 第 1~41 位 :一共 41 位,用来表示时间戳,单位是毫秒,可以支撑 2 ^41 毫秒(约 69 年)
  • 第 42~52 位 :一共 10 位,一般来说,前 5 位表示机房 ID,后 5 位表示机器 ID(实际项目中可以根据实际情况调整)。这样就可以区分不同集群/机房的节点。
  • 第 53~64 位 :一共 12 位,用来表示序列号。 序列号为自增值,代表单台机器每毫秒能够产生的最大 ID 数(2^12 = 4096),也就是说单台机器每毫秒最多可以生成 4096 个 唯一 ID。
  • 优点 :生成速度比较快、生成的 ID 有序递增、比较灵活(可以对 Snowflake 算法进行简单的改造比如加入业务 ID)
  • 缺点 : 需要解决重复 ID 问题(依赖时间,当机器时间不对的情况下,可能导致会产生重复 ID)。f
  1. 分布式锁
    一个最基本的分布式锁需要满足:
  • 互斥 :任意一个时刻,锁只能被一个线程持有;
  • 高可用 :锁服务是高可用的。并且,即使客户端的释放锁的代码逻辑出现问题,锁最终一定还是会被释放,不会影响其他线程对共享资源的访问。
    通常情况下,我们一般会选择基于 Redis 或者 ZooKeeper 实现分布式锁,Redis 用的要更多一点,我这里也以 Redis 为例介绍分布式锁的实现。
    如何基于 Redis 实现一个最简易的分布式锁?
    不论是实现锁还是分布式锁,核心都在于“互斥”。
    在 Redis 中, SETNX 命令是可以帮助我们实现互斥。SETNX 即 SET if Not eXists (对应 Java 中的 setIfAbsent 方法),如果 key 不存在的话,才会设置 key 的值。如果 key 已经存在, SETNX 啥也不做。

这是一种最简易的 Redis 分布式锁实现,实现方式比较简单,性能也很高效。不过,这种方式实现分布式锁存在一些问题。就比如应用程序遇到一些问题比如释放锁的逻辑突然挂掉,可能会导致锁无法被释放,进而造成共享资源无法再被其他线程/进程访问。
#为什么要给锁设置一个过期时间?
为了避免锁无法被释放,我们可以想到的一个解决办法就是:给这个 key(也就是锁) 设置一个过期时间。

127.0.0.1:6379> SET lockKey uniqueValue EX 3 NX
OK

1
2

  • lockKey :加锁的锁名;
  • uniqueValue :能够唯一标示锁的随机字符串;
  • NX :只有当 lockKey 对应的 key 值不存在的时候才能 SET 成功;
  • EX :过期时间设置(秒为单位)EX 3 标示这个锁有一个 3 秒的自动过期时间。与 EX 对应的是 PX(毫秒为单位),这两个都是过期时间设置。
    一定要保证设置指定 key 的值和过期时间是一个原子操作!!! 不然的话,依然可能会出现锁无法被释放的问题。
    这样确实可以解决问题,不过,这种解决办法同样存在漏洞:如果操作共享资源的时间大于过期时间,就会出现锁提前过期的问题,进而导致分布式锁直接失效。如果锁的超时时间设置过长,又会影响到性能。
    你或许在想: 如果操作共享资源的操作还未完成,锁过期时间能够自己续期就好了!
    Redisson

lua脚本为什么可以保证原子性
简单来说,因为所有的lua脚本在Redis实例中共用同一个Lua解释器,某一个lua脚本在被执行的时候,其他lua脚本或命令无法执行。
因此对于其他lua脚本而言,一个lua脚本要么不可见,要么就已经执行完了。
7. 分布式限流器
一般限流器有五种算法,分别是:令牌桶,漏斗桶,固定窗口,滑动日志(指的其实是广义上的滑动窗口),滑动窗口(这里指的是滑动日志+固定窗口结合的一种算法)。

  1. 令牌桶(Token bucket)
    令牌桶算法用来控制一段时间内发送到网络上的数据的数目,并允许突发数据的发送。
    算法大概是: 假设允许的请求速率为r次每秒,那么每过1/r秒就会向桶里面添加一个令牌。桶的最大大小是b。当一个大小为n的请求到来时,检查桶内令牌数是否足够,如果足够,令牌数减少n,请求通过。不够的话就会触发拒绝策略。
    令牌桶有一个固定大小,假设每一个请求也有一个大小,当要检查请求是否符合定义的限制时,会检查桶,以确定它当时是否包含足够的令牌。如果有,那么会移除掉这些令牌,请求通过。否则,会采取其他操作,一般是拒绝。令牌桶中的令牌会以一定速率恢复,这个速率就是允许请求的速率(当然,根据大小的配置,可能实际会超过这个速率,但是随着令牌桶的消耗会被调整回这个恢复速率)。
    如果令牌不被消耗,或者被消耗的速度小于产生的速度,令牌就会不断地增多,直到把桶填满。可以看出,令牌桶在保持整体上的请求速率的同时,允许某种程度的突发传输。
    分布式环境下的令牌桶的实现需要考虑如下几个问题:
    令牌桶当前大小究竟如何存储?是只存储一个当前令牌桶的大小(例如通过 redis 的一个键值对存储),还是存放每个通过的请求到来的时间戳(例如通过 redis 的 zset 实现,zset 的大小就是桶的最大大小)?
    令牌桶的令牌补充是由谁补充?对于存储一个当前令牌桶的大小的实现方式,需要一个进程以速率r不断地往里面添加令牌,那么如何在分布式的环境下保证有且只有一个这样的进程,这个进程挂了怎么办?对于存放每个通过的请求到来的时间戳的这种实现方式实现,那么怎么控制记录请求的个数,肯定不能每个都记录,并且每次怎么通过目前的请求以及时间戳来判断剩余令牌数量
  2. 漏斗桶(Leaky bucket)
    漏斗桶控制请求必须在最大某个速率被消费,就像一个漏斗一样,入水量可大可小,但是最大速率只能到某一量值,不会像令牌桶一样,会有小的尖峰。
    算法大概是: 主要实现方式是通过一个 FIFO (First in first out)的队列实现,这个队列是一个有界队列,大小为b,如果请求堆积满了队列,就会触发丢弃策略。假设允许的请求速率为r次每秒,那么这个队列中的请求,就会以这个速率进行消费。
    分布式环境下的漏桶的实现需要考虑如下几个问题:
  3. 漏桶的队列,怎么存放?这个队列需要存放每个通过的请求以及对应的消费的时间戳,保证消费的平稳。同时,这个队列最好是无锁队列,因为会有分布式锁征用。并且,这个队列大小应该设置为b,并每次有请求到来时,放入队列的同时清理队列。
  4. 消费如何实现?也就是存入队列的请求,如何消费呢?可以请求到来时,通过队列中的请求来判断当前这个请求的执行时间应该是多久以后,之后入队列,延迟这么久再执行这个请求。也可以利用本身带延迟时间实现的队列来实现。
  5. 固定时间窗口(Fixed window)
    固定时间窗口比较简单,就是将时间切分成若干个时间片,每个时间片内固定处理若干个请求。这种实现不是非常严谨,但是由于实现简单,适用于一些要求不严格的场景。
    算法大概是: 假设n秒内最多处理b个请求,那么每隔n秒将计数器重置为b。请求到来时,如果计数器值足够,则扣除并请求通过,不够则触发拒绝策略。
    固定时间窗口是最容易实现的算法,但是也是有明显的缺陷:那就是在很多情况下,尤其是请求限流后拒绝策略为排队的情况下,请求都在时间窗口的开头被迅速消耗,剩下的时间不处理任何请求,这是不太可取的。并且,在一些极限情况下,实际上的流量速度可能达到限流的 2 倍。例如限制 1 秒内最多 100 个请求。假设 0.99 秒的时候 100 个请求到了,之后 1.01 秒的时候又有 100 个请求到了,这样的话其实在 0.99 秒 ~ 1.01 秒这一段时间内有 200 个请求,并不是严格意义上的每一秒都只处理 100 个请求。为了能实现严格意义上的请求限流,则有了后面两种算法。
  6. 滑动日志(Sliding Log)
    滑动日志根据缓存之前接受请求对应的时间戳,与当前请求的时间戳进行计算,控制速率。这样可以严格限制请求速率。一般的网上提到的滑动窗口算法也指的是这里的滑动日志(Sliding Log)算法,但是我们这里的滑动窗口是另一种优化的算法,待会会提到。
    算法大概是: 假设n秒内最多处理b个请求。那么会最多缓存 b 个通过的请求与对应的时间戳,假设这个缓存集合为B。每当有请求到来时,从B中删除掉n秒前的所有请求,查看集合是否满了,如果没满,则通过请求,并放入集合,如果满了就触发拒绝策略。
    分布式环境下的滑动日志的实现需要考虑如下几个问题:
    我们的算法其实已经简化了存储,但是对于高并发的场景,要缓存的请求可能会很多(例如限制每秒十万的请求,那么这个缓存的大小是否就应该能存储十万个请求?),这个缓存应该如何实现?
    高并发场景下,对于这个集合的删除掉n秒前的所有请求的这个操作,需要速度非常快。如果你的缓存集合实现对于按照时间戳删除这个操作比较慢,可以缓存多一点请求,定时清理删除n秒前的所有请求而不是每次请求到来都删除。请求到来的时候,查看b个之前的请求是否存在并且时间差小于n秒,存在并且小于代表应该触发限流策略。
  7. 滑动窗口(滑动日志 + 固定窗口)
    前面的滑动日志,我们提到了一个问题 - 要缓存的请求可能会很多。也许在我们的架构内不能使用一个恰当的缓存来实现,我们可以通过滑动窗口这个方法来减少要存储的请求数量,并减少集合大小减少同一个集合上面的并发。
    算法大概是: 假设n秒内最多处理b个请求。我们可以将n秒切分成每个大小为m毫秒得时间片,只有最新的时间片内缓存请求和时间戳,之前的时间片内只保留一个请求量的数字。这样可以大大优化存储,小幅度增加计算量。对于临界条件,就是之前已经有了n/m个时间片,计算n秒内请求量时可以计算当前时间片内经过时间的百分比,假设是 25%,那么就取开头的第一个时间片的请求量的 75% 进行计算
    ————————————————
    版权声明:本文为CSDN博主「干货满满张哈希」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
    原文链接:https://blog.csdn.net/zhxdick/article/details/113491670
  8. RPC基础
    RPC 的为了让调用远程方法像调用本地方法一样简单。

RPC 的原理

  1. 客户端(服务消费端) :调用远程方法的一端。

  2. 客户端 Stub(桩) : 这其实就是一代理类。代理类主要做的事情很简单,就是把你调用方法、类、方法参数等信息传递到服务端。

  3. 网络传输 : 网络传输就是你要把你调用的方法的信息比如说参数啊这些东西传输到服务端,然后服务端执行完之后再把返回结果通过网络传输给你传输回来。网络传输的实现方式有很多种比如最近基本的 Socket或者性能以及封装更加优秀的 Netty(推荐)。

  4. 服务端 Stub(桩) :这个桩就不是代理类了。我觉得理解为桩实际不太好,大家注意一下就好。这里的服务端 Stub 实际指的就是接收到客户端执行方法的请求后,去指定对应的方法然后返回结果给客户端的类。

  5. 服务端(服务提供端) :提供远程方法的一端。

  6. 服务消费端(client)以本地调用的方式调用远程服务;

  7. 客户端 Stub(client stub) 接收到调用后负责将方法、参数等组装成能够进行网络传输的消息体(序列化)(rpcRequest), 然后找到远程服务的地址,并将消息发送到服务提供端

  8. 服务端 Stub(桩)收到消息将消息反序列化为Java对象: 根据RpcRequest中的类、方法、方法参数等信息调用本地的方法;然后得到方法执行结果并将组装成能够进行网络传输的消息体:RpcResponse(序列化)后发送至消费方;

  9. 客户端 Stub(client stub)接收到消息并将消息反序列化为Java对象:
    RpcResponse ,这样也就得到了最终结果。

  10. Dubbo基础
    dʌbəʊ| 是一款高性能、轻量级的开源 Java RPC 框架。

  11. 面向接口代理的高性能RPC调用。

  12. 智能容错和负载均衡。

  13. 服务自动注册和发现。

  14. 高度可扩展能力。

  15. 运行期流量调度。

  16. 可视化的服务治理与运维。

Dubbo 帮助我们解决了什么问题呢?

  1. 负载均衡 : 同一个服务部署在不同的机器时该调用哪一台机器上的服务。
  2. 服务调用链路生成 : 随着系统的发展,服务越来越多,服务间依赖关系变得错踪复杂,甚至分不清哪个应用要在哪个应用之前启动,架构师都不能完整的描述应用的架构关系。Dubbo 可以为我们解决服务之间互相是如何调用的。
  3. 服务访问压力以及时长统计、资源调度和治理 :基于访问压力实时管理集群容量,提高集群利用率。

Dubbo 架构中的核心角色

  • Container: 服务运行容器,负责加载、运行服务提供者。必须。
  • Provider: 暴露服务的服务提供方,会向注册中心注册自己提供的服务。必须。
  • Consumer: 调用远程服务的服务消费方,会向注册中心订阅自己所需的服务。必须。
  • Registry: 服务注册与发现的注册中心。注册中心会返回服务提供者地址列表给消费者。非必须。
  • Monitor: 统计服务的调用次数和调用时间的监控中心。服务消费者和提供者会定时发送统计数据到监控中心。 非必须。

Dubbo 中的 Invoker 概念了解么?
Invoker 就是 Dubbo 对远程调用的抽象。

按照 Dubbo 官方的话来说,
Invoker 分为

  • 服务提供
    Invoker
  • 服务消费
    Invoker

Dubbo 的工作原理了解么

Dubbo 的 SPI 机制
如何扩展 Dubbo 中的默认实现
SPI(Service Provider Interface) 机制被大量
用在开源项目中,它可以帮助我们动态寻找服务/功能(比如负载均衡策略)的实现。
Java 本身就提供了 SPI 机制的实现。不过,Dubbo 没有直接用,而是对 Java原生的 SPI机制进行了增强,以便更好满足自己的需求。
那我们如何扩展 Dubbo 中的默认实现呢?
比如说我们想要实现自己的负载均衡策略,我们创建对应的实现类
XxxLoadBalance 实现
LoadBalance 接口或者
AbstractLoadBalance 类。

Dubbo 的微内核架构
Dubbo 采用 微内核(Microkernel) + 插件(Plugin) 模式,简单来说就是微内核架构。微内核只负责组装插件。
微内核架构包含两类组件:核心系统(core system) 和 插件模块(plug-in modules)。
Dubbo 不想依赖 Spring 等 IoC 容器,也不想自己造一个小的 IoC 容器(过度设计),因此采用了一种最简单的 Factory 方式管理插件 :JDK 标准的 SPI 扩展机制 (
java.util.ServiceLoader)

注册中心的作用了解么
注册中心负责服务地址的注册与查找,相当于目录服务,服务提供者和消费者只在启动时与注册中心交互。
服务提供者宕机后,注册中心会做什么?
注册中心会立即推送事件通知消费者。
#监控中心的作用呢?
监控中心负责统计各服务调用次数,调用时间等。
#注册中心和监控中心都宕机的话,服务都会挂掉吗?
不会。两者都宕机也不影响已运行的提供者和消费者,消费者在本地缓存了提供者列表。注册中心和监控中心都是可选的,服务消费者可以直连服务提供者。

Dubbo 的负载均衡策略
就是如何将请求分发到服务器上, 以保证最大化吞吐量,最小化响应时间,并避免任何单个资源的过载

  1. 在集群负载均衡时,Dubbo 提供了多种均衡策略,默认为
    random 随机调用。我们还可以自行扩展负载均衡策略(参考Dubbo SPI机制)。
    在 Dubbo 中,所有负载均衡实现类均继承自
    AbstractLoadBalance,该类实现了
    LoadBalance 接口,并封装了一些公共的逻辑。

RandomLoadBalance
根据权重随机选择(对加权随机算法的实现)。这是Dubbo默认采用的一种负载均衡策略。
RandomLoadBalance 具体的实现原理非常简单,假如有两个提供相同服务的服务器 S1,S2,S1的权重为7,S2的权重为3。
我们把这些权重值分布在坐标区间会得到:S1->[0, 7) ,S2->[7, 10)。我们生成[0, 10) 之间的随机数,随机数落到对应的区间,我们就选择对应的服务器来处理请求。

LeastActiveLoadBalance
LeastActiveLoadBalance 直译过来就是最小活跃数负载均衡。
这个名字起得有点不直观,不仔细看官方对活跃数的定义,你压根不知道这玩意是干嘛的。
我这么说吧!初始状态下所有服务提供者的活跃数均为 0(每个服务提供者的中特定方法都对应一个活跃数,我在后面的源码中会提到),每收到一个请求后,对应的服务提供者的活跃数 +1,当这个请求处理完之后,活跃数 -1。
因此,Dubbo 就认为谁的活跃数越少,谁的处理速度就越快,性能也越好,这样的话,我就优先把请求给活跃数少的服务提供者处理。
如果有多个服务提供者的活跃数相等怎么办?
很简单,那就再走一遍
RandomLoadBalance 。

ConsistentHashLoadBalance
ConsistentHashLoadBalance 小伙伴们应该也不会陌生,在分库分表、各种集群中就经常使用这个负载均衡策略。
ConsistentHashLoadBalance 即一致性Hash负载均衡策略。
ConsistentHashLoadBalance 中没有权重的概念,具体是哪个服务提供者处理请求是由你的请求的参数决定的,也就是说相同参数的请求总是发到同一个服务提供者。
另外,Dubbo 为了避免数据倾斜问题(节点不够分散,大量请求落到同一节点),还引入了虚拟节点的概念。通过虚拟节点可以让节点更加分散,有效均衡各个节点的请求量。
RoundRobinLoadBalance
加权轮询负载均衡。
轮询就是把请求依次分配给每个服务提供者。加权轮询就是在轮询的基础上,让更多的请求落到权重更大的服务提供者上。比如假如有两个提供相同服务的服务器 S1,S2,S1的权重为7,S2的权重为3。
如果我们有 10 次请求,那么 7 次会被 S1处理,3次被 S2处理。
但是,如果是
RandomLoadBalance 的话,很可能存在10次请求有9次都被 S1 处理的情况(概率性问题)。
Dubbo 中的
RoundRobinLoadBalance 的代码实现被修改重建了好几次,Dubbo-2.6.5 版本的
RoundRobinLoadBalance 为平滑加权轮询算法。

Dubbo序列化协议
Dubbo 支持多种序列化方式:JDK自带的序列化、hessian2、JSON、Kryo、FST、Protostuff,ProtoBuf等等。
Dubbo 默认使用的序列化方式是 hession2。

  1. 常见的 RPC 框架总结
  • RMI(JDK自带): JDK自带的RPC,有很多局限性,不推荐使用。
  • Dubbo: Dubbo是 阿里巴巴公司开源的一个高性能优秀的服务框架,使得应用可通过高性能的 RPC 实现服务的输出和输入功能,可以和 Spring框架无缝集成。目前 Dubbo 已经成为 Spring Cloud Alibaba 中的官方组件。
  • gRPC :gRPC是可以在任何环境中运行的现代开源高性能RPC框架。它可以通过可插拔的支持来有效地连接数据中心内和跨数据中心的服务,以实现负载平衡,跟踪,运行状况检查和身份验证。它也适用于分布式计算的最后一英里,以将设备,移动应用程序和浏览器连接到后端服务。
  • Hessian: Hessian是一个轻量级的remoting on http工具,使用简单的方法提供了RMI的功能。 相比WebService,Hessian更简单、快捷。采用的是二进制RPC协议,因为采用的是二进制协议,所以它很适合于发送二进制数据。
  • Thrift: Apache Thrift是Facebook开源的跨语言的RPC通信框架,目前已经捐献给Apache基金会管理,由于其跨语言特性和出色的性能,在很多互联网公司得到应用,有能力的公司甚至会基于thrift研发一套分布式服务框架,增加诸如服务注册、服务发现等功能。
  1. ZooKeeper
    ZooKeeper 是一个开源的分布式协调服务,它的设计目标是将那些复杂且容易出错的分布式一致性服务封装起来,构成一个高效可靠的原语集,并以一系列简单易用的接口提供给用户使用。
    ZooKeeper 为我们提供了高可用、高性能、稳定的分布式数据一致性解决方案,通常被用于实现诸如数据发布/订阅、负载均衡、命名服务、分布式协调/通知、集群管理、Master 选举、分布式锁和分布式队列等功能。
    ZooKeeper 特点
  • 顺序一致性: 从同一客户端发起的事务请求,最终将会严格地按照顺序被应用到 ZooKeeper 中去。
  • 原子性: 所有事务请求的处理结果在整个集群中所有机器上的应用情况是一致的,也就是说,要么整个集群中所有的机器都成功应用了某一个事务,要么都没有应用。
  • 单一系统映像 : 无论客户端连到哪一个 ZooKeeper 服务器上,其看到的服务端数据模型都是一致的。
  • 可靠性: 一旦一次更改请求被应用,更改的结果就会被持久化,直到被下一次更改覆盖。
    2.4. ZooKeeper 典型应用场景
    ZooKeeper 概览中,我们介绍到使用其通常被用于实现诸如数据发布/订阅、负载均衡、命名服务、分布式协调/通知、集群管理、Master 选举、分布式锁和分布式队列等功能。
    下面选 3 个典型的应用场景来专门说说:
  1. 分布式锁 : 通过创建唯一节点获得分布式锁,当获得锁的一方执行完相关代码或者是挂掉之后就释放锁。
  2. 命名服务 :可以通过 ZooKeeper 的顺序节点生成全局唯一 ID
  3. 数据发布/订阅 :通过 Watcher 机制 可以很方便地实现数据发布/订阅。当你将数据发布到 ZooKeeper 被监听的节点上,其他机器可通过监听 ZooKeeper 上节点的变化来实现配置的动态更新。
    有哪些著名的开源项目用到了 ZooKeeper?
  4. Kafka : ZooKeeper 主要为 Kafka 提供 Broker 和 Topic 的注册以及多个 Partition 的负载均衡等功能。
  5. Hbase : ZooKeeper 为 Hbase 提供确保整个集群只有一个 Master 以及保存和提供 regionserver 状态信息(是否在线)等功能。
  6. Hadoop : ZooKeeper 为 Namenode 提供高可用支持。
    3.1. Data model(数据模型)
    ZooKeeper 数据模型采用层次化的多叉树形结构,每个节点上都可以存储数据,这些数据可以是数字、字符串或者是二级制序列。并且。每个节点还可以拥有 N 个子节点,最上层是根节点以“/”来代表。每个数据节点在 ZooKeeper 中被称为 znode,它是 ZooKeeper 中数据的最小单元。并且,每个 znode 都一个唯一的路径标识。
    强调一句:ZooKeeper 主要是用来协调服务的,而不是用来存储业务数据的,所以不要放比较大的数据在 znode 上,ZooKeeper 给出的上限是每个结点的数据大小最大是 1M。
    从下图可以更直观地看出:ZooKeeper 节点路径标识方式和 Unix 文件系统路径非常相似,都是由一系列使用斜杠"/"进行分割的路径表示,开发人员可以向这个节点中写入数据,也可以在节点下面创建子节点。这些操作我们后面都会介绍到。

#3.2. znode(数据节点)
介绍了 ZooKeeper 树形数据模型之后,我们知道每个数据节点在 ZooKeeper 中被称为 znode,它是 ZooKeeper 中数据的最小单元。你要存放的数据就放在上面,是你使用 ZooKeeper 过程中经常需要接触到的一个概念。
#3.2.1. znode 4 种类型
我们通常是将 znode 分为 4 大类:

  • 持久(PERSISTENT)节点 :一旦创建就一直存在即使 ZooKeeper 集群宕机,直到将其删除。
  • 临时(EPHEMERAL)节点 :临时节点的生命周期是与 客户端会话(session) 绑定的,会话消失则节点消失 。并且,临时节点只能做叶子节点 ,不能创建子节点。
  • 持久顺序(PERSISTENT_SEQUENTIAL)节点 :除了具有持久(PERSISTENT)节点的特性之外, 子节点的名称还具有顺序性。比如
    /node1/app0000000001 、
    /node1/app0000000002 。
  • 临时顺序(EPHEMERAL_SEQUENTIAL)节点 :除了具备临时(EPHEMERAL)节点的特性之外,子节点的名称还具有顺序性。
    #3.2.2. znode 数据结构
    每个 znode 由 2 部分组成:
  • stat :状态信息
  • data : 节点存放的数据的具体内容
    如下所示,我通过 get 命令来获取 根目录下的 dubbo 节点的内容。(get 命令在下面会介绍到)。

Stat 类中包含了一个数据节点的所有状态信息的字段,包括事务 ID-cZxid、节点创建时间-ctime 和子节点个数-numChildren 等等。

3.3. 版本(version)
在前面我们已经提到,对应于每个 znode,ZooKeeper 都会为其维护一个叫作 Stat 的数据结构,Stat 中记录了这个 znode 的三个相关的版本:

  • dataVersion :当前 znode 节点的版本号
  • cversion : 当前 znode 子节点的版本
  • aclVersion : 当前 znode 的 ACL 的版本。
    #3.4. ACL(权限控制)
    ZooKeeper 采用 ACL(AccessControlLists)策略来进行权限控制,类似于 UNIX 文件系统的权限控制。
    对于 znode 操作的权限,ZooKeeper 提供了以下 5 种:
  • CREATE : 能创建子节点
  • READ :能获取节点数据和列出其子节点
  • WRITE : 能设置/更新节点数据
  • DELETE : 能删除子节点
  • ADMIN : 能设置节点 ACL 的权限
    其中尤其需要注意的是,CREATE 和 DELETE 这两种权限都是针对 子节点 的权限控制。
    对于身份认证,提供了以下几种方式:
  • world : 默认方式,所有用户都可无条件访问。
  • auth :不使用任何 id,代表任何已认证的用户。
  • digest :用户名:密码认证方式: username:password 。
  • ip : 对指定 ip 进行限制。
    #3.5. Watcher(事件监听器)
    Watcher(事件监听器),是 ZooKeeper 中的一个很重要的特性。ZooKeeper 允许用户在指定节点上注册一些 Watcher,并且在一些特定事件触发的时候,ZooKeeper 服务端会将事件通知到感兴趣的客户端上去,该机制是 ZooKeeper 实现分布式协调服务的重要特性。

破音:非常有用的一个特性,都拿出小本本记好了,后面用到 ZooKeeper 基本离不开 Watcher(事件监听器)机制。
#3.6. 会话(Session)
Session 可以看作是 ZooKeeper 服务器与客户端的之间的一个 TCP 长连接,通过这个连接,客户端能够通过心跳检测与服务器保持有效的会话,也能够向 ZooKeeper 服务器发送请求并接受响应,同时还能够通过该连接接收来自服务器的 Watcher 事件通知。
Session 有一个属性叫做:
sessionTimeout ,
sessionTimeout 代表会话的超时时间。当由于服务器压力太大、网络故障或是客户端主动断开连接等各种原因导致客户端连接断开时,只要在
sessionTimeout规定的时间内能够重新连接上集群中任意一台服务器,那么之前创建的会话仍然有效。
另外,在为客户端创建会话之前,服务端首先会为每个客户端都分配一个
sessionID。由于
sessionID是 ZooKeeper 会话的一个重要标识,许多与会话相关的运行机制都是基于这个
sessionID 的,因此,无论是哪台服务器为客户端分配的
sessionID,都务必保证全局唯一。
4. ZooKeeper 集群
为了保证高可用,最好是以集群形态来部署 ZooKeeper,这样只要集群中大部分机器是可用的(能够容忍一定的机器故障),那么 ZooKeeper 本身仍然是可用的。通常 3 台服务器就可以构成一个 ZooKeeper 集群了。ZooKeeper 官方提供的架构图就是一个 ZooKeeper 集群整体对外提供服务。

上图中每一个 Server 代表一个安装 ZooKeeper 服务的服务器。组成 ZooKeeper 服务的服务器都会在内存中维护当前的服务器状态,并且每台服务器之间都互相保持着通信。集群间通过 ZAB 协议(ZooKeeper Atomic Broadcast)来保持数据的一致性。
最典型集群模式: Master/Slave 模式(主备模式)。在这种模式中,通常 Master 服务器作为主服务器提供写服务,其他的 Slave 服务器从服务器通过异步复制的方式获取 Master 服务器最新的数据提供读服务。
4.1. ZooKeeper 集群角色
但是,在 ZooKeeper 中没有选择传统的 Master/Slave 概念,而是引入了 Leader、Follower 和 Observer 三种角色。如下图所示

ZooKeeper 集群中的所有机器通过一个 Leader 选举过程 来选定一台称为 “Leader” 的机器,Leader 既可以为客户端提供写服务又能提供读服务。除了 Leader 外,Follower 和 Observer 都只能提供读服务。Follower 和 Observer 唯一的区别在于 Observer 机器不参与 Leader 的选举过程,也不参与写操作的“过半写成功”策略,因此 Observer 机器可以在不影响写性能的情况下提升集群的读性能。
无法复制加载中的内容
当 Leader 服务器出现网络中断、崩溃退出与重启等异常情况时,就会进入 Leader 选举过程,这个过程会选举产生新的 Leader 服务器。
这个过程大致是这样的:

  1. Leader election(选举阶段):节点在一开始都处于选举阶段,只要有一个节点得到超半数节点的票数,它就可以当选准 leader。
  2. Discovery(发现阶段) :在这个阶段,followers 跟准 leader 进行通信,同步 followers 最近接收的事务提议。
  3. Synchronization(同步阶段) :同步阶段主要是利用 leader 前一阶段获得的最新提议历史,同步集群中所有的副本。同步完成之后 准 leader 才会成为真正的 leader。
  4. Broadcast(广播阶段) :到了这个阶段,ZooKeeper 集群才能正式对外提供事务服务,并且 leader 可以进行消息广播。同时如果有新的节点加入,还需要对新节点进行同步。
    #4.2. ZooKeeper 集群中的服务器状态
  • LOOKING :寻找 Leader。
  • LEADING :Leader 状态,对应的节点为 Leader。
  • FOLLOWING :Follower 状态,对应的节点为 Follower。
  • OBSERVING :Observer 状态,对应节点为 Observer,该节点不参与 Leader 选举。
    #4.3. ZooKeeper 集群为啥最好奇数台?
    ZooKeeper 集群在宕掉几个 ZooKeeper 服务器之后,如果剩下的 ZooKeeper 服务器个数大于宕掉的个数的话整个 ZooKeeper 才依然可用。假如我们的集群中有 n 台 ZooKeeper 服务器,那么也就是剩下的服务数必须大于 n/2。先说一下结论,2n 和 2n-1 的容忍度是一样的,都是 n-1,大家可以先自己仔细想一想,这应该是一个很简单的数学问题了。
    比如假如我们有 3 台,那么最大允许宕掉 1 台 ZooKeeper 服务器,如果我们有 4 台的的时候也同样只允许宕掉 1 台。 假如我们有 5 台,那么最大允许宕掉 2 台 ZooKeeper 服务器,如果我们有 6 台的的时候也同样只允许宕掉 2 台。
    综上,何必增加那一个不必要的 ZooKeeper 呢?
    #4.4. ZooKeeper 选举的过半机制防止脑裂
    何为集群脑裂?
    对于一个集群,通常多台机器会部署在不同机房,来提高这个集群的可用性。保证可用性的同时,会发生一种机房间网络线路故障,导致机房间网络不通,而集群被割裂成几个小集群。这时候子集群各自选主导致“脑裂”的情况。
    举例说明:比如现在有一个由 6 台服务器所组成的一个集群,部署在了 2 个机房,每个机房 3 台。正常情况下只有 1 个 leader,但是当两个机房中间网络断开的时候,每个机房的 3 台服务器都会认为另一个机房的 3 台服务器下线,而选出自己的 leader 并对外提供服务。若没有过半机制,当网络恢复的时候会发现有 2 个 leader。仿佛是 1 个大脑(leader)分散成了 2 个大脑,这就发生了脑裂现象。脑裂期间 2 个大脑都可能对外提供了服务,这将会带来数据一致性等问题。
    过半机制是如何防止脑裂现象产生的?
    ZooKeeper 的过半机制导致不可能产生 2 个 leader,因为少于等于一半是不可能产生 leader 的,这就使得不论机房的机器如何分配都不可能发生脑裂。
  1. ZAB 协议和 Paxos 算法
    Paxos 算法应该可以说是 ZooKeeper 的灵魂了。但是,ZooKeeper 并没有完全采用 Paxos 算法 ,而是使用 ZAB 协议作为其保证数据一致性的核心算法。另外,在 ZooKeeper 的官方文档中也指出,ZAB 协议并不像 Paxos 算法那样,是一种通用的分布式一致性算法,它是一种特别为 Zookeeper 设计的崩溃可恢复的原子消息广播算法。
    5.1. ZAB 协议介绍
    ZAB(ZooKeeper Atomic Broadcast 原子广播) 协议是为分布式协调服务 ZooKeeper 专门设计的一种支持崩溃恢复的原子广播协议。 在 ZooKeeper 中,主要依赖 ZAB 协议来实现分布式数据一致性,基于该协议,ZooKeeper 实现了一种主备模式的系统架构来保持集群中各个副本之间的数据一致性。
    5.2. ZAB 协议两种基本的模式:崩溃恢复和消息广播
    ZAB 协议包括两种基本的模式,分别是
  • 崩溃恢复 :当整个服务框架在启动过程中,或是当 Leader 服务器出现网络中断、崩溃退出与重启等异常情况时,ZAB 协议就会进入恢复模式并选举产生新的 Leader 服务器。当选举产生了新的 Leader 服务器,同时集群中已经有过半的机器与该 Leader 服务器完成了状态同步之后,ZAB 协议就会退出恢复模式。其中,所谓的状态同步是指数据同步,用来保证集群中存在过半的机器能够和 Leader 服务器的数据状态保持一致。
  • 消息广播 :当集群中已经有过半的 Follower 服务器完成了和 Leader 服务器的状态同步,那么整个服务框架就可以进入消息广播模式了。 当一台同样遵守 ZAB 协议的服务器启动后加入到集群中时,如果此时集群中已经存在一个 Leader 服务器在负责进行消息广播,那么新加入的服务器就会自觉地进入数据恢复模式:找到 Leader 所在的服务器,并与其进行数据同步,然后一起参与到消息广播流程中去。
  1. 总结
  2. ZooKeeper 本身就是一个分布式程序(只要半数以上节点存活,ZooKeeper 就能正常服务)。
  3. 为了保证高可用,最好是以集群形态来部署 ZooKeeper,这样只要集群中大部分机器是可用的(能够容忍一定的机器故障),那么 ZooKeeper 本身仍然是可用的。
  4. ZooKeeper 将数据保存在内存中,这也就保证了 高吞吐量和低延迟(但是内存限制了能够存储的容量不太大,此限制也是保持 znode 中存储的数据量较小的进一步原因)。
  5. ZooKeeper 是高性能的。 在“读”多于“写”的应用程序中尤其地明显,因为“写”会导致所有的服务器间同步状态。(“读”多于“写”是协调服务的典型场景。)
  6. ZooKeeper 有临时节点的概念。 当创建临时节点的客户端会话一直保持活动,瞬时节点就一直存在。而当会话终结时,瞬时节点被删除。持久节点是指一旦这个 znode 被创建了,除非主动进行 znode 的移除操作,否则这个 znode 将一直保存在 ZooKeeper 上。
  7. ZooKeeper 底层其实只提供了两个功能:① 管理(存储、读取)用户程序提交的数据;② 为用户程序提供数据节点监听服务。
    十. 10101010系统设计
  8. 设计秒杀系统
  • 前端随机抽取一半的用户, 返回抢购失败, 在保证公平的情况下极大地减轻服务器的压力.
  • 提前在redis缓存中建立要参与抢购商品的用户与要秒杀的商品
  • 异步更新机制, 就是秒杀是先不将数据更新到数据库, 先保存在redis中, 在结束之后再将数据保存到数据库中
  • 还有一点就是线程池的拒绝策略可以一用discardPolicy, 无法处理的任务是它会默默丢弃, 不处理也不抛出任何异常
  • 页面静态化
  • 单独设计一个秒杀系统, 避免秒杀系统影响到其他服务的运行
  • 采用限流, 降级熔断等保护措施

作者:Eckel丶
链接:https://www.nowcoder.com/discuss/965283
来源:牛客网

秒杀中如何处理超卖问题?
直接由数据库操作库存的sql语句如下所示。依靠MySQL中的排他锁实现
复制代码
无法复制加载中的内容
利用redis的单线程特性预减库存处理秒杀超卖问题!!!

  1. 在系统初始化时,将商品以及对应的库存数量预先加载到Redis缓存中;(缓存预热)
  2. 接收到秒杀请求时,在Redis中进行预减库存(decrement),当Redis中的库存不足时,直接返回秒杀失败,否则继续进行第3步;
  3. 将请求放入异步队列中,返回正在排队中;
  4. 服务端异步队列(MQ)将请求出队,出队成功的请求可以生成秒杀订单,减少数据库库存,返回秒杀订单详情。
    十一. 算法
    排序算法

快排 时间nlogn 空间logn 不稳定(如果要编程稳定, 使用二元组<val, i>)
public void quick(int[] q, int l, int r) {
if (l >= r) return;

int i = l - 1, j = r + 1;
int x = q[l];
while (i < j) {
    while (q[++i] <ff x);
    while (q[--j] > x);
    if (i < j) {
        int tmp = q[i];
        q[i] = q[j];
        q[j] = tmp;
    }
}
sort(q, l, j);
sort(q, j + 1, r);

}

归并排序 时间nlogn 空间 n
public void merge(int[] q, int l, int r) {
if (l >= r) return;

int mid = l + r >> 1;
merge(q, l, mid);
merge(q, mid + 1, r);

int k = 0, i = l, j = mid + 1;
while (i <= mid && j <= r) {
    if (q[i] <= q[j]) tmp[k++] = q[i++];
    else tmp[k++] = q[j++];
}
while (i <= mid) tmp[k++] = q[i++];
while (j <= r) tmp[k++] =q[j++];

for (i = l, j = 0; i <= r; i++, j++) q[i] = tmp[k];

}

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值