无题

142 阅读1小时+

JavaSE

语言特性

JDK 和 JRE

JDK = JRE + 开发工具集(java javac javadoc jar....)

JRE: JRE = JVM + 核心类库

反射

在运行状态中,动态的获取类的各个属性以及调用它的方法

原理:通过将类对应的字节码文件加载到jvm内存中得到一个Class对象,通过这个Class对象可以反向获取实例的各个属性以及调用它的方法。

使用场景:jdk,jdbc,bean

1、通过反射运行配置文件内容

  • 加载配置文件,并解析配置文件得到相应信息
  • 根据解析的字符串利用反射机制获取某个类的Class实例
  • 动态配置属性

2、JDK 动态代理

3、jdbc 通过Class.forName()加载数据的驱动程序

4、Spring 解析 xml 装配 Bean

获取一个 Class 对象

  • 类名.class
  • 对象的 getClass方法
  • Class.forName(类的全限定名)

JDK 与 cjlib 动态代理

作用:

  • 功能增强:再原有功能加新功能
  • 控制访问:代理类不让你访问目标

JDK 动态代理:利用反射机制生成代理类,可以动态指定代理类的目标类,要求实现invovationHandler接口,重写invoke方法进行功能增强,还要求目标类必须实现接口

Cjlib 动态代理:利用ASM开源包,把代理对象的CLass文件加载进来,修改其字节码文件生成子类,子类重写目标类的方法,被final修饰不可以,然后在子类采用方法拦截技术拦截父类方法调用织入逻辑(定义拦截器实现 MethodInterceptor 接口)

注解

注解是一种标记,使类或接口附加额外信息,帮助编译器和 JVM 完成一些特定功能,例如 @Override 标识一个方法是重写方法。

元注解是自定义注解的注解,例如:

  • @Target:约束作用位置,值是 ElementType 枚举常量,包括 METHOD 方法、VARIABLE 变量、TYPE 类/接口、PARAMETER 方法参数、CONSTRUCTORS 构造方法和 LOACL_VARIABLE 局部变量等。
  • @Rentention:约束生命周期,值是 RetentionPolicy 枚举常量,包括 SOURCE 源码、CLASS 字节码和 RUNTIME 运行时。
  • @Documented:表明这个注解应该被 javadoc 记录。

泛型

在定义类、接口、方法的时候不局限地指定某一种特定类型,而让类、接口、方法的调用者来决定具体使用哪一种类型的参数。

泛型的好处:

  • 类型安全,放置什么出来就是什么,不存在 ClassCastException。
  • 提升可读性,编码阶段就显式知道泛型集合、泛型方法等处理的对象类型。
  • 代码重用,合并了同类型的处理代码。

异常有哪些分类?

所有异常都是 Throwable 的子类,分为 ErrorException

Error 是 Java 运行时系统的内部错误和资源耗尽错误,例如 StackOverFlowError 和 OutOfMemoryError,这种异常程序无法处理。

Exception 分为受检异常和非受检异常,受检异常需要在代码中显式处理,否则会编译出错,非受检异常是运行时异常,继承自 RuntimeException

受检异常(编译时异常):

  • 无能为力型,如字段超长导致的 SQLException
  • 力所能及型,如未授权异常 UnAuthorizedException,程序可跳转权限申请页面。
  • 常见受检异常还有 FileNotFoundExceptionClassNotFoundExceptionIOException 等。

非受检异常(运行时异常):

  • 可预测异常,例如 IndexOutOfBoundsExceptionNullPointerExceptionClassCastException 等,这类异常应该提前处理。

常⻅的异常有哪些

数组越界异常: IndexOutOfBoundsException

空指针异常: NullPointerException

数学运算异常: ArithmeticException

类没有找到异常: ClassNotFoundException (检测异常)

数组格式化异常: NumberFormatException

NoClassDefFoundError 和 ClassNotFoundException 区别的常见回答

  • NoClassDefFoundError 是一种 Error,Error 在大多数情况下代表无法从程序中恢复的致命错误,产生的原因在于 JVM 或者 ClassLoader 在运行时类加载器在 classpath 下找不到需要的类定义(编译期是可以正常找到的,所以和 ClassNotFoundException 不同的是这是一个运行期的 Error),这个时候虚拟机就会抛出 NoClassDefFoundError,通常造成该 ERROR 的原因是打包过程中漏掉了部分类,或者 jar 包出现损坏或篡改,对应的 Class 在 classpath 中不可用等等原因
  • ClassNotFoundException 是属于 Exception 的编译时异常,大多是可以从代码中恢复的异常类型,导致该异常的原因大多是因为使用 Class.forName()方法动态的加载类信息,但是这个类在类路径中并没有被找到,那么就会在运行时抛出 ClassNotFoundException

数据类型

占用内存

  • 内存大小:1 2 4 8 4 8 (byte short int long) 2 4

  • char(2)

  • boolean: 单个变量 4 字节 / 数组 1 字节

    • JVM 没有 boolean 赋值的专用字节码指令,boolean f = false 就是使用 ICONST_0常数 0 赋值。
    • 单个 boolean 变量用 int 代替,boolean 数组会编码成 byte 数组。

什么是值传递和引⽤传递

都是副本,影响原来

值传递是对于基本变量⽽⾔的,就是 传递⼀个变量副本,变量副本的改变不会影响原变量

引⽤传递是对对象⽽⾔的,就是 传递对象地址副本,并不是原对象本身,如果副本改变会影响原对象

String 为何不可变不可变好处

String 类中使⽤ private final 关键字修饰字符数组,表示不可修改和访问,jdk9后,使用byte数组。

StringBuilder 与 StringBuffer 都继承⾃**AbstractStringBuilder类,在此类中也是使⽤字符数组保存字符串char[]value 但是没有⽤ final** 关键字修饰,所以这两种对象都是可变的。

不可变的好处: hashmap,常量池,安全

  • 创建的时候 hashcode 就被缓存,使得字符串很适合作为 Map 中的键,字符串的处理速度要快过其它的键对象,HashMap 中的键往往都使用字符串。保证 hash 属性值不会频繁变更,确保了唯⼀性,这也是HashMap采⽤string作为key的原因
  • 实现 String pool:两种方式创建字符串对象。。。
  • 线程安全

String 是不可变类为什么值可以修改?

  • 对一个 String 对象的任何修改实际上都是创建一个新 String 对象,再引用该对象。
  • 只是修改 String 变量引用的对象,没有修改原 String 对象的内容。
  • String类的对象的内容不可更改,但可以更改的是对象的引用, 字符串 String类之间使用'+'来操作是重新分配空间

String,StringBuffer 和 StringBuilder

可变: String是不可变对象,任何对String修改都会创建新的String对象

线程安全:Stringbuffer 方法由 synchronized 修饰,线程安全。

加分: StringBuffer: 锁粗化 锁消除

  • 假如StringBuffer出现在循环体中即使没有出现线程竞争,频繁进⾏同步互斥会带来性能开销, 所以synchronized进⾏了锁粗化,将锁的范围扩⼤到整个操作
  • 假如是单线程使⽤StringBuffer也就是不存在线程竞争, synchronized会进⾏锁消除

String a = "a" + new String("b") 创建了几个对象?

  • 常量和常量拼接仍是常量,结果在常量池
  • 只要有变量参与拼接结果就是变量,存在堆。
  • 使用字面量时只创建一个常量池中的常量,使用 new 时如果常量池中没有该值就会在常量池中新创建,再在堆中创建一个对象引用常量池中常量。
  • 因此 String a = "a" + new String("b") 会创建四个对象,常量池中的 a 和 b,堆中的 b 和堆中的 ab。

重写 equals 时必须重写 hashCode ⽅法

  • hashcode 哈希码,确定该对象在哈希表中的索引位置

    • 提高效率:使用 hashcode() 提前检验,定位,hashmap,不用每一次都使用 equlas() 方法比较,提高效
    • 保证没有重复对象出现,确保 hashmap 去重性:假如只重写equas方法,不重写hashcode,相同的对象hashCode不同,从而映射到不同下标下。
    • hashcode 不同,equals 一定不同;hashcode 相同,equals 不一定相同 (哈希冲突)

equals==的区别

  • ==对于基本类型⽐较的是值,但是引⽤类型⽐较的是内存地址
  • equals取决于⼦类是否重写,默认和==等价

包装类型和基本类型的区别

对象(字段方法) 初始值 内存位置

包装类型是对象,拥有字段和方法,可以很方便地调用一些基本的方法,初始值是null,而且可以使用null代表空值,而基本数据类型只能使用0来代表初始值。

其次是基本数据类型是直接存储在中,而包装类型是一个对象,对象的引用变量是存储在栈中,存储了对象在堆中的地址,对象的数据是存在堆中。

面向对象

面向对象的理解

实现对象,过程。三大特性

面向过程:注重实现过程

面向对象:关心实现对象是谁,封装、继承、多态

面向对象的三大特性?

  • 抽象:就是将具体事物抽象成⼀个类

  • 封装:将一类属性和行为抽象成一个类,属性私有,行为公开,安全,通过抽象所得到数据信息及其功能,以封装的技术将其重新聚合,形成一个类

  • 继承复用代码,减少冗余,易于扩展,将几个类共有的属性和行为抽象成一个父类,子类有父类属性行为,有自己属性行为

  • 多态:同个方法调用,对象不同,不同行为

    • 多态指在编译层面无法确定最终调用的方法体,在运行期由 JVM 动态绑定,调用合适的重写方法。
    • 重载属于静态绑定,本质上是完全不同的方法,多态专指重写,运行时的多态性。
    • 多态存在的 3 个必要条件:继承,重写,父类引用指向子类对象:编译的时候不知道这个指针指向哪里,等到运行时才知道,从而去指向对应的子类或者接口具体实现的类所指向的方法

重载和重写

重载:发生在同一类中,方法名相同,参数列表不同。与方法返回值和访问修饰符无关

重写:发生在父子类中,两同两小一大:方法名,参数。返回值,异常。public

两同:👉 方法名相同 ​ 👉 参数列表相同

两小:👉 子类 返回值 类型小于等于父类返回值类型(返回值为基本数据类型时,必须相等) ​ 👉 子类 抛出异常 小于等于父类抛出的异常

一大:子类 访问控制修饰符 大于等于父类访问控制修饰符

设计模式中的多态

策略,工厂

Java创建对象有哪⼏种⽅式

可以通过new

可以通过反射进⾏newInstance

可以通过clone

Object 类有哪些方法?

  • equals: 对象是否相等,默认 == 比较对象引用,可重写 equals 方法自定义比较规则

  • hashCode: 值由对象存储地址得出。用于 hash 查找,减少使用 equal() 次数。字符串散列码由内容导出,值可能相同。为了在集合中正确使用,一般需要同时重写 equals 和 hashCode,要求 equals 相同 hashCode 必须相同,hashCode 相同 equals 未必相同

  • clone: 类通过该方法克隆它自己的对象,浅克隆, 一般重写 clone 方法需要实现 Cloneable 接口并指定访问修饰符为 public。

  • finalize: 对象在被GC释放之前一定会调用finalize方法,对象被释放前最后的挣扎,因为无法确定该方法什么时候被调用,很少使用。

  • getClass: 返回包含对象信息的类对象。

  • toString:打印对象时默认的方法,如果没有重写默认返回的是当前对象的类名+hashCode的16进制数字。

  • notify / notifyAll: 唤醒持有该对象锁的线程。

  • wait:让当前线程进入等待状态,同时,wait()也会让当前线程释放它所持有的锁,直到其他线程调用此对象的 notify() 方法或 notifyAll() 方法,当前线程被唤醒(进入“就绪状态”)。

    还有一个wait(long timeout)超时时间

    补充 sleep() 不会释放锁

final的作用

  1)修饰变量,被final修饰的变量必须要初始化,赋初值后不能再重新赋值

  2)修饰方法,被final修饰的方法代表不能重写。

  3)修饰类,被final修饰的类,不能够被继承。

final finally finalize的区别

  • final修饰的类不能被继承、属性是常量⽽且必须初始化、修饰的⽅法不能被重写
  • finallytry catch异常捕获的⼀部分,如果加了finally⼀定会执⾏
  • finalizeObject类的⼀个⽅法,该⽅法⼀般由垃圾收集器调⽤,当我们调⽤Sytem.gc的时候

加分

重写了finalize⽅法的对象会被加⼊到⼀个unfinalized的链表中去

第⼀次GC的时候并不是去回收这个对象,因为要先调⽤finalize ,然后从unfinalized链表中移除,在这⾥对象可以进⾏⾃救,将⾃⼰重新加⼊到引⽤链上

第⼆次GC的时候会将对象内存回收

重写了这个方法,就有可能导致对象被延迟回收,如果这个方法再被放入错误的代码,就极有可能导致该对象无法回收。

静态内部类

  • 定义在类内部的静态类成为静态内部类。
  • 静态内部类可以访问外部类的静态变量和方法;在静态内部类中可以定义静态变量/方法/构造函数等;
  • 静态内部类通过“外部类.静态内部类”的方式调用

访问权限控制符

本类 包内 包外子类 任何地方

private default

接口和抽象类

成员变量,方法实现,多继承

  • 抽象类可以有⽅法实现,成员变量可以是各种类型,可以有静态代码块和静态⽅法,并且⼀个可以继承⼀个抽象类⽽且必须实现抽象类中的⽅法
  • 接⼝中只能是抽象⽅法,成员变量只能是private static final类型,接⼝可以进⾏多实现

子类初始化的顺序

父子 -> 父父 -> 子子

1、父类静态变量和静态代码块(先声明的先执行);

2、子类静态变量和静态代码块(先声明的先执行);

3、父类的变量和代码块(先声明的先执行);

4、父类的构造函数;

5、子类的变量和代码块(先声明的先执行);

6、子类的构造函数。

深拷⻉和浅拷⻉

浅克隆:创建一个新对象,如果字段类型是基本数据类型的,那么对该字段进行复制;如果字段是引用类型的,则只复制对象的地址

深拷⻉:将引用类型的属性内容也拷贝一份新的

集合

常⻅的集合有哪些

主要分为两⼤类 Collection Map

  • Collection 的⼦接⼝有List Set Queue
  • Map 的⼦接⼝有 HashMap ConcurrentHashMap TreeMap

List是有序集合,元素允许重复,允许存放多个null值,底层实现有数组和链表两种

Set是⽆序集合,不能存放重复元素,允许放⼀个null值,基于Map的key实现的

set 与 list 区别?

collection, 有序可重复,无序不重复覆盖

ArrayList

容量可变的非线程安全列表,使用数组实现,集合扩容时会创建更大的数组,把原有数组复制到新数组。

支持对元素的快速随机访问,但插入与删除速度很慢。

扩容机制

  • 默认 10
  • 1.5 倍
  • Arrays.copyOf()

Vector 区别

  • 线程安全,性能,扩容倍数 1.5、2, 默认长度 10

LinkedList

双向链表,与 ArrayList 相比插入和删除速度更快,但随机访问元素很慢。

实现了 Deque 接口,这个接口具有队列和栈的性质。

ArrayList 和 LinkedList 的区别

数据结构,应用场景,队列

数据结构不同:ArrayList:初始 10、1.5倍,LinkedList 双向链表

插入删除、查询

LinkedList

  1. 基于双向链表,无需连续内存
  2. 随机访问慢(要沿着链表遍历)
  3. 头尾插入删除性能高
  4. 占用内存多

ArrayList

  1. 基于数组,需要连续内存
  2. 随机访问快(指根据下标访问)
  3. 尾部插入、删除性能可以,其它部分插入、删除都会移动数据,因此性能会低
  4. 可以利用 cpu 缓存,局部性原理

什么是快速失败(fast fail)

快速失败就是Java集合的⼀种机制,当多个线程对同⼀个集合进⾏操作的时候,有可能会产⽣快速失败的问题

Fail-Fast 与 Fail-Safe

  • ArrayList 是 fail-fast 的典型代表,遍历的同时不能修改,尽快失败
  • CopyOnWriteArrayList 是 fail-safe 的典型代表,遍历的同时可以修改,原理是读写分离

解决办法

  • 可以使⽤Colletions.synchronizedList⽅法或者在修改集合的地⽅加上synchronized,这样话反⽽降低了性能
  • 使⽤CopyOnWriteArrayList来替换ArrayList ,在对CopyOnWriteArrayList修改的时候会新拷⻉⼀个数组,对新的数组进⾏操作,操作完之后再把引⽤指向新数组

CopyOnWriteArrayList 底层原理实现

  • ⾸先CopyOnWriteArrayList内部也是通过数组来实现的,在向CopyOnWriteArrayList添加元素的时候,会复制到⼀个新的数组,写操作时对新数组进⾏写,读操作还是在原数组上进⾏读,也就是 读写分离 ,在写操作时会进⾏加锁,防⽌出现并发写⼊⽽丢失数据,写操作结束后会把原数组引⽤指向新数组
  • CopyOnWriteArrayList允许再写操作时来读取数据,⼤⼤提⾼了读的性能,适合读多写少的场景,但 CopyOnWriteArrayList 会⽐较占内存,同时可能读到旧数据,所以不适合实时性要求很⾼的场景

Set

不允许元素重复且无序,常用实现有 HashSet、LinkedHashSet 和 TreeSet

HashSet 通过 HashMap 实现,HashMap 的 Key 即 HashSet 存储的元素,所有 Key 都使用相同的 Value ,一个 Object 类型常量。使用 Key 保证元素唯一性,但不保证有序性。由于 HashSet 是 HashMap 实现的,因此线程不安全。

LinkedHashSet 继承自 HashSet,通过 LinkedHashMap 实现,使用双向链表维护元素插入顺序。

TreeSet 通过 TreeMap 实现的,添加元素到集合时按照比较规则将其插入合适的位置,保证插入后的集合仍然有序。

TreeMap

TreeMap 基于[红黑树]实现,增删改查的平均和最差时间复杂度均为 O(logn) ,最大特点是 Key 有序。Key 必须实现 Comparable 接口或提供的 Comparator 比较器,所以 Key 不允许为 null。

HashMap 依靠 hashCodeequals 去重,而 TreeMap 依靠 Comparable 或 Comparator。

HashMap 有什么特点?

JDK8 之前底层实现是 数组 + 链表,JDK8 改为 数组 + [链表]/[红黑树],节点类型从 Entry 变更为 Node。主要成员变量包括存储数据的 table 数组、元素数量 size、加载因子 loadFactor。

table 数组记录 HashMap 的数据,每个下标对应一条[链表],所有哈希冲突的数据都会被存放到同一条[链表]

HashMap 中数据以键值对的形式存在,键对应的 hash 值用来计算数组下标,如果两个元素 key 的 hash 值一样,就会发生哈希冲突,被放到同一个[链表]上,为使查询效率尽可能高,键的 hash 值要尽可能分散。

HashMap 默认初始化容量为 16,扩容容量必须是 2 的幂次方、默认加载因子为 0.75。

HashMap为什么⽤String作为key

创建的时候 hashcode 就被缓存

因为String中的char数组是⽤private final进⾏修饰的,不能被恶意篡改以及不可变,既保证了唯⼀性也保证了线程安全

HashTable 和 Hashtable 的区别

4 方面:安全与效率,数据结构,默认容量与扩容,NULL

  • 线程安全: Hashtable 方法同步修饰,线程安全, 效率比 HashMap 低
  • 数据结构: HashMap jdk8当链表长度 > = 8并且数组长度 > = 64链表会转红黑树,Hashtable 没有这样机制
  • 默认初始: 不指定默认Hashtable 11,HashMap 16; 指定初始量HashMap 会扩充为2的幂次方大小
  • 扩容: Hashtable 容量变为原来 2n+1 倍,HashMap 变为2倍。
  • NULL: HashMap 支持一个 Null 键多个 Null 值,Hashtable 不支持 Null 键,否则报错空指针异常

HashMap 与 HashSet区别

HashSet 底层就是基于 HashMap 实现的

HashMap使用键(Key) 计算hashcode,HashSet使用成员对象来计算hashcode值

HashMap jdk8 与 jdk7 区别

数据结构,链表头插,扩容时机

  • JDK8中新增了红黑树,JDK8是通过数组+链表+红黑树来实现的
  • JDK7中链表的插入是用的头插法,而JDK8中则改为了尾插法。
  • JDK7中是先扩容再添加新元素,JDK8中是先添加新元素然后再扩容
  • 扩容机制,hash 算法(红黑树)

hashmap 的 put 方法

hash 3 步(hash、异或、与), 下标位置 2 种,扩容

  • 如果当前位置存在元素的话,就判断 key 是否相同,如果相同的话,直接覆盖,不相同就通过拉链法解决冲突。

    • 经过扰动函数使其hash值更散列(调用key对象的hashcode方法计算出来hash值,将 Hash 值的高 16 位右移并与原 Hash 值取异或运算(^),混合高 16 位和低 16 位的值,得到一个更加散列的低 16 位的 Hash 值)通过(n-1) & hash 进行与运算判断当前元素存放的位置,判断是否第一次调用 put,初始化数组长度 16。

    • 数组该位置为空,创建结点插入

    • 不为空,则向后添加元素。

      • jdk7 头插法,判断key是否已经存在,存在替换value
      • jdk8 尾插法,看是链表节点还是红⿊树节点,遍历存在相同则更新value。否则尾插或红黑树的put,链表的话插入完还要判断是否树化(链表长度>=8 并且 当前数组长度>=64才能树化,如果<64则 扩容)
    • 进行 ++ size 超过阈值进行扩容

HashMap 的 get流程

  • 通过hash⽅法计算keyhash值从⽽确定数组的下标
  • 顺序遍历数组使⽤equals⽅法查找key值相同的元素,如果槽位是链表或红⿊树的就继续往下遍历

hashmap 扩容流程?

开辟2倍,差异,扩展到线程安全

扩展:扩容时多线程操作可能会导致链表成环的出现,然后调⽤get⽅法会死循环,因为 HashMap 本身就是非线程安全的,如果要在多线程下,建议使用 ConcurrentHashMap 替代

触发时机:1、未初始化,第一次put时;2、大于扩容阈值。

流程

  • HashMap的扩容指的是数组的扩容,跟ArrayList⼀样如果需要扩容需要新开辟⼀个数组将⽼数组中的元素进⾏转移,当所有元素都转移到新数组之后,会将值赋值给table数组,⽼数组会被回收
  • 对于 HashMap 会新开辟⼀块2倍原容量的数组
  • 在 [JDK7] 会将⽼数组槽位中的每个元素重新⽤hash运算得到新数组的下标,然后放到新数组对应的槽位中去,如果出现hash碰撞就会采⽤链表进⾏存放,扩容的⽬的主要是为了解决链表过⻓的问题
  • 在 [JDK8] 通过高位运算(e.hash & oldCap)来确定元素是否需要移动,为 0 放低位,否则高位,低位位置不变,高位位置=原位置+原容量

