Linux 6种日志查看方法

  1. tail:
    1. tail -n 10 test.log 查询日志尾部最后10行的日志;
    2. tail -n +10 test.log 查询10行之后的所有日志;
  2. head:与tail相反
  3. cat:由第一行到最后一行连续显示在屏幕上
  4. more:可翻页
  5. sed:查找特定一段
    1. sed -n ‘5,10p’ filename 这样你就可以只查看文件的第5行到第10行。
  6. less:使用less可以随意浏览文件,而more仅能向前移动,不能向后移动

逻辑删除和物理删除

逻辑删除:像一些订单
物理删除:操作日志

SpringBoot定时器

在启动类中加入 @EnableScheduling 开启定时器

1
2
3
4
5
6
7
8
9
10
@Component
public class Schedu {
//每五秒执行方法
@Scheduled(cron = "0/5 * * * * ? ")
public void scheduled() {
SimpleDateFormat formatter= new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
System.out.println("在 "+formatter.format(new Date())+" 执行了方法。");
}
}

Kafka实现消息通知

比如出库操作发现仓库缺货的时候,会生成一个补货通知,将具体通知事件发送到指定的topic,然后服务会去监听这个topic,首先判断是否由收到消息,然后把收到的消息字符串转换为Event对象,然后我们将该消息经过处理存储到message数据库中。

打开APP主页去查询消息

计算机网络

HTTPS 执行流程

  1. 客户端使用 HTTPS 访问服务器端。
  2. 服务器端返回数字证书,以及使用非对称加密,生成一个公钥给客户端(私钥服务器端自己保留)。
  3. 客户端验证数字证书是否有效,如果无效,终止访问,如果有效:
    1. 使用对称加密生成一个共享秘钥;
    2. 使用对称加密的共享秘钥加密数据;
    3. 使用非对称加密的公钥加密(对称加密生成的)共享秘钥。
    4. 发送加密后的秘钥和数据给服务器端。
  4. 服务器端使用私钥解密出客户端(使用对称加密生成的)共享秘钥,再使用共享秘钥解密出数据的具体内容。
  5. 之后客户端和服务器端就使用共享秘钥加密的内容内容进行交互了。

    使用加密的方式也间接的保证了数据的完整性问题,如果是不完整的数据或有多余的数据,那么在解密时会报错,这样就能间接的保证数据的完整性了。

Java基础

面向对象的三大特性

封装,继承,多态
多态:对于同一个行为,不同的子类对象具有不同的表现形式
比如继承了一个动物类,重写叫声这个方法,cat类就是喵喵喵,dog类就是汪汪汪

实现多线程的几种方式

  1. 继承Thread类,重写run方法
  2. 实现Runnable接口,重写run方法,实现Runnable接口的实现类的实例对象作为Thread构造函数的target
  3. 通过Callable和FutureTask创建线程
  4. 通过线程池创建线程

接口和抽象类有什么共同点和区别?

共同点

  • 都不能被实例化。
  • 都可以包含抽象方法。
  • 都可以有默认实现的方法(Java 8 可以用 default 关键字在接口中定义默认方法)。

区别

  • 接口主要用于对类的行为进行约束,你实现了某个接口就具有了对应的行为。抽象类主要用于代码复用,强调的是所属关系。
  • 一个类只能继承一个类,但是可以实现多个接口。
  • 接口中的成员变量只能是 public static final 类型的,不能被修改且必须有初始值,而抽象类的成员变量默认 default,可在子类中被重新定义,也可被重新赋值。

TCP和UDP的区别

TCP是面向连接的,UDP是无连接的
TCP是可靠的,UDP是不可靠的
TCP是面向字节流的,UDP是面向数据报文的
TCP只支持点对点通信,UDP支持一对一,一对多,多对多
TCP报文首部20个字节,UDP首部8个字节
TCP有拥塞控制机制,UDP没有
TCP协议下双方发送接受缓冲区都有,UDP并无实际意义上的发送缓冲区,但是存在接受缓冲区

Java集合

Fail-Fast机制

  1. 当多个线程对同一个集合进行操作的时候,某个线程通过iterator访问集合的时候,其他线程对这个集合进行了add,remove,clear等方法,这时候就会抛出ConcurrentModificationException异常,产生fail-fast事件。
  2. 在 foreach 循环里进行元素的 remove/add 操作, foreach 语法底层其实还是依赖 Iterator 。不过, remove/add 操作直接调用的是集合自己的方法,而不是 Iterator 的 remove/add方法,这就导致 Iterator 莫名其妙地发现自己有元素被 remove/add ,然后,它就会抛出一个 ConcurrentModificationException。这就是单线程状态下产生的 fail-fast 机制

    可以使用CopyOnWrite(写时复制)容器避免这个问题:

    定义:
    当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy,复制出一个新的容器,然后新的容器里添加元素,添加完元素之后,再将原容器的引用指向新的容器。这样做的好处是我们可以对CopyOnWrite容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素。所以CopyOnWrite容器也是一种读写分离的思想,读和写不同的容器。
    CopyOnWrite容器只能保证数据的最终一致性,不能保证数据的实时一致性。

    缺点:
    内存占用问题,由于写时复制,在进行写操作的时候内存中会存在两个对象的内存,如果这些对象占用的内存比较大,那么再写入一些新数据的时候,可能会造成Full GC

红黑树

定义:

  1. 每一个结点都有一个颜色,要么为红色,要么为黑色;
  2. 树的根结点为黑色;
  3. 树中不存在两个相邻的红色结点(即红色结点的父结点和孩子结点均不能是红色);
  4. 从任意一个结点(包括根结点)到其任何后代 NULL 结点(默认是黑色的)的每条路径都具有相同数量的黑色结点。

为什么要有红黑树:

二叉排序树:
大多数针对二叉排序树的操作(查找,最大值,最小值,插入,删除等等)都是O(h)的时间复杂度,h为树的高度,但是对于斜树(极端情况下)会达到O(n)的复杂度。
平衡二叉树AVL:
AVL树在进行添加删除时候会存在大量的旋转操作。所以当你的应用涉及到频繁的插入和删除操作的时候请选择性能更好的红黑树;当然,如果你的应用查找操作相对更频繁,那么就优先选择 AVL 树进行实现。

HashMap

HashMap遍历

HashMap遍历的时候是无序的,和插入顺序没有关系
同一个HashMap在循环遍历的时候,顺序不会改变

JDK1.8和1.7的区别

  1. 引入红黑树解决链表过长的问题
  2. 重写resize方法

负载因子

HashMap 所能容纳的键值对数量

当我们调低负载因子时,HashMap 所能容纳的键值对数量变少。扩容时,重新将键值对存储新的桶数组里,键与键之间产生的碰撞会下降,链表长度变短。此时,HashMap 的增删改查等操作的效率将会变高,这里是典型的拿空间换时间
增加负载因子(负载因子可以大于1),HashMap 所能容纳的键值对数量变多,空间利用率高,但碰撞率也高。这意味着链表长度变长,效率也随之降低,这种情况是拿时间换空间

加载因子越大,填满的元素越多,好处是,空间利用率高了,但:冲突的机会加大了.
加载因子越小,填满的元素越少,好处是:冲突的机会减小了,但:空间浪费多了.

hash 方法

通过移位和异或运算,可以让 hash 变得更复杂,进而影响 hash 的分布性

1
2
3
4
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

这里为什么要这样做呢?为什么不直接用键的 hashCode 方法产生的 hash 呢
图中的 hash 是由键的 hashCode 产生。计算余数时,由于 n 比较小,hash 只有低4位参与了计算,高位的计算可以认为是无效的。这样导致了计算结果只与低位信息有关,高位数据没发挥作用。为了处理这个缺陷,我们可以上图中的 hash 高4位数据与低4位数据进行异或运算,即 hash ^ (hash >>> 4)。通过这种方式,让高位数据与低位数据进行异或,以此加大低位信息的随机性,变相的让高位数据参与到计算中。此时的计算过程如下:
image.png

为什么不使用AVL树或者B+树

因为AVL对于添加删除时候的旋转,相比红黑树更难实现,虽然提升速度,但是代价太大
B和B+树主要用于数据存储在磁盘上的场景。这两种数据结构的特点就是树比较矮胖,每个结点存放一个磁盘大小的数据,这样一次可以把一个磁盘的数据读入内存,减少磁盘转动的耗时,提高效率。而红黑树多用于内存中排序,也就是内部排序

ConcurrentHashMap实现线程安全的方式

  • JDK1.7时:对整个桶数组进行了分段(Segment,分段锁),每一把锁只锁容器中的一部分数据,多线程访问容器中不同数据段的数据时,不会存在锁竞争,提高效率。
  • JDK1.8时:直接使用Node数组+链表+红黑树,并发控制通过Synchronized(JDK1.6后Synchronized做了很多优化)和CAS来操作。
  • JDK1.8中,synchronized只锁定当前链表或者红黑树的首节点,这样只要hash不冲突,就不会产生并发,提升效率。

JDK 1.7 和 JDK 1.8 的 ConcurrentHashMap 实现有什么不同

  • 线程安全实现方式:JDK1.7使用Segment分段锁来实现线程安全,Segment继承自ReentrantLock。JDK1.8使用Node+CAS+Synchronized来实现线程安全,而且Synchronized只锁链表或者红黑树的首节点,锁粒度更细
  • Hash碰撞解决办法:JDK1.7使用拉链法,JDK1.8在拉链法的基础上加入了红黑树
  • 并发度:JDK1.7最大并发数是Segment的个数,默认是16,JDK1.8最大并发度是Node数组的大小,并发度更大

Java并发

进程和线程

进程:是系统进行资源分配和调度的基本单位
线程:是系统进行资源调度的最小单位

线程拥有私有空间的意义

  • 程序计数器:记录程序执行到的位置,以方便CPU切换回来能够继续从上次执行到的位置开始执行。
  • 局部变量:方法中的变量,只供当前方法使用
  • 方法参数:Java 方法会定义自己的入参,入参的真实值也会记录到内存空间供当前线程使用

线程的生命周期

  • NEW: 初始状态,线程被创建出来但没有被调用 start() 。
  • RUNNABLE: 运行状态,线程被调用了 start()等待运行的状态。
  • BLOCKED :阻塞状态,需要等待锁释放。
  • WAITING:等待状态,表示该线程需要等待其他线程做出一些特定动作(通知或中断)。
  • TIME_WAITING:超时等待状态,可以在指定的时间后自行返回而不是像 WAITING 那样一直等待。(执行sleep后)
  • TERMINATED:终止状态,表示该线程已经运行完毕。

Java多线程 3 种创建方式:

  • 方式一:继承 Thread 类的方式创建线程;
  • 方式二:实现 java.lang.Runnable 接口;
  • 方式三:实现 Callable 接口。

yield方法

暂停当前正在执行的线程对象(及放弃当前拥有的 cup 资源),并执行其他线程。yield () 做的是让当前运行线程回到可运行状态,以允许具有相同优先级的其他线程获得运行机会。
但是,实际中无法保证 yield () 达到谦让目的,因为放弃 CPU 执行权的线程还有可能被线程调度程序再次选中

