# JVM面试问题

# JVM的内存模型

jvm
  • 线程独占:栈,本地方法栈,程序计数器
  • 线程共享:堆,方法区
  1. :又称方法栈,线程私有的,线程执行方法是都会创建一个栈阵,用来存储局部变量表,操作栈,动态链接,方法 出口等信息.调用方法时执行入栈,方法返回式执行出栈。
  2. :JVM内存管理最大的一块,对被线程共享,目的是存放对象的实例,几乎所欲的对象实例都会放在这里,当堆没有可用空间时,抛出OOM异常.根据对象的存活周期不同,JVM把对象进行分代管理,由垃圾回收器进行和管理。
  3. 方法区:又称非堆区,用于存储已被虚拟机加载的类信息,常量,静态变量,即时编译器优化后的代码等数据1.7的永久代和1.8的元空间都是方法区的一种实现。
  4. 本地方法栈:与栈类似,也是用来保存执行方法的信息.执行Java方法是使用栈,执行Native方法时使用本地方法栈。
  5. 程序计数器:保存着当前线程执行的字节码位置,每个线程工作时都有独立的计数器,只为执行Java方法服务,执行 Native方法时,程序计数器为空。

# 堆和栈的区别

  • 栈由操作系统自动分配释放 ,存放函数的参数值、局部变量等,其操作方式类似于数据结构中的栈
  • 堆由程序员分配释放,若程序员不释放,程序结束时由OS回收,分配方式倒是类似于链表
  1. 管理方式不同。栈由操作系统自动分配释放,无需我们手动控制;堆的申请和释放工作由程序员控制,容易产生内存泄漏;
  2. 空间大小不同。每个进程拥有的栈的大小要远远小于堆的大小。理论上,程序员可申请的堆大小为虚拟内存的大小,进程栈的大小64bits的Windows默认1M,64bits的Linux默认10M;
  3. 生长方向不同。堆的生长方向向上,内存地址由低到高;栈的生长方向向下,内存地址由高到低。
  4. 分配方式不同。堆都是动态分配的,没有静态分配的堆。栈有2种分配方式:静态分配和动态分配。静态分配是由操作系统完成的,比如局部变量的分配。动态分配由alloca函数进行分配,但是栈的动态分配和堆是不同的,他的动态分配是由操作系统进行释放,无需我们手工实现。
  5. 分配效率不同。栈由操作系统自动分配,会在硬件层级对栈提供支持:分配专门的寄存器存放栈的地址,压栈出栈都有专门的指令执行,这就决定了栈的效率比较高。堆则是由C/C++提供的库函数或运算符来完成申请与管理,实现机制较为复杂,频繁的内存申请容易产生内存碎片。显然,堆的效率比栈要低得多。
  6. 栈(stack):主要保存基本类型(或者叫内置类型)(char、byte、short、int、long、float、double、boolean)和对象的引用,数据可以共享,速度仅次于寄存器(register),快于堆。 堆(heap):用于存储对象。

# JVM的异常与解决方法

栈溢出原因就是方法执行时创建的栈帧超过了栈的深度。最有可能的就是方法递归调用产生这种结果。

堆内存不足:这种场景最为常见,报错信息:java.lang.OutOfMemoryError: Java heap space

原因:

  • 代码中可能存在大对象分配。
  • 可能存在内存泄露,导致在多次GC之后,还是无法找到一块足够大的内存容纳当前对象。

解决方法

  • 检查是否存在大对象的分配,最有可能的是大数组分配。
  • 通过jmap命令,把堆内存dump下来,使用mat工具分析一下,检查是否存在内存泄露的问题。
  • 如果没有找到明显的内存泄露,使用-Xmx加大堆内存。
  • 还有一点容易被忽略,检查是否有大量的自定义的 Finalizable对象,也有可能是框架内部提供的,考虑其存在的必要性。

永久代/元空间溢出 报错信息:java.lang.OutOfMemoryError: PermGen space

原因

  • 永久代是 HotSot 虚拟机对方法区的具体实现,存放了被虚拟机加载的类信息、常量、静态变量、JIT编译后的代码等。
  • JDK8后,元空间替换了永久代,元空间使用的是本地内存,还有其它细节变化:字符串常量由永久代转移到堆中,和永久代相关的JVM参数已移除.
  • 在Java7之前,频繁的错误使用String.intern方法.
  • 生成了大量的代理类,导致方法区被撑爆,无法卸载.
  • 应用长时间运行,没有重启.

解决方法

  • 检查是否永久代空间或者元空间设置的过小
  • 检查代码中是否存在大量的反射操作
  • dump之后通过mat检查是否存在大量由于反射生成的代理类
  • 放大招,重启JVM

GC overhead limit exceeded 报错信息:java.lang.OutOfMemoryError:GC overhead limit exceeded

原因

  1. 这个是JDK6新加的错误类型,一般都是堆太小导致的。
  2. Sun 官方对此的定义:超过98%的时间用来做GC并且回收了不到2%的堆内存时会抛出此异常。

解决方法

  1. 检查项目中是否有大量的死循环或有使用大内存的代码,优化代码。
  2. 添加参数-XX:-UseGCOverheadLimit 禁用这个检查,其实这个参数解决不了内存问题,只是把错误的信息延后,最终出现 java.lang.OutOfMemoryError: Java heap space。
  3. dump内存,检查是否存在内存泄露,如果没有,加大内存。

方法栈溢出 报错信息:java.lang.OutOfMemoryError : unable to create new native Thread

原因

  • 出现这种异常,基本上都是创建的了大量的线程导致的,以前碰到过一次,通过jstack出来一共8000多个线程。

解决方法

  • 通过-Xss降低的每个线程栈大小的容量
  • 线程总数也受到系统空闲内存和操作系统的限制,检查是否该系统下有此限制:
    • /proc/sys/kernel/pid_max
    • /proc/sys/kernel/thread-max
    • max_user_process(ulimit -u)
    • /proc/sys/vm/max_map_count

分配超大数组 报错信息: java.lang.OutOfMemoryError: Requested array size exceeds VM limit

  • 这种情况一般是由于不合理的数组分配请求导致的,在为数组分配内存之前,JVM 会执行一项检查。要分配的数组在该平台是否可以寻址(addressable), 如果不能寻址(addressable)就会抛出这个错误。
  • 解决方法就是检查你的代码中是否有创建超大数组的地方。

swap区溢出 报错信息: java.lang.OutOfMemoryError: Out of swap space

这种情况一般是操作系统导致的

  • swap 分区大小分配不足;
  • 其他进程消耗了所有的内存。

解决方案

  • 其它服务进程可以选择性的拆分出去
  • 加大swap分区大小,或者加大机器内存大小

本地方法溢出 报错信息:java.lang.OutOfMemoryError: stack_trace_with_native_method

本地方法在运行时出现了内存分配失败,和之前的方法栈溢出不同,方法栈溢出发生在JVM代码层面,而本地方法溢出发生在JNI代码或本地方法处。 这个异常出现的概率极低,就算出现,只能通过操作系统本地工具进行诊断,难度有点大,还是放弃为妙。

遇到过元空间溢出吗?

