本文为个人学习笔记,内容大部分摘自JavaGuide,稍有部分修改,如有错误,欢迎指正

基本介绍

  • 全程Java Virtual Machine
  • 特点:直接与操作系统进行交互,与硬件不直接交互

运行机制

java文件编译成可读的.class文件

类加载器

JVM执行.class文件,需要将文件先放入类加载器中

方法区

  1. 存放类似于元数据信息方面的数据
    • eg:类信息、常量、静态变量、编译后的代码…
  2. 类加载器将.class文件搬过来就是先丢在这一块

  1. 存放一些储存的数据
    • eg:对象实例、数组…
  2. 与方法区共属线程共享区域,存在线程不安全的问题

  • 编写的方法都在此处运行
  • 8 种基本类型的变量+对象的引用变量+实例方法都是在栈里面分配内存

程序计数器

  1. 指向下一行我们需要执行的代码
  2. 和栈一样都是线程独享的

    JVM调优的主要方法就是围绕堆和栈两块进行

类加载器-ClassLoader

从类被加载到虚拟机内存中开始,到释放内存共七个步骤:加载、验证、准备、解析、初始化、使用、卸载

其中验证、准备、解析三个步骤统称为链接


类加载器的流程

加载

  1. class文件加载到内存
  2. 将静态数据结构转化成方法区中运行时的数据结构
  3. 在堆中生成一个代表这个类的java.lang.Class对象,作为数据访问的入口

链接

  1. 验证:检查字节码文件是否符合JVM规范
  2. 准备:为static变量在方法区分配内存空间初始值

    实例变量不同的,实例变量在堆中分配内存空间,在实例对象初始化时候赋值

  3. 解析:将常量池中的符号引用替换为直接引用

    引用对象一定是在内存中进行

初始化

  • 初始化其实就是执行类构造器方法的<clinit>()的过程,而且要保证执行前父类的<clinit>()方法执行完毕。这个方法由编译器收集,顺序执行所有类变量(static修饰的成员变量)显式初始化和静态代码块中语句。
  • 此时准备阶段时的那个 static int a 由默认初始化的 0 变成了显式初始化的 3。 由于执行顺序缘故,初始化阶段类变量如果在静态代码块中又进行了更改,会覆盖类变量的显式初始化,最终值会为静态代码块中的赋值。

    注意:字节码文件中初始化方法有两种,非静态资源初始化的<init>和静态资源初始化的<clinit>,类构造器方法<clinit>()不同于类的构造器,这些方法都是字节码文件中只能给 JVM 识别的特殊方法。

卸载

GC 将无用对象从内存中卸载

类加载器的加载顺序

加载一个 Class 类的顺序也是有优先级的,类加载器从最底层开始往上的顺序是这样的

  1. BootStrap ClassLoader:rt.jar
  2. Extension ClassLoader: 加载扩展的 jar
  3. App ClassLoader:指定的 classpath 下面的 jar
  4. Custom ClassLoader:自定义的类加载器

双亲委派机制

机制说明

  • 当一个类收到了加载请求时,它是不会先自己去尝试加载的,而是委派给父类去完成
  • 只有当父类加载器都反馈自己无法完成这个请求(也就是父类加载器都没有找到加载所需的 Class)时,子类加载器才会自行尝试加载。

好处

  1. 加载位于rt.jar包中的类时不管是哪个加载器加载,最终都会委托到BootStrap ClassLoader进行加载,这样保证了使用不同的类加载器得到的都是同一个结果。

    rt.jar代表runtime,包含所有核心Java 运行环境的已编译calss文件。

  2. 隔离:避免了我们的代码影响了 JDK 的代码
1
2
3
4
5
6
7
package java.lang;
public class String {
public static void main(String[] args) {
System.out.println();
}
}
//尝试运行当前类的 main 函数的时候,我们的代码肯定会报错。这是因为在加载的时候其实是找到了 rt.jar 中的java.lang.String,然而发现这个里面并没有 main 方法。

运行时数据区

本地方法栈和程序计数器

  1. 本地方法栈:比如说我们现在点开Thread类的源码,会看到它的start0方法带有一个native关键字修饰,而且不存在方法体,这种用native修饰的方法就是本地方法,这是使用 C 来实现的,然后一般这些方法都会放到一个叫做本地方法栈的区域。
  2. 程序计数器其实就是一个指针:
    • 它指向了我们程序中下一句需要执行的指令,它也是内存区域中唯一一个不会出现 OutOfMemoryError 的区域,而且占用内存空间小到基本可以忽略不计。这个内存仅代表当前线程所执行的字节码的行号指示器,字节码解析器通过改变这个计数器的值选取下一条需要执行的字节码指令。
    • 如果执行的是 native 方法,那这个指针就不工作了。

