爆肝一个月,24年春招java面试题总计50w字(附答案)

发布时间:2024年01月17日

🍅 作者简介:Tom,咕泡科技的联合创始人,兼任CTO(首席技术官)。著有畅销书《设计模式就该这样学》、《Netty4核心原理与手写RPC框架实战》、《Spring5核心原理与30个类手写实战》,也是电子工业出版社“Java架构师成长丛书”长期签约作者。

爆肝一个月,总结的2024春招面试题,总共50w字本文会慢慢更新完,完整版可以先去我公众号领取

文末点击我的名片就可以就可以跳转到我的公众号或者微信

前言?

最近有很多粉丝问我,有什么方法能够快速提升自己,通过阿里、腾讯、字节跳动、京东等互联网大厂的面试,我觉得短时间提升自己最快的手段就是背面试题,最近总结了Java常用的面试题,分享给大家,希望大家都能上岸,加油,奥里给!!!

目录

一.基础面试题

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

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

什么是受检异常和非受检异常吗?

JDK动态代理为什么只能代理有接口的类?

对象的创建过程

new String("abc")到底创建了几个对象?

请简单说一下你对受检异常和非受检异常的理解

String、StringBuffer、StringBuilder区别

Integer能不能用==来判断

ArrayList的自动扩容机制

什么是深拷贝和浅拷贝?

HashMap中的hash方法为什么要右移16位异或?

HashMap啥时候扩容,为什么扩容?

强引用、软引用、弱引用、虚引用有什么区别?

Java有几种文件拷贝方式,哪一种效率最高?

聊聊你知道的设计模式

finally块一定会执行吗?

在Java中实现单例模式有哪些方法

Java SPI是什么?有什么用?

Integer和int的区别?Java为什么要设计封装类?

Integer a1=100 Integer a2=100,a1==a2?的运行结果?

HashMap与HashTable区别

Java反射的优缺点?

为什么重写 equals() 就一定要重写 hashCode() 方法?

介绍下策略模式和观察者模式?

谈谈什么是零拷贝?

SortedSet和List异同点?

哪些情况下的单例对象可能会破坏?

什么是Java SPI,它有什么作用?

二.Mysql数据库面试题

innoDB如何解决幻读

b树和b+树的理解

你是否在面试中也被过MySQL优化相关的问题呢?

MVCC的理解

索引的底层实现,为什么选择B+Tree而不是红黑树?

请你说一下Mysql中的性能调优方法

为什么一线互联网公司严禁使用存储过程?

Mysql中的RR隔离级别,到底有没有解决幻读问题?

为什么索引要用B+树来实现呢,而不是B树?

Mysql是如何解决幻读问题的?

什么是聚集索引和非聚集索引

请你简单说一下Mysql的事务隔离级别

binlog和redolog有什么区别?

说一下你日常工作中是怎么优化SQL的

Mysql 主从集群同步延迟问题怎么解决

MySQL 数据库 cpu 飙升的话,要怎么处理呢?

Mysql 的binlog 有几种格式?分别有什么区别

索引有哪些缺点以及具体有哪些索引类型

b树和b+树的理解

什么是聚集索引和非聚集索引

Mysql的事务隔离级别

数据库连接池有什么用?它有哪些关键参数?

Mysql索引的优点和缺点?

Mysql事务的实现原理

说一下你对行锁、临键锁、间隙锁的理解

索引什么时候失效?

Mysql中MyISAM和InnoDB引擎的区别

存储MD5的值应该用VARCHAR还是CHAR

Mysql 索引在什么情况下会失效

MVCC过程中会加锁吗?

MySQL update 是锁行还是锁表?

为什么 SQL 语句不要过多的 join?

什么情况下不建索引?

为什么SQL语句命中索引比不命中索引要快?

执行SQL响应比较慢,你有哪些排查思路?

三.java并发编程基础

谈谈你对AQS的理解

lock和synchronized区别

线程池如何知道一个线程的任务已经执行完成

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

ConcurrentHashMap 底层具体实现知道吗?实现原理是什么?

CAS机制吗?

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

讲一下wait和notify这个为什么要在synchronized代码块中?

你是怎么理解线程安全问题的?

什么是守护线程,它有什么特点

AbstractQueuedSynchronized为什么采用双向链表

volatile关键字有什么用?它的实现原理是什么?

ThreadLocal是什么?它的实现原理呢?

?ArrayBlockingQueue 原理

怎么理解线程安全?

请简述一下伪共享的概念以及如何避免

什么是可重入,什么是可重入锁? 它用来解决什么问题?

ReentrantLock的实现原理?

简述一下你对线程池的理解?

如何中断一个正在运行的线程?

为什么引入偏向锁、轻量级锁,介绍下升级流程

ReentrantLock 是如何实现锁公平和非公平性的 ?

说一下你对CompletableFuture的理解

线程状态,BLOCKED和WAITING有什么区别

Thread和Runnable的区别


一.基础面试题

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

像这样一种情况

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

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

像这样一种情况

存在冲突的key直接以单向链表的方式进行存储。

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

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

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

什么是受检异常和非受检异常吗?

一、首先是异常的本质

受检异常和非受检异常,都是继承自Throwable这个类中,分别是Error和Exception,Error是程序报错,系统收到无法处理的错误消息,它和程序本身无关。

Excetpion是指程序运行时抛出需要处理的异常信息如果不主动捕获,则会被jvm处理。

二、然后是对受检异常和非受检异常的定义

前面说过受检异常和非受检异常均派生自Exception这个类。

1. 受检异常的定义是程序在编译阶段必须要主动捕获的异常,遇到该异常有两种处理方法

通过try/catch捕获该异常或者通过throw把异常抛出去

2. 非受检异常的定义是程序不需要主动捕获该异常,一般发生在程序运行期间,比如NullPointException

三、最后我还可以说下他们优点和缺点

受检异常优点有两个:

第一,它可以响应一个明确的错误机制,这些错误在写代码的时候可以随时捕获并且能很好的提高代码的健壮性。

第二,在一些连接操作中,它能很好的提醒我们关注异常信息,并做好预防工作。

不过受检异常的缺点是:抛出受检异常的时候需要上声明,而这个做法会直接破坏方法签名导致版本不兼容。这个恶心特性导致我会经常使用RuntimeException包装。

非受检异常的好处是可以去掉一些不需要的异常处理代码,而不好之处是开发人员可能忽略某些应该处理的异常,导致带来一些隐藏很深的Bug,比如流忘记关闭?连接忘记释放等。

JDK动态代理为什么只能代理有接口的类?

首先,在Java里面,动态代理是通过Proxy.newProxyInstance()方法来实现的,它需要传入被动态代理的接口类。

之所以要传入接口,不能传入类,还是取决于JDK动态代理的底层实现(如图)。

JDK动态代理会在程序运行期间动态生成一个代理类$Proxy0,这个动态生成的代理类会继承java.lang.reflect.Proxy类,同时还会实现被代理类的接口IHelloService。

在Java中,是不支持多重继承的。而每个动态代理类都会继承Proxy类(这也是JDK动态代理的实现规范),所以就导致JDK里面的动态代理只能代理接口,而不能代理实现类。

(注意,下面这张图片展示的时候,上面的图片仍然保存在一个画面里面)

我分析过动态代理的源码,发现Proxy这个类只是保存了动态代理的处理器InvocationHandler,如果不抽出来,直接设置到$Proxy0动态代理类里面,也是可以的。

如果这么做,就可以针对实现类来做动态代理了。作者为什么这么设计,我认为有几个方面的原因。

  1. 动态代理本身的使用场景或者需求,只是对原始实现的一个拦截,然后去做一些功能的增强或者扩展。而实际的开发模式也都是基于面向接口来开发,所以基于接口来实现动态代理,从需求和场景都是吻合的。当然确实可能存在有些类没有实现接口的,那这个时候,JDK动态代理确实无法满足。
  2. 在Java里面,类的继承关系的设计,更多的是考虑到共性能力的抽象,从而提高代码的重用性和扩展性,而动态代理也是在做这样一个事情,它封装了动态代理类生成的抽象逻辑、判断一个类是否是动态代理类、InvocationHandler的持有等等,那么把这些抽象的公共逻辑放在Proxy这个父类里面,很显然是一个比较正常的设计思路。

总的来说,我认为这个设计上并没有什么特别值得讨论的地方,因为我认为技术方案的设计是解决特定场景问题的。

如果一定要针对普通类来做动态代理,可以选择cglib这个组件,它会动态生成一个被代理类的子类,子类重写了父类中所有非final修饰的方法,在子类中拦截父类的所有方法调用从而实现动态代理。

对象的创建过程

(如图)在实例化一个对象的时候,JVM首先会去检查目标对象是否已经被加载并初始化了。

如果没有,JVM需要立刻去加载目标类,然后调用目标类的构造器完成初始化。 目标类的加载是通过类加载器来实现的,主要就是把一个类加载到内存里面。

然后初始化的过程,主要是对目标类里面的静态变量、成员变量、静态代码块进行初始化。

当目标类被初始化以后,就可以从常量池里面找到对应的类元信息,并且目标对象的大小在类加载之后就已经确定了,所以这个时候就需要为新创建的对象,根据目标对象的大小在堆内存里面分配内存空间。

内存分配的方式一般有两种,一种指针碰撞,另一种是空闲列表,JVM会根据Java堆内存是否规整来决定内存分配方式。

接下来,JVM会把目标对象里面的普通成员变量初始化为零值,比如int类型初始化为0,对象类型初始化为null,(类变量在类加载的准备阶段就已经初始化过了)。

这一步操作主要是保证对象里面的实例字段,不用初始化就可以直接使用,也就是程序能够获得这些字段对应数据类型的零值。

然后,JVM还需要对目标对象的对象头做一些设置,比如对象所属的类元信息、对象的GC分代年龄、hashcode、锁标记等等。

完成这些步骤以后,对于JVM来说,新对象的创建工作已经完成。但是基于Java语言来说,对象创建才算是开始。

接下来要做的,就是执行目标对象内部生成的init方法,初始化成员变量的值、执行构造块、最后执行目标对象的构造方法,完成对象的创建。

其中,init方法是Java文件编译之后在字节码文件中生成的,它是一个实例构造器,这个构造器会把语句块、变量初始化、调用父类构造器等操作组织在一起。所以调用init方法能够完成一系列的初始化动作。

new String("abc")到底创建了几个对象?

首先,这个代码里面有一个new关键字,这个关键字是在程序运行时,根据已经加载的系统类String,在堆内存里面实例化的一个字符串对象。

然后,在这个String的构造方法里面,传递了一个“abc”字符串,因为String里面的字符串成员变量是final修饰的,所以它是一个字符串常量。

接下来,JVM会拿字面量“abc” 去字符串常量池里面试图去获取它对应的String对象引用,如果拿不到,就会在堆内存里面创建一个”abc”的String对象

并且把引用保存到字符串常量池里面。

后续如果再有字面量“abc”的定义,因为字符串常量池里面已经存在了字面量“abc”的引用,所以只需要从常量池获取对应的引用就可以了,不需要再创建。

所以,对于这个问题,我认为的答案是

  1. 如果abc这个字符串常量不存在,则创建两个对象,分别是abc这个字符串常量,以及new String这个实例对象。
  2. 如果abc这字符串常量存在,则只会创建一个对象

请简单说一下你对受检异常和非受检异常的理解

(如图)所谓的受检异常,表示在编译的时候强制检查的异常,这种异常需要显示的通过try/catch来捕捉,或者通过throws抛出去,否则从程序无法通过编译。

而非受检异常,表示在编译器可以不需要强制检查的异常,这种异常不需要显示去捕捉。

(如图)在Java里面,所有的异常都是继承自java.lang.Throwable类,Throwable有两个直接子类,Error和Exception。

Error用来表示程序底层或者硬件有关的错误,这种错误和程序本身无关,比如常见的OOM异常。这种异常和程序本身无关,所以不需要检查,属于非受检异常。

Exception表示程序中的异常,可能是由于程序不严谨导致的,比如NullPointerException。

Exception下面派生了RuntimeException和其他异常,其中RuntimeException运行时异常,也是属于非受检异常。

所以,除了Error和RuntimeException及派生类以外,其他异常都是属于受检异常,比如IOException、SQLException。

之所以在Java中要设计一些强制检查的异常,我认为主要原因是考虑到程序的正确性、稳定性和可靠性。

比如数据库异常、文件读取异常,这些异常是程序无法提前预料到的,但是一旦出现问题,就会造成资源被占用导致程序出现问题。

所以这些异常我们需要主动捕获,一旦出现问题,我们可以做出相应的处理,比如关闭数据库连接、文件流的释放等。

String、StringBuffer、StringBuilder区别

关于String、StringBuffer、StringBuilder的区别,我想从四个角度来说明。

第一个,可变性,

String内部的value值是final修饰的,所以它是不可变类。所以每次修改String的值,都会产生一个新的对象。

StringBuffer和StringBuilder是可变类,字符串的变更不会产生新的对象。

第二个,线程安全性,

String是不可变类,所以它是线程安全的。

StringBuffer是线程安全的,因为它每个操作方法都加了synchronized同步关键字。

StringBuilder不是线程安全的。

所以在多线程环境下对字符串进行操作,应该使用StringBuffer,否则使用StringBuilder

第三个,性能方面。

String的性能是最的低的,因为不可变意味着在做字符串拼接和修改的时候,需要重新创建新的对象以及分配内存。

其次是StringBuffer要比String性能高,因为它的可变性使得字符串可以直接被修改

最后是StringBuilder,它比StringBuffer的性能高,因为StringBuffer加了同步锁。

第四个,存储方面。

String存储在字符串常量池里面

StringBuffer和StringBuilder存储在堆内存空间。?

最后再补充一下, StringBuilder和StringBuffer都是派生自AbstractStringBuilder这个抽象类。

Integer能不能用==来判断

Integer是一个封装类型。它是对应一个int类型的包装。

在Java里面之所以要提供Integer这种基本类型的封装类,是因为Java是一个面向对象的语言,

而基本类型不具备对象的特征,所以在基本类型上做了一层对象的包装并且提供了相关的属性和访问方法来完善基本类型的操作。

在Integer这个封装类里面,除了基本的int类型的操作之外,还引入了享元模式的设计,

对-128到127之间的数据做了一层缓存(如图),也就是说,如果Integer类型的目标值在-128到127之间,

就直接从缓存里面获取Integer这个对象实例并返回,否则创建一个新的Integer对象。

这么设计的好处是减少频繁创建Integer对象带来的内存消耗从而提升性能。

因此在这样一个前提下,如果定义两个Integer对象,并且这两个Integer的取值范围正好在-128到127之间。

如果直接用==号来判断,返回的结果必然是true,因为这两个Integer指向的内存地址是同一个。

否则,返回的结果是false。

之所以在测试环境上没有把这个问题暴露出来,是因为测试环境上验证的数据量有限,使得取值的范围正好在Integer

的缓存区间,从而通过了测试。

但是在实际的应用里面,数据量远远超过IntegerCache的取值范围,所以就导致了校验失败的问题。

ArrayList的自动扩容机制

ArrayList是一个数组结构的存储容器,默认情况下,数组的长度是10.

当然我们也可以在构建ArrayList对象的时候自己指定初始长度。

随着在程序里面不断的往ArrayList中添加数据,当添加的数据达到10个的时候,

ArrayList就没有多余容量可以存储后续的数据。

这个时候ArrayList会自动触发扩容。

扩容的具体流程很简单,

  1. 首先,创建一个新的数组,这个新数组的长度是原来数组长度的1.5倍。
  2. 然后使用Arrays.copyOf方法把老数组里面的数据拷贝到新的数组里面。

扩容完成后再把当前要添加的元素加入到新的数组里面,从而完成动态扩容的过程。

什么是深拷贝和浅拷贝?

深拷贝和浅拷贝是用来描述对象或者对象数组这种引用数据类型的复制场景的。

浅拷贝,(如图)就是只复制某个对象的指针,而不复制对象本身。

