Java
基础
-
java 中常量和变量的区别
常量使用 final 修饰,初始化后不能被修改
-
HashMap
-
putVal():
-
resize():
-
判断是否达到最大容量(2^30),如果到大,长度设置为int.max
-
创建一个数组大小为为原数组的两倍
-
遍历原数组
重新计算index的方法:
1.当前 index 只有一个元素,重新做一遍hash,计算index并放入新的位置
2.如果当前是红黑树结构,将调用红黑树的 split() 方法,将红黑树拆成高低位两部分,分为两部分处理
1)如果高位的链表头指针为空,说明split之后当前树的节点都落在了低位,直接将根节点放入数组对应位置,反之同理
2)如果高低位都不为空,则判断:低位元素数量 <= 6 的时候,调用 untreeify() 方法,去树化,将红黑树转为链表,否则将低位链表转为红黑树。反之同理
-
其实在去树化和转为红黑树是,操作的都是链表,不过当前链表的元素类型是 treeNode 类型,去树化就是将 treeNode 转为普通 Node,转为红黑树就是将链表结构转为红黑树结构
-
红黑树,一种平衡的二叉搜索树,查找、插入、删除的时间复杂度都是O(logN)
-
-
-
并发
-
进程与线程
-
进程
-
程序运行时的一个实例
-
每个进程都有独立的内存空间
-
进程是系统进行资源分配和调度的基本单位
-
进程中的堆,是一个进程中最大的一块内存,被所有线程共享,进程创建时分配,主要存储new的对象
-
进程里的方法区,用于存放进程中的代码片段,线程共享
-
多线程 os 中,进程并不是一个实体,每一个进程最少要有一个线程去执行代码
-
-
线程
-
进程中的一个实体
-
CPU 调度和分配的一个基本单位
-
系统不会为线程分配内存,线程组之间只能共享所属进程的内存
-
线程只拥有在运行中必不可少的资源(程序计数器、栈)
-
-
-
守护线程
-
一种在后台提供通用服务的线程
-
java 中将线程设置为守护线程的方法为,在调用线程的 start 方法之前,先调用setDaemon(true);
-
-
线程的状态
任何线程一般具有五中状态:创建、就绪、运行、阻塞、终止
-
创建状态:在程序中创建一个线程对象之后,这个线程就是创建状态,此时线程已拥有了相应的内存等资源,但是还不可以运行
-
就绪状态:当线程对象调用 start() 方法之后,当前线程就会进入线程队列排队,等待 cpu 的服务
-
运行状态:当就绪线程的run()方法被执行时
-
阻塞状态:一个正在正常执行的线程被人为的挂起或者执行耗时的IO操作时,会让cpu暂时终止自己的执行,进入阻塞状态,调用 sleep()、wait() 等方法时,线程都将进入阻塞状态,直到阻塞状态消除,进入线程队列重新排队
-
终止:线程调用 stop() 方法或者 run() 方法结束之后
-
-
创建线程的方法:
-
newSingleThreadPool:创建单一线程,当这个线程结束,在创建一个
-
newFixThreadPool:固定大小的
-
newCacheThreadPool:可缓存的线程池
-
newScheduleThreadPool:无限大小的线程池
-
-
ThreadPoolExecutor的参数:
-
corePoolSize:线程池核心线程数
-
maximumPoolSize:线程池的最大大小
-
keepAliveTime:非核心线程的存活时间
-
unit:时间单位
-
workQueue:存放任务的阻塞队列
-
threadFactory:用于创建线程的工厂,可以设置线程名字
-
handler:线程池饱和拒绝策略,四种
-
-
线程池执行流程:
-
当一个任务进来,如果当前存活的线程数量小于corePoolSize,那么创建一个线程执行次任务
-
如果当前线程数量已经等于corePoolSize,那么将次任务放入任务队列
-
如果任务队列已满,判断线程数量是否达到maximumPoolSize,如果没达到,创建一个非核心线程处理此任务
-
如果已达到maximumPoolSize,执行拒绝策略
-
抛出异常
-
直接丢弃任务
-
丢弃最老的任务
-
交给线程池所调用的线程进行处理
-
-
-
为什么多线程在并发操作共享变量的时候,会有并发问题?
-
涉及到 JMM(java内存模型),对于java程序来说,所有的共享变量存储于主内存中,每个线程只能操作自己的工作内存,工作内存中存储着主内存的变量的副本,线程对变量的操作不会马上写回主内存,这样就会导致出现问题
-
-
volatile
-
保证可见性,禁止指令重排序
-
被volatile修饰的变量,在本被线程修改后,立马写回主内存,并且使其他线程的缓存无效
-
通过在特定地方插入内存屏障的方式来禁止指令重排序
-
-
对于一个线程写操作,多个线程读操作的变量,适用于volatile修饰
-
JMM(Java Memeory model)
JMM:java 内存模型,描述了java中各种变量(线程共享变量)的访问规则,以及在 jvm 中将变量存储到内存和从内存中读取变量的底层细节
JMM 规定,所以共享变量都存储于主内存,这里的变量指的是实例变量和类变量,不包含局部变量,因为局部变量是线程私有的,不存在竞争问题
每一个线程还有自己的工作内存,每个线程对变量的读写都在工作内存中完成,不能直接读写主内存,不同线程之间也不能访问彼此工作内存中的变量,线程间变量的传递通过主内存完成
怎样能解决可见性问题
-
加锁:
当一个线程进入 synchronized 修饰的代码时,会清空工作内存,重新读取主内存中的变量,保证使用的变量是最新的,当这块代码结束之后将工作内存中的变量刷回主内存,而其他线程在此期间,因为获取不到当前代码的锁,会一直阻塞等待锁的释放
-
使用 volatile /ˈvɒlətaɪl/ 关键字
当一个线程将工作内存中的变量刷回到主内存时,其他线程立即就能看到变量最新值
-
MESI 缓存一致性协议
当 cpu 写数据时,如果发现当前变量是共享变量,即其他 cpu 中也有该变量的副本,会通知其他 cpu 将该变量的缓存标志置为无效,当其他线程要操作该变量的时候,发现缓存无效,就会去主内存重新读取,这样就能获取最新值
怎么样知道数据失效?
-
嗅探
每个处理器通过在总线上传播的数据来检查自己的缓存是不是过期了,当处理器发现自己缓存数据的内存地址被修改时,就会将缓存置为无效
-
总线风暴
由于 volatile 的 MESI ,需要不断地与总线交互,会导致总线的带宽出现问题
-
-
指令重排序
为了提高性能,编译器和处理器往往会对既定的代码执行顺序进行指令重排序
volatile 怎么保证不被重排序
java 编译器会在生成指令序列时,在适当位置插入内存屏障指令来禁止指令重排序
JVM
-
JVM 内存结构
方法区
方法区存放了要加载的类信息(如类名,修饰符等)、静态变量、final 修饰的常量、类中的字段和方法等信息。方法区是所有线程共享的,在一定情况下也会GC。当方法区超过允许的大小时,会抛出:OutOfMemory:PermGen Space 异常
在 Hotspot 虚拟机中,这块区域对应永久代,一般来说,方法区上的GC很少,其上的GC主要是针对常量池的回收和已加载的类的卸载。在方法区上GC,条件相当苛刻
运行时常量池是方法区的一部分,用于存储编译器生成的常量和引用,一般来说,常量的分配在编译是就能确定,但也不完全是,String 类的 intern() 方法
堆
用于存放对象实例和数组,是GC最为频繁的内存区域
虚拟机栈
虚拟机栈占用的是操作系统的内存,每个线程对应一个虚拟机栈,它是线程私有的,生命周期和线程一致,每个方法被执行时,产生一个栈帧,用于存储局部变量表,动态连接,操作数和方法出口等信息,当方法调用是,栈帧进栈,当方法结束时,栈帧出栈
局部变量表中存储着方法相关的局部变量,包括基本数据类型和各种引用,因此它有一个特点,在编译时就已经确定,不可更改
虚拟机栈定义了两种异常类型:StackOverFlowError 和 OutOfMemoryError,如果线程调用的栈深度超过最大允许深度,就会抛出StackOverFlowError ,jvm 动态扩展时,如果无法申请到足够的内存,则会抛出OutOfMemoryError
本地方法栈
用于支持 native 方法的执行,和虚拟机栈的运行机制一直
程序计数器
用于记录当前线程所执行的位置,在多线程的情况下,线程 A 拿到 cpu 的执行权,开始执行内部程序,但是还没有执行完,cpu 的执行权被线程 B 拿到,A 停止执行,B 开始执行,某一时间,A 再次拿到 cpu 的执行权,开始执行,肯定不能从头开始执行,而是通过程序计数器的记录,来判断从哪个位置执行。程序计数器是线程私有的,因而它是线程安全的,程序计数器是唯一一个不会抛出OOM的内存区域
垃圾回收算法
在 JVM 的五个内存区域中,虚拟机栈、本地方法栈、程序计数器是不需要进行 GC 的,因为它们是线程私有的,随着线程的销毁而销毁,只有堆、方法区会进行GC,GC 的对象是那些不存在任何引用的对象
-
查找可回收的对象
通常情况下查找需要被回收的对象有两种方法:引用计数法和可达性分析算法(根搜索法)
-
引用计数法
一个对象每次被引用的时候,计数器+1,当引用对象失效时,计数器-1,当计数器为0时,则认为此对象是能够被回收的对象
如果存在这样一种情况:两个对象相互引用,则计数器一直不为0,对象就无法回收,所以 JVM 采用了另一种回收算法
-
可达性分析算法
基本思想:从 GC Roots 出发,向下搜索,如果一个对象不能通过任一路径到达 GC Roots ,则判断此对象可以被回收
可以被当做GC Roots的对象:
-
虚拟机栈中被引用的对象,各个线程调用的参数,局部变量,临时变量等
-
方法区中,类的静态属性引用的对象,比如引用类型的静态变量
-
方法区中常量引用的对象
-
本地方法栈中引用的对象
-
jvm 内部的引用,一些常驻的异常对象
-
被 synchronized 持有的对象
-
-
-
垃圾回收算法
主要有三种:标记-删除算法、标记-复制算法、标记-整理算法
-
标记-删除算法
通过查找算法,标记查找到的可回收的对象,对标记的对象进行删除。因为可回收的对象可能随机的分布于内存地址的各个地方,此算法再删除这些对象之后,可能会导致有大批小二零散的可用内存空间,如果此时想要创建一个大的对象,那么就会在此进行垃圾回收,那么回收的效率就会大大降低
-
标记-复制算法
将内存分为两块,每次分配对象的时候只使用其中的一块,在进行垃圾回收的时候,回收掉可回收的对象,然后将留下来的对象复制到另一块空的内存中,这样就避免的产生大量的零散内存,但是这样,内存在使用的时候,只利用了总内存的一半,内存空间的使用率很低。现在大部分 JVM 都是使用的标记-复制算法,但是对于内存,并不是简单的一分为二
通常情况下,jvm 将堆内存分为三部分:新生代、老年代、持久代(很多 JVM 没有,Hotspot中指的就是方法区)
GC 的分类有三种:
-
Minor GC:针对新生代对象的回收
-
Major GC:针对老年代对象的回收
-
Full GC: 针对整个堆和方法区的回收
Minor GC 流程
刚开始创建对象时,对象都是分配在Eden区的,如果Eden区快满了,就会触发Minor GC:
首先:一次性将Eden区中存活的对象转移到一块空着的survivor区
然后:清空Eden区,这样Eden区就有空间存放新产生的对象了
如果下一次再次进行Minor GC的话,会把Eden区存活的对象和survivor区存活的对象转移到另一块空着的survivor区,并清空该Eden和survivor区
一直保持着一块survivor区是空着的,用来提供复制算法的垃圾回收,而这块区域只占新生代的10%
什么时候进入老年代
-
通过判断计数
Minor GC 时,每一次GC,都会对存活的对象进行标记计数,每次+1,如果一个对象的这个计数达到阈值时,就会进入老年代;这个阈值默认为15,可以通过JVM参数:-XX:MaxTenuringThreshold” 来设置
-
动态对象年龄判断
这是一种不用判断GC次数的方式,大致规则是:如果一批对象(年龄1+2+..N)的大小大于survivor区的一半,则大于这批对象年龄的对象就会被转移到老年代(年龄大于N)
-
大对象直接进入老年代
通过 JVM 参数:"-XX:PretenureSizeThreshold"设置多大的对象直接被放入老年代,默认值是0,表示所以对象都会先被放入新生代
-
Minor GC后的对象过多,survivor 区放不下
这样就会把这些对象都转移到老年代
老年代空间分配担保原则
首先,在执行任何一次Young GC之前,JVM都会先检查一下老年代可用的内存空间是否大于新生代所有对象的总大小。
为啥要检查这个呢?因为在极端情况下,Young GC后,新生代中所有的对象都存活下来了,那就会把所有新生代中的对象放入老年代中。
如果说老年代可用内存大于新生代对象总大小,那么就可以放心的执行Young GC了。
但是如果老年代的可用内存小于新生代对象的总大小,这个时候就会看一个参数“-XX:HandlePromotionFailure”是否设置为true了(可以认为jdk7之后,默认设置为true)。
如果设置为true,那么进入下一步判断,就是看看老年代可用的内存,是否大于之前每次Young GC后进入老年代对象的平均大小。
如果说老年代的可用内存小于平均大小,或者说参数没有设置成true,那么就会直接触发“Full GC”,就是对老年代进行垃圾回收,腾出空间后,再进行Young GC。
如果上边两种情况判断成功,没有执行Full GC,进行了Young GC,有以下几种可能:
1.如果Young GC后,存活的对象大小小于Survivor区域的大小,那么直接进入Survivor区域即可。
2.如果Young GC后,存活的对象大小大于Survivor区域的大小,但是小于老年代可用内存大小,那就直接进入老年代。
3.很不幸,老年代可用空间也放不下这些存活对象了,那就会发生“Handle Promotion Failure”的情况,触发Full GC。
如果Full GC后,老年代可用内存还是不够,那么就会导致OOM内存溢出了。
Major GC 流程
Major GC 采用的是标记-整理算法,标记出哪些是需要回收的对象,哪些是存活的对象,将存活对象进行整理移动,移动到一边,减少内存碎片换,然后将垃圾对象清除。此过程非常耗时,每次进行垃圾回收的时候,都要暂停用户线程。
-
-
JVM 对象
-
创建对象的方式
-
使用 new 关键字
-
使用 Class 的 newInstance() 方法
-
使用 Constructor 类的 newInstance() 方法
-
使用 clone() 方法,对象所属类需实现 Cloneable 接口
-
-
JVM 对象分配
-
判断这个类是否加载、链接、初始化
虚拟机遇到一条 new 指令,首先去检查这个指令的参数能否在常量池中定位到一个类的符号引用,并检查这个符号对应的类是否被加载,解析和初始化,如果没有,必须先执行类的加载,解析,初始化
-
为对象分配内存
类加载检查通过后,虚拟机为新生对象分配内存。对象所需内存在类加载完之后就能够完全确定
-
处理并发安全问题
-
初始化分配到的内存
内存分配结束,虚拟机将分配到的内存空间都设置为零值,这一步保证了对象的实例字段在 java 代码中不用赋初值就能够直接使用,程序访问到这些字段时能够获得零值
-
设置对象的对象头
将对象的所属类,对象的 HashCode,GC 分代年龄等数据存储于对象的对象头中
-
执行 init 方法进行初始化
-
-
JVM 对象内存的分配方式
-
JVM 对象内存的分配方式有两种,指针碰撞法和空闲链表法
-
如果内存是规整的,那么虚拟机将使用指针碰撞法来为对象分配内存。此时,内存中,用过的内存在一边,空闲的内存在另一边,中间放着一个指针作为分界。在分配内存时,只需要将指针向空闲区挪动一段与对象大小相等的距离。如果使用的垃圾收集器是压缩整理的,适合用这种对象分配方式
-
空闲链表法适用于内存空间不连续的。虚拟机维护了一个列表,记录的内存空间上,哪些是可用的
-
-
Spring
1.Spring容器的启动流程
2.
SpringCloud
1.什么是SpringCloud?
微服务就是一个独立的、职责单一的服务应用程序,每个服务按照业务来分
2.微服务之间是如何通讯的?
springcloud 通过 rest(http) 接口进行同步通讯,通过消息队列(kafka、rabbitmq等)异步通讯
3.SpringBoot 和 SpringCloud 之间关系?
springboot 用于开发微服务架构系统中的具体服务,springcloud 关注全局的微服务治理和协调
4.什么是熔断?什么是服务降级?
服务熔断:当某个服务因为故障出现不可用或者无响应的时候,为了防止整个服务系统雪崩,暂时停止对该服务的调用
服务降级:服务降级是从整个系统的负载角度出发和考虑的。对某些负载较高的情况下,为了避免某个功能响应较慢,在内部调用的时候,舍弃一些对非核心接口的调用或者直接返回一个预处理的信息,保证整个系统的可用性
5.微服务的优缺点是什么?说下你在项目中碰到的坑。
优点:微服务最大的优点就是解耦,对于开发而言,一个功能复杂且庞大的系统,不再需要在同一个项目下构建代码了,各个功能之间不比再有代码级别的依赖,取而代之的是,我们可以把所有注意力投入业务逻辑的开发中,服务之间调用的时候,直接通过基于 http 的 rest 接口的方式
缺点:分布式系统中,通常会出现的痛点:网络问题,数据一致性、多个服务器的维护,性能监控,错误预警,对运维相关的工作要有所提高。
6.eureka和zookeeper都可以提供服务注册与发现的功能,请说说两个的区别?
cap 原则:数据一致性、系统可用性、网络分区容错性
eureka 保证的是 ap、即系统可用性
zookeeper 保证的是数据一致性
MySQL
-
MySQL 架构
-
连接层:处理客户端的连接服务,连接处理、授权认证、以及相关的安全方案
-
服务层:完成大部分核心功能,包括查询的解析、分析、优化、缓存(8.0以上取消了缓存)以及所有的内置函数
-
引擎层:存储引擎负责了MySQL中数据的存储以及查询
-
数据存储层:将数据存储于MySQL运行设备的文件系统上
-
-
一条 SQL 语句在 MySQL 中是如何执行的
-
客户端请求 -- 连接器(身份认证)-- 查询缓存(如果命中缓存,直接返回,没有命中的话则执行后续操作)-- 分析器(对 SQL 进行语法和词法分析)-- 优化器(对要执行的 SQL 做优化处理)-- 执行器(操作存储引擎,返回数据,如果开启缓存,则会将结果缓存)
-
-
MySQL 引擎
-
InnoDB 支持事务,MyISAM 不支持
-
InnoDB 支持外键MyISAM 不支持
-
InnoDB 是聚簇索引,聚簇索引的文件保存在主键索引的叶子节点上,因此InnoDB必须有主键
-
InnoDB 不保存表的行数,count(*) 要扫描全表,MyISAM 保存行数
-
InnoDB 锁的最小粒度是行,MyISAM 是表级
-
-
MySQL 事务
-
MySQL 事务主要用于处理操作量大,复杂度高的数据。比如说,在人员管理系统中,你删除一个人员,你即需要删除人员的基本资料,也要删除和该人员相关的信息,如信箱,文章等等,这样,这些数据库操作语句就构成一个事务!
-
事务的四个特性
-
ACID:
原子性、一致性、隔离性、持久性
-
-
并发事务处理带来的问题
-
更新丢失:事务 A 和 事务 B 对同一行做更新操作,可能会发生更新丢失
-
脏读:A 读取 B 更新的数据,然后 B 回滚数据
-
不可重复读:A 多次读同一数据,B 对数据进行更新并提交,导致 A 多次读的数据不一致
-
幻读:A 多次读到的数据量不一致
-
-
事务的隔离级别
-
读未提交:最低级别的隔离,允许读取其他事务未提交的数据,会导致脏读、幻读、不可重复读
-
读已提交:允许读取并发事务已经提交的数据,可以阻止脏读,但是幻读或不可重复读仍有可能发生。
-
可重复读:对同一字段的多次读取结果都是一致的,除非数据是被本身事务自己所修改,可以阻止脏读和不可重复读,但幻读仍有可能发生。
-
可串行化:最高的隔离级别,完全服从ACID的隔离级别。所有的事务依次逐个执行,这样事务之间就完全不可能产生干扰,也就是说,该级别可以防止脏读、不可重复读以及幻读。
-
-
查看当前数据库的隔离级别
show variables like 'tx_isolation';
-
-
MySQL 调优
-
索引优化,最左匹配原则
-
不要再索引列上面做函数计算
-
少用 select 子查询,替换为 inner join
-
少用 UDF
-
少用 left join,left join 导致不走索引
-
group by, order by 尽量使用索引列
-
is null 和 is not null 是不会走索引的,对于这种情况可以将null值替换为1/0
-
索引对于查询来说能有效提高查询速度,但是对于插入数据来说却会影响插入速度
-
怎么样删除百万级数据:先删除索引,在删除数据
-
-
Mybatis
Nginx
Redis
-
数据类型
字符串,列表,哈希(Map),set,sortedSet,hyperloglog(用于少量使用少量空间来count的,不存储Key,可以根据key来获得count)
-
缓存雪崩,穿透、击穿
-
缓存雪崩指的是,在同一时间,有大量的key失效,导致大量的请求打到db
-
解决办法:将key的缓存失效时间分散
-
-
穿透指的是用不合法的查询条件去查询数据库,由于一直查不到,就一直打DB
-
解决办法1:参数校验和网关层对于频繁请求的IP查封
-
解决办法2:使用bloomfilter来对请求进行处理,原理是不在的肯定不在,在的可能也不在,有一定的误差
-
-
击穿指的是一个承担着大量请求的key,在key过期的时候,大量请求打到DB
-
解决办法:设置key永不过期或者加上互斥锁
-
-
Elasticsearch
Kafka
Zookeeper
Zookeeper 是 Apache 的一个软件项目,它为大型分布式计算提供分布式配置服务,同步服务,命名注册
zk 数据结构
类似于文件系统,以Key-value的形式存储,名称key是以/分割的一系列路径元素,zk中的每一个节点都由一个路径标识
zk 有哪些特性
-
单一试图,客户端连接任意一个节点,看到的数据模型都是一致的
-
可靠性,一旦服务器成功应用一个事务,并完成对客户端的响应,那么该事务所引起的服务端状态变更将会一直保留下来,除非有另一个事务又对其进行了变更
-
顺序一致性,从同一个客户端发起的请求,会按照顺序被应用到zk中
-
原子性,所有事务请求都会被应用到整个集群中,不会出现部分应用的情况
-
实时性,zookeeper仅仅保证在一定时间段内,客户端最终能够从服务器上读取到最新的数据
zk 有哪些应用场景
-
分布式协调:A 向 mq 发送一个请求,由 B 来处理,A 怎么样知道 B 处理完了呢?A 对 zk 的一个节点值注册一个监听器,当 B 处理完毕之后,修改这个值,那么 A 就能知道 B 处理完毕了
-
分布式锁:排它锁,利用 zk 只能创建相同节点一次的特性。共享锁,创建顺序节点
-
元数据,配置信息管理:kafka,storm等系统用zk来做信息管理
-
HA
RocketMQ
TREE
-
二叉树
1)在二叉树的第i层上最多有2i-1 个节点 。(i>=1) 2)二叉树中如果深度为k,那么最多有2k-1个节点。(k>=1) 3)n0=n2+1 n0表示度数为0的节点数,n2表示度数为2的节点数。 4)在完全二叉树中,具有n个节点的完全二叉树的深度为[log2n]+1,其中[log2n]是向下取整。 5)若对含 n 个结点的完全二叉树从上到下且从左至右进行 1 至 n 的编号,则对完全二叉树中任意一个编号为 i 的结点有如下特性:
(1) 若 i=1,则该结点是二叉树的根,无双亲, 否则,编号为 [i/2] 的结点为其双亲结点; (2) 若 2i>n,则该结点无左孩子, 否则,编号为 2i 的结点为其左孩子结点; (3) 若 2i+1>n,则该结点无右孩子结点, 否则,编号为2i+1 的结点为其右孩子结点。
-
满二叉树
满二叉树:在一棵二叉树中。如果所有分支结点都存在左子树和右子树,并且所有叶子都在同一层上,这样的二叉树称为满二叉树。 满二叉树的特点有: 1)叶子只能出现在最下一层。出现在其它层就不可能达成平衡。 2)非叶子结点的度一定是2。 3)在同样深度的二叉树中,满二叉树的结点个数最多,叶子数最多。
-
完全二叉树
3.6 完全二叉树
完全二叉树:对一颗具有n个结点的二叉树按层编号,如果编号为i(1<=i<=n)的结点与同样深度的满二叉树中编号为i的结点在二叉树中位置完全相同,则这棵二叉树称为完全二叉树。 图3.5展示一棵完全二叉树
图3.5 完全二叉树
特点: 1)叶子结点只能出现在最下层和次下层。 2)最下层的叶子结点集中在树的左部。 3)倒数第二层若存在叶子结点,一定在右部连续位置。 4)如果结点度为1,则该结点只有左孩子,即没有右子树。 5)同样结点数目的二叉树,完全二叉树深度最小。 注:满二叉树一定是完全二叉树,但反过来不一定成立。
-
二叉树的遍历
-
前序遍历:父节点 ~ 左子树 ~ 右子树
-
中序遍历:左子树 ~ 父节点 ~ 右子树
-
后续遍历:左子树 ~ 右子树 ~ 父节点
-
-
二叉搜索树
-
定义
二叉搜索树又称二叉查找树,亦称为二叉排序树。设x为二叉查找树中的一个节点,x节点包含关键字key,节点x的key值记为key[x]。如果y是x的左子树中的一个节点,则key[y] <= key[x];如果y是x的右子树的一个节点,则key[y] >= key[x]。
-
性质
(1)若左子树不空,则左子树上所有节点的值均小于它的根节点的值;
(2)若右子树不空,则右子树上所有节点的值均大于它的根节点的值;
(3)左、右子树也分别为二叉搜索树;
-
图示
-
使用二叉搜索树查找的平均时间复杂度为O(log2n)
-
-
平衡二叉树
-
左右子树高度不超过1的二叉树
-
通过左旋或者右旋来实现平衡
-
-
红黑树
-
等价于2-3-4树,查找、插入、删除的时间复杂度为O(logN)
-
-
B 树(B-Tree)
-
多路平衡查找树
一棵m阶的B-Tree有如下特性:
-
每个节点最多有m个孩子
-
除了根节点和叶子节点外,其它每个节点至少有Ceil(m/2)个孩子。
-
若根节点不是叶子节点,则至少有2个孩子
-
所有叶子节点都在同一层,且不包含其它关键字信息
-
每个非终端节点包含n个关键字信息(P0,P1,…Pn, k1,…kn)
-
关键字的个数n满足:ceil(m/2)-1 <= n <= m-1
-
ki(i=1,…n)为关键字,且关键字升序排序
-
Pi(i=1,…n)为指向子树根节点的指针。P(i-1)指向的子树的所有节点关键字均小于ki,但都大于k(i-1)
B-Tree 中的每个节点根据实际情况可以包含大量的关键字信息和分支,如下图所示为一个 3 阶的 B-Tree:
每个节点占用一个盘块的磁盘空间,一个节点上有两个升序排序的关键字和三个指向子树根节点的指针,指针存储的是子节点所在磁盘块的地址。两个关键词划分成的三个范围域对应三个指针指向的子树的数据的范围域。以根节点为例,关键字为17和35,P1指针指向的子树的数据范围为小于17,P2指针指向的子树的数据范围为17~35,P3指针指向的子树的数据范围为大于35。
-
-
-
B+ Tree
-
与 B- Tree 不同的是,为了避免每个关键字 key 对应的数据域过大导致一个节点存储的 key 数量过少,而导致的树的高度增加,B+ Tree 将所有数据按照 key 的大小顺序存储于同一层的叶子节点上面,非叶子节点只存储 key 值和指向孩子节点的指针,每一个节点可以大大增加 key 的数量,降低数的高度
-
B+Tree相对于B-Tree有几点不同:
-
非叶子节点只存储键值信息;
-
所有叶子节点之间都有一个链指针;
-
数据记录都存放在叶子节点中
将上一节中的B-Tree优化,由于B+Tree的非叶子节点只存储键值信息,假设每个磁盘块能存储4个键值及指针信息,则变成B+Tree后其结构如下图所示:
通常在B+Tree上有两个头指针,一个指向根节点,另一个指向关键字最小的叶子节点,而且所有叶子节点(即数据节点)之间是一种链式环结构。因此可以对B+Tree进行两种查找运算:一种是对于主键的范围查找和分页查找,另一种是从根节点开始,进行随机查找。
可能上面例子中只有22条数据记录,看不出B+Tree的优点,下面做一个推算:
InnoDB存储引擎中页的大小为16KB,一般表的主键类型为INT(占用4个字节)或BIGINT(占用8个字节),指针类型也一般为4或8个字节,也就是说一个页(B+Tree中的一个节点)中大概存储16KB/(8B+8B)=1K个键值(因为是估值,为方便计算,这里的K取值为10^3)。也就是说一个深度为3的B+Tree索引可以维护10^3 * 10^3 * 10^3 = 10亿 条记录。
实际情况中每个节点可能不能填充满,因此在数据库中,B+Tree的高度一般都在2-4层。MySQL的InnoDB存储引擎在设计时是将根节点常驻内存的,也就是说查找某一键值的行记录时最多只需要1~3次磁盘I/O操作。
B+Tree性质
-
通过上面的分析,我们知道IO次数取决于b+数的高度h,假设当前数据表的数据为N,每个磁盘块的数据项的数量是m,则有h=㏒(m+1)N,当数据量N一定的情况下,m越大,h越小;而m = 磁盘块的大小 / 数据项的大小,磁盘块的大小也就是一个数据页的大小,是固定的,如果数据项占的空间越小,数据项的数量越多,树的高度越低。这就是为什么每个数据项,即索引字段要尽量的小,比如int占4字节,要比bigint8字节少一半。这也是为什么b+树要求把真实的数据放到叶子节点而不是内层节点,一旦放到内层节点,磁盘块的数据项会大幅度下降,导致树增高。当数据项等于1时将会退化成线性表。
-
当b+树的数据项是复合的数据结构,比如(name,age,sex)的时候,b+数是按照从左到右的顺序来建立搜索树的,比如当(张三,20,F)这样的数据来检索的时候,b+树会优先比较name来确定下一步的所搜方向,如果name相同再依次比较age和sex,最后得到检索的数据;但当(20,F)这样的没有name的数据来的时候,b+树就不知道下一步该查哪个节点,因为建立搜索树的时候name就是第一个比较因子,必须要先根据name来搜索才能知道下一步去哪里查询。比如当(张三,F)这样的数据来检索时,b+树可以用name来指定搜索方向,但下一个字段age的缺失,所以只能把名字等于张三的数据都找到,然后再匹配性别是F的数据了, 这个是非常重要的性质,即索引的最左匹配特性。
-
-