ConcurrentHashMap在 JDK7 和 JDK8 的区别

  • 在 [JDK7] 采⽤的是分段锁的⽅式来保证线程安全的,ConcurrentHashMap维护了⼀个segement数组,每个 segement 都继承与ReentrantLock,Segment 默认为 16,也就是并发度为 16。并且segement内部有⼀个HashEntry数组也有扩容的阈值,相当于⼀个⼩HashMap,并且提供了get和put⽅法,当segement进⾏put的时候会使⽤ReentrantLock进⾏加锁,将 key和value存到segement之后会进⾏释放锁
  • 在 [JDK8] 采⽤是synchronized来进⾏保证线程安全的,相对于JDK7减⼩了锁的粒度,此时 ConcurrentHashMap维护了⼀个Node数组,到进⾏put的时候只要对Node节点加锁就⾏,Node节点可能 是红⿊树节点也可能是链表节点,从整体上来看,JDK8⽐JDK7更节省内存

Hashtable 对比 ConcurrentHashMap

  • Hashtable 与 ConcurrentHashMap 都是线程安全的 Map 集合
  • Hashtable 并发度低,整个 Hashtable 对应一把锁,同一时刻,只能有一个线程操作它
  • ConcurrentHashMap 并发度高,整个 ConcurrentHashMap 对应多把锁,只要线程访问的是不同锁,那么不会冲突

JDK8的ConcurrentHashMap如何统计元素个数

  • 对于ConcurrentHashMap在统计元素个数的时候,并不能在put的时候直接进⾏加锁去统计,这样会影响put的效率
  • 所以在JDK8中,CountCell是⽤来统计元素个数的,对于ConcurrentHashMap统计元素个数还需要⼀个变量baseCount,⾸先多个线程会先去通过CAS去进⾏baseCount的累加,当其中⼀个线程CAS失败之后,会将对于⼀个热点值的计算分散到CountCell的每个槽位中去,只要保证每个线程对应⼀个CountCell的槽位进⾏CAS累加,最终只要将对应槽位的值进⾏累加就能得到想要的结果
  • 所以 ConcurrentHashMap 最终元素个数 等于 baseCount+CountCell各槽位值的和

HashMap 为什么线程不安全?

JDK7 存在死循环和数据丢失问题。

数据丢失: 并发赋值被覆盖: 新添加的元素直接放在头部,使元素之后可以被更快访问,但如果两个线程同时执行到此处,会导致其中一个线程的赋值被覆盖。

死循环: 扩容时 resize 调用 transfer 使用头插法迁移元素,导致数据丢失或死循环。

JDK8 在 resize 方法中完成扩容,并改用尾插法,不会产生死循环,但并发下仍可能丢失数据。可用 ConcurrentHashMapCollections.synchronizedMap 包装成同步集合。

TreeMap

TreeMap是基于红黑树的一种提供顺序访问的Map,操作的时间复杂度都是O(logn);

默认按键的升序排序,具体顺序可有指定的comparator决定。

TreeMap键、值都不能为null;hashtable也不能为null

IO 流

同步/异步/阻塞/非阻塞 IO

同步和异步的区别 : 处理请求者是通过原调用将结果返回,还是通过其他方式将结果通知调用者(服务端)

同步: 同步⽅法⼀旦执⾏,调⽤者必须等待同步⽅法执⾏完才能继续后续的操作

异步: 异步⽅法更像⼀个消息传递,传递过去就能直接返回,调⽤者可以继续后续的操作

关注的是任务完成时消息通知的方式。由调用方盲目主动问询的方式是同步调用,由被调用方主动通知调用方任务已完成的方式是异步调用。

阻塞和⾮阻塞的区别 : 发出请求者是否会在等待过程中去做别的事情(客户端:发出请求的)

阻塞就是把当前线程挂起,直到结果返回才会恢复运⾏

⾮阻塞就是得到结果之前,不会阻塞当前线程

解决了客户端等待数据返回时的状态问题

关注的是接口调用(发出请求)后等待数据返回时的状态。被挂起无法执行其他操作的则是阻塞型的,可以被立即「抽离」去完成其他「任务」的则是非阻塞型的。

Java中IO的方式通常分为同步阻塞的BIO,同步非阻塞的NIO,异步非阻塞的AIO

什么是 BIO?

BIO 是同步阻塞式 IO

同步并阻塞(传统阻塞型),服务器实现模式为一个连接一个线程,即客户端有连接请求时服务器端就需要启动一个线程进行处理,如果这个连接不做任何事情会造成不必要的线程开销。

什么是 NIO?

同步非阻塞,服务器实现模式为一个线程处理多个请求(连接),即客户端发送的连接请求都会注册到多路复用器上,多路复用器轮询到连接有I/O请求就进行处理。

image-20210916114642170

java.io 包下有哪些流?

主要分为字符流和字节流,字符流一般用于文本文件,字节流一般用于图像或其他文件。

字符流包括了字符输入流 Reader 和字符输出流 Writer,字节流包括了字节输入流 InputStream 和字节输出流 OutputStream。字符流和字节流都有对应的缓冲流,字节流也可以包装为字符流,缓冲流带有一个 8KB 的缓冲数组,可以提高流的读写效率。除了缓冲流外还有过滤流 FilterReader、字符数组流 CharArrayReader、字节数组流 ByteArrayInputStream、文件流 FileInputStream 等。

序列化和反序列化是什么?

Java 对象 JVM 退出时会全部销毁,如果需要将对象及状态持久化,就要通过序列化实现,将内存中的对象保存在二进制流中,需要时再将二进制流反序列化为对象。对象序列化保存的是对象的状态,因此属于类属性的静态变量不会被序列化。

不需要进行序列化的敏感属性传输时应加上 transient 关键字。transient 的作用就是把变量生命周期仅限于内存而不会写到磁盘里持久化,变量会被设为对应数据类型的零值。

并发

JMM

JMM关于同步的规定:

1:线程解锁前,必须把共享变量的值重新刷新回主内存当中

2:线程加锁前,必须将主内存中最新值读到工作内存中

3:加锁解锁是同一把锁

线程操作数据只能在工作内存中进行,再刷回主内存,所以线程通信得依靠主内存。

谈⼀下 JMM 共享内存模型

  • JMM就是常说的共享内存模型,为了屏蔽各种硬件和系统差异来实现Java并发,并且告知我们Java线程是如何进⾏通信,主要是围绕着并发的三⼤特性展开的

对于多线程操作共享资源的流程

  • 线程会先将共享资源从主存中read出来,然后load到⾃⼰本地内存中赋值给变量副本,然后线程会use这个变量副本,当线程需要修改共享资源的时候,⾸先会assign新值给变量副本,然后将新值 store 到主存中 write 给共享资源
  • 从流程上可以知道此时共享资源的变化其他线程并不能⽴刻感知到,需要等到上下⽂切换或者某个时间点才能发⽣,所以说JMM共享内存模型并不能实时的保证可⻅性,所以需要基于写失效的缓存⼀致性协议 MESI来保证实时的可⻅性

MESI 协议

M代表已修改、E代表独占、S代表共享、I代表失效

  • 主存中维护了共享资源,线程A将共享资源read出来,然后load到⾃⼰本地内存中赋值给变量副 本,此时处于独占状态,线程A会use这个变量副本,当线程B也去和线程A⼀样操作的时候,此 时处于共享状态,当线程A想去修改共享资源的时候,会将新值assign给⾃⼰本地内存的变量副 本,然后处于已修改状态,然后store到主存中,最后write赋值给共享资源,此时线程B会感知 到共享资源被修改了,然后⾃⼰的变量副本失效,处于失效状态

锁机制

悲观锁与乐观锁

  • 悲观锁的代表是 synchronized 和 Lock 锁

    • 其核心思想是【线程只有占有了锁,才能去操作共享变量,每次只有一个线程占锁成功,获取锁失败的线程,都得停下来等待】
    • 线程从运行到阻塞、再从阻塞到唤醒,涉及线程上下文切换,如果频繁发生,影响性能
    • 实际上,线程在获取 synchronized 和 Lock 锁时,如果锁已被占用,都会做几次重试操作,减少阻塞的机会
  • 乐观锁的代表是 AtomicInteger,使用 cas 来保证原子性

    • 其核心思想是【无需加锁,每次只有一个线程能成功修改共享变量,其它失败的线程不需要停止,不断重试直至成功】
    • 由于线程一直运行,不需要阻塞,因此不涉及线程上下文切换
    • ⼀般CAS都会配合版本机制⼀起实现,主要是为了解决ABA问题
    • ⽐如JUC下的 AtomicStampedReference ⼀样,其中内部refrence就是变量,stamped就是版本号,每次修改成功进⾏stamped进⾏加1,但是还存在⼀个问题就是版本号可能会冲突,所以可以改成时间戳之类的

死锁、活锁、饥饿

  • 死锁就是两个或两个以上的线程在执⾏过程中因为争夺资源⽽相互等待,⽆法⾃⾏解开
  • 活锁就是任务或执⾏者没有被阻塞,但是因为某些条件不满⾜导致⼀直重复尝试,⼀直失败,相对于死锁 会不断的改变状态,可以⾃⾏解开
  • 饥饿就是⾼优先级的线程⼀直占⽤着时间⽚导致低优先级线程没有时间⽚执⾏,导致低优先级线程⼀直阻塞

synchronized

底层实现原理

synchronized是JVM的内置锁,属于悲观锁,在JDK6之前是基于Monitor机制实现的,依赖于底层互斥原 语Mutex,属于重量级锁,性能较低

在JDK6之后,迫于JUC包的压⼒,对synchronized进⾏了优化,新增了偏向锁、轻量级锁、重量级锁、⾃适应⾃旋等特性,性能基本与Lock持平

字节码实现

修饰同步代码块

  • 同步代码块是基于monitorenter和两个monitorexit实现的,为什么这⾥会需要两个monitorexit,主 要是因为synchronzied是⼀个内置锁,不需要⼿动显示的去释放锁,所以对于第⼀个monitorexit主要是处理正常返回的,⽽第⼆个是⽤来处理异常情况⾃动释放锁的

修饰方法

  • 同步⽅法是基于ACC_SYNCHRONIZED访问标志实现的

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

synchronized 使用范围修饰

实例方法:锁的是当前对象实例修饰

静态方法:锁当前类的Class对象

修饰代码块:锁是Sychonized括号里配置的对象

synchronized的可以保证原⼦性、有序性、可⻅性

原⼦性: 多个操作要么全部执⾏成功,要么全部执⾏失败 ,synchronized的原⼦性主要是通过加锁和释放锁保证的

有序性: 程序执⾏代码顺序是按照先后顺序执⾏的, synchronized的有序性是通过在load屏障之后加acquire屏障以及store屏障之后加release屏障来禁⽌写操作和读写操作之间重排序,保证代码块内部可以重排序,但是代码块内部和代码块外部的指令是不能重排序的

可⻅性: 对于共享变量的修改,其他线程也能感知到, synchronized的可⻅性主要是在加锁的时候会加load屏障执⾏refresh,将最新的值刷到⾃⼰的 本地内存,释放锁的时候会加store屏障执⾏flush,将修改后的值刷到⾃⼰的本地内存和主存

synchronized的可重⼊是怎么实现的

synchronized会维护⼀个计数器,当monitorenter的时候,计数器会加1,当monitorexit的时候,计数器会减1

偏向锁、轻量级锁和重量级锁的区别?

  • 偏向锁⽐较适合⽆线程竞争的场景,锁标识为101 对于偏向锁默认是匿名偏向状态,也就是在MarkWord中的线程ID为0,当4s延迟偏向之后会绑定偏向的线程ID,这个线程ID不是Java中的线程ID,⽽是操作系统中的线程地址
  • 轻量级锁适合轻微竞争也就是线程交替执⾏的场景,锁标识为010
  • 重量级锁适合激烈竞争的场景,锁标识为10

synchronized 锁升级的过程

无锁 -> 偏向锁 -> 轻量级锁(自旋) -> 重量级锁 ,jdk15

  • 无锁 到 偏向锁,然后自旋,JVM根据自旋的成功率,如果自旋的成功率高,那么接着自旋,如果自旋获取锁的成功率比较低,会消耗资源,进入重量级锁。

    • 第一个线程来的时候是由无锁升级到偏向锁
    • 假设有其他线程访问偏向锁申请获得锁,那么此时偏向锁升级到轻量级锁,这个轻量级锁的具体表现为获取锁失败的线程,并不会陷入阻塞状态,而是会自旋,以及相应的⾃适应⾃旋,不停循环去获取锁,偏向锁和轻量级锁操作的是对象头中的MarkWord
    • 长时间的自旋比较消耗CPU的资源,所以到达一定次数之后,就会到达重量级锁,如果锁处于重量级锁状态,获取锁失败的线程将会进入阻塞状态。
  • JDK 15 移除了偏向锁,原因在于引入偏向锁,主要是为了优化JDK 1.0的那两个集合 hashtable,Vector相关的代码,但是现在看来这两个集合很少有人用到,况且JVM撤销偏向锁状态比较消耗资源,所以JDK 15撤销了偏向锁。所以JDK 15的锁升级流程为 无锁到 轻量级锁 再到 重量级锁。HashTable、Vector,这两个集合就是我们上面说的,对应的锁在整个集合生命周期,有的时候只会被一个线程所获取。现在移除偏向锁是因为基本没人用这俩集合,再加上撤销偏向锁也需要高昂的成本,所以JDK 15决定移除此特性。

synchronized的优化还有 锁粗化、 锁消除

  • 锁粗化,⽐如锁对象出现在循环体中,反复加锁释放锁,即使没有出现线程竞争也会导致性能下 降,当JVM检测到这种操作,会将锁的范围扩⼤到循环体外
  • 锁消除,⽐如在单线程情况加锁,不存在竞争,JVM会清除锁 ⽐如 StringBuffer.append 操作

ReentrantLock 底层源码实现

  • 当多个线程去竞争共享变量state的时候,只有⼀个线程能获取到state,如果线程A获取到state,将 state从0变为1,表示加锁成功,并且设置为独占状态,⽽没有抢到锁的B线程会被安排进⼊到同步队列中,对于⾸次⼊队的线程B会先创建⼀个双向链表的同步队列,并且⾃旋⼊队,⽽跟B⼀起未抢到锁的线程都会依次⾃旋⼊队,并且所有⼊队成功的节点,waitStatus都会从0变为-1,表示这些线程需要被唤醒出队
  • 对于线程A释放锁的时候,会将state从1变为0以及独占状态置空,对于同步队列的头节点的 waitStatus会变为0,并且后继节点的waitStatus会变为-1,然后唤醒同步队列中的节点

可重⼊锁 ReentrantLock 和 synchronized 有什么区别?

来源,公平,释放锁,可中断,获取锁 is/try lock,超时等待

  1. ReentrantLock是JDK层⾯的锁,synchronized是JVM层⾯的内置锁
  2. ReentrantLock可以⽀持公平和⾮公平,默认⾮公平,因为可以提升并发量,synchonrized只⽀持⾮公平
  3. ReentrantLock在发⽣异常时必须在finally中显示释放锁,synchronized会⾃动释放锁
  4. ReentrantLock可以通过lockInterruptly进⾏中断,synchronized不可中断
  5. ReentrantLock可以通过isLocked判断是否获取到锁,synchronized不能直接判断
  6. ReentrantLock获取锁⽅式多,⽐如tryLock,可以等待指定时⻓更加灵活

lock vs synchronized

三个层面

不同点

  • 语法层面

    • synchronized 是关键字,源码在 jvm 中,用 c++ 语言实现
    • Lock 是接口,源码由 jdk 提供,用 java 语言实现
    • 使用 synchronized 时,退出同步代码块锁会自动释放,而使用 Lock 时,需要手动调用 unlock 方法释放锁
  • 功能层面

    • 二者均属于悲观锁、都具备基本的互斥、同步、锁重入功能
    • Lock 提供了许多 synchronized 不具备的功能,例如获取等待状态、公平锁、可打断、可超时、多条件变量
    • Lock 有适合不同场景的实现,如 ReentrantLock, ReentrantReadWriteLock
  • 性能层面

    • 在没有竞争时,synchronized 做了很多优化,如偏向锁、轻量级锁,性能不赖
    • 在竞争激烈时,Lock 的实现通常会提供更好的性能

公平锁

  • 公平锁的公平体现

    • 已经处在阻塞队列中的线程(不考虑超时)始终都是公平的,先进先出
    • 公平锁是指未处于阻塞队列中的线程来争抢锁,如果队列不为空,则老实到队尾等待
    • 非公平锁是指未处于阻塞队列中的线程来争抢锁,与队列头唤醒的线程去竞争,谁抢到算谁的
  • 公平锁会降低吞吐量,一般不用

条件变量

  • ReentrantLock 中的条件变量功能类似于普通 synchronized 的 wait,notify,用在当线程获得锁后,发现条件不满足时,临时等待的链表结构
  • 与 synchronized 的等待集合不同之处在于,ReentrantLock 中的条件变量可以有多个,可以实现更精细的等待、唤醒控制

volatile

volatile主要是围绕并发三个特性展开

原⼦性

  • volatile只能保证单⼀变量的原⼦性,但是不能保证复合操作的原⼦性,⽐如对象创建,因为分为对象分配内存、初始化变量、变量指向内存,再⽐如⾃增,分为getstatic、iadd、putstatic
  • 在32位机器下,long和double类型都占⽤64字节,需要分⾼低位将值读取到CPU,所以需要加volatile关键字才能保证原⼦性,⽽在64位机器下就不需要加volatile关键字就能保证

可⻅性

  • volatile修饰的变量只要被修改了都会被其他线程感知到
  • volatile的可⻅性主要是当线程去修改⾃⼰本地内存的变量副本的时候会缓存⾏锁定刷回主存

有序性

  • 用 volatile 修饰共享变量会在读、写共享变量时加入不同的屏障,阻止其他读写操作越过屏障,从而达到阻止重排序的效果
  • volatile的有序性在JVM层⾯是通过内存屏障禁⽌指令重排序保障的,但是本质是是通过C++的
  • volatile关键字保证,因为C++的volatile关键字具有禁⽌指令重排序和防⽌代码被优化的特性

谈⼀下 volatile 的伪共享问题

  • volatile的伪共享主要是发⽣在多个volatile修饰的共享变量在同⼀个缓存⾏中导致频繁缓存⾏失效影响别 的共享变量,⽽缓存⾏占⽤64字节
  • 对于这个问题的解决⽅式就是将volatile修饰的共享变量进⾏相互隔离,所以可以采⽤填充缓存⾏的⽅式,将每个volatile修饰的共享变量放在单独的⼀个缓存⾏中
  • Java提供了注解@Contended,只要配置了相关参数,就可以在共享变量上加注解⾃动填充缓存⾏

synchronized 与 volatile 区别

本质,范围,三大特性,重排优化,阻塞

  1. 本质原理不同:虽然两个都能保证可见性和有序性,但是本质不一样。volatile本质是lock汇编前缀,利用嗅探与缓存一致性协议保持可见性,用内存屏障达到禁止指令重排序,以保证有序性。synchronized是通过阻塞其他线程,只有当前线程可以访问。
  2. 使用范围不同:volatile 仅能使用在变量级别;synchronized则可以使用在方法、代码块的
  3. 功能不同:volatile仅能保证可见性和有序性,synchronized可以保证原子性
  4. 是否能被编译器优化:被volatile修饰禁止指令重排,不能被编译器优化,synchronized可以
  5. 是否造成线程阻塞情况:volatile不会造成线程的阻塞;synchronized可能会造成线程的阻塞(不会释放锁)。

CAS

缺点:aba(version),while,代码块原子性、多读

实现: (内存值,旧的预期值,要修改的值)每次比较内存中值与预期值是否相同,不同就自旋,相同就修改

缺点:

  • ABA(version)——当一个值从A被更新为B,然后又改回来,普通CAS机制发现不了。
  • 一直while浪费资源:若并发量高,许多线程反复尝试更新变量更新不成功,循环往复,给CPU带来高消耗
  • 不能保证代码块原子性:只能保证一个变量的原子操作,代码块要用 sychronized

场景: 读多写少。对于资源竞争严重的情况,CAS自旋的概率会比较大,从而浪费更多的CPU资源,效率低于synchronized

解决ABA问题:可以参考MySQL数据库中乐观锁的实现,表中维护了⼀个版本号version,每次需要更新数据的时候都需要带上版本跟查询到的版本号作匹配,只有匹配成功才能进⾏更新,对于乐观锁的实现在JUC包下有AtomicStampedReference,其中reference就是变量,stamped是版本,每次修改成功都会累加 1,但是还会存在版本号冲突的问题,所以版本号最好是推荐使⽤时间戳

乐观锁⼀般配合⾃旋⼀起使⽤,当⼤量线程⼀起⾃旋竞争修改共享资源的时候会出现CPU满载的情况,所以为了避免这个问题的出现,推荐模仿synchoronized的⾃适应⾃旋,⾃旋⼀定时间阻塞

Java死锁如何避免

造成死锁的原因

  • 互斥条件:一个资源每次只能被一个进程使用。
  • 请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放。
  • 不剥夺条件:进程已获得的资源,在末使用完之前,不能强行剥夺。
  • 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。

前三个条件是满⾜锁的条件,如果想要避免死锁只要避免线程间循环等待就⾏

在开发过程中,要注意加锁的顺序,保证线程按照相同顺序进⾏加锁,加锁要设定⼀个时限以及注意死锁检查,能在第⼀时间进⾏处理

线程

进程与线程

进程是系统进行资源分配的最小单位,每个进程都有自己的内存空间和系统资源

线程是操作系统任务调度和执⾏的最⼩单位,线程是进程的一部分

加分 - 协程:用户态,上下文切换效率,类似

协程是基于线程之上但是相对于线程更轻量级的存在,存在于⽤户态,通过 yield 关键字来实现,有效的减少上下⽂切换提⾼了性能,正如一个进程可以拥有多个线程一样,一个线程也可以拥有多个协程。适⽤⼤量并发的场景,⽐如go

协程有什么优缺点?

a、协程不被操作系统内核管理,是在用户态执行控制,性能得到了很大的提升,不会像线程切换那样消耗资源。

b、缺点:异步代码,可能不那么容易理解和调度

并发与并行

并发:通过CPU调度算法,让用户看上去同时执行,实际上,是通过CPU在高速切换,并不是真正的额同时

并行:多个CPU实例或者多台机器同时执行一段处理逻辑,这就是真正的同时;

什么是上下⽂切换

  • 上下文是指某一时间点 CPU 寄存器和程序计数器的内容。
  • 上下⽂切换主要是指CPU从⼀个线程切换到另外⼀个线程,上下⽂会保存上⼀个线程的操作状态,以便于下次继续执⾏时使⽤

什么是并发线程安全问题

  • 并发线程安全问题是当多个线程同时操作⼀个变量时,可能会导致结果不⼀致的问题
  • 所谓的线程安全是指在多线程并发操作的时候能保证共享变量变量数据⼀致

保证线程安全

JVM 内置锁 synchronized

JUC 提供的 Lock

run 和 start 的区别

run⽅法只是对象的⽅法,并不会去创建线程

start⽅法会从⽤户态切换到内核态进⾏创建线程,创建完之后会进⾏回调run,线程处于运行态

什么是守护线程

守护线程就是运⾏在后台的特殊进程,GC线程就属于守护线程

Java 实现同步方式

  • synchronized 关键字
  • wait、notify 等
  • Concurrent 包中的 ReentrantLock
  • volatile 关键字
  • ThreadLocal 局部变量