这种复制方式意味着两个引用指针指向被复制对象的同一块内存地址。

深拷贝,(如图)会完全创建一个一模一样的新对象,新对象和老对象不共享内存,

也就意味着对新对象的修改不会影响老对象的值。

在Java里面,无论是深拷贝还是浅拷贝,都需要通过实现Cloneable接口,并实现clone()方法。

然后我们可以在clone()方法里面实现浅拷贝或者深拷贝的逻辑。

实现深拷贝的方法有很多,比如

  1. 通过序列化的方式实现,也就是把一个对象先序列化一遍,然后再反序列化回来,就会得到一个完整的新对象。
  2. 在clone()方法里面重写克隆逻辑,也就是对克隆对象内部的引用变量再进行一次克隆。

HashMap中的hash方法为什么要右移16位异或?

(如图)

之所以要对hashCode无符号右移16位并且异或,核心目的是为了让hash值的散列度更高,

尽可能减少hash表的hash冲突,从而提升数据查找的性能。

(如图)在HashMap的put方法里面,是通过Key的hash值与数组的长度取模计算得到数组的位置。

而在绝大部分的情况下,n的值一般都会小于2^16次方,也就是65536。

所以也就意味着i的值 , 始终是使用hash值的低16位与(n-1)进行取模运算,这个是由与运算符&的特性决定的。

这样就会造成key的散列度不高,导致大量的key集中存储在固定的几个数组位置,很显然会影响到数据查找性能。

因此,为了提升key的hash值的散列度,在hash方法里面,做了位移运算。

(如图)

首先使用key的hashCode无符号右移16位,意味着把hashCode的高位移动到了低位。

然后再用hashCode与右移之后的值进行异或运算,就相当于把高位和低位的特征进行和组合。

从而降低了hash冲突的概率。

HashMap啥时候扩容,为什么扩容?

在任何语言中,我们希望在内存中临时存放一些数据,可以用一些官方封装好的集合(如图),

比如List、HashMap、Set等等。作为数据存储的容器。

容器的大小

当我们创建一个集合对象的时候,实际上就是在内存中一次性申请一块内存空间。

而这个内存空间大小是在创建集合对象的时候指定的。

比如List的默认大小是10、HashMap的默认大小是16。

长度不够怎么办

在实际开发中,我们需要存储的数据量往往大于存储容器的大小。

针对这种情况,通常的做法就是扩容。

当集合的存储容量达到某个阈值的时候,集合就会进行动态扩容,从而更好的满足更多数据的存储。

(如图)List和HashMap,本质上都是一个数组结构,所以基本上只需要新建一个更长的数组

然后把原来数组中的数据拷贝到新数组就行了。

以HashMap为例,它是什么时候触发扩容以及扩容的原理是什么呢?

HashMap是如何扩容的?

当HashMap中元素个数超过临界值时会自动触发扩容,这个临界值有一个计算公式。

threashold=loadFactor*capacity(屏幕显示)。

loadFactor的默认值是0.75,capacity的默认值是16,也就是元素个数达到12的时候触发扩容。

扩容后的大小是原来的2倍。

由于动态扩容机制的存在,所以我们在实际应用中,需要注意在集合初始化的时候明确指定集合的大小。

避免频繁扩容带来性能上的影响。

假设我们要向HashMap中存储1024个元素,如果按照默认值16,随着元素的不断增加,会造成7次扩容。

而这7次扩容需要重新创建Hash表,并且进行数据迁移,对性能影响非常大。

最后,可能有些面试官会继续问,为什么扩容因子是0.75?

为什么扩容因子是0.75?

扩容因子表示Hash表中元素的填充程度,扩容因子的值越大,那么触发扩容的元素个数更多,

虽然空间利用率比较高,但是hash冲突的概率会增加。

扩容因子的值越小,触发扩容的元素个数就越少,也意味着hash冲突的概率减少,

但是对内存空间的浪费就比较多,而且还会增加扩容的频率。

因此,扩容因子的值的设置,本质上就是在 冲突的概率 以及 空间利用率之间的平衡。

0.75这个值的来源,和统计学里面的泊松分布有关。

(如图)我们知道,HashMap里面采用链式寻址法来解决hash冲突问题,为了避免链表过长带来时间复杂度的增加

所以链表长度大于等于7的时候,就会转化为红黑树,提升检索效率。

当扩容因子在0.75的时候,链表长度达到8的可能性几乎为0,也就是比较好的达到了空间成本和时间成本的平衡。

以上就是关于这个问题的完整理解。

在面试的时候,我们可以这么回答。

面试题的标准回答

当HashMap元素个数达到扩容阈值,默认是12的时候,会触发扩容。

默认扩容的大小是原来数组长度的2倍,HashMap的最大容量是Integer.MAX_VALUE,也就是2的31次方-1。

强引用、软引用、弱引用、虚引用有什么区别?

不同的引用类型,主要体现的是对象不同的可达性状态和对垃圾收集的影响。

强引用,就是普通对象的引用,只要还有强引用指向一个对象,就能表示对象还“活着”,

垃圾收集器无法回收这一类对象。

只有在没有其他引用关系,或者超过了引用的作用域,再或者显示的把引用赋值为null的时候,

垃圾回收器才能进行内存回收。

软引用,是一种相对强引用弱化一些的引用,可以让对象豁免一些垃圾收集,

只有当 JVM 认为内存不足时,才会去试图回收软引用指向的对象。

软引用通常用来实现内存敏感的缓存,如果还有空闲内存,就可以暂时保留缓存,

当内存不足时清理掉,这样就保证了使用缓存的同时,不会耗尽内存。

弱引用,相对强引用而言,它允许在存在引用关联的情况下被垃圾回收的对象

在垃圾回收器线程扫描它所管辖的内存区域的过程中,

一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,垃圾回收期都会回收该内存

虚引用,它不会决定对象的生命周期,它提供了一种确保对象被finalize以后,去做某些事情的机制。

当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,

就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。

程序可以通过判断引用队列中是否已经加入了虚引用

来了解被引用的对象是否将要进行垃圾回收,然后我们就可以在引用的对象的内存回收之前采取必要的行动。

Java有几种文件拷贝方式,哪一种效率最高?

第一种,使用java.io包下的库,使用FileInputStream读取,再使用FileOutputStream写出。

第二种,利用java.nio包下的库,使用transferTo或transfFrom方法实现。

第三种,Java 标准类库本身已经提供了 Files.copy 的实现。

对于 Copy 的效率,这个其实与操作系统和配置等情况相关,

在传统的文件IO操作里面,我们都是调用操作系统提供的底层标准IO系统调用函数? read()、write() ,

由于内核指令的调用会使得当前用户线程切换到内核态,然后内核线程负责把相应的文件数据读取到内核的IO缓冲区,

再把数据从内核IO缓冲区拷贝到进程的私有地址空间中去,这样便完成了一次IO操作。

而NIO里面提供的NIO transferTo和transfFrom方法,也就是常说的零拷贝实现。

它能够利用现代操作系统底层机制,避免不必要拷贝和上下文切换,因此在性能上表现比较好。

聊聊你知道的设计模式

大致按照模式的应用目标分类,设计模式可以分为创建型模式、结构型模式和行为型模式。

创建型模式,是对对象创建过程的各种问题和解决方案的总结,包括各种工厂模式、单例模式、构建器模式、原型模式。

结构型模式,是针对软件设计结构的总结,关注于类、对象继承、组合方式的实践经验。

常见的结构型模式,包括桥接模式、适配器模式、装饰者模式、代理模式、组合模式、外观模式、享元模式等。

行为型模式,是从类或对象之间交互、职责划分等角度总结的模式。

比较常见的行为型模式有策略模式、解释器模式、命令模式、观察者模式、迭代器模式、模板方法模式、访问者模式。

finally块一定会执行吗?

finally语句块在两种情况下不会执行:

  1. 程序没有进入到try语句块因为异常导致程序终止,这个问题主要是开发人员在编写代码的时候,异常捕获的范围不够。
  2. 在try或者cache语句块中,执行了System.exit(0)语句,导致JVM直接退出

在Java中实现单例模式有哪些方法

问题解析

单例模式,就是一个类在任何情况下绝对只有一个实例,并且提供一个全局访问点来获取该实例。

要实现单例,至少需要满足两个点:

  1. 私有化构造方法,防止被外部实例化造成多实例问题
  2. 提供一个静态方位作为全局访问点来获取唯一的实例对象

在Java里面,至少有6种方法来实现单例。

(如图)第一种,是最简单的实现,通过延迟加载的方式进行实例化,并且增加了同步锁机制避免多线程环境下

的线程安全问题。

但是这种加锁会造成性能问题,而且同步锁只有在第一次实例化的时候才产生作用,后续不需要。

(如图)于是有了第二种改进方案,通过双重检查锁的方式,减少了锁的范围来提升性能

(如图)第三种,通过饿汉式实现单例。

这种方式在类加载的时候就触发了实例化,从而避免了多线程同步问题。

还有一种与这个方式类似的实现(如图)

通过在静态块里面实例化,而静态块是在类加载的时候触发执行的,所以也只会执行一次。

上面两种方式,都是在类加载的时候初始化,没有达到延迟加载的效果,当然本身影响不大,但是

其实还是可以更进一步优化,就是可以在使用的时候去触发初始化(如图)。

像这种写法,把INSTANCE写在一个静态内部类里面,

由于静态内部类只有调用静态内部类的方法,静态域,或者构造方法的时候才会加载静态内部类。

所以当Singleton被加载的时候不会初始化INSTANCE,从而实现了延迟加载。

另外,我们还可以使用枚举类来实现(如图)。

这种写法既能避免多线程同步问题,又能防止反序列化重新创建新对象,也是一个比较好的方案。

当然,除了这些方案以外,也许还有更多的写法,只需要满足单例模式的特性就行了。

说人话

我认为可以通过3种方式来实现单例,

第一种是通过双重检查锁的方式,它是一种线程安全并且是延迟实例化的方式,

但是因为加锁,所以会有性能上的影响。

第二种是通过静态内部类的方式实现,它也是一种延迟实例化,

由于它是静态内部类,所以只会使用的时候加载一次,不存在线程安全问题。

第三种是通过枚举类的方式实现,它既是线程安全的,又能防止反序列化导致破坏单例问题。

但是,多线程、克隆、反序列化、反射,都有可能会造成单例的破坏。

而我认为,通过枚举的方式实现单例,是能够解决所有可能被破坏的情况。

Java SPI是什么?有什么用?

问题解析

Java SPI,全称是Service Provider Interface。

它是一种基于接口的动态扩展机制,相当于Java里面提供了一套接口。

然后第三方可以实现这个接口来完成功能的扩展和实现。

(如图),举个简单的例子。

在Java的SDK里面,提供了一个数据库驱动的接口java.sql.Driver。

它的作用是提供数据库的访问能力。

不过,在Java里面并没有提供实现,因为不同的数据库厂商,会有不同的语法和实现。

所以只能由第三方数据库厂商来实现,比如Oracle是oracle.jdbc.OracleDriver,mysql是com.mysql.jdbc.Driver.

然后在应用开发的时候,根据集成的驱动实现连接到对应数据库。

Java中SPI机制主要思想是将装配的控制权移到程序之外实现标准和实现的解耦,以及提供动态可插拔的能力,

在模块化的设立中,这种思想非常重要。

实现Java SPI,需要满足几个基本的格式(如图):

  1. 需要先定义一个接口,作为扩展的标准
  2. 在classpath目录下创建META-INF/service文件目录
  3. 在这个目录下,以接口的全限定名命名的配置文件, 文件内容是这个接口的实现类
  4. 在应用程序里面,使用ServiceLoad,就可以根据接口名称找到classpath所有的扩展时间

然后根据上下文场景选择实现类完成功能的调用。

Java SPI有一定的不足之处,比如,不能根据需求去加载扩展实现,每次都会加载扩展接口的所有实现类并进行实例化,

实例化会造成性能开销,并且加载一些不需要用到的实现类,会导致内存资源的浪费,

好了,下面看看高手的回答。

讲人话

Java SPI是Java里面提供的一种接口扩展机制。

它的作用我认为有两个:

  1. 把标准定义和接口实现分离,在模块化开发中很好的实现了解耦
  2. 实现功能的扩展,更好的满足定制化的需求

除了Java的SPI以外,基于SPI思想的扩展实现还有很多,比如Spring里面的SpringFactoriesLoader。

Dubbo里面的ExtensionLoader,并且Dubbo还在SPI基础上做了更进一步优化,

提供了激活扩展点、自适应扩展点。

Integer和int的区别?Java为什么要设计封装类?

问题分析

Integer是基本数据类型int的封装类

在Java里面,有八种基本数据类型,他们都有一一对应的封装类型。

基本类型和封装类型的区别有很多,比如

  1. int类型,我们可以直接定义一个变量名赋值即可,但是Integer需要使用new关键字创建对象
  2. 基本类型和Integer类型混合使用时,Java会自动通过拆箱和装箱实现类型转换
  3. Integer作为一个对象类型,封装了一些方法和属性,我们可以利用这些方法来操作数据。
  4. 作为成员变量,Integer的默认值是null,而int的默认值是0

要是真正列数出来,还可以挖掘更多的差异点。

在Java里面,之所以要对基础类型设计一个对应的封装类型。

是因为Java本身是一门面向对象的语言,对象是Java语言的基础单元,我们时时刻刻都在创建对象,也随时都在使用对象,

很多时候在传递数据时也需要对象类型,比如像ArrayList、HashMap这些集合,只能存储对象类型,

因此从这个点来说,封装类型存在的意义就很大。

其次,封装类型还有很多好处,比如

  1. 安全性较好,可以避免外部操作随意修改成员变量的值,保证了成员变量和数据传递的安全性
  2. 隐藏了实现细节,对使用者更加友好,只需要调用对象提供的方法就可以完成对应的操作

下面来看看高手的回答

讲人话

Integer和int的区别有很多,我简单说3个方面

  1. Integer的初始值是null,int的初始值是0
  2. Integer存储在堆内存,int类型是直接存储在栈空间
  3. Integer是对象类型,它封装了很多的方法和属性,我们在使用的时候更加灵活。

至于为什么要设计封装类型,最主要的原因是Java本身是面向对象的语言,一切操作都是以对象作为基础。

比如像集合里面存储的元素,也只支持存储Object类型,普通类型无法通过集合来存储。

Integer a1=100 Integer a2=100,a1==a2?的运行结果?

问题分析

按照大家对于Java基础的认知,两个独立的对象用==进行比较,是比较两个对象的内存地址。

那得到的结果必然是false。但是在这个场景中,得到的结果是true。

为什么呢?

首先, Integer a1=100, 把一个int数字赋值给一个封装类型,Java会默认进行装箱操作,也就是调用Integer.valueOf()

方法,把数字100包装成封装类型Integer。

其次,在Integer内部设计中,用到了享元模式的设计,享元模式的核心思想是通过复用对象,减少对象的创建数量,

从而减少内存占用和提升性能。

Integer内部维护了一个IntegerCache,它缓存了-128到127这个区间的数值对应的Integer类型。

一旦程序调用valueOf 方法,如果数字是在-128 到 127 之间就直接在cache缓存数组中去取Integer对象。

否则,就会创建一个新的对象。

所以,对于这个面试题来说,两个Integer对象,因为值都是100,并且默认通过装箱机制调用了valueOf方法。

从IntegerCache中拿到了两个完全相同的Integer实例。

因此用等号比较得到的结果必然是true。

讲人话

a1==a2的执行结果是true

原因是Integer内部用到了享元模式的设计,针对-128到127之间的数字做了缓存。

使用Integer a1=100这个方式赋值时,Java默认会通过valueOf对100这个数字进行装箱操作,

从而触发了缓存机制,使得a1和a2指向了同一个Integer地址空间。

HashMap与HashTable区别

问题分析

Hashtable和HashMap都是一个基于hash表实现的K-V结构的集合。

Hashtable是JDK1.0引入的一个线程安全的集合类,因为所有数据访问的方法都加了一个Synchronized同步锁。

