JVM理论和实战知识-学习笔记

本文详细阐述了JVM从编译到执行的流程,涉及类加载机制、类加载器、双亲委派、内存区域划分(包括方法区、程序计数器、虚拟机栈)、垃圾回收机制、分代模型及常见参数设置。还讨论了如何保护.class文件安全、Web容器类加载器设计和面试中可能遇到的问题。
摘要由CSDN通过智能技术生成

JVM-学习笔记


蓝色为自己的疑惑
橙色为对疑问的解答
红色为需要突出的重点
绿色为面试时可能遇到的问题

一. JVM从编译到执行的粗略过程

1.1 概念

  1. 一旦你执行’java’命令,实际上此时就会启动一个JVM进程,这个JVM进程负责运行我们写好的、已经编译好的.class文件。
  2. JVM想要直接运行这些".class"字节码文件中的代码是做不到的,它必须依赖一个叫做类加载器的东西。".class"字节码文件是静态的(这里面静态的代码没有半毛钱用处),它里面存放着一个一个的类,“类加载器"通过扫描这些”.class"文件,取出写在文件当中的类,并加载到JVM中,然后才能供后续代码运行使用。(此时,类已经被加载到内存了)
  3. 加载到内存中的类并不会自动运行,现在想要运行它们,就必须借助JVM自己的字节码执行引擎,来执行加载到内存里的我们写好的那些类。
  4. 由于内存的容量是有限的,我们不可能一股脑的把所有.class文件内的所有类全部一次性加载到内存中,所以JVM想了一个办法,它需要哪个类的时候,就去派类加载器加载对应的.class文件,将目标类加载到内存中,再通过字节码执行引擎去执行。

1.2 问题

  1. “一旦你执行’java’命令,实际上此时就会启动一个JVM进程”。如果我执行多次java命令,是不是会启动多个JVM进程?同一时刻能够运行多个JVM进程吗?它们之间通过什么东西来区分呢?
  2. JVM是个什么东西?
    答: 它是一个用于执行加载到内存中的类的工具(平台)。
  3. JVM根我们平时运行在机器上的系统之间是什么关系?
    答: 当我们使用了java命令时,就会启动一个JVM进程,JVM会帮我们去执行加载到内存中的类。
  4. 类加载器的概念?
    答: 类加载器用于扫描指定.class文件中的类,并把它们加载到内存中。类加载器不会执行字节码!
  5. 到底是谁来执行加载到内存中类的代码的?
    答: 是JVM中的字节码执行引擎。

二. JVM的类加载机制到底是怎么样的?

一个类从加载到使用,一般会经历下面的过程:

加载 → 验证 → 准备 → 解析 → 初始化 → 使用 → 卸载

看上去过程非常的复杂,所以我们搞清楚每一个阶段的核心原理就行了。

2.1 JVM一般在什么时候会去加载一个类?

换句话说,就是JVM在什么会派遣类加载器从".class"字节码文件中加载这个类到分配给JVM的内存中?答案非常简单,就是你的代码用到这个类的时候

首先你的代码中包含"main"方法的主类一定会在JVM进程启动以后,通过类加载器被加载到内存中,开始执行你的main()方法中的代码。

接着在main()中遇到使用了别的类,比如"ReplicaManager",此时就会从依赖的类的.class字节码文件中加载对应的类到内存中。

在这里插入图片描述
如图所示,jar中包含了Kafka.class和ReplicaManger.class这两份字节码文件,JVM进程启动后,一定会去加载Kafka.class(因为Kafka.class中包含了main()方法),在执行main()的过程中遇到了ReplicaManager,此时JVM为了使程序顺利的运行下去,不得不派出类加载器去扫描ReplicaManager对应的字节码文件(ReplicaManager.class),加载ReplicaManager对应的类到内存中。如果在main()方法中没有用到ReplicaManager,那么ReplicaManager根本就不会被加载到内存中!

2.2 验证阶段

验证阶段主要是校验准备执行的字节码(此时已经在内存中了)是否符合JVM的规范,如果不符合,JVM的字节码执行引擎就没有必要执行它了。
在这里插入图片描述

2.3 准备阶段

为什么要有准备阶段?这是因为我们写好的那些类当中都会有一些类变量,比如:

public class ReplicManager {
	public static int flushInterval;
}

假设我们有这么一个ReplicManager类,它对应的"ReplicManager.class"字节码文件的内容通过类加载器刚刚被加载到内存中,紧接着进行验证,发现完全符合JVM的规范😁,这时候就需要进行准备工作了。

这个准备工作,其实就是给这个"ReplicaManager"类分配一定的内存空间,然后给它里面的类变量(也就是static修饰的变量)分配内存空间,说白了就是给定一个默认值。

比如上面的示例中,JVM就会给"flushInterval"这个类变量分配内存空间,给一个"0"这个初始值。

整个过程,如下图所示:
在这里插入图片描述
总结一下,准备阶段实际上就是给加载进来的类分配好了内存空间,类变量也分配好了内存空间,并给予了初始值。

2.4 解析阶段

这个阶段干的事儿,其实就是把符号引用替换为直接引用的过程。
解析阶段非常复杂,待后面补充
在这里插入图片描述

2.5 初始化 (核心阶段)

在准备阶段,就会为我们的"ReplicaManager"类分配好内存空间,并给予类变量初始值。

那么接下来,在初始化阶段,就会正式执行我们的类初始化代码了。

那么什么是类初始化代码呢?

public class ReplicaManager {
	public static int flushInterval = Configuration.getInt("replica.flush.interval");
}

准备阶段不会执行Configuration.getInt(“replica.flush.interval”)这段代码逻辑来为flushInterval赋初值的!

准备阶段仅仅只会为flushInterval开辟内存空间,并给予0这个初值,而已!

那么Configuration.getInt(“replica.flush.interval”)到底在何时执行呢?答案是在"初始化"阶段来执行。

比如静态块也是在初始化阶段执行的,与准备阶段没有半毛钱关系。

