如何挖掘出 Java 线程和同步设施的最大性能


1 线程池与 ThreadPollExecutor

Java EE 应用服务器就是围绕用一个或多个线程池处理请求这一概念构建的:对服务器上 Servlet 的每个调用都是通过池中的线程处理的(也有可能不同)。类似的,其它应用可以使用 Java 的 ThreadPoolExecutor 并行执行任务。

所有线程池的工作方法本质是一样的:有一个队列,任务被提交到这个队列中。(可以有不止一个队列,概念是一样的。)一定数量的线程会从该队列中取任务,然后执行。任务的结果可以发回客户端(比如应用服务器的情况下),或保存到数据库中。但是在执行完任务后,这个线程会返回任务队列,检索另一个任务并执行(如果没有更多任务要执行,该线程会等待下一个任务)。

线程池有最小线程数和最大线程数。池中会有最小数目的线程随时待命,等待任务指派给它们。因为创建线程的成本非常高昂,这样可以提高任务提交时的整体性能。另一方面,线程需要一些系统次元,包括栈所需的原生内存,如果空闲线程太多,就会消耗本来可以分配给其它金恒的资源。

ThreadPoolExecutor 和相关的类将最小线程数称为核心池大小。

1.1 设置最大线程数

使用 CPU 的最大线程数

1.2 设置最小线程数

也可称为初始线程数和核心线程数,尽量能做到刚好能满足基本的任务需要,同时,如果线程运行完任务后,他作为空闲线程需要保留 10~30 分钟,来应对可能的新任务。

注意:线程局部变量

1.3 线程池任务大小

如果当前任务队列有 30,000 个任务,有 4 个 CPU 可用,执行一个任务需要 50 毫秒,则清空任务队列需要 6 分钟。当任务列队达到阈值,需要返回合理的错误。

1.4 设置 ThreadPoolExecutor 的大小

标准线程池:创建时准备最小数目的线程,来一个任务,并且当前所有的线程都在忙碌,则启动一个新线程,任务就立即执行。否则加入等待队列,如果队列满了,则拒绝。
ThreadPoolExecuter 表现则不同:

  1. SynchronousQueue
    来一个任务则创建线程执行任务,没有等待队列。建议将最大线程数指定为一个非常大的值,同时任务是
    完全 CPU 密集型最合适。
  2. 无界队列
    如果 ThreadPoolExecutor 搭配的是无界队列(例如 LinkedBlockedingQueue,链表阻塞队列,默认 Integer.MAX,包括 take 操作和 put 操作,FIFO 队列)。
  3. 无界队列
    有界队列,例如 ArrayBlockingQueue(FIFO)。 假如 coreThread 为 4,maxThread 为 8,用的 ArrayBlockingQueue 为 10。任务先占满 4 个 coreThread,接着占用 10 个 queue,最后才会启动 maxThread-coreThread 的 4 个线程。

    这个算法的背后理念在于:池大部分时间使用核心线程,只有当积压到一定的阈值后,才启动线程执行。

2 ForkJoinPool

使用 fork 和 join 对任务进行分、并操作, 大任务变成两个小任务,由两个线程执行这两个小任务,另一个线程用于合并这两个任务的结果, 还有另一个特性是工作窃取:每个线程有自己的任务队列,当某个线程完成了自己的任务队列,就会执行另一个未执行完的任务队列的尾部任务。

Java 8 中使用了自动并行化的特性,用到的就是一个公共的 ForkJoinPool 实例,其中的 forEach 会并行的计算。

1
2
3
4
Stream<Integer> stream = arrayList.parallelStream();
stream.forEach(a -> {
doSomething()
});

3 线程同步

3.1 线程的代价

同步的目的是保护对内存中的值的访问。变量有可能临时保存在寄存器中,这比直接在主内存中访问更高效。寄存器值对其它线程是不可见的,
而当一个线程离开某个同步块时,必须将任何修改的值刷新到主内存中。

3.2 避免同步

  1. 使用线程局部变量 ThreadLocal
  2. 使用 CAS 无锁操作,atomic 包

3.3 伪共享

  1. 伪共享:加载内存时,同时会加载邻接的值,作为缓存行, 因此 会出现伪共享问题:当程序更新本地缓存中的某个值,需要通知其它核作废缓存行中的所有涉及的值,重新从缓存行中加载。
  2. 在 volatile 中问题比较突出, 频繁的修改 volatile 以及退出同步块的代码。
  3. 使用 @Contentded 进行宽度填充( J默认情况下仅在 JVM 内部使用,Doug Lea 使用多余的变量进行宽度填充)

4 JVM 线程调优

4.1 调节线程栈大小

64位机器默认线程栈大小为 1MB,栈的异常:StackOverflowError

调节线程栈大小:-Xss=256k

4.2 偏向锁

如果一个线程最近使用了某个锁,那么该线程下一次执行由同一把锁保护的代码所需的代码可能仍然保存在处理器的缓存中。使用对象头
的某个空间来记录该线程的 id,下次访问直接进入该锁。

4.3 自旋锁

JVM 处理同步锁的竞争问题,有两种选择:

  1. 死循环+检查该锁
  2. 将该线程放入线程等待池队列,在锁可用时通知该队列。

而 JVM 会自动调整将线程从循环到通知队列的时间。

5 监控线程与锁

5.1 查看线程

使用 Jstack、jcmd

注意:JVM 只能在特定的位置(safepoint,安全点)转储出一个线程的栈,每次只能针对一个线程转储出栈的纤细。

6 小结

  1. ThreadPollExecutor 三种队列
  2. 避免同步的两种方式:ThreadLocal、CAS
  3. 伪共享
  4. 偏向锁和自旋锁
  5. 使用死循环 +检查锁的状态、将线程放入线程池中来处理锁的竞争问题
  6. jstack 命令

0 CPU 使用率

使用 vmstat 命令查看 CPU 使用率,即 us\sy\id 三个参数,用户、系统、空闲使用 CPU 的时间。

  1. 检查应用性能时,首先应该审查 CPU 时间(尤其是多线程,CPU 的上下文切换报告)
  2. 优化代码的目的是提升而不是降低(更短时间段内的)CPU 使用率。
  3. 在试图深入优化应用前,应该先弄清楚为何 CPU 使用率低。

1 JIT 编译器

java文件->编译->class字节码文件->JVM编译解释成平台相关的二进制文件。
而 JIT 编译器属于最后的 JVM 编译过程,也可以称为后端编译器,这样便于理解
Java 应用汇被编译——但不是编译成特定 CPU 所专用的二进制代码,而是被编译成一种理想化的汇编语言(即 .class 字节码文件),它专用于 JVM 所执行。这个编译时在程序执行时进行的,即编译同时执行,(C 这种编译语言会先编译成 .o 或者 .obj 再执行),而 Java 是直接执行编译代码(JVM 执行)。Java 是一种半编译半解释语言(先编译成 .class,再让 JVM 解释成特定 CPU 的指令),而 Java 的表面直接执行其实内部 JVM 帮我们做了编译解释,不像 C 用户手动编译再执行,因为 C 的编译后的 .o 文件是针对特定的 CPU ,也许在下个 CPU 就需要重新编译了。参见:https://www.zhihu.com/question/21486706

由于编译成 .class 这个行为是在程序执行的时候进行的,因为被称为“即时编译”(即JIT,just in time)。你也可以先 javac 编译后,再 java 命令 执行。

1.热点编译

