面试题整理

小龙 721 2022-04-24

谈谈你对AQS的理解

AQS是AbstractQueuedSynchronizer的简称,是并发编程中比较核心的组件。

回答

AQS是多线程同步器,它是J.U.C包中多个组件的底层实现,如Lock、CountDownLatch、Semaphore等都用到了AQS.

从本质上来说,AQS提供了两种锁机制,分别是排它锁,和 共享锁。

排它锁,就是存在多线程竞争同一共享资源时,同一时刻只允许一个线程访问该共享资源,也就是多个线程中只能有一个线程获得锁资源,比如Lock中的ReentrantLock重入锁实现就是用到了AQS中的排它锁功能。

共享锁也称为读锁,就是在同一时刻允许多个线程同时获得锁资源,比如CountDownLatch和Semaphore都是用到了AQS中的共享锁功能。

fail-safe机制与fail-fast机制分别有什么作用

回答

fail-safe和fail-fast ,是多线程并发操作集合时的一种失败处理机制。

Fail-fast : 表示快速失败,在集合遍历过程中,一旦发现容器中的数据被修改了,会立刻抛出ConcurrentModificationException异常,从而导致遍历失败,像这种情况(贴下面这个图)。

定义一个Map集合,使用Iterator迭代器进行数据遍历,在遍历过程中,对集合数据做变更时,就会发生fail-fast

java.util包下的集合类都是快速失败机制的, 常见的的使用fail-fast方式遍历的容器有HashMap和ArrayList等。

carbon-2022030202

Fail-safe,表示失败安全,也就是在这种机制下,出现集合元素的修改,不会抛出ConcurrentModificationException。

原因是采用安全失败机制的集合容器,在遍历时不是直接在集合内容上访问的,而是先复制原有集合内容,

在拷贝的集合上进行遍历。由于迭代时是对原集合的拷贝进行遍历,所以在遍历过程中对原集合所作的修改并不能被迭代器检测到

比如这种情况(贴下面这个图) , 定义了一个CopyOnWriteArrayList,在对这个集合遍历过程中,对集合元素做修改后,不会抛出异常,但同时也不会打印出增加的元素。

java.util.concurrent包下的容器都是安全失败的,可以在多线程下并发使用,并发修改。

常见的的使用fail-safe方式遍历的容器有ConcerrentHashMap和CopyOnWriteArrayList等。

carbon-2022030203

谈谈你对Seata的理解

回答

  1. 在微服务架构下,由于数据库和应用服务的拆分,导致原本一个事务单元中的多个DML操作,

    变成了跨进程或者跨数据库的多个事务单元的多个DML操作,

    而传统的数据库事务无法解决这类的问题,所以就引出了分布式事务的概念。

  2. 分布式事务本质上要解决的就是跨网络节点的多个事务的数据一致性问题,业内常见的解决方法有两种

    1. 强一致性,就是所有的事务参与者要么全部成功,要么全部失败,全局事务协调者需要知道每个事务参与者的执行状态,再根据状态来决定数据的提交或者回滚!

    2. 最终一致性,也叫弱一致性,也就是多个网络节点的数据允许出现不一致的情况,但是在最终的某个时间点会达成数据一致。

      基于CAP定理我们可以知道,强一致性方案对于应用的性能和可用性会有影响,所以对于数据一致性要求不高的场景,就会采用最终一致性算法。

  3. 在分布式事务的实现上,对于强一致性,我们可以通过基于XA协议下的二阶段提交来实现,对于弱一致性,可以基于TCC事务模型、可靠性消息模型等方案来实现。

  4. 市面上有很多针对这些理论模型实现的分布式事务框架,我们可以在应用中集成这些框架来实现分布式事务。

    而Seata就是其中一种,它是阿里开源的分布式事务解决方案,提供了高性能且简单易用的分布式事务服务。

Seata中封装了四种分布式事务模式,分别是:

  • AT模式,是一种基于本地事务+二阶段协议来实现的最终数据一致性方案,也是Seata默认的解决方案

    image-20220302110420998

  • TCC模式,TCC事务是Try、Confirm、Cancel三个词语的缩写,简单理解就是把一个完整的业务逻辑拆分成三个阶段,然后通过事务管理器在业务逻辑层面根据每个分支事务的执行情况分别调用该业务的Confirm或者Cacel方法。

    Overview of a global transaction

  • Saga模式,Saga模式是SEATA提供的长事务解决方案,在Saga模式中,业务流程中每个参与者都提交本地事务,当出现某一个参与者失败则补偿前面已经成功的参与者。

    Saga模式示意图

  • XA模式,XA可以认为是一种强一致性的事务解决方法,它利用事务资源(数据库、消息服务等)对 XA 协议的支持,以 XA 协议的机制来管理分支事务的一种 事务模式。

    img

从这四种模型中不难看出,在不同的业务场景中,我们可以使用Seata的不同事务模型来解决不同业务场景中的分布式事务问题,因此我们可以认为Seata是一个一站式的分布式事务解决方案。

Spring Boot的约定优于配置,你的理解是什么?

回答