方法区

方法区主要的作用是存放类的元数据信息,常量和静态变量···等。当它存储的信息过大时,会在无法满足内存分配时报错。

虚拟机栈和虚拟机堆

栈管运行,堆管存储。则虚拟机栈负责运行代码,而虚拟机堆负责存储数据。

虚拟机栈的概念

  • 它是 Java 方法执行的内存模型。
  • 里面会对局部变量,动态链表,方法出口,栈的操作(入栈和出栈)进行存储,且线程独享。同时如果我们听到局部变量表,那也是在说虚拟机栈

虚拟机栈存在的异常

  • 如果线程请求的栈的深度大于虚拟机栈的最大深度,就会报 StackOverflowError (这种错误经常出现在递归中)。
  • Java 虚拟机也可以动态扩展,但随着扩展会不断地申请内存,当无法申请足够内存时就会报错 OutOfMemoryError

虚拟机栈的生命周期

  • 对于栈来说,不存在垃圾回收。只要程序运行结束,栈的空间自然就会释放了。栈的生命周期和所处的线程是一致的。

虚拟机栈的执行

  • 我们经常说的栈帧数据,说白了在 JVM 中叫栈帧,放到 Java 中其实就是方法,它也是存放在栈中的。
  • 栈中的数据都是以栈帧的格式存在,它是一个关于方法和运行期数据的数据集。

    比如我们执行一个方法 a,就会对应产生一个栈帧 A1,然后 A1 会被压入栈中。同理方法 b 会有一个 B1,方法 c 会有一个 C1,等到这个线程执行完毕后,栈会先弹出 C1,后 B1,A1。它是一个先进后出,后进先出原则。

局部变量的复用

  • 局部变量表用于存放方法参数和方法内部所定义的局部变量。它的容量是以 Slot 为最小单位,一个 slot 可以存放 32 位以内的数据类型。
  • 虚拟机通过索引定位的方式使用局部变量表,范围为 [0,局部变量表的 slot 的数量]。方法中的参数就会按一定顺序排列在这个局部变量表中,至于怎么排的我们可以先不关心。
  • 而为了节省栈帧空间,这些 slot可以复用的,当方法执行位置超过了某个变量,那么这个变量的 slot 可以被其它变量复用。当然如果需要复用,那我们的垃圾回收自然就不会去动这些内存。

虚拟机堆的概念

  • JVM 内存会划分为堆内存非堆内存堆内存中也会划分为年轻代老年代,而非堆内存则为永久代
  • 年轻代又会分为EdenSurvivor区。Survivor 也会分为FromPlaceToPlacetoPlacesurvivor 区域是空的。EdenFromPlaceToPlace 的默认占比为 8:1:1。当然这个东西其实也可以通过一个 -XX+UsePSAdaptiveSurvivorSizePolicy参数来根据生成对象的速率动态调整堆内存中存放的是对象,垃圾收集就是收集这些对象然后交给 GC 算法进行回收。非堆内存其实我们已经说过了,就是方法区。
  • 在 1.8 中已经移除永久代,替代品是一个元空间(MetaSpace),最大区别是 metaSpace 是不存在于 JVM 中的,它使用的是本地内存。并有两个参数
    1
    2
    MetaspaceSize:初始化元空间大小,控制发生GC
    MaxMetaspaceSize:限制元空间大小上限,防止占用过多物理内存。

    移除的原因可以大致了解一下:融合 HotSpot JVM 和 JRockit VM 而做出的改变,因为 JRockit 是没有永久代的,不过这也间接性地解决了永久代的 OOM 问题。