官方的 java 实现是 Oracle 的 HotSpot JVM。HotSpot 的名字来与它看待代码编译的方式。对于程序来说,通常只有一部分代码被经常执行,而应用的性能就取决于这些代码执行得有多快。这些关键代码段被称为应用的热点,代码执行得越多就被认为是越热。

因此 JVM 执行代码时,并不会立即编译代码。原因1:如果代码只执行一次,那编译完全就是浪费精力。对于只执行一次的代码,解释执行 Java 字节码比先编译然后执行的速度快。原因2:JVM 执行特定方法或者循环的次数越多,它就会越了解这段代码,使得 JVM 可以在编译代码时进行大量优化。

例,equals() 方法,存在每个 Java 对象中,并且经常被子类重写。当解释器遇到 b = obj1.equals(obj2) 语句时,为了知道该执行哪个 equals() ,必须先查找 obj1 的类。这个动态查找的过程有点消耗时间。

寄存器和主内存:

1
2
3
4
5
6
7
8
9
>public class RegisterTest {
> private int sum;
> public void calculateSum(int n) {
> for(int i = 0; i < n; i++) {
> sum += i;
> }
> }
>}
>

实例变量如果一直存在主内存中,但是从主内存获取数据是非常昂贵的操作,需要花费多个时钟周期才能完成,这样性能就会比较低,编译器就不会这么做,它会将 sum 的初始值装入寄存器,用寄存器中的值执行循环,然后(某个不确定时刻)将最终的结果从寄存器写回主内存。
使用寄存器是编译器普遍采用的优化方法,当开启逃逸分析(escape analysis)时,寄存器的使用更为频繁(详见本章尾)。

比如,随着时间流逝, JVM 发现每次执行这条语句时,obj1 的类型都是 java.lang.String。于是 JVM 就可以生成直接调用 String.equals() 的编译代码。现在代码更快乐,不仅是因为被编译,也是因为跳过了查找该调用哪个方法的步骤。
不过没那么简单,下次执行代码时,obj1 完全有可能是别的类型而不是 String ,所以 JVM 必须生成编译代码处理这种可能,尽管如此,由于跳过了方法查找的步骤,这里的编译代码整体性能仍然要快(至少和 obj1 一直是 String时同样快)。这种优化只有在代码运行过一段时间观察它如何做之后才能使用:这是为何 JIT 编译器等待代码编译的第二个原因。

2 调优入门:选择编译器类型(client/server或两者同用)

有两种 JIT 编译器,client 和 server。两者编译器的最主要的差别在于编译代码的时机不同。
client 编译器开启编译比 server 编译器要早。意味着在代码执行的开始阶段,client 编译器比 server 编译器要快,因为它编译代码相比 server 编译器而言要多。
server 编译器等待编译的时候是否还能做更有价值的事:server 编译器在编译代码时可以更好地进行优化。最终,server 编译器生成的代码要比 client 编译器快。
此处的问题:为什么需要人来做这种选择?为什么 JVM 不能在启动用 client 编译器,然后随着代码变热使用 server 编译器?这种技术被称为分层编译。java7 的分层编译容易超出 JVM 代码缓存的大小,默认关闭。在 java8 分层编译默认为开启。
即时应用永远运行, server 编译器也不可能编译它的所有代码,但是任何程序都有一小部分代码很少执行,最好是编译这些代码——即便编译不是最好的方法——而不是以解释模式运行。

对于长时间运行的应用来说,应该一直使用 server 编译器,最好配合分层编译器。

3 Java 和 JIT 编译器版本

JIT 编译器有 3 种版本:

  1. 32位 client 编译器(-client)
  2. 32位 server 编译器(-server)
  3. 64位 server 编译器(-d64)

4 编译器中级调优

1.调优代码缓存

JVM 编译代码时,会在代码缓存中保存编译之后的汇编语言指令集。代码缓存一旦填满,JVM 就不能编译更多代码了(只能解释执行其余代码了)。
但是,如果设置过多,例如设置代码缓存为 1GB,JVM 就会保留 1GB 的本地内存空间。然后这部分内存在需要时才会分配,但它仍然是保留的,这意味着为了满足保留内存,你的机器必须有足够的虚拟内存。
此外,如果是 32位 JVM,则进程占用的总内存不能超过 4GB。这包括 Java堆、JVM 自身所有嗲吗占用的空间(包括它的本地库和线程栈)、分配给应用的本地内存(或者 NIO 库的直接内存),当然还有代码缓存。
代码缓存: -XX:ReservedCodeCacheSize=N ,可以设置代码缓存的最大值。

2.编译阈值

编译时基于两种 JVM 计数器的:方法调用计数器和方法中的循环回边计数器。回边实际上可看作是循环完成执行的次数。
JVM 执行某个 Java 方法时,会检查该方法的两种计数器总数,然后判定该方法是否适合编译。如果适合就进入编译队列。被称为标准编译。
如果循环真的很长——或因包含所有程序逻辑而永远不退出,JVM 不等方法被调用就会编译循环。所以循环每完成一轮,回边计数器就会增加并被检测。如果循环的回边计数器超过阈值,那这个循环(不是整个方法)就可以被编译。被称为栈上替换(On-Stack Replacement,OSR)。

标准编译由: -XX:CompileThreshold=N 标志触发。

5 高级编译器调优

前面说道,当方法(或循环)适合编译时,就会进入到编译队列。队列则由一个或多个后台线程处理。这是件好事,意味着编译过程是异步的,这使得即便是代码正在编译的时候,程序也能持续执行。如果是用标准编译所编译的方法,那下次调用该方法时就会执行编译后的方法;如果是用 OSR 编译的循环,那下次循环迭代时就会执行编译后的代码。

编译队列并不严格遵守先进先出的原则:调用次数多的方法有更高的优先级(非公平更好使)。

5.1 逃逸分析

开启逃逸分析: -XX:DoEscapeAnalysis,默认为 true。server 编译器将会执行一些非常激进的优化措施,例如, for 循环中的新建变量,如果对象只在循环中引用,JVM 会毫不犹豫地对这个对象进行一系列优化。
包括,锁去除,值存储在寄存器而不是内存中,甚至不需要分配实际的对象,可以只追踪这个对象的个别字段。

6 逆优化

有两种逆优化的情形:代码状态分别为“made not entrant”(代码被丢弃)和“made zombie”(产生僵尸代码)时。

6.1 代码被丢弃

当一个接口有多重实现,在使用switch 进行工厂模式的创建时,可能上次的编译器内联,在下次就必须使用解释执行了,因为对象变了,要开始新的编译,而上次的编译代码就属于丢弃代码。
另一种情况就是,分层编译。先使用 client 编译,再使用 server 编译,那么在第二次编译时,第一次编译的一些代码就要被丢弃,属于丢弃代码。

6.2 逆优化僵尸代码

  1. 逆优化使得编译器可以回到之前版本的编译代码。
  2. 先前的优化不再有效时(例,所设计的对象类型发生了更改),才会发生代码逆优化。
  3. 代码逆优化时,会对性能产生一些小而短暂的影响。

7 小结

  1. 不用担心小方法,特别是 getter 和 setter ,因为它们很容易内联。编译器会修复这些问题。
  2. 需要编译的代码在编译队列中。队列中代码越多,程序达到最佳性能的时间越久。
  3. 虽然代码缓存的大小(也应该)调整,但它仍然是有限的资源。
  4. 代码越简单,优化越多。分析反馈和逃逸分析可以使代码更快,但复杂的循环结果和大方法限制了它的有效性。

