1. Java线程有哪六种状态?
状态的划分
NEW
:初始状态RUNNABLE
:运行状态BLOCKED
:阻塞状态WAITING
:等待状态TIMED_WAITING
:超时等待状态TERMINATED
:终止状态
runnable
包含就绪状态和运行状态
状态的转变
NEW
表示线程创建成功,但没有运行,在new Thread
之后,没有start()
之前,线程都处于 NEW 状态;RUNNABLE
表示线程正在运行中,当我们运行start()
方法,子线程被创建成功之后,子线程的状态变成RUNNABLE
;TERMINATED
表示线程已经运行结束,子线程运行完成被打断、被中止,状态都会从 RUNNABLE 变成 TERMINATEDBLOCKED
表示线程被阻塞,如果线程正好在等待获得monitor lock 锁,比如在等待进入 synchronized 修饰的代码块或方法时,会从RUNNABLE 变成 BLOCKED。WAITING
和TIMED_WAITING
都表示等待,现在在遇到Object#wait、Thread#join、LockSupport#park 这些方法时,线程就会等待另一个线程执行完特定的动作之后,才能结束等待,只不过TIMED_WAITING
是带有等待时间的;
2. Java线程池的拒绝策略有哪些
当线程池的任务队列已满并且线程池中的线程数目达到
maximumPoolSize
时,如果还有任务到来就会采取任务拒绝策略。
线程池中的线程已经用完了,无法继续为新任务服务,同时,等待队列也已经排满了,再也塞不下新任务了。这时候我们就需要拒绝策略机制合理的处理这个问题。
JDK 内置的拒绝策略
- AbortPolicy : 直接抛出异常,阻止系统正常运行,abort不推荐。
- 2.CallerRunsPolicy : 该策略直接将任务交给调用者线程运行性能极有可能会急剧下降。
- DiscardoldestPolicy : 丢弃最老的一个请求,也就是即将被执行的一个任务,并尝试再次提交当前任务。
- Discardpolicy : 该策略然然地丢弃无法处理的任务,不予任何处理。如果允许任务丢失,这是最好的一种方案。
以上内置拒绝策略均实现了 RejectedExecutionHandler
接口,若以上策略仍无法满足实际需要,完全可以自已扩展 RejectedExecutionHandler
接口默认的拒绝策略是AbortPolicy
。
3. Java如何优雅的中断线程?
1. Volatile
private static volatile boolean stopFlag=false;
public static void main(String[] args) {
new Thread(()->{
while (true){
if (stopFlag){
System.out.println("Now I am preparing to quit");
return;
}
else{
System.out.println("I am working");
}
}
},"t1").start();
try{
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
System.out.println(e);
}
new Thread(()->{
stopFlag=true;
},"t2").start();
}
2. atomic
3. interrupt
4. 什么是Java自旋锁?
自旋锁原理非常简单,如果持有锁的线程能在很短时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞挂起状态,它们只需要等一等 (自旋) ,等持有锁的线程释放锁后即可立即获取锁,这样就避免用户线程和内核的切换的消耗。
线程自旋是需要消耗 cpu 的,说白了就是让 cpu 在做无用功,如果一直获取不到锁,那线程也不能一直占用 cpu 自旋做无用功,所以需要设定一个自旋等待的最大时间。
如果持有锁的线程执行的时间超过自旋等待的最大时间扔没有释放锁,就会导致其它争用锁的线程在最大等待时间内还是获取不到锁,这时争用线程会停止自旋进入阻塞状态
自旋锁会尽可能的减少线程的阻塞,这对于锁的竞争不激烈,且占用锁时间非常短的代码块来说性能能大幅度的提升,因为自旋的消耗会小于线程阻塞挂起再唤醒的操作的消耗,这些操作会导致线程发生两次上下文切换但是如果锁的竞争激烈,或者持有锁的线程需要长时间占用锁执行同步块这时候就不适合使用自旋锁了,因为自旋锁在获取锁前一直都是占用 cpu做无用功,占着茅坑不 XX,同时有大量线程在竞争一个锁,会导致获取锁的时间很长,线程自旋的消耗大于线程阻塞挂起操作的消耗,其它需要cpu 的线程又不能获取到 cpu,造成 cpu 的浪费。所以这种情况下我们要关闭自旋锁;
自旋锁的目的是为了占着 CPU 的资源不释放,等到获取到锁立即进行处理。但是如何去选择自旋的执行时间呢?如果自旋执行时间太长,会有大量的线程处于自旋状态占用 CPU 资源,进而会影响整体系统的性能。因此自旋的周期选的额外重要!JVM 对于自旋周期的选择,jdk1.5 这个限度是一定的写死的,在 1.6引入了适应性自旋锁,适应性自旋锁意味着自旋的时间不在是固定的了而是由前一次在同一个锁上的自旋时间以及锁的拥有者的状态来决定基本认为一个线程上下文切换的时间是最佳的一个时间,同时 JVM 还针对当前 CPU 的负荷情况做了较多的优化,如果平均负载小于 CPUs则一直自旋,如果有超过(CPUs/2)个线程正在自旋,则后来线程直接阻塞,如果正在自旋的线程发现 Owner 发生了变化则延迟自旋时间 (自旋计数) 或进入阻塞,如果 CPU 处于节电模式则停止自旋,自旋时间的最坏情况是 CPU的存储延迟 (CPU A 存储了一个数据,到 CPU B得知这个数据直接的时间差) ,自旋时会适当放弃线程优先级之间的差异。
5. Java中的final有啥用?
final用来告知编译器这一块数据是恒定不变的。数据恒定不变又如下作用:
- 一个永不改变的编译时常量。
- 一个在运行时被初始化的值,而你不希望他改变。
编译器常量的情况,编译器可以将常量值代入任何可能用到的计算式,可以在编译时执行计算式,减轻运行的负担。这类常量必须是基本数据类型,并且以关键字final表示。常量在定义的时候,必须对其进行赋值。一个即是static又是final的域占据一段不能改变的存储空间。
如果不赋初值,编译器会报错提示当final修饰对象引用的时候,final使对象的引用恒定不变,一旦引用被初始化指向一个对象,就无法再把它指向另一个对象。然而对象的自身是可以修改的。
空白final
java允许生产空白final,指被声明为final,但又未给定初值的域,无论什么情况,编译器确保空白final在使用前必须被初始化。空白fianl的作用可以做到根据对象的不同而有所不同,却又保持其恒定不变的特性。当我们定义空白的final的时候,必须保证在构造器的时候对值进行初始化。
方法中的final参数
java允许在参数列表中以声明的方式将参数指明为final,这意味着无法在方法中更改参数引用指向的对象。
//错误代码,会报错
public void testVoidFinal(final TemplateClass templateClass){
templateClass=new TemplateClass();
}
如上代码是不可以的,因为final的类型不可以进行更改
//错误代码,会报错
public void testVoidFinal(final int i){
i++;
}
如上也是不可以的。
//正常编译
public int testVoidFinal(final int i){
return i+1;
}
final方法
fanal方法主要有两大用法
第一个用法:把方法锁定,防止任何继承类修改它的含义。想要确保在继承中使方法行为保持不变,并且不会覆盖。
第二个用法:在java早期开发过程中,如果将一个方法指明为final,就是同意编译器将针对该方法的所有调用转为内嵌调用。当编译器发现一个final方法调用命令的时候会根据自己的谨慎判断,跳过插入程序代码这种正常方式而执行方法调用机制。并且以方法体中的实际代码的副本来替代方法的调用。这将消除方法调用的开销。当然,如果一个方法很大,你的程序代码就会膨胀,因而看不到内嵌带来的任何性能提高因为,所带来的性能提高会因为花费于方法内的时间量而被缩减,在最近的iava版本中,虚拟机可以探测到这些情况,并优化去掉这些效率反而降低的额外的内嵌调用,因此不再需要用final来进行优化,这种方法正在逐渐的受阻,只有在想要明确禁止覆盖的时候,才将方法设置为final。
final类
类似JDK15 sealed
当将某个类的整体定义为final
的时候,就表明了你不打算继承该类,而且也不允许别人这样做,对于该类永远不需要做任何变动,出于安全考虑,你不希望有子类。fina
l类的域可以根据个人意愿选择为是或不是final
。不论类是否被定义为final
。相同的规则都适用于定义为final
的域。
由于final类禁止继承,所以final
类中的所有方法都隐式指定为是final
的。因为无法覆盖它。在final类中给方法添加final关键字,没什么意义。
6. 线程池如何没置最优线程数
线程池核心参数
ThreadPoolExecutor 的构造方法如下:
I. corePoolSize: 指定了线程池中的线程数量。
z. maximumPoolSize: 指定了线程池中的最大线程数量。了.keepAliveTime: 当前线程池数量超过 corePoolSize 时,多余的空闲线程的存活时间,即多长时间内会被销毁。
4. unit: keepAliveTime 的单位。
5. workQueue:任务队列,被提交但尚未被执行的任务。
b. threadFactory: 线程工厂,用于创建线程,一般用默认的即可。7.handler:拒绝策略,当任务太多来不及处理,如何拒绝任务
如何设置最优线程数
1、高并发、任务执行时间短的业务怎样使用线程池?线程池线程数可以设置为CPU核数+1,减少线程上下文的切换
2 、并发不高、任务执行时间长的业务怎样使用线程池?
a) 假如是业务时间长集中在10操作上,也就是IO密集型的任务,因为IO操作并不占用CPU,所以不要让所有的CPU闲下来,可以适当加大线程池中的线程数目,让CPU处理更多的业务
b) 假如是业务时间长集中在计算操作上,也就是计算密集型任务,线程池中的线程数设置得少一些减少线程上下文的切换
3、并发高、业务执行时间长的业务怎样使用线程池?并发高、业务执行时间长,解决这种类型任务的关键不在于线程池而在于整体架构的设计,看看这些业务里面某些数据是否能做缓存是第一步,增加服务器是第二步,以及线程池的设置。最后业务执行时间长的问题,也可能需要分析一下,看看能不能使用中间件对任务进行拆分和解耦。
7. 介绍一下ThreadLocal?
面试官:今天要不来聊聊ThreadLocal吧?
候选者:我个人对ThreadLocal理解就是
候选者:它提供了线程的局部变量,每个线程都可以通过set/get来对这个局部变量进行操作
候选者:不会和其他线程的局部变量进行冲突,实现了线程的数据隔离
面试官:你在工作中有用到过ThreadLocal吗?
候选者:这块是真不多,不过还是有一处的。就是我们项目有个的DateUtils工具类
候选者:这个工具类主要是对时间进行格式化
候选者:格式化/转化的实现是用的SimpleDateFormat
候选者:但众所周知SimpleDateFormat不是线程安全的,所以我们就用ThreadLocal来让每个线程装载着自己的SimpleDateFormat对象
候选者:以达到在格式化时间时,线程安全的目的
候选者:在方法上创建SimpleDateFormat对象也没问题,但每调用一次就创建一次有点不优雅
候选者:在工作中ThreadLocal的应用场景确实不多,但要不我给你讲讲Spring是怎么用的?
面试官:好吧,你讲讲呗
候选者:Spring提供了事务相关的操作,而我们知道事务是得保证一组操作同时成功或失败的
候选者:这意味着我们一次事务的所有操作需要在同一个数据库连接上
候选者:但是在我们日常写代码的时候是不需要关注这点的
候选者:Spring就是用的ThreadLocal来实现,ThreadLocal存储的类型是一个Map
候选者:Map中的key 是DataSource,value 是Connection(为了应对多数据源的情况,所以是一个Map)
候选者:用了ThreadLocal保证了同一个线程获取一个Connection对象,从而保证一次事务的所有操作需要在同一个数据库连接上
面试官:了解
面试官:你知道ThreadLocal内存泄露这个知识点吗?
候选者:怎么都喜欢问这个…
候选者:了解的,要不我先来讲讲ThreadLocal的原理?
面试官:请开始你的表演吧
候选者:ThreadLocal是一个壳子,真正的存储结构是ThreadLocal里有ThreadLocalMap
这么个内部类
候选者:而有趣的是,ThreadLocalMap的引用是在Thread上定义的
候选者:ThreadLocal本身并不存储值,它只是作为key来让线程从ThreadLocalMap获取value
候选者:所以,得出的结论就是ThreadLocalMap该结构本身就在Thread下定义,而ThreadLocal只是作为key,存储set到ThreadLocalMap的变量当然是线程私有的咯
面试官:那我想问下,我可以在ThreadLocal下定义Map,key是Thread,value是set进去的值吗?
面试官:就是说,为啥我要把ThreadLocal做为key,而不是Thread做为key?这样不是更清晰吗?
候选者:嗯,我明白你的意思。
候选者:理论上是可以,但没那么优雅。
候选者:你提出的做法实际上就是所有的线程都访问ThreadLocal的Map,而key是当前线程
候选者:但这有点小问题,一个线程是可以拥有多个私有变量的嘛,那key如果是当前线程的话,意味着还点做点「手脚」来唯一标识set进去的value
候选者:假设上一步解决了,还有个问题就是;并发量足够大时,意味着所有的线程都去操作同一个Map,Map体积有可能会膨胀,导致访问性能的下降
候选者:这个Map维护着所有的线程的私有变量,意味着你不知道什么时候可以「销毁」
候选者:现在JDK实现的结构就不一样了。
候选者:线程需要多个私有变量,那有多个ThreadLocal对象足以,对应的Map体积不会太大
候选者:只要线程销毁了,ThreadLocalMap也会被销毁
面试官:嗯,了解。
面试官:回到ThreadLocal内存泄露上吧,谈谈你对这个的理解呗
候选者:ThreadLocal内存泄露其实发生的概率非常非常低,我也不知道为什么这么喜欢问。
候选者:回到原理上,我们知道Thread在创建的时候,会有栈引用指向Thread对象,Thread对象内部维护了ThreadLocalMap引用
候选者:而ThreadLocalMap的Key是ThreadLocal,value是传入的Object
候选者:ThreadLocal对象会被对应的栈引用关联,ThreadLocalMap的key也指向着ThreadLocal
候选者:ThreadLocalRef && ThreadLocalMap Entry key ->ThreadLocal
候选者:ThreadRef->Thread->ThreadLoalMap-> Entry value-> Object
候选者:网上大多分析的是ThreadLocalMap的key是弱引用指向ThreadLocal
面试官:嗯…要不顺便讲讲Java的4种引用吧
候选者:强引用是最常见的,只要把一个对象赋给一个引用变量,这个引用变量就是一个强引用
候选者:强引用:只要对象没有被置null,在GC时就不会被回收
候选者:软引用相对弱化了一些,需要继承 SoftReference实现
候选者:软引用:如果内存充足,只有软引用指向的对象不会被回收。如果内存不足了,只有软引用指向的对象就会被回收
候选者:弱引用又更弱了一些,需要继承WeakReference实现
候选者:弱引用:只要发生GC,只有弱引用指向的对象就会被回收
候选者:最后就是虚引用,需要继承PhantomReference实现
候选者:虚引用的主要作用是:跟踪对象垃圾回收的状态,当回收时通过引用队列做些「通知类」的工作
候选者:了解了这几种引用之后,再回过头来看ThreadLocal
面试官:嗯…
候选者:ThreadLocal内存泄露指的是:ThreadLocal被回收了,ThreadLocalMap Entry的key没有了指向
候选者:但Entry仍然有ThreadRef->Thread->ThreadLoalMap-> Entry value-> Object 这条引用一直存在导致内存泄露
面试官:嗯…
候选者:为什么我说导致内存泄露的概率非常低呢,我觉得是这样的
候选者:首先ThreadLocal被两种引用指向
候选者:1):ThreadLocalRef->ThreadLocal(强引用)
候选者:2):ThreadLocalMap Entry key ->ThreadLocal(弱引用)
候选者:只要ThreadLocal没被回收(使用时强引用不置null),那ThreadLocalMap Entry key的指向就不会在GC时断开被回收,也没有内存泄露一说法
候选者:通过ThreadLocal了解实现后,又知道ThreadLocalMap是依附在Thread上的,只要Thread销毁,那ThreadLocalMap也会销毁
候选者:那非线程池环境下,也不会有长期性的内存泄露问题
候选者:而ThreadLocal实现下还做了些”保护“措施,如果在操作ThreadLocal时,发现key为null,会将其清除掉
候选者:所以,如果在线程池(线程复用)环境下,如果还会调用ThreadLocal的set/get/remove方法
候选者:发现key为null会进行清除,不会有长期性的内存泄露问题
候选者:那存在长期性内存泄露需要满足条件:ThreadLocal被回收&&线程被复用&&线程复用后不再调用ThreadLocal的set/get/remove方法
当线程被回收时,ThreadLocalMap 中对应的 Entry 对象也会被回收。但是,如果线程被复用,并且在复用后不再调用 ThreadLocal 的 set/get/remove 方法,则 ThreadLocalMap 中对应的 Entry 对象就会一直存在,导致长期性内存泄漏。
这是因为 ThreadLocalMap 中的 Entry 对象是弱引用,当对应的 ThreadLocal 对象被回收时,Entry 对象会被垃圾回收器自动回收。但是,如果线程复用后不再调用 ThreadLocal 的 set/get/remove 方法,Entry 对象就不会被回收,因为它仍然被 ThreadLocalMap 引用着,而 ThreadLocalMap 又与线程相关联,因此即使线程被复用,Entry 对象仍然存在于 ThreadLocalMap 中,从而导致内存泄漏。
候选者:使用ThreadLocal的最佳实践就是:用完了,手动remove掉。就像使用Lock加锁后,要记得解锁
面试官:那我想问下,为什么要将ThreadLocalMap的key设置为弱引用呢?强引用不香吗?
候选者:外界是通过ThreadLocal来对ThreadLocalMap进行操作的,假设外界使用ThreadLocal的对象被置null了,那ThreadLocalMap的强引用指向ThreadLocal也毫无意义啊。
候选者:弱引用反而可以预防大多数内存泄漏的情况
候选者:毕竟被回收后,下一次调用set/get/remove时ThreadLocal内部会清除掉
面试官:我看网上有很多人说建议把ThreadLocal修饰为static,为什么?
候选者:ThreadLocal能实现了线程的数据隔离,不在于它自己本身,而在于Thread的ThreadLocalMap
候选者:所以,ThreadLocal可以只初始化一次,只分配一块存储空间就足以了,没必要作为成员变量多次被初始化。
面试官:最后想问个问题:什么叫做内存泄露?
候选者:……
候选者:意思就是:你申请完内存后,你用完了但没有释放掉,你自己没法用,系统又没法回收。
面试官:清楚了
8. ThreadLocal的常用场景有哪些?
定义
ThreadLocal是什么从名字我们就可以看到ThreadLocal叫做线程变量,意思是ThreadLocal中填充的变量属于当前线程,该变量对其他线程而言是隔离的。ThreadLocal为变量在每个线程中都创建了一个副本,那么每个线程可以访问自己内部的副本变量。从字面意思来看非常容易理解,但是从实际使用的角度来看,就没那么容易了,作为一个面试常问的点,使用场景那也是相当的丰富:
1. 代替参数传递
代替参数的显式传递当我们在写API接口的时候,通常Controller层会接受来自前端的入参,当这个接口功能比较复杂的时候,可能我们调用的Service层内部还调用了很多其他的很多方法,通常情况下,我们会在每个调用的方法上加上需要传递的参数。但是如果我们将参数存入ThreadLocal中,那么就不用显式的传递参数了,而是只需要ThreadLocal中获取即可。这个场景其实使用的比较少,一方面显式传参比较容易理解,另一方面我们可以将多个参数封装为对象去传递。
2.解析信息
用户信息会保存在Session或者Token中。这个时候,我们如果使用常规的手段去获取用户信息会很费劲,拿Session来说,我们要在接口参数中加上HttpServletRequest对象,然后调用 getSession方法,且每一个需要用户信息的接口都要加上这个参数,才能获取Session,这样实现就很麻烦了。我们需要使用ThreadLocal,我们会选择在拦截器的业务中, 获取到保存的用户信息,然后存入ThreadLocal,那么当前线程在任何地方如果需要拿到用户信息都可以使用ThreadLocal的get()方法 。当用户登录后,会将用户信息存入Token中返回前端当用户调用需要授权的接口时,需要在header中携带 Token,然后拦截器中解析Token,获取用户信息,调用自定义的类存入 ThreadLocal中,当请求结束的时候,将ThreadLocal存储数据清空, 中间的过程无需在关注如何获取用户信息只需要使用工具类的get方法即可,
3. 资源分割
解决线程安全问题在Spring的Web项目中,我们通常会将业务分为Controller层Service层,Dao层,我们都知道@Autowired注解默认使用单例模式,那么不同请求线程进来之后,由于Dao层使用单例,那么负责数据库连接的Connection也只有一个,如果每个请求线程都去连接数据库,那么就会造成线程不安全的问题Spring是如何解决这个问题的呢?在Spring项目中Dao层中装配的Connection是线程安全的,其解决方案就是采用ThreadLocal方法,当每个请求线程使用Connection的时候,都会从ThreadLocal获取一次,如果为null,说明没有进行过数据库连接,连接后存入ThreadLocal中,如此一来,每一个请求线程都保存有一份自己的Connection。于是便解决了线程安全问题ThreadLocal在设计之初就是为解决并发问题而提供一种方案,每个线程维护一份自己的数据,达到线程隔离的效果。
7 ## Volatile
volatile是Java中的一个关键字,用于声明变量是“易变的”(volatile variable)。在多线程编程中,volatile主要用于确保变量的可见性和禁止指令重排序优化,从而保证多线程环境中的数据同步和安全性。
使用volatile关键字声明的变量,在被读取时,总是从主内存中读取最新的值,而不是从线程的本地缓存中读取值。同时,当一个线程修改了volatile变量的值时,它会立即将新的值刷新回主内存,而不是等到线程结束时再刷新,从而确保了多线程环境中的数据可见性。
需要注意的是,volatile只能保证变量的可见性和禁止指令重排序优化,但不能保证变量操作的原子性。如果多个线程同时对volatile变量进行写操作,那么仍然可能出现竞争和数据不一致的问题。如果需要保证变量的原子性,可以使用Atomic类或锁等同步机制来解决。
因此,volatile是一个关键字,用于声明变量是“易变的”,在多线程编程中用于确保变量的可见性和禁止指令重排序优化。
9. CAS
CAS(Compare and Swap)操作是一种原子性操作,常用于实现乐观锁。在Java中,CAS操作通常是由java.util.concurrent包下的Atomic类提供的,如AtomicInteger、AtomicLong等。
CAS操作包含三个操作数:内存位置V、旧的预期值A和新的值B。操作的时候,如果当前内存位置V的值等于预期值A,那么就将内存位置V的值更新为新的值B,否则不进行操作。整个操作是原子性的,即在同一时刻只有一个线程能够执行该操作。
Java中的Atomic类就是基于CAS操作实现的,它提供了一些原子性的操作,如getAndIncrement()、compareAndSet()等。其中,compareAndSet()就是一个典型的CAS操作,它会比较Atomic对象的当前值是否等于期望值,如果相等则更新为新值,否则不进行更新。
CAS操作的优点在于它不需要使用锁,避免了锁的竞争和开销,从而提高了程序的并发性能。但是CAS操作也有一定的缺点,即它可能会存在ABA问题。例如,一个线程读取了某个变量的值A,然后另一个线程将变量的值修改为B,然后又将变量的值修改为A,此时第一个线程再次进行CAS操作时,由于变量的值仍然等于A,因此它会认为变量的值没有被修改,从而进行了错误的操作。为了解决ABA问题,Java中提供了AtomicStampedReference类和AtomicMarkableReference类,它们在原子操作的基础上增加了一个标记,用于检测变量是否被修改过。
CAS三大缺陷❝「学习思路:先了解CAS语义的一些不足之处,然后再来学习优化思路。」❞
- ABA问题(面试重点)大白话描述:如果某个值一开始是A,后来变成了B,然后又变成了A,你本来期望的是值如果是第一个A才会设置新值,结果第二个A一比较也ok,也设置了新值,跟期望是不符合的。
你期望的是第一个A,但是现在改成了第二个A,和你的期望应该是不符合的,但是你还是没判断出来,仍旧去更新了。(这个逻辑就错误了)
使用CAS操作时,修改了链表中某个结点的指向,但是CAS操作只能比较和修改变量的值,无法感知到指针引用的变化
10. AQS
候选者:嗯嗯,AQS全称叫做AbstractQueuedSynchronizer
候选者:是可以给我们实现锁的一个「框架」,内部实现的关键就是维护了一个先进先出的队列以及state状态变量
候选者:先进先出队列存储的载体叫做Node节点,该节点标识着当前的状态值、是独占还是共享模式以及它的前驱和后继节点等等信息
候选者:简单理解就是:AQS定义了模板,具体实现由各个子类完成。
候选者:总体的流程可以总结为:会把需要等待的线程以Node的形式放到这个先进先出的队列上,state变量则表示为当前锁的状态。
候选者:像ReentrantLock、ReentrantReadWriteLock、CountDownLatch、Semaphore这些常用的实现类都是基于AQS实现的候选者:
AQS支持两种模式:独占(锁只会被一个线程独占)和共享(多个线程可同时执行)
面试官:你以ReentrantLock来讲讲加锁和解锁的过程呗
候选者:以非公平锁为例,我们在外界调用lock方法的时候,源码是这样实现的
候选者:1):CAS尝试获取锁,获取成功则可以执行同步代码
候选者:2):CAS获取失败,则调用acquire方法,acquire方法实际上就是AQS的模板方法
候选者:3):acquire首先会调用子类的tryAcquire方法(又回到了ReentrantLock中)
候选者:4):tryAcquire方法实际上会判断当前的state是否等于0,等于0说明没有线程持有锁,则又尝试CAS直接获取锁
候选者:5):如果CAS获取成功,则可以执行同步代码
候选者:6):如果CAS获取失败,那判断当前线程是否就持有锁,如果是持有的锁,那更新state的值,获取得到锁(这里其实就是处理可重入的逻辑)
候选者:7):CAS失败&&非重入的情况,则回到tryAcquire方法执行「入队列」的操作
候选者:8):将节点入队列之后,会判断「前驱节点」是不是头节点,如果是头结点又会用CAS尝试获取锁
候选者:9):如果是「前驱节点」是头节点并获取得到锁,则把当前节点设置为头结点,并且将前驱节点置空(实际上就是原有的头节点已经释放锁了)
候选者:10):没获取得到锁,则判断前驱节点的状态是否为SIGNAL,如果不是,则找到合法的前驱节点,并使用CAS将状态设置为SIGNAL
候选者:11):最后调用park将当前线程挂起
面试官:你说了一大堆,麻烦使用压缩算法压缩下加锁的过程。
候选者:压缩后:当线程CAS获取锁失败,将当前线程入队列,把前驱节点状态设置为SIGNAL状态,并将自己挂起。
面试官:为什么要设置前驱节点为SIGNAL状态,有啥用?
候选者:其实就是表示后继节点需要被唤醒
候选者:我先把解锁的过程说下吧
候选者:1):外界调用unlock方法时,实际上会调用AQS的release方法,而release方法会调用子类tryRelease方法(又回到了ReentrantLock中)
候选者:2):tryRelease会把state一直减(锁重入可使state>1),直至到0,当前线程说明已经把锁释放了候选者:
3):随后从队尾往前找节点状态需要 < 0,并离头节点最近的节点进行唤醒
候选者:唤醒之后,被唤醒的线程则尝试使用CAS获取锁,假设获取锁得到则把头节点给干掉,把自己设置为头节点
候选者:解锁的逻辑非常简单哈,把state置0,唤醒头结点下一个合法的节点,被唤醒的节点线程自然就会去获取锁面试官:嗯,了解了。候选者:回到上一个问题,为什么要设置前驱节点为SIGNAL状态候选者:其实归终结底就是为了判断节点的状态,去做些处理。
候选者:Node 中节点的状态有4种,分别是:CANCELLED(1)、SIGNAL(-1)、CONDITION(-2)、PROPAGATE(-3)和0
候选者:在ReentrantLock解锁的时候,会判断节点的状态是否小于0,小于等于0才说明需要被唤醒候选者:另外一提的是:公平锁的实现与非公平锁是很像的,只不过在获取锁时不会直接尝试使用CAS来获取锁。
候选者:只有当队列没节点并且state为0时才会去获取锁,不然都会把当前线程放到队列中
面试官:最后画个流程图吧
11. Java中的锁
P、synchronized与Lock
Java中有两种加锁的方式:一种是用synchronized关键字,另一种是用Lock接口的实现类。
形象地说,synchronized关键字是自动档,可以满足一切日常驾驶需求。但是如果你想要玩漂移或者各种骚操作,就需要手动档了——各种Lock的实现类。
一、乐观锁与悲观锁
乐观锁(Optimistic Lock):在Java中,乐观锁主要通过CAS(Compare and Swap)操作来实现。
乐观锁, 就是很乐观,每次去拿数据的时候都认为别人不会修改。所以不会上锁,不会上锁!但是如果想要更新数据,则会在更新前检查在读取至更新这段时间别人有没有修改过这个数据。如果修改过,则重新读取,再次尝试更新,循环上述步骤直到更新成功(当然也允许更新失败的线程放弃操作)。
悲观锁:在Java中,悲观锁主要通过synchronized关键字来实现。synchronized可以保证同一时刻只有一个线程能够访问共享资源,其他线程需要等待锁释放才能访问。Java中的Lock接口也提供了悲观锁的实现。
悲观锁阻塞事务,乐观锁回滚重试,它们各有优缺点,不要认为一种一定好于另一种。像乐观锁适用于写比较少的情况下,即冲突真的很少发生的时候,这样可以省去锁的开销,加大了系统的整个吞吐量。但如果经常产生冲突,上层应用会不断的进行重试,这样反倒是降低了性能,所以这种情况下用悲观锁就比较合适。
自旋锁:在Java中,自旋锁主要通过CAS操作和循环来实现。Java中的Atomic类中的自旋锁通过循环调用CAS操作来实现,而ReentrantLock中的自旋锁则是在获取锁时进行自旋操作,直到获取到锁为止。
四、synchronized锁升级:偏向锁 → 轻量级锁 → 重量级锁
前面提到,synchronized关键字就像是汽车的自动档,现在详细讲这个过程。一脚油门踩下去,synchronized会从无锁升级为偏向锁,再升级为轻量级锁,最后升级为重量级锁,就像自动换挡一样。那么自旋锁在哪里呢?这里的轻量级锁
就是一种自旋锁
。
初次执行到synchronized代码块的时候,锁对象变成偏向锁(通过CAS修改对象头里的锁标志位),字面意思是“偏向于第一个获得它的线程”的锁。执行完同步代码块后,线程并不会主动释放偏向锁。当第二次到达同步代码块时,线程会判断此时持有锁的线程是否就是自己(持有锁的线程ID也在对象头里),如果是则正常往下执行。由于之前没有释放锁,这里也就不需要重新加锁。如果自始至终使用锁的线程只有一个,很明显偏向锁几乎没有额外开销,性能极高。
一旦有第二个线程加入锁竞争,偏向锁就升级为轻量级锁(自旋锁)
。这里要明确一下什么是锁竞争:如果多个线程轮流获取一个锁,但是每次获取锁的时候都很顺利,没有发生阻塞,那么就不存在锁竞争。只有当某线程尝试获取锁的时候,发现该锁已经被占用,只能等待其释放,这才发生了锁竞争。
在轻量级锁状态下继续锁竞争,没有抢到锁的线程将自旋
,即不停地循环判断锁是否能够被成功获取。获取锁的操作,其实就是通过CAS修改对象头里的锁标志位。先比较当前锁标志位是否为“释放”,如果是则将其设置为“锁定”,比较并设置是原子性发生的。这就算抢到锁了,然后线程将当前锁的持有者信息修改为自己。
长时间的自旋操作是非常消耗资源的,一个线程持有锁,其他线程就只能在原地空耗CPU,执行不了任何有效的任务,这种现象叫做忙等(busy-waiting)
。如果多个线程用一个锁,但是没有发生锁竞争,或者发生了很轻微的锁竞争,那么synchronized就用轻量级锁,允许短时间的忙等现象。这是一种折衷的想法,短时间的忙等,换取线程在用户态和内核态之间切换的开销。
显然,此忙等是有限度的(有个计数器记录自旋次数,默认允许循环10次,可以通过虚拟机参数更改)。如果锁竞争情况严重,某个达到最大自旋次数的线程,会将轻量级锁升级为重量级锁(依然是CAS修改锁标志位,但不修改持有锁的线程ID)。当后续线程尝试获取锁时,发现被占用的锁是重量级锁,则直接将自己挂起(而不是忙等),等待将来被唤醒。在JDK1.6之前,synchronized直接加重量级锁,很明显现在得到了很好的优化。
一个锁只能按照 偏向锁、轻量级锁、重量级锁的顺序逐渐升级(也有叫锁膨胀
的),不允许降级。
A Good Question
偏向锁的一个特性是,持有锁的线程在执行完同步代码块时不会释放锁。那么当第二个线程执行到这个synchronized代码块时是否一定会发生锁竞争然后升级为轻量级锁呢?
线程A第一次执行完同步代码块后,当线程B尝试获取锁的时候,发现是偏向锁,会判断线程A是否仍然存活。如果线程A仍然存活,将线程A暂停,此时偏向锁升级为轻量级锁,之后线程A继续执行,线程B自旋。但是如果判断结果是线程A不存在了,则线程B持有此偏向锁,锁不升级。
还有人对此有疑惑,我之前确实没有描述清楚,但如果要展开讲,涉及到太多新概念,可以新开一篇了。更何况有些太底层的东西,我没读过源码,没有自信说自己一定是对的。其实在升级为轻量级锁之前,虚拟机会让线程A尽快在安全点挂起,然后在它的栈中“伪造”一些信息,让线程A在被唤醒之后,认为自己一直持有的是轻量级锁。如果线程A之前正在同步代码块中,那么线程B自旋等待即可。如果线程A之前不在同步代码块中,它会在被唤醒后检查到这一情况并立即释放锁,让线程B可以拿到。这部分内容我之前也没有深入研究过,如果有说的不对的,请多多指教啊!
五、可重入锁(递归锁)
可重入锁:在Java中,可重入锁主要通过ReentrantLock类来实现。ReentrantLock类提供了与synchronized关键字类似的功能,但支持更多的高级特性,如公平锁和非公平锁、可重入、可中断等。
可重入锁的字面意思是“可以重新进入的锁”,即允许同一个线程多次获取同一把锁。比如一个递归函数里有加锁操作,递归过程中这个锁会阻塞自己吗?如果不会,那么这个锁就是可重入锁(因为这个原因可重入锁也叫做递归锁)。
Java里只要以Reentrant开头命名的锁都是可重入锁,而且JDK提供的所有现成的Lock实现类,包括synchronized关键字锁都是可重入的。如果你需要不可重入锁,只能自己去实现了。网上不可重入锁的实现真的很多,就不在这里贴代码了。99%的业务场景用可重入锁就可以了,剩下的1%是什么呢?我也不知道,谁可以在评论里告诉我?### Wait()和Sleep()的区别
在 Java 中,wait() 和 sleep() 都是线程相关的方法,但是它们的作用和使用方式是不同的。
六、公平锁、非公平锁
如果多个线程申请一把公平锁
,那么当锁释放的时候,先申请的先得到,非常公平。显然如果是非公平锁
,后申请的线程可能先获取到锁,是随机或者按照其他优先级排序的(。
对ReentrantLock类而言,通过构造函数传参可以指定该锁是否是公平锁,默认是非公平锁。一般情况下,非公平锁的吞吐量比公平锁大,如果没有特殊要求,优先使用非公平锁。
七、可中断锁
可中断锁,字面意思是“可以响应中断的锁”。
这里的关键是理解什么是中断。Java并没有提供任何直接中断某线程的方法,只提供了中断机制。何谓“中断机制”?线程A向线程B发出“请你停止运行”的请求(线程B也可以自己给自己发送此请求),但线程B并不会立刻停止运行,而是自行选择合适的时机以自己的方式响应中断,也可以直接忽略此中断。也就是说,Java的中断不能直接终止线程,而是需要被中断的线程自己决定怎么处理。这好比是父母叮嘱在外的子女要注意身体,但子女是否注意身体,怎么注意身体则完全取决于自己。
回到锁的话题上来,如果线程A持有锁,线程B等待获取该锁。由于线程A持有锁的时间过长,线程B不想继续等待了,我们可以让线程B中断自己或者在别的线程里中断它,这种就是可中断锁。
在Java中,synchronized就是不可中断锁,而Lock的实现类都是可中断锁,可以简单看下Lock接口。
/* Lock接口 */
public interface Lock {
void lock(); // 拿不到锁就一直等,拿到马上返回。
void lockInterruptibly() throws InterruptedException; // 拿不到锁就一直等,如果等待时收到中断请求,则需要处理InterruptedException。
boolean tryLock(); // 无论拿不拿得到锁,都马上返回。拿到返回true,拿不到返回false。
boolean tryLock(long time, TimeUnit unit) throws InterruptedException; // 同上,可以自定义等待的时间。
void unlock();
Condition newCondition();
}
八、读写锁、共享锁、互斥锁
记得之前的乐观锁策略吗?所有线程随时都可以读,仅在写之前判断值有没有被更改。
读写锁其实做的事情是一样的,但是策略稍有不同。很多情况下,线程知道自己读取数据后,是否是为了更新它。那么何不在加锁的时候直接明确这一点呢?如果我读取值是为了更新它(SQL的for update就是这个意思),那么加锁的时候就直接加写锁
,我持有写锁的时候别的线程无论读还是写都需要等待;如果我读取数据仅为了前端展示,那么加锁时就明确地加一个读锁,其他线程如果也要加读锁,不需要等待,可以直接获取(读锁计数器+1)。
虽然读写锁感觉与乐观锁有点像,但是读写锁是悲观锁策略。因为读写锁并没有在更新前判断值有没有被修改过,而是在加锁前决定应该用读锁还是写锁。乐观锁特指无锁编程,如果仍有疑惑可以再回到第一、二小节,看一下什么是“乐观锁”。
JDK提供的唯一一个ReadWriteLock接口实现类是ReentrantReadWriteLock。看名字就知道,它不仅提供了读写锁,而是都是可重入锁。 除了两个接口方法以外,ReentrantReadWriteLock还提供了一些便于外界监控其内部工作状态的方法,这里就不一一展开。
九、回到悲观锁和乐观锁
这篇文章经历过一次修改,我之前认为偏向锁和轻量级锁是乐观锁,重量级锁和Lock实现类为悲观锁,网上很多资料对这些概念的表述也很模糊,各执一词。
先抛出我的结论:
我们在Java里使用的各种锁,几乎全都是悲观锁。synchronized从偏向锁、轻量级锁到重量级锁,全是悲观锁。JDK提供的Lock实现类全是悲观锁。其实只要有“锁对象”出现,那么就一定是悲观锁。因为乐观锁不是锁,而是一个在循环里尝试CAS的算法。
那JDK并发包里到底有没有乐观锁呢?
有。java.util.concurrent.atomic包里面的原子类都是利用乐观锁实现的。
原子类AtomicInteger的自增方法为乐观锁策略
为什么网上有些资料认为偏向锁、轻量级锁是乐观锁?理由是它们底层用到了CAS?或者是把“乐观/悲观”与“轻量/重量”搞混了?其实,线程在抢占这些锁的时候,确实是循环+CAS的操作,感觉好像是乐观锁。但问题的关键是,我们说一个锁是悲观锁还是乐观锁,总是应该站在应用层,看它们是如何锁住应用数据的,而不是站在底层看抢占锁的过程。如果一个线程尝试获取锁时,发现已经被占用,它是否继续读取数据,等后续要更新时再决定要不要重试?对于偏向锁、轻量级锁来说,显然答案是否定的。无论是挂起还是忙等,对应用数据的读取操作都被“挡住”了。从这个角度看,它们确实是悲观锁。
Wait & Sleep
-
wait方法是
Object类
的方法,可以在任何对象上调用,而sleep方法是Thread
类的方法,只能在当前线程上调用。 -
wait方法会释放对象的锁,让其他线程可以进入同步块或方法,而sleep方法不会释放锁。
-
wait方法可以被
notify
或notifyAll
方法唤醒,而sleep方法必须等待指定的时间过去或被interrupted才能终止。 -
wait方法必须在
synchronized
块中调用,否则会抛出IllegalMonitorStateException
异常,而sleep方法可以在任何地方调用。 -
wait方法和notify方法通常用于线程间的通信,而sleep方法用于控制线程的执行顺序和时间。
12. 讲讲CountDownLatch和CyclicBarrier
面试官:现在我有50个任务,这50个任务在完成之后,才能执行下一个「函数」,要是你,你怎么设计?候选者:嗯,我想想哈。候选者:可以用JDK给我们提供的线程工具类,CountDownLatch
和CyclicBarrier
都可以完成这个需求。候选者:这两个类都可以等到线程完成之后,才去执行某些操作
面试官:那既然都能实现的话?那CountDownLatch和CyclicBarrier有什么什么区别呢?
候选者:主要的区别就是CountDownLatch用完了,就结束了,没法复用。而CyclicBarrier不一样,它可以复用。
面试官:那如果是这样的话,那我多次用CountDownLatch不也可以解决问题吗?候选者:….面试官:要不今天面试就到这里就结束了?你有什么想问我的吗?
面试官:是这样的,我提出了个场景,它确实很像可以用CountDownLatch和CyclicBarrier解决面试官:但是,作为面试者的你可以尝试向我获取更多的信息
面试官:我可没说一个任务就用一个线程处理哦
面试官:放一步讲,即便我是想考察CountDownLatch和CyclicBarrier的知识
面试官:但是过程也是很重要的,我会看你思考的以及沟通的过程候选者:…
面试官:你提到了CountDownLatch和CyclicBarrier这些关键词,不能就直接就抛给我面试官:我是希望你能讲下什么是CountDownLatch和CyclicBarrier分别是什么意思
面试官:比如说:CountDownLatch和CyclicBarrier都是线程同步的工具类
面试官:CountDownLatch允许一个或多个线程一直等待,直到这些线程完成它们的操作
面试官:而CyclicBarrier不一样,它往往是当线程到达某状态后,暂停下来等待其他线程,等到所有线程均到达以后,才继续执行
面试官:可以发现这两者的等待主体是不一样的。
面试官:CountDownLatch调用await()通常是主线程/调用线程,而CyclicBarrier调用await()是在任务线程调用的
在使用CountDownLatch时,通常主线程会调用await()方法,等待计数器减为0,而其他线程执行完后会调用countDown()方法来减少计数器的值。
CyclicBarrier也是一种同步工具,它的作用是让多个线程在某个屏障处等待,直到所有线程都到达后再继续执行。在使用CyclicBarrier时,所有任务线程会在等待屏障处调用await()方法,等待所有线程都到达后再继续执行。
面试官:所以,CyclicBarrier中的阻塞的是任务的线程,而主线程是不受影响的。
面试官:简单叙述完这些基本概念后,可以特意抛出这两个类都是基于AQS
实现的
面试官:反正你在前几次面试的过程中都说过AQS
了,我知道你是懂的,你可以抛出来
面试官:比如说CountDownLatch你就可以回答:前面提到了CountDownLatch也是基于AQS实现的,它的实现机制很简单面
试官:当我们在构建CountDownLatch对象时,传入的值其实就会赋值给 AQS 的关键变量state面试官:执行countDown方法时,其实就是利用CAS 将state 减一
面试官:执行await方法时,其实就是判断state是否为0,不为0则加入到队列中,将该线程阻塞掉(除了头结点)
面试官:因为头节点会一直自旋等待state为0,当state为0时,头节点把剩余的在队列中阻塞的节点也一并唤醒。
面试官:回到CyclicBarrier上,代码也不难,重点就是await方法
面试官:从源码不难发现的是,它没有像CountDownLatch和ReentrantLock使用AQS的state变量,而CyclicBarrier是直接借助ReentrantLock加上Condition 等待唤醒的功能 进而实现的
面试官:在构建CyclicBarrier时,传入的值会赋值给CyclicBarrier内部维护count
变量,也会赋值给parties变量(这是可以复用的关键)
面试官:每次调用await时,会将count -1 ,操作count值是直接使用ReentrantLock来保证线程安全性面试官:如果count不为0,则添加则condition队列中
面试官:如果count等于0时,则把节点从condition队列添加至AQS的队列中进行全部唤醒,并且将parties的值重新赋值为count的值(实现复用)
13. 协程
协程(Coroutine)是一种轻量级的线程(或称协作式任务),不同于传统的线程(或称抢占式任务),它是由程序员手动控制的。协程可以在单个线程中运行多个协作任务,这些任务可以在特定的时刻暂停或恢复执行,并在暂停时保留它们的状态。协程通常用于执行高并发、高吞吐量的 I/O 操作,以及处理复杂的并发编程任务。
在传统的线程模型中,线程是由操作系统内核进行调度的,每个线程都有自己的上下文和状态,这些状态需要在线程间频繁地切换,需要消耗大量的系统资源。而在协程模型中,协程是由程序员自己控制的,不需要频繁地进行上下文切换,可以有效地节约系统资源,提高程序的并发能力。
协程通常具有以下特点:
轻量级:协程可以在同一个线程内创建和运行多个任务,避免了线程的创建和销毁的开销,因此比线程更加轻量级。
协作式:协程的调度是由程序员手动控制的,需要在特定的时刻手动暂停和恢复执行,因此需要协作式的编程模型。
高效性:协程不需要频繁地进行上下文切换,避免了线程切换的开销,因此可以提高程序的运行效率和并发能力。
可靠性:由于协程的调度是由程序员手动控制的,因此可以避免一些常见的线程安全问题,如竞态条件和死锁等。
在 Java 中,协程通常是通过第三方库实现的,如 Quasar、Kotlin Coroutines 和 Project Loom 等。这些库可以有效地提高 Java 程序的并发性能和可靠性,使得开发人员能够更加轻松地实现高效的异步编程。
14. Collections
15. Java的HashCode实现方法有哪些?
SHA-1
MOD
特定类的实现
Integer.hashCode() 的实现为:
public int hashCode() {
return value;
}
String.hashCode()的实现为
public int hashCode() {
int h = hash;
if (h == 0) {
int off = offset;
char val[] = value;
int len = count;
for (int i = 0; i < len; i++) {
h = 31*h + val[off++];
}
hash = h;
}
return h;
}
之所以选择31,是因为它是个奇素数,如果乘数是偶数,并且乘法溢出的话,信息就会丢失,因为与2相乘等价于移位运算。使用素数的好处并不是很明显,但是习惯上都使用素数来计算散列结果。31有个很好的特性,就是用移位和减法来代替乘法,可以得到更好的性能:31*i==(i<<5)-i。现在的VM可以自动完成这种优化。
16. 接口的幂等性怎么设计?
关于幂等,我浏览了一下网络上的实现方案,感觉大体上大同小异:
- 用户端向客户端请求一个唯一的token,服务端接到这个请求后生成一个token并放入redis,将这个token返回给客户端。
- 客户端再携带这个token发送“创建”请求。服务端接收到“创建”请求后,去redis中查询这个token,如果有,就执行请求,并删掉redis中的token。
- 如果没有,说明请求已经执行过了,直接返回结果(比如告诉用户端请求重复了)。这样的作法能确保请求只执行一次,也就是所谓的幂等。
可是
大厂的实际实现却没有这么简单,接下来我将谈谈互联网大厂究竟是如何实现幂等接口的。
首先看看大厂对幂等接口目标的定义:
在网络请求中,客户端可能因为各种原因重复发送请求,在这种情况下,我们的服务必须避免重复执行这些请求。
举个简单的例子,对于电商项目,如果客户连续点击“提交”按钮导致前端重复发送创建订单的请求,后端不应该创建多个订单,而应该只创建一个订单。再看api的适用范围,理论上我们只对创建和删除这两类api做幂等处理,其他的api(如更新,或者创建\更新)则不需要做幂等处理。
再看服务的理想情况:
- 如果服务接受了两个请求,且
幂等token
和请求的参数
完全一样,服务应该返回相似的结果。 - 如果服务接受了两个请求,
幂等token
一样但“请求的参数
不完全一样,服务应该拒绝该请求。
不同用户之间的请求不能相互影响。首先说说幂等token,与网上大部分的实现不同,大厂真正的实现中不做强制要求客户端向服务端拿token。也就是说,这个token可以是客户端(比如前端)自己生成的,也可以是向客户端请求得到的,不同的组有不同的实现,但对于服务端来说,我只关注这个token,至于这个token怎么来的我并不关心。
再说说第一点中加粗的相似的结果,由于这一点要求直接导致了不能采用网络上通用的幂等的实现方式。有读者可能会迷糊,什么叫相似的结果?比如,有一个创建订单的请求,发送之后返回的结果可能包含订单详细信息(订单编号,创建时间等),那么我第二次发送的幂等请求(注意这里的幂等token要和第一次一致,否则不会触发幂等条件)返回结果里依然要有这些信息,但是部分属性可以不一致,比如订单状态。如果按照之前的实现方案,那就没有办法返回这种相似的结果。
具体实现
我们来看看大厂究竟如何实现这种幂等接口的。
1.首先,我们在所有的request之前加上一层拦截器,拦截所有的request,在这一层做幂等的实现。
public class IdempotentRequestBeforeIntecepter {
public void intecept(Context context) {
Request request = context.getResponse();
Optional<String> idempotentToken = request.getIdempotentToken();
if (idempotentToken.isEmpty()) {
return;
}
// 操作的名称,例子:u1234#CreateOrderRequest
String operationId = request.getUserId() + "#" + request.getClass().getSimpleName();
context.putOperationId(operationId);
Optional<RequestResponse<Object, Object>> storedRequestResponse = getStoredIdempotentRequestResponse(
operationId,
idempotentToken.get());
RequestResponse<Object, Object> newRequestResponse = RequestResponse.of(request, null);
if (storedRequestResponse.isEmpty()) {
saveNewRerquestResponse(operationId, idempotentToekn, newRequestResponse);
context.setIdempotentStatus(NEW_REQUEST);
return;
}
if (!Object.equals(storedRequestResponse.getRequest(), request)) {
context.setIdempotentStatus(VALIDATION_EXCEPTION);
throw new ValidationException("请求参数与之前不一致");
}
if (null == Object.equals(storedRequestResponse.getResponse())) {
context.setIdempotentStatus(REQUEST_IS_EXECUTING_EXCEPTION);
throw new RequestIsExecutingExeption("请求正在执行");
}
context.setIdempotentStatus(IDEMPOTENT_RESPONSE);
context.setResponse(storedRequestResponse.getResponse());
context.setDone(true);
}
}
2.在处理完request之后,再加一个拦截器,拦截所有response的结果:
public class IdempotentRequestAfterIntecepter {
public void intecept(Context context) {
Request request = context.getRequest();
Optional<String> idempotentToken = request.getIdempotentToken();
if (idempotentToken.isEmpty()) {
return;
}
String operationId = context.getOperationId(operationId);
IdempotentStatus status = context.getIdempotentStatus();
if (VALIDATION_EXCEPTION.eqauls(status)
|| REQUEST_IS_EXECUTING_EXCEPTION.equals(status)
|| IDEMPOTENT_RESPONSE.equals(status)) {
return;
}
Request request = context.getRequest();
Response response = context.getResponse();
RequestResponse<Object, Object> requestResponse = RequestResponse.of(request, response);
updateRerquestResponse(operationId, idempotentToekn, newRequestResponse);
}
}
简要说明一下逻辑,在执行请求之前:
- 判断请求中是否有幂等token,
- 如果有,说明是一个幂等请求,根据请求生成一个
operationId
,注意,这个operationId
是用户名+请求类别,也就是说,对于同一个用户的同一个类别(比如创建订单)的请求,只要用户端能保证幂等token唯一,那服务端就能保证这个数据全局唯一。然后,我们根据token,operationId从数据库(什么数据库都可以,比如mysql,redis等)取出数据。分两种情况- 取不到:说明这是一个新请求,那我们就向数据库中放入一个新的RequestResponse对象,且response为null
- 取到了:对比请求是否完全一致,不一致则抛异常,再看“响应”是否为null,如果为null则说明之前的请求还在执行过程中,抛异常。如果都不是,说明我们在数据库中的reponse就是用户需要的结果,直接设置response。
在请求执行之后:
- 判断一下幂等状态,如果:
- 之前抛了异常或者已经是幂等结果,直接返回结果给用户。
- 如果都不是,说明是一个新请求,且之前已经在数据库中存入了RequestResponse对象(response为null),我们只需要更新这个response就行了。
再说说如何存储request和response,对于这两个对象,我们先选取一个Serializer
,把Object序列化为String,然后将String转为utf8存入数据库即可。从数据取数据时则相反,先将utf8转为字符串,再把字符串反序列化为Object。这里由于有多种实现方式,且实现比较简单,我就不举例了。
总结
可以看到这样的实现方式和网络上通用的方式有着本质的区别,其扩展性也非常强,不得不佩服大厂中的优秀的程序员。相比于之前那种实现方式,该方式有如下几个优点:
- 对于request可以做额外的逻辑:之前的方式中,服务端对于幂等的判断全局限在token上,request发生了变化对于服务端来说是完全没有感知的。但本文的实现方式中如果request发生了变化则会抛出validationException,更加符合幂等的定义。(request参数不同则不应该叫做幂等)。
- 本文的实现方式中存储了response,那么对于相同的request我们能直接返回response而不是返回给用户一个重复执行的异常。这应该是最大的优点。
17.正向代理 & 反向代理
正向代理
通常我们说的代理,都是指的正向代理。
继续看这张图,你会发现,此处的代理服务器可以由客户端提供,也可以由服务器端提供。
当客户端主动使用代理服务器时,此时的代理叫正向代理。比如:一些网络代理工具(加速器/VPN…)
正向代理完整流程
正向代理时,由客户端发送对某一个目标服务器的请求,代理服务器在中间将请求转发给该目标服务器,目标服务器将结果返回给代理服务器,代理服务器再将结果返回给客户端。使用正向代理时,客户端是需要配置代理服务的地址、端口、账号密码(如有)等才可使用的。
通过上图可以看到,客户端并没有直接与服务器相连。正向代理隐藏了真实的客户端地址。可以很好地保护客户端的安全性。正向代理的适用场景访问被禁止的资源(让客户端访问原本不能访问的服务器。
再比如客户端IP被服务器封禁,可以绕过IP封禁也可以突破网站的区域限制隐藏客户端的地址(对于被请求的服务器而言,代理服务器代表了客户端,所以在服务器或者网络拓扑上,看不到原始客户端)进行客户访问控制可以集中部署策略,控制客户端的访问行为(访问认证等)记录用户访问记录(上网行为管理)内部资源的控制(公司、教育网等)加速访问资源使用缓冲特性减少网络使用率(代理服务器设置一个较大的缓冲区,当有外界的信息通过时,同时也将其保存到缓冲区中,当其他用户再访问相同的信息时, 则直接由缓冲区中取出信息,传给用户,以提高访问速度。)过滤内容(可以通过代理服务器统一过滤一些危险的指令/统一加密一些内容、防御代理服务器两端的一些攻击性行为)
反向代理
服务器根据客户端的请求,从其关系的一组或多组后端服务器(如Web服务器)上获取资源,然后再将这些资源返回给客户端,客户端只会得知代理服务器的IP地址,而不知道在代理服务器后面的服务器集群的存在。
反向代理整个流程:
由客户端发起对代理服务器的请求,代理服务器在中间将请求转发给某一个服务器,服务器将结果返回给代理服务器,代理服务器再将结果返回给客户端。
反向代理的适用场景
负载均衡:如果服务器集群中有负荷较高者,反向代理通过URL重写,根据连线请求从负荷较低者获取与所需相同的资源或备援。可以有效降低服务器压力,增加服务器稳定性提升服务器安全性可以对客户端隐藏服务器的IP地址也可以作为应用层防火墙,为网站提供对基于Web的攻击行为(例如DoS/DDoS)的防护,更容易排查恶意软件等
加密/SSL加速:将SSL加密工作交由配备了SSL硬件加速器的反向代理来完成提供缓存服务,加速客户端访问对于静态内容及短时间内有大量访问请求的动态内容
提供缓存服务 加速客户端访问
对于静态内容及短时间内有大量访问请求的动态内容提供缓存服务
数据统一压缩
节约带宽
为网络带宽不好的网络提供服务
统一的访问权限控制
统一的访问控制
区别
正向代理与反向代理的区别当前面的内容理解后,对于正向代理和反向代理的区别就很好理解了。最核心的不同在于代理的对象不同。
正向代理是代理客户端。反向代理是代理服务器。
而根据这核心的区别,我们也可以记住:代理哪端便可以隐藏哪端。也就是说:正向代理隐藏真实客户端反向代理隐藏真实服务端。
Hash算法本质上就是三步:取key的hashCode值、高位运算、取模运算。
对于任意给定的对象,只要它的hashCode()返回值相同,那么程序调用方法一所计算得到的Hash码值总是相同的。我们首先想到的就是把hash值对数组长度取模运算,这样一来,元素的分布相对来说是比较均匀的。但是,模运算的消耗还是比较大的,在HashMap中是这样做的:调用方法二来计算该对象应该保存在table数组的哪个索引处。
这个方法非常巧妙,它通过h & (table.length -1)来得到该对象的保存位,而HashMap底层数组的长度总是2的n次方,这是HashMap在速度上的优化。当length总是2的n次方时,h& (length-1)运算等价于对length取模,也就是h%length,但是&比%具有更高的效率。
在JDK1.8的实现中,优化了高位运算的算法,通过hashCode()的高16位异或低16位实现的:(h = k.hashCode()) ^ (h >>> 16),主要是从速度、功效、质量来考虑的,这么做可以在数组table的length比较小的时候,也能保证考虑到高低Bit都参与到Hash的计算中,同时不会有太大的开销。
JAVA8中 HashMap 的优化?(头插尾插)
Java 8还有三点主要的优化:数组+链表改成了数组+链表或红黑树;
链表的插入方式从头插法改成了尾插法,简单说就是插入时,如果数组位置上已经有元素,Java 7 将新元素放到数组中,原始节点作为新节点的后继节点,
Java 8 遍历链表,将元素放置到链表的最后;
扩容的时候Java 7 需要对原数组中的元素进行重新 hash 定位在新数组的位置,
Java 8采用更简单的判断逻辑,位置不变或索引+旧容量大小;
在插入时,Java 7 先判断是否需要扩容,再插入,Java 8 先进行插入,插入完成再判断是否需要扩容;
为什么要做这几点优化?
防止发生 hash 冲突,链表长度过长,将时间复杂度由O(n)降为O(logn); 因为Java 7 头插法扩容时,头插法会使链表发生反转,多线程环境下会产生环; A 线程在插入节点 B,B 线程也在插入,遇到容量不够开始扩容,重新 hash,放置元素,采用头插法,后遍历到的 B 节点放入了头部,这样形成了环,如下图所示:
1.7 的扩容调用 transfer 代码,如下所示:
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i]; //A 线程如果执行到这一行挂起,B 线程开始进行扩容
newTable[i] = e;
e = next;
}
}
}
扩容的时候为什么Java 8不用重新 hash 就可以直接定位原节点在新数据的位置呢? 这是由于扩容是扩大为原数组大小的 2 倍,用于计算数组位置的掩码仅仅只是高位多了一个 1,怎么理解呢? 扩容前长度为 16,用于计算(n-1) & hash 的二进制 n-1 为 0000 1111,扩容为 32 后的二进制就高位多了 1,为 0001 1111。 因为是& 运算,1 和任何数 & 都是它本身,那就分二种情况,如下图:原数据 hashcode 高位第 4 位为 0 和高位为 1 的情况; 第四位高位为 0,重新 hash 数值不变,第四位为 1,重新 hash 数值比原来大 16(旧数组的容量)
在Java 8中,当链表的长度超过8时,会将链表转换为红黑树,以提高查询性能。在红黑树中,新节点的插入方式与链表略有不同,会按照键的大小进行插入,但也是插入到树的头部。
synchronized 作用于静态方法、普通方法、this、Lock.class 的区别
静态方法:synchronized 作用于静态方法时,锁定的是整个类,即使有多个实例,也只有一个锁。静态方法的锁定对象是当前类的 Class 对象。示例代码如下:
public class SynchronizedTest {
public static synchronized void staticMethod() {
// do something
}
}
普通方法:synchronized 作用于普通方法时,锁定的是当前对象,即每个对象都有一个锁。示例代码如下:
public class SynchronizedTest {
public synchronized void method() {
// do something
}
}
this:synchronized 作用于 this 关键字时,锁定的是当前对象,与普通方法相同。示例代码如下:
public class SynchronizedTest {
public void method() {
synchronized (this) {
// do something
}
}
}
Lock.class:synchronized 作用于 Lock.class 时,锁定的是 Lock.class 对象,即锁定的是所有使用该锁的对象。示例代码如下:
public class SynchronizedTest {
private static Lock lock = new ReentrantLock();
public void method() {
lock.lock();
try {
// do something
} finally {
lock.unlock();
}
}
}
Spring 中的常见扩展点有哪些
n Private & Friend & Protected & Public
2. 在类的其他构造方法中可以用this()的方式调用其他构造方法;
-
在类的子类中则可以通过super调用父类中指定的构造方法;
-
在反射中可以使用newInstance()的方式调用。