【面试系列】深入浅出JVM

发布于:2024-12-07 ⋅ 阅读:(30) ⋅ 点赞:(0)

Ø 深入理解JVM、JMM,熟悉内存模型、类加载、垃圾收集器、GC算法等、具有生产JVM调优经验;

面试题整理

  1. 简单描述JVM的内存模型
  2. 什么情况下会触发 FullGC
  3. Java 类加载器有几种,关系怎样
  4. 双亲委派机制的加载流程及其好处
  5. 1.8为什么用 Metaspace 替换 PermGen,Metaspace 保存在哪里
  6. 编译器会对指令做哪些优化(简单描述编译器的指令重排)
  7. 对 ZGC 的了解,使用场景
  8. 简单描述 volatile 可以解决什么问题?如何做到的?
  9. 简单描述 GC 的分代回收
  10. G1 垃圾回收算法与 CMS 的区别有哪些
  11. 对象引用有哪几种方式,有什么特点?
  12. 问题排查经验与思路
  13. JVM 调优经验和调优思路

1、简单描述JVM的内存模型

JVM内存结构分为5大区域,程序计数器、虚拟机栈、本地方法栈、堆、方法区。

程序计数器

特点:

  • 线程私有
  • CPU会为每个线程分配时间片,当当前线程的时间片使用完以后,CPU就会去执行另一个线程中的代码
  • 程序计数器是每个线程所私有的,当另一个线程的时间片用完,又返回来执行当前线程的代码时,通过程序计数器可以知道应该执行哪一句指令
  • 不存在内存溢出
程序计数器的作用?

线程私有的,作为当前线程的行号指示器,用于记录当前虚拟机正在执行的线程指令地址。

  1. 当前线程所执行的字节码的行号指示器,通过它实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
  2. 在多线程的情况下,程序计数器用于记录当前线程执行的位置,当线程被切换回来的时候能够知道它上次执行的位置。
程序计数器会出现OOM吗?

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

虚拟机栈

定义:每个线程在创建的时候都会创建一个虚拟机栈,其内部保存一个个的栈帧(Stack Frame),对应着一次次 Java 方法调用,是线程私有的,生命周期和线程一致。

作用:主管 Java 程序的运行,它保存方法的局部变量、部分结果,并参与方法的调用和返回。

特点:

  • 每个线程运行需要的内存空间,称为虚拟机栈。是线程私有的,每个线程都有各自的 Java 虚拟机栈,而且随着线程的创建而创建,随着线程的死亡而死亡
  • Java 虚拟机栈是由一个个栈帧组成,对应着每次调用方法时所占用的内存。每一次函数调用都会有一个对应的栈帧被压入虚拟机栈,每一个函数调用结束后,都会有一个栈帧被弹出。两种返回函数的方式,不管用哪种方式,都会导致栈帧被弹出
    • 正常的函数返回,使用 return 指令
    • 抛出异常
  • 每个线程只能有一个活动栈帧,栈顶存放当前当前正在执行的方法
虚拟机栈里有什么?

每个栈帧中都存储着:

  • 局部变量表(Local Variables)
  • 操作数栈(Operand Stack)(或称为表达式栈)
  • 动态链接(Dynamic Linking):指向运行时常量池的方法引用
  • 方法返回地址(Return Address):方法正常退出或异常退出的地址
虚拟机栈会发生stackOverflowError吗?

stackOverflowError发生原因

  • 虚拟机栈中,栈帧过多(无限递归)
  • 每个栈帧所占用内存过大
虚拟机栈会发生OutOfMemoryError吗?

OutOfMemoryError发生原因:

  • 在单线程程序中,无法出现OOM异常;但是通过循环创建线程(线程体调用方法),可以产生OOM异常。此时OOM异常产生的原因与栈空间是否足够大无关。
  • 线程动态扩展,没有足够的内存供申请时会产生OOM
垃圾回收是否涉及栈内存?

不需要。因为虚拟机栈中是由一个个栈帧组成的,在方法执行完毕后,对应的栈帧就会被弹出栈。所以无需通过垃圾回收机制去回收内存。

栈内存的分配越大越好吗?

不是。因为物理内存是一定的,栈内存越大,可以支持更多的递归调用,但是可执行的线程数就会越少。

方法内的局部变量是否是线程安全的?

如果方法内局部变量没有逃离方法的作用范围,则是线程安全的;如果如果局部变量引用了对象,并逃离了方法的作用范围,则需要考虑线程安全问题

本地方法栈

也是线程私有的

虚拟机栈为虚拟机执行 Java 方法服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。Native 方法一般是用其它语言(C、C++等)编写的。

本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息。

为什么需要本地方法?

一些带有native关键字的方法就是需要JAVA去调用C或者C++方法,因为JAVA有时候没法直接和操作系统底层交互,所以需要用到本地方法

Native Method Stack:它的具体做法是Native Method Stack中登记native方法,在( Execution Engine )执行引擎执行的时候加载Native Libraies

Native Interface本地接口:本地接口的作用是融合不同的编程语言为Java所用,它的初衷是融合C/C程序, Java在诞生的时候是C/C横行的时候,想要立足,必须有调用C、C++的程序,于是就在内存中专门开辟了块区域处理标记为native的代码,它的具体做法是在Native Method Stack 中登记native方法,在( Execution Engine )执行引擎执行的时候加载Native Libraies。 目前该方法使用的越来越少了,除非是与硬件有关的应用,比如通过Java程序驱动打印机或者Java系统管理生产设备,在企业级应用中已经比较少见。因为现在的异构领域间通信很发达,比如可以使用Socket通信,也可以使用Web Service等等。

在 Hotspot JVM 中,直接将本地方法栈和虚拟机栈合二为一。

通过new关键字创建的对象都会被放在堆内存

  • 所有线程共享,堆内存中的对象都需要考虑线程安全问题
  • 有垃圾回收机制
  • 堆中的区域:新生代( Eden 空间、 From Survivor 、 To Survivor 空间)和老年代。
说一下堆与栈的区别?
  1. 堆的物理地址分配是不连续的,性能较慢;栈的物理地址分配是连续的,性能相对较快。
  2. 堆存放的是对象的实例和数组;栈存放的是局部变量,操作数栈,返回结果等。
  3. 堆是线程共享的;栈是线程私有的。
如何设置堆内存大小

Java 堆用于存储 Java 对象实例,那么堆的大小在 JVM 启动的时候就确定了,我们可以通过 -Xmx 和 -Xms 来设定

  • -Xms 用来表示堆的起始内存,等价于 -XX:InitialHeapSize
  • -Xmx 用来表示堆的最大内存,等价于 -XX:MaxHeapSize

如果堆的内存大小超过 -Xmx 设定的最大内存, 就会抛出 OutOfMemoryError 异常。

为什么通常会将 -Xmx 和 -Xms 两个参数配置为相同的值?

目的是为了能够在垃圾回收机制清理完堆区后不再需要重新分隔计算堆的大小,从而提高性能。

如果 -Xms-Xmx 设置为不同的值,JVM 在运行时可能会根据内存使用情况不断调整堆的大小。这种动态调整需要进行内存分配和垃圾收集,可能会增加系统的开销和延迟。而将这两个参数设置为相同的值,JVM 在启动时就分配好固定量的堆内存,从而避免了内存重新分配的开销。

方法区

结构

方法区与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

对方法区进行垃圾回收的主要目标是对常量池的回收和对类的卸载。

方法区(method area)只是 JVM 规范中定义的一个概念,用于存储类信息、常量池、静态变量、JIT编译后的代码等数据,并没有规定如何去实现它,不同的厂商有不同的实现。而永久代(PermGen)是 Hotspot 虚拟机特有的概念, Java8 的时候又被元空间取代了,永久代和元空间都可以理解为方法区的落地实现。

  • 永久代

方法区是 JVM 的规范,而永久代 PermGen 是方法区的一种实现方式,并且只有 HotSpot 有永久代。对于其他类型的虚拟机,如 JRockit 没有永久代。由于方法区主要存储类的相关信息,所以对于动态生成类的场景比较容易出现永久代的内存溢出。

永久区是常驻内存的,是用来存放JDK自身携带的Class对象和interface元数据。这样这些数据就不会占用空间。用于存储java运行时环境。

  1. 在JDK1.7前,字符串存放在方法区之中
  2. 在JDK1.7后字符串被放在了堆
  3. 在Java8,取消了方法区,改用了直接使用直接内存的的元空间。即元空间逻辑上属于堆,但在物理内存上,元空间的内存并不由堆空间内存分配
  • 元空间

JDK 1.8 的时候, HotSpot 的永久代被彻底移除了,使用元空间替代。元空间的本质和永久代类似,都是对JVM规范中方法区的实现。两者最大的区别在于:元空间并不在虚拟机中,而是使用直接内存。

为什么要将永久代替换为元空间呢?

永久代内存受限于 JVM 可用内存,而元空间使用的是直接内存,受本机可用内存的限制,虽然元空间仍旧可能溢出,但是相比永久代内存溢出的概率更小。

运行时常量池

运行时常量池是方法区的一部分,在类加载之后,会将编译器生成的各种字面量和符号引号放到运行时常量池。在运行期间动态生成的常量,如 String 类的 intern()方法,也会被放入运行时常量池。

直接内存

直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用。而且也可能导致 OutOfMemoryError 错误出现。

NIO的Buffer提供了DirectBuffer ,可以直接访问系统物理内存,避免堆内内存到堆外内存的数据拷贝操作,提高效率。DirectBuffer 直接分配在物理内存中,并不占用堆空间,其可申请的最大内存受操作系统限制,不受最大堆内存的限制。

直接内存的读写操作比堆内存快,可以提升程序I/O操作的性能。通常在I/O通信过程中,会存在堆内内存到堆外内存的数据拷贝操作,对于需要频繁进行内存间数据拷贝且生命周期较短的暂存数据,都建议存储到直接内存。

2、什么情况下会触发 FullGC

画板

详见:JVM基础 - GC

内存的分配策略?

  • 对象优先在 Eden 分配: 大多数情况下,对象在新生代 Eden 上分配,当 Eden 空间不够时,触发 Minor GC
  • 大对象直接进入老年代: 当遇到一个较大的对象时,就算新生代的伊甸园为空,也无法容纳该对象时,会将该对象直接晋升为老年代,最典型的大对象有长字符串和大数组。可以设置JVM参数 -XX:PretenureSizeThreshold ,大于此值的对象直接在老年代分配。
  • 长期存活的对象进入老年代: 通过参数 -XX:MaxTenuringThreshold 可以设置对象进入老年代的年龄阈值。对象在 Survivor 区每经过一次 Minor GC ,年龄就增加 1 岁,当它的年龄增加到一定程度,就会被晋升到老年代中。
  • 动态对象年龄判定: 并非对象的年龄必须达到 MaxTenuringThreshold 才能晋升老年代,如果在 Survivor 中相同年龄所有对象大小的总和大于 Survivor 空间的一半,则年龄大于或等于该年龄的对象可以直接进入老年代,无需达到 MaxTenuringThreshold 年龄阈值。
  • 空间分配担保: 在发生 Minor GC 之前,虚拟机先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果条件成立的话,那么 Minor GC 是安全的。如果不成立的话虚拟机会查看HandlePromotionFailure 的值是否允许担保失败。如果允许,那么就会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次 Minor GC,尽管这次Minor GC是有风险的;(也就是说,会把原先新生代的对象挪到老年代中) ;如果小于,或者 HandlePromotionFailure 的值为不允许担保失败,那么就要进行一次 Full GC 。

