首页 文章详情

2w 字长文爆肝 JVM 经典面试题!太顶了!

老周聊架构 | 517 2021-04-12 22:00 0 0 0
UniSMS (合一短信)

点击上方老周聊架构关注我


1、Java 类加载过程

Java 类加载需要经历以下 7 个过程:


1.1 加载

加载是类加载的第一个过程,在这个阶段,将完成以下三件事情:

  • 通过一个类的全限定名获取该类的二进制流。

  • 将该二进制流中的静态存储结构转化为方法去运行时数据结构。

  • 在内存中生成该类的 Class 对象,作为该类的数据访问入口。

1.2 验证

验证的目的是为了确保 Class 文件的字节流中的信息不会危害到虚拟机。

在该阶段主要完成以下四种验证:

  • 文件格式验证:验证字节流是否符合 Class 文件的规范,如主次版本号是否在当前虚拟机范围内,常量池中的常量是否有不被支持的类型。

  • 元数据验证:对字节码描述的信息进行语义分析,如这个类是否有父类,是否集成了不被继承的类等。

  • 字节码验证:是整个验证过程中最复杂的一个阶段,通过验证数据流和控制流的分析,确定程序语义是否正确,主要针对方法体的验证。如:方法中的类型转换是否正确,跳转指令是否正确等。

  • 符号引用验证:这个动作在后面的解析过程中发生,主要是为了确保解析动作能正确执行。

1.3 准备

准备阶段是为类的静态变量分配内存并将其初始化为默认值,这些内存都将在方法区中进行分配。准备阶段不分配类中的实例变量的内存,实例变量将会在对象实例化时随着对象一起分配在 Java 堆中。

public static String value = "公众号【老周聊架构】"// 在准备阶段 value 初始值为 null 。在初始化阶段才会变为 "公众号【老周聊架构】" 。

1.4 解析

该阶段主要完成符号引用到直接引用的转换动作。解析动作并不一定在初始化动作完成之前,也有可能在初始化之后。

1.5 初始化

初始化是类加载的最后一步,前面的类加载过程,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导和控制。到了初始化阶段,才真正开始执行类中定义的 Java 程序代码。

1.5.1 类构造器

初始化阶段是执行类构造器<client>方法的过程。<client>方法是由编译器自动收集类中的类变量的赋值操作和静态语句块中的语句合并而成的。虚拟机会保证子<client>方法执行之前,父类的<client>方法已经执行完毕,如果一个类中没有对静态变量赋值也没有静态语句块,那么编译器可以不为这个类生成<client>()方法。

注意以下几种情况不会执行类初始化:

  • 通过子类引用父类的静态字段,只会触发父类的初始化,而不会触发子类的初始化。

  • 定义对象数组,不会触发该类的初始化。

  • 常量在编译期间会存入调用类的常量池中,本质上并没有直接引用定义常量的类,不会触
    发定义常量所在的类。

  • 通过类名获取 Class 对象,不会触发类的初始化。

  • 通过 Class.forName 加载指定类时,如果指定参数 initialize 为 false 时,也不会触发类初
    始化,其实这个参数是告诉虚拟机,是否要对类进行初始化。

  • 通过 ClassLoader 默认的 loadClass 方法,也不会触发初始化动作。

1.6 使用

1.7 卸载

2、描述一下 JVM 加载 Class 文件的原理机制

Java 语言是一种具有动态性的解释型语言,类(Class)只有被 载到 JVM 后才能运行。当运行指定程序时,JVM 会将编译生成 的 .class 文件按照需求和一定的规则加载到内存中,并组织成为一个完整的 Java 应用程序。这个加载过程是由类加载器完成,具体来说,就是由 ClassLoader 和它的子类来实现的。类加载器本身也是一个类,其实就是把类文件从硬盘读取到内存中。

类的加载方式分为隐式加载和显示加载。隐式加载指的是程序在使 用 new 等方式创建对象时,会隐式地调用类的加载器把对应的类加载到 JVM 中。显示加载指的是通过直接调用 class.forName() 方法来把所需的类加载到 JVM 中。

任何一个工程项目都是由许多类组成的,当程序启动时,只把需要的类加载到 JVM 中,其他类只有被使用到的时候才会被加载,采用这种方法一方面可以加快加载速度,另一方面可以节约程序运行时对内存的开销。此外,在 Java 语言中,每个类或接口都对应一个 .class 文件,这些文件可以被看成是一个个可以被动态加载的单元,因此当只有部分类被修改时,只需要重新编译变化的类即可, 而不需要重新编译所有文件,因此加快了编译速度。

在 Java 语言中,类的加载是动态的,它并不会一次性将所有类全部加载后再运行,而是保证程序运行的基础类(例如基类)完全加载到 JVM 中,至于其他类,则在需要的时候才加载。

类加载的主要步骤:

  • 装载。根据查找路径找到相应的 class 文件,然后导入。

  • 链接。链接又可分为 3 个小步:
    检查,检查待加载的 class 文件的正确性。
    准备,给类中的静态变量分配存储空间。
    解析,将符号引用转换为直接引用(这一步可选)。

  • 初始化。对静态变量和静态代码块执行初始化工作。

3、什么是类加载器,类加载器有哪些?

实现通过类的全限定名获取该类的二进制字节流的代码块叫做类加载器。

主要有以下四种类加载器:


  • 启动类加载器(Bootstrap ClassLoader):用来加载 Java 核心类库,无法被 Java 程序直接引用。

  • 扩展类加载器(extensions class loader):它用来加载 Java 的扩展库。Java 虚拟机的实现会提供一个扩展库目录。该类加载器在此目录里面查找并加载 Java 类。

  • 系统类加载器(system class loader):它根据 Java 应用的类路径(CLASSPATH)来加载 Java 类。一般来说,Java 应用的类都是由它来完成加载的。可以通过 ClassLoader.getSystemClassLoader() 来获取它。

  • 用户自定义类加载器,通过继承 java.lang.ClassLoader 类的方式实现。

3.1 双亲委派机制

当一个类收到了类加载请求,他首先不会尝试自己去加载这个类,而是把这个请求委派给父
类去完成,每一个层次类加载器都是如此,因此所有的加载请求都应该传送到启动类加载器中,只有当父类加载器反馈自己无法完成这个请求的时候(在它的加载路径下没有找到所需加载的 Class),子类加载器才会尝试自己去加载。