Eden年轻代的介绍

  • 当我们 new 一个对象后,会先放到 Eden 划分出来的一块作为存储空间的内存,但是我们知道对堆内存是线程共享的,所以有可能会出现两个对象共用一个内存的情况。这里 JVM 的处理是为每个线程都预先申请好一块连续的内存空间并规定了对象存放的位置,而如果空间不足会再申请多块内存空间。这个操作我们会称作 TLAB
  • Eden 空间满了之后,会触发一个叫做 Minor GC(就是一个发生在年轻代的 GC)的操作,存活下来的对象移动到 Survivor0 区。Survivor0 区满后触发 Minor GC,就会将存活对象移动到 Survivor1 区,此时还会把 from 和 to 两个指针交换,这样保证了一段时间内总有一个 survivor 区为空且 to 所指向的 survivor 区为空。经过多次的 Minor GC 后仍然存活的对象(这里的存活判断是 15 次,对应到虚拟机参数为 -XX:MaxTenuringThreshold 。为什么是 15,因为 HotSpot 会在对象头中的标记字段里记录年龄,分配到的空间仅有 4 位,所以最多只能记录到 15)会移动到老年代。

    🐛 修正:当 Eden 区内存空间满了的时候,就会触发 Minor GC,Survivor0 区满不会触发 Minor GC 。那 Survivor0 区 的对象什么时候垃圾回收呢?
    假设 Survivor0 区现在是满的,此时又触发了 Minor GC ,发现 Survivor0 区依旧是满的,存不下,此时会将 S0 区与 Eden 区的对象一起进行可达性分析,找出活跃的对象,将它复制到 S1 区并且将 S0 区域和 Eden 区的对象给清空,这样那些不可达的对象进行清除,并且将 S0 区 和 S1 区交换。

  • 老年代是存储长期存活的对象的,占满时就会触发我们最常听说的 Full GC,期间会停止所有线程等待 GC 的完成。所以对于响应要求高的应用应该尽量去减少发生 Full GC 从而避免响应超时的问题。
  • 而且当老年区执行了 full gc 之后仍然无法进行对象保存的操作,就会产生 OOM,这时候就是虚拟机中的堆内存不足,原因可能会是堆内存设置的大小过小,这个可以通过参数-Xms、-Xmx 来调整。也可能是代码中创建的对象大且多,而且它们一直在被引用从而长时间垃圾收集无法收集它们。
  • 补充说明:关于-XX:TargetSurvivorRatio 参数的问题。其实也不一定是要满足-XX:MaxTenuringThreshold 才移动到老年代。
    • 可以举个例子:如对象年龄 5 的占 30%,年龄 6 的占 36%,年龄 7 的占 34%,加入某个年龄段(如例子中的年龄 6)后,总占用超过 Survivor 空间*TargetSurvivorRatio 的时候,从该年龄段开始及大于的年龄对象就要进入老年代(即例子中的年龄 6 对象,就是年龄 6 和年龄 7 晋升到老年代),这时候无需等到 MaxTenuringThreshold 中要求的15。

