学习笔记 -- Note

maskbyfongshaderdef8f7vfullview.jpg

JVM虚拟机

JVM底层架构.png

包含运行时数据区(内存区域)、类装载子系统、字节码执行引擎 --> JVM和JMM

运行时数据区

  • 本地方法栈:线程私有,Java虚拟机调用本地方法时分配的内存空间,为本地方法(naive方法)服务
  • 栈:线程私有,栈中存放的是一个个的栈帧,每个栈帧对应一个被调用的方法。局部变量表(存放方法参数和局部变量的区域),操作栈,动态链接,方法出口。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。
  • 程序计数器:线程私有,内存空间小,如果线程正在执行一个 Java 方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是 Native 方法,这个计数器的值则为 (Undefined)。此内存区域是唯一一个在 Java 虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域。
  • 方法区(元空间):线程共享,运行时常量池、静态变量、虚拟机加载的类信息
  • 堆:线程共享,对象实例(new出来的对象),字符串常量池,新生代(Eden区:To Survivor区:From Survivor区 = 8:1:1):老年代 = 1:2

常用JVM设置命令

  • -Xms 设置最小堆内存
  • -Xmx 设置最大堆内存
  • -Xmn 设置新生代大小
  • -Xss 设置每个线程栈的大小
  • -XX:PermSize 设置永久代的初始内存大小
  • -XX:MaxPermSize 设置永久代的最大内存上限
  • -XX:MetaspaceSize 设置元空间的初始内存大小
  • -XX:MaxMetaspaceSize 设置元空间的最大内存上限

Java对象的创建过程

  1. 类加载检查( new的时候,虚拟机⾸先去检查是否能在常量池中定位到这个类的符号引⽤,并且检查这个符号引⽤代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执⾏相应的类加载过程。)
  2. 分配内存(在堆中分配内存)
  3. 初始化零值(虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这⼀步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使⽤,程序能访问到这些字段的数据类型所对应的零值。)
  4. 设置对象头(虚拟机要对对象进⾏必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。 另外,根据虚拟机当前运⾏状态的不同,如是否启⽤偏向锁等,对象头会有不同的设置⽅式。)
  5. 执行init方法(把对象按照程序员的意愿进⾏初始化,真正的初始化该对象的各个属性。)

类加载器

JVM 中内置了三个重要的 ClassLoader,除了 BootstrapClassLoader 其他类加载器均由 Java 实现且全部继承自java.lang.ClassLoader:

  1. BootstrapClassLoader(启动类加载器) :最顶层的加载类,由C++实现,负责加载 %JAVA_HOME%/lib目录下的jar包和类或者或被 -Xbootclasspath参数指定的路径中的所有类。
  2. ExtensionClassLoader(扩展类加载器) :主要负责加载目录 %JRE_HOME%/lib/ext 目录下的jar包和类,或被 java.ext.dirs 系统变量所指定的路径下的jar包。
  3. AppClassLoader(应用程序类加载器) :面向我们用户的加载器,负责加载当前应用classpath下的所有jar包和类。

双亲委派模型

Java自定义类加载器与双亲委派模型

每一个类都有一个对应它的类加载器。系统中的 ClassLoder 在协同工作的时候会默认使用 双亲委派模型 。即在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载。加载的时候,首先会把该请求委派该父类加载器的 loadClass() 处理,因此所有的请求最终都应该传送到顶层的启动类加载器 BootstrapClassLoader 中。当父类加载器无法处理时,才由自己来处理。当父类加载器为null时,会使用启动类加载器 BootstrapClassLoader 作为父类加载器。

双亲委派模型的好处

双亲委派模型保证了Java程序的稳定运行,可以避免类的重复加载(JVM 区分不同类的方式不仅仅根据类名,相同的类文件被不同的类加载器加载产生的是两个不同的类),也保证了 Java 的核心 API 不被篡改。如果没有使用双亲委派模型,而是每个类加载器加载自己的话就会出现一些问题,比如我们编写一个称为 java.lang.Object 类的话,那么程序运行的时候,系统就会出现多个不同的 Object 类。

参考:https://www.cnblogs.com/aspirant/p/7200523.html

JVM回收机制

可回收对象

  1. 引用计数法

引用计数是垃圾收集器中的早期策略。在这种方法中,堆中每个对象实例都有一个引用计数。当一个对象被创建时,就将该对象实例分配给一个变量,该变量计数设置为1。当任何其它变量被赋值为这个对象的引用时,计数加1(a = b,则b引用的对象实例的计数器+1),但当一个对象实例的某个引用超过了生命周期或者被设置为一个新值时,对象实例的引用计数器减1。任何引用计数器为0的对象实例可以被当作垃圾收集。当一个对象实例被垃圾收集时,它引用的任何对象实例的引用计数器减1。

优点:引用计数收集器可以很快的执行,交织在程序运行中。对程序需要不被长时间打断的实时环境比较有利。

缺点:无法检测出循环引用。如父对象有一个对子对象的引用,子对象反过来引用父对象。这样,他们的引用计数永远不可能为0。

  1. 可达性分析算法

可达性分析算法是从离散数学中的图论引入的,程序把所有的引用关系看作一张图,从一个节点GC ROOT开始,寻找对应的引用节点,找到这个节点以后,继续寻找这个节点的引用节点,当所有的引用节点寻找完毕之后,剩余的节点则被认为是没有被引用到的节点,即无用的节点,无用的节点将会被判定为是可回收的对象。

在Java语言中,可作为GC Roots的对象包括下面几种:
(1) 虚拟机栈中引用的对象(栈帧中的本地变量表);
(2) 方法区中类静态属性引用的对象;
(3) 方法区中常量引用的对象;
(4) 本地方法栈中JNI(Native方法)引用的对象。

垃圾收集算法

  1. 标记-清除算法(Mark-Sweep),适用于老年代

标记-清除算法采用从根集合(GC Roots)进行扫描,对存活的对象进行标记,标记完毕后,再扫描整个空间中未被标记的对象,进行回收,简而言之就是标记然后清除。标记-清除算法不需要进行对象的移动,只需对不存活的对象进行处理。

标记和清除的过程效率都不高,所以在存活对象很多时,非常高效,但由于标记-清除算法直接回收不存活的对象,因此会造成内存碎片。碎片太多可能会导致后续过程中需要为大对象分配空间时无法找到足够的空间而提前触发新的一次垃圾收集动作。

  1. 复制算法(Copying),适用于新生代

为了解决Mark-Sweep算法的缺陷,Copying算法就被提了出来。它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用的内存空间一次清理掉,这样一来就不容易出现内存碎片的问题。

运行高效且不容易产生内存碎片,但是能使用的内存缩减到原来的一半,典型的用空间换取时间。存活对象很多时,那么Copying算法的效率将会大大降低(需要复制的对象多)。

  1. 标记-整理算法(Mark-compact),适用于老年代

为了解决Copying算法的缺陷,充分利用内存空间,提出了Mark-Compact算法。该算法标记阶段和Mark-Sweep一样,但是在完成标记之后,它不是直接清理可回收对象,而是将存活对象都向一端移动(记住是完成标记之后,先不清理,先移动再清理回收对象,并且会更新对应的对象指针),然后清理掉端边界以外的内存。

标记-整理算法是在标记-清除算法的基础上,又进行了对象的移动,因此成本更高,但是却解决了内存碎片的问题。

分代收集算法

根据对象存活的生命周期将内存划分为老年代和新生代,老年代的特点是每次垃圾收集时只有少量对象需要被回收,而新生代的特点是每次垃圾回收时都有大量的对象需要被回收,那么就可以根据不同代的特点采取最适合的收集算法。

目前大部分垃圾收集器对于新生代都采取Copying算法,因为新生代中每次垃圾回收都要回收大部分对象,也就是说需要复制的操作次数较少,但是实际中并不是按照1:1的比例来划分新生代的空间的,一般来说是将新生代划分为一块较大的Eden空间和两块较小的Survivor空间(一般为8:1:1),每次使用Eden空间和其中的一块Survivor空间,当进行回收时,将Eden和Survivor中还存活的对象复制到另一块Survivor空间中,然后清理掉Eden和刚才使用过的Survivor空间。

