分布式和微服务面试

文章目录

一、Spring Boot常见面试题

1、Spring、Spring Boot和Spring Cloud的关系

  • Spring最初利用IOC和AOP解耦
  • 按照这种模式搞了MVC框架
  • 写很多样板代码很麻烦,就有了Spring Boot
  • Spring Cloud是在Spring Boot基础上诞生的

你知不知道spring、spring boot和spring cloud的关系呢?
        这是一道常见的面试题,并且有可能面试官会从这道题出发来逐步的去考察你对于spring spring boot以及spring cloud的了解,有可能呢,有的候选人啊,他不知道spring boot,也有的候选人呢不知道spring cloud,所以通过这道题呢,其实可以挖掘出候选人的很多信息,那大部分同学啊至少都是知道spring的,所以首先呢,我们要从spring出发,去讲一下spring他的最大的特点
        对于spring而言,它最大的两个核心特点呢就是IOC和AOP。这是spring的两大核心功能,并且呢,spring在这两大核心功能的基础上逐渐发展出来了,像spring事务、spring mvc这样的框架,而且这些框架呢,其实也都是非常强大非常伟大的,最终呢也就此成就了spring帝国,随着spring逐渐完善,它几乎可以解决企业开发中遇到的所有的问题,不过也正是因为它内容的丰富以及功能不断完善,不断强大,导致了它的体积也越来越大,而且也越来越笨重,这个笨重主要就体现在我们即便是开发一个简单的程序,都需要对它进行很繁琐的配置,而且呢有很多配置都是大同小异的,不同的项目之间,他们配置起来的内容呢,几乎是一模一样的,但是你不配置呢又不行,所以就写了很多的样板代码,也正是因为这样的样板代码很麻烦。才诞生了spring boot,这也是spring boot诞生的初衷
        最开始呢想开发spring boot的最核心的原因就是希望能解决掉,开发人员开发一个程序,这种配置太繁琐的这个问题,而且啊,这个开发spring的配套公司,也把spring boot定位为能够帮助程序员快速开发的一个快速框架,而且呢,这也是企业中所梦寐以求的,开发一个程序速度越快对于业务就越有利,也有利于抢占市场的先机,他们之间的关系,所以说啊,这里的第1层关系就出来了,spring和spring boot是什么关系呢?
        其实是这样子的,spring boot他是在强大的spring帝国生态上面发展而来的,而且发明spring boot是为了让人们更容易的去使用spring,所以说如果最开始没有spring的强大的功能和生态的话,那就更不可能会有后期的spring boot的火热,那spring boot呢,它的理念是约定优于配置,所以正是在这样理念的驱动下,很多配置我们都不需要再去额外的进行配置了,直接按照约定来就可以了,这也让我们的spring焕发了生机,让他的生命力更加强大了,那现在啊我们就说到了spring cloud,spring boot和spring cloud又是什么关系呢?
        其实spring cloud和spring boot的关系就类似于spring boot和spring的关系,也就是说啊,spring cloud他是在我们spring boot的基础上诞生的,spring cloud并没有去重复的造轮子,他只是将各家公司开发的,比较成熟的,经得起考验的服务框架呢,给组合了起来,并且啊同样的去利用spring boot这样的风格屏蔽掉复杂的配置和实现原理,给开发者呢,留出了一套简单易懂的、容易部署、容易维护的微服务框架,它是一系列框架的有序集合,比如说就包含服务注册与发现、登录器、网关等等,而且每一个模块啊,它其实都是具有spring boot风格的,比如说呢,可以做到一键的启动,综上啊,我们就理解了,我们来总结一下,正是由于spring和spring这两个强大的功能才有了spring,而spring生态不断发展蓬勃壮大之后,由于它的配置繁琐,所以因此呢,就诞生了spring boot,spring boot让spring更加有生命力,而spring cloud呢正是基于spring boot的一套微服务框架,所以啊,从中也可以看出 spring,spring boot,spring cloud之间也是具有层层递进逐步演化这样的关系的,这也符合软件发展的历程,软件发展的通常也不是一蹴而就的,也是不断迭代不断升级的。

2、Spring Boot如何配置多环境?

        面试官有的时候为了考察你的实战经历,他可能会问你这样的问题,比如说你在开发的时候是不是只在本地开发?有没有去针对不同的环境做不同的区分呢?那么如果我们被问到这道题,首先我们可以这样回答面试官。
        你好,我这边对于多环境的知识是了解的,我平时会使用多套环境,比如说开发环境、测试环境、预发环境和线上环境。然后你还可以介绍一下这四个环境的用途。开发环境通常可以在本地它所连接的数据库也是专门用于开发的。里面的数据一定程度上也是我们造出来的,因为并不需要在开发环境就保证数据的完全准确。为了开发效率的提高,我们通常会造一些模拟的数据。那通常我们开发完之后需要把这个程序部署到测试环境。为什么需要测试环境呢?因为测试环境通常是公司所提供的一个服务器,而开发环境很有可能是我们本机。那对于本机而言,如果你电脑关闭了,或者你本机的程序停止了,那别人就无法访问了。但是测试的同学它和你的工作时间不可能是完全的一致。这样的话一旦你把你的程序关掉了,它就没有办法进行测试了,这样是不行的。所以我们需要给测试的同学提供一套稳定的环境去测试。而且有的时候我们会同时开发多种功能,那么有可能我前一个功能开发完了需要去测试,那这个时候后面我又要去开发新的功能了。所以你本地的代码其实已经发生了变化。也说如果我们把开发环境当作测试环境,这两个环境不独立的话会导致的问题,就是他实际测试的可能和他想要测试的并不是同一套代码,这样的话也会有很大的问题。正是基于这样的原因,通常情况下测试环境是必不可少的,我们用一台稳定的服务器去把我们开发好的需要被测试的代码给部署上去。这样的话无论你的电脑是不是关机,都不会影响到测试同学的进度,这是测试环境所主要做的工作。但是在测试环境的数据库配置往往可以和开发环境保持一致。也就是说可以允许他们共用同一个数据库,毕竟里面的数据都是模拟出来的,所以不需要做严格的区分。下一个环境是预发环境,为什么需要一个预发环境预发这两个字,顾名思义就是预备发布,准备发布。也就是说其实它和真正的线上环境是高度统一的。那么它和测试环境的差异点在哪儿呢?第一就是网络的隔离。通常为了保证线上服务的稳定,我们会做环境的隔离。环境隔离指的说我们在本地或者是测试环境是没有办法访问到线上的环境的机器的。也说通常情况下我们是不能在测试环境直接访问到生产环境的数据库,包括它的容器的。而且在预发环境通常我们会采用真实的数据去进行测试。有的时候正是因为这一点细微的差别,可能在测试环境并不能把所有的问题都测出来。所以正是因为这些区别,有的时候我们在测试环境无法测试出来的问题,在预发环境就可以暴露出来了。比如说我们举一个数据的例子,有的时候我们在测试环境自己模拟出来的数据不是特别的准确,和真实数据有一定的差别。比如说我们去模拟一个商品详情,可能我们只造了 50 个字,但是后来你发现真实的需求有好几千个字。那么这个时候只有用了真实的数据,你才能发现我的数据库所设置的大小不够。或者有的时候你在测试环境模拟数据的时候,模拟的都是一些整数,但是你发现到了真实的数据里面,它其实是小数,所以这个时候又能帮助你去发现问题,你也同样的需要做一定的调整。那这就是预发环境的作用,最主要是起到隔离以及数据验真的作用。最后一个环境就是生产环境了,生产环境也称为线上环境,就是我们真实对外所提供服务的。这里所采用的数据那肯定是真实数据了,并且也会有很多的流量进来。生产环境和预发环境最大的不同就在于流量的多少,通常的预发环境不会对外暴露,但是生产环境是直接面向所有用户的,所以也会存在一些并发访问的问题。那么一旦我们发布到生产环境,就要尽量的去确保这个程序是稳定的,是没有问题的。以上我们就介绍了这四种最常见的环境。那我们如何利用 spring 去配置多环境呢?我们最不可取的做法就是在发布到某一个环境之前,把它的配置文件全部的去删除替换,这样的话不但费时费力,而且很有可能由于你漏了替换,导致发布了错误的配置。比如说一旦你发布到生产环境时,所使用的配置文件是测试环境的数据库,那么就有可能造成对外暴露的是测试数据的情况,这实际上就是很严重的事故了。所以我们需要通过更加优雅的方法去解决这个问题。在 spring boot 中,我们可以通过改变配置中的 profile.active 这个值来加载对应的环境,只要做小小的修改,就能把整个配置文件进行替换。

3、实际工作中,如何全局处理异常?

        面试官可能会问你,你在实际工作中如何去处理这种异常?你是全局处理的吗?还是逐个处理的?还是说就不进行任何的处理?
        那在这里,面试官其实并不是说想听你回答。我是全局处理的,这个答案过于简单了,其实他真正想听到的是你背后的思考,也就是说他想让你主动的去回答这个问题。为什么异常需要全局处理,不处理行不行?那么刚才那个问题的答案,正确答案当然是应该全局处理,你不处理是不行的。但重点在于这个理由我们可以跟面试官这样回答
        首先如果我们不进行处理的话,那么很有可能这个异常会把整个的堆栈都抛出去,这也是默认的情况。也说我们如果不进行处理,一旦发生异常用户或者是别有用心的黑客,他们就可以看到详细的异常发生的情况,包含你的详细的错误信息,甚至是你代码的行数。那么在这种情况下,对方可以利用简单的一个漏洞不停地去尝试,而且他们还可以顺藤摸瓜分析出你更多的潜在的风险,最终把系统给攻破。所以我们异常是必须处理的。
        那么处理的时候为什么需要全局处理呢?这个时候我们需要举一个我们在写电商的时候的例子,我们来看一看当时我们是怎么写的,好切换到我们的电商项目。在这里会有一个和异常相关的包叫做 exception 而这里面最重要的就是这个 global exceptionhandler 这也是我们全局处理中最重要的一个类。我们可以跟面试官说我们使用了这样的一个 handler 去处理。具体处理的方式首先它会去加上 controller advice 注解,并且在这里面有多个方法,这多个方法的区别在于它们处理的异常不同。比如说第一个它处理的异常是 exception 镇电 plus 也就是所有的异常的父类。他处理的办法是首先打出一个日志,然后把这个系统错误,这个 system error 也就是 2 万这个错误码进行返回。而假设我们抛出的异常是我们自己定义的 exception 这个时候他就会使用到这个方法 handler exception 那么他处理的时候会更加的优雅,它会根据我们具体的异常,也就是这里这个 E 的类型去把它的状态码和它的信息给取出来。比如说这里面的这些异常的枚举都是有可能会抛出来的,比如说用户名不能为空,密码不能为空等等。好我们回去。那这是对于 imkmorexception 下面我们还有一个我们在验证参数的时候,如果它的参数不符合我们的规定,比如说参数不能为空或者参数的长度超出限制。那么它的异常的类型是 method argument notvalid exception 如果系统识别到现在遇到了这个异常,它就会调用这个处理器。那么那调用的时候也会友好的提示给用户,说你现在参数不符合我们的规定。所以通过以上这个代码我们就知道了,在处理异常的时候,我们如果写了这样的全局异常处理器,也就是 global exception handler 那么就可以非常轻松地去针对不同类型的异常去做出定制化的解决方案,不但增加了安全性,而且对用户也是非常友好的。用户可以通过你的错误信息知道他应该怎么去调整,并且不会从中去暴露关键的敏感信息,这就是实际工作中正确的处理异常的方式。那我们在遇到这个问题的时候,可以参考这样的回答思路去跟面试官进行交流。