问题来了: 什么时候会初始化一个类呢?
一般来说,有以下两个时机会初始化类:

  1. 遇到了new ReplicaManager()。
    此时JVM会触发类的加载到类的初始化的全过程,在把这个类准备好后,然后再实例化一个对象出来。
  2. 包含了main()方法的主类。
    JVM会立刻、马上初始化包含了main()方法的主类。

此外,如果初始化一个类的时候发现他的父类还没有被初始化,那么就必须先初始化他的父类。

JVM会优先加载启动jar时指定走的main()方法的类(程序的启动入口是唯一,放心),所以即便jar内有多份class文件都含有main()方法,也没有关系。

2.6 类加载器和双亲委派机制

现在有一个疑惑了,类加载器到底是什么?它是怎样扫描并把对应的字节码加载到内存中的?

启动类加载器
Bootstrap ClassLoader,它主要负责加载我们在机器上安装的java目录下的核心类(lib目录下)。
在这里插入图片描述

扩展类加载器
Extension ClassLoader,找到Java安装目录下的"lib\ext"目录,这里面有一些类,需要使用扩展类加载器来加载,以支撑整个系统的运行。
在这里插入图片描述
应用程序类加载器
Application ClassLoader,这个加载器就是负责加载"ClassPath"环境变量所指定的路径中类。
由于我们自己的类也包含在了ClassPath中,所以我们自己写的类也是通过应用程序类加载器来加载的。

自定义类加载器
除了上面三种类加载器以外,还可以自定义类加载器,去根据你自己的需求加载类。

双亲委派机制
JVM的类加载器有亲子层级结构,启动类加载器位于最上层,扩展类加载器位于第二层,应用程序类加载器位于第三层,自定义类加载器位于最下层。
在这里插入图片描述

然后,基于亲子层级结构,就有了一个双亲委派的机制。

就是假设你的应用程序类加载器需要加载一个类,它首先会把这份工作委派给它的父类,也就是扩展类加载器去尝试加载,类似的,扩展类加载器会把工作委派给它的父亲,直到委派到启动类加载器(到顶了)。

但是如果父类加载器在自己负责的范围内,没有找到这个类,那么就会向下推送加载的权利给自己的子类加载器。

通过双亲委派机制加载类的好处是: 避免多层级的加载器结构重复加载某些类。
在这里插入图片描述

2.7 本章的问题

2.7.1 如何对".class"文件处理保证不被人拿到编译的文件后,进行反编译并获取公司的源码?

答: 通过本章的学习,我们知道了在拿到编译的文件后,是通过类的加载器来加载编译的文件。所以我们可以购买一些第三方公司推出的小工具对字节码进行加密,或者是混淆处理。接着,在类的加载阶段,使用自定义类加载器(这个在购买加密工具时,肯定会配套购买)来对加密后的字节码文件进行解密,并加载到内存中。

2.7.2 Tomcat这种Web容器中的类加载器应该如何设计实现

答: Tomcat的类加载器体系如下,他定义了很多自定义的类加载器。
在这里插入图片描述
Tomcat自定义的Common、Catalina、Shared等加载器,其实就是用来加载Tomcat自己的一些核心基础类库的。

然后Tomcat为每个部署在容器中的Web应用都有一个WebApp类加载器,负责加载我们部署的这个Web应用中的类。

至于JSP类加载器,则是给每个JSP都准备了一个JSP类加载器。

Tomcat打破了双亲委派机制!

每个WebApp负责加载自己对应的那个Web应用的class文件,也就是我们写好的某个系统打包好的war包内所有的class文件,不会传导给上层加载器去加载。

2.7.3 为什么必须要让类加载器一级一级的往上找,不能直接从顶层类加载器开始找呢?

这是因为类加载器是基于父子关系模型设计的,每一个子类加载器,都需要找他的父类加载器。

如果直接从父类加载器向下找,就需要通过硬编码写死一个顶层加载器,放弃了代码的可扩展性。

2.7.4 自定义的类加载器如何实现?

自己写一个类,继承ClassLoader类,重写类加载的方法,然后在代码内可以用自己的类加载器去将某个路径下的类加载到内存中。
自己实现试试

2.7.5 Tomcat的类加载器是如何实现的?

在这里插入图片描述
Tomcat类加载器 Common ClassLoader没有委托Application ClassLoader作为自己的父类加载器去加载类!
为什么Common ClassLoader不去委托Application ClassLoader呢?

2.7.6 tomcat需要破坏双亲委派模型的原因

  1. tomcat需要支持不同的Web应用依赖同一个第三方类库的不同版本,jar类库需要保证相互隔离。
  2. 同一个第三方类库的相同版本在不同的Web应用之间可以共享。
  3. tomcat自身依赖的类库要与应用依赖的类库隔离。(要是双亲委派模型,那么类信息都是从顶层往下找,很可能都被Java的类加载器加载到了,也就达不到类库隔离的效果了)
  4. jsp需要支持修改后不重启tomcat即可生效。(为了实现这个效果,Tomcat定制开发了各种类加载器)

三. JVM内存区域划分(☆)

JVM在运行时,不可能被分配到一大块完整的内存空间,大部分情况是分到了多块内存空间,不同的内存空间用来存放不同的数据,然后配合我们的代码流程,才能让我们的系统运行起来。

问题来了,我们都知道JVM会加载类到内存中来供后续运行,那么这些类都被放到哪儿去了呢?总得有地方存放这些类吧。

实际上,JVM会划分出不同的内存区域,为我们写好的代码在运行的过程中根据不同的需要来使用。比如运行方法时,方法内的许多变量、方法内创建的对象都需要放入内存区域中,他们各自存放在不同的内存区域中。

下面让我们依次来看看JVM中到底有哪些内存区域。

3.1 存放类的方法区

JDK1.8以前,JVM中有这样一块内存区域,它专门用于存放从".class"加载进来的类,静态变量,还会有一些类似常量池的东西会放入这个区域。此时的类是静态的,只不过是一堆字节码指令。