画板

对于 Minor GC,其触发条件非常简单,当 Eden 空间满时,就将触发一次 Minor GC。而 Full GC 则相对复杂,有以下条件:

  • 用 System.gc(): 只是建议虚拟机执行 Full GC,但是虚拟机不一定真正去执行。不建议使用这种方式,而是让虚拟机管理内存。
  • 老年代(Tenured Gen)空间不足: 老年代空间不足的常见场景为前文所讲的大对象直接进入老年代、长期存活的对象进入老年代等。为了避免以上原因引起的 Full GC,应当尽量不要创建过大的对象以及数组、注意编码规范避免内存泄露。除此之外,可以通过 -Xmn 参数调大新生代的大小,让对象尽量在新生代被回收掉,不进入老年代。还可以通过 -XX:MaxTenuringThreshold 调大对象进入老年代的年龄,让对象在新生代多存活一段时间。
  • 空间分配担保失败: 当程序创建一个大对象时,Eden区域放不下大对象,使用复制算法的 Minor GC 需要老年代的内存空间作担保,如果担保失败会执行一次 Full GC。
  • JDK 1.7 及以前的永久代空间不足: 在 JDK 1.7 及以前,HotSpot 虚拟机中的方法区是用永久代实现的,永久代中存放的为一些 Class 的信息、常量、静态变量等数据。当系统中要加载的类、反射的类和调用的方法较多时,永久代可能会被占满,在未配置为采用 CMS GC 的情况下也会执行 Full GC。如果经过 Full GC 仍然回收不了,那么虚拟机会抛出 java.lang.OutOfMemoryError 。(JDK 8以后元空间不足)
  • Concurrent Mode Failure:执行 CMS GC 的过程中同时有对象要放入老年代,而此时老年代空间不足(可能是 GC 过程中浮动垃圾过多导致暂时性的空间不足),便会报 Concurrent Mode Failure 错误,并触发 Full GC。

3、Java 类加载器有几种,关系怎样

详见:JVM基础 - 类加载机制

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

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

  • 启动类加载器:用来加载 Java 核心类库,无法被 Java 程序直接引用。
  • 扩展类加载器:它用来加载 Java 的扩展库。Java 虚拟机的实现会提供一个扩展库目录。该类加载器在此目录里面查找并加载 Java 类。
  • 系统类加载器:它根据应用的类路径来加载 Java 类。可通过ClassLoader.getSystemClassLoader() 获取它。
  • 自定义类加载器:通过继承java.lang.ClassLoader 类的方式实现。

JDK中有三个默认类加载器:AppClassLoader、ExtClassLoader、BootStrapClassLoader。AppClassLoader的父加载器为Ext ClassLoader、Ext ClassLoader的父加载器为BootStrap ClassLoader。

4、双亲委派机制的加载流程及其好处

一个类加载器收到一个类的加载请求时,它首先不会自己尝试去加载它,而是把这个请求委派给父类加载器去完成,这样层层委派,因此所有的加载请求最终都会传送到顶层的启动类加载器中,只有当父类加载器反馈自己无法完成这个加载请求时,子加载器才会尝试自己去加载。

画板

双亲委派模型的具体实现代码在 java.lang.ClassLoader 中,此类的 loadClass() 方法运行过程如下:先检查类是否已经加载过,如果没有则让父类加载器去加载。当父类加载器加载失败时抛出ClassNotFoundException ,此时尝试自己去加载。

双亲委派机制的主要特点和优势包括:

  • 避免类的重复加载:当一个类被加载后,它会被父类加载器缓存起来,避免了重复加载同一个类的问题,提高了类加载的效率。
  • 类的隔离和安全性:通过双亲委派机制,不同的类加载器加载的类具有不同的命名空间,相同类名的类可以被不同的类加载器加载,实现了类的隔离和安全性。
  • 保护核心类库的完整性:核心类库由启动类加载器加载,避免了用户自定义的类替换核心类库的情况,保护了核心类库的完整性。

总结起来:

  1. 双亲委派机制通过层级结构的类加载器组织,实现了类的共享、隔离和安全性。
  2. 它是Java类加载器的一种重要机制,为Java应用程序提供了良好的类加载环境。
  3. 然而,在某些特定的场景下,为了满足特定的需求,可能需要打破双亲委派机制,使用自定义的类加载器来加载类。
  4. 在使用自定义类加载器时,需要仔细评估和测试,确保能够正确处理类的加载和依赖关系。
双亲委派模型目的?

可以防止内存中出现多份同样的字节码。如果没有双亲委派模型而是由各个类加载器自行加载的话,如果用户编写了一个 java.lang.Object 的同名类并放在 ClassPath 中,多个类加载器都去加载这个类到内存中,系统中将会出现多个不同的 Object 类,那么类之间的比较结果及类的唯一性将无法保证。

什么时候需要打破双亲委派模型?

比如类A已经有一个classA,恰好类B也有一个clasA 但是两者内容不一致,如果不打破双亲委派模型,那么类A只会加载一次;

只要在加载类的时候,不按照UserClassLoader -> ApplicationClassLoader -> ExtensionClassLoader -> BootstrapClassLoader 的顺序来加载就算打破打破双亲委派模型了。比如自定义个ClassLoader,重写loadClass方法(不依照往上开始寻找类加载器),那就算是打破双亲委派机制了。

打破双亲委派模型的方式?

有两种方式:

  1. 自定义一个类加载器的类,并覆盖抽象类java.lang.ClassL oader中loadClass…)方法,不再优先委派“父”加载器进行类加载。(比如Tomcat)
  2. 主动违背类加载器的依赖传递原则
    • 例如在一个BootstrapClassLoader加载的类中,又通过APPClassLoader来加载所依赖的其它类,这就打破了“双亲委派模型”中的层次结构,逆转了类之间的可见性。
    • 典型的是Java SPI机制,它在类ServiceLoader中,会使用线程上下文类加载器来逆向加载classpath中的第三方厂商提供的Service Provider类。(比如JDBC)
什么是依赖传递原则?

如果一个类由类加载器A加载,那么这个类的依赖类也是由「相同的类加载器」加载。

如何打破双亲委派模型?

在Java中,有以下几种方法可以打破双亲委派机制:

  1. 自定义类加载器:通过自定义ClassLoader的子类,重写findClass()方法,实现自定义的类加载逻辑。在自定义类加载器中,可以选择不委派给父类加载器,而是自己去加载类。
  2. 线程上下文类加载器:通过Thread类的setContextClassLoader()方法,可以设置线程的上下文类加载器。在某些框架或库中,会使用线程上下文类加载器来加载特定的类,从而打破双亲委派机制。
  3. OSGi框架:OSGi(Open Service Gateway Initiative)是一种动态模块化的Java平台,它提供了一套机制来管理和加载模块。在OSGi中,每个模块都有自己的类加载器,可以独立加载和管理类,从而打破双亲委派机制。
  4. Java SPI机制:Java SPI(Service Provider Interface)是一种标准的服务发现机制,在SPI中,服务的实现类通过在META-INF/services目录下的配置文件中声明,而不是通过类路径来查找。通过SPI机制,可以实现在不同的类加载器中加载不同的服务实现类,从而打破双亲委派机制。

需要注意的是,打破双亲委派机制可能会引入一些潜在的风险和问题,如类的冲突、不一致性等。在使用这些方法打破双亲委派机制时,需要谨慎考虑,并确保能够正确处理类的加载和依赖关系。

除了上述提到的方法,还有一些其他的方法可以打破双亲委派机制:

  • 使用Java Instrumentation API:Java Instrumentation API允许在类加载过程中修改字节码,从而可以在类加载时修改类的加载行为,包括打破双亲委派机制。通过Instrumentation API,可以在类加载前修改类的字节码,使其加载时使用自定义的类加载器。
  • 使用Java动态代理:Java动态代理机制可以在运行时生成代理类,并在代理类中实现特定的逻辑。通过使用动态代理,可以在类加载时动态生成代理类,并在代理类中实现自定义的类加载逻辑,从而打破双亲委派机制。
  • 使用字节码操作库:可以使用字节码操作库,如ASM、Javassist等,来直接操作字节码,从而修改类的加载行为。通过这些库,可以在类加载时修改字节码,使其加载时使用自定义的类加载器。

在某些框架或场景中,为了满足特定的需求,可能会打破双亲委派机制。以下是一些常见的框架或场景:

  • JavaEE容器:JavaEE容器(如Tomcat、WebLogic、WebSphere等)通常会使用自定义的类加载器来加载应用程序的类,以实现应用程序的隔离和独立性。这些容器会打破双亲委派机制,使用自定义的类加载器来加载应用程序的类。
  • OSGi框架:OSGi(Open Service Gateway Initiative)是一种动态模块化的Java平台,它提供了一套机制来管理和加载模块。在OSGi中,每个模块都有自己的类加载器,可以独立加载和管理类,从而打破双亲委派机制。
  • Java SPI机制:Java SPI(Service Provider Interface)是一种标准的服务发现机制,在SPI中,服务的实现类通过在META-INF/services目录下的配置文件中声明,而不是通过类路径来查找。通过SPI机制,可以实现在不同的类加载器中加载不同的服务实现类,从而打破双亲委派机制。
  • 动态代理框架:一些动态代理框架,如CGLIB、Byte Buddy等,可以在运行时生成代理类,并在代理类中实现特定的逻辑。这些框架通常会使用自定义的类加载器来加载生成的代理类,从而打破双亲委派机制;

5、1.8为什么用 Metaspace 替换 PermGen,Metaspace 保存在哪里

Java8为什么要将永久代替换成Metaspace?

  • 字符串存在永久代中,容易出现性能问题和内存溢出。
  • 类及方法的信息等比较难确定其大小,因此对于永久代的大小指定比较困难,太小容易出现永久代溢出,太大则容易导致老年代溢出。
  • 永久代会为 GC 带来不必要的复杂度,并且回收效率偏低。

1.7和1.8之间JVM内存结构以及它们的差异

事实上,移除永久代的工作是从 JDK 1.7开始的。在 JDK 1.7中,存储在永久生成中的部分数据已经转移到 Java 堆或本机堆。

然而,JDK 1.7中的永久代仍然存在,并且没有被完全删除。

例如,将符号引用转移到本机堆; 将类的文本变量(内嵌字符串)和静态变量转移到 Java 堆。

JDK 1.8 同 JDK 1.7 比,最大的差别就是:元数据区取代了永久代。元空间的本质和永久代类似,都是对 JVM 规范中方法区的实现。

不过元空间与永久代之间最大的区别在于:元数据区并不在虚拟机中,而是使用本地内存。

PermGen(永久代)

Java7及以前版本的Hotspot中方法区位于永久代中。同时,永久代和堆是相互隔离的,但它们使用的物理内存是连续的,永久代的垃圾收集是和老年代捆绑在一起的,因此无论谁满了,都会触发永久代和老年代的垃圾收集。

Java7中永久代中存储的部分数据已经开始转移到Java Heap或Native Memory中了。比如,符号引用(Symbols)转移到了Native Memory;字符串常量池(interned strings)转移到了Java Heap;类的静态变量(class statics)转移到了Java Heap。

绝大部分Java程序员应该都见过java.lang.OutOfMemoryError: PremGen space异常。这里的PermGen space其实指的就是方法区。不过方法区和PermGen space又有着本质的区别。前者是JVM的规范,而后者则是JVM规范的一种实现,并且只有HotSpot才有PermGen space,而对于其他类型的虚拟机,如JRockit(Oracle)、J9(IBM)并没有PermGen space。由于方法区主要存储类的相关信息,所以对于动态生成类的情况比较容易出现永久代的内存溢出。并且JDK 1.8中永久代的参数PermSize和MaxPermSize已经失效。