Hashtable内部采用数组加链表来实现,链表用来解决hash冲突的问题。

HashMap是JDK1.2引入的一个线程不安全的集合类,

HashMap内部也是采用了数组加链表实现,在JDK1.8版本里面做了优化,引入了红黑树。

当链表长度大于等于8并且数组长度大于64的时候,就会把链表转化为红黑树,提升数据查找性能。

讲人话

  1. 从功能特性的角度来说
    • HashTable是线程安全的,而HashMap不是。
    • HashMap的性能要比HashTable更好,因为,HashTable采用了全局同步锁来保证安全性,对性能影响较大
  2. 从内部实现的角度来说
    • HashTable使用数组加链表、HashMap采用了数组+链表+红黑树
    • HashMap初始容量是16、HashTable初始容量是11
    • HashMap可以使用null作为key,HashMap会把null转化为0进行存储,而Hashtable不允许。

最后,他们两个的key的散列算法不同,HashTable直接是使用key的hashcode对数组长度做取模。

而HashMap对key的hashcode做了二次散列,从而避免key的分布不均匀问题影响到查询性能。

Java反射的优缺点?

问题分析

反射是Java语言里面比较重要的一个特征。

它能够在程序运行的过程中去构造任意一个类对象、并且可以获取任意一个类的成员变量、成员方法、属性,以及调用任意一个对象的方法。

通过反射的能力,可以让Java语言支持动态获取程序信息以及动态调用方法的能力。

在Java里面,专门有一个java.lang.reflect用来实现反射相关的类库,包括Construct、Field、Method等类,

分别用来获取类的构造方法、成员变量、方法信息。

反射的使用场景还挺多的,比如在动态代理的场景中,使用动态生成的代理类来提升代码的复用性。

在Spring框架中,有大量用到反射,比如用反射来实例化Bean对象。

讲人话

Java反射的优点:

  1. 增加程序的灵活性,可以在运行的过程中动态对类进行修改和操作
  2. 提高代码的复用率,比如动态代理,就是用到了反射来实现
  3. 可以在运行时轻松获取任意一个类的方法、属性,并且还能通过反射进行动态调用

Java反射的缺点:

  1. 反射会涉及到动态类型的解析,所以JVM无法对这些代码进行优化,导致性能要比非反射调用更低。
  2. 使用反射以后,代码的可读性会下降
  3. 反射可以绕过一些限制访问的属性或者方法,可能会导致破坏了代码本身的抽象性

为什么重写 equals() 就一定要重写 hashCode() 方法?

问题分析

关于这个问题,首先需要深入了解一下equals这个方法。

这个equals方法是String这个类里面的实现。

从代码中可以看到,当调用equals比较两个对象的时候,会做两个操作

  1. 用==号比较两个对象的内存地址,如果地址相同则返回true
  2. 否则,继续比较字符串的值,如果两个字符串的值完全相等,同样返回true

那equals和hashCode()有什么关系呢?

  1. 首先,Java里面任何一个对象都有一个native的hashCode()方法
  2. 其次,这个方法在散列集合中会用到,比如HashTable、HashMap这些,当添加元素的时候,需要判断元素是否存在,

而如果用equals效率太低,所以一般是直接用对象的hashCode的值进行取模运算。

    • 如果table中没有该hashcode值,它就可以直接存进去,不用再进行任何比较了;
    • 如果存在该hashcode值, 就调用它的equals方法与新元素进行比较,相同的话就不存了,不相同就散列其它的地址,所以这里存在一个冲突解决的问题,这样一来实际调用equals方法的次数就大大降低了.

hashCode的值默认是JVM使用随机数来生成的,两个不同的对象,可能生成的HashCode会相同。

这种情况在Hash表里面就是所谓的哈希冲突,通常会使用链表或者线性探测等方式来解决冲突问题。

但是如果两个完全相同的对象,也就是内存地址指向同一个,那么他们的hashCode一定是相同的。

了解了equals和hashCode的关系以后,再来分析这个面试题。

在理论情况下,如果x.equals(y)==true,如果没有重写equals方法,那么这两个对象的内存地址是同一个,意味着hashCode必然相等。

但是如果我们只重写了equals方法,就有可能导致hashCode不相同。

一旦出现这种情况,就导致这个类无法和所有集合类一起工作。

所以,在实际开发中,约定俗成了一条规则,重写equals方法的同时也需要重写hashCode方法。

讲人话

如果只重写equals方法,不重写hashCode方法。

就有可能导致a.equals(b)这个表达式成立,但是hashCode却不同。

那么这个只重写了equals方法的对象,在使用散列集合进行存储的时候就会出现问题。

因为散列结合是使用hashCode来计算key的存储位置,如果存储两个完全相同的对象,但是有不同的hashcode

就会导致这两个对象存储在hash表的不同位置,当我们想根据这个对象去获取数据的时候,就会出现一个悖论

一个完全相同的对象会在存储在hash表的两个位置,造成大家约定俗成的规则,出现一些不可预料的错误。
?

介绍下策略模式和观察者模式?

问题分析

在Java里面,有23种设计模式

而在实际开发中,用到的设计模式屈指可数,主要有两方面的原因

  1. 目前的开发模式,基本上按照MVC这一套在搞,大部分业务逻辑的实现都不复杂
  2. 对设计模式的理解不够,只能生搬硬套,不仅仅没带来好处,还让程序处理变得更麻烦

但是,设计模式确实是无数前辈在软件开发过程中总结的一些经验,他们能够使得程序更加灵活可扩展

有人把它总结成了公式化的23种设计模式,导致大家以为按照这个公式去搬运就可以,但实际上我认为。

设计模式应该是一种软件设计的思想或者方法论,它不应该固化成某种特定的公式,它的运用应该更加灵活。

这23种设计模式可以分成三种类型分别是创建型、结构型、行为型。

策略模式和观察者模式属于行为型模式。

行为型模式主要用来描述多个类和对象之间的相互协同完成单个对象无法单独完成的任务,除了这两种以外,

还包括模版方法、状态模式、责任链模式、解释器模式等。

讲讲人话

策略模式和观察者模式属于行为型模式。

策略模式主要是用在根据上下文动态控制类的行为的场景,

  1. 一方面可以解决多个if...else判断带来的代码复杂性和维护性问题
  2. 另一方面,把类的不同行为进行封装,使得程序可以进行动态的扩展和替换,增加了程序的灵活性。

像支付路由这种场景,就可以使用策略模式实现。

观察者模式主要用在一对多的对象依赖关系的中,实现某一个对象状态变更之后的感知的场景

  1. 一方面可以降低对象依赖关系的耦合度,弱化依赖关系。
  2. 另一方面,通过这种状态通知机制,可以保证这些依赖对象之间的状态协同。

在Spring源码里面有大量运用这种观察者模式实现事件的传播和感知。

以上就是我的理解。

谈谈什么是零拷贝?

在实际应用中,如果我们需要把磁盘中的某个文件内容发送到远程服务器上,那么它必须要经过几个拷贝的过程,。从磁盘中读取目标文件内容拷贝到内核缓冲区,CPU 控制器再把内核缓冲区的数据赋值到用户空间的缓冲区中,

接着在应用程序中,调用 write() 方法,把用户空间缓冲区中的数据拷贝到内核下的 Socket Buffer 中。最后,把在内核模式下的 SocketBuffer 中的数据赋值到网卡缓冲区(NIC Buffer)网卡缓冲区再把数据传输到目标服务器上。

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

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

除此之外,由于用户空间和内核空间的切换会带来CPU的上线文切换,对于CPU性能也会造成性能影响。而零拷贝,就是把这两次多于的拷贝省略掉,应用程序可以直接把磁盘中的数据从内核中直接传输给 Socket,而不需要再经过应用程序所在的用户空间(如图)。

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

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

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

所以,所谓零拷贝,并不是完全没有数据复制,只是相对于用户空间来说,不再需要进行数据拷贝。对于前面说的整个流程来说,零拷贝只是减少了不必要的拷贝次数而已。在程序中如何实现零拷贝呢?

  1. 在 Linux 中,零拷贝技术依赖于底层的 sendfile()方法实现
  2. 在 Java 中,FileChannal.transferTo()方法的底层实现就是 sendfile()方法。
  3. 除此之外,还有一个 mmap 的文件映射机制

SortedSet和List异同点?

问题分析

在面试过程中遇到的xx技术和xx技术的异同点问题。

大家必须要深刻了解这两种技术的特征和优缺点,如果单纯的去背诵,很难记住。

这个问题中,List大家都已经非常熟悉了。我简单分享一下SortedSet(如图)。

在Java的整个集合体系中,集合可以分成两个体系,一个是Collection存储单个对象的集合,另一个是k-v结构的Map集合

SortedSet是Collection体系下Set接口下的派生类,而Set集合的特征是不包含重复的元素的集合。

了解了这个特点,才能更加准确的回答这个问题。

问题解答

相同点:

  1. 都可以用来存储一组有序的元素。
  2. 都支持随机访问和按照索引位置插入元素。
  3. 都是派生自Collection接口

不同点:

  1. SortedSet是一个有序的集合,不允许元素的重复,而List是一个有序的列表,允许元素的重复。
  2. SortedSet可以按照元素的自然顺序或者自定义比较器进行排序,而List只能按照元素的添加顺序排序。
  3. 在SortedSet中,元素的添加和删除操作的时间复杂度为O(logn),而在List中,元素的添加和删除操作的时间复杂度为O(n),因为需要移动其他元素的位置。
  4. SortedSet可以方便地进行范围查询操作,例如获取某个区间内的元素,而List只能通过遍历实现范围查询。

哪些情况下的单例对象可能会破坏?

1、单例模式的定义

关于单例模式的定义,官方原文是这样描述的

大致意思是,确保一个类在任何情况下都绝对只有一个实例,并提供一个全局访问点。

2、单例被破坏的五个场景

第一种:多线程破坏单例

在多线程环境下,线程的时间片是由CPU自由分配的,具有随机性,而单例对象作为共享资源可能会同时被多个线程同时操作,从而导致同时创建多个对象。当然,这种情况只出现在懒汉式单例中。如果是饿汉式单例,在线程启动前就被初始化了,不存在线程再创建对象的情况。

如果懒汉式单例出现多线程破坏的情况,我给出以下两种解决方案:

1、改为DCL双重检查锁的写法。

2、使用静态内部类的写法,性能更高。

第二种:指令重排破坏单例

指令重排也可能导致懒汉式单例被破坏。来看这样一句代码:

instance = new Singleton();

看似简单的一段赋值语句:instance = new Singleton();

其实JVM内部已经被转换为多条执行指令:

memory = allocate();? 分配对象的内存空间指令

ctorInstance(memory);? 初始化对象

instance = memory;?? 将已分配存地址赋值给对象引用

1、分配对象的内存空间指令,调用allocate()方法分配内存。?

2、调用ctorInstance()方法初始化对象??

3、将已分配存地址赋值给对象引用

但是经过重排序后,执行顺序可能是这样的:

memory = allocate();? 分配对象的内存空间指令

instance = memory;?? 将已分配存地址赋值给对象引用

ctorInstance(memory);? 初始化对象

1、分配对象的内存空间指令

2、设置instance指向刚分配的内存地址

3、初始化对象

我们可以看到指令重排之后,instance指向分配好的内存放在了前面,而这段内存的初始化的指令被排在了后面,在线程 T1 初始化完成这段内存之前,线程T2 虽然进不去同步代码块,但是在同步代码块之前的判断就会发现 instance 不为空,此时线程T2 获得 instance 对象,如果直接使用就可能发生错误。

如果出现这种情况,我该如何解决呢?只需要在成员变量前加volatile,保证所有线程的可见性就可以了。

private static volatile Singleton instance = null;

第三种:克隆破坏单例

在Java中,所有的类就继承自Object,也就是说所有的类都实现了clone()方法。如果是深clone(),每次都会重新创建新的实例。那如果我们定义的是单例对象,岂不是也可调用clone()方法来反复创建新的实例呢?确实,这种情况是有可能发生的。为了避免发生这样结果,我们可以在单例对象中重写clone() 方法,将单例自身的引用作为返回值。这样,就能避免这种情况发生。

第四种:反序列化破坏单例

我们将Java对象序列化以后,对象通常会被持久化到磁盘或者数据库。如果我们要再次加载到内存,就需要将持久化的内容反序列化成Java对象。反序列化是基于字节码来操作的,我们要序列化以前的内容进行反序列化到内存,就需要重新分配内存,也就是说,要重新创建对象。那如果要反序列化的对象恰恰是单例对象,我们该怎么办呢?

我告诉大家一种解决方案,在反序列的过程中,Java API会调用readResolve()方法,可以通过获取readResolve()方法的返回值覆盖反序列化创建的对象。

(反序列化对象 指向 单例对象动画 出现 )

因此,只需要重写readResolve()方法,将返回值设置为已经存在的单例对象,就可以保证反序列化以后的对象是同一个了。之后再将反序列化后的对象中的值,克隆到单例对象中。

第五种:反射破坏单例

以上讲的所有单例情况都有可能被反射破坏。因为Java中的反射机制是可以拿到对象的私有的构造方法,也就是说,反射可以任意调用私有构造方法创建单例对象。当然,没有人会故意这样做,但是如果出现意外的情况,该如何处理呢?我推荐大家两种解决方案,

第一种方案是在所有的构造方法中第一行代码进行判断,检查单例对象是否已经被创建,如果已经被创建,则抛出异常。这样,构造方法将会被终止调用,也就无法创建新的实例。

第二种方案,将单例的实现方式改为枚举式单例,因为在JDK源码层面规定了,不允许反射访问枚举。

什么是Java SPI,它有什么作用?

1、什么是SPI

SPI全称Service Provider Interface,它是Java提供的一套用来被第三方实现或者扩展的API。

如图所示,简单来说,就是我们定义一个标准的接口,然后第三方的库里面可以实现这个接口。

那么,程序在运行的时候,会根据配置信息动态加载第三方实现的类,从而完成功能动态扩展的机制。

2、SPI的应用场景

SPI的主要作用是解耦。适用于调用者根据实际使用需要,启用、扩展、或者替换框架的实现策略。

在Java中,SPI机制有一个非常典型的实现案例,就是数据库驱动java.jdbc.Driver

如图所示,JDK里面定义了数据库驱动类Driver,它是一个接口,JDK并没有提供实现,具体的实现是由第三方数据库厂商来完成的。

在程序运行的时候,会根据我们声明的驱动类型,来动态加载对应的扩展实现,从而完成数据库的连接。

除此之外,在很多开源框架里面都借鉴了Java SPI的思想,提供了自己的SPI框架,比如Dubbo定义了ExtensionLoader,实现功能的扩展。

Spring提供了SpringFactoriesLoader,实现外部功能的集成,基于Servlet 3.0规范对ServletContainerInitializer的实现。

二.Mysql数据库面试题

innoDB如何解决幻读

1、 Mysql的事务隔离级别

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

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

2、 什么是幻读?

那么, 什么是幻读呢?

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

b树和b+树的理解

从三个方面来回答:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

你是否在面试中也被过MySQL优化相关的问题呢?

MySQL的性能优化我认为可以分为4大部分

  1. 硬件和操作系统层面的优化
  2. 架构设计层面的优化
  3. MySQL程序配置优化
  4. SQL优化

硬件及操作系统层面优化

从硬件层面来说,影响Mysql性能的因素有,CPU、可用内存大小、磁盘读写速度、网络带宽

从操作系层面来说,应用文件句柄数、操作系统网络的配置都会影响到Mysql性能。

这部分的优化一般由DBA或者运维工程师去完成。

在硬件基础资源的优化中,我们重点应该关注服务本身承载的体量,然后提出合理的指标要求,避免出现资源浪费!

架构设计层面的优化

MySQL是一个磁盘IO访问量非常频繁的关系型数据库