二、线程常见面试题

1、线程如何启动?

        面试官在面试的时候通常有一个循序渐进的过程,比如说他会首先问你线程如何启动。线程启动可以使用 thread.start 方法来进行启动。但是 start 方法最终它其实背后所要执行的也是 run 方法。那在你回答完这个 start 方法启动之后,它可能会继续问你这个问题, 既然 start 方法会调用 run 方法,那为什么我们要多此一举?为什么我们要多走一步去调用 star 的方法,而不是直接的去调用 run 方法呢?这样做又有什么好处呢?
        你可以这样回答,如果你选择直接去调用 run 方法。那么其实它就是一个普通的 Java 方法,就跟你去调用一个自己写的普通的方法没有任何的区别。那最重要的缺点在于它不会真正的去启动一个线程,你调用了一次 run 方法之后,它就执行一次,而且是在主线程中执行的,那就没有起到任何的创建线程的效果了。如果我们选择 star 的方法,它会在后台进行很多的工作,比如说去申请一个新的线程,比如说去让这个子线程执行 run 方法里面的内容,而且还包括在执行完毕之后的对于线程状态的调整。所以我们在启动线程的时候,虽然表面上看起来你使用 star 的方法和 run 方法都是去执行一段代码,但是其背后是有很大不同的。
        那这个时候面试官可能还会去问好,现在你说的对应该用 star 的方法来启动线程,那我如果启动两次会怎么样呢?也就是说如果我两次调用 start 方法会出现什么情况呢? 在这里插入图片描述

        我曾测试过,他这个说的是抛出了一个异常,并且这个异常叫做 illegal thread state exception 含义就是说线程的状态不对,去看一下 star 的方法里面究竟是怎么执行的,为什么会抛出这个异常呢?在这里插入图片描述

源码是这样的如果 thread state 不等于0,它就会抛出这个异常。源码上面的这个注释。A zero status value corresponds to state new 也就是说 0 代表这个状态是 new 那我们知道,如果这个线程一旦被 start 之后,它的线程状态会从 new 变成 runnable 所以它的状态肯定就不是 new 了,所以它这个值也不是0。所以第二次你去执行 start 的时候,自然就会抛出这样的一场。
        那这道题我们就可以这样回答了: 两次调用 start 方法会抛出异常,这个异常的类型叫做 illegal threadstateexception 之所以会抛出这个异常,是因为在 start 的时候会首先进行线程状态的检测,只有是 new 的时候才能去正常的启动,不允许启动两次。

2、实现多线程的方法有几种?

实现多线程主要有这两种方法。第一种方法是实现 runnable 接口,而第二种方法是继承 thread 类。
在这里插入图片描述
在这里插入图片描述
两种方法进行一下对比。哪种方式它会更好?
答案还是比较明确的,runnable 接口的这种方式会更好。
        第一个角度是从代码架构的角度去考虑的,代码架构的角度是这样分析的。事实上,之所以 Java 在设计的时候会有 runnable 接口这样的一个接口,它的本意是想让我们把这个任务的执行类和任务的具体内容进行解耦。解耦的意思就是让他们的关系不那么的密切。这样的话从架构的角度去考虑它的扩展性会更好。所以 runnable 接口其实它所做的最主要的工作是去描述这个工作的内容,但是和现成的启动、销毁其实没有关系。而 thread 类本身它是用于维护整个线程的,比如说启动、线程状态更改,包括最后任务结束这些都是由 thread 类去做的。所以它们两个之间也就是 runnable 接口和 thread 类之间本身权责是很分明的。因此我们从代码架构的角度考虑,不应该让它们过度的耦合。一旦过度耦合,未来就会发生很难扩展的这种问题。所以从代码架构的角度去考虑,实现 runnable 接口这种方式会更好。
        第二个角度在于我们是从新建线程的这种损耗的角度去考虑的,同样也是实现 runnable 接口这种方式更好。那我们就来分析一下,我们如果使用继承 thread 类的方式,正如我们刚才代码所看到的那样,在这个是继承 thread 类的方式。那么我们如果想去启动一个线程,需要把这个类给拗出来,把它给实例化出来,并且启动起来。所以每一次我们如果要去新建一个线程,那通常要去 new 一个 thread 类。但是其实我们在线程池这样的更高级的用法中,我们并不是是每一个任务都去新建一个线程的。我们为了提高整体的效率,会让有线数量的线程比如说 10 个或者是 20 个或者是 100 个,这个数量可以由我们自己来确定。但是我们假设用 10 个线程,它实际上是可以执行成千上万个这样的任务。而有了这样的一个思路之后,我们并不是每次都去新建一个线程,然后执行一个任务,而是把同样的一个线程它去执行很多很多个任务。所以这样的话它就减少了新建线程的损耗。因为它并不需要去新建 1000 个线程,而是只需要用一个线程去执行这 1000 个任务就可以了。所以如果我们使用继承 thread 类的方式,就不得不去把这个损耗都承担起来。有的时候我们在 run 方法里面所执行的内容是比较少的。比如说像我们的这个代码中,它如果只打印一行话的话,其实整体而言它的开销甚至还比不上我们新建一个现成的开销。那这样一来,我们相当于是捡了芝麻,丢了西瓜,得不偿失了。那假设我们使用这个实现 runnable 接口的这种方式,可以把这个任务作为一个参数直接传递给线程池。而线程池里面用固定的线程来执行这些任务,就不需要每次都新建并且销毁线程了,这样的话就大大的降低了性能的开销。所以这是从第二个角度新建线程的损耗这个角度去看的。从这个我们也可以分析出实现 runnable 接口,它要比继承 thread 类去来得更好。
        第三个好处在于 Java 不支持双继承,不支持双继承的意思就是说在我们的这个类中,它如果已经 extends 一个 thread 类了,就不能再让他去 extends 更多的类了。

3、创建线程的原理是什么?

第一种方法是实现 runnable 接口的方法,然后我们会看到它最终调用的是 target.run;第一种,这个实现 runnable 接口这种方法,它的本质在于我们传入了一个 target 并且最终通过 spread 类调用了这个 target.run 这个方法,最终去实现了我们想要它执行的这个逻辑
方法 2 指的是我们去继承 spread 类这种方法去实现线程。那么它的原理是什么样的?
去继承 thread 类这种方式,它的原理是整个 run 方法都被重写,那么它自然也就没有调用target.run这样的一个过程。
假如我们同时用两种方法会怎么样?
        当同时使用两种方法去执行的时候,由于已经把父类的这个 run 方法给覆盖了,所以我们即便传入了 target 或者说传入了 runnable 它都不会起到效果,真正执行的还是覆盖了 thread 类的那个 run 方法。


无论是实现 runnable 接口还是继承 thread 类,它们本质都是一样的。都是最终会去执行到这个 thread 类里面的这个 run 方法。只不过如果你是通过实现 runnable 接口的形式,那么它就会调用这个 target 的 run ,如果你去直接重写,那么它就不会调用这三行代码,而是去执行你重写的这个代码。不过本质它都是从 thread 类这个 run 方法里面去来的。其实无论你是实现 runnable 接口还是继承 thread 类,它本质都是一样的。准确的讲,创建线程它只有一种方式就是构建 thread 类。但是不同之处是在于如何实现线程的执行单元。刚才如果是实现 runnable 接口,它是把 runnable 这个实例传给 thread 类去实现,然后再通过 target 这种方式去进行中转,最终执行到 runnable 里面的内容。
关于有多少种实现多线程的方法,用的最多的是两种,一个是实现 runnable 接口,另外一个是继承 thread 类。不过这两种方法它们背后本质是一样的。而其他的方式比如说线程池或者是定时器,它们是对于前面两种方式的一种包装。

4、线程有哪几种状态? 生命周期是什么?

六种状态:

  • New

第一个就是 new 这种状态代表已创建但还没启动的新线程。这个含义非常明确说当我们用 NEO thread 新建了一个线程之后,但是我们还没有去执行 start 方法,此时这个线程就处于 new 的这个状态。事实上我们用 new thread 建立了这个线程之后,它还没有开始运行,但是他已经做了一些准备工作了。但是做完准备工作之后还没有去执行 run 方法里面的这个代码,因为没有人去执行 start 方法,这种情况下它的状态是 new

  • Runnable

第二种状态是 runnable, runnable 相对而言是比较特殊的一种状态。这种状态是一旦从 new 调用了 start 方法之后,它就会处于 runnable 了,一旦调用了 start 方法,线程便会进入到 runnable 状态,也就是说我们从 new 到 runnable 而不会从 new 到 waiting。 Java 中的 runnable 状态实际上就对应到我们操作系统中的两种状态,分别是 ready 和 running 也就是说我们这边一个 runnable 它既可以是可运行的,又可以是实际运行中的,它有可能正在执行,也有可能没有在执行,那没有在执行的时候,它就是其实是等待着 CPU 为它分配执行时间。
并且还有一种情况,比如说我这个线程已经拿到 CPU 资源了对吧?那么它是 runnable 状态, CPU 资源是被我们的调度器不停地在调度的,所以有的时候会突然又被拿走。一旦我们某一个线程拿到了 CPU 资源正在运行了,突然这个 CPU 资源又被抢走了,又被分配给别人了。这个时候我们这个线程还是 runner 这个状态,因为虽然它并没有在运行中,但它依然是处于一个可运行的状态,随时随地它都有可能又被调度器分配回来 CPU 资源,那我们又可以继续运行了。所以这些情况下我们的状态都是 runnable。

  • Block

当一个线程进入到被 synchronized 修饰的代码块的时候,并且该锁已经被其他线程所拿走了。我们拿不到这把锁的时候,线程的状态就是 block 的。进入 synchronized 修饰的代码块儿,这个 block 仅仅是针对 synchronized 这个关键字才能进入 block 的。因为和 synchronized 关键字起到相似效果的还有其他的 lock 各种各样的锁,像可重入锁、读写锁,这些都可以让一个线程进行到这个等待的情况。但是那些情况下它绝对不是 block 的这个线程状态。针对 block 的这个线程状态,我们要记住它一定是 synchronized 修饰的。当然无论是 synchronized 修饰的一个方法或者是代码块儿,这都可以。那么只要是一个 synchronized 所保护的一段代码中,它且没有拿到锁,陷入到一个等待的状态,这种情况下才是 block 的。

  • Waiting