元空间(Metaspace)默认是没有上限的,不加限制比较危险。当应用中的Java类过多,比如Spring等一些使用动态代理的框架生成了很多类, 如果占用空间超出了我们的设定值,就会发生元空间溢出。所以,默认风险大,但如果你不给足它空间,它也会溢出。

遇到过堆外内存溢出吗?

使用了Unsafe类申请内存,或者使用了JNI对内存进行操作。这部分内存是不受JVM控制的,不加限制的使用,容易发生内存溢出。

JVM的永久代中会发生垃圾回收么

  • Full GC为一次特殊GC行为的描述,这次GC会回收整个堆的内存,包含老年代,新生代,metaspace等.
  • 而1.7以前的jdk采用的是永久代作为方法区的实现,在1.7及以前的jdk版本,永久代的空间不足也会导致fullGC,1.7以前,永久代空间如果设小了,就会触发整个堆的一次full GC(注意是触发堆的full GC),经过这样的一次定位就初步定位到了是由于永久代空间不足导致了堆的full GC。
  • 所以垃圾回收会发生在永久代,如果永久代满了或者是超过了临界值,会触发完全垃圾回收(Full GC)。如 果你仔细查看垃圾收集器的输出信息,就会发现永久代也是被回收的。这就是为什么正确的永久代大小 对避免Full GC是非常重要的原因。
  • 但是在1.8以后由于改成了元空间,它的垃圾回收就不是由java来控制了,元空间的默认情况下内存空间是使用的操作系统的内存空间,所以空间的容量是比较充裕的,发生OOMM的概率较小,但是也有可能发生。

# JVM中对象分配规则

  1. 对象优先分配在Eden区,如果Eden区没有足够的空间时,虚拟机执行一次Minor GC。
  2. 大对象直接进入老年代(大对象是指需要大量连续内存空间的对象)。这样做的目的是避免在 Eden区和两个Survivor区之间发生大量的内存拷贝(新生代采用复制算法收集内存)。
  3. 长期存活的对象进入老年代。虚拟机为每个对象定义了一个年龄计数器,如果对象经过了1次 Minor GC那么对象会进入Survivor区,之后每经过一次Minor GC那么对象的年龄加1,知道达到 阀值对象进入老年区。
  4. 动态判断对象的年龄。如果Survivor区中相同年龄的所有对象大小的总和大于Survivor空间的一 半,年龄大于或等于该年龄的对象可以直接进入老年代。
  5. 空间分配担保。每次进行Minor GC时,JVM会计算Survivor区移至老年区的对象的平均大小,如 果这个值大于老年区的剩余值大小则进行一次Full GC,如果小于检查HandlePromotionFailure设 置,如果true则只进行Monitor GC,如果false则进行Full GC。

对象的优先分配在年轻代? 不是。当新生代内存不够时,老年代分配担保。而大对象则是直接在老年代分配。

# 对象内存布局

object-header

对象在堆内存的存储布局可分为对象头、实例数据和对齐填充。

  1. 对象头占 12B,包括对象标记和类型指针。对象标记存储对象自身的运行时数据,如哈希码、GC 分代年龄、锁标志、偏向线程 ID 等,这部分占 8B,称为 Mark Word。Mark Word 被设计为动态数据结构,以便在极小的空间存储更多数据,根据对象状态复用存储空间。类型指针是对象指向它的类型元数据的指针,占 4B。JVM 通过该指针来确定对象是哪个类的实例。
  2. 实例数据是对象真正存储的有效信息,即本类对象的实例成员变量和所有可见的父类成员变量。存储顺序会受到虚拟机分配策略参数和字段在源码中定义顺序的影响。相同宽度的字段总是被分配到一起存放,在满足该前提条件的情况下父类中定义的变量会出现在子类之前。
  3. 对齐填充不是必然存在的,仅起占位符作用。虚拟机的自动内存管理系统要求任何对象的大小必须是 8B 的倍数,对象头已被设为 8B 的 1 或 2 倍,如果对象实例数据部分没有对齐,需要对齐填充补全。

# Java对象创建过程

object-create
  1. 检查类是否已经被加载:new关键字时创建对象时,首先会去运行时常量池中查找该引用所指向的类有没有被虚拟机加载,如果没有被加载,那么会进行类的加载过程。类的加载过程需要经历:加载、链接、初始化三个阶段。
  2. 为对象分配内存空间: 此时,对象所属类已经加载,现在需要在堆内存中为该对象分配一定的空间,该空间的大小在类加载完成时就已经确定下来了。为对象分配内存空间有两种方式:
    • 第一种jvm将堆区抽象为两块区域,一块是已经被其他对象占用的区域,另一块是空白区域,中间通过一个指针进行标注,这时只需要将指针向空白区域移动相应大小空间,就完成了内存的分配,当然这种划分的方式要求虚拟机的对内存是地址连续的,且虚拟机带有内存压缩机制,可以在内存分配完成时压缩内存,形成连续地址空间,这种分配内存方式成为“指针碰撞”,但是很明显,这种方式也存在一个比较严重的问题,那就是多线程创建对象时,会导致指针划分不一致的问题,例如A线程刚刚将指针移动到新位置,但是B线程之前读取到的是指针之前的位置,这样划分内存时就出现不一致的问题,解决这种问题,虚拟机采用了循环CAS操作来保证内存的正确划分。
    • 第二种也是为了解决第一种分配方式的不足而创建的方式,多线程分配内存时,虚拟机为每个线程分配了不同的空间,这样每个线程在分配内存时只是在自己的空间中操作,从而避免了上述问题,不需要同步。当然,当线程自己的空间用完了才需要需申请空间,这时候需要进行同步锁定。为每个线程分配的空间称为“本地线程分配缓冲(TLAB)”,是否启用TLAB需要通过 -XX:+/-UseTLAB参数来设定。
  3. 为对象的字段赋默认值:分配完内存后,需要对对象的字段进行零值初始化(赋默认值),对象头除外。 值初始化意思就是对对象的字段赋0值,或者null值,这也就解释了为什么这些字段在不需要进程初始化时候就能直接使用。
  4. 设置对象头:对这个将要创建出来的对象,进行信息标记,包括是否为新生代/老年代,对象的哈希码,元数据信息,这些标记存放在对象头信息中。
  5. 执行实例的初始化方法init:linit方法包含成员变量、构造代码块的初始化,按照声明的顺序执行。
  6. 执行构造方法:执行对象的构造方法。至此,对象创建成功。

# jvm堆中的新生代

新生代用来存放新生的对象,新生代中的对象朝生夕死,所以会频繁的触发 minor (脉了)GC 进行垃圾回收。新生代分为 eden 区、survivor from 区和 survivor to 区。 eden区是java新对象的出生地,如果新创建的对象占用内存很大的话就会直接分配到老年代。当eden区的内存不足时就会触发 minor gc 对新生代进行一次垃圾回收。 survivor from 区存放的是上一次minor gc 的幸存者,它将作为这一次gc的被扫描者。survivor to 区会保留这一次gc的幸存者。