yield 方法和 sleep 方法的区别

  • sleep () 方法给其他线程运行机会时不考虑线程的优先级,因此会给低优先级的线程以运行的机会;
  • yield () 方法只会给相同优先级或更高优先级的线程以运行的机会;
  • 线程执行 sleep () 方法后转入超时等待 (TIME_WAITING) 状态,而执行 yield () 方法后转入就绪 (ready) 状态;
  • sleep () 方法声明会抛出 InterruptedException, 而 yield () 方法没有声明任何异常;
  • sleep () 方法比 yield () 方法具有更好的移植性 (跟操作系统 CPU 调度相关)。

乐观锁和悲观锁

乐观锁定义

大部分时候认为不会出现并发问题,用于读多写少的情况。通常来说是版本号机制或者CAS(compare and swap)来实现

乐观锁存在的问题

  1. ABA问题:解决思路是加版本号或者时间戳,AtomicStampedReference
  2. 循环时间长开销大:CAS通常需要自旋操作来重试,如果长时间不成功就会给CPU带来很大的开销
  3. 只能保证一个共享变量的原子操作:使用AtomicReference把多个共享变量合并成一个类来充当共享变量进行操作

线程安全的实现方式

互斥同步

synchronized,经过javac编译后,会在同步代码块前后形成monitorenter和monitorexit指令,这俩指令需要一个reference类型的参数来指明要锁定和解锁的对象,如果没有明确指明,那将根据synchronized修饰的方法类型来决定是取代码所在的对象实例还是取类型对应的Class对象来作为线程持有的锁。

在执行monitorenter时,首先要去获取对象的锁,如果对象没被锁定,或者当前线程已经持有这个锁了,那么会将锁计数器加一,执行monitorexit减一,如果计数器的值为0,那么锁就会被释放。如果获取对象失败,那么就会一直阻塞等待。

synchronized是可重入锁
因为被synchronized修饰的线程会无条件阻止其他线程进入,所以无法强制释放锁,也无法强制中断或者超时退出

锁优化

锁升级

无锁 —–> 偏向锁 —–> 轻量级锁 —–> 重量级锁

1. 偏向锁 (JDK6引入,JDK15废除)

当轻量级锁(没有竞争,就自己这个线程)+ 锁重入时,每次都需要生成锁记录,并尝试CAS替换对象头的MarkWord操作。JDK6引入偏向锁做进一步优化: 第一次加锁时,不生成锁记录,将线程ID设置到锁对象的MarkWord中;之后锁重入时,检查MarkWord是否是自己的线程ID,只要不发生竞争,则可以一直使用偏向锁。

2. 自旋锁和自适应自旋锁

自旋等待不能代替阻塞,虽然避免了线程切换的开销,但是浪费了CPU资源
JDK6中引入了自适应自旋,自旋的时间不再是固定的,而是由上次同一个锁上的自旋时间决定的。

3. 轻量级锁

在代码进入同步块的时候,判断对象头Mark Word的标记位,如果没有被锁定,虚拟机将生成一个锁记录Lock Record,并尝试用CAS操作去将对象的Mark Word更新为指向当前锁记录地址,如果更新成功,则代表获得当前锁,如果更新失败,则证明至少存在一条线程与当前线程竞争获取该锁,判断当前对象的Mark Word是否指向当前锁记录地址,如果是表示当前线程已经拥有这个锁,,如果不是,则尝试进行自适应自旋,达到临界值后阻塞

4. 锁消除

虚拟机编译器运行时,对于一些代码要求同步,但是检测到不可能存在共享数据竞争的锁进行消除

5. 锁粗化

对于一系列的连续操作都对同一个对象反复加锁解锁,甚至加锁解锁操作是在循环体内的,那么即使没有线程竞争,也会造成不少的损耗,所以虚拟机会对这些操作加锁范围粗化到整个范围之外

ThreadLocal

什么是ThreadLocal

一种数据结构,类似于HashMap,可以存放键值对,但只可以存放一个

ThreadLocal和Synchronized

两者都用于解决多线程并发访问。但是ThreadLocal与synchronized有本质的区别。Synchronized用于线程间的数据共享,而ThreadLocal则用于线程间的数据隔离。Synchronized是利用锁的机制,使变量或代码块在某一时该只能被一个线程访问。而ThreadLocal为每一个线程都提供了变量的副本,使得每个线程在某一时间访问到的并不是同一个对象,这样就隔离了多个线程对数据的数据共享。而Synchronized却正好相反,它用于在多个线程间通信时能够获得数据共享。

ThreadLocal中的key和value

ThreadLocal实例的弱引用对象会作为key存放在ThreadLocalMap中,然后set方法加入的值就作为ThreadLocalMap中的value,一个线程中可以new多个ThreadLocal用于存放多个值,这些值在线程内是共享的

ThreadLocal内存泄漏

如果ThreadLocal没有外部强引用,那么在发生垃圾回收的时候,ThreadLocal就必定会被回收,而ThreadLocal又作为Map中的key,ThreadLocal被回收就会导致一个key为null的entry,外部就无法通过key来访问这个entry,垃圾回收也无法回收,这就造成了内存泄漏
解决方案
解决办法是每次使用完ThreadLocal都调用它的remove()方法清除数据,或者按照JDK建议将ThreadLocal变量定义成private static,这样就一直存在ThreadLocal的强引用,也就能保证任何时候都能通过ThreadLocal的弱引用访问到Entry的value值,进而清除掉。

线程池

FixedThreadPool 固定大小线程池

FixedThreadPool 被称为可重用固定线程数的线程池

1
2
3
4
5
6
7
8
9
10
/**
* 创建一个可重用固定数量线程的线程池
*/
public static ExecutorService newFixedThreadPool(int nThreads, ThreadFactory threadFactory) {
return new ThreadPoolExecutor(nThreads, nThreads,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>(),
threadFactory);
}

为什么不推荐使用FixedThreadPool?

FixedThreadPool 使用无界队列 LinkedBlockingQueue(队列的容量为 Integer.MAX_VALUE)作为线程池的工作队列会对线程池带来如下影响 :

  1. 当线程池中的线程数达到 corePoolSize 后,新任务将在无界队列中等待,因此线程池中的线程数不会超过 corePoolSize;
  2. 由于使用无界队列时 maximumPoolSize 将是一个无效参数,因为不可能存在任务队列满的情况。所以,通过创建 FixedThreadPool的源码可以看出创建的 FixedThreadPool 的 corePoolSize 和 maximumPoolSize 被设置为同一个值。
  3. 由于 1 和 2,使用无界队列时 keepAliveTime 将是一个无效参数;
  4. 运行中的 FixedThreadPool(未执行 shutdown()或 shutdownNow())不会拒绝任务,在任务比较多的时候会导致 OOM(内存溢出)。

SingleThreadExecutor 单线程线程池

是只有一个线程的线程池

1
2
3
4
5
6
7
8
9
10
11
/**
*返回只有一个线程的线程池
*/
public static ExecutorService newSingleThreadExecutor(ThreadFactory threadFactory) {
return new FinalizableDelegatedExecutorService
(new ThreadPoolExecutor(1, 1,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>(),
threadFactory));
}

为什么不推荐使用SingleThreadExecutor?

SingleThreadExecutor 使用无界队列 LinkedBlockingQueue 作为线程池的工作队列(队列的容量为 Intger.MAX_VALUE)。SingleThreadExecutor 使用无界队列作为线程池的工作队列会对线程池带来的影响与 FixedThreadPool 相同。说简单点就是可能会导致 OOM,

CachedThreadPool 缓冲线程池

是一个会根据需要创建新线程的线程池

1
2
3
4
5
6
7
8
9
10
11
/**
* 创建一个线程池,根据需要创建新线程,但会在先前构建的线程可用时重用它。
*/
public static ExecutorService newCachedThreadPool(ThreadFactory threadFactory) {
return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
60L, TimeUnit.SECONDS,
new SynchronousQueue<Runnable>(),
threadFactory);
}


CachedThreadPool 的corePoolSize 被设置为空(0),maximumPoolSize被设置为 Integer.MAX.VALUE,即它是无界的,这也就意味着如果主线程提交任务的速度高于 maximumPool 中线程处理任务的速度时,CachedThreadPool 会不断创建新的线程。极端情况下,这样会导致耗尽 cpu 和内存资源。

为什么不推荐使用CachedThreadPool?

CachedThreadPool允许创建的线程数量为 Integer.MAX_VALUE ,可能会创建大量线程,从而导致 OOM。

ScheduledThreadPoolExecutor

ScheduledThreadPoolExecutor 主要用来在给定的延迟后运行任务,或者定期执行任务。 这个在实际项目中基本不会被用到,也不推荐使用,大家只需要简单了解一下它的思想即可。

使用线程池的好处

  • 降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
  • 提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立即执行。
  • 提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。

ThreadPoolExecutor的参数

  • corePoolSize : 核心线程数,定义了最小可以同时运行的线程数量。
  • maximumPoolSize : 线程池的最大线程数,当队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数。
  • workQueue: 任务队列,当新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。
  • keepAliveTime:当线程池中的线程数量大于 corePoolSize 的时候,如果这时没有新的任务提交,核心线程外的线程不会立即销毁,而是会等待,直到等待的时间超过了 keepAliveTime才会被回收销毁;
  • unit : keepAliveTime 参数的时间单位。
  • threadFactory :线程工厂,用来创建线程,一般默认即可
  • handler :拒绝策略,当提交的任务过多而不能及时处理时,我们可以定制策略来处理任务

拒绝策略

  • AbortPolicy: 抛出 RejectedExecutionException来拒绝新任务的处理。
  • CallerRunsPolicy: 将任务分给调用线程来执行,运行当前被丢弃的任务,这样做不会真的丢弃任务,但是提交的线程性能有可能急剧下降。
  • DiscardPolicy: 不处理新任务,直接丢弃掉。
  • DiscardOldestPolicy: 此策略将丢弃最早的未处理的任务请求。

为什么当线程池的核心线程满了后,是先加入到阻塞队列,而不是先创建新的线程?

  • 线程池创建线程需要获取mainlock这个全局锁,会影响并发效率,所以使用阻塞队列把第一步创建核心线程与第三步创建最大线程隔离开来,起一个缓冲的作用。
  • 引入阻塞队列,是为了在执行execute()方法时,尽可能的避免获取全局锁。

线程池原理

如何设定线程池的大小?

  • CPU 密集型任务(N+1): 这种任务消耗的主要是 CPU 资源,可以将线程数设置为 N(CPU 核心数)+1,比 CPU 核心数多出来的一个线程是为了防止线程偶发的缺页中断,或者其它原因导致的任务暂停而带来的影响。一旦任务暂停,CPU 就会处于空闲状态,而在这种情况下多出来的一个线程就可以充分利用 CPU 的空闲时间。
  • I/O 密集型任务(2N): 这种任务应用起来,系统会用大部分的时间来处理 I/O 交互,而线程在处理 I/O 的时间段内不会占用 CPU 来处理,这时就可以将 CPU 交出给其它线程使用。因此在 I/O 密集型任务的应用中,我们可以多配置一些线程,具体的计算方法是 2N。

