java后端研发经典面试题总结三

线程池参数

JDK1.5中引入了强大的concurrent包,其中最常用的莫过了线程池的实现。ThreadPoolExecutor(线程池执行器),它给我们带来了极大的方便,但同时,对于该线程池不恰当的设置也可能使其效率并不能达到预期的效果,甚至仅相当于或低于单线程的效率。

ThreadPoolExecutor类可设置的参数主要有:

     (1)corePoolSize  基本大小

核心线程数,核心线程会一直存活,即使没有任务需要处理。当线程数小于核心线程数时,即使现有的线程空闲,线程池也会优先创建新线程来处理任务,而不是直接交给现有的线程处理。

核心线程在allowCoreThreadTimeout被设置为true时会超时退出,默认情况下不会退出。

(2)maxPoolSize 最大大小

当线程数大于或等于核心线程corePoolSize,且任务队列已满时,线程池会创建新的线程,直到线程数量达到maxPoolSize。如果线程数已等于maxPoolSize,且任务队列已满,则已超出线程池的处理能力,线程池会拒绝处理任务而抛出异常。

keepAliveTime 大于coolPoolSize的 会退出

当线程空闲时间达到keepAliveTime,该线程会退出,直到线程数量等于corePoolSize。如果allowCoreThreadTimeout设置为true,则所有线程均会退出直到线程数量为0。

allowCoreThreadTimeout 是否退出核心线程

是否允许核心线程空闲退出,默认值为false。

queueCapacity

任务队列容量。从maxPoolSize的描述上可以看出,任务队列的容量会影响到线程的变化,因此任务队列的长度也需要恰当的设置。

线程池按以下行为执行任务

当线程数小于核心线程数时,创建线程。

当线程数大于等于核心线程数,且任务队列未满时,将任务放入任务队列。

当线程数大于等于核心线程数,且任务队列已满

若线程数小于最大线程数,创建线程

若线程数等于最大线程数,抛出异常,拒绝任务

系统负载

参数的设置跟系统的负载有直接的关系,下面为系统负载的相关参数:

tasks,每秒需要处理的最大任务数量

tasktime,处理每个任务所需要的时间

responsetime,系统允许任务最大的响应时间,比如每个任务的响应时间不得超过2秒。

参数设置

corePoolSize:

每个任务需要tasktime秒处理,则每个线程每钞可处理 1/tasktime个任务。系统每秒有tasks个任务需要处理,则需要的线程数为:tasks/(1/tasktime),即 taskstasktime个线程数。假设系统每秒任务数为100~1000,每个任务耗时0.1秒,则需要1000.1至1000*0.1,即 10~100个线程。那么corePoolSize应该设置为大于10,具体数字最好根据8020原则,即80%情况下系统每秒任务数,若系统80%的情 况下第秒任务数小于200,最多时为1000,则corePoolSize可设置为20。

queueCapacity:

任务队列的长度要根据核心线程数,以及系统对任务响应时间的要求有关。队列长度可以设置为(corePoolSize/tasktime)*responsetime: (20/0.1)*2=400,即队列长度可设置为400。

队列长度设置过大,会导致任务响应时间过长,切忌以下写法:

LinkedBlockingQueue queue = new LinkedBlockingQueue();

这实际上是将队列长度设置为Integer.MAX_VALUE,将会导致线程数量永远为corePoolSize,再也不会增加,当任务数量陡增时,任务响应时间也将随之陡增。

maxPoolSize:

当系统负载达到最大值时,核心线程数已无法按时处理完所有任务,这时 就需要增加线程。每秒200个任务需要20个线程,那么当每秒达到1000个任务时,则需要(1000-queueCapacity)* (20/200),即60个线程,可将maxPoolSize设置为60。

keepAliveTime:

线程数量只增加不减少也不行。当负载降低时,可减少线程数量,如果一个线程空闲时间达到keepAliveTiime,该线程就退出。默认情况下线程池最少会保持corePoolSize个线程。

allowCoreThreadTimeout:

默认情况下核心线程不会退出,可通过将该参数设置为true,让核心线程也退出。