而由于老年代的特点是每次回收都只回收少量对象,一般使用的是标记-整理算法,CMS垃圾收集器使用的是标记-清理算法。

垃圾收集器

  • Serial收集器(复制算法)
    新生代单线程收集器,标记和清理都是单线程,优点是简单高效。是client级别默认的GC方式,可以通过-XX:+UseSerialGC来强制指定。
  • Serial Old收集器(标记-整理算法)
    老年代单线程收集器,Serial收集器的老年代版本。
  • ParNew收集器(停止-复制算法) 
    新生代收集器,可以认为是Serial收集器的多线程版本,在多核CPU环境下有着比Serial更好的表现。
  • Parallel Scavenge收集器(停止-复制算法)
    并行收集器,追求高吞吐量,高效利用CPU。吞吐量一般为99%, 吞吐量= 用户线程时间/(用户线程时间+GC线程时间)。适合后台应用等对交互相应要求不高的场景。是server级别默认采用的GC方式,可用-XX:+UseParallelGC来强制指定,用-XX:ParallelGCThreads=4来指定线程数。
  • Parallel Old收集器(停止-复制算法)
    Parallel Scavenge收集器的老年代版本,并行收集器,吞吐量优先。
  • CMS(Concurrent Mark Sweep)收集器(标记-清理算法)
    高并发、低停顿,追求最短GC回收停顿时间,cpu占用比较高,响应时间快,停顿时间短,多核cpu追求高响应时间的选择。

参考:https://www.jianshu.com/p/50d5c88b272d

CMS收集器(通常和ParNew收集器搭配使用)

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。它非常符合在注重用户体验的应用上使用。

CMS(Concurrent Mark Sweep)收集器是 HotSpot 虚拟机第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。

从名字中的Mark Sweep这两个词可以看出,CMS 收集器是一种 “标记-清除”算法实现的,它的运作过程相比于前面几种垃圾收集器来说更加复杂一些。整个过程分为四个步骤:

  • 初始标记(STW): 暂停所有的其他线程,并记录下直接与 root 相连的对象,速度很快 ;
  • 并发标记: 同时开启 GC 和用户线程,用一个闭包结构去记录可达对象。但在这个阶段结束,这个闭包结构并不能保证包含当前所有的可达对象。因为用户线程可能会不断的更新引用域,所以 GC 线程无法保证可达性分析的实时性。所以这个算法里会跟踪记录这些发生引用更新的地方。
  • 重新标记(STW): 之前在并发标记时,因为是 GC 和用户程序是并发执行的,可能导致一部分已经标记为从 GC Roots不可达的对象,因为用户程序的(并发)运行,又可达了,Remark的作用就是将这部分对象又标记为 可达对象。这个阶段的停顿时间一般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短。
  • 并发清除: 开启用户线程,同时 GC 线程开始对未标记的区域做清扫,这个阶段如果有新增对象会被标记为黑色不做任何处理。这就可能产生新的垃圾,新的垃圾在此次GC无法清除,只能等到下次清理。这些垃圾有个专业名词:浮动垃圾
  • 并发重置: 重置本次GC过程中的标记数据。

从它的名字就可以看出它是一款优秀的垃圾收集器,主要优点:并发收集、低停顿。但是它有下面三个明显的缺点:

  1. 对 CPU 资源敏感:并发收集虽然不会暂停用户线程,但因为占用一部分CPU资源,还是会导致应用程序变慢,总吞吐量降低。
  2. 无法处理浮动垃圾:在并发标记时,用户线程新产生的垃圾,称为浮动垃圾;
    所谓的“浮动垃圾”,就是在并发标记阶段,由于用户程序在运行,那么自然就会有新的垃圾产生,这部分垃圾被标记过后,CMS无法在当次集中处理它们(为什么?原因在于CMS是以获取最短停顿时间为目标的,自然不可能在一次垃圾处理过程中花费太多时间),只好在下一次GC的时候处理。这部分未处理的垃圾就称为“浮动垃圾。
    • promotion failed是在进行Minor GC时,survivor space放不下、对象只能放入老年代,而此时老年代也放不下造成的;
    • concurrent mode failure是在执行CMS GC的过程中同时有对象要放入老年代,而此时老年代空间不足造成的(有时候“空间不足”是CMS GC时当前的浮动垃圾过多导致暂时性的空间不足触发Full GC)。
  3. 它使用的回收算法-“标记-清除”算法会导致收集结束时会有大量空间碎片产生。

G1收集器(JDK9默认垃圾收集器)

G1 (Garbage-First) 是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器. 以极高概率满足 GC 停顿时间要求的同时,还具备高吞吐量性能特征.

被视为 JDK1.7 中 HotSpot 虚拟机的一个重要进化特征。它具备一下特点:

  • 并行与并发:G1 能充分利用 CPU、多核环境下的硬件优势,使用多个 CPU(CPU 或者 CPU 核心)来缩短 Stop-The-World 停顿时间。部分其他收集器原本需要停顿 Java 线程执行的 GC 动作,G1 收集器仍然可以通过并发的方式让 java 程序继续执行。
  • 分代收集:虽然 G1 可以不需要其他收集器配合就能独立管理整个 GC 堆,但是还是保留了分代的概念。
  • 空间整合:与 CMS 的“标记-清理”算法不同,G1 从整体来看是基于“标记-整理”算法实现的收集器;从局部上来看是基于“标记-复制”算法实现的。
  • 可预测的停顿:这是 G1 相对于 CMS 的另一个大优势,降低停顿时间是 G1 和 CMS 共同的关注点,但 G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内。

G1 收集器的运作大致分为以下几个步骤:

  • 初始标记(STW):初始标记阶段仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可用的Region中创建新对象,这阶段需要停顿线程,但耗时很短。
  • 并发标记:并发标记阶段是从GC Root开始对堆中对象进行可达性分析,找出存活的对象,这阶段耗时较长,但可与用户程序并发执行。
  • 最终标记(STW):最终标记阶段是为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程Remembered Set Logs里面,最终标记阶段需要把Remembered Set Logs的数据合并到Remembered Set中,这阶段需要停顿线程,但是可并行执行。
  • 筛选回收(STW):筛选回收阶段首先对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间来制定回收计划,这个阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分Region,时间是用户可控制的,而且停顿用户线程将大幅提高收集效率。

G1 收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的 Region(这也就是它的名字 Garbage-First 的由来) 。这种使用 Region 划分内存空间以及有优先级的区域回收方式,保证了 G1 收集器在有限时间内可以尽可能高的收集效率(把内存化整为零)。

ZGC收集器(JDK11默认垃圾收集器)

与 CMS 中的 ParNew 和 G1 类似,ZGC 也采用标记-复制算法,不过 ZGC 对该算法做了重大改进。

在 ZGC 中出现 Stop The World 的情况会更少!可以控制STW<=10ms。

详情可以看 : 《新一代垃圾回收器 ZGC 的探索与实践》

垃圾收集器的选择参考
4G以下可使用Parallel,4-8G可使用ParNew+CMS,8G以上使用G1(适用于较大内存的机器),几百G以上使用ZGC(适用于大内存低延迟服务的内存管理和回收)

Spring

SpringMVC工作流程

客户端发送请求-》DispatcherServlet(接收请求,响应结果)
-》调用HandlerMapping(解析该请求url对应的Handler),返回处理器执行链HandlerExecutionChain(处理器对象及处理器拦截器(如果有则生成))
-》请求处理器适配器HandlerAdapter执行对应的Handler-》Handler(Controller)处理相关业务逻辑
-》最后HandlerAdapter会返回ModelAndView给DispatcherServlet
-》DispaterServlet请求视图解析器ViewResolver进行视图解析
-》返回View-》DispaterServlet把Model传到View进行视图渲染,数据填充
-》将View返回给请求者(响应结果)

spring框架使用了哪些设计模式,及其应用场景

  • 工厂设计模式 : Spring使用工厂模式通过 BeanFactory、ApplicationContext 创建 bean 对象。
  • 代理设计模式 : Spring AOP 功能的实现。
  • 单例设计模式 : Spring 中的 Bean 默认都是单例的。
  • 模板方法模式 : Spring 中 jdbcTemplate、hibernateTemplate 等以 Template 结尾的对数据库操作的类,它们就使用到了模板模式。
  • 包装器设计模式 : 我们的项目需要连接多个数据库,而且不同的客户在每次访问中根据需要会去访问不同的数据库。这种模式让我们可以根据客户的需求能够动态切换不同的数据源。
  • 观察者模式 : Spring 事件驱动模型就是观察者模式很经典的一个应用。
  • 适配器模式 : Spring AOP 的增强或通知(Advice)使用到了适配器模式、spring MVC 中也是用到了适配器模式适配Controller。