新生代 minor gc 的流程是:它采用的复制算法,首先eden区和survivor from区中存活的对象复制到survivor to区域, 并将它们的年龄加一。然后清空eden区和survivor from区中的对象,接着将survivor from和survivor to互换, 也就是原先的survivor to成为下一次gc时的survivor from。(这样要注意的是,如果有对象的年龄达到了老年代的标准, 就放进老年代;如果survivor to区域的空间不够的话,就会通过分配担保机制,将多出来的对象提前转到老年代, 但老年代要进行担保的前提是自己本身还有容纳这些对象的剩余空间,由于无法提前知道会有多少对象存活下来, 所以这里是取之前每次晋升到老年代的对象的平均大小作为经验值,与老年代的剩余空间做比较)

# jvm堆中的老生代

老年代主要存放生命周期较长的内存对象,所以不会频繁的进行垃圾回收。老年代采用的是标记清除算法,也就是首先扫描一次老年代,标记出存活对象,然后回收没有标记的对象。

java8之前,jvm堆中还有一块称作永久代的区域,主要存放class和元数据的信息,class被加载的时候就会被放入永久代,gc不会在主程序运行期间对永久代进行清理, 这样会导致一个问题,就是永久代区域会随着加载的class的增多而胀满,最终抛出OOM异常。

java8移除了永久代,取而代之的是一个叫做元数据区的概念,也叫做元空间。元空间和永久代是类似的,但它们最大的区别是元空间并不在虚拟机中, 而是使用的本地内存,因此默认情况下,元空间的大小仅受本地内存的限制。也就是类的元数据放入本地内存中,字符串池和类的静态变量放入java堆中, 这样可以加载多少类的元数据就由系统实际可用空间来控制了。

# 永久代和元空间

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

  1. 永久代和元空间的作用都是存储类的元数据,用来存储class相关信息,包括class对象的Method,Field等
  2. 永久代和元空间的区别本质只有一个,就是永久代使用的是JVM内存存储,元空间使用的是本地内存存储。

# 什么要废除永久代

  1. 由于永久代内存经常不够用或者发生内存泄露,爆出异常java.lang.OutOfMemoryError: PermGen 。
  2. 类及方法的信息等比较难确定其大小,因此对于永久代的大小指定比较困难,太小容易出现永久代溢出,太大则容易导致老年代溢出。
  3. 永久代会位GC带来不必要的复杂度,而且回收效率偏低

# 什么情况会造成元空间溢出?

  1. JDK8后,元空间替换了永久代,元空间使用的是本地内存,还有其它细节变化:字符串常量由永久代转移到堆中,和永久代相关的JVM参数已移除.
  2. 在Java7之前,频繁的错误使用String.intern方法.
  3. 生成了大量的代理类,导致方法区被撑爆,无法卸载.
  4. 应用长时间运行,没有重启.

# 永久代/元空间溢出的解决方法

  1. 检查是否永久代空间或者元空间设置的过小
  2. 检查代码中是否存在大量的反射操作
  3. dump之后通过mat检查是否存在大量由于反射生成的代理类
  4. 放大招,重启JVM

# 对象是怎么从年轻代进入老年代的?

  1. 占用内存较大的对象,直接进入老年代,这个“大”由参数-XX:PretenureSizeThreshold来决定,超过这个参数设置的值就直接进入老年代,例如很长的字符串、很大的数组。
  2. 正常创建一个对象,对象内存布局,包含三部分信息(对象头、实例数据、对齐数据),对象头中存储的就是两部分信息,一部分是对象的运行时数据(GC年龄、锁信息等), 一部分是类型指针,GC年龄在对象初始化时为1,每经过一次minorGC年龄增1,达到系统设置XX:MaxTenuringThreshold年龄值之后,进入老年代。 当一个对象从Eden区到了Survivor区**,当 Survivor 空间中相同年龄所有对象的大小总和大于 Survivor 空间的一半, 年龄大于或等于该年龄的对象就可以直接进入老年代,而不需要达到默认的分代年龄

# JVM中class文件加载原理

类加载有几个过程:加载、验证、准备、解析、初始化。

当 Java程序需要使用某个类时,JVM会确保这个类已经被加载、连接(验证、准备和解析)和初始化。 类的加载是指把类的.class 文件中的数据读入到内存中,通常是创建一个字节数组读入.class 文件, 然后 产生与所加载类对应的 Class 对象。加载完成后,Class 对象还不完整,所以此时的类还不可用。 当类被加载后就进入连接阶段,这一阶段 包括验证、准备(为静态变量分配内存并设置默认的初始值)和解析(将符号引用替换为直接引用)三个步骤。 最后 JVM 对类进行初始化,包括:

  1. 如果类存在直接的父类并且这个类还没有被初始化,那么就先初始化父类;
  2. 如果类中存在初始化语句,就依次执行这些初始化语句。

# JVM类加载器类型

ClassLoader
  1. 启动类加载器(Bootstrp ClassLoader),加载 /lib/rt.jar、-Xbootclasspath。
  2. 扩展类加载器(Extension ClassLoader)sun.misc.Launcher$ExtClassLoader,加载 /lib/ext、java.ext.dirs。
  3. 应用程序类加载器(Application ClassLoader,sun.misc.Launcher$AppClassLoader),加载 CLASSPTH、-classpath、-cp、Manifest。
  4. 自定义类加载器(user ClassLoader)。

# jVM类加载为什么要使用双亲委派模式

**双亲委托模型的重要用途是为了解决类载入过程中的安全性问题。**假设有一个开发者自己编写了一个名为java.lang.Object的类,想借此欺骗JVM。 现在他要使用自定义ClassLoader来加载自己编写的java.lang.Object类。然而幸运的是,双亲委托模型不会让他成功。 因为JVM会优先在Bootstrap ClassLoader的路径下找到java.lang.Object类,并载入它。

# Java的类加载是否一定遵循双亲委托模型?

  1. 在实际开发中,我们可以通过自定义ClassLoader,并重写父类的loadClass方法,来打破这一机制。
  2. SPI就是打破了双亲委托机制的(SPI:服务提供发现)。

# 判断对象是否为垃圾

引用计数器: 也就是为每一个对象添加一个引用计数器,用来统计指向当前对象的引用次数,如果当前对象存在应用的更新,那么就对这个引用计数器进行增加, 一旦这个引用计数器变成0,就意味着它可以被回收了。这种方法需要额外的空间来存储引用计数器,但是它的实现很简单,而且效率也比较高。 不过主流的JVM都没有采用这种方式,因为引用计数器在处理一些复杂的循环引用或者相互依赖的情况时, 可能会出现一些不再使用但是又无法回收的内存,造成内存泄露的问题。

可达性分析可达性分析是目前主流JVM使用的算法。它的主要思想是,首先确定一系列肯定不能回收的对象作为GC root,比如虚拟机栈里面的引用对象、 本地方法栈引用的对象等,然后以GC ROOT作为起始节点,从这些节点开始向下搜索,去寻找它的直接和间接引用的对象,当遍历完之后如果发现有一些对象不可到达, 那么就认为这些对象已经没有用了,需要被回收。在垃圾回收的时候,JVM会首先找到所有的GC root,这个过程会暂停所有用户线程, 也就是stop the world,然后再从GC Roots这些根节点向下搜索,可达的对象保留,不可达的就会回收掉。

# 垃圾回收算法