以上关于线程数量的计算并没有考虑CPU的情况。若结合CPU的情况,比如,当线程数量达到50时,CPU达到100%,则将 maxPoolSize设置为60也不合适,此时若系统负载长时间维持在每秒1000个任务,则超出线程池处理能力,应设法降低每个任务的处理时间 (tasktime)。

使用ThreadLocal解决SimpleDateFormat,

privatestatic ThreadLocal threadLocal = new ThreadLocal() { @Override protected DateFormat initialValue() { returnnew SimpleDateFormat(“yyyy-MM-dd HH:mm:ss”); } };

或者推荐Joda-Time处理时间比较推荐

在java线程中有6个状态也就是Thread中的枚举类

NEW,RUNNABLE,WAITING,TIME_WAITING,BLOCKED,TERMINATED

应该在try{} catch{}中重新设置中断状态,因为发出中断异常被退出了。

java内存模型

jvm规范定了jvm内存模型来屏蔽掉各种操作系统,虚拟机实现厂商和硬件的内存访问差异,确保java程序在所有操作系统和平台上能够实现一次编写,到处运行的效果。

为什么IP协议也能够进行数据的不可靠传输,还需要Udp

1.我们需要端口号来实现应用程序间的区分

2.UDP校验和可以实现传输层的校验,虽然UDP协议不具备纠错能力,但是可以对出错的数据包进行丢弃,而**IP的校验只是在校验IP报头,而不是整个数据包,整个数据包的校验是在传输层完成的****,**如果出错了,就会把出错的数据包丢弃。这也就是为什么需要有传输层

进程通信中管道和共享内存谁的速度快?

1.管道通信方式的中间介质是文件,通常称这种文件为管道文件。两个进程利用管道进行通信,一个进程为写进程,另一个进程为读进程。写进程通过往管道文件中写入信息,读进程通过读端从管道文件中读取信息,这样的方式进行通信。

2.共享内存是最快的可用IPC形式,他通过把共享的内存空间映射到进程的地址空间,进程间的数据传递不在通过执行任何进入内核的系统调用,节约了时间。java内存模型就采用的是共享内存的方式。各个进程通过公共的内存区域进行通信。

java线程池shutdown和shutdownNow的区别?

Shutdown()方法

当线程池调用该方法时,线程池的状态则立刻变成SHUTDOWN状态。此时,则不能再往线程池中添加任何任务,否则将会抛出RejectedExecutionException(拒绝执行异常)异常。但是,此时线程池不会立刻退出,直到添加到线程池中的任务都已经处理完成,才会退出。

shutdownNow()方法

根据JDK文档描述,大致意思是:执行该方法,线程池的状态立刻变成STOP状态并试图停止所有正在执行的线程,不再处理还在池队列中等待的任务,当然,它会返回那些未执行的任务。

 它试图终止线程的方法是通过调用Thread.interrupt()方法来实现的,但是大家知道,这种方法的作用有限,如果线程中没有sleep 、wait、Condition、定时锁等应用, interrupt()方法是无法中断当前的线程的。所以,ShutdownNow()并不代表线程池就一定立即就能退出,它可能必须要等待所有正在执行的任务都执行完成了才能退出。

Integer的装箱和拆箱的基本原理

Integer包装类是Java最基本的语法糖优化,如果我们写一段程序,通过反编译就会看到,通过的是Integer.valueOf()或者Interger.intValue()来进行转换的。

Integer和int比较会自动拆箱,

当Integer和integer比较的时候是不会自动拆箱的,除非遇到了算术符,才会调用intValue()方法去拆箱。并且在Integer中的equals是不会处理数据类型转换关系的,使用时是需要慎用,在equals方法中判断为Integer类的才会执行真正的判断流程也就是拆箱去判断,所以不会处理数据类型转换比如Long。如果是基本类型比较,编译器会隐形的把int转换为long,就可以得出正确的结论。

为什么不推荐使用resume和suspend??

因为在使用suspend()去挂起线程的时候,suspend在导致线程暂停的同时,不会去释放任何锁的资源。必须要等待resume()操作,被挂起的线程才能继续。如果我们resume()操作意外地在suspend()前就执行了,那么被挂起的线程可能很难有机会被继续执行。并且,更严重的是:所占用的锁不会被释放,因此可能会导致整个系统工作不正常