采用双亲委派的一个好处是比如加载位于 rt.jar 包中的类 java.lang.Object,不管是哪个加载
器加载这个类,最终都是委托给顶层的启动类加载器进行加载,这样就保证了使用不同的类加载。


4、谈谈你对JVM的理解

JVM 是可运行 Java 代码的假想计算机 ,包括一套字节码指令集、一组寄存器、一个栈、 一个垃圾回收,堆和一个存储方法域。JVM 是运行在操作系统之上的,它与硬件没有直接的交互。


5、JVM 内存模型

JVM 内存区域主要分为:

  • 线程共享区域:【方法区、JAVA 堆】、直接内存。

  • 线程私有区域:【程序计数器、虚拟机栈、本地方法区】

线程共享区域随虚拟机的启动/关闭而创建/销毁。

线程私有数据区域生命周期与线程相同,依赖用户线程的启动/结束而创建/销毁(在 Hotspot VM 内,每个线程都与操作系统的本地线程直接映射,因此这部分内存区域的存活跟随本地线程的生死对应)。

直接内存并不是 JVM 运行时数据区的一部分,但也会被频繁的使用。在 JDK 1.4 引入的 NIO 提供了基于 Channel 与 Buffer 的 IO 方式,它可以使用 Native 函数库直接分配堆外内存,然后使用 DirectByteBuffer 对象作为这块内存的引用进行操作,这样就避免了在 Java 堆和 Native 堆中来回复制数据,因此在一些场景中可以显著提高性能。


5.1 程序计数器(线程私有)

一块较小的内存空间,是当前线程所执行的字节码的行号指示器,每条线程都要有一个独立的程序计数器,这类内存也称为“线程私有”的内存。

正在执行 java 方法的话,计数器记录的是虚拟机字节码指令的地址(当前指令的地址)。如果还是 Native 方法,则为空。

这个内存区域是唯一一个在虚拟机中没有规定任何 OutOfMemoryError 情况的区域。

5.2 虚拟机栈(线程私有)

是描述 java 方法执行的内存模型,每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。

栈帧(Frame)是用来存储数据和部分过程结果的数据结构,同时也被用来处理动态链接 (Dynamic Linking)、 方法返回值和异常分派(Dispatch Exception)。栈帧随着方法调用而创建,随着方法结束而销毁——无论方法是正常完成还是异常完成(抛出了在方法内未被捕获的异常)都算作方法结束。

5.3 本地方法区(线程私有)

本地方法区和 Java Stack 作用类似,区别是虚拟机栈为执行 Java 方法服务,而本地方法栈则为 Native 方法服务,如果一个 VM 实现使用 C-linkage 模型来支持 Native 调用,那么该栈将会是一个 C 栈,但 HotSpot VM 直接就把本地方法栈和虚拟机栈合二为一。

5.4 堆(Heap)-运行时数据区(线程共享)

是被线程共享的一块内存区域,创建的对象和数组都保存在 Java 堆内存中,也是垃圾收集器进行垃圾收集的最重要的内存区域。由于现代 VM 采用分代收集算法。因此 Java 堆从 GC 的角度还可以细分为: 新生代(Eden 区、From Survivor 区和 To Survivor 区)和老年代。

5.5 方法区/永久代(线程共享)

即我们常说的永久代(Permanent Generation),用于存储被 JVM 加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。HotSpot VM 把 GC 分代收集扩展至方法区,即使用 Java 堆的永久代来实现方法区,这样 HotSpot 的垃圾收集器就可以像管理 Java 堆一样管理这部分内存,而不必为方法区开发专门的内存管理器(永久代的内存回收的主要目标是针对常量池的回收和类型的卸载,因此收益一般很小)。

运行时常量池(Runtime Constant Pool)是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述等信息外,还有一项信息是常量池(Constant Pool Table),用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。Java 虚拟机对 Class 文件的每一部分(自然也包括常量池)的格式都有严格的规定,每一个字节用于存储哪种数据都必须符合规范上的要求,这样才会被虚拟机认可、装载和执行。

这里提一下 JDK 8 永久代被元空间(Metaspace)替换。

6、JVM 运行时内存

Java 堆从 GC 的角度还可以细分为: 新生代(Eden 区、Survivor From 区和 Survivor To 区)和老年代。



6.1 新生代

是用来存放新生的对象。一般占据堆的 1/3 空间。由于频繁创建对象,所以新生代会频繁触发 MinorGC 进行垃圾回收。新生代又分为 Eden 区、Survivor From、Survivor To 三个区。

6.1.1 Eden 区

Java 新对象的出生地(如果新创建的对象占用内存很大,则直接分配到老年代)。当 Eden 区内存不够的时候就会触发 MinorGC,对新生代区进行一次垃圾回收。

6.1.2 Survivor From

上一次 GC 的幸存者,作为这一次 GC 的被扫描者。

6.1.3 Survivor To

保留了一次 MinorGC 过程中的幸存者。

6.1.4 MinorGC 的过程(复制->清空->互换)

  • eden、Survivor From 复制到 Survivor To,年龄+1
    首先,把 Eden 和 SurvivorFrom 区域中存活的对象复制到 Survivor To 区域(如果有对象的年龄以及达到了老年的标准,则赋值到老年代区),同时把这些对象的年龄+1(如果 Survivor To 不够位置了就放到老年区);

  • 清空 eden、Survivor From
    然后,清空 Eden 和 Survivor From 中的对象;

  • Survivor To 和 Survivor From 互换
    最后,Survivor To 和 Survivor From 互换,原 Survivor To 成为下一次 GC 时的 Survivor From
    区。

6.2 老年代

主要存放应用程序中生命周期长的内存对象。

老年代的对象比较稳定,所以 MajorGC 不会频繁执行。在进行 MajorGC 前一般都先进行了一次 MinorGC,使得有新生代的对象晋身入老年代,导致空间不够用时才触发。当无法找到足够大的连续空间分配给新创建的较大对象时也会提前触发一次 MajorGC 进行垃圾回收腾出空间。

MajorGC 采用标记清除算法:首先扫描一次所有老年代,标记出存活的对象,然后回收没有标记的对象。MajorGC 的耗时比较长,因为要扫描再回收。MajorGC 会产生内存碎片,为了减少内存损耗,我们一般需要进行合并或者标记出来方便下次直接分配。当老年代也满了装不下的时候,就会抛出 OOM(Out of Memory)异常。