Metaspace(元空间)

对于Java8,HotSpot取消了永久代,那么是不是就没有方法区了呢?当然不是,方法区只是一个规范,只不过它的实现变了。

在Java8中,元空间(Metaspace)登上舞台,方法区存在于元空间(Metaspace)。同时,元空间不再与堆连续,而且是存在于本地内存(Native memory)。

JDK1.7后对JVM架构进行了改造,将类元数据放到本地内存中,另外,将字符串常量池和静态变量放到Java堆里。HotSpot VM将会为类的元数据明确分配和释放本地内存。在这种架构下,类元信息就突破了原来 -XX:MaxPermSize的限制,现在可以使用更多的本地内存。这样就从一定程度上解决了原来在运行时生成大量类造成经常Full GC问题,如运行时使用反射、代理等。所以升级以后Java堆空间可能会增加。

元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代之间的最大区别在于:元空间并不在虚拟机中,而是使用本地内存。本地内存(Native memory),也称为C-Heap,是供JVM自身进程使用的。当Java Heap空间不足时会触发GC,但Native memory空间不够却不会触发GC。默认情况下元空间是可以无限使用本地内存的,但为了不让它如此膨胀,JVM同样提供了参数来限制它使用的使用。

-XX:MetaspaceSize,class metadata的初始空间配额,以bytes为单位,达到该值就会触发垃圾收集进行类型卸载,同时GC会对该值进行调整:如果释放了大量的空间,就适当的降低该值;如果释放了很少的空间,那么在不超过MaxMetaspaceSize(如果设置了的话),适当的提高该值。

-XX:MaxMetaspaceSize,可以为class metadata分配的最大空间。默认是没有限制的。

-XX:MinMetaspaceFreeRatio,在GC之后,最小的Metaspace剩余空间容量的百分比,减少为class metadata分配空间导致的垃圾收集。

