毕老哥阿里面经
1.分布式锁问题。
(面试官)问:在多线程并发的情况下,如何保证一个代码块在同一时间只能由一个线程访问?
(小灰)答:这个简单,可以用[锁]来保证。比如java的synchronized语法以及Reentrantlock类等等。
(面试官)问:OK,这样子可以保证在同一个JVM进程内的多个线程同步执行。如果在分布式的集群环境中,如何保证不同节点的线程同步执行呢?
(小灰)答:嘿嘿,不会...
(面试官)问:回家等通知吧。
(小灰)问:大黄,我们怎么能够在分布式系统中,实现不同线程对代码和资源的同步访问呢?
(大黄)答:对于单线程的并发场景,我们可以使用语言和类库提供的锁。对于分布式场景,我们可以使用[分布式锁]。
(小灰)问:分布式锁?怎么才能实现分布式系统中的锁呢?
(大黄)答:有许多中实现方法,下面我简单列举一下:
(1)Memcached分布式锁
利用Memcached的add命令。此命令是原子性操作,只有在key不存在的情况下,才能add成功,也就意味着线程得到了锁。
(2)Redis分布式锁
和Memcached的方式类似,利用Redis的setnx命令。此命令同样是原子性操作,只有在key不存在的情况下,才能set成功。(setnx命令并不完善,后续会介绍替代方案)
(3)Zookeeper分布式锁
利用Zookeeper的顺序临时节点,来实现分布式锁和等待队列。Zookeeper设计的初衷,就是为了实现分布式锁服务的。
(4)Chubby
Google公司实现的粗粒度分布式锁服务,底层利用了Paxos一致性算法。
我们主要讲一下redis分布式锁!
如何用Redis实现分布式锁?
Redis分布式锁的基本流程并不难理解,但要想写的尽善尽美,也并不是那么容易。在这里,我们需要先了解分布式锁实现的三个核心要素:
(1)加锁
最简单的方法是使用setnx命令。key是锁的唯一标识,按业务来决定命名。比如想要给一种商品的秒杀活动加锁,可以给key命名为“lock_sale_商品ID”。而value设置成什么呢?我们可以姑且设置成1。加锁的伪代码如下:
setnx(key,1)
当一个线程执行setnx返回1,说明key原本不存在,该线程成功得到了锁;当一个线程执行setnx返回0,说明key已经存在,该线程抢锁失败。
(2)解锁
有加锁就得有解锁。当得到锁的线程执行完任务,需要释放锁,以便其他线程可以进入。释放锁的最简单方式是执行del指令,伪代码如下:
del(key)
释放锁之后,其他线程就可以继续执行setnx命令来获得锁。
(3)锁超时
锁超时是什么意思呢?如果一个得到锁的线程在执行任务的过程中挂掉,来不及显式地释放锁,这块资源将会永远被锁住,别的线程再也别想进来。
expire(key,30)
综合起来,我们分布式锁实现的第一版伪代码如下:
if(setnx(key,1) == 1){
expire(key,30)
try{
do something......
}finally{
del(key)
}
}
注意:谁要是面试的时候这么写,立马回家等通知!
因为上面的伪代码中,存在着三个致命问题:
(1)setnx和expire的非原子性
设想一个极端场景,当某线程执行setnx,成功得到了锁。Setnx刚执行成功,还未来得及执行expire指令,节点1 Duang的一声挂掉了。这样一来,这把锁就没有设置过期时间,变得“长生不老”,别的线程再也无法获得锁了。
怎么解决呢?setnx指令本身是不支持传入超时时间的,幸好Redis 2.6.12以上版本为set指令增加了可选参数,伪代码如下:
set(key,1,30,NX)
这样就可以取代setnx指令。
(2)del导致误删
又是一个极端场景,假如某线程成功得到了锁,并且设置的超时时间是30秒。如果某些原因导致线程A执行的很慢很慢,过了30秒都没执行完,这时候锁过期自动释放,线程B得到了锁。
随后,线程A完成了任务,线程A接着执行del指令来释放锁。但这时候线程B还没执行完,线程A实际上删除的是线程B加的锁。
怎么避免这种情况呢?可以在del释放锁之前做一个判断,验证当前的锁是不是自己加的锁。
至于具体的实现,可以再加锁的时候把当前的线程ID当做value,并在删除之前验证key对应的value是不是自己线程的ID。
加锁:
String threadId = Thread.currentThread().getId()
Set(key,threadId,30,NX)
解锁:
if (threadId.equals(redisClient.get(key))){
del(key)
}
但是,这样做又隐含一个新的问题,判断和释放锁是两个独立操作,不是原子性。
我们都是追求极致的程序员,所以这一块要用Lua脚本来实现:
String luaScript = “if redis.call(‘get’,KEYS[1]) == ARGV[1] then return redis.call(‘del’,KEYS[1]) else return 0 end”;
redisClient.eval(luaScript , Collections.singletonList(key),
Collections.singletonList(threadId));
这样一来,验证和删除过程就是原子操作了。
(3)出现并发的可能性
还是刚才第二点所描述的场景,虽然我们避免了线程A误删掉key的情况,但是同一时间有A,B两个线程在访问代码块,仍然是不完美的。
怎么办呢?我们可以让获得锁的线程开启一个守护线程,用来给快要过期的锁“续航”。
当过去了29秒,线程A还没执行完,这时候守护线程会执行expire指令,为这把锁“续命20秒”。守护线程从第29秒开始执行,每20秒执行一次。
当线程A执行完任务,会显示关掉守护线程。
另一种情况,如果节点1忽然断电,由于线程A和守护线程在同一个进程,守护线程也会停下。这把锁到了超时的时候,没人给它续命,也就自动释放了。
守护线程的代码并不难实现,有了大体思路,大家可以自己尝试实现以下。
- CAP定理和BASE定理。
CAP定理。
答:CAP定理理论告诉我们,一个分布式系统不可能同时满足一致性、可用性和分区容错性这三个基本需求,最多只能同时满足其中的两项。
一致性
在分布式环境中,一致性是指数据在多个副本之间是否能够保持一致的特性。在一致性的需求下,当一个系统在数据一致的状态下执行更新操作后,应该保证系统的数据仍然处于一致的状态。
对于一个将数据副本分布在不同分布式节点上的系统来说,如果对第一个节点的数据进行了更新操作并且更新成功后,却没有使得第二个节点上的数据得到相应的更新,于是在对第二个节点的数据进行读取操作时,获取的依然是老数据(或称为脏数据),这就是典型的分布式数据不一致情况。在分布式系统中,如果能够做到针对一个数据项的更新操作执行成功后,所有的用户都可以读取到其最新的值,那么这样的系统就被认为具有强一致性(或严格的一致性)。
可用性
可用性是指系统提供的服务必须一直处于可用的状态,对于用户的每一个操作请求总是能够在有限的时间内返回结果。这里我们重点看下“有限的时间内”和“返回结果”。
“有限的时间内”是指,对于用户的一个操作请求,系统必须能够在指定的时间(即响应时间)内返回对应的处理结果,如果超过了这个时间范围,那么系统就被认为是不可用的。另外,“有限的时间内”是一个在系统设计之初就设定好的系统运行指标,通常不同的系统之间会有很大的不同。比如说,对于一个在线搜索引擎来说,通常在0.5秒内需要给出用户搜索关键词对应的检索结果。以Google为例,搜索“分布式”这一关键词,Google能够在0.3秒左右的时间,返回大约上千万条检索结果。而对于一个面向HIVE的海量数据查询平台来说,正常的一次数据检索时间可能在20秒到30秒之间,而如果是一个时间跨度较大的数据内容查询,“有限的时间”有时候甚至会长达几分钟。
从上面的例子中,我们可以看出,用户对于一个系统的请求响应时间的期望值不尽相同。但是,无论系统之间的差异有多大,唯一相同的一点就是对于用户请求,系统必须存在一个合理的响应时间,否则用户便会对系统感到失望。
让我们再来看看上面提到的在线搜索引擎的例子,如果用户输入指定的搜索关键词后,返回的结果是一个系统错误,通常类似于“OutOfMemoryError”或“System Has Crashed”等提示语,那么我们认为此时系统是不可用的。
分区容错性
分区容错性约束了一个分布式系统需要具有如下特性:分布式系统在遇到任何网络分区故障的时候,仍然需要能够保证对外提供满足一致性和可用性的服务,除非是整个网络环境都发生了故障。
网络分区是指在分布式系统中,不同的节点分布在不同的子网络(机房或异地网络等)中,由于一些特殊的原因导致这些子网络之间出现网络不连通的状况,但各个子网络的内部网络时正常的,从而导致整个系统的网络环境被切分成了若干个孤立的区域。需要注意的是,组成一个分布式系统的每个节点的加入与退出都可以看作是一个特殊的网络分区。
以上就是对CAP定理中一致性、可用性和分区容错性的讲解,通常使用图1-2所示的示意图来表示CAP定理。
既然在上文中我们提到,一个分布式系统无法同时满足上述三个需求,而只能满足其中的两项,因此在进行对CAP定理的应用时,我们就需要抛弃其中的一项,表1-2所示是抛弃CAP定理中任意一项特性的场景说明。
从CAP定理中我们可以看出,一个分布式系统不可能同时满足一致性、可用性和分区容错性这三个需求。另一方面,需要明确地一点是,对于一个分布式系统而言,分区容错性可以说是一个最基本的要求。为什么这样说,其实很简单,因为既然是一个分布式系统,那么分布式系统中的组件必然需要被部署到不同的节点,否则也就无所谓分布式系统了,因此必然出现子网络。而对于分布式系统而言,网络问题又是一个必定会出现的异常情况,因此分区容错性也就成为了一个分布式系统必然需要面对和解决的问题。因此系统架构设计师往往需要把精力花在如何根据业务特点在C(一致性)和A(可用性)之间寻求平衡。
BASE定理
Basically Available(基本可用)、Soft state(软状态)和Eventually consistent(最终一致性)。
将BASE定理和CAP定理结合可知:BASE定理牺牲高一致性,获取可用性和分区容错性。
将BASE定理与ACID特性结合可知:BASE定理保障了事务的原子性(A)和持久性(D),降低了一致性(C)和隔离性(I)的要求。
基于CAP定理和BASE定理,常见的分布式事务解决方案有三种:
(1)可靠消息最终一致性方案(MQ事务)(适用场景比较广)
(2)TCC事务补偿型方案(TCC事务)
(3)最大努力通知型方案(MQ事务)
- 什么是红黑树?
答:要学习红黑树,首先要理解二叉查找树(Binary Search Tree)。
二叉查找树(BST)具备什么特性呢?
- 左子树上所有结点的值均小于或等于它的根结点的值。
- 右子树上所有结点的值均大于或等于它的根结点的值。
- 左、右子树也分别为二叉查找树。
利用二叉查找树的思想,查找所需的最大次数等同于二叉查找树的高度,优化了查询的时间复杂度。但是二叉查找树查找性能严重依赖于输入,而输入却是随机的,在极端情况下,二叉查找树会成为昂贵的链表。
如何解决二叉查找树可能存在的性能问题呢?红黑树应运而生了!
红黑树(Red Black Tree)是一种自平衡的二叉查找树。除了符合二叉查找树的基本特性外,它还具有下列的附加特性:
(1)每一个节点要么着成红色,要么着成黑色。
(2)根结点是黑色的。
(3)每个叶子结点都是黑色的空节点。
(4)如果一个节点是红色的,那么它的子节点必须是黑色的。
(5)从任一节点到其每个叶子节点的所有路径都包含相同数目的黑色节点。
当向红黑树插入或删除节点的时候,红黑树的规则有可能被打破。这时候就需要做出一些调整,来继续维持我们的规则。调整有两种方法:变色和旋转。而旋转又分成两种形式:[左旋转]和[右旋转]。
什么情况下会破坏红黑树的规则,什么情况下不会破坏规则呢?
答:每次新插入的节点都标记为红色,当插入节点的父节点为黑色时,不会破坏红黑树的规则;当插入节点的父节点为红色时,会破坏红黑树的规则。
调整有两种方法:变色和旋转。而旋转又分成两种形式:[左旋转]和[右旋转]。
变色:
为了重新符合红黑树的规则,尝试把红色节点变为黑色,或者把黑色节点变为红色。
左旋转:
逆时针旋转红黑树的两个节点,使得父节点被自己的右孩子取代,而自己成为自己的左孩子。
右旋转:
顺时针旋转红黑树的两个节点,使得父节点被自己的左孩子取代,而自己成为自己的右孩子。
具体什么时候用变色?什么时候用旋转?要视情况而定!
面试题:红黑树在哪些地方被实际用到呢?
答:红黑树的应用有很多,其中JDK的集合类TreeMap和TreeSet底层就是红黑树实现的。在Java8中,连HashMap也用到了红黑树(当HashMap中存放哈希值冲突元素的链表长度超过8以后,会转成红黑树,以提高查询性能)。
- volatile关键字的实现原理。
答:volatile关键字具有许多特性,其中最重要的特性就是保证了用volatile修饰的变量对所有线程的内存可见性。
内存可见性指的是当一个线程修改了变量的值,新的值会立即同步到主内存中,被其它线程所知道。
volatile是通过什么来保证内存可见性的呢?
是通过在读写操作前后加内存屏障来实现变量的内存可见性。
Java内存模型提出了4种内存屏障:
load-load 屏障
load-store 屏障
store-store 屏障
store-load 屏障
四种内存屏障都能够保证程序语义的正确,编译器不会重排逻辑。
load-load和load-store只能保证程序语义正确,无法确保内存可见性。也就是说,load操作虽然执行了,但实际上可能取得是缓存的值,而不是重新到共享内存取。Store操作虽然执行了,但实际上,可能还没有马上同步到共享内存中。
store-store和store-load都可以确保程序语义正确,也都可以确保屏障前的store操作能够马上同步到共享内存。但是store-store屏障后面的load操作,不能确保一定会从共享内存取最新值,可能还是读的缓存。而store-load屏障后面的load操作,一定能够确保是去共享内存取的最新值。
从性能的角度讲,store-load是最耗性能的,但是在确保正确性方面,store-load做的最好。
那volatile是如何通过内存屏障来保证内存可见性呢?
在一个变量被volatile修饰后,JVM会为我们做两件事:
- 在每个volatile写操作前插入StoreStore屏障,在写操作后插入StoreLoad屏障。
- 在每个volatile读操作前插入LoadLoad屏障,在读操作后插入LoadStore屏障。
这样就可以保证,当对volatile变量进行写操作时,结果会立即同步到主内存;当对volatile变量进行读操作时,每次都是从主内存中读取最新的值,而不是从工作内存中读取。
- 什么是循环依赖?
答:循环依赖指两个或多个Bean之间相互持有对方,比如A引用B,B引用C,C引用A。
- 循环依赖和循环引用有什么区别?
答:循环引用是方法之间的循环调用,是无法解决的,除非有终结条件,否则就是死循环,最终导致内存溢出错误。
- spring是如何处理循环依赖的?
答:spring中的循环依赖包括构造器注入循环依赖和set方法注入循环依赖。
(1)构造器循环依赖
什么是构造器循环依赖?怎么判断是否是循环依赖?解决方案?
构造器循环依赖指的是通过构造器注入构成的循环依赖,此依赖是无法解决的,只能抛出BeanCurrentlyInException异常表示循环依赖。
在创建A类时,发现构造器需要B类,那将去创建B类,在创建B类时,又发现构造器需要C类,那将去创建C类,在创建C类时,又发现构造器需要A类。从而形成了一个环,三个类都没有办法创建成功!
具体过程如下:
Spring容器中有一个“当前创建Bean池”,所有正在创建的Bean都会有一个Bean标识符,这个标识符在Bean创建过程中会一直保持在这个池子中。如果一个Bean在创建过程中发现自己已经在“当前创建Bean池”中了,就会抛出BeanCurrentlyInException异常表示循环依赖;如果一个Bean创建成功了,那么这个标识符就会从“当前创建Bean池”中清除掉。
- Spring创建“A”Bean时,首先去“当前创建Bean池”中查找当前Bean是否正在创建,如果没有,执行构造函数创建“A”,发现A依赖B,就会去创建“B”,并将“A”标识符放到“当前创建Bean池”中。
- Spring创建“B”Bean时,首先去“当前创建Bean池”中查找当前Bean是否正在创建,如果没有,执行构造函数创建“B”,发现B依赖C,就会去创建“C”,并将“B”标识符放到“当前创建Bean池”中。
- Spring创建“C”Bean时,首先去“当前创建Bean池”中查找当前Bean是否正在创建,如果没有,执行构造函数创建“C”,发现C依赖A,就会去创建“A”,并将“C”标识符放到“当前创建Bean池”中。
- Spring创建“A”Bean时,首先去“当前创建Bean池”中查找当前Bean是否正在创建,发现Bean“A”已经在“当前创建Bean池”中,因为这表示循环依赖,所以抛出BeanCurrentlyInException异常。
构造器循环依赖没有办法解决!
简化版:如何检测是否存在循环依赖?
答:检测循环依赖相对比较容易,Bean在创建的时候可以给该Bean打标识,如果递归调用回来发现正在创建中的话,即说明循环依赖了。
- set方法注入循环依赖
set方法循环依赖的解决方案?
Spring只能解决set方法循环依赖中单例作用域的情况。采用的是提前暴露对象的方法。
Spring将实例化Bean的过程分为initBean和popuBean过程,在这两个阶段之间调用addSingletonFactory()方法将Bean注入到DefaultSingletonBeanRegistry中去。这样在循环依赖的时候就能够获取到依赖的对象的引用,而不用再一次取initBean,发生循环依赖。
具体过程如下:
Spring创建“A”Bean时,先执行initBean,创建实例,然后将对象暴露出来,此时的对象“A”还有完成属性注入,接着执行popuBean过程预完成属性注入,结果发现A依赖于B,所以就去创建B。
Spring创建“ B”Bean时,先执行initBean,创建实例,然后将对象暴露出来,此时的对象“B”还有完成属性注入,接着执行popuBean过程预完成属性注入,结果发现B依赖于C,所以就去创建C。
Spring创建“ C”Bean时,先执行initBean,创建实例,然后将对象暴露出来,此时的对象“C”还有完成属性注入,接着执行popuBean过程预完成属性注入,结果发现B依赖于A,因为第一步中已经将尚未完成属性注入的A暴露出来了,所以这个时候C能够拿到A,从而完成popuBean过程。
当C完成之后,返回给B,B也就能够完成popuBean过程,从而完成整个创建过程。
当B完成之后,返回给A,A也就能够完成popuBean过程,从而完成整个创建过程。
面试题:在实际项目中,你采用哪种spring的依赖注入方式?为什么?
Spring依赖注入的方式主要有三种:构造器注入、set方法注入和属性注入(@Autowired),其中,构造器注入和set方法注入都存在循环依赖问题,而且构造器循环依赖spring无法解决,set方法循环依赖spring也只能解决单例作用域情况,对于多例作用域情况也解决不了。所以,在实际项目中,我直接使用属性注入(@Autowired),让spring决定在合适的时机注入。
- tcp粘包和拆包问题。
答:
- Spring定义了一个事务,但是我捕获了所有的异常,这个时候这个事务还有用吗?
答:没有。Spring事务异常回滚,捕获异常不抛出就不会回滚。
为什么不回滚呢?
默认spring事务只在发生未被捕获的 runtimeexcetpion时才回滚。
spring aop 异常捕获原理:被拦截的方法需显式抛出异常,并不能经任何处理,这样aop代理才能捕获到方法的异常,才能进行回滚,默认情况下aop只捕获runtimeexception的异常,但可以通过配置来捕获特定的异常并回滚。
换句话说在service的方法中不使用try catch 或者在catch中最后加上throw new RuntimeExcetpion(),这样程序异常时才能被aop捕获进而回滚。
解决方案:
方案1:例如service层处理事务,那么service中的方法中不做异常捕获,或者在catch语句中最后增加throw new RuntimeException()语句,以便让aop捕获异常再去回滚,并且在service上层(webservice客户端,view层 action)要继续捕获这个异常并处理
方案2:在service层方法的catch语句中增加这样一句代码: TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
手动回滚,这样上层就无需去处理异常(现在项目的做法)。