Skip to content

JVM 面试总结

Java 的跨平台、跨语言?

Java 的跨平台

我们写的一个类,在不同的操作系统上(Linux、Windows、MacOS 等平台)执行,效果是一样,这个就是 JVM 的跨平台性。

为什么 Java 是平台无关的编程语言?

Java 程序的执行过程如下:Java 文件->编译器>字节码->JVM->机器码。

JVM 的作用就是将我们的写好的 Java 代码(.java)编译的字节码(.class)文件进行转化为底层硬件可以理解的机器语言。所以,不管我们的程序运行在什么平台上,JVM 都可以根据平台的特性,进行翻译为特定的机器码。

Java 的跨语言

JVM 只识别字节码,所以 JVM 其实跟语言是解耦的,也就是没有直接关联,JVM 运行不是翻译 Java 文件,而是识别 class 文件,这个一般称之为字节码。还有像 Groovy 、Kotlin、Scala 等等语言,它们其实也是编译成字节码,所以它们也可以在 JVM 上面跑,这个就是 JVM 的 跨语言特征。Java 的跨语言性一定程度上奠定了非常强大的 java 语言生态圈。

JVM、JRE、JDK 的关系?

JVM(Java Virtual Machine) 只是一个翻译,把 Class 翻译成机器识别的代码。大家在编写代码,同时需要很多依赖类库,这个时候就需要用到 JRE。

JRE(Java Runtime Environment)包含 JVM,并提供了很多的类库,这两者组成了 java 运行时环境。

JDK(Java Developer’s Kit)是 java 开发工具包。JDK 中包含 JRE。对于程序员来说,JRE 还不够。我写完代码要编译代码,还需要调试代码,还需要打包代码、有时候还需要反编译代码。所以我们会使用 JDK。JDK 提供了一些非常好用的小工具,比如 javac(编译代码)、java、jar (打包代码)、javap(反编译<反汇编>)等。

说一下 JVM 内存模型?

JVM 的内存区域

运行时数据区域

线程共享区:

  • 堆:

    • 堆是 JVM 上最大的内存区域,我们申请的几乎所有的对象,都是在这里存储的。我们常说的垃圾回收,操作的对象就是堆。
    • 堆大小参数:
      • -Xms:堆的最小值;
      • -Xmx:堆的最大值;
      • -Xmn:新生代的大小;
      • -XX:NewSize;新生代最小值;
      • -XX:MaxNewSize:新生代最大值;
  • 方法区:

    • 存储了每一个类的结构信息,例如运行时常量池(Runtime Constant Pool) 字段和方法数据、构造函数和普通方法的字节码内容、还包括一些在类、实例、接口初始化时用到的特殊方法。
    • 元空间:假如两个线程都试图访问方法区中的同一个类信息,而这个类还没有装入 JVM,那么此时就只允许一个线程去加载它,另一个线程必须等待。
    • 在 JDK1.7 及之前将方法区称为“永久代”,是因为在 HotSpot 虚拟机中,设计人员使用了永久代来实现了 JVM 规范的方法区。在 JDK1.8 及以后使用了元空间来实现方法区。
    • Java8 版本已经将方法区中实现的永久代去掉了,并用元空间(class metadata)代替了之前的永久代,并且元空间的存储位置是本地内存。
    • 元空间大小参数:
      • jdk1.7 及以前(初始和最大值):-XX:PermSize;-XX:MaxPermSize;
      • jdk1.8 以后(初始和最大值):-XX:MetaspaceSize; -XX:MaxMetaspaceSize
      • jdk1.8 以后大小就只受本机总内存的限制(如果不设置参数的话)

线程私有区:

  • 虚拟机栈:

    • 数据结构为先进后出;
    • 体现在方法的调用上。A 方法调用 B 方法,B 方法调用 C 方法,A 方法先入栈,然后 B 方法入栈, C 方法入栈;接着 C 方法执行完毕出栈,B 方法执行完毕出栈,最后 A 方法执行完毕出栈,至此,整个程序执行完毕。
    • 在执行每个方法的时候都会打包成一个栈帧,存储了局部变量表,操作数栈,动态链接,方法出口等信息。
    • 虚拟机栈的大小缺省为 1M,可用参数 –Xss 调整大小。
  • 本地方法栈:

    • 本地方法栈跟 Java 虚拟机栈的功能类似,Java 虚拟机栈用于管理 Java 函数的调用,而本地方法栈则用于管理本地方法的调用。
    • 本地方法并不是用 Java 实现的,而是由 C 语言实现的(比如 Object.hashcode 方法)。
    • 本地方法栈是和虚拟机栈非常相似的一个区域,它服务的对象是 native 方法。你甚至可以认为虚拟机栈和本地方法栈是同一个区域。
    • 虚拟机规范无强制规定,各版本虚拟机自由实现 ,HotSpot 直接把本地方法栈和虚拟机栈合二为一。
  • 程序计数器:

    • 较小的内存空间,当前线程执行的字节码的行号指示器;各线程之间独立存储,互不影响。
    • 当争夺 CPU 资源时,根据时间片轮询,如果未抢到 CPU 资源,则标记线程当前执行到的位置;如果抢到了 CPU 资源,则根据当前标记的执行位置,继续执行程序。
    • 分支、循环、跳转、异常、线程恢复等都依赖于计数器。