在高并发和高性能的场景中.MySQL数据库必然会承受巨大的并发压力,而此时,我们的优化方式可以分为几个部分。

  1. 搭建Mysql主从集群,单个Mysql服务容易单点故障,一旦服务器宕机,将会导致依赖Mysql数据库的应用全部无法响应。 主从集群或者主主集群可以保证服务的高可用性。
  2. 读写分离设计,在读多写少的场景中,通过读写分离的方案,可以避免读写冲突导致的性能影响
  3. 引入分库分表机制,通过分库可以降低单个服务器节点的IO压力,通过分表的方式可以降低单表数据量,从而提升sql查询的效率。
  4. 针对热点数据,可以引入更为高效的分布式数据库,比如Redis、MongoDB等,他们可以很好的缓解Mysql的访问压力,同时还能提升数据检索性能。

MySQL程序配置优化

MySQL是一个经过互联网大厂验证过的生产级别的成熟数据库,对于Mysql数据库本身的优化,一般是通过Mysql中的配置文件my.cnf来完成的,比如。

Mysql5.7版本默认的最大连接数是151个,这个值可以在my.cnf中修改。

binlog日志,默认是不开启

缓存池bufferpoll的默认大小配置等。

由于这些配置一般都和用户安装的硬件环境以及使用场景有关系,因此这些配置官方只会提供一个默认值,具体情况还得由使用者来修改。

关于配置项的修改,需要关注两个方面。

  1. 配置的作用域,分为会话级别和全局
  2. 是否支持热加载

因此,针对这两个点,我们需要注意的是:

  1. 全局参数的设定对于已经存在的会话无法生效
  2. 会话参数的设定随着会话的销毁而失效
  3. 全局类的统一配置建议配置在默认配置文件中,否则重启服务会导致配置失效

SQL优化

SQL优化又能分为三步曲

  1. 第一、慢SQL的定位和排查

我们可以通过慢查询日志和慢查询日志分析工具得到有问题的SQL列表。

  1. 第二、执行计划分析

针对慢SQL,我们可以使用关键字explain来查看当前sql的执行计划.可以重点关注type key rows filterd 等字段 ,从而定位该SQL执行慢的根本原因。再有的放矢的进行优化

  1. 第三、使用show profile工具

Show Profile是MySQL提供的可以用来分析当前会话中,SQL语句资源消耗情况的工具,可用于SQL调优的测量。在当前会话中.默认情况下处于show profile是关闭状态,打开之后保存最近15次的运行结果

针对运行慢的SQL,通过profile工具进行详细分析.可以得到SQL执行过程中所有的资源开销情况.

如IO开销,CPU开销,内存开销等.

以上就是我对MySQL性能优化的理解。

好的,看完高手的回答后,相信各位对MySQL性能优化有了一定的理解了,最后我在给各位总结一下常见的SQL优化规则:

  1. SQL的查询一定要基于索引来进行数据扫描
  2. 避免索引列上使用函数或者运算,这样会导致索引失效
  3. where 字句中like %号,尽量放置在右边
  4. 使用索引扫描,联合索引中的列从左往右,命中越多越好.
  5. 尽可能使用SQL语句用到的索引完成排序,避免使用文件排序的方式
  6. 查询有效的列信息即可.少用 * 代替列信息
  7. 永远用小结果集驱动大结果集。

MVCC的理解

第一种:读读

就是线程A与线程B同时在进行读操作,这种情况下不会出现任何并发问题。

第二种:读写?

就是线程A与线程B在同一时刻分别进行读和写操作。

这种情况下,可能会对数据库中的数据造成以下问题:

  1. 事物隔离性问题,
  2. 出现脏读,幻读,不可重复读的问题

第三种:写写

就是线程A与线程B同时进行写操作

这种情况下可能会存在数据更新丢失的问题。

而MVCC就是为了解决事务操作中并发安全性问题的无锁并发控制技术全称为Multi-Version Concurrency Control ,也就是多版本并发控制。它是通过数据库记录中的隐式字段,undo日志 ,Read View 来实现的。

?MVCC主要解决了三个问题

  1. 第一个是:通过MVCC 可以解决读写并发阻塞问题从而提升数据并发处理能力
  2. 第二个是:MVCC 采用了乐观锁的方式实现,降低了死锁的概率
  3. 第三个是:解决了一致性读的问题也就是事务启动时根据某个条件读取到的数据,直到事务结束时,再次执行相同条件,还是读到同一份数据,不会发生变化。

而我们在使用MVCC时一般会根据业务场景来选择组合搭配乐观锁或悲观锁。

这两个组合中,MVCC用来解决读写冲突,乐观锁或者悲观锁解决写写冲突从而最大程度的提高数据库并发性能。

索引的底层实现,为什么选择B+Tree而不是红黑树?

  1. 第一点

对于一个数据库来说? 存储的数据量会比较多,导致索引也很大? 因此需要将索引存储在磁盘,但是磁盘的IO操作又非常耗,所以提高索引效率的关键在于减少磁盘IO的次数。

举个例子 对于31个节点的树来说 ,一个5阶B+Tree的高度是3 一个红黑树的最小高度是5,树的高度基本决定了磁盘的IO次数 ,所以使用B+Tree性能要高很多

  1. 第二点

B+Tree有个特点是相邻的数据在物理上也是相邻的,因为B+Tree的node的大小设为一个页,而一个节点上存有多个相邻的关键字和分支信息,每个节点只需要一次IO就能完全载入,相当于一次IO载入了多个相邻的关键字和分支,而红黑树不具有这个特性,红黑树中大小相邻的数据,在物理结构上可能距离相差很大。由于程序的局部性原理,如果我们在索引中采用了预加载的技术,每次磁盘访问的时候除了将访问到的页加载到磁盘,我们还可以基于局部性原理加载,几页相邻的数据到内存中,而这个加载是不需要消耗多余磁盘IO时间的。

因此 基于局部性原理,以及B+Tree存储结构物理上的特性,所以B+Tree的索引性能比红黑树要好很多。

请你说一下Mysql中的性能调优方法

Mysql性能调优方法可以从四个方面来说,分别是

  1. 表结构与索引
  2. SQL语句优化
  3. Mysql参数优化
  4. 硬件及系统配置

这四个方面的优化成本和优化效果是成反比的。

  1. 表结构和索引的优化,主要可以下面这些方面去优化

分库分表、读写分离、为字段选择合适的数据类型、适当的反范式设计,适当冗余设计、 为查询操作创建必要的索引但是要避免索引滥用、尽可能使用Not Null。

  1. SQL语句优化可以从几个方面来做

通过慢查询分析需要优化的SQL进行合理优化、利用explain、profile等工具分析SQL执行计划、避免使用SELECT *查询。

尽可能使用索引扫描来排序。

  1. 参数调优方面,主要可以设置Buffer_pool的大小,建议占总内存的70%左右。设置刷盘策略,平衡好数据安全性和性能的关系等
  2. 硬件方面、主要是CPU核数、磁盘的读写性能(减小寻道时间、旋转时间、传输时间),可以选择SSD、网卡、内存等方面。

为什么一线互联网公司严禁使用存储过程?

  1. 存储过程不好调试,一旦涉及到非常复杂的逻辑,定位问题的时候比较麻烦
  2. 存储过程的一致性很差, 如果从Oracle迁移到Mysql,涉及到部分数据库独有特性的时候,整个存储过程就需要重写,不仅成本高,而且还有可能对上游业务造成影响
  3. 存储过程的管理比较困难,一旦存储过程的量比较大的时候,就会陷入到一个混乱的状态
  4. 存储过程的优化和维护很麻烦, 随着业务的发展,数据库的表结构也许会发生变化,这些变化需要同步给到存储过程,并且有可能原来的SQL语句的执行计划不是最优的,也需要重新维护。

Mysql中的RR隔离级别,到底有没有解决幻读问题?

一部分人说有,一部分人说没有。

我先说结论,Mysql中的RR事务隔离级别,在特定的情况下会出现幻读的问题。

所谓的幻读,表示在同一个事务中的两次相同条件的查询得到的数据条数不一样。

那在RR级别下,具体什么情况下会出现幻读呢?

来看这样一种情况{如图},在事务1里面通过update语句触发当前读的情况下,就会导致在该事务中的前后两次查询的数据行数不一致,从而出现幻读的现象。

导致幻读的根本原因是,update触发的当前读操作,绕过了快照读,从而导致MVCC机制在当前场景下失效。

最终读取到了事务2中已经提交的数据。

为了避免出现这类的情况,我们可以通过for update语句加锁。

为什么索引要用B+树来实现呢,而不是B树?

常规的数据库存储引擎,一般都是采用B树或者B+树来实现索引的存储。

(如图)因为B树是一种多路平衡树,用这种存储结构来存储大量数据,它的整个高度会相比二叉树来说,会矮很多。

而对于数据库来说,所有的数据必然都是存储在磁盘上的,而磁盘IO的效率实际上是很低的,特别是在随机磁盘IO的情况下效率更低。

所以树的高度能够决定磁盘IO的次数,磁盘IO次数越少,对于性能的提升就越大,这也是为什么采用B树作为索引存储结构的原因。

(如图)但是在Mysql的InnoDB存储引擎里面,它用了一种增强的B树结构,也就是B+树来作为索引和数据的存储结构。

相比较于B树结构,B+树做了几个方面的优化。

  1. B+树的所有数据都存储在叶子节点,非叶子节点只存储索引。
  2. 叶子节点中的数据使用双向链表的方式进行关联。

对了,关于Mysql相关的面试题,我给大家准备了1000道带标准答案的面试题,只有10份,大家可以在评论区置顶中去领取。

使用B+树来实现索引的原因,我认为有几个方面。

  1. B+树非叶子节点不存储数据,所以每一层能够存储的索引数量会增加,意味着B+树在层高相同的情况下存储的数据量要比B树要多,使得磁盘IO次数更少。
  2. 在Mysql里面,范围查询是一个比较常用的操作,而B+树的所有存储在叶子节点的数据使用了双向链表来关联,所以在查询的时候只需查两个节点进行遍历就行,而B树需要获取所有节点,所以B+树在范围查询上效率更高。
  3. 在数据检索方面,由于所有的数据都存储在叶子节点,所以B+树的IO次数会更加稳定一些。
  4. 因为叶子节点存储所有数据,所以B+树的全局扫描能力更强一些,因为它只需要扫描叶子节点。但是B树需要遍历整个树。

另外,基于B+树这样一种结构,如果采用自增的整型数据作为主键,还能更好的避免增加数据的时候,带来叶子节点分裂导致的大量运算的问题。

总的来说,我认为技术方案的选型,更多的是去解决当前场景下的特定问题,并不一定是说B+树就是最好的选择,就像MongoDB里面采用B树结构,本质上来说,其实是关系型数据库和非关系型数据库的差异。???

Mysql是如何解决幻读问题的?

1、 Mysql的事务隔离级别

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

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

2、 什么是幻读?

那么, 什么是幻读呢?

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

总结

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

什么是聚集索引和非聚集索引

  1. 简单来说,聚集索引就是基于主键创建的索引,除了主键索引以外的其他索引,称为非聚集索引,也叫做二级索引。
  2. 由于在InnoDB引擎里面,一张表的数据对应的物理文件本身就是按照B+树来组织的一种索引结构,而聚集索引就是按照每张表的主键来构建一颗B+树,然后叶子节点里面存储了这个表的每一行数据记录。
  3. 所以基于InnoDB这样的特性,聚集索引并不仅仅是一种索引类型,还代表着一种数据的存储方式。
  4. 同时也意味着每个表里面必须要有一个主键,如果没有主键,InnoDB会默认选择或者添加一个隐藏列作为主键索引来存储这个表的数据行。一般情况是建议使用自增id作为主键,这样的话id本身具有连续性使得对应的数据也会按照顺序存储在磁盘上,写入性能和检索性能都很高。否则,如果使用uuid这种随机id,那么在频繁插入数据的时候,就会导致随机磁盘IO,从而导致性能较低。
  5. 需要注意的是,InnoDB里面只能存在一个聚集索引,原因很简单,如果存在多个聚集索引,那么意味着这个表里面的数据存在多个副本,造成磁盘空间的浪费,以及数据维护的困难。
  6. (如图)由于在InnoDB里面,主键索引表示的是一种数据存储结构,所以如果是基于非聚集索引来查询一条完整的记录,最终还是需要访问主键索引来检索。

请你简单说一下Mysql的事务隔离级别

事务隔离级别,是为了解决多个并行事务竞争导致的数据安全问题的一种规范。

具体来说,多个事务竞争可能会产生三种不同的现象。

  1. (如图)假设有两个事务T1/T2同时在执行,T1事务有可能会读取到T2事务未提交的数据,但是未提交的事务T2可能会回滚,也就导致了T1事务读取到最终不一定存在的数据产生脏读的现象。

  1. (如图)假设有两个事务T1/T2同时执行,事务T1在不同的时刻读取同一行数据的时候结果可能不一样,从而导致不可重复读的问题。

  1. (如图),假设有两个事务T1/T2同时执行,事务T1执行范围查询或者范围修改的过程中,事务T2插入了一条属于事务T1范围内的数据并且提交了,这时候在事务T1查询发现多出来了一条数据,或者在T1事务发现这条数据没有被修改,看起来像是产生了幻觉,这种现象称为幻读。

而这三种现象在实际应用中,可能有些场景不能接受某些现象的存在,所以在SQL标准中定义了四种隔离级别,分别是:

  1. 读未提交,在这种隔离级别下,可能会产生脏读、不可重复读、幻读。
  2. 读已提交(RC),在这种隔离级别下,可能会产生不可重复读和幻读。
  3. 可重复读(RR),在这种隔离级别下,可能会产生幻读
  4. 串行化,在这种隔离级别下,多个并行事务串行化执行,不会产生安全性问题。

这四种隔离级别里面,只有串行化解决了全部的问题,但也意味着这种隔离级别的性能是最低的。

在Mysql里面,InnoDB引擎默认的隔离级别是RR(可重复读),因为它需要保证事务ACID特性中的隔离性特征。

binlog和redolog有什么区别?

问题解析

这个问题其实比较简单,但凡稍微了解过Mysql数据库,都很容易回答出来。

一般考察1~3年的程序员,所以对于这个工作年限的小伙伴要注意了解它们的原理。

首先,binlog和redolog都是Mysql里面用来记录数据库数据变更操作的日志。

{如图}其中binlog主要用来做数据备份、数据恢复和数据同步,大家初步接触这个概念 ,应该是在Mysql的主从数据同步的场景中,master节点的数据变更,会写入到binlog中,然后再把binlog中的数据通过网络传输给slave节点,实现数据同步。

而redolog,主要是在Mysql数据库事务的ACID特性里面,用来保证数据的持久化特性。但是其实它还有很多的作用。

比如数据库崩溃时,可以通过Redo Log来恢复未完成的数据,保证数据的完整性。

通过合理的配置Redo Log的大小和数量,还可以优化Mysql的性能。

那下面来看下这个面试题的回答吧。

问题答案

binlog和redolog的区别有很多,我可以简单总结三个点

  1. 使用场景不同,binlog主要用来做数据备份、数据恢复、以及主从集群的数据同步; Redo Log主要用来实现Mysql数据库的事务恢复,保证事务的ACID特性。当数据库出现崩溃的时候,Redo Log可以把未提交的事务回滚,把已提交的事务进行持久化,从而保证数据的一致性和持久性。
  2. 记录的信息不同,binlog是记录数据库的逻辑变化,它提供了三种日志格式分别是statement,row以及mixed

redo log记录的是物理变化,也就是数据页的变化结果。

  1. 记录的时机不同, binlog是在执行SQL语句的时候,在主线程中生成逻辑变化写入到磁盘中,所以它是语句级别的记录方式; RedoLog是在InnoDB存储引擎层面的操作,它是在Mysql后台线程中生成并写入到磁盘中的,所以它是事务级别的记录方式,一个事务操作完成以后才会被写入到redo log中。