如何判断一个对象需要被干掉

  • 程序计数器虚拟机栈本地方法栈,3 个区域随着线程的生存而生存的。内存分配和回收都是确定的。随着线程的结束内存自然就被回收了,因此不需要考虑垃圾回收的问题。而 Java 堆和方法区则不一样,各线程共享,内存的分配和回收都是动态的。因此垃圾收集器所关注的都是堆和方法这部分内存
  • 在进行回收前就要判断哪些对象还存活,哪些已经死去。下面介绍两个基础的计算方法
    1. 引用计数器计算:给对象添加一个引用计数器,每次引用这个对象时计数器加一,引用失效时减一,计数器等于 0 时就是不会再次使用的。不过这个方法有一种情况就是出现对象的循环引用时 GC 没法回收。
    2. 可达性分析计算:这是一种类似于二叉树的实现,将一系列的 GC ROOTS 作为起始的存活对象集,从这个节点往下搜索,搜索所走过的路径成为引用链,把能被该集合引用到的对象加入到集合中。搜索当一个对象到GC Roots没有使用任何引用链时,则说明该对象是不可用的。主流的商用程序语言,例如 JavaC#等都是靠这招去判定对象是否存活的。

  • (了解一下即可)在 Java 语言汇总能作为 GC Roots 的对象分为以下几种:
    1. 虚拟机栈(栈帧中的本地方法表)中引用的对象(局部变量)
    2. 方法区中静态变量所引用的对象(静态变量)
    3. 方法区中常量引用的对象
    4. 本地方法栈(即 native 修饰的方法)中 JNI 引用的对象(JNIJava 虚拟机调用对应的 C 函数的方式,通过 JNI 函数也可以创建新的 Java 对象。且 JNI 对于对象的局部引用或者全局引用都会把它们指向的对象都标记为不可回收)
    5. 已启动的且未终止的 Java 线程 这种方法的优点是能够解决循环引用的问题,可它的实现需要耗费大量资源和时间,也需要 GC(它的分析过程引用关系不能发生变化,所以需要停止所有进程

如何宣告一个对象真正的死亡

  • 首先必须要提到的是一个名叫 finalize() 的方法
    • finalize()是 Object 类的一个方法、一个对象的 finalize()方法只会被系统自动调用一次,经过 finalize()方法逃脱死亡的对象,第二次不会再调用。
    • 补充一句:并不提倡在程序中调用 finalize()来进行自救。建议忘掉 Java 程序中该方法的存在。因为它执行的时间不确定,甚至是否被执行也不确定(Java 程序的不正常退出),而且运行代价高昂,无法保证各个对象的调用顺序(甚至有不同线程中调用)。在 Java9 中已经被标记为 deprecated ,且 java.lang.ref.Cleaner(也就是强、软、弱、幻象引用的那一套)中已经逐步替换掉它,会比 finalize 来的更加的轻量及可靠。

  • 判断一个对象的死亡至少需要两次标记
    1. 如果对象进行可达性分析之后没发现与 GC Roots 相连的引用链,那它将会第一次标记并且进行一次筛选。判断的条件是决定这个对象是否有必要执行 finalize()方法。如果对象有必要执行 finalize()方法,则被放入 F-Queue 队列中。
    2. GC 对 F-Queue 队列中的对象进行二次标记。如果对象在 finalize()方法中重新与引用链上的任何一个对象建立了关联,那么二次标记时则会将它移出“即将回收”集合。如果此时对象还没成功逃脱,那么只能被回收了。

垃圾回收算法

TO DO:后续单开一篇回收算法

(了解)各种各样的垃圾回收器

  • 到 jdk8 为止,默认的垃圾收集器是 Parallel Scavenge 和 Parallel Old
  • 从 jdk9 开始,G1 收集器成为默认的垃圾收集器 目前来看,G1 回收器停顿时间最短而且没有明显缺点,非常适合 Web 应用。在 jdk8 中测试 Web 应用,堆内存 6G,新生代 4.5G 的情况下,Parallel Scavenge 回收新生代停顿长达 1.5 秒。G1 回收器回收同样大小的新生代只停顿 0.2 秒。

(了解)JVM的常用参数

图片摘自JavaGuide,链接同文章开头。




关于JVM调优的一些方面

所有线程共享数据区大小=新生代大小 + 年老代大小 + 持久代大小。持久代一般固定大小为 64m。所以 java 堆中增大年轻代后,将会减小年老代大小(因为老年代的清理是使用 fullgc,所以老年代过小的话反而是会增多 fullgc 的)。此值对系统性能影响较大,Sun 官方推荐配置为 java 堆的 3/8。

调整最大堆内存和最小堆内存

  • -Xmx –Xms:指定 java 堆最大值(默认值是物理内存的 1/4(<1GB))和初始 java 堆最小值(默认值是物理内存的 1/64(<1GB))
  • 默认(MinHeapFreeRatio 参数可以调整)空余堆内存小于 40%时,JVM 就会增大堆直到-Xmx 的最大限制.,默认(MaxHeapFreeRatio 参数可以调整)空余堆内存大于 70%时,JVM 会减少堆直到 -Xms 的最小限制。简单点来说,你不停地往堆内存里面丢数据,等它剩余大小小于 40%了,JVM 就会动态申请内存空间不过会小于-Xmx,如果剩余大小大于 70%,又会动态缩小不过不会小于–Xms。就这么简单
  • 开发过程中,通常会将 -Xms 与 -Xmx 两个参数配置成相同的值,其目的是为了能够在 java 垃圾回收机制清理完堆区后不需要重新分隔计算堆区的大小而浪费资源。
1
2
3
System.out.println("Xmx=" + Runtime.getRuntime().maxMemory() / 1024.0 / 1024 + "M");    //系统的最大空间
System.out.println("free mem=" + Runtime.getRuntime().freeMemory() / 1024.0 / 1024 + "M"); //系统的空闲空间
System.out.println("total mem=" + Runtime.getRuntime().totalMemory() / 1024.0 / 1024 + "M"); //当前可用的总空间

此处设置的是 Java 堆大小,也就是新生代大小 + 老年代大小

  • 设置一个 VM options 的参数
1
-Xmx20m -Xms5m -XX:+PrintGCDetails
  • 再次启动 main 方法
  • 这里 GC 弹出了一个 Allocation Failure 分配失败,这个事情发生在 PSYoungGen,也就是年轻代中这时候申请到的内存为 18M,空闲内存为 4.214195251464844M我们此时创建一个字节数组看看,执行下面的代码
1
2
3
4
5
byte[] b = new byte[1 * 1024 * 1024];
System.out.println("分配了1M空间给数组");
System.out.println("Xmx=" + Runtime.getRuntime().maxMemory() / 1024.0 / 1024 + "M"); //系统的最大空间
System.out.println("free mem=" + Runtime.getRuntime().freeMemory() / 1024.0 / 1024 + "M"); //系统的空闲空间
System.out.println("total mem=" + Runtime.getRuntime().totalMemory() / 1024.0 / 1024 + "M");
  • 此时 free memory 就又缩水了,不过 total memory 是没有变化的。Java 会尽可能将 total mem 的值维持在最小堆内存大小
1
2
3
4
5
byte[] b = new byte[10 * 1024 * 1024];
System.out.println("分配了10M空间给数组");
System.out.println("Xmx=" + Runtime.getRuntime().maxMemory() / 1024.0 / 1024 + "M"); //系统的最大空间
System.out.println("free mem=" + Runtime.getRuntime().freeMemory() / 1024.0 / 1024 + "M"); //系统的空闲空间
System.out.println("total mem=" + Runtime.getRuntime().totalMemory() / 1024.0 / 1024 + "M"); //当前可用的总空间
  • 这时候我们创建了一个 10M 的字节数据,这时候最小堆内存是顶不住的。我们会发现现在的 total memory 已经变成了 15M,这就是已经申请了一次内存的结果。
  • 此时我们再跑一下这个代码:
1
2
3
4
5
byte[] b = new byte[1 * 1024 * 1024];
System.out.println("分配了1M空间给数组");
System.out.println("Xmx=" + Runtime.getRuntime().maxMemory() / 1024.0 / 1024 + "M"); //系统的最大空间
System.out.println("free mem=" + Runtime.getRuntime().freeMemory() / 1024.0 / 1024 + "M"); //系统的空闲空间
System.out.println("total mem=" + Runtime.getRuntime().totalMemory() / 1024.0 / 1024 + "M");
  • 此时我们手动执行了一次 fullgc,此时 total memory 的内存空间又变回 5.5M 了,此时又是把申请的内存释放掉的结果。

调整新生代和老年代的比值

1
2
-XX:NewRatio --- 新生代(eden+2\*Survivor)和老年代(不包含永久区)的比值
例如:-XX:NewRatio=4,表示新生代:老年代=1:4,即新生代占整个堆的 1/5。在 Xms=Xmx 并且设置了 Xmn 的情况下,该参数不需要进行设置。

调整 Survivor 区和 Eden 区的比值

1
2
-XX:SurvivorRatio(幸存代)--- 设置两个 Survivor 区和 eden 的比值
例如:8,表示两个 Survivor:eden=2:8,即一个 Survivor 占年轻代的 1/10

设置年轻代和老年代的大小

1
2
-XX:NewSize --- 设置年轻代大小
-XX:MaxNewSize --- 设置年轻代最大值

可以通过设置不同参数来测试不同的情况,反正最优解当然就是官方的 Eden 和 Survivor 的占比为 8:1:1,然后在刚刚介绍这些参数的时候都已经附带了一些说明,感兴趣的也可以看看。反正最大堆内存和最小堆内存如果数值不同会导致多次的 gc,需要注意

小总结

  • 根据实际事情调整新生代和幸存代的大小,官方推荐新生代占 java 堆的 3/8,幸存代占新生代的 1/10
  • 在 OOM 时,记得 Dump 出堆,确保可以排查现场问题,通过下面命令你可以输出一个.dump 文件,这个文件可以使用 VisualVM 或者 Java 自带的 Java VisualVM 工具。
1
-Xmx20m -Xms5m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=你要输出的日志路径
  • 一般我们也可以通过编写脚本的方式来让 OOM 出现时给我们报个信,可以通过发送邮件或者重启程序等来解决。

永久区的设置

1
-XX:PermSize -XX:MaxPermSize
  • 初始空间(默认为物理内存的 1/64)和最大空间(默认为物理内存的 1/4)。也就是说,jvm 启动时,永久区一开始就占用了 PermSize 大小的空间,如果空间还不够,可以继续扩展,但是不能超过 MaxPermSize,否则会 OOM。

    tips:如果堆空间没有用完也抛出了 OOM,有可能是永久区导致的。堆空间实际占用非常少,但是永久区溢出 一样抛出 OOM

JVM的栈参数调优

调整每个线程栈空间的大小

  • 可以通过-Xss:调整每个线程栈空间的大小
  • JDK5.0 以后每个线程堆栈大小为 1M,以前每个线程堆栈大小为 256K。在相同物理内存下,减小这个值能生成更多的线程。但是操作系统对一个进程内的线程数还是有限制的,不能无限生成,经验值在 3000~5000 左右

设置线程栈的大小

1
2
-XXThreadStackSize:
设置线程栈的大小(0 means use default stack size)

JVM 其他参数介绍

直接见JavaGuide的4-6-永久区的设置