非运行时数据区域

  • 直接内存(堆外内存):
    • JVM 在运行时,会从操作系统申请大块的堆内存,进行数据的存储;同时还有虚拟机栈、本地方法栈和程序计数器,这块称之为栈区。操作系统剩余的内存也就是堆外内存。
    • 它不是虚拟机运行时数据区的一部分,也不是 java 虚拟机规范中定义的内存区域;如果使用了 NIO,这块区域会被频繁使用,在 java 堆内可以用 directByteBuffer 对象直接引用并操作;
    • 这块内存不受 java 堆大小限制,但受本机总内存的限制,可以通过-XX:MaxDirectMemorySize 来设置(默认与堆内存最大值一样),所以也会出现 OOM 异常。
    • 堆外内存的泄漏是非常严重的,它的排查难度高、影响大,甚至会造成主机的死亡。

一个对象创建的时候,到底是在堆上分配,还是在栈上分配呢?

这和两个方面有关:对象的类型和在 Java 类中存在的位置。

Java 的对象可以分为基本数据类型和普通对象。

对于普通对象来说,JVM 会首先在堆上创建对象,然后在其他地方使用的其实是它的引用。比如,把这个引用保存在虚拟机栈的局部变量表中。

对于基本数据类型来说(byte、short、int、long、float、double、char),有两种情况。 当你在方法体内声明了基本数据类型的对象,它就会在栈上直接分配。其他情况,都是在堆上分配。

堆空间分代划分

堆被划分为 新生代 和 老年代(Tenured),新生代又被进一步划分为 Eden 和 Survivor 区,最后 Survivor 由 From Survivor 和 To Survivor 组成。

+---------------------+------+------+-------------------------------------------------+
|                                       堆                                            |
+---------------------+------+------+-------------------------------------------------+
|            新生代(8:1:1)          |                      老年代                      |
+---------------------+------+------+-------------------------------------------------+
|        Eden         |  Survivor   |                     Tenured                     |
+---------------------+------+------+-------------------------------------------------+
|        Eden         | From |  To  |                     Tenured                     |
+---------------------+------+------+-------------------------------------------------+
|                     |      |      |                                                 |
+---------------------+------+------+-------------------------------------------------+

你有没有遇到 OutOfMemoryError 问题?你有没有遇到过内存溢出?你是怎么处理的?

一般线程不断申请栈内存,机器没有足够的内存,就会发生 OutOfMemoryError。一般发生这个异常程序也死了。

一般的方法调用是很难出现 StackOverflowError 的,如果出现了可能会是无限递归。

常见的内存溢出的原因有:

  • 内存加载的数据量太大,一次性从数据库取太多数据;
  • 集合类中有对对象的引用,使用后未清空,GC 不能进行回收,即发生了内存泄漏;
  • 代码中存在循环产生过多的重复对象;
  • 启动参数堆内存值小。
  • 资源、连接未关闭。

内存溢出

栈溢出

  • java.lang.StackOverflowError 一般的方法调用是很难出现的,如果出现了可能会是无限递归。
  • OutOfMemoryError:不断建立线程,JVM 申请栈内存,机器没有足够的内存。(一般演示不出,演示出来机器也死了) 参数默认值:-Xss1m

堆溢出

申请内存空间,超出最大堆内存空间。 如果是内存溢出,则通过 调大 -Xms,-Xmx 参数。

如果不是内存泄漏,就是说内存中的对象却是都是必须存活的,那么久应该检查 JVM 的堆参数设置,与机器的内存对比,看是否还有可以调整的空间, 再从代码上检查是否存在某些对象生命周期过长、持有状态时间过长、存储结构设计不合理等情况,尽量减少程序运行时的内存消耗。

方法区溢出

  • 运行时常量池溢出
  • 方法区中保存的 Class 对象没有被及时回收掉或者 Class 信息占用的内存超过了我们配置。

注意 Class 要被回收,条件比较苛刻(仅仅是可以,不代表必然,因为还有一些参数可以进行控制):

  1. 该类所有的实例都已经被回收,也就是堆中不存在该类的任何实例。
  2. 加载该类的 ClassLoader 已经被回收。
  3. 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

直接内存溢出

直接内存的容量可以通过 MaxDirectMemorySize 来设置(默认与堆内存最大值一样),所以也会出现 OOM 异常;

由直接内存导致的内存溢出,一个比较明显的特征是在 HeapDump 文件中不会看见有什么明显的异常情况,如果发生了 OOM,同时 Dump 文件很小,可以考虑重点排查下直接内存方面的原因。

String 类分析(JDK1.8)

String 对象在实现代码中 String 类被 final 关键字修饰了,而且变量 char 数组也被 final 修饰了。