yield是谦让,调用后会使当前线程让出CPU,但是注意的地方让出CPU并不表示当前线程不执行了。当前线程在让出CPU后,还会进行CPU资源的争夺,有可能刚刚一让马上又进。

数据库事务的隔离级别

1.未提交读 都不能解决

2.已提交读 能解决脏读

3.可重复读 能解决脏读,不可重复读

4.序列化读 能解决脏读,不可重复读,幻读

Docker和虚拟机的比较

1.传统的虚拟机在宿主机操作系统上面会利用虚拟机管理程序去模拟完整的一个虚拟机操作系统,docker只是在操作系统上的虚拟化,直接复用本地主机的操作系统,非常轻量级。

docker启动速度一般在秒级,虚拟机启动速度一般在分钟级。

2.对于资源的使用一般是mb,一台机器可以有上千个docker容器,但是虚拟机占用资源为GB,只能支持几个。

3.性能:接近原生,由于又虚拟化了一层所以效率低。

4.docker采用类似git的命令学习升本低,指令简单。

5.虚拟机隔离性是完全隔离,容器是安全隔离

lucence组件

每一个词都会有一个倒排表,多个可以合并

为了标识webSocket:会在请求头中写一个upgrade:webSocket

与HTTP比较

同样作为应用层的协议,WebSocket在现代的软件开发中被越来越多的实践,和HTTP有很多相似的地方,这里将它们简单的做一个纯个人、非权威的比较:

相同点

都是基于TCP的应用层协议。

都使用Request/Response模型进行连接的建立。

在连接的建立过程中对错误的处理方式相同,在这个阶段WS可能返回和HTTP相同的返回码。

都可以在网络中传输数据。

不同点

WS使用HTTP来建立连接,但是定义了一系列新的header域,这些域在HTTP中并不会使用。

WS的连接不能通过中间人来转发,它必须是一个直接连接。

WS连接建立之后,通信双方都可以在任何时刻向另一方发送数据。f

WS连接建立之后,数据的传输使用帧来传递,不再需要Request消息。

WS的数据帧有序。

微服务架构

首先看一下[微服务架构]的定义:微服务(MSA)是一种架构风格,旨在通过将功能分解到各个离散的服务中以实现对解决方案的解耦。它有如下几个特征:

小,且只干一件事情。

独立部署和生命周期管理。

异构性

轻量级通信,RPC或者Restful。

BIO通信模型图:

BIO以是一客户端一线程,一个Acceptor线程来接受请求,之后为每一个客户端都创建一个新的线程进行链路处理,最大的问题缺乏弹性伸缩能力,客户端并发访问量增加后,线程数急剧膨胀。可以用线程池缓解但是还是不行。

NIO非阻塞IO解决了这个问题,一个线程就可以管理多个Socket.

在NIO中有三个需要我们了解Buffer,Channel,Selector

Buffer:NIO是面向缓冲区,IO是面向流的。缓冲区实质上一个数组

,缓冲区不仅仅是一个数组,缓冲区提供了对数据结构化访问以及维护读写位置等信息。

通道Channel:

Channel是一个通道,网络数据通过Channel读取和写入。通道与流的不同之处在于通道是双向的,流是一个方向上移动,通道可以用于读写操作,特别是在UNIX网络编程模型底层操作系统的通道都是全双工的,同时支持读写操作。

Selector:多路复用器NIo编程的基础,多路复用器提供选择就绪任务的 能力。简单来说Selector会不断注册在其上的Channel,如果某个Channel上面发生读或者写时间,这个Channel就处于就绪状态,会被Selector轮询出来,通过SelectionKey,一个多路复用器可以同时轮询多个Channel,JDK使用了epoll代理传统select实现,所以没有最大连接句柄fd的限制,意味着一个线程负责Selector的轮询,就可以接入成千上万的客户端

select/poll 和 epoll: select poll顺序扫描fd,就绪就返回fd。epoll则是 采用事件驱动,用回调的方式返回fd。

面向对象设计七大原则

1. 单一职责原则(Single Responsibility Principle)

每一个类应该专注于做一件事情。

2. 里氏替换原则(Liskov Substitution Principle)

超类存在的地方,子类是可以替换的。