我从4个点方面来回答。

  1. 首先, 约定优于配置是一种软件设计的范式,它的核心思想是减少软件开发人员对于配置项的维护,从而让开发人员更加聚焦在业务逻辑上。

  2. Spring Boot就是约定优于配置这一理念下的产物,它类似于Spring框架下的一个脚手架,通过Spring Boot,我们可以快速开发基于Spring生态下的应用程序。

  3. 基于传统的Spring框架开发web应用,我们需要做很多和业务开发无关并且只需要做一次的配置,比如

    1. 管理jar包依赖
    2. web.xml维护
    3. Dispatch-Servlet.xml配置项维护
    4. 应用部署到Web容器
    5. 第三方组件集成到Spring IOC容器中的配置项维护

    而在Spring Boot中,我们不需要再去做这些繁琐的配置,Spring Boot已经自动帮我们完成了,这就是约定由于配置思想的体现。

  4. Spring Boot约定由于配置的体现有很多,比如

    1. Spring Boot Starter启动依赖,它能帮我们管理所有jar包版本
    2. 如果当前应用依赖了spring mvc相关的jar,那么Spring Boot会自动内置Tomcat容器来运行web应用,我们不需要再去单独做应用部署。
    3. Spring Boot的自动装配机制的实现中,通过扫描约定路径下的spring.factories文件来识别配置类,实现Bean的自动装配。
    4. 默认加载的配置文件application.properties 等等。

总的来说,约定优于配置是一个比较常见的软件设计思想,它的核心本质都是为了更高效以及更便捷的实现软件系统的开发和维护。

滴滴二面:kafka的零拷贝原理?

回答

在实际应用中,如果我们需要把磁盘中的某个文件内容发送到远程服务器上,如图

那么它必须要经过几个拷贝的过程,如图(贴图)。

  1. 从磁盘中读取目标文件内容拷贝到内核缓冲区
  2. CPU控制器再把内核缓冲区的数据赋值到用户空间的缓冲区中
  3. 接着在应用程序中,调用write()方法,把用户空间缓冲区中的数据拷贝到内核下的Socket Buffer中。
  4. 最后,把在内核模式下的SocketBuffer中的数据赋值到网卡缓冲区(NIC Buffer)
  5. 网卡缓冲区再把数据传输到目标服务器上。

preview

在这个过程中我们可以发现,数据从磁盘到最终发送出去,要经历4次拷贝,而在这四次拷贝过程中,有两次拷贝是浪费的,分别是:

  1. 从内核空间赋值到用户空间
  2. 从用户空间再次复制到内核空间

除此之外,由于用户空间和内核空间的切换会带来CPU的上线文切换,对于CPU性能也会造成性能影响。

而零拷贝,就是把这两次多于的拷贝省略掉,应用程序可以直接把磁盘中的数据从内核中直接传输给Socket,而不需要再经过应用程序所在的用户空间,如下图所示。

零拷贝通过DMA(Direct Memory Access)技术把文件内容复制到内核空间中的Read Buffer,

接着把包含数据位置和长度信息的文件描述符加载到Socket Buffer中,DMA引擎直接可以把数据从内核空间中传递给网卡设备。

在这个流程中,数据只经历了两次拷贝就发送到了网卡中,并且减少了2次cpu的上下文切换,对于效率有非常大的提高。

preview

所以,所谓零拷贝,并不是完全没有数据赋值,只是相对于用户空间来说,不再需要进行数据拷贝。对于前面说的整个流程来说,零拷贝只是减少了不必要的拷贝次数而已。

在程序中如何实现零拷贝呢?

  • 在Linux中,零拷贝技术依赖于底层的sendfile()方法实现
  • 在Java中,FileChannal.transferTo() 方法的底层实现就是 sendfile() 方法。

除此之外,还有一个 mmap 的文件映射机制

它的原理是:将磁盘文件映射到内存, 用户通过修改内存就能修改磁盘文件。使用这种方式可以获取很大的I/O提升,省去了用户空间到内核空间复制的开销。

以上就是我对于Kafka中零拷贝原理的理解

innoDB如何解决幻读

MVCC类似于一种乐观锁的机制,通过版本的方式来区分不同的并发事务,避免幻读问题!

回答

我会从三个方面来回答:

1、 Mysql的事务隔离级别

Mysql有四种事务隔离级别,这四种隔离级别代表当存在多个事务并发冲突时,可能出现的脏读、不可重复读、幻读的问题。

其中InnoDB在RR的隔离级别下,解决了幻读的问题。

image-20220306154449430

2、 什么是幻读?

那么, 什么是幻读呢?

幻读是指在同一个事务中,前后两次查询相同的范围时,得到的结果不一致(我们来看这个图)

  • 第一个事务里面我们执行了一个范围查询,这个时候满足条件的数据只有一条
  • 第二个事务里面,它插入了一行数据,并且提交了
  • 接着第一个事务再去查询的时候,得到的结果比第一查询的结果多出来了一条数据。

image-20220301151549601

所以,幻读会带来数据一致性问题。

3、 InnoDB如何解决幻读的问题

InnoDB引入了间隙锁和next-key Lock机制来解决幻读问题,为了更清晰的说明这两种锁,我举一个例子:

假设现在存在这样(图片)这样一个B+ Tree的索引结构,这个结构中有四个索引元素分别是:1、4、7、10。

image-20220306155849177

当我们通过主键索引查询一条记录,并且对这条记录通过for update加锁(请看这个图片)

image-20220306161435984

这个时候,会产生一个记录锁,也就是行锁,锁定id=1这个索引(请看这个图片)。

image-20220302175018903

被锁定的记录在锁释放之前,其他事务无法对这条记录做任何操作。