Spring Bean的生命周期

  • 通过反射实例化bean对象(在实例化bean对象之前,会有BeanDefinition(创建bean时用的模板或者说是定义)、BeanFactory组建以及BeanFactoryPostProcessor(bean工厂后置处理器);BeanFactoryPostProcessor可以让我们操作bean工厂,bean工厂可以操作BeanDefinition设置其各种属性,可以registerSignleton()注册bean,也可以getBean()-》该操作会获取bean,没有时会创建对应的bean)
  • 通过反射设置bean的相关属性(依赖注入、属性填充)
  • 是否实现Aware相关接口:BeanNameAware、BeanFactoryAware、BeanClassLoaderAware、ApplicationContextAware,回调对应的set方法
  • 初始化前,如果有和该Bean相关的BeanPostProcess对象,执行前置方法postProcessBeforeInitialization
  • 初始化:检查是否实现InitializingBean接口,有则执行afterPropertitesSet方法(@PostConstruct注解也是执行对应的方法);检查是否配置init-method属性,有则执行指定方法
  • 初始化后,如果有和该Bean相关的BeanPostProcess对象,执行后置方法postProcessAfterInitialization,在这里如果有和该bean相关的AOP切面,则会先判断是否生成过代理对象(循环依赖会提前AOP生成代理对象,使用ConcurrentHashMap earlyProxyReferences的remove()判断是否在该Map中),没有则生成代理对象,最后是将代理对象(对象中有个属性target就是原本的bean对象)放入单例池,而不是原本的bean对象。
  • 放入单例池,以供使用该bean对象
  • 检查是否有实现DisposableBean接口,有则执行destroy方法
  • 检查是否配置destroy-method属性,有则执行指定方法

Spring循环依赖问题

Bean创建时循环依赖问题

  • 属性上的循环依赖使用三级缓存解决

首先尝试从一级缓存singletonObjects中获取单例Bean如果获取不到,则先通过singletonsCurrentlyInCreation判断该Bean是否正在创建中,是则表明出现了循环依赖,然后再从二级缓存earlySingletonObjects中获取单例Bean如果仍然获取不到,则从三级缓存singletonFactories中获取单例BeanFactory最后,如果从三级缓存中拿到了BeanFactory,则通过getObject()把Bean存入二级缓存中,并把该Bean的三级缓存删掉。

DefaultSingletonBeanRegistry类

  1. Map<String, Object> singletonObjects ConcurrentHashMap 一级缓存单例池(存放原始bean)
  2. Map<String, Object> earlySingletonObjects ConcurrentHashMap 二级缓存 单例池(存放不完整的bean,属性未设置的bean)
  3. Map<String, ObjectFactory<?>> singletonFactories HashMap 三级缓存 存放ObjectFactory(要执行的lambda表达式),提前暴露的一个单例工厂,二级缓存中存储的就是从这个工厂中获取到的对象(该缓存是为了AOP操作而存在)

这里还有使用到一个singletonsCurrentlyInCreation,存放的是正在创建中的bean,循环依赖的时候可以判断自身需要依赖创建的bean是否正在创建中

private final Set<String> singletonsCurrentlyInCreation =
			Collections.newSetFromMap(new ConcurrentHashMap<>(16));
  • 构造器上的循环依赖spring自身是无法解决的,我们可以使用懒加载@Lazy解决,懒加载会生成对应bean的代理对象作为构造器的参数

参考:https://juejin.cn/post/6930904292958142478#heading-5

为什么这么做就能解决Spring中的循环依赖问题

  • 其实在没有真正创建出来一个实例对象的时候,这个对象已经被生产出来了,虽然还不完美(还没有进行初始化的第二步和第三步),但是已经能被人认出来了(根据对象引用能定位到堆中的对象),所以Spring此时将这个对象提前曝光出来让大家认识,让大家使用
  • A首先完成了初始化的第一步,并且将自己提前曝光到singletonFactories中,此时进行初始化的第二步,发现自己依赖对象B,此时就尝试去get(B),发现B还没有被create,所以走create流程,B在初始化第一步的时候发现自己依赖了对象A,于是尝试get(A),尝试一级缓存singletonObjects(肯定没有,因为A还没初始化完全),尝试二级缓存earlySingletonObjects(也没有),尝试三级缓存singletonFactories,由于A通过ObjectFactory将自己提前曝光了,所以B能够通过ObjectFactory.getObject拿到A对象(虽然A还没有初始化完全,但是总比没有好呀),B拿到A对象后顺利完成了初始化阶段1、2、3,完全初始化之后将自己放入到一级缓存singletonObjects中。此时返回A中,A此时能拿到B的对象顺利完成自己的初始化阶段2、3,最终A也完成了初始化,长大成人,进去了一级缓存singletonObjects中,而且更加幸运的是,由于B拿到了A的对象引用,所以B现在hold住的A对象也蜕变完美了!一切都是这么神奇!!

总结:Spring通过三级缓存加上“提前曝光”机制,配合Java的对象引用原理,比较完美地解决了某些情况下的循环依赖问题!
参考:Spring循环依赖的处理
spring常见问题

Spring的两种动态代理:JDK动态代理和CGlib动态代理的区别和实现

一、原理区别:
java动态代理是利用反射机制生成一个实现代理接口的匿名类,在调用具体方法前调用InvokeHandler来处理。
而cglib动态代理是利用asm开源包,对代理对象类的class文件加载进来,通过修改其字节码生成子类来处理。

  1. 如果目标对象实现了接口,默认情况下会采用JDK的动态代理实现AOP
  2. 如果目标对象实现了接口,可以强制使用CGLIB实现AOP
  3. 如果目标对象没有实现了接口,必须采用CGLIB库,spring会自动在JDK动态代理和CGLIB之间转换