很多时候,我们没有机会重写代码,又要面临需要提高 Java 应用性能的压力,这种情况下对垃圾收集器的调优就变得至关重要。

  1. Serial 收集器(常用于单 CPU环境)。
  2. Throughput(或者 Parallel)收集器。
  3. Concurrent 收集器(CMS)。
  4. G1 收集器。

1 垃圾收集概述

简单来说,垃圾收集由两步构成:查找不再使用的对象,以及释放这些对象所管理的内存。 JVM 从查找不再使用的对象(垃圾对象)入手。有时,这也被称为查找不再有任何对象引用的对象(暗指采用“引用计数”的方式统计对象引用)。

例,如下场景:一个程序要分配大小为 1000 字节的数组,紧接着又分配一个大小为 24 字节的数组,并在一个循环中持续进行这样的分配。最终程序会耗尽整个堆,结果如下图的第一行所示:堆空间被沾满,分配的数组间隔地分布于整个堆内:
![][1]
堆内存用尽会触发 JVM 回收不再使用的数组空间。假设所有大小为 24 字节的数组都不再被使用,而大小为 1000 字节的数组还继续使用,这样就形成了上图的第二行的场景。
虽然堆内部有足够的空闲空间,却找不到任何一个大于 24 字节的连续空间,除非 JVM 移动所有大小为 1000 字节的数组,让它们连续存储,把空闲的空间整合成一块更大的连续空间,供其他的内存分配使用(如上图的第三行)。

而垃圾收集的性能就是由这些基本操作所决定的:找到不再使用的对象、回收它们使用的内存、对堆的内存布局进行压缩整理。完成这些操作时不同的收集器采用了不同的方法,这也是不同垃圾收起表现出不同性能特征的原因。
通常垃圾收集器自身往往也是多线程的。接下来的讨论中,我们从逻辑上将县城分成了两组,分别是应用程序线程和处理垃圾收集的线程。垃圾收集器回收对象,或者在内存中移动对象时,必须确保应用程序线程不再继续使用这些对象。这一点在收集器移动对象时尤其重要:在操作过程中,对象的内存地址会发生变化,因此这个过程中任何应用线程都不应再访问该对象。

所有应用线程都停止运行所产生的停顿被称为时空停顿(stop-the-world)。通常这些停顿对应用的性能影响对打,调优垃圾收集时,尽量减少这种停顿是最为关键的考量因素。

1.1 分代垃圾收集器

虽然实现的细节千差万别,但所有的垃圾收集器都遵循了同一个方式,即根据情况将堆划分成不同的代(Generation)。这些代被称为“老年代”(Old Generation 或 Tenured Generation)和“新生代”(Young Generation)。新生代又被进一步划分为不同的区段,分别称为 Eden 空间和 survivor 空间(不过 Eden 有时会被错误地用于指代整个新生代)。

新生代被填满时,垃圾收集器会暂停所有的应用线程,回收新生代空间。不再使用的对象会被回收,仍然在使用的对象会被移动到其它地方。这种操作被称为 Minor GC。
采用这种设计有两个性能上的优势:

  1. 新生代仅是堆的一部分,这意味着应用线程停顿的时间更短。但是更加频繁。
  2. 对象分配与 Eden 空间,垃圾收集时,新生代空间被清空,Eden 空间的对象要么被移走,要么移动到另一个 Survivor 空间,要么被移动到老年代。这就相当于自动的进行了一次压缩整理。

所有的垃圾收集算法在对新生代回收时都存在“时空停顿”现象。

JVM 需要找出老年代中不再使用的对象,并对它们进行回收。而这便是垃圾收集算法差异最大的地方。简单的:停掉所有的应用线程,找出不再使用的对象,对其进行回收,接着对对空间进行整理。这个过程称为 Full GC。这通常会导致应用线程长时间的停顿。

另,通过更复杂的计算,我们还有可能在应用线程运行的同时找出不再使用的对象;
CMS 和 G1 收集器就是通过这种方式进行垃圾收集的。由于它们不需要停止应用线程就能找出不再用的对象, CMS 和 G1 收集器被称为 Concurrent 垃圾收集器。同时,由于它们将停止应用程序的可能降到了最小,也被称为低停顿(Low-Pause)收集器。Concurrent 收集器也使用各种不同的方法对老年代空间进行压缩。

使用 CMS 和 G1 收集器时,应用程序经历的停顿会更少(也更短),代价是会消耗更多的 CPU。

  1. 所有的 GC 算法都将堆划分成了老年代和新生代。
  2. 所有的 GC 算法在清理新生代对象时,都使用了“时空停顿”(stop-the-world)方式的垃圾收集方法。

1.2 GC 算法

JVM 提供了以下四种不同的垃圾收集算法。

1.Serial 垃圾收集器

它是单线程清理堆的内容。使用 Serial 垃圾收集器,无论是进行 Minor GC 还是 Full GC ,清理堆空间时,所有的应用线程都会被暂停。

2.Throughput 垃圾收集器

Throughput 收集器是 Server 级虚拟机(多 CPU)的默认收集器。
使用多线程回收新生代空间, Minoc GC 的速度比使用 Serial 收集器快得多。处理老年代在 JDK7 之后默认也是多线程。因为其使用多线程,也被称为 Parallel 收集器。
在 Minor GC 和 Full GC 时会暂停所有的应用线程,同时在 Full GC 过程中会对老年代空间进行压缩整理。

3.CMS 收集器

CMS 收集器设计的初衷是为了消除 Throughput 收集器和 Serial 收集器 Full GC 周期中的长时间停顿。 CMS 收集器在 Minor GC 时会暂停所有的应用线程,并以多线程的方式进行垃圾回收。
它不使用 Throughput 的收集算法(-XX:+UseParallelGC),而是用新的算法(-XX:+UseParNewGC)来收集新生代对象。
它在 Full GC 不再暂停应用线程,而是使用若干个后台线程定期地对老年代空间进行扫描,及时回收其中不再使用的对象。这种算法使 CMS 成为一个低延迟的收集器:应用线程只在 Minor GC 以后后台线程扫描老年代时发生极其短暂的停顿。
代价是额外的 CPU 使用率。而且后台线程不再进行任何压缩整理的工作,这意味着逐渐碎片化,碎片化一定程度, CMS 会降级为 Serial 收集器:暂停所有应用线程,使用单线程回收。之后再恢复到并发回收。(这种思想在写锁降级为读锁也有体现)。

4.G1 收集器

G1 垃圾收集器(垃圾优先收集器)的设计初衷是为了尽量缩短处理超大堆(大于4 GB)时产生的停顿。G1 收集算法将老年堆划分为若干个区域(Region),不过它依旧属于分代收集器。这些区域中的一部分包含新生代,新生代的垃圾收集仍然采用暂停所有应用线程的方法,将存活对象移动到老年代或者 Survivor 空间,这也是多线程完成的。

G1 收集器属于 Concurrent 收集器:老年代的垃圾收集工作由后台线程完成,大多数的工作不需要暂停应用线程。由于老年代被划分到不同的区域,G1 收集器通过将对象从一个区域复制到另一个区域,完成对象的清理工作,这也意味着在正常的处理过程中,G1 收集器实现了堆的压缩整理(至少是部分的整理)。因此,使用G1 收集器的堆不大容易发生碎片化——虽然这种问题无法避免。

通常情况下垃圾收集是由 JVM 在需要的时候触发:新生代用尽时会触发 Minor GC,老年代用尽时会触发 Full GC,或者堆空间即将填满时会触发 Concurrent 垃圾收集。
System.gc() 让应用程序强制进行 GC, Full GC。应用程序线程会因此而停顿相当长的一段时间。同时,调用这个方法也不会让应用程序更高效,它会让 GC 更早的开始,但那实际只是将性能的影响往后推迟而已。