3. 依赖倒置原则(Dependence Inversion Principle)

实现尽量依赖抽象,不依赖具体实现。

4. 接口隔离原则(Interface Segregation Principle)

应当为客户端提供尽可能小的单独的接口,而不是提供大的总的接口。

5. 迪米特法则(Law Of Demeter)

又叫最少知识原则,一个软件实体应当尽可能少的与其他实体发生相互作用。

6. 开闭原则(Open Close Principle)

面向扩展开放,面向修改关闭。

7. 组合/聚合复用原则(Composite/Aggregate Reuse Principle CARP)

尽量使用合成/聚合达到复用,尽量少用继承。原则: 一个类中有另一个类的对象。

拦截器和过滤器的区别

强类型:不允许隐形转换

弱类型:允许隐形转换

静态类型:编译的时候就知道每一个变量的类型,因为类型错误而不能做的事情是语法错误.

动态类型:编译得时候不知道每一个变量的类型,因为类型错误而不能做的事情是运行时错误

编译语言和解释语言:解释性编程语言,每个语句都是执行的时候才翻译而且是一句一句的翻译就很低。

编译的语言就只需要一次 就可以了

继承Thread 和 接口Runnabel的区别

主要是继承和实现接口的两个区别,如果只想重写run方法就可以使用,如果不重写其他方法 就使用runnable,如果使用实现接口的实现,让自己方便管理线程以及让线程复用,可以使用线程池去创建。

hash算法(特点、哈希函数构造、解决冲突的策略)

哈希表的概念:

哈希表就是一种以 键-值(key-indexed) 存储数据的结构,我们只要输入待查找的值即key,即可查找到其对应的值。

哈希表的实现思路:

如果所有的键都是整数,那么就可以使用一个简单的无序数组来实现:将键作为索引,值即为其对应的值,这样就可以快速访问任意键的值。这是对于简单的键的情况,我们将其扩展到可以处理更加复杂的类型的键。对于冲突的情况,则需要处理地址的冲突问题。所以,一方面要构造出良好的哈希函数,对键值集合进行哈希,另外一方面需要设计出良好的解决冲突的算法,处理哈希碰撞的冲突。

哈希表的查找步骤:

(1)使用哈希函数将被查找的键转换为数组的索引。在理想的情况下,不同的键会被转换为不同的索引值,但是在有些情况下我们需要处理多个键被哈希到同一个索引值的情况。所以哈希查找的第二个步骤就是处理冲突

(2)处理哈希碰撞冲突。有很多处理哈希碰撞冲突的方法,本文后面会介绍拉链法线性探测法

哈希表****的思想:

是一个在时间和空间上做出权衡的经典例子。如果没有内存限制,那么可以直接将键作为数组的索引。那么所有的查找时间复杂度为O(1);如果没有时间限制,那么我们可以使用无序数组并进行顺序查找,这样只需要很少的内存。哈希表使用了适度的时间和空间来在这两个极端之间找到了平衡。只需要调整哈希函数[算法]即可在时间和空间上做出取舍。

哈希表的工作步骤:

  1. 哈希(Hash)函数是一个映象,即将关键字的集合映射到某个地址集合上,它的设置很灵活,只要这个地址集合的大小不超出允许范围即可;

  2. 由于哈希函数是一个压缩映象,因此,在一般情况下,很容易产生“冲突”现象,即: key1!=key2,而 f (key1) = f(key2)。键不同,但是对应的取值相同。

3). 只能尽量减少冲突而不能完全避免冲突,这是因为通常关键字集合比较大,其元素包括所有可能的关键字,而地址集合的元素仅为哈希表中的地址值。

哈希函数的构造方法:

1、直接地址法

以数据元素的关键字k本身或者他的线性的函数作为它 的哈希地址,也就是H(k)=k,或者H(k)=a*k+b;

适用的场景:地址集合的大小==关键字的集合。

2、数字分析法

取数据元素关键字中某些取值较均匀的数字位作为哈希地址的方法

适用的场景:能预先估计出全体关键字的每一位上各种数字出现的频度。

3、折叠法

将关键字分割成位数相同的几部分(最后一部分的位数可以不同),然后取这几部分的叠加和(舍去进位),这方法称为折叠法