类被 final 修饰代表该类不可继承,而 char[]被 final+private 修饰,代表了 String 对象不可被更改。Java 实现的这个特性叫作 String 对象的不 可变性,即 String 对象一旦创建成功,就不能再对它进行改变。

Java 这样做的好处在哪里呢?

  • 保证 String 对象的安全性。假设 String 对象是可变的,那么 String 对象将可能被恶意修改。
  • 保证 hash 属性值不会频繁变更,确保了唯一性,使得类似 HashMap 容器才能实现相应的 key-value 缓存功能。
  • 可以实现字符串常量池。在 Java 中,通常有两种创建字符串对象的方式,一种是通过字符串常量的方式创建,如 String str=“abc”;另一种是字符串变量通过 new 形式的创建,如 String str = new String(“abc”)。

String 的创建方式及内存分配的方式

String str=“abc”;

当代码中使用这种方式创建字符串对象时,JVM 首先会检查该对象是否在字符串常量池中,如果在,就返回该对象引用,否则新的字符串将在常量池中被创建。这种方式可以减少同一个值的字符串对象的重复创建,节约内存。(str 只是一个引用)

String str = new String(“abc”)

首先在编译类文件时,"abc"常量字符串将会放入到常量结构中,在类加载时,“abc"将会在常量池中创建;其次,在调用 new 时,JVM 命令将会调用 String 的构造函数,同时引用常量池中的"abc” 字符串,在堆内存中创建一个 String 对象;最后,str 将引用 String 对象。

String str2= "ab"+ "cd"+ "ef";

编译器自动优化了这行代码,编译后如下 String str= "abcdef";

str.intern() 方法

String 的 intern 方法,如果常量池中有相同值,就会重复使用该对象,返回对象引用。

JVM 中对象的创建过程

类加载 -> 检查加载 -> 分配内存 -> 内存空间初始化 -> 设置 -> 对象初始化。

对象的内存分配:虚拟机遇到一条 new 指令时,首先检查是否被类加载器加载,如果没有,那必须先执行相应的类加载过程。 类加载就是把 class 加载到 JVM 的运行时数据区的过程。

  • 检查加载:首先检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查类是否已经被加载、 解析和初始化过。
  • 分配内存:接下来虚拟机将为新生对象分配内存。为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。
  • 内存空间初始化:(注意不是构造方法)内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(如 int 值为 0,boolean 值为 false 等等)。这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。
  • 设置:接下来,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息(Java classes 在 Java hotspot VM 内部表示为类元数据)、对象的哈希码、对象的 GC 分代年龄等信息。这些信息存放在对象的对象头之中。
  • 对象初始化:在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚刚开始,所有的字段都还为零值。 所以,一般来说,执行 new 指令之后会接着把对象按照程序员的意愿进行初始化(构造方法),这样一个真正可用的对象才算完全产生出来。

如何判断一个对象是否应该被回收?如何判断一个对象是否存活?

引用计数法

在对象中添加一个引用计数器,每当有一个地方引用它,计数器就加 1,当引用失效时,计数器减 1.

JVM 中采用的不是引用计数法,为存在对象相互引用的情况,这个时候需要引入额外的机制来处理,这样做影响效率。

什么是可达性分析算法?GC Roots 的对象包括哪些?

可达性分析

JVM 中采用可达性分析来判定对象是否存活的。这个算法的基本思路就是通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为 引用链(Reference Chain),当一个对象到 GC Roots 没有任何引用链相连时,则证明此对象是不可用的。

作为 GC Roots 的对象包括下面几种(重点是前面 4 种):

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象;各个线程调用方法堆栈中使用到的参数、局部变量、临时变量等。
  • 方法区中类静态属性引用的对象;java 类的引用类型静态变量。
  • 方法区中常量引用的对象;比如:字符串常量池里的引用。
  • 本地方法栈中 JNI(即一般说的 Native 方法)引用的对象。
  • JVM 的内部引用(class 对象、异常对象 NullPointException、OutofMemoryError,系统类加载器)。(非重点)
  • 所有被同步锁(synchronized 关键)持有的对象。(非重点)
  • JVM 内部的 JMXBean、JVMTI 中注册的回调、本地代码缓存等(非重点)
  • JVM 实现中的“临时性”对象,跨代引用的对象(在使用分代模型回收只回收部分代的对象,这个后续会细讲,先大致了解概念)(非重点)

以上的回收都是对象,类的回收条件: 注意 Class 要被回收,条件比较苛刻,必须同时满足以下的条件(仅仅是可以,不代表必然,因为还有一些参数可以进行控制):

  1. 该类所有的实例都已经被回收,也就是堆中不存在该类的任何实例。
  2. 加载该类的 ClassLoader 已经被回收。
  3. 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

Finalize 方法

即使通过可达性分析判断不可达的对象,也不是“非死不可”,它还会处于“缓刑”阶段,真正要宣告一个对象死亡,需要经过两次标记过程,一次是 没有找到与 GCRoots 的引用链,它将被第一次标记。随后进行一次筛选(如果对象覆盖了 finalize),我们可以在 finalize 中去拯救。