2 GC 调优基础

2.1 调整堆的大小

如果分配的堆过于小,程序的大部分时间可能都消耗在 GC 上。
如果分配的过于大也不行,GC 停顿消耗的时间取决于堆的大小,如果增大堆的空间,停顿的持续时间也会变长,这种情况下,停顿的频率会变得更少,但是它们持续的时间会让程序的整体性能变慢。还有一个风险是,操作系统使用虚拟内存机制管理机器的物理内存。一台机器可能有 8G 的物理内存,不过操作系统可能让你感觉有更多的可用内存。虚拟内存的数量取决于操作系统的设置,譬如操作系统可能让你感觉它的内存达到了 16G 。操作系统通过名为“交换”(swapping)(或者称之为分页,虽然两者技术存在差异)。你可以载入需要 16G 内存的应用程序,操作系统在需要时会将程序运行时不活跃的数据由内存复制到磁盘。再次需要这部分内存的内容时,操作系统再将它们由磁盘重新载入到内存(为了腾出空间,通常它会先将另一部分内存的内容复制到磁盘)。

系统中运行着大量不同的应用程序时,这个流程工作的很顺畅,因为大多数的应用程序不会同时处于活跃状态。但是,对于 Java 应用,它工作得并不那么好。如果一个 Java 应用使用了这个系统上大约 12G 的堆,操作系统可能在 RAM 上分配了 8G 的堆空间,另外 4G 的空间存在于磁盘。这样操作系统需要将相当一部分的数据由磁盘交换到内存,而发生 Full GC 时,因为 JVM 必须访问整个堆的内容,如果系统发生内存交换,停顿时间会更长。

堆的大小由 2 个参数值控制:初始值(-Xms)、最大值(-Xmx)。

2.2 代空间的调整

一旦堆的大小确定下来,JVM 就需要决定分配多少堆给新生代空间,多少给老年代空间。
必须清楚:如果新生代分配得比较大,垃圾收集发生的频率就比较低,从新生代晋升到老年代的对象也更少。但是老年代相对比较小,容易填满,会更频繁的触发 Full GC。

  1. -XX:NewRatio=N:设置新生代与老年代的空间占用比率
  2. -XX:NewSize=N:设置新生代空间的初始大小
  3. -XX:MaxNewSize=N:设置新生代空间的最大大小
  4. -XmnN:将 NewSize 和 MaxNewSize 设定为同一个值的快捷方法。

2.3 永久代和元空间的调整

JVM 载入类的时候,它需要记录这些类的元数据。这部分数据被保存在一个单独的堆空间中。在 Java7 里,这部分空间被称为永久代(Permgen),在 Java8 中,它们被称为元空间(Metaspace)。
永久代和元空间并不完全一样。Java7 中永久代还保存了一些与类数据无关的杂项对象;这些对象在 Java8 中被挪到了普通的堆空间内。它们保存的信息只对编译器或者 JVM 的运行时有用。
通过 -XX:PermSize=N、-XX:MaxPerSize=N 来调整永久代大小。
通过 -XX:MetaspaceSize=N、-XX:MaxMetaspaceSize=N 来调整元空间的大小。

调整这些区间会触发 Full GC ,所以是一种代价昂贵的操作。如果程序在启动时发生大量的 Full GC(因为需要载入数量巨大的类),通常都是由于永久代或者元空间发生了大小调整。

3 垃圾回收工具

开启 GC 的日志功能:使用 -verbose:gc 或 -XX:+PrintGC 的任意一个能创建基本的 GC 日志。使用 -XX:+PrintGCDetails 创建更详细的 GC 日志。

4 小结

多说无益,多尝试。

![1]: http://leran2deeplearnjavawebtech.oss-cn-beijing.aliyuncs.com/learn/Java_performance_definitive_guide/5_1.png

1 原则1:测试真实应用

应该在产品实际使用的环境中进行性能测试。

1.1 微基准测试

1. 必须使用被测的结果

例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Test
public void doTest() {
int nLoops = 50;
double l;
long then = System.currentTimeMillis();
for (int i = 0; i < nLoops; i++) {
l = fibImpl1(50);
}
long now = System.currentTimeMillis();
System.out.println("Elapsed time:" + (now - then));
}

private double fibImpl1(int n) {
if (n < 0) throw new IllegalArgumentException("Must be > 0");
if (n == 0) return 0d;
if (n == 1) return 1d;
double d = fibImpl1(n - 2) + fibImpl1(n - 1);
if (Double.isInfinite(d)) throw new ArithmeticException("Overflow");
return d;
}

这段代码的问题在于,它实际永远都不会改变程序的任何状态。因为斐波那契的计算结果从来没有被使用,所以编译器可以很放心地去除计算结果。智能的编译器(包括当前的 Java7 和 Java8)最终执行的以下代码:

1
2
3
long then = System.currentTimeMillis();
long now = System.currentTimeMillis();
System.out.println("Elapsed time:" + (now - then));

解决在于,确保读取被测结果,而不只是简单地写。实际上,将局部变量 l 的定义改为实例变量(并用关键字 volatile 声明)就能测试这个方法的性能了。(必须声明 volatile 的原因参见第九章)。

2. 不要包括无关的操作

多余的循环迭代的多余的,如果编译器足够智能的话,就能发现这个问题,从而只执行一遍循环。
另外,fibImpl(1000) 的性能可能与 fibImpl(1) 相差很大。如果目的是为了比较不同实现的性能,测试的输入就应该考虑用一系列数据。如下:

1
2
3
for(int i = 0; i < nLoops; i++) {
l = fibImpl1(random.nextInteger());
}

但是,微基准测试中的输入值必须事先计算好。

3. 必须输入合理的参数

此时还有第三个隐患:任意选择的随机输入值对于这段被测代码的用法来说并不具有代表性,实际用户可能只输入 100 以下的值。输入参数大于 1476 时,会抛出异常,因为此时计算出的是 double 类所能表示的最大斐波那契数。考虑如下实现:

1
2
3
4
5
public double fibImplSlow(int n) {
if (n < 0) throw new IllegalArgumentException("Must be > 0");
if (n > 1476) throw new ArithmeticException("Must be < 1476");
return verySlowImpl(n);
}

虽然很难想象会有比原先用递归更慢的实现,但我们假定有这个实现。通过大量输入值比较这两种实现(fibImplSlow 和 verySlowImpl),发现前者比后者快得多——仅仅因为在方法开始时进行了范围检查。
如果在真实场景中,用户只会传入小于 100 的值,那这个比较就是不正确的。(仅仅在原先的实现上添加了边界测试就使得性能变好,通常这是不可能的)。

Java 的一个特点就是代码执行的越多性能越好,第四章详解。基于这点,微基准测试应该包括热身期,使得编译器能生成优化的代码。
微基准测试需要热身期,否则测量的是编译而不是被测代码的性能了。

综上,正确的微基准测试代码可能是这样:

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
36
37
38
39
40
class FibonacciTest {
private volatile double l;
private int nLoops;
private int[] input;

public static void main(String[] args) {
FibonacciTest ft = new FibonacciTest(Integer.parseInt(args[0]));
ft.doTest(true);
ft.doTest(false);
}

private FibonacciTest(int n) {
nLoops = n;
input = new int[nLoops];
Random r = new Random();
for (int i = 0; i < nLoops; i++) {
input[i] = r.nextInt(100);
}
}

private void doTest(boolean isWarmup) {
long then = System.currentTimeMillis();
for (int i = 0; i < nLoops; i++) {
l = fibImpl1(input[1]);
}
if (!isWarmup) {
long now = System.currentTimeMillis();
System.out.println("Elapsed time:" + (now - then));
}
}

private double fibImpl1(int n) {
if (n < 0) throw new IllegalArgumentException("Must be > 0");
if (n == 0) return 0d;
if (n == 1) return 1d;
double d = fibImpl1(n - 2) + fibImpl1(n - 1);
if (Double.isInfinite(d)) throw new ArithmeticException("Overflow");
return d;
}
}