这个是微停状态。微停是等待哪些情况会进入到这个状态呢?一方面是没有设置 timeout 参数的 object.wait 方法。给大家看一下流转图状态间的转化图示:
在这里插入图片描述
我们首先看一下刚才所讲过的 new 然后 thread.start 方法之后进入到 runnablerunnable 和 blocked 有两条线,从 runnable 想进到 blocked 需要进入到 synchronize 修饰的相关方法或代码块,并且没拿到锁。然后那从 block 回到 runnable 那自然是在刚才进入 synchronized 之后,等待锁的过程中有人释放了,于是我拿到了就回到 runnable 现在我们再来看一下从 runnable 如何到右边这个 waiting 状态。箭头的左右指向不同,代表着它状态切换的方向也不同。所以大家要看准箭头。从左边往右边的这个箭头。上面说了这三种情况,分别是 object 的 wait 方法,第二个是 thread 的 join 方法,第三个是 lock support 的 park 方法。有一个很类似的看 object 的 wait 以及 thread 的 join 但是它里面是有参数的,这里面参数不同决定了它状态的不同。所以大家注意在这边从 runnable 到 waiting 的时候,这里面是不带参数的 wait 和 join 方法才有这种情况才会进入到waiting,否则进入的可能是 time 的 waiting 这两种状态是不一样的。另外第三, locksupport 的 park 方法我们可能不经常见到这个 locksupport 但是实际上它是我们很多锁的底层原理。在这边我们要掌握的是这三种情况会让我们 runnable 进入到微信状态,好要想从这三种状态返回回来,那么自然也是很类似的。用 object 的 notify 或者是 object 的 notify all 会让我们从 wait 这种情况被唤醒回到 runnable 以及和 locksupportpark 对应的是 locksupport unpark 方法,这些都会让我们从等待变回可运行状态。而中间的这个 join 方法需要等待我们 join 方法所执行的那个线程,它运行完毕才会回来。

  • Time Waiting

依旧使用上面的图做参考,这个状态叫做计时等待。这个状态大家可以理解成和这个等待状态非常类似,它们是一个很兄弟的状态关系,只不过一个是有一定时间期限的。另外一个是没有时间期限的,在这边有时间期限的有哪些呢?我们看一下这五种情况。第一个是 sleep 方法,然后就是 object wait 和 threadjoin 这些和刚才的区别仅仅是多了一个时间参数,我们可以指定它是两秒还是 20 秒或者是 400 毫秒,这些都可以。一旦你指定了,那么它就是一个计时等待。另外刚才的 locksupport 的 park 也有两个对应的可以放入时间参数的这个方法,总体而言其实和 wait 是差不多的,只不过一个是带了时间参数,而一个是没有带。那么它的区别就在于带了时间参数的这种它需要等待超时,它在超时的情况下会被系统自动唤醒。并且如果在超时之前就收到了像类似 notify 或 notifyAll 这种情况也可以提前的被唤醒,这就是它的不同之处。它相比于 wait 只能等待被唤醒信号之外,这种计时等待除了可以等待唤醒信号,也可以等待时间到。所以这两种情况是比刚才这个 wait一种情况返回的机会要更大一些。
那么 waiting 和 time waiting 这两种和刚才的 block 的大家一看好像是不是也很相似我都在等待一些信号。但是在这边区别在于我们的 blocked 它等待是另外线程释放一个排他锁。而这个 waiting 和 time waiting 他是等待被换唤醒或等待一段被设置好的时间,所以这是有所不同的。

  • Terminated

最后一个是我们的被终止状态或者叫已终止状态。 terminated 这种状态有两种情况可以到达。第一种同学们都可以想到的就是我们 run 方法正常执行完毕了,正常退出了,自然是线程进入到 terminated 另外一种情况,相对而言少见一些,出现了一个没有被捕获的异常,终止了这个 run 方法导致意外终止,这样的话 run 方法不一定会执行完毕。因为它执行到一半就抛出异常了,但是这种情况下依然会进入到这个 terminated 的状态。

以上六种状态的代码演示:

/**
 * 描述:     演示New、Runnable、Terminated状态。
 */
public class NewRunnableTerminated {

	public static void main(String[] args) throws InterruptedException {
		Thread thread = new Thread();
		//打印出NEW的状态
		System.out.println(thread.getState());
		thread.start();
		//打印出Runnable状态
		System.out.println(thread.getState());
		Thread.sleep(100);
		//打印出TERMINATED状态
		System.out.println(thread.getState());
	}
}

在这里插入图片描述

/**
 * 描述:     展示Blocked、Waiting、Timed_Waiting状态
 */
public class BlockedWaitingTimedWaiting implements Runnable {

	public static void main(String[] args) throws InterruptedException {
		Runnable runnable = new BlockedWaitingTimedWaiting();
		Thread t1 = new Thread(runnable);
		t1.start();
		Thread t2 = new Thread(runnable);
		t2.start();
		Thread.sleep(10);
		//打印Timed_Waiting状态,因为正在执行Thread.sleep(1000);
		System.out.println(t1.getState());
		//打印出BLOCKED状态,因为t2拿不到synchronized锁
		System.out.println(t2.getState());

		Thread.sleep(1300);
		//打印出WAITING状态,以为执行了wait()
		System.out.println(t1.getState());
	}

	@Override
	public void run() {
		syn();
	}

	private synchronized void syn() {
		try {
			Thread.sleep(1000);
			wait();
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
	}
}

在这里插入图片描述

一般习惯而言,把Blocked(被阻塞)、Waiting(等待)、Timed_waiting(计时等待)都称为阻塞状态

回到我们的正题:线程有哪几种状态? 生命周期是什么?
其实答案都在上面的那幅图里。可以一边画一边解释这个图便能完美解答这个面试题。

三、分布式的面试题

1、什么是分布式?

面对这个问题,我们就可以给出这个厨房的例子了。比如说我们开饭店,最开始的时候,由于客流量很小,我们只有一个厨师就够了。这个厨师他不仅去负责做菜,还负责切菜、洗菜等等。后面我们发现一个厨师不够,于是我们就雇了多个厨师。但是这多个厨师仅仅是简单的对于一个厨师进行了复制。也就是说同样还是由一个厨师进行所有的工作,包括洗菜、切菜和做菜。那么他并没有进行分工,后续饭店发现这样的话成本太高,因为厨师的工资他往往要比洗菜和切菜的工人要高很多。所以他发现我们如果利用术业有专攻,是不是可以让我们整体的成本下降?比如说我们去聘请一个配菜师或者是洗菜工或者是切菜工,那么他就可以在不忙的时候把所有的菜给洗好,切好准备好,而厨师就可以专注于他的炒菜了,这样一来整体的效率就提高了。那刚才这个例子其实就比喻了我们实际项目的演进过程。最开始一个厨师对应的就是一个项目,它大而全。后来我们发现一个项目不够用了,那我们就去用多台机器,但是每一台机器上部署的都是一样的内容,同样也是大而全的。这样会造成一定资源的浪费。所以我们就引出了分布式。我们以一个公司系统为例,比如说公司系统里面分为权限系统、员工、那请假系统很显然它的使用频率要比其他的系统要低得多。那么你就没有必要去在每台机器上都去部署一个请假系统,而是只要部署一个请假系统,就足以应付所有的流量请求了。相反员工系统用得很频繁。那么你可以去多部署几个员工系统,这样的话我们就可以充分利用机器资源,也同样发挥了分布式的优势。

2、分布式和单体结构哪个更好?

在这里插入图片描述

其实这是一个坑,因为脱离业务的技术选型都是没有意义的。我们在对比某些技术哪个更好,哪个更差的时候,一定要结合我们的业务,结合具体的场景。
        首先对于传统的单体架构而言,对于新人的学习成本,它的难点在于业务逻辑很多,因为你这一个项目很大,功能点也特别多。而分布式架构由于它都进行了拆分,所以它的主要成本在于架构的复杂度上高。而这一点往往是架构师或是 leader 所负责的。那么底下的开发,他们更多的是关注某一个模块就够了。
        在部署和运维方面单体的架构会很简单,但是对于分布式架构而言,它的部署和运维都要复杂得多。对于隔离性而言,单体架构由于它是浑然一体的,所以有一个地方出问题,整个就会出问题,正所谓是一损俱损,殃及鱼池。而分布式架构没有这个缺点。如果某一个内容出现了问题,它影响的也仅仅是它自己,对于其他的部分不会产生太大的影响。所以故障的影响范围是比较小的。
        在架构设计方面,传统的单体架构它的难度要远远低于我们的分布式架构。而在系统性能方面,由于单体架构它所有的调用、操作都是在内部的,也没有网络通信的开销,所以它的响应其实是比较快的。但是在同样资源的情况下,它整体所能承担的最大的流量范围也就是吞吐量是不如我们的分布式架构来得好。因为我们的分布式架构更加充分地利用了资源。在测试成本这一块儿,传统的单体架构它的测试成本是比较低的。而分布式架构要想测试是比较困难的,有的时候这个链路很长,你甚至都不容易发现是哪里出了问题。
        在技术多样性方面,传统的单体架构技术肯定是很单一的,而且是封闭的,你要是想引入新的语言几乎是不可能的。但是分布式架构它的技术多样性就体现出来了,它技术就很多样,你这个模块所用用的技术在另外一个模块儿中不一定要用一样的,它对于技术完全是开放的。
        在系统扩展性方面,单体的架构扩展性也是比较差的,因为你引入一个新的包可能会造成依赖的冲突。而分布式架构由于相互之间的独立性很好,所以你要想进行新功能的扩展,那么往往是没有太大的阻力的。
        在系统管理成本方面,传统的单体架构由于它架构很简单,所以管理成本相对而言就比较低。但是分布式架构管理成本确实要高很多。那我们可以看出,其实你很难说单体就比分布式好,或者说单体就比分布式差。往往我们在项目建立之初的时候,为了追求项目的快速上线,我们可以选用单体架构。因为那个时候我们的业务还没有很复杂,我们也不能预测在半年之后或者一年之后究竟需要去新增哪些模块。所以在最开始我们使用单体架构是最合适的。
        最后的总结一句话:根据具体的业务场景和发展阶段选择适合自己的技术。

3、CAP理论是什么?

  • C ( Consistency ,一致性)︰读操作是否总能读到前一个写操作的结果
  • A( Availability,可用性)︰非故障节点应该在合理的时间内作出合理的响应
  • P( Partition tolerance,分区容错性): 当出现网络分区现象后,系统能够继续运行

简单的说就是:一致性的意思说假设有人操作了某一个数据,那么后面想去读取的时候,要求是读取到操作之后的结果,而不是以前的缓存。 A 可用性的意思说你这个系统是不是随时都能对外提供服务?如果系统挂了,如果不给响应了或者给出错误响应了,那么这个就叫做不可用。而 P 它指的就是说如果节点与节点之间相互无法通信了,是否影响到你整个系统的运行。

4、CAP怎么选?

在这里插入图片描述

        通常可以跟面试官画一个这样的图,这是三个有交集的缘。但是特点在于最中间是三个圆的焦点,它是一个点而不是一个面。这就反映了 cap 的一个很重要的特点,说你不能 cap 三者兼得,只能从中选取两个。那这个就涉及到 cap 如何选择的问题了。由于网络是我们人为无法完全控制的,也就是说网络错误无法避免。所以从系统的层面去考虑, P 始终是要考虑在内的。那么供我们选择的就是 CP 或者是 ap 我们还记得 C 代表的是一致性,而 A 代表的是可用性。
        什么场合下可用性会高于一致性呢?我们来举个例子,比如说我们是做一个图片网站的,对外提供的就是各种各样的图片。那么我们对于可用性的要求就会高于一致性。比如说有的时候我们是允许不一致的,我们在这里更新了一张图片,或许在短时间内其他的用户拿到的还是旧的图片还是老版本的图片,但是这并没有太大的问题。最终随着时间的推移,人人都会看到最新的版本。但是我们不希望说我在更新的时候其他人就不可用了,他们连网站都访问不了,这个是我们不希望的。所以在这种情况下,可用性高于一致性。
        那什么情况下一致性会高于可用性呢?比如说在交易支付这样的场景中,一致性的要求就特别高。不能说我把这个钱转出去了,已经转走了,别人却还看到的是我救的那个余额,然后又转走一份这样的话会造成很大的问题。所以在这种场景下,一致性就高于可用性。所以 cap 怎么选还是取决于我们的业务适合自己的才是最好的,孰优孰劣并没有定论。如果是涉及到钱财,我们的 C 也就是一致性是必须保证的。那如果是不涉及到这种强一致的内容,我们就可以优先去选择 A 也就是可用性,这就是和 cap 理论相关的内容。

四、Docker相关面试题

1、为什么需要Docker ?

