文章目录


线上 YGC 耗时过长优化方案有哪些?


  1. 如果生命周期过长的对象越来越多(比如全局变量或者静态变量等),会导致标注和复制过程的耗时增加
  2. 对存活对象标注时间过长:比如重载了 Object 类的 Finalize 方法,导致标注 Final Reference 耗时过长;或者 String.intern 方法使用不当,导致 YGC 扫描 StringTable 时间过长。可以通过以下参数显示 GC 处理 Reference 的耗时-XX:+PrintReferenceGC
  3. 长周期对象积累过多:比如本地缓存使用不当,积累了太多存活对象;或者锁竞争严重导致线程阻塞,局部变量的生命周期变长
  4. 案例参考​​​


线上频繁 FullGC 优化方案有哪些?


  1. 线上频繁 FullGC 一般会有这么几个特征:


    1. 线上多个线程的 CPU 都超过了 100%,通过 jstack 命令可以看到这些线程主要是垃圾回收线程
    2. 通过 jstat 命令监控 GC 情况,可以看到 Full GC 次数非常多,并且次数在不断增加


  2. 排查流程:


    1. top 找到 cpu 占用最高的一个 ​进程 id
    2. 然后 【top -Hp 进程 id】,找到 cpu 占用最高的 ​线程 id
    3. 【printf “%x\n” ​线程 id 】​,假设 16 进制结果为 a
    4. jstack 线程 id | grep ‘0xa’ -A 50 --color
    5. 如果是正常的用户线程, 则通过该线程的堆栈信息查看其具体是在哪处用户代码处运行比较消耗 CPU
    6. 如果该线程是 VMThread,则通过 jstat-gcutil 命令监控当前系统的 GC 状况,然后通过 jmapdump:format=b,file=导出系统当前的内存数据。导出之后将内存情况放到 eclipse 的 mat 工具中进行分析即可得出内存中主要是什么对象比较消耗内存,进而可以处理相关代码;正常情况下会发现 VM Thread 指的就是垃圾回收的线程
    7. 再执行【jstat -gcutil **进程 id】, **看到结果,如果 FGC 的数量很高,且在不断增长,那么可以定位是由于内存溢出导致 FullGC 频繁,系统缓慢
    8. 然后就可以 Dump 出内存日志,然后使用 MAT 的工具分析哪些对象占用内存较大,然后找到对象的创建位置,处理即可


  3. 参考案例:​​https://mp.weixin.qq.com/s/g8KJhOtiBHWb6wNFrCcLVg​​​​




如何进行线上堆外内存泄漏的分析?(Netty 尤其居多)


  1. JVM 的堆外内存泄露的定位一直是个比较棘手的问题
  2. 对外内存的泄漏分析一般都是先从堆内内存分析的过程中衍生出来的。有可能我们分析堆内内存泄露过程中发现,我们计算出来的 JVM 堆内存竟然大于了整个 JVM 的​Xmx​的大小,那说明多出来的是堆外内存
  3. 如果使用了 Netty 堆外内存,那么可以自行监控堆外内存的使用情况,不需要借助第三方工具,我们是使用的“反射”拿到的堆外内存的情况
  4. 逐渐缩小范围,直到 Bug 被找到。当我们确认某个线程的执行带来 Bug 时,可单步执行,可二分执行,定位到某行代码之后,跟到这段代码,然后继续单步执行或者二分的方式来定位最终出 Bug 的代码。这个方法屡试不爽,最后总能找到想要的 Bug
  5. 熟练掌握 idea 的调试,让我们的“捉虫”速度快如闪电(“闪电侠”就是这么来的)。这里,最常见的调试方式是​预执行表达式​,以及通过​线程调用栈​,死盯某个对象,就能够掌握这个对象的定义、赋值之类
  6. 在使用直接内存的项目中,最好建议配置 -XX:MaxDirectMemorySize,设定一个系统实际可达的最大的直接内存的值,默认的最大直接内存大小等于 -Xmx 的值
  7. 排查堆外泄露,建议指定启动参数: -XX:NativeMemoryTracking=summary - Dio.netty.leakDetection.targetRecords=100-Dio.netty.leakDetection.level=PARANOID,后面两个参数是 Netty 的相关内存泄露检测的级别与采样级别
  8. 参考案例: ​​https://tech.meituan.com/2018/10/18/netty-direct-memory-screening.html​

线上元空间内存泄露优化方案有哪些?


  1. 需要注意的一点是 Java8 以及 Java8+的 JVM 已经将永久代废弃了,取而代之的是元空间,且元空间是不是在 JVM 堆中的,而属于堆外内存,受最大物理内存限制。最佳实践就是我们在启动参数中最好设置上 -XX:MetaspaceSize=1024m -XX:MaxMetaspaceSize=1024m。具体的值根据情况设置。为避免动态申请,可以直接都设置为最大值
  2. 元空间主要存放的是类元数据,而且 metaspace 判断类元数据是否可以回收,是根据加载这些类元数据的 Classloader 是否可以回收来判断的,只要 Classloader 不能回收,通过其加载的类元数据就不会被回收。所以线上有时候会出现一种问题,由于框架中,往往大量采用类似 ASM、javassist 等工具进行字节码增强,生成代理类。如果项目中由主线程频繁生成动态代理类,那么就会导致元空间迅速占满,无法回收
  3. 具体案例可以参见: ​​https://zhuanlan.zhihu.com/p/200802910​

java 类加载器有哪些?

Bootstrap 类加载器

启动类加载器主要加载的是 JVM 自身需要的类,这个类加载使用 C++语言实现的,没有父类,是虚拟机自身的一部分,它负责将 ​<JAVA_HOME>/lib 路径下的核心类库​或**-Xbootclasspath 参数指定的路径下的 jar 包**加载到内存中,注意必由于虚拟机是按照文件名识别加载 jar 包的,如 rt.jar,如果文件名不被虚拟机识别,即使把 jar 包丢到 lib 目录下也是没有作用的(出于安全考虑,Bootstrap 启动类加载器只加载包名为 java、javax、sun 等开头

Extention 类加载器

扩展类加载器是指 Sun 公司实现的 sun.misc.Launcher$ExtClassLoader 类,​由 Java 语言实现的​,父类加载器为 null,是 Launcher 的静态内部类,它负责加载**<JAVA_HOME>/lib/ext 目录下​或者由系统变量​-Djava.ext.dir 指定位路径中的类库**,开发者可以直接使用标准扩展类加载器

[


Application 类加载器

称应用程序加载器是指 Sun 公司实现的 sun.misc.Launcher$AppClassLoader。父类加载器为 ExtClassLoader,它负责加载​系统类路径 java -classpath​或**-D java.class.path 指定路径下的类库**,也就是我们经常用到的​classpath 路径​,开发者可以直接使用系统类加载器,一般情况下该类加载是程序中默认的类加载器,通过 ClassLoader#getSystemClassLoader()方法可以获取到该类加载器


Custom 自定义类加载器

应用程序可以自定义类加载器,父类加载器为 AppClassLoader

[外链图片存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-xeq7vQ9d-1645802727670)(images/classloader.png)]

双亲委派机制是什么?

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-b3b2v9Cj-1645802727672)(images/classloader2.png)]

双亲委派机制

双亲委派模式是在 Java 1.2 后引入的,其工作原理的是,如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个​请求委托给父类的加载器去执行​,如果父类加载器还存在其父类加载器,则​进一步向上委托,依次递归​,​请求最终将到达顶层的启动类加载器​,如果父类加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派模式。

双亲委派的好处


  • 每一个类都只会被加载一次,避免了重复加载
  • 每一个类都会被尽可能的加载(从引导类加载器往下,每个加载器都可能会根据优先次序尝试加载它)
  • 有效避免了某些恶意类的加载(比如自定义了 Java.lang.Object 类,一般而言在双亲委派模型下会加载系统的 Object 类而不是自定义的 Object 类)

另外,可以多讲一下,如何破坏双亲委派模型


  1. 双亲委派模型的第一次“被破坏”是重写自定义加载器的 loadClass(),jdk 不推荐。一般都只是重写 findClass(),这样可以保持双亲委派机制.而 loadClass 方法加载规则由自己定义,就可以随心所欲的加载类
  2. 双亲委派模型的第二次“被破坏”是 ServiceLoader 和 Thread.setContextClassLoader()。即线程上下文类加载器(contextClassLoader)。双亲委派模型很好地解决了各个类加载器的基础类统一问题(越基础的类由越上层的加载器进行加载),基础类之所以被称为“基础”,是因为它们总是作为被调用代码调用的 API。但是,如果基础类又要调用用户的代码,那该怎么办呢?线程上下文类加载器就出现了。

  1. SPI。这个类加载器可以通过 java.lang.Thread 类的 setContextClassLoader()方法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个;如果在应用程序的全局范围内都没有设置过,那么这个类加载器默认就是应用程序类加载器。了有线程上下文类加载器,JNDI 服务使用这个线程上下文类加载器去加载所需要的 SPI 代码,也就是父类加载器请求子类加载器去完成类加载动作,这种行为实际上就是打通了双亲委派模型的层次结构来逆向使用类加载器,已经违背了双亲委派模型,但这也是无可奈何的事情。Java 中所有涉及 SPI 的加载动作基本上都采用这种方式,例如 JNDI,JDBC,JCE,JAXB 和 JBI 等。
  2. 线程上下文类加载器默认情况下就是 AppClassLoader,那为什么不直接通过 getSystemClassLoader()获取类加载器来加载 classpath 路径下的类的呢?其实是可行的,但这种直接使用 getSystemClassLoader()方法获取 AppClassLoader 加载类有一个缺点,那就是代码部署到不同服务时会出现问题,如把代码部署到 Java Web 应用服务或者 EJB 之类的服务将会出问题,因为这些服务使用的线程上下文类加载器并非 AppClassLoader,而是 Java Web 应用服自家的类加载器,类加载器不同。,所以我们应用该少用 getSystemClassLoader()。总之不同的服务使用的可能默认 ClassLoader 是不同的,但使用线程上下文类加载器总能获取到与当前程序执行相同的 ClassLoader,从而避免不必要的问题

  1. 双亲委派模型的第三次“被破坏”是由于用户对程序动态性的追求导致的,这里所说的“动态性”指的是当前一些非常“热门”的名词:代码热替换、模块热部署等,简答的说就是机器不用重启,只要部署上就能用。

GC 如何判断对象可以被回收?

  1. 引用计数法(已被淘汰的算法)
  1. 每一个对象有一个引用属性,新增一个引用时加一,引用释放时减一,计数为 0 的时候可以回收。