Ruunable与Callabale区别?

返回值,可捕获异常。

实现Callable接⼝,重写call⽅法,通过FutureTask创建线程获取线程执⾏的返回值

需要将实现Callable接口的类作为参数丢给FutureTask类,然后将FutureTask实例作为参数丢给Thread类,才开启多线程。

程通信的方式有哪些?

volatile 告知程序任何对变量的读需要从主内存中获取,写必须同步刷新回主内存,保证所有线程对变量访问的可见性。

synchronized 确保多个线程在同一时刻只能有一个处于方法或同步块中,保证线程对变量访问的原子性、可见性和有序性。

等待通知机制

  • 一个线程 A 调用了对象的 wait 方法进入等待状态,另一线程 B 调用了对象的 notify/notifyAll 方法,线程 A 收到通知后结束阻塞并执行后序操作。对象上的 waitnotify/notifyAll 如同开关信号,完成等待方和通知方的交互。
  • 如果一个线程执行了某个线程的 join 方法,这个线程就会阻塞等待执行了 join 方法的线程终止,这里涉及等待/通知机制。join 底层通过 wait 实现,线程终止时会调用自身的 notifyAll 方法,通知所有等待在该线程对象上的线程。

线程常用方法(线程同步线程调度相关)

Object: wait, notify

  • wait():Object 类的方法,使线程阻塞等待,释放锁,不需要捕获异常,用于线程通信,等待必须 notify 唤醒
  • wait,notify 和 notifyAll 只能在同步控制方法或者同步控制块里面使用
  • Object.notify()、notifyAll():唤醒一个等待线程,唤醒所有等待线程去竞争锁

Thread: sleep, join, yield 3个

sleep()

  • 可以让线程从运⾏状态进⼊睡眠状态不释放锁,需要捕获异常,可以指定睡眠时间,睡眠⼀段时间之后会重新进⼊运⾏状态
  • 可以通过 interrupt ⽅法打断正在睡眠的线程,sleep 抛出中断异常会清除中断标志
  • 如果睡眠时间是 0,效果和yield⼀样

yield()

  • 上下⽂切换,会释放CPU资源,让当前线程回到可执行状态,礼让,让同优先级或则更高优先级的线程获得执行机会

join()

  • 等待调用 join 方法的线程结束,再继续执行
  • join线程底层会⼀直检查join线程是否存活,如果存活就会继续执⾏,会让其他线程⼀直等待

wait vs sleep

一个共同点,三个不同点

共同点

  • wait() ,wait(long) 和 sleep(long) 的效果都是让当前线程暂时放弃 CPU 的使用权,进入阻塞状态

不同点

  • 方法归属不同

    • sleep(long) 是 Thread 的静态方法
    • 而 wait(),wait(long) 都是 Object 的成员方法,每个对象都有
  • 醒来时机不同

    • 执行 sleep(long) 和 wait(long) 的线程都会在等待相应毫秒后醒来
    • wait(long) 和 wait() 还可以被 notify 唤醒,wait() 如果不唤醒就一直等下去
    • 它们都可以被打断唤醒
  • 锁特性不同(重点)

    • wait 方法的调用必须先获取 wait 对象的锁,而 sleep 则无此限制
    • wait 方法执行后会释放对象锁,允许其它线程获得该对象锁(我放弃 cpu,但你们还可以用)
    • 而 sleep 如果在 synchronized 代码块中执行,并不会释放对象锁(我放弃 cpu,你们也用不了)

什么是线程中断

线程中断也就是线程在执⾏的过程中被打断了,与stop⽅法最⼤的区别在于stop会强制终⽌还未执⾏完的线程,⽽线程中断只会向需要⽬标线程发送中断信号,如果⽬标线程没有接收到线程中断的信号不会停⽌,如果接收到了,后续是否退出和终⽌可以⾃定义

线程中断提供了三个⽅法

interrupt : 给线程加上中断标志,不会停⽌线程

isInterrupted : 判断当前线程是否有中断标志,不会清除中断标志

interrupted : 判断当前线程是否有中断标志,会清除中断标志

对于如何优雅的中断线程可以使⽤isInterrupted和⼀个共享变量的组合通过while循环来判断,要注意的是 在执⾏逻辑中如果有⽤sleep,要记得⾃⼰再interrupt⼀下,因为sleep会清除中断标志

谈一谈 ThreadLocal

ThreadLoacl 是线程共享变量,主要用于一个线程内跨类、方法传递数据。为每个线程提供一个独立的变量副本解决了变量并发访问的冲突问题

ThreadLoacl 有一个静态内部类 ThreadLocalMap,Map的键是ThreadLocal,值对应的是线程的变量副本,对于键是⼀个弱引⽤可以被回收,但是值是⼀个强引⽤。ThreadLocal 主要有 set、get 和 remove 三个方法。

存在的问题 内存泄漏

  • 由于 ThreadLocal 是弱引用,但 value 是强引用,因此当 ThreadLocal 被垃圾回收后,value 依旧不会被释放。ThreadLocal的value可能会⼀直占⽤⽼年代的空间导致内存泄露,最终可能导致OOM.

  • ThreadLocalMap 中的 key 被设计为弱引用,原因如下

    • Thread 可能需要长时间运行(如线程池中的线程),如果 key 不再使用,需要在内存不足(GC)时释放其占用的内存
  • 会出现 key 为 null 的 value

  • 因此需要及时调用 remove 方法进行清理操作。

应⽤场景

  • 线程安全:数据库连接管理,⼀个线程持有⼀个连接,连接对象可以在不同的⽅法之间进⾏传递,线程之间不共享同⼀个连接
  • 全局存储用户信息 session 管理,类似于全局变量

加分 InheritableThreadLocal

  • 对于ThreadLocal只能保证线程内部的通信,但是不能保证⽗⼦线程间的通信,如果想要保证⽗⼦线程间的通信可以使⽤InheritableThreadLocal,通过对它重写,让本地变量保存到InheritableThreadLocals中通过构造函数传递给⼦线程的InheritableThreadLocals完成通信
  • 对于InheritableThreadLocal可以保证⽗⼦线程间的通信,但是在线程池中就⽆法保证线程间的通信了,这个时候可以使⽤开源的TransmitThreadLoca来解决,先创建线程池,通过ttl进⾏修饰,每次调⽤的时候ttl会抓取⽗线程的上下⽂传递给⼦线程中,不管当前线程是否是新建

JUC

AQS?

我所理解的AQS,核心关键就是 一个表示同步状态的volatile int state变量+cas机制设置state状态+FIFO等待队列(双向链表)。

具体来说:AQS的锁类型分为 独占锁,共享锁

以 Reentantlocak 独占锁 可重入 为例,非公平锁的加锁流程:

  1. 尝试着使用CAS操作将锁的状态state由0修改为1,修改成功则线程获得锁。
  2. 不成功就会再次尝试去抢锁,以及判断这个线程是否是当前持有锁的线程(如果是只需要将state+1,代表锁重入)。
  3. 抢锁没成功,也不是持有锁的线程,那么就会添加到等待队列然后调用LockSupport.park()方法进行阻塞等待,然后被唤醒。

线程池

线程的创建和销毁都会进⾏⽤户态和内核态的切换,当进⾏new Thread的时候会调⽤start⽅法进⼊内核 态创建线程,创建完之后会进⾏回调run⽅法 线程池⽐较适合单任务执⾏短,任务数量多的场景使⽤

线程池的好处

  • ⽅便管理线程
  • 减少线程创建的性能开销,可以复⽤线程
  • 提⾼响应速度,任务到达时可以⽴即去执⾏

工作流程

核心线程,等待队列,非核心线程,拒绝策略

  • AbortPoliy,默认抛异常
  • DiscardPolicy,丢弃新任务
  • CallerunsPolicy,主线程去执⾏任务
  • DiscardOldestPolicy,丢弃⽼任务

核心参数

  • 核⼼线程数
  • 最⼤线程数
  • 最⼤空闲时间
  • 最⼤空闲时间单位
  • 阻塞队列
  • 线程⼯⼚
  • 拒绝策略

类型

SingleThreadPool: 核⼼线程数和⾮核⼼线程数都是1,⾮核⼼线程数存活时间为0,阻塞队列是 LinkedBlockingQueue ⽐较合适单线程串⾏的场景

FixedThreadPool: 核⼼线程数和⾮核⼼线程数都是n,⾮核⼼线程存活时间为0,阻塞队列是 LinkedBlockingQueue ⽐较适合CPU密集型任务

CachedThreadPool: 核⼼线程数是1,⾮核⼼线程数是n,⾮核⼼线程存活时间为60s,阻塞队列是 SynchronousQueue ⽐较适合传递性任务

ScheduledThreadPool: ⽤来定期执⾏任务,但是实际场景不会⽤到,⽽是选择XXL-JOB这些成熟的框架

线程池调优(基础)

  • IO密集型可以考虑多些线程来平衡CPU的使⽤,CPU密集型可以考虑少些线程减少线程调度的消耗
  • 如果考虑程序计算为主,通常理解为⼏核就是⼏,可以保持CPU效率更⾼,可以将最⼤线程数设置为N,核⼼线程数也是 N
  • 如果考虑磁盘IO为主,线程数等于IO任务数最佳,可以将最⼤线程数设置为2N+1,核⼼线程数也是N
  • 对于阻塞队列的选择,要求任务执⾏的数量不⼤的情况可以采⽤⽆界队列,如果是任务量⽐较⼤的要选有界队列,防⽌OOM,根据不同的场景选择合适的阻塞队列
  • 对于不同的场景选择合适的拒绝策略,如果需要可以⾃定义拒绝策略

原⼦类

常⻅的原⼦类

  • ⼀般我们想到的是⽤加锁⽐如synchronized来解决线程安全问题,但是并不是很⾼效,所以JUC包下提供许多类型的原⼦类,最常⻅的有原⼦类AtomicInteger、AtomicLong、LongAdder、LongAccumulator、AtomicReference
  • 对于原⼦⾃增场景⽐如AtomicInteger,内部维护共享资源state,每次都会通过CAS修改state来完成⾃增操作
  • 但是CAS⼀般都是配合⾃旋的⽅式来完成的,所以当遇到⼤量线程时候会导致CPU满载,所以应对这个问题,可以想到分散热点的⽅式来解决热点冲突严重的问题,所以JUC包下提供了LongAdder,它的思想就是将共享资源分散到Cells数组中,每个线程只要对⾃⼰对应的槽位进⾏CAS修改,最终只要将所有槽位的值累加就能得到最终的值,但是它提供的sum⽅法并不能获取到精确的值,因为没有对Cells数组加锁所以累加的过程存在线程安全问题,所以如果要sum⽅法得到实时的值需要加全局锁,并且JUC包下还提供了另外⼀个原⼦类LongAccumulator,实现本质⼀样,只是可以⾃定义计算过程

共享锁 Semaphore

Semaphore是基于PV操作在Java中的具体实现,也是基于AQS实现的,可以⽤来进⾏限流,Semaphore⽀ 持公平和⾮公平,属于共享锁,与ReentrantLock最⼤区别在于,Semaphore在可⽤资源state充⾜的情况 下,只要同步队列中的节点属于共享模式的话,可以持续唤醒出队,以及Semaphore不⽀持可重⼊

Semaphore底层源码实现

  • 当多个线程去获取可⽤资源state进⾏执⾏的时候,state也会随之减少,如果线程A获取到可⽤资 源,此时刚好state⼩于0,那么线程B因为没有获取到可⽤资源⽽被安排进⼊到同步队列中,对于⾸ 次⼊队的线程B会先创建⼀个双向链表的同步队列,并且⾃旋⼊队,⽽跟线程B⼀起没有抢到可⽤资 源的线程都会依次⾃旋⼊队,并且所有⼊队成功的节点都会设置为共享模式,并且waitStatus都会从 0变为-1,表示这些线程需要被唤醒出队
  • 当可⽤资源state⼤于0的时候,只要可⽤资源充⾜以及同步队列中的节点是共享模式就会进⾏持续唤 醒出队

闭锁 CountDownLatch

CountDownLatch叫闭锁,属于共享锁,主要的场景

  • 让单个线程等待,⽐如可以实现让多个线程同时计算,最终合并结果返回,这⾥可以⽤于导出数据的时候
  • 让多个线程等待,⽐如模拟并发

对于让线程等待,join⽅法也可以,⽽join线程功能并不像CountDownLatch功能那么多,但是join底层会 ⼀直检查join线程是否存活,如果存活就会让其他线程等待

CountDownLatch的底层源码实现

CoutDownLatch的state充当⼀个计数器,当调⽤await⽅法的时候会判断state是否为0,如果不为0就会让当前线程处于阻塞等待,当调⽤countDown的时候,state会进⾏release减1,当state为0的时 候,会唤醒所有被await阻塞的线程

回环栅栏 CyclicBarrier

CyclicBarrier叫回环栅栏,属于独占锁,主要的场景⽐较适合多线程同时计算,最终合并结果,与 CountDownLatch的区别有

  • CyclicBarrier的计数器可以进⾏重置,所以可以应对计算异常的时候进⾏重新计算,
  • CountDownLatch的计数器只能⽤⼀次
  • CyclicBarrier是基于ReentrantLock和Condition条件队列实现的,CountDownLatch是基于AQS实现的 共享锁
  • CyclicBarrier会阻塞⼦线程,CountDownLatch会阻塞主线程

CyclicBarrier的底层源码实现

  • CyclicBarrier内部有⼀个计数器count以及计数器副本parties,线程⾸先会通过ReentrantLock加锁, 当count⼤于0的时候,线程会被安排进⼊到Condition条件队列中阻塞,对于⾸次⼊队的线程会创建 ⼀个单向链表的条件队列,并且⾃旋⼊队,然后释放锁,对于所有⼊队成功的节点会将waitStatus设 置为-2,表示这些线程处于条件队列中
  • 当count等于0时,也就是最后⼀个线程到达屏障的时候,线程⾸先会通过ReentrantLock加锁,然后 唤醒条件队列中的线程进⾏出队,并且⽤计数器副本parties重置计数器count,然后会把条件队列中 的节点转移到双向链表的同步队列中,然后将⼊队成功的节点的waitStatus从0变为-1,然后继续唤 醒其他条件队列中的节点,当所有线程进⼊到同步队列的时候,会释放锁,然后去唤醒同步队列中 的线程出队

读写锁 ReentrantReadWriteLock

  • 读写锁⽐较适合于读多写少的并发场景,它的实现主要是将int类型变量的32位拆分为⾼低16位,⾼16于表示读锁,低16位⽤于表示写锁,对于读锁属于共享锁,以及写锁属于独占锁,所以写锁的重⼊次数只要通过移位操作就能计算,但是读锁却不能直接这么做,所以采⽤的是第⼀个线程⽤firstReader变量记录,⽽重⼊次数使⽤firstReaderCount记录,⽽其他线程通过ThreadLocal的线程变量副本HoldCounter来存,其中tid表示线程,count表示重⼊次数
  • 对于读写锁的使⽤,要注意的是写锁能降级为读锁,但是读锁不能升级为写锁,⽽⼀般的⽤法是先加写锁,然后加读锁,释放写锁,再释放读锁,在写锁中加读锁主要是为了避免脏读的情况
  • 对于读写锁属于悲观读,因为读的时候不能写,所以JDK8的时候引⼊了StampedLock来保证乐观读,采CAS的⽅式来避免加锁阻塞线程,但是操作起来⽐较复杂,⼀般不推荐使⽤

🎉 如何保证三个线程同时执⾏、并发情况下三个线程依次执⾏、三个线程有序交错执⾏、两个线程交替打印奇偶数保证三个线程同时执⾏

保证三个线程同时执⾏

  1. 可以使⽤CountDownLatch让三个线程等待然后⼀起执⾏

    • 创建⼀个CountDownLatch
    • 创建线程A,a.await
    • 创建线程B,b.await
    • 创建线程C,c.await
    • countDown
  2. 可以使⽤CyclicBarrier让三个线程都到达屏障时再⼀起执⾏

并发情况下三个线程依次执⾏

  1. 可以使⽤volatile修饰共享变量对三个线程可⻅,通过计数的⽅式实现依次执⾏

  2. 可以使⽤CountDownLatch让单个线程等待,类似倒计时的⽅式

    • 创建三个CountDownLatch分别是aLatch、bLatch、cLatch
    • 创建线程A,aLatch.await、bLatch.countDown
    • 创建线程B,bLatch.await、cLatch.countDown
    • 创建线程C,cLatch.await
    • aLatch.countDown
  3. 可以使⽤让三个线程顺序join

    • 创建线程A
    • 创建线程B,A.join
    • 创建线程C,B.join

三个线程有序交错执⾏

  1. 可以使⽤ReentrantLock和 3 个Condition来实现

    • 创建独占锁ReentrantLock、3 个Condition分别是conditionA、conditionB、conditionC
    • 创建线程A后加锁,conditionA.await、conditionC.await,释放锁
    • 创建线程B后加锁,conditionA.singnal、conditionB.await、conditionC.signal,释放锁
    • 创建线程C后加锁,conditionB.singnal,释放锁

两个线程交替打印奇偶数

互斥锁保证 synchronized/ReentrantLock

  • 创建两个线程,⼀个线程负责打印奇数,另⼀个线程打印偶数
  • 两个线程竞争同⼀个对象锁,每次打印⼀个数字后释放锁,然后另⼀个线程拿到锁打印下⼀个数字

JVM

JVM 的重要组成部分和作⽤

  • JVM的组成部分主要分为两⼤类,两个⼦系统两个组件

  • 两个⼦系统是类加载⼦系统字节码执⾏引擎

    • 类加载⼦系统主要是负责将字节码⽂件加载到JVM内存中去
    • 字节码执⾏引擎主要是负责GC回收、执⾏字节码⽂件中的指令以及修改程序计数器的值
  • 两个组件是本地接⼝运⾏时数据区

    • 本地接⼝主要是跟本地库打交道的,都知道JVM是C++写的,所以需要⼀些本地库的⽀持

    • 运⾏数据区就是我们常说的JVM内存,它主要分为两⼤块,⼀个是线程共享的,⼀个是线程私有的

      • 线程共享的有堆和⽅法区

        • ⼀般new出来的对象会存放在堆中
        • ⽅法区在JDK7的时候处于堆中,⽽在JDK8被移除堆放到了直接内存中并且,因为直接内存中对于IO操作具有更⾼的性能,因为是可以直接操作内存,不像JDK7时多了很多中间的步骤,从虚拟内存到直接内存的重复开销
        • ⽅法区主要是存放类型信息、常量、静态变量以及即时编译器编译的代码
      • 线程私有的有 本地⽅法栈 、线程栈 以及 程序计数器

        • 本地⽅法栈 跟线程栈很类似主要是针对于本地⽅法开辟的⼀块内存空间,所以我们讲述下线程栈就能说明⽩本地⽅法栈了

        • 线程栈 主要是⽤来存放栈帧的,栈帧主要分为这⼏个部分,局部变量表、操作数栈、⽅法出⼝、动态链接

          • 局部变量表 类似于⼀个table,可以存放编译期间的⼀些变量和对象的内存地址,对于局部变量表有⼀个⾸位参数就是this,在字节码层⾯也可以看到这个this,在编译期间,this会被作为⼀个隐式参数放在⽅法的第⼀个位置,这也是为什么构造函数中能直接使⽤this的原因
          • 操作数栈 主要是⽤来计算⼀些操作数,可以跟寄存器⼀起配合运算使⽤
          • ⽅法出⼝ 主要是当⽅法执⾏完之后需要找到被调⽤的位置,可以通过它进⾏定位
          • 动态链接 是在程序运⾏期间将符号引⽤转变为直接引⽤
        • 程序计数器 可以理解为代码的标识,当代码执⾏到哪个位置的时候,字节码执⾏引起回去修改程序计数器中的值

  • 整体上可以看到

    • ⾸先类加载⼦系统会将字节码⽂件加载到JVM内存中去,⽽字节码并不能直接被操作系统识别
    • 所以需要字节码执⾏引擎去解析成操作系统能识别的指令然后交给CPU去执⾏
    • ⽽这个交互过程中需要本地库的⽀持

内存区域划分

运行时数据区是什么?

线程私有:程序计数器、Java 虚拟机栈、本地方法栈。

线程共享:Java 堆、方法区。

Java 虚拟机栈来描述 Java 方法的内存模型。每当有新线程创建时就会分配一个栈空间,线程结束后栈空间被回收,栈与线程拥有相同的生命周期。栈中元素用于支持虚拟机进行方法调用,每个方法在执行时都会创建一个栈帧存储方法的局部变量表、操作栈、动态链接和方法出口等信息。每个方法从调用到执行完成,就是栈帧从入栈到出栈的过程。

有两类异常:

  • 线程请求的栈深度大于虚拟机允许的深度抛出 StackOverflowError。
  • 如果 JVM 栈容量可以动态扩展,栈扩展无法申请足够内存抛出 OutOfMemoryError

方法区的作用是什么?

方法区用于存储被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。

JDK8 之前使用永久代实现方法区,容易内存溢出,因为永久代有 -XX:MaxPermSize 上限,即使不设置也有默认大小。JDK7 把放在永久代的字符串常量池、静态变量等移出

JDK8 中永久代完全废弃,改用在本地内存中实现的元空间代替,把 JDK 7 中永久代剩余内容(主要是类型信息)全部移到元空间。

虚拟机规范对方法区的约束宽松,除和堆一样不需要连续内存和可选择固定大小/可扩展外,还可以不实现垃圾回收。垃圾回收在方法区出现较少,主要目标针对常量池和类型卸载。如果方法区无法满足新的内存分配需求,将抛出 OutOfMemoryError。

方法区,永久代和元空间的概念

  • 方法区是 Java 虚拟机规范中的一个概念,就像是一个接口吧;
  • 永久代是 HotSpot 虚拟机中对方法的一个实现,就像是接口的实现类;
  • Java 8 的时候,移除了永久代,取而代之的是元空间,是方法区的另外一个实现。

永久代是放在运行时数据区中的,所以它的大小受到 Java 虚拟机本身大小的限制,所以 Java 8 之前,会经常遇到 java.lang.OutOfMemoryError: PremGen Space 的异常,PremGen Space 就是方法区的意思;

元空间是直接放在内存中的,所以只受本机可用内存的限制,虽然也会发生内存溢出,但出现的几率相对之前就小了很多。

字符串常量池在内存中的什么位置呢?

Java 8 之前,字符串常量池在永久代中。

Java 8 之后,移除了永久代,字符串常量池就移到了堆中。

imgimg

常量池的分类

Class常量池

存放编译期间的符号引⽤以及字⾯量

运⾏时常量池

JVM运⾏期间,会将字常量池的静态数据加载到⽅法区符号引用转变为直接引用

字符串常量池

类似于缓存池,会缓存字符串,每次创建字符串之前都会去这⾥⾯查⼀下,有就直接返回,没有就* 创建*

运行时常量池

运行时常量池是方法区的一部分,Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表,用于存放编译器生成的各种字面量符号引用,这部分内容在类加载后存放到运行时常量池。一般除了保存 Class 文件中描述的符号引用外,还会把符号引用翻译的直接引用也存储在运行时常量池。