自JDK1.8版本后,这块区域的改名为"Metaspace",虽然名字变了,实际上主要还是存储着我们自己写的各种类的相关信息。

3.2 执行代码指令时使用的程序计数器

对写好的java代码进行编译,接着把这些字节码指令写入内存后,由JVM使用自己的字节码执行引擎,去执行这些字节码指令。

在执行字节码指令时,JVM需要一个特殊的内存区域,那就是"程序计数器"。"程序计数器"用于记录当前执行的字节码指令的位置。我们都知道,JVM是支持多线程的,不同的线程可能会执行同一份代码,那么每条线程分别执行到代码的哪一行,这就需要线程各自的程序计数器了。所以说,程序计数器是线程隔离的。
在这里插入图片描述
图上可以发现,字节码执行引擎只有一台,但是可以通过切换线程,并发的执行字节码指令。

3.3 Java虚拟机栈

Java代码在执行时,一定是某条线程在执行某个方法中的某行代码。就算是main()方法,也一定会有一个main线程来执行。
在这里插入图片描述
但是在方法内,我们经常会定义一些局部变量,比如上述代码中,replicaManager就是一个局部变量。因此,JVM必须有一块区域用来保存每个方法内的局部变量等数据,这个区域就是Java虚拟机栈。

每个线程都有自己的Java虚拟机栈,如果线程执行了一个方法,就会对这次方法调用创建一个栈帧。

栈帧专门用于存放这个方法的局部变量表、操作数栈、动态链接、方法出口等。我们先关注局部变量。比如main线程执行了main()方法,那么就会给本次main()方法调用创建一个栈帧,压入main线程的java虚拟机栈。
在这里插入图片描述
字节码执行引擎继续向下执行时,发现方法调用loadReplicasFromDisk(),此时JVM会为第二个方法创建一个全新的栈帧,并压入Java虚拟机栈中。
在这里插入图片描述
随着第二个方法的栈帧入栈,这个栈帧的局部变量表中会包含hasFinishedLoad变量,也随之入栈。接着再调用isLocalDataCorrupt(),又会再次创建一个栈帧并压入栈,所以此时这条线程的Java虚拟机栈为:
在这里插入图片描述
这就非常简单了,首先isLocalDateCorrupt()执行完毕后,对应栈帧出栈,接着位于第二层的loadReplicasFromDisk()栈帧在运行完毕后也随之出栈,最后在执行完main()。

上述就是JVM中的"Java虚拟机栈"这个组件的作用:

  1. 调用执行任何方法时,都会给方法创建栈帧并压入Java虚拟机栈。
  2. 栈帧里存放了这个方法对应的局部变量之类的数据,还包括了这个方法执行的其他相关的信息,方法执行完毕后就会出栈。

3.4 核心内存区域总体回顾 (☆)

在这里插入图片描述
编译器对Kafka.java文件进行编译,形成字节码文件Kafka.class和ReplicaManager.class。

通过java -jar等命令启动jar后,会立刻启动一个JVM进程。JVM通过应用程序类加载器扫描和加载含有main()方法的类,结果发现了Kafka.class字节码文件,并存入专门存放类信息的方法区中(又叫Metaspace)。初始阶段,不会主动加载ReplicaManager.class!

接着,JVM会在被分配的内存中专门开辟一块区域,程序计数器,命令字节码执行引擎配合程序计数器,从main()方法开始逐行的执行代码。

main()线程在执行main()方法后,会在main()线程关联的Java虚拟机栈里,压入一个main()方法的栈帧。

然后发现需要创建一个ReplicaManager类的实例对象,此时JVM会通过类加载器加载ReplicaManager.class的字节码,存入方法区中。紧接着,JVM会利用方法区内的字节码相关信息,由字节码执行引擎,执行ReplicaManager类的构造函数,创建该类的实例对象,并在Java虚拟机堆内存中划一块内存,将它存起来(此时,实例对象必然有了堆内存的地址)

再接着,回到main()方法的栈帧,在栈帧内的局部变量表中引入一个"replicaManager"变量,让他引用ReplicaManager实例对象在堆内存中的地址(引用,说白了就是存储呗)。

最后,main()线程开始执行ReplicaManager对象的方法,依次创建新的栈帧,并按顺序压入main()线程对应的Java虚拟机栈,执行完方法后,再按照后进先出的顺序,把方法对应的栈帧在Java虚拟机栈内依次出栈。

3.5 其它内存区域

本地方法栈

Java有时候调用的方法并不是由java语言写的,可能是由C或者底层类库实现,比如public native int hashCode();

在调动这些方法时,同样需要创建栈帧,需要创建局部变量表。为了不与普通方法的栈帧放在一起,JVM创建了一个叫做本地方法栈的地方,专门存放这类方法的栈帧。(凭什么native方法的栈帧不能与普通方法的栈帧放一起呢?搞特殊化?)

堆外内存
一般不太可能把所有的内存全部给JVM进程使用,所以就有了堆内内存(JVM进程能使用的内存)和堆外内存之分。

比如通过NIO的allocateDirect这种api,居然可以在Java堆外分配内存空间,然后通过java虚拟机里的DirectByteBuffer来引用和操作堆外内存空间。

部分场景下,使用堆外内存,可以提升性能。(哪些场景下,怎样使用,可以提升多大的性能呢?)

3.6 本章的问题

3.6.1 我们创建的对象,在Java堆中到底会占用多少内存空间呢?

一个对象对内存的占用,大致分为两块:

  1. 对象本身的一些信息
  2. 对象的实例变量作为数据占用的空间

比如对象头,如果在64位的Linux操作系统上,会占用16个字节,如果对象实例变量内部有一个int类型的实例变量,就会占用4个字节,如果是long类型的实例变量,就会占用8个字节。如果是数组、Map之类的,就会占用更多的内存。

回答的非常不完善。JVM对这块内容有很多优化,比如对齐机制、指针压缩机制等等

注意: JVM内存占用是8的倍数,所以上方计算完毕后,需要将结果向上取整到8的倍数。

四. JVM垃圾回收机制