但是这种计算方法,有一个致命的问题,无法解决循环引用的问题


  1. 可达性分析算法(根引用)

  1. 从 GcRoot 开始向下搜索,搜索所走过的路径被称为引用链,当一个对象到 GcRoot 没有任何引用链相连时,则证明此对象是不可用的,那么虚拟机就可以判定回收。
  2. 那么 GcRoot 有哪些?

  1. 虚拟机栈中引用的对象
  2. 方法区中静态属性引用的对象。
  3. 方法区中常量引用的对象
  4. 本地方法栈中(即一般说的 native 方法)引用的对象


  1. 此外,不同的引用类型的回收机制是不一样的

  1. 强引用:通过关键字 new 的对象就是强引用对象,强引用指向的对象任何时候都不会被回收,宁愿 OOM 也不会回收。
  2. 软引用:如果一个对象持有软引用,那么当 JVM 堆空间不足时,会被回收。一个类的软引用可以通过 java.lang.ref.SoftReference 持有。
  3. 弱引用:如果一个对象持有弱引用,那么在 GC 时,只要发现弱引用对象,就会被回收。一个类的弱引用可以通过 java.lang.ref.WeakReference 持有。
  4. 虚引用:几乎和没有一样,随时可以被回收。通过 PhantomReference 持有。


如何回收内存对象,有哪些回收算法?

1.标记-清除(Mark-Sweep)算法

分为标记和清除两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。[外链图片存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-DH2Nqzav-1645802727673)(images/before.png)]

它的主要不足有两个:


  • 效率问题,标记和清除两个过程的效率都不高。
  • 空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

  1. 复制算法

为了解决效率问题,一种称为复制(Copying)的收集算法出现了,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。

[外链图片存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ognfH0NS-1645802727673)(images/copy.png)]

复制算法的代价​是将内存缩小为了原来的一半,减少了实际可用的内存。现在的商业虚拟机都采用这种收集算法来回收新生代,IBM 公司的专门研究表明,新生代中的对象 98%是“朝生夕死”的,所以并不需要按照 1:1 的比例来划分内存空间,而是将内存分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 和其中一块 Survivor。当回收时,将 Eden 和 Survivor 中还存活着的对象一次性地复制到另外一块 Survivor 空间上,最后清理掉 Eden 和刚才用过的 Survivor 空间。HotSpot 虚拟机默认 Eden 和 Survivor 的大小比例是 8:1,也就是每次新生代中可用内存空间为整个新生代容量的 90%(80%+10%),只有 10%的内存会被“浪费”。当然,98%的对象可回收只是一般场景下的数据,我们没有办法保证每次回收都只有不多于 10%的对象存活,当 Survivor 空间不够用时,需要依赖其他内存(这里指老年代)进行分配担保(Handle Promotion)。

  1. 标记-整理算法

复制收集算法在对象存活率较高时就要进行较多的复制操作,效率将会变低。更关键的是,如果不想浪费 50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都 100%存活的极端情况,所以在老年代一般不能直接选用这种算法。根据老年代的特点,有人提出了另外一种标记-整理(Mark-Compact)算法,标记过程仍然与标记-清除算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。

[外链图片存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-5JlymLXn-1645802727675)(images/3-1621487892206.png)]

  1. 分代收集算法

当前商业虚拟机的垃圾收集都采用分代收集(Generational Collection)算法,这种算法并没有什么新的思想,只是根据对象存活周期的不同将内存划分为几块。一般是把 Java 堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用标记—清理或者标记—整理算法来进行回收。

jvm 有哪些垃圾回收器,实际中如何选择?

[外链图片存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-QFtvfTz5-1645802727676)(images/gcollector.png)]

图中展示了 7 种作用于不同分代的收集器,如果两个收集器之间存在连线,则说明它们可以搭配使用。虚拟机所处的区域则表示它是属于新生代还是老年代收集器。

新生代收集器(全部的都是复制算法):Serial、ParNew、Parallel Scavenge

老年代收集器:CMS(标记-清理)、Serial Old(标记-整理)、Parallel Old(标记整理)

整堆收集器: G1(一个 Region 中是标记-清除算法,2 个 Region 之间是复制算法)

同时,先解释几个名词:

1,​并行(Parallel)​:多个垃圾收集线程并行工作,此时用户线程处于等待状态

2,​并发(Concurrent)​:用户线程和垃圾收集线程同时执行

3,​吞吐量​:运行用户代码时间/(运行用户代码时间+垃圾回收时间)

1.Serial 收集器是最基本的、发展历史最悠久的收集器。

**特点:**单线程、简单高效(与其他收集器的单线程相比),对于限定单个 CPU 的环境来说,Serial 收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程手机效率。收集器进行垃圾回收时,必须暂停其他所有的工作线程,直到它结束(Stop The World)。

应用场景​:适用于 Client 模式下的虚拟机。

Serial / Serial Old 收集器运行示意图

[外链图片存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-OMe36yIH-1645802727677)(images/serial.png)]

2.ParNew 收集器其实就是 Serial 收集器的多线程版本。

除了使用多线程外其余行为均和 Serial 收集器一模一样(参数控制、收集算法、Stop The World、对象分配规则、回收策略等)。

特点​:多线程、ParNew 收集器默认开启的收集线程数与 CPU 的数量相同,在 CPU 非常多的环境中,可以使用-XX:ParallelGCThreads 参数来限制垃圾收集的线程数。

   和 Serial 收集器一样存在 Stop The World 问题

应用场景​:ParNew 收集器是许多运行在 Server 模式下的虚拟机中首选的新生代收集器,因为它是除了 Serial 收集器外,唯一一个能与 CMS 收集器配合工作的。

ParNew/Serial Old 组合收集器运行示意图如下:

[外链图片存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-2nvNsqpM-1645802727677)(images/parnew.png)]

3.Parallel Scavenge 收集器与吞吐量关系密切,故也称为吞吐量优先收集器。

特点​:属于新生代收集器也是采用复制算法的收集器,又是并行的多线程收集器(与 ParNew 收集器类似)。

该收集器的目标是达到一个可控制的吞吐量。还有一个值得关注的点是:GC 自适应调节策略(与 ParNew 收集器最重要的一个区别)

GC 自适应调节策略​:Parallel Scavenge 收集器可设置-XX:+UseAdptiveSizePolicy 参数。当开关打开时不需要手动指定新生代的大小(-Xmn)、Eden 与 Survivor 区的比例(-XX:SurvivorRation)、晋升老年代的对象年龄(-XX:PretenureSizeThreshold)等,虚拟机会根据系统的运行状况收集性能监控信息,动态设置这些参数以提供最优的停顿时间和最高的吞吐量,这种调节方式称为 GC 的自适应调节策略。

Parallel Scavenge 收集器使用两个参数控制吞吐量:


  • XX:MaxGCPauseMillis 控制最大的垃圾收集停顿时间
  • XX:GCRatio 直接设置吞吐量的大小。

4.Serial Old 是 Serial 收集器的老年代版本。

特点​:同样是单线程收集器,采用标记-整理算法。

应用场景​:主要也是使用在 Client 模式下的虚拟机中。也可在 Server 模式下使用。

Server 模式下主要的两大用途(在后续中详细讲解···):


  1. 在 JDK1.5 以及以前的版本中与 Parallel Scavenge 收集器搭配使用。
  2. 作为 CMS 收集器的后备方案,在并发收集 Concurent Mode Failure 时使用。

Serial / Serial Old 收集器工作过程图(Serial 收集器图示相同):

[外链图片存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-XTmJQLOz-1645802727678)(images/serial-old.png)]

5.Parallel Old 是 Parallel Scavenge 收集器的老年代版本。

特点​:多线程,采用标记-整理算法。

应用场景​:注重高吞吐量以及 CPU 资源敏感的场合,都可以优先考虑 Parallel Scavenge+Parallel Old 收集器。

Parallel Scavenge/Parallel Old 收集器工作过程图:

6.CMS 收集器是一种以获取最短回收停顿时间为目标的收集器。

特点​:基于标记-清除算法实现。并发收集、低停顿。

应用场景​:适用于注重服务的响应速度,希望系统停顿时间最短,给用户带来更好的体验等场景下。如 web 程序、b/s 服务。

CMS 收集器的运行过程分为下列 4 步:

初始标记​:标记 GC Roots 能直接到的对象。速度很快但是仍存在 Stop The World 问题。

并发标记​:进行 GC Roots Tracing 的过程,找出存活对象且用户线程可并发执行。

重新标记​:为了修正并发标记期间因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录。仍然存在 Stop The World 问题。

并发清除​:对标记的对象进行清除回收。

CMS 收集器的内存回收过程是与用户线程一起并发执行的。

CMS 收集器的工作过程图:

[外链图片存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-rYOJnsug-1645802727678)(images/cms.png)]

CMS 收集器的缺点:


  • 对 CPU 资源非常敏感。
  • 无法处理浮动垃圾,可能出现 Concurrent Model Failure 失败而导致另一次 Full GC 的产生。
  • 因为采用标记-清除算法所以会存在空间碎片的问题,导致大对象无法分配空间,不得不提前触发一次 Full GC。[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-1S3vgltd-1645802727679)(images/cms2.png)]


7.G1 收集器一款面向服务端应用的垃圾收集器。

特点如下:

并行与并发:G1 能充分利用多 CPU、多核环境下的硬件优势,使用多个 CPU 来缩短 Stop-The-World 停顿时间。部分收集器原本需要停顿 Java 线程来执行 GC 动作,G1 收集器仍然可以通过并发的方式让 Java 程序继续运行。

分代收集:G1 能够独自管理整个 Java 堆,并且采用不同的方式去处理新创建的对象和已经存活了一段时间、熬过多次 GC 的旧对象以获取更好的收集效果。

空间整合:G1 运作期间不会产生空间碎片,收集后能提供规整的可用内存。

可预测的停顿:G1 除了追求低停顿外,还能建立可预测的停顿时间模型。能让使用者明确指定在一个长度为 M 毫秒的时间段内,消耗在垃圾收集上的时间不得超过 N 毫秒。

G1 收集器运行示意图:

[外链图片存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-jJv0zrBN-1645802727680)(images/g1.png)]


关于 gc 的选择

除非应用程序有非常严格的暂停时间要求,否则请先运行应用程序并允许 VM 选择收集器(如果没有特别要求。使用 VM 提供给的默认 GC 就好)。