如何强制使用CGLIB实现AOP?
(1)添加CGLIB库,SPRING_HOME/cglib/*.jar
(2)在spring配置文件中加入<aop:aspectj-autoproxy proxy-target-class="true"/>
(3)@EnableAspectJAutoProxy(proxyTargetClass = true)

JDK动态代理和CGLIB字节码生成的区别?
(1)JDK动态代理只能对实现了接口的类生成代理,而不能针对类
(2)CGLIB是针对类实现代理,主要是对指定的类生成一个子类,覆盖其中的方法
因为是继承,所以该类或方法最好不要声明成final

JDK动态代理
为什么JDK动态代理是只能代理的接口,如下所示:
public final class $Proxy0 extends Proxy implements Interface
由于java的单继承,动态生成的代理类已经继承了Proxy类的,就不能再继承其他的类,所以只能靠实现被代理类的接口的形式,故JDK的动态代理必须有接口。

Bean依赖注入(属性填充)工作流程

@AutoWired

  1. 根据类型从容器中找到一个或多个bean,多个时则继续往下筛选
  2. 是不是isAutowireCandidate,@Bean(autowireCandidate = false),为false则筛掉,多个时则继续往下筛选
  3. 是不是符合@Qualifier的值,符合则取出来,该注解的值可以相同,即可以找出多个bean,多个时则继续往下筛选
  4. 取@Primary标注的bean,找出对应的一个bean
  5. 取优先级最高的Bean,@Priority(1)只能标注在类上,找出对应的一个bean
  6. 根据属性名找对应的bean

@Resource

  1. 根据注解中配置的属性值找,若没有配置则往下找
  • 配置的name的值寻找对应bean,在容器中(按照Map的key去找)没有对应的bean则会报错
  • 配置的type值,找不到或找到多个则会报错
  • 既指定了@Resource的name属性又指定了type,则从Spring上下文中找到唯一匹配的bean进行装配,找不到则抛出异常
  1. 根据注解在参数上的参数名(若注解在方法上则根据方法名getXxx的Xxx,而@AutoWired是根据方法的参数名查找),没有则往下找
  2. 根据类型查找,找不到或找到多个注入的bean则会报错

SpringBoot自动装配原理

Spring Boot启动的时候会通过@EnableAutoConfiguration注解找到类路径下的META-INF/spring.factories配置文件中的所有自动配置类,并对其进行加载,而这些自动配置类都是以AutoConfiguration结尾来命名的,它实际上就是一个JavaConfig形式的Spring容器配置类,它能通过以Properties结尾命名的类中取得在全局配置文件中配置的属性如:server.port,而XxxxProperties类是通过@ConfigurationProperties注解与全局配置文件中对应的属性进行绑定的。

@SpringBootApplication -> @EnableAutoConfiguration
-> @Import({AutoConfigurationImportSelector.class}) -> AutoConfigurationImportSelector类
-> selectImport方法 -> getAutoConfigurationEntry方法

getAutoConfigurationEntry方法:

  • 判断自动装配开关是否打开
  • getAttributes()获取EnableAutoConfiguration注解中的exclude和excludeName
  • getCandidateConfigurations() -> SpringFactoriesLoader.loadFactoryNames() -> 将所有需要导入的组件以全类名的方式返回,这些组件就会被添加到容器中。 -> 如何找到:通过找到类路径下的META-INF/spring.factories配置文件中的所有自动配置类,并对其进行加载
  • 移除重复的配置(使用LinkedHashSet)
  • getExclusions获取要排除的自动配置类,在检验之后去除这些类
  • 筛选过滤掉不满足条件的自动配置类,即不满足@ConditionalOnXXX的类

网络基础

TCP如何保证传输可靠性?

  • 检验和:这是一个端到端的检验和,目的是用于检测数据在传输过程中有没有发生变化,如果接受到的 TCP 报文段检验和发生了差别,那么 TCP 会丢弃这个报文段
  • 流量控制:TCP 连接的每一端都有固定大小的缓冲区,TCP 的接收端只允许发送端发送接收端缓冲区能容纳的数据量,当接收端来不及处理来自发送端的数据,会提示发送端降低发送频率,防止丢包,TCP 的流量控制协议是基于可变大小的滑动窗口协议
  • 阻塞控制:当网络出现阻塞的时候,会减少数据传输
  • 超时重传机制:每次发送一个报文段,会启动一个定时器,等待接收端确认收到这个报文段,如果没有及时收到确认消息,那么会重新发送这个报文段

浏览器输⼊URL发⽣了什么?

1.DNS域名解析;
2.建立TCP连接(三次握手);
3.发送HTTP请求;
4.服务器处理请求;
5.返回响应结果;
6.关闭TCP连接(四次挥手);
7.浏览器解析HTML;
8.浏览器布局渲染;

TCP连接(三次握手)和断开(四次挥手)

三次握手:

  1. 发送方发送一个带SYN(序号seq=x)标志的数据包给接收方。(第一次的seq序列号是随机产生的,这样是为了网络安全,如果不是随机产生初始序列号,黑客将会以很容易的方式获取到你与其他主机之间的初始化序列号,并且伪造序列号进行攻击)
  2. 接收方收到后,向发送方回传一个带有SYN(自己的序号seq=y)/ACK(确认序号seq=x+1)标志的数据包以示传达确认信息。(SYN是为了告诉发送方,发送方到接收方的通道没问题;ACK用来验证接收方到发送方的通道没问题)
  3. 最后,发送方再回传一个带ACK(确认序号seq=y+1)标志的数据包给接收方,表示三次握手结束。

若在握手某个过程中某个阶段莫名中断,TCP协议会再次以相同的顺序发送相同的数据包

为什么要三次握手?

  • 第一次握手,发送端:什么都确认不了;接收端:对方发送正常,自己接受正常
  • 第二次握手,发送端:对方发送,接受正常,自己发送,接受正常 ;接收端:对方发送正常,自己接受正常
  • 第三次握手,发送端:对方发送,接受正常,自己发送,接受正常;接收端:对方发送,接受正常,自己发送,接受正常

三次握手的目的是建立可靠的通信信道,说到通讯,简单来说就是数据的发送与接收,而三次握手最主要的目的就是双方确认自己与对方的发送与接收是正常的。

四次挥手:

  1. 主动关闭方发送一个FIN(序号为x),用来关闭主动断开方(客户端/服务端)到被动断开方(客户端/服务端)的数据传送。
  2. 被动关闭方接收到这个FIN,它发回一个ACK,确认序号为x+1。
  3. 被动关闭方关闭与主动断开方的连接,发送一个FIN(序号为y)给主动断开方。
  4. 主动关闭方发回一个ACK,确认序号为y+1。

为什么连接的时候是三次握手,关闭的时候却是四次握手?

  • 建立连接的时候, 服务器在LISTEN状态下,收到建立连接请求的SYN报文后,把ACK和SYN放在一个报文里发送给客户端。
  • 关闭连接时,服务器收到对方的FIN报文时,仅仅表示对方不再发送数据了但是还能接收数据,而自己也未必全部数据都发送给对方了,所以服务器可以立即关闭,也可以发送一些数据给对方后,再发送FIN报文给对方来表示同意现在关闭连接。因此,服务器ACK和FIN一般都会分开发送,从而导致多了一次。

HTTPS

运行流程(包含两次HTTP请求):

  1. 客户端向服务器发起HTTPS的请求,连接到服务器的443端口。
  2. 第一次HTTP请求,服务器将非对称加密的公钥以数字证书的形式发送给客户端。
  3. 客户端接收到该证书并验证该证书的合法性,如果不合法则本次HTTPS请求无法继续,合法的话客户端将会随机生成一个客户端私钥(client key),使用服务端的公钥进行非对称加密。
  4. 第二次HTTP请求,客户端将加密后的客户端私钥发送给服务端,服务端使用自己的私钥进行非对称解密获得客户端私钥,然后使用客户端私钥对数据进行对称加密。
  5. 服务端将加密后的数据发给客户端,客户端使用客户端私钥进行对称解密。

对称加密(AES、DES、3DES等)使用相同的秘钥进行加解密,客户端生成该秘钥,加密解密的速度比较快,适合数据比较大时的使用。
非对称加密(RSA、DSA/DSS、ECC等)使用公钥加密,私钥解密或私钥加密,公钥解密,运行速度比对称加密慢得多,适合少量数据的加密。

MySQL 索引B+树 事务隔离级别

事务隔离级别

  • 读未提交(read uncommitted):会读到未提交的事务,会导致读到脏数据、不可重复读(多次读取同一批数据发生变化,针对更新操作)以及幻读(数据数量数据变化,针对插入操作)
  • 读已提交(read committed):不会读到脏数据,但是会导致不可重复读、幻读
  • 可重复读(reapeatable read):不会导致读到脏数据、不可重复读,但是会出现幻读
  • 可串行化(serializable):按顺序进行事务,能避免脏数据、可重复读、幻读,但是效率低下

MYSQL索引

B+树对比B树:

  1. 单一节点存储更多的元素,使得查询的IO次数更少。B+树只有叶子节点存放 key 和 data,其他内节点只存放 key,而B树所有节点既存放键(key) 也存放数据(data)。
  2. 所有查询都要查找到叶子节点,查询性能稳定。B树因为非叶子节点也存在数据Data域,有可能在非叶子节点中就可获取数据并返回。
  3. 所有叶子节点形成有序链表,便于范围查询。B树的叶子节点都是独立的。

B树更适用于磁盘读取,红黑树更适用于内存读取的原因:

  • B树优点
  1. B+树的高度要比红黑树小,有效减少了磁盘的随机访问
  2. B+树的数据节点相互临近,能够发挥磁盘顺序读取的优势(缓存)
  3. B+树的数据全部存于叶子结点,而其他节点产生的浪费在经济负担上能够接收,红黑树存储浪费小
  • 红黑树优点
  1. 红黑树常用于存储内存中的有序数据,增删很快,B+树常用于文件系统和数据库索引,因为B树的子节点大于红黑树,红黑树只能有2个子节点,B树子节点大于2,子节点树多这一特点保证了存储相同大小的数据,树的高度更小,数据局部更加紧凑,而硬盘读取有局部加载的优化(紧凑的好处:把要读取数据和周围的数据一起预先读取),B树相邻数据物理上更加紧凑这一特点符合硬盘进行io优化的特性。
  2. B+树在B树基础上进一步将数据只存在叶子节点,非叶子节点不存值只存储值的指向,这使得单个节点能有更多子节点,除此之外将所有叶子节点(值存在叶子节点)放入链表中,使得数据更加紧凑有序,只需要链表(叶子节点)的一次遍历就能获取所有树上的值。

综上所述B+树这些特性适合用于数据库的索引,mysql底层数据结构就是B+树。B树更适用于磁盘读取,红黑树更适用于内存读取

聚簇索引和非聚簇索引

  • 聚集索引即叶子节点中索引结构和数据一起存放的索引。主键索引属于聚集索引。(适合范围查询)

    • 聚集索引的优点
      聚集索引的查询速度非常的快,因为整个 B+树本身就是一颗多叉平衡树,叶子节点也都是有序的,定位到索引的节点,就相当于定位到了数据。
    • 聚集索引的缺点
      依赖于有序的数据 :因为 B+树是多路平衡树,如果索引的数据不是有序的,那么就需要在插入时排序,如果数据是整型还好,否则类似于字符串或 UUID 这种又长又难比较的数据,插入或查找的速度肯定比较慢。
      更新代价大 : 如果对索引列的数据被修改时,那么对应的索引也将会被修改, 而且况聚集索引的叶子节点还存放着数据,修改代价肯定是较大的, 所以对于主键索引来说,主键一般都是不可被修改的。
  • 非聚集索引即叶子节点中索引结构和数据分开存放的索引。

    • 非聚集索引的优点
      更新代价比聚集索引要小 。非聚集索引的更新代价就没有聚集索引那么大了,非聚集索引的叶子节点是不存放数据的
    • 非聚集索引的缺点
      跟聚集索引一样,非聚集索引也依赖于有序的数据
      可能会二次查询(回表) :这应该是非聚集索引最大的缺点了。 当查到索引对应的指针或主键后,可能还需要根据指针或主键再到数据文件或表中查询。

覆盖索引

如果一个索引包含(或者说覆盖)所有需要查询的字段的值,我们就称之为“覆盖索引”。
覆盖索引即需要查询的字段正好是索引的字段,那么直接根据该索引,就可以查到数据了, 而无需回表查询。
如主键索引,如果一条 SQL 需要查询主键,那么正好根据主键索引就可以查到主键。
再如普通索引,如果一条 SQL 需要查询 name,name 字段正好有索引, 那么直接根据这个索引就可以查到数据,也无需回表。

Redis

数据结构

Redis一个对象结构redisObject:

typedef struct redisObject {
    // 类型  
    unsigned type:4;
    // 编码方式  
    unsigned encoding: 4;  
    // 引用计数  
    int refcount;  
    // 指向对象的值  
    void *ptr;  
} robj;
  • String:编码可以是int(可以存储long的整数)、raw(Simple Dynamic String(SDS))或者embstr,在长度较短时,使用emb形式存储(embeded),当长度超过44个字节时,使用raw形式存储
  • list:编码可以是ziplist(压缩列表,数据量少时使用)或者linkedlist(双端列表)
  • hash:编码可以是ziplist(压缩列表,数据量少时使用)或者hashtable(由dict这个结构来实现的, dict是一个字典)
  • set:编码可以是intset(整数有序集合)或者hashtable(由dict这个结构来实现的, dict是一个字典)
  • zset:编码可以是ziplist(压缩列表,数据量少时使用)或者skiplist与dict的结合(跳跃表加字典)
编码常量 编码所对应的底层数据结构 特性
REDIS_ENCODING_INT long 类型的整数 如果一个字符串的内容可以转换为long,那么该字符串就会被转换成为long类型,对象的ptr就会指向该long,并且对象类型也用int类型表示。
REDIS_ENCODING_EMBSTR embstr 编码的简单动态字符串 长度不超过44个字节时使用,embstr的创建只需分配一次内存(objet和sds放在一起,连续的),而raw为两次(分开),释放内存的次数也为一次。
REDIS_ENCODING_RAW 简单动态字符串Simple Dynamic String(SDS) Redis自己实现的一种简单动态字符串
REDIS_ENCODING_HT 字典 字典中有两个哈希表,即dicht ht[2] 指向了两个哈希表,dicht[0] 是用于真正存放数据,dicht[1]一般在哈希表元素过多进行rehash的时候用于中转数据。
REDIS_ENCODING_LINKEDLIST 双端链表 linkedlist是一种双向链表。它的结构比较简单,节点中存放pre和next两个指针,还有节点相关的信息,所以消耗空间比较大。地址不连续,节点多了容易产生内存碎片。数据量大时使用。
REDIS_ENCODING_ZIPLIST 压缩列表 ziplist是一种压缩链表,它的好处是更能节省内存空间,因为它所存储的内容都是在连续的内存区域当中的,并且不容易产生内存碎片。因为为了保证他存储内容在内存中的连续性,插入的复杂度是O(N),插入和删除操作需要频繁的申请和释放内存, 同时会发生内存拷贝,数据量大时内存拷贝开销较大。数据量少时使用,可以配置ziplist压缩列表的大小。
REDIS_ENCODING_INTSET 整数集合 intset是一个有序集合,查找元素的复杂度为O(logN),但插入时不一定为O(logN),因为有可能涉及到升级操作。
REDIS_ENCODING_SKIPLIST 跳跃表和字典 skiplist是一种跳跃表,它实现了有序集合中的快速查找,在大多数情况下它的速度都可以和平衡树差不多。但它的实现比较简单,可以作为平衡树的替代品。

参考:https://segmentfault.com/a/1190000018887256

quicklist

Redis3.2版本开始对列表数据结构进行了改造,使用quicklist代替了ziplist和linkedlist,quicklist实际上是把ziplist和普通的双向链表结合起来。每个双链表节点中保存一个ziplist,然后每个ziplist中存一批list中的数据(具体ziplist大小可配置),这样既可以避免大量链表指针带来的内存消耗,也可以避免ziplist更新导致的大量性能损耗,将大的ziplist化整为零。quicklist有一个head指向头结点,tail指向尾结点。

结构图如下:

QuickList.png

skiplist

跳跃表是一种有序的数据结构,它通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的。跳跃表在链表的基础上增加了多级索引以提升查找的效率,链表加多级索引的结构,就是跳跃表,但其是一个空间换时间的方案,必然会带来一个问题——索引是占内存的。原始链表中存储的有可能是很大的对象,而索引结点只需要存储关键值值和几个指针,并不需要存储对象,因此当节点本身比较大或者元素数量比较多的时候,其优势必然会被放大,而缺点则可以忽略。

结构图如下:

跳跃表.png

分布式锁

使用setnx命令:将 key 的值设为 value,当且仅当 key 不存在。 若给定的 key 已经存在,则 SETNX 不做任何动作。SETNX 是SET if Not eXists的简写。

在需要做的同步业务操作完之后需要释放该锁,即delete对应的分布式锁key。

若过程中发生异常或者服务宕机,则会导致死锁。此时需要使用expire: EXPIRE key seconds,给锁的key加过期时间。

但是服务若中途宕机还是会死锁,此时需要保证setnx和expire是原子操作,redis在2.6.12版本过后增加新的解决方案:
set key value [expiration EX seconds|PX milliseconds] [NX|XX]

例:set name zhangsan EX 10 NX

  • EX seconds:将键的过期时间设置为 seconds 秒。 SET key value EX seconds 等同于 SETEX key seconds value
  • PX millisecounds:将键的过期时间设置为 milliseconds 毫秒。 SET key value PX milliseconds 等同于 PSETEX key milliseconds value
  • NX:只在键不存在的时候,才对键进行设置操作。 SET key value NX 等同于 SETNX key value
  • XX:只在键已经存在的时候,才对键进行设置操作

如果在操作过程中,业务操作时间过久,假如设置的锁key过期了或者因为其他原因给误删了该分布式锁,其他线程又来操作相同的业务,导致分布式锁被其他线程获取,此时原本的线程又操作释放该锁,导致又可以让更多其他的线程来获取锁,最后分布式锁相当于失效,此时我们可以给分布式锁里面对应的value值设置为对应加锁的线程id或生成的UUID等唯一标识,这样释放锁的就必须为同一个线程,即释放锁的必须为加锁的线程。

上面提到的锁过期问题,我们可以在主线程又开一个定时子线程,定时对我们的锁key进行判断,如果该锁key还在则延长存活时间。

综上所述,实现一个分布式key是一个复杂的过程,过程中实现可能还会出现一些bug之类的,我们可以使用Redisson框架,它帮我们封装实现了分布式锁,使用非常简单,在许多分布式场景上都会选择使用Redisson。
Redisson底层使用了许多lua脚本,而Redis也会保证lua脚本的原子性,在获得分布式锁后也会开一个定时器进行对锁key的判断延时操作。

可重入锁
可使用ThreadLocal存放唯一标识

自旋锁
获取分布式锁失败,可使用循环获取锁的方式阻塞线程,若一直循环获取会不断消耗CPU资源以及不断访问Redis对其性能也会造成影响,可使用JUC包AQS(Semaphore,线程调用acquire()阻塞,在持有锁的线程调用release()唤醒该线程)或MQ队列阻塞线程的循环

提高分布式锁的性能

分段锁,类似JDK7中ConcurrentHashMap的分段锁,把数据分成很多个段,每个段是一个单独的锁,所以多个线程过来并发修改数据的时候,可以并发的修改不同段的数据。不至于说,同一时间只能有一个线程独占修改ConcurrentHashMap中的数据。

缓存穿透

key对应的数据在数据源并不存在,每次针对此key的请求从缓存获取不到,请求都会到数据源,从而可能压垮数据源。比如用一个不存在的用户id获取用户信息,不论缓存还是数据库都没有,若黑客利用此漏洞进行攻击可能压垮数据库。

解决方案:

  1. 对于像ID为负数的非法请求直接过滤掉,采用布隆过滤器(Bloom Filter)过滤。将所有可能存在的数据哈希到一个足够大的bitmap中,一个一定不存在的数据会被 这个bitmap拦截掉,从而避免了对底层存储系统的查询压力。
  2. 针对在数据库中找不到记录的,可以缓存这些⽆效key,会导致Redis中缓存⼤量⽆效的key,所以一般会设置一个较短的过期时间。

缓存击穿

缓存击穿表示某个key的缓存非常热门,有很高的并发一直在访问,如果该缓存失效,那同时会走数据库,可能会压垮数据库。缓存击穿与缓存雪崩的区别是这里针对的是某一热门key缓存,而雪崩针对的是大量缓存的集中失效。

解决方案:

  1. 让该热门key的缓存永不过期,可以使用定时任务之类的定时更新热门key的数据。
  2. 使用互斥锁,通过redis的setnx实现互斥锁(做法大致是从缓存中获取数据失败时,即缓存失效时,先使用SETNX去set一个互斥的key(此时也要注意设置过期时间,要保持获取锁和设置过期时间为原子操作,以免死锁,具体操作可以参考分布式锁死锁解决方案),成功的线程就从数据库拿数据并回写入缓存中,还要将互斥key删除(释放锁),此时其他互斥失败的线程则可以睡眠等待一小会时间之后再重试获取数据。

缓存雪崩

可能因为缓存服务器出故障需要重启或者大量缓存的key集中在某一个时间段失效,这样在失效的时候,所有的请求都直接到了数据源,会给数据源带来很大的压力。如果有⼀些被⼤量访问数据(热点缓存)在某⼀时刻⼤⾯积失效,导致对应的请求直接落到了数据库上,可能会压垮数据库。

解决方案:

  1. 针对缓存服务器出故障,可以实现redis的高可用,Redis Cluster以及Redis Sentinel(哨兵) 等方案,或者进行限流,避免同时处理⼤量的请求。
  2. 针对大量缓存的key集中某时间段内失效,设置缓存过期时间时加上一个随机值,避免缓存在同一时间过期或者也让这些热点数据永不过期,可以使用定时任务之类的定时更新热门key的数据。
  3. 使用双缓存策略,设置两个缓存,原始缓存和备用缓存,原始缓存失效时,访问备用缓存,备用缓存失效时间设置长点。

Redis 是如何实现高可用的?

Redis 高可用的手段主要有以下四种:

  1. 数据持久化
  2. 主从数据同步(主从复制)
  3. Redis 哨兵模式(Sentinel)
  4. Redis 集群(Cluster)

其中数据持久化保证了系统在发生宕机或者重启之后数据不会丢失,增加了系统的可靠性和减少了系统不可用的时间(省去了手动恢复数据的过程);而主从数据同步可以将数据存储至多台服务器,这样当遇到一台服务器宕机之后,可以很快地切换至另一台服务器以继续提供服务;哨兵模式用于发生故障之后自动切换服务器;而 Redis 集群提供了多主多从的 Redis 分布式集群环境,用于提供性能更好的 Redis 服务,并且它自身拥有故障自动切换的能力。

参考:https://blog.csdn.net/u011168837/article/details/110673662

多线程 死锁

多线程

线程池

public ThreadPoolExecutor(int corePoolSize,
                            int maximumPoolSize,
                            long keepAliveTime,
                            TimeUnit unit,
                            BlockingQueue<Runnable> workQueue,
                            ThreadFactory threadFactory,
                            RejectedExecutionHandler handler)

死锁

死锁是指两个或两个以上的进程在执⾏过程中,因争夺资源⽽造成的⼀种互相等待的现象,若⽆外⼒作⽤,它们都将⽆法推进下去。此时称系统处于死锁。

JDK对锁的优化 -> Java6及以上版本对synchronized的优化

死锁的四个必要条件

  • 互斥条件:同一资源任意时刻只有一个进程占用
  • 请求与保持条件:一个进程请求资源阻塞时,对已获得的资源保持不放
  • 不剥夺条件:一个进程已拥有的资源在未使用完之前不会被其他线程强行剥夺,在使用完后才释放资源
  • 循环等待条件:多个线程之间形成一种头尾相接的循环等待资源的关系

如何避免死锁

  1. 破坏互斥条件 :这个条件我们没有办法破坏,因为我们用锁本来就是想让他们互斥的(临界资源需要互斥访问)。
  2. 破坏请求与保持条件 :一次性申请所有的资源。
  3. 破坏不剥夺条件 :占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。
  4. 破坏循环等待条件 :靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放,破坏循环等待条件。

AQS

参考以下资料:
Java并发之AQS详解
Java并发包基石-AQS详解

IO模型

BIO、NIO 和 AIO

JAVA BIO:同步并阻塞,服务器实现模式为一个连接一个线程,即客户端有连接请求时服务器端就需要启动一个线程并处理,如果这个连接不做任何事情会造成不必要的开销,当然可以通过线程池机制改善。

JAVA NIO:同步非阻塞,服务器实现模式为一个线程可以处理多个请求(连接),客户端发送的连接请求都会注册到多路复用器selector上,多路复用 器轮询到连接有IO请求就进行处理,JDK1.4开始引入。

JAVA AIO(NIO2):异步非阻塞,服务器实现模式为一个有效请求一个线程,客户端的I/O请求都是由OS先完成了再通知服务器应用去启动线程进行处理。

Epoll

epoll在Linux2.6内核正式提出,是基于事件驱动的I/O方式,相对于select来说,epoll没有描述符个数限制,使用一个文件描述符管理多个描述符,将用户关心的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的copy只需一次。

Linux中提供的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的工作原理

epoll使用基于事件的就绪通知方式,在select/poll中,进程只有在调用一定的方法后,内核才对所有监视的文件描述符进行扫描;而通过epoll_ctl注册fd,一旦该fd就绪,内核就会采用类似callback的回调机制来激活该fd,epoll_wait便可以收到通知。

假如添加100万个事件连接:

  1. 调用epoll_create建立一个epoll对象(在epoll文件系统中给这个句柄分配资源);
  2. 调用epoll_ctl向epoll对象中添加这100万个连接的套接字;
  3. 调用epoll_wait收集发生事件的连接。

这样只需要在进程启动时建立1个epoll对象,并在需要的时候向它添加或删除连接就可以了,因此,在实际收集事件时,epoll_wait的效率就会非常高,因为调用epoll_wait时并没有向它传递这100万个连接,内核也不需要去遍历全部的连接。

如上所述,epoll原理:
当某一进程调用epoll_create方法时,Linux内核会创建一个eventpoll结构体,这个结构体中有两个成员与epoll的使用方式密切相关,如下所示:

struct eventpoll {
  ...
  /*红黑树的根节点,这棵树中存储着所有添加到epoll中的事件,
  也就是这个epoll监控的事件*/
  struct rb_root rbr;
  /*双向链表rdllist保存着将要通过epoll_wait返回给用户的、满足条件的事件*/
  struct list_head rdllist;
  ...
};