强引用、软引用、弱引用、虚引用的区别?

  • 强引用(new):
    • 一般的 Object obj = new Object() ,就属于强引用。在任何情况下,只有有强引用关联(与根可达)还在,垃圾回收器就永远不会回收掉被引用的对象。
  • 软引用(SoftReference):
    • 一些有用但是并非必需,用软引用关联的对象,系统将要发生内存溢出(OuyOfMemory)之前,这些对象就会被回收(如果这次回收后还是没有足够的空间,才会抛出内存溢出)。
  • 弱引用(WeakReference):
    • 一些有用(程度比软引用更低)但是并非必需,用弱引用关联的对象,只能生存到下一次垃圾回收之前,GC 发生时,不管内存够不够,都会被回收。
  • 虚引用(PhantomReference):
    • 幽灵引用,最弱(随时会被回收掉) 垃圾回收的时候收到一个通知,就是为了监控垃圾回收器是否正常工作。

注意:软引用 SoftReference 和弱引用 WeakReference,可以用在内存资源紧张的情况下以及创建不是很重要的数据缓存。当系统内存不足的时候,缓存中的内容是可以被释放的。 实际运用(WeakHashMap、ThreadLocal)

对象的分配策略

逃逸分析

逃逸分析原理:分析对象动态作用域,当一个对象在方法中定义后,它是否可能被外部方法所引用。

比如:调用参数传递到其他方法中,这种称之为方法逃逸。甚至还有可能被外部线程访问到,例如:赋值给其他线程中访问的变量,这个称之为线程逃逸。

从不逃逸到方法逃逸到线程逃逸,称之为对象由低到高的不同逃逸程度。

如果确定一个对象不会逃逸出线程之外,那么让对象在栈上分配内存可以提高 JVM 的效率。

个人概括:逃逸分析的作用就是根据一个对象是否能够被外部访问到而确定对象分配在堆上还是栈上。如果逃逸分析出来的对象可以在栈上分配的话,那么该对象的生命周期就跟随线程,就不需要垃圾回收,如果是频繁的调用此方法,将对象分配在栈上则可以得到很大的性能提高。

即采用了逃逸分析后,满足逃逸的对象会在栈上分配(JVM 默认开启逃逸分析)。

如果关闭逃逸分析,对象都在堆上分配,会频繁触发垃圾回收(垃圾回收会影响系统性能),导致代码运行慢。

对象在虚拟机内存中的分配策略是什么?对象的分配规则是什么?

  • 对象优先在 Eden 区分配
    • 大多数情况下,对象在新生代 Eden 区中分配。当 Eden 区没有足够空间分配时,虚拟机将发起一次 Minor GC。
  • 大对象直接进入老年代
    • 大对象就是指需要大量连续内存空间的 Java 对象,最典型的大对象便是那种很长的字符串,或者元素数量很庞大的数组。
    • 写程序 的时候应注意避免遇到一群“朝生夕灭”的“短命大对象”,
    • 在 Java 虚拟机中要避免大对象的原因是,在分配空间时,它容易导致内存明明还有不少空间时就提前触发垃圾收集,以获取足够的连续空间才能安置好它们。
    • 而当复制对象时,大对象就意味着高额的内存复制开销。
  • 长期存活对象进入老年区
    • 虚拟机给每个对象定义了一个对象年龄(Age)计数器,存储在对象头中。如果对象在 Eden 出生并经过第一次 Minor GC 后仍然存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间中,并将对象年龄设为 1,对象在 Survivor 区中每熬过一次 Minor GC,年龄就增加 1,当它的年龄增加到一定程度(并发的垃圾回收器默认为 15), CMS 是 6 时,就会被晋升到老年代中。 -XX:MaxTenuringThreshold 调整
    • 对象年龄动态判定
      • 虚拟机并不是永远地要求对象的年龄必须达到了 MaxTenuringThreshold 才能晋升老年代,如果在 Survivor 空间中相同年龄所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到 MaxTenuringThreshold 中要求的年龄

分代回收理论

  1. 绝大部分的对象都是朝生夕死。
  2. 熬过多次垃圾回收的对象就越难回收。

根据以上两个理论,朝生夕死的对象放一个区域,难回收的对象放另外一个区域,这个就构成了新生代和老年代。

GC 分类

  1. 新生代回收(Minor GC/Young GC):指只是进行新生代的回收。
  2. 老年代回收(Major GC/Old GC):指只是进行老年代的回收。目前只有 CMS 垃圾回收器会有这个单独的回收老年代的行为。
  3. 整堆回收(Full GC):收集整个 Java 堆和方法区(注意包含方法区)

JVM 中一次完整的 GC 流程是怎样的,对象如何晋升到老年代?