如有必要,调整堆大小以提高性能。 如果性能仍然不能满足目标,请使用以下准则作为选择收集器的起点:


  • 如果应用程序的数据集较小(最大约 100 MB),则选择带有选项-XX:+ UseSerialGC 的串行收集器。
  • 如果应用程序将在单个处理器上运行,并且没有暂停时间要求,则选择带有选项-XX:+ UseSerialGC 的串行收集器。
  • 如果(a)峰值应用程序性能是第一要务,并且(b)没有暂停时间要求或可接受一秒或更长时间的暂停,则让 VM 选择收集器或使用-XX:+ UseParallelGC 选择并行收集器 。
  • 如果响应时间比整体吞吐量更重要,并且垃圾收集暂停时间必须保持在大约一秒钟以内,则选择具有-XX:+ UseG1GC。(值得注意的是 JDK9 中 CMS 已经被 Deprecated,不可使用!移除该选项)
  • 如果使用的是 jdk8,并且堆内存达到了 16G,那么推荐使用 G1 收集器,来控制每次垃圾收集的时间。
  • 如果响应时间是高优先级,或使用的堆非常大,请使用-XX:UseZGC 选择完全并发的收集器。(值得注意的是 JDK11 开始可以启动 ZGC,但是此时 ZGC 具有实验性质,在 JDK15 中[202009 发布]才取消实验性质的标签,可以直接显示启用,但是 JDK15 默认 GC 仍然是 G1)

这些准则仅提供选择收集器的起点,因为性能取决于堆的大小,应用程序维护的实时数据量以及可用处理器的数量和速度。

如果推荐的收集器没有达到所需的性能,则首先尝试调整堆和新生代大小以达到所需的目标。 如果性能仍然不足,尝试使用其他收集器

总体原则​:减少 STOP THE WORD 时间,使用并发收集器(比如 CMS+ParNew,G1)来减少暂停时间,加快响应时间,并使用并行收集器来增加多处理器硬件上的总体吞吐量。

JVM8 为什么要增加元空间?

原因:

1、字符串存在永久代中,容易出现性能问题和内存溢出。

2、类及方法的信息等比较难确定其大小,因此对于永久代的大小指定比较困难,太小容易出现永久代溢出,太大则容易导致老年代溢出。

3、永久代会为 GC 带来不必要的复杂度,并且回收效率偏低。

JVM8 中元空间有哪些特点?

1,每个加载器有专门的存储空间。

2,不会单独回收某个类。

3,元空间里的对象的位置是固定的。

4,如果发现某个加载器不再存货了,会把相关的空间整个回收

如何解决线上 gc 频繁的问题?


  1. 查看监控,以了解出现问题的时间点以及当前 FGC 的频率(可对比正常情况看频率是否正常)
  2. 了解该时间点之前有没有程序上线、基础组件升级等情况。
  3. 了解 JVM 的参数设置,包括:堆空间各个区域的大小设置,新生代和老年代分别采用了哪些垃圾收集器,然后分析 JVM 参数设置是否合理。
  4. 再对步骤 1 中列出的可能原因做排除法,其中元空间被打满、内存泄漏、代码显式调用 gc 方法比较容易排查。
  5. 针对大对象或者长生命周期对象导致的 FGC,可通过 jmap -histo 命令并结合 dump 堆内存文件作进一步分析,需要先定位到可疑对象。
  6. 通过可疑对象定位到具体代码再次分析,这时候要结合 GC 原理和 JVM 参数设置,弄清楚可疑对象是否满足了进入到老年代的条件才能下结论。

内存溢出的原因有哪些,如何排查线上问题?


  1. java.lang.OutOfMemoryError: …java heap space… 堆栈溢出,代码问题的可能性极大
  2. java.lang.OutOfMemoryError: GC over head limit exceeded 系统处于高频的 GC 状态,而且回收的效果依然不佳的情况,就会开始报这个错误,这种情况一般是产生了很多不可以被释放的对象,有可能是引用使用不当导致,或申请大对象导致,但是 java heap space 的内存溢出有可能提前不会报这个错误,也就是可能内存就直接不够导致,而不是高频 GC.
  3. java.lang.OutOfMemoryError: PermGen space jdk1.7 之前才会出现的问题 ,原因是系统的代码非常多或引用的第三方包非常多、或代码中使用了大量的常量、或通过 intern 注入常量、或者通过动态代码加载等方法,导致常量池的膨胀
  4. java.lang.OutOfMemoryError: Direct buffer memory 直接内存不足,因为 jvm 垃圾回收不会回收掉直接内存这部分的内存,所以可能原因是直接或间接使用了 ByteBuffer 中的 allocateDirect 方法的时候,而没有做 clear
  5. java.lang.StackOverflowError - Xss 设置的太小了
  6. java.lang.OutOfMemoryError: unable to create new native thread 堆外内存不足,无法为线程分配内存区域
  7. java.lang.OutOfMemoryError: request {} byte for {}out of swap 地址空间不够

Happens-Before 规则是什么?


  1. 程序顺序规则:一个线程中的每一个操作,happens-before 于该线程中的任意后续操作。
  2. 监视器规则:对一个锁的解锁,happens-before 于随后对这个锁的加锁。
  3. volatile 规则:对一个 volatile 变量的写,happens-before 于任意后续对一个 volatile 变量的读。
  4. 传递性:若果 A happens-before B,B happens-before C,那么 A happens-before C。
  5. 线程启动规则:Thread 对象的 start()方法,happens-before 于这个线程的任意后续操作。
  6. 线程终止规则:线程中的任意操作,happens-before 于该线程的终止监测。我们可以通过 Thread.join()方法结束、Thread.isAlive()的返回值等手段检测到线程已经终止执行。
  7. 线程中断操作:对线程 interrupt()方法的调用,happens-before 于被中断线程的代码检测到中断事件的发生,可以通过 Thread.interrupted()方法检测到线程是否有中断发生。
  8. 对象终结规则:一个对象的初始化完成,happens-before 于这个对象的 finalize()方法的开始。

介绍一下线程的生命周期及状态?

[外链图片存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-GA51H1DQ-1645802727681)(images/life.jpg)]

1.创建

当程序使用 new 关键字创建了一个线程之后,该线程就处于一个新建状态(初始状态),此时它和其他 Java 对象一样,仅仅由 Java 虚拟机为其分配了内存,并初始化了其成员变量值。此时的线程对象没有表现出任何线程的动态特征,程序也不会执行线程的线程执行体。

2.就绪

当线程对象调用了 Thread.start()方法之后,该线程处于就绪状态。Java 虚拟机会为其创建方法调用栈和程序计数器,处于这个状态的线程并没有开始运行,它只是表示该线程可以运行了。从 start()源码中看出,start 后添加到了线程列表中,接着在 native 层添加到 VM 中,至于该线程何时开始运行,取决于 JVM 里线程调度器的调度(如果 OS 调度选中了,就会进入到运行状态)。

3.运行

当线程对象调用了 Thread.start()方法之后,该线程处于就绪状态。添加到了线程列表中,如果 OS 调度选中了,就会进入到运行状态

4.阻塞

阻塞状态是线程因为某种原因放弃 CPU 使用权,暂时停止运行。直到线程进入就绪状态,才有机会到运行状态。阻塞的情况大概三种:


  • 1、​等待阻塞​:运行的线程执行 wait()方法,JVM 会把该线程放入等待池中。(wait 会释放持有的锁)
  • 2、​同步阻塞​:运行的线程在获取对象的同步锁时,若该同步锁被别的线程占用,则 JVM 会把该线程放入锁池中。
  • 3、​其他阻塞​:运行的线程执行 sleep()或 join()方法,或者发出了 I/O 请求时,JVM 会把该线程置为阻塞状态。当 sleep()状态超时、join()等待线程终止或者超时、或者 I/O 处理完毕时,线程重新转入就绪状态。(注意,sleep 是不会释放持有的锁)。
  • 线程睡眠:Thread.sleep(long millis)方法,使线程转到阻塞状态。millis 参数设定睡眠的时间,以毫秒为单位。当睡眠结束后,就转为就绪(Runnable)状态。sleep()平台移植性好。
  • 线程等待:Object 类中的 wait()方法,导致当前的线程等待,直到其他线程调用此对象的 notify() 方法或 notifyAll() 唤醒方法。这个两个唤醒方法也是 Object 类中的方法,行为等价于调用 wait(0) 一样。唤醒线程后,就转为就绪(Runnable)状态。
  • 线程让步:Thread.yield() 方法,暂停当前正在执行的线程对象,把执行机会让给相同或者更高优先级的线程。
  • 线程加入:join()方法,等待其他线程终止。在当前线程中调用另一个线程的 join()方法,则当前线程转入阻塞状态,直到另一个进程运行结束,当前线程再由阻塞转为就绪状态。
  • 线程 I/O:线程执行某些 IO 操作,因为等待相关的资源而进入了阻塞状态。比如说监听 system.in,但是尚且没有收到键盘的输入,则进入阻塞状态。
  • 线程唤醒:Object 类中的 notify()方法,唤醒在此对象监视器上等待的单个线程。如果所有线程都在此对象上等待,则会选择唤醒其中一个线程,选择是任意性的,并在对实现做出决定时发生。类似的方法还有一个 notifyAll(),唤醒在此对象监视器上等待的所有线程。

5.死亡

线程会以以下三种方式之一结束,结束后就处于死亡状态:


  • run()方法执行完成,线程正常结束。
  • 线程抛出一个未捕获的 Exception 或 Error。
  • 直接调用该线程的 stop()方法来结束该线程——该方法容易导致死锁,通常不推荐使用

线程的 sleep、wait、join、yield 如何使用?

sleep​:让线程睡眠,期间会出让 cpu,在同步代码块中,不会释放锁

wait​(必须先获得对应的锁才能调用):让线程进入等待状态,释放当前线程持有的锁资源线程只有在 notify 或者 notifyAll 方法调用后才会被唤醒,然后去争夺锁.

join​:线程之间协同方式,使用场景: 线程 A 必须等待线程 B 运行完毕后才可以执行,那么就可以在线程 A 的代码中加入 ThreadB.join();

yield​:让当前正在运行的线程回到可运行状态,以允许具有相同优先级的其他线程获得运行的机会。因此,使用 yield()的目的是让具有相同优先级的线程之间能够适当的轮换执行。但是,实际中无法保证 yield()达到让步的目的,因为,让步的线程可能被线程调度程序再次选中。

创建线程有哪些方式?

1)继承 Thread 类创建线程

2)实现 Runnable 接口创建线程

3)使用 Callable 和 Future 创建线程

4)使用线程池例如用 Executor 框架

什么是守护线程?

在 Java 中有两类线程:User Thread(用户线程)、Daemon Thread(守护线程)