前面我说过对幻读的定义: 幻读是指在同一个事务中,前后两次查询相同的范围时,得到的结果不一致!

注意,这里强调的是范围查询,

也就是说,InnoDB引擎要解决幻读问题,必须要保证一个点,就是如果一个事务通过这样一条语句(如图)进行锁定时。

image-20220306161951090

另外一个事务再执行这样一条(显示图片)insert语句,需要被阻塞,直到前面获得锁的事务释放。

image-20220306162324367

所以,在InnoDB中设计了一种间隙锁,它的主要功能是锁定一段范围内的索引记录(如图)

当对查询范围id>4 and id <7加锁的时候,会针对B+树中(4,7)这个开区间范围的索引加间隙锁。

意味着在这种情况下,其他事务对这个区间的数据进行插入、更新、删除都会被锁住。

image-20220306163023219

但是,还有另外一种情况,比如像这样(图片)

image-20220306163933925

这条查询语句是针对id>4这个条件加锁,那么它需要锁定多个索引区间,所以在这种情况下InnoDB引入了next-key Lock机制。

next-key Lock相当于间隙锁和记录锁的合集,记录锁锁定存在的记录行,间隙锁锁住记录行之间的间隙,而next-key Lock锁住的是两者之和。(如图所示)

image-20220306164559217

每个数据行上的非唯一索引列上都会存在一把next-key lock,当某个事务持有该数据行的next-key lock时,会锁住一段左开右闭区间的数据。

因此,当通过id>4这样一种范围查询加锁时,会加next-key Lock,锁定的区间范围是:(4, 7] , (7,10],(10,+∞]

image-20220306164958923

间隙锁和next-key Lock的区别在于加锁的范围,间隙锁只锁定两个索引之间的引用间隙,而next-key Lock会锁定多个索引区间,它包含记录锁和间隙锁。

当我们使用了范围查询,不仅仅命中了Record记录,还包含了Gap间隙,在这种情况下我们使用的就是临键锁,它是MySQL里面默认的行锁算法。

4 、总结

虽然InnoDB中通过间隙锁的方式解决了幻读问题,但是加锁之后一定会影响到并发性能,因此,如果对性能要求较高的业务场景中,可以把隔离级别设置成RC,这个级别中不存在间隙锁。

以上就是我对于innoDB如何解决幻读问题的理解!

CPU飙高系统反应慢怎么排查?

高手

好的,关于这个问题,我从四个方面来回答。

  1. CPU是整个电脑的核心计算资源,对于一个应用进程来说,CPU的最小执行单元是线程。

  2. 导致CPU飙高的原因有几个方面

    1. CPU上下文切换过多,对于CPU来说,同一时刻下每个CPU核心只能运行一个线程,如果有多个线程要执行,CPU只能通过上下文切换的方式来执行不同的线程。上下文切换需要做两个事情

      1. 保存运行线程的执行状态
      2. 让处于等待中的线程执行

      这两个过程需要CPU执行内核相关指令实现状态保存,如果较多的上下文切换会占据大量CPU资源,从而使得cpu无法去执行用户进程中的指令,导致响应速度下降。

      在Java中,文件IO、网络IO、锁等待、线程阻塞等操作都会造成线程阻塞从而触发上下文切换

    2. CPU资源过度消耗,也就是在程序中创建了大量的线程,或者有线程一直占用CPU资源无法被释放,比如死循环!

    CPU利用率过高之后,导致应用中的线程无法获得CPU的调度,从而影响程序的执行效率!

  3. 既然是这两个问题导致的CPU利用率较高,于是我们可以通过top命令,找到CPU利用率较高的进程,在通过Shift+H找到进程中CPU消耗过高的线程,这里有两种情况。

    1. CPU利用率过高的线程一直是同一个,说明程序中存在线程长期占用CPU没有释放的情况,这种情况直接通过jstack获得线程的Dump日志,定位到线程日志后就可以找到问题的代码。
    2. CPU利用率过高的线程id不断变化,说明线程创建过多,需要挑选几个线程id,通过jstack去线程dump日志中排查。
  4. 最后有可能定位的结果是程序正常,只是在CPU飙高的那一刻,用户访问量较大,导致系统资源不够。

以上就是我对这个问题的理解!

lock和synchronized区别

回答

下面我从3个方面来回答

  1. 从功能角度来看,Lock和Synchronized都是Java中用来解决线程安全问题的工具。

  2. 从特性来看,

    1. Synchronized是Java中的同步关键字,Lock是J.U.C包中提供的接口,这个接口有很多实现类,其中就包括ReentrantLock重入锁

    2. Synchronized可以通过两种方式来控制锁的粒度,(贴图)

      carbon2022030701

      一种是把synchronized关键字修饰在方法层面,

      另一种是修饰在代码块上,并且我们可以通过Synchronized加锁对象的声明周期来控制锁的作用范围,比如锁对象是静态对象或者类对象,那么这个锁就是全局锁。

      如果锁对象是普通实例对象,那这个锁的范围取决于这个实例的声明周期。

      Lock锁的粒度是通过它里面提供的lock()unlock()方法决定的(贴图),包裹在这两个方法之间的代码能够保证线程安全性。而锁的作用域取决于Lock实例的生命周期。

      carbon2022030702

    3. Lock比Synchronized的灵活性更高,Lock可以自主决定什么时候加锁,什么时候释放锁,只需要调用lock()unlock()这两个方法就行,同时Lock还提供了非阻塞的竞争锁方法tryLock()方法,这个方法通过返回true/false来告诉当前线程是否已经有其他线程正在使用锁。

      Synchronized由于是关键字,所以它无法实现非阻塞竞争锁的方法,另外,Synchronized锁的释放是被动的,就是当Synchronized同步代码块执行完以后或者代码出现异常时才会释放。

    4. Lock提供了公平锁和非公平锁的机制,公平锁是指线程竞争锁资源时,如果已经有其他线程正在排队等待锁释放,那么当前竞争锁资源的线程无法插队。而非公平锁,就是不管是否有线程在排队等待锁,它都会尝试去竞争一次锁。 Synchronized只提供了一种非公平锁的实现。

  3. 从性能方面来看,Synchronized和Lock在性能方面相差不大,在实现上会有一些区别,Synchronized引入了偏向锁、轻量级锁、重量级锁以及锁升级的方式来优化加锁的性能,而Lock中则用到了自旋锁的方式来实现性能优化。