当方法运行完毕后,对应的栈帧会从Java虚拟机栈中出栈,局部变量表也就没有了。此时,没有任何一个局部变量引用(持有)Java堆内存对象的内存地址,而内存资源是有限的,这时就需要借助Java垃圾回收机制来回收这个对象了。

JVM本身是有垃圾回收机制的,只要我们启动一个JVM进程,就会自带一个可爱的垃圾回收后台线程。

这个线程会在后台暗中不断检查JVM堆内存中的各个实例对象,如果某个实例对象没有任何一个方法的局部变量指向它,也没有任何一个类的静态变量,包括常量指向它,那么这个垃圾回收线程就会把这个对象从Java堆内存中清除掉,释放内存资源。

五. JVM分代模型

5.1 对象的存活周期与创建方式密切相关

我们都知道,对象被创建后,会保存到Java堆内存中,但是这些对象会在内存中驻留多长时间,与创建的方式密切相关。

存活时间极短的对象
在这里插入图片描述
上述代码中,main()线程会循环调用loadReplicasFromDisk()方法,每次调用都会为方法创建栈帧,并压入main()线程的Java虚拟机栈,当loadReplicasFromDisk()运行完毕后,又会让栈帧出栈。

loadReplicasFromDisk()方法内会创建ReplicaManager对象,并在栈帧的局部变量表中创建replicaManager变量指向它。出栈后,局部变量表也会随之释放,由于ReplicaManager对象没有任何变量指向它,因此会立刻被GC回收。

不难发现,上述的代码中,每经历一次循环,就会让ReplicaManager类型的对象经历一次创建和回收的过程。

所以,大部分的代码中创建的对象,存活时间都是极短的。

存活时间极长的对象
在这里插入图片描述
上述代码在初始化阶段,就会创建ReplicaManager对象,并让一个静态变量replicaManager一直指向它。

注意,这个静态变量replicaManager位于方法区。

因此,即便loadReplicasFromDisk()的栈帧被不断的创建和释放,也不会影响ReplicaManager对象。由于一直有静态变量指向它,所以ReplicaManager对象不会轻易被垃圾回收。

由于采用不同的方式来创建和使用对象,其对象的生命周期是不同的,为了更好的管理这些对象,JVM将Java堆划分为了两个区域: 年轻代和老年代。

5.2 年轻代、老年代、永久代

年轻代
就是5.1节的第一种案例,这种内存专门存储创建和使用完毕后需要立马回收的对象

老年代
就是5.1节的第二种案例,这种内存专门存储创建之后需要长期存放的对象
在这里插入图片描述
Kafka的静态变量"fetcher"引用(指向)了ReplicaFetcher对象,这个对象需要长期驻留在Java堆内存中(并且fetcher变量也会常驻方法区)。

这个对象会在年轻代里停留一会儿,最终还是会进入老年代。为什么要先进入年轻代?什么时候从年轻代进入老年代呢?

loadReplicasFromDisk()内的ReplicaManager对象自不必说,它是用完就回收的,所以是放在年轻代里,由栈帧里面的局部变量(replicaManager)引用。

为什么要分成年轻代和老年代呢?
这就和垃圾回收有关了。

对于年轻代里的对象,他们的特点是创建并使用之后,很快就会被回收,所以需要一种垃圾回收算法。

而对于老年代里的对象,它们的特点是需要长期存在,所以需要另外一种垃圾回收算法。

所以需要分成两个不同的区域来存放不同的对象。

什么是永久代?
永久代就是方法区,存放类信息和一些常量池的。

5.3 你的对象在JVM中如何分配?如何流转?

大部分的对象在创建初期都是存放在新生代中的,就连5.2节中的fetcher静态变量也是如此。

对象优先分配在新生代

当我们需要新建对象,但新生代的剩余内存不够用的时候,就会触发一次新生代内存空间的垃圾回收。新生代内存空间的垃圾回收,也被称作"Minor GC",有时候也被称作"Young GC",他会尝试把新生代里没有人引用的垃圾对象回收掉。
在这里插入图片描述
比如上图中的RelicaManager就没有任何人指向它,此时JVM就会当机立断,把"RelicaManager"实例对象给回收掉,腾出更多的内存空间,然后放一个新的对象到新生代中。

长期存活的对象会躲过多次垃圾回收

比如"ReplicaFetcher"对象,他确实一直会存活在新生代里,因为他一直被Kafka类的静态变量给引用了,所以他不会被回收。

此时JVM有一条规定: 如果一个实例对象在新生代中,成功的在15次垃圾回收之后,仍然没有被回收掉,那就说明他已经15岁了。

也就是说,每次垃圾回收,如果一个对象没有被回收,便会使它的年龄加一。接着,JVM会将这些"老年人"从年轻代转移到"老年代"中存储。

如果有对象躲过了10多次垃圾回收,就会被转移到老年代里。

老年代需要垃圾回收吗?
当然需要,因为随着代码的运行,老年代内的对象也有可能不再被人引用了。

如果老年代满了,同样会触发垃圾回收,清除老年代中没人引用的对象

5.4 本章的问题

5.4.1 方法区内会不会进行垃圾回收?(☆)

方法区会进行类信息的垃圾回收,但是只会在满足以下条件时触发:

  1. 该类的所有实例对象都已经从Java堆内存中被回收。
  2. 加载这个类的ClassLoader已经被回收。
  3. 对该类的Class对象没有任何的引用。

补充说明:
4. 什么时候类的ClassLoader会被回收?
答: 比如我们自定义一个ClassLoader,它本身其实就是一个对象,一旦没有人引用它时,它就满足回收条件了。
5. “对该类的Class对象没有任何的引用” 这句话怎么理解?
答: Class对象代表类,比如Class a1 = ClassA.class; 或者Class a1 = Class.forName(“ClassA”); 此时a1这个变量就指向了ClassA这个类的Class对象。
由此可以看出,Class对象和实例对象还是有很大差距的,Class对象并不是new出来的。