任何一个守护线程都是整个 JVM 中所有非守护线程的保姆:

只要当前 JVM 实例中尚存在任何一个非守护线程没有结束,守护线程就全部工作;只有当最后一个非守护线程结束时,守护线程随着 JVM 一同结束工作。Daemon 的作用是为其他线程的运行提供便利服务,守护线程最典型的应用就是 GC (垃圾回收器),它就是一个很称职的守护者。

User 和 Daemon 两者几乎没有区别,唯一的不同之处就在于虚拟机的离开:如果 User Thread 已经全部退出运行了,只剩下 Daemon Thread 存在了,虚拟机也就退出了。 因为没有了被守护者,Daemon 也就没有工作可做了,也就没有继续运行程序的必要了。

注意事项:

(1) thread.setDaemon(true)必须在 thread.start()之前设置,否则会出现一个 IllegalThreadStateException 异常。只能在线程未开始运行之前设置为守护线程。

(2) 在 Daemon 线程中产生的新线程也是 Daemon 的。

(3) 不要认为所有的应用都可以分配给 Daemon 来进行读写操作或者计算逻辑,因为这会可能回到数据不一致的状态。

ThreadLocal 的原理是什么,使用场景有哪些?

Thread 类中有两个变量 threadLocals 和 inheritableThreadLocals,二者都是 ThreadLocal 内部类 ThreadLocalMap 类型的变量,我们通过查看内部内 ThreadLocalMap 可以发现实际上它类似于一个 HashMap。在默认情况下,每个线程中的这两个变量都为 null:

ThreadLocal.ThreadLocalMap threadLocals = null;
ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;

只有当线程第一次调用 ThreadLocal 的 set 或者 get 方法的时候才会创建他们。

public T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
return setInitialValue();
}

ThreadLocalMap getMap(Thread t) {
return t.threadLocals;
}

除此之外,每个线程的本地变量不是存放在 ThreadLocal 实例中,而是放在调用线程的​ThreadLocals​变量里面。也就是说,​ThreadLocal 类型的本地变量是存放在具体的线程空间上​,其本身相当于一个装载本地变量的载体,通过 set 方法将 value 添加到调用线程的 threadLocals 中,当调用线程调用 get 方法时候能够从它的 threadLocals 中取出变量。如果调用线程一直不终止,那么这个本地变量将会一直存放在他的 threadLocals 中,所以不使用本地变量的时候需要调用 remove 方法将 threadLocals 中删除不用的本地变量,防止出现内存泄漏。

public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
}
public void remove() {
ThreadLocalMap m = getMap(Thread.currentThread());
if (m != null)
m.remove(this);
}

ThreadLocal 有哪些内存泄露问题,如何避免?

每个 Thread 都有一个 ThreadLocal.ThreadLocalMap 的 map,该 map 的 key 为 ThreadLocal 实例,它为一个弱引用,我们知道弱引用有利于 GC 回收。当 ThreadLocal 的 key == null 时,GC 就会回收这部分空间,但是 value 却不一定能够被回收,因为他还与 Current Thread 存在一个强引用关系,如下

[外链图片存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-7NCgQNj6-1645802727682)(images/threadlocal.png)]由于存在这个强引用关系,会导致 value 无法回收。如果这个线程对象不会销毁那么这个强引用关系则会一直存在,就会出现内存泄漏情况。所以说只要这个线程对象能够及时被 GC 回收,就不会出现内存泄漏。如果碰到线程池,那就更坑了。 那么要怎么避免这个问题呢? 在前面提过,在 ThreadLocalMap 中的 setEntry()、getEntry(),如果遇到 key == null 的情况,会对 value 设置为 null。当然我们也可以显示调用 ThreadLocal 的 remove()方法进行处理。 下面再对 ThreadLocal 进行简单的总结:


  • ThreadLocal 不是用于解决共享变量的问题的,也不是为了协调线程同步而存在,而是为了方便每个线程处理自己的状态而引入的一个机制。这点至关重要。
  • 每个 Thread 内部都有一个 ThreadLocal.ThreadLocalMap 类型的成员变量,该成员变量用来存储实际的 ThreadLocal 变量副本。
  • ThreadLocal 并不是为线程保存对象的副本,它仅仅只起到一个索引的作用。它的主要木得视为每一个线程隔离一个类的实例,这个实例的作用范围仅限于线程内部。

为什么要使用线程池?

为了减少创建和销毁线程的次数,让每个线程可以多次使用,可根据系统情况​调整执行​的线程数量,防止消耗过多内存,所以我们可以使用线程池.

线程池线程复用的原理是什么?

思考这么一个问题:任务结束后会不会回收线程?

答案是:allowCoreThreadTimeOut 控制

/java/util/concurrent/ThreadPoolExecutor.java:1127
final void runWorker(Worker w) {
Thread wt = Thread.currentThread();
Runnable task = w.firstTask;
w.firstTask = null;
w.unlock(); // allow interrupts
boolean completedAbruptly = true;
try {
while (task != null || (task = getTask()) != null) {...执行任务...}
completedAbruptly = false;
} finally {
processWorkerExit(w, completedAbruptly);
}
}
首先线程池内的线程都被包装成了一个个的java.util.concurrent.ThreadPoolExecutor.Worker,然后这个worker会马不停蹄的执行任务,执行完任务之后就会在while循环中去取任务,取到任务就继续执行,取不到任务就跳出while循环(这个时候worker就不能再执行任务了)执行 processWorkerExit方法,这个方法呢就是做清场处理,将当前woker线程从线程池中移除,并且判断是否是异常的进入processWorkerExit方法,如果是非异常情况,就对当前线程池状态(RUNNING,shutdown)和当前工作线程数和当前任务数做判断,是否要加入一个新的线程去完成最后的任务(防止没有线程去做剩下的任务).
那么什么时候会退出while循环呢?取不到任务的时候(getTask() == null).下面看一下getTask方法

private Runnable getTask() {
boolean timedOut = false; // Did the last poll() time out?

for (;;) {
int c = ctl.get();
int rs = runStateOf(c);

//(rs == SHUTDOWN && workQueue.isEmpty()) || rs >=STOP
//若线程池状态是SHUTDOWN 并且 任务队列为空,意味着已经不需要工作线程执行任务了,线程池即将关闭
//若线程池的状态是 STOP TIDYING TERMINATED,则意味着线程池已经停止处理任何任务了,不在需要线程
if (rs >= SHUTDOWN && (rs >= STOP || workQueue.isEmpty())) {
//把此工作线程从线程池中删除
decrementWorkerCount();
return null;
}

int wc = workerCountOf(c);

//allowCoreThreadTimeOut:当没有任务的时候,核心线程数也会被剔除,默认参数是false,官方推荐在创建线程池并且还未使用的时候,设置此值
//如果当前工作线程数 大于 核心线程数,timed为true
boolean timed = allowCoreThreadTimeOut || wc > corePoolSize;

//(wc > maximumPoolSize || (timed && timedOut)):当工作线程超过最大线程数,或者 允许超时并且超时过一次了
//(wc > 1 || workQueue.isEmpty()):工作线程数至少为1个 或者 没有任务了
//总的来说判断当前工作线程还有没有必要等着拿任务去执行
//wc > maximumPoolSize && wc>1 : 就是判断当前工作线程是否超过最大值
//或者 wc > maximumPoolSize && workQueue.isEmpty():工作线程超过最大,基本上不会走到这,
// 如果走到这,则意味着wc=1 ,只有1个工作线程了,如果此时任务队列是空的,则把最后的线程删除
//或者(timed && timedOut) && wc>1:如果允许超时并且超时过一次,并且至少有1个线程,则删除线程
//或者 (timed && timedOut) && workQueue.isEmpty():如果允许超时并且超时过一次,并且此时工作 队列为空,那么妥妥可以把最后一个线程(因为上面的wc>1不满足,则可以得出来wc=1)删除
if ((wc > maximumPoolSize || (timed && timedOut))
&& (wc > 1 || workQueue.isEmpty())) {
if (compareAndDecrementWorkerCount(c))
//如果减去工作线程数成功,则返回null出去,也就是说 让工作线程停止while轮训,进行收尾
return null;
continue;
}

try {
//判断是否要阻塞获取任务
Runnable r = timed ?
workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) :
workQueue.take();
if (r != null)
return r;
timedOut = true;
} catch (InterruptedException retry) {
timedOut = false;
}
}
}

//综上所述,如果allowCoreThreadTimeOut为true,并且在第1次阻塞获取任务失败了,那么当前getTask会返回null,不管是不是核心线程;那么runWorker中将推出while循环,也就意味着当前工作线程被销毁

通过上面这个问题可以得出一个结论:当你的线程池参数配置合理的时候,执行完任务的线程是不会被销毁的,而是会从任务队列中取出任务继续执行!

如何预防死锁?


  1. 首先需要将死锁发生的是个必要条件讲出来:

  1. 互斥条件 同一时间只能有一个线程获取资源。
  2. 不可剥夺条件 一个线程已经占有的资源,在释放之前不会被其它线程抢占
  3. 请求和保持条件 线程等待过程中不会释放已占有的资源
  4. 循环等待条件 多个线程互相等待对方释放资源

  1. 死锁预防,那么就是需要破坏这四个必要条件

  1. 由于资源互斥是资源使用的固有特性,无法改变,我们不讨论
  2. 破坏不可剥夺条件
  1. 一个进程不能获得所需要的全部资源时便处于等待状态,等待期间他占有的资源将被隐式的释放重新加入到系统的资源列表中,可以被其他的进程使用,而等待的进程只有重新获得自己原有的资源以及新申请的资源才可以重新启动,执行

  1. 破坏请求与保持条件

  1. 第一种方法静态分配即每个进程在开始执行时就申请他所需要的全部资源
  2. 第二种是动态分配即每个进程在申请所需要的资源时他本身不占用系统资源

  1. 破坏循环等待条件
  1. 采用资源有序分配其基本思想是将系统中的所有资源顺序编号,将紧缺的,稀少的采用较大的编号,在申请资源时必须按照编号的顺序进行,一个进程只有获得较小编号的进程才能申请较大编号的进程。

描述一下线程安全活跃态问题?