标记清除算法( Mark-Sweep):最基础的垃圾回收算法,分为两个阶段,标注和清除。标记阶段标记出所有需要回收的对象, 清除阶段 回收被标记的对象所占用的空间。该算法最大的问题是内存碎片化严重,后续可能发生大对象不能找到可利用空间的问题。

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

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

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

GC

# 新生代的算法实现

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

# 老年代的算法实现

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

  1. JAVA 虚拟机提到过的处于方法区的永生代(Permanet Generation), 它用来存储 class 类,常量, 方法描述等。对永生代的回收主要包括废弃常量和无用的类。
  2. 对象的内存分配主要在新生代的 Eden Space 和 Survivor Space 的 From Space(Survivor 目前存 放对象的那一块),少数情况会直接分配到老生代。
  3. 当新生代的 Eden Space 和 From Space 空间不足时就会发生一次 GC,进行 GC 后, EdenSpace 和 From Space 区的存活对象会被挪到 To Space,然后将 Eden Space 和 FromSpace 进行清理。
  4. 如果 To Space 无法足够存储某个对象,则将这个对象存储到老生代。
  5. 在进行 GC 后,使用的便是 Eden Space 和 To Space 了,如此反复循环。
  6. 当对象在 Survivor 区躲过一次GC 后,其年龄就会+1。 默认情况下年龄到达 15 的对象会被移到 老生代中。

# MinorGC,MajorGC、FullGC都什么时候发生?

  1. Minor GC:发生在年轻代的 GC。当Eden区满时,触发Minor GC。
  2. Major GC:发生在老年代的 GC。
  3. Full GC:全堆垃圾回收。比如 Metaspace 区引起年轻代和老年代的回收。

# FullGC的发生条件

  1. System.gc()方法的调用。此方法的调用是建议JVM进行Full GC,虽然只是建议而非一定,但很多情况下它会触发 Full GC,从而增加Full GC的频率,也即增加了间歇性停顿的次数。强烈影响系建议能不使用此方法就别使用,让虚拟机自己去管理它的内存,可通过通过-XX:+ DisableExplicitGC来禁止RMI(Java远程方法调用)调用System.gc。
  2. 老年代空间不足。 老年代空间只有在新生代对象转入及创建为大对象、大数组时才会出现不足的现象,当执行Full GC后空间仍然不足,则抛出错误:java.lang.OutOfMemoryError: Java heap space 。为避免以上两种状况引起的FullGC,调优时应尽量做到让对象在Minor GC阶段被回收、让对象在新生代多存活一段时间及不要创建过大的对象及数组。
  3. Permanet Generation空间满了。Permanet Generation中存放的为一些class的信息等,当系统中要加载的类、反射的类和调用的方法较多时,Permanet Generation可能会被占满,在未配置为采用CMS GC的情况下会执行Full GC。如果经过Full GC仍然回收不了,那么JVM会抛出错误信息:java.lang.OutOfMemoryError: PermGen space 。为避免Perm Gen占满造成Full GC现象,可采用的方法为增大Perm Gen空间或转为使用CMS GC。
  4. 通过Minor GC后进入老年代的平均大小大于老年代的可用内存
  5. 由Eden区、From Space区向To Space区复制时,对象大小大于To Space可用内存,则把该对象转存到老年代,且老年代可用内存不足(老年代可用内存小于该对象)

# 垃圾分代收集的过程?

headp

堆内存分为新生代和老年代,新生代默认占总空间的1/3,老年代默认占2/3。新生代使用复制算法,有3个分区:Eden、To Survivor、From Survivor,它们的默认占比是 8:1:1。

当新生代中的Eden区内存不足时,就会触发MinorGC,过程如下:

  1. 在Eden区执行了第一次 GC 之后,存活的对象会被移动到其中一个Survivor 分区;
  2. Eden区再次GC,这时会采用复制算法,将Eden和from区一起清理,存活的对象会被复制到 to 区;
  3. 移动一次,对象年龄加 1,对象年龄大于一定阀值会直接移动到老年代
  4. Survivor 区相同年龄所有对象大小的总和 (Survivor 区内存大小 * 这个目标使用率)时,大于或等于该年龄的对象直接进入老年代。其中这个使用率通过 -XX:TargetSurvivorRatio 指定,默认为 50%
  5. Survivor 区内存不足会发生担保分配
  6. 超过指定大小的对象可以直接进入老年代

Major GC,指的是老年代的垃圾清理,但并未找到明确说明何时在进行Major GC

FullGC,整个堆的垃圾收集,触发条件:

  1. 每次晋升到老年代的对象平均大小>老年代剩余空间。
  2. MinorGC后存活的对象超过了老年代剩余空间。
  3. 元空间不足。
  4. System.gc() 可能会引起。
  5. CMS GC异常,promotion failed:MinorGC时,survivor空间放不下,对象只能放入老年代,而老年代也放不下造成;concurrent mode failure:GC时,同时有对象要放入老年代,而老年代空间不足造成
  6. 堆内存分配很大的对象。

# 垃圾回收器的类型

GC算法(引用计数/复制/标清/标整)是内存回收的方法论,垃圾收集器就是算法落地实现。因为目前为止还没有完美的收集器出现, 更加没有万能的收集器,只是针对巨日应用最合适的收集器,进行分代收集。主要的垃圾收集器:

  1. 串行垃圾回收器(Serial): 它为单线程环境设计且只使用一个线程进行垃圾回收,会暂停所有的用户线程,不适合服务器环境
  2. 并行垃圾回收器(Parallel): 多个垃圾收集线程并行工作,此时用户线程是暂停的,适用于科学计算/大数据处理首台处理等弱交互场景。
  3. 并发垃圾回收器(CMS): 用户线程和垃圾收集线程同时执行(不一定是并行,可能是交替执行),不需要停顿用户线程。互联网公司多用它,适用对响应时间有要求的场景(强交互的环境)
  4. G1垃圾回收器: 将堆内存分割成不同的区域,然后并发的对其进行垃圾回收

# CMS垃圾回收期的工作原理

  1. 初始标记
  2. 并发标记
  3. 并发预清理
  4. 并发可取消的预清理
  5. 重新标记
  6. 并发清理

# CMS都有哪些问题?

  1. 内存碎片问题。Full GC的整理阶段,会造成较长时间的停顿。
  2. 需要预留空间,用来分配收集阶段产生的“浮动垃圾“。
  3. 使用更多的CPU资源,在应用运行的同时进行堆扫描。
  4. 停顿时间是不可预期的。

# ZGC垃圾收集器原理

JDK11 中加入的具有实验性质的低延迟垃圾收集器,目标是尽可能在不影响吞吐量的前提下,实现在任意堆内存大小都可以把停顿时间限制在 10ms 以内的低延迟。 基于Region内存布局,不设分代,使用了读屏障、染色指针和内存多重映射等技术实现可并发的标记-整理,以低延迟为首要目标。ZGC的 Region 具有动态性, 是动态创建和销毁的,并且容量大小也是动态变化的。