5.4.2 线程执行方法后,方法对应的栈帧从Java虚拟机栈中出栈了,那么那里的局部变量需要垃圾回收吗?

这个问题本身有问题。JVM里垃圾回收针对的是新生代、老年代、方法区(永久代),不会针对方法的栈帧。

方法一旦执行完毕,栈帧就会出栈,里面的局部变量直接就从内存里清理掉了。

六. JVM的核心参数

  • Xms: Java堆内存刚开始的大小
  • Xmx: Java堆内存的最大大小
  • Xmn: Java堆内存中的新生代的大小,扣除新生代剩下的就是老年代的大小了。
  • XX:PermSize: 永久代的大小
  • XX:MaxPermSize: 永久代的最大大小
  • Xss: 每个线程的栈内存的大小

-Xms和-Xms,通常来说,都会设置成完全一样的大小。为什么?
-XX:PermSize和-XX:MaxPermSize通常这两个数值也是设置为一样的。为什么?
在这里插入图片描述
复制公司对JVM的参数配置:

-Xmx2048m -Xms512m -Xss512K -XX:MaxMetaspaceSize=256m -XX:+UseParNewGC 
-XX:+UseConcMarkSweepGC -XX:+CMSClassUnloadingEnabled -XX:+ExplicitGCInvokesConcurrent

初始堆内存512M,最大堆内存2G,每个栈内存的大小为512K,元数据空间(方法区)的大小为256M。
剩余的参数分别是什么意思?

七. 每日百万交易的支付系统,如何设置JVM堆内存大小?

7.1 如何考虑对生产项目设置JVM堆内存大小?

对于一个真实项目,在上生产环境后,到底应该怎样设置JVM的内存大小?

比如日均百万交易的支付系统,它的性能瓶颈就是每天JVM内存里会创建和销毁100万个支付订单,此时就需要考虑几个问题:

  1. 我们的支付系统需要部署多少台机器?
  2. 每台机器需要多大的内存空间?
  3. 每台机器上的JVM需要分配多大的内存空间?
  4. 给JVM多大的内存空间,才能保证支撑这么多的支付订单在内存中创建,而不会导致内存不够直接崩溃?

首先我们要计算的,就是每秒钟我们的系统要处理多少笔订单。(找到最核心的、消耗内存的、有并发请求的业务)
假设每天100万个订单,高峰时期,大概每秒100笔订单。假设我们的支付系统部署了3台机器,那么一台机器大概一秒钟需要处理30个订单。

换句话说,每台机器每秒会接收到30条创建订单的请求,然后在JVM的新生代里创建30个支付对象,再去做数据库的写入等处理。

那么每个支付订单大概需要占用多大的内存空间呢?
假设订单类有20个属性,每个属性占用4~8个字节,一般也就是几百字节,我们算成500字节好了。

每秒发起的请求需要占用多大的内存空间?
500b * 30(订单数量) = 15000b = 15kb

对运行起来的支付系统进行思考
现在假设每秒对内存产生15KB的新对象存储压力,真实的支付系统上线后,可能还会创建大量其他的对象,但是核心对象(订单对象)就15KB,所以我们可以适当扩大10~20倍,将其他对象也考虑在内。整体上来看,每秒产生的对象大概能占据几百KB~1MB的内存空间。

然后下一秒继续来新的请求放入新生代中,接着变成垃圾,迎接下一秒的请求。

支付系统的JVM堆内存应该怎样设置?
一般来说,这种线上业务系统,常见的机器配置是4核8G,由于机器本身(比如系统)也需要占据一些内存空间,所以剩余能使用的内存可能只有6G左右。

这6个G还得分配给老年代、方法区、栈内存、堆内存几个区域,那么堆内存可能最多就分到4G左右的空间。

根据刚才的估算,每秒可能占据1MB左右的内存空间,如果你只为新生代分配几百MB的空间,那岂不是几百秒之后,新生代空间就满了?此时就需要触发Minor GC了?

这么频繁的触发GC,绝对不是一件好事,频繁的GC会影响系统的稳定性,比如一次GC就会导致系统卡顿一下!为什么?

因此我们可以考虑给新生代分配2G左右的内存空间,这样起码可以做到将近半个小时至1个小时,新生代才会因为满,而触发一次GC。

所以配置可以为: -Xms:4G -Xmx:4G -Xmn:2G

如果业务量变大,我们可以考虑部署不止3台机器,横向扩容,减轻每一台机器的JVM内存压力。

总结一下,针对生产项目,我们分以下几个步骤来设置比较靠谱的初始JVM配置:

  1. 找到整个系统并发量最高的请求,再找出请求中最核心的、占用内存高的业务场景和对象。
  2. 对第一步中的对象,通过属性类型和属性个数初步估算所需要消耗的新生代内存大小,在估算时,记得为请求中可能产生的对象预留内存空间。(一般为核心请求的10倍~20倍大小)
  3. 此时,我们可以估算出高峰期、每秒可能会占用新生代内存的大小了。新生代内存触发Minor GC不易过快,一般控制在半小时至1小时左右,通过 每秒占用内存大小 * 触发GC的时间 = 分配给java堆 年轻代的内存总量,计算出目标年轻代内存分配值。
  4. 最好要为老年代分配年轻代一半的空间。
  5. JVM配置不仅仅包含java堆,还包含了方法区、永久代、每个线程的Java虚拟机栈等等内存配置,都需要考虑进去。

7.2 不合理设置内存的反面示例 (☆)

假设我们给支付系统分配了一台2核4G的虚拟机,线上的Java堆内存只分配了1G的内存空间,新生代500MB,老年代500MB。

业务压力,每天100万次交易,高峰时期每秒大概100笔支付订单,对应核心的支付订单对象会创建出100个,每个支付订单对象占据500个字节,总计50kb。接着,一笔交易需要1秒来处理,所以这100个对象在1秒期间内被人引用,无法被回收。根据全局预估的思想,从核心的订单支付对象扩展到系统其它对象上,起码可以把系统扩展10~20倍,比如我们直接使用20倍,那么1秒大概有1MB左右的对象,无法被回收。

