基础知识整理

注:这份基础知识整理只记录了一些较高频的点以及我自己不太熟悉的点,有很强的主观性,对于很多常见的八股文都没有详细记录,仅供参考。

Java

Java基础

Java修饰符

default (即默认,什么也不写): 在同一包内可见,不使用任何修饰符。使用对象:类、接口、变量、方法。

private : 在同一类内可见。使用对象:变量、方法。 注意:不能修饰类(外部类)。

public : 对所有类可见。使用对象:类、接口、变量、方法。

protected : 对同一包内的类和所有子类可见。使用对象:变量、方法。 注意:不能修饰类(外部类)。

== 与 equals

== : 它的作⽤是判断两个对象的地址是不是相等,即判断两个对象是不是同⼀个对象(基本数据类型==⽐较的是值,引⽤数据类型 == ⽐较的是内存地址)。

equals() : 它的作⽤也是判断两个对象是否相等。但它⼀般有两种使⽤情况:

  • 情况 1:类没有覆盖 equals() ⽅法。则通过 equals() ⽐较该类的两个对象时,等价于通过 “==”⽐较这两个对象。
  • 情况 2:类覆盖了 equals() ⽅法。⼀般,我们都覆盖 equals() ⽅法来⽐较两个对象的内容是否相等;若它们的内容相等,则返回 true (即,认为这两个对象相等)。

为什么要重写 hashcode 和 equals 方法?

hashcode和equals组合在一起确定元素的唯一性。查找元素时,如果单单使用equals来确定一个元素,需要对集合内的元素逐个调用equals方法,效率太低。

我们以“HashSet 如何检查重复”为例⼦来说明为什么要有 hashCode: 当你把对象加入HashSet 时,HashSet 会先计算对象的 hashcode 值来判断对象加⼊的位置,同时也会与该位置其他已经加⼊的 对象的 hashcode 值作⽐较,如果没有相符的 hashcode,HashSet 会假设对象没有重复出现。但是如果发现有相同 hashcode 值的对象,这时会调⽤ equals()⽅法来检查 hashcode 相等的对象是否真的相同。如果两者相同,HashSet 就不会让其加⼊操作成功。如果不同的话,就会重新散列到其他位置。这样我们就⼤⼤减少了 equals 的次数,相应就⼤⼤提⾼了执⾏速度。
通过我们可以看出:hashCode() 的作⽤就是获取哈希码,也称为散列码;它实际上是返回⼀个 int 整数。这个哈希码的作⽤是确定该对象在哈希表中的索引位置。hashCode()在散列表中才有⽤,在 其它情况下没⽤。在散列表中 hashCode() 的作⽤是获取对象的散列码,进⽽确定该对象在散列表中的位置。

new String(“abc”)问题

String s = “abc”;

存放在字符串常量池中。

String s = new String(“abc”);

存放在堆中。

注意:通过new产生的对象,会先去常量池检查有没有“abc”,如果没有,先在常量池中创建一个该对象,再在堆中拷贝一个常量池中的“abc” 。如果有直接创建拷贝对象。

有道面试题: String s = new String(“xyz”); 产生几个对象?
答:一个或两个。如果常量池中没有,就创建一个“xyz”,再创建一个堆中的拷贝对象。如果有,就直接创建一个堆中拷贝对象。

String,StringBuffer,StringBuillder

StringBuffer通过synchronized来保证多线程下的数据安全。

StringBuffer/StringBuillder 底层通过char[]数组实现,在没有传参的情况下默认初始容量是16,有参数的情况下,初始容量是16+字符串的长度,并且是用append()方法追加的字符。

扩容时先增加为自身长度的两倍然后再加2;这个时候如果还是放不下,那就直接扩容到它需要的长度newCapacity = minCapacity。

String类对象为不可变字符串,String类没有提供用于修改字符串的方法。

list集合排序

  1. 利用集合框架提供的Collections.sort实现排序,其需要实现比较器Comparable接口,并且重写compareTo方法;
  2. 利用集合框架提供的Collections.sort实现排序,不用实现Comparable,而是直接在调用sort接口时传一个Comparator的对象,并重写compare方法。

泛型

泛型是 JDK1.5 的一个新特性,其实就是一个『语法糖』,本质上就是编译器为了提供更好的可读性而提供的一种小「手段」,虚拟机层面是不存在所谓『泛型』的概念的。

在我看来,『泛型』的存在具有以下两点意义,这也是它被设计出来的初衷。

一是,通过泛型的语法定义,编译器可以在编译期提供一定的类型安全检查,过滤掉大部分因为类型不符而导致的运行时异常;

二是,泛型可以让程序代码的可读性更高,并且由于本身只是一个语法糖,所以对于 JVM 运行时的性能是没有任何影响的。

泛型擦除

泛型这种语法糖,编译器会在编译期间「擦除」泛型语法并相应的做出一些类型转换动作。例如:

public class Caculate<T> {

    private T num;
}

反编译一下这个 Caculate 类:

public class Caculate{

    public Caculate(){}

    private Object num;
}

会得到这样一个结果,很明显的是,编译器擦除 Caculate 类后面的两个尖括号,并且将 num 的类型定义为 Object 类型。但是,如果构建实例时使用了泛型语法,那么编译器将标记该实例并关注该实例后续所有方法的调用,每次调用前都进行安全检查,非指定类型的方法都不能调用成功。

「是不是所有的泛型类型都以 Object 进行擦除呢?」

答案是:大部分情况下,泛型类型都会以 Object 进行替换,而有一种情况则不是。

public class Caculate<T extends String> {

    private T num;
}

这种情况的泛型类型,num 会被替换为 String 而不再是 Object。

这是一个类型限定的语法,它限定 T 是 String 或者 String 的子类,也就是你构建 Caculate 实例的时候只能限定 T 为 String 或者 String 的子类,所以无论你限定 T 为什么类型,String 都是父类,不会出现类型不匹配的问题,于是可以使用 String 进行类型擦除。

集合

HashMap

结构

HashMap内部存放了一个Entry类型的数组Table,entry存储着键值对。它包含了四个字段,从next可以看出entry是一个链表。即数组的每个位置都被当成一个桶,一个桶存放一个链表。HashMap 使用拉链法来解决冲突,同一个链表中存放哈希值和散列桶取模运算结果相同的 Entry。链表使用头插法(JDK1.8改为尾插)。

put操作
  1. 判断数组是否为空,如果是空,则创建默认长度为 16 的数组。
  2. 通过与运算计算对应 hash 值的下标,如果对应下标的位置没有元素,则直接创建一个。
  3. 如果有元素,说明 hash 冲突了,则再次进行 3 种判断。
    1. 判断两个冲突的key是否相等,equals 方法的价值在这里体现了。如果相等,则将已经存在的值赋给变量e。最后更新e的value,也就是替换操作。
    2. 如果key不相等,则判断是否是红黑树类型,如果是红黑树,则交给红黑树追加此元素。
    3. 如果key既不相等,也不是红黑树,则是链表,那么就遍历链表中的每一个key和给定的key是否相等。如果,链表的长度大于等于8了,则将链表改为红黑树,这是Java8 的一个新的优化。
  4. 最后,如果这三个判断返回的 e 不为null,则说明key重复,则更新key对应的value的值(保证key的唯一性)。
  5. 维护着迭代器的 modCount 变量加一。
  6. 最后判断,如果当前数组的长度已经大于阀值了。则 rehash 扩容。
get操作

根据 key 的 hashcode 算出元素在数组中的下标,之后遍历 Entry 对象链表或红黑树,直到找到元素为止。

扩容操作

和扩容相关的参数主要有:capacity、size、threshold 和 load_factor。

  • capacity:table 的容量大小,默认为 16。需要注意的是 capacity 必须保证为 2 的 n 次方。
  • size:键值对数量。
  • threshold:size 的临界值,当 size 大于等于 threshold 就必须进行扩容操作。
  • loadFactor:负载因子,table 能够使用的比例,threshold = (int)(capacity* loadFactor)。

扩容条件:实际节点数大于等于容量的四分之三。负载因子是0.75的时候,空间利用率比较高,而且避免了相当多的Hash冲突,使得底层的链表或者是红黑树的高度比较低,提升了空间效率。

在进行扩容时,需要把键值对重新计算桶下标,从而放到对应的桶上。在前面提到,HashMap 使用 hash%capacity 来确定桶下标。HashMap capacity 为 2 的 n 次方这一特点能够极大降低重新计算桶下标操作的复杂度。

扩容后,一个key要么在原位置,要么在原位置+原容量的位置。

为什么初始容量为2的倍数:

1.与操作系统有关。操作系统申请内存时为2的倍数,可以避免内存碎片;

2.计算机擅长移位操作,效率高的多;

3.提高散列度,减少冲突。

ArrayList

ArrayList和LinkedList特点及各自应用场景,它们的底层是什么?

ArrayList:底层是Object数组实现的:由于数组的地址是连续的,数组支持O(1)随机访问;数组在初始化时需要指定容量;数组不支持动态扩容,像ArrayList、Vector和Stack使用的时候看似不用考虑容量问题(因为可以一直往里面存放数据);但是它们的底层实际做了扩容;数组扩容代价比较大,需要开辟一个新数组将数据拷贝进去,数组扩容效率低;适合读数据较多的场合。

LinkedList:底层使用一个Node数据结构,有前后两个指针,双向链表实现的。相对数组,链表插入效率较高,只需要更改前后两个指针即可;另外链表不存在扩容问题,因为链表不要求存储空间连续,每次插入数据都只是改变last指针;另外,链表所需要的内存比数组要多,因为他要维护前后两个指针;它适合删除,插入较多的场景。

  • ArrayList add(): O(n), remove(): O(n), get(): O(1), set(): O(1)
  • LinkedList add(): O(1), remove(): O(1), get(): O(n), set(): O(1)

ConcurrentHashMap

结构

ConcurrentHashMap 和 HashMap 实现上类似,最主要的差别是 ConcurrentHashMap 采用了分段锁(Segment),每个分段锁维护着几个桶(HashEntry),多个线程可以同时访问不同分段锁上的桶,从而使其并发度更高(并发度就是 Segment 的个数)。Segment 继承自 ReentrantLock,默认的并发级别为 16,也就是说默认创建 16 个 Segment。

每个 Segment 维护了一个 count 变量来统计该 Segment 中的键值对个数。在执行 size 操作时,需要遍历所有 Segment 然后把 count 累计起来。

ConcurrentHashMap 在执行 size 操作时先尝试不加锁,如果连续两次不加锁操作得到的结果一致,那么可以认为这个结果是正确的。尝试次数使用 RETRIES_BEFORE_LOCK 定义,该值为 2,retries 初始值为 -1,因此尝试次数为 3。如果尝试的次数超过 3 次,就需要对每个 Segment 加锁。

JDK 1.8 使用了 CAS 操作来支持更高的并发度,在 CAS 操作失败时使用内置锁 synchronized,并且 JDK 1.8 的实现也在链表过长时会转换为红黑树。

ConcurrentHashMap怎么取的size值

计算 ConcurrentHashMap 的元素大小就是一个有趣的问题,因为他是并发操作的,就是在你计算 size 的时候,它还在并发的插入数据,可能会导致你计算出来的 size 和你实际的 size 有差距。

在 JDK1.7 中,第一种方案他会使用不加锁的模式去尝试多次计算 ConcurrentHashMap 的 size,最多三次,比较前后两次计算的结果,结果一致就认为当前没有元素加入,计算的结果是准确的。 第二种方案是如果第一种方案不符合,他就会给每个 Segment 加上锁,然后计算 ConcurrentHashMap 的 size 返回。

JDK1.8 中 size 是通过对 baseCount 和 counterCell 进行 CAS 计算,最终通过 baseCount 和 遍历 CounterCell 数组得出 size。

ConcurrentHashMap为什么读不加锁

TreeMap

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

HashMap 依靠 hashCode 和 equals 去重,而 TreeMap 依靠 Comparable 或 Comparator。 TreeMap 排序时,如果比较器不为空就会优先使用比较器的 compare 方法,否则使用 Key 实现的 Comparable 的 compareTo 方法,两者都不满足会抛出异常。

TreeMap 通过 put 和 deleteEntry 实现增加和删除树节点。插入新节点的规则有三个:① 需要调整的新节点总是红色的。② 如果插入新节点的父节点是黑色的,不需要调整。③ 如果插入新节点的父节点是红色的,由于红黑树不能出现相邻红色,进入循环判断,通过重新着色或左右旋转来调整。TreeMap 的插入操作就是按照 Key 的对比往下遍历,大于节点值向右查找,小于向左查找,先按照二叉查找树的特性操作,后续会重新着色和旋转,保持红黑树的特性。

JVM

JVM内存划分

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

线程共享的:堆;方法区;直接内存 (非运行时数据区的一部分)。

img

程序计数器:

程序计数器是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。程序计数器主要有两个作用:

  • 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理;
  • 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。

注意:程序计数器是唯一一个不会出现 OutOfMemoryError 的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡。

Java虚拟机栈:

每个 Java 方法在执行的同时会创建一个栈帧用于存储局部变量表、操作数栈、常量池引用等信息。从方法调用直至执行完成的过程,对应着一个栈帧在 Java 虚拟机栈中入栈和出栈的过程。

局部变量表主要存放了编译器可知的各种数据类型(boolean、byte、char、short、int、float、long、double)、对象引用。

Java 虚拟机栈会出现两种错误:StackOverFlowError 和 OutOfMemoryError。

  • StackOverFlowError:若 Java 虚拟机栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候,就抛出 StackOverFlowError 错误。
  • OutOfMemoryError:若 Java 虚拟机栈的内存大小允许动态扩展,且当线程请求栈时内存用完了,无法再动态扩展了,此时抛出 OutOfMemoryError 错误。

本地方法栈:

和虚拟机栈所发挥的作用非常相似,区别是:虚拟机栈为虚拟机执行 Java 方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。

堆:

此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。Java 堆是垃圾收集器管理的主要区域,因此也被称作 GC 堆(Garbage Collected Heap)。从垃圾回收的角度,由于现在收集器基本都采用分代垃圾收集算法,所以 Java 堆还可以细分为:新生代和老年代:再细致一点有:Eden 空间、From Survivor、To Survivor 空间等。进一步划分的目的是更好地回收内存,或者更快地分配内存。

堆不需要连续内存,并且可以动态增加其内存,增加失败会抛出 OutOfMemoryError 异常。

方法区:

用于存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。方法区和永久代就像是接口和类之间的关系,方法区是一种规范,而永久代是对它的一种实现。JDK 1.8 的时候,方法区(HotSpot 的永久代)被彻底移除了(JDK1.7 就已经开始了),取而代之是元空间,元空间使用的是直接内存。

运行时常量池:

运行时常量池是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有常量池表(用于存放编译期生成的各种字面量和符号引用)。既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError 错误。

直接内存:

直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用。而且也可能导致 OutOfMemoryError 错误出现。

JDK1.4 中新加入的 NIO(New Input/Output) 类,引入了一种基于通道(Channel)与缓存区(Buffer) 的 I/O 方式,它可以直接使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样就能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆之间来回复制数据。

OOM都发生在哪里

除了程序计数器不会抛出OOM外,其他各个内存区域都可能会抛出OOM。

最常见的OOM情况有以下三种:

• java.lang.OutOfMemoryError: Java heap space ------>java堆内存溢出,此种情况最常见,一般由于内存泄露或者堆的大小设置不当引起。对于内存泄露,需要通过内存监控软件查找程序中的泄露代码,而堆大小可以通过虚拟机参数-Xms,-Xmx等修改。

• java.lang.OutOfMemoryError: PermGen space ------>java永久代溢出,即方法区溢出了,一般出现于大量Class或者jsp页面,或者采用cglib等反射机制的情况,因为上述情况会产生大量的Class信息存储于方法区。此种情况可以通过更改方法区的大小来解决,使用类似-XX:PermSize=64m -XX:MaxPermSize=256m的形式修改。另外,过多的常量尤其是字符串也会导致方法区溢出。

• Java 虚拟机栈会出现两种错误:StackOverFlowError 和 OutOfMemoryError。

  • StackOverFlowError:若 Java 虚拟机栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候,就抛出 StackOverFlowError 错误。
  • OutOfMemoryError:若 Java 虚拟机栈的内存大小允许动态扩展,且当线程请求栈时内存用完了,无法再动态扩展了,此时抛出 OutOfMemoryError 错误。

垃圾回收

垃圾收集主要是针对堆和方法区进行。程序计数器、虚拟机栈和本地方法栈这三个区域属于线程私有的,只存在于线程的生命周期内,线程结束之后就会消失,因此不需要对这三个区域进行垃圾回收。

如何判断一个对象是否可被回收
  1. 引用计数算法:

为对象添加一个引用计数器,当对象增加一个引用时计数器加 1,引用失效时计数器减 1。引用计数为 0 的对象可被回收。在两个对象出现循环引用的情况下,此时引用计数器永远不为 0,导致无法对它们进行回收。正是因为循环引用的存在,因此 Java 虚拟机不使用引用计数算法。

  1. 可达性分析算法:

以 GC Roots 为起始点进行搜索,可达的对象都是存活的,不可达的对象可被回收。

Java 虚拟机使用该算法来判断对象是否可被回收,GC Roots 一般包含以下内容:

  • 虚拟机栈中局部变量表中引用的对象(虚拟机栈中的引用)
  • 本地方法栈中 JNI 中引用的对象(本地方法栈中的引用)
  • 方法区中类静态属性引用的对象(方法区类静态引用)
  • 方法区中的常量引用的对象(方法区常量引用)

垃圾收集器

CMS

CMS(Concurrent Mark Sweep)收集器是⼀种以获取最短回收停顿时间为⽬标的收集器。它⾮常适合在注重⽤户体验的应⽤上使⽤。适用于老年代。