以上就是我对于这个问题的理解。

回答

好的,我会从两个方面来回答。

  1. 在线程池内部,当我们把一个任务丢给线程池去执行,线程池会调度工作线程来执行这个任务的run方法,run方法正常结束,也就意味着任务完成了。

    所以线程池中的工作线程是通过同步调用任务的run()方法并且等待run方法返回后,再去统计任务的完成数量。

  2. 如果想在线程池外部去获得线程池内部任务的执行状态,有几种方法可以实现。

    1. 线程池提供了一个isTerminated()方法,可以判断线程池的运行状态,我们可以循环判断isTerminated()方法的返回结果来了解线程池的运行状态,一旦线程池的运行状态是Terminated,意味着线程池中的所有任务都已经执行完了。想要通过这个方法获取状态的前提是,程序中主动调用了线程池的shutdown()方法。在实际业务中,一般不会主动去关闭线程池,因此这个方法在实用性和灵活性方面都不是很好。

    2. 在线程池中,有一个submit()方法,它提供了一个Future的返回值,我们通过Future.get()方法来获得任务的执行结果,当线程池中的任务没执行完之前,future.get()方法会一直阻塞,直到任务执行结束。因此,只要future.get()方法正常返回,也就意味着传入到线程池中的任务已经执行完成了!

    3. 可以引入一个CountDownLatch计数器,它可以通过初始化指定一个计数器进行倒计时,其中有两个方法分别是await()阻塞线程,以及countDown()进行倒计时,一旦倒计时归零,所以被阻塞在await()方法的线程都会被释放。

      基于这样的原理,我们可以定义一个CountDownLatch对象并且计数器为1,接着在线程池代码块后面调用await()方法阻塞主线程,然后,当传入到线程池中的任务执行完成后,调用countDown()方法表示任务执行结束。

      最后,计数器归零0,唤醒阻塞在await()方法的线程。

      carbon-20220307002

  3. 基于这个问题,我简单总结一下,不管是线程池内部还是外部,要想知道线程是否执行结束,我们必须要获取线程执行结束后的状态,而线程本身没有返回值,所以只能通过阻塞-唤醒的方式来实现,future.get和CountDownLatch都是这样一个原理。

以上就是我对于这个问题的回答!

HashMap是怎么解决哈希冲突的?

回答

嗯,这个问题我从三个方面来回答。

  1. 要了解Hash冲突,那首先我们要先了解Hash算法和Hash表。(如图)

    1. Hash算法,就是把任意长度的输入,通过散列算法,变成固定长度的输出,这个输出结果是散列值。

    2. Hash表又叫做“散列表”,它是通过key直接访问在内存存储位置的数据结构,在具体实现上,我们通过hash函数把key映射到表中的某个位置,来获取这个位置的数据,从而加快查找速度。

    image-20220309201241144

    1. 所谓hash冲突,是由于哈希算法被计算的数据是无限的,而计算后的结果范围有限,所以总会存在不同的数据经过计算后得到的值相同,这就是哈希冲突。

    2. 通常解决hash冲突的方法有4种。

      1. 开放定址法,也称为线性探测法,就是从发生冲突的那个位置开始,按照一定的次序从hash表中找到一个空闲的位置,然后把发生冲突的元素存入到这个空闲位置中。ThreadLocal就用到了线性探测法来解决hash冲突的。

        向这样一种情况(如图),在hash表索引1的位置存了一个key=name,当再次添加key=hobby时,hash计算得到的索引也是1,这个就是hash冲突。而开放定址法,就是按顺序向前找到一个空闲的位置来存储冲突的key。

        image-20220310135640704

      2. 链式寻址法,这是一种非常常见的方法,简单理解就是把存在hash冲突的key,以单向链表的方式来存储,比如HashMap就是采用链式寻址法来实现的。

        向这样一种情况(如图),存在冲突的key直接以单向链表的方式进行存储。

        image-20220310145348381

      3. 再hash法,就是当通过某个hash函数计算的key存在冲突时,再用另外一个hash函数对这个key做hash,一直运算直到不再产生冲突。这种方式会增加计算时间,性能影响较大。

      4. 建立公共溢出区, 就是把hash表分为基本表和溢出表两个部分,凡事存在冲突的元素,一律放入到溢出表中。

    3. HashMap在JDK1.8版本中,通过链式寻址法+红黑树的方式来解决hash冲突问题,其中红黑树是为了优化Hash表链表过长导致时间复杂度增加的问题。当链表长度大于8并且hash表的容量大于64的时候,再向链表中添加元素就会触发转化。

    以上就是我对这个问题的理解!

