Java基础
一、 SpringBoot
1. Spring Boot 的核心注解是哪个?它主要由哪几个注解组成的?
启动类上面的注解是@SpringBootApplication,它也是 Spring Boot 的核心注解,主要组合包含了以下 3 个注解:
- @SpringBootConfiguration:组合了 @Configuration 注解,实现配置文件的功能。
- @EnableAutoConfiguration:打开自动配置的功能,也可以关闭某个自动配置的选项, 例如:java 如关闭数据源自动配置功能: @SpringBootApplication(exclude = { DataSourceAutoConfiguration.class })。
- @ComponentScan:Spring组件扫描。
2. SpringMVC和SpringBoot的区别?
- SpringMVC是基于servlet的web应用程序,有一套视图-模型-控制器架构来简化web应用程序的开发,而且springmvc的很多配置需要手动配置,同时springmvc需要部署在外部服务器,springmvc需要手动管理依赖
- SpringBoot可以自动配置,快速开发,自动部署,同时内嵌web服务器,而且可以用starter自动管理依赖
3. SpringBoot的启动流程 引用
①. java程序由启动主类调用main()方法开始。
②. 调用 SpringApplication的构造方法,实例一个Spirng应用对象。在构造方法里主要完成启动环境初始化工作,如,推断主类,spring应用类型,加载配置文件,读取spring.factories文件等。
③. 调用run方法,所有的启动工作在该方法内完成,主要完成加载配置资源,准备上下文,创建上下文,刷新上下文,过程事件发布等。
二、Spring
1. Spring IOC和AOP
①. IOC:IOC/DI就是控制翻转或是依赖注入。通俗的讲就是如果在什么地方需要一个对象,你自己不用去通过new 生成你需要的对象,而是通过spring的bean工厂为你产生这样一个对象。
spring的IOC有三种注入方式 :
1. 第一种是根据属性注入 也叫set方法注入;
2. 第二种是根据构造方法进行注入;
3. 第三种是使用注解注入;
- Resource资源信息定位
- BeanDefinition的载入(将资源中的信息初始化为BeanDefinition对象)
- 向IoC容器注册生成的BeanDefinition对象
②. AOP:aop就是面向切面的编程。比如说你每做一次对数据库操作,都生成一句日志。如果,你对数据库的操作有很多类,那你每一类中都要写关于日志的方法。但是如果你用aop,那么你可以写一个方法,在这个方法中有关于数据库库操作的方法,每一次调用这个方法的时候,就加上生成日志的操作。
AOP是用的JDK动态代理,动态代理分为JDK动态代理和CGlib动态代理
- JDK代理只能对实现接口的类生成代理
- CGlib是针对类实现代理,对指定的类生成一个子类,并覆盖其中的方法,这种通过继承类的实现方式,不能代理final修饰的类
常用注解:@Aspect、@Pointcut、@Around、@Before、@After
2. Spring MVC的流程
① 用户点击某个请求路径,发起一个 HTTP request 请求,该请求会被提交 到 DispatcherServlet(前端控制器);
② 由 DispatcherServlet 请求一个或多个 HandlerMapping(处理器映射器),并返回一个执行链(HandlerExecutionChain)。
③ DispatcherServlet 将执行链返回的 Handler 信息发送给 HandlerAdapter(处理器适配器);
④ HandlerAdapter 根据 Handler 信息找到并执行相应的 Handler(常称为 Controller);
⑤ Handler 执行完毕后会返回给 HandlerAdapter 一个 ModelAndView 对象(Spring MVC的底层对象,包括 Model 数据模型和 View 视图信息);
⑥ HandlerAdapter 接收到 ModelAndView 对象后,将其返回给 DispatcherServlet ;
⑦ DispatcherServlet 接收到 ModelAndView 对象后,会请求 ViewResolver(视图解析器)对视图进行解析;
⑧ ViewResolver 根据 View 信息匹配到相应的视图结果,并返回给 DispatcherServlet;
⑨ DispatcherServlet 接收到具体的 View 视图后,进行视图渲染,将 Model 中的模型数据填充到 View 视图中的 request 域,生成最终的 View(视图);
3. Spring事物
(1). Spring的声明式事物配置方式?
① 配置sessionFactory ② 配置事务管理器transactionManager
③ 配置事务特性txAdvice ④ 配置哪些类的哪些方法配置事务
(2). 事物的传播级别常用的是spring默认的required这种,这种的好处是如果当前上下文中已经存在了事物,那么就加入到事物中执行,如果不存在则新建一个事物。
(3). 事务常用的两个属性:readonly和timeout,一个是设置事务为只读以提升性能。另一个是设置事务的超时时间,一般用于防止大事务的发生。
(4). Spring中用到了哪些设计模式? 引用
①.单例模式(Singleton):Spring中的bean默认作用域是单例。这意味着在一个Spring容器中,每个bean的实例只会被创建一次。单例模式确保了全局只有一个实例对象,有助于节省资源和提高性能。
②.工厂模式(Factory):Spring框架的核心是IoC(Inversion of Control)容器,它负责管理bean的创建、配置和生命周期。Spring的BeanFactory和ApplicationContext都是基于工厂模式实 现的,负责创建和管理bean实例。
③.模板方法模式(Template Method):Spring框架中,JdbcTemplate、HibernateTemplate、RestTemplate等都使用了模板方法模式。这个模式定义了一个操作中的算法骨架,而将一些步骤延迟到子类中实现。模板方法模式使得子类可以在不改变算法结构的情况下,重新定义算法中的某些步骤。
④.代理模式(Proxy):Spring框架在AOP(面向切面编程)和事务管理等功能中使用了代理模式。代理模式通过创建一个代理对象,来控制对实际对象的访问。在Spring中,代理对象可以在目标对象执行方法之前或之后,添加额外的行为,如日志记录、权限控制、事务处理等。
⑤.观察者模式(Observer Pattern):Spring中的ApplicationEvent和ApplicationListener就实现了观察者模式,用于在系统内部进行事件通知和响应。
⑥.策略模式(Strategy Pattern):Spring的资源加载(ResourceLoader)使用了策略模式,根据不同的资源路径选择合适的资源加载策略。
⑦.装饰器模式(Decorator Pattern):Spring中的BufferedReaderFactory使用了装饰器模式,为Reader添加缓冲功能。
(5). Spring Bean的循环依赖场景?
①.构造器的循环依赖 ②.field属性的循环依赖。
(6). 怎么检测是否存在循环依赖
Bean在创建的时候可以给该Bean打标,如果递归调用回来发现正在创建中的话,即说明了循环依赖了。
(7). Spring Bean循环依赖?
①. Spring怎么解决循环依赖?
- A 创建过程中需要 B,于是 A 将自己放到三级缓里面 ,去实例化 B
- B 实例化的时候发现需要 A,于是 B 先查一级缓存,没有,再查二级缓存,还是没有,再查三级缓存,找到了!
- 然后把三级缓存里面的这个 A 放到二级缓存里面,并删除三级缓存里面的 A
- B 顺利初始化完毕,将自己放到一级缓存里面(此时B里面的A依然是创建中状态)
- 然后回来接着创建 A,此时 B 已经创建结束,直接从一级缓存里面拿到 B ,然后完成创建,并将自己放到一级缓存里面
一句话解释:先让最底层对象完成初始化,通过三级缓存与二级缓存提前曝光创建中的 Bean,让其他 Bean 率先完成初始化。
②. 为什么使用三级缓存解决循环依赖?
- 二级缓存存储的是提前曝光的未完成初始化的bean实例,而三级缓存存储的是bean工厂对象。通过使用三级缓存,我们可以控制获取到的bean实例的初始化进度。这意味着,当一个bean被另一个bean依赖时,我们可以获取到未完成初始化的bean实例,从而解决循环依赖问题。
- 使用三级缓存可以确保安全地处理循环依赖。当一个bean需要依赖另一个尚未完成创建的bean时,我们可以通过三级缓存的ObjectFactory来获取该bean的实例。这样,我们可以确保获取到的bean实例是安全的,并且在需要时可以完成剩余的初始化过程。
- 三级缓存提供了一种灵活的机制来处理bean之间的依赖关系。通过将bean工厂对象存储在三级缓存中,我们可以在需要时控制bean实例的创建和初始化,从而解决循环依赖问题。
三、Synchronized和Lock
1. Synchronized
(1). Synchronized的作用区域
Synchronized总共有三种用法: (1)修饰普通方法(2)修饰静态方法(3)修饰代码块
①. 普通方法,锁是当前实例对象 ,进入同步代码前要获得当前实例的锁方法的同步是使用ACC_SYNCHRONIZED常量标识符来完成的,当方法被调用的时候会先检查方法的ACC_SYNCHRONIZED是否被设置,如果被设置了线程先获取monitor,获取成功才执行方法体,执行完在释放monitor,在此期间其他任何方法都无法在获得同一个monitor对象
②. 静态方法,锁是当前类的class对象 ,进入同步代码前要获得当前类对象的锁
③. 代码块,锁是括号里面的对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。
代码块加锁解锁原理:
①用monitorenter和monitorexit代表获取锁和退出锁,获取锁的过程是当monitor为0的时候将进入数设置为1,代表当前线程获得了锁,每次退出monitor减1。
②. 如果当前线程已经获得了锁则monitor进入数加1。
③. 如果是其它线程获取锁则进入阻塞状态,直到monitor进入数为0修饰
方法加锁原理
方法加锁是通过在方法上加一个标记位ACC_SYNCHRONIZED 实现的,JVM发现此标志位后在执行对应方法就会进入加锁逻辑。
Synchronized对象头
synchronized的锁存储在对象头里面的Mark Word区,主要包括哈希码、GC分代年龄、锁标识、线程持有的锁ID、是否是偏向锁这些信息,存储在JVM的堆中
(2).Synchronized分为四种锁
锁在争夺过程中会膨胀
①. 无锁,不锁住资源,多个线程只有一个能修改资源成功,其他线程会重试;
②. 偏向锁,同一个线程获取同步资源时,没有别人竞争时,去掉所有同步操作,相当于没锁;
③. 轻量级锁,多个线程抢夺同步资源时,没有获得锁的线程使用CAS自旋等待锁的释放;
④. 重量级锁,多个线程抢夺同步资源时,使用操作系统的互斥量进行同步,没有获得锁的线程阻塞等待唤醒;
偏向锁的加锁过程
当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁。只需要简单的测试一下对象头的
Mark Word里是否存储着指向当前线程的偏向锁。如果成功,表示线程已经获取到了锁。
轻量加锁-解锁过程:
加锁:执行线程前,jvm再当前线程的栈中创建用于存储所记录的空间,并用CAS将对象头中的mark word复制到锁记录中。如果成功当前线程获取锁,否则其他线程获得锁,当前线程CAS自旋等待。如果有两条以上的线程竞争同一个锁,那轻量级锁就不再有效,直接膨胀为重量级锁
解锁:轻量级锁解锁时,会使用原子的CAS操作将Displaced mark word替换回对象头,如果成功,则表示没有竞争,如果失败,则表示该锁当前存在竞争,锁会升级为重量级锁
重量级锁加锁过程
重量级锁的实现依靠monitor,每一个锁对象都有且仅有一个自己的monitor对象。因此synchronized膨胀为重量级锁的第一件事情就是申请一块内存获取自己的ObjectMonitor,并且初始化。
ObjectMonitor的初始化需要一个过程,而重量级锁的膨胀一般处于竞争比较激烈的阶段。为了避免ObjectMonitor的重复申请与初始化,会记录一个膨胀状态到Mark Word中,如果已经有线程在膨胀了,那么后面的线程就会自旋等待膨胀完成。
2. Lock
ReentrantLock是基于AQS的,内部维护了一个volatile的状态变量state,AQS又是基于FIFO队列(先入先出)实现的,在实现上分为公平锁和不公平锁
- NofairSync:表示可以存在抢占锁的功能,也就是说不管当前队列上是否存在其他线程等待,新线程都有机会抢占锁
- FailSync: 表示所有线程严格按照 FIFO 来获取锁
锁获取过程:
ReentrantLock通过state来表示锁的状态。state的值表示锁被持有的次数,当state为0时表示锁未被持有。ReentrantLock使用CAS操作更新state的值,以确保原子性。
(1)当一个线程尝试获取锁时,首先会检查state的值。如果state为0,表示锁未被占用,线程会用CAS操作将state的值从0更新为1。如果成功,表示当前线程获取到了锁,并将持有计数+1。
(2)如果这时候再来一个线程2去获取锁首先会用CAS判断state是不是0,如果是同一个线程的话可以重入state+1(最大Integer.MAX_VALUE次),如果不是0代表已经被占用了会走acquire方法调用tryacquire方法在次获取state状态(因为state是volatile修饰的,可能第一个线程执行比较快已经释放锁了),如果失败了会加入由AQS实现的一个FIFO队列,被封装成node加入到队列的尾部,然后挂起等待,当持有锁的线程释放锁的时候state会被减成0,AQS会从队列的头部取出一个node并唤醒进入锁的争夺
3. Synchronized和Lock的区别?
(1). Synchronized可以用在代码块和方法,Lock只能用在方法内。
(2). Synchronized是JVM层面的,加锁解锁是自动的,Lock是Java的一个类,需要手动加锁解锁
(3). Synchronized是不公平的,Lock可公平可不公平。
4. 互斥锁和自旋锁的区别 互斥锁、自旋锁、读写锁、悲观锁、乐观锁的应用场景
- 互斥锁加锁失败后,线程会释放 CPU ,给其他线程;
- 自旋锁加锁失败后,线程会忙等待,直到它拿到锁;
(1).互斥锁
互斥锁是一种「独占锁」,比如当线程 A 加锁成功后,此时互斥锁已经被线程 A 独占了,只要线程 A 没有释放手中的锁,线程 B 加锁就会失败,于是就会释放 CPU 让给其他线程,既然线程 B 释放掉了 CPU,自然线程 B 加锁的代码就会被阻塞。
对于互斥锁加锁失败而阻塞的现象,是由操作系统内核实现的。当加锁失败时,内核会将线程置为「睡眠」状态,等到锁被释放后,内核会在合适的时机唤醒线程,当这个线程成功获取到锁后,于是就可以继续执行。
所以,互斥锁加锁失败时,会从用户态陷入到内核态,让内核帮我们切换线程,虽然简化了使用锁的难度,但是存在一定的性能开销成本。
那这个开销成本是什么呢?会有两次线程上下文切换的成本:
- 当线程加锁失败时,内核会把线程的状态从「运行」状态设置为「睡眠」状态,然后把 CPU 切换给其他线程运行;
- 接着,当锁被释放时,之前「睡眠」状态的线程会变为「就绪」状态,然后内核会在合适的时间,把 CPU 切换给该线程运行。
(2).自旋锁
自旋锁是通过 CPU 提供的 CAS 函数(Compare And Swap),在「用户态」完成加锁和解锁操作,不会主动产生线程上下文切换,所以相比互斥锁来说,会快一些,开销也小一些。
一般加锁的过程,包含两个步骤:
- 第一步,查看锁的状态,如果锁是空闲的,则执行第二步;
- 第二步,将锁设置为当前线程持有;
CAS 函数就把这两个步骤合并成一条硬件级指令,形成原子指令,这样就保证了这两个步骤是不可分割的,要么一次性执行完两个步骤,要么两个步骤都不执行。
使用自旋锁的时候,当发生多线程竞争锁的情况,加锁失败的线程会「忙等待」,直到它拿到锁。这里的「忙等待」可以用 while 循环等待实现,不过最好是使用 CPU 提供的 PAUSE 指令来实现「忙等待」,因为可以减少循环等待时的耗电量。
5.锁消除和锁粗化
(1).锁消除 引用
一段代码不需要加锁但是却写了加锁操作
锁消除即删除不必要的加锁操作。JVM在运行时,对一些“在代码上要求同步,但是被检测到不可能存在共享数据竞争情况”的锁进行消除。根据代码逃逸技术,如果判断到一段代码中,堆上的数据不会逃逸出当前线程,那么就可以认为这段代码是线程安全的,无需加锁。
从上述代码看出来,StringBuffer的append是个同步方法,但是LockClearTest中的 StringBuffer 属于一个局部变量(局部变量在虚拟机栈里面,是私有的),不可能从该方法中逃逸出去(即stringBuffer的引用没有传递到该方法外,不会被其他线程引用),因此其实这过程是线程安全的,可以将锁消除。
(2).锁粗化
两个加锁操作之间间隔的代码可以很快执行完合并成一个加锁操作或者循环里面加锁 通常情况下,为了保证多线程间的有效并发,会要求每个线程持有锁的时间尽可能短,但是大某些情况下,一个程序对同一个锁不间断、高频地请求、同步与释放,会消耗掉一定的系统资源,因为锁的讲求、同步与释放本身会带来性能损耗,这样高频的锁请求就反而不利于系统性能的优化了,虽然单次同步操作的时间可能很短。锁粗化就是告诉我们任何事情都有个度,有些情况下我们反而希望把很多次锁的请求合并成一个请求,以降低短时间内大量锁请求、同步、释放带来的性能损耗。
四、集合
1. List和Set的区别?
相同点:List和Set都是继承自Collection接口
不同点:
List特点:元素有放入顺序,元素可重复,查找元素效率高,插入删除元素效率低,因为会引起其他元素位置改变。
Set特点:元素无放入顺序,元素不可重复,重复元素会覆盖掉,检索元素效率低下,删除和插入效率高,插入和删除不会引起元素位置改变。
2. ArrayList和LinkedList区别
Arraylist:初始大小是10,扩容是1.5倍。
优点:ArrayList是基于动态数组的数据结构,因为地址连续,查询操作效率会 比较高。
缺点:因为地址连续, ArrayList要移动数据,所以插入和删除操作效率比较 低。查找效率O(1),插入删除效率O(n)
LinkedList:是一个双向链表,没有初始大小。
优点:LinkedList基于链表的数据结构,地址是任意的,对于新增和删除操作 add和remove,LinedList比较占优势。LinkedList 适用于要头尾操作或 插入指定位置的场景。
缺点:因为LinkedList要移动指针,所以查询操作性能比较低。查找效率O (n),插入删除效率O(1)
五、HashMap
(1). HashMap在JDK1.7是头插法,在JDK1.8是尾插法是为了避免链表成环,因为在JDK1.8尾插法扩容后会保持和原来一样的顺序
(2). JDK1.8采用数组+链表+红黑树的结构
(3). HashMap的put过程
- 通过hash算法和与运算得到key的数组下标
- 这时候会有两种情况,
第一种情况:如果对应的数组下标位置元素为空,则将key和value封装为Node放到这个位置上。
第二种情况:如果对应的数组下标位置元素不为空,分为链表和红黑树两种情况
a:假设是红黑树node,则将key和value封装为一个红黑树节点并添加到红黑树中,在添加的过程中同样会判断当前树中是否存在对应的key,如果存在则直接更新对应的value值。
b:假设是链表node,则将key和value封装为一个链表node,然后通过尾插法,插入到尾部。这个过程需要遍历当前整个链表,在遍历的过程中同样也会判断是否存在对应的key,如果存在也同样会直接更新对应的value值。遍历结束且将新封装的Node节点插入后,此时会判断如果节点个数大于等于8,则会将对应的链表转换为红黑树。
c:不管是链表还是红黑树Node,都是最后再判断是否需要扩容,如果需要扩容则进行扩容,如不需要则结束put方法。
PS:选择8的原因是根据泊松分布链表长度达到8的概率0.00000006非常非常小,链表长度1的概率0.60653066。链表时间复杂度为O(n),红黑树的时间复杂度是O(logN)
在得到下标值以后,可以开始put值进入到数组+链表中,会有三种情况:
1.数组的位置为空。
2.数组的位置不为空,且下面是链表的格式。
3.数组的位置不为空,且下面是红黑树的格式。
(4). HashMap扩容过程
Hashmap的扩容有三种情况:
①众所周知当HashMap的使用的桶数达到总桶数*加载因子(16 * 0.75)的时候会触发扩容;
②当某个桶中的链表长度达到8进行链表扭转为红黑树的时候,会检查总桶数是否小于64,如果总桶数小于64也会进行扩容;
③当new完HashMap之后,第一次往HashMap进行put操作的时候,首先会进行扩容。
HashMap初始化后首次插入数据时,先发生resize扩容再插入数据,之后每当插入的数据个数达到threshold时就会发生resize,此时是先插入数据再resize。
HashMap的扩容机制就是调用resize方法重新申请一个容量是当前的2倍的桶数组,然后将原有的数组元素拷贝到新的entry数组里面,然后将原先的桶逐个置为null使得引用失效。不需要像JDK1.7的实现那样重新计算hash,只需要通过计算原来的hash值新增的那个第五位bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引+oldCap(旧桶的个数)”
(5). HashMap为什么是线程不安全的?
在jdk1.8中并发put可能会造成数据覆盖。
假设两个线程A、B都在进行put操作,并且hash函数计算出的插入下标是相同的,当线程A执行完代码后由于时间片耗尽导致被挂起,而线程B得到时间片后在该下标处插入了元素,完成了正常的插入,然后线程A获得时间片,由于之前已经进行了hash碰撞的判断,所有此时不会再进行判断,而是直接进行插入,这就导致了线程B插入的数据被线程A覆盖了,从而线程不安全。
(6). 怎么让HashMap变成安全的? 引用
①. 使用hashtable或者使用使用Collections类的synchronizedMap方法包装一下。方法如下: public static <K,V> Map<K,V> synchronizedMap(Map<K,V> m) 返回由指定映射支持的同步(线程安全的)映射
②. 使用ConcurrentHashMap
- jdk1.7时候采用分段锁的方式来保证线程安全
- jdk1.8的时候放弃了分段锁,使用Synchronized和CAS来保证线程安全
- 使用volatile保证当Node中的值变化时对于其他线程是可见的
- 使用table数组的头结点作为synchronized的锁来保证写操作的安全
- 当头结点为null时,使用CAS操作来保证数据能正确的写入。
(7). HashMap和HashTable的区别
- HashMap是线程不安全的,HashTable是线程安全的
- HashMap可以使用null作为key,HashMap以null作为key时,总是存储在table数组的第一个节点上。而Hashtable则不允许null作为key
- HashMap默认初始容量是16,HashTable默认初始容量是11.
(8). JDK1.8ConcurrentHashMap为什么放弃了分段锁:
- 减少内存开销:如果使用ReentrantLock则需要节点继承AQS来获得同步支持,增加内存开销,而1.8中只有头节点需要进行同步。
- 内部优化:synchronized则是JVM直接支持的,JVM能够在运行时作出相应的优化措施:锁粗化、锁消除、锁自旋等等。
(9). CAS的原理以及有什么问题?
CAS是比较并替换,有三个参数:内存地址V,旧的预期值A,新的预期值B,当内存地址V的值与预期值A相等时,将内存地址V的值修改为B,否则就什么都不做。整个比较并替换的操作是一个原子操作。
①. CAS缺点
- 只能保证一个共享变量的原子操作。
- ABA问题。
只能保证一个共享变量的原子操作:当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁来保证原子性。
②. 什么是ABA问题?ABA问题怎么解决?
如果内存地址V初次读取的值是A,并且在准备赋值的时候检查到它的值仍然为A,如果在这段期间它的值曾经被改成了B,后来又被改回为A,那CAS操作就会误认为它从来没有被改变过。这个漏洞称为CAS操作的“ABA”问题。
解决方法用带有标记的原子引用类“AtomicStampedReference”,它可以通过控制变量值的版本来保证CAS的正确性。
六、线程和线程池?
1. 创建线程的三种方式以及六种状态
(1).三种创建方式
1.继承Thread 2.实现Runnable 3.实现callable接口
(2).线程的六种状态
- NEW: 线程刚创建,还没有调用start()方法;
- RUNNABLE:线程准备就绪状(即:调用start()方法)或运行中;
- TIME_WAITING:等待隔一段时间自动唤醒;
- WAITING: 等待被唤醒;
- BLOCKED:阻塞,正在等待锁;
- TERMINATED:线程结束;
NEW:就绪态
new Thread();或者
new Thread子类();
这两行代码执行后,线程就处于就绪态,此时线程没有开启。
RUNNABLE:可运行
线程调用start()方法可以进入到可运行状态。
可运行状态就是线程可以在java虚拟机中运行的状态。此时他可能正在运行,也有可能正在等待因为cpu要不停地切换执行线程。注意这两种状态都是可运行状态。
Teminated(被终止)
因为线程的run方法正常执行结束或者被调用了stop方法被终止,这个时候就是被终止状态。
接下来是另外三个状态
Blocked(锁阻塞)
在线程处于可运行状态的时候,他可能会尝试获取锁。
如果他没有获取到锁,那么这个进程就处于阻塞态。
如果获取到了锁,就转成了可运行态。
Waiting(无限等待)
线程调用wait()方法,此时从运行态变成了无限等待。
进入此状态后,线程一直等待被其他线程唤醒,也就是等待其他线程获取此对象的锁,执行notify()。
如果唤醒者线程获取此线程的锁成功执行了notify(),这个线程就被唤醒,进入到可运行。
如果唤醒者线程没有成功成功获取锁,可运行状态进入到阻塞态。(尝试获取锁)
Timed Waiting(计时等待)
和无线等待类似,这里指定了等待时间,在这个时间内一直等待被唤醒。常见的Thread.sleep方法和wait(long time)方法。
可运行态调用Thread.sleep方法或wait(long time)方法进入此状态。
等待时间内,获取到了锁,被唤醒进入可运行状态。
等待时间到,获取到了锁,被唤醒进入可运行状态。
等待时间结束,没有获取到锁,进入阻塞态。
(3).wait和sleep的区别
- sleep方法只是暂停线程的执行,不会释放任何锁,而wait方法会释放对象的锁。
- sleep方法可以在任何地方使用,而wait方法必须在同步方法中使用。
- sleep方法只能由线程自身调用,而wait方法可以由其他线程调用。
(4).notify的唤醒顺序
作者说的是随机唤醒,但是具体取决于用的JVM,如果是hotspot是顺序唤醒
2. 创建线程池的四种方式
(1). newSingleThreadExecutor单个线程的线程池,即线程池中每次只有一个线程工作,单线程串行执行任务
(2). newFixedThreadExecutor(n)固定数量的线程池,每提交一个任务就是一个线程,直到达到线程池的最大数量,然后后面进入等待队列直到前面的任务完成才继续执行
(3). newCacheThreadExecutor(推荐使用)可缓存线程池,当线程池大小超过了处理任务所需的线程,那么就会回收部分空闲(一般是60秒无执行)的线程,当有任务来时,又智能的添加新线程来行
(4). newThreadExecutor大小无限制的线程池,支持定时和周期性的执行线程
(5). ThreadPoolTaskExecutor spring中自带的线程池
3. 线程池的核心参数?
- corePoolSize :核心池的大小,如果调用了prestartAllCoreThreads()或者prestartCoreThread()方法,会直接预先创建corePoolSize的线程,否则当有任务来之后,就会创建一个线程去执行任务,当线程池中的线程数目达到corePoolSize后,就会把到达的任务放到缓存队列当中;这样做的好处是,如果任务量很小,那么甚至就不需要缓存任务,corePoolSize的线程就可以应对;
- maximumPoolSize:线程池最大线程数,表示在线程池中最多能创建多少个线程,如果运行中的线程超过了这个数字,那么相当于线程池已满,新来的任务会使用RejectedExecutionHandler 进行处理;
- keepAliveTime:表示线程没有任务执行时最多保持多久时间会终止,然后线程池的数目维持在corePoolSize 大小;
- unit:参数keepAliveTime的时间单位;
- workQueue:一个阻塞队列,用来存储等待执行的任务,如果当前对线程的需求超过了corePoolSize大小,才会放在这里;
- threadFactory:线程工厂,主要用来创建线程,比如可以指定线程的名字;
- handler:如果线程池已满,新的任务的处理方式
4. 线程池的原理?
(1). 线程池刚创建时,里面没有一个线程。任务队列是作为参数传进来的。不过,就算队列里面有任务,线程池也不会马上执行它们。
(2). 当调用 execute() 方法添加一个任务时,线程池会做如下判断:
- ①. 如果正在运行的线程数量小于 corePoolSize,那么马上创建线程运行这个任务;
- ②. 如果正在运行的线程数量大于或等于 corePoolSize,那么将这个任务放入队列。
- ③. 如果这时候队列满了,而且正在运行的线程数量小于 maximumPoolSize,那么还是要创建线程运行这个任务;
- ④. 如果队列满了,而且正在运行的线程数量大于或等于 maximumPoolSize,那么线程池会抛出异常,告诉调用者“我不能再接受任务了”。
(3). 当一个线程完成任务时,它会从队列中取下一个任务来执行。
(4). 当一个线程无事可做,超过一定的时间(keepAliveTime)时,线程池会判断,如果当前运行的线程数大于 corePoolSize,那么这个线程就被停掉。所以线程池的所有任务完成后,它最终会收缩corePoolSize 的大小。
举个例子:假设队列大小为 10,corePoolSize 为 3,maximumPoolSize 为 6,那么当加入 20 个任务时,执行的顺序就是这样的:首先执行任务 1、2、3,然后任务 4 ~ 13 被放入队列。这时候队列满了,任务 14、15、16 会被马上执行,而任务 17~20 则会抛出异常。最终顺序是:1、2、3、14、15、16、4、5、6、7、8、9、10、11、12、13。
5. 四种拒绝策略?
(1). AbortPolicy:不执行新任务,直接抛出异常,提示线程池已满,线程池默认策略
(2). DiscardPolicy:不执行新任务,也不抛出异常,基本上为静默模式。
(3). DisCardOldSetPolicy:将消息队列中的第一个任务替换为当前新进来的任务执行
(4). CallerRunPolicy:拒绝新任务进入,如果该线程池还没有被关闭,那么这个新的任务在执行线程中被调用)
当然也可以自己实现处理策略类,继承RejectedExecutionHandler接口即可,该接口只有一个方法:
void rejectedExecution(Runnable r, ThreadPoolExecutor executor);
6. 线程池的阻塞队列?
使用比较多的是LinkedBlockingQueue阻塞队列大小的配置是可选的,如果我们初始化时指定一个大小,它就是有边界的,如果不指定,它就是无边界的。说是无边界,其实是采用了默认大小为Integer.MAX_VALUE的容量 。它的内部实现是一个链表。要注意的就是一般情况下要配置一下队列大小,设置成有界队列,否则JVM内存会被撑爆!
7. 怎样优化线程池的配置和合理配置线程池大小?
(1).确定任务类型,是IO密集还是CPU密集型任务,任务是长任务还是短任务以及任务的到达频率。
(2).核心线程数(corePoolSize):
对于CPU密集型可以设置为CPU核心数+1可以最大利用CPU资源,对于IO密集型可以设置CPU核心*2或者更大的核心线程,因为IO密集型的任务会有大量线程处于等待状态,这个需要根据实际情况进行测试。
(3).最大线程数(maximumPoolSize):
设置最大线程数时,需要考虑系统承受能力和任务特性。将最大线程数设置得过大可能会导致系统资源耗 尽,而设置得过小则可能无法应对突发的高并发请求。对于CPU密集型任务,最大线程数通常不需要比核心 线程数大很多;而对于IO密集型任务,可以设置较大的最大线程数以提高并发处理能力。
(4).空闲线程存活时间(keepAliveTime):
根据任务执行时间和到达频率来设置空闲线程存活时间。如果任务执行时间较长或到达频率较低,可以将存 活时间设置得较短以减少资源占用。反之,如果任务执行时间较短且到达频率较高,可以设置较长的存活时 间以避免频繁创建和销毁线程。
(5).工作队列(workQueue)
合适的工作队列对性能也有很大影响。ArrayBlockingQueue是一个有界队列,可以限制任务数量,适用于固定大小的线程池。LinkedBlockingQueue是一个无界队列,适用于任务到达速度较快且处理速度较慢的场景。SynchronousQueue是一个同步队列,适用于任务执行时间较短的场景,可以实现线程池的动态扩容。
8. ThreadLocal线程变量副本
ThreadLocal的核心数据结构是一个弱引用的ThreadLocalMap,其中的键是ThreadLocal对象本身,值是线程相关的局部变量。每个线程Thread对象中都有一个ThreadLocalMap的实例,用于存储当前线程的局部变量。ThreadLocalMap内部使用Entry数组存储键值对ThreadLocal在Spring中发挥着巨大的作用,在管理Request作用域中的Bean、事务管理、任务调度、AOP等模块都出现了它的身影。当使用完ThreadLocal时应该使用remove()清除当前线程的局部变量避免内存泄漏
9. volatile
(1). volatile具备两种特性:
①.保证此变量对所有线程的可见性,指一条线程修改了这个变量的值,新值对于其他线程来说是可见的,但并不是多线程安全的。
②.禁止指令重排序优化。
(2). volatile怎么保证内存可见性?
计算机所有的指令都是在CPU中完成的,但是运行的数据是存储在主存中,如果所有的操作都需要与主存打交道效率会很低,所以有一个缓存
线程写volatile变量的过程:
①. 修改线程工作内存中volatile变量的副本的值
②. 将改变后的副本的值从工作内存刷新到主内存
线程读volatile变量的过程 :
①. 从主内存中读取volatile变量的最新值到线程的工作内存中
②. 从工作内存中读取volatile变量的副本
对一个变量加了volatile关键字修饰之后,只要一个线程修改了这个变量的值,立马强制刷回主内存。接着强制过期其他线程的本地工作内存中的缓存,最后其他线程读取变量值时,强制重新从主内存来加载最新的值。这样就保证,任何一个线程修改了变量,其他线程立马就可以看见了。
七、JVM
1. JVM的内存结构
主要分为堆、虚拟机栈、本地方法栈、方法区、程序计数器
堆:存放对象实例。由所有线程共享
方法区:存放已被虚拟机加载的类信息,常量,静态变量等数据,即时编译器编译后的代码。由所有线程共享
虚拟机栈:存储局部变量和方法调用信息。私有
本地方法栈:存储本地方法调用信。私有
程序计数器:代码行号指示器。
String str = new String("hello");
变量str放在栈上,用new创建出来的字符串对象放在堆上,而”hello”这个字面量是
放在方法区的。
2. 垃圾收集算法
(1). 标记-清理算法
分为标记和清除两个阶段。该算法首先从根集合进行扫描,对存活的对象对象标记,标记完毕后,再扫描整个空间中未被标记的对象并进行回收
缺点:
①. 效率问题:标记和清除两个过程的效率都不高;
②. 空间问题:标记-清除算法不需要进行对象的移动,并且仅对不存活的对象进行处理,因此标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
(2). 标记-整理算法(适用于老年代)
标记整理算法的标记过程类似标记清除算法,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存,类似于磁盘整理的过程,该垃圾回收算法适用于对象存活率高的场景(老年代)。
优点:不需要额外的空间
缺点:整理阶段需要移动对象可能导致停顿时间较长
标记整理算法与标记清除算法最显著的区别是:标记清除算法不进行对象的移动,并且仅对不存活的对象进行处理;而标记整理算法会将所有的存活对象移动到一端,并对不存活对象进行处理,因此其不会产生内存碎片。
(3). 复制算法优化(适用于年轻代)
复制算法进一步优化:使用Eden/S0/S1三个分区,比例8:1:1,有效内存(即可分 配新生对象的内存)是总内存的9/10 算法过程:
- 对象优先在Eden区进行分配,如果Eden区满了之后会触发一次Minor GC
- Minor GC之后从Eden存活下来的对象将会被移动到S0区域,当S0内存满了之后又会被触发一次Minor GC,S0区存活下来的对象会被移动到S1区,S0区空闲;S1满了之后在Minor GC,存活下来的再次移动到S0区,S1区空闲,这样反反复复GC,每GC一次,对象的年龄就涨一岁,默认达到15岁之后就会进入老年代,对于晋身到老年代的年龄阈值可以通过参数 -XX:MaxTenuringThreshold设置
- 在Minor GC之后需要的发送晋身到老年代的对象没有空间安置,那么就会触发Full GC (这步非绝对,视垃圾回收器决定)
3. 垃圾回收有两种类型
Minor GC:对新生代进行回收,不会影响到年老代。因为新生代的 Java 对象大多 死亡频繁,所以 Minor GC 非常频繁,一般在这里使用速度快、效率高的算法,使 垃圾回收能尽快完成。触发Minor GC的原因:当新生代的Eden区满的时候触发 Minor GC。
Full GC:也叫 Major GC,对整个堆进行回收,包括新生代和老年代。由于Full GC需要对整个堆进行回收,所以比Minor GC要慢,因此应该尽可能减少Full GC的 次数,导致Full GC的原因包括:老年代被写满、永久代(Perm)被写满和 System.gc()被显式调用等。
4. 如何确定对象是否需要被回收
①. 引用计数算法:判断对象的引用数量
当创建对象的时候,为这个对象在堆栈空间中分配对象,同时会产生一个引用计数器,同时引用计数器+1,当有新的引用时,引用计数器继续+1,而当其中一个引用销毁时,引用计数器-1,当引用计数器减为0的时候,标志着这个对象已经没有引用了,可以回收了!但是这样会有一个问题:
当我们的代码出现这样的循环引用情况时:
a)ObjA.obj=ObjB b)ObjB.obj=ObjA
②.可达性分析法
可达性分析算法是通过判断对象的引用链是否可达来决定对象是否可以被回收。
程序把所有的引用关系看作一张图,通过一系列的名为 “GC Roots” 的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链。当一个对象到 GC Roots没有任何引用链相连时,则证明此对象是不可用的。
可作为 GC Root 的对象包括以下几种:
•虚拟机栈和本地方法栈中引用的对象;
•方法区中静态属性和常量引用的对象;
5. 几种垃圾收集器
垃圾收集器共分为Serial串行、ParNew并行、CMS和G1几种
(1).Serial串行收集器(适用于新生代):
这是一个单线程的垃圾收集器,适用于客户端应用程序和小型服务器应用程序。在执行垃圾收集时,用户线程会暂停,可能导致应用程序的停顿时间较长。这种收集器适用于只有一个或两个处理器的系统。
(2).ParNew并行收集器(适用于新生代):
使用多线程执行垃圾回收。这种收集器可以显著提高应用程序的吞吐量,但停顿时间依然可能较长。这种收集器适用于拥有多个处理器的服务器端系统。
(3).CMS垃圾收集器(用于老年代)引用
CMS:基于标记-清理算法,整个过程分为4步:(
①. 初始标记:标记所有的根对象,包括根对象直接引用的对象,以及被年轻代中所有存活的对象所引用的老年代对象。这个阶段会停
②. 并发标记:CMS GC遍历所有的对象,标记存活的对象,从前一阶段“初始标记”找到的根元素开始算起。 这个阶段不会停
③. 重新标记:修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记时间短;
④. 并发清理 -清除算法;
优点:并发收集,低停顿 由于在整个过程中最耗时的并发标记和并发清除过程收集器程序都可以和用户线程一起工作,所以总体来说,cms收集器的内存回收过程是与用户线程一起并发执行的
缺点:
①. CMS处理器无法处理浮动垃圾 CMS在并发清理阶段线程还在运行, 伴随着程序的运行自然也会产生新的垃圾,这一部分垃圾产生在标记过程之后,CMS无法再当次过程中处理,所以只有等到下次gc时候在清理掉,这一部分垃圾就称作“浮动垃圾”
②. CMS是基于“标记--清除”算法实现的,所以在收集结束的时候会有大量的空间碎片产生。空间碎片太多的时候,将会给大对象的分配带来很大的麻烦,往往会出现老年代还有很大的空间剩余,但是无法找到足够大的连续空间来分配当前对象的,只能提前触发 full gc。
为了解决这个问题,CMS提供了一个开关参数-XX:CMSFullGCsBeforeCompaction可以设置在多少次full gc之后进行内存碎片整合
(4).G1垃圾收集器(适用于整个堆回收):
使用G1收集器时,它将整个Java堆划分成约2048个大小相同的独立Region块,每个Region块大小根据堆空间的实际大小而定,整体被控制在1MB到32MB之间,且为2的N次幂,即1MB,2MB, 4MB, 8MB, 1 6MB, 32MB。可以通过-XX :G1HeapRegionSize设定。所有的Region大小相同,且在JVM生命周期内不会被改变。
①.年轻代GC (Young GC):应用程序分配内存,当年轻代的Eden区用尽时开始年轻代回收过程;G1的年轻代收集阶段是一个并行(多个垃圾线程)的独占式收集器。在年轻代回收期,G1 GC暂停所有应用程序线程,启动多线程执行年轻代回收。然后从年轻代区间移动存活对象到Survivor区间或者老年区间,也有可能是两个区间都会涉及
②.老年代并发标记过程 (Concurrent Marking):当堆内存使用达到一定值(默认45%)时,开始老年代并发标记过程
3.混合回收(Mixed GC) :标记完成马上开始混合回收过程。对于一个混合回收期,G1 GC从老年区间移动存活对象到空闲区间,这些空闲区间也就成为了老年代的一部分。和年轻代不同,老年代的G1回收器和其他GC不同,G1的老年代回收器不需要整个老年代被回收,一次只需要扫描/回收一小部分老年代的Region就可以了。同时,这个老年代Region是和年轻代一起被回收的。
6. JVM常用参数
- -Xms:指定JVM的初始堆大小,默认为物理内存的1/64。
- -Xmx:指定JVM的最大堆大小,默认为物理内存的1/4。
- -XX:PermSize=:指定JVM的永久代大小。
- -XX:MaxPermSize=:指定JVM的最大永久代大小。
- -XX:NewSize=:指定JVM的新生代大小。
- -XX:MaxNewSize=:指定JVM的最大新生代大小。
- -XX:+UseConcMarkSweepGC:指定JVM使用CMS垃圾回收器。
- -XX:+UseParallelGC:指定JVM使用并行垃圾回收器。
八、双亲委派机制
1. 什么是双亲委派机制?
当一个类加载器收到了类加载的请求的时候,他不会直接去加载指定的类,而是把这个请求委托给自己的父加载器去加载。只有父加载器无法加载这个类的时候,才会由当前这个加载器来负责类的加载。
2. 为什么需要时双亲委派机制?
- 避免类重复加载
- 保证安全性
3. 父子加载器之间是继承关系么?
双亲委派模型中,类加载器之间的父子关系一般不会以继承(Inheritance)的关系来实现,而是都使用组合(Composition)关系来复用父加载器的代码的。
4. 双亲委派机制是怎么实现的?
1、先检查类是否已经被加载过
2、若没有加载则调用父加载器的loadClass()方法进行加载
3、若父加载器为空则默认使用启动类加载器作为父加载器。
4、如果父类加载失败,抛出ClassNotFoundException异常后,再调用自己的findClass()方法进行加载。
5. 怎么破坏双亲委派机制?
因为他的双亲委派过程都是在loadClass方法中实现的,那么想要破坏这种机制,那么就自定义一个类加载器,重写其中的loadClass方法,使其不进行双亲委派即可。
6. loadClass()、findClass()、defineClass()区别
ClassLoader中和类加载有关的方法有很多,前面提到了loadClass,除此之外,还有findClass和defineClass等,那么这几个方法有什么区别呢?
- loadClass()就是主要进行类加载的方法,默认的双亲委派机制就实现在这个方法中。
- findClass()根据名称或位置加载.class字节码
- definclass()把字节码转化为Class
九、深拷贝和浅拷贝
1. 浅拷贝
浅拷贝会创建一个新对象,这个对象有着原来对象属性值的一份精确拷贝。
- 如果属性是基本类型,拷贝的就是基本类型的值;如果属性是内存地址(引用类型),拷贝的就是内存地址 ,因此如果其中一个对象改变了这个地址,就会影响到另一个对象。
2. 深拷贝
深拷贝会拷贝所有的属性,并拷贝属性指向的动态分配的内存。当对象和它所引用的对象一起拷贝时即发生深拷贝。深拷贝相比于浅拷贝速度较慢并且花销较大。
3. 零拷贝 引用
Redis
一、Redis的数据结构以及使用场景 引用
Redis有String、List、Set、ZSet、Hash五种结构
(1).String:
①. 数据结构:动态字符串结构,在C语言里面字符串可以用一个 \0 结尾
的 char 数组来表示,但是不支持redis的追加和长度计算,所以Redis 的动态
字符串有两部分组成已使用长度len和剩余可用长度free,比如当前内容为hello
world,当需要追加123的时候,如果当前剩余长度不够在执行append追加之后重新
创建了多余一倍长度的空间,比如hello world123长度为14+1个字节,在append
之后程序重新分配14+14+1个字节,这样的话下次追加如果长度小于14就不用重新分
配内存,当追加内容小于1M的时候分配一倍的长度,当追加内容大于1M的时候最多分
配1M的空间。
struct sdshdr {
// buf 已占用长度
int len;
// buf 剩余可用长度
int free;
// 实际保存字符串数据的地方
char buf[];
};
②. 常用命令:SET、GET
(2).List
①. 数据结构:quicklist(底层链表)
②. 使用场景:粉丝列表、关注列表、队列
③. 常用命令:
LPUSH和RPUSH分别可以向 list 的左边(头部)和右边(尾部)添加一个新元素;LPOP和RPOP取出LRANGE命令可以从 list 中取出一定范围的元素;LINDEX命令可以从 list 中取出指定下表的元素,相当于 Java 链表操作中的get(int index)操作;
Redis中链表的特性:
- 每一个节点都有指向前一个节点和后一个节点的指针。
- 头节点和尾节点的prev和next指针指向为null,所以链表是无环的。
- 链表有自己长度的信息,获取长度的时间复杂度为O(1)。
(3).Set
①. 数据结构:hashtable和intset
②. 使用场景:存储一个不需要重复的列表
③. 常用命令:SADD、SISMEMBER查询某个key是否存在、SCARD获取长度、SPOP取出一个
(4).Hash
①. 数据结构:ziplist、hashtable
②. 使用场景:存储一些对象
③. 常用命令:hget、hset、hgetall
(5).zset
①. 数据结构:使用HashMap和跳跃表(SkipList)来保证数据的存储和有序,HashMap里放的是成员到score的映射,而跳跃表里存放的是所有的成员,排序依据是HashMap里存的score。
②. 使用场景:需要一个有序的并且不重复的集合列表
③. 常用命令:ZADD、ZRANGE按score排序取出0 -1代表取出全部、ZREVRANGE逆序取出、ZCARD统计个数、ZSCORE获取指定value的score、ZREM删除
二、跳表详解 参考
-
跳表的时间复杂度是O(log n)
-
跳表是个有序链表,主要分四部分,head头、level层、节点和null尾,每一次插入的时候会随机分配一个层数
三、Redis的分布式锁
1. Set方法
SET key value [EX seconds][NX|XX] [GET]
- EX -- 设置指定的过期时间,以秒为单位。
- NX -- 仅当该键不存在的时才会设置该键。
- XX -- 仅当该键存在时才会设置该键。
加锁命令: SET lock_key lock_value PX 10000 NX
2. 加锁注意事项
-
防死锁
、设置锁的超时时间要保持原子性,这点很容易做到 使用
SET lock_key lock_value PX 10000 NX命令即可, 不要使用SETNX lock_key lock_value,EXPIRE lock_key 10这些命令,因为他们之间不是原子性的,有发生死锁的风险。如下,先加锁,在设置过期时间发生死锁:
if(jedis.setnx(lock_key,lock_value) == 1){ //加锁
jedis.expire(lock_key,timeout); //设置过期时间
doBusiness //业务逻辑处理
}
-
合理设置锁超时时间
锁的超时时间要大于程序执行的时间,否则多个客户端可能同时获取锁。充分预估使用锁的业务代码执行时间,该时间不宜过长也不宜过短,过短,可能使锁发生错误;过长,客户端异常时可能会影响执行效率。
-
释放锁要及时
客户端使用完共享资源之后要及时的释放锁,即使在程序发生异常,Java 中一般都是在
finally里释放锁。 -
只能释放自己加的锁
在释放锁的时要确保这个锁是自己的,不能将其他锁释放掉,这样可能导致多个客户端同时获取锁。可以通过判断 lock_value 的值是否相等来判断是否是自己加的锁,lock_value 的值可以使用 UUID 或者任意确定唯一的值。
-
释放锁要保证原子性
客户端在释放锁时分两个步骤,一要比较锁的值是否相等,二要删除锁(
DEL key),这两个步骤要保证原子性,否则的话可能导致将其他锁释放掉,画个图解释下:
- 客户端A 设置 lock_order 锁成功,锁值为123uD,超时间为10000ms。
- 客户端A 业务代码执行完成,释放锁前需要获取 lock_order 锁的值。
- 客户端A 判断锁值是否是123uD,执行缓慢。
- 客户端A 的锁超时时间已到,Redis 自动移除了锁。
- 此时客户端B 设置锁,lock_order 锁不存在,所以加锁成功。
- 客户端A 判断锁值相等,执行del 释放锁,此时客户端A 释放的锁是客户端B 的而不是自己的,锁出现错误。
3. Redis分布式锁的缺点
-
Redis分布式锁的缺点?
如果你对某个redis master实例,写入了myLock这种锁key的value,此时会异步复制给对应的master slave实例。但是这个过程中一旦发生redis master宕机,主备切换,redis slave变为了redis master。
接着就会导致,客户端2来尝试加锁的时候,在新的redis master上完成了加锁,而客户端1也以为自己成功加了锁。此时就会导致多个客户端对一个分布式锁完成了加锁。这时系统在业务语义上一定会出现问题,导致各种脏数据的产生。
所以这个就是redis cluster,或者是redis master-slave架构的主从异步复制导致的redis分布式锁的最大缺陷:在redis master实例宕机的时候,可能导致多个客户端同时完成加锁。
4. Redis集群
Redis集群最适合的方案是用虚拟槽的方法,
使用 分散度良好 的 哈希函数 把所有数据 映射 到一个 固定范围 的 整数集合 中,整数定义为 槽(slot)。这个范围一般 远远大于 节点数,比如 Redis Cluster 槽范围是 0 ~ 16383。槽 是集群内 数据管理 和 迁移 的 基本单位。采用 大范围槽 的主要目的是为了方便 数据拆分 和 集群扩展。
当前集群有 5 个节点,每个节点平均大约负责 3276 个 槽。由于采用 高质量 的 哈希算法,每个槽所映射的数据通常比较 均匀,将数据平均划分到 5 个节点进行 数据分区。Redis Cluster 就是采用 虚拟槽分区。
- 节点1: 包含
0到3276号哈希槽。 - 节点2:包含
3277到6553号哈希槽。 - 节点3:包含
6554到9830号哈希槽。 - 节点4:包含
9831到13107号哈希槽。 - 节点5:包含
13108到16383号哈希槽。
这种结构很容易 添加 或者 删除 节点。如果 增加 一个节点 6,就需要从节点 1 ~ 5 获得部分 槽 分配到节点 6 上。如果想 移除 节点 1,需要将节点 1 中的 槽 移到节点 2 ~ 5 上,然后将 没有任何槽 的节点 1 从集群中 移除 即可。
由于从一个节点将 哈希槽 移动到另一个节点并不会 停止服务,所以无论 添加删除 或者 改变 某个节点的 哈希槽的数量 都不会造成 集群不可用 的状态.
5. 数据怎么计算存储到哪个槽
Redis Cluster 采用 虚拟槽分区,所有的 键 根据 哈希函数 映射到 0~16383 整数槽内,计算公式:slot = CRC16(key)& 16383。每个节点负责维护一部分槽以及槽所映射的 键值数据,如图所示:
6. Redis为什么这么快?
(1). 完全基于内存,绝大部分请求是纯粹的内存操作,非常快速。数据存在内存中,类似于HashMap,HashMap的优势就是查找和操作的时间复杂度都是O(1);
(2). 数据结构简单,对数据操作也简单,Redis中的数据结构是专门进行设计的;
(3). 采用单线程,避免了不必要的上下文切换和竞争条件,也不存在多进程或者多线程导致的切换而消耗 CPU,不用去考虑各种锁的问题,不存在加锁释放锁操作,没有因为可能出现死锁而导致的性能消耗;
(4).虚拟内存机制就是暂时把不经常访问的数据(冷数据)从内存交换到磁盘中,从而腾出宝贵的内存空间用于其它需要访问的数据(热数据)。通过VM功能可以实现冷热数据分离,使热数据仍在内存中、冷数据保存到磁盘。这样就可以避免因为内存不足而造成访问速度下降的问题。
(4).使用多路I/O复用模型,非阻塞IO;
I/O多路复用是指selector、poll、epoll,让单个进程可以监视多个文件描述符,一旦某个描述符就绪(一般是读就绪或写就绪),能够通知程序进行相应的读写操作。
7. Redis持久化方式
(1). RDB方式,Redis调用fork(),产生一个子进程。子进程把数据写到一个临时的RDB文件。当子进程写完新的RDB文件后,把旧的RDB文件替换掉。
(2). AOF方式AOF就可以做到全程持久化,只需要在配置文件中开启(默认是no),appendonly yes开启AOF之后,redis每执行一个修改数据的命令,都会把它添加到aof文件中,当redis重启时,将会读取AOF文件进行“重放”以恢复到 redis关闭前的最后时刻。
(3). 混合方式
AOF在进行文件重时将重写这一刻之前的内存rdb快照文件的内容和增量的AOF修改 内存数据的命令日志文件存在一起,都写入新的aof文件,新的文件一开始不叫 appendonly.aof,等到重写完新的AOF文件才会进行改名,原子的覆盖原有的AOF 文件,完成新旧两个AOF文件的替换。
文件的前半部分储存的是 RDB 格式的数据, 而后半部分储存的则是 AOF 格式的数 据。
判断 aof 文件的前面部分是否为 rdb 格式,只需要判断前 5 个字符是否是 REDIS。这个是因为 rdb 持久化开头就是 REDIS, 同时 aof 命令开头一定不会是REDIS(命令开头都是 *)。
8. Redis的过期策略?
Redis 中数据过期策略采用定期删除+惰性删除策略
-
定期删除策略:Redis 启用一个定时器定时监视所有的 key,判断key是否过期,过期的话就删除。这种策略可以保证过期的 key 最终都会被删除,但是也存在严重的缺点:每次都遍历内存中所有的数据,非常消耗 CPU 资源,并且当 key 已过期,但是定时器还处于未唤起状态,这段时间内 key 仍然可以用。
-
惰性删除策略:在获取 key 时,先判断 key 是否过期,如果过期则删除。这种方式存在一个缺点:如果这个 key 一直未被使用,那么它一直在内存中,其实它已经过期了,会浪费大量的空间。
-
这两种策略天然的互补,结合起来之后,定时删除策略就发生了一些改变,不在是每次扫描全部的 key 了,而是随机抽取一部分 key 进行检查,这样就降低了对 CPU 资源的损耗,惰性删除策略互补了为检查到的key,基本上满足了所有要求。但是有时候就是那么的巧,既没有被定时器抽取到,又没有被使用,这些数据又如何从内存中消失?没关系,还有内存淘汰机制,当内存不够用时,内存淘汰机制就会上场。淘汰策略分为:
可以细分为「在设置了过期时间的数据中进行淘汰」和「在所有数据范围内进行淘汰」这两类策略。 在设置了过期时间的数据中进行淘汰:
- volatile-random:随机淘汰设置了过期时间的任意键值;
- volatile-ttl:优先淘汰更早过期的键值。
- volatile-lru(Redis3.0 之前,默认的内存淘汰策略):淘汰所有设置了过期时间的键值中,最久未使用的键值;
- volatile-lfu(Redis 4.0 后新增的内存淘汰策略):淘汰所有设置了过期时间的键值中,最少使用的键值;
在所有数据范围内进行淘汰:
- allkeys-random:随机淘汰任意键值;
- allkeys-lru:淘汰整个键值中最久未使用的键值;
- allkeys-lfu(Redis 4.0 后新增的内存淘汰策略):淘汰整个键值中最少使用的键值。
9. LRU
(1).Java中LRU
LRU:Least Recently Used 最近最少使用
这个算法的思想就是:如果一个数据在最近一段时间没有被访问到,那么在将来它被访问的可能性也很小。所以,当指定的空间已存满数据时,应当把最久没有被访问到的数据淘汰。
LRU 存储是基于双向链表实现的,其中 head 代表双向链表的表头,tail 代表尾部。首先预先设置 LRU 的容量为3,如果存储满了,可以通过 O(1) 的时间淘汰掉双向链表的尾部,每次新增和访问数据,都可以通过 O(1)的效率把新的节点增加到对头,或者把已经存在的节点移动到队头。
save("key1", 7)
save("key2", 0)
save("key3", 1)
save("key4", 2)
get("key2")
save("key5", 3)
get("key2")
save("key6", 4)
- save(key, value),首先在 HashMap 找到 Key 对应的节点,如果节点存在,更新节点的值,并把这个节点移动队头。如果不存在,需要构造新的节点,并且尝试把节点塞到队头,如果LRU空间不足,则通过 tail 淘汰掉队尾的节点,同时在 HashMap 中移除 Key。
- get(key),通过 HashMap 找到 LRU 链表节点,因为根据LRU 原理,这个节点是最新访问的,所以要把节点插入到队头,然后返回缓存的值。 完整基于 Java 的代码参考如下
class DLinkedNode {
String key;
int value;
DLinkedNode pre;
DLinkedNode post;
}
LRU Cache
public class LRUCache {
private Hashtable<Integer, DLinkedNode>
cache = new Hashtable<Integer, DLinkedNode>();
private int count;
private int capacity;
private DLinkedNode head, tail;
public LRUCache(int capacity) {
this.count = 0;
this.capacity = capacity;
head = new DLinkedNode();
head.pre = null;
tail = new DLinkedNode();
tail.post = null;
head.post = tail;
tail.pre = head;
}
public int get(String key) {
DLinkedNode node = cache.get(key);
if(node == null){
return -1; // should raise exception here.
}
// move the accessed node to the head;
this.moveToHead(node);
return node.value;
}
public void set(String key, int value) {
DLinkedNode node = cache.get(key);
if(node == null){
DLinkedNode newNode = new DLinkedNode();
newNode.key = key;
newNode.value = value;
this.cache.put(key, newNode);
this.addNode(newNode);
++count;
if(count > capacity){
// pop the tail
DLinkedNode tail = this.popTail();
this.cache.remove(tail.key);
--count;
}
}else{
// update the value.
node.value = value;
this.moveToHead(node);
}
}
/**
* Always add the new node right after head;
*/
private void addNode(DLinkedNode node){
node.pre = head;
node.post = head.post;
head.post.pre = node;
head.post = node;
}
/**
* Remove an existing node from the linked list.
*/
private void removeNode(DLinkedNode node){
DLinkedNode pre = node.pre;
DLinkedNode post = node.post;
pre.post = post;
post.pre = pre;
}
/**
* Move certain node in between to the head.
*/
private void moveToHead(DLinkedNode node){
this.removeNode(node);
this.addNode(node);
}
// pop the current tail.
private DLinkedNode popTail(){
DLinkedNode res = tail.pre;
this.removeNode(res);
return res;
}
}
(2). Redis中LRU
如果按照HashMap和双向链表实现,需要额外的存储存放 next 和 prev 指针,牺牲比较大的存储空间,显然是不划算的。所以Redis采用了一个近似的做法,就是随机取出若干个key,然后按照访问时间排序后,淘汰掉最不经常使用的
Redis会基于server.maxmemory_samples配置选取固定数目的key,然后比较它们的lru访问时间,然后淘汰最近最久没有访问的key,maxmemory_samples的值越大,Redis的近似LRU算法就越接近于严格LRU算法,但是相应消耗也变高,对性能有一定影响,样本值默认为5。
volatile-lru设置了过期时间的key参与近似的lru淘汰策略allkeys-lru所有的key均参与近似的lru淘汰策略
10.缓存雪崩的解决策略?
由于原有缓存失效,新缓存未到期间,所有原本应该访问缓存的请求都去查询数据库了,而对数据库CPU和内存造成巨大压力,严重的会造成数据库宕机。从而形成一系列连锁反应,造成整个系统崩溃。
解决办法:
(1).设置缓存不过期: 我们可以通过后台服务来更新缓存数据,从而避免因为缓存失效造成的缓存雪崩,也可以在一定程度上避免缓存并发问题。
(2).缓存时间不一致,给缓存的失效时间,加上一个随机值,避免集体失效
11.缓存击穿的解决策略?
如果缓存中的某个热点数据过期了,此时大量的请求访问了该热点数据,就无法从缓存中读取,直接访问数据库,数据库很容易就被高并发的请求冲垮,这就是缓存击穿的问题。
解决办法:
(1).互斥锁方案,保证同一时间只有一个业务线程更新缓存,未能获取互斥锁的请求,要么等待锁释放后重新读取缓存,要么就返回空值或者默认值。
(2).不给热点数据设置过期时间,由后台异步更新缓存,或者在热点数据准备要过期前,提前通知后台线程更新缓存以及重新设置过期时间;
12.缓存穿透的解决策略?
缓存穿透是指用户查询数据,在数据库没有,自然在缓存中也不会有。这样就导致用户查询的时候,在缓存中找不到,每次都要去数据库再查询一遍,然后返回空(相当于进行了两次无用的查询)。这样请求就绕过缓存直接查数据库。
解决办法:
(1).存null值(缓存击穿加锁)
(2).布隆过滤器拦截: 将所有可能的查询key 先映射到布隆过滤器中,查询时先判断key是否存在布隆过滤器中,存在才继续向下执行,如果不存在,则直接返回。布隆过滤器将值进行多次哈希bit存储,布隆过滤器说某个元素在,可能会被误判。布隆过滤器说某个元素不在,那么一定不在。布隆过滤器引用
布隆过滤器:布隆过滤器是一个很长的二进制向量和一系列随机映射函数,二进制存储的数据不是0就是1,默认是0。 时间复杂度是O(K) 引用
存入过程
主要用于判断一个元素是否在一个集合中,0代表不存在某个数据,1代表存在某个数据。,就是一个二进制数据的集合。当一个数据加入这个集合时,经历如下洗礼:
如果我们要映射一个值到布隆过滤器中,我们需要使用多个不同的哈希函数生成多个哈希值, 并对每个生成的哈希值指向的 bit 位置 1
查询过程 查询过程也是使用多个不同的哈希函数生成多个哈希值,然后去看存储的位置上的数字是0还是1,如果都是1代表可能存在(并不一定存在),如果有一个0代表不存在
如果我们要映射一个值到布隆过滤器中,我们要使用 hash 算法,对 key 生成多个哈希值,生成的哈希值与布隆过滤器的位数组下标相对应,并将位从 0 更改为 1
假设现在有 3 个 key:分表为 a, b, c
将 a,b 映射到过滤器中,此时过滤器的状态如下:
当设置了过滤器,我们在查询 key 的时候首先会以同样的方法计算出 key 的哈希值,然后在过滤器中从查找,这样 a 的哈希值 1 4 6 对应的位都是 1 ,表明这个 key 是可能存在的,可以查询缓存或者数据库,而 c 的哈希值 3 6 7 所对应的位为有两个是 0,表明这个 key 是绝对不存在的,这时就可以直接返回结果给客户端,避免了空值查询数据库。
值得注意的是,固定长度的过滤器 key 存储的越多,hash 碰撞的几率就越大,越容易产生误判,比如说 c 哈希的值映射到位上碰巧都是1,那么即使 c 不存在,也有可能被误判存在。因此设置合适的数组长度也是需要考虑的,数组越大,误判的几率越小。
13.缓存更新策略(一般使用旁路缓存策略)
写策略的步骤: 先更新数据库中的数据,再删除缓存中的数据。
读策略的步骤:
- 如果读取的数据命中了缓存,则直接返回数据;
- 如果读取的数据没有命中缓存,则从数据库中读取数据,然后将数据写入到缓存,并且返回给用户。
MySQL
一、执行一条MySQL发生了什么?
(1).客户端和连接器进行连接
(2).查询缓存,如果命中直接返回,如果没命中走分析器(语法分析)
(3).走优化器(执行计划生成,索引选择)
(4).去存储引擎读取数据
二、索引
1.索引的类型
(1).索引类型有B+树、主键索引、唯一索引、联合索引、前缀索引
2.索引失效是情况?
- 当我们使用左或者左右模糊匹配的时候,也就是
like %xx或者like %xx%这两种方式都会造成索引失效; - 当我们在查询条件中对索引列使用函数,就会导致索引失效。
- 当我们在查询条件中对索引列进行表达式计算,也是无法走索引的。
- MySQL 在遇到字符串和数字比较的时候,会自动把字符串转为数字,然后再进行比较。如果字符串是索引列,而条件语句中的输入参数是数字的话,那么索引列会发生隐式类型转换,由于隐式类型转换是通过 CAST 函数实现的,等同于对索引列使用了函数,所以就会导致索引失效。
- 联合索引要能正确使用需要遵循最左匹配原则,也就是按照最左优先的方式进行索引的匹配,否则就会导致索引失效。
- 在 WHERE 子句中,如果在 OR 前的条件列是索引列,而在 OR 后的条件列不是索引列,那么索引会失效。
3.MySQL的SQL执行计划
对于执行计划,参数有:
- possible_keys 字段表示可能用到的索引;
- key 字段表示实际用的索引,如果这一项为 NULL,说明没有使用索引;
- key_len 表示索引的长度;
- rows 表示扫描的数据行数。
- type 表示数据扫描类型,我们需要重点看这个。
type 字段就是描述了找到所需数据时使用的扫描方式是什么,常见扫描类型的执行效率从低到高的顺序为:
- All(全表扫描);
- index(全索引扫描);
- range(索引范围扫描);
- ref(非唯一索引扫描);
- eq_ref(唯一索引扫描);
- const(结果只有一条的主键或唯一索引扫描)。
all 是最坏的情况,因为采用了全表扫描的方式。index 和 all 差不多,只不过 index 对索引表进行全扫描
range 表示采用了索引范围扫描,一般在 where 子句中使用 < 、>、in、between 等关键词,只检索给定范围的行,属于范围查找。从这一级别开始,索引的作用会越来越明显,因此我们需要尽量让 SQL 查询可以使用到 range 这一级别及以上的 type 访问方式。
ref 类型表示采用了非唯一索引,或者是唯一索引的非唯一性前缀,返回数据返回可能是多条。因为虽然使用了索引,但该索引列的值并不唯一,有重复。这样即使使用索引快速查找到了第一条数据,仍然不能停止,要进行目标值附近的小范围扫描。但它的好处是它并不需要扫全表,因为索引是有序的,即便有重复值,也是在一个非常小的范围内扫描。
eq_ref 类型是使用主键或唯一索引时产生的访问方式,通常使用在多表联查中。比如,对两张表进行联查,关联条件是两张表的 user_id 相等,且 user_id 是唯一索引,那么使用 EXPLAIN 进行执行计划查看的时候,type 就会显示 eq_ref。
const 类型表示使用了主键或者唯一索引与常量值进行比较,比如 select name from product where id=1。
三、InnoDB是怎么存储数据的?
InnoDB 的数据是按「数据页」为单位来读写的,当需要读一条记录的时候,并不是将这个记录本身从磁盘读出来,而是以页为单位,将其整体读入内存。InnoDB 数据页的默认大小是 16KB结构如下
四、B+树索引 引用
1.B+ 树的特点:
- 只有叶子节点(最底层的节点)才存放了数据,非叶子节点(其他上层节点)仅用来存放目录项作为索引。
- 节点的子树数和关键字数相同(B 树是关键字数比子树数少一)
- 节点的关键字表示的是子树中的最大数,在子树中同样含有这个数据
- 叶子节点包含了全部数据,同时符合左小右大的顺序
我们再看看 B+ 树如何实现快速查找主键为 6 的记录,以上图为例子:
- 从根节点开始,通过二分法快速定位到符合页内范围包含查询值的页,因为查询的主键值为 6,在[1, 7)范围之间,所以到页 30 中查找更详细的目录项;
- 在非叶子节点(页30)中,继续通过二分法快速定位到符合页内范围包含查询值的页,主键值大于 5,所以就到叶子节点(页16)查找记录;
- 接着,在叶子节点(页16)中,通过槽查找记录时,使用二分法快速定位要查询的记录在哪个槽(哪个记录分组),定位到槽后,再遍历槽内的所有记录,找到主键为 6 的记录。
可以看到,在定位记录所在哪一个页时,也是通过二分法快速定位到包含该记录的页。定位到该页后,又会在该页内进行二分法快速定位记录所在的分组(槽号),最后在分组内进行遍历查找。
- InnoDB 的数据是按「数据页」为单位来读写的,默认数据页大小为 16 KB。每个数据页之间通过双向链表的形式组织起来,物理上不连续,但是逻辑上连续。
- 数据页内包含用户记录,每个记录之间用单向链表的方式组织起来,为了加快在数据页内高效查询记录,设计了一个页目录,页目录存储各个槽(分组),且主键值是有序,于是可以通过二分查找法的方式进行检索从而提高效率。
- 为了高效查询记录所在的数据页,InnoDB 采用 b+ 树作为索引,每个节点都是一个数据页。
- 如果叶子节点存储的是实际数据的就是聚簇索引,一个表只能有一个聚簇索引;如果叶子节点存储的不是实际数据,而是主键值则就是二级索引,一个表中可以有多个二级索引。
- 在使用二级索引进行查找数据时,如果查询的数据能在二级索引找到,那么就是「索引覆盖」操作,如果查询的数据不在二级索引里,就需要先在二级索引找到主键值,需要去聚簇索引中获得数据行,这个过程就叫作「回表」。
2.B-树和B+树的区别
- B-树内部节点是保存数据的;而B+树内部节点是不保存数据的,只作索引作用,它的叶子节点才保存数据。
- B+树相邻的叶子节点之间是通过链表指针连起来的,B-树却不是。
- 查找过程中,B-树在找到具体的数值以后就结束,而B+树则需要通过索引找到叶子结点中的数据才结束
3.为什么用B+树作为索引而不用B-树
- B树只适合随机检索,而B+树同时支持随机检索和顺序检索;
- B+树空间利用率更高,可减少I/O次数,磁盘读写代价更低。一般来说,索引本身也很大,不可能全部存储在内存中,因此索引往往以索引文件的形式存储的磁盘上。这样的话,索引查找过程中就要产生磁盘I/O消耗。B+树的内部结点并没有指向关键字具体信息的指针,只是作为索引使用,其内部结点比B树小,盘块能容纳的结点中关键字数量更多,一次性读入内存中可以查找的关键字也就越多,相对的,IO读写次数也就降低了。而IO读写次数是影响索引检索效率的最大因素;
- B+树的查询效率更加稳定。B树搜索有可能会在非叶子结点结束,越靠近根节点的记录查找时间越短,只要找到关键字即可确定记录的存在,其性能等价于在关键字全集内做一次二分查找。而在B+树中,顺序检索比较明显,随机检索时,任何关键字的查找都必须走一条从根节点到叶节点的路,所有关键字的查找路径长度相同,导致每一个关键字的查询效率相当。
- B-树在提高了磁盘IO性能的同时并没有解决元素遍历的效率低下的问题。B+树的叶子节点使用指针顺序连接在一起,只要遍历叶子节点就可以实现整棵树的遍历。而且在数据库中基于范围的查询是非常频繁的,而B树不支持这样的操作。
- 增删文件(节点)时,效率更高。因为B+树的叶子节点包含所有关键字,并以有序的链表结构存储,这样可很好提高增删效率。
五、事物
1.事物的特性
- 原子性(Atomicity) :一个事务中的所有操作,要么全部完成,要么全部不完成,不会结束在中间某个环节,而且事务在执行过程中发生错误,会被回滚到事务开始前的状态,就像这个事务从来没有执行过一样,就好比买一件商品,购买成功时,则给商家付了钱,商品到手;购买失败时,则商品在商家手中,消费者的钱也没花出去。
- 一致性(Consistency) :是指事务操作前和操作后,数据满足完整性约束,数据库保持一致性状态。比如,用户 A 和用户 B 在银行分别有 800 元和 600 元,总共 1400 元,用户 A 给用户 B 转账 200 元,分为两个步骤,从 A 的账户扣除 200 元和对 B 的账户增加 200 元。一致性就是要求上述步骤操作后,最后的结果是用户 A 还有 600 元,用户 B 有 800 元,总共 1400 元,而不会出现用户 A 扣除了 200 元,但用户 B 未增加的情况(该情况,用户 A 和 B 均为 600 元,总共 1200 元)。
- 隔离性(Isolation) :数据库允许多个并发事务同时对其数据进行读写和修改的能力,隔离性可以防止多个事务并发执行时由于交叉执行而导致数据的不一致,因为多个事务同时使用相同的数据时,不会相互干扰,每个事务都有一个完整的数据空间,对其他并发事务是隔离的。也就是说,消费者购买商品这个事务,是不影响其他消费者购买的。
- 持久性(Durability) :事务处理结束后,对数据的修改就是永久的,即便系统故障也不会丢失。
- 持久性是通过 redo log (重做日志)来保证的;
- 原子性是通过 undo log(回滚日志) 来保证的;
- 隔离性是通过 MVCC(多版本并发控制) 或锁机制来保证的;
- 一致性则是通过持久性+原子性+隔离性来保证;
2.事物的隔离级别
当多个事务并发执行时可能会遇到「脏读、不可重复读、幻读」的现象,这些现象会对事务的一致性产生不同程序的影响。
- 脏读:读到其他事务未提交的数据;
- 不可重复读:前后读取的数据不一致;
- 幻读:前后读取的记录数量不一致。 这三个现象的严重性排序如下:
SQL 标准提出了四种隔离级别来规避这些现象,隔离级别越高,性能效率就越低,这四个隔离级别如下:
- 读未提交(read uncommitted) ,指一个事务还没提交时,它做的变更就能被其他事务看到;
- 读提交(read committed) ,指一个事务提交之后,它做的变更才能被其他事务看到;
- 可重复读(repeatable read) ,指一个事务执行过程中看到的数据,一直跟这个事务启动时看到的数据是一致的,MySQL InnoDB 引擎的默认隔离级别;
- 串行化(serializable ) ;会对记录加上读写锁,在多个事务对这条记录进行读写操作时,如果发生了读写冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行;
按隔离水平高低排序如下:
针对不同的隔离级别,并发事务时可能发生的现象也会不同。
3.可重复读详解
**MySQL InnoDB 引擎的默认隔离级别虽然是「可重复读」,但是它很大程度上避免幻读现象(并不是完全解决了,解决的方案有两种:
-
针对快照读(普通 select 语句),是通过 MVCC 方式解决了幻读,因为可重复读隔离级别下,事务执行过程中看到的数据,一直跟这个事务启动时看到的数据是一致的,即使中途有其他事务插入了一条数据,是查询不出来这条数据的,所以就很好了避免幻读问题。
-
针对当前读(select ... for update 等语句),是通过 next-key lock(记录锁+间隙锁)方式解决了幻读,因为当执行 select ... for update 语句的时候,会加上 next-key lock,如果有其他事务在 next-key lock 锁范围内插入了一条记录,那么这个插入语句就会被阻塞,无法成功插入,所以就很好了避免幻读问题。
4.MVCC详解 引用 B站视频
MVCC(Multi-Version Concurrency Control)多版本并发控制,是用来在数据库中控制并发的方法,实现对数据库的并发访问用的。在MySQL中,MVCC只在读取已提交(Read Committed)和可重复读(Repeatable Read)两个事务级别下有效。其是通过Undo日志中的版本链和ReadView一致性视图来实现的。MVCC就是在多个事务同时存在时,SELECT语句找寻到具体是版本链上的哪个版本,然后在找到的版本上返回其中所记录的数据的过程。
六、锁
1.死锁
死锁的四个必要条件:互斥、占有且等待、不可强占用、循环等待。只要系统发生死锁,这些条件必然成立,但是只要破坏任意一个条件就死锁就不会成立。
在数据库层面,有两种策略通过「打破循环等待条件」来解除死锁状态:
-
设置事务等待锁的超时时间。当一个事务的等待时间超过该值后,就对这个事务进行回滚,于是锁就释放了,另一个事务就可以继续执行了。在 InnoDB 中,参数
innodb_lock_wait_timeout是用来设置超时时间的,默认值时 50 秒。 -
开启主动死锁检测。主动死锁检测在发现死锁后,主动回滚死锁链条中的某一个事务,让其他事务得以继续执行。将参数
innodb_deadlock_detect设置为 on,表示开启这个逻辑,默认就开启。
2.乐观锁和悲观锁
乐观锁是指在进行数据修改操作之前,先读取数据,并将该数据的版本号记录下来,然后进行修改操作。在修改数据时,如果该数据的版本号发生了变化,说明在读取数据和修改数据之间,该数据已经被其他线程修改过了,此时需要重新读取数据并重新进行修改。乐观锁通常适用于并发更新冲突比较少的场景。数据库实现方式版本号,Java实现方式CAS
悲观锁是指在进行数据修改操作之前,先对数据进行加锁,以防止其他线程对该数据进行修改。在MySQL中,悲观锁可以通过使用FOR UPDATE语句来实现,该语句会对查询结果中的数据进行排他锁定,从而防止其他线程对该数据进行修改。悲观锁通常适用于并发更新冲突比较多的场景。数据库实现方式for update,Java实现方式sync
七、日志 引用
1.MySQL日志有几种?
- undo log(回滚日志) :是 Innodb 存储引擎层生成的日志,实现了事务中的原子性,主要用于事务回滚和 MVCC。
- redo log(重做日志) :是 Innodb 存储引擎层生成的日志,实现了事务中的持久性,主要用于掉电等故障恢复;
- binlog (归档日志) :是 Server 层生成的日志,主要用于数据备份和主从复制;
binlog用于记录数据库执行的写入性操作(不包括查询)信息,以二进制的形式保存在磁盘中。binlog是mysql的逻辑日志,并且由Server层进行记录,使用任何存储引擎的mysql数据库都会记录binlog日志。binlog刷盘时机有三个参数0、1、n,1的意思就是每条binlog都刷盘,也是默认的值
- 逻辑日志: 可以简单理解为记录的就是sql语句 。
- 物理日志:
mysql数据最终是保存在数据页中的,物理日志记录的就是数据页变更 。
binlog是通过追加的方式进行写入的,可以通过max_binlog_size参数设置每个binlog文件的大小,当文件大小达到给定值之后,会生成新的文件来保存日志。
2. redolog和binlog
- redo log 是InnoDB 引擎特有的;而 binlog 是MySQL Server 层实现的
- redo log 是物理日志,记录的是“在某个数据页做了什么修改”;而 binlog 是逻辑日志,记录的是语句的原始逻辑。比如
update T set c=c+1 where ID=2;这条SQL,redo log 中记录的是 :xx页号,xx偏移量的数据修改为xxx;binlog 中记录的是:id = 2 这一行的 c 字段 +1- redo log 是循环写的,固定空间会用完;binlog 可以追加写入,一个文件写满了会切换到下一个文件写,并不会覆盖之前的记录
- 记录内容时间不同,redo log 记录事务发起后的 DML 和 DDL语句;binlog 记录commit 完成后的 DML 语句和 DDL 语句
- 作用不同,redo log 作为异常宕机或者介质故障后的数据恢复使用;binlog 作为恢复数据使用,主从复制搭建。
3.undolog
数据库事务四大特性中有一个是原子性,具体来说就是原子性是指对数据库的一系列操作,要么全部成功,要么全部失败。实际上, 原子性底层就是通过undo log实现的。 undo log 主要记录了数据的逻辑变化,比如一条
INSERT语句,对应一条 DELETE 的 undo log ,对于每个 UPDATE 语句,对应一条相反的 UPDATE 的undo log ,这样在发生错误时,就能回滚到事务之前的数据状态。
八、主从复制 引用
1.主从复制过程
- 从库生成两个线程,一个 I/O 线程,一个 SQL 线程;
- I/O 线程去请求主库的 binlog,并将得到的 binlog 日志写到 relay log(中继日志) 文件中;
- 主库会生成一个 log dump 线程,用来给从库 I/O 线程传 binlog;
- SQL 线程会读取 relay log 文件中的日志,并解析成具体操作,来实现主从的操作一致,而最终数据一致;
九、分库分表
1.分表可以采用垂直拆分和水平拆分
①.水平拆分:一个表数据量过分可以按照水平拆分 ②.垂直拆分:一个表字段过多可以按照垂直拆分,把常用的热字段提取出来单独组成一个表
2.MySQL集群解决方案 引用
一致性hash
一致性hash就是一个虚拟圆环上,按照顺时针分布的,起点是0,终点是2^32-1,然后,我们就可以使用哈希函数H计算值为key的数据在哈希环的具体位置h,根据h确定在环中的具体位置,从此位置沿顺时针滚动,遇到的第一台服务器就是其应该定位到的服务器。
但是这样会有一个问题,就是服务器少的情况下数据分布不均匀,针对这个问题,我们可以通过引入虚拟节点来解决负载不均衡的问题。即将每台物理服务器虚拟为一组虚拟服务器,将虚拟服务器放置到哈希环上,如果要确定对象的服务器,需先确定对象的虚拟服务器,再由虚拟服务器确定物理服务器。
MQ
一、kafka
1.kafka有哪些组成?
- kafka将消息以topic为单位进行归纳
- 将向 Kafka topic 发布消息的程序成为 producers.
- 将预订 topics 并消费消息的程序成为 consumer.
- Kafka 以集群的方式运行,可以由一个或多个服务组成,每个服务叫做一个 broker.
- Partition:Partition 是Topic在物理上的分区,一个Topic可以分为多个Partition,每个Partition是一个有序的不可变的记录序列。单一主题中的分区有序,但无法保证主题中所有分区的消息有序。
2.kafka有哪些特点?
- 高吞吐量、低延迟:kafka每秒可以处理几十万条消息,它的延迟最低只有几毫秒
- 可扩展性:kafka集群支持热扩展
- 持久性、可靠性:消息被持久化到本地磁盘,并且支持数据备份防止数据丢失
- 容错性:允许集群中节点失败(若副本数量为n,则允许n-1个节点失败)
- 高并发:支持数千个客户端同时读写
3.kafka的应用场景?
- 日志聚合:可收集各种服务的日志写入kafka的消息队列进行存储
- 消息系统:广泛用于消息中间件
- 系统解耦:在重要操作完成后,发送消息,由别的服务系统来完成其他操作
- 流量削峰:一般用于秒杀或抢购活动中,来缓冲网站短时间内高流量带来的压力
- 异步处理:通过异步处理机制,可以把一个消息放入队列中,但不立即处理它,在需要的时候再进行处理
4.kafka获取消息的模式?
kafka通过pull的方式获取消息,如果采用 Push 模式,则Consumer难以处理不同速率的上游推送消息。
采用 Pull 模式的好处是Consumer可以自主决定是否批量的从Broker拉取数据。Pull模式有个缺点是,如果Broker没有可供消费的消息,将导致Consumer不断在循环中轮询,直到新消息到达。为了避免这点,Kafka有个参数可以让Consumer阻塞直到新消息到达。
5.Consumer 如何消费指定分区消息
Cosumer 消费消息时,想Broker 发出
fetch请求去消费特定分区的消息,Consumer 可以通过指定消息在日志中的偏移量 offset,就可以从这个位置开始消息消息,Consumer 拥有了 offset 的控制权,也可以向后回滚去重新消费之前的消息。
6. Kafka 是如何做到消息的有序性?
可以通过自定义分区的策略,将满足指定规则的数据存储在同一个partion中,从而实现有序:
- (1),比如,同一个订单的不同状态的消息存储在同一个分区中
- (2),或者,同一个登录的用户的各类操作存储在同一个分区中
7. 如何判断一个 Broker 是否还有效
- Broker必须可以维护和ZooKeeper的连接,Zookeeper通过心跳机制检查每个结点的连接。
- 如果Broker是个Follower,它必须能及时同步Leader的写操作,延时不能太久。
8. Kafka消息丢失/不一致的问题
分析可能出现消息丢失的几种情况:
- (1),消息队列本身:在写数据的过程中,我们如果只保证写入Leader节点,而不管副本是否同步成功就算写入成功的话,这种情况下是存在单点故障的,即如果Leader节点挂了那么就会出现丢失数据的情况;
- (2),生产者:由于网络的延迟,导致数据出现发送失败情况,也可以理解为数据丢失的一种情况;
- (3),消费者:使用自动提交Offset的方式,会出现数据在处理完成之前就把Offset提交了,这样也会出现数据丢失的情况;
消息发送时 kafka有一个acks,设置成1或者all
acks=0,表示生产者在成功写入消息之前不会等待任何来自服务器的响应. 适用于很高的吞吐量。
acks=1,表示只要集群的leader分区副本接收到了消息,就会向生产者发送一个成功响应的ack,此时生产者接收到ack之后就可以认为该消息是写入成功的.
acks =all,表示只有所有参与复制的节点(ISR列表的副本)全部收到消息时,生产者才会接收到来自服务器的响应. 该模式的延迟会很高.
从Broker的角度考虑: 保证消费者消费到数据之后,再删除Broker中的暂存的信息。 如果是kafka的话,在Broker层面,使用到了
ISR列表 + HW高水位 + Leader Epoch来防止数据丢失。
消息消费时 关闭自动提交,根据回调函数合理处理消息,并手动提交Offset。
9. 如何保证消息不重复消费/消息幂等的思路 引用
(1)比如,你拿到这个消息做数据库的insert操作,给这个消息做一个唯一的主键,那么就算出现重复消费的情况,就会导致主键冲突,避免数据库出现脏数据。
(2)如果拿到这个消息做redis的set的操作,那就容易了,不用解决,因为你无论set几次结果都是一样的,set操作本来就算幂等操作。
(3)如果上面两种情况还不行,上大招。准备一个第三方介质,来做消费记录。以redis为例,给消息分配一个全局id,只要消费过该消息,将以K-V形式写入redis.那消费者开始消费前,先去redis中查询有没有消费记录即可。
SpringCloud和Dubbo
一、SpringCloud和dubbo区别
1.SpringCloud使用的是Eureka当注册中心,Eureka是AP,Dubbo使用的zookeeper,zookeeper是CP。
CAP理论:
①C:Consistency,一致性,数据一致更新,所有数据变动都是同步的。
②A:Availability,可用性,系统具有好的响应性能。
③P:Partition tolerance,分区容错性。
2.dubbo 采用的是传输层 tcp 协议,是二进制传输的,占用带宽较少,序列化采用的是 jdk 自带的序列化协议。springcloud 是应用层 http 协议,占用带宽比较多,同时 springcloud 采用的是 json 报文传输,消耗会比较大。
3.dubbo 调用使用的是长链接,适合传输数据量小的包,而对于 springcloud 是短连接,适合传输大数据量的信息,比如图片、文本之类的。
二、SpringCloud介绍
1.组件介绍
- Eureka:各个服务启动时,Eureka Client都会将服务注册到Eureka Server,并且Eureka Client还可以反过来从Eureka Server拉取注册表,从而知道其他服务在哪里
- Ribbon:服务间发起请求的时候,基于Ribbon做负载均衡,从一个服务的多台机器中选择一台,采用轮询的方式(负载均衡策略:轮询、权重、随机、最小连接数)
Ribbon是和Feign以及Eureka紧密协作,完成工作的,具体如下:
-
- 首先Ribbon会从 Eureka Client里获取到对应的服务注册表,也就知道了所有的服务都部署在了哪些机器上,在监听哪些端口号。
- 然后Ribbon就可以使用默认的Round Robin算法,从中选择一台机器
- Feign就会针对这台机器,构造并发起请求。
-
Feign:基于Feign的动态代理机制,根据注解和选择的机器,拼接请求URL地址,发起请求。Feign的一个关键机制就是使用了动态代理。
- 首先,如果你对某个接口定义了@FeignClient注解,Feign就会针对这个接口创建一个动态代理
- 接着你要是调用那个接口,本质就是会调用 Feign创建的动态代理,这是核心中的核心
- Feign的动态代理会根据你在接口上的@RequestMapping等注解,来动态构造出你要请求的服务的地址
- 最后针对这个地址,发起请求、解析响应
-
Hystrix:发起请求是通过Hystrix的线程池来走的,不同的服务走不同的线程池,实现了不同服务调用的隔离,避免了服务雪崩的问题
-
Zuul:如果前端、移动端要调用后端系统,统一从Zuul网关进入,由Zuul网关转发请求给对应的服务
2.服务是怎么治理的?
服务治理主要包括性能监控、日志处理、审计统计、限流熔断、扩缩容等内容,常用治理思路分为微服务层解决方案(如Spring Cloud)、网格计算解决方案(如Istio)和容器化解决方案(如Docker+K8S);
三、Zookeeper
1.Zookeeper的选举机制(zk的数据一致性核心算法paxos)引用
比如有三个节点,zk1,zk2,zk3
zk会进行多轮的投票,直到某一个节点的票数大于或等于半数以上,在3个节点中,总共会进行2轮的投票:
- 第一轮,每个节点启动时投票给自己,那这样zk1,zk2,zk3各有一票。
- 第二轮,每个节点投票给大于自己myid,那这样zk2启动时又获得一票。加上自己给自己投的那一票。总共有2票。2票大于了当前节点总数的半数,所以投票终止。zk2当选leader。
有的童鞋会问,zk3呢,因为zk2已经当选了,投票终止了。所以zk2也不会投票给zk3了
2.Zookeeper的同步机制
假设还是有一个3个节点的集群,zk2为Leader,这时候如果zk2挂了。zk3当选Leader,zk1为Follower。这时候如果更新集群中的一个数据。然后把zk1和zk3都关闭。然后挨个再重启zk1,zk2,zk3。这时候启动后,zk2还能当选为Leader吗? 其实这个问题,换句话说就是:在挨个启动zk节点的时候,zk1和zk3的数据为最新,而zk2的数据不是最新的,按照之前的选举规则的话,zk2是否能顺利当选Leader?
答案为否,最后当选的为zk1。
因为zk2的最新ZXID已经不是最新了,zk的选举过程会优先考虑ZXID大的节点。这时ZXID最大的有zk1和zk3,选举只会在这2个节点中产生,根据之前说的选举规则。在第一轮投票的时候,zk1只要获得1票,就能达到半数了,就能顺利当选为Leader了。
3.Zookeeper的分布式锁 引用
- 客户端连接zk并且用zk创建一个临时有序节点,第一个节点0001,第二个0002
- 客户端连接获取所有的临时有序节点,判断自己创建的节点是不是编号最小的,如果是最小的则认为已经获取了锁,如果不是最小的监听前一个节点
- 前一个节点执行完业务代码之后删除临时节点并且通知后续节点
4.zk的羊群效应
ZooKeeper这种首尾相接,后面监听前面的方式,可以避免羊群效应。所谓羊群效应就是一个节点挂掉,所有节点都去监听,然后做出反应,这样会给服务器带来巨大压力,所以有了临时顺序节点,当一个节点挂掉,只有它后面的那一个节点才做出反应。
Linux
一、CPU满了
CPU满了的常见原因
- 代码中某个位置读取数据量较大,导致系统内存耗尽,从而导致Full GC次数过多,系统缓慢;
- 代码中有比较耗CPU的操作,导致CPU过高,系统运行缓慢; 相对来说,这是出现频率最高的两种线上问题,而且它们会直接导致系统不可用。
排查方法
- 首先我们可以使用top命令找到占用比较高的进程,命令:top,结果:进程id2732最高
- 用top -p 进程id查看所有线程信息,命令:top -p 2732,得到结果线程id2734最高
- 通过在线进制转化器转换把线程id2734转换成十六进制
- 查看进程堆栈信息,jstack 进程id,命令:jstack 2732
- 用得到的十六进制去搜索,之后找到nid=0x16进制的地方
二、Linux常用命令
ls、cd、pwd、mkdir、rm、cat、tail、top、free、grep、ps
网络
一、TCP? 引用
1.TCP和UDP的区别?
TCP 是一种面向连接的、可靠的、有序的协议,UDP 是一种无连接的、不可靠的、无序的协议。
2.TCP的三次握手
- 第一次握手(SYN=1, seq=x),发送完毕后,客户端就进入SYN_SEND状态(客户端向服务端发送连接请求报文段。该报文段中包含自身的数据通讯初始序号。请求发送后,客户端便进入 SYN-SENT 状态。)
- 第二次握手(SYN=1, ACK=1, seq=y, ACKnum=x+1), 发送完毕后,服务器端就进入SYN_RCV状态。(服务端收到连接请求报文段后,如果同意连接,则会发送一个应答,该应答中也会包含自身的数据通讯初始序号,发送完成后便进入 SYN-RECEIVED 状态。)
- 第三次握手(ACK=1,ACKnum=y+1),发送完毕后,客户端进入ESTABLISHED状态,当服务器端接收到这个包时,也进入ESTABLISHED状态。(当客户端收到连接同意的应答后,还要向服务端发送一个确认报文。客户端发完这个报文段后便进入 ESTABLISHED 状态,服务端收到这个应答后也进入 ESTABLISHED 状态,此时连接建立成功。)
3.为什么不能是两次和四次?
因为为了防止出现失效的连接请求报文段被服务端接收的情况,从而产生错误。四次就多余了。
4.TCP的四次挥手
-
第一次挥手(FIN=1,seq=u),发送完毕后,客户端进入FIN_WAIT_1状态。
-
第二次挥手(ACK=1,ack=u+1,seq =v),发送完毕后,服务器端进入CLOSE_WAIT状态,客户端接收到这个确认包之后,进入FIN_WAIT_2状态。
-
第三次挥手(FIN=1,ACK1,seq=w,ack=u+1),发送完毕后,服务器端进入LAST_ACK状态,等待来自客户端的最后一个ACK。
-
第四次挥手(ACK=1,seq=u+1,ack=w+1),客户端接收到来自服务器端的关闭请求,发送一个确认包,并进入TIME_WAIT状态,等待了某个固定时间(两个最大段生命周期,2MSL,2 Maximum Segment Lifetime)之后,没有收到服务器端的ACK ,认为服务器端已经正常关闭连接,于是自己也关闭连接,进入CLOSED状态。服务器端接收到这个确认包之后,关闭连接,进入CLOSED状态。
5.TCP四次挥手过程中,为什么需要等待2MSL,才进入CLOSED关闭状态
1.为了保证客户端发送的最后一个ACK报文段能够到达服务端。
2. 防止已失效的连接请求报文段出现在本连接中。
分布式
一、分布式ID
1.UUID
缺点
- 无序:无法预测他的生成顺序,不能生成递增有序的数字
- 主键:ID作为主键时在特定的环境下会存在一些问题,比如做DB主键的场景下,UUID非常不适用,MySQL官方有明确的建议主键要尽量越短越好,36位的UUID不合要求。
2.snowflake(雪花算法)
Snowflake ID组成结构:正数位(占1比特)+ 时间戳(占41比特)+ 机器ID(占5比特)+ 数据中心(占5比特)+ 自增值(占12比特),总共64比特组成的一个Long类型。
- 第一个bit位(1bit):Java中long的最高位是符号位代表正负,正数是0,负数是1,一般生成ID都为正数,所以默认为0。
- 时间戳部分(41bit):毫秒级的时间,不建议存当前时间戳,而是用(当前时间戳 - 固定开始时间戳)的差值,可以使产生的ID从更小的值开始;41位的时间戳可以使用69年,(1L << 41) / (1000L * 60 * 60 * 24 * 365) = 69年
- 工作机器id(10bit):也被叫做
workId,这个可以灵活配置,机房或者机器号组合都可以。 - 序列号部分(12bit),自增值支持同一毫秒内同一个节点可以生成4096个ID
3.基于数据库自增ID
解决方案:设置起始值和自增步长
MySQL_1 配置:
set @@auto_increment_offset = 1; -- 起始值
set @@auto_increment_increment = 2; -- 步长
MySQL_2 配置:
set @@auto_increment_offset = 2; -- 起始值
set @@auto_increment_increment = 2; -- 步长
这样两个MySQL实例的自增ID分别就是:
1、3、5、7、9 2、4、6、8、10
4.基于数据库号段模式
号段模式是当下分布式ID生成器的主流实现方式之一,号段模式可以理解为从数据库批量的获取自增ID,每次从数据库取出一个号段范围,例如 (1,1000] 代表1000个ID,具体的业务服务将本号段,生成1~1000的自增ID并加载到内存。表结构如下:
CREATE TABLE id_generator (
id int(10) NOT NULL,
max_id bigint(20) NOT NULL COMMENT '当前最大id',
step int(20) NOT NULL COMMENT '号段的布长',
biz_type int(20) NOT NULL COMMENT '业务类型',
version int(20) NOT NULL COMMENT '版本号',
PRIMARY KEY (`id`)
)
biz_type :代表不同业务类型
max_id :当前最大的可用id
step :代表号段的长度
version :是一个乐观锁,每次都更新version,保证并发时数据的正确性
等这批号段ID用完,再次向数据库申请新号段,对max_id字段做一次update操作,update max_id= max_id + step,update成功则说明新号段获取成功,新的号段范围是(max_id ,max_id +step]。
update id_generator set max_id = #{max_id+step}, version = version + 1 where version = # {version} and biz_type = XXX
由于多业务端可能同时操作,所以采用版本号version乐观锁方式更新,这种分布式ID生成方式不强依赖于数据库,不会频繁的访问数据库,对数据库的压力小很多。
5.基于Redis
Redis也同样可以实现,原理就是利用redis的 incr命令实现ID的原子性自增。
127.0.0.1:6379> set seq_id 1 // 初始化自增ID为1
OK
127.0.0.1:6379> incr seq_id // 增加1,并返回递增后的数值
(integer) 2
二、分布式锁
1.Redis
1.Set方法
SET key value [EX seconds][NX|XX] [GET]
- EX -- 设置指定的过期时间,以秒为单位。
- NX -- 仅当该键不存在的时才会设置该键。
- XX -- 仅当该键存在时才会设置该键。
加锁命令: SET lock_key lock_value PX 10000 NX
2.加锁注意事项
-
防死锁
、设置锁的超时时间要保持原子性,这点很容易做到 使用
SET lock_key lock_value PX 10000 NX命令即可, 不要使用SETNX lock_key lock_value,EXPIRE lock_key 10这些命令,因为他们之间不是原子性的,有发生死锁的风险。 -
合理设置锁超时时间
锁的超时时间要大于程序执行的时间,否则多个客户端可能同时获取锁。充分预估使用锁的业务代码执行时间,该时间不宜过长也不宜过短,过短,可能使锁发生错误;过长,客户端异常时可能会影响执行效率。
-
释放锁要及时
客户端使用完共享资源之后要及时的释放锁,即使在程序发生异常,Java 中一般都是在
finally里释放锁。 -
只能释放自己加的锁
在释放锁的时要确保这个锁是自己的,不能将其他锁释放掉,这样可能导致多个客户端同时获取锁。可以通过判断 lock_value 的值是否相等来判断是否是自己加的锁,lock_value 的值可以使用 UUID 或者任意确定唯一的值。
-
释放锁要保证原子性
客户端在释放锁时分两个步骤,一要比较锁的值是否相等,二要删除锁(
DEL key),这两个步骤要保证原子性,否则的话可能导致将其他锁释放掉,画个图解释下:
- 客户端A 设置 lock_order 锁成功,锁值为123uD,超时间为10000ms。
- 客户端A 业务代码执行完成,释放锁前需要获取 lock_order 锁的值。
- 客户端A 判断锁值是否是123uD,执行缓慢。
- 客户端A 的锁超时时间已到,Redis 自动移除了锁。
- 此时客户端B 设置锁,lock_order 锁不存在,所以加锁成功。
- 客户端A 判断锁值相等,执行del 释放锁,此时客户端A 释放的锁是客户端B 的而不是自己的,锁出现错误。
3.Redis分布式锁的缺点
-
Redis分布式锁的缺点?
如果你对某个redis master实例,写入了myLock这种锁key的value,此时会异步复制给对应的master slave实例。但是这个过程中一旦发生redis master宕机,主备切换,redis slave变为了redis master。
接着就会导致,客户端2来尝试加锁的时候,在新的redis master上完成了加锁,而客户端1也以为自己成功加了锁。此时就会导致多个客户端对一个分布式锁完成了加锁。这时系统在业务语义上一定会出现问题,导致各种脏数据的产生。
所以这个就是redis cluster,或者是redis master-slave架构的主从异步复制导致的redis分布式锁的最大缺陷:在redis master实例宕机的时候,可能导致多个客户端同时完成加锁。
2.zookeeper
Zookeeper的分布式锁 引用
- 客户端连接zk并且用zk创建一个临时有序节点,第一个节点0001,第二个0002
- 客户端连接获取所有的临时有序节点,判断自己创建的节点是不是编号最小的,如果是最小的则认为已经获取了锁,如果不是最小的监听前一个节点
- 前一个节点执行完业务代码之后删除临时节点并且通知后续节点
zk的羊群效应
ZooKeeper这种首尾相接,后面监听前面的方式,可以避免羊群效应。所谓羊群效应就是一个节点挂掉,所有节点都去监听,然后做出反应,这样会给服务器带来巨大压力,所以有了临时顺序节点,当一个节点挂掉,只有它后面的那一个节点才做出反应。
三、分布式事物
1.两阶段提交/XA
第一阶段(prepare):事务管理器向所有本地资源管理器发起请求,询问是否是 ready 状态,所有参与者都将本事务能否成功的信息反馈发给协调者;
第二阶段 (commit/rollback):事务管理器根据所有本地资源管理器的反馈,通知所有本地资源管理器,步调一致地在所有分支上提交或者回滚。
缺点:
- 同步阻塞:当参与事务者存在占用公共资源的情况,其中一个占用了资源,其他事务参与者就只能阻塞等待资源释放,处于阻塞状态。
- 单点故障:一旦事务管理器出现故障,整个系统不可用
- 数据不一致:在阶段二,如果事务管理器只发送了部分 commit 消息,此时网络发生异常,那么只有部分参与者接收到 commit 消息,也就是说只有部分参与者提交了事务,使得系统数据不一致。
2.TCC
TCC(Try Confirm Cancel)
- Try 阶段:尝试执行,完成所有业务检查(一致性), 预留必须业务资源(准隔离性)
- Confirm 阶段:确认执行真正执行业务,不作任何业务检查,只使用 Try 阶段预留的业务资源,Confirm 操作满足幂等性。要求具备幂等设计,Confirm 失败后需要进行重试。
- Cancel 阶段:取消执行,释放 Try 阶段预留的业务资源 Cancel 操作满足幂等性 Cancel 阶段的异常和 Confirm 阶段异常处理方案基本上一致。
TCC的Confirm/Cancel阶段在业务逻辑上是不允许返回失败的,如果因为网络或者其他临时故障,导致不能返回成功,TM会不断的重试,直到Confirm/Cancel返回成功。
3.本地消息表
4.最大努力通知
最大努力通知是最简单的一种柔性事务,适用于一些最终一致性时间敏感度低的业务,且被动方处理结果 不影响主动方的处理结果。
这个方案的大致意思就是:
- 系统 A 本地事务执行完之后,发送个消息到 MQ;
- 这里会有个专门消费 MQ 的服务,这个服务会消费 MQ 并调用系统 B 的接口;
- 要是系统 B 执行成功就 ok 了;要是系统 B 执行失败了,那么最大努力通知服务就定时尝试重新调用系统 B, 反复 N 次,最后还是不行就放弃。
四、单机限流和分布式限流
1.单机限流
1.令牌桶
令牌桶算法的原理是系统会以一个恒定的速度往桶里放入令牌,而如果请求需要被处理,则需要先从桶里获取一个令牌,当桶里没有令牌可取时,则拒绝服务。
适用场景: 适合电商抢购或者微博出现热点事件这种场景,因为在限流的同时可以应对一定的突发流量。如果采用漏桶那样的均匀速度处理请求的算法,在发生热点时间的时候,会造成大量的用户无法访问,对用户体验的损害比较大。
优点:
- 放过的流量比较均匀,有利于保护系统。
- 存量令牌能应对突发流量,很多时候,我们希望能放过脉冲流量。而对于持续的高流量,后面又能均匀地放过不超过限流值的请求数。
缺点:
- 存量令牌没有过期时间,突发流量时第一个周期会多放过一些请求,可解释性差。即在突发流量的第一个周期,默认最多会放过 2 倍限流值的请求数。
- 实际限流数难以预知,跟请求数和流量分布有关。
2.漏桶
适用场景:
漏桶算法是流量最均匀的限流实现方式,一般用于流量“整形”。例如保护数据库的限流,先把对数据库的访问加入到木桶中,worker再以db能够承受的qps从木桶中取出请求,去访问数据库。
问题:木桶流入请求的速率是不固定的,但是流出的速率是恒定的。这样的话能保护系统资源不被打满,但是面对突发流量时会有大量请求失败,不适合电商抢购和微博出现热点事件等场景的限流。
令牌桶和漏桶对比:
- 令牌桶是按照固定速率往桶中添加令牌,请求是否被处理需要看桶中令牌是否足够,当令牌数减为零时则拒绝新的请求;
- 漏桶则是按照常量固定速率流出请求,流入请求速率任意,当流入的请求数累积到漏桶容量时,则新流入的请求被拒绝;
- 令牌桶限制的是平均流入速率(允许突发请求,只要有令牌就可以处理,支持一次拿3个令牌,4个令牌),并允许一定程度突发流量;
- 漏桶限制的是常量流出速率(即流出速率是一个固定常量值,比如都是1的速率流出,而不能一次是1,下次又是2),从而平滑突发流入速率;
- 令牌桶允许一定程度的突发,而漏桶主要目的是平滑流入速率;
3.接口限流 - 平滑限流接口的请求数
Guava的RateLimiter提供的令牌桶算法,创建了容量为5的桶,并且每秒新增5个令牌,即每200ms新增一个令牌
4.滑动窗口限流 引用
将一秒分为了100个格子,那么一个格子也就是10ms,每个格子记录的就是当时统计的QPS总数,每隔10ms进行校验,首先判断格子的数量是不是超过了100,超过了就代表时间窗口需要滑动了,那么将最开始的格子移除,同时判断第一个和最后一个格子的差值是不是超过了限定的QPS,来决定是否限流。