线程安全的活跃性问题可以分为 死锁、活锁、饥饿


  1. 活锁 就是有时线程虽然没有发生阻塞,但是仍然会存在执行不下去的情况,活锁不会阻塞线程,线程会一直重复执行某个相同的操作,并且一直失败重试

  1. 我们开发中使用的异步消息队列就有可能造成活锁的问题,在消息队列的消费端如果没有正确的 ack 消息,并且执行过程中报错了,就会再次放回消息头,然后再拿出来执行,一直循环往复的失败。这个问题除了正确的 ack 之外,往往是通过将失败的消息放入到延时队列中,等到一定的延时再进行重试来解决。
  2. 解决活锁的方案很简单,尝试等待一个随机的时间就可以,会按时间轮去重试

  1. 饥饿 就是 线程因无法访问所需资源而无法执行下去的情况

  1. 饥饿 分为两种情况:

  1. 一种是其他的线程在临界区做了无限循环或无限制等待资源的操作,让其他的线程一直不能拿到锁进入临界区,对其他线程来说,就进入了饥饿状态
  2. 另一种是因为线程优先级不合理的分配,导致部分线程始终无法获取到 CPU 资源而一直无法执行

  1. 解决饥饿的问题有几种方案:

  1. 保证资源充足,很多场景下,资源的稀缺性无法解决
  2. 公平分配资源,在并发编程里使用公平锁,例如 FIFO 策略,线程等待是有顺序的,排在等待队列前面的线程会优先获得资源
  3. 避免持有锁的线程长时间执行,很多场景下,持有锁的线程的执行时间也很难缩短


  1. 死锁 线程在对同一把锁进行竞争的时候,未抢占到锁的线程会等待持有锁的线程释放锁后继续抢占,如果两个或两个以上的线程互相持有对方将要抢占的锁,互相等待对方先行释放锁就会进入到一个循环等待的过程,这个过程就叫做死锁

线程安全的竞态条件有哪些?


  1. 同一个程序多线程访问同一个资源,如果对资源的访问顺序敏感,就称存在竞态条件,代码区成为临界区。 大多数并发错误一样,竞态条件不总是会产生问题,还需要不恰当的执行时序
  2. 最常见的竞态条件为

  1. 先检测后执行执行依赖于检测的结果,而检测结果依赖于多个线程的执行时序,而多个线程的执行时序通常情况下是不固定不可判断的,从而导致执行结果出现各种问题,见一种可能 的解决办法就是:在一个线程修改访问一个状态时,要防止其他线程访问修改,也就是加锁机制,保证原子性
  2. 延迟初始化(典型为单例)


程序开多少线程合适?


  1. CPU 密集型程序,一个完整请求,I/O 操作可以在很短时间内完成,CPU 还有很多运算要处理,也就是说 CPU 计算的比例占很大一部分,线程等待时间接近 0

  1. 单核 CPU: 一个完整请求,I/O 操作可以在很短时间内完成, CPU 还有很多运算要处理,也就是说 CPU 计算的比例占很大一部分,线程等待时间接近 0。单核 CPU 处理 CPU 密集型程序,这种情况并不太适合使用多线程。
  2. 多核 : 如果是多核 CPU 处理 CPU 密集型程序,我们完全可以最大化的利用 CPU 核心数,应用并发编程来提高效率。CPU 密集型程序的最佳线程数就是:理论上线程数量 = CPU 核数(逻辑),但是实际上,数量一般会设置为 CPU 核数(逻辑)+ 1(经验值),计算(CPU)密集型的线程恰好在某时因为发生一个页错误或者因其他原因而暂停,刚好有一个“额外”的线程,可以确保在这种情况下 CPU 周期不会中断工作

  1. I/O 密集型程序,与 CPU 密集型程序相对,一个完整请求,CPU 运算操作完成之后还有很多 I/O 操作要做,也就是说 I/O 操作占比很大部分,等待时间较长,线程等待时间所占比例越高,需要越多线程;线程 CPU 时间所占比例越高,需要越少线程

  1. I/O 密集型程序的最佳线程数就是: 最佳线程数 = CPU 核心数 ​(1/CPU 利用率) = CPU 核心数​ (1 + (I/O 耗时/CPU 耗时))
  2. 如果几乎全是 I/O 耗时,那么 CPU 耗时就无限趋近于 0,所以纯理论你就可以说是 2N(N=CPU 核数),当然也有说 2N + 1 的,1 应该是 backup
  3. 一般我们说 2N + 1 就即可


synchronized 和 lock 有哪些区别?

区别类型

synchronized

Lock

存在层次

Java 的关键字,在 jvm 层面上

是 JVM 的一个接口

锁的获取

假设 A 线程获得锁,B 线程等待。如果 A 线程阻塞,B 线程会一直等待

情况而定,Lock 有多个锁获取的方式,大致就是可以尝试获得锁,线程可以不用一直等待(可以通过 tryLock 判断有没有锁)

锁的释放

1、以获取锁的线程执行完同步代码,释放锁 2、线程执行发生异常,jvm 会让线程释放

在 finally 中必须释放锁,不然容易造成线程死锁

锁类型

锁可重入、不可中断、非公平

可重入、可判断 可公平(两者皆可)

性能

少量同步

适用于大量同步

支持锁的场景

1. 独占锁

1. 公平锁与非公平锁

ABA 问题遇到过吗,详细说一下?


  1. 有两个线程同时去修改一个变量的值,比如线程 1、线程 2,都更新变量值,将变量值从 A 更新成 B。
  2. 首先线程 1 获取到 CPU 的时间片,线程 2 由于某些原因发生阻塞进行等待,此时线程 1 进行比较更新(CompareAndSwap),成功将变量的值从 A 更新成 B。
  3. 更新完毕之后,恰好又有线程 3 进来想要把变量的值从 B 更新成 A,线程 3 进行比较更新,成功将变量的值从 B 更新成 A。
  4. 线程 2 获取到 CPU 的时间片,然后进行比较更新,发现值是预期的 A,然后有更新成了 B。但是线程 1 并不知道,该值已经有了 A->B->A 这个过程,这也就是我们常说的 ABA 问题。

volatile 的可见性和禁止指令重排序怎么实现的?


  • 可见性:
    volatile 的功能就是被修饰的变量在被修改后可以立即同步到主内存,被修饰的变量在每次是用之前都从主内存刷新。本质也是通过内存屏障来实现可见性
    写内存屏障(Store Memory Barrier)可以促使处理器将当前 store buffer(存储缓存)的值写回主存。读内存屏障(Load Memory Barrier)可以促使处理器处理 invalidate queue(失效队列)。进而避免由于 Store Buffer 和 Invalidate Queue 的非实时性带来的问题。
  • 禁止指令重排序:
    volatile 是通过​内存屏障​来禁止指令重排序
    JMM 内存屏障的策略


    • 在每个 volatile 写操作的前面插入一个 StoreStore 屏障。
    • 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障。
    • 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障。
    • 在每个 volatile 读操作的后面插入一个 LoadStore 屏障。



ConcurrentHashMap 底层原理是什么?

1.7

数据结构:

内部主要是一个 Segment 数组,而数组的每一项又是一个 HashEntry 数组,元素都存在 HashEntry 数组里。因为每次锁定的是 Segment 对象,也就是整个 HashEntry 数组,所以又叫分段锁。

[外链图片存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-JDqGwSnC-1645802727682)(images/1.7ConcurrentHashMap.png)]

1.8

数据结构:

与 HashMap 一样采用:数组+链表+红黑树

[外链图片存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-xOfZeKJP-1645802727683)(images/ConCurrentHashMap.png)]

底层原理则是采用锁链表或者红黑树头结点,相比于 HashTable 的方法锁,力度更细,是对数组(table)中的桶(链表或者红黑树)的头结点进行锁定,这样锁定,只会影响数组(table)当前下标的数据,不会影响其他下标节点的操作,可以提高读写效率。

putVal 执行流程:


  1. 判断存储的 key、value 是否为空,若为空,则抛出异常
  2. 计算 key 的 hash 值,随后死循环(该循环可以确保成功插入,当满足适当条件时,会主动终止),判断 table 表为空或者长度为 0,则初始化 table 表
  3. 根据 hash 值获取 table 中该下标对应的节点,如果该节点为空,则根据参数生成新的节点,并以 CAS 的方式进行更新,并终止死循环。
  4. 如果该节点的 hash 值是 MOVED(-1),表示正在扩容,则辅助对该节点进行转移。
  5. 对数组(table)中的节点,即桶的头结点进行锁定,如果该节点的 hash 大于等于 0,表示此桶是链表,然后对该桶进行遍历(死循环),寻找链表中与 put 的 key 的 hash 值相等,并且 key 相等的元素,然后进行值的替换,如果到链表尾部都没有符合条件的,就新建一个 node,然后插入到该桶的尾部,并终止该循环遍历。
  6. 如果该节点的 hash 小于 0,并且节点类型是 TreeBin,则走红黑树的插入方式。
  7. 判断是否达到转化红黑树的阈值,如果达到阈值,则链表转化为红黑树。

分布式 id 生成方案有哪些?

UUID,数据库主键自增,Redis 自增 ID,雪花算法。

描述

优点

缺点

UUID

UUID 是通用唯一标识码的缩写,其目的是让分布式系统中的所有元素都有唯一的辨识信息,而不需要通过中央控制器来指定唯一标识。

1. 降低全局节点的压力,使得主键生成速度更快;

2. 生成的主键全局唯一;

3. 跨服务器合并数据方便。

1. UUID 占用 16 个字符,空间占用较多;

2. 不是递增有序的数字,数据写入 IO 随机性很大,且索引效率下降

数据库主键自增

MySQL 数据库设置主键且主键自动增长

1. INT 和 BIGINT 类型占用空间较小;

2. 主键自动增长,IO 写入连续性好;

3. 数字类型查询速度优于字符串

1. 并发性能不高,受限于数据库性能;

2. 分库分表,需要改造,复杂;

3. 自增:数据和数据量泄露

Redis 自增

Redis 计数器,原子性自增

使用内存,并发性能好

1. 数据丢失;

2. 自增:数据量泄露

雪花算法(snowflake)

大名鼎鼎的雪花算法,分布式 ID 的经典解决方案

1. 不依赖外部组件;

2. 性能好

时钟回拨

雪花算法生成的 ID 由哪些部分组成?


  1. 符号位,占用 1 位。
  2. 时间戳,占用 41 位,可以支持 69 年的时间跨度。
  3. 机器 ID,占用 10 位。
  4. 序列号,占用 12 位。一毫秒可以生成 4095 个 ID。

[外链图片存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-NypLiZCT-1645802727684)(images/image-20210521124236027.png)]

分布式锁在项目中有哪些应用场景?

使用分布式锁的场景一般需要满足以下场景:


  1. 系统是一个分布式系统,集群集群,java 的锁已经锁不住了。
  2. 操作共享资源,比如库里唯一的用户数据。
  3. 同步访问,即多个进程同时操作共享资源。