什么叫做阻塞队列的有界和无界

回答

  1. (如图),阻塞队列,是一种特殊的队列,它在普通队列的基础上提供了两个附加功能

    1. 当队列为空的时候,获取队列中元素的消费者线程会被阻塞,同时唤醒生产者线程。
    2. 当队列满了的时候,向队列中添加元素的生产者线程被阻塞,同时唤醒消费者线程。

    image-20220313201420205

  2. 其中,阻塞队列中能够容纳的元素个数,通常情况下是有界的,比如我们实例化一个ArrayBlockingList,可以在构造方法中传入一个整形的数字,表示这个基于数组的阻塞队列中能够容纳的元素个数。这种就是有界队列。

  3. 而无界队列,就是没有设置固定大小的队列,不过它并不是像我们理解的那种元素没有任何限制,而是它的元素存储量很大,像LinkedBlockingQueue,它的默认队列长度是Integer.Max_Value,所以我们感知不到它的长度限制。

  4. 无界队列存在比较大的潜在风险,如果在并发量较大的情况下,线程池中可以几乎无限制的添加任务,容易导致内存溢出的问题!

以上就是我对这个问题的理解!

回答

Dubbo是一个RPC框架,它为我们的应用提供了远程通信能力的封装,同时,Dubbo在RPC通信的基础上,逐步在向一个生态在演进,它涵盖了服务注册、动态路由、容错、服务降级、负载均衡等能力,基本上在微服务架构下面临的问题,Dubbo都可以解决。

而对于Dubbo服务请求失败的场景,默认提供了重试的容错机制,也就是说,如果基于Dubbo进行服务间通信出现异常,服务消费者会对服务提供者集群中其他的节点发起重试,确保这次请求成功,默认的额外重试次数是2次。

除此之外,Dubbo还提供了更多的容错策略,我们可以根据不同的业务场景来进行选择。

  1. 快速失败策略,服务消费者只发起一次请求,如果请求失败,就直接把错误抛出去。这种比较适合在非幂等性场景中使用
  2. 失败安全策略,如果出现服务通信异常,直接把这个异常吞掉不做任何处理
  3. 失败自动恢复策略,后台记录失败请求,然后通过定时任务来对这个失败的请求进行重发。
  4. 并行调用多个服务策略,就是把这个消息广播给服务提供者集群,只要有任何一个节点返回,就表示请求执行成功。
  5. 广播调用策略,逐个调用服务提供者集群,只要集群中任何一个节点出现异常,就表示本次请求失败

要注意的是,默认基于重试策略的容错机制中,需要注意幂等性的处理,否则在事务型的操作中,容易出现多次数据变更的问题。

以上就是我对这个问题的理解!

回答

这个问题我从这三个方面来回答:

  1. ConcurrentHashMap的整体架构
  2. ConcurrentHashMap的基本功能
  3. ConcurrentHashMap在性能方面的优化
  • ConcurrentHashMap的整体架构

    (如图所示),这个是ConcurrentHashMap在JDK1.8中的存储结构,它是由数组、单向链表、红黑树组成。

    当我们初始化一个ConcurrentHashMap实例时,默认会初始化一个长度为16的数组。由于ConcurrentHashMap它的核心仍然是hash表,所以必然会存在hash冲突问题。

    ConcurrentHashMap采用链式寻址法来解决hash冲突。

    当hash冲突比较多的时候,会造成链表长度较长,这种情况会使得ConcurrentHashMap中数据元素的查询复杂度变成O(~n~)。因此在JDK1.8中,引入了红黑树的机制。

    当数组长度大于64并且链表长度大于等于8的时候,单项链表就会转换为红黑树。

    另外,随着ConcurrentHashMap的动态扩容,一旦红黑树大小小于6,红黑树会退化成单向链表。

    image-20220313224920782

  • ConcurrentHashMap的基本功能

    ConcurrentHashMap本质上是一个HashMap,因此功能和HashMap一样,但是ConcurrentHashMap在HashMap的基础上,提供了并发安全的实现。

    并发安全的主要实现是通过对指定的Node节点加锁,来保证数据更新的安全性(如图所示)。

    image-20220313230533685

  • ConcurrentHashMap在性能方面做的优化

    如果在并发性能和数据安全性之间做好平衡,在很多地方都有类似的设计,比如cpu的三级缓存、mysql的buffer_pool、Synchronized的锁升级等等。

    ConcurrentHashMap也做了类似的优化,主要体现在以下几个方面:

    • 在JDK1.8中,ConcurrentHashMap锁的粒度是数组中的某一个节点,而在JDK1.7,锁定的是Segment,锁的范围要更大,因此性能上会更低。

    • 引入红黑树,降低了数据查询的时间复杂度,红黑树的时间复杂度是O(~logn~)。

    • (如图所示),当数组长度不够时,ConcurrentHashMap需要对数组进行扩容,在扩容的实现上,ConcurrentHashMap引入了多线程并发扩容的机制,简单来说就是多个线程对原始数组进行分片后,每个线程负责一个分片的数据迁移,从而提升了扩容过程中数据迁移的效率。

      image-20220313231950178

    • ConcurrentHashMap中有一个size()方法来获取总的元素个数,而在多线程并发场景中,在保证原子性的前提下来实现元素个数的累加,性能是非常低的。ConcurrentHashMap在这个方面的优化主要体现在两个点:

      • 当线程竞争不激烈时,直接采用CAS来实现元素个数的原子递增。
      • 如果线程竞争激烈,使用一个数组来维护元素个数,如果要增加总的元素个数,则直接从数组中随机选择一个,再通过CAS实现原子递增。它的核心思想是引入了数组来实现对并发更新的负载。

      image-20220313232019737