ParNew收集器是新生代的垃圾收集器,采用多线程进行垃圾收集和回收,采用复制算法的收集器,它是Serial收集器的多线程版本,它的实现复用了很多Serial收集器的代码,所以它包含了很多Serial收集器的参数和特性,它能与CMS收集器配合工作。它开始工作的时候会暂停所有用户线程。

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

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

  1. 初始标记:暂停所有的其他线程,并记录下直接与root相连的对象,速度很快;

  2. 并发标记:同时开启GC和⽤户线程,⽤⼀个闭包结构去记录可达对象。但在这个阶段结束,这个闭包结构并不能保证包含当前所有的可达对象。因为⽤户线程可能会不断的更新引⽤域,所以GC线程⽆法保证可达性分析的实时性。所以这个算法⾥会跟踪记录这些发⽣引⽤更新的地⽅。

  3. 重新标记:重新标记阶段就是为了修正并发标记期间因为⽤户程序继续运⾏⽽导致标记产⽣变动的那⼀部分对象的标记记录,这个阶段的停顿时间⼀般会⽐初始标记阶段的时间稍⻓,远远⽐并发标记阶段时间短。

  4. 并发清除:开启⽤户线程,同时GC线程开始对为标记的区域做清扫。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-XDIKhdv0-1640000025216)(https://user-gold-cdn.xitu.io/2019/2/24/1691ed081995f262?imageView2/0/w/1280/h/960/format/webp/ignore-error/1)]

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

  1. 对CPU资源敏感;
  2. ⽆法处理浮动垃圾;
  3. 它使⽤的回收算法-“标记-清除”算法会导致收集结束时会有⼤量空间碎⽚产⽣。

CMS垃圾收集可以进行内存的整理,那内存整理的过程中对象地址发生了变动,JVM不可能去通知所有的引用这些对象的线程地址变了,那是通过什么方式保证了引用对象的线程还能找到这些对象的呢?

JVM里面有一张表,这张表记录了所有对象引用的地址和他被引用的地址,内存整理时会更新这张表,和操作系统里面的内存页,地址映射类似。

G1

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

它具备以下特点:

  1. 并⾏与并发:G1能充分利⽤CPU、多核环境下的硬件优势,使⽤多个CPU(CPU或者CPU核⼼)来缩短Stop-The-World停顿时间。部分其他收集器原本需要停顿Java线程执⾏的GC动作,G1收集器仍然可以通过并发的⽅式让java程序继续执⾏。

  2. 分代收集:虽然G1可以不需要其他收集器配合就能独⽴管理整个GC堆,但是还是保留了分代的概念。

  3. 空间整合:与CMS的“标记–清理”算法不同,G1从整体来看是基于“标记整理”算法实现的收集器;从局部上来看是基于“复制”算法实现的。

  4. 可预测的停顿:这是G1相对于CMS的另⼀个⼤优势,降低停顿时间是G1和 CMS共同的关注点,但G1除了追求低停顿外,还能建⽴可预测的停顿时间模型,能让使⽤者明确指定在⼀个⻓度为M毫秒的时间⽚段内。

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

  1. 初始标记

  2. 并发标记

  3. 最终标记

  4. 筛选回收

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

G1有没有Full GC

G1提供了两种GC模式,Young GC和Mixed GC,两种都是完全Stop The World的。 * Young GC:选定所有年轻代里的Region。通过控制年轻代的region个数,即年轻代内存大小,来控制young GC的时间开销。 * Mixed GC:选定所有年轻代里的Region,外加根据global concurrent marking统计得出收集收益高的若干老年代Region。在用户指定的开销目标范围内尽可能选择收益高的老年代Region。

由上面的描述可知,Mixed GC不是full GC,它只能回收部分老年代的Region,如果mixed GC实在无法跟上程序分配内存的速度,导致老年代填满无法继续进行Mixed GC,就会使用serial old GC(full GC)来收集整个GC heap。所以我们可以知道,G1是不提供full GC的。

CMS收集器和G1收集器的区别

区别一: 使用范围不一样

CMS收集器是老年代的收集器,可以配合新生代的Serial和ParNew收集器一起使用;
G1收集器收集范围是老年代和新生代。不需要结合其他收集器使用。

区别二: STW的时间

CMS收集器以最小的停顿时间为目标。

G1收集器可预测垃圾回收的停顿时间(建立可预测的停顿时间模型)。

区别三: 垃圾碎片

CMS收集器是使用“标记-清除”算法进行的垃圾回收,容易产生内存碎片;

G1收集器使用的是“标记-整理”算法,进行了空间整合,降低了内存空间碎片。

强引用,软引用,弱引用,虚引用

  1. 强引用(StrongReference)

    以前我们使⽤的⼤部分引⽤实际上都是强引⽤,这是使⽤最普遍的引⽤。如果⼀个对象具有强引⽤,那就类似于必不可少的⽣活⽤品,垃圾回收器绝不会回收它。当内存空间不⾜,Java虚拟机宁愿抛出OutOfMemoryError错误,使程序异常终⽌,也不会靠随意回收具有强引⽤的对象来解决内存不⾜问题。

  2. 软引用(SoftReference)

    如果⼀个对象只具有软引⽤,那就类似于可有可⽆的⽣活⽤品。如果内存空间⾜够,垃圾回收器就不会回收它,如果内存空间不⾜了,就会回收这些对象的内存。只要垃圾回收器没有回收它,该对象就可以被程序使⽤。软引⽤可⽤来实现内存敏感的⾼速缓存。

    软引⽤可以和⼀个引⽤队列(ReferenceQueue)联合使⽤,如果软引⽤所引⽤的对象被垃圾回收,JAVA 虚拟机就会把这个软引⽤加⼊到与之关联的引⽤队列中。

  3. 弱引用(WeakReference)

    如果⼀个对象只具有弱引⽤,那就类似于可有可⽆的⽣活⽤品。弱引⽤与软引⽤的区别在于:只具有弱引⽤的对象拥有更短暂的⽣命周期。在垃圾回收器线程扫描它所管辖的内存区域的过程中,⼀旦发现了只具有弱引⽤的对象,不管当前内存空间⾜够与否,都会回收它的内存。不过,由于垃圾回收器是⼀个优先级很低的线程, 因此不⼀定会很快发现那些只具有弱引⽤的对象。

    弱引⽤可以和⼀个引⽤队列(ReferenceQueue)联合使⽤,如果弱引⽤所引⽤的对象被垃圾回收,Java虚拟机就会把这个弱引⽤加⼊到与之关联的引⽤队列中。

  4. 虚引用(PhantomReference)

    "虚引⽤"顾名思义,就是形同虚设,与其他⼏种引⽤都不同,虚引⽤并不会决定对象的⽣命周期。如果⼀个对象仅持有虚引⽤,那么它就和没有任何引⽤⼀样,在任何时候都可能被垃圾回收。 虚引⽤主要⽤来跟踪对象被垃圾回收的活动。

    虚引⽤与软引⽤和弱引⽤的⼀个区别在于:虚引⽤必须和引⽤队列(ReferenceQueue)联合使⽤。当垃圾回收器准备回收⼀个对象时,如果发现它还有虚引⽤,就会在回收对象的内存之前,把这个虚引⽤加⼊到与之关联的引⽤队列中。程序可以通过判断引⽤队列中是否已经加⼊了虚引⽤,来了解被引⽤的对象是否将要被垃圾回收。程序如果发现某个虚引⽤已经被加⼊到引⽤队列,那么就可以在所引⽤的对象的内存被回收之前采取必要的⾏动。

虚引用有什么用?

深入理解JAVA虚拟机一书中有这样一句描述:“为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知”。所以虚引用更多的是用于对象回收的监听,能做的功能如下:

  • 重要对象回收监听:进行日志统计

  • 系统gc监听:因为虚引用每次gc都会被回收,那么我们就可以通过虚引用来判断gc的频率,如果频率过大,内存使用可能存在问题,才导致了系统gc频繁调用

JVM中各种变量保存位置

  • 静态变量:位于方法区。
  • 实例变量:作为对象的一部分,保存在堆中。
  • 临时变量:保存于栈中,栈随线程的创建而被分配。
  • 常量:位于常量池,而常量池位于方法区。

类加载过程

类加载分为加载-验证-准备-解析-初始化五个步骤。

加载

简单来说,加载指的是把class字节码文件从各个来源通过类加载器装载入内存中。类加载器一般包括启动类加载器,扩展类加载器,应用类加载器,以及用户的自定义类加载器。

验证

主要是为了保证加载进来的字节流符合虚拟机规范,不会造成安全错误。包括对于文件格式的验证,对于元数据的验证,对于字节码的验证,对于符号引用的验证等。

准备

主要是为类变量(注意,不是实例变量)分配内存,并且赋予初值

特别需要注意,初值,不是代码中具体写的初始化的值,而是Java虚拟机根据不同变量类型的默认初始值。

解析

在解析阶段,虚拟机会把所有的类名,方法名,字段名这些符号引用替换为具体的内存地址或偏移量,也就是直接引用。

初始化

这个阶段主要是对类变量初始化,是执行类构造器的过程。换句话说,只对static修饰的变量或语句进行初始化。

如果初始化一个类的时候,其父类尚未初始化,则优先初始化其父类。

如果同时包含多个静态变量和静态代码块,则按照自上而下的顺序依次执行。

双亲委派机制

  1. 首先判断该Class是否已经加载
  2. 如果没有则不是自身去查找而是委托给父加载器进行查找,这样依次的进行递归,直到委托到最顶层的Bootstrap ClassLoader
  3. 如果Bootstrap ClassLoader找到了该Class,就会直接返回
  4. 如果没找到,则继续依次向下查找,如果还没找到则最后会交由自身去查找

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-GEKFj21x-1640000025219)(https://user-gold-cdn.xitu.io/2020/1/8/16f8413a95a8812f?imageView2/0/w/1280/h/960/format/webp/ignore-error/1)]

为什么使用双亲委托机制?

  1. 避免重复加载,如果已经加载过一次Class,就不需要再次加载,而是先从缓存中直接读取。
  2. 安全方面的考虑,如果不使用双亲委托模式,就可以自定义一个String类来替代系统的String类,这样便会造成安全隐患,采用双亲委托模式会使得系统的String类在Java虚拟机启动时就被加载,也就无法自定义String类来替代系统的String类。
  3. 避免产生多个类。在JVM中,一个类用其包名+类名和一个ClassLoader的实例作为唯一标识,不同类加载器加载同一个Class会产生多个类。

如何打破双亲委托机制

重写父类ClassLoader的loadClass方法。

并发

多线程

线程状态转换

一个线程只能处于一种状态,并且这里的线程状态特指 Java 虚拟机的线程状态,不能反映线程在特定操作系统下的状态。

  • 初始(NEW):新创建了一个线程对象,但还没有调用start()方法。
  • 运行(RUNNABLE):Java线程中将就绪(ready)和运行中(running)两种状态笼统的称为“运行”。线程对象创建后,其他线程(比如main线程)调用了该对象的start()方法。该状态的线程位于可运行线程池中,等待被线程调度选中,获取CPU的使用权,此时处于就绪状态(ready)。就绪状态的线程在获得CPU时间片后变为运行中状态(running)。
  • 阻塞(BLOCKED):表示线程阻塞于锁。
  • 无限期等待(WAITING):等待其它线程显式地唤醒。阻塞和等待的区别在于,阻塞是被动的,它是在等待获取 monitor lock。而等待是主动的,通过调用 Object.wait() 等方法进入。
进入方法退出方法
没有设置 Timeout 参数的 Object.wait() 方法Object.notify() / Object.notifyAll()
没有设置 Timeout 参数的 Thread.join() 方法被调用的线程执行完毕
LockSupport.park() 方法LockSupport.unpark(Thread)
  • 限期等待(TIMED_WAITING):无需等待其它线程显式地唤醒,在一定时间之后会被系统自动唤醒。
进入方法退出方法
Thread.sleep() 方法时间结束
设置了 Timeout 参数的 Object.wait() 方法时间结束 / Object.notify() / Object.notifyAll()
设置了 Timeout 参数的 Thread.join() 方法时间结束 / 被调用的线程执行完毕
LockSupport.parkNanos() 方法LockSupport.unpark(Thread)
LockSupport.parkUntil() 方法LockSupport.unpark(Thread)
  • 终止(TERMINATED):表示该线程已经执行完毕。可以是线程结束任务之后自己结束,或者产生了异常而结束。

线程状态图

sleep() 和 wait()
  1. 两者最主要的区别在于:sleep ⽅法没有释放锁,⽽ wait ⽅法释放了锁。
  2. 两者都可以暂停线程的执⾏。
  3. Wait 通常被⽤于线程间交互/通信,sleep通常被⽤于暂停执⾏。
  4. wait() ⽅法被调⽤后,线程不会⾃动苏醒,需要别的线程调⽤同⼀个对象上的 notify() 或者 notifyAll() ⽅法。sleep() ⽅法执⾏完成后,线程会⾃动苏醒。或者可以使⽤ wait(long timeout)超时后线程会⾃动苏醒。

ps.notifyAll()方法与notify()方法的工作方式相同,重要的一点差异是:notifyAll 使所有原来在该对象上 wait 的线程统统退出 WAITTING 状态,使得他们全部从等待队列中移入到同步队列中去,等待下一次能够有机会获取到对象监视器锁。

线程池
使用线程池的好处
  1. 降低资源消耗。通过重复利⽤已创建的线程降低线程创建和销毁造成的消耗。
  2. 提⾼响应速度。当任务到达时,任务可以不需要的等到线程创建就能⽴即执⾏。
  3. 提⾼线程的可管理性。线程是稀缺资源,如果⽆限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使⽤线程池可以进⾏统⼀的分配,调优和监控。
创建线程

继承Thread类;实现Runnable接口;实现Callable接口。

与 Runnable 相比,Callable 可以有返回值或抛出检查异常,返回值通过 FutureTask 进行封装。

submit()和execute()
  1. execute()⽅法⽤于提交不需要返回值的任务,所以⽆法判断任务是否被线程池执⾏成功与否;
  2. submit()⽅法⽤于提交需要返回值的任务。线程池会返回⼀个 Future 类型的对象,通过这个 Future 对象可以判断任务是否执⾏成功,并且可以通过 Future 的 get() ⽅法来获取返回值,get() ⽅法会阻塞当前线程直到任务完成,⽽使⽤ get(long timeout, TimeUnit unit) ⽅法则会阻塞当前线程⼀段时间后⽴即返回,这时候有可能任务没有执⾏完。
创建线程池
  1. 通过ThreadPoolExecutor实现:

  2. 通过Executor框架的⼯具类Executors来实现。我们可以创建三种类型的 ThreadPoolExecutor:

  • FixedThreadPool:该⽅法返回⼀个固定线程数量的线程池。该线程池中的线程数量始终不变。当有⼀个新的任务提交时,线程池中若有空闲线程,则⽴即执⾏。若没有,则新的任务会被暂存在⼀个任务队列中,待有线程空闲时,便处理在任务队列中的任务。workQueue是LinkedBlockingQueue,这是一个链表阻塞队列。
  • SingleThreadExecutor:⽅法返回⼀个只有⼀个线程的线程池。若多于⼀个任务被提交到该线程池,任务会被保存在⼀个任务队列中,待线程空闲,按先⼊先出的顺序执⾏队列中的任务。workQueue是LinkedBlockingQueue。
  • CachedThreadPool:该⽅法返回⼀个可根据实际情况调整线程数量的线程池。线程池的线程数 量不确定,但若有空闲线程可以复⽤,则会优先使⽤可复⽤的线程。若所有线程均在⼯作,⼜有新的任务提交,则会创建新的线程处理任务。所有线程在当前任务执⾏完毕后,将返回线程池进⾏复⽤。workQueue是SynchronousQueue,该同步队列是一个没有容量队列,即一个任务到来后,要等待线程来消费,才能再继续添加任务。

注意:《阿⾥巴巴Java开发⼿册》中强制线程池不允许使⽤ Executors 去创建,⽽是通过 ThreadPoolExecutor 的⽅式,这样的处理⽅式让写的同学更加明确线程池的运⾏规则,规避资源耗尽的⻛险。

  • FixedThreadPool和SingleThreadExecutor:允许请求的队列⻓度为 Integer.MAX_VALUE,可能堆积⼤量的请求,从⽽导致OOM。
  • CachedThreadPool和ScheduledThreadPool:允许创建的线程数量为 Integer.MAX_VALUE,可能会创建⼤量线程,从⽽导致OOM。
ThreadPoolExecutor参数
public ThreadPoolExecutor(int corePoolSize,
​           int maximumPoolSize,
​           long keepAliveTime,
​           TimeUnit unit,
​           BlockingQueue<Runnable> workQueue,
​           ThreadFactory threadFactory,
​           RejectedExecutionHandler handler)

其中handler为拒绝策略。

  • ThreadPoolExecutor.AbortPolicy:抛出 RejectedExecutionException来拒绝新任务的处理。
  • ThreadPoolExecutor.CallerRunsPolicy:调⽤执⾏⾃⼰的线程运⾏任务。
  • ThreadPoolExecutor.DiscardPolicy:不处理新任务,直接丢弃掉。
  • ThreadPoolExecutor.DiscardOldestPolicy:此策略将丢弃最早的未处理的任务请求。
线程池原理分析

你也被Spring的这个“线程池”坑过吗?

执行逻辑说明:

  1. 判断核心线程数是否已满,核心线程数大小和corePoolSize参数有关,未满则创建线程执行任务;
  2. 若核心线程池已满,判断队列是否满,队列是否满和workQueue参数有关,若未满则加入队列中;
  3. 若队列已满,判断线程池是否已满,线程池是否已满和maximumPoolSize参数有关,若未满创建线程执行任务;
  4. 若线程池已满,则采用拒绝策略处理无法执执行的任务,拒绝策略和handler参数有关。

核心线程:固定线程数,可闲置,不会被销毁。

非核心线程数:非核心线程闲置时的超时时长,超过这个时长,非核心线程就会被回收。

ThreadLocal

ThreadLocal原理总结:

  • 每个Thread维护着一个ThreadLocalMap的引用;
  • ThreadLocalMap是ThreadLocal的内部类,用Entry来进行存储;
  • 调用ThreadLocal的set()方法时,实际上就是往ThreadLocalMap设置值,key是ThreadLocal对象,值是传递进来的对象;
  • 调用ThreadLocal的get()方法时,实际上就是往ThreadLocalMap获取值,key是ThreadLocal对象;
  • ThreadLocal本身并不存储值,它只是作为一个key来让线程从ThreadLocalMap获取value。

ThreadLocal内存泄漏:根源是ThreadLocalMap的生命周期跟Thread一样长,如果没有手动删除对应key就会导致内存泄漏。当把threadlocal实例置为null以后,没有任何强引用指向threadlocal实例,所以threadlocal将会被gc回收. 但是,我们的value却不能回收,因为存在一条从current thread连接过来的强引用。想要避免内存泄露就要手动remove()掉!

volatile
JMM内存模型

Java内存模型试图屏蔽各种硬件和操作系统之间的内存访问差异,以实现Java程序在各种平台下都能达到一致的内存访问效果。可从以下三点展开说:

1.Java内存模型对工作内存与主内存之间的具体交互协议定义了八种操作;

2.Java内存模型还会对指令进行重排序;

3.Java内存模型是围绕并发编程中的原子性,可见性,有序性三个特征来建立的。

工作内存和主内存

处理器上寄存器的速度比内存要快几个数量级,为了解决这个问题我们在他们之间引入了缓存。这带来了一个问题:缓存一致性,即多个缓存共享一块主内存区域,缓存的数据可能不一致,需要协议来解决这个问题。

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

img

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

img

read:把一个变量的值从主内存传输到工作内存中

load:在 read 之后执行,把 read 得到的值放入工作内存的变量副本中

use:把工作内存中一个变量的值传递给执行引擎

assign:把一个从执行引擎接收到的值赋给工作内存的变量

store:把工作内存的一个变量的值传送到主内存中

write:在 store 之后执行,把 store 得到的值放入主内存的变量中

lock/unlock:作用于主内存的变量

原子性,可见性,有序性如何保证

  1. 原子性。Java 内存模型允许虚拟机将没有被 volatile 修饰的 64 位数据(long,double)的读写操作划分为两次 32 位的操作来进行,即 load、store、read 和 write 操作可以不具备原子性;int 等原子性的类型在多线程环境中也会出现线程安全问题。有两种办法解决:

    1. AtomicInteger能保证多个线程修改的原子性,可用AtomicInteger重写线程不安全的代码;

    2. Synchronized互斥锁也保证操作的原子性。

  2. 可见性。可见性指当一个线程修改了共享变量的值,其它线程能够立即得知这个修改。Java 内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值来实现可见性的。主要有三种方式实现:

    1. volatile

    2. synchronized

    3. final

  3. 有序性。在本线程内观察,所有操作都是有序的。在一个线程观察另一个线程,所有操作都是无序的,无序是因为发生了指令重排序。在 Java 内存模型中,允许编译器和处理器对指令进行重排序,重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。

    volatile关键字通过添加内存屏障的方式来禁止指令重排,即重排序时不能把后面的指令放到内存屏障之前;

    也可以通过 synchronized来保证有序性,它保证每个时刻只有一个线程执行同步代码,相当于是让线程顺序执行同步代码。

volatile关键字原理

volatile 可以保证线程可见性且提供了一定的有序性,但是无法保证原子性。在 JVM 底层,volatile 是采用“内存屏障”来实现的。

观察加入 volatile 关键字和没有加入 volatile 关键字时所生成的汇编代码发现, 加入volatile 关键字时,会多出一个 lock 前缀指令。lock 前缀指令,其实就相当于一个内存屏障。 内存屏障是一组处理指令,用来实现对内存操作的顺序限制。volatile 的底层就是通过内存屏障来实现的。

可见性

修改了volatile变量,必须立即同步到主存,同时使其他线程工作内存中的值变为无效;使用volatile变量前,必须先从主存刷新,以此来保证可见性。

有序性

volatile修饰的变量会禁止指令重排序,典型的应用就是单例模式的Double Check Lock,singleton变量是需要被volatile修饰的,具体过程:

先判断singleton变量是不是等于null,然后synchronized锁住类锁,然后接着判断singleton变量是不是等于null,等于null则new出对象,但是new出对象是需要三个步骤的

第一,为对象分配内存

第二,对象初始化

第三,对象引用指向第一步分配好的内存

那其实不加入volatile关键字修饰的话,第二步和第三步是可以重排序的,可以乱序执行

那如果这样第一个线程过来时只执行到第二步,就是说还没有初始化对象就已经引用上了,第二个线程再过来时,因为已经有了引用,所以直接返回对象,但对象还没有初始化,所以用起来会报错

指令为什么会重排序

为了执行指令更快,指令间没有相互依赖的话,可以乱序执行,前面丢失的指令不会阻塞后面指令的执行。

volatile与synchronized的区别

volatile只能修饰实例变量和类变量,而synchronized可以修饰方法,以及代码块。

volatile保证数据的可见性,但是不保证原子性(多线程进行写操作,不保证线程安全);而synchronized是一种排他(互斥)的机制。volatile用于禁止指令重排序:可以解决单例双重检查对象初始化代码执行乱序问题。

volatile可以看做是轻量版的synchronized,volatile不保证原子性,但是如果是对一个共享变量进行多个线程的赋值,而没有其他的操作,那么就可以用volatile来代替synchronized,因为赋值本身是有原子性的,而volatile又保证了可见性,所以就可以保证线程安全了。

乐观锁和悲观锁

悲观锁

总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁(共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程)。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。Java中synchronized和ReentrantLock等独占锁就是悲观锁思想的实现。

乐观锁

总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号机制和CAS算法实现。乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库提供的类似于write_condition机制,其实都是提供的乐观锁。在Java中java.util.concurrent.atomic包下面的原子变量类就是使用了乐观锁的一种实现方式CAS实现的。CAS有3个操作数,内存值V,旧的预期值A,要修改的新值B。当且仅当预期值A和内存值V相同时,将内存值V修改为B,否则什么都不做。

两种锁的使用场景

从上面对两种锁的介绍,我们知道两种锁各有优缺点,不可认为一种好于另一种,像乐观锁适用于写比较少的情况下(多读场景),即冲突真的很少发生的时候,这样可以省去了锁的开销,加大了系统的整个吞吐量。但如果是多写的情况,一般会经常产生冲突,这就会导致上层应用会不断的进行retry,这样反倒是降低了性能,所以一般多写的场景下用悲观锁就比较合适。

高并发下应使用悲观锁。对于资源竞争严重(线程冲突严重)的情况,CAS自旋的概率会比较大,从而浪费更多的CPU资源,效率低于synchronized。

CAS的三个问题及解决方案

1.CAS引发的ABA问题

ABA问题是指在CAS操作时,其他线程将变量值A改为了B,但是又被改回了A,等到本线程使用期望值A与当前变量进行比较时,发现变量A没有变,于是CAS就将A值进行了交换操作,但是实际上该值已经被其他线程改变过,这与乐观锁的设计思想不符合。ABA问题的解决思路是,**每次变量更新的时候把变量的版本号加1,**那么A-B-A就会变成A1-B2-A3,只要变量被某一线程修改过,改变量对应的版本号就会发生递增变化,从而解决了ABA问题。

2.CAS导致自旋消耗

多个线程争夺同一个资源时,如果自旋一直不成功,将会一直占用CPU。

解决方法:破坏掉for死循环,当超过一定时间或者一定次数时,return退出

3.CAS只能单变量

CAS的原子操作只能针对一个共享变量,假如需要针对多个变量进行原子操作也是可以解决的。

**方法:**CAS操作是针对一个变量的,如果对多个变量操作,1.可以加锁来解决。2.封装成对象类解决。

synchronized底层实现

对于synchronized关键字而言,javac在编译时,会生成对应的monitorentermonitorexit指令分别对应synchronized同步块的进入和退出,有两个monitorexit指令的原因是:为了保证抛异常的情况下也能释放锁,所以javac为同步代码块添加了一个隐式的try-finally,在finally中会调用monitorexit命令释放锁。而对于synchronized方法而言,javac为其生成了一个ACC_SYNCHRONIZED关键字,在JVM进行方法调用时,发现调用的方法被ACC_SYNCHRONIZED修饰,则会先尝试获得锁。

synchronized的锁升级过程

Synchronized的升级顺序是 无锁–>偏向锁–>轻量级锁–>重量级锁,顺序不可逆。

偏向锁

初次执行到synchronized代码块的时候,锁对象变成偏向锁(通过CAS修改对象头里的锁标志位),字面意思是“偏向于第一个获得它的线程”的锁。执行完同步代码块后,线程并不会主动释放偏向锁。当第二次到达同步代码块时,线程会判断此时持有锁的线程是否就是自己(持有锁的线程ID也在对象头里),如果是则正常往下执行。由于之前没有释放锁,这里也就不需要重新加锁。如果自始至终使用锁的线程只有一个,很明显偏向锁几乎没有额外开销,性能极高。

偏向锁是指当一段同步代码一直被同一个线程所访问时,即不存在多个线程的竞争时,那么该线程在后续访问时便会自动获得锁,从而降低获取锁带来的消耗,即提高性能。

当一个线程访问同步代码块并获取锁时,会在 Mark Word 里存储锁偏向的线程 ID。在线程进入和退出同步块时不再通过 CAS 操作来加锁和解锁,而是检测 Mark Word 里是否存储着指向当前线程的偏向锁。轻量级锁的获取及释放依赖多次 CAS 原子指令,而偏向锁只需要在置换 ThreadID 的时候依赖一次 CAS 原子指令即可。

偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程是不会主动释放偏向锁的。

关于偏向锁的撤销,需要等待全局安全点,即在某个时间点上没有字节码正在执行时,它会先暂停拥有偏向锁的线程,然后判断锁对象是否处于被锁定状态。如果线程不处于活动状态,则将对象头设置成无锁状态,并撤销偏向锁,恢复到无锁(标志位为01)或轻量级锁(标志位为00)的状态。

轻量级锁

轻量级锁是指当锁是偏向锁的时候,却被另外的线程所访问,此时偏向锁就会升级为轻量级锁,其他线程会通过自旋(关于自旋的介绍见文末)的形式尝试获取锁,线程不会阻塞,从而提高性能。

轻量级锁的获取主要由两种情况:
① 当关闭偏向锁功能时;
② 由于多个线程竞争偏向锁导致偏向锁升级为轻量级锁。

一旦有第二个线程加入锁竞争,偏向锁就升级为轻量级锁(自旋锁)。这里要明确一下什么是锁竞争:如果多个线程轮流获取一个锁,但是每次获取锁的时候都很顺利,没有发生阻塞,那么就不存在锁竞争。只有当某线程尝试获取锁的时候,发现该锁已经被占用,只能等待其释放,这才发生了锁竞争。

在轻量级锁状态下继续锁竞争,没有抢到锁的线程将自旋,即不停地循环判断锁是否能够被成功获取。获取锁的操作,其实就是通过CAS修改对象头里的锁标志位。先比较当前锁标志位是否为“释放”,如果是则将其设置为“锁定”,比较并设置是原子性发生的。这就算抢到锁了,然后线程将当前锁的持有者信息修改为自己。

长时间的自旋操作是非常消耗资源的,一个线程持有锁,其他线程就只能在原地空耗CPU,执行不了任何有效的任务,这种现象叫做忙等(busy-waiting)。如果多个线程用一个锁,但是没有发生锁竞争,或者发生了很轻微的锁竞争,那么synchronized就用轻量级锁,允许短时间的忙等现象。这是一种折衷的想法,短时间的忙等,换取线程在用户态和内核态之间切换的开销。

重量级锁

重量级锁显然,此忙等是有限度的(有个计数器记录自旋次数,默认允许循环10次,可以通过虚拟机参数更改)。如果锁竞争情况严重,某个达到最大自旋次数的线程,会将轻量级锁升级为重量级锁(依然是CAS修改锁标志位,但不修改持有锁的线程ID)。当后续线程尝试获取锁时,发现被占用的锁是重量级锁,则直接将自己挂起(而不是忙等),等待将来被唤醒。

重量级锁是指当有一个线程获取锁之后,其余所有等待获取该锁的线程都会处于阻塞状态。

简言之,就是所有的控制权都交给了操作系统,由操作系统来负责线程间的调度和线程的状态变更。而这样会出现频繁地对线程运行状态的切换,线程的挂起和唤醒,从而消耗大量的系统资源。

为什么synchronized演变成重量级锁后性能会下降

偏向锁和轻量级锁都是在用户态,而重量级锁需要到OS的内核态,很耗性能。

问:那为什么要到内核态

答:保护OS,有的指令不能让用户执行

问:计算机通过什么来区分什么是高优先级的,或者说需要在内核态执行的

答:指令会分级,Intel的x86会有R0、R1、R2、R3指令等级

AQS

AQSAbstractQueuedSynchronizer的缩写,这个是个内部实现了两个队列的抽象类,分别是同步队列条件队列。其中同步队列是一个双向链表,里面储存的是处于等待状态的线程,正在排队等待唤醒去获取锁,而条件队列是一个单向链表,里面储存的也是处于等待状态的线程,只不过这些线程唤醒的结果是加入到了同步队列的队尾,AQS所做的就是管理这两个队列里面线程之间的等待状态-唤醒的工作。
在同步队列中,还存在2种模式,分别是独占模式共享模式,这两种模式的区别就在于AQS在唤醒线程节点的时候是不是传递唤醒,这两种模式分别对应独占锁共享锁
AQS是一个抽象类,所以不能直接实例化,当我们需要实现一个自定义锁的时候可以去继承AQS然后重写获取锁的方式释放锁的方式还有管理state,而ReentrantLock就是通过重写了AQStryAcquiretryRelease方法实现的lockunlock

ReentrantLock

首先ReentrantLock继承自父类Lock,然后有3个内部类,其中Sync内部类继承自AQS,另外的两个内部类 FairSync 和 NonfairSync 继承自Sync,这两个类分别是用来公平锁和非公平锁的。
通过Sync重写的方法tryAcquiretryRelease可以知道,ReentrantLock实现的是AQS的独占模式,也就是独占锁,这个锁是悲观锁

ReentrantLock有个重要的成员变量:

private final Sync sync;

这个变量是用来指向Sync的子类的,也就是FairSync或者NonfairSync,这个也就是多态的父类引用指向子类,具体Sycn指向哪个子类,看构造方法:

public ReentrantLock() {
    sync = new NonfairSync();
}

public ReentrantLock(boolean fair) {
    sync = fair ? new FairSync() : new NonfairSync();
}

ReentrantLock有两个构造方法,无参构造方法默认是创建非公平锁,而传入true为参数的构造方法创建的是公平锁,传入 false 则创建非公平锁。

Synchronized和Lock的区别
  1. 锁的实现。synchronized 是 JVM 实现的,而 ReentrantLock 是 JDK 实现的。
  2. 性能。新版本 Java 对 synchronized 进行了很多优化,例如自旋锁等,synchronized 与 ReentrantLock 大致相同。
  3. 等待可中断。当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情。ReentrantLock 可中断,而 synchronized 不行。
  4. 公平锁。公平锁是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁。synchronized 中的锁是非公平的,ReentrantLock 默认情况下也是非公平的,但是也可以是公平的。
  5. 锁绑定多个条件。一个 ReentrantLock 可以同时绑定多个 Condition 对象。

使用选择:

除非需要使用 ReentrantLock 的高级功能,否则优先使用 synchronized。这是因为 synchronized 是 JVM 实现的一种锁机制,JVM 原生地支持它,而 ReentrantLock 不是所有的 JDK 版本都支持。并且使用 synchronized 不用担心没有释放锁而导致死锁问题,因为 JVM 会确保锁的释放。

公平锁与非公平锁

公平锁和非公平锁主要是从线程获取锁所等待的时间来区分两者的公平性。公平锁中,多个线程抢锁时,获取到锁的线程一定是同步队列中等待时间最长的线程。而非公平锁中,多个线程抢锁时,获取锁的线程不一定是同步队列中等待时间最长的线程,有可能是同步队列之外的线程先抢到锁。

公平锁:当持有锁的线程T1释放锁以后,会唤醒同步队列中的T2线程,只要同步队列中有线程在等待获取锁,那么其他刚进来想要获取锁的人,就不能插队,包括T1自己还想要获取锁,也需要去排队,这样就保证了让等待时间最长的线程获取到锁,即保证了公平性。

非公平锁:当持有锁的线程T1释放锁以后,会唤醒同步队列中的T2线程,此时即使同步队列中有线程在排队,从外面刚进来的线程想要获取锁,此时是可以直接去争抢锁的,包括线程T1自己,这个时候就是T2、T1、外面刚进来的线程一起去抢锁,虽然此时T2线程等待的时间最长,但是不能保证T2一定抢到锁,所以此时是不公平的。

非公平锁一定不公平吗?

答案是不一定,对于刚刚那种情况,非公平锁是不公平的。但是存在一种特殊情况,当T1线程释放锁以后,AQS同步队列外部没有线程来争抢锁,T1线程在释放锁以后,自己也不需要获取锁了,此时T1因为唤醒的是T2,现在是有T2一个线程来抢锁,所以此时能获取到锁的线程一定是T2,这种情况下,非公平锁又是公平的了,因为此时即使同步队列中有T3、T4、…、Tn线程,但是T2等待的时间最长,所以是T2获取到锁(AQS的同步队列遵循的原则是FIFO)。

性能

非公平锁的性能会优于公平锁。因为公平锁在获取锁时,永远是等待时间最长的线程获取到锁,这样当线程T1释放锁以后,如果还想继续再获取锁,它也得去同步队列尾部排队,这样就会频繁的发生线程的上下文切换,当线程越多,对CPU的损耗就会越严重。

IO

BIO

BIO 是同步阻塞式 IO,JDK1.4 之前的 IO 模型。服务器实现模式为一个连接请求对应一个线程,服务器需要为每一个客户端请求创建一个线程,如果这个连接不做任何事会造成不必要的线程开销。可以通过线程池改善,这种 IO 称为伪异步 IO。适用连接数目少且服务器资源多的场景。

NIO

NIO 是 JDK1.4 引入的同步非阻塞 IO。服务器实现模式为多个连接请求对应一个线程,客户端连接请求会注册到一个多路复用器 Selector ,Selector 轮询到连接有 IO 请求时才启动一个线程处理。适用连接数目多且连接时间短的场景。

同步是指线程还是要不断接收客户端连接并处理数据,非阻塞是指如果一个管道没有数据,不需要等待,可以轮询下一个管道。

核心组件:

  • **Selector:**多路复用器,轮询检查多个 Channel 的状态,判断注册事件是否发生,即判断 Channel 是否处于可读或可写状态。使用前需要将 Channel 注册到 Selector,注册后会得到一个 SelectionKey,通过 SelectionKey 获取 Channel 和 Selector 相关信息。

  • **Channel:**双向通道,替换了 BIO 中的 Stream 流,不能直接访问数据,要通过 Buffer 来读写数据,也可以和其他 Channel 交互。

  • **Buffer:**缓冲区,本质是一块可读写数据的内存,用来简化数据读写。Buffer 三个重要属性:position 下次读写数据的位置,limit 本次读写的极限位置,capacity 最大容量。

    • flip 将写转为读,底层实现原理把 position 置 0,并把 limit 设为当前的 position 值。
    • clear 将读转为写模式(用于读完全部数据的情况,把 position 置 0,limit 设为 capacity)。
    • compact 将读转为写模式(用于存在未读数据的情况,让 position 指向未读数据的下一个)。
    • 通道方向和 Buffer 方向相反,读数据相当于向 Buffer 写,写数据相当于从 Buffer 读。

    使用步骤:向 Buffer 写数据,调用 flip 方法转为读模式,从 Buffer 中读数据,调用 clear 或 compact 方法清空缓冲区。

BIO

AIO 是 JDK7 引入的异步非阻塞 IO。服务器实现模式为一个有效请求对应一个线程,客户端的 IO 请求都是由操作系统先完成 IO 操作后再通知服务器应用来直接使用准备好的数据。适用连接数目多且连接时间长的场景。

异步是指服务端线程接收到客户端管道后就交给底层处理IO通信,自己可以做其他事情,非阻塞是指客户端有数据才会处理,处理好再通知服务器。

实现方式包括通过 Future 的 get 方法进行阻塞式调用以及实现 CompletionHandler 接口,重写请求成功的回调方法 completed 和请求失败回调方法 failed

计算机基础

数据结构

哈希和红黑树的特点和应用场景

红黑树是有序的,Hash是无序的,根据需求来选择。

红黑树占用的内存更小(仅需要为其存在的节点分配内存),而Hash事先就应该分配足够的内存存储散列表(即使有些槽可能遭弃用)。

红黑树查找和删除的时间复杂度都是O(logn),Hash查找和删除的时间复杂度都是O(1)。

如果只需要判断Map中某个值是否存在之类的操作,当然是Hash实现的要更加高效。

如果是需要将两个Map求并集交集差集等大量比较操作,就是红黑树实现的Map更加高效。

计算机网络

三次握手

第一次握手:建立连接时,客户端发送syn包(syn=x)到服务器,并进入SYN_SENT状态,等待服务器确认;SYN:同步序列编号(Synchronize Sequence Numbers)。

第二次握手:服务器收到syn包,必须确认客户的SYN(ack=x+1),同时自己也发送一个SYN包(syn=y),即SYN+ACK包,此时服务器进入SYN_RECV状态;

第三次握手:客户端收到服务器的SYN+ACK包,向服务器发送确认包ACK(ack=y+1),此包发送完毕,客户端和服务器进入ESTABLISHED(TCP连接成功)状态,完成三次握手。

三次握手的原因:第三次握手是为了防止失效的连接请求到达服务器,让服务器错误打开连接。

客户端发送的连接请求如果在网络中滞留,那么就会隔很长一段时间才能收到服务器端发回的连接确认。客户端等待一个超时重传时间之后,就会重新请求连接。但是这个滞留的连接请求最后还是会到达服务器,如果不进行三次握手,那么服务器就会打开两个连接。如果有第三次握手,客户端会忽略服务器之后发送的对滞留连接请求的连接确认,不进行第三次握手,因此就不会再次打开连接。

四次挥手

  • 客户端-发送⼀个 FIN,⽤来关闭客户端到服务器的数据传送
  • 服务器-收到这个 FIN,它发回⼀个 ACK,确认序号为收到的序号加1 。和 SYN ⼀样,⼀个 FIN 将占⽤⼀个序号
  • 服务器-关闭与客户端的连接,发送⼀个FIN给客户端
  • 客户端-发回 ACK 报⽂确认,并将确认序号设置为收到序号加1

四次挥手的原因:客户端发送了 FIN 连接释放报文之后,服务器收到了这个报文,就进入了 CLOSE-WAIT 状态。这个状态是为了让服务器端发送还未传送完毕的数据,传送完毕之后,服务器会发送 FIN 连接释放报文。

Time Wait

time-wait开始的时间为tcp四次挥手中主动关闭连接方发送完最后一次挥手,也就是ACK=1的信号结束后,主动关闭连接方所处的状态。

然后time-wait的的持续时间为2MSL. MSL是Maximum Segment Lifetime,译为“报文最大生存时间”,可为30s,1min或2min。2msl就是2倍的这个时间。工程上为2min,2msl就是4min。但一般根据实际的网络情况进行确定。

TIME_WAIT状态存在的理由:

1.为了保证客户端发送的最后一个ack报文段能够到达服务器。因为这最后一个ack确认包可能会丢失,然后服务器就会超时重传第三次挥手的fin信息报,然后客户端再重传一次第四次挥手的ack报文。如果没有这2msl,客户端发送完最后一个ack数据报后直接关闭连接,那么就接收不到服务器超时重传的fin信息报(此处应该是客户端收到一个非法的报文段,而返回一个RST的数据报,表明拒绝此次通信,然后双方就产生异常,而不是收不到。),那么服务器就不能按正常步骤进入close状态。那么就会耗费服务器的资源。当网络中存在大量的timewait状态,那么服务器的压力可想而知。

2.在第四次挥手后,经过2msl的时间足以让本次连接产生的所有报文段都从网络中消失,这样下一次新的连接中就肯定不会出现旧连接的报文段了。也就是已经失效的连接请求报文段出现在本次连接中。如果没有的话就可能这样:这次连接一挥手完马上就结束了,没有timewait。这次连接中有个迷失在网络中的syn包,然后下次连接又马上开始,下个连接发送syn包,迷失的syn包忽然又到达了对面,所以对面可能同时收到或者不同时间收到请求连接的syn包,然后就出现问题了。

TIME_WAIT状态过多的危害

TIME_WAIT状态是TCP链接中正常产生的一个状态,但凡事都有利弊,TIME_WAIT状态过多会存在以下的问题:

(1)在socket的TIME_WAIT状态结束之前,该socket所占用的本地端口号将一直无法释放。这也是文章开头的提到问题的一个原因之一。

(2)在高并发(每秒几万qps)并且采用短连接方式进行交互的系统中运行一段时间后,系统中就会存在大量的time_wait状态,如果time_wait状态把系统所有可用端口都占完了且尚未被系统回收时,就会出现无法向服务端创建新的socket连接的情况。此时系统几乎停转,任何链接都不能建立。

(3)大量的time_wait状态也会系统一定的fd,内存和cpu资源,当然这个量一般比较小,并不是主要危害

如何优化TIME_WAIT过多的问题

调整短链接为长链接。长连接比短连接从根本上减少了关闭连接的次数,减少了TIME_WAIT状态的产生数量,在高并发的系统中,这种方式的改动非常有效果,可以明显减少系统TIME_WAIT的数量。

TIME_WAIT和CLOSE_WAIT状态区别

TIME_WAIT 是主动关闭链接时形成的,等待2MSL时间,约4分钟。主要是防止最后一个ACK丢失。 由于TIME_WAIT 的时间会非常长,因此server端应尽量减少主动关闭连接。

CLOSE_WAIT是被动关闭连接是形成的。根据TCP状态机,服务器端收到客户端发送的FIN,则按照TCP实现发送ACK,因此进入CLOSE_WAIT状态。但如果服务器端不执行close(),就不能由CLOSE_WAIT迁移到LAST_ACK,则系统中会存在很多CLOSE_WAIT状态的连接。

拥塞控制

TCP协议通过滑动窗口来进行流量控制,它是控制发送方的发送速度从而使接受者来得及接收并处理。而拥塞控制是作用于网络,它是防止过多的包被发送到网络中,避免出现网络负载过大,网络拥塞的情况。

拥塞控制主要是四个算法:1)慢启动,2)拥塞避免,3)快重传,4)快恢复。