6.3 永久代

指内存的永久保存区域,主要存放 Class 和 Meta(元数据)的信息,Class 在被加载的时候被放入永久区域,它和存放实例的区域不同,GC 不会在主程序运行期对永久区域进行清理。所以这也导致了永久代的区域会随着加载的 Class 的增多而胀满,最终抛出 OOM 异常。

6.3.1 JDK 8 与元数据

在 JDK 8 中,永久代已经被移除,被一个称为“元数据区”(元空间)的区域所取代。元空间的本质和永久代类似,元空间与永久代之间最大的区别在:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制。类的元数据放入 native memory,字符串池和类的静态变量放入 java 堆中,这样可以加载多少类的元数据就不再由 MaxPermSize 控制,而由系统的实际可用空间来控制。

这里老周要提两点注意的地方:

  • 如果你们的应用是 JDK 8 以上的话,PermSize 以及 MaxPermSize 参数是不生效的,要改成 MetaspaceSize 以及 MaxMetaspaceSize。

  • 应用是 JDK 8 以上的话,MetaspaceSize 以及 MaxMetaspaceSize 一定要设置,因为不设置的话,32 位的 JVM MetaspaceSize 以及 MaxMetaspaceSize 默认是 16M、64M,64 位的 JVM MetaspaceSize 以及 MaxMetaspaceSize 默认是 21M、82M。因为老周线上遇到过没有设置而 JVM 采用的默认值,导致项目部署阶段多次 FullGC 的问题。

7、垃圾回收与算法

7.1 如何确定垃圾

7.1.1 引用计数法

在 Java 中,引用和对象是有关联的。如果要操作对象则必须用引用进行。因此,很显然一个简单 的办法是通过引用计数来判断一个对象是否可以回收。简单说,即一个对象如果没有任何与之关联的引用,即他们的引用计数都不为 0,则说明对象不太可能再被用到,那么这个对象就是可回收对象。

7.1.2 可达性分析

为了解决引用计数法的循环引用问题,Java 使用了可达性分析的方法。通过一系列的“GC roots”对象作为起点搜索。如果在“GC roots”和一个对象之间没有可达路径,则称该对象是不可达的。要注意的是,不可达对象不等价于可回收对象,不可达对象变为可回收对象至少要经过两次标记过程。两次标记后仍然是可回收对象,则将面临回收。

7.2 垃圾回收算法

7.2.1 标记清除算法(Mark-Sweep)

最基础的垃圾回收算法,分为两个阶段,标记和清除。标记阶段标记出所有需要回收的对象,清除阶段回收被标记的对象所占用的空间。



从图中我们就可以发现,该算法最大的问题是内存碎片化严重,后续可能发生大对象不能找到可利用空间的问题。

7.2.2 复制算法(copying)

为了解决 Mark-Sweep 算法内存碎片化的缺陷而被提出的算法。按内存容量将内存划分为等大小的两块。每次只使用其中一块,当这一块内存满后将尚存活的对象复制到另一块上去,把已使用的内存清掉。


这种算法虽然实现简单,内存效率高,不易产生碎片,但是最大的问题是可用内存被压缩到了原本的一半。且存活对象增多的话,Copying 算法的效率会大大降低。

7.2.3 标记整理算法(Mark-Compact)

结合了以上两个算法,为了避免缺陷而提出。标记阶段和 Mark-Sweep 算法相同,标记后不是清理对象,而是将存活对象移向内存的一端。然后清除端边界外的对象。



7.2.4 分代收集算法

分代收集法是目前大部分 JVM 所采用的方法,其核心思想是根据对象存活的不同生命周期将内存划分为不同的域,一般情况下将 GC 堆划分为老年代(Tenured/Old Generation)和新生代(Young Generation)。老年代的特点是每次垃圾回收时只有少量对象需要被回收,新生代的特点是每次垃圾回收时都有大量垃圾需要被回收,因此可以根据不同区域选择不同的算法。

7.2.4.1 新生代与复制算法

目前大部分 JVM 的 GC 对于新生代都采取 Copying 算法,因为新生代中每次垃圾回收都要 回收大部分对象,即要复制的操作比较少,但通常并不是按照 1:1 来划分新生代。一般将新生代划分为一块较大的 Eden 空间和两个较小的 Survivor 空间(From Space, To Space),每次使用 Eden 空间和其中的一块 Survivor 空间,当进行回收时,将该两块空间中还存活的对象复制到另一块 Survivor 空间中。


7.2.4.2 老年代与标记整理算法

而老年代因为每次只回收少量对象,因而采用 Mark-Compact 算法。

  • JAVA 虚拟机提到过的处于方法区的永久代(Permanet Generation),它用来存储 class 类,
    常量,方法描述等。对永久代的回收主要包括废弃常量和无用的类。

  • 对象的内存分配主要在新生代的 Eden Space 和 Survivor Space 的 From Space(Survivor 目
    前存放对象的那一块),少数情况会直接分配到老年代。

  • 当新生代的 Eden Space 和 From Space 空间不足时就会发生一次 GC,进行 GC 后,Eden Space 和 From Space 区的存活对象会被挪到 To Space,然后将 Eden Space 和 From Space 进行清理。

  • 如果 To Space 无法足够存储某个对象,则将这个对象存储到老生代。

  • 在进行 GC 后,使用的便是 Eden Space 和 To Space 了,如此反复循环。

  • 当对象在 Survivor 区躲过一次 GC 后,其年龄就会+1。默认情况下年龄到达 15 的对象会被移到老年代中。

8、JAVA 四中引用类型

8.1 强引用

在 Java 中最常见的就是强引用,把一个对象赋给一个引用变量,这个引用变量就是一个强引用。当一个对象被强引用变量引用时,它处于可达状态,它是不可能被垃圾回收机制回收的,即使该对象以后永远都不会被用到 JVM 也不会回收。因此强引用是造成 Java 内存泄漏的主要原因之一。

8.2 软引用

软引用需要用 SoftReference 类来实现,对于只有软引用的对象来说,当系统内存足够时它
不会被回收,当系统内存空间不足时它会被回收。软引用通常用在对内存敏感的程序中。

8.3 弱引用