image-20220814220858439

内存溢出

内存溢出和内存泄漏的区别?

  • 内存溢出 OOM OutOfMemory指程序在申请内存时,没有足够的内存空间供其使用。
  • 内存泄露 ML Memory Leak对象⽆法得到及时回收,⼑⼦嘴持续占⽤内存空间,造成内存空间的浪费,内存泄露⼀般是强引⽤才会出现问题

引⽤类型有哪⼏种

  • : ⼀般new出来的对象就是强引⽤,不会被GC
  • : 通过SoftReference包装的类,在OOM(内存溢出)之前会被回收,可以⽤作⾼速缓存使⽤
  • : 通过WeakRefrence包装的,每次GC都会回收
  • : 幽灵引⽤⼀般不使⽤

JVM 故障分析

对于还在正常运⾏的系统

  • 可以使⽤jmap去查看JVM中各个区域的使⽤情况

    • 假如有内存激增的情况,可以定位到哪个地⽅对象创建⽐较多
  • 可以使⽤jstack去查看线程的运⾏情况

    • ⽐如有哪些线程阻塞、是否产⽣了死锁
  • 可以使⽤jstat查看垃圾回收的情况

    • 假如FullGC⽐较频繁,就得进⾏GC调优了
  • 对于各个命令的结果,可以直接通过jvisualvm⼯具查看分析

  • 观察FullGC的频率,如果FullGC频繁但是没有出现OOM,那么表示FullGC实际上回收了很多对象,所以这些对象最好是能在MinorGC的时候进⾏回收,避免直接进⼊⽼年代,对于这种情况,要考虑存活时间不⻓的对象是不是⼤对象,对于⼤对象尽早的让它们进⼊⽼年代,避免由于复制算法带来的性能问题,尝试加⼤年轻代的⼤⼩,让朝⽣夕死的对象在MinorGC的时候被回收,如果FullGC减少,说明调优有效
  • 同时观察占⽤CPU最多的线程,定位具体的⽅法,优化这个⽅法的执⾏,看是否能避免某些对象的过多创建,从⽽节省内存

对于已经发⽣ OOM 的系统

  • ⼀般⽣产系统中都会配置发⽣OOM的时候⽣成dump⽂件
  • 可以通过jvisualvm进⾏分析,找出dump⽂件异常的地⽅⽐如异常的线程、对象,定位到具体的代码位置,然后进⾏详细分析

创建对象

对象创建过程

对象的创建的过程主要分为这⼏步

  • 类加载检查

    • 当JVM遇到⼀个new指令的时候,会先去检查这个指令的参数能不能在常量池定位到这个类的符号引⽤
    • 如果能找到会去检查这个类有没有进⾏过类加载过程,没有就会先去进⾏类加载
  • 分配内存

    • 经过类加载检查之后,对象需要的内存会被确定下来,对应的会从堆上划分出⼀块相同⼤⼩的内存给对象使⽤
    • 对于分配内存的⽅法主要有两种,⼀种是指针碰撞,⼀种是空闲列表
  • 初始化零值

    • 当对象的内存分配完成之后,JVM 会给分配到的内存空间赋零值
  • 设置对象头

    • JVM 对 [对象头] 设置相应的值,对象的布局如下:

      • 对象头

        • 对象头的布局主要有 Mark Word、Klass Pointer、数组⻓度

          • Mark Word 存放的是⾃身运⾏时数据,⽐如 hash、GC分代年龄、锁标志、线程ID,32位占⽤4字节,64位占8字节
          • Klass Pointer是指向类元数据信息的指针,在32位占⽤4字节,在64位占⽤8字节,如果开启了指针压缩占⽤4字节
          • 数组⻓度是只有数组对象才会有,占⽤4字节
      • 实例数据

        • 实例数据存放的类的属性信息
      • 对⻬填充

        • 保证对象占⽤空间是 8 的整数倍
        • 因为经过内存对⻬填充后,CPU 的内存访问效率会⼤⼤提升以及规避平台之间的差异
  • 执⾏<init>⽅法

    • 设置完对象头之后,会执⾏⽅法按照预期的值对内存空间进⾏初始化,然后执⾏构造⽅法

什么是栈上分配

  • 我们⼀般new出来的对象都是放在堆上的,当对象没有被引⽤的时候就会被GC进⾏回收,当对象⽐较多的时候会给GC带来压⼒
  • 为了减少临时对象直接分配到堆内存,JVM可以通过逃逸分析将不被外部引⽤的对象,不进⾏创建对象,使⽤标量替换的⽅式直接分配到栈上,这样可以随着栈帧的出栈⽽销毁,减轻GC的压⼒

垃圾回收

如何判断对象是否是垃圾?

引用计数 + 可达性分析

可作为 GC Roots 的对象包括:

  • 虚拟机栈和本地方法栈中引用的对象
  • 静态变量引用的对象:常⻅的⽐如Spring中的bean对象
  • 常量引用的对象

有哪些 GC 算法?

标记-清除:内存碎片 - CMS

标记-复制(先搬移后清空):浪费内存空间

标记-整理

什么是STW

STW就是stop the world,会暂停所有⽤户线程然后去进⾏垃圾回收,如果是MinorGC,停顿的时间⼏乎察觉不到,如果是FullGC,可能在浏览⽹⻚的时候卡顿⼀下的感觉

为什么要进⾏STW

STW是⽤来帮助GC来进⾏垃圾回收的,假如不进⾏STW,可能出现不该回收的对象被回收了

  • 因为⽤户线程和GC线程都在运⾏着的,可能⼀个对象主观上是⼀个⾮垃圾对象,但是还未被及时标记就被GC回收
  • 再⽐如使⽤复制算法的过程中,可能复制到Survivor区的对象存在垃圾对象和⾮垃圾对象

你知道哪些垃圾收集器?

  • Serial、SerialOld 是单线程垃圾收集器

    • Serial⽤在年轻代采⽤的是复制算法、Serial Old⽤在⽼年代采⽤的是标记整理算法
    • 在单核处理器的情况下,简单⾼效,⽐较适合100MB内存以内的垃圾回收,多核处理器下⽆法发挥多核的性能不推荐使⽤
  • Parallel、Parallel Old 是多线程垃圾收集器

    • 相当于Serial多线程版本,Parallel⽤在年轻代采⽤的是复制算法,Parallel Old采⽤的是标记整理算法
    • 关注点在于吞吐量,⽐较合适⾼效率使⽤CPU的场景,⼀般4G以下内存推荐使⽤
  • ParNew & CMS

    • ParNew 其实跟 Parallel 类似,只是为了配合 CMS 才出现的,ParNew ⽤在年轻代采⽤的是复制算法,CMS⽤在⽼年代采⽤的是标记清除算法

    • CMS 关注点是最⼤停顿时间,也就是⽤户的体验度,⽐较适合4G到8G内存的情况使⽤

    • CMS的运作步骤

      • 初始标记

        • 因为程序运⾏期间可能会标记不完所以需要进⾏STW,此时会从gcroot出发,只标记直接引⽤对象
        • 这⾥不会包含内部成员变量相关的间接引⽤对象
      • 并发标记

        • gcroot的直接引⽤对象出发遍历整个对象图进⾏标记,耗时⽐较⻓,但是由于⽤户线程和GC线程还在⼀起运⾏着,所以会有对象的状态发⽣改变的情况,⽐如漏标、多标

        • 漏标

          • 漏标就是在并发标记过程中,由于⽤户线程和GC线程还在运⾏着,⼀个新来的对象被⼀个强引⽤对象引⽤着,也就是被⿊⾊对象引⽤着,但是没有被标记为⾮垃圾对象,会导致被GC回收

          • 漏标的处理⽅案主要是三⾊标记算法,主要分为增量更新和原始快照,⾸先先谈下三⾊标记

            • 三⾊标记主要是分为三种颜⾊,分别是⿊⾊、⽩⾊、灰⾊

              • ⿊⾊对象:表示当前对象的所有关联的对象都扫描完了
              • ⽩⾊对象:表示当前对象还没有被扫描
              • 灰⾊对象:表示当前对象没全部扫描完
          • 对于增量更新是通过记录下⿊⾊对象新增的⽩⾊对象引⽤关系,将⿊⾊对象回退到灰⾊对象,重新深度扫描⼀次

          • 对于原始快照是通过记录下灰⾊对象删除的⽩⾊对象的引⽤关系,以灰⾊对象为根简单扫描⼀下,将⽩⾊对象标记为⿊⾊对象,当作浮动垃圾处理,等待下⼀轮GC

            • 什么是浮动垃圾

              • 浮动垃圾就是在并发标记和并发清理阶段产⽣的垃圾,对GC最终效果影响不⼤,只要等待下⼀轮GC处理就⾏
        • 多标

          • 多标就是原来是⾮垃圾对象,但是⽤户线程还在运⾏着,本应该是垃圾对象了,但是没有被清除⾮垃圾的标记
      • 重新标记

        • 此时会对并发标记过程中产⽣状态改变的对象进⾏修正所以会进⾏STW
        • 这⾥对于漏标的问题采⽤的是三⾊标记算法中的增量更新来做的重新标记
      • 并发清理

        • 对未标记的对象进⾏清理,这⾥因为没有进⾏STW,所以对于新增对象会被标记会⿊⾊对象
      • 并发重置

        • 将对象的标记位进⾏重置,进⾏下⼀轮GC

    img

  • G1

    • G1跟以往的垃圾收集器有点不同,它对于分代的概念不是物理分代⽽是逻辑分代了,它将堆默认分成了2048个region,每个region在每次GC结束后都会有不同的⻆⾊,并且相对于以往的⼤对象,它也有专⻔的⼀个Humogous区来存放,倘若⼀个Humogous区放不下⼤对象会⽤连续的⼏个region来存放,对于G1从region来看采⽤的是复制算法,但是从整体上来看是标记整理算法,G1和CMS的出发点⼀样,但是G1⽐CMS更加先进,可控的最⼤停顿时间,⼀般建议需要500ms以内停顿或者内存超过8G的可以去使⽤

    • G1的运作步骤

      • 初始标记和CMS的初始标记⼀样

      • 并发标记和CMS的并发标记⼀样

      • 最终标记和CMS的重新标记⼀样,但是这⾥对于漏标的对象采⽤原始快照的⽅式进⾏处理

      • 筛选回收

        • STW对未标记的region进⾏清理,此时会将每个region区域回收价值和成本进⾏排序,根据⽤户的预期停顿时间进⾏⽐较来选择合适的回收⽅式,此时并不会把所有垃圾对象进⾏回收,因为考虑到预期停顿时间,所以只会回收接近于这个时间的region,剩余的region等待下⼀轮GC进⾏回收

对象的分配策略?

  • 对象优先在 Eden 区分配

    • 当Eden区没有⾜够内存的时候会进⾏MinorGC,将存活的对象移动到S0区并且分代年龄进⾏加1,当下次进⾏MinorGC的时候会将Eden区存活的对象和S0区存活的对象移动到S1并且分代年龄都进⾏加1,然后置空S0
    • 重复此操作,当对象的分代年龄达到阈值会被移动到⽼年代(默认是15、CMS是6,为什么这⾥最⼤是15因为Mark Word中存放分代年龄占⽤4字节所以最⼤是15)
    • 当⽼年代空间达到阈值会进⾏FullGC,倘若⽼年代达到阈值回收不了还是满的会触发OOM
    • 对于Eden区和S0/S1的内存⽐例是8:1:1
  • 大对象直接进入老年代

    • 因为复制算法进⾏对象的的来回复制会带来性能开销,所以需要把⼤对象直接移动到⽼年代
    • ⽽对于采⽤复制算法的垃圾收集器有serial、parNew、parallel它们可以设置⼤对象是多少
  • 长期存活对象进入老年代

    • 分代年龄达到阈值⽐如默认 15 CMS是 6

    • 对象动态年龄判断机制

      • 经过MinorGC在Survivor区存活的前N代的对象的内存总和超过 Survivor区内存的⼀半时,会直接将N代以及N代以上的对象直接移动到⽼年代,这样做的⽬的是尽可能将⻓期存活的对象直接进⼊到⽼年代,避免频繁的GC
  • ⽼年代空间分配担配机制

    • 年轻代每次MinorGC之前会计算⽼年代剩余可⽤空间

    • 如果⽼年代剩余可⽤空间⼩于年轻代⾥所有对象的内存就会触发 MinorGC

    • 如果不⼩于就会看有没有配置担保参数

      • 如果配置了担保参数,就会看⽼年代剩余可⽤空间是否⼩于历史上每⼀次MinorGC进⼊⽼年代的对象的内存⼤⼩
      • 如果⼩于就会触发 FullGC,如果不⼩于就会触发 MinorGC,倘若前⾯没有担保参数就会触发 FullGC
    • 避免频繁进行Full GC

类加载机制

类加载器有哪些

  • Bootstrap,引导类加载器,加载jre/lib⽬录下的包
  • Ext,ExtClassLoader 拓展类加载器,加载jre/lib/ext⽬录下的拓展⼯具包
  • App,AppClassLoader 应⽤程序类加载器,加载⽤户路径下的包
  • ⾃定义类加载器,加载⾃定义路径下的包

什么是双亲委派机制

  • 双亲委派机制就是⼀个⾃上⽽下加载类过程,⾸先会委托⽗加载器去加载⽬标类,如果能找到⽬标类就加载
  • 如果找不到就继续委托它的⽗加载器去加载,如果⽗加载器加载不了,就⾃⼰加载⽬标类

为什么要有双亲委派机制

  • ⾸先⾃上⽽下的过程可以避免重复类加载
  • 可以实现沙箱安全机制,防⽌核⼼类被篡改

如何打破双亲委派机制

  • 可以继承ClassLoader,重写loadClass以及findClass⽅法
  • loadClass主要是⽤来加载类的,findClass主要是⽤来寻找类的

Tomcat 为什么要打破双亲委派机制

TTomcat给每个 Web 应用创建一个类加载器实例(WebAppClassLoader),该加载器重写了loadClass方法,优先加载当前应用目录下的类,如果当前找不到了,才一层一层往上找

  • 隔离性上讲,相同类库的不同版本需要进⾏隔离防⽌冲突,另外Tomcat⾃⼰类库需要隔离,为每个应用程序创建WebAppClassLoader类加载器
  • 共享性上将,保证相同版本之间的类库可以进⾏共享,避免重复加载开销,把ShareClassLoader作为WebAppClassLoader的父类加载器,如果WebApp加载器找不到,则尝试用Share进行加载

JVM 类加载过程

加载, 验 准 心,初始化

  • 对于JVM的类加载过程主要分为5步

    • 加载,将字节码⽂件加载到运⾏时数据区的⽅法区中
    • 验证,对于字节码的格式规范会做⼀些检验,⽐如魔数、主次版本号等
    • 准备,将类中的静态变量分配内存并且赋默认值,⽐如boolean类型赋false
    • 解析,将符号引⽤转变为直接引⽤,因为是在类加载期间所以属于静态链接,相对的动态链接是在程序运⾏期间,最常⻅就是类的多态
    • 初始化,对类中的静态变量赋预期的值以及执⾏静态代码块
  • 对于类中代码块和构造⽅法的先后执⾏顺序

    • 父子 -> 父父 -> 子子
    • ⽗类的静态代码块->⼦类的静态代码块
    • ⽗类的普通代码块->⽗类的构造⽅法
    • ⼦类的普通代码块->⼦类的构造⽅法

什么情况下类会被卸载

  • 这个类的所有对象实例都回收了
  • 这个类的java.lang.Class对象不被外部引⽤了,也就是不能通过反射进⾏访问这个类了
  • 这个类的类加载器被回收了

计算机网络

OSI 和 TCP/IP

OSI

OSI 的七层体系结构概念清楚,理论也很完整,但是它比较复杂而且不实用,而且有些功能在多个层中重复出现。

TCP/IP

  1. 应用层

    应用层位于传输层之上,主要提供两个终端设备上的应用程序之间信息交换的服务,它定义了信息交换的格式,消息会交给下一层传输层来传输。

  2. 传输层

    传输层的主要任务就是负责向两台终端设备进程之间的通信提供通用的数据传输服务。 应用进程利用该服务传送应用层报文。

    运输层主要使用以下两种协议:

    1. 传输控制协议 TCP(Transmisson Control Protocol)--提供面向连接的,可靠的数据传输服务。
    2. 用户数据协议 UDP(User Datagram Protocol)--提供无连接的,尽最大努力的数据传输服务(不保证数据传输的可靠性)。
  3. 网络层

    网络层负责为分组交换网上的不同主机提供通信服务。 在发送数据时,网络层把运输层产生的报文段或用户数据报封装成分组和包进行传送。

    网络层的还有一个任务就是选择合适的路由,使源主机运输层所传下来的分株,能通过网络层中的路由器找到目的主机。

  4. 网络接口层

    • 数据链路层(data link layer)通常简称为链路层( 两台主机之间的数据传输,总是在一段一段的链路上传送的)。数据链路层的作用是将网络层交下来的 IP 数据报组装成帧,在两个相邻节点间的链路上传送帧。每一帧包括数据和必要的控制信息(如同步信息,地址信息,差错控制等)。
    • 物理层的作用是实现相邻计算机节点之间比特流的透明传送,尽可能屏蔽掉具体传输介质和物理设备的差异

为什么网络要分层?

  1. 各层之间相互独立:各层之间相互独立,各层之间不需要关心其他层是如何实现的,只需要知道自己如何调用下层提供好的功能就可以了(可以简单理解为接口调用) 。这个和我们对开发时系统进行分层是一个道理。
  2. 提高了整体灵活性 :每一层都可以使用最适合的技术来实现,你只需要保证你提供的功能以及暴露的接口的规则没有改变就行了。这个和我们平时开发系统的时候要求的高内聚、低耦合的原则也是可以对应上的。
  3. 大问题化小 : 分层可以将复杂的网络间题分解为许多比较小的、界线比较清晰简单的小问题来处理和解决。这样使得复杂的计算机网络系统变得易于设计,实现和标准化。 这个和我们平时开发的时候,一般会将系统功能分解,然后将复杂的问题分解为容易理解的更小的问题是相对应的,这些较小的问题具有更好的边界(目标和接口)定义。

HTTP vs HTTPS

HTTP

全称超文本传输协议,就是用来规范超文本的传输

HTTP 是一个无状态(stateless)协议,也就是说服务器不维护任何有关客户端过去所发请求的消息。这其实是一种懒政,有状态协议会更加复杂,需要维护状态(历史信息),而且如果客户或服务器失效,会产生状态的不一致,解决这种不一致的代价更高。

HTTP 协议通信过程

  1. 服务器在 80 端口等待客户的请求。
  2. 浏览器发起到服务器的 TCP 连接(创建套接字 Socket)。
  3. 服务器接收来自浏览器的 TCP 连接。
  4. 浏览器(HTTP 客户端)与 Web 服务器(HTTP 服务器)交换 HTTP 消息。
  5. 关闭 TCP 连接。

HTTPS

是 HTTP 的加强安全版本。HTTPS 是基于 HTTP 的,也是用 TCP 作为底层协议,并额外使用 SSL/TLS 协议用作加密和安全认证。默认端口号是 443.

SSL/TLS

SSL/TLS 的核心要素是非对称加密。非对称加密采用两个密钥——一个公钥,一个私钥。在通信时,私钥仅由解密者保存,公钥由任何一个想与解密者通信的发送者(加密者)所知。

对称加密

使用 SSL/TLS 进行通信的双方需要使用非对称加密方案来通信,但是非对称加密设计了较为复杂的数学算法,在实际通信过程中,计算的代价较高,效率太低,因此,SSL/TLS 实际对消息的加密使用的是对称加密。

HTTP 1.0 vs HTTP 1.1

HTTP/1.0 默认使用短连接,每遇到这样一个 Web 资源,浏览器就会重新建立一个TCP连接,这样就会导致有大量的“握手报文”和“挥手报文”占用了带宽。

为了解决 HTTP/1.0 存在的资源浪费的问题, HTTP/1.1 优化为默认长连接模式 。

三握四挥

三次握手

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

第一次握手:Client 什么都不能确认;Server 确认了对方发送正常,自己接收正常

第二次握手:Client 确认了:自己发送、接收正常,对方发送、接收正常;Server 确认了:对方发送正常,自己接收正常

第三次握手:Client 确认了:自己发送、接收正常,对方发送、接收正常;Server 确认了:自己发送、接收正常,对方发送、接收正常

所以三次握手就能确认双方收发功能都正常,缺一不可。

四次挥手

客户端-发送一个 FIN,用来关闭客户端到服务器的数据传送

服务器-收到这个 FIN,它发回一 个 ACK,确认序号为收到的序号加 1 。和 SYN 一样,一个 FIN 将占用一个序号

服务器-关闭与客户端的连接,发送一个 FIN 给客户端

客户端-发回 ACK 报文确认,并将确认序号设置为收到序号加 1

任何一方都可以在数据传送结束后发出连接释放的通知,待对方确认后进入半关闭状态。当另一方也没有数据再发送的时候,则发出连接释放通知,对方确认后就完全关闭了 TCP 连接。

TCP 协议如何保证可靠传输

  1. 应用数据被分割成 TCP 认为最适合发送的数据块。
  2. TCP 给发送的每一个包进行编号,接收方对数据包进行排序,把有序数据传送给应用层。
  3. 校验和: TCP 将保持它首部和数据的检验和。这是一个端到端的检验和,目的是检测数据在传输过程中的任何变化。如果收到段的检验和有差错,TCP 将丢弃这个报文段和不确认收到此报文段。
  4. TCP 的接收端会丢弃重复的数据。
  5. 流量控制: TCP 连接的每一方都有固定大小的缓冲空间,TCP 的接收端只允许发送端发送接收端缓冲区能接纳的数据。当接收方来不及处理发送方的数据,能提示发送方降低发送的速率,防止包丢失。TCP 使用的流量控制协议是可变大小的滑动窗口协议。 (TCP 利用滑动窗口实现流量控制)
  6. 拥塞控制: 当网络拥塞时,减少数据的发送。
  7. ARQ 协议: 也是为了实现可靠传输的,它的基本原理就是每发完一个分组就停止发送,等待对方确认。在收到确认后再发下一个分组。
  8. 超时重传: 当 TCP 发出一个段后,它启动一个定时器,等待目的端确认收到这个报文段。如果不能及时收到一个确认,将重发这个报文段

ARQ 协议

自动重传请求(Automatic Repeat-reQuest,ARQ)是 OSI 模型中数据链路层和传输层的错误纠正协议之一。它通过使用确认和超时这两个机制,在不可靠服务的基础上实现可靠的信息传输。如果发送方在发送后一段时间之内没有收到确认帧,它通常会重新发送。ARQ 包括停止等待 ARQ 协议和连续 ARQ 协议。

停止等待 ARQ 协议

每发完一个分组就停止发送,等待对方确认 , 如果过了一段时间(超时时间后),还是没有收到 ACK 确认,说明没有发送成功,需要重新发送,直到收到确认后再发下一个分组。

  • 优点: 简单
  • 缺点: 信道利用率低,等待时间长

连续 ARQ 协议