img

TCP和UDP的区别

  • TCP 是面向连接的,UDP 是面向无连接的
  • TCP 是面向字节流的,UDP 是基于数据报的
  • TCP 保证数据正确性,UDP 可能丢包
  • TCP 保证数据顺序,UDP 不保证

如何理解TCP是基于字节流的

TCP发送报文时,是将应用层数据写入TCP缓冲区中,然后由TCP协议来控制发送这里面的数据,而发送的状态是按字节流的方式发送的,跟应用层写下来的报文长度没有任何关系,所以说是流。

另一方面,TCP给上层提供了正序的保证送达且不重复服务,正序、不重复和可靠都是TCP层要完成的工作,在上层看来就是一个不间断的数据流。

TCP协议如何保证可靠传输

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

TCP拆包/粘包

TCP拆包/粘包原因:

  1. 要发送的数据大于TCP发送缓冲区剩余空间大小,将会发生拆包。

  2. 待发送数据大于MSS(最大报文长度),TCP在传输前将进行拆包。

  3. 要发送的数据小于TCP发送缓冲区的大小,TCP将多次写入缓冲区的数据一次发送出去,将会发生粘包。

  4. 接收数据端的应用层没有及时读取接收缓冲区中的数据,将发生粘包。

解决方法:

关键在于如何给每个数据包添加边界信息,常用的方法有如下几个:

  1. 发送端给每个数据包添加包首部,首部中应该至少包含数据包的长度,这样接收端在接收到数据后,通过读取包首部的长度字段,便知道每一个数据包的实际长度了。
  2. 发送端将每个数据包封装为固定长度(不够的可以通过补0填充),这样接收端每次从接收缓冲区中读取固定长度的数据就自然而然的把每个数据包拆分开来。
  3. 可以在数据包之间设置边界,如添加特殊符号,这样,接收端通过这个边界就可以将不同的数据包拆分开。