Java 堆由新生代和老年代组成,新生代又分为 Eden 区、 Survivor0(from 区)、 Survivor1(to 区),当 Eden 区的空间满了, Java 虚拟机会触发一次 Minor GC,以收集新生代的垃圾,存活下来的对象,则会转移到 Survivor 区。每进行一次 Minor GC, 对象的分带年龄会加一,当老年代满了而无法容纳更多的对象,Minor GC 之后通常就会进行 Full GC,Full GC 清理整个内存堆 – 包括年轻代和年老代。

常见的垃圾回收算法有哪些?你知道的垃圾回收算法有哪些?

复制算法(Copying)

将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。

这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要按顺序分配内存即可,

实现简单,运行高效。只是这种算法的代价是将内存缩小为了原来的一半。

复制回收算法适合于新生代,因为大部分对象朝生夕死,那么复制过去的对象比较少,效率自然就高,另外一半的一次性清理是很快的。

Appel 式回收

一种更加优化的复制回收分代策略:具体做法是分配一块较大的 Eden 区和两块较小的 Survivor 空间(你可以叫做 From 或者 To,也可以叫做 Survivor1 和 Survivor2)

研究表明,新生代中的对象 98% 是“朝生夕死”的,所以并不需要按照 1:1 的比例来划分内存空间,而是将内存分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 和其中一块 Survivor。当回收时,将 Eden 和 Survivor 中还存活着的对象一次性地复制到另外一块 Survivor 空间上, 最后清理掉 Eden 和刚才用过的 Survivor 空间。

HotSpot 虚拟机默认 Eden 和 Survivor 的大小比例是 8:1,也就是每次新生代中可用内存空间为整个新生代容量的 90%(80%+10%),只有 10%的内存会被 “浪费”。

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

算法分为“标记”和“清除”两个阶段:首先扫描所有对象标记出需要回收的对象,在标记完成后扫描回收所有被标记的对象,所以需要扫描两遍。 回收效率略低,如果大部分对象是朝生夕死,那么回收效率降低,因为需要大量标记对象和回收对象,对比复制回收效率要低。它的主要问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾回收动作。 并且 GC 执行期间需要暂停用户线程才能进行(STW)。

回收的时候如果需要回收的对象越多,需要做的标记和清除的工作越多,所以标记清除算法适用于老年代。

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

首先标记出所有需要回收的对象,在标记完成后,后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端 边界以外的内存。

标记整理算法虽然没有内存碎片,但是效率偏低。 我们看到标记整理与标记清除算法的区别主要在于对象的移动。对象移动不单单会加重系统负担,同时需要全程暂停用户线程才能进行(STW),同时所有引用对象的地方都需要更新(直接指针需要调整)。 所以看到,老年代采用的标记整理算法与标记清除算法,各有优点,各有缺点。

个人概括

新生代多是朝生夕死,使用复制算法效率高。

老年代多是长久存活的对象,可以根据需要采用标记-清除算法 或者 标记-整理算法。

GC 是什么?为什么要有 GC?

GC 是 Java 中的垃圾回收器。内存处理是编程人员容易出现问题的地方,忘记或者错误的内存回收会导致程序或系统的不稳定甚至崩溃。于是 Java 提供了垃圾回收器来规避可能发生的问题,从而不需要程序员手动释放内存,而交由垃圾回收器来回收不需要的内存。

常见的垃圾收集器有哪些?各自的优缺点是什么?

JVM 中常见的垃圾回收器

Stop The World(STW)(重点):单线程进行垃圾回收时,必须暂停所有的工作线程,直到它回收结束。这个暂停称之为“Stop The World”,但是这种 STW 带来了恶劣的用户体验。

  • Serial/Serial Old(了解即可):最古老的,单线程,独占式,成熟,适合单 CPU,一般用在客户端模式下。
  • Parallel Scavenge(ParallerGC)/Parallel Old(重点):
    • JDK1.8 默认就是以下组合 -XX:+UseParallelGC 新生代使用 Parallel Scavenge,老年代使用 Parallel Old
  • ParNew(了解即可):多线程垃圾回收器,与 CMS 进行配合,对于 CMS(CMS 只回收老年代),新生代垃圾回收器只有 Serial 与 ParNew 可以选。和 Serial 基本没区别,唯一的区别:多线程,多 CPU 的,停顿时间比 Serial 少。
  • Concurrent Mark Sweep (CMS):多线程垃圾回收器,CMS 只回收老年代。基于“标记—清除”算法实现的。
  • Garbage First(G1):多线程垃圾回收器,致力于减少 CMS 产生的空间碎片和 STW 的时间。
    • 跟前面所有的垃圾回收器的内存划分不一样。G1 并不是划分为老年代和新生代,新生代又划分为 8:1:1 的模式。
    • 在 G1 算法中,采用了另外一种完全不同的方式组织堆内存,堆内存被划分为多个大小相等的内存块(Region),每个 Region 是逻辑连续的一段内存,每个 Region 被标记了 E(Eden)、S(Survivor)、O(Old)和 H(Humongous),每个 Region 在运行时都充当了一种角色,其中 H 是以往算法中没有的,它代表 Humongous,这表示这些 Region 存储的是巨型对象(humongous object,H-obj),当新建对象大小超过 Region 大小一半时,直接在新的一个或多个连续 Region 中分配,并标记为 H。