弱引用需要用 WeakReference 类来实现,它比软引用的生存期更短,对于只有弱引用的对象来说,只要垃圾回收机制一运行,不管 JVM 的内存空间是否足够,总会回收该对象占用的内存。

8.4 虚引用

虚引用需要 PhantomReference 类来实现,它不能单独使用,必须和引用队列联合使用。虚 引用的主要作用是跟踪对象被垃圾回收的状态。

9、GC 垃圾收集器

Java 堆内存被划分为新生代和老年代两部分,新生代主要使用复制和标记-清除垃圾回收,
老年代主要使用标记-整理垃圾回收算法,因此 Java 虚拟中针对新生代和年老代分别提供了多种不
同的垃圾收集器,Sun HotSpot 虚拟机的垃圾收集器如下:


9.1 Serial 垃圾收集器(单线程、复制算法)

Serial(连续)是最基本垃圾收集器,使用复制算法,曾经是 JDK1.3.1 之前新生代唯一的垃圾收集器。Serial 是一个单线程的收集器,它不但只会使用一个 CPU 或一条线程去完成垃圾收集工作,并且在进行垃圾收集的同时,必须暂停其他所有的工作线程,直到垃圾收集结束。

Serial 垃圾收集器虽然在收集垃圾过程中需要暂停所有其他的工作线程,但是它简单高效,对于限定单个 CPU 环境来说,没有线程交互的开销,可以获得最高的单线程垃圾收集效率,因此 Serial 垃圾收集器依然是 Java 虚拟机运行在 Client 模式下默认的新生代垃圾收集器。

9.2 ParNew 垃圾收集器(Serial+多线程)

ParNew(平行的) 垃圾收集器其实是 Serial 收集器的多线程版本,也使用复制算法,除了使用多线程进行垃圾收集之外,其余的行为和 Serial 收集器完全一样,ParNew 垃圾收集器在垃圾收集过程中同样也要暂停所有其他的工作线程。

ParNew 收集器默认开启和 CPU 数目相同的线程数,可以通过 -XX:ParallelGCThreads 参数来限制垃圾收集器的线程数。

ParNew 虽然是除了多线程外和 Serial 收集器几乎完全一样,但是 ParNew 垃圾收集器是很多 Java 虚拟机运行在 Server 模式下新生代的默认垃圾收集器。

9.3 Parallel Scavenge 收集器(多线程复制算法、高效)

Parallel Scavenge 收集器也是一个新生代垃圾收集器,同样使用复制算法,也是一个多线程的垃圾收集器,它重点关注的是程序达到一个可控制的吞吐量(Thoughput,CPU 用于运行用户代码的时间/CPU 总消耗时间,即吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间)), 高吞吐量可以最高效率地利用 CPU 时间,尽快地完成程序的运算任务,主要适用于在后台运算而不需要太多交互的任务。自适应调节策略也是 ParallelScavenge 收集器与 ParNew 收集器的一个重要区别。

9.4 Serial Old收集器(单线程标记整理算法)

Serial Old 是 Serial 垃圾收集器年老代版本,它同样是个单线程的收集器,使用标记-整理算法, 这个收集器也主要是运行在 Client 默认的 Java 虚拟机默认的年老代垃圾收集器。
在 Server 模式下,主要有两个用途:

  • 在 JDK1.5 之前版本中与新生代的 Parallel Scavenge 收集器搭配使用。

  • 作为年老代中使用 CMS 收集器的后备垃圾收集方案。

9.5 Parallel Old收集器(多线程标记整理算法)

Parallel Old 收集器是 Parallel Scavenge 的年老代版本,使用多线程的标记-整理算法,在 JDK1.6 才开始提供。

在 JDK1.6 之前,新生代使用 Parallel Scavenge 收集器只能搭配年老代的 Serial Old 收集器,只能保证新生代的吞吐量优先,无法保证整体的吞吐量,Parallel Old 正是为了在年老代同样提供吞吐量优先的垃圾收集器,如果系统对吞吐量要求比较高,可以优先考虑新生代 Parallel Scavenge 和年老代 Parallel Old 收集器的搭配策略。

9.6 CMS收集器(多线程标记清除算法)

Concurrent Mark Sweep(CMS)收集器是一种年老代垃圾收集器,其最主要目标是获取最短垃圾回收停顿时间,和其他年老代使用标记-整理算法不同,它使用多线程的标记-清除算法。最短的垃圾收集停顿时间可以为交互比较高的程序提高用户体验。

CMS 工作机制相比其他的垃圾收集器来说更复杂,整个过程分为以下 4 个阶段:

9.6.1 初始标记

只是标记一下 GC Roots 能直接关联的对象,速度很快,仍然需要暂停所有的工作线程。

9.6.2 并发标记

进行 GC Roots 跟踪的过程,和用户线程一起工作,不需要暂停工作线程。

9.6.3 重新标记

为了修正在并发标记期间,因用户程序继续运行而导致标记产生变动的那一部分对象的标记 记录,仍然需要暂停所有的工作线程。

9.6.4 并发清除

清除 GC Roots 不可达对象,和用户线程一起工作,不需要暂停工作线程。由于耗时最长的并发标记和并发清除过程中,垃圾收集线程可以和用户现在一起并发工作,所以总体上来看 CMS 收集器的内存回收和用户线程是一起并发地执行。

9.7 G1收集器

Garbage First 垃圾收集器是目前垃圾收集器理论发展的最前沿成果,相比与 CMS 收集器,G1 收集器两个最突出的改进是:

  • 基于标记-整理算法,不产生内存碎片。

  • 可以非常精确控制停顿时间,在不牺牲吞吐量前提下,实现低停顿垃圾回收。

G1 收集器避免全区域垃圾收集,它把堆内存划分为大小固定的几个独立区域,并且跟踪这些区域的垃圾收集进度,同时在后台维护一个优先级列表,每次根据所允许的收集时间,优先回收垃圾最多的区域。区域划分和优先级区域回收机制,确保 G1 收集器可以在有限时间获得最高的垃圾收集效率。

10、简述 Java 垃圾回收机制

在 Java 中,程序员是不需要显式的去释放一个对象的内存的,而是由虚拟机自行执行。在 JVM 中,有一个垃圾回收线程,它是低优先级的,在正常情况下是不会执行的,只有在虚拟机空闲或者当前堆内存不足时,才会触发执行,扫面那些没有被任何引用的对象,并将它们添加到要回收的集合中,进行回收。

