🍅 作者简介:Tom,咕泡科技的联合创始人,兼任CTO(首席技术官)。著有畅销书《设计模式就该这样学》、《Netty4核心原理与手写RPC框架实战》、《Spring5核心原理与30个类手写实战》,也是电子工业出版社“Java架构师成长丛书”长期签约作者。
爆肝一个月,总结的2024春招面试题,总共50w字本文会慢慢更新完,完整版可以先去我公众号领取
文末点击我的名片就可以就可以跳转到我的公众号或者微信了
最近有很多粉丝问我,有什么方法能够快速提升自己,通过阿里、腾讯、字节跳动、京东等互联网大厂的面试,我觉得短时间提升自己最快的手段就是背面试题,最近总结了Java常用的面试题,分享给大家,希望大家都能上岸,加油,奥里给!!!
目录
fail-safe机制与fail-fast机制分别有什么作用
String、StringBuffer、StringBuilder区别
Integer a1=100 Integer a2=100,a1==a2?的运行结果?
为什么重写 equals() 就一定要重写 hashCode() 方法?
ConcurrentHashMap 底层具体实现知道吗?实现原理是什么?
讲一下wait和notify这个为什么要在synchronized代码块中?
AbstractQueuedSynchronized为什么采用双向链表
ReentrantLock 是如何实现锁公平和非公平性的 ?
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等。
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,比如流忘记关闭?连接忘记释放等。
首先,在Java里面,动态代理是通过Proxy.newProxyInstance()方法来实现的,它需要传入被动态代理的接口类。
之所以要传入接口,不能传入类,还是取决于JDK动态代理的底层实现(如图)。
JDK动态代理会在程序运行期间动态生成一个代理类$Proxy0,这个动态生成的代理类会继承java.lang.reflect.Proxy类,同时还会实现被代理类的接口IHelloService。
在Java中,是不支持多重继承的。而每个动态代理类都会继承Proxy类(这也是JDK动态代理的实现规范),所以就导致JDK里面的动态代理只能代理接口,而不能代理实现类。
(注意,下面这张图片展示的时候,上面的图片仍然保存在一个画面里面)
我分析过动态代理的源码,发现Proxy这个类只是保存了动态代理的处理器InvocationHandler,如果不抽出来,直接设置到$Proxy0动态代理类里面,也是可以的。
如果这么做,就可以针对实现类来做动态代理了。作者为什么这么设计,我认为有几个方面的原因。
总的来说,我认为这个设计上并没有什么特别值得讨论的地方,因为我认为技术方案的设计是解决特定场景问题的。
如果一定要针对普通类来做动态代理,可以选择cglib这个组件,它会动态生成一个被代理类的子类,子类重写了父类中所有非final修饰的方法,在子类中拦截父类的所有方法调用从而实现动态代理。
(如图)在实例化一个对象的时候,JVM首先会去检查目标对象是否已经被加载并初始化了。
如果没有,JVM需要立刻去加载目标类,然后调用目标类的构造器完成初始化。 目标类的加载是通过类加载器来实现的,主要就是把一个类加载到内存里面。
然后初始化的过程,主要是对目标类里面的静态变量、成员变量、静态代码块进行初始化。
当目标类被初始化以后,就可以从常量池里面找到对应的类元信息,并且目标对象的大小在类加载之后就已经确定了,所以这个时候就需要为新创建的对象,根据目标对象的大小在堆内存里面分配内存空间。
内存分配的方式一般有两种,一种指针碰撞,另一种是空闲列表,JVM会根据Java堆内存是否规整来决定内存分配方式。
接下来,JVM会把目标对象里面的普通成员变量初始化为零值,比如int类型初始化为0,对象类型初始化为null,(类变量在类加载的准备阶段就已经初始化过了)。
这一步操作主要是保证对象里面的实例字段,不用初始化就可以直接使用,也就是程序能够获得这些字段对应数据类型的零值。
然后,JVM还需要对目标对象的对象头做一些设置,比如对象所属的类元信息、对象的GC分代年龄、hashcode、锁标记等等。
完成这些步骤以后,对于JVM来说,新对象的创建工作已经完成。但是基于Java语言来说,对象创建才算是开始。
接下来要做的,就是执行目标对象内部生成的init方法,初始化成员变量的值、执行构造块、最后执行目标对象的构造方法,完成对象的创建。
其中,init方法是Java文件编译之后在字节码文件中生成的,它是一个实例构造器,这个构造器会把语句块、变量初始化、调用父类构造器等操作组织在一起。所以调用init方法能够完成一系列的初始化动作。
首先,这个代码里面有一个new关键字,这个关键字是在程序运行时,根据已经加载的系统类String,在堆内存里面实例化的一个字符串对象。
然后,在这个String的构造方法里面,传递了一个“abc”字符串,因为String里面的字符串成员变量是final修饰的,所以它是一个字符串常量。
接下来,JVM会拿字面量“abc” 去字符串常量池里面试图去获取它对应的String对象引用,如果拿不到,就会在堆内存里面创建一个”abc”的String对象
并且把引用保存到字符串常量池里面。
后续如果再有字面量“abc”的定义,因为字符串常量池里面已经存在了字面量“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内部的value值是final修饰的,所以它是不可变类。所以每次修改String的值,都会产生一个新的对象。
StringBuffer和StringBuilder是可变类,字符串的变更不会产生新的对象。
第二个,线程安全性,
String是不可变类,所以它是线程安全的。
StringBuffer是线程安全的,因为它每个操作方法都加了synchronized同步关键字。
StringBuilder不是线程安全的。
所以在多线程环境下对字符串进行操作,应该使用StringBuffer,否则使用StringBuilder
第三个,性能方面。
String的性能是最的低的,因为不可变意味着在做字符串拼接和修改的时候,需要重新创建新的对象以及分配内存。
其次是StringBuffer要比String性能高,因为它的可变性使得字符串可以直接被修改
最后是StringBuilder,它比StringBuffer的性能高,因为StringBuffer加了同步锁。
第四个,存储方面。
String存储在字符串常量池里面
StringBuffer和StringBuilder存储在堆内存空间。?
最后再补充一下, StringBuilder和StringBuffer都是派生自AbstractStringBuilder这个抽象类。
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是一个数组结构的存储容器,默认情况下,数组的长度是10.
当然我们也可以在构建ArrayList对象的时候自己指定初始长度。
随着在程序里面不断的往ArrayList中添加数据,当添加的数据达到10个的时候,
ArrayList就没有多余容量可以存储后续的数据。
这个时候ArrayList会自动触发扩容。
扩容的具体流程很简单,
扩容完成后再把当前要添加的元素加入到新的数组里面,从而完成动态扩容的过程。
深拷贝和浅拷贝是用来描述对象或者对象数组这种引用数据类型的复制场景的。
浅拷贝,(如图)就是只复制某个对象的指针,而不复制对象本身。
这种复制方式意味着两个引用指针指向被复制对象的同一块内存地址。
深拷贝,(如图)会完全创建一个一模一样的新对象,新对象和老对象不共享内存,
也就意味着对新对象的修改不会影响老对象的值。
在Java里面,无论是深拷贝还是浅拷贝,都需要通过实现Cloneable接口,并实现clone()方法。
然后我们可以在clone()方法里面实现浅拷贝或者深拷贝的逻辑。
实现深拷贝的方法有很多,比如
(如图)
之所以要对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冲突的概率。
在任何语言中,我们希望在内存中临时存放一些数据,可以用一些官方封装好的集合(如图),
比如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.io包下的库,使用FileInputStream读取,再使用FileOutputStream写出。
第二种,利用java.nio包下的库,使用transferTo或transfFrom方法实现。
第三种,Java 标准类库本身已经提供了 Files.copy 的实现。
对于 Copy 的效率,这个其实与操作系统和配置等情况相关,
在传统的文件IO操作里面,我们都是调用操作系统提供的底层标准IO系统调用函数? read()、write() ,
由于内核指令的调用会使得当前用户线程切换到内核态,然后内核线程负责把相应的文件数据读取到内核的IO缓冲区,
再把数据从内核IO缓冲区拷贝到进程的私有地址空间中去,这样便完成了一次IO操作。
而NIO里面提供的NIO transferTo和transfFrom方法,也就是常说的零拷贝实现。
它能够利用现代操作系统底层机制,避免不必要拷贝和上下文切换,因此在性能上表现比较好。
大致按照模式的应用目标分类,设计模式可以分为创建型模式、结构型模式和行为型模式。
创建型模式,是对对象创建过程的各种问题和解决方案的总结,包括各种工厂模式、单例模式、构建器模式、原型模式。
结构型模式,是针对软件设计结构的总结,关注于类、对象继承、组合方式的实践经验。
常见的结构型模式,包括桥接模式、适配器模式、装饰者模式、代理模式、组合模式、外观模式、享元模式等。
行为型模式,是从类或对象之间交互、职责划分等角度总结的模式。
比较常见的行为型模式有策略模式、解释器模式、命令模式、观察者模式、迭代器模式、模板方法模式、访问者模式。
finally语句块在两种情况下不会执行:
问题解析
单例模式,就是一个类在任何情况下绝对只有一个实例,并且提供一个全局访问点来获取该实例。
要实现单例,至少需要满足两个点:
在Java里面,至少有6种方法来实现单例。
(如图)第一种,是最简单的实现,通过延迟加载的方式进行实例化,并且增加了同步锁机制避免多线程环境下
的线程安全问题。
但是这种加锁会造成性能问题,而且同步锁只有在第一次实例化的时候才产生作用,后续不需要。
(如图)于是有了第二种改进方案,通过双重检查锁的方式,减少了锁的范围来提升性能
(如图)第三种,通过饿汉式实现单例。
这种方式在类加载的时候就触发了实例化,从而避免了多线程同步问题。
还有一种与这个方式类似的实现(如图)
通过在静态块里面实例化,而静态块是在类加载的时候触发执行的,所以也只会执行一次。
上面两种方式,都是在类加载的时候初始化,没有达到延迟加载的效果,当然本身影响不大,但是
其实还是可以更进一步优化,就是可以在使用的时候去触发初始化(如图)。
像这种写法,把INSTANCE写在一个静态内部类里面,
由于静态内部类只有调用静态内部类的方法,静态域,或者构造方法的时候才会加载静态内部类。
所以当Singleton被加载的时候不会初始化INSTANCE,从而实现了延迟加载。
另外,我们还可以使用枚举类来实现(如图)。
这种写法既能避免多线程同步问题,又能防止反序列化重新创建新对象,也是一个比较好的方案。
当然,除了这些方案以外,也许还有更多的写法,只需要满足单例模式的特性就行了。
说人话
我认为可以通过3种方式来实现单例,
第一种是通过双重检查锁的方式,它是一种线程安全并且是延迟实例化的方式,
但是因为加锁,所以会有性能上的影响。
第二种是通过静态内部类的方式实现,它也是一种延迟实例化,
由于它是静态内部类,所以只会使用的时候加载一次,不存在线程安全问题。
第三种是通过枚举类的方式实现,它既是线程安全的,又能防止反序列化导致破坏单例问题。
但是,多线程、克隆、反序列化、反射,都有可能会造成单例的破坏。
而我认为,通过枚举的方式实现单例,是能够解决所有可能被破坏的情况。
问题解析
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,需要满足几个基本的格式(如图):
然后根据上下文场景选择实现类完成功能的调用。
Java SPI有一定的不足之处,比如,不能根据需求去加载扩展实现,每次都会加载扩展接口的所有实现类并进行实例化,
实例化会造成性能开销,并且加载一些不需要用到的实现类,会导致内存资源的浪费,
好了,下面看看高手的回答。
讲人话
Java SPI是Java里面提供的一种接口扩展机制。
它的作用我认为有两个:
除了Java的SPI以外,基于SPI思想的扩展实现还有很多,比如Spring里面的SpringFactoriesLoader。
Dubbo里面的ExtensionLoader,并且Dubbo还在SPI基础上做了更进一步优化,
提供了激活扩展点、自适应扩展点。
问题分析
Integer是基本数据类型int的封装类
在Java里面,有八种基本数据类型,他们都有一一对应的封装类型。
基本类型和封装类型的区别有很多,比如
要是真正列数出来,还可以挖掘更多的差异点。
在Java里面,之所以要对基础类型设计一个对应的封装类型。
是因为Java本身是一门面向对象的语言,对象是Java语言的基础单元,我们时时刻刻都在创建对象,也随时都在使用对象,
很多时候在传递数据时也需要对象类型,比如像ArrayList、HashMap这些集合,只能存储对象类型,
因此从这个点来说,封装类型存在的意义就很大。
其次,封装类型还有很多好处,比如
下面来看看高手的回答
讲人话
Integer和int的区别有很多,我简单说3个方面
至于为什么要设计封装类型,最主要的原因是Java本身是面向对象的语言,一切操作都是以对象作为基础。
比如像集合里面存储的元素,也只支持存储Object类型,普通类型无法通过集合来存储。
问题分析
按照大家对于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地址空间。
问题分析
Hashtable和HashMap都是一个基于hash表实现的K-V结构的集合。
Hashtable是JDK1.0引入的一个线程安全的集合类,因为所有数据访问的方法都加了一个Synchronized同步锁。
Hashtable内部采用数组加链表来实现,链表用来解决hash冲突的问题。
HashMap是JDK1.2引入的一个线程不安全的集合类,
HashMap内部也是采用了数组加链表实现,在JDK1.8版本里面做了优化,引入了红黑树。
当链表长度大于等于8并且数组长度大于64的时候,就会把链表转化为红黑树,提升数据查找性能。
讲人话
最后,他们两个的key的散列算法不同,HashTable直接是使用key的hashcode对数组长度做取模。
而HashMap对key的hashcode做了二次散列,从而避免key的分布不均匀问题影响到查询性能。
问题分析
反射是Java语言里面比较重要的一个特征。
它能够在程序运行的过程中去构造任意一个类对象、并且可以获取任意一个类的成员变量、成员方法、属性,以及调用任意一个对象的方法。
通过反射的能力,可以让Java语言支持动态获取程序信息以及动态调用方法的能力。
在Java里面,专门有一个java.lang.reflect用来实现反射相关的类库,包括Construct、Field、Method等类,
分别用来获取类的构造方法、成员变量、方法信息。
反射的使用场景还挺多的,比如在动态代理的场景中,使用动态生成的代理类来提升代码的复用性。
在Spring框架中,有大量用到反射,比如用反射来实例化Bean对象。
讲人话
Java反射的优点:
Java反射的缺点:
问题分析
关于这个问题,首先需要深入了解一下equals这个方法。
这个equals方法是String这个类里面的实现。
从代码中可以看到,当调用equals比较两个对象的时候,会做两个操作
那equals和hashCode()有什么关系呢?
而如果用equals效率太低,所以一般是直接用对象的hashCode的值进行取模运算。
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种设计模式
而在实际开发中,用到的设计模式屈指可数,主要有两方面的原因
但是,设计模式确实是无数前辈在软件开发过程中总结的一些经验,他们能够使得程序更加灵活可扩展
有人把它总结成了公式化的23种设计模式,导致大家以为按照这个公式去搬运就可以,但实际上我认为。
设计模式应该是一种软件设计的思想或者方法论,它不应该固化成某种特定的公式,它的运用应该更加灵活。
这23种设计模式可以分成三种类型分别是创建型、结构型、行为型。
策略模式和观察者模式属于行为型模式。
行为型模式主要用来描述多个类和对象之间的相互协同完成单个对象无法单独完成的任务,除了这两种以外,
还包括模版方法、状态模式、责任链模式、解释器模式等。
讲讲人话
策略模式和观察者模式属于行为型模式。
策略模式主要是用在根据上下文动态控制类的行为的场景,
像支付路由这种场景,就可以使用策略模式实现。
观察者模式主要用在一对多的对象依赖关系的中,实现某一个对象状态变更之后的感知的场景
在Spring源码里面有大量运用这种观察者模式实现事件的传播和感知。
以上就是我的理解。
在实际应用中,如果我们需要把磁盘中的某个文件内容发送到远程服务器上,那么它必须要经过几个拷贝的过程,。从磁盘中读取目标文件内容拷贝到内核缓冲区,CPU 控制器再把内核缓冲区的数据赋值到用户空间的缓冲区中,
接着在应用程序中,调用 write() 方法,把用户空间缓冲区中的数据拷贝到内核下的 Socket Buffer 中。最后,把在内核模式下的 SocketBuffer 中的数据赋值到网卡缓冲区(NIC Buffer)网卡缓冲区再把数据传输到目标服务器上。
在这个过程中我们可以发现,数据从磁盘到最终发送出去,要经历 4 次拷贝,而在这四次拷贝过程中,有两次拷贝是浪费的,分别是:
除此之外,由于用户空间和内核空间的切换会带来CPU的上线文切换,对于CPU性能也会造成性能影响。而零拷贝,就是把这两次多于的拷贝省略掉,应用程序可以直接把磁盘中的数据从内核中直接传输给 Socket,而不需要再经过应用程序所在的用户空间(如图)。
零拷贝通过 DMA(Direct Memory Access)技术把文件内容复制到内核空间中的 Read Buffer,
接着把包含数据位置和长度信息的文件描述符加载到 Socket Buffer 中,DMA 引擎直接可以把数据从内核空间中传递给网卡设备。
在这个流程中,数据只经历了两次拷贝就发送到了网卡中,并且减少了 2 次 cpu的上下文切换,对于效率有非常大的提高
所以,所谓零拷贝,并不是完全没有数据复制,只是相对于用户空间来说,不再需要进行数据拷贝。对于前面说的整个流程来说,零拷贝只是减少了不必要的拷贝次数而已。在程序中如何实现零拷贝呢?
问题分析
在面试过程中遇到的xx技术和xx技术的异同点问题。
大家必须要深刻了解这两种技术的特征和优缺点,如果单纯的去背诵,很难记住。
这个问题中,List大家都已经非常熟悉了。我简单分享一下SortedSet(如图)。
在Java的整个集合体系中,集合可以分成两个体系,一个是Collection存储单个对象的集合,另一个是k-v结构的Map集合
SortedSet是Collection体系下Set接口下的派生类,而Set集合的特征是不包含重复的元素的集合。
了解了这个特点,才能更加准确的回答这个问题。
问题解答
相同点:
不同点:
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源码层面规定了,不允许反射访问枚举。
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的实现。
1、 Mysql的事务隔离级别
Mysql有四种事务隔离级别,这四种隔离级别代表当存在多个事务并发冲突时,可能出现的脏读、不可重复读、幻读的问题。
其中InnoDB在RR的隔离级别下,解决了幻读的问题。
2、 什么是幻读?
那么, 什么是幻读呢?
幻读是指在同一个事务中,前后两次查询相同的范围时,得到的结果不一致(我们来看这个图)
所以,幻读会带来数据一致性问题。
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里面默认的行锁算法。
从三个方面来回答:
二叉树,每个节点支持两个分支的树结构,相比于单向链表,多了一个分支。
二叉查找树,在二叉树的基础上增加了一个规则,左子树的所有节点的值都小于它的根节点,右子树的所有子节点都大于它的根节点。
(如图),二叉查找树会出现斜树问题,导致时间复杂度增加,因此又引入了一种平衡二叉树,它具有二叉查找树的所有特点,同时增加了一个规则:”它的左右两个子树的高度差的绝对值不超过1“。平衡二叉树会采用左旋、右旋的方式来实现平衡。
(如图),而B树是一种多路平衡查找树,它满足平衡二叉树的规则,但是它可以有多个子树,子树的数量取决于关键字的数量,比如这个图中根节点有两个关键字3和5,那么它能够拥有的子路数量=关键字数+1。
因此从这个特征来看,在存储同样数据量的情况下,平衡二叉树的高度要大于B树。
B+树,其实是在B树的基础上做的增强,最大的区别有两个:
(如图所示)这个是B树的存储结构,从B树上可以看到每个节点会存储数据。
(如图所示)这个是B+树,B+树的所有数据是存储在叶子节点,并且叶子节点的数据是用双向链表关联的。
以Mysql中的InnoDB为例,当我们通过select语句去查询一条数据时,InnoDB需要从磁盘上去读取数据,这个过程会涉及到磁盘IO以及磁盘的随机IO(如图所示)
我们知道磁盘IO的性能是特别低的,特别是随机磁盘IO。
因为,磁盘IO的工作原理是,首先系统会把数据逻辑地址传给磁盘,磁盘控制电路按照寻址逻辑把逻辑地址翻译成物理地址,也就是确定要读取的数据在哪个磁道,哪个扇区。
为了读取这个扇区的数据,需要把磁头放在这个扇区的上面,为了实现这一个点,磁盘会不断旋转,把目标扇区旋转到磁头下面,使得磁头找到对应的磁道,这里涉及到寻道事件以及旋转时间。
很明显,磁盘IO这个过程的性能开销是非常大的,特别是查询的数据量比较多的情况下。
所以在InnoDB中,干脆对存储在磁盘块上的数据建立一个索引,然后把索引数据以及索引列对应的磁盘地址,以B+树的方式来存储。
如图所示,当我们需要查询目标数据的时候,根据索引从B+树中查找目标数据即可,由于B+树分路较多,所以只需要较少次数的磁盘IO就能查找到。
MySQL的性能优化我认为可以分为4大部分
硬件及操作系统层面优化
从硬件层面来说,影响Mysql性能的因素有,CPU、可用内存大小、磁盘读写速度、网络带宽
从操作系层面来说,应用文件句柄数、操作系统网络的配置都会影响到Mysql性能。
这部分的优化一般由DBA或者运维工程师去完成。
在硬件基础资源的优化中,我们重点应该关注服务本身承载的体量,然后提出合理的指标要求,避免出现资源浪费!
架构设计层面的优化
MySQL是一个磁盘IO访问量非常频繁的关系型数据库
在高并发和高性能的场景中.MySQL数据库必然会承受巨大的并发压力,而此时,我们的优化方式可以分为几个部分。
MySQL程序配置优化
MySQL是一个经过互联网大厂验证过的生产级别的成熟数据库,对于Mysql数据库本身的优化,一般是通过Mysql中的配置文件my.cnf来完成的,比如。
Mysql5.7版本默认的最大连接数是151个,这个值可以在my.cnf中修改。
binlog日志,默认是不开启
缓存池bufferpoll的默认大小配置等。
由于这些配置一般都和用户安装的硬件环境以及使用场景有关系,因此这些配置官方只会提供一个默认值,具体情况还得由使用者来修改。
关于配置项的修改,需要关注两个方面。
因此,针对这两个点,我们需要注意的是:
SQL优化
SQL优化又能分为三步曲
我们可以通过慢查询日志和慢查询日志分析工具得到有问题的SQL列表。
针对慢SQL,我们可以使用关键字explain来查看当前sql的执行计划.可以重点关注type key rows filterd 等字段 ,从而定位该SQL执行慢的根本原因。再有的放矢的进行优化
Show Profile是MySQL提供的可以用来分析当前会话中,SQL语句资源消耗情况的工具,可用于SQL调优的测量。在当前会话中.默认情况下处于show profile是关闭状态,打开之后保存最近15次的运行结果
针对运行慢的SQL,通过profile工具进行详细分析.可以得到SQL执行过程中所有的资源开销情况.
如IO开销,CPU开销,内存开销等.
以上就是我对MySQL性能优化的理解。
好的,看完高手的回答后,相信各位对MySQL性能优化有了一定的理解了,最后我在给各位总结一下常见的SQL优化规则:
第一种:读读
就是线程A与线程B同时在进行读操作,这种情况下不会出现任何并发问题。
第二种:读写?
就是线程A与线程B在同一时刻分别进行读和写操作。
这种情况下,可能会对数据库中的数据造成以下问题:
第三种:写写
就是线程A与线程B同时进行写操作
这种情况下可能会存在数据更新丢失的问题。
而MVCC就是为了解决事务操作中并发安全性问题的无锁并发控制技术全称为Multi-Version Concurrency Control ,也就是多版本并发控制。它是通过数据库记录中的隐式字段,undo日志 ,Read View 来实现的。
?MVCC主要解决了三个问题
而我们在使用MVCC时一般会根据业务场景来选择组合搭配乐观锁或悲观锁。
这两个组合中,MVCC用来解决读写冲突,乐观锁或者悲观锁解决写写冲突从而最大程度的提高数据库并发性能。
对于一个数据库来说? 存储的数据量会比较多,导致索引也很大? 因此需要将索引存储在磁盘,但是磁盘的IO操作又非常耗,所以提高索引效率的关键在于减少磁盘IO的次数。
举个例子 对于31个节点的树来说 ,一个5阶B+Tree的高度是3 一个红黑树的最小高度是5,树的高度基本决定了磁盘的IO次数 ,所以使用B+Tree性能要高很多
B+Tree有个特点是相邻的数据在物理上也是相邻的,因为B+Tree的node的大小设为一个页,而一个节点上存有多个相邻的关键字和分支信息,每个节点只需要一次IO就能完全载入,相当于一次IO载入了多个相邻的关键字和分支,而红黑树不具有这个特性,红黑树中大小相邻的数据,在物理结构上可能距离相差很大。由于程序的局部性原理,如果我们在索引中采用了预加载的技术,每次磁盘访问的时候除了将访问到的页加载到磁盘,我们还可以基于局部性原理加载,几页相邻的数据到内存中,而这个加载是不需要消耗多余磁盘IO时间的。
因此 基于局部性原理,以及B+Tree存储结构物理上的特性,所以B+Tree的索引性能比红黑树要好很多。
Mysql性能调优方法可以从四个方面来说,分别是
这四个方面的优化成本和优化效果是成反比的。
分库分表、读写分离、为字段选择合适的数据类型、适当的反范式设计,适当冗余设计、 为查询操作创建必要的索引但是要避免索引滥用、尽可能使用Not Null。
通过慢查询分析需要优化的SQL进行合理优化、利用explain、profile等工具分析SQL执行计划、避免使用SELECT *查询。
尽可能使用索引扫描来排序。
一部分人说有,一部分人说没有。
我先说结论,Mysql中的RR事务隔离级别,在特定的情况下会出现幻读的问题。
所谓的幻读,表示在同一个事务中的两次相同条件的查询得到的数据条数不一样。
那在RR级别下,具体什么情况下会出现幻读呢?
来看这样一种情况{如图},在事务1里面通过update语句触发当前读的情况下,就会导致在该事务中的前后两次查询的数据行数不一致,从而出现幻读的现象。
导致幻读的根本原因是,update触发的当前读操作,绕过了快照读,从而导致MVCC机制在当前场景下失效。
最终读取到了事务2中已经提交的数据。
为了避免出现这类的情况,我们可以通过for update语句加锁。
常规的数据库存储引擎,一般都是采用B树或者B+树来实现索引的存储。
(如图)因为B树是一种多路平衡树,用这种存储结构来存储大量数据,它的整个高度会相比二叉树来说,会矮很多。
而对于数据库来说,所有的数据必然都是存储在磁盘上的,而磁盘IO的效率实际上是很低的,特别是在随机磁盘IO的情况下效率更低。
所以树的高度能够决定磁盘IO的次数,磁盘IO次数越少,对于性能的提升就越大,这也是为什么采用B树作为索引存储结构的原因。
(如图)但是在Mysql的InnoDB存储引擎里面,它用了一种增强的B树结构,也就是B+树来作为索引和数据的存储结构。
相比较于B树结构,B+树做了几个方面的优化。
对了,关于Mysql相关的面试题,我给大家准备了1000道带标准答案的面试题,只有10份,大家可以在评论区置顶中去领取。
使用B+树来实现索引的原因,我认为有几个方面。
另外,基于B+树这样一种结构,如果采用自增的整型数据作为主键,还能更好的避免增加数据的时候,带来叶子节点分裂导致的大量运算的问题。
总的来说,我认为技术方案的选型,更多的是去解决当前场景下的特定问题,并不一定是说B+树就是最好的选择,就像MongoDB里面采用B树结构,本质上来说,其实是关系型数据库和非关系型数据库的差异。???
1、 Mysql的事务隔离级别
Mysql有四种事务隔离级别,这四种隔离级别代表当存在多个事务并发冲突时,可能出现的脏读、不可重复读、幻读的问题。
其中InnoDB在RR的隔离级别下,解决了幻读的问题。
2、 什么是幻读?
那么, 什么是幻读呢?
幻读是指在同一个事务中,前后两次查询相同的范围时,得到的结果不一致(我们来看这个图)
所以,幻读会带来数据一致性问题。
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,这个级别中不存在间隙锁。
事务隔离级别,是为了解决多个并行事务竞争导致的数据安全问题的一种规范。
具体来说,多个事务竞争可能会产生三种不同的现象。
而这三种现象在实际应用中,可能有些场景不能接受某些现象的存在,所以在SQL标准中定义了四种隔离级别,分别是:
这四种隔离级别里面,只有串行化解决了全部的问题,但也意味着这种隔离级别的性能是最低的。
在Mysql里面,InnoDB引擎默认的隔离级别是RR(可重复读),因为它需要保证事务ACID特性中的隔离性特征。
问题解析
这个问题其实比较简单,但凡稍微了解过Mysql数据库,都很容易回答出来。
一般考察1~3年的程序员,所以对于这个工作年限的小伙伴要注意了解它们的原理。
首先,binlog和redolog都是Mysql里面用来记录数据库数据变更操作的日志。
{如图}其中binlog主要用来做数据备份、数据恢复和数据同步,大家初步接触这个概念 ,应该是在Mysql的主从数据同步的场景中,master节点的数据变更,会写入到binlog中,然后再把binlog中的数据通过网络传输给slave节点,实现数据同步。
而redolog,主要是在Mysql数据库事务的ACID特性里面,用来保证数据的持久化特性。但是其实它还有很多的作用。
比如数据库崩溃时,可以通过Redo Log来恢复未完成的数据,保证数据的完整性。
通过合理的配置Redo Log的大小和数量,还可以优化Mysql的性能。
那下面来看下这个面试题的回答吧。
问题答案
binlog和redolog的区别有很多,我可以简单总结三个点
redo log记录的是物理变化,也就是数据页的变化结果。
基本上回答到这个程度得到面试官的认可是没问题,不过要注意,面试官还会基于你回答的内容再做更进一步的深度考察。
下面我先来个大家复习一下主从复制的工作原理。
复制过程分为几个步骤:
主从数据同步涉及到网络数据传输,由于网络通信的延迟以及从库数据处理的效率问题,就会导致主从数据同步延迟的情况。
一般可以通过以下几个方法来解决
实际上,主从复制的场景无法避免同步延迟的问题,如果一定要用强一致方案,那就应该考虑其他能够实现一致性场景的技术方案。
第一步,排查问题
第二步,处理方式
第三步,其他情况
如果不是SQL的问题导致,那就需要分析CPU飙高的这个时间段,Mysql的整体并发连接数。
如果有大量的请求连接进来,那我们就需要分析这个时间段业务的情况,再做出相应的调整。
最后,如果是Mysql本身的参数并不是最优状态,那我们可以对Mysql服务节点的配置进行调整,比如缓存大小、线程池大小等
综上所述,处理 MySQL 数据库 CPU 飙升问题需要综合考虑多个方面,包括查询优化、索引优化、MySQL 配置优化、硬件升级、负载均衡等。大家在回答的时候尽可能全面一点去考虑。
准确来说,Binlog有三种格式:statement,row 和 mixed。
如果大家想加深印象更好的应对面试,可以自己搭建Mysql的主从环境,然后通过配置不同方式来查看日志内容。
第一个,索引的优缺点
优点:
缺点:
第二个,索引的类型
以上就是我的回答,大家如果觉得这个回答比较完整,可以点赞收藏一下,后续面试的时候可以复习
从三个方面来回答:
二叉树,每个节点支持两个分支的树结构,相比于单向链表,多了一个分支。
二叉查找树,在二叉树的基础上增加了一个规则,左子树的所有节点的值都小于它的根节点,右子树的所有子节点都大于它的根节点。
(如图),二叉查找树会出现斜树问题,导致时间复杂度增加,因此又引入了一种平衡二叉树,它具有二叉查找树的所有特点,同时增加了一个规则:”它的左右两个子树的高度差的绝对值不超过1“。平衡二叉树会采用左旋、右旋的方式来实现平衡。
(如图),而B树是一种多路平衡查找树,它满足平衡二叉树的规则,但是它可以有多个子树,子树的数量取决于关键字的数量,比如这个图中根节点有两个关键字3和5,那么它能够拥有的子路数量=关键字数+1。
因此从这个特征来看,在存储同样数据量的情况下,平衡二叉树的高度要大于B树。
B+树,其实是在B树的基础上做的增强,最大的区别有两个:
(如图所示)这个是B树的存储结构,从B树上可以看到每个节点会存储数据。
(如图所示)这个是B+树,B+树的所有数据是存储在叶子节点,并且叶子节点的数据是用双向链表关联的。
以Mysql中的InnoDB为例,当我们通过select语句去查询一条数据时,InnoDB需要从磁盘上去读取数据,这个过程会涉及到磁盘IO以及磁盘的随机IO(如图所示)
我们知道磁盘IO的性能是特别低的,特别是随机磁盘IO。
因为,磁盘IO的工作原理是,首先系统会把数据逻辑地址传给磁盘,磁盘控制电路按照寻址逻辑把逻辑地址翻译成物理地址,也就是确定要读取的数据在哪个磁道,哪个扇区。
为了读取这个扇区的数据,需要把磁头放在这个扇区的上面,为了实现这一个点,磁盘会不断旋转,把目标扇区旋转到磁头下面,使得磁头找到对应的磁道,这里涉及到寻道事件以及旋转时间。
很明显,磁盘IO这个过程的性能开销是非常大的,特别是查询的数据量比较多的情况下。
所以在InnoDB中,干脆对存储在磁盘块上的数据建立一个索引,然后把索引数据以及索引列对应的磁盘地址,以B+树的方式来存储。
如图所示,当我们需要查询目标数据的时候,根据索引从B+树中查找目标数据即可,由于B+树分路较多,所以只需要较少次数的磁盘IO就能查找到。
关于这个问题,我会从几个方面来回答。
首先,事务隔离级别,是为了解决多个并行事务竞争导致的数据安全问题的一种规范。
具体来说,多个事务竞争可能会产生三种不同的现象。
而这三种现象在实际应用中,可能有些场景不能接受某些现象的存在,所以在SQL标准中定义了四种隔离级别,分别是:
这四种隔离级别里面,只有串行化解决了全部的问题,但也意味着这种隔离级别的性能是最低的。
在Mysql里面,InnoDB引擎默认的隔离级别是RR(可重复读),因为它需要保证事务ACID特性中的隔离性特征。
数据库连接池是一种池化技术,池化技术的核心思想是实现资源的复用,避免资源重复创建销毁的开销。
而在数据库的应用场景里面,应用程序每次向数据库发起CRUD操作的时候,都需要创建连接
在数据库访问量较大的情况下,频繁的创建连接会带来较大的性能开销。
(如图)而连接池的核心思想,就是应用程序在启动的时候提前初始化一部分连接保存到连接池里面,当应用需要使用连接的时候,直接从连接池获取一个已经建立好的链接。
连接池的设计,避免了每次连接的建立和释放带来的开销。
连接池的参数有很多,不过关键参数就几个:
首先是,连接池初始化的时候会有几个关键参数:
然后,就是在使用连接的时候的关键参数:
不同的连接池框架,除了核心的参数以外,还有很多业务型的参数,比如是否要检测连接sql的有效性、连接初始化SQL等等,这些配置参数可以在使用的时候去查询api文档就可以知道。
(图片)索引,是一种能够帮助Mysql高效从磁盘上检索数据的一种数据结构。
在Mysql中的InnoDB引擎中,采用了B+树的结构来实现索引和数据的存储
在我看来,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的执行计划,然后针对性的进行调优即可。
MyISAM和InnoDB都是Mysql里面的两个存储引擎。
在Mysql里面,存储引擎是可以自己扩展的,它的本质其实是定义数据存储的方式
以及数据读取的实现逻辑。
而不同存储引擎本身的特性,使得我们可以针对性的选择合适的引擎来实现不同的业务场景。
从而获得更好的性能。
在Mysql 5.5之前,默认的存储引擎是MyISAM,从5.5以后,InnoDB就作为了默认的存储引擎。
在实际应用开发中,我们基本上都是采用InnoDB引擎。
我们先来看一下MyISAM引擎。
MyISAM引擎的数据是通过二进制的方式存储在磁盘上,它在磁盘上体现为两个文件
实现机制如图所示(如图)。
因为索引和数据是分离的,所以在进行查找的时候,先从索引文件中找到数据的磁盘位置,再到数据文件
中找到索引对应的数据内容。
在InnoDB存储引擎中,数据同样存储在磁盘上,它在磁盘上只有一个ibd文件,里面包含索引和数据。
(如图),它的整体结构如图所示,在B+树的叶子节点里面存储了索引对应的数据,在通过索引进行检索的时候,命中叶子节点,就可以直接从叶子节点中取出行数据。
了解了这两个存储引擎以后,我们在面试的时候该怎么回答呢?
讲人话
MyISAm和InnoDB的区别有4个,
而InnoDB是把索引和数据存储在同一个文件里面。
因此基于这些特性,我们在实际应用中,可以根据不同的场景来选择合适的存储引擎。
比如如果需要支持事务,那必须要选择InnoDB。
如果大部分的表操作都是查询,可以选择MyISAM。
char类型是固定长度的字符串,varchar是可变长度字符串。
而MD5是一个固定长度的字符,不管数据怎么修改,长度不变,这个点很符合char类型。
另外,由于是固定长度,所以在数据变更的时候,不需要去调整存储空间大小,在效率上会比varchar好。
问题分析
MD5是由数字和字母组成的一个16位或者32位长度的字符串,一般在应用开发中都是使用32位。
看起来,我们用varchar(32)或者char(32)都可以存储,那用哪种更好呢?
要回答这个问题,必须要了解这两个类型的功能特性和区别。
假设声明一个char(10)的长度,如果存储字符串“abc”,虽然实际字符长度只有3,但是char还是会占10个字节长度。
同样,如果用varchar存储,那它只会使用3个字符的实际长度来存储。
varchar每次修改数据都需要更新存储空间长度,效率较低
所以varchar实际存储空间的使用要比char更小
基于他们特性的分析,可以得出一个基本的结论:
对于这个问题,尽可能多说一点,但是说不完整没关系,把核心的一些点说出来就够了
问题解析
MVCC机制,全称(Multi-Version Concurrency Control)多版本并发控制,是确保在高并发下,
多个事务读取数据时不加锁也可以多次读取相同的值。
MVCC在读已提交(READ COMMITTED)、可重复读(REPEATABLE READ 简称RR)模式下才生效。
MVCC在可重复读的事物隔离级别下,可以解决脏读、脏写、不可重复读等问题。
我们知道,MVCC是基于乐观锁的实现,所以很自然的想到MVCC是不是不会加锁。
这个问题也要看情况来回答,下面看看回答建议。
问题答案
在MVCC中,通常不需要加锁来控制并发访问。
相反,每个事务都可以读取已提交的快照,而不需要获得共享锁或排它锁。
在写操作的时候,MVCC会使用一种叫为“写时复制”(Copy-On-Write)的技术,
也就是在修改数据之前先将数据复制一份,从而创建一个新的快照。
当一个事务需要修改数据时,MVCC 会首先检查修改数据的快照版本号
是否与该事务的快照版本一致,如果一致则表示可以修改这条数据,
否则该事务需要等待其他事务完成对该数据的修改。
另外,这个事物在新快照之上修改的结果,不会影响原始数据,
其他事务可以继续读取原始数据的快照,从而解决了脏读、不可重复度问题。
所以,正是有了MVCC机制,让多个事务对同一条数据进行读写时,不需要加锁也不会出现读写冲突。
问题分析
要回答好这个问题,需要先了解Mysql中为什么要引入锁。
在多个事物并行对同一个数据进行修改的时候,会产生事物的竞争造成脏读、幻读、不可重复读等问题。
所谓Mysql为了避免这类问题的出现,引入了事物隔离级别,其实本质上来说,最终解决的方式无非就是LBCC和MVCC两种。
而锁是解决事物竞争问题的底层实现方式。
通常来说,加锁会影响性能, 所以一般情况下都会考虑到性能和安全性的平衡
而Mysql,也根据不同的作用范围,提供了不同的锁的实现方式。
而这个问题,就是考察候选人对锁范围的理解,下面来看下这个问题的回答思路。
问题解答
MySQL的Update操作既可以锁行,也可以锁表,
具体使用哪种锁类型,取决于执行的Update语句的条件、事务隔离级别等因素。
如果where条件中不包含索引列,这个时候会加表锁
比如针对主键索引的for update操作:
SELECT * FROM t WHERE id = 10 FOR UPDATE;
Mysql会增加Next-Key Lock来锁定id=10索引所在的区间
SELECT * FROM user WHERE id BETWEEN 1 AND 100 FOR UPDATE;
Mysql会自动对索引间隙加锁,来解决幻读问题。
问题分析
面试官主要想了解面试者对于 SQL 优化的理解以及在实际工作中如何处理 SQL 语句的性能问题。
要回答好这个问题,只需要了解join操作会带来哪些影响,而这些影响对程序产生什么样的影响就行了。
问题解答
我认为主要有两个方面的原因:
一般情况下说的索引,都是Mysql里面InnoDB引擎的B+树索引。
所以大家首先的知道索引的原理,大家都知道B+树是一颗多路平衡二叉树(如图)
它的特点是,非叶子节点只存储索引,叶子节点存储数据,从而减少B+树的层高降低磁盘IO次数啊从而提升数据检索效率。
通常情况下,加索引是能直接提升数据的检索效率,但面试官反其道而行问什么时候不建索引,所以这个问题考察候选人对于索引的理解深度。
如果期望这次面试有好的反馈,候选人需要能够分析出不建立索引的原因和影响,并根据具体情况进行详细说明。
问题解答
我认为有几种情况不适合建立索引:
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、索引还使用磁盘空间,因此在选择要索引的字段时要小心。
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等,以此来缓解数据的压力,从而提高数据库的响应速度。
AQS是多线程同步器,它是J.U.C包中多个组件的底层实现,如Lock、CountDownLatch、Semaphore等都用到了AQS.
从本质上来说,AQS提供了两种锁机制,分别是排它锁,和 共享锁。
排它锁,就是存在多线程竞争同一共享资源时,同一时刻只允许一个线程访问该共享资源,也就是多个线程中只能有一个线程获得锁资源,比如Lock中的ReentrantLock重入锁实现就是用到了AQS中的排它锁功能。
共享锁也称为读锁,就是在同一时刻允许多个线程同时获得锁资源,比如CountDownLatch和Semaphore都是用到了AQS中的共享锁功能。
从4个方面来回答
一种是把synchronized关键字修饰在方法层面,
另一种是修饰在代码块上,并且我们可以通过Synchronized加锁对象的声明周期来控制锁的作用范围,比如锁对象是静态对象或者类对象,那么这个锁就是全局锁。
如果锁对象是普通实例对象,那这个锁的范围取决于这个实例的声明周期。
Lock锁的粒度是通过它里面提供的lock()和unlock()方法决定的(贴图),包裹在这两个方法之间的代码能够保证线程安全性。而锁的作用域取决于Lock实例的生命周期。
Synchronized由于是关键字,所以它无法实现非阻塞竞争锁的方法,另外,Synchronized锁的释放是被动的,就是当Synchronized同步代码块执行完以后或者代码出现异常时才会释放。
从两个方面来回答。
所以线程池中的工作线程是通过同步调用任务的run()方法并且等待run方法返回后,再去统计任务的完成数量。
基于这样的原理,我们可以定义一个CountDownLatch对象并且计数器为1,接着在线程池代码块后面调用await()方法阻塞主线程,然后,当传入到线程池中的任务执行完成后,调用countDown()方法表示任务执行结束。
最后,计数器归零0,唤醒阻塞在await()方法的线程。
这个问题我从这三个方面来回答:
--------------------------------------------------------------------------------------------
(如图所示),这个是ConcurrentHashMap在JDK1.8中的存储结构,它是由数组、单向链表、红黑树组成。
当我们初始化一个ConcurrentHashMap实例时,默认会初始化一个长度为16的数组。由于ConcurrentHashMap它的核心仍然是hash表,所以必然会存在hash冲突问题。
ConcurrentHashMap采用链式寻址法来解决hash冲突。
当hash冲突比较多的时候,会造成链表长度较长,这种情况会使得ConcurrentHashMap中数据元素的查询复杂度变成O(n)。因此在JDK1.8中,引入了红黑树的机制。
当数组长度大于64并且链表长度大于等于8的时候,单项链表就会转换为红黑树。
另外,随着ConcurrentHashMap的动态扩容,一旦链表长度小于8,红黑树会退化成单向链表。
ConcurrentHashMap本质上是一个HashMap,因此功能和HashMap一样,但是ConcurrentHashMap在HashMap的基础上,提供了并发安全的实现。
并发安全的主要实现是通过对指定的Node节点加锁,来保证数据更新的安全性(如图所示)。
如果在并发性能和数据安全性之间做好平衡,在很多地方都有类似的设计,比如cpu的三级缓存、mysql的buffer_pool、Synchronized的锁升级等等。
ConcurrentHashMap也做了类似的优化,主要体现在以下几个方面:
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主要用在并发场景中,比较典型的使用场景有两个。
(如图),死锁,简单来说就是两个或者两个以上的线程在执行的过程中,争夺同一个共享资源造成的相互等待的现象。
如果没有外部干预,线程会一直阻塞无法往下执行,这些一直处于相互等待资源的线程就称为死锁线程。
导致死锁的条件有四个,也就是这四个条件同时满足就会产生死锁。
导致死锁之后,只能通过人工干预来解决,比如重启服务,或者杀掉某个线程。
所以,只能在写代码的时候,去规避可能出现的死锁问题。
按照死锁发生的四个条件,只需要破坏其中的任何一个,就可以解决,但是,互斥条件是没办法破坏的,因为这是互斥锁的基本约束,其他三方条件都有办法来破坏:
但是多线程本身具有并行执行的特性,也就是在同一时刻,多个线程可以同时执行。在这种情况下,线程t2在访问共享变量s之前,必须要知道线程t1已经修改过了共享变量s,否则就需要等待。
同时,线程t1修改过了共享变量S之后,还需要通知在等待中的线程t2。
所以要在这种特性下要去实现线程之间的通信,就必须要有一个竞争条件控制线程在什么条件下等待,什么条件下唤醒。
所谓线程安全问题,简单来说,就是在多个线程访问某个方法或者对象的时候,不管通过任何的方式调用以及线程如何去交替执行。
在程序中不做任何同步干预操作的情况下,这个方法或者对象的执行/修改都能按照预期的结果来反馈。
我这样去解释,大家可能会有点懵逼。
实际上,线程安全问题的具体表现在三个方面,原子性、有序性、可见性。
原子性呢,是指当一个线程执行一系列程序指令操作的时候,它应该是不可中断的,因为一旦出现中断,站在多线程的视角来看,这一系列的程序指令会出现前后执行结果不一致的问题。
这个和数据库里面的原子性是一样的,就是一段程序只能由一个线程完整的执行完成,而不能存在多个线程干扰。
(如图)CPU的上下文切换,是导致原子性问题的核心,而JVM里面提供了Synchronized关键字来解决原子性问题。
可见性,就是说在多线程环境下,由于读和写是发生在不同的线程里面,有可能出现某个线程对共享变量的修改,对其他线程不是实时可见的。
导致可见性问题的原因有很多,比如CPU的高速缓存、CPU的指令重排序、编译器的指令重排序。
有序性,指的是程序编写的指令顺序和最终CPU运行的指令顺序可能出现不一致的现象,这种现象也可以称为指令重排序,所以有序性也会导致可见性问题。
可见性和有序性可以通过JVM里面提供了一个Volatile关键字来解决。
在我看来,导致有序性、原子性、可见性问题的本质,是计算机工程师为了最大化提升CPU利用率导致的。比如为了提升CPU利用率,设计了三级缓存、设计了StoreBuffer、设计了缓存行这种预读机制、在操作系统里面,设计了线程模型、在编译器里面,设计了编译器的深度优化机制。
简单来说,守护线程就是一种后台服务线程,他和我们在Java里面创建的用户线程是一模一样的。
守护线程和用户线程的区别有几个点,这几个点也是守护线程本身的特性:
注意,Java进程的终止与否,只和用户线程有关。如果当前还有守护线程正在运行,也不会阻止Java程序的终止。
因此,守护线程的生命周期依赖于用户线程。
举个例子,JVM垃圾回收线程就是一个典型的守护线程,它存在的意义是不断的处理用户线程运行过程中产生的内存垃圾。
一旦用户线程全部结束了,那垃圾回收线程也就没有存在的意义了。
由于守护线程的特性,所以它它适合用在一些后台的通用服务场景里面。
但是守护线程不能用在线程池或者一些IO任务的场景里面,因为一旦JVM退出之后,守护线程也会直接退出。
就会可能导致任务没有执行完或者资源没有正确释放的问题。
从两个方面给大家解释一下这个问题:
第一个方面,双向链表的优势:
第二个方面,说一下AQS采用双向链表的原因
总而言之,采用单向链表不支持双向遍历,而AQS中存在很多需要双向遍历的场景来提升线程阻塞和唤醒的效率。
volatile关键字有两个作用。
我理解的可见性,是指当某一个线程对共享变量的修改,其他线程可以立刻看到修改之后的值。
其实这个可见性问题,我认为本质上是由几个方面造成的。
所以,对于增加了volatile关键字修饰的共享变量,JVM虚拟机会自动增加一个#Lock汇编指令,这个指令会根据CPU型号自动添加总线锁或/缓存锁
我简单说一下这两种锁,
所以,如果对共享变量增加了volatile关键字,那么在编译器层面,就不会去触发编译器优化,同时再JVM里面,会插入内存屏障指令来避免重排序问题。
当然,除了volatile以外,从JDK5开始,JMM就使用了一种Happens-Before模型去描述多线程之间的内存可见性问题。
如果两个操作之间具备Happens-Before关系,那么意味着这两个操作具备可见性关系,不需要再额外去考虑增加volatile关键字来提供可见性保障。
从三个方面来回答。
简单来说,在多个线程访问某个方法或者对象的时候,不管通过任何的方式调用以及线程如何去交替执行。
在程序中不做任何同步干预操作的情况下,这个方法或者对象的执行/修改都能按照预期的结果来反馈,那么这个类就是线程安全的。
实际上,线程安全问题的具体表现体现在三个方面,原子性、有序性、可见性。
原子性呢,是指当一个线程执行一系列程序指令操作的时候,它应该是不可中断的,因为一旦出现中断,站在多线程的视角来看,这一系列的程序指令会出现前后执行结果不一致的问题。
这个和数据库里面的原子性是一样的,简单来说就是一段程序只能由一个线程完整的执行完成,而不能存在多个线程干扰。
(如图)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中的缓存行失效。
这就是伪共享问题的原理。
因为伪共享会问题导致缓存锁的竞争,所以在并发场景中的程序执行效率一定会收到较大的影响。
这个问题的解决办法有两个:
可重入是多线程并发编程里面一个比较重要的概念,
简单来说,就是在运行的某个函数或者代码,因为抢占资源或者中断等原因导致函数或者代码的运行中断,
等待中断程序执行结束后,重新进入到这个函数或者代码中运行,并且运行结果不会受到影响,那么这个函数或者代码就是可重入的。
(如图) 而可重入锁,简单来说就是一个线程如果抢占到了互斥锁资源,在锁释放之前再去竞争同一把锁的时候,不需要等待,只需要记录重入次数。
在多线程并发编程里面,绝大部分锁都是可重入的,比如Synchronized、ReentrantLock等,但是也有不支持重入的锁,比如JDK8里面提供的读写锁StampedLock。
锁的可重入性,主要解决的问题是避免线程死锁的问题。
因为一个已经获得同步锁X的线程,在释放锁X之前再去竞争锁X的时候,相当于会出现自己要等待自己释放锁,这很显然是无法成立的。
首先,ReentrantLock是一种可重入的排它锁,主要用来解决多线程对共享资源竞争的问题。
它的核心特性有几个:
(如图)然后,ReentrantLock的底层实现有几个非常关键的技术。
以上就是我对这个问题的理解。
首先,线程池本质上是一种池化技术,而池化技术是一种资源复用的思想,比较常见的有连接池、内存池、对象池。
而线程池里面复用的是线程资源,它的核心设计目标,我认为有两个:
起到了资源保护的作用。
其次,我简单说一下线程池里面的线程复用技术。因为线程本身并不是一个受控的技术,也就是说线程的生命周期时由任务运行的状态决定的,无法人为控制。
(图片)所以为了实现线程的复用,线程池里面用到了阻塞队列,简单来说就是线程池里面的工作线程处于一直运行状态,它会从阻塞队列中去获取待执行的任务,一旦队列空了,那这个工作线程就会被阻塞,直到下次有新的任务进来。
也就是说,工作线程是根据任务的情况实现阻塞和唤醒,从而达到线程复用的目的。
最后,线程池里面的资源限制,是通过几个关键参数来控制的,分别是核心线程数、最大线程数。
核心线程数表示默认长期存在的工作线程,而最大线程数是根据任务的情况动态创建的线程,主要是提高阻塞队列中任务的
处理效率。
(如图)首先,线程是系统级别的概念,在Java里面实现的线程,最终的执行和调度都是由操作系统来决定的,JVM只是对操作系统层面的线程做了一层包装而已。
所以我们在Java里面通过start方法启动一个线程的时候,只是告诉操作系统这个线程可以被执行,但是最终交给CPU来执行是操作系统的调度算法来决定的。
因此,理论上来说,要在Java层面去中断一个正在运行的线程,只能像类似于Linux里面的kill命令结束进程的方式一样,强制终止。
所以,Java Thread里面提供了一个stop方法可以强行终止,但是这种方式是不安全的,因为有可能线程的任务还没有,导致出现运行结果不正确的问题。
要想安全的中断一个正在运行的线程,只能在线程内部埋下一个钩子,外部程序通过这个钩子来触发线程的中断命令。
(如图)因此,在Java Thread里面提供了一个interrupt()方法,这个方法配合isInterrupted()方法使用,就可以实现安全的中断机制。
这种实现方法并不是强制中断,而是告诉正在运行的线程,你可以停止了,不过是否要中断,取决于正在运行的线程,所以它能够保证线程运行结果的安全性。
之所以称它为重量级锁,是因为它的底层底层依赖操作系统的Mutex Lock来实现互斥功能。
(如图)Mutex是系统方法,由于权限隔离的关系,应用程序调用系统方法时需要切换到内核态来执行。
这里涉及到用户态向内核态的切换,这个切换会带来性能的损耗。
????????2.在jdk1.6版本中,synchronized增加了锁升级的机制,来平衡数据安全性和性能。简单来说,就是线程去访问synchronized同步代码块的时候,synchronized根据
线程竞争情况,会先尝试在不加重量级锁的情况下去保证线程安全性。所以引入了偏向锁和轻量级锁的机制。
偏向锁,就是直接把当前锁偏向于某个线程,简单来说就是通过CAS修改偏向锁标记,这种锁适合同一个线程多次去申请同一个锁资源并且没有其他线程竞争的场景。
轻量级锁也可以称为自旋锁,基于自适应自旋的机制,通过多次自旋重试去竞争锁。自旋锁优点在于它避免避免了用户态到内核态的切换带来的性能开销。
?????????3.(如图)Synchronized引入了锁升级的机制之后,如果有线程去竞争锁:
??? 首先,synchronized会尝试使用偏向锁的方式去竞争锁资源,如果能够竞争到偏向锁,表示加锁成功直接返回。如果竞争锁失败,说明当前锁已经偏向了其他线程。
??? 需要将锁升级到轻量级锁,在轻量级锁状态下,竞争锁的线程根据自适应自旋次数去尝试抢占锁资源,如果在轻量级锁状态下还是没有竞争到锁,
??? 就只能升级到重量级锁,在重量级锁状态下,没有竞争到锁的线程就会被阻塞,线程状态是Blocked。
??? 处于锁等待状态的线程需要等待获得锁的线程来触发唤醒。
总的来说, Synchronized的锁升级的设计思想,在我看来本质上是一种性能和安全性的平衡,也就是如何在不加锁的情况下能够保证线程安全性。
这种思想在编程领域比较常见,比如Mysql里面的MVCC使用版本链的方式来解决多个并行事务的竞争问题。
我先解释一下个公平和非公平的概念。
公平,指的是竞争锁资源的线程,严格按照请求顺序来分配锁。
非公平,表示竞争锁资源的线程,允许插队来抢占锁资源。
ReentrantLock默认采用了非公平锁的策略来实现锁的竞争逻辑。
(如图)其次,ReentrantLock内部使用了AQS来实现锁资源的竞争,
没有竞争到锁资源的线程,会加入到AQS的同步队列里面,这个队列是一个FIFO的双向链表。
在这样的一个背景下,公平锁的实现方式就是,线程在竞争锁资源的时候判断AQS同步队列里面有没有等待的线程。
如果有,就加入到队列的尾部等待。
而非公平锁的实现方式,就是不管队列里面有没有线程等待,它都会先去尝试抢占锁资源,如果抢不到,再加入到
AQS同步队列等待。
ReentrantLock和Synchronized默认都是非公平锁的策略,之所以要这么设计,我认为还是考虑到了性能这个方面的原因。
因为一个竞争锁的线程如果按照公平的策略去阻塞等待,同时AQS再把等待队列里面的线程唤醒,这里会涉及到内核态
的切换,对性能的影响比较大。
如果是非公平策略,当前线程正好在上一个线程释放锁的临界点抢占到了锁,就意味着这个线程不需要切换到内核态,
虽然对原本应该要被唤醒的线程不公平,但是提升了锁竞争的性能。
CompletableFuture是JDK1.8里面引入的一个基于事件驱动的异步回调类。
简单来说,就是当使用异步线程去执行一个任务的时候,我们希望在任务结束以后触发一个后续的动作。
而CompletableFuture就可以实现这个功能。
(如图),举个简单的例子,比如在一个批量支付的业务逻辑里面,
涉及到查询订单、支付、发送邮件通知这三个逻辑。
这三个逻辑是按照顺序同步去实现的,也就是先查询到订单以后,再针对这个订单发起支付,支付成功以后再发送邮件通知。
而这种设计方式导致这个方法的执行性能比较慢。
所以,这里可以直接使用CompletableFuture,(如图),也就是说把查询订单的逻辑放在一个异步线程池里面去处理。
然后基于CompletableFuture的事件回调机制的特性,可以配置查询订单结束后自动触发支付,支付结束后自动触发邮件通知。
从而极大的提升这个这个业务场景的处理性能!
CompletableFuture提供了5种不同的方式,把多个异步任务组成一个具有先后关系的处理链,然后基于事件驱动任务链的执行。
也就是第一个任务执行完以后自动触发执行第二个任务。
并且第一个任务的执行结果作为第二个任务的参数,这个方法是纯粹接受上一个任务的结果,不返回新的计算值。
最后,我认为,CompletableFuture弥补了原本Future的不足,使得程序可以在非阻塞的状态下完成异步的回调机制。
BLOCKED和WAITING都是属于线程的阻塞等待状态。
BLOCKED状态是指线程在等待监视器锁的时候的阻塞状态。
(如图)也就是在多个线程去竞争Synchronized同步锁的时候,没有竞争到锁资源的线程,会被阻塞等待,这个时候线程状态就是BLOCKED。
在线程的整个生命周期里面,只有Synchronized同步锁等待才会存在这个状态。
WAITING状态,表示线程的等待状态,在这种状态下,线程需要等待某个线程的特定操作才会被唤醒。我们可以使用Object.wait()、Object.join()、LockSupport.park()这些方法
使得线程进入到WAITING状态,在这个状态下,必须要等待特定的方法来唤醒,
比如Object.notify方法可以唤醒Object.wait()方法阻塞的线程
LockSupport.unpark()可以唤醒LockSupport.park()方法阻塞的线程。
所以,在我看来,BLOCKED和WAITING两个状态最大的区别有两个:
所以,基于这四个点的原因,所以在实际应用中,建议实现Runnable接口实现线程的任务定义,然后使用Thread的start方法
去启动启动线程并执行Runnable这个任务。