Minor GC 和 Full GC 分别在什么时候发生?

新生代内存不够用时发生 Minor GC, JVM 内存不够用时发生 Full GC。

三色标记算法

Mark-And-Sweep(标记清除算法)最大的问题是 GC 执行期间需要把整个程序完全暂停,不能异步进行 GC 操作。

三色标记法来解决 GC 运行时程序长时间挂起的问题。

三色标记最大的好处是可以异步执行,从而可以以中断时间极少的代价或者完全没有中断来进行整个 GC。

三色标记法很简单。首先将对象用三种颜色表示,分别是白色、灰色和黑色。

  • 黑色:根对象,或者该对象与它的子对象都被扫描过。
  • 灰色:对本身被扫描,但是还没扫描完该对象的子对象。
  • 白色:未被扫描对象,如果扫描完所有对象之后,最终为白色的为不可达对象,既垃圾对象。

三色标记在 GC 并发情况下会有漏标问题。这在 CMS 和 G1 垃圾回收器中分别有不同的处理方式。

装箱拆箱

Java 中有 8 种基本类型,但鉴于 Java 面向对象的特点,它们同样有着对应的 8 个包装类型,比如 int 和 Integer,包装类型的值可以为 null(基本类型没有 null 值,而数据库的表中普遍存在 null 值。 所以实体类中所有属性均应采用封装类型),很多时候,它们都能够相互赋值。

java
public Integer calc() {
    // 实际调用了 Integer.valueOf(); 方法 装箱
    Integer a = 1000;
    // 实际调用了 Integer.intValue(); 方法 拆箱
    int b = a * 10;
    return b;
}

通过观察字节码(使用字节码查看工具),我们发现:

  1. 在进行乘法运算的时候,调用了 Integer.intValue 方法来获取基本类型的值。
  2. 赋值操作使用的是 Integer.valueOf 方法。
  3. 在方法返回的时候,再次使用了 Integer.valueOf 方法对结果进行了包装。 这就是 Java 中的自动装箱拆箱的底层实现。

IntegerCache

但这里有一个陷阱问题,我们继续跟踪 Integer.valueOf 方法

java
public static Integer valueOf(int i) {
    if (i >= IntegerCache.low && i <= IntegerCache.high)
        return IntegerCache.cache[i + (-IntegerCache.low)];
    return new Integer(i);
}

这个 IntegerCache,缓存了 low 和 high 之间的 Integer 对象

一般情况下,缓存是的-128 到 127 之间的值,但是可以通过 -XX:AutoBoxCacheMax 来修改上限。

什么是类加载?类加载的过程是什么?

类加载就是把 .class 文件加载到虚拟机的过程。过程为:加载 -> 验证 -> 准备 -> 解析 -> 初始化 -> 卸载

  • 被 new 时、子类调用时等加载
  • 验证文件格式等
  • 准备:为类的静态变量分配内存
  • 解析:将符号引用改为直接引用
  • 初始化:为类的静态变量赋值
  • 卸载:执行 System.exit()正常结束或者程序崩溃

简单说说你了解的类加载器,可以打破双亲委派么,怎么打破?

类加载器

JDK 提供的三层类加载器

Bootstrap ClassLoader

这是加载器中的扛把子,任何类的加载行为,都要经它过问。它的作用是加载核心类库,也就是 rt.jar、resources.jar、charsets.jar 等。当然这些 jar 包的路径是可以指定的,-Xbootclasspath 参数可以完成指定操作。 这个加载器是 C++ 编写的,随着 JVM 启动。

Extention ClassLoader

扩展类加载器,主要用于加载 lib/ext 目录下的 jar 包和 .class 文件。同样的,通过系统变量 java.ext.dirs 可以指定这个目录。 这个加载器是个 Java 类,继承自 URLClassLoader。

Application ClassLoader

这是我们写的 Java 类的默认加载器,有时候也叫作 System ClassLoader。一般用来加载 classpath 下的其他所有 jar 包和 .class 文件,我们写的代码, 会首先尝试使用这个类加载器进行加载。

Custom ClassLoader

自定义加载器,支持一些个性化的扩展功能。

类加载器的问题

如果你在项目代码里,写一个 java.lang 的包,然后改写 String 类的一些行为,编译后,发现并不能生效。JRE 的类当然不能轻易被覆盖,否则会被别有用心的人利用,这就太危险了。 对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在 Java 虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。这句话可以表达得更通俗一些:比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一 个 Class 文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。

这里所指的“相等”,包括代表类的 Class 对象的 equals()方法、isAssignableFrom()方法、isInstance()方法的返回结果,也包括使用 instanceof 关键字做对象所属关系判定等情况。

什么是双亲委派机制?Java 为什么使用双亲委派机制?