我们在调用epoll_create时,内核除了帮我们在epoll文件系统里建了个file结点,在内核cache里建了个红黑树用于存储以后epoll_ctl传来的socket外,还会再建立一个rdllist双向链表,用于存储准备就绪的事件,当epoll_wait调用时,仅仅观察这个rdllist双向链表里有没有数据即可。有数据就返回,没有数据就sleep,等到timeout时间到后即使链表没数据也返回。所以,epoll_wait非常高效。

所有添加到epoll中的事件都会与设备(如网卡)驱动程序建立回调关系,也就是说相应事件的发生时会调用这里的回调方法。这个回调方法在内核中叫做ep_poll_callback,它会把这样的事件放到上面的rdllist双向链表中。

当调用epoll_wait检查是否有发生事件的连接时,只是检查eventpoll对象中的rdllist双向链表是否有epitem元素而已,如果rdllist链表不为空,则这里的事件复制到用户态内存(使用共享内存提高效率)中,同时将事件数量返回给用户。因此epoll_waitx效率非常高。epoll_ctl在向epoll对象中添加、修改、删除事件时,从rbr红黑树中查找事件也非常快,也就是说epoll是非常高效的,它可以轻易地处理百万级别的并发连接。

即一颗红黑树,一张准备就绪句柄链表,少量的内核cache,就帮我们解决了大并发下的socket处理问题。

  • 执行epoll_create()时,创建了红黑树和就绪链表;
  • 执行epoll_ctl()时,如果增加socket句柄,则检查在红黑树中是否存在,存在立即返回,不存在则添加到树干上,然后向内核注册回调函数,用于当中断事件来临时向准备就绪链表中插入数据;
  • 执行epoll_wait()时立刻返回准备就绪链表里的数据即可。