大促期间,瞬时访问量增加了10倍。原先高峰期1秒只处理100笔请求,而现在可能需要处理1000笔请求,那么每秒钟系统对内存的占用增加到10MB以上。最可怕的事情来了,原先系统在1秒内处理100笔请求没什么问题,但现在1秒内涌入了1000个请求,由于压力骤增,会导致你的系统性能下降,可能偶尔会出现某些请求需要花费几秒,甚至数十秒来处理。

这个时候会出现下述问题:

由于新生代内存空间较小,每秒系统对内存的占用量又在增加,这就导致新生代会快就被积压的对象填满了,此时触发Minor GC。由于少数请求处理速度较慢,导致部分对象仍然被人引用,导致Minor GC无法将其回收,因此在新生代中可用的内存空间就会更少了,Minor GC的触发也就更频繁了。在经历了十多次的Minor GC后,年轻代中,这些处理请求速度较慢的对象将被转移到老年代。

然后,后续请求处理完毕后,老年代中的那些对象就没人引用了,成为垃圾对象。上述的过程不断重复,会导致老年代中的垃圾对象越来越多,最终会频繁的触发老年代的垃圾回收。老年代的垃圾回收非常的慢! 老年代的GC为什么这么慢?

这样频繁的触发Minor GC和老年代的GC,会极大的影响系统的性能。

7.3 JVM的栈内存与永久代该如何设置?

7.3.1 如何合理设置永久代的大小?

一般设置几百MB,大体上是够用的。因为永久代里存放的是一些类的信息。

永久代难道就不会溢出吗?永久代内存什么时候会溢出呢?是不是反射用得很多时容易溢出?

7.3.2 如何合理设置栈内存的大小?

栈内存的大小设置,一般也不会特别的去预估和设置,一般默认就是512KB到1MB。因为栈内存中存放的每个线程在执行方法期间的各种局部变量。

Java虚拟机栈难道就不会溢出吗?什么时候会溢出呢?是不是循环创建变量时容易溢出?

7.4 本章的问题

7.4.1 Spring Boot和Tomcat怎么设置JVM参数?

Spring Boot直接在在启动时,在VM options中设置JVM参数,如下图所示:
在这里插入图片描述
Tomcat是在bin目录下的catalina.sh中加入JVM参数。

7.4.2 问题答疑

  • 为什么不在栈帧中直接存放程序计数器记录的执行情况呢?
    答: 这就是JVM设计者的思想了,因为程序计数器记针对的是代码指令的执行,Java虚拟机栈帧针对的是方法的数据(如局部变量)。一个是数据,一个是指令,分开设计。

  • 静态代码块在执行时也会由JVM生成栈帧,压入Java虚拟机栈吗?
    答: 静态代码块是在初始化阶段执行的,但不是JVM执行代码的那套流程来执行,他是类初始化,自成一套体系。什么体系?能不能把话说清楚?

八. JVM垃圾回收算法

8.1 JVM有哪些垃圾回收算法,每个算法的优劣?

新生代中垃圾回收采用的就是复制算法。

简单的说,就是把新生代分成了两块内存,只在一块内存中存储对象,另一块内存空着。当再次分配对象时,发现专门存储对象的内存空间不足了,就会触发Minor GC。

那回收的时候怎么做呢?

方法1: 标记法/挖孔法 (垃圾中的战斗机)

把不再被人引用的对象全部标记上,接着直接对内存区域中被标记的对象进行垃圾回收。

缺点: 借助标记法回收垃圾对象会产生大量的、零散存储的内存碎片。由于内存碎片的大小不一,再次分配对象时,可能在内存中找不到一块完整的、能够容纳新对象的内存空间。

方法2: 复制内存法 (垃圾)

把不能被回收的对象全部标记上,接着将这些存活的对象转移到另一块空白的内存中,转移时,尽可能的让这些对象比较紧凑的排列在内存里。

这种做法岂不是把对象存储在内存的位置改变了?引用这些对象的局部变量或者静态成员变量,它们持有的对象在内存中的地址岂不是都错了?

接着,这块被转移的内存区域是不是还剩下一大块连续可用的内存空间?此时就可以将新对象分配到这个连续的内存空间里了。

最后,再一次性把原先使用的那块内存彻底清空,这样便又空出来了一块新内存。

优点: 不会产生过多的内存碎片。

缺点: 太明显了,我TM好不容易申请的内存,从始至终,你居然只拿出一半的内存来存储对象,这对内存的使用效率也太低了吧?

方法3: Eden区和Survivor区 (还凑合)
首先我们要明确一件事情: 绝大部分(99%)的对象的存活时间非常短暂,可能被创建出来1毫秒之后就没人引用了,成为垃圾对象。只有极少数长期存活的对象或者还没有使用完的对象,才可能躲过Minor GC。

考虑到上述情况,我们对复制算法进行了优化,把新生代内存区域划分成了三块: 1个Eden区,2个Survivor区,其中Eden区占据80%的新生代内存空间,每一块Survivor区占据10%的新生代内存空间。平时可以使用的就是Eden区和一块Survivor区。

打个比方,新生代内存区域共有1G,Eden区800MB,每个Survivor区100MB,那么相当于有900MB的内存可用。但是在刚开始创建对象时,对象都是分配在Eden区内的,如果Eden区快满了,就会触发Minor GC,把存活的对象从Eden区转移到Survivor。之前说了,能在GC下存活的对象少之又少,所以我们不必担心只有100MB的Survivor能否装的下这么多存活的对象。接着Eden区就会被清空,然后再次分配新对象到Eden区中,此时就会出现Eden区和一块Survivor区都有存活的对象,其中Eden区存放的是新对象,而其中一块Survivor区域中存放的是上一次Minor GC后存活转移的对象。另一块Survivor区域始终是空的。

如果Eden又快满了,且无法分配新对象,那么将再次触发Minor GC,在Eden区和存放着上一次Minor GC后存活的对象中,挑选出本次GC存活的对象,并转移到剩余的那块空的Survivor内存区域上。最后,回收Eden区和上一块Survivor内存区域内所有的对象。

