Freeman's Blog

一个菜鸡心血来潮搭建的个人博客

0%

Hadoop

  • 大数据解决方案,分布式系统基础架构,主要包括HDFS和MapReduce。

HDFS

NameNode

  • Master节点
    • 负责管理HDFS中的文件元信息、例如目录树、各个数据节点的可用空间、每个文件的备份情况等等。
      • 目录树
      • 权限信息
      • 记录HDFS中有哪些块
      • 块到DataNode的映射
    • 负责监控各个DataNode的健康状况,如果发现无法连接到DataNode,会将该DataNode移出HDFS并重新备份该节点相关的数据,以满足集群中文件的备份数要求(默认每个文件每块都要有3个副本)。
  • 这些信息以两种形式保存在本地磁盘
    • fsimage: HDFS元数据镜像文件,相当于全量备份
    • editlog: HDFS文件改动日志,相当于增量备份(对执行的修改进行备份)

Secondary NameNode(Hadoop 1.X的思路)

  • 定期合并fsimage和editlog并传输给NameNode,以减小editlog的体积。Secondary NameNode为了减轻NameNode的负担会帮其完成这项工作。
  • 可以作为备份节点作为NameNode的元数据进行热备份
    • 云玩家方案:定期备份,但是两次备份期间的修改会被丢失
    • 真实方案:的确是定期备份。。。
      • 每隔1小时会进行一次合并,editlog中的事务条数达到某个数字也会进行一次合并。

合并fsimage和editlog的流程

  • 生成一个新文件edits.new,用于记录合并过程中产生的日志信息
  • Secondary NameNode从NameNode上读取edits文件和fsimage文件,并进行合并操作,生成一个fsimage.ckpt文件。
  • 将生成的合并后的文件发送到NameNode上,让NameNode替换原本的fsimage
  • 用edits.new成为新的edits文件。

Standby NameNode + 共享存储(since Hadoop 2.X)

  • 只有主NameNode可以对外提供读写服务,StandBy NameNode作为备份。
  • ZKFailOverController:检测到NameNode的健康状况,在主NameNode故障时,借助Zookeeper实现自动的主备选举和切换。
  • ZK集群:提供选举支持
  • 共享存储系统:保证Active NameNode和Standby NameNode能够实现元数据同步。主备切换时新的主NameNode需要确认元数据能够完全同步。

QJM(Quorum Journal Manager)

  • HDFS的默认共享存储方案
  • ActiveNameNode每次写Editlog时都需要向JournalNode集群的每一个JournalNode发送写请求,让每个JournalNode写入相同的内容,只要大多数JournalNode节点返回成功就认为向集群写入EditLog成功。对于2N + 1台JournalNode,最多可以有N台机器挂掉。剩下的N + 1台机器如果能够保持一致,仍然能够构成“大多数”。
  • 向JournalNode提交Editlog是同步阻塞的,但是只需要接收到大多数JournalNode的返回就可以了。
  • 如果没有收到大多数的Editlog返回,则认为提交EditLog失败了。(如果借鉴Paxos算法的经验,此处应该有一个版本号,提交失败的时候,说明当前NameNode的数据版本可能落后于JournalNode集群,不应该继续对外提供服务了)此时NameNode会停止服务。
  • Editlog的分代和分段同步:每个JournalNode只会接收比自身代数大的Editlog同步请求。
    • 增量同步:存在跨度问题,如果本地和最新版本差了不止一代,只添加一个增量也是错误的EditLog
    • 全量同步:每次都传一个最新版本的EditLog,EditLog文件可能会很大。
    • 分段全量同步:fsimage:当前元信息的完整快照。Active NameNode定期完成fsimage和editlog的合并(借助Secondary NameNode),将旧的editlog截断并只同步最新一段的editlog。Standby NameNode也需要进行fsimage和editlog的合并
  • 主备切换
    • ZK方式:让NameNode和ZK维持会话,使ZK能够监控NameNode的健康状况。发生主备切换时,多个StandBy NameNode可能会竞争成为Active NameNode,此时让ZK支持主节点的选举(本质是获得一个分布式锁?)。
    • 只有代数高的节点能够对元信息进行写,但是客户端有可能读到旧的信息。HDFS提供fencing机制,当完成主备切换时,使用一定的机制让原本的Active NameNode停止对外服务。默认是通过ssh的方式发送一条命令,杀掉原本的NameNode进程。

StandBy NameNode

  • Standby NameNode会从JournalNode定期同步editlog,当需要转换为NameNode时需要将落后的Editlog补回来。

DataNode

  • 完成实际的数据存储,并向NameNode定期发送心跳便于双方感知对方存在。HDFS以固定大小的块存储文件内容。文件会被切分为若干块存储到不同的DataNode中,同时会将同一个块写到3个不同的DataNode上。文件切割需要由客户端完成,但是文件块的复制对用户透明。
  • DataNode把每个数据块存在单独的文件中。它不在同一个目录创建所有的文件,而是通过试探的方法确定每个目录的最佳文件数目。
  • DataNode的状态报告:本地文件对应HDFS数据块的列表,作为报告发送到NameNode。

HDFS上传文件的流程

  1. Client向NameNode请求数据上传,先告知NameNode自己需要上传的元数据信息(包括大小吗)
  2. NameNode对上传文件的请求进行检查。例如重名校验和权限校验等。如果检查通过,NameNode会写Editlog,然后修改内存中的元数据信息。
  3. NameNode向客户端响应可以上传文件。
  4. Client在本地将文件进行分割,分割成固定大小的块。按顺序向NameNode请求上传块。
  5. NameNode向Client返回可以上传的节点信息。节点信息是顺序返回的。
  6. Client向返回的DataNode发出传输通道建立请求。第一位的DataNode在收到通道建立请求后也会递归地向下一位DataNode发送通道建立请求。之后DataNode递归地应答通道建立请求,收到所有的通道建立应答后,第一为的DataNode会返回信息示意Client可以开始传输文件。
  7. Client会将每个Block上传到通道第一位的DataNode上,通道中的DataNode按顺序完成Block的复制。(这里不清楚是同步的还是异步的,但是肯定会有确认)。
    • 想要异步复制,可以设置dfs.replication.min,只让最低限度的DataNode先建立通道并完成复制。然后再让NameNode检测到该块未达到复制因子时决定DataNode怎么复制。
  8. 重复以上流程,Client将所有分块传输完毕会向NameNode进行报告。

HDFS下载文件的流程

MapReduce

  • 同样使用Master-Slave架构:JobTracker - TaskTracker。Slave节点向Master节点发送心跳消息以让双方感知对方存在。
  • 只有Map和Reduce函数,更高级的计算范式需要用户手动根据Map和Reduce进行构造。
  • 所有的计算中间结果都需要保存在HDFS上,计算过程需要反复地读写磁盘。

Spark

  • 主要提供一个全面、统一的框架来管理有不同性质的数据集和数据源的大数据处理需求。
  • 更多的计算范式,不局限于Map和Reduce
  • 优化的磁盘使用方式:相比于Hadoop MapReduce,尽可能少地读写磁盘。(只有Shuffle的时候将数据完全存放在磁盘?)
  • DAG计算模型:根据RDD的依赖关系先计算得到DAG,如果不涉及与其它节点进行数据交换(Shuffle),Spark可以尽可能地在内存中完成这些操作。(减少不了Shuffle,这个是由计算的依赖关系决定的)
  • Spark Core:定义RDD的API,操作。
  • Spark Streaming: 允许程序像处理普通RDD一样处理实时数据(流数据)
  • Spark GraphX:控制图、并行图操作和计算的一组算法和工具的集合。

Cluster Manager

  • Master节点,控制整个集群,监控Worker

Worker:控制计算节点,启动Executor或者Driver

  • Driver:运行Application的main函数
  • Executor: 为某个Application运行在Worker Node上的一个进程。

Spark计算模型

RDD

特点

  • 最小的计算单元
  • RDD不会将所有的中间结果存放在硬盘上,只会在内存不足时将部分中间结果存放在内存。
  • 弹性:
    • 存储的弹性:内存和磁盘可以进行自动切换
    • 容错:数据丢失可以自动回复(Linage?)
    • 计算:计算出错可以重试
    • 分片:根据需要重新分片
  • 分布式:数据存储在集群的不同节点
  • 封装计算逻辑而不是持有数据
  • 不可变:逻辑不可改变,如果需要执行新逻辑需要创建新RDD。(转换…)
  • 可分区、并行计算。

核心属性

  • 分区列表:用于执行任务时进行并行计算
  • 分区计算函数:每个分区的数据不同,但是计算逻辑是一样的。计算逻辑对每个分区的数据进行计算
  • 依赖关系:依赖的所有RDD
  • 分区器:可选、决定对数据进行分区的方式
  • 首选位置:确定计算发送给哪个节点。(让效率最优)
    • 移动计算胜过移动数据

创建

  1. 从HDFS/与HDFS兼容的其它持久化存储系统如Hive、Canssandra、HBase输入创建
  2. 从本地文件创建
  3. 从父RDD转换得到新RDD
  4. 通过parallelize或makeRDD将单机数据转换为RDD

算子

  • Transformation:延迟计算,等到Action操作才会触发计算。

动态代理

  • 静态代理的麻烦:要为每一个实现类单独写一个代理类。

JDK动态代理

  • 对于实现了某个接口的类,能够直接生成接口的的代理对象(而不用写代理类)。
  • 基本原理:利用Java反射机制
  • 基本思路:
    1. 接口有方法的定义,但是没有方法体,也没有构造器。通过反射的某个方法根据接口的方法信息创建一个有相同类结构信息但是有构造器的某个类。 -> java.lang.reflect.Proxy类,提供静态方法getProxyClass(ClassLoader loader, Class<?> ... interfaces),只要提供一个类加载器和接口(数组),就可以生成一个代理类的Class对象。而这个代理类是有构造器的(可以通过Class对象调用getConstructors())。该类没有无参构造器,需要传入实现InvocationHandler的类实例。
    2. 接口的方法体为空,在代理对象中使用传入的实现了InvocationHandler的实例即可利用代理目标在代理对象中执行代理目标的方法,并在代理目标的方法调用前后进行额外的操作或控制。代理对象需要持有实现了InvocationHandler接口的实例。在代理对象上调用代理目标的方法时,代理对象会通过实现了InvocationHandler的实例,将调用转移到代理目标上。
      1
      2
      3
      4
      5
      interface InvocationHandler {
      ...
      Object invoke(Object proxy, Method method, Object[] args);
      ...
      }
      其中,proxy为代理对象本身(而不是代理目标)。method为调用的方法,需要对method指定执行方法的目标才可以通过该参数调用方法。args为传入的参数。总之,单独有实现了InvocationHandler接口的实例还是不能正常地调用目标对象的方法,我们需要设法让invoke方法中可以获得到目标对象的引用。方法之一是让实现了InvocationHandler的类持有代理目标的引用(可以用构造函数传参的方式)。
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      class MyInvocationHandler implements InvocationHandler {
      private final Object target;

      public MyInvocationHandler(Object target) {
      this.target = target;
      }

      @Override
      public Objcet invoke(...) {
      ...
      }
      }
    3. 更简单地获得代理对象的方法:Proxy.newProxyInstance(ClassLoader loader, Class<?>[] interfaces, InvocationHandler handler)
  • 优点:不用写代理类,直接生成代理对象。接口增加新方法时,代理对象可以不用进行修改。
  • 缺点:只能对实现了接口的类使用。在代理对象上只能调用接口中定义的方法。

CGLIB动态代理

  • CGLIB(Code Generation Library)是一个基于ASM的字节码生成库,它允许我们在运行时对字节码进行修改和动态生成。CGLIB 通过继承方式实现代理。很多知名的开源框架都使用到了CGLIB, 例如 Spring 中的 AOP 模块中:如果目标对象实现了接口,则默认采用 JDK 动态代理,否则采用 CGLIB 动态代理。CGLIB属于开源项目,需要额外依赖。
  • 基本思路:
    1. 自定义MethodInterceptor并重写intercept方法。该方法用于拦截增强代理目标的方法,类似于JDK动态代理的invoke。
      1
      2
      3
      4
      5
      6
      public class MyInterceptor implements MethodInterceptor {
      @Override
      public Object intercept(Object o, Method method, Object[] args, MethodProxy methodProxy) {
      ...
      }
      }
      其中o是已经被增强的代理目标(the enhanced object),method是被增强的方法(Intercepted Method),args是方法调用的参数。methodProxy用于调用原始方法(super method, non-intercepted method)。
    2. 获得代理类
      1
      2
      3
      4
      5
      // 假设需要代理的目标类的Class对象是clazz
      Enhancer enhancer = new Enhancer();
      enhancer.setClassLoader(clazz.getClassLoader());
      enhancer.setSuperclass(clazz);
      enhancer.setCallback(new MyInterceptor());
  • CGLIB动态代理是通过生成一个代理目标的子类来拦截代理类的方法调用,因此不能代理声明为final的类和方法。

集合

集合概述

javaColletion

Comparable和Comparator

  • 不同的接口,Comparable的方法是compareTo(obj),Comparator的方法是compare(obj1, obj2)

集合底层数据结构

List

  • ArrayList、Vector: Object[]数组。Vector线程安全。
  • ArrayList:
    • 真正持有函数的成员为transient Object[] elementData
    • 无参构造时,为elementData赋予静态成员DEFAULTCAPACITY_EMPTY_ELEMENTDATA
    • 带参构造时,如果参数initialCapacity为0,那么为elementData赋予静态成员EMPTY_ELEMENTDATA。如果参数为正整数则为elementData初始化一个指定容量的新数组。其他情况则抛出异常。
    • 带参构造时,如果参数为空的集合(不是null),那么也赋予elementData静态成员EMPTY_ELEMENTDATA的值
    • 调用add方法时要调用ensureCapacityInternal方法保证数组容量足够。
    • 无论如何,如果最开始不显式指定容量或者使用非空集合创建ArrayList,ArrayList都会将创建DEFAULT_CAPACITY个元素这一行为推迟到向ArrayList加入第一个元素时。
    • ArrayList的扩容过程:
      1. 首先计算扩容后的最小容量。如果当前数据为DEFAULTCAPACITY_EMPTY_ELEMENTDATA,则扩容后容量至少为Math.max(DEFAULT_CAPACITY, minCapacity)。DEFAULT_CAPACITY为10。否则扩容后容量至少为size + 1
      2. 如果minCapacity > elementData.length则需要扩容。扩容的总体规则是,如果旧容量的1.5倍大于minCapacity则扩容1.5倍,否则扩容到minCapacity。此时创建扩容后的新数组并把原本存放在elementData中的元素复制到新数组
      3. Java数组存在最大容量。最大容量一般稍微小于Integer.MAX_VALUE。ArrayList内部用MAX_ARRAY_SIZE保存一个稍微小于Integer.MAX_VALUE的值。如果出现了计算得到的新数组容量存在newCapacity - MAX_ARRAY_SIZE > 0的情况,则判断minCapacity有没有溢出。如果溢出了32位整数的取值范围,则抛出OOM异常,如果没有溢出,则新容量为(minCapacity > MAX_ARRAY_SIZE) ? Integer.MAX_VALUE : MAX_ARRAY_SIZE。
    • 为什么要区分DEFAULTCAPACITY_EMPTY_ELEMENTDATA和EMPTY_ELEMENTDATA?(since Java 8)
      • 为了区分 “我就是要一个初始容量为0的ArrayList(显示指定初始容量为0,使用空集合初始化ArrayList)” 和 “我想要一个初始容量为DEFAULT_CAPACITY的ArrayList,但是我不一定马上要用,因此先返回容量为0的数组,等我要用的时候(第一次加入元素的时候),再给我一次扩容上来” 。
  • LinkedList: 双向(不循环)链表

Set

  • HashSet: 基于HashMap实现,底层使用HashMap,使用成员对象来计算hashcode
    • 检查重复的方法:先计算hashcode值来判断对象加入的位置,同时与其它对象的hashcode值进行比较。如果发现相同hashcode值的对象,需要调用equals方法来检测对象是否真正相同。(hashcode的定义:同一个对象不可能有两个hashcode,不同对象的hashcode可能相同,其实就是“函数”的定义)
  • LinkedHashSet: 基于LinkedHashMap,能够按照添加的顺序进行遍历
  • TreeSet: 有序,能按照添加元素的顺序进行遍历,唯一,基于红黑树

Map(需要重新看)

  • HashTable:内部方法都是synchronized的,线程安全。
  • HashMap: 底层数据结构是数组+链表/红黑树。使用拉链法解决哈希冲突,JDK 1.8引入红黑树来优化过长的链表。HashMap会重新计算键的哈希值而不是使用hashCode。
    • 初始大小(capacity)为16,每次扩容时容量为原来的2倍。HashMap会让集合中的元素保持在一个合理的范围内(由装填因子load factor限制),如果元素数超过指定的阈值this.threshold时,则需要进行扩容并进行rehash。
    • HashMap总是使用2的幂作为哈希表capacity的大小。目的是为了加快通过hash计算KV对存储位置的速度。
    • 使用Key来计算hashcode。通过key的hashcode经过HashMap的扰动函数hash()处理(防止较差的hashCode()实现导致HashMap性能下降)后得到真正的hash值,然后通过(n - 1) & hash(n为HashMap数组的长度,该运算等价于用hash对长度取余)的方式计算当前元素要存储的位置。
    • Table数组:hash表的桶(bucket)。数组的每个位置上都是一个链表。当链表长度大于阈值(默认为8)时,如果当前数组长度小于64,则进行数组扩容,否则将这个链表转换为红黑树。
      HashMap
      • 红黑树:
        • 首先是一种二叉搜索树:左子树小于根小于右子树
        • 根节点是黑色,叶子节点(Nil)是黑色
        • 每个红色节点的叶子节点都是黑色
        • 任意节点到每个叶子节点的路径都有数量相同的黑色节点
  • TreeMap实现了NavigableMap实现对集合内元素的搜索能力,实现SortedMap来实现对元素根据key进行排序的能力。
  • HashMap不是线程安全的

HashMap

初始化

  • 初始容量initialCapacity
  • 负载因子loadFactor
  • 阈值threshold: 键值对数超过这个值要扩容

查找

  • 需要用插入键值对的key的hashCode进行再次hash
    • hash方法的规则:如果key为null则返回0。否则计算hashcode ^ (hashcode >>> 16)
    • 目的:
      1. 对hashCode进行扰动,防止分布性不佳的hashCode影响HashMap的性能
      2. 当数组长度n比较小时可能只有hashCode的低位信息能参与计算,用hashcode的高16位和低16位进行异或,变相让hashcode的高位参与计算,提高了hash值的随机性。
  • 用(n - 1) & hash的方法将hash对数组长度进行求余,效率高。但这要求数组长度必须是2的幂