HTTP状态码

  1xx   Informational(信息状态码)    接受请求正在处理
  2xx   Success(成功状态码)  请求正常处理完毕
  3xx   Redirection(重定向状态码) 需要附加操作已完成请求
  4xx   Client Error(客户端错误状态码)  服务器无法处理请求
  5xx   Server Error(服务器错误状态码)  服务器处理请求出错
  • 2XX

    • 200 (成功)
      表示客户端发送的请求在服务器被正常的处理了。
    • 204 (No Content)
      服务器成功处理了请求,但没有返回任何内容。
    • 206(Partial Content)
      服务器成功处理了请求,返回部分内容。
  • 3XX

    • 301 (Move Permanently)
      永久性重定向,请求的资源被分配了新的URI,以后都使用这个。
    • 302(Found)
      临时性重定向,请求的资源被分配了新的URI,本次使用这个。
    • 303(See Other)
      请求资源存在另一个URI,应使用get方法获取请求资源。
    • 304(Not Modified)
      客户端发送附带条件的请求时,服务器允许请求访问资源,但是没有合适的。
    • 307(Temporary Redirect)
      类似于302,只是302的POST方法会变为GET,而307的不会。
  • 4XX

    • 400 (Bad Request)
      请求报文存在语法错误。
    • 401 (Unauthorised)
      发送请求需要Http的认证信息。
    • 403(Forbidden)
      请求资源的访问被拒绝。
    • 404(Not Found)
      服务器上无法找到请求的资源。
  • 5XX

    • 500 (Internal Server Error)
      服务器端执行请求时发送异常。
    • 503(Server Unavailable)
      服务器暂时无法处理请求。

Get和Post的区别

  1. GET使用URL或Cookie传参,而POST将数据放在BODY中。
  2. GET方式提交的数据有长度限制,则POST的数据则可以非常大。
  3. POST比GET安全,因为数据在地址栏上不可见。
  4. 以上三点是它们在使用上的区别,最大的区别主要是GET请求是幂等性的,POST请求不是。

Get:请求指定页面信息,并返回实体主体;

Post:向指定资源提交数据进行处理请求(例如提交表单或者上传文件)。数据被包含在请求体中。POST请求可能会导致新的资源的建立和/或已有资源的修改。

Put和Post的区别

使用PUT时,必须明确知道要操作的对象,如果对象不存在,创建对象;如果对象存在,则全部替换目标对象。同样POST既可以创建对象,也可以修改对象。但用POST创建对象时,之前并不知道要操作的对象,由HTTP服务器为新创建的对象生成一个唯一的URI;使用POST修改已存在的对象时,一般只是修改目标对象的部分内容。也就是说PUT是“idempotent”(幂等),意味着相同的PUT请求不管执行多少次,结果都是一样的。但POST则不是。

HTTPS

对称加密和非对称加密

对称加密

对称加密,顾名思义就是加密和解密都是使用同一个密钥,常见的对称加密算法有 DES、3DES 和 AES 等,其优缺点如下:

  • 优点:算法公开、计算量小、加密速度快、加密效率高,适合加密比较大的数据。
  • 缺点:交易双方需要使用相同的密钥,也就无法避免密钥的传输,而密钥在传输过程中无法保证不被截获,因此对称加密的安全性得不到保证。

非对称加密

非对称加密,顾名思义,就是加密和解密需要使用两个不同的密钥:公钥(public key)和私钥(private key)。公钥与私钥是一对,如果用公钥对数据进行加密,只有用对应的私钥才能解密;如果用私钥对数据进行加密,那么只有用对应的公钥才能解密。非对称加密算法实现机密信息交换的基本过程是:甲方生成一对密钥并将其中的一把作为公钥对外公开;得到该公钥的乙方使用公钥对机密信息进行加密后再发送给甲方;甲方再用自己保存的私钥对加密后的信息进行解密。

HTTPS工作原理

HTTPS采用对称加密和非对称加密两者并用的混合加密机制。具体做法是:发送密文的一方使用对方的公钥进行加密处理,然后对方用自己的私钥解密拿到“对称的密钥”,这样可以确保交换的密钥是安全的前提下,使用对称加密方式进行通信。

HTTPS工作流程