这样一来,新的对象会在Eden、Survivor、Survivor上反复的游历,且始终至少保持着一块Survivor是空的。

优点: 只有10%的内存空间是被闲置的,90%的内存都被使用上了。无论是垃圾回收的性能,内存碎片的控制,还是内存的使用效率,都有非常好的控制。

8.2 年轻代内的对象会在什么时候进入老年代呢?

8.2.1 经历了MaxTenuringThreshold次GC

我们知道,被GC Root引用了的对象,不会被垃圾回收,这种对象每次在新生代里躲过一次GC被转义到一块Survivor中,他的年龄就会增涨一岁。默认配置下,当对象的年龄达到了15岁时,也就是刚刚躲过第15次GC时,它就会被被转移到老年代中。

具体是多少岁进入老年代,可以通过JVM参数"-XX:MaxTenuringThreshold"来设置,默认是15岁。

8.2.2 动态对象年龄

JVM并非死板的要求对象必须经历MaxTenuringThreshold次GC后,才能从年轻代转移到老年代。

如果在存放对象的Survivor内存区域内存在某类相同年龄的对象,它们占用内存的总和大于或等于Survivor内存区域的一半,那么大于或等于该年龄的对象就可以直接进入老年代,无需刻意等到MaxTenuringThreshold要求的年龄。

假设每块Survivor的大小为100MB,在经历了一次GC后,存放了存活对象的Survovir的对象分布情况如下:

岁数对象个数占用总内存
1岁10020MB
2岁2050MB
3岁105MB

由于2岁(相同年龄)的所有对象,占用内存的总和达到了50MB,满足"大于或等于Survivor内存区域的一半",因此这20个2岁的对象,10个3岁的对象全部会被转移到老年代中。

8.2.3 大对象直接进入老年代

设想一个场景:现在需要创建一个被GC Root引用的超大对象,假设我们直接把它放到新生代,由于GC Root的关系,这个超大对象会躲过GC,并在两个Survivor之间来回复制。

显然,这种做法非常消耗性能,而且很浪费时间。

所以,JVM设计了一个参数:-XX:PretenureSizeThreshold,它的单位是字节,比如"1048576"个字节,就是1MB。如果你配置了这个参数,那么在创建对象时,如果对象的大小超过了PretenureSizeThreshold,JVM会直接将这个对象放到老年代存储,压根就不会经过新生代。

8.2.4 Minor GC后,存活的对象太多,无法放入Survivor

在这里插入图片描述
上图中,经过一次GC后,发现Eden中存活的对象高达150MB,没办法放入其中一块空的Survivor内存区域,毕竟存不下啦,那这时候该怎么办呢?

这个时候就需要把所有的存活对象直接转移到老年代中了。(绿色框)

8.3 老年代空间分配担保规则

如果新生代中有大量的对象存活下来,并且连老年代里的空间都不够用了,那该怎么办呢?

没关系,JVM在设计阶段就想到了这一点。

首先,在执行任何一次Minor GC之前,JVM必须检查老年代的可用内存空间,是否大于新生代中所有已存在对象的内存占用总和。

为什么去检查所有对象,而不是去检查能存活的对象呢?这是因为极端情况下,新生代中的所有对象都有可能存活下来,并企图向老年代转移。

如果老年代的可用内存空间大于了新生代所有对象的内存占用总和,那就放心的Minor GC吧,因为即便是最坏情况,老年代也能容纳的下这么多存活的对象。

如果老年代的可用内存空间不足,小于了新生代所有对象的内存占用总和,为了避免最坏情况下,老年代不足以容纳存活对象的后果,JVM提供了一个参数:"-XX:-HandlePromotionFailure"。

假设我们配置了"-XX:-HandlePromotionFailure",那么JVM会判断老年代的可用内存大小,是否大于之前每一次Minor GC后进入老年代的对象的平均大小。 这个参数就像做手术之前,签生死协议。JVM把可能发生异常的情况统统告诉了我们,至于到底冒不冒这个风险,就由我们自己来决定了。

举个例子,假设经历了多次Minor GC,每次Minor GC后,平均都有10MB左右的对象会进入老年代,而老年代此时的可用内存大于10MB。JVM就可以推测,很可能这次Minor GC后,新生代也会转移10MB左右的对象进入老年代。这个时候就会作出如下探测:

"老年代的可用内存大小 > 10MB"是否成立?

如果上方表达式不成立,或者是没有设置"-XX:-HandlePromotionFailure"(允许担保失败),作为JVM就很不放心了,毕竟按照惯例,这次Minor GC会有可能会出事故。此时就会直接触发一次"Full GC",对老年代进行垃圾回收,尽可能的腾出一些内存空间,让老年代的可用内存空间变大一点,再去执行Minor GC。

如果Full GC后,老年代的可用内存空间仍然小于10MB怎么办?硬着头皮继续Minor GC?为何不设计成循环逻辑,不断的Full GC -> 判断上述表达式是否成立 -> 不成立继续Full GC呢?

Full GC时,老年代和新生代都会被GC,我猜测是先清理老年代,腾出更多的可用空间后,再去清理年轻代。如果经历一次Full GC后,老年代仍然放不下年轻代中的存活对象,那就不好意思了,直接抛出OOM异常。不循环判断是因为,Full GC本身就包括Minor GC,执行不通过就会直接抛异常,根本就不需要循环啊!

如果上方的表达式成立,那么就是说,按照"惯例",老年代可以容纳下接下来Minor GC后存活的对象,因此JVM愿意冒点风险执行Minor GC。执行之后,这里可能发生以下几种可能场景:

第一种可能场景,Minor GC过后,剩余存活对象的总和小于Survivor区域大小,那么此时把存活对象转移到空着的那块Surivovr即可。

第二种可能场景,Minor GC过后,剩余存活对象的总和大于Survivor区域的大小,但小于老年代可用内存大小,此时我们把存活对象转移到老年代即可。