双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器。这里类加载器之间的父子关系一般不会以继承(Inheritance)的关系来实现,而是都使用组合(Composition)关系来复用父加载器的代码。

如果没有双亲委派,那么用户可以自己定义一个 java.lang.Object 的同名类,java.lang.String 的同名类,并把它放到 ClassPath 中, 那么类之间的比较结果及类的唯一性将无法保证,因此,为什么需要双亲委派模型?防止内存中出现多份同样的字节码。

使用双亲委派模型来组织类加载器之间的关系,有一个显而易见的好处就是 Java 类随着它的类加载器一起具备了一种带有优先级的层次关系。例如类 java.lang.Object,它存放在 rt.jar 之中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此 Object 类在程序的各种类加载器环境中都是同一个类。相反,如果没有使用双亲委派模型,由各个类加载器自行去加载的话,如果用户自己编写了一个称为 java.lang.Object 的类,并放在程序的 ClassPath 中,那系统中将会出现多个不同的 Object 类,Java 类型体系中最基础的行为也就无法保证,应用程序也将会变得一片混乱。

我们可以翻阅 JDK 代码的 ClassLoader#loadClass 方法,来看一下具体的加载过程。和我们描述的一样,它首先使用 parent 尝试进行类加载,parent 失败后才轮到自己。

同时,这个方法是可以被覆盖的,也就是双亲委派机制并不一定生效,是可以通过方法的覆盖来打破双亲委派的。

违反双亲委派机制的典型例子:Tomcat

Tomcat 首先自定义了一个 CommonClassLoader(继承自 ApplicationClassLoader),又自定义了一个 CatalinaClassLoader(加载公有类库,如 JDK 的)和 SharedClassLoader(子类 WebAppClassLoader),而 CatalinaClassLoader 此时和 SharedClassLoader 是相互隔离的,对于一些需要加载的非基础类(如自己的项目中的类),会由一个叫作 WebAppClassLoader 的自定义类加载器优先加载。等它加载不到的时候,再交给上层的 ClassLoader 进行加载。 这个加载器用来隔绝不同应用的 .class 文件,比如你的两个应用,可能会依赖同一个第三方的不同版本,它们是相互没有影响的。

这样就打破了双亲委派,公有类库由 CatalinaClassLoader 来加载,项目类库由 WebAppClassLoader 来加载,而这两个类加载器之间没有直接的继承关系,达到了多个项目部署在同一个 tomcat 中时,各个项目之间类隔离的目的。此时多个项目中可以包含类名称和路径完全相同,但功能可以不同的类。

SPI (Service Provider Interface)

Java 中有一个 SPI 机制,全称是 Service Provider Interface,是 Java 提供的一套用来被第三方实现或者扩展的 API,它可以用来启用框架扩展和替换组件。

例如:JDBC 驱动类的加载 Class.forName("com.mysql.jdbc.Driver")

这只是一种初始化模式,通过 static 代码块显式地声明了驱动对象,然后把这些信息,保存到底层的一个 List 中。这种方式我们不做过多的介绍,因为这明显就是一个接口编程的思路(这里不进行细讲)。

但是你会发现,即使删除了 Class.forName 这一行代码,也能加载到正确的驱动类,什么都不需要做,非常的神奇,它是怎么做到的呢?

MySQL 的驱动代码,就是在这里实现的。 路径:mysql-connector-java-8.0.11.jar!/META-INF/services/java.sql.Driver 里面的内容是:com.mysql.cj.jdbc.Driver

通过在 META-INF/services 目录下,创建一个以接口全限定名为命名的文件(内容为实现类的全限定名),即可自动加载这一种实现,这就是 SPI。 SPI 实际上是“基于接口的编程+策略模式+配置文件”组合实现的动态加载机制,主要使用 java.util.ServiceLoader 类进行动态装载。

这种方式,同样打破了双亲委派的机制。 DriverManager 类和 ServiceLoader 类都是属于 rt.jar 的。它们的类加载器是 Bootstrap ClassLoader,也就是最上层的那个。 而具体的数据库驱动,却属于业务代码,这个启动类加载器是无法加载的。这就比较尴尬了,虽然凡事都要祖先过问,但祖先没有能力去做这件事情,怎么办?

通过代码你可以发现它把当前的类加载器,设置成了线程的上下文类加载器。那么,对于一个刚刚启动的应用程序来说,它当前的加载器是谁呢?也就是说,启动 main 方法的那个加载器,到底是哪一个? 所以我们继续跟踪代码。找到 Launcher 类,就是 jre 中用于启动入口函数 main 的类。

到此为止,事情就比较明朗了,当前线程上下文的类加载器,是应用程序类加载器。使用它来加载第三方驱动。

个人概括

按道理来说,在自己的代码中写 Class.forName("com.mysql.jdbc.Driver") 可以加载到正确的驱动没问题,因为所有的代码都是通过当前的应用程序类加载器来加载的,当然可以找到正确的驱动类。