        首先 Docker 它是用来装程序及其环境的一个容器,所主要解决的问题就是环境配置的问题。比如说这个程序在我这台电脑上可以良好的运行,但是在你那边却报错了,这就是一个环境所带来的典型的问题。那为了解决这种问题之前,就诞生了其他的解决方案,比如说虚拟机的解决方案。但是虚拟机了,太重的意思就是说它的成本太高了。
        我们为了保证一样的环境,需要去模拟出一台完整的机器,包括硬盘包括内存,而且它们都是独享的,即便你程序不运行,它的那部分资源也不能去拿来共享,那就造成很大程度的浪费了。正是因为有这样的问题,所以我们才需要 Docker 那有了 Docker 之后,Docker就给我们提供了统一的环境,而且还提供了可以快速扩展的弹性伸缩的云浮。这个指的是说如果遇到了双 11 这样流量大的情况,那么我们可以利用 Docker 迅速的去扩展几十台甚至上百台机器,它们的环境都是统一的,也就是可以保证程序是可以在上面稳定运行的。那这样一来我们就可以根据流量的不同进行合理的配置,让我们资源既不浪费,也不会出现资源不足的情况。
        另外 Docker 还有一大好处说它可以防止其他用户的进程把服务器的资源占用过多,Docker可以做到很好的隔离。并且如果你这里出错了,也不会影响到其他的用户。这相比于以前,我们可能出现某一个程序把 CPU 占满了,或者把内存或者把磁盘占满了,导致这台机器上的所有的程序都不可用。相比于这种情况,Docker就有很大优势了。所以这就是 Docker 所带来的好处。

2、Docker的架构是什么样的?

>

最主要的几个部分分别是 containerimage 和 registrycontainer 是容器的意思,把 images 启动起来之后就形成了一个容器。而 image 是镜像。镜像我们可以从镜像市场也就是右边的这个 registry 中去获取到这个镜像,包括五帮图、OS 、Redis、nginx都有。作为我们使用者而言,通常情况下我们要去启动一个 container 然后在里面去运行程序。而启动 container 的步骤就包括从镜像市场中下载,还包括把 image 给启动起来,主要就是这样的一个流程。而在最左侧是我们的 client 是客户端,客户端他负责发送命令去真正执行操作的是中间的这边的 Docker 的服务端也可以叫做 docker_host。

3、Docker的网络模式有哪些?

Docker 的网络模式啊一共有这三种,第一种呢是 bridge 叫做桥接,第二种叫做 host ,第三种叫做none。其中 bridge 用的是最多的,也就是说我们用外面的主机的一个端口号去映射到容器里面的某一个端口号,实现了一座桥,通过这个桥大家就可以通信了。第二种 host 的含义是里面的容器不会获得一个独立的网络资源配置,它和我们外界的主机使用的是一模一样的,使用同一个网络。也说里面的容器将不会虚拟出自己的网卡,也不会配置自己的 IP 而是使用我们宿主机上的 IP 和端口号,这就是 host 模式。第三种是不需要网络的模式,是 none 的模式,如果是选择这种模式的话,那么就不能和外界有任何通信了。通常情况下我们都会选择 bridge 作为我们的网络模式。

五、Nginx和Zookeeper相关面试题

1、Nginx的适用场景有哪些?

nginx 主要有两个适用场景,第一个是 HTTP 的反向代理服务器,而第二个就是动态静态的资源分离。

HTTP 反向代理服务器:
在这里插入图片描述

        外面是我们的互联网用户,他们连接到 nginx 然后再由 nginx 进行转发,转发到我们里面的各个服务器。那么其中从 nginx 到我们里面各个服务器的这个过程就叫做反向代理。正是因为有了 nginx 作为我们的反向代理服务器,我们就可以很好的去进行负载均衡,我们把不同的请求分到不同的服务器上,让他们雨露均沾,各自都去处理自己应该处理的内容。
        第二个应用场景就是动态静态的资源分离。如果我们不进行动态静态的资源分离的话,那么有很多的静态资源也会去经过我们的 tomcat 处理。那其实这种处理是没有必要的,因为这种资源都是固定且死的,都是固定的,其实只要直接提供给用户就可以了。所以有了这个动静分离之后,我们就可以做到静态资源无需经过 tomcat 他们只负责处理动态资源,比如说后缀为 GIF 这样的一个图片。这种图片资源 nginx 会首先识别到这个用户想请求这个图片,然后直接把这个文件就提供给用户了。同样有的时候我们的网站如果不是特别复杂,完全可以利用这个 nginx 搭建一个静态的资源服务器。

2、Nginx常用命令有哪些?

  • /usr/sbin/nginx启动
  • -h帮助
  • -c读取指定配置文件
  • -t测试
  • -v版本
  • -s信号
    • stop 立即停止
  • 立即停止的意思就是说对于当前已经接到的这个请求也不管了,直接我就停止了不处理了。

    • quit优雅停止
  • 优雅停止的意思是说我们不再接收新的连接了。但是对于已经处理的处理到一半的,我们会继续对他们提供服务,逐步的让我们的程序停止下来。

    • reload重启
  • 它在我们配置的时候也经常会用到。比如说我们更改了配置文件,就需要利用 reload 命令来读取出最新的这个配置文件的内容。

    • reopen更换日志文件
  • 更换日志文件

3、Zookeeper有哪些节点类型?

直接画个图:
在这里插入图片描述

  • 持久节点
  • 临时节点
  • 顺序节点

对于树来讲最重要的就是节点,而它的节点又分为持久节点、临时节点和顺序节点。持久节点的意思是说我创建这个节点之后它就一直在那里了,除非你把我删掉。临时节点指的是说在链接断开之后会自动的进行删除。而顺序节点在创建的时候它是有顺序的,而且是递增的。那么我们就可以通过 zokeeper 生成的这个节点的号码去用于生成一些唯一的 ID 这也是 zookeeper 的一个应用场景。

六、RabbitMQ相关面试题

1、为什么要用消息队列?什么场景用?

消息队列的三大作用:第一个作用就是 系统解耦 。通过消息队列的收发消息的机制,我们就可以让不同的系统之间解耦,我不再需要去调用你的接口了。我也不必等你返回了,我就只要发个消息就可以了,剩下的事情都由我们的消息队列去完成。另外消息队列还可以用于 异步调用 。比如说我们有一个功能,它所涉及到的模块儿特别特别多,可能有十几个。那么我们的用户其实不关心后续的内容,所以这个时候就可以利用消息队列,我们把消息发出去就可以了,不需要等他们返回。而对于用户而言,它的体验就好很多,因为它等待时间就大幅缩小了。下一个场景是 流量削峰 ,在高并发的情况下,有可能短时间内我们会接到特别多的请求。那我们不应该让这个请求一下子都进来。这个时候我们可以把这些请求都放到我们的消息队列中,然后由消息队列一个一个的后面去逐渐的对这些消息对这些请求进行消化。这样一来我们就很好地去控制了我们机器的访问压力,不至于由于过大的访问量导致我们的机器宕机。

2、RabbitMQ核心概念

在这里插入图片描述

先它会有发送者和消费者,发送者会把自己的消息发送到交换机上,然后由交换机去把这个消息放到合适合理的队列上。而我们的消费者其实他只去关心队列就可以了,队列里有什么他就去消费什么,这是消息的最主要的一个流转的路径。那么在我们的交换机和队列的外面,会有一个概念叫做 virtual host 虚拟主机的意思。在同一个 rabbitmq 的 server 之下,你可以建立不同的虚拟主机,那它们之间都是相互独立的,可以用于不同的业务线,这就是消息队列的核心概念。

3、交换机工作模式有哪4种?

第一种叫做 find out 是广播的意思,如图所示:
在这里插入图片描述
如果我们利用广播的话,如果我们利用这种交换机的模式,那么他就会把这个消息毫无差别的发送到所有绑定的队列上,适用于最普通的消息。

第二种工作模式是 direct ,direct 是要根据我们的 roading key 去精准匹配的。我们来看一下图:
在这里插入图片描述
比如说我们交换机的工作模式是 direct 那么如果我们指定了 orange 作为第一个队列的路由键,而同时指定 black 和 green 作为第二个队列的路由键。那么在发送消息的时候,orange的就会被放到第一个去,而 black 和 green 的就会被放到第二个队列中去。所以这种模式适合精准匹配。
比如说我们在实际工作中可能会出现这样的场景,我们去处理日志。有一个队列只接收错误日志,而有另外一个队列他接收的是所有的日志,就包括 info error 和 warning 这三个级别。那这种场景就很适合去使用 direct 模式。我们把 error 的只发到第一个队列中,而把 iinfo、error和 warning 的都发送到第二个队列中,实现了日志的分离。
在这里插入图片描述

其实在我们的生产中更多的用的是第三种,也就是 topic 模式。 topic 模式它非常的灵活,它可以根据我们设定的内容进行模糊匹配,并且进行相应的转发。在 topic 里面,*代表是一个单词,而#号代表是零个或者多个单词。比如说我们举个例子:
在这里插入图片描述
在 topic 模式下,我们第一个队列只关心橙色的动物,而第二个队列只关心 lazy 的动物以及兔子。那么我们使用这个 topic 模式,它的优势就体现出来了。对于第一个队列而言,只要你是橙色的,那不管你是什么物种,不管你是兔子还是火烈鸟,只要你是orange的都会匹配过来。那么在实际工作中,我们完全可以把 orange 换成是请假系统里面和请假相关的信息。那么这样一来,你这个队列就能把所有和请假相关的信息都收集到,不会遗漏。

第四种工作模式是 headers 这种使用的非常少,它是根据我们消息内容中的 headers 来进行匹配,需要我们自定义。那通常情况下我们用不到这一种。

七、微服务相关

1、微服务有哪两大门派?

  • Spring Cloud:众多子项目
  • dubbo:高性能、轻量级的开源RPC框架,它提供了三大核心能力∶面向接口的远程方法调用,智能容错和负载均衡,以及服务自动注册和发现
    以上也就是说 dubbo 所提供的能力它只是 spring cloud 的一部分子集。

2、Spring Cloud核心组件有哪些?

在这里插入图片描述

3、能画一下Eureka架构吗?

  • Eureka Server和Eureka Client
    在这里插入图片描述

上面的这个蓝色的部分是eureka server 而右下角的 service provider 它就是一个 eureka client 它会注册到我们的 eureka server 上面去。左下角的是我们的服务消费者,它先访问到 eureka server 拿到地址,然后再去对这个服务提供者进行远程调用,这就是一个最基本的Eureka 的架构。

4、负载均衡的两种类型是什么?