# JVM四大引用作用

  1. JAVA强引用:在Java中最常见的就是强引用, 把一个对象赋给一个引用变量,这个引用变量就是一个强引用。当一个对象被强引用变量引用时,它处于可达状态,它是不可能被垃圾回收机制回收的,即使该对象以后永远都不会被用到JVM也不会回收。因此强引用是造成Java内存泄漏的主要原因之一我们平时申明变量使用的就是强引用,普通系统99%以上都是强引用,比如,String s="Hello World"
  2. JAVA软引用: 软引用需要用 SoftReference 类来实现,对于只有软引用的对象来说,当系统内存足够时它不会被回收,当系统内存空间不足时它会被回收。软引用通常用在对内存敏感的程序中 。软引用的主要场景是:内存中不重要的数据缓存 String str = new String("xxx"); SoftReference<String> softStr = new SoftReference<String>(str); str = null; softStr.get();
  3. JAVA弱引用:弱引用需要用 WeakReference 类来实现,它比软引用的生存期更短,对于只有弱引用的对象来说,只要垃圾回收机制一运行,不管JVM的内存空间是否足够,总会回收该对象占用的内存。在 Java 中最常见的就是强引用, 把一个对象赋给一个引用变量,这个引用变量就是一个强引用。当一个对象被强引用变量引用时,它处于可达状态,它是不可能被垃圾回收机制回收的,即使该对象以后永远都不会被用到 JVM 也不会回收。因此强引用是造成 Java 内存泄漏的主要原因之一。Hashmap的使用就是一种的弱引用,
  4. JAVA虚引用:虚引用需要 PhantomReference 类来实现,它不能单独使用,必须和引用队列联合使用。 虚引用的主要作用是跟踪对象被垃圾回收的状态。虚引用来做finalize不一定能做到的事

# 逃逸分析

为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成与本地平台相关的机器码,并进行各种层次的优化。 完成这个任务的编译器,就称为即时编译器(Just In Time Compiler),简称 JIT 编译器。

# 指令重排序?

指令重排:为了提高性能,编译器和和处理器通常会对指令进行指令重排序。

jvm001

图中的三个重排位置可以调换的,根据系统优化需要进行重排。遵循的原则是单线程重排后的执行结果要与顺序执行结果相同。 内存屏障指令:volatile在指令之间插入内存屏障,保证按照特定顺序执行和某些变量的可见性。volatile就是通过内存屏障通知cpu和编译器不做指令重排优化来维持有序性。

# 你能保证GC执行吗

不能,虽然你可以调用 System.gc() 或者 Runtime.gc(),但是没有办法保证 GC的执行。

# safepoint是什么?

STW并不会只发生在内存回收的时候。现在程序员这么卷,碰到几次safepoint的问题几率也是比较大的。当发生GC时,用户线程必须全部停下来,才可以进行垃圾回收, 这个状态我们可以认为JVM是安全的(safe),整个堆的状态是稳定的。 如果在GC前,有线程迟迟进入不了safepoint,那么整个JVM都在等待这个阻塞的线程,造成了整体GC的时间变长。

# SWAP会影响性能么?

当操作系统内存不足的时候,会将部分数据写入到SWAP交换分中,但是SWAP的性能是比较低的。如果应用的访问量较大,需要频繁申请和销毁内存,就容易发生卡顿。一般高并发场景下,会禁用SWAP。

# JVM 配置参数有哪些?

日志

  1. -XX:+PrintFlagsFinal,打印JVM所有参数的值
  2. -XX:+PrintGC,打印GC信息
  3. -XX:+PrintGCDetails,打印GC详细信息
  4. -XX:+PrintGCTimeStamps,打印GC的时间戳
  5. -Xloggc:filename,设置GC log文件的位置
  6. -XX:+PrintTenuringDistribution,查看熬过收集后剩余对象的年龄分布信息

内存设置

  1. -Xms,设置堆的初始化内存大小
  2. -Xmx,设置堆的最大内存
  3. -Xmn,设置新生代内存大小
  4. -Xss,设置线程栈大小
  5. -XX:NewRatio,新生代与老年代比值
  6. -XX:SurvivorRatio,新生代中Eden区与两个Survivor区的比值,默认为8,即Eden:Survivor:Survivor=8:1:1
  7. -XX:MaxTenuringThreshold,从年轻代到老年代,最大晋升年龄。CMS 下默认为 6,G1 下默认为 15
  8. -XX:MetaspaceSize,设置元空间的大小,第一次超过将触发 GC
  9. -XX:MaxMetaspaceSize,元空间最大值
  10. -XX:MaxDirectMemorySize,用于设置直接内存的最大值,限制通过 DirectByteBuffer 申请的内存
  11. -XX:ReservedCodeCacheSize,用于设置 JIT 编译后的代码存放区大小,如果观察到这个值有限制,可以适当调大,一般够用即可

设置垃圾收集相关

  1. -XX:+UseSerialGC,设置串行收集器
  2. -XX:+UseParallelGC,设置并行收集器
  3. -XX:+UseConcMarkSweepGC,使用CMS收集器
  4. -XX:ParallelGCThreads,设置Parallel GC的线程数
  5. -XX:MaxGCPauseMillis,GC最大暂停时间 ms
  6. -XX:+UseG1GC,使用G1垃圾收集器

CMS 垃圾回收器相关

  1. -XX:+UseCMSInitiatingOccupancyOnly。
  2. -XX:CMSInitiatingOccupancyFraction,与前者配合使用,指定MajorGC的发生时机。
  3. -XX:+ExplicitGCInvokesConcurrent,代码调用 System.gc() 开始并行 FullGC,建议加上这个参数。
  4. -XX:+CMSScavengeBeforeRemark,表示开启或关闭在 CMS 重新标记阶段之前的清除(YGC)尝试,它可以降低 remark 时间,建议加上。
  5. -XX:+ParallelRefProcEnabled,可以用来并行处理 Reference,以加快处理速度,缩短耗时。

G1 垃圾回收器相关

  1. -XX:MaxGCPauseMillis,用于设置目标停顿时间,G1 会尽力达成。
  2. -XX:G1HeapRegionSize,用于设置小堆区大小,建议保持默认。
  3. -XX:InitiatingHeapOccupancyPercent,表示当整个堆内存使用达到一定比例(默认是 45%),并发标记阶段就会被启动。
  4. -XX:ConcGCThreads,表示并发垃圾收集器使用的线程数量,默认值随JVM运行的平台不同而变动,不建议修改。

# JVM 提供的常用工具

  1. jps:用来显示本地的 Java 进程,可以查看本地运行着几个 Java 程序,并显示他们的进程号。 命令格式:jps
  2. jinfo:运行环境参数:Java System 属性和 JVM 命令行参数,Java class path 等信息。 命令格式:jinfo 进程 pid
  3. jstat:监视虚拟机各种运行状态信息的命令行工具。 命令格式:jstat -gc 123 250 20
  4. jstack:可以观察到 JVM 中当前所有线程的运行情况和线程当前状态。 命令格式:jstack 进程 pid
  5. jmap:观察运行中的 JVM 物理内存的占用情况(如:产生哪些对象,及其数量)。 命令格式:jmap [option] pid

# invokedynamic指令是干什么的?