但是当删除了 Class.forName("com.mysql.jdbc.Driver") 时,DriverManager 类和 ServiceLoader 类都是属于 rt.jar 的。它们的类加载器是 Bootstrap ClassLoader,而其他连接数据库的代码却是由应用程序类加载器加载的,按道理来讲,程序应该不能够加载到正确的数据库驱动类才对,然而事实却是相反的,为什么呢?

是因为 首先根据 SPI 在路径:mysql-connector-java-8.0.11.jar!/META-INF/services/java.sql.Driver 里面的内容是:com.mysql.cj.jdbc.Driver, 找到了驱动类的名称和位置,然后,虽然 DriverManager 类和 ServiceLoader 类都是属于 rt.jar 的,它们的类加载器是 Bootstrap ClassLoader,但在执行代码逻辑的时候,它把当前执行代码的类加载器设置为了当前线程上下文类加载器,而当前执行业务代码的线程上下文类加载器正是应用程序类加载器,于是依然等同于包括数据库驱动类在内的所有业务代码,都是由应用程序上下文类加载器加载的,因而可以找到正确的数据库驱动类并执行。

如何打破双亲委派机制?

  1. 自定义类加载器。继承 ClassLoader 类,还要重写 loadClass 和 findClass 方法。
  2. 使用线程上下文类加载器,使用 SPI(实际上也是使用的线程上下文类加载器)。如 JDBC DriverManager
  3. 自定义多个类加载器,使多个子类加载之间没有直接继承关系。如:Tomcat。

如何进行 JVM 调优?

通过 JMAP 命令查看内存信息,分析做数据调优。

基本原则:降低 GC 频率,减少 Full GC 次数。

可以通过设置新生代和老年代的比例进行调节,新生代大可以降低 GC 频率,但是 Full GC 的次数会上升。

默认设置: 新生代:老年代=1:2

新生代中 Eden 和 Survivor 的,默认比例是 8:1:1,如果不适合你的应用,可以根据对象存活程度设置比例。

可从响应时间优先和吞吐量优先两个维度来设置。

推荐策略

  1. 新生代大小选择
  • 响应时间优先的应用: 尽可能设大, 直到接近系统的最低响应时间限制(根据实际情况选择). 在此种情况下,新生代收集发生的频率也是最小的. 同时, 减少到达老年代的对象.
  • 吞吐量优先的应用: 尽可能的设置大, 可能到达 Gbit 的程度. 因为对响应时间没有要求, 垃圾收集可以并行进行, 一般适合 8CPU 以上的应用.
  • 避免设置过小. 当新生代设置过小时会导致:
    • MinorGC 次数更加频繁
    • 可能导致 MinorGC 对象直接进入老年代, 如果此时老年代满了, 会触 发 FullGC.
  1. 老年代大小选择
  • 响应时间优先的应用: 老年代使用并发收集器, 所以其大小需要小心设置, 一般要考虑并发会话率和会话持续时间等一些参数. 如果堆设置小了, 可以会造成内存碎片, 高回收频率以及应用暂停而使用传统的标记清除方式; 如果堆大了, 则需要较长的收集时间. 最优化的方案,一般需要参考以下数据获得:
    • 并发垃圾收集信息
    • 持久代并发收集次数
    • 传统 GC 信息
    • 花在新生代和老年代回收上的时间比例。
  • 吞吐量优先的应用: 一般吞吐量优先的应用都有一个很大的新生代和一个较小的老年代. 原因是, 这样可以尽可能回收掉大部分短期对象, 减少中期的对象, 而老年代尽存放长期存活对象。

怎么打出线程栈信息?

  • 使用 jps,获得进程号 pid。
  • 使用 top -Hp pid 获取本进程中所有线程的 CPU 耗时性能
  • 使用 jstack pid 查看当前 java 进程的堆栈状态

JDK 提供的 JVM 调优工具有哪些?

  • jps,JVM Process Status Tool, 显示指定系统内所有的 HotSpot 虚拟机进程。
  • jstat,JVM statistics Monitoring 是用于监视虚拟机运行时状态信息的命令,它可以显示出虚拟机进程中的类装载、内存、垃圾收集、JIT 编译等运行数据。
  • jmap,JVM Memory Map 命令用于生成 heap dump 文件
  • jhat,JVM Heap Analysis Tool 命令是与 jmap 搭配使用,用来分析 jmap 生成的 dump,jhat 内置了一个微型的 HTTP/HTML 服务器,生成 dump 的分析结果后,可以在浏览器中查看
  • jstack,用于生成 java 虚拟机当前时刻的线程快照。
  • jinfo,JVM Configuration info 这个命令作用是实时查看和调整虚拟机运行参数

JVM 调优工具有哪些?

JDK 提供的调优工具除了上面的命令行工具,还有可视化工具:jconsole,jvisualvm

其他可视化工具有:MAT。Memory Analyzer Tool,一个基于 Eclipse 的内存分析工具,是一个快速、功能丰富的 Java heap 分析工具,它可以帮助我们查找内存泄漏和减少内存消耗

Alibaba 开源的命令行工具:Arthas