        一种类型是客户端的负载均衡。比如说客户端的负载均衡的意思就是说我们在请求的时候就已经知道了,这三个 IP 地址都能提供服务。那么我们就一个一个的去调用,或者通过一定的算法去调用。但总之这个决策是在我们调用方的,这就叫客户端的负载均衡。
        一个服务端的负载均衡。一个非常典型的例子就是 nginx 对于普通的广大的用户而言,他可不会进行负载均衡,他就访问你一个入口就可以了。那么这个时候我们就需要用到服务端的负载均衡,比如说利用 nginx 进行合理的转发,让我们的请求分散开来。那么刚才我们说到了负载均衡,面试官可能会接下来问你,你知道有哪些典型的负载均衡策略呢?比较典型的策略有以下这几种。第一个是 random 叫做随机策略。随机策略顾名思义,他发送请求的时候并没有一个具体的规则,完全是随机的。第二个是用的最多的是轮询的策略,轮询的策略就是说挨个的去进行请求。第一次我请求一号,第二次请求二号,第三次请求三号,第四次再次回到一号,然后就是这样一二三周而复始,叫做轮询。
        一种比较高级是加权,加权的含义说它会根据每一个服务器的响应时间进行动态的调整。比如说你这个服务器响应特别慢,那我就给你少几个请求。如果有其他的服务器,响应很快,我就给多几个请求,这样也可以更大程度上的去发挥我们机器的性能。

5、为什么需要断路器?

比如说我们依赖很多服务,但是有一个服务突然就不能用了,我们这边标红的 dependency i 一旦有一个服务不可用了之后,
在这里插入图片描述
假设我们没有断录器,会发生什么样可怕的情况呢?
在这里插入图片描述
假设我们的用户请求会用到这个不可用的 i 那么其实每一个请求基本上都是和用户相关的,所以都会访问到 i而这个i 现在又不可用,所以会导致你所有的线程几乎在一瞬间之内都卡在了这个地方。那么这样一来,现有的用户他的请求被卡住了,而后面的用户由于没有更多的线程来处理了,所以后面的用户也进不来,就导致你的整个服务在很短的时间内就变得不可用了,发生很严重的故障。所以我们 需要断路器的一个很重要的原因 就是当我们发现某一个服务某一个模块不可用的时候,我们把它给摘除掉,不至于影响到我们其他的主要的流程。

6、为什么需要网关?

主要有这两个原因:
        第一个是和鉴权相关的。如果我们不使用网关,那么每一个模块儿自己都要去实现一套独立的鉴权服务,那这个通常是一种资源浪费,而且维护起来也很困难。所以我们通过网关把这个功能进行统一的收集。
        第二个,主要的功能是统一对外增强了安全性。我们在线上服务通常只会对外暴露网关这一个服务,而其他的都作为内部服务不对外暴露。那这样的话外面想访问必须通过网关。所以我们只需要在网关这个层面去进行安全的保护就可以了。我们可以对恶意 IP 进行拦截,我们同样也可以对所有的记录进行打日志。那么由于我们把所有的请求都收集到一起了,所以要想保护它的安全比分散的时候容易得多,这就是需要网关的两大主要原因。

7、Dubbo的工作流程是什么?

直接画图:
在这里插入图片描述
在这幅图中,由数字标出来的012345,这就代表 double 工作的时候的最主要的流程。那么我们一个一个的来看,0代表服务,这个容器启动了,容器启动之后会把我们的 provider 给启动起来,然后这个 provider 就会去注册中心注册上,一旦他注册上之后,后续我们假设有 consumer 想要去调用服务的话,那么他就会去订阅这个地址,我们的注册中心就会把 provider 的地址通知到 consumer 于是 consumer 就可以进行调用了。也就是我们这边的第四步, invoke 调用的时候,如果有多台,那同样它也可以进行一定的负载均衡的处理。最后一步是我们的第五步,count它的含义是进行数据统计,我们的服务其实是需要一定的监控保障的,无论是 consumer 还是 provider 那么我们可能会想知道他们被调用的次数是多少,他们运行的是否稳定,运行了多久。那么正是因为有这样的需求就有了监控。于是 provider 和 consumer 都会定时的把自己的一些信息上报到监控中心,这就是 double 工作的主要的流程。

八、锁分类、死锁

1、Lock简介、地位、作用

本身它是一种锁,它是一种工具,这种工具专门用来控制对共享资源的访问的最常见的类就是 reentlock 其他的实现可能是在其他锁的内部一般不直接使用。所以我们说到 lock 接口,我们就以 reaction 的 lock 这个为最主要的典型,就可以对于锁而言,lock和 synchronized 是两种最常见的锁,它们都可以达到线程安全的目的,但是在使用上和功能上又有比较大的不同。在这一点我们要明确一点说它们不是一个相互替代的关系,他们不是说我后来的我比你厉害,那我就全盘的代替你,他们有各自适用的场合。对于 lock 而言,有的时候可以提供一些 synchronized 不提供的功能高级功能。但有的时候我们又没必要用这个高级功能,直接用 synchronized 就可以了。 lock 接口中最常见的实现类就是我们的 reentant lock 我们如果说到 lock 接口,你要举一个实现类的话,你举它肯定没错。

2、Lock主要方法介绍

在Lock中声明了四个方法来获取锁:
lock()、 tryLock()、tryLock(long time, TimeUnit unit)和lockInterruptibly()

第一个方法叫做 lock 方法,这个方法就是最普通的获取锁了。如果说这个锁已经被其他线程的锁拿到,那么它就等待。但是它有一个特点这也是我们 lock 的特点。 lock 它不会像 synchronized 一样在异常的时候自动释放锁。对于 synchronized 而言,你即便没有写,我在发生异常的时候能不能帮我释放一下锁没有写也没关系,jvm会自动帮我们释放,这是隐藏在背后的逻辑。可是对于我们这个 lock 而言,无论你是加锁还是解锁,你都必须自己主动的写出来,要用代码来明示,而不是暗示。所以说我们在使用 lock 的时候,最佳实践就是你无论怎么样都要写一个。Try类在 finallay 里面去释放锁,保证发生异常的时候锁一定会被释放。下面让我们来看一下它有一个什么问题,这个方法它不能被中断,这会带来很大的隐患。比如说我们陷入死锁了,死锁说两个线程互相想拿到对方持有的锁。如果我们用这个 lock 方法,它遇到死锁之后,那它也没有办法自己消除,它会陷入永久等待。

trylock 方法,这个方法可以用来获取锁。如果说当前的锁没有被其他线程所占用,那么我就获取成功了,它会返回一个布尔值返回处代表获取成功,返回 false 代表获取失败。相比于 lock 而言,这样的方法显然功能是更强大了,我们可以根据获取锁是否成功来决定程序后续要怎么处理。这个方法会立刻返回。不是说等一段时间我拿不拿,这个方法里面是没有参数的,那这个 trylock 会立刻返回,无论你拿到还是拿不到,它立刻给你一个答案,拿不到他也不会一直在那里一等。

trylock 兄弟方法,里面是多了一个参数区别,就是说它会等一段时间有一个超时时间,如果在这段时间内拿到锁,它就返回处。如果还是拿不到,那时间到了它也会返回,返回 false 还有一个方法叫做 lockinterruptibly 这个方法和上面那个方法是一样的,这两个方法它们都是声明了异常,也就是说你使用这个方法你必须 trycache 或者抛出去。但是它和它不同之处就在于它的时间默认为设置为无限,所以在等待的过程中也是可以被打断的,也是可以去感受到这个中断的门 lock 有一个很好的好处,就是它在灯锁期间他不是盲目的,也不是愣头青,也不是不见黄河不死心,他是很灵活的。如果你不想让他去等锁了,那么你随时可以中断他在锁里面。

最后一个介绍的方法就是 unlock unlock 这个方法大家一定要注意,就是它最应该被写在 final 类里面。并且我们一旦获取到锁,第一件事不是去执行,我获取到锁了有什么业务逻辑,而是写个 try 再写个 finally 把我们的 unlock 放在 finally 之后,完成了这个固定动作之后再去写我们的业务逻辑,这个是非常好的习惯,否则的话你可能就会漏掉去解锁或者是发生异常就跳过你的解锁。那这样一来就会导致你这个程序陷入死锁,因为你锁拿了又没有释放,这样很不好。

3、synchronized和Lock有什么异同?

相同点:

主要从两方方面去回答。第一个方面是相同点。那么最大的相同点相信小伙伴们都知道他们的目的和作用都是为了保证资源的线程安全。比如说我们使用了那么被保护的代码块儿,它就最多只有一个线程能同时访问,那这样的话它就保证了安全。同样 lock 的作用是类似的,是用于保证线程安全的,所以它们都可以被称为是锁。那这个是他们的最基础的作用是第一个相同点。而第二个相同点通常就不太容易考虑到了。第二个相同点是可重入。可重入是什么意思呢?意思就是说当我们拿到锁的这个线程想再次去获取这把锁的时候,是否需要提前释放掉我手中的锁?下面代码演示一下:

/**
 * 描述:     synchronized可重入
 */
public class Reentrant {

    public synchronized void f1() {
        System.out.println("f1方法被运行了");
        f2();
    }

    public synchronized void f2() {
        System.out.println("f2方法被运行了");
    }

    public static void main(String[] args) {
        Reentrant reentrant = new Reentrant();
        reentrant.f1();
    }
}

在这里插入图片描述
不同点:

第一个不同点就在于它们的用法 synchrniced 它可以用在方法上,同样也可以用在同步代码块儿上。那么这是它的最主要的用法。而对于 lock 而言,它的用法就不太一样了,它必须去使用 lock 方法来加锁,并且使用 unlock 方法来解锁。所以它的加解锁都显示的都是很明显你能看到的什么时候加锁,什么时候解锁?而 synchronize 它的加解锁是隐式的,是隐含在其中的。在 Java 代码中并没有很明显的说这个时候我要加锁,那个时候我要解锁这样的代码它是没有的。

第二个区别是加解锁的顺序不同。那由于我们的 lock 它加锁和解锁是我们可以程序员去手动写代码控制的。所以我们比如说想先给这三个锁加锁再去给它们反方向的解锁,这些都是可以去做到的,有灵活度是由我们程序员去掌控的。而 synchronized 的它是由我们的 Java 内部去控制的。在进入 synchronized 保护的代码的时候,它会加锁退出的时候会解锁,而这些都是自动的,所以在顺序上也不能灵活的调整。那这就是他们的第二个不同

第三个不同是 synchronized 不够灵活,怎么体现是这样的。如果我们有一个锁已经被某一个线程给获取了,这是一个锁,此时如果其他线程还想去获得这个锁的话,它只能等待直到上面一个锁释放。那这个时候就有一个问题,比如说等的时间可能会很长,这样的话你整个程序的运行效率就非常低了,甚至是如果别人他几天都不释放锁,那么你也只能一直等待下去。相反我们的 lock 就很灵活了,它在等锁的过程中你如果觉得时间太长了不想等的话,你可以去提前的退出。同样它的灵活之处还在于他在获取之前他可以先看一看现在你这个锁能不能获取到?是不是已经有线程占有你这个锁了?那么如果说当他发现此时获取不到锁的话,他可以灵活的去调整,比如说去执行其他的逻辑,那这样的话他就很灵活。所以这是他们的第三个不同。

第四个不同是性能上的区别。在性能上,之前的 Java 版本中, synchrniced 的性能是比较低的,在 Java 5 和 5 之前的版本它都比较低。但是到了 Java 6 以后,我们的 Java 对于 synchronized 进行了性能的优化。那么有有了这些优化之后,原本新 chronize 的性能确实是比 lock 要差,但是有了这些优化之后,它的性能逐步的去提高。所以到现在我们所使用的 Java 主流版本 synchronized 和 lock 它们的性能并没有很明显的差异,所以这就是它们在性能上的区别。具体指的是早期版本中 synchronized 性能差,现在的版本中性能差异较小。

4、你知道有几种锁?