第三种可能场景,Minor GC过后,剩余存活对象的总和大于Survivor区域的大小,并且大于老年代可用内存大小。换句话说,无论是Survivor还是老年代都放不下这些对象了,此时就会发生"Handle Promotion Failure",触发一次"Full GC"。Full GC就是对老年代进行垃圾回收,同时一般也会对新生代进行回收。

要是Full GC后,老年代还是没有足够的空间存放Minor GC(Full GC中包含了这个过程)新生代中存活的对象,那就只能直接抛出OOM异常了。

总结一下,对老年代触发垃圾回收的时机一般有以下两个:

  1. Minor GC之前,JVM检查发现Minor GC后可能需要进入老年代的对象太多了,多到老年代放不下这么多存活对象。
  2. Minor GC之后,新生代存活的对象放不进老年代。

8.4 老年代垃圾回收算法

前面说的挖孔法、复制法、Eden+Survivor法都是年轻代垃圾回收算法,那么老年代垃圾回收算法是一个怎样的实现逻辑呢?

老年代采取的是标记整理算法。

首先,标记出老年代当前存活的对象,当然了,存储在老年代中的对象可能是东一个西一个。
在这里插入图片描述
接着,将这些对象在内存中进行移动,把存活的对象尽量挪到一起,紧凑的放在一边,这么做是为了避免在接下来的内存回收时,出现过多的内存碎片。
在这里插入图片描述
然后,再一次性的把垃圾对象都回收掉。

老年代垃圾回收算法的速度至少比新生代垃圾回收算法的速度慢10倍! 所以,如果系统频繁的出现Full GC,会导致系统性能被严重影响,出现频繁卡顿的情况。所以我们应当尽可能的让对象在新生代待着,不要来老年代,少出现Full GC。

九. JVM中都有哪些常见的垃圾回收器,各自的特点是什么?

十. 大厂面试题

10.1 什么情况下JVM内存中的一个对象会被垃圾回收?

10.1.1 哪些对象能被回收,哪些对象不能被回收?

JVM使用了可达性分析算法判定哪些对象可以被回收,哪些对象不可以被回收。

可达性算法: 找到一个对象,看看有谁在引用他,接着再一层一层的往上找,看看是否有一个GC Roots。

那么什么是GC Roots呢?
在这里插入图片描述
上图中,loadReplicasFromDisk()的栈帧中创建了一个局部变量replicaManager,引用了ReplicaManager对象。假如此时新生代快满了,触发Minor GC,则垃圾回收器就会去分析这个ReplicaManager对象的可达性。这时发现不能回收ReplicaManager对象,因为它被人引用了,而且是身为局部变量的"replicaManager"引用了它。

在JVM规范中,局部变量可以作为GC Roots。

只要一个变量被局部变量引用了,那么就说明它有一个GC Roots,此时就不能被回收了。
在这里插入图片描述
上图中,Kafka的静态成员变量replicaManager引用了ReplicaManager对象。

在JVM规范中,静态成员变量也可以作为GC Roots。

只要一个变量被静态成员变量引用了,那么就说明它有一个GC Roots,此时就不能被回收了。

10.1.2 Java中对象不同的引用类型

Java中有多种不同的引用类型: 强引用、弱引用、软引用和虚引用。

  • 强引用
    在这里插入图片描述
    一个变量引用了一个对象,这就是强引用。只要是强引用类型的对象,那么垃圾回收时就绝对不会去回收这个对象。

  • 软引用
    在这里插入图片描述
    ReplicaManager对象被SoftReference软引用类型的对象给包裹起来了,此时这个replicaManager变量对"ReplicaManager"对象的引用就是软引用了。正常情况下垃圾回收不会回收软引用,但是如果垃圾回收以后,发现内存空间还是不够存放新的对象,内存都快溢出了,此时就会把这些软引用对象给回收掉,哪怕他被变量引用了。

  • 弱引用
    在这里插入图片描述
    弱引用就好像没有任何变量引用这个对象似的。只要发生垃圾回收,必定回收这个被弱引用的对象,哪怕他被变量引用了。

  • 虚引用
    虚引用的存在感比弱引用还要低,如果一个对象仅持有虚引用,那么它就像没有被引用一样,在任何时候都可能被垃圾回收器回收。虚引用用于跟踪对象被垃圾回收器回收的活动。虚引用必须和引用队列 (ReferenceQueue)联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之 关联的引用队列中。你声明虚引用的时候是要传入一个queue的。当你的虚引用所引用的对象已经执行完finalize函数的时候,就会把对象加到queue里面。你可以通过判断queue里面是不是有对象来判断你的对象是不是要被回收了【这是重点,让你知道你的对象什么时候会被回收。因为对普通的对象,gc要回收它的,你是知道它什么时候会被回收】。

10.1.3 finalize()方法的作用

关于回收,我们得出如下结论: 有GC Roots的对象不能被回收,没有GC Roots的对象可以被回收。如果有GC Roots,但被软引用、弱引用或虚引用所引用了,也有可能被回收掉。

那么问题来了,没有GC Roots的对象,只要遇到垃圾回收,就一定会被回收吗?

其实并不是,这里有一个finalize()方法可以拯救自己。
在这里插入图片描述
上图中,一个ReplicaManager快要被回收了,假如这个对象重写了Object类的finalize()方法,那么垃圾回收器会先尝试调用finalize()方法,看看最后关头,这个对象是否有被某个GC Roots变量引用,比如代码中就让ReplicaManager的静态成员变量在最后关头引用了对象。

既然重新让某个GC Roots强引用了自己,那么就不需要被垃圾回收器回收了。

10.2 你们系统的垃圾回收情况是什么样子的?

10.3 年轻代和老年代分别适合什么样的垃圾回收算法?

十一. 工程素养

每个合格的工程师,都应该在上线系统时,对系统压力进行评估,然后对JVM内存、磁盘空间大小、网络带宽、数据库压力做出预估,然后各方面都给出合理的配置。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值