1.Client发起一个HTTPS(比如https://juejin.im/user/4283353031252967)的请求,根据RFC2818的规定,Client知道需要连接Server的443(默认)端口。

2.Server把事先配置好的公钥证书(public key certificate)返回给客户端。

3.Client验证公钥证书:比如是否在有效期内,证书的用途是不是匹配Client请求的站点,是不是在CRL吊销列表里面,它的上一级证书是否有效,这是一个递归的过程,直到验证到根证书(操作系统内置的Root证书或者Client内置的Root证书)。如果验证通过则继续,不通过则显示警告信息。

4.Client使用伪随机数生成器生成加密所使用的对称密钥,然后用证书的公钥加密这个对称密钥,发给Server。

5.Server使用自己的私钥(private key)解密这个消息,得到对称密钥。至此,Client和Server双方都持有了相同的对称密钥。

6.Server使用对称密钥加密“明文内容A”,发送给Client。

7.Client使用对称密钥解密响应的密文,得到“明文内容A”。

8.Client再次发起HTTPS的请求,使用对称密钥加密请求的“明文内容B”,然后Server使用对称密钥解密密文,得到“明文内容B”。

HTTP与HTTPS的区别
  • 最最重要的区别就是安全性,HTTP 明文传输,不对数据进行加密安全性较差。HTTPS (HTTP + SSL / TLS)的数据传输过程是加密的,安全性较好。
  • 使用 HTTPS 协议需要申请 CA 证书,一般免费证书较少,因而需要一定费用。证书颁发机构如:Symantec、Comodo、DigiCert 和 GlobalSign 等。
  • HTTP 页面响应速度比 HTTPS 快,这个很好理解,由于加了一层安全层,建立连接的过程更复杂,也要交换更多的数据,难免影响速度。
  • HTTPS 比 HTTP 更耗费服务器资源。
  • HTTPS 和 HTTP 使用的是完全不同的连接方式,用的端口也不一样,前者是 443,后者是 80。

session,cookie,token

http是一个无状态协议

什么是无状态呢?就是说这一次请求和上一次请求是没有任何关系的,互不认识的,没有关联的。这种无状态的的好处是快速。坏处是假如我们想要把www.zhihu.com/login.htmlwww.zhihu.com/index.html关联起来,必须使用某些手段和工具。

分布式情况下的session和token

我们已经知道session时有状态的,一般存于服务器内存或硬盘中,当服务器采用分布式或集群时,session就会面对负载均衡问题。

  • 负载均衡多服务器的情况,不好确认当前用户是否登录,因为多服务器不共享session。这个问题也可以将session存在一个服务器中来解决,但是就不能完全达到负载均衡的效果。

而token是无状态的,token字符串里就保存了所有的用户信息

  • 客户端登陆传递信息给服务端,服务端收到后把用户信息加密(token)传给客户端,客户端将token存放于localStroage等容器中。客户端每次访问都传递token,服务端解密token,就知道这个用户是谁了。通过cpu加解密,服务端就不需要存储session占用存储空间,就很好的解决负载均衡多服务器的问题了。

总结

  • session存储于服务器,可以理解为一个状态列表,拥有一个唯一识别符号sessionId,通常存放于cookie中。服务器收到cookie后解析出sessionId,再去session列表中查找,才能找到相应session。依赖cookie。
  • cookie类似一个令牌,装有sessionId,存储在客户端,浏览器通常会自动添加。
  • token也类似一个令牌,无状态,用户信息都被加密到token中,服务器收到token后解密就可知道是哪个用户。需要开发者手动添加。

操作系统

进程和线程的区别

Ⅰ 拥有资源

进程是资源分配的基本单位,但是线程不拥有资源,线程可以访问隶属进程的资源。

Ⅱ 调度

线程是独立调度的基本单位,在同一进程中,线程的切换不会引起进程切换,从一个进程中的线程切换到另一个进程中的线程时,会引起进程切换。

Ⅲ 系统开销

由于创建或撤销进程时,系统都要为之分配或回收资源,如内存空间、I/O 设备等,所付出的开销远大于创建或撤销线程时的开销。类似地,在进行进程切换时,涉及当前执行进程 CPU 环境的保存及新调度进程 CPU 环境的设置,而线程切换时只需保存和设置少量寄存器内容,开销很小。

Ⅳ 通信方面

线程间可以通过直接读写同一进程中的数据进行通信,但是进程通信需要借助 IPC。

死锁

死锁的必要条件

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

如何避免(预防)死锁

  1. 破坏“请求和保持”条件:让进程在申请资源时,一次性申请所有需要用到的资源,不要一次一次来申请,当申请的资源有一些没空,那就让线程等待。不过这个方法比较浪费资源,进程可能经常处于饥饿状态。还有一种方法是,要求进程在申请资源前,要释放自己拥有的资源。
  2. 破坏“不可抢占”条件:允许进程进行抢占,方法一:如果去抢资源,被拒绝,就释放自己的资源。方法二:操作系统允许抢,只要你优先级大,可以抢到。
  3. 破坏“循环等待”条件:将系统中的所有资源统一编号,进程可在任何时刻提出资源申请,但所有申请必须按照资源的编号顺序提出(指定获取锁的顺序,顺序加锁)。

孤儿进程/僵尸进程

在unix/linux中,正常情况下,子进程是通过父进程创建的。子进程的结束和父进程的运行是一个异步过程,即父进程永远无法预测子进程到底什么时候结束。当一个进程完成它的工作终止之后,它的父进程需要调用wait()或者waitpid()系统调用取得子进程的终止状态。

孤儿进程

一个父进程退出,而它的一个或多个子进程还在运行,那么那些子进程将成为孤儿进程。孤儿进程将被init进程(进程号为1)所收养,并由init进程对它们完成状态收集工作。
由于孤儿进程会被init进程给收养,所以孤儿进程不会对系统造成危害。

僵尸进程

一个进程使用fork创建子进程,如果子进程退出,而父进程并没有调用wait或waitpid获取子进程的状态信息,那么子进程的进程描述符仍然保存在系统中。这种进程称之为僵死进程。

僵死进程的危害

unix提供了一种机制可以保证只要父进程想知道子进程结束时的状态信息,就可以得到。这种机制就是: 在每个进程退出的时候,内核释放该进程所有的资源,包括打开的文件,占用的内存等,但是仍然为其保留一定的信息(包括进程号the process ID,退出状态the termination status of the process,运行时间the amount of CPU time taken by the process等),直到父进程通过wait / waitpid来取时才释放。但这样就导致了问题,如果进程不调用wait/waitpid的话,那么保留的那段信息就不会释放,其进程号就会一直被占用,但是系统所能使用的进程号是有限的,如果大量的产生僵死进程,将因为没有可用的进程号而导致系统不能产生新的进程. 此即为僵尸进程的危害,应当避免。

任何一个子进程(init除外)在exit()之后,并非马上就消失掉,而是留下一个称为僵尸进程(Zombie)的数据结构,等待父进程处理。这是每个子进程在结束时都要经过的阶段。如果子进程在exit()之后,父进程没有来得及处理,这时用ps命令就能看到子进程的状态是“Z”。如果父进程能及时 处理,可能用ps命令就来不及看到子进程的僵尸状态,但这并不等于子进程不经过僵尸状态。 如果父进程在子进程结束之前退出,则子进程将由init接管。init将会以父进程的身份对僵尸状态的子进程进行处理。

一个进程如果只复制fork子进程而不负责对子进程进行wait()或是waitpid()调用来释放其所占有资源的话,那么就会产生很多的僵死进程,如果要消灭系统中大量的僵死进程,只需要将其父进程杀死,此时所有的僵死进程就会编程孤儿进程,从而被init所收养,这样init就会释放所有的僵死进程所占有的资源,从而结束僵死进程。

Linux

查看进程pid

ps -ef | grep 进程名

查看占用端口

netstat -nap | grep 进程pid

文本去重

sort:排序 uniq:去重

由于uniq只能对临近的行去重,所以需要先排序再去重,即

sort 1.txt | uniq >unique_1.txt

查看负载

top

查看大文件

less / head -n / tail -n

一个文件输出5-10行

sed -n ‘5,10p’ filename

数据库

MySQL

InnoDB 和 MyISAM

MyISAM 记录了表的总行数,而 InnoDB 没有。比如 SELECT count(*) FROM table1;

对于以上查询操作,

对于MyISAM:因为MySQL对该引擎的count有对应优化,精确的行数会被储存在存储引擎中,因此此类没有where条件的单表总行数查询会迅速返回结果。

对于InnoDB:因为InnoDB的事务特性,在同一时刻表中的行数对于不同的事务而言是不一样的,因此count统计会计算对于当前事务而言可以统计到的行数,而不是将总行数储存起来方便快速查询。

InnoDB和MyISAM的比较

MyISAM:

每个MyISAM在磁盘上存储成三个文件。第一个文件的名字以表的名字开始,扩展名指出文件类型。.frm文件存储表定义。数据文件的扩展名为.MYD (MYData)。

MyISAM表格可以被压缩,而且它们支持全文搜索。不支持事务,而且也不支持外键。如果事物回滚将造成不完全回滚,不具有原子性。在进行updata时进行表锁,并发量相对较小。如果执行大量的SELECT,MyISAM是更好的选择。

MyISAM的索引和数据是分开的,并且索引是有压缩的,内存使用率就对应提高了不少。能加载更多索引,而Innodb是索引和数据是紧密捆绑的,没有使用压缩从而会造成Innodb比MyISAM体积庞大不小

MyISAM缓存在内存的是索引,不是数据。而InnoDB缓存在内存的是数据,相对来说,服务器内存越大,InnoDB发挥的优势越大。

**优点:**查询数据相对较快,适合大量的select,可以全文索引。

**缺点:**不支持事务,不支持外键,并发量较小,不适合大量update

InnoDB:

这种类型是事务安全的。它与BDB类型具有相同的特性,它们还支持外键。InnoDB表格速度很快。具有比BDB还丰富的特性,因此如果需要一个事务安全的存储引擎,建议使用它。在update时表进行行锁,并发量相对较大。如果你的数据执行大量的INSERT或UPDATE,出于性能方面的考虑,应该使用InnoDB表。

**优点:**支持事务,支持外键,并发量较大,适合大量update

**缺点:**查询数据相对较快,不适合大量的select

对于支持事物的InnoDB类型的表,影响速度的主要原因是AUTOCOMMIT默认设置是打开的,而且程序没有显式调用BEGIN 开始事务,导致每插入一条都自动Commit,严重影响了速度。可以在执行sql前调用begin,多条sql形成一个事物(即使autocommit打开也可以),将大大提高性能。

基本的差别为:MyISAM类型不支持事务处理等高级处理,而InnoDB类型支持。

MyISAM类型的表强调的是性能,其执行数度比InnoDB类型更快,但是不提供事务支持,而InnoDB提供事务支持已经外部键等高级数据库功能。

比较

  • 事务:InnoDB 是事务型的,可以使用 Commit 和 Rollback 语句。
  • 并发:MyISAM 只支持表级锁,而 InnoDB 还支持行级锁。
  • 外键:InnoDB 支持外键。
  • 备份:InnoDB 支持在线热备份。
  • 崩溃恢复:MyISAM 崩溃后发生损坏的概率比 InnoDB 高很多,而且恢复的速度也更慢。
  • 其它特性:MyISAM 支持压缩表和空间数据索引。

MySQL锁

乐观锁

用数据版本(Version)记录机制实现,这是乐观锁最常用的一种实现方式。何谓数据版本?即为数据增加一个版本标识,一般是通过为数据库表增加一个数字类型的 “version” 字段来实现。当读取数据时,将version字段的值一同读出,数据每更新一次,对此version值加1。当我们提交更新的时候,判断数据库表对应记录的当前版本信息与第一次取出来的version值进行比对,如果数据库表当前版本号与第一次取出来的version值相等,则予以更新,否则认为是过期数据。

update TABLE
set value=2,version=version+1
where id=#{id} and version=#{version}
悲观锁

共享锁和排它锁是悲观锁的不同的实现,它俩都属于悲观锁的范畴。

共享锁

共享锁又称读锁 (read lock),是读取操作创建的锁。其他用户可以并发读取数据,但任何事务都不能对数据进行修改(获取数据上的排他锁),直到已释放所有共享锁。当如果事务对读锁进行修改操作,很可能会造成死锁。

如果事务T对数据A加上共享锁后,则其他事务只能对A再加共享锁,不能加排他锁。获得共享锁的事务只能读数据,不能修改数据。

命令:lock in share mode

排他锁

排他锁 exclusive lock(也叫writer lock)又称写锁。若某个事务对某一行加上了排他锁,只能这个事务对其进行读写,在此事务结束之前,其他事务不能对其进行加任何锁,其他进程可以读取,不能进行写操作,需等待其释放。

排它锁会阻塞所有的排它锁和共享锁。

读取为什么要加读锁呢?防止数据在被读取的时候被别的线程加上写锁。 排他锁使用方式:在需要执行的语句后面加上for update就可以了 select status from TABLE where id=1 for update;

行锁

只有通过索引条件检索数据,InnoDB才使用行级锁,否则,InnoDB将使用表锁!

间隙锁

当我们用范围条件而不是相等条件检索数据,并请求共享或排他锁时,InnoDB会给符合条件的已有数据记录的索引项加锁;对于键值在条件范围内但并不存在的记录,叫做“间隙(GAP)”,InnoDB也会对这个“间隙”加锁,这种锁机制就是所谓的间隙锁(Next-Key锁)。举例来说,假如emp表中只有101条记录,其empid的值分别是 1,2,…,100,101,下面的SQL:

Select * from  emp where empid > 100 for update;

是一个范围条件的检索,InnoDB不仅会对符合条件的empid值为101的记录加锁,也会对empid大于101(这些记录并不存在)的“间隙”加锁。

另外,InnoDB除了通过范围条件加锁时使用间隙锁外,如果使用相等条件请求给一个不存在的记录加锁,InnoDB也会使用间隙锁。

InnoDB使用间隙锁的目的,主要是为了防止幻读,以满足相关隔离级别的要求,对于上面的例子,要是不使用间隙锁,如果其他事务插入了empid大于100的任何记录,那么本事务如果再次执行上述语句,就会发生幻读。

MVCC

MVCC(Multiversion concurrency control) 就是同一份数据临时保留多版本的一种方式,进而实现并发控制。

当一个 MVCC 数据库需要更一个一条数据记录的时候,它不会直接用新数据覆盖旧数据,而是将旧数据标记为过时(obsolete)并在别处增加新版本的数据。这样就会有存储多个版本的数据,但是只有一个是最新的。这种方式允许读者读取在他读之前已经存在的数据,即使这些在读的过程中半路被别人修改、删除了,也对先前正在读的用户没有影响。

MVCC 并发控制下的读事务一般使用时间戳或者事务ID去标记当前读的数据库的状态(版本),读取这个版本的数据。读、写事务相互隔离,不需要加锁。读写并存的时候,写操作会根据目前数据库的状态,创建一个新版本,并发的读则依旧访问旧版本的数据。

MVCC逻辑流程-插入

在MySQL中建表时,每个表都会有三列隐藏记录,其中和MVCC有关系的有两列

  • 数据行的版本号 (DB_TRX_ID)
  • 删除版本号 (DB_ROLL_PT)
idtest_idDB_TRX_IDDB_ROLL_PT

在插入数据的时候,假设系统的全局事务ID从1开始,以下SQL语句执行分析参考注释信息:

begin;-- 获取到全局事务ID
insert into `test_zq` (`id`, `test_id`) values('5','68');
insert into `test_zq` (`id`, `test_id`) values('6','78');
commit;-- 提交事务

当执行完以上SQL语句之后,表格中的内容会变成:

idtest_idDB_TRX_IDDB_ROLL_PT
5681NULL
6781NULL

可以看到,插入的过程中会把全局事务ID记录到列 DB_TRX_ID 中去

MVCC逻辑流程-删除

对上述表格做删除逻辑,执行以下SQL语句(假设获取到的事务逻辑ID为3)

begin;--获得全局事务ID = 3
delete test_zq where id = 6;
commit;

执行完上述SQL之后数据并没有被真正删除,而是对删除版本号做改变,如下所示:

idtest_idDB_TRX_IDDB_ROLL_PT
5681NULL
67813

MVCC逻辑流程-修改

修改逻辑和删除逻辑有点相似,修改数据的时候 会先复制一条当前记录行数据,同事标记这条数据的数据行版本号为当前是事务版本号,最后把原来的数据行的删除版本号标记为当前是事务。

执行以下SQL语句:

begin;-- 获取全局系统事务ID 假设为 10
update test_zq set test_id = 22 where id = 5;
commit;

执行后表格实际数据应该是:

idtest_idDB_TRX_IDDB_ROLL_PT
568110
67813
52210NULL

MVCC逻辑流程-查询

此时,数据查询规则如下:

  • 查找数据行版本号早于当前事务版本号的数据行记录

    也就是说,数据行的版本号要小于或等于当前是事务的系统版本号,这样也就确保了读取到的数据是当前事务开始前已经存在的数据,或者是自身事务改变过的数据

  • 查找删除版本号要么为NULL,要么大于当前事务版本号的记录

    这样确保查询出来的数据行记录在事务开启之前没有被删除

根据上述规则,我们继续以上张表格为例,对此做查询操作

begin;-- 假设拿到的系统事务ID为 12
select * from test_zq;
commit;

执行结果应该是:

idtest_idDB_TRX_IDDB_ROLL_PT
52210NULL

MVCC如何实现数据库读已提交和可重复读

MVCC其实主要包含三个概念:隐藏列,undo log,ReadView。

隐藏列

在Innodb引擎中,每个数据表都会有两个隐藏列,分别是trx_id,创建版本号和roll_pointer,回滚指针。其中创建版本号其实就是创建该行数据的事务id。

undo log

如果在一个事务中多次对记录进行修改,则每次修改都会生成undo日志,并且这些undo日志通过回滚指针串联成一个版本链,版本链的头结点是该记录最新的值,尾结点是事务开始时的初始值。

例如,我们在表book中做以下修改:

BEGIN;

UPDATE book SET stock = 200 WHERE id = 1;

UPDATE book SET stock = 300 WHERE id = 1;
复制代码

那么id=1的记录此时的版本链就如下图所示:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-NtEjZj9a-1640000025234)(https://user-gold-cdn.xitu.io/2020/3/19/170f31e5989d7caf?imageView2/0/w/1280/h/960/format/webp/ignore-error/1)]

ReadView

当进行查询操作时,事务会生成一个ReadView,ReadView是一个事务快照,准确来说是当前时间点系统内活跃的事务列表,也就是说系统内所有未提交的事务,都会记录在这个Readview内,事务就根据它来判断哪些数据是可见的,哪些是不可见的。

查询一条数据时,事务会拿到这个ReadView,去到undo log中进行判断。若查询到某一条数据:

  • 先去查看undo log中的最新数据行,如果数据行的版本号小于ReadView记录的事务id最小值,就说明这条数据对当前数据库是可见的,可以直接作为结果集返回
  • 若数据行版本号大于ReadView记录最大值,说明这条数据是由一个新的事务修改的,对当前事务不可见,那么就顺着版本链继续往下寻找第一条满足条件的
  • 若数据行版本号在ReadView最小值和最大值之间,那么就需要进行遍历整个ReadView了,如果数据行版本号等于ReadView的某个值,说说明该行数据仍然处于活跃状态,那么对当前事务不可见。

读已提交和可重复读的实现

ReadView就是这样来判断数据可见性的。

那又是如何实现读已提交和可重复读呢?其实很简单,就是生成ReadView的时机不同。

对读已提交来说,事务中的每次读操作都会生成一个新的ReadView,也就是说,如果这期间某个事务提交了,那么它就会从ReadView中移除。这样确保事务每次读操作都能读到相对比较新的数据。

而对可重复读来说,事务只有在第一次进行读操作时才会生成一个ReadView,后续的读操作都会重复使用这个ReadView。也就是说,如果在此期间有其他事务提交了,那么对于可重复读来说也是不可见的,因为对它来说,事务活跃状态在第一次进行读操作时就已经确定下来,后面不会修改了。

慢SQL的解决思路

开启SQL慢查询的日志

MySQL 提供了慢查询日志,这个日志会记录所有执行时间超过 long_query_time(默认是10s)的 SQL 及相关的信息。

SQL调优

有些SQL虽然出现在慢查询日志中,但未必是其本身的性能问题,可能是因为锁等待,服务器压力高等等。需要分析SQL语句真实的执行计划,而不是看重新执行一遍SQL时,花费了多少时间,由自带的慢查询日志或者开源的慢查询系统定位到具体的出问题的SQL,然后使用Explain工具来逐步调优,了解 MySQL 在执行这条数据时的一些细节,比如是否进行了优化、是否使用了索引等等。基于 Explain 的返回结果我们就可以根据 MySQL 的执行细节进一步分析是否应该优化搜索、怎样优化索引。

Explain:执行计划是 SQL 调优的一个重要依据,可以通过 EXPLAIN 命令查看 SQL 语句的执行计划。

重要字段:

id:表示 SELECT 子句或操作表的顺序,执行顺序从大到小执行。

select_type:表示查询中每个 SELECT 子句的类型,例如 SIMPLE 表示不包含子查询、表连接或其他复杂语法的简单查询。

type:表示访问类型,性能由差到好为:ALL 全表扫描、index 索引全扫描、range 索引范围扫描、ref 返回匹配某个单独值得所有行。

key:显示 MySQL 在查询时实际使用的索引,如果没有使用则显示为 NULL。

一些优化方法:

1.创建必要的索引

在经常需要进行检索的字段上创建索引,比如要按照姓名进行检索,那么就应该在姓名字段上创建索引,如果经常要按照员工部门和员工岗位级别进行检索,那么就应该在员工部门和员工岗位级别这两个字段上创建索引。创建索引给检索带来的性能提升往往是巨大的,因此在发现检索速度过慢的时候应该首先想到的就是创建索引。

2.使用预编译查询

程序中通常是根据用户的输入来动态执行SQL,这时应该尽量使用参数化SQL,这样不仅可以避免SQL注入漏洞攻击,最重要数据库会对这些参数化SQL进行预编译,这样第一次执行的时候DBMS会为这个SQL语句进行查询优化并且执行预编译,这样以后再执行这个SQL的时候就直接使用预编译的结果,这样可以大大提高执行的速度。

3.调整Where字句中的连接顺序

DBMS一般采用自下而上的顺序解析where字句,根据这个原理表连接最好写在其他where条件之前,那些可以过滤掉最大数量记录。

4.尽量将多条SQL语句压缩到一句

SQL中每次执行SQL的时候都要建立网络连接、进行权限校验、进行SQL语句的查询优化、发送执行结果,这个过程是非常耗时的,因此应该尽量避免过多的执行SQL语句,能够压缩到一句SQL执行的语句就不要用多条来执行。

5.用where字句替换HAVING字句

避免使用HAVING字句,因为HAVING只会在检索出所有记录之后才对结果集进行过滤,而where则是在聚合前刷选记录,如果能通过where字句限制记录的数目,那就能减少这方面的开销。HAVING中的条件一般用于聚合函数的过滤,除此之外,应该将条件写在where字句中。

6.使用表的别名

当在SQL语句中连接多个表时,请使用表的别名并把别名前缀于每个列名上。这样就可以减少解析的时间并减少那些由列名歧义引起的语法错误。

7.在in和exists中通常情况下使用EXISTS,因为in不走索引。

8.避免在索引上使用计算

在where字句中,如果索引列是计算或者函数的一部分,DBMS的优化器将不会使用索引而使用全表查询,函数属于计算的一种

效率低:select * from Employee where dsalary*12>2500.0(dsalary是索引列,索引不起作用)

效率高:select * from Employee where dsalary>2500.0/12(dsalary是索引列)

9.用union all替换union

当SQL语句需要union两个查询结果集合时,即使检索结果中不会有重复的记录,如果使用union这两个结果集同样会尝试进行合并,然后在输出最终结果前进行排序,因此如果可以判断检索结果中不会有重复的记录时候,应该用union all,这样效率就会因此得到提高。

10.避免SQL中出现隐式类型转换

当某一张表中的索引字段在作为where条件的时候,如果进行了隐式类型转换,则此索引字段将会不被识别,因为隐式类型转换也属于计算,所以此时DBMS会使用全表扫描。

一条SQL语句在MySQL中如何执行的

MySQL 主要分为 Server 层和引擎层,Server 层主要包括连接器、查询缓存、分析器、优化器、执行器,同时还有一个日志模块(binlog),这个日志模块所有执行引擎都可以共用,redolog 只有 InnoDB 有。

SQL 等执行过程分为两类,一类对于查询等过程如下:权限校验—》查询缓存—》分析器—》优化器—》权限校验—》执行器—》引擎。

对于更新等语句执行流程如下:分析器----》权限校验----》执行器—》引擎—redo log prepare—》binlog—》redo log commit。

查询语句

select * from tb_student  A where A.age='18' and A.name=' 张三 ';

结合上面的说明,我们分析下这个语句的执行流程:

  • 先检查该语句是否有权限,如果没有权限,直接返回错误信息,如果有权限,在 MySQL8.0 版本以前,会先查询缓存,以这条 sql 语句为 key 在内存中查询是否有结果,如果有直接缓存,如果没有,执行下一步。
  • 通过分析器进行词法分析,提取 sql 语句的关键元素,比如提取上面这个语句是查询 select,提取需要查询的表名为 tb_student,需要查询所有的列,查询条件是这个表的 id=‘1’。然后判断这个 sql 语句是否有语法错误,比如关键词是否正确等等,如果检查没问题就执行下一步。
  • 接下来就是优化器进行确定执行方案,上面的 sql 语句,可以有两种执行方案:
  a.先查询学生表中姓名为“张三”的学生,然后判断是否年龄是 18。  
  b.先找出学生中年龄 18 岁的学生,然后再查询姓名为“张三”的学生。

那么优化器根据自己的优化算法进行选择执行效率最好的一个方案(优化器认为,有时候不一定最好)。那么确认了执行计划后就准备开始执行了。

  • 进行权限校验,如果没有权限就会返回错误信息,如果有权限就会调用数据库引擎接口,返回引擎的执行结果。

更新语句

update tb_student A set A.age='19' where A.name=' 张三 ';

其实这条语句也基本上会沿着上一个查询的流程走,只不过执行更新的时候肯定要记录日志啦,这就会引入日志模块了,MySQL 自带的日志模块式 binlog(归档日志),所有的存储引擎都可以使用,我们常用的 InnoDB 引擎还自带了一个日志模块 redo log(重做日志),我们就以 InnoDB 模式下来探讨这个语句的执行流程。流程如下:

  • 先查询到张三这一条数据,如果有缓存,也是会用到缓存。
  • 然后拿到查询的语句,把 age 改为 19,然后调用引擎 API 接口,写入这一行数据,InnoDB 引擎把数据保存在内存中,同时记录 redo log,此时 redo log 进入 prepare 状态,然后告诉执行器,执行完成了,随时可以提交。
  • 执行器收到通知后记录 binlog,然后调用引擎接口,提交 redo log 为提交状态。
  • 更新完成。

这里肯定有同学会问,为什么要用两个日志模块,用一个日志模块不行吗?

这是因为最开始 MySQL 并没与 InnoDB 引擎( InnoDB 引擎是其他公司以插件形式插入 MySQL 的) ,MySQL 自带的引擎是 MyISAM,但是我们知道 redo log 是 InnoDB 引擎特有的,其他存储引擎都没有,这就导致会没有 crash-safe 的能力(crash-safe 的能力即使数据库发生异常重启,之前提交的记录都不会丢失),binlog 日志只能用来归档。

并不是说只用一个日志模块不可以,只是 InnoDB 引擎就是通过 redo log 来支持事务的。那么,又会有同学问,我用两个日志模块,但是不要这么复杂行不行,为什么 redo log 要引入 prepare 预提交状态?这里我们用反证法来说明下为什么要这么做?

  • 先写 redo log 直接提交,然后写 binlog,假设写完 redo log 后,机器挂了,binlog 日志没有被写入,那么机器重启后,这台机器会通过 redo log 恢复数据,但是这个时候 bingog 并没有记录该数据,后续进行机器备份的时候,就会丢失这一条数据,同时主从同步也会丢失这一条数据。
  • 先写 binlog,然后写 redo log,假设写完了 binlog,机器异常重启了,由于没有 redo log,本机是无法恢复这一条记录的,但是 binlog 又有记录,那么和上面同样的道理,就会产生数据不一致的情况。

如果采用 redo log 两阶段提交的方式就不一样了,写完 binglog 后,然后再提交 redo log 就会防止出现上述的问题,从而保证了数据的一致性。那么问题来了,有没有一个极端的情况呢?假设 redo log 处于预提交状态,binglog 也已经写完了,这个时候发生了异常重启会怎么样呢? 这个就要依赖于 MySQL 的处理机制了,MySQL 的处理过程如下:

  • 判断 redo log 是否完整,如果判断是完整的,就立即提交。
  • 如果 redo log 只是预提交但不是 commit 状态,这个时候就会去判断 binlog 是否完整,如果完整就提交 redo log, 不完整就回滚事务。

这样就解决了数据一致性的问题。

SQL执行顺序

from–where–group by–having–select–order by

索引

Hash

哈希算法有个数据碰撞的问题,也就是哈希函数可能对不同的 key 会计算出同一个结果,比如 hash(7)可能跟 hash(199)计算出来的结果一样,也就是不同的 key 映射到同一个结果了,这就是碰撞问题。解决碰撞问题的一个常见处理方式就是链地址法,即用链表把碰撞的数据接连起来。计算哈希值之后,还需要检查该哈希值是否存在碰撞数据链表,有则一直遍历到链表尾,直达找到真正的 key 对应的数据为止。

对于单列数据来讲,哈希算法时间复杂度为 O(1),检索速度非常快,但对于范围查找来说,哈希基本没什么用。

二叉搜索树

二叉查找树的时间复杂度是 O(lgn),范围查找的效率也很高。但二叉查找树极端情况下会退化为线性链表,二分查找也会退化为遍历查找,时间复杂退化为 O(N),检索性能急剧下降。

红黑树

红黑树会自动调整树形态的树结构,比如当二叉树处于一个不平衡状态时,红黑树就会自动左旋右旋节点以及节点变色,调整树的形态,使其保持基本的平衡状态(时间复杂度为 O(logn)),也就保证了查找效率不会明显减低。但红黑树的左旋右旋很消耗性能,同时还有右倾的问题,数据量大的时候性能损耗也很严重。

B树,B+树

从磁盘读取 1B 数据和 1KB 数据所消耗的时间是基本一样的(空间局部性与时间局部性决定),根据这个思路,可以在一个树节点上尽可能多地存储数据,一次磁盘 IO 就尽可能多的加载数据到内存,影响数据查询时间的是树的高度,高度越高,比较的次数越多,尽量把树的高度降低。这就是 B 树的的设计原理了。

B 树一个节点里存的是数据,而B+树节点存储的是索引,在单个节点存储容量有限的情况下,单节点也能存储大量索引,使得整个 B+树高度降低,减少了磁盘 IO。其次,B+树的叶子节点是真正数据存储的地方,叶子节点用了链表连接起来,这个链表本身就是有序的,在数据范围查找时,更具备效率。

自适应哈希索引

自适应哈希索引是 InnoDB 引擎的一个特殊功能,当它注意到某些索引值被使用的非常频繁时,会在内存中基于 B-Tree 索引之上再创键一个哈希索引,这样就让 B-Tree 索引也具有哈希索引的一些优点,比如快速哈希查找。这是一个完全自动的内部行为,用户无法控制或配置,但如果有必要可以关闭该功能。

建立索引原则
  1. 最左前缀匹配原则,非常重要的原则,mysql会一直向右匹配直到遇到范围查询(>、<、between、like)就停止匹配,比如a = 1 and b = 2 and c > 3 and d = 4 如果建立(a,b,c,d)顺序的索引,d是用不到索引的,如果建立(a,b,d,c)的索引则都可以用到,a,b,d的顺序可以任意调整。
  2. 选择唯一性索引。唯一性索引的值是唯一的,可以更快速的通过该索引来确定某条记录。例如,学生表中学号是具有唯一性的字段。为该字段建立唯一性索引可以很快的确定某个学生的信息。如果使用姓名的话,可能存在同名现象,从而降低查询速度。
  3. 尽量选择区分度高的列作为索引,区分度的公式是count(distinct col)/count(*),表示字段不重复的比例,比例越大我们扫描的记录数越少,唯一键的区分度是1,而一些状态、性别字段可能在大数据面前区分度就是0,那可能有人会问,这个比例有什么经验值吗?使用场景不同,这个值也很难确定,一般需要join的字段我们都要求是0.1以上,即平均1条扫描10条记录
  4. 索引列不能参与计算,保持列“干净”,比如from_unixtime(create_time) = ’2014-05-29’就不能使用到索引,原因很简单,b+树中存的都是数据表中的字段值,但进行检索时,需要把所有元素都应用函数才能比较,显然成本太大。所以语句应该写成create_time = unix_timestamp(’2014-05-29’);
  5. 尽量的扩展索引,不要新建索引。比如表中已经有a的索引,现在要加(a,b)的索引,那么只需要修改原来的索引即可
  6. Where子句中经常使用的字段应该创建索引,分组字段或者排序字段应该创建索引,两个表的连接字段应该创建索引。
  7. like 模糊查询中,右模糊查询(321%)会使用索引,而%321 和%321%会放弃索引而使用全局扫描。
索引失效
  1. 如果条件中有or,即使其中有条件带索引也不会使用;
  2. 对于多列索引,不是使用的第一部分(第一个),则不会使用索引;
  3. like查询是以%开头;
  4. 如果列类型是字符串,那一定要在条件中将数据使用引号引用起来,否则不使用索引;
  5. 如果mysql预估使用全表扫描要比使用索引快,则不使用索引;
  6. 避免在where子句中使用!= ,< >这样的符号,否则会导致引擎放弃索引而产生全表扫描。

并发事务带来的问题

  • 脏读(Dirty read): 当⼀个事务正在访问数据并且对数据进⾏了修改,⽽这种修改还没有提交到数据库中,这时另外⼀个事务也访问了这个数据,然后使⽤了这个数据。因为这个数据是还没有提交的数据,那么另外⼀个事务读到的这个数据是“脏数据”,依据“脏数据”所做的操作可能是不正确的。
  • 丢失修改(Lost to modify): 指在⼀个事务读取⼀个数据时,另外⼀个事务也访问了该数据, 那么在第⼀个事务中修改了这个数据后,第⼆个事务也修改了这个数据。这样第⼀个事务内的修改结果就被丢失,因此称为丢失修改。
  • 不可重复读(Unrepeatableread): 指在⼀个事务内多次读同⼀数据。在这个事务还没有结束时,另⼀个事务也访问该数据。那么,在第⼀个事务中的两次读数据之间,由于第⼆个事务的修改导致第⼀个事务两次读取的数据可能不太⼀样。这就发⽣了在⼀个事务内两次读到的数据是不⼀样的情况,因此称为不可重复读。
  • 幻读(Phantom read): 幻读与不可重复读类似。它发⽣在⼀个事务(T1)读取了⼏⾏数据,接着另⼀个并发事务(T2)插⼊了⼀些数据时。在随后的查询中,第⼀个事务(T1)就会发现多了⼀些原本不存在的记录,就好像发⽣了幻觉⼀样,所以称为幻读。

不可重复读和幻读区别:

不可重复读的重点是修改⽐如多次读取⼀条记录发现其中某些列的值被修改,幻读的重点在于新增或者删除⽐如多次读取⼀条记录发现记录增多或减少了。

怎么解决幻读

InnoDB 引擎通过多版本并发控制MVCC 和 Next-Key Lock 解决幻读的问题。

多版本并发控制(MVCC)

多数数据库都实现了多版本并发控制,并且都是靠保存数据快照来实现的。以 InnoDB 为例,每一行中都冗余了两个字段。一个是行的创建版本,一个是行的删除(过期)版本。具体的版本号(trx_id)存在 information_schema.INNODB_TRX 表中。版本号(trx_id)随着每次事务的开启自增。

事务每次取数据的时候都会取创建版本小于当前事务版本的数据,以及过期版本大于当前版本的数据。

普通的 select 就是快照读。

**next-key 锁 **

next-key 锁包含两部分:

  • 记录锁(行锁)
  • 间隙锁

记录锁是加在索引上的锁,间隙锁是加在索引之间的。原理:将当前数据行与上一条数据和下一条数据之间的间隙锁定,保证此范围内读取的数据是一致的。

InnoDB 中设置了快照读和当前读两种模式,在快照读读情况下,mysql通过mvcc来避免幻读。在当前读读情况下,mysql通过 next-key lock 来避免幻读。select * from t where a=1;属于快照读;select * from t where a=1 lock in share mode;属于当前读。不能把快照读和当前读得到的结果不一样这种情况认为是幻读,这是两种不同的使用。所以我认为mysql的rr级别是解决了幻读的。

事务隔离级别

READ-UNCOMMITTED(未提交读): 最低的隔离级别,事务中的修改,即使没有提交,对其它事务也是可见的,可能会导致脏读、幻读或不可重复读。

READ-COMMITTED(提交读): 一个事务只能读取已经提交的事务所做的修改。换句话说,一个事务所做的修改在提交之前对其它事务是不可见的,可以阻止脏读,但是幻读或不可重复读仍有可能发生。

REPEATABLE-READ(可重复读): 保证在同一个事务中多次读取同一数据的结果是一样的。可以阻止脏读和不可重复读,但幻读仍有可能发生。

SERIALIZABLE(可串行化): 最高的隔离级别,完全服从ACID的隔离级别。所有的事务依次逐个执行,这样事务之间就完全不可能产生干扰,也就是说,该级别可以防止脏读、不可重复读以及幻读。

InnoDB 存储引擎在分布式事务的情况下一般才会用到 SERIALIZABLE(可串行化) 隔离级别。

MySQL是如何实现可重复读的?

MVCC其实主要包含三个概念:隐藏列,undo log,ReadView。

隐藏列

在Innodb引擎中,每个数据表都会有两个隐藏列,分别是trx_id,创建版本号和roll_pointer,回滚指针。其中创建版本号其实就是创建该行数据的事务id。

undo log

如果在一个事务中多次对记录进行修改,则每次修改都会生成undo日志,并且这些undo日志通过回滚指针串联成一个版本链,版本链的头结点是该记录最新的值,尾结点是事务开始时的初始值。

ReadView

当进行查询操作时,事务会生成一个ReadView,ReadView是一个事务快照,准确来说是当前时间点系统内活跃的事务列表,也就是说系统内所有未提交的事务,都会记录在这个Readview内,事务就根据它来判断哪些数据是可见的,哪些是不可见的。

查询一条数据时,事务会拿到这个ReadView,去到undo log中进行判断。若查询到某一条数据:

- 先去查看undo log中的最新数据行,如果数据行的版本号小于ReadView记录的事务id最小值,就说明这条数据对当前数据库是可见的,可以直接作为结果集返回

- 若数据行版本号大于ReadView记录最大值,说明这条数据是由一个新的事务修改的,对当前事务不可见,那么就顺着版本链继续往下寻找第一条满足条件的

- 若数据行版本号在ReadView最小值和最大值之间,那么就需要进行遍历整个ReadView了,如果数据行版本号等于ReadView的某个值,说说明该行数据仍然处于活跃状态,那么对当前事务不可见。

读已提交和可重复读的实现

ReadView就是这样来判断数据可见性的。

那又是如何实现读已提交和可重复读呢?其实很简单,就是生成ReadView的时机不同。

对读已提交来说,事务中的每次读操作都会生成一个新的ReadView,也就是说,如果这期间某个事务提交了,那么它就会从ReadView中移除。这样确保事务每次读操作都能读到相对比较新的数据。

而对可重复读来说,事务只有在第一次进行读操作时才会生成一个ReadView,后续的读操作都会重复使用这个ReadView。也就是说,如果在此期间有其他事务提交了,那么对于可重复读来说也是不可见的,因为对它来说,事务活跃状态在第一次进行读操作时就已经确定下来,后面不会修改了。

Redis

Redis线程模型

Redis实现了一个文件事件处理器,它的组成结构为4部分:多个套接字、IO多路复用程序、文件事件分派器、事件处理器。因为文件事件分派器队列的消费是单线程的,所以Redis才叫单线程模型。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-fCrALyD8-1640000025238)(https://user-gold-cdn.xitu.io/2019/10/20/16de751520af1d39?imageView2/0/w/1280/h/960/format/webp/ignore-error/1)]

文件事件处理器使用I/O多路复用(multiplexing)程序来同时监听多个套接字,并根据套接字目前执行的任务来为套接字关联不同的事件处理器。

尽管多个文件事件可能会并发地出现,但I/O多路复用程序总是会将所有产生事件的套接字都推到一个队列里面,然后通过这个队列,以有序、同步、每次一个套接字的方式向文件事件分派器传送套接字:当上一个套接字产生的事件被处理完毕之后(该套接字为事件所关联的事件处理器执行完毕), I/O多路复用程序才会继续向文件事件分派器传送下一个套接字。

Redis IO多路复用

关于I/O多路复用,首先要理解的是,操作系统为你提供了一个功能,当你的某个socket可读或者可写的时候,它可以给你一个通知。这样当配合非阻塞的socket使用时,只有当系统通知我哪个描述符可读了,我才去执行read操作,可以保证每次read都能读到有效数据而不做纯返回-1和EAGAIN的无用功。写操作类似。操作系统的这个功能通过select/poll/epoll/kqueue之类的系统调用函数来使用,这些函数都可以同时监视多个描述符的读写就绪状况,这样,多个描述符的I/O操作都能在一个线程内并发交替地顺序完成,这就叫I/O多路复用,这里的“复用”指的是复用同一个线程。

I/O 多路复用模块封装了底层的 selectepollavport 以及 kqueue 这些 I/O 多路复用函数,为上层提供了相同的接口,所以I/O多路复用程序的底层实现是可以互换的,如下图所示。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-34kKRTCi-1640000025241)(https://user-gold-cdn.xitu.io/2019/10/20/16de7515278c4238?imageView2/0/w/1280/h/960/format/webp/ignore-error/1)]

select、poll、epoll之间的区别

select,poll,epoll都是IO多路复用的机制。I/O多路复用通过一种机制,可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。没有描述符就绪时会阻塞应用程序,交出cpu。多路是指网络连接,复用指的是同一个线程。

select,poll实现需要自己不断轮询所有fd(文件描述符)集合,直到设备就绪,期间可能要睡眠和唤醒多次交替。而epoll其实也需要调用epoll_wait不断轮询就绪链表,期间也可能多次睡眠和唤醒交替,但是它是设备就绪时,调用回调函数,把就绪fd放入就绪链表中,并唤醒在epoll_wait中进入睡眠的进程。虽然都要睡眠和交替,但是select和poll在“醒着”的时候要遍历整个fd集合,而epoll在“醒着”的时候只要判断一下就绪链表是否为空就行了,这节省了大量的CPU时间。这就是回调机制带来的性能提升。

select,poll每次调用都要把fd集合从用户态往内核态拷贝一次,并且要把current往设备等待队列中挂一次,而epoll只要一次拷贝,而且把current往等待队列上挂也只挂一次(在epoll_wait的开始,注意这里的等待队列并不是设备等待队列,只是一个epoll内部定义的等待队列)。这也能节省不少的开销。

持久化机制

  1. RDB:Redis可以通过创建快照来获得存储在内存⾥⾯的数据在某个时间点上的副本。当Redis需要做持久化时,Redis会fork一个子进程,子进程将数据写到磁盘上一个临时RDB文件中。当子进程完成写临时文件后,将原来的RDB替换掉。
  2. AOF:开启AOF持久化后每执⾏⼀条会更改Redis中的数据的命令,Redis就会将该命令写⼊硬盘中的AOF⽂件。aof的默认策略是每秒钟fsync一次,在这种配置下,就算发生故障停机,也最多丢失一秒钟的数据。缺点是对于相同的数据集来说,AOF的文件体积通常要大于RDB文件的体积。根据所使用的fsync策略,AOF的速度可能会慢于RDB。

Redis默认是快照RDB的持久化方式。对于主从同步来说,主从刚刚连接的时候,进行全量同步(RDB);全同步结束后,进行增量同步(AOF)。

Redis一次请求拿出来所有的数据有什么问题,那怎么改进

Redis单线程阻塞后面请求,对于内存的消耗和redis服务器都是一个隐患,类似线上服务不要执行keys *,可以分批取数据,用 SCAN cursor [MATCH pattern] [COUNT count] 命令,SCAN 每次执行都只会返回少量元素,所以可以用于生产环境。

zset区间查询怎么做的,时间复杂度多少

skiplist,顾名思义,首先它是一个list。实际上,它是在有序链表的基础上发展起来的。skiplist,翻译成中文,可以翻译成“跳表”或“跳跃表”,指的就是除了最下面第1层链表之外,它会产生若干层稀疏的链表,这些链表里面的指针故意跳过了一些节点(而且越高层的链表跳过的节点越多)。这就使得我们在查找数据的时候能够先在高层的链表中进行查找,然后逐层降低,最终降到第1层链表来精确地确定数据位置。在这个过程中,我们跳过了一些节点,从而也就加快了查找速度。

每一个节点的层数(level)是随机出来的,而且新插入一个节点不会影响其它节点的层数。因此,插入操作只需要修改插入节点前后的指针,而不需要对很多节点都进行调整。这就降低了插入操作的复杂度。实际上,这是skiplist的一个很重要的特性,这让它在插入性能上明显优于平衡树的方案。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-VhAXnJoI-1640000025244)(https://lc-gold-cdn.xitu.io/714264ea6eba7af0fe67.png?imageView2/0/w/1280/h/960/format/webp/ignore-error/1)]

跳表的时间复杂度比较难分析,粗略地讲下:时间复杂度 = 索引的高度 * 每层索引遍历元素的个数。

跳表的索引高度。如下图所示,假设每两个结点会抽出一个结点作为上一级索引的结点,原始的链表有n个元素,则一级索引有n/2 个元素、二级索引有 n/4 个元素、k级索引就有 n/2k个元素。最高级索引一般有2个元素,即:最高级索引 h 满足 2 = n/2h,即 h = log2n - 1,最高级索引 h 为索引层的高度加上原始数据一层,跳表的总高度 h = log2n。

当每级索引都是两个结点抽出一个结点作为上一级索引的结点时,每一层最多遍历3个结点。

跳表的索引高度 h = log2n,且每层索引最多遍历 3 个元素。所以跳表中查找一个元素的时间复杂度为 O(3*logn),省略常数即:O(logn)。

zset为什么用跳表不用红黑树

  • 在做范围查找的时候,平衡树比skiplist操作要复杂。在平衡树上,我们找到指定范围的小值之后,还需要以中序遍历的顺序继续寻找其它不超过大值的节点。如果不对平衡树进行一定的改造,这里的中序遍历并不容易实现。而在skiplist上进行范围查找就非常简单,只需要在找到小值之后,对第1层链表进行若干步的遍历就可以实现。
  • 平衡树的插入和删除操作可能引发子树的调整,逻辑复杂,而skiplist的插入和删除只需要修改相邻节点的指针,操作简单又快速。
  • 从内存占用上来说,skiplist比平衡树更灵活一些。一般来说,平衡树每个节点包含2个指针(分别指向左右子树),而skiplist每个节点包含的指针数目平均为1/(1-p),具体取决于参数p的大小。如果像Redis里的实现一样,取p=1/4,那么平均每个节点包含1.33个指针,比平衡树更有优势。
  • 从算法实现难度上来比较,skiplist比平衡树要简单得多。

zset的中插入一个元素的时间复杂度

插入操作只需要修改插入节点前后的指针,而不需要对很多节点都进行调整,所以插入这个操作本身应该为O(1)。需要注意的是,前面演示的各个节点的插入过程,实际上在插入之前也要先经历一个类似的查找过程(O(logN)),在确定插入位置后,再完成插入操作。

Redis分布式锁

实现

Redis 锁主要利用 Redis 的 setnx 命令。

  • 加锁命令:SETNX key value,当键不存在时,对键进行设置操作并返回成功,否则返回失败。KEY 是锁的唯一标识,一般按业务来决定命名。
  • 解锁命令:DEL key,通过删除键值对释放锁,以便其他线程可以通过 SETNX 命令来获取锁。
  • 锁超时:EXPIRE key timeout, 设置 key 的超时时间,以保证即使锁没有被显式释放,锁也可以在一定时间后自动释放,避免资源被永远锁住。

存在的问题:

SETNX 和 EXPIRE 非原子性

如果 SETNX 成功,在设置锁超时时间后,服务器挂掉、重启或网络问题等,导致 EXPIRE 命令没有执行,锁没有设置超时时间变成死锁。有很多开源代码来解决这个问题,比如使用 lua 脚本。

锁误解除

如果线程 A 成功获取到了锁,并且设置了过期时间 30 秒,但线程 A 执行时间超过了 30 秒,锁过期自动释放,此时线程 B 获取到了锁;随后 A 执行完成,线程 A 使用 DEL 命令来释放锁,但此时线程 B 加的锁还没有执行完成,线程 A 实际释放的是线程 B 加的锁。

通过在 value 中设置当前线程加锁的标识,在删除之前验证 key 对应的 value 判断锁是否是当前线程持有。可生成一个 UUID 标识当前线程,使用 lua 脚本做验证标识和解锁操作。

超时解锁导致并发

如果线程 A 成功获取锁并设置过期时间 30 秒,但线程 A 执行时间超过了 30 秒,锁过期自动释放,此时线程 B 获取到了锁,线程 A 和线程 B 并发执行。

A、B 两个线程发生并发显然是不被允许的,一般有两种方式解决该问题:

  • 将过期时间设置足够长,确保代码逻辑在锁释放之前能够执行完成。
  • 为获取锁的线程增加守护线程,为将要过期但未释放的锁增加有效时间。

热点Key

如何发现热点key

  • 凭借业务经验,进行预估哪些是热key。比如某些商品要做秒杀,则商品key就可以判断为热key。但并非所有业务都能预估出热key。
  • 在客户端进行收集。比如在redis客户端执行redis命令之前,加入一行代码进行命令数据收集,,然后通过网络将收集的命令发送出去,缺点是对客户端代码有入侵。
  • 在Proxy层做收集。但是并非所有的redis集群都有proxy。
  • 用redis自带命令。monitor命令可以实时抓取出redis服务器接收到的命令,然后写代码统计出热key是啥,不过高并发条件下,有内存暴增的隐患,影响redis的性能。redis4.0.3提供了客户端热点key发现功能,如果key比较多,执行比较慢。
  • 自己抓包评估,redis客户端使用TCP协议与服务端进行交互,通信协议采用RESP,自己写程序监听端口,按照RESP协议规则解析数据进行分析,不过开发成本较高,不易维护。

如何解决热key

  • 增加二级缓存,发现热key以后,可以把热key数据加载到系统JVM并设置合适的缓存过期时间,针对热key的请求就会直接分散到各业务服务器上,防止所有请求同时访问同一台redis。
  • 备份热key。可以把热点key的数据备份到所有redis的集群节点中,可以通过在热点key后面拼接集群节点编号,然后将这些备份key分散到所有集群节点中,客户端访问热点key的时候也在热点key后面随机拼接集群节点编号,将热点key的请求分散到不同集群节点上。

Redis的过期删除策略

  • 定时删除: 在设置键的过期时间的同时,创建一个定时器(timer),让定时器在键的过期时间来临时,立即执行对键的删除操作. 即从设置key的Expire开始,就启动一个定时器,到时间就删除该key;这样会对内存比较友好,但浪费CPU资源

  • 惰性删除:放任键过期不管,但是每次从键空间中获取键时,都检查取得的键是否过期,如果过期的话,就删除该键;如果没有过期,就返回该键。 即平时不处理,在使用的时候,先检查该key是否已过期,已过期则删除,否则不做处理;这样对CPU友好,但是浪费内存资源,并且如果一个key不再使用,那么它会一直存在于内存中,造成浪费

  • 定期删除:每隔一段时间,程序就对数据库进行一次检查,删除里面的过期键。至于要删除多少过期键,以及要检查多少个数据库,则由算法决定。 即设置一个定时任务,比如10分钟删除一次过期的key;间隔小则占用CPU,间隔大则浪费内存

在这三种策略中,第一种和第三种为主动删除策略,而第二种则为被动删除策略。

Redis服务器实际使用的是惰性删除和定期删除两种策略:通过配合使用这两种删除策略,服务器可以很好地在合理使用CPU时间和避免浪费内存空间之间取得平衡。

Redis缓存淘汰策略

在 redis 中可以配置 6 种淘汰机制:

  • noeviction:不删除策略, 达到最大内存限制时, 如果需要更多内存, 直接返回错误信息。
  • allkeys-lru:所有 key 通用,优先删除最近最少使用 (LRU) 的 key。
  • volatile-lru:只限于设置了 expire 的部分; 优先删除最近最少使用 (LRU) 的 key。
  • allkeys-random:所有 key 通用; 随机删除一部分 key。
  • volatile-random:只限于设置了 expire 的部分; 随机删除一部分 key。
  • volatile-ttl:只限于设置了 expire 的部分; 优先删除剩余时间 (time to live,TTL) 短的key。

大Key问题

redis的基础假设是每个操作都很快,所以设计成单线程处理;
所以如果有大key,基础设计就不成立了,会阻塞。

单Key大小:

Redis限制每个String类型value大小不超过512MB, 实际开发中,不要超过10KB,否则会对CPU和网卡造成极大负载。 hash、list、set、zset元素个数不要超过5000。

理论上限: 每个hashset里头元素数量小于 2^32.

带来的问题:

  1. 数据倾斜,部分redis分片节点存储占用很高;
  2. 查询突然很慢,qps降低;

解决方法:

分治法,加一些key前缀/后置分解(如时间、哈希前缀、用户id后缀)。

通过jedis进行连接redis的时候,已经和一个进程连接了 ,redis还能够和其它的进程进行通信么?

Redis为单进程单线程模式,采用队列模式将并发访问变为串行访问。Redis本身没有锁的概念,Redis对于多个客户端连接并不存在竞争,但是在Jedis客户端对Redis进行并发访问时会发生连接超时、数据转换错误、阻塞、客户端关闭连接等问题,这些问题均是由于客户端连接混乱造成。对此有2种解决方法:

  1. 客户端角度,为保证每个客户端间正常有序与Redis进行通信,对连接进行池化,同时对客户端读写Redis操作采用内部锁synchronized。

  2. 服务器角度,利用setnx实现锁。

    注:对于第一种,需要应用程序自己处理资源的同步,可以使用的方法比较通俗,可以使用synchronized也可以使用lock;第二种需要用到Redis的setnx命令,但是需要注意一些问题。

一致性哈希算法

普通的hash算法会面临容错性和扩展性的问题。现假设有一台Redis服务器宕机了,那么为了填补空缺,要将宕机的服务器从编号列表中移除,后面的服务器按顺序前移一位并将其编号值减一,此时每个key就要按h = Hash(key) % 2重新计算。

同样,如果新增一台服务器,规则也同样需要重新计算,h = Hash(key) % 4。因此,系统中如果有服务器更变,会直接影响到Hash值,大量的key会重定向到其他服务器中,造成缓存命中率降低,而这种情况在分布式系统中是十分糟糕的。

一个设计良好的分布式哈希方案应该具有良好的单调性,即服务节点的变更不会造成大量的哈希重定位。一致性哈希算法由此而生。

一致性哈希算法

简单的说,一致性哈希是将整个哈希值空间组织成一个虚拟的圆环,如假设哈希函数H的值空间为0-2^32-1(哈希值是32位无符号整形),整个哈希空间环如下:

哈希环

整个空间按顺时针方向组织,0和2^32-1在零点中方向重合。

接下来,把服务器按照IP或主机名作为关键字进行哈希,这样就能确定其在哈希环的位置。

哈希环2

然后,我们就可以使用哈希函数H计算值为key的数据在哈希环的具体位置h,根据h确定在环中的具体位置,从此位置沿顺时针滚动,遇到的第一台服务器就是其应该定位到的服务器。

例如我们有A、B、C、D四个数据对象,经过哈希计算后,在环空间上的位置如下:

哈希环3

根据一致性哈希算法,数据A会被定为到Server 1上,数据B被定为到Server 2上,而C、D被定为到Server 3上。

容错性和拓展性

容错性

假如RedisService2宕机了,那么会怎样呢?

Redis2宕机

那么,数据B对应的节点保存到RedisService3中。因此,其中一台宕机后,干扰的只有前面的数据(原数据被保存到顺时针的下一个服务器),而不会干扰到其他的数据。

扩展性

下面考虑另一种情况,假如增加一台服务器Redis4,具体位置如下图所示:

RedisServicee4

原本数据C是保存到Redis3中,但由于增加了Redis4,数据C被保存到Redis4中。干扰的也只有Redis3而已,其他数据不会受到影响。

因此,一致性哈希算法对于节点的增减都只需重定位换空间的一小部分即可,具有较好的容错性和可扩展性。

虚拟节点

前面部分都是讲述到Redis节点较多和节点分布较为均衡的情况,如果节点较少就会出现节点分布不均衡造成数据倾斜问题。

例如,我们的的系统有两台Redis,分布的环位置如下图所示:

哈希环

这会产生一种情况,Redis1的hash范围比Redis2的hash范围大,导致数据大部分都存储在Redis1中,数据存储不平衡。

为了解决这种数据存储不平衡的问题,一致性哈希算法引入了虚拟节点机制,即对每个节点计算多个哈希值,每个计算结果位置都放置在对应节点中,这些节点称为虚拟节点

具体做法可以在服务器IP或主机名的后面增加编号来实现,例如上面的情况,可以为每个服务节点增加三个虚拟节点,于是可以分为 RedisService1#1、 RedisService1#2、 RedisService1#3、 RedisService2#1、 RedisService2#2、 RedisService2#3,具体位置如下图所示:

虚拟节点

对于数据定位的hash算法仍然不变,只是增加了虚拟节点到实际节点的映射。例如,数据C保存到虚拟节点Redis1#2,实际上数据保存到Redis1中。这样,就能解决服务节点少时数据不平均的问题。在实际应用中,通常将虚拟节点数设置为32甚至更大,因此即使很少的服务节点也能做到相对均匀的数据分布

实现

import java.util.SortedMap;
import java.util.TreeMap;

public class ConsistentHashingWithoutVirtualNode {
    //待添加入Hash环的服务器列表
    private static String[] servers = {"192.168.0.1:8888", "192.168.0.2:8888", 
      "192.168.0.3:8888"};

    //key表示服务器的hash值,value表示服务器
    private static SortedMap<Integer, String> sortedMap = new TreeMap<Integer, String>();

    //程序初始化,将所有的服务器放入sortedMap中
    static {
        for (int i = 0; i < servers.length; i++) {
            int hash = getHash(servers[i]);
            System.out.println("[" + servers[i] + "]加入集合中, 其Hash值为" + hash);
            sortedMap.put(hash, servers[i]);
        }
    }

    //得到应当路由到的结点
    private static String getServer(String key) {
        //得到该key的hash值
        int hash = getHash(key);
        //得到大于该Hash值的所有Map
        SortedMap<Integer, String> subMap = sortedMap.tailMap(hash);
        if (subMap.isEmpty()) {
            //如果没有比该key的hash值大的,则从第一个node开始
            Integer i = sortedMap.firstKey();
            //返回对应的服务器
            return sortedMap.get(i);
        } else {
            //第一个Key就是顺时针过去离node最近的那个结点
            Integer i = subMap.firstKey();
            //返回对应的服务器
            return subMap.get(i);
        }
    }

    //使用FNV1_32_HASH算法计算服务器的Hash值
    private static int getHash(String str) {
        final int p = 16777619;
        int hash = (int) 2166136261L;
        for (int i = 0; i < str.length(); i++)
            hash = (hash ^ str.charAt(i)) * p;
        hash += hash << 13;
        hash ^= hash >> 7;
        hash += hash << 3;
        hash ^= hash >> 17;
        hash += hash << 5;

        // 如果算出来的值为负数则取其绝对值
        if (hash < 0)
            hash = Math.abs(hash);
        return hash;
    }

    public static void main(String[] args) {
        String[] keys = {"semlinker", "kakuqo", "fer"};
        for (int i = 0; i < keys.length; i++)
            System.out.println("[" + keys[i] + "]的hash值为" + getHash(keys[i])
                    + ", 被路由到结点[" + getServer(keys[i]) + "]");
    }

}

以上代码成功运行后,在控制台会输出以下结果:

[192.168.0.1:8888]加入集合中, 其Hash值为1326271016
[192.168.0.2:8888]加入集合中, 其Hash值为1132535844
[192.168.0.3:8888]加入集合中, 其Hash值为115798597

[semlinker]的hash值为1549041406, 被路由到结点[192.168.0.3:8888]
[kakuqo]的hash值为463104755, 被路由到结点[192.168.0.2:8888]
[fer]的hash值为1677150790, 被路由到结点[192.168.0.3:8888]

框架

Spring

IOC

业务场景: 在使用Java进行开发业务的过程中,很多时候一个业务是由各种组件组成,在每个使用到这些组件时都会毫不犹豫的new一个组件对象来使用,在小项目中这样的做法无可厚非,也不存在什么问题。但是在业务逻辑复杂并且多人协作开发的项目中,这会导致业务和组件之间的关系错综复杂而且不便于管理,对象之间的耦合度变得很高,这就是所谓的牵一发而动全身。

首先说说IoC(Inversion of Control,控制倒转),这是spring的核心,贯穿始终。所谓IoC,对于spring框架来说,就是由spring来负责控制对象的生命周期和对象间的关系。举个例子,我们是如何找女朋友的?常见的情况是,我们到处去看哪里有长得漂亮身材又好的mm,然后打听她们的兴趣爱好、qq号、电话号…,想办法认识她们,投其所好送其所好,然后嘿嘿…这个过程是复杂深奥的,我们必须自己设计和面对每个环节。 传统的程序开发也是如此,在一个对象中,如果要使用另外的对象,就必须得到它(自己new一个,或者从JNDI中查询一个),使用完之后还要将对象销毁(比如Connection等),对象始终会和其他的接口或类藕合起来。

那么IoC是如何做的呢?有点像通过婚介找女朋友,在我和女朋友之间引入了一个第三者:婚姻介绍所。婚介管理了很多男男女女的资料,我可以向婚介提出一个列表,告诉它我想找个什么样的女朋友,比如长得像李嘉欣,身材像林熙雷,技术像齐达内之类的,然后婚介就会按照我们的要求,提供一个mm,我们只需要去和她谈恋爱、结婚就行了。简单明了,如果婚介给我们的人选不符合要求,我们就会抛出异常。整个过程不再由我自己控制,而是有婚介这样一个类似容器的机构来控制。

Spring所倡导的开发方式就是如此:所有的类都会在spring容器中登记,告诉spring你是个什么东西,你需要什么东西,然后spring会在系统运行到适当的时候,把你要的东西主动给你,同时也把你交给其他需要你的东西。所有的类的创建、销毁都由 spring来控制,也就是说控制对象生存周期的不再是引用它的对象,而是spring。对于某个具体的对象而言,以前是它控制其他对象,现在是所有对象都被spring控制,所以这叫控制反转。

IoC的一个重点是在系统运行中,动态的向某个对象提供它所需要的其他对象。这一点是通过DI(Dependency Injection,依赖注入)来实现的。比如对象A需要操作数据库,以前我们总是要在A中自己编写代码来获得一个Connection对象,有了 spring我们就只需要告诉spring,A中需要一个Connection,至于这个Connection怎么构造,何时构造,A不需要知道。在系统运行时,spring会在适当的时候制造一个Connection,然后像打针一样,注射到A当中,这样就完成了对各个对象之间关系的控制。A需要依赖 Connection才能正常运行,而这个Connection是由spring注入到A中的,依赖注入的名字就这么来的。 那么DI是如何实现的呢? Java 1.3之后一个重要特征是反射(reflection),它允许程序在运行的时候动态的生成对象、执行对象的方法、改变对象的属性,spring就是通过反射来实现注入的。

AOP

AOP动态代理的实现方式和区别

  • JDK动态代理通过反射来接收被代理的类,并且要求被代理的类必须实现一个接口,核心是InvocationHandler接口和Proxy类
  • CGLIB(Code Generation Library),是一个代码生成的类库,可以在运行时动态的生成某个类的子类,注意,CGLIB是通过继承的方式做的动态代理,因此如果某个类被标记为final,那么它是无法使用CGLIB做动态代理的

AOP通知方式

  • 前置通知[Before advice]:在连接点前面执行,前置通知不会影响连接点的执行,除非此处抛出异常。
  • 正常返回通知[After returning advice]:在连接点正常执行完成后执行,如果连接点抛出异常,则不会执行。
  • 异常返回通知[After throwing advice]:在连接点抛出异常后执行。
  • 返回通知[After (finally) advice]:在连接点执行完成后执行,不管是正常执行完成,还是抛出异常,都会执行返回通知中的内容。
  • 环绕通知[Around advice]:环绕通知围绕在连接点前后,比如一个方法调用的前后。这是最强大的通知类型,能在方法调用前后自定义一些操作。环绕通知还需要负责决定是继续处理join point(调用ProceedingJoinPoint的proceed方法)还是中断执行。

Filter和Interceptor

  • Filter依赖于Servlet容器,而Interceptor不依赖于Servlet容器。

  • Filter对几乎所有的请求起作用,而Interceptor只能对action请求起作用。

  • 在action的生命周期里,Interceptor可以被多次调用,而Filter只能在容器初始化时调用一次。

  • Filter在过滤是只能对request和response进行操作,而interceptor可以对request、response、handler、modelAndView、exception进行操作。

  • 执行顺序:HttpRequest ----> Filter ----> Servlet ----> Controller/Action/… ----> Filter ----> HttpResponse

    HttpRequest ----> DispactherServlet ----> HandlerInterceptor ---->Controller----> HandlerInterceptor ----> HttpResponse

    故filter先执行。Filter面对的是所有的请求,而HandlerInterceptor是面对具体的Controller。Filter总是先于HandlerInterceptor发挥作用,在Filter中甚至可以中断请求,从而使它无法到达相应的Servlet。

Bean的生命周期

1.Spring对bean进行实例化;

2.Spring将值和bean的引用注入到bean对应的属性中;

3.如果bean实现了BeanNameAware接口,Spring将bean的ID传递给setBeanName()方法;

4.如果bean实现了BeanFactoryAware接口,Spring将调用setBeanFactory()方法,将BeanFactory容器实例传入;

5.如果bean实现了ApplicationContextAware接口,Spring将调用setApplicationContext()方法,将bean所在的应用上下文的引用传入进来;

6.如果bean实现了BeanPostProcessor接口,Spring将调用它们的post-ProcessBeforeInitialization()方法;

7.如果bean实现了InitializingBean接口,Spring将调用它们的after-PropertiesSet()方法。类似地,如果bean使用init-method声明了初始化方法,该方法也会被调用;

8.如果bean实现了BeanPostProcessor接口,Spring将调用它们的post-ProcessAfterInitialization()方法;

9.此时,bean已经准备就绪,可以被应用程序使用了,它们将一直驻留在应用上下文中,直到该应用上下文被销毁;

10.如果bean实现了DisposableBean接口,Spring将调用它的 destroy()接口方法。同样,如果bean使用destroy-method声明了销毁方法,该方法也会被调用。

Bean的作用域

img

@Autoweird和@Resourse有什么区别

@Autowired注解是按类型装配依赖对象,默认情况下它要求依赖对象必须存在,如果允许null值,可以设置它required属性为false。

@Resource注解和@Autowired一样,也可以标注在字段或属性的setter方法上,但它默认按名称装配。名称可以通过@Resource的name属性指定,如果没有指定name属性,当注解标注在字段上,即默认取字段的名称作为bean名称寻找依赖对象,当注解标注在属性的setter方法上,即默认取属性名作为bean名称寻找依赖对象。

@Resources按名字,是JDK的,@Autowired按类型,是Spring的。

MyBatis

一级缓存和二级缓存

一级缓存

一级缓存是SqlSession级别的缓存。在操作数据库时需要构造sqlSession对象,在对象中有一个数据结构(HashMap)用于存储缓存数据。不同的sqlSession之间的缓存数据区域是互相不影响的。

在参数和SQL完全一样的情况下,我们使用同一个SqlSession对象调用一个Mapper方法,往往只执行一次SQL,因为使用SelSession第一次查询后,MyBatis会将其放在缓存中,以后再查询的时候,如果没有声明需要刷新,并且缓存没有超时的情况下,SqlSession都会取出当前缓存的数据,而不会再次发送SQL到数据库。

一级缓存时执行commit,close,增删改等操作,就会清空当前的一级缓存;当对SqlSession执行更新操作(update、delete、insert)后并执行commit时,不仅清空其自身的一级缓存(执行更新操作的效果),也清空二级缓存(执行commit()的效果)。

二级缓存

二级缓存指的就是同一个namespace下的mapper,多个SqlSession去操作同一个Mapper的sql语句,多个SqlSession可以共用二级缓存,二级缓存是跨SqlSession的。

Dubbo

HTTP与RPC

首先通用定义的http1.1协议的tcp报文包含太多废信息,而我们使用自定义tcp协议可以极大地精简传输内容。

另外成熟的rpc库相对http容器,更多的是封装了“服务发现”,“负载均衡”,“熔断降级”一类面向服务的高级特性。可以这么理解,rpc框架是面向服务的更高级的封装。如果把一个http servlet容器上封装一层服务发现和函数代理调用,那它就已经可以做一个rpc框架了。

所以为什么要用rpc调用?

因为良好的rpc调用是面向服务的封装,针对服务的可用性和效率等都做了优化。单纯使用http调用则缺少了这些特性。

调用方式

Dubbo 支持同步和异步两种调用方式,其中异步调用还可细分为“有返回值”的异步调用和“无返回值”的异步调用。所谓“无返回值”异步调用是指服务消费方只管调用,但不关心调用结果,此时 Dubbo 会直接返回一个空的 RpcResult。若要使用异步特性,需要服务消费方手动进行配置。默认情况下,Dubbo 使用同步调用方式。

协议

dubbo协议:
单一TCP长连接和NIO异步通讯
适合大并发小数据量的服务调用,以及服务消费者远大于提供者的情况
Hessian二进制序列化

缺点是不适合传送大数据包的服务

rmi协议:
采用JDK标准的rmi协议实现,传输参数和返回参数对象需要实现Serializable接口
使用java标准序列化机制,使用阻塞式短连接,传输数据包不限,消费者和提供者个数相当
多个短连接,TCP协议传输,同步传输,适用常规的远程服务调用和rmi互操作

缺点是在依赖低版本的Common-Collections包,java反序列化存在安全漏洞,需升级commons-collections3 到3.2.2版本或commons-collections4到4.1版本。

webservice协议:
基于WebService的远程调用协议(Apache CXF的frontend-simple和transports-http)实现,提供和原生WebService的互操作
多个短连接,基于HTTP传输,同步传输,适用系统集成和跨语言调用。

http协议:
基于Http表单提交的远程调用协议,使用Spring的HttpInvoke实现
对传输数据包不限,传入参数大小混合,提供者个数多于消费者

缺点是不支持传文件,只适用于同时给应用程序和浏览器JS调用

hessian:
集成Hessian服务,基于底层Http通讯,采用Servlet暴露服务,Dubbo内嵌Jetty作为服务器实现,可与Hession服务互操作
通讯效率高于WebService和Java自带的序列化
适用于传输大数据包(可传文件),提供者比消费者个数多,提供者压力较大

缺点是参数及返回值需实现Serializable接口,自定义实现List、Map、Number、Date、Calendar等接口

thrift协议:
对thrift原生协议的扩展添加了额外的头信息
使用较少,不支持传null值

memcache:
基于memcached实现的RPC协议

redis:
基于redis实现的RPC协议

中间件

ZooKeeper

Leader选举过程

Zookeeper Server三种角色:Leader,Follower,Observer。
Leader是Zookeeper集群工作机制的核心,主要工作:

​ a.调度者:集群内部各个服务节点的调度者
​ b.事务请求:事务请求的唯一调度和处理者,保证集群事务处理的顺序性

Follower主要职责:

​ a.非事务请求:Follower 直接处理非事务请求,对于事务请求,转发给 Leader
​ b.Proposal 投票:Leader 上执行事务时,需要 Follower 投票,Leader 才真正执行
​ c.Leader 选举投票

Observer主要职责:

​ a.非事务请求:Follower 直接处理非事务请求,对于事务请求,转发给 Leader

Observer 跟 Follower的区别:

​ a.Follower 参与投票:Leader 选举、Proposal 提议投票(事务执行确认)
​ b.Observer 不参与投票:只用于提供非事务请求的处理

Zookeeper Server的状态

LOOKING:寻找Leader
LEADING:Leader状态,对应的节点为Leader。
FOLLOWING:Follower状态,对应的节点为Follower。
OBSERVING:Observer状态,对应节点为Observer,该节点不参与Leader选举。

其它概念

ZXID(zookeeper transaction id):每个改变Zookeeper状态的操作都会形成一个对应的zxid,并记录到transaction log中。这个值越大,表示更新越新
myid:服务器SID,一个数字,通过配置文件配置,唯一
SID:服务器的唯一标识
成为Leader的必要条件:Leader要具有最高的zxid;当集群的规模是n时,集群中大多数的机器(至少n/2+1)得到响应并follow选出的Leader。
心跳机制:Leader与Follower利用PING来感知对方的是否存活,当Leader无法相应PING时,将重新发起Leader选举。

选举有两种情况,一是服务器启动的投票,二是运行期间的投票。

服务器启动时期的Leader选举

  1. 每个服务器发送一个投票(SID,ZXID)
    其中sid是自己的myid,初始阶段都将自己投为Leader。

  2. 接收来自其他服务器的投票。
    集群的每个服务器收到投票后,首先判断该投票的有效性,如检查是否是本轮投票、是否来自LOOKING状态的服务器。

  3. 处理投票
    针对每个投票都按以下规则与自己的投票PK,PK后依据情况是否更新投票,再发送给其他机器。

    a.优先检查ZXID,ZXID较大者优先为Leader
    b.如果ZXID相同,检查SID,SID较大者优先为Leader

  4. 统计投票
    每次投票后,服务器统计所有投票,判断是否有过半的机器收到相同的投票,如果某个投票达到一半的要求,则认为该投票提出者可以成为Leader。

  5. 改变服务器状态
    一旦确定了Leader,每个服务器都更新自己的状态,Leader变更为Leading,Follower变更为Following。正常情况下一旦选出一个Leader则一直会保持,除非Leader服务器宕掉,则再进行重新选举。

服务器运行时期的Leader选举

  1. 变更状态
    当Leader宕机后,余下的所非Observer的服务器都会将自己的状态变更为Looking,然后开启新的Leader选举流程。
  2. 每个服务器发出一个投票。
    生成(SID,ZXID)信息,注意运行期间的ZXID可能是不同的,但是在投票时都会将自己投为Leader,然后发送给其他的服务器。
  3. 接收来自各个服务器的投票
    与启动时过程相同
  4. 处理投票
    与启动时过程相同
  5. 统计投票
    与启动时过程相同
  6. 改变服务器状态
    与启动时过程相同

ZooKeeper分布式锁

ZooKeeper节点性质
  1. 有序节点:假如当前有一个父节点为/lock,我们可以在这个父节点下面创建子节点;zookeeper提供了一个可选的有序特性,例如我们可以创建子节点“/lock/node-”并且指明有序,那么zookeeper在生成子节点时会根据当前的子节点数量自动添加整数序号,也就是说如果是第一个创建的子节点,那么生成的子节点为/lock/node-0000000000,下一个节点则为/lock/node-0000000001,依次类推。
  2. 临时节点:客户端可以建立一个临时节点,在会话结束或者会话超时后,zookeeper会自动删除该节点。
  3. 事件监听:在读取数据时,我们可以同时对节点设置事件监听,当节点数据或结构变化时,zookeeper会通知客户端。当前zookeeper有如下四种事件:1)节点创建;2)节点删除;3)节点数据修改;4)子节点变更。