  • 共享锁/独占锁
  • 公平锁/非公平锁
  • 悲观锁/乐观锁
  • 自旋锁/非自旋锁
  • 可重入锁/非可重入锁
  • 可中断锁/不可中断锁

第一种分类是 共享锁和独占锁 。这个含义是说这个锁是不是可以被共享还是只能被同一个线程所拿到。那么大部分的锁都是独占锁,也说当一个线程获取到之后,其他的线程不能来访问。而共享锁的一个最典型的案例就是读写锁。它的含义是说在多个线程同时去进行读操作的时候,你们是可以共享这把锁的,因为读操作并不会带来线程安全问题,所以使用共享锁可以提高效率。排他锁又有一个名字叫做独占锁或者叫独享锁。排他锁获取了这个锁之后,它既能读又能写。但是此时其他线程再也没有办法获得这个派他锁了,只能由他本人去修改数据,所以保证了线程安全。所以说我们举个例子,synchronized它本身就是一个排他锁,因为它获取之后别人获取不了。但是此时还有一种叫做共享锁。共享锁又可以称为读锁,我们获取到共享锁之后我们可以查看查询,但是我们不能修改也不能删除,做改动的都是不行的。其他线程,同时如果这个时候也只是想读的话,它是可以同时获取到这个共享锁的。但是同样道理,其他线程虽然获取到这个共享锁,它也不能修改也不能删除。那么对于这个而言,最典型的就是 reententreadwritelock 因为这里面有两把锁,其中一把独锁是共享锁,可以有多个线程同时持有。而写锁是独享锁只能最多有一个线程持有。下面我们就来看一下读写锁的作用。在一开始没有读写锁之前,假设我们使用最普通的 reentlock 那么这个时候我们确实是可以保证线程安全的,但是与此同时也浪费了一定的资源。比如说多个线程想同时读,多个线程想同时读实际上并没有线程安全问题,或者更多的线程,100个线程想同时读都是可以的。我们这个时候并没有必要给它加锁,因为读是安全的。可是我们如果使用了 reaction 的 lock 那它是不区分场景的,它不管你是读还是写,都必须要求有了这个锁之后才能操作。所以就造成了没有意义的同步,浪费了时间,浪费了资源。我们如果在此基础上升级,我们在读的地方只用读锁,在写的地方用写锁,这样就非常灵活。如果我们在没有协锁持有的情况下,我们的读锁它是没有阻塞的,多个线程可以同时来读,提高了我们程序的效率。

下一种分类是 公平锁和非公平锁。什么是公平和非公平。对于公平而言,它指的是按照我们现成请求的顺序来分配锁,你先来我就给你先分配锁,很公平也很好理解。但是这里的非公平指的是不是说完全乱序,这里大家一定要注意清楚这个非公平不是说我既然不公平,我就特别不公平,我就随机好了,不是这个意思,他这个非公平指的是我不完全按照请求的顺序,只有在一定的情况下他才可以插队的。我们这里有一个注意点,就是说我们这里的非公平,同样他其实内心还是一个好人,他是不提倡插队的。他这里的非公平只是在合适的时机他允许插队,不是说盲目乱插队。那么好小伙伴们肯定会有一个疑问了,那你说合适的时机插队,什么叫做合适的时机呢?这个合适的时机你说合适就合适,我被插队了,我不高兴,我说不合适。那你说以谁说的为准呢?所以说在这里我们要举一个例子,我们举一个买火车票被插队的例子,用这个例子就可以说明公平和非公平她们的情况。第一个,假设我们以前还没有1236,网上 App 的时候,大家还是说才对,去火车站买那个时候是这样的,其实 12306 也没几年,我们那个时候买火车票,尤其是春运的时候,可是很难抢票的。这个时候一个插队,那简直是影响到我能不能买到票,所以是非常关键的。这个时候假设有这么一个情况,我们是排在队伍的第二位。在我们前面有一个人他是先于我们排队,所以他自然是先于我们买票,他买完了票走了。下一个本来是我,可是因为我经过了彻夜的排队,那个时候买火车票实际上要彻夜排队的提早去的,要不然你买不到。所以那个时候其实我脑袋还是嗡嗡作响,还不是特别清醒,确实该轮到我了。可是这个时候我也没有一下子缓过神来,在那愣住了。这个时候第一个人本来已经走了,他突然回来又问了一下乘务员说我就问一句,很快的请问那火车几点发车就这样问一句,那你说这个叫不叫插队?这个实际上完全模拟了我们在线程中插队的情况。我们来想一下,这种情况下主要是体现了什么呢?体现了第一,由于我从呆蒙的状态到缓过神来去执行,这个就对应到我们线程从阻塞状态被唤醒,这个是需要一个长时间切换的。而刚才那个人他是很清醒,他直接来问,问好之后他就走,其实并没有影响到我们什么东西,因为那个时候就算他不来问,我也是脑子不清楚,也没办法买票。这个反映了我们这边非公平的意思。我们来看一下为什么要有非公平?主要是避免了唤醒带来的空档期这里有一个空档期的,因为我们为什么不希望锁都是公平的呢?毕竟公平是一种好的行为,不公平是不好的对不对?但是我们如果始终公平的话,他在把那个已经挂起的线程恢复过来的这段时间是有开销的。而这段时间如果你是公平的话,你要求必须排队的,那么这段时间谁都拿不到锁,谁都没办法处理。但是我们假设我们是可以允许非公平的。我们假设我们这边有三个线程,第一个线程 A 持有这把锁。线程 B 请求这把锁,由于这个锁已经被 A 持有了,那么 B 自然而然要去休息,假设 A 这个时候释放了,那么 B 就要被换唤醒并且拿到这把锁。假设与此同时,突然 C 来请求这个锁。那么由于 C 这个线程它本身一直是处于唤醒状态,它也没有休息,它是可以立刻执行的。那么它很有可能在 B 被完全唤醒之前就已经获得了,并且使用完了并且又释放掉这种锁了,这就形成了一种双赢的局面。为什么叫双赢呢?第一个,谁赢了, C 肯定是赢了, C 没有排队,他拿到锁了并且用完了释放了第二个谁赢了,其实 B 也没有输。为什么呢? B 本身这段时间他知道说 A 已经释放了,然后 B 唤醒 B 的这个过程是耗时的。那么这段时间本身这段时间我既然耗时,我也拿不到锁,不如就让给别人。所以说对于 B 而言,它拿到锁的时间并没有推迟,所以这是一种双赢的局面,这种插队是可以带来吞吐量的提升的。说到这里,小伙伴们一定明白了,为什么说要有非公平锁,主要因为在我们大多数的情况下,由于这个唤醒的过程这个开销胶其实是比较大的。那在这个期间它为了增加我们的吞吐量来把这个期间也给利用出去,这就是我们非公平设计的最根本的原因。

5、对比公平和非公平的优缺点

在这里插入图片描述

6、什么是乐观锁和悲观锁?

悲观锁

  • 如果我不锁住这个资源,别人就会来争抢,就会造成数据结果错误,所以每次悲观锁为了确保结果的正确性,会在每次获取并修改数据时,把数据锁住,让别人无法访问该数据,这样就可以确保数据内容万无一失
  • Java中悲观锁的实现就是synchronized和Lock相关类

乐观锁

  • 认为自己在处理操作的时候不会有其他线程来干扰,所以并不会锁住被操作对象
  • 在更新的时候,去对比在我修改的期间数据有没有被其他人改变过如果没被改变过,就说明真的是只有我自己在操作,那我就正常去修改数据
  • 如果数据和我一开始拿到的不一样了,说明其他人在这段时间内改过数据,那我就不能继续刚才的更新数据过程了,我会选择放弃、报错、重试等策略
  • 乐观锁的实现一般都是利用CAS算法来实现的

举几个典型的例子。悲观锁的例子我们刚才介绍过,主要是 synchronized 和 lock 锁。那么我们看一下乐观锁,乐观锁它也有很多的应用场景。比如说我们再举一个数据库的例子,关于乐观锁和悲观锁而言,在数据库中都有体现。我们先说一个悲观锁的体现。对于悲观锁而言呢,我们在数据库中如果用了这样的语句 select for update 那么它就会把库给锁住。锁住之后你再去更新。更新的期间其他人不能修改。但是如果我们用 version 来控制数据库,这就是乐观锁。我们来看一下怎么写这个语句。我们首先需要有一个字段啊叫做 lock_version 这个是专门用来记录版本号的。然后啊我们在查询询的时候是要把这个版本号给查出来,并且在下一次更新的时候把加 1 的这个版本号给更新上去。更新的时候它会去检查 where version 等于1。这个实际上就是在检查。如果在我更新的期间,有其他人已经率先修改了,那么由于对方也同样会把这个新的版本号更新上去。假设第二个线程先更新了,那么他会看到的现在的版本这个 ID 等于 5 的这条语句的版本,它就是 2 而不是1。所以如果在此期间它被修改过,那么这条语句是不会生效的。如果它更新的时候发现 ID 等于5,并且 version 确实等于1,说明在此期间没有人去修改。那么很好,我就把我现在的版本号是 2 给更新上去,这就是在数据库中利用我们的乐观锁去实现。

7、自旋锁和阻塞锁

什么是自旋锁?
如果我们不使用自旋锁,那么我们就需要阻塞或者唤醒一个 Java 线程。那么唤醒它需要我们切换 CPU 的状态,这个是需要耗费我们的处理器时间的。那么假设我们很快我所等待的那个锁就会被释放,那么其实不值得我每次都切换这个状态对不对?因为有可能我带来切换的开销比我执行那个代码还要时间长,我执行代码也许很简单,也许就是一行代码,但是你开销可能很大。所以为了应对这种场景,哪种场景就是我们同步资源锁定时间很短的场景,我就不必要为了这一小段时间去切换线程了。因为线程的挂起和恢复可能让整个的这个操作得不偿失。如果我们的物理机有多个处理器的话,我们可以让两个以上的线程同时是并行执行的。那么在这种情况下,我们后面请求锁的那个线程,他就不放弃 CPU 的执行时间,他去在那里不停地检测你,你是不是很快就释放了?如果你释放了,我就来拿到。这样一来我 CPU 没有释放,我 CPU 一直在检测,这样一来就避免了那个切换的过程。为了让当前线程去检测,也说让我稍等一下,我们让当前线程进行自旋。如果自旋完成后前面那个已经释放了,那么 OK 我就可以直接获取到了,避免了线程的开销。这个就是自旋锁。

自旋锁的缺点
如果我们的锁占用时间过长,那么自旋只会白白浪费处理器。为什么呢?因为前面那个人家不想释放,人家不想释放。轮到你本该去阻塞的,你又不阻塞,你老是占着 CPU 来问我,你问我我就告诉你。那么我现在站着的这个人他就说了,别问就是不释放,你别来问我,你问我我也不释放,一个小时我也不释放。那如果是这样的一个情况,这种特例的话,我们自旋所它的效率就不高了,因为它在自旋的过程中一直要消耗 CPU 虽然一开始它的开销确实不高,但是随着自旋的时间增长,它的开销线性增长,那逐渐它的开销就大了。

8、可重入的性质

如果我再次去申请这个锁的时候,无需提前释放掉我这把锁,而是可以直接继续使用我手里这把锁再去获取的话,这个就叫做可重入。可重入锁也叫做递归锁,指的是我同一个线程可以多次获取同一把锁。在我们的 Java 中, lock 是一种synchronized ,也是一种可重入锁。那么这有什么好处呢?首先第一个它的好处就是可以避免死锁。为什么这么说,假设我们有两个方法都被我们的 synchronize 修饰了,或者是被我们同一个锁给锁住了。那么这个时候线程 A 运行到第一个方法他拿到这把锁了。可是这个时候他如果想执行第二个方法,这个方法也是被同样的锁锁住。假设我们不具备可重入性,那么这个时候再去获取那把锁你是获取不到的,因为这把锁你必须要先释放才能再获取。那你如果不具备可重入性的话,这个时候就发生死锁了,相当于我手上拿着这把锁,我还想获取这把锁对不起,你获取不到。那么有了可重入性之后,我们就不会发生这种现象了,可以避免死锁的发生。二点好处就是提高了我们的封装性。这样一来啊我们的枷锁锁解锁就没有那么麻烦,避免了一次的解锁又加锁,解锁又加锁,降低了我们编程的难度。

9、中断锁和不可中断锁

可中断锁说你在获取锁的时候,如果期间你不想去获取了,你觉得等待的时间太长了,你可以中断它,让它不再去傻等而不可中断锁,它就没有这个功能。一旦你想让它去获取锁,他就必须去一直等,一直等,直到他拿到锁才可以进行其他的操作。

10、什么是死锁?