invokedynamic是Java7之后新加入的字节码指令,使用它可以实现一些动态类型语言的功能。我们使用的Lambda表达式,在字节码上就是invokedynamic指令实现的。 它的功能有点类似反射,但它是使用方法句柄实现的,执行效率更高。

# 能够找到Reference Chain的对象,就一定会存活么?

JVM判断对象是否是垃圾采用的是可达性分析算法,通过 GC Roots 来判定对象存活,从GC Roots向下追溯、搜索,会产生一个叫做 Reference Chain 的链条, 但是能够找到 Reference Chain 的对象却不一定会存活,还得考虑到对象的引用类型,比如如果对象是软引用类型,那么在堆内存不足时, 该对象就会在GC时被回收,而如果对象是弱引用类型,那么只要发生了GC,该对象就会被回收。 因此能够找到 Reference Chain 的对象,不一定会存活, 但是找不到 Reference Chain 的对象,就一定会被回收。

# CPU过高解决方案

  1. CAS:限制CAS的次数。
  2. 程序破除死循环。
  3. 系统修改频繁的进行Full GC。
  4. 云服务器被黑客攻击,端口不能够被外网访问,建议Redis部署在内网,不要公开在外网。
  5. 服务器被DDOS攻击:限流、ip黑名单、图形验证码。

# CPU过高排查流程

  1. 先用top命令找出cpu占比最高的。
  2. ps -ef或者jps进一步定位,得知是一个怎样的后台程序的问题。
  3. ps -mp 进程 -o THREAD,tid,time 定位到具体的线程或者代码。
  4. 将需要的线程id转化成16进制格式(英文小写格式)。
  5. jstack 进程id | grep tid(十六进制线程id英文小写) -A60。
  6. 利用的Arthas工具排查。

# OOM问题排查

oom就是我们常说的内存溢出,它是指需要的内存空间大于系统分配的内存空间,oom后果就是项目程序crash;

  1. 请求创建一个超大对象,通常是一个大数组。(所以尽量根据自己的实际需要去初始化数组大小)
  2. 超出预期的访问量/数据量,通常是上游系统请求流量飙升,常见于各类促销/秒杀活动,可以结合业务流量指标排查是否有尖状峰值。当流程突然很高是,由于提前没有对堆的内存空间做合理的准备,所以短时间内线程会创建大量的对象,这些对象可能会短时间内迅速的占满堆内存。
  3. 用终结器(Finalizer),该对象没有立即被 GC。
  4. 内存泄漏,大量的对象引用没有释放,GC没有办法对这些内存空间进行回收导致了内存泄漏的问题。

# GC overhead limit exceeded错误

当 Java 进程花费 98% 以上的时间执行 GC,但只恢复了不到 2% 的内存,且该动作连续重复了 5 次, 就会抛出 java.lang.OutOfMemoryError:GC overhead limit exceeded错误。简单地说,就是应用程序已经基本耗尽了所有可用内存, GC 也无法回收。 此类问题的原因与解决方案跟 Java heap space非常类似

# Permgen space错误

该错误表示永久代(Permanent Generation)已用满,通常是因为加载的 class 数目太多或体积太大。 永久代存储对象主要包括以下几类:

  1. 加载/缓存到内存中的 class 定义,包括类的名称,字段,方法和字节码;
  2. 常量池;
  3. 对象数组/类型数组所关联的 class;
  4. JIT 编译器优化后的 class 信息。 PermGen 的使用量与加载到内存的 class 的数量/大小正相关。

根据 Permgen space 报错的时机,可以采用不同的解决方案,如下所示:

  1. 程序启动报错,修改 -XX:MaxPermSize 启动参数,调大永久代空间。
  2. 应用重新部署时报错,很可能是没有应用没有重启,导致加载了多份 class 信息,只需重启 JVM 即可解决。
  3. 运行时报错,应用程序可能会动态创建大量class,而这些 class 的生命周期很短暂,但是 JVM 默认不会卸载 class, 可以设置 -XX:+CMSClassUnloadingEnabled 和 -XX:+UseConcMarkSweepGC 这两个参数允许 JVM 卸载 class。 如果上述方法无法解决,可以通过 jmap 命令dump内存对象 jmap-dump:format=b,file=dump.hprof ,然后利用MAT功能逐一分析开销最大的classloader 和重复class。

# Metaspace错误

JDK 1.8 使用 Metaspace 替换了永久代(Permanent Generation),该错误表示 Metaspace 已被用满,通常是因为加载的 class 数目太多或体积太大。 此类问题的原因与解决方法跟 Permgenspace 非常类似,可以参考上文。需要特别注意的是调整 Metaspace 空间大小的启动参数为 -XX:MaxMetaspaceSize。

# Unable to create new native thread错误

每个 Java 线程都需要占用一定的内存空间,当 JVM 向底层操作系统请求创建一个新的 native 线程时,如果没有足够的资源分配就会报此类错误。 JVM 向 OS 请求创建 native 线程失败,就会抛出 Unableto createnewnativethread,常见的原因包括以下几类:

  1. 线程数超过操作系统最大线程数 ulimit 限制;
  2. 线程数超过 kernel.pid_max(只能重启);
  3. native 内存不足;

该问题发生的常见过程主要包括以下几步:

  1. JVM 内部的应用程序请求创建一个新的 Java 线程;
  2. JVM native 方法代理了该次请求,并向操作系统请求创建一个 native 线程;
  3. 操作系统尝试创建一个新的 native 线程,并为其分配内存;
  4. 如果操作系统的虚拟内存已耗尽,或是受到 32 位进程的地址空间限制,操作系统就会拒绝本次 native 内存分配;
  5. JVM 将抛出 java.lang.OutOfMemoryError:Unableto createnewnativethread错误。

解决方案

  1. 升级配置,为机器提供更多的内存;
  2. 降低 Java Heap Space 大小;
  3. 修复应用程序的线程泄漏问题;
  4. 限制线程池大小;
  5. 使用 -Xss 参数减少线程栈的大小;
  6. 调高 OS 层面的线程最大数:执行 ulimia-a 查看最大线程数限制,使用 ulimit-u xxx 调整最大线程数限制。

# Out of swap space 错误

该错误表示所有可用的虚拟内存已被耗尽。虚拟内存(Virtual Memory)由物理内存(Physical Memory)和交换空间(Swap Space)两部分组成。 当运行时程序请求的虚拟内存溢出时就会报Outof swap space错误。该错误出现的常见原因包括以下几类:

  1. 地址空间不足;
  2. 物理内存已耗光;
  3. 应用程序的本地内存泄漏(native leak),例如不断申请本地内存,却不释放。
  4. 执行 jmap-histo:live 命令,强制执行 Full GC;如果几次执行后内存明显下降,则基本确认为 Direct ByteBuffer 问题。

根据错误原因可以采取如下解决方案:

  1. 升级地址空间为 64 bit;
  2. 使用 Arthas 检查是否为 Inflater/Deflater 解压缩问题,如果是,则显式调用 end 方法。
  3. Direct ByteBuffer 问题可以通过启动参数 -XX:MaxDirectMemorySize 调低阈值。
  4. 升级服务器配置/隔离部署,避免争用。

# Kill process or sacrifice child错误