分布式锁实现
  1. 假设锁空间的根节点为/lock:
  2. 客户端连接zookeeper,并在/lock下创建临时的且有序的子节点,第一个客户端对应的子节点为/lock/lock-0000000000,第二个为/lock/lock-0000000001,以此类推。
  3. 客户端获取/lock下的子节点列表,判断自己创建的子节点是否为当前子节点列表中序号最小的子节点,如果是则认为获得锁,否则监听刚好在自己之前一位的子节点删除消息,获得子节点变更通知后重复此步骤直至获得锁;
  4. 执行业务代码;
  5. 完成业务流程后,删除对应的子节点释放锁。

临时节点能够保证在故障的情况下锁也能被释放,考虑这么个场景:假如客户端a当前创建的子节点为序号最小的节点,获得锁之后客户端所在机器宕机了,客户端没有主动删除子节点;如果创建的是永久的节点,那么这个锁永远不会释放,导致死锁;由于创建的是临时节点,客户端宕机后,过了一定时间zookeeper没有收到客户端的心跳包判断会话失效,将临时节点删除从而释放锁。

RocketMQ

RocketMQ的消费模式

广播消费

在这种模式下,rocketmq会将消息发送给group中的每一个消费者,如果这种模式在公司的项目中,会造成消息重复消费的问题,理论上会有N-1次重复消费,那么rocketmq为什么还会保留这种消费模式呢?存在必有它的道理,比方说,如果需要动态更细一些配置,我们需要在不重启服务的情况下,将新的配置推送给group中的每一个消费者,这时候广播消费就发挥它的独到之处了。