以上就是我对这个问题的理解!

b树和b+树的理解

回答

为了更清晰的解答这个问题,我打算从三个方面来回答:

  • 了解二叉树、AVL树、B树的概念
  • B树和B+树的应用场景
  1. B树是一种多路平衡查找树,为了更形象的理解,(我们来看这张图)。

    二叉树,每个节点支持两个分支的树结构,相比于单向链表,多了一个分支。

    二叉查找树,在二叉树的基础上增加了一个规则,左子树的所有节点的值都小于它的根节点,右子树的所有子节点都大于它的根节点。

    image-20220314115513853

    (如图),二叉查找树会出现斜树问题,导致时间复杂度增加,因此又引入了一种平衡二叉树,它具有二叉查找树的所有特点,同时增加了一个规则:”它的左右两个子树的高度差的绝对值不超过1“。平衡二叉树会采用左旋、右旋的方式来实现平衡。

    image-20220314115535464

    (如图),而B树是一种多路平衡查找树,它满足平衡二叉树的规则,但是它可以有多个子树,子树的数量取决于关键字的数量,比如这个图中根节点有两个关键字3和5,那么它能够拥有的子路数量=关键字数+1。

    因此从这个特征来看,在存储同样数据量的情况下,平衡二叉树的高度要大于B树。

    image-20220314115948794

    B+树,其实是在B树的基础上做的增强,最大的区别有两个:

    1. B树的数据存储在每个节点上,而B+树中的数据是存储在叶子节点,并且通过链表的方式把叶子节点中的数据进行连接。
    2. B+树的子路数量等于关键字数

    (如图所示)这个是B树的存储结构,从B树上可以看到每个节点会存储数据。

    20220314003

(如图所示)这个是B+树,B+树的所有数据是存储在叶子节点,并且叶子节点的数据是用双向链表关联的。

  1. B树和B+树,一般都是应用在文件系统和数据库系统中,用来减少磁盘IO带来的性能损耗。

以Mysql中的InnoDB为例,当我们通过select语句去查询一条数据时,InnoDB需要从磁盘上去读取数据,这个过程会涉及到磁盘IO以及磁盘的随机IO(如图所示)

我们知道磁盘IO的性能是特别低的,特别是随机磁盘IO。

因为,磁盘IO的工作原理是,首先系统会把数据逻辑地址传给磁盘,磁盘控制电路按照寻址逻辑把逻辑地址翻译成物理地址,也就是确定要读取的数据在哪个磁道,哪个扇区。

为了读取这个扇区的数据,需要把磁头放在这个扇区的上面,为了实现这一个点,磁盘会不断旋转,把目标扇区旋转到磁头下面,使得磁头找到对应的磁道,这里涉及到寻道事件以及旋转时间。

image-20220315134210543

很明显,磁盘IO这个过程的性能开销是非常大的,特别是查询的数据量比较多的情况下。

所以在InnoDB中,干脆对存储在磁盘块上的数据建立一个索引,然后把索引数据以及索引列对应的磁盘地址,以B+树的方式来存储。

如图所示,当我们需要查询目标数据的时候,根据索引从B+树中查找目标数据即可,由于B+树分路较多,所以只需要较少次数的磁盘IO就能查找到。

image-20220315144805358

  1. 为什么用B树或者B+树来做索引结构?原因是AVL树的高度要比B树的高度要高,而高度就意味着磁盘IO的数量。所以为了减少磁盘IO的次数,文件系统或者数据库才会采用B树或者B+树。

以上就是我对B树和B+树的理解!

能谈一下CAS机制吗?

回答

CAS是Java中Unsafe类里面的方法,它的全称是CompareAndSwap,比较并交换的意思。它的主要功能是能够保证在多线程环境下,对于共享变量的修改的原子性。

我来举个例子,比如说有这样一个场景(如图),有一个成员变量state,默认值是0,

定义了一个方法doSomething(),这个方法的逻辑是,判断state是否为0 ,如果为0,就修改成1。

这个逻辑看起来没有任何问题,但是在多线程环境下,会存在原子性的问题,因为这里是一个典型的,Read - Write的操作。

一般情况下,我们会在doSomething()这个方法上加同步锁来解决原子性问题。

carbon20220318001

但是,加同步锁,会带来性能上的损耗,所以,对于这类场景,我们就可以使用CAS机制来进行优化

这个是优化之后的代码(如图)

在doSomething()方法中,我们调用了unsafe类中的compareAndSwapInt()方法来达到同样的目的,这个方法有四个参数,

分别是:当前对象实例、成员变量state在内存地址中的偏移量、预期值0、期望更改之后的值1。

CAS机制会比较state内存地址偏移量对应的值和传入的预期值0是否相等,如果相等,就直接修改内存地址中state的值为1.

否则,返回false,表示修改失败,而这个过程是原子的,不会存在线程安全问题。