调用 fibImpl1() 的循环和方法开销,将每个结果都写入 volatile 变量中的额外开销导致测量结果有出入。
此外还要留意编译效应:频繁调用的方法、调用时的栈深度、方法参数的实际类型等,它还依赖代码实际运行的环境。
这里的基准测试,有大量的循环,整体时间以秒计,但每轮循环迭代通常是纳秒级。纳秒累计起来,“极少成都”就会成为频繁出现的性能问题。
特别是在做回归测试的时候,追踪级别设为纳秒很有意义。如果集合操作每次都节约几纳秒,日积月累下来意义就很重大了(第十二章详解),但是,对于那些不频繁的操作来说,例,同时只需要处理一个请求的 servlet ,修复微基准测所发现的纳秒级性能衰弱就是浪费时间(后者才是过度优化!)。

2 原则2:理解批处理流逝时间,吞吐量和响应时间

在客户端——服务器的吞吐量测试中,并不考虑客户端的思考时间。客户端向服务器发送请求,当它收到响应时,立刻发送新的请求。
指标常常被称为每秒事务数(TPS)、每秒请求数(RPS)、每秒操作数(OPS)。

知识梳理

Java 基础

Java 的异常有哪几种?Throwble 的子类有哪些?

((20200916194439-4h97y6o “异常”))

描述一下 Java8 哪些新特性?你是如何使用 Stream 流操作的?知道 Stream 底层原理吗?

Java8 支持如下特性:

  1. Lambda 表达式和函数式接口,@FunctionalInterface 修饰的接口只能由一个函数的接口,不包括默认方法和静态方法
  2. 接口支持默认方法和静态方法
  3. 方法引用,支持四种 类型引用:构造器引用(Class::new)、静态方法引用(Class::static_method)、类的成员方法引用(Class::method)、实例的成员方法引用(instance::method)。示例如下:
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
class scratch {
public static void main(String[] args) {
// 构造器引用
Car car = Car.create(Car::new);
List<Car> list = Arrays.asList(car);
// 静态方法引用,入参为自己
list.forEach(Car::collide);
// 类的成员方法的引用,没有入参
list.forEach(Car::repair);
// 实例对象的成员方法的引用,入参为自己
Car car2 = Car.create(Car::new);
list.forEach(car2::follow);
}
}

class Car {
public static Car create(final Supplier<Car> supplier) {
return supplier.get();
}

public static void collide(final Car car) {
System.out.println("Collided " + car.toString());
}

public void follow(final Car another) {
System.out.println("Following the " + another.toString());
}

public void repair() {
System.out.println("Repaired " + this.toString());
}
}
  1. 支持重复注解
  2. 可以获取参数名称,需要编译增加 -parameters 参数,可以在 maven 的 compile 过程增加该参数
1
2
3
4
5
6
7
8
private static void compile() throws NoSuchMethodException {
// javac -parameters Java8Test.java
// java scratch 输出 args,不带 -parameters 参数则输出 arg0
Method method = scratch.class.getMethod( "main", String[].class );
for( final Parameter parameter: method.getParameters() ) {
System.out.println( "Parameter: " + parameter.getName() );
}
}
  1. Optional、Streams、Date/Time API、并行数组
  2. ConcurrentHashMap(((20200930172816-sf88c8v “ConcurrentHashMap 的实现原理与使用”)))、concurrentForkJoinPool、StampedLock(((20200925172739-4h7jrms “StampedLock 详解”)))、原子变量(((20200925175233-ur4xqzv “原子变量详解”)))
  3. 类依赖分析工具 jdeps
  4. Metaspace 代替 PermGen space(((20200925231449-p5eetg4 “JVM 运行机制”)))

详情参考:((20201005234154-te3yj73 “Java8 的新特性”))

HashMap 和 HashSet 的底层实现原理

  1. put 过程
  2. get 过程,其中的效率红黑树优化
  3. hash 过程
  4. resize 过程,其中的死锁问题

((20200915004102-t3x94b1 “5.1 HashMap”))

死锁及打破方式;(互斥、请求与保持、循环等待、阻塞;超时、银行家算法)

线程&协程;(blabla…)

阻塞 IO、非阻塞 IO、同步 IO、异步 IO、IO 多路复用(区别及内部流程、用户态内核态 blablabla…)

请描述一下从浏览器输入一行请求 url 开始,SpringMVC 的整个处理过程

DispatcherServlet 调用 handlermapper,根据 url 映射到 controller 处理器。最后通过参数解析,返回包装到 view。

请求是先 Filter 还是先经过 Servlet?Servlet 处理完后,还会经过 Filter 吗?

拦截器是全局生效,包括静态资源也拦截,常用于字符串编码,cros 拦截

maven 是 compile、package、install 啥区别

他们属于同一个生命周期,一共三生命周期:clean、defaul、site。其中 compile 是编译代码、package 是打成包到当前 target 目录,install 是将包上传到本地仓库

2. 并发基础

1. 公平锁和非公平锁的区别

非公平锁进来时先进行 state 的 CAS 公平锁要判断是否有线程在队列中等待

非公平锁 CAS 失败后,执行 tryAcquire 时,没有判断线程队列是否有等待,直接再进行 state 的 CAS,如果失败后,再和公平锁一样,进队列等前节点唤醒

2. Java 有哪些同步机制?说一下 volatile 关键字

ReentrantLock、Synchronized、volatile、final、wait/notify、Semaphore、Condition

同步要求保证:有序性、可见性、原子性。其中 volatile 保证了可见性和原子性。

3. Java 线程有几种状态

  1. New
  2. Runnable
  3. Blocked
  4. Waiting
  5. timed_waiting
  6. terminated

4. 说一下 sleep() 和 wait() 方法的区别

  1. Thread.sleep()不会释放占有的锁,Object.wait()会释放占有的锁
  2. Thread.sleep()必须传入时间,Object.wait()可传可不传,不传表示一直阻塞下去
  3. Thread.sleep()到时间了会自动唤醒,然后继续执行
  4. Object.wait()不带时间的,需要另一个线程使用 Object.notify()唤醒
  5. Object.wait()带时间的,假如没有被 notify,到时间了会自动唤醒,这时又分好两种情况,一是立即获取到了锁,线程自然会继续执行;二是没有立即获取锁,线程进入同步队列等待获取锁

5. 如何唤醒处于睡眠状态的线程

通过 interrupt 方式

6. 如何释放处于等待中的线程

notify

7. Executor 创建的线程池,有哪几种类型?

  1. newCachedThreadPool:0,MAX,60,SynchronousQueue
  2. newFixedThreadPool:core,core,0,LinkedBlockingQueue
  3. newScheduledThreadPool:core,MAX,0,DelayedWorkQueue
  4. newSingleThreadExecutor:1,1,0,LinkedBlockingQueue
  5. newWorkStealingPool:parallelism,asyncMode ? FIFO_QUEUE : LIFO_QUEUE

((20200919140157-d41qw7t “线程池”))

8. Executor 创建线程池的构造方法的参数都有哪些,线程池的关闭,线程池使用 interrupt 怎么样?