适用场景:关键字的数字位数特别多。

4、平方取中法

先取关键字的平方,然后根据可使用空间的大小,选取平方数是中间几位为哈希地址。

适用场景:通过取平方扩大差别,平方值的中间几位和这个数的每一位都相关,则对不同的关键字得到的哈希函数值不易产生冲突,由此产生的哈希地址也较为均匀。

5、减去法

6、基数转换法

7、除留余数法

8、随机数法

9、随机乘数法

10、旋转法

构造哈希哈希函数的原则:

1、计算哈希函数的时间

2、关键子的长度

3、哈希表的长度

4、关键字的分布的情况

5、记录查找频率

哈希函数的冲突解决的方法:

1、开放地址法

这种方法也称**再散列法****,**其基本思想是:当关键字key的哈希地址p=H(key)出现冲突时,以p为基础,产生另一个哈希地址p1,如果p1仍然冲突,再以p为基础,产生另一个哈希地址p2,…,直到找出一个不冲突的哈希地址pi ,将相应元素存入其中。这种方法有一个通用的再散列函数形式:

      Hi=(H(key)+d<sub>i</sub>)% m   i=1,2,…,n

其中H(key)为哈希函数,m 为表长,d<sub>i</sub>称为**增量序列。**增量序列的取值方式不同,相应的再散列方式也不同。主要有以下三种:

l 线性探测再散列

d<sub>i</sub>i=1,2,3,…,m-1

这种方法的特点是:冲突发生时,顺序查看表中下一单元,直到找出一个空单元或查遍全表。

l 二次探测再散列

d<sub>i</sub>=1<sup>2</sup>,-1<sup>2</sup>,2<sup>2</sup>,-2<sup>2</sup>,…,k<sup>2</sup>,-k<sup>2</sup>    ( k<=m/2 )

这种方法的特点是:冲突发生时,在表的左右进行跳跃式探测,比较灵活。

l 伪随机探测再散列

d<sub>i</sub>=伪随机数序列。

2、再哈希法

这种方法是同时构造多个不同的哈希函数:

H<sub>i</sub>=RH<sub>1</sub>(key)  i=1,2,…,k

当哈希地址Hi=RH1(key)发生冲突时,再计算Hi=RH2(key)……,直到冲突不再产生。这种方法不易产生聚集,但增加了计算时间。

3、链地址法

这种方法的基本思想是将所有哈希地址为i的元素构成一个称为同义词链的单链表,并将单链表的头指针存在哈希表的第i个单元中,因而查找、插入和删除主要在同义词链中进行。链地址法适用于经常进行插入和删除的情况。

4、建立公共溢出区

这种方法的基本思想是:将哈希表分为基本表溢出表两部分,凡是和基本表发生冲突的元素,一律填入溢出表

类加载的方式

1.通过new

2.通过反射利用当前线程的classloader

3.自己继承实现一个classloader实现自己的类装载器

class.forName 和 Classloader.loadClass区别

Classloader

HashMap内部是怎么实现的?(拉链式结构)

核心:hashMap采用拉链法,构成“链表的数组”

存储示意图:

索引index建立规则:

(1)一般情况下通过hash(key)%length实现,元素存储在数组中的索引是由key的哈希值对数组的长度取模得到。

(2)hashmap也是一个线性的数组实现的,里面定义一个内部类Entry,属性包括key、value、next.Hashmap的基础就是一个线性数组,该数组为Entry[] ,map里面的内容都保存在entry[]数组中。

(3)确定数组的index:hashcode%table.length

 数组的下标index相同,但是不表示hashcode相同。

实现随机存储的方法:

// 存储时:
int hash = key.hashCode(); // 这个hashCode方法这里不详述,只要理解每个key的hash是一个固定的int值
int index = hash % Entry[].length;
Entry[index] = value;

// 取值时:
int hash = key.hashCode();
int index = hash % Entry[].length;
return Entry[index];

put方法的实现:

如果两个key通过hash%Entry[].length得到的index相同,会不会有覆盖的危险?