carbon20220318002

CompareAndSwap是一个native方法,实际上它最终还是会面临同样的问题,就是先从内存地址中读取state的值,然后去比较,最后再修改。

这个过程不管是在什么层面上实现,都会存在原子性问题。

所以呢,CompareAndSwap的底层实现中,在多核CPU环境下,会增加一个Lock指令对缓存或者总线加锁,从而保证比较并替换这两个指令的原子性。

CAS主要用在并发场景中,比较典型的使用场景有两个。

  1. 第一个是J.U.C里面Atomic的原子实现,比如AtomicInteger,AtomicLong。
  2. 第二个是实现多线程对共享资源竞争的互斥性质,比如在AQS、ConcurrentHashMap、ConcurrentLinkedQueue等都有用到。

以上就是我对这个问题的理解。

请说一下网络四元组

回答

四元组,简单理解就是在TCP协议中,去确定一个客户端连接的组成要素,它包括源IP地址、目标IP地址、源端口号、目标端口号。

正常情况下,我们对于网络通信的认识可能是这样(如图)。

服务端通过ServerSocket建立一个对指定端口号的监听,比如8080。 客户端通过目标ip和端口就可以和服务端建立一个连接,然后进行数据传输。

image-20220318214531492

但是我们知道的是,一个Server端可以接收多个客户端的连接,比如像这种情况(如图)。

那,当多个客户端连接到服务端的时候,服务端需要去识别每一个连接。

image-20220318222048758

并且(如图),TCP是全双工协议,也就是说数据允许在连接的两个方向上同时传输,因此这里的客户端,如果是反向通信,它又变成了服务端。

image-20220318222926416

所以基于这两个原因,就引入了四元组的设计,也就是说,当一个客户端和服务端建立一个TCP连接的时候,通过源IP地址、目标IP地址、源端口号、目标端口号来确定一个唯一的TCP连接。因为服务器的IP和端口是不变的,只要客户端的IP和端口彼此不同就OK了。

比如像这种情况(如图),同一个客户端主机上有三个连接连到Server端,那么这个时候源IP相同,源端口号不同。此时建立的四元组就是(10.23.15.3,59461 , 192.168.8.135,8080)

其中,源端口号是每次建立连接的时候系统自动分配的。

image-20220318230045901

以上就是我对于四元组的理解。

什么是服务网格?

回答

服务网格,也就是Service Mesh,它是专门用来处理服务通讯的基础设施层。它的主要功能是处理服务之间的通信,并且负责实现请求的可靠性传递。

Service Mesh,我们通常把他称为第三代微服务架构,既然是第三代,那么意味着他是在原来的微服务架构下做的升级。

为了更好的说明Service Mesh,那我就不得不说一下微服务架构部分的东西。

首先,当我们把一个电商系统以微服务化架构进行拆分后,会的到这样的一个架构(如图),其中包括Webserver、payment、inventory等等。

image-20220319153141256

(如图)这些微服务应用,会被部署到Docker容器、或者Kubernetes集群。由于每个服务的业务逻辑是独立的,比如payment会实现支付的业务逻辑、order实现订单的处理、Webserver实现客户端请求的响应等。

image-20220319155839424

(如图)所以,服务之间必须要相互通信,才能实现功能的完整性。比如用户把一个商品加入购物车,请求会进入到Webserver,然后转发到shopping cart进行处理,并存到数据库。

image-20220319155900416

而在这个过程中,每个服务之间必须要知道对方的通信地址,并且当有新的节点加入进来的时候,还需要对这些通信地址进行动态维护。所以,在第一代微服务架构中,每个微服务除了要实现业务逻辑以外,还需要解决上下游寻址、通讯、以及容错等问题。

(如图)于是,在第二代微服务架构下,引入了服务注册中心来实现服务之间的寻址,并且服务之间的容错机制、负载均衡也逐步形成了独立的服务框架,比如主流的Spring Cloud、或者Spring Cloud Alibaba。

image-20220319162844396

在第二代微服务架构中,负责业务开发的小伙伴不仅仅需要关注业务逻辑,还需要花大量精力去处理微服务中的一些基础性配置工作,虽然Spring Cloud已经尽可能去完成了这些事情,但对于开发人员来说,学习Spring Cloud,以及针对Spring Cloud的配置和维护,仍然存在较大的挑战。另外呢,也增加了整个微服务的复杂性。

实际上,在我看来,“微服务中所有的这些服务注册、容错、重试、安全等工作,都是为了保证服务之间通信的可靠性”。

于是,就有了第三代微服务架构,Service Mesh。

(如图)原本模块化到微服务框架里的微服务基础能力,被进一步的从一个SDK中演进成了一个独立的代理进程-SideCar

SideCar的主要职责就是负责各个微服务之间的通信,承载了原本第二代微服务架构中的服务发现、调用容错、服务治理等功能。使得微服务基础能力和业务逻辑迭代彻底解耦。

image-20220319165030924

之所以我们称Service Mesh为服务网格,是因为在大规模微服务架构中,每个服务的通信都是由SideCar来代理的,各个服务之间的通信拓扑图,看起来就像一个网格形状(如图)。

image-20220319170808603

Istio是目前主流的Service Mesh开源框架。

以上就是我对服务网格的理解。

Redis和Mysql如何保证数据一致性

回答

一般情况下,Redis用来实现应用和数据库之间读操作的缓存层,主要目的是减少数据库IO,还可以提升数据的IO性能。