11、如何判断一个对象是否存活?(或者 GC 对象的判定方法)

其实第 7 点回答了哈,这里再详细说一下。

判断一个对象是否存活有两种方法:

11.1 引用计数法

所谓引用计数法就是给每一个对象设置一个引用计数器,每当有一个地方引用这个对象时,就将计数器加一,引用失效时,计数器就减一。当一个对象的引用计数器为零时,说明此对象没有被引用,也就是“死对象”,将会被垃圾回收。

引用计数法有一个缺陷就是无法解决循环引用问题,也就是说当对象 A 引用对 象 B,对象 B 又引用者对象 A,那么此时 A、B 对象的引用计数器都不为零, 也就造成无法完成垃圾回收,所以主流的虚拟机都没有采用这种算法。

11.2 可达性算法(引用链法)

该算法的思想是:从一个被称为 GC Roots 的对象开始向下搜索,如果一个对象到 GC Roots 没有任何引用链相连时,则说明此对象不可用。

在 Java 中可以作为 GC Roots 的对象有以下几种:

  • 虚拟机栈中引用的对象

  • 方法区类静态属性引用的对象

  • 方法区常量池引用的对象

  • 本地方法栈 JNI 引用的对象

虽然这些算法可以判定一个对象是否能被回收,但是当满足上述条件时,一个对象不一定会被回收。当一个对象不可达 GC Root 时,这个对象并不会立马被回收,而是处于一个死缓的阶段,若要被真正的回收需要经历两次标记。

如果对象在可达性分析中没有与 GC Root 的引用链,那么此时就会被第一次标记并且进行一次筛选,筛选的条件是是否有必要执行 finalize() 方法。当对象没有覆盖 finalize() 方法或者已被虚拟机调用过,那么就认为是没必要的。如果该对象有必要执行 finalize() 方法,那么这个对象将会放在一个称为 F-Queue 的对队列中,虚拟机会触发一个 Finalize() 线程去执行,此线程是低优先级的, 并且虚拟机不会承诺一直等待它运行完,这是因为如果 finalize() 执行缓慢或者发生了死锁,那么就会造成 F-Queue 队列一直等待,造成了内存回收系统的崩溃。GC 对处于 F-Queue 中的对象进行第二次被标记,这时,该对象将被移除 ”即将回收” 集合,等待回收。

12、垃圾回收的优点和原理

Java 语言中一个显著的特点就是引入了垃圾回收机制,使 C++ 程序员最头疼的内存管理的问题迎刃而解,它使得 Java 程序员在编写程序的时候不再需要考虑内存管理。由于有个垃圾回收机制,Java 中的对象不再有“作用域”的概念,只有对象的引用才有"作用域"。垃圾回收可以有效的防止内存泄露,有效的使用可以使用的内存。垃圾回收器通常是作为一个单独的低级别的线程运行,不可预知的情况下对内存堆中已经死亡的或者长时间没有使用的对象进行清除和回收,程序员不能实时的调用垃圾回收器对某个对象或所有对象进行垃圾回收。

13、垃圾回收器可以马上回收内存吗?有什么办法主动通知虚拟机进行垃圾回收?

对于 GC 来说,当程序员创建对象时,GC 就开始监控这个对象的地址、大小以及使用情况。通常,GC 采用有向图的方式记录和管理堆(heap)中的所有对象。通过这种方式确定哪些对象是”可达的”,哪些对象是”不可达的”。当 GC 确定一些对象为“不可达”时,GC 就有责任回收这些内存空间。

可以。程序员可以手动执行 System.gc(),通知 GC 运行,但是 Java 语言规范并不保证 GC 一定会执行。

14、Java 中会存在内存泄漏吗,请简单描述。

所谓内存泄露就是指一个不再被程序使用的对象或变量一直被占据在内存中。Java 中有垃圾回收机制,它可以保证一对象不再被引用的时候,即对象变成了孤儿的时候,对象将自动被垃圾回收器从内存中清除掉。由于 Java 使用有向图的方式进行垃圾回收管理,可以消除引用循环的问题,例如有两个对象,相互引用,只要它们和根进程不可达的,那么 GC 也是可以回收它们的,例如下面的代码可以看到这种情况的内存回收:

public class GarbageTest {

    public static void main(String[] args) throws IOException {
        try {
            gcTest();
        } catch (IOException e) {
        }

        System.out.println("has exited gcTest!");
        System.in.read();
        System.in.read();
        System.out.println("out begin gc!");

        for (int i = 0; i < 100; i++) {
            System.gc();
            System.in.read();
            System.in.read();
        }
    }

    private static void gcTest() throws IOException {
        System.in.read();
        System.in.read();

        Person p1 = new Person();
        System.in.read();
        System.in.read();

        Person p2 = new Person();
        p1.setMate(p2);
        p2.setMate(p1);
        System.out.println("before exit gctest!");
        System.in.read();
        System.in.read();
        System.gc();
        System.out.println("exit gctest!");
    }

    private static class Person {
        byte[] data = new byte[20000000];
        Person mate = null;

        public void setMate(Person other) {
            mate = other;
        }
    }
}

Java 中的内存泄露的情况:长生命周期的对象持有短生命周期对象的引用就很可能发生内存泄露,尽管短生命周期对象已经不再需要,但是因为长生命周期对象持有它的引用而导致不能被回收,这就是 Java 中内存泄露的发生场景,通俗地说,就是程序员可能创建了一个对象,以后一直不再使用这个对象,这个对象却一直被引用,即这个对象无用但是却无法被垃圾回收器回收的,这就是 Java 中可能出现内存泄露的情况,例如,缓存系统,我们加载了一个对象放在缓存中 (例如放在一个全局 map 对象中),然后一直不再使用它,这个对象一直被缓存引用,但却不再被使用。

检查 Java 中的内存泄露,一定要让程序将各种分支情况都完整执行到程序结束,然后看某个对象是否被使用过,如果没有,则才能判定这个对象属于内存泄露。

如果一个外部类的实例对象的方法返回了一个内部类的实例对象,这个内部类对象被长期引用了,即使那个外部类实例对象不再被使用,但由于内部类持久外部类的实例对象,这个外部类对象将不会被垃圾回收,这也会造成内存泄露。