集群消费

集群消费是用的最广泛的一种消费模式,在集群消费模式下,同一条消息,只能被group中的任意一个消费者消费,这个概念很重要,这是与广播消费的最明显区别。

如果消费者组A下面有两个消费者组A1,A2,问消费者A1和A2能否消费不同的topic?

不可以,同组的实例需保持其订阅一致。

Nginx

正向代理/反向代理

正向代理代理的对象是客户端,反向代理代理的对象是服务端;正向代理隐藏真实客户端,反向代理隐藏真实服务端。举例来说,正向代理就像我们翻墙访问谷歌,梯子就是代理服务器,而谷歌并不知道我们是谁,只返回请求结果;而反向代理就像我们拨打10086,10086就像是反向代理服务器,我们不知道背后是谁给我们提供了人工服务,我们只关心查询结果。访问某个网址也是同样道理。

设计模式

单例模式

懒汉模式

public class Singleton {
    
    // 1.构造函数是private,防止外部实例化
    private Singleton() {}
    
    // 2.提供静态方法来获取实例
    public static Singleton getInstance() {
        return singleton;
    }
    
    // 3.由自己维护实例
    static Singleton singleton;
    static {
    	singleton = new Singleton();
    }
}

上面的方法实现简单,在单线程环境下没有问题,但是在多线程环境下就会有并发安全问题。如果两个线程同时进入if (singleton == null)这里,就会同时去实例化,这样就达不到单例的目的。