  • 发生在并发中
  • 互不相让:当两个(或更多)线程(或进程)相互持有对方所需要的资源,又不主动释放,导致所有人都无法继续前进,导致程序陷入无尽的阻塞,这就是死锁。
    在这里插入图片描述

线程 A 大家看到它在左侧是持有第一把锁的,但是同时它想去获取右侧的这第二把锁。同样道理,线程 B 它持有第二把锁,他想去获取第一把锁。如果假设他们在这里不首先让出自己的锁,那么就相当于陷入了无穷的等待了。因为锁它的特性是只能同时被一个线程所拥有。在这种情况下,锁 1 已经被线程 A 拿走了,它就不可能被线程 B 拿走。锁 2 已经被线程 B 拿走了,它也不可能被线程 A 拿走。所以线程 A 和线程 B 他们手握一部分资源,想获取另一部分资源,可是却永远没有办法让这个程序员继续下去。

多个线程造成死锁的情况
在这里插入图片描述
多个线程和两个线程它们情况是类似的,只不过多个线程它们相互依赖不再是你依赖我,我依赖你,而是说它们要形成一个环路,一旦它们形成了这个环路,它们依然可能发生死锁。在这个图中,我们一共有三个线程,第一个线程它是拿到了锁 A 想去获取锁 B 第二个线程是拿到了锁 B 想去获取锁 C 我们来看一下,假设是这种情况,我们先考虑前两个线程是一个什么样的状态。那么前两个线程,由于第一个线程他拿到 A 了,这拿得很顺利,然后想去拿 B 可是 B 已经被我们的第二个线程拿到了。所以对于第一个线程而言,他就说,那我等一等,没关系对吧,我等到你闭这个锁是不是释放之后再给我,这就可以。所以说第一个线程他就开始等。那么对于第二个线程而言,他拿到了 B 想去拿 C 可是不巧的是,这个 c2 已经被我们线程 3 所获取了。所以说这个时候第二个线程他就开始等啦,他想等到 C 被释放之后他去拿,可是 C 会不会释放呢?我们来看到线程3,线程 3 他是拿到了 C 想去获取 A 这个 A 恰恰就形成了环路了。在这里他想去获得 A 可是 A 被线程 1 所拿到,线程 1 是不会轻易释放 A 的,除非他拿到了 B 线程 2 是不会释放 B 的,除非他拿到了 C 线程 3 是不会释放 C 的,除非他拿到了 A 这样一来,他们三个就相互打架,三个和尚没水喝,说的恰恰就是这种情况。这样一来,多个线程同样也会造成死锁的情况,因为它们之间会形成一个锁的环路。

编写一个死锁的例子:

/**
 * 描述:     必然发生死锁
 */
public class DeadLock implements Runnable {

    public int flag;

    static Object o1 = new Object();
    static Object o2 = new Object();

    public void run() {
        System.out.println("开始执行");
        if (flag == 1) {
            synchronized (o1) {
                try {
                    Thread.sleep(500);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (o2) {
                    System.out.println("成功获取到了两把锁");
                }
            }
        }
        if (flag == 2) {
            synchronized (o2) {
                try {
                    Thread.sleep(500);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (o1) {
                    System.out.println("成功获取到了两把锁");
                }
            }
        }
    }

    public static void main(String[] args) {
        DeadLock r1 = new DeadLock();
        DeadLock r2 = new DeadLock();
        r1.flag = 1;
        r2.flag = 2;
        new Thread(r1).start();
        new Thread(r2).start();
    }
}

在这里插入图片描述

九、HashMap和final

1、Hashmap为什么不安全?

我们直接看源码:
在这里插入图片描述
会出现安全问题的就是这个++,虽然这是一行但有三个操作:

  • 第一个步骤是读取
  • 第二个步骤是增加
  • 第三个步骤是保存
    以i为例:
    在这里插入图片描述

        假设最开始i的值是1,然后线程 1 先去执行i++,他会发现i 等于1,然后假设他执行第二步进行增加,I加 1 它就算出来了。但是此时它还没有进行第三个步骤保存。它还没有保存的时候线程 2 开始执行了,所以这边的箭头指向了右侧。那由于线程 1 还没有保存,所以此时线程 2 所读到的值一定还是 1 而不是2。所以线程 2 拿到 1 之后再去进行加,同样是把i从 1 加到了2。假设此时线程 1 又执行了,然后线程 1 就会把i等于 2 给保存回去。同样最后轮到线程 2 执行的时候,线程 2 会把 i 等于 2 给保存回去。那这样一来两个线程执行了两次i++,本来如果是 1 的话就应该变成3,但是最终你会发现它变成的是2,那这就导致了线程安全问题,导致我们运行的结果都错误了,这肯定是不行的。所以说我们已经发现了,在我们的哈希 map 中,只要你存在这样的代码,比如说加,并且没有对这个方法进行任何的同步,比如说 synchronize 或者是锁这样的同步它都没有。那这样就可以证明我们的希 map 是不安全的了。当然这是第一点,也就是在计算的时候,我们这个 modcount 可能它计算是会不准确的,这是一个角度。
        另外也有其他的角度说它线程不安全。还有一个角度就是在同时 put 的时候会导致数据丢失。如果有多个线程同时对哈希 map 进行赋值的话,并且他们的 key 假设是一样的,那么就可能会发生冲突,在发生冲突的时候就可能会有一个线程,它的值直接就丢失了,那这样的话就造成了数据的损失也是不好的。
        不仅如此它还有可见性问题。那可见性问题指的就是说比如说一个线程对哈希 map 进行了赋值,但是另外一个线程却有可能是看不见的。第二个线程去获取的时候可能获取到的是旧的值,所以这也是一个很严重的问题,这就是哈希 map 它的一个弊端。那么经过这些分析我们可以得出一个结论,说由于哈希 map 自身它不是线程安全的,所以我们尽量不要在并发的情况下去使用它。

2、final的作用是什么?有哪些用法?

  • final修饰变量
  • final修饰方法
  • final修饰类

final的作用:
        早期: 早期的 Java 版本中,如果用了 final 修饰,它就会把某一个方法转为内嵌。内嵌的意思就是说我们用一个方法去调用另外一个 final 方法。那么当编译器发现它是 final 的,它就会把那个方法里面的东西全都给挪过来,相当于我们只在同一个方法内就完成了整个的工作,而不是方法之间调来调去。因为我们知道方法之间的调用它是有一个性能损耗的,所以这样一来可以提高一定的效率。
        现在: 第一点,我们可以修饰一个类,防止被继承。第二点我们可以修饰一个方法,防止被重写。第三点我们可以修饰一个变量,防止被修改。而且第二点其实现在用 final 的一大原因就是为了实现线程安全,如果我们可以用 final 把对象做到不可变,那就不再需要额外的同步开销,这是一个很划算的生意。并且第三点就是之前的我们刚才说在早期版本中用 final 带来的性能提高,在目前我们几乎是不需要再考虑了。因为我们目前的 JVM 它非常智能,它会把能优化的点都优化到。这样一来用不用 final 所带来的区别可以说是可以忽略不计的。而且也有人做过测试,目前从性能的角度考虑已经看不出它的优势了。目前我们使用它更多的还是基于设计的清晰。因为修饰之后我们就知道了这个属性或者这个类或者这个方法,它拥有了 final 语义,也就是我们不希望它被继承被重写或者被修改,这是目前使用 final 的原因,而不再是性能原因了。

final的3种用法: 第一种用法是修饰变量,第二种用法是修饰方法,第三种用法是修饰类。

  • final instance variable(类中的final属性)
  • final static variable(类中的static final属性)
  • final local variable(方法中的final变量)

三种变量它们最主要的区别就是在赋值。实际上一旦一个属性被声明为 final 之后,它的变量就只能被赋值一次,一旦赋值就不能再改变,无论如何也不能改变。

赋值时机:

  • final instance variable(类中的final属性)
    • 第一种是在声明变量的等号右边直接赋值第二种就是构造函数中赋值
    • 第三就是在类的初始代码块中赋值(不常用)
    • 如果不采用第一种赋值方法,那么就必须在第2、3种挑一个来赋值,而不能不赋值,这是final语法所规定的

  • final static variable(类中的static final属性)
    • 两个赋值时机∶除了在声明变量的等号右边直接赋值外,static final变量还可以用static初始代码块赋值,但是不能用普通的初始代码块赋值

  • final local variable(方法中的final变量)
    • 和前面两种不同,由于这里的变量是在方法里的,所以没有构造函数,也不存在初始代码块
    • final local variable不规定赋值时机,只要求在使用前必须赋值,这和方法中的非final变量的要求也是一样的

为什么要规定赋值时机?
我们来思考一下为什么语法要这继承这样?∶如果初始化不赋值,后续赋值,就是从null变成你的赋值,这就违反final不变的原则了!

总结: 使用它的时候有三个途径,一个是变量,一个是方法,一个是类。尤其是对于变量而言,还分为三种变量。在这边会有类中的属性,类中的 static 以及方法中的它们各自都有不同的赋值时机。但是总结出来一旦被赋值,那么它就不可以再变化了。对于 final 的第二种用法而言,是修饰方法,构造方法不允许被修饰。而普通方法被修饰之后,它不能被 override 如果用发音道去修饰类代表这个类不可被继承。最典型的就是我们的 string 它就是发音道修饰的,它也不可以被继承。

十、单例模式的八种写法

1、什么是单例模式?

单例模式指的是保证一个类只有一个实例,并且还提供一个全局可以访问的入口,这个就是单例模式了。我们举个例子,比如说分身术,分身术分出来其实有很多个,但是真正的真身只有一个。也就是说如果我们使用了单例模式看上去每个地方都能调用到这个对象,但其实它们背后都是同一个对象。

2、为什么需要单例模式?

节省内存和计算、保证结果正确、方便管理

3、应用场景

        没有状态的工具类。比如说日志工具类,它就属于没有状态的,无论在哪里使用,其实我们去调用它仅仅是让它帮我们去记录日志信息。除此之外,我们也不需要在实例对象上存储任何的状态。那么在这种情况下,这种工具类我们使用一个实例就够了,类似的还有像字符串处理工具类或者是日期工具类都可以。那么我们利用单例模式给我们提供一个统一的入口,使得管理这些工具类就非常的方便。
        全局的信息类。比如说我们用一个类记录网站的访问次数,我们不希望有的被记录在 A 上,有的记录被记录在对象 B 上。那此时我们用这个单例模式去做就很合适,类似的还有环境变量。

4、单例模式的八种写法

  • 饿汉式(静态常量)[可用]
  • 饿汉式(静态代码块)[可用]
  • 懒汉式(线程不安全)[不可用]
  • 懒汉式(线程安全,同步方法)[不推荐用]
  • 懒汉式(线程不安全,同步代码块)[不可用]
  • 双重检查[推荐用]
  • 静态内部类[推荐用]
  • 枚举[推荐用]

下面是代码演示:

1)、饿汉式(静态常量)(可用)

public class Singleton1 {