说一下你日常工作中是怎么优化SQL的

  1. 加索引,增加索引是一种简单高效的手段,但是需要选择合适的列,同时避免导致索引失效的操作,比如like、函数等。
  2. 避免返回不必要的数据列,减少返回的数据列可以增加查询的效率。
  3. 根据查询分析器适当优化SQL的结构,比如是否走全表扫描、避免子查询等
  4. 分库分表,在单表数据量较大或者并发连接数过高的情况下,通过这种方式可以有效提升查询效率
  5. 读写分离,针对读多写少的场景,这样可以保证写操作的数据库承受更小的压力,也可以缓解独占锁和共享锁的竞争。

基本上回答到这个程度得到面试官的认可是没问题,不过要注意,面试官还会基于你回答的内容再做更进一步的深度考察。

Mysql 主从集群同步延迟问题怎么解决

下面我先来个大家复习一下主从复制的工作原理。

复制过程分为几个步骤:

  1. 主库的更新事件(update、insert、delete)被写到?? binlog
  2. 从库发起连接,连接到主库。
  3. 此时主库创建一个 binlog dump thread,把binlog 的内容发送到从库。
  4. 从库启动之后,创建一个 I/O 线程,读取主库传过来的 binlog 内容并写入到 relay log
  5. 从库还会创建一个 SQL 线程,从 relay log 里面读取内容,从Exec_Master_Log_Pos 位置开始执行读取到的更新事件,将更新内容写入到slave 的db

主从数据同步涉及到网络数据传输,由于网络通信的延迟以及从库数据处理的效率问题,就会导致主从数据同步延迟的情况。

一般可以通过以下几个方法来解决

  1. 设计一主多从来分担从库压力,减少主从同步延迟问题
  2. 如果对数据一致性要求高,在从库存在延迟的情况下,可以强制走主库查询数据
  3. 可以在从库上执行show slave status命令,获取seconds_behind_master字段的延迟时间,然后通过sleep阻塞等待固定时间后再次查询
  4. 通过并行复制解决从库复制延迟的问题

实际上,主从复制的场景无法避免同步延迟的问题,如果一定要用强一致方案,那就应该考虑其他能够实现一致性场景的技术方案。

MySQL 数据库 cpu 飙升的话,要怎么处理呢?

第一步,排查问题

  1. 使用top命令,找到cpu占用过高的进程是否是mysqld
  2. 如果是,可以在mysql中通过show processlist查看当前的会话情况,确定是否有消耗资源的SQL正在运行
  3. 找到消耗过高的SQL,通过执行计划进行具体的分析

第二步,处理方式

  1. 如果确定是SQL问题,可以通过SQL的优化手段进行调整
  2. 重新执行SQL分析确认是否有达到优化的目的

第三步,其他情况

如果不是SQL的问题导致,那就需要分析CPU飙高的这个时间段,Mysql的整体并发连接数。

如果有大量的请求连接进来,那我们就需要分析这个时间段业务的情况,再做出相应的调整。

最后,如果是Mysql本身的参数并不是最优状态,那我们可以对Mysql服务节点的配置进行调整,比如缓存大小、线程池大小等

综上所述,处理 MySQL 数据库 CPU 飙升问题需要综合考虑多个方面,包括查询优化、索引优化、MySQL 配置优化、硬件升级、负载均衡等。大家在回答的时候尽可能全面一点去考虑。

Mysql 的binlog 有几种格式?分别有什么区别

准确来说,Binlog有三种格式:statement,row 和 mixed。

  1. statement,记录的是 SQL 的原文。好处是,不需要记录每一行的变化,减少了binlog 日志量,节约了 IO,提高性能。由于 sql 的执行是有上下文的,因此在保存的时候需要保存相关的信息,同时还有一些使用了函数之类的语句无法被记录复制。
  2. row,不记录 sql 语句上下文相关信息,仅保存哪条记录被修改。记录单元为每一行的改动,基本是可以全部记下来但是由于很多操作,会导致大量行的改动(比如 alter table),因此这种模式的文件保存的信息太多,日志量太大。
  3. mixed,一种折中的方案,普通操作使用 statement 记录,当无法使用statement 的时候使用 row。

如果大家想加深印象更好的应对面试,可以自己搭建Mysql的主从环境,然后通过配置不同方式来查看日志内容。

索引有哪些缺点以及具体有哪些索引类型

第一个,索引的优缺点

优点:

  1. 合理的增加索引,可以提高数据查询的效率,减少查询时间
  2. 有一些特殊的索引,可以保证数据的完整性,比如唯一索引

缺点:

  1. 创建索引和维护索引需要消耗时间
  2. 索引需要额外占用物理空间
  3. 对创建了索引的表进行数据的增加、修改、删除时,会同步动态维护索引,这个部分会造成性能的影响

第二个,索引的类型

  1. 主键索引:? 数据列不允许重复,不允许为 NULL,一个表只能有一个主键。
  2. 唯一索引:? 数据列不允许重复,允许为 NULL 值,一个表允许多个列创建唯一索引。
  3. 普通索引:? 基本的索引类型,没有唯一性的限制,允许为 NULL 值。
  4. 全文索引:是目前搜索引擎使用的一种关键技术,对文本的内容进行分词、搜索。
  5. 覆盖索引:查询列要被所建的索引覆盖,不必读取数据行
  6. 组合索引:多列值组成一个索引,用于组合搜索,效率大于索引合并

以上就是我的回答,大家如果觉得这个回答比较完整,可以点赞收藏一下,后续面试的时候可以复习

b树和b+树的理解

从三个方面来回答:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

什么是聚集索引和非聚集索引

  1. 简单来说,聚集索引就是基于主键创建的索引,除了主键索引以外的其他索引,称为非聚集索引,也叫做二级索引。
  2. 由于在InnoDB引擎里面,一张表的数据对应的物理文件本身就是按照B+树来组织的一种索引结构,而聚集索引就是按照每张表的主键来构建一颗B+树,然后叶子节点里面存储了这个表的每一行数据记录。
  3. 所以基于InnoDB这样的特性,聚集索引并不仅仅是一种索引类型,还代表着一种数据的存储方式。
  4. 同时也意味着每个表里面必须要有一个主键,如果没有主键,InnoDB会默认选择或者添加一个隐藏列作为主键索引来存储这个表的数据行。一般情况是建议使用自增id作为主键,这样的话id本身具有连续性使得对应的数据也会按照顺序存储在磁盘上,写入性能和检索性能都很高。否则,如果使用uuid这种随机id,那么在频繁插入数据的时候,就会导致随机磁盘IO,从而导致性能较低。
  5. 需要注意的是,InnoDB里面只能存在一个聚集索引,原因很简单,如果存在多个聚集索引,那么意味着这个表里面的数据存在多个副本,造成磁盘空间的浪费,以及数据维护的困难。
  6. (如图)由于在InnoDB里面,主键索引表示的是一种数据存储结构,所以如果是基于非聚集索引来查询一条完整的记录,最终还是需要访问主键索引来检索。

Mysql的事务隔离级别

关于这个问题,我会从几个方面来回答。

首先,事务隔离级别,是为了解决多个并行事务竞争导致的数据安全问题的一种规范。

具体来说,多个事务竞争可能会产生三种不同的现象。

  1. (如图)假设有两个事务T1/T2同时在执行,T1事务有可能会读取到T2事务未提交的数据,但是未提交的事务T2可能会回滚,也就导致了T1事务读取到最终不一定存在的数据产生脏读的现象。

  1. (如图)假设有两个事务T1/T2同时执行,事务T1在不同的时刻读取同一行数据的时候结果可能不一样,从而导致不可重复读的问题。

  1. (如图),假设有两个事务T1/T2同时执行,事务T1执行范围查询或者范围修改的过程中,事务T2插入了一条属于事务T1范围内的数据并且提交了,这时候在事务T1查询发现多出来了一条数据,或者在T1事务发现这条数据没有被修改,看起来像是产生了幻觉,这种现象称为幻读。

而这三种现象在实际应用中,可能有些场景不能接受某些现象的存在,所以在SQL标准中定义了四种隔离级别,分别是:

  1. 读未提交,在这种隔离级别下,可能会产生脏读、不可重复读、幻读。
  2. 读已提交(RC),在这种隔离级别下,可能会产生不可重复读和幻读。
  3. 可重复读(RR),在这种隔离级别下,可能会产生幻读
  4. 串行化,在这种隔离级别下,多个并行事务串行化执行,不会产生安全性问题。

这四种隔离级别里面,只有串行化解决了全部的问题,但也意味着这种隔离级别的性能是最低的。

在Mysql里面,InnoDB引擎默认的隔离级别是RR(可重复读),因为它需要保证事务ACID特性中的隔离性特征。

数据库连接池有什么用?它有哪些关键参数?

数据库连接池是一种池化技术,池化技术的核心思想是实现资源的复用,避免资源重复创建销毁的开销。

而在数据库的应用场景里面,应用程序每次向数据库发起CRUD操作的时候,都需要创建连接

在数据库访问量较大的情况下,频繁的创建连接会带来较大的性能开销。

(如图)而连接池的核心思想,就是应用程序在启动的时候提前初始化一部分连接保存到连接池里面,当应用需要使用连接的时候,直接从连接池获取一个已经建立好的链接。

连接池的设计,避免了每次连接的建立和释放带来的开销。

连接池的参数有很多,不过关键参数就几个:

首先是,连接池初始化的时候会有几个关键参数:

  1. 初始化连接数,表示启动的时候初始多少个连接保存到连接池里面。
  2. 最大连接数,表示同时最多能支持多少连接,如果连接数不够,后续要获取连接的线程会阻塞。
  3. 最大空闲连接数,表示没有请求的时候,连接池中要保留的最大空闲连接。
  4. 最小空闲连接,当连接数小于这个值的时候,连接池需要再创建连接来补充到这个值。

然后,就是在使用连接的时候的关键参数:

  1. 最大等待时间,就是连接池里面的连接用完了以后,新的请求要等待的时间,超过这个时间就会提示超时异常。
  2. 无效连接清除, 清理连接池里面的无效连接,避免使用这个连接操作的时候出现错误。

不同的连接池框架,除了核心的参数以外,还有很多业务型的参数,比如是否要检测连接sql的有效性、连接初始化SQL等等,这些配置参数可以在使用的时候去查询api文档就可以知道。

Mysql索引的优点和缺点?

(图片)索引,是一种能够帮助Mysql高效从磁盘上检索数据的一种数据结构。

在Mysql中的InnoDB引擎中,采用了B+树的结构来实现索引和数据的存储

在我看来,Mysql里面的索引的优点有很多

  1. 通过B+树的结构来存储数据,可以大大减少数据检索时的磁盘IO次数,从而提升数据查询的性能
  2. B+树索引在进行范围查找的时候,只需要找到起始节点,然后基于叶子节点的链表结构往下读取即可,查询效率较高。
  3. 通过唯一索引约束,可以保证数据表中每一行数据的唯一性

当然,索引的不合理使用,也会有带来很多的缺点。

  1. 数据的增加、修改、删除,需要涉及到索引的维护,当数据量较大的情况下,索引的维护会带来较大的性能开销。
  2. 一个表中允许存在一个聚簇索引和多个非聚簇索引,但是索引数不能创建太多,否则造成的索引维护成本过高。
  3. 创建索引的时候,需要考虑到索引字段值的分散性,如果字段的重复数据过多,创建索引反而会带来性能降低。

在我看来,任何技术方案都会有两面性,大部分情况下,技术方案的选择更多的是看中它的优势 和当前问题的匹配度。

Mysql事务的实现原理

Mysql里面的事务,满足ACID特性,所以在我看来,Mysql的事务实现原理,就是InnoDB是如何保证ACID特性的。

首先,A表示Atomic原子性,也就是需要保证多个DML操作是原子的,要么都成功,要么都失败。

那么,失败就意味着要对原本执行成功的数据进行回滚,所以InnoDB设计了一个UNDO_LOG表,在事务执行的过程中,

把修改之前的数据快照保存到UNDO_LOG里面,一旦出现错误,就直接从UNDO_LOG里面读取数据执行反向操作就行了。

其次,C表示一致性,表示数据的完整性约束没有被破坏,这个更多是依赖于业务层面的保证,数据库本身也提供了一些,比如主键的唯一余数,字段长度和类型的保证等等。

接着,I表示事物的隔离性,也就是多个并行事务对同一个数据进行操作的时候,如何避免多个事务的干扰导致数据混乱的问题。

而InnoDB实现了SQL92的标准,提供了四种隔离级别的实现。分别是:

RU(未提交读)

RC(已提交读)

RR(可重复读)

Serializable(串行化)

InnoDB默认的隔离级别是RR(可重复读),然后使用了MVCC机制解决了脏读和不可重复读的问题,然后使用了行锁/表锁的方式解决了幻读的问题。

最后一个是D,表示持久性,也就是只要事务提交成功,那对于这个数据的结果的影响一定是永久性的。

不能因为宕机或者其他原因导致数据变更失效。

理论上来说,事务提交之后直接把数据持久化到磁盘就行了,但是因为随机磁盘IO的效率确实很低,所以InnoDB设计了

Buffer Pool缓冲区来优化,也就是数据发生变更的时候先更新内存缓冲区,然后在合适的时机再持久化到磁盘。

那在持久化这个过程中,如果数据库宕机,就会导致数据丢失,也就无法满足持久性了。

所以InnoDB引入了Redo_LOG文件,这个文件存储了数据被修改之后的值,当我们通过事务对数据进行变更操作的时候,除了修改内存缓冲区里面的数据以外,还会把本次修改的值追加到REDO_LOG里面。

当提交事务的时候,直接把REDO_LOG日志刷到磁盘上持久化,一旦数据库出现宕机,

在Mysql重启在以后可以直接用REDO_LOG里面保存的重写日志读取出来,再执行一遍从而保证持久性。

因此,在我看来,事务的实现原理的核心本质就是如何满足ACID的,在InnDB里面用到了MVCC、行锁表锁、UNDO_LOG、REDO_LOG等机制来保证。

说一下你对行锁、临键锁、间隙锁的理解

行锁、临键锁、间隙锁,都是Mysql里面InnoDB引擎下解决事务隔离性的一系列排他锁。

下面请允许我分别介绍一下这三种锁。

行锁,也称为记录锁。(如图)

当我们针对主键或者唯一索引加锁的时候,Mysql默认会对查询的这一行数据加行锁,

避免其他事务对这一行数据进行修改。

间隙锁,顾名思义,就是锁定一个索引区间。

在普通索引或者唯一索引列上,由于索引是基于B+树的结构存储,所以默认会存在一个索引区间。(如图),

而间隙锁,就是某个事物对索引列加锁的时候,默认锁定对应索引的左右开区间范围。

在基于索引列的范围查询,无论是否是唯一索引,都会自动触发间隙锁。

比如基于between的范围查询,就会产生一个左右开区间的间隙锁。

最后一个是临键锁,(如图)它相当于行锁+间隙锁的组合,也就是它的锁定范围既包含了索引记录,也包含了索引区间

它会锁定一个左开右闭区间的数据范围。

(如图)假设我们使用非唯一索引列进行查询的时候,默认会加一个临键锁,锁定一个左开右闭区间的范围。

所以总的来说,行锁、临键锁、间隙锁只是表示锁定数据的范围,最终目的是为了解决幻读的问题。

而临键锁相当于行锁+间隙锁,因此当我们使用非唯一索引进行精准匹配的时候,会默认加临键锁,

因为它需要锁定匹配的这一行数据,还需要锁定这一行数据对应的左开右闭区间。

因此在实际应用中,尽可能使用唯一索引或者主键索引进行查询,避免大面积的锁定造成性能影响。

索引什么时候失效?

InnoDB引擎里面有两种索引类型,一种是主键索引、一种是普通索引。

InnoDB用了B+树的结构来存储索引数据。

当使用索引列进行数据查询的时候,最终会到主键索引树中查询对应的数据行进行返回。

理论上来说,使用索引列查询,就能很好的提升查询效率,但是不规范的使用

会导致索引失效,从而无法发挥索引本身的价值。

导致索引失效的情况有很多:

????????1.在索引列上做运算,比如使用函数,Mysql在生成执行计划的时候,它是根据统计信息来判断是否要使用索引的。