我们来看个堆栈经典的例子,主要特点就是清空堆栈中的某个元素,并不是彻底把它从数组中拿掉,而是把存储的总数减少。

public class Stack {
    private Object[] elements = new Object[10];
    private int size = 0;

    public void push(Object e) {
        ensureCapacity();
        elements[size++] = e;
    }

    public Object pop() {
        if (size == 0) {
            throw new EmptyStackException();
        }
        return elements[--size];
    }

    private void ensureCapacity() {
        if (elements.length == size) {
            Object[] oldElements = elements;
            elements = new Object[(2 * elements.length) + 1];
            System.arraycopy(oldElements, 0, elements, 0, size);
        }
    }
}

上面的原理应该很简单,假如堆栈加了 10 个元素,然后全部弹出来,虽然堆栈是空的,没有我们要的东西,但是这是个对象是无法回收的,这个才符合了内存泄露的两个条件:无用,无法回收。但是就是存在这样的东西也不一定会导致什么样的后果,如果这个堆栈用的比较少,也就浪费了几个 K 内存而已,反正我们的内存都上 G 了,哪里会有什么影响,再说这个东西很快就会被回收的,有什么关系。下面再看个例子。

public class Bad {
    public static Stack s = Stack();

    static {
        s.push(new Object());
        s.pop(); //这里有一个对象发生内存泄露
        s.push(new Object()); //上面的对象可以被回收了,等于是自愈了
    }
}

因为是 static,就一直存在到程序退出,但是我们也可以看到它有自愈功能,就是说如果你的 Stack 最多有 100 个对象,那么最多也就只有 100 个对象无法被回收,其实这个应该很容易理解,Stack 内部持有 100 个引用,最坏的情况就是他们都是无用的,因为我们一旦放新的进去,以前的引用自然消失!

内存泄露的另外一种情况:当一个对象被存储进 HashSet 集合中以后,就不能修改这个对象中的那些参与计算哈希值的字段了,否则,对象修改后的哈希值与最初存储进 HashSet 集合中时的哈希值就不同了,在这种情况下,即使在 contains 方法使用该对象的当前引用作为的参数去 HashSet 集合中检索对 象,也将返回找不到对象的结果,这也会导致无法从 HashSet 集合中单独删除当前对象,造成内存泄露。

15、简述 Java 内存分配与回收策略以及 Minor GC 和 Major GC。

  • 对象优先在堆的 Eden 区分配

  • 大对象直接进入老年代

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

当 Eden 区没有足够的空间进行分配时,虚拟机会执行一次 Minor GC。Minor GC 通常发生在新生代的 Eden 区,在这个区的对象生存期短,往往发生 GC 的 频率较高,回收速度比较快;Full GC/Major GC 发生在老年代,一般情况下, 触发老年代 GC 的时候不会触发 Minor GC,但是通过配置,可以在 Full GC 之前进行一次 Minor GC 这样可以加快老年代的回收速度。

16、JVM 内存为什么要分成新生代,老年代,持久代。新生代中为什么要分为 Eden 和 Survivor。

第一个问题我觉得是通过分新生代,老年代,持久代而更好的利用有限的内存空间。

第二个问题:

  • 如果没有 Survivor,Eden 区每进行一次 Minor GC,存活的对象就会被送到老年代。老年代很快被填满,触发 Major GC。老年代的内存空间远大于新生代,进行一次 Full GC 消耗的时间比 Minor GC 长得多,所以需要分为 Eden 和 Survivor。

  • Survivor 的存在意义,就是减少被送到老年代的对象,进而减少 Full GC 的发生,Survivor 的预筛选保证,只有经历 16 次 Minor GC 还能在新生代中存活的对象,才会被送到老年代。

  • 设置两个 Survivor 区最大的好处就是解决了碎片化,刚刚新建的对象在 Eden 中,经历一次 Minor GC,Eden 中的存活对象就会被移动到第一块 survivor space S0,Eden 被清空;等 Eden 区再满了,就再触发一次 Minor GC,Eden 和 S0 中的存活对象又会被复制送入第二块 survivor space S1(这个过程非常重要,因为这种复制算法保证了 S1 中来自 S0 和 Eden 两部分的存活对象占用连续的内存空间,避免了碎片化的发生)。

17、 Minor GC ,Full GC 触发条件

Minor GC 触发条件:当 Eden 区满时,触发 Minor GC。

Full GC 触发条件:

  • 调用 System.gc 时,系统建议执行 Full GC,但是不必然执行

  • 老年代空间不足

  • 方法区空间不足

  • 通过 Minor GC 后进入老年代的平均大小大于老年代的可用内存

  • 由 Eden区、From Space 区向 To Space 区复制时,对象大小大于 To Space 可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小。

18、当出现了内存溢出,你怎么排错?

  • 首先控制台查看错误日志

  • 然后使用 jdk 自带的 jvisualvm工具查看系统的堆栈日志

  • 定位出内存溢出的空间:堆,栈还是永久代(jdk8 以后不会出现永久代的内存溢出)。

  • 如果是堆内存溢出,看是否创建了超大的对象

  • 如果是栈内存溢出,看是否创建了超大的对象,或者产生了死循环。

19、你们线上应用的 JVM 参数有哪些?

这里老周给我们服务的 JVM 参数给大家参考下哈,按照自己线上应用来答就好了。

  • -server

  • -Xms4096M

  • Xmx4096M

  • -Xmn1536M

  • -XX:MetaspaceSize=256M

  • -XX:MaxMetaspaceSize=256M

  • -XX:+UseParNewGC

  • -XX:+UseConcMarkSweepGC

  • -XX:+CMSScavengeBeforeRemark

  • -XX:CMSInitiatingOccupancyFraction=75

  • -XX:CMSInitiatingOccupancyOnly

20、什么是内存泄漏,它与内存溢出的关系?

20.1 内存泄漏 memory leak

是指程序在申请内存后,无法释放已申请的内存空间,一次内存泄漏似乎不会有大的影响,但内存泄漏堆积后的后果就是内存溢出。

20.2 内存溢出 out of memory

指程序申请内存时,没有足够的内存供申请者使用,或者说,给了你一块存储 int 类型数据的存储空间,但是你却存储 long 类型的数据,那么结果就是内存不够用,此时就会报错 OOM,即所谓的内存溢出。