    private Singleton1() {

    }

    private final static Singleton1 INSTANCE = new Singleton1();

    public static Singleton1 getInstance() {
        return INSTANCE;
    }
}

当用户想去拿到这个单例的时候,他会调用这边的 get instance 方法。那么返回的就是这个 instance 而这个 instance 它会在最开始类加载的时候就把这个实例给初始化出来。那么你可以在这个构造函数里面去写很多的或者说更多的初始化的内容,无论是给其中的属性赋值或者是去计算或者是去调用数据库都可以。但是后续凡是去使用 get instance 拿到的实例一定是这个单例。那这种写法为什么说它可用呢?原因就在于它不具备懒加载的效果。

那什么叫做懒加载啊?懒加载的意思就是说在加载这个类之后,并不一定要立刻的把这个实例给初始化出来,可以到运用实例的时候再初始化出来。但是我们这种写法只要加载了这个类,那么由于我们这边的 instance 它是 static 修饰的。所以根据 Java 类加载的原则,datect修饰的,在类加载的时候就会完成对于后面这个实例的创建,所以它的主要缺点在于没有达到懒加载的效果。

2)、饿汉式(静态代码块)(可用)

public class Singleton2 {

    private Singleton2() {

    }

    static {
        INSTANCE = new Singleton2();
    }
    private final static Singleton2 INSTANCE;

    public static Singleton2 getInstance() {
        return INSTANCE;
    }
}

根据我们类加载的原则,同样在类加载的时候会把静态代码块儿也就是 static 修饰的这个大括号里面的内容都执行完,所以它就执行完了。那么一旦你执行完,这个对象也就创建出来了。那有的时候如果你类加载了,但是其实你并不需要这个单例的话,但是这个时候由于 static 这个代码块儿一定会被执行,所以这个实例它所占用的内存包括初始化所带来的开销,其实都属于浪费了。

所以这种写法和之前的那种写法,它们拥有一样的缺点,那就是没有实现懒加载的效果,这就是饿汉式的一个通病。饿汉式之所以叫饿汉式。说明他饿很饿的人一见到食物就会去吃。所以这个饿汉式的写法,一旦在类加载的时候,就会把实例给实例化出来。

3)、懒汉式(线程不安全)

public class Singleton3 {

    private Singleton3() {

    }

    private static Singleton3 INSTANCE;

    public static Singleton3 getInstance() {
        if (INSTANCE == null) {
            INSTANCE = new Singleton3();
        }
        return INSTANCE;
    }
}

这个就是最简单的懒汉式的写法。那么逻辑上看上去并没有问题。因为第一个访问这个 get instance 方法的线程,它会发现 instance 等于 null于是就新建并且返回后面的线程,发现它不等于 null直接返回并且返回的都是同一个实例。

可是这种写法的问题在哪里呢?问题就在于,如果有两个线程同时的访问到这一行代码,也就是他们同时去判断 instance 是不是等于 null那么假设此时这个 instance 还没有被初始化,也就是这两个线程都是第一次去访问这个 instance 那么这个时候由于他们都是同时在这边,所以他们都会同时的判断。你确实等于null,于是他们都会进入到这一行语句中。这样一来,我们就创建了两个实例,第一个线程会创建一个 singleton3 而第二个线程也会创建一个 singleton3。那这样一来就违背了我们单例模式的初衷和原则。我们最大的原则就是只有一个实例不能有两个实例。那现在一旦两个线程同时去访问的话,就会导致你这个单例模式失效,所以这是线程不安全的。

4)、懒汉式(线程安全,同步方法)(不推荐)

public class Singleton4 {

    private Singleton4() {

    }

    private static Singleton4 INSTANCE;

    public synchronized static Singleton4 getInstance() {
        if (INSTANCE == null) {
            INSTANCE = new Singleton4();
        }
        return INSTANCE;
    }
}

第一个线程它全都执行完毕了。第二个线程进来,它就不可能再看到这个 instance 为 null 了。因为第一个线程执行完毕之后,它已经把它实例化完毕了。所以第二个线程看到 OK 你不等于闹,于是就返回了。所以就避免了之前的两个实例的这种问题。所以这种写法是线程安全的,是可以使用的。

但是说它可以使用的同时我们同样又标出了不推荐。所以这种写法它的问题在哪里呢?
系统并发量比较大,那么大家都排队的话这个效率就太低了。每个线程想获取这个类的单例的时候都要进行同步,那多个线程还不能同时的进行获取。那假设我们线程多一点,可能会导致在获取这个实例的时候发生拥堵。那其实这种麻烦是没有必要的,我们并不需要让他每次都进行同步。

5)、假如我们升级一下(同步的范围尽量缩小),上面的代码

public class Singleton5 {

    private Singleton5() {

    }

    private static Singleton5 INSTANCE;

    public static Singleton5 getInstance() {
        if (INSTANCE == null) {
            synchronized (Singleton5.class) {
                INSTANCE = new Singleton5();
            }
        }
        return INSTANCE;
    }
}

这个也不是线程安全的,我们来想象一种情况,那假设有两个线程同时的去走到了这一行语句,并且他们都判断出来 instance 等于 null 于是他们都会进入到 synchronized 代码块的外面,虽然根据规定不能有两个线程同时的去执行这里面的语句。但是假设第一个线程已经执行完了,里面的语句,第二个线程此时就会进去并且再次执行这个语句。这样一来还是生成了两个实例。所以这种写法它的初衷是好,他想把我们的同步的范围尽量缩小,这样的话效率尽可能的可以提高,但是是线程不安全的,那么线程不安全的话肯定是不能使用的。

6)、双重检查(推荐)

public class Singleton6 {

    private Singleton6() {

    }

    private static volatile Singleton6 INSTANCE;

    public static Singleton6 getInstance() {
        if (INSTANCE == null) {
            synchronized (Singleton6.class) {
                if (INSTANCE == null) {
                    INSTANCE = new Singleton6();
                }
            }
        }
        return INSTANCE;
    }
}

首先我们再来看刚才我们所讲到的那种情况,当两个线程同时的去访问到这一行,并且都发现它等于闹,于是一个线程会等待在这个代码块的外面,另外一个线程去执行。这样当它执行完毕之后,这个 instance 就被实例化了。此时第二个线程再进来他还会检查一下,他此时一定会发现这个 instance 不等于闹。因为前面那个线程已经把它初始化完毕了,所以它就会跳过这个创建实例的这个过程。然后返回返回的也是第一个线程所创建的那个实例,所以这样一来就不会出现多个实例的情况了。

现在我们就来看一下为什么需要使用 volatile 新建一个对象,比如说我们去执行一个 newsingle6,这就是一个典型的新建对象。那么新建一个对象,其实会有三个步骤,而不是我们所表面上看到的这一个步骤。哪三个步骤呢?

  • 1.新建一个对象,但还未初始化
  • 2.调用构造函数等来初始化该对象
  • 3.把对象指向引用

问题:
        由于 CPU 的优化或者是编译器的优化,这三步其实可能它的顺序会被调换,也就是说看上去是 123 的步骤,有一定可能性会变成132。一旦它的顺序变成了132。也说我们在这边它是先新建对象,但是还没有初始化,但是他就把这个对象指向这个引用了。那此时这个对象其实还没有调用构造函数,但是对于我们判断它等不等于 null 这个时候它的结果已经是不等于null了。
        现在我们就来模拟这种场景,假设第一个线程它先执行,然后它去判断等不等于null。由于是第一次执行,所以它等于null,它就进来了这个同步代码块儿。那么进来了同步代码块之后,他再次判断等不等于到依然等于到,于是他就去创建。那这里我们已经知道了,他其实背后是有三步的,于是假设他执行完了第一步,然后跳到第三步来执行,第二步还没有执行。那么此时这个 instance 已经不等于null了,但是它还没有执行真正的构造函数,所以它的很多的属性还没有被初始化。此时假设有另外一个线程进来了,它刚刚进入到这个方法之后,第一步就是去判断它是不是等于null。那此时由于它不等于null,因为前面我们讲过这个对象已经被指向了这个引用,所以对于第二个线程而言,它看到的确实不等于null,于是他就把这个对象给返回了。但是这个对象返回的时候,其实这个对象还没有执行构作函数里面的内容,所以它还没有初始化完毕。那么第二个线程拿到这样一个半成品的对象去使用的话,自然就会报错了。

总结: 第一点是第一重检查的作用在于提高效率。第二点在于第二重检查的作用在于保证线程安全。而第三点在于volatile ,它的作用主要是为了防止重排序所带来的问题。有了它之后就可以自动的避免重排序,保证了线程安全。

7)、静态内部类写法(推荐用)

public class Singleton7 {

    private Singleton7() {

    }

    private static class SingletonInstance {

        private static Singleton7 INSTANCE = new Singleton7();
    }

    public static Singleton7 getInstance() {
        return SingletonInstance.INSTANCE;
    }
}

那我们来看一下,这种写法的关键点在于它有一个内部类,并且在这个内部类里面去把我们的 instance 给实例化了。那用这种写法的好处在于外面这个类被装载的时候,里面这个类并不会被装载,所以它就实现了懒加载。那么只有调用 get instance 方法的时候,它会去访问到里面的这个 instance 实例,此时才会把它给加载出来,所以它就避免了内存的浪费。那这种写法我们可以在项目中使用是没有问题的。

8)、枚举单例模式

public enum Singleton8 {
    // 1.写法简洁,只需要Singleton8.INSTANCE,就可以进行操作了
    // 2.线程安全,Java 虚拟机所保证
    // 3.防止反射,Java规定枚举是不允许被反射创建的,所以它天然的就保证了反射时候的安全性。

    INSTANCE;
}

不同的写法对比:

  • 饿汉︰简单,但是没有lazy loading(懒加载)
  • 懒汉︰有线程安全问题
  • 静态内部类∶可用
  • 双重检查∶面试用
  • 枚举:最好
  • 3
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值