而在索引列上加函数运算,导致Mysql无法识别索引列,也就不会再走索引了。

不过从Mysql8开始,增加了函数索引可以解决这个问题。

????????2.在一个由多列构成的组合索引中,需要按照最左匹配法则,也就是从索引的最左列开始顺序检索,否则不会走索引。在组合索引中,索引的存储结构是按照索引列的顺序来存储的,因此在sql中也需要按照这个顺序才能进行逐一匹配。否则InnoDB无法识别索引导致索引失效。

????????3.当索引列存在隐式转化的时候, 比如索引列是字符串类型,但是在sql查询中没有使用引号。那么Mysql会自动进行类型转化,从而导致索引失效

????????4.索引列使用不等于号、not查询的时候,由于索引数据的检索效率非常低,因此Mysql引擎会判断不走索引。

????????5.使用like通配符匹配后缀%xxx的时候,由于这种方式不符合索引的最左匹配原则,所以也不会走索引。但是反过来,如果通配符匹配的是前缀xxx%,符合最左匹配,也会走索引。

????????6.使用or连接查询的时候,or语句前后没有同时使用索引,那么索引会失效。只有or左右查询字段都是索引列的时候,才会生效。除了这些场景以外,对于多表连接查询的场景中,连接顺序也会影响索引的使用。不过最终是否走索引,我们可以使用explain命令来查看sql的执行计划,然后针对性的进行调优即可。

Mysql中MyISAM和InnoDB引擎的区别

MyISAM和InnoDB都是Mysql里面的两个存储引擎。

在Mysql里面,存储引擎是可以自己扩展的,它的本质其实是定义数据存储的方式

以及数据读取的实现逻辑。

而不同存储引擎本身的特性,使得我们可以针对性的选择合适的引擎来实现不同的业务场景。

从而获得更好的性能。

在Mysql 5.5之前,默认的存储引擎是MyISAM,从5.5以后,InnoDB就作为了默认的存储引擎。

在实际应用开发中,我们基本上都是采用InnoDB引擎。

我们先来看一下MyISAM引擎。

MyISAM引擎的数据是通过二进制的方式存储在磁盘上,它在磁盘上体现为两个文件

  1. 一个是.MYD文件,D代表Data,是MyISAM的数据文件,存放数据记录,
  2. 一个是.MYI文件,I代表Index,是MyISAM的索引文件,存放索引

实现机制如图所示(如图)。

因为索引和数据是分离的,所以在进行查找的时候,先从索引文件中找到数据的磁盘位置,再到数据文件

中找到索引对应的数据内容。

在InnoDB存储引擎中,数据同样存储在磁盘上,它在磁盘上只有一个ibd文件,里面包含索引和数据。

(如图),它的整体结构如图所示,在B+树的叶子节点里面存储了索引对应的数据,在通过索引进行检索的时候,命中叶子节点,就可以直接从叶子节点中取出行数据。

了解了这两个存储引擎以后,我们在面试的时候该怎么回答呢?

讲人话

MyISAm和InnoDB的区别有4个,

  1. 第一个,数据存储的方式不同,MyISAM中的数据和索引是分开存储的,

而InnoDB是把索引和数据存储在同一个文件里面。

  1. 第二个,对于事务的支持不同,MyISAM不支持事务,而InnoDB支持ACID特性的事务处理
  2. 第三个,对于锁的支持不同,MyISAM只支持表锁,而InnoDB可以根据不同的情况,支持行锁,表锁,间隙锁,临键锁
  3. 第四个,MyISAM不支持外键,InnoDB支持外键

因此基于这些特性,我们在实际应用中,可以根据不同的场景来选择合适的存储引擎。

比如如果需要支持事务,那必须要选择InnoDB。

如果大部分的表操作都是查询,可以选择MyISAM。

存储MD5的值应该用VARCHAR还是CHAR

char类型是固定长度的字符串,varchar是可变长度字符串。

而MD5是一个固定长度的字符,不管数据怎么修改,长度不变,这个点很符合char类型。

另外,由于是固定长度,所以在数据变更的时候,不需要去调整存储空间大小,在效率上会比varchar好。

问题分析

MD5是由数字和字母组成的一个16位或者32位长度的字符串,一般在应用开发中都是使用32位。

看起来,我们用varchar(32)或者char(32)都可以存储,那用哪种更好呢?

要回答这个问题,必须要了解这两个类型的功能特性和区别。

  1. 第一个,char是一个固定长度的字符串,Varchar是一个可变长度的字符串

假设声明一个char(10)的长度,如果存储字符串“abc”,虽然实际字符长度只有3,但是char还是会占10个字节长度。

同样,如果用varchar存储,那它只会使用3个字符的实际长度来存储。

  1. 第二个,存储的效率不同,char类型每次修改以后存储空间的长度不变,所以效率更高

varchar每次修改数据都需要更新存储空间长度,效率较低

  1. 第三个,存储空间不同,char不管实际数据大小,存储空间是固定的,而varchar存储空间等于实际数据长度,

所以varchar实际存储空间的使用要比char更小

基于他们特性的分析,可以得出一个基本的结论:

  1. char适合存储比较短的且是固定长度的字符串
  2. varchar适合存储可变长度的字符串

Mysql 索引在什么情况下会失效

  1. 没有使用索引列作为WHERE子句的查询条件。
  2. 对索引列进行函数操作。比如字符串操作、日期操作等,MySQL将无法使用索引,此时索引失效。因为MySQL无法在运行时使用函数计算来匹配索引。
  3. 对索引列进行类型转换。如果索引列是数字类型,但是你传入的值是字符串,那这个时候Mysql会默认对类型进行转化,同样会导致MySQL将无法使用索引,MySQL在使用索引时必须将查询条件与索引列的数据类型匹配。
  4. LIKE查询的查询字符串以通配符开头。当使用LIKE查询并且查询字符串以通配符(例如%或_)开头时,MySQL无法使用索引,因为通配符在开头时无法进行前缀匹配。
  5. OR条件查询。当查询中包含OR条件时,如果OR条件中的每个条件都不涉及索引列,MySQL无法使用索引,此时索引失效。
  6. 查询条件涉及到大量数据。当查询条件涉及到大量数据时,例如返回表中大部分数据的查询,MySQL可能会认为使用索引并不高效,因此会放弃使用索引。

对于这个问题,尽可能多说一点,但是说不完整没关系,把核心的一些点说出来就够了

MVCC过程中会加锁吗?

问题解析

MVCC机制,全称(Multi-Version Concurrency Control)多版本并发控制,是确保在高并发下,

多个事务读取数据时不加锁也可以多次读取相同的值。

MVCC在读已提交(READ COMMITTED)、可重复读(REPEATABLE READ 简称RR)模式下才生效。

MVCC在可重复读的事物隔离级别下,可以解决脏读、脏写、不可重复读等问题。

我们知道,MVCC是基于乐观锁的实现,所以很自然的想到MVCC是不是不会加锁。

这个问题也要看情况来回答,下面看看回答建议。

问题答案

在MVCC中,通常不需要加锁来控制并发访问。

相反,每个事务都可以读取已提交的快照,而不需要获得共享锁或排它锁。

在写操作的时候,MVCC会使用一种叫为“写时复制”(Copy-On-Write)的技术,

也就是在修改数据之前先将数据复制一份,从而创建一个新的快照。

当一个事务需要修改数据时,MVCC 会首先检查修改数据的快照版本号

是否与该事务的快照版本一致,如果一致则表示可以修改这条数据,

否则该事务需要等待其他事务完成对该数据的修改。

另外,这个事物在新快照之上修改的结果,不会影响原始数据,

其他事务可以继续读取原始数据的快照,从而解决了脏读、不可重复度问题。

所以,正是有了MVCC机制,让多个事务对同一条数据进行读写时,不需要加锁也不会出现读写冲突。

MySQL update 是锁行还是锁表?

问题分析

要回答好这个问题,需要先了解Mysql中为什么要引入锁。

在多个事物并行对同一个数据进行修改的时候,会产生事物的竞争造成脏读、幻读、不可重复读等问题。

所谓Mysql为了避免这类问题的出现,引入了事物隔离级别,其实本质上来说,最终解决的方式无非就是LBCC和MVCC两种。

而锁是解决事物竞争问题的底层实现方式。

通常来说,加锁会影响性能, 所以一般情况下都会考虑到性能和安全性的平衡

而Mysql,也根据不同的作用范围,提供了不同的锁的实现方式。

而这个问题,就是考察候选人对锁范围的理解,下面来看下这个问题的回答思路。

问题解答

MySQL的Update操作既可以锁行,也可以锁表,

具体使用哪种锁类型,取决于执行的Update语句的条件、事务隔离级别等因素。

  1. 如果update语句中的where条件包含了索引列,并且只更新一条数据,那这个时候就加行锁。

如果where条件中不包含索引列,这个时候会加表锁

  1. 另外,根据查询范围不同,Mysql也会选择不同粒度的锁来避免幻读问题。

比如针对主键索引的for update操作:

SELECT * FROM t WHERE id = 10 FOR UPDATE;

Mysql会增加Next-Key Lock来锁定id=10索引所在的区间

  1. 另外,针对于索引区间的查询或者修改

SELECT * FROM user WHERE id BETWEEN 1 AND 100 FOR UPDATE;

Mysql会自动对索引间隙加锁,来解决幻读问题。

为什么 SQL 语句不要过多的 join?

问题分析

面试官主要想了解面试者对于 SQL 优化的理解以及在实际工作中如何处理 SQL 语句的性能问题。

要回答好这个问题,只需要了解join操作会带来哪些影响,而这些影响对程序产生什么样的影响就行了。

问题解答

我认为主要有两个方面的原因:

  1. 性能问题:每个 join 操作都需要对两个或多个表进行连接操作,这个操作需要消耗大量的计算资源和时间,如果 join 操作过多,会导致 SQL 的执行效率降低,从而影响整个系统的性能。
  2. 可读性和维护性问题:join 操作会使 SQL 语句变得复杂,难以理解和维护,特别是当 join 操作涉及到多个表的时候,SQL 语句的复杂度会呈现指数级增长,给代码的可读性和可维护性带来挑战。

什么情况下不建索引?

一般情况下说的索引,都是Mysql里面InnoDB引擎的B+树索引。

所以大家首先的知道索引的原理,大家都知道B+树是一颗多路平衡二叉树(如图)

它的特点是,非叶子节点只存储索引,叶子节点存储数据,从而减少B+树的层高降低磁盘IO次数啊从而提升数据检索效率。

通常情况下,加索引是能直接提升数据的检索效率,但面试官反其道而行问什么时候不建索引,所以这个问题考察候选人对于索引的理解深度。

如果期望这次面试有好的反馈,候选人需要能够分析出不建立索引的原因和影响,并根据具体情况进行详细说明。

问题解答

我认为有几种情况不适合建立索引:

  1. 数据量太小的情况下,即使没有索引,查询的速度也比较快,这个时候建立索引反而会增加维护成本和查询时间
  2. 数据离散度不高的列,比如性别、年龄这种,创建索引反而会降低检索效率,从底层原理来说,相当于增加了B+树的扫描范围
  3. 存在函数操作的情况,如果查询条件包含函数操作,那这个时候可能不会走索引,所以建了索引意义不大
  4. 频繁变更的表,比如经常需要更新、删除或插入记录,那么对这个表建立索引的开销就会很大,甚至可能影响到整个数据库的性能。

为什么SQL语句命中索引比不命中索引要快?

1、索引的作用?

想象一下,现在有一本包含几十万字的字典,有几百页厚,同时里面的字是无序排列的。如果在不使用目录的情况下,我们如何从字典中找出需要的字来呢?毫无疑问,我们只能一页一页的翻,显然,这是一项反人类的的工作。

我们必然想的是先看目录,然后,找到相关的字或者偏旁,然后,找到对应的页码再去查找想要找的文字,这样,效率就大大提高了。而事实上,目录就是一种索引,我们说的数据库索引思想和目录的思想一脉相承。

数据库索引最主要的作用就是帮助我们快速检索到想要的数据,从而不至于每次查询都做全局扫描。

假设不使用任何算法的情况下,我们要查询10万条记录中的某一条,在最坏的情况下需要遍历10万次。

但如果使用二分查找算法,则只需要进行log2 20000次,也就是14.287712次即可。这意味着我们只需对排序后的值进行14次搜索,就可以使用二分查找到想要的唯一值,常见的索引数据结构有B树和B+树。

下面我们,以MySQL的InnoDB引擎为例,分析一下索引的工作原理。

2、索引执行原理

我们知道MySQL的InnoDB引擎采用的是B+树数据结构,当我们去执行SELECT语句查询数据的时候,InnoDB需要从磁盘上去读取数据,而这个过程会涉及到磁盘 以及磁盘的随机IO ,我们来看这么一个图:

系统会把数据的逻辑地址传给磁盘,磁盘控制线路按照寻址逻辑把逻辑地址翻译成物理地址。也就是确定要读取的数据在哪个磁道、哪个扇区。为了读取这个扇区的数据,需要把磁头放在这个扇区上面,为了实现这样一个点,磁盘会不断地去旋转。把目标扇区旋转到磁头下面,使得磁头能够去找到对应的磁道。这里还会涉及到寻道的时间以及旋转时间的一个损耗。很明显磁盘IO这个过程的性能开销是非常大的,尤其是查询的数据量比较多的情况下。

所以InnotDB里面,干脆对存储在磁盘上的数据建立一个索引,然后把索引数据以及索引列对应的磁盘地址以B+树的方式进行存储。来看这么一个图:

当我们需要查找目标数据的时候,根据索引从B+树中去查找目标数据就行了。由于B+树的子树比较多,所以,只需要较少次数的磁盘IO就能够查找到目标数据。

至于B+树的数据结构,在这里就不分析了。

3、索引的弊端

虽然,使用索引能减少磁盘IO次数,提高查询效率,但是,索引也不能建立太多。如果一个表中所有字段的索引很大,也会导致性能? l下降。想象一下,如果一个索引和一个表一样长,那么它将再次成为一个需要检查的开销。这就好比字典的目录非常详细,但是其长度已经和所有的文字一样长,这个时候目录本身的效率就大大下降了。

那索引有弊端吗?肯定是有的,索引可以提高查询读取性能,而它会将降低写入性能。当有索引时,如果更改一条记录,或者在数据库中插入一条新的记录,它将执行两个写入操作(一个操作是写入记录本身,另一个操作是将更新索引)。

因此,在定义索引时,必须牢记以下几点:

1、索引表中的每个字段将降低写入性能。

2、建议使用表中的唯一值为字段编制索引。

3、在关系数据库中充当外键的字段必须建立索引,因为它们有助于跨多个表进行复杂查询。

4、索引还使用磁盘空间,因此在选择要索引的字段时要小心。

执行SQL响应比较慢,你有哪些排查思路?

1、排查思路

如果执行SQL响应比较慢,我觉得可能有以下4个原因:

第1个原因:没有索引或者 导致索引失效。

第2个原因:单表数据量数据过多,导致查询瓶颈

第3个原因:网络原因或者机器负载过高。

第4个原因:热点数据导致单点负载不均衡。

2、解决方案

第1种情况:索引失效或者没有没有索引的情况

首先,可以打开MySQL的慢查询日志,收集一段时间的慢查询日志内容,然后找出耗时最长的SQL语句,对这些SQL语句进行分析。

比如可以利用执行计划explain去查看SQL是否有命中索引。如果发现慢查询的SQL没有命中索引,可以尝试去优化这些SQL语句,保证SQL走索引执行。如果SQL结构没有办法优化的话,可以考虑在表上再添加对应的索引。我们在优化SQL或者是添加索引的时候,都需要符合最左匹配原则。

第2种情况:单表数据量数据过多,导致查询瓶颈的情况。即使SQL语句走了索引,表现性能也不会特别好。这个时候我们需要考虑对表进行切分。表切分规则一般分为两种,一种是水平切分,一种是垂直切分。

水平切分的意思是把一张数据行数达到千万级别的大表,按照业务主键切分为多张小表,这些小表可能达到100张甚至1000张。