epoll除了提供select/poll那种IO事件的水平触发(Level Triggered)外,还提供了边缘触发(Edge Triggered),这就使得用户空间程序有可能缓存IO状态,减少epoll_wait/epoll_pwait的调用,提高应用程序效率。ET模式很大程度上减少了epoll事件的触发次数,因此效率比LT模式下高。

  • 水平触发(LT):默认工作模式,即当epoll_wait检测到某描述符事件就绪并通知应用程序时,应用程序可以不立即处理该事件;下次调用epoll_wait时,会再次通知此事件
  • 边缘触发(ET): 当epoll_wait检测到某描述符事件就绪并通知应用程序时,应用程序必须立即处理该事件。如果不处理,下次调用epoll_wait时,不会再次通知此事件。(直到你做了某些操作导致该描述符变成未就绪状态了,也就是说边缘触发只在状态由未就绪变为就绪时只通知一次)。

epoll为什么要有EPOLLET触发模式?

如果采用EPOLLLT模式的话,系统中一旦有大量你不需要读写的就绪文件描述符,它们每次调用epoll_wait都会返回,这样会大大降低处理程序检索自己关心的就绪文件描述符的效率。而采用EPOLLET这种边缘触发模式的话,当被监控的文件描述符上有可读写事件发生时,epoll_wait()会通知处理程序去读写。如果这次没有把数据全部读写完(如读写缓冲区太小),那么下次调用epoll_wait()时,它不会通知你,也就是它只会通知你一次,直到该文件描述符上出现第二次可读写事件才会通知你!!!这种模式比水平触发效率高,系统不会充斥大量你不关心的就绪文件描述符。