core, max, keepalive, timeunit, blockqueue, threadfactory, rejecthandler

9. corePoolSize = 5,maximumPoolSize = 10,那么现在有 4 个任务同时进来,这时会创建几条线程?如果还没处理完又进来 2 个任务,此时又会创建几条线程还是不会创建?

不会创建。

创建 1 个。

10. 还有哪些最原生最简单使用多线程的方法?

  1. 继承 Thread
  2. 实现 Runnable
  3. tiimer
  4. future/callable

11. blockqueue 原理,condition 和普通 wait 的区别

12. new 一个对象都经历了那几个过程?(考反射)

new:

  1. new 关键字创建一个 Java 对象时,JVM 首先会检查这个 new 指令的参数能否在常量池中定位到一个类的符号引用
  2. 然后检查与这个符号引用相对应的类是否已经成功经历加载、解析和初始化等步骤,里面会对静态变量初始化,重新赋值
  3. 类完成装载步骤之后,就已经完全确定出创建对象实例时所需的内存空间大小,接下来 JVM 将会对其进行内存分配,以存储所生成的对象实例(分配可能在栈上分配,否则在堆上使用指针碰撞或空闲列表方式创建)
  4. 调用构造方法生成一个对象

反射:

  1. class.forName()指定了 ClassLoader 后,就可以在指定的环境中查找某些类,即加载类可以在编译时不存在,运行时存在
  2. 实例

13. 锁膨胀过程和锁的优化

锁优化:自旋锁、锁粗化、锁消除、偏向锁和轻量级锁
偏向锁:

3. 消息基础

在哪里使用过消息队列?不使用消息队列可以吗?使用过哪些消息队列?有哪些优劣势?(顺序性如何保证?事务消息如何保证?消息重复了怎么办?消息丢失了怎么办-> 消息队列的常见解决方案:)消息队列引入之后的缺点?MQ 挂了怎么办?

参考:

  1. MQ 的设计概要:https://zhuanlan.zhihu.com/p/21649950,这个地址搞通可以回答 80%。
  2. 还需要看其它的消息队列,进行优劣势对比。
  3. rocketmq 的 example:http://rocketmq.apache.org/docs/transaction-example/

答:短信使用过,我在钉钉开发的时候,审批通过回调需要调用短信项目来给审批人发送短信,实际调用的就是消息队列,放在消息队列中,等待短信中台去消费来实际发送短信。消息队列本身是用来解耦、异步和削峰。但是系统可用性就会降低,MQ 中间件挂掉怎么办?复杂性,重复数据和丢失数据的处理,幂等性。一致性,多个系统同时需要处理数据,如果只有某个系统失败,那么数据就会出现不一致。

回答中透露出三个点:
解耦:如果不使用,你单独 A 系统对接 B、C 系统还要分别考虑重试机制。
异步:发送多个消息,快速返回,消息系统异步的执行任务。
削峰:A 业务系统只能接受每秒 5K 条消息,但是中午达到了 1W 条每秒,可以使用 MQ,接受 1W 条消息,然后 A 业务系统以每秒 5K 条消息慢慢进行消费,虽然会有消息堆积,但是不会持续时间很长

我们用的 push 方式、使用过顺序消息、重置位点。

消息重复发送怎么办?(即幂等性)

例如 kafka 的重复消费场景

消息丢失怎么办?

现在你要设计一个广告计费,用户点击一次发送消息进行计费结算,如何保证?(涉及金额)

  1. 生产者消息发送到 MQ 过程中由于网络问题, MQ 没收到,消息丢失。这需要发送消息的时候需要回调:MQ 收到了消息。
  2. MQ 突然宕机停电,这需要开启持久化。(有可能还没持久化到磁盘就挂了)。
  3. 消费者消费到一半自己挂掉,MQ 以为消费已经被消费了。这需要消费者每次消费完之后手动发送消费成功的 ACK。
  4. 例如 kafka,消费者在写入数据到 broker 中,broker 的 leader 在同步该消息到 follower 时,自己挂掉,重新选举了 leader 就会导致之前的消息丢失。解决方案是每次只有 leader 同步到 follower 时,才会返回生产者消息发送成功!其中 kafka 的 topic 是有首领副本的概念。

消息的顺序性保证

例如 MySQL 主从同步,是通过 binlog 来实现,从数据库通过复制主数据库的 binlog 来同步,对一条记录进行增删改,对应生成了三个 binlog 有三条记录增删改,这时将 binlog 使用 MQ 发送,就需要保证顺序性。

  1. 生产者生产消息于同一个 queue/partition:将需要保证顺序的消息发送在同一个 queue 中,这样消费者消费 queue 就会按照顺序消费。在 kafka 中就是一个 partition。
  2. 消费者消费消息:消费者使用多线程消费就会出现消费顺序不一致,因此消费者需要在消费的时候使用内存队列,例如订单的生成创建发送短信等顺序业务,可以在同一个内存队列中落盘。即使用内存队列 + 多线程来即保证了多线程性能也保证了消息的顺序性。

消息的延迟和消息积压的过期恢复?

几百万消息在 MQ 中积压了几小时怎么办?
这时候先让 3 个消费者恢复正常,然后再部署 10 倍的消费者专门消费 topic 为 consu 的消费,将 3 个消费者修改逻辑,让原来的消费者中转到 consu 中进行消费落盘,这样能快速的消费也不会对落盘数据库造成影响。
几百万消息在 MQ 中积压了几个小时,由于过期失效策略导致消息没了怎么办?
晚上凌晨在消息的源头补数据重新导入到 MQ 中。(不要设置过期失效策略)
几百万消息 在 MQ 快满了怎么办?
消费者快速消费,消费完就丢掉不做处理,然后再使用方案 2。或者导入到其它的 MQ 中进行中转消费。

消息位点重置原理

事务消息原理

如何设计一个消息中间件?

  1. 支持分布式(可用性)
  2. 落盘顺序写(持久化)
  3. 消息多个副本,并使用 zk 做 leader follower
  4. 数据丢失,内部每次写都进行落盘(每秒)或者写入 MQ 就持久化
  5. 消息顺序性,使用单个线程。
  6. 消息堆积能力
  7. 两次 RPC + 消息存储。
  8. 计算存储分离
  9. 弹性扩容缩容
  10. 网络、存储、复制、高可用、刷盘、堆积、顺序、幂等、事务
  11. 元数据中心同步
  12. 生产者消费者模型设计(缓冲队列),推拉模式

消息的重复处理

  1. 依赖 Exactly-once 语义,根据消息消费表 + 事务来做状态的更新,局限在于,如果有非 MySQL 操作或跨库就有问题,而且引入了事务
  2. 插入消息消费状态表,插入失败再稍后重试。
  3. 根据消息内容做重试(messageKey)。为了解决 md5 可能重复,messageKey 为 topic+tag+md5(body)做 Redis 锁一段时间,我们是锁两天,方便重置位点和解决问题的时间
  4. 机器重启过程的消息重复几率很大,因此需要在 bean 初始化后启动 consumer,bean 销毁前关停 consumer。producer 直接在 initmethod 使用 init/destory 即可。

消息可靠性保证

数据幂等、高可用镜像集群、Confirm 机制、事务机制、持久性

搜索基础

Spring/Mybatis

Spring 如何加载注解

Spring 启动流程、bean 实例化

MyBatis 如何防止 SQL 穿透

使用 # 而不是 $,底层使用 PreparedStatement,基于 SQL 预编译的替换

Spring 的 PostProcessor 接口起到什么作用