那垂直切分的意思是,将一张单表中的多个列,按照业务逻辑把关联性比较大的列放到同一张表中去。

除了这种分表之外,我们还可以分库,

比如我们已经拆分完1000表,然后,把后缀为0-100的表放到同一个数据库实例中,然后,100-200的表放到另一个数据库实例中,依此类推把1000表存放到10个数据库实例中。这样的话,我们就可以根据业务主键把请求路由到不同数据库实例,从而让每一个数据库实例承担的流量比较小,达到提高数据库性能的目的。

第3种情况:网络原因或者机器负载过高的情况,我们可以进行读写分离.

比如MySQL支持一主多从的分布式部署,我们可以将主库只用来处理写数据的操作,而多个从库只用来处理读操作。在流量比较大的场景中,可以增加从库来提高数据库的负载能力,从而提升数据库的总体性能。

第4种情况:热点数据导致单点负载不均衡的情况。

这种情况下,除了对数据库本身的调整以外,还可以增加缓存。将查询比较频繁的热点数据预存到缓存当中,比如Redis、MongoDB、ES等,以此来缓解数据的压力,从而提高数据库的响应速度。

三.java并发编程基础

谈谈你对AQS的理解

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

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

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

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

lock和synchronized区别

从4个方面来回答

  1. 从功能角度来看,Lock和Synchronized都是Java中用来解决线程安全问题的工具。
  2. 从特性来看,
    1. Synchronized是Java中的同步关键字,Lock是J.U.C包中提供的接口,这个接口有很多实现类,其中就包括ReentrantLock重入锁
    2. Synchronized可以通过两种方式来控制锁的粒度,(下图)

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

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

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

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

      1. Lock比Synchronized的灵活性更高,Lock可以自主决定什么时候加锁,什么时候释放锁,只需要调用lock()和unlock()这两个方法就行,同时Lock还提供了非阻塞的竞争锁方法tryLock()方法,这个方法通过返回true/false来告诉当前线程是否已经有其他线程正在使用锁。
    3. Synchronized由于是关键字,所以它无法实现非阻塞竞争锁的方法,另外,Synchronized锁的释放是被动的,就是当Synchronized同步代码块执行完以后或者代码出现异常时才会释放。

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

线程池如何知道一个线程的任务已经执行完成

从两个方面来回答。

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

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

  1. 如果想在线程池外部去获得线程池内部任务的执行状态,有几种方法可以实现。
    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()方法的线程。

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

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

  1. (如图),阻塞队列,是一种特殊的队列,它在普通队列的基础上提供了两个附加功能
    1. 当队列为空的时候,获取队列中元素的消费者线程会被阻塞,同时唤醒生产者线程。
    2. 当队列满了的时候,向队列中添加元素的生产者线程被阻塞,同时唤醒消费者线程。

  1. 其中,阻塞队列中能够容纳的元素个数,通常情况下是有界的,比如我们实例化一个ArrayBlockingList,可以在构造方法中传入一个整形的数字,表示这个基于数组的阻塞队列中能够容纳的元素个数。这种就是有界队列。
  2. 而无界队列,就是没有设置固定大小的队列,不过它并不是像我们理解的那种元素没有任何限制,而是它的元素存储量很大,像LinkedBlockingQueue,它的默认队列长度是Integer.Max_Value,所以我们感知不到它的长度限制。
  3. 无界队列存在比较大的潜在风险,如果在并发量较大的情况下,线程池中可以几乎无限制的添加任务,容易导致内存溢出的问题!

ConcurrentHashMap 底层具体实现知道吗?实现原理是什么?

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

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

--------------------------------------------------------------------------------------------

  1. ConcurrentHashMap的整体架构(字幕提示)

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

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

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

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

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

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

  1. ConcurrentHashMap的基本功能(字幕提示)

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

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

  1. ConcurrentHashMap在性能方面做的优化(字幕提示)

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

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

    • 在JDK1.8中,ConcurrentHashMap锁的粒度是数组中的某一个节点,而在JDK1.7,锁定的是Segment,锁的范围要更大,因此性能上会更低。
    • 引入红黑树,降低了数据查询的时间复杂度,红黑树的时间复杂度是O(logn)。
    • (如图所示),当数组长度不够时,ConcurrentHashMap需要对数组进行扩容,在扩容的实现上,ConcurrentHashMap引入了多线程并发扩容的机制,简单来说就是多个线程对原始数组进行分片后,每个线程负责一个分片的数据迁移,从而提升了扩容过程中数据迁移的效率。

    • ConcurrentHashMap中有一个size()方法来获取总的元素个数,而在多线程并发场景中,在保证原子性的前提下来实现元素个数的累加,性能是非常低的。ConcurrentHashMap在这个方面的优化主要体现在两个点:
      1. 当线程竞争不激烈时,直接采用CAS来实现元素个数的原子递增。
      2. 如果线程竞争激烈,使用一个数组来维护元素个数,如果要增加总的元素个数,则直接从数组中随机选择一个,再通过CAS实现原子递增。它的核心思想是引入了数组来实现对并发更新的负载。

CAS机制吗?

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

讲一下wait和notify这个为什么要在synchronized代码块中?

  1. wait和notify用来实现多线程之间的协调,wait表示让线程进入到阻塞状态,notify表示让阻塞的线程唤醒。
  2. wait和notify必然是成对出现的,如果一个线程被wait()方法阻塞,那么必然需要另外一个线程通过notify()方法来唤醒这个被阻塞的线程,从而实现多线程之间的通信。
  3. (如图)在多线程里面,要实现多个线程之间的通信,除了管道流以外,只能通过共享变量的方法来实现,也就是线程t1修改共享变量s,线程t2获取修改后的共享变量s,从而完成数据通信。

但是多线程本身具有并行执行的特性,也就是在同一时刻,多个线程可以同时执行。在这种情况下,线程t2在访问共享变量s之前,必须要知道线程t1已经修改过了共享变量s,否则就需要等待。

同时,线程t1修改过了共享变量S之后,还需要通知在等待中的线程t2。

所以要在这种特性下要去实现线程之间的通信,就必须要有一个竞争条件控制线程在什么条件下等待,什么条件下唤醒。

  1. 而Synchronized同步关键字就可以实现这样一个互斥条件,也就是在通过共享变量来实现多个线程通信的场景里面,参与通信的线程必须要竞争到这个共享变量的锁资源,才有资格对共享变量做修改,修改完成后就释放锁,那么其他的线程就可以再次来竞争同一个共享变量的锁来获取修改后的数据,从而完成线程之前的通信。
  2. 所以这也是为什么wait/notify需要放在Synchronized同步代码块中的原因,有了Synchronized同步锁,就可以实现对多个通信线程之间的互斥,实现条件等待和条件唤醒。
  3. 另外,为了避免wait/notify的错误使用,jdk强制要求把wait/notify写在同步代码块里面,否则会抛出IllegalMonitorStateException
  4. 最后,基于wait/notify的特性,非常适合实现生产者消费者的模型,比如说用wait/notify来实现连接池就绪前的等待与就绪后的唤醒。

你是怎么理解线程安全问题的?

所谓线程安全问题,简单来说,就是在多个线程访问某个方法或者对象的时候,不管通过任何的方式调用以及线程如何去交替执行。

在程序中不做任何同步干预操作的情况下,这个方法或者对象的执行/修改都能按照预期的结果来反馈。

我这样去解释,大家可能会有点懵逼。

实际上,线程安全问题的具体表现在三个方面,原子性、有序性、可见性。

原子性呢,是指当一个线程执行一系列程序指令操作的时候,它应该是不可中断的,因为一旦出现中断,站在多线程的视角来看,这一系列的程序指令会出现前后执行结果不一致的问题。

这个和数据库里面的原子性是一样的,就是一段程序只能由一个线程完整的执行完成,而不能存在多个线程干扰。

(如图)CPU的上下文切换,是导致原子性问题的核心,而JVM里面提供了Synchronized关键字来解决原子性问题。

可见性,就是说在多线程环境下,由于读和写是发生在不同的线程里面,有可能出现某个线程对共享变量的修改,对其他线程不是实时可见的。

导致可见性问题的原因有很多,比如CPU的高速缓存、CPU的指令重排序、编译器的指令重排序。

有序性,指的是程序编写的指令顺序和最终CPU运行的指令顺序可能出现不一致的现象,这种现象也可以称为指令重排序,所以有序性也会导致可见性问题。

可见性和有序性可以通过JVM里面提供了一个Volatile关键字来解决。

在我看来,导致有序性、原子性、可见性问题的本质,是计算机工程师为了最大化提升CPU利用率导致的。比如为了提升CPU利用率,设计了三级缓存、设计了StoreBuffer、设计了缓存行这种预读机制、在操作系统里面,设计了线程模型、在编译器里面,设计了编译器的深度优化机制。

什么是守护线程,它有什么特点

简单来说,守护线程就是一种后台服务线程,他和我们在Java里面创建的用户线程是一模一样的。

守护线程和用户线程的区别有几个点,这几个点也是守护线程本身的特性:

  1. 在线程创建方面,对于守护线程,我们需要主动调用setDaemon()并且设置成true。
  2. 我们知道,一个Java进程中,只要有任何一个用户线程还在运行,那么这个java进程就不会结束,否则,这个程序才会终止。

注意,Java进程的终止与否,只和用户线程有关。如果当前还有守护线程正在运行,也不会阻止Java程序的终止。

因此,守护线程的生命周期依赖于用户线程。

举个例子,JVM垃圾回收线程就是一个典型的守护线程,它存在的意义是不断的处理用户线程运行过程中产生的内存垃圾。

一旦用户线程全部结束了,那垃圾回收线程也就没有存在的意义了。

由于守护线程的特性,所以它它适合用在一些后台的通用服务场景里面。

但是守护线程不能用在线程池或者一些IO任务的场景里面,因为一旦JVM退出之后,守护线程也会直接退出。

就会可能导致任务没有执行完或者资源没有正确释放的问题。

AbstractQueuedSynchronized为什么采用双向链表

从两个方面给大家解释一下这个问题:

第一个方面,双向链表的优势:

  1. 双向链表提供了双向指针,可以在任何一个节点方便向前或向后进行遍历,这种对于有反向遍历需求的场景来说非常有用。
  2. 双向链表可以在任意节点位置实现数据的插入和删除,并且这些操作的时间复杂度都是 O(1),不受链表长度的影响。这对于需要频繁对链表进行增删操作的场景非常有用。

第二个方面,说一下AQS采用双向链表的原因

  1. 存储在双向链表中的线程,有可能这个线程出现异常不再需要竞争锁,所以需要把这些异常节点从链表中删除,而删除操作需要找到这个节点的前驱结点,如果不采用双向链表,就必须要从头节点开始遍历,时间复杂度就变成了O(n)。

  1. 新加入到链表中的线程,在进入到阻塞状态之前,需要判断前驱节点的状态,只有前驱节点是Sign状态的时候才会让当前线程阻塞,所以这里也会涉及到前驱节点的查找,采用双向链表能够更好的提升查找效率

  1. 线程在加入到链表中后,会通过自旋的方式去尝试竞争锁来提升性能,在自旋竞争锁的时候为了保证锁竞争的公平性,需要先判断当前线程所在节点的前驱节点是否是头节点。这个判断也需要获取当前节点的前驱节点,同样采用双向链表能提高查找效率。

总而言之,采用单向链表不支持双向遍历,而AQS中存在很多需要双向遍历的场景来提升线程阻塞和唤醒的效率。

volatile关键字有什么用?它的实现原理是什么?

volatile关键字有两个作用。

  1. 可以保证在多线程环境下共享变量的可见性。
  2. 通过增加内存屏障防止多个指令之间的重排序。

我理解的可见性,是指当某一个线程对共享变量的修改,其他线程可以立刻看到修改之后的值。

其实这个可见性问题,我认为本质上是由几个方面造成的。

  1. (如图)CPU层面的高速缓存,在CPU里面设计了三级缓存去解决CPU运算效率和内存IO效率问题,但是带来的就是缓存的一致性问题,而在多线程并行执行的情况下,缓存一致性就会导致可见性问题。

所以,对于增加了volatile关键字修饰的共享变量,JVM虚拟机会自动增加一个#Lock汇编指令,这个指令会根据CPU型号自动添加总线锁或/缓存锁

我简单说一下这两种锁,

    • 总线锁是锁定了CPU的前端总线,从而导致在同一时刻只能有一个线程去和内存通信,这样就避免了多线程并发造成的可见性。
    • 缓存锁是对总线锁的优化,因为总线锁导致了CPU的使用效率大幅度下降,所以缓存锁只针对CPU三级缓存中的目标数据加锁,缓存锁是使用MESI缓存一致性来实现的。
  1. 指令重排序,所谓重排序,就是指令的编写顺序和执行顺序不一致,在多线程环境下导致可见性问题。指令重排序本质上是一种性能优化的手段,它来自于几个方面。
    • CPU层面,针对MESI协议的更进一步优化去提升CPU的利用率,引入了StoreBuffer机制,而这一种优化机制会导致CPU的乱序执行。当然为了避免这样的问题,CPU提供了内存屏障指令,上层应用可以在合适的地方插入内存屏障来避免CPU指令重排序问题。
    • 编译器的优化,编译器在编译的过程中,在不改变单线程语义和程序正确性的前提下,对指令进行合理的重排序优化来提升性能。

所以,如果对共享变量增加了volatile关键字,那么在编译器层面,就不会去触发编译器优化,同时再JVM里面,会插入内存屏障指令来避免重排序问题。

当然,除了volatile以外,从JDK5开始,JMM就使用了一种Happens-Before模型去描述多线程之间的内存可见性问题。

如果两个操作之间具备Happens-Before关系,那么意味着这两个操作具备可见性关系,不需要再额外去考虑增加volatile关键字来提供可见性保障。

ThreadLocal是什么?它的实现原理呢?

从三个方面来回答。

  1. ThreadLocal是一种线程隔离机制,它提供了多线程环境下对于共享变量访问的安全性。
  2. 在多线程访问共享变量的场景中(出现下面第一个图),一般的解决办法是对共享变量加锁(出现下面第二个图),从而保证在同一时刻只有一个线程能够对共享变量进行更新,并且基于Happens-Before规则里面的监视器锁规则,又保证了数据修改后对其他线程的可见性。

  1. 但是加锁会带来性能的下降,所以ThreadLocal用了一种空间换时间的设计思想,也就是说在每个线程里面,都有一个容器来存储共享变量的副本,然后每个线程只对自己的变量副本来做更新操作,这样既解决了线程安全问题,又避免了多线程竞争加锁的开销。

  1. ThreadLocal的具体实现原理是,在Thread类里面有一个成员变量ThreadLocalMap,它专门来存储当前线程的共享变量副本,后续这个线程对于共享变量的操作,都是从这个ThreadLocalMap里面进行变更,不会影响全局共享变量的值。

?ArrayBlockingQueue 原理

  1. (如图)阻塞队列(BlockingQueue)是在队列的基础上增加了两个附加操作,
    • 在队列为空的时候,获取元素的线程会等待队列变为非空。
    • 当队列满时,存储元素的线程会等待队列可用。
  2. 由于阻塞队列的特性,可以非常容易实现生产者消费者模型,也就是生产者只需要关心数据的生产,消费者只需要关注数据的消费,所以如果队列满了,生产者就等待,同样,队列空了,消费者也需要等待。
  3. 要实现这样的一个阻塞队列,需要用到两个关键的技术,队列元素的存储、以及线程阻塞和唤醒。
  4. 而ArrayBlockingQueue是基于数组结构的阻塞队列,也就是队列元素是存储在一个数组结构里面,并且由于数组有长度限制,为了达到循环生产和循环消费的目的,ArrayBlockingQueue用到了循环数组。
  5. 而线程的阻塞和唤醒,用到了J.U.C包里面的ReentrantLock和Condition。 Condition相当于wait/notify在JUC包里面的实现。

怎么理解线程安全?