这里HashMap里面用到链式数据结构的一个概念。上面我们提到过Entry类里面有一个next属性,作用是指向下一个Entry。打个比方, 第一个键值对A进来,通过计算其key的hash得到的index=0,记做:Entry[0] = A。一会后又进来一个键值对B,通过计算其index也等于0,现在怎么办?HashMap会这样做:B.next = A,Entry[0] = B,如果又进来C,index也等于0,那么C.next = B,Entry[0] = C;这样我们发现index=0的地方其实存取了A,B,C三个键值对,他们通过next这个属性链接在一起。所以疑问不用担心。也就是说数组中存储的是最后插入的元素

get方法的实现:

先定位到数组元素,再遍历该元素处的链表

table的大小:

table的初始的大小并不是initialCapacity,是initialCapacity的2的n次幂

目的在于:当哈希表的容量超过默认的容量时,必须重新调整table的大小,当容量已经达到最大的可能的值时,这时需要创建一张新的表,将原来的表映射到该新表。

req.getSession().invalidate(); 销毁session

req.getSession().setMaxInactiveInterval(30); 设置默认session的过期时间,tomcat的默认过期时间是 30分钟

利用线程池的优势:

1、降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。

2、提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立即执行。

3、提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。

boolean 单独使用的时候还会被变成int,4个字节

boolean[] 数组使用的时候变成byte,1个字节

Cache和Buffer的区别

Cache:缓存区,位于cpu和主内存之间容量很小但速度很快的存储器,因为CPU的速度远远高于主内存的速度,CPU从内存中读取数据许要等待很长的时间,而Cache保存着CPU刚用过的数据或循环使用的部分数据,Cache读取数据更快,减少cpu等待时间。

Buffer:缓冲区,用于存储速度不同步的情况,处理系统两端速度平衡,为了减小短期内突发I/O的影响,起到流量整形的作用。速度慢的可以先把数据放到buffer,然后达到一定程度在读取数据

内连接:

必须两个表互相匹配才会出现

外链接

左外链接:左边不加限制

右外连接:右边不加限制

全连接:左右都不加限制

三、如何创建索引

全文检索的索引创建过程一般有以下几步:

第一步:一些要索引的原文档(Document)。

为了方便说明索引创建过程,这里特意用两个文件为例:

文件一:Students should be allowed to go out with their friends, but not allowed to drink beer.

文件二:My friend Jerry went to school to see his students but found them drunk which is not allowed.

第二步:将原文档传给分词器(Tokenizer)。

分词器(Tokenizer)会做以下几件事情(此过程称为Tokenize):

1. 将文档分成一个一个单独的单词。

2. 去除标点符号。

3. 去除停词(Stop word)。

所谓停词(Stop word)就是一种语言中最普通的一些单词,由于没有特别的意义,因而大多数情况下不能成为搜索的关键词,因而创建索引时,这种词会被去掉而减少索引的大小。

英语中停词(Stop word)如:“the”,“a”,“this”等。

对于每一种语言的分词组件(Tokenizer),都有一个停词(stop word)集合。

经过分词(Tokenizer)后得到的结果称为词元(Token)。

在我们的例子中,便得到以下词元(Token):

“Students”,“allowed”,“go”,“their”,“friends”,“allowed”,“drink”,“beer”,“My”,“friend”,“Jerry”,“went”,“school”,“see”,“his”,“students”,“found”,“them”,“drunk”,“allowed”。

第三步:将得到的词元(Token)传给语言处理组件(Linguistic Processor)。

语言处理组件(linguistic processor)主要是对得到的词元(Token)做一些同语言相关的处理。

对于英语,语言处理组件(Linguistic Processor)一般做以下几点:

1. 变为小写(Lowercase)。

2. 将单词缩减为词根形式,如“cars”到“car”等。这种操作称为:stemming。

3. 将单词转变为词根形式,如“drove”到“drive”等。这种操作称为:lemmatization。

而且在此过程中,我们惊喜地发现,搜索“drive”,“driving”,“drove”,“driven”也能够被搜到。因为在我们的索引 中,“driving”,“drove”,“driven”都会经过语言处理而变成“drive”,在搜索时,如果您输入“driving”,输入的查询 语句同样经过我们这里的一到三步,从而变为查询“drive”,从而可以搜索到想要的文档。