不同于其他的 OOM 错误, Killprocessorsacrifice child 错误不是由 JVM 层面触发的,而是由操作系统层面触发的。 默认情况下,Linux 内核允许进程申请的内存总量大于系统可用内存,通过这种“错峰复用”的方式可以更有效的利用系统资源。 然而,这种方式也会无可避免地带来一定的“超卖”风险。例如某些进程持续占用系统内存,然后导致其他进程没有可用内存。 此时,系统将自动激活 OOM Killer,寻找评分低的进程,并将其“杀死”,释放内存资源。

解决方案

  1. 升级服务器配置/隔离部署,避免争用。
  2. OOM Killer 调优。

# Requested array size exceeds VM limit错误

JVM 限制了数组的最大长度,该错误表示程序请求创建的数组超过最大长度限制。 JVM 在为数组分配内存前,会检查要分配的数据结构在系统中是否可寻址, 通常为Integer.MAX_VALUE-2。 此类问题比较罕见,通常需要检查代码,确认业务是否需要创建如此大的数组,是否可以拆分为多个块,分批执行。

# Direct buffer memory错误

Direct ByteBuffer 的默认大小为 64 MB,一旦使用超出限制,就会抛出 Directbuffer memory 错误。

解决方案

  1. Java 只能通过 ByteBuffer.allocateDirect 方法使用 Direct ByteBuffer,因此,可以通过 Arthas 等在线诊断工具拦截该方法进行排查。
  2. 检查是否直接或间接使用了 NIO,如 netty,jetty 等。
  3. 通过启动参数 -XX:MaxDirectMemorySize 调整 Direct ByteBuffer 的上限值。
  4. 检查 JVM 参数是否有 -XX:+DisableExplicitGC 选项,如果有就去掉,因为该参数会使 System.gc() 失效。
  5. 检查堆外内存使用代码,确认是否存在内存泄漏;或者通过反射调用 sun.misc.Cleaner 的 clean() 方法来主动释放被 Direct ByteBuffer 持有的内存空间。
  6. 内存容量确实不足,升级配置。

# HashMap存在内存泄露问题

VM的垃圾回收实现原理,这个功能是java内存对象管理者的其中一个线程,用于释放内存中不在被使用的对像,其实现过程是依次遍历对象的索引将没有被其他对象应用的对象释放掉. 也就是java的垃圾回收只能释放那些没有被引用的对象空间,有很多程序在编写的时候运行很正常,但部署到服务器不久后就有内存溢出.

那么集合在程序中造成内存溢出有两种原因:

  1. 集合装载的数据量过大,超出了JVM执行的内存范围,这个时候就有内存溢出.通常解决办法是分段读取数据量,减少内存在短时间内的开销,或者加大设 置JVM内存(默认是64M).
  2. 集合在程序中被大循环调用,不断在内存中创建无法被垃圾回收机制回收的集合对象,当程序运行到一定时期时集合对象就会沾满内存,造成内存溢出.
public class Demo {
    //定义一个静态变量
    static Integer i = 0;
    public static void main(String[] ages) {
        //无线循环调用test()方法
        while (true) {
            new Demo().test();
        }
    }
    //定义一个被循环调用的方法
    public void test() {
        i++;//i值一直在变
        //定义一个HashMap集合
        HashMap hashMap = new HashMap();
        /*
         *在此hashMap集合并没有跟随该方法的运行结束而被释放.因为集合装载有一个静态常量
         *也就是集合中的元素被其他对象引用,所以垃圾回收机制不能回收hashMap对象
         *而在方法每次执行时都创建一个新的集合去装载一个新的静态元素"i".
         *所以集合会被无限的循环创建对象存在内存中知道内存溢出
         *
         */
        hashMap.put(null, i);
    }
}

解决方案:

  1. 不要往集合中存入静态元素或者其他生命周期比集合生命周期还要长的元素.以至于使得GC能够回收释放.
  2. 如果在大型的程序中,不能确确保集合要存入其他静态变量或者使集合不能在方法运行结束后跟着释放的元素,那么在集合被使用完成后调用clear()方法移除集合中所有元素,以便之后垃圾回收机制能回收释放.
  3. 如果在程序中集合要被不断循环创建使用,那么就将该集合写成成员静态身份.防止在循环中不断创建出新的集合实例,同时也减少了程序在循环中不再去创建新集合实例时而消耗的资源.

# hashmap hashset的内存泄漏问题

  • HashMap 和 HashSet 内存泄漏的可能性是存在的,但是只有在使用不当的情况下才会发生。
  • HashMap 和 HashSet 都是基于哈希表实现的,哈希表中的每个元素都有一个键和一个值,当键和值都不再被使用时,它们就会被垃圾回收器回收,从而避免内存泄漏。
  • 但是,如果在使用 HashMap 或 HashSet 时,存储的键和值都是引用类型,而且这些引用类型的对象还被其他地方引用,那么就可能会发生内存泄漏。这是因为垃圾回收器无法回收被其他地方引用的对象,从而导致内存泄漏。
  • 因此,为了避免 HashMap 和 HashSet 内存泄漏的可能性,应该尽量避免使用引用类型的键和值,或者在不再使用时及时将其置为 null,以便垃圾回收器可以回收它们。

# 如果发生内存泄漏怎么排查

这个问题分为两个部分:1.什么是内存泄露以及会带来什么影响,2.内存泄露的排查和解决方法。

内存泄漏指的是在程序运行过程中,因为某些原因导致不需要使用的对象,仍然占用JVM的内存空间并且这块内存还无法被回收。 最终导致程序占用的内存越来越大从而出现OOM错误或者影响程序性能。一般情况下,除了OOM这种错误以外,

内存泄露会有一些比较明显的现象,比如频繁的Full GC; 内存占用量过大一直无法释放等。

内存泄露的排查,我一般会根据现象去定位问题。所以第一步,会先去定位是否是内存泄露,比如老年代逐步增长、fullGC卡顿、年轻代的内存一直在高位无法释放、频繁full gc等。

这些现象基本上都是内存出现异常。要了解gc的情况,可以使用jstat命令,查看虚拟机中各个内存区域的使用情况和gc情况。 然后使用dump工具,把当前内存dump下来,然后使用MAT工具来分析。如果dump的文件比较大,可以使用轻量级的在线分析工具jmap。 MAT工具会自动分析dump文件的内容,给出一个分析结果并定位到有问题的类,然后去对这部分代码进行优化即可

一般可能是循环引用、内存对象泄露没有被销毁、动态分配内存以后未释放、长期持有对象引用、资源未关闭等。

# 如何破坏双亲委派模型?

我们自己写的java源文件到最终运行,必须要经过编译和类加载两个阶段。编译的过程就是把.java文件编译成.class文件。 类加载的过程,就是把class文件装载到JVM内存中,装载完成以后就会得到一个Class对象,我们就可以使用new关键字来实例化这个对象。

img.png