DCL+volatile

public class Singleton {

    private volatile Singleton singleton;
    
    // 构造函数是private,防止外部实例化
    private Singleton() {}
    
    public static Singleton getInstance() {
        if (singleton == null) { // 第一次检查
            synchronized (Singleton.class) {
                if (singleton == null) { // 第二次检查,"double check"的由来
                    singleton = new Singleton();
                }
            }
        }
        return singleton;
    }
}

DCL方法中做了两次singleton == null的判断,那么这里为什么需要做两次检查呢? 首先我们看一下这个方法的过程:

  1. 检查singleton实例是否为空,如果不为空直接返回。
  2. 对Singleton的class加synchronized锁,锁住整个类。如果没有获取锁则阻塞等待。
  3. 判断singleton实例是否为空,如果为空则进行初始化。

设想一下,在最开始,如果N个线程同时并发来获取实例,除了获取锁的线程之外其他的线程都阻塞在第2步,等待第一个线程初始化实例完成后。后面的N - 1线程会穿行执行synchronized代码块,如果代码块中没有判断singleton是否为null,则还是会再"new" N - 1个实例出来,无法达到单例的目的。 因此这里的DCL机制是必须的。

上面的方案看起来很完美,但是在更严苛的意义上还是有问题的,假设线程1获取锁之后在执行singleton = new Singleton();这一行,这里是new一个Singleton实例,Java中新建一个对象分为三个步骤:

  1. 在内存中开辟一块地址
  2. 对象初始化
  3. 将指针指向这块内存地址

Java中如果我们在一个线程中观察代码,代码都是顺序穿行执行的,但是如果我们在一个线程中观察其他线程,其他线程中的执行都是乱序的。这句话说的是Java中的指令重排序现象。如果在新建Singleton对象的时候第2步和第3步发生了重排序,线程1将singleton指针指向了内存中的地址,但是此时我们的对象还没有初始化。这个时候线程2进来,看到singleton不是null,于是直接返回。这个时候错误就发生了:线程2拿到了一个没有经过初始化的对象。

解决这个问题的思路也很简单:防止指令重排序,Java中可以通过volatile关键字来防止指令重排序。

静态内部类

public class MySingleton1 {
  private MySingleton1() {}
 
  public static MySingleton1 getInstance() {
    return Inner.singleton;
  }
 
  private static class Inner {
    private static MySingleton1 singleton = new MySingleton1();
  }
}

设计

火车票区间查询怎么设计数据结构

比如上海去武汉,途经南京、合肥,现在要快速查询出两点之间票的库存
借鉴跳表的思路可以实现

海量数据处理

如何从大量的 URL 中找出相同的 URL?

给定 a、b 两个文件,各存放 50 亿个 URL,每个 URL 各占 64B,内存限制是 4G。请找出 a、b 两个文件共同的 URL。

对于这种类型的题目,一般采用分治策略,即:把一个文件中的 URL 按照某个特征划分为多个小文件,使得每个小文件大小不超过 4G,这样就可以把这个小文件读到内存中进行处理了。

思路如下

首先遍历文件 a,对遍历到的 URL 求 hash(URL) % 1000 ,根据计算结果把遍历到的 URL 存储到 a0, a1, a2, …, a999,这样每个大小约为 300MB。使用同样的方法遍历文件 b,把文件 b 中的 URL 分别存储到文件 b0, b1, b2, …, b999 中。这样处理过后,所有可能相同的 URL 都在对应的小文件中,即 a0 对应 b0, …, a999 对应 b999,不对应的小文件不可能有相同的 URL。那么接下来,我们只需要求出这 1000 对小文件中相同的 URL 就好了。

接着遍历 ai( i∈[0,999] ),把 URL 存储到一个 HashSet 集合中。然后遍历 bi 中每个 URL,看在 HashSet 集合中是否存在,若存在,说明这就是共同的 URL,可以把这个 URL 保存到一个单独的文件中。


上面的方法实现简单,在单线程环境下没有问题,但是在多线程环境下就会有并发安全问题。如果两个线程同时进入`if (singleton == null)`这里,就会同时去实例化,这样就达不到单例的目的。

**DCL+volatile**

public class Singleton {

private volatile Singleton singleton;

// 构造函数是private,防止外部实例化
private Singleton() {}

public static Singleton getInstance() {
    if (singleton == null) { // 第一次检查
        synchronized (Singleton.class) {
            if (singleton == null) { // 第二次检查,"double check"的由来
                singleton = new Singleton();
            }
        }
    }
    return singleton;
}

}


DCL方法中做了两次`singleton == null`的判断,那么这里为什么需要做两次检查呢? 首先我们看一下这个方法的过程:

1. 检查singleton实例是否为空,如果不为空直接返回。
2. 对Singleton的class加synchronized锁,锁住整个类。如果没有获取锁则阻塞等待。
3. 判断singleton实例是否为空,如果为空则进行初始化。

设想一下,在最开始,如果N个线程同时并发来获取实例,除了获取锁的线程之外其他的线程都阻塞在第2步,等待第一个线程初始化实例完成后。后面的N - 1线程会穿行执行synchronized代码块,如果代码块中没有判断singleton是否为null,则还是会再"new" N - 1个实例出来,无法达到单例的目的。 因此这里的DCL机制是必须的。

上面的方案看起来很完美,但是在更严苛的意义上还是有问题的,假设线程1获取锁之后在执行`singleton = new Singleton();`这一行,这里是new一个Singleton实例,Java中新建一个对象分为三个步骤:

1. 在内存中开辟一块地址
2. 对象初始化
3. 将指针指向这块内存地址

Java中如果我们在一个线程中观察代码,代码都是顺序穿行执行的,但是如果我们在一个线程中观察其他线程,其他线程中的执行都是乱序的。这句话说的是Java中的指令重排序现象。如果在新建Singleton对象的时候第2步和第3步发生了重排序,线程1将singleton指针指向了内存中的地址,但是此时我们的对象还没有初始化。这个时候线程2进来,看到singleton不是null,于是直接返回。这个时候错误就发生了:线程2拿到了一个没有经过初始化的对象。

解决这个问题的思路也很简单:防止指令重排序,Java中可以通过volatile关键字来防止指令重排序。

**静态内部类**

public class MySingleton1 {
private MySingleton1() {}

public static MySingleton1 getInstance() {
return Inner.singleton;
}

private static class Inner {
private static MySingleton1 singleton = new MySingleton1();
}
}




# 设计

## 	火车票区间查询怎么设计数据结构

比如上海去武汉,途经南京、合肥,现在要快速查询出两点之间票的库存
借鉴跳表的思路可以实现

## 海量数据处理

### 如何从大量的 URL 中找出相同的 URL?

给定 a、b 两个文件,各存放 50 亿个 URL,每个 URL 各占 64B,内存限制是 4G。请找出 a、b 两个文件共同的 URL。

对于这种类型的题目,一般采用**分治策略**,即:把一个文件中的 URL 按照某个特征划分为多个小文件,使得每个小文件大小不超过 4G,这样就可以把这个小文件读到内存中进行处理了。

**思路如下**:

首先遍历文件 a,对遍历到的 URL 求 `hash(URL) % 1000` ,根据计算结果把遍历到的 URL 存储到 a0, a1, a2, ..., a999,这样每个大小约为 300MB。使用同样的方法遍历文件 b,把文件 b 中的 URL 分别存储到文件 b0, b1, b2, ..., b999 中。这样处理过后,所有可能相同的 URL 都在对应的小文件中,即 a0 对应 b0, ..., a999 对应 b999,不对应的小文件不可能有相同的 URL。那么接下来,我们只需要求出这 1000 对小文件中相同的 URL 就好了。

接着遍历 ai( `i∈[0,999]` ),把 URL 存储到一个 HashSet 集合中。然后遍历 bi 中每个 URL,看在 HashSet 集合中是否存在,若存在,说明这就是共同的 URL,可以把这个 URL 保存到一个单独的文件中。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

猫与非门

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值