分布锁有哪些解决方案?


  1. Reids 的分布式锁,很多大公司会基于 Reidis 做扩展开发。setnx key value ex 10s,Redisson。
    watch dog.
  2. 基于 Zookeeper。临时节点,顺序节点。
  3. 基于数据库,比如 Mysql。主键或唯一索引的唯一性。

Redis 做分布式锁用什么命令?

SETNX

格式:setnx key value 将 key 的值设为 value ,当且仅当 key 不存在。

若给定的 key 已经存在,则 SETNX 不做任何动作,操作失败。

SETNX 是『SET if Not eXists』(如果不存在,则 SET)的简写。

加锁:set key value nx ex 10s

释放锁:delete key

Redis 做分布式锁死锁有哪些情况,如何解决?

情况 1:加锁,没有释放锁。需要加释放锁的操作。比如 delete key。

情况 2:加锁后,程序还没有执行释放锁,程序挂了。需要用的 key 的过期机制。

Redis 如何做分布式锁?

假设有两个服务 A、B 都希望获得锁,执行过程大致如下:

Step1: 服务 A 为了获得锁,向 Redis 发起如下命令: SET productId:lock 0xx9p03001 NX EX 30000 其中,"productId"由自己定义,可以是与本次业务有关的 id,"0xx9p03001"是一串随机值,必须保证全局唯一,“NX"指的是当且仅当 key(也就是案例中的"productId:lock”)在 Redis 中不存在时,返回执行成功,否则执行失败。"EX 30000"指的是在 30 秒后,key 将被自动删除。执行命令后返回成功,表明服务成功的获得了锁。

Step2: 服务 B 为了获得锁,向 Redis 发起同样的命令: SET productId:lock 0000111 NX EX 30000

由于 Redis 内已经存在同名 key,且并未过期,因此命令执行失败,服务 B 未能获得锁。服务 B 进入循环请求状态,比如每隔 1 秒钟(自行设置)向 Redis 发送请求,直到执行成功并获得锁。

Step3: 服务 A 的业务代码执行时长超过了 30 秒,导致 key 超时,因此 Redis 自动删除了 key。此时服务 B 再次发送命令执行成功,假设本次请求中设置的 value 值为 0000222。此时需要在服务 A 中对 key 进行续期,watch dog。

Step4: 服务 A 执行完毕,为了释放锁,服务 A 会主动向 Redis 发起删除 key 的请求。注意: 在删除 key 之前,一定要判断服务 A 持有的 value 与 Redis 内存储的 value 是否一致。比如当前场景下,Redis 中的锁早就不是服务 A 持有的那一把了,而是由服务 2 创建,如果贸然使用服务 A 持有的 key 来删除锁,则会误将服务 2 的锁释放掉。此外,由于删除锁时涉及到一系列判断逻辑,因此一般使用 lua 脚本,具体如下:

if redis.call("get", KEYS[1])==ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end

基于 ZooKeeper 的分布式锁实现原理是什么?

顺序节点特性:

使用 ZooKeeper 的顺序节点特性,假如我们在/lock/目录下创建 3 个节点,ZK 集群会按照发起创建的顺序来创建节点,节点分别为/lock/0000000001、/lock/0000000002、/lock/0000000003,最后一位数是依次递增的,节点名由 zk 来完成。

临时节点特性:

ZK 中还有一种名为临时节点的节点,临时节点由某个客户端创建,当客户端与 ZK 集群断开连接,则该节点自动被删除。EPHEMERAL_SEQUENTIAL 为临时顺序节点。

根据 ZK 中节点是否存在,可以作为分布式锁的锁状态,以此来实现一个分布式锁,下面是分布式锁的基本逻辑:


  1. 客户端 1 调用 create()方法创建名为“/业务 ID/lock-”的临时顺序节点。
  2. 客户端 1 调用 getChildren(“业务 ID”)方法来获取所有已经创建的子节点。
  3. 客户端获取到所有子节点 path 之后,如果发现自己在步骤 1 中创建的节点是所有节点中序号最小的,就是看自己创建的序列号是否排第一,如果是第一,那么就认为这个客户端 1 获得了锁,在它前面没有别的客户端拿到锁。
  4. 如果创建的节点不是所有节点中需要最小的,那么则监视比自己创建节点的序列号小的最大的节点,进入等待。直到下次监视的子节点变更的时候,再进行子节点的获取,判断是否获取锁。

ZooKeeper 和 Reids 做分布式锁的区别?

Reids:


  1. Redis 只保证最终一致性,副本间的数据复制是异步进行(Set 是写,Get 是读,Reids 集群一般是读写分离架构,存在主从同步延迟情况),主从切换之后可能有部分数据没有复制过去可能会 ​「丢失锁」​ 情况,故强一致性要求的业务不推荐使用 Reids,推荐使用 zk。
  2. Redis 集群各方法的响应时间均为最低。随着并发量和业务数量的提升其响应时间会有明显上升(公网集群影响因素偏大),但是极限 qps 可以达到最大且基本无异常

ZooKeeper:


  1. 使用 ZooKeeper 集群,锁原理是使用 ZooKeeper 的临时顺序节点,临时顺序节点的生命周期在 Client 与集群的 Session 结束时结束。因此如果某个 Client 节点存在网络问题,与 ZooKeeper 集群断开连接,Session 超时同样会导致锁被错误的释放(导致被其他线程错误地持有),因此 ZooKeeper 也无法保证完全一致。
  2. ZK 具有较好的稳定性;响应时间抖动很小,没有出现异常。但是随着并发量和业务数量的提升其响应时间和 qps 会明显下降。

总结:


  1. Zookeeper 每次进行锁操作前都要创建若干节点,完成后要释放节点,会浪费很多时间;
  2. 而 Redis 只是简单的数据操作,没有这个问题。

MySQL 如何做分布式锁?

在 Mysql 中创建一张表,设置一个 主键或者 UNIQUE KEY 这个 KEY 就是要锁的 KEY(商品 ID),所以同一个 KEY 在 mysql 表里只能插入一次了,这样对锁的竞争就交给了数据库,处理同一个 KEY 数据库保证了只有一个节点能插入成功,其他节点都会插入失败。

DB 分布式锁的实现:通过主键 id 或者 唯一索性 的唯一性进行加锁,说白了就是加锁的形式是向一张表中插入一条数据,该条数据的 id 就是一把分布式锁,例如当一次请求插入了一条 id 为 1 的数据,其他想要进行插入数据的并发请求必须等第一次请求执行完成后删除这条 id 为 1 的数据才能继续插入,实现了分布式锁的功能。

这样 lock 和 unlock 的思路就很简单了,伪代码:

def lock :
exec sql: insert into locked—table (xxx) values (xxx)
if result == true :
return true
else :
return false

def unlock :
exec sql: delete from lockedOrder where order_id='order_id'

计数器算法是什么?

计数器算法,是指在指定的时间周期内累加访问次数,达到设定的阈值时,触发限流策略。下一个时间周期进行访问时,访问次数清零。此算法无论在单机还是分布式环境下实现都非常简单,使用 redis 的 incr 原子自增性,再结合 key 的过期时间,即可轻松实现。

[外链图片存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-GbxRPZrs-1645802727685)(images/4-6 计数器算法-1621753094321.jpg)]

从上图我们来看,我们设置一分钟的阈值是 100,在 0:00 到 1:00 内请求数是 60,当到 1:00 时,请求数清零,从 0 开始计算,这时在 1:00 到 2:00 之间我们能处理的最大的请求为 100,超过 100 个的请求,系统都拒绝。

这个算法有一个临界问题,比如在上图中,在 0:00 到 1:00 内,只在 0:50 有 60 个请求,而在 1:00 到 2:00 之间,只在 1:10 有 60 个请求,虽然在两个一分钟的时间内,都没有超过 100 个请求,但是在 0:50 到 1:10 这 20 秒内,确有 120 个请求,虽然在每个周期内,都没超过阈值,但是在这 20 秒内,已经远远超过了我们原来设置的 1 分钟内 100 个请求的阈值。

滑动时间窗口算法是什么?

为了解决计数器算法的临界值的问题,发明了滑动窗口算法。在 TCP 网络通信协议中,就采用滑动时间窗口算法来解决网络拥堵问题。

滑动时间窗口是将计数器算法中的实际周期切分成多个小的时间窗口,分别在每个小的时间窗口中记录访问次数,然后根据时间将窗口往前滑动并删除过期的小时间窗口。最终只需要统计滑动窗口范围内的小时间窗口的总的请求数即可。

[外链图片存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-uYAzPswg-1645802727686)(images/4-7 滑动窗口算法-1621753118270.jpg)]

在上图中,假设我们设置一分钟的请求阈值是 100,我们将一分钟拆分成 4 个小时间窗口,这样,每个小的时间窗口只能处理 25 个请求,我们用虚线方框表示滑动时间窗口,当前窗口的大小是 2,也就是在窗口内最多能处理 50 个请求。随着时间的推移,滑动窗口也随着时间往前移动,比如上图开始时,窗口是 0:00 到 0:30 的这个范围,过了 15 秒后,窗口是 0:15 到 0:45 的这个范围,窗口中的请求重新清零,这样就很好的解决了计数器算法的临界值问题。

在滑动时间窗口算法中,我们的小窗口划分的越多,滑动窗口的滚动就越平滑,限流的统计就会越精确。

漏桶限流算法是什么?

漏桶算法的原理就像它的名字一样,我们维持一个漏斗,它有恒定的流出速度,不管水流流入的速度有多快,漏斗出水的速度始终保持不变,类似于消息中间件,不管消息的生产者请求量有多大,消息的处理能力取决于消费者。

漏桶的容量=漏桶的流出速度*可接受的等待时长。在这个容量范围内的请求可以排队等待系统的处理,超过这个容量的请求,才会被抛弃。

在漏桶限流算法中,存在下面几种情况:


  1. 当请求速度大于漏桶的流出速度时,也就是请求量大于当前服务所能处理的最大极限值时,触发限流策略。
  2. 请求速度小于或等于漏桶的流出速度时,也就是服务的处理能力大于或等于请求量时,正常执行。
    漏桶算法有一个缺点:当系统在短时间内有突发的大流量时,漏桶算法处理不了。

令牌桶限流算法是什么?

令牌桶算法,是增加一个大小固定的容器,也就是令牌桶,系统以恒定的速率向令牌桶中放入令牌,如果有客户端来请求,先需要从令牌桶中拿一个令牌,拿到令牌,才有资格访问系统,这时令牌桶中少一个令牌。当令牌桶满的时候,再向令牌桶生成令牌时,令牌会被抛弃。