而类的加载过程,需要涉及到类加载器。JVM在运行的时候,会产生3个类加载器,这三个类加载器组成了一个层级关系。每个类加载器分别去加载不同作用范围的jar包,比如:

  1. Bootstrap ClassLoader,主要是负责Java核心类库的加载,也就是 %{JDK_HOME}\lib下的rt.jar、resources.jar等
  2. Extension ClassLoader,主要负责%{JDK_HOME}\lib\ext目录下的jar包和class文件
  3. Application ClassLoader,主要负责当前应用里面的classpath下的所有jar包和类文件
  4. 除了系统自己提供的类加载器以外,还可以通过ClassLoader类实现自定义加载器,去满足一些特殊场景的需求。

img.png

而双亲模型,就是按照类加载器的层级关系,逐层进行委派。比如当需要加载一个class文件的时候, 首先会把这个class的查询和加载委派给父加载器去执行,如果父加载器都无法加载,再尝试自己来加载这个class。

img.png

不过,双亲委派并不是一个强制性的约束模型,我们可以通过一些方式去打破双亲委派模型。这个打破的意思, 就是类加载器可以加载不属于当前作用范围的类,实际上,JVM本身就存在双亲委派被破坏的情况。

  • 第一种情况,双亲委派是在JDK1.2版本发布的,而类加载器和抽象类ClassLoader在JDK1.0就已经存在了,用户可以通过重写ClassLoader里面的loadClass()方法实现自定义类加载, JDK1.2为了向前兼容,所以在设计的时候需要兼容loadClass()重写的实现,导致双亲委派被破坏的情况。同时,为了避免后续再出现这样的问题, 不在提倡重写loadClass()方法,而是使用JDK1.2中ClassLoader中提供了findClass方法来实现符合双亲委派规则的类加载逻辑。

  • 第二种情况,在这个类加载模型中,有可能存在顶层类加载器加载的类,需要调用用户类加载器实现的代码的情况。 比如java.jdbc.Driver接口,它只是一个数据库驱动接口,这个接口是由启动类加载器加载的。但是java.jdbc.Driver接口的实现是由各大数据库厂商来完成的,既然是自己实现的代码,就应该由应用类加载器来加载。 于是就出现了启动类加载器加载的类要调用应用类加载器加载的实现。为了解决这个问题,在JVM中引入了线程上下文类加载器,它可以把原本需要启动类加载器加载的类,由应用类加载器进行加载。 除此之外,像Tomcat容器,也存在破坏双亲委派的情况,来实现不同应用之间的资源隔离。

# JVM为什么使用元空间替换了永久代?

  • 在1.7版本里面,永久代内存是有上限的,虽然我们可以通过参数来设置,但是JVM加载的class总数、大小是很难确定的。所以很容易出现OOM问题。 但是元空间是存储在本地内存里面,内存上限比较大,可以很好的避免这个问题。
  • 永久代的对象是通过FullGC进行垃圾收集,也就是和老年代同时实现垃圾收集。替换成元空间以后,简化了Full GC。可以在不进行暂停的情况下并发地释放类数据,同时也提升了GC的性能
  • Oracle要合并Hotspot和JRockit的代码,而JRockit没有永久代。

# Happens-Before的理解

  • 首先,Happens-Before是一种可见性模型,也就是说,在多线程环境下。原本因为指令重排序的存在会导致数据的可见性问题,也就是A线程修改某个共享变量对B线程不可见。 因此,JMM通过Happens-Before关系向开发人员提供跨越线程的内存可见性保证。如果一个操作的执行结果对另外一个操作可见,那么这两个操作之间必然存在Happens-Before管理。
  • Happens-Before关系只是描述结果的可见性,并不表示指令执行的先后顺序,也就是说只要不对结果产生影响,仍然允许指令的重排序。
  • 最后,在JMM中存在很多的Happens-Before规则。
    • 程序顺序规则,一个线程中的每个操作,happens-before这个线程中的任意后续操作,可以简单认为是as-if-serial也就是不管怎么重排序,单线程的程序的执行结果不能改变
    • 传递性规则,也就是A Happens-Before B,B Happens-Before C。
    • 就可以推导出A Happens-Before C。
    • volatile变量规则,对一个volatile修饰的变量的写一定happens-before于任意后续对这个volatile变量的读操作
    • 监视器锁规则,一个线程对于一个锁的释放锁操作,一定happens-before与后续线程对这个锁的加锁操作在这个场景中,如果线程A获得了锁并且把x修改成了12,那么后续的线程获得锁之后得到的x的值一定是12。
    • 线程启动规则,如果线程A执行操作ThreadB.start(),那么线程A的ThreadB.start()之前的操作happens-before线程B中的任意操作。
    • join规则,如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功的返回。

# JVM分代年龄为什么是15次?

其次呢,一个对象的GC年龄,是存储在对象头里面的,一个Java对象在JVM内存中的布局由三个部分组成,分别是对象头、实例数据、对齐填充。而对象头里面有4个bit位来存储GC年龄。

img.png

而4个bit位能够存储的最大数值是15,所以从这个角度来说,JVM分代年龄之所以设置成15次是因为它最大能够存储的数值就是15。 虽然JVM提供了参数来设置分代年龄的大小,但是这个大小不能超过15。而从设计角度来看,当一个对象触发了最大值15次gc,还没有办法被回收,就只能移动到old generation了。 另外,设计者还引入了动态对象年龄判断的方式来决定把对象转移到old generation,也就是说不管这个对象的gc年龄是否达到了15次,只要满足动态年龄判断的依据,也同样会转移到old generation。以上就是我对这个问题的理解。

# 什么是双亲委派?

首先,我简单说一下类的加载机制:就是我们自己写的java源文件到最终运行,必须要经过编译和类加载两个阶段。

  1. 编译的过程就是把.java文件编译成.class文件。

img.png

  1. 类加载的过程,就是把class文件装载到JVM内存中,装载完成以后就会得到一个Class对象,我们就可以使用new关键字来实例化这个对象。

img.png

而类的加载过程,需要涉及到类加载器。

JVM在运行的时候,会产生3个类加载器,这三个类加载器组成了一个层级关系,每个类加载器分别去加载不同作用范围的jar包,比如

  • Bootstrap ClassLoader,主要是负责Java核心类库的加载,也就是 %{JDK_HOME}\lib下的rt.jar、resources.jar等
  • Extension ClassLoader,主要负责%{JDK_HOME}\lib\ext目录下的jar包和class文件
  • Application ClassLoader,主要负责当前应用里面的classpath下的所有jar包和类文件
  • 除了系统自己提供的类加载器以外,还可以通过ClassLoader类实现自定义加载器,去满足一些特殊场景的需求。

所谓的父委托模型,就是按照类加载器的层级关系,逐层进行委派。

img.png

比如当需要加载一个class文件的时候,首先会把这个class的查询和加载委派给父加载器去执行,如果父加载器都无法加载,再尝试自己来加载这个class。

这样设计的好处,我认为有几个。

  • 安全性,因为这种层级关系实际上代表的是一种优先级,也就是所有的类的加载,优先给Bootstrap ClassLoader。那对于核心类库中的类,就没办法去破坏,比如自己写一个java.lang.String,最终还是会交给启动类加载器。再加上每个类加载器的作用范围,那么自己写的java.lang.String就没办法去覆盖类库中类。
  • 我认为这种层级关系的设计,可以避免重复加载导致程序混乱的问题,因为如果父加载器已经加载过了,那么子类就没必要去加载了。