遍历

  • 迭代器HashIterator:
    • 构造方法:找到第一个包含链表节点引用的桶
    • nextNode方法:如果当前节点非空,则返回的是当前节点,同时如果当前节点的next字段为空(当前桶没有下一个元素了),则寻找下一个包含节点引用的桶。

插入(重点)

插入操作总体逻辑
  1. 首先通过hash方法(hashcode ^ (hashcode >>> 16))计算出真正要使用的hash值
  2. HashMap桶数组初始化可以延迟到第一次插入时。开始插入时首先判断table是否被初始化。如果没有则需要初始化。
  3. 通过(n - 1) & hash计算出桶的位置,如果此处还没有节点,直接新建节点存入该桶即可。
  4. 否则,开始查找该桶中存不存在Key相同的节点e
    • 如果节点是链表节点,则对链表进行遍历,找不到key相同的节点时将节点插入到链表的尾部。如果链表的长度在插入后大于等于树化阈值,则要将链表转换为红黑树。
    • 如果节点是树节点,则调用红黑树的插入方法。
    • 如果在定位插入位置的时候找到了Key相同的节点,则会返回Key相同的节点而不会进行插入。
  5. 判断要插入的键值对是否已经存在(e != null)。如果已经存在,则用新的值替换旧的值,并返回旧的值。否则,说明上一步进行过插入操作,如果插入后节点数量大于阈值,则需要对数组进行扩容。
链表树化机制
  • 如果只使用链表,当发生碰撞的次数过多性能会下降,JDK1.8引入红黑树来处理这个问题
  • 树化链表需要满足:
    1. 桶数组长度大于等于MIN_TREEIFY_CAPACITY = 64。原因可能是因为当桶数组容量较小时,键值对出现哈希冲突的概率会比较高,较容易导致链表长度较长。高碰撞率是因为桶数组容量较小时,应该有限扩容桶数组。并且桶容量较小时,扩容会比较频繁,而扩容时需要拆分红黑树并且进行重新映射。
    2. 链表长度大于等于TREEIFY_THRESHOLD = 8。
  • 链表树化的比较依据(Key不要求实现Comparable接口):
    1. 比较key之间的hash大小,如果hash相同则继续
    2. 检测键是否实现了Comparable接口,如果实现则调用compareTo进行比较,否则继续
    3. 如果仍不能比较出大小,使用tieBreakOrder进行仲裁确定顺序
  • TreeNode节点仍保持了next引用和prev引用(链表型节点是单链表,TreeNode才会有prev引用),链表树化之后原链表的顺序会得到保留
桶数组扩容机制
  • 使用resize方法进行扩容
  • 计算出新数组容量和新阈值
  • 根据新容量创建新的桶数组
  • 将键值对节点重新映射到新的桶数组。如果节点是TreeNode类型,则需要拆分红黑树。如果是普通节点,则节点按原顺序进行分组。
  1. 计算出新数组容量和新阈值
  2. 当oldCap > 0时,桶数组table已经被初始化
    1. 当oldCap >= MAXIMUM_CAPACITY(2^30)时,不再扩容
    2. 当newCap < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY时,会通过乘以2的方式计算新阈值,此时新阈值newThr = oldThr << 1可能会溢出。(loadFactor可能大于1)
  3. 创建数组
  4. 键值对节点的重新映射:对于树形节点,需要先拆分红黑树再进行映射。对于链表型节点,需要先对链表进行分组再映射。
  • 扩容后的数组长度为原数组长度的两倍,并且数组的长度永远是2的幂
  • 对链表型节点的分组映射
    • 对于扩容前的长度m和扩容后的长度n,n - 1比m - 1在最高有效位上多一位1。
    • 举例:如果原容量为16,扩容后为32,那么16 - 1 = 15的二进制表示为0000 1111, 32 - 1 = 31的二进制表示为0001 1111
    • 因此可知扩容后,链表中的元素会根据第5位为1还是0被分成两组。使用m & hash(即oldCap & hash)的值即可判断元素应该属于哪一组
    • 遍历旧链表,根据当前元素oldCap & hash的计算结果是否为0,可以将链表中的旧元素使用尾插法插入两个新链表中,完成链表分组。
    • 最后将两条新链表分别确定插入位置后插入桶中即可完成扩容中的重新映射步骤。
      • 确定的方式:假设原链表所在的数组位置为index。对于新参与hash & (n - 1)计算的位为1的所有节点,该位的新结果必定为1。因此这些节点的新位置应该为index + m,即index + oldCap。相对的,对于该位为0的所有节点,扩容后hash & (n - 1)的计算结果会与原来保持不变,因此这些节点的位置不会改变。
  • 对树形节点的拆分和重映射
    • 树形节点的分组方式和链表型节点是一样的,也是通过oldCap & hash的方式确定分组。然后以遍历链表的方式(利用next引用)遍历红黑树,将树中的节点用尾插法插入到两个链表中。
    • 分别确定两个链表的新插入位置并将两个链表插入新位置。
    • 如果链表长度小于等于UNTREEIFY_THRESHOLD = 6,则将原本的红黑树转换为链表。
    • 否则,如果另一个链表不为空,说明当前链表中的一部分节点重新分组到了另一个链表,需要对当前链表重新进行树化。

删除

  1. 定位桶的位置
  2. 如果是TreeNode类型,用红黑树的查找逻辑定位到待删除节点,调用删除方法
  3. 如果是链表类型,找到待删除节点和它的前驱节点,删除待删除结点。

为什么桶数组table被transient修饰

  • HashMap不使用默认的序列化机制,而是使用readObject/writeObject两个方法定制了序列化和反序列化的过程。
  • 只要保存了键值对,桶数组可以根据键值对的数据重建HashMap
  • 由于装填因子的设置,多数情况下table无法被存满,保存table会浪费空间
  • 如果键对象没有重写hashCode方法,计算hash时需要使用Object类的hashCode方法,而该方法不同的JVM的实现可能不同。如果同一个键产生了不同的Hash,原本的table就无法继续使用了。

为什么TREEIFY_THRESHOLD的取值是8

理想状态下,在默认装填因子为0.75时,容器中的节点遵循参数λ=5的泊松分布。链表中的元素达到8个的概率是非常低的。

为什么UNTREEIFY_THRESHOLD的取值是6(而不是7)

如果为7,删除一个元素就会转换成链表,插入一个元素又会转换成红黑树。

从CPU-寄存器-高速缓存-内存说起

  • 问题:CPU不可能只靠寄存器来完成运算任务,但CPU的速度和内存的存取速度相差太远。
  • 解决:引入高速缓存来缓和CPU速度和内存存取速度的差异。CPU直接独写高速缓存,运算结束再将数据从缓存同步回内存中。
  • 新的问题:缓存一致性。多个CPU有自己的高速缓存,但是他们共享同一个内存,如果多个CPU的运算涉及同一个内存区域将产生数据的不一致问题。此时需要引入缓存一致性协议。
    • 处理器对内存的读写操作的执行顺序,不一定与内存实际发生的独写顺序一致。

Java内存模型

  • 一组规范,并不真实存在,定义了一组规则,定义了程序中各个变量的访问方式(确认一段程序的执行轨迹是否是合法的)。
  • 检查程序执行轨迹的每个读操作,根据一些规则检查读操作观察到的写操作是否是有效的。
  • JMM的实现可以是任意的,只要生成的代码的执行结果能够根据JMM的规则进行预测即可(松规范,给编译器/JVM实现者进行优化的空间,比如指令重排序或者移除不必要的同步)。

JMM的规定

  • 所有共享变量(不包括局部变量,局部变量线程私有)都存储于主内存。共享变量:实例变量、类变量(静态字段)、数组元素。
  • 每一个线程还在自己的工作内存中保留了被线程使用的变量的工作副本。
  • 线程对变量的所有的操作(读,取)都必须在工作内存中完成,而不能直接读写主内存中的变量。
  • 不同线程之间也不能直接访问对方工作内存中的变量,线程间变量的值的传递需要通过主内存中转来完成。
  • intra-thread semantics: 保证重排序不会改变单线程内的程序执行结果。
  • 对指令的重排序:
    • as-if-serial:不能改变单线程程序的语义,重排序后单线程程序的执行结果不能被改变。
    • 不存在数据依赖性的情况下,处理器可以进行重排序
      • 两个操作访问同一个变量,且其中一个操作为写操作,此时两个操作之间存在依赖性。

Java内存模型的三大特性(JMM在多线程环境下的三个问题)

  1. 可见性:一个线程修改了共享变量的值,其他线程能够立即得知这个修改。而单纯遵照JMM的规定,多线程程序中单个线程并不一定能马上获知其它线程对共享变量的更改。JMM通过在变量修改后将值同步回主内存,在变量读取前从主内存刷新变量值来实现可见性。
    • volatile关键字:
    • synchronized关键字:进入synchronized代码块前,线程会获得锁,清空工作内存,从主内存拷贝共享变量最新的值到工作内存成为副本。进入synchronized代码后,会将修改后的副本的值刷新回主内存中,线程才会释放锁。
    • final关键字
  2. 原子性(?):
  3. 有序性
    • 进行指令重排的时候必须考虑数据的依赖性。
    • 在多线程程序中,不同线程之间存在控制依赖关系重排序可能会改变程序的执行结果。
    • 举例:
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      public class ControlDependency {
      int a = 0;
      boolean flag = false;

      public void init() {
      a = 1; // 1
      flag = true; // 2
      }

      public void use() {
      if (flag) { // 3
      int i = a * a; // 4
      }
      }
      }
      在单线程环境下,语句1、2和语句3、4都不存在数据依赖性,因此指令会被重排序。但是在编译器在判断if语句块时会对语句块的内容进行猜测,因此会存在先执行int i = a * a;,然后再判断flag。如果另一线程在执行init时将指令重排序,先执行flag = true,然后又发生了线程切换,这时a的值为0,flag的值却为true。这样就会得到错误的结果int i = 0;

怎么解决JMM在多线程环境下的问题

  1. 插入内存屏障禁止指令重排序。(volatile)
  2. 设置临界区。(synchronized):JMM允许临界区内的代码重排序,不允许临界区内的代码逃逸到临界区之外。
  3. Happens-Before语义:如果一个操作的执行结果需要对另一个操作可见,那么这两个操作之间必须存在happens-before关系。但这并不意味之前一个操作必须在后一个操作之前执行。Happens-before语义类似于单线程环境下的as-if-serial语义,它保证正确同步的多线程程序执行结果不被改变。
    • 程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作
    • 监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
    • volatile变量规则:对一个volatile字段的写操作happens-before于任意后续对这个volatile域的读。
    • 传递性:A happens-before B ,B happens-before C,那么A happens-before C
    • start()规则:如果线程A执行ThreadB.start(),那么A线程的start() happens-before于线程B中的任意操作
    • join()规则:如果线程A执行ThreadB.join(),那么线程B中的任意操作happens-before于线程A从ThreadB.join()成功返回
    • 线程中断规则:对线程interrput方法的调用happens-before于被中断线程的代码检查到中断事件的发生。

内存区域

运行时数据区域

线程私有

程序计数器:

  • 程序计数器是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。字节码解释器工作时通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等功能都需要依赖这个计数器来完成。每条线程都需要有一个独立的程序计数器,各线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。唯一一个不会出现OutOfMemoryError的内存区域。

虚拟机栈

  • 生命周期和线程相同,描述的是 Java 方法执行的内存模型,每次方法调用的数据都是通过栈传递的。
  • 实际上,Java 虚拟机栈是由一个个栈帧组成,而每个栈帧中都拥有:局部变量表、操作数栈、动态链接、方法出口信息。
  • 局部变量表主要存放了编译期可知的各种数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference 类型,它不同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)。
  • StackOverflowError: 若 Java 虚拟机栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候,就抛出 StackOverFlowError错误。
  • OutOfMemoryError: 若 Java 虚拟机堆中没有空闲内存,并且垃圾回收器也无法提供更多内存的话。就会抛出OutOfMemoryError错误。

本地方法栈

  • 拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息。
  • 也会出现StackOverflowError和OutOfMemoryError错误。

线程共享:

堆

  • Java 虚拟机所管理的内存中最大的一块,Java 堆是所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。
  • 逃逸分析/栈上分配: 如果某些方法中的对象引用没有被返回,或者未被外面使用,对象可以直接在栈上分配内存。
  • 分代垃圾回收:
    • JDK < 7:新生代、老年代、方法区(HotSpot 永久代)
    • JDK 7: JDK1.7 字符串常量池被从方法区拿到了堆中, 这里没有提到运行时常量池,也就是说字符串常量池被单独拿到堆,运行时常量池剩下的东西还在方法区, 也就是hotspot中的永久代 。
    • JDK >= 8: 移除堆中的方法区(永久代)、将除了StringTable以外的运行时常量池内容移动到直接内存中的元空间(Metaspace)
    • Eden区、From Survivor区、To Survivor区
  • OutOfMemoryError: GC Overhead Limit Exceeded: JVM花太多时间执行垃圾回收并且只能回收很少垃圾
  • java.lang.OutOfMemoryError: Java heap space: 创建新对象时堆内存空间不足存放新创建的对象(和配置的内存大小有关,和本机物理内存无关)

(逻辑上的)方法区

  • 它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
  • JDK >= 8: StringTable存放于堆内存,其余内容存放于直接内存(元空间)
  • 为什么要将永久代替换为元空间?
    • 整个永久代有一个 JVM 本身设置固定大小上限,无法进行调整,而元空间使用的是直接内存,受本机可用内存的限制,虽然元空间仍旧可能溢出,但是比原来出现的几率会更小。(当元空间溢出时会得到如下错误: java.lang.OutOfMemoryError: MetaSpace)
    • 元空间里面存放的是类的元数据,这样加载多少类的元数据就不由MaxPermSize控制了, 而可以只由由系统的实际可用空间来控制(也可以用参数进行限制,默认为unlimited),这样能加载的类就更多了。
  • 运行时常量池:

直接内存(非运行时数据区的一部分)

  • 直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用。而且也可能导致OutOfMemoryError错误出现。
  • NIO(New Input/Output) 类,引入了一种基于通道(Channel) 与缓存区(Buffer) 的 I/O 方式,它可以直接使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样就能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆之间来回复制数据。
  • 不会受到 Java 堆的限制,但是,既然是内存就会受到本机总内存大小以及处理器寻址空间的限制。

对象的创建过程

1.类加载检查

虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程。

  • 符号引用:大概是一个名字,用名字(字符串)来表达引用关系
  • 直接引用:方法或类的信息被解析到JVM中的数据结构的引用,可以直接使用的数据或指令的引用
  • JVM只有真正需要用到的时候才会将符号引用解析为直接引用

2.分配内存

基本步骤

在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。分配方式有 “指针碰撞” 和 “空闲列表” 两种,选择哪种分配方式由 Java 堆是否规整决定,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。

内存分配

  1. 指针碰撞
    • 适用场景:堆内存规整、没有碎片
    • 原理:用过的内存全部整合到一边、没用过的放在另一边,中间有个分界值指针、只要向着没用过的内存方向将该指针移动内存大小位置即可
    • 对应的GC收集器:Serial、ParNew
  2. 空闲列表
    • 适用:堆内存不规整
    • 原理:JVM维护列表,列表记录哪些内存块可用,分配时找出足够大的内存块为对象分配并更新列表
    • GC收集器:CMS

内存分配的线程安全问题

分配内存的操作需要保证原子性。

  1. CAS:一种乐观锁的实现,冲突则重试
  2. TLAB:为每个线程预先在Eden区分配一部分内存,优先使用TLAB内存,TLAB内存不够用时再通过CAS+重试的方法进行内存分配。

3.初始化零值

内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(默认值?)(不包括对象头),这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。

4.设置对象头

初始化零值完成之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。 另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。

5.执行init方法

在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始,<init> 方法还没有执行,所有的字段都还为零。所以一般来说,执行new指令之后会接着执行<init>方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。

对象的内存布局

对象在内存中的布局:对象头、实例数据、对齐填充。
Hotspot 虚拟机的对象头包括两部分信息,第一部分用于存储对象自身的运行时数据(哈希码、GC 分代年龄、锁状态标志等等),另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是那个类的实例。

实例数据部分是对象真正存储的有效信息,也是在程序中所定义的各种类型的字段内容。