如何判断是 CPU 密集任务还是 IO 密集任务?
CPU 密集型简单理解就是利用 CPU 计算能力的任务比如你在内存中对大量数据进行排序。但凡涉及到网络读取,文件读取这类都是 IO 密集型,这类任务的特点是 CPU 计算耗费时间相比于等待 IO 操作完成的时间来说很少,大部分时间都花在了等待 IO 操作完成上。

线程池常用工作队列

  1. ArrayBlockingQueue: 是一个基于数组结构的有界阻塞队列,此队列按 FIFO(先进先出)原则对元素进行排序。 有界
  2. LinkedBlockingQueue: 一个基于链表结构的阻塞队列,此队列按FIFO (先进先出) 排序元素,吞吐量通常要高于ArrayBlockingQueue。静态工厂方法Executors.newFixedThreadPo·ol()使用了这个队列。无界
  3. SynchronousQueue: 一个不存储元素的阻塞队列。每个插入操作必须等到另一个线程调用移除操作,否则插入操作一直处于阻塞状态,吞吐量通常要高于LinkedBlockingQueue,静态工厂方法Executors.newCachedThreadPool(5)使用了这个队列。 无界
  4. PriorityBlockingQueue: 一个具有优先级的无限阻塞队列无界

线程池为什么要添加空的核心线程

  1. 当核心线程数为0
  2. 核心线程超时被干掉了

如果阻塞队列中加入任务,但是没有核心线程,此时会添加一个空的核心线程

线程池使用完毕为什么要调用shutdown

  1. 核心线程不会被垃圾回收,造成线程内存泄漏

JUC

  • ConcurrentHashMap : 线程安全的 HashMap
  • CopyOnWriteArrayList : 线程安全的 List,在读多写少的场合性能非常好,远远好于 Vector。

JVM

运行时数据区域

程序计数器

  • 用于代码的流程控制,如:顺序执行、选择、循环、异常处理。
  • 用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。

    注意 :程序计数器是唯一一个不会出现 OutOfMemoryError 的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡。

虚拟机栈

线程私有的,它的生命周期和线程相同,随着线程的创建而创建,随着线程的死亡而死亡。
存放局部变量表和对象引用,实现方法调用

本地方法栈

本地方法栈为虚拟机使用到的 Native 方法服务

此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。
Java 堆是垃圾收集器管理的主要区域,因此也被称作 GC 堆(Garbage Collected Heap)。从垃圾回收的角度,由于现在收集器基本都采用分代垃圾收集算法,所以 Java 堆还可以细分为:新生代和老年代;再细致一点有:Eden、Survivor、Old 等空间。进一步划分的目的是更好地回收内存,或者更快地分配内存。

在 JDK 7 版本及 JDK 7 版本之前,堆内存被通常分为下面三部分:

  1. 新生代内存(Young Generation)
  2. 老生代(Old Generation)
  3. 永久代(Permanent Generation)

JDK 8 版本之后 PermGen(永久) 已被 Metaspace(元空间) 取代,元空间使用的是直接内存

方法区

方法区属于是 JVM 运行时数据区域的一块逻辑区域,是各个线程共享的内存区域。
方法区会存储已被虚拟机加载的 元数据信息(类信息、字段信息、方法信息)、常量、静态变量、即时编译器编译后的代码缓存等数据

永久代以及元空间是 HotSpot 虚拟机对虚拟机规范中方法区的两种实现方式。并且,永久代是 JDK 1.8 之前的方法区实现,JDK 1.8 及以后方法区的实现变成了元空间。

运行时常量池

字符串常量池

字符串常量池 是 JVM 为了提升性能和减少内存消耗针对字符串(String 类)专门开辟的一块区域,主要目的是为了避免字符串的重复创建。

直接内存

直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用。而且也可能导致 OutOfMemoryError 错误出现。
JDK1.4 中新加入的 NIO(New Input/Output) 类,引入了一种基于通道(Channel)与缓存区(Buffer)的 I/O 方式,它可以直接使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样就能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆之间来回复制数据
本机直接内存的分配不会受到 Java 堆的限制,但是,既然是内存就会受到本机总内存大小以及处理器寻址空间的限制。

对象的创建

Java 对象的创建过程我建议最好是能默写出来,并且要掌握每一步在做什么。

Step1:类加载检查

虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程。

Step2:分配内存

类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。分配方式“指针碰撞”“空闲列表” 两种,选择哪种分配方式由 Java 堆是否规整决定,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定
内存分配的两种方式 (补充内容,需要掌握):

  • 指针碰撞 :
    • 适用场合 :堆内存规整(即没有内存碎片)的情况下。
    • 原理 :用过的内存全部整合到一边,没有用过的内存放在另一边,中间有一个分界指针,只需要向着没用过的内存方向将该指针移动对象内存大小位置即可。
    • 使用该分配方式的 GC 收集器:Serial, ParNew
  • 空闲列表 :
    • 适用场合 : 堆内存不规整的情况下。
    • 原理 :虚拟机会维护一个列表,该列表中会记录哪些内存块是可用的,在分配的时候,找一块儿足够大的内存块儿来划分给对象实例,最后更新列表记录。
    • 使用该分配方式的 GC 收集器:CMS

选择以上两种方式中的哪一种,取决于 Java 堆内存是否规整。而 Java 堆内存是否规整,取决于 GC 收集器的算法是”标记-清除”,还是”标记-整理”(也称作”标记-压缩”),值得注意的是,复制算法内存也是规整的。

内存分配并发问题(补充内容,需要掌握)

在创建对象的时候有一个很重要的问题,就是线程安全,因为在实际开发过程中,创建对象是很频繁的事情,作为虚拟机来说,必须要保证线程是安全的,通常来讲,虚拟机采用两种方式来保证线程安全:

  • CAS+失败重试: CAS 是乐观锁的一种实现方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性。
  • TLAB: 为每一个线程预先在 Eden 区分配一块儿内存,JVM 在给线程中的对象分配内存时,首先在 TLAB 分配,当对象大于 TLAB 中的剩余内存或 TLAB 的内存已用尽时,再采用上述的 CAS 进行内存分配

Step3:初始化零值

内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。

Step4:设置对象头

初始化零值完成之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。 另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。

Step5:执行 init 方法

在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始, 方法还没有执行,所有的字段都还为零。所以一般来说,执行 new 指令之后会接着执行 方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。

对象的内存布局