参考:https://blog.csdn.net/daaikuaichuan/article/details/83862311

IO多路复用的三种机制Select,Poll,Epoll

select,poll,epoll的区别:

select poll epoll
操作方式 遍历 遍历 回调
底层实现 数组 链表 红黑树
IO效率 每次调用都进行线性遍历,时间复杂度为O(n) 每次调用都进行线性遍历,时间复杂度为O(n) 事件通知方式,每当fd就绪,系统注册的回调函数就会被调用,将就绪fd放到readyList里面,时间复杂度O(1)
最大连接数 1024(x86)或2048(x64) 无上限 无上限
fd拷贝 每次调用select,都需要把fd集合从用户态拷贝到内核态 每次调用poll,都需要把fd集合从用户态拷贝到内核态 调用epoll_ctl时拷贝进内核并保存,之后每次epoll_wait不拷贝(零拷贝机制)

Netty

Netty的高性能表现在哪些方面:

  1. IO线程模型 :同步非阻塞,用最少的资源做更多的事情。
  2. 内存零拷贝 :尽量减少不必要的内存拷贝,实现了更高效率的传输。
  3. 内存池设计 :申请的内存可以重用,主要指直接内存。内部实现是用一颗二叉查找树管理内存分配情况。
  4. 串行化处理读写 :避免使用锁带来的性能开销。即消息的处理尽可能再同一个线程内完成,期间不进行线程切换,这样就避免了多线程竞争和同步锁。表面上看,串行化设计似乎CPU利用率不高,并发程度不够。但是,通过调整NIO线程池的线程参数,可以同时启动多个串行化的线程并行运行,这种局部无锁化的串行线程设计相比一个队里-多个工作线程模型性能更优。
  5. 高性能序列化协议 :支持protobuf等高性能序列化协议。
  6. 高效并发编程的体现 :volatile的大量、正确使用;CAS和原子类的广泛使用;线程安全容器的使用;通过读写锁提升并发性能。

线程模型:单线程模型、多线程模型、主从多线程模型。TCP粘包/拆包。Netty的零拷贝实现。
参考:https://blog.csdn.net/ThinkWon/article/details/104391081
https://segmentfault.com/a/1190000021054503

微服务

CAP理论:指的是一个分布式系统最多只能同时满足一致性(Consistency)、可用性(Availability)和分区容错性(Partition tolerance)这三项中的两项。

使用AP或CP系统完全取决于具体的应用场景,当对一致性的要求不高时,我们可以使用AP系统。当要求强一致性时,我们可以选择CP系统。

Sentinel

资源

资源是 Sentinel 的关键概念。它可以是 Java 应用程序中的任何内容,例如,由应用程序提供的服务,或由应用程序调用的其它应用提供的服务,甚至可以是一段代码。在接下来的文档中,我们都会用资源来描述代码块。只要通过 Sentinel API 定义的代码,就是资源,能够被 Sentinel 保护起来。大部分情况下,可以使用方法签名,URL,甚至服务名称作为资源名来标示资源。

规则

围绕资源的实时状态设定的规则,可以包括流量控制规则、熔断降级规则以及系统保护规则。所有规则可以动态实时调整。

规则

Sentinel 的所有规则都可以在内存态中动态地查询及修改,修改之后立即生效。同时 Sentinel 也提供相关 API,供您来定制自己的规则策略。

Sentinel 支持以下几种规则:流量控制规则熔断降级规则系统保护规则来源访问控制规则热点参数规则

流量控制规则 (FlowRule)

流量规则的定义以及重要属性:

Field 说明 默认值
resource 资源名,资源名是限流规则的作用对象
count 限流阈值
grade 限流阈值类型,QPS 模式(1)或并发线程数模式(0) QPS 模式
limitApp 流控针对的调用来源 default,代表不区分调用来源
strategy 调用关系限流策略:直接、链路、关联 根据资源本身(直接)
controlBehavior 流控效果(直接拒绝/WarmUp/匀速+排队等待),不支持按调用关系限流 直接拒绝
clusterMode 是否集群限流

同一个资源可以同时有多个限流规则,检查规则时会依次检查。

流量控制(flow control)

其原理是监控应用流量的 QPS 或并发线程数等指标,当达到指定的阈值时对流量进行控制,以避免被瞬时的流量高峰冲垮,从而保障应用的高可用性。
流量控制主要有两种统计类型,一种是统计并发线程数,另外一种则是统计 QPS。类型由 FlowRulegrade 字段来定义。其中,0 代表根据并发数量来限流,1 代表根据 QPS 来进行流量控制。其中线程数、QPS 值,都是由 StatisticSlot 实时统计获取的。

  • 并发线程数控制