ZK实现分布式锁- 是否存在,并且最小的

根据ZK中节点是否存在,可以作为分布式锁的锁状态,以此来实现一个分布式锁,下面是分布式锁的基本逻辑:

客户端调用create()方法创建名为“/dlm-locks/lockname/lock-”的临时顺序节点。

客户端调用getChildren(“lockname”)方法来获取所有已经创建的子节点。

客户端获取到所有子节点path之后,如果发现自己在步骤1中创建的节点是所有节点中序号最小的,那么就认为这个客户端获得了锁。

如果创建的节点不是所有节点中需要最小的,那么则监视比自己创建节点的序列号小的最大的节点,进入等待。直到下次监视的子节点变更的时候,再进行子节点的获取,判断是否获取锁。

而且zk的临时节点可以直接避免网络断开或主机宕机,锁状态无法清除的问题,顺序节点可以避免惊群效应。这些特性都使得利用ZK实现分布式锁成为了最普遍的方案之一。

Redus实现分布式锁,使用setNX (set if not exists)

getset(先写新值返回旧值,用于分辨是不是首次操作) 防止网络断开后 会设置超时

SETNX 可以直接加锁操作,比如说对某个关键词foo加锁,客户端可以尝试

SETNX foo.lock

如果返回1,表示客户端已经获取锁,可以往下操作,操作完成后,通过

DEL foo.lock

命令来释放锁。

处理死锁

在 上面的处理方式中,如果获取锁的客户端端执行时间过长,进程被kill掉,或者因为其他异常崩溃,导致无法释放锁,就会造成死锁。所以,需要对加锁要做时 效性检测。因此,我们在加锁时,把当前时间戳作为value存入此锁中,通过当前时间戳和Redis中的时间戳进行对比,如果超过一定差值,认为锁已经时 效,防止锁无限期的锁下去,但是,在大并发情况,如果同时检测锁失效,并简单粗暴的删除死锁,再通过SETNX上锁,可能会导致竞争条件的产生,即多个客 户端同时获取锁。

C1获取锁,并崩溃。C2和C3调用SETNX上锁返回0后,获得foo.lock的时间戳,通过比对时间戳,发现锁超时。

C2 向foo.lock发送DEL命令。

C2 向foo.lock发送SETNX获取锁。

C3 向foo.lock发送DEL命令,此时C3发送DEL时,其实DEL掉的是C2的锁。

C3 向foo.lock发送SETNX获取锁。

此时C2和C3都获取了锁,产生竞争条件,如果在更高并发的情况,可能会有更多客户端获取锁。所以,DEL锁的操作,不能直接使用在锁超时的情况下,幸好我们有GETSET方法,假设我们现在有另外一个客户端C4,看看如何使用GETSET方式,避免这种情况产生。

C1获取锁,并崩溃。C2和C3调用SETNX上锁返回0后,调用GET命令获得foo.lock的时间戳T1,通过比对时间戳,发现锁超时。

C4 向foo.lock发送GESET命令,

GETSET foo.lock

并得到foo.lock中老的时间戳T2

如果T1=T2,说明C4获得时间戳。

如果T1!=T2,说明C4之前有另外一个客户端C5通过调用GETSET方式获取了时间戳,C4未获得锁。只能sleep下,进入下次循环中。

现在唯一的问题是,C4设置foo.lock的新时间戳,是否会对锁产生影响。其实我们可以看到C4和C5执行的时间差值极小,并且写入foo.lock中的都是有效时间错,所以对锁并没有影响。

为 了让这个锁更加强壮,获取锁的客户端,应该在调用关键业务时,再次调用GET方法获取T1,和写入的T0时间戳进行对比,以免锁因其他情况被执行DEL意 外解开而不知。以上步骤和情况,很容易从其他参考资料中看到。客户端处理和失败的情况非常复杂,不仅仅是崩溃这么简单,还可能是客户端因为某些操作被阻塞 了相当长时间,紧接着 DEL 命令被尝试执行(但这时锁却在另外的客户端手上)。也可能因为处理不当,导致死锁。还有可能因为sleep设置不合理,导致Redis在大并发下被压垮。 最为常见的问题还有

AOF重写带有子进程副本保证安全

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值