连续 ARQ 协议可提高信道利用率。发送方维持一个发送窗口,凡位于发送窗口内的分组可以连续发送出去,而不需要等待对方确认。接收方一般采用累积确认,对按序到达的最后一个分组发送确认,表明到这个分组为止的所有分组都已经正确收到了。

  • 优点: 信道利用率高,容易实现,即使确认丢失,也不必重传。
  • 缺点: 不能向发送方反映出接收方已经正确收到的所有分组的信息。 比如:发送方发送了 5 条 消息,中间第三条丢失(3 号),这时接收方只能对前两个发送确认。发送方无法知道后三个分组的下落,而只好把后三个全部重传一次。这也叫 Go-Back-N(回退 N),表示需要退回来重传已经发送过的 N 个消息

滑动窗口和流量控制

TCP 利用滑动窗口实现流量控制。流量控制是为了控制发送方发送速率,保证接收方来得及接收。 接收方发送的确认报文中的窗口字段可以用来控制发送方窗口大小,从而影响发送方的发送速率。将窗口字段设置为 0,则发送方不能发送数据。

拥塞控制

拥塞控制就是为了防止过多的数据注入到网络中,这样就可以使网络中的路由器或链路不致过载。拥塞控制所要做的都有一个前提,就是网络能够承受现有的网络负荷。

TCP 的拥塞控制采用了四种算法,即 慢开始拥塞避免快重传快恢复。在网络层也可以使路由器采用适当的分组丢弃策略(如主动队列管理 AQM),以减少网络拥塞的发生。

  • 慢开始: 慢开始算法的思路是当主机开始发送数据时,如果立即把大量数据字节注入到网络,那么可能会引起网络阻塞,因为现在还不知道网络的符合情况。经验表明,较好的方法是先探测一下,即由小到大逐渐增大发送窗口,也就是由小到大逐渐增大拥塞窗口数值。cwnd 初始值为 1,每经过一个传播轮次,cwnd 加倍。
  • 拥塞避免: 拥塞避免算法的思路是让拥塞窗口 cwnd 缓慢增大,即每经过一个往返时间 RTT 就把发送放的 cwnd 加 1.
  • 快重传与快恢复: 在 TCP/IP 中,快速重传和恢复(fast retransmit and recovery,FRR)是一种拥塞控制算法,它能快速恢复丢失的数据包。没有 FRR,如果数据包丢失了,TCP 将会使用定时器来要求传输暂停。在暂停的这段时间内,没有新的或复制的数据包被发送。有了 FRR,如果接收机接收到一个不按顺序的数据段,它会立即给发送机发送一个重复确认。如果发送机接收到三个重复确认,它会假定确认件指出的数据段丢失了,并立即重传这些丢失的数据段。有了 FRR,就不会因为重传时要求的暂停被耽误。  当有单独的数据包丢失时,快速重传和恢复(FRR)能最有效地工作。当有多个数据信息包在某一段很短的时间内丢失时,它则不能很有效地工作。

从输入URL到页面加载发生了什么

  1. DNS 解析

    DNS解析的过程就是寻找哪台机器上有你需要资源的过程

  2. TCP 连接

  3. 发送 HTTP 请求

  4. 服务器处理请求并返回 HTTP 报文

  5. 浏览器解析渲染页面

  6. 连接结束

HTTP 是不保存状态的协议, 如何保存用户状态?

Session 机制的存在就是为了解决这个问题,Session 的主要作用就是通过服务端记录用户的状态。典型的场景是购物车,当你要添加商品到购物车的时候,系统不知道是哪个用户操作的,因为 HTTP 协议是无状态的。服务端给特定的用户创建特定的 Session 之后就可以标识这个用户并且跟踪这个用户了(一般情况下,服务器会在一定时间内保存这个 Session,过了时间限制,就会销毁这个 Session)。

Cookie 的作用是什么? 和 Session 有什么区别?

Cookie 一般用来保存用户信息 比如 ① 我们在 Cookie 中保存已经登录过的用户信息,下次访问网站的时候页面可以自动帮你把登录的一些基本信息给填了;② 一般的网站都会有保持登录,也就是说下次你再访问网站的时候就不需要重新登录了,这是因为用户登录的时候我们可以存放了一个 Token 在 Cookie 中,下次登录的时候只需要根据 Token 值来查找用户

Session 的主要作用就是通过服务端记录用户的状态即可

HTTP状态码

200:成功响应

403:客户端发送的url正确,但是服务端由于某些原因拒绝响应

404:客户端请求的资源不存在

400:其他4开头的状态码不适用时使用400,往往是服务器不理解客户端的请求

500:这是一个通用的服务器错误响应。对于大多数web框架,如果在执行请求处理代码时遇到了异常,它们就发送此响应代码。

505: 当服务器不支持客户端试图使用的HTTP版本时发送此响应代码。

其他

设计模式

手撕单例模式🎉

双重检测锁

 public class Singleton {
     // 声明私有对象
     private volatile static Singleton instance;
     
     private Singleton() {}
     
     // 获取实例(单例对象)
     public static Singleton getInstance() {
         if (instance == null) {
             synchronized (Singleton.class) {
                 if (instance == null) {
                     instance = new Singleton();
                 }
             }
         }
         return instance;
     }
 }

单例的实现分为饿汉模式和懒汉模式。

顾名思义,饿汉模式就好比他是一个饿汉,而且有一定的危机意识,他会提前把食物囤积好,以备饿了之后直接能吃到食物。对应到程序中指的是,在类加载时就会进行单例的初始化,以后访问时直接使用单例对象即可。

它的优点是线程安全,因为单例对象在类加载的时候就已经被初始化了,当调用单例对象时只是把早已经创建好的对象赋值给变量;它的缺点是可能会造成资源浪费,如果类加载了单例对象(对象被创建了),但是一直没有使用,这样就造成了资源的浪费。

 public class Singleton {
     // 声明私有对象
     private static Singleton instance = new Singleton();  
     
     private Singleton() {}
     
     // 获取实例(单例对象)
     public static Singleton getInstance() {
         return instance;
     } 
 }
 复制代码

懒汉模式的实现代码如下:

它的优点是不会造成资源的浪费,因为在调用的时候才会创建被实例化对象;它的缺点在多线程环境下是非线程是安全的,比如多个线程同时执行到 if 判断处,此时判断结果都是未被初始化,那么这些线程就会同时创建 n 个实例,这样就会导致意外的情况发生。

 public class Singleton {
     // 声明私有对象
     private static Singleton instance;
     
     private Singleton() {}
     
     // 获取实例(单例对象)
     public static Singleton getInstance() {
         if (instance == null) {
             instance = new Singleton();
         }
         return instance;
     }
 }

和此知识点相关的面试题还有以下这些:

  • 什么是双重检测锁?它是线程安全的吗?
  • 单例的还有其他实现方式吗?

双重检测锁

为了保证懒汉模式的线程安全我们最简单的做法就是给获取实例的方法上加上 synchronized(同步锁)修饰,如下代码所示:

 public class Singleton {
     // 声明私有对象
     private static Singleton instance;
     
     private Singleton() {}
     
     // 获取实例(单例对象)
     public synchronized static Singleton getInstance() {
         if (instance == null) {
             instance = new Singleton();
         }
         return instance;
     }
 }

这样虽然能让懒汉模式变成线程安全的,但由于整个方法都被 synchronized 所包围,因此增加了同步开销,降低了程序的执行效率。

于是为了改进程序的执行效率,我们将 synchronized 放入到方法中,以此来减少被同步锁所修饰的代码范围,实现代码如下:

 public class Singleton {
     // 声明私有对象
     private static Singleton instance;
     
     private Singleton() {}
     
     // 获取实例(单例对象)
     public static Singleton getInstance() {
         if (instance == null) {
             synchronized (Singleton.class) {
                 instance = new Singleton();
             }
         }
         return instance;
     }
 }