这是它的整体架构。

当应用程序需要去读取某个数据的时候,首先会先尝试去Redis里面加载,如果命中就直接返回。如果没有命中,就从数据库查询,查询到数据后再把这个数据缓存到Redis里面。

image-20220321194004261

(如图)在这样一个架构中,会出现一个问题,就是一份数据,同时保存在数据库和Redis里面,当数据发生变化的时候,需要同时更新Redis和Mysql,由于更新是有先后顺序的,并且它不像Mysql中的多表事务操作,可以满足ACID特性。所以就会出现数据一致性问题。

image-20220321202345646

在这种情况下,能够选择的方法只有几种。

  1. 先更新数据库,再更新缓存
  2. 先删除缓存,再更新数据库

如果先更新数据库,再更新缓存,如果缓存更新失败,就会导致数据库和Redis中的数据不一致。

image-20220321210016652

如果是先删除缓存,再更新数据库,理想情况是应用下次访问Redis的时候,发现Redis里面的数据是空的,就从数据库加载保存到Redis里面,那么数据是一致的。但是在极端情况下,由于删除Redis和更新数据库这两个操作并不是原子的,所以这个过程如果有其他线程来访问,还是会存在数据不一致问题。

image-20220321211003351

所以,如果需要在极端情况下仍然保证Redis和Mysql的数据一致性,就只能采用最终一致性方案。

(如图)比如基于RocketMQ的可靠性消息通信,来实现最终一致性。

image-20220321212602212

(如图)还可以直接通过Canal组件,监控Mysql中binlog的日志,把更新后的数据同步到Redis里面。

image-20220321213239428

因为这里是基于最终一致性来实现的,如果业务场景不能接受数据的短期不一致性,那就不能使用这个方案来做。

以上就是我对这个问题的理解。

Spring Boot中自动装配机制的原理

回答

自动装配,简单来说就是自动把第三方组件的Bean装载到Spring IOC器里面,不需要开发人员再去写Bean的装配配置。

在Spring Boot应用里面,只需要在启动类加上@SpringBootApplication注解就可以实现自动装配。

@SpringBootApplication是一个复合注解,真正实现自动装配的注解是@EnableAutoConfiguration。

(如图)自动装配的实现主要依靠三个核心关键技术。

  1. 引入Starter启动依赖组件的时候,这个组件里面必须要包含@Configuration配置类,在这个配置类里面通过@Bean注解声明需要装配到IOC容器的Bean对象。
  2. 这个配置类是放在第三方的jar包里面,然后通过SpringBoot中的约定优于配置思想,把这个配置类的全路径放在classpath:/META-INF/spring.factories文件中。这样SpringBoot就可以知道第三方jar包里面的配置类的位置,这个步骤主要是用到了Spring里面的SpringFactoriesLoader来完成的。
  3. SpringBoot拿到所第三方jar包里面声明的配置类以后,再通过Spring提供的ImportSelector接口,实现对这些配置类的动态加载。

在我看来,SpringBoot是约定优于配置这一理念下的产物,所以在很多的地方,都会看到这类的思想。它的出现,让开发人员更加聚焦在了业务代码的编写上,而不需要去关心和业务无关的配置。

其实,自动装配的思想,在SpringFramework3.x版本里面的@Enable注解,就有了实现的雏形。@Enable注解是模块驱动的意思,我们只需要增加某个@Enable注解,就自动打开某个功能,而不需要针对这个功能去做Bean的配置,@Enable底层也是帮我们去自动完成这个模块相关Bean的注入。

以上,就是我对Spring Boot自动装配机制的理解。

image-20220322101025232

死锁的发生原因和怎么避免

回答

(如图),死锁,简单来说就是两个或者两个以上的线程在执行的过程中,争夺同一个共享资源造成的相互等待的现象。

如果没有外部干预,线程会一直阻塞无法往下执行,这些一直处于相互等待资源的线程就称为死锁线程。

image-20210612222402287

导致死锁的条件有四个,也就是这四个条件同时满足就会产生死锁。

  • 互斥条件,共享资源 X 和 Y 只能被一个线程占用;
  • 请求和保持条件,线程 T1 已经取得共享资源 X,在等待共享资源 Y 的时候,不释放共享资源 X;
  • 不可抢占条件,其他线程不能强行抢占线程 T1 占有的资源;
  • 循环等待条件,线程 T1 等待线程 T2 占有的资源,线程 T2 等待线程 T1 占有的资源,就是循环等待。

导致死锁之后,只能通过人工干预来解决,比如重启服务,或者杀掉某个线程。

所以,只能在写代码的时候,去规避可能出现的死锁问题。

按照死锁发生的四个条件,只需要破坏其中的任何一个,就可以解决,但是,互斥条件是没办法破坏的,因为这是互斥锁的基本约束,其他三方条件都有办法来破坏:

  • 对于“请求和保持”这个条件,我们可以一次性申请所有的资源,这样就不存在等待了。
  • 对于“不可抢占”这个条件,占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源,这样不可抢占这个条件就破坏掉了。
  • 对于“循环等待”这个条件,可以靠按序申请资源来预防。所谓按序申请,是指资源是有线性顺序的,申请的时候可以先申请资源序号小的,再申请资源序号大的,这样线性化后自然就不存在循环了。

以上就是我对这个问题的理解。


# 面试