简单来说,在多个线程访问某个方法或者对象的时候,不管通过任何的方式调用以及线程如何去交替执行。

在程序中不做任何同步干预操作的情况下,这个方法或者对象的执行/修改都能按照预期的结果来反馈,那么这个类就是线程安全的。

实际上,线程安全问题的具体表现体现在三个方面,原子性、有序性、可见性。

原子性呢,是指当一个线程执行一系列程序指令操作的时候,它应该是不可中断的,因为一旦出现中断,站在多线程的视角来看,这一系列的程序指令会出现前后执行结果不一致的问题。

这个和数据库里面的原子性是一样的,简单来说就是一段程序只能由一个线程完整的执行完成,而不能存在多个线程干扰。

(如图)CPU的上下文切换,是导致原子性问题的核心,而JVM里面提供了Synchronized关键字来解决原子性问题。

可见性,就是说在多线程环境下,由于读和写是发生在不同的线程里面,有可能出现某个线程对共享变量的修改,对其他线程不是实时可见的。

导致可见性问题的原因有很多,比如CPU的高速缓存、CPU的指令重排序、编译器的指令重排序。

有序性,指的是程序编写的指令顺序和最终CPU运行的指令顺序可能出现不一致的现象,这种现象也可以称为指令重排序,所以有序性也会导致可见性问题。

可见性和有序性可以通过JVM里面提供了一个Volatile关键字来解决。

在我看来,导致有序性、原子性、可见性问题的本质,是计算机工程师为了最大化提升CPU利用率导致的。比如为了提升CPU利用率,设计了三级缓存、设计了StoreBuffer、设计了缓存行这种预读机制、在操作系统里面,设计了线程模型、在编译器里面,设计了编译器的深度优化机制。

请简述一下伪共享的概念以及如何避免

计算机工程师为了提高CPU的利用率,平衡CPU和内存之间的速度差异,在CPU里面设计了三级缓存。

CPU在向内存发起IO操作的时候,一次性会读取64个字节的数据作为一个缓存行,缓存到CPU的高速缓存里面。

在Java中一个long类型是8个字节,意味着一个缓存行可以存储8个long类型的变量。

这个设计是基于空间局部性原理来实现的,也就是说,如果一个存储器的位置被引用,那么将来它附近的位置也会被引用。

所以缓存行的设计对于CPU来说,可以有效的减少和内存的交互次数,从而避免了CPU的IO等待,以提升CPU的利用率。

正是因为这种缓存行的设计,导致如果多个线程修改同一个缓存行里面的多个独立变量的时候,基于缓存一致性协议,就会无意中影响了彼此的性能,这就是伪共享的问题。

(如图)像这样一种情况,CPU0上运行的线程想要更新变量X、CPU1上的线程想要更新变量Y,而X/Y/Z都在同一个缓存行里面。

每个线程都需要去竞争缓存行的所有权对变量做更新,基于缓存一致性协议。

一旦运行在某个CPU上的线程获得了所有权并执行了修改,就会导致其他CPU中的缓存行失效。

这就是伪共享问题的原理。

因为伪共享会问题导致缓存锁的竞争,所以在并发场景中的程序执行效率一定会收到较大的影响。

这个问题的解决办法有两个:

  1. 使用对齐填充,因为一个缓存行大小是64个字节,如果读取的目标数据小于64个字节,可以增加一些无意义的成员变量来填充。
  2. 在Java8里面,提供了@Contented注解,它也是通过缓存行填充来解决伪共享问题的,被@Contented注解声明的类或者字段,会被加载到独立的缓存行上。

什么是可重入,什么是可重入锁? 它用来解决什么问题?

可重入是多线程并发编程里面一个比较重要的概念,

简单来说,就是在运行的某个函数或者代码,因为抢占资源或者中断等原因导致函数或者代码的运行中断,

等待中断程序执行结束后,重新进入到这个函数或者代码中运行,并且运行结果不会受到影响,那么这个函数或者代码就是可重入的。

(如图) 而可重入锁,简单来说就是一个线程如果抢占到了互斥锁资源,在锁释放之前再去竞争同一把锁的时候,不需要等待,只需要记录重入次数。

在多线程并发编程里面,绝大部分锁都是可重入的,比如Synchronized、ReentrantLock等,但是也有不支持重入的锁,比如JDK8里面提供的读写锁StampedLock。

锁的可重入性,主要解决的问题是避免线程死锁的问题。

因为一个已经获得同步锁X的线程,在释放锁X之前再去竞争锁X的时候,相当于会出现自己要等待自己释放锁,这很显然是无法成立的。

ReentrantLock的实现原理?

  1. 什么是ReentrantLock
  2. ReentrantLock的特性
  3. ReentrantLock的实现原理

首先,ReentrantLock是一种可重入的排它锁,主要用来解决多线程对共享资源竞争的问题。

它的核心特性有几个:

  1. 它支持可重入,也就是获得锁的线程在释放锁之前再次去竞争同一把锁的时候,不需要加锁就可以直接访问。
  2. 它支持公平和非公平特性
  3. 它提供了阻塞竞争锁和非阻塞竞争锁的两种方法,分别是lock()和tryLock()。

(如图)然后,ReentrantLock的底层实现有几个非常关键的技术。

  1. 锁的竞争,ReentrantLock是通过互斥变量,使用CAS机制来实现的。
  2. 没有竞争到锁的线程,使用了AbstractQueuedSynchronizer这样一个队列同步器来存储,底层是通过双向链表来实现的。当锁被释放之后,会从AQS队列里面的头部唤醒下一个等待锁的线程。
  3. 公平和非公平的特性,主要是体现在竞争锁的时候,是否需要判断AQS队列存在等待中的线程。
  4. 最后,关于锁的重入特性,在AQS里面有一个成员变量来保存当前获得锁的线程,当同一个线程下次再来竞争锁的时候,就不会去走锁竞争的逻辑,而是直接增加重入次数。

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

简述一下你对线程池的理解?

首先,线程池本质上是一种池化技术,而池化技术是一种资源复用的思想,比较常见的有连接池、内存池、对象池。

而线程池里面复用的是线程资源,它的核心设计目标,我认为有两个:

  1. 减少线程的频繁创建和销毁带来的性能开销,因为线程创建会涉及到CPU上下文切换、内存分配等工作。
  2. 线程池本身会有参数来控制线程创建的数量,这样就可以避免无休止的创建线程带来的资源利用率过高的问题,

起到了资源保护的作用。

其次,我简单说一下线程池里面的线程复用技术。因为线程本身并不是一个受控的技术,也就是说线程的生命周期时由任务运行的状态决定的,无法人为控制。

(图片)所以为了实现线程的复用,线程池里面用到了阻塞队列,简单来说就是线程池里面的工作线程处于一直运行状态,它会从阻塞队列中去获取待执行的任务,一旦队列空了,那这个工作线程就会被阻塞,直到下次有新的任务进来。

也就是说,工作线程是根据任务的情况实现阻塞和唤醒,从而达到线程复用的目的。

最后,线程池里面的资源限制,是通过几个关键参数来控制的,分别是核心线程数、最大线程数。

核心线程数表示默认长期存在的工作线程,而最大线程数是根据任务的情况动态创建的线程,主要是提高阻塞队列中任务的

处理效率。

如何中断一个正在运行的线程?

(如图)首先,线程是系统级别的概念,在Java里面实现的线程,最终的执行和调度都是由操作系统来决定的,JVM只是对操作系统层面的线程做了一层包装而已。

所以我们在Java里面通过start方法启动一个线程的时候,只是告诉操作系统这个线程可以被执行,但是最终交给CPU来执行是操作系统的调度算法来决定的。

因此,理论上来说,要在Java层面去中断一个正在运行的线程,只能像类似于Linux里面的kill命令结束进程的方式一样,强制终止。

所以,Java Thread里面提供了一个stop方法可以强行终止,但是这种方式是不安全的,因为有可能线程的任务还没有,导致出现运行结果不正确的问题。

要想安全的中断一个正在运行的线程,只能在线程内部埋下一个钩子,外部程序通过这个钩子来触发线程的中断命令。

(如图)因此,在Java Thread里面提供了一个interrupt()方法,这个方法配合isInterrupted()方法使用,就可以实现安全的中断机制。

这种实现方法并不是强制中断,而是告诉正在运行的线程,你可以停止了,不过是否要中断,取决于正在运行的线程,所以它能够保证线程运行结果的安全性。

为什么引入偏向锁、轻量级锁,介绍下升级流程

  1. Synchronized在jdk6版本之前,是通过重量级锁的方式来实现线程之间锁的竞争。

之所以称它为重量级锁,是因为它的底层底层依赖操作系统的Mutex Lock来实现互斥功能。

(如图)Mutex是系统方法,由于权限隔离的关系,应用程序调用系统方法时需要切换到内核态来执行。

这里涉及到用户态向内核态的切换,这个切换会带来性能的损耗。

????????2.在jdk1.6版本中,synchronized增加了锁升级的机制,来平衡数据安全性和性能。简单来说,就是线程去访问synchronized同步代码块的时候,synchronized根据

线程竞争情况,会先尝试在不加重量级锁的情况下去保证线程安全性。所以引入了偏向锁和轻量级锁的机制。

偏向锁,就是直接把当前锁偏向于某个线程,简单来说就是通过CAS修改偏向锁标记,这种锁适合同一个线程多次去申请同一个锁资源并且没有其他线程竞争的场景。

轻量级锁也可以称为自旋锁,基于自适应自旋的机制,通过多次自旋重试去竞争锁。自旋锁优点在于它避免避免了用户态到内核态的切换带来的性能开销。

?????????3.(如图)Synchronized引入了锁升级的机制之后,如果有线程去竞争锁:

??? 首先,synchronized会尝试使用偏向锁的方式去竞争锁资源,如果能够竞争到偏向锁,表示加锁成功直接返回。如果竞争锁失败,说明当前锁已经偏向了其他线程。

??? 需要将锁升级到轻量级锁,在轻量级锁状态下,竞争锁的线程根据自适应自旋次数去尝试抢占锁资源,如果在轻量级锁状态下还是没有竞争到锁,

??? 就只能升级到重量级锁,在重量级锁状态下,没有竞争到锁的线程就会被阻塞,线程状态是Blocked。

??? 处于锁等待状态的线程需要等待获得锁的线程来触发唤醒。

总的来说, Synchronized的锁升级的设计思想,在我看来本质上是一种性能和安全性的平衡,也就是如何在不加锁的情况下能够保证线程安全性。

这种思想在编程领域比较常见,比如Mysql里面的MVCC使用版本链的方式来解决多个并行事务的竞争问题。

ReentrantLock 是如何实现锁公平和非公平性的 ?

我先解释一下个公平和非公平的概念。

公平,指的是竞争锁资源的线程,严格按照请求顺序来分配锁。

非公平,表示竞争锁资源的线程,允许插队来抢占锁资源。

ReentrantLock默认采用了非公平锁的策略来实现锁的竞争逻辑。

(如图)其次,ReentrantLock内部使用了AQS来实现锁资源的竞争,

没有竞争到锁资源的线程,会加入到AQS的同步队列里面,这个队列是一个FIFO的双向链表。

在这样的一个背景下,公平锁的实现方式就是,线程在竞争锁资源的时候判断AQS同步队列里面有没有等待的线程。

如果有,就加入到队列的尾部等待。

而非公平锁的实现方式,就是不管队列里面有没有线程等待,它都会先去尝试抢占锁资源,如果抢不到,再加入到

AQS同步队列等待。

ReentrantLock和Synchronized默认都是非公平锁的策略,之所以要这么设计,我认为还是考虑到了性能这个方面的原因。

因为一个竞争锁的线程如果按照公平的策略去阻塞等待,同时AQS再把等待队列里面的线程唤醒,这里会涉及到内核态

的切换,对性能的影响比较大。

如果是非公平策略,当前线程正好在上一个线程释放锁的临界点抢占到了锁,就意味着这个线程不需要切换到内核态,

虽然对原本应该要被唤醒的线程不公平,但是提升了锁竞争的性能。

说一下你对CompletableFuture的理解

CompletableFuture是JDK1.8里面引入的一个基于事件驱动的异步回调类。

简单来说,就是当使用异步线程去执行一个任务的时候,我们希望在任务结束以后触发一个后续的动作。

而CompletableFuture就可以实现这个功能。

(如图),举个简单的例子,比如在一个批量支付的业务逻辑里面,

涉及到查询订单、支付、发送邮件通知这三个逻辑。

这三个逻辑是按照顺序同步去实现的,也就是先查询到订单以后,再针对这个订单发起支付,支付成功以后再发送邮件通知。

而这种设计方式导致这个方法的执行性能比较慢。

所以,这里可以直接使用CompletableFuture,(如图),也就是说把查询订单的逻辑放在一个异步线程池里面去处理。

然后基于CompletableFuture的事件回调机制的特性,可以配置查询订单结束后自动触发支付,支付结束后自动触发邮件通知。

从而极大的提升这个这个业务场景的处理性能!

CompletableFuture提供了5种不同的方式,把多个异步任务组成一个具有先后关系的处理链,然后基于事件驱动任务链的执行。

  1. 第一种,thenCombine(如图),把两个任务组合在一起,当两个任务都执行结束以后触发事件回调。

  1. 第二种,thenCompose(如图),把两个任务组合在一起,这两个任务串行执行,

也就是第一个任务执行完以后自动触发执行第二个任务。

  1. 第三种,thenAccept(如图),第一个任务执行结束后触发第二个任务,

并且第一个任务的执行结果作为第二个任务的参数,这个方法是纯粹接受上一个任务的结果,不返回新的计算值。

  1. 第四种,thenApply(如图),和thenAccept一样,但是它有返回值。

  1. 第五种,thenRun(如图),就是第一个任务执行完成后触发执行一个实现了Runnable接口的任务。

最后,我认为,CompletableFuture弥补了原本Future的不足,使得程序可以在非阻塞的状态下完成异步的回调机制。

线程状态,BLOCKED和WAITING有什么区别

BLOCKED和WAITING都是属于线程的阻塞等待状态。

BLOCKED状态是指线程在等待监视器锁的时候的阻塞状态。

(如图)也就是在多个线程去竞争Synchronized同步锁的时候,没有竞争到锁资源的线程,会被阻塞等待,这个时候线程状态就是BLOCKED。

在线程的整个生命周期里面,只有Synchronized同步锁等待才会存在这个状态。

WAITING状态,表示线程的等待状态,在这种状态下,线程需要等待某个线程的特定操作才会被唤醒。我们可以使用Object.wait()、Object.join()、LockSupport.park()这些方法

使得线程进入到WAITING状态,在这个状态下,必须要等待特定的方法来唤醒,

比如Object.notify方法可以唤醒Object.wait()方法阻塞的线程

LockSupport.unpark()可以唤醒LockSupport.park()方法阻塞的线程。

所以,在我看来,BLOCKED和WAITING两个状态最大的区别有两个:

  1. BLOCKED是锁竞争失败后被被动触发的状态,WAITING是人为的主动触发的状态
  2. BLCKED的唤醒时自动触发的,而WAITING状态是必须要通过特定的方法来主动唤醒

Thread和Runnable的区别

  1. Thread是一个类,Runnable是接口,因为在Java语言里面的继承特性,接口可以支持多继承,而类只能单一继承。所以如果在已经存在继承关系的类里面要实现线程的话,只能实现Runnable接口。
  2. Runnable表示一个线程的顶级接口,Thread类其实也是实现了Runnable接口。
  3. 站在面向对象的思想来说,Runnable相当于一个任务,而Thread才是真正处理的线程,所以我们只需要用Runnable去定义一个具体的任务,然后交给Thread去处理就可以了,这样达到了松耦合的设计目的
  4. Runnable接口定义了线程执行任务的标准方法 run,所以它

所以,基于这四个点的原因,所以在实际应用中,建议实现Runnable接口实现线程的任务定义,然后使用Thread的start方法

去启动启动线程并执行Runnable这个任务。

文章来源:https://blog.csdn.net/gupaoedu_tom/article/details/135596985
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。