- 基础
- Spring
- 数据库
- MySQL
- 1. 为什么要使用索引
- 2. 索引是不是越多越好
- 3. MySQL索引机制
- 4. 什么是聚簇索引
- 5. 索引失效场景
- 6. 什么情况下没必要用索引
- 7. explain优化
- 8. union和union all的区别
- 9. InnoDB和MyISAM的区别
- 10. InnoDB如果没有主键的话,内部是怎么处理的
- 11. MySQL的某个字段存null和空字符串有什么区别?效率和空间上有什么区别
- 12. MySQL删除一条记录的过程
- 13. MySQL索引优化思路
- 14. 联合索引
- 15. MySQL内存被撑爆,如何快速定位
- 16. 批量向MySQL中导入1000万条数据如何优化
- 17. 分页的时候偏移量过大,效率很差,如何优化
- 18. 大数据量高并发访问的数据库优化方法
- 事务
- MyBatis
- MySQL
- 并发编程
- JVM虚拟机
- 中间件
- 分布式、微服务
- IO流、网络通信
- 算法、数据结构
- 设计模式
- 场景方案设计
基础
Java基础
1. 静态代理和动态代理的区别
代理模式是常用的Java设计模式,它的特征是代理类与委托类有同样的接口,代理类主要负责为委托类预处理消息、过滤消息、把消息转发给委托类,以及事后处理消息等。代理类与委托类之间通常会存在关联关系,一个代理类的对象与一个委托类的对象关联,代理类的对象本身并不真正实现服务,而是通过调用委托类的对象的相关方法,来提供特定的服务
静态代理
- 由程序员创建或由特定工具自动生成源代码,再对其编译。在程序运行前,代理类的.class文件就已经存在了
- 静态代理通常只代理一个类
- 静态代理事先知道要代理的是什么
动态代理
- 在程序运行时,运用反射机制动态创建而成
- 动态代理是代理一个接口下的多个实现类
- 动态代理不知道要代理什么东西,只有在运行时才知道
2. 动态代理有哪些实现方式以及区别
-
JDK原生动态代理
JDK原生动态代理是Java原生支持的,不需要任何外部依赖,但是它只能基于接口进行代理(需要代理的对象必须实现于某个接口)。它的实现原理是通过InvocationHandler.invoke方法实现对实现类方法的调用(InvocationHandler实例已经持有了对实现类对象的引用了),然后实现方法前后的拦截
实现JDK里的InvocationHandler接口的invoke方法,但注意的是代理的是接口,也就是你的业务类必须要实现接口,通过Proxy里的newProxyInstance得到代理对象
-
CGLIB动态代理
CGLIB是针对类实现代理,主要是对指定的类生成一个子类,覆盖其中的方法(
继承
),然后通过MethodIntercept.intercept方法来实现在调用父类方法的前后执行拦截。代理的是类,不需要业务类继承接口,通过派生的子类来实现代理。通过在运行时,动态修改字节码达到修改类的目的
-
JDK代理是通过持有实现类的引用来实现对实现类方法的调用的,而CGLIB是通过调用父类的方法来实现对被代理类的方法调用的
3. sleep()和wait()区别
- sleep是线程类(Thread)的方法,该方法会导致线程暂停执行指定时间,将执行机会让给其它线程,但是监控状态依然保持,到时后会自动恢复,该方法不会释放对象锁
- wait是Object类的方法,对此对象调用wait方法会导致本线程放弃对象锁,进入等待此对象的等待锁定池,只有针对此对象发出notify方法(或notifyAll)后本线程才进入对象锁定池准备获得对象锁进入运行状态
4. CheckedException和RuntimeException
-
CheckedException
检查型异常是指编译器会检查这些异常,开发人员必须处理这些异常(try catch或者方法抛出),如果不处理,编译器就会编译不通过。检查异常适用于那些不是因程序引起的错误情况
-
UnCheckedException
非检查型异常也是
RuntimeException
,编译器不会检查这类异常,这种异常一般是由开发者去避免的,如果不避免运行的时候可能就会出问题,比如空指针属于越界等。不被检查的异常通常都是由于糟糕的编程引起的
5. 过滤器(Filter)和拦截器(Interceptor)的执行顺序和区别
-
执行顺序
Filter1 -> Filter2 -> Interceptor1 -> Interceptor2 -> Interceptor2 -> Interceptor1 -> Filter2 -> Filter1
-
区别
- Filter过滤器依赖于Servlet容器,在实现上是基于函数回调,它可以对几乎所有请求进行过滤,但是缺点是一个过滤器实例只能在容器初始化时调用一次。Filter只能在Serlet前后起作用,不能使用Spring资源
- Interceptor拦截器依赖于Spring的web框架,在实现上是基于Java的反射机制,属于AOP的一种运用,一个拦截器实例在一个controller生命周期之内可以多次调用。但是缺点是只能对controller请求进行拦截
6. 双重单例锁
public class SingletonTest {
// 私有构造器
private SingletonTest() {
}
// 使用volatile修饰,解决有可能出现半成品对象的问题
private volatile SingletonTest singletonTest;
// 使用双重检查锁,外侧判断是否需要加锁,内部判断是否已实例化
public SingletonTest getInstance() {
if (singletonTest == null) {
synchronized (SingletonTest.class) {
if (singletonTest == null) {
singletonTest = new SingletonTest();
}
}
}
return singletonTest;
}
}
集合框架
1. HashMap原理
- HashMap是一个散列通(数组+链表,1.8之后还有数组+红黑树),维护了一个数组,数组上存储的内容是键值对(key-value),如果有冲突可能一个数组上会有多个元素(形成链表或红黑树)
- HashMap是非线程安全的,所以性能很高
- HashMap能接受null键和值
2. HashMap put流程
- hash(key),取key的hashcode进行高位运算,获得hash值,直接调用
putVal()
方法 - 如果table为空,会进行一次
resize()
,在第一次put的时候初始化table(懒加载) - 通过
hash & (table.length - 1)
获取该key对应的数据节点的hash槽(slot) - 判断散列表对应索引中的首节点是否为空,为空则创建链表,并创建节点
- 若不为空说明出现了hash碰撞,如果该首节点与插入的键值对的key和hash完全一致,则替换该节点的值
- 如果key不相等,再判断首节点是否为树节点,若是则调用树节点的
putTreeVal()
方法遍历红黑树,如果存在key和hash相同的节点就替换对应节点的值value,否则插入新的树节点 - 如果不是红黑树,则遍历链表,若存在key和hash相同的节点就替换对应节点的值为value。否则链表尾部插入节点,并且判断当前链表长度大于或等于树化阈值
TREEIFY_THRESHOLD(8)
,如果大于树化阈值则将链表转化为红黑树 - 插入成功后,判断实际存在的键值对数量是否超过了最大容量threshold(table.length*0.75),如果超过,进行扩容
HashMap的数据存储是通过数组+链表/红黑树实现的,存储大概流程是通过hash函数计算在数组中存储的位置,如果该位置已经有值了,判断key是否相同,相同则覆盖,不相同则放到元素对应的链表中,如果链表长度大于8,就转化为红黑树,如果容量不够,则需扩容
3. HashMap get流程
- 通过
hash & (table.length - 1)
获取该key对应的数据节点的hash槽(slot) - 先判断首节点是否为空, 为空则直接返回空
- 再判断首节点的key 是否和目标值相同, 相同则直接返回(首节点不用区分链表还是红黑树)
- 如果首节点的next为空, 则直接返回空(只有首节点的next不为空才进入下面的find流程)
- 如果首节点是树形节点, 则进入红黑树数的取值流程, 并返回结果
- 否则进入链表的取值流程, 并返回结果
4. hash & (table.length - 1)的作用
-
保证不会发生数组越界
首先我们要知道,在HashMap和ConcurrentHashMap中,数组的长度按规定一定是2的幂(2的n次方)。
因此,数组的长度的二进制形式是:10000…000, 1后面有一堆0。
那么,tab.length - 1 的二进制形式就是01111…111, 0后面有一堆1。最高位是0, 和hash值相“与”,结果值一定不会比数组的长度值大,因此也就不会发生数组越界 -
保证元素尽可能的均匀分布
由上边的分析可知,tab.length一定是一个偶数,tab.length - 1一定是一个奇数。
假设现在数组的长度(tab.length)为16,减去1后(tab.length - 1)就是15,15对应的二进制是:1111。
现在假设有两个元素需要插入,一个哈希值是8,二进制是1000,一个哈希值是9,二进制是1001。和1111“与”运算后,结果分别是1000和1001,它们被分配在了数组的不同位置,这样,哈希的分布非常均匀。
那么,如果数组长度是奇数呢?减去1后(tab.length - 1)就是偶数了,偶数对应的二进制最低位一定是 0,例如14二进制1110。对上面两个数子分别“与”运算,得到1000和1000。结果都是一样的值。那么,哈希值8和9的元素都被存储在数组同一个index位置的链表中。在操作的时候,链表中的元素越多,效率越低,因为要不停的对链表循环比较。
所以,一定要使哈希均匀分布,尽量减少哈希冲突,提高效率
5. HashMap为什么要把链表转成红黑树
-
HashMap在JDK1.8及以后的版本中引入了红黑树结构,若桶中链表元素个数大于等于8时,链表转换成树结构;若桶中链表元素个数小于等于6时,树结构还原成链表。因为红黑树的平均查找长度是log(n),长度为8的时候,平均查找长度为3,如果继续使用链表,平均查找长度为8/2=4,这才有转换为树的必要。链表长度如果是小于等于6,6/2=3,虽然速度也很快的,但是转化为树结构和生成树的时间并不会太短。
-
还有选择6和8,中间有个差值7可以有效防止链表和树频繁转换。假设一下,如果设计成链表个数超过8则链表转换成树结构,链表个数小于8则树结构转换成链表,如果一个HashMap不停的插入、删除元素,链表个数在8左右徘徊,就会频繁的发生树转链表、链表转树,效率会很低
6. HashMap 扩容(resize)机制
扩容(resize)就是重新计算容量,向HashMap对象里不停的添加元素,而HashMap对象内部的数组无法装载更多的元素时(由阈值决定),就需要扩大数组的长度,以便能装入更多的元素。当然Java里的数组是无法自动扩容的,方法是使用一个新的数组代替已有的容量小的数组,就像我们用一个小桶装水,如果想装更多的水,就得换大水桶
-
什么时候扩容:
HashMap使用的是懒加载,构造完HashMap对象后,只要不进行put 方法插入元素之前,HashMap并不会去初始化或者扩容table
-
扩容流程:
- 当首次调用put方法时,HashMap发现table为空调用resize方法进行初始化
- 当添加完元素后,如果HashMap发现size(元素总数)大于threshold(阈值),则会调用resize方法进行扩容
- 根据新容量(旧容量的2倍)新建数组,同时保存旧数组,并将旧数组上的数据(键值对)转移到新数组上
- 遍历旧数组中的每个数据,重新计算每个数据在新数组中的位置(1.7和1.8有区别),将旧数组上的每个数据逐步转移到新数组中,即
rehashing
。如果有hash冲突,1.7使用头插法插入链接头部,1.8使用尾插法插入链表尾部 - 将新数组引用到HashMap的table上,重新设置扩容阈值
- 扩容结束,此时新的哈希表table.length为扩容之前的2倍,旧数组上的数据被转移到了新表上
7. HashMap扩容是增长几倍?为什么?
- 容量是2的n次幂,可以使得添加的元素均匀分布在HashMap中的数组上,减少hash碰撞
- 避免形成链表的结构,避免查询效率降低
8. HashMap的key可以是object吗
- Hashmap的key可以是任何类型的对象,例如User这种对象,为了保证两个具有相同属性的user的hashcode相同,我们就需要改写hashcode方法,比方把hashcode值的计算与User对象的id关联起来,那么只要user对象拥有相同id,那么他们的hashcode也能保持一致了,这样就可以找到在hashmap数组中的位置了。如果这个位置上有多个元素,还需要用key的equals方法在对应位置的链表中找到需要的元素,所以只改写了hashcode方法是不够的,equals方法也是需要改写滴~当然啦,按正常思维逻辑,equals方法一般都会根据实际的业务内容来定义,例如根据user对象的id来判断两个user是否相等
9. ConcurrentHashMap和HashTable的区别
HashTable
- 底层数组+链表实现,无论key还是value都不能为null,线程安全,实现线程安全的方式是在修改数据时锁住整个HashTable,效率低
- 初始size为11,扩容:newsize = olesize*2+1
ConcurrentHashMap
(JDK 1.7)- 底层采用分段的数组+链表实现,线程安全
- 通过把整个Map分为N个Segment,可以提供相同的线程安全,但是效率提升N倍,默认提升16倍。(读操作不加锁,由于HashEntry的value变量是 volatile的,也能保证读取到最新的值。)
- HashTable的synchronized是针对整张Hash表的,即每次锁住整张表让线程独占,ConcurrentHashMap允许多个修改操作并发进行,其关键在于使用了锁分离技术
- 有些方法需要跨段,比如size()和containsValue(),它们可能需要锁定整个表而而不仅仅是某个段,这需要按顺序锁定所有段,操作完毕后,又按顺序释放所有段的锁
- 扩容:段内扩容(段内元素超过该段对应Entry数组长度的75%触发扩容,不会对整个Map进行扩容),插入前检测需不需要扩容,有效避免无效扩容
10. ConcurrentHashMap之1.7和1.8的区别
-
数据结构:
- JDK1.7:使用 Segment数组 + HashEntry数组 + 链表
- JDK1.8:Node数组 + 链表 + 红黑树
-
线程安全机制:JDK1.7 采用 Segment 的分段锁机制实现线程安全,其中 Segment 继承自 ReentrantLock 。JDK1.8 采用
CAS + synchronized
保证线程安全 为什么使用内置锁 synchronized替换可重入锁 ReentrantLock?
-
粒度更细,将锁的级别控制在了更细粒度的哈希桶数组元素级别,也就是说只需要锁住这个链表头节点(红黑树的根节点),就不会影响其他的哈希桶数组元素的读写,大大提高了并发度
-
在 JDK1.6 中,对 synchronized 锁的实现引入了大量的优化,并且 synchronized 有多种锁状态,会从无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁一步步转换。同时由于锁粒度较细,出现竞争的可能性本身就不高,加上synchronized可以自旋,避免了上下文的切换
-
减少内存开销 。假设使用可重入锁来获得同步支持,那么每个节点都需要通过继承 AQS 来获得同步支持。但并不是每个节点都需要获得同步支持的,只有链表的头节点(红黑树的根节点)需要同步,这无疑带来了巨大内存浪费。
-
-
锁的粒度:JDK1.7 是对需要进行数据操作的 Segment (分段)加锁,JDK1.8 调整为对每个数组元素加锁(Node)
-
链表转化为红黑树:定位节点的 hash 算法简化会带来弊端,hash 冲突加剧,因此在链表节点数量大于 8(且数据总量大于等于 64)时,会将链表转化为红黑树进行存储
参考:https://segmentfault.com/a/1190000039087868
11. LinkedHashMap
LinkedHashMap继承自HashMap,它的多种操作都是建立在HashMap操作的基础上的。同HashMap不同的是,LinkedHashMap维护了一个Entry的双向链表,保证了插入的Entry中的顺序
。
Spring
IOC
IOC(Inversion Of Controll,控制反转)是一种设计思想,就是将原本在程序中手动创建对象的控制权,交由给Spring框架来管理。IOC在其他语言中也有应用,并非Spring特有。IOC容器是Spring用来实现IOC的载体,IOC容器实际上就是一个Map(key, value),Map中存放的是各种对象。
将对象之间的相互依赖关系交给IOC容器来管理,并由IOC容器完成对象的注入。这样可以很大程度上简化应用的开发,把应用从复杂的依赖关系中解放出来。IOC容器就像是一个工厂一样,当我们需要创建一个对象的时候,只需要配置好配置文件/注解即可,完全不用考虑对象是如何被创建出来的。在实际项目中一个Service类可能由几百甚至上千个类作为它的底层,假如我们需要实例化这个Service,可能要每次都搞清楚这个Service所有底层类的构造函数,这可能会把人逼疯。如果利用IOC的话,你只需要配置好,然后在需要的地方引用就行了,大大增加了项目的可维护性且降低了开发难度
IoC容器的主要功能是可以管理对象的生命周期。也就是bean的管理
AOP
1. 什么是AOP
AOP为Aspect Oriented Programming的缩写,是面向切面编程,通过预编译方式和运行期动态代理实现程序功能的统一维护的一种技术。AOP是一种规范,是基于动态代理模式。AOP代理主要分为静态代理和动态代理,静态代理的代表为AspectJ。而动态代理则以Spring AOP为代表,静态代理是编译期实现,动态代理是运行期实现
2. 应用场景
- 记录日志
- 监控方法运行时间 (监控性能)
- 权限控制
- 缓存优化 (第一次调用查询数据库,将查询结果放入内存对象, 第二次调用, 直接从内存对象返回,不需要查询数据库 )
- 事务管理 (调用方法前开启事务, 调用方法后提交关闭事务 )
3. AOP被代理方法报错会不会影响代理方法?
AOP代理类可以捕捉到被代理方法抛出的异常,从而可以选择处理异常或者抛出异常
Spring框架
1. SpringBean作用域
-
singleton
默认单例,在spring IoC容器仅存在一个Bean实例,Bean以单例方式存在
prototype
-
prototype
每次从容器中调用Bean时,都返回一个新的实例,即每次调用getBean()时,相当于执行newXxxBean()
-
request
每次HTTP请求都会创建一个新的Bean,该作用域仅适用于web的Spring WebApplicationContext环境
-
session
同一个HTTP Session共享一个Bean,不同Session使用不同的Bean。该作用域仅适用于web的Spring WebApplicationContext环境
-
application
限定一个Bean的作用域为ServletContext的生命周期。该作用域仅适用于web的Spring WebApplicationContext环境
2. 单例对象和原型对象的区别
-
当Spring的bean作用域设置为
scope=singleton
,即默认情况下,会在容器启动时初始化,也可以用注解@Lazy
来延迟初始化。当对象被销毁时,会调用bean的destory
方法 -
当作用域为
scope=propotype
时,容器并不会在启动时初始化,而是在第一次请求该bean时才初始化。对象销毁的时候,Spring 容器不会帮我们调用任何方法,因为是非单例,这个类型的对象有很多个,Spring容器一旦把这个对象交给你之后,就不再管理这个对象了
Spring 容器可以管理 singleton 作用域下 bean 的生命周期,在此作用域下,Spring 能够精确地知道bean何时被创建,何时初始化完成,以及何时被销毁。而对于 prototype 作用域的bean,Spring只负责创建,当容器创建了 bean 的实例后,bean 的实例就交给了客户端的代码管理,Spring容器将不再跟踪其生命周期,并且不会管理那些被配置成prototype作用域的bean的生命周期
3. Spring中的单例bean的是线程安全的吗?
不是
-
但是一般不会出现线程安全问题。在spring中,绝大部分bean都是无状态的,因此即使这些bean默认是单例的,也不会出现线程安全问题的。比如controller、service、dao这些类,这些类里面通常不会含有成员变量,因此它们被设计成单例的。如果这些类中定义了实例变量,就线程不安全了,所以尽量避免定义实例变量
-
如果一定要使用实例变量,可以在类中定义一个ThreadLocal成员变量,将需要的可变成员变量保存在ThreadLocal中
4. SpringBean生命周期
-
实例化Bean
对于BeanFactory容器,当客户向容器请求一个尚未初始化的bean时,或初始化bean的时候需要注入另一个尚未初始化的依赖时,容器就会调用createBean进行实例化。
对于ApplicationContext容器,当容器启动结束后,便实例化所有的bean。
容器通过获取BeanDefinition对象中的信息进行实例化。并且这一步仅仅是简单的实例化,并未进行依赖注入。
实例化对象被包装在BeanWrapper对象中,BeanWrapper提供了设置对象属性的接口,从而避免了使用反射机制设置属性。 -
设置对象属性(依赖注入)
实例化后的对象被封装在BeanWrapper对象中,并且此时对象仍然是一个原生的状态,并没有进行依赖注入。
紧接着,Spring根据BeanDefinition中的信息进行依赖注入。
并且通过BeanWrapper提供的设置属性的接口完成依赖注入。 -
注入Aware接口
紧接着,Spring会检测该对象是否实现了xxxAware接口,并将相关的xxxAware实例注入给bean。
-
BeanPostProcessor
当经过上述几个步骤后,bean对象已经被正确构造,但如果你想要对象被使用前再进行一些自定义的处理,就可以通过BeanPostProcessor接口实现。
该接口提供了两个函数:
-
postProcessBeforeInitialzation( Object bean, String beanName ) 当前正在初始化的bean对象会被传递进来,我们就可以对这个bean作任何处理。 这个函数会先于InitialzationBean执行,因此称为前置处理。 所有Aware接口的注入就是在这一步完成的。
-
postProcessAfterInitialzation( Object bean, String beanName ) 当前正在初始化的bean对象会被传递进来,我们就可以对这个bean作任何处理。 这个函数会在InitialzationBean完成后执行,因此称为后置处理。
-
-
InitializingBean与init-method
当BeanPostProcessor的前置处理完成后就会进入本阶段。 InitializingBean接口只有一个函数:afterPropertiesSet()这一阶段也可以在bean正式构造完成前增加我们自定义的逻辑,但它与前置处理不同,由于该函数并不会把当前bean对象传进来,因此在这一步没办法处理对象本身,只能增加一些额外的逻辑。 若要使用它,我们需要让bean实现该接口,并把要增加的逻辑写在该函数中。然后Spring会在前置处理完成后检测当前bean是否实现了该接口,并执行afterPropertiesSet函数。当然,Spring为了降低对客户代码的侵入性,给bean的配置提供了init-method属性,该属性指定了在这一阶段需要执行的函数名。Spring便会在初始化阶段执行我们设置的函数。init-method本质上仍然使用了InitializingBean接口。
-
DisposableBean和destroy-method
和init-method一样,通过给destroy-method指定函数,就可以在bean销毁前执行指定的逻辑。
另外一种回答:
- 根据配置情况调用 Bean 构造方法或工厂方法实例化 Bean
- 利用依赖注入完成 Bean 中所有属性值的配置注入
- 如果 Bean 实现了 BeanNameAware 接口,则 Spring 调用 Bean 的 setBeanName() 方法传入当前 Bean 的 id 值
- 如果 Bean 实现了 BeanFactoryAware 接口,则 Spring 调用 setBeanFactory() 方法传入当前工厂实例的引用
- 如果 Bean 实现了 ApplicationContextAware 接口,则 Spring 调用 setApplicationContext() 方法传入当前 ApplicationContext 实例的引用
- 如果 BeanPostProcessor 和 Bean 关联,则 Spring 将调用该接口的预初始化方法 postProcessBeforeInitialzation() 对 Bean 进行加工操作,此处非常重要,Spring 的 AOP 就是利用它实现的
- 如果 Bean 实现了 InitializingBean 接口,则 Spring 将调用 afterPropertiesSet() 方法
- 如果在配置文件中通过 init-method 属性指定了初始化方法,则调用该初始化方法
- 如果 BeanPostProcessor 和 Bean 关联,则 Spring 将调用该接口的初始化方法 postProcessAfterInitialization()。此时,Bean 已经可以被应用系统使用了
- 如果在 <bean> 中指定了该 Bean 的作用范围为 scope="singleton",则将该 Bean 放入 Spring IoC 的缓存池中,将触发 Spring 对该 Bean 的生命周期管理;如果在 <bean> 中指定了该 Bean 的作用范围为 scope="prototype",则将该 Bean 交给调用者,调用者管理该 Bean 的生命周期,Spring 不再管理该 Bean
- 如果 Bean 实现了 DisposableBean 接口,则 Spring 会调用 destory() 方法将 Spring 中的 Bean 销毁;如果在配置文件中通过 destory-method 属性指定了 Bean 的销毁方法,则 Spring 将调用该方法对 Bean 进行销毁
5. BeanFactory和FactoryBean的区别
- BeanFactory定义了 IOC 容器的最基本形式,并提供了 IOC 容器应遵守的的最基本的接口,也就是 Spring IOC 所遵守的最底层和最基本的编程规范。在 Spring 代码中, BeanFactory 只是个接口,并不是 IOC 容器的具体实现,但是 Spring 容器给出了很多种实现,如 DefaultListableBeanFactory 、 XmlBeanFactory 、 ApplicationContext 等,都是附加了某种功能的实现
- 一般情况下,Spring 通过反射机制利用<bean> 的 class 属性指定实现类实例化 Bean ,在某些情况下,实例化 Bean 过程比较复杂,如果按照传统的方式,则需要在 <bean> 中提供大量的配置信息。配置方式的灵活性是受限的,这时采用编码的方式可能会得到一个简单的方案。 Spring 为此提供了一个 org.springframework.bean.factory.FactoryBean 的工厂类接口,用户可以通过实现该接口定制实例化 Bean 的逻辑
6. Spring循环依赖
- Spring默认是支持单例模式下的循环依赖的,不支持多例和构造器注入
- Spring内部有三级缓存:
- singletonObjects 一级缓存,即单例池,缓存创建完成单例Bean的地方
- earlySingletonObjects 二级缓存,提前暴光的单例对象的Cache,也就是说在这个Map里的Bean不是完整的,甚至还不能称之为“Bean”,只是一个Instance
- singletonFactories 三级缓存,用于保存bean创建工厂,以便于后面扩展有机会创建代理对象
- 为什么构造器注入和多例不能解决循环依赖
- 在Bean调用构造器实例化之前,一二三级缓存并没有Bean的任何相关信息,在实例化之后才放入三级缓存中,因此当getBean的时候缓存并没有命中,这样就抛出了循环依赖的异常了
- 解决循环依赖的核心是利用一个map来解决这个问题的,这个map就相当于缓存,因为我们的bean是单例的,而且是字段注入(setter注入)的,单例意味着只需要创建一次对象,后面就可以从缓存中取出来,字段注入,意味着我们无需调用构造方法进行注入
- 如果是原型bean,那么就意味着每次都要去创建对象,无法利用缓存
- 如果是构造方法注入,那么就意味着需要调用构造方法注入,也无法利用缓存
- 具体流程:
- 开始初始化对象A
- 调用A的构造,把A放入singletonFactories
- 开始注入A的依赖,发现A依赖对象B
- 开始初始化对象B
- 调用B的构造,把B放入singletonFactories
- 开始注入B的依赖,发现B依赖对象A
- 开始初始化对象A,发现singletonFactories中已经有A了,直接获取A,把A放入earlySingletonObjects 中,从singletonFactories删除A
- 对象B依赖注入完成
- 对象B创建完成,把B放入singletonObjects,并从earlySingletonObjects 和singletonFactories中移除
- 对象B注入给对象A,继续注入A的其它依赖,知道A注入完成
- 对象A创建完成,把A放入singletonObjects,并从earlySingletonObjects 和singletonFactories中移除
- 循环依赖处理结束,A和B都初始化和注入完成
- 认真的讲循环依赖本来就是有问题的。就算Spring确实有三级缓存的模式解决了循环依赖,循环依赖还是不应该出现在实际的项目里
SpringMVC
1. 什么是MVC设计模式
MVC即Model-View-Controller,将应用按照Model
(模型)、View
(视图)、Controller
(控制)这样的方式分离
- 视图(View):代表用户交互界面,对于Web应用来说,可以是HTML,也可能是jsp、XML和Applet等。一个应用可能有很多不同的视图,MVC设计模式对于视图的处理仅限于视图上数据的采集和处理,以及用户的请求,而不包括在视图上的业务流程的处理。业务流程的处理交予模型(Model)处理
- 模型(Model):是业务的处理以及业务规则的制定。模型接受视图请求的数据,并返回最终的处理结果。业务模型的设计是MVC最主要的核心。MVC设计模式告诉我们,把应用的模型按一定的规则抽取出来,抽取的层次很重要,抽象与具体不能隔得太远,也不能太近。MVC并没有提供模型的设计方法,而只是组织管理这些模型,以便于模型的重构和提高重用性
- 控制(Controller):可以理解为从用户接收请求, 将模型与视图匹配在一起,共同完成用户的请求。划分控制层的作用也很明显,它清楚地告诉你,它就是一个分发器,选择什么样的模型,选择什么样的视图,可以完成什么样的用户请求。控制层并不做任何的数据处理
2. SpringMVC运行流程
- 用户发送请求至前端控制器DispatcherServlet
- DispatcherServlet收到请求调用HandlerMapping处理器映射器
- 处理器映射器找到具体的处理器(可以根据xml配置、注解进行查找),生成处理器对象及处理器拦截器(如果有则生成)一并返回给DispatcherServlet
- DispatcherServlet调用HandlerAdapter处理器适配器
- HandlerAdapter经过适配调用具体的处理器(Controller,也叫后端控制器)
- Controller执行完成返回ModelAndView
- HandlerAdapter将controller执行结果ModelAndView返回给DispatcherServlet
- DispatcherServlet将ModelAndView传给ViewReslover视图解析器
- ViewReslover解析后返回具体View
- DispatcherServlet根据View进行渲染视图(即将模型数据填充至视图中)
- DispatcherServlet响应用户
SpringBoot
1. SpringBoot自动装配原理
IoC容器的主要功能是可以管理对象的生命周期,也就是bean的管理。我们把Bean对象托管到Spring Ioc容器的这个过程称为装配。通过@Component、@Bean等注解配置或者以前的xml配置Bean到Spring容器中的方式为手动装配。自动装配就是SpringBoot在启动的时候从jar包的类路径下的META-INF/spring.factories中获取EnableAutoConfiguration指定的值将这些值作为自动配置类导入容器,自动配置类就生效,帮我们进行自动配置工作,以前我们需要自己配置的东西,自动配置类都帮我们解决了
- SpringBoot启动的时候加载主配置类,开启了自动配置功能
@EnableAutoConfiguration
- @EnableAutoConfiguration借助@Import的帮助,将所有符合自动配置条件的bean定义加载到IoC容器
- 通过
getCandidateConfigurations()
获取候选的配置,这个是扫描所有jar包类路径下META-INF/spring.factories
,然后把扫描到的这些文件包装成Properties对象,从properties中获取到EnableAutoConfiguration.class类名对应的值,然后把他们添加在容器中 - 整个过程就是将类路径下"META-INF/spring.factories"里面配置的所有EnableAutoConfiguration的值加入到容器中
- 每一个这样XXAutoConfiguration类都是容器中的一个组件都加入到容器中,用他们来做自动配置
- 每一个自动配置类进行自动配置功能,根据
@ConditionalOnProperty
当前不同的条件判断,决定这个配置是否生效
2. SpringBoot启动流程
- 第一部分进行SpringApplication的初始化模块,配置一些基本的环境变量、资源、构造器、监听器
- 第二部分实现了应用具体的启动方案,包括启动流程的监听模块、加载配置环境模块、及核心的创建上下文环境模块
- 第三部分是自动化配置模块,该模块作为SpringBoot自动配置核心,参考上面的自动装配
数据库
MySQL
1. 为什么要使用索引
- 索引大大减少了存储引擎需要扫描的数据量
- 索引可以帮助我们进行排序以避免使用临时表
- 索引可以将随机I/O转为顺序I/O
说白了使用索引可以提高查找效率,数据索引的存储是有序的,在有序的情况下,通过索引查询一个数据是无需遍历索引记录的,极端情况下,数据索引的查询效率为二分法查询效率,趋近于O(log2N)
2. 索引是不是越多越好
- 索引越多,更新数据的速度越慢。索引会增加数据库写入操作的成本(InnoDB对这个做了一个优化:插入缓存、将多次插入合并成一次插入)
- 太多的索引会影响MySQL查询优化器的选择时间(影响查询效率)
- 更多的索引意味着也需要更多的空间(索引也是需要空间来存放的)
3. MySQL索引机制
索引的本质是一种优化查询的数据结构
,比如MySQL中的索引是B+树
实现的,而B+树就是一种数据结构,可以优化查询速度,可以利用索引快速查找数据,所以能优化查询
索引一般以文件形式存储在磁盘上,索引检索需要磁盘I/O操作。所以评价一个数据结构作为索引的优劣最重要的指标就是在查找过程中磁盘I/O操作次数的渐进复杂度
当数据保存在磁盘类存储介质上时,它是作为数据块存放。这些数据块是被当作一个整体来访问的,这样可以保证操作的原子性。硬盘数据块存储结构类似于链表,都包含数据部分,以及一个指向下一个节点(或数据块)的指针,不需要连续存储。所以没有索引则检索过程变成了顺序查找,时间复杂度是O(n)
4. 什么是聚簇索引
- 聚簇索引就是按照表的主键构造一颗B+树,同时叶子节点中存放的就是行记录数据,也将聚集索引的叶子节点称为数据页。这个特性决定了索引组织表中数据也是索引的一部分
- 由于聚簇索引的索引页面指针指向数据页面,所以使用聚簇索引查找数据几乎总是比使用非聚簇索引快
- 每张表只能建一个聚簇索引,并且建聚簇索引需要至少相当该表120%的附加空间,以存放该表的副本和索引中间页
5. 索引失效场景
- 以“%”开头的LIKE语句,模糊匹配
or
语句前后没有同时使用索引- 数据类型出现隐式转化(如varchar不加单引号的话可能会自动转换为int型)
- 联合索引未用左列字段
- 如果MySQL觉得全表扫描更快时(数据少)
6. 什么情况下没必要用索引
- 唯一性差
- 频繁更新的字段不用,比如某个持续增长的数值LoginCount(更新索引消耗)
- where中不用的字段(不用你建什么索引)
- 数据量少的表不要使用索引(没啥用)
- 索引使用<> 即 !=时,效果一般
7. explain优化
-
id
- id相同,执行顺序由上至下
- id不同,如果是子查询,id的序号会递增,id值越大优先级越高,越先被执行
- id有相同有不同,同时存在 id相同的可以认为是一组,同一组中从上往下执行,所有组中id大的优先执行
-
table
显示这一行的数据是关于哪张表的
-
type
type所显示的是查询使用了哪种类型,type包含的类型包括如下图所示的几种,从好到差依次是
system > const > eq_ref > ref > range > index > all
system
表只有一行记录(等于系统表),这是const类型的特列,平时不会出现,这个也可以忽略不计const
表示通过索引一次就找到了,const用于比较primary key 或者unique索引。因为只匹配一行数据,所以很快。如将主键置于where列表中,MySQL就能将该查询转换为一个常量eq_ref
唯一性索引扫描,对于每个索引键,表中只有一条记录与之匹配。常见于主键或唯一索引扫描ef
非唯一性索引扫描,返回匹配某个单独值的所有行,本质上也是一种索引访问,它返回所有匹配某个单独值的行,然而,它可能会找到多个符合条件的行,所以他应该属于查找和扫描的混合体range
只检索给定范围的行,使用一个索引来选择行,key列显示使用了哪个索引,一般就是在你的where语句中出现between、< 、>、in等的查询,这种范围扫描索引比全表扫描要好,因为它只需要开始于索引的某一点,而结束于另一点,不用扫描全部索引index
Full Index Scan,Index与All区别为index类型只遍历索引树。这通常比ALL快,因为索引文件通常比数据文件小。(也就是说虽然all和Index都是读全表,但index是从索引中读取的,而all是从硬盘读取的)all
Full Table Scan 将遍历全表以找到匹配的行
type显示的是访问类型,是较为重要的一个指标,结果值从好到坏依次是:
system > const > eq_ref > ref > fulltext > ref_or_null > index_merge > unique_subquery > index_subquery > range > index > ALL
一般来说,得保证查询至少达到range级别,最好能达到ref -
possible_keys
显示可能应用在这张表中的索引,一个或多个。查询涉及到的字段上若存在索引,则该索引将被列出,但不一定被查询实际使用
-
key
实际使用的索引,如果为NULL,则没有使用索引。(可能原因包括没有建立索引或索引失效)
-
key_len
表示索引中使用的字节数,可通过该列计算查询中使用的索引的长度,在不损失精确性的情况下,长度越短越好
-
ref
显示索引的哪一列被使用了,如果可能的话,是一个常数
-
rows
根据表统计信息及索引选用情况,大致估算出找到所需的记录所需要读取的行数,也就是说,用的越少越好
-
Extra
-
Using filesort
说明mysql会对数据使用一个外部的索引排序,而不是按照表内的索引顺序进行读取。MySQL中无法利用索引完成的排序操作称为“文件排序”
-
Using temporary
使用了用临时表保存中间结果,MySQL在对查询结果排序时使用临时表。常见于排序order by和分组查询group by
-
Using index
表示相应的select操作中使用了覆盖索引(Covering Index),避免访问了表的数据行,效率不错。如果同时出现using where,表明索引被用来执行索引键值的查找;如果没有同时出现using where,表明索引用来读取数据而非执行查找动作
-
Using join buffer
表明使用了连接缓存,比如说在查询的时候,多表join的次数非常多,那么将配置文件中的缓冲区的join buffer调大一些
-
8. union和union all的区别
-
union: 对两个结果集进行并集操作,不包括重复行,相当于distinct,同时进行默认规则的排序
-
union all: 对两个结果集进行并集操作,包括重复行,即所有的结果全部显示,不管是不是重复
-
union all只是合并查询结果,并不会进行去重和排序操作,在没有去重的前提下,使用union all的执行效率要比union高
9. InnoDB和MyISAM的区别
- InnoDB支持事务,MyISAM不支持
- InnoDB支持外键,而MyISAM不支持
- InnoDB是聚集索引,使用B+Tree作为索引结构,数据文件是和(主键)索引绑在一起的(表数据文件本身就是按B+Tree组织的一个索引结构),必须要有主键,通过主键索引效率很高。但是辅助索引需要两次查询,先查询到主键,然后再通过主键查询到数据。因此,主键不应该过大,因为主键太大,其他索引也都会很大
- MyISAM是非聚集索引,也是使用B+Tree作为索引结构,索引和数据文件是分离的,索引保存的是数据文件的指针。主键索引和辅助索引是独立的
- InnoDB的B+树主键索引的叶子节点就是数据文件,辅助索引的叶子节点是主键的值。而MyISAM的B+树主键索引和辅助索引的叶子节点都是数据文件的地址指针
- InnoDB5.7之前不支持全文索引,而MyISAM支持全文索引,在涉及全文索引领域的查询效率上MyISAM速度更快高
- InnoDB支持表、行(默认)级锁,而MyISAM支持表级锁
10. InnoDB如果没有主键的话,内部是怎么处理的
MySQL的InnoDB引擎本身存储的形式就必须是聚簇索引的形式 , 在磁盘上树状存储的 , 但是不一定是根据主键聚簇的 , 有三种情形:
-
有主键的情况下 , 主键就是聚簇索引
-
没有主键的情况下 , 第一个非空null的唯一索引就是聚簇索引
-
如果上面都没有 , 那么就是有一个隐藏的row-id作为聚簇索引
如果一个表没有申明主键和一个不为null的唯一索引,InnoDB将会自动增加一个6字节(48位)的整数列,被叫做行ID,聚集数据都是依靠这列的。这列既不能通过任何查询获取到也不能做像基于行复制的任何内部操作
11. MySQL的某个字段存null和空字符串有什么区别?效率和空间上有什么区别
- 空值不占用内存空间,
NULL值
占用空间 - 在进行
count()
统计某列的记录数的时候,如果采用的NULL
值,系统会自动忽略掉,但是空值是会进行统计到其中的 - 查找指定列是否为NULL不能使用
=
和!=
,也不能用<>
,只能使用IS NULL
和IS NOT NULL
12. MySQL删除一条记录的过程
在删除sql语句中,写法如下:DELETE FROM TABLE WHERE type=0;
凡是这样,delete带有where条件的,都不是真删除,只是MySQL给记录加了个删除标识(delete mark),这样操作后表数据占有空间也不会变小
但如果是DELETE FROM TABLE
这条sql语句执行后,就清空了表数据,占有空间就变为0了
13. MySQL索引优化思路
- 索引列的数据长度能少则少
- 索引不是越多越好
- 多用指定列查询,只返回自己想要的数据列,少用select *
- 使用联合索引
14. 联合索引
联合索引选择原则:
- 经常用的列优先【最左匹配原则】
- 选择性(离散度)高的列优先【离散度高原则】(列的离散性越高,选择性越好)
- 宽度小的列优先【最少空间原则】
在MySQL建立联合索引时会遵循最左前缀匹配的原则,即最左优先,在检索数据时从联合索引的最左边开始匹配。MySQL会一直向右匹配直到遇到范围查询>
、<
、between
、like
就停止匹配,索引可以任意顺序,MySQL的查询优化器会帮你优化成索引可以识别的形式,其针对的是组合索引(又名联合索引)
示例:对列col1、列col2和列col3建一个联合索引
KEY test_col1_col2_col3 on test(col1,col2,col3);
联合索引 test_col1_col2_col3 实际建立了(col1)、(col1,col2)、(col,col2,col3)三个索引。
SELECT * FROM test WHERE col1=“1” AND clo2=“2” AND clo4=“4”
上面这个查询语句执行时会依照最左前缀匹配原则,检索时会使用索引(col1,col2)进行数据匹配。
注意:索引的字段可以是任意顺序的,如:
SELECT * FROM test WHERE col1=“1” AND clo2=“2”
SELECT * FROM test WHERE col2=“2” AND clo1=“1”
这两个查询语句都会用到索引(col1,col2),mysql创建联合索引的规则是首先会对联合合索引的最左边的,也就是第一个字段col1的数据进行排序,在第一个字段的排序基础上,然后再对后面第二个字段col2进行排序。其实就相当于实现了类似 order by col1 col2这样一种排序规则。
有人会疑惑第二个查询语句不符合最左前缀匹配:首先可以肯定是两个查询语句都包含索引(col1,col2)中的col1、col2两个字段,只是顺序不一样,查询条件一样,最后所查询的结果肯定是一样的。既然结果是一样的,到底以何种顺序的查询方式最好呢?此时我们可以借助mysql查询优化器explain,explain会纠正sql语句该以什么样的顺序执行效率最高,最后才生成真正的执行计划。
将被查询的字段,建立到联合索引里去,可以实现索引覆盖
联合索引的作用:
- 减少开销:建一个联合索引(col1,col2,col3),实际相当于建了(col1),(col1,col2),(col1,col2,col3)三个索引。每多一个索引,都会增加写操作的开销和磁盘空间的开销。对于大量数据的表,使用联合索引会大大的减少开销!
- 覆盖索引:对联合索引(col1,col2,col3),如果有如下的sql:
select col1,col2,col3 from test where col1=1 and col2=2
。那么MySQL可以直接通过遍历索引取得数据,而无需回表,这减少了很多的随机io操作。减少io操作,特别的随机io其实是dba主要的优化策略。所以,在真正的实际应用中,覆盖索引是主要的提升性能的优化手段之一。 - 效率高:索引列越多,通过索引筛选出的数据越少。有1000W条数据的表,有如下sql:
select *from table where col1=1 and col2=2 and col3=3
,假设假设每个条件可以筛选出10%的数据,如果只有单值索引,那么通过该索引能筛选出1000W*10%=100w条数据,然后再回表从100w条数据中找到符合col2=2 and col3= 3的数据,然后再排序,再分页;如果是联合索引,通过索引筛选出1000w*10%* 10% *10%=1w,效率提升可想而知!
15. MySQL内存被撑爆,如何快速定位
-
操作系统检查
free -m
查看系统内存使用情况,如果此时的内存使用已经很高,物理内存和swap虚拟内存几乎都被用完,buffers和cached也不多,随时可能出现OOM的情况的话。再使用top
命令查看占用内存最大的进程。如果查出是MySQL占用内存过高,可以使用命令ps -e -o 'pid,comm,args,pcpu,rsz,vsz,stime,user,uid'|grep -E 'PID|mysql'|grep -v grep
查看MySQL占用内存的情况 -
查看给MySQL分配的内存,查看缓冲区和缓存的内存情况
SET @giga_bytes = 1024*1024*1024; SELECT (@@key_buffer_size + @@query_cache_size + @@tmp_table_size + @@innodb_buffer_pool_size + @@innodb_additional_mem_pool_size + @@innodb_log_buffer_size + (select count(HOST) from information_schema.processlist)/*@@max_connections*/*(@@read_buffer_size + @@read_rnd_buffer_size + @@sort_buffer_size + @@join_buffer_size + @@binlog_cache_size + @@thread_stack)) / @giga_bytes AS MAX_MEMORY_GB; show global variables like '%buffer%'; show global variables like '%cache%';
-
如果mysql分配的内存比系统内存大
比如系统内存128G,mysql分配的内存已经大于128G,但是系统本身和其它程序也需要内存,甚至mysqldump同样需要内存,所以这样就很容易造成系统内存不足,从而导致OOM。这时我们要查出哪些参数设置比较大,适当降低内存分配。
innodb_buffer_pool在mysql中占有最大内存,将innodb_buffer_pool_size调小可以有效降低OOM问题。但如果设置太小会导致内存刷脏页频率增加,IO增多,从而降低性能。通常我们认为innodb_buffer_pool_size为系统内存的60%~75%最优。 -
如果mysql分配的内存比系统内存小
如果mysql参数设置都比较合理,但是仍然出现oom,那么可能是由于mysql在系统层面所需内存不足导致,因为mysql读取表时,如果同时有多个session引用一个表则会创建多个表对象,这样虽然减少了内部表锁的争用,但是会加大内存使用量。
-
首先,可以通过lsof -p pid查看进程打开的系统文件数,pid为mysqld的进程号。
-
查看mysql服务打开文件数限制:
show global variables like 'open_files_limit';
查看操作系统打开文件数限制:
-
-
16. 批量向MySQL中导入1000万条数据如何优化
-
批量录入数据,手动开启事务,并手动提交
-
批量SQL
insert into table_name() values();
单条数据录入
insert into table_name() values(),(),();
多条数据录入,带有缓存的。可以通过命令配置,也可以通过配置文件配置。单条sql不要录入过多的数据。通常不超过3M~10M -
数据库配置
配置SQL批处理缓存:
配置是否记录binlog,不推荐关闭
配置IO缓存 -
通过txt或csv文件做本地导入,
mysql import xxx文件
-
代码级开发,batch批处理。找临界值,循环多次访问数据库,批量写入(比如临界值是2000条数据)
-
索引只提升读效率,会降低写效率
降低写效率的原因:
- 索引是写入数据过程中维护的,将索引字段的值进行比较处理,并保存在一个树下,树是B[+]Tree。平衡树,查询效率高,维护效率低。
- 推荐是索引使用方式是:建表时,先不创建索引,当数据相对趋于稳定,或正式商业发布时,创建索引。
索引是先内存维护,索引内存空间不足,需要持久化到磁盘。
17. 分页的时候偏移量过大,效率很差,如何优化
出现这个问题的原因:因为MySQL在进行分页的时候,并不直接查rows的数据,而是把offset和rows的数据全部查出来,然后再将offset的数据扔掉,返回rows的数据;
- 查询所有列导致回表
- limit a, b会查询前a*b+b条数据,然后丢弃前a*b条数据
解决方案:
- 使用覆盖索引
- 条件过滤
引用:https://zhuanlan.zhihu.com/p/191067745
18. 大数据量高并发访问的数据库优化方法
-
数据库结构设计优化
比如优化数据模型,增加冗余减少数据库的访问次数,优化查询sql建索引等减少每个查询的时间,增加批量查询
-
分布式部署,增加主从配置,实现读写分离
-
数据库分库(垂直分割)分表(水平分割)
-
使用缓存和nosql,分离冷热数据
事务
1. 事务的基本要素(ACID)
原子性
(Atomicity):事务开始后所有操作,要么全部做完,要么全部不做,不可能停滞在中间环节。事务执行过程中出错,会回滚到事务开始前的状态,所有的操作就像没有发生一样。也就是说事务是一个不可分割的整体,就像化学中学过的原子,是物质构成的基本单位。原子性是基于日志的Redo/Undo
机制一致性
(Consistency):事务开始前和结束后,数据库的完整性约束没有被破坏 。比如A向B转账,不可能A扣了钱,B却没收到隔离性
(Isolation):同一时间,只允许一个事务请求同一数据,不同的事务之间彼此没有任何干扰。比如A正在从一张银行卡中取钱,在A取钱的过程结束前,B不能向这张卡转账持久性
(Durability):事务完成后,事务对数据库的所有更新将被保存到数据库,不能回滚
2. 事务的并发问题
-
脏读:事务A读取了事务B尚未提交的更新数据,然后B回滚操作,那么A读取到的数据是脏数据
-
幻读:系统管理员A将数据库中所有学生的成绩从具体分数改为ABCDE等级,但是系统管理员B就在这个时候插入了一条具体分数的记录,当系统管理员A改结束后发现还有一条记录没有改过来,就好像发生了幻觉一样,这就叫幻读
-
不可重复读:事务 A 多次读取同一数据,事务 B 在事务A多次读取的过程中,对数据作了更新并提交,导致事务A多次读取同一数据时,结果 不一致
不可重复读的和幻读很容易混淆,不可重复读侧重于修改,幻读侧重于新增或删除。解决不可重复读的问题只需锁住满足条件的行,解决幻读需要锁表
3. 事务隔离级别
-
读未提交
(READ UNCOMMITTED)最低的隔离级别,允许读取尚未提交的数据变更,可能会导致脏读、幻读或不可重复读
-
读已提交
(READ COMMITTED) Oracle默认允许读取并发事务已经提交的数据,可以阻止脏读,但是幻读或不可重复读仍有可能发生
-
可重复读
(REPEATABLE READ) MySQL默认对同一字段的多次读取结果都是一致的,除非数据是被本身事务自己所修改,可以阻止脏读和不可重复读,但幻读仍有可能发生
-
串行化
(SERIALIZABLE)最高的隔离级别,完全服从ACID的隔离级别。所有的事务依次逐个执行,这样事务之间就完全不可能产生干扰,也就是说,该级别可以防止脏读、不可重复读以及幻读
4. 事务不生效场景
- 数据库引擎不支持。确认创建的mysql数据库表引擎是InnoDB,MyISAM不支持事务
- 调用的方法必须是public,否则事务不起作用。这一点由Spring的AOP特性决定的
- 入口方法没有事务,子方法即便加了事务也不会生效
- 抛出一个runtimeException才能回滚,Spring使用声明式事务处理,默认情况下,如果被注解的数据库操作方法中发生了unchecked(比如RuntimeException)异常,所有的数据库操作将rollback;如果发生的异常是checked(比如IOException)异常,默认情况下数据库操作还是会提交的
- 请确保你的业务和事务入口在同一个线程里,否则事务也是不生效的
- 请确认你的类是否被代理了(因为spring的事务实现原理为AOP,只有通过代理对象调用方法才能被拦截,事务才能生效)
事务不生效的根本原因:
spring 在扫描bean的时候会扫描方法上是否包含@Transactional注解,如果包含,spring会为这个bean动态地生成一个子类(即代理类,proxy),代理类是继承原来那个bean的。此时,当这个有注解的方法被调用的时候,实际上是由代理类来调用的,代理类在调用之前就会启动transaction。然而,如果这个有注解的方法是被同一个类中的其他方法调用的,那么该方法的调用并没有通过代理类,而是直接通过原来的那个bean,所以就不会启动transaction,我们看到的现象就是@Transactional注解无效
5. 事务传播机制
-
REQUIRED
默认策略,如果当前没有事务,就创建一个新事务,如果当前存在事务,就加入该事务,该设置是最常用的设置
总得有一个事务,你有我就用你的,你没有我就新开一个
-
REQUIRES_NEW
创建一个新事务,如果当前存在一个事务,则将其挂起(Suspend)
你用你的,我用我的
-
SUPPORTS
优先使用当前事务,如果当前不存在事务,则以无事务的方式运行
你有我就用你的,你没有我就不用
-
MANDATORY
优先使用当前事务,如果当前不存在事务,则抛出异常
你要用你的,你必须得有
-
NOT_SUPPORTED
以非事务方式运行,如果当前存在事务,则将其挂起
你用你的,我反正不用
-
NEVER
以非事务方式运行,如果当前存在事务,则抛出异常
谁都不准用
-
NESTED
如果当前存在事务,则在嵌套事务内执行。如果当前没有事务,则执行与PROPAGATION_REQUIRED类似
6. MySQL三大日志之binlog、redo log、undo log
-
binlog
binlog用于记录数据库执行的写入性操作(不包括查询)信息,以二进制的形式保存在磁盘中。binlog是mysql的逻辑日志,并且由Server层进行记录,使用任何存储引擎的mysql数据库都会记录binlog日志
- 逻辑日志:可以简单理解为记录的就是sql语句
- 物理日志:因为mysql数据最终是保存在数据页中的,物理日志记录的就是数据页变更
binlog是通过追加的方式进行写入的,可以通过max_binlog_size参数设置每个binlog文件的大小,当文件大小达到给定值之后,会生成新的文件来保存日志
binlog使用场景:
- 主从复制:在Master端开启binlog,然后将binlog发送到各个Slave端,Slave端重放binlog从而达到主从数据一致
- 数据恢复:通过使用mysqlbinlog工具来恢复数据
-
redo log
redo log通常是物理日志,记录的是数据页的物理修改,而不是某一行或某几行修改成怎样怎样,它用来恢复提交后的物理数据页,具体来说就是只记录事务对数据页做了哪些修改
redo log主要记录了一段时间内物理数据页的变化
-
undo log
undo log主要记录了数据的逻辑变化,比如一条INSERT语句,对应一条DELETE的undo log,对于每个UPDATE语句,对应一条相反的UPDATE的undo log,这样在发生错误时,就能回滚到事务之前的数据状态
数据库事务四大特性中有一个是原子性,具体来说就是 原子性是指对数据库的一系列操作,要么全部成功,要么全部失败,不可能出现部分成功的情况。实际上,原子性底层就是通过undo log实现的
注意:undo log不是redo log的逆向过程
redo log用来记录某数据块被修改后的值,可以用来恢复未写入 data file 的已成功事务更新的数据;undo log是用来记录数据更新前的值,保证数据更新失败能够回滚。假如数据库在执行的过程中,不小心崩了,可以通过该日志的方式,回滚之前已经执行成功的操作,实现事务的一致性
7. MySQL的锁机制
按锁的粒度进行分类,可分为行锁和表锁
-
行锁
行级锁是Mysql中锁定粒度最细的一种锁,表示只针对当前操作的行进行加锁。行级锁能大大减少数据库操作的冲突。其加锁粒度最小,但加锁的开销也最大。有可能会出现死锁的情况。
实现算法:
-
Record Lock
单个行记录的锁。对索引项加锁,锁定符合条件的行。其他事务不能修改和删除加锁项
-
Gap Lock
锁定一个范围,不包含记录本身。对索引项之间的“间隙”加锁,锁定记录的范围(对第一条记录前的间隙或最后一条将记录后的间隙加锁),不包含索引项本身。其他事务不能在锁范围内插入数据,这样就防止了别的事务新增幻影行
-
Next-key Lock(默认)
同时锁住数据+间歇锁。锁定索引项本身和索引范围。即Record Lock和Gap Lock的结合。可解决幻读问题
-
-
表锁
表级锁是MySQL锁中粒度最大的一种锁,表示当前的操作对整张表加锁,资源开销比行锁少,不会出现死锁的情况,但是发生锁冲突的概率很大。被大部分的MySQL引擎支持,MyISAM和InnoDB都支持表级锁,但是InnoDB默认的是行级锁
按是否可写分类,可分为共享锁和排他锁
-
共享锁
(读锁 S锁)共享锁(Shared Locks)又被称为读锁,其他用户可以并发读取数据,但任何事务都不能获取数据上的排他锁,直到已释放所有共享锁。
共享锁(S锁)又称为读锁,若事务T对数据对象A加上S锁,则事务T只能读A;其他事务只能再对A加S锁,而不能加X锁,直到T释放A上的S锁。这就保证了其他事务可以读A,但在T释放A上的S锁之前不能对A做任何修改。
-
排他锁
(写锁 X锁)排它锁(Exclusive Locks)又称为写锁,若事务T对数据对象A加上X锁,则只允许T读取和修改A,其它任何事务都不能再对A加任何类型的锁,直到T释放A上的锁。它防止任何其它事务获取资源上的锁,直到在事务的末尾将资源上的原始锁释放为止。在更新操作(INSERT、UPDATE 或 DELETE)过程中始终应用排它锁。
8. 如何排查锁的问题
-
表锁
-
查看表锁的情况
-
查看所有线程情况
show full processlist;
-
查看当前正在使用的表
show open tables;
-
查看innodb服务器信息
show engine innodb status;
-
-
表锁分析
show status like 'table%';
Table_locks_waited
:出现表级锁定争用而发生等待的次数(不能立即获取锁的次数,每等待一次加1),这个比例值越大说明表级锁争用的情况越严重Table_locks_immediate
:表示可以立即获取锁的次数
-
-
行锁
-
查看行锁的情况
show status like 'innodb_row_lock%';
Innodb_row_lock_current_waits
:当前正在等待锁定的数量Innodb_row_lock_time
:从系统启动到现在锁定的总时间Innodb_row_lock_time_avg
:每次等待所花平均时间Innodb_row_lock_time_max
:从系统启动到现在等待最长的一次所花时间Innodb_row_lock_waits
:从系统启动到现在总共等待的次数 -
information_schema库
innodb_trx
表:该表只用来显示当前运行innodb事务情况,不能判断锁的情况innodb_locks
表:可以查看锁的情况innodb_lock_waits
表:可以查看锁等待的情况表及字段含义参考:https://blog.csdn.net/yuyinghua0302/article/details/82318408
-
-
优化建议:
- 尽可能让所有数据检索都通过索引来完成,避免无索引导致行级锁升级为表级锁
- 合理设计索引,尽量缩小锁的范围
- 尽可能较少检索条件,避免间隙锁
- 尽量控制事务大小,减少锁定资源量和时间长度
- 尽可能低级别事务隔离
9. MVCC机制
-
定义
英文全称为Multi-Version Concurrency Control,就是
多版本并发控制
,用来解决读写冲突。它主要由隐藏字段(trx_id
当前事务ID,roll_pointer
回滚指针),undo log
日志,read-view
来配合完成的,也是一种乐观锁的实现 -
基本原理
MVCC的实现,通过保存数据在某个时间点的快照来实现的。这意味着一个事务无论运行多长时间,在同一个事务里能够看到数据一致的视图。根据事务开始的时间不同,同时也意味着在同一个时刻不同事务看到的相同表里的数据可能是不同的
- 每行数据都存在一个版本,每次数据更新时都更新该版本
- 修改时Copy出当前版本随意修改,各个事务之间无干扰
- 保存时比较版本号,如果成功(commit),则覆盖原记录;失败则放弃copy(rollback)
- MVCC手段只适用于MsSQL隔离级别中的读已提交(Read committed)和可重复读(Repeatable Read)
-
InnoDB存储引擎MVCC的实现策略
在每一行数据中额外保存两个隐藏的列:当前行创建时的版本号和删除时的版本号(可能为空,其实还有一列称为回滚指针,用于事务回滚,不在本文范畴)。这里的版本号并不是实际的时间值,而是系统版本号。每开始新的事务,系统版本号都会自动递增。事务开始时刻的系统版本号会作为事务的版本号,用来和查询每行记录的版本号进行比较
参考视频:https://www.bilibili.com/video/BV1864y1976i/?spm_id_from=333.788.recommend_more_video.-1
MyBatis
1. MyBatis一级缓存和二级缓存
-
一级缓存
MyBatis的一级缓存是Session缓存。一级缓存的作用域默认是一个SqlSession。不同的SqlSession之间的缓存数据区域是互不影响的。MyBatis默认开启一级缓存。
也就是在同一个SqlSession中,执行相同的查询SQL,第一次会去数据库进行查询,并写到缓存中。第二次以后是直接去缓存中取。当执行SQL查询中间发生了增删改的操作,MyBatis会把SqlSession的缓存清空。
命中条件:
- 同一个SqlSession
- 要查询的SQL一模一样
-
二级缓存
二级缓存指的是SqlSessionFactory对象的缓存,由同一个SqlSessionFactory对象创建的SqlSession缓存其缓存,但是其中缓存的是数据而不是对象,所以从二级缓存再次查询出的结果的对象和第一次存入的对象是不一样的。二级缓存不建议开启
2. ${}和#{}有什么区别
-
#{}是预编译处理,${}是字符串替换
-
MyBatis在处理#{}时,会将sql中的#{}替换为?号,调用PreparedStatement的set方法来赋值
-
MyBatis在处理${}时,就是把${}替换成变量的值
-
使用#{}可以有效的防止SQL注入,提高系统安全性,一般能用#的就别用$
-
$方式一般用于传入数据库对象,例如传入表名
并发编程
多线程
1. 创建线程有哪几种方式
-
继承Thread类,并重写run方法,通过调用start()方法来开启线程
// 继承Thread类重写run()方法 public class MyThread extends Thread { @Override public void run() { System.out.println("继承Thread类创建线程"); } } // 创建Thread实例调用start()方法启动线程 public class ThreadTest { public static void main(String[] args) { new MyThread().start(); } }
-
实现Runnable接口,并实现run方法,然后创建Runnable实例并放到Thread实例中
// 实现Runnable接口 public class MyRunnable implements Runnable { @Override public void run() { System.out.println("通过实现Runnable接口实现线程"); } } public class ThreadTest { public static void main(String[] args) { new Thread(new MyRunnable()).start(); } }
-
通过Callable和Future创建线程
- 创建Callable接口的实现类,并实现call()方法,该call()方法将作为线程执行体,且该call()方法没有返回值,再创建Callable实现类的实例。
- 使用FutureTask类来包装Callable对象,该FutureTask对象封装了该Callable对象的call()方法的返回值。
- 使用FutureTask作为Thread对象的target创建并启动新线程。
- 调用FutureTask对象的get方法来获得子线程执行结束后的返回值。
public class MyCallable implements Callable<Integer> { @Override public Integer call() throws Exception { return 10; } } public class ThreadTest { public static void main(String[] args) throws ExecutionException, InterruptedException { FutureTask<Integer> futureTask = new FutureTask<>(new MyCallable()); new Thread(futureTask).start(); Integer integer = futureTask.get(); System.out.println(integer); } } // 输出结果10
-
使用线程池创建
2. 线程有哪些状态
-
新建(
New
)新建后尚未启动的线程
-
就绪(
Runnable
)线程对象创建后,其他线程(比如main线程)调用了该对象的start()方法。该状态的线程位于可运行线程池中,等待被线程调度选中,获取cpu 的使用权
-
运行(
Running
)就绪状态的线程获取了CPU,执行程序代码
-
阻塞(
Blocked
)-
无限期等待(
Waiting
)等待阻塞,运行的线程执行wait()方法,该线程会释放占用的所有资源,JVM会把该线程放入等待池中。进入这个状态后,是不能自动唤醒的,必须依靠其他线程调用notify()或notifyAll()方法才能被唤醒
-
限期等待(
Timed Waiting
)不会被分配CPU执行时间,不过无需等待其他线程显示的唤醒,在一定时间之后会由系统自动唤醒。例如调用Thread.sleep()方法
线程被阻塞了,“阻塞状态”与“等待状态”的区别是:“阻塞状态”在等待获取着一个排他锁,这个事件将在另外一个线程放弃这个锁的时候发生,而“等待状态”则是在等待一段时间,或者唤醒动作的发生。在程序等待进入同步区域的时候,线程将进入这种状态
-
-
结束(
Terminated
)线程结束执行
3. 什么是守护线程
Java线程分两类,守护线程和非守护线程,默认开的都是非守护线程
在Java中比较特殊的线程是被称为守护线程的低级别线程,守护线程在没有用户线程可服务时自动离开。这个线程具有最低的优先级,用于为系统中的其它对象和线程提供服务。所有的非守护线程都退出后,整个JVM进程也就退出了,可以通过thread.setDaemon()进行设置为守护线程。setDeamon(true)的唯一意义就是告诉JVM不要等待它退出,让JVM喜欢什么时候退出就退出吧,不用管它
4. 3个线程分别按顺序输出1~99如何实现
线程池
1. 线程池的好处
-
降低资源消耗
可以重复利用已创建的线程降低线程创建和销毁造成的消耗
-
提高响应速度
当任务到达时,任务可以不需要等到线程创建就能立即执行
-
提高线程的可管理性
线程是稀缺资源,如果无限制地创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一分配、调优和监控
2. 线程池的主要实现
线程池不允许使用Executors去创建,而要通过ThreadPoolExecutor
方式,这一方面是由于jdk中Executor框架虽然提供了如下创建线程池的方法,但都有其局限性,不够灵活;另外由于前面几种方法内部也是通过ThreadPoolExecutor方式实现,使用ThreadPoolExecutor有助于大家明确线程池的运行规则,创建符合自己的业务场景需要的线程池,避免资源耗尽的风险
newCachedThreadPool
:创建一个可缓存线程池,如果线程池长度超过处理需要,可灵活回收空闲线程,若无可回收,则新建线程newFixedThreadPool
:创建一个可重用的、具有固定线程数的线程池,可控制线程最大并发数,超出的线程会在队列中等待newScheduledThreadPool
:创建具有指定线程数的线程池,它可以在指定延迟后执行线程任务。corePoolSize指池中所保存的线程数,即使线程时空闲的也被保存在线程池内,支持定时及周期性任务执行newSingleThreadExecutor
:创建一个单线程化的线程池,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序(FIFO, LIFO, 优先级)执行
避免使用Executors创建线程池,主要是避免使用其中的默认实现,那么我们可以自己直接调用ThreadPoolExecutor的构造函数来自己创建线程池。在创建的同时,给BlockQueue指定容量就可以了
private static ExecutorService executor = new ThreadPoolExecutor(10, 10,
60L, TimeUnit.SECONDS, new ArrayBlockingQueue(10));
3. 线程池的主要参数
参数名 | 说明 |
---|---|
CorePoolSize | 核心池的大小,线程池中会维持不被释放的核心线程数量。也是懒加载 |
MaximumPoolSizes | 线程池的最大线程数,即总共能创建多少线程 |
KeepAliveTime、Unit | 空闲线程回收时间及单位, 默认情况下,只有线程数大于核心线程corePoolSize时,该参数才会起作用 |
WorkQueue | 阻塞队列,用来存储等待执行的任务 ArrayBlockingQueue、LinkedBlockingQueue、SynchronousQueue |
ThreadFactory | 线程工厂,主要用来创建线程 |
RejectedExecutionHandler | 线程池饱和处理策略 |
4. 线程池的执行流程
-
当线程池中线程数小于corePoolSize时,新提交任务将创建一个新线程执行任务,即使此时线程池中存在空闲线程
-
当线程池中线程数达到corePoolSize时,且workQueue还没满时,新提交任务将被放入workQueue中,等待线程池中任务调度执行
-
当workQueue已满,且maximumPoolSize > corePoolSize时,新提交任务会创建新线程执行任务
-
当workQueue已满,且提交任务数超过maximumPoolSize,任务由RejectedExecutionHandler处理
当线程池中线程数超过corePoolSize,且超过这部分的空闲时间达到keepAliveTime时,回收这些线程
当设置allowCoreThreadTimeOut(true)时,线程池中corePoolSize范围内的线程空闲时间达到keepAliveTime也将回收
任务调度流程:
5. 线程池饱和策略
AbortPolicy
默认的阻塞策略,不执行此任务。丢弃任务并抛出RejectedExecutionException异常(运行时异常),所以ThreadPoolExecutor.execute需要try catch,否则程序会直接退出DiscardPolicy
直接抛弃,任务不执行,不抛出异常DiscardOldestPolicy
丢弃队列最前面的任务,然后重新尝试执行任务(重复此过程)CallerRunsPolicy
只要线程池未关闭,该策略直接在调用者线程中运行当前被丢弃的任务。显然这样不会真的丢弃任务,但是,调用者线程性能可能急剧下降
6. 如何合理地配置线程池
要合理地配置线程池,就要分析任务特性。任务的性质主要分为2种类型:
-
CPU密集型
CPU密集型也是指计算密集型,大部分时间用来做计算逻辑判断等CPU动作的程序称为CPU密集型任务。该类型的任务需要进行大量的计算,主要消耗CPU资源。这种计算密集型任务虽然也可以用多任务完成,但是任务越多,花在任务切换的时间就越多,CPU执行任务的效率就越低,所以,要最高效地利用CPU,计算密集型任务同时进行的数量应当等于CPU的核心数
-
IO密集型和混合型
IO密集型任务指任务需要执行大量的IO操作,涉及到网络、磁盘IO操作,对CPU消耗较少
CPU密集型任务应配置尽可能小的线程,如配置CPU数目+1个线程的线程池。由于IO密集型任务线程并不是一直在执行任务,则应配置尽可能多的线程,如2*CPU数目
7. ExecutorService中submit和execute的区别
-
接收的参数不同
-
submit有返回值,而execute没有
-
submit方便Exception处理,而execute处理异常比较麻烦
在task里会抛出checked或者unchecked exception,而用户又希望外面的调用者能够感知这些exception并做出及时的处理,那么就需要用到submit。submit()能在返回的Future对象调用get()方法的时候再次抛出线程中的异常,而execute()会交由线程的UncaughtExceptionHandler去处理
线程安全
1. 实现线程同步的几种方式
- 使用
synchronized
关键字修饰方法或代码块 - 使用
volatile
关键字修饰变量,使某个变量对多个线程可见并保证数据同步 - 使用
Lock
锁的实现,比如ReentrantLock
可重入锁,ReentrantReadWriteLock
读写锁等 - 使用
ThreadLocal
来管理,该方法和同步机制不同,被ThreadLocal管理的变量在每个线程中都有自己的副本,副本之间相互独立 - 使用
AutomicInteger
原子类 - 基于
AQS
实现的其它阻塞队列,如CountDownLatch
、LinkedBlockingQueue
等
2. 什么是CAS
-
CAS概念
CAS(Compare and Swap),即比较并替换,实现并发算法时常用到的一种技术,也是乐观锁的一种实现,是用非阻塞算法来代替锁定,java.util.concurrent包完全是建立于CAS机制之上的,比如AtomicInteger等实现
-
CAS的机制
三个参数,一个当前内存值V、旧的预期值A、即将更新的值B,当且仅当预期值A和内存值V相同时,将内存值修改为B并返回true,否则什么都不做,并返回false。一般和volatile变量配合使用,这样才能保证每次拿到的变量是主内存中最新的那个值,否则旧的预期值A对某条线程来说,永远是一个不会变的值A
-
CAS原理
Java CAS是通过调用Unsafe的native方法,再由C程序调用CPU底层命令实现的
-
CAS缺点
-
会产生ABA的问题,ABA 通常的解决办法是添加版本号,每次修改操作时版本号加一,这样数据对比的时候就不会出现 ABA 的问题了
-
自旋开销大
CAS自旋如果长时间不成功,会给CPU带来非常大的执行开销
-
只能保证一个共享变量的原子操作
当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁,或者有一个取巧的办法,就是把多个共享变量合并成一个共享变量来操作
-
3. volatile关键字
volatile变量是一种比sychronized关键字更轻量级的同步机制,它在多线程中保证了变量的可见性
。可见性的意思是当一个线程修改了一个变量的值后,另外的线程能够读取到这个变量修改后的值。
在Java中,所有的实例域、静态变量和数组元素都存储在堆内存中,堆内存在线程之间是共享的。线程之间共享变量存储在主内存中,每个线程都有一个私有的本地内存,本地内存中存储了该线程共享变量的副本。
被volatile修饰的共享变量,就具有了以下两点特性:
- 保证了不同线程对该变量操作的内存可见性
- 禁止指令重排序
volatile能保证可见性和有序性,但不能保证原子性,类似于i++问题不能保证
-
volatile底层的实现机制:
如果把加入volatile关键字的代码和未加入volatile关键字的代码都生成汇编代码,会发现加入volatile关键字的代码会多出一个lock前缀指令。lock前缀指令实际相当于一个内存屏障,内存屏障提供了以下功能:
- 重排序时不能把后面的指令重排序到内存屏障之前的位置
- 使得本CPU的Cache写入主内存内存
- 写入动作也会引起别的CPU或者别的内核的本地Cache无效,相当于让新写入的值对别的线程可见
-
延伸:什么是内存屏障
CPU中,每个CPU又有多级缓存(即高速缓存),一般分为L1,L2,L3,因为这些缓存的出现,提高了数据访问性能,避免每次都向内存索取;但是弊端也很明显,不能实时的和内存发生信息交换,分在不同CPU执行的不同线程对同一个变量的缓存值不同。
由于现代操作系统都是多处理器操作系统,每个处理器都会有自己的缓存,可能存再不同处理器缓存不一致的问题,而且由于操作系统可能存在重排序,导致读取到错误的数据,因此操作系统提供了一些内存屏障以解决这种问题
简单来说:- 在不同CPU执行的不同线程对同一个变量的缓存值不同,为了解决这个问题
- 用volatile可以解决上面的问题,不同硬件对内存屏障的实现方式不一样。java屏蔽掉这些差异,通过jvm生成内存屏障的指令。对于读屏障:在指令前插入读屏障,可以让高速缓存中的数据失效,强制从主内存取
4. sychronized关键字
synchronized是Java中解决并发问题的一种最常用最简单的方法 ,他可以确保线程互斥的访问同步代码。被synchronized修饰的代码,每次执行时会进行加锁操作,同时只允许一个线程进行操作,所以synchronized其实也是悲观锁的一种实现
-
synchronized的作用主要有三个:
- 原子性:确保线程互斥的访问同步代码
- 可见性:保证共享变量的修改能够及时可见,其实是通过Java内存模型中的对一个变量unlock操作之前,必须要同步到主内存中;如果对一个变量进行lock操作,则将会清空工作内存中此变量的值,在执行引擎使用此变量前,需要重新从主内存中load操作或assign操作初始化变量值来保证的
- 有序性:有效解决重排序问题,即一个unlock操作先行发生(happen-before)于后面对同一个锁的lock操作
-
synchronized总共有三种用法:
- 作用于普通方法,锁是当前实例对象
- 作用于静态同步方法,锁是当前类的class对象
- 作用于同步代码块,锁是括号中的对象
注意,synchronized 内置锁 是一种 对象锁(锁的是对象而非引用变量),作用粒度是对象,可以用来实现对 临界资源的同步互斥访问,是可重入的,其可重入最大的作用是避免死锁
-
实现原理:
JVM是通过进入、退出对象监视器(Monitor) 来实现对方法、同步块的同步的,而对象监视器的本质依赖于底层操作系统的互斥锁(Mutex Lock) 实现。
具体实现是在编译之后在同步方法调用前加入一个
monitor.enter
指令,在退出方法和异常处插入monitor.exit
的指令。对于没有获取到锁的线程将会阻塞到方法入口处,直到获取锁的线程monitor.exit之后才能尝试继续获取锁-
当我们进入一个人方法的时候,执行monitor.enter,就会获取当前对象的一个所有权,这个时候monitor进入数为1,当前的这个线程就是这个monitor的owner
-
如果你已经是这个monitor的owner了,你再次进入,就会把进入数+1
-
同理,当他执行完monitor.exit,对应的进入数就-1,直到为0,才可以被其他线程持有
-
5. 什么是Java对象头?
对象在内存的分布分为3个部分:对象头,实例数据,和对齐填充
-
对象头
-
Mark Word(标记字段)
Mark Word用于存储对象自身的运行时数据,如:哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等。比如锁膨胀就是借助Mark Word的偏向的线程ID
-
Class Pointer(类型指针)
Class Pointer是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例,Mark Word用于存储对象自身的运行时数据,它是实现轻量级锁和偏向锁的关键
-
数组长度(只有数组才有)
-
-
实例数据
存放类的属性数据信息,包括父类的属性信息
-
对齐填充
由于虚拟机要求 对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐
6. sychronized锁升级(膨胀)过程
在Java早期版本中,synchronized属于重量级锁,效率低下,因为操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高。
不过JDK1.6之后Java官方对从JVM层面对synchronized较大优化,所以现在的synchronized锁效率也优化得很不错了,JDK1.6之后,为了减少获得锁和释放锁所带来的性能消耗,引入了偏向锁和轻量级锁。ConcurrentHashMap在JDK1.7的时候,实现用的是分段锁,用ReentrantLock来保证并发安全。而在JDK1.61.8的时候,抛弃了原有的分段锁,而采用了 CAS + synchronized 来保证并发安全性,也可以说明synchronized的的效率现在确实很高了。
synchronized锁有四种状态,无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁,这几个状态会随着竞争状态逐渐升级,锁可以升级但不能降级,但是偏向锁状态可以被重置为无锁状态
-
偏向锁
-
为什么要引入偏向锁
因为经过HotSpot的作者大量的研究发现,大多数时候是不存在锁竞争的,常常是一个线程多次获得同一个锁,因此如果每次都要竞争锁会增大很多没有必要付出的代价,为了降低获取锁的代价,才引入的偏向锁
-
偏向锁原理和升级过程
当线程1访问代码块并获取锁对象时,会在java对象头和栈帧中记录偏向的锁的ThreadID,因为偏向锁不会主动释放锁,因此以后线程1再次获取锁的时候,需要比较当前线程的ThreadID和Java对象头中的ThreadID是否一致,如果一致(还是线程1获取锁对象),则无需使用CAS来加锁、解锁;如果不一致(其他线程,如线程2要竞争锁对象,而偏向锁不会主动释放因此还是存储的线程1的ThreadID),那么需要查看Java对象头中记录的线程1是否存活,如果没有存活,那么锁对象被重置为无锁状态,其它线程(线程2)可以竞争将其设置为偏向锁;如果存活,那么立刻查找该线程(线程1)的栈帧信息,如果还是需要继续持有这个锁对象,那么暂停当前线程1,撤销偏向锁,升级为轻量级锁,如果线程1 不再使用该锁对象,那么将锁对象状态设为无锁状态,重新偏向新的线程
-
-
轻量级锁
-
为什么要引入轻量级锁
轻量级锁考虑的是竞争锁对象的线程不多,而且线程持有锁的时间也不长的情景。因为阻塞线程需要CPU从用户态转到内核态,代价较大,如果刚刚阻塞不久这个锁就被释放了,那这个代价就有点得不偿失了,因此这个时候就干脆不阻塞这个线程,让它自旋这等待锁释放
-
轻量级锁原理和升级过程
线程1获取轻量级锁时会先把锁对象的对象头MarkWord复制一份到线程1的栈帧中创建的用于存储锁记录的空间(称为DisplacedMarkWord),然后使用CAS把对象头中的内容替换为线程1存储的锁记录(DisplacedMarkWord)的地址
如果在线程1复制对象头的同时(在线程1CAS之前),线程2也准备获取锁,复制了对象头到线程2的锁记录空间中,但是在线程2CAS的时候,发现线程1已经把对象头换了,线程2的CAS失败,那么线程2就尝试使用自旋锁来等待线程1释放锁。 自旋锁简单来说就是让线程2在循环中不断CAS
但是如果自旋的时间太长也不行,因为自旋是要消耗CPU的,因此自旋的次数是有限制的,比如10次或者100次,如果自旋次数到了线程1还没有释放锁,或者线程1还在执行,线程2还在自旋等待,这时又有一个线程3过来竞争这个锁对象,那么这个时候轻量级锁就会膨胀为重量级锁。重量级锁把除了拥有锁的线程都阻塞,防止CPU空转
-
7. JVM使用锁和Mark Word的具体过程
-
当没有被当成锁时,这就是一个普通的对象,Mark Word记录对象的HashCode,锁标志位是01,是否偏向锁那一位是0
-
当对象被当做同步锁并有一个线程A抢到了锁时,锁标志位还是01,但是否偏向锁那一位改成1,前23bit记录抢到锁的线程id,表示进入偏向锁状态
-
当线程A再次试图来获得锁时,JVM发现同步锁对象的标志位是01,是否偏向锁是1,也就是偏向状态,Mark Word中记录的线程id就是线程A自己的id,表示线程A已经获得了这个偏向锁,可以执行同步锁的代码
-
当线程B试图获得这个锁时,JVM发现同步锁处于偏向状态,但是Mark Word中的线程id记录的不是B,那么线程B会先用CAS操作试图获得锁,这里的获得锁操作是有可能成功的,因为线程A一般不会自动释放偏向锁。如果抢锁成功,就把Mark Word里的线程id改为线程B的id,代表线程B获得了这个偏向锁,可以执行同步锁代码。如果抢锁失败,则继续执行步骤5
-
偏向锁状态抢锁失败,代表当前锁有一定的竞争,偏向锁将升级为轻量级锁。JVM会在当前线程的线程栈中开辟一块单独的空间,里面保存指向对象锁Mark Word的指针,同时在对象锁Mark Word中保存指向这片空间的指针。上述两个保存操作都是CAS操作,如果保存成功,代表线程抢到了同步锁,就把Mark Word中的锁标志位改成00,可以执行同步锁代码。如果保存失败,表示抢锁失败,竞争太激烈,继续执行步骤6
-
轻量级锁抢锁失败,JVM会使用自旋锁,自旋锁不是一个锁状态,只是代表不断的重试,尝试抢锁。从JDK1.7开始,自旋锁默认启用,自旋次数由JVM决定。如果抢锁成功则执行同步锁代码,如果失败则继续执行步骤7
-
自旋锁重试之后如果抢锁依然失败,同步锁会升级至重量级锁,锁标志位改为10。在这个状态下,未抢到锁的线程都会被阻塞
8. Lock和synchronized区别
- Lock是一个接口,而synchronized是Java中的关键字,synchronized是内置的语言实现
- synchronized在发生异常时,会自动释放线程占有的锁,因此不会导致死锁现象发生;而Lock在发生异常时,如果没有主动通过unLock()去释放锁,则很可能造成死锁现象,因此使用Lock时需要在finally块中释放锁
- Lock可以让等待锁的线程响应中断,而synchronized却不行,使用synchronized时,等待的线程会一直等待下去,不能够响应中断
- 通过Lock可以知道有没有成功获取锁,而synchronized却无法办到
- synchronized和Lock都可重入,synchronized为非公平锁,而Lock锁可以指定公平或非公平
在性能上来说,如果竞争资源不激烈,两者的性能是差不多的,而当竞争资源非常激烈时(即有大量线程同时竞争),此时Lock的性能要远远优于synchronized,Lock可以提高多个线程进行读操作的效率。所以说,在具体使用时要根据适当情况选择,一般Lock锁适合大量同步的代码的同步问题,synchronized锁适合代码少量的同步问题
9. ThreadLocal
-
简介
ThreadLocal提供了线程内存储变量的能力,是一个线程内部的存储类,可以在指定线程内存储数据,数据存储以后,只有指定线程才能取出数据。
当使用ThreadLocal维护变量时,ThreadLocal为每个使用该变量的线程提供了独立的变量副本,所以每一个线程都可以独立的改变自己的本地副本,而不会影响其它线程对应的副本
-
使用场景:
ThreadLocal的作用主要是做数据隔离,填充的数据只属于当前线程,变量的数据对别的线程而言是相对隔离的,在多线程环境下,可以防止自己的变量被其它线程篡改
- 每个线程都需要一个独享的对象(比如工具类,典型的就是
SimpleDateFormat
,每次使用都new一个多浪费性能呀,直接放到成员变量里又是线程不安全,所以把他用ThreadLocal管理起来就完美了。) - 每个线程内需要保存全局变量(比如在登录成功后将用户信息存到ThreadLocal里,然后当前线程操作的业务逻辑直接get取就完事了,有效的避免的参数来回传递的麻烦之处),一定层级上减少代码耦合度,很多场景的cookie,session等数据隔离都是通过ThreadLocal去做实现的
- Spring采用ThreadLocal的方式,来保证单个线程中的数据库操作使用的是同一个数据库连接,同时,采用这种方式可以使业务层使用事务时不需要感知并管理connection对象,通过传播级别,巧妙地管理多个事务配置之间的切换,挂起和恢复。
jdbc连接池
(很典型的一个ThreadLocal用法) - 比如存储 交易id等信息。每个线程私有
- 比如aop里记录日志需要before记录请求id,end拿出请求id,这也可以
- Spring框架中DateTimeContextHolder,RequestContextHolder都用到了
- 每个线程都需要一个独享的对象(比如工具类,典型的就是
-
原理
线程进来之后初始化一个带泛型的ThreadLocal对象,之后这个线程只要在remove之前去get,都能拿到之前set的值。他是能做到线程间数据隔离的,所以别的线程使用get()方法是没办法拿到其他线程的值的,每个线程Thread都维护了自己的threadLocals(ThreadLocalMap)变量,所以在每个线程创建ThreadLocal的时候,实际上数据是存在自己线程Thread的threadLocals变量里面的,别人没办法拿到,从而实现了隔离
-
ThreadLocalMap的key是什么引用
弱引用。key不设置成弱引用的话可能就会造成内存泄漏。ThreadLocal使用完要remove,否则也可能会发生内存泄漏(ThreadLocalMap的value)
-
-
特性
ThreadLocal和synchronized都是为了解决多线程中相同变量的访问冲突问题,不同的是:
- synchronized是通过线程等待,牺牲时间来解决访问冲突
- ThreadLocak是通过每个线程单独一份存储空间,牺牲空间来解决冲突
- 相比于synchronized,ThreadLocal具有线程隔离的效果,只有在线程内才能获取对应的值
参考:https://www.zhihu.com/question/341005993
10. Atomic原子类
-
概念
- 不可分割
- 一个操作是不可中断的,即便是多线程下也可以保证
- 原子类的作用与锁相似,可以保证并发情况下的线程安全
-
优势
-
粒度更细
原子变量可以把竞争范围缩小到变量级别,这是我们可以获得的最细粒度的情况,通常锁的粒度都要大于原子变量的粒度
-
效率更高
使用原子类的效率会比使用锁的效率更高,除了高度竞争的情况
-
-
原理:
概述:Atomic包里的类基本都是使用Unsafe实现的包装类,绝大多数都是调用Unsafe的方法,而Unsafe底层实际上是调用C代码,C代码调用汇编,最后生成出一条CPU指令cmpxchg完成操作。这也就为啥CAS是原子性的,因为它是一条CPU指令,不会被打断。AtomicInteger中的incrementAndGet方法就是乐观锁的一个实现,使用自旋(循环检测更新)的方式来更新内存中的值并通过底层CPU执行来保证是更新操作是原子操作
-
分类
-
基本类型:
- AtomicBoolean:布尔型
- AtomicInteger:整型
- AtomicLong:长整型
-
数组:
- AtomicIntegerArray:数组里的整型
- AtomicLongArray:数组里的长整型
- AtomicReferenceArray:数组里的引用类型
-
引用类型:
- AtomicReference:引用类型
- AtomicStampedReference:带有版本号的引用类型
- AtomicMarkableReference:带有标记位的引用类型
-
对象的属性:
- AtomicIntegerFieldUpdater:对象的属性是整型
- AtomicLongFieldUpdater:对象的属性是长整型
- AtomicReferenceFieldUpdater:对象的属性是引用类型
-
JDK8新增DoubleAccumulator、LongAccumulator、DoubleAdder、LongAdder
-
是对AtomicLong等类的改进。比如LongAccumulator与LongAdder在高并发环境下比AtomicLong更高效
-
常用API,以AtomicInteger为例:
// 以原子方式将给定值与当前值相加,可用于线程中的计数使用,(返回更新的值)。 int addAndGet(int delta) // 以原子方式将给定值与当前值相加,可用于线程中的计数使用,(返回以前的值) int getAndAdd(int delta) // 以原子方式将当前值加 1(返回更新的值) int incrementAndGet() // 以原子方式将当前值加 1(返回以前的值) int getAndIncrement() // 以原子方式设置为给定值(返回旧值) int getAndSet(int newValue) // 以原子方式将当前值减 1(返回更新的值) int decrementAndGet() : // 以原子方式将当前值减 1(返回以前的值) int getAndDecrement() // 获取当前值 get()
-
-
11. AQS
-
基本原理
AbstractQuenedSynchronizer
抽象的队列式同步器。是除了java自带的synchronized关键字之外的锁机制,这个类在java.util.concurrent.locks包AQS的核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并将共享资源设置为锁定状态,如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制AQS是用CLH队列锁实现的,即将暂时获取不到锁的线程加入到队列中
CLH(Craig,Landin,and Hagersten)队列是一个虚拟的双向队列,虚拟的双向队列即不存在队列实例,仅存在节点之间的关联关系。AQS是将每一条请求共享资源的线程封装成一个CLH锁队列的一个结点(Node),来实现锁的分配
AQS就是基于CLH队列,用volatile修饰共享变量state,线程通过CAS去改变状态符,成功则获取锁成功,失败则进入等待队列,等待被唤醒
AQS是自旋锁:在等待唤醒的时候,经常会使用自旋(while(!cas()))的方式,不停地尝试获取锁,直到被其他线程获取成功
-
主要流程
AQS维护了一个volatile int state和一个FIFO线程等待队列,多线程争用资源被阻塞的时候就会进入这个队列。state就是共享资源,其访问方式有如下三种:
getState();setState();compareAndSetState();AQS 定义了两种资源共享方式:
Exclusive
:独占,只有一个线程能执行,如ReentrantLockShare
:共享,多个线程可以同时执行,如Semaphore、CountDownLatch、ReadWriteLock,CyclicBarrier
不同的自定义的同步器争用共享资源的方式也不同。
AQS底层使用了模板方法模式
同步器的设计是基于模板方法模式的,如果需要自定义同步器一般的方式是这样(模板方法模式很经典的一个应用):
- 使用者继承AbstractQueuedSynchronizer并重写指定的方法。(这些重写方法很简单,无非是对于共享资源state的获取和释放)
- 将AQS组合在自定义同步组件的实现中,并调用其模板方法,而这些模板方法会调用使用者重写的方法。
这和我们以往通过实现接口的方式有很大区别,这是模板方法模式很经典的一个运用。
自定义同步器在实现的时候只需要实现共享资源state的获取和释放方式即可,至于具体线程等待队列的维护,AQS已经在顶层实现好了。自定义同步器实现的时候主要实现下面几种方法:
- isHeldExclusively():该线程是否正在独占资源。只有用到condition才需要去实现它。
- tryAcquire(int):独占方式。尝试获取资源,成功则返回true,失败则返回false。
- tryRelease(int):独占方式。尝试释放资源,成功则返回true,失败则返回false。
- tryAcquireShared(int):共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
- tryReleaseShared(int):共享方式。尝试释放资源,如果释放后允许唤醒后续等待结点返回true,否则返回false。
ReentrantLock为例,(可重入独占式锁):state初始化为0,表示未锁定状态,A线程lock()时,会调用tryAcquire()独占锁并将state+1.之后其他线程再想tryAcquire的时候就会失败,直到A线程unlock()到state=0为止,其他线程才有机会获取该锁。A释放锁之前,自己也是可以重复获取此锁(state累加),这就是可重入的概念。
注意:获取多少次锁就要释放多少次锁,保证state是能回到零态的。以CountDownLatch为例,任务分N个子线程去执行,state就初始化 为N,N个线程并行执行,每个线程执行完之后countDown()一次,state就会CAS减一。当N子线程全部执行完毕,state=0,会unpark()主调用线程,主调用线程就会从await()函数返回,继续之后的动作。
一般来说,自定义同步器要么是独占方法,要么是共享方式,他们也只需实现tryAcquire-tryRelease、tryAcquireShared-tryReleaseShared中的一种即可。但AQS也支持自定义同步器同时实现独占和共享两种方式,如ReentrantReadWriteLock。在acquire() acquireShared()两种方式下,线程在等待队列中都是忽略中断的,acquireInterruptibly()/acquireSharedInterruptibly()是支持响应中断的。
-
AQS的实现及应用
ReentrantLock
可重入锁- 通过为每个锁关联一个请求计数器和获得该锁的线程来实现,当计数器的值为0时表示该锁是未被占用的
- 当线程请求一个未被占用的锁时,计数器的值加1,此时线程就为获取到该锁的状态,再次请求获取此锁时计数器将递增
- 当此线程退出这个同步方法或代码块时,计数器将递减,直到计数器的值变为0时就表示锁已释放,其它线程才能获取该锁
ReentrantReadWriteLock
读写锁- 读写锁内部维护了两个锁,一个用于读操作,一个用于写操作。所有 ReadWriteLock实现都必须保证 writeLock操作的内存同步效果也要保持与相关 readLock的联系。也就是说,成功获取读锁的线程会看到写入锁之前版本所做的所有更新
- 它的自定义同步器(继承AQS)需要在同步状态(一个整型变量state)上维护多个读线程和一个写线程的状态,使得该状态的设计成为读写锁实现的关键。如果在一个整型变量上维护多种状态,就一定需要“按位切割使用”这个变量,读写锁将变量切分成了两个部分,高16位表示读,低16位表示写
CountDownLatch
倒计时计算器- 我们知道join()方法的作用是让一个线程执行完后再执行另一个线程,但你如果进一步了解它的话你就会知道join()方法控制的粒度比较大,它是以整个线程为单位的;也就是说join()只能在某线程执行完后才能执行另一个线程,如果我们有如下场景那join()就不能完成工作了
- 线程A和线程B分别都要执行两个动作,记为A1、A2、B1、B2,我需要在线程A执行完A1后就要执行线程B的B1、B2
- 如果遇到类似的场景就需要使用CountDownLatch来实现了,它是join()方法的扩展,可以更加细粒度的控制线程之间的执行问题;通过countDown()和await()来实现
- 通过构造函数初始化一个内置的计数器,当计数器为0时会唤醒所有调用await()方法而被阻塞的线程
- 一般情况下我们通过完成某个操作后来调用countDown()减少计数器的值,通过await()方法阻塞线程,让其执行完毕后再退出方法
CyclicBarrier
回环栅栏- 可以让所有线程都等待执行完后再继续做后续处理,与CountDownLatch非常类似,不同的是CountDownLatch是一次性的,而CyclicBarrier是可以循环使用的
- 可以说CyclicBarrier是CountDownLatch的扩展,是对CountDownLatch执行能力的扩展,可重复执行
- 底层基于ReentrantLock和Condition实现,定义了parties字段来表示栅栏数量,当parties满足特定条件后则唤醒线程,这是让所有线程都等待执行完后再继续做后续处理的实现原理
- 而重复使用的原理则是通过一个叫Generation的内部类中的broken来标记,每当调用await()方法是栅栏数就会减1,直到数量为0时就会唤醒在此之上的所有线程,并重置broken为false,这样就又可以继续使用栅栏了
Semaphore
信号量- Semaphore和ReentrantLock一样,也是对synchronize的扩展。不同的是ReentrantLock是锁粒度和灵活去的扩展,而Semaphore是对并发数量的扩展,控制线程的并发数量
- 通过在Semaphore初始化的时候传入一个凭证,每当有一个线程获取到锁后就将凭证数量减1,释放锁后凭证数量加1
- 直到凭证数据小于等于0时就不再允许线程获取锁
ArrayBlockingQueue
、LinkedBlockingQueue
、DelayQueue
、CopyOnWriteArrayList
等
12. volatile和Atomic原子类区别
Atomic原子类可以保证原子性,volatile不行
JVM虚拟机
JVM内存模型
1. JVM内存区域
-
线程共享
-
堆
(Heap)存储对象实例和数组
-
方法区
(Method Area)存储类模板、方法信息、常量、静态变量、即时编译器编译后的代码等数据
-
-
线程私有
-
虚拟机栈
(VM Stack)和线程的生命周期相同,一个线程中,每调用一个方法就创建一个栈帧,每一个方法被调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。栈帧结构如下:
-
局部变量表
存放了编译期可知的各种基本数据类型,对象引用(reference 类型)
-
操作数栈
-
动态链接
-
方法出口区
-
-
本地方法栈
(Native Method Stack)执行本地(Native)方法
-
程序计数器
(Program Counter Register)当前线程所执行的字节码的行号指示器,指向当前正在执行的字节码的行号
-
2. JVM堆内存模型
-
区域划分
-
JVM总共分为三大块,新生代(YoungGen)占1/3,老年代(OldGen)占(2/3),及持久代(Perm,在Java8中被取消变成直接内存)
-
新生代分为Eden区、From(S0)区和To(S1)区,占比为8:1:1
-
-
堆运行流程
-
对象优先在Eden区分配,当Eden区没有足够的空间进行分配时,虚拟机将发起一次Minor GC,Eden中存活的对象将被移动到第一块Survivor区S0,Eden被清空。
当Eden区再次填满,再次触发Minor GC,Eden区和S0中的存活对象被复制送入第二块Survivor区S1中,S0和Eden被清空,下一轮交换S0和S1的角色 -
虚拟机给每个对象定义了一个对象年龄计数器,对象每经过一个MinorGC仍然存活,则年龄加一,当对象的复制次数达到-XX:MaxTenuringThreshold设置的值(默认-XX:MaxTenuringThreshold=15)时,将被移至老年代
-
-
对象分配规则
-
对象优先分配在Eden区
如果Eden区没有足够的空间时,虚拟机执行一次Minor GC
-
大对象直接进入老年代
大对象是指需要大量连续内存空间的对象,这样做的目的是避免在Eden区和两个Survivor区之间发生大量的内存拷贝(新生代采用复制算法收集内存)
-
长期存活的对象进入老年代
虚拟机为每个对象定义了一个年龄计数器,如果对象经过了1次Minor GC那么对象会进入Survivor区,之后每经过一次Minor GC那么对象的年龄加1,知道达到阀值对象进入老年区
-
动态判断对象的年龄
如果Survivor区中相同年龄的所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代
-
空间分配担保
当出现大量对象在MinorGC后仍然存活的情况,Survivor区无法容纳多余的对象,此时,需要老年代进行分配担保,把Survivor无法容纳的对象直接进入老年代。另外每次进行Minor GC时,JVM会计算Survivor区移至老年区的对象的平均大小,如果这个值大于老年区的剩余值大小则进行一次Full GC,如果小于检查HandlePromotionFailure设置,如果true则只进行Monitor GC,如果false则进行Full GC
-
3. 内存溢出、内存泄漏
-
内存溢出
(Out Of Memory)指程序申请内存时,没有足够的内存供申请者使用
出现的原因:
- 内存中加载的数据量过于庞大,如一次从数据库取出过多数据
- 代码中存在死循环或循环产生过多重复的对象实体
- 集合类中有对对象的引用,使用完后未清空,使得JVM不能回收(属于内存泄露的情况)
- 启动参数内存值设定的过小
解决方案:
-
修改JVM启动参数,直接增加内存。(-Xms,-Xmx参数一定不要忘记加。)
-
检查错误日志,查看“OutOfMemory”错误前是否有其 它异常或错误。
-
对代码进行走查和分析,找出可能发生内存溢出的位置。
重点排查以下几点:
- 检查对数据库查询中,是否有一次获得全部数据的查询。一般来说,如果一次取十万条记录到内存,就可能引起内存溢出。这个问题比较隐蔽,在上线前,数据库中数据较少,不容易出问题,上线后,数据库中数据多了,一次查询就有可能引起内存溢出。因此对于数据库查询尽量采用分页的方式查询
- 检查代码中是否有死循环或递归调用
- 检查是否有大循环重复产生新对象实体
- 检查List、Map等集合对象是否有使用完后,未清除的问题。List、Map等集合对象会始终存有对对象的引用,使得这些对象不能被GC回收
-
使用内存查看工具动态查看内存使用情况
-
内存泄漏
(Memory Leak)是指程序在申请内存后,无法释放已申请的内存空间,一次内存泄漏似乎不会有大的影响,但内存泄漏堆积后的后果就是内存溢出。泄漏的原因本质上是长期存活对象引用短期存活对象,导致短期存活对象占用的对象无法回收
-
区别和联系:
- 内存泄漏的堆积最终会导致内存溢出
- 内存溢出就是你要的内存空间超过了系统实际分配给你的空间,此时系统相当于没法满足你的需求,就会报内存溢出的错误
4. 栈内存溢出(Stack Over Flow)
栈是线程私有的,生命周期与线程相同,每个方法在执行的时候都会创建一个栈帧,用来存储局部变量表,操作数栈,动态链接,方法出口灯信息。局部变量表又包含基本数据类型,对象引用类型(局部变量表编译器完成,运行期间不会变化),栈溢出就是方法执行是创建的栈帧超过了栈的深度,最有可能的就是方法递归调用产生这种结果
出现的原因:方法递归调用,导致创建的栈帧超过了栈的深度
解决方案:
- 查找可能出现方法递归调用或死循环的地方
- 使用参数 -Xss 去调整JVM栈的大小
GC垃圾回收
1. GC种类
-
Minor GC
(针对年轻代)又称Young GC或新生代GC,指发生在新生代堆的垃圾收集动作
频率比较高,因为大部分对象的存活寿命较短,在新生代里被回收,性能耗费较小
-
Full GC
(针对整个堆)又称Major GC,收集整个堆,包括young gen、old gen、perm gen(如果存在的话)等所有部分的模式,全堆范围的GC
出现Full GC经常会伴随至少一次的Minor GC(不是绝对,Parallel Sacvenge收集器就可以选择设置Major GC策略)
-
Old GC
(针对老年代)只收集old gen的GC。只有CMS的concurrent collection是这个模式
2. 什么时候会触发GC
-
Minor GC的触发时机
Eden区满了的时候
-
Full GC的触发时机
- 调用 System.gc()时,系统建议执行 Full GC,但是不必然执行
- 老年代空间不足
- 方法区空间不足
- 通过 Minor GC后进入老年代的平均大小大于老年代的可用内存
- 由 Eden区、 From Space区向 To Space区复制时,对象大小大于 To Space可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小(其实也符合上面那条)
3. 垃圾回收器的种类
-
Serial收集器
单线程、 复制算法
-
ParNew收集器
Serial+多线程
-
Parallel Scavenge收集器
多线程复制算法、高效
-
Serial Old收集器
单线程标记整理算法
-
Parallel Old收集器
多线程标记整理算法
-
CMS收集器
多线程标记清除算法
Concurrent mark sweep(CMS)收集器是一种年老代垃圾收集器,其最主要目标是获取最短垃圾回收停顿时间, 和其他年老代使用标记-整理算法不同,它使用多线程的标记-清除算法。最短的垃圾收集停顿时间可以为交互比较高的程序提高用户体验。CMS 工作机制相比其他的垃圾收集器来说更复杂。整个过程分为以下 4 个阶段:
- 初始标记
只是标记一下 GC Roots 能直接关联的对象,速度很快,仍然需要暂停所有的工作线程 - 并发标记
进行 GC Roots 跟踪的过程,和用户线程一起工作,不需要暂停工作线程 - 重新标记
为了修正在并发标记期间,因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,仍然需要暂停所有的工作线程 - 并发清除
清除 GC Roots 不可达对象,和用户线程一起工作,不需要暂停工作线程。由于耗时最长的并发标记和并发清除过程中,垃圾收集线程可以和用户现在一起并发工作, 所以总体上来看CMS 收集器的内存回收和用户线程是一起并发地执行
- 初始标记
-
G1收集器
Garbage first 垃圾收集器是目前垃圾收集器理论发展的最前沿成果,相比与 CMS 收集器, G1 收集器两个最突出的改进是:
- 基于标记-整理算法,不产生内存碎片
- 可以非常精确控制停顿时间,在不牺牲吞吐量前提下,实现低停顿垃圾回收。G1 收集器避免全区域垃圾收集,它把堆内存划分为大小固定的几个独立区域,并且跟踪这些区域的垃圾收集进度,同时在后台维护一个优先级列表,每次根据所允许的收集时间, 优先回收垃圾最多的区域。区域划分和优先级区域回收机制,确保 G1 收集器可以在有限时间获得最高的垃圾收集效率
4. 如何判断对象是否存活
引用计数法
- Java中,引用和对象是有关联的。如果要操作对象则必须用引用进行。
因此,很显然一个简单的办法是通过引用计数来判断一个对象是否可以回收。简单说,给对象中添加一个引用计数器,每当有一个地方引用它,计数器值加1,每当有一个引用失效时,计数器值减1 - 任何时刻计数器值为零的对象就是不可能再被使用的,那么这个对象就是可回收对象
- 由于很难解决对象之间相互循环引用的问题,所以JVM实现一般不采取这种方式
- Java中,引用和对象是有关联的。如果要操作对象则必须用引用进行。
可达性算法
(现在基本上都用此算法)- 所谓GC Roots,或者说tracingGC的“根集合”就是一组必须活跃的引用
- 基本思路就是通过一系列名为”GCRoots”的对象作为起始点,从这个被称为GC Roots的对象开始向下搜索,如果一个对象到GCRoots没有任何引用链相连时,则说明此对象不可用。也即给定一个集合的引用作为根出发,通过引用关系遍历对象图,能被遍历到的(可到达的)对象就被判定为存活,没有被遍历到的就自然被判定为死亡
- Java中可以作为GC Roots的对象
- 虚拟机栈(栈帧中的本地变量表)中引用的对象
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈中JNI(即一般说的native方法)中引用的对象
5. GC算法
-
复制
可以解决效率问题,将可用的内存按容量划分为大小相等的两块。
- 每次只使用其中的一块
- 当这一块用完了,就将还存活的对象复制到另一块上
- 然后再把已使用的内存空间清理掉
优点是每次对整个半区进行内存回收,避免内存碎片问题,只需移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。缺点是将内存缩小为原来的一半,代价高;当对象存活率较高时需要进行较多的复制操作,效率降低。主要应用于
新生代
-
标记–清除
分为“标记”和“清除”两个阶段:
- 首先标记出所需要回收的对象(引用计数法和可达性分析,两次标记过程)
- 在标记完成后统一回收所有被标记的对象
缺点:
- 效率问题:标记和清除两个过程的效率不高
- 空间问题:标记清除后会产生大量不连续的内存碎片,导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作
-
标记-整理
- 首先标记处所需要回收的对象
- 不直接对可回收对象进行清理,让所有存活的对象都向一端移动
- 直接清理掉端边界以外的内存
优点是改进了复制算法在对象存活率较高时带来的效率问题。主要应用于
老年代
收集(对象存活率较高) -
分代收集
根据对象存活周期的不同将内存划分为新生代和老年代,根据各自的特点采用合适的收集算法。
- 新生代中,每次垃圾收集时都发现有大批对象死去,少量存活,选用复制算法
- 老年代中,对象存活率高、没有额外空间进行分配担保,使用“标记-清理”或者“标记-整理”
JVM优化
1. JVM虚拟机优化技术
-
逃逸分析
逃逸分析是编译语言中的一种优化分析,而不是一种优化的手段。通过对象的作用范围的分析,为其他优化手段提供分析数据从而进行优化。
逃逸分析包括:- 全局变量赋值逃逸
- 方法返回值逃逸
- 实例引用发生逃逸
- 线程逃逸:赋值给类变量或可以在其他线程中访问的实例变量
-
标量替换
-
标量和聚合量
标量即不可被进一步分解的量,而JAVA的基本数据类型就是标量(如:int,long等基本数据类型以及reference类型等),标量的对立就是可以被进一步分解的量,而这种量称之为聚合量。而在JAVA中对象就是可以被进一步分解的聚合量
-
替换过程
通过逃逸分析确定该对象不会被外部访问,并且对象可以被进一步分解时,JVM不会创建该对象,而会将该对象成员变量分解若干个被这个方法使用的成员变量所代替。这些代替的成员变量在栈帧或寄存器上分配空间
-
-
栈上分配
我们通过JVM内存分配可以知道JAVA中的对象都是在堆上进行分配,当对象没有被引用的时候,需要依靠GC进行回收内存,如果对象数量较多的时候,会给GC带来较大压力,也间接影响了应用的性能。为了减少临时对象在堆内分配的数量,JVM通过逃逸分析确定该对象不会被外部访问。那就通过标量替换将该对象分解在栈上分配内存,这样该对象所占用的内存空间就可以随栈帧出栈而销毁,就减轻了垃圾回收的压力
-
同步消除
同步消除是java虚拟机提供的一种优化技术。通过逃逸分析,可以确定一个对象是否会被其他线程进行访问
如果对象没有出现线程逃逸,那该对象的读写就不会存在资源的竞争,不存在资源的竞争,则可以消除对该对象的同步锁
2. JVM内存调优步骤
-
监控GC的状态
使用各种JVM工具,查看当前日志,分析当前JVM参数设置,并且分析当前堆内存快照和gc日志,根据实际的各区域内存划分和GC执行时间,觉得是否进行优化
-
生成堆的dump文件
- 在应用启动时配置相关的参数 -XX:+HeapDumpOnOutOfMemoryError,当应用抛出OutOfMemoryError时生成dump文件
- 发现程序异常前通过执行指令,直接生成当前JVM的dmp文件: jmap -dump:file=文件名.dump [pid]。此方式在执行时,JVM是暂停服务的,所以对线上的运行会产生影响
- 通过JMX的MBean生成当前的堆(Heap)信息,大小为一个3G(整个堆的大小)的hprof文件
-
分析dump文件,判断是否需要优化
打开.hprof的dump文件,几种工具打开该文件: Visual VM、IBM HeapAnalyzer、JDK 自带的Hprof工具、Mat(Eclipse专门的静态内存分析工具)推荐使用。注:文件太大,建议使用Eclipse专门的静态内存分析工具Mat打开分析。
如果各项参数设置合理,系统没有超时日志出现,GC频率不高,GC耗时不高,那么没有必要进行GC优化,如果GC时间超过1-3秒,或者频繁GC,则必须优化。
注:如果满足下面的指标,则一般不需要进行GC:Minor GC执行时间不到50ms;
Minor GC执行不频繁,约10秒一次;
Full GC执行时间不到1s;
Full GC执行频率不算频繁,不低于10分钟1次; -
调整GC类型和内存分配
如果内存分配过大或过小,或者采用的GC收集器比较慢,则应该优先调整这些参数,并且先找1台或几台机器进行beta,然后比较优化过的机器和没有优化的机器的性能对比,并有针对性的做出最后选择。通过不断的试验和试错,分析并找到最合适的参数,如果找到了最合适的参数,则将这些参数应用到所有服务器
-
不断分析和调整
-
JVM调优参数参考
针对JVM堆的设置,一般可以通过-Xms -Xmx限定其最小、最大值,为了防止垃圾收集器在最小、最大之间收缩堆而产生额外的时间,通常把最大、最小设置为相同的值;
年轻代和年老代将根据默认的比例(1:2)分配堆内存, 可以通过调整二者之间的比率NewRadio来调整二者之间的大小,也可以针对回收代。比如年轻代,通过 -XX:newSize -XX:MaxNewSize来设置其绝对大小。同样,为了防止年轻代的堆收缩,我们通常会把-XX:newSize -XX:MaxNewSize设置为同样大小。 -
年轻代和年老代设置多大才算合理
更大的年轻代必然导致更小的年老代,大的年轻代会延长普通GC的周期,但会增加每次GC的耗时;
小的年老代会导致更频繁的Full GC,更小的年轻代必然导致更大年老代,小的年轻代会导致普通GC很频繁,但每次的GC时间会更短;大的年老代会减少Full GC的频率。
如何选择应该依赖应用程序对象生命周期的分布情况:
如果应用存在大量的临时对象,应该选择更大的年轻代;如果存在相对较多的持久对象,年老代应该适当增大。但很多应用都没有这样明显的特性。
在抉择时应该根据以下两点:
- 本着Full GC尽量少的原则,让年老代尽量缓存常用对象,JVM的默认比例1:2也是这个道理
- 通过观察应用一段时间,看其他在峰值时年老代会占多少内存,在不影响Full GC的前提下,根据实际情况加大年轻代,比如可以把比例控制在1:1。但应该给年老代至少预留1/3的增长空间。
-
在配置较好的机器上(比如多核、大内存),可以为年老代选择并行收集算法: -XX:+UseParallelOldGC 。
-
线程堆栈的设置:每个线程默认会开启1M的堆栈,用于存放栈帧、调用参数、局部变量等,对大多数应用而言这个默认值太了,一般256K就足用。
理论上,在内存不变的情况下,减少每个线程的堆栈,可以产生更多的线程,但这实际上还受限于操作系统。
-
-
对JVM内存的系统级的调优主要的目的是减少GC的频率和Full GC的次数
3. 常用JVM内存分析工具
工具 | 用途 |
---|---|
jps | 列出已装载的JVM |
jstack | 打印线程堆栈信息 |
jstat | JVM监控统计信息 |
jmap | 打印JVM堆内对象情况 |
jinfo | 输出JVM配置信息 |
jconsole | GUI监控工具 |
jvisualvm | GUI监控工具 |
jhat | 堆离线分析工具 |
jdb | java进程调试工具 |
jstatd | 远程JVM监控统计信息 |
MAT | eclipse java内存分析工具 |
4. JVM调优的堆栈参数怎么设置比较合理
Heap size 设置 JVM堆的设置是指java程序运行过程中JVM可以调配使用的内存空间的设置.JVM在启动的时候会自动设置Heap size的值,其初始空间(即-Xms)是物理内存的1/64,最大空间(-Xmx)是物理内存的1/4。可以利用JVM提供的-Xmn -Xms -Xmx等选项可进行设置。Heap size 的大小是Young Generation 和Tenured Generaion 之和。
提示:在JVM中如果98%的时间是用于GC且可用的Heap size 不足2%的时候将抛出此异常信息。
提示:Heap Size 最大不要超过可用物理内存的80%,一般的要将-Xms和-Xmx选项设置为相同,而-Xmn为1/4的-Xmx值。
5. 如何定位Full GC问题
-
使用jps和top确定进程号pid
jps可以列出正在运行的jvm进程,并显示jvm执行主类名称( main()函数所在的类),以及进程id
而top命令查看cpu使用情况,获取对应的进程号pid
比如发现进程号pid为72的进程占用了近100%的cpu
-
使用jstat检查进程号的gc,是否发生FullGC
jstat,就是JVM Statistics Monitoring Tool
下面这个命令的意思是每隔2s显示pid为72的进程的GC情况:
jstat -gcutil 72 2000
-
查看在这个进程中消耗cpu最多的线程
top -H -p 72
可以查看在72这个进程的各个线程
-H表示 Threads-mode operation,线程模式,展示各个线程
-p表示 Monitor-PIDs mode,监控模式,通过进程id监控进程计算线程号的16进制结果:printf %x 79,将线程号80和79分别转换成16进制,将结果4f和50记下来,可以在后面的dump文件搜索
-
jstack分析线程堆栈,并保存结果
根据进程号,输出进程的线程dump文件。
以下命令是将进程号为72的dump文件,输出到 /tmp/dump_file这个路径,也可以是其他任意路径。> 表示将命令执行的结果保存到文件并覆盖原文件的内容。
jstack 72 > /tmp/dump_file
打开dump文件,根据之前printf %x计算得到的16进制结果搜索。
比如printf %x 79 计算得到的结果为4f,可以通过4f进行搜索,也可以用0x4f搜索。(注:如果不想保存文件,也可以直接用命令
jstack -l 72 | grep 0x4f -C 10
jstack -l显示线程堆栈详情,grep匹配关键字0x4f,-C 10表示显示关键字前后10行。)
主要看nid。 nid,意思是 native thread id. 每一个nid对应于linux下的一个tid。
jstack中的nid是十六进制数。
搜索找到 nid=0x4f 的线程,就可以拿到线程的堆栈,找到出问题的代码了 -
jmap分析进程的内存使用情况
jmap,就是Java Memory Map。jmap分析进程72的内存使用情况,并保存dump文件
jmap -histo 72 > pid72.log
以上命令中的 pid72.log是文件名称,也可以改用其他名称。而72是进程号
-
jhat分析jmap生成的堆内存快照
jhat,就是JVM Heap Analysis Tool,虚拟机堆内存快照分析工具,可以用来分析jmap生成的堆内存文件。除了jhat,也可以用专业用于分析dump文件的Memory Analyzer(MAT)等工具
jmap -dump:format=b,file=a.hprof 72 jhat -J-Xmx512M a.hprof
a.hprof是文件名称,也可以改用其他命名
参考:https://www.cnblogs.com/expiator/p/14530928.html
6. 最有可能导致FullGC的场景
-
大对象:系统一次性加载了过多数据到内存中(比如SQL查询未做分页),导致大对象进入了老年代
-
内存泄漏:频繁创建了大量对象,但是无法被回收(比如IO对象使用完后未调用close方法释放资源),先引发FGC,最后导致OOM
-
程序频繁生成一些长生命周期的对象,当这些对象的存活年龄超过分代年龄时便会进入老年代,最后引发FGC
-
程序BUG导致动态生成了很多新类,使得 Metaspace 不断被占用,先引发FGC,最后导致OOM
-
代码中显式调用了gc方法,包括自己的代码甚至框架中的代码
-
JVM参数设置问题:包括总内存大小、新生代和老年代的大小、Eden区和S区的大小、元空间大小、垃圾回收算法等等
类加载
1. JVM类加载机制
JVM 类加载机制分为五个部分:
-
加载
加载是类加载过程中的一个阶段, 这个阶段会在内存中生成一个代表这个类的 java.lang.Class 对象, 作为方法区这个类的各种数据的入口。注意这里不一定非得要从一个 Class 文件获取,这里既可以从 ZIP 包中读取(比如从 jar 包和 war 包中读取),也可以在运行时计算生成(动态代理),也可以由其它文件生成(比如将 JSP 文件转换成对应的 Class 类)
-
验证
这一阶段的主要目的是为了确保 Class 文件的字节流中包含的信息是否符合当前虚拟机的要求,并且不会危害虚拟机自身的安全
-
准备
准备阶段是正式为类变量分配内存并设置类变量的初始值阶段,即在方法区中分配这些变量所使用的内存空间
-
解析
解析阶段是指虚拟机将常量池中的符号引用替换为直接引用的过程
- 符号引用
符号引用与虚拟机实现的布局无关, 引用的目标并不一定要已经加载到内存中。 各种虚拟机实现的内存布局可以各不相同,但是它们能接受的符号引用必须是一致的,因为符号引用的字面量形式明确定义在 Java 虚拟机规范的 Class 文件格式中 - 直接引用
直接引用可以是指向目标的指针,相对偏移量或是一个能间接定位到目标的句柄。如果有了直接引用,那引用的目标必定已经在内存中存在。
- 符号引用
-
初始化
初始化阶段是类加载最后一个阶段,前面的类加载阶段之后,除了在加载阶段可以自定义类加载器以外,其它操作都由 JVM 主导。到了初始阶段,才开始真正执行类中定义的 Java 程序代码。
初始化阶段是执行类构造器方法的过程。方法是由编译器自动收集类中的类变量的赋值操作和静态语句块中的语句合并而成的。虚拟机会保证子方法执行之前,父类的方法已经执行完毕,如果一个类中没有对静态变量赋值也没有静态语句块,那么编译器可以不为这个类生成() 方法。注意以下几种情况不会执行类初始化:- 通过子类引用父类的静态字段,只会触发父类的初始化,而不会触发子类的初始化
- 定义对象数组,不会触发该类的初始化
- 常量在编译期间会存入调用类的常量池中,本质上并没有直接引用定义常量的类,不会触发定义常量所在的类
- 通过类名获取 Class 对象,不会触发类的初始化
- 通过 Class.forName 加载指定类时,如果指定参数 initialize 为 false 时,也不会触发类初始化,其实这个参数是告诉虚拟机,是否要对类进行初始化
- 通过 ClassLoader 默认的 loadClass 方法,也不会触发初始化动作。
2. 类加载器种类
虚拟机设计团队把加载动作放到 JVM 外部实现,以便让应用程序决定如何获取所需的类, JVM 提供了 3 种类加载器:
-
启动类加载器(Bootstrap ClassLoader)
负责加载 JAVA_HOME\lib 目录中的, 或通过-Xbootclasspath 参数指定路径中的, 且被虚拟机认可(按文件名识别, 如 rt.jar) 的类
-
扩展类加载器(Extension ClassLoader)
负责加载 JAVA_HOME\lib\ext 目录中的,或通过 java.ext.dirs 系统变量指定路径中的类库
-
应用程序类加载器(Application ClassLoader)
负责加载用户路径(classpath)上的类库。JVM 通过双亲委派模型进行类的加载, 当然我们也可以通过继承 java.lang.ClassLoader实现自定义的类加载器
因为系统的ClassLoader只会加载指定目录下的class文件,如果你想加载自己的class文件,那么就可以自定义一个ClassLoader。也就是自定义类加载器(User ClassLoader)
3. 双亲委派机制
JVM在加载类时默认采用的是双亲委派机制。通俗的讲,就是某个特定的类加载器在接到加载类的请求时,首先将加载任务委托给父类加载器,依次递归 (本质上就是loadClass函数的递归调用),因此所有的加载请求最终都应该传送到顶层的启动类加载器中。如果父类加载器可以完成这个类加载请求,就成功返回;只有当父类加载器无法完成此加载请求时,子加载器才会尝试自己去加载。事实上,大多数情况下,越基础的类由越上层的加载器进行加载,因为这些基础类之所以称为“基础”,是因为它们总是作为被用户代码调用的API。
采用双亲委派的一个好处是比如加载位于 rt.jar 包中的类 java.lang.Object,不管是哪个加载器加载这个类,最终都是委托给顶层的启动类加载器进行加载,这样就保证了使用不同的类加载器最终得到的都是同样一个 Object 对象
何时需要打破双亲委派机制:
某些情况下父类加载器需要委托子类加载器去加载class文件。受到加载范围的限制,父类加载器无法加载到需要的文件,以Driver接口为例,由于Driver接口定义在jdk当中的,而其实现由各个数据库的服务商来提供,比如mysql的就写了MySQL Connector,那么问题就来了,DriverManager(也由jdk提供)要加载各个实现了Driver接口的实现类,然后进行管理,但是DriverManager由启动类加载器加载,只能记载JAVA_HOME的lib下文件,而其实现是由服务商提供的,由系统类加载器加载,这个时候就需要启动类加载器来委托子类来加载Driver实现,从而破坏了双亲委派
中间件
Redis缓存
1. Redis数据结构及使用场景
Redis自身是一个Map类型的存储方式,其中所有的数据都是采用key:value的形式存储,所以下面讨论的数据类型指的是存储的数据的类型,也就是value部分的类型,key部分永远都是字符串
-
string
字符串string是Redis中最基本的数据类型,存储单个数据,一个key对应一个value
使用场景:
- 缓存: 经典使用场景,把常用信息,字符串,图片或者视频等信息放到redis中,redis作为缓存层,mysql做持久化层,降低mysql的读写压力
- 计数器:访问量统计
- 共享用户Session
-
hash
字典适合存储对象,是类似Map的一种结构,这个一般就是可以将结构化的数据,比如一个对象(前提是这个对象没嵌套其他的对象)给缓存在Redis里,然后每次读写缓存的时候,可以就操作Hash里的某个字段,底层使用哈希表结构实现数据存储。相当于Java中的HashMap
使用场景:
-
存储对象信息
-
电商网站购物车
将用户id作为key,不同的商品id作为filed,购买数量作为filed的value
-
-
list
有序列表存储多个数据,并对数据进入存储空间的顺序进行区分,底层使用双向链表存储结构实现,相当于Java中的LinkedList
使用场景:
- 微信朋友圈点赞,要求按照点赞顺序显示点赞好友信息
- 微博个人的关注列表需要按照用户的关注顺序进行展示
- 消息队列
-
set
无序集合是无序集合,会自动去重。有交集、并集、差集等操作,能够保存大量的数据,高效的内部存储机制,便于查询,尤其随机查询。相当于Java里的HashSet
使用场景:
- 应用于随机推荐类信息检索,例如热点歌单推荐,热点新闻推荐,热点旅游线路,应用APP推荐,大V推荐等
- 全局去重等
-
zset
有序集合是排序的 Set,去重并且可以排序,写进去的时候给一个分数,自动根据分数排序。有序集合可以被用于一些排序场景,相当于在set的存储结构基础上添加了可排序字段。底层采用跳跃表实现
使用场景:
- 排行榜应用
- 延时任务,范围查找
2. Redis线程模型
Redis 内部使用文件事件处理器(file event handler),这个文件事件处理器是单线程的,所以 Redis 才叫做单线程的模型。它采用 IO 多路复用机制同时监听多个 socket,根据 socket 上的事件来选择对应的事件处理器进行处理,文件事件处理器的结构包含 4 个部分:
- 多个 socket
- IO 多路复用程序
- 文件事件分派器
- 事件处理器(包括:连接应答处理器、命令请求处理器、命令回复处理器)
多个 socket 可能会并发产生不同的操作,每个操作对应不同的文件事件,但是 IO 多路复用 程序会监听多个 socket,会将 socket 产生的事件放入队列中排队,事件分派器每次从队列中取出一个事件,把该事件交给对应的事件处理器进行处理
3. Redis单线程为什么快
- 纯内存操作
- 单线程避免了多线程的频繁上下文切换问题,也不用考虑多进程或多线程切换消耗的cpu,不考虑锁的问题
- 核心是基于非阻塞的 IO 多路复用机制
4. Redis事务
Redis会将一个事务中的所有命令序列化,然后按顺序执行。Redis不可能在一个Redis事务的执行过程中插入执行另一个客户端发出的请求。这样便能保证Redis将这些命令作为一个单独的隔离操作执行。 > 在一个Redis事务中,Redis要么执行其中的所有命令,要么什么都不执行。因此,Redis事务能够保证原子性
-
Redis是支持原子性的
虽然Redis提供的不是严格的事务,Redis 只保证串行执行命令,并且能保证全部执行,但是执行命令失败时并不会回滚,而是会继续执行下去
-
Redis事务不支持事务回滚机制
首先,MySQL 和 Redis 的定位不一样,一个是关系型数据库,一个是 NoSQL。MySQL 的 SQL 查询是可以相当复杂的,而且 MySQL 没有事务队列这种说法,SQL 真正开始执行才会进行分析和检查,MySQL 不可能提前知道下一条 SQL 是否正确,所以支持事务回滚是非常有必要的。但是,Redis 使用了事务队列来预先将执行命令存储起来,并且会对其进行格式检查的,提前就知道命令是否可执行了。所以如果只要有一个命令是错误的,那么这个事务是不能执行的。所以Redis事务不支持检查那些程序员自己逻辑错误
5. Redis持久化方案
Redis提供了 RDB 和 AOF 两种持久化方式,RDB 是把内存中的数据集以快照形式写入磁盘,实际操作是通过 fork 子进程执行,采用二进制压缩存储;AOF 是以文本日志的形式记录 Redis 处理的每一个写入或删除操作。
-
RDB 把整个 Redis 的数据保存在单一文件中,比较适合用来做灾备,但缺点是快照保存完成之前如果宕机,这段时间的数据将会丢失,另外保存快照时可能导致服务短时间不可用
-
AOF 对日志文件的写入操作使用的追加模式,有灵活的同步策略,支持每秒同步、每次修改同步和不同步,缺点就是相同规模的数据集,AOF 要大于 RDB,AOF 在运行效率上往往会慢于 RDB
6. Redis缓存淘汰策略
不管是本地缓存还是分布式缓存,为了保证较高性能,都是使用内存来保存数据,由于成本和内存限制,当存储的数据超过缓存容量时,需要对缓存的数据进行剔除。
一般的剔除策略有 FIFO 淘汰最早数据、LRU 剔除最近最少使用、和 LFU 剔除最近使用频率最低的数据几种策略。
- noeviction:返回错误当内存限制达到并且客户端尝试执行会让更多内存被使用的命令(大部分的写入指令,但DEL和几个例外)
- allkeys-lru:尝试回收最少使用的键(LRU),使得新添加的数据有空间存放。
- volatile-lru:尝试回收最少使用的键(LRU),但仅限于在过期集合的键,使得新添加的数据有空间存放。
- allkeys-random: 回收随机的键使得新添加的数据有空间存放。
- volatile-random:回收随机的键使得新添加的数据有空间存放,但仅限于在过期集合的键。
- volatile-ttl:回收在过期集合的键,并且优先回收存活时间(TTL)较短的键,使得新添加的数据有空间存放
7. Redis缓存穿透、缓存击穿、缓存雪崩区别和解决方案
-
缓存穿透
出现的场景:缓存穿透是指缓存和数据库中都没有的数据,而用户不断发起请求,如发起为id为-1的数据或id为特别大不存在的数据。这时的用户很可能是攻击者,攻击会导致数据库压力过大
解决方案:
- 接口层增加校验,如用户鉴权校验,id做基础校验,id<=0的直接拦截
- 从缓存取不到的数据,在数据库中也没有取到,这时也可以将key-value对写为key-null,缓存有效时间可以设置短点,如30秒(设置太长会导致正常情况也没法使用)。这样可以防止攻击用户反复用同一个id暴力攻击
- 布隆过滤器
-
缓存击穿
出现的场景:缓存击穿是指缓存中没有但数据库中有的数据(一般是缓存时间到期),这时由于并发用户特别多,同时读缓存没读到数据,又同时去数据库去取数据,引起数据库压力瞬间增大,造成过大压力
解决方案:
- 热点数据设置二级缓存,并设置不同的失效时间
- 在第一个请求上加互斥锁
- 设置热点数据永远不过期
-
缓存雪崩
出现的场景:缓存雪崩是指缓存中数据大批量到过期时间,而查询数据量巨大,引起数据库压力过大甚至down机。和缓存击穿不同的是, 缓存击穿指并发查同一条数据,缓存雪崩是不同数据都过期了,很多数据都查不到从而查数据库
解决方案:
- 缓存数据的过期时间设置随机,防止同一时间大量数据过期现象发生
- 如果缓存数据库是分布式部署,将热点数据均匀分布在不同的缓存数据库中
- 设置热点数据永远不过期
8. Redis分布式锁
-
setnx
想要实现分布式锁,必须要求 Redis有「互斥」的能力,我们可以使用 SETNX 命令,这个命令表示SET if Not eXists,即如果 key 不存在,才会设置它的值,否则什么也不做
// 客户端 1 申请加锁,加锁成功: 127.0.0.1:6379> SETNX lock 1 (integer) 1 // 客户端1,加锁成功 // 客户端 2 申请加锁,因为后到达,加锁失败: 127.0.0.1:6379> SETNX lock 1 (integer) 0 // 客户端2,加锁失败
此时,加锁成功的客户端,就可以去操作「共享资源」,例如,修改 MySQL 的某一行数据,或者调用一个 API 请求。操作完成后,还要及时释放锁,给后来者让出操作共享资源的机会
但是,它存在一个很大的问题,当客户端 1 拿到锁后,如果发生下面的场景,就会造成「死锁」:
- 程序处理业务逻辑异常,没及时释放锁
- 进程挂了,没机会释放锁
这时,这个客户端就会一直占用这个锁,而其它客户端就「永远」拿不到这把锁了,所以这里还得用expire给锁加一个过期时间防止锁忘记了释放。不过还有可能会释放别的锁的问题,这个可以再使用lua脚本和加锁的uuid解决
SET key_name my_random_value NX PX 30000 // NX 表示if not exist 就设置并返回True,否则不设置并返回False // PX 表示过期时间用毫秒级, 30000 表示这些毫秒时间后此key过期
-
Redisson
框架//获取锁RLock lock = redisson.getLock("myLock"); //上锁lock.lock(); //业务代码//... //释放锁lock.unlock();
代码很简单,而且还支持Redis单实例、Redis哨兵、Redis cluster、Redis master-slave等各种部署架构
9. Redis集群
-
主从模式
主从模式是三种模式中最简单的,在主从复制中,数据库分为两类:主数据库(master)和从数据库(slave)
-
主数据库可以进行读写操作,当读写操作导致数据变化时会自动将数据同步给从数据库
-
从数据库一般都是只读的,并且接收主数据库同步过来的数据
-
一个master可以拥有多个slave,但是一个slave只能对应一个master
-
slave挂了不影响其他slave的读和master的读和写,重新启动后会将数据从master同步过来
-
master挂了以后,不影响slave的读,但redis不再提供写服务,master重启后redis将重新对外提供写服务
-
master挂了以后,不会在slave节点中重新选一个master
工作机制:当slave启动后,主动向master发送SYNC命令。master接收到SYNC命令后在后台保存快照(RDB持久化)和缓存保存快照这段时间的命令,然后将保存的快照文件和缓存的命令发送给slave。slave接收到快照文件和命令后加载快照文件和缓存的执行命令。复制初始化后,master每次接收到的写命令都会同步发送给slave,保证主从数据一致性
-
-
Sentinel 哨兵模式
- sentinel模式是建立在主从模式的基础上,如果只有一个Redis节点,sentinel就没有任何意义
- 当master挂了以后,sentinel会在slave中选择一个做为master,并修改它们的配置文件,其他slave的配置文件也会被修改,比如slaveof属性会指向新的master
- 当master重新启动后,它将不再是master而是做为slave接收新的master的同步数据
- sentinel因为也是一个进程有挂掉的可能,所以sentinel也会启动多个形成一个sentinel集群
- 多sentinel配置的时候,sentinel之间也会自动监控
- 当主从模式配置密码时,sentinel也会同步将配置信息修改到配置文件中,不需要担心
- 一个sentinel或sentinel集群可以管理多个主从Redis,多个sentinel也可以监控同一个redis
- sentinel最好不要和Redis部署在同一台机器,不然Redis的服务器挂了以后,sentinel也挂了
工作机制:
- 每个sentinel以每秒钟一次的频率向它所知的master,slave以及其他sentinel实例发送一个 PING 命令
- 如果一个实例距离最后一次有效回复 PING 命令的时间超过 down-after-milliseconds 选项所指定的值, 则这个实例会被sentinel标记为主观下线。
- 如果一个master被标记为主观下线,则正在监视这个master的所有sentinel要以每秒一次的频率确认master的确进入了主观下线状态
- 当有足够数量的sentinel(大于等于配置文件指定的值)在指定的时间范围内确认master的确进入了主观下线状态, 则master会被标记为客观下线
- 在一般情况下, 每个sentinel会以每 10 秒一次的频率向它已知的所有master,slave发送 INFO 命令
- 当master被sentinel标记为客观下线时,sentinel向下线的master的所有slave发送 INFO 命令的频率会从 10 秒一次改为 1 秒一次
- 若没有足够数量的sentinel同意master已经下线,master的客观下线状态就会被移除。若master重新向sentinel的 PING 命令返回有效回复,master的主观下线状态就会被移除
当使用sentinel模式的时候,客户端就不要直接连接Redis,而是连接sentinel的ip和port,由sentinel来提供具体的可提供服务的Redis实现,这样当master节点挂掉以后,sentinel就会感知并将新的master节点提供给使用者
-
Cluster
sentinel模式基本可以满足一般生产的需求,具备高可用性。但是当数据量过大到一台服务器存放不下的情况时,主从模式或sentinel模式就不能满足需求了,这个时候需要对存储的数据进行分片,将数据存储到多个Redis实例中。cluster模式的出现就是为了解决单机Redis容量有限的问题,将Redis的数据根据一定的规则分配到多台机器
cluster可以说是sentinel和主从模式的结合体,通过cluster可以实现主从和master重选功能,所以如果配置两个副本三个分片的话,就需要六个Redis实例。因为Redis的数据是根据一定规则分配到cluster的不同机器的,当数据量过大时,可以新增机器进行扩容。
使用集群,只需要将redis配置文件中的cluster-enable配置打开即可。每个集群中至少需要三个主数据库才能正常运行,新增节点非常方便。
- 多个redis节点网络互联,数据共享
- 所有的节点都是一主一从(也可以是一主多从),其中从不提供服务,仅作为备用
- 不支持同时处理多个key(如MSET/MGET),因为redis需要把key均匀分布在各个节点上,
并发量很高的情况下同时创建key-value会降低性能并导致不可预测的行为 - 支持在线增加、删除节点
- 客户端可以连接任何一个主节点进行读写
10. Redis同步机制
-
全同步
全同步是指slave启动时进行的初始化同步
- 在slave启动时,会向master发送一条SYNC指令
- master收到这条指令后,会启动一个备份进程将所有数据写到
rdb
文件中去 - 更新master的状态(备份是否成功、备份时间等),然后将rdb文件内容发送给等待中的slave
注意,master并不会立即将rdb内容发送给slave。而是为每个等待中的slave注册写事件,当slave对应的socket可以发送数据时,再将rdb内容发送给slave
-
部分同步
部分同步是指Redis运行过程中的修改同步。当Redis的master/slave服务启动后,首先进行全同步。之后,所有的写操作都在master上,而所有的读操作都在slave上。因此写操作需要及时同步到所有的slave上,这种同步就是部分同步
- master收到一个操作,然后判断是否需要同步到salve
- 如果需要同步,则将操作记录到
aof
文件中 - 遍历所有的salve,将操作的指令和参数写入到savle的恢复缓存中
- 一旦slave对应的socket发送缓存中有空间写入数据,即将数据通过socket发出去
11. Redis双重校验锁
先看如果没有双重校验锁以下代码的缺陷:
@Service
public class ItemServiceImpl implements IItemService {
@Resource
private ItemMapper itemMapper;
@Resource
private RedisTemplate redisTemplate;
@Override
public Item getItem(Long id) {
ValueOperations cacheOps = redisTemplate.opsForValue();
Item item = (Item) cacheOps.get(id);
if (item == null) {
Item itemFromDb = itemMapper.selectById(id);
item = itemFromDb;
}
return item;
}
}
当并发量比较大时候,有可能会出现多个线程同时调用getItem,如果缓存中此时还没有数据,查询就有可能全部落到数据库。这个漏洞的发生概率也会由于具体的代码时间复杂度的增大而变高,这是由于getTbItem方法中的if代码块仍然是非原子(nonatomic)的“先检查再执行”操作,先检查再执行的执行过程耗时越久,重复计算的概率越高。可以用双重检查锁来优化
@Service
public class ItemServiceImpl implements IItemService {
@Resource
private ItemMapper itemMapper;
@Resource
private RedisTemplate redisTemplate;
private final Object lock = new Object();
@Override
public Item getItem(Long id) {
ValueOperations cacheOps = redisTemplate.opsForValue();
Item item = (Item) cacheOps.get(id);
if (item == null) {
synchronized (lock) {
if (item == null) {
Item itemFromDb = itemMapper.selectById(id);
cacheOps.set(id, itemFromDb);
item = itemFromDb;
}
}
}
return item;
}
}
12. Redis与数据库双写一致性
- 先淘汰缓存
- 再写数据库
- 休眠1秒,再次淘汰缓存
13. Redis如果有大量的key需要设置同一时间过期,一般需要注意什么
如果大量的key过期时间设置的过于集中,到过期的那个时间点,Redis可能会出现短暂的卡顿现象。严重的话会出现缓存雪崩,我们一般需要在时间上加一个随机值,使得过期时间分散一些
MQ消息队列
1. MQ是如何保证可靠消息的(举例RabbitMQ)
RabbitMQ消息的大致流程过程:
Producer(生产者生产消息) -> Broker(存储消息) -> Consumer(消费消息)
-
Producer如何保证消息不丢失
- 可以选择使用rabbitmq提供是事务功能,就是生产者在发送数据之前开启事务,然后发送消息,如果消息没有成功被rabbitmq接收到,那么生产者会受到异常报错,这时就可以回滚事务,然后尝试重新发送;如果收到了消息,那么就可以提交事务。缺点是事务一开启,就会变为同步阻塞操作,生产者会阻塞等待是否发送成功,太耗性能会造成吞吐量的下降
- 可以开启confirm模式。在生产者设置开启了confirm模式之后,每次写的消息都会分配一个唯一的id,然后如果写入了rabbitmq之中,rabbitmq会给你回传一个ack消息,告诉你这个消息发送OK了;如果rabbitmq没能处理这个消息,会回调你一个nack接口,告诉你这个消息失败了,你可以进行重试。而且你可以结合这个机制知道自己在内存里维护每个消息的id,如果超过一定时间还没接收到这个消息的回调,那么你可以进行重发
由于提交了一个事务之后会阻塞住,而confirm机制是异步的,发送消息之后可以接着发送下一个消息,然后rabbitmq会回调告知成功与否。所以一般在生产者这块避免丢失,都是用confirm机制
-
Broker如何保证消息不丢失
设置消息持久化到磁盘。设置持久化有两个步骤:
- 创建queue的时候将其设置为持久化的,这样就可以保证rabbitmq持久化queue的元数据,但是不会持久化queue里面的数据
- 发送消息的时候讲消息的deliveryMode设置为2,这样消息就会被设为持久化方式,此时rabbitmq就会将消息持久化到磁盘上,必须要同时开启这两个才可以。
而且持久化可以跟生产的confirm机制配合起来,只有消息持久化到了磁盘之后,才会通知生产者ack,这样就算是在持久化之前rabbitmq挂了,数据丢了,生产者收不到ack回调也会进行消息重发
-
Consumer如何保证消息不丢失
使用rabbitmq提供的ack机制,首先关闭rabbitmq的自动ack,然后每次在确保处理完这个消息之后,在代码里手动调用ack。这样就可以避免消息还没有处理完就ack
2. MQ如何保证消息的顺序性(举例RabbitMQ)
- 拆分多个queue,每个queue一个consumer,就是多一些queue而已,确实是麻烦点;这样也会造成吞吐量下降,可以在消费者内部采用多线程的方式取消费
- 或者就一个queue但是对应一个consumer,然后这个consumer内部用内存队列做排队,然后分发给底层不同的worker来处理
3. 如何保证MQ消费者消息消费的幂等性
基本上还是得结合业务来思考,由业务代码来保证。比如需要让生产者发送每条数据的时候,里面加一个全局唯一的 id,类似订单 id 之类的东西,然后你这里消费到了之后,先根据这个 id 去比如 Redis 里查一下,之前消费过吗?如果没有消费过,你就处理,然后这个 id 写 Redis。如果消费过了,那你就别处理了,保证别重复处理相同的消息即可
4. 消息积压在消息队列里怎么办
这种时候只能操作临时扩容,以更快的速度去消费数据了。具体操作步骤和思路如下:
- 先修复consumer的问题,确保其恢复消费速度,然后将现有consumer都停掉
- 临时建立好原先10倍或者20倍的queue数量(新建一个topic,partition是原来的10倍)
- 然后写一个临时分发消息的consumer程序,这个程序部署上去消费积压的消息,消费之后不做耗时处理,直接均匀轮询写入临时建好分10数量的queue里面
- 紧接着征用10倍的机器来部署consumer,每一批consumer消费一个临时queue的消息
- 这种做法相当于临时将queue资源和consumer资源扩大10倍,以正常速度的10倍来消费消息
- 等快速消费完了之后,恢复原来的部署架构,重新用原来的consumer机器来消费消息
5. 延时队列
-
应用场景
什么是延时队列?顾名思义:首先它要具有队列的特性,再给它附加一个延迟消费队列消息的功能,也就是说可以指定队列中的消息在哪个时间点被消费。
延时队列在项目中的应用还是比较多的,尤其像电商类平台:
- 订单成功后,在30分钟内没有支付,自动取消订单
- 外卖平台发送订餐通知,下单成功后60s给用户推送短信
- 如果订单一直处于某一个未完结状态时,及时处理关单,并退还库存
- 淘宝新建商户一个月内还没上传商品信息,将冻结商铺等
-
实现
-
DelayQueue 延时队列
JDK
中提供了一组实现延迟队列的API,位于Java.util.concurrent
包下DelayQueue
DelayQueue
是一个BlockingQueue
(无界阻塞)队列,它本质就是封装了一个PriorityQueue
(优先队列),PriorityQueue
内部使用完全二叉堆
(不知道的自行了解哈)来实现队列元素排序,我们在向DelayQueue
队列中添加元素时,会给元素一个Delay
(延迟时间)作为排序条件,队列中最小的元素会优先放在队首。队列中的元素只有到了Delay
时间才允许从队列中取出。队列中可以放基本数据类型或自定义实体类,在存放基本数据类型时,优先队列中元素默认升序排列,自定义实体类就需要我们根据类属性值比较计算了 -
Redis sorted set
利用 Redis 的 sorted set 结构,使用 timeStamp 作为 score,比如你的任务是要延迟5分钟,那么就在当前时间上加5分钟作为 score ,轮询任务每秒只轮询 score 大于当前时间的 key即可,如果任务支持有误差,那么当没有扫描到有效数据的时候可以休眠对应时间再继续轮询
-
RabbitMQ队列
RabbitMQ 有两个特性,一个是 Time-To-Live Extensions,另一个是 Dead Letter Exchanges。
-
Time-To-Live Extensions
RabbitMQ允许我们为消息或者队列设置TTL(time to live),也就是过期时间。TTL表明了一条消息可在队列中存活的最大时间,单位为毫秒。也就是说,当某条消息被设置了TTL或者当某条消息进入了设置了TTL的队列时,这条消息会在经过TTL秒后 “死亡”,成为Dead Letter。如果既配置了消息的TTL,又配置了队列的TTL,那么较小的那个值会被取用。
-
Dead Letter Exchanges
在 RabbitMQ 中,一共有三种消息的 “死亡” 形式:
- 消息被拒绝。通过调用
basic.reject
或者basic.nack
并且设置的requeue
参数为 false; - 消息因为设置了TTL而过期;
- 队列达到最大长度。
- 消息被拒绝。通过调用
DLX同一般的 Exchange 没有区别,它能在任何的队列上被指定,实际上就是设置某个队列的属性。当队列中有 DLX 消息时,RabbitMQ就会自动的将 DLX 消息重新发布到设置的 Exchange 中去,进而被路由到另一个队列,publish 可以监听这个队列中消息做相应的处理。
-
-
ElasticSearch搜索引擎
1. 为什么要使用ElasticSearch
因为在我们商城中的数据,将来会非常多,所以采用以往的模糊查询,模糊查询前置配置,会放弃索引,导致商品查询是全表扫面,在百万级别的数据库中,效率非常低下,而我们使用ES做一个全文索引,我们将经常查询的商品的某些字段,比如说商品名,描述、价格还有id这些字段我们放入我们索引库里,可以提高查询速度
2. ES使用场景
- ElasticSearch+HBase的模式,商品的主要字段存在ElasticSearch中,比如商品名称、品牌、价格等,然后再根据这些信息去HBase中查出其它非关键字段信息
- 数据预热热点数据预处理,先查看搜索比较多的商品,轮询刷到FileSystem Cache中去。针对热点数据做一个缓存预热
- 冷热分离,将大量的访问很少、频率很低的数据,单独写一个索引,然后将访问很频繁的热数据单独写一个索引
3. ES分片机制、master选举机制
分布式、微服务
微服务架构
1. 说一说你对微服务的理解
微服务架构(Microservice Architecture)是一种架构概念,微服务架构主要作用是将功能分解到离散的各个服务当中,从而降低系统的耦合性
,并提供更加灵活的服务支持。相对于单体应用(所谓单体应用是指所有的模块、业务都包含在一个应用中)而言,微服务把各个模块拆分成不同的项目,每个模块都只关注一个特定的业务功能,发布时每一个项目都是一个独立的包,运行在独立的进程上。它解决了单体应用造成的一些问题。
**概念:**把一个大型的单个应用程序和服务拆分为数个甚至数十个的支持微服务,它可扩展单个组件而不是整个的应用程序堆栈,从而满足服务等级协议。增强可用性、服务易扩展、减少开发成本、减少服务发布对整个平台的影响
定义:围绕业务领域组件来创建应用,这些应用可独立地进行开发、管理和迭代。在分散的组件中使用云架构和平台式部署、管理和服务功能,使产品交付变得更加简单。实现有很多方式,企业转由单个系统转向微服务就要考虑很多问题,比如技术选型、业务拆分问题、高可用、服务通信、服务发现和治理、集群容错、配置管理、数据一致性问题
本质:用一些功能比较明确、业务比较精练的服务去解决更大、更实际的问题
优点:
- 易于开发,可维护性高: 一个服务只会关注一个特定的业务模块,代码比较少,可维护性就高
- 服务之间可以独立部署:发布风险低,发布单个服务不需要重新发布整个应用
- 易于扩展:每个服务可以各自进行负载均衡扩展和数据库扩展,而且每个服务可以根据自己的需要部署到合适的硬件服务器上
- 提高容错性:经过负载的服务有更好的容错率,挂了一台实例不会导致整个系统瘫痪
- 技术栈不受限(异构): 微服务之间通过轻量级的通信机制进行通信,比如RESTful API,因此不同项目可以随意选择合适的技术来实现
缺点:
-
运维要求高: 对于单体应用只要部署一个服务,微服务化后可能需要部署几十几百个服务
-
分布式固有的复杂性: 开发人员需要考虑分布式事务、系统的容错性等,比如服务A的某个接口依赖服务B,通过RESTful API调用服务B的接口但发生了错误,需要提供重试等机制
-
修改接口成本增加: 修改接口本就是一件繁琐的事,微服务化后成本更高,因为各个服务之间只通过轻量级通信机制访问,耦合度比较低,需要排查哪些服务的接口受到了影响,而在单体应用中通常只是方法间的依赖,如果修改了某个方法的签名,那么在编译时就会报错
-
重复劳动: 当一个单体应用微服务化后,不同的服务之间会有重复代码产生,比如Gradle脚本、Maven依赖都非常类似,使用到的某些函数每个服务都造了一遍等,如果都是用同一种语言实现的还能用共享库解决,如果有多种语言那就难以避免重复劳动了。还有各种实体类可能会有重复
2. 微服务和模块划分原则
-
微服务设计四个原则
- AKF拆分原则
- 前后端分离
- 无状态服务
- Restful通信风格
-
微服务设计目标
- 架构必须稳定
- 服务必须高内聚,服务应该实现一小组强相关的功能
- 服务必须符合开闭原则,将一同变更的内容打包在一起,以确保每个更改仅影响一个服务
- 服务必须松耦合,每个服务都可以在不影响客户端的情况下更改实现
-
微服务划分方法
-
纵向拆分
从
业务维度
进行拆分。标准是按照业务的关联程度来决定,关联比较密切的业务适合拆分为一个微服务,而功能相对比较独立的业务适合单独拆分为一个微服务 -
横向拆分
从
公共且独立功能
维度拆分。标准是按照是否有公共的被多个其他服务调用,且依赖的资源独立不与其他业务耦合
-
SpringCloud
1. 常用组件
- 服务注册与发现:Eureka、Nacos
- 配置中心:Config、Apollo、Nacos
- 网关:Zuul、Config
- 服务调用与负载均衡:Feign、Ribbon
- 断路器:Hystrix、Sentinel
- 链路追踪:Sleuth、Zipkin
2. Eureka原理和工作机制
-
概念
Eureka负责管理、记录服务提供者的信息,服务调用者把自己的需求告诉Eureka,然后Eureka会把符合你需求的服务告诉你,它实现了服务的自动注册、发现、状态监控。
简单来说,就是服务提供者将服务放到Eureka里,Eureka再把相应的服务(也就是服务调用者需要的服务)给服务调用者Eureka:服务注册中心(可以是一个集群),对外暴露自己的地址
提供者:启动后向Eureka注册自己信息(地址,提供什么服务)
消费者:向Eureka订阅服务,Eureka会将对应服务的所有提供者地址列表发送给消费者,并且定期更新
心跳(续约):提供者定期通过http方式向Eureka刷新自己的状态 -
工作流程
服务启动后向Eureka注册,Eureka Server会将注册信息向其他Eureka Server进行同步,当服务消费者要调用服务提供者,则向服务注册中心获取服务提供者地址,然后会将服务提供者地址缓存在本地,下次再调用时,则直接从本地缓存中取,完成一次调用
-
服务提供者
服务提供者要向EurekaServer注册服务,并且完成服务续约等工作。
-
服务注册
服务提供者在启动时,会检测配置属性中的eureka.client.register-with-erueka,若它为ture,代表将自己的信息注册到EurekaServer。
则会向EurekaServer发起一个Rest请求,并携带自己的元数据信息,Eureka Server会把这些信息保存到一个双层Map结构中。第一层Map的Key就是服务名称,第二层Map的key是服务的实例id -
服务续约(心跳机制)
在注册服务完成以后,服务提供者会维持一个心跳(定时向EurekaServer发起Rest请求),告诉EurekaServer:“我还活着”。这个我们称为服务的续约(renew),client 心跳线程http调用server的renew,增加心跳次数,修改注册表中对应InstanceInfo的最近心跳时间
-
-
服务消费者
当服务消费者启动时,会检测eureka.client.fetch-registry参数的值,如果为true,代表拉取其它服务的信息,则会从Eureka Server服务的列表只读备份,然后缓存在本地。默认是每隔30秒会重新获取并更新数据。也可以自己设置时间
-
失效剔除
有些时候,我们的服务提供方并不一定会正常下线,可能因为内存溢出、网络故障等原因导致服务无法正常工作。Eureka Server需要将这样的服务剔除出服务列表。因此它会开启一个定时任务,每隔60秒对所有失效的服务(超过90秒未响应)进行剔除。
可以通过eureka.server.eviction-interval-timer-in-ms参数对其进行修改,单位是毫秒
-
自我保护机制
当一个服务未按时进行心跳续约时,Eureka会统计最近15分钟心跳失败的服务实例的比例是否超过了85%。在生产环境下,因为网络延迟等原因,心跳失败实例的比例很有可能超标,但是此时就把服务剔除列表并不妥当,因为服务可能没有宕机。Eureka就会把当前实例的注册信息保护起来,不予剔除。生产环境下这很有效,保证了大多数服务依然可用。
自我保护模式可以自己设置,可设为关闭eureka: server: enable-self-preservation: false # 关闭自我保护模式(缺省为打开) eviction-interval-timer-in-ms: 1000 # 扫描失效服务的间隔时间(缺省为60*1000ms)
-
多级缓存机制
在eureka client拉取注册表的时候,就会用到所谓的多级缓存机制,多级缓存机制中有两个缓存,一个叫只读缓存
ReadOnlyCacheMap
,一个叫读写缓存ReadWriteCacheMap
。eureka client拉取注册表的时候,会先从ReadOnlyCacheMap中去获取注册表数据,如果获取不到的话再去ReadWriteCacheMap中找,如果还是找不到的话,那就只能重新从注册表中registry
拉取了ReadOnlyCacheMap就是一个普通的ConcurrentHashMap,而ReadWriteCacheMap是guava cache,如果ReadWriteCacheMap读不到数据,就会通过ClassLoader的load方法直接从注册表获取数据再返回
多级缓存机制有多种过期策略:
- 主动过期:当服务实例发生注册、下线、故障的时候,ReadWriteCacheMap中所有的缓存过期掉
- 定时过期:readWriteCacheMap在构建的时候,指定了一个自动过期的时间,默认值就是180秒,所以你往readWriteCacheMap中放入一个数据,180秒过后,就将这个数据给他过期了
- 被动过期:默认是每隔30秒,执行一个定时调度的线程任务,对readOnlyCacheMap和readWriteCacheMap中的数据进行一个比对,如果两块数据是不一致的,那么就将readWriteCacheMap中的数据放到readOnlyCacheMap中来
通过过期的机制,可以发现一个问题,就是如果ReadWriteCacheMap发生了主动过期或定时过期,此时里面的缓存就被清空或部分被过期了,但是在此之前readOnlyCacheMap刚执行了被动过期,发现两个缓存是一致的,就会接着使用里面的缓存数据
所以可能会存在30秒的时间,readOnlyCacheMap和ReadWriteCacheMap的数据不一致
-
-
Eureka和zookeeper的区别
CAP理论指出,一个分布式系统不可能同时满足C(一致性)、A(可用性)和P(分区容错性)。由于分区容错性P在是分布式系统中必须要保证的,因此我们只能在A和C之间进行权衡。
Zookeeper保证CP
Zookeeper 为主从结构,有leader节点和follow节点。当leader节点down掉之后,剩余节点会重新进行选举。选举过程中会导致服务不可用,丢掉了可用行Eureka保证AP
Eureka各个节点都是平等的,几个节点挂掉不会影响正常节点的工作,剩余的节点依然可以提供注册和查询服务。而Eureka的客户端在向某个Eureka注册或时如果发现连接失败,则会自动切换至其它节点,只要有一台Eureka还在,就能保证注册服务可用(保证可用性),只不过查到的信息可能不是最新的(不保证强一致性)
3. 网关Gateway的工作原理
Spring Cloud Gateway是Spring官方基于Spring5.0,Spring Boot2.0和Project Reactor等技术开发的网关,Spring Cloud Gateway旨在为微服务架构提供简单,有效且统一的API路由管理方式。Spring Cloud Gateway作为Spring Cloud 生态系统中的网关,目标是替代Netflix Zuul,其不仅提供统一的路由方式,并且还基于Filter链的方式提供了网关基本的功能,例如:安全,监控、埋点,限流等。相比阻塞I/O的zuul,gateway使用了webflux中的reactor-netty响应式编程组件,底层使用了netty通讯框架
Spring Cloud Gateway 的核心处理流程:
- Gateway的客户端回向Spring Cloud Gateway发起请求,请求首先会被HttpWebHandlerAdapter进行提取组装成网关的上下文,然后网关的上下文会传递到DispatcherHandler
- DispatcherHandler是所有请求的分发处理器,DispatcherHandler主要负责分发请求对应的处理器,比如将请求分发到对应RoutePredicateHandlerMapping(路由断言处理器映射器)
- 路由断言处理映射器主要用于路由的查找,以及找到路由后返回对应的FilteringWebHandler
- FilteringWebHandler主要负责组装Filter链表并调用Filter执行一系列Filter处理,然后把请求转到后端对应的代理服务处理,处理完毕后,将Response返回到Gateway客户端。
在Filter链中,过滤器可以在转发请求之前处理或者接收到被代理服务的返回结果之后处理。所有的Pre类型的Filter执行完毕之后,才会转发请求到被代理的服务处理。被代理的服务把所有请求完毕之后,才会执行Post类型的过滤器
4. Feign工作原理
Feign是一个声明式的伪Http客户端,它使得写Http客户端变得更简单。 使用Feign,只需要创建一个接口并注解。
Feign是一个HTTP请求调用的轻量级框架,可以以JAVA接口注解的方式调用HTTP请求,而不用像Java中通过封装HTTP请求报文的方式直接调用。Feign通过处理注解,将请求模板化,当实际调用的时候,传入参数,根据参数再应用到请求上,进而转化成真正的请求。
它具有可插拔的注解特性,可使用Feign 注解和JAX-RS注解。Feign支持可插拔的编码器和解码器。
Feign默认集成了Ribbon
- 封装了HTTP调用流程,更适合面向接口化开发
- 代码少,使用简单
- 几乎完全可以从服务提供方的Controller中依靠复制操作,来构建出相应的服务接口客户端,或是通过Swagger生成的API文档来编写出客户端,亦或是通过Swagger的代码生成器来生成客户端绑定(复制粘贴党的福音)
5. Ribbon工作原理
-
概述:
SpringCloud Ribbon是一个基于HTTP和TCP的客户端的负载均衡工具,Ribbon和微服务是同级别的,融合到微服务的一些基础设施(如Feign),不需要独立部署。
Ribbon会将微服务之间的Rest请求转为客户端的负载均衡的RPC调用
Ribbon默认的负载均衡策略是轮询,但不止轮询一种,可以自定义配置
-
工作流程
-
微服务之间通过Feign调用,最后通过LoadBalancerFeignClient发送请求
-
LoadBalancerFeignClient端从client端服务的上下文环境中找到负载均衡器,并把提取到的服务名称交给负载均衡器
-
负载均衡器提到选到server实例,将client端的请求包装成调用请求LoadBalancerCommand
-
根据封装的信息,发送远程调用到具体的服务实例
-
和Feign的集成模式:
在使用Feign作为客户端时,最终请求会转发成 http://<服务名称>/
的格式,通过LoadBalancerFeignClient, 提取出服务标识<服务名称>,然后根据服务名称在上下文中查找对应服务的负载均衡器FeignLoadBalancer,负载均衡器负责根据既有的服务实例的统计信息,挑选出最合适的服务实例
-
6. Hystrix的工作原理
-
容错限流的需求
在复杂的分布式系统中通常有很多依赖,如果一个应用不能对来自依赖故障进行隔离,那么应用本身就处于被拖垮的风险中。在一个高流量的网站中,某一个单一后端一旦发生延迟,将会在数秒内导致所有的应用资源被耗尽,这也就是我们常说的雪崩效应。
比如在电商系统的下单业务中,在订单服务创建订单后同步调用库存服务进行库存的扣减,假如库存服务出现了故障,那么会导致下单请求线程会被阻塞,当有大量的下单请求时,则会占满应用连接数从而导致订单服务无法对外提供服务。
-
容错限流的原理
对于基本的容错限流模式,主要有以下几点需要考量:
- 主动超时:在调用依赖时尽快的超时,可以设置比较短的超时时间,比如2s,防止长时间的等待
- 限流:限制最大并发数
- 熔断:错误数达到阈值时,类似于保险丝熔断
- 隔离:隔离不同的依赖调用
- 服务降级:资源不足时进行服务降级
-
容错模式
-
断路器模式
实现流程为:当断路器的开关为关闭时,每次请求进来都是成功的,当后端服务出现问题,请求出现的错误数达到一定的阈值,则会触发断路器为打开状态;在断路器为打开状态时,进来的所有请求都会被拒绝,当然也不是一直会拒绝请求,而是弹性的,过了特定的时间后,断路器会进入半打开状态,这是会让一部分请求通过进行尝试,如果尝试还是有问题,则继续进入打开状态,如果尝试没有问题了,则会进入关闭状态
-
舱壁隔离模式
舱壁隔离模式可以对资源进行隔离,类似于船的船舱都是被隔离开来的,当其中一个或者几个船舱出现问题,比如漏水,是不会影响到其他的船舱的,从而实现一种资源隔离的效果
-
-
容错理念
- 凡是依赖都有可能会失败
- 凡是资源都有限制,比如CPU、Memory、Threads、Queue
- 网络并不可靠,可能存在网络抖动等其他问题
- 延迟是应用稳定的杀手,延迟会占据大量的资源
-
Hystrix工作流程
-
对于一次依赖调用,会被封装在一个HystrixCommand对象中,调用的执行有两种方式,一种是调用execute()方法同步调用,另一种是调用queue()方法进行异步调用。
-
执行时会判断断路器开关是否打开,如果断路器打开,则进入getFallback()降级逻辑;如果断路器关闭,则判断线程池/信号量资源是否已满,如果资源满了,则进入getFallback()降级逻辑;如果没满,则执行run()方法。再判断执行run()方法是否超时,超时则进入getFallback()降级逻辑,run()方法执行失败,则进入getFallback()降级逻辑,执行成功则报告Metrics。Metrics中的数据包括执行成功、超时、失败等情况的数据,Hystrix会计算一个断路器的健康值,也就是失败率,当失败率超过阈值后则会触发断路器开关打开。
-
getFallback()逻辑为:如果没有实现fallback()方法,则直接抛出异常,另外fallback降级也是需要资源的,在fallback时需要获取一个针对fallback的信号量,只有获取成功才能fallback,获取信号量失败,则抛出异常,获取信号量成功,才会执行fallback方法并且会响应fallback方法中的内容
-
7. 对系统服务进行限流有哪些方式
-
熔断
系统在设计之初就把熔断措施考虑进去。当系统出现问题时,如果短时间内无法修复,系统要自动做出判断,开启熔断开关,拒绝流量访问,避免大流量对后端的过载请求。
系统也应该能够动态监测后端程序的修复情况,当程序已恢复稳定时,可以关闭熔断开关,恢复正常服务。常见的熔断组件有Hystrix以及阿里的Sentinel,两种互有优缺点,可以根据业务的实际情况进行选择
-
服务降级
将系统的所有功能服务进行一个分级,当系统出现问题需要紧急限流时,可将不是那么重要的功能进行降级处理,停止服务,这样可以释放出更多的资源供给核心功能的去用。
例如在电商平台中,如果突发流量激增,可临时将商品评论、积分等非核心功能进行降级,停止这些服务,释放出机器和CPU等资源来保障用户正常下单,而这些降级的功能服务可以等整个系统恢复正常后,再来启动,进行补单/补偿处理。除了功能降级以外,还可以采用不直接操作数据库,而全部读缓存、写缓存的方式作为临时降级方案
-
延迟处理
这个模式需要在系统的前端设置一个流量缓冲池,将所有的请求全部缓冲进这个池子,不立即处理。然后后端真正的业务处理程序从这个池子中取出请求依次处理,常见的可以用队列模式来实现。这就相当于用异步的方式去减少了后端的处理压力,但是当流量较大时,后端的处理能力有限,缓冲池里的请求可能处理不及时,会有一定程度延迟,也可以直接使用缓存数据。后面具体的漏桶算法以及令牌桶算法就是这个思路
-
特权处理
这个模式需要将用户进行分类,通过预设的分类,让系统优先处理需要高保障的用户群体,其它用户群的请求就会延迟处理或者直接不处理
-
缓存、降级、限流区别
-
缓存,是用来增加系统吞吐量,提升访问速度提供高并发
-
降级,是在系统某些服务组件不可用的时候、流量暴增、资源耗尽等情况下,暂时屏蔽掉出问题的服务,继续提供降级服务,给用户尽可能的友好提示,返回兜底数据,不会影响整体业务流程,待问题解决再重新上线服务
-
限流,是指在使用缓存和降级无效的场景。比如当达到阈值后限制接口调用频率,访问次数,库存个数等,在出现服务不可用之前,提前把服务降级。只服务好一部分用户
-
-
并发限流
简单来说就是设置系统阈值总的QPS个数,这些也挺常见的,就拿Tomcat来说,很多参数就是出于这个考虑,例如
配置的
acceptCount
设置响应连接数,maxConnections
设置瞬时最大连接数,maxThreads
设置最大线程数,在各个框架或者组件中,并发限流体现在下面几个方面:- 限制总并发数(如数据库连接池、线程池)
- 限制瞬时并发数(nginx的limit_conn模块,用来限制瞬时并发连接数)
- 限制时间窗口内的平均速率(如Guava的RateLimiter、nginx的limit_req模块,限制每秒的平均速率)
- 其他的还有限制远程接口调用速率、限制MQ的消费速率。
- 另外还可以根据网络连接数、网络流量、CPU或内存负载等来限流。
有了并发限流,就意味着在处理高并发的时候多了一种保护机制,不用担心瞬间流量导致系统挂掉或雪崩,最终做到有损服务而不是不服务;但是限流需要评估好,不能乱用,否则一些正常流量出现一些奇怪的问题而导致用户体验很差造成用户流失
-
接口限流
接口限流分为两个部分,一是限制一段时间内接口调用次数,二是设置滑动时间窗口算法
限流的算法:
-
计数器算法
简单粗暴,比如指定线程池大小,指定数据库连接池大小、nginx连接数等,这都属于计数器算法。
计数器算法是限流算法里最简单也是最容易实现的一种算法。举个例子,比如我们规定对于A接口,我们1分钟的访问次数不能超过100个。那么我们可以这么做:在一开 始的时候,我们可以设置一个计数器counter,每当一个请求过来的时候,counter就加1,如果counter的值大于100并且该请求与第一个请求的间隔时间还在1分钟之内,那么说明请求数过多拒绝访问;如果该请求与第一个请求的间隔时间大于1分钟,且counter的值还在限流范围内,那么就重置 counter,就是这么简单粗暴
-
漏桶算法
漏桶算法思路很简单,水(请求)先进入到漏桶里,漏桶以一定的速度出水,当水流入速度过大会超过桶可接纳的容量时直接溢出,可以看出漏桶算法能强行限制数据的传输速率
削峰:有大量流量进入时,会发生溢出,从而限流保护服务可用
缓冲:不至于直接请求到服务器,缓冲压力 消费速度固定 因为计算性能固定
-
令牌桶算法
令牌桶与漏桶相似,不同的是令牌桶桶中放了一些令牌,服务请求到达后,要获取令牌之后才会得到服务
-
8. 如何使用Zuul对某一个IP进行限流
在项目中,大部分都会使用到hyrtrix做熔断机制,通过某个预定的阈值来对异常流量进行降级处理,除了做服务降级以外,还可以对服务进行限流,分流,排队等。
当然,zuul也能做到限流策略,最简单的方式就是使用自定义的filter加上限流算法,生产环境中zuul网关肯定是部署的多节点,所以还会借助类似Redis的K/V存储工具。
它提供了多种细粒度策略:
- user:认证用户名或者匿名,针对某个用户进行限流。
- origin:客户机IP,针对请求的客户机IP进行限流。
- url:针对某个特定的url进行限流。
- serviceId:针对某个服务进行限流。
多粒度临时变量存储方式:
- IN_MEMEORY:基于本地内存,底层是ConcurrentHashMap。
- REDIS:基于Redis的K/V存储。
- CONSUL:基于consul的K/V存储。
- JPA:基于数据库。
- BUKET4J:Java编写的基于令牌桶算法的限流库,它有4种模式,JCache、Hazelcast、Apache Ignite、Inifinispan,后面3种支持异步。
配置文件参考:
zuul:
routes:
client-a:
path: /client/**
serviceId: client-a
ratelimit:
#key-prefix: springcloud-book #按粒度拆分的临时变量key前缀
enabled: true #启用开关
repository: IN_MEMORY #key存储类型,默认是IN_MEMORY本地内存,此外还有多种形式
behind-proxy: true #表示代理之后
default-policy: #全局限流策略,可单独细化到服务粒度
limit: 2 #在一个单位时间窗口的请求数量
quota: 1 #在一个单位时间窗口的请求时间限制(秒)
refresh-interval: 3 #单位时间窗口(秒)
type:
- user #可指定用户粒度
- origin #可指定客户端地址粒度
- url #可指定url粒度
上面的配置是说,3秒中内不能有超过2次的接口调用,只需在zuul工程中加入pom依赖,修改配置文件,即可实现效果。
分布式事务
1. 分布式事务解决方案
-
两阶段提交(2PC)
-
运行过程
-
协调者询问参与者事务是否执行成功,参与者发回事务执行结果。
-
如果事务在每个参与者上都执行成功,事务协调者发送通知让参与者提交事务;否则,协调者发送通知让参与者回滚事务。
需要注意的是,在准备阶段,参与者执行了事务,但是还未提交。只有在提交阶段接收到协调者发来的通知后,才进行提交或者回滚。
-
-
存在的问题
- 同步阻塞 所有事务参与者在等待其它参与者响应的时候都处于同步阻塞状态,无法进行其它操作。
- 单点问题 协调者在 2PC 中起到非常大的作用,发生故障将会造成很大影响。特别是在阶段二发生故障,所有参与者会一直等待状态,无法完成其它操作。
- 数据不一致 在阶段二,如果协调者只发送了部分 Commit 消息,此时网络发生异常,那么只有部分参与者接收到 Commit 消息,也就是说只有部分参与者提交了事务,使得系统数据不一致。
- 太过保守 任意一个节点失败就会导致整个事务失败,没有完善的容错机制。
-
-
补偿事务(TCC)
- 实现:一个完整的业务活动由一个主业务服务于若干的从业务服务组成。主业务服务负责发起并完成整个业务活动。从业务服务提供TCC型业务操作。业务活动管理器控制业务活动的一致性,它登记业务活动的操作,并在业务活动提交时确认所有的TCC型操作的Confirm操作,在业务活动取消时调用所有TCC型操作的Cancel操作。
- 成本:实现TCC操作的成本较高,业务活动结束的时候Confirm和Cancel操作的执行成本。业务活动的日志成本。
- 使用范围:强隔离性,严格一致性要求的业务活动。适用于执行时间较短的业务,比如处理账户或者收费等等。
- 特点:不与具体的服务框架耦合,位于业务服务层,而不是资源层,可以灵活的选择业务资源的锁定粒度。TCC里对每个服务资源操作的是本地事务,数据被锁住的时间短,可扩展性好,可以说是为独立部署的SOA服务而设计的。
-
基于可靠消息的最终一致性
- 实现:业务处理服务在业务事务提交之前,向实时消息服务请求发送消息,实时消息服务只记录消息数据,而不是真正的发送。业务处理服务在业务事务提交之后,向实时消息服务确认发送。只有在得到确认发送指令后,实时消息服务才会真正发送。
- 消息:业务处理服务在业务事务回滚后,向实时消息服务取消发送。消息发送状态确认系统定期找到未确认发送或者回滚发送的消息,向业务处理服务询问消息状态,业务处理服务根据消息ID或者消息内容确认该消息是否有效。被动方的处理结果不会影响主动方的处理结果,被动方的消息处理操作是幂等操作。
- 成本:可靠的消息系统建设成本,一次消息发送需要两次请求,业务处理服务需要实现消息状态回查接口。
- 优点:消息数据独立存储,独立伸缩,降低业务系统和消息系统之间的耦合。对最终一致性时间敏感度较高,降低业务被动方的实现成本。兼容所有实现JMS标准的MQ中间件,确保业务数据可靠的前提下,实现业务的最终一致性,理想状态下是准实时的一致性。
-
最大努力通知型
- 实现:业务活动的主动方在完成处理之后向业务活动的被动方发送消息,允许消息丢失。业务活动的被动方根据定时策略,向业务活动的主动方查询,恢复丢失的业务消息。
- 约束:被动方的处理结果不影响主动方的处理结果。
- 成本:业务查询与校对系统的建设成本。
- 使用范围:对业务最终一致性的时间敏感度低。跨企业的业务活动。
- 特点:业务活动的主动方在完成业务处理之后,向业务活动的被动方发送通知消息。主动方可以设置时间阶梯通知规则,在通知失败后按规则重复通知,知道通知N次后不再通知。主动方提供校对查询接口给被动方按需校对查询,用户恢复丢失的业务消息。
- 适用范围:银行通知,商户通知。
持续集成DepOvs
你们项目docker是什么打成镜像并上云的
你们项目中的部署打包方式
其它架构设计思想
1. 领域驱动设计DDD
-
领域: 一个组织所做的事情以及包含的一切,通俗就是组织的业务范围和做事的方式——软件开发的目标范围。
领域模型的对象则包含了对象的数据和计算逻辑。从面向对象的角度,领域模型才是真正的面向对象。
领域驱动设计就是从领域出发,分析领域内的模型及关系,进而设计软件系统的方法。
DDD 战略设计与战术设计。领域模型合并了行为和数据的领域的对象模型。通过领域模型的交互完成业务逻辑的实现。设计好了领域模型的对象,设计好了业务逻辑实现。
-
子域:一个组织内所做的事情及包含的一切。通常做法将领域拆分成多个子域
-
限界上下文: 限界上下文和子域有一对一的关系,用于控制子域的边界。对应一个组件或者一个模块,或者一个微服务。
DDD 使用上下文映射图设计子系统或者模块之间的各种交互合作。通过业务分析,识别出实体对象,通过相关业务逻辑设计出实体的方法和属性。把握实体的职责。
领域模型的对象成为实体,实体设计是 DDD 的核心所在。分析一定要放在业务场景和界限上下文中。
领域、子域、界限上下文、上下文映射图,都是 DDD 的战略设计。划分模块和服务的边界和依赖关系。
实体、值对象、聚合、CQRS、事件溯源:DDD 战术设计
2. 中台架构
在传统单体或 SOA 架构下,应用如果频繁升级更新,开发团队非常痛苦。企业的业务应用经过多年 IT 建设,系统非常庞大,要改动其中任何一小部分,都需要重新部署整个应用,敏捷开发和快速交付无从谈起。
中台架构主要集中在业务共享服务层,业务共享服务团队,有独立的团队来做,也更利于业务的沉淀,降低研发成本,提高研发效率,打破了产品壁垒。将业务、数据抽象和沉淀形成服务能力,对前台提供调用。
建设中台:涉及领域建模;分析关键场景等能力的抽象。
业务中台化:对业务的深入理解,将基础逻辑处理出来。每个业务领域的边界、每个领域提供的基础服务;领域和领域服务间的流程标准通过业务中台化,将原来产品内部的服务提升到产品间的能力共享。
IO流、网络通信
HTTP相关
1. 正向代理和反向代理
- 一个位于客户端和原始服务器(origin server)之间的服务器,为了从原始服务器取得内容,客户端向代理发送一个请求并指定目标(原始服务器),然后代理向原始服务器转交请求并将获得的内容返回给客户端。例如翻墙软件,我们的客户端在进行翻墙操作的时候,我们使用的正是正向代理,通过正向代理的方式,在我们的客户端运行一个软件,将我们的HTTP请求转发到其他不同的服务器端,实现请求的分发
- 反向代理(Reverse Proxy)方式,是指以代理服务器来接受 Internet上的连接请求,然后将请求,发给内部网络上的服务器并将从服务器上得到的结果返回给 Internet 上请求连接的客户端,此时代理服务器对外就表现为一个反向代理服务器。Nginx就是一个反向代理服务器软件
2. Nginx处理Http请求的流程
- Nginx 在启动时,会解析配置文件,得到需要监听的端口与 IP 地址,然后在 Nginx 的 Master 进程里面先初始化好这个监控的Socket(创建 Socket,设置 addr、reuse 等选项,绑定到指定的 ip 地址端口,再 listen 监听)
- 然后再 fork(一个现有进程可以调用 fork 函数创建一个新进程。由 fork 创建的新进程被称为子进程 )出多个子进程出来
- 之后子进程会竞争 accept 新的连接。此时,客户端就可以向 nginx 发起连接了。当客户端与nginx进行三次握手,与 nginx 建立好一个连接后。此时,某一个子进程会 accept 成功,得到这个建立好的连接的 Socket ,然后创建 nginx 对连接的封装,即 ngx_connection_t 结构体
- 接着设置读写事件处理函数,并添加读写事件来与客户端进行数据的交换
- 最后,Nginx 或客户端来主动关掉连接,到此,一个连接就结束了
3. Nginx是如何实现高并发的
-
如果一个 server 采用一个进程(或者线程)负责一个request的方式,那么进程数就是并发数。那么显而易见的,就是会有很多进程在等待中。等什么?最多的应该是等待网络传输。
-
而 Nginx 的异步非阻塞工作方式正是利用了这点等待的时间。在需要等待的时候,这些进程就空闲出来待命了。因此表现为少数几个进程就解决了大量的并发问题。
-
Nginx是如何利用的呢,简单来说:同样的 4 个进程,如果采用一个进程负责一个 request 的方式,那么,同时进来 4 个 request 之后,每个进程就负责其中一个,直至会话关闭。期间,如果有第 5 个request进来了。就无法及时反应了,因为 4 个进程都没干完活呢,因此,一般有个调度进程,每当新进来了一个 request ,就新开个进程来处理。
-
Nginx 不这样,每进来一个 request ,会有一个 worker 进程去处理。但不是全程的处理,处理到什么程度呢?处理到可能发生阻塞的地方,比如向上游(后端)服务器转发 request ,并等待请求返回。那么,这个处理的 worker 不会这么傻等着,他会在发送完请求后,注册一个事件:“如果 upstream 返回了,告诉我一声,我再接着干”。于是他就休息去了。此时,如果再有 request 进来,他就可以很快再按这种方式处理。而一旦上游服务器返回了,就会触发这个事件,worker 才会来接手,这个 request 才会接着往下走。
-
由于 web server 的工作性质决定了每个 request 的大部份生命都是在网络传输中,实际上花费在 server 机器上的时间片不多。这是几个进程就解决高并发的秘密所在。即:webserver 刚好属于网络 IO 密集型应用,不算是计算密集型。
总结:异步,非阻塞,使用 epoll ,和大量细节处的优化
4. Nginx的负载均衡策略
-
轮询
(默认)round_robin每个请求按时间顺序逐一分配到不同的后端服务器,如果后端服务器 down 掉,能自动剔除
-
权重方式
weight在轮询策略的基础上指定轮询的几率,权重越高分配到需要处理的请求越多
-
IP 哈希
ip_hash每个请求按访问 ip 的 hash 结果分配,这样每个访客固定访问一个后端服务器,可以解决 session 共享的问题。当然,实际场景下,一般不考虑使用 ip_hash 解决 session 共享
-
Nginx中的ip_hash技术能够将某个ip 的请求定向到同一台后端web机器中,这样一来这个ip 下的客户端和某个后端,web机器就能建立起稳固的session
-
p_hash机制能够让某一客户机在相当长的一段时间内只访问固定的后端的某台真实的web服务器,这样会话就会得以保持,在网站页面进行login的时候就不会在后面的web服务器之间跳来跳去了,也不会出现登录一次的网站又提醒重新登录的情况
-
Ip_hash机制缺陷:
-
nginx不是最前端的服务器
-
ip_hash要求nginx一定是最前端的服务器,否则nginx得不到正确ip,就不能根据ip作hash。比如使用的是squid(也是一种代理服务器)作为最前端。那么nginx取ip时只能得到squid的服务器ip地址,用这个地址来作分流肯定是错乱的
-
nginx的后端还有其它负载均衡
假如nginx后端还有其它负载均衡,将请求又通过另外的方式分流了,那么某个客户端的请求肯定不能定位到同一台session应用服务器上,这么算起来,nginx后端只能直接指向应用服务器,或者再搭一人squid,然后指向应用服务器. 最好 的办法是用location作一次分流,将需要session的部分请求通过ip_hash分流,剩下的走其它后端去
-
-
-
最少连接
least_conn下一个请求将被分派到活动连接数量最少的服务器
-
第三方插件提供的策略
响应时间 fair 和依据URL分配 url_hash 等
https为什么比http安全
https不会被抓包吗?
一万个http请求就会发起一万个tcp请求吗
如果Http client一下接收到了10万个请求,并且它能抗的住这么多并发,会发生什么?http client可以设置长连接、短连接那些
TCP相关
1. TCP/IP四层模型与OSI七层模型的对应关系
OSI七层网络模型 | TCP/IP四层概念模型 | 对应网络协议 | 功能 |
---|---|---|---|
应用层 | 应用层 | HTTP、FTP、TFTP、SMTP、Telnet、DNS | 直接向用户提供服务,文件传输、电子邮件、文件服务、虚拟终端 |
表示层 | 应用层 | 无 | 数据格式化、代码转换、数据加密 |
会话层 | 应用层 | 无 | 在两个会话实体间建立和使用连接、解除连接 |
传输层 | 传输层 | TCP、UDP | 提供可靠的端到端的差错和流量控制,保证报文的正确传输 |
网络层 | 网络层 | IP、ICMP、ARP、RARP | 通过路由选择算法,为报文或分组通过通信子网选择最适当的路径 |
数据链路层 | 网络接口层 | FDDI、Ethernet、Arpanet、PDN、SLIP、PPP | 传输有地址的帧,错误检测功能 |
物理层 | 网络接口层 | IEEE 802.1A、IEEE 802.2 ~ IEEE 802.11 | 以二进制数据形式在物理媒体上传输数据 |
2. TCP和UDP的区别
TCP的优点: 可靠,稳定 TCP的可靠体现在TCP在传递数据之前,会有三次握手来建立连接,而且在数据传递时,有确认、窗口、重传、拥塞控制机制,在数据传完后,还会断开连接用来节约系统资源。 TCP的缺点: 慢,效率低,占用系统资源高,易被攻击 TCP在传递数据之前,要先建连接,这会消耗时间,而且在数据传递时,确认机制、重传机制、拥塞控制机制等都会消耗大量的时间,而且要在每台设备上维护所有的传输连接,事实上,每个连接都会占用系统的CPU、内存等硬件资源。 而且,因为TCP有确认机制、三次握手机制,这些也导致TCP容易被人利用,实现DOS、DDOS、CC等攻击。
UDP的优点: 快,比TCP稍安全 UDP没有TCP的握手、确认、窗口、重传、拥塞控制等机制,UDP是一个无状态的传输协议,所以它在传递数据时非常快。没有TCP的这些机制,UDP较TCP被攻击者利用的漏洞就要少一些。但UDP也是无法避免攻击的,比如:UDP Flood攻击…… UDP的缺点: 不可靠,不稳定 因为UDP没有TCP那些可靠的机制,在数据传递时,如果网络质量不好,就会很容易丢包。 基于上面的优缺点,那么: 什么时候应该使用TCP: 当对网络通讯质量有要求的时候,比如:整个数据要准确无误的传递给对方,这往往用于一些要求可靠的应用,比如HTTP、HTTPS、FTP等传输文件的协议,POP、SMTP等邮件传输的协议。 在日常生活中,常见使用TCP协议的应用如下: 浏览器,用的HTTP FlashFXP,用的FTP Outlook,用的POP、SMTP Putty,用的Telnet、SSH QQ文件传输 ………… 什么时候应该使用UDP: 当对网络通讯质量要求不高的时候,要求网络通讯速度能尽量的快,这时就可以使用UDP。 比如,日常生活中,常见使用UDP协议的应用如下: QQ语音 QQ视频 TFTP ……
有些应用场景对可靠性要求不高会用到UPD,比如长视频,要求速率
总结:
-
TCP面向连接(如打电话要先拨号建立连接);UDP是无连接的,即发送数据之前不需要建立连接
-
TCP提供可靠的服务。也就是说,通过TCP连接传送的数据,无差错,不丢失,不重复,且按序到达;UDP尽最大努力交付,即不保证可靠交付
-
TCP面向字节流,实际上是TCP把数据看成一连串无结构的字节流;UDP是面向报文的
UDP没有拥塞控制,因此网络出现拥塞不会使源主机的发送速率降低(对实时应用很有用,如IP电话,实时视频会议等)
-
每一条TCP连接只能是点到点的;UDP支持一对一,一对多,多对一和多对多的交互通信
-
TCP首部开销20字节;UDP的首部开销小,只有8个字节
-
TCP的逻辑通信信道是全双工的可靠信道,UDP则是不可靠信道
3. TCP三次握手
TCP 提供面向有连接的通信传输。面向有连接是指在数据通信开始之前先做好两端之间的准备工作。所谓三次握手是指建立一个 TCP 连接时需要客户端和服务器端总共发送三个包以确认连接的建立。在socket编程中,这一过程由客户端执行connect来触发
-
第一次握手
客户端将标志位SYN置为1,随机产生一个值seq=J,并将该数据包发送给服务器端,客户端进入SYN_SENT状态,等待服务器端确认(
第一次握手确保了客户端的发送功能
) -
第二次握手
服务器端收到数据包后由标志位SYN=1知道客户端请求建立连接,服务器端将标志位SYN和ACK都置为1,ack=J+1,随机产生一个值seq=K,并将该数据包发送给客户端以确认连接请求,服务器端进入SYN_RCVD状态(第二次握手确保了客户端的发送功能是正常的,服务端的接收功能是正常的)
-
第三次握手
客户端收到确认后,检查ack是否为J+1,ACK是否为1,如果正确则将标志位ACK置为1,ack=K+1,并将该数据包发送给服务器端,服务器端检查ack是否为K+1,ACK是否为1,如果正确则连接建立成功,客户端和服务器端进入ESTABLISHED状态(第三次握手确保了服务端的发送功能是正常的,客户端接收功能是正常的)
完成三次握手,随后客户端与服务器端之间可以开始传输数据了
4. TCP四次挥手
四次挥手即终止TCP连接,就是指断开一个TCP连接时,需要客户端和服务端总共发送4个包以确认连接的断开。在socket编程中,这一过程由客户端或服务端任一方执行close来触发。
由于TCP连接是全双工的,因此,每个方向都必须要单独进行关闭,这一原则是当一方完成数据发送任务后,发送一个FIN来终止这一方向的连接,收到一个FIN只是意味着这一方向上没有数据流动了,即不会再收到数据了,但是在这个TCP连接上仍然能够发送数据,直到这一方向也发送了FIN。首先进行关闭的一方将执行主动关闭,而另一方则执行被动关闭。
中断连接端可以是客户端,也可以是服务器端
-
第一次挥手
客户端发送一个FIN=M,用来关闭客户端到服务器端的数据传送,客户端进入FIN_WAIT_1状态。意思是说"我客户端没有数据要发给你了",但是如果你服务器端还有数据没有发送完成,则不必急着关闭连接,可以继续发送数据
-
第二次挥手
服务器端收到FIN后,先发送ack=M+1,告诉客户端,你的请求我收到了,但是我还没准备好,请继续你等我的消息。这个时候客户端就进入FIN_WAIT_2 状态,继续等待服务器端的FIN报文
-
第三次挥手
当服务器端确定数据已发送完成,则向客户端发送FIN=N报文,告诉客户端,好了,我这边数据发完了,准备好关闭连接了。服务器端进入LAST_ACK状态
-
第四次挥手
客户端收到FIN=N报文后,就知道可以关闭连接了,但是他还是不相信网络,怕服务器端不知道要关闭,所以发送ack=N+1后进入TIME_WAIT状态,如果Server端没有收到ACK则可以重传。服务器端收到ACK后,就知道可以断开连接了。客户端等待了2MSL后依然没有收到回复,则证明服务器端已正常关闭,那好,我客户端也可以关闭连接了。最终完成了四次握手
上面是一方主动关闭,另一方被动关闭的情况,实际中还会出现同时发起主动关闭的情况
5. TCP是如何保证可靠消息的
-
通过
序列号
与确认应答
提高可靠性- 在 TCP 中,当发送端的数据到达接收主机时,接收端主机会返回一个已收到消息的通知。这个消息叫做确认应答(ACK)。当发送端将数据发出之后会等待对端的确认应答。如果有确认应答,说明数据已经成功到达对端。反之,则数据丢失的可能性很大
- 在一定时间内没有等待到确认应答,发送端就可以认为数据已经丢失,并进行重发。由此,即使产生了丢包,仍然能够保证数据能够到达对端,实现可靠传输
- 未收到确认应答并不意味着数据一定丢失。也有可能是数据对方已经收到,只是返回的确认应答在途中丢失。这种情况也会导致发送端误以为数据没有到达目的地而重发数据
- 此外,也有可能因为一些其他原因导致确认应答延迟到达,在源主机重发数据以后才到达的情况也屡见不鲜。此时,源主机只要按照机制重发数据即可
- 对于目标主机来说,反复收到相同的数据是不可取的。为了对上层应用提供可靠的传输,目标主机必须放弃重复的数据包。为此我们引入了序列号
- 序列号是按照顺序给发送数据的每一个字节(8位字节)都标上号码的编号。接收端查询接收数据 TCP 首部中的序列号和数据的长度,将自己下一步应该接收的序列号作为确认应答返送回去。通过序列号和确认应答号,TCP 能够识别是否已经接收数据,又能够判断是否需要接收,从而实现可靠传输
-
超时重传
- 超时重传是指发送出去的数据包到接收到确认包之间的时间,如果超过了这个时间会被认为是丢包了,需要重传
- 那么我们该如何确认这个时间值呢?我们知道,一来一回的时间总是差不多的,都会有一个类似于平均值的概念。比如发送一个包到接收端收到这个包一共是0.5s,然后接收端回发一个确认包给发送端也要0.5s,这样的两个时间就是RTT(往返时间)。然后可能由于网络原因的问题,时间会有偏差,称为抖动(方差)。从上面的介绍来看,超时重传的时间大概是比往返时间+抖动值还要稍大的时间
- 但是在重发的过程中,假如一个包经过多次的重发也没有收到对端的确认包,那么就会认为接收端异常,强制关闭连接。并且通知应用通信异常强行终止
-
最大消息长度
在建立TCP连接的时候,双方约定一个最大的长度(MSS)作为发送的单位,重传的时候也是以这个单位来进行重传。理想的情况下是该长度的数据刚好不被网络层分块
-
滑动窗口控制
-
上面提到的超时重传的机制存在效率低下的问题,发送一个包到发送下一个包要经过一段时间才可以。所以我们就想着能不能不用等待确认包就发送下一个数据包呢?这就提出了一个滑动窗口的概念
-
窗口的大小就是在无需等待确认包的情况下,发送端还能发送的最大数据量。这个机制的实现就是使用了大量的缓冲区,通过对多个段进行确认应答的功能。通过下一次的确认包可以判断接收端是否已经接收到了数据,如果已经接收了就从缓冲区里面删除数据
-
在窗口之外的数据就是还未发送的和对端已经收到的数据。那么发送端是怎么样判断接收端有没有接收到数据呢?或者怎么知道需要重发的数据有哪些呢
-
接收端在没有收到自己所期望的序列号数据之前,会对之前的数据进行重复确认。发送端在收到某个应答包之后,又连续3次收到同样的应答包,则数据已经丢失了,需要重发
-
-
拥塞控制
窗口控制解决了 两台主机之间因传送速率而可能引起的丢包问题,在一方面保证了TCP数据传送的可靠性。然而如果网络非常拥堵,此时再发送数据就会加重网络负担,那么发送的数据段很可能超过了最大生存时间也没有到达接收方,就会产生丢包问题。为此TCP引入慢启动机制,先发出少量数据,就像探路一样,先摸清当前的网络拥堵状态后,再决定按照多大的速度传送数据。
此处引入一个拥塞窗口:
发送开始时定义拥塞窗口大小为1;每次收到一个ACK应答,拥塞窗口加1;而在每次发送数据时,发送窗口取拥塞窗口与接送段接收窗口最小者。
慢启动:在启动初期以指数增长方式增长;设置一个慢启动的阈值,当以指数增长达到阈值时就停止指数增长,按照线性增长方式增加至拥塞窗口;线性增长达到网络拥塞时立即把拥塞窗口置回1,进行新一轮的“慢启动”,同时新一轮的阈值变为原来的一半
上面的滑动窗口控制和拥塞控制看不懂,请看下面的例子(来自知乎的大神<安静的木小昊>,原文地址:https://www.zhihu.com/question/32255109/answer/495373328)
-- 有这么个场景,老师说一段话,学生来记
老师说"从前有个人, 她叫马冬梅. 她喜欢他, 而他却喜欢她."
学生写道"从前有..". "老师你说的太快我跟不上"
-- 于是他们换了一种模式.
老师说"从"
学生写"从". 学生说"嗯"
老师说"前"
学生写"前". 学生说"嗯"
老师说"今天我还想早点下班呢..."
-- 于是他们换了一种模式.
老师说"从前有个人"
学生写"从前有个人". 学生说"嗯"
老师说"她叫马冬梅".
学生写"她叫马...梅". 学生说"马什么梅?"
老师说"她叫马冬梅".
学生写"她叫马冬...". 学生说"马冬什么?"
老师"....."
学生说"有的时候状态好我能把5个字都记下来, 有的时候状态不好就记不下来. 我状态不好的时候你能不能慢一点. "
-- 于是他们换了一种模式
老师说"从前有个人"
学生写"从前有个人". 学生说"嗯, 再来5个"
老师说"她叫马冬梅"
学生写"她叫马..梅". 学生说"啥?重来, 来2个"
老师说"她叫"
学生写"她叫". 学生说"嗯,再来3个"
老师说"马冬梅".
学生写"马冬梅". 学生说"嗯, 给我来10个"
老师说"她喜欢他,而他却喜欢她"
学生写...
所以呢
第一种模式简单粗暴,发的只管发,收的更不上
第二种模式稳定却低效,每发一个,必须等到确认才再次发送,等待时间很多
第三种模式提高了效率,分组进行发送,但是分组的大小该怎么决定呢?
第四中模式才是起到了流控的作用,接收方认为状态好的时候,让发送方每次多发一点。接收方认为状态不好的时候(阻塞),让发送方每次少发送一点
常用的抓包工具用过吗
NIO相关
算法、数据结构
1. 冒泡排序
依次比较相邻的两个数,将小的数放在前面,大的数放在后面。相当于每一轮都找出一个最大的,然后抛开最大的,继续找第二大的直到最后一个元素。所以需要2轮循环,外层循环元素个数,内层循环每次减一
2. 快速排序
3. 插入排序
4. 堆排序
二叉树的前结构顺序?以及如何遍历
堆排序的复杂度
你们的接口是如何做数据加密的,用的是对称加密吗?非对称加密算法了解吗?
MD5是加密算法吗?
AES加密算法是非对称的吗?
应该是信息摘要算法
一致性hash算法
快速排序思路
如何判断一个链表中是否有环
b+树和二叉树的区别
平衡二叉树了解过吗
树的深度太深怎么办
10亿个数如何去重
10亿个数如何找出最大的10个数
设计模式
1. Spring框架中用到了哪些设计模式
- 工厂设计模式:Spring使用工厂模式通过BeanFactory和ApplicationContext创建bean对象。
- 代理设计模式:Spring AOP功能的实现。
- 单例设计模式:Spring中的bean默认都是单例的。
- 模板方法模式:Spring中的jdbcTemplate、hibernateTemplate等以Template结尾的对数据库操作的类,它们就使用到了模板模式。
- 包装器设计模式:我们的项目需要连接多个数据库,而且不同的客户在每次访问中根据需要会去访问不同的数据库。这种模式让我们可以根据客户的需求能够动态切换不同的数据源。
- 观察者模式:Spring事件驱动模型就是观察者模式很经典的一个应用。
- 适配器模式:Spring AOP的增强或通知(Advice)使用到了适配器模式、Spring MVC中也是用到了适配器模式适配Controller。
2. 常用设计模式
- 单例模式:懒汉式、饿汉式、双重校验锁、静态加载,内部类加载、枚举类加载。保证一个类仅有一个实例,并提供一个访问它的全局访问点。
- 代理模式:动态代理和静态代理,什么时候使用动态代理。
- 适配器模式:将一个类的接口转换成客户希望的另外一个接口。适配器模式使得原本由于接口不兼容而不能一起工作的那些类可以一起工作。
- 装饰者模式:动态给类加功能。
- 观察者模式:有时被称作发布/订阅模式,观察者模式定义了一种一对多的依赖关系,让多个观察者对象同时监听某一个主题对象。这个主题对象在状态发生变化时,会通知所有观察者对象,使它们能够自动更新自己。
- 策略模式:定义一系列的算法,把它们一个个封装起来, 并且使它们可相互替换。
- 外观模式:为子系统中的一组接口提供一个一致的界面,外观模式定义了一个高层接口,这个接口使得这一子系统更加容易使用。
- 命令模式:将一个请求封装成一个对象,从而使您可以用不同的请求对客户进行参数化。
- 创建者模式:将一个复杂的构建与其表示相分离,使得同样的构建过程可以创建不同的表示。
- 抽象工厂模式:提供一个创建一系列相关或相互依赖对象的接口,而无需指定它们具体的类。