在 Hotspot 虚拟机中,对象在内存中的布局可以分为 3 块区域:对象头实例数据对齐填充
Hotspot 虚拟机的对象头包括两部分信息第一部分用于存储对象自身的运行时数据(哈希码、GC 分代年龄、锁状态标志等等),另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
实例数据部分是对象真正存储的有效信息,也是在程序中所定义的各种类型的字段内容。
对齐填充部分不是必然存在的,也没有什么特别的含义,仅仅起占位作用。 因为 Hotspot 虚拟机的自动内存管理系统要求对象起始地址必须是 8 字节的整数倍,换句话说就是对象的大小必须是 8 字节的整数倍。而对象头部分正好是 8 字节的倍数(1 倍或 2 倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全

内存分配和回收原则

对象优先在Eden区分配

当Eden区没有空间分配,虚拟机将会进行一次Minor GC

大对象直接进入老年代

大对象就是需要大量连续内存空间的对象(比如:字符串、数组)。
大对象直接进入老年代主要是为了避免为大对象分配内存时由于分配担保机制带来的复制而降低效率。

长期存活的对象直接进入老年代

既然虚拟机采用了分代收集的思想来管理内存,那么内存回收时就必须能识别哪些对象应放在新生代,哪些对象应放在老年代中。为了做到这一点,虚拟机给每个对象一个对象年龄(Age)计数器
大部分情况,对象都会首先在 Eden 区域分配。如果对象在 Eden 出生并经过第一次 Minor GC 后仍然能够存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间(s0 或者 s1)中,并将对象年龄设为 1(Eden 区->Survivor 区后对象的初始年龄变为 1)。
对象在 Survivor 中每熬过一次 MinorGC,年龄就增加 1 岁,当它的年龄增加到一定程度(默认为 15 岁),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。

针对 HotSpot VM 的实现,它里面的 GC 其实准确分类只有两大种:

部分收集 (Partial GC):

  • 新生代收集(Minor GC / Young GC):只对新生代进行垃圾收集;
  • 老年代收集(Major GC / Old GC):只对老年代进行垃圾收集。需要注意的是 Major GC 在有的语境中也用于指代整堆收集;
  • 混合收集(Mixed GC):对整个新生代和部分老年代进行垃圾收集。

整堆收集 (Full GC):收集整个 Java 堆和方法区

死亡对象判断法

引用计数法

这个方法实现简单,效率高,但是目前主流的虚拟机中并没有选择这个算法来管理内存,其最主要的原因是它很难解决对象之间相互循环引用的问题。

可达性分析算法

通过一系列的称为 “GC Roots” 的对象作为起点,从这些节点开始向下搜索,节点所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连的话,则证明此对象是不可用的,需要被回收。

哪些对象可以作为 GC Roots 呢?
  • 虚拟机栈(栈帧中的本地变量表)中引用的对象
  • 本地方法栈(Native 方法)中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 所有被同步锁持有的对象

对象可以被回收,就代表一定会被回收吗?

即使在可达性分析法中不可达的对象,要真正宣告一个对象死亡,至少要经历两次标记过程;可达性分析法中不可达的对象被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行 finalize 方法。当对象没有覆盖 finalize 方法,或 finalize 方法已经被虚拟机调用过时,虚拟机将这两种情况视为没有必要执行。
被判定为需要执行的对象将会被放在一个队列中进行第二次标记,除非这个对象与引用链上的任何一个对象建立关联,否则就会被真的回收。

如何判断一个类是无用的类

需要同时满足三个条件:

  • 该类的所有实例都已经被回收
  • 加载该类的ClassLoader已经被回收
  • 该类的Class对象没有在任何地方被引用

什么时候触发垃圾回收

是JVM根据系统环境判断,自动完成的

  1. 当Eden区或者S区不够用了
  2. 当老年代空间不够用了
  3. 当方法区不够用了
  4. System.gc() 通知jvm进行一次垃圾回收

垃圾收集算法

标记-清除算法

首先标记出所有不需要回收的对象,在标记完成后统一回收掉所有没有被标记的对象
但是它会带来两个问题:

  1. 效率问题
  2. 空间问题(标记清除后会产生大量不连续的碎片)

标记-复制算法

为了解决效率问题,“标记-复制”收集算法出现了。它可以将内存分为大小相同的两块,每次使用其中的一块。当这一块的内存使用完后,就将还存活的对象复制到另一块去,然后再把使用的空间一次清理掉。这样就使每次的内存回收都是对内存区间的一半进行回收。

标记-整理算法

根据老年代的特点提出的一种标记算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象回收,而是让所有存活的对象向一端移动,然后直接清理掉端边界以外的内存

分代收集算法

当前虚拟机的垃圾收集都采用分代收集算法,这种算法没有什么新的思想,只是根据对象存活周期的不同将内存分为几块。一般将 java 堆分为新生代和老年代,这样我们就可以根据各个年代的特点选择合适的垃圾收集算法。
比如在新生代中,每次收集都会有大量对象死去,所以可以选择”标记-复制“算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。而老年代的对象存活几率是比较高的,而且没有额外的空间对它进行分配担保,所以我们必须选择“标记-清除”或“标记-整理”算法进行垃圾收集。

HotSpot 为什么要分为新生代和老年代

参考上面的分代收集算法

垃圾收集器

Serial 收集器

Serial(串行)收集器是最基本、历史最悠久的垃圾收集器了。大家看名字就知道这个收集器是一个单线程收集器了。它的 “单线程” 的意义不仅仅意味着它只会使用一条垃圾收集线程去完成垃圾收集工作,更重要的是它在进行垃圾收集工作的时候必须暂停其他所有的工作线程( “Stop The World” ),直到它收集结束。
新生代采用标记-复制算法,老年代采用标记-整理算法。
优点:

  • 简单而高效
  • 没有线程交互的开销

ParNew 收集器

ParNew 收集器其实就是 Serial 收集器的多线程版本,除了使用多线程进行垃圾收集外,其余行为(控制参数、收集算法、回收策略等等)和 Serial 收集器完全一样。
新生代采用标记-复制算法,老年代采用标记-整理算法。

Parallel Scavenge 收集器
Parallel Scavenge 收集器也是使用标记-复制算法的多线程收集器
Parallel Scavenge 收集器关注点是吞吐量(高效率的利用 CPU)。CMS 等垃圾收集器的关注点更多的是用户线程的停顿时间(提高用户体验)。所谓吞吐量就是 CPU 中用于运行用户代码的时间与 CPU 总消耗时间的比值。 Parallel Scavenge 收集器提供了很多参数供用户找到最合适的停顿时间或最大吞吐量,如果对于收集器运作不太了解,手工优化存在困难的时候,使用 Parallel Scavenge 收集器配合自适应调节策略,把内存管理优化交给虚拟机去完成也是一个不错的选择。
新生代采用标记-复制算法,老年代采用标记-整理算法。

Serial Old 收集器

Serial 收集器的老年代版本,它同样是一个单线程收集器。它主要有两大用途:一种用途是在 JDK1.5 以及以前的版本中与 Parallel Scavenge 收集器搭配使用,另一种用途是作为 CMS 收集器的后备方案。

Parallel Old 收集器

Parallel Scavenge 收集器的老年代版本。使用多线程和“标记-整理”算法。在注重吞吐量以及 CPU 资源的场合,都可以优先考虑 Parallel Scavenge 收集器和 Parallel Old 收集器。

CMS 收集器(Concurrent Mark Sweep)回收老年代

CMS 收集器是 “标记-清除”算法实现的
CMS收集器是一种以获取最短回收停顿时间为目标的收集器。它非常符合在注重用户体验的应用上使用。
CMS收集器是 HotSpot 虚拟机第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作

它的运作过程相比于前面几种垃圾收集器来说更加复杂一些。整个过程分为四个步骤:

  • 初始标记: 暂停所有的其他线程,并记录下直接与 root 相连的对象,速度很快 ;
  • 并发标记: 同时开启 GC 和用户线程,用一个闭包结构去记录可达对象。但在这个阶段结束,这个闭包结构并不能保证包含当前所有的可达对象。因为用户线程可能会不断的更新引用域,所以 GC 线程无法保证可达性分析的实时性。所以这个算法里会跟踪记录这些发生引用更新的地方。
  • 重新标记: 重新标记阶段就是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短
  • 并发清除: 开启用户线程,同时 GC 线程开始对未标记的区域做清扫。

从它的名字就可以看出它是一款优秀的垃圾收集器,主要优点:并发收集、低停顿。但是它有下面三个明显的缺点:

  • 对 CPU 资源敏感;
  • 无法处理浮动垃圾;
  • 它使用的回收算法-“标记-清除”算法会导致收集结束时会有大量空间碎片产生。

G1 收集器**(Garbage-First)**(老年代和新生代)

标记整理
G1 是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器. 以极高概率满足 GC 停顿时间要求的同时,还具备高吞吐量性能特征.
被视为 JDK1.7 中 HotSpot 虚拟机的一个重要进化特征。它具备以下特点:

  • 并行与并发:G1 能充分利用 CPU、多核环境下的硬件优势,使用多个 CPU(CPU 或者 CPU 核心)来缩短 Stop-The-World 停顿时间。部分其他收集器原本需要停顿 Java 线程执行的 GC 动作,G1 收集器仍然可以通过并发的方式让 java 程序继续执行。
  • 分代收集:虽然 G1 可以不需要其他收集器配合就能独立管理整个 GC 堆,但是还是保留了分代的概念。
  • 空间整合:与 CMS 的“标记-清理”算法不同,G1 从整体来看是基于“标记-整理”算法实现的收集器;从局部上来看是基于“标记-复制”算法实现的。
  • 可预测的停顿:这是 G1 相对于 CMS 的另一个大优势,降低停顿时间是 G1 和 CMS 共同的关注点,但 G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内。

G1 收集器的运作大致分为以下几个步骤:

  • 初始标记
  • 并发标记
  • 最终标记
  • 筛选回收

G1 收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的 Region(这也就是它的名字 Garbage-First 的由来) 。这种使用 Region 划分内存空间以及有优先级的区域回收方式,保证了 G1 收集器在有限时间内可以尽可能高的收集效率(把内存化整为零)

类加载的流程

从类被加载到虚拟机内存中开始,到释放内存总共有7个步骤:加载,验证,准备,解析,初始化,使用,卸载。其中验证,准备,解析三个部分统称为连接

加载

  1. 将Class文件加载到内存
  2. 将静态数据结构转化成方法区中运行时的数据结构
  3. 在堆中生成一个该类的Class对象,作为访问的入口

连接

  • 验证:确保加载的类符合 JVM 规范和安全,保证被校验类的方法在运行时不会做出危害虚拟机的事件,其实就是一个安全检查
  • 准备:为static变量在方法区中分配内存空间,设置变量的初始值,例如 static int a = 3 (注意:准备阶段只设置类中的静态变量(方法区中),不包括实例变量(堆内存中),实例变量是对象初始化时赋值的)
  • 解析:虚拟机将常量池内的符号引用替换为直接引用的过程(符号引用比如我现在import java.util.ArrayList这就算符号引用,直接引用就是指针或者对象地址,注意引用对象一定是在内存进行)

初始化

初始化其实就是执行类构造器方法的()的过程,而且要保证执行前父类的()方法执行完毕。这个方法由编译器收集,顺序执行所有类变量(static修饰的成员变量)显式初始化和静态代码块中语句。此时准备阶段时的那个 static int a 由默认初始化的0变成了显式初始化的3。 由于执行顺序缘故,初始化阶段类变量如果在静态代码块中又进行了更改,会覆盖类变量的显式初始化,最终值会为静态代码块中的赋值。

注意:字节码文件中初始化方法有两种,非静态资源初始化的和静态资源初始化的,类构造器方法()不同于类的构造器,这些方法都是字节码文件中只能给JVM识别的特殊方法

卸载

GC将无用对象从内存中卸载

类加载器的加载顺序

加载一个Class类的顺序也是有优先级的,类加载器从最底层开始往上的顺序是这样的

  1. BootStrap ClassLoader:rt.jar
  2. Extension ClassLoader: 加载扩展的jar包
  3. App ClassLoader:指定的classpath下面的jar包
  4. Custom ClassLoader:自定义的类加载器

双亲委派机制

当一个类收到了加载请求时,它是不会先自己去尝试加载的,而是委派给父类去完成,比如我现在要 new 一个 Person,这个 Person 是我们自定义的类,如果我们要加载它,就会先委派 App ClassLoader ,只有当父类加载器都反馈自己无法完成这个请求(也就是父类加载器都没有找到加载所需的 Class)时,子类加载器才会自行尝试加载

为什么要有双亲委派

其实这个也是一个隔离的作用,避免了我们的代码影响了 JDK 的代码,比如我现在自己定义一个 java.lang.String :

1
2
3
4
5
6
7
package java.lang;
public class String {
public static void main(String[] args) {
System.out.println();
}
}

尝试运行当前类的 main 函数的时候,我们的代码肯定会报错。这是因为在加载的时候其实是找到了 rt.jar 中的java.lang.String,然而发现这个里面并没有 main 方法。

MySQL

MyISAM 和 InnoDB 有什么区别

虽然,MyISAM 的性能还行,各种特性也还不错(比如全文索引、压缩、空间函数等)。但是,MyISAM 不支持事务和行级锁,而且最大的缺陷就是崩溃后无法安全恢复。

是否支持行级锁

MyISAM 只有表级锁(table-level locking),而 InnoDB 支持行级锁(row-level locking)和表级锁,默认为行级锁。

是否支持事务

MyISAM 不提供事务支持。
InnoDB 提供事务支持,实现了 SQL 标准定义了四个隔离级别,具有提交(commit)和回滚(rollback)事务的能力。并且,InnoDB 默认使用的 REPEATABLE-READ(可重读)隔离级别是可以解决幻读问题发生的(基于 MVCC 和 Next-Key Lock)。

是否支持外键

MyISAM 不支持,而 InnoDB 支持。
总结:一般我们也是不建议在数据库层面使用外键的,应用层面可以解决。不过,这样会对数据的一致性造成威胁。具体要不要使用外键还是要根据你的项目来决定。

是否支持数据库异常崩溃后的安全恢复

MyISAM 不支持,而 InnoDB 支持。
使用 InnoDB 的数据库在异常崩溃后,数据库重新启动的时候会保证数据库恢复到崩溃前的状态。这个恢复的过程依赖于 redo log

是否支持 MVCC

MyISAM 不支持,而 InnoDB 支持。

索引实现不一样。

虽然 MyISAM 引擎和 InnoDB 引擎都是使用 B+Tree 作为索引结构,但是两者的实现方式不太一样。
InnoDB 引擎中,其数据文件本身就是索引文件。相比 MyISAM,索引文件和数据文件是分离的,其表数据文件本身就是按 B+Tree 组织的一个索引结构,树的叶节点 data 域保存了完整的数据记录。

总结

  • InnoDB 支持行级别的锁粒度,MyISAM 不支持,只支持表级别的锁粒度。
  • MyISAM 不提供事务支持。InnoDB 提供事务支持,实现了 SQL 标准定义了四个隔离级别。
  • MyISAM 不支持外键,而 InnoDB 支持。
  • MyISAM 不支持 MVVC,而 InnoDB 支持。
  • 虽然 MyISAM 引擎和 InnoDB 引擎都是使用 B+Tree 作为索引结构,但是两者的实现方式不太一样。
  • MyISAM 不支持数据库异常崩溃后的安全恢复,而 InnoDB 支持。
  • InnoDB 的性能比 MyISAM 更强大。

事务

ACID

关系型数据库的事务都有ACID特性:

  • 原子性(Atomicity) : 事务是最小的执行单位,不允许分割。事务的原子性确保动作要么全部完成,要么完全不起作用;
  • 一致性(Consistency): 执行事务前后,数据保持一致,例如转账业务中,无论事务是否成功,转账者和收款人的总额应该是不变的;
  • 隔离性(Isolation): 并发访问数据库时,一个用户的事务不被其他事务所干扰,各并发事务之间数据库是独立的;
  • 持久性(Durability): 一个事务被提交之后。它对数据库中数据的改变是持久的,即使数据库发生故障也不应该对其有任何影响。

并发事务带来的问题

脏读

一个事务读取数据并进行了修改,这个修改对于其他事务可见,即使当前事务还没有提交,这时候另一个事务读取了这个数据,但第一个事务突然回滚了,那第二个事务读取到的就是脏数据。

丢失修改

在一个事务读取一个数据时,另外一个事务也访问了该数据,那么在第一个事务中修改了这个数据后,第二个事务也修改了这个数据。这样第一个事务内的修改结果就被丢失,因此称为丢失修改。

不可重复读

指在一个事务内多次读同一数据。在这个事务还没有结束时,另一个事务也访问该数据。那么,在第一个事务中的两次读数据之间,由于第二个事务的修改导致第一个事务两次读取的数据可能不太一样。这就发生了在一个事务内两次读到的数据是不一样的情况,因此称为不可重复读。

幻读

它发生在一个事务读取了几行数据,接着另一个并发事务插入了一些数据时。在随后的查询中,第一个事务就会发现多了一些原本不存在的记录,就好像发生了幻觉一样,所以称为幻读

事务隔离级别

SQL 标准定义了四个隔离级别:

  • READ-UNCOMMITTED(读取未提交) : 最低的隔离级别,允许读取尚未提交的数据变更,可能会导致脏读、幻读或不可重复读。
  • READ-COMMITTED(读取已提交) : 允许读取并发事务已经提交的数据,可以阻止脏读,但是幻读或不可重复读仍有可能发生。
  • REPEATABLE-READ(可重复读) : 对同一字段的多次读取结果都是一致的,除非数据是被本身事务自己所修改,可以阻止脏读和不可重复读,但幻读仍有可能发生。
  • SERIALIZABLE(可串行化) : 最高的隔离级别,完全服从 ACID 的隔离级别。所有的事务依次逐个执行,这样事务之间就完全不可能产生干扰,也就是说,该级别可以防止脏读、不可重复读以及幻读。
    隔离级别 脏读 不可重复读 幻读
    READ-UNCOMMITTED
    READ-COMMITTED ×
    REPEATABLE-READ × ×
    SERIALIZABLE × × ×

InnoDB 实现的 REPEATABLE-READ 隔离级别其实是可以解决幻读问题发生的,主要有两种情况
快照读:由MVCC来保证不出现幻读
当前读: 使用 Next-Key Lock 进行加锁来保证不出现幻读,Next-Key Lock 是行锁(Record Lock)和间隙锁(Gap Lock)的结合,行锁只能锁住已经存在的行,为了避免插入新行,需要依赖间隙锁。

MySQL 的隔离级别是基于什么实现的?

MySQL 的隔离级别基于锁和 MVCC 机制共同实现的。

SERIALIZABLE 隔离级别是通过锁来实现的,READ-COMMITTED 和 REPEATABLE-READ 隔离级别是基于 MVCC 实现的。不过, SERIALIZABLE 之外的其他隔离级别可能也需要用到锁机制,就比如 REPEATABLE-READ 在当前读情况下需要使用加锁读来保证不会出现幻读。

MySQL 的默认隔离级别是什么?

MySQL InnoDB 存储引擎的默认支持的隔离级别是 REPEATABLE-READ(可重读)。我们可以通过SELECT @@tx_isolation;命令来查看,MySQL 8.0 该命令改为SELECT @@transaction_isolation;

InnoDB 有哪几类行锁?

InnoDB 行锁是通过对索引数据页上的记录加锁实现的,MySQL InnoDB 支持三种行锁定方式:

  • 记录锁(Record Lock) :也被称为记录锁,属于单个行记录上的锁。
  • 间隙锁(Gap Lock) :锁定一个范围,不包括记录本身。
  • 临键锁(Next-Key Lock) :Record Lock+Gap Lock,锁定一个范围,包含记录本身,主要目的是为了解决幻读问题(MySQL 事务部分提到过)。记录锁只能锁住已经存在的记录,为了避免插入新记录,需要依赖间隙锁。

在 InnoDB 默认的隔离级别 REPEATABLE-READ 下,行锁默认使用的是 Next-Key Lock。但是,如果操作的索引是唯一索引或主键,InnoDB 会对 Next-Key Lock 进行优化,将其降级为 Record Lock,即仅锁住索引本身,而不是范围。

MySQL执行计划

各个字段的含义如下:

列名 含义
id SELECT 查询的序列标识符
select_type SELECT 关键字对应的查询类型
table 用到的表名
partitions 匹配的分区,对于未分区的表,值为 NULL
type 表的访问方法
possible_keys 可能用到的索引
key 实际用到的索引
key_len 所选索引的长度
ref 当使用索引等值查询时,与索引作比较的列或常量
rows 预计要读取的行数
filtered 按表条件过滤后,留存的记录数的百分比
Extra 附加信息

索引

1. 索引是什么

索引是一种能够提高数据库查询效率的数据结构,存放在磁盘中

2. 索引有哪些类型

数据结构维度

  1. B+树索引:所有数据存放在叶子节点上,复杂度为logn,支持范围查询
  2. Hash索引:支持等值查询
  3. 全文索引:MyISAM和InnoDB都支持全文索引,一般建立在char,text,varchar上
  4. R-Tree索引:用来对GIS数据类型创建SPATIAL索引

物理存储维度

  1. 聚簇索引:聚簇索引就是主键建立的索引,叶子节点上存放数据(InnoDB存储引擎)
  2. 非聚簇索引:以非主键创建的索引,在叶子节点存储的是主键和索引列。(Innodb存储引擎)

逻辑维度

  1. 主键索引:特殊的唯一索引,不允许有空值
  2. 普通索引:基本索引类型,不支持空值和重复值
  3. 联合索引:多个字段创建的索引,使用时遵循最左匹配原则
  4. 唯一索引:索引列中的值必须是唯一的,但是允许为空值
  5. 空间索引:MySQL5.7之后支持空间索引,在空间索引这方面遵循OpenGIS几何数据模型规则。

3. 索引什么时候会失效

  • or语句前后没有使用同一个索引
  • 字段类型是字符串,where时一定要用单引号括起来
  • like使用%开头,如果查询的结果中只包含主键和索引字段,也就是索引覆盖,则会使用索引,否则索引失效
  • 组合索引不使用第一个列开
  • 在索引列上使用is null,is not null
  • 在索引字段上使用not ,<>, != 等
  • 在索引列上使用函数
  • 左右连接查询相关联的字段编码格式不一样,可能会导致索引失效
  • mysql估计全表扫描比索引快

4. 哪些情况不适合建立索引

  • 数据量少的表
  • 更新比较频繁的字段
  • 区分度低的字段,如性别
  • 已经有冗余的索引情况(比如已经有a,b的联合索引,不需要再单独建立a索引)

5. 为什么要用 B+树,为什么不用二叉树

为什么不是一般二叉树

如果二叉树特殊化为一个链表,相当于全表扫描。平衡二叉树相比于二叉查找 树来说,查找效率更稳定,总体的查找速度也更快。

为什么不是平衡二叉树

平衡二叉树每个节点只存储一个键值和数据,如果是B树,可以存储更多的节点数据,树的高度也会降低,因此读取磁盘的次数就降下来啦,查询效率就快啦。

为什么不是B树

  • B+树非叶子节点上是不存储数据的,仅存储键值,而 B 树节点中不仅存储键值,也会存储数据,innodb 中页的默认大小是 16KB,如果不存储数据,那么就会存储更多的键值,相应的树的阶数(节点的子节点树)就会更大,树就会更矮更胖,如此一来我们查找数据进行磁盘的 IO 次数又会再次减少,数据查询的效率也会更快。
  • B+树索引的所有数据均存储在叶子节点,而且数据是按照顺序排列的,链表连着的。那么 B+树使得范围查找,排序查找,分组查找以及去重查找变得异常简单。

6. 什么是回表

查询的数据在索引中找不到的时候,需要回到主键索引树中去获取,这个过程叫做回表。

7. 什么是覆盖索引

覆盖索引是select的数据列只用从索引中就能够取得,不必回表,换句话说,查询列要被所建的索引覆盖。

8. 什么是索引下推

MySQL5.6引入了索引下推,可以在索引遍历的过程中,对筛选条件中包含在索引里的字段先做判断,直接过滤掉不符合条件的记录,减少回表次数

9. 大表如何添加索引

  1. 给表添加索引的时候,是会对表加锁的

可以参考以下方法:

  1. 先创建一张跟原表A数据结构相同的新表B。
  2. 在新表B添加需要加上的新索引。
  3. 把原表A数据导到新表B
  4. 重命名新表B为原表的表名A,原表A换别的表名;

10. Hash索引和B树索引有什么区别

  • B+树可以进行范围查询,Hash 索引不能。
  • B+树支持联合索引的最左侧原则,Hash 索引不支持。
  • B+树支持 order by 排序,Hash 索引不支持。
  • Hash 索引在等值查询上比 B+树效率更高。(但是索引列的重复值很多的话,Hash冲突,效率降低)。
  • B+树使用 like 进行模糊查询的时候,like 后面(比如%开头)的话可以起到优化的作用,Hash 索引根本无法进行模糊查询。

11. 索引有哪些优缺点

优点:

  • 索引可以加快数据查询速度,减少查询时间
  • 唯一索引可以保证数据库表中每一行的数据的唯一性

缺点:

  • 创建索引和维护索引要耗费时间
  • 索引需要占物理空间,除了数据表占用数据空间之外,每一个索引还要占用一定的物理空间
  • 以表中的数据进行增、删、改的时候,索引也要动态的维护。

12. 聚簇索引与非聚簇索引的区别

InnoDB存储引擎中, 聚簇索引与非聚簇索引最大的区别,在于叶节点是否存放一整行记录。聚簇索引叶子节点存储了一整行记录,而非聚簇索引叶子节点存储的是主键信息,因此,一般非聚簇索引还需要回表查询

  • 一个表中只能拥有一个聚集索引(因为一般聚簇索引就是主键索引),而非聚集索引一个表则可以存在多个。
  • 一般来说,相对于非聚簇索引,聚簇索引查询效率更高,因为不用回表。

而在MyISAM存储引擎中,它的主键索引,普通索引都是非聚簇索引,因为数据和索引是分开的,叶子节点都使用一个地址指向真正的表数据。

13. 正确使用索引的建议

选择合适的字段创建索引

  • 不为 NULL 的字段 :索引字段的数据应该尽量不为 NULL,因为对于数据为 NULL 的字段,数据库较难优化。如果字段频繁被查询,但又避免不了为 NULL,建议使用 0,1,true,false 这样语义较为清晰的短值或短字符作为替代。
  • 被频繁查询的字段 :我们创建索引的字段应该是查询操作非常频繁的字段。
  • 被作为条件查询的字段 :被作为 WHERE 条件查询的字段,应该被考虑建立索引。
  • 频繁需要排序的字段 :索引已经排序,这样查询可以利用索引的排序,加快排序查询时间。
  • 被经常频繁用于连接的字段 :经常用于连接的字段可能是一些外键列,对于外键列并不一定要建立外键,只是说该列涉及到表与表的关系。对于频繁被连接查询的字段,可以考虑建立索引,提高多表连接查询的效率。

被频繁更新的字段应该慎重建立索引

虽然索引能带来查询上的效率,但是维护索引的成本也是不小的。 如果一个字段不被经常查询,反而被经常修改,那么就更不应该在这种字段上建立索引了。

尽可能的考虑建立联合索引而不是单列索引

因为索引是需要占用磁盘空间的,可以简单理解为每个索引都对应着一颗 B+树。如果一个表的字段过多,索引过多,那么当这个表的数据达到一个体量后,索引占用的空间也是很多的,且修改索引时,耗费的时间也是较多的。如果是联合索引,多个字段在一个索引上,那么将会节约很大磁盘空间,且修改数据的操作效率也会提升。

注意避免冗余索引

冗余索引指的是索引的功能相同,能够命中索引(a, b)就肯定能命中索引(a) ,那么索引(a)就是冗余索引。如(name,city )和(name )这两个索引就是冗余索引,能够命中前者的查询肯定是能够命中后者的 在大多数情况下,都应该尽量扩展已有的索引而不是创建新索引。

考虑在字符串类型的字段上使用前缀索引代替普通索引

前缀索引仅限于字符串类型,较普通索引会占用更小的空间,所以可以考虑使用前缀索引带替普通索引。

避免索引失效

索引失效也是慢查询的主要原因之一,常见的导致索引失效的情况有下面这些:

  • 使用 SELECT * 进行查询;
  • 创建了组合索引,但查询条件未准守最左匹配原则;
  • 在索引列上进行计算、函数、类型转换等操作;
  • 以 % 开头的 LIKE 查询比如 like ‘%abc’;;
  • 查询条件中使用 or,且 or 的前后条件中有一个列没有索引,涉及的索引都不会被使用到;
  • 发生隐式转换
  • ……

删除长期未使用的索引

删除长期未使用的索引,不用的索引的存在会造成不必要的性能损耗 MySQL 5.7 可以通过查询 sys 库的 schema_unused_indexes 视图来查询哪些索引从未被使用

为什么like %在左会导致索引失效

因为b+tree索引排序规则是按照首字母去排序的,%在左导致无法得知字符串的首字母是什么

可以使用覆盖索引来避免

B树和B+树的区别

B树

优点:因为结点内存储了键值和相关数据,所以把需要频繁访问的数据放在根节点附近会提高查询效率
缺点:如果结点中data数据较大,会导致每个结点存储Key值减少,导致B树层数变高,IO次数变多

MongoDB使用B树

B+树

优点

  • 范围搜索,因为根节点之间有引用
  • 因为数据只存在于根节点,所以保存的键值相对较多,IO次数较少
  • 排序能力更强

怎么SQL优化

  1. 建立索引,不要建立冗余索引,不要在经常修改的字段上建索引,不要在区分度比较小的字段上建索引
  2. 避免

三大日志

redo log

redo log(重做日志)是物理日志,记录内容是“在某个数据页上做了什么修改”,是InnoDB存储引擎独有的,它让MySQL拥有了崩溃恢复能力。
比如 MySQL 实例挂了或宕机了,重启时,InnoDB存储引擎会使用redo log恢复数据,保证数据的持久性与完整性。
MySQL中的数据是以页为单位的,查询一条数据,会从硬盘中把当前页加载出来,加载出来的数据叫数据页,会放入到 Buffer Pool 中。后续的查询都是先从 Buffer Pool 中找,没有命中再去硬盘加载,减少硬盘 IO 开销,提升性能。
然后会把“在某个数据页上做了什么修改”记录到重做日志缓存(redo log buffer)里,接着刷盘到 redo log 文件里。

理想情况,事务一提交就会进行刷盘操作,但实际上,刷盘的时机是根据策略来进行的。

小贴士:每条 redo 记录由“表空间号+数据页号+偏移量+修改数据长度+具体修改的数据”组成

为什么不能修改后的数据直接刷盘,还需要redo log?

实际上,数据页大小是16KB,刷盘比较耗时,可能就修改了数据页里的几 Byte 数据,有必要把完整的数据页刷盘吗?
而且数据页刷盘是随机写,因为一个数据页对应的位置可能在硬盘文件的随机位置,所以性能是很差。
如果是写 redo log,一行记录可能就占几十 Byte,只包含表空间号、数据页号、磁盘文件偏移 量、更新值,再加上是顺序写,所以刷盘速度很快。
所以用 redo log 形式记录修改内容,性能会远远超过刷数据页的方式,这也让数据库的并发能力更强。

binlog

binlog 是逻辑日志,记录内容是语句的原始逻辑,类似于“给 ID=2 这一行的 c 字段加 1”,属于MySQL Server 层。
不管用什么存储引擎,只要发生了表数据更新,都会产生 binlog 日志。

binlog 主要用来MySQL数据库的数据备份、主备、主主、主从都离不开binlog,需要依靠binlog来同步数据,保证数据一致性。
binlog会记录所有涉及更新数据的逻辑操作,并且是顺序写。

写入时机

binlog的写入时机也非常简单,事务执行过程中,先把日志写到binlog cache,事务提交的时候,再把binlog cache写到binlog文件中。

两阶段提交

在执行更新语句过程,会记录redo log与binlog两块日志,以基本的事务为单位,redo log在事务执行过程中可以不断写入,而binlog只有在提交事务时才写入,所以redo log与binlog的写入时机不一样。
为了避免写完redo log后,binlog日志写期间发生数据库异常,出现的数据不一致问题。
InnoDB存储引擎使用两阶段提交方案。
原理很简单,将redo log的写入拆成了两个步骤prepare和commit,这就是两阶段提交

undo log

我们知道如果想要保证事务的原子性,就需要在异常发生时,对已经执行的操作进行回滚,在 MySQL 中,恢复机制是通过 回滚日志(undo log) 实现的,所有事务进行的修改都会先记录到这个回滚日志中,然后再执行相关的操作。如果执行过程中遇到异常的话,我们直接利用 回滚日志 中的信息将数据回滚到修改之前的样子即可!并且,回滚日志会先于数据持久化到磁盘上。这样就保证了即使遇到数据库突然宕机等情况,当用户再次启动数据库的时候,数据库还能够通过查询回滚日志来回滚将之前未完成的事务。
另外,MVCC 的实现依赖于:隐藏字段、Read View、undo log。在内部实现中,InnoDB 通过数据行的 DB_TRX_ID 和 Read View 来判断数据的可见性,如不可见,则通过数据行的 DB_ROLL_PTR 找到 undo log 中的历史版本。每个事务读到的数据版本可能是不一样的,在同一个事务中,用户只能看到该事务创建 Read View 之前已经提交的修改和该事务本身做的修改

binlog和redo log的区别

  1. redo log是InnoDB独有的;binlog是MySQL的Server层实现的
  2. redo log是物理日志,记录的是在xxx数据页上做了xxx修改;binlog是逻辑日志,记录的是原始逻辑,对应的SQL语句
  3. redo log是循环写,空间会用完,binlog是追加写,写到一定大小会切换到下一个

总结

MySQL InnoDB 引擎使用 redo log(重做日志) 保证事务的持久性,使用 undo log(回滚日志) 来保证事务的原子性
MySQL数据库的数据备份、主备、主主、主从都离不开binlog,需要依靠binlog来同步数据,保证数据一致性。

InnoDB存储引擎对MVCC的实现

一致性非锁定读和锁定读

在 Repeatable Read 和 Read Committed 两个隔离级别下,如果是执行普通的 select 语句(不包括 select … lock in share mode ,select … for update)则会使用 一致性非锁定读(MVCC)。并且在 Repeatable Read 下 MVCC 实现了可重复读和防止部分幻读

锁定读

如果执行的是下列语句,就是 锁定读(Locking Reads)

  • select … lock in share mode
  • select … for update
  • insert、update、delete 操作

在锁定读下,读取的是数据的最新版本,这种读也被称为 当前读(current read)

InnoDB 对 MVCC 的实现
MVCC 的实现依赖于:

  1. 隐藏字段
  2. Read View
  3. undo log

隐藏字段

在内部,InnoDB 存储引擎为每行数据添加了三个隐藏字段:

  • DB_TRX_ID(6字节):表示最后一次插入或更新该行的事务 id。此外,delete 操作在内部被视为更新,只不过会在记录头 Record header 中的 deleted_flag 字段将其标记为已删除
  • DB_ROLL_PTR(7字节) 回滚指针指向该行的 undo log 。如果该行未被更新,则为空
  • DB_ROW_ID(6字节):如果没有设置主键且该表没有唯一非空索引时,InnoDB 会使用该 id 来生成聚簇索引

Read View

主要是用来做可见性判断,里面保存了 “当前对本事务不可见的其他活跃事务”

undo-log

undo log 主要有两个作用:

  • 当事务回滚时用于将数据恢复到修改前的样子
  • 另一个作用是 MVCC ,当读取记录时,若该记录被其他事务占用或当前版本对该事务不可见,则可以通过 undo log 读取之前的版本数据,以此实现非锁定读

数据可见性算法

在 InnoDB 存储引擎中,创建一个新事务后,执行每个 select 语句前,都会创建一个快照(Read View),快照中保存了当前数据库系统中正处于活跃(没有 commit)的事务的 ID 号。其实简单的说保存的是系统中当前不应该被本事务看到的其他事务 ID 列表(即 m_ids)。当用户在这个事务中要读取某个记录行的时候,InnoDB 会将该记录行的 DB_TRX_ID 与 Read View 中的一些变量及当前事务 ID 进行比较,判断是否满足可见性条件

RC 和 RR 隔离级别下 MVCC 的差异

在事务隔离级别 RC 和 RR (InnoDB 存储引擎的默认事务隔离级别)下,InnoDB 存储引擎使用 MVCC(非锁定一致性读),但它们生成 Read View 的时机却不同

  • 在 RC 隔离级别下的 每次select 查询前都生成一个Read View (m_ids 列表)
  • 在 RR 隔离级别下只在事务开始后 第一次select 数据前生成一个Read View(m_ids 列表)

MVCC

MVCC是多版本并发控制,通过维护数据的多个版本,来代替锁,让不同事务可以并发执行,从而提升性能
读已提交下:每次查询都会生成新的ReadView,所以在读已提交隔离级别下的事务读取到的是版本链中已提交事务修改之后的数据。
可重复读下:只会在第一次执行查询时生成ReadView,该事务中后续的查询操作都会沿用这个ReadView,因此此隔离级别下一个事务中多次执行同样的查询,其结果都是一样的,这样就实现了可重复读。

MVCC + Next-key-Lock 防止幻读

InnoDB存储引擎在 RR 级别下通过 MVCC和 Next-key Lock 来解决幻读问题:
1、执行普通 select,此时会以 MVCC 快照读的方式读取数据
在快照读的情况下,RR 隔离级别只会在事务开启后的第一次查询生成 Read View ,并使用至事务提交。所以在生成 Read View 之后其它事务所做的更新、插入记录版本对当前事务并不可见,实现了可重复读和防止快照读下的 “幻读”
2、执行 select…for update/lock in share mode、insert、update、delete 等当前读
在当前读下,读取的都是最新的数据,如果其它事务有插入新的记录,并且刚好在当前事务查询范围内,就会产生幻读!InnoDB 使用 Next-key Lock 来防止这种情况。当执行当前读时,会锁定读取到的记录的同时,锁定它们的间隙,防止其它事务在查询范围内插入数据。只要我不让你插入,就不会发生幻读

Redis

基本数据结构

String
Hash
List
Set
Sorted Set

Redis可以做什么

  • 缓存
  • 分布式锁
  • 限流
  • 消息队列
  • 复杂业务场景,比如通过bitmap统计活跃用户

Redis单线程,那怎么监听大量的客户端连接呢

Redis 通过 IO 多路复用程序 来监听来自客户端的大量连接(或者说是监听多个 socket),它会将感兴趣的事件及类型(读、写)注册到内核中并监听每个事件是否发生。
这样的好处非常明显: I/O 多路复用技术的使用让 Redis 不需要额外创建多余的线程来监听客户端的大量连接,降低了资源的消耗(和 NIO 中的 Selector 组件很像)。

Redis 是如何判断数据是否过期的呢

Redis 通过一个叫做过期字典(可以看作是 hash 表)来保存数据过期的时间。过期字典的键指向 Redis 数据库中的某个 key(键),过期字典的值是一个 long long 类型的整数,这个整数保存了 key 所指向的数据库键的过期时间(毫秒精度的 UNIX 时间戳)。

过期的数据的删除策略

如果假设你设置了一批 key 只能存活 1 分钟,那么 1 分钟后,Redis 是怎么对这批 key 进行删除的呢?
常用的过期数据的删除策略就两个(重要!自己造缓存轮子的时候需要格外考虑的东西):

  1. 惰性删除 :只会在取出 key 的时候才对数据进行过期检查。这样对 CPU 最友好,但是可能会造成太多过期 key 没有被删除。
  2. 定期删除 : 每隔一段时间抽取一批 key 执行删除过期 key 操作。并且,Redis 底层会通过限制删除操作执行的时长和频率来减少删除操作对 CPU 时间的影响。

定期删除对内存更加友好,惰性删除对 CPU 更加友好。两者各有千秋,所以 Redis 采用的是 定期删除+惰性/懒汉式删除
但是,仅仅通过给 key 设置过期时间还是有问题的。因为还是可能存在定期删除和惰性删除漏掉了很多过期 key 的情况。这样就导致大量过期 key 堆积在内存里,然后就 Out of memory 了。
怎么解决这个问题呢?答案就是:Redis 内存淘汰机制。

Redis 内存淘汰机制了解么

相关问题:MySQL 里有 2000w 数据,Redis 中只存 20w 的数据,如何保证 Redis 中的数据都是热点数据?
Redis 提供 6 种数据淘汰策略:

  1. volatile-lru(least recently used):从已设置过期时间的数据集(server.db[i].expires)中挑选最近最少使用的数据淘汰
  2. volatile-ttl:从已设置过期时间的数据集(server.db[i].expires)中挑选将要过期的数据淘汰
  3. volatile-random:从已设置过期时间的数据集(server.db[i].expires)中任意选择数据淘汰
  4. allkeys-lru(least recently used):当内存不足以容纳新写入数据时,在键空间中,移除最近最少使用的 key(这个是最常用的)
  5. allkeys-random从数据集(server.db[i].dict)中任意选择数据淘汰
  6. no-eviction禁止驱逐数据,也就是说当内存不足以容纳新写入数据时,新写入操作会报错。这个应该没人使用吧!

4.0 版本后增加以下两种:

  1. volatile-lfu(least frequently used)从已设置过期时间的数据集(server.db[i].expires)中挑选最不经常使用的数据淘汰
  2. allkeys-lfu(least frequently used)当内存不足以容纳新写入数据时,在键空间中,移除最不经常使用的 key

Redis 持久化机制

两种方式

  • 快照(RDB)
  • 只追加文件(AOF)

快照 RDB(默认方式)

Redis 可以通过创建快照来获得存储在内存里面的数据在某个时间点上的副本。
Redis 创建快照之后,可以对快照进行备份,可以将快照复制到其他服务器从而创建具有相同数据的服务器副本(Redis 主从结构,主要用来提高 Redis 性能),还可以将快照留在原地以便重启服务器的时候使用。

RDB 创建快照时会阻塞主线程吗?

Redis 提供了两个命令来生成 RDB 快照文件:

  • save : 主线程执行,会阻塞主线程;
  • bgsave : 子线程执行,不会阻塞主线程,默认选项。

什么是 AOF 持久化?

与快照持久化相比,AOF 持久化的实时性更好,因此已成为主流的持久化方案。默认情况下 Redis 没有开启 AOF(append only file)方式的持久化,可以通过 appendonly 参数开启:
appendonly yes
开启 AOF 持久化后每执行一条会更改 Redis 中的数据的命令,Redis 就会将该命令写入到内存缓存 server.aof_buf 中,然后再根据 appendfsync 配置来决定何时将其同步到硬盘中的 AOF 文件。
AOF 文件的保存位置和 RDB 文件的位置相同,都是通过 dir 参数设置的,默认的文件名是 appendonly.aof
在 Redis 的配置文件中存在三种不同的 AOF 持久化方式,它们分别是:

  • appendfsync always #每次有数据修改发生时都会写入AOF文件,这样会严重降低Redis的速度
  • appendfsync everysec #每秒钟同步一次,显式地将多个写命令同步到硬盘
  • appendfsync no #让操作系统决定何时进行同步

为了兼顾数据和写入性能,用户可以考虑 appendfsync everysec 选项 ,让 Redis 每秒同步一次 AOF 文件,Redis 性能几乎没受到任何影响。而且这样即使出现系统崩溃,用户最多只会丢失一秒之内产生的数据。当硬盘忙于执行写入操作的时候,Redis 还会优雅的放慢自己的速度以便适应硬盘的最大写入速度。

为什么是在执行完命令之后记录日志呢?

  • 避免额外的检查开销,AOF 记录日志不会对命令进行语法检查;
  • 在命令执行完之后再记录,不会阻塞当前的命令执行。

这样也带来了风险(我在前面介绍 AOF 持久化的时候也提到过):

  • 如果刚执行完命令 Redis 就宕机会导致对应的修改丢失;
  • 可能会阻塞后续其他命令的执行(AOF 记录日志是在 Redis 主线程中进行的)

Redis的事务

你可以将 Redis 中的事务就理解为 :Redis 事务提供了一种将多个命令请求打包的功能。然后,再按顺序执行打包的所有命令,并且不会被中途打断。
除了不满足原子性之外,事务中的每条命令都会与 Redis 服务器进行网络交互,这是比较浪费资源的行为。明明一次批量执行多个命令就可以了,这种操作实在是看不懂。
因此,Redis 事务是不建议在日常开发中使用的。

实现事务的方式

Redis + lua脚本

Redis生产问题

缓存穿透

大量请求的 key 是不合理的,根本不存在于缓存中,也不存在于数据库中 。这就导致这些请求直接到了数据库上,根本没有经过缓存这一层,对数据库造成了巨大的压力,可能直接就被这么多请求弄宕机了。

有哪些解决办法?
最基本的就是首先做好参数校验,一些不合法的参数请求直接抛出异常信息返回给客户端。比如查询的数据库 id 不能小于 0、传入的邮箱格式不对的时候直接返回错误消息给客户端等等。

1. 缓存无效key

如果缓存和数据库都查不到某个 key 的数据就写一个到 Redis 中去并设置过期时间,具体命令如下: SET key value EX 10086 。这种方式可以解决请求的 key 变化不频繁的情况

2. 布隆过滤器

布隆过滤器是一个非常神奇的数据结构,通过它我们可以非常方便地判断一个给定数据是否存在于海量数据中。我们需要的就是判断 key 是否合法,有没有感觉布隆过滤器就是我们想要找的那个“人”。
具体是这样做的:把所有可能存在的请求的值都存放在布隆过滤器中,当用户请求过来,先判断用户发来的请求的值是否存在于布隆过滤器中。不存在的话,直接返回请求参数错误信息给客户端,存在的话才会走下面的流程。

布隆过滤器说某个元素存在,小概率会误判。布隆过滤器说某个元素不在,那么这个元素一定不在。

缓存击穿

请求的 key 对应的是 热点数据 ,该数据 存在于数据库中,但不存在于缓存中(通常是因为缓存中的那份数据已经过期) 。这就可能会导致瞬时大量的请求直接打到了数据库上,对数据库造成了巨大的压力,可能直接就被这么多请求弄宕机了。

有哪些解决办法?

  • 设置热点数据永不过期或者过期时间比较长。
  • 针对热点数据提前预热,将其存入缓存中并设置合理的过期时间比如秒杀场景下的数据在秒杀结束之前不过期。
  • 请求数据库写数据到缓存之前,先获取互斥锁,保证只有一个请求会落到数据库上,减少数据库的压力。

缓存雪崩

缓存在同一时间大面积的失效,导致大量的请求都直接落到了数据库上,对数据库造成了巨大的压力

有哪些解决办法?

针对 Redis 服务不可用的情况:

  1. 采用 Redis 集群,避免单机出现问题整个缓存服务都没办法使用。
  2. 限流,避免同时处理大量的请求。

针对热点缓存失效的情况:

  1. 设置不同的失效时间比如随机设置缓存的失效时间。
  2. 缓存永不失效(不太推荐,实用性太差)。
  3. 设置二级缓存

总结:

缓存雪崩

当某一个时刻出现大规模的缓存失效,那么就会导致大量请求打到数据库上,导致数据库压力过大。
(可能是Redis宕机,可能是采用了相同的过期时间)

解决办法:

  1. 采用随机过期时间
  2. 使用熔断机制,当流量到达一定的阈值时,就直接返回“系统拥挤”之类的提示
  3. 搭建Redis集群

缓存击穿

一个热点的Key有大并发集中请求,突然这个Key失效了,导致大并发打到了数据库上

解决办法:

  1. 业务允许,可以设置永不过期
  2. 使用互斥锁,如果缓存失效的情况,只有拿到锁才可以查询数据库

缓存穿透

发送来的Key在Redis里不存在,那么就会去数据库里查询

  1. 如果不存在,则给这个Key保存一个默认值
  2. 使用布隆过滤器,布隆过滤器的作用是某个 key 不存在,那么就一定不存在,它说某个 key 存在,那么很大可能是存在(存在一定的误判率)。

布隆过滤器

一种数据结构,是由一串很长的二进制向量组成,可以将其看成一个二进制数组。既然是二进制,那么里面存放的不是0,就是1,但是初始默认值都是0。当要向布隆过滤器中添加一个元素key时,我们通过多个hash函数,算出一个值,然后将这个值所在的方格置为1。我们只需要将这个新的数据通过上面自定义的几个哈希函数,分别算出各个值,然后看其对应的地方是否都是1,如果存在一个不是1的情况,那么我们可以说,该新数据一定不存在于这个布隆过滤器中。

布隆过滤器可以判断某个数据一定不存在,但是无法判断一定存在

解决缓存数据库一致性问题

延时双删

先进行缓存清除,再执行update,最后(延迟N秒)再执行缓存清除。
延迟双删用比较简洁的方式实现 mysql 和 redis 数据最终一致性,但它不是强一致。

分布式锁

1
2
setnx key value
set key value EX 过期时间 NX

基于Redis的分布式锁的问题:

  1. 不可重入
  2. 不可重试
  3. 超时释放,可能导致误删
  4. 主从一致性

redisson怎么解决的

  1. 可重入:利用hash结构,记录线程id和重入次数
  2. 可重试:利用信号量和PubSub功能实现等待唤醒获取锁失败的重试机制
  3. 超时续约:利用watchDog

redisson看门狗

帮助锁续期,如果没有手动设置锁释放时间,会有看门狗机制去自动续期
释放锁的时候取消看门狗

SpringBoot

Spring IOC

依赖注入(DI):是IOC的一个方面,是个通常的概念,它有多种解释。这概念是说你不用创建对象,而只需要描述它如何被创建

IoC(Inversion of Control:控制反转) IoC 的思想就是将原本在程序中手动创建对象的控制权,交由 Spring 框架来管理。不过, IoC 并非 Spring 特有,在其他语言中也有应用。
IoC 容器是 Spring 用来实现 IoC 的载体, IoC 容器实际上就是个 Map(key,value),Map 中存放的是各种对象。
为什么叫控制反转?

  • 控制 :指的是对象创建(实例化、管理)的权力
  • 反转 :控制权交给外部环境(Spring 框架、IoC 容器)

IoC 解决了什么问题

  1. 对象之间的耦合度或者说依赖程度降低;
  2. 资源变的容易管理;比如你用 Spring 容器提供的话很容易就可以实现一个单例。

SpringAOP

AOP(Aspect-Oriented Programming:面向切面编程)能够将那些与业务无关,却为业务模块所共同调用的逻辑或责任(例如事务处理、日志管理、权限控制等)封装起来,在需要执行的时候,使用动态代理的技术,在不修改源码的基础上,对自己的已有方法进行增强,便于减少系统的重复代码,降低模块间的耦合度,并有利于未来的可拓展性和可维护性。

AspectJ 定义的通知类型有哪些

  • Before(前置通知):目标对象的方法调用之前触发
  • After (后置通知):目标对象的方法调用之后触发
  • AfterReturning(返回通知):目标对象的方法调用完成,在返回结果值之后触发
  • AfterThrowing(异常通知) :目标对象的方法运行中抛出 / 触发异常后触发。AfterReturning 和 AfterThrowing 两者互斥。如果方法调用成功无异常,则会有返回值;如果方法抛出了异常,则不会有返回值。
  • Around (环绕通知):编程式控制目标对象的方法调用。环绕通知是所有通知类型中可操作范围最大的一种,因为它可以直接拿到目标对象,以及要执行的方法,所以环绕通知可以任意的在目标对象的方法调用前后搞事,甚至不调用目标对象的方法

多个切面的执行顺序如何控制

通常使用@Order 注解直接定义切面顺序,实现Ordered 接口重写 getOrder 方法。

IOC和AOP的原理

IOC原理

spring的容器启动之后,根据xml的配置或者注解,实例化bean对象,再根据xml配置或者注解,对bean对象之间的引用关系进行依赖注入(一个bean依赖了另一个bean)
底层通过反射技术,直接根据你的类构建相应地对象

AOP原理

spring就会通过aop机制(核心是动态代理技术)进行事务管理。
cgclib & jdk动态代理

  • 如果一个类实现了某个接口,spring aop会使用jdk动态代理,生成一个实现同样的接口的代理类,构造一个实例对象出来。
  • 一个类没有实现接口,spring aop会改用cglib生成动态代理,其会生成一个该类的子类出来,动态生成字节码,覆盖一些方法,在方法里进行增强

统一异常处理

推荐使用注解的方式统一异常处理,具体会使用到 @ControllerAdvice + @ExceptionHandler 这两个注解 。

Spring 框架中用到了哪些设计模式?

  • 工厂设计模式 : Spring 使用工厂模式通过 BeanFactory、ApplicationContext 创建 bean 对象。
  • 代理设计模式 : Spring AOP 功能的实现,基于动态代理。
  • 单例设计模式 : Spring 中的 Bean 默认都是单例的。
  • 模板方法模式 : Spring 中 jdbcTemplate、hibernateTemplate 等以 Template 结尾的对数据库操作的类,它们就使用到了模板模式。
  • 包装器设计模式 : 我们的项目需要连接多个数据库,而且不同的客户在每次访问中根据需要会去访问不同的数据库。这种模式让我们可以根据客户的需求能够动态切换不同的数据源。
  • 观察者模式: Spring 事件驱动模型就是观察者模式很经典的一个应用。
  • 适配器模式 : Spring AOP 的增强或通知(Advice)使用到了适配器模式、spring MVC 中也是用到了适配器模式适配Controller。
  • ……

Spring 事务

Spring 管理事务的方式有几种?

  • 编程式事务 : 在代码中硬编码(不推荐使用) : 通过 TransactionTemplate或者 TransactionManager 手动管理事务,实际应用中很少使用,但是对于你理解 Spring 事务管理原理有帮助。
  • 声明式事务 : 在 XML 配置文件中配置或者直接基于注解(推荐使用) : 实际是通过 AOP 实现(基于@Transactional 的全注解方式使用最多)

Spring 事务中哪几种事务传播行为

1.TransactionDefinition.PROPAGATION_REQUIRED
使用的最多的一个事务传播行为,我们平时经常使用的@Transactional注解默认使用就是这个事务传播行为。如果当前存在事务,则加入该事务;如果当前没有事务,则创建一个新的事务
2.TransactionDefinition.PROPAGATION_REQUIRES_NEW
创建一个新的事务,如果当前存在事务,则把当前事务挂起。也就是说不管外部方法是否开启事务,Propagation.REQUIRES_NEW修饰的内部方法会新开启自己的事务,且开启的事务相互独立,互不干扰。
3.TransactionDefinition.PROPAGATION_NESTED
如果当前存在事务,则创建一个事务作为当前事务的嵌套事务来运行;如果当前没有事务,则该取值等价于TransactionDefinition.PROPAGATION_REQUIRED。
4.TransactionDefinition.PROPAGATION_MANDATORY
如果当前存在事务,则加入该事务;如果当前没有事务,则抛出异常。(mandatory:强制性)
这个使用的很少。
若是错误的配置以下 3 种事务传播行为,事务将不会发生回滚:

  • TransactionDefinition.PROPAGATION_SUPPORTS: 如果当前存在事务,则加入该事务;如果当前没有事务,则以非事务的方式继续运行。
  • TransactionDefinition.PROPAGATION_NOT_SUPPORTED: 以非事务方式运行,如果当前存在事务,则把当前事务挂起。
  • TransactionDefinition.PROPAGATION_NEVER: 以非事务方式运行,如果当前存在事务,则抛出异常。

Spring自动装配

  1. 包扫描
  2. 实例化Bean(反射)

微服务

单体应用的问题

  • 部署效率低下:单体应用代码越来越多,需要的资源越来越多,打包耗费的时间就越长
  • 团队协作成本高:团队人数多时,代码合并一起打包部署,但凡有一个环节出问题,需要重新编译打包部署,所有相关人员参与其中,效率低下
  • 系统可用性差:如果一部分代码有问题,比如内存泄漏或者异常退出,那么部署在同一个JVM中的服务都不可用
  • 线上发布慢:

微服务化:

服务化简单来说就是把传统的单机应用中通过 JAR 包依赖产生的本地方法调用,改造成通过 RPC 接口产生的远程方法调用。

服务化拆分

这种服务化拆分方式是纵向拆分,是从业务维度进行拆分。标准是按照业务的关联程度来决定,关联比较密切的业务适合拆分为一个微服务,而功能相对比较独立的业务适合单独拆分为一个微服务。
还有一种服务化拆分方式是横向拆分,是从公共且独立功能维度拆分。标准是按照是否有公共的被多个其他服务调用,且依赖的资源独立不与其他业务耦合。

微服务架构6大组件:

  1. 服务描述:RestfulApi (http)、xml (rpc)、IDL (grpc)
  2. 注册中心:注册(服务提供者->注册中心)、订阅(服务消费者->注册中心)、返回(注册中心->服务消费者)、通知(注册中心->服务消费者)
  3. 服务框架
  4. 服务监控(发现问题):指标收集、数据处理、数据展现
  5. 服务追踪(定位问题):RequestId传递
  6. 服务治理(解决问题):单机故障——自动摘除、单IDC故障——自动切换、依赖服务不可用——熔断

SpringCloud全家桶

image.png

场景问题

后端接口性能优化

  1. 优化数据库索引
    1. 没加索引
    2. 索引失效
  2. sql优化
    1. 避免使用 select *
    2. 小表驱动大表
    3. 批量操作
    4. 多用limit
    5. in中的值不要太多
    6. 用连接代替子查询
    7. join的表不要过多
    8. 注意索引数量
  3. 远程调用
    1. 并行调用:CompleteFuture
    2. 数据异构:把接口返回数据统一存放在Redis(可能存在数据一致性问题)
  4. 重复调用
    1. 避免循环查询数据库
    2. 避免死循环
    3. 避免无限递归
  5. 异步处理:非核心逻辑,可以异步执行,异步写库
    1. 线程池
    2. mq
  6. 避免大事务
    1. 少用@Transactional注解
    2. 将查询(select)方法放到事务外
    3. 事务中避免远程调用
    4. 事务中避免一次性处理太多数据
    5. 有些功能可以非事务执行
    6. 有些功能可以异步处理
  7. 注意锁粒度
  8. 加缓存
    1. Redis缓存
    2. Redis缓存 + 二级缓存(内存中)
  9. 分库分表
  10. 辅助功能
  11. 开启慢查询日志
  • slow_query_log 慢查询开关
  • slow_query_log_file 慢查询日志存放的路径
  • long_query_time 超过多少秒才会记录日志
  1. 加监控:Prometheus
  2. 链路跟踪:skywalking

算法题

剑指Offer

Offer30:包含min函数的栈

定义栈的数据结构,请在该类型中实现一个能够得到栈的最小元素的 min 函数在该栈中,调用 min、push 及 pop 的时间复杂度都是 O(1)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
class MinStack {

Stack<Integer> stack;
Stack<Integer> minStack;

public MinStack() {
stack = new Stack<>();
minStack = new Stack<>();
}

public void push(int x) {
stack.push(x);

// 比如 -2 3 10 -3
// 将-3弹出后,必须将10,3都弹出才可以弹出下一个最小值-2,所以可以过滤掉3,10不push
// 注意这里的 = ,如果有两个最小值,比如 -2,-2,-3,必须将两个都push到minStack
if (minStack.isEmpty() || x <= minStack.peek()) {
minStack.push(x);
}
}

public void pop() {
if (stack.pop().equals(minStack.peek())) {
minStack.pop();
}
}

public int top() {
return stack.peek();
}

public int min() {
return minStack.peek();
}
}