20.3 二者的关系

  • 内存泄漏的堆积最终会导致内存溢出

  • 内存溢出就是你要的内存空间超过了系统实际分配给你的空间,此时系统相当于没法满足你的需求,就会报内存溢出的错误。

  • 内存泄漏是指你向系统申请分配内存进行使用(new),可是使用完了以后却不归还(delete),结果你申请到的那块内存你自己也不能再访问(也许你把它的地址给弄丢了),而系统也不能再次将它分配给需要的程序。就相当于你租了个带钥匙的柜子,你存完东西之后把柜子锁上之后,把钥匙丢了或者没有将钥匙还回去,那么结果就是这个柜子将无法供给任何人使用,也无法被垃圾回收器回收,因为找不到他的任何信息。

  • 内存溢出:一个盘子用尽各种方法只能装4个果子,你装了5个,结果掉倒地上不能吃了。这就是溢出。比方说栈,栈满时再做进栈必定产生空间溢出,叫上溢,栈空时再做退栈也产生空间溢出,称为下溢。就是分配的内存不足以放下数据项序列,称为内存溢出。说白了就是我承受不了那么多,那我就报错,

20.4 内存泄漏的分类(按发生方式来分类)

  • 常发性内存泄漏。发生内存泄漏的代码会被多次执行到,每次被执行的时候都会导致一块内存泄漏。

  • 偶发性内存泄漏。发生内存泄漏的代码只有在某些特定环境或操作过程下才会发生。常发性和偶发性是相对的。对于特定的环境,偶发性的也许就变成了常发性的。所以测试环境和测试方法对检测内存泄漏至关重要。

  • 一次性内存泄漏。发生内存泄漏的代码只会被执行一次,或者由于算法上的缺陷,导致总会有一块仅且一块内存发生泄漏。比如,在类的构造函数中分配内存,在析构函数中却没有释放该内存,所以内存泄漏只会发生一次。

  • 隐式内存泄漏。程序在运行过程中不停的分配内存,但是直到结束的时候才释放内存。严格的说这里并没有发生内存泄漏,因为最终程序释放了所有申请的内存。但是对于一个服务器程序,需要运行几天,几周甚至几个月,不及时释放内存也可能导致最终耗尽系统的所有内存。所以,我们称这类内存泄漏为隐式内存泄漏。

20.5 内存溢出的原因及解决方法

20.5.1 内存溢出原因

  • 内存中加载的数据量过于庞大,如一次从数据库取出过多数据;

  • 集合类中有对对象的引用,使用完后未清空,使得 JVM 不能回收;

  • 代码中存在死循环或循环产生过多重复的对象实体;

  • 使用的第三方软件中的 BUG;

  • 启动参数内存值设定的过小。

20.5.2 内存溢出的解决方案

  • 修改 JVM 启动参数,直接增加内存。(-Xms,-Xmx参数一定不要忘记加。)

  • 检查错误日志,查看“OutOfMemory”错误前是否有其它异常或错误。

  • 对代码进行走查和分析,找出可能发生内存溢出的位置。

20.5.3 重点排查以下几点

  • 检查对数据库查询中,是否有一次获得全部数据的查询。一般来说,如果一次取十万条记录到内存,就可能引起内存溢出。这个问题比较隐蔽,在上线前,数据库中数据较少,不容易出问题,上线后,数据库中数据多了,一次查询就有可能引起内存溢出。因此对于数据库查询尽量采用分页的方式查询。

  • 检查代码中是否有死循环或递归调用。

  • 检查是否有大循环重复产生新对象实体。

  • 检查 List、Map 等集合对象是否有使用完后,未清除的问题。List、Map 等集合对象会始终存有对对象的引用,使得这些对象不能被 GC 回收。

21、Full GC 问题的排查和解决经历说一下

我们可以从以下几个方面来进行排查

21.1 碎片化

对于 CMS,由于老年代的碎片化问题,在 YGC 时可能碰到晋升失败(promotion failures,即使老年代还有足够多有效的空间,但是仍然可能导致分配失败,因为没有足够连续的空间),从而触发Concurrent Mode Failure,发生会完全 STW 的 Full GC。Full GC 相比 CMS 这种并发模式的 GC 需要更长的停顿时间才能完成垃圾回收工作,这绝对是 Java 应用最大的灾难之一。

为什么 CMS 场景下会有碎片化问题?由于 CMS 在老年代回收时,采用的是标记清理(Mark-Sweep)算法,它在垃圾回收时并不会压缩堆,日积月累,导致老年代的碎片化问题会越来越严重,直到发生单线程的 Mark-Sweep-Compact GC,即FullGC,会完全 STW。如果堆比较大的话,STW 的时间可能需要好几秒,甚至十多秒,几十秒都有可能。

21.2 GC 时操作系统的活动

当发生 GC 时,一些操作系统的活动,比如 swap,可能导致 GC 停顿时间更长,这些停顿可能是几秒,甚至几十秒级别。

如果你的系统配置了允许使用 swap 空间,操作系统可能把 JVM 进程的非活动内存页移到 swap 空间,从而释放内存给当前活动进程(可能是操作系统上其他进程,取决于系统调度)。Swapping 由于需要访问磁盘,所以相比物理内存,它的速度慢的令人发指。所以,如果在 GC 的时候,系统正好需要执行 Swapping,那么 GC 停顿的时间一定会非常非常恐怖。

除了swapping 以外,我们也需要监控了解长 GC 暂停时的任何 IO 或者网络活动情况等, 可以通过 iostat 和 netstat 两个工具来实现。我们还能通过 mpstat 查看 CPU 统计信息,从而弄清楚在 GC 的时候是否有足够的 CPU 资源。

21.3 堆空间不够

如果应用程序需要的内存比我们执行的 Xmx 还要大,也会导致频繁的垃圾回收,甚至 OOM。由于堆空间不足,对象分配失败,JVM 就需要调用 GC 尝试回收已经分配的空间,但是 GC 并不能释放更多的空间,从而又回导致 GC,进入恶性循环。

同样的,如果在老年代的空间不够的话,也会导致频繁 Full GC,这类问题比较好办,给足老年代和永久代。

21.4 JVM Bug

什么软件都有 BUG,JVM 也不例外。有时候,GC 的长时间停顿就有可能是 BUG 引起的。例如,下面列举的这些 JVM 的 BUG,就可能导致 Java 应用在 GC 时长时间停顿。