细心的你可能会发现以上的代码也存在着非线程安全的问题。例如,当两个线程同时执行到「if (instance == null) { 」判断时,判断的结果都为 true,于是他们就排队都创建了新的对象,这显然不符合我们的预期。于是就诞生了大名鼎鼎的双重检测锁(Double Checked Lock,DCL),实现代码如下:

 public class Singleton {
     // 声明私有对象
     private static Singleton instance;
     
      private Singleton() {}
      
     // 获取实例(单例对象)
     public static Singleton getInstance() {
         if (instance == null) {
             synchronized (Singleton.class) {
                 if (instance == null) {
                     instance = new Singleton();
                 }
             }
         }
         return instance;
     }
 }

上述代码看似完美,其实隐藏着一个不容易被人发现的小问题,该问题就出在 new 对象这行代码上,也就是 instance = new Singleton() 这行代码。这行代码看似是一个原子操作,然而并不是,这行代码最终会被编译成多条汇编指令,它大致的执行流程为以下三个步骤:

  • 给对象实例分配内存空间;
  • 调用对象的构造方法、初始化成员字段;
  • 将 instance 对象指向分配的内存空间。

但由于 CPU 的优化会对执行指令进行重排序,也就说上面的执行流程的执行顺序有可能是 1-2-3,也有可能是 1-3-2。假如执行的顺序是 1-3-2,那么当 A 线程执行到步骤 3 时,切换至 B 线程了,而此时 B 线程判断 instance 对象已经指向了对应的内存空间,并非为 null 时就会直接进行返回,而此时因为没有执行步骤 2,因此得到的是一个未初始化完成的对象,这样就导致了问题的诞生。

时间点线程执行操作
t1Ainstance = new Singleton() 的 1-3 步骤,待执行步骤2
t2Bif (instance == null) { 判断结果为 false
t3B返回半初始的 instance 对象

为了解决此问题,我们可以使用关键字 volatile 来修饰 instance 对象,这样就可以防止 CPU 指令重排,从而完美地运行懒汉模式,实现代码如下:

静态成员变量,静态方法 static

volatile synchronized

 public class Singleton {
     // 声明私有对象
     private volatile static Singleton instance;
     
     private Singleton() {}
     
     // 获取实例(单例对象)
     public static Singleton getInstance() {
         if (instance == null) {
             synchronized (Singleton.class) {
                 if (instance == null) {
                     instance = new Singleton();
                 }
             }
         }
         return instance;
     }
 }

单例其他实现方式

除了以上的 6 种方式可以实现单例模式外,还可以使用静态内部类和枚举类来实现单例。静态内部类的实现代码如下:

private static final Singleton

 public class Singleton {
     private Singleton() {}
     
     // 静态内部类
     private static class SingletonInstance {
         private static final Singleton instance = new Singleton();
     }
     
     // 获取实例(单例对象)
     public static Singleton getInstance() {
         return SingletonInstance.instance;
     }
 }

从上述代码可以看出,静态内部类和饿汉方式有异曲同工之妙,它们都采用了类装载的机制来保证,当初始化实例时只有一个线程执行,从而保证了多线程下的安全操作。JVM 会在类初始化阶段(也就是类装载阶段)创建一个锁,该锁可以保证多个线程同步执行类初始化的工作,因此在多线程环境下,类加载机制依然是线程安全的。

但静态内部类和饿汉方式也有着细微的差别,饿汉方式是在程序启动时就会进行加载,因此可能造成资源的浪费;而静态内部类只有在调用 getInstance() 方法时,才会装载内部类从而完成实例的初始化工作,因此不会造成资源浪费的问题。由此可知,此方式也是较为推荐的单例实现方式。

代理模式

代理模式属于结构型模式,为其他对象提供一种代理以控制对这个对象的访问。优点是可以增强目标对象的功能,降低代码耦合度,扩展性好。缺点是在客户端和目标对象之间增加代理对象会导致请求处理速度变慢,增加系统复杂度。

Spring 利用动态代理实现 AOP,如果 Bean 实现了接口就使用 JDK 代理,否则使用 CGLib 代理。

静态代理: 代理对象持有被代理对象的引用,调用代理对象方法时也会调用被代理对象的方法,但是会在被代理对象方法的前后增加其他逻辑。需要手动完成,在程序运行前就已经存在代理类的字节码文件,代理类和被代理类的关系在运行前就已经确定了。 缺点是一个代理类只能为一个目标服务,如果要服务多种类型会增加工作量。

动态代理: 动态代理在程序运行时通过反射创建具体的代理类,代理类和被代理类的关系在运行前是不确定的。动态代理的适用性更强,主要分为 JDK 动态代理和 CGLib 动态代理。

  • JDK 动态代理: 通过 Proxy 类的 newInstance 方法获取一个动态代理对象,需要传入三个参数,被代理对象的类加载器、被代理对象实现的接口,以及一个 InvocationHandler 调用处理器来指明具体的逻辑,相比静态代理的优势是接口中声明的所有方法都被转移到 InvocationHandlerinvoke 方法集中处理。
  • CGLib 动态代理: JDK 动态代理要求实现被代理对象的接口,而 CGLib 要求继承被代理对象,如果一个类是 final 类则不能使用 CGLib 代理。两种代理都在运行期生成字节码,JDK 动态代理直接写字节码,而 CGLib 动态代理使用 ASM 框架写字节码,ASM 的目的是生成、转换和分析以字节数组表示的已编译 Java 类。 JDK 动态代理调用代理方法通过反射机制实现,而 GCLib 动态代理通过 FastClass 机制直接调用方法,它为代理类和被代理类各生成一个类,该类为代理类和被代理类的方法分配一个 int 参数,调用方法时可以直接定位,因此调用效率更高。

工厂模式

其他模式

装饰器模式和动态代理的区别?

装饰器模式的关注点在于给对象动态添加方法,而动态代理更注重对象的访问控制。动态代理通常会在代理类中创建被代理对象的实例,而装饰器模式会将装饰者作为构造方法的参数。

适配器模式

适配器模式属于结构型模式,它作为两个不兼容接口之间的桥梁,结合了两个独立接口的功能,将一个类的接口转换成另外一个接口使得原本由于接口不兼容而不能一起工作的类可以一起工作。

缺点是过多使用适配器会让系统非常混乱,不易整体把握。

java.io 包中,InputStream 字节输入流通过适配器 InputStreamReader 转换为 Reader 字符输入流。

Spring MVC 中的 HandlerAdapter,由于 handler 有很多种形式,包括 Controller、HttpRequestHandler、Servlet 等,但调用方式又是确定的,因此需要适配器来进行处理,根据适配规则调用 handle 方法。

Arrays.asList 方法,将数组转换为对应的集合(注意不能使用修改集合的方法,因为返回的 ArrayList 是 Arrays 的一个内部类)。

适配器模式和和装饰器模式以及代理模式的区别?

适配器模式:一个适配允许通常因为接口不兼容而不能在一起工作的类工作在一起,做法是将类自己的接口包裹在一个已存在的类中。

装饰器模式:原有的不能满足现有的需求,对原有的进行增强。

代理模式:同一个类而去调用另一个类的方法,不对这个方法进行直接操作

JDK中设计模式

java.lang.Runtime (饿汉式单例模式)

Runtime类封装了Java运行时的环境。每一个java程序实际上都是启动了一个JVM进程,那么每个JVM进程都是对应这一个Runtime实例,此实例是由JVM为其实例化的。每个 Java 应用程序都有一个 Runtime 类实例,使应用程序能够与其运行的环境相连接。

由于Java是单进程的,所以,在一个JVM中,Runtime的实例应该只有一个。所以应该使用单例来实现。

 public class Runtime {
     private static Runtime currentRuntime = new Runtime();
  
     public static Runtime getRuntime() {
         return currentRuntime;
     }
  
     private Runtime() {}
 }

Factory(静态工厂)

作用: (1)代替构造函数创建对象 (2)方法名比构造函数清晰 JDK中体现: (1)Integer.valueOf (2)Class.forName

Proxy(代理)

作用: (1)透明调用被代理对象,无须知道复杂实现细节 (2)增加被代理类的功能 JDK中体现:动态代理;

装饰器模式

装饰器模式属于结构型模式,在不改变原有对象的基础上将功能附加到对象,相比继承可以更加灵活地扩展原有对象的功能。

装饰器模式适合的场景:在不想增加很多子类的前提下扩展一个类的功能。

java.io 包中,InputStream 字节输入流通过装饰器 BufferedInputStream 增强为缓冲字节输入流。

Prototype(原型)

作用: (1)复制对象 (2)浅复制、深复制 JDK中体现:Object.clone;Cloneable

Adapter(适配器)

作用:使不兼容的接口相容 JDK中体现: (1)java.io.InputStreamReader(InputStream) (2)java.io.OutputStreamWriter(OutputStream)

Flyweight(享元)

作用:共享对象,节省内存 JDK中体现: (1)Integer.valueOf(int i);Character.valueOf(char c) (2)String常量池

Strategy(策略)

作用:提供不同的算法 JDK中的体现:ThreadPoolExecutor中的四种拒绝策略

在集合框架中,经常需要通过构造方法传入一个比较器 Comparator 进行比较排序。Comparator 就是一个抽象策略,一个类通过实现该接口并重写 compare 方法成为具体策略类。

MySQL

逻辑架构

数据类型

事务

事务的四⼤特性(ACID) 原子,一致,隔离,持久

事务的隔离级别以及对应解决的四大问题

  • 谈到事务隔离级别就必须谈到并发事务可能产⽣的问题:脏写、脏读、不可重复读、幻读
  • 读未提交,读已提交,可重复读,串行化

InnoDB 引擎的默认隔离级别虽然是「可重复读」,但是它通过next-key lock 锁(行锁和间隙锁的组合)来锁住记录之间的“间隙”和记录本身,防止其他事务在这个记录之间插入新的记录,这样就避免了幻读现象。

这四种隔离级别具体是如何实现的呢?

  • 对于「读未提交」隔离级别的事务来说,因为可以读到未提交事务修改的数据,所以直接读取最新的数据就好了;

  • 对于「串行化」隔离级别的事务来说,通过加读写锁的方式来避免并行访问;

  • 对于「读提交」和「可重复读」隔离级别的事务来说,它们是通过 Read View 来实现的,它们的

    区别在于创建 Read View 的时机不同,可以把 Read View 理解成一个数据快照,就像相机拍照那样,定格某一时刻的风景。

    • 「读已提交」隔离级别是在「每个语句执行前」都会重新生成一个 Read View,
    • 「可重复读」隔离级别是「启动事务时」生成一个 Read View,然后整个事务期间都在用这个 Read View。

这两个隔离级别实现是通过「事务的 Read View 里的字段」和「记录中的两个隐藏列」的比对,来控制并发事务访问同一个记录时的行为,这就叫 MVCC(多版本并发控制)。

可重复读隔离级别中,普通的 select 语句就是基于 MVCC 实现的快照读,也就是不会加锁的。而 select .. for update 语句就不是快照读了,而是当前读了,也就是每次读都是拿到最新版本的数据,但是它会对读到的记录加上 next-key lock 锁。

幻读

在可重复读隔离级别下,普通的查询是快照读,是不会看到别的事务插入的数据的

MySQL 里除了普通查询是快照读,其他都是当前读,比如update、insert、delete,这些语句执行前都会查询最新版本的数据,然后再做进一步的操作。另外,select ... for update 这种查询语句是当前读,每次执行的时候都是读取最新的数据。

要讨论「可重复读」隔离级别的幻读现象,是要建立在「当前读」的情况下。

Innodb 引擎为了解决「可重复读」隔离级别使用「当前读」而造成的幻读问题,就引出了 next-key 锁,就是记录锁和间隙锁的组合。

  • 记录锁,锁的是记录本身;
  • 间隙锁,锁的就是两个值之间的空隙,以防止其他事务在这个空隙间插入新的数据,从而避免幻读现象。

当前读,快照读

  • 当前读: (也称锁定读,Locking Read)就是它读取的是记录的最新版本,读取时还要保证其他并发事务不能修改当前记录,会对读取的记录进行加锁
  • 快照读:不加锁的select操作就是快照读,即不加锁的非阻塞读,通过 undo log + MVCC 来实现的

索引及优化

什么是索引

  1. 优化速度:查询,分组,排序,连接
  2. 占用空间,维护难
  • 索引就是⽤来优化查询速度 的⼀种数据结构,可以⽤来加快分组和排序的速度以及表之间连接
  • 但是建⽴索引需要占⽤物理空间,对于增删改后的索引数据需要进⾏动态维护,在⼤数据量情况下效率会很低

索引的作用

  • 数据是存储在磁盘上的,倘若没有索引会加载所有数据到内存中,磁盘IO的次数会很很多,查询速度会很慢
  • 但是有了索引就不需要加载所有的数据到内存中,访问磁盘IO的频率也会少很多
  • ⽐如B+Tree的深度只有2到4层,相当于2到4次磁盘IO就能查询到数据

什么时候需要建索引

4 个:where、order、group 唯一性的字段

什么时候不使用索引

4 个:where order group ⽤不到的字段、经常更新的字段、重复度⾼的字段、表记录较少

索引的设计原则

要与不要,前缀,不过度,最左

  • 要建索引:where、order、group 唯一的字段推荐建⽴索引
  • 不用索引:where order group ⽤不到的字段、经常更新重复度⾼的字段、表记录较少不推荐建⽴索引
  • 前缀: 对于字符类型,如果是是⻓字符串可以指定⼀个前缀⻓度,可以节省磁盘空间
  • 不过度:不要过度使⽤索引,索引需要占⽤额外的磁盘空间降低写操作的性能,修改字段时会重建索引,数据量越⼤花费的时间也越多
  • 最左前缀原则

索引什么时候会失效

like, 计算隐式,最左,or

  • 进⾏like模糊查询时,[like %x] 放在索引字段之前会导致索引失效,倘若数据量过⼤,索引下推会失效
  • 对索引列做了[计算、函数、隐式类型转换(字符串没加单引号) ] 操作,这些情况下都会造成索引失效;会自动把字符串转为数字,判断索引列不等于 [!=] 某个值时会导致索引失效
  • 对于组合索引没有⾛[最左前缀]原则会导致索引失效
  • 查询条件使⽤ [or] 连接会导致索引失效

索引优化原则

  • 前缀索引优化

  • 覆盖索引优化:联合索引

  • 主键索引最好是自增的 页分裂

  • 防止索引失效

    • 上面的 4 点: like, 计算隐式,最左,or

隐式类型转换(字符串没加单引号)

 select * from t_user where phone = 123;
 select * from t_user where CAST(phone AS signed int) = 123;

索引数据结构

常⻅的索引结构

  1. 二叉搜索树
  2. 红⿊树
  • 节点⾮红即⿊
  • 根节点是⿊节点
  • 红节点的⼦节点⼀定是⿊节点
  • 节点到达叶⼦节点相同路径包含相同数量的⿊节点
  • 叶⼦节点都是⿊节点
  1. 哈希表
  • 本质就是⼀个数组,通过将key进⾏hash计算出数组的下标进⾏存放元素,当遇到hash碰撞的时候可以采⽤链表进⾏存储
  • 对于拿元素的时候,将key进⾏hash计算定位到元素存放的槽位然后去查找
  • 哈希表⽐较适合=、in等值查询,效率很⾼,但是不⽀持范围查询⽽且会占⽤更多的空间
  1. BTree
  • 叶⼦节点具有相同深度,叶⼦节点的指针为空
  • 所有索引元素不重复
  • 节点是从左到右递增排列
  1. B+Tree
  • 叶⼦节点具有指针连接,提升区间访问效率
  • ⾮叶⼦节点不存储数据只存储索引并且冗余上⼀层节点,可以存放更多数据
  • 叶⼦节点包含所有索引字段
  • 节点是从左到右递增排列

为什么 MySQL 最终选择 B+Tree 作为索引结构

深度,区间,非叶子只索引

  • 对于⼆叉树、红⿊树的深度不可控,可能会进⾏⼤量的磁盘IO
  • 哈希表主要是适合等值查询的场景,不适合范围查询
  • BTree 不⽀持区间访问(中序)以及不能存储更多的索引
  • B+Tree深度可控以及⾮叶⼦节点只存储索引不存储数据,能存放更多的索引并且叶⼦节点有指针连接⽀持区间访问

为什么 MySQL 要⽤ B+Tree ⽽不⽤跳表

  • B+Tree⽐跳表的检索效率更⾼,数据分布也更均匀
  • 跳表的思想类似⼆路分治实现O(logN),B+Tree通过多路分治实现O(logN)
  • 当数据表的数据⾜够多的时候,B+Tree的根节点到任意⼀个叶⼦节点路径是固定的,⽽跳表的头节点到⽬标节点的路径是不固定的,所以检索的value越⼤,跳表的路径就越深,磁盘IO的次数就越多
  • B+Tree的所有叶⼦节点构成⼀个双向循环链表,每⼀块叶⼦节点可以存储⼀条或多条数据,这种可以减少磁盘IO
  • 跳表每⼀个节点只存储⼀条数据,对于⼀条数据的查找是⽐较节省磁盘IO的,但是对于多条数据的查询,跳表的磁盘IO效率⽐B+Tree低

索引的分类

  • 按「数据结构」分类:B+tree 索引、Hash 索引、Full-text 索引
  • 按「物理存储」分类:聚簇索引(主键索引)、二级索引(辅助索引)
  • 按「字段特性」分类:主键索引、唯一索引、普通索引、前缀索引
  • 按「字段个数」分类:单列索引、联合索引

全⽂索引就是MylSAM提供的,只能字符类型的才能使⽤

聚簇索引和⾮聚簇索引

  • 聚簇索引就是叶⼦节点存储索引和数据,InnoDB就属于聚簇索引
  • ⾮聚索引就是叶⼦节点存储索引和磁盘地址通过磁盘地址找到存储的数据,需要回表操作所以效率⽐聚簇索引慢,MylSAM就属于⾮聚簇索引

InnoDB 和 MylSAM 的区别

6个:事务,外键,锁,聚簇索引,记录总行数,场景:查询快

  • InnoDB⽀持事务,⽀持事务的四种隔离级别,MylSAM不⽀持事务,但是每次查询都是原⼦的

  • InnoDB⽀持外键,MylSAM不⽀持外键

  • InnoDB属于聚簇索引,MylSAM属于⾮聚簇索引

  • InnoDB⽀持⾏锁粒度更⼩,但是可能因为范围⽽锁表,MylSAM⽀持表锁,每次操作都会锁表

  • InnoDB不存储总⾏数,MylSAM存储总⾏数

  • InnoDB适合增删改场景,MylSAM适合⼤量的查询

    为什么 myisam 查询 比 innodb 快?事务:MVCC, 回表,锁

    • 查询的时候,由于innodb支持事务,所以会有mvvc的一个比较。这个过程会损耗性能。
    • 查询的时候,如果走了索引,而索引又不是主键索引,此时,由于innodb是聚簇索引,会有一个回表的过程,即:先去非聚簇索引树(非主键索引树)中查询数据,找到数据对应的key之后,再通过key回表到聚簇索引树,最后找到需要的数据。而myisam是非聚集索引,而且叶子节点存储的是磁盘地址,所以,查询的时候查到的最后结果不是聚簇索引树的key,而是会直接去查询磁盘
    • 锁的一个损耗,innodb锁支持行锁,在检查锁的时候不仅检查表锁,还要看行锁。

为什么⾮主键索引的叶⼦节点的数据存储的是主键ID

  • 对于InnoDB的⼆级索引存储的数据是主键ID,主要是为了保证数据⼀致性
  • 因为如果存储的是数据,需要维护主键索引和⾮主键索引两棵树,数据⼀致性很难保证好
  • 但是存储唯⼀⾮空的主键ID,可以通过根据主键ID会表查询数据就⾏

为什么建议InnoDB必须设置主键

  • 建立主键索引可以维护整张表
  • 设置了[主键],InnoDB会直接使⽤该主键作为索引列
  • 如果没有设置主键,InnoDB会从数据列中选出[唯⼀非空]的作为索引列
  • 如果没有找到值唯⼀的会维护⼀个[隐藏列]作为索引列,⽽这样是⽐较损耗性能

为什么推荐使⽤⾃增整型作为主键⽽不是UUID

  • 整型占⽤磁盘空间更少,查询匹配更快
  • ⾃增主键与索引节点递增有序吻合,在新增主键的时候不会因为乱序⽽导致叶分裂页重组⽽降低性能

MySQL的执⾏计划怎么看/explain

我们可以通过explain关键字去查看SQL执⾏的顺序以及分析查询语句的性能瓶颈,主要看的列有type、key_len、extra

type

  • 会显示关联类型,执⾏效率排序为system > const > eq_ref > ref > range > index > all system和const基本不⽤进⾏优化,只要优化到range就可以了

key_len

  • 会显示索引列占⽤的字节数,可以算出具体⾛了哪些索引列,⽐如int占⽤4字节、tinyint占⽤1字节,如果字段允许为null,会单独占⽤1字节来判断是否为空

extra

  • 会显示额外信息

  • Using index 表示使⽤了普通索引和覆盖索引

  • Using where 表示使⽤ where 进⾏过滤

  • Using temporary 表示使⽤临时表,查询效率不⾼,需要进⾏优化

  • Using filesort 表示使⽤⽂件排序,表示order by没有⾛索引,需要让它⾛左前缀原则

    • ⽂件排序原理就是MySQL会在内存中开辟⼀⼩块内存sort_buffer,如果需要排序的数据内存⼩于sort_buffer,那么就会直接在sort_buffer中排序,如果需要排序的数据内存⼤于 sort_buffer 就会创建⼀个临时⽂件暂存,然后再从临时⽂件中数据加载到sort_buffer进⾏排序

    • 对于排序主要分为两种

      • 单路排序: 单路排序就是将结果集加⼊到内存中去,然后order返回结果,占⽤的内存空间较⼤
      • 双路排序: 双路排序就是将需要排序的字段和主键ID加⼊到内存中去,然后order再根据主键ID回表查询然后返回结果占⽤内存⼩
  • Using join buffer 表示使⽤NLJ算法,如果有显示Blocked Nested Loop Join表示使⽤BNL算法

    • 这两种算法主要针对join关联的进⾏选择的,如果被驱动表的关联字段有索引,就会选择NLJ算法,否则就会选择BNL算法
    • NLJ算法,嵌套循环连接算法,就是每次从驱动表取出⼀⾏加载到join_buffer中,然后通过关联字段与被驱动表进⾏⽐较,过滤出符合条件的结果集
    • BNL算法,基于块的循环连接算法,就是每次将驱动表的数据加载到join_buffer中,被驱动表每次取出⼀⾏数据与join_buffer中数据进⾏⽐较,过滤出符合条件的结果集
    • 这⾥要注意的是join_buffer默认只有256kb,如果⼀次加载到join_buffer数据过多会分批去进⾏处理
    • 为什么被驱动表的关联字段没有索引会选择BNL算法: 因为⼤量数据在内存中做数据过滤会⽐磁盘快很多,MySQL会选择BNL算法来优化
    • 对于join关联的优化原则就是⼩表驱动⼤表,⽐如inner join MySQL的优化器会帮忙选择⼩表去驱动⼤表,left/right join需要我们⾃⼰指定,left join⼩表在左、⼤表在右,right join反之
    • 总结:left join⼩表在左、⼤表在右
  • 整体上看尽量让查询、排序的结果集使⽤上索引

索引下推

对于联合索引(a, b),在执行 select * from table where a > 1 and b = 2 语句的时候,只有 a 字段能用到索引,那在联合索引的 B+Tree 找到第一个满足条件的主键值(ID: a 为 2)后,还需要判断其他条件是否满足(看 b 是否等于 2),那是在联合索引里判断?还是回主键索引去判断呢?

  • 5.6 之前,只能从 ID2 (主键值)开始一个个回表,到「主键索引」上找出数据行,再对比 b 字段值。
  • 5.6 引入的索引下推优化(index condition pushdown), 可以在联合索引遍历过程中,对联合索引中包含的字段先做判断,直接过滤掉不满足条件的记录,减少回表次数

当你的查询语句的执行计划里,出现了 Extra 为 Using index condition,那么说明使用了索引下推的优化。

对慢 SQL 怎么优化

  • ⾸先分析SQL语句,看看是否加载了额外的数据,⽐如额外的列,对于SQL语句进⾏重写
  • 查看SQL的执⾏计划,看看索引列有没有命中,没有命中对SQL语句进⾏调整
  • 对于SQL⽆法继续进⾏优化,看看是不是数据量的问题,如果对查询效率要求较⾼,可以采⽤垂直分表或者⽔平分表

count(*) 和 count(1)

小结

count(1)、 count(*)、 count(主键字段)在执行的时候,如果表里存在二级索引,优化器就会选择二级索引进行扫描。

所以,如果要执行 count(1)、 count(*)、 count(主键字段) 时,尽量在数据表上建立二级索引,这样优化器会自动采用 key_len 最小的二级索引进行扫描,相比于扫描主键索引效率会高一些。

再来,就是不要使用 count(字段) 来统计记录个数,因为它的效率是最差的,会采用全表扫描的方式来统计。如果你非要统计表中该字段不为 NULL 的记录个数,建议给这个字段建立一个二级索引

MySQL中的锁有哪些

  • 基于锁的属性分为独占锁共享锁

    • 独占锁(写锁)

      • 当⼀个事务给数据加上写锁的时候,其他请求不允许再为数据加任何锁,想要进⾏操作需要等写锁释放之后
      • 独占锁的⽬的是在修改数据的时候不允许其他⼈同时进⾏读和修改,避免了脏写和脏读的问题
    • 共享锁(读锁)

      • 当⼀个事务给数据加上读锁的时候,其他事务只能对数据加读锁,⽽不能加写锁,直到读锁都释放完之后才能允许其他事务加写锁
      • 共享锁的⽬的⽀持并发读,但是读时不允许修改,避免不可重复读的问题
  • 基于锁的粒度可以分为表锁、⾏锁、记录锁、间隙锁、临键锁

    • 表锁

      • 每次操作都会锁住整张表,粒度⼤,加锁简单,容易冲突,并发度低,⼀般⽤于数据迁移
    • ⾏锁

      • 每次操作会锁住表的某⾏或多⾏记录,粒度⼩,加锁⽐表锁复杂,不容易冲突,⽀持更⾼的并发
      • 是建⽴在索引的基础上的,没有索引或者索引失效时,InnoDB 的⾏锁变表锁,⽐如普通索引数据重复率过⾼会导致索引失效,⾏锁会升级为表锁
    • 记录锁

      • ⾏锁的⼀种,每次操作会锁住表的某⾏记录,避免不可重复读的问题以及脏读问题
    • 间隙锁

      • ⾏锁的⼀种,锁住的是两个值之间的空隙,遵循左开右闭原则,为了解决幻读问题间隙锁在RR级别才会⽣效,在RC级别会失效
    • 临键锁

      • ⾏锁的⼀种,基于记录锁和间隙锁实现的,会锁住查询到记录、记录之间的间隙以及记录相邻的下⼀个区间,加了临键锁在范围内的数据不能被修改,可以避免不可重复读、脏读、幻读等问题

MySQL避免死锁

  • 尽量让表中数据查询通过索引来完成,避免索引失效导致⾏锁升级为表锁
  • 合理设计索引,尽量缩⼩锁的范围
  • 尽量控制事务的⼤⼩,减少⼀次事务锁定的资源数量,缩短锁定资源的时间
  • 如果⼀条SQL语句涉及事务加锁操作,尽量将其放到整个事务的最后执⾏
  • 尽可能使⽤低级别隔离级别

解释下隔离级别与锁的关系

  • 在RU级别下,读不需要共享锁,这样就不跟被修改数据上的独占锁冲突
  • 在RC级别下,读需要加共享锁,SQL执⾏完之后才会释放
  • RR级别下,读需要加共享锁,要等事务结束后才会释放
  • S级别下,会锁住整个范围,等事务结束了才会释放

日志

MySQL的三个⽇志

  • undo log:当执⾏修改操作时,会记录undo⽇志,undo⽇志相当于快照,⽤于数据回滚,并且实现MVCC机制可以根据undo⽇志版本链回滚到特定的版本
  • redo log:是InnoDB级别的,当MySQL宕机时会通过redo⽇志将BufferPool中数据进⾏恢复
  • binlog:主要是记录数据库执⾏修改的所有操作,主要是⽤于数据归档

undo log 两大作用:

  • 实现事务回滚,保障事务的原子性。事务处理过程中,如果出现了错误或者用户执 行了 ROLLBACK 语句,MySQL 可以利用 undo log 中的历史数据将数据恢复到事务开始之前的状态。
  • 实现 MVCC(多版本并发控制)关键因素之一。MVCC 是通过 ReadView + undo log 实现的。undo log 为每条记录保存多份历史数据,MySQL 在执行快照读(普通 select 语句)的时候,会根据事务的 Read View 里的信息,顺着 undo log 的版本链找到满足其可见性的记录

为什么需要 redo log 这个问题我们有两个答案:

  • 实现事务的持久性,让 MySQL 有 crash-safe 的能力,能够保证 MySQL 在任何时间段突然崩溃,重启后之前已提交的记录都不会丢失;
  • 将写操作从「随机写」变成了「顺序写」,提升 MySQL 写入磁盘的性能。

redo log 和 undo log 区别在哪?

这两种日志是属于 InnoDB 存储引擎的日志,它们的区别在于:

  • redo log 记录了此次事务「完成后」的数据状态,记录的是更新之后的值;
  • undo log 记录了此次事务「开始前」的数据状态,记录的是更新之前的值;

事务提交之前发生了崩溃,重启后会通过 undo log 回滚事务,事务提交之后发生了崩溃,重启后会通过 redo log 恢复事务

redo log 和 binlog 有什么区别?

这两个日志有四个区别。

  1. redo log 是 InnoDB 引擎特有的;binlog 是 MySQL 的 Server 层实现的,所有引擎都可以使用。

  2. redo log 是物理日志,记录的是“在某个数据页上做了什么修改”;binlog 是逻辑日志,记录的是这个语句的原始逻辑,比如“给 ID=2 这一行的 c 字段加 1 ”。

  3. redo log 是循环写的,空间固定会用完;binlog 是可以追加写入的。“追加写”是指 binlog 文件写到一定大小后会切换到下一个,并不会覆盖以前的日志。

  4. 用途不同:

    • binlog 用于备份恢复、主从复制;
    • redo log 用于掉电等故障恢复。

MVCC 机制

  • MVCC就是通过保留数据多版本并发控制,MVCC在RR和RC级别都实现了,主要是为了提升并发性能以及对于⾼并发场景MVCC⽐⾏级锁更有效并且开销更⼩
  • 它内部维护了⼀个undo⽇志版本链,在事务第⼀次查询的时候会⽣成⼀个read_view(⼀致性视图),当后⾯进⾏数据查询的时候会与read_view按照⼀定规则的匹配然后返回对应的数据

总结一句话:

  • MVCC机制的实现就是通过read-view机制与undo版本链比对机制
  • 使得不同的事务会根据数据版本链对比规则读取同一条数据在版本链上的不同版本数据。

BufferPool

SQL 的执⾏流程

连接器,缓存,分析器,优化器,执行器

客户端通过连接器进⾏权限验证,然后先去判断有没有开启缓存,如果开启了先从缓存中查询数据返回,如果没有开启就通过词法分析器去进⾏词法分析,然后通过优化器去进⾏SQL优化,然后再通过执⾏器去选择存储引擎去进⾏SQL执⾏

BufferPool 缓存机制

  1. 将相关的数据⻚加载到BufferPool中:当我们执⾏⼀条修改语句的时候会经过Server调⽤具体存储引擎

  2. 记录 undo ⽇志:⽅便事务失败之后进⾏回滚操作

  3. 更新 BufferPool 中的数据

  4. 记录 redo ⽇志(prepare状态):⽅便 MySQL 宕机之后从redo⽇志中将 BufferPool 中数据进⾏恢复

  5. 记录 binlog ⽇志: ⽅便数据归档

  6. 记录 redo ⽇志记录提交标记(commit 状态),此时redo⽇志和binlog⽇志数据⼀致 (两阶段提交

    其中 redo ⽇志采⽤顺序IO进⾏记录写⼊,效率堪⽐内存

  7. 对于数据持久化,MySQL会开启后台线程定时BufferPool中刷到磁盘

  • 对于数据库的增删改查都是直接操作BufferPool的
  • 对于BufferPool缓存机制主要采⽤的是LRU算法,⼀般将BufferPool设置为物理内存的 60% 左右即可

为什么 MYSQL 不直接操作磁盘⽽是设置 BufferPool 缓存机制来执⾏ SQL

  • 对于磁盘的随机读写不如直接操作内存,更新内存的性能是极⾼的,可以抗下⾼并发
  • 这套机制能保证每个更新请求都是直接操作BufferPool,然后顺序IO写⽇志⽂件,同时还能保证异常情况的数据⼀致性

两阶段提交

为了保证事务提交后,redo⽇志和binlog⽇志数据⼀致

  • 将 redo log 的写入拆成了两个步骤:prepare 和 commit,这就是"两阶段提交"。
  • prepare 阶段:将 redo log 对应的事务状态设置为 prepare,然后将 redo log 刷新到硬盘;
  • commit 阶段:将 binlog 刷新到磁盘,接着调用引擎的提交事务接口,将 redo log 状态设置为 commit(将事务设置为 commit 状态后,刷入到磁盘 redo log 文件);

更新语句执行过程

更新语句执行流程如下:分析器、权限校验、执行器、引擎、redo log(prepare状态)、binlog、redo log(commit状态)

update user set name = 'barry' where id = 1;

  • 先查询到 id 为1的记录,有缓存会使用缓存。
  • 拿到查询结果,将 name 更新为'barry',然后调用引擎接口,写入更新数据,innodb 引擎将数据保存在内存中,同时记录redo log,此时redo log进入 prepare状态。
  • 执行器收到通知后记录binlog,然后调用引擎接口,提交redo log为commit状态。更新完成。

记录完 redo log,不直接提交,而是先进入 prepare 状态?

假设先写redo log直接提交,然后写binlog,写完redo log后,机器挂了,binlog日志没有被写入,那么机器重启后,这台机器会通过redo log恢复数据,但是这个时候binlog并没有记录该数据,后续进行机器备份的时候,就会丢失这一条数据,同时主从同步也会丢失这一条数据

主重同步

MySQL主从复制原理

  • 主节点的binlog线程会记录数据修改的操作然后写⼊binlog
  • 从节点会通过io线程去拉取主节点的binlog到⾃⼰的replylog中
  • 然后从节点执⾏replylog中的语句进⾏数据同步

为什么要做主从同步

  • 读写分离,让数据库⽀撑更⾼的并发
  • 数据备份,保证数据的安全

分库分表

  • 分库分表主要是为了解决数据量过⼤⽽导致性能降低的问题,可以通过分库或分表来提升数据库的性能,在阿⾥规范中有说道数据量达到500w或者占⽤磁盘空间达到2G就需要进⾏分库分表,常⻅的分库分表组件有ShardingSphereMyCat,对于数据分⽚分为垂直分⽚和⽔平分⽚
  • 垂直分⽚主要是从业务⻆度将表中数据拆分到不同的库中来解决数据⽂件过⼤的问题,但本质上不能解决数据查询效率慢的问题
  • ⽔平分⽚主要是从数据库⻆度出发将表中数据拆分到多个表或库中,能解决数据查询效率慢的问题,但是存在很多问题如分布式事务

Redis

架构

单线程为什么这么快

  • ⽹络IO、键值的读写、指令执⾏: 单线程
  • 持久化、异步删除、集群数据同步:多线程

  • 原因

    • 内存读写
    • 无锁竞争、上下文切换、keys 要少用
    • epoll:让单线程循环遍历描述符,⼀旦描述符操作就绪就会⽴刻通知线程去执⾏相应的读写操作,减少了⽹络IO的开销
    • 数据结构优化

为什么要引⼊多线程

Redis对于⼤部分场景使⽤单线程进⾏⽹络IO和键值读写执⾏已经⾜够了,但是对于⼀些特定的场景处理的数据量还完全不够

所以引⼊多线程可以充分的发挥多核处理器的性能来帮助处理⼤量⽹络IO读写操作,但是指令的执⾏还是交给⼀个线程去做的

什么是IO多路复⽤

  • IO多路复⽤就是通过⼀种机制实现监听多个描述符,⼀旦描述符就绪就会通知程序去执⾏相应的读写操作

  • 常⻅的有select poll epoll

    • selcet底层采⽤的是数组,每次调⽤都会遍历,连接数有上限
    • poll底层采⽤的是链表,每次调⽤都会遍历,连接数⽆上限
    • epoll底层采⽤的是hash表,⼀旦事件就绪就会通知去执⾏回调,连接数⽆上限
  • 当连接数少但⽐较活跃的情况下,select和poll机制⽐较合适

  • 因为epoll的事件通知机制会有⼤量的回调,所以适合⼤量连接的情况下

数据结构

常⻅数据类型

  • Redis的key和value都是保存在dict中,数据保存在两个dict中,有两个dict的原因是为了做扩容,采⽤渐进式rehash的⽅式,当hash取模定位到某个桶的时候,就将这个桶移动到新的dict中,这样就不会让所有的数据⼀次性迁移完⽽影响性能

    • 对于key是⽤数组存储,根据key进⾏hash运算得到数组下标,如果产⽣hash碰撞,采⽤链表进⾏存储
    • 对于value采⽤ptr指针指向数据地址
  • string 类型,可以⽤来存储字符串或JSON字符串等

    • 应⽤场景:有分布式session、分布式锁

    • 常⽤的指令:有set、get、setnx 、setex等

    • 底层数据结构

      • 采⽤动态字符串sds实现
  • hash 类型,基于键值对存储的集合,⽐string 类型更好管理以及节省内存

    • 应⽤场景:有购物⻋

    • 常⽤的指令:有hset、hget、hgetall、hdel、hincrby等

    • 需要注意的是不能针对field设置过期时间,在集群模式下不建议⼤规模使⽤

    • 底层数据结构

      • 采⽤字典 dict 实现,当数据量⽐较⼩时,采⽤压缩列表ziplist存储 ,数据⼤⼩和元素数量阈值可以参数设置
  • list 类型,基于链表实现的⽆序可重复集合

    • 应⽤场景:有微信公众号消息流、栈、队列、阻塞队列、发布订阅

    • 常⽤的指令lpush、lpop、rpush、rpop、blpop、brpop、lrange

    • 其中栈的实现可以通过lpush+lpop实现、队列可以通过lpush+rpop实现、阻塞队列可以通过lpush+brpop实现

    • 底层数据结构

      • 采⽤ quicklist 双向队列和压缩列表 ziplist 实现
  • set 类型,⽆序不可重复集合

    • 应⽤场景:主要有抽奖、点赞收藏关注、共同关注、可能认识的⼈

    • 常⽤的指令有sadd、srem、smembers、scard、srandmember、sismember、spop、sinter、sunion、sdiff、sinterstore、sdiffstore

    • 抽奖可以使⽤srandmember实现、共同关注可以使⽤sinter实现、可能认识的⼈可以使⽤sdiff实现

    • set类型不适合存放⼤量数据建议5k以下,如果超过5k会导致性能下降,如果遇到这种情况可以进⾏将key拆分为多个key进⾏存储数据

    • 底层数据结构

      • 采⽤⼀个value为null的字典dict实现
  • zset,有序不可重复集合

    • 应⽤场景有排⾏榜

    • 常⽤指令zadd、zrem、zsore、zincrby、zrange、zreverange、zrangebyscore、zreverangescore、zunionscore、zinterscore

    • 排⾏榜可以通过zrange/zreverange/zunionscore实现

    • 如果需要进⾏数据分⻚可以通过zrange或zreverange进⾏实现

    • 如果需要实现让某元素过期删除可以将score存放具体过期时间,然后通过定时任务去获取最近到期的元素进⾏删除

    • 底层数据结构( 重点加分 )

      • 采⽤字典dict和跳表skiplist 实现,当数据量⽐较⼩是,采⽤压缩列表ziplist存储

      • 压缩列表 本身就是⼀个数组,只是增加了列表的⻓度、尾部偏移量、列表元素个数以及列表结尾的标志,这样就有利于快速的寻找列表的头、尾节点,但对于寻找⾮头尾元素效率不是很⾼效,只能⼀个个遍历

      • 跳表 就是在链表的基础上增加了多级索引,通过多级索引位置的转跳,实现快速查找元素

        • 问题 : 跳表是如何查找某个元素的

          • 每隔⼀个元素建⽴⼀个索引,通过建⽴多个索引,利⽤⼀次索引定位到需要查询的元素,如果觉得慢,可以在⼀级索引的基础上建⽴⼆级索引,依次类推,在多级索引之间来回转跳实现快速定位,当数据量特别⼤的时候,查找时间复杂度为O(logN),因为它本身的思想就类似⼆分查找
        • 问题 : 为什么会有跳表

          • 因为普通链表查找的时间复杂度为O(N),⽽跳表查找的时间复杂度为O(logN),查找的速度会更快,并且删除插⼊的时间复杂度也是O(logN)
        • 问题 : 为什么⽤跳表⽽不⽤红⿊树或者⼆叉树

          • 红⿊树和⼆叉树查找的时间复杂度也是O(logN),不⽤的原因主要是zset有⼀个很核⼼的操作就是范围查找,跳表的范围查找效率⽐它们的⾼,跳表可以做到时间复杂度O(logN)快速查找,也就是可以找到区间的起点,然后⼀次往后遍历就能定位到
          • 另外就是跳表的实现⽐红⿊树或⼆叉树的简单也更容易实现,可以有效的控制跳表的索引层级来控制内存的消耗
        • 问题 : 什么时候采⽤压缩列表

          • 有序集合保存的元素数量⼩于128或者有序集合保存的所有元素⻓度⼩于64字节就采⽤压缩列表
  • bitmap,可以看作⼀个数组只存0和1,数组的下标对应的是偏移量

  • 应⽤场景有⽉打卡、⽉活跃数

  • ⽉打卡可以通过将当前第⼏天作为偏移量 ,如果打卡对应的位置为1,反之为0

redis 五种数据类型的应用场景:

  • String 类型的应用场景:缓存对象、常规计数、分布式锁、共享session信息等。
  • List 类型的应用场景:消息队列(有两个问题:1. 生产者需要自行实现全局唯一 ID;2. 不能以消费组形式消费数据)等。
  • Hash 类型:缓存对象、购物车等。
  • Set 类型:聚合计算(并集、交集、差集)场景,比如点赞、共同关注、抽奖活动等。
  • Zset 类型:排序场景,比如排行榜、电话和姓名排序等。

Redis 后续版本又支持四种数据类型,它们的应用场景如下:

  • BitMap(2.2 版新增):二值状态统计的场景,比如签到、判断用户登陆状态、连续签到用户总数等;
  • HyperLogLog(2.8 版新增):海量数据基数统计的场景,比如百万级网页 UV 计数等;
  • GEO(3.2 版新增):存储地理位置信息的场景,比如滴滴叫车;
  • Stream(5.0 版新增):消息队列,相比于基于 List 类型实现的消息队列,有这两个特有的特性:自动生成全局唯一消息ID,支持以消费组形式消费数据。

布隆过滤器

bitmap、hash、内存少

误判、不能删,获取原数据

  • 基于bitmap实现的

    • 添加数据时,将数据进⾏hash运算得到hash值对应bit位,将该bit位改为1 hash可以定义多个,如果⼀个数据添加会将多个bit位改为1,多个hash的⽬的是减少hash碰撞的概率
    • 查询数据时,通过hash运算得到hash值,对应到bit位,如果有⼀个为0,说明数据不存在,如果都为1,表示可能存在
  • 优点:就是占⽤内存⼩、增加和查询的时间复杂度为O(K) K为hash函数的个数、不需要存储元素

  • 缺点:就是存在误判、不能获取原元素、不能从布隆过滤器中删除元素

键和数据库管理

获取所有key怎么做

  • ⾸先可以想到就是使⽤keys命令去获取所有key,但是这种形式只适合数据量较少的情况,倘若数据量过⼤会导致单线程阻塞的时间过⻓影响其他指令的执⾏
  • 所以当遇到⼤数据量的情况建议使⽤渐进式遍历scan的⽅式获取所有key,每次scan都会返回⼀个游标,根据这个游标可以进⾏下⼀轮遍历,所以可以采⽤递归遍历的形式获取所有key
  • scan有个不好的地⽅就是可能会遍历到重复key,所以建议放到⼀个不可重复元素的集合中处理

持久化

持久化机制

  • 持久化机制就是将数据持久化到磁盘中防⽌服务宕机⽽导致数据丢失
  • Redis提供的持久化⽅式有2种分别是RDB和AOF

    • RDB持久化会根据将内存中的数据写⼊到指定路径的dump.rdb⽂件中,然后当Redis重启时会通过这个⽂件进⾏数据恢复,⽽bgsave是触发RDB持久化的主流⽅式,fork主线程会开启⼀个bgsave⼦线程读取内存中的数据写⼊到rdb⽂件中,如果fork主线程只是读并不会影响到bgsave⼦线程,如果fork主线程是写,会⽣成⼀份数据副本让bgsave⼦线程将副本中的数据写⼊rdb⽂件,不会影响fork主线程写
    • AOF持久化会将每次执⾏的写命令写⼊到aof⽂件中,然后当Redis重启时会通过这个⽂件进⾏数据恢复
  • 缺点:重启Redis时很少使⽤RDB来恢复内存数据,因为会丢失⼤量数据,通常会选择AOF⽅式进⾏持久化,但是AOF⽅式持久化会⽐RDB⽅式慢很多

  • 所以Redis为了解决这个问题,提供了混合持久化的⽅式,RDB作全量持久化、AOF作增量持久化存放在⼀起

持久化⽅式如何选择

  • 如果数据不敏感,可以不开启持久化
  • 如果数据⽐较重要并且允许⼏分钟的数据丢失,可以使⽤RDB
  • 如果作为内存数据,建议都开启RDBAOF这两种⽅式,优先会从aof⽂件中进⾏数据恢复,因为aof⽂件数据更完整

主从复制原理

  • 从节点不管是不是第⼀次连接主节点都会向主节点发送psync指令
  • 如果是第⼀次连接就会开启bgsave⼦线程进⾏全量复制并且⽣成rdb⽂件,在持久化期间主节点还会接收从节点发来的写命令并且放到缓存中,持久化结束后会将rdb⽂件发送到从节点,从节点会将rdb⽂件写⼊磁盘,然后从磁盘中读取数据到内存中
  • 如果从节点与主节点之间发⽣了⽹络波动,从节点会主动重连,连接主节点之后,主节点会将部分缺失的数据发送给从节点

集群⽅案

  • Sentinel哨兵模式

    • 由于主从架构⽆法进⾏故障转移,⽆法实现⾼可⽤并且配置复杂所以引出了哨兵模式
    • 客户端会先连接哨兵,哨兵会告知客户端主节点的地址,然后客户端会连接主节点进⾏后续的操作,当主节点宕机时,哨兵发现主节点宕机,会推选出哨兵的局部leader来进⾏故障转移也就是帮助主从节点进⾏主从切换
  • Cluster集群模式

    • 由于哨兵模式进⾏故障转移的时候会出现⽹络瞬断以及只有⼀个主节点⽆法实现⾼并发所以引出了集群模式
    • 集群模式是由多主多从组成的,能⾃我进⾏故障转移也就是⾃我主从选举,达到了⾼可⽤并且配置简单
    • 集群模式会将数据分散到16384个槽位中,每个节点会管理⼀部分槽位,当客户端来连接集群时会缓存⼀部分槽位信息⽅便定位到⽬标节点,但是缓存的槽位信息可能会与集群的槽位不⼀致,所以集群提供了纠错机制,当客户端通过槽位去定位节点时发现异常,集群会进⾏重定位并且缓存⼀份新的槽位信息给客户端
    • 集群模式⾄少需要2N+1个master才能过半选举机制
    • 集群节点之间的通信是采⽤的gossip协议,由于数据是分散存储的,时效性较弱

CAP 理论

  • C是⼀致性,分布式系统的数据要保持⼀致
  • A是可⽤性,分布式系统能进⾏故障转移
  • P是分区容错性,分布式系统出现⽹络问题能正常运⾏
  • CAP理论是指分布式系统中不能保证三者同时存在,只能两两组合对于CAP理论太过严格,在实际⽣产中更多使⽤的是Base理论,也就是不需要保证数据的强⼀致性,只要保证最终⼀致性就⾏

缓存雪崩、缓存击穿、缓存穿透 (项目)

  • 缓存雪崩,由于⼤量的key在同⼀时间失效,导致流量直接打到数据库,导致数据库宕机

    • 解决⽅案

      • 可以将key的过期时间设置随机值,避免同⼀时间过期
      • 并发量不多的时候可以采⽤加锁排队
      • 给每⼀个缓存数据加⼀个缓存标记来记录缓存是否失效,如果失效就更新
  • 缓存击穿,⼤量⽤户访问某个key时,这个key刚好失效,导致流量直接打到数据库,导致数据库宕机

    • 解决⽅案

      • 设置热点数据永不过期
      • 加互斥锁
  • 缓存穿透,⽤户频繁使⽤缓存和数据库中不存在的数据进⾏访问,导致流量直接打到数据库,导致数据库宕机

    • 解决⽅案

      • 接⼝层增加校验
      • 如果缓存中不存在该值,就缓存空值到缓存中
      • 使⽤布隆过滤器,布隆过滤器是⼀个位图,如果它说不存在就⼀定不存在,如果说存在只能是可能存在,可以将可能存在的key放⼊bitmap进⾏过滤

热点缓存并发重建 (项目)

  • 冷数据突然变为热数据,当处于⾼并发场景下,重建缓存不是短时间能完成的,所以为了减少重建缓存的次数可以使⽤DCL机制

    • 可以先查询⼀次如果有缓存就返回,如果没有就加锁,在加锁后再查询⼀次,如果有就直接返回
    • 如果没有就进⾏重建⼯作,多次查询为了保证当有线程已经完成了重建⼯作⽽其他线程⽆需多次进⾏缓存重建
  • 对于缓存重建可能因为突发性热点访问导致系统压⼒暴增,所以需要提升系统承受的并发量可以使⽤"串⾏变并⾏"的思想来解决,让多个线程尝试获取锁⼀段时间,倘若缓存已重建好就能让多个线程同时拿到缓存返回

删除与淘汰策略

过期键的删除策略

  • 惰性删除

    • 只有当访问⼀个key的时候,才会判断的当前key是否已过期,已过期就会删除
    • 这个策略可以节省CPU资源,但是占⽤内存,可能因为⼤量的key不被再次访问,导致⼀直不清楚从占⽤内存
  • 定期删除

    • 每隔⼀段时间会扫描⼀定数量的过期key,并且清除已过期的key
    • 这个策略属于折中⽅案,可以有效的平衡CPU资源以及内存资源
  • 强制删除

    • 当已使⽤内存超过Redis最⼤允许内存,会触发内存淘汰策略
  • Redis中同时使⽤了惰性删除和定期删除

淘汰算法

  • LRU

    • 最近最少使⽤,根据时间区分
  • LFU

    • 最近不经常使⽤,根据使⽤频率区分

内存淘汰策略有哪些

  • 默认策略 noeviction 当内存不⾜以容纳新写⼊数据时,新写⼊操作会报错

  • 针对设置过期时间的 key

    • volatile-lru,按照LRU算法删除
    • volatile-lfu,按照LFU算法删除
    • volatile-radom,随机删除
    • volatile-ttl,按过期时间顺序删除
  • 针对所有key

    • allkeys-lru,按照LRU算法删除
    • allkeys-lfu,按照LFU算法删除
    • allkeys-random,随机删除

数据库与缓存双写一致性问题(项目)

  • 延时双删,先删除缓存,再写⼊数据库,延时500ms,再删除缓存

    • 问题 : 为什么要延时500ms

      • 为了我们在第⼆次删除缓存之前,能完成数据库的更新操作,保证数据库的值最新
    • 问题 : 为什么要两次删除缓存

      • 第⼀次删除缓存是为了更新数据,保证数据库的值是最新,第⼆次是为了保证拿到缓存数据是最新的
      • 如果不进⾏第⼆次删除缓存,可能查到的是未修改的缓存数据,进⾏第⼆次删除之后,会从数据库中重新查,保证了数据的⼀致性

结论

想要保证数据库和缓存一致性,推荐采用「先更新数据库,再删除缓存」方案,并配合「消息队列」或「订阅变更日志」的方式来做

  1. 引入缓存后,需要考虑缓存和数据库一致性问题,可选的方案有:「更新数据库 + 更新缓存」、「更新数据库 + 删除缓存」
  2. [更新数据库 + 更新缓存]方案,在「并发」场景下无法保证缓存和数据一致性,且存在「缓存资源浪费」和「机器性能浪费」的情况发生
  3. 在[更新数据库 + 删除缓存]的方案中,「先删除缓存,再更新数据库」在「并发」场景下依旧有数据不一致问题,解决方案是「延迟双删」,但这个延迟时间很难评估,所以推荐用「先更新数据库,再删除缓存」的方案
  4. 在「先更新数据库,再删除缓存」方案下,为了保证两步都成功执行,需配合「消息队列」或「订阅变更日志」的方案来做,本质是通过「重试」的方式保证数据一致性

Redis 分布式锁

  • 分布式锁主要是⽤来保证分布式系统数据同步操作的,可以通过让多个操作的串⾏执⾏来保证整体的原⼦性

  • ⼀般情况下可以想到⽤setnx来实现分布式锁,但是考虑⼀个问题,倘若客户端宕机会导致key⽆法删除变成死锁

  • 对于这个问题,可以想到⽤过期时间来解决,所以Redis提供了另外⼀个指令setex,但是这个⼜出了⼀个问题就是这个指令不能判断是否获取到了锁,所以也不是最佳的⽅案

  • 之后Redis提供了组合指令set ..ex ..nx实现了带过期时间的分布式锁解决了这个问题

  • 当我们去使⽤这个分布式锁的时候需要考虑到释放锁的问题,并不是直接在加锁之后释放锁,需要保证在异常情况下也能实现⾃动释放锁,所以这⾥可以放在finally⾥进⾏显示的释放锁避免死锁的问题

  • 然后⼜会出现⼀个问题,如果锁key是同⼀个key会导致后⾯的线程删除前⾯线程的锁,所以不可以将key设置成相同的key

  • 然后⼜出现⼀个问题,如果锁的时间⼩于业务执⾏时间会导致前⾯的线程删除相同业务ID线程的锁,所以需要考虑到锁续命的问题

  • 所以我们需要考虑到很多⽅⾯的问题,所以对于复杂的操作可以配合lua脚本来实现⼀个完善的分布式锁,⽽市⾯上刚好有完善的框架Redisson帮助我解决了这些问题,Redisson是基于lua脚本实现的,它的⼤致流程

    • 当某个线程抢到了锁,倘若业务还没有执⾏完,会定时去进⾏锁续命,⽽那些没有抢到所得线程会订阅抢到锁线程的channel,然后它们会⾃旋⼀定时间去尝试获取锁,获取锁失败后会被安排到阻塞队列中阻塞,⼀旦抢到锁的线程释放锁,它们就会被通知到然后持续唤醒出队继续去抢锁
  • 分布式锁该如何实现集群模式下的⾼可⽤,在集群模式下会出现⼀个问题就是如果master中的锁key在同步给slave的过程中突然宕机,slave发现master宕机,然后正好⾃⼰被选举为新master,在新master同步原master数据的时候,此时原master⼜突然恢复了,数据还没开始清空,就会出现多把锁的情况,对于这个问题Redisson提供了redLock,是从CP机制⻆度去实现的,只有当过半以上的节点加锁成功,才算加锁成功,已经违背了Redis的AP机制,如果⾮要考虑⼀致性问题,可以考虑使⽤ZK去实现分布式锁

框架

Spring

什么是 Spring

  • Spring是⼀个企业级Java应⽤框架,主要的作⽤是简化软件的开发以及配置过程,简化项⽬部署环境
  • Spring的优点

    • 低侵⼊设计,对业务代码的污染度⾮常低
    • DI机制将对象之间的关系交给框架处理,减少组件的耦合性
    • 提供AOP技术,⽀持将⼀些通⽤的功能进⾏集中式管理,从⽽提供更好的复⽤
    • 对于主流框架提供了⾮常好的⽀持

谈⼀下 IOC 的理解

IOC主要涉及三个部分,分别是IOC容器、控制反转以及依赖注⼊

  • IOC容器

    • Spring会通过包扫描将相关的Bean注册成BeanDefinition存放BeanDefinitionMap,当需要获取对象的时候,会通过这个Map拿出指定Bean的BeanDefinition通过反射去创建返回
  • 控制反转

    • 对象创建由主动创建变为了被动创建,全部交给了BeanFactory统⼀创建
  • 依赖注⼊

    • IOC容器会将对象的依赖关系进⾏依赖动态注⼊,也就是有依赖关系的属性初始化前进⾏赋值

谈⼀下 AOP 的理解

  • AOP就是⾯向切⾯编程,跟OOP⾯向过程编程相对,AOP⼀般⽤于将公共逻辑和业务逻辑进⾏拆分,可以减少代码间的耦合性

  • AOP的实现⽅式主要有基于CGLIB动态代理和基于JDK动态代理

    • 基于CGLIB动态代理是基于⽗⼦类实现的,主要是通过被代理的类⽣成⼀个代理⼦类,代理⼦类重写⽗类⽅法,并且将被代理类赋值给内部属性target,当执⾏完切⾯逻辑后,通过target执⾏被代理类⽅法
    • 基于JDK动态代理是基于接⼝实现的,实现InvocationHandler和Proxy接⼝就⾏
  • AOP在我们业务中应⽤场景主要有⽇志处理、限流处理、事务、异步、缓存等

ApplicationContext 和 BeanFactory 的区别

  • BeanFactory是Spring中⾮常核⼼的组件,专⻔⽤来⽣成Bean、管理Bean的
  • ApplicationContext继承于BeanFactory,所以具有BeanFactory所有功能,并且ApplicationContext还集成了⽐如EnvironmentCapable、MessageSource、ApplicationEventPublisher等接⼝,从⽽具备了获取系统环境变量、国际化、时间发布等功能

谈⼀下 Spring 启动

  • Spring启动我们可以通过AnnotationConfigApplicationContext来进⾏跟踪

    • 通过调⽤AnnotationConfigApplicationContext的构造⽅法进⾏初始化reader和sacnner

    • 通过scan扫描指定包路径下的class⽂件并注册成BeanDefinition放到BeanDefinitionMap

    • 调⽤核⼼⽅法refresh进⾏刷新容器

      • prepareRefresh进⾏刷新前准备
      • obtainFreshBeanFactory进⾏获取容器刷新时的Bean⼯⼚
      • prepareBeanFactory进⾏Bean⼯⼚的初始化预处理
      • postProcessBeanFactory进⾏处理⼦类重写该⽅法的逻辑,对Bean⼯⼚进⼀步处理
      • invokeBeanFactoryPostProcessors进⾏执⾏Bean⼯⼚后置处理器
      • registerBeanPostProcessors进⾏注册Bean后置处理器
      • initMessageSource进⾏初始化国际化
      • initAppplicationEventMulticaster进⾏初始化事件⼴播器
      • onRefresh进⾏⼦类重写该⽅法,留给⼦容器
      • registerListeners进⾏注册监听器
      • finishBeanFactoryInitialization完成Bean⼯⼚初始化
      • finishRefresh结束容器刷新

什么是 Bean 装配

  • Bean装配是指在 Spring 容器中把 Bean 组装到⼀起,前提是容器需要知道 Bean 的依赖关系,如何通过依赖注⼊来把它们装配到⼀起

什么是 bean 的⾃动装配

  • 在Spring框架中,在配置⽂件中设定Bean的依赖关系是⼀个很好的机制,Spring 容器能够⾃动装配相互合作的bean,这意味着容器不需要和配置,能通过Bean⼯⼚⾃动处理Bean之间的协作,这意味着 Spring可以通过向BeanFactory中注⼊的⽅式⾃动搞定Bean之间的依赖关系
  • ⾃动装配可以设置在每个Bean上,也可以设定在特定的Bean上

Bean 的创建过程

简单来说,Bean主要经过四个阶段,分别是实例化、依赖注⼊、初始化、销毁

具体来说,讲下 Spring 的 Bean 的⽣命周期

Bean 生命周期

img

  1. 调用bean的构造方法创建Bean
  1. 通过反射调用setter方法进行属性的依赖注入
  1. 如果Bean实现了BeanNameAware接口,Spring将调用setBeanName(),设置 Bean的name(xml文件中bean标签的id)
  1. 如果Bean实现了BeanFactoryAware接口,Spring将调用setBeanFactory()把bean factory设置给Bean
  1. 如果存在BeanPostProcessor,Spring将调用它们的postProcessBeforeInitialization(预初始化)方法,在Bean初始化前对其进行处理
  1. 如果Bean实现了InitializingBean接口,Spring将调用它的afterPropertiesSet方法,然后调用xml定义的 init-method 方法,两个方法作用类似,都是在初始化 bean 的时候执行
  1. 如果存在BeanPostProcessor,Spring将调用它们的postProcessAfterInitialization(后初始化)方法,在Bean初始化后对其进行处理
  1. Bean初始化完成,供应用使用,这里分两种情况:

    • 如果Bean为单例的话,那么容器会返回Bean给用户,并存入缓存池。如果Bean实现了DisposableBean接口,Spring将调用它的destory方法,然后调用在xml中定义的 destory-method方法,这两个方法作用类似,都是在Bean实例销毁前执行。
    • 如果Bean是多例的话,容器将Bean返回给用户,剩下的生命周期由用户控制。
 public interface BeanPostProcessor {
     @Nullable
     default Object postProcessBeforeInitialization(Object bean, String beanName) {
         return bean;
     }
     @Nullable
     default Object postProcessAfterInitialization(Object bean, String beanName){
         return bean;
     }
 }
 ​
 public interface InitializingBean {
     void afterPropertiesSet();
 }

谈⼀下依赖注⼊流程

Autowirespring的注解。默认情况下@Autowired是按类型匹配的(byType)。如果需要按名称(byName)匹配的话,可以使用@Qualifier注解与@Autowired结合。@Autowired 可以传递一个required=false的属性,false指明当userDao实例存在就注入不存就忽略,如果为true,就必须注入,若userDao实例不存在,就抛出异常

Resourcej2ee的注解,默认按 byName 模式自动注入。@Resource有两个中重要的属性:name和type。name属性指定bean的名字,type属性则指定bean的类型。因此使用name属性,则按byName模式的自动注入策略,如果使用type属性,则按 byType模式自动注入策略。倘若既不指定name也不指定type属性,Spring容器将通过反射技术默认按byName模式注入。

Autowire 依赖注⼊流程

  • 依赖注⼊流程主要分为两种

    • @Autowired 注解的⽅法
    • @Autowired 注解的属性
  • ⾸先会根据注⼊点去找 Bean

    • 如果注解了的是属性,会先根据属性的类型去找Bean
    • 如果注解的是⽅法,会先根据⽅法参数的类型去找Bean
  • 如果找到了单个Bean就会直接赋值返回
  • 如果找到了多个Bean,会从多个Bean中确定⼀个Bean

    • 会先判断是否是isAutowiredCandidate再判断是否配合了@Qulifier,如果能确定就返回

    • 如果加了@Primary,就返回加了这个注解的Bean

    • 如果没有加@Primary,就会取优先级最⾼的Bean

    • 如果以上还不能确定,就根据名字筛选

      • 如果注解了的是属性,会通过属性名去确定⼀个Bean
      • 如果注解了的是⽅法,会通过参数名去确定⼀个Bean
    • 如果根据名字不能找到唯⼀的Bean就会再看@Autowired的required是否为true

      • 如果是true就报错
      • 如果是false就返回
 public class UserServiceImpl implements UserService {
     // 标注成员变量
     @Autowired
     @Qualifier("userDao")
     private UserDao userDao;   
  }

@Autowired 和 @Resource 的区别

  • @Resouce 在没有指定别名的情况下,@Autowired和@Resource都是先byType再byName
  • @Resouce 在指定别名的情况下是先byName再byType

Bean 的作⽤域

singleton,每次注⼊只会创建⼀次

prototype,每次注⼊都会创建⼀次

request,每次请求都会创建⼀次

session,每个session内只会创建⼀次,随着session过期

global_session,全局session只会创建⼀次,随着全局session过期

Bean 如何保证线程安全

  • Spring对Bean没有提供线程安全策略,所以Spring中的Bean是线程不安全的,如果想要保证Bean的线程安全需要从Bean的作⽤域来看

    • 对于prototype、request作⽤域每次都会创建⼀个新对象,肯定是没有线程安全问题的
    • 对于singleton,对于绝⼤多数情况下,Bean是属于⽆状态的,所以平常的MVC开发是不存在线程安全的问题的,但是并不能真正意义上的线程安全,对于⽆状态是指实例没有属性对象,不进⾏保存数据,是⼀个不变的类,⽐如controller、service、dao,对于有状态是指实例有属性对象,可以保存数据,⽐如pojo
    • 所以如果要保证线程安全,可以将Bean作⽤域改成prototyperequest,或者使⽤ThreadLocal来保存线程变量副本,保证线程安全

如何进⾏Bean销毁

  • 实现了 DisposableBean 接⼝
  • 实现了 AutoCloseable 接⼝
  • BeanDefinition中是否指定了 destroyMethod
  • 拥有 @PreDestroy 注解了的⽅法

循环依赖如何解决

  • 循环依赖就是多个对象之间存在属性相互依赖的问题,也就是先有鸡还是先有蛋的问题
  • 如何解决循环依赖问题

    • 可以通过@Lazy注解解决构造⽅法造成的循环依赖问题

    • 可以使⽤Spring提供的三级缓存来解决

      • 三级缓存

        • ⼀级缓存singletonObjects,⽤来存放完整⽣命周期的对象
        • ⼆级缓存earlySingletonObjects,⽤来存放半成品对象,也就是初始化的对象
        • 三级缓存singletonFactories,⽤来存放ObjectFactroy,根据不同的情况创建对象
      • 对于处理对象之间普通的循环依赖,可以先将new出来的不完整对象放到⼆级缓存中,当其中⼀个循环依赖的对象完成⽣命周期放⼊⼀级缓存之后,由于放⼊缓存中对象存在引⽤关系,不完整的对象也完整了,然后另外⼀个对象也能正常⾛完⽣命周期了
      • 对于处理对象之间有AOP的循环依赖,⼀级缓存和⼆级缓存就完全不够了,三级缓存会保存对象的代理配置信息,当发现产⽣循环依赖就会通过ObjectFactory进⾏创建动态代理类,提前进⾏AOP,相应的⼀级缓存存放的是⼀个代理对象
      • 核⼼代码位置DefaultSingletonBeanRegistry#getSingleton

Spring 事务

Spring 如何处理事务

Spring中提供了两种⽅式,分别是编程式事务和声明式事务

  • 编程式事务,使⽤TransactionTemplate,可以很好的控制事务粒度
  • 声明式事务,基于AOP实现的注解@Transactional,只能应⽤于⽅法,不能很好的控制事务粒度

什么时候 @Transactional 注解失效

  • @Transactional 注解是基于AOP实现也就是基于CGLIB动态代理实现的
  • 如果要注解⽣效

    • 需要让代理对象去执⾏⽅法才会⽣效,反之让被代理对象去执⾏⽅法就会失效
    • 需要让⽅法是公开的,因为CGLIB代理是基于⽗⼦类实现的,需要重写⽗类⽅法,反之⽆法重写⽅法就会失效
  • 所以⽆法很好的利⽤ @Transactional,会导致 @Transactional 失效

    • 检测异常
    • try-catch
    • aop 切面顺序
    • 非 public
    • bean 没被 spring管理
    • 调用自己本类方法

谈⼀下Spring事务机制

  • 编程式、声明式事务,方便用户业务逻辑操作
  • Spring中的事务机制主要是通过数据库事务AOP进⾏实现的

  • Spring会为加了@Transactional注解的Bean⽣成⼀个代理对象作为Bean

  • 当调⽤代理对象的⽅法的时候会先判断是否加了 @Transactional 注解

    • 如果加了注解,4 步:利⽤事务管理器创建⼀个数据库连接,并且修改⾃动提交为 false

      然后执⾏当前⽅法中的 SQL,如果出现了异常就会进⾏事务回滚,否则提交事务

  • Spring 中的事务隔离级别和数据库中的事务隔离级别⼀致,传播⾏为是 Spring ⾃⼰实现的,但是是基于数据库连接来做的,⼀个数据库连接⼀个事务,如果需要新开⼀个事务,就会新建⼀个数据库连接,在这个连接上执⾏SQL

事务隔离级别

required:默认,如果当前没有事务,则新建一个事务,如果当前存在事务,则加入这个事务 (增删改)

supports: 如果当前没有事务,则以非事务的方式执行, 当前存在事务,则加入当前事务(select)

required_new:不管什么都创建一个新事务,如果存在当前事务,则挂起该事务 (看业务)

not_supported: 以非事务方式执行,如果存在当前事务,则挂起当前事务

mandatory: 当前存在事务,则加入当前事务,如果当前事务不存在,则抛出异常

never: 不使用事务,如果当前事务存在,则抛出异常

nested: 如果当前事务存在,则在嵌套事务中执行,否则required的操作一样

Spring 中的设计模式有哪些

  • 简单⼯⼚

    • 由⼀个⼯⼚类根据传⼊的参数,动态决定应该创建哪个实例
    • Spring中的BeanFactory就是简单⼯⼚模式的体现,根据传⼊⼀个唯⼀的标识来获得Bean对象,但是是否在传⼊参数后创建还是传⼊参数前创建这个要根据具体情况来定
  • ⼯⼚⽅法

    • 实现了FactoryBean接⼝的bean是⼀类叫做factory的bean,spring会在使⽤getBean()调⽤获得该bean时,会⾃动调⽤该bean的getObject()⽅法,所以返回的不是factory这个bean,⽽是这个bean.getOjbect()⽅法的返回值
  • 单例模式

    • 保证⼀个类仅有⼀个实例,并提供⼀个访问它的全局访问点
    • Spring中的单例模式完成了后半句话,即提供了全局的访问点BeanFactory,但没有从构造器级别去控制单例,这是因为spring管理的是任意的java对象
  • 动态代理

    • spring 的 aop 使用了动态代理,有两种方式JdkDynamicAopProxyCglib2AopProxy
    • 切⾯在应⽤运⾏的时刻被织⼊,⼀般情况下,在织⼊切⾯时,AOP容器会为⽬标对象创建动态的创建⼀个代理对象,Spring就是这样织⼊切⾯的
  • 适配器模式

    • Spring定义了⼀个适配接⼝,使得每⼀种Controller有⼀种对应的适配器实现类,让适配器代替controller执⾏相应的⽅法,这样在扩展Controller时,只需要增加⼀个适配器类就完成了SpringMVC的扩展了

Spring MVC

谈谈 SpringMVC 理解

  • Spring MVC是⼀个基于Java的实现了MVC设计模式 的请求驱动类型的轻量级Web框架
  • 通过把模型-视图-控制器分离,将web层进⾏职责解耦,把复杂的web应⽤分成逻辑清晰的⼏部分,简化开发,减少出错,⽅便组内开发⼈员之间的配合

SpringMVC 请求流程

  • DispatcherServlet 接收用户的请求

  • 遍历所有 HandlerMapping 找到用于处理 request 的 handler 和 Interceptors,构造成 HandlerExecutionChain 执行链

  • 找到 handler 相对应的 HandlerAdapter

  • 执行所有注册拦截器的 preHandler 方法

  • 调用 HandlerAdapterhandle() 方法处理请求,返回 ModelAndView

  • 倒序执行所有注册拦截器的postHandler方法

  • 请求视图解析器 ViewResolver 解析和视图渲染

    • 最后都会执行拦截器的 afterCompletion 方法
    • 如果控制器方法标注了 @ResponseBody 注解,则在执行时就会生成 json 结果,并标记 ModelAndView 已处理,这样就不会执行视图渲染

img

Mybatis

#{}和${}的区别是什么?

  • ${}是 Properties 文件中的变量占位符,它可以用于标签属性值和 sql 内部,属于静态文本替换,比如${driver}会被静态替换为com.mysql.jdbc.Driver
  • #{}是 sql 的参数占位符,MyBatis 会将 sql 中的#{}替换为? 号,在 sql 执行前会使用 PreparedStatement 的参数设置方法,按序给 sql 的 ? 号占位符设置参数值

Xml 映射文件中,除了常见的 select|insert|update|delete 标签之外,还有哪些标签?

<resultMap><parameterMap><sql><include><selectKey>

加上动态 sql 的 9 个标签, trim|where|set|foreach|if|choose|when|otherwise|bind

其中 <sql> 为 sql 片段标签,通过 <include> 标签引入 sql 片段, <selectKey> 为不支持自增的主键生成策略标签。

SpringBoot

什么是SpringBoot

  • SpringBoot是Spring 组件⼀站式解决⽅案,主要是简化了使⽤ Spring 的难度,能进⾏快速开发和整合、简化配置以及内嵌服务容器

常⽤注解

  • @SpringBootApplication

    • 这个注解标识了⼀个SpringBoot⼯程,它实际上是另外三个注解的组合

      • @SpringBootConfiguration

        • 这个注解实际就是⼀个@Configuration,表示启动类也是⼀个配置类
      • @EnableAutoConfiguration

        • 向Spring容器中导⼊⼀个Selector⽤来加载classpath下spring.factories中定义的⾃动配置类,将这些⾃动加载为配置Bean
      • @ComponentScan

        • 表示扫描路径,因为默认是没有配置实际扫描路径的,所以SpringBott扫描的路径是启动类所在的当前⽬录@Bean
  • @Bean

    • ⽤来定义Bean,类似于XML中<bean>,Spring在启动的时候会对这些@Bean注解的⽅法进⾏解析,将⽅法的名字作为beanName,并通过执⾏⽅法得到Bean对象

⾃动装配原理

  • ⾸先会通过import导⼊ DeferredImportSelector
  • 为什么要导⼊DeferredImportSelector

    • 这是因为为了顺序的⼀个考虑,它的加载顺序是最后的,把它放到最后才能进⾏定制我们⾃⼰的,⽽不是以它的Bean优先
  • 然后去扫描所有jar包中的spring.factories⽂件,把其中所有全类限定名封装成⼀个list,然后进⾏排序返回给Spring,然后Spring会将它们注册是BeanDeifnition放到BeanDefinitionMap中去,然后Spring就能管理到这些Bean了

RabbitMQ

RabbitMQ 的应⽤场景

  • 消息队列是⼀种先进先出的数据结构,是⼀种⽣产者消费者模型

  • 主要的应⽤场景

    • 削峰

      • 可以使⽤消息队列排队处理流量⾼峰
    • 解耦

      • 可以进⾏业务解耦
    • 异步

      • 提升系统的吞吐量和响应速度

⼯作队列模式

  • 简单模式(Simple)

    • ⼀个⽣产者对应⼀个消费者

    • 应⽤场景

      • 发送短信给指定的⼈
  • ⼯作队列模式(Work Queue)

    • ⼀个⽣产者对应多个消费者

    • 应⽤场景

      • ⼤量订单让多个消费者消费
  • 发布订阅模式(Publish/Subscribe)

    • ⽣产者发送消息到fanout交换机,交换机转发给订阅消息的消费者

    • 应⽤场景

      • 订阅天⽓预报的⼈都会收到通知
  • 路由模式(Routing)

    • ⽣产者发送消息到direct交换机,交换机根据路由key发送到对应的消费者

    • 应⽤场景

      • iphone⼿机促销接收到iphone的消息
  • 主题模式(Topic)

    • ⽣产则发送消息到topic交换机,交换机根据路由key匹配规则发送到对应的消费者

    • 应⽤场景

      • iphone⼿机促销接收到iphone的消息
  • RPC

    • 远程调⽤模式

死信队列和延迟队列

  • 消息成为死信的条件

    • 队列中消息达到限制
    • 消费者拒收消息并且消息不返回队列
    • 消息在队列中的存活时间超过设置的TTL
  • TTL 就是存活时间,当消息过期后还没有被消费会被清除,RabbitMQ可以对消息设置TTL以及整个队列
  • 如果配置了死信队列,会根据路由key将死信放⼊死信队列中,如果没有配置,死信会被直接丢弃
  • 对于延迟队列的实现可以通过TTL+死信队列的⽅式进⾏完成

RabbitMQ 如何保证消息可靠性传输

  • 可靠性传输的含义就是消息不能多也不能少

    • 消息不能多就是消息要保证幂等性

      • 对于消息幂等性的处理可以采⽤全局唯⼀ID的⽅式来解决

        • ⽣产者发送消息的时候携带⼀个全局唯⼀ID,消费者先从Redis中判断这个全局唯⼀ID是否存在,如果不存在,就进⾏消费然后放到Redis中进⾏存储
    • 消息不能就是消息要保证不丢失

      • 对于消息丢失的3个场景主要发⽣在⽣产者发送消息到MQ、MQ主从节点间的数据同步、MQ发送消息到消费者

      • 如何解决⽣产者发送消息到MQ期间消息丢失问题

        • 可以通过⽣产者消息确认机制,⽣产者多次确认来确保消息发送到MQ
        • 可以通过⼿动事务,开启事务、提交事务以及业务异常进⾏事务回滚,但是会阻塞消息导致吞吐量下降,造成MQ性能瓶颈
      • 如何解决MQ主从节点之间的数据同步期间消息丢失问题

        • RabbitMQ集群⼀般采⽤的是普通集群模式,数据存储是分散开的,并且节点间不会主动同步数据,所以想要消息不丢失,可以采⽤镜像集群模式,开启后节点间会主动同步数据,这样造成数据丢失的可能性就降低了很多
        • 对于数据存盘可以采⽤持久化队列
      • 如何解决MQ发送消息到消费者期间消息丢失问题

        • 对于RabbitMQ消费者提供了两种应答机制

          • ⾃动应答

            • ⾃动应答就是消息发送过来,消费者会⾃动消费,假如业务异常会进⾏多次重试,这样消息基本不会丢失,但是需要解决消息幂等性问题
          • ⼿动应答

            • ⼿动应答就是消息发送过来,需要我们⾃⼰根据业务处理完之后再进⾏确认消费,如果想要保证数据百分百不丢失可以这么做
            • 消息第⼀次消费失败后,我们可以将它放⼊死信队列并且记录⼀条消费⽇志,其中包含了重试次数,使⽤TTL设置整个队列等待30s,30s后再重新消费⼀次,当消费⽇志的重试次数达到3次,就会进⾏通知业务员⾃⼰⼿动处理这条消息,根据⽇志去分析问题原因

RabbitMQ 如何保证消息有序

对于RabbitMQ如果要保证消息有序,只能单消息 + 单队列的⽅式去解决,对于多队列处理多消息的场景并没有很好的⽅案

RabbitMQ 如何处理消息堆积

对于消息堆积可以从三个⽅⾯去考虑问题

  • ⽣产者端

    • 消费者消费消息的速度⼩于⽣产者⽣产的速度,倘若想解决,只能让两者速度持平,但是由于业务需求,这⾥基本不能动
  • 消费者端

    • 消费者单机消费消息的速度肯定是相对慢的,所以可以采⽤多消费者的⽅式来接提升消费速度
  • 服务端

    • 适当的提升服务器的配置,⽐如提升消费者端服务器的配置,提升吞吐量

Elasticsearch

Elasticsearch的倒排索引是什么?

面试官:想了解你对基础概念的认知。

解答:通俗解释一下就可以。

倒排索引是搜索引擎的核心。搜索引擎的主要目标是在查找发生搜索条件的文档时提供快速搜索。倒排索引是一种像数据结构一样的散列图,可将用户从单词导向文档或网页。它是搜索引擎的核心。其主要目标是快速搜索从数百万文件中查找数据。

传统的我们的检索是通过文章,逐个遍历找到对应关键词的位置。

而倒排索引,是通过分词策略,形成了词和文章的映射关系表,这种词典+映射表即为倒排索引。

有了倒排索引,就能实现o(1)时间复杂度的效率检索文章了,极大的提高了检索效率。

学术的解答方式:

倒排索引,相反于一篇文章包含了哪些词,它从词出发,记载了这个词在哪些文档中出现过,由两部分组成——词典和倒排表。

加分项:

倒排索引的底层实现是基于:FST(Finite State Transducer)数据结构。

lucene从4+版本后开始大量使用的数据结构是FST。FST有两个优点:

1)空间占用小。通过对词典中单词前缀和后缀的重复利用,压缩了存储空间;

2)查询速度快。O(len(str))的查询时间复杂度。

Spring Cloud

什么是微服务架构

  • 微服务架构就是将单体的应⽤程序分成多个应⽤程序 ,这多个应⽤程序就成为微服务,每个微服务运⾏在⾃⼰的进程中,并使⽤轻量级的机制通信

    • 这些服务围绕业务能⼒来划分,并通过⾃动化部署机制来独⽴部署
    • 这些服务可以使⽤不同的编程语⾔,不同数据库,以保证最低限度的集中式管理

什么是SpringCloud

  • SpringCloud是⼀系列框架的有序集合,它利⽤SpringBoot的开发便利性巧妙地简化了分布式系统基础设施的开发⽐如服务注册发现、智能路由、消息总线、负载均衡等,都可以利⽤SpringBoot的开发⻛格做到⼀键启动和部署
  • SpringCloud并没有重复造轮⼦,它只是将各家公司开发的⽐较成熟、经得起实际考验的是服务框架组合起来,通过SpringBoot⻛格进⾏再封装屏蔽掉了复杂的配置和实现原理,最终给开发者留出了⼀套简单易懂、易部署和易维护的分布式系统开发⼯具包

SpringCloud 常⽤组件

  • Eureka: 注册中⼼
  • Nacos: 注册中⼼、配置中⼼
  • Consul: 注册中⼼、配置中⼼
  • Spring Cloud Config: 配置中⼼
  • Feign/OpenFeign: RPC调⽤
  • Zuul: 服务⽹关
  • Spring Cloud Gateway: 服务⽹关
  • Ribbon: 负载均衡
  • Spring Cloud Sleuth: 链路追踪
  • Zipkin: 链路追踪
  • Seata: 分布式事务
  • Dubbo: RPC调⽤
  • Sentinel: 服务熔断
  • Hystrix: 服务熔断

SpringBoot和SpringCloud的区别

  • SpringBoot专注于快速⽅便的开发单个个体微服务,SpringCloud是关注全局的微服务协调整理治理框架,它将SpringBoot开发的⼀个个单体微服务整合并管理起来,为各个微服务之间提供配置管理、服务发现、断路器、路由等集成服务
  • SpringBoot可以离开SpringCloud独⽴开发项⽬,但是SpringCloud依赖SpringBoot
  • SpringBoot专注于快速、⽅便的开发单个微服务个体,SpringCloud关注于全局的服务治理

🌕 场景题

[每个url各占64字节,内存限制是4G,让你找出a、b文件共同的url?]

Step1:遍历文件a,对每个url求取hash(url)%1000,然后根据所取得的值将url分别存储到1000个小文件(记为a0,a1,...,a999,每个小文件约300M);

  Step2:遍历文件b,采取和a相同的方式将url分别存储到1000个小文件(记为b0,b1,...,b999);

  巧妙之处:这样处理后,所有可能相同的url都被保存在对应的小文件(a0vsb0,a1vsb1,...,a999vsb999)中,不对应的小文件不可能有相同的url。然后我们只要求出这个1000对小文件中相同的url即可。

  Step3:求每对小文件ai和bi中相同的url时,可以把ai的url存储到hash_set/hash_map中。然后遍历bi的每个url,看其是否在刚才构建的hash_set中,如果是,那么就是共同的url,存到文件里面就可以了。

线上百万数据如何添加索引

  • 百万数据为什么需要加索引主要是因为打开⻚⾯很⻓时间看不到数据,加索引主要是为了提升查询的效率
  • ⽽对于线上百万数据直接添加索引会导致锁表,所以不建议直接对百万数据加索引,可以这么操作
  1. 导出原表数据
  2. 创建与原表结构⼀致的新表,先再新表上添加索引
  3. 将原表的数据导⼊新表
  4. 修改新表的表名为原表名