-XX:MaxMetaspaceFreeRatio,在GC之后,最大的Metaspace剩余空间容量的百分比,减少为class metadata释放空间导致的垃圾收集。

  • 对于方法区,Java8之后的变化
    • 移除了永久代(PermGen),替换为元空间(Metaspace)
    • 永久代中的class metadata(类元信息)转移到了native memory(本地内存,而不是虚拟机)
    • 永久代中的interned Strings(字符串常量池) 和 class static variables(类静态变量)转移到了Java heap
    • 永久代参数(PermSize MaxPermSize)-> 元空间参数(MetaspaceSize MaxMetaspaceSize

6、编译器会对指令做哪些优化(简单描述编译器的指令重排)

Java编译器的指令重排主要发生在JIT(Just-In-Time)编译器层面,它会在运行时将字节码转换为本地机器码并进行优化。指令重排的目的是提高程序的执行效率,通过重新安排指令的执行顺序来更好地利用CPU资源。以下是Java编译器(特别是JIT编译器)中指令重排的一些关键点:

指令重排的目的

  1. 提高CPU利用率:通过重新排列指令,使得CPU能够更高效地利用其资源,如寄存器和缓存。
  2. 减少指令依赖:消除不必要的数据依赖,使得指令可以并行执行。
  3. 隐藏延迟:通过提前执行某些指令,隐藏某些操作的延迟,提高整体性能。

指令重排的影响和应对策略

影响:在单线程环境下,指令重排通常不会影响程序的正确性,因为 Java 的语义保证了单个线程内的执行顺序看起来是按照程序代码顺序执行的。但是在多线程环境下,如果不正确地处理指令重排,可能会导致数据不一致、死锁等并发问题。

应对策略:为了避免指令重排带来的并发问题,可以使用 Java 中的volatile关键字。当一个变量被声明为volatile时,编译器和处理器会禁止对这个变量的访问操作进行指令重排,确保变量的读写操作按照程序顺序执行。另外,还可以使用锁(如synchronized关键字)来保证多线程环境下代码的顺序执行,可以确保同一时间只有一个线程可以进入同步块,从而防止数据竞争和不一致的问题,避免因指令重排而产生的错误。

指令重排的具体优化

Java编译器(如HotSpot JVM中的JIT编译器)会对字节码指令进行多种优化,以提高程序的执行效率。以下是一些具体的优化例子:

1、方法内联(Method Inlining)
  • 定义和原理:方法内联是指编译器将被调用的方法的代码直接嵌入到调用处,而不是进行常规的方法调用。例如,有一个简单的方法add(int a, int b),其功能是返回a + b的值。
  • 示例代码:
class MathUtils {
    public static int add(int a, int b) {
        return a + b;
    }
}
public class Main {
    public static void main(String[] args) {
        int result = MathUtils.add(3, 5);
    }
}
  • 优化过程:编译器在优化时,可能会将MathUtils.add(3, 5)直接替换为3 + 5。这样做的好处是减少了方法调用的开销,包括保存和恢复调用栈帧、参数传递等操作。在频繁调用小方法的场景中,这种优化可以显著提高性能。
2、常量折叠(Constant Folding)
  • 定义和原理:编译器在编译阶段对常量表达式进行计算,将计算结果直接替换表达式。例如,对于表达式int a = 2 * 3 + 4;
  • 示例代码:
public class Main {
    public static void main(String[] args) {
        int a = 2 * 3 + 4;
    }
}
  • 优化过程:编译器会在编译时计算2 * 3 + 4的值为10,然后将代码变为int a = 10;。这样就避免了在运行时进行乘法和加法运算,节省了 CPU 时间。
3、公共子表达式消除(Common Sub - expression Elimination)
  • 定义和原理:当程序中存在多个相同的子表达式时,编译器会识别并只计算一次。例如,在代码int a = b * c + d; int e = b * c - f;中,b * c是公共子表达式。
  • 示例代码:
public class Main {
    public static void main(String[] args) {
        int b = 2;
        int c = 3;
        int d = 4;
        int f = 1;
        int a = b * c + d;
        int e = b * c - f;
    }
}
  • 优化过程:编译器可能会先计算b * c的值(这里是6),将其存储在一个临时变量(假设为temp)中。然后代码会变为int temp = b * c; int a = temp + d; int e = temp - f;,这样就减少了一次b * c的重复计算。
4、循环优化(Loop Optimization)
  • 循环不变量外提(Loop - Invariant Code Motion):
    • 定义和原理:在循环中,如果存在不依赖于循环变量的表达式,编译器会将其提到循环体外面。例如,在一个循环中计算一个固定数组的长度。
    • 示例代码:
public class Main {
    public static void main(String[] args) {
        int[] array = {1, 2, 3, 4, 5};
        int length = array.length;
        for (int i = 0; i < length; i++) {
            System.out.println(array[i]);
        }
    }
}
  • 优化过程:array.length是循环不变量,因为它在循环过程中不会改变。编译器可能会将其计算提到循环之前,避免在每次循环迭代时都重新计算数组长度。
5、强度削弱(Strength Reduction):
  • 定义和原理:将循环中较复杂的操作替换为较简单的操作。例如,在一个循环中用乘法运算来控制循环次数,编译器可能会将乘法替换为加法。
  • 示例代码:
public class Main {
    public static void main(String[] args) {
        int n = 10;
        int result = 0;
        for (int i = 0; i < 10 * n; i++) {
            result++;
        }
    }
}
  • 优化过程:编译器可能会将i < 10 * n改写为k < 10; k++; i < n * k(这里只是简单示意,实际实现更复杂),通过加法来逐步达到原来乘法控制的循环次数,因为加法运算通常比乘法运算更快。
6、死代码消除(Dead - Code Elimination)
  • 定义和原理:编译器会分析程序的控制流,识别出那些永远不会被执行的代码。例如,在if(false) { int a = 5; }这个代码片段中,由于条件false,花括号内的代码int a = 5;永远不会被执行。
  • 示例代码:
public class Main {
    public static void main(String[] args) {
        boolean flag = false;
        if (flag) {
            System.out.println("This code will not be executed");
        }
    }
}
  • 优化过程:编译器会将if语句块内的代码删除,因为它不会对程序的输出或状态产生任何影响。这样可以减小程序的代码规模,提高程序的运行效率。
7、逃逸分析(Escape Analysis)
  • 原理:编译器通过分析对象的作用域和引用情况,判断对象是否会逃逸出方法。如果对象不会逃逸,那么可以进行一些优化,如在栈上分配对象,减少堆内存的使用和垃圾回收的压力。
  • 示例代码:
public class Main {
    public static void main(String[] args) {
        LocalObject localObj = createLocalObject();
        // 使用局部对象
        System.out.println(localObj.getValue());
    }
    public static LocalObject createLocalObject() {
        LocalObject obj = new LocalObject();
        obj.setValue(10);
        return obj;
    }
}
class LocalObject {
    private int value;
    public void setValue(int value) {
        this.value = value;
    }
    public int getValue() {
        return this.value;
    }
}
  • 优化过程:编译器通过逃逸分析发现createLocalObject方法中创建的obj对象只在main方法中被使用,没有逃逸出main方法的范围。理论上可以在栈上分配obj的内存,当main方法执行结束后,obj所占用的内存自动回收,不需要进行垃圾回收操作,从而提高性能。不过,实际的 JVM 实现会根据自身的策略来决定是否进行这种优化。
8、锁优化(Lock Optimization)
  • 偏向锁(Biased Locking)
    • 原理:当一个线程第一次访问同步块时,JVM 会将对象头中的偏向锁标志位设置为该线程的 ID,表示这个锁偏向于这个线程。在后续这个线程再次访问这个同步块时,不需要进行传统的加锁和解锁操作,直接进入同步块,提高性能。只有当有其他线程竞争这个锁时,偏向锁才会升级为轻量级锁或者重量级锁。
    • 示例代码:
public class Main {
    private static final Object lock = new Object();
    public static void main(String[] args) {
        // 单线程多次获取锁
        for (int i = 0; i < 10; i++) {
            synchronized (lock) {
                System.out.println("Thread - " + Thread.currentThread().getName() + " - " + i);
            }
        }
    }
}
  • 优化过程:在这个单线程场景下,当第一次进入synchronized块时,lock对象被设置为偏向当前线程。之后的 9 次循环,因为是同一个线程访问,所以可以直接进入同步块,避免了额外的加锁和解锁开销。
  • 轻量级锁(Lightweight Lock)
    • 原理:在没有多线程竞争或者竞争程度较低的情况下使用。当一个线程尝试获取一个没有被锁定的对象的轻量级锁时,它会通过 CAS(Compare - And - Swap)操作将对象头中的部分信息替换为自己的线程 ID,表示已经获取了轻量级锁。如果在这个过程中发现对象已经被其他线程轻量级锁定,那么就会尝试自旋等待,而不是直接升级为重量级锁,避免线程上下文切换带来的开销。
    • 示例代码:
public class Main {
    private static final Object lock = new Object();
    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            synchronized (lock) {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        Thread thread2 = new Thread(() -> {
            synchronized (lock) {
                System.out.println("Thread2 acquired the lock");
            }
        });
        thread1.start();
        thread2.start();
    }
}
  • 优化过程:当thread1先获取锁并进入睡眠状态时,lock对象被轻量级锁定。thread2尝试获取锁时,发现对象已经被锁定,会尝试自旋等待一段时间,看是否能获取锁。如果在自旋等待期间thread1释放了锁,thread2就可以通过 CAS 操作获取轻量级锁,避免了直接升级为重量级锁和相关的上下文切换开销。
  • 锁粗化(Lock Coarsening)
    • 原理:在一系列连续的对同一个对象加锁和解锁操作中,如果中间没有其他线程访问这个对象的临界区,编译器会将这些小的同步块合并成一个大的同步块,减少加锁和解锁的次数,降低开销。
    • 示例代码:
public class Main {
    private static final Object lock = new Object();
    public static void main(String[] args) {
        for (int i = 0; i < 10; i++) {
            synchronized (lock) {
                System.out.println(i);
            }
        }
    }
}
  • 优化过程:编译器可能会将这 10 个小的同步块合并为一个大的同步块,将循环体整个包含在一个synchronized块中。这样就只需要进行一次加锁和一次解锁操作,而不是 10 次加锁和 10 次解锁,减少了锁操作的开销。
  • 锁消除(Lock Elimination)
    • 原理:编译器通过逃逸分析等手段,判断在某些情况下,一个对象的锁是不必要的,然后将这个锁消除。例如,如果一个对象只在一个线程中被访问,那么对这个对象的同步操作是多余的,编译器可以将锁消除。
    • 示例代码:
public class Main {
    public static void main(String[] args) {
        StringBuffer buffer = new StringBuffer();
        buffer.append("Hello");
        buffer.append(" World");
        System.out.println(buffer.toString());
    }
}
  • 优化过程:在早期的 Java 版本中,StringBufferappend方法是同步方法。但在这个例子中,buffer对象只在main线程中使用,没有逃逸。编译器通过逃逸分析发现这个情况后,可能会消除append方法中的锁操作,提高性能。
9、寄存器分配(Register Allocation)
  • 原理:寄存器是 CPU 内部的高速存储单元,比内存访问速度快得多。编译器会尝试将频繁使用的变量分配到寄存器中,以减少内存访问次数,提高程序的执行速度。这涉及到变量的活跃度分析和寄存器分配算法。
  • 示例代码(简单示意):
public class Main {
    public static void main(String[] args) {
        int a = 5;
        int b = 3;
        int c = a + b;
        System.out.println(c);
    }
}
  • 优化过程(简单想象):编译器可能会将变量ab分配到寄存器中。在计算c = a + b时,直接从寄存器中读取ab的值进行加法运算,而不是从内存中读取。这样可以加快运算速度,因为寄存器的访问速度比内存快很多。不过,实际的寄存器分配是一个复杂的过程,受到寄存器数量、变量的使用频率和范围等多种因素的影响。
10、指令重排(Instruction Reordering)
  • 原理:在不改变单线程程序语义的前提下,编译器和处理器为了提高程序的运行效率,可能会对指令进行重排,以更好地利用处理器的资源,例如让处理器的各个执行单元能够更充分地工作,减少等待时间,或者优化指令在缓存中的布局,提高缓存命中率。但在多线程环境下,指令重排可能会导致数据不一致等问题。
  • 示例代码:
public class Main {
    private static int x = 0;
    private static int y = 0;
    private static int a = 0;
    private static int b = 0;
    public static void main(String[] args) {
        Thread thread = new Thread(() -> {
            a = 1;
            x = b;
        });
        Thread thread2 = new Thread(() -> {
            b = 1;
            y = a;
        });
        thread.start();
        thread2.start();
        try {
            thread.join();
            thread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("x = " + x + ", y = " + y);
    }
}
  • 优化过程:由于指令重排,a = 1x = b以及b = 1y = a的执行顺序可能会被打乱。可能出现的一种情况是,在第一个线程中,x = b先执行,此时b还没有被赋值为 1,所以x可能为 0。在第二个线程中,y = a先执行,此时a还没有被赋值为 1,所以y也可能为 0。最终输出的xy的值可能是(0,0),这与我们直观上认为的顺序执行结果可能不同。在多线程环境下,为了避免指令重排带来的问题,可以使用volatile关键字或者锁机制来保证指令的顺序执行。

总结

Java编译器(包括javac编译器和JIT编译器)会运用多种优化策略来提高程序的执行效率。这些优化策略包括但不限于常量折叠、死代码消除、方法内联、循环展开、逃逸分析、锁优化、寄存器分配和指令重排。通过这些优化,Java程序可以在保持语义不变的前提下获得更好的性能。

7、对 ZGC 的了解,使用场景

ZGC概述

ZGC(The Z Garbage Collector)是JDK 11中推出的一款低延迟垃圾回收器,它的设计目标包括:

  • 停顿时间不超过10ms;
  • 停顿时间不会随着堆的大小,或者活跃对象的大小而增加(对程序吞吐量影响小于15%);
  • 支持8MB~4TB级别的堆(未来支持16TB)。

从设计目标来看,我们知道ZGC适用于大内存低延迟服务的内存管理和回收。本文主要介绍ZGC在低延时场景中的应用和卓越表现,文章内容主要分为四部分:

  1. GC之痛:介绍实际业务中遇到的GC痛点,并分析CMS收集器和G1收集器停顿时间瓶颈;
  2. ZGC原理:分析ZGC停顿时间比G1或CMS更短的本质原因,以及背后的技术原理;
  3. ZGC调优实践:重点分享对ZGC调优的理解,并分析若干个实际调优案例;
  4. 升级ZGC效果:展示在生产环境应用ZGC取得的效果。

ZGC调优实践

ZGC不是“银弹”,需要根据服务的具体特点进行调优。网络上能搜索到实战经验较少,调优理论需自行摸索,最终才达到理想的性能。本文的一个目的是列举一些使用ZGC时常见的问题,帮助大家使用ZGC提高服务可用性。

调优基础知识

理解ZGC重要配置参数

重要参数配置样例:

-Xms10G -Xmx10G 
-XX:ReservedCodeCacheSize=256m -XX:InitialCodeCacheSize=256m 
-XX:+UnlockExperimentalVMOptions -XX:+UseZGC 
-XX:ConcGCThreads=2 -XX:ParallelGCThreads=6 
-XX:ZCollectionInterval=120 -XX:ZAllocationSpikeTolerance=5 
-XX:+UnlockDiagnosticVMOptions -XX:-ZProactive 
-Xlog:safepoint,classhisto*=trace,age*,gc*=info:file=/opt/logs/logs/gc-%t.log:time,tid,tags:filecount=5,filesize=50m
  • -Xms -Xmx:堆的最大内存和最小内存,这里都设置为10G,程序的堆内存将保持10G不变。
  • -XX:ReservedCodeCacheSize -XX:InitialCodeCacheSize:设置CodeCache的大小, JIT编译的代码都放在CodeCache中,一般服务64m或128m就已经足够。我们的服务因为有一定特殊性,所以设置的较大,后面会详细介绍。
  • -XX:+UnlockExperimentalVMOptions -XX:+UseZGC:启用ZGC的配置。
  • -XX:ConcGCThreads:并发回收垃圾的线程。默认是总核数的12.5%,8核CPU默认是1。调大后GC变快,但会占用程序运行时的CPU资源,吞吐会受到影响。
  • -XX:ParallelGCThreads:STW阶段使用线程数,默认是总核数的60%。
  • -XX:ZCollectionInterval:ZGC发生的最小时间间隔,单位秒。
  • -XX:ZAllocationSpikeTolerance:ZGC触发自适应算法的修正系数,默认2,数值越大,越早的触发ZGC。
  • -XX:+UnlockDiagnosticVMOptions -XX:-ZProactive:是否启用主动回收,默认开启,这里的配置表示关闭。
  • -Xlog:设置GC日志中的内容、格式、位置以及每个日志的大小。
理解ZGC触发时机

相比于CMS和G1的GC触发机制,ZGC的GC触发机制有很大不同。ZGC的核心特点是并发,GC过程中一直有新的对象产生。如何保证在GC完成之前,新产生的对象不会将堆占满,是ZGC参数调优的第一大目标。因为在ZGC中,当垃圾来不及回收将堆占满时,会导致正在运行的线程停顿,持续时间可能长达秒级之久。

ZGC有多种GC触发机制,总结如下:

  • 阻塞内存分配请求触发:当垃圾来不及回收,垃圾将堆占满时,会导致部分线程阻塞。我们应当避免出现这种触发方式。日志中关键字是“Allocation Stall”。
  • 基于分配速率的自适应算法:最主要的GC触发方式,其算法原理可简单描述为”ZGC根据近期的对象分配速率以及GC时间,计算出当内存占用达到什么阈值时触发下一次GC”。自适应算法的详细理论可参考彭成寒《新一代垃圾回收器ZGC设计与实现》一书中的内容。通过ZAllocationSpikeTolerance参数控制阈值大小,该参数默认2,数值越大,越早的触发GC。我们通过调整此参数解决了一些问题。日志中关键字是“Allocation Rate”。
  • 基于固定时间间隔:通过ZCollectionInterval控制,适合应对突增流量场景。流量平稳变化时,自适应算法可能在堆使用率达到95%以上才触发GC。流量突增时,自适应算法触发的时机可能会过晚,导致部分线程阻塞。我们通过调整此参数解决流量突增场景的问题,比如定时活动、秒杀等场景。日志中关键字是“Timer”。
  • 主动触发规则:类似于固定间隔规则,但时间间隔不固定,是ZGC自行算出来的时机,我们的服务因为已经加了基于固定时间间隔的触发机制,所以通过-ZProactive参数将该功能关闭,以免GC频繁,影响服务可用性。 日志中关键字是“Proactive”。
  • 预热规则:服务刚启动时出现,一般不需要关注。日志中关键字是“Warmup”。
  • 外部触发:代码中显式调用System.gc()触发。 日志中关键字是“System.gc()”。
  • 元数据分配触发:元数据区不足时导致,一般不需要关注。 日志中关键字是“Metadata GC Threshold”。

升级ZGC效果

延迟降低

TP(Top Percentile)是一项衡量系统延迟的指标:TP999表示99.9%请求都能被响应的最小耗时;TP99表示99%请求都能被响应的最小耗时。

在不同集群中,ZGC在低延迟(TP999 < 200ms)场景中收益较大:

  • TP999:下降12142ms,下降幅度18%74%。
  • TP99:下降528ms,下降幅度10%47%。

超低延迟(TP999 < 20ms)和高延迟(TP999 > 200ms)服务收益不大,原因是这些服务的响应时间瓶颈不是GC,而是外部依赖的性能。

吞吐下降

对吞吐量优先的场景,ZGC可能并不适合。例如,某离线集群原先使用CMS,升级ZGC后,系统吞吐量明显降低。究其原因有二:

  • 第一,ZGC是单代垃圾回收器,而CMS是分代垃圾回收器。单代垃圾回收器每次处理的对象更多,更耗费CPU资源;
  • 第二,ZGC使用读屏障,读屏障操作需耗费额外的计算资源。

总结

ZGC作为下一代垃圾回收器,性能非常优秀。ZGC垃圾回收过程几乎全部是并发,实际STW停顿时间极短,不到10ms。这得益于其采用的着色指针和读屏障技术。

Zeus在升级JDK 11+ZGC中,通过将风险和问题分类,然后各个击破,最终顺利实现了升级目标,GC停顿也几乎不再影响系统可用性。

更多可参考:GC - ZGC详解

8、简单描述 volatile 可以解决什么问题?如何做到的?

volatile 是 Java 中的一个关键字,用于修饰变量,它提供了一种轻量级的同步机制。volatile 可以解决以下几个问题:

1.1、 可见性问题

当一个线程修改了一个 volatile 变量的值,其他线程能够立即看到这个修改。这是因为 volatile 变量的写操作会立即被写入主内存,而读操作总是从主内存中读取最新的值,而不是从线程的本地缓存(工作内存)中读取。

1.2、 禁止指令重排序

在 Java 内存模型中,为了提高性能,编译器和处理器可能会对指令进行重排序。但是,这种重排序可能会导致多线程程序出现意外的结果。volatile 关键字通过添加内存屏障(Memory Barrier)来禁止这种重排序,从而确保程序的正确性。

如何做到的?

volatile 关键字通过以下方式实现上述功能:

  1. 写操作的内存屏障:当一个线程对 volatile 变量进行写操作时,会先执行一个 StoreStore 内存屏障,确保在此之前的所有普通变量的写操作都完成,并且这些写操作的结果已经刷新到主内存。然后,volatile 变量的写操作才会被执行,并且其结果也会被刷新到主内存。
  2. 读操作的内存屏障:当一个线程对 volatile 变量进行读操作时,会先执行一个 LoadLoad 内存屏障,确保在此之后的所有普通变量的读操作都从主内存中读取最新的值,而不是从本地缓存中读取。然后,volatile 变量的读操作才会被执行,并且其结果会被加载到本地缓存。

通过这种方式,volatile 关键字确保了变量的可见性和禁止指令重排序,从而解决了多线程编程中的一些常见问题。

需要注意的是,volatile 并不能解决所有的并发问题,例如它不能保证复合操作的原子性(如 i++),在这种情况下,需要使用 synchronized 关键字或者 java.util.concurrent 包中的原子类。

另一种回答方式:

2.1、解决可见性问题

当一个变量被声明为volatile时,表明该变量可能会被多个线程同时访问,并且确保每个线程都能够看到最新的值。这是通过一系列的内存屏障(Memory Barrier)指令实现的。在多线程环境下,每个线程运行时可能会将共享变量从主内存复制到线程本地内存中。如果一个变量被volatile修饰,那么当一条线程修改了这个变量的值,新的值会被立即写回到主内存中。同时,当其他线程要读取这个变量时,它会直接从主内存中读取新值,而不是线程本地内存中的旧值。这样保证了volatile变量修改对所有线程的立即可见性。

2.2、解决顺序性问题

volatile还可以保证变量的读写操作是按照编写的顺序进行的,避免了指令重排导致的问题。在现代的计算机中,为了提高效率,处理器和JVM可能会对指令进行重排序。然而,volatile关键字可以防止这种重排序,确保程序执行的有序性。具体到底层实现,volatile变量的读写操作会伴随着内存屏障指令。在读操作前,会插入LoadLoad内存屏障,保证之后的读取操作获取到的是最新数据。同时,在写操作后,会插入StoreStore的内存屏障,确保写入操作对其他读操作可见。在volatile变量写操作后,还会插入StoreLoad的内存屏障,确保前面的操作(如变量的写入)在后面的读操作发布之前完成,从而维护了操作的有序性。

注意事项

虽然volatile提供了变量的内存可见性与有序性保障,但它仍然是比较轻量级的同步策略。volatile适合一个线程写、多个线程读的情况,可以保证读操作能即时看到最新的值。然而,volatile并不能保证原子性,即当有多个线程同时对一个volatile变量进行写操作时,可能会出现数据不一致的情况。此时,仍然需要通过锁等同步手段来确保数据的一致性和完整性。

综上所述,volatile通过内存屏障和直接对主内存进行操作来实现变量的内存可见性和操作的有序性,从而解决了多线程并发访问共享变量时的可见性和顺序性问题。

Java的内存屏障:Java四种内存屏障详解,LoadLoad、LoadStore、StoreLoad、StoreStore

9、简单描述 GC 的分代回收

就目前来说,JVM 的垃圾收集器主要分为两大类:分代收集器和分区收集器,分代收集器的代表是 CMS,分区收集器的代表是 G1 和 ZGC,下面主要讲解下分代收集。

  • 分代收集器:Serial、ParNew、Parallel Scavenge、CMS、Serial Old(MSC)、Parallel Old
  • 分区收集器:G1、ZGC、Shenandoah

如上图所示,两者之间存在连线则代表两个GC收集器可以搭配使用,所以一共存在六种搭配方案:

新生代 年老代
Serial CMS(主用)/Serial Old(备用)
Serial Serial Old(MSC)
ParNew CMS(主用)/Serial Old(备用)
ParNew Serial Old(MSC)
Parallel Scavenge Serial Old(MSC)
Parallel Scavenge Parallel Old

在上表中,可以看到CMS是可以和MSC搭配的,关于具体为何我们后续分析,也包括为什么Parallel Scavenge不能和CMS进行搭配,后续分析完GC收集器实现后再阐述。

JVM中的分代GC收集器,除开被划分为新生代和年老代外,也会根据其收集过程,分为单线程和多线程属性的收集器。其中Serial、Serial Old(MSC)属于单线程的收集器,而ParNew、Parallel Scavenge、CMS、Parallel Old则属于并发型的多线程收集器。但接下来我们会从分代角度出发,对GC收集器进行全面阐述。

1、新生代GC收集器详解

前面提到过新生代收集器主要包含Serial、ParNew、Parallel Scavenge,首先来看看作用于新生代的Serial收集器。

1.1、Serial收集器(单线程)

Serial是最原始的新生代收集器,同时它属于单线程的GC收集器,所以也被称为串行收集器。顾名思义,它在执行GC工作时,是以单线程运行的,并且该收集器在发生GC时,会产生STW,也就是会停止所有用户线程。但正由于会停止其他用户线程,所以在执行GC时并不会出现线程间的切换。因此,在单颗CPU的机器上,它的清理效率非常高。一般来说,采用Client模式运行的JVM,选取该款收集器作为内嵌GC是个不错的选择。

Serial收集器小结:
启动参数:-XX:+UseSerialGC(开启该参数后,年老代会使用MSC)。
收集动作:串行GC,单线程。
采用算法:复制算法。
STW:GC过程在STW中执行。
GC发生时,执行过程如下:

Serial收集器执行过程

因为该款收集器GC过程中是需要全程发生在STW中的,所以基于系统层面来说,对用户体验感欠佳。就好比你在线看片(指电影),看两分钟转几圈,看一段时间后又看圈,反反复复的卡顿…,对于你而言,这显然一件令人难以接受的事情。

1.2、ParNew收集器(多线程)

ParNew收集器是基于Serial收集器的演进版,从严格意义上来看,它可以被称为Serial收集器的多线程版本,同样是作用于新生代区域的收集器。在整个实现上,除开GC收集阶段会使用多条线程回收外,其他实现几乎与Serial收集器大致相同。

ParNew收集器小结:
启动参数:-XX:+UseParNewGC
收集动作:并行GC,多线程。
采用算法:复制算法。
STW:GC过程发生在STW中,采用多线程回收。
GC发生时,执行过程如下:

ParNew收集器执行过程

因为该款收集器与Serial唯一的不同点就在于使用了多线程,所以GC发生时仍旧会造成程序停顿。但也因为使用了多线程回收,因此能够在很大程度上缩短系统的停顿时间,从而能够带来比Serial更好的用户体验。

但该款GC收集器因为采用了多线程,所以需要多核CPU的支持,该收集器会根据CPU核数,开启不同的GC线程数,从而达到最优的垃圾回收效果(也可以通过-XX:ParallelGCThreads参数指定)。但如若是单核的机器上运行时,其效率可能还不如Serial

一般如果你的程序是以Server模式运行的程序,而老年代又采用了CMS收集器,那么新生代搭配ParNew是个不错的选择。

1.3、Parallel Scavenge收集器(多线程)

Parallel Scavenge同样是一款作用于新生代的多线程GC收集器,但与ParNew收集器不同的是:ParNew通过控制GC线程数量来缩短程序暂停时间,更关心程序的响应时间,而Parallel Scavenge更关心的是程序运行的吞吐量,也就是更注重一段时间内,用户代码执行时长与程序执行总时长的占比。

Parallel Scavenge收集器小结:
启动参数:-XX:+UseParallelGC
收集动作:并行GC,多线程。
采用算法:复制算法。
STW:GC过程发生在STW中,采用多线程回收。
GC发生时,执行过程如下:

Parallel Scavenge收集器执行过程

从上述小结来看,PS收集器和ParNew收集器好像并未有太大的区别。但实际上它们两者之间基于的底层GC框架完全不同,同时关注的方向也完全不同。PS收集器的目标是让程序达到一个可控制的吞吐量(Throughput),所以PS也被称为吞吐量优先的垃圾收集器。

PS收集器可以通过-XX:MaxGCPauseMillis-XX:GCTimeRatio参数精准控制GC发生时的时间以及吞吐量占比。同时与ParNew收集器最大的不同在于:PS收集器还可以通过开启-XX:+UseAdaptiveSizePolicy参数,让JVM启动自适应的GC调节策略,开启该参数后,JVM会根据当前系统的运行状态调整吞吐比与GC时间,从而确保能够提供最合适的停顿时间和吞吐量。

  • 那如果使用PS收集器的时候,我们通过参数手动将GC时间设的很小,然后将吞吐占比设的很高,岂不是GC回收会变得非常完美?
  • 答案是:并非如此。因为在追求响应时间的时候必然会牺牲吞吐量,而追求吞吐量的同时必然会牺牲响应时间。好比你通过参数将GC时间设置的很小,那么PS在运行时会将新生代空间调小,如从原本的1GB调整到800MB,收集800MB的空间必然速度会比1GB的快很多。但与之相对应的收集频率会增高,可能原本原来60s收集一次,每次收集停顿100ms,而现如今内存被调小后,40s就要发生一次GC,每次GC停顿80ms,你可以对比这两者之间的区别:
  • 24min/1GB空间-GC开销:(24min/60s)*100ms=24000ms
  • 24min/800MB空间-GC开销:(24min/40s)*80ms=28800ms
  • 因此,最终可以得到一个结果,虽然响应时间确实降低了,但吞吐量也降了下来了。

所以一般线上情况,对于调优没有丰富经验的情况下,我们不应该自己去手动调整这些参数,而是开启JVM的自适应策略,由JVM自行调整。

2、年老代GC收集器详解

年老代收集器主要有CMS、Serial Old(MSC)、Parallel Old三款,与新生代的收集器一样,同样存在单线程和多线程收集器之分,接下来我们对年老代收集器进行依次分析。

2.1、Serial Old(MSC)收集器(单线程)

Serial Old(MSC)Serial收集器相同,同样是一款单线程串行回收的收集器,但不同的是:MSC是一款作用于年老代空间的收集器,它采用标记-整理算法对年老代空间进行回收。同时,该款收集器也可作为CMS的备用收集器使用。

Serial Old(MSC)收集器小结:
启动参数:-XX:+UseSerialGC(开启该参数后,新生代会使用Serial)。
收集动作:串行GC,单线程。
采用算法:标记-整理算法。
STW:GC过程发生在STW中,采用单线程执行串行回收。
GC发生时,执行过程如下:

Serial Old(MSC)收集器执行过程

Serial Old(MSC)与新生代收集器Serial差距不大,回收过程也是采用单线程做串行收集,属于Serial的年老代版本。

2.2、Parallel Old收集器(多线程)

Parallel Old则是Parallel Scavenge收集器的年老代版本,同样采用多线程进行并行收集,其内部采用标记-整理算法。与新生代的PS收集器相同的是:PO同样追求的是吞吐量优先。

Parallel Old收集器小结:
启动参数:-XX:+UseParallelOldGC
收集动作:并行GC,多线程。
采用算法:标记-整理算法。
STW:GC过程发生在STW中,采用多线程回收。
GC发生时,执行过程如下:

Parallel Old收集器执行过程

PO作为PS收集器的年老代版本,其特性与PS大致相同,所以该款收集器同样适用于注重吞吐量或对CPU资源敏感的系统。

2.3、CMS收集器(多线程/并发)

以获取最短回收停顿时间为目标,采用“标记-清除”算法,分 4 大步进行垃圾收集,其中初始标记和重新标记会 STW,JDK 1.5 时引入,JDK9 被标记弃用,JDK14 被移除,详情可见 JEP 363

CMS(Concurrent Mark Sweep)垃圾收集器是第一个关注 GC 停顿时间(STW 的时间)的垃圾收集器。之前的垃圾收集器,要么是串行的垃圾回收方式,要么只关注系统吞吐量。

CMS 垃圾收集器之所以能够实现对 GC 停顿时间的控制,其本质来源于对「可达性分析算法」的改进,即三色标记算法。在 CMS 出现之前,无论是 Serious 垃圾收集器,还是 ParNew 垃圾收集器,以及 Parallel Scavenge 垃圾收集器,它们在进行垃圾回收的时候都需要 Stop the World,无法实现垃圾回收线程与用户线程的并发执行。

CMS收集器全称为ConcurrentMarkSweep,该款回收器是GC机制中的一座里程碑,在该款收集器中首次实现了并发收集的概念,也就是不停止用户线程,GC线程与用户线程一同工作的情况。同时该款收集器追求的是最短的回收时间,属于多线程收集器,其内部采用标记-清除算法。

CMS收集器小结:
启动参数:-XX:+UseConcMarkSweepGC
收集动作:并发GC,多线程并行执行。
采用算法:标记-清除算法。
STW:GC过程会发生STW,但并非整个GC过程都在STW中执行,采用多线程回收。
GC发生时,执行过程如下:

CMS收集器执行过程

从上面的CMS执行图中可以明确看出,CMS对比其他的GC收集器,回收过程明显复杂很多,CMS收集器的回收工作会分为四个步骤:初始标记、并发标记、重新标记以及并发清除。

  • ①初始标记:仅标记GcRoot节点直接关联的对象,该阶段速度会很快,需在STW中进行。
  • ②并发标记:该阶段主要是做GC溯源工作(GcTracing),从根节点出发,对整个堆空间进行可达性分析,找出所有存活对象,该阶段的GC线程会与用户线程同时执行。
  • ③重新标记:这个阶段主要是为了修正“并发标记”阶段由于用户线程执行造成的GC标记变动的那部分对象,该阶段需要在STW中执行,并且该阶段的停顿时间会比初始阶段要长不少。
  • ④并发清除:在该阶段主要是对存活对象之外的垃圾对象进行清除,该阶段不需要停止用户线程,是并发执行的。

PS:其实在并发标记和重新标记中间存在两步细节操作:预清理以及可终止的预清理。

在整个收集过程中,除开初始标记与重新标记阶段,其他的收集动作都是与用户线程并发执行的。因此,CMS收集器在发生GC时,造成的程序暂停是非常短暂的,对于用户体验感而言,相对比之前的收集器而言是最优者。也正由于CMS收集器并发收集、停顿延迟低的特性,所以在有些地方也被称为并发低停顿收集器。

从如上的总结看来,CMS好像很不错哎~,但实际上,CMS也存在几个致命的缺点:会产生且无法回收浮动垃圾、对CPU资源非常依赖、GC完成后会造成大量内存碎片。

  • ①CMS是一款完全基于多线程环境研发的收集器,默认情况下,回收过程中开启的线程数为(CPU核数+3)/4,也就代表着:一台八核的机器至少要开启2~3条GC线程。而当CPU核数少于4时,CMS的GC线程则会对用户线程性能造成很大影响,因为需要让出一半的CPU运算资源去执行GC回收工作。
  • ②由于CMS收集器的回收工作是并发清除垃圾对象的,因此,在清除阶段用户线程依旧在执行,而用户线程执行就必然会造成新的垃圾产生,但这部分新产生的垃圾对象是无法标记的,所以只能等到下次GC发生时才可回收,而这部分垃圾则被称为“浮动垃圾”。
  • ③因为CMS采用的是标记-清除算法,所以在回收工作结束之后会造成大量的内存碎片。
    • 为何不采用标-整算法呢?因为CMS是并发执行的,所以如果将存活对象压缩到内存一端,那么用户线程中的所有对象引用都需改变,实现起来及其复杂且影响效率。

因为CMS在回收时会产生浮动垃圾以及内存碎片,所以CMS一般来说都必须要要搭配一款其他的收集器作为后备方案,而可选项有且只有一个:那就是Serial Old(MSC),当内存太过碎片化导致无法分配新对象时,或回收一次后存活对象+浮动垃圾占比达到指定阈值时则会触发Serial Old(MSC)收集器回收。
决定着是否触发Serial Old(MSC)的关键参数有三个:

  • -XX:CMSInitIatingOccupancyFaction:需要指定一个百分比,当存活对象+浮动垃圾占比达到该值时会触发MSC工作。
  • XX:UseCMSCompactAtFullCollection:该参数默认开启,当内存太过碎片化导致无法分配新对象时,触发MSC发生FullGC
  • XX:CMSFullGCsBeforeCompaction:该参数可以设置间隔多少次FullGC后发生一次整理内存碎片的FullGCMSC的GC),默认为0,既每次FullGC都会触发MSC回收。

3、分代GC收集器总结

就目前而言,分析过的GC收集器中,根据分代特征,可分为新生代、年老代收集器。基于线程角度出发,则可分为单线程串行、多线程并行收集器。而从关注度来看,又可分为吞吐量优先、响应时间优先两大类。

一般而言,如果你的程序是更为关注用户体验度,那么可以采用响应速度优先的收集器工作,因为该类收集器造成的程序暂停不会很久。但如若你的程序不需要与用户有特别多的交互,如批量处理、订单处理、报表计算、科学计算等类型的后台系统,那你则可以采用吞吐量优先的收集器,因为高吞吐量可以高效率地利用CPU资源。

总结

在JVM的GC体系中,其实并不存在所谓的最好GC器,不同的场景下采用合适的GC收集器,才能在最大程度上追求最优的方案。各款GC收集器对比如下:

GC收集器 GC属性 作用区域 GC算法 特性 应用场景
Serial 串行回收 新生代 复制算法 响应速度优先 单核机器/client程序
Serial Old 串行回收 年老代 标-整算法 响应速度优先 单核机器/client程序
ParNew 并行回收 新生代 复制算法 响应速度优先 交互多/计算少的程序
Parallel Scavenge 并行回收 新生代 复制算法 吞吐量优先 计算多/交互少的程序
Parallel Old 并行回收 年老代 标-整算法 吞吐量优先 计算多/交互少的程序
CMS 并行/并发回收 年老代 标-清算法 响应速度优先 交互多/计算少的程序

10、G1 垃圾回收算法与 CMS 的区别有哪些

区别一:CMS 和 G1 使用的范围不一样:

CMS 收集器是老年代的收集器,通常需要配合新生代的 Serial 和 ParNew 收集器一起使用。而 G1 收集器收集范围是老年代和新生代,无需结合其他收集器使用,它可以同时管理新生代和老年代。G1 将堆内存划分为多个大小相等的区域(Region),每个 Region 都可以根据情况扮演 Eden、Survivor 或 Old 区等不同角色。例如,在 G1 中,如果一个对象的大小超过了一个 Region 的 50%,那么该对象就会被直接存放进 H 区(Humongous regions,巨型对象区域)。而 CMS 则是按照传统的老年代和新生代划分进行垃圾回收。

区别二:CMS 和 G1 STW 的时间差异:

CMS 收集器以最小的停顿时间为目标,致力于减少垃圾收集期间的停顿时间,适用于对延迟敏感的应用程序。但随着应用程序的负载增加,CMS 可能会出现 “Concurrent Mode Failure”,导致 Full GC 停顿时间较长。G1 收集器可预测垃圾回收的停顿时间,它通过建立可预测的停顿时间模型,根据用户设置的垃圾回收时间,优先回收价值最大的 Region。例如,可以通过参数 - XX:MaxGCPauseMillis 来指定期望的回收时间。在初始标记阶段,G1 因为自己有新生代的回收功能,在 minor gc 阶段会触发到初始标记,标记根对象,实际上并不会多出处理时间,所以在这个阶段 G1 可能更快。并发标记阶段,两者耗时都较长且都可以与用户程序并发执行,时间差不多。第三阶段最终标记,也差不多。第四阶段,CMS 的垃圾回收线程与用户线程并行,没有 STW 的时间,在此阶段速度远大于 G1,但 G1 可以更好地规划限制停顿时间,所以两者在 STW 的时间各有优势,不过总体上延迟方面还是 CMS 占优。

区别三: CMS 和 G1 垃圾碎片情况对比

CMS 收集器是使用 “标记 - 清除” 算法进行的垃圾回收,容易产生内存碎片。这可能导致在分配大对象时出现内存不足的情况,从而触发 Full GC。而 G1 收集器使用的是 “标记 - 整理” 算法,进行了空间整合,基本不会产生内存碎片。例如,在 G1 中,通过在标记阶段记录每个区域的存活对象信息,以及在垃圾收集阶段将存活对象移动到空闲的区域,避免了内存碎片的问题。这使得 G1 能够更好地应对大量分配和回收对象的场景,减少 Full GC 的频率。

区别四:CMS 和 G1 垃圾回收过程比较

CMS 垃圾回收整体分为四个阶段:初始标记、并发标记、重新标记、并发清理。初始标记阶段会让线程全部停止,仅仅只是标记出根节点能直接关联的对象,速度很快但会短暂停顿。并发标记阶段对所有对象进行追踪,耗时较长但与系统并发运行。重新标记阶段由于并发标记期间对象变化,需再次标记变动的少数对象,会进入 STW 状态但比初始标记时间长比并发标记短。并发清理阶段清除标记的死亡对象,与用户线程并发执行,耗时较长但不影响系统运行太大。G1 垃圾回收也分为四个阶段:初始标记、并发标记、最终标记、筛选回收。初始标记阶段与 CMS 类似,会让线程全部停止,标记 GC Roots 可以直接关联的对象,耗时短。并发标记从 GC Roots 直接关联对象开始可达性分析,耗时很长,并发执行。最终标记处理并发标记阶段留下的少量 SATB 记录,将断开的白色对象置为灰色,让 GC 线程重新扫描这些对象,需 STW。筛选回收负责更新 Region 的统计数据,对各个 Region 的回收价值和成本进行排序,根据用户预期停顿时间选择多个 Region 构成回收集进行回收,需 STW,GC 线程并行将存活对象复制到空闲状态的 Region 中。

区别五:CMS 和 G1 内存占用对比

CMS 垃圾回收器在执行垃圾回收时,会暂停应用程序的执行,相对来说内存占用较低。而 G1 垃圾回收器虽然不会暂停应用程序的执行,但它需要更多的内存来存储堆空间中的对象。例如,G1 将堆内存划分为多个 Region,需要维护每个 Region 的状态信息以及相关的数据结构,这会占用一定的内存空间。然而,在实际应用中,具体的内存占用情况还会受到多种因素的影响,如堆的大小、对象的分布、垃圾回收的频率等。

区别六:CMS 和 G1 垃圾收集方式不同

CMS 收集器使用标记清除算法,标记出垃圾对象后进行清除。这种算法在老年代执行,由于新生代产生无法接受该算法产生的碎片垃圾。G1 收集器采用分代收集的概念,将整个内存区域划分为若干个大小相等的 Region,每个 Region 都能扮演 Eden、Survivor、Old 区等角色。在垃圾回收过程中,G1 会对各个 Region 的回收价值进行量化和排序,根据用户期望的 GC 停顿时间来制定回收计划。例如,在并发标记阶段,G1 会记录每个 Region 的存活对象信息,以便在筛选回收阶段能够更准确地选择回收价值最大的 Region 进行回收。同时,G1 在老年代使用标记整理算法,避免了内存碎片的产生。

CMS 垃圾回收器和 G1 垃圾回收器在使用范围、STW 时间、垃圾碎片情况、垃圾回收过程、内存占用以及垃圾收集方式等方面都存在着不同。在实际应用中,应根据具体的应用场景和需求来选择合适的垃圾回收器。如果对延迟要求非常高,且堆内存不是特别大的情况下,可以考虑使用 CMS 收集器。而如果需要更好地控制停顿时间、避免内存碎片问题,并且能够接受一定的内存占用增加,那么 G1 收集器可能是更好的选择。

G1 收集器的最大特点

  • G1 最大的特点是引入分区的思路,弱化了分代的概念。
  • 并行与并发:G1 能充分利用 CPU、多核环境下的硬件优势,使用多个 CPU(CPU 或者 CPU 核心)来缩短 Stop-The-World 停顿时间。部分其他收集器原本需要停顿 Java 线程执行的 GC 动作,G1 收集器仍然可以通过并发的方式让 Java 程序继续执行。
  • 空间整合:与 CMS 的“标记-清除”算法不同,G1 从整体来看是基于“标记-整理”算法实现的收集器,不会产生空间碎片;从局部上来看是基于“标记-复制”算法实现的。
  • 可预测的停顿:G1垃圾回收器设定了用户可控的停顿时间目标,开发者可以通过设置参数来指定允许的最大垃圾回收停顿时间。G1会根据这个目标来动态调整回收策略,尽可能地减少长时间的垃圾回收停顿。

如何完成可预测的停顿?

G1根据历史数据来预测本次回收需要的堆分区数量,也就是选择回收哪些内存空间。最简单的方法就是使用算术的平均值建立一个线性关系来进行预测。比如:过去10次一共收集了10GB的内存,花费了1s。那么在200ms的时间下,最多可以收集2GB的内存空间。而G1的预测逻辑是基于衰减平均值和衰减标准差来确定的。

JVM篇-说下你对G1垃圾收集器的理解?

11、对象引用有哪几种方式,有什么特点?

引自:GC - 理论基础

引用类型

四个引用的特点:

  • 强引用:gc时不会回收
  • 软引用:只有在内存不够用时,gc才会回收
  • 弱引用:只要gc就会回收
  • 虚引用:是否回收都找不到引用的对象,仅用于管理直接内存

强引用

平时常见的

Object object = new Object();

只要一个对象有强引用,垃圾回收器就不会进行回收。即便内存不够了,抛出OutOfMemoryError异常也不会回收。因此强引用是造成java内存泄漏的主要原因之一。 对于一个普通的对象,如果没有其他的引用关系,只要超过了引用的作用域或者显式地将相 应(强)引用赋值为 null,就是可以被垃圾收集的了,具体回收时机还是要看垃圾收集策略。

/**
 * 一个对象
 * 重写finalize方法,可以知道已经被回收的状态
 */
public class OneObject {
    @Override
    protected void finalize() throws Throwable {
        System.out.println("啊哦~OneObject被回收了");
    }
}

/**
 * 强引用例子
 */
public class ShowStrongReference {
    public static void main(String[] args) {
        // 直接new一个对象,就是强引用
        OneObject oneObject = new OneObject();
        System.out.println("输出对象地址:" + oneObject);
        System.gc();
        System.out.println("第一次gc后输出对象地址:" + oneObject);
        oneObject = null;
        System.gc();
        System.out.println("置为null后gc输出对象地址:" + oneObject);
    }
}

//输出:
输出对象地址:com.tyron.learngc.references.OneObject@448139f0
第一次gc后输出对象地址:com.tyron.learngc.references.OneObject@448139f0
置为null后gc输出对象地址:null
啊哦~OneObject被回收了

软引用

特点:软引用通过java.lang.SoftReference类实现。只有在内存不够用时,gc才会回收

软引用的生命周期比强引用短一些。只有当 JVM 认为内存不足时,才会去试图回收软引用指向的对象:即JVM 会确保在抛出 OutOfMemoryError 之前,清理软引用指向的对象。软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被垃圾回收器回收,Java虚拟机就会把这个软引用加入到与之关联的引用队列中。后续,我们可以调用ReferenceQueue的poll()方法来检查是否有它所关心的对象被回收。如果队列为空,将返回一个null;否则该方法返回队列中前面的一个Reference对象。

SoftReference<OneObject> oneObjectSr = new SoftReference<>(new OneObject());

当内存足够的时候,垃圾回收器不会进行回收。当内存不够时,就会回收只存在软引用的对象释放内存。

常用于本地缓存处理。

/**
 * 软引用
 * 内存不够了就会回收
 * 注意,运行时需要保证heap大小为35m,即小于实验中全部对象的大小,才能触发gc
 * -Xmx35m
 *
 */
public class ShowSoftReference {
    public static void main(String[] args) {
        // 我们需要通过SoftReference来创建软引用
        SoftReference<OneObject> oneObjectSr = new SoftReference<>(new OneObject());
        // 我们这里创建一个大小为20m的数组
        SoftReference<byte[]> arraySr = new SoftReference<>(new byte[1024 * 1024 * 20]);
        System.out.println("软引用对象oneObjectSr的地址:" + oneObjectSr);
        System.out.println("通过oneObjectSr关联的oneObject对象的地址:" + oneObjectSr.get());
        System.out.println("数组的地址:" + arraySr);
        System.out.println("通过arraySr关联的byte数组的地址:" + arraySr.get());
        System.gc();
        System.out.println("正常gc一次之后,oneObject对象并没有回收。地址" + oneObjectSr.get());

        // 再创建另一个大小为20m的数组,这样heap就不够大了,从而系统自动gc。如果依旧不够,会把已有的软引用关联的对象都回收掉。
        System.out.println("创建另一个大小为20m的数组otherArray");
        byte[] otherArray = new byte[1024 * 1024 * 20];
        System.out.println("otherArray的地址:" + otherArray);

        // gc后,软引用对象还在,但是通过软引用对象创建的对象就被回收了
        System.out.println("现在软引用对象oneObjectSr的地址:" + oneObjectSr);
        System.out.println("通过oneObjectSr关联的oneObject对象的地址:" + oneObjectSr.get());
        System.out.println("现在数组的地址:" + arraySr);
        System.out.println("现在arraySr中关联的byte数组的地址:" + arraySr.get());
    }
}

执行代码,可以看到以下输出:

软引用对象oneObjectSr的地址:java.lang.ref.SoftReference@135fbaa4
通过oneObjectSr关联的oneObject对象的地址:com.tyron.learngc.references.OneObject@45ee12a7
数组的地址:java.lang.ref.SoftReference@330bedb4
通过arraySr关联的byte数组的地址:[B@2503dbd3
正常gc一次之后,oneObject对象并没有回收。地址com.tyron.learngc.references.OneObject@45ee12a7
创建另一个大小为20m的数组otherArray
啊哦~OneObject被回收了
otherArray的地址:[B@4b67cf4d
现在软引用对象oneObjectSr的地址:java.lang.ref.SoftReference@135fbaa4
通过oneObjectSr关联的oneObject对象的地址:null
现在数组的地址:java.lang.ref.SoftReference@330bedb4
现在arraySr中关联的byte数组的地址:null

弱引用

特点:弱引用通过WeakReference类实现。只要gc就会回收

弱引用的生命周期比软引用短。在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。由于垃圾回收器是一个优先级很低的线程,因此不一定会很快回收弱引用的对象。弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被垃圾回收,Java虚拟机就会把这个弱引用加入到与之关联的引用队列中。

WeakReference<OneObject> oneObjectWr = new WeakReference<>(new OneObject());

只要发生gc,就会回收只存在弱引用的对象。

常用于Threadlocal。

/**
 * 弱引用
 * 只要gc就会回收
 */
public class ShowWeakReference {
    public static void main(String[] args) {
        // 我们需要通过WeakReference来创建弱引用
        WeakReference<OneObject> objectWr = new WeakReference<>(new OneObject());
        System.out.println("弱引用objectWr的地址:" + objectWr);
        System.out.println("弱引用objectWr关联的oneObject对象的地址:" + objectWr.get());

        System.gc();

        // gc后,弱引用对象还在,但是通过弱引用对象创建的对象就被回收了
        System.out.println("gc后,弱引用objectWr的地址:" + objectWr);
        System.out.println("gc后,弱引用objectWr关联的oneObject对象的地址:" + objectWr.get());
    }
}

执行代码,可以看到以下输出:

弱引用objectWr的地址:java.lang.ref.WeakReference@448139f0
弱引用objectWr关联的oneObject对象的地址:com.tyron.learngc.references.OneObject@7cca494b
gc后,弱引用objectWr的地址:java.lang.ref.WeakReference@448139f0
gc后,弱引用objectWr关联的oneObject对象的地址:null
啊哦~OneObject被回收了

虚引用

特点:虚引用也叫幻象引用,通过PhantomReference类来实现。是否回收都找不到引用的对象,仅用于管理直接内存

无法通过虚引用访问对象的任何属性或函数。幻象引用仅仅是提供了一种确保对象被 finalize 以后,做某些事情的机制。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收器回收。虚引用必须和引用队列 (ReferenceQueue)联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。 程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。如果程序发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取一些程序行动。

private ReferenceQueue<OneObject> queue = new ReferenceQueue<>();
PhantomReference<OneObject> oneObjectPr = new PhantomReference<>(new OneObject(), queue);

无论是否gc,其实都获取不到通过PhantomReference创建的对象。

其仅用于管理直接内存,起到通知的作用。

这里补充一下背景。因为垃圾回收器只能管理JVM内部的内存,无法直接管理系统内存的。对于一些存放在系统内存中的数据,JVM会创建一个引用(类似于指针)指向这部分内存。

当这个引用在回收的时候,就需要通过虚引用来管理指向的系统内存。这里还需要依赖一个队列来实现。当触发gc对一个虚引用对象回收时,会将虚引用放入创建时指定的ReferenceQueue中。之后单独对这个队列进行轮询,并做额外处理。

/**
 * 虚引用
 * 只用于管理直接内存,起到通知的作用
 */
public class ShowPhantomReference {
    /**
     * 虚引用需要的队列
     */
    private static final ReferenceQueue<OneObject> QUEUE = new ReferenceQueue<>();

    public static void main(String[] args) {
        // 我们需要通过 PhantomReference来创建虚引用
        PhantomReference<OneObject> objectPr = new PhantomReference<>(new OneObject(), QUEUE);
        System.out.println("虚引用objectPr的地址:" + objectPr);
        System.out.println("虚引用objectPr关联的oneObject对象的地址:" + objectPr.get());

        // 触发gc,然后检查队列中是否有虚引用
        while (true) {
            System.gc();
            Reference<? extends OneObject> poll = QUEUE.poll();
            if (poll != null) {
                System.out.println("队列里找到objectPr啦" + poll);
                break;
            }
        }
    }
}

输出:

虚引用objectPr的地址:java.lang.ref.PhantomReference@448139f0
虚引用objectPr关联的oneObject对象的地址:null
啊哦~OneObject被回收了
队列里找到objectPr啦java.lang.ref.PhantomReference@448139f0

终结器引用

所有的类都继承自Object类,Object类有一个finalize方法。当某个对象不再被其他的对象所引用时,会先将终结器引用对象放入引用队列中,然后根据终结器引用对象找到它所引用的对象,然后调用该对象的finalize方法。调用以后,该对象就可以被垃圾回收了如上图,B对象不再引用A4对象。这时终结器对象就会被放入引用队列中,引用队列会根据它,找到它所引用的对象。然后调用被引用对象的finalize方法。调用以后,该对象就可以被垃圾回收了

引用队列

软引用和弱引用可以配合引用队列

在弱引用和虚引用所引用的对象被回收以后,会将这些引用放入引用队列中,方便一起回收这些软/弱引用对象

虚引用和终结器引用必须配合引用队列

虚引用和终结器引用在使用时会关联一个引用队列

四种引用的应用场景

  • 强引用:普通用法
  • 软引用:缓存,软引用可以用于缓存非必须的数据
  • 弱引用:防止一些关于map的内存泄漏。Threadlocal中防内存泄漏;线程池,当一个线程不再使用时,垃圾回收器会回收其所占用的内存空间,以便释放资源。
  • 虚引用:用来管理直接内存

12、问题排查经验与思路

收集基本信息

  • 现象确认:明确是内存居高不下、缓慢增加、突然 dump 掉,还是其他如 CPU 使用率过高、频繁 Full GC 等现象.
  • 环境信息:了解 JDK 版本、应用程序的运行环境、是否有新业务上线或变更等.
  • 监控数据:查看应用本身的监控数据,如内存使用率、GC 频率、线程数等,若没有则推荐使用如阿里云 ARMS 等监控工具来获取内存和 GC 情况.

保留现场

若条件允许,不建议直接重启或回滚机器,应先保留现场信息,以便后续深入分析 :

  • HeapDump 文件:使用jmap命令或jcmd命令保存整个 java 堆或存活对象的堆转储文件,也可通过配置-XX:+HeapDumpOnOutOfMemoryError让 JVM 在出现内存溢出错误时自动生成 。
  • JVM 启动参数:通过ps -ef|grep java命令获取当前 JVM 的启动参数,这些参数可能会影响 JVM 的性能和行为.

排查内存问题

  • 内存泄漏:通过分析 HeapDump 文件,查找是否存在大量不再使用但仍被引用的对象,导致内存无法释放。还可以使用内存分析工具如 Eclipse Memory Analyzer 等,帮助定位内存泄漏的具体位置.
  • 内存溢出:增加堆空间-Xmx参数来解决内存不足导致的溢出,但同时需分析内存使用情况,找出高内存消耗的区域进行优化.
  • GC 问题:使用jstat命令监视虚拟机各种运行状态信息,重点关注垃圾收集的频率、时间和回收的内存量等,判断是否存在频繁 GC 或 GC 停顿时间过长的问题。若发现老年代内存使用率过高,可能需要调整新生代和老年代的比例,或优化对象的生命周期管理,减少进入老年代的对象数量.

排查线程问题

  • 死锁:使用jstack命令生成 java 虚拟机当前时刻的线程快照,查看是否存在线程相互等待对方释放资源的死锁情况。若发现死锁,需分析代码逻辑,避免长时间持有互斥资源,或重新设计代码以消除死锁的可能性.
  • 线程饥饿:检查是否存在某些线程长时间无法获取 CPU 资源,导致饥饿现象。这可能是由于线程优先级设置不合理、线程池配置不当或存在资源竞争等原因引起的。可以通过调整线程优先级、优化线程池配置或解决资源竞争问题来改善线程饥饿情况2.
  • 线程泄漏:线程泄漏是指线程在执行完毕后没有正确释放资源,导致线程数量不断增加。通过查看线程快照和分析代码逻辑,查找是否存在线程未正确结束或被无限期阻塞的情况,并及时修复相关问题。

排查性能问题

  • 代码瓶颈:使用性能分析工具如 JProfiler、VisualVM 等,对应用程序进行性能剖析,找出代码中的瓶颈点,如耗时的方法调用、频繁的对象创建和销毁等,并进行相应的优化,如优化算法、减少不必要的对象创建、缓存常用数据等1.
  • JVM 参数调优:分析当前的 JVM 参数设置是否合理,根据应用程序的特点和性能需求,适当调整参数,如调整堆内存大小、新生代和老年代的比例、垃圾收集器类型等,以优化 JVM 的性能1.
  • 系统资源限制:检查服务器的系统资源是否足够,如 CPU、内存、磁盘 I/O 等。如果系统资源不足,可能会导致应用程序性能下降。可以通过升级硬件、优化系统配置或调整应用程序的部署方式来解决系统资源限制问题.

13、JVM 调优经验和调优思路

  • 内存分配方面:
    • 合理设置堆内存大小:-Xmx 和 - Xms 参数要根据实际业务场景和服务器硬件配置来确定。一般情况下,将两者设置为相同的值,避免 JVM 在运行过程中动态调整堆内存大小带来的性能开销。例如,对于一个内存消耗相对稳定的服务,可以将 - Xmx 和 - Xms 都设置为 2G,表示堆内存的最大值和初始值都是 2G。
    • 调整年轻代和老年代比例:年轻代和老年代的默认比例在 Hotspot JDK8 中是 1:2,可以通过 - XX:NewRatio 参数来调整 。如果应用中大部分对象都是短期存活的,比如一些临时数据处理的服务,可以适当增大年轻代的比例,减少 Minor GC 的次数。如设置 - XX:NewRatio=1,使年轻代和老年代各占堆内存的一半。
    • 优化 Eden 区和 Survivor 区比例:JDK8 中默认的 Eden 区和 Survivor 区比例是 8:1,可以使用 - XX:SurvivorRatio 参数修改。如果 Eden 区设置过大,可能导致 Survivor 区空间不足,对象过早晋升到老年代;而 Eden 区过小,则会频繁触发 Minor GC。需根据实际对象的生命周期和内存分配情况来调整,如设置 - XX:SurvivorRatio=4,即 Eden 区大小是 Survivor 区大小的 4 倍。
  • 垃圾收集器选择方面:
    • 了解不同垃圾收集器特点:不同的垃圾收集器适用于不同的场景。例如,Serial 收集器适用于单 CPU 环境下的小型应用;Parallel 收集器在多 CPU 环境下能充分发挥并行优势,提高垃圾回收效率;CMS 收集器以获取最短回收停顿时间为目标,适合对响应时间有要求的应用;G1 收集器则适用于大内存、多核处理器的服务器环境,能在高并发场景下提供较好的性能和较低的停顿时间1.
    • 根据业务需求选择:对于响应时间敏感的 Web 应用,如电商网站的前台页面展示,可优先选择 CMS 或 G1 收集器来降低停顿时间,提升用户体验。而对于一些后台数据处理任务,对响应时间要求不高,但对吞吐量有要求的应用,如数据仓库的 ETL 作业,则可以选择 Parallel 收集器来提高垃圾回收的效率,从而提高整体吞吐量.
  • 监控与分析方面:
    • 开启 GC 日志:通过设置 - XX:+PrintGCDetails、-Xloggc:logs/gc.log 等参数,将 GC 详细信息输出到日志文件中,以便后续分析 GC 的频率、停顿时间、内存回收情况等。例如,通过分析 gc.log 文件,可以发现是否存在频繁的 Full GC,以及每次 GC 前后各个内存区域的变化情况,从而为调优提供依据。
    • 使用监控工具:利用 JDK 自带的工具如 jstat、jmap、jstack 等,以及第三方工具如 VisualVM、MAT 等,对 JVM 的运行状态进行实时监控和分析。例如,使用 jstat 可以查看各个内存区域的使用情况、GC 次数和时间等统计信息;使用 jmap 可以生成堆内存快照,通过 MAT 工具分析是否存在内存泄漏等问题;使用 jstack 可以查看线程的状态,排查是否存在死锁等线程相关的问题.

调优思路

  • 明确调优目标:在进行 JVM 调优之前,需要明确调优的目标,是提高系统的吞吐量、降低响应时间,还是解决内存泄漏、频繁 GC 等问题。不同的调优目标会导致不同的调优策略和参数设置。例如,如果目标是提高系统的吞吐量,可以考虑增加堆内存大小、选择合适的垃圾收集器和调整相关参数来提高垃圾回收的效率;如果是降低响应时间,则需要重点关注减少 GC 停顿时间,可选择 CMS 或 G1 等低停顿的垃圾收集器,并对其参数进行精细调整1.
  • 分析系统现状:
    • 收集性能数据:通过监控工具和 GC 日志收集系统的性能数据,包括内存使用情况、GC 频率和时间、CPU 使用率、线程状态等。例如,使用 jstat 命令每隔一段时间查看一次内存区域的使用情况和 GC 统计信息,连续观察一段时间,了解系统在不同负载下的性能表现.
    • 查找性能瓶颈:根据收集到的数据,分析系统存在的性能瓶颈。常见的性能瓶颈包括内存不足导致的频繁 GC、CPU 使用率过高、线程死锁或阻塞等。例如,如果发现 Full GC 次数频繁,且每次 Full GC 的停顿时间较长,可能是老年代内存不足或者存在内存泄漏导致的;如果 CPU 使用率一直居高不下,可能是业务代码存在性能问题,或者是垃圾回收线程占用了过多的 CPU 资源.
  • 制定调优方案:
    • 调整 JVM 参数:根据分析结果,选择合适的 JVM 参数进行调整。例如,如果发现年轻代内存过小导致 Minor GC 频繁,可以适当增大年轻代的大小;如果使用的是 CMS 收集器,且发现 Concurrent Mode Failure 的次数较多,可以调整 - XX:CMSInitiatingOccupancyFraction 参数来优化 CMS 的触发时机1.
    • 优化业务代码:除了调整 JVM 参数外,还需要对业务代码进行优化。例如,检查是否存在不必要的对象创建和内存泄漏问题,及时释放不再使用的资源;对于一些耗时较长的操作,可以考虑采用异步处理、缓存等方式来提高系统的性能.
  • 验证调优效果:在对 JVM 参数或业务代码进行调整后,需要对调优效果进行验证。通过再次收集性能数据,并与调优前的数据进行对比,评估调优是否达到了预期的目标。如果调优效果不理想,需要重新分析问题,调整调优方案,直至达到满意的调优效果.

以上仅列举出部分常见面试题,在整理这些题目的时候也发现了许多好的题目,后续会将这些题目进行补充,整理一个完善的面试题题库!

参考

Java面试 32个核心必考点完全解析(上)【附视频地址】-CSDN博客

JVM 常见面试题

从实际案例聊聊Java应用的GC优化

双亲委派机制?原理?能打破吗?

GC - ZGC详解

你在线上有没有进行过JVM调优?频繁 minor gc 怎么办?频繁Full GC怎么办?

深入理解 JVM 的垃圾收集器:CMS、G1、ZGC

(七)JVM成神路之GC分代篇:分代GC器、CMS收集器及YoungGC、FullGC日志剖析

【JVM调优】如何进行JVM调优?一篇文章就够了!


网站公告

今日签到

点亮在社区的每一天
去签到