bean 初始化后回调,包括初始化前和初始化后的回调,例如 Apollo 的 ApolloConfig 注解,就是在初始化前增加监听器

Spring 如何实现事务

  1. ACID
  2. 隔离级别:读未提交、读已提交、可重复读、串行读
  3. 传播机制:require、support、mandatory、requires_new、not_support、never、nested
  4. 事务行为:事务开启、事务提交、事务回滚
  5. 事务切面:根据 ThreadLocal 获取是否有事务

如何在 Bean 对象中获取 ApllicationContext 上下文对象?

实现 ApplicationContextAware,原理是读取配置文件(注解扫描)后调用 refresh()

refresh:调用 FactorypostProcessors,初始化事件、注册事件监听器、通知子类刷新容器、初始化单例对象。

动态代理有哪些?JDK 动态代理和 cglib 有什么不同?

  1. JDK 代理:继承 Proxy,实现目标实现类的接口,将代理的方法都加上 final 修饰。在动态生成的实现类里面去调用 InvocationHandler 的 invoke 方法。
  2. cglib:继承目标类,实现 Factory,通过回调钩子方法调用

为什么用 Spring data JPA 而不用 MyBatis,MQ 的选型、注册中心的选型、配置中心的选型、分库分表的选型、数据传输的选型(主要针对我的项目来问的)

6. RPC 框架

1. Dubbo序列化,默认的序列化,各个序列化区别

2. Dubbo泛化调用实现的网关优劣点

3. Dubbo 消费者到提供者执行涉及的模块

4. Dubbo SPI

5. Spring Cloud 的 Eureka

内部服务注册表有双缓存:ReadWrite 缓存和 ReadOnly 缓存,会存在短时间内服务上线不可见的情况

7. JVM 基础

jvm 内存结构介绍、代码调用过程中的内存流程

堆 虚拟机栈 本地方法栈 程序计数器 元空间
栈帧的入栈出栈过程,与线程绑定,操作数栈用来记录操作位点
Java 文件被跨平台的虚拟机编译为 .class 二进制文件,接着加载类信息到方法区,验证,准备阶段初始化类变量为默认值,解析初始化卸载

Java 如何分配内存,多线程呢?

分为堆栈(栈上分配),每个线程一个线程栈,里面独立的有本地变量,共同引用了来自堆的对象

JVM 的内存模型

  1. 程序计数器
  2. 虚拟机栈
  3. 虚拟机堆
  4. 本地方法栈:栈上分配和逃逸分析以及 TLAB
  5. 元空间:取代了方法区,而且改为了直接内存,其中运行时常量池是其部分

常见异常:OutOfMemoryError: GC Overhead Limit Exceeded/Java heap space/MetaSpace

方法区是接口,永久代是实现。

内存分配:指针碰撞、空闲列表。内存回收:引用计数、可达性分析

((20200925230314-cidbmyl “【11】JVM 基础入门”))

类加载机制

类的加载过程:

  1. 加载:指 JVM 读取 Class 文件,并且根据 Class 文件描述创建 java.lang.Class 对象的过程
  2. 验证:验证文件
  3. 准备:这时候进行内存分配的仅包括类变量(static),而不包括实例变量
  4. 解析:JVM 会将常量池中的符号引用替换为直接引用,也就是得到类或者字段、方法在内存中的指针或者偏移量
  5. 初始化:执行类构造器 <clinit> () 方法的过程
  6. 卸载:所有实例被回收、加载该类的 ClassLoader 被回收、Class 对象无法通过任何途径访问(包括反射)

加载类的加载器:

  1. BootstrapClassLoader(启动类加载器):最顶层的加载类,由 C++ 实现,负责加载 %JAVA_HOME%/lib 目录下的 jar 包和类或者或被 -Xbootclasspath 参数指定的路径中的所有类。
  2. ExtensionClassLoader(扩展类加载器) :主要负责加载目录 %JRE_HOME%/lib/ext 目录下的 jar 包和类,或被 java.ext.dirs 系统变量所指定的路径下的 jar 包。
  3. AppClassLoader(应用程序类加载器) :面向我们用户的加载器,负责加载当前应用 classpath 下的所有 jar 包和类。

双亲委派模型

((20200925230314-cidbmyl “【11】JVM 基础入门”))

OOM 定位、解决方式,流程

gc 算法、扫描算法(优劣)、内存分区

JVM 中如何扫描对象;(root、hotspot 算法)、cardTable)

GC 算法(复制、标记清除、标记整理、内存碎片及解决方式)

GC 异常

什么情况下类被初始化,什么情况下类不会被初始化

类被初始化:

  1. 反射

类不会被初始化:

  1. 定义对象数组

8. 缓存基础

1. 你在公司怎么用的缓存,为什么用缓存?那会出现什么问题(缓存击穿、穿透、雪崩)?

  1. 在获取钉钉 token 时,使用的缓存,2 个小时过期,缓存 1 个小时 45 分钟,不用每次都去请求钉钉的 API。
  2. 在大规模情况下,会出现数据不一致以及缓存穿透、缓存雪崩。

2. Redis 为什么这么快,它的过期和淘汰策略是什么以及手写一个 LRU 算法

IO 多路复用 + 队列 + 事件分发(单线程) + 多线程操作 + 纯内存操作

3. Redis 高并发和高可用的集群原理

  1. 高并发:一主多从架构。
  2. 高可用:主从架构 + 哨兵。

4. Redis 持久化机制、底层原理是什么,有什么优劣?

  1. RDB:还可以做冷备,通过 runid 做 slave 的备份全量复制。按时间间隔全量备份写入磁盘。
  2. AOF:后者保证数据不丢失(1 秒)。每次操作都会写入 os cache,然后每隔一段时间写入磁盘。当 AOF 大到一定程度会执行 rewrite 操作,基于当前内存数据重新构造一个 aof 文件,将旧的 aof 文件删掉。

当同时开启了 RDB 和 AOF。重启时默认使用 AOF 恢复数据。因为 AOF 比 RDB 数据更完整。

RDB 优点:

  1. 基于 fork 子进程,对于主进程影响小。
  2. 基于 RDB 恢复数据时速度更快。
  3. 而且生成的 RDB 文件可以放在分布式存储中。方便做冷热备份

RDB 缺点:

  1. 丢数据会更多
  2. fork 子进程时,当快照文件过大时,会严重影响主进程。

AOF 优点:

  1. 默认间隔 1s ,丢失数据会更少
  2. 基于 append-only 写入,写入性能高

AOF 缺点:

  1. 因为 AOF 记录指令日志。所以AOF 文件比 RDB 文件大
  2. 影响写 QPS

5. 集群中 Redis key 如何寻址,寻址算法?hash 一致性算法

寻址算法基于 hash slot 寻址。固定 16739 取余。

使用 redis cluster,而不是 replication + sentinel。redis cluster 针对海量数据+高并发+高可用场景。

redis cluster:自动将数据进行分片,每个 master 上放一部分数据。内部支持当 master 不可用时还可以继续工作。

redis cluster 不仅有 6379 端口,还有 16379 端口,用于集群间的通信。

hash 算法演进:hash->一致性hash(自动缓存迁移)+虚拟节点(解决负载均衡问题),当某个机器宕机,会影响部分机器->redis cluster的hash slot,固定有 16739 个 slot,直接基于这个 16739 取余,就算有机器宕机,还是基于 16739 取模,这样对其它机器无影响。

redis cluster 的节点通信:内部使用 gossip 协议进行通信,元数据分布式存储,好处在于每个节点都存储了元数据,缺点在于会通信同步的的延迟。