在令牌桶算法中,存在以下几种情况:


  1. 请求速度大于令牌的生成速度:那么令牌桶中的令牌会被取完,后续再进来的请求,由于拿不到令牌,会被限流。
  2. 请求速度等于令牌的生成速度:那么此时系统处于平稳状态。
  3. 请求速度小于令牌的生成速度:那么此时系统的访问量远远低于系统的并发能力,请求可以被正常处理。
    令牌桶算法,由于有一个桶的存在,可以处理短时间大流量的场景。这是令牌桶和漏桶的一个区别。

你设计微服务时遵循什么原则?


  1. 单一职责原则:让每个服务能独立,有界限的工作,每个服务只关注自己的业务。做到高内聚。
  2. 服务自治原则:每个服务要能做到独立开发、独立测试、独立构建、独立部署,独立运行。与其他服务进行解耦。
  3. 轻量级通信原则:让每个服务之间的调用是轻量级,并且能够跨平台、跨语言。比如采用 RESTful 风格,利用消息队列进行通信等。
  4. 粒度进化原则:对每个服务的粒度把控,其实没有统一的标准,这个得结合我们解决的具体业务问题。不要过度设计。服务的粒度随着业务和用户的发展而发展。

总结一句话,软件是为业务服务的,好的系统不是设计出来的,而是进化出来的。

CAP 定理是什么?

CAP 定理,又叫布鲁尔定理。指的是:在一个分布式系统中,最多只能同时满足一致性(Consistency)、可用性(Availability)和分区容错性(Partition tolerance)这三项中的两项。


  • C:一致性(Consistency),数据在多个副本中保持一致,可以理解成两个用户访问两个系统 A 和 B,当 A 系统数据有变化时,及时同步给 B 系统,让两个用户看到的数据是一致的。
  • A:可用性(Availability),系统对外提供服务必须一直处于可用状态,在任何故障下,客户端都能在合理时间内获得服务端非错误的响应。
  • P:分区容错性(Partition tolerance),在分布式系统中遇到任何网络分区故障,系统仍然能对外提供服务。网络分区,可以这样理解,在分布式系统中,不同的节点分布在不同的子网络中,有可能子网络中只有一个节点,在所有网络正常的情况下,由于某些原因导致这些子节点之间的网络出现故障,导致整个节点环境被切分成了不同的独立区域,这就是网络分区。

    我们来详细分析一下 CAP,为什么只能满足两个。看下图所示:
    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-fw2WywjY-1645802727686)(images/10-4 CAP 演示-1617721637028.jpg)]

用户 1 和用户 2 分别访问系统 A 和系统 B,系统 A 和系统 B 通过网络进行同步数据。理想情况是:用户 1 访问系统 A 对数据进行修改,将 data1 改成了 data2,同时用户 2 访问系统 B,拿到的是 data2 数据。

但是实际中,由于分布式系统具有八大谬论:


  • 网络相当可靠
  • 延迟为零
  • 传输带宽是无限的
  • 网络相当安全
  • 拓扑结构不会改变
  • 必须要有一名管理员
  • 传输成本为零
  • 网络同质化

我们知道,只要有网络调用,网络总是不可靠的。我们来一一分析。


  1. 当网络发生故障时,系统 A 和系统 B 没法进行数据同步,也就是我们不满足 P,同时两个系统依然可以访问,那么此时其实相当于是单机系统,就不是分布式系统了,所以既然我们是分布式系统,P 必须满足。
  2. 当 P 满足时,如果用户 1 通过系统 A 对数据进行了修改将 data1 改成了 data2,也要让用户 2 通过系统 B 正确的拿到 data2,那么此时是满足 C,就必须等待网络将系统 A 和系统 B 的数据同步好,并且在同步期间,任何人不能访问系统 B(让系统不可用),否则数据就不是一致的。此时满足的是 CP。
  3. 当 P 满足时,如果用户 1 通过系统 A 对数据进行了修改将 data1 改成了 data2,也要让系统 B 能继续提供服务,那么此时,只能接受系统 A 没有将 data2 同步给系统 B(牺牲了一致性)。此时满足的就是 AP。

我们在前面学过的注册中心 Eureka 就是满足 的 AP,它并不保证 C。而 Zookeeper 是保证 CP,它不保证 A。在生产中,A 和 C 的选择,没有正确的答案,是取决于自己的业务的。比如 12306,是满足 CP,因为买票必须满足数据的一致性,不然一个座位多卖了,对铁路运输都是不可以接受的。

BASE 理论是什么?

由于 CAP 中一致性 C 和可用性 A 无法兼得,eBay 的架构师,提出了 BASE 理论,它是通过牺牲数据的强一致性,来获得可用性。它由于如下 3 种特征:


  • B​asically ​A​vailable(基本可用):分布式系统在出现不可预知故障的时候,允许损失部分可用性,保证核心功能的可用。
  • S​oft state(软状态):软状态也称为弱状态,和硬状态相对,是指允许系统中的数据存在中间状态,并认为该中间状态的存在不会影响系统的整体可用性,即允许系统在不同节点的数据副本之间进行数据同步的过程存在延时。、
  • E​ventually consistent(最终一致性):最终一致性强调的是系统中所有的数据副本,在经过一段时间的同步后,最终能够达到一个一致的状态。因此,最终一致性的本质是需要系统保证最终数据能够达到一致,而不需要实时保证系统数据的强一致性。

BASE 理论并没有要求数据的强一致性,而是允许数据在一定的时间段内是不一致的,但在最终某个状态会达到一致。在生产环境中,很多公司,会采用 BASE 理论来实现数据的一致,因为产品的可用性相比强一致性来说,更加重要。比如在电商平台中,当用户对一个订单发起支付时,往往会调用第三方支付平台,比如支付宝支付或者微信支付,调用第三方成功后,第三方并不能及时通知我方系统,在第三方没有通知我方系统的这段时间内,我们给用户的订单状态显示支付中,等到第三方回调之后,我们再将状态改成已支付。虽然订单状态在短期内存在不一致,但是用户却获得了更好的产品体验。

2PC 提交协议是什么?

二阶段提交(Two-phaseCommit)是指,在计算机网络以及数据库领域内,为了使基于分布式系统架构下的所有节点在进行事务提交时保持一致性而设计的一种算法(Algorithm)。通常,二阶段提交也被称为是一种协议(Protocol))。在分布式系统中,每个节点虽然可以知晓自己的操作时成功或者失败,却无法知道其他节点的操作的成功或失败。当一个事务跨越多个节点时,为了保持事务的 ACID 特性,需要引入一个作为协调者的组件来统一掌控所有节点(称作参与者)的操作结果并最终指示这些节点是否要把操作结果进行真正的提交(比如将更新后的数据写入磁盘等等)。因此,​二阶段提交的算法思路可以概括为:参与者将操作成败通知协调者,再由协调者根据所有参与者的反馈情报决定各参与者是否要提交操作还是中止操作。

所谓的两个阶段是指:第一阶段:**准备阶段(投票阶段)**和第二阶段:​提交阶段(执行阶段)​。

准备阶段

事务协调者(事务管理器)给每个参与者(资源管理器)发送 Prepare 消息,每个参与者要么直接返回失败(如权限验证失败),要么在本地执行事务,写本地的 redo 和 undo 日志,但不提交,到达一种“万事俱备,只欠东风”的状态。

可以进一步将准备阶段分为以下三个步骤:


1)协调者节点向所有参与者节点询问是否可以执行提交操作(vote),并开始等待各参与者节点的响应。

2)参与者节点执行询问发起为止的所有事务操作,并将 Undo 信息和 Redo 信息写入日志。(注意:若成功这里其实每个参与者已经执行了事务操作)

3)各参与者节点响应协调者节点发起的询问。如果参与者节点的事务操作实际执行成功,则它返回一个”同意”消息;如果参与者节点的事务操作实际执行失败,则它返回一个”中止”消息。


提交阶段


如果协调者收到了参与者的失败消息或者超时,直接给每个参与者发送回滚(Rollback)消息;否则,发送提交(Commit)消息;参与者根据协调者的指令执行提交或者回滚操作,释放所有事务处理过程中使用的锁资源。(注意:必须在最后阶段释放锁资源)

接下来分两种情况分别讨论提交阶段的过程。

当协调者节点从所有参与者节点获得的相应消息都为”同意”时:

​[外链图片存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-2OR6VKtp-1645802727687)(images/success.png)]​


1)协调者节点向所有参与者节点发出”正式提交(commit)”的请求。

2)参与者节点正式完成操作,并释放在整个事务期间内占用的资源。

3)参与者节点向协调者节点发送”完成”消息。

4)协调者节点受到所有参与者节点反馈的”完成”消息后,完成事务。


如果任一参与者节点在第一阶段返回的响应消息为”中止”,或者 协调者节点在第一阶段的询问超时之前无法获取所有参与者节点的响应消息时:

​[外链图片存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-kJOApJbT-1645802727688)(images/fail.png)]​


1)协调者节点向所有参与者节点发出”回滚操作(rollback)”的请求。

2)参与者节点利用之前写入的 Undo 信息执行回滚,并释放在整个事务期间内占用的资源。

3)参与者节点向协调者节点发送”回滚完成”消息。

4)协调者节点受到所有参与者节点反馈的”回滚完成”消息后,取消事务。


不管最后结果如何,第二阶段都会结束当前事务。

2PC 提交协议有什么缺点?


  1. 同步阻塞问题​。执行过程中,所有参与节点都是事务阻塞型的。当参与者占有公共资源时,其他第三方节点访问公共资源不得不处于阻塞状态。
  2. 单点故障​。由于协调者的重要性,一旦协调者发生故障。参与者会一直阻塞下去。尤其在第二阶段,协调者发生故障,那么所有的参与者还都处于锁定事务资源的状态中,而无法继续完成事务操作。(如果是协调者挂掉,可以重新选举一个协调者,但是无法解决因为协调者宕机导致的参与者处于阻塞状态的问题)
  3. 数据不一致​。在二阶段提交的阶段二中,当协调者向参与者发送 commit 请求之后,发生了局部网络异常或者在发送 commit 请求过程中协调者发生了故障,这回导致只有一部分参与者接受到了 commit 请求。而在这部分参与者接到 commit 请求之后就会执行 commit 操作。但是其他部分未接到 commit 请求的机器则无法执行事务提交。于是整个分布式系统便出现了数据部一致性的现象。
  4. 二阶段无法解决的问题:协调者再发出 commit 消息之后宕机,而唯一接收到这条消息的参与者同时也宕机了。那么即使协调者通过选举协议产生了新的协调者,这条事务的状态也是不确定的,没人知道事务是否被已经提交。