6459113: CMS+ParNew: wildly different ParNew pause times depending on heap shape caused by allocation spread

fixed in JDK 6u1 and 7

6572569: CMS: consistently skewed work distribution indicated in (long) re-mark pauses

fixed in JDK 6u4 and 7

6631166: CMS: better heuristics when combatting fragmentation

fixed in JDK 6u21 and 7

6999988: CMS: Increased fragmentation leading to promotion failure after CR#6631166 got implemented

fixed in JDK 6u25 and 7

6683623: G1: use logarithmic BOT code such as used by other collectors

fixed in JDK 6u14 and 7

6976350: G1: deal with fragmentation while copying objects during GC

fixed in JDK 8

如果你的 JDK 正好是上面这些版本,强烈建议升级到更新 BUG 已经修复的版本。

21.5 显式 System.gc 调用

检查是否有显示的 System.gc 调用,应用中的一些类里,或者第三方模块中调用 System.gc 调用从而触发 STW 的 Full GC,也可能会引起非常长时间的停顿。如下 GC 日志所示,Full GC 后面的(System)表示它是由调用 System.GC 触发的 FullGC,并且耗时 5.75 秒:

164638.058: [Full GC (System) [PSYoungGen: 22789K->0K(992448K)]

[PSOldGen: 1645508K->1666990K(2097152K)] 1668298K->1666990K(3089600K)

[PSPermGen: 164914K->164914K(166720K)], 5.7499132 secs] [Times: user
=5.69, sys=0.06, real=5.75 secs]

如果你使用了 RMI,能观察到固定时间间隔的 Full GC,也是由于 RMI 的实现调用了 System.gc。这个时间间隔可以通过系统属性配置:

-Dsun.rmi.dgc.server.gcInterval=7200000

-Dsun.rmi.dgc.client.gcInterval=7200000

JDK 1.4.2和5.0的默认值是60000毫秒,即1分钟;JDK6以及以后的版本,默认值是3600000毫秒,即1个小时。

如果你要关闭通过调用 System.gc() 触发 Full GC,配置JVM参数 -XX:+DisableExplicitGC 即可。

21.6 那么如何定位并解决这类问题问题呢?

  • 配置 JVM 参数:-XX:+PrintGCDetails -XX:+PrintHeapAtGC -XX:+PrintGCTimeStamps -XX:+PrintGCDateStamps and -XX:+PrintGCApplicationStoppedTime. 如果是 CMS,还需要添加-XX:PrintFLSStatistics=2,然后收集 GC 日志。因为 GC 日志能告诉我们 GC 频率,是否长时间停顿等重要信息。

  • 使用 vmstat, iostat, netstat 和 mpstat 等工具监控系统全方位健康状况。

  • 使用 GCHisto 工具可视化分析 GC 日志,弄明白消耗了很长时间的 GC,以及这些 GC 的出现是否有一定的规律。

  • 尝试从 GC 日志中能否找出一下 JVM 堆碎片化的表征。

  • 监控指定应用的堆大小是否足够。

  • 检查你运行的 JVM 版本,是否有与长时间停顿相关的 BUG,然后升级到修复问题的最新 JDK。

22、GC 中的三色标记你了解吗?

Java 垃圾回收目前采用的算法是可达性标记算法,即基于 GC Roots 进行可达性分析。分析标记过程采用三色标记法。

三色标记按照垃圾回收器 ”是否访问过“ 为条件将对象标为三种颜色:

  • 白色:表示对象未被垃圾回收器访问过;

  • 灰色:表示对象本身被垃圾回收器访问过,但这个对象上至少有一个引用未被访问扫描过;

  • 黑色:对象完全被扫描,并且其所有引用都已完成扫描。

其实灰色就是一个过渡状态,在垃圾回收器标记完成结束后,对象只有白色或者黑色其中一种状态,当为白色时,说明该对象在可达性分析后没有引用,也就是之后被销毁的对象。当为黑色时,说明当前对象为此次垃圾回收存活对象。

当垃圾回收开始时,GC Roots 对象是黑色对象。沿着他找到的对象 A 首先是灰色对象,当对象 A 所有引用都扫描后,对象 A 为黑色对象,以此类推继续往下扫描。

这是垃圾回收标记基本操作。

但目前的垃圾回收是并发操作的,就是在你进行标记的时候,程序线程也是继续运行的,那原有的对象引用就有可能发生变化。

比如已经标记为黑色(存活对象)对象,程序运行将其所有引用取消,那么这个对象应该是白色的(垃圾对象)。这种情况相对好一些,在下一次垃圾回收时候,我们还是可以把他回收,只是让他多活了一会儿,系统也不会出现什么问题,可以不解决。

当已经标记为白色对象(垃圾对象)时,此时程序运行又让他和其他黑色(存活)对象产生引用,那么该对象最终也应该是黑色(存活)对象,如果此时垃圾回收器标记完回收后,会出现对象丢失,这样就引起程序问题。

出现对象丢失的必要条件是(在垃圾回收器标记进行时出现的改变):

  • 重新建立了一条或多条黑色对象到白色对象的新引用

  • 删除了灰色对象到白色对象的直接或间接引用

因为已经标记黑色的对象说明此轮垃圾回收中垃圾回收器对其的扫描已经完成,不会再扫描,如果他又引用了一个白色对象,而且这个白色对象在垃圾扫描完后还是白色,那么这个白色对象最终会被误回收。

为了防止这种情况的出现,上边说的必要条件中的一个处理掉即可避免对象误删除;

当黑色对象直接引用了一个白色对象后,我们就将这个黑色对象记录下来,在扫描完成后,重新对这个黑色对象扫描,这个就是增量更新(Incremental Update)。

当删除了灰色对象到白色对象的直接或间接引用后,就将这个灰色对象记录下来,再以此灰色对象为根,重新扫描一次。这个就是原始快照(Snapshot At TheBeginning,SATB)。

自此,对象可达标记完成。



欢迎大家关注我的公众号【老周聊架构】,Java后端主流技术栈的原理、源码分析、架构以及各种互联网高并发、高性能、高可用的解决方案。

喜欢的话,点赞、再看、分享三连。

点个在看你最好看



good-icon 0
favorite-icon 0
收藏
回复数量: 0
    暂无评论~~
    Ctrl+Enter