对齐填充部分不是必然存在的,也没有什么特别的含义,仅仅起占位作用。 因为 Hotspot 虚拟机的自动内存管理系统要求对象起始地址必须是 8 字节的整数倍,换句话说就是对象的大小必须是 8 字节的整数倍。而对象头部分正好是 8 字节的倍数(1 倍或 2 倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。

对象的访问定位(如何通过引用访问一个具体对象)

句柄

  • Java 堆中划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息;(一次间接地址)
  • 优点:移动对象不需要改变引用,只需要改变句柄池中到对象实例数据的指针。

直接指针

  • 如果使用直接指针,Java 堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而 reference 中存储的直接就是对象的地址。
  • 优点:速度快,少一次指针定位

String常量池

1
2
3
4
5
String str1 = "abcd";//先检查字符串常量池中有没有"abcd",如果字符串常量池中没有,则创建一个,然后 str1 指向字符串常量池中的对象,如果有,则直接将 str1 指向"abcd"";
String str2 = new String("abcd");//堆中创建一个新的对象,如果常量池中没有这个字符串常量,则也要创建一个
String str3 = new String("abcd");//堆中创建一个新的对象,注意new关键字一定会在堆上创建新的对象
System.out.println(str1==str2);//false
System.out.println(str2==str3);//false

垃圾回收

GC的三个任务

  • 确定回收目标(哪些是垃圾)
  • 确定回收时机
  • 怎么回收

堆内存的区域划分

新生代

存放新生对象,一般占1/3,由于频繁创建对象,会频繁触发MinorGC进行垃圾回收。

  • Eden区:新对象的出生地(如果新对象占用内存很大会直接分配到老年代)。Eden区内存不够的时候会触发MinorGC,对新生代进行一次垃圾回收
  • Survivor From:上一次GC的幸存者,作为这一次GC的被扫描者
  • Survivor To:保留一次MinorGC过程中的幸存者

MinorGC的过程(复制算法)

  1. 将Eden区和Survivor From区域中经历过GC而存活的对象复制到Survivor To区域,如果有对象的年龄到达了进入老年代的标准,则移动到老年代区。复制的同时将这些对象的年龄+1。如果Survivor To空间不足则直接移动到老年代
  2. 然后清空Eden区和Survivor From区
  3. 将Survivor From区和Survivor To区的角色互换,原本的Survivor To区会成为下一次GC的Survivor From区

老年代

主要存放生命周期长的内存对象。因为对象较为稳定所以Old GC不会频繁执行。一般情况下,进行Minor GC使得新生代对象需要移动到老年代,导致老年代空间不足时才触发。如果找不到足够大的连续空间分配给新创建的较大对象时也会提前触发一次Old GC。

Old GC的过程(标记-清除算法)

首先扫描一次所有老年代,标记出存活的对象,然后回收没有标记的对象。OldGC 的耗时比较长,因为要扫描再回收。OldGC 会产生内存碎片,为了减少内存损耗,我们一般需要进行合并或者标记出来方便下次直接分配。当老年代也满了装不下的时候,就会抛出OOM(Out of Memory)异常。

如何确定垃圾

引用计数法

引用和对象是有关联的。如果要操作对象则必须用引用进行。因此,很显然一个简单的办法是通过引用计数来判断一个对象是否可以回收。简单说,即一个对象如果有任何与之关联的引用,即引用计数为0,则说明对象不太可能再被用到,那么这个对象就是可回收对象。但是该方法难以处理循环引用的情况。

可达性分析

通过GC Roots对象为起点进行搜索,节点走过的路径为引用链。GC Roots就是一组必须活跃的引用。当一个对象到GC Roots没有任何引用链相连的话,证明此对象是不可用的。

  • 可以作为GC Roots的对象
    • 虚拟机栈中(栈帧中的本地变量表)的对象
    • 本地方法栈(Native 方法)中引用的对象
    • 方法区中类静态属性引用的对象
    • 方法区中的常量引用的对象(static final…)
    • 所有被同步锁持有的对象

垃圾回收算法

  • 标记-清除:内存碎片
  • 标记-整理
  • 标记-复制:实现简单效率高,但是内存可用空间减小。存活对象增多的时候复制开销大(因此适合对象生命周期不长的新生代)
  • 分代收集:新生代多数采取复制算法。老年代可能使用标记-整理

3种垃圾回收算法的比较

四种引用类型

  • 在程序设计中一般很少使用弱引用与虚引用,使用软引用的情况较多,这是因为软引用可以加速 JVM 对垃圾内存的回收速度,可以维护系统的运行安全,防止内存溢出(OutOfMemory)等问题的产生。

强引用

在Java 中最常见的就是强引用,把一个对象赋给一个引用变量,这个引用变量就是一个强引用。当一个对象被强引用变量引用时,它处于可达状态,它是不可能被垃圾回收机制回收的,即使该对象以后永远都不会被用到JVM也不会回收。因此强引用是造成Java 内存泄漏的主要原因之一。

软引用

软引用需要用SoftReference 类来实现,对于只有软引用的对象来说,当系统内存足够时它不会被回收,当系统内存空间不足时它会被回收。软引用通常用在对内存敏感的程序中。软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被垃圾回收,JAVA 虚拟机就会把这个软引用加入到与之关联的引用队列中。应用场景:内存cache,性能不足的设备上的多任务(多应用、后台应用)。

弱引用

弱引用需要用WeakReference 类来实现,它比软引用的生存期更短,对于只有弱引用的对象来说,只要垃圾回收机制一运行,不管JVM 的内存空间是否足够,总会回收该对象占用的内存。不过,由于垃圾回收器是一个优先级很低的线程, 因此不一定会很快发现那些只具有弱引用的对象。

  • WeakHashMap: 不使用的Key会被移除。更准确地说,不会阻止垃圾回收器将Key进行回收。

虚引用

虚引用需要PhantomReference 类来实现,它不能单独使用,必须和引用队列联合使用。虚引用与软引用和弱引用的一个区别在于: 虚引用必须和引用队列(ReferenceQueue)联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。程序如果发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动。
设置虚引用关联的唯一目的就是在这个对象被收集器回收的时候收到一个系统通知,或者后续添加进一步的处理。允许finalize()方法在垃圾收集器将对象从内存中清除出去之前做必要的清理工作。对虚引用调用get()会返回null。
清理工作:例如使用DirectByteBuffer的时候回收堆外内存。

引用队列

对象在被GC后会被添加到引用队列中。

被判定为需要回收的对象的回收时机

要真正宣告一个对象死亡,至少要经历两次标记过程;可达性分析法中不可达的对象被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行 finalize 方法。当对象没有覆盖 finalize 方法,或 finalize 方法已经被虚拟机调用过时,虚拟机将这两种情况视为没有必要执行。
被判定为需要执行的对象将会被放在一个队列中进行第二次标记,除非这个对象与引用链上的任何一个对象建立关联,否则就会被真的回收。(在finalize方法上强行将引用挂到一个可达对象上可以抢救一个对象,但是下次GC若被判定为需要回收,则不会再次调用finalize方法)

  • finalize方法并不推荐使用。JVM不会保证等到finalize方法执行完成。

方法区的垃圾回收

  • 方法区(“永久代”)并不是不会进行垃圾回收。方法区GC的主要对象是废弃常量和无用的类。

运行时常量池的垃圾回收

假如在字符串常量池中存在字符串 “abc”,如果当前没有任何 String 对象引用该字符串常量的话,就说明常量 “abc” 就是废弃常量,如果这时发生内存回收的话而且有必要的话,”abc” 就会被系统清理出常量池了。

如何判断无用的类

  • 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。
  • 加载该类的ClassLoader已经被回收。
  • 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
    虚拟机可以对满足上述 3 个条件的无用类进行回收,这里说的仅仅是“可以”,而并不是和对象一样不使用了就会必然被回收。

垃圾收集器(重点是CMS和G1)

Serial

Serial(串行)收集器是最基本、历史最悠久的垃圾收集器了。大家看名字就知道这个收集器是一个单线程收集器了。它的 “单线程” 的意义不仅仅意味着它只会使用一条垃圾收集线程去完成垃圾收集工作,更重要的是它在进行垃圾收集工作的时候必须暂停其他所有的工作线程( “Stop The World” ),直到它收集结束。

新生代采用标记-复制算法,老年代采用标记-整理算法。
GCSerial

ParNew

ParNew 收集器其实就是 Serial 收集器的多线程版本,除了使用多线程进行垃圾收集外,其余行为(控制参数、收集算法、回收策略等等)和 Serial 收集器完全一样。它是许多运行在 Server 模式下的虚拟机的首要选择,除了 Serial 收集器外,只有它能与 CMS 收集器(真正意义上的并发收集器,后面会介绍到)配合工作。

会暂停用户线程(Stop The World),适用与科学计算、大数据处理等弱交互场景。

新生代采用标记-复制算法,老年代采用标记-整理算法。

  • 并行(Parallel) :指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态。
  • 并发(Concurrent):指用户线程与垃圾收集线程同时执行(但不一定是并行,可能会交替执行),用户程序在继续运行,而垃圾收集器运行在另一个 CPU 上。
    GCParNew

Parallel Scavenge

Parallel Scavenge 收集器也是使用标记-复制算法的多线程收集器,它看上去几乎和 ParNew 都一样。 那么它有什么特别之处呢?(Parallel Scavenge无法与CMS配合工作)
Parallel Scavenge 收集器关注点是吞吐量(高效率的利用 CPU)。CMS 等垃圾收集器的关注点更多的是用户线程的停顿时间(提高用户体验)。(降低总的停顿时间 vs 降低单次停顿需要的时间)所谓吞吐量就是 CPU 中用于运行用户代码的时间与 CPU 总消耗时间的比值。 Parallel Scavenge 收集器提供了很多参数供用户找到最合适的停顿时间或最大吞吐量,如果对于收集器运作不太了解,手工优化存在困难的时候,使用 Parallel Scavenge 收集器配合自适应调节策略,把内存管理优化交给虚拟机去完成也是一个不错的选择。
此为JDK1.8的默认收集器。

新生代采用标记-复制算法,老年代采用标记-整理算法。

Serial Old

Serial 收集器的老年代版本,它同样是一个单线程收集器。它主要有两大用途:一种用途是在 JDK1.5 以及以前的版本中与 Parallel Scavenge 收集器搭配使用,另一种用途是作为 CMS 收集器的后备方案。

Parallel Old

Parallel Scavenge 收集器的老年代版本。使用多线程和“标记-整理”算法。在注重吞吐量以及 CPU 资源的场合,都可以优先考虑 Parallel Scavenge 收集器和 Parallel Old 收集器。

CMS(Concurrent Mark Sweep,也是用于老年代的垃圾收集器)

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。它非常符合在注重用户体验的应用上使用。是 HotSpot 虚拟机第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。是一种标记-清除算法的实现。

  • 工作过程
    • 初始标记: 暂停所有的其他线程,并记录下直接与 root 相连的对象,速度很快
    • 并发标记: 同时开启 GC 和用户线程,用一个闭包结构去记录可达对象。但在这个阶段结束,这个闭包结构并不能保证包含当前所有的可达对象。因为用户线程可能会不断的更新引用域,所以 GC 线程无法保证可达性分析的实时性。所以这个算法里会跟踪记录这些发生引用更新的地方。
    • 重新标记: 重新标记阶段就是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录(由于用户程序的并发运行,一部分被标记为不可达的对象又可达了),这个阶段的停顿时间一般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短。
    • 并发清除: 开启用户线程,同时 GC 线程开始对未标记的区域做清扫。
    • 并发重置(?)
      GCCMS
  • 优点:并发收集、低停顿
  • 缺点:
    • 对CPU资源敏感
    • 无法处理浮动垃圾:并发标记过程中,由于用户线程继续运行,可能会产生新的垃圾(这种现象成为Mutation, Mutator Problems),这部分垃圾并没有被GC线程识别(标记成了活动对象,不会被回收),称为浮动垃圾。而重新标记阶段的作用只是修改并发标记获得的不可达对象,没有办法处理,只能等到下一次GC再进行。
    • 标记清除算法会产生内存碎片 -> CMS怎么解决内存碎片问题?
      1. 在应用访问量少的时候调用System.gc()。System.gc()会对堆内存进行整理。
      2. 在应用启动完并完成所有初始化工作之后,主动调用System.gc(),可以将初始化的数据整理到一个连续的块中,以腾出更多连续内存空间给新生代晋升使用。
      3. 降低-XX:CMSInitiatingOccupancyFraction=NNN参数,让CMS提前开始并发收集。并发收集不会进行整理,但是会合并老年代中相邻的可用空间,让新生代有足够的连续空间可以用于晋升。该参数是并发收集启动的老年代内存占用率阈值,当老年代内存占用达到NNN(百分比)时,启动并发收集。但是频繁GC也会带来频繁的停顿,需要避免。
      4. (9以上已废弃)开启-XX:+UseCMSCompactAtFullColletion,并设置-XX:CMSFullGCBeforeCompaction=NNN,可以在NNN次Full GC之后进行标记整理。
      5. 触发Concurrent Mode Failure:用户线程向老年代请求分配的空间超过预留空间,后台线程的收集没有赶上应用线程的分配速度
        • 可能触发后备收集器Serial Old进行标记整理(Full GC)。
        • 可能触发CMS的foreground模式进行标记整理(第4点,9以上已经废弃)
        • 都会触发Stop The World

G1(Garbage-First,新生代、老年代都可以,from Java 8)

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

  • 特点
    • 并行与并发:G1 能充分利用 CPU、多核环境下的硬件优势,使用多个 CPU(CPU 或者 CPU 核心)来缩短 Stop-The-World 停顿时间。部分其他收集器原本需要停顿 Java 线程执行的 GC 动作,G1 收集器仍然可以通过并发的方式让 java 程序继续执行。
    • 分代收集:虽然 G1 可以不需要其他收集器配合就能独立管理整个 GC 堆,但是还是保留了分代的概念。
      • G1并没有将堆划分为连续的新生代(以及其中的Eden区,Survivor0区,Survivor1区)、老年代,而是将堆划分为若干个区域(Region)。
      • 这些Region的一部分包含新生代,负责新生代的GC仍然采用暂停所有应用线程的方式(Stop The World),将存活对象复制到老年代或者Survivor To空间。
      • 这些Region的一部分包含老年代,G1收集器通过将对象从一个区域复制到另一个区域完成了清理工作。这意味着在正常的处理过程中G1完成了(一部分)堆的压缩。解决了CMS的内存碎片问题。
      • G1中有一种特殊区域:Humongous区。之前的做法是将巨型对象分配到老年代,但是如果该对象生存时间短就会对GC产生负面影响。G1为此专门划分了Humongous区来存放巨型对象。G1会寻找一个或连续的H分区来存储巨型对象,有时候可能会启用Full GC。
    • 空间整合:与 CMS 的“标记-清理”算法不同,G1 从整体来看是基于“标记-整理”算法实现的收集器;从局部上来看是基于“标记-复制”算法实现的。不会产生内存碎片。
    • 可预测的停顿:这是 G1 相对于 CMS 的另一个大优势,降低停顿时间是 G1 和 CMS 共同的关注点,但 G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内。
    • G1 收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的 Region(这也就是它的名字 Garbage-First 的由来) 。这种使用 Region 划分内存空间以及有优先级的区域回收方式,保证了 G1 收集器在有限时间内可以尽可能高的收集效率(把内存化整为零)。

工作过程(类似于CMS

  • 初始标记(STW)
  • 并发标记
  • 最终标记(STW)
  • 筛选回收

G1收集器的Young GC/Minor GC

针对Eden进行收集,当Eden区空间耗尽会触发。
G1YoungGC
G1YoungGCAfter

常见参数

  • -XX:+UseG1GC
  • -XX:G1HeapRegionSize=N: G1划分单个区域的大小
  • -XX:MaxGCPauseMillis=N: 最大GC停顿时间,JVM追求尽可能小于该值(对G1的STW时间进行预测
  • -XX:InitiatingHeapOccupancyPercent=N: 堆占用达到多少的时候触发GC
  • -XX:ConcGCThreads=N: 并发GC使用的线程数
  • -XX:G1ReservePercent=N: 作为空闲空间的预留内存百分比,降低目标空间的溢出风险

内存分配策略

1. 对象优先在 Eden 分配

大多数情况下,对象在新生代 Eden 上分配,当 Eden 空间不够时,发起 Minor GC。

2. 大对象直接进入老年代

大对象是指需要连续内存空间的对象,最典型的大对象是那种很长的字符串以及数组。

经常出现大对象会提前触发垃圾收集以获取足够的连续空间分配给大对象。

-XX:PretenureSizeThreshold,大于此值的对象直接在老年代分配,避免在 Eden 和 Survivor 之间的大量内存复制。

3. 长期存活的对象进入老年代

为对象定义年龄计数器,对象在 Eden 出生并经过 Minor GC 依然存活,将移动到 Survivor 中,年龄就增加 1 岁,增加到一定年龄则移动到老年代中。

-XX:MaxTenuringThreshold 用来定义年龄的阈值。

4. 动态对象年龄判定

虚拟机并不是永远要求对象的年龄必须达到 MaxTenuringThreshold 才能晋升老年代,如果在 Survivor 中相同年龄所有对象大小的总和大于 Survivor 空间的一半,则年龄大于或等于该年龄的对象可以直接进入老年代,无需等到 MaxTenuringThreshold 中要求的年龄。

5. 空间分配担保

在发生 Minor GC 之前,虚拟机先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果条件成立的话,那么 Minor GC 可以确认是安全的。
如果不成立的话虚拟机会查看 HandlePromotionFailure 的值是否允许担保失败,如果允许那么就会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次 Minor GC;如果小于,或者 HandlePromotionFailure 的值不允许冒险,那么就要进行一次 Full GC。

  • 新生代使用标记-复制算法,Survivor From区和Eden区存活的对象都需要复制到Survivor To区,而Survivor To区的空间是比较小的。这需要老年代进行担保,将Survivor无法容纳的对象放到老年代。这要求老年代有足够的空间容纳这些对象,需要用某些方式估计晋升到老年代对象的大小。
  • 如果老年代可以容纳当前新生代的所有对象,即使遇到最极端的情况(新生代的对象全部存活),可以断定本次Minor GC肯定是安全的。
  • 如果上述条件不满足,则Minor GC有失败风险,需要确定是否冒险进行Minor GC。如果HandlePromotionFailure = true,说明可以冒险进行Minor GC,则使用另一种方式对晋升老年代的对象大小进行评估,即参考晋升老年代对象的平均大小。如果还是空间不足,则只能进行Full GC,让老年代腾出空间。

Full GC 的触发条件

对于 Minor GC,其触发条件非常简单,当 Eden 空间满时,就将触发一次 Minor GC。而 Full GC 则相对复杂,有以下条件:

1. 调用 System.gc()

只是建议虚拟机执行 Full GC,但是虚拟机不一定真正去执行。不建议使用这种方式,而是让虚拟机管理内存。

2. 老年代空间不足

老年代空间不足的常见场景为前文所讲的大对象直接进入老年代、长期存活的对象进入老年代等。

为了避免以上原因引起的 Full GC,应当尽量不要创建过大的对象以及数组。除此之外,可以通过 -Xmn 虚拟机参数调大新生代的大小,让对象尽量在新生代被回收掉,不进入老年代。还可以通过 -XX:MaxTenuringThreshold 调大对象进入老年代的年龄,让对象在新生代多存活一段时间。

3. 空间分配担保失败

使用复制算法的 Minor GC 需要老年代的内存空间作担保,如果担保失败会执行一次 Full GC。具体内容请参考上面的第 5 小节。

4. JDK 1.7 及以前的永久代空间不足

在 JDK 1.7 及以前,HotSpot 虚拟机中的方法区是用永久代实现的,永久代中存放的为一些 Class 的信息、常量、静态变量等数据。

当系统中要加载的类、反射的类和调用的方法较多时,永久代可能会被占满,在未配置为采用 CMS GC 的情况下也会执行 Full GC。如果经过 Full GC 仍然回收不了,那么虚拟机会抛出 java.lang.OutOfMemoryError。

为避免以上原因引起的 Full GC,可采用的方法为增大永久代空间或转为使用 CMS GC。

5. Concurrent Mode Failure

执行 CMS GC 的过程中同时有对象要放入老年代,而此时老年代空间不足(可能是 GC 过程中浮动垃圾过多导致暂时性的空间不足),便会报 Concurrent Mode Failure 错误,并触发 Full GC。

OutOfMemoryError

Java heap space

StackOverflowError

GC overhead limit exceeded

GC耗时过长且回收效果差(回收了很小一部分堆内存)。

DirectBufferMemory

  • NIO: 可以直接分配堆外内存(OS的本地内存,不属于GC的管辖范围,不需要内存拷贝(Java堆和Native堆之间)所以速度较快)
  • 错误表示堆外内存耗尽

unable to create native thread

  • 一个进程中创建了太多线程。线程的上限和OS有关。

Metaspace

加载太多类。

Java内存泄漏

  • 内存溢出:内存溢出是指没有足够的内存空间可供程序使用,出现OutOfMemoryError。内存中加载的数据量过于庞大,静态集合类中对对象的引用使用完未清空等。
  • 内存泄漏:申请内存后无法及时释放内存空间,造成可达但没有用的对象,这些对象不会被GC,依然占用内存空间。内存泄漏最终会导致内存溢出。
    • 可能的场景:全局(静态)的集合(长生命周期对象持有短生命周期对象的强引用 -> WeakHashMap),Key使用强引用,不关闭数据库连接。
  • 如何判断分析:频繁出现Full GC。
    • 进行堆转储并分析堆内对象的大小。
      • JVM参数:-XX:+HeapDumpOnOutOfMemoryError
      • jmap -dump:format=b file=[文件名] [pid]
      • JMX: 使用Jconsole找到名为HotSpotDiagnostic的MBean,即可完成堆转储。

类文件结构(?)

关键点:

  • 当前类名
  • 父类名
  • 接口数量
  • 所有实现的接口
  • 有多少字段
  • 字段的具体信息(字段名/字段类型)
  • 方法数量
  • 方法信息

类的生命周期

类加载过程(跟对象创建过程进行一下区别和联系)

  • 加载、连接(验证、准备、解析)、初始化

加载

类加载过程的第一步,主要完成下面3件事情:

  1. 通过全类名获取定义此类的二进制字节流
  2. 将字节流所代表的静态存储结构转换为方法区的运行时数据结构
  3. 在内存中生成一个代表该类的 Class 对象,作为方法区这些数据的访问入口
    加载是类加载过程中的一个阶段,这个阶段会在内存中生成一个代表这个类的java.lang.Class 对象,作为方法区这个类的各种数据的入口。注意这里不一定非得要从一个Class 文件获取,这里既可以从ZIP 包中读取(比如从jar 包和war 包中读取),也可以在运行时计算生成(动态代理),也可以由其它文件生成(比如将JSP 文件转换成对应的Class 类)。
  • 一个非数组类的加载阶段(加载阶段获取类的二进制字节流的动作)是可控性最强的阶段,这一步我们可以去完成还可以自定义类加载器去控制字节流的获取方式(重写一个类加载器的loadClass()方法)。数组类型不通过类加载器创建,它由 Java 虚拟机直接创建。

验证

确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。

准备

准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中分配。

  1. 这时候进行内存分配的仅包括类变量(static),而不包括实例变量,实例变量会在对象实例化时随着对象一块分配在 Java 堆中。
  2. 这里所设置的初始值”通常情况”下是数据类型默认的零值(如0、0L、null、false等),比如我们定义了public static int value=111 ,那么 value 变量在准备阶段的初始值就是 0 而不是111(初始化阶段才会赋值)。特殊情况:比如给 value 变量加上了 fianl 关键字public static final int value=111 ,那么准备阶段 value 的值就被赋值为 111。

解析(?)

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用限定符7类符号引用进行。

符号引用就是一组符号来描述目标,可以是任何字面量。直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。在程序实际运行时,只有符号引用是不够的,举个例子:在程序执行方法时,系统需要明确知道这个方法所在的位置。Java 虚拟机为每个类都准备了一张方法表来存放类中所有的方法。当需要调用一个类的方法的时候,只要知道这个方法在方法表中的偏移量就可以直接调用该方法了。通过解析操作符号引用就可以直接转变为目标方法在类中方法表的位置,从而使得方法可以被调用。

综上,解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,也就是得到类或者字段、方法在内存中的指针或者偏移量。

初始化

初始化是类加载的最后一步,也是真正执行类中定义的 Java 程序代码(字节码),初始化阶段是执行初始化方法 <clinit> ()方法的过程。

对于<clinit>() 方法的调用,虚拟机会自己确保其在多线程环境中的安全性。因为 <clinit>() 方法是带锁线程安全,所以在多线程环境下进行类初始化的话可能会引起死锁,并且这种死锁很难被发现。

对于初始化阶段,虚拟机严格规范了必须对类进行初始化的情况(只有主动去使用类才会初始化类):

  1. 当遇到 new 、 getstatic、putstatic或invokestatic 这4条直接码指令时,比如 new 一个类,读取一个静态字段(未被 final 修饰)、或调用一个类的静态方法时。
    • 当jvm执行new指令时会初始化类。即当程序创建一个类的实例对象。
    • 当jvm执行getstatic指令时会初始化类。即程序访问类的静态变量(不是静态常量,常量会被加载到运行时常量池)。
    • 当jvm执行putstatic指令时会初始化类。即程序给类的静态变量赋值。
    • 当jvm执行invokestatic指令时会初始化类。即程序调用类的静态方法。
  2. 使用 java.lang.reflect 包的方法对类进行反射调用时如Class.forname(“…”),newInstance()等等。 如果类没初始化,需要触发其初始化。
  3. 初始化一个类,如果其父类还未初始化,则先触发该父类的初始化。
  4. 当虚拟机启动时,用户需要定义一个要执行的主类 (包含 main 方法的那个类),虚拟机会先初始化这个类。
  5. MethodHandle和VarHandle可以看作是轻量级的反射调用机制,而要想使用这2个调用, 就必须先使用findStaticVarHandle来初始化要调用的类。
  6. 当一个接口中定义了JDK8新加入的默认方法(被default关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。

卸载

卸载类即该类的Class对象被GC。
卸载类需要满足3个要求:

  1. 该类的所有的实例对象都已被GC,也就是说堆不存在该类的实例对象。
  2. 该类没有在其他任何地方被引用
  3. 该类的类加载器的实例已被GC
    所以,在JVM生命周期内,由jvm自带的类加载器加载的类是不会被卸载的。但是由我们自定义的类加载器加载的类是可能被卸载的。
    jdk自带的BootstrapClassLoader,ExtClassLoader,AppClassLoader负责加载jdk提供的类,所以它们(类加载器的实例)肯定不会被回收。而我们自定义的类加载器的实例是可以被回收的,所以使用我们自定义加载器加载的类是可以被卸载掉的。

类加载机制

所有的类都由类加载器加载,加载的作用就是将 .class文件加载到内存。

JVM内置ClassLoader

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

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

为什么要自定义ClassLoader?

  • Java中提供的默认ClassLoader,只加载指定目录下的jar和class,如果我们想加载其它位置的类或jar时,比如:我要加载网络上的一个class文件,通过动态加载到内存之后,要调用这个类中的方法实现我的业务逻辑。在这样的情况下,默认的ClassLoader就不能满足我们的需求了,所以需要定义自己的ClassLoader。
  • JVM运行时并不会一次性加载所需要的全部类,它是按需加载(懒加载、延迟加载)。比如你在调用某个类的静态方法时,首先这个类肯定是需要被加载的,但是并不会触及这个类的实例字段,那么实例字段的类别 Class 就可以暂时不必去加载,但是它可能会加载静态字段相关的类别,因为静态方法会访问静态字段。而实例字段的类别需要等到你实例化对象的时候才可能会加载。
  • 程序在运行过程中,遇到了一个未知的类,它会选择哪个 ClassLoader 来加载它呢?虚拟机的策略是使用调用者 Class 对象的 ClassLoader 来加载当前未知的类。何为调用者 Class 对象?就是在遇到这个未知的类时,虚拟机肯定正在运行一个方法调用(静态方法或者实例方法),这个方法挂在哪个类上面,那这个类就是调用者 Class 对象。前面我们提到每个 Class 对象里面都有一个 classLoader 属性记录了当前的类是由谁来加载的。

双亲委派模型

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

  • When loading a class, a class loader first “delegates” the search for the class to its parent class loader before attempting to find the class itself.
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    private final ClassLoader parent; 
    protected Class<?> loadClass(String name, boolean resolve)
    throws ClassNotFoundException
    {
    synchronized (getClassLoadingLock(name)) {
    // 首先,检查请求的类是否已经被加载过
    Class<?> c = findLoadedClass(name);
    if (c == null) {
    long t0 = System.nanoTime();
    try {
    if (parent != null) {//父加载器不为空,调用父加载器loadClass()方法处理
    c = parent.loadClass(name, false);
    } else {//父加载器为空,使用启动类加载器 BootstrapClassLoader 加载
    c = findBootstrapClassOrNull(name);
    }
    } catch (ClassNotFoundException e) {
    //抛出异常说明父类加载器无法完成加载请求
    }

    if (c == null) {
    long t1 = System.nanoTime();
    //自己尝试加载
    c = findClass(name);

    // this is the defining class loader; record the stats
    sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
    sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
    sun.misc.PerfCounter.getFindClasses().increment();
    }
    }
    if (resolve) {
    resolveClass(c);
    }
    return c;
    }
    }

    loadClass() 方法是加载目标类的入口,它首先会查找当前 ClassLoader 以及它的双亲里面是否已经加载了目标类,如果没有找到就会让双亲尝试加载,如果双亲都加载不了,就会调用 findClass() 让自定义加载器自己来加载目标类。ClassLoader 的 findClass() 方法是需要子类来覆盖的,不同的加载器将使用不同的逻辑来获取目标类的字节码。拿到这个字节码之后再调用 defineClass() 方法将字节码转换成 Class 对象。

为什么要使用双亲委派模型

  • 双亲委派模型保证了Java程序的稳定运行,可以避免类的重复加载(JVM 区分不同类的方式不仅仅根据类名,相同的类文件被不同的类加载器加载产生的是两个不同的类),当父类加载器已经加载该类的时候,没有必要让子ClassLoader再加载一次。保证了 Java 的核心 API 不被篡改(?)。
  • 如果不使用这种委托模式,那我们就可以随时使用自定义的String来动态替代java核心api中定义的类型,这样会存在非常大的安全隐患,而双亲委托的方式,就可以避免这种情况,因为String已经在启动时就被引导类加载器(Bootstrcp ClassLoader)加载,所以用户自定义的ClassLoader永远也无法加载一个自己写的String,除非你改变JDK中ClassLoader搜索类的默认算法。
  • JVM在判定两个class是否相同时,不仅要判断两个类名是否相同,而且要判断是否由同一个类加载器实例加载的。只有两者同时满足的情况下,JVM才认为这两个class是相同的。

钻石依赖问题(为什么JVM不只根据类名来区分不同类?)

  • 钻石依赖问题:软件依赖导致同一个软件包的两个版本需要共存而不能冲突。
  • Maven怎么解决钻石依赖:扁平化依赖管理
    • 依赖于JVM的默认懒加载策略。
    • 从多个冲突的版本中选一个。如果不同版本之间的兼容性很糟糕,则程序无法正常编译运行。
  • ClassLoader的解决方案:使用不同的ClassLoader加载不同版本的软件包。位于不同的ClassLoader中名称一样的类实际上是不同的类。
    • 只能使用反射或者接口的形式进行动态调用。
  • ClassLoader:相当于命名空间,一定程度上起到类隔离的作用。

如何打破双亲委派机制

  • 自定义加载器需要继承ClassLoader(除了BootstrapClassLoader所有的类加载器都继承自ClassLoader)
  • 自定义加载器的话,需要继承 ClassLoader 。如果我们不想打破双亲委派模型,就重写 ClassLoader 类中的 findClass() 方法即可,无法被父类加载器加载的类最终会通过这个方法被加载。但是,如果想打破双亲委派模型则需要重写 loadClass() 方法

Class.forName vs ClassLoader.loadClass

这两个方法都可以用来加载目标类,它们之间有一个小小的区别,那就是 Class.forName() 方法可以获取原生类型的 Class,而 ClassLoader.loadClass() 则会报错。

经典应用场景

  • Tomcat:
    • 保证同一个服务器的两个Web应用的Java类库互相隔离。
    • 保证同一个服务器的两个Web应用程序的Java类库又可以共享(???)
    • 保证服务器尽可能保证自身安全,不受到web应用的影响。
    • JSP的HotSwap?
  • OSGi?

JVM 命令

JVM的参数类型

标配参数

X参数

  • 解释执行
  • 第一次就编译成本地代码
  • 混合模式

XX参数

  • -XX:+/-: 开启/关闭某个属性值
  • K-V设值类型:-XX:MetspaceSize=blabla
  • 堆内存大小
    • 初始: -Xms= 等价于 -XX:InitialHeapSiZE:
    • 最大: -Xmx= 等价于 -XX:MaxlHeapSiZE:
    • 最小: -Xmn
  • 栈内存大小
    • -Xss:单个线程栈的大小

查看运行中的java程序

  • jps:查看进程号
  • jinfo :查看运行参数 jinfo -flag [设置] <进程号>

查看JVM默认值

  • -XX:+PrintFlagsInitial

查看被修改过的JVM参数

  • -XX:+PrintFlagFinal

查看(默认)JVM命令

  • -XX:PrintCommandLineFlags

  • [] Netty线程模型
  • [] Zero Copy

反应器模式

  • 反应器Reactor: 负责查询IO事件。检测到一个IO事件时,将其发送给相应的Handler处理器去处理。这里的IO事件就是NIO中选择器监控的通道IO事件。
  • Handler处理器:与IO事件绑定,负责IO事件的处理。例如,完成真正的连接建立、通道(channel)的读取、处理业务逻辑、负责将结果写出到通道等。

单线程的Reactor模式

  • Reactor和Handler在同一线程中运行。
  • 相对于传统的多线程Blocking-IO,免去了线程切换的成本
  • 缺点:Reactor和Handler都在同一线程上执行。如果一个Handler被阻塞,其它所有的Handler都得不到执行。假如负责监听连接的Handler和负责数据输入输出的Handler都在同一个线程上,负责监听连接的Handler可能会被阻塞,此时整个服务都不能接受新的连接。
  • 思考:为什么Redis使用了单线程的Reactor模式?
    • Redis是一个服务器,Redis的数据操作多数都在内存中,因此Handler的操作耗时应该非常少
    • 多个线程切换需要线程切换开销,如果Handler并不需要很长的阻塞时间,使用多线程模型可能弊大于利
    • Redis是一个数据库,如果使用多线程的IO模型,多个线程可能会对同一个数据进行操作,此时需要引入同步机制,而同步机制的开销是相当大的。使用单线程模型,天然保证了单个Redis的数据库操作的原子性。

多线程的Reactor模式

  1. 升级Handler:使用线程池
  2. 升级Reactor:引入多个Selector,将单个Reactor线程拆分为多个SubReactor线程,每个SubReactor线程使用一个Selector。

VS 生产者消费者模式

反应器模式没有专门的队列去缓冲存储IO事件

VS 观察者模式

在Reactor模式中一个事件绑定到了一个Handler上,每个IO事件被查询后Reactor会将事件分发给所绑定的Handler。而观察者模式中同一个事件(主题)可以被订阅过的多个观察者处理。

Reactor的优缺点

优点:

  • 响应快,不会被单个连接的同步IO阻塞整个服务
  • 编程相对简单
  • 可扩展:增加Reactor线程的个数

缺点:

  • 增加一定复杂性
  • 需要OS底层IO多路复用的支持

Netty

Netty中的Reactor

NioEventLoop类

  • 一个NioEventLoop拥有一个线程,负责一个Java NIO Selector上的IO事件轮询。一个Reactor可以注册多个netty channel。一个EventLoop相当于一个(Sub)Reactor。
  • EventLoopGroup:线程组,多线程版本的反应器,其中的每一个EventLoop为一个SubReactor。

Netty通道(Channel)

  • Netty channel(TCP服务器里主流的就是NioSocketChannel)封装的就是Java NIO通道。Java NIO通道对应的就是OS底层的文件描述符(File Descriptor, fd)。
  • 连接监听型socket描述符:接受客户端的socket连接。对应ServerSocketChannel(Java NIO)和NioServerSocketChannel(Netty)
  • 传输数据型socket描述符:负责传输数据,在客户端和服务端都会有一个与一条TCP连接相对应的socket描述符进行数据传输。对应SocketChannel(Java NIO)和NioSocketChannel(Netty)
  • 有接受关系的NioServerSocketChannel和NioSocketChannel可以被称为亲子(Parent - Child)通道

Netty的入站处理和出站处理

  • 通道中发生IO事件 -> 被EventLoop查询到 -> 分发给实现了ChannelInboundHandler处理器,并调用该处理器的对应处理方法(比如channelRead方法,这个事件在pipeline中可以通过pipeline上下文进行冒泡),进行一系列业务处理 -> 如果事件超出管道顶端,则会被抛弃,也可以通过事件冒泡的方式传递到出站处理器,通常由出站处理器生成或者处理出站流量 -> 事件被出站处理器处理 -> 如果IO事件到达了出站管道的底部,则IO事件会交由与该channel相关联的线程进行处理,这个IO线程通常会执行实际的IO操作。

Netty管道(pipeline)

  • Handler和Reactor是多对多的关系,一个Handler可以处理多个Reactor上分发的IO事件,一个Reactor上可以使用多个Handler来处理IO事件。多个Handler可以组成一个pipeline。
  • 一个Netty Channel拥有一条Pipeline。
  • IO事件在Pipeline上进行双向流动,入站时按顺序由实现了ChannelInboundHandler的处理器处理,出站时按逆序由实现了ChannelOutboundHandler的处理器处理。一个Handler可以同时实现这两个接口,这样出入站都会参与处理。

Netty脚手架(Bootstrap)

  • [] MVCC彻底理解清楚
  • [] 分库分表
  • [] MySQL Cluster在CAP中的定位

关系数据库基本概念

3大范式是什么

  • 函数依赖:对于X, Y, 如果不存在关系,使得X上的属性值相等而Y的属性值不等,称Y函数依赖于X。此时X称为决定因素。
  • 完全函数依赖:对于X的任何一个真子集X’,都不满足X’ -> Y,说明Y完全函数依赖与X
  • 部分函数依赖:如果存在X的真子集X’,满足X’->Y,说明Y对X部分函数依赖
  • 如果X->Y, Y不属于X(防止Y->X), Y -> Z, Z不属于Y(防止Z->Y),称Z对X传递函数依赖。
  • 码:如果K -F-> U(完全函数确定),K是R的候选码。
  • 1NF: 关系模式中的每一个分量是不可分的数据项
  • 2NF: 如果某个关系满足1NF,并且任何一个非主属性都完全函数依赖于任何一个候选码,则该关系模式满足2NF
  • 3NF: 如果任何一个非主属性既不部分依赖于码,也不传递依赖于码,说明该关系模式满足3NF
  • BCNF: 如果每一个决定因素都包含码,说明该关系模式满足BCNF

ACID

  • A原子性:一个事务中的操作要么全部完成,要么都不完成。
  • C一致性:数据从一个一致的状态转换为另一个一致的状态,维持数据性约束,数据的完整性不会被破坏。对数据库作出的修改必须符合预定义的规则,不能违反预定义的不变量。(这些“规则”和“不变量”都是与业务强相关的)
  • I隔离性:多个事务的执行不能互相干扰。隔离性可以防止多个事务并发执行时由于交叉执行而导致数据不一致。
  • D持久性:事务做出的更改应该是持久的,即使系统故障也不会丢失。

MyISAM vs InnoDB

  • 是否支持行级锁 : MyISAM 只有表级锁(table-level locking),而InnoDB 支持行级锁(row-level locking)和表级锁,默认为行级锁。
  • 是否支持事务和崩溃后的安全恢复: MyISAM 强调的是性能,每次查询具有原子性,其执行速度比InnoDB类型更快,但是不提供事务支持。但是InnoDB 提供事务支持,外部键等高级数据库功能。 具有事务(commit)、回滚(rollback)和崩溃修复能力(crash recovery capabilities)的事务安全(transaction-safe (ACID compliant))型表。
  • MyISAM不支持外键,InnoDB支持外键
  • 是否支持MVCC :仅 InnoDB 支持。应对高并发事务, MVCC比单纯的加锁更高效;MVCC只在 READ COMMITTED 和 REPEATABLE READ 两个隔离级别下工作;MVCC可以使用 乐观(optimistic)锁 和 悲观(pessimistic)锁来实现;

索引

为什么要用?

  • 创建唯一性索引可以保证每行数据的唯一性
  • 加快数据检索速度
  • 帮助服务器避免排序和临时表(?)
  • 将随机IO变为顺序IO
  • 加速表之间的连接

为什么不为每一个列创建索引

  • 索引需要维护,加入索引会降低对数据更新的速度
  • 索引要占据物理空间

注意事项

  • 在经常需要搜索的列上,可以加快搜索的速度;

  • 在经常使用在WHERE子句中的列上面创建索引,加快条件的判断速度。

  • 在经常需要排序的列上创 建索引,因为索引已经排序,这样查询可以利用索引的排序,加快排序查询时间;

  • 对于中到大型表索引都是非常有效的,但是特大型表的话维护开销会很大,不适合建索引

  • 在经常用在连接的列上,这 些列主要是一些外键,可以加快连接的速度;

  • 避免 where 子句中对字段施加函数,这会造成无法命中索引。

  • 在使用InnoDB时使用与业务无关的自增主键作为主键,即使用逻辑主键,而不要使用业务主键。

  • 单行访问是很慢的。特别是在机械硬盘存储中(SSD的随机I/O要快很多,不过这一点仍然成立)。如果服务器从存储中读取一个数据块只是为了获取其中一行,那么就浪费了很多工作。最好读取的块中能包含尽可能多所需要的行。使用索引可以创建位置引用,以提升效率。

  • 按顺序访问范围数据是很快的,这有两个原因。第一,顺序 I/O 不需要多次磁盘寻道,所以比随机I/O要快很多(特别是对机械硬盘)。第二,如果服务器能够按需要顺序读取数据,那么就不再需要额外的排序操作,并且GROUPBY查询也无须再做排序和将行按组进行聚合计算了。

  • 索引覆盖查询是很快的。如果一个索引包含了查询需要的所有列,那么存储引擎就 不需要再回表查找行。这避免了大量的单行访问,而上面的第1点已经写明单行访 问是很慢的。

BTree索引

  • 数据结构:B+树

B树 vs B+树

  • B树的所有节点既存放key也存放data,但B+树只有叶子节点存放key和data,其它节点只存放key
  • B 树的叶子节点都是独立的;B+树的叶子节点有一条引用链指向与它相邻的叶子节点。
  • B 树的检索的过程相当于对范围内的每个节点的关键字做二分查找,可能还没有到达叶子节点,检索就结束了。而 B+树的检索效率就很稳定了,任何查找都是从根节点到叶子节点的过程。

MyISAM实现

  • 非聚簇索引,在索引数据结构(叶子节点的data域)上存放的是数据记录的地址。在索引检索的时候,首先按照B+Tree搜索算法搜索索引,如果指定的Key存在,则取出其 data 域的值,然后以 data 域的值为地址读取相应的数据记录。非聚集索引的叶子节点并不一定存放数据的指针, 因为二级索引的叶子节点就存放的是主键,根据主键再回表查数据。
  • MyISAM使用前缀压缩以减少索引,可以让更多的索引进入内存以减少磁盘IO的时间。MyISAM压缩索引库的方法是完全保存索引块中的第一个值,后续的值只保存和第一个值相同前缀的字节数和后续的不同部分。这样每个值的压缩前缀都依赖前面的值,所以MyISAM查找时无法在索引块使用二分查找而只能从头开始扫描,这会影响某些操作的性能,例如倒序扫描。
  • 硬要说的话适合UPDATE密集的表

InnoDB实现

  • 聚簇索引,表数据文件本身就是按B+Tree组织的一个索引结构,树的叶节点data域保存了完整的数据记录。这个索引的key是数据表的主键,因此InnoDB表数据文件本身就是主索引。其它的索引都作为辅助索引,辅助索引的data域保存的是主键的值而不是地址。在根据主索引搜索时,直接找到key所在的节点即可取出数据;在根据辅助索引查找时,则需要先取出主键的值,再走一遍主索引。 因此,在设计表的时候,不建议使用过长的字段作为主键,也不建议使用非单调的字段作为主键,这样会造成主索引频繁分裂。
  • MyISAM索引按照行存储的物理位置引用被索引的行,但是InnoDB按照主键值引用行。如果使用DML对表中数据进行操作(插入、删除、修改…),行的存储位置可能会发生变化,如果使用主键值引用行,此时就不需要对索引进行更新。

关于B+树索引的NULL值和 != 条件

  • 记录的主键值不允许存储NULL值,因此在有与主键相关的IS NULL条件下查询优化器会直接优化这部分。(Impossible WHERE)
  • 对于二级索引来说,索引列的值可以为NULL,这些索引记录存放在B+树的最左边。(Define the SQL NULL to be the smallest possible value of a field)
  • 对于IS NULL, IS NOT NULL, != 条件,什么时候能够使用索引?
    • 读取二级索引记录的成本
    • 将二级索引记录执行回表操作,也就是到聚簇索引中找到完整的用户记录的操作所付出的成本。
    • 如果要扫描的二级索引记录条数越多,需要执行的回表操作次数也就越多。当二级索引的执行查询成本超过全表扫描的成本时,不如直接扫描聚簇索引。

哈希索引

  • 数据结构:哈希表
  • 对于哈希索引来说,底层的数据结构就是哈希表,因此在绝大多数需求为单条记录查询的时候,可以选择哈希索引,查询性能最快;其余大部分场景,建议选择BTree索引。哈希索引无法支持范围查询和顺序查询。
  • InnoDB存储引擎和MyISAM存储引擎都无法建立Hash索引,只有Memory/HEAP/NDB存储引擎才能建立哈希索引
    • 然而Memory引擎效率差,没有数据备份恢复机制(只能依赖Server层的binlog,引擎层没有任何机制保障),重启之后数据表中的数据就会丢失

最左前缀原则

MySQL中的索引可以以一定顺序引用多列,这种索引叫作联合索引。而最左前缀原则指的是,如果查询的时候查询条件精确匹配索引的左边连续一列或几列,则此列就可以被用到。

联合索引的数据结构

对于(a, b, c)上的联合索引(只考虑(a, b, c),不考虑自动创建的a, (a, b)等索引),B+树的每个节点都会包括索引中的所有列。排序规则是先按a排序,如果a相等时再按b排序,a和b都相等时再按c排序。

问题:对于(a, b, c)上的联合索引,对于条件where a = 'xx' and c = 'yy',是否可以利用索引?
会利用索引。(why?)
问题:MySQL 8.0还需要遵守最左匹配原则吗?

查询缓存

事务

事务的特性

  • 事务是逻辑上的一组操作,要么都执行,要么都不执行。
  • 原子性(Atomicity): 事务是最小的执行单位,不允许分割。事务的原子性确保动作要么全部完成,要么完全不起作用;
  • 一致性(Consistency): 执行事务后,数据库从一个正确的状态变化到另一个正确的状态;
  • 隔离性(Isolation): 并发访问数据库时,一个用户的事务不被其他事务所干扰,各并发事务之间数据库是独立的;
  • 持久性(Durability): 一个事务被提交之后。它对数据库中数据的改变是持久的,即使数据库发生故障也不应该对其有任何影响。

MySQL如何保证事务的一致性

  • 是目的,AID是手段(存疑)。保证事务的原子性、隔离性和持久性是数据库保证一致性的手段。数据库提供的外键约束、级联操作和触发器也是保证一致性的手段。但是一致性是业务强相关的特性,而在代码中或在事务操作中故意写出违反约束的代码是可能的。
  • MySQL如何保证事务的原子性:利用InnoDB的Undo log。Undo Log记录了回滚数据库事务操作所需要的信息,当事务执行失败或者主动调用ROLLBACK,事务可以利用Undo Log将数据回滚到未作出修改的状态。
  • MySQL如何保证事务的持久性:利用InnoDB的Redo Log。
    • MySQL先把磁盘上的数据加载到内存中,在内存中对数据进行修改,最后再刷回硬盘。MySQL以页为单位与硬盘进行交互,单页的大小为16KB。而单次事务可能对多个页的一小部分数据进行修改,如果每次事务提交都需要将修改的页写回硬盘,性能将会大幅下降。因此MySQL使用Redo log,事务中的操作执行的同时将修改写在redo log,事务提交时将redo log写回硬盘。如果事务提交过程中出现宕机,在数据库恢复时可以恢复redo log的内容,再根据具体情况决定对修改进行回滚或是提交。
  • MySQL如何保证事务的隔离性:利用锁机制和多版本并发控制来实现不同级别的事务隔离性。

并发事务的问题

  • 脏读(Dirty read):事务修改了数据但是还没有将修改提交到数据库中,另一个事务也访问了这个数据,然后使用了这个数据。因为这个数据是还没来得及提交的旧数据,读取的事务读到的是脏数据,根据这样的脏数据做的操作可能是不正确的
  • 丢失修改(Lost to modify):两个事务同时读取一个数据,并同时修改这个数据。第一个事务修改了这个数据后第二个事务也修改了这个数据,导致第一个事务的修改被丢失。
  • 不可重复读(Unrepeatableread):在一个事务内多次读同一数据。在这个事务还没结束时另一个事务也访问这个数据,并且可能对这个数据进行修改。这样,一个事务内的多次读取到的同一数据的值可能不一样,这种情况称为不可重复读
  • 幻读(Phantom read):在一个事务内用相同的条件查询多次数据,在这个事务查询一次数据之后,另一个并发的事务插入或删除了一些数据,因此事务后续用相同的条件进行查询时结果集会发生变化。

事务的隔离级别

  • READ-UNCOMMITTED(读取未提交):最低的隔离级别,允许读取尚未提交的数据变更。可能会导致脏读、幻读、不可重复读。
  • READ-COMMITTED(读取已提交):允许读取并发事务已经提交的数据,可以阻止脏读,但是无法阻止幻读和不可重复读。
  • REPEATABLE-READ(可重复读):默认的隔离级别。一个事务内对同一字段的多次读取结果是一致的,除非数据本身已经被事务自己改变。可以阻止脏读和不可重复读,但是仍然可能发生幻读。
  • SERIALIZABLE(可串行化):最高的隔离级别,分布式事务的情况下一般使用可串行化的隔离级别,完全满足ACID性质,所有的事务依次逐个执行,互不干扰。可以防止脏读、不可重复读和幻读。

MySQL 锁机制

按照锁的粒度分类

  • 表级锁:对整张表加锁,实现简单,消耗资源少,加锁快,不会出现死锁,粒度最大,并发度最低。
  • 行级锁:粒度最小的锁,减少数据库操作的锁冲突,并发度高,但加锁开销大,加锁慢,可能出现死锁。

共享/排他锁

  • S锁:可以是行级也可以是表级
  • X锁:可以实行级也可以是表级
  • IS锁:表级
  • IX锁:表级

其他锁

记录锁(Record locks)

  • 记录锁是施加在索引记录上的锁。(是指主索引吗?)
  • 记录锁永远只会对索引记录施加,即使表没有定义任何索引。在这种情况下,InnoDB会创建一个隐藏的聚簇索引,用该索引实现记录锁。

间隙锁(Gap locks) <- ?

  • 间隙锁是施加在索引记录之间间隙、在第一个索引记录之前和最后一个索引记录之后的锁。例如查询
    SELECT c1 FROM t WHERE c1 BETWEEN 10 and 20 FOR UPDATE会阻止其他事务插入一个t.c1 = 15的行,因为在这个范围内的间隙都已被加锁。(一个间隙指的是一个区间)
  • 间隙锁是性能和并发性之间权衡的一部分,在某些事务隔离级别中使用(目测是为了防止幻读),而在其他级别中则不使用。
  • 当使用唯一索引搜索唯一行时,不需要使用间隙锁。但是使用多列唯一索引的部分列进行搜索时,间隙锁会发生作用。
  • 不同事务可以持有互相冲突的间隙锁。
  • InnoDB中的间隙锁是纯禁止性的,间隙锁的唯一目的就是防止其它事务向间隙中插入数据。

邻键锁(Next-Key locks) <- ?

  • 邻键锁是记录锁和在索引记录之前的间隙锁的结合(a combination of a record lock on the index record and a gap lock on the gap before the index record)。
  • InnoDB是这样实现行级锁的:当搜索或扫描表的索引时,InnoDB在它遇到的索引记录上施加共享锁或排他锁。因此行级锁实际上是索引记录锁。
  • 邻键锁对索引记录加锁,同时对索引记录之前的(一个)间隙加锁。
  • 在REPEATABLE_READ事务隔离级别下,InnoDB使用邻键锁来进行搜索和索引扫描,避免幻读的出现(->因此REPEATABLE_READ隔离级别不存在幻读问题?

插入意向锁(Insert Intension locks)

  • 插入意图锁是一种间隙锁,是由INSERT操作在行插入之前设置的。该锁表明插入数据的意向,多个事务向同一个间隙(区间)插入数据时,如果他们插入的位置不同,则不需要互相等待。

自增锁(AUTO-INC locks)

  • 自增锁是一种特殊的表级锁,当事务需要向包含AUTO_INCREMENT列的表插入数据时自增锁会发挥作用。如果一个事务正在向表中插入数据,另一个需要插入数据的事务必须等待,这样插入表中的行才能有连续的自增主键。
  • 通过更改innodb_autoinc_lock_mode配置项可以选择用于自增锁的算法,更改该选项用户可以自由地在自增值的可预测性和插入操作的并发性之间进行权衡取舍。

大表优化

限定数据范围,禁止不带任何限制范围条件的查询语句

读写分离

主库写、从库读

垂直分区

根据数据库里面数据表的相关性进行拆分。 例如,用户表中既有用户的登录信息又有用户的基本信息,可以将用户表拆分成两个单独的表,甚至放到单独的库做分库。
简单来说垂直拆分是指数据表列的拆分,把一张列比较多的表拆分为多张表。
优点:列数据变小,减少读取的block数,减少IO次数,简化表的结构,易于维护。
缺点:主键冗余,增加join操作,让事务变得更复杂。

水平分区

保持数据表结构不变,通过某种策略存储数据分片。这样每一片数据分散到不同的表或者库中(比如,按时间区间水平分区,按ID水平分区),达到了分布式的目的。 水平拆分可以支撑非常大的数据量。
分表仅仅是解决了单一表数据过大的问题,但由于表的数据还是在同一台机器上,其实对于提升MySQL并发能力没有什么意义,所以 水平拆分最好分库 。
水平拆分能够 支持非常大的数据量存储,应用端改造也少,但 分片事务难以解决 ,跨节点Join性能较差,逻辑复杂。

  • 分片的常见方案:
    • 客户端代理:分片逻辑在应用端,封装在jar包中,通过修改或封装JDBC层来实现。(?)
    • 中间件代理:在应用和数据中间加了一个代理层。分片逻辑统一维护在中间件服务中。(?)

SQL

  • 使用慢查询日志找出较慢的SQL
  • 不做列运算:SELECT id WHERE age + 1 = 10,任何对列的操作都将导致表扫描,它包括数据库教程函数、计算表达式等等,查询时要尽可能将操作移至等号右边
  • 对列进行函数操作可能会导致无法使用索引 -> 函数索引。隐式类型转换也会导致索引失效。
  • 尽量不用SELECT *,而是显式指定需要的列
  • 不用函数和触发器,在应用程序实现(?)
  • 少用JOIN
  • 尽量避免在WHERE中使用!=, <>, 这类条件无法利用索引,而只能使用全表扫描。

索引

  • 频繁的查询优先考虑使用覆盖索引(包含了所查询字段的索引),这样可以必满InnoDB表进行索引的二次查询。
    • 例如,要通过某个建立了二级索引的列A上的条件查询列B的值,如果只使用A上的索引,由于该索引的叶子节点只保存了主键的值,因此此时需要回表,即在主键索引上再次进行查找,才能找到对应行的B的值。如果在A和B上建立一个联合索引,在叶子节点处就会存在B列的值,不需要进行回表。
  • 一个 SQL 只能利用到复合索引中的一列进行范围查询。如:有 a,b,c 列的联合索引,在查询条件中有 a 列的范围查询,则在 b,c 列上的索引将不会被用到。

模糊查询

  • 严禁左模糊(例如,a like '%123'。形如a like '123%',即左侧具体的条件,是可以使用列上的索引的)或者全模糊,有这种需求的时候需要使用搜索引擎。B树索引有最左前缀匹配的特性,最左边的值不确定时无法使用。

外键

  • 对于外键和级联的一种好的方式是在应用层实现。外键与级联更新适用于单机低并发,不适合分布式、高并发集群;级联更新是强阻塞,存在数据库更新风暴的风 险;外键影响数据库的插入速度。并且分库分表的情况下数据库级别的外键也无法生效。
  • 外键也有一定的好处:在DB层面就保证了数据的一致性和完整性,并且由数据库自动完成级联操作也可以减少代码量。
  • 在定义联合索引时,如果 a 列要用到范围查找的话,就要把 a 列放到联合索引的右侧,使用 left join 或 not exists 来优化 not in 操作,因为 not in 也通常会使用索引失效。

池化(数据库连接池)

建立连接是需要消耗时间的,如果有较多的任务陆续提交而不进行任何特殊处理,就需要重复建立连接-关闭连接的过程,浪费时间。因此池化技术的思想就是复用这些创建的连接。(数据库连接的本质可以视为一个socket连接)。

SQL语句的执行过程

sqlProcess

主要组件

  • Server层
    • 连接器: 身份认证和权限相关(登录 MySQL 的时候)。
    • 查询缓存: 执行查询语句的时候,会先查询缓存(MySQL 8.0 版本后移除,因为这个功能不太实用)。执行查询语句的时候,会先查询缓存,MySQL 会先校验这个 sql 是否执行过,以 Key-Value 的形式缓存在内存中,Key 是查询预计,Value 是结果集。如果缓存 key 被命中,就会直接返回给客户端,如果没有命中,就会执行后续的操作,完成后也会把结果缓存起来,方便下一次调用。
    • 分析器: 没有命中缓存的话,SQL 语句就会经过分析器,分析器说白了就是要先看你的 SQL 语句要干嘛,再检查你的 SQL 语句语法是否正确。词法分析提取关键字,语法分析校验SQL语法是否正确。
    • 优化器: 按照 MySQL 认为最优的方案,生成查询计划去执行。 比如决定如何选择索引。
    • 执行器: 执行语句,然后从存储引擎返回数据。执行器在执行前会首先检查用户有没有权限。
  • 存储引擎:InnoDB包括Redolog模块

更新语句的执行流程(Server层和InnoDB存储引擎的交互)

执行更新语句时要记录日志。MySQL使用binlog进行日志记录,InnoDB还自带一个日志模块redo log。

  1. 拿到待修改的数据,进行修改,然后调用引擎API接口写入修改后的数据。InnoDB会将数据保存在内存中,同时记录Redo log(写不写硬盘?),此时redo log进入prepare状态,然后告诉执行器执行完成,随时可以提交。
  2. 执行器收到通知后记录binlog(写不写硬盘?),然后调用引擎接口,提交redo log为提交状态(写不写硬盘?)。
  3. 更新完成。(怎么记录undo log?)
  • 为什么要用两个日志模块?

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

    • 先写 redo log 直接提交,然后写 binlog,假设写完 redo log 后,机器挂了,binlog 日志没有被写入,那么机器重启后,这台机器会通过 redo log 恢复数据,但是这个时候 bingog 并没有记录该数据,后续进行机器备份的时候,就会丢失这一条数据,同时主从同步也会丢失这一条数据。
    • 先写 binlog,然后写 redo log,假设写完了 binlog,机器异常重启了,由于没有 redo log,本机是无法恢复这一条记录的,但是 binlog 又有记录,那么和上面同样的道理,就会产生数据不一致的情况。(直接用binlog恢复可以吗?)
      如果采用 redo log 两阶段提交的方式就不一样了,写完 binglog 后,然后再提交 redo log 就会防止出现上述的问题,从而保证了数据的一致性。那么问题来了,有没有一个极端的情况呢?假设 redo log 处于预提交状态,binglog 也已经写完了,这个时候发生了异常重启会怎么样呢? 这个就要依赖于 MySQL 的处理机制了,MySQL 的处理过程如下:
    • 判断 redo log 是否完整,如果判断是完整的,就立即提交。(什么叫是否完整?意思是是否已经是commit状态?)
    • 如果 redo log 只是预提交但不是 commit 状态,这个时候就会去判断 binlog 是否完整(什么叫是否完整?),如果完整就提交 redo log, 不完整就回滚事务。
      这样就解决了数据一致性的问题。

MySQL日志

  • binlog:二进制日志
  • redo log、undo log:事务日志(?)
  • redo log:物理日志,记录数据页的物理修改,而不是对某一行的具体修改
  • undo log:逻辑日志(?)
  • 慢查询日志

binlog

binlog 用于记录数据库执行的写入性操作(不包括查询)信息,以二进制的形式保存在磁盘中。 binlog 是 mysql的逻辑日志,并且由 Server 层进行记录,使用任何存储引擎的 mysql 数据库都会记录 binlog 日志。

  • 包含引起或可能引起数据库改变的事件信息。
  • 对于事务操作,二进制日志只在事务提交的时候一次性写入。提交前的每个二进制日志记录都会先写到内存,事务提交时写入硬盘。
  • 是Server层产生的,不管什么存储引擎对数据库进行了修改都会产生binlog
  • 逻辑日志: 逻辑性的语句。基于语句时直接记录SQL语句,基于行时记录行数据的修改情况。
  • 物理日志: mysql 数据最终是保存在数据页中的,物理日志记录的就是数据页变更。

使用场景

  • 主从复制 :在 Master 端开启 binlog ,然后将 binlog 发送到各个 Slave 端, Slave 端重放 binlog 从而达到主从数据一致。
  • 数据恢复 :通过使用 mysqlbinlog 工具来恢复数据。

刷盘时机

对于 InnoDB 存储引擎而言,只有在事务提交时才会记录 binlog ,此时记录还在内存中。mysql 通过 sync_binlog 参数控制 biglog 的刷盘(调用fsync)时机(影响的是如果操作系统崩溃,是否会丢失修改。如果单纯数据库服务器崩溃,似乎操作系统缓冲区的内容还是能够刷入磁盘),取值范围是 0-N:

  • 0:不去强制要求,由系统自行判断何时写入磁盘;
  • 1(默认):每次 commit 的时候都要将 binlog 写入磁盘;
  • N:每N个事务,才会将 binlog 写入磁盘。

redo log

事务的持久性:只要提交成功,对数据库的修改就要永久保存。
内存与硬盘数据的一致性如何保证?最简单的方法是每次事务提交的时候就把涉及到的数据页全部刷写到磁盘中。可能每次事务只会改一个页里的几个字节,却要把整个页完整地刷新到磁盘(InnoDB以页为单位与磁盘交互),而且一个事务可能涉及多个数据页,这样性能太差。

  • redo log:记录事务对数据页做了哪些修改。
    redo log 包括两部分:一个是内存中的日志缓冲(redo log buffer),另一个是磁盘上的日志文件(redo logfile)。 mysql 每执行一条 DML 语句,先将记录写入redo log buffer,后续某个时间点再一次性将多个操作记录写到 redo log file 。这种 先写日志,再写磁盘 的技术就是 MySQL里经常说到的 WAL(Write-Ahead Logging) 技术。
    • redo log buffer是易失性的。磁盘上的redo log file是持久的。
    • log file其实指的是内存中内核空间的操作系统缓冲区(OS Buffer)中的redo log。InnoDB会以一定的时间间隔(例如每个事务提交时)调用fsync将OS Buffer中的redo log内容刷到硬盘上的真正的redo log文件。
    • 可以通过调整innodb_flush_log_at_trx_commit的参数值来调整调用fsync的时机。
      • 1:每次提交都写入os buffer并fsync。主从复制时必须设置为1.
      • 0:每秒将log buffer写入os buffer并调用fsync。
      • 2:每次提交都写入os buffer,但每秒调用一次fsync。
        RedoLogFile
  • redo log记录数据页的变更(物理日志?)redo log采用大小固定、循环写入的方式,到达结尾时会回到开头循环写日志。日志上的记录在数据落盘后会被覆盖掉。
    • redo log group:一组redo log需要由几个redo log file构成。
    • 第一个redo log file写完后会开始写第二个。第二个写完后会开始覆盖第一个redo log file的内容。
  • redolog会在数据准备修改前先写入缓存中的redolog,然后再对缓存中的数据执行实际操作。

redo log的内容

  • redo_log_type:redo log的日志类型
  • space:表空间的ID(?)
  • page_no:页的偏移量(??)
  • redo_log_body:数据部分

redo log和binlog的区别

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

BinlogVSRedolog

undo log

数据库事务四大特性中有一个是 原子性 ,具体来说就是 原子性是指对数据库的一系列操作,要么全部成功,要么全部失败,不可能出现部分成功的情况。实际上, 原子性 底层就是通过 undo log 实现的。 undo log 主要记录了数据的逻辑变化,比如一条INSERT语句,对应一条 DELETE 的 undo log ,对于每个UPDATE 语句,对应一条相反的 UPDATE 的undo log ,这样在发生错误时,就能回滚到事务之前的数据状态。同时, undo log 也是 MVCC(多版本并发控制)实现的关键。

慢查询日志

  • 执行时间、执行用户、查询用时、具体的查询语句
  • 如何分析?使用explain语句

MVCC(多版本并发控制)

  • 为什么要引入MVCC?:只让写写互相阻塞,其它操作可以并行
  • MySQL的大多数事务型存储引擎实现的其实都不是简单的行级锁。基于提升并发性能的考虑, 它们一般都同时实现了多版本并发控制(MVCC)。可以认为MVCC是行级锁的一个变种, 但是它在很多情况下避免了加锁操作, 因此开销更低。虽然实现机制有所不同, 但大都实现了非阻塞的读操作,写操作也只锁定必要的行。MVCC的实现方式有多种, 典型的有乐观(optimistic)并发控制 和 悲观(pessimistic)并发控制。
  • 只在READ COMMITTED和READ REPEATABLE隔离级别下工作。因为 READ UNCOMMITTED 总是读取最新的数据行, 而不是符合当前事务版本的数据行。而 SERIALIZABLE 则会对所有读取的行都加锁。

MVCC在InnoDB中的实现

在InnoDB中,会在每行数据后添加3个额外的隐藏的值来实现MVCC:

  • DB_TRX_ID: 插入或更新该行的最后一个事务的标识符。删除也算一种更新,有特殊的标志位说明该行已经被删除。
  • DB_ROLL_PTR: (Roll Pointer)指向回滚段中的undo log记录。如果该行被更新,使用undo log可以重构出该行更新前的数据。
    • undo log记录的是旧版本的数据,其它事务读取数据的时候,根据DB_TRX_ID和DB_ROLL_PTR从undo log链中找到符合可见性要求的数据。undo log中的数据通过DB_ROLL_PTR和主数据关联。
  • DB_ROW_ID: 行标识,是一个随着新行插入单调自增的行ID。如果InnoDB自动生成了聚簇索引(用户没有指定主键),这个聚簇索引的索引列就是这个单调自增行ID。否则这一列不会出现在任何索引中。

一致性读和加锁读

  • Consistent Nonlocking reads:Consistent read是一种读操作,使用基于某个时间点的快照信息来展示查询结果,而忽略同时运行的其他事务做出的修改。对于查询而言,在快照的时间点之前已经提交的事务所做出的更改是可见的,而在该时间点之后做出的更改或未提交的事务所作出的更改是不可见的(例外情况:同一事务中之前的语句做出的修改是可见的)。
    • 是REPEATABLE_READ和READ_COMMITTED隔离级别下InnoDB处理SELECT语句的默认模式。
    • 不会对行或表加锁,同时会忽略所有在读视图记录上加的任何锁。
  • Locking Read: 如果想要确保读到最新数据,需要使用FOR UPDATE、FOR SHARE加入互斥锁或共享锁,或者使用READ_UNCOMMITTED隔离级别。

快照的本质

  • 每个事务启动的瞬间(还是创建快照时?)都会记录当前所有“活跃事务”(已经启动但还没有提交)的事务标识符(ID)。
  • 最小的活跃事务ID为低水位
  • 所有已经出现过的最大事务ID + 1为高水位(即下一个开启的事务会被分配的ID)
  • 可见性判断:用当前数据的DB_TRX_ID和数组中的事务ID进行比较。低水位以前的数据版本可见(肯定已经提交了),大于等于高水位的数据版本不可见(还没开始)。如果处于高水位和低水位之间,需要看当前数据版本的DB_TRX是否存在于高低水位之间,如果存在,则说明事务仍未提交,该版本对当前事务不可见,否则说明事务已经提交,该版本对当前事务可见。
  • 寻找可见的数据版本:如果发现当前版本的数据不可见,则根据隐藏列DB_ROLL_PTR在undo log中找到旧版本的版本号,重新判断,知道找到可见的版本,读出该版本的数据。

快照的创建时机

REPEATABLE_READ可重复读

  • 对于可重复读的事务隔离级别,快照会基于事务的第一次读操作进行构建,同一事务中的所有consistent Read操作读出的内容都是(基于)这一快照的。如果数据已经被其他事务修改,原始数据会通过undo log的内容进行重构。在该隔离级别下,每个在同一事务中的普通的(不使用锁的)SELECT语句都是一致的。

READ_COMMITTED读已提交

  • 对于每次的Consistent Read操作,即使他们都在同一个事务中,他们都会对快照进行更新并读取最新快照。

START TRANSACTION WITH CONSISTENT SNAPSHOT

  • 立即启动事务并创建一致性读快照,相当于马上执行一个普通的SELECT语句。

Undo Log

  1. INSERT UNDO LOG:对插入数据产生的UNDO LOG,只有在事务回滚的时候才需要,事务提交之后就可以丢弃。
  2. UPDATE UNDO LOG: 对修改和删除数据产生的UNDO LOG,快照读也是需要的。只有当数据库所使用的快照不涉及该日志记录(低于低水位?)时对应的回滚日志才会被删除。
  • purge线程:需要删除时只是先设置旧记录的标志位,InnoDB使用专门的purge线程来清理这些记录。

加锁读

读已提交

  • 对于locking read操作、UPDATE语句、DELETE语句,InnoDB只会对索引记录加锁,而不会对他们之前的gap加锁,因此允许在这些被锁住的记录旁边插入新记录。Gap锁只会被用于外键约束检查和重复键检查。
  • 个人理解:已提交读不会出现脏读,而可能出现幻读(允许插入数据)和不可重复读(要对快照进行更新并读取最新快照)。

可重复读

  • 对于locking reads(使用FOR UPDATE或FOR SHARE的SELECT),如何加锁取决于该SELECT语句是在唯一索引(unique index)上使用了唯一的搜索条件,还是使用了范围型的搜索条件。
    • 对于在使用唯一索引和唯一的搜索条件的情况,InnoDB只会对结果集中的记录的索引进行加锁,而不会对其之前的gap(其它能够插入数据的位置)进行加锁。(此时结果集中的记录的索引已经被加锁,不会被删除也不会被修改)。
    • 对于其他搜索条件,InnoDB会对所有扫描的索引范围加锁,使用gap locks或next-key locks对其它试图在范围中的gap进行数据插入的操作进行阻塞。(个人理解:结果集中的行的间隙(gap)也会加锁,覆盖到的范围都会加锁,不让其它session在可以插入数据的位置进行插入)
  • 个人理解:可重复读隔离级别解决了脏读、不可重复读问题,交替使用快照读和加锁读会出现幻读。

高级主题

MySQL 主从模式

  • 主从模式的好处
    • 一定程度上解决了单点故障问题
    • 可以实现读写分离,降低单台服务器的负载
  • 主从复制的策略
    • 同步复制:等到所有Slave的回应才会提交并返回结果
    • 半同步复制:等到至少一个Slave的回应就返回
    • 异步复制:Master不等Slave回应就返回结果
    • 延迟策略?

ORM框架

  • 将Java Bean转换成行记录
  • 将行记录转换为Java Bean

Hibernate

  • 通过Entity标注实体类,可用@Table(name="...")来标识对应的表名。通过@Column(...)建立Java Bean字段和数据库表的列的对应关系
  • 实体类之间可以有继承关系: 父类使用@MappedSuperClass标识
  • 提供自动生成的插入/删除/修改(update)/查询(findByExample/findByCriteria/HQL,使用类名和属性名,由Hibernate转换成实际的表名和列名)
  • ORM如何将对Java Bean的修改反应到数据库中(调用update时如何确定哪些字段更新了)
    • 使用代理模式,从ORM读出的对象是代理对象,原对象的每个属性都附加一个标志变量说明这个对象是否被修改。

MyBatis

  • 半自动ORM:可以完成Java Bean到数据行和数据行到Java Bean的双向转换,但是SQL需要用户自行编写。
  • 属性名与列名不一致?:添加ResultMap(注解或XML)

  • SpringMVC原理

Spring框架

  • 多模块的集合
    • 核心容器(IOC)
    • 数据访问
    • Web
    • AOP

常用注解

@Component和@Bean

  • @Component作用于类,使用该注解的类通常会通过classpath扫描来自动侦测并被装配到Spring容器中。可以通过@ComponentScan来定义要扫描的路径,以及定义排除规则。
  • @Bean作用于方法,说明标有该注解的方法中会产生一个Bean。使用第三方库的时候,如果需要将第三方库的类装配到Spring容器中,可以定义一个产生该类的方法并附加@Bean注解。同时,使用@Bean可以实现根据不同条件进行不同的装配操作(在方法中编写条件代码),比@Component相对灵活。

如何将一个类声明为Spring的Bean

  • @Component: 通用
  • @Repository: 对应持久层(DAO)
  • @Service: 对应服务层
  • @Controller: 对应控制层
  • @Configuration: 配置类
  • 名称:@....(value = "...")

@Controller, @RestController

  • 都是表明某类是一个Controller的注解。Dispatcher会扫描使用了这两个注解的类的方法,并检查该方法是否使用了@RequestMapping或@GetMapping一类的注解。
  • @Controller一般用于返回一个视图(一个页面,可以是使用模板引擎进行渲染的页面)。如果要返回JSON或XML格式的数据,需要搭配使用@ResponseBody注解。如果处理器方法被注解@ResponseBody的话,该方法的返回类型会通过适当的Converter转换后写到HttpServletResponse中,而不是被当成视图处理。
  • @RestController,是@Controller和@ResponseBody的结合,只返回对象,对象数据直接以JSON或XML形式写入HTTP响应中。

属性注入:@Autowired @Qualifier @Resource

  • @AutoWired:根据类型进行自动装配
  • @Qualifier: 根据属性名称进行注入,需要和@Autowired配合使用。@Qualifier(value = "...")
  • @Resource: 可以根据类型注入,也可以根据名称注入
  • @Value:注入普通类型属性, @Value("classpath:/...")…

前后端传值

  • @PathVariable: 路径参数
  • @RequestParam: 查询参数(Query String)
  • @RequestBody: 读取Request请求。Content-Type为application/json格式时,接收到数据之后会自动将数据绑定到Java对象上去。系统使用HttpMessageConverter或自定义的Converter将body中的json字符串转换为Java对象。

读取配置信息

  • @PropertySource("app.properties") + @Value("${app.property:defaultValue}")
  • @ConfigurationProperties(prefix = "..."): 读取配置信息并与Bean绑定

Json数据处理

  • @JsonIgnoreProperties({"字段名"}): 过滤掉特定字段不返回或者不解析
  • @JsonIgnore: 同上,不过直接作用于类的属性上
  • @JsonFormat: 格式化JSON数据
  • @JsonUnwrapped: 扁平化对象

IOC

怎么理解IOC

IoC(Inverse of Control:控制反转)是一种设计思想,就是 将原本在程序中手动创建对象的控制权,交由Spring框架来管理。IoC 容器是 Spring 用来实现 IoC 的载体, IoC 容器实际上就是个Map(key,value),Map 中存放的是各种对象。这样可以很大程度上简化应用的开发,把应用从复杂的依赖关系中解放出来。 IoC 容器就像是一个工厂一样,当我们需要创建一个对象的时候,只需要配置好配置文件/注解即可,完全不用考虑对象是如何被创建出来的。

  • 传统的new关键字进行手动装配存在一定困难
    • 实例化组件的过程是复杂的,某些组件需要读取配置才能完成实例化
    • 组件之间存在依赖关系,但多个组件依赖一个组件时,很多时候只需要依赖组件的单个实例,没有必要多次实例化。而实现这一点需要理清组件之间的依赖关系,以确定恰当的实例化顺序,这对于手动装配而言是麻烦的
    • 有时候组件需要进行销毁以释放资源,但销毁组件时需要确定所有依赖该组件的其他组件也已经被销毁
  • 因此IOC容器的职责就是
    • 负责创建组件
    • 负责根据依赖关系组装组件
    • 按照依赖顺序正确地销毁组件
  • Spring IOC的无侵入性:组件无需要实现框架特定的接口。因此编写完成的组件可以自动装配也可以手动装配。并且测试组件的时候可以单独进行测试。
    资源(对象)不由使用和提供资源的双方管理,而由第三方管理,集中式的管理容易配置,容易管理,也降低了资源提供方和使用方的耦合度。使得通过配置文件而不是在代码里硬编码(hardcode)的方式来实例化对象和装配对象图。
  • 本质上,IOC容器是个Map。IOC容器可以这样完成自动装配
    • BeanFactory或ApplicationContext通过XML配置文件或classpath扫描获得所有的附加了@Bean的方法或者附加了@Component的注解,获得所有Bean的类名。通过对类名使用Class.forName可以完成类加载
    • BeanFactory:加载配置时不创建对象,第一次通过getBean获取Bean时才会创建对象。ApplicationContext:加载配置时就创建对象

Spring Bean管理

Bean的作用域

Spring IOC容器原本支持的作用域只有singleton和prototpye。其它作用域是后续框架的增强。

  • singleton:默认是单例的
  • prototype:每次获取bean时都会创建一个新的bean实例
  • request:每一次HTTP请求都会生成一个新的bean,仅在当前HTTP request内有效
  • session:每一次HTTP请求都会生成一个新的bean,仅在当前HTTP session内有效

单例bean的线程安全问题

  • 多个线程操作同一个单例对象时对这个对象的成员变量的写操作存在线程安全问题。
  • 一般情况下,Controller、Service、Dao等Bean都应该是无状态的,不保存数据的Bean即使被多线程使用也是安全的。
  • 如果需要成员变量,可以将成员变量保存在ThreadLocal中。或者将Bean的作用域改为prototype

Bean的生命周期

  • 实例化:实例化一个Bean对象
  • 属性赋值:为Bean设置相关属性。如果该Bean依赖其它Bean,则获取对其它bean的引用
  • 初始化:可能的Aware相关接口,BeanPostProcessor的前后置处理,是否实现InitializingBean接口,自定义的init-method(@PostConstruct)(如果Bean有定义初始化方法,则调用这些初始化方法)
  • 销毁:自定义的destroy-method(容器关闭的时候,如果bean调用了销毁方法,则调用销毁方法@PreDestroy)

常用扩展点(生命周期钩子?)

影响多个Bean的接口:后置处理器
  • 实现了这些接口的Bean会切入到多个Bean的生命周期中。
  • BeanPostProcessor:初始化阶段的前后,Bean实例会被传入这个后置处理器的方法。同时会返回一个Bean。String AOP可以在此处完成对Bean的增强,返回代理对象
    • postProcessBeforeInitialization
    • postProcessAfterInitialization
  • InstantiationAwareBeanPostProcessor:实例化阶段的前后
只调用一次的接口

Spring中的设计模式

  • 工厂模式:通过BeanFactory、ApplicationContext创建对象
  • 代理模式:Spring AOP
  • 单例模式:Spring中的单例Bean

SpringMVC

为什么要使用MVC设计模式

  • 将数据模型定义、控制逻辑和表现逻辑分离,降低各个模块之间的耦合度。
  • 视图:呈现模型,展示数据,直接与用户交互
  • 控制器:取得用户的输入,解读其对模型的操作,据此改变视图的状态、改变视图的显示
  • 模型:模型封装持有的数据、状态和业务逻辑,不需要对控制器和视图的存在进行感知,只提供操作和检索状态的接口,并发送状态改变通知给观察者。

MVC vs MVVM

  • MVC: M封装数据和逻辑,V提供展示,C进行控制:V向C请求修改Model,C帮V调用Model的操作,得到Model的反馈后用新的数据更新V。
  • MVVM:VM完成V与M的双向绑定。直接对V上的数据进行修改,VM会自动将对V的修改同步到M上。反之对M进行修改,VM会自动将对M的修改同步到V上,表现为用户界面的变化。

工作原理

  • 客户端发送请求,通过DispatcherServlet接收请求。
  • DispatcherServlet根据请求信息,从HandlerMapping查找对应的HandlerAdapter。
  • 与HandlerAdapter相关的Handler调用真正的处理器来处理请求,完成相应的业务逻辑。
    • 为什么要区分Handler和HandlerAdapter?
    • HandlerAdapter相当于一个代理。
    • 我们编写的Controller希望能够将路径参数、URL参数和请求体都作为方法的参数传入。因此HandlerAdapter可以将HttpServletRequest中的URL参数、请求体等进行解析,然后使用反射机制调用Handler的方法,实现这一目的。
  • 处理完请求后,如果是RESTful接口,则将返回数据通过适当的转换器转换后写到HttpServletResponse中。
  • 如果不是一个RESTful接口,处理完业务后会返回一个ModelAndView对象。

Spring 三级缓存

  • 一级缓存:singletonObjects:单例池,存放已经经历了完整生命周期的Bean(已经初始化好的Bean)
  • 二级缓存:earlySingletonObjects:存放早期暴露出来的Bean对象,生命周期未结束(未完成初始化,属性未填充完)
  • 三级缓存:singletonFactories:存放可以生成Bean的工厂
    springCache
  • 四大方法:
    • getSingleton
      • 一级缓存没有,并且当前对象并不是创建过程中(isSingletonCurrentlyInCreation返回false)就去二级缓存找
      • 二级缓存没有,就去三级缓存找
      • 三级缓存有的话(能够获得对应的FactoryBean),就通过factory创建一个原始Bean,放到二级缓存,并删除三级缓存中的factory
    • doCreateBean
    • populateBean
    • earlySingletonObjects
  • Spring创建Bean的过程:创建原始的bean对象,然后填充对象属性进行初始化
  • 每次创建单例Bean之前先在一级缓存中检查是否已经创建过

循环依赖问题

问题

  • 多个Bean之间互相依赖,形成闭环。默认B的单例Bean中,属性互相引用的场景。
  • 构造方法注入,setter方法注入
  • 构造方法注入可能导致循环依赖问题。并且坚持使用构造方法注入是无法解决的。
    • 注:一般情况下推荐使用构造注入
    • 使用基于setter方法的注入代替构造方法注入
    • 只要注入方式是setter方法注入,且Bean为单例Bean(@Component),则能够解决循环依赖问题
    • 原型(prototpye, @Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE), @Scope("prototype"))的场景无法支持循环依赖,会报错BeanCurrentlyInCreationException。

解决

  • Spring内部通过三级缓存解决循环依赖:DefaultSingletonBeanRegistry
  • 单例Bean:三级缓存提前暴露,依靠Bean的“中间态”概念,中间态指的是已经实例化但没有初始化的状态
  • 非单例Bean:每次都要获取一个新的对象,要重新创建,没有缓存
  • 创建单例Bean A的原始对象后(未填充属性),将其放入三级缓存,然后开始填充对象属性。此时发现Bean A需要依赖Bean B,顺序查找三级缓存未能找到Bean B,于是进行BeanB的创建。经过相似的过程后会发现创建Bean B需要Bean A
  • 此时Bean B在第三级缓存中查找到Bean A的原始对象,直接将原始对象注入Bean B(提前暴露Bean A),完成对Bean B的创建。
  • Bean B创建完毕后Bean A的创建流程即可继续,完成属性填充的工作。
    SpringCircularDependency

为什么一定要用三级缓存?

  • 维持Spring Bean的生命周期

AOP

怎么理解

能够将那些与业务无关,却为业务模块所共同调用的逻辑或责任(例如事务处理、日志管理、权限控制、统一的错误处理等)封装起来,便于减少系统的重复代码,降低模块间的耦合度,并有利于未来的可拓展性和可维护性。

原理

Spring AOP基于动态代理。如果代理对象实现了某个接口,使用JDK动态代理。否则使用CGLIB生产一个被代理对象的子类作为代理。
SpringAOPProcess

Spring AOP和AspectJ AOP的区别

  • Spring AOP无论是使用JDK提供的Proxy还是CGLIB,本质都是动态代理,在运行时对类进行增强。而AspectJ AOP其实使用了静态代理的方法,在编译字节码时在方法的周围加上业务逻辑,是编译期通过操作字节码完成静态织入。
  • AspectJ的功能更强大,同时性能更好,因为对方法的增强在编译期就已经完成了。

常用注解

  • @Before
  • @After
  • @AfterReturning
  • @AfterThrowing
  • @Around

AOP的执行顺序

  • 正常执行:环绕Before -> Before -> AfterReturn -> After -> 环绕After
  • 异常流程:环绕Before -> Before -> AfterThrowing -> After

Spring事务

  • 编程式事务:TransactionTemplate,TransactionManager
  • 声明式事务:基于XML、基于注解
    • 实现原理:动态代理

Spring事务的隔离级别(isolation)

  • TransactionDefinition.ISOLATION_DEFAULT: 使用后端数据库默认的隔离级别,Mysql 默认采用的 REPEATABLE_READ隔离级别 Oracle 默认采用的 READ_COMMITTED隔离级别.
  • TransactionDefinition.ISOLATION_READ_UNCOMMITTED: 最低的隔离级别,允许读取尚未提交的数据变更,可能会导致脏读、幻读或不可重复读
  • TransactionDefinition.ISOLATION_READ_COMMITTED: 允许读取并发事务已经提交的数据,可以阻止脏读,但是幻读或不可重复读仍有可能发生
  • TransactionDefinition.ISOLATION_REPEATABLE_READ: 对同一字段的多次读取结果都是一致的,除非数据是被本身事务自己所修改,可以阻止脏读和不可重复读,但幻读仍有可能发生。
  • TransactionDefinition.ISOLATION_SERIALIZABLE: 最高的隔离级别,完全服从ACID的隔离级别。所有的事务依次逐个执行,这样事务之间就完全不可能产生干扰,也就是说,该级别可以防止脏读、不可重复读以及幻读。但是这将严重影响程序的性能。通常情况下也不会用到该级别。

Spring事务的传播(propagation)

加入当前事务

  • TransactionDefinition.PROPAGATION_REQUIRED: 如果当前存在事务,则加入该事务;如果当前没有事务,则创建一个新的事务。
  • TransactionDefinition.PROPAGATION_SUPPORTS: 如果当前存在事务,则加入该事务;如果当前没有事务,则以非事务的方式继续运行。
  • TransactionDefinition.PROPAGATION_MANDATORY: 如果当前存在事务,则加入该事务;如果当前没有事务,则抛出异常。(mandatory:强制性)

挂起当前事务

  • TransactionDefinition.PROPAGATION_REQUIRES_NEW: 创建一个新的事务,如果当前存在事务,则把当前事务挂起。
  • TransactionDefinition.PROPAGATION_NOT_SUPPORTED: 以非事务方式运行,如果当前存在事务,则把当前事务挂起。
  • TransactionDefinition.PROPAGATION_NEVER: 以非事务方式运行,如果当前存在事务,则抛出异常。

其他情况

  • TransactionDefinition.PROPAGATION_NESTED: 如果当前存在事务,则创建一个事务作为当前事务的嵌套事务来运行;如果当前没有事务,则该取值等价于TransactionDefinition.PROPAGATION_REQUIRED。

@Transcational(rollbackFor = Exception.class)

  • 让Spring事务遇到非运行时异常时也回滚。

事务超时属性(timeout)

  • 在TransactionDefinition中以int类型timeout表示超时时间

事务只读属性(readOnly)

自调用问题

若同一类中的其他没有 @Transactional 注解的方法内部调用有 @Transactional 注解的方法,有@Transactional 注解的方法的事务会失效。
因为Spring AOP的实现原因,当含有@Transactional注解的方法在类以外被调用的时候,Spring事务管理才生效。

TODOS:

  • [] SpringBoot的启动流程

SpringBoot帮我们干了什么

依赖管理

  • 父项目完成依赖管理(spring-boot-starter-parent)。父项目几乎声明了常用的所有依赖的版本号
  • 开发时导入starter场景启动器:对于一个场景,引入starter依赖会引入该场景相关的所有常规依赖
    • 所有场景启动器都依赖spring-boot-starter
  • 无需关注版本号,完成自动版本仲裁
    • 查看spring-boot-dependencies中的依赖标签名。也可以在项目中的<properties>标签中自定义依赖的版本号

自动配置

  • 对于一个场景中的各种依赖会提供默认配置,开箱即用(例如web.xml中的各种设置,例如字符编码…etc)
  • 默认的包结构(包扫描的目录:主程序所在的包及其子包中的所有类都会被扫描
  • 各种配置拥有默认值(默认端口,默认值…),配置类持有这些默认值,而在SpringBoot类的项目配置文件中(application.properties/application.yml)进行设置可以将这些值绑定到对应的配置类上,配置类会被加载到容器中发挥作用。
  • 按需加载自动配置项:引入的时候自动配置才会开启
    • 所有的自动配置功能都在spring-boot-autoconfigure中

底层注解

@SpringBootApplication

默认加在主类上,是@SpringBootConfiguration(其实就是@Configuration, 允许在Spring上下文中注册额外的Bean或导入其它配置类)、@EnableAutoConfiguration(启动Springboot自动配置机制)、@ComponentScan(扫描Bean,扫描该类所在包下所有的类)的集合。

@Configuration

  • 声明配置类,作用如beans.xml
  • 创建Bean:
    • 在类中创建方法并附加@Bean注解,方法名就是组件ID,返回类型就是组件类型,返回值就是组件实例。默认为单例Bean
  • 默认proxyBeanMethods = true:代理Bean的方法。可以说明@Configuration中的方法是被代理的。(重复直接调用单例Bean的创建方法不会返回不同对象,说明方法已经被代理)
    • Full配置与Lite配置:Full模式中配置类的proxyBeanMethods = true,Lite模式中为false

@Import(Class<?>[])

  • 向容器中导入指定的类的实例

@Conditional

  • 满足条件时才进行装配

@ImportResource

  • 从classpath中搜索配置文件,注入配置文件中描述的Bean

@ConfigurationProperties

  • 从配置文件中读取配置,绑定到容器中的Bean的属性上
  • @EnableConfigurationProperties:附加在配置类上,开启指定类的属性配置功能,同时把这个类的对象加入容器中。主要在为第三方包的类进行配置绑定时使用。(因为没办法在第三方的类加注解)

自动装配原理

核心注解@EnableAutoConfiguration

@AutoConfigurationPackage

AutoConfigurationPackage

@Import({AutoConfigurationImportSelector.class})

  • 其中包含注解@Import({AutoConfigurationImportSelector.class})。
  • AutoConfigurationImportSelector 类实现了 ImportSelector接口,也就实现了这个接口中的 selectImports方法,该方法主要用于获取所有符合条件的类的全限定类名,这些类需要被加载到 IoC 容器中
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    private static final AutoConfigurationEntry EMPTY_ENTRY = new AutoConfigurationEntry();

    AutoConfigurationEntry getAutoConfigurationEntry(AutoConfigurationMetadata autoConfigurationMetadata, AnnotationMetadata annotationMetadata) {
    //<1>.
    if (!this.isEnabled(annotationMetadata)) {
    return EMPTY_ENTRY;
    } else {
    //<2>.
    AnnotationAttributes attributes = this.getAttributes(annotationMetadata);
    //<3>.
    List<String> configurations = this.getCandidateConfigurations(annotationMetadata, attributes);
    //<4>.
    configurations = this.removeDuplicates(configurations);
    Set<String> exclusions = this.getExclusions(annotationMetadata, attributes);
    this.checkExcludedClasses(configurations, exclusions);
    configurations.removeAll(exclusions);
    configurations = this.filter(configurations, autoConfigurationMetadata);
    this.fireAutoConfigurationImportEvents(configurations, exclusions);
    return new AutoConfigurationImportSelector.AutoConfigurationEntry(configurations, exclusions);
    }
    }
  1. 判断自动装配开关是否打开。默认spring.boot.enableautoconfiguration=true,可在 application.properties 或 application.yml 中设置
  2. 用于获取EnableAutoConfiguration注解中的 exclude 和 excludeName。为排除
  3. 获取需要自动装配的所有配置类,读取META-INF/spring.factories(所有引入的starter依赖中都需要定义/META-INF/spring.factories,才能被SpringBoot的自动装配机制捕捉到),查找出所有的XXXAutoConfiguration。
  4. 对自动配置器进行筛选,在XXXAutoConfiguration类中的@ConditionalOnClass中,规定只有相关的类存在的时候(也就是classpath下有指定类的时候),才会将加载器实例化并注入IOC容器中,进而完成下一步的自动装配。

手写Starter

  1. 命名最好是XXX-spring-boot-starter,而不是spring-boot-starter-XXX,后者是SpringBoot官方的Starter的命名方式
  2. 引入依赖:spring-boot-starter-parent, spring-boot-starter
  3. 创建自动配置器:带有@Configuration注解的类,这样其中的方法可以向IOC容器注入Bean。需要为类或者方法附加@ConditionalOnClass注解,否则无论如何都会为Spring注入Bean。
  4. 在工程的resource目录下创建/META-INF/spring.factories文件,文件内容包含org.springframework.boot.autoconfigure.EnableAutoConfiguration=\以及自动装配器的全限定类名。
  5. 最后在SpringBoot项目中引入该starter,即可实现自动装配。

TODOS

  • [] Redis分布式锁

5大数据类型

  • type: 查看类型

String

  • 分布式锁
    • set if not exists: setnx key value
    • set key value [EX seconds] [PX milliseconds] [NX|XX]
      • ex: 几秒后过期
      • px:几毫秒后过期
      • NX:不存在的时候创建key
      • XX:存在的时候覆盖key
  • 需要计数的场景
  • 商品编号、订单号,使用INCR生成
  • 赞/踩

List

  • 有序有重复/可以左右加入
  • lpush/rpush
  • brpop: 阻塞的pop操作
  • lrange: 分页查询
  • (离线的)消息推送。
  • 消息队列、慢查询

Hash

  • hset/hmset
  • hget/hmget
  • 对象存储
  • 也可以实现分布式锁 -> 可重入锁
    • 记录过期时间和释放次数
  • 购物车?

set

  • smembers
  • 无序无重复
  • 删除/加入/判重
  • 随机弹x个(删/不删)
  • 集合运算:差/交/并
  • 场景
    • 数据不能重复,需要进行集合操作
    • 抽奖:参与sadd,显示参与scard,抽取srandmember/spop
    • 点赞
    • 社交关系
    • 可能认识的人

zset(Sorted set)

  • 有序无重复
  • 需要根据权重进行排序的情况。
  • 在线用户列表、排行榜、弹幕…

bitmap

  • 用一个bit位来表示某个元素对应的值或者状态。key就是对应元素本身。-> 节省存储空间
  • 用户签到:offset = day % 365,key = 年份#用户ID
  • 统计活跃用户?BITOP [AND | OR | NOT | XOR]
  • 用户在线状态:用户ID为offset

hyperloglogs

geospatial

stream

Redis底层数据结构

Redis为什么单线程,又为什么开始使用多线程

单线程模型

  • IO多路复用 + Reactor模式
    • 单线程带来更好的可维护性,方便开发调试
    • 单线程也能够并发。
    • 绝大多数操作的性能瓶颈不是CPU
      • 简单总结一下,Redis 并不是 CPU 密集型的服务,如果不开启 AOF 备份,所有 Redis 的操作都会在内存中完成不会涉及任何的 I/O 操作,这些数据的读写由于只发生在内存中,所以处理速度是非常快的;整个服务的瓶颈在于网络传输带来的延迟和等待客户端的数据传输,也就是网络 I/O,所以使用多线程模型处理全部的外部请求可能不是一个好的方案。
    • 减少线程创建和切换的开销
    • 不需要同步机制,可以保障单次操作的原子性

为什么引入多线程模型

  • 如果某个任务特别大,单次任务可能要消耗较多的时间,这些操作会阻塞待处理的任务。
  • 删除超大键值对:UNLINK,将key从元数据中删除,开启新线程在后台执行释放内存空间的工作。
  • 接受数据包并解析Redis命令,发送返回数据包:这些过程可以引入多线程进行并发处理。

Redis如何判断数据过期、删除策略、内存淘汰机制

Redis判断数据过期

过期字典:key就是数据库中的key,value是时间戳

Redis过期删除策略

  • 惰性删除:只有在取出key的时候对数据进行过期检查。对CPU友好,但可能会造成过期key太多。
  • 定期删除:隔一段时间抽取一批key执行删除过期key操作。执行时长和执行频率会对CPU时间造成影响。
  • 定期删除+惰性删除:每隔一段时间进行检查,但是控制请求的时间和范围,只对key进行随机抽检。然后在取出某个key的时候进行检查,如果过期则删除。
    • 即使使用这种策略,如果定期删除没有删除完全/没有对key进行请求/没有设置过期时间,redis的内存占用会越来越高,此时需要采用内存淘汰机制。

Redis内存淘汰机制

  • redis.conf中:maxmemory-policy [policy name]
  • volatile-lru:从设置了过期时间的数据集里进行LRU,只有在将redis既作为持久存储又作为缓存的时候才使用。
  • volatile-ttl:从设置了过期时间的数据级中选择即将过期的数据进行淘汰
  • volatile-random:从已设置过期时间的数据集中任意选择数据淘汰
  • allkeys-lru: 当内存不足以容纳新数据时对键空间中所有的key进行lru
  • allkeys-random:从数据集中任意选择数据淘汰
  • no-eviction(默认策略):禁止驱逐数据,也就是说当内存不足以容纳新写入数据时,新写入操作会报错。
  • volatile-lfu(least frequently used):从已设置过期时间的数据集中挑选最不经常使用的数据淘汰
  • allkeys-lfu(least frequently used):当内存不足以容纳新写入数据时,在键空间中,移除最不经常使用的 key

Redis持久化

  • 1.重启机器后重用数据、2. 防止系统故障将数据备份

快照持久化(Snapshotting,RDB,默认持久化方式)

创建快照,建立内存数据在某个时间点上的副本。可以对快照进行备份,可以将快照复制到其它服务器上(主从复制、读写分离)

RDB的触发机制

  • save命令,是同步命令,会占用Redis主进程
  • bgsave命令,执行一个异步操作,使用fork()生成子进程将数据保存到硬盘上。主进程调用fork()时也会阻塞,但是比让主进程亲自创建RDB快多了。缺点在于消耗内存。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    # Redis.conf
    save 900 100 # 在900秒后,如果至少有100个key发生变化,Redis就自动触发BGSAVE命令创建快照。

    # RDB持久化文件名
    dbfilename dump-<port>.rdb

    # 数据持久化文件存储目录
    dir /var/lib/redis

    # bgsave发生错误时是否停止写入,通常为yes
    stop-writes-on-bgsave-error yes

    # rdb文件是否使用压缩格式
    rdbcompression yes

    # 是否对rdb文件进行校验和检验,通常为yes
    rdbchecksum yes

创建RDB的机制:写时复制

当 Redis 需要保存 dump.rdb 文件时, 服务器执行以下操作:

  1. Redis 调用forks。同时拥有父进程和子进程。
  2. 子进程将数据集写入到一个临时 RDB 文件中。
  3. 当子进程完成对新 RDB 文件的写入时,Redis 用新 RDB 文件替换原来的 RDB 文件,并删除旧的 RDB 文件。

RDB的优缺点

  • 优点
    • 紧凑、适合数据集的备份、用RDB进行备份恢复比AOF快、可以隔段时间进行备份,创建符合不同要求的数据还原点。
    • 方便传送到远端机器,适合容灾恢复。
    • 使用BGSAVE的方式,Redis主进程除了fork不需要任何其他操作。
  • 缺点
    • 频繁进行fork操作也会影响性能。
    • 不可控,存在数据丢失风险。将数据集进行全量备份是耗时的工作,如果Redis宕机,只能保存上一个检查点记录的数据。

AOF持久化(Append-Only File)

比起RDB持久化,AOF持久化的实时性更好。

1
2
# Redis.conf
appendonly yes

每执行一条更改Redis中数据的命令,Redis就将命令写入硬盘中的AOF文件,保存位置和RDB文件的位置相同。

AOF持久化的不同方式

通过配置文件设置Redis隔多长时间fsync到磁盘一次。

1
2
3
4
# Redis.conf
appendfsync always #每次有数据修改发生时都会写入AOF文件,好处是不丢数据,但IO开销大,这样会严重降低Redis的速度
appendfsync everysec #每秒钟同步一次,显示地将多个写命令同步到硬盘,但可能丢失1秒数据
appendfsync no #让操作系统决定何时进行同步

AOF重写

因为 AOF 的运作方式是不断地将命令追加到文件的末尾, 所以随着写入命令的不断增加, AOF 文件的体积也会变得越来越大。
AOF 重写可以产生一个新的 AOF 文件,这个新的 AOF 文件和原有的 AOF 文件所保存的数据库状态一样,但体积更小。可以减少磁盘占用量,加速数据恢复。
AOF 重写由 Redis 自行触发,bgrewriteaof 仅仅用于手动触发重写操作。在执行 BGREWRITEAOF 命令时,Redis 服务器会维护一个 AOF 重写缓冲区,该缓冲区会在子进程创建新 AOF 文件期间,记录服务器执行的所有写命令。当子进程完成创建新 AOF 文件的工作之后,服务器会将重写缓冲区中的所有内容追加到新 AOF 文件的末尾,使得新旧两个 AOF 文件所保存的数据库状态一致。最后,服务器用新的 AOF 文件替换旧的 AOF 文件,以此来完成 AOF 文件重写操作

AOF的优缺点

  • 优点
    • AOF有序地保存了对数据库执行的所有写入操作,可以对文件进行解析。如果出现了错误的操作,可以对AOF文件解析后通过编辑去除错误的操作,进行数据恢复。
  • 缺点
    • 一般AOF文件的体积大于RDB文件
    • 使用的fsync策略不当的时候可能会降低速度

Redis事务

事务的四大特性:

  • 原子性(Atomicity): 事务是最小的执行单位,不允许分割。事务的原子性确保动作要么全部完成,要么完全不起作用;Redis事务并不支持原子性,虽然Redis事务的多个操作是原子的,事务机制也可以保证多个命令的正确执行不被打断,但是Redis事务不支持回滚。
    • DISCARD命令:清除之前放入队列的命令,然后恢复连接状态。
    • WATCH命令: 将特定的key设置为受监控的状态,如果该key被修改,之后的事务调用EXEC时队列中的所有命令均不会执行。调用UNWATCH命令可以接触之前的WATCH操作。
    • Redis命令语法错误:后续的命令即使是正确地添加到命令队列中,所有的命令(命令队列中的所有命令,包括出错命令之前和之后的命令)都不会被执行。
    • Redis命令运行时错误(Redis似乎并不会在加入命令队列时对命令进行类型匹配检查):
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      127.0.0.1:6379> multi
      OK
      127.0.0.1:6379> set test1 value1-3
      QUEUED
      127.0.0.1:6379> lpush test2 value2-3
      QUEUED
      127.0.0.1:6379> set test3 value3-3
      QUEUED
      127.0.0.1:6379> exec
      1) OK
      2) (error) WRONGTYPE Operation against a key holding the wrong kind of value
      3) OK
      127.0.0.1:6379> get test1
      "value1-3"
      127.0.0.1:6379> get test3
      "value3-3"
      可以看到,运行时错误只会影响出错的命令执行,其它命令都会正常执行,而不会回滚。
  • 隔离性:一个用户的事务不被其他事务干扰。Redis是单线程的,命令在主线程上串行执行,因此Redis事务可以保证隔离性。
  • 持久性:一个事务被提交之后。它对数据库中数据的改变是持久的,即使数据库发生故障也不应该对其有任何影响,除非下一个事务覆盖了这次事务的修改。
  • 一致性(Consistency): 执行事务前后,数据保持一致,多个事务对同一个数据读取的结果是相同的。

Redis缓存性能问题

  • key的一种设计:表名:列名:主键名:主键值

缓存穿透

  • 问题:缓存穿透说简单点就是大量请求的 key 根本不存在于缓存中,进而需要到数据库上进行查找,同时数据库中也不存在相应的数据。大量的无效请求会落到数据库上造成数据库压力过大。
  • 解决
    • 做好参数校验,在查询前过滤错误的、不合法的参数,向客户端返回错误。校验可以在后端接口层完成,也可以在前端完成。
    • 缓存无效key:在缓存中放置一些无效key,通过这些key进行查询时让缓存返回无意义值或者null。可以防范使用重复无效key的攻击,但是对于大量变化的无效key会占用内存,因此要为无效key设置较短的过期时间。
    • 布隆过滤器:把所有可能存在的请求的值都存放在布隆过滤器中,当用户请求过来,先判断用户发来的请求的值是否存在于布隆过滤器中。不存在的话,直接返回请求参数错误信息给客户端。
      • 场景:有限的存储空间存储大量需要查找/匹配的元素,提供判断一个元素是否在集合中的功能,允许较低的查找误差。
      • 大致的做法:使用k个hash函数,对于每个要加入的元素,计算出k个hash值。准备一个长度为m的bitmap,然后令这k个hash值对m取模,得到k个在[0, m - 1]范围内的下标,将bitmap中这k个下标对应位置的bit设置为1。对于一个待查找的元素,用同样的k个hash函数计算出k个hash值并对m取模得到k个下标,如果这k个下标对应的位都是1,则说明这个元素可能在集合中,否则,说明这个元素一定不在集合中。布隆过滤器有将不存在的元素判断为在集合中的可能,但是一定不会将存在于集合中的元素判断为不在集合中。
      • 缺点:有误判率、删除困难。降低概率:增加bitmap的大小、调整hash函数

缓存击穿

  • 问题:读取到了缓存中不存在但是数据库中存在的数据,需要向数据库发送请求,并发用户多的时候去数据库读取数据。
  • 解决:
    • 设置热点数据永不过期。
      • 要对数据库发送请求的情况下,加入互斥锁(信号量?),保证只有一定量的请求能够到达数据库。在缓存重生效之前避免数据库崩溃。

缓存雪崩

  • 问题:缓存在同一时间大面积的失效/Redis突然不可用,后面的请求都直接落到了数据库上,造成数据库短时间内承受大量请求。
  • 解决
    • 针对Redis服务不可用的情况:
      • 采用Redis集群
      • 限流(?
    • 针对热点缓存失效的情况:
      • 为不同缓存设置不同的(随机的)生效时间
      • 要对数据库发送请求的情况下,加入互斥锁(信号量?),保证只有一定量的请求能够到达数据库。在缓存重生效之前避免数据库崩溃。
      • 对热点数据不设置过期时间

Redis实现分布式锁

怎么实现分布式锁

  • mysql、zookeeper、redis
  • Redlock算法 -> redisson(Redlock算法的Java实现) lock/unlock
  • 解锁问题:加了锁一定要解锁 -> 怎么删锁?
    • 代码可能出现异常:finally中解锁
    • 整个节点直接炸了,或者网给掐了:过期策略
      • 取得锁后直接为锁添加过期时间:这个操作必须是原子的(否则刚加完锁还没设置过期时间节点直接被扬了,这把锁就不会过期了)。然而redis的setnx命令本身并没有提供直接的timeout参数。
    • 节点从宕机状态中恢复/网络恢复,由于过期策略的存在节点持有的锁有可能已经过期。
      • 该节点会进行一些不持有锁时不应该继续进行的操作
      • 该节点可能会进行锁释放,在redis上删除了别的节点的锁
      • 解决:删除锁时只能删除自己的锁,先尝试获得锁进行判断
        • 这个判断-解锁的操作必须是原子的:否则可能出现在锁过期前一刻判断出锁属于自己,而其它节点在锁过期后获得锁,当前节点判断完成后误删其它节点的锁的情况
        • Redis事务?MULTI EXEC WATCH
        • lua脚本?(Jedis,eval()
      • 锁的过期时间应该确保大于业务执行时间 -> 续期
  • Redis集群分布式锁:主从复制
    • Redis是AP型的(??):有可能存在Redis异步复制造成的锁丢失
      • Master写,Slave读。Redis先直接返回成功信息,再将锁同步到Slave节点。但是复制成功之前Master可能会故障,Master进行降级 -> 锁丢失(似乎并没有什么好的办法,这是Redis方案的固有缺陷)
    • 加锁操作
    • 对比ZK:ZK属于CP型,实行同步复制,先将数据从Master复制到从节点,再返回对外成功消息。此时master故障,新master会保留最新的锁信息。但是这会牺牲可用性(A):对网络延迟敏感,速度取决于最慢速度。

Safety and Liveness

  • Safety: 互斥
  • Liveness
    • A:不会死锁,最终总能获得锁
    • B: 容错性,只要多数的Redis节点还能工作,client总能获取和释放锁。

单实例下的Redis分布式锁实现

加锁

使用SETNX命令:set key value [EX seconds] [PX milliseconds] [NX|XX]。指定过期时间。

  • SET resource_name a_random_value NX PX 30000
  • NX: 只有key不存在的时候才会SET。PX: 以毫秒为单位指定key的过期时间。
  • value: 对于所有的锁请求,这个值必须是唯一的(防止误删)

解锁

  • 无法以单一命令实现判断和解锁,只能使用lua脚本。Redis对Lua脚本的执行具有原子性。
    1
    2
    3
    4
    5
    if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])
    else
    return 0
    end
  • 使用EVAL指令可以执行匿名lua脚本:eval [脚本] [key的数量] [arg1, arg2, ...]
  • script load命令:将脚本传送到Redis服务器,Redis服务器返回通过sha1算法得到的哈希值
  • evalsha [哈希值] [key数量] [arg1, arg2, ...]:通过哈希值执行命令

主从模式下用单实例方法有什么问题

  • Redis使用异步复制实现主从复制。
    • A在master上写入了一个锁
    • master在将锁复制到slave之前就crash了
    • salve选出了新的master
    • 新的master并没有A写入的锁,此时B可以在新的master上写入锁,因为key并不存在

秒杀设计

  • 单机情况下的多线程同步只需要使用Java提供的同步机制(synchronized, ReentrantLock…)
  • 多机部署:Nginx负载均衡
  • 多机部署的情况下Java提供的锁机制不够用了 -> 分布式锁(用上Redis提供的分布式锁也没必要使用Java提供的同步机制了

缓存和数据库的数据一致性(读写策略)

旁路缓存模式(Cache Aside Pattern)

  • 适用场景:需要同时维系DB和cache,读操作较多
  • 写步骤
    • 先更新DB
    • 然后删除cache
  • 读步骤
    • 从cache中读数据,读到就直接返回
    • 如果从cache中读不到,就从DB中读取数据返回
    • 再将数据放到cache中

常见问题

  • 旁路缓存模式下数据一致性问题是无法避免的(无法完全保证DB和缓存的一致性。
  • 可以先删cache再更新DB吗
    • 不可以。因为这样可能会造成数据库(DB)和缓存(Cache)数据不一致的问题。
    • 例如,两个对同一数据的并发操作,一个是更新操作,另一个是查询操作。更新操作删除缓存后,查询操作没有命中缓存,然后查询操作到数据库中读出了旧数据并将旧数据写入缓存,最后更新操作更新了数据库。此时缓存中的旧数据成为了脏数据(和当前数据库内的值不一致了),如果没有过期的话很可能一直脏下去。
  • 我就是要先删缓存再更新DB有什么解决办法吗?
    • 延时双删策略:先删除cache,然后更新数据库,隔一定时间后再次删除cache,在这段时间内造成的缓存脏数据就会被删除。但是这个间隔时间需要自行进行评估。
  • 这种方式就没有问题吗
    • 理论上也会出现导致数据不一致的case:一个是读操作,但是没有命中缓存,然后就到数据库中取数据,此时来了一个写操作,写完数据库后,让缓存失效,然后,之前的那个读操作再把老的数据放进去,所以,会造成脏数据。不过,实际上出现的概率可能非常低,因为这个条件需要发生在读缓存时缓存失效,而且并发着有一个写操作。而实际上数据库的写操作会比读操作慢得多,而且还要锁表,而读操作必需在写操作前进入数据库操作,而又要晚于写操作更新缓存,所有的这些条件都具备的概率基本并不大(至少比先删cache再更新数据库要小得多)。
  • 写操作的时候为什么要删除cache,而不是写cache(?
    • 写操作频繁的时候删除cache确实会影响缓存命中率而导致性能下降。选择更新cache的方式需要考虑安全问题。并发的写操作也可能导致脏数据。
    • 例如:写操作A更新了数据库 -> 写操作B更新了数据库 -> 写操作B更新了缓存 -> 写操作A更新了缓存
  • 如果更新数据库成功而删除缓存失败?
    • 增加cache更新重试机制:如果cache服务当前不可用,就存入队列中等缓存服务可用的时候再将缓存中的key删除。

读写穿透模式(Read/Write Through Pattern)

主从、集群、哨兵

主从

  • 在conf文件中加入slave的IP和端口
  • 读写分离,master可读写、slave一般只读
  • 将rdb传送给从数据库
  • 异步复制:master执行完写操作立级将结果返回给客户端,再将命令发送给slave

哨兵

集群

  • 主从是全部都放在一个库里