3PC 提交协议是什么?

CanCommit 阶段

3PC 的 CanCommit 阶段其实和 2PC 的准备阶段很像。协调者向参与者发送 commit 请求,参与者如果可以提交就返回 Yes 响应,否则返回 No 响应。


1.事务询问​ 协调者向参与者发送 CanCommit 请求。询问是否可以执行事务提交操作。然后开始等待参与者的响应。

2.响应反馈​ 参与者接到 CanCommit 请求之后,正常情况下,如果其自身认为可以顺利执行事务,则返回 Yes 响应,并进入预备状态。否则反馈 No


PreCommit 阶段

协调者根据参与者的反应情况来决定是否可以进行事务的 PreCommit 操作。根据响应情况,有以下两种可能。

假如协调者从所有的参与者获得的反馈都是 Yes 响应,那么就会执行事务的预执行。


1.发送预提交请求​ 协调者向参与者发送 PreCommit 请求,并进入 Prepared 阶段。

2.事务预提交​ 参与者接收到 PreCommit 请求后,会执行事务操作,并将 undo 和 redo 信息记录到事务日志中。

3.响应反馈​ 如果参与者成功的执行了事务操作,则返回 ACK 响应,同时开始等待最终指令。


假如有任何一个参与者向协调者发送了 No 响应,或者等待超时之后,协调者都没有接到参与者的响应,那么就执行事务的中断。


1.发送中断请求​ 协调者向所有参与者发送 abort 请求。

2.中断事务​ 参与者收到来自协调者的 abort 请求之后(或超时之后,仍未收到协调者的请求),执行事务的中断。


pre 阶段参与者没收到请求,rollback。

doCommit 阶段

该阶段进行真正的事务提交,也可以分为以下两种情况。

执行提交


1.发送提交请求​ 协调接收到参与者发送的 ACK 响应,那么他将从预提交状态进入到提交状态。并向所有参与者发送 doCommit 请求。

2.事务提交​ 参与者接收到 doCommit 请求之后,执行正式的事务提交。并在完成事务提交之后释放所有事务资源。

3.响应反馈​ 事务提交完之后,向协调者发送 Ack 响应。

4.完成事务​ 协调者接收到所有参与者的 ack 响应之后,完成事务。


中断事务​ 协调者没有接收到参与者发送的 ACK 响应(可能是接受者发送的不是 ACK 响应,也可能响应超时),那么就会执行中断事务。


1.发送中断请求​ 协调者向所有参与者发送 abort 请求

2.事务回滚​ 参与者接收到 abort 请求之后,利用其在阶段二记录的 undo 信息来执行事务的回滚操作,并在完成回滚之后释放所有的事务资源。

3.反馈结果​ 参与者完成事务回滚之后,向协调者发送 ACK 消息

4.中断事务​ 协调者接收到参与者反馈的 ACK 消息之后,执行事务的中断。


2PC 和 3PC 的区别是什么?

1、引入超时机制。同时在协调者和参与者中都引入超时机制。

2、三阶段在 2PC 的第一阶段和第二阶段中插入一个准备阶段。保证了在最后提交阶段之前各参与节点的状态是一致的。

  • TCC 解决方案是什么?​ TCC(Try-Confirm-Cancel)是一种常用的分布式事务解决方案,它将一个事务拆分成三个步骤:
  • [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-kMnCpHGk-1645802727688)(images/image-20210521230854476-1621753201509.png)]


T(Try):业务检查阶段,这阶段主要进行业务校验和检查或者资源预留;也可能是直接进行业务操作。



C(Confirm):业务确认阶段,这阶段对 Try 阶段校验过的业务或者预留的资源进行确认。



C(Cancel):业务回滚阶段,这阶段和上面的 C(Confirm)是互斥的,用于释放 Try 阶段预留的资源或者业务。



[外链图片存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-PaksbrbF-1645802727689)(images/image-20210521230904203-1621753201509.png)]

[外链图片存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-TZlfc1Q7-1645802727690)(images/image-20210521230912365-1621753201509.png)]

[外链图片存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-XrMoQTyj-1645802727691)(images/image-20210521230919795-1621753201509.png)]

TCC 空回滚是解决什么问题的?

​ 在没有调用 TCC 资源 Try 方法的情况下,调用了二阶段的 Cancel 方法。比如当 Try 请求由于网络延迟或故障等原因,没有执行,结果返回了异常,那么此时 Cancel 就不能正常执行,因为 Try 没有对数据进行修改,如果 Cancel 进行了对数据的修改,那就会导致数据不一致。

​ 解决思路是关键就是要识别出这个空回滚。思路很简单就是需要知道 Try 阶段是否执行,如果执行了,那就是正常回滚;如果没执行,那就是空回滚。建议 TM 在发起全局事务时生成全局事务记录,全局事务 ID 贯穿整个分布式事务调用链条。再额外增加一张​分支事务记录表​,其中有全局事务 ID 和分支事务 ID,第一阶段 Try 方法里会插入一条记录,表示 Try 阶段执行了。Cancel 接口里读取该记录,如果该记录存在,则正常回滚;如果该记录不存在,则是空回滚。

如何解决 TCC 幂等问题?

为了保证 TCC 二阶段提交重试机制不会引发数据不一致,要求 TCC 的二阶段 Confirm 和 Cancel 接口保证幂等,这样不会重复使用或者释放资源。如果幂等控制没有做好,很有可能导致数据不一致等严重问题。

解决思路在上述 ​分支事务记录​中增加执行状态,每次执行前都查询该状态。

分布式锁。

如何解决 TCC 中悬挂问题?

悬挂就是对于一个分布式事务,其二阶段 Cancel 接口比 Try 接口先执行。

出现原因是在调用分支事务 Try 时,由于网络发生拥堵,造成了超时,TM 就会通知 RM 回滚该分布式事务,可能回滚完成后,Try 请求才到达参与者真正执行,而一个 Try 方法预留的业务资源,只有该分布式事务才能使用,该分布式事务第一阶段预留的业务资源就再也没有人能够处理了,对于这种情况,我们就称为悬挂,即业务资源预留后无法继续处理。

解决思路是如果二阶段执行完成,那一阶段就不能再继续执行。在执行一阶段事务时判断在该全局事务下,判断​分支事务记录表​中是否已经有二阶段事务记录,如果有则不执行 Try。

可靠消息服务方案是什么?

可靠消息最终一致性方案指的是:当事务的发起方(事务参与者,消息发送者)执行完本地事务后,同时发出一条消息,事务参与方(事务参与者,消息的消费者)一定能够接受消息并可以成功处理自己的事务。

这里面强调两点:


  1. 可靠消息:发起方一定得把消息传递到消费者。
  2. 最终一致性:最终发起方的业务处理和消费方的业务处理得完成,达成最终一致。

[外链图片存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-XTk72V7Z-1645802727692)(images/image-20210522125830646.png)]

最大努力通知方案的关键是什么?


  1. 有一定的消息重复通知机制。因为接收通知方(上图中的我方支付系统)可能没有接收到通知,此时要有一定的机制对消息重复通知。
  2. 消息校对机制。如果尽最大努力也没有通知到接收方,或者接收方消费消息后要再次消费,此时可由接收方主动向通知方查询消息信息来满足需求。

什么是分布式系统中的幂等?

幂等(idempotent、idempotence)是一个数学与计算机学概念,常见于抽象代数中。

在编程中,一个幂等操作的特点是其任意多次执行所产生的影响均与一次执行的影响相同。幂等函数,或幂等方法,是指可以使用相同参数重复执行,并能获得相同结果的函数。这些函数不会影响系统状态,也不用担心重复执行会对系统造成改变。

例如,“getUsername()和 setTrue()”函数就是一个幂等函数. 更复杂的操作幂等保证是利用唯一交易号(流水号)实现. 我的理解:幂等就是一个操作,不论执行多少次,产生的效果和返回的结果都是一样的。

操作:查询,set 固定值。逻辑删除。set 固定值。

流程:分布式系统中,网络调用,重试机制。

幂等有哪些技术解决方案?

1.查询操作

查询一次和查询多次,在数据不变的情况下,查询结果是一样的。select 是天然的幂等操作;

2.删除操作

删除操作也是幂等的,删除一次和多次删除都是把数据删除。(注意可能返回结果不一样,删除的数据不存在,返回 0,删除的数据多条,返回结果多个。

3.唯一索引

防止新增脏数据。比如:支付宝的资金账户,支付宝也有用户账户,每个用户只能有一个资金账户,怎么防止给用户创建多个资金账户,那么给资金账户表中的用户 ID 加唯一索引,所以一个用户新增成功一个资金账户记录。要点:唯一索引或唯一组合索引来防止新增数据存在脏数据(当表存在唯一索引,并发时新增报错时,再查询一次就可以了,数据应该已经存在了,返回结果即可。

4.token 机制

防止页面重复提交。

**业务要求:**页面的数据只能被点击提交一次;

**发生原因:**由于重复点击或者网络重发,或者 nginx 重发等情况会导致数据被重复提交;

**解决办法:**集群环境采用 token 加 redis(redis 单线程的,处理需要排队);单 JVM 环境:采用 token 加 redis 或 token 加 jvm 锁。

处理流程:


  1. 数据提交前要向服务的申请 token,token 放到 redis 或 jvm 内存,token 有效时间;
  2. 提交后后台校验 token,同时删除 token,生成新的 token 返回。

**token 特点:**要申请,一次有效性,可以限流。

注意:redis 要用删除操作来判断 token,删除成功代表 token 校验通过。

  1. traceId
    操作时唯一的。

对外提供的 API 如何保证幂等?

举例说明: 银联提供的付款接口:需要接入商户提交付款请求时附带:source 来源,seq 序列号。

source+seq 在数据库里面做唯一索引,防止多次付款(并发时,只能处理一个请求) 。重点:对外提供接口为了支持幂等调用,接口有两个字段必须传,一个是来源 source,一个是来源方序列号 seq,这个两个字段在提供方系统里面做联合唯一索引,这样当第三方调用时,先在本方系统里面查询一下,是否已经处理过,返回相应处理结果;没有处理过,进行相应处理,返回结果。

注意,为了幂等友好,一定要先查询一下,是否处理过该笔业务,不查询直接插入业务系统,会报错,但实际已经处理。

微信公众号

【面试题】Java 高级工程师面试刷题100题(三)_面试