6. 缓存、数据库双写不一致问题

先修改数据库、再删除缓存失败怎么办?这个时候先删缓存、再写数据。

  1. 删缓存成功、写数据库失败。这样其它的请求过来虽然请求的数据库是旧数据,但是写入缓存之后,是一致的。
  2. 线程 1 在删缓存成功、写数据库减少过程中,线程 2 读缓存为空,读数据库为 1000,这时候就设置了缓存为 1000。线程 1 数据库减少 1,变成 999。使用内存队列,同一个商品 id、订单 id 取模进行分发操作缓存和数据库,实现原子化。
  3. 分布式,多机器下,内存队列单点热化,在 ng 层就实现负载。

7. Redis 并发写的问题,即顺序问题

解决方案在于分布式锁和数据库中的时间戳版本号,不要用旧版本号覆盖新的版本号即可。

8. 公司的 Redis 架构,多少台,多少内存,多少核,CPU,主从架构,QPS?

9. 热点 key 的缓存,以及大对象的缓存问题解决。

10. Redis pipline 是全双工还是半双工

11. jedis 是否使用连接池,如何处理有序数据

12. Redis 数据结构类型及源码简述;

(String、Hash、ZSet、Set、Dict、GEO、HyperLogLog、BloomFilter、Stream、Bitmap 等)

13. Redis 并发竞争 Key 问题

  1. 分布式锁
  2. 写入数据库的时候,需要保存一个时间戳。假设时间戳如下
    系统 A key 1 {valueA 3:00}
    系统 B key 1 {valueB 3:05}
    系统 C key 1 {valueC 3:10}
    那么,假设这会系统 B 先抢到锁,将 key1 设置为{valueB 3:05}。接下来系统 A 抢到锁,发现自己的 valueA 的时间戳早于缓存中的时间戳,那就不做 set 操作了

14. Redis Replication 全量复制过程

  1. master 执行 bgsave,本地生成 rdb 快照文件(通过配置可以设置文件是否落盘)
  2. master -> slave
  3. master 在生成 rdb 过程中,期间的命令写入内存
  4. 如果复制过程中,内存写入数据过多(可配置)那么复制失败
  5. slave 收到 rdb,清空旧数据,然后加载 rdb 到内存,并提供服务(此时是旧数据)
  6. 如果 slave 开启了 aof,那么会立即执行 bgrewriteaof,重写 aof。

slave 第一次连接 master,会执行全复制。它们都会维护自己的 offset,slave 会上报到 master。
master 维护了 backlog,大约 1M,用来解决复制过程中的断传,后面进行增量。
当 master 回滚数据时,slave 会通过 run id 和 master 不同来发去全量复制。

15. Redis sentinel 机制

类似 zk 的功能,sentinel 自身高可用集群,用来监控 master 和 slave,负责 Redis 整体集群的高可用。

16. Redis 的数据丢失和脑裂问题

问题:由于 master->slave 异步,可能会丢失
解决:min-salves-write 和 min-slaves-max-log 配置,如果 slave 复制和 ack 延迟太长,master 拒绝写请求,保证数据不会丢,此时客户端可以将数据临时写入 MQ,定时任务将 MQ 刷入 Redis
问题:由于网络分区,多个 Redsi 集群互不通信,有多个 Master 接收写入,当脑裂恢复后,其它 Master 的数据会丢失。
问题:和丢失数据解决方案相同,如果 slave 和 master ack 延迟过大,直接拒绝请求,不会导致消息丢失过多。

17. Redis 实现分布式锁的几种方式,优劣

18. Redis 分片

  1. 客户端分片
  2. 代理分片
  3. 一致性hash
  4. 虚拟节点分片

19. sentinel 的作用,以及选举 slave 为 master 过程

sentinel 三大作用:

  1. 集群监控:监控 master 和 slave 进程是否正常工作
  2. 消息通知:某个实例有问题,可以报警给管理员
  3. 故障转移:master 故障则转移到 slave 中
  1. sentinel 觉得 master 宕机,主观宕机
  2. 大多数 sentienl 觉得 master 宕机,客观宕机
  3. 选举 master 的考虑优先级:
    1. 跟 master 断开时长
    2. slave 优先级(配置文件slave priority)
    3. 复制的 offset(复制进度)
    4. run id

9. 数据库

1. MySQL 索引类型有多少种

  1. 主键索引:alter table table_name add primary key_name(column)
  2. 唯一索引:alter table table_name add unique(column)
  3. 普通索引:alter table table_name add index index_name (column )
  4. 全文索引:alter table table_name add fulltext (column)

聚簇索引:索引结构和数据一起存放,例如主键索引叶子节点就存了数据,其中 innodb 的 .ibd 文件就包含了该表的索引和数据。

非聚簇索引:索引结构和数据分开存放,例如 MYISAM 的 .MYI 文件包含了表的索引,叶子节点存储索引和索引对应数据的指针,指向 .MYD 文件的数据。

非聚集索引的叶子节点并不一定存放数据的指针, 因为二级索引的叶子节点就存放的是主键,根据主键再回表查数据。
聚簇索引缺点:依赖有序数据,更新代价大(数据被修改索引也被修改)
非聚簇索引缺点:依赖有序数据,回表查(回查 .MYD 数据文件)

其中主键索引叶子节点存 data(聚簇索引,),二级索引叶子节点存主键 key,再回查主键索引树

2. MySQL 索引的使用与优化

覆盖索引、不回表、最左前缀、索引字段选择、数据量大时,优化器可能会使用不好的索引需要使用 force index

3. MyBatis 是如何执行 SQL 语句的

SQLSessionFactory->SqlSession->Executor->StatementHandler->ParameterHandler->TypeHandler->ResultSetHandler

其中二级缓存在解析 mapper 标签时创建,接着再 CachingExecutor 里面的 TransactionalCacheManager 中保存了这个引用,也就是跨 sqlsession(同时也是跨 executor 的,因为创建 sqlsession 时 executor 也会创建新的)。

4. JDBC 获取数据库连接的大概过程(从 getConnection 开始)

  1. 导包
  2. 反射注册驱动
  3. 打开连接获取 connection
  4. 执行查询,statement
  5. 获取结果集
  6. 释放资源

5. PreparedStatement 和 Statement 区别

prepareStatement 继承自 Statement,会先初始化 SQL,先把这个 SQL 提交到数据库中进行预处理,prepareStatement 可以在 SQL 中用?替换变量;

而 Statement 直接 SQL 编译,执行一次编译一次,而且存在 SQL 注入风险

6. MyBatis 的一些 API 接口,比如 SqlSession、sessionFactory 等等

  1. 参数处理:ParameterHandler
  2. 结果集处理:ResultSetHandler
  3. statement 实例:StatementHandler(PreparedStatment/Statement/CallableStatement),例如设置超时时间返回行数
  4. 执行器:Executor,模板模式,例如 缓存,事务,连接

7. 线上为什么用 RR 级别

8. 请问是不是每次连接数据库都创建一个连接?线程池你都有哪些了解?有接触过哪些线程池?

9. 数据库联表查询时,左连接和右连接的区别

10. 说说 MySQL 的七种事务隔离级别

11. MVCC

10. APM

1. skywalking 基本架构

分布式理论

云原生是什么

一致性 Hash 算法详解

项目实战

你是通过什么方式进行学习的?

随便讲个源码组件源码结构及内部核心原理

自己技术发展方向

读过什么组件源码 Redis、JVM、MyBatis、Spring 及相关基本都读过、sharding-jdbc 等)

看什么书