1. 基础篇
讲讲面向对象三大特性
- 封装: 封装最好理解了。封装是面向对象的特征之一,是对象和类概念的主要特性。封装,也就是把客观事物封装成抽象的类,并且类可以把自己的数据和方法只让可信的类或者对象操作,对不可信的进行信息隐藏。
- 继承: 继承是指这样一种能力:它可以使用现有类的所有功能,并在无需重新编写原来的类的情况下对这些功能进行扩展。通过继承创建的新类称为“子类”或“派生类”,被继承的类称为“基类”、“父类”或“超类”。
- 多态性: 它是指在父类中定义的属性和方法被子类继承之后,可以具有不同的数据类型或表现出不同的行为,这使得同一个属性或方法在父类及其各个子类中具有不同的含义。
String,StringBuffer,StringBuilder的区别是什么?
- 可变与不可变
String类中使用字符数组保存字符串,因为有“final”修饰符,所以string对象是不可变的。对于已经存在的String对象的修改都是重新创建一个新的对象,然后把新的值保存进去.
String类利用了final修饰的char类型数组存储字符,源码如下:
private final char value[];
StringBuilder与StringBuffer都继承自AbstractStringBuilder类,在AbstractStringBuilder中也是使用字符数组保存字符串,这两种对象都是可变的。
源码如下:
char[] value;
- 是否多线程安全
String中的对象是不可变的,也就可以理解为常量,显然线程安全。
StringBuilder是非线程安全的。
StringBuffer对方法加了同步锁或者对调用的方法加了同步锁,所以是线程安全的。
源码如下:
@Override
public synchronized StringBuffer append(String str) {
toStringCache = null;
super.append(str);
return this;
}
- 性能
每次对String 类型进行改变的时候,都会生成一个新的String对象,然后将指针指向新的String 对象。StringBuffer每次都会对StringBuffer对象本身进行操作,而不是生成新的对象并改变对象引用。相同情况下使用StirngBuilder相比使用StringBuffer仅能获得10%~15% 左右的性能提升,但却要冒多线程不安全的风险。
参考文章:拼接字符串String、StringBuilder、StringBuffer你用对了吗
Java语言有哪些特点?
面向对象(封装,继承,多态);
平台无关性,平台无关性的具体表现在于,Java 是“一次编写,到处运行(Write Once,Run any Where)”的语言,因此采用 Java 语言编写的程序具有很好的可移植性,而保证这一点的正是 Java 的虚拟机机制。在引入虚拟机之后,Java 语言在不同的平台上运行不需要重新编译。
可靠性、安全性;
支持多线程。C++ 语言没有内置的多线程机制,因此必须调用操作系统的多线程功能来进行多线程程序设计,而 Java 语言却提供了多线程支持;
支持网络编程并且很方便。Java 语言诞生本身就是为简化网络编程设计的,因此 Java 语言不仅支持网络编程而且很方便;
编译与解释并存。
什么是字节码?
这个问题,面试官可以扩展提问,Java 是编译执行的语言,还是解释执行的语言?
Java是解释执行语言,Java在Java编译器编译后,形成.class文件,不能直接运行,而是通过JVM解释执行
Java之所以可以“一次编译,到处运行”,一是因为JVM针对各种操作系统、平台都进行了定制,二是因为无论在什么平台,都可以编译生成固定格式的字节码(.class文件)供JVM使用。因此,也可以看出字节码对于Java生态的重要性。
之所以被称之为字节码,是因为字节码文件由十六进制值组成,而JVM以两个十六进制值为一组,即以字节为单位进行读取。在Java中一般是用javac命令编译源代码为字节码文件,一个.java文件从编译到运行的示例如图所示。
采用字节码的好处是什么?
Java语言通过字节码的方式,在一定程度上解决了传统解释型语言执行效率低的问题,同时又保留了解释型语言可移植的特点。所以Java程序运行时比较高效,而且,由于字节码并不专对一种特定的机器,因此,Java程序无须重新编译便可在多种不同的计算机上运行。
Oracle JDK 和 OpenJDK 的区别是什么?
可能在看这个问题之前很多人和我一样并没有接触和使用过 OpenJDK 。下面通过我通过我收集到一些资料对你解答这个被很多人忽视的问题。
- Oracle JDK 版本将每三年发布一次,而 OpenJDK 版本每三个月发布一次;
- OpenJDK 是一个参考模型并且是完全开源的,而 Oracle JDK 是OpenJDK 的一个实现,并不是完全开源的;
- Oracle JDK 比 OpenJDK 更稳定。OpenJDK 和 Oracle JDK 的代码几乎相同,但 Oracle JDK 有更多的类和一些错误修复。因此,如果您想开发企业/商业软件,建议选择 Oracle JDK,因为它经过了彻底的测试和稳定。某些情况下,有些人提到在使用 OpenJDK 可能会遇到了许多应用程序崩溃的问题,但是,只需切换到 Oracle JDK 就可以解决问题;
- 在响应性和 JVM 性能方面,Oracle JDK 与 OpenJDK 相比提供了更好的性能;
- Oracle JDK 不会为即将发布的版本提供长期支持,用户每次都必须通过更新到最新版本获得支持来获取最新版本;
- Oracle JDK 根据二进制代码许可协议获得许可,而 OpenJDK 根据 GPLv2 许可获得许可。
访问修饰符public、private、protected、以及不写(默认)时的区别?
Java中,可以使用访问控制符来保护对类、变量、方法和构造方法的访问。Java 支持 4 种不同的访问权限。
- default (即默认,什么也不写): 在同一包内可见,不使用任何修饰符。使用对象:类、接口、变量、方法。
- private : 在同一类内可见。使用对象:变量、方法。 注意:不能修饰类(外部类)
- public : 对所有类可见。使用对象:类、接口、变量、方法
- protected : 对同一包内的类和所有子类可见。使用对象:变量、方法。 注意:不能修饰类(外部类)。
ArrayList和LinkedList区别
数组是基于索引的数据结构,使用索引在数组中搜索和读取数据是很快的。数组获取数据的时间复杂度是O(1),但是在进行删除的时候开销很大,因为需要对数组进行重排(删除位置后的元素需要整体向前移动)。
数组在初始化的时候必须指定长度,并在堆内存中分配空间。
ArrayList是底层是通过“可变长”数组来实现的,因此具有了数组的特性。ArrayList的变长本质是数组的重新创建和扩容,根据扩容因子来确定是否需要扩容,单次扩容的长度是当前数组长度的0.5倍。具体ArrayList的详细信息见博客。
ArrayList源码(一):java1.8源码之ArrayList源码解读
ArrayList源码(二):扩容和移位、删除元素详解
LinkedList是基于双向链表实现的,链表节点在堆中无需连续,通过节点的首尾指针建立节点间的关联关系。
在进行新增、删除的时候,LinkedList相对于ArrayList效率会更高一点,但在查询方面ArrayList要比LinkedList效率高。在插入元素的时候,ArrayList效率要比LinkedList效率低一点,因为ArrayList涉及元素的复制移位,甚至重新开辟内存的问题。不过这些比较都是在数据量较大的、操作复杂的情况下成立的,反之,各自的优缺点就没有这么明显了,可以忽略。
HashMap和Hashtable的区别
- 两者父类不同:HashMap是继承自AbstractMap类,而Hashtable是继承自Dictionary类;
- 对null的支持不同:Hashtable的Key和Value都不能为null,HashMap的可以为null,但是只能出现一次,因为需要保证Key值的唯一性。Value值不需要保证唯一性,多个不同的Key下的Value都是可以为null的。
- 安全性不同:Hashtable是线程安全的,HashMap是非线程安全的,在Hashtable内,每个方法都添加了synchronized关键字。
- 效率方面不同:安全性和效率是相斥的,Hashtable的每个方法都添加了synchronized关键字,增加了锁的开销,就会导致执行效率的降低,反之HashMap的效率要优于Hashtable的,在没有多线程共用、竞争的情况下,最优的选择就是HashMap。
HashMap和ConcurrentHashMap的区别
ConcurrentHashMap和HashMap都是采用同样的数据存储结构,实现过程基本相同。但是由于HashMap是非线程安全的,使用Hashtable效率又很低。因此引入了ConcurrentHashMap,在提供线程安全的前提下,最大化提高效率。ConcurrentHashMap采用了分段锁机制,在数据结构的插槽级别增加锁机制,在保证并发(最大并发量是插槽的总数量)的情况下,实现对不同插槽进行锁的实现,保证数据安全。
对比异同点
- 都是 key-value 形式的存储数据;
- HashMap 是线程不安全的,ConcurrentHashMap 是 J.U.C 下的线程安全的;
- HashMap 底层数据结构是数组 + 链表(JDK 1.8 之前)。JDK 1.8 之后是数组 + 链表 + 红黑树。当链表中元素个数达到 8 的时候,链表的查询速度不如红黑树快,链表会转为红黑树,红黑树查询速度快;
- HashMap 初始数组大小为 16(默认),当出现扩容的时候,以 0.75 * 数组大小的方式进行扩容;
- ConcurrentHashMap 在 JDK 1.8 之前是采用分段锁来现实的 Segment + HashEntry,Segment 数组大小默认是 16,2 的 n 次方;JDK 1.8 之后,采用 Node + CAS + Synchronized来保证并发安全进行实现。
JDK7、8对ConcurrentHashMap的实现和总结
try catch finally,try里面有return,finally还执行吗?
finally会执行的,且finally的执行早于try里面return的执行。
结论
- 不管有没有出现异常,finally块中代码都会执行;
- 当try和catch中有return时,finally任然会执行;
- finally是在return后面的表达式运算后执行的(此时并没有返回运算后的值,而是先把要返回的值保存起来,不管finally中的代码怎么样,返回的值都不会改变,仍然是之前保存的值),所以函数返回值是在finally执行前确定的;
- finally中最好不要包括return,否则程序会提前退出,返回值不是try或者catch中保存的返回值。
Java序列化中如果有些字段不想进行序列化,怎么办?
对于不想进行序列化的变量,使用transient关键字修饰。
transient关键字的作用:
阻止实例中那些用此关键字修饰的变量序列化;当对象被反序列时,被transient修饰的变量值不会被持久化和恢复。transient只能修饰变量,不能修饰类和方法。
java 创建对象有哪几种方式?
java中提供了以下四种创建对象的方式:
- 使用new关键字;
- 使用反射方式创建对象,newInstance()方法;
- 使用clone方法,包括浅克隆和深克隆;
- 使用反序列化创建对象,使用ObjectInputStream类的readObject()方法。
前两者都需要显式地调用构造方法。对于clone机制,需要注意浅拷贝和深拷贝的区别,对于序列化机制需要明确其实现原理,在java中序列化可以通过实现Externalizable或者Serializable来实现。
ArrayList有什么特点
- Java集合框架中的一种存放相同类型的元素数据集合,是一种变长的集合类,基于定长数组实现,当加入数据达到一定程度,会实现自动扩容,即扩大数组大小;
- 当使用add(O),添加到数组的尾部,可能触发扩容机制;
- 当使用add(int,O),添加到数组的指定位置,可能会需要挪动大量的数组元素,并且可能触发扩容机制;
- 当可预见数组容量,可以在创建ArrayList对象的时候初始化其容量,降低后续扩容带来的性能消耗;
- 高并发的情况下,线程不安全,会引发不可预见的异常或错误;
- ArrayList实现了Cloneable接口,表示它可以被复制(浅复制)。
说说什么是fail-fast
fail-fast机制是Java集合(Collection)中的一种错误机制,当多个线程对同一个集合的内容进行操作时,就可能触发fail-fast事件。
当一个线程A通Iterator去遍历某个集合的过程中,若改集合的内容被其他线程所改变了,那么线程A访问集合时,就会抛出ConcurrentModificationException异常,产生fail-fast事件。(这里的操作主要是指add、remove和clear,会引起数组元素数量变化的操作)
主要机制:在Iterator内中维护了一个expectModCount属性,在Java集合中维护了一个modCount字段,创建Iterator迭代器的时候,会将最新的modCount值赋值给expectModCount。当在使用Iterator遍历集合的时候,每次取集合内元素,都会对比expectModCount和modCount是否相同,如果不相同,说明集合被迭代器以外的线程操作修改了,于是就抛出ConcurrentModificationException异常。
HashMap的长度为什么是2的N次方呢?
为能让HashMap存数据和取数据的效率高,尽可能地减少hash值的碰撞,也就是说尽量把数据能均匀的分配,每个链表或者红黑树长度尽量相同。
在这样的情况下,首先想到的是取模(%)操作来实现。在取余(%)的时候,如果除数是2的幂次,则等价于与其除数减一的与(&)操作。并且采用二进制位操作&,相对于%能够提高运行效率。
hash % length == hash & (length -1)
因此HashMap的长度需要2的N次方。
红黑树有哪几个特征?
在ConcurrentHashMap问完后,你肯定会回答ConcurrentHashMap的数据结构,在JDK1.8以后,采用的是数组、链表+红黑树的结构存储,在链表长度达到8以上后,红黑树的查询效率会比链表好。紧接着面试官就会为红黑树的特征。
- 每个节点都是黑色或者红色;
- 根节点是黑色;
- 每个叶子节点都是黑色(指向空的叶子节点);
- 如果一个叶子节点是红色,那么其子节点必须都是黑色;
- 从一个节点到该节点的子孙节点的所有路径上包含相同数据的黑节点。
说说平时是怎么处理Java异常的
正常处理标准流程:try-catch-finally
- try块负责监控可能出现异常的代码
- catch块负责捕获可能出现的异常,并进行处理
- finally块负责清理各种资源,不管是否出现异常都会执行
- 其中try块是必须的,catch和finally至少存在一个标准异常处理流程
优化点
- try块要合理的界定区间,不将大量无关的代码也圈入,影响整体的性能;
- catch捕获异常后,可以采用三种解决方案,第一通过对异常的处理,封装成自定义异常抛出;第二可以通过日志记录异常信息后,将原始异常直接抛出;第三是将无需处理的异常进行记录,不中断流程,继续业务代码的执行。其中第一、二种解决方案可以配合切面或者Spring Boot自带的异常处理机制,从控制层统一做异常的处理。
- finally中是在异常发生后,对需要释放的资源等做最后的保证,需要注意finally内不能使用return,这样会导致程序提前退出,try、catch块中做的处理后的返回值无法返回。
说说什么是单例模式
单例模式是一种常用的软件设计模式,在应用这个模式时,单例对象的类必须保证只有一个实例存在,整个系统只能使用一个对象实例。
优点:不会频繁地创建和销毁对象,浪费系统资源。
可能这会需要你手写一个单例模式,这就得自己去学了,因为单例模式有很多种写法,懒汉模式,饿汉模式,双重检查模式等。懒汉模式就是用的时候再去创建对象,饿汉模式就是提前就已经加载好的静态static对象,双重检查模式就是两次检查避免多线程造成创建了多个对象。
单例模式有很多种的写法,我总结一下:
- 饿汉式单例模式:线程安全
- 懒汉式单例模式:非线程安全
- 双检锁单例模式:线程安全
- 静态内部类模式:线程安全
- 枚举模式:线程安全
说说你对代理模式的理解
代理模式是给某一个对象提供一个代理,并由代理对象控制对原对象的引用。
优点:
- 代理模式能够协调调用者和被调用者,在一定程度上降低了系统的耦合度;
- 可以灵活地隐藏被代理对象的部分功能和服务,也增加额外的功能和服务。
缺点:
- 由于使用了代理模式,因此程序的性能没有直接调用性能高;
- 使用代理模式提高了代码的复杂度。
黄牛卖火车票:没有流行网络购票的年代是很喜欢找黄牛买火车票的,因为工作忙的原因,没时间去买票,然后就托黄牛给你买张回家过年的火车票。这个过程中黄牛就是代理人,火车票就是被代理的对象。
婚姻介绍所:婚姻介绍所的工作人员,搜集单身人士信息,婚介所的工作人员为这个单身人士找对象,这个过程也是代理模式的生活案例。对象就是被代理的对象。
注意了,问代理模式的时候,很有可能会问:动态代理。在Spring篇中已经讲述过,如果你把动态代理讲了后,很有可能还会问什么是静态代理?
说说工厂模式
简单工厂模式又叫静态工厂方法模式,就是建立一个工厂类,对实现了同一接口的一些类进行实例的创建。比如,一台咖啡机就可以理解为一个工厂模式,你只需要按下想喝的咖啡品类的按钮(摩卡或拿铁),它就会给你生产一杯相应的咖啡,你不需要管它内部的具体实现,只要告诉它你的需求即可。
优点:
- 工厂类含有必要的判断逻辑,可以决定在什么时候创建哪一个产品类的实例,客户端可以免除直接创建产品对象的责任,而仅仅“消费”产品;简单工厂模式通过这种做法实现了对责任的分割,它提供了专门的工厂类用于创建对象;
- 客户端无须知道所创建的具体产品类的类名,只需要知道具体产品类所对应的参数即可,对于一些复杂的类名,通过简单工厂模式可以减少使用者的记忆量;
- 通过引入配置文件,可以在不修改任何客户端代码的情况下更换和增加新的具体产品类,在一定程度上提高了系统的灵活性。
缺点:
- 不易拓展,一旦添加新的产品类型,就不得不修改工厂的创建逻辑;
- 产品类型较多时,工厂的创建逻辑可能过于复杂,一旦出错可能造成所有产品的创建失败,不利于系统的维护。
抽象工厂模式
抽象工厂模式是在简单工厂的基础上将未来可能需要修改的代码抽象出来,通过继承的方式让子类去做决定。
比如,以上面的咖啡工厂为例,某天我的口味突然变了,不想喝咖啡了想喝啤酒,这个时候如果直接修改简单工厂里面的代码,这种做法不但不够优雅,也不符合软件设计的“开闭原则”,因为每次新增品类都要修改原来的代码。这个时候就可以使用抽象工厂类了,抽象工厂里只声明方法,具体的实现交给子类(子工厂)去实现,这个时候再有新增品类的需求,只需要新创建代码即可。
装饰器模式是什么
装饰器模式是指动态地给一个对象增加一些额外的功能,同时又不改变其结构。
优点:装饰类和被装饰类可以独立发展,不会相互耦合,装饰模式是继承的一个替代模式,装饰模式可以动态扩展一个实现类的功能。
装饰器模式的关键:装饰器中使用了被装饰的对象。
模板方法模式
模板方法模式是指定义一个算法骨架,将具体内容延迟到子类去实现。
优点:
- 提高代码复用性:将相同部分的代码放在抽象的父类中,而将不同的代码放入不同的子类中;
- 实现了反向控制:通过一个父类调用其子类的操作,通过对子类的具体实现扩展不同的行为,实现了反向控制并且符合开闭原则。
享元模式
顾名思义就是被共享的单元。享元模式的意图是复用对象,节省内存,前提是享元对象是不可变对象。
具体来讲,当一个系统中存在大量重复对象的时候,如果这些重复的对象是不可变对象,我们就可以利用享元模式将对象设计成享元,在内存中只保留一份实例,供多处代码引用。这样可以减少内存中对象的数量,起到节省内存的目的。
典型的使用场景:Integer中cache,就是享元模式很经典的实现。
享元模式和单例模式的区别
单例模式是创建型模式,重在只能有一个对象。而享元模式是结构型模式,重在节约内存使用,提升程序性能。
享元模式:把一个或者多个对象缓存起来,用的时候,直接从缓存里获取。也就是说享元模式不一定只有一个对象。
说说策略模式在我们生活的场景
策略模式是指定义一系列算法,将每个算法都封装起来,并且使他们之间可以相互替换。
优点: 遵循了开闭原则,扩展性良好。
缺点: 随着策略的增加,对外暴露越来越多。
条条大路通罗马,条条大路通北京。
我们去北京的交通方式(策略)很多,比如说坐飞机、坐高铁、自己开车等方式。每一种方式就可以理解为每一种策略。
这就是生活中的策略模式。
适配器模式
适配器模式是将一个类的接口变成客户端所期望的另一种接口,从而使原本因接口不匹配而无法一起工作的两个类能够在一起工作。
优点:
- 可以让两个没有关联的类一起运行,起着中间转换的作用;
- 灵活性好,不会破坏原有的系统。
缺点:
过多地使用适配器,容易使代码结构混乱,如明明看到调用的是 A 接口,内部调用的却是 B接口的实现。
生活中的插座,为了适应各种插头,然后上面有两个孔的,三个空的,基本都能适应。还有万能充电器、USB接口等。这些都是生活中的适配器模式。
观察者模式
观察者模式是定义对象间的一种一对多依赖关系,使得每当一个对象状态发生改变时,其相关依赖对象皆得到通知并被自动更新。观察者模式又叫做发布-订阅(Publish/Subscribe)模式、模型-视图(Model/View)模式、源-监听器(Source/Listener)模式或从属者(Dependents)模式。
优点:
- 观察者模式可以实现表示层和数据逻辑层的分离,并定义了稳定的消息更新传递机制,抽象了更新接口,使得可以有各种各样不同的表示层作为具体观察者角色;
- 观察者模式在观察目标和观察者之间建立一个抽象的耦合;
- 观察者模式支持广播通信;
- 观察者模式符合开闭原则(对拓展开放,对修改关闭)的要求。
缺点:
- 如果一个观察目标对象有很多直接和间接的观察者的话,将所有的观察者都通知到会花费很多时间;
- 如果在观察者和观察目标之间有循环依赖的话,观察目标会触发它们之间进行循环调用,可能导致系统崩溃;
- 观察者模式没有相应的机制让观察者知道所观察的目标对象是怎么发生变化的,而仅仅只是知道观察目标发生了变化。
在观察者模式中有如下角色:
- Subject:抽象主题(抽象被观察者),抽象主题角色把所有观察者对象保存在一个集合里,每个主题都可以有任意数量的观察者,抽象主题提供一个接口,可以增加和删除观察者对象;
- ConcreteSubject:具体主题(具体被观察者),该角色将有关状态存入具体观察者对象,在具体主题的内部状态- 发生改变时,给所有注册过的观察者发送通知;
- Observer:抽象观察者,是观察者者的抽象类,它定义了一个更新接口,使得在得到主题更改通知时更新自己;
- ConcrereObserver:具体观察者,实现抽象观察者定义的更新接口,以便在得到主题更改通知时更新自身的状态。
在Spring中大量的使用的观察者模式,只要看到是以Event结尾或者Publish开头的基本上都是观察者模式。
什么是不可变对象?好处是什么?
不可变对象指对象一旦被创建,状态就不能再改变,任何修改都会创建一个新的对象,如 String、Integer及其它包装类。不可变对象最大的好处是线程安全。
值传递和引用传递的区别的什么?为什么说Java中只有值传递?
值传递:指的是在方法调用时,传递的参数是按值的拷贝传递,传递的是值的拷贝,也就是说传递后就互不相关了。
引用传递:指的是在方法调用时,传递的参数是按引用进行传递,其实传递的是引用的地址,也就是变量所对应的内存空间的地址。传递的是值的引用,也就是说传递前和传递后都指向同一个引用(也就是同一个内存空间)。
基本类型作为参数被传递时肯定是值传递;引用类型作为参数被传递时也是值传递,只不过“值”为对应的引用。
想要深入了解,可以参考这篇文章 :http://www.itwanger.com/java/2019/11/26/java-yinyong-value.html
介绍下hashCode()
hashCode()的作用是获取哈希码,也称为散列码;它实际上是返回一个int整数。这个哈希码的作用是确定该对象在哈希表中的索引位置。hashCode()定义在JDK的Object.java中,这就意味着Java中的任何类都包含有hashCode()函数。
散列表存储的是键值对(key-value),它的特点是:能根据“键”快速的检索出对应的“值”。这其中就利用到了散列码!(可以快速找到所需要的对象)
为什么重写equals方法必须重写hashcode方法
判断的时候先根据hashcode进行的判断,相同的情况下再根据equals()方法进行判断。如果只重写了equals方法,而不重写hashcode的方法,会造成hashcode的值不同,而equals()方法判断出来的结果为true。
在Java中的一些容器中,不允许有两个完全相同的对象,插入的时候,如果判断相同则会进行覆盖。这时候如果只重写了equals()的方法,而不重写hashcode的方法,Object中hashcode是根据对象的存储地址转换而形成的一个哈希值。这时候就有可能因为没有重写hashcode方法,造成相同的对象散列到不同的位置而造成对象的不能覆盖的问题。
String为什么要设计成不可变的
- 便于实现字符串池(String pool)
在Java中,由于会大量的使用String常量,如果每一次声明一个String都创建一个String对象,那将会造成极大的空间资源的浪费。Java提出了String pool的概念,在堆中开辟一块存储空间String pool,当初始化一个String变量时,如果该字符串已经存在了,就不会去创建一个新的字符串变量,而是会返回已经存在了的字符串的引用。
String a = "Hello world!";
String b = "Hello world!";
如果字符串是可变的,某一个字符串变量改变了其值,那么其指向的变量的值也会改变,String pool将不能够实现!
- 使多线程安全
在并发场景下,多个线程同时读一个资源,是安全的,不会引发竞争,但对资源进行写操作时是不安全的,不可变对象不能被写,所以保证了多线程的安全。
- 避免安全问题
在网络连接和数据库连接中字符串常常作为参数,例如,网络连接地址URL,文件路径path,反射机制所需要的String参数。其不可变性可以保证连接的安全性。如果字符串是可变的,黑客就有可能改变字符串指向对象的值,那么会引起很严重的安全问题。
- 加快字符串处理速度
由于String是不可变的,保证了hashcode的唯一性,于是在创建对象时其hashcode就可以放心的缓存了,不需要重新计算。这也就是Map喜欢将String作为Key的原因,处理速度要快过其它的键对象。所以HashMap中的键往往都使用String。
总体来说,String不可变的原因要包括 设计考虑,效率优化,以及安全性这三大方面。
深拷贝和浅拷贝的区别
浅拷贝: 被复制对象的所有变量都含有与原来的对象相同的值,而所有的对其他对象的引用仍然指向原来的对象,换言之,浅拷贝仅仅复制所考虑的对象,而不复制它所有引用的对象。(源对象内引用的对象不复制,复制后的对象和源对象共用)
深拷贝: 被复制对象的所有变量都含有与原来的对象相同的值,而那些引用其他对象的变量将指向被复制过的新对象。不再是原来被引用的对象。换言之,深拷贝把要复制的对象及所引用的对象都复制了一遍。
break ,continue ,return 的区别及作用?
break 跳出总上一层循环,不再执行循环(结束当前的循环体)
continue 跳出本次循环,继续执行下次循环(结束正在执行的循环 进入下一个循环条件)
return 程序返回,不再执行下面的代码(结束当前的方法,直接返回)
final、finally、finalize的区别?
final 用于修饰变量、方法和类。
- final 变量:被修饰的变量不可变,不可变分为
引用不可变
和对象不可变
,final 指的是引用不可变
,final 修饰的变量必须初始化,通常称被修饰的变量为常量
。 - final 方法:被修饰的方法不允许任何子类重写,子类可以使用该方法。
- final 类:被修饰的类不能被继承,所有方法不能被重写。
finally 作为异常处理的一部分,它只能在 try/catch
语句中,并且附带一个语句块表示这段语句最终一定被执行(无论是否抛出异常),经常被用在需要释放资源的情况下,System.exit (0)
可以阻断 finally 执行。
finalize 是在 java.lang.Object
里定义的方法,也就是说每一个对象都有这么个方法,这个方法在 gc
启动,该对象被回收的时候被调用。
一个对象的 finalize 方法只会被调用一次,finalize 被调用不一定会立即回收该对象,所以有可能调用 finalize 后,该对象又不需要被回收了,然后到了真正要被回收的时候,因为前面调用过一次,所以不会再次调用 finalize 了,进而产生问题,因此不推荐使用 finalize 方法。
为什么要用static关键字?
通常来说,用new创建类的对象时,数据存储空间才被分配,方法才供外界调用。但有时我们只想为特定域分配单一存储空间,不考虑要创建多少对象或者说根本就不创建任何对象,再就是我们想在没有创建对象的情况下也想调用方法。在这两种情况下,static关键字,满足了我们的需求。
”static”关键字是什么意思?Java中是否可以覆盖(override)一个private或者是static的方法?
“static”关键字表明一个成员变量或者是成员方法可以在没有所属的类的实例变量的情况下被访问。
Java中static方法不能被覆盖,因为方法覆盖是基于运行时动态绑定的,而static方法是编译时静态绑定的。static方法跟类的任何实例都不相关,所以概念上不适用。
是否可以在static环境中访问非static变量?
static变量在Java中是属于类的,它在所有的实例中的值是一样的。当类被Java虚拟机载入的时候,会对static变量进行初始化。如果你的代码尝试不用实例来访问非static的变量,编译器会报错,因为这些变量还没有被创建出来,还没有跟任何实例关联上。
static静态方法能不能引用非静态资源?
不能,new的时候才会产生的东西,对于初始化后就存在的静态资源来说,根本不认识它。
static静态方法里面能不能引用静态资源?
可以,因为都是类初始化的时候加载的,大家相互都认识。
非静态方法里面能不能引用静态资源?
可以,非静态方法就是实例方法,那是new之后才产生的,那么属于类的内容它都认识。
java静态变量、代码块、和静态方法的执行顺序是什么?
基本上代码块分为三种:Static静态代码块、构造代码块、普通代码块
代码块执行顺序静态代码块——> 构造代码块 ——> 构造函数——> 普通代码块
继承中代码块执行顺序:父类静态块——>子类静态块——>父类代码块——>父类构造器——>子类代码块——>子类构造器
Java语言是如何实现多态的?
本质上多态分两种:
1、编译时多态(又称静态多态)
2、运行时多态(又称动态多态)
重载(overload)就是编译时多态的一个例子,编译时多态在编译时就已经确定,运行的时候调用的是确定的方法。
我们通常所说的多态指的都是运行时多态,也就是编译时不确定究竟调用哪个具体方法,一直延迟到运行时才能确定。这也是为什么有时候多态方法又被称为延迟方法的原因。
Java实现多态有 3 个必要条件:继承、重写和向上转型。只有满足这 3 个条件,开发人员才能够在同一个继承结构中使用统一的逻辑实现代码处理不同的对象,从而执行不同的行为。
- 继承:在多态中必须存在有继承关系的子类和父类。
- 重写:子类对父类中某些方法进行重新定义,在调用这些方法时就会调用子类的方法。
- 向上转型:在多态中需要将子类的引用赋给父类对象,只有这样该引用才既能可以调用父类的方法,又能调用子类的方法。
重载(Overload)和重写(Override)的区别是什么?
方法的重载和重写都是实现多态的方式,区别在于前者实现的是编译时的多态性,而后者实现的是运行时的多态性。
- 重写发生在子类与父类之间, 重写方法返回值和形参都不能改变,与方法返回值和访问修饰符无关,即重写的方法不能根据返回类型进行区分。即外壳不变,核心重写!
- 重载(overloading) 是在一个类里面,方法名字相同,而参数不同。返回类型可以相同也可以不同。每个重载的方法(或者构造函数)都必须有一个独一无二的参数类型列表。最常用的地方就是构造器的重载。
抽象类和接口的区别是什么?
语法层面上的区别:
- 抽象类可以提供成员方法的实现细节,而接口中只能存在public abstract 方法;
- 抽象类中的成员变量可以是各种类型的,而接口中的成员变量只能是public static final类型的;
- 接口中不能含有静态代码块以及静态方法,而抽象类可以有静态代码块和静态方法;
- 一个类只能继承一个抽象类,而一个类却可以实现多个接口。
设计层面上的区别:
- 抽象类是对一种事物的抽象,即对类抽象,而接口是对行为的抽象。抽象类是对整个类整体进行抽象,包括属性、行为,但是接口却是对类局部(行为)进行抽象。
- 设计层面不同,抽象类作为很多子类的父类,它是一种模板式设计。而接口是一种行为规范,它是一种辐射式设计。
== 和 equals 区别是什么?
==
常用于相同的基本数据类型之间的比较,也可用于相同类型的对象之间的比较;
- 如果
==
比较的是基本数据类型,那么比较的是两个基本数据类型的值是否相等; - 如果
==
是比较的两个对象,那么比较的是两个对象的引用,也就是判断两个对象是否指向了同一块内存区域;
equals方法主要用于两个对象之间,检测一个对象是否等于另一个对象
看一看Object类中equals方法的源码:
public boolean equals(Object obj) {
return (this == obj);
}
它的作用也是判断两个对象是否相等,一般有两种使用情况:
- 情况1,类没有覆盖equals()方法。则通过equals()比较该类的两个对象时,等价于通过“==”比较这两个对象。
- 情况2,类覆盖了equals()方法。一般,我们都覆盖equals()方法来两个对象的内容相等;若它们的内容相等,则返回true(即,认为这两个对象相等)。
java语言规范要求equals方法具有以下特性:
- 自反性。对于任意不为null的引用值x,x.equals(x)一定是true。
- 对称性。对于任意不为null的引用值x和y,当且仅当x.equals(y)是true时,y.equals(x)也是true。
- 传递性。对于任意不为null的引用值x、y和z,如果x.equals(y)是true,同时y.equals(z)是true,那么x.equals(z)一定是true。
- 一致性。对于任意不为null的引用值x和y,如果用于equals比较的对象信息没有被修改的话,多次调用时x.equals(y)要么一致地返回true要么一致地返回false。
- 对于任意不为null的引用值x,x.equals(null)返回false。
为什么要有 hashCode?
以“HashSet 如何检查重复”为例子来说明为什么要有 hashCode:
当你把对象加入 HashSet 时,HashSet 会先计算对象的 hashcode 值来判断对象加入的位置,同时也会与其他已经加入的对象的 hashcode 值作比较,如果没有相符的hashcode,HashSet会假设对象没有重复出现。
但是如果发现有相同 hashcode 值的对象,这时会调用 equals()方法来检查 hashcode 相等的对象是否真的相同。如果两者相同,HashSet 就不会让其加入操作成功。如果不同的话,就会重新散列到其他位置。这样我们就大大减少了 equals 的次数,相应就大大提高了执行速度。
hashCode(),equals()两种方法是什么关系?
要弄清楚这两种方法的关系,就需要对哈希表有一个基本的认识。其基本的结构如下:
对于hashcode方法,会返回一个哈希值,哈希值对数组的长度取余后会确定一个存储的下标位置,如图中用数组括起来的第一列。
不同的哈希值取余之后的结果可能是相同的,用equals方法判断是否为相同的对象,不同则在链表中插入。
则有hashCode()与equals()的相关规定:
- 如果两个对象相等,则hashcode一定也是相同的;
- 两个对象相等,对两个对象分别调用equals方法都返回true;
- 两个对象有相同的hashcode值,它们也不一定是相等的。
字符型常量和字符串常量的区别
形式上: 字符常量是单引号引起的一个字符,字符串常量是双引号引起的若干个字符;
含义上: 字符常量相当于一个整型值( ASCII 值),可以参加表达式运算;字符串常量代表一个地址值(该字符串在内存中存放位置,相当于对象);
占内存大小:字符常量只占2个字节;字符串常量占若干个字节(至少一个字符结束标志) (注意: char 在Java中占两个字节)。
什么是字符串常量池?
java中常量池的概念主要有三个:全局字符串常量池
,class文件常量池
,运行时常量池
。我们现在所说的就是全局字符串常量池
,对这个想弄明白的同学可以看这篇Java中几种常量池的区分。
JVM为了提升性能和减少内存开销,避免字符的重复创建,其维护了一块特殊的内存空间,即字符串池,当需要使用字符串时,先去字符串池中查看该字符串是否已经存在,如果存在,则可以直接使用,如果不存在,初始化,并将该字符串放入字符串常量池中。
字符串常量池的位置也是随着JDK版本的不同而不同。在JDK6中,常量池的位置在永久代(方法区)中,此时常量池中存储的是对象。在JDK7中,常量池的位置在堆中,此时,常量池存储的就是引用了。在JDK8中,永久代(方法区)被元空间取代了。
String有哪些特性?
不变性:String 是只读字符串,是一个典型的 immutable 对象,对它进行任何操作,其实都是创建一个新的对象,再把引用指向该对象。不变模式的主要作用在于当一个对象需要被多线程共享并频繁访问时,可以保证数据的一致性;
常量池优化:String 对象创建之后,会在字符串常量池中进行缓存,如果下次创建同样的对象时,会直接返回缓存的引用;
final:使用 final 来定义 String 类,表示 String 类不能被继承,提高了系统的安全性。
在使用 HashMap 的时候,用 String 做 key 有什么好处?
HashMap 内部实现是通过 key 的 hashcode 来确定 value 的存储位置,因为字符串是不可变的,所以当创建字符串时,它的 hashcode 被缓存下来,不需要再次计算,所以相比于其他对象更快。
包装类型是什么?基本类型和包装类型有什么区别?
Java 为每一个基本数据类型都引入了对应的包装类型(wrapper class),int 的包装类就是 Integer,从 Java 5 开始引入了自动装箱/拆箱机制,把基本类型转换成包装类型的过程叫做装箱(boxing);反之,把包装类型转换成基本类型的过程叫做拆箱(unboxing),使得二者可以相互转换。
Java 为每个原始类型提供了包装类型:
原始类型: boolean,char,byte,short,int,long,float,double
包装类型:Boolean,Character,Byte,Short,Integer,Long,Float,Double
基本类型和包装类型的区别主要有以下 几点:
包装类型可以为 null,而基本类型不可以。它使得包装类型可以应用于 POJO 中,而基本类型则不行。那为什么 POJO 的属性必须要用包装类型呢?《阿里巴巴 Java 开发手册》上有详细的说明, 数据库的查询结果可能是 null,如果使用基本类型的话,因为要自动拆箱(将包装类型转为基本类型,比如说把 Integer 对象转换成 int 值),就会抛出
NullPointerException
的异常。包装类型可用于泛型,而基本类型不可以。泛型不能使用基本类型,因为使用基本类型时会编译出错。
List<int> list = new ArrayList<>(); // 提示 Syntax error, insert "Dimensions" to complete ReferenceType List<Integer> list = new ArrayList<>();
因为泛型在编译时会进行类型擦除,最后只保留原始类型,而原始类型只能是 Object 类及其子类——基本类型是个特例。
- 基本类型比包装类型更高效。基本类型在栈中直接存储的具体数值,而包装类型则存储的是堆中的引用。 很显然,相比较于基本类型而言,包装类型需要占用更多的内存空间和更长的访问路径。
解释一下自动装箱和自动拆箱?
自动装箱:将基本数据类型重新转化为对象
public class Test {
public static void main(String[] args) {
// 声明一个Integer对象,用到了自动的装箱:解析为:Integer num = Integer.valueOf(9);
Integer num = 9;
}
}
9是属于基本数据类型的,原则上它是不能直接赋值给一个对象Integer的。但JDK1.5开始引入了自动装箱/拆箱机制,就可以进行这样的声明,自动将基本数据类型转化为对应的封装类型,成为一个对象以后就可以调用对象所声明的所有的方法。
自动拆箱:将对象重新转化为基本数据类型
public class Test {
public static void main(String[] args) {
/ /声明一个Integer对象
Integer num = 9;
// 进行计算时隐含的有自动拆箱
System.out.print(num--);
}
}
因为对象是不能直接进行运算的,而是要转化为基本数据类型后才能进行加减乘除。
int 和 Integer 有什么区别?
- Integer是int的包装类;int是基本数据类型;
- Integer变量必须实例化后才能使用;int变量不需要;
- Integer实际是对象的引用,指向此new的Integer对象;int是直接存储数据值 ;
- Integer的默认值是null;int的默认值是0。
两个new生成的Integer变量的对比
由于Integer变量实际上是对一个Integer对象的引用,所以两个通过new生成的Integer变量永远是不相等的(因为new生成的是两个对象,其内存地址不同)。
Integer i = new Integer(10000);
Integer j = new Integer(10000);
System.out.print(i == j); //false
Integer变量和int变量的对比
Integer变量和int变量比较时,只要两个变量的值是相等的,则结果为true(因为包装类Integer和基本数据类型int比较时,java会自动拆包装为int,然后进行比较,实际上就变为两个int变量的比较)
int a = 10000;
Integer b = new Integer(10000);
Integer c=10000;
System.out.println(a == b); // true
System.out.println(a == c); // true
非new生成的Integer变量和new Integer()生成变量的对比
非new生成的Integer变量和new Integer()生成的变量比较时,结果为false。(因为非new生成的Integer变量指向的是java常量池中的对象,而new Integer()生成的变量指向堆中新建的对象,两者在内存中的地址不同)
Integer b = new Integer(10000);
Integer c=10000;
System.out.println(b == c); // false
两个非new生成的Integer对象的对比
对于两个非new生成的Integer对象,进行比较时,如果两个变量的值在区间-128到127之间,则比较结果为true,如果两个变量的值不在此区间,则比较结果为false
Integer i = 100;
Integer j = 100;
System.out.print(i == j); //true
Integer i = 128;
Integer j = 128;
System.out.print(i == j); //false
当值在 -128 ~ 127之间时,java会进行自动装箱,然后会对值进行缓存,如果下次再有相同的值,会直接在缓存中取出使用。缓存是通过Integer的内部类IntegerCache来完成的。当值超出此范围,会在堆中new出一个对象来存储。
给一个Integer对象赋一个int值的时候,会调用Integer类的静态方法valueOf,源码如下:
public static Integer valueOf(String s, int radix) throws NumberFormatException {
return Integer.valueOf(parseInt(s,radix));
}
/**
* (1)在-128~127之内:静态常量池中cache数组是static final类型,cache数组对象会被存储于静态常量池中。
* cache数组里面的元素却不是static final类型,而是cache[k] = new Integer(j++),
* 那么这些元素是存储于堆中,只是cache数组对象存储的是指向了堆中的Integer对象(引用地址)
*
* (2)在-128~127 之外:新建一个 Integer对象,并返回。
*/
public static Integer valueOf(int i) {
assert IntegerCache.high >= 127;
if (i >= IntegerCache.low && i <= IntegerCache.high) {
return IntegerCache.cache[i + (-IntegerCache.low)];
}
return new Integer(i);
}
IntegerCache是Integer的内部类,源码如下:
/**
* 缓存支持自动装箱的对象标识语义 -128和127(含)。
* 缓存在第一次使用时初始化。 缓存的大小可以由-XX:AutoBoxCacheMax = <size>选项控制。
* 在VM初始化期间,java.lang.Integer.IntegerCache.high属性可以设置并保存在私有系统属性中
*/
private static class IntegerCache {
static final int low = -128;
static final int high;
static final Integer cache[];
static {
// high value may be configured by property
int h = 127;
String integerCacheHighPropValue =
sun.misc.VM.getSavedProperty("java.lang.Integer.IntegerCache.high");
if (integerCacheHighPropValue != null) {
int i = parseInt(integerCacheHighPropValue);
i = Math.max(i, 127);
// Maximum array size is Integer.MAX_VALUE
h = Math.min(i, Integer.MAX_VALUE - (-low) -1);
}
high = h;
cache = new Integer[(high - low) + 1];
int j = low;
for(int k = 0; k < cache.length; k++) {
cache[k] = new Integer(j++); // 创建一个对象
}
}
private IntegerCache() {}
}
什么是反射?
反射是在运行状态中,对于任意一个类,都能够知道这个类的所有属性和方法;对于任意一个对象,都能够调用它的任意一个方法和属性;这种动态获取的信息以及动态调用对象的方法的功能称为 Java 语言的反射机制。
如何获取反射中的Class对象?
Class.forName(“类的路径”);当你知道该类的全路径名时,你可以使用该方法获取 Class 类对象。
Class clz = Class.forName("java.lang.String");
类名.class。这种方法只适合在编译前就知道操作的 Class。
Class clz = String.class;
对象名.getClass()。
String str = new String("Hello"); Class clz = str.getClass();
如果是基本类型的包装类,可以调用包装类的Type属性来获得该包装类的Class对象。
Java反射的作用与原理
定义
反射机制是在运行时,对于任意一个类,都能够知道这个类的所有属性和方法;对于任意个对象,都能够调用它的任意一个方法。在Java中,只要给定类的名字,就可以通过反射机制来获得类的所有信息。
反射的实现方式
- Class.forName(“类的路径”);
- 类名.class
- 对象名.getClass
- 基于类型的包装类,可以调用包装类的Type属性来获得该包装类的Class对象
实现Java反射的类
- Class:表示正在运行的Java应用程序中的类和接口(所有获取对象的信息都需要Class类来实现);
- Field:提供有关类和接口的属性信息,以及对它的动态访问权限;
- Constructor:提供关于类的单个构造方法的信息以及访问权限;
- Method:提供类或接口中某个方法的信息。
反射机制的优缺点
优点
- 能够运行时动态获取类的示例,提高灵活性;
- 与动态编译结合(如加载MySQL数据库驱动)。
缺点
- 使用反射性能较低,需要解析字节码,将内存中的对象进行解析;
- 相对不安全,破坏了封装性(通过反射可以获取私有的方法和属性)。
针对反射性能低的解决方案
- 通过setAccessible关闭JDK的安全检查来提升反射速度;
- 多次创建一个类的实例时,有缓存会快很多;
- ReflectASM工具类,通过字节码生成的方式加快反射速度。
反射使用的步骤?
获取想要操作的类的Class对象,这是反射的核心,通过Class对象我们可以任意调用类的方法。
调用 Class 类中的方法,既就是反射的使用阶段。
使用反射 API 来操作这些信息。
具体可以看下面的例子:
public class Apple {
private int price;
public int getPrice() {
return price;
}
public void setPrice(int price) {
this.price = price;
}
public static void main(String[] args) throws Exception{
//正常的调用
Apple apple = new Apple();
apple.setPrice(5);
System.out.println("Apple Price:" + apple.getPrice());
//使用反射调用
Class clz = Class.forName("com.chenshuyi.api.Apple");
Method setPriceMethod = clz.getMethod("setPrice", int.class);
Constructor appleConstructor = clz.getConstructor();
Object appleObj = appleConstructor.newInstance();
setPriceMethod.invoke(appleObj, 14);
Method getPriceMethod = clz.getMethod("getPrice");
System.out.println("Apple Price:" + getPriceMethod.invoke(appleObj));
}
}
从代码中可以看到我们使用反射调用了 setPrice 方法,并传递了 14 的值。之后使用反射调用了 getPrice 方法,输出其价格。上面的代码整个的输出结果是:
Apple Price:5
Apple Price:14
从这个简单的例子可以看出,一般情况下我们使用反射获取一个对象的步骤:
- 获取类的 Class 对象实例
Class clz = Class.forName("com.zhenai.api.Apple");
- 根据 Class 对象实例获取 Constructor 对象
Constructor appleConstructor = clz.getConstructor();
- 使用 Constructor 对象的 newInstance 方法获取反射类对象
Object appleObj = appleConstructor.newInstance();
而如果要调用某一个方法,则需要经过下面的步骤:
- 获取方法的 Method 对象
Method setPriceMethod = clz.getMethod("setPrice", int.class);
- 利用 invoke 方法调用方法
setPriceMethod.invoke(appleObj, 14);
为什么引入反射概念?反射机制的应用有哪些?
我们来看一下 Oracle 官方文档中对反射的描述:
从 Oracle 官方文档中可以看出,反射主要应用在以下几方面:
- 反射让开发人员可以通过外部类的全路径名创建对象,并使用这些类,实现一些扩展的功能。
- 反射让开发人员可以枚举出类的全部成员,包括构造函数、属性、方法。以帮助开发者写出正确的代码。
- 测试时可以利用反射 API 访问类的私有成员,以保证测试代码覆盖率。
也就是说,Oracle 希望开发者将反射作为一个工具,用来帮助程序员实现本不可能实现的功能。
举两个最常见使用反射的例子,来说明反射机制的强大之处:
第一种:JDBC 的数据库的连接
在JDBC 的操作中,如果要想进行数据库的连接,则必须按照以上的几步完成
- 通过Class.forName()加载数据库的驱动程序 (通过反射加载,前提是引入相关了Jar包);
- 通过 DriverManager 类进行数据库的连接,连接的时候要输入数据库的连接地址、用户名、密码;
- 通过Connection 接口接收连接。
public class ConnectionJDBC {
/**
* @param args
*/
//驱动程序就是之前在classpath中配置的JDBC的驱动程序的JAR 包中
public static final String DBDRIVER = "com.mysql.jdbc.Driver";
//连接地址是由各个数据库生产商单独提供的,所以需要单独记住
public static final String DBURL = "jdbc:mysql://localhost:3306/test";
//连接数据库的用户名
public static final String DBUSER = "root";
//连接数据库的密码
public static final String DBPASS = "";
public static void main(String[] args) throws Exception {
Connection con = null; //表示数据库的连接对象
Class.forName(DBDRIVER); //1、使用CLASS 类加载驱动程序 ,反射机制的体现
con = DriverManager.getConnection(DBURL,DBUSER,DBPASS); //2、连接数据库
System.out.println(con);
con.close(); // 3、关闭数据库
}
第二种:Spring 框架的使用,最经典的就是xml的配置模式。
Spring 通过 XML 配置模式装载 Bean 的过程:
- 将程序内所有 XML 或 Properties 配置文件加载入内存中;
- Java类里面解析xml或properties里面的内容,得到对应实体类的字节码字符串以及相关的属性信息;
- 使用反射机制,根据这个字符串获得某个类的Class实例;
- 动态配置实例的属性。
Spring这样做的好处是:
- 不用每一次都要在代码里面去new或者做其他的事情;
- 以后要改的话直接改配置文件,代码维护起来就很方便了;
- 有时为了适应某些需求,Java类里面不一定能直接调用另外的方法,可以通过反射机制来实现。
模拟 Spring 加载 XML 配置文件:
public class BeanFactory {
private Map<String, Object> beanMap = new HashMap<String, Object>();
/**
* bean工厂的初始化.
* @param xml xml配置文件
*/
public void init(String xml) {
try {
//读取指定的配置文件
SAXReader reader = new SAXReader();
ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
//从class目录下获取指定的xml文件
InputStream ins = classLoader.getResourceAsStream(xml);
Document doc = reader.read(ins);
Element root = doc.getRootElement();
Element foo;
//遍历bean
for (Iterator i = root.elementIterator("bean"); i.hasNext();) {
foo = (Element) i.next();
//获取bean的属性id和class
Attribute id = foo.attribute("id");
Attribute cls = foo.attribute("class");
//利用Java反射机制,通过class的名称获取Class对象
Class bean = Class.forName(cls.getText());
//获取对应class的信息
java.beans.BeanInfo info = java.beans.Introspector.getBeanInfo(bean);
//获取其属性描述
java.beans.PropertyDescriptor pd[] = info.getPropertyDescriptors();
//设置值的方法
Method mSet = null;
//创建一个对象
Object obj = bean.newInstance();
//遍历该bean的property属性
for (Iterator ite = foo.elementIterator("property"); ite.hasNext();) {
Element foo2 = (Element) ite.next();
//获取该property的name属性
Attribute name = foo2.attribute("name");
String value = null;
//获取该property的子元素value的值
for(Iterator ite1 = foo2.elementIterator("value"); ite1.hasNext();) {
Element node = (Element) ite1.next();
value = node.getText();
break;
}
for (int k = 0; k < pd.length; k++) {
if (pd[k].getName().equalsIgnoreCase(name.getText())) {
mSet = pd[k].getWriteMethod();
//利用Java的反射极致调用对象的某个set方法,并将值设置进去
mSet.invoke(obj, value);
}
}
}
//将对象放入beanMap中,其中key为id值,value为对象
beanMap.put(id.getText(), obj);
}
} catch (Exception e) {
System.out.println(e.toString());
}
}
//other codes
}
反射机制的原理是什么?
Class actionClass=Class.forName(“MyClass”);
Object action=actionClass.newInstance();
Method method = actionClass.getMethod(“myMethod”,null);
method.invoke(action,null);
上面就是最常见的反射使用的例子,前两行实现了类的装载、链接和初始化(newInstance方法实际上也是使用反射调用了
因反射原理较复杂,下面简要描述下流程,想要详细了解的小伙伴,可以看这篇文章:https://www.cnblogs.com/yougewe/p/10125073.html
- 反射获取类实例 Class.forName(),并没有将实现留给了java,而是交给了jvm去加载!主要是先获取 ClassLoader, 然后调用 native 方法,获取信息,加载类则是回调 java.lang.ClassLoader。最后,jvm又会回调 ClassLoader 进类加载!
- newInstance() 主要做了三件事:
- 权限检测,如果不通过直接抛出异常;
- 查找无参构造器,并将其缓存起来;
- 调用具体方法的无参构造方法,生成实例并返回。
- 获取Method对象,
上面的Class对象是在加载类时由JVM构造的,JVM为每个类管理一个独一无二的Class对象,这份Class对象里维护着该类的所有Method,Field,Constructor的cache,这份cache也可以被称作根对象。
每次getMethod获取到的Method对象都持有对根对象的引用,因为一些重量级的Method的成员变量(主要是MethodAccessor),我们不希望每次创建Method对象都要重新初始化,于是所有代表同一个方法的Method对象都共享着根对象的MethodAccessor,每一次创建都会调用根对象的copy方法复制一份:
Method copy() {
Method res = new Method(clazz, name, parameterTypes, returnType,
exceptionTypes, modifiers, slot, signature,
annotations, parameterAnnotations, annotationDefault);
res.root = this;
res.methodAccessor = methodAccessor;
return res;
}
- 调用invoke()方法。调用invoke方法的流程如下:
调用Method.invoke之后,会直接去调MethodAccessor.invoke。MethodAccessor就是上面提到的所有同名method共享的一个实例,由ReflectionFactory创建。
创建机制采用了一种名为inflation的方式(JDK1.4之后):如果该方法的累计调用次数<=15,会创建出NativeMethodAccessorImpl,它的实现就是直接调用native方法实现反射;如果该方法的累计调用次数>15,会由java代码创建出字节码组装而成的MethodAccessorImpl。(是否采用inflation和15这个数字都可以在jvm参数中调整)
以调用MyClass.myMethod(String s)为例,生成出的MethodAccessorImpl字节码翻译成Java代码大致如下:
public class GeneratedMethodAccessor1 extends MethodAccessorImpl {
public Object invoke(Object obj, Object[] args) throws Exception {
try {
MyClass target = (MyClass) obj;
String arg0 = (String) args[0];
target.myMethod(arg0);
} catch (Throwable t) {
throw new InvocationTargetException(t);
}
}
}
Java中的泛型是什么 ?
泛型是 JDK1.5 的一个新特性,泛型就是将类型参数化,其在编译时才确定具体的参数。这种参数类型可以用在类、接口和方法的创建中,分别称为泛型类、泛型接口、泛型方法。
使用泛型的好处是什么?
远在 JDK 1.4 版本的时候,那时候是没有泛型的概念的,如果使用 Object 来实现通用、不同类型的处理,有这么两个缺点:
- 每次使用时都需要强制转换成想要的类型
- 在编译时编译器并不知道类型转换是否正常,运行时才知道,不安全。
如这个例子:
List list = new ArrayList();
list.add("www.cnblogs.com");
list.add(23);
String name = (String)list.get(0);
String number = (String)list.get(1); //ClassCastException
上面的代码在运行时会发生强制类型转换异常。这是因为我们在存入的时候,第二个是一个 Integer 类型,但是取出来的时候却将其强制转换为 String 类型了。Sun 公司为了使 Java 语言更加安全,减少运行时异常的发生。于是在 JDK 1.5 之后推出了泛型的概念。
根据《Java 编程思想》中的描述,泛型出现的动机在于:有许多原因促成了泛型的出现,而最引人注意的一个原因,就是为了创建容器类。
使用泛型的好处有以下几点:
类型安全
- 泛型的主要目标是提高 Java 程序的类型安全
- 编译时期就可以检查出因 Java 类型不正确导致的 ClassCastException 异常
- 符合越早出错代价越小原则
消除强制类型转换
- 泛型的一个附带好处是,使用时直接得到目标类型,消除许多强制类型转换
- 所得即所需,这使得代码更加可读,并且减少了出错机会
潜在的性能收益
- 由于泛型的实现方式,支持泛型(几乎)不需要 JVM 或类文件更改
- 所有工作都在编译器中完成
- 编译器生成的代码跟不使用泛型(和强制类型转换)时所写的代码几乎一致,只是更能确保类型安全而已
Java泛型的原理是什么 ? 什么是类型擦除 ?
泛型是一种语法糖,泛型这种语法糖的基本原理是类型擦除。Java中的泛型基本上都是在编译器这个层次来实现的,也就是说:泛型只存在于编译阶段,而不存在于运行阶段。在编译后的 class 文件中,是没有泛型这个概念的。
类型擦除:使用泛型的时候加上的类型参数,编译器在编译的时候去掉类型参数。
例如:
public class Caculate<T> {
private T num;
}
我们定义了一个泛型类,定义了一个属性成员,该成员的类型是一个泛型类型,这个 T 具体是什么类型,我们也不知道,它只是用于限定类型的。反编译一下这个 Caculate 类:
public class Caculate{
public Caculate(){}
private Object num;
}
发现编译器擦除 Caculate 类后面的两个尖括号,并且将 num 的类型定义为 Object 类型。
那么是不是所有的泛型类型都以 Object 进行擦除呢?大部分情况下,泛型类型都会以 Object 进行替换,而有一种情况则不是。那就是使用到了extends和super语法的有界类型,如:
public class Caculate<T extends String> {
private T num;
}
这种情况的泛型类型,num 会被替换为 String 而不再是 Object。这是一个类型限定的语法,它限定 T 是 String 或者 String 的子类,也就是你构建 Caculate 实例的时候只能限定 T 为 String 或者 String 的子类,所以无论你限定 T 为什么类型,String 都是父类,不会出现类型不匹配的问题,于是可以使用 String 进行类型擦除。
实际上编译器会正常的将使用泛型的地方编译并进行类型擦除,然后返回实例。但是除此之外的是,如果构建泛型实例时使用了泛型语法,那么编译器将标记该实例并关注该实例后续所有方法的调用,每次调用前都进行安全检查,非指定类型的方法都不能调用成功。
实际上编译器不仅关注一个泛型方法的调用,它还会为某些返回值为限定的泛型类型的方法进行强制类型转换,由于类型擦除,返回值为泛型类型的方法都会擦除成 Object 类型,当这些方法被调用后,编译器会额外插入一行 checkcast 指令用于强制类型转换。这一个过程就叫做『泛型翻译』。
什么是泛型中的限定通配符和非限定通配符 ?
限定通配符对类型进行了限制。有两种限定通配符,一种是<? extends T>它通过确保类型必须是T的子类来设定类型的上界,另一种是<? super T>它通过确保类型必须是T的父类来设定类型的下界。泛型类型必须用限定内的类型来进行初始化,否则会导致编译错误。
非限定通配符 ?,可以用任意类型来替代。如List<?>
的意思是这个集合是一个可以持有任意类型的集合,它可以是List<A>
,也可以是List<B>
,或者List<C>
等等。
List<? extends T>和List <? super T>之间有什么区别 ?
这两个List的声明都是限定通配符的例子,List<? extends T>可以接受任何继承自T的类型的List,而List<? super T>可以接受任何T的父类构成的List。例如List<? extends Number>可以接受List
可以把List<String>
传递给一个接受List<Object>
参数的方法吗?
不可以。真这样做的话会导致编译错误。因为List<Object>
可以存储任何类型的对象包括String, Integer等等,而List<String>
却只能用来存储String。
List<Object> objectList;
List<String> stringList;
objectList = stringList; //compilation error incompatible types
Array中可以用泛型吗?
不可以。这也是为什么 Joshua Bloch 在 《Effective Java》一书中建议使用 List 来代替 Array,因为 List 可以提供编译期的类型安全保证,而 Array 却不能。
判断ArrayList<String>
与ArrayList<Integer>
是否相等?
ArrayList<String> a = new ArrayList<String>();
ArrayList<Integer> b = new ArrayList<Integer>();
Class c1 = a.getClass();
Class c2 = b.getClass();
System.out.println(c1 == c2);
输出的结果是 true。因为无论对于ArrayList<String>
还是ArrayList<Integer>
,它们的Class类型都是一致的,都是ArrayList.class。
那它们声明时指定的 String 和 Integer 到底体现在哪里呢?
答案是体现在类编译的时候。当 JVM 进行类编译时,会进行泛型检查,如果一个集合被声明为 String 类型,那么它往该集合存取数据的时候就会对数据进行判断,从而避免存入或取出错误的数据。
Java序列化与反序列化是什么?
Java序列化是指把Java对象转换为字节序列的过程,而Java反序列化是指把字节序列恢复为Java对象的过程:
序列化:序列化是把对象转换成有序字节流,以便在网络上传输或者保存在本地文件中。核心作用是对象状态的保存与重建。我们都知道,Java对象是保存在JVM的堆内存中的,也就是说,如果JVM堆不存在了,那么对象也就跟着消失了。
而序列化提供了一种方案,可以让你在即使JVM停机的情况下也能把对象保存下来的方案。就像我们平时用的U盘一样。把Java对象序列化成可存储或传输的形式(如二进制流),比如保存在文件中。这样,当再次需要这个对象的时候,从文件中读取出二进制流,再从二进制流中反序列化出对象。
反序列化:客户端从文件中或网络上获得序列化后的对象字节流,根据字节流中所保存的对象状态及描述信息,通过反序列化重建对象。
为什么需要序列化与反序列化
简要描述:对内存中的对象进行持久化或网络传输, 这个时候都需要序列化和反序列化
深入描述:
- 对象序列化可以实现分布式对象
主要应用例如:RMI(即远程调用Remote Method Invocation)要利用对象序列化运行远程主机上的服务,就像在本地机上运行对象时一样。
- java对象序列化不仅保留一个对象的数据,而且递归保存对象引用的每个对象的数据
可以将整个对象层次写入字节流中,可以保存在文件中或在网络连接上传递。利用对象序列化可以进行对象的”深复制”,即复制对象本身及引用的对象本身。序列化一个对象可能得到整个对象序列。
- 序列化可以将内存中的类写入文件或数据库中
比如:将某个类序列化后存为文件,下次读取时只需将文件中的数据反序列化就可以将原先的类还原到内存中。也可以将类序列化为流数据进行传输。
总的来说就是将一个已经实例化的类转成文件存储,下次需要实例化的时候只要反序列化即可将类实例化到内存中并保留序列化时类中的所有变量和状态。
- 对象、文件、数据,有许多不同的格式,很难统一传输和保存
序列化以后就都是字节流了,无论原来是什么东西,都能变成一样的东西,就可以进行通用的格式传输或保存,传输结束以后,要再次使用,就进行反序列化还原,这样对象还是对象,文件还是文件。
序列化实现的方式有哪些
实现Serializable接口或者Externalizable接口。
Serializable接口
类通过实现 java.io.Serializable 接口以启用其序列化功能。可序列化类的所有子类型本身都是可序列化的。序列化接口没有方法或字段,仅用于标识可序列化的语义。
Externalizable接口
Externalizable继承自Serializable,该接口中定义了两个抽象方法:writeExternal()
与readExternal()
。
当使用Externalizable接口来进行序列化与反序列化的时候需要开发人员重写writeExternal()
与readExternal()
方法。否则所有变量的值都会变成默认值。
两种序列化的对比
实现Serializable接口 | 实现Externalizable接口 |
---|---|
系统自动存储必要的信息 | 程序员决定存储哪些信息 |
Java内建支持,易于实现,只需要实现该接口即可,无需任何代码支持 | 必须实现接口内的两个方法 |
性能略差 | 性能略好 |
serialVersionUID
serialVersionUID 用来表明类的不同版本间的兼容性
Java的序列化机制是通过在运行时判断类的serialVersionUID来验证版本一致性的。在进行反序列化时,JVM会把传来的字节流中的serialVersionUID与本地相应实体(类)的serialVersionUID进行比较,如果相同就认为是一致的,可以进行反序列化,否则就会出现序列化版本不一致的异常。
为什么还要显示指定serialVersionUID的值
如果不显示指定serialVersionUID, JVM在序列化时会根据属性自动生成一个serialVersionUID, 然后与属性一起序列化, 再进行持久化或网络传输. 在反序列化时, JVM会再根据属性自动生成一个新版serialVersionUID, 然后将这个新版serialVersionUID与序列化时生成的旧版serialVersionUID进行比较, 如果相同则反序列化成功, 否则报错。
如果显示指定了, JVM在序列化和反序列化时仍然都会生成一个serialVersionUID, 但值为我们显示指定的值, 这样在反序列化时新旧版本的serialVersionUID就一致了。
在实际开发中, 不显示指定serialVersionUID的情况会导致什么问题? 如果我们的类写完后不再修改, 那当然不会有问题, 但这在实际开发中是不可能的, 我们的类会不断迭代, 一旦类被修改了, 那旧对象反序列化就会报错. 所以在实际开发中, 我们都会显示指定一个serialVersionUID, 值是多少无所谓, 只要不变就行。
Java 序列化中如果有些字段不想进行序列化,怎么办?
对于不想进行序列化的变量,使用 transient 关键字修饰。
transient
关键字的作用是控制变量的序列化,在变量声明前加上该关键字,可以阻止该变量被序列化到文件中,在被反序列化后,transient
变量的值被设为初始值,如 int 型的是 0,对象型的是 null。transient 只能修饰变量,不能修饰类和方法。
静态变量会被序列化吗
不会。因为序列化是针对对象而言的, 而静态变量优先于对象存在, 随着类的加载而加载, 所以不会被序列化。
看到这个结论, 是不是有人会问, serialVersionUID也被static修饰, 为什么serialVersionUID会被序列化? 其实serialVersionUID属性并没有被序列化, JVM在序列化对象时会自动生成一个serialVersionUID, 然后将我们显示指定的serialVersionUID属性值赋给自动生成的serialVersionUID。
Error 和 Exception 区别是什么?
Java 中,所有的异常都有一个共同的祖先 java.lang
包中的 Throwable
类。Throwable
类有两个重要的子类 Exception
(异常)和 Error
(错误)。
Exception
和 Error
二者都是 Java 异常处理的重要子类,各自都包含大量子类。
Exception
:程序本身可以处理的异常,可以通过catch
来进行捕获,通常遇到这种错误,应对其进行处理,使应用程序可以继续正常运行。Exception
又可以分为运行时异常(RuntimeException, 又叫非受检查异常)和非运行时异常(又叫受检查异常) 。Error
:Error
属于程序无法处理的错误 ,我们没办法通过catch
来进行捕获 。例如,系统崩溃,内存不足,堆栈溢出等,编译器不会对这类错误进行检测,一旦这类错误发生,通常应用程序会被终止,仅靠应用程序本身无法恢复。
非受检查异常(运行时异常)和受检查异常(一般异常)区别是什么?
非受检查异常:包括 RuntimeException
类及其子类,表示 JVM 在运行期间可能出现的异常。 Java 编译器不会检查运行时异常。例如:NullPointException(空指针)
、NumberFormatException(字符串转换为数字)
、IndexOutOfBoundsException(数组越界)
、ClassCastException(类转换异常)
、ArrayStoreException(数据存储异常,操作数组时类型不一致)
等。
受检查异常:是Exception 中除 RuntimeException
及其子类之外的异常。 Java 编译器会检查受检查异常。常见的受检查异常有: IO 相关的异常、ClassNotFoundException
、SQLException
等。
非受检查异常和受检查异常之间的区别:是否强制要求调用者必须处理此异常,如果强制要求调用者必须进行处理,那么就使用受检查异常,否则就选择非受检查异常。
try-catch-finally 中哪个部分可以省略?
catch 可以省略。更为严格的说法其实是:try只适合处理运行时异常,try+catch适合处理运行时异常+普通异常。也就是说,如果你只用try去处理普通异常却不加以catch处理,编译是通不过的,因为编译器硬性规定,普通异常如果选择捕获,则必须用catch显示声明以便进一步处理。而运行时异常在编译时没有如此规定,所以catch可以省略,你加上catch编译器也觉得无可厚非。
理论上,编译器看任何代码都不顺眼,都觉得可能有潜在的问题,所以你即使对所有代码加上try,代码在运行期时也只不过是在正常运行的基础上加一层皮。但是你一旦对一段代码加上try,就等于显示地承诺编译器,对这段代码可能抛出的异常进行捕获而非向上抛出处理。如果是普通异常,编译器要求必须用catch捕获以便进一步处理;如果运行时异常,捕获然后丢弃并且+finally扫尾处理,或者加上catch捕获以便进一步处理。
至于加上finally,则是在不管有没捕获异常,都要进行的“扫尾”处理。
try-catch-finally 中,如果 catch 中 return 了,finally 还会执行吗?
会执行,在 return 前执行。
在 finally 中改变返回值的做法是不好的,因为如果存在 finally 代码块,try中的 return 语句不会立马返回调用者,而是记录下返回值待 finally 代码块执行完毕之后再向调用者返回其值,然后如果在 finally 中修改了返回值,就会返回修改后的值。显然,在 finally 中返回或者修改返回值会对程序造成很大的困扰,Java 中也可以通过提升编译器的语法检查级别来产生警告或错误。
代码示例1:
public static int getInt() {
int a = 10;
try {
System.out.println(a / 0);
a = 20;
} catch (ArithmeticException e) {
a = 30;
return a;
/*
* return a 在程序执行到这一步的时候,这里不是return a 而是 return 30;这个返回路径就形成了
* 但是呢,它发现后面还有finally,所以继续执行finally的内容,a=40
* 再次回到以前的路径,继续走return 30,形成返回路径之后,这里的a就不是a变量了,而是常量30
*/
} finally {
a = 40;
}
return a;
}
//执行结果:30
代码示例2:
public static int getInt() {
int a = 10;
try {
System.out.println(a / 0);
a = 20;
} catch (ArithmeticException e) {
a = 30;
return a;
} finally {
a = 40;
//如果这样,就又重新形成了一条返回路径,由于只能通过1个return返回,所以这里直接返回40
return a;
}
}
// 执行结果:40
Java常见异常有哪些
- java.lang.IllegalAccessError:违法访问错误。当一个应用试图访问、修改某个类的域(Field)或者调用其方法,但是又违反域或方法的可见性声明,则抛出该异常。
- java.lang.InstantiationError:实例化错误。当一个应用试图通过Java的new操作符构造一个抽象类或者接口时抛出该异常。
- java.lang.OutOfMemoryError:内存不足错误。当可用内存不足以让Java虚拟机分配给一个对象时抛出该错误。
- java.lang.StackOverflowError:堆栈溢出错误。当一个应用递归调用的层次太深而导致堆栈溢出或者陷入死循环时抛出该错误。
- java.lang.ClassCastException:类造型异常。假设有类A和B(A不是B的父类或子类),O是A的实例,那么当强制将O构造为类B的实例时抛出该异常。该异常经常被称为强制类型转换异常。
- java.lang.ClassNotFoundException:找不到类异常。当应用试图根据字符串形式的类名构造类,而在遍历CLASSPAH之后找不到对应名称的class文件时,抛出该异常。
- java.lang.ArithmeticException:算术条件异常。譬如:整数除零等。
- java.lang.ArrayIndexOutOfBoundsException:数组索引越界异常。当对数组的索引值为负数或大于等于数组大小时抛出。
- java.lang.IndexOutOfBoundsException:索引越界异常。当访问某个序列的索引值小于0或大于等于序列大小时,抛出该异常。
- java.lang.InstantiationException:实例化异常。当试图通过newInstance()方法创建某个类的实例,而该类是一个抽象类或接口时,抛出该异常。
- java.lang.NoSuchFieldException:属性不存在异常。当访问某个类的不存在的属性时抛出该异常。
- java.lang.NoSuchMethodException:方法不存在异常。当访问某个类的不存在的方法时抛出该异常。
- java.lang.NullPointerException:空指针异常。当应用试图在要求使用对象的地方使用了null时,抛出该异常。譬如:调用null对象的实例方法、访问null对象的属性、计算null对象的长度、使用throw语句抛出null等等。
- java.lang.NumberFormatException:数字格式异常。当试图将一个String转换为指定的数字类型,而该字符串确不满足数字类型要求的格式时,抛出该异常。
- java.lang.StringIndexOutOfBoundsException:字符串索引越界异常。当使用索引值访问某个字符串中的字符,而该索引值小于0或大于等于序列大小时,抛出该异常。
JVM 是如何处理异常的?
在一个方法中如果发生异常,这个方法会创建一个异常对象,并转交给 JVM,该异常对象包含异常名称,异常描述以及异常发生时应用程序的状态。创建异常对象并转交给 JVM 的过程称为抛出异常。可能有一系列的方法调用,最终才进入抛出异常的方法,这一系列方法调用的有序列表叫做调用栈。
JVM 会顺着调用栈去查找看是否有可以处理异常的代码,如果有,则调用异常处理代码。当 JVM 发现可以处理异常的代码时,会把发生的异常传递给它。如果 JVM 没有找到可以处理该异常的代码块,JVM 就会将该异常转交给默认的异常处理器(默认处理器为 JVM 的一部分),默认异常处理器打印出异常信息并终止应用程序。
想要深入了解的小伙伴可以看这篇文章:https://www.cnblogs.com/qdhxhz/p/10765839.html
Java的IO流分为几种?
- 按照流的方向:输入流(inputStream)和输出流(outputStream);
- 按照实现功能分:节点流(可以从或向一个特定的地方读写数据,如 FileReader)和处理流(是对一个已存在的流的连接和封装,通过所封装的流的功能调用实现数据读写, BufferedReader);
- 按照处理数据的单位: 字节流和字符流。分别由四个抽象类来表示(每种流包括输入和输出两种所以一共四个):InputStream,OutputStream,Reader,Writer。Java中其他多种多样变化的流均是由它们派生出来的。
字节流如何转为字符流?
字节输入流转字符输入流通过 InputStreamReader 实现,该类的构造函数可以传入 InputStream 对象。
字节输出流转字符输出流通过 OutputStreamWriter 实现,该类的构造函数可以传入 OutputStream 对象。
字符流与字节流的区别?
- 读写的时候字节流是按字节读写,字符流按字符读写。
- 字节流适合所有类型文件的数据传输,因为计算机字节(Byte)是电脑中表示信息含义的最小单位。字符流只能够处理纯文本数据,其他类型数据不行,但是字符流处理文本要比字节流处理文本要方便。
- 在读写文件需要对内容按行处理,比如比较特定字符,处理某一行数据的时候一般会选择字符流。
- 只是读写文件,和文件内容无关时,一般选择字节流。
BIO、NIO、AIO的区别
- BIO:同步并阻塞,在服务器中实现的模式为一个连接一个线程。也就是说,客户端有连接请求的时候,服务器就需要启动一个线程进行处理,如果这个连接不做任何事情会造成不必要的线程开销,当然这也可以通过线程池机制改善。BIO一般适用于连接数目小且固定的架构,这种方式对于服务器资源要求比较高,而且并发局限于应用中,是JDK1.4之前的唯一选择,但好在程序直观简单,易理解。
- NIO:同步并非阻塞,在服务器中实现的模式为一个请求一个线程,也就是说,客户端发送的连接请求都会注册到多路复用器上,多路复用器轮询到有连接IO请求时才会启动一个线程进行处理。NIO一般适用于连接数目多且连接比较短(轻操作)的架构,并发局限于应用中,编程比较复杂,从JDK1.4开始支持。
- AIO:异步并非阻塞,在服务器中实现的模式为一个有效请求一个线程,也就是说,客户端的IO请求都是通过操作系统先完成之后,再通知服务器应用去启动线程进行处理。AIO一般适用于连接数目多且连接比较长(重操作)的架构,充分调用操作系统参与并发操作,编程比较复杂,从JDK1.7开始支持。
Java IO都有哪些设计模式?
使用了适配器模式和装饰器模式
适配器模式:
Reader reader = new InputStreamReader(inputStream);
把一个类的接口变换成客户端所期待的另一种接口,从而使原本因接口不匹配而无法在一起工作的两个类能够在一起工作
- 类适配器:Adapter类(适配器)继承Adaptee类(源角色)实现Target接口(目标角色)
- 对象适配器:Adapter类(适配器)持有Adaptee类(源角色)对象实例,实现Target接口(目标角色)
装饰器模式:
new BufferedInputStream(new FileInputStream(inputStream));
一种动态地往一个类中添加新的行为的设计模式。就功能而言,装饰器模式相比生成子类更为灵活,这样可以给某个对象而不是整个类添加一些功能。
- ConcreteComponent(具体对象)和Decorator(抽象装饰器)实现相同的Conponent(接口)并且Decorator(抽象装饰器)里面持有Conponent(接口)对象,可以传递请求。
- ConcreteComponent(具体装饰器)覆盖Decorator(抽象装饰器)的方法并用super进行调用,传递请求。
常见的集合有哪些
Java集合类主要由两个根接口Collection和Map派生出来的,Collection派生出了三个子接口:List、Set、Queue(Java5新增的队列),因此Java集合大致也可分成List、Set、Queue、Map四种接口体系。
注意:Collection是一个接口,Collections是一个工具类,Map不是Collection的子接口。
Java集合框架图如下:
图中,List代表了有序可重复集合,可直接根据元素的索引来访问;Set代表无序不可重复集合,只能根据元素本身来访问;Queue是队列集合。
Map代表的是存储key-value对的集合,可根据元素的key来访问value。
上图中淡绿色背景覆盖的是集合体系中常用的实现类,分别是ArrayList、LinkedList、ArrayQueue、HashSet、TreeSet、HashMap、TreeMap等实现类。
线程安全的集合有哪些?线程不安全的呢?
线程安全的:
- Hashtable:比HashMap多了个线程安全。
- ConcurrentHashMap:是一种高效且线程安全的集合。
- Vector:比Arraylist多了个同步化机制。
- Stack:栈,也是线程安全的,继承于Vector。
线性不安全的:
- HashMap
- Arraylist
- LinkedList
- HashSet
- TreeSet
- TreeMap
Arraylist与LinkedList异同点
- 是否保证线程安全: ArrayList 和 LinkedList 都是不同步的,也就是不保证线程安全;
- 底层数据结构: Arraylist 底层使用的是Object数组;LinkedList 底层使用的是双向循环链表数据结构;
- 插入和删除是否受元素位置的影响: ArrayList 采用数组存储,所以插入和删除元素的时间复杂度受元素位置的影响。 比如:执行add(E e)方法的时候, ArrayList 会默认在将指定的元素追加到此列表的末尾,这种情况时间复杂度就是O(1)。但是如果要在指定位置 i 插入和删除元素的话(add(int index, E element))时间复杂度就为 O(n-i)。因为在进行上述操作的时候集合中第 i 和第 i 个元素之后的(n-i)个元素都要执行向后位/向前移一位的操作。 LinkedList 采用链表存储,所以插入,删除元素时间复杂度不受元素位置的影响,都是近似 O(1)而数组为近似 O(n)。
- 是否支持快速随机访问: LinkedList 不支持高效的随机元素访问,而ArrayList 实现了RandmoAccess 接口,所以有随机访问功能。快速随机访问就是通过元素的序号快速获取元素对象(对应于get(int index)方法)。
- 内存空间占用: ArrayList的空间浪费主要体现在list列表的结尾会预留一定的容量空间,而LinkedList的空间花费则体现在它的每一个元素都需要消耗比ArrayList更多的空间(因为要存放直接后继和直接前驱以及本身数据)。
Array和ArrayList有什么区别
- Array可以包含基本类型和对象类型,ArrayList只能包含对象类型。
- Array大小是固定的(不支持扩容),ArrayList的大小是动态变化的(支持扩容)。
- ArrayList提供了更多的方法和特性,比如:addAll(),removeAll(),iterator()等等。
ArrayList与Vector区别?
- Vector是线程安全的,ArrayList不是线程安全的。其中,Vector在关键性的方法前面都加了synchronized关键字,来保证线程的安全性。如果有多个线程会访问到集合,那最好是使用 Vector,因为不需要我们自己再去考虑和编写线程安全的代码。
- ArrayList在底层数组不够用时在原来的基础上扩展0.5倍,Vector是扩展1倍,这样ArrayList就有利于节约内存空间。
说一说ArrayList的扩容机制?
ArrayList扩容的本质就是计算出新的扩容数组的size后实例化,并将原有数组内容复制到新数组中去。默认情况下,新的容量会是原容量的1.5倍。
以JDK1.8为例说明:
public boolean add(E e) {
//判断是否可以容纳e,若能,则直接添加在末尾;若不能,则进行扩容,然后再把e添加在末尾
ensureCapacityInternal(size + 1); // Increments modCount!!
//将e添加到数组末尾
elementData[size++] = e;
return true;
}
// 每次在add()一个元素时,arraylist都需要对这个list的容量进行一个判断。通过ensureCapacityInternal()方法确保当前ArrayList维护的数组具有存储新元素的能力,经过处理之后将元素存储在数组elementData的尾部
private void ensureCapacityInternal(int minCapacity) {
ensureExplicitCapacity(calculateCapacity(elementData, minCapacity));
}
private static int calculateCapacity(Object[] elementData, int minCapacity) {
//如果传入的是个空数组则最小容量取默认容量与minCapacity之间的最大值
if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
return Math.max(DEFAULT_CAPACITY, minCapacity);
}
return minCapacity;
}
private void ensureExplicitCapacity(int minCapacity) {
modCount++;
// 若ArrayList已有的存储能力满足最低存储要求,则返回add直接添加元素;如果最低要求的存储能力>ArrayList已有的存储能力,这就表示ArrayList的存储能力不足,因此需要调用 grow();方法进行扩容
if (minCapacity - elementData.length > 0)
grow(minCapacity);
}
private void grow(int minCapacity) {
// 获取elementData数组的内存空间长度
int oldCapacity = elementData.length;
// 扩容至原来的1.5倍
int newCapacity = oldCapacity + (oldCapacity >> 1);
//校验容量是否够
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity;
//若预设值大于默认的最大值,检查是否溢出
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
// 调用Arrays.copyOf方法将elementData数组指向新的内存空间
//并将elementData的数据复制到新的内存空间
elementData = Arrays.copyOf(elementData, newCapacity);
}
HashMap的底层数据结构是什么?
在JDK1.7 和JDK1.8 中有所差别:
在JDK1.7 中,由“数组+链表”组成,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的。
在JDK1.8 中,由“数组+链表+红黑树”组成。当链表过长,则会严重影响 HashMap 的性能,红黑树搜索时间复杂度是 O(logn),而链表是糟糕的 O(n)。因此,JDK1.8 对数据结构做了进一步的优化,引入了红黑树,链表和红黑树在达到一定条件会进行转换:
当链表超过 8 且数据总量超过64才会转红黑树。
将链表转换成红黑树前会判断,如果当前数组的长度小于64,那么会选择先进行数组扩容,而不是转换为红黑树,以减少搜索时间。
解决hash冲突的办法有哪些?HashMap用的哪种?
解决Hash冲突方法有:开放定址法、再哈希法、链地址法(拉链法)、建立公共溢出区。HashMap中采用的是链地址法。
- 开放定址法也称为再散列法,基本思想就是,如果p=H(key)出现冲突时,则以p为基础,再次hash,p1=H(p),如果p1再次出现冲突,则以p1为基础,以此类推,直到找到一个不冲突的哈希地址pi。 因此开放定址法所需要的hash表的长度要大于等于所需要存放的元素,而且因为存在再次hash,所以只能在删除的节点上做标记,而不能真正删除节点。
- **再哈希法(双重散列,多重散列)**,提供多个不同的hash函数,当R1=H1(key1)发生冲突时,再计算R2=H2(key1),直到没有冲突为止。 这样做虽然不易产生堆集,但增加了计算的时间。
- **链地址法(拉链法)**,将哈希值相同的元素构成一个同义词的单链表,并将单链表的头指针存放在哈希表的第i个单元中,查找、插入和删除主要在同义词链表中进行。链表法适用于经常进行插入和删除的情况。
- 建立公共溢出区,将哈希表分为公共表和溢出表,当溢出发生时,将所有溢出数据统一放到溢出区。
HashMap和ConcurrentHashMap是选择先用链表,再转红黑树
因为红黑树需要进行左旋,右旋,变色这些操作来保持平衡,而单链表不需要。当元素小于 8 个的时候,此时做查询操作,链表结构已经能保证查询性能。当元素大于 8 个的时候, 红黑树搜索时间复杂度是 O(logn),而链表是 O(n),此时需要红黑树来加快查询速度,但是新增节点的效率变慢了。
因此,如果一开始就用红黑树结构,元素太少,新增效率又比较慢,无疑这是浪费性能的。
HashMap默认加载因子是多少?为什么是 0.75,不是 0.6 或者 0.8 ?
回答这个问题前,我们来先看下HashMap的默认构造函数:
int threshold; // 容纳键值对的最大值
final float loadFactor; // 负载因子
int modCount;
int size;
Node[] table的初始化长度length(默认值是16),Load factor为负载因子(默认值是0.75),threshold是HashMap所能容纳键值对的最大值。threshold = length * Load factor。也就是说,在数组定义好长度之后,负载因子越大,所能容纳的键值对个数越多。
默认的loadFactor是0.75,0.75是对空间和时间效率的一个平衡选择,一般不要修改,除非在时间和空间比较特殊的情况下:
如果内存空间很多而又对时间效率要求很高,可以降低负载因子loadFactor的值。
相反,如果内存空间紧张而对时间效率要求不高,可以增加负载因子loadFactor的值,这个值可以大于1。
我们来追溯下作者在源码中的注释(JDK1.7):
As a general rule, the default load factor (.75) offers a good tradeoff between time and space costs. Higher values decrease the space overhead but increase the lookup cost (reflected in most of the operations of the HashMap class, including get and put). The expected number of entries in the map and its load factor should be taken into account when setting its initial capacity, so as to minimize the number of rehash operations. If the initial capacity is greater than the maximum number of entries divided by the load factor, no rehash operations will ever occur.
翻译过来大概的意思是:作为一般规则,默认负载因子(0.75)在时间和空间成本上提供了很好的折衷。较高的值会降低空间开销,但提高查找成本(体现在大多数的HashMap类的操作,包括get和put)。设置初始大小时,应该考虑预计的entry数在map及其负载系数,并且尽量减少rehash操作的次数。如果初始容量大于最大条目数除以负载因子,rehash操作将不会发生。
HashMap 中 key 的存储索引是怎么计算的
首先根据key的值计算出hashcode的值,然后根据hashcode计算出hash值,最后通过hash&(length-1)计算得到存储的位置。看看源码的实现:
// jdk1.7
方法一:
static int hash(int h) {
int h = hashSeed;
if (0 != h && k instanceof String) {
return sun.misc.Hashing.stringHash32((String) k);
}
h ^= k.hashCode(); // 为第一步:取hashCode值
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
方法二:
static int indexFor(int h, int length) { //jdk1.7的源码,jdk1.8没有这个方法,但实现原理一样
return h & (length-1); //第三步:取模运算
}
// jdk1.8
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
/*
h = key.hashCode() 为第一步:取hashCode值
h ^ (h >>> 16) 为第二步:高位参与运算
*/
}
这里的 Hash 算法本质上就是三步:取key的 hashCode 值、根据 hashcode 计算出hash值、通过取模计算下标。其中,JDK1.7和1.8的不同之处,就在于第二步。我们来看下详细过程,以JDK1.8为例,n为table的长度。
HashMap 的put方法流程
简要流程如下:
首先根据 key 的值计算 hash 值,找到该元素在数组中存储的下标;
如果数组是空的,则调用 resize 进行初始化;
如果没有哈希冲突直接放在对应的数组下标里;
如果冲突了,且 key 已经存在,就覆盖掉 value;
如果冲突后,发现该节点是红黑树,就将这个节点挂在树上;
如果冲突后是链表,判断该链表是否大于 8 ,如果大于 8 并且数组容量小于 64,就进行扩容;如果链表节点大于 8 并且数组的容量大于 64,则将这个结构转换为红黑树;否则,链表插入键值对,若 key 存在,就覆盖掉 value。
一般用什么作为HashMap的key?
一般用Integer、String 这种不可变类当 HashMap 当 key,而且 String 最为常用。
- 因为字符串是不可变的,所以在它创建的时候 hashcode 就被缓存了,不需要重新计算。这就是 HashMap 中的键往往都使用字符串的原因。
- 因为获取对象的时候要用到 equals() 和 hashCode() 方法,那么键对象正确的重写这两个方法是非常重要的,这些类已经很规范的重写了 hashCode() 以及 equals() 方法。
HashMap 的扩容方式
HashMap 在容量超过负载因子所定义的容量之后,就会扩容。Java 里的数组是无法自动扩容的,方法是将 HashMap 的大小扩大为原来数组的两倍,并将原来的对象放入新的数组中。
那扩容的具体步骤是什么?让我们看看源码。
先来看下JDK1.7 的代码:
void resize(int newCapacity) { //传入新的容量
Entry[] oldTable = table; //引用扩容前的Entry数组
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) { //扩容前的数组大小如果已经达到最大(2^30)了
threshold = Integer.MAX_VALUE; //修改阈值为int的最大值(2^31-1),这样以后就不会扩容了
return;
}
Entry[] newTable = new Entry[newCapacity]; //初始化一个新的Entry数组
transfer(newTable); //!!将数据转移到新的Entry数组里
table = newTable; //HashMap的table属性引用新的Entry数组
threshold = (int)(newCapacity * loadFactor);//修改阈值
}
这里就是使用一个容量更大的数组来代替已有的容量小的数组,transfer()方法将原有Entry数组的元素拷贝到新的Entry数组里。
void transfer(Entry[] newTable) {
Entry[] src = table; //src引用了旧的Entry数组
int newCapacity = newTable.length;
for (int j = 0; j < src.length; j++) { //遍历旧的Entry数组
Entry<K,V> e = src[j]; //取得旧Entry数组的每个元素
if (e != null) {
src[j] = null;//释放旧Entry数组的对象引用(for循环后,旧的Entry数组不再引用任何对象)
do {
Entry<K,V> next = e.next;
int i = indexFor(e.hash, newCapacity); //!!重新计算每个元素在数组中的位置
e.next = newTable[i]; //标记[1]
newTable[i] = e; //将元素放在数组上
e = next; //访问下一个Entry链上的元素
} while (e != null);
}
}
}
newTable[i] 的引用赋给了 e.next ,也就是使用了单链表的头插入方式,同一位置上新元素总会被放在链表的头部位置;这样先放在一个索引上的元素终会被放到 Entry 链的尾部(如果发生了 hash 冲突的话)。
ConcurrentHashMap 的实现原理
ConcurrentHashMap 在 JDK1.7 和 JDK1.8 的实现方式是不同的。
先来看下JDK1.7
JDK1.7中的ConcurrentHashMap 是由 Segment 数组结构和 HashEntry 数组结构组成,即ConcurrentHashMap 把哈希桶切分成小数组(Segment ),每个小数组有 n 个 HashEntry 组成。
其中,Segment 继承了 ReentrantLock,所以 Segment 是一种可重入锁,扮演锁的角色;HashEntry 用于存储键值对数据。
首先将数据分为一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据时,其他段的数据也能被其他线程访问,能够实现真正的并发访问。
再来看下JDK1.8
在数据结构上, JDK1.8 中的ConcurrentHashMap 选择了与 HashMap 相同的数组+链表+红黑树结构;在锁的实现上,抛弃了原有的 Segment 分段锁,采用CAS + synchronized实现更加低粒度的锁。
将锁的级别控制在了更细粒度的哈希桶元素级别,也就是说只需要锁住这个链表头结点(红黑树的根节点),就不会影响其他的哈希桶元素的读写,大大提高了并发度。
JDK1.7与JDK1.8 中ConcurrentHashMap 的对比
- 数据结构: 取消了Segment分段锁的数据结构,取而代之的是数组+链表+红黑树的结构。
- 保证线程安全机制: JDK1.7采用Segment的分段锁机制实现线程安全,其中segment继承自ReentrantLock。JDK1.8 采用CAS+Synchronized保证线程安全。
- 锁的粒度: 原来是对需要进行数据操作的Segment加锁,现调整为对每个数组元素加锁(Node)。
- 链表转化为红黑树: 定位结点的hash算法简化会带来弊端,Hash冲突加剧,因此在链表节点数量大于8且数组长度大于64时,会将链表转化为红黑树进行存储。
- 查询时间复杂度: 从原来的遍历链表O(n),变成遍历红黑树O(logN)。
ConcurrentHashMap 的 put 方法执行逻辑是什么
先来看JDK1.7
首先,会尝试获取锁,如果获取失败,利用自旋获取锁;如果自旋重试的次数超过 64 次,则改为阻塞获取锁。
获取到锁后:
- 将当前 Segment 中的 table 通过 key 的 hashcode 定位到 HashEntry。
- 遍历该 HashEntry,如果不为空则判断传入的 key 和当前遍历的 key 是否相等,相等则覆盖旧的 value。
- 不为空则需要新建一个 HashEntry 并加入到 Segment 中,同时会先判断是否需要扩容。
- 释放 Segment 的锁。
再来看JDK1.8
大致可以分为以下步骤:
- 根据 key 计算出 hash值。
- 判断是否需要进行初始化。
- 定位到 Node,拿到首节点 f,判断首节点 f:
- 如果为 null ,则通过cas的方式尝试添加。
- 如果为 f.hash = MOVED = -1 ,说明其他线程在扩容,参与一起扩容。
- 如果都不满足 ,synchronized 锁住 f 节点,判断是链表还是红黑树,遍历插入。
- 当在链表长度达到8,且数组长度大于64的时候,数组扩容或者将链表转换为红黑树。
ConcurrentHashMap 的 get 方法是否要加锁,为什么?
get 方法不需要加锁。因为 Node 的元素 val 和指针 next 是用 volatile 修饰的,在多线程环境下线程A修改结点的val或者新增节点的时候是对线程B可见的。
这也是它比其他并发集合比如 Hashtable、用 Collections.synchronizedMap()包装的 HashMap 安全效率高的原因之一。
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
//可以看到这些都用了volatile修饰
volatile V val;
volatile Node<K,V> next;
}
get方法不需要加锁与volatile修饰的哈希桶有关吗?
没有关系。哈希桶table
用volatile修饰主要是保证在数组扩容的时候保证可见性。
static final class Segment<K,V> extends ReentrantLock implements Serializable {
// 存放数据的桶
transient volatile HashEntry<K,V>[] table;
ConcurrentHashMap 的并发度是多少
在JDK1.7中,并发度默认是16,这个值可以在构造函数中设置。如果自己设置了并发度,ConcurrentHashMap 会使用大于等于该值的最小的2的幂指数作为实际并发度,也就是比如你设置的值是17,那么实际并发度是32。
Iterator 和 ListIterator 有什么区别
遍历。使用Iterator,可以遍历所有集合,如Map,List,Set;但只能在向前方向上遍历集合中的元素。使用ListIterator,只能遍历List实现的对象,但可以向前和向后遍历集合中的元素。
添加元素。Iterator无法向集合中添加元素;而,ListIteror可以向集合添加元素。
修改元素。Iterator无法修改集合中的元素;而,ListIterator可以使用set()修改集合中的元素。
索引。Iterator无法获取集合中元素的索引;而,使用ListIterator,可以获取集合中元素的索引。
HashMap为什么线程不安全
- 多线程下扩容死循环。JDK1.7中的 HashMap 使用头插法插入元素,在多线程的环境下,扩容的时候有可能导致环形链表的出现,形成死循环。因此,JDK1.8使用尾插法插入元素,在扩容时会保持链表元素原本的顺序,不会出现环形链表的问题。
- 多线程的put可能导致元素的丢失。多线程同时执行 put 操作,如果计算出来的索引位置是相同的,那会造成前一个 key 被后一个 key 覆盖,从而导致元素的丢失。此问题在JDK 1.7和 JDK 1.8 中都存在。
- put和get并发时,可能导致get为null。线程1执行put时,因为元素个数超出threshold而导致rehash,线程2此时执行get,有可能导致这个问题。此问题在JDK 1.7和 JDK 1.8 中都存在。
ConcurrentHashMap 不支持 key 或者 value 为 null 的原因?
我们先来说value 为什么不能为 null ,因为ConcurrentHashMap
是用于多线程的 ,如果map.get(key)
得到了 null ,无法判断,是映射的value是 null ,还是没有找到对应的key而为 null ,这就有了二义性。
而用于单线程状态的HashMap
却可以用containsKey(key)
去判断到底是否包含了这个 null 。
我们用反证法来推理:
假设ConcurrentHashMap 允许存放值为 null 的value,这时有A、B两个线程,线程A调用ConcurrentHashMap.get(key)方法,返回为 null ,我们不知道这个 null 是没有映射的 null ,还是存的值就是 null 。
假设此时,返回为 null 的真实情况是没有找到对应的key。那么,我们可以用ConcurrentHashMap .containsKey(key)来验证我们的假设是否成立,我们期望的结果是返回false。
但是在我们调用ConcurrentHashMap .get(key)方法之后,containsKey方法之前,线程B执行了ConcurrentHashMap .put(key, null )的操作。那么我们调用containsKey方法返回的就是true了,这就与我们的假设的真实情况不符合了,这就有了二义性。
至于ConcurrentHashMap 中的key为什么也不能为 null 的问题,源码就是这样写的,哈哈。如果面试官不满意,就回答因为作者Doug不喜欢 null ,所以在设计之初就不允许了 null 的key存在。
ConcurrentHashMap 和 Hashtable 的效率哪个更高?为什么?
ConcurrentHashMap 的效率要高于Hashtable,因为Hashtable给整个哈希表加了一把大锁从而实现线程安全。而ConcurrentHashMap 的锁粒度更小,在JDK1.7中采用分段锁实现线程安全,在JDK1.8 中采用CAS+Synchronized
实现线程安全。
说一下Hashtable的锁机制
Hashtable是使用Synchronized来实现线程安全的,给整个哈希表加了一把大锁,多线程访问时候,只要有一个线程访问或操作该对象,那其他线程只能阻塞等待需要的锁被释放,在竞争激烈的多线程场景中性能就会非常差!
多线程下安全的操作 map 还有其他方法吗?
还可以使用Collections.synchronizedMap
方法,对方法进行加同步锁
private static class SynchronizedMap<K,V>
implements Map<K,V>, Serializable {
private static final long serialVersionUID = 1978198479659022715L;
private final Map<K,V> m; // Backing Map
final Object mutex; // Object on which to synchronize
SynchronizedMap(Map<K,V> m) {
this.m = Objects.requireNon null (m);
mutex = this;
}
SynchronizedMap(Map<K,V> m, Object mutex) {
this.m = m;
this.mutex = mutex;
}
// 省略部分代码
}
如果传入的是 HashMap 对象,其实也是对 HashMap 做的方法做了一层包装,里面使用对象锁来保证多线程场景下,线程安全,本质也是对 HashMap 进行全表锁。在竞争激烈的多线程环境下性能依然也非常差,不推荐使用!
HashSet 和 HashMap 区别?
补充HashSet的实现:HashSet的底层其实就是HashMap,只不过我们HashSet是实现了Set接口并且把数据作为K值,而V值一直使用一个相同的虚值来保存。如源码所示:
public boolean add(E e) {
return map.put(e, PRESENT)==null;// 调用HashMap的put方法,PRESENT是一个至始至终都相同的虚值
}
由于HashMap的K值本身就不允许重复,并且在HashMap中如果K/V相同时,会用新的V覆盖掉旧的V,然后返回旧的V,那么在HashSet中执行这一句话始终会返回一个false,导致插入失败,这样就保证了数据的不可重复性。
Collection框架中实现比较要怎么做?
第一种,实体类实现Comparable接口,并实现 compareTo(T t) 方法,称为内部比较器。
第二种,创建一个外部比较器,这个外部比较器要实现Comparator接口的 compare(T t1, T t2)方法。
讲一讲快速失败(fail-fast)和安全失败(fail-safe)
快速失败(fail—fast)
在用迭代器遍历一个集合对象时,如果遍历过程中对集合对象的内容进行了修改(增加、删除、修改),则会抛出Concurrent Modification Exception。
原理:
迭代器在遍历时直接访问集合中的内容,并且在遍历过程中使用一个 modCount 变量。集合在被遍历期间如果内容发生变化,就会改变modCount的值。每当迭代器使用hashNext()/next()遍历下一个元素之前,都会检测modCount变量是否为expectedmodCount值,是的话就返回遍历;否则抛出异常,终止遍历。
注意:
这里异常的抛出条件是检测到 modCount!=expectedmodCount 这个条件。如果集合发生变化时修改modCount值刚好又设置为了expectedmodCount值,则异常不会抛出。因此,不能依赖于这个异常是否抛出而进行并发操作的编程,这个异常只建议用于检测并发修改的bug。
场景:
java.util包下的集合类都是快速失败的,不能在多线程下发生并发修改(迭代过程中被修改),比如HashMap、ArrayList 这些集合类。
安全失败(fail—safe)
采用安全失败机制的集合容器,在遍历时不是直接在集合内容上访问的,而是先复制原有集合内容,在拷贝的集合上进行遍历。
原理:
由于迭代时是对原集合的拷贝进行遍历,所以在遍历过程中对原集合所作的修改并不能被迭代器检测到,所以不会触发Concurrent Modification Exception。
缺点:
基于拷贝内容的优点是避免了ConcurrentModificationException,但同样地,迭代器并不能访问到修改后的内容,即:迭代器遍历的是开始遍历那一刻拿到的集合拷贝,在遍历期间原集合发生的修改迭代器是不知道的。
场景:
J.U.C包下的容器都是安全失败,可以在多线程下并发使用,并发修改,比如:ConcurrentHashMap。
2. JVM篇
JVM系列文章:Java虚拟机
什么是JVM内存结构?
JVM将虚拟机分为5大区域,程序计数器、虚拟机栈、本地方法栈、java堆、方法区:
- 程序计数器:线程私有的,是一块很小的内存空间,作为当前线程的行号指示器,用于记录当前虚拟机正在执行的线程指令地址;
- 虚拟机栈:线程私有的,每个方法执行的时候都会创建一个栈帧,用于存储局部变量表、操作数、动态链接和方法返回等信息,当线程请求的栈深度超过了虚拟机允许的最大深度时,就会抛出StackOverFlowError;
- 本地方法栈:线程私有的,保存的是native方法的信息,当一个jvm创建的线程调用native方法后,jvm不会在虚拟机栈中为该线程创建栈帧,而是简单的动态链接并直接调用该方法;
- 堆:java堆是所有线程共享的一块内存,几乎所有对象的实例和数组都要在堆上分配内存,因此该区域经常发生垃圾回收的操作;
- 方法区:存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码数据。即永久代,在jdk1.8中不存在方法区了,被元数据区替代了,原方法区被分成两部分;1:加载的类信息;2:运行时常量池;加载的类信息被保存在元数据区中,运行时常量池保存在堆中。
什么是Java内存模型?
Java 内存模型(下文简称 JMM)就是在底层处理器内存模型的基础上,定义自己的多线程语义。它明确指定了一组排序规则,来保证线程间的可见性。
这一组规则被称为 Happens-Before, JMM 规定,要想保证 B 操作能够看到 A 操作的结果(无论它们是否在同一个线程),那么 A 和 B 之间必须满足 Happens-Before 关系:
- 单线程规则:一个线程中的每个动作都 happens-before 该线程中后续的每个动作
- 监视器锁定规则:监听器的解锁动作 happens-before 后续对这个监听器的锁定动作
- volatile 变量规则:对 volatile 字段的写入动作 happens-before 后续对这个字段的每个读取动作
- 线程 start 规则:线程 start() 方法的执行 happens-before 一个启动线程内的任意动作
- 线程 join 规则:一个线程内的所有动作 happens-before 任意其他线程在该线程 join() 成功返回之前
- 传递性:如果 A happens-before B, 且 B happens-before C, 那么 A happens-before C
怎么理解 happens-before 呢?如果按字面意思,比如第二个规则,线程(不管是不是同一个)的解锁动作发生在锁定之前?这明显不对。happens-before 也是为了保证可见性,比如那个解锁和加锁的动作,可以这样理解,线程1释放锁退出同步块,线程2加锁进入同步块,那么线程2就能看见线程1对共享对象修改的结果。
Java 提供了几种语言结构,包括 volatile, final 和 synchronized, 它们旨在帮助程序员向编译器描述程序的并发要求,其中:
- volatile - 保证可见性和有序性
- synchronized - 保证可见性和有序性; 通过管程(Monitor)保证一组动作的原子性
- final - 通过禁止在构造函数初始化和给 final 字段赋值这两个动作的重排序,保证可见性(如果 this 引用逃逸就不好说可见性了)
编译器在遇到这些关键字时,会插入相应的内存屏障,保证语义的正确性。
有一点需要注意的是,synchronized 不保证同步块内的代码禁止重排序,因为它通过锁保证同一时刻只有一个线程访问同步块(或临界区),也就是说同步块的代码只需满足 as-if-serial 语义 - 只要单线程的执行结果不改变,可以进行重排序。
所以说,Java 内存模型描述的是多线程对共享内存修改后彼此之间的可见性,另外,还确保正确同步的 Java 代码可以在不同体系结构的处理器上正确运行。
heap 和stack 有什么区别?
- 申请方式
stack:由系统自动分配。例如,声明在函数中一个局部变量 int b; 系统自动在栈中为 b 开辟空间
heap:需要程序员自己申请,并指明大小,在 c 中 malloc 函数,对于Java 需要手动 new Object()的形式开辟
- 申请后系统的响应
stack:只要栈的剩余空间大于所申请空间,系统将为程序提供内存,否则将报异常提示栈溢出。
heap:首先应该知道操作系统有一个记录空闲内存地址的链表,当系统收到程序的申请时,会遍历该链表,寻找第一个空间大于所申请空间的堆结点,然后将该结点从空闲结点链表中删除,并将该结点的空间分配给程序。另外,由于找到的堆结点的大小不一定正好等于申请的大小,系统会自动的将多余的那部分重新放入空闲链表中。
- 申请大小的限制
stack:栈是向低地址扩展的数据结构,是一块连续的内存的区域。这句话的意思是栈顶的地址和栈的最大容量是系统预先规定好的,在 WINDOWS 下,栈的大小是 2M(默认值也取决于虚拟内存的大小),如果申请的空间超过栈的剩余空间时,将提示 overflow。因此,能从栈获得的空间较小。
heap:堆是向高地址扩展的数据结构,是不连续的内存区域。这是由于系统是用链表来存储的空闲内存地址的, 自然是不连续的,而链表的遍历方向是由低地址向高地址。堆的大小受限于计算机系统中有效的虚拟内存。由此可见, 堆获得的空间比较灵活,也比较大。
- 申请效率的比较
stack:由系统自动分配,速度较快。但程序员是无法控制的。
heap:由 new 分配的内存,一般速度比较慢,而且容易产生内存碎片,不过用起来最方便。
- heap和stack中的存储内容
stack:在函数调用时,第一个进栈的是主函数中后的下一条指令(函数调用语句的下一条可执行语句)的地址, 然后是函数的各个参数,在大多数的 C 编译器中,参数是由右往左入栈的,然后是函数中的局部变量。注意静态变量是不入栈的。
当本次函数调用结束后,局部变量先出栈,然后是参数,最后栈顶指针指向最开始存的地址,也就是主函数中的下一条指令,程序由该点继续运行。
heap:一般是在堆的头部用一个字节存放堆的大小。堆中的具体内容由程序员安排。
谈谈对 OOM 的认识?如何排查 OOM 的问题?
除了程序计数器,其他内存区域都有 OOM 的风险。
- 栈一般经常会发生 StackOverflowError,比如 32 位的 windows 系统单进程限制 2G 内存,无限创建线程就会发生栈的 OOM
- Java 8 常量池移到堆中,溢出会出 java.lang.OutOfMemoryError: Java heap space,设置最大元空间大小参数无效;
- 堆内存溢出,报错同上,这种比较好理解,GC 之后无法在堆中申请内存创建对象就会报错;
- 方法区 OOM,经常会遇到的是动态生成大量的类、jsp 等;
- 直接内存 OOM,涉及到 -XX:MaxDirectMemorySize 参数和 Unsafe 对象对内存的申请。
排查 OOM 的方法:
- 增加两个参数
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/heapdump.hprof
,当 OOM 发生时自动 dump 堆内存信息到指定目录; - 同时 jstat 查看监控 JVM 的内存和 GC 情况,先观察问题大概出在什么区域;
- 使用 MAT 工具载入到 dump 文件,分析大对象的占用情况,比如 HashMap 做缓存未清理,时间长了就会内存溢出,可以把改为弱引用 。
谈谈 JVM 中的常量池?
JVM常量池主要分为Class文件常量池、运行时常量池,全局字符串常量池,以及基本类型包装类对象常量池。
- Class文件常量池。class文件是一组以字节为单位的二进制数据流,在java代码的编译期间,我们编写的java文件就被编译为.class文件格式的二进制数据存放在磁盘中,其中就包括class文件常量池。
- 运行时常量池:运行时常量池相对于class常量池一大特征就是具有动态性,java规范并不要求常量只能在运行时才产生,也就是说运行时常量池的内容并不全部来自class常量池,在运行时可以通过代码生成常量并将其放入运行时常量池中,这种特性被用的最多的就是String.intern()。
- 全局字符串常量池:字符串常量池是JVM所维护的一个字符串实例的引用表,在HotSpot VM中,它是一个叫做StringTable的全局表。在字符串常量池中维护的是字符串实例的引用,底层C++实现就是一个Hashtable。这些被维护的引用所指的字符串实例,被称作”被驻留的字符串”或”interned string”或通常所说的”进入了字符串常量池的字符串”。
- 基本类型包装类对象常量池:java中基本类型的包装类的大部分都实现了常量池技术,这些类是Byte,Short,Integer,Long,Character,Boolean,另外两种浮点数类型的包装类则没有实现。另外上面这5种整型的包装类也只是在对应值小于等于127时才可使用对象池,也即对象不负责创建和管理大于127的这些类的对象。
OOM和SOF在什么情况下会引起
OOM(OutOfMemoryError异常)
除了程序计数器外,虚拟机内存的其他几个运行是区域都有发生OutOfMemoryError(OOM)异常的可能。
Java Heap 溢出
一般的异常信息:java.lang.OutOfMemoryError:Java heap spacess
Java堆用于存储对象实例,我们只要不断的创建对象,并且保证GC Roots到对象之间有可达路径来避免垃圾回收机制清除对象,就会在对象数量达到最大堆容量限制后产生内存溢出异常。
出现这种异常,一般手段是先通过内存映像分析工具对dump出来的堆转存快照进行分析,重点是确认内存中的对象是否是必要的,先分清是因为内存泄漏还是内存溢出引起的。
如果是内存泄漏,可进一步通过工具查看泄漏对象到GC Roots的应用链。于是就能找到泄漏对象是通过怎样的路径与GC Roots相关联并导致垃圾收集器无法自动回收。
如果不存在泄漏,那几应该检查虚拟机的参数(-Xmx和-Xms)的设置是否适当。
虚拟机栈和本地方法栈溢出
- 如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverflowError异常;
- 如果虚拟机在拓展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError异常;
- 如果虚拟机栈在配置的时候指定不可拓展。就不会存在虚拟机栈拓展请求内存空调的操作,因此就不会出现OutOfMemoryError异常,只会是StackOverflowError异常。
运行时常量池异常
异常信息:java.lang.OutOfMemoryError:PermGenspace
如果要想运行时常量池中添加内容,最简单的做法就是使用String.intern()这个Native方法。该方法的作用是:
如果常量池中已经包含一个等于此String的字符串,则返回代表池中这个字符串的String对象;否则,将此String对象包含的字符串添加到常量池中,并且返回此String对象的应用。由于常量池分配在方法区中。我们可以通过-XX:PermSize和-XX:MaxPermSize限制方法区的大小,从而间接限制其中常量池的容量。
方法区溢出
异常信息:java.lang.OutOfMemoryError:PermGenspace
方法区用于存放Class的相关信息,如类名、访问修饰符、常量池、字段描述、方法描述等。也有可能是方法区中保存的class对象没有被及时回收掉或者class信息占用的内存超过了我们的配置。
方法区溢出也是一种常见的内存溢出异常,一个类如果要被垃圾收集器回收,判定条件是很苛刻的。在经常动态生成大量class的应用中,要特别注意这点。
SOF(堆栈溢出StackOverflowError)
当应用程序递归太深而发生堆栈溢出时,抛出该错误。
因为栈空间是有限的,一旦出现死循环或者是大量的递归调用,在不断的压栈过程中,造成栈容量不能满足需求而导致溢出。
栈溢出的原因:递归调用、大量循环或者死循环,全局变量是否过多,数组、List、Map数据过大。
JVM内存模型简介
JVM定义了不同原型时数据区,他们是用来执行应用程序的。某些区域随着JVM启动及销毁,另外一些区域的数据是线程性独立的,随着线程创建和销毁。JVM内存模型总体架构图如下:(来自官网)
JVM在执行Java程序时,会把它管理的内存划分为若干个区域,每个区域都有自己的用户和创建销毁时间。主要分为两个大部分,线程私有区和共享区。
线程私有区
程序计数器
当同时进行的线程数超过CPU数或其内核数时,就要通过时间片轮询分派CPU的时间资源,会发生线程切换。这时,每个线程就需要一个数据自己的计数器来记录下一条要运行的指令。如果执行的是Java方法,计数器记录正在执行的Java字节码地址。如果执行的是native本地方法,则计数器为空。
虚拟机栈
线程私有,随线程创建而创建。管理Java方法执行的内存模型。每个方法执行时都会创建一个栈帧来存储方法的变量表、操作数栈、动态链接方法、返回值、返回地址等信息。栈的大小决定了方法调用的可达深度(可通过-Xss参数设置虚拟机栈大小)。栈的大小可以是固定的,或者是动态拓展的。如果请求的栈深度大于最大可用深度,则抛出StackOverflowError;如果栈是可动态拓展的,单没有内存空间支持拓展,则抛出OutOfMemoryError。
本地方法栈
与虚拟机栈作用相似,但它不是为Java方法服务的,而是本地方法(C语言)。
线程共享区
方法区
线程共享的,用于存放被虚拟机加载的类的元数据信息,如常量、静态变量和即时编译器编译后的代码。若要分代,算是永久代。另外运行时常量池存放编译生成的各种常量。(Hotspot虚拟机确认一个类的定义信息不会被使用,也会将其回收。回收的基本条件至少有:所有该类的实例被回收,而且装载该类的ClassLoader被回收)
堆
存放对象实例和数组,是垃圾回收的主要区域,分为新生代和老年代。刚创建的对象在新生代的Eden区中,经过GC后进入新生代的S0区中,再经过GC进入新生代的S1区中,15次GC后仍存在就进入老年代。这是按照一种回收机制进行划分的,不是固定的。若堆的控件不够实例分配,则会出现OutOfMemoryError错误。
堆和栈的区别
栈是运行时单位,代表着逻辑,内含基本数据类型和堆中对象引用,所在区域连续,没有碎片;堆是存储单位,代表着数据,可被多个栈共享(包括成员中基本数据类型、引用和引用对象),所在区域不连续,会有碎片。
功能不同
栈内存用来存储局部变量和方法调用,而堆内存用来存储Java中的对象。无论是成员变量,局部变量,还是类变量,它们指向的对象都存储在堆内存中。
共享性不同
栈内存是线程私有的。 堆内存是所有线程共有的。
异常错误不同
如果栈内存或者堆内存不足都会抛出异常。 栈空间不足:StackOverFlowError或者OutOfMemoryError。 堆空间不足:OutOfMemoryError。
空间大小
栈的空间大小远远小于堆的。
对象的分配规则
对象优先分配在Eden区,如果Eden区没有足够的空间时,虚拟机执行一次Minor GC;
- 大对象直接进入老年代(大对象是指需要大量连续内存空间的对象) ,这样做的目的是避免在Eden区和两个Survivor区之间发生大量的内存拷贝(新生代采用复制算法收集内存)。
- 长期存活的对象进入老年代 ,虚拟机为每个对象定义了一个年龄计数器,如果对象经过了1次Minor GC那么对象会进入Survivor区,之后每经过一次Minor GC那么对象的年龄加1,直到达到阀值对象进入老年代。(默认年龄阈值是15)
- 动态判断对象的年龄 ,如果Survivor区中相同年龄的所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代。
- 空间分配担保 ,每次进行Minor GC时,JVM会计算Survivor区移至老年区的对象的平均大小,如果这个值大于老年区的剩余值大小则进行一次Full GC,如果小于,则检查HandlePromotionFailure设置,如果true则只进行Minor GC,如果false则进行Full GC。
JVM加载class文件的原理机制
JVM中类的装载是由类加载器(ClassLoader)和它的子类来实现的,Java中的类加载器是一个重要的Java运行时系统组件,它负责在运行时查找和装入类文件中的类。 由于Java的跨平台性,经过编译的Java源程序并不是一个可执行程序,而是一个或多个类文件。当Java程序需要使用某个类时,JVM会确保这个类已经被加载、连接(验证、准备和解析)和初始化。类的加载是指把类的.class文件中的数据读入到内存中,通常是创建一个字节数组读入.class文件,然后产生与所加载类对应的Class对象。加载完成后,Class对象还不完整,所以此时的类还不可用。当类被加载后就进入连接阶段,这一阶段包括验证、准备(为静态变量分配内存并设置默认的初始值)和解析(将符号引用替换为直接引用)三个步骤。最后JVM对类进行初始化。
初始化内容
- 如果类存在直接的父类并且这个类还没有被初始化,那么就先初始化父类;
- 如果类中存在初始化语句,就依次执行这些初始化语句。 类的加载是由类加载器完成的。
类加载器分类
类加载器包括:根加载器(BootStrap)、扩展加载器(Extension)、系统加载器(System)和用户自定义类加载器(java.lang.ClassLoader的子类)。类加载过程采取了双亲委托机制(PDM)。PDM更好的保证了Java平台的安全性,在该机制中,JVM自带的Bootstrap是根加载器,其他的加载器都有且仅有一个父类加载器。类的加载首先请求父类加载器加载,父类加载器无能为力时才由其子类加载器自行加载。JVM不会向Java程序提供对Bootstrap的引用。下面是关于几个类加载器的说明:
- Bootstrap:一般用本地代码实现,负责加载JVM基础核心类库(rt.jar);
- Extension:从java.ext.dirs系统属性所指定的目录中加载类库,它的父加载器是Bootstrap;
- System:又叫应用类加载器,其父类是Extension。它是应用最广泛的类加载器。它从环境变量classpath或者系统属性java.class.path所指定的目录中记载类,是用户自定义加载器的默认父加载器。
类的生命周期
类的生命周期包括这几个部分,加载、连接、初始化、使用和卸载,其中前三部是类的加载的过程,如下图:
- 加载:查找并加载类的二进制数据,在Java堆中也创建一个java.lang.Class类的对象;
- 连接:连接又包含三块内容:验证、准备、初始化;
- 验证,文件格式、元数据、字节码、符号引用验证;
- 准备,为类的静态变量分配内存,并将其初始化为默认值,如果类变量被final修饰,不会设置初始值,而是将其进行实际赋值;
- 解析,把类中的符号引用转换为直接引用(换句话说,解析是在引用转换的时候触发,无转换无解析)。
- 初始化:为类的静态变量赋予正确的初始值(初始化过程是单线程的,如果存在多线程,由一个线程执行,其他线程自旋);
- 使用:new出对象程序中使用;
- 卸载:执行垃圾回收。
Java的对象结构
Java对象由三个部分组成:对象头、实例数据、对齐填充。
对象头: 由两部分组成,第一部分存储对象自身的运行时数据:哈希码、GC分代年龄、锁标识状态、线程持有的锁、偏向线程ID(一般占32/64 bit)。第二部分是指针类型,指向对象的类元数据类型(即对象代表哪个类)。如果是数组对象,则对象头中还有一部分用来记录数组长度。
实例数据: 用来存储对象真正的有效信息(包括父类继承下来的和自己定义的)。
对齐填充: JVM要求对象起始地址必须是8字节的整数倍(8字节对齐)。
如何判断对象可以被回收
判断对象是否存活一般有两种方式:
- 引用计数:每个对象有一个引用计数属性,新增一个引用时计数加1,引用释放时计数减1,计数为0时可以回收。此方法简单,无法解决对象相互循环引用的问题;(对象的循环引用会引发内存的泄露问题)
- 可达性分析(Reachability Analysis):从GC Roots开始向下搜索,搜索所走过的路径称为引用链。当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的,不可达对象。
在可达性分析算法和引用计数法中被标记的对象就一定会被回收吗
不一定,此两种算法标记这些可被回收对象后,虚拟机会对其进行最后的判定。
- 首先判定当前对象是否有必要执行finalize方法,即这个类有没有重写finalize方法;
- 如果没有重写,则最终判定此对象死亡;
- 如果重写,会判定此方法是否已经被JVM执行过,如果执行过,则被判定为对象死亡;
- 如果没有执行,会将对象加入到F-Queue队列中,JVM会自动创建低优先级的Finalizer线程去执行F-Queue队列中的对象;
- 如果此对象的finalize方法中重新与引用链上的对象关联,就可以不被回收,获得重生,否则被判定为对象死亡。
需要注意:
- finalize方法如果存在死循环或者其他复杂逻辑,会导致F-Queue队列其他对象永久等待,会导致内存回收系统崩溃,JVM不保证等待finalize方法执行结束;
- finalize方法运行代价较高,不确定性大,不建议使用该方法。
垃圾收集算法
GC最基础的算法有标记清除法、标记整理法、复制算法、分代收集算法,我们常用的垃圾回收器一般都采用分代收集算法。
标记-清除算法
步骤:
- 第一步:利用可达性去遍历内存,把存活对象和垃圾对象进行标记;
- 第二步:在遍历一遍,将所有标记的对象回收掉。
特点:
效率不行,标记和清除的效率都不高;标记和清除后会产生大量的不连续的空间分片,可能会导致之后程序运行的时候需分配大对象而找不到连续分片而不得不触发一次GC。
标记-整理法
步骤:
- 第一步:利用可达性去遍历内存,把存活对象和垃圾对象进行标记;
- 第二步:将所有的存活的对象向一端移动,将端边界以外的对象都回收掉;
特点:
适用于存活对象多,垃圾少的情况;需要整理的过程,无空间碎片产生。
复制算法
将内存按照容量大小分为大小相等的两块,每次只使用一块,当一块使用完了,就将还存活的对象移到另一块上,然后在把使用过的内存空间移除。
特点:
不会产生空间碎片;内存使用率极低。
分代收集算法
根据内存对象的存活周期不同,将内存划分成几块,java虚拟机一般将内存分成新生代和老生代,在新生代中,有大量对象死去和少量对象存活,所以采用复制算法,只需要付出少量存活对象的复制成本就可以完成收集;老年代中因为对象的存活率高,没有额外的空间对他进行分配担保,所以采用标记清理或者标记整理算法进行回收。
对比
JVM调优命令有哪些
Sun JDK监控和故障处理命令有jps
、jstat
、jmap
、jhat
、jstack
、jinfo
。
jps
JVM Process Status Tool,显示指定系统内所有的HotSpot虚拟机进程。
命令格式:jps [options] [hostid]
-l
:输出应用程序main class的完整package名或者应用程序的jar文件完整路径名;-m
:输出传递给main方法的参数,在嵌入式jvm上可能是null;-v
:输出传递给JVM的参数;-V
:隐藏传递给JVM的参数;-p
:只显示pid,不显示class名称、jar名和传递给main方法的参数。
jstat
JVM statistics Monitoring是用于监视虚拟机运行时状态信息的命令,它可以显示出虚拟机进程中的类装载、内存、垃圾收集、JIT编译等运行数据。
命令格式:jstat [options vmid [interval[s|ms] [count]]]
-class
:显示类装载、卸载数量、总空间以及类装载所消耗的时间;-compiler
:显示JIT编译的相关信息;-gc
:显示和GC相关的堆和方法区信息;-gcapacity
:显示各个代的容量以及使用情况;-gcmetacapacity
:显示metaspace的大小和使用情况;-gcnew
:显示新生代的信息;-gcold
:显示老年代和永久代(元数据区)的信息;-gcoldcapacity
:显示老年代的信息;-gcutil
:显示垃圾收集信息;-gccause
:显示垃圾回收的相关信息;-printcompilation
:输出JIT编译的方法信息
jmap
JVM Memory Map命令用于生成heap dump文件。
命令格式:jmap [option] vmid
[pid]
:查看进程的内存映像信息;-heap
:显示Java堆详细信息;-histo
:显示堆中对象的统计信息,包括类、实例数量、合计数量;-clstats
:打印类加载器信息;-finalizerinfo
:显示在F-Queue队列等待Finalizer线程执行finalizer方法的对象;-dump
:生成堆转储快照。(jmap -dump:file=filename,format=b
)
jhat
JVM Heap Analysis Tool命令是与jmap搭配使用,用来分析jmap生成的dump,jhat内置了一个微型的HTTP/HTML服务器,生成dump的分析结果后,可以在浏览器中查看。
命令:jhat [filename]
此命令一般不会使用的原因:
- 在生产导出日志文件后,直接在生产服务器中分析日志对性能消耗较大;
- 将日志放到本地分析,有更多的优秀日志分析工具替代jhat。
jstack
用于生成java虚拟机当前时刻的线程快照。
命令格式:jstack [option] vmid
-l
:打印关于锁的附加信息,例如java.util.concurrent的ownable synchronizer列表打印的是运行中的栈信息;-F -l
:和jstack -l相对应,打印的是挂起的栈信息(如死锁);-m
:打印java和native c/C++框架的所有栈信息。
jinfo
JVM Configuration info 这个命令作用是实时查看和调整虚拟机运行参数。
命令格式:jinfo [option] <name> vmid
[pid]
:输出全部的参数和系统属性;-flag name
:输出对应名称的参数;-flag [+|-]name
:开启或者关闭对应名称的参数;-flag name=value
:设定对应名称的参数;-flags
:输出全部的参数;-sysprops
:输出系统属性,和System.getProperties()方法获取的相同。
常用的调优工具有哪些
常用调优工具分为两类,jdk自带监控工具:jconsole和jvisualvm,第三方有:MAT(MemoryAnalyzer Tool)、GChisto。
- jconsole,Java Monitoring and Management Console是从java5开始,在JDK中自带的java监控和管理控制台,用于对JVM中内存,线程和类等的监控;
- VisualVM,jdk自带全能工具,可以分析内存快照、线程快照;监控内存变化、GC变化等;
- MAT,Memory Analyzer Tool,一个基于Eclipse的内存分析工具,是一个快速、功能丰富的Java heap分析工具,它可以帮助我们查找内存泄漏和减少内存消耗;
- GChisto,一款专业分析gc日志的工具。
JVM常用的性能调优的参数有哪些
设定堆内存大小
-Xmx
:堆内存最大限制;-Xms
:堆内存的初始大小
设定新生代大小
新生代不宜太小,否则会出现频繁的Minor GC,不断刷新对象进入老年代,一般新生代占堆内存的2/8为宜,也是官方推荐的比率。
-XX:NewSize
:新生代大小;-XX:NewRatio
:新生代和老生代占比;-XX:SurvivorRatio
:伊甸园空间和幸存者空间的占比(一般不设置,采用默认的8:1即可)
设定垃圾回收器
- 年轻代用
-XX:+UseParNewGC
(ParNew GC是以低延迟为核心,注重的是应用系统,提高用户的使用体验,Parallel Scavenge是注重吞吐量,主要是计算型的服务使用) - 年老代用
-XX:+UseConcMarkSweepGC
对象一定分配在堆内吗,为什么?
对象不是一定分配在堆内的,Java通过逃逸分析,那些逃不出方法的对象会在栈上分配。
逃逸分析
逃逸分析(Escape Analysis),是一种可以有效减少Java 程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。通过逃逸分析,Java Hotspot编译器能够分析出一个新的对象的引用的使用范围,从而决定是否要将这个对象分配到堆上。
逃逸分析的好处
- 栈上分配:可以降低垃圾收集器运行的频率;
- 同步消除:如果发现某个对象只能从一个线程可访问,那么在这个对象上的操作可以不需要同步;
- 标量替换:把对象分解成一个个基本类型,并且内存分配不再是分配在堆上,而是分配在栈上。这样的好处有,第一减少内存使用,因为不用生成对象头。第二程序内存回收效率高,并且GC频率也会减少。
详细内容文章
Java的即时编译(Just In Time, JIT)及其优化
什么是Stop The world?什么是OopMap?什么是安全点?
进行垃圾回收的过程中,会涉及对象的移动。为了保证对象引用更新的正确性,必须暂停所有的用户线程,像这样的停顿,虚拟机设计者形象描述为Stop The World。也简称为STW。
在HotSpot中,有个数据结构(映射表)称为OopMap。一旦类加载动作完成的时候,HotSpot就会把对象内什么偏移量上是什么类型的数据计算出来,记录到OopMap。在即时编译过程中,也会在特定的位置生成 OopMap,记录下栈上和寄存器里哪些位置是引用。
这些特定的位置主要在:
- 循环的末尾(非 counted 循环)
- 方法临返回前 / 调用方法的call指令后
- 可能抛异常的位置
这些位置就叫作安全点(safepoint)。用户程序执行时并非在代码指令流的任意位置都能够在停顿下来开始垃圾收集,而是必须是执行到安全点才能够暂停。
安全点、安全区域和中断方式
在Stop The World的时候,会涉及安全点、安全区域以及中断方式的概念。
安全点(safe point)
- HotSpot通过OoMap来记录安全点,安全点是定在指令上,但不是每一条指令都可以作为安全点;
- 当指令让程序运行时间较长,此指令不会被定为安全点。
安全区域(safe region)
- 存在sleep或者blocked状态的线程无法响应JVM中断请求;
- 在此区域中引用关系不会发生变化,可以安全进行GC。
中断方式
- 抢先式中断:不需要线程执行代码主动配合,直接中断全部线程,如果线程执行在非安全点位置,等待其执行到安全点后中断;
- 主动式中断:需要线程执行代码主动配合,GC开始时设置一个标志,每个线程主动轮询标志,发现标志为true,主动中断,轮询的动作是在线程执行到每个安全点时触发。
指针碰撞和空闲列表
指针碰撞
一般情况下,JVM的对象都放在堆内存中(发生逃逸分析除外)。当类加载检查通过后,Java虚拟机开始为新生对象分配内存。如果Java堆中内存是绝对规整的,所有被使用过的的内存都被放到一边,空闲的内存放到另外一边,中间放着一个指针作为分界点的指示器,所分配内存仅仅是把那个指针向空闲空间方向挪动一段与对象大小相等的实例,这种分配方式就是指针碰撞。
空闲列表
如果Java堆内存中的内存并不是规整的,已被使用的内存和空闲的内存相互交错在一起,不可以进行指针碰撞了,虚拟机必须维护一个列表,记录哪些内存是可用的,在分配的时候从列表找到一块大小匹配的空间分配给对象实例,并更新列表上的记录,这种分配方式就是空闲列表。
如何判断一个对象是否存活?
判断一个对象是否存活,分为两种算法1:引用计数法;2:可达性分析算法;
引用计数法
给每一个对象设置一个引用计数器,当有一个地方引用该对象的时候,引用计数器就+1,引用失效时,引用计数器就-1;当引用计数器为0的时候,就说明这个对象没有被引用,也就是垃圾对象,等待回收;
缺点:无法解决循环引用的问题,当A引用B,B也引用A的时候,此时AB对象的引用都不为0,此时也就无法垃圾回收,所以一般主流虚拟机都不采用这个方法。(容易导致内存泄漏问题)
可达性分析法
从一个被称为GC Roots的对象向下搜索,如果一个对象到GC Roots没有任何引用链相连接时,说明此对象不可用,在java中可以作为GC Roots的对象有以下几种:
- 虚拟机栈中引用的对象
- 方法区类静态属性引用的变量
- 方法区常量池引用的对象
- 本地方法栈JNI引用的对象
但一个对象满足上述条件的时候,不会马上被回收,还需要进行两次标记;第一次标记:判断当前对象是否有finalize()方法并且该方法没有被执行过,若不存在则标记为垃圾对象,等待回收;若有的话,则进行第二次标记;第二次标记将当前对象放入F-Queue队列,并生成一个finalize线程去执行该方法,虚拟机不保证该方法一定会被执行,这是因为如果线程执行缓慢或进入了死锁,会导致回收系统的崩溃;如果执行了finalize方法之后仍然没有与GC Roots有直接或者间接的引用,则该对象会被回收。
JVM有哪些垃圾收集器
年轻代的垃圾收集器
年轻代的垃圾收集器有Serial收集器、ParNew收集器、Parallel Scanvenge收集器,针对不同场景下,使用不同的垃圾收集器。
Serial收集器
- 是JVM最基本的、历史发展最悠久的收集器
- 单线程收集器,在进行GC时,必须暂停其他所有工作线程,直到GC结束
- 对于单CPU的服务器表现很优秀,无线程交互的开销,专心做垃圾回收
ParNew收集器
- Serial收集器的多线程版本
- 响应速度优先,适用于一些应用项目,追求用户体验
- 默认开启的回收线程数量和CPU数量相同
Parallel Scanvenge收集器
- 多线程垃圾回收
- 吞吐量优先,适用于一些计算型项目,用户交互少,吞吐量=运行用户代码时间/(运行用户代码时间+GC时间)
老年代的垃圾收集器
老年代的垃圾收集器有Serial Old收集器、Parallel Old收集器、CMS收集器。
Serial Old收集器
- Serial收集器老年代版本
- 使用标记整理算法
- 单线程收集器,在进行GC时,必须暂停其他所有工作线程,直到GC结束
- 对于单CPU的服务器表现很优秀,无线程交互的开销,专心做垃圾回收
Parallel Old收集器
- Parallel Scanvenge老年代版本
- 使用标记整理算法
- 多线程回收机制,适合计算类型项目,强调吞吐量
CMS收集器
- 以获取最短回收停顿时间为目标
- 并发收集,低停顿,但是在并发的过程中可能会形成新的垃圾,也就是浮动垃圾
- 使用的是标记清理算法,会产生内存碎片
- 对CPU资源敏感,默认启动的回收线程是(CPU+3)/4,但在CPU数量4以上时,回收线程数量不小于CPU数量的25%
G1、ZGC回收器
G1(Garbage First)收集器 (标记-整理算法)
Java堆并行收集器,G1收集器是JDK1.7提供的一个新收集器,G1收集器基于“标记-整理”算法实现,也就是说不会产生内存碎片。此外,G1收集器不同于之前的收集器的一个重要特点是:G1回收的范围是整个Java堆(包括新生代,老年代),而前六种收集器回收的范围仅限于新生代或老年代。
ZGC (Z Garbage Collector)收集器
是一款由Oracle公司研发的,以低延迟为首要目标的一款垃圾收集器。它是基于动态Region内存布局,(暂时)不设年龄分代,使用了读屏障、染色指针和内存多重映射等技术来实现可并发的标记-整理算法的收集器。在 JDK 11 新加入,还在实验阶段,主要特点是:回收TB级内存(最大4T),停顿时间不超过10ms。
优点: 低停顿,高吞吐量, ZGC 收集过程中额外耗费的内存小。
缺点: 浮动垃圾。
垃圾回收器间的配合使用图
各个垃圾回收器对比
CMS收集器执行过程
CMS(Concurrent Mark Sweep,并发标记清除) 收集器是以获取最短回收停顿时间为目标的收集器(追求低停顿),它在垃圾收集时使得用户线程和 GC 线程并发执行,因此在垃圾收集过程中用户也不会感到明显的卡顿。
从名字就可以知道,CMS是基于“标记-清除”算法实现的。CMS 回收过程分为以下四步:
初始标记 (CMS initial mark):主要是标记 GC Root 开始的下级(注:仅下一级)对象,这个过程会 STW,但是跟 GC Root 直接关联的下级对象不会很多,因此这个过程其实很快。
并发标记 (CMS concurrent mark):根据上一步的结果,继续向下标识所有关联的对象,直到这条链上的最尽头。这个过程是多线程的,虽然耗时理论上会比较长,但是其它工作线程并不会阻塞,没有 STW。
重新标记(CMS remark):顾名思义,就是要再标记一次。为啥还要再标记一次?因为第 2 步并没有阻塞其它工作线程,其它线程在标识过程中,很有可能会产生新的垃圾。
并发清除(CMS concurrent sweep):清除阶段是清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发进行的。
CMS 的问题
- 并发回收导致CPU资源紧张
在并发阶段,它虽然不会导致用户线程停顿,但却会因为占用了一部分线程而导致应用程序变慢,降低程序总吞吐量。CMS默认启动的回收线程数是:(CPU核数 + 3)/ 4,当CPU核数不足四个时,CMS对用户程序的影响就可能变得很大。
- 无法清理浮动垃圾
在CMS的并发标记和并发清理阶段,用户线程还在继续运行,就还会伴随有新的垃圾对象不断产生,但这一部分垃圾对象是出现在标记过程结束以后,CMS无法在当次收集中处理掉它们,只好留到下一次垃圾收集时再清理掉。这一部分垃圾称为“浮动垃圾”。
- 并发失败(Concurrent Mode Failure)
由于在垃圾回收阶段用户线程还在并发运行,那就还需要预留足够的内存空间提供给用户线程使用,因此CMS不能像其他回收器那样等到老年代几乎完全被填满了再进行回收,必须预留一部分空间供并发回收时的程序运行使用。默认情况下,当老年代使用了 92% 的空间后就会触发 CMS 垃圾回收,这个值可以通过 -XX: CMSInitiatingOccupancyFraction 参数来设置。
这里会有一个风险:要是CMS运行期间预留的内存无法满足程序分配新对象的需要,就会出现一次“并发失败”(Concurrent Mode Failure),这时候虚拟机将不得不启动后备预案:Stop The World,临时启用 Serial Old 来重新进行老年代的垃圾回收,这样一来停顿时间就很长了。
- 内存碎片问题
CMS是一款基于“标记-清除”算法实现的回收器,这意味着回收结束时会有内存碎片产生。内存碎片过多时,将会给大对象分配带来麻烦,往往会出现老年代还有很多剩余空间,但就是无法找到足够大的连续空间来分配当前对象,而不得不提前触发一次 Full GC 的情况。
为了解决这个问题,CMS收集器提供了一个 -XX:+UseCMSCompactAtFullCollection 开关参数(默认开启),用于在 Full GC 时开启内存碎片的合并整理过程,由于这个内存整理必须移动存活对象,是无法并发的,这样停顿时间就会变长。还有另外一个参数 -XX:CMSFullGCsBeforeCompaction,这个参数的作用是要求CMS在执行过若干次不整理空间的 Full GC 之后,下一次进入 Full GC 前会先进行碎片整理(默认值为0,表示每次进入 Full GC 时都进行碎片整理)。
CMS收集器出现Concurrent Mode Failure的场景
CMS在进行并发清理的时候,是与用户线程同步进行,如果这个时候从年轻代晋升到老年代或者创建的大对象直接进入老年代,导致老年代的内存不足,抛出Concurrent Mode Failure异常,就会触发Serial Old,stop the world单线程进行垃圾收集,Serial Old采用的是标记整理的算法,在垃圾收集的同时,也会整理老年代的内存空间。从这些内容中总结触发Concurrent Mode Failure的可能如下:
- 在并发清理的时候,年轻代晋升到老年代或者大对象进入老年代的时候,内存担保机制失败,触发Concurrent Mode Failure;
- CMS在设置垃圾清理的上限值过高,导致CMS垃圾收集触发太晚,设置参数是
-XX:CMSInitiatingOccupancyFraction
,在JDK5的时候默认值是68%,JDK7及以后默认值是92%,一般保持默认值即可; - 空间碎片太多,因为CMS采用的是标记清理算法,不会对内存进行整理,导致在年轻代晋升到老年代或者大对象进入老年代的时候无足够的连续内存空间,内存担保机制失败,触发Concurrent Mode Failure;
- 垃圾产生的速度较快,年轻代的大多对象都是朝生夕死的,如果此时年轻代的空间较小,频繁的向老年代晋升对象,且晋升的对象会很快死亡,导致老年代回收压力大,产生的空间碎片也会增多,另一点就是年轻代空间小,大对象也会直接进入老年代,加剧了老年代的压力,容易触发Concurrent Mode Failure。
触发Concurrent Mode Failure,老年代垃圾收集器切换为Serial Old,让停顿时间边长。
引用类型的分类
- 强引用: 只要存在强引用,GC永远不会回收掉被引用的对象,类似Object obj = new Object()这类的引用。
- 软引用: 用来描述有用但非必需的对象,发生内存溢出异常之前,会把软引用进行第二次回收,如果回收以后还没有足够的内存,才会抛出内存溢出异常。
- 弱引用: 用来描述有用但非必需的对象,只能生存到下一次GC之前,当GC工作时,无论当前内存是否足够,都会回收只被弱引用关联的对象,WeekReference类可实现弱引用。
- 虚引用: 一个对象是否有虚引用存在,都对其生存时间没有影响,也无法通过一个虚引用来获取一个对象的实例,设置虚引用的目的是在这个对象被GC回收时收到一个系统通知,PhantomReference类可实现虚引用。
方法区回收目标
只有发生full GC的时候才会对方法区进行回收操作,回收的目标如下:
- 废弃的常量
- 无用的类,同时满足以下四个条件即被定义为无用的类
- Java堆中无该类的任何实例;
- 加载该类的ClassLoader已被回收;
- 该类的java.lang.Class对象没有被任何地方应用,无法通过反射访问该类的方法;
- 满足以上三个条件后,还需要看-Xnoclassgc参数,如果是true,表示不进行类卸载,也不会对类进行回收,默认是false。
详细说一下G1的回收过程
G1(Garbage First)回收器采用面向局部收集的设计思路和基于Region的内存布局形式,是一款主要面向服务端应用的垃圾回收器。G1设计初衷就是替换 CMS,成为一种全功能收集器。G1 在JDK9 之后成为服务端模式下的默认垃圾回收器,取代了 Parallel Scavenge 加 Parallel Old 的默认组合,而 CMS 被声明为不推荐使用的垃圾回收器。G1从整体来看是基于 标记-整理 算法实现的回收器,但从局部(两个Region之间)上看又是基于 标记-复制 算法实现的。
G1 回收过程,G1 回收器的运作过程大致可分为四个步骤:
初始标记(会STW):仅仅只是标记一下 GC Roots 能直接关联到的对象,并且修改TAMS指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。这个阶段需要停顿线程,但耗时很短,而且是借用进行Minor GC的时候同步完成的,所以G1收集器在这个阶段实际并没有额外的停顿。
并发标记:从 GC Roots 开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以后,还要重新处理在并发时有引用变动的对象。
最终标记(会STW):对用户线程做短暂的暂停,处理并发阶段结束后仍有引用变动的对象。
清理阶段(会STW):更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间。这里的操作涉及存活对象的移动,必须暂停用户线程,由多条回收器线程并行完成的。
JVM中一次完整的GC是什么样子的
先描述一下Java堆内存划分。
在 Java 中,堆被划分成两个不同的区域:新生代 ( Young )、老年代 ( Old ),新生代默认占总空间的 1/3,老年代默认占 2/3。 新生代有 3 个分区:Eden、To Survivor、From Survivor,它们的默认占比是 8:1:1。
新生代的垃圾回收(又称Minor GC)后只有少量对象存活,所以选用复制算法,只需要少量的复制成本就可以完成回收。
老年代的垃圾回收(又称Major GC)通常使用“标记-清理”或“标记-整理”算法。
再描述它们之间转化流程:
对象优先在Eden分配。当 eden 区没有足够空间进行分配时,虚拟机将发起一次 Minor GC。
- 在 Eden 区执行了第一次 GC 之后,存活的对象会被移动到其中一个 Survivor 分区;
- Eden 区再次 GC,这时会采用复制算法,将 Eden 和 from 区一起清理,存活的对象会被复制到 to 区;
- 移动一次,对象年龄加 1,对象年龄大于一定阀值会直接移动到老年代。GC年龄的阀值可以通过参数 -XX:MaxTenuringThreshold 设置,默认为 15;
- 动态对象年龄判定:Survivor 区相同年龄所有对象大小的总和 > (Survivor 区内存大小 * 这个目标使用率)时,大于或等于该年龄的对象直接进入老年代。其中这个使 用率通过 -XX:TargetSurvivorRatio 指定,默认为 50%;
- Survivor 区内存不足会发生担保分配,超过指定大小的对象可以直接进入老年代。
大对象直接进入老年代,大对象就是需要大量连续内存空间的对象(比如:字符串、数组),为了避免为大对象分配内存时由于分配担保机制带来的复制而降低效率。
老年代满了而无法容纳更多的对象,Minor GC 之后通常就会进行Full GC,Full GC 清理整个内存堆 – 包括年轻代、老年代和永久代。
Minor GC 和 Full GC 有什么不同
Minor GC: 只收集新生代的GC。
Full GC: 收集整个堆,包括 新生代,老年代,永久代(在 JDK 1.8及以后,永久代被移除,换为metaspace 元空间)等所有部分的模式。
Minor GC触发条件: 当Eden区满时,触发Minor GC。
Full GC触发条件:
通过Minor GC后进入老年代的平均大小大于老年代的可用内存。如果发现统计数据说之前Minor GC的平均晋升大小比目前old gen剩余的空间大,则不会触发Minor GC而是转为触发full GC。
老年代空间不够分配新的内存(或永久代空间不足,但只是JDK1.7有的,这也是用元空间来取代永久代的原因,可以减少Full GC的频率,减少GC负担,提升其效率)。
由Eden区、From Space区向To Space区复制时,对象大小大于To Space可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小。
调用System.gc时,系统建议执行Full GC,但是不必然执行。
介绍下空间分配担保原则
如果YougGC时新生代有大量对象存活下来,而 survivor 区放不下了,这时必须转移到老年代中,但这时发现老年代也放不下这些对象了,那怎么处理呢?其实JVM有一个老年代空间分配担保机制来保证对象能够进入老年代。
在执行每次 YoungGC 之前,JVM会先检查老年代最大可用连续空间是否大于新生代所有对象的总大小。因为在极端情况下,可能新生代 YoungGC 后,所有对象都存活下来了,而 survivor 区又放不下,那可能所有对象都要进入老年代了。这个时候如果老年代的可用连续空间是大于新生代所有对象的总大小的,那就可以放心进行 YoungGC。但如果老年代的内存大小是小于新生代对象总大小的,那就有可能老年代空间不够放入新生代所有存活对象,这个时候JVM就会先检查 -XX:HandlePromotionFailure 参数是否允许担保失败,如果允许,就会判断老年代最大可用连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次YoungGC,尽快这次YoungGC是有风险的。如果小于,或者 -XX:HandlePromotionFailure 参数不允许担保失败,这时就会进行一次 Full GC。
在允许担保失败并尝试进行YoungGC后,可能会出现三种情况:
- YoungGC后,存活对象小于survivor大小,此时存活对象进入survivor区中
- YoungGC后,存活对象大于survivor大小,但是小于老年大可用空间大小,此时直接进入老年代。
- YoungGC后,存活对象大于survivor大小,也大于老年大可用空间大小,老年代也放不下这些对象了,此时就会发生“Handle Promotion Failure”,就触发了 Full GC。如果 Full GC后,老年代还是没有足够的空间,此时就会发生OOM内存溢出了。
通过下图来了解空间分配担保原则:
什么是类加载?类加载的过程?
虚拟机把描述类的数据加载到内存里面,并对数据进行校验、解析和初始化,最终变成可以被虚拟机直接使用的class对象;
类的整个生命周期包括:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)7个阶段。其中准备、验证、解析3个部分统称为连接(Linking)。如图所示:
加载、验证、准备、初始化和卸载这5个阶段的顺序是确定的,类的加载过程必须按照这种顺序按部就班地开始,而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java语言的运行时绑定(也称为动态绑定或晚期绑定)
类加载过程如下:
加载,加载分为三步:
- 通过类的全限定性类名获取该类的二进制流;
- 将该二进制流的静态存储结构转为方法区的运行时数据结构;
- 在堆中为该类生成一个class对象;
验证:验证该class文件中的字节流信息复合虚拟机的要求,不会威胁到jvm的安全;
准备:为class对象的静态变量分配内存,初始化其初始值;
解析:该阶段主要完成符号引用转化成直接引用;
初始化:到了初始化阶段,才开始执行类中定义的java代码;初始化阶段是调用类构造器的过程。
什么是类加载器,常见的类加载器有哪些?
类加载器是指:通过一个类的全限定性类名获取该类的二进制字节流叫做类加载器;类加载器分为以下四种:
启动类加载器(BootStrapClassLoader):用来加载java核心类库,无法被java程序直接引用;
扩展类加载器(Extension ClassLoader):用来加载java的扩展库,java的虚拟机实现会提供一个扩展库目录,该类加载器在扩展库目录里面查找并加载java类;
系统类加载器(AppClassLoader):它根据java的类路径来加载类,一般来说,java应用的类都是通过它来加载的;
自定义类加载器:由java语言实现,继承自ClassLoader。
什么是双亲委派模型?为什么需要双亲委派模型?
当一个类加载器收到一个类加载的请求,他首先不会尝试自己去加载,而是将这个请求委派给父类加载器去加载,只有父类加载器在自己的搜索范围类查找不到给类时,子加载器才会尝试自己去加载该类;
为了防止内存中出现多个相同的字节码;因为如果没有双亲委派的话,用户就可以自己定义一个java.lang.String类,那么就无法保证类的唯一性。
补充:那怎么打破双亲委派模型?
自定义类加载器,继承ClassLoader类,重写loadClass方法和findClass方法。
列举一些你知道的打破双亲委派机制的例子,为什么要打破?
JNDI:通过引入线程上下文类加载器,可以在 Thread.setContextClassLoader 方法设置,默认是应用程序类加载器,来加载 SPI 的代码。有了线程上下文类加载器,就可以完成父类加载器请求子类加载器完成类加载的行为。打破的原因,是为了 JNDI 服务的类加载器是启动器类加载,为了完成高级类加载器请求子类加载器(即上文中的线程上下文加载器)加载类。
Tomcat:应用的类加载器优先自行加载应用目录下的 class,并不是先委派给父加载器,加载不了才委派给父加载器。
tomcat之所以造了一堆自己的classloader,大致是出于下面三类目的:
- 对于各个
webapp
中的class
和lib
,需要相互隔离,不能出现一个应用中加载的类库会影响另一个应用的情况,而对于许多应用,需要有共享的lib以便不浪费资源。 - 与
jvm
一样的安全性问题。使用单独的classloader
去装载tomcat
自身的类库,以免其他恶意或无意的破坏; - 热部署。
- 对于各个
tomcat类加载器如下图:
OSGi:实现模块化热部署,为每个模块都自定义了类加载器,需要更换模块时,模块与类加载器一起更换。其类加载的过程中,有平级的类加载器加载行为。打破的原因是为了实现模块热替换。
JDK 9:Extension ClassLoader 被 Platform ClassLoader 取代,当平台及应用程序类加载器收到类加载请求,在委派给父加载器加载前,要先判断该类是否能够归属到某一个系统模块中,如果可以找到这样的归属关系,就要优先委派给负责那个模块的加载器完成加载。打破的原因,是为了添加模块化的特性。
3. 多线程&并发篇
Java并发编程系列文章:并发编程
线程和进程有什么区别?
线程具有许多传统进程所具有的特征,故又称为轻型进程(Light—Weight Process)或进程元;而把传统的进程称为重型进程(Heavy—Weight Process),它相当于只有一个线程的任务。在引入了线程的操作系统中,通常一个进程都有若干个线程,至少包含一个线程。
根本区别:进程是操作系统资源分配的基本单位,而线程是处理器任务调度和执行的基本单位
资源开销:每个进程都有独立的代码和数据空间(程序上下文),程序之间的切换会有较大的开销;线程可以看做轻量级的进程,同一类线程共享代码和数据空间,每个线程都有自己独立的运行栈和程序计数器(PC),线程之间切换的开销小。
包含关系:如果一个进程内有多个线程,则执行过程不是一条线的,而是多条线(线程)共同完成的;线程是进程的一部分,所以线程也被称为轻权进程或者轻量级进程。
内存分配:同一进程的线程共享本进程的地址空间和资源,而进程之间的地址空间和资源是相互独立的
影响关系:一个进程崩溃后,在保护模式下不会对其他进程产生影响,但是一个线程崩溃整个进程都死掉。所以多进程要比多线程健壮。
执行过程:每个独立的进程有程序运行的入口. 顺序执行序列和程序出口。但是线程不能独立执行,必须依存在应用程序中,由应用程序提供多个线程执行控制,两者均可并发执行
Java创建线程的几种方式
创建线程的常用三种方式
- 继承Thread类
优势:
编写简单,如果需要访问当前线程,则无需使用Thread.currentThread()方法,直接使用this即可获得当前线程。
劣势:
线程类已经继承了Thread类,所以不能再继承其他父类。
- 实现Runnable、Callable接口的方式创建多线程
优势:
线程类只是实现了Runnable接口或Callable接口,还可以继承其他类。
在这种方式下,多个线程可以共享同一个target对象,所以非常适合多个相同线程来处理同一份资源的情况,从而可以将CPU、代码和数据分开,形成清晰的模型,较好地体现了面向对象的思想。
劣势:
编程稍微复杂,如果要访问当前线程,则必须使用Thread.currentThread()方法。
- 线程池方式创建
通过继承Thread类或者实现Runnable接口、Callable接口都可以实现多线程,不过实现Runnable接口与实现Callable接口的方式基本相同,只是Callable接口里定义的方法返回值,可以声明抛出异常。因此可以将实现Runnable接口和实现Callable接口归为一种方式。
采用实现Runnable、Callable接口的方式创建线程的优缺点
优点: 线程类只是实现了Runnable或者Callable接口,还可以继承其他类。这种方式下,多个线程可以共享一个target对象,所以非常适合多个相同线程来处理同一份资源的情况,从而可以将CPU、代码和数据分开,形成清晰的模型,较好的体现了面向对象的思想。
缺点: 编程稍微复杂一些,如果需要访问当前线程,则必须使用 Thread.currentThread() 方法。
- Callable规定(重写)的方法是call(),Runnable规定(重写)的方法是run()。
- Callable的任务执行后可返回值,而Runnable的任务是不能返回值的。
- Call方法可以抛出异常,run方法不可以。
- 运行Callable任务可以拿到一个Future对象,表示异步计算的结果。它提供了检查计算是否完成的方法,以等待计算的完成,并检索计算的结果。通过Future对象可以了解任务执行情况,可取消任务的执行,还可获取执行结果。
为什么要使用多线程呢?
- 从计算机底层来说: 线程可以比作是轻量级的进程,是程序执行的最小单位,线程间的切换和调度的成本远远小于进程。另外,多核 CPU 时代意味着多个线程可以同时运行,这减少了线程上下文切换的开销。
- 从当代互联网发展趋势来说: 现在的系统动不动就要求百万级甚至千万级的并发量,而多线程并发编程正是开发高并发系统的基础,利用好多线程机制可以大大提高系统整体的并发能力以及性能。
从计算机底层来说:
- 单核时代: 在单核时代多线程主要是为了提高 CPU 和 IO 设备的综合利用率。举个例子:当只有一个线程的时候会导致 CPU 计算时,IO 设备空闲;进行 IO 操作时,CPU 空闲。我们可以简单地说这两者的利用率目前都是 50%左右。但是当有两个线程的时候就不一样了,当一个线程执行 CPU 计算时,另外一个线程可以进行 IO 操作,这样两个的利用率就可以在理想情况下达到 100%了。
- 多核时代:多核时代多线程主要是为了提高 CPU 利用率。举个例子:假如我们要计算一个复杂的任务,我们只用一个线程的话,CPU 只会一个 CPU 核心被利用到,而创建多个线程就可以让多个 CPU 核心被利用到,这样就提高了 CPU 的利用率。
线程的状态流转
线程的生命周期及五种基本状态:
Java线程具有五中基本状态
1)新建状态(New):当线程对象创建后,即进入了新建状态,如:Thread t = new MyThread();
2)就绪状态(Runnable):当调用线程对象的start()方法(t.start();),线程即进入就绪状态。处于就绪状态的线程,只是说明此线程已经做好了准备,随时等待CPU调度执行,并不是说执行了t.start()此线程立即就会执行;
3)运行状态(Running):当CPU开始调度处于就绪状态的线程时,此时线程才得以真正执行,即进入到运行状态。注:就绪状态是进入到运行状态的唯一入口,也就是说,线程要想进入运行状态执行,首先必须处于就绪状态中;
4)阻塞状态(Blocked):处于运行状态中的线程由于某种原因,暂时放弃对CPU的使用权,停止执行,此时进入阻塞状态,直到其进入到就绪状态,才有机会再次被CPU调用以进入到运行状态。根据阻塞产生的原因不同,阻塞状态又可以分为三种:
等待阻塞:运行状态中的线程执行wait()方法,使本线程进入到等待阻塞状态;
同步阻塞:线程在获取synchronized同步锁失败(因为锁被其它线程所占用),它会进入同步阻塞状态;
其他阻塞:通过调用线程的sleep()或join()或发出了I/O请求时,线程会进入到阻塞状态。当sleep()状态超时. join()等待线程终止或者超时. 或者I/O处理完毕时,线程重新转入就绪状态。
5)死亡状态(Dead):线程执行完了或者因异常退出了run()方法,该线程结束生命周期。
什么是线程死锁?如何避免死锁?
死锁
- 多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止。
死锁必须具备以下四个条件
- 互斥条件:该资源任意一个时刻只由一个线程占用。
- 请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放。
- 不剥夺条件:线程已获得的资源在末使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源。
- 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。
如何避免线程死锁?
只要破坏产生死锁的四个条件中的其中一个就可以了
- 破坏互斥条件
这个条件我们没有办法破坏,因为我们用锁本来就是想让他们互斥的(临界资源需要互斥访问) - 破坏请求与保持条件
一次性申请所有的资源。 - 破坏不剥夺条件
占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。 - 破坏循环等待条件
靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。破坏循环等待条件。 - 锁排序法:(必须回答出来的点)
指定获取锁的顺序,比如某个线程只有获得A锁和B锁,才能对某资源进行操作,在多线程条件下,如何避免死锁?
通过指定锁的获取顺序,比如规定,只有获得A锁的线程才有资格获取B锁,按顺序获取锁就可以避免死锁。这通常被认为是解决死锁很好的一种方法。 - 使用显式锁中的ReentrantLock.try(long,TimeUnit)来申请锁
常见的对比
Runnable VS Callable
- Callable仅在 Java 1.5 中引入,目的就是为了来处理Runnable不支持的用例。Callable 接口可以返回结果或抛出检查异常;
- Runnable 接口不会返回结果或抛出检查异常;
- 如果任务不需要返回结果或抛出异常推荐使用Runnable接口,这样代码看起来会更加简洁;
- 工具类 Executors 可以实现 Runnable 对象和 Callable 对象之间的相互转换。
shutdown() VS shutdownNow()
- shutdown():关闭线程池,线程池的状态变为 SHUTDOWN。线程池不再接受新任务了,但是队列里的任务得执行完毕。
- shutdownNow():关闭线程池,线程的状态变为 STOP。线程池会终止当前正在运行的任务,并停止处理排队的任务并返回正在等待执行的 List。shutdownNow的原理是遍历线程池中的工作线程,然后逐个调用线程的interrupt方法来中断线程,所以无法响应中断的任务可能永远无法终止。
isTerminated() VS isShutdown()
- isShutDown():当调用 shutdown() 方法后返回为 true。
- isTerminated():当调用 shutdown() 方法后,并且所有提交的任务完成后返回为 true
sleep() 方法和 wait() 方法区别和共同点?
区别
sleep()
方法:是Thread类的静态方法,当前线程将睡眠n毫秒,线程进入阻塞状态。当睡眠时间到了,会解除阻塞,进入可运行状态,等待CPU的到来。睡眠不释放锁(如果有的话)。wait()
方法:是Object的方法,必须与synchronized
关键字一起使用,线程进入阻塞状态,当notify()
或者notifyall()
被调用后,会解除阻塞。但是,只有重新占用互斥锁之后才会进入可运行状态。睡眠时,会释放互斥锁。sleep()
方法没有释放锁,而wait()
方法释放了锁 。sleep()
通常被用于暂停执行,wait()
通常被用于线程间交互/通信(暂时释放锁)sleep()
方法执行完成后,线程会自动苏醒。或者可以使用wait(long timeout)
超时后线程会自动苏醒。wait()
方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的notify()
或者notifyAll()
方法
相同
- 两者都可以暂停线程的执行。
为什么我们调用 start() 方法时会执行 run() 方法,为什么我们不能直接调用 run() 方法
- new 一个 Thread,线程进入了新建状态; 调用
start()
会执行线程的相应准备工作,然后自动执行run()
方法的内容,(调用start()
方法,会启动一个线程并使线程进入了就绪状态,当分配到时间片后就可以开始运行了)这是真正的多线程工作。 - 直接执行
run()
方法,会把run()
方法当成一个普通方法去执行,并不会在某个线程中执行它,所以这并不是多线程工作。
调用start()
方法方可启动线程并使线程进入就绪状态,而run()
方法只是thread的一个普通方法调用,还是在主线程里执行。
Thread类中的yield方法有什么作用?
yield方法可以暂停当前正在执行的线程对象,让其它有相同优先级的线程执行。它是一个静态方法而且只保证当前线程放弃CPU占用而不能保证使其它线程一定能占用CPU,执行yield()的线程有可能在进入到暂停状态后马上又被执行。
谈谈volatile的使用及其原理
volatile的两层语义
volatile保证变量对所有线程的可见性:当volatile变量被修改,新值对所有线程会立即更新。或者理解为多线程环境下使用volatile修饰的变量的值一定是最新的。
JDK1.5以后volatile完全避免了指令重排优化,实现了有序性。
volatile的原理
获取JIT(即时Java编译器,把字节码解释为机器语言发送给处理器)的汇编代码,发现volatile多加了lock addl指令,这个操作相当于一个内存屏障,使得lock指令后的指令不能重排序到内存屏障前的位置。这也是为什么JDK1.5以后可以使用双锁检测实现单例模式。
lock前缀的另一层意义是使得本线程工作内存中的volatile变量值立即写入到主内存中,并且使得其他线程共享的该volatile变量无效化,这样其他线程必须重新从主内存中读取变量值。
线程阻塞的三种情况
当线程因为某种原因放弃 CPU 使用权后,即让出了 CPU 时间片,暂时就会停止运行,知道线程进入可运行状态(Runnable
),才有机会再次获得 CPU 时间片转入 RUNNING
状态。一般来讲,阻塞的情况可以分为如下三种:
- 等待阻塞(Object.wait -> 等待队列)
RUNNING
状态的线程执行 Object.wait()
方法后,JVM 会将线程放入等待序列(waitting queue);
- 同步阻塞(lock -> 锁池)
RUNNING
状态的线程在获取对象的同步锁时,若该 同步锁被其他线程占用,则 JVM 将该线程放入锁池(lock pool)中;
- 其他阻塞(sleep/join)
RUNNING
状态的线程执行 Thread.sleep(long ms)
或 Thread.join()
方法,或发出 I/O 请求时,JVM 会将该线程置为阻塞状态。当 sleep()
状态超时,join()
等待线程终止或超时. 或者 I/O 处理完毕时,线程重新转入可运行状态(RUNNABLE
)。
线程死亡的三种方式
- 正常结束
run()
或者 call()
方法执行完成后,线程正常结束;
- 异常结束
线程抛出一个未捕获的 Exception
或 Error
,导致线程异常结束;
- 调用 stop()
直接调用线程的 stop()
方法来结束该线程,但是一般不推荐使用该种方式,因为该方法通常容易导致死锁。
守护线程是什么?
守护线程是运行在后台的一种特殊进程。它独立于控制终端并且周期性地执行某种任务或等待处理某些发生的事件。在 Java 中垃圾回收线程就是特殊的守护线程。
了解Fork/Join框架吗?
Fork/Join框架是Java7提供的一个用于并行执行任务的框架,是一个把大任务分割成若干个小任务,最终汇总每个小任务结果后得到大任务结果的框架。
Fork/Join框架需要理解两个点,「分而治之」 和 「工作窃取算法」。
「分而治之」
以上Fork/Join框架的定义,就是分而治之思想的体现啦
「工作窃取算法」
把大任务拆分成小任务,放到不同队列执行,交由不同的线程分别执行时。有的线程优先把自己负责的任务执行完了,其他线程还在慢慢悠悠处理自己的任务,这时候为了充分提高效率,就需要工作盗窃算法啦~
工作盗窃算法就是,「某个线程从其他队列中窃取任务进行执行的过程」。一般就是指做得快的线程(盗窃线程)抢慢的线程的任务来做,同时为了减少锁竞争,通常使用双端队列,即快线程和慢线程各在一端。
CAS了解吗?
CAS:全称
Compare and swap
,即比较并交换,它是一条 CPU 同步原语。是一种硬件对并发的支持,针对多处理器操作而设计的一种特殊指令,用于管理对共享数据的并发访问。CAS 是一种无锁的非阻塞算法的实现。
CAS 包含了 3 个操作数:
- 需要读写的内存值 V
- 旧的预期值 A
- 要修改的更新值 B
当且仅当 V 的值等于 A 时,CAS 通过原子方式用新值 B 来更新 V 的 值,否则不会执行任何操作(他的功能是判断内存某个位置的值是否为预期值,如果是则更改为新的值,这个过程是原子的。)
CAS 并发原语体现在 Java 语言中的 sum.misc.Unsafe
类中的各个方法。调用 Unsafe 类中的 CAS 方法, JVM 会帮助我们实现出 CAS 汇编指令。这是一种完全依赖于硬件的功能,通过它实现了原子操作。再次强调,由于 CAS是一种系统原语,原语属于操作系统用于范畴,是由若干条指令组成的,用于完成某个功能的一个过程,并且原语的执行必须是连续的,在执行过程中不允许被中断,CAS 是一条 CPU 的原子指令,不会造成数据不一致问题。
CAS的缺陷
- ABA 问题
并发环境下,假设初始条件是A,去修改数据时,发现是A就会执行修改。但是看到的虽然是A,中间可能发生了A变B,B又变回A的情况。此时A已经非彼A,数据即使成功修改,也可能有问题。
可以通过AtomicStampedReference解决ABA问题,它是一个带有标记的原子引用类,通过控制变量值的版本来保证CAS的正确性。
- 循环时间长开销
自旋CAS,如果一直循环执行,一直不成功,会给CPU带来非常大的执行开销。
很多时候,CAS思想体现,是有个自旋次数的,就是为了避开这个耗时问题~
- 只能保证一个变量的原子操作
CAS 保证的是对一个变量执行操作的原子性,如果对多个变量操作时,CAS 目前无法直接保证操作的原子性的。
可以通过这两个方式解决这个问题
- 使用互斥锁来保证原子性;
- 将多个变量封装成对象,通过AtomicReference来处理或者使用锁synchronized实现原子性。
具体可参考CAS详细分析文章:原子操作CAS和相关原子操作类的实现原理
synchronized 和 volatile 的区别是什么?
volatile
解决的是内存可见性问题,会使得所有对 volatile
变量的读写都直接写入主存,即 保证了变量的可见性。
synchronized
解决的是执行控制的问题,它会阻止其他线程获取当前对象的监控锁,这样一来就让当前对象中被 synchronized
关键字保护的代码块无法被其他线程访问,也就是无法并发执行。而且,synchronized
还会创建一个 内存屏障,内存屏障指令保证了所有 CPU 操作结果都会直接刷到主存中,从而 保证操作的内存可见性,同时也使得这个锁的线程的所有操作都 happens-before
于随后获得这个锁的线程的操作。
两者的区别主要有如下:
- volatile 本质是在告诉 JVM 当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读取; synchronized 则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住。
- volatile 仅能使用在变量级别;synchronized 则可以使用在 变量. 方法. 和类级别的
- volatile 仅能实现变量的修改可见性,不能保证原子性;而synchronized 则可以 保证变量的修改可见性和原子性
- volatile 不会造成线程的阻塞;synchronized 可能会造成线程的阻塞。
- volatile 标记的变量不会被编译器优化;synchronized 标记的变量可以被编译器优化。
synchronized 和 Lock 有什么区别?
- synchronized 可以给类、方法、代码块加锁;而 lock 只能给代码块加锁。
- synchronized 不需要手动获取锁和释放锁,使用简单,发生异常会自动释放锁,不会造成死锁;而 lock 需要自己加锁和释放锁,如果使用不当没有 unLock()去释放锁就会造成死锁。
- 通过 Lock 可以知道有没有成功获取锁,而 synchronized 却无法办到。
为什么wait, notify 和 notifyAll这些方法不在thread类里面
明显的原因是JAVA提供的锁是对象级的而不是线程级的,每个对象都有锁,通过线程获得。如果线程需要等待某些锁那么调用对象中的wait()方法就有意义了。如果wait()方法定义在Thread类中,线程正在等待的是哪个锁就不明显了。简单的说,由于wait,notify和notifyAll都是锁级别的操作,所以把他们定义在Object类中因为锁属于对象。
Java中synchronized 和 ReentrantLock 有什么不同
- 两者都是可重入锁
可重入锁:重入锁,也叫做递归锁,可重入锁指的是在一个线程中可以多次获取同一把锁,比如:
一个线程在执行一个带锁的方法,该方法中又调用了另一个需要相同锁的方法,则该线程可以直接执行调用的方法,而无需重新获得锁,两者都是同一个线程每进入一次,锁的计数器都自增1,所以要等到锁的计数器下降为0时才能释放锁。
- synchronized 依赖于 JVM 而 ReentrantLock 依赖于 API
- synchronized 是依赖于 JVM 实现的,前面我们也讲到了 虚拟机团队在 JDK1.6 为 synchronized 关键字进行了很多优化,但是这些优化都是在虚拟机层面实现的
- ReentrantLock 是 JDK 层面实现的(也就是 API 层面,需要 lock() 和 unlock() 方法配合 try/finally 语句块来完成)
3.ReentrantLock 比 synchronized 增加了一些高级功能
相比synchronized,ReentrantLock增加了一些高级功能。主要来说主要有三点:①等待可中断;②可实现公平锁;③可实现选择性通知(锁可以绑定多个条件)
- 等待可中断。通过lock.lockInterruptibly()来实现这个机制。也就是说正在等待的线程可以选择放弃等待,改为处理其他事情。
- ReentrantLock可以指定是公平锁还是非公平锁。而synchronized只能是非公平锁。所谓的公平锁就是先等待的线程先获得锁。 ReentrantLock默认情况是非公平的,可以通过 ReentrantLock类的ReentrantLock(boolean fair)构造方法来制定是否是公平的。
- ReentrantLock类线程对象可以注册在指定的Condition中,从而可以有选择性的进行线程通知,在调度线程上更加灵活。 在使用notify()/notifyAll()方法进行通知时,被通知的线程是由 JVM 选择的,用ReentrantLock类结合Condition实例可以实现“选择性通知”
- 使用选择
- 除非需要使用 ReentrantLock 的高级功能,否则优先使用 synchronized。
- synchronized 是 JVM 实现的一种锁机制,JVM 原生地支持它,而 ReentrantLock 不是所有的 JDK 版本都支持。并且使用 synchronized 不用担心没有释放锁而导致死锁问题,因为 JVM 会确保锁的释放
synchronized的用法有哪些?
- 修饰普通方法:作用于当前对象实例,进入同步代码前要获得当前对象实例的锁
- 修饰静态方法:作用于当前类,进入同步代码前要获得当前类对象的锁,synchronized 关键字加到 static 静态方法和 synchronized(class)代码块上都是在 Class 类上锁
- 修饰代码块:指定加锁对象,对给定对象加锁,进入同步代码块前要获得给定对象的锁
特别注意:
如果一个线程A调用一个实例对象的非静态 synchronized 方法,而线程B需要调用这个实例对象所属类的静态 synchronized 方法,是允许的,不会发生互斥现象,因为访问静态 synchronized 方法占用的锁是当前类的锁
尽量不要使用 synchronized(String s) ,因为JVM中,字符串常量池具有缓冲功能
说一下 synchronized 底层实现原理?
synchronized 同步代码块的实现是通过 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。当执行 monitorenter 指令时,线程试图获取锁也就是获取 monitor(monitor对象存在于每个Java对象的对象头中,synchronized 锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因) 的持有权。
其内部包含一个计数器,当计数器为0则可以成功获取,获取后将锁计数器设为1也就是加1。相应的在执行 monitorexit 指令后,将锁计数器设为0,表明锁被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止
synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法,JVM 通过该 ACC_SYNCHRONIZED 访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。
多线程中 synchronized 锁升级的原理是什么?
synchronized
锁升级原理:在锁对象的对象头里面有一个 threadid 字段,在第一次访问的时候 threadid 为空,JVM 让其持有偏向锁,并将 threadid 设置为其线程 id,再次进入的时候会先判断 threadid 是否与其线程 id 一致,如果一致则可以直接使用此对象,如果不一致,则升级偏向锁为轻量级锁,通过自旋循环一定次数来获取锁,执行一定次数之后,如果还没有正常获取到要使用的对象,此时就会把锁从轻量级升级为重量级锁,此过程就构成了 synchronized 锁的升级。
** 锁的升级的目的:**锁升级是为了减低了锁带来的性能消耗。在 Java 6 之后优化 synchronized 的实现方式,使用了偏向锁升级为轻量级锁再升级到重量级锁的方式,从而减低了锁带来的性能消耗。
synchronized 为什么是非公平锁?非公平体现在哪些地方?
synchronized 的非公平其实在源码中应该有不少地方,因为设计者就没按公平锁来设计,核心有以下几个点:
当持有锁的线程释放锁时,该线程会执行以下两个重要操作:
- 先将锁的持有者 owner 属性赋值为 null
- 唤醒等待链表中的一个线程(假定继承者)。
在1和2之间,如果有其他线程刚好在尝试获取锁(例如自旋),则可以马上获取到锁。
- 当线程尝试获取锁失败,进入阻塞时,放入链表的顺序,和最终被唤醒的顺序是不一致的,也就是说你先进入链表,不代表你就会先被唤醒。
JVM对synchronized的优化有哪些?
从最近几个JDK版本中可以看出,Java的开发团队一直在对synchronized优化,其中最大的一次优化就是在JDK6的时候,新增了两个锁状态,通过锁消除、锁粗化、自旋锁等方法使用各种场景,给synchronized性能带来了很大的提升。
锁膨胀
上面讲到锁有四种状态,并且会因实际情况进行膨胀升级,其膨胀方向是:无锁——>偏向锁——>轻量级锁——>重量级锁,并且膨胀方向不可逆。
偏向锁
一句话总结它的作用:减少统一线程获取锁的代价。在大多数情况下,锁不存在多线程竞争,总是由同一线程多次获得,那么此时就是偏向锁。
核心思想:
如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word
的结构也就变为偏向锁结构,当该线程再次请求锁时,无需再做任何同步操作,即获取锁的过程只需要检查Mark Word
的锁标记位为偏向锁以及当前线程ID等于Mark Word
的ThreadID即可,这样就省去了大量有关锁申请的操作。
轻量级锁
轻量级锁是由偏向锁升级而来,当存在第二个线程申请同一个锁对象时,偏向锁就会立即升级为轻量级锁。注意这里的第二个线程只是申请锁,不存在两个线程同时竞争锁,可以是一前一后地交替执行同步块。
重量级锁
重量级锁是由轻量级锁升级而来,当同一时间有多个线程竞争锁时,锁就会被升级成重量级锁,此时其申请锁带来的开销也就变大。
重量级锁一般使用场景会在追求吞吐量,同步块或者同步方法执行时间较长的场景。
锁消除
消除锁是虚拟机另外一种锁的优化,这种优化更彻底,在JIT编译时,对运行上下文进行扫描,去除不可能存在竞争的锁。比如下面代码的method1和method2的执行效率是一样的,因为object锁是私有变量,不存在所得竞争关系。
锁粗化
锁粗化是虚拟机对另一种极端情况的优化处理,通过扩大锁的范围,避免反复加锁和释放锁。比如下面method3经过锁粗化优化之后就和method4执行效率一样了。
自旋锁与自适应自旋锁
轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。
自旋锁:许多情况下,共享数据的锁定状态持续时间较短,切换线程不值得(内核态和用户态切换),通过让线程执行循环等待锁的释放,不让出CPU。如果得到锁,就顺利进入临界区。如果还不能获得锁,那就会将线程在操作系统层面挂起,这就是自旋锁的优化方式。但是它也存在缺点:如果锁被其他线程长时间占用,一直不释放CPU,会带来许多的性能开销。
自适应自旋锁:这种相当于是对上面自旋锁优化方式的进一步优化,它的自旋的次数不再固定,其自旋的次数由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定,这就解决了自旋锁带来的缺点。
为什么要引入偏向锁和轻量级锁?为什么重量级锁开销大?
重量级锁底层依赖于系统的同步函数来实现,在 linux 中使用 pthread_mutex_t
(互斥锁)来实现。
这些底层的同步函数操作会涉及到:操作系统用户态和内核态的切换、进程的上下文切换,而这些操作都是比较耗时的,因此重量级锁操作的开销比较大。
而在很多情况下,可能获取锁时只有一个线程,或者是多个线程交替获取锁,在这种情况下,使用重量级锁就不划算了,因此引入了偏向锁和轻量级锁来降低没有并发竞争时的锁开销。
synchronized 锁能降级吗?
可以的。
具体的触发时机:在全局安全点(safepoint)中,执行清理任务的时候会触发尝试降级锁。
当锁降级时,主要进行了以下操作:
1)恢复锁对象的 markword 对象头;
2)重置 ObjectMonitor,然后将该 ObjectMonitor 放入全局空闲列表,等待后续使用。
有三个线程T1,T2,T3,如何保证顺序执行
在多线程中有多种方法让线程按特定顺序执行,你可以用线程类的join()方法在一个线程中启动另一个线程,另外一个线程完成该线程继续执行。为了确保三个线程的顺序你应该先启动最后一个(T3调 用T2,T2调用T1),这样T1就会先完成而T3最后完成。
SynchronizedMap和ConcurrentHashMap有什么区别
SynchronizedMap()和Hashtable一样,实现上在调用map所有方法时,都对整个map进行同步。而ConcurrentHashMap的实现却更加精细,它对map中的所有桶加了锁。如果一个线程在访问ConcurrentHashMap某个桶时,其他线程,仍然可以对map执行某些操作。
所以,ConcurrentHashMap在性能以及安全性方面,明显比Collections.synchronizedMap()更加有优势。同时,同步操作精确控制到桶,这样,即使在遍历map时,如果其他线程试图对map进行数据修改,也不会抛出ConcurrentModificationException。
什么是线程安全
线程安全就是说多线程访问同一段代码,不会产生不确定的结果。
又是一个理论的问题,各式各样的答案有很多,我给出一个个人认为解释地最好的:如果你的代码在多线程下执行和在单线程下执行永远都能获得一样的结果,那么你的代码就是线程安全的。
这个问题有值得一提的地方,就是线程安全也是有几个级别的:
- 不可变
像String、Integer、Long这些,都是final类型的类,任何一个线程都改变不了它们的值,要改变除非新创建一个,因此这些不可变对象不需要任何同步手段就可以直接在多线程环境下使用。
- 绝对线程安全
不管运行时环境如何,调用者都不需要额外的同步措施。要做到这一点通常需要付出许多额外的代价,Java中标注自己是线程安全的类,实际上绝大多数都不是线程安全的,不过绝对线程安全的类,Java中也有,比方说CopyOnWriteArrayList、CopyOnWriteArraySet(一致性的快照)。
- 相对线程安全
相对线程安全也就是我们通常意义上所说的线程安全,像Vector这种,add、remove方法都是原子操作,不会被打断,但也仅限于此,如果有个线程在遍历某个Vector、有个线程同时在add这个Vector,99%的情况下都会出现ConcurrentModificationException,也就是fail-fast机制。
- 线程非安全
这个就没什么好说的了,ArrayList、LinkedList、HashMap等都是线程非安全的类。
说一说自己对于 synchronized 关键字的了解
synchronized关键字解决的是多个线程之间访问资源的同步性,synchronized关键字可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。 另外,在 Java 早期版本中,synchronized属于重量级锁,效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的Mutex Lock 来实现的,Java 的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,这也是为什么早期的synchronized 效率低的原因。庆幸的是在 Java 6 之后 Java 官方对从 JVM 层面对synchronized 较大优化,所以现在的 synchronized 锁效率也优化得很不错了。JDK1.6对锁的实现引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。
参考文章:死磕Java并发:深入分析synchronized的实现原理
volatile关键字的作用
一旦一个共享变量(类的成员变量、类的静态成员变量)被volatile修饰之后,那么就具备了两层语义:
- 保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。
- 禁止进行指令重排序。
- volatile本质是在告诉JVM当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读取;synchronized则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住。
- volatile仅能使用在变量级别;synchronized则可以使用在变量、方法、和类级别的。
- volatile仅能实现变量的修改可见性,并不能保证原子性;synchronized则可以保证变量的修改可见性和原子性。
- volatile不会造成线程的阻塞;synchronized可能会造成线程的阻塞。
volatile标记的变量不会被编译器优化;synchronized标记的变量可以被编译器优化。
如何避免死锁
这个问题个人觉得需要从两个方面来回答,首先需要说明产生死锁的原因,以及产生死锁的场景,然后根据不同的死锁场景给出避免死锁的解决方案。
场景一:静态死锁
两个固定的锁对象,锁A和锁B,线程1、2都需要同时获取A、B锁才能执行业务逻辑,但是出现线程1先获取锁A,线程2先获取锁B,当线程1再去获取B锁的时候,发现A锁已经被占用,处于阻塞状态,同时线程2获取A锁的时候也无法获取,最终导致两个线程都处于等待对方释放锁,形成死锁的情况。这种情况主要是因为多个线程获取锁的顺序不同导致的,因此调整顺序相同即可。
场景二:动态死锁
锁是不固定的,比如用户A给用户B转账,用户B同时也在给用户A转账。此时如果设置锁的顺序都是先锁自己再锁对方,从代码角度来看是没有问题的,但是从实际角度来看,锁的顺序已经发生变化。此时可以通过对两个动态锁取hash值,根据hash值的比较,确定锁的顺序。若出现hash值相同,引入第三方锁,实现双层加锁,达到避免死锁的情况发生。
参考文章:死锁的两种体现形式分析和解决方案
为什么要用线程池?
线程池提供了一种限制和管理资源(包括执行一个任务)。 每个线程池还维护一些基本统计信息,例如已完成任务的数量。
使用线程池的好处:
- 降低资源消耗。 通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
- 提高响应速度。 当任务到达时,任务可以不需要的等到线程创建就能立即执行。
- 提高线程的可管理性。 线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。
执行execute()方法和submit()方法的区别是什么呢?
execute()
方法用于提交不需要返回值的任务,所以无法判断任务是否被线程池执行成功与否;submit()
方法用于提交需要返回值的任务。线程池会返回一个future类型的对象,通过这个future对象可以判断任务是否执行成功,并且可以通过future的get()方法来获取返回值,get()
方法会阻塞当前线程直到任务完成,而使用get(long timeout,TimeUnit unit)
方法则会阻塞当前线程一段时间后立即返回,这时候有可能任务没有执行完。
线程池的主要参数有哪些,以及其之间的关系
涉及的主要参数:corePoolSize
、maximumPoolSize
、keepAliveTime
、unit
、workQueue
、threadFactory
、handler
。
核心线程数:corePoolSize
表示线程池的核心线程数量,当线程池中的线程数量小于这个值得时候,再有新任务进来时,会直接创建新的线程去执行,当线程数量达到corePoolSize
时,就会将溢出的任务放到workQueue
中,等待执行。
这里需要注意的是,很多小伙伴都认为设置这个参数后,线程池启动就会创建corePoolSize
数量的线程,并放在线程池中等待使用,但事实并非如此,而是有任务进来的时候才会创建线程,初始的线程池是空的,如果需要线程池拥有corePoolSize
数量的线程被创建,那就在初始化的时候调用prestartAllCoreThreads()
方法。
最大线程数:maximumPoolSize
表示允许最大线程数,上一个参数中说多余的任务会放到workQueue
中,当workQueue
也满了以后,就会判断当前线程池中线程数量是不是小于maximumPoolSize
,如果小于,那就接着创建线程,执行任务。
最大存活时间:keepAliveTime
线程空闲下来后存活的时间,这个存活时间只对线程池中线程数量超过corePoolSize
的时候才会生效。也就是说,当线程池中的线程数量小于corePoolSize
,空闲的线程是不会根据这个时间销毁,而是一直存在于线程池中。
时间单位:unit
很简单,就是指定keepAliveTime
的时间单位。
工作缓存队列:workQueue
保存任务的阻塞队列,是在线程数达到corePoolSize
后,新进来的任务会被保存到workQueue
中,这里使用的是有界的队列(BlockingQueue<Runnable> workQueue
)。
线程制造工厂:threadFactory
采用工厂模式,用于创建线程的工厂,并给线程设定线程名。
饱和策略:RejectedExecutionHandler
- AbortPolicy:直接抛出异常(默认)
- CallerRunsPolicy:用调用者所在的线程来执行任务
- DiscardOldestPolicy:丢弃阻塞队列中最老的任务,队列中最靠前的任务
- DiscardPolicy:直接忽略当前添加进来的任务
但是实际应用中这四种方式都不会使用,后两种放弃任务应该是没有使用的,无论任务的重要性,线上项目都不会忍受任务的丢失。可以通过实现RejectedExecutionHandler
接口,实现其内唯一接口方法rejectedExecution(Runnable r, ThreadPoolExecutor executor)
即可自己来制定符合实际要求的策略。
rejectedExecution
这里传入的参数是Runnable
,但是在执行submit
的时候,传入的是Callable
,那么这个策略怎么执行呢(参数对不上)。其实在AbstractExecutorService
中对submit(Callable c)
方法进行了封装,将执行的结果封装到了RunnableFuture
中,RunnableFuture
继承了Future
和Runnable
接口,这个时候在拒绝策略的时候,可以将RunnableFuture
对象直接传入给rejectedExecution
方法,不受影响。
常用的线程池有哪些
- newSingleThreadExecutor:创建一个单线程化的Executor,即只创建唯一的工作者线程来执行任务,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序(FIFO, LIFO, 优先级)执行。如果这个线程异常结束,会有另一个取代它,保证顺序执行。单工作线程最大的特点是可保证顺序地执行各个任务,并且在任意给定的时间不会有多个线程是活动的。
- newFixedThreadPool:创建固定大小的线程池,每次提交一个任务就创建一个线程,直到线程达到线程池的最大大小。
FixedThreadPool是一个典型且优秀的线程池,它具有线程池提高程序效率和节省创建线程时所耗的开销的优点。但是,在线程池空闲时,即线程池中没有可运行任务时,它不会释放工作线程,还会占用一定的系统资源。
- newCachedThreadPool:创建一个可缓存的线程池,此线程池不会对线程池大小做限制,线程池大小完全依赖于操作系统(或者说JVM)能够创建的最大线程大小。
这种类型的线程池特点是:
工作线程的创建数量几乎没有限制(其实也有限制的,数目为Interger. MAX_VALUE), 这样可灵活的往线程池中添加线程。
如果长时间没有往线程池中提交任务,即如果工作线程空闲了指定的时间(默认为1分钟),则该工作线程将自动终止。终止后,如果你又提交了新的任务,则线程池重新创建一个工作线程。
在使用CachedThreadPool时,一定要注意控制任务的数量,否则,由于大量线程同时运行,很有可能会造成系统OOM。
- newScheduledThreadPool:创建一个定长的线程池,而且支持定时的以及周期性的任务执行,支持定时及周期性任务执行。
参考文章:线程池ThreadPool浅层面原理分析
线程池常用的阻塞队列有哪些?
- LinkedBlockingQueue
对于 FixedThreadPool 和 SingleThreadExector 而言,它们使用的阻塞队列是容量为 Integer.MAX_VALUE 的 LinkedBlockingQueue,可以认为是无界队列。由于 FixedThreadPool 线程池的线程数是固定的,所以没有办法增加特别多的线程来处理任务,这时就需要 LinkedBlockingQueue 这样一个没有容量限制的阻塞队列来存放任务。
这里需要注意,由于线程池的任务队列永远不会放满,所以线程池只会创建核心线程数量的线程,所以此时的最大线程数对线程池来说没有意义,因为并不会触发生成多于核心线程数的线程。
- SynchronousQueue
第二种阻塞队列是 SynchronousQueue,对应的线程池是 CachedThreadPool。线程池 CachedThreadPool 的最大线程数是 Integer 的最大值,可以理解为线程数是可以无限扩展的。CachedThreadPool 和上一种线程池 FixedThreadPool 的情况恰恰相反,FixedThreadPool 的情况是阻塞队列的容量是无限的,而这里 CachedThreadPool 是线程数可以无限扩展,所以 CachedThreadPool 线程池并不需要一个任务队列来存储任务,因为一旦有任务被提交就直接转发给线程或者创建新线程来执行,而不需要另外保存它们。
我们自己创建使用 SynchronousQueue 的线程池时,如果不希望任务被拒绝,那么就需要注意设置最大线程数要尽可能大一些,以免发生任务数大于最大线程数时,没办法把任务放到队列中也没有足够线程来执行任务的情况。
- DelayedWorkQueue
第三种阻塞队列是DelayedWorkQueue,它对应的线程池分别是 ScheduledThreadPool 和 SingleThreadScheduledExecutor,这两种线程池的最大特点就是可以延迟执行任务,比如说一定时间后执行任务或是每隔一定的时间执行一次任务。
DelayedWorkQueue 的特点是内部元素并不是按照放入的时间排序,而是会按照延迟的时间长短对任务进行排序,内部采用的是“堆”的数据结构。之所以线程池 ScheduledThreadPool 和 SingleThreadScheduledExecutor 选择 DelayedWorkQueue,是因为它们本身正是基于时间执行任务的,而延迟队列正好可以把任务按时间进行排序,方便任务的执行。
简述一下你对线程池的理解
(如果问到了这样的问题,可以展开的说一下线程池如何用、线程池的好处、线程池的启动策略)合理利用线程池能够带来三个好处:
- 降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
- 提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立即执行。
- 提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。
线程池执行任务的流程?
- 线程池执行execute/submit方法向线程池添加任务,当任务小于核心线程数corePoolSize,线程池中可以创建新的线程。
- 当任务大于核心线程数corePoolSize,就向阻塞队列添加任务。
- 如果阻塞队列已满,需要通过比较参数maximumPoolSize,在线程池创建新的线程,当线程数量大于maximumPoolSize,说明当前设置线程池中线程已经处理不了了,就会执行饱和策略。
源码中线程池是怎么复用线程的?
源码中ThreadPoolExecutor中有个内置对象Worker,每个worker都是一个线程,worker线程数量和参数有关,每个worker会while死循环从阻塞队列中取数据,通过置换worker中Runnable对象,运行其run方法起到线程置换的效果,这样做的好处是避免多线程频繁线程切换,提高程序运行性能。
线程池核心线程数怎么设置
分为CPU密集型和IO密集型。
CPU密集型
这种任务消耗的主要是 CPU 资源,可以将线程数设置为 N(CPU 核心数)+1,比 CPU 核心数多出来的一个线程是为了防止线程偶发的缺页中断,或者其它原因导致的任务暂停而带来的影响。一旦任务暂停,CPU 就会处于空闲状态,而在这种情况下多出来的一个线程就可以充分利用 CPU 的空闲时间。
IO密集型
这种任务应用起来,系统会用大部分的时间来处理 I/O 交互,而线程在处理 I/O 的时间段内不会占用 CPU 来处理,这时就可以将 CPU 交出给其它线程使用。因此在 I/O 密集型任务的应用中,我们可以多配置一些线程,具体的计算方法是 : 核心线程数=CPU核心数量*2。
当以上都不适用时,选用动态化线程池,看美团技术团队的实践:Java线程池实现原理及其在美团业务中的实践
线程安全需要保证几个基本特征
原子性: 简单说就是相关操作不会中途被其他线程干扰,一般通过同步机制实现。
可见性: 是一个线程修改了某个共享变量,其状态能够立即被其他线程知晓,通常被解释为将线程本地状态反映到主内存上,volatile 就是负责保证可见性的。
有序性: 是保证线程内串行语义,避免指令重排等。
Executor和Executors的区别?
Executors 工具类的不同方法按照我们的需求创建了不同的线程池,来满足业务的需求。
Executor 接口对象能执行我们的线程任务。ExecutorService接口继承了Executor接口并进行了扩展,提供了更多的方法我们能获得任务执行的状态并且可以获取任务的返回值。
使用ThreadPoolExecutor 可以创建自定义线程池。Future 表示异步计算的结果,他提供了检查计算是否完成的方法,以等待计算的完成,并可以使用get()方法获取计算的结果。
线程之间是如何通信
线程之间的通信有两种方式:共享内存和消息传递。
共享内存
在共享内存的并发模型里,线程之间共享程序的公共状态,线程之间通过写-读内存中的公共状态来隐式进行通信。典型的共享内存通信方式,就是通过共享对象进行通信消息传递。
例如线程 A 与 线程 B 之间如果要通信的话,那么就必须经历下面两个步骤:
- 线程 A 把本地内存 A 更新过得共享变量刷新到主内存中去;
- 线程 B 到主内存中去读取线程 A 之前更新过的共享变量。
消息传递
在消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过明确的发送消息来显式进行通信。在 Java 中典型的消息传递方式,就是wait()
和notify()
,或者BlockingQueue
。
说说ThreadLocal原理和应用场景
ThreadLocal可以理解为线程本地变量,他会在每个线程都创建一个副本,那么在线程之间访问内部副本变量就行了,做到了线程之间互相隔离,相比于synchronized的做法是用空间来换时间。
ThreadLocal有一个静态内部类ThreadLocalMap,ThreadLocalMap又包含了一个Entry数组,Entry本身是一个弱引用,他的key是指向ThreadLocal的弱引用,Entry具备了保存key value键值对的能力。
弱引用的目的是为了防止内存泄露,如果是强引用那么ThreadLocal对象除非线程结束否则始终无法被回收,弱引用则会在下一次GC的时候被回收。
但是这样还是会存在内存泄露的问题,假如key和ThreadLocal对象被回收之后,entry中就存在key为null,但是value有值的entry对象,但是永远没办法被访问到,同样除非线程结束运行。
但是只要ThreadLocal使用恰当,在使用完之后调用remove方法删除Entry对象,实际上是不会出现这个问题的。
由结构图是可以看出:
- Thread对象中持有一个ThreadLocal.ThreadLocalMap的成员变量。
- ThreadLocalMap内部维护了Entry数组,每个Entry代表一个完整的对象,key是ThreadLocal本身,value是ThreadLocal的泛型值。
ThreadLocal的应用场景有
- 数据库连接池
- 会话管理中使用
参考文章:
知道ThreadLocal 内存泄露问题吗?
先看看一下的TreadLocal的引用示意图哈,
ThreadLocalMap中使用的 key 为 ThreadLocal 的弱引用,如下:
弱引用:只要垃圾回收机制一运行,不管JVM的内存空间是否充足,都会回收该对象占用的内存。
弱引用比较容易被回收。因此,如果ThreadLocal(ThreadLocalMap的Key)被垃圾回收器回收了,但是因为ThreadLocalMap生命周期和Thread是一样的,它这时候如果不被回收,就会出现这种情况:ThreadLocalMap的key没了,value还在,这就会 「造成了内存泄漏问题」。
如何 「解决内存泄漏问题」 ?使用完ThreadLocal后,及时调用remove()方法释放内存空间。
happen-before规则
虽然指令重排提高了并发的性能,但是Java虚拟机会对指令重排做出一些规则限制,并不能让所有的指令都随意的改变执行位置,主要有以下几点:
- 单线程每个操作,happen-before于该线程中任意后续操作
- volatile写happen-before于后续对这个变量的读
- synchronized解锁happen-before后续对这个锁的加锁
- final变量的写happen-before于final域对象的读,happen-before后续对final变量的读
- 传递性规则,A先于B,B先于C,那么A一定先于C发生
多线程有什么用
一个可能在很多人看来很扯淡的一个问题:我会用多线程就好了,还管它有什么用?在我看来,这个回答更扯淡。所谓”知其然知其所以然”,”会用”只是”知其然”,”为什么用”才是”知其所以然”,只有达到”知其然知其所以然”的程度才可以说是把一个知识点运用自如。OK,下面说说我对这个问题的看法:
发挥多核CPU的优势
随着工业的进步,现在的笔记本、台式机乃至商用的应用服务器至少也都是双核的,4核、8核甚至16核的也都不少见,如果是单线程的程序,那么在双核CPU上就浪费了50%,在4核CPU上就浪费了75%。单核CPU上所谓的”多线程”那是假的多线程,同一时间处理器只会处理一段逻辑,只不过线程之间切换得比较快,看着像多个线程”同时”运行罢了。多核CPU上的多线程才是真正的多线程,它能让你的多段逻辑同时工作,多线程,可以真正发挥出多核CPU的优势来,达到充分利用CPU的目的。
防止阻塞
从程序运行效率的角度来看,单核CPU不但不会发挥出多线程的优势,反而会因为在单核CPU上运行多线程导致线程上下文的切换,而降低程序整体的效率。但是单核CPU我们还是要应用多线程,就是为了防止阻塞。试想,如果单核CPU使用单线程,那么只要这个线程阻塞了,比方说远程读取某个数据吧,对端迟迟未返回又没有设置超时时间,那么你的整个程序在数据返回回来之前就停止运行了。多线程可以防止这个问题,多条线程同时运行,哪怕一条线程的代码执行读取数据阻塞,也不会影响其它任务的执行。
便于建模
这是另外一个没有这么明显的优点了。假设有一个大的任务A,单线程编程,那么就要考虑很多,建立整个程序模型比较麻烦。但是如果把这个大的任务A分解成几个小任务,任务B、任务C、任务D,分别建立程序模型,并通过多线程分别运行这几个任务,那就简单很多了。
说说CyclicBarrier和CountDownLatch的区别
两个看上去有点像的类,都在J.U.C下,都可以用来表示代码运行到某个点上,二者的区别在于:
- CyclicBarrier的某个线程运行到某个点上之后,该线程即停止运行,直到所有的线程都到达了这个点,所有线程才重新运行;CountDownLatch则不是,某线程运行到某个点上之后,只是给某个数值-1而已,该线程继续运行;
- CyclicBarrier只能唤起一个任务,CountDownLatch可以唤起多个任务;
- CyclicBarrier可重用,CountDownLatch不可重用,计数值为0该CountDownLatch就不可再用了。
了解ReentrantLock吗?
ReetrantLock
是一个可重入的独占锁,主要有两个特性,一个是支持公平锁和非公平锁,一个是可重入。
ReetrantLock
实现依赖于AQS(AbstractQueuedSynchronizer)
。
ReetrantLock
主要依靠AQS
维护一个阻塞队列,多个线程对加锁时,失败则会进入阻塞队列。等待唤醒,重新尝试加锁。
ReadWriteLock是什么?
首先ReentrantLock
某些时候有局限,如果使用ReentrantLock
,可能本身是为了防止线程A在写数据、线程B在读数据造成的数据不一致,但这样,如果线程C在读数据、线程D也在读数据,读数据是不会改变数据的,没有必要加锁,但是还是加锁了,降低了程序的性能。
因为这个,才诞生了读写锁ReadWriteLock
。ReadWriteLock
是一个读写锁接口,ReentrantReadWriteLock
是ReadWriteLock
接口的一个具体实现,实现了读写的分离,读锁是共享的,写锁是独占的,读和读之间不会互斥,读和写、写和读、写和写之间才会互斥,提升了读写的性能。
什么是AQS
- AQS 是一个锁框架,它定义了锁的实现机制,并开放出扩展的地方,让子类去实现,比如我们在 lock 的时候,AQS 开放出 state 字段,让子类可以根据 state 字段来决定是否能够获得锁,对于获取不到锁的线程 AQS 会自动进行管理,无需子类锁关心,这就是 lock 时锁的内部机制,封装的很好,又暴露出子类锁需要扩展的地方;
- AQS 底层是由同步队列 + 条件队列联手组成,同步队列管理着获取不到锁的线程排队和释放,条件队列是在一定场景下,对同步队列的补充,比如获得锁的线程从空队列中拿数据,肯定是拿不到数据的,这时候条件队列就会管理该线程,使该线程阻塞;
- AQS 围绕两个队列,提供了四大场景,分别是:获得锁、释放锁、条件队列的阻塞,条件队列的唤醒,分别对应着 AQS 架构图中的四种颜色的线的走向。
参考文章:AQS实现方式和独占锁、共享锁的原理分析
AQS使用了哪些设计模式?
AQS同步器的设计是基于模板方法模式的,如果需要自定义同步器一般的方式是这样(模板方法模式很经典的一个应用):
- 使用者继承
AbstractQueuedSynchronizer
并重写指定的方法。(这些重写方法很简单,无非是对于共享资源state的获取和释放) - 将AQS组合在自定义同步组件的实现中,并调用其模板方法,而这些模板方法会调用使用者重写的方法。
这和我们以往通过实现接口的方式有很大区别,这是模板方法模式很经典的一个运用。
AQS使用了模板方法模式,自定义同步器时需要重写下面几个AQS提供的模板方法:
isHeldExclusively()//该线程是否正在独占资源。只有用到condition才需要去实现它。
tryAcquire(int)//独占方式。尝试获取资源,成功则返回true,失败则返回false。
tryRelease(int)//独占方式。尝试释放资源,成功则返回true,失败则返回false。
tryAcquireShared(int)//共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
tryReleaseShared(int)//共享方式。尝试释放资源,成功则返回true,失败则返回false。
了解AQS中同步队列的数据结构吗?
- 当前线程获取同步状态失败,同步器将当前线程机等待状态等信息构造成一个Node节点加入队列,放在队尾,同步器重新设置尾节点
- 加入队列后,会阻塞当前线程
- 同步状态被释放并且同步器重新设置首节点,同步器唤醒等待队列中第一个节点,让其再次获取同步状态
了解AQS 对资源的共享方式吗?
AQS定义两种资源共享方式
Exclusive(独占):只有一个线程能执行,如ReentrantLock。又可分为公平锁和非公平锁:
- 公平锁:按照线程在队列中的排队顺序,先到者先拿到锁
- 非公平锁:当线程要获取锁时,无视队列顺序直接去抢锁,谁抢到就是谁的
Share(共享):多个线程可同时执行,如
Semaphore
/CountDownLatch
。
ReentrantReadWriteLock
可以看成是组合式,因为ReentrantReadWriteLock
也就是读写锁允许多个线程同时对某一资源进行读。
不同的自定义同步器争用共享资源的方式也不同。自定义同步器在实现时只需要实现共享资源 state 的获取与释放方式即可,至于具体线程等待队列的维护(如获取资源失败入队/唤醒出队等),AQS已经在顶层实现好了。
AQS 组件了解吗?
- Semaphore(信号量)-允许多个线程同时访问:
synchronized
和ReentrantLock
都是一次只允许一个线程访问某个资源,Semaphore
(信号量)可以指定多个线程同时访问某个资源。 - CountDownLatch (倒计时器):
CountDownLatch
是一个同步工具类,用来协调多个线程之间的同步。这个工具通常用来控制线程等待,它可以让某一个线程等待直到倒计时结束,再开始执行。 - CyclicBarrier(循环栅栏):
CyclicBarrier
和CountDownLatch
非常类似,它也可以实现线程间的技术等待,但是它的功能比CountDownLatch
更加复杂和强大。主要应用场景和CountDownLatch
类似。CyclicBarrier
的字面意思是可循环使用(Cyclic)的屏障(Barrier)。它要做的事情是,让一组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续干活。CyclicBarrier
默认的构造方法是CyclicBarrier(int parties)
,其参数表示屏障拦截的线程数量,每个线程调用await()
方法告诉CyclicBarrier
我已经到达了屏障,然后当前线程被阻塞。
了解Semaphore吗
Semaphore
就是一个信号量,它的作用是限制某段代码块的并发数。Semaphore
有一个构造函数,可以传入一个int型整数n,表示某段代码最多只有n个线程可以访问,如果超出了n,那么请等待,等到某个线程执行完毕这段代码块,下一个线程再进入。由此可以看出如果Semaphore
构造函数中传入的int型整数n=1
,相当于变成了一个synchronized
了。
什么是阻塞队列?阻塞队列的实现原理是什么?如何使用阻塞队列来实现生产者-消费者模型?
阻塞队列(BlockingQueue)是一个支持两个附加操作的队列。
这两个附加的操作是:在队列为空时,获取元素的线程会等待队列变为非空。当队列满时,存储元素的线程会等待队列可用。
阻塞队列常用于生产者和消费者的场景,生产者是往队列里添加元素的线程,消费者是从队列里拿元素的线程。阻塞队列就是生产者存放元素的容器,而消费者也只从容器里拿元素。
JDK7提供了7个阻塞队列。分别是:
ArrayBlockingQueue
:一个由数组结构组成的有界阻塞队列。LinkedBlockingQueue
:一个由链表结构组成的有界阻塞队列。PriorityBlockingQueue
:一个支持优先级排序的无界阻塞队列。DelayQueue
:一个使用优先级队列实现的无界阻塞队列。SynchronousQueue
:一个不存储元素的阻塞队列。LinkedTransferQueue
:一个由链表结构组成的无界阻塞队列。LinkedBlockingDeque
:一个由链表结构组成的双向阻塞队列。
Java 5之前实现同步存取时,可以使用普通的一个集合,然后在使用线程的协作和线程同步可以实现生产者,消费者模式,主要的技术就是用好wait
,notify
,notifyAll
,sychronized
这些关键字。而在Java5之后,可以使用阻塞队列来实现,此方式大大简少了代码量,使得多线程编程更加容易,安全方面也有保障。
BlockingQueue
接口是Queue
的子接口,它的主要用途并不是作为容器,而是作为线程同步的的工具,因此他具有一个很明显的特性,当生产者线程试图向BlockingQueue
放入元素时,如果队列已满,则线程被阻塞,当消费者线程试图从中取出一个元素时,如果队列为空,则该线程会被阻塞,正是因为它所具有这个特性,所以在程序中多个线程交替向BlockingQueue
中放入元素,取出元素,它可以很好的控制线程之间的通信。
阻塞队列使用最经典的场景就是socket
客户端数据的读取和解析,读取数据的线程不断将数据放入队列,然后解析线程不断从队列取数据解析。
什么是多线程中的上下文切换
在上下文切换过程中,CPU会停止处理当前运行的程序,并保存当前程序运行的具体位置以便之后继续运行。从这个角度来看,上下文切换有点像我们同时阅读几本书,在来回切换书本的同时我们需要记住每本书当前读到的页码。
在程序中,上下文切换过程中的“页码”信息是保存在进程控制块(PCB)中的。PCB还经常被称作“切换桢”(switchframe)。“页码”信息会一直保存到CPU的内存中,直到他们被再次使用。
上下文切换是存储和恢复CPU状态的过程,它使得线程执行能够从中断点恢复执行。上下文切换是多任务操作系统和多线程环境的基本特征。
什么是Daemon线程?它有什么意义?
所谓后台(daemon)线程,也叫守护线程,是指在程序运行的时候在后台提供一种通用服务的线程,并且这个线程并不属于程序中不可或缺的部分。
因此,当所有的非后台线程结束时,程序也就终止了,同时会杀死进程中的所有后台线程。反过来说, 只要有任何非后台线程还在运行,程序就不会终止。
必须在线程启动之前调用setDaemon()
方法,才能把它设置为后台线程。
注意:后台进程在不执行finally
子句的情况下就会终止其run()
方法。
比如:JVM的垃圾回收线程就是Daemon线程,Finalizer也是守护线程。
乐观锁和悲观锁的理解及如何实现,有哪些实现方式?
悲观锁:总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。
传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。再比如Java里面的同步原语synchronized
关键字的实现也是悲观锁。
乐观锁:顾名思义,就是很乐观,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号等机制。
乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库提供的类似于write_condition
机制,其实都是提供的乐观锁。
在Java中java.util.concurrent.atomic
包下面的原子变量类就是使用了乐观锁的一种实现方式CAS实现的。
介绍一下 Atomic 原子类
Atomic 是指一个操作是不可中断的。即使是在多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程干扰。
所以,所谓原子类说简单点就是具有原子操作特征的类。
并发包java.util.concurrent
的原子类都存放在java.util.concurrent.atomic
下:
JUC 包中的原子类是哪4类?
基本类型
使用原子的方式更新基本类型:
AtomicInteger
: 整型原子类AtomicLong
: 长整型原子类AtomicBoolean
: 布尔型原子类
数组类型
使用原子的方式更新数组里的某个元素:
AtomicIntegerArray
: 整型数组原子类AtomicLongArray
: 长整型数组原子类AtomicReferenceArray
: 引用类型数组原子类
引用类型
使用原子的方式更新引用类型:
AtomicReference
: 引用类型原子类AtomicStampedReference
: 原子更新带有版本号的引用类型。该类将整型数值与引用关联起来,可用于解决原子的更新数据和数据的版本号,可以解决使用 CAS 进行原子更新时可能出现的 ABA 问题。AtomicMarkableReference
: 原子更新带有标记位的引用类型。对象属性修改类型AtomicIntegerFieldUpdater
: 原子更新整型字段的更新器AtomicLongFieldUpdater
: 原子更新长整型字段的更新器AtomicMarkableReference
: 原子更新带有标记位的引用类型
简单介绍一下 AtomicInteger 类的原理
AtomicInteger
类主要利用CAS和volatile
和native
方法来保证原子操作,从而避免synchronized
的高开销,执行效率大为提升。
AtomicInteger
类的部分源码:
// 更新操作时提供“比较并替换”的作用
private static final Unsafe unsafe = Unsafe.getUnsafe();
private static final long valueOffset;
static {
try{
valueOffset = unsafe.objectFieldOffset(AutomicInteger.class.getDeclaredField("value"));
}catch(Exception ex){
throw new Error(ex);
}
}
private volatile int value;
4. Spring篇
Spring系列文章:Spring
使用Spring框架的好处是什么?
这么问的话,就直接说Spring框架的好处就可以了。比如说Spring有以下特点:
轻量: Spring 是轻量的,基本的版本大约2MB。
控制反转: Spring通过控制反转实现了松散耦合,对象给出它们的依赖,而不是创建或查找依赖的对象。
面向切面的编程(AOP): Spring支持面向切面的编程,并且把应用业务逻辑和系统服务分开。
容器: Spring 包含并管理应用中对象的生命周期和配置。
MVC框架: Spring的WEB框架是个精心设计的框架,是Web框架的一个很好的替代品。
事务管理: Spring 提供一个持续的事务管理接口,可以扩展到上至本地事务下至全局事务(JTA)。
异常处理: Spring 提供方便的API把具体技术相关的异常(比如由JDBC,Hibernate or JDO抛出的)转化为一致的unchecked 异常。
Autowired和Resource关键字的区别
@Resource
和@Autowired
都是做bean的注入时使用,其实@Resource
并不是Spring的注解,它的包是javax.annotation.Resource
,需要导入,但是Spring支持该注解的注入。
@Autowired
@Autowired
为Spring提供的注解,只按照byType注入。@Autowired
注解是按照类型(byType)装配依赖对象,默认情况下它要求依赖对象必须存在,如果允许null值,可以设置它的required属性为false。如果我们想使用按照名称(byName)来装配,可以结合@Qualifier
注解一起使用。
@Resource
@Resource
默认按照ByName自动注入,由J2EE提供。@Resource
有两个重要的属性:name和type,而Spring将@Resource
注解的name属性解析为bean的名字,而type属性则解析为bean的类型。所以,如果使用name属性,则使用byName的自动注入策略,而使用type属性时则使用byType自动注入策略。如果既不指定name也不指定type属性,这时将通过反射机制使用byName自动注入策略。
@Resource装配顺序
- 如果同时指定了name和type,则从Spring上下文中找到唯一匹配的bean进行装配,找不到则抛出异常。
- 如果指定了name,则从上下文中查找名称(id)匹配的bean进行装配,找不到则抛出异常。
- 如果指定了type,则从上下文中找到类似匹配的唯一bean进行装配,找不到或是找到多个,都会抛出异常。
- 如果既没有指定name,又没有指定type,则自动按照byName方式进行装配;如果没有匹配,则回退为一个原始类型进行匹配,如果匹配则自动装配。
注意:@Resource
的作用相当于@Autowired
,只不过@Autowired
按照byType自动注入。
Spring MVC工作原理
原理图如下:
- 用户发送请求至前端控制器
DispatcherServlet
。 DispatcherServlet
收到请求调用HandlerMapping处理器映射器。- 处理器映射器找到具体的处理器(可以根据xml配置、注解进行查找),生成处理器对象及处理器拦截器(如果有则生成)一并返回给
DispatcherServlet
。 DispatcherServlet
调用HandlerAdapter
处理器适配器。HandlerAdapter
经过适配调用具体的处理器(Controller
,也叫后端控制器)。Controller
执行完成返回ModelAndView
。HandlerAdapter
将controller
执行结果ModelAndView
返回给DispatcherServlet
。DispatcherServlet
将ModelAndView
传给ViewReslover
视图解析器。ViewReslover
解析后返回具体View
。DispatcherServlet
根据View
进行渲染视图(即将模型数据填充至视图中)。DispatcherServlet
响应用户。
简单介绍 Spring MVC 的核心组件
那么接下来就简单介绍一下 DispatcherServlet
和九大组件(按使用顺序排序的):
组件 | 说明 |
---|---|
DispatcherServlet | Spring MVC 的核心组件,是请求的入口,负责协调各个组件工作 |
MultipartResolver | 内容类型( Content-Type )为 multipart/* 的请求的解析器,例如解析处理文件上传的请求,便于获取参数信息以及上传的文件 |
HandlerMapping | 请求的处理器匹配器,负责为请求找到合适的 HandlerExecutionChain 处理器执行链,包含处理器(handler )和拦截器们(interceptors ) |
HandlerAdapter | 处理器的适配器。因为处理器 handler 的类型是 Object 类型,需要有一个调用者来实现 handler 是怎么被执行。Spring 中的处理器的实现多变,比如用户处理器可以实现 Controller 接口、HttpRequestHandler 接口,也可以用 @RequestMapping 注解将方法作为一个处理器等,这就导致 Spring MVC 无法直接执行这个处理器。所以这里需要一个处理器适配器,由它去执行处理器 |
HandlerExceptionResolver | 处理器异常解析器,将处理器( handler )执行时发生的异常,解析( 转换 )成对应的 ModelAndView 结果 |
RequestToViewNameTranslator | 视图名称转换器,用于解析出请求的默认视图名 |
LocaleResolver | 本地化(国际化)解析器,提供国际化支持 |
ThemeResolver | 主题解析器,提供可设置应用整体样式风格的支持 |
ViewResolver | 视图解析器,根据视图名和国际化,获得最终的视图 View 对象 |
FlashMapManager | FlashMap 管理器,负责重定向时,保存参数至临时存储(默认 Session) |
Spring MVC 对各个组件的职责划分的比较清晰。DispatcherServlet
负责协调,其他组件则各自做分内之事,互不干扰。
谈谈你对Spring的AOP理解
AOP(Aspect-Oriented Programming,面向切面编程)能够将那些与业务无关,却为业务模块所共同调用的逻辑或责任(例如事务处理、日志管理、权限控制等)封装起来,便于减少系统的重复代码,降低模块间的耦合度,并有利于未来的可扩展性和可维护性。
Spring AOP是基于动态代理的,如果要代理的对象实现了某个接口,那么Spring AOP就会使用JDK动态代理去创建代理对象;而对于没有实现接口的对象,就无法使用JDK动态代理,转而使用CGlib动态代理生成一个被代理对象的子类来作为代理。
注意:图中的implements和extend。即一个是接口,一个是实现类。
当然也可以使用AspectJ,Spring AOP中已经集成了AspectJ,AspectJ应该算得上是Java生态系统中最完整的AOP框架了。使用AOP之后我们可以把一些通用功能抽象出来,在需要用到的地方直接使用即可,这样可以大大简化代码量。我们需要增加新功能也方便,提高了系统的扩展性。日志功能、事务管理和权限管理等场景都用到了AOP。
AOP 有哪些实现方式?
实现 AOP 的技术,主要分为两大类:
- 静态代理 - 指使用 AOP 框架提供的命令进行编译,从而在编译阶段就可生成 AOP 代理类,因此也称为编译时增强;
- 编译时编织(特殊编译器实现)
- 类加载时编织(特殊的类加载器实现)。
- 动态代理 - 在运行时在内存中“临时”生成 AOP 动态代理类,因此也被称为运行时增强。
JDK
动态代理:通过反射来接收被代理的类,并且要求被代理的类必须实现一个接口 。JDK 动态代理的核心是 InvocationHandler 接口和 Proxy 类 。CGLIB
动态代理: 如果目标类没有实现接口,那么Spring AOP
会选择使用CGLIB
来动态代理目标类 。CGLIB
( Code Generation Library ),是一个代码生成的类库,可以在运行时动态的生成某个类的子类,注意,CGLIB
是通过继承的方式做的动态代理,因此如果某个类被标记为final
,那么它是无法使用CGLIB
做动态代理的。
Spring AOP和AspectJ AOP有什么区别
- Spring AOP是属于运行时增强,而AspectJ是编译时增强。Spring AOP基于代理(Proxying),而AspectJ基于字节码操作(Bytecode Manipulation)。
- Spring AOP已经集成了AspectJ,AspectJ应该算得上是Java生态系统中最完整的AOP框架了。
- AspectJ相比于Spring AOP功能更加强大,但是Spring AOP相对来说更简单。
- 如果我们的切面比较少,那么两者性能差异不大。但是,当切面太多的话,最好选择AspectJ,它比SpringAOP快很多。
在Spring AOP 中,关注点和横切关注点区别是什么
关注点是应用中一个模块的行为,一个关注点可能会被定义成一个我们想实现的一个功能。 横切关注点是一个关注点,此关注点是整个应用都会使用的功能,并影响整个应用,比如日志,安全和数据传输,几乎应用的每个模块都需要的功能。因此这些都属于横切关注点。
那什么是连接点呢?连接点代表一个应用程序的某个位置,在这个位置我们可以插入一个AOP切面,它实际上是个应用程序执行Spring AOP的位置。
切入点是什么?切入点是一个或一组连接点,通知将在这些位置执行。可以通过表达式或匹配的方式指明切入点。
面向切面编程中,通知是什么,有哪些类型
通知是个在方法执行前或执行后要做的动作,实际上是程序执行时要通过SpringAOP框架触发的代码段。
Spring切面可以应用五种类型的通知:
before
:前置通知,在一个方法执行前被调用。after
: 在方法执行之后调用的通知,无论方法执行是否成功。after-returning
: 仅当方法成功完成后执行的通知。after-throwing
: 在方法抛出异常退出时执行的通知。around
: 在方法执行之前和之后调用的通知。
切面执行的顺序参考文章:Spring Aspect切面执行顺序
说说你对Spring的IoC是怎么理解的
- IoC就是控制反转,是指创建对象的控制权的转移。以前创建对象的主动权和时机是由自己把控的,而现在这种权力转移到Spring容器中,并由容器根据配置文件去创建实例和管理各个实例之间的依赖关系。对象与对象之间松散耦合,也利于功能的复用。DI依赖注入,和控制反转是同一个概念的不同角度的描述,即应用程序在运行时依赖IoC容器来动态注入对象需要的外部资源。
- 最直观的表达就是,IoC让对象的创建不用去new了,可以由spring自动生产,使用java的反射机制,根据配置文件在运行时动态的去创建对象以及管理对象,并调用对象的方法的。
- Spring的IoC有三种注入方式 :构造器注入、setter方法注入、根据注解注入。
拓展理解: IoC让相互协作的组件保持松散的耦合,而AOP编程允许你把遍布于应用各层的功能分离出来形成可重用的功能组件。
什么是依赖注入?可以通过多少种方式完成依赖注入?
在依赖注入中,您不必创建对象,但必须描述如何创建它们。您不是直接在代码中将组件和服务连接在一起,而是描述配置文件中哪些组件需要哪些服务。由 IoC 容器将它们装配在一起。
通常,依赖注入可以通过三种方式完成,即:
- 构造函数注入
- setter 注入
- 接口注入
在 Spring Framework 中,仅使用构造函数和 setter 注入。
如何理解IoC和DI?
IOC就是控制反转,通俗的说就是我们不用自己创建实例对象,这些都交给Spring的bean工厂帮我们创建管理。这也是Spring的核心思想,通过面向接口编程的方式来实现对业务组件的动态依赖。这就意味着IOC是Spring针对解决程序耦合而存在的。在实际应用中,Spring通过配置文件(xml或者properties)指定需要实例化的java类(类名的完整字符串),包括这些java类的一组初始化值,通过加载读取配置文件,用Spring提供的方法(getBean())就可以获取到我们想要的根据指定配置进行初始化的实例对象。
优点:IOC或依赖注入减少了应用程序的代码量。它使得应用程序的测试很简单,因为在单元测试中不再需要单例或JNDI查找机制。简单的实现以及较少的干扰机制使得松耦合得以实现。IOC容器支持勤性单例及延迟加载服务。
DI:DI—Dependency Injection,即“依赖注入”:组件之间依赖关系由容器在运行期决定,形象的说,即由容器动态的将某个依赖关系注入到组件之中。依赖注入的目的并非为软件系统带来更多功能,而是为了提升组件重用的频率,并为系统搭建一个灵活、可扩展的平台。通过依赖注入机制,我们只需要通过简单的配置,而无需任何代码就可指定目标需要的资源,完成自身的业务逻辑,而不需要关心具体的资源来自何处,由谁实现。
区分 BeanFactory 和 ApplicationContext?
BeanFactory | ApplicationContext |
---|---|
它使用懒加载 | 它使用即时加载 |
它使用语法显式提供资源对象 | 它自己创建和管理资源对象 |
不支持国际化 | 支持国际化 |
不支持基于依赖的注解 | 支持基于依赖的注解 |
BeanFactory和ApplicationContext的优缺点分析:
BeanFactory的优缺点:
- 优点:应用启动的时候占用资源很少,对资源要求较高的应用,比较有优势;
- 缺点:运行速度会相对来说慢一些。而且有可能会出现空指针异常的错误,而且通过Bean工厂创建的Bean生命周期会简单一些。
ApplicationContext的优缺点:
- 优点:所有的Bean在启动的时候都进行了加载,系统运行的速度快;在系统启动的时候,可以发现系统中的配置问题。
- 缺点:把费时的操作放到系统启动中完成,所有的对象都可以预加载,缺点就是内存占用较大。
区分构造函数注入和 setter 注入
构造函数注入 | setter 注入 |
---|---|
没有部分注入 | 有部分注入 |
不会覆盖 setter 属性 | 会覆盖 setter 属性 |
任意修改都会创建一个新实例 | 任意修改不会创建一个新实例 |
适用于设置很多属性 | 适用于设置少量属性 |
spring 提供了哪些配置方式?
- 基于 xml 配置
bean 所需的依赖项和服务在 XML 格式的配置文件中指定。这些配置文件通常包含许多 bean 定义和特定于应用程序的配置选项。它们通常以 bean 标签开头。例如:
<bean id="studentbean" class="org.edureka.firstSpring.StudentBean">
<property name="name" value="Edureka"></property>
</bean>
- 基于注解配置
您可以通过在相关的类,方法或字段声明上使用注解,将 bean 配置为组件类本身,而不是使用 XML 来描述 bean 装配。默认情况下,Spring 容器中未打开注解装配。因此,您需要在使用它之前在 Spring 配置文件中启用它。例如:
<beans>
<context:annotation-config/>
<!-- bean definitions go here -->
</beans>
- 基于 Java API 配置
Spring 的 Java 配置是通过使用 @Bean 和 @Configuration 来实现。
- @Bean 注解扮演与
<bean />
元素相同的角色。 - @Configuration 类允许通过简单地调用同一个类中的其他 @Bean 方法来定义 bean 间依赖关系。
例如:
@Configuration
public class StudentConfig {
@Bean
public StudentBean myStudent() {
return new StudentBean();
}
}
Spring 中的 bean 的作用域有哪些?
- singleton:唯一 bean 实例,Spring 中的 bean 默认都是单例的。
- prototype:每次请求都会创建一个新的 bean 实例。
- request:每一次HTTP请求都会产生一个新的bean,该bean仅在当前HTTP request内有效。
- session:在一个HTTP Session中,一个Bean定义对应一个实例。该作用域仅在基于web的Spring ApplicationContext情形下有效。
- global-session:全局session作用域,仅仅在基于portlet的web应用中才有意义,Spring5已经没有了。Portlet是能够生成语义代码(例如:HTML)片段的小型Java Web插件。它们基于portlet容器,可以像servlet一样处理HTTP请求。但是,与 servlet 不同,每个 portlet 都有不同的会话
将一个类声明为Spring的 bean 的注解有哪些?
我们一般使用 @Autowired 注解自动装配 bean,要想把类标识成可用于 @Autowired 注解自动装配的 bean 的类,采用以下注解可实现:
@Component
:通用的注解,可标注任意类为 Spring 组件。如果一个Bean不知道属于哪个层,可以使用@Component 注解标注。@Repository
: 对应持久层即 Dao 层,主要用于数据库相关操作。@Service
: 对应服务层,主要涉及一些复杂的逻辑,需要用到 Dao 层。@Controller
: 对应 Spring MVC 控制层,主要用户接受用户请求并调用 Service 层返回数据给前端页面。
解释一下spring bean的生命周期
Bean的生命周期是由容器来管理的。主要在创建和销毁两个时期。
创建过程
- 实例化
Bean
对于BeanFactory
容器,当客户向容器请求一个尚未初始化的bean时,或初始化bean的时候需要注入另一个尚未初始化的依赖时,容器就会调用createBean
进行实例化。对于ApplicationContext
容器,当容器启动结束后,通过获取BeanDefinition
对象中的信息,实例化所有的bean。
- 设置对象属性(依赖注入)
实例化后的对象被封装在BeanWrapper
对象中,紧接着,Spring根据BeanDefinition
中的信息以及通过BeanWrapper
提供的设置属性的接口完成依赖注入。
- 处理
Aware
接口
接着,Spring会检测该对象是否实现了xxxAware
接口,并将相关的xxxAware
实例注入给Bean:
- 如果这个Bean已经实现了
BeanNameAware
接口,会调用它实现的setBeanName(String beanId)
方法,此处传递的就是Spring配置文件中Bean的id值; - 如果这个Bean已经实现了
BeanFactoryAware
接口,会调用它实现的setBeanFactory()
方法,传递的是Spring工厂自身。 - 如果这个Bean已经实现了
ApplicationContextAware
接口,会调用setApplicationContext(ApplicationContext)
方法,传入Spring上下文。
BeanPostProcessor
如果想对Bean进行一些自定义的处理,那么可以让Bean实现了BeanPostProcessor
接口,那将会调用postProcessBeforeInitialization(Object obj, String s)
方法。
InitializingBean
与init-method
如果Bean在Spring配置文件中配置了 init-method
属性,则会自动调用其配置的初始化方法。
- 如果这个Bean实现了
BeanPostProcessor
接口,将会调用postProcessAfterInitialization(Object obj, String s)
方法;由于这个方法是在Bean初始化结束时调用的,所以可以被应用于内存或缓存技术。
以上几个步骤完成后,Bean就已经被正确创建了,之后就可以使用这个Bean了。
销毁过程
DisposableBean
当Bean不再需要时,会经过清理阶段,如果Bean实现了DisposableBean
这个接口,会调用其实现的destroy()
方法;
destroy-method
最后,如果这个Bean的Spring配置中配置了destroy-method
属性,会自动调用其配置的销毁方法。
总结
主要把握创建过程和销毁过程这两个大的方面:
创建过程:首先实例化Bean,并设置Bean的属性,根据其实现的Aware
接口(主要是BeanFactoryAware
接口,BeanFactoryAware
,ApplicationContextAware
)设置依赖信息,
接下来调用BeanPostProcess
的postProcessBeforeInitialization
方法,完成initial
前的自定义逻辑;afterPropertiesSet
方法做一些属性被设定后的自定义的事情;调用Bean自身定义的init
方法,去做一些初始化相关的工作;然后再调用postProcessAfterInitialization
去做一些bean初始化之后的自定义工作。这四个方法的调用有点类似AOP。
此时,Bean初始化完成,可以使用这个Bean了。
销毁过程:如果实现了DisposableBean
的destroy()
方法,则调用它,如果实现了自定义的销毁方法,则调用之。
什么是 spring 装配?
当 bean 在 Spring 容器中组合在一起时,它被称为装配 bean 或 bean 装配。 Spring 容器需要知道需要什么 bean 以及容器应该如何使用依赖注入来将 bean 绑定在一起,同时装配 bean。
Spring 容器能够自动装配 bean。也就是说,可以通过检查 BeanFactory
的内容让 Spring 自动解析 bean 的协作者。
自动装配的不同模式:
- no:这是默认设置,表示没有自动装配。应使用显式 bean 引用进行装配。
- byName:它根据 bean 的名称注入对象依赖项。它匹配并装配其属性与 XML 文件中由相同名称定义的 bean。(
@Resource
注解默认使用的就是byName
) - byType:它根据类型注入对象依赖项。如果属性的类型与 XML 文件中的一个 bean 名称匹配,则匹配并装配属性。(
@Autowired
注解默认使用的就是byType
) - 构造函数:它通过调用类的构造函数来注入依赖项。它有大量的参数。
- autodetect:首先容器尝试通过构造函数使用 autowire 装配,如果不能,则尝试通过 byType 自动装配。
自动装配有什么局限?
- 覆盖的可能性 - 您始终可以使用
<constructor-arg>
和<property>
设置指定依赖项,这将覆盖自动装配。 - 基本元数据类型 - 简单属性(如原数据类型,字符串和类)无法自动装配。
- 令人困惑的性质 - 总是喜欢使用明确的装配,因为自动装配不太精确。
Spring中出现同名bean怎么办?
- 同一个配置文件内同名的Bean,以最上面定义的为准
- 不同配置文件中存在同名Bean,后解析的配置文件会覆盖先解析的配置文件
- 同文件中
@ComponentScan
和@Bean
出现同名Bean。同文件下@Bean
的会生效,@ComponentScan
扫描进来不会生效。通过@ComponentScan
扫描进来的优先级是最低的,原因就是它扫描进来的Bean定义是最先被注册的~
Spring 怎么解决循环依赖问题?
spring对循环依赖的处理有三种情况:
- 构造器的循环依赖:这种依赖spring是处理不了的,直接抛出
BeanCurrentlylnCreationException
异常。 - 单例模式下的setter循环依赖:通过“三级缓存”处理循环依赖。
- 非单例循环依赖:无法处理。
下面分析单例模式下的setter循环依赖如何解决
Spring的单例对象的初始化主要分为三步:
createBeanInstance
:实例化,其实也就是调用对象的构造方法实例化对象populateBean
:填充属性,这一步主要是多bean的依赖属性进行填充initializeBean
:调用spring xml中的 init 方法。
从上面讲述的单例bean初始化步骤我们可以知道,循环依赖主要发生在第一、第二部。也就是构造器循环依赖和field
循环依赖。
举例:A的某个field或者setter依赖了B的实例对象,同时B的某个field或者setter依赖了A的实例对象”这种循环依赖的情况。A首先完成了初始化的第一步(createBeanInstance
实例化),并且将自己提前曝光到singletonFactories
中。
此时进行初始化的第二步,发现自己依赖对象B,此时就尝试去get(B)
,发现B还没有被create,所以走create流程,B在初始化第一步的时候发现自己依赖了对象A,于是尝试get(A)
,尝试一级缓存singletonObjects
(肯定没有,因为A还没初始化完全),尝试二级缓存earlySingletonObjects
(也没有),尝试三级缓存singletonFactories
,由于A通过ObjectFactory
将自己提前曝光了,所以B能够通过ObjectFactory.getObject
拿到A对象(虽然A还没有初始化完全,但是总比没有好呀),B拿到A对象后顺利完成了初始化阶段1、2、3,完全初始化之后将自己放入到一级缓存singletonObjects中。
此时返回A中,A此时能拿到B的对象顺利完成自己的初始化阶段2、3,最终A也完成了初始化,进去了一级缓存singletonObjects中,而且更加幸运的是,由于B拿到了A的对象引用,所以B现在hold住的A对象完成了初始化。
关键字:三级缓存,提前曝光。
Spring 中的单例 bean 的线程安全问题?
当多个用户同时请求一个服务时,容器会给每一个请求分配一个线程,这时多个线程会并发执行该请求对应的业务逻辑(成员方法),此时就要注意了,如果该处理逻辑中有对单例状态的修改(体现为该单例的成员属性),则必须考虑线程同步问题。
线程安全问题都是由全局变量及静态变量引起的。
若每个线程中对全局变量、静态变量只有读操作,而无写操作,一般来说,这个全局变量是线程安全的;若有多个线程同时执行写操作,一般都需要考虑线程同步,否则就可能影响线程安全。
无状态bean和有状态bean
- 有状态就是有数据存储功能。有状态对象(Stateful Bean),就是有实例变量的对象,可以保存数据,是非线程安全的。在不同方法调用间不保留任何状态。
- 无状态就是一次操作,不能保存数据。无状态对象(Stateless Bean),就是没有实例变量的对象,不能保存数据,是不变类,是线程安全的。
在spring中无状态的Bean适合用不变模式,就是单例模式,这样可以共享实例提高性能。有状态的Bean在多线程环境下不安全,适合用Prototype
原型模式。
Spring使用ThreadLocal
解决线程安全问题。如果你的Bean有多种状态的话(比如 View Model 对象),就需要自行保证线程安全 。
Spring框架中都用到了哪些设计模式
这是一道相对有难度的题目,你不仅要会设计模式,还要知道每个设计模式在Spring中是如何使用的。
简单工厂模式: Spring 中的 BeanFactory
就是简单工厂模式的体现。根据传入一个唯一的标识来获得 Bean 对象,但是在传入参数后创建还是传入参数前创建,要根据具体情况来定。
工厂方法模式: Spring 中的 FactoryBean
就是典型的工厂方法模式,实现了 FactoryBean 接口的 bean是一类叫做 factory 的 bean。其特点是,spring 在使用getBean()
调用获得该 bean 时,会自动调用该 bean 的 getObject()
方法,所以返回的不是 factory 这个 bean,而是这个 bean.getOjbect()
方法的返回值。
单例模式: 在 spring 中用到的单例模式有:scope="singleton"
,注册式单例模式,bean 存放于 Map 中。bean name 当做 key,bean 当做 value。
原型模式: 在 spring 中用到的原型模式有:scope="prototype"
,每次获取的是通过克隆生成的新实例,对其进行修改时对原有实例对象不造成任何影响。
迭代器模式: 在 Spring 中有个 CompositeIterator
实现了 Iterator
,Iterable
接口和 Iterator
接口,这两个都是迭代相关的接口。可以这么认为,实现了 Iterable
接口,则表示某个对象是可被迭代的。Iterator
接口相当于是一个迭代器,实现了 Iterator
接口,等于具体定义了这个可被迭代的对象时如何进行迭代的。
代理模式: Spring 中经典的 AOP,就是使用动态代理实现的,分 JDK 和 CGlib 动态代理。
适配器模式: Spring 中的 AOP 中 AdvisorAdapter 类,它有三个实现:
MethodBeforAdviceAdapter
、AfterReturnningAdviceAdapter
、ThrowsAdviceAdapter
。Spring会根据不同的 AOP 配置来使用对应的 Advice,与策略模式不同的是,一个方法可以同时拥有多个Advice。Spring 存在很多以 Adapter 结尾的,大多数都是适配器模式。
观察者模式: Spring 中的 Event 和 Listener。spring 事件:ApplicationEvent
,该抽象类继承了EventObject
类,JDK 建议所有的事件都应该继承自 EventObject
。spring 事件监听器:ApplicationListener
,该接口继承了 EventListener
接口,JDK 建议所有的事件监听器都应该继承EventListener
。
模板模式: Spring 中的 org.springframework.jdbc.core.JdbcTemplate
就是非常经典的模板模式的应用,里面的 execute
方法,把整个算法步骤都定义好了。
责任链模式: DispatcherServlet
中的 doDispatch()
方法中获取与请求匹配的处理器HandlerExecutionChain
,this.getHandler()
方法的处理使用到了责任链模式。
注意: 这里只是列举了部分设计模式,其实里面用到了还有享元模式、建造者模式等。可选择性的回答(你会的熟悉的模式),主要是怕你回答了迭代器模式,然后继续问你,结果你一问三不知,那就尴了尬了。
说说Spring 中 ApplicationContext 和 BeanFactory 的区别
对 Web 应用的支持
与 BeanFactory
通常以编程的方式被创建,ApplicationContext
能以声明的方式创建,如使用ContextLoader
。
当然你也可以使用 ApplicationContext
的实现方式之一,以编程的方式创建 ApplicationContext
实例。
延迟加载
BeanFactroy
采用的是延迟加载形式来注入 Bean 的,即只有在使用到某个 Bean 时(调用getBean()
),才对该 Bean 进行加载实例化。这样,我们就不能发现一些存在的 spring 的配置问题。而ApplicationContext
则相反,它是在容器启动时,一次性创建了所有的 Bean。这样,在容器启动时,我们就可以发现 Spring 中存在的配置错误。BeanFactory
和ApplicationContext
都支持BeanPostProcessor
、BeanFactoryPostProcessor
的使用。两者之间的区别是:BeanFactory
需要手动注册,而ApplicationContext
则是自动注册。
可以看到,ApplicationContext
继承了 BeanFactory
,BeanFactory
是 Spring 中比较原始的Factory,它不支持 AOP、Web 等 Spring 插件。而 ApplicationContext
不仅包含了 BeanFactory
的所有功能,还支持 Spring 的各种插件,还以一种面向框架的方式工作以及对上下文进行分层和实现继承。
BeanFactory
是 Spring 框架的基础设施,面向 Spring 本身;而 ApplicationContext
面向使用Spring 的开发者,相比 BeanFactory
提供了更多面向实际应用的功能,几乎所有场合都可以直接使用 ApplicationContext
,而不是底层的 BeanFactory
。
常用容器
BeanFactory
类型的有 XmlBeanFactory
,它可以根据 XML 文件中定义的内容,创建相应的Bean。ApplicationContext
类型的常用容器有:
ClassPathXmlApplicationContext
:从 ClassPath 的 XML 配置文件中读取上下文,并生成上下文定义。应用程序上下文从程序环境变量中取得;FileSystemXmlApplicationContext
:由文件系统中的 XML 配置文件读取上下文;XmlWebApplicationContext
:由 Web 应用的 XML 文件读取上下文。例如我们在 Spring MVC使用的情况。
Spring 框架中的单例 Bean 是线程安全的么
Spring 框架并没有对单例 Bean 进行任何多线程的封装处理。
- 关于单例 Bean 的线程安全和并发问题,需要开发者自行去搞定。
- 单例的线程安全问题,并不是 Spring 应该去关心的。Spring 应该做的是,提供根据配置,创建单例 Bean 或多例 Bean 的功能。
当然,但实际上,大部分的 Spring Bean 并没有可变的状态,所以在某种程度上说 Spring 的单例Bean 是线程安全的。如果你的 Bean 有多种状态的话,就需要自行保证线程安全。最浅显的解决办法,就是将多态 Bean 的作用域(Scope)由 Singleton 变更为 Prototype。
说说事务的传播机制
Spring事务定义了7种传播机制:
PROPAGATION_REQUIRED
:默认的Spring事物传播级别,若当前存在事务,则加入该事务,若不存在事务,则新建一个事务。PAOPAGATION_REQUIRE_NEW
:若当前没有事务,则新建一个事务。若当前存在事务,则新建一个事务,新老事务相互独立。外部事务抛出异常回滚不会影响内部事务的正常提交。PROPAGATION_NESTED
:如果当前存在事务,则嵌套在当前事务中执行。如果当前没有事务,则新建一个事务,类似于REQUIRE_NEW
。PROPAGATION_SUPPORTS
:支持当前事务,若当前不存在事务,以非事务的方式执行。PROPAGATION_NOT_SUPPORTED
:以非事务的方式执行,若当前存在事务,则把当前事务挂起。PROPAGATION_MANDATORY
:强制事务执行,若当前不存在事务,则抛出异常。PROPAGATION_NEVER
:以非事务的方式执行,如果当前存在事务,则抛出异常。
Spring事务传播级别一般不需要定义,默认就是PROPAGATION_REQUIRED,除非在嵌套事务的情况下需要重点了解。
Spring 事务实现方式
编程式事务管理: 这意味着你可以通过编程的方式管理事务,这种方式带来了很大的灵活性,但很难维护。
声明式事务管理: 这种方式意味着你可以将事务管理和业务代码分离。你只需要通过注解或者XML配置管理事务。
Spring框架的事务管理有哪些优点
- 它为不同的事务API(如JTA, JDBC, Hibernate, JPA, 和JDO)提供了统一的编程模型。
- 它为编程式事务管理提供了一个简单的API而非一系列复杂的事务API(如JTA),它支持声明式事务管理。
- 它可以和Spring 的多种数据访问技术很好的融合。
事务注解@Transactional的本质是什么
@Transactional
这个注解仅仅是一些(和事务相关的)元数据,在运行时被事务基础设施读取消费,并使用这些元数据来配置bean的事务行为。 大致来说具有两方面功能,一是表明该方法要参与事务,二是配置相关属性来定制事务的参与方式和运行行为。
声明式事务主要是得益于Spring AOP。使用一个事务拦截器,在方法调用的前后/周围进行事务性增强(advice),来驱动事务完成。
@Transactional
注解既可以标注在类上,也可以标注在方法上。当在类上时,默认应用到类里的所有方法。如果此时方法上也标注了,则方法上的优先级高。 另外注意方法一定要是public的。
5. Mybatis篇
Mybatis系列文章:Mybatis
什么是MyBatis
- Mybatis是一个半ORM(对象关系映射)框架,它内部封装了JDBC,开发时只需要关注SQL语句本身,不需要花费精力去处理加载驱动、创建连接、创建statement等繁杂的过程。程序员直接编写原生态sql,可以严格控制sql执行性能,灵活度高。
- MyBatis 可以使用 XML 或注解来配置和映射原生信息,将 POJO映射成数据库中的记录,避免了几乎所有的 JDBC 代码和手动设置参数以及获取结果集。
- 通过xml 文件或注解的方式将要执行的各种 statement 配置起来,并通过java对象和statement中sql的动态参数进行映射生成最终执行的sql语句,最后由mybatis框架执行sql并将结果映射为java对象并返回。(从执行sql到返回result的过程)。
说说MyBatis的优点和缺点
优点
- 基于SQL语句编程,相当灵活,不会对应用程序或者数据库的现有设计造成任何影响,SQL写在XML里,解除sql与程序代码的耦合,便于统一管理;提供XML标签,支持编写动态SQL语句,并可重用。
- 与JDBC相比,减少了50%以上的代码量,消除了JDBC大量冗余的代码,不需要手动开关连接;
- 很好的与各种数据库兼容(因为MyBatis使用JDBC来连接数据库,所以只要JDBC支持的数据库MyBatis都支持);
- 能够与Spring很好的集成;
- 提供映射标签,支持对象与数据库的ORM字段关系映射;提供对象关系映射标签,支持对象关系组件维护。
缺点
- SQL语句的编写工作量较大,尤其当字段多、关联表多时,对开发人员编写SQL语句的功底有一定要求。
- SQL语句依赖于数据库,导致数据库移植性差,不能随意更换数据库。
#{}和${}的区别是什么
#{}
是预编译处理,${}
是字符串替换。
Mybatis在处理#{}
时,会将sql中的#{}
替换为?号,调用PreparedStatement的set方法来赋值;Mybatis在处理${}
时,就是把${}
替换成变量的值。
使用#{}可以有效的防止SQL注入,提高系统安全性。
Mybatis是如何进行分页的?分页插件的原理是什么
Mybatis使用RowBounds对象进行分页,它是针对ResultSet结果集执行的内存分页,而非物理分页。可以在sql内直接拼写带有物理分页的参数来完成物理分页功能,也可以使用分页插件来完成物理分页,比如:MySQL数据的时候,在原有SQL后面拼写limit。
分页插件的基本原理是使用Mybatis提供的插件接口,实现自定义插件,在插件的拦截方法内拦截待执行的sql,然后重写sql,根据dialect方言,添加对应的物理分页语句和物理分页参数。常用的分页插件就是PageHelper。
Mybatis是如何将sql执行结果封装为目标对象并返回的?都有哪些映射形式
- 第一种是使用标签,逐一定义数据库列名和对象属性名之间的映射关系。(resultMap)
- 第二种是使用sql列的别名功能,将列的别名书写为对象属性名。(别名,驼峰规则)
有了列名与属性名的映射关系后,Mybatis通过反射创建对象,同时使用反射给对象的属性逐一赋值并返回,那些找不到映射关系的属性,是无法完成赋值的。
Mybatis是否支持延迟加载?如果支持,它的实现原理是什么?
Mybatis仅支持association关联对象和collection关联集合对象的延迟加载,association指的就是一对一,collection指的就是一对多查询。在Mybatis配置文件中,可以配置是否启用延迟加载lazyLoadingEnabled=true|false。
它的原理是,使用CGLIB创建目标对象的代理对象,当调用目标方法时,进入拦截器方法,比如调用a.getB().getName(),拦截器invoke()方法发现a.getB()是null值,那么就会单独发送事先保存好的查询关联B对象的sql,把B查询上来,然后调用a.setB(b),于是a的对象b属性就有值了,接着完成a.getB().getName()方法的调用。这就是延迟加载的基本原理。
当然了,不光是Mybatis,几乎所有的包括Hibernate,支持延迟加载的原理都是一样的。
说说Mybatis的缓存机制
一级缓存localCache
在应用运行过程中,我们有可能在一次数据库会话中,执行多次查询条件完全相同的 SQL,MyBatis 提供了一级缓存的方案优化这部分场景,如果是相同的 SQL 语句,会优先命中一级缓存,避免直接对数据库进行查询,提高性能。
每个 SqlSession 中持有了 Executor,每个 Executor 中有一个 LocalCache。当用户发起查询时,MyBatis 根据当前执行的语句生成 MappedStatement,在 Local Cache 进行查询,如果缓存命中的话,直接返回结果给用户,如果缓存没有命中的话,查询数据库,结果写入 Local Cache,最后返回结果给用户。具体实现类的类关系图如下图所示:
- MyBatis 一级缓存的生命周期和 SqlSession 一致。
- MyBatis 一级缓存内部设计简单,只是一个没有容量限定的 HashMap,在缓存的功能性上有所欠缺。
- MyBatis 的一级缓存最大范围是 SqlSession 内部,有多个 SqlSession 或者分布式的环境下,数据库写操作会引起脏数据,建议设定缓存级别为 Statement。
二级缓存
在上文中提到的一级缓存中,其最大的共享范围就是一个 SqlSession 内部,如果多个 SqlSession之间需要共享缓存,则需要使用到二级缓存。开启二级缓存后,会使用 CachingExecutor 装饰Executor,进入一级缓存的查询流程前,先在 CachingExecutor 进行二级缓存的查询,具体的工作流程如下所示。
二级缓存开启后,同一个 namespace 下的所有操作语句,都影响着同一个 Cache,即二级缓存被多个 SqlSession 共享,是一个全局的变量。
当开启缓存后,数据的查询执行的流程为:二级缓存 -> 一级缓存 -> 数据库。
- MyBatis 的二级缓存相对于一级缓存来说,实现了 SqlSession 之间缓存数据的共享,同时粒度更加细,能够到 namespace 级别,通过 Cache 接口实现类不同的组合,对 Cache 的可控性也更强。
- MyBatis 在多表查询时,极大可能会出现脏数据,有设计上的缺陷,安全使用二级缓存的条件比较苛刻。
- 在分布式环境下,由于默认的 MyBatis Cache 实现都是基于本地的,分布式环境下必然会出现读取到脏数据,需要使用集中式缓存将 MyBatis 的 Cache 接口实现,有一定的开发成本,直接使用 Redis、Memcached 等分布式缓存可能成本更低,安全性也更高。
参考文章:ORM框架之Mybatis(五):一级缓存和二级缓存初步认识
MyBatis 中见过什么设计模式
MyBatis 中比如 UserMapper.java 是接口,为什么没有实现类还能调用
使用JDK动态代理+MapperProxy。本质上调用的是MapperProxy的invoke方法。
6. Spring Boot篇
Spring Boot系列文章:Spring Boot
为什么要用Spring Boot
Spring Boot 优点非常多。
独立运行
Spring Boot而且内嵌了各种servlet容器,Tomcat、Jetty等,现在不再需要打成war包部署到容器中,Spring Boot只要打成一个可执行的jar包就能独立运行,所有的依赖包都在一个jar包内。
简化配置
spring-boot-starter-web启动器自动依赖其他组件,减少了maven的配置。
自动配置
Spring Boot能根据当前类路径下的类、jar包来自动配置bean,如添加一个spring-boot-starter-web启动器就能拥有web的功能,无需其他配置。
无代码生成和XML配置
Spring Boot配置过程中无代码生成,也无需XML配置文件就能完成所有配置工作,这一切都是借助于条件注解完成的,这也是Spring4.x的核心功能之一。
应用监控
Spring Boot提供一系列端点可以监控服务及应用,做健康检测。
容器配置
在开发web应用程序时,springboot会配置一个嵌入式Tomcat服务器,以便它可以作为独立的应用程序运行。(Tomcat是默认的,当然你也可以配置Jetty或Undertow)
可以打包成jar文件。可以使用Java-jar命令从命令行将应用程序作为独立的Java应用程序运行。
Spring Boot 的核心注解是哪个?它主要由哪几个注解组成的?
启动类上面的注解是@SpringBootApplication,它也是 Spring Boot 的核心注解,主要组合包含了以下 3 个注解:
- @SpringBootConfiguration:组合了 @Configuration 注解,实现配置文件的功能。
- @EnableAutoConfiguration:打开自动配置的功能,也可以关闭某个自动配置的选项,如关闭数据源自动配置功能: @SpringBootApplication(exclude = { DataSourceAutoConfiguration.class })。
- @ComponentScan:Spring组件扫描。
如何理解 Spring Boot 中的 Starters
Starters是什么
Starters可以理解为启动器,它包含了一系列可以集成到应用里面的依赖包,你可以一站式集成Spring及其他技术,而不需要到处找示例代码和依赖包。如你想使用Spring JPA访问数据库,只要加入spring-boot-starter-data-jpa启动器依赖就能使用了。Starters包含了许多项目中需要用到的依赖,它们能快速持续的运行,都是一系列得到支持的管理传递性依赖。
Starters命名
Spring Boot官方的启动器都是以spring-boot-starter-命名的,代表了一个特定的应用类型。第三方的启动器不能以spring-boot开头命名,它们都被Spring Boot官方保留。一般一个第三方的应该这样命名,像mybatis的mybatis-spring-boot-starter。
Starters分类
- Spring Boot应用类启动器,比如spring-boot-starter-web;
- Spring Boot生产启动器,比如spring-boot-starter-actuator;
- Spring Boot技术类启动器,比如spring-boot-starter-json;
- 其他第三方启动器,比如mybatis-spring-boot-starter。
Spring Boot Starter 的工作原理是什么?
Spring Boot 在启动的时候会干这几件事情:
- Spring Boot 在启动时会去依赖的 Starter 包中寻找 resources/META-INF/spring.factories 文件,然后根据文件中配置的 Jar 包去扫描项目所依赖的 Jar 包。
- 根据 spring.factories 配置加载 AutoConfigure 类
- 根据 @Conditional 注解的条件,进行自动配置并将 Bean 注入 Spring Context
总结一下,其实就是 Spring Boot 在启动的时候,按照约定去读取 Spring Boot Starter 的配置信息,再根据配置信息对资源进行初始化,并注入到 Spring 容器中。这样 Spring Boot 启动完毕后,就已经准备好了一切资源,使用过程中直接注入对应 Bean 资源即可
如何在Spring Boot启动的时候运行一些特定的代码
如果你想在Spring Boot启动的时候运行一些特定的代码,你可以实现接口ApplicationRunner或者CommandLineRunner,这两个接口实现方式一样,它们都只提供了一个run方法。
Spring Boot 需要独立的容器运行吗
可以不需要,内置了 Tomcat/ Jetty 等容器。
Spring Boot中的监视器是什么
Spring boot actuator是spring启动框架中的重要功能之一。Spring boot监视器可帮助您访问生产环境中正在运行的应用程序的当前状态。有几个指标必须在生产环境中进行检查和监控。即使一些外部应用程序可能正在使用这些服务来向相关人员触发警报消息。监视器模块公开了一组可直接作为HTTP URL访问的REST端点来检查状态。
如何使用Spring Boot实现异常处理
Spring提供了一种使用ControllerAdvice处理异常的非常有用的方法。 我们通过实现一个ControllerAdvice类,来处理控制器类抛出的所有异常。
参考文章:SpringBoot统一异常处理之404、500系统异常处理
SpringBoot 实现热部署有哪几种方式
主要有两种方式:
- Spring Loaded
- Spring-boot-devtools
Spring Boot 的核心配置文件有哪几个?它们的区别是什么?
Spring Boot 的核心配置文件是 application 和 bootstrap 配置文件。
application 配置文件这个容易理解,主要用于 Spring Boot 项目的自动化配置。
bootstrap 配置文件有以下几个应用场景。
- 使用 Spring Cloud Config 配置中心时,这时需要在 bootstrap 配置文件中添加连接到配置中心的配置属性来加载外部配置中心的配置信息;
- 一些固定的不能被覆盖的属性;
- 一些加密/解密的场景。
Spring 、Spring Boot 和 Spring Cloud 的关系?
Spring 最初最核心的两大核心功能 Spring Ioc 和 Spring Aop 成就了 Spring,Spring 在这两大核心的功能上不断的发展,才有了 Spring 事务、Spring Mvc 等一系列伟大的产品,最终成就了 Spring 帝国,到了后期 Spring 几乎可以解决企业开发中的所有问题。
Spring Boot 是在强大的 Spring 帝国生态基础上面发展而来,发明 Spring Boot 不是为了取代 Spring ,是为了让人们更容易的使用 Spring 。
Spring Cloud 是一系列框架的有序集合。它利用 Spring Boot 的开发便利性巧妙地简化了分布式系统基础设施的开发,如服务发现注册、配置中心、消息总线、负载均衡、断路器、数据监控等,都可以用 Spring Boot 的开发风格做到一键启动和部署。
Spring Cloud 是为了解决微服务架构中服务治理而提供的一系列功能的开发框架,并且 Spring Cloud 是完全基于 Spring Boot 而开发,Spring Cloud 利用 Spring Boot 特性整合了开源行业中优秀的组件,整体对外提供了一套在微服务架构中服务治理的解决方案。
用一组不太合理的包含关系来表达它们之间的关系。
Spring ioc/aop > Spring > Spring Boot > Spring Cloud
7. MySQL篇
MySQL系列文章:MySQL
数据库的三范式是什么
第一范式:列不可再分
第二范式:行可以唯一区分,主键约束
第三范式:表的非主属性不能依赖与其他表的非主属性外键约束
三大范式是一级一级依赖的,第二范式建立在第一范式上,第三范式建立第一第二范式上。
MySQL数据库引擎有哪些
如何查看MySQL提供的所有存储引擎。
通过show engines;可以查看,结果如下:
MySQL常用引擎包括:MYISAM、Innodb、Memory、MERGE
- MYISAM:全表锁,拥有较高的执行速度,不支持事务,不支持外键,并发性能差,占用空间相对较小,对事务完整性没有要求,以select、insert为主的应用基本上可以使用这引擎;
- Innodb:行级锁,提供了具有提交、回滚和崩溃回复能力的事务安全,支持自动增长列,支持外键约束,并发能力强,占用空间是MYISAM的2.5倍,处理效率相对会差一些;
- Memory:全表锁,存储在内容中,速度快,但会占用和数据量成正比的内存空间且数据在MySQL重启时会丢失,默认使用HASH索引,检索效率非常高,但不适用于精确查找,主要用于那些内容变化不频繁的代码表;
- MERGE:是一组MYISAM表的组合。
说说InnoDB与MyISAM的区别
- InnoDB支持事务,MyISAM不支持,对于InnoDB每一条SQL语言都默认封装成事务,自动提交,这样会影响速度,所以最好把多条SQL语言放在begin和commit之间,组成一个事务;
- InnoDB支持外键,而MyISAM不支持。对一个包含外键的InnoDB表转为MYISAM会失败;
- InnoDB是聚集索引,数据文件是和索引绑在一起的,必须要有主键,通过主键索引效率很高。但是辅助索引需要两次查询,先查询到主键,然后再通过主键查询到数据。因此,主键不应该过大,因为主键太大,其他索引也都会很大。而MyISAM是非聚集索引,数据文件是分离的,索引保存的是数据文件的指针。主键索引和辅助索引是独立的;
- InnoDB不保存表的具体行数,执行select count(*) from table时需要全表扫描。而MyISAM用一个变量保存了整个表的行数,执行上述语句时只需要读出该变量即可,速度很快;
- 在 MySQL 5.1 及之前的版本中,MyISAM 是默认的存储引擎,而在 MySQL 5.5 版本以后,默认使用 InnoDB 存储引擎;
- MyISAM 不支持行级锁,换句话说,MyISAM 会对整张表加锁,而不是针对行。同时,MyISAM 不支持事务和外键。MyISAM 可被压缩,存储空间较小,而且 MyISAM 在筛选大量数据时非常快;
- InnoDB 是事务型引擎,当事务异常提交时,会被回滚。同时,InnoDB 支持行锁。此外,InnoDB 需要更多存储空间,会在内存中建立其专用的缓冲池用于高速缓冲数据和索引。InnoDB 支持自动奔溃恢复特性。
- Innodb不支持全文索引,而MyISAM支持全文索引,查询效率上MyISAM要高。
SQL 约束有哪几种?
- NOT NULL: 用于控制字段的内容一定不能为空(NULL)。
- UNIQUE: 控件字段内容不能重复,一个表允许有多个 Unique 约束。
- PRIMARY KEY: 也是用于控件字段内容不能重复,但它在一个表只允许出现一个。
- FOREIGN KEY: 用于预防破坏表之间连接的动作,也能防止非法数据插入外键列,因为它必须是它指向的那个表中的值之一。
- CHECK: 用于控制字段的值范围。
数据库的事务
什么是事务
事务是一个不可分割的数据库操作序列,也是数据库并发控制的基本单位,其执行的结果必须使数据库从一种一致性状态变到另一种一致性状态。事务是逻辑上的一组操作,要么都执行,要么都不执行。
事务最经典也经常被拿出来说例子就是转账了。
假如小明要给小红转账1000元,这个转账会涉及到两个关键操作就是:将小明的余额减少1000元,将小红的余额增加1000元。万一在这两个操作之间突然出现错误比如银行系统崩溃,导致小明余额减少而小红的余额没有增加,这样就不对了。事务就是保证这两个关键操作要么都成功,要么都要失败。
事务的特性
原子性(Atomic)、一致性(Consistency)、隔离性(Isolation)、持久性(Durabiliy)。简称ACID。
- 原子性: 组成一个事务的多个数据库操作是一个不可分割的原子单元,只有所有操作都成功,整个事务才会提交。任何一个操作失败,已经执行的任何操作都必须撤销,让数据库返回初始状态。
- 一致性: 事务操作成功后,数据库所处的状态和它的业务规则是一致的。即数据不会被破坏。如A转账100元给B,不管操作是否成功,A和B的账户总额是不变的。
- 隔离性: 在并发数据操作时,不同的事务拥有各自的数据空间,它们的操作不会对彼此产生干扰。
- 持久性: 一旦事务提交成功,事务中的所有操作都必须持久化到数据库中。
MYISAM引擎和Innodb都支持锁,为何MYISAM没有事务
MYISAM引擎是针对当前表做增删改操作时加锁,没有事务开启和事务提交机制,一旦增删改成功后,就不可进行回滚。如果在一个连续操作过程,涉及多张表的增删改,没有事务的情况下,就有可能靠前的操作成功,数据成功修改,靠后的操作失败,数据未修改成功,最终导致脏数据的产生。但是在Innodb引擎下就可以解决这种脏数据的问题,事务内出现异常,事务内的所有操作都会被回滚。
索引是什么
官方介绍索引是帮助MySQL高效获取数据的数据结构。更通俗的说,数据库索引好比是一本书前面的目录,能加快数据库的查询速度;
一般来说索引本身也很大,不可能全部存储在内存中,因此索引往往是存储在磁盘上的文件中的(可能存储在单独的索引文件中,也可能和数据一起存储在数据文件中)。
我们通常所说的索引,包括聚集索引、覆盖索引、组合索引、前缀索引、唯一索引等,没有特别说明,默认都是使用B+树结构组织(多路搜索树,并不一定是二叉的)的索引。
MySQL中 in 和 exists 区别
MySQL中的in语句是把外表和内表作hash 连接,而exists语句是对外表作loop循环,每次loop循环再对内表进行查询。一直大家都认为exists比in语句的效率要高,这种说法其实是不准确的。这个是要区分环境的。
如果查询的两个表大小相当,那么用in和exists差别不大。
如果两个表中一个较小,一个是大表,则子查询表大的用exists,子查询表小的用in。
not in 和not exists:如果查询语句使用了not in,那么内外表都进行全表扫描,没有用到索引;而not extsts的子查询依然能用到表上的索引。所以无论那个表大,用not exists都比not in要快。
简单说一说drop、delete与truncate的区别
SQL中的drop、delete、truncate都表示删除,但是三者有一些差别delete和truncate只删除表的数据不删除表的结构 速度,一般来说: drop> truncate >delete,delete语句是dml语句,这个操作会放到rollback segement中,事务提交之后才生效; 如果有相应的trigger,执行的时候将被触发. truncate,drop是ddl, 操作立即生效,原数据不放到rollback segment中,不能回滚,操作不触发trigger。
MySQL 执行查询的过程
- 客户端通过 TCP 连接发送连接请求到 MySQL 连接器,连接器会对该请求进行权限验证及连接资源分配
- 查缓存。(当判断缓存是否命中时,MySQL 不会进行解析查询语句,而是直接使用 SQL 语句和客户端发送过来的其他原始信息。所以,任何字符上的不同,例如空格、注解等都会导致缓存的不命中。)
- 语法分析(SQL 语法是否写错了)。 如何把语句给到预处理器,检查数据表和数据列是否存在,解析别名看是否存在歧义。
- 优化。是否使用索引,生成执行计划。
- 交给执行器,将数据保存到结果集中,同时会逐步将数据缓存到查询缓存中,最终将结果集返回给客户端。
什么是内联接、左外联接、右外联接
- 内联接(Inner Join): 匹配2张表中相关联的记录;
- 左外联接(Left Outer Join): 除了匹配2张表中相关联的记录外,还会匹配左表中剩余的记录,右表中未匹配到的字段用NULL表示;
- 右外联接(Right Outer Join): 除了匹配2张表中相关联的记录外,还会匹配右表中剩余的记录,左表中未匹配到的字段用NULL表示。在判定左表和右表时,要根据表名出现在Outer Join的左右位置关系。
什么是存储过程?有哪些优缺点?
存储过程是一些预编译的 SQL 语句。
更加直白的理解:存储过程可以说是一个记录集,它是由一些 T-SQL 语句组成的代码块,这些 T-SQL 语句代码像一个方法一样实现一些功能(对单表或多表的增删改查),然后再给这个代码块取一个名字,在用到这个功能的时候调用他就行了。
存储过程是一个预编译的代码块,执行效率比较高,一个存储过程替代大量 T_SQL 语句 ,可以降低网络通信量,提高通信速率,可以一定程度上确保数据安全
但是,在互联网项目中,其实是不太推荐存储过程的,比较出名的就是阿里的《Java 开发手册》中禁止使用存储过程,我个人的理解是,在互联网项目中,迭代太快,项目的生命周期也比较短,人员流动相比于传统的项目也更加频繁,在这样的情况下,存储过程的管理确实是没有那么方便,同时,复用性也没有写在服务层那么好。
并发事务带来哪些问题
在典型的应用程序中,多个事务并发运行,经常会操作相同的数据来完成各自的任务(多个用户对同一数据进行操作)。并发虽然是必须的,但可能会导致以下的问题。
脏读(Dirty read): 当一个事务正在访问数据并且对数据进行了修改,而这种修改还没有提交到数据库中,这时另外一个事务也访问了这个数据,然后使用了这个数据。因为这个数据是还没有提交的数据,那么另外一个事务读到的这个数据是“脏数据”,依据“脏数据”所做的操作可能是不正确的。
丢失修改(Lost to modify): 指在一个事务读取一个数据时,另外一个事务也访问了该数据,那么在第一个事务中修改了这个数据后,第二个事务也修改了这个数据。这样第一个事务内的修改结果就被丢失,因此称为丢失修改。 例如:事务1读取某表中的数据A=20,事务2也读取A=20,事务1修改A=A-1,事务2也修改A=A-1,最终结果A=19,事务1的修改被丢失。
不可重复读(Unrepeatableread): 指在一个事务内多次读同一数据。在这个事务还没有结束时,另一个事务也访问该数据。那么,在第一个事务中的两次读数据之间,由于第二个事务的修改导致第一个事务两次读取的数据可能不太一样。这就发生了在一个事务内两次读到的数据是不一样的情况,因此称为不可重复读。
幻读(Phantom read): 幻读与不可重复读类似。它发生在一个事务(T1)读取了几行数据,接着另一个并发事务(T2)插入了一些数据时。在随后的查询中,第一个事务(T1)就会发现多了一些原本不存在的记录,就好像发生了幻觉一样,所以称为幻读。
不可重复读和幻读区别: 不可重复读的重点是修改比如多次读取一条记录发现其中某些列的值被修改,幻读的重点在于新增或者删除比如多次读取一条记录发现记录增多或减少了。
事务隔离级别有哪些?MySQL的默认隔离级别
SQL 标准定义了四个隔离级别:
READ-UNCOMMITTED(读取未提交): 最低的隔离级别,允许读取尚未提交的数据变更,可能会导致脏读、幻读或不可重复读;
READ-COMMITTED(读取已提交): 允许读取并发事务已经提交的数据,可以阻止脏读,但是幻读或不可重复读仍有可能发生;
REPEATABLE-READ(可重复读): 对同一字段的多次读取结果都是一致的,除非数据是被本身事务自己所修改,可以阻止脏读和不可重复读,但幻读仍有可能发生;
SERIALIZABLE(可串行化): 最高的隔离级别,完全服从ACID的隔离级别。所有的事务依次逐个执行,这样事务之间就完全不可能产生干扰,也就是说,该级别可以防止脏读、不可重复读以及幻读。
隔离级别 | 脏读 | 不可重复读 | 幻读 |
---|---|---|---|
READ-UNCOMMITTED | √ | √ | √ |
READ-COMMITTED | × | √ | √ |
REPEATABLE-READ | × | × | √ |
SERIALIZABLE | × | × | × |
MySQL InnoDB 存储引擎的默认支持的隔离级别是 REPEATABLE-READ(可重读),Oracle 默认采用的 READ_COMMITTED隔离级别。我们可以通过 SELECT @@tx_isolation;
命令来查看。
这里需要注意的是:与 SQL 标准不同的地方在于 InnoDB 存储引擎在 REPEATABLE-READ(可重读) 事务隔离级别下使用的是Next-Key Lock 锁算法,因此可以避免幻读的产生,这与其他数据库系统(如 SQL Server) 是不同的。所以说InnoDB 存储引擎的默认支持的隔离级别是REPEATABLEREAD(可重读) 已经可以完全保证事务的隔离性要求,即达到了 SQL标准的SERIALIZABLE(可串行化) 隔离级别。因为隔离级别越低,事务请求的锁越少,所以大部分数据库系统的隔离级别都是READ-COMMITTED(读取提交内容) ,但是你要知道的是InnoDB 存储引擎默认使用REPEAaTABLE-READ(可重读) 并不会有任何性能损失。
InnoDB 存储引擎在 分布式事务 的情况下一般会用到 SERIALIZABLE(可串行化) 隔离级别。
事务的实现原理
事务是基于重做日志文件(redo log)和回滚日志(undo log)实现的。
每提交一个事务必须先将该事务的所有日志写入到重做日志文件进行持久化,数据库就可以通过重做日志来保证事务的原子性和持久性。
每当有修改事务时,还会产生 undo log,如果需要回滚,则根据 undo log 的反向语句进行逻辑操作,比如 insert 一条记录就 delete 一条记录。undo log 主要实现数据库的一致性。
MySQL事务日志介绍下?
innodb 事务日志包括 redo log 和 undo log。
undo log 指事务开始之前,在操作任何数据之前,首先将需操作的数据备份到一个地方。redo log 指事务中操作的任何数据,将最新的数据备份到一个地方。
事务日志的目的:实例或者介质失败,事务日志文件就能派上用场。
redo log
redo log 不是随着事务的提交才写入的,而是在事务的执行过程中,便开始写入 redo 中。具体的落盘策略可以进行配置 。防止在发生故障的时间点,尚有脏页未写入磁盘,在重启 MySQL 服务的时候,根据 redo log 进行重做,从而达到事务的未入磁盘数据进行持久化这一特性。RedoLog 是为了实现事务的持久性而出现的产物。
undo log
undo log 用来回滚行记录到某个版本。事务未提交之前,Undo 保存了未提交之前的版本数据,Undo 中的数据可作为数据旧版本快照供其他并发事务进行快照读。是为了实现事务的原子性而出现的产物,在 MySQL innodb 存储引擎中用来实现多版本并发控制。
什么是MySQL的 binlog?
MySQL的 binlog 是记录所有数据库表结构变更(例如 CREATE、ALTER TABLE)以及表数据修改(INSERT、UPDATE、DELETE)的二进制日志。binlog 不会记录 SELECT 和 SHOW 这类操作,因为这类操作对数据本身并没有修改,但你可以通过查询通用日志来查看 MySQL 执行过的所有语句。
MySQL binlog 以事件形式记录,还包含语句所执行的消耗的时间,MySQL 的二进制日志是事务安全型的。binlog 的主要目的是复制和恢复。
binlog 有三种格式,各有优缺点:
statement: 基于 SQL 语句的模式,某些语句和函数如 UUID, LOAD DATA INFILE 等在复制过程可能导致数据不一致甚至出错。
row: 基于行的模式,记录的是行的变化,很安全。但是 binlog 会比其他两种模式大很多,在一些大表中清除大量数据时在 binlog 中会生成很多条语句,可能导致从库延迟变大。
mixed: 混合模式,根据语句来选用是 statement 还是 row 模式。
具体参考文章:MySQL数据库系列(九):MySQL之binlog数据恢复和回滚
在事务中可以混合使用存储引擎吗?
尽量不要在同一个事务中使用多种存储引擎,MySQL服务器层不管理事务,事务是由下层的存储引擎实现的。
如果在事务中混合使用了事务型和非事务型的表(例如InnoDB和MyISAM表),在正常提交的情况下不会有什么问题。
但如果该事务需要回滚,非事务型的表上的变更就无法撤销,这会导致数据库处于不一致的状态,这种情况很难修复,事务的最终结果将无法确定。所以,为每张表选择合适的存储引擎非常重要。
什么是MVCC以及其实现原理
MVCC, 即多版本并发控制。MVCC 的实现,是通过保存数据在某个时间点的快照来实现的。根据事务开始的时间不同,每个事务对同一张表,同一时刻看到的数据可能是不一样的。
MVCC实现原理
对于 InnoDB ,聚簇索引记录中包含 3 个隐藏的列:
- ROW ID:隐藏的自增 ID,如果表没有主键,InnoDB 会自动按 ROW ID 产生一个聚集索引树。
- 事务 ID:记录最后一次修改该记录的事务 ID。
- 回滚指针:指向这条记录的上一个版本。
我们拿上面的例子,对应解释下 MVCC 的实现原理,如下图:
如图,首先 insert 语句向表 t1 中插入了一条数据,a 字段为 1,b 字段为 1, ROW ID 也为 1 ,事务 ID 假设为 1,回滚指针假设为 null。当执行 update t1 set b=666 where a=1 时,大致步骤如下:
- 数据库会先对满足 a=1 的行加排他锁;
- 然后将原记录复制到 undo 表空间中;
- 修改 b 字段的值为 666,修改事务 ID 为 2;
- 并通过隐藏的回滚指针指向 undo log 中的历史记录;
- 事务提交,释放前面对满足 a=1 的行所加的排他锁。
在前面实验的第 6 步中,session2 查询的结果是 session1 修改之前的记录,这个记录就是来自 undolog 中。
因此可以总结出 MVCC 实现的原理大致是:
InnoDB 每一行数据都有一个隐藏的回滚指针,用于指向该行修改前的最后一个历史版本,这个历史版本存放在 undo log 中。如果要执行更新操作,会将原记录放入 undo log 中,并通过隐藏的回滚指针指向 undo log 中的原记录。其它事务此时需要查询时,就是查询 undo log 中这行数据的最后一个历史版本。
MVCC 最大的好处是读不加锁,读写不冲突,极大地增加了 MySQL 的并发性。通过 MVCC,保证了事务 ACID 中的 I(隔离性)特性。
为什么要加锁?
当多个用户并发地存取数据时,在数据库中就会产生多个事务同时存取同一数据的情况。若对并发操作不加控制就可能会读取和存储不正确的数据,破坏数据库的一致性。
保证多用户环境下保证数据库完整性和一致性。
为什么要分库分表?
分表
比如你单表都几千万数据了,你确定你能扛住么?绝对不行,单表数据量太大,会极大影响你的 sql执行的性能,到了后面你的 sql 可能就跑的很慢了。一般来说,就以我的经验来看,单表到几百万的时候,性能就会相对差一些了,你就得分表了。
分表就是把一个表的数据放到多个表中,然后查询的时候你就查一个表。比如按照用户 id 来分表,将一个用户的数据就放在一个表中。然后操作的时候你对一个用户就操作那个表就好了。这样可以控制每个表的数据量在可控的范围内,比如每个表就固定在 200 万以内。
分库
分库就是你一个库一般我们经验而言,最多支撑到并发 2000,一定要扩容了,而且一个健康的单库并发值你最好保持在每秒 1000 左右,不要太大。那么你可以将一个库的数据拆分到多个库中,访问的时候就访问一个库好了。
这就是所谓的分库分表。
用过哪些分库分表中间件?不同的分库分表中间件都有什么优点和缺点?
这个其实就是看看你了解哪些分库分表的中间件,各个中间件的优缺点是啥?然后你用过哪些分库分表的中间件。
比较常见的包括:
- cobar
- TDDL
- atlas
- sharding-jdbc
- mycat
cobar
阿里 b2b 团队开发和开源的,属于 proxy 层方案。早些年还可以用,但是最近几年都没更新了,基本没啥人用,差不多算是被抛弃的状态吧。而且不支持读写分离、存储过程、跨库 join 和分页等操作。
TDDL
淘宝团队开发的,属于 client 层方案。支持基本的 crud 语法和读写分离,但不支持 join、多表查询等语法。目前使用的也不多,因为还依赖淘宝的 diamond 配置管理系统。
atlas
360 开源的,属于 proxy 层方案,以前是有一些公司在用的,但是确实有一个很大的问题就是社区最新的维护都在 5 年前了。所以,现在用的公司基本也很少了。
sharding-jdbc
当当开源的,属于 client 层方案。确实之前用的还比较多一些,因为 SQL 语法支持也比较多,没有太多限制,而且目前推出到了 2.0 版本,支持分库分表、读写分离、分布式 id 生成、柔性事务(最大努力送达型事务、TCC 事务)。而且确实之前使用的公司会比较多一些(这个在官网有登记使用的公司,可以看到从 2017 年一直到现在,是有不少公司在用的),目前社区也还一直在开发和维护,还算是比较活跃,个人认为算是一个现在也可以选择的方案。
mycat
基于 cobar 改造的,属于 proxy 层方案,支持的功能非常完善,而且目前应该是非常火的而且不断流行的数据库中间件,社区很活跃,也有一些公司开始在用了。但是确实相比于 sharding jdbc 来说,年轻一些,经历的锤炼少一些。
如何对数据库如何进行垂直拆分或水平拆分的?
水平拆分的意思,就是把一个表的数据给弄到多个库的多个表里去,但是每个库的表结构都一样,只不过每个库表放的数据是不同的,所有库表的数据加起来就是全部数据。水平拆分的意义,就是将数据均匀放更多的库里,然后用多个库来抗更高的并发,还有就是用多个库的存储容量来进行扩容。
垂直拆分的意思,就是把一个有很多字段的表给拆分成多个表,或者是多个库上去。每个库表的结构都不一样,每个库表都包含部分字段。一般来说,会将较少的访问频率很高的字段放到一个表里去,然后将较多的访问频率很低的字段放到另外一个表里去。因为数据库是有缓存的,你访问频率高的行字段越少,就可以在缓存里缓存更多的行,性能就越好。这个一般在表层面做的较多一些。
两种分库分表的方式:
- 一种是按照 range 来分,就是每个库一段连续的数据,这个一般是按比如时间范围来的,但是这种一般较少用,因为很容易产生热点问题,大量的流量都打在最新的数据上了。
- 或者是按照某个字段hash一下均匀分散,这个较为常用。
range 来分,好处在于说,扩容的时候很简单,因为你只要预备好,给每个月都准备一个库就可以了,到了一个新的月份的时候,自然而然,就会写新的库了;缺点,但是大部分的请求,都是访问最新的数据。实际生产用 range,要看场景。
hash 分发,好处在于说,可以平均分配每个库的数据量和请求压力;坏处在于说扩容起来比较麻烦,会有一个数据迁移的过程,之前的数据需要重新计算 hash 值重新分配到不同的库或表。
具体可以参考文章:MySQL数据库系列(十):MySQL之分库分表
大表如何优化
当MySQL单表记录数过大时,数据库的CRUD性能会明显下降,一些常见的优化措施如下:
限定数据的范围
务必禁止不带任何限制数据范围条件的查询语句。比如:我们当用户在查询订单历史的时候,我们可以控制在一个月的范围内;
读/写分离
经典的数据库拆分方案,主库负责写,从库负责读;
垂直分区
根据数据库里面数据表的相关性进行拆分。 例如,用户表中既有用户的登录信息又有用户的基本信息,可以将用户表拆分成两个单独的表,甚至放到单独的库做分库。
简单来说垂直拆分是指数据表列的拆分,把一张列比较多的表拆分为多张表。 如下图所示,这样来说大家应该就更容易理解了。
- 垂直拆分的优点: 可以使得列数据变小,在查询时减少读取的Block数,减少I/O次数。此外,垂直分区可以简化表的结构,易于维护。
- 垂直拆分的缺点: 主键会出现冗余,需要管理冗余列,并会引起Join操作,可以通过在应用层进行Join来解决。此外,垂直分区会让事务变得更加复杂。
水平分区
保持数据表结构不变,通过某种策略存储数据分片。这样每一片数据分散到不同的表或者库中,达到了分布式的目的。 水平拆分可以支撑非常大的数据量。
水平拆分是指数据表行的拆分,表的行数超过200万行时,就会变慢,这时可以把一张的表的数据拆成多张表来存放。举个例子:我们可以将用户信息表拆分成多个用户信息表,这样就可以避免单一表数据量过大对性能造成影响。
水平拆分可以支持非常大的数据量。需要注意的一点是:分表仅仅是解决了单一表数据过大的问题,但由于表的数据还是在同一台机器上,其实对于提升MySQL并发能力没有什么意义,所以水平拆分最好分库。
水平拆分能够 支持非常大的数据量存储,应用端改造也少,但 分片事务难以解决 ,跨节点Join性能较差,逻辑复杂。《Java工程师修炼之道》的作者推荐 尽量不要对数据进行分片,因为拆分会带来逻辑、部署、运维的各种复杂度 ,一般的数据表在优化得当的情况下支撑千万以下的数据量是没有太大问题的。如果实在要分片,尽量选择客户端分片架构,这样可以减少一次和中间件的网络I/O。
下面补充一下数据库分片的两种常见方案:
客户端代理: 分片逻辑在应用端,封装在jar包中,通过修改或者封装JDBC层来实现。 当当网的 Sharding-JDBC 、阿里的TDDL是两种比较常用的实现。
中间件代理: 在应用和数据中间加了一个代理层。分片逻辑统一维护在中间件服务中。 我们现在谈的 Mycat 、360的Atlas、网易的DDB等等都是这种架构的实现。
参考文章:MySQL大表优化方案
分库分表之后,id 主键如何处理
因为要是分成多个表之后,每个表都是从 1 开始累加,这样是不对的,我们需要一个全局唯一的 id来支持。
生成全局 id 有下面这几种方式:
- UUID: 不适合作为主键,因为太长了,并且无序不可读,查询效率低。比较适合用于生成唯一的名字的标示比如文件的名字。
- 数据库自增 id : 两台数据库分别设置不同步长,生成不重复ID的策略来实现高可用。这种方式生成的 id 有序,但是需要独立部署数据库实例,成本高,还会有性能瓶颈。
- 利用 redis 生成 id : 性能比较好,灵活方便,不依赖于数据库。但是,引入了新的组件造成系统更加复杂,可用性降低,编码更加复杂,增加了系统成本。
- Twitter的snowflake算法 : 算法GitHub地址
- 美团的Leaf分布式ID生成系统: Leaf 是美团开源的分布式ID生成器,能保证全局唯一性、趋势递增、单调递增、信息安全,里面也提到了几种分布式方案的对比,但也需要依赖关系数据库、Zookeeper等中间件。感觉还不错。美团技术团队的一篇文章:技术博客
说说在 MySQL 中一条查询 SQL 是如何执行的
- 取得链接: 使用使用到 MySQL 中的连接器;
- 查询缓存: key 为 SQL 语句,value 为查询结果,如果查到就直接返回。不建议使用缓存,在 MySQL 8.0 版本已经将查询缓存删除,也就是说 MySQL 8.0 版本后不存在此功能;
- 分析器: 分为词法分析和语法分析。此阶段只是做一些 SQL 解析,语法校验。所以一般语法错误在此阶段;
- 优化器: 是在表里有多个索引的时候,决定使用哪个索引;或者一个语句中存在多表关联的时候(join),决定各个表的连接顺序;
- 执行器: 通过分析器让 SQL 知道你要干啥,通过优化器知道该怎么做,于是开始执行语句。执行语句的时候还要判断是否具备此权限,没有权限就直接返回提示没有权限的错误;有权限则打开表,根据表的引擎定义,去使用这个引擎提供的接口,获取这个表的第一行,判断 id 是都等于 1。如果是,直接返回;如果不是继续调用引擎接口去下一行,重复相同的判断,直到取到这个表的最后一行,最后返回。
什么是MySQL主从同步?
主从同步使得数据可以从一个数据库服务器复制到其他服务器上,在复制数据时,一个服务器充当主服务器(master),其余的服务器充当从服务器(slave)。
因为复制是异步进行的,所以从服务器不需要一直连接着主服务器,从服务器甚至可以通过拨号断断续续地连接主服务器。通过配置文件,可以指定复制所有的数据库,某个数据库,甚至是某个数据库上的某个表。
MySQL主从同步的目的?为什么要做主从同步?
- 通过增加从服务器来提高数据库的性能,在主服务器上执行写入和更新,在从服务器上向外提供读功能,可以动态地调整从服务器的数量,从而调整整个数据库的性能。
- 提高数据安全-因为数据已复制到从服务器,从服务器可以终止复制进程,所以,可以在从服务器上备份而不破坏主服务器相应数据
- 在主服务器上生成实时数据,而在从服务器上分析这些数据,从而提高主服务器的性能
- 数据备份。一般我们都会做数据备份,可能是写定时任务,一些特殊行业可能还需要手动备份,有些行业要求备份和原数据不能在同一个地方,所以主从就能很好的解决这个问题,不仅备份及时,而且还可以多地备份,保证数据的安全
MySQL主从复制流程和原理?
基本原理流程,是3个线程以及之间的关联。
主:binlog线程——记录下所有改变了数据库数据的语句,放进master上的binlog中;
从:io线程——在使用start slave之后,负责从master上拉取binlog内容,放进自己的relay log中;
从:sql执行线程——执行relay log中的语句。
复制过程如下:
Binary log:主数据库的二进制日志
Relay log:从服务器的中继日志
第一步: master在每个事务更新数据完成之前,将该操作记录串行地写入到binlog文件中。
第二步: salve开启一个I/O Thread,该线程在master打开一个普通连接,主要工作是binlog dump process。如果读取的进度已经跟上了master,就进入睡眠状态并等待master产生新的事件。I/O线程最终的目的是将这些事件写入到中继日志中。
第三步: SQL Thread会读取中继日志,并顺序执行该日志中的SQL事件,从而与主数据库中的数据保持一致。
索引是什么?
索引是一种特殊的文件(InnoDB数据表上的索引是表空间的一个组成部分),它们包含着对数据表里所有记录的引用指针。
索引是一种数据结构。数据库索引,是数据库管理系统中一个排序的数据结构,以协助快速查询、更新数据库表中数据。索引的实现通常使用B树及其变种B+树。更通俗的说,索引就相当于目录。为了方便查找书中的内容,通过对内容建立索引形成目录。而且索引是一个文件,它是要占据物理空间的。
MySQL索引的建立对于MySQL的高效运行是很重要的,索引可以大大提高MySQL的检索速度。比如我们在查字典的时候,前面都有检索的拼音和偏旁、笔画等,然后找到对应字典页码,这样然后就打开字典的页数就可以知道我们要搜索的某一个key的全部值的信息了。
索引有什么优缺点
索引的优点
- 可以大大加快数据的检索速度,这也是创建索引的最主要的原因。
- 通过使用索引,可以在查询的过程中,使用优化隐藏器,提高系统的性能。
索引的缺点
- 时间方面:创建索引和维护索引要耗费时间,具体地,当对表中的数据进行增加、删除和修改的时候,索引也要动态的维护,会降低增/改/删的执行效率;
- 空间方面:索引需要占物理空间。
4. 说一说索引的底层实现?
Hash索引
基于哈希表实现,只有精确匹配索引所有列的查询才有效,对于每一行数据,存储引擎都会对所有的索引列计算一个哈希码(hash code),并且Hash索引将所有的哈希码存储在索引中,同时在索引表中保存指向每个数据行的指针。
图片来源:面试官:深入谈谈你对MySQL索引的理解
B-Tree索引(MySQL使用B+Tree)
B-Tree能加快数据的访问速度,因为存储引擎不再需要进行全表扫描来获取数据,数据分布在各个节点之中。
B+Tree索引
是B-Tree的改进版本,同时也是数据库索引索引所采用的存储结构。数据都在叶子节点上,并且增加了顺序访问指针,每个叶子节点都指向相邻的叶子节点的地址。相比B-Tree来说,进行范围查找时只需要查找两个节点,进行遍历即可。而B-Tree需要获取所有节点,相比之下B+Tree效率更高。
B+tree性质:
n棵子tree的节点包含n个关键字,不用来保存数据而是保存数据的索引。
所有的叶子结点中包含了全部关键字的信息,及指向含这些关键字记录的指针,且叶子结点本身依关键字的大小自小而大顺序链接。
所有的非终端结点可以看成是索引部分,结点中仅含其子树中的最大(或最小)关键字。
B+ 树中,数据对象的插入和删除仅在叶节点上进行。
B+树有2个头指针,一个是树的根节点,一个是最小关键码的叶节点。
索引的种类有哪些?
从存储结构上来划分:BTree索引(B-Tree或B+Tree索引),Hash索引,full-index全文索引,R-Tree索引。这里所描述的是索引存储时保存的形式,
从应用层次来分:普通索引,唯一索引,复合索引
根据中数据的物理顺序与键值的逻辑(索引)顺序关系:聚集索引,非聚集索引。
平时讲的索引类型一般是指在应用层次的划分。
- 普通索引:即一个索引只包含单个列,一个表可以有多个单列索引
- 复合索引:多列值组成一个索引,专门用于组合搜索,其效率大于索引合并
- 唯一索引:索引列的值必须唯一,但允许有空值
- 聚簇索引(聚集索引):并不是一种单独的索引类型,而是一种数据存储方式。具体细节取决于不同的实现,InnoDB的聚簇索引其实就是在同一个结构中保存了B-Tree索引(技术上来说是B+Tree)和数据行。
- 非聚簇索引: 不是聚簇索引,就是非聚簇索引
为什么 MySQL 的索引要使用 B+树而不是其它树形结构?比如 B 树?
B-tree:
B+树的磁盘读写代价更低:B+树的内部节点并没有指向关键字具体信息的指针,因此其内部节点相对B(B-)树更小,如果把所有同一内部节点的关键字存放在同一盘块中,那么盘块所能容纳的关键字数量也越多,一次性读入内存的需要查找的关键字也就越多,相对
IO读写次数就降低
了。由于B+树的数据都存储在叶子结点中,分支结点均为索引,方便扫库,只需要扫一遍叶子结点即可,但是B树因为其分支结点同样存储着数据,我们要找到具体的数据,需要进行一次中序遍历按序来扫,所以B+树更加适合在
区间查询
的情况,所以通常B+树用于数据库索引。
Hash:虽然可以快速定位,但是没有顺序,IO复杂度高。
基于Hash表实现,只有Memory存储引擎显式支持哈希索引 ;
适合等值查询,如=、in()、<=>,不支持范围查询 ;
因为不是按照索引值顺序存储的,就不能像B+Tree索引一样利用索引完成排序 ;
Hash索引在查询等值时非常快 ;
因为Hash索引始终索引的所有列的全部内容,所以不支持部分索引列的匹配查找 ;
如果有大量重复键值得情况下,哈希索引的效率会很低,因为存在哈希碰撞问题 。
二叉树:树的高度不均匀,不能自平衡,查找效率跟数据有关(树的高度),并且IO代价高。
红黑树:树的高度随着数据量增加而增加,IO代价高。
不使用平衡二叉树的原因如下:
最大原因:深度太大(因为一个节点最多只有2个子节点),一次查询需要的I/O复杂度为O(lgN),而b+tree只需要O(log_mN),而其出度m非常大,其深度一般不会超过4
平衡二叉树逻辑上很近的父子节点,物理上可能很远,无法充分发挥磁盘顺序读和预读的高效特性。
参考文章:MySQL数据库系列(六):MySQL之索引数据结构分析
讲一讲聚簇索引与非聚簇索引?
在 InnoDB 里,索引B+ Tree的叶子节点存储了整行数据的是主键索引,也被称之为聚簇索引,即将数据存储与索引放到了一块,找到索引也就找到了数据。
而索引B+ Tree的叶子节点存储了主键的值的是非主键索引,也被称之为非聚簇索引、二级索引。
聚簇索引与非聚簇索引的区别:
非聚集索引与聚集索引的区别在于非聚集索引的叶子节点不存储表中的数据,而是存储该列对应的主键(行号)
对于InnoDB来说,想要查找数据我们还需要根据主键再去聚集索引中进行查找,这个再根据聚集索引查找数据的过程,我们称为回表。第一次索引一般是顺序IO,回表的操作属于随机IO。需要回表的次数越多,即随机IO次数越多,我们就越倾向于使用全表扫描 。
通常情况下, 主键索引(聚簇索引)查询只会查一次,而非主键索引(非聚簇索引)需要回表查询多次。当然,如果是覆盖索引的话,查一次即可
注意:MyISAM无论主键索引还是二级索引都是非聚簇索引,而InnoDB的主键索引是聚簇索引,二级索引是非聚簇索引。我们自己建的索引基本都是非聚簇索引。
非聚簇索引一定会回表查询吗?
不一定,这涉及到查询语句所要求的字段是否全部命中了索引,如果全部命中了索引,那么就不必再进行回表查询。一个索引包含(覆盖)所有需要查询字段的值,被称之为”覆盖索引”。
举个简单的例子,假设我们在员工表的年龄上建立了索引,那么当进行select score from student where score > 90
的查询时,在索引的叶子节点上,已经包含了score 信息,不会再次进行回表查询。
联合索引是什么?为什么需要注意联合索引中的顺序?
MySQL可以使用多个字段同时建立一个索引,叫做联合索引。在联合索引中,如果想要命中索引,需要按照建立索引时的字段顺序挨个使用,否则无法命中索引。
具体原因为:
MySQL使用索引时需要索引有序,假设现在建立了”name,age,school”的联合索引,那么索引的排序为: 先按照name排序,如果name相同,则按照age排序,如果age的值也相等,则按照school进行排序。
当进行查询时,此时索引仅仅按照name严格有序,因此必须首先使用name字段进行等值查询,之后对于匹配到的列而言,其按照age字段严格有序,此时可以使用age字段用做索引查找,以此类推。因此在建立联合索引的时候应该注意索引列的顺序,一般情况下,将查询需求频繁或者字段选择性高的列放在前面。此外可以根据特例的查询或者表结构进行单独的调整。
MyISAM和InnoDB实现BTree索引方式的区别
MyISAM
B+Tree叶节点的data域存放的是数据记录的地址。在索引检索的时候,首先按照B+Tree搜索算法搜索索引,如果指定的Key存在,则取出其 data 域的值,然后以 data 域的值为地址读取相应的数据记录。这被称为“非聚簇索引”。
索引文件和数据文件是分离的
InnoDB
- InnoDB 的 B+Tree 索引分为主索引(聚集索引)和辅助索引(非聚集索引)。一张表一定包含一个聚集索引构成的 B+ 树以及若干辅助索引的构成的 B+ 树。
- 辅助索引的存在并不会影响聚集索引,因为聚集索引构成的 B+ 树是数据实际存储的形式,而辅助索引只用于加速数据的查找,所以一张表上往往有多个辅助索引以此来提升数据库的性能。
- 就很容易明白为什么不建议使用过长的字段作为主键,因为所有辅助索引都引用主索引,过长的主索引会令辅助索引变得过大。再例如,用非单调的字段作为主键在InnoDB中不是个好主意,因为InnoDB数据文件本身是一颗B+Tree,非单调的主键会造成在插入新记录时数据文件为了维持B+Tree的特性而频繁的分裂调整,十分低效,而使用自增字段作为主键则是一个很好的选择。
什么是最左匹配原则?
最左优先,以最左边的为起点任何连续的索引都能匹配上。同时遇到范围查询(>、<、between、like)就会停止匹配。
例如:b = 2 如果建立(a,b)顺序的索引,是匹配不到(a,b)索引的;但是如果查询条件是a = 1 and b = 2,就可以,因为**优化器会自动调整a,b的顺序**。再比如a = 1 and b = 2 and c > 3 and d = 4 如果建立(a,b,c,d)顺序的索引,d是用不到索引的,因为c字段是一个范围查询,它之后的字段会停止匹配。
最左匹配原则的原理
MySQL中的索引可以以一定顺序引用多列,这种索引叫作联合索引.最左匹配原则都是针对联合索引来说的
- 我们都知道索引的底层是一颗B+树,那么联合索引当然还是一颗B+树,只不过联合索引的健值数量不是一个,而是多个。构建一颗B+树只能根据一个值来构建,因此数据库依据联合索引最左的字段来构建B+树。
例子:假如创建一个(a,b)的联合索引,那么它的索引树是这样的可以看到a的值是有顺序的,1,1,2,2,3,3,而b的值是没有顺序的1,2,1,4,1,2。所以b = 2这种查询条件没有办法利用索引,因为联合索引首先是按a排序的,b是无序的。
同时我们还可以发现在a值相等的情况下,b值又是按顺序排列的,但是这种顺序是相对的。所以最左匹配原则遇上范围查询就会停止,剩下的字段都无法使用索引。例如a = 1 and b = 2 a,b字段都可以使用索引,因为在a值确定的情况下b是相对有序的,而a>1and b=2,a字段可以匹配上索引,但b值不可以,因为a的值是一个范围,在这个范围中b是无序的。
优点:最左前缀原则的利用也可以显著提高查询效率,是常见的MySQL性能优化手段。
讲一讲前缀索引?
因为可能我们索引的字段非常长,这既占内存空间,也不利于维护。所以我们就想,如果只把很长字段的前面的公共部分作为一个索引,就会产生超级加倍的效果。但是,我们需要注意,order by不支持前缀索引 。
流程是:
先计算完整列的选择性 : select count(distinct col_1)/count(1) from table_1
再计算不同前缀长度的选择性 :select count(distinct left(col_1,4))/count(1) from table_1
找到最优长度之后,创建前缀索引 : create index idx_front on table_1 (col_1(4))
了解索引下推吗?
MySQL 5.6引入了索引下推优化。默认开启,使用SET optimizer_switch = ‘index_condition_pushdown=off’;可以将其关闭。
有了索引下推优化,可以在减少回表次数
在InnoDB中只针对二级索引有效
官方文档中给的例子和解释如下:
在 people_table中有一个二级索引(zipcode,lastname,address),查询是SELECT * FROM people WHERE zipcode=’95054′ AND lastname LIKE ‘%etrunia%’ AND address LIKE ‘%Main Street%’
;
如果没有使用索引下推技术,则MySQL会通过zipcode=’95054’从存储引擎中查询对应的数据,返回到MySQL服务端,然后MySQL服务端基于
lastname LIKE ‘%etrunia%’ and address LIKE ‘%Main Street%’
来判断数据是否符合条件如果使用了索引下推技术,则MYSQL首先会返回符合zipcode=’95054’的索引,然后根据
lastname LIKE ‘%etrunia%’ and address LIKE ‘%Main Street%’
来判断索引是否符合条件。如果符合条件,则根据该索引来定位对应的数据,如果不符合,则直接reject掉。
怎么查看MySQL语句有没有用到索引?
通过explain,如以下例子:
EXPLAIN SELECT * FROM employees.titles WHERE emp_no='10001' AND title='Senior Engineer' AND from_date='1986-06-26';
id | select_type | table | partitions | type | possible_keys | key | key_len | ref | filtered | rows | Extra |
---|---|---|---|---|---|---|---|---|---|---|---|
1 | SIMPLE | titles | null | const | PRIMARY | PRIMARY | 59 | const,const,const | 10 | 1 |
id:在⼀个⼤的查询语句中每个SELECT关键字都对应⼀个唯⼀的id ,如
explain select * from s1 where id = (select id from s1 where name = 'egon1');
第一个select的id是1,第二个select的id是2。有时候会出现两个select,但是id却都是1,这是因为优化器把子查询变成了连接查询 。select_type:select关键字对应的那个查询的类型,如SIMPLE,PRIMARY,SUBQUERY,DEPENDENT,SNION 。
table:每个查询对应的表名 。
type:
type
字段比较重要, 它提供了判断查询是否高效的重要依据依据. 通过type
字段, 我们判断此次查询是全表扫描
还是索引扫描
等。如const(主键索引或者唯一二级索引进行等值匹配的情况下),ref(普通的⼆级索引列与常量进⾏等值匹配),index(扫描全表索引的覆盖索引) 。通常来说, 不同的 type 类型的性能关系如下:
ALL < index < range ~ index_merge < ref < eq_ref < const < system
ALL
类型因为是全表扫描, 因此在相同的查询条件下, 它是速度最慢的.
而index
类型的查询虽然不是全表扫描, 但是它扫描了所有的索引, 因此比 ALL 类型的稍快.possible_key:查询中可能用到的索引*(可以把用不到的删掉,降低优化器的优化时间)* 。
key:此字段是 MySQL 在当前查询时所真正使用到的索引。
filtered:查询器预测满足下一次查询条件的百分比 。
rows 也是一个重要的字段. MySQL 查询优化器根据统计信息, 估算 SQL 要查找到结果集需要扫描读取的数据行数.
这个值非常直观显示 SQL 的效率好坏, 原则上 rows 越少越好。extra:表示额外信息,如Using where,Start temporary,End temporary,Using temporary等。
为什么官方建议使用自增长主键作为索引?
结合B+Tree的特点,自增主键是连续的,在插入过程中尽量减少页分裂,即使要进行页分裂,也只会分裂很少一部分。并且能减少数据的移动,每次插入都是插入到最后。总之就是减少分裂和移动的频率。
插入连续的数据:
图片来自:面试官:深入谈谈你对MySQL索引的理解
插入非连续的数据:
如何创建索引?
创建索引有三种方式。
- 在执行CREATE TABLE时创建索引
CREATE TABLE user_index2 (
id INT auto_increment PRIMARY KEY,
first_name VARCHAR (16),
last_name VARCHAR (16),
id_card VARCHAR (18),
information text,
KEY name (first_name, last_name),
FULLTEXT KEY (information),
UNIQUE KEY (id_card)
);
- 使用ALTER TABLE命令去增加索引。
ALTER TABLE table_name ADD INDEX index_name (column_list);
ALTER TABLE用来创建普通索引、UNIQUE索引或PRIMARY KEY索引。
其中table_name是要增加索引的表名,column_list指出对哪些列进行索引,多列时各列之间用逗号分隔。
索引名index_name可自己命名,缺省时,MySQL将根据第一个索引列赋一个名称。另外,ALTER TABLE允许在单个语句中更改多个表,因此可以在同时创建多个索引。
3. 使用CREATE INDEX命令创建。
CREATE INDEX index_name ON table_name (column_list);
创建索引时需要注意什么?
- 非空字段:应该指定列为NOT NULL,除非你想存储NULL。在mysql中,含有空值的列很难进行查询优化,因为它们使得索引、索引的统计信息以及比较运算更加复杂。你应该用0、一个特殊的值或者一个空串代替空值;
- 取值离散大的字段:(变量各个取值之间的差异程度)的列放到联合索引的前面,可以通过count()函数查看字段的差异值,返回值越大说明字段的唯一值越多字段的离散程度高;
- 索引字段越小越好:数据库的数据存储以页为单位一页存储的数据越多一次IO操作获取的数据越大效率越高。
建索引的原则有哪些?
最左前缀匹配原则,非常重要的原则,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的顺序可以任意调整。
=和in可以乱序,比如a = 1 and b = 2 and c = 3 建立(a,b,c)索引可以任意顺序,mysql的查询优化器会帮你优化成索引可以识别的形式。
尽量选择区分度高的列作为索引,区分度的公式是count(distinct col)/count(*),表示字段不重复的比例,比例越大我们扫描的记录数越少,唯一键的区分度是1,而一些状态、性别字段可能在大数据面前区分度就是0,那可能有人会问,这个比例有什么经验值吗?使用场景不同,这个值也很难确定,一般需要join的字段我们都要求是0.1以上,即平均1条扫描10条记录。
索引列不能参与计算,保持列“干净”,比如from_unixtime(create_time) = ’2014-05-29’就不能使用到索引,原因很简单,b+树中存的都是数据表中的字段值,但进行检索时,需要把所有元素都应用函数才能比较,显然成本太大。所以语句应该写成create_time = unix_timestamp(’2014-05-29’)。
尽量的扩展索引,不要新建索引。比如表中已经有a的索引,现在要加(a,b)的索引,那么只需要修改原来的索引即可。
使用索引查询一定能提高查询的性能吗?
通常通过索引查询数据比全表扫描要快。但是我们也必须注意到它的代价。
索引需要空间来存储,也需要定期维护, 每当有记录在表中增减或索引列被修改时,索引本身也会被修改。 这意味着每条记录的I* NSERT,DELETE,UPDATE将为此多付出4,5 次的磁盘I/O。 因为索引需要额外的存储空间和处理,那些不必要的索引反而会使查询反应时间变慢。使用索引查询不一定能提高查询性能,索引范围查询(INDEX RANGE SCAN)适用于两种情况:
- 基于一个范围的检索,一般查询返回结果集小于表中记录数的30%。
- 基于非唯一性索引的检索。
什么情况下不走索引(索引失效)?
使用!= 或者 <> 导致索引失效
类型不一致导致的索引失效
函数导致的索引失效
如:
SELECT * FROM `user` WHERE DATE(create_time) = '2020-09-03';
如果使用函数在索引列,这是不走索引的。
- 运算符导致的索引失效
SELECT * FROM `user` WHERE age - 1 = 20;
如果你对列进行了(+,-,*,/,!), 那么都将不会走索引。
- OR引起的索引失效
SELECT * FROM `user` WHERE `name` = '张三' OR height = '175';
OR导致索引是在特定情况下的,并不是所有的OR都是使索引失效,如果OR连接的是同一个字段,那么索引不会失效,反之索引失效。
- 模糊搜索导致的索引失效
SELECT * FROM `user` WHERE `name` LIKE '%冰';
当%
放在匹配字段前是不走索引的,放在后面才会走索引。
- NOT IN、NOT EXISTS导致索引失效
MySQL建表的约束条件有哪些?
- 主键约束(Primay Key Coustraint) 唯一性,非空性
- 唯一约束 (Unique Counstraint)唯一性,可以空,但只能有一个
- 检查约束 (Check Counstraint) 对该列数据的范围、格式的限制
- 默认约束 (Default Counstraint) 该数据的默认值
- 外键约束 (Foreign Key Counstraint) 需要建立两表间的关系并引用主表的列
索引这么多优点,为什么不对表中的每一个列创建一个索引呢?
- 当对表中的数据进行增加、删除和修改的时候,索引也要动态的维护,这样就降低了数据的维护速度。
- 索引需要占物理空间,除了数据表占数据空间之外,每一个索引还要占一定的物理空间,如果要建立聚簇索引,那么需要的空间就会更大。
- 创建索引和维护索引要耗费时间,这种时间随着数据量的增加而增加。
MySQL 中 varchar 与 char 的区别?varchar(30) 中的 30代表的涵义
- varchar 与 char 的区别,char 是一种固定长度的类型,varchar 则是一种可变长度的类型;
- varchar(30) 中 30 的涵义最多存放 30 个字符。varchar(30) 和 (130) 存储 hello 所占空间一样,但后者在排序时会消耗更多内存,因为 ORDER BY col 采用 fixed_length 计算 col 长度(memory 引擎也一样);
- 对效率要求高用 char,对空间使用要求高用 varchar。
int(11) 中的 11 代表什么涵义
int(11) 中的 11,不影响字段存储的范围,只影响展示效果。
为什么 SELECT COUNT(*) FROM table 在 InnoDB 比MyISAM 慢
对于 SELECT COUNT(*) FROM table 语句,在没有 WHERE 条件的情况下,InnoDB 比 MyISAM 可能会慢很多,尤其在大表的情况下。因为,InnoDB 是去实时统计结果,会全表扫描;而 MyISAM内部维持了一个计数器,预存了结果,所以直接返回即可。
MySQL 索引类型有哪些
- 主键索引: 索引列中的值必须是唯一的,不允许有空值。
- 普通索引: MySQL中基本索引类型,没有什么限制,允许在定义索引的列中插入重复值和空值。
- 唯一索引: 索引列中的值必须是唯一的,但是允许为空值。
- 全文索引: 只能在文本类型CHAR,VARCHAR,TEXT类型字段上创建全文索引。字段长度比较大时,如果创建普通索引,在进行like模糊查询时效率比较低,这时可以创建全文索引。MyISAM和InnoDB中都可以使用全文索引。
- 空间索引: MySQL在5.7之后的版本支持了空间索引,而且支持OpenGIS几何数据模型。MySQL在空间索引这方面遵循OpenGIS几何数据模型规则。
- 前缀索引: 在文本类型如CHAR,VARCHAR,TEXT类列上创建索引时,可以指定索引列的长度,但是数值类型不能指定。
- 组合索引: 组合索引的使用,需要遵循最左前缀匹配原则(最左匹配原则)。一般情况下在条件允许的情况下使用组合索引替代多个单列索引使用。
什么时候不要使用索引
- 经常增删改的列不要建立索引;
- 有大量重复的列不建立索引;
- 表记录太少不要建立索引。
请说说 MySQL 数据库的锁
关于 MySQL 的锁机制,可能会问很多问题,不过这也得看面试官在这方面的知识储备。
MySQL 中的锁从类别上来讲,分为共享锁和排它锁,也就是读锁和写锁。从粒度上来讲,分为行级锁(INNODB引擎)、表级锁(MYISAM引擎)、页级锁(BDB引擎)。
根据类别区分
- 共享锁: 不堵塞,多个用户可以同一时刻读取同一个资源,相互之间没有影响;
- 排它锁: 一个写操作阻塞其他的读锁和写锁,这样可以只允许一个用户进行写入,防止其他用户读取正在写入的资源。
根据粒度区分
- 表锁: 系统开销最小,会锁定整张表,MyISAM 使用表锁。
- 表级锁是MySQL中锁定粒度最大的一种锁,表示对当前操作的整张表加锁,它实现简单,资源消耗较少,被大部分MySQL引擎支持。最常使用的MYISAM与INNODB都支持表级锁定。表级锁定分为表共享读锁(共享锁)与表独占写锁(排他锁)。
- 开销小,加锁快;不会出现死锁;锁定粒度大,发出锁冲突的概率最高,并发度最低。
- 行锁: 容易出现死锁,发生冲突概率低,并发高,InnoDB 支持行锁(必须有索引才能实现,否则会自动锁全表,那么就不是行锁了)。
- 行级锁是MySQL中锁定粒度最细的一种锁,表示只针对当前操作的行进行加锁。行级锁能大大减少数据库操作的冲突。其加锁粒度最小,但加锁的开销也最大。行级锁分为共享锁和排他锁。
- 开销大,加锁慢;会出现死锁;锁定粒度最小,发生锁冲突的概率最低,并发度也最高。
- 页级锁:
- 页级锁是MySQL中锁定粒度介于行级锁和表级锁中间的一种锁。表级锁速度快,但冲突多,行级冲突少,但速度慢。所以取了折衷的页级,一次锁定相邻的一组记录。BDB支持页级锁
- 开销和加锁时间界于表锁和行锁之间;会出现死锁;锁定粒度界于表锁和行锁之间,并发度一般
MyISAM和InnoDB存储引擎使用的锁:
- MyISAM采用表级锁(table-level locking)。
- InnoDB支持行级锁(row-level locking)和表级锁,默认为行级锁
数据库的乐观锁和悲观锁是什么?怎么实现的?
数据库管理系统(DBMS)中的并发控制的任务是确保在多个事务同时存取数据库中同一数据时不破坏事务的隔离性和统一性以及数据库的统一性。乐观并发控制(乐观锁)和悲观并发控制(悲观锁)是并发控制主要采用的技术手段。
悲观锁:假定会发生并发冲突,屏蔽一切可能违反数据完整性的操作。在查询完数据的时候就把事务锁起来,直到提交事务。实现方式:使用数据库中的锁机制
乐观锁:假设不会发生并发冲突,只在提交操作时检查是否违反数据完整性。在修改数据的时候把事务锁起来,通过version的方式来进行锁定。实现方式:乐一般会使用版本号机制或CAS算法实现。
两种锁的使用场景
从上面对两种锁的介绍,我们知道两种锁各有优缺点,不可认为一种好于另一种,像乐观锁适用于写比较少的情况下(多读场景),即冲突真的很少发生的时候,这样可以省去了锁的开销,加大了系统的整个吞吐量。
但如果是多写的情况,一般会经常产生冲突,这就会导致上层应用会不断的进行retry,这样反倒是降低了性能,所以一般多写的场景下用悲观锁就比较合适。
InnoDB引擎的行锁是怎么实现的?
InnoDB是基于索引来完成行锁
例: select * from tab_with_index where id = 1 for update;
for update 可以根据条件来完成行锁锁定,并且 id 是有索引键的列,如果 id 不是索引键那么InnoDB将完成表锁,并发将无从谈起。
什么是死锁?怎么解决?
死锁是指两个或多个事务在同一资源上相互占用,并请求锁定对方的资源,从而导致恶性循环的现象。
常见的解决死锁的方法:
如果不同程序会并发存取多个表,尽量约定以相同的顺序访问表,可以大大降低死锁机会。
在同一个事务中,尽可能做到一次锁定所需要的所有资源,减少死锁产生概率;
对于非常容易产生死锁的业务部分,可以尝试使用升级锁定颗粒度,通过表级锁定来减少死锁产生的概率。
如果业务处理不好可以用分布式事务锁或者使用乐观锁。
隔离级别与锁的关系
在Read Uncommitted级别下,读取数据不需要加共享锁,这样就不会跟被修改的数据上的排他锁冲突
在Read Committed级别下,读操作需要加共享锁,但是在语句执行完以后释放共享锁;
在Repeatable Read级别下,读操作需要加共享锁,但是在事务提交之前并不释放共享锁,也就是必须等待事务执行完毕以后才释放共享锁。
SERIALIZABLE 是限制性最强的隔离级别,因为该级别锁定整个范围的键,并一直持有锁,直到事务完成。
优化锁方面的意见?
- 使用较低的隔离级别
- 设计索引,尽量使用索引去访问数据,加锁更加精确,从而减少锁冲突
- 选择合理的事务大小,给记录显示加锁时,最好一次性请求足够级别的锁。列如,修改数据的话,最好申请排他锁,而不是先申请共享锁,修改时在申请排他锁,这样会导致死锁
- 不同的程序访问一组表的时候,应尽量约定一个相同的顺序访问各表,对于一个表而言,尽可能的固定顺序的获取表中的行。这样大大的减少死锁的机会。
- 尽量使用相等条件访问数据,这样可以避免间隙锁对并发插入的影响
- 不要申请超过实际需要的锁级别
- 数据查询的时候不是必要,不要使用加锁。MySQL的MVCC可以实现事务中的查询不用加锁,优化事务性能:MVCC只在committed read(读提交)和 repeatable read (可重复读)两种隔离级别
- 对于特定的事务,可以使用表锁来提高处理速度活着减少死锁的可能。
说说什么是锁升级
- MySQL 行锁只能加在索引上,如果操作不走索引,就会升级为表锁。因为 InnoDB 的行锁是加在索引上的,如果不走索引,自然就没法使用行锁了,原因是 InnoDB 是将 primary key index和相关的行数据共同放在 B+ 树的叶节点。InnoDB 一定会有一个 primary key,secondary index 查找的时候,也是通过找到对应的 primary,再找对应的数据行。
- 当非唯一索引上记录数超过一定数量时,行锁也会升级为表锁。测试发现当非唯一索引相同的内容不少于整个表记录的二分之一时会升级为表锁。因为当非唯一索引相同的内容达到整个记录的二分之一时,索引需要的性能比全文检索还要大,查询语句优化时会选择不走索引,造成索引失效,行锁自然就会升级为表锁。
使用 MySQL 的索引应该注意些什么
MySQL 如何做到高可用方案
MySQL 高可用,意味着不能一台 MySQL 出了问题,就不能访问了。
- MySQL 高可用: 分库分表,通过 MyCat 连接多个 MySQL
- MyCat 也得高可用: Haproxy,连接多个 MyCat
- Haproxy 也得高可用: 通过 keepalived 辅助Haproxy
如何定位及优化SQL语句的性能问题?
对于低性能的SQL语句的定位,最重要也是最有效的方法就是使用执行计划,MySQL提供了explain命令来查看语句的执行计划。 我们知道,不管是哪种数据库,或者是哪种数据库引擎,在对一条SQL语句进行执行的过程中都会做很多相关的优化,对于查询语句,最重要的优化方式就是使用索引。
而执行计划,就是显示数据库引擎对于SQL语句的执行的详细情况,其中包含了是否使用索引,使用什么索引,使用的索引的相关信息等。
大表数据查询,怎么优化
- 优化shema、sql语句+索引;
- 第二加缓存,memcached, redis;
- 主从复制,读写分离;
- 垂直拆分,根据你模块的耦合度,将一个大的系统分为多个小的系统,也就是分布式系统;
- 水平切分,针对数据量大的表,这一步最麻烦,最能考验技术水平,要选择一个合理的sharding key, 为了有好的查询效率,表结构也要改动,做一定的冗余,应用也要改,sql中尽量带sharding key,将数据定位到限定的表上去查,而不是扫描全部的表;
超大分页怎么处理?
数据库层面,这也是我们主要集中关注的(虽然收效没那么大),类似于select * from table where age > 20 limit 1000000
,10 这种查询其实也是有可以优化的余地的. 这条语句需要 load1000000 数据然后基本上全部丢弃,只取 10 条当然比较慢. 当时我们可以修改为select * from table where id in (select id from table where age > 20 limit 1000000,10)
.这样虽然也 load 了一百万的数据,但是由于索引覆盖,要查询的所有字段都在索引中,所以速度会很快。
解决超大分页,其实主要是靠缓存,可预测性的提前查到内容,缓存至redis等k-V数据库中,直接返回即可.
在阿里巴巴《Java开发手册》中,对超大分页的解决办法是类似于上面提到的第一种.
【推荐】利用延迟关联或者子查询优化超多分页场景。
说明:MySQL并不是跳过offset行,而是取offset+N行,然后返回放弃前offset行,返回N行,那当offset特别大的时候,效率就非常的低下,要么控制返回的总页数,要么对超过特定阈值的页数进行SQL改写。
正例:先快速定位需要获取的id段,然后再关联:
SELECT a.* FROM 表1 a, (select id from 表1 where 条件 LIMIT 100000,20 ) b where a.id=b.id
统计过慢查询吗?对慢查询都怎么优化过?
在业务系统中,除了使用主键进行的查询,其他的我都会在测试库上测试其耗时,慢查询的统计主要由运维在做,会定期将业务中的慢查询反馈给我们。
慢查询的优化首先要搞明白慢的原因是什么? 是查询条件没有命中索引?是load了不需要的数据列?还是数据量太大?
所以优化也是针对这三个方向来的,
- 首先分析语句,看看是否load了额外的数据,可能是查询了多余的行并且抛弃掉了,可能是加载了许多结果中并不需要的列,对语句进行分析以及重写。
- 分析语句的执行计划,然后获得其使用索引的情况,之后修改语句或者修改索引,使得语句可以尽可能的命中索引。
- 如果对语句的优化已经无法进行,可以考虑表中的数据量是否太大,如果是的话可以进行横向或者纵向的分表。
如何优化查询过程中的数据访问
- 访问数据太多导致查询性能下降
- 确定应用程序是否在检索大量超过需要的数据,可能是太多行或列
- 确认MySQL服务器是否在分析大量不必要的数据行
- 查询不需要的数据。解决办法:使用limit解决
- 多表关联返回全部列。解决办法:指定列名
- 总是返回全部列。解决办法:避免使用SELECT *
- 重复查询相同的数据。解决办法:可以缓存数据,下次直接读取缓存
- 是否在扫描额外的记录。解决办法:使用explain进行分析,如果发现查询需要扫描大量的数据,但只返回少数的行,可以通过如下技巧去优化:使用索引覆盖扫描,把所有的列都放到索引中,这样存储引擎不需要回表获取对应行就可以返回结果。
- 改变数据库和表的结构,修改数据表范式
- 重写SQL语句,让优化器可以以更优的方式执行查询。
如何优化关联查询
- 确定ON或者USING子句中是否有索引。
- 确保GROUP BY和ORDER BY只有一个表中的列,这样MySQL才有可能使用索引。
数据库结构优化
一个好的数据库设计方案对于数据库的性能往往会起到事半功倍的效果。
需要考虑数据冗余、查询和更新的速度、字段的数据类型是否合理等多方面的内容。
- 将字段很多的表分解成多个表
对于字段较多的表,如果有些字段的使用频率很低,可以将这些字段分离出来形成新表。
因为当一个表的数据量很大时,会由于使用频率低的字段的存在而变慢。
- 增加中间表
对于需要经常联合查询的表,可以建立中间表以提高查询效率。
通过建立中间表,将需要通过联合查询的数据插入到中间表中,然后将原来的联合查询改为对中间表的查询。
- 增加冗余字段
设计数据表时应尽量遵循范式理论的规约,尽可能的减少冗余字段,让数据库设计看起来精致、优雅。但是,合理的加入冗余字段可以提高查询速度。
表的规范化程度越高,表和表之间的关系越多,需要连接查询的情况也就越多,性能也就越差。
注意:
冗余字段的值在一个表中修改了,就要想办法在其他表中更新,否则就会导致数据不一致的问题。
MySQL数据库cpu飙升到500%的话他怎么处理?
当 cpu 飙升到 500%时,先用操作系统命令 top 命令观察是不是 MySQLd 占用导致的,如果不是,找出占用高的进程,并进行相关处理。
如果是 MySQLd 造成的, show processlist,看看里面跑的 session 情况,是不是有消耗资源的 sql 在运行。找出消耗高的 sql,看看执行计划是否准确, index 是否缺失,或者实在是数据量太大造成。
一般来说,肯定要 kill 掉这些线程(同时观察 cpu 使用率是否下降),等进行相应的调整(比如说加索引、改 sql、改内存参数)之后,再重新跑这些 SQL。
也有可能是每个 sql 消耗资源并不多,但是突然之间,有大量的 session 连进来导致 cpu 飙升,这种情况就需要跟应用一起来分析为何连接数会激增,再做出相应的调整,比如说限制连接数等。
大表怎么优化?
类似的问题:某个表有近千万数据,CRUD比较慢,如何优化?分库分表了是怎么做的?分表分库了有什么问题?有用到中间件么?他们的原理知道么?
当MySQL单表记录数过大时,数据库的CRUD性能会明显下降,一些常见的优化措施如下:
- 限定数据的范围: 务必禁止不带任何限制数据范围条件的查询语句。比如:我们当用户在查询订单历史的时候,我们可以控制在一个月的范围内;
- 读/写分离: 经典的数据库拆分方案,主库负责写,从库负责读;
- 缓存: 使用MySQL的缓存,另外对重量级、更新少的数据可以考虑;
- 通过分库分表的方式进行优化,主要有垂直分表和水平分表。
8. Spring Cloud篇
Spring Cloud系列文章:Spring-Cloud
什么是服务熔断?什么是服务降级?
熔断机制是应对雪崩效应的一种微服务链路保护机制。当某个微服务不可用或者响应时间太长时,会进行服务降级,进而熔断该节点微服务的调用,快速返回“错误”的响应信息。当检测到该节点微服务调用响应正常后恢复调用链路。在Spring Cloud框架里熔断机制通过Hystrix实现,Hystrix会监控微服务间调用的状况,当失败的调用到一定阈值,缺省是5秒内调用20次,如果失败,就会启动熔断机制。
服务降级,一般是从整体负荷考虑。就是当某个服务熔断之后,服务器将不再被调用,此时客户端可以自己准备一个本地的fallback回调,返回一个缺省值。这样做,虽然水平下降,但好歹可用,比直接挂掉强。
Eureka和zookeeper都可以提供服务注册与发现的功能,请说说两个的区别
Zookeeper保证了CP(C:一致性,P:分区容错性),Eureka保证了AP(A:高可用,P:分区容错性)。
- 当向注册中心查询服务列表时,我们可以容忍注册中心返回的是几分钟以前的信息,但不能容忍直接down掉不可用。也就是说,服务注册功能对高可用性要求比较高,但zk会出现这样一种情况,当master节点因为网络故障与其他节点失去联系时,剩余节点会重新选leader。问题在于,选取leader时间过长,30 ~ 120s,且选取期间zk集群都不可用,这样就会导致选取期间注册服务瘫痪。在云部署的环境下,因网络问题使得zk集群失去master节点是较大概率会发生的事,虽然服务能够恢复,但是漫长的选取时间导致的注册长期不可用是不能容忍的。
- Eureka保证了可用性,Eureka各个节点是平等的,几个节点挂掉不会影响正常节点的工作,剩余的节点仍然可以提供注册和查询服务。而Eureka的客户端向某个Eureka注册或发现时发生连接失败,则会自动切换到其他节点,只要有一台Eureka还在,就能保证注册服务可用,只是查到的信息可能不是最新的。除此之外,Eureka还有自我保护机制,如果在15分钟内超过85%的节点没有正常的心跳,那么Eureka就认为客户端与注册中心发生了网络故障,此时会出现以下几种情况:
- Eureka不在从注册列表中移除因为长时间没有收到心跳而应该过期的服务。
- Eureka仍然能够接受新服务的注册和查询请求,但是不会被同步到其他节点上(即保证当前节点仍然可用)
- 当网络稳定时,当前实例新的注册信息会被同步到其他节点。
因此,Eureka可以很好的应对因网络故障导致部分节点失去联系的情况,而不会像Zookeeper那样使整个微服务瘫痪。
负载平衡的意义什么
在计算中,负载平衡可以改善跨计算机,计算机集群,网络链接,中央处理单元或磁盘驱动器等多种计算资源的工作负载分布。负载平衡旨在优化资源使用,最大化吞吐量,最小化响应时间并避免任何单一资源 的过载。使用多个组件进行负载平衡而不是单个组件可能会通过冗余来提高可靠性和可用性。负载平衡通常涉及专用软件或硬件,例如多层交换机或域名系统服务器进程。
什么是Hystrix?它如何实现容错?
Hystrix是一个延迟和容错库,旨在隔离远程系统,服务和第三方库的访问点,当出现故障是不可避免的故障时,停止级联故障并在复杂的分布式系统中实现弹性。
通常对于使用微服务架构开发的系统,涉及到许多微服务。这些微服务彼此协作。
比如一个服务调用链路是A调用B,B调用C,C调用D。如果D出现网络短时的中断,这个收就会导致整个链路堵塞在C位置,增加了C的压力,有可能导致C的崩溃,同理可能影响到B、A,最终引起服务雪崩的问题。
使用Hystrix在这种情况下的Fallback方法功能。在C服务中定义fallback(回退)处理逻辑,及时的给出反馈信息。很好的预防服务雪崩的问题。
说说 RPC 的实现原理
首先需要有处理网络连接通讯的模块,负责连接建立、管理和消息的传输。其次需要有编解码的模块,因为网络通讯都是传输的字节码,需要将我们使用的对象序列化和反序列 化。剩下的就是客户端和服务器端的部分,服务器端暴露要开放的服务接口,客户调用服 务接口的一个代理实现,这个代理实现负责收集数据、编码并传输给服务器然后等待结果返回。
Eureka自我保护机制是什么
当Eureka Server 节点在短时间内丢失了过多实例的连接时(比如网络故障或频繁启动关闭客户端)节点会进入自我保护模式,保护注册信息,不再删除注册数据,故障恢复时,自动退出自我保护模式。
什么是Ribbon
ribbon是一个负载均衡客户端,可以很好的控制htt和tcp的一些行为。feign默认集成了ribbon。
什么是 Feigin ?它的优点是什么
- feign采用的是基于接口的注解
- feign整合了ribbon,具有负载均衡的能力
- 整合了Hystrix,具有熔断的能力
Ribbon和Feign的区别
- Ribbon都是调用其他服务的,但方式不同
- 启动类注解不同,Ribbon是@RibbonClient,feign则是@EnableFeignClients
- 服务指定的位置不同,Ribbon是在@RibbonClient注解上声明,Feign则是在定义抽象方法的接口中使用@FeignClient声明
- 调用方式不同,Ribbon需要自己构建http请求,模拟http请求
什么是Spring Cloud Gateway
Spring Cloud Gateway是Spring Cloud官方推出的第二代网关框架,取代Zuul网关。网关作为流量的,在微服务系统中有着非常作用,网关常见的功能有路由转发、权限校验、限流控制等作用。
使用了一个RouteLocatorBuilder的bean去创建路由,除了创建路由RouteLocatorBuilder可以让你添加各种predicates和filters,predicates断言的意思,顾名思义就是根据具体的请求的规则,由具体的route去处理,filters是各种过滤器,用来对请求做各种判断和修改。
什么是Spring Cloud Alibaba
Spring Cloud Alibaba 致力于提供微服务开发的一站式解决方案。此项目包含开发分布式应用微服务的必需组件,方便开发者通过 Spring Cloud 编程模型轻松使用这些组件来开发分布式应用服务。
Spring Cloud Alibaba有哪些功能
服务限流降级(Sentinel):默认支持 WebServlet、WebFlux, OpenFeign、RestTemplate、Spring Cloud Gateway, Zuul, Dubbo 和 RocketMQ 限流降级功能的接入,可以在运行时通过控制台实时修改限流降级规则,还支持查看限流降级 Metrics 监控。
服务注册与发现(Nacos):适配 Spring Cloud 服务注册与发现标准,默认集成了 Ribbon 的支持。
分布式配置管理(Nacos):支持分布式系统中的外部化配置,配置更改时自动刷新。
消息驱动能力:基于 Spring Cloud Stream 为微服务应用构建消息驱动能力。
分布式事务(Seata):使用 @GlobalTransactional 注解, 高效并且对业务零侵入地解决分布式事务问题。
阿里云对象存储:阿里云提供的海量、安全、低成本、高可靠的云存储服务。支持在任何应用、任何时间、任何地点存储和访问任意类型的数据。
分布式任务调度:提供秒级、精准、高可靠、高可用的定时(基于 Cron 表达式)任务调度服务。同时提供分布式的任务执行模型,如网格任务。网格任务支持海量子任务均匀分配到所有 Worker(schedulerx-client)上执行。
阿里云短信服务:覆盖全球的短信服务,友好、高效、智能的互联化通讯能力,帮助企业迅速搭建客户触达通道。
Spring Cloud Alibaba的常用组件有哪些
Sentinel:把流量作为切入点,从流量控制、熔断降级、系统负载保护等多个维度保护服务的稳定性。
Nacos:一个更易于构建云原生应用的动态服务发现、配置管理和服务管理平台。
RocketMQ:一款开源的分布式消息系统,基于高可用分布式集群技术,提供低延时的、高可靠的消息发布与订阅服务。
Dubbo:Apache Dubbo™ 是一款高性能 Java RPC 框架。
Seata:阿里巴巴开源产品,一个易于使用的高性能微服务分布式事务解决方案。
Alibaba Cloud OSS:阿里云对象存储服务(Object Storage Service,简称 OSS),是阿里云提供的海量、安全、低成本、高可靠的云存储服务。您可以在任何应用、任何时间、任何地点存储和访问任意类型的数据。
Alibaba Cloud SchedulerX: 阿里中间件团队开发的一款分布式任务调度产品,提供秒级、精准、高可靠、高可用的定时(基于 Cron 表达式)任务调度服务。
Alibaba Cloud SMS: 覆盖全球的短信服务,友好、高效、智能的互联化通讯能力,帮助企业迅速搭建客户触达通道。
什么是Nacos
前面组件中也有简单介绍;Nacos命名的由来:前四个字母分别是Naming和Configuration的前两个字母,后面的s是Service
Nacos是一个易于使用的动态服务发现,配置和服务管理平台,用于构建云本机应用程序。使用Spring Cloud Alibaba Nacos Discovery,您可以基于Spring Cloud的编程模型快速访问Nacos服务注册功能。
Nacos就是注册中心+配置中心的组合(Nacos= Eureka + Config + Bus
)
Nacos有什么作用
服务发现与服务健康检查Nacos使服务更容易注册,并通过DNS或HTTP接口发现其他服务,Nacos还提供服务的实时健康检查,以防止向不健康的主机或服务实例发送请求。
动态配置管理动态配置服务允许您在所有环境中以集中和动态的方式管理所有服务的配置。Nacos消除了在更新配置时重新部署应用程序,这使配置的更改更加高效和灵活。
动态DNS服务Nacos提供基于DNS 协议的服务发现能力,旨在支持异构语言的服务发现,支持将注册在Nacos上的服务以域名的方式暴露端点,让三方应用方便的查阅及发现。
服务和元数据管理Nacos 能让您从微服务平台建设的视角管理数据中心的所有服务及元数据,包括管理服务的描述、生命周期、服务的静态依赖分析、服务的健康状态、服务的流量管理、路由及安全策略。
Nacos支持Ap和Cp的切换,该如何选择呢
一般来说,如果不需要存储服务级别的信息且服务实例是通过nacos-client注册,并能够保持心跳上报,那么就可以选择AP模式。当前主流的服务如 Spring cloud 和 Dubbo 服务,都适用于AP模式,AP模式为了服务的可能性而减弱了一致性,因此AP模式下只支持注册临时实例。
如果需要在服务级别编辑或者存储配置信息,那么 CP 是必须,K8S服务和DNS服务则适用于CP模式。
CP模式下则支持注册持久化实例,此时则是以 Raft 协议为集群运行模式,该模式下注册实例之前必须先注册服务,如果服务不存在,则会返回错误。
模式切换代码:curl -X PUT '$NACOS_SERVER:8848/nacos/v1/ns/operator/switches?entry=serverMode&value=CP'
Nacos读取配置文件的有哪几种方案
Data ID
方案:指定spring.profile.active
和配置文件的DataID来使不同环境下读取不同的配置Group
方案:通过Group实现环境区分Namespace
方案:通过建立不同NameSpace来区分
什么是Spring Cloud Sentinel
Sentinel 是面向分布式服务架构的流量控制组件,主要以流量为切入点,从流量控制、熔断降级、系统自适应保护等多个维度来帮助您保障微服务的稳定性。
Sentinel 基本概念有哪些
资源:资源是 Sentinel 的关键概念。它可以是 Java 应用程序中的任何内容,例如,由应用程序提供的服务,或由应用程序调用的其它应用提供的服务,甚至可以是一段代码。在接下来的文档中,我们都会用资源来描述代码块。只要通过 Sentinel API 定义的代码,就是资源,能够被 Sentinel 保护起来。大部分情况下,可以使用方法签名,URL,甚至服务名称作为资源名来标示资源。
规则:围绕资源的实时状态设定的规则,可以包括流量控制规则、熔断降级规则以及系统保护规则。所有规则可以动态实时调整。
Sentinel有哪些优点
丰富的适用场景:哨兵在阿里巴巴得到了广泛的应用,几乎覆盖了近10年双11(11.11)购物节的所有核心场景,比如需要限制突发流量的“秒杀”满足系统能力、消息削峰填谷、不依靠业务断路、流量控制等。
实时监控:Sentinel 还提供实时监控能力。可以实时查看单台机器的运行时信息,以及以下 500 个节点的集群运行时信息。
广泛的开源生态:Sentinel 提供与 Spring、Dubbo 和 gRPC 等常用框架和库的开箱即用集成。
多语言支持:Sentinel 为 Java、Go和C++提供了本机支持。
丰富的 SPI 扩展:Sentinel 提供简单易用的 SPI 扩展接口,可以让您快速自定义逻辑,例如自定义规则管理、适配数据源等。
Sentinel有哪几种流控模式
直接(默认):api达到限流条件,直接限流
关联:当关联的资料达到阈值时,就限流自己。当与A关联的资源B达到阈值后,就限流A
链路:链路流控模式指的是,当从某个接口过来的资源达到限流条件时,开启限流;它的功能有点类似于针对 来源配置项,区别在于:针对来源是针对上级微服务,而链路流控是针对上级接口,也就是说它的粒度更细。
Sentinel有哪几种流控效果呢
直接(默认的流控处理):该方式是默认的流量控制方式,当QPS超过任意规则的阈值后,新的请求就会被立即拒绝,拒绝方式为抛出FlowException
预热(Warm Up):阈值除以coldFactor(默认为3),经过预热时长后才达到阈值,案例,阀值为10+预热时长设置5秒。系统初始化的阀值为10 3 约等于3,即阀值刚开始为3;然后过了5秒后阀值才慢慢升高恢复到10
排队等待:匀速器(
RuleConstant.CONTROL_BEHAVIOR_RATE_LIMITER
)方式。这种方式严格控制了请求通过的间隔时间,也即是让请求以均匀的速度通过,对应的是漏桶算法
Sentinel 有哪些降级规则(熔断策略)
慢调用比例 (SLOW_REQUEST_RATIO):选择以慢调用比例作为阈值,需要设置允许的慢调用 RT(即最大的响应时间),请求的响应时间大于该值则统计为慢调用。当单位统计时长(statIntervalMs)内请求数目大于设置的最小请求数目,并且慢调用的比例大于阈值,则接下来的熔断时长内请求会自动被熔断。经过熔断时长后熔断器会进入探测恢复状态(HALF-OPEN 状态),若接下来的一个请求响应时间小于设置的慢调用 RT 则结束熔断,若大于设置的慢调用 RT 则会再次被熔断。
异常比例 (ERROR_RATIO):当单位统计时长(statIntervalMs)内请求数目大于设置的最小请求数目,并且异常的比例大于阈值,则接下来的熔断时长内请求会自动被熔断。经过熔断时长后熔断器会进入探测恢复状态(HALF-OPEN 状态),若接下来的一个请求成功完成(没有错误)则结束熔断,否则会再次被熔断。异常比率的阈值范围是 [0.0, 1.0],代表 0% - 100%。
异常数 (ERROR_COUNT):当单位统计时长内的异常数目超过阈值之后会自动进行熔断。经过熔断时长后熔断器会进入探测恢复状态(HALF-OPEN 状态),若接下来的一个请求成功完成(没有错误)则结束熔断,否则会再次被熔断。
分布式事务存在的问题
单体应用被拆分成微服务应用,原来的三个模块被拆分成三个独立的应用,分别使用三个独立的数据源,业务操作需要调用三个服务来完成。此时每个服务内部的数据一致性由本地事务来保证,但是全局的数据一致性问题没法保证。
什么是Spring Cloud Seata
Seata 是一款开源的分布式事务解决方案,致力于提供高性能和简单易用的分布式事务服务。Seata 将为用户提供了 AT、TCC、SAGA 和 XA 事务模式,为用户打造一站式的分布式解决方案。
分布式事务的处理过程是怎样的
分布式事务处理过程的唯一ID+三组件模型:Transaction ID XID(全局唯一的事务ID)
三组件概念
- TC (Transaction Coordinator) - 事务协调者:维护全局和分支事务的状态,驱动全局事务提交或回滚。
- TM (Transaction Manager) - 事务管理器:定义全局事务的范围:开始全局事务、提交或回滚全局事务。
- RM (Resource Manager) - 资源管理器:管理分支事务处理的资源,与TC交谈以注册分支事务和报告分支事务的状态,并驱动分支事务提交或回滚。
处理过程
- TM 向 TC 申请开启一个全局事务,全局事务创建成功并生成一个全局唯一的 XID;
- XID 在微服务调用链路的上下文中传播;
- RM 向 TC 注册分支事务,将其纳入 XID 对应全局事务的管辖;
- TM 向 TC 发起针对 XID 的全局提交或回滚决议;
- TC 调度 XID 下管辖的全部分支事务完成提交或回滚请求。
Seata分布式事务框架实现原理
Seata有三个组成部分
事务协调器TC:协调者
事务管理器TM:发起方
资源管理器RM:参与方
- 发起方会向协调者申请一个全局事务id,并保存到ThreadLocal中(为什么要保存到ThreadLocal中?弱引用,线程之间不会发生数据冲突;
- Seata数据源代理发起方和参与方的数据源,将前置镜像和后置镜像写入到undo_log表中,方便后期回滚使用;
- 发起方获取全局事务id,通过改写Feign客户端请求头传入全局事务id;
- 参与方从请求头中获取全局事务id保存到ThreadLocal中,并把该分支注册到SeataServer中;
- 如果没有出现异常,发起方会通知协调者,协调者通知所有分支,通过全局事务id和本地事务id删除undo_log数据,如果出现异常,通过undo_log逆向生成sql语句并执行,然后删除undo_log语句。如果处理业务逻辑代码超时,也会回滚。
9. Doubbo篇
Dubbo是什么?
Dubbo是阿里巴巴开源的基于 Java 的高性能 RPC 分布式服务框架,现已成为 Apache 基金会孵化项目。
其核心部分包含:
- 集群容错:提供基于接口方法的透明远程过程调用,包括多协议支持,以及软负载均衡,失败容错,地址路由,动态配置等集群支持。
- 远程通讯:提供对多种基于长连接的NIO框架抽象封装,包括多种线程模型,序列化,以及“请求-响应”模式的信息交换方式。
- 自动发现:基于注册中心目录服务,使服务消费方能动态的查找服务提供方,使地址透明,使服务提供方可以平滑增加或减少机器。
说说一次 Dubbo 服务请求流程
基本工作流程
核心组件
节点 | 角色 |
---|---|
Provider | 暴露服务的服务提供方 |
Consumer | 调用远程服务的服务消费方 |
Registry | 服务注册与发现的注册中心 |
Monitor | 统计服务的调用次数和调用时间的监控中心 |
Container | 服务运行容器 |
说说 Dubbo 工作原理
工作原理分 10 层:
- 第一层:service 层,接口层,给服务提供者和消费者来实现的(留给开发人员来实现);
- 第二层:config 层,配置层,主要是对 Dubbo 进行各种配置的,Dubbo 相关配置;
- 第三层:proxy 层,服务代理层,透明生成客户端的 stub 和服务单的 skeleton,调用的是接口,实现类没有,所以得生成代理,代理之间再进行网络通讯、负责均衡等;
- 第四层:registry 层,服务注册层,负责服务的注册与发现;
- 第五层:cluster 层,集群层,封装多个服务提供者的路由以及负载均衡,将多个实例组合成一个服务;
- 第六层:monitor 层,监控层,对 rpc 接口的调用次数和调用时间进行监控;
- 第七层:protocol 层,远程调用层,封装 rpc 调用;
- 第八层:exchange 层,信息交换层,封装请求响应模式,同步转异步;
- 第九层:transport 层,网络传输层,抽象 mina 和 netty 为统一接口;
- 第十层:serialize 层,数据序列化层。
这是个很坑爹的面试题,但是很多面试官又喜欢问,你真的要背么?你能背那还是不错的,我建议不要背,你就想想 Dubbo 服务调用过程中应该会涉及到哪些技术,把这些技术串起来就 OK 了。
Dubbo和 Spring Cloud 有什么区别?
最大的区别:
- Dubbo底层是使用Netty这样的NIO框架,是基于TCP协议传输的,配合以Hession序列化完成RPC通信;
- 而SpringCloud是基于Http协议+rest接口调用远程过程的通信,相对来说,Http请求会有更大的报文,占的带宽也会更多。但是REST相比RPC更为灵活,服务提供方和调用方的依赖只依靠一纸契约,不存在代码级别的强依赖,这在强调快速演化的微服务环境下,显得更为合适,至于注重通信速度还是方便灵活性,具体情况具体考虑。
模块区别:
Dubbo主要分为服务注册中心,服务提供者,服务消费者,还有管控中心;
相比起Dubbo简单的四个模块,SpringCloud则是一个完整的分布式一站式框架,他有着一样的服务注册中心,服务提供者,服务消费者,管控台,断路器,分布式配置服务,消息总线,以及服务追踪等。
通信方式:
Dubbo 使用的是 RPC 通信;Spring Cloud 使用的是 HTTP RestFul 方式。
注册中心:
Dubbo 使用 ZooKeeper(官方推荐),还有 Redis、Multicast、Simple 注册中心,但不推荐。Spring Cloud 使用的是 Spring Cloud Netflix Eureka。
监控:
Dubbo 使用的是 Dubbo-monitor;Spring Cloud 使用的是 Spring Boot admin。
断路器:
Dubbo 在断路器这方面还不完善,Spring Cloud 使用的是 Spring Cloud Netflix Hystrix。
分布式配置、网关服务、服务跟踪、消息总线、批量任务等。
Dubbo 目前可以说还是空白,而 Spring Cloud 都有相应的组件来支撑。
注册中心挂了,consumer 还能不能调用 provider
可以。因为刚开始初始化的时候,consumer 会将需要的所有提供者的地址等信息拉取到本地缓存,所以注册中心挂了可以继续通信。但是 provider 挂了,那就没法调用了。
怎么实现动态感知服务下线的呢
服务订阅通常有 pull 和 push 两种方式:
- pull 模式需要客户端定时向注册中心拉取配置;
- push 模式采用注册中心主动推送数据给客户端。
Dubbo ZooKeeper 注册中心采用是事件通知与客户端拉取方式。服务第一次订阅的时候将会拉取对应目录下全量数据,然后在订阅的节点注册一个 watcher。一旦目录节点下发生任何数据变化,ZooKeeper 将会通过 watcher 通知客户端。客户端接到通知,将会重新拉取该目录下全量数据,并重新注册 watcher。利用这个模式,Dubbo 服务就可以做到服务的动态发现。
注意: ZooKeeper 提供了“心跳检测”功能,它会定时向各个服务提供者发送一个请求(实际上建立的是一个 socket 长连接),如果长期没有响应,服务中心就认为该服务提供者已经“挂了”,并将其剔除。
Zookeeper 和 Dubbo 的关系
Zookeeper的作用
zookeeper用来注册服务和进行负载均衡,哪一个服务由哪一个机器来提供必需让调用者知道,简单来说就是ip地址和服务名称的对应关系。当然也可以通过硬编码的方式把这种对应关系在调用方业务代码中实现,但是如果提供服务的机器挂掉调用者无法知晓,如果不更改代码会继续请求挂掉的机器提供服务。
zookeeper通过心跳机制可以检测挂掉的机器并将挂掉机器的ip和服务对应关系从列表中删除。至于支持高并发,简单来说就是横向扩展,在不更改代码的情况通过添加机器来提高运算能力。通过添加新的机器向zookeeper注册服务,服务的提供者多了能服务的客户就多了。
dubbo
是管理中间层的工具,在业务层到数据仓库间有非常多服务的接入和服务提供者需要调度,dubbo提供一个框架解决这个问题。 注意这里的dubbo只是一个框架,至于你架子上放什么是完全取决于你的,就像一个汽车骨架,你需要配你的轮子引擎。这个框架中要完成调度必须要有一个分布式的注册中心,储存所有服务的元数据,你可以用zk,也可以用别的,只是大家都用zk。
zookeeper和dubbo的关系
Dubbo 的将注册中心进行抽象,它可以外接不同的存储媒介给注册中心提供服务,有ZooKeeper,Memcached,Redis 等。
引入了 ZooKeeper 作为存储媒介,也就把 ZooKeeper 的特性引进来。首先是负载均衡,单注册中心的承载能力是有限的,在流量达到一定程度的时 候就需要分流,负载均衡就是为了分流而存在的,一个 ZooKeeper 群配合相应的 Web 应用就可以很容易达到负载均衡;资源同步,单单有负载均衡还不 够,节点之间的数据和资源需要同步,ZooKeeper 集群就天然具备有这样的功能;命名服务,将树状结构用于维护全局的服务地址列表,服务提供者在启动 的时候,向 ZooKeeper 上的指定节点 /dubbo/${serviceName}/providers 目录下写入自己的 URL 地址,这个操作就完成了服务的发布。 其他特性还有 Mast 选举,分布式锁等。
Dubbo都支持什么协议,推荐用哪种?
- Dubbo协议:Dubbo默认使用Dubbo协议。
- 适合大并发小数据量的服务调用,以及服务消费者远大于提供者的情况
- 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协议
Dubbo服务器注册与发现的流程?
- 服务容器Container负责启动,加载,运行服务提供者。
- 服务提供者Provider在启动时,向注册中心注册自己提供的服务。
- 服务消费者Consumer在启动时,向注册中心订阅自己所需的服务。
- 注册中心Registry返回服务提供者地址列表给消费者,如果有变更,注册中心将基于长连接推送变更数据给消费者。
- 服务消费者Consumer,从提供者地址列表中,基于软负载均衡算法,选一台提供者进行调用,如果调用失败,再选另一台调用。
- 服务消费者Consumer和提供者Provider,在内存中累计调用次数和调用时间,定时每分钟发送一次统计数据到监控中心Monitor。
Dubbo内置了哪几种服务容器?
三种服务容器:
- Spring Container
- Jetty Container
- Log4j Container
Dubbo的服务容器只是一个简单的 Main 方法,并加载一个简单的 Spring 容器,用于暴露服务。
Dubbo负载均衡的作用?
将负载均衡功能实现在rpc客户端侧,以便能够随时适应外部的环境变化,更好地发挥硬件作用。而且客户端的负载均衡天然地就避免了单点问题。定制化的自有定制化的优势和劣势。
它可以从配置文件中指定,也可以在管理后台进行配置修改。
事实上,它支持 服务端服务/方法级别、客户端服务/方法级别 的负载均衡配置。
Dubbo有哪几种负载均衡策略,默认是哪种?
Dubbo提供了4种负载均衡实现:
- RandomLoadBalance:随机负载均衡。随机的选择一个。是Dubbo的默认负载均衡策略。
- RoundRobinLoadBalance:轮询负载均衡。轮询选择一个。
- LeastActiveLoadBalance:最少活跃调用数,相同活跃数的随机。活跃数指调用前后计数差。使慢的 Provider 收到更少请求,因为越慢的 Provider 的调用前后计数差会越大。
- ConsistentHashLoadBalance:一致性哈希负载均衡。相同参数的请求总是落在同一台机器上。
Dubbo服务之间的调用是阻塞的吗?
默认是同步等待结果阻塞的,支持异步调用。
Dubbo是基于 NIO 的非阻塞实现并行调用,客户端不需要启动多线程即可完成并行调用多个远程服务,相对多线程开销较小,异步调用会返回一个 Future 对象。
DubboMonitor 实现原理?
Consumer 端在发起调用之前会先走 filter 链;provider 端在接收到请求时也是先走 filter 链,然后才进行真正的业务逻辑处理。默认情况下,在 consumer 和 provider 的 filter 链中都会有 Monitorfilter。
MonitorFilter
向 DubboMonitor 发送数据DubboMonitor
将数据进行聚合后(默认聚合 1min 中的统计数据)暂存到ConcurrentMap<Statistics, AtomicReference> statisticsMap
,然后使用一个含有 3 个线程(线程名字:DubboMonitorSendTimer)的线程池每隔 1min 钟,调用 SimpleMonitorService 遍历发送 statisticsMap 中的统计数据,每发送完毕一个,就重置当前的 Statistics 的 AtomicReferenceSimpleMonitorService
将这些聚合数据塞入 BlockingQueue queue 中(队列大写为 100000)SimpleMonitorService
使用一个后台线程(线程名为:DubboMonitorAsyncWriteLogThread)将 queue 中的数据写入文件(该线程以死循环的形式来写)SimpleMonitorService
还会使用一个含有 1 个线程(线程名字:DubboMonitorTimer)的线程池每隔 5min 钟,将文件中的统计数据画成图表
Dubbo有哪些注册中心?
- Multicast注册中心:Multicast 注册中心不需要任何中心节点,只要广播地址,就能进行服务注册和发现,基于网络中组播传输实现。
- Zookeeper注册中心:基于分布式协调系统 Zookeeper 实现,采用 Zookeeper 的 watch 机制实现数据变更。
- Redis注册中心:基于 Redis 实现,采用 key/map 存储,key 存储服务名和类型,map 中 key 存储服务 url,value 服务过期时间。基于 Redis 的发布/订阅模式通知数据变更。
- Simple 注册中心。
推荐使用 Zookeeper 作为注册中心。
Dubbo的集群容错方案有哪些?
- Failover Cluster:失败自动切换,当出现失败,重试其它服务器。通常用于读操作,但重试会带来更长延迟。
- Failfast Cluster:快速失败,只发起一次调用,失败立即报错。通常用于非幂等性的写操作,比如新增记录。
- Failsafe Cluster:失败安全,出现异常时,直接忽略。通常用于写入审计日志等操作。
- Failback Cluster:失败自动恢复,后台记录失败请求,定时重发。通常用于消息通知操作。
- Forking Cluster:并行调用多个服务器,只要一个成功即返回。通常用于实时性要求较高的读操作,但需要浪费更多服务资源。可通过 forks=”2″ 来设置最大并行数。
- Broadcast Cluster:广播调用所有提供者,逐个调用,任意一台报错则报错 。通常用于通知所有提供者更新缓存或日志等本地资源信息。
Dubbo超时设置有哪些方式?
Dubbo超时设置有两种方式:
- 服务提供者端设置超时时间,在Dubbo的用户文档中,推荐如果能在服务端多配置就尽量多配置,因为服务提供者比消费者更清楚自己提供的服务特性。
- 服务消费者端设置超时时间,如果在消费者端设置了超时时间,以消费者端为主,即优先级更高。因为服务调用方设置超时时间控制性更灵活。如果消费方超时,服务端线程不会定制,会产生警告。
Dubbo用到哪些设计模式?
- 工厂模式
Provider 在 export 服务时,会调用 ServiceConfig 的 export 方法。ServiceConfig中有个字段:
private static final Protocol protocol =
ExtensionLoader.getExtensionLoader(Protocol.class).getAdaptiveExtension();
Dubbo里有很多这种代码。这也是一种工厂模式,只是实现类的获取采用了 JDKSPI 的机制。这么实现的优点是可扩展性强,想要扩展实现,只需要在 classpath下增加个文件就可以了,代码零侵入。另外,像上面的 Adaptive 实现,可以做到调用时动态决定调用哪个实现,但是由于这种实现采用了动态代理,会造成代码调试比较麻烦,需要分析出实际调用的实现类。
- 装饰器模式
Dubbo在启动和调用阶段都大量使用了装饰器模式。以 Provider 提供的调用链为例,具体的调用链代码是在 ProtocolFilterWrapper 的 buildInvokerChain 完成的,具体是将注解中含有 group=provider 的 Filter 实现,按照 order 排序,最后的调用顺序是:
EchoFilter -> ClassLoaderFilter -> GenericFilter -> ContextFilter ->ExecuteLimitFilter -> TraceFilter -> TimeoutFilter -> MonitorFilter -> ExceptionFilter
更确切地说,这里是装饰器和责任链模式的混合使用。例如,EchoFilter 的作用是判断是否是回声测试请求,是的话直接返回内容,这是一种责任链的体现。而像ClassLoaderFilter 则只是在主功能上添加了功能,更改当前线程的 ClassLoader,这是典型的装饰器模式。
- 观察者模式
Dubbo的 Provider 启动时,需要与注册中心交互,先注册自己的服务,再订阅自己的服务,订阅时,采用了观察者模式,开启一个 listener。注册中心会每 5 秒定时检查是否有服务更新,如果有更新,向该服务的提供者发送一个 notify 消息,provider 接受到 notify 消息后,运行 NotifyListener 的 notify 方法,执行监听器方法。
- 动态代理模式
Dubbo扩展 JDK SPI 的类 ExtensionLoader 的 Adaptive 实现是典型的动态代理实现。Dubbo需要灵活地控制实现类,即在调用阶段动态地根据参数决定调用哪个实现类,所以采用先生成代理类的方法,能够做到灵活的调用。生成代理类的代码是 ExtensionLoader 的 createAdaptiveExtensionClassCode 方法。代理类主要逻辑是,获取 URL 参数中指定参数的值作为获取实现类的 key。
10. Nginx篇
Nginx系列文章:Nginx
简述一下什么是Nginx,它有什么优势和功能
Nginx是一个web服务器和方向代理服务器,用于HTTP、HTTPS、SMTP、POP3和IMAP协议。因它的稳定性、丰富的功能集、示例配置文件和低系统资源的消耗而闻名。
- 更快 ,这表现在两个方面:一方面,在正常情况下,单次请求会得到更快的响应;另一方面,在高峰期(如有数以万计的并发请求),Nginx可以比其他Web服务器更快地响应请求。
- 高扩展性,跨平台 Nginx的设计极具扩展性,它完全是由多个不同功能、不同层次、不同类型且耦合度极低的模块组成。因此,当对某一个模块修复Bug或进行升级时,可以专注于模块自身,无须在意其他。而且在HTTP模块中,还设计了HTTP过滤器模块:一个正常的HTTP模块在处理完请求后,会有一串HTTP过滤器模块对请求的结果进行再处理。这样,当我们开发一个新的HTTP模块时,不但可以使用诸如HTTP核心模块、events模块、log模块等不同层次或者不同类型的模块,还可以原封不动地复用大量已有的HTTP过滤器模块。这种低耦合度的优秀设计,造就了Nginx庞大的第三方模块,当然,公开的第三方模块也如官方发布的模块一样容易使用。 Nginx的模块都是嵌入到二进制文件中执行的,无论官方发布的模块还是第三方模块都是如此。这使得第三方模块一样具备极其优秀的性能,充分利用Nginx的高并发特性,因此,许多高流量的网站都倾向于开发符合自己业务特性的定制模块。
- 高可靠性:用于反向代理,宕机的概率微乎其微 高可靠性是我们选择Nginx的最基本条件,因为Nginx的可靠性是大家有目共睹的,很多家高流量网站都在核心服务器上大规模使用Nginx。Nginx的高可靠性来自于其核心框架代码的优秀设计、模块设计的简单性;另外,官方提供的常用模块都非常稳定,每个worker进程相对独立,master进程在1个worker进程出错时可以快速“拉 起”新的worker子进程提供服务。
- 低内存消耗 一般情况下,10 000个非活跃的HTTP Keep-Alive连接在Nginx中仅消耗2.5MB的内存,这是Nginx支持高并发连接的基础。
- 单机支持10万以上的并发连接 这是一个非常重要的特性!随着互联网的迅猛发展和互联网用户数量的成倍增长,各大公司、网站都需要应付海量并发请求,一个能够在峰值期顶住10万以上并发请求的Server,无疑会得到大家的青睐。理论上,Nginx支持的并发连接上限取决于内存,10万远未封顶。当然,能够及时地处理更多的并发请求,是与业务特点紧密相关的。
- 热部署 master管理进程与worker工作进程的分离设计,使得Nginx能够提供热部署功能,即可以在7×24小时不间断服务的前提下,升级Nginx的可执行文件。当然,它也支持不停止服务就更新配置项、更换日志文件等功能。
- 最自由的BSD许可协议 这是Nginx可以快速发展的强大动力。BSD许可协议不只是允许用户免费使用Nginx,它还允许用户在自己的项目中直接使用或修改Nginx源码,然后发布。这吸引了无数开发者继续为Nginx贡献自己的智慧。 以上7个特点当然不是Nginx的全部,拥有无数个官方功能模块、第三方功能模块使得Nginx能够满足绝大部分应用场景,这些功能模块间可以叠加以实现更加强大、复杂的功能,有些模块还支持Nginx与Perl、Lua等脚本语言集成工作,大大提高了开发效率。这些特点促使用户在寻找一个Web服务器时更多考虑Nginx。 选择Nginx的核心理由还是它能在支持高并发请求的同时保持高效的服务。
Nginx是如何处理一个HTTP请求的
Nginx 是一个高性能的 Web 服务器,能够同时处理大量的并发请求。它结合多进程机制和异步机制,异步机制使用的是异步非阻塞方式 ,接下来就给大家介绍一下 Nginx 的多线程机制和异步非阻塞机制。
多进程机制
服务器每当收到一个客户端时,就有 服务器主进程 ( master process )生成一个 子进程(worker process )出来和客户端建立连接进行交互,直到连接断开,该子进程就结束了。
使用进程的好处是各个进程之间相互独立,不需要加锁,减少了使用锁对性能造成影响,同时降低编程的复杂度,降低开发成本。其次,采用独立的进程,可以让进程互相之间不会影响 ,如果一个进程发生异常退出时,其它进程正常工作, master 进程则很快启动新的 worker 进程,确保服务不会中断,从而将风险降到最低。
缺点是操作系统生成一个子进程需要进行 内存复制等操作,在资源和时间上会产生一定的开销。当有大量请求时,会导致系统性能下降。
异步非阻塞机制
每个工作进程 使用 异步非阻塞方式 ,可以处理多个客户端请求 。
当某个工作进程 接收到客户端的请求以后,调用 IO 进行处理,如果不能立即得到结果,就去 处理其他请求 (即为 非阻塞 );而 客户端 在此期间也 无需等待响应 ,可以去处理其他事情(即为 异 步 )。
当 IO 返回时,就会通知此 工作进程 ;该进程得到通知,暂时 挂起 当前处理的事务去 响应客户端请求。
列举一些Nginx的特性
Nginx服务器的特性包括:
- 反向代理/L7负载均衡器
- 嵌入式Perl解释器
- 动态二进制升级
- 可用于重新编写URL,具有非常好的PCRE支持
请解释Nginx服务器上的Master和Worker进程分别是什么
- 主程序 Master process 启动后,通过一个 for 循环来 接收 和 处理外部信号;
- 主进程通过 fork() 函数产生 worker 子进程 ,每个子进程执行一个 for循环来实现Nginx服务器对事件的接收和处理。
请解释代理中的正向代理和反向代理
首先,代理服务器一般指局域网内部的机器通过代理服务器发送请求到互联网上的服务器,代理服务器一般作用在客户端。例如:GoAgent翻墙软件。我们的客户端在进行翻墙操作的时候,我们使用的正是正向代理,通过正向代理的方式,在我们的客户端运行一个软件,将我们的HTTP请求转发到其他不同的服务器端,实现请求的分发。
反向代理服务器作用在服务器端,它在服务器端接收客户端的请求,然后将请求分发给具体的服务器进行处理,然后再将服务器的相应结果反馈给客户端。Nginx就是一个反向代理服务器软件。
从上图可以看出:客户端必须设置正向代理服务器,当然前提是要知道正向代理服务器的IP地址,还有代理程序的端口。 反向代理正好与正向代理相反,对于客户端而言代理服务器就像是原始服务器,并且客户端不需要进行任何特别的设置。客户端向反向代理的命名空间(name-space)中的内容发送普通请求,接着反向代理将判断向何处(原始服务器)转交请求,并将获得的内容返回给客户端。
11. MQ篇
Kafka系列文章:Kafka
为什么要使用MQ
使用MQ的场景很多,主要有三个:解耦,异步,削峰
- 解耦
场景:A 系统发送数据到 BCD 三个系统,通过接口调用发送。如果 E 系统也要这个数据呢?那如果 C 系统现在不需要了呢?A 系统负责人几乎崩溃……
在这个场景中,A 系统跟其它各种乱七八糟的系统严重耦合,A 系统产生一条比较关键的数据,很多系统都需要 A 系统将这个数据发送过来。A 系统要时时刻刻考虑 BCDE 四个系统如果挂了该咋办?要不要重发,要不要把消息存起来?头发都白了啊!
如果使用 MQ,A 系统产生一条数据,发送到 MQ 里面去,哪个系统需要数据自己去 MQ 里面消费。如果新系统需要数据,直接从 MQ 里消费即可;如果某个系统不需要这条数据了,就取消对 MQ 消息的消费即可。这样下来,A 系统压根儿不需要去考虑要给谁发送数据,不需要维护这个代码,也不需要考虑人家是否调用成功、失败超时等情况。
总结:通过一个 MQ,Pub/Sub 发布订阅消息这么一个模型,A 系统就跟其它系统彻底解耦了。
- 异步
场景:A 系统接收一个请求,需要在自己本地写库,还需要在 BCD 三个系统写库,自己本地写库要 3ms,BCD 三个系统分别写库要 300ms、450ms、200ms。最终请求总延时是 3 + 300 + 450 + 200 = 953ms,接近 1s,用户感觉搞个什么东西,慢死了慢死了。用户通过浏览器发起请求,等待个 1s,这几乎是不可接受的。
一般互联网类的企业,对于用户直接的操作,一般要求是每个请求都必须在 200 ms 以内完成,对用户几乎是无感知的。
如果使用 MQ,那么 A 系统连续发送 3 条消息到 MQ 队列中,假如耗时 5ms,A 系统从接受一个请求到返回响应给用户,总时长是 3 + 5 = 8ms,对于用户而言,其实感觉上就是点个按钮,8ms 以后就直接返回了。
- 削峰
场景:每天 0:00 到 12:00,A 系统风平浪静,每秒并发请求数量就 50 个。结果每次一到 12:00 ~ 13:00 ,每秒并发请求数量突然会暴增到 5k+ 条。但是系统是直接基于 MySQL 的,大量的请求涌入 MySQL,每秒钟对 MySQL 执行约 5k 条 SQL。
使用 MQ,每秒 5k 个请求写入 MQ,A 系统每秒钟最多处理 2k 个请求,因为 MySQL 每秒钟最多处理 2k 个。A 系统从 MQ 中慢慢拉取请求,每秒钟就拉取 2k 个请求,不要超过自己每秒能处理的最大请求数量就 ok,这样下来,哪怕是高峰期的时候,A 系统也绝对不会挂掉。而 MQ 每秒钟 5k 个请求进来,就 2k 个请求出去,结果就导致在中午高峰期(1 个小时),可能有几十万甚至几百万的请求积压在 MQ 中。
这个短暂的高峰期积压是 ok 的,因为高峰期过了之后,每秒钟就 50 个请求进 MQ,但是 A 系统依然会按照每秒 2k 个请求的速度在处理。所以说,只要高峰期一过,A 系统就会快速将积压的消息给解决掉。
MQ有什么优缺点
优点上面已经说了,就是在特殊场景下有其对应的好处,解耦、异步、削峰。
缺点有以下几个:
系统可用性降低: 系统引入的外部依赖越多,越容易挂掉。
系统复杂度提高: 加入了消息队列,要多考虑很多方面的问题,比如:一致性问题、如何保证消息不被重复消费、如何保证消息可靠性传输等。因此,需要考虑的东西更多,复杂性增大。
一致性问题: A 系统处理完了直接返回成功了,人都以为你这个请求就成功了;但是问题是,要是 BCD 三个系统那里,BD 两个系统写库成功了,结果 C 系统写库失败了,这就数据不一致了。
Kafka、ActiveMQ、RabbitMQ、RocketMQ 有什么优缺点
特性 | ActiveMQ | RabbitMQ | RocketMQ | Kafka |
---|---|---|---|---|
开发语言 | java | erlang | java | scala |
单机吞吐量 | 万级,比 RocketMQ、Kafka 低一个数量级 | 同 ActiveMQ | 10 万级,支撑高吞吐 | 10 万级,高吞吐,一般配合大数据类的系统来进行实时数据计算、日志采集等场景 |
topic 数量对吞吐量的影响 | topic 可以达到几百/几千的级别,吞吐量会有较小幅度的下降,这是 RocketMQ 的一大优势,在同等机器下,可以支撑大量的 topic | topic 从几十到几百个时候,吞吐量会大幅度下降,在同等机器下,Kafka 尽量保证 topic 数量不要过多,如果要支撑大规模的 topic,需要增加更多的机器资源 | ||
时效性 | ms 级 | 微秒级,这是 RabbitMQ 的一大特点,延迟最低 | ms 级 | 延迟在 ms 级以内 |
可用性 | 高,基于主从架构实现高可用 | 同 ActiveMQ | 非常高,分布式架构 | 非常高,分布式,一个数据多个副本,少数机器宕机,不会丢失数据,不会导致不可用 |
消息可靠性 | 有较低的概率丢失数据 | 基本不丢 | 经过参数优化配置,可以做到 0 丢失 | 同 RocketMQ |
功能支持 | MQ 领域的功能极其完备 | 基于 erlang 开发,并发能力很强,性能极好,延时很低 | MQ 功能较为完善,还是分布式的,扩展性好 | 功能较为简单,主要支持简单的 MQ 功能,在大数据领域的实时计算以及日志采集被大规模使用 |
社区活跃度 | 低 | 很高 | 一般 | 很高 |
- 中小型公司,技术实力较为一般,技术挑战不是特别高,用 RabbitMQ 是不错的选择;
- 大型公司,基础架构研发实力较强,用 RocketMQ 是很好的选择。
- 大数据领域的实时计算、日志采集等场景,用 Kafka 是业内标准的,几乎是全世界这个领域的事实性规范。
RabbitMQ是什么?
RabbitMQ是实现了高级消息队列协议(AMQP)的开源消息代理软件(亦称面向消息的中间件)。RabbitMQ服务器是用Erlang语言编写的,而群集和故障转移是构建在开放电信平台框架上的。所有主要的编程语言均有与代理接口通讯的客户端库。
RabbitMQ特点?
可靠性: RabbitMQ使用一些机制来保证可靠性, 如持久化、传输确认及发布确认等。
灵活的路由: 在消息进入队列之前,通过交换器来路由消息。对于典型的路由功能, RabbitMQ 己经提供了一些内置的交换器来实现。针对更复杂的路由功能,可以将多个 交换器绑定在一起, 也可以通过插件机制来实现自己的交换器。
扩展性: 多个RabbitMQ节点可以组成一个集群,也可以根据实际业务情况动态地扩展 集群中节点。
高可用性: 队列可以在集群中的机器上设置镜像,使得在部分节点出现问题的情况下队 列仍然可用。
多种协议: RabbitMQ除了原生支持AMQP协议,还支持STOMP, MQTT等多种消息 中间件协议。
多语言客户端: RabbitMQ 几乎支持所有常用语言,比如 Java、 Python、 Ruby、 PHP、 C#、 JavaScript 等。
管理界面: RabbitMQ 提供了一个易用的用户界面,使得用户可以监控和管理消息、集 群中的节点等。
令插件机制: RabbitMQ 提供了许多插件 , 以实现从多方面进行扩展,当然也可以编写自 己的插件。
AMQP是什么?
RabbitMQ就是 AMQP 协议的 Erlang
的实现(当然 RabbitMQ 还支持 STOMP2
、 MQTT3
等协议 ) AMQP 的模型架构 和 RabbitMQ 的模型架构是一样的,生产者将消息发送给交换器,交换器和队列绑定 。
RabbitMQ 中的交换器、交换器类型、队列、绑定、路由键等都是遵循的 AMQP 协议中相 应的概念。目前 RabbitMQ 最新版本默认支持的是 AMQP 0-9-1。
AMQP的3层协议?
Module Layer: 协议最高层,主要定义了一些客户端调用的命令,客户端可以用这些命令实现自己的业务逻辑。
Session Layer: 中间层,主要负责客户端命令发送给服务器,再将服务端应答返回客户端,提供可靠性同步机制和错误处理。
TransportLayer: 最底层,主要传输二进制数据流,提供帧的处理、信道服用、错误检测和数据表示等。
说说Broker服务节点、Queue队列、Exchange交换器?
- Broker:可以看做RabbitMQ的服务节点。一般请下一个Broker可以看做一个RabbitMQ服务器。
- Queue:RabbitMQ的内部对象,用于存储消息。多个消费者可以订阅同一队列,这时队列中的消息会被平摊(轮询)给多个消费者进行处理。
- Exchange:生产者将消息发送到交换器,由交换器将消息路由到一个或者多个队列中。当路由不到时,或返回给生产者或直接丢弃。
如何保证消息的可靠性?
- 生产者到RabbitMQ:事务机制和Confirm机制,注意:事务机制和 Confirm 机制是互斥的,两者不能共存,会导致 RabbitMQ 报错。
- RabbitMQ自身:持久化、集群、普通模式、镜像模式。
- RabbitMQ到消费者:basicAck机制、死信队列、消息补偿机制。
生产者消息运转的流程?
Producer
先连接到Broker,建立连接Connection,开启一个信道(Channel)。Producer
声明一个交换器并设置好相关属性。Producer
声明一个队列并设置好相关属性。Producer
通过路由键将交换器和队列绑定起来。Producer
发送消息到Broker
,其中包含路由键、交换器等信息。相应的交换器根据接收到的路由键查找匹配的队列。
如果找到,将消息存入对应的队列,如果没有找到,会根据生产者的配置丢弃或者退回给生产者。
关闭信道。
管理连接。
消费者接收消息过程?
Producer
先连接到Broker
,建立连接Connection
,开启一个信道(Channel
)。向
Broker
请求消费响应的队列中消息,可能会设置响应的回调函数。等待
Broker
回应并投递相应队列中的消息,接收消息。消费者确认收到的消息,
ack
。RabbitMq
从队列中删除已经确定的消息。关闭信道。
关闭连接。
生产者如何将消息可靠投递到RabbitMQ?
Client发送消息给MQ
MQ将消息持久化后,发送Ack消息给Client,此处有可能因为网络问题导致Ack消息无法发送到Client,那么Client在等待超时后,会重传消息;
Client收到Ack消息后,认为消息已经投递成功。
RabbitMQ如何将消息可靠投递到消费者?
MQ将消息push给Client(或Client来pull消息)
Client得到消息并做完业务逻辑
Client发送Ack消息给MQ,通知MQ删除该消息,此处有可能因为网络问题导致Ack失败,那么Client会重复消息,这里就引出消费幂等的问题;
MQ将已消费的消息删除。
如何保证RabbitMQ消息队列的高可用?
RabbitMQ 有三种模式:单机模式
,普通集群模式
,镜像集群模式
。
单机模式:就是demo级别的,一般就是你本地启动了玩玩儿的,没人生产用单机模式
普通集群模式:意思就是在多台机器上启动多个RabbitMQ实例,每个机器启动一个。
镜像集群模式:这种模式,才是所谓的RabbitMQ的高可用模式,跟普通集群模式不一样的是,你创建的queue,无论元数据(元数据指RabbitMQ的配置数据)还是queue里的消息都会存在于多个实例上,然后每次你写消息到queue的时候,都会自动把消息到多个实例的queue里进行消息同步。
RabbitMQ中Exchange的分发策略
Direct 键(routing key)分布:消息中的路由键(routing key)如果和 Binding 中的 binding key 一致,交换器就将消息发到对应的队列中。它是完全匹配、单播的模式。
Fanout(广播分发):每个发到 fanout 类型交换器的消息都会分到所有绑定的队列上去。很像子网广播,每台子网内的主机都获得了一份复制的消息。fanout 类型转发消息是最快的。
topic交换器:topic 交换器通过模式匹配分配消息的路由键属性,将路由键和某个模式进行匹配,此时队列需要绑定到一个模式上。它将路由键和绑定键的字符串切分成单词,这些单词之间用点隔开。它同样也会识别两个通配符:符号“#”和符号“”。#匹配 0 个或多个单词,匹配不多不少一个单词。
RocketMQ是什么?
RocketMQ 是阿里巴巴开源的分布式消息中间件。支持事务消息、顺序消息、批量消息、定时消息、消息回溯等。它里面有几个区别于标准消息中件间的概念,如Group、Topic、Queue等。系统组成则由Producer、Consumer、Broker、NameServer等。
RocketMQ 特点
- 是一个队列模型的消息中间件,具有高性能、高可靠、高实时、分布式等特点
- Producer、Consumer、队列都可以分布式
- Producer 向一些队列轮流发送消息,队列集合称为 Topic,Consumer 如果做广播消费,则一个 Consumer 实例消费这个 Topic 对应的所有队列,如果做集群消费,则多个 Consumer 实例平均消费这个 Topic 对应的队列集合
- 能够保证严格的消息顺序
- 支持拉(pull)和推(push)两种消息模式
- 高效的订阅者水平扩展能力
- 实时的消息订阅机制
- 亿级消息堆积能力
- 支持多种消息协议,如 JMS、OpenMessaging 等
- 较少的依赖
RocketMQ由哪些角色组成,每个角色作用和特点是什么?
角色 | 作用 |
---|---|
Nameserver | 无状态,动态列表;这也是和zookeeper的重要区别之一。zookeeper是有状态的。 |
Producer | 消息生产者,负责发消息到Broker。 |
Broker | 就是MQ本身,负责收发消息、持久化消息等。 |
Consumer | 消息消费者,负责从Broker上拉取消息进行消费,消费完进行ack。 |
RocketMQ消费模式有几种?
消费模型由Consumer决定,消费维度为Topic。
1、集群消费
一条消息只会被同Group中的一个Consumer消费
多个Group同时消费一个Topic时,每个Group都会有一个Consumer消费到数据
2、广播消费
消息将对一 个Consumer Group 下的各个 Consumer 实例都消费一遍。即即使这些 Consumer 属于同一个Consumer Group ,消息也会被 Consumer Group 中的每个 Consumer 都消费一次。
RocketMQ消费消息是push还是pull?
RocketMQ没有真正意义的push,都是pull,虽然有push类,但实际底层实现采用的是长轮询机制,即拉取方式
broker端属性 longPollingEnable 标记是否开启长轮询。默认开启
追问:为什么要主动拉取消息而不使用事件监听方式?
事件驱动方式是建立好长连接,由事件(发送数据)的方式来实时推送。
如果broker主动推送消息的话有可能push速度快,消费速度慢的情况,那么就会造成消息在consumer端堆积过多,同时又不能被其他consumer消费的情况。而pull的方式可以根据当前自身情况来pull,不会造成过多的压力而造成瓶颈。所以采取了pull的方式。
broker如何处理拉取请求的?
Consumer首次请求Broker
Broker中是否有符合条件的消息
有
- 响应Consumer
- 等待下次Consumer的请求
没有
- DefaultMessageStore#ReputMessageService#run方法
- PullRequestHoldService 来Hold连接,每个5s执行一次检查pullRequestTable有没有消息,有的话立即推送
- 每隔1ms检查commitLog中是否有新消息,有的话写入到pullRequestTable
- 当有新消息的时候返回请求
- 挂起consumer的请求,即不断开连接,也不返回数据
- 使用consumer的offset,
如何让RocketMQ保证消息的顺序消费?
首先多个queue只能保证单个queue里的顺序,queue是典型的FIFO,天然顺序。多个queue同时消费是无法绝对保证消息的有序性的。所以总结如下:
同一topic,同一个QUEUE,发消息的时候一个线程去发送消息,消费的时候 一个线程去消费一个queue里的消息。
RocketMQ如何保证消息不丢失?
首先在如下三个部分都可能会出现丢失消息的情况:
- Producer端
- Broker端
- Consumer端
- Producer端如何保证消息不丢失
采取send()同步发消息,发送结果是同步感知的。
发送失败后可以重试,设置重试次数。默认3次。
集群部署,比如发送失败了的原因可能是当前Broker宕机了,重试的时候会发送到其他Broker上。
- Broker端如何保证消息不丢失
修改刷盘策略为同步刷盘。默认情况下是异步刷盘的。
集群部署,主从模式,高可用。
- Consumer端如何保证消息不丢失
- 完全消费正常后在进行手动ack确认。
rocketMQ的消息堆积如何处理?
首先要找到是什么原因导致的消息堆积,是Producer太多了,Consumer太少了导致的还是说其他情况,总之先定位问题。
然后看下消息消费速度是否正常,正常的话,可以通过上线更多consumer临时解决消息堆积问题
追问:如果Consumer和Queue不对等,上线了多台也在短时间内无法消费完堆积的消息怎么办?
- 准备一个临时的topic
- queue的数量是堆积的几倍
- queue分布到多Broker中
- 上线一台Consumer做消息的搬运工,把原来Topic中的消息挪到新的Topic里,不做业务逻辑处理,只是挪过去
- 上线N台Consumer同时消费临时Topic中的数据
- 改bug
- 恢复原来的Consumer,继续消费之前的Topic
追问:堆积时间过长消息超时了?
RocketMQ中的消息只会在commitLog被删除的时候才会消失,不会超时。也就是说未被消费的消息不会存在超时删除这情况。
追问:堆积的消息会不会进死信队列?
不会,消息在消费失败后会进入重试队列(%RETRY%+ConsumerGroup),18次(默认18次,网上所有文章都说是16次,无一例外。但是我没搞懂为啥是16次,这不是18个时间吗 ?)才会进入死信队列(%DLQ%+ConsumerGroup)。
RocketMQ为什么自研nameserver而不用zk?
- RocketMQ只需要一个轻量级的维护元数据信息的组件,为此引入zk增加维护成本还强依赖另一个中间件了。
- RocketMQ追求的是AP,而不是CP,也就是需要高可用。
- zk是CP,因为zk节点间通过zap协议有数据共享,每个节点数据会一致,但是zk集群当挂了一半以上的节点就没法使用了。
- nameserver是AP,节点间不通信,这样会导致节点间数据信息会发生短暂的不一致,但每个broker都会定时向所有nameserver上报路由信息和心跳。当某个broker下线了,nameserver也会延时30s才知道,而且不会通知客户端(生产和消费者),只能靠客户端自己来拉,rocketMQ是靠消息重试机制解决这个问题的,所以是最终一致性。但nameserver集群只要有一个节点就可用。
Kafka 的设计是什么样的?
Kafka 将消息以 topic 为单位进行归纳
将向 Kafka topic 发布消息的程序成为 producers.
将预订 topics 并消费消息的程序成为 consumer.
Kafka 以集群的方式运行,可以由一个或多个服务组成,每个服务叫做一个 broker.
producers 通过网络将消息发送到 Kafka 集群,集群向消费者提供消息
Kafka 如何保证高可用?
Kafka
的基本架构组成是:由多个 broker
组成一个集群,每个 broker
是一个节点;当创建一个 topic
时,这个 topic
会被划分为多个 partition
,每个 partition
可以存在于不同的 broker
上,每个 partition
只存放一部分数据。
这就是天然的分布式消息队列,就是说一个 topic
的数据,是分散放在多个机器上的,每个机器就放一部分数据。
在 Kafka 0.8
版本之前,是没有 HA
机制的,当任何一个 broker
所在节点宕机了,这个 broker
上的 partition
就无法提供读写服务,所以这个版本之前,Kafka
没有什么高可用性可言。
在 Kafka 0.8
以后,提供了 HA
机制,就是 replica
副本机制。每个 partition
上的数据都会同步到其它机器,形成自己的多个 replica
副本。所有 replica
会选举一个 leader
出来,消息的生产者和消费者都跟这个 leader
打交道,其他 replica
作为 follower
。写的时候,leader
会负责把数据同步到所有 follower
上去,读的时候就直接读 leader
上的数据即可。Kafka
负责均匀的将一个 partition
的所有 replica
分布在不同的机器上,这样才可以提高容错性。
拥有了 replica
副本机制,如果某个 broker
宕机了,这个 broker
上的 partition
在其他机器上还存在副本。如果这个宕机的 broker
上面有某个 partition
的 leader
,那么此时会从其 follower
中重新选举一个新的 leader
出来,这个新的 leader
会继续提供读写服务,这就有达到了所谓的高可用性。
写数据的时候,生产者只将数据写入 leader
节点,leader
会将数据写入本地磁盘,接着其他 follower
会主动从 leader
来拉取数据,follower
同步好数据了,就会发送 ack
给 leader
,leader
收到所有 follower
的 ack
之后,就会返回写成功的消息给生产者。
消费数据的时候,消费者只会从 leader
节点去读取消息,但是只有当一个消息已经被所有 follower
都同步成功返回 ack
的时候,这个消息才会被消费者读到。
Kafka 消息是采用 Pull 模式,还是 Push 模式?
生产者使用push模式将消息发布到Broker,消费者使用pull模式从Broker订阅消息。
push模式很难适应消费速率不同的消费者,如果push的速度太快,容易造成消费者拒绝服务或网络拥塞;如果push的速度太慢,容易造成消费者性能浪费。但是采用pull的方式也有一个缺点,就是当Broker没有消息时,消费者会陷入不断地轮询中,为了避免这点,kafka有个参数可以让消费者阻塞知道是否有新消息到达。
Kafka 与传统消息系统之间的区别
Kafka 持久化日志,这些日志可以被重复读取和无限期保留
Kafka 是一个分布式系统:它以集群的方式运行,可以灵活伸缩,在内部通过复制数据提升容错能力和高可用性
Kafka 支持实时的流式处理
什么是消费者组?
消费者组是Kafka独有的概念,即消费者组是Kafka提供的可扩展且具有容错性的消费者机制。
但实际上,消费者组(Consumer Group)其实包含两个概念,作为队列,消费者组允许你分割数据处理到一组进程集合上(即一个消费者组中可以包含多个消费者进程,他们共同消费该topic的数据),这有助于你的消费能力的动态调整;作为发布-订阅模型(publish-subscribe),Kafka允许你将同一份消息广播到多个消费者组里,以此来丰富多种数据使用场景。
需要注意的是:在消费者组中,多个实例共同订阅若干个主题,实现共同消费。同一个组下的每个实例都配置有相同的组ID,被分配不同的订阅分区。当某个实例挂掉的时候,其他实例会自动地承担起它负责消费的分区。 因此,消费者组在一定程度上也保证了消费者程序的高可用性。
在Kafka中,ZooKeeper的作用是什么?
目前,Kafka使用ZooKeeper存放集群元数据、成员管理、Controller选举,以及其他一些管理类任务。之后,等KIP-500提案完成后,Kafka将完全不再依赖于ZooKeeper。
- “存放元数据”是指主题分区的所有数据都保存在 ZooKeeper 中,且以它保存的数据为权威,其他 “人” 都要与它保持对齐。
- “成员管理” 是指 Broker 节点的注册、注销以及属性变更,等等。
- “Controller 选举” 是指选举集群 Controller,而其他管理类任务包括但不限于主题删除、参数配置等。
KIP-500 思想,是使用社区自研的基于Raft的共识算法,替代ZooKeeper,实现Controller自选举。
解释下Kafka中位移(offset)的作用
在Kafka中,每个主题分区下的每条消息都被赋予了一个唯一的ID数值,用于标识它在分区中的位置。这个ID数值,就被称为位移,或者叫偏移量。一旦消息被写入到分区日志,它的位移值将不能被修改。
kafka 为什么那么快?
- Cache Filesystem Cache PageCache缓存
顺序写
:由于现代的操作系统提供了预读和写技术,磁盘的顺序写大多数情况下比随机写内存还要快。Zero-copy
:零拷技术减少拷贝次数Batching of Messages
:批量量处理。合并小的请求,然后以流的方式进行交互,直顶网络上限。Pull 拉模式
:使用拉模式进行消息的获取消费,与消费端处理能力相符。
kafka producer发送数据,ack为0,1,-1分别是什么意思?
1
(默认) 数据发送到Kafka后,经过leader成功接收消息的的确认,就算是发送成功了。在这种情况下,如果leader宕机了,则会丢失数据。0
生产者将数据发送出去就不管了,不去等待任何返回。这种情况下数据传输效率最高,但是数据可靠性确是最低的。-1
producer需要等待ISR中的所有follower都确认接收到数据后才算一次发送完成,可靠性最高。当ISR中所有Replica都向Leader发送ACK时,leader才commit,这时候producer才能认为一个请求中的消息都commit了。
Kafka如何保证消息不丢失?
首先需要弄明白消息为什么会丢失,对于一个消息队列,会有 生产者
、MQ
、消费者
这三个角色,在这三个角色数据处理和传输过程中,都有可能会出现消息丢失。
消息丢失的原因以及解决办法:
消费者异常导致的消息丢失
消费者可能导致数据丢失的情况是:消费者获取到了这条消息后,还未处理,Kafka
就自动提交了 offset
,这时 Kafka
就认为消费者已经处理完这条消息,其实消费者才刚准备处理这条消息,这时如果消费者宕机,那这条消息就丢失了。
消费者引起消息丢失的主要原因就是消息还未处理完 Kafka
会自动提交了 offset
,那么只要关闭自动提交 offset
,消费者在处理完之后手动提交 offset
,就可以保证消息不会丢失。但是此时需要注意重复消费问题,比如消费者刚处理完,还没提交 offset
,这时自己宕机了,此时这条消息肯定会被重复消费一次,这就需要消费者根据实际情况保证幂等性。
生产者数据传输导致的消息丢失
对于生产者数据传输导致的数据丢失主常见情况是生产者发送消息给 Kafka
,由于网络等原因导致消息丢失,对于这种情况也是通过在 producer 端设置 acks=all 来处理,这个参数是要求 leader
接收到消息后,需要等到所有的 follower
都同步到了消息之后,才认为本次写成功了。如果没满足这个条件,生产者会自动不断的重试。
Kafka 导致的消息丢失
Kafka
导致的数据丢失一个常见的场景就是 Kafka
某个 broker
宕机,,而这个节点正好是某个 partition
的 leader
节点,这时需要重新重新选举该 partition
的 leader
。如果该 partition
的 leader
在宕机时刚好还有些数据没有同步到 follower
,此时 leader
挂了,在选举某个 follower
成 leader
之后,就会丢失一部分数据。
对于这个问题,Kafka
可以设置如下 4 个参数,来尽量避免消息丢失:
- 给
topic
设置replication.factor
参数:这个值必须大于1
,要求每个partition
必须有至少2
个副本; - 在
Kafka
服务端设置min.insync.replicas
参数:这个值必须大于1
,这个参数的含义是一个leader
至少感知到有至少一个follower
还跟自己保持联系,没掉队,这样才能确保leader
挂了还有一个follower
节点。 - 在
producer
端设置acks=all
,这个是要求每条数据,必须是写入所有replica
之后,才能认为是写成功了; - 在
producer
端设置retries=MAX
(很大很大很大的一个值,无限次重试的意思):这个参数的含义是一旦写入失败,就无限重试,卡在这里了。
Kafka 如何保证消息的顺序性
在某些业务场景下,我们需要保证对于有逻辑关联的多条MQ消息被按顺序处理,比如对于某一条数据,正常处理顺序是新增-更新-删除
,最终结果是数据被删除;如果消息没有按序消费,处理顺序可能是删除-新增-更新
,最终数据没有被删掉,可能会产生一些逻辑错误。对于如何保证消息的顺序性,主要需要考虑如下两点:
- 如何保证消息在
Kafka
中顺序性; - 如何保证消费者处理消费的顺序性。
如何保证消息在 Kafka 中顺序性
对于 Kafka
,如果我们创建了一个 topic
,默认有三个 partition
。生产者在写数据的时候,可以指定一个 key
,比如在订单 topic
中我们可以指定订单 id
作为 key
,那么相同订单 id
的数据,一定会被分发到同一个 partition
中去,而且这个 partition
中的数据一定是有顺序的。消费者从 partition
中取出来数据的时候,也一定是有顺序的。通过制定 key
的方式首先可以保证在 kafka
内部消息是有序的。
如何保证消费者处理消费的顺序性
对于某个 topic
的一个 partition
,只能被同组内部的一个 consumer
消费,如果这个 consumer
内部还是单线程处理,那么其实只要保证消息在 MQ
内部是有顺序的就可以保证消费也是有顺序的。但是单线程吞吐量太低,在处理大量 MQ
消息时,我们一般会开启多线程消费机制,那么如何保证消息在多个线程之间是被顺序处理的呢?对于多线程消费我们可以预先设置 N
个内存 Queue
,具有相同 key
的数据都放到同一个内存 Queue
中;然后开启 N
个线程,每个线程分别消费一个内存 Queue
的数据即可,这样就能保证顺序性。当然,消息放到内存 Queue
中,有可能还未被处理,consumer
发生宕机,内存 Queue
中的数据会全部丢失,这就转变为上面提到的如何保证消息的可靠传输的问题了。
Kafka中的ISR、AR代表什么?ISR的伸缩指什么?
ISR
:In-Sync Replicas 副本同步队列AR
:Assigned Replicas 所有副本
ISR是由leader维护,follower从leader同步数据有一些延迟(包括延迟时间replica.lag.time.max.ms
和延迟条数replica.lag.max.messages
两个维度,当前最新的版本0.10.x中只支持replica.lag.time.max.ms
这个维度),任意一个超过阈值都会把follower剔除出ISR,存入OSR(Outof-Sync Replicas)列表,新加入的follower也会先存放在OSR中。
AR=ISR+OSR。
描述下 Kafka 中的领导者副本(Leader Replica)和追随者副本(Follower Replica)的区别
Kafka副本当前分为领导者副本和追随者副本。只有Leader副本才能对外提供读写服务,响应Clients端的请求。Follower副本只是采用拉(PULL)的方式,被动地同步Leader副本中的数据,并且在Leader副本所在的Broker宕机后,随时准备应聘Leader副本。
加分点:
- 强调Follower副本也能对外提供读服务。自Kafka 2.4版本开始,社区通过引入新的Broker端参数,允许Follower副本有限度地提供读服务。
- 强调Leader和Follower的消息序列在实际场景中不一致。通常情况下,很多因素可能造成Leader和Follower之间的不同步,比如程序问题,网络问题,broker问题等,短暂的不同步我们可以关注(秒级别),但长时间的不同步可能就需要深入排查了,因为一旦Leader所在节点异常,可能直接影响可用性。
注意:之前确保一致性的主要手段是高水位机制(HW),但高水位值无法保证Leader连续变更场景下的数据一致性,因此,社区引入了Leader Epoch机制,来修复高水位值的弊端。
分区Leader选举策略有几种?
分区的Leader副本选举对用户是完全透明的,它是由Controller独立完成的。你需要回答的是,在哪些场景下,需要执行分区Leader选举。每一种场景对应于一种选举策略。
- OfflinePartition Leader选举:每当有分区上线时,就需要执行Leader选举。所谓的分区上线,可能是创建了新分区,也可能是之前的下线分区重新上线。这是最常见的分区Leader选举场景。
- ReassignPartition Leader选举:当你手动运行kafka-reassign-partitions命令,或者是调用Admin的alterPartitionReassignments方法执行分区副本重分配时,可能触发此类选举。假设原来的AR是[1,2,3],Leader是1,当执行副本重分配后,副本集合AR被设置成[4,5,6],显然,Leader必须要变更,此时会发生Reassign Partition Leader选举。
- PreferredReplicaPartition Leader选举:当你手动运行kafka-preferred-replica-election命令,或自动触发了Preferred Leader选举时,该类策略被激活。所谓的Preferred Leader,指的是AR中的第一个副本。比如AR是[3,2,1],那么,Preferred Leader就是3。
- ControlledShutdownPartition Leader选举:当Broker正常关闭时,该Broker上的所有Leader副本都会下线,因此,需要为受影响的分区执行相应的Leader选举。
这4类选举策略的大致思想是类似的,即从AR中挑选首个在ISR中的副本,作为新Leader。
Kafka的哪些场景中使用了零拷贝(Zero Copy)?
在Kafka中,体现Zero Copy使用场景的地方有两处:基于mmap的索引和日志文件读写所用的TransportLayer。
先说第一个。索引都是基于MappedByteBuffer的,也就是让用户态和内核态共享内核态的数据缓冲区,此时,数据不需要复制到用户态空间。不过,mmap虽然避免了不必要的拷贝,但不一定就能保证很高的性能。在不同的操作系统下,mmap的创建和销毁成本可能是不一样的。很高的创建和销毁开销会抵消Zero Copy带来的性能优势。由于这种不确定性,在Kafka中,只有索引应用了mmap,最核心的日志并未使用mmap机制。
再说第二个。TransportLayer是Kafka传输层的接口。它的某个实现类使用了FileChannel的transferTo方法。该方法底层使用sendfile实现了Zero Copy。对Kafka而言,如果I/O通道使用普通的PLAINTEXT,那么,Kafka就可以利用Zero Copy特性,直接将页缓存中的数据发送到网卡的Buffer中,避免中间的多次拷贝。相反,如果I/O通道启用了SSL,那么,Kafka便无法利用Zero Copy特性了。
为什么Kafka不支持读写分离?
在 Kafka 中,生产者写入消息、消费者读取消息的操作都是与 leader 副本进行交互的,从 而实现的是一种主写主读的生产消费模型。
Kafka 并不支持主写从读,因为主写从读有 2 个很明 显的缺点:
- 数据一致性问题。数据从主节点转到从节点必然会有一个延时的时间窗口,这个时间 窗口会导致主从节点之间的数据不一致。某一时刻,在主节点和从节点中 A 数据的值都为 X, 之后将主节点中 A 的值修改为 Y,那么在这个变更通知到从节点之前,应用读取从节点中的 A 数据的值并不为最新的 Y,由此便产生了数据不一致的问题。
- 延时问题。类似 Redis 这种组件,数据从写入主节点到同步至从节点中的过程需要经历
网络→主节点内存→网络→从节点内存
这几个阶段,整个过程会耗费一定的时间。而在 Kafka 中,主从同步会比 Redis 更加耗时,它需要经历网络→主节点内存→主节点磁盘→网络→从节点内存→从节点磁盘
这几个阶段。对延时敏感的应用而言,主写从读的功能并不太适用。
如何保证高可用的
RabbitMQ 是比较有代表性的,因为是基于主从(非分布式)做高可用性的,我们就以 RabbitMQ为例子讲解第一种 MQ 的高可用性怎么实现。RabbitMQ 有三种模式:单机模式、普通集群模式、镜像集群模式。
单机模式: 就是 Demo 级别的,一般就是你本地启动了玩玩儿的?,没人生产用单机模式。
普通集群模式: 意思就是在多台机器上启动多个 RabbitMQ 实例,每个机器启动一个。你创建的queue,只会放在一个 RabbitMQ 实例上,但是每个实例都同步 queue 的元数据(元数据可以认为是 queue 的一些配置信息,通过元数据,可以找到 queue 所在实例)。你消费的时候,实际上如果连接到了另外一个实例,那么那个实例会从 queue 所在实例上拉取数据过来。这方案主要是提高吞吐量的,就是说让集群中多个节点来服务某个 queue 的读写操作。
镜像集群模式: 这种模式,才是所谓的 RabbitMQ 的高可用模式。跟普通集群模式不一样的是,在镜像集群模式下,你创建的 queue,无论元数据还是 queue 里的消息都会存在于多个实例上,就是说,每个 RabbitMQ 节点都有这个 queue 的一个完整镜像,包含 queue 的全部数据的意思。然后每次你写消息到 queue 的时候,都会自动把消息同步到多个实例的 queue 上。
RabbitMQ 有很好的管理控制台,就是在后台新增一个策略,这个策略是镜像集群模式的策略,指定的时候是可以要求数据同步到所有节点的,也可以要求同步到指定数量的节点,再次创建 queue 的时候,应用这个策略,就会自动将数据同步到其他的节点上去了。这样的话,好处在于,你任何一个机器宕机了,没事儿,其它机器(节点)还包含了这个 queue 的完整数据,别的 consumer 都可以到其它节点上去消费数据。坏处在于,第一,这个性能开销也太大了吧,消息需要同步到所有机器上,导致网络带宽压力和消耗很重!RabbitMQ 一个 queue 的数据都是放在一个节点里的,镜像集群下,也是每个节点都放这个 queue 的完整数据。
Kafka 一个最基本的架构认识: 由多个 broker 组成,每个 broker 是一个节点;你创建一个topic,这个 topic 可以划分为多个 partition,每个 partition 可以存在于不同的 broker 上,每个partition 就放一部分数据。这就是天然的分布式消息队列,就是说一个 topic 的数据,是分散放在多个机器上的,每个机器就放一部分数据。Kafka 0.8 以后,提供了 HA 机制,就是 replica(复制品) 副本机制。每个 partition 的数据都会同步到其它机器上,形成自己的多个 replica 副本。所有replica 会选举一个 leader 出来,那么生产和消费都跟这个 leader 打交道,然后其他 replica 就是follower。写的时候,leader 会负责把数据同步到所有 follower 上去,读的时候就直接读 leader上的数据即可。只能读写 leader?很简单,要是你可以随意读写每个 follower,那么就要 care 数据一致性的问题,系统复杂度太高,很容易出问题。Kafka 会均匀地将一个 partition 的所有replica 分布在不同的机器上,这样才可以提高容错性。因为如果某个 broker 宕机了,没事儿,那个 broker上面的 partition 在其他机器上都有副本的,如果这上面有某个 partition 的 leader,那么此时会从 follower 中重新选举一个新的 leader 出来,大家继续读写那个新的 leader 即可。这就有所谓的高可用性了。写数据的时候,生产者就写 leader,然后 leader 将数据落地写本地磁盘,接着其他 follower 自己主动从 leader 来 pull 数据。一旦所有 follower 同步好数据了,就会发送ack 给 leader,leader 收到所有 follower 的 ack 之后,就会返回写成功的消息给生产者。(当然,这只是其中一种模式,还可以适当调整这个行为)消费的时候,只会从 leader 去读,但是只有当一个消息已经被所有 follower 都同步成功返回 ack 的时候,这个消息才会被消费者读到。
如何保证消息的可靠传输?如果消息丢了怎么办
数据的丢失问题,可能出现在生产者、MQ、消费者中。
生产者丢失: 生产者将数据发送到 RabbitMQ 的时候,可能数据就在半路给搞丢了,因为网络问题啥的,都有可能。此时可以选择用 RabbitMQ 提供的事务功能,就是生产者发送数据之前开启RabbitMQ 事务channel.txSelect,然后发送消息,如果消息没有成功被 RabbitMQ 接收到,那么生产者会收到异常报错,此时就可以回滚事务channel.txRollback,然后重试发送消息;如果收到了消息,那么可以提交事务channel.txCommit。吞吐量会下来,因为太耗性能。所以一般来说,如果你要确保说写 RabbitMQ 的消息别丢,可以开启confirm模式,在生产者那里设置开启confirm模式之后,你每次写的消息都会分配一个唯一的 id,然后如果写入了 RabbitMQ 中,RabbitMQ 会给你回传一个ack消息,告诉你说这个消息 ok 了。如果 RabbitMQ 没能处理这个消息,会回调你一个nack接口,告诉你这个消息接收失败,你可以重试。而且你可以结合这个机制自己在内存里维护每个消息 id 的状态,如果超过一定时间还没接收到这个消息的回调,那么你可以重发。事务机制和cnofirm机制最大的不同在于,事务机制是同步的,你提交一个事务之后会阻塞在那儿,但是confirm机制是异步的,你发送个消息之后就可以发送下一个消息,然后那个消息RabbitMQ 接收了之后会异步回调你一个接口通知你这个消息接收到了。所以一般在生产者这块避免数据丢失,都是用confirm机制的。
MQ中丢失: 就是 RabbitMQ 自己弄丢了数据,这个你必须开启 RabbitMQ 的持久化,就是消息写入之后会持久化到磁盘,哪怕是 RabbitMQ 自己挂了,恢复之后会自动读取之前存储的数据,一般数据不会丢。设置持久化有两个步骤:创建 queue 的时候将其设置为持久化,这样就可以保证RabbitMQ 持久化 queue 的元数据,但是不会持久化 queue 里的数据。第二个是发送消息的时候将消息的 deliveryMode 设置为 2,就是将消息设置为持久化的,此时 RabbitMQ 就会将消息持久化到磁盘上去。必须要同时设置这两个持久化才行,RabbitMQ 哪怕是挂了,再次重启,也会从磁盘上重启恢复 queue,恢复这个 queue 里的数据。持久化可以跟生产者那边的confirm机制配合起来,只有消息被持久化到磁盘之后,才会通知生产者ack了,所以哪怕是在持久化到磁盘之前,RabbitMQ 挂了,数据丢了,生产者收不到ack,你也是可以自己重发的。注意,哪怕是你给RabbitMQ 开启了持久化机制,也有一种可能,就是这个消息写到了 RabbitMQ 中,但是还没来得及持久化到磁盘上,结果不巧,此时 RabbitMQ 挂了,就会导致内存里的一点点数据丢失。
消费端丢失:你消费的时候,刚消费到,还没处理,结果进程挂了,比如重启了,那么就尴尬了,RabbitMQ 认为你都消费了,这数据就丢了。这个时候得用 RabbitMQ 提供的ack机制,简单来说,就是你关闭 RabbitMQ 的自动ack,可以通过一个 api 来调用就行,然后每次你自己代码里确保处理完的时候,再在程序里ack一把。这样的话,如果你还没处理完,不就没有ack?那RabbitMQ 就认为你还没处理完,这个时候 RabbitMQ 会把这个消费分配给别的 consumer 去处理,消息是不会丢的。
如何保证消息的顺序性
先看看顺序会错乱的场景:RabbitMQ:一个 queue,多个 consumer,这不明显乱了。
解决方案:
如何解决消息队列的延时以及过期失效问题?消息队列满了以后该怎么处理?有几百万消息持续积压几小时,说说怎么解决?
消息积压处理办法:临时紧急扩容
先修复 consumer 的问题,确保其恢复消费速度,然后将现有 cnosumer 都停掉。 新建一个topic,partition 是原来的 10 倍,临时建立好原先 10 倍的 queue 数量。 然后写一个临时的分发数据的 consumer 程序,这个程序部署上去消费积压的数据,消费之后不做耗时的处理,直接均匀轮询写入临时建立好的 10 倍数量的 queue。 接着临时征用 10 倍的机器来部署 consumer,每一批 consumer 消费一个临时 queue 的数据。这种做法相当于是临时将 queue 资源和 consumer 资源扩大 10 倍,以正常的 10 倍速度来消费数据。 等快速消费完积压数据之后,得恢复原先部署的架构,重新用原先的 consumer 机器来消费消息。
MQ中消息失效
假设你用的是 RabbitMQ,RabbtiMQ 是可以设置过期时间的,也就是 TTL。如果消息在 queue 中积压超过一定的时间就会被RabbitMQ 给清理掉,这个数据就没了。那这就是第二个坑了。这就不是说数据会大量积压在 mq里,而是大量的数据会直接搞丢。我们可以采取一个方案,就是批量重导,这个我们之前线上也有类似的场景干过。就是大量积压的时候,我们当时就直接丢弃数据了,然后等过了高峰期以后,比如大家一起喝咖啡熬夜到晚上12点以后,用户都睡觉了。这个时候我们就开始写程序,将丢失的那批数据,写个临时程序,一点一点的查出来,然后重新灌入 mq 里面去,把白天丢的数据给他补回来。也只能是这样了。假设 1 万个订单积压在 mq 里面,没有处理,其中 1000 个订单都丢了,你只能手动写程序把那 1000 个订单给查出来,手动发到 mq 里去再补一次。
MQ消息队列块满了
如果消息积压在 mq 里,你很长时间都没有处理掉,此时导致 mq 都快写满了,咋办?这个还有别的办法吗?没有,谁让你第一个方案执行的太慢了,你临时写程序,接入数据来消费,消费一个丢弃一个,都不要了,快速消费掉所有的消息。然后走第二个方案,到了晚上再补数据吧。
12. Zookeeper篇
ZooKeeper 有哪些应用场景
数据发布与订阅
发布与订阅即所谓的配置管理,顾名思义就是将数据发布到ZooKeeper节点上,供订阅者动态获取数据,实现配置信息的集中式管理和动态更新。例如全局的配置信息,地址列表等就非常适合使用。
数据发布/订阅的一个常见的场景是配置中心,发布者把数据发布到 ZooKeeper 的一个或一系列的节点上,供订阅者进行数据订阅,达到动态获取数据的目的。
配置信息一般有几个特点:
- 数据量小的KV
- 数据内容在运行时会发生动态变化
- 集群机器共享,配置一致
ZooKeeper 采用的是推拉结合的方式。
- 推: 服务端会推给注册了监控节点的客户端 Wathcer 事件通知;
- 拉: 客户端获得通知后,然后主动到服务端拉取最新的数据。
命名服务
作为分布式命名服务,命名服务是指通过指定的名字来获取资源或者服务的地址,利用ZooKeeper创建一个全局的路径,这个路径就可以作为一个名字,指向集群中的集群,提供的服务的地址,或者一个远程的对象等等。
统一命名服务的命名结构图如下所示:
- 在分布式环境下,经常需要对应用/服务进行统一命名,便于识别不同服务
- 类似于域名与IP之间对应关系,IP不容易记住,而域名容易记住
- 通过名称来获取资源或服务的地址,提供者等信息
- 按照层次结构组织服务/应用名称
可将服务名称以及地址信息写到ZooKeeper上,客户端通过ZooKeeper获取可用服务列类。
配置管理
程序分布式的部署在不同的机器上,将程序的配置信息放在ZooKeeper的znode下,当有配置发生改变时,也就是znode发生变化时,可以通过改变zk中某个目录节点的内容,利用watch通知给各个客户端 从而更改配置。
ZooKeeper配置管理结构图如下所示:
- 分布式环境下,配置文件管理和同步是一个常见问题
- 一个集群中,所有节点的配置信息是一致的,比如 Hadoop 集群
- 对配置文件修改后,希望能够快速同步到各个节点上
- 配置管理可交由ZooKeeper实现
- 可将配置信息写入ZooKeeper上的一个Znode
- 各个节点监听这个Znode
- 一旦Znode中的数据被修改,ZooKeeper将通知各个节点
集群管理
所谓集群管理就是:是否有机器退出和加入、选举master。
集群管理主要指集群监控和集群控制两个方面。前者侧重于集群运行时的状态的收集,后者则是对集群进行操作与控制。开发和运维中,面对集群,经常有如下需求:
- 希望知道集群中究竟有多少机器在工作
- 对集群中的每台机器的运行时状态进行数据收集
- 对集群中机器进行上下线的操作
集群管理结构图如下所示:
- 分布式环境中,实时掌握每个节点的状态是必要的,可根据节点实时状态做出一些调整
- 可交由ZooKeeper实现
- 可将节点信息写入ZooKeeper上的一个Znode
- 监听这个Znode可获取它的实时状态变化
- 典型应用
Hbase中Master状态监控与选举:利用ZooKeeper的强一致性,能够保证在分布式高并发情况下节点创建的全局唯一性,即:同时有多个客户端请求创建 /currentMaster 节点,最终一定只有一个客户端请求能够创建成功。
分布式通知与协调
- 分布式环境中,经常存在一个服务需要知道它所管理的子服务的状态
- NameNode需知道各个Datanode的状态
- JobTracker需知道各个TaskTracker的状态
- 心跳检测机制可通过ZooKeeper来实现
- 信息推送可由ZooKeeper来实现,ZooKeeper相当于一个发布/订阅系统
分布式锁
处于不同节点上不同的服务,它们可能需要顺序的访问一些资源,这里需要一把分布式的锁。
分布式锁具有以下特性:写锁、读锁、时序锁。
写锁: 在zk上创建的一个临时的无编号的节点。由于是无序编号,在创建时不会自动编号,导致只能客户端有一个客户端得到锁,然后进行写入。
读锁: 在zk上创建一个临时的有编号的节点,这样即使下次有客户端加入是同时创建相同的节点时,他也会自动编号,也可以获得锁对象,然后对其进行读取。
时序锁: 在zk上创建的一个临时的有编号的节点根据编号的大小控制锁。
分布式队列
分布式队列分为两种:
- 当一个队列的成员都聚齐时,这个队列才可用,否则一直等待所有成员到达,这种是同步队列。
- 一个job由多个task组成,只有所有任务完成后,job才运行完成
- 可为job创建一个/job目录,然后在该目录下,为每个完成的task创建一个临时的Znode,一旦临时节点数目达到task总数,则表明job运行完成
- 队列按照FIFO方式进行入队和出队操作,例如实现生产者和消费者模型。
说说Zookeeper的工作原理
Zookeeper的核心是原子广播,这个机制保证了各个Server之间的同步。实现这个机制的协议叫做Zab协议。
Zab协议有两种模式,它们 分别是恢复模式(选主)和广播模式(同步)。
Zab协议 的全称是 Zookeeper Atomic Broadcast(Zookeeper原子广播)。Zookeeper 是通过Zab 协议来保证分布式事务的最终一致性。Zab协议要求每个 Leader 都要经历三个阶段:发现,同步,广播。
当服务启动或者在领导者崩溃后,Zab就进入了恢复模式,当领导者被选举出来,且大多数Server完成了和 leader的状态同步以后,恢复模式就结束了。状态同步保证了leader和Server具有相同的系统状态。
为了保证事务的顺序一致性,zookeeper采用了递增的事务id号(zxid)来标识事务。所有的提议(proposal)都在被提出的时候加 上了zxid。实现中zxid是一个64位的数字,它高32位是epoch用来标识leader关系是否改变,每次一个leader被选出来,它都会有一 个新的epoch,标识当前属于那个leader的统治时期。低32位用于递增计数。
epoch:可以理解为皇帝的年号,当新的皇帝leader产生后,将有一个新的epoch年号。
每个Server在工作过程中有三种状态:
- LOOKING:当前Server不知道leader是谁,正在搜寻
- LEADING:当前Server即为选举出来的leader
- FOLLOWING:leader已经选举出来,当前Server与之同步
Zookeeper 集群中有哪些角色
在一个集群中,最少需要 3 台。或者保证 2N + 1 台,即奇数。为什么保证奇数?主要是为了选举算法。
Zookeeper 集群中是怎样选举leader的
Zookeeper的核心是原子广播,这个机制保证了各个Server之间的同步。实现这个机制的协议叫做Zab协议。Zab协议有两种模式,它们分别是恢复模式(选主)和广播模式(同步)。当服务启动或者在领导者崩溃后,Zab就进入了恢复模式,当领导者被选举出来,且大多数Server完成了和leader的状态同步以后,恢复模式就结束了。状态同步保证了leader和Server具有相同的系统状态。leader选举是保证分布式数据一致性的关键。
出现选举主要是两种场景:初始化、leader不可用。
当zk集群中的一台服务器出现以下两种情况之一时,就会开始leader选举。
- 服务器初始化启动。
- 服务器运行期间无法和leader保持连接。
而当一台机器进入leader选举流程时,当前集群也可能处于以下两种状态。 - 集群中本来就已经存在一个leader。
- 集群中确实不存在leader。
首先第一种情况,通常是集群中某一台机器启动比较晚,在它启动之前,集群已经正常工作,即已经存在一台leader服务器。当该机器试图去选举leader时,会被告知当前服务器的leader信息,它仅仅需要和leader机器建立连接,并进行状态同步即可。
重点是leader不可用了,此时的选主制度。
投票信息中包含两个最基本的信息。
sid :即server id,用来标识该机器在集群中的机器序号。
zxid :即zookeeper事务id号。
ZooKeeper状态的每一次改变, 都对应着一个递增的Transaction id,,该id称为zxid.,由于zxid的递增性质, 如果zxid1小于zxid2,,那么zxid1肯定先于zxid2发生。创建任意节点,或者更新任意节点的数据, 或者删除任意节点,都会导致Zookeeper状态发生改变,从而导致zxid的值增加。
以(sid,zxid)的形式来标识一次投票信息。
例如:如果当前服务器要推举sid为1,zxid为8的服务器成为leader,那么投票信息可以表示为(1,8)。
集群中的每台机器发出自己的投票后,也会接受来自集群中其他机器的投票。每台机器都会根据一定的规则,来处理收到的其他机器的投票,以此来决定是否需要变更自己的投票。
规则如下 :
- 初始阶段,都会给自己投票。
- 当接收到来自其他服务器的投票时,都需要将别人的投票和自己的投票进行pk,规则如下:
优先检查zxid。zxid比较大的服务器优先作为leader。如果zxid相同的话,就比较sid,sid比较大的服务器作为leader。
所有服务启动时候的选举流程:三台服务器 server1、server2、server3。
- server1 启动,一台机器不会选举。
- server2 启动,server1 和 server2 的状态改为 looking,广播投票。
- server3 启动,状态改为 looking,加入广播投票。
- 初识状态,互不认识,大家都认为自己是王者,投票也投自己为 Leader。
- 投票信息说明,票信息本来为五元组,这里为了逻辑清晰,简化下表达。
初始 zxid = 0,sid 是每个节点的名字,这个 sid 在 zoo.cfg 中配置,不会重复。
节点 | sid |
---|---|
Server1 | 1 |
Server2 | 2 |
Server3 | 3 |
- 初始 zxid=0,server1 投票(1,0),server2 投票(2,0),server3 投票(3,0)
- server1 收到 投票(2,0)时,会先验证投票的合法性,然后自己的票进行 pk,pk 的逻辑是先比较 zxid,server1(zxid)=server2(zxid)=0,zxid 相等再比较 sid,server1(sid)<server2(sid),pk 结果为 server2 的投票获胜。server1 更新自己的投票为 (2,0),server1重新投票。
- TODO 这里最终是 2 还是 3,需要做实验确定。
- server2 收到 server1 投票,会先验证投票的合法性,然后 pk,自己的票获胜,server 不用更新自己的票,pk 后,重新在发送一次投票。
- 统计投票,pk 后会统计投票,如果半数以上的节点投出相同的票,确定选出了 Leader。
选举结束,被选中节点的状态由 LOOKING 变成 LEADING,其他参加选举的节点由 LOOKING变成 FOLLOWING。如果有 Observer 节点,如果 Observer 不参与选举,所以选举前后它的状态一直是 OBSERVING,没有变化。
简单地说
开始投票 -> 节点状态变成 LOOKING -> 每个节点选自己-> 收到票进行 PK -> sid 大的获胜 -> 更新选票 -> 再次投票 -> 统计选票,选票过半数选举结果 -> 节点状态更新为自己的角色状态。
ZooKeeper 分布式锁怎么实现的
如果有客户端1、客户端2等N个客户端争抢一个 Zookeeper 分布式锁。大致如下:
- 大家都是上来直接创建一个锁节点下的一个接一个的临时有序节点
- 如果自己不是第一个节点,就对自己上一个节点加监听器
- 只要上一个节点释放锁,自己就排到前面去了,相当于是一个排队机制
而且用临时顺序节点的另外一个用意就是,如果某个客户端创建临时顺序节点之后,不小心自己宕机了也没关系, Zookeeper 感知到那个客户端宕机,会自动删除对应的临时顺序节点,相当于自动释放锁,或者是自动取消自己的排队。
本地锁,可以用 JDK 实现,但是分布式锁就必须要用到分布式的组件。比如 ZooKeeper、Redis。网上代码一大段,面试一般也不要写,我这说一些关键点。
几个需要注意的地方如下:
- 死锁问题:锁不能因为意外就变成死锁,所以要用 ZK 的临时节点,客户端连接失效了,锁就自动释放了。
- 锁等待问题:锁有排队的需求,所以要 ZK 的顺序节点。
- 锁管理问题:一个使用使用释放了锁,需要通知其他使用者,所以需要用到监听。
监听的羊群效应:比如有 1000 个锁竞争者,锁释放了,1000 个竞争者就得到了通知,然后判断,最终序号最小的那个拿到了锁。其它 999 个竞争者重新注册监听。这就是羊群效应,出点事,就会惊动整个羊群。应该每个竞争者只监听自己前面的那个节点。比如 2 号释放了锁,那么只有 3 号得到了通知。
为什么Zookeeper集群的数目,一般为奇数个
首先需要明确zookeeper选举的规则:leader选举,要求 可用节点数量 > 总节点数量/2 。
比如:标记一个写是否成功是要在超过一半节点发送写请求成功时才认为有效。同样,Zookeeper选择领导者节点也是在超过一半节点同意时才有效。最后,Zookeeper是否正常是要根据是否超过一半的节点正常才算正常。这是基于CAP的一致性原理。
zookeeper有这样一个特性:集群中只要有过半的机器是正常工作的,那么整个集群对外就是可用的。
也就是说如果有2个zookeeper,那么只要有1个死了zookeeper就不能用了,因为1没有过半,所以2个zookeeper的死亡容忍度为0;
同理,要是有3个zookeeper,一个死了,还剩下2个正常的,过半了,所以3个zookeeper的容忍度为1;
同理:
- 2->0;两个zookeeper,最多0个zookeeper可以不可用
- 3->1;三个zookeeper,最多1个zookeeper可以不可用
- 4->1;四个zookeeper,最多1个zookeeper可以不可用
- 5->2;五个zookeeper,最多2个zookeeper可以不可用
- 6->2;两个zookeeper,最多0个zookeeper可以不可用
会发现一个规律,2n和2n-1的容忍度是一样的,都是n-1,所以为了更加高效,何必增加那一个不必要的zookeeper呢。
zookeeper的选举策略也是需要半数以上的节点同意才能当选leader,如果是偶数节点可能导致票数相同的情况。
讲解一下 ZooKeeper 的持久化机制
什么是持久化
- 数据,存到磁盘或者文件当中。
- 机器重启后,数据不会丢失。内存 -> 磁盘的映射,和序列化有些像。
ZooKeeper 的持久化
- SnapShot 快照,记录内存中的全量数据
- TxnLog 增量事务日志,记录每一条增删改记录(查不是事务日志,不会引起数据变化)
为什么持久化这么麻烦,一个不可用吗?
快照的缺点,文件太大,而且快照文件不会是最新的数据。 增量事务日志的缺点,运行时间长了,日志太多了,加载太慢。二者结合最好。
快照模式:
- 将 ZooKeeper 内存中以 DataTree 数据结构存储的数据定期存储到磁盘中。
- 由于快照文件是定期对数据的全量备份,所以快照文件中数据通常不是最新的。
13. Redis篇
Redis系列文章:Redis
为什么要用缓存
使用缓存的目的就是提升读写性能。而实际业务场景下,更多的是为了提升读性能,带来更好的性能,带来更高的并发量。 Redis 的读写性能比 Mysql 好的多,我们就可以把 Mysql 中的热点数据缓存到 Redis 中,提升读取性能,同时也减轻了 Mysql 的读取压力。
为什么要用 Redis 做缓存?
从高并发上来说:
- 直接操作缓存能够承受的请求是远远大于直接访问数据库的,所以我们可以考虑把数据库中的部分数据转移到缓存中去,这样用户的一部分请求会直接到缓存这里而不用经过数据库。
从高性能上来说:
- 用户第一次访问数据库中的某些数据。 因为是从硬盘上读取的所以这个过程会比较慢。将该用户访问的数据存在缓存中,下一次再访问这些数据的时候就可以直接从缓存中获取了。操作缓存就是直接操作内存,所以速度相当快。如果数据库中的对应数据改变的之后,同步改变缓存中相应的数据。
使用 Redis 有哪些优缺点
优点
- 读写性能极高, Redis能读的速度是110000次/s,写的速度是81000次/s。
- 支持数据持久化,支持AOF和RDB两种持久化方式。
- 支持事务, Redis的所有操作都是原子性的,意思就是要么成功执行要么失败完全不执行。单个操作是原子性的。多个操作也支持事务,即原子性,通过MULTI和EXEC指令包起来。
- 数据结构丰富,除了支持string类型的value外,还支持hash、set、zset、list等数据结构。
- 支持主从复制,主机会自动将数据同步到从机,可以进行读写分离。
- 丰富的特性 – Redis还支持 publish/subscribe, 通知, key 过期等特性。
缺点
- 数据库容量受到物理内存的限制,不能用作海量数据的高性能读写,因此Redis适合的场景主要局限在较小数据量的高性能操作和运算上。
- 主机宕机,宕机前有部分数据未能及时同步到从机,切换IP后还会引入数据不一致的问题,降低了系统的可用性。
什么是 Redis
Redis 是一个开源(BSD 许可)、基于内存、支持多种数据结构的存储系统,可以作为数据库、缓存和消息中间件。它支持的数据结构有字符串(strings)、哈希(hashes)、列表(lists)、集合(sets)、有序集合(sorted sets)等,除此之外还支持 bitmaps、hyperloglogs 和地理空间(geospatial )索引半径查询等功能。
它内置了复制(Replication)、LUA 脚本(Lua scripting)、LRU 驱动事件(LRU eviction)、事务(Transactions)和不同级别的磁盘持久化(persistence)功能,并通过 Redis 哨兵(哨兵)和集群(Cluster)保证缓存的高可用性(High availability)。
Redis本质上是一个Key-Value类型的内存数据库,很像Memcached,整个数据库加载在内存当中操作,定期通过异步操作把数据库中的数据flush到硬盘上进行保存。
因为是纯内存操作,Redis的性能非常出色,每秒可以处理超过 10万次读写操作,是已知性能最快的Key-Value 数据库。
Redis为什么这么快?
内存存储:Redis是使用内存(in-memeroy)存储,没有磁盘IO上的开销。数据存在内存中,类似于 HashMap,HashMap 的优势就是查找和操作的时间复杂度都是O(1)。
单线程实现( Redis 6.0以前):Redis使用单个线程处理请求,避免了多个线程之间线程切换和锁资源争用的开销。注意:单线程是指的是在核心网络模型中,网络请求模块使用一个线程来处理,即一个线程处理所有网络请求。
非阻塞IO:Redis使用多路复用IO技术,将epoll作为I/O多路复用技术的实现,再加上Redis自身的事件处理模型将epoll中的连接、读写、关闭都转换为事件,不在网络I/O上浪费过多的时间。
优化的数据结构:Redis有诸多可以直接应用的优化数据结构的实现,应用层可以直接使用原生的数据结构提升性能。
使用底层模型不同:Redis直接自己构建了 VM (虚拟内存)机制 ,因为一般的系统调用系统函数的话,会浪费一定的时间去移动和请求。
Redis的VM(虚拟内存)机制就是暂时把不经常访问的数据(冷数据)从内存交换到磁盘中,从而腾出宝贵的内存空间用于其它需要访问的数据(热数据)。通过VM功能可以实现冷热数据分离,使热数据仍在内存中、冷数据保存到磁盘。这样就可以避免因为内存不足而造成访问速度下降的问题。
Redis提高数据库容量的办法有两种:一种是可以将数据分割到多个RedisServer上;另一种是使用虚拟内存把那些不经常访问的数据交换到磁盘上。需要特别注意的是Redis并没有使用OS提供的Swap,而是自己实现。
为什么使用 Redis 而不是用 Memcache 呢
这时候肯定想到的就是做一个 Memcache 与 Redis 区别。
- Redis 和 Memcache 都是将数据存放在内存中,都是内存数据库。不过 Memcache 还可用于缓存其他东西,例如图片、视频等等。
- Memcache 仅支持key-value结构的数据类型,Redis不仅仅支持简单的key-value类型的数据,同时还提供list,set,hash等数据结构的存储。
- 虚拟内存– Redis 当物理内存用完时,可以将一些很久没用到的value 交换到磁盘。
- 分布式–设定 Memcache 集群,利用 magent 做一主多从; Redis 可以做一主多从。都可以一主一从。
- 存储数据安全– Memcache 挂掉后,数据没了; Redis 可以定期保存到磁盘(持久化)
- Memcache 的单个value最大 1m , Redis 的单个value最大 512m 。
- 灾难恢复– Memcache 挂掉后,数据不可恢复; Redis 数据丢失后可以通过 aof 恢复
- Redis 原生就支持集群模式, Redis3.0 版本中,官方便能支持Cluster模式了, Memcached 没有原生的集群模式,需要依赖客户端来实现,然后往集群中分片写入数据。
- Memcached 网络IO模型是多线程,非阻塞IO复用的网络模型,原型上接近于 nignx 。而 Redis使用单线程的IO复用模型,自己封装了一个简单的 AeEvent 事件处理框架,主要实现类epoll,kqueue 和 select ,更接近于Apache早期的模式。
为什么要用 Redis 而不用 map/guava 做缓存?
缓存分为本地缓存和分布式缓存。以java为例,使用自带的map或者guava实现的是本地缓存,最主要的特点是轻量以及快速,生命周期随着jvm的销毁而结束,并且在多实例的情况下,每个实例都需要各自保存一份缓存,缓存不具有一致性。
使用Redis或memcached之类的称为分布式缓存,在多实例的情况下,各实例共用一份缓存数据,缓存具有一致性。缺点是需要保持Redis或memcached服务的高可用,整个程序架构上较为复杂。
对比:
- Redis 可以用几十 G 内存来做缓存,Map 不行,一般 JVM 也就分几个 G 数据就够大了;
- Redis 的缓存可以持久化,Map 是内存对象,程序一重启数据就没了;
- Redis 可以实现分布式的缓存,Map 只能存在创建它的程序里;
- Redis 可以处理每秒百万级的并发,是专业的缓存服务,Map 只是一个普通的对象;
- Redis 缓存有过期机制,Map 本身无此功能;Redis 有丰富的 API,Map 就简单太多了;
- Redis可单独部署,多个项目之间可以共享,本地内存无法共享;
- Redis有专门的管理工具可以查看缓存数据。
6. Redis的常用场景有哪些?
- 缓存
缓存现在几乎是所有中大型网站都在用的必杀技,合理的利用缓存不仅能够提升网站访问速度,还能大大降低数据库的压力。Redis提供了键过期功能,也提供了灵活的键淘汰策略,所以,现在Redis用在缓存的场合非常多。
- 排行榜
很多网站都有排行榜应用的,如京东的月度销量榜单、商品按时间的上新排行榜等。Redis提供的有序集合数据类构能实现各种复杂的排行榜应用。
- 计数器
什么是计数器,如电商网站商品的浏览量、视频网站视频的播放数等。为了保证数据实时效,每次浏览都得给+1,并发量高时如果每次都请求数据库操作无疑是种挑战和压力。Redis提供的incr命令来实现计数器功能,内存操作,性能非常好,非常适用于这些计数场景。
- 分布式会话
集群模式下,在应用不多的情况下一般使用容器自带的session复制功能就能满足,当应用增多相对复杂的系统中,一般都会搭建以Redis等内存数据库为中心的session服务,session不再由容器管理,而是由session服务及内存数据库管理。
- 分布式锁
在很多互联网公司中都使用了分布式技术,分布式技术带来的技术挑战是对同一个资源的并发访问,如全局ID、减库存、秒杀等场景,并发量不大的场景可以使用数据库的悲观锁、乐观锁来实现,但在并发量高的场合中,利用数据库锁来控制资源的并发访问是不太理想的,大大影响了数据库的性能。可以利用Redis的setnx功能来编写分布式的锁,如果设置返回1说明获取锁成功,否则获取锁失败,实际应用中要考虑的细节要更多。
- 社交网络
点赞、踩、关注/被关注、共同好友等是社交网站的基本功能,社交网站的访问量通常来说比较大,而且传统的关系数据库类型不适合存储这种类型的数据,Redis提供的哈希、集合等数据结构能很方便的的实现这些功能。如在微博中的共同好友,通过Redis的set能够很方便得出。
- 最新列表
Redis列表结构,LPUSH可以在列表头部插入一个内容ID作为关键字,LTRIM可用来限制列表的数量,这样列表永远为N个ID,无需查询最新的列表,直接根据ID去到对应的内容页即可。
- 消息系统
消息队列是大型网站必用中间件,如ActiveMQ、RabbitMQ、Kafka等流行的消息队列中间件,主要用于业务解耦、流量削峰及异步处理实时性低的业务。Redis提供了发布/订阅及阻塞队列功能,能实现一个简单的消息队列系统。另外,这个不能和专业的消息中间件相比。
为什么 Redis 单线程模型效率也能那么高
- C语言实现,效率高
- 纯内存操作
- 基于非阻塞的IO复用模型机制
- 单线程的话就能避免多线程的频繁上下文切换问题
- 丰富的数据结构(全程采用hash结构,读取速度非常快,对数据存储进行了一些优化,比如亚索表,跳表等)
说说 Redis 的线程模型
Redis 内部使用文件事件处理器 file event handler ,这个文件事件处理器是单线程的,所以Redis 才叫做单线程的模型。它采用 IO 多路复用机制同时监听多个 socket ,根据 socket 上的事件来选择对应的事件处理器进行处理。
文件事件处理器的结构包含 4 个部分:
- 多个 socket;
- IO 多路复用程序;
- 文件事件分派器;
- 事件处理器(连接应答处理器、命令请求处理器、命令回复处理器)。
多个 socket 可能会并发产生不同的操作,每个操作对应不同的文件事件,但是 IO 多路复用程序会监听多个 socket,会将 socket 产生的事件放入队列中排队,事件分派器每次从队列中取出一个事件,把该事件交给对应的事件处理器进行处理。
来看客户端与 Redis 的一次通信过程:
下面来大致说一下这个图:
- 客户端 Socket01 向 Redis 的 Server Socket 请求建立连接,此时 Server Socket 会产生一个AE_READABLE 事件,IO 多路复用程序监听到 server socket 产生的事件后,将该事件压入队列中。文件事件分派器从队列中获取该事件,交给连接应答处理器。连接应答处理器会创建一个能与客户端通信的 Socket01,并将该 Socket01 的 AE_READABLE 事件与命令请求处理器关联。
- 假设此时客户端发送了一个 set key value 请求,此时 Redis 中的 Socket01 会产生AE_READABLE 事件,IO 多路复用程序将事件压入队列,此时事件分派器从队列中获取到该事件,由于前面 Socket01 的 AE_READABLE 事件已经与命令请求处理器关联,因此事件分派器将事件交给命令请求处理器来处理。命令请求处理器读取 Socket01 的 set key value 并在自己内存中完成 set key value 的设置。操作完成后,它会将 Socket01 的 AE_WRITABLE 事件与令回复处理器关联。
- 如果此时客户端准备好接收返回结果了,那么 Redis 中的 Socket01 会产生一个AE_WRITABLE 事件,同样压入队列中,事件分派器找到相关联的命令回复处理器,由命令回复处理器对 Socket01 输入本次操作的一个结果,比如 ok ,之后解除 Socket01 的AE_WRITABLE 事件与命令回复处理器的关联。
这样便完成了一次通信。 不要怕这段文字,结合图看,一遍不行两遍,实在不行可以网上查点资料结合着看,一定要搞清楚,否则前面吹的牛逼就白费了。
为什么 Redis 需要把所有数据放到内存中
Redis 将数据放在内存中有一个好处,那就是可以实现最快的对数据读取,如果数据存储在硬盘中,磁盘 I/O 会严重影响 Redis 的性能。而且 Redis 还提供了数据持久化功能,不用担心服务器重启对内存中数据的影响。其次现在硬件越来越便宜的情况下,Redis 的使用也被应用得越来越多,使得它拥有很大的优势。
Redis 的同步机制了解是什么
Redis 支持主从同步、从从同步。如果是第一次进行主从同步,主节点需要使用 bgsave 命令,再将后续修改操作记录到内存的缓冲区,等 RDB 文件全部同步到复制节点,复制节点接受完成后将RDB 镜像记载到内存中。等加载完成后,复制节点通知主节点将复制期间修改的操作记录同步到复制节点,即可完成同步过程。
pipeline 有什么好处,为什么要用 pipeline
使用 pipeline(管道)的好处在于可以将多次 I/O 往返的时间缩短为一次,但是要求管道中执行的指令间没有因果关系。
用 pipeline 的原因在于可以实现请求/响应服务器的功能,当客户端尚未读取旧响应时,它也可以处理新的请求。如果客户端存在多个命令发送到服务器时,那么客户端无需等待服务端的每次响应才能执行下个命令,只需最后一步从服务端读取回复即可。
说一下 Redis 有什么优点和缺点
优点
- 速度快:因为数据存在内存中,类似于 HashMap , HashMap 的优势就是查找和操作的时间复杂度都是O(1) 。
- 支持丰富的数据结构:支持 String ,List,Set,Sorted Set,Hash 五种基础的数据结构。
- 持久化存储:Redis 提供 RDB 和 AOF 两种数据的持久化存储方案,解决内存数据库最担心的万一 Redis 挂掉,数据会消失掉。
- 高可用:内置 Redis Sentinel ,提供高可用方案,实现主从故障自动转移。 内置 Redis Cluster ,提供集群方案,实现基于槽的分片方案,从而支持更大的 Redis 规模。
- 丰富的特性:Key过期、计数、分布式锁、消息队列等。
缺点
- 由于 Redis 是内存数据库,所以,单台机器,存储的数据量,跟机器本身的内存大小。虽然Redis 本身有 Key 过期策略,但是还是需要提前预估和节约内存。如果内存增长过快,需要定期删除数据。
- 如果进行完整重同步,由于需要生成 RDB 文件,并进行传输,会占用主机的 CPU ,并会消耗现网的带宽。不过 Redis 2.8 版本,已经有部分重同步的功能,但是还是有可能有完整重同步的。比如,新上线的备机。
- 修改配置文件,进行重启,将硬盘中的数据加载进内存,时间比较久。在这个过程中, Redis不能提供服务。
Redis 缓存刷新策略有哪些
Redis 持久化方式有哪些?以及有什么区别
Redis 提供两种持久化机制 RDB 和 AOF 机制。
RDB 持久化方式
是指用数据集快照的方式半持久化模式)记录 redis 数据库的所有键值对,在某个时间点将数据写入一个临时文件,持久化结束后,用这个临时文件替换上次持久化的文件,达到数据恢复。
优点
- 只有一个文件 dump.rdb ,方便持久化。
- 容灾性好,一个文件可以保存到安全的磁盘。
- 性能最大化,fork 子进程来完成写操作,让主进程继续处理命令,所以是 IO 最大化。使用单独子进程来进行持久化,主进程不会进行任何 IO 操作,保证了 Redis 的高性能)。
- 相对于数据集大时,比 AOF 的启动效率更高。
缺点
数据安全性低。 RDB 是间隔一段时间进行持久化,如果持久化之间 Redis 发生故障,会发生数据丢失。所以这种方式更适合数据要求不严谨的时候。
AOF(Append-only file) 持久化方式
以日志的形式来记录每个写操作,将Redis执行过的所有写指令记录下来(读操作不记录),只许追加文件但不可以改写文件,Redis启动之初会读取该文件重新构建数据,换言之,Redis重启的话就根据日志文件的内容将写指令从前到后执行一次以完成数据的恢复工作。
AOF采用文件追加方式,文件会越来越大,为避免出现此种情况,新增了重写机制,当AOF文件的大小超过所设定的阈值时, Redis就会启动AOF文件的内容压缩,只保留可以恢复数据的最小指令集。
优点
- 数据安全, AOF 持久化可以配置 appendfsync 属性,有 always,每进行一次命令操作就记录到 AOF 文件中一次。
- 每修改同步:
appendfsync always
同步持久化,每次发生数据变更会被立即记录到磁盘,性能较差但数据完整性比较好 - 每秒同步:
appendfsync everysec
异步操作,每秒记录,如果一秒内宕机,有数据丢失 - 不同步:
appendfsync no
从不同步
- 每修改同步:
- 通过 append 模式写文件,即使中途服务器宕机,可以通过 redis-check-aof 工具解决数据一致性问题。
- AOF 机制的 rewrite 模式。 AOF 文件没被 rewrite 之前(文件过大时会对命令进行合并重写),可以删除其中的某些命令(比如误操作的flushall)。
缺点
- 相同数据集的数据而言,AOF 文件比 RDB 文件大,且恢复速度慢。
- 数据集大的时候,比 RDB 启动效率低。
如何选择合适的持久化方式
- 如果是数据不那么敏感,且可以从其他地方重新生成补回的,那么可以关闭持久化。
- 如果是数据比较重要,不想再从其他地方获取,且可以承受数分钟的数据丢失,比如缓存等,那么可以只使用RDB。
- 如果是用做内存数据库,要使用Redis的持久化,建议是RDB和AOF都开启,或者定期执行bgsave做快照备份,RDB方式更适合做数据的备份,AOF可以保证数据的不丢失。
- 不要仅仅使用RDB,因为那样会导致你丢失很多数据。
- 也不要仅仅使用AOF,因为那样有两个问题
- 第一,你通过AOF做冷备没有RDB做冷备的恢复速度更快;
- 第二,RDB每次简单粗暴生成数据快照,更加健壮,可以避免AOF这种复杂的备份和恢复机制的bug。
- Redis支持同时开启开启两种持久化方式,我们可以综合使用AOF和RDB两种持久化机制,用AOF来保证数据不丢失,作为数据恢复的第一选择; 用RDB来做不同程度的冷备,在 AOF文件都丢失或损坏不可用的时候,还可以使用RDB来进行快速的数据恢复。
- 如果同时使用RDB和AOF两种持久化机制,那么在Redis 启的时候,会使用AOF来重新构建数据,因为AOF中的数据更加完整。
补充:Redis4.0 对于持久化机制的优化
Redis4.0相对与3.X版本其中一个比较大的变化是4.0添加了新的混合持久化方式。
简单的说:新的AOF文件前半段是RDB格式的全量数据后半段是AOF格式的增量数据,如下图:
优势: 混合持久化结合了RDB持久化 和 AOF 持久化的优点, 由于绝大部分都是RDB格式,加载速度快,同时结合AOF,增量的数据以AOF方式保存了,数据更少的丢失。
劣势: 兼容性差,一旦开启了混合持久化,在4.0之前版本都不识别该aof文件,同时由于前部分是RDB格式,阅读性较差。
Redis持久化数据和缓存怎么做扩容?
如果Redis被当做缓存使用,使用一致性哈希实现动态扩容缩容。
如果Redis被当做一个持久化存储使用,必须使用固定的keys-to-nodes映射关系,节点的数量一旦确定不能变化。否则的话(即Redis节点需要动态变化的情况),必须使用可以在运行时进行数据再平衡的一套系统,而当前只有Redis集群可以做到这样。
Redis过期键的删除策略
Redis的过期删除策略就是:惰性删除和定期删除两种策略配合使用。
惰性删除: 惰性删除不会去主动删除数据,而是在访问数据的时候,再检查当前键值是否过期,如果过期则执行删除并返回 null 给客户端,如果没有过期则返回正常信息给客户端。它的优点是简单,不需要对过期的数据做额外的处理,只有在每次访问的时候才会检查键值是否过期,缺点是删除过期键不及时,造成了一定的空间浪费。
定期删除: Redis会周期性的随机测试一批设置了过期时间的key并进行处理。测试到的已过期的key将被删除。
附:删除key常见的三种处理方式。
- 定时删除
在设置某个key 的过期时间同时,我们创建一个定时器,让定时器在该过期时间到来时,立即执行对其进行删除的操作。
优点:定时删除对内存是最友好的,能够保存内存的key一旦过期就能立即从内存中删除。
缺点:对CPU最不友好,在过期键比较多的时候,删除过期键会占用一部分 CPU 时间,对服务器的响应时间和吞吐量造成影响。
- 惰性删除
设置该key 过期时间后,我们不去管它,当需要该key时,我们在检查其是否过期,如果过期,我们就删掉它,反之返回该key。
优点:对 CPU友好,我们只会在使用该键时才会进行过期检查,对于很多用不到的key不用浪费时间进行过期检查。
缺点:对内存不友好,如果一个键已经过期,但是一直没有使用,那么该键就会一直存在内存中,如果数据库中有很多这种使用不到的过期键,这些键便永远不会被删除,内存永远不会释放。从而造成内存泄漏。
- 定期删除
每隔一段时间,我们就对一些key进行检查,删除里面过期的key。
优点:可以通过限制删除操作执行的时长和频率来减少删除操作对 CPU 的影响。另外定期删除,也能有效释放过期键占用的内存。
缺点:难以确定删除操作执行的时长和频率。如果执行的太频繁,定期删除策略变得和定时删除策略一样,对CPU不友好。如果执行的太少,那又和惰性删除一样了,过期键占用的内存不会及时得到释放。另外最重要的是,在获取某个键时,如果某个键的过期时间已经到了,但是还没执行定期删除,那么就会返回这个键的值,这是业务不能忍受的错误。
Redis内存淘汰策略
Redis是不断的删除一些过期数据,但是很多没有设置过期时间的数据也会越来越多,那么Redis内存不够用的时候是怎么处理的呢?答案就是淘汰策略。此类的
当Redis的内存超过最大允许的内存之后,Redis会触发内存淘汰策略,删除一些不常用的数据,以保证Redis服务器的正常运行。
Redisv4.0前提供 6种数据淘汰策略
- volatile-lru:利用LRU算法移除设置过过期时间的key (LRU:最近使用 Least Recently Used )
- allkeys-lru:当内存不足以容纳新写入数据时,在键空间中,移除最近最少使用的key(这个是最常用的)
- volatile-ttl:从已设置过期时间的数据集(server.db[i].expires)中挑选将要过期的数据淘汰
- volatile-random:从已设置过期时间的数据集(server.db[i].expires)中任意选择数据淘汰
- allkeys-random:从数据集(server.db[i].dict)中任意选择数据淘汰
- no-eviction:禁止驱逐数据,也就是说当内存不足以容纳新写入数据时,新写入操作会报错。这个应该没人使用吧!
Redisv4.0后增加以下两种
- volatile-lfu:从已设置过期时间的数据集(server.db[i].expires)中挑选最不经常使用的数据淘汰(LFU(Least Frequently Used)算法,也就是最频繁被访问的数据将来最有可能被访问到)
- allkeys-lfu:当内存不足以容纳新写入数据时,在键空间中,移除最不经常使用的key。
内存淘汰策略可以通过配置文件来修改,Redis.conf对应的配置项是maxmemory-policy 修改对应的值就行,默认是noeviction。
如何保证缓存与数据库双写时的数据一致性?
背景:使用到缓存,无论是本地内存做缓存还是使用 Redis 做缓存,那么就会存在数据同步的问题,因为配置信息缓存在内存中,而内存时无法感知到数据在数据库的修改。这样就会造成数据库中的数据与缓存中数据不一致的问题。
共有四种方案:
- 先更新数据库,后更新缓存
- 先更新缓存,后更新数据库
- 先删除缓存,后更新数据库
- 先更新数据库,后删除缓存
第一种和第二种方案,没有人使用的,因为第一种方案存在问题是:并发更新数据库场景下,会将脏数据刷到缓存。
第二种方案存在的问题是:如果先更新缓存成功,但是数据库更新失败,则肯定会造成数据不一致。
目前主要用第三和第四种方案。
先删除缓存,后更新数据库
该方案也会出问题,此时来了两个请求,请求 A(更新操作) 和请求 B(查询操作)
- 请求A进行写操作,删除缓存
- 请求B查询发现缓存不存在
- 请求B去数据库查询得到旧值
- 请求B将旧值写入缓存
- 请求A将新值写入数据库
上述情况就会导致不一致的情形出现。而且,如果不采用给缓存设置过期时间策略,该数据永远都是脏数据。
答案一:延时双删
最简单的解决办法延时双删
使用伪代码如下:
public void write(String key,Object data){
Redis.delKey(key);
db.updateData(data);
Thread.sleep(1000);
Redis.delKey(key);
}
转化为中文描述如下:
- 先淘汰缓存
- 再写数据库(这两步和原来一样)
- 休眠1秒,再次淘汰缓存,这么做,可以将1秒内所造成的缓存脏数据,再次删除。确保读请求结束,写请求可以删除读请求造成的缓存脏数据。自行评估自己的项目的读数据业务逻辑的耗时,写数据的休眠时间则在读数据业务逻辑的耗时基础上,加几百ms即可。
如果使用的是 Mysql 的读写分离的架构的话,那么其实主从同步之间也会有时间差。
此时来了两个请求,请求 A(更新操作) 和请求 B(查询操作)
- 请求 A 更新操作,删除了 Redis
- 请求主库进行更新操作,主库与从库进行同步数据的操作
- 请 B 查询操作,发现 Redis 中没有数据
- 去从库中拿去数据
- 此时同步数据还未完成,拿到的数据是旧数据
此时的解决办法就是如果是对 Redis 进行填充数据的查询数据库操作,那么就强制将其指向主库进行查询。
答案二: 更新与读取操作进行异步串行化
采用更新与读取操作进行异步串行化
异步串行化
我在系统内部维护n个内存队列,更新数据的时候,根据数据的唯一标识,将该操作路由之后,发送到其中一个jvm内部的内存队列中(对同一数据的请求发送到同一个队列)。读取数据的时候,如果发现数据不在缓存中,并且此时队列里有更新库存的操作,那么将重新读取数据+更新缓存的操作,根据唯一标识路由之后,也将发送到同一个jvm内部的内存队列中。然后每个队列对应一个工作线程,每个工作线程串行地拿到对应的操作,然后一条一条的执行。
这样的话,一个数据变更的操作,先执行删除缓存,然后再去更新数据库,但是还没完成更新的时候,如果此时一个读请求过来,读到了空的缓存,那么可以先将缓存更新的请求发送到队列中,此时会在队列中积压,排在刚才更新库的操作之后,然后同步等待缓存更新完成,再读库。
读操作去重
多个读库更新缓存的请求串在同一个队列中是没意义的,因此可以做过滤,如果发现队列中已经有了该数据的更新缓存的请求了,那么就不用再放进去了,直接等待前面的更新操作请求完成即可,待那个队列对应的工作线程完成了上一个操作(数据库的修改)之后,才会去执行下一个操作(读库更新缓存),此时会从数据库中读取最新的值,然后写入缓存中。
如果请求还在等待时间范围内,不断轮询发现可以取到值了,那么就直接返回;如果请求等待的时间超过一定时长,那么这一次直接从数据库中读取当前的旧值。(返回旧值不是又导致缓存和数据库不一致了么?那至少可以减少这个情况发生,因为等待超时也不是每次都是,几率很小吧。这里我想的是,如果超时了就直接读旧值,这时候仅仅是读库后返回而不放缓存)
先更新数据库,后删除缓存
这一种情况也会出现问题,比如更新数据库成功了,但是在删除缓存的阶段出错了没有删除成功,那么此时再读取缓存的时候每次都是错误的数据了。
此时解决方案就是利用消息队列进行删除的补偿。具体的业务逻辑用语言描述如下:
- 请求 A 先对数据库进行更新操作
- 在对 Redis 进行删除操作的时候发现报错,删除失败
- 此时将Redis 的 key 作为消息体发送到消息队列中
- 系统接收到消息队列发送的消息后再次对 Redis 进行删除操作
但是这个方案会有一个缺点就是会对业务代码造成大量的侵入,深深的耦合在一起,所以这时会有一个优化的方案,我们知道对 Mysql 数据库更新操作后再 binlog 日志中我们都能够找到相应的操作,那么我们可以订阅 Mysql 数据库的 binlog 日志对缓存进行操作。
怎么使用 Redis 实现消息队列
一般使用 list 结构作为队列, rpush 生产消息, lpop 消费消息。当 lpop 没有消息的时候,要适当sleep 一会再重试。
- 面试官可能会问可不可以不用 sleep 呢?list 还有个指令叫 blpop ,在没有消息的时候,它会阻塞住直到消息到来。
- 面试官可能还问能不能生产一次消费多次呢?使用 pub / sub 主题订阅者模式,可以实现 1:N的消息队列。
- 面试官可能还问 pub / sub 有什么缺点?在消费者下线的情况下,生产的消息会丢失,得使用专业的消息队列如 rabbitmq 等。
- 面试官可能还问 Redis 如何实现延时队列?我估计现在你很想把面试官一棒打死如果你手上有一根棒球棍的话,怎么问的这么详细。但是你很克制,然后神态自若的回答道:使用sortedset ,拿时间戳作为 score ,消息内容作为 key 调用 zadd 来生产消息,消费者用zrangebyscore 指令获取 N 秒之前的数据轮询进行处理。
Redis事务的概念
Redis的事务并不是我们传统意义上理解的事务,我们都知道 单个 Redis 命令的执行是原子性的,但 Redis 没有在事务上增加任何维持原子性的机制,所以 Redis 事务的执行并不是原子性的。
事务可以理解为一个打包的批量执行脚本,但批量指令并非原子化的操作,中间某条指令的失败不会导致前面已做指令的回滚,也不会造成后续的指令不做。
总结:
Redis事务中如果有某一条命令执行失败,之前的命令不会回滚,其后的命令仍然会被继续执行。鉴于这个原因,所以说Redis的事务严格意义上来说是不具备原子性的。
Redis事务中所有命令都会序列化、按顺序地执行。事务在执行的过程中,不会被其他客户端发送来的命令请求所打断。
在事务开启之前,如果客户端与服务器之间出现通讯故障并导致网络断开,其后所有待执行的语句都将不会被服务器执行。然而如果网络中断事件是发生在客户端执行EXEC命令之后,那么该事务中的所有命令都会被服务器执行。
当使用Append-Only模式时,Redis会通过调用系统函数write将该事务内的所有写操作在本次调用中全部写入磁盘。然而如果在写入的过程中出现系统崩溃,如电源故障导致的宕机,那么此时也许只有部分数据被写入到磁盘,而另外一部分数据却已经丢失。Redis服务器会在重新启动时执行一系列必要的一致性检测,一旦发现类似问题,就会立即退出并给出相应的错误提示。此时,我们就要充分利用Redis工具包中提供的Redis-check-aof工具,该工具可以帮助我们定位到数据不一致的错误,并将已经写入的部分数据进行回滚。修复之后我们就可以再次重新启动Redis服务器了。
说说你对Redis事务的理解
什么是 Redis 事务?原理是什么?
Redis 中的事务是一组命令的集合,是 Redis 的最小执行单位。它可以保证一次执行多个命令,每个事务是一个单独的隔离操作,事务中的所有命令都会序列化、按顺序地执行。服务端在执行事务的过程中,不会被其他客户端发送来的命令请求打断。
它的原理是先将属于一个事务的命令发送给 Redis,然后依次执行这些命令。
Redis 事务的注意点有哪些?
- Redis 事务是不支持回滚的,不像 MySQL 的事务一样,要么都执行要么都不执行;
- Redis 服务端在执行事务的过程中,不会被其他客户端发送来的命令请求打断。直到事务命令全部执行完毕才会执行其他客户端的命令。
Redis事务支持隔离性吗?
Redis 是单进程程序,并且它保证在执行事务时,不会对事务进行中断,事务可以运行直到执行完所有事务队列中的命令为止。因此,Redis 的事务是总是带有隔离性的。
Redis 事务为什么不支持回滚?
- Redis 命令只会因为错误的语法而失败,或是命令用在了错误类型的键上面,这些问题不能在入队时发现,这也就是说,从实用性的角度来说,失败的命令是由编程错误造成的,而这些错误应该在开发的过程中被发现,而不应该出现在生产环境中.
- 因为不需要对回滚进行支持,所以 Redis 的内部可以保持简单且快速。
Redis事务命令和特点
Redis事务功能是通过MULTI、EXEC、DISCARD和WATCH 四个原语实现的 Redis会将一个事务中的所有命令序列化,然后按顺序执行。
- redis 不支持回滚“Redis 在事务失败时不进行回滚,而是继续执行余下的命令”, 所以 Redis 的内部可以保持简单且快速。
- 如果在一个事务中的命令出现错误,那么所有的命令都不会执行;
- 如果在一个事务中出现运行错误,那么正确的命令会被执行。
Redis事务执行流程
- MULTI命令用于开启一个事务,它总是返回OK。 MULTI执行之后,客户端可以继续向服务器发送任意多条命令,这些命令不会立即被执行,而是被放到一个队列中,当EXEC命令被调用时,所有队列中的命令才会被执行。
- EXEC:执行所有事务块内的命令。返回事务块内所有命令的返回值,按命令执行的先后顺序排列。 当操作被打断时,返回空值 nil 。
- 通过调用DISCARD,客户端可以清空事务队列,并放弃执行事务, 并且客户端会从事务状态中退出。
- WATCH 命令可以为 Redis 事务提供 check-and-set (CAS)行为。 可以监控一个或多个键,一旦其中有一个键被修改(或删除),之后的事务就不会执行,监控一直持续到EXEC命令。
事务执行过程中,如果服务端收到有EXEC、DISCARD、WATCH、MULTI之外的请求,将会把请求放入队列中排队。
Redis事务相关命令
Redis事务功能是通过MULTI、EXEC、DISCARD和WATCH 四个原语实现的
- WATCH 命令是一个乐观锁,可以为 Redis 事务提供 check-and-set (CAS)行为。 可以监控一个或多个键,一旦其中有一个键被修改(或删除),之后的事务就不会执行,监控一直持续到EXEC命令。
- MULTI命令用于开启一个事务,它总是返回OK。 MULTI执行之后,客户端可以继续向服务器发送任意多条命令,这些命令不会立即被执行,而是被放到一个队列中,当EXEC命令被调用时,所有队列中的命令才会被执行。
- EXEC:执行所有事务块内的命令。返回事务块内所有命令的返回值,按命令执行的先后顺序排列。 当操作被打断时,返回空值 nil 。
通过调用DISCARD,客户端可以清空事务队列,并放弃执行事务, 并且客户端会从事务状态中退出。 - UNWATCH命令可以取消watch对所有key的监控。
Redis事务其他实现
- 基于Lua脚本,Redis可以保证脚本内的命令一次性、按顺序地执行,
其同时也不提供事务运行错误的回滚,执行过程中如果部分命令运行错误,剩下的命令还是会继续运行完。 - 基于中间标记变量,通过另外的标记变量来标识事务是否执行完成,读取数据时先读取该标记变量判断是否事务执行完成。但这样会需要额外写代码实现,比较繁琐。
Redis为何选择单线程?
在Redis 6.0以前,Redis的核心网络模型选择用单线程来实现。先来看下官方的回答:
It’s not very frequent that CPU becomes your bottleneck with Redis, as usually Redisis either memory or network bound. For instance, using pipelining Redisrunning on an average Linux system can deliver even 1 million requests per second, so if your application mainly uses O(N) or O(log(N)) commands, it is hardly going to use too much CPU.
核心意思就是,对于一个 DB 来说,CPU 通常不会是瓶颈,因为大多数请求不会是 CPU 密集型的,而是 I/O 密集型。具体到 Redis的话,如果不考虑 RDB/AOF 等持久化方案,Redis是完全的纯内存操作,执行速度是非常快的,因此这部分操作通常不会是性能瓶颈,Redis真正的性能瓶颈在于网络 I/O,也就是客户端和服务端之间的网络传输延迟,因此 Redis选择了单线程的 I/O 多路复用来实现它的核心网络模型。
实际上更加具体的选择单线程的原因如下:
- 避免过多的上下文切换开销:如果是单线程则可以规避进程内频繁的线程切换开销,因为程序始终运行在进程中单个线程内,没有多线程切换的场景。
- 避免同步机制的开销:如果 Redis选择多线程模型,又因为 Redis是一个数据库,那么势必涉及到底层数据同步的问题,则必然会引入某些同步机制,比如锁,而我们知道 Redis不仅仅提供了简单的 key-value 数据结构,还有 list、set 和 hash 等等其他丰富的数据结构,而不同的数据结构对同步访问的加锁粒度又不尽相同,可能会导致在操作数据过程中带来很多加锁解锁的开销,增加程序复杂度的同时还会降低性能。
- 简单可维护:如果 Redis使用多线程模式,那么所有的底层数据结构都必须实现成线程安全的,这无疑又使得 Redis的实现变得更加复杂。
总而言之,Redis选择单线程可以说是多方博弈之后的一种权衡:在保证足够的性能表现之下,使用单线程保持代码的简单和可维护性。
Redis真的是单线程?
讨论 这个问题前,先看下 Redis的版本中两个重要的节点:
- Redisv4.0(引入多线程处理异步任务)
- Redis 6.0(在网络模型中实现多线程 I/O )
所以,网络上说的Redis是单线程,通常是指在Redis 6.0之前,其核心网络模型使用的是单线程。
且Redis6.0引入多线程I/O,只是用来处理网络数据的读写和协议的解析,而执行命令依旧是单线程。
Redis在 v4.0 版本的时候就已经引入了的多线程来做一些异步操作,此举主要针对的是那些非常耗时的命令,通过将这些命令的执行进行异步化,避免阻塞单线程的事件循环。
在 Redisv4.0 之后增加了一些的非阻塞命令如
UNLINK
、FLUSHALL ASYNC
、FLUSHDB ASYNC
。
Redis 6.0为何引入多线程?
很简单,就是 Redis的网络 I/O 瓶颈已经越来越明显了。
随着互联网的飞速发展,互联网业务系统所要处理的线上流量越来越大,Redis的单线程模式会导致系统消耗很多 CPU 时间在网络 I/O 上从而降低吞吐量,要提升 Redis的性能有两个方向:
- 优化网络 I/O 模块
- 提高机器内存读写的速度
后者依赖于硬件的发展,暂时无解。所以只能从前者下手,网络 I/O 的优化又可以分为两个方向:
- 零拷贝技术或者 DPDK 技术
- 利用多核优势
零拷贝技术有其局限性,无法完全适配 Redis这一类复杂的网络 I/O 场景,更多网络 I/O 对 CPU 时间的消耗和 Linux 零拷贝技术。而 DPDK 技术通过旁路网卡 I/O 绕过内核协议栈的方式又太过于复杂以及需要内核甚至是硬件的支持。
总结起来,Redis支持多线程主要就是两个原因:
可以充分利用服务器 CPU 资源,目前主线程只能利用一个核
多线程任务可以分摊 Redis 同步 IO 读写负荷
Redis 6.0 采用多线程后,性能的提升效果如何?
Redis 作者 antirez 在 RedisConf 2019 分享时曾提到:Redis 6 引入的多线程 IO 特性对性能提升至少是一倍以上。
国内也有大牛曾使用 unstable 版本在阿里云 esc 进行过测试,GET/SET 命令在 4 线程 IO 时性能相比单线程是几乎是翻倍了。
介绍下Redis的线程模型
Redis的线程模型包括Redis 6.0之前和Redis 6.0。
下面介绍的是Redis 6.0之前。
Redis 是基于 reactor 模式开发了网络事件处理器,这个处理器叫做文件事件处理器(file event handler)。由于这个文件事件处理器是单线程的,所以 Redis 才叫做单线程的模型。采用 IO 多路复用机制同时监听多个 Socket,根据 socket 上的事件来选择对应的事件处理器来处理这个事件。
IO多路复用是 IO 模型的一种,有时也称为异步阻塞 IO,是基于经典的 Reactor 设计模式设计的。多路指的是多个 Socket 连接,复用指的是复用一个线程。多路复用主要有三种技术:Select,Poll,Epoll。
Epoll 是最新的也是目前最好的多路复用技术。
模型如下图:
文件事件处理器的结构包含了四个部分:
- 多个 Socket。Socket 会产生 AE_READABLE 和 AE_WRITABLE 事件:
- 当 socket 变得可读时或者有新的可以应答的 socket 出现时,socket 就会产生一个 AE_READABLE 事件
- 当 socket 变得可写时,socket 就会产生一个 AE_WRITABLE 事件。
- IO 多路复用程序
- 文件事件分派器
- 事件处理器。事件处理器包括:连接应答处理器、命令请求处理器、命令回复处理器,每个处理器对应不同的 socket 事件:
- 如果是客户端要连接 Redis,那么会为 socket 关联连接应答处理器
- 如果是客户端要写数据到 Redis(读、写请求命令),那么会为 socket 关联命令请求处理器
- 如果是客户端要从 Redis 读数据,那么会为 socket 关联命令回复处理器
多个 socket 会产生不同的事件,不同的事件对应着不同的操作,IO 多路复用程序监听着这些 Socket,当这些 Socket 产生了事件,IO 多路复用程序会将这些事件放到一个队列中,通过这个队列,以有序、同步、每次一个事件的方式向文件时间分派器中传送。当事件处理器处理完一个事件后,IO 多路复用程序才会继续向文件分派器传送下一个事件。
下图是客户端与 Redis 通信的一次完整的流程:
- Redis 启动初始化的时候,Redis 会将连接应答处理器与 AE_READABLE 事件关联起来。
- 如果一个客户端跟 Redis 发起连接,此时 Redis 会产生一个 AE_READABLE 事件,由于开始之初 AE_READABLE 是与连接应答处理器关联,所以由连接应答处理器来处理该事件,这时连接应答处理器会与客户端建立连接,创建客户端响应的 socket,同时将这个 socket 的 AE_READABLE 事件与命令请求处理器关联起来。
- 如果这个时间客户端向 Redis 发送一个命令(set k1 v1),这时 socket 会产生一个 AE_READABLE 事件,IO 多路复用程序会将该事件压入队列中,此时事件分派器从队列中取得该事件,由于该 socket 的 AE_READABLE 事件已经和命令请求处理器关联了,因此事件分派器会将该事件交给命令请求处理器处理,命令请求处理器读取事件中的命令并完成。操作完成后,Redis 会将该 socket 的 AE_WRITABLE 事件与命令回复处理器关联。
- 如果客户端已经准备好接受数据后,Redis 中的该 socket 会产生一个 AE_WRITABLE 事件,同样会压入队列然后被事件派发器取出交给相对应的命令回复处理器,由该命令回复处理器将准备好的响应数据写入 socket 中,供客户端读取。
- 命令回复处理器写完后,就会删除该 socket 的 AE_WRITABLE 事件与命令回复处理器的关联关系。
Redis 6.0 多线程的实现机制?
流程简述如下:
- 主线程负责接收建立连接请求,获取 Socket 放入全局等待读处理队列。
- 主线程处理完读事件之后,通过 RR(Round Robin)将这些连接分配给这些 IO 线程。
- 主线程阻塞等待 IO 线程读取 Socket 完毕。
- 主线程通过单线程的方式执行请求命令,请求数据读取并解析完成,但并不执行。
- 主线程阻塞等待 IO 线程将数据回写 Socket 完毕。
该设计有如下特点:
- IO 线程要么同时在读 Socket,要么同时在写,不会同时读或写。
- IO 线程只负责读写 Socket 解析命令,不负责命令处理。
Redis 6.0开启多线程后,是否会存在线程并发安全问题?
从实现机制可以看出,Redis 的多线程部分只是用来处理网络数据的读写和协议解析,执行命令仍然是单线程顺序执行。
所以我们不需要去考虑控制 Key、Lua、事务,LPUSH/LPOP 等等的并发及线程安全问题。
Redis 6.0 与 Memcached 多线程模型的对比
相同点:都采用了 Master 线程 -Worker 线程的模型。
不同点:Memcached 执行主逻辑也是在 Worker 线程里,模型更加简单,实现了真正的线程隔离,符合我们对线程隔离的常规理解。
而 Redis 把处理逻辑交还给 Master 线程,虽然一定程度上增加了模型复杂度,但也解决了线程并发安全等问题。
什么是 bigkey?会存在什么影响?
bigkey 是指键值占用内存空间非常大的 key。例如一个字符串 a 存储了 200M 的数据。
bigkey 的主要影响有:
- 网络阻塞;获取 bigkey 时,传输的数据量比较大,会增加带宽的压力。
- 超时阻塞;因为 bigkey 占用的空间比较大,所以操作起来效率会比较低,导致出现阻塞的可能性增加。
- 导致内存空间不平衡;一个 bigkey 存储数据量比较大,同一个 key 在同一个节点或服务器中存储,会造成一定影响。
36. Redis常见使用方式有哪些?
Redis的几种常见使用方式包括:
- Redis单副本;
- Redis多副本(主从);
- Redis Sentinel(哨兵);
- Redis Cluster;
- Redis自研。
使用场景:
Redis单副本:如果数据量很少,主要是承载高并发高性能的场景,比如缓存一般就几个G的话,单机足够了。
主从模式:master 节点挂掉后,需要手动指定新的 master,可用性不高,基本不用。
哨兵模式:master 节点挂掉后,哨兵进程会主动选举新的 master,可用性高,但是每个节点存储的数据是一样的,浪费内存空间。数据量不是很多,集群规模不是很大,需要自动容错容灾的时候使用。
Redis cluster:主要是针对海量数据+高并发+高可用的场景,如果是海量数据,如果你的数据量很大,那么建议就用Redis cluster,所有master的容量总和就是Redis cluster可缓存的数据容量。
熟悉哪些 Redis 集群模式
Redis Sentinel
体量较小时,选择 Redis Sentinel ,单主 Redis 足以支撑业务。
Redis Cluster
Redis 官方提供的集群化方案,体量较大时,选择 Redis Cluster ,通过分片,使用更多内存。
Twemprox
Twemprox 是 Twtter 开源的一个 Redis 和 Memcached 代理服务器,主要用于管理 Redis 和Memcached 集群,减少与Cache 服务器直接连接的数量。
Codis
Codis 是一个代理中间件,当客户端向 Codis 发送指令时, Codis 负责将指令转发到后面的Redis 来执行,并将结果返回给客户端。一个 Codis 实例可以连接多个 Redis 实例,也可以启动多个 Codis 实例来支撑,每个 Codis 节点都是对等的,这样可以增加整体的 QPS 需求,还能起到容灾功能。
客户端分片
在 Redis Cluster 还没出现之前使用较多,现在基本很少热你使用了,在业务代码层实现,起几个毫无关联的 Redis 实例,在代码层,对 Key 进行 hash 计算,然后去对应的 Redis 实例操作数据。这种方式对 hash 层代码要求比较高,考虑部分包括,节点失效后的替代算法方案,数据震荡后的自动脚本恢复,实例的监控,等等。
是否使用过 Redis Cluster 集群,集群的原理是什么
使用过 Redis 集群,它的原理是:
- 所有的节点相互连接;
- 集群消息通信通过集群总线通信,集群总线端口大小为客户端服务端口 + 10000(固定值);
- 节点与节点之间通过二进制协议进行通信;
- 客户端和集群节点之间通信和通常一样,通过文本协议进行;
- 集群节点不会代理查询;
- 数据按照 Slot 存储分布在多个 Redis 实例上;
- 集群节点挂掉会自动故障转移;
- 可以相对平滑扩/缩容节点。
Redis 集群中内置了 16384 个哈希槽,当需要在 Redis 集群中放置一个 key-value 时,redis 先对key 使用 crc16 算法算出一个结果,然后把结果对 16384 求余数,这样每个 key 都会对应一个编号在 0~16383 之间的哈希槽,redis 会根据节点数量大致均等的将哈希槽映射到不同的节点。
介绍下Redis单副本
Redis单副本,采用单个Redis节点部署架构,没有备用节点实时同步数据,不提供数据持久化和备份策略,适用于数据可靠性要求不高的纯缓存业务场景。
优点:
- 架构简单,部署方便;
- 高性价比:缓存使用时无需备用节点(单实例可用性可以用supervisor或crontab保证),当然为了满足业务的高可用性,也可以牺牲一个备用节点,但同时刻只有一个实例对外提供服务;
- 高性能。
缺点:
- 不保证数据的可靠性;
- 在缓存使用,进程重启后,数据丢失,即使有备用的节点解决高可用性,但是仍然不能解决缓存预热问题,因此不适用于数据可靠性要求高的业务;
- 高性能受限于单核CPU的处理能力(Redis是单线程机制),CPU为主要瓶颈,所以适合操作命令简单,排序、计算较少的场景。也可以考虑用Memcached替代。
介绍下Redis多副本(主从)
Redis多副本,采用主从(replication)部署结构,相较于单副本而言最大的特点就是主从实例间数据实时同步,并且提供数据持久化和备份策略。主从实例部署在不同的物理服务器上,根据公司的基础环境配置,可以实现同时对外提供服务和读写分离策略。
优点:
- 高可靠性:一方面,采用双机主备架构,能够在主库出现故障时自动进行主备切换,从库提升为主库提供服务,保证服务平稳运行;另一方面,开启数据持久化功能和配置合理的备份策略,能有效的解决数据误操作和数据异常丢失的问题;
- 读写分离策略:从节点可以扩展主库节点的读能力,有效应对大并发量的读操作。
缺点:
故障恢复复杂,如果没有RedisHA系统(需要开发),当主库节点出现故障时,需要手动将一个从节点晋升为主节点,同时需要通知业务方变更配置,并且需要让其它从库节点去复制新主库节点,整个过程需要人为干预,比较繁琐;
主库的写能力受到单机的限制,可以考虑分片;
主库的存储能力受到单机的限制,可以考虑Pika;
原生复制的弊端在早期的版本中也会比较突出,如:Redis复制中断后,Slave会发起psync,此时如果同步不成功,则会进行全量同步,主库执行全量备份的同时可能会造成毫秒或秒级的卡顿;又由于COW机制,导致极端情况下的主库内存溢出,程序异常退出或宕机;主库节点生成备份文件导致服务器磁盘IO和CPU(压缩)资源消耗;发送数GB大小的备份文件导致服务器出口带宽暴增,阻塞请求,建议升级到最新版本。
介绍下Redis Sentinel(哨兵)
主从模式下,当主服务器宕机后,需要手动把一台从服务器切换为主服务器,这就需要人工干预,费事费力,还会造成一段时间内服务不可用。这种方式并不推荐,实际生产中,我们优先考虑哨兵模式。这种模式下,master 宕机,哨兵会自动选举 master 并将其他的 slave 指向新的 master。
Redis Sentinel是社区版本推出的原生高可用解决方案,其部署架构主要包括两部分:Redis Sentinel集群和Redis数据集群。
其中Redis Sentinel集群是由若干Sentinel节点组成的分布式集群,可以实现故障发现、故障自动转移、配置中心和客户端通知。Redis Sentinel的节点数量要满足2n+1(n>=1)的奇数个。
优点:
- Redis Sentinel集群部署简单;
- 能够解决Redis主从模式下的高可用切换问题;
- 很方便实现Redis数据节点的线形扩展,轻松突破Redis自身单线程瓶颈,可极大满足Redis大容量或高性能的业务需求;
- 可以实现一套Sentinel监控一组Redis数据节点或多组数据节点。
缺点:
- 部署相对Redis主从模式要复杂一些,原理理解更繁琐;
- 资源浪费,Redis数据节点中slave节点作为备份节点不提供服务;
- Redis Sentinel主要是针对Redis数据节点中的主节点的高可用切换,对Redis的数据节点做失败判定分为主观下线和客观下线两种,对于Redis的从节点有对节点做主观下线操作,并不执行故障转移。
- 不能解决读写分离问题,实现起来相对复杂。
介绍下Redis Cluster
Redis 的哨兵模式基本已经可以实现高可用,读写分离 ,但是在这种模式下每台 Redis 服务器都存储相同的数据,很浪费内存,所以在 Redis3.0 上加入了 Cluster 集群模式,实现了 Redis 的分布式存储,对数据进行分片,也就是说每台 Redis 节点上存储不同的内容。
Redis Cluster是社区版推出的Redis分布式集群解决方案,主要解决Redis分布式方面的需求,比如,当遇到单机内存,并发和流量等瓶颈的时候,Redis Cluster能起到很好的负载均衡的目的。
Redis Cluster集群节点最小配置6个节点以上(3主3从),其中主节点提供读写操作,从节点作为备用节点,不提供请求,只作为故障转移使用。
Redis Cluster采用虚拟槽分区,所有的键根据哈希函数映射到0~16383个整数槽内,每个节点负责维护一部分槽以及槽所印映射的键值数据。
优点:
- 无中心架构;
- 数据按照slot存储分布在多个节点,节点间数据共享,可动态调整数据分布;
- 可扩展性:可线性扩展到1000多个节点,节点可动态添加或删除;
- 高可用性:部分节点不可用时,集群仍可用。通过增加Slave做standby数据副本,能够实现故障自动failover,节点之间通过gossip协议交换状态信息,用投票机制完成Slave到Master的角色提升;
- 降低运维成本,提高系统的扩展性和可用性。
缺点:
- Client实现复杂,驱动要求实现Smart Client,缓存slots mapping信息并及时更新,提高了开发难度,客户端的不成熟影响业务的稳定性。目前仅JedisCluster相对成熟,异常处理部分还不完善,比如常见的“max redirect exception”。
- 节点会因为某些原因发生阻塞(阻塞时间大于clutser-node-timeout),被判断下线,这种failover是没有必要的。
- 数据通过异步复制,不保证数据的强一致性。
- 多个业务使用同一套集群时,无法根据统计区分冷热数据,资源隔离性较差,容易出现相互影响的情况。
- Slave在集群中充当“冷备”,不能缓解读压力,当然可以通过SDK的合理设计来提高Slave资源的利用率。
- Key批量操作限制,如使用mset、mget目前只支持具有相同slot值的Key执行批量操作。对于映射为不同slot值的Key由于Keys不支持跨slot查询,所以执行mset、mget、sunion等操作支持不友好。
- Key事务操作支持有限,只支持多key在同一节点上的事务操作,当多个Key分布于不同的节点上时无法使用事务功能。
- Key作为数据分区的最小粒度,不能将一个很大的键值对象如hash、list等映射到不同的节点。
- 不支持多数据库空间,单机下的Redis可以支持到16个数据库,集群模式下只能使用1个数据库空间,即db 0。
- 复制结构只支持一层,从节点只能复制主节点,不支持嵌套树状复制结构。
- 避免产生hot-key,导致主库节点成为系统的短板。
- 避免产生big-key,导致网卡撑爆、慢查询等。
- 重试时间应该大于cluster-node-time时间。
- Redis Cluster不建议使用pipeline和multi-keys操作,减少max redirect产生的场景。
介绍下Redis自研
Redis自研的高可用解决方案,主要体现在配置中心、故障探测和failover的处理机制上,通常需要根据企业业务的实际线上环境来定制化。
优点:
- 高可靠性、高可用性;
- 自主可控性高;
- 贴切业务实际需求,可缩性好,兼容性好。
缺点:
- 实现复杂,开发成本高;
- 需要建立配套的周边设施,如监控,域名服务,存储元数据信息的数据库等;
- 维护成本高。
Redis高可用方案具体怎么实施?
使用官方推荐的哨兵(sentinel)机制就能实现,当主节点出现故障时,由Sentinel自动完成故障发现和转移,并通知应用方,实现高可用性。它有四个主要功能:
- 集群监控,负责监控Redis master和slave进程是否正常工作。
- 消息通知,如果某个Redis实例有故障,那么哨兵负责发送消息作为报警通知给管理员。
- 故障转移,如果master node挂掉了,会自动转移到slave node上。
- 配置中心,如果故障转移发生了,通知client客户端新的master地址。
了解主从复制的原理吗?
- 主从架构的核心原理
当启动一个slave node的时候,它会发送一个PSYNC命令给master node
如果这是slave node重新连接master node,那么master node仅仅会复制给slave部分缺少的数据; 否则如果是slave node第一次连接master node,那么会触发一次full resynchronization
开始full resynchronization的时候,master会启动一个后台线程,开始生成一份RDB快照文件,同时还会将从客户端收到的所有写命令缓存在内存中。RDB文件生成完毕之后,master会将这个RDB发送给slave,slave会先写入本地磁盘,然后再从本地磁盘加载到内存中。然后master会将内存中缓存的写命令发送给slave,slave也会同步这些数据。
slave node如果跟master node有网络故障,断开了连接,会自动重连。master如果发现有多个slave node都来重新连接,仅仅会启动一个rdb save操作,用一份数据服务所有slave node。
- 主从复制的断点续传
从Redis 2.8开始,就支持主从复制的断点续传,如果主从复制过程中,网络连接断掉了,那么可以接着上次复制的地方,继续复制下去,而不是从头开始复制一份
master node会在内存中常见一个backlog,master和slave都会保存一个replica offset还有一个master id,offset就是保存在backlog中的。如果master和slave网络连接断掉了,slave会让master从上次的replica offset开始继续复制
但是如果没有找到对应的offset,那么就会执行一次resynchronization
- 无磁盘化复制
master在内存中直接创建rdb,然后发送给slave,不会在自己本地落地磁盘了
repl-diskless-sync repl-diskless-sync-delay,等待一定时长再开始复制,因为要等更多slave重新连接过来
- 过期key处理
slave不会过期key,只会等待master过期key。如果master过期了一个key,或者通过LRU淘汰了一个key,那么会模拟一条del命令发送给slave。
由于主从延迟导致读取到过期数据怎么处理?
- 通过scan命令扫库:当Redis中的key被scan的时候,相当于访问了该key,同样也会做过期检测,充分发挥Redis惰性删除的策略。这个方法能大大降低了脏数据读取的概率,但缺点也比较明显,会造成一定的数据库压力,否则影响线上业务的效率。
- Redis加入了一个新特性来解决主从不一致导致读取到过期数据问题,增加了key是否过期以及对主从库的判断,如果key已过期,当前访问的master则返回null;当前访问的是从库,且执行的是只读命令也返回null。
主从复制的过程中如果因为网络原因停止复制了会怎么样?
如果出现网络故障断开连接了,会自动重连的,从Redis 2.8开始,就支持主从复制的断点续传,可以接着上次复制的地方,继续复制下去,而不是从头开始复制一份。
master如果发现有多个slave node都来重新连接,仅仅会启动一个rdb save操作,用一份数据服务所有slave node。
master node会在内存中创建一个backlog
,master和slave都会保存一个replica offset
,还有一个master id
,offset就是保存在backlog中的。如果master和slave网络连接断掉了,slave会让master从上次的replica offset开始继续复制。
但是如果没有找到对应的offset,那么就会执行一次resynchronization
全量复制。
Redis主从架构数据会丢失吗,为什么?
有两种数据丢失的情况:
- 异步复制导致的数据丢失:因为master -> slave的复制是异步的,所以可能有部分数据还没复制到slave,master就宕机了,此时这些部分数据就丢失了。
- 脑裂导致的数据丢失:某个master所在机器突然脱离了正常的网络,跟其他slave机器不能连接,但是实际上master还运行着,此时哨兵可能就会认为master宕机了,然后开启选举,将其他slave切换成了master。这个时候,集群里就会有两个master,也就是所谓的脑裂。此时虽然某个slave被切换成了master,但是可能client还没来得及切换到新的master,还继续写向旧master的数据可能也丢失了。因此旧master再次恢复的时候,会被作为一个slave挂到新的master上去,自己的数据会清空,重新从新的master复制数据。
如何解决主从架构数据丢失的问题?
数据丢失的问题是不可避免的,但是我们可以尽量减少。
在Redis的配置文件里设置参数
min-slaves-to-write 1
min-slaves-max-lag 10
min-slaves-to-write
默认情况下是0,min-slaves-max-lag
默认情况下是10。
上面的配置的意思是要求至少有1个slave,数据复制和同步的延迟不能超过10秒。如果说一旦所有的slave,数据复制和同步的延迟都超过了10秒钟,那么这个时候,master就不会再接收任何请求了。
减小min-slaves-max-lag
参数的值,这样就可以避免在发生故障时大量的数据丢失,一旦发现延迟超过了该值就不会往master中写入数据。
那么对于client,我们可以采取降级措施,将数据暂时写入本地缓存和磁盘中,在一段时间后重新写入master来保证数据不丢失;也可以将数据写入kafka消息队列,隔一段时间去消费kafka中的数据。
Redis哨兵是怎么工作的?
每个Sentinel以每秒钟一次的频率向它所知的Master,Slave以及其他 Sentinel 实例发送一个 PING 命令。
如果一个实例(instance)距离最后一次有效回复 PING 命令的时间超过 down-after-milliseconds 选项所指定的值, 则这个实例会被当前 Sentinel 标记为主观下线。
如果一个Master被标记为主观下线,则正在监视这个Master的所有 Sentinel 要以每秒一次的频率确认Master的确进入了主观下线状态。
当有足够数量的 Sentinel(大于等于配置文件指定的值)在指定的时间范围内确认Master的确进入了主观下线状态, 则Master会被标记为客观下线 。
当Master被 Sentinel 标记为客观下线时,Sentinel 向下线的 Master 的所有 Slave 发送 INFO 命令的频率会从 10 秒一次改为每秒一次 (在一般情况下, 每个 Sentinel 会以每 10 秒一次的频率向它已知的所有Master,Slave发送 INFO 命令 )。
若没有足够数量的 Sentinel 同意 Master 已经下线, Master 的客观下线状态就会变成主观下线。若 Master 重新向 Sentinel 的 PING 命令返回有效回复, Master 的主观下线状态就会被移除。
sentinel节点会与其他sentinel节点进行“沟通”,投票选举一个sentinel节点进行故障处理,在从节点中选取一个主节点,其他从节点挂载到新的主节点上自动复制新主节点的数据。
故障转移时会从剩下的slave选举一个新的master,被选举为master的标准是什么?
如果一个master被认为odown了,而且majority哨兵都允许了主备切换,那么某个哨兵就会执行主备切换操作,此时首先要选举一个slave来,会考虑slave的一些信息。
- 跟master断开连接的时长。
如果一个slave跟master断开连接已经超过了down-after-milliseconds的10倍,外加master宕机的时长,那么slave就被认为不适合选举为master.
( down-after-milliseconds * 10) + milliseconds_since_master_is_in_SDOWN_state
slave优先级。
按照slave优先级进行排序,slave priority越低,优先级就越高复制offset。
如果slave priority相同,那么看replica offset,哪个slave复制了越多的数据,offset越靠后,优先级就越高run id
如果上面两个条件都相同,那么选择一个run id比较小的那个slave。
同步配置的时候其他哨兵根据什么更新自己的配置呢?
执行切换的那个哨兵,会从要切换到的新master(salve->master)那里得到一个configuration epoch,这就是一个version号,每次切换的version号都必须是唯一的。
如果第一个选举出的哨兵切换失败了,那么其他哨兵,会等待failover-timeout时间,然后接替继续执行切换,此时会重新获取一个新的configuration epoch 作为新的version号。
这个version号就很重要了,因为各种消息都是通过一个channel去发布和监听的,所以一个哨兵完成一次新的切换之后,新的master配置是跟着新的version号的,其他的哨兵都是根据版本号的大小来更新自己的master配置的。
为什么Redis哨兵集群只有2个节点无法正常工作?
哨兵集群必须部署2个以上节点。
如果两个哨兵实例,即两个Redis实例,一主一从的模式。
则Redis的配置quorum=1,表示一个哨兵认为master宕机即可认为master已宕机。
但是如果是机器1宕机了,那哨兵1和master都宕机了,虽然哨兵2知道master宕机了,但是这个时候,需要majority,也就是大多数哨兵都是运行的,2个哨兵的majority就是2(2的majority=2,3的majority=2,5的majority=3,4的majority=2),2个哨兵都运行着,就可以允许执行故障转移。
但此时哨兵1没了就只有1个哨兵了了,此时就没有majority来允许执行故障转移,所以故障转移不会执行。
Redis cluster中是如何实现数据分布的?这种方式有什么优点?
Redis cluster有固定的16384个hash slot(哈希槽),对每个key计算CRC16值,然后对16384取模,可以获取key对应的hash slot。
Redis cluster中每个master都会持有部分slot(槽),比如有3个master,那么可能每个master持有5000多个hash slot。
hash slot让node的增加和移除很简单,增加一个master,就将其他master的hash slot移动部分过去,减少一个master,就将它的hash slot移动到其他master上去。每次增加或减少master节点都是对16384取模,而不是根据master数量,这样原本在老的master上的数据不会因master的新增或减少而找不到。并且增加或减少master时Redis cluster移动hash slot的成本是非常低的。
Redis cluster节点间通信是什么机制?
Redis cluster节点间采取gossip协议进行通信,所有节点都持有一份元数据,不同的节点如果出现了元数据的变更之后U不断地i将元数据发送给其他节点让其他节点进行数据变更。
节点互相之间不断通信,保持整个集群所有节点的数据是完整的。
主要交换故障信息、节点的增加和移除、hash slot信息等。
这种机制的好处在于,元数据的更新比较分散,不是集中在一个地方,更新请求会陆陆续续,打到所有节点上去更新,有一定的延时,降低了压力;
缺点,元数据更新有延时,可能导致集群的一些操作会有一些滞后。
Redis Cluster 集群方案什么情况下会导致整个集群不可用
Redis 没有使用哈希一致性算法,而是使用哈希槽。Redis 中的哈希槽一共有 16384 个,计算给定密钥的哈希槽,我们只需要对密钥的 CRC16 取摸 16384。假设集群中有 A、B、C 三个集群节点,不存在复制模式下,每个集群的节点包含的哈希槽如下:
- 节点 A 包含从 0 到 5500 的哈希槽;
- 节点 B 包含从 5501 到 11000 的哈希槽;
- 节点 C 包含从 11001 到 16383 的哈希槽。
这时,如果节点 B 出现故障,整个集群就会出现缺少 5501 到 11000 的哈希槽范围而不可用。
Redis 常见性能问题和解决方案有哪些
Redis 常见性能问题和解决方案如下:
- Master 最好不要做任何持久化工作,如 RDB 内存快照和 AOF 日志文件;
- 如果数据比较重要,某个 Slave 开启 AOF 备份数据,策略设置为每秒同步一次;
- 为了主从复制的速度和连接的稳定性,Master 和 Slave 最好在同一个局域网内;
- 尽量避免在压力很大的主库上增加从库;
- 主从复制不要用图状结构,用单向链表结构更为稳定,即:Master <- Slave1 <- Slave2 <-Slave3….;这样的结构方便解决单点故障问题,实现 Slave 对 Master 的替换。如果 Master 挂了,可以立刻启用 Slave1 做 Master,其他不变。
假如 Redis 里面有 1 亿个 key,其中有 10w 个 key 是以某个固定的已知的前缀开头的,如果将它们全部找出来?
我们可以使用 keys 命令和 scan 命令,但是会发现使用 scan 更好。
使用 keys 命令
直接使用 keys 命令查询,但是如果是在生产环境下使用会出现一个问题,keys 命令是遍历查询的,查询的时间复杂度为 O(n),数据量越大查询时间越长。而且 Redis 是单线程,keys 指令会导致线程阻塞一段时间,会导致线上 Redis 停顿一段时间,直到 keys 执行完毕才能恢复。这在生产环境是不允许的。除此之外,需要注意的是,这个命令没有分页功能,会一次性查询出所有符合条件的 key 值,会发现查询结果非常大,输出的信息非常多。所以不推荐使用这个命令。
使用 scan 命令
scan 命令可以实现和 keys 一样的匹配功能,但是 scan 命令在执行的过程不会阻塞线程,并且查找的数据可能存在重复,需要客户端操作去重。因为 scan 是通过游标方式查询的,所以不会导致Redis 出现假死的问题。Redis 查询过程中会把游标返回给客户端,单次返回空值且游标不为 0,则说明遍历还没结束,客户端继续遍历查询。scan 在检索的过程中,被删除的元素是不会被查询出来的,但是如果在迭代过程中有元素被修改,scan 不能保证查询出对应元素。相对来说,scan 指令查找花费的时间会比 keys 指令长。
如果有大量的 key 需要设置同一时间过期,一般需要注意什么?
如果有大量的 key 在同一时间过期,那么可能同一秒都从数据库获取数据,给数据库造成很大的压力,导致数据库崩溃,系统出现 502 问题。也有可能同时失效,那一刻不用都访问数据库,压力不够大的话,那么 Redis 会出现短暂的卡顿问题。所以为了预防这种问题的发生,最好给数据的过期时间加一个随机值,让过期时间更加分散。
什么情况下可能会导致 Redis 阻塞
Redis 产生阻塞的原因主要有内部和外部两个原因导致。
内部原因
- 如果 Redis 主机的 CPU 负载过高,也会导致系统崩溃;
- 数据持久化占用资源过多;
- 对 Redis 的 API 或指令使用不合理,导致 Redis 出现问题。
外部原因
外部原因主要是服务器的原因,例如服务器的 CPU 线程在切换过程中竞争过大,内存出现问题、网络问题等。
缓存和数据库谁先更新呢
解决方案:
- 写请求过来,将写请求缓存到缓存队列中,并且开始执行写请求的具体操作(删除缓存中的数据,更新数据库,更新缓存)。
- 如果在更新数据库过程中,又来了个读请求,将读请求再次存入到缓存队列(可以搞n个队列,采用key的hash值进行队列个数取模hash%n,落到对应的队列中,队列需要保证顺序性)中,顺序性保证等待队列前的写请求执行完成,才会执行读请求之前的写请求删除缓存失败,直接返回,此时数据库中的数据是旧值,并且与缓存中的数据是一致的,不会出现缓存一致性的问题。
- 写请求删除缓存成功,则更新数据库,如果更新数据库失败,则直接返回,写请求结束,此时数据库中的值依旧是旧值,读请求过来后,发现缓存中没有数据, 则会直接向数据库中请求,同时将数据写入到缓存中,此时也不会出现数据一致性的问题。
- 更新数据成功之后,再更新缓存,如果此时更新缓存失败,则缓存中没有数据,数据库中是新值 ,写请求结束,此时读请求还是一样,发现缓存中没有数据,同样会从数据库中读取数据,并且存入到缓存中,其实这里不管更新缓存成功还是失败, 都不会出现数据一致性的问题。
上面这方案解决了数据不一致的问题,主要是使用了串行化,每次操作进来必须按照顺序进行。如果某个队列元素积压太多,可以针对读请求进行过滤,提示用户刷新页面,重新请求。
潜在的问题 ,留给大家自己去想吧,因为这个问题属于发散性。
请求时间过长,大量的写请求堆压在队列中,一个读请求来得等都写完了才可以获取到数据。读请求并发高。热点数据路由问题,导致请求倾斜。
Redis 报内存不足怎么处理
- 修改配置文件 redis.conf 的 maxmemory 参数,增加 Redis 可用内存;
- 设置缓存淘汰策略,提高内存的使用效率;
- 使用 Redis 集群模式,提高存储量。
什么是缓存击穿?
缓存击穿跟缓存雪崩有点类似,缓存雪崩是大规模的key失效,而缓存击穿是某个热点的key失效,大并发集中对其进行请求,就会造成大量请求读缓存没读到数据,从而导致高并发访问数据库,引起数据库压力剧增。这种现象就叫做缓存击穿。
从两个方面解决,第一是否可以考虑热点key不设置过期时间,第二是否可以考虑降低打在数据库上的请求数量。
解决方案:
在缓存失效后,通过互斥锁或者队列来控制读数据写缓存的线程数量,比如某个key只允许一个线程查询数据和写缓存,其他线程等待。这种方式会阻塞其他的线程,此时系统的吞吐量会下降
热点数据缓存永远不过期。永不过期实际包含两层意思:
- 物理不过期,针对热点key不设置过期时间
- 逻辑过期,把过期时间存在key对应的value里,如果发现要过期了,通过一个后台的异步线程进行缓存的构建
什么是缓存穿透?
缓存穿透是指用户请求的数据在缓存中不存在即没有命中,同时在数据库中也不存在,导致用户每次请求该数据都要去数据库中查询一遍。如果有恶意攻击者不断请求系统中不存在的数据,会导致短时间大量请求落在数据库上,造成数据库压力过大,甚至导致数据库承受不住而宕机崩溃。
缓存穿透的关键在于在Redis中查不到key值,它和缓存击穿的根本区别在于传进来的key在Redis中是不存在的。假如有黑客传进大量的不存在的key,那么大量的请求打在数据库上是很致命的问题,所以在日常开发中要对参数做好校验,一些非法的参数,不可能存在的key就直接返回错误提示。
解决方法:
- 将无效的key存放进Redis中:
当出现Redis查不到数据,数据库也查不到数据的情况,我们就把这个key保存到Redis中,设置value=”null”,并设置其过期时间极短,后面再出现查询这个key的请求的时候,直接返回null,就不需要再查询数据库了。但这种处理方式是有问题的,假如传进来的这个不存在的Key值每次都是随机的,那存进Redis也没有意义。
- 使用布隆过滤器:
如果布隆过滤器判定某个 key 不存在布隆过滤器中,那么就一定不存在,如果判定某个 key 存在,那么很大可能是存在(存在一定的误判率)。于是我们可以在缓存之前再加一个布隆过滤器,将数据库中的所有key都存储在布隆过滤器中,在查询Redis前先去布隆过滤器查询 key 是否存在,如果不存在就直接返回,不让其访问数据库,从而避免了对底层存储系统的查询压力。
如何选择:针对一些恶意攻击,攻击带过来的大量key是随机,那么我们采用第一种方案就会缓存大量不存在key的数据。那么这种方案就不合适了,我们可以先对使用布隆过滤器方案进行过滤掉这些key。所以,针对这种key异常多、请求重复率比较低的数据,优先使用第二种方案直接过滤掉。而对于空数据的key有限的,重复率比较高的,则可优先采用第一种方式进行缓存。
什么是缓存雪崩?
如果缓在某一个时刻出现大规模的key失效,那么就会导致大量的请求打在了数据库上面,导致数据库压力巨大,如果在高并发的情况下,可能瞬间就会导致数据库宕机。这时候如果运维马上又重启数据库,马上又会有新的流量把数据库打死。这就是缓存雪崩。
造成缓存雪崩的关键在于同一时间的大规模的key失效,主要有两种可能:第一种是Redis宕机,第二种可能就是采用了相同的过期时间。
解决方案:
1、事前:
均匀过期:设置不同的过期时间,让缓存失效的时间尽量均匀,避免相同的过期时间导致缓存雪崩,造成大量数据库的访问。如把每个Key的失效时间都加个随机值,
setRedis(Key,value,time + Math.random() * 10000);
,保证数据不会在同一时间大面积失效。分级缓存:第一级缓存失效的基础上,访问二级缓存,每一级缓存的失效时间都不同。
热点数据缓存永远不过期。永不过期实际包含两层意思:
- 物理不过期,针对热点key不设置过期时间
- 逻辑过期,把过期时间存在key对应的value里,如果发现要过期了,通过一个后台的异步线程进行缓存的构建
保证Redis缓存的高可用,防止Redis宕机导致缓存雪崩的问题。可以使用 主从+ 哨兵,Redis集群来避免 Redis 全盘崩溃的情况。
2、事中:
互斥锁:在缓存失效后,通过互斥锁或者队列来控制读数据写缓存的线程数量,比如某个key只允许一个线程查询数据和写缓存,其他线程等待。这种方式会阻塞其他的线程,此时系统的吞吐量会下降
使用熔断机制,限流降级。当流量达到一定的阈值,直接返回“系统拥挤”之类的提示,防止过多的请求打在数据库上将数据库击垮,至少能保证一部分用户是可以正常使用,其他用户多刷新几次也能得到结果。
3、事后:
开启Redis持久化机制,尽快恢复缓存数据,一旦重启,就能从磁盘上自动加载数据恢复内存中的数据。
什么是缓存预热?
缓存预热是指系统上线后,提前将相关的缓存数据加载到缓存系统。避免在用户请求的时候,先查询数据库,然后再将数据缓存的问题,用户直接查询事先被预热的缓存数据。
如果不进行预热,那么Redis初始状态数据为空,系统上线初期,对于高并发的流量,都会访问到数据库中, 对数据库造成流量的压力。
缓存预热解决方案:
数据量不大的时候,工程启动的时候进行加载缓存动作;
数据量大的时候,设置一个定时任务脚本,进行缓存的刷新;
数据量太大的时候,优先保证热点数据进行提前加载到缓存。
什么是缓存降级?
缓存降级是指缓存失效或缓存服务器挂掉的情况下,不去访问数据库,直接返回默认数据或访问服务的内存数据。降级一般是有损的操作,所以尽量减少降级对于业务的影响程度。
在进行降级之前要对系统进行梳理,看看系统是不是可以丢卒保帅;从而梳理出哪些必须誓死保护,哪些可降级;比如可以参考日志级别设置预案:
一般:比如有些服务偶尔因为网络抖动或者服务正在上线而超时,可以自动降级;
警告:有些服务在一段时间内成功率有波动(如在95~100%之间),可以自动降级或人工降级,并发送告警;
错误:比如可用率低于90%,或者数据库连接池被打爆了,或者访问量突然猛增到系统能承受的最大阀值,此时可以根据情况自动降级或者人工降级;
严重错误:比如因为特殊原因数据错误了,此时需要紧急人工降级。
什么是缓存更新?
除了缓存服务器自带的缓存失效策略之外(Redis默认的有6中策略可供选择),我们还可以根据具体的业务需求进行自定义的缓存淘汰,
常见的策略有两种:
- 定时去清理过期的缓存;
- 当有用户请求过来时,再判断这个请求所用到的缓存是否过期,过期的话就去底层系统得到新数据并更新缓存。两者各有优劣,第一种的缺点是维护大量缓存的key是比较麻烦的,第二种的缺点就是每次用户请求过来都要判断缓存失效,逻辑相对比较复杂!具体用哪种方案,大家可以根据自己的应用场景来权衡。
以参考日志级别设置预案
- 一般:比如有些服务偶尔因为网络抖动或者服务正在上线而超时,可以自动降级;
- 警告:有些服务在一段时间内成功率有波动(如在95~100%之间),可以自动降级或人工降级,并发送告警;
- 错误:比如可用率低于90%,或者数据库连接池被打爆了,或者访问量突然猛增到系统能承受的最大阀值,此时可以根据情况自动降级或者人工降级;
- 严重错误:比如因为特殊原因数据错误了,此时需要紧急人工降级。
服务降级的目的,是为了防止Redis服务故障,导致数据库跟着一起发生雪崩问题。因此,对于不重要的缓存数据,可以采取服务降级策略,例如一个比较常见的做法就是,Redis出现问题,不去数据库查询,而是直接返回默认值给用户。
热点数据和冷数据是什么
热点数据,缓存才有价值对于冷数据而言,大部分数据可能还没有再次访问到就已经被挤出内存,不仅占用内存,而且价值不大。频繁修改的数据,看情况考虑使用缓存 对于上面两个例子,寿星列表、导航信息都存在一个特点,就是信息修改频率不高,读取通常非常高的场景。 对于热点数据,比如我们的某IM产品,生日祝福模块,当天的寿星列表,缓存以后可能读取数十万次。再举个例子,某导航产品,我们将导航信息,缓存以后可能读取数百万次。 数据更新前至少读取两次,缓存才有意义。这个是最基本的策略,如果缓存还没有起作用就失效了,那就没有太大价值了。 那存不存在,修改频率很高,但是又不得不考虑缓存的场景呢?有!比如,这个读取接口对数据库的压力很大,但是又是热点数据,这个时候就需要考虑通过缓存手段,减少数据库的压力,比如我们的某助手产品的,点赞数,收藏数,分享数等是非常典型的热点数据,但是又不断变化,此时就需要将数据同步保存到Redis缓存,减少数据库压力。
Redis的数据类型,以及每种数据类型的使用场景
- String: 这个其实没啥好说的,最常规的set/get操作,value可以是String也可以是数字。一般做一些复杂的计数功能的缓存。
- hash: 这里value存放的是结构化的对象,比较方便的就是操作其中的某个字段。博主在做单点登录的时候,就是用这种数据结构存储用户信息,以cookieId作为key,设置30分钟为缓存过期时间,能很好的模拟出类似session的效果。
- list: 使用List的数据结构,可以做简单的消息队列的功能。另外还有一个就是,可以利用lrange命令,做基于redis的分页功能,性能极佳,用户体验好。本人还用一个场景,很合适—取行情信息。就也是个生产者和消费者的场景。LIST可以很好的完成排队,先进先出的原则。
- set: 因为set堆放的是一堆不重复值的集合。所以可以做全局去重的功能。为什么不用JVM自带的Set进行去重?因为我们的系统一般都是集群部署,使用JVM自带的Set,比较麻烦,难道为了一个做一个全局去重,再起一个公共服务,太麻烦了。 另外,就是利用交集、并集、差集等操作,可以计算共同喜好,全部的喜好,自己独有的喜好等功能。
- sorted set: sorted set多了一个权重参数score,集合中的元素能够按score进行排列。可以做排行榜应用,取TOP N操作。
三种特殊的数据类型
Bitmap:位图,Bitmap想象成一个以位为单位数组,数组中的每个单元只能存0或者1,数组的下标在Bitmap中叫做偏移量。使用Bitmap实现统计功能,更省空间。如果只需要统计数据的二值状态,例如商品有没有、用户在不在等,就可以使用 Bitmap,因为它只用一个 bit 位就能表示 0 或 1。
Hyperloglog:HyperLogLog 是一种用于统计基数的数据集合类型,HyperLogLog 的优点是,在输入元素的数量或者体积非常非常大时,计算基数所需的空间总是固定 的、并且是很小的。每个 HyperLogLog 键只需要花费 12 KB 内存,就可以计算接近 2^64 个不同元素的基 数。场景:统计网页的UV(即Unique Visitor,不重复访客,一个人访问某个网站多次,但是还是只计算为一次)。
要注意,HyperLogLog 的统计规则是基于概率完成的,所以它给出的统计结果是有一定误差的,标准误算率是 0.81%。
- Geospatial:主要用于存储地理位置信息,并对存储的信息进行操作,适用场景如朋友的定位、附近的人、打车距离计算等。
Redis的过期策略以及内存淘汰机制
redis采用的是定期删除+惰性删除策略。 为什么不用定时删除策略? 定时删除,用一个定时器来负责监视key,过期则自动删除。虽然内存及时释放,但是十分消耗CPU资源。在大并发请求下,CPU要将时间应用在处理请求,而不是删除key,因此没有采用这一策略. 定期删除+惰性删除是如何工作的呢? 定期删除,redis默认每个100ms检查,是否有过期的key,有过期key则删除。需要说明的是,redis不是每个100ms将所有的key检查一次,而是随机抽取进行检查(如果每隔100ms,全部key进行检查,redis岂不是卡死)。因此,如果只采用定期删除策略,会导致很多key到时间没有删除。 于是,惰性删除派上用场。也就是说在你获取某个key的时候,redis会检查一下,这个key如果设置了过期时间那么是否过期了?如果过期了此时就会删除。 采用定期删除+惰性删除就没其他问题了么?不是的,如果定期删除没删除key。然后你也没即时去请求key,也就是说惰性删除也没生效。这样,redis的内存会越来越高。那么就应该采用内存淘汰机制。 在redis.conf中有一行配置(maxmemory-policy volatile-lru)。
内存淘汰策略
volatile-lru
:从已设置过期时间的数据集(server.db[i].expires)中挑选最近最少使用的数据淘汰;volatile-ttl
:从已设置过期时间的数据集(server.db[i].expires)中挑选将要过期的数据淘汰;volatile-random
:从已设置过期时间的数据集(server.db[i].expires)中任意选择数据淘汰;allkeys-lru
:从数据集(server.db[i].dict)中挑选最近最少使用的数据淘汰;allkeys-random
:从数据集(server.db[i].dict)中任意选择数据淘汰;no-enviction
(驱逐):禁止驱逐数据,新写入操作会报错。
如果没有设置 expire 的key, 不满足先决条件(prerequisites); 那么 volatile-lru
, volatile-random
和 volatile-ttl
策略的行为, 和 noeviction(不删除) 基本上一致。
为什么Redis的操作是原子性的,怎么保证原子性的
对于Redis而言,命令的原子性指的是:一个操作的不可以再分,操作要么执行,要么不执行。Redis的操作之所以是原子性的,是因为Redis是单线程的。 Redis本身提供的所有API都是原子操作,Redis中的事务其实是要保证批量操作的原子性。 多个命令在并发中也是原子性的吗? 不一定,将get和set改成单命令操作,incr 。使用Redis的事务,或者使用Redis+Lua的方式实现。
什么是分布式锁?为什么用分布式锁?
锁在程序中的作用就是同步工具,保证共享资源在同一时刻只能被一个线程访问,Java中的锁我们都很熟悉了,像synchronized 、Lock都是我们经常使用的,但是Java的锁只能保证单机的时候有效,分布式集群环境就无能为力了,这个时候我们就需要用到分布式锁。
分布式锁,顾名思义,就是分布式项目开发中用到的锁,可以用来控制分布式系统之间同步访问共享资源。
思路是:在整个系统提供一个全局、唯一的获取锁的“东西”,然后每个系统在需要加锁时,都去问这个“东西”拿到一把锁,这样不同的系统拿到的就可以认为是同一把锁。至于这个“东西”,可以是Redis、Zookeeper,也可以是数据库。
一般来说,分布式锁需要满足的特性有这么几点:
互斥性:在任何时刻,对于同一条数据,只有一台应用可以获取到分布式锁;
高可用性:在分布式场景下,一小部分服务器宕机不影响正常使用,这种情况就需要将提供分布式锁的服务以集群的方式部署;
防止锁超时:如果客户端没有主动释放锁,服务器会在一段时间之后自动释放锁,防止客户端宕机或者网络不可达时产生死锁;
独占性:加锁解锁必须由同一台服务器进行,也就是锁的持有者才可以释放锁,不能出现你加的锁,别人给你解锁了。
常见的分布式锁有哪些解决方案?
实现分布式锁目前有三种流行方案,即基于关系型数据库、Redis、ZooKeeper 的方案
- 基于关系型数据库,如MySQL
基于关系型数据库实现分布式锁,是依赖数据库的唯一性来实现资源锁定,比如主键和唯一索引等。
缺点:
- 这把锁强依赖数据库的可用性,数据库是一个单点,一旦数据库挂掉,会导致业务系统不可用。
- 这把锁没有失效时间,一旦解锁操作失败,就会导致锁记录一直在数据库中,其他线程无法再获得到锁。
- 这把锁只能是非阻塞的,因为数据的insert操作,一旦插入失败就会直接报错。没有获得锁的线程并不会进入排队队列,要想再次获得锁就要再次触发获得锁操作。
- 这把锁是非重入的,同一个线程在没有释放锁之前无法再次获得该锁。因为数据中数据已经存在了。
- 基于Redis实现
优点:
Redis 锁实现简单,理解逻辑简单,性能好,可以支撑高并发的获取、释放锁操作。
缺点:
- Redis 容易单点故障,集群部署,并不是强一致性的,锁的不够健壮;
- key 的过期时间设置多少不明确,只能根据实际情况调整;
- 需要自己不断去尝试获取锁,比较消耗性能。
- 基于zookeeper
优点:
zookeeper 天生设计定位就是分布式协调,强一致性,锁很健壮。如果获取不到锁,只需要添加一个监听器就可以了,不用一直轮询,性能消耗较小。
缺点:
在高请求高并发下,系统疯狂的加锁释放锁,最后 zk 承受不住这么大的压力可能会存在宕机的风险。
Redis实现分布式锁
分布式锁的三个核心要素
- 加锁
使用setnx来加锁。key是锁的唯一标识,按业务来决定命名,value这里设置为test。
setx key test
当一个线程执行setnx返回1,说明key原本不存在,该线程成功得到了锁;当一个线程执行setnx返回0,说明key已经存在,该线程抢锁失败;
- 解锁
有加锁就得有解锁。当得到的锁的线程执行完任务,需要释放锁,以便其他线程可以进入。释放锁的最简单方式就是执行del指令。
del key
释放锁之后,其他线程就可以继续执行setnx命令来获得锁。
- 锁超时
锁超时知道的是:如果一个得到锁的线程在执行任务的过程中挂掉,来不及显式地释放锁,这块资源将会永远被锁住,别的线程北向进来。
所以,setnx的key必须设置一个超时时间,以保证即使没有被显式释放,这把锁也要在一段时间后自动释放。setnx不支持超时参数,所以需要额外指令,
expire key 30
上述分布式锁存在的问题
通过上述setnx
、del
和expire
实现的分布式锁还是存在着一些问题。
- SETNX 和 EXPIRE 非原子性
假设一个场景中,某一个线程刚执行setnx,成功得到了锁。此时setnx刚执行成功,还未来得及执行expire命令,节点就挂掉了。此时这把锁就没有设置过期时间,别的线程就再也无法获得该锁。
解决措施:
由于setnx
指令本身是不支持传入超时时间的,而在Redis2.6.12版本上为set
指令增加了可选参数, 用法如下:
SET key value [EX seconds][PX milliseconds] [NX|XX]
- EX second: 设置键的过期时间为second秒;
- PX millisecond:设置键的过期时间为millisecond毫秒;
- NX:只在键不存在时,才对键进行设置操作;
- XX:只在键已经存在时,才对键进行设置操作;
- SET操作完成时,返回OK,否则返回nil。
- 锁误解除
如果线程 A 成功获取到了锁,并且设置了过期时间 30 秒,但线程 A 执行时间超过了 30 秒,锁过期自动释放,此时线程 B 获取到了锁;随后 A 执行完成,线程 A 使用 DEL 命令来释放锁,但此时线程 B 加的锁还没有执行完成,线程 A 实际释放的线程 B 加的锁。
解决办法:
在del释放锁之前加一个判断,验证当前的锁是不是自己加的锁。
具体在加锁的时候把当前线程的id当做value,可生成一个 UUID 标识当前线程,在删除之前验证key对应的value是不是自己线程的id。
还可以使用 lua 脚本做验证标识和解锁操作。
- 超时解锁导致并发
如果线程 A 成功获取锁并设置过期时间 30 秒,但线程 A 执行时间超过了 30 秒,锁过期自动释放,此时线程 B 获取到了锁,线程 A 和线程 B 并发执行。
A、B 两个线程发生并发显然是不被允许的,一般有两种方式解决该问题:
- 将过期时间设置足够长,确保代码逻辑在锁释放之前能够执行完成。
- 为获取锁的线程增加守护线程,为将要过期但未释放的锁增加有效时间。
- 不可重入
当线程在持有锁的情况下再次请求加锁,如果一个锁支持一个线程多次加锁,那么这个锁就是可重入的。如果一个不可重入锁被再次加锁,由于该锁已经被持有,再次加锁会失败。Redis 可通过对锁进行重入计数,加锁时加 1,解锁时减 1,当计数归 0 时释放锁。
- 无法等待锁释放
上述命令执行都是立即返回的,如果客户端可以等待锁释放就无法使用。
- 可以通过客户端轮询的方式解决该问题,当未获取到锁时,等待一段时间重新获取锁,直到成功获取锁或等待超时。这种方式比较消耗服务器资源,当并发量比较大时,会影响服务器的效率。
- 另一种方式是使用 Redis 的发布订阅功能,当获取锁失败时,订阅锁释放消息,获取锁成功后释放时,发送锁释放消息。
了解RedLock吗?
Redlock是一种算法,Redlock也就是 Redis Distributed Lock,可用实现多节点Redis的分布式锁。
RedLock官方推荐,Redisson完成了对Redlock算法封装。
此种方式具有以下特性:
- 互斥访问:即永远只有一个 client 能拿到锁
- 避免死锁:最终 client 都可能拿到锁,不会出现死锁的情况,即使锁定资源的服务崩溃或者分区,仍然能释放锁。
- 容错性:只要大部分 Redis 节点存活(一半以上),就可以正常提供服务
RedLock的原理
假设有5个完全独立的Redis主服务器
获取当前时间戳
client尝试按照顺序使用相同的key,value获取所有Redis服务的锁,在获取锁的过程中的获取时间比锁过期时间短很多,这是为了不要过长时间等待已经关闭的Redis服务。并且试着获取下一个Redis实例。
比如:TTL为5s,设置获取锁最多用1s,所以如果一秒内无法获取锁,就放弃获取这个锁,从而尝试获取下个锁
client通过获取所有能获取的锁后的时间减去第一步的时间,这个时间差要小于TTL时间并且至少有3个Redis实例成功获取锁,才算真正的获取锁成功
如果成功获取锁,则锁的真正有效时间是 TTL减去第三步的时间差 的时间;比如:TTL 是5s,获取所有锁用了2s,则真正锁有效时间为3s(其实应该再减去时钟漂移);
如果客户端由于某些原因获取锁失败,便会开始解锁所有Redis实例;因为可能已经获取了小于3个锁,必须释放,否则影响其他client获取锁
算法示意图如下:
Redis如何做内存优化?
控制key的数量。当使用Redis存储大量数据时,通常会存在大量键,过多的键同样会消耗大量内存。Redis本质是一个数据结构服务器,它为我们提供多种数据结构,如hash,list,set,zset 等结构。使用Redis时不要进入一个误区,大量使用get/set这样的API,把Redis当成Memcached使用。对于存储相同的数据内容利用Redis的数据结构降低外层键的数量,也可以节省大量内存。
缩减键值对象。降低Redis内存使用最直接的方式就是缩减键(key)和值(value)的长度。
- key长度:如在设计键时,在完整描述业务情况下,键值越短越好。
- value长度:值对象缩减比较复杂,常见需求是把业务对象序列化成二进制数组放入Redis。首先应该在业务上精简业务对象,去掉不必要的属性避免存储无效数据。其次在序列化工具选择上,应该选择更高效的序列化工具来降低字节数组大小。
编码优化。Redis对外提供了string,list,hash,set,zet等类型,但是Redis内部针对不同类型存在编码的概念,所谓编码就是具体使用哪种底层数据结构来实现。编码不同将直接影响数据的内存占用和读写效率。
如果现在有个读超高并发的系统,用Redis来抗住大部分读请求,你会怎么设计?
如果是读高并发的话,先看读并发的数量级是多少,因为Redis单机的读QPS在万级,每秒几万没问题,使用一主多从+哨兵集群的缓存架构来承载每秒10W+的读并发,主从复制,读写分离。
使用哨兵集群主要是提高缓存架构的可用性,解决单点故障问题。主库负责写,多个从库负责读,支持水平扩容,根据读请求的QPS来决定加多少个Redis从实例。如果读并发继续增加的话,只需要增加Redis从实例就行了。
如果需要缓存1T+的数据,选择Redis cluster模式,每个主节点存一部分数据,假设一个master存32G,那只需要n*32G>=1T,n个这样的master节点就可以支持1T+的海量数据的存储了。
Redis单主的瓶颈不在于读写的并发,而在于内存容量,即使是一主多从也是不能解决该问题,因为一主多从架构下,多个slave的数据和master的完全一样。假如master是10G那slave也只能存10G数据。所以数据量受单主的影响。
而这个时候又需要缓存海量数据,那就必须得有多主了,并且多个主保存的数据还不能一样。Redis官方给出的 Redis cluster 模式完美的解决了这个问题。
14. 分布式篇
分布式幂等性如何设计
在高并发场景的架构里,幂等性是必须得保证的。比如说支付功能,用户发起支付,如果后台没有做幂等校验,刚好用户手抖多点了几下,于是后台就可能多次受到同一个订单请求,不做幂等很容易就让用户重复支付了,这样用户是肯定不能忍的。
解决方案:
- 查询和删除不在幂等讨论范围,查询肯定没有幂等的说,删除:第一次删除成功后,后面来删除直接返回0,也是返回成功。
- 建唯一索引:唯一索引或唯一组合索引来防止新增数据存在脏数据 (当表存在唯一索引,并发时新增异常时,再查询一次就可以了,数据应该已经存在了,返回结果即可)。
- token机制:由于重复点击或者网络重发,或者nginx重发等情况会导致数据被重复提交。前端在数据提交前要向后端服务的申请token,token放到 Redis 或 JVM 内存,token有效时间。提交后后台校验token,同时删除token,生成新的token返回。redis要用删除操作来判断token,删除成功代表token校验通过,如果用select+delete来校验token,存在并发问题,不建议使用。
- 悲观锁:如
select id ,name from table_# where id='##' for update;
,悲观锁使用时一般伴随事务一起使用,数据锁定时间可能会很长,根据实际情况选用(另外还要考虑id是否为主键,如果id不是主键或者不是 InnoDB 存储引擎,那么就会出现锁全表)。 - 乐观锁:给数据库表增加一个version字段,可以通过这个字段来判断是否已经被修改了。
- 分布式锁,比如 Redis 、 Zookeeper 的分布式锁。单号为key,然后给Key设置有效期(防止支付失败后,锁一直不释放),来一个请求使用订单号生成一把锁,业务代码执行完成后再释放锁。
- 保底方案,先查询是否存在此单,不存在进行支付,存在就直接返回支付结果。
什么分布式事务?
分布式事务服务(Distributed Transaction Service,DTS)是一个分布式事务框架,用来保障在大规模分布式环境下事务的最终一致性。
CAP理论告诉我们在分布式存储系统中,最多只能实现上面的两点。而由于当前的网络硬件肯定会出现延迟丢包等问题,所以分区容忍性是我们必须需要实现的,所以我们只能在一致性和可用性之间进行权衡。
为了保障系统的可用性,互联网系统大多将强一致性需求转换成最终一致性的需求,并通过系统执行幂等性的保证,保证数据的最终一致性。
说说你对分布式事务的了解
分布式事务是企业集成中的一个技术难点,也是每一个分布式系统架构中都会涉及到的一个东西,特别是在微服务架构中,几乎可以说是无法避免。
首先要搞清楚:ACID、CAP、BASE理论。
ACID
指数据库事务正确执行的四个基本要素:
- 原子性(Atomicity)
- 一致性(Consistency)
- 隔离性(Isolation)
- 持久性(Durability)
CAP
CAP原则又称CAP定理,指的是在一个分布式系统中,一致性(Consistency)、可用性(Availability)、分区容忍性(Partition tolerance)。CAP 原则指的是,这三个要素最多只能同时实现两点,不可能三者兼顾。
- 一致性(Consistency):在分布式系统中的所有数据备份,在同一时刻是否同样的值。
- 可用性(Availability):在集群中一部分节点故障后,集群整体是否还能响应客户端的读写请求。
- 分区容忍性(Partition Tolerance):以实际效果而言,分区相当于对通信的时限要求。系统如果不能在时限内达成数据一致性,就意味着发生了分区的情况,必须就当前操作在C和A之间做出选择。
具体地讲在分布式系统中,在任何数据库设计中,一个Web应用最多只能同时支持上面的两个属性。显然,任何横向扩展策略都要依赖于数据分区。因此,设计人员必须在一致性与可用性之间做出选择。
AP(高可用&&分区容错):
允许至少一个节点更新状态会导致数据不一致,即丧失了C性质(一致性)。会导致全局的数据不一致。
CP(一致&&分区容错):
为了保证数据一致性,将分区一侧的节点设置为不可用,那么又丧失了A性质(可用性)。分区同步会导致同步时间无限延长(也就是等数据同步完成之后才能正常访问)
CA(一致&&高可用):
两个节点可以互相通信,才能既保证C(一致性)又保证A(可用性),这又会导致丧失P性质(分区容错性)。这样的话就分布式节点受阻,无法部署子节点,放弃了分布式系统的可扩展性。因为分布式系统与单机系统不同,它涉及到多节点间的通讯和交互,节点间的分区故障是必然发生的,所以在分布式系统中分区容错性是必须要考虑的。
BASE理论
BASE理论是对CAP中的一致性和可用性进行一个权衡的结果,是对互联网大规模分布式系统的实践总结,强调可用性。
理论的核心思想就是:基本可用(Basically Available)和最终一致性(Eventually consistent)。虽然无法做到强一致,但每个应用都可以根据自身的业务特点,采用适当的方式来使系统达到最终一致性(Eventual consistency)。
- Basically Available(基本可用)
- Soft state(软状态)
- Eventually consistent(最终一致性)
你知道哪些分布式事务解决方案
方案分类
- 两阶段提交(2PC)
- 三阶段提交(3PC)
- 补偿事务(TCC=Try-Confirm-Cancel)
- 本地消息队列表(MQ)
- Sagas事务模型(最终一致性)
二阶段提交
两阶段提交2PC是分布式事务中最强大的事务类型之一,两段提交就是分两个阶段提交:
- 第一阶段询问各个事务数据源是否准备好。
- 第二阶段才真正将数据提交给事务数据源。
为了保证该事务可以满足ACID,就要引入一个协调者(Cooradinator)。其他的节点被称为参与者(Participant)。协调者负责调度参与者的行为,并最终决定这些参与者是否要把事务进行提交。
处理流程
阶段一
- 协调者向所有参与者发送事务内容,询问是否可以提交事务,并等待答复;
- 各参与者执行事务操作,将 undo 和 redo 信息记入事务日志中(但不提交事务);
- 如参与者执行成功,给协调者反馈 yes,否则反馈 no。
阶段二
如果协调者收到了参与者的失败消息或者超时,直接给每个参与者发送回滚(rollback)消息;否则,发送提交(commit)消息。两种情况处理如下:
情况1: 当所有参与者均反馈 yes,提交事务
- 协调者向所有参与者发出正式提交事务的请求(即 commit 请求);
- 参与者执行 commit 请求,并释放整个事务期间占用的资源;
- 各参与者向协调者反馈 ack(应答)完成的消息;
- 协调者收到所有参与者反馈的 ack 消息后,即完成事务提交。
情况2: 当有一个参与者反馈 no,回滚事务
- 协调者向所有参与者发出回滚请求(即 rollback 请求);
- 参与者使用阶段 1 中的 undo 信息执行回滚操作,并释放整个事务期间占用的资源;
- 各参与者向协调者反馈 ack 完成的消息;
- 协调者收到所有参与者反馈的 ack 消息后,即完成事务。
问题
- 性能问题: 所有参与者在事务提交阶段处于同步阻塞状态,占用系统资源,容易导致性能瓶颈。
- 可靠性问题: 如果协调者存在单点故障问题,或出现故障,提供者将一直处于锁定状态。
- 数据一致性问题: 在阶段 2 中,如果出现协调者和参与者都挂了的情况,有可能导致数据不一致。
优点: 尽量保证了数据的强一致,适合对数据强一致要求很高的关键领域。(其实也不能100%保证强一致)。
缺点: 实现复杂,牺牲了可用性,对性能影响较大,不适合高并发高性能场景。
三阶段提交
三阶段提交是在二阶段提交上的改进版本,3PC最关键要解决的就是协调者和参与者同时挂掉的问题,所以3PC把2PC的准备阶段再次一分为二,这样三阶段提交。
处理流程
阶段一
- 协调者向所有参与者发出包含事务内容的 canCommit 请求,询问是否可以提交事务,并等待所有参与者答复;
- 参与者收到 canCommit 请求后,如果认为可以执行事务操作,则反馈 yes 并进入预备状态,否则反馈 no。
阶段二
协调者根据参与者响应情况,有以下两种可能。
情况1: 所有参与者均反馈 yes,协调者预执行事务
- 协调者向所有参与者发出 preCommit 请求,进入准备阶段;
- 参与者收到 preCommit 请求后,执行事务操作,将 undo 和 redo 信息记入事务日志中(但不提交事务);
- 各参与者向协调者反馈 ack 响应或 no 响应,并等待最终指令。
情况2: 只要有一个参与者反馈 no,或者等待超时后协调者尚无法收到所有提供者的反馈,即中断事务
- 协调者向所有参与者发出 abort 请求;
- 无论收到协调者发出的 abort 请求,或者在等待协调者请求过程中出现超时,参与者均会中断事务。
阶段三
该阶段进行真正的事务提交,也可以分为以下两种情况。
情况1: 所有参与者均反馈 ack 响应,执行真正的事务提交
- 如果协调者处于工作状态,则向所有参与者发出 do Commit 请求;
- 参与者收到 do Commit 请求后,会正式执行事务提交,并释放整个事务期间占用的资源;
- 各参与者向协调者反馈 ack 完成的消息;
- 协调者收到所有参与者反馈的 ack 消息后,即完成事务提交。
情况2: 只要有一个参与者反馈 no,或者等待超时后协调组尚无法收到所有提供者的反馈,即回滚事务。
- 如果协调者处于工作状态,向所有参与者发出 rollback 请求;
- 参与者使用阶段 1 中的 undo 信息执行回滚操作,并释放整个事务期间占用的资源;
- 各参与者向协调组反馈 ack 完成的消息;
- 协调组收到所有参与者反馈的 ack 消息后,即完成事务回滚。
优点: 相比二阶段提交,三阶段提交降低了阻塞范围,在等待超时后协调者或参与者会中断事务。避免了协调者单点问题。阶段 3 中协调者出现问题时,参与者会继续提交事务。
缺点: 数据不一致问题依然存在,当在参与者收到 preCommit 请求后等待 do commite 指令时,此时如果协调者请求中断事务,而协调者无法与参与者正常通信,会导致参与者继续提交事务,造成数据不一致。
什么是补偿事务
TCC (Try Confirm Cancel)是服务化的二阶段编程模型,采用的补偿机制:
TCC 其实就是采用的补偿机制,其核心思想是:针对每个操作,都要注册一个与其对应的确认和补偿(撤销)操作。
它分为三个步骤:
- Try 阶段主要是对业务系统做检测及资源预留;
- Confirm 阶段主要是对业务系统做确认提交,Try阶段执行成功并开始执行 Confirm阶段时,默认 Confirm阶段是不会出错的。即:只要Try成功,Confirm一定成功;
- Cancel 阶段主要是在业务执行错误,需要回滚的状态下执行的业务取消,预留资源释放。
举个例子,假入你要向老田转账,思路大概是:我们有一个本地方法,里面依次调用步骤:
- 首先在 Try 阶段,要先调用远程接口把你和老田的钱给冻结起来。
- 在 Confirm 阶段,执行远程调用的转账的操作,转账成功进行解冻。
- 如果第2步执行成功,那么转账成功,如果第二步执行失败,则调用远程冻结接口对应的解冻方法 (Cancel)。
优点:
性能提升: 具体业务来实现控制资源锁的粒度变小,不会锁定整个资源。
数据最终一致性: 基于 Confirm 和 Cancel 的幂等性,保证事务最终完成确认或者取消,保证数据的一致性。
可靠性: 解决了 XA 协议的协调者单点故障问题,由主业务方发起并控制整个业务活动,业务活动管理器也变成多点,引入集群。
缺点:TCC 的 Try、Confirm 和 Cancel 操作功能要按具体业务来实现,业务耦合度较高,提高了开发成本。
消息队列是怎么实现的
本地消息表(异步确保)
本地消息表这种实现方式应该是业界使用最多的,其核心思想是将分布式事务拆分成本地事务进行处理,这种思路是来源于ebay。我们可以从下面的流程图中看出其中的一些细节:
基本思路
消息生产方,需要额外建一个消息表,并记录消息发送状态。消息表和业务数据要在一个事务里提交,也就是说他们要在一个数据库里面。然后消息会经过MQ发送到消息的消费方。如果消息发送失败,会进行重试发送。
消息消费方,需要处理这个消息,并完成自己的业务逻辑。此时如果本地事务处理成功,表明已经处理成功了,如果处理失败,那么就会重试执行。如果是业务上面的失败,可以给生产方发送一个业务补偿消息,通知生产方进行回滚等操作。
生产方和消费方定时扫描本地消息表,把还没处理完成的消息或者失败的消息再发送一遍。如果有靠谱的自动对账补账逻辑,这种方案还是非常实用的。
这种方案遵循BASE理论,采用的是最终一致性,笔者认为是这几种方案里面比较适合实际业务场景的,即不会出现像2PC那样复杂的实现(当调用链很长的时候,2PC的可用性是非常低的),也不会像TCC那样可能出现确认或者回滚不了的情况。
优点:
一种非常经典的实现,避免了分布式事务,实现了最终一致性。在 .NET中 有现成的解决方案。
缺点:
消息表会耦合到业务系统中,如果没有封装好的解决方案,会有很多杂活需要处理。
MQ事务消息
有一些第三方的MQ是支持事务消息的,比如RocketMQ,他们支持事务消息的方式也是类似于采用的二阶段提交,但是市面上一些主流的MQ都是不支持事务消息的,比如 RabbitMQ 和 Kafka 都不支持。
以阿里的 RocketMQ 中间件为例,其思路大致为:
第一阶段Prepared消息,会拿到消息的地址。 第二阶段执行本地事务,第三阶段通过第一阶段拿到的地址去访问消息,并修改状态。
也就是说在业务方法内要想消息队列提交两次请求,一次发送消息和一次确认消息。如果确认消息发送失败了RocketMQ会定期扫描消息集群中的事务消息,这时候发现了Prepared消息,它会向消息发送者确认,所以生产方需要实现一个check接口,RocketMQ会根据发送端设置的策略来决定是回滚还是继续发送确认消息。这样就保证了消息发送与本地事务同时成功或同时失败。
优点: 实现了最终一致性,不需要依赖本地数据库事务。
缺点: 实现难度大,主流MQ不支持,没有.NET客户端,RocketMQ事务消息部分代码也未开源。
那你说说Sagas事务模型
Saga模式是一种分布式异步事务,一种最终一致性事务,是一种柔性事务,有两种不同的方式来实现saga事务,最流行的两种方式是:事件/编排Choreography和命令/协调orchestrator。
事件/编排Choreography
没有中央协调器(没有单点风险)时,每个服务产生并聆听其他服务的事件,并决定是否应采取行动。
该实现第一个服务执行一个事务,然后发布一个事件。该事件被一个或多个服务进行监听,这些服务再执行本地事务并发布(或不发布)新的事件,当最后一个服务执行本地事务并且不发布任何事件时,意味着分布式事务结束,或者它发布的事件没有被任何Saga参与者听到都意味着事务结束。
处理流程说明
订单服务保存新订单,将状态设置为pengding挂起状态,并发布名为ORDER_CREATED_EVENT的事件。
支付服务监听ORDER_CREATED_EVENT,并公布事件BILLED_ORDER_EVENT。
库存服务监听BILLED_ORDER_EVENT,更新库存,并发布ORDER_PREPARED_EVENT。
货运服务监听ORDER_PREPARED_EVENT,然后交付产品。最后,它发布ORDER_DELIVERED_EVENT。
最后,订单服务侦听ORDER_DELIVERED_EVENT并设置订单的状态为concluded完成。
假设库存服务在事务过程中失败了。进行回滚:库存服务产生PRODUCT_OUT_OF_STOCK_EVENT
订购服务和支付服务会监听到上面库存服务的这一事件:
- 支付服务会退款给客户
- 订单服务将订单状态设置为失败
优点:事件/编排是实现Saga模式的自然方式; 它很简单,容易理解,不需要太多的努力来构建,所有参与者都是松散耦合的,因为他们彼此之间没有直接的耦合。如果您的事务涉及2至4个步骤,则可能是非常合适的。
命令/协调orchestrator
中央协调器负责集中处理事件的决策和业务逻辑排序。
saga协调器orchestrator以命令/回复的方式与每项服务进行通信,告诉他们应该执行哪些操作。
订单服务保存pending状态,并要求订单Saga协调器(简称OSO)开始启动订单事务。
OSO向收款服务发送执行收款命令,收款服务回复Payment Executed消息。
OSO向库存服务发送准备订单命令,库存服务将回复OrderPrepared消息。
OSO向货运服务发送订单发货命令,货运服务将回复Order Delivered消息。
OSO订单Saga协调器必须事先知道执行“创建订单”事务所需的流程(通过读取BPM业务流程XML配置获得)。如果有任何失败,它还负责通过向每个参与者发送命令来撤销之前的操作来协调分布式的回滚。当你有一个中央协调器协调一切时,回滚要容易得多,因为协调器默认是执行正向流程,回滚时只要执行反向流程即可。
优点:
避免服务之间的循环依赖关系,因为saga协调器会调用saga参与者,但参与者不会调用协调器。集中分布式事务的编排。
只需要执行命令/回复(其实回复消息也是一种事件消息),降低参与者的复杂性。
在添加新步骤时,事务复杂性保持线性,回滚更容易管理。
如果在第一笔交易还没有执行完,想改变有第二笔事务的目标对象,则可以轻松地将其暂停在协调器上,直到第一笔交易结束。
分布式ID生成有几种方案
分布式ID的特性
- 唯一性: 确保生成的ID是全网唯一的。
- 有序递增性: 确保生成的ID是对于某个用户或者业务是按一定的数字有序递增的。
- 高可用性: 确保任何时候都能正确的生成ID。
- 带时间: ID里面包含时间,一眼扫过去就知道哪天的交易。
分布式ID生成方案
UUID
算法的核心思想是结合机器的网卡、当地时间、一个随记数来生成UUID。
- 优点:本地生成,生成简单,性能好,没有高可用风险。
- 缺点:长度过长,存储冗余,且无序不可读,查询效率低。
数据库自增ID
使用数据库的id自增策略,如 MySQL 的 auto_increment。并且可以使用两台数据库分别设置不同步长,生成不重复ID的策略来实现高可用。
- 优点:数据库生成的ID绝对有序,高可用实现方式简单。
- 缺点:需要独立部署数据库实例,成本高,有性能瓶颈。
批量生成ID
一次按需批量生成多个ID,每次生成都需要访问数据库,将数据库修改为最大的ID值,并在内存中记录当前值及最大值。
- 优点:避免了每次生成ID都要访问数据库并带来压力,提高性能。
- 缺点:属于本地生成策略,存在单点故障,服务重启造成ID不连续。
Redis生成ID
Redis的所有命令操作都是单线程的,本身提供像 incr 和 increby 这样的自增原子命令,所以能保证生成的 ID 肯定是唯一有序的。
- 优点:不依赖于数据库,灵活方便,且性能优于数据库;数字ID天然排序,对分页或者需要排序的结果很有帮助。
- 缺点:如果系统中没有Redis,还需要引入新的组件,增加系统复杂度;需要编码和配置的工作量比较大。
考虑到单节点的性能瓶颈,可以使用 Redis 集群来获取更高的吞吐量。假如一个集群中有5台Redis。可以初始化每台 Redis 的值分别是1, 2, 3, 4, 5,然后步长都是 5。
Twitter的snowflake算法(重点)
Twitter 利用 zookeeper 实现了一个全局ID生成的服务 Snowflake。
如上图的所示,Twitter 的 Snowflake 算法由下面几部分组成:
- 1位符号位
由于 long 类型在 java 中带符号的,最高位为符号位,正数为 0,负数为 1,且实际系统中所使用的ID一般都是正数,所以最高位为 0。
- 41位时间戳(毫秒级)
需要注意的是此处的 41 位时间戳并非存储当前时间的时间戳,而是存储时间戳的差值(当前时间戳 - 起始时间戳),这里的起始时间戳一般是ID生成器开始使用的时间戳,由程序来指定,所以41位毫秒时间戳最多可以使用 (1 << 41) / (1000x60x60x24x365) = 69
年 。
- 10位数据机器位
包括5位数据标识位和5位机器标识位,这10位决定了分布式系统中最多可以部署 1 << 10 = 1024
个节点。超过这个数量,生成的ID就有可能会冲突。
- 12位毫秒内的序列
这 12 位计数支持每个节点每毫秒(同一台机器,同一时刻)最多生成 1 << 12 = 4096个ID加起来刚好64位,为一个Long型。
优点: 高性能,低延迟,按时间有序,一般不会造成ID碰撞。
优点: 高性能,低延迟,按时间有序,一般不会造成ID碰撞。
百度UidGenerator
UidGenerator是百度开源的分布式ID生成器,基于于snowflake算法的实现,看起来感觉还行。不过,国内开源的项目维护性真是担忧。
美团Leaf
Leaf 是美团开源的分布式ID生成器,能保证全局唯一性、趋势递增、单调递增、信息安全,里面也提到了几种分布式方案的对比,但也需要依赖关系数据库、Zookeeper等中间件。
幂等解决方法有哪些
什么是幂等
- 常见描述:对于相同的请求应该返回相同的结果,所以查询类接口是天然的幂等性接口。
- 真正的回答方式:幂等指的是相同请求(identical request)执行一次或者多次所带来的副作用(side-effects)是一样的。
14.8.2 常见出现幂等问题场景
- 前端调后端接口发起支付超时,然后再次发起重试。可能会导致多次支付。
- Dubbo中也有重试机制。
- 页面上多次点击。
我们想要的是:接口的幂等性实际上就是接口可重复调用,在调用方多次调用的情况下,接口最终得到的结果是一致的。
解决方案
在插入数据的时候,插入去重表,利用数据库的唯一索引特性,保证唯一的逻辑。
悲观锁,select for update,整个执行过程中锁定该订单对应的记录。注意:这种在DB读大于写的情况下尽量少用。
先查询后修改数据,并发不高的后台系统,或者一些任务JOB,为了支持幂等,支持重复执行,简单的处理方法是,先查询下一些关键数据,判断是否已经执行过,在进行业务处理,就可以了。注意:核心高并发流程不要用这种方法。
状态机幂等,在设计单据相关的业务,或者是任务相关的业务,肯定会涉及到状态机,就是业务单据上面有个状态,状态在不同的情况下会发生变更,一般情况下存在有限状态机,这时候,如果状态机已经处于下一个状态,这时候来了一个上一个状态的变更,理论上是不能够变更的,这样的话,保证了有限状态机的幂等。
token机制,防止页面重复提交:
- 集群环境:采用token加redis(redis单线程的,处理需要排队)或者
- 单JVM环境:采用token加redis或token加jvm内存
数据提交前要向服务的申请token,token放到redis或jvm内存,设置token有效时间,提交后后台校验token,同时删除token,生成新的token返回。token特点:要申请,一次有效性,可以限流。
全局唯一ID,如果使用全局唯一ID,就是根据业务的操作和内容生成一个全局ID,在执行操作前先根据这个全局唯一ID是否存在,来判断这个操作是否已经执行。如果不存在则把全局ID,存储到存储系统中,比如数据库、redis等。如果存在则表示该方法已经执行。
你知道哪些限流算法
限流算法有四种常见算法:
- 计数器算法(固定窗口)
- 滑动窗口
- 漏桶算法
- 令牌桶算法
说说什么是计数器(固定窗口)算法
计数器算法是使用计数器在周期内累加访问次数,当达到设定的限流值时,触发限流策略。下一个周期开始时,进行清零,重新计数。
此算法在单机还是分布式环境下实现都非常简单,使用redis的incr原子自增性和线程安全即可轻松实现。
这个算法通常用于QPS限流和统计总访问量,对于秒级以上的时间周期来说,会存在一个非常严重的问题,那就是临界问题,如下图:
假设1min内服务器的负载能力为100,因此一个周期的访问量限制在100,然而在第一个周期的最后5秒和下一个周期的开始5秒时间段内,分别涌入100的访问量,虽然没有超过每个周期的限制量,但是整体上10秒内已达到200的访问量,已远远超过服务器的负载能力,由此可见,计数器算法方式限流对于周期比较长的限流,存在很大的弊端。
说说什么是滑动窗口算法
滑动窗口算法是将时间周期分为N个小周期,分别记录每个小周期内访问次数,并且根据时间滑动删除过期的小周期。
如下图,假设时间周期为1min,将1min再分为2个小周期,统计每个小周期的访问数量,则可以看到,第一个时间周期内,访问数量为75,第二个时间周期内,访问数量为100,超过100的访问则被限流掉了。
由此可见,当滑动窗口的格子划分的越多,那么滑动窗口的滚动就越平滑,限流的统计就会越精确。
此算法可以很好的解决固定窗口算法的临界问题。
说说什么是漏桶算法
漏桶算法是访问请求到达时直接放入漏桶,如当前容量已达到上限(限流值),则进行丢弃(触发限流策略)。漏桶以固定的速率进行释放访问请求(即请求通过),直到漏桶为空。
说说什么是令牌桶算法
令牌桶算法是程序以r(r=时间周期/限流值)的速度向令牌桶中增加令牌,直到令牌桶满,请求到达时向令牌桶请求令牌,如获取到令牌则通过请求,否则触发限流策略。
数据库如何处理海量数据
对数据库进行:分库分表,主从架构,读写分离。
水平分库/分表,垂直分库/分表。
- 水平分库/表,各个库和表的结构一模一样。
- 垂直分库/表,各个库和表的结构不一样。
读写分离:主机负责写,从机负责读。
如何将长链接转换成短链接,并发送短信
短 URL 从生成到使用分为以下几步
- 有一个服务,将要发送给你的长 URL 对应到一个短 URL 上.例如 www.baidu.com -> www.t.cn/1。
- 把短 url 拼接到短信等的内容上发送。
- 用户点击短 URL ,浏览器用 301 / 302 进行重定向,访问到对应的长 URL。
- 展示对应的内容。
如何提高系统的并发能力
- 使用分布式系统。
- 部署多台服务器,并做负载均衡。
- 使用缓存(Redis)集群。
- 数据库分库分表 + 读写分离。
- 引入消息中间件集群。
15. 网络篇
HTTP 响应码有哪些?分别代表什么含义?
- 200:成功,Web 服务器成功处理了客户端的请求。
- 301:永久重定向,当客户端请求一个网址的时候,Web 服务器会将当前请求重定向到另一个网址,搜索引擎会抓取重定向后网页的内容并且将旧的网址替换为重定向后的网址。
- 302:临时重定向,搜索引擎会抓取重定向后网页的内容而保留旧的网址,因为搜索引擎认为重定向后的网址是暂时的。
- 400:客户端请求错误,多为参数不合法导致 Web 服务器验参失败。
- 404:未找到,Web 服务器找不到资源。
- 500:Web 服务器错误,服务器处理客户端请求的时候发生错误。
- 503:服务不可用,服务器停机。
- 504:网关超时。
状态码开头代表类型:
状态码301和302的区别是什么?
共同点:301和302状态码都表示重定向,就是说浏览器在拿到服务器返回的这个状态码后会自动跳转到一个新的URL地址,这个地址可以从响应的Location首部中获取(用户看到的效果就是他输入的地址A瞬间变成了另一个地址B)。
不同点:301表示旧地址A的资源已经被永久地移除了(这个资源不可访问了),搜索引擎在抓取新内容的同时也将旧的网址交换为重定向之后的网址;302表示旧地址A的资源还在(仍然可以访问),这个重定向只是临时地从旧地址A跳转到地址B,搜索引擎会抓取新的内容而保存旧的网址。 SEO中302好于301。
补充,重定向原因:
- 网站调整(如改变网页目录结构);
- 网页被移到一个新地址;
- 网页扩展名改变(如应用需要把.php改成.Html或.shtml)。
Forward 和 Redirect 的区别
- 浏览器 URL 地址:Forward 是服务器内部的重定向,服务器内部请求某个 servlet,然后获取响应的内容,浏览器的 URL 地址是不会变化的;Redirect 是客户端请求服务器,然后服务给客户端返回了一个 302 状态码和新的 location,客户端重新发起 HTTP 请求,服务器给客户端响应 location 对应的 URL 地址,浏览器的 URL 地址发生了变化。
- 数据的共享:Forward 是服务器内部的重定向,request 在整个重定向过程中是不变的,request 中的信息在 servlet 间是共享的。Redirect 发起了两次 HTTP 请求分别使用不同的request。
- 请求的次数:Forward 只有一次请求;Redirect 有两次请求。
Get 和 Post 请求有哪些区别
用途
- get 请求用来从服务器获取资源
- post 请求用来向服务器提交数据
表单的提交方式
- get 请求直接将表单数据以 name1=value1&name2=value2 的形式拼接到 URL 上
http://www.baidu.com/action?name1=value1&name2=value2
,多个参数参数值需要用 & 连接起来并且用 ? 拼接到 action 后面; - post 请求将表单数据放到请求头或者请求的消息体中。
传输数据的大小限制
- get 请求传输的数据受到 URL 长度的限制,而 URL 长度是由浏览器决定的;
- post 请求传输数据的大小理论上来说是没有限制的。
参数的编码
- get 请求的参数会在地址栏明文显示,使用 URL 编码的文本格式传递参数;
- post 请求使用二进制数据多重编码传递参数。
缓存
- get 请求可以被浏览器缓存被收藏为标签;
- post 请求不会被缓存也不能被收藏为标签。
说说 TCP 与 UDP 的区别,以及各自的优缺点
- TCP面向连接(如打电话要先拨号建立连接):UDP是无连接的,即发送数据之前不需要建立连接。
- TCP提供可靠的服务。也就是说,通过TCP连接传送的数据,无差错,不丢失,不重复,且按序到达;UDP尽最大努力交付,即不保证可靠交付。tcp通过校验和,重传控制,序号标识,滑动窗口、确认应答实现可靠传输。如丢包时的重发控制,还可以对次序乱掉的分包进行顺序控制。
- UDP具有较好的实时性,工作效率比TCP高,适用于对高速传输和实时性有较高的通信或广播通信。
- 每一条TCP连接只能是点到点的;UDP支持一对一,一对多,多对一和多对多的交互通信。
- TCP对系统资源要求较多,UDP对系统资源要求较少。
总结如表:
TCP 用于在传输层有必要实现可靠传输的情况,UDP 用于对高速传输和实时性有较高要求的通信。TCP 和 UDP 应该根据应用目的按需使用。
UDP | TCP | |
---|---|---|
是否连接 | 无连接 | 面向连接 |
是否可靠 | 不可靠传输,不使用流量控制和拥塞控制 | 可靠传输,使用流量控制和拥塞控制 |
是否有序 | 无序 | 有序,消息在传输过程中可能会乱序,TCP 会重新排序 |
传输速度 | 快 | 慢 |
连接对象个数 | 支持一对一,一对多,多对一和多对多交互通信 | 只能是一对一通信 |
传输方式 | 面向报文 | 面向字节流 |
首部开销 | 首部开销小,仅8字节 | 首部最小20字节,最大60字节 |
适用场景 | 适用于实时应用(IP电话、视频会议、直播等) | 适用于要求可靠传输的应用,例如文件传输 |
UDP 和 TCP 对应的应用场景是什么?
TCP 是面向连接,能保证数据的可靠性交付,因此经常用于:
- FTP文件传输
- HTTP / HTTPS
UDP 面向无连接,它可以随时发送数据,再加上UDP本身的处理既简单又高效,因此经常用于:
- 包总量较少的通信,如 DNS 、SNMP等
- 视频、音频等多媒体通信
- 广播通信
说一下 HTTP 和 HTTPS 的区别
端口不同:HTTP和 HTTPS 的连接方式不同没用的端口也不一样,HTTP是80, HTTPS 用的是443。
消耗资源:和HTTP相比,HTTPS通信会因为加解密的处理消耗更多的CPU和内存资源。
开销: HTTPS 通信需要证书,这类证书通常需要向认证机构申请或者付费购买。
说说HTTP、TCP、Socket 的关系是什么
- TCP/IP 代表传输控制协议/网际协议,指的是一系列协议族。
- HTTP 本身就是一个协议,是从 Web 服务器传输超文本到本地浏览器的传送协议。
- Socket 是 TCP/IP 网络的 API ,其实就是一个门面模式,它把复杂的 TCP/IP 协议族隐藏在Socket 接口后面。对用户来说,一组简单的接口就是全部,让 Socket 去组织数据,以符合指定的协议。
综上所述:
- 需要 IP 协议来连接网络
- TCP 是一种允许我们安全传输数据的机制,使用 TCP 协议来传输数据的 HTTP 是 Web 服务器和客户端使用的特殊协议。
- HTTP 基于 TCP 协议,所以可以使用 Socket 去建立一个 TCP 连接。
说一下HTTP的长连接与短连接的区别
HTTP协议的长连接和短连接,实质上是TCP协议的长连接和短连接。
短连接
在HTTP/1.0中默认使用短链接,也就是说,浏览器和服务器每进行一次HTTP操作,就建立一次连接,但任务结束就中断连接。如果客户端访问的某个HTML或其他类型的Web资源,如 JavaScript文件、图像文件、 CSS 文件等。当浏览器每遇到这样一个Web资源,就会建立一个HTTP会话。
长连接
从HTTP/1.1起,默认使用长连接,用以保持连接特性。在使用长连接的情况下,当一个网页打开完成后,客户端和服务器之间用于传输HTTP数据的TCP连接不会关闭。如果客户端再次访问这个服务器上的网页,会继续使用这一条已经建立的连接。Keep-Alive不会永久保持连接,它有一个保持时间,可以在不同的服务器软件(如Apache)中设定这个时间。
HTTP请求报文和响应报文的格式?
请求报文格式:
- 请求行(请求方法+URI协议+版本)
- 请求头部
- 空行
- 请求主体
GET/sample.jspHTTP/1.1 请求行
Accept:image/gif.image/jpeg, 请求头部
Accept-Language:zh-cn
Connection:Keep-Alive
Host:localhost
User-Agent:Mozila/4.0(compatible;MSIE5.01;Window NT5.0)
Accept-Encoding:gzip,deflate
username=jinqiao&password=1234 请求主体
响应报文:
- 状态行(版本+状态码+原因短语)
- 响应首部
- 空行
- 响应主体
HTTP/1.1 200 OK
Server:Apache Tomcat/5.0.12
Date:Mon,6Oct2003 13:23:42 GMT
Content-Length:112
<html>
<head>
<title>HTTP响应示例<title>
</head>
<body>
Hello HTTP!
</body>
</html>
HTTP1.0和HTTP1.1的区别?
长连接:HTTP 1.1支持长连接(Persistent Connection)和请求的流水线(Pipelining)处理,在一个TCP连接上可以传送多个HTTP请求和响应,减少了建立和关闭连接的消耗和延迟,在HTTP1.1中默认开启
Connection: keep-alive
,一定程度上弥补了HTTP1.0每次请求都要创建连接的缺点。缓存处理:在HTTP1.0中主要使用header里的If-Modified-Since,Expires来做为缓存判断的标准,HTTP1.1则引入了更多的缓存控制策略,可供选择的缓存头来控制缓存策略。
带宽优化及网络连接的使用:HTTP1.0中,存在一些浪费带宽的现象,例如客户端只是需要某个对象的一部分,而服务器却将整个对象送过来了,并且不支持断点续传功能,HTTP1.1则在请求头引入了range头域,它允许只请求资源的某个部分,即返回码是206(Partial Content),这样就方便了开发者自由的选择以便于充分利用带宽和连接。
错误通知的管理:在HTTP1.1中新增了24个错误状态响应码,如409(Conflict)表示请求的资源与资源的当前状态发生冲突;410(Gone)表示服务器上的某个资源被永久性的删除。
Host头处理:在HTTP1.0中认为每台服务器都绑定一个唯一的IP地址,因此,请求消息中的URL并没有传递主机名(hostname)。但随着虚拟主机技术的发展,在一台物理服务器上可以存在多个虚拟主机(Multi-homed Web Servers),并且它们共享一个IP地址。HTTP1.1的请求消息和响应消息都应支持Host头域,且请求消息中如果没有Host头域会报告一个错误(400 Bad Request)。
HTTP1.1和 HTTP2.0的区别?
HTTP2.0相比HTTP1.1支持的特性:
新的二进制格式:HTTP1.1的解析是基于文本。基于文本协议的格式解析存在天然缺陷,文本的表现形式有多样性,要做到健壮性考虑的场景必然很多,二进制则不同,只认0和1的组合。基于这种考虑HTTP2.0的协议解析决定采用二进制格式,实现方便且健壮。
多路复用,即连接共享,即每一个request都是用作连接共享机制的。一个request对应一个id,这样一个连接上可以有多个request,每个连接的request可以随机的混杂在一起,接收方可以根据request的 id将request再归属到各自不同的服务端请求里面。
头部压缩,HTTP1.1的头部(header)带有大量信息,而且每次都要重复发送;HTTP2.0使用encoder来减少需要传输的header大小,通讯双方各自cache一份header fields表,既避免了重复header的传输,又减小了需要传输的大小。
服务端推送:服务器除了对最初请求的响应外,服务器还可以额外的向客户端推送资源,而无需客户端明确的请求。
HTTP 与 HTTPS 的区别?
HTTP | HTTPS | |
---|---|---|
端口 | 80 | 443 |
安全性 | 无加密,安全性较差 | 有加密机制,安全性较高 |
资源消耗 | 较少 | 由于加密处理,资源消耗更多 |
是否需要证书 | 不需要 | 需要 |
协议 | 运行在TCP协议之上 | 运行在SSL协议之上,SSL运行在TCP协议之上 |
HTTPS 的优缺点?
优点:
安全性:
使用HTTPS协议可认证用户和服务器,确保数据发送到正确的客户机和服务器;
HTTPS协议是由SSL+HTTP协议构建的可进行加密传输、身份认证的网络协议,要比http协议安全,可防止数据在传输过程中不被窃取、改变,确保数据的完整性。
HTTPS是现行架构下最安全的解决方案,虽然不是绝对安全,但它大幅增加了中间人攻击的成本。
SEO方面:谷歌曾在2014年8月份调整搜索引擎算法,并称“比起同等HTTP网站,采用HTTPS加密的网站在搜索结果中的排名将会更高”。
缺点:
- 在相同网络环境中,HTTPS 相比 HTTP 无论是响应时间还是耗电量都有大幅度上升。
- HTTPS 的安全是有范围的,在黑客攻击、服务器劫持等情况下几乎起不到作用。
- 在现有的证书机制下,中间人攻击依然有可能发生。
- HTTPS 需要更多的服务器资源,也会导致成本的升高。
讲一讲HTTPS 的原理?
加密流程按图中的序号分为:
客户端请求 HTTPS 网址,然后连接到 server 的 443 端口 (HTTPS 默认端口,类似于 HTTP 的80端口)。
采用 HTTPS 协议的服务器必须要有一套数字 CA (Certification Authority)证书。颁发证书的同时会产生一个私钥和公钥。私钥由服务端自己保存,不可泄漏。公钥则是附带在证书的信息中,可以公开的。证书本身也附带一个证书电子签名,这个签名用来验证证书的完整性和真实性,可以防止证书被篡改。
服务器响应客户端请求,将证书传递给客户端,证书包含公钥和大量其他信息,比如证书颁发机构信息,公司信息和证书有效期等。
客户端解析证书并对其进行验证。如果证书不是可信机构颁布,或者证书中的域名与实际域名不一致,或者证书已经过期,就会向访问者显示一个警告,由其选择是否还要继续通信。
如果证书没有问题,客户端就会从服务器证书中取出服务器的公钥A。然后客户端还会生成一个随机码 KEY,并使用公钥A将其加密。
客户端把加密后的随机码 KEY 发送给服务器,作为后面对称加密的密钥。
服务器在收到随机码 KEY 之后会使用私钥B将其解密。经过以上这些步骤,客户端和服务器终于建立了安全连接,完美解决了对称加密的密钥泄露问题,接下来就可以用对称加密愉快地进行通信了。
服务器使用密钥 (随机码 KEY)对数据进行对称加密并发送给客户端,客户端使用相同的密钥 (随机码 KEY)解密数据。
双方使用对称加密愉快地传输所有数据。
TCP 如何保证可靠性
检验和
通过检验和的方式,接收端可以检测出来数据是否有差错和异常,假如有差错就会直接丢弃TCP段,重新发送。
序列号和确认号机制
序列号的作用不仅仅是应答的作用,有了序列号能够将接收到的数据根据序列号排序,并且去掉重复序列号的数据。
TCP传输的过程中,每次接收方收到数据后,都会对传输方进行确认应答。也就是发送ACK报文,这个ACK报文当中带有对应的确认序列号,告诉发送方,接收到了哪些数据,下一次的数据从哪里发。
超时重发机制
TCP 发送端发送了数据包后会启动一个定时器,如果一定时间没有收到接受端的确认后,将会重新发送该数据包。
对乱序数据包重新排序
从 IP 网络层传输到 TCP 层的数据包可能会乱序,TCP 层会对数据包重新排序再发给应用层。
丢弃重复数据
从 IP 网络层传输到 TCP 层的数据包可能会重复,TCP 层会丢弃重复的数据包。
流量控制
如果主机A 一直向主机B发送数据,不考虑主机B的接受能力,则可能导致主机B的接受缓冲区满了而无法再接受数据,从而会导致大量的数据丢包,引发重传机制。而在重传的过程中,若主机B的接收缓冲区情况仍未好转,则会将大量的时间浪费在重传数据上,降低传送数据的效率。所以引入流量控制机制,主机B通过告诉主机A自己接收缓冲区的大小,来使主机A控制发送的数据量。流量控制与TCP协议报头中的窗口大小有关。
拥塞控制
在数据传输过程中,可能由于网络状态的问题,造成网络拥堵,此时引入拥塞控制机制,在保证TCP可靠性的同时,提高性能。
滑动窗口
滑动窗口既提高了报文传输的效率,也避免了发送方发送过多的数据而导致接收方无法正常处理的异常。
详细讲一下TCP的滑动窗口?
在进行数据传输时,如果传输的数据比较大,就需要拆分为多个数据包进行发送。TCP 协议需要对数据进行确认后,才可以发送下一个数据包。这样一来,就会在等待确认应答包环节浪费时间。
为了避免这种情况,TCP引入了窗口概念。窗口大小指的是不需要等待确认应答包而可以继续发送数据包的最大值。
从上面的图可以看到滑动窗口左边的是已发送并且被确认的分组,滑动窗口右边是还没有轮到的分组。
滑动窗口里面也分为两块,一块是已经发送但是未被确认的分组,另一块是窗口内等待发送的分组。随着已发送的分组不断被确认,窗口内等待发送的分组也会不断被发送。整个窗口就会往右移动,让还没轮到的分组进入窗口内。
可以看到滑动窗口起到了一个限流的作用,也就是说当前滑动窗口的大小决定了当前 TCP 发送包的速率,而滑动窗口的大小取决于拥塞控制窗口和流量控制窗口的两者间的最小值。
详细讲一下拥塞控制?
TCP 一共使用了四种算法来实现拥塞控制:
慢开始 (slow-start);
拥塞避免 (congestion avoidance);
快速重传 (fast retransmit);
快速恢复 (fast recovery)。
发送方维持一个叫做拥塞窗口cwnd(congestion window)的状态变量。当cwndssthresh时,改用拥塞避免算法。
慢开始:不要一开始就发送大量的数据,由小到大逐渐增加拥塞窗口的大小。
拥塞避免:拥塞避免算法让拥塞窗口缓慢增长,即每经过一个往返时间RTT就把发送方的拥塞窗口cwnd加1而不是加倍。这样拥塞窗口按线性规律缓慢增长。
快重传:我们可以剔除一些不必要的拥塞报文,提高网络吞吐量。比如接收方在收到一个失序的报文段后就立即发出重复确认,而不要等到自己发送数据时捎带确认。快重传规定:发送方只要一连收到三个重复确认就应当立即重传对方尚未收到的报文段,而不必继续等待设置的重传计时器时间到期。
快恢复:主要是配合快重传。当发送方连续收到三个重复确认时,就执行“乘法减小”算法,把ssthresh门限减半(为了预防网络发生拥塞),但接下来并不执行慢开始算法,因为如果网络出现拥塞的话就不会收到好几个重复的确认,收到三个重复确认说明网络状况还可以。
OSI 的七层模型都有哪些
OSI七层模型一般指开放系统互连参考模型 (Open System Interconnect 简称OSI)是国际标准化组织(ISO)和国际电报电话咨询委员会(CCITT)联合制定的开放系统互连参考模型,为开放式互连信息系统提供了一种功能结构的框架。
- 应用层:各种应用程序协议,比如 HTTP、HTTPS、FTP、SOCKS 安全套接字协议、DNS 域名系统、GDP 网关发现协议等等。
- 表示层:加密解密、转换翻译、压缩解压缩,比如 LPP 轻量级表示协议。
- 会话层:不同机器上的用户建立和管理会话,比如 SSL 安全套接字层协议、TLS 传输层安全协议、RPC 远程过程调用协议等等。
- 传输层:接受上一层的数据,在必要的时候对数据进行分割,并将这些数据交给网络层,保证这些数据段有效到达对端,比如 TCP 传输控制协议、UDP 数据报协议。
- TCP:提供面向连接的、可靠的数据传输服务;
- UDP:提供无连接的、尽最大努力的数据传输服务,但不保证数据传输的可靠性。
- 网络层:控制子网的运行:逻辑编址、分组传输、路由选择,比如 IP、IPV6、SLIP 等等。
- 数据链路层:物理寻址,同时将原始比特流转变为逻辑传输路线,比如 XTP 压缩传输协议、PPTP 点对点隧道协议等等。
- 物理层:机械、电子、定时接口通信信道上的原始比特流传输,比如 IEEE802.2 等等。
说说TCP/IP四层网络模型
TCP/IP分层模型(TCP/IP Layening Model)被称作因特网分层模型(Internet Layering Model)、因特网参考模型(Internet Reference Model)。
TCP/IP协议被组织成四个概念层,其中有三层对应于ISO参考模型中的相应层。ICP/IP协议族并不包含物理层和数据链路层,因此它不能独立完成整个计算机网络系统的功能,必须与许多其他的协议协同工作。
TCP/IP分层模型的四个协议层分别完成以下的功能:
- 第一层:网络接口层
网络接口层包括用于协作IP数据在已有网络介质上传输的协议。
协议:ARP,RARP
- 第二层:网间层
网间层对应于OSI七层参考模型的网络层。负责数据的包装、寻址和路由。同时还包含网间控制报文协议(Internet Control Message Protocol,ICMP)用来提供网络诊断信息。
协议:本层包含IP协议、RIP协议(Routing Information Protocol,路由信息协议),ICMP协议。
- 第三层:传输层
传输层对应于OSI七层参考模型的传输层,它提供两种端到端的通信服务。
其中TCP协议(Transmission Control Protocol)提供可靠的数据流运输服务,UDP协议(UseDatagram Protocol)提供不可靠的用户数据报服务。
- 第四层:应用层
应用层对应于OSI七层参考模型的应用层和表达层。
因特网的应用层协议包括Finger、Whois、FTP(文件传输协议)、Gopher、HTTP(超文本传输协议)、Telent(远程终端协议)、SMTP(简单邮件传送协议)、IRC(因特网中继会话)、NNTP(网络新闻传输协议)等。
如何实现跨域
当浏览器执行 JS 脚本的时候,会检测脚本要访问的协议、域名、端口号是不是和当前网址一致,如果不一致就是跨域。跨域是不允许的,这种限制叫做浏览器的同源策略,简单点的说法就是浏览器不允许一个源中加载脚本与其他源中的资源进行交互。那么如何实现跨域呢?主要实现方式有三种:JSONP、CORS方式、代理方式。
JSONP 方式
script、img、iframe、link、video、audio 等带有 src 属性的标签可以跨域请求和执行资源,JSONP 利用这一点“漏洞”实现跨域。
<script>
var scriptTag = document.createElement('script');
scriptTag.type = "text/javascript";
scriptTag.src = "http://10.10.0.101:8899/jsonp?callback=f";
document.head.appendChild(scriptTag);
</script>
再看下 jQuery 的写法。
$.ajax({
// 请求域名
url:'http://10.10.0.101:8899/login',
// 请求方式
type:'GET',
// 数据类型选择 jsonp
dataType:'jsonp',
// 回调方法名
jsonpCallback:'callback',
});
// 回调方法
function callback(response) {
console.log(response);
}
JSONP 实现跨域很简单但是只支持 GET 请求方式。而且在服务器端接受到 JSONP 请求后需要设置请求头,添加 Access-Control-Allow-Origin
属性,属性值为 * ,表示允许所有域名访问,这样浏览器才会正常解析,否则会报 406 错误。
response.setHeader("Access-Control-Allow-Origin", "*");
CORS 方式
CORS(Cross-Origin Resource Sharing)即跨域资源共享,需要浏览器和服务器同时支持,这种请求方式分为简单请求和非简单请求。
当浏览器发出的 XMLHttpRequest 请求的请求方式是 POST 或者 GET,请求头中只包含 Accept、Accept-Language、Content-Language、Last-Event-ID、Content-Type(applicationx-www-form-urlencoded、multipart/form-data、text/plain)时那么这个请求就是一个简单请求。
对于简单的请求,浏览器会在请求头中添加 Origin 属性,标明本次请求来自哪个源(协议 + 域名 +端口)。
GET
// 标明本次请求来自哪个源(协议+域名+端口)
Origin: http://127.0.0.1:8080
// IP
Host: 127.0.0.1:8080
// 长连接
Connection: keep-alive
Content-Type: text/plain
如果 Origin 标明的域名在服务器许可范围内,那么服务器就会给出响应:
// 该值上文提到过,表示允许浏览器指定的域名访问,要么为浏览器传入的 origin,要么为 * 表示
所有域名都可以访问
Access-Control-Allow-Origin: http://127.0.0.1:8080
// 表示服务器是否同意浏览器发送 cookie
Access-Control-Allow-Credentials: true
// 指定 XMLHttpRequest#getResponseHeader() 方法可以获取到的字段
Access-Control-Expose-Headers: xxx
Content-Type: text/html; charset=utf-8
Access-Control-Allow-Credentials: true
表示服务器同意浏览器发送 cookie,另外浏览器也需要设置支持发送 cookie,否则就算服务器支持浏览器也不会发送。
var xhr = new XMLHttpRequest();
// 设置发送的请求是否带 cookie
xhr.withCredentials = true;
xhr.open('post', 'http://10.10.0.101:8899/login', true);
xhr.setRequestHeader('Content-Type', 'text/plain');
另外一种是非简单请求,请求方式是 PUT 或 DELETE,或者请求头中添加了Content-Type:application/json 属性和属性值的请求。
这种请求在浏览器正式发出 XMLHttpRequest 请求前会先发送一个预检 HTTP 请求,询问服务器当前网页的域名是否在服务器的许可名单之中,只有得到服务器的肯定后才会正式发出通信请求。
预检请求的头信息:
// 预检请求的请求方式是 OPTIONS
OPTIONS
// 标明本次请求来自哪个源(协议+域名+端口)
Origin: http://127.0.0.1:8080
// 标明接下来的 CORS 请求要使用的请求方式
Access-Control-Request-Method: PUT
// 标明接下来的 CORS 请求要附加发送的头信息属性
Access-Control-Request-Headers: X-Custom-Header
// IP
Host: 127.0.0.1:8080
// 长连接
Connection: keep-alive
如果服务器回应预检请求的响应头中没有任何 CORS 相关的头信息的话表示不支持跨域,如果允许跨域就会做出响应,响应头信息如下:
HTTP/1.1 200 OK
// 该值上文提到过,表示允许浏览器指定的域名访问,要么为浏览器传入的 origin,要么为 * 表示所
有域名都可以访问
Access-Control-Allow-Origin:http://127.0.0.1:8080
// 服务器支持的所有跨域请求方式,为了防止浏览器发起多次预检请求把所有的请求方式返回给浏览器
Access-Control-Allow-Methods: GET, POST, PUT
// 服务器支持预检请求头信息中的 Access-Control-Request-Headers 属性值
Access-Control-Allow-Headers: X-Custom-Header
// 服务器同意浏览器发送 cookie
Access-Control-Allow-Credentials: true
// 指定预检请求的有效期是 20 天,期间不必再次发送另一个预检请求
Access-Control-Max-Age:1728000
Content-Type: text/html; charset=utf-8
Keep-Alive: timeout=2, max=100
// 长连接
Connection: Keep-Alive
Content-Type: text/plain
接着浏览器会像简单请求一样,发送一个 CORS 请求,请求头中一定包含 Origin 属性,服务器的响应头中也一定得包含 Access-Control-Allow-Origin 属性。
代理方式(最优方案)
跨域限制是浏览器的同源策略导致的,使用 nginx 当做服务器访问别的服务的 HTTP 接口是不需要执行 JS 脚步不存在同源策略限制的,所以可以利用 Nginx 创建一个代理服务器,这个代理服务器的域名跟浏览器要访问的域名一致,然后通过这个代理服务器修改 cookie 中的域名为要访问的 HTTP接口的域名,通过反向代理实现跨域。
Nginx 的配置信息:
server {
# 代理服务器的端口
listen 88;
# 代理服务器的域名
server_name http://127.0.0.1;
location / {
# 反向代理服务器的域名+端口
proxy_pass http://127.0.0.2:89;
# 修改cookie里域名
proxy_cookie_domain http://127.0.0.2 http://127.0.0.1;
index index.html index.htm;
# 设置当前代理服务器允许浏览器跨域
add_header Access-Control-Allow-Origin http://127.0.0.1;
# 设置当前代理服务器允许浏览器发送 cookie
add_header Access-Control-Allow-Credentials true;
}
}
前端代码:(正常发送http请求即可)
var xhr = new XMLHttpRequest();
// 设置浏览器允许发送 cookie
xhr.withCredentials = true;
// 访问 nginx 代理服务器
xhr.open('get', 'http://127.0.0.1:88', true);
xhr.send();
说说TCP三次握手和四次挥手的全过程
三次握手
第一次握手:客户端请求建立连接,向服务端发送一个同步报文(SYN=1),同时选择一个随机数 seq = x 作为初始序列号,并进入SYN_SENT状态,等待服务器确认。
第二次握手:服务端收到连接请求报文后,如果同意建立连接,则向客户端发送同步确认报文(SYN=1,ACK=1),确认号为 ack = x + 1,同时选择一个随机数 seq = y 作为初始序列号,此时服务器进入SYN_RECV状态。
第三次握手:客户端收到服务端的确认后,向服务端发送一个确认报文(ACK=1),确认号为 ack = y + 1,序列号为 seq = x + 1,客户端和服务器进入ESTABLISHED状态,完成三次握手。
理想状态下,TCP连接一旦建立,在通信双方中的任何一方主动关闭连接之前,TCP 连接都将被一直保持下去。
四次挥手
第一次挥手:客户端向服务端发送连接释放报文(FIN=1,ACK=1),主动关闭连接,同时等待服务端的确认。
- 序列号 seq = u,即客户端上次发送的报文的最后一个字节的序号 + 1
- 确认号 ack = k, 即服务端上次发送的报文的最后一个字节的序号 + 1
第二次挥手:服务端收到连接释放报文后,立即发出确认报文(ACK=1),序列号 seq = k,确认号 ack = u + 1。
这时 TCP 连接处于半关闭状态,即客户端到服务端的连接已经释放了,但是服务端到客户端的连接还未释放。这表示客户端已经没有数据发送了,但是服务端可能还要给客户端发送数据。
第三次挥手:服务端向客户端发送连接释放报文(FIN=1,ACK=1),主动关闭连接,同时等待 A 的确认。
- 序列号 seq = w,即服务端上次发送的报文的最后一个字节的序号 + 1。
- 确认号 ack = u + 1,与第二次挥手相同,因为这段时间客户端没有发送数据
第四次挥手:客户端收到服务端的连接释放报文后,立即发出确认报文(ACK=1),序列号 seq = u + 1,确认号为 ack = w + 1。
此时,客户端就进入了 TIME-WAIT
状态。注意此时客户端到 TCP 连接还没有释放,必须经过 2*MSL(最长报文段寿命)的时间后,才进入 CLOSED
状态。而服务端只要收到客户端发出的确认,就立即进入 CLOSED
状态。可以看到,服务端结束 TCP 连接的时间要比客户端早一些。
为什么需要三次握手,而不是两次?
主要有三个原因:
防止已过期的连接请求报文突然又传送到服务器,因而产生错误和资源浪费。
在双方两次握手即可建立连接的情况下,假设客户端发送 A 报文段请求建立连接,由于网络原因造成 A 暂时无法到达服务器,服务器接收不到请求报文段就不会返回确认报文段。
客户端在长时间得不到应答的情况下重新发送请求报文段 B,这次 B 顺利到达服务器,服务器随即返回确认报文并进入 ESTABLISHED 状态,客户端在收到 确认报文后也进入 ESTABLISHED 状态,双方建立连接并传输数据,之后正常断开连接。
此时姗姗来迟的 A 报文段才到达服务器,服务器随即返回确认报文并进入 ESTABLISHED 状态,但是已经进入 CLOSED 状态的客户端无法再接受确认报文段,更无法进入 ESTABLISHED 状态,这将导致服务器长时间单方面等待,造成资源浪费。
三次握手才能让双方均确认自己和对方的发送和接收能力都正常。
第一次握手:客户端只是发送处请求报文段,什么都无法确认,而服务器可以确认自己的接收能力和对方的发送能力正常;
第二次握手:客户端可以确认自己发送能力和接收能力正常,对方发送能力和接收能力正常;
第三次握手:服务器可以确认自己发送能力和接收能力正常,对方发送能力和接收能力正常;
可见三次握手才能让双方都确认自己和对方的发送和接收能力全部正常,这样就可以愉快地进行通信了。
告知对方自己的初始序号值,并确认收到对方的初始序号值。
TCP 实现了可靠的数据传输,原因之一就是 TCP 报文段中维护了序号字段和确认序号字段,通过这两个字段双方都可以知道在自己发出的数据中,哪些是已经被对方确认接收的。这两个字段的值会在初始序号值得基础递增,如果是两次握手,只有发起方的初始序号可以得到确认,而另一方的初始序号则得不到确认。
为什么要三次握手,而不是四次?
因为三次握手已经可以确认双方的发送接收能力正常,双方都知道彼此已经准备好,而且也可以完成对双方初始序号值得确认,也就无需再第四次握手了。
- 第一次握手:服务端确认“自己收、客户端发”报文功能正常。
- 第二次握手:客户端确认“自己发、自己收、服务端收、客户端发”报文功能正常,客户端认为连接已建立。
- 第三次握手:服务端确认“自己发、客户端收”报文功能正常,此时双方均建立连接,可以正常通信。
为什么连接的时候是三次握手,关闭的时候却是四次握手?
服务器在收到客户端的 FIN 报文段后,可能还有一些数据要传输,所以不能马上关闭连接,但是会做出应答,返回 ACK 报文段.
接下来可能会继续发送数据,在数据发送完后,服务器会向客户单发送 FIN 报文,表示数据已经发送完毕,请求关闭连接。服务器的ACK和FIN一般都会分开发送,从而导致多了一次,因此一共需要四次挥手。
为什么客户端的 TIME-WAIT 状态必须等待 2MSL ?
主要有两个原因:
- 确保 ACK 报文能够到达服务端,从而使服务端正常关闭连接。
第四次挥手时,客户端第四次挥手的 ACK 报文不一定会到达服务端。服务端会超时重传 FIN/ACK 报文,此时如果客户端已经断开了连接,那么就无法响应服务端的二次请求,这样服务端迟迟收不到 FIN/ACK 报文的确认,就无法正常断开连接。
MSL 是报文段在网络上存活的最长时间。客户端等待 2MSL 时间,即「客户端 ACK 报文 1MSL 超时 + 服务端 FIN 报文 1MSL 传输」,就能够收到服务端重传的 FIN/ACK 报文,然后客户端重传一次 ACK 报文,并重新启动 2MSL 计时器。如此保证服务端能够正常关闭。
如果服务端重发的 FIN 没有成功地在 2MSL 时间里传给客户端,服务端则会继续超时重试直到断开连接。
- 防止已失效的连接请求报文段出现在之后的连接中。
TCP 要求在 2MSL 内不使用相同的序列号。客户端在发送完最后一个 ACK 报文段后,再经过时间 2MSL,就可以保证本连接持续的时间内产生的所有报文段都从网络中消失。这样就可以使下一个连接中不会出现这种旧的连接请求报文段。或者即使收到这些过时的报文,也可以不处理它。
如果已经建立了连接,但是客户端出现故障了怎么办?
或者说,如果三次握手阶段、四次挥手阶段的包丢失了怎么办?如“服务端重发 FIN丢失”的问题。
简而言之,通过定时器 + 超时重试机制,尝试获取确认,直到最后会自动断开连接。
具体而言,TCP 设有一个保活计时器。服务器每收到一次客户端的数据,都会重新复位这个计时器,时间通常是设置为 2 小时。若 2 小时还没有收到客户端的任何数据,服务器就开始重试:每隔 75 分钟发送一个探测报文段,若一连发送 10 个探测报文后客户端依然没有回应,那么服务器就认为连接已经断开了。
TIME-WAIT 状态过多会产生什么后果?怎样处理?
从服务器来讲,短时间内关闭了大量的Client连接,就会造成服务器上出现大量的TIME_WAIT连接,严重消耗着服务器的资源,此时部分客户端就会显示连接不上。
从客户端来讲,客户端TIME_WAIT过多,就会导致端口资源被占用,因为端口就65536个,被占满就会导致无法创建新的连接。
解决办法:
服务器可以设置 SO_REUSEADDR 套接字选项来避免 TIME_WAIT状态,此套接字选项告诉内核,即使此端口正忙(处于
TIME_WAIT状态),也请继续并重用它。调整系统内核参数,修改/etc/sysctl.conf文件,即修改
net.ipv4.tcp_tw_reuse 和 tcp_timestamps
net.ipv4.tcp_tw_reuse = 1 表示开启重用。允许将TIME-WAIT sockets重新用于新的TCP连接,默认为0,表示关闭; net.ipv4.tcp_tw_recycle = 1 表示开启TCP连接中TIME-WAIT sockets的快速回收,默认为0,表示关闭。
强制关闭,发送 RST 包越过TIME_WAIT状态,直接进入CLOSED状态。
TIME_WAIT 是服务器端的状态?还是客户端的状态?
TIME_WAIT 是主动断开连接的一方会进入的状态,一般情况下,都是客户端所处的状态;服务器端一般设置不主动关闭连接。
TIME_WAIT 需要等待 2MSL,在大量短连接的情况下,TIME_WAIT会太多,这也会消耗很多系统资源。对于服务器来说,在 HTTP 协议里指定 KeepAlive(浏览器重用一个 TCP 连接来处理多个 HTTP 请求),由浏览器来主动断开连接,可以一定程度上减少服务器的这个问题。
什么是 SYN洪泛攻击?如何防范?
SYN洪泛攻击属于 DOS 攻击的一种,它利用 TCP 协议缺陷,通过发送大量的半连接请求,耗费 CPU 和内存资源。
原理:
- 在三次握手过程中,服务器发送
[SYN/ACK]
包(第二个包)之后、收到客户端的[ACK]
包(第三个包)之前的 TCP 连接称为半连接(half-open connect),此时服务器处于SYN_RECV
(等待客户端响应)状态。如果接收到客户端的[ACK]
,则 TCP 连接成功,如果未接受到,则会不断重发请求直至成功。 - SYN 攻击的攻击者在短时间内伪造大量不存在的 IP 地址,向服务器不断地发送
[SYN]
包,服务器回复[SYN/ACK]
包,并等待客户的确认。由于源地址是不存在的,服务器需要不断的重发直至超时。 - 这些伪造的
[SYN]
包将长时间占用未连接队列,影响了正常的 SYN,导致目标系统运行缓慢、网络堵塞甚至系统瘫痪。
检测:
当在服务器上看到大量的半连接状态时,特别是源 IP 地址是随机的,基本上可以断定这是一次 SYN 攻击。
防范:
- 通过防火墙、路由器等过滤网关防护。
- 通过加固 TCP/IP 协议栈防范,如增加最大半连接数,缩短超时时间。
- SYN cookies技术。SYN Cookies 是对 TCP 服务器端的三次握手做一些修改,专门用来防范 SYN 洪泛攻击的一种手段。
三次握手连接阶段,最后一次ACK包丢失,会发生什么?
服务端:
- 第三次的ACK在网络中丢失,那么服务端该TCP连接的状态为SYN_RECV,并且会根据 TCP的超时重传机制,会等待3秒、6秒、12秒后重新发送SYN+ACK包,以便客户端重新发送ACK包。
- 如果重发指定次数之后,仍然未收到 客户端的ACK应答,那么一段时间后,服务端自动关闭这个连接。
客户端:
客户端认为这个连接已经建立,如果客户端向服务端发送数据,服务端将以RST包(Reset,标示复位,用于异常的关闭连接)响应。此时,客户端知道第三次握手失败。
说说域名解析详细过程
- 域名解析(域名 www.baidu.com 变为 ip 地址)。
浏览器搜索自己的DNS缓存(维护一张域名与IP的对应表);若没有,则搜索操作系统的DNS缓存(维护一张域名与IP的对应表);若没有,则搜索操作系统的hosts文件(维护一张域名与IP的对应表)。
若都没有,则找 tcp/ip 参数中设置的首选 dns 服务器,即本地 dns 服务器(递归查询),本地域名服务器查询自己的dns缓存,如果没有,则进行迭代查询。将本地dns服务器将IP返回给操作系统,同时缓存IP。
发起 tcp 的三次握手,建立 tcp 连接。浏览器会以一个随机端口(1024-65535)向服务端的 web 程序 80 端口发起 tcp 的连接。
建立 tcp 连接后发起 http 请求。
服务器响应 http 请求,客户端得到 html 代码。服务器 web 应用程序收到 http 请求后,就开始处理请求,处理之后就返回给浏览器 html 文件。
浏览器解析 html 代码,并请求 html 中的资源。
浏览器对页面进行渲染,并呈现给用户。
什么是 Cookie 和 Session ?
什么是 Cookie
HTTP Cookie(也叫 Web Cookie或浏览器 Cookie)是服务器发送到用户浏览器并保存在本地的一小块数据,它会在浏览器下次向同一服务器再发起请求时被携带并发送到服务器上。通常,它用于告知服务端两个请求是否来自同一浏览器,如保持用户的登录状态。Cookie 使基于无状态的 HTTP 协议记录稳定的状态信息成为了可能。
Cookie 主要用于以下三个方面:
- 会话状态管理(如用户登录状态、购物车、游戏分数或其它需要记录的信息)
- 个性化设置(如用户自定义设置、主题等)
- 浏览器行为跟踪(如跟踪分析用户行为等)
什么是 Session
Session 代表着服务器和客户端一次会话的过程。Session 对象存储特定用户会话所需的属性及配置信息。这样,当用户在应用程序的 Web 页之间跳转时,存储在 Session 对象中的变量将不会丢失,而是在整个用户会话中一直存在下去。当客户端关闭会话,或者 Session 超时失效时会话结束。
Cookie和Session的区别?
- 作用范围不同,Cookie 保存在客户端(浏览器),Session 保存在服务器端。
- 存取方式的不同,Cookie 只能保存 ASCII,Session 可以存任意数据类型,一般情况下我们可以在 Session 中保持一些常用变量信息,比如说 UserId 等。
- 有效期不同,Cookie 可设置为长时间保持,比如我们经常使用的默认登录功能,Session 一般失效时间较短,客户端关闭或者 Session 超时都会失效。
- 隐私策略不同,Cookie 存储在客户端,比较容易遭到不法获取,早期有人将用户的登录名和密码存储在 Cookie 中导致信息被窃取;Session 存储在服务端,安全性相对 Cookie 要好一些。
- 存储大小不同, 单个 Cookie 保存的数据不能超过 4K,Session 可存储数据远高于 Cookie。
Cookie 和 Session 是如何配合的呢?
用户第一次请求服务器的时候,服务器根据用户提交的相关信息,创建对应的 Session ,请求返回时将此 Session 的唯一标识信息 SessionID 返回给浏览器,浏览器接收到服务器返回的 SessionID 信息后,会将此信息存入到 Cookie 中,同时 Cookie 记录此 SessionID 属于哪个域名。
当用户第二次访问服务器的时候,请求会自动判断此域名下是否存在 Cookie 信息,如果存在自动将 Cookie 信息也发送给服务端,服务端会从 Cookie 中获取 SessionID,再根据 SessionID 查找对应的 Session 信息,如果没有找到说明用户没有登录或者登录失效,如果找到 Session 证明用户已经登录可执行后面操作。
根据以上流程可知,SessionID 是连接 Cookie 和 Session 的一道桥梁,大部分系统也是根据此原理来验证用户登录状态。
如何考虑分布式 Session 问题?
在互联网公司为了可以支撑更大的流量,后端往往需要多台服务器共同来支撑前端用户请求,那如果用户在 A 服务器登录了,第二次请求跑到服务 B 就会出现登录失效问题。
分布式 Session 一般会有以下几种解决方案:
- 客户端存储:直接将信息存储在cookie中,cookie是存储在客户端上的一小段数据,客户端通过http协议和服务器进行cookie交互,通常用来存储一些不敏感信息
- Nginx ip_hash 策略:服务端使用 Nginx 代理,每个请求按访问 IP 的 hash 分配,这样来自同一 IP 固定访问一个后台服务器,避免了在服务器 A 创建 Session,第二次分发到服务器 B 的现象。
- Session 复制:任何一个服务器上的 Session 发生改变(增删改),该节点会把这个 Session 的所有内容序列化,然后广播给所有其它节点。
- 共享 Session:服务端无状态话,将用户的 Session 等信息使用缓存中间件(如Redis)来统一管理,保障分发到每一个服务器的响应结果都一致。
建议采用共享 Session的方案。
什么是DDos攻击?
DDos全称Distributed Denial of Service,分布式拒绝服务攻击。最基本的DOS攻击过程如下:
- 客户端向服务端发送请求链接数据包。
- 服务端向客户端发送确认数据包。
- 客户端不向服务端发送确认数据包,服务器一直等待来自客户端的确认
DDoS则是采用分布式的方法,通过在网络上占领多台“肉鸡”,用多台计算机发起攻击。
DOS攻击现在基本没啥作用了,因为服务器的性能都很好,而且是多台服务器共同作用,1V1的模式黑客无法占上风。
对于DDOS攻击,预防方法有:
- 减少SYN timeout时间。在握手的第三步,服务器会等待30秒-120秒的时间,减少这个等待时间就能释放更多的资源。
- 限制同时打开的SYN半连接数目。
什么是XSS攻击?
XSS也称 cross-site scripting,跨站脚本。这种攻击是由于服务器将攻击者存储的数据原原本本地显示给其他用户所致的。比如一个存在XSS漏洞的论坛,用户发帖时就可以引入带有<script>标签的代码,导致恶意代码的执行。
预防措施有:
- 前端:过滤。
- 后端:转义,比如go自带的处理器就具有转义功能。
SQL注入是什么,如何避免SQL注入?
SQL 注入就是在用户输入的字符串中加入 SQL 语句,如果在设计不良的程序中忽略了检查,那么这些注入进去的 SQL 语句就会被数据库服务器误认为是正常的 SQL 语句而运行,攻击者就可以执行计划外的命令或访问未被授权的数据。
SQL注入的原理主要有以下4点
- 恶意拼接查询
- 利用注释执行非法命令
- 传入非法参数
- 添加额外条件
避免SQL注入的一些方法:
- 限制数据库权限,给用户提供仅仅能够满足其工作的最低权限。
- 对进入数据库的特殊字符(’”\尖括号&*;等)转义处理。
- 提供参数化查询接口,不要直接使用原生SQL。
负载均衡算法有哪些?
多台服务器以对称的方式组成一个服务器集合,每台服务器都具有等价的地位,能互相分担负载。
- 轮询法:将请求按照顺序轮流的分配到服务器上。大锅饭,不能发挥某些高性能服务器的优势。
- 随机法:随机获取一台,和轮询类似。
- 哈希法:通过ip地址哈希化来确定要选择的服务器编号。好处是,每次客户端访问的服务器都是同一个服务器,能很好地利用session或者cookie。
- 加权轮询:根据服务器性能不同加权。
16. ElasticSearch篇
谈谈分词与倒排索引的原理
首先说分词是给检索用的。
- 英文:一个单词一个词,很简单。I am a student,词与词之间空格分隔。
- 中文:我是学生,就不能一个字一个字地分,我-是-学生。这是好分的。还有歧义的,使用户放心,使用-户,使-用户。人很容易看出,机器就难多了。所以市面上有各种各样的分词器,一个强调的效率一个强调的准确率。
倒排索引:倒排针对的是正排。
正排就是我记得我电脑有个文档,讲了 ES 的常见问题总结。那么我就找到文档,从上往下翻页,找到 ES 的部分。通过文档找文档内容。
倒排:一个 txt 文件 ES 的常见问题 -> D:/分布式问题总结.doc
所以倒排就是文档内容找文档。当然内容不是全部的,否则也不需要找文档了,内容就是几个分词而已。这里的 txt 就是搜索引擎。
说说分段存储的思想
Lucene 是著名的搜索开源软件,ElasticSearch 和 Solr 底层用的都是它。
分段存储是 Lucene 的思想。
早期,都是一个整个文档建立一个大的倒排索引。简单,快速,但是问题随之而来。
文档有个很小的改动,整个索引需要重新建立,速度慢,成本高,为了提高速度,定期更新那么时效性就差。
现在一个索引文件,拆分为多个子文件,每个子文件是段。修改的数据不影响的段不必做处理。
谈谈你对段合并的策略思想的认识
分段的思想大大的提高了维护索引的效率。但是随之就有了新的问题。
每次新增数据就会新增加一个段,时间久了,一个文档对应的段非常多。段多了,也就影响检索性能了。
检索过程:
- 查询所有短中满足条件的数据
- 对每个段的结果集合并
所以,定期的对段进行合理是很必要的。真是天下大势,分久必合合久必分。
策略:将段按大小排列分组,大到一定程度的不参与合并。小的组内合并。整体维持在一个合理的大小范围。当然这个大到底应该是多少,是用户可配置的。这也符合设计的思想。
了解文本相似度 TF-IDF吗
简单地说,就是你检索一个词,匹配出来的文章,网页太多了。比如 1000 个,这些内容再该怎么呈现,哪些在前面哪些在后面。这需要也有个对匹配度的评分。
TF-IDF 就是干这个的。
- TF = Term Frequency 词频,一个词在这个文档中出现的频率。值越大,说明这文档越匹配,正向指标。
- IDF = Inverse Document Frequency 反向文档频率,简单点说就是一个词在所有文档中都出现,那么这个词不重要。比如“的、了、我、好”这些词所有文档都出现,对检索毫无帮助。反向指标。
TF-IDF = TF / IDF
ElasticSearch 写索引的逻辑
ElasticSearch 是集群的 = 主分片 + 副本分片。
写索引只能写主分片,然后主分片同步到副本分片上。但主分片不是固定的,可能网络原因,之前还是 Node1 是主分片,后来就变成了 Node2 经过选举成了主分片了。
客户端如何知道哪个是主分片呢? 看下面过程。
- 客户端向某个节点 NodeX 发送写请求
- NodeX 通过文档信息,请求会转发到主分片的节点上
- 主分片处理完,通知到副本分片同步数据,向 Nodex 发送成功信息。
- Nodex 将处理结果返回给客户端。
ElasticSearch 集群中搜索数据的过程
- 客户端向集群发送请求,集群随机选择一个 NodeX 处理这次请求。
- Nodex 先计算文档在哪个主分片上,比如是主分片 A,它有三个副本 A1,A2,A3。那么请求会轮询三个副本中的一个完成请求。
- 如果无法确认分片,比如检索的不是一个文档,就遍历所有分片。
一个节点的存储量是有限的,于是有了分片的概念。但是分片可能有丢失,于是有了副本的概念。
比如:ES 集群有 3 个分片,分片 A、分片 B、分片 C,那么分片 A + 分片 B + 分片 C = 所有数据,每个分片只有大概 1/3。分片 A 又有副本 A1 A2 A3,数据都是一样的。
ElasticSearch 深翻页的问题及解决
深翻页:比如我们检索一次,轮询所有分片,汇集结果,根据 TF-IDF 等算法打分,排序后将前 10条数据返回。用户感觉不错,说我看看下一页。ES 依然是轮询所有分片,汇集结果,根据 TF-IDF等算法打分,排序后将前 11-20 条数据返回。
对用户来说,翻页应该很快啊,但是实际上,第一次检索多复杂,下一次检索就多复杂。
解决的话,可以把用户的检索结果,存入 Redis 中 10 分钟。这样分页就很快,超过 10 分钟,用户不翻页,也就不会翻页了,数据就可以清除了。
ElasticSearch 性能优化
批量提交
背景是大量的写操作,每次提交都是一次网络开销。网络永久是优化要考虑的重点。
优化硬盘
索引文件需要落地硬盘,段的思想又带来了更多的小文件,磁盘 IO 是 ES 的性能瓶颈。一个固态硬盘比普通硬盘好太多。
减少副本数量
副本可以保证集群的可用性,但是严重影响了 写索引的效率。写索引时不只完成写入索引,还要完成索引到副本的同步。ES 不是存储引擎,不要考虑数据丢失,性能更重要。 如果是批量导入,建议就关闭副本。
ElasticSearch 查询优化手段
设计阶段调优
- 根据业务增量需求,采取基于日期模板创建索引,通过 roll over API 滚动索引;
- 使用别名进行索引管理;
- 每天凌晨定时对索引做 force_merge 操作,以释放空间;
- 采取冷热分离机制,热数据存储到 SSD,提高检索效率;冷数据定期进行 shrink操作,以缩减存储;
- 采取 curator 进行索引的生命周期管理;
- 仅针对需要分词的字段,合理的设置分词器;
- Mapping 阶段充分结合各个字段的属性,是否需要检索、是否需要存储等。
写入调优
- 写入前副本数设置为 0;
- 写入前关闭 refresh_interval 设置为-1,禁用刷新机制;
- 写入过程中:采取 bulk 批量写入;
- 写入后恢复副本数和刷新间隔;
- 尽量使用自动生成的 id。
查询调优
- 禁用 wildcard;
- 禁用批量 terms(成百上千的场景)
- 充分利用倒排索引机制,能 keyword 类型尽量 keyword;
- 数据量大时候,可以先基于时间敲定索引再检索;
- 设置合理的路由机制。
其他调优
部署调优,业务调优等。
Elasticsearch如何实现master选举
前置前提:
- 只有候选主节点(master:true)的节点才能成为主节点。
- 最小主节点数(min_master_nodes)的目的是防止脑裂。
核对了一下代码,核心入口为 findMaster,选择主节点成功返回对应 Master,否则返回 null。选举流程大致描述如下:
第一步:确认候选主节点数达标,elasticsearch.yml 设置的值discovery.zen.minimum_master_nodes;
第二步:比较:先判定是否具备 master 资格,具备候选主节点资格的优先返回;若两节点都为候选主节点,则 id 小的值会主节点。注意这里的 id 为 string 类型。
elasticsearch 索引数据多了怎么办,如何调优,部署
索引数据的规划,应在前期做好规划,正所谓“设计先行,编码在后”,这样才能有效的避免突如其来的数据激增导致集群处理能力不足引发的线上客户检索或者其他业务受到影响。
动态索引层面
基于模板+时间+rollover api 滚动创建索引,举例:设计阶段定义:blog 索引的模板格式为:blog_index_时间戳的形式,每天递增数据。这样做的好处:不至于数据量激增导致单个索引数据量非常大,接近于上线 2 的32 次幂-1,索引存储达到了 TB+甚至更大。
一旦单个索引很大,存储等各种风险也随之而来,所以要提前考虑+及早避免。
存储层面
冷热数据分离存储,热数据(比如最近 3 天或者一周的数据),其余为冷数据。
对于冷数据不会再写入新数据,可以考虑定期 force_merge 加 shrink 压缩操作,节省存储空间和检索效率。
部署层面
一旦之前没有规划,这里就属于应急策略。
结合 ES 自身的支持动态扩展的特点,动态新增机器的方式可以缓解集群压力,注意:如果之前主节点等规划合理,不需要重启集群也能完成动态新增的。
ElasticSearch中的集群、节点、索引、文档、类型
- 群集是一个或多个节点(服务器)的集合,它们共同保存您的整个数据,并提供跨所有节点的联合索引和搜索功能。群集由唯一名称标识,默认情况下为“elasticsearch”。此名称很重要,因为如果节点设置为按名称加入群集,则该节点只能是群集的一部分。
- 节点是属于集群一部分的单个服务器。它存储数据并参与群集索引和搜索功能。
- 索引就像关系数据库中的“数据库”。它有一个定义多种类型的映射。索引是逻辑名称空间,映射到一个或多个主分片,并且可以有零个或多个副本分片。 MySQL =>数据库 ElasticSearch=>索引
- 文档类似于关系数据库中的一行。不同之处在于索引中的每个文档可以具有不同的结构(字段),但是对于通用字段应该具有相同的数据类型。 MySQL => Databases => Tables =>Columns / Rows ElasticSearch => Indices => Types =>具有属性的文档
- 类型是索引的逻辑类别/分区,其语义完全取决于用户
ElasticSearch中的分片是什么
在大多数环境中,每个节点都在单独的盒子或虚拟机上运行。
- 索引-在Elasticsearch中,索引是文档的集合。
- 分片-因为Elasticsearch是一个分布式搜索引擎,所以索引通常被分割成分布在多个节点上的
被称为分片的元素。
ElasticSearch中的副本是什么
一个索引被分解成碎片以便于分发和扩展。副本是分片的副本。一个节点是一个属于一个集群的ElasticSearch的运行实例。一个集群由一个或多个共享相同集群名称的节点组成。
ElasticSearch中的分析器是什么
在ElasticSearch中索引数据时,数据由为索引定义的Analyzer在内部进行转换。 分析器由一个Tokenizer和零个或多个TokenFilter组成。编译器可以在一个或多个CharFilter之前。分析模块允许您在逻辑名称下注册分析器,然后可以在映射定义或某些API中引用它们。
Elasticsearch附带了许多可以随时使用的预建分析器。或者,您可以组合内置的字符过滤器,编译器和过滤器器来创建自定义分析器。
什么是ElasticSearch中的编译器
编译器用于将字符串分解为术语或标记流。一个简单的编译器可能会将字符串拆分为任何遇到空格或标点的地方。Elasticsearch有许多内置标记器,可用于构建自定义分析器。
什么是ElasticSearch中的过滤器
数据由Tokenizer处理后,在编制索引之前,过滤器会对其进行处理。
启用属性,索引和存储的用途是什么
enabled属性适用于各类ElasticSearch特定/创建领域,如index和size。用户提供的字段没有“已启用”属性。 存储意味着数据由Lucene存储,如果询问,将返回这些数据。
存储字段不一定是可搜索的。默认情况下,字段不存储,但源文件是完整的。因为您希望使用默认值(这是有意义的),所以不要设置store属性 该指数属性用于搜索。
索引属性只能用于搜索。只有索引域可以进行搜索。差异的原因是在分析期间对索引字段进行了转换,因此如果需要的话,您不能检索原始数据。
17. Netty篇
Netty 是什么?
Netty是 一个异步事件驱动的网络应用程序框架,用于快速开发可维护的高性能协议服务器和客户端。Netty是基于nio的,它封装了jdk的nio,让我们使用起来更加方法灵活。
Netty 的特点是什么?
高并发:Netty 是一款基于 NIO(Nonblocking IO,非阻塞IO)开发的网络通信框架,对比于 BIO(Blocking I/O,阻塞IO),他的并发性能得到了很大提高。
传输快:Netty 的传输依赖于零拷贝特性,尽量减少不必要的内存拷贝,实现了更高效率的传输。
封装好:Netty 封装了 NIO 操作的很多细节,提供了易于使用调用接口。
Netty 的优势有哪些?
使用简单:封装了 NIO 的很多细节,使用更简单。
功能强大:预置了多种编解码功能,支持多种主流协议。
定制能力强:可以通过 ChannelHandler 对通信框架进行灵活地扩展。
性能高:通过与其他业界主流的 NIO 框架对比,Netty 的综合性能最优。
稳定:Netty 修复了已经发现的所有 NIO 的 bug,让开发人员可以专注于业务本身。
社区活跃:Netty 是活跃的开源项目,版本迭代周期短,bug 修复速度快。
Netty 的高性能表现在哪些方面?
心跳,对服务端:会定时清除闲置会话 inactive(netty5),对客户端:用来检测会话是否断开,是否重来,检测网络延迟,其中 idleStateHandler 类 用来检测会话状态
串行无锁化设计,即消息的处理尽可能在同一个线程内完成,期间不进行线程切换,这样就避免了多线程竞争和同步锁。表面上看,串行化设计似乎 CPU 利用率不高,并发程度不够。但是,通过调整 NIO 线程池的线程参数,可以同时启动多个串行化的线程并行运行,这种局部无锁化的串行线程设计相比一个队列-多个工作线程模型性能更优。
可靠性,链路有效性检测:链路空闲检测机制,读/写空闲超时机制;内存保护机制:通过内存池重用 ByteBuf;ByteBuf 的解码保护;优雅停机:不再接收新消息、退出前的预处理操作、资源的释放操作。
Netty 安全性:支持的安全协议SSL V2 和 V3,TLS,SSL 单向认证、双向认证和第三方 CA证。
高效并发编程的体现:volatile 的大量、正确使用;CAS 和原子类的广泛使用;线程安全容器的使用;通过读写锁提升并发性能。IO 通信性能三原则:传输(AIO)、协议(Http)、线程(主从多线程)
流量整型的作用(变压器):防止由于上下游网元性能不均衡导致下游网元被压垮,业务流中断;防止由于通信模块接受消息过快,后端业务线程处理不及时导致撑死问题。
TCP 参数配置:SO_RCVBUF 和 SO_SNDBUF:通常建议值为 128K 或者 256K;
SO_TCPNODELAY:NAGLE 算法通过将缓冲区内的小封包自动相连,组成较大的封包,阻止大量小封包的发送阻塞网络,从而提高网络应用效率。但是对于时延敏感的应用场景需要关闭该优化算法;
18. 场景分析题
硬件故障排查
如果一个实例发生了问题,根据情况选择,要不要着急去重启。如果出现的CPU、内存飙高或者日志里出现了OOM异常
第一步是隔离,第二步是保留现场,第三步才是问题排查。
隔离
就是把你的这台机器从请求列表里摘除,比如把 nginx 相关的权重设成零。
现场保留
瞬时态和历史态
查看比如 CPU、系统内存等,通过历史状态可以体现一个趋势性问题,而这些信息的获取一般依靠监控系统的协作。
保留信息
- 系统当前网络连接
ss -antp > $DUMP_DIR/ss.dump 2>&1
使用 ss 命令而不是 netstat 的原因,是因为 netstat 在网络连接非常多的情况下,执行非常缓慢。
后续的处理,可通过查看各种网络连接状态的梳理,来排查 TIME_WAIT 或者 CLOSE_WAIT,或者其他连接过高的问题,非常有用。
- 网络状态统计
netstat -s > $DUMP_DIR/netstat-s.dump 2>&1
它能够按照各个协议进行统计输出,对把握当时整个网络状态,有非常大的作用。
sar -n DEV 1 2 > $DUMP_DIR/sar-traffic.dump 2>&1
在一些速度非常高的模块上,比如 Redis、Kafka,就经常发生跑满网卡的情况。表现形式就是网络通信非常缓慢。
- 进程资源
lsof -p $PID > $DUMP_DIR/lsof-$PID.dump
通过查看进程,能看到打开了哪些文件,可以以进程的维度来查看整个资源的使用情况,包括每条网络连接、每个打开的文件句柄。同时,也可以很容易的看到连接到了哪些服务器、使用了哪些资源。这个命令在资源非常多的情况下,输出稍慢,请耐心等待。
- CPU 资源
mpstat > $DUMP_DIR/mpstat.dump 2>&1
vmstat 1 3 > $DUMP_DIR/vmstat.dump 2>&1
sar -p ALL > $DUMP_DIR/sar-cpu.dump 2>&1
uptime > $DUMP_DIR/uptime.dump 2>&1
主要用于输出当前系统的 CPU 和负载,便于事后排查。
- I/O 资源
iostat -x > $DUMP_DIR/iostat.dump 2>&1
一般,以计算为主的服务节点,I/O 资源会比较正常,但有时也会发生问题,比如日志输出过多,或者磁盘问题等。此命令可以输出每块磁盘的基本性能信息,用来排查 I/O 问题。在第 8 课时介绍的 GC 日志分磁盘问题,就可以使用这个命令去发现。
- 内存问题
free -h > $DUMP_DIR/free.dump 2>&1
free 命令能够大体展现操作系统的内存概况,这是故障排查中一个非常重要的点,比如 SWAP 影响了 GC,SLAB 区挤占了 JVM 的内存。
- 其他全局
ps -ef > $DUMP_DIR/ps.dump 2>&1
dmesg > $DUMP_DIR/dmesg.dump 2>&1
sysctl -a > $DUMP_DIR/sysctl.dump 2>&1
dmesg 是许多静悄悄死掉的服务留下的最后一点线索。当然,ps 作为执行频率最高的一个命令,由于内核的配置参数,会对系统和 JVM 产生影响,所以我们也输出了一份。
- 进程快照,最后的遗言(jinfo)
${JDK_BIN}jinfo $PID > $DUMP_DIR/jinfo.dump 2>&1
此命令将输出 Java 的基本进程信息,包括环境变量和参数配置,可以查看是否因为一些错误的配置造成了 JVM 问题。
- dump 堆信息
${JDK_BIN}jstat -gcutil $PID > $DUMP_DIR/jstat-gcutil.dump 2>&1
${JDK_BIN}jstat -gccapacity $PID > $DUMP_DIR/jstat-gccapacity.dump 2>&1
jstat 将输出当前的 gc 信息。一般,基本能大体看出一个端倪,如果不能,可将借助 jmap 来进行分析。
- 堆信息
${JDK_BIN}jmap $PID > $DUMP_DIR/jmap.dump 2>&1
${JDK_BIN}jmap -heap $PID > $DUMP_DIR/jmap-heap.dump 2>&1
${JDK_BIN}jmap -histo $PID > $DUMP_DIR/jmap-histo.dump 2>&1
${JDK_BIN}jmap -dump:format=b,file=$DUMP_DIR/heap.bin $PID > /dev/null 2>&1
jmap 将会得到当前 Java 进程的 dump 信息。如上所示,其实最有用的就是第 4 个命令,但是前面三个能够让你初步对系统概况进行大体判断。因为,第 4 个命令产生的文件,一般都非常的大。而且,需要下载下来,导入 MAT 这样的工具进行深入分析,才能获取结果。这是分析内存泄漏一个必经的过程。
- JVM 执行栈
${JDK_BIN}jstack $PID > $DUMP_DIR/jstack.dump 2>&1
jstack 将会获取当时的执行栈。一般会多次取值,我们这里取一次即可。这些信息非常有用,能够还原 Java 进程中的线程情况。
top -Hp $PID -b -n 1 -c > $DUMP_DIR/top-$PID.dump 2>&1
为了能够得到更加精细的信息,我们使用 top 命令,来获取进程中所有线程的 CPU 信息,这样,就可以看到资源到底耗费在什么地方了。
- 高级替补
kill -3 $PID
有时候,jstack 并不能够运行,有很多原因,比如 Java 进程几乎不响应了等之类的情况。我们会尝试向进程发送 kill -3 信号,这个信号将会打印 jstack 的 trace 信息到日志文件中,是 jstack 的一个替补方案。
gcore -o $DUMP_DIR/core $PID
对于 jmap 无法执行的问题,也有替补,那就是 GDB 组件中的 gcore,将会生成一个 core 文件。我们可以使用如下的命令去生成 dump:
${JDK_BIN}jhsdb jmap --exe ${JDK}java --core $DUMP_DIR/core --binaryheap
内存泄漏的现象
稍微提一下 jmap 命令,它在 9 版本里被干掉了,取而代之的是 jhsdb,你可以像下面的命令一样使用。
jhsdb jmap --heap --pid 37340
jhsdb jmap --pid 37288
jhsdb jmap --histo --pid 37340
jhsdb jmap --binaryheap --pid 37340
一般内存溢出,表现形式就是 Old 区的占用持续上升,即使经过了多轮 GC 也没有明显改善。比如ThreadLocal里面的GC Roots,内存泄漏的根本就是,这些对象并没有切断和 GC Roots 的关系,可通过一些工具,能够看到它们的联系。
报表异常 | JVM调优
有一个报表系统,频繁发生内存溢出,在高峰期间使用时,还会频繁的发生拒绝服务,由于大多数使用者是管理员角色,所以很快就反馈到研发这里。
业务场景是由于有些结果集的字段不是太全,因此需要对结果集合进行循环,并通过 HttpClient 调用其他服务的接口进行数据填充。使用 Guava 做了 JVM 内缓存,但是响应时间依然很长。
初步排查,JVM 的资源太少。接口 A 每次进行报表计算时,都要涉及几百兆的内存,而且在内存里驻留很长时间,有些计算又非常耗 CPU,特别的“吃”资源。而我们分配给 JVM 的内存只有 3 GB,在多人访问这些接口的时候,内存就不够用了,进而发生了 OOM。在这种情况下,没办法,只有升级机器。把机器配置升级到 4C8G,给 JVM 分配 6GB 的内存,这样 OOM 问题就消失了。但随之而来的是频繁的 GC 问题和超长的 GC 时间,平均 GC 时间竟然有 5 秒多。
进一步,由于报表系统和高并发系统不太一样,它的对象,存活时长大得多,并不能仅仅通过增加年轻代来解决;而且,如果增加了年轻代,那么必然减少了老年代的大小,由于 CMS 的碎片和浮动垃圾问题,我们可用的空间就更少了。虽然服务能够满足目前的需求,但还有一些不太确定的风险。
第一,了解到程序中有很多缓存数据和静态统计数据,为了减少 MinorGC 的次数,通过分析 GC 日志打印的对象年龄分布,把 MaxTenuringThreshold 参数调整到了 3(特殊场景特殊的配置)。这个参数是让年轻代的这些对象,赶紧回到老年代去,不要老呆在年轻代里。
第二,我们的 GC 时间比较长,就一块开了参数 CMSScavengeBeforeRemark,使得在 CMS remark 前,先执行一次 Minor GC 将新生代清掉。同时配合上个参数,其效果还是比较好的,一方面,对象很快晋升到了老年代,另一方面,年轻代的对象在这种情况下是有限的,在整个 MajorGC 中占的时间也有限。
第三,由于缓存的使用,有大量的弱引用,拿一次长达 10 秒的 GC 来说。我们发现在 GC 日志里,处理 weak refs 的时间较长,达到了 4.5 秒。这里可以加入参数 ParallelRefProcEnabled 来并行处理Reference,以加快处理速度,缩短耗时。
优化之后,效果不错,但并不是特别明显。经过评估,针对高峰时期的情况进行调研,我们决定再次提升机器性能,改用 8core16g 的机器。但是,这带来另外一个问题。
高性能的机器带来了非常大的服务吞吐量,通过 jstat 进行监控,能够看到年轻代的分配速率明显提高,但随之而来的 MinorGC 时长却变的不可控,有时候会超过 1 秒。累积的请求造成了更加严重的后果。
这是由于堆空间明显加大造成的回收时间加长。为了获取较小的停顿时间,我们在堆上改用了 G1 垃圾回收器,把它的目标设定在 200ms。G1 是一款非常优秀的垃圾收集器,不仅适合堆内存大的应用,同时也简化了调优的工作。通过主要的参数初始和最大堆空间、以及最大容忍的 GC 暂停目标,就能得到不错的性能。修改之后,虽然 GC 更加频繁了一些,但是停顿时间都比较小,应用的运行较为平滑。
到目前为止,也只是勉强顶住了已有的业务,但是,这时候领导层面又发力,要求报表系统可以支持未来两年业务10到100倍的增长,并保持其可用性,但是这个“千疮百孔”的报表系统,稍微一压测,就宕机,那如何应对十倍百倍的压力呢 ? 硬件即使可以做到动态扩容,但是毕竟也有极限。
使用 MAT 分析堆快照,发现很多地方可以通过代码优化,那些占用内存特别多的对象:
select * 全量排查,只允许获取必须的数据
报表系统中cache实际的命中率并不高,将Guava 的 Cache 引用级别改成弱引用(WeakKeys)
限制报表导入文件大小,同时拆分用户超大范围查询导出请求。
每一步操作都使得JVM使用变得更加可用,一系列优化以后,机器相同压测数据性能提升了数倍。
大屏异常 | JUC调优
有些数据需要使用 HttpClient 来获取进行补全。提供数据的服务提供商有的响应时间可能会很长,也有可能会造成服务整体的阻塞。
接口 A 通过 HttpClient 访问服务 2,响应 100ms 后返回;接口 B 访问服务 3,耗时 2 秒。HttpClient 本身是有一个最大连接数限制的,如果服务 3 迟迟不返回,就会造成 HttpClient 的连接数达到上限,概括来讲,就是同一服务,由于一个耗时非常长的接口,进而引起了整体的服务不可用
这个时候,通过 jstack 打印栈信息,会发现大多数竟然阻塞在了接口 A 上,而不是耗时更长的接口 B,这个现象起初十分具有迷惑性,不过经过分析后,我们猜想其实是因为接口 A 的速度比较快,在问题发生点进入了更多的请求,它们全部都阻塞住的同时被打印出来了。
为了验证这个问题,我搭建了一个demo 工程,模拟了两个使用同一个 HttpClient 的接口。fast 接口用来访问百度,很快就能返回;slow 接口访问谷歌,由于众所周知的原因,会阻塞直到超时,大约 10 s。 利用ab对两个接口进行压测,同时使用 jstack 工具 dump 堆栈。首先使用 jps 命令找到进程号,然后把结果重定向到文件(可以参考 10271.jstack 文件)。
过滤一下 nio 关键字,可以查看 tomcat 相关的线程,足足有 200 个,这和 Spring Boot 默认的 maxThreads 个数不谋而合。更要命的是,有大多数线程,都处于 BLOCKED 状态,说明线程等待资源超时。通过grep fast | wc -l 分析,确实200个中有150个都是blocked的fast的进程。
问题找到了,解决方式就顺利成章了。
fast和slow争抢连接资源,通过线程池限流或者熔断处理
有时候slow的线程也不是一直slow,所以就得加入监控
使用带countdownLaunch对线程的执行顺序逻辑进行控制
接口延迟 | SWAP调优
有一个关于服务的某个实例,经常发生服务卡顿。由于服务的并发量是比较高的,每多停顿 1 秒钟,几万用户的请求就会感到延迟。
我们统计、类比了此服务其他实例的 CPU、内存、网络、I/O 资源,区别并不是很大,所以一度怀疑是机器硬件的问题。
接下来我们对比了节点的 GC 日志,发现无论是 Minor GC,还是 Major GC,这个节点所花费的时间,都比其他实例长得多。
通过仔细观察,我们发现在 GC 发生的时候,vmstat 的 si、so 飙升的非常严重,这和其他实例有着明显的不同。
使用 free 命令再次确认,发现 SWAP 分区,使用的比例非常高,引起的具体原因是什么呢?
更详细的操作系统内存分布,从 /proc/meminfo 文件中可以看到具体的逻辑内存块大小,有多达 40 项的内存信息,这些信息都可以通过遍历 /proc 目录的一些文件获取。我们注意到 slabtop 命令显示的有一些异常,dentry(目录高速缓冲)占用非常高。
问题最终定位到是由于某个运维工程师删除日志时,定时执行了一句命令:
find / | grep "xxx.log"
他是想找一个叫做 要被删除 的日志文件,看看在哪台服务器上,结果,这些老服务器由于文件太多,扫描后这些文件信息都缓存到了 slab 区上。而服务器开了 swap,操作系统发现物理内存占满后,并没有立即释放 cache,导致每次 GC 都要和硬盘打一次交道。
解决方式就是关闭 SWAP 分区。
swap 是很多性能场景的万恶之源,建议禁用。在高并发 SWAP 绝对能让你体验到它魔鬼性的一面:进程倒是死不了了,但 GC 时间长的却让人无法忍受。
内存溢出 | Cache调优
有一次线上遇到故障,重新启动后,使用 jstat 命令,发现 Old 区一直在增长。我使用 jmap 命令,导出了一份线上堆栈,然后使用 MAT 进行分析,通过对 GC Roots 的分析,发现了一个非常大的 HashMap 对象,这个原本是其他同事做缓存用的,但是做了一个无界缓存,没有设置超时时间或者 LRU 策略,在使用上又没有重写key类对象的hashcode和equals方法,对象无法取出也直接造成了堆内存占用一直上升,后来,将这个缓存改成 guava 的 Cache,并设置了弱引用,故障就消失了。
关于文件处理器的应用,在读取或者写入一些文件之后,由于发生了一些异常,close 方法又没有放在 finally 块里面,造成了文件句柄的泄漏。由于文件处理十分频繁,产生了严重的内存泄漏问题。
内存溢出是一个结果,而内存泄漏是一个原因。内存溢出的原因有内存空间不足、配置错误等因素。一些错误的编程方式,不再被使用的对象、没有被回收、没有及时切断与 GC Roots 的联系,这就是内存泄漏。
举个例子,有团队使用了 HashMap 做缓存,但是并没有设置超时时间或者 LRU 策略,造成了放入 Map 对象的数据越来越多,而产生了内存泄漏。
再来看一个经常发生的内存泄漏的例子,也是由于 HashMap 产生的。代码如下,由于没有重写 Key 类的 hashCode 和 equals 方法,造成了放入 HashMap 的所有对象都无法被取出来,它们和外界失联了。所以下面的代码结果是 null。
//leak example
import java.util.HashMap;
import java.util.Map;
public class HashMapLeakDemo {
public static class Key {
String title;
public Key(String title) {
this.title = title;
}
}
public static void main(String[] args) {
Map<Key, Integer> map = new HashMap<>();
map.put(new Key("1"), 1);
map.put(new Key("2"), 2);
map.put(new Key("3"), 2);
Integer integer = map.get(new Key("2"));
System.out.println(integer);
}
}
即使提供了 equals 方法和 hashCode 方法,也要非常小心,尽量避免使用自定义的对象作为 Key。
再看一个例子,关于文件处理器的应用,在读取或者写入一些文件之后,由于发生了一些异常,close 方法又没有放在 finally 块里面,造成了文件句柄的泄漏。由于文件处理十分频繁,产生了严重的内存泄漏问题。
CPU飙高 | 死循环
我们有个线上应用,单节点在运行一段时间后,CPU 的使用会飙升,一旦飙升,一般怀疑某个业务逻辑的计算量太大,或者是触发了死循环(比如著名的 HashMap 高并发引起的死循环),但排查到最后其实是 GC 的问题。
- 使用 top 命令,查找到使用 CPU 最多的某个进程,记录它的 pid。使用 Shift + P 快捷键可以按 CPU 的使用率进行排序。
top
- 再次使用 top 命令,加 -H 参数,查看某个进程中使用 CPU 最多的某个线程,记录线程的 ID。
top -Hp $pid
- 使用 printf 函数,将十进制的 tid 转化成十六进制。
printf %x $tid
- 使用 jstack 命令,查看 Java 进程的线程栈。
jstack $pid >$pid.log
- 使用 less 命令查看生成的文件,并查找刚才转化的十六进制 tid,找到发生问题的线程上下文。
less $pid.log
我们在 jstack 日志搜关键字DEAD,以及中找到了 CPU 使用最多的几个线程id。
可以看到问题发生的根源,是我们的堆已经满了,但是又没有发生 OOM,于是 GC 进程就一直在那里回收,回收的效果又非常一般,造成 CPU 升高应用假死。接下来的具体问题排查,就需要把内存 dump 一份下来,使用 MAT 等工具分析具体原因了。