并发控制不负责创建和管理线程池,而是简单统计当前请求上下文的线程数目(正在执行的调用数目),如果超出阈值,新的请求会被立即拒绝,效果类似于信号量隔离。并发数控制通常在调用端进行配置。

  • QPS流量控制

当 QPS 超过某个阈值的时候,则采取措施进行流量控制。流量控制的效果包括以下几种:直接拒绝Warm Up匀速排队。对应 FlowRule 中的 controlBehavior 字段。

熔断降级规则 (DegradeRule)

熔断降级规则包含下面几个重要的属性:

Field 说明 默认值
resource 资源名,即规则的作用对象
grade 熔断策略,支持慢调用比例/异常比例/异常数策略 慢调用比例
count 慢调用比例模式下为慢调用临界 RT(超出该值计为慢调用);异常比例/异常数模式下为对应的阈值
timeWindow 熔断时长,单位为 s
minRequestAmount 熔断触发的最小请求数,请求数小于该值时即使异常比率超出阈值也不会熔断(1.7.0 引入) 5
statIntervalMs 统计时长(单位为 ms),如 60*1000 代表分钟级(1.8.0 引入) 1000 ms
slowRatioThreshold 慢调用比例阈值,仅慢调用比例模式有效(1.8.0 引入)

同一个资源可以同时有多个降级规则。

熔断策略

Sentinel 提供以下几种熔断策略:

  • 慢调用比例 (SLOW_REQUEST_RATIO):选择以慢调用比例作为阈值,需要设置允许的慢调用 RT(即最大的响应时间),请求的响应时间大于该值则统计为慢调用。当单位统计时长(statIntervalMs)内请求数目大于设置的最小请求数目,并且慢调用的比例大于阈值,则接下来的熔断时长内请求会自动被熔断。经过熔断时长后熔断器会进入探测恢复状态(HALF-OPEN 状态),若接下来的一个请求响应时间小于设置的慢调用 RT 则结束熔断,若大于设置的慢调用 RT 则会再次被熔断。
  • 异常比例 (ERROR_RATIO):当单位统计时长(statIntervalMs)内请求数目大于设置的最小请求数目,并且异常的比例大于阈值,则接下来的熔断时长内请求会自动被熔断。经过熔断时长后熔断器会进入探测恢复状态(HALF-OPEN 状态),若接下来的一个请求成功完成(没有错误)则结束熔断,否则会再次被熔断。异常比率的阈值范围是 [0.0, 1.0],代表 0% - 100%。
  • 异常数 (ERROR_COUNT):当单位统计时长内的异常数目超过阈值之后会自动进行熔断。经过熔断时长后熔断器会进入探测恢复状态(HALF-OPEN 状态),若接下来的一个请求成功完成(没有错误)则结束熔断,否则会再次被熔断。

注意异常降级仅针对业务异常,对 Sentinel 限流降级本身的异常(BlockException)不生效。为了统计异常比例或异常数,需要通过 Tracer.trace(ex) 记录业务异常。

系统保护规则 (SystemRule)

Sentinel 系统自适应限流从整体维度对应用入口流量进行控制,结合应用的 Load、CPU 使用率、总体平均 RT、入口 QPS 和并发线程数等几个维度的监控指标,通过自适应的流控策略,让系统的入口流量和系统的负载达到一个平衡,让系统尽可能跑在最大吞吐量的同时保证系统整体的稳定性。

系统规则包含下面几个重要的属性:

Field 说明 默认值
highestSystemLoad load1 触发值,用于触发自适应控制阶段 -1 (不生效)
avgRt 所有入口流量的平均响应时间 -1 (不生效)
maxThread 入口流量的最大并发数 -1 (不生效)
qps 所有入口资源的 QPS -1 (不生效)
highestCpuUsage 当前系统的 CPU 使用率(0.0-1.0) -1 (不生效)

系统规则

系统保护规则是从应用级别的入口流量进行控制,从单台机器的 load、CPU 使用率、平均 RT、入口 QPS 和并发线程数等几个维度监控应用指标,让系统尽可能跑在最大吞吐量的同时保证系统整体的稳定性。

系统保护规则是应用整体维度的,而不是资源维度的,并且仅对入口流量生效。入口流量指的是进入应用的流量(EntryType.IN),比如 Web 服务或 Dubbo 服务端接收的请求,都属于入口流量。

系统规则支持以下的模式:

  • Load 自适应(仅对 Linux/Unix-like 机器生效):系统的 load1 作为启发指标,进行自适应系统保护。当系统 load1 超过设定的启发值,且系统当前的并发线程数超过估算的系统容量时才会触发系统保护(BBR 阶段)。系统容量由系统的 maxQps * minRt 估算得出。设定参考值一般是 CPU cores * 2.5
  • CPU usage(1.5.0+ 版本):当系统 CPU 使用率超过阈值即触发系统保护(取值范围 0.0-1.0),比较灵敏。
  • 平均 RT:当单台机器上所有入口流量的平均 RT 达到阈值即触发系统保护,单位是毫秒。
  • 并发线程数:当单台机器上所有入口流量的并发线程数达到阈值即触发系统保护。
  • 入口 QPS:当单台机器上所有入口流量的 QPS 达到阈值即触发系统保护。

访问控制规则 (AuthorityRule)

很多时候,我们需要根据调用方来限制资源是否通过,这时候可以使用 Sentinel 的访问控制(黑白名单)的功能。黑白名单根据资源的请求来源(origin)限制资源是否通过,若配置白名单则只有请求来源位于白名单内时才可通过;若配置黑名单则请求来源位于黑名单时不通过,其余的请求通过。

授权规则,即黑白名单规则(AuthorityRule)非常简单,主要有以下配置项:

  • resource:资源名,即规则的作用对象
  • limitApp:对应的黑名单/白名单,不同 origin 用 , 分隔,如 appA,appB
  • strategy:限制模式,AUTHORITY_WHITE 为白名单模式,AUTHORITY_BLACK 为黑名单模式,默认为白名单模式

黑白名单规则配置

来源访问控制规则(AuthorityRule)非常简单,主要有以下配置项:

  • resource:资源名,即限流规则的作用对象。
  • limitApp:对应的黑名单/白名单,不同 origin 用 , 分隔,如 appA,appB
  • strategy:限制模式,AUTHORITY_WHITE 为白名单模式,AUTHORITY_BLACK 为黑名单模式,默认为白名单模式。

热点规则 (ParamFlowRule)

热点参数规则(ParamFlowRule)类似于流量控制规则(FlowRule):

属性 说明 默认值
resource 资源名,必填
count 限流阈值,必填
grade 限流模式 QPS 模式
durationInSec 统计窗口时间长度(单位为秒),1.6.0 版本开始支持 1s
controlBehavior 流控效果(支持快速失败和匀速排队模式),1.6.0 版本开始支持 快速失败
maxQueueingTimeMs 最大排队等待时长(仅在匀速排队模式生效),1.6.0 版本开始支持 0ms
paramIdx 热点参数的索引,必填,对应SphU.entry(xxx, args) 中的参数索引位置
paramFlowItemList 参数例外项,可以针对指定的参数值单独设置限流阈值,不受前面count 阈值的限制。仅支持基本类型和字符串类型
clusterMode 是否是集群参数流控规则 false
clusterConfig 集群流控相关配置

何为热点?热点即经常访问的数据。很多时候我们希望统计某个热点数据中访问频次最高的 Top K 数据,并对其访问进行限制。比如:

  • 商品 ID 为参数,统计一段时间内最常购买的商品 ID 并进行限制
  • 用户 ID 为参数,针对一段时间内频繁访问的用户 ID 进行限制

热点参数限流会统计传入参数中的热点参数,并根据配置的限流阈值与模式,对包含热点参数的资源调用进行限流。热点参数限流可以看做是一种特殊的流量控制,仅对包含热点参数的资源调用生效。

Sentinel 利用 LRU 策略统计最近最常访问的热点参数,结合令牌桶算法来进行参数级别的流控。热点参数限流支持集群模式。

参考:Sentinel Wiki