JVM-Java虚拟机
基础篇
JVM介绍
-
JVM全称是Java Virtual Machine, 即Java虚拟机,是一个运行在计算机上的程序,职责是运行Java字节码文件,但JVM上也可以运行Java、Kotlin、Scala等语言
-
JVM的三大核心功能是内存管理(自动为对象、方法等分配内存;自动的垃圾回收机制,回收不再使用的对象),解释运行虚拟机指令(对字节码文件中的指令,实时解释成机器码,让计算机执行),即时编译(对热点代码进行优化,提升执行效率)
-
Java需要实时解释,主要是为了支持跨平台特性(一次编写,多处运行)


-
常见的JVM有HotSpot、GraalVM、OpenJ9等,使用最广泛的是HotSpot
-
JVM组成包括类加载子系统,运行时数据区,执行引擎和本地接口四部分

字节码文件
字节码文件本质上是一个二进制文件,无法直接使用记事本等工具打开,需要使用专业工具进行读取:
- 开发环境中使用jclasslib插件
- 服务器环境使用javap -v命令

-
Magic魔数:
软件无法直接通过文件扩展名确定文件类型(可以随意更改而不影响文件内容),而是使用文件的头几个字节(文件头)校验文件的类型
在Java字节码文件中,将文件头称为magic魔数

-
主副版本号:
主版本号指的是编译字节码文件的JDK版本号,主版本号用来标记大版本号,JDK1.0 -1.1使用45.0- 45.3,JDK1.2是46之后每升级一个大版本就会增加1;副版本号是当主版本号相同时作为区分不同版本的标识,一般只需要关心主版本号
版本号的作用主要是判断当前字节码的版本和运行时的JDK是否兼容
1.2之后的大版本号计算方法:主版本号-44

-
常量池:
字节码文件中常量池的作用:避免相同的内容重复定义,节省空间
常量池中的数据都有一个编号,编号从1开始。在字段或者字节码指令中通过编号可以快速的找到对应的数据
字节码指令中通过编号引用到常量池的过程称之为符号引用 -
方法:
操作数栈是临时存放数据的地方
局部变量表是存放方法中的局部变量的位置

类的生命周期
类的生命周期包括加载,链接(验证、准备和解析),初始化,使用和卸载五个过程

- 加载阶段:
加载阶段第一步是类加载器根据类的全限定名通过不同的渠道以二进制流的方式获取字节码信息。程序员可以使用Java代码拓展的不同的渠道(本地文件、动态代理生成、网络传输的类等)
第二步,类加载器在加载完类之后,Java虚拟机会将字节码中的信息保存到方法区中。生成一个InstanceKlass对象,保存类的所有信息,里边还包含实现特定功能比如多态的信息

第三步,Java虚拟机还会在堆中生成一份与方法区中数据类似的java.lang.Class对象,作用是在Java代码中去获取类的信息以及存储静态字段的数据(JDK8及之后)
对于开发者来说,只需要访问堆中的Class对象而不需要访问方法区中所有信息,这样Java虚拟机就能很好地控制开发者访问数据的范围

- 连接阶段-验证:
连接阶段的第一个环节是验证,验证的主要目的是检测Java字节码文件是否遵守《Java虚拟机规范》中的约束,这个阶段一般不需要程序员参与,主要包含如下四部分:
(1)文件格式验证,PS:文件是否以OxCAFEBABE开头,主次版本号是否满足当前Java虚拟机版本要求
(2)元信息验证,PS:类必须有父类(super不能为空)
(3)验证程序执行指令的语义,PS:方法内的指令执行中跳转到不正确的位置
(4)符号引用验证,PS:是否访问了其他类中private的方法

- 连接阶段-准备:
准备阶段为静态变量(static)分配内存并赋初始值(堆区),而每一种基本数据类型和引用数据类型都有其初始值
final修饰的基本数据类型的静态变量,准备阶段直接会将代码中的值进行赋值

- 连接阶段-解析:
解析阶段主要是将常量池中的符号引用替换为直接引用
符号引用就是在字节码文件中使用编号来访问常量池中的内容
直接引用不再使用编号,而是使用内存中地址进行访问具体的数据

- 初始化阶段:
初始化阶段会执行静态代码块中的代码,并为静态变量赋值
初始化阶段会执行字节码文件中clinit部分的字节码指令
clinit方法中的执行顺序与Java中编写的顺序是一致的

导致类初始化的方式:
(1)访问一个类的静态变量或者静态方法,注意变量是final修饰的并且等号右边是常量不会触发初始化
(2)调用Class.forName(String className)
(3)new一个该类的对象时
(4)执行Main方法的当前类

不导致类初始化的方式:直接访问父类的静态变量,不会触发子类的初始化。因为,子类的初始化clinit调用之前,会先调用父类的clinit初始化方法
clinit指令在特定情况下不会出现:
(1)无静态代码块且无静态变量赋值语句
(2)有静态变量的声明,但是没有赋值语句
(3)静态变量的定义使用final关键字,这类变量会在准备阶段直接进行初始化

数组的创建不会导致数组中元素的类进行初始化
final修饰的变量如果赋值的内容需要执行指令才能得出结果,会执行clinit方法进行初始化 - 卸载阶段:
判定一个类可以被卸载,需要同时满足下面三个条件:
(1)此类所有实例对象都已经被回收,在堆中不存在任何该类的实例对象以及子类对象
(2)加载该类的类加载器已经被回收
(3)该类对应的java.lang.Class对象没有在任何地方被引用

类加载器
类加载器(ClassLoader)是Java虚拟机提供给应用程序去实现获取类和接口字节码数据的技术,类加载器只参与加载过程中的字节码获取并加载到内存这一部分,负责将字节码信息以流的方式获取并加载到内存中转换成byte[],接下来虚拟机可以调用底层方法将byte[]转换成方法区和堆中的数据
类加载器分为两类:Java代码中实现的;Java虚拟机底层源码实现的
JVM中类加载器包含,启动类加载器(Bootstrap,在JDK8及之前由底层C++实现,在之后由Java实现,用于加载Java中最核心的类),扩展类加载器(Extension,允许扩展Java中比较通用的类,由Java实现,JDK9之后改为平台类加载器),应用程序类加载器(Application,加载应用使用的类,由Java实现)和用户自己定义的类加载器

- 启动类加载器:
启动类加载器(Bootstrap ClassLoader)是由Hotspot虚拟
机提供的、使用C++编写的类加载器,默认加载Java安装目录/jre/lib下的类文件,比如rt.jar,tools.jar,resources.jar等。
推荐,使用-Xbootclasspath/a:jar包目录/jar包名进行扩展
JDK9引入了module的概念,启动类加载器使用Java编写,位于jdk.internal.loader.ClassLoaders类中,继承自BuiltinClassLoader实现从模块中找到要加载的字节码资源文件,但启动类加载器依然无法通过java代码获取到,返回的仍然是null,保持了统一 - 扩展类加载器:
扩展类加载器(Extension ClassLoader)是JDK中提供的,使用Java编写的类加载器,默认加载Java安装目录/jre/lib/ext下的类文件
推荐,使用-Djava.ext.dirs=jar包目录进行扩展,这种方式会覆盖掉原始目录,可以用:(windows):(macos/linux)追加上原始目录 - 平台类加载器:
平台类加载器遵循模块化方式加载字节码文件,所以继承关系从URLClassLoader变成了BuiltinClassLoader,BuiltinClassLoader实现了从模块中加载字节码文件
平台类加载器的存在更多的是为了与老版本的设计方案兼容,自身没有特殊的逻辑 - 应用程序类加载器:
应用程序类加载器(Application Class Loader)是JDK中提供的,使用Java编写的类加载器,默认加载Java安装目录classpath下的类文件

- 自定义类加载器:
自定义类加载器允许用户自行实现类加载的逻辑,可以从网络、数据库等来源加载类信息。自定义类加载器需要继承自ClassLoader抽象类,重写findClass方法

双亲委派机制
双亲委派机制指的是:当一个类加载器接收到加载类的任务时,会自底向上查找是否加载过,再由顶向下进行加载
每个类加载器都有一个父类加载器,在类加载的过程中,每个类加载器都会先检查是否已经加载了该类,如果已经加载则直接返回,否则会将加载请求委派给父类加载器,向上查找如果已经加载过,就直接返回Class对象,加载过程结束,这样就能避免一个类重复加载;如果所有的父类加载器都无法加载该类,则由当前类加载器自己尝试加载,因此看上去是自顶向下尝试加载(向下委派加载起到了一个加载优先级的作用);第二次再去加载相同的类,仍然会向上进行委派,如果某个类加载器加载过就会直接返回

应用程序类加载器的parent父类加载器是扩展类加载器,而扩展类加载器的parent是空,但是在代码逻辑上,扩展类加载器依然会把启动类加载器当成父类加载器处理;启动类加载器使用C++编写,没有父类加载器

双亲委派机制的优势:
(1)保证类加载的安全性:通过双亲委派机制避免恶意代码替换JDK中的核心类库,比如java.lang.String,确保核心类库的完整性和安全性
(2)避免重复加载:双亲委派机制可以避免同一个类被多次加载
加载类的方法:
(1)使用Class.forName方法,使用当前类的类加载器去加载指定的类
(2)获取到类加载器,通过类加载器的loadClass方法指定某个类加载器加载

打破双亲委派机制的方式:
- 自定义类加载器:
自定义类加载器并且重写loadclass方法,就可以将双亲委派机制的代码去除。Tomcat通过这种方式实现应用之间类的隔离
ClassLoader中包含了4个核心方法,双亲委派机制的核心代码就位于loadClass方法中



正确的去实现一个自定义类加载器的方式是重写findClass方法,这样不会破坏双亲委派机制
在同一个JVM中,只有相同类加载器+相同的类限定名才会被认为是同一个类
案例Tomcat:
一个Tomcat程序中是可以运行多个Web应用的,如果这两个应用中出现了相同限定名的类,比如Servlet类,Tomcat要保证这两个类都能加载并且它们应该是不同的类
如果不打破双亲委派机制,当应用类加载器加载Web应用1中的MyServlet之后,Web应用2中相同限定名的MyServlet类就无法被加载了
Tomcat使用了自定义类加载器来实现应用之间类的隔离,为每一个应用创建一个独立的类加载器加载对应的类

common类加载器主要加载tomcat自身使用以及应用使用的jar包,默认配置在catalina.properties文件中
common.loader=“ ( c a t a l i n a . b a s e ) / l i b " , " (catalina.base)/lib", " (catalina.base)/lib","(catalina.base)/lib/*.jar”
catalina类加载器主要加载tomcat自身使用的jar包,不让应用使用,默认配置在catalina.properties文件中。
server.loader=默认配置为空,为空时catalina加载器和common加载器是同一个
shared类加载器主要加载应用使用的jar包,不让tomcat使用,默认配置在catalina.properties文件中
shared.loader=默认配置为空,为空时shared加载器和common加载器是同一个
ParallelWebappClassLoader类加载器可以多线程并行加载应用中使用到的类,每个应用都拥有一个自己的该类加载器,这是为了确保两个应用中相同名称的类都被成功加载

ParallelWebappClassLoader类加载器的加载流程:默认这里打破了双亲委派机制,应用中的类如果没有加载过时,会先从当前类加载器加载,然后再交给父类加载器通过双亲委派机制加载


JasperLoader类加载器负责加载JSP文件编译出来的class字节码文件,为了实现热部署(不重启让修改的jsp生效),每一个jsp文件都由一个独立的JasperLoader负责加载 - 线程上下文类加载器:利用上下文类加载器加载类,PS:JDBC和JNDI等
案例:JDBC
JDBC中使用了DriverManager来管理项目中引I入的不同数据库的驱动,比如mysql驱动、oracle驱动

DriverManager类位于rt.jar包中,由启动类加载器加载,而用户jar包中的mysql驱动等对应的类,由应用程序类加载器来加载,这违反了双亲委派机制

DriverManager怎么知道jar包中要加载的驱动位置 ------ SPI机制(Service Provider Interface),是JDK内置的一种服务提供发现机制,工作原理:
(1)在ClassPath路径下的META-INF/services文件夹中,以接口的全限定名来命名文件名,对应的文件里面写该接口的实现
(2)使用ServiceLoader加载实现类
(3)SPI中使用了线程上下文中保存的类加载器进行类的加载,这个类加载器一般是应用程序类加载器


首先启动类加载器加载DriverManager;然后,在初始化DriverManager时,通过SPI机制加载jar包中的myql驱动;最后,SPI中利用了线程上下文类加载器(应用程序类加载器)去加载类并创建对象
这种由启动类加载器加载的类,委派应用程序类加载器去加载类的方式,打破了双亲委派机制


- Osgi框架的类加载器:历史上0sgi框架实现了一套新的类加载器机制,允许同级之间委托进行类的加载
历史上,OSGi模块化框架。它存在同级之间的类加载器的委托加载。OSGi还使用类加载器实现了热部署的功能(在服务不停止的情况下,动态地更新字节码文件到内存中),但OSGi模块化目前已不再使用
运行时数据区
运行时数据区指的是JVM所管理的内存区域,其中分成两大类:线程共享-方法区、堆;线程不共享-本地方法栈、虚拟机栈、程序计数器。直接内存主要是NIO使用,由操作系统直接管理,不属于JVM内存

- 程序计数器:
程序计数器(ProgramCounterRegister)也叫PC寄存器,每个线程会通过程序计数器记录当前要执行的的字节码指令的地址,运行时数据区中唯一不会发生内存溢出的部分
在加载阶段,虚拟机将字节码文件中的指令读取到内存之后,会将原文件中的偏移量转换成内存地址。每一条字节码指令都会拥有一个内存地址
在代码执行过程中,程序计数器会记录下一行字节码指令的地址。执行完当前指令之后,虚拟机的执行引擎根据程序计数器执行下一行指令

程序计数器的作用:
(1)控制程序指令的进行,实现分支、跳转、异常等逻辑
(2)在多线程执行情况下,Java虚拟机需要通过程序计数器记录CPU切换前解释执行到那一句指令并继续解释运行 - 虚拟机栈:
Java虚拟机栈(Java Virtual Machine Stack)采用栈的数据结构来管理方法调用中的基本数据,先进后出(First In Last Out),每一个方法的调用使用一个栈帧(StackFrame)来保存
Java虚拟机栈随着线程的创建而创建,而回收则会在线程的销毁时进行(生命周期和线程相同)。由于方法可能会在不同线程中执行,每个线程都会包含一个自已的虚拟机栈

每一个栈帧都由局部变量表,操作数栈和帧数据组成:
(1)局部变量表:在方法执行过程中存放所有的局部变量,编译成字节码文件时就可以确定局部变
量表的内容。局部变量表保存的内容有:实例方法的this对象,方法的参数,方法体中声明的局部变量

栈帧中的局部变量表是一个数组,数组中每一个位置称之为槽(slot),long和double类型占用两个槽,其他类型占用一个槽;方法参数也会保存在局部变量表中,其顺序与方法中参数定义的顺序一致;为了节省空间,局部变量表中的槽是可以复用的,一旦某个局部变量不再生效,当前槽就可以再次被使用

(2)操作数栈:操作数栈是栈帧中虚拟机在执行指令过程中用来存放中间数据的一块区域,如
果一条指令将一个值压入操作数栈,则后面的指令可以弹出并使用该值,在编译期就可以确定操作数栈的最大深度,从而在执行时正确的分配内存大小
(3)帧数据:帧数据主要包含动态链接、方法出口、异常表的引用
当前类的字节码指令引用了其他类的属性或者方法时,需要将符号引用(编号)转换成对应的运行时常量池中的内存地址。动态链接就保存了编号到运行时常量池的内存地址的映射关系

方法调用完需要弹出栈帧,回到上一个方法,程序计数器要切换到上一个方法的地址继续执行,方法出口保存的就是这个地址。
异常表存放的是代码中异常的处理信息,包含了异常捕获的生效范围以及异常发生后跳转到的字节码指令位置

栈溢出:Java虚拟机栈如果栈帧过多,占用内存超过栈内存可以分配的最大大小就会出现内存溢出,出现StackOverflowError的错误
修改Java虚拟机栈的大小,可以使用虚拟机参数:-Xss栈大小,单位:字节(默认1024 的倍数)、k或者K(KB)、m或者M(MB)、g或者G(GB),一般情况下,工作中即便使用了递归进行操作,栈的深度最多也只能到几百,不会出现栈的溢出。所以此参数可以手动指定为-Xss256k节省内存 - 本地方法栈:
Java虚拟机栈存储了Java方法调用时的栈帧,而本地方法栈存储的是native本地方法的栈帧
在Hotspot虚拟机中,Java虚拟机栈和本地方法栈实现上使用了同一个栈空间,本地方法栈会在栈内存上生成一个栈帧,临时保存方法的参数同时方便出现异常时也把本地方法的栈信息打印出来

- Java堆:
一般Java程序中堆内存是空间最大的一块内存区域,创建出来的对象都存在于堆上。栈上的局部变量表中,可以存放堆上对象的引用。静态变量也可以存放堆对象的引用,通过静态变量就可以实现对象在线程之间共享。堆是垃圾回收最主要的部分,堆结构更详细的划分与垃圾回收器有关

堆空间有三个需要关注的值:used指的是当前已使用的堆内存,total是java虚拟机已经分配的可用堆内存,max是java虚拟机可以分配的最大堆内存;随着堆中的对象增多,当total可以使用的内存即将不足时,java虚拟机会继续分配内存给堆;如果堆内存不足,java虚拟机就会不断的分配内存,total值会变大,total最多只能与max相等;如果不设置任何的虚拟机参数,max默认是系统内存的1/4,total默认是系统内存的1/64,在实际应用中一般都需要设置total和max的值
要修改堆的大小,可以使用虚拟机参数 -Xmx (max最大值)和 -Xms (初始的total):-Xmx值 -Xms值,单位字节(默认,必须是 1024 的倍数)、k/K、m/M、g/G,限制:Xmx必须大于 2MB,Xms必须大于1MB
Java服务端程序开发时,建议将-Xmx和-Xms设置为相同的值,这样在程序启动之后可使用的总内存就是最大内存,而无需向java虚拟机再次申请,减少了申请并分配内存时间上的开销,同时也不会出现内存过剩之后堆收缩的情况

- 方法区:
方法区是用来存储每个类的基本信息(元信息),一般称之为InstanceKlass对象,在类的加载阶段完成

方法区除了存储类的元信息之外,还存放了运行时常量池。常量池中存放的是字节码中的常量池内容
字节码文件中通过编号查表的方式找到常量,这种常量池称为静态常量池。当常量池加载到内存中之后,可以通过内存地址快速的定位到常量池中的内容,这种常量池称为运行时常量池

方法区是《Java虚拟机规范》中设计的虚拟概念,每款Java虚拟机在实现上都各不相同。Hotspot设计如下:
JDK7及之前的版本将方法区存放在堆区域中的永久代空间,堆的大小可以由虚拟机参数:-XX:MaxPermSize=值来控制
JDK8及之后的版本将方法区存放在元空间中,元空间位于操作系统维护的直接内存中,默认情况下只要不超过操作系统承受的上限,可以一直分配,使用:-XX:MaxMetaspaceSize=值来控制

方法区中除了类的元信息、运行时常量池之外,还有一块区域叫字符串常量池用于存储在代码中定义的常量字符串内容
JDK7之前,运行时常量池逻辑包含字符串常量池,hotspot虚拟机对方法区的实现为永久代;JDK7,字符串常量池被从方法区拿到了堆中,运行时常量池剩下的东西还在永久代;JDK8及之后,hotspot移除了永久代用元空间(Metaspace)取而代之,字符串常量池还在堆
案例:前者不相同,后者相同

JDK6版本中intern()方法会把第一次遇到的字符串实例复制到永久代的字符串常量池中,返回的也是永久代里面这个字符串实例的引用,JVM启动时就会把java加入到常量池中

JDK7及之后版本中由于字符串常量池在堆上,所以intern()方法会把第一次遇到的字符串的引用放入字符串常量池

JDK6及之前的版本中,静态变量是存放在方法区中的,也就是永久代;JDK7及之后的版本中,静态变量是存放在堆中的Class对象中,脱离了永久代 - 直接内存:
直接内存(Direct Memory)并不在《Java虚拟机规范》中存在,所以并不属于Java运行时的内存区域。
在JDK1.4中引入了NIO机制,使用了直接内存,主要为了解决以下两个问题:
(1)Java堆中的对象如果不再使用要回收,回收时会影响对象的创建和使用
(2)IO操作比如读文件,需要先把文件读入直接内存(缓冲区)再把数据复制到Java堆中,现在直接放入直接内存即可,同时Java堆上维护直接内存的引用,减少了数据复制的开销。写文件也是类似的思路

要创建直接内存上的数据,可以使用ByteBuffer:ByteBuffer directBuffer = ByteBuffer.allocateDirect(size)
如果需要手动调整直接内存的大小,可以使用**-XX:MaxDirectMemorySize=值**,单位k/K、m/M、g/G。默认不设置该参数情况下,JVM自动选择最大分配的大小 - 内存溢出总结:
在Java虚拟机中,只有程序计数器不会出现内存溢出的情况,因为每个线程的程序计数器只保存一个固定长度的地址

(1)堆内存溢出:
堆内存溢出指的是在堆上分配的对象空间超过了堆的最大大小,从而导致的内存溢出。堆的最大大小使用:-Xmx参数进行设置,溢出之后会抛出OutOfMemoryError,并提示是Java heap Space导致的
(2)栈内存溢出:
栈内存溢出指的是所有栈帧空间的占用内存超过了最大值,最大值使用:-Xss进行设置,溢出之后会抛出StackOverflowError
(3)方法区溢出:
方法区内存溢出指的是方法区中存放的内容比如类的元信息超过过了方法区内存的最大值,JDK7及之前版本方法区使用永久代:-XX:MaxPermSize=值来实现,溢出之后会抛出OutOfMemoryError,并提示是MetaSpace导致的;JDK8及之后使用元空间:-XX:MaxMetaspaceSize=值来实现,溢出之后会抛出OutOfMemoryError,并提示是PermGen Space导致的
(4)直接内存溢出:
直接内存溢出指的是申请的直接内存空间大小超过了最大值,使用:-XX:MaxQirectMemorySize=值设置最大值,溢出之后会抛出OutOfMemoryError,并提示是Direct buffer memory导致的 - JDK6 - JDK8内存区域变化:
方法区是《Java虚拟机规范》中设计的虚拟概念,每款Java虚拟机在实现上都各不相同。Hotspot设计如下:
JDK7及之前的版本将方法区存放在堆区域中的永久代空间,堆的大小由虚拟机参数来控制
JDK8及之后的版本将方法区存放在元空间中,元空间位于操作系统维护的直接内存中,默认情况下只要不超过操作系统承受的上限,可以一直分配,也可以手动设置最大大小
使用元空间替换永久代的原因:
(1)提高内存上限:元空间使用的是操作系统内存,而不是JVM内存。如果不设置上限,只要不超过操作系统内存上限,就可以持续分配。而永久代在堆中,可使用的内存上限是有限的。所以使用元空间可以有效减少OOM情况的出现
(2)优化垃圾回收的策略:永久代在堆上,垃圾回收机制一般使用老年代的垃圾回收方式,不够灵活。使用元空间之后单独设计了一套适合方法区的垃圾回收机制

早期设计时,字符串常量池是属于运行时常量池的一部分,他们存储的位置也是一致的,后续做出了调整,将字符串常量池和运行时常量池做了拆分
字符串常量池从方法区移动到堆的原因:
(1)垃圾回收优化:字符串常量池的回收逻辑和对象的回收逻辑类似,内存不足的情况下,如果字符串常量池中的常量不被使用就可以被回收;方法区中的类的元信息回收逻辑更复杂一些。移动到堆之后,就可以利用对象的垃圾回收器,对字符串常量池进行回收
(2)方法区大小更可控:一般在项目中,类的元信息不会占用特别大的空间,所以会给方法区设置一个比较小的上限。如果字符串常量池在方法区中,会让方法区的空间大小变得不可控
(3)intern方法的优化:JDK6版本中intern()方法会把第一次遇到的字符串实例复制到永久代的字符串常量池中,JDK7及之后版本中由于字符串常量池在堆上,就可以进行优化:字符串保存在堆上,把字符串的引用放入字符串常量池,减少了复制的操作

JDK6

JDK7

JDK8

自动垃圾回收
Java中为了简化对象的释放,引入了自动的垃圾回收(Garbage Collection, GC)机制;通过垃圾回收器来对不再使用的对象完成自动的回收,垃圾回收器主要负责对堆上的内存进行回收;其他很多现代语言比如C#、Python、Go都拥有自己的垃圾回收器
线程不共享的部分,都是伴随着线程的创建而创建,线程的销毁而销毁,而方法的栈帧在执行完方法之后就会自动弹出栈并释放掉对应的内存

方法区中能回收的内容主要就是不再使用的类
判定一个类可以被卸载。需要同时满足下面三个条件:
- 此类所有实例对象都已经被回收,在堆中不存在任何该类的实例对象以及子类对象
- 加载该类的类加载器已经被回收
- 该类对应的java.lang.Class对象没有在任何地方被引用

如果需要手动触发垃圾回收,可以调用**System.gc()**方法,但调用System.gc()方法并不一定会立即回收垃圾,仅仅是向Java虚拟机发送一个垃圾回收的请求,具体是否需要执行垃圾回收Java虚拟机会自行判断
开发中此类场景一般很少出现,主要在如OSGi、JSP的热部署等应用场景中。每个jsp文件对应一个唯一的类加载器,当一个jsp文件修改了,就直接卸载这个jsp类加载器,重新创建类加载器,重新加载jsp文件
垃圾回收对象判定
- 引用计数法:
引用计数法会为每个对象维护一个引用计数器,当对象被引用时加1,取消引用时减1
引用计数法的优点是实现简单,C++中的智能指针就采用了引用计数法,但是它也存在缺点,主要有两点:
(1)每次引用和取消引用都需要维护计数器,对系统性能会有一定的影响
(2)存在循环引用问题,所谓循环引用就是当A引用B,B同时引用A时会出现对象无法回收的问题

- 可达性分析:
Java使用的是可达性分析算法来判断对象是否可以被回收。可达性分析将对象分为两类:垃圾回收的根对象(GC Root)和普通对象,对象与对象之间存在引用关系
下图中A到B再到C和D,形成了一个引用链,可达性分析算法指的是如果从某个到GCRoot对象是可达的,对象就不可被回收

GC Root对象:
(1)线程Thread对象,引用线程栈帧中的方法参数、局部变量
(2)系统类加载器加载的Java.lang.Class对象,引用类中的静态变量
(3)监视器对象,用来保存同步锁synchronized关键字持有的对象
(4)本地方法调用时使用的全局对象
对象引用类型:
(1)强引用:可达性算法中描述的对象引用,一般指的是强引用,即是GC Root对象对普通对象有引用关系,只要这层关系存在,普通对象就不会被回收
(2)软引用:软引用相对于强引用是一种比较弱的引用关系,如果一个对象只有软引用关联到它,当程序内存不足时,就会将软引用中的数据进行回收
在JDK1.2版之后提供了SoftReference类来实现软引用,软引用常用于缓存中

软引用的执行过程:
a.将对象使用软引用包装起来,newSoftReference<对象类型>(对象)
b.内存不足时,虚拟机尝试进行垃圾回收
c.如果垃圾回收仍不能解决内存不足的问题,回收软引用中的对象
d.如果依然内存不足,抛出OutOfMemory异常

软引用中的对象如果在内存不足时回收,SoftReference对象本身也需要被回收。SoftReference提供了一套队列机制:
a.软引用创建时,通过构造器传入引用队列
b.在软引用中包含的对象被回收时,该软引用对象会被放入引用队列
c.通过代码遍历引用队列,将SoftReference的强引用删除
(3)弱引用:弱引用的整体机制和软引用基本一致,区别在于弱引用包含的对象在垃圾回收时,不管内存够不够都会直接被回收
在JDK1.2版之后提供了WeakReference类来实现弱引用,弱引用主要在ThreadLocal中使用,弱引用对象本身也可以使用引用队列进行回收

(4)虚引用:在常规开发中不会使用。虚引用也叫幽灵引用/幻影引用,不能通过虚引用对象获取到包含的对象。虚引用唯一的用途是当对象被垃圾回收器回收时可以接收到对应的通知。Java中使用PhantomReference实现了虚引用,直接内存中为了及时知道直接内存对象不再使用,从而回收内存,使用了虚引用来实现
(5)终结器引用:在常规开发中不会使用。终结器引用指的是在对象需要被回收时,终结器引用会关联对象并放置在Finalizer类中的引用队列中,在稍后由一条由FinalizerThread线程从队列中获取对象,然后执行对象的finalize方法,在对象第二次被回收时,该对象才真正的被回收。在这个过程中可以在finalize方法中再将自身对象使用强引用关联上,但是不建议这样做
垃圾回收算法
- 评价标准:
Java垃圾回收过程会通过单独的GC线程来完成,但是不管使用哪一种GC算法,都会有部分阶段需要停止所有的用户线程。这个过程被称之为Stop The World简称STW,如果STW时间过长则会影响用户的使用

(1)吞吐量:吞吐量指的是CPU用于执行用户代码的时间与CPU总执行时间的比值,即吞吐量=执行用户代码时间/(执行用户代码时间+GC时间)。吞吐量数值越高,垃圾回收的效率就越高。
(2)最大暂停时间:最大暂停时间指的是所有在垃圾回收过程中的STW时间最大值。最大暂停时间越短,用户使用系统时受到的影响就越短
(3)堆使用效率:不同垃圾回收算法,对堆内存的使用方式是不同的。比如标记清除算法,可以使用完整的堆内存;而复制算法会将堆内存一分为二,每次只能使用一半内存。从堆使用效率上来说,标记清除算法要优于复制算法

堆使用效率、吞吐量,以及最大暂停时间不可兼得。一般来说,堆内存越大,最大暂停时间就越长;想要减少最大暂停时间,就会降低吞吐量。因而,不同的垃圾回收算法,适用于不同的场景 - 标记清除算法:
标记清除算法的核心思想分为两个阶段:
(1)标记阶段:将所有存活的对象进行标记。Java中使用可达性分析算法,从GC Root开始通过引用链遍历出所有存活对象
(2)清除阶段:从内存中删除没有被标记也就是非存活对象
优点:实现简单,只需要在第一阶段给每个对象维护标志位,第二阶段删除对象即可
缺点:
(1)碎片化问题:由于内存是连续的,所以在对象被删除之后,内存中会出现很多细小的可用内存单元。如果我们需要的是一个比较大的空间,很有可能这些内存单元的大小过小无法进行分配
(2)分配速度慢:由于内存碎片的存在,需要维护一个空闲链表,极有可能发生每次需要遍历到链表的最后才能获得合适的内存空间

- 复制算法:
复制算法的核心思想:
(1)准备两块空间From空间和To空间,每次在对象分配阶段,只能使用其中一块空间(From空间)
(2)在垃圾回收GC阶段,将From中存活对象复制到To空间
(3)将两块空间的From和To名字互换

缺点:每次只能让一半的内存空间来为创建对象使用
优点:
(1)吞吐量高:复制算法只需要遍历一次存活对象复制到To空间即可,比标记-整理算法少了一次遍历的过程,因而性能较好,但是不如标记-清除算法,因为标记清除算法不需要进行对象的移动
(2)不会发生碎片化:复制算法在复制之后就会将对象按顺序放入To空间中,所以对象以外的区域都是可用空间,不存在碎片化内存空间 - 标记整理算法:
标记整理算法也叫标记压缩算法,是对标记清理算法中容易产生内存碎片问题的一种解决方案,核心思想分为两个阶段:
(1)标记阶段:将所有存活的对象进行标记。Java中使用可达性分析算法,从GC Root开始通过引用链遍历出所有存活对象
(2)整理阶段:将存活对象移动到堆的一端,清理掉存活对象的内存空间

缺点:整理算法有很多种,比如Lisp2整理算法需要对整个堆中的对象搜索3次,整体性能不佳。可以通过TwO-Finger、表格算法、ImixGc等高效的整理算法优化此阶段的性能
优点:
(1)内存使用效率高:整个堆内存都可以使用,不会像复制算法只能使用半个堆内存
(2)不会发生碎片化:在整理阶段可以将对象往内存的一侧进行移动,剩下的空间都是可以分配对象的有效空间 - 分代垃圾回收:
分代垃圾回收将整个内存区域划分为年轻代,存放存活时间较短的对象和老年代,存放存活时间较长的对象:

分代回收时,创建出来的对象,首先会被放入Eden伊甸园区
随着对象在Eden区越来越多,如果Eden区满,新创建的对象已经无法放入,就会触发年轻代的GC,称为Minor GC或者Young GC
MinorGC会把需要Eden中和From需要回收的对象回收,把没有回收的对象放入To区
接下来,S0会变成To区,S1变成From区
当Eden区满时再往里放入对象,依然会发生Minor GC,此时,会回收Eden区和S1(From)中的对象,并把Eden和From区中剩余的对象放入S0
PS:每次Minor GC中都会为对象记录他的年龄,初始值为0,每次GC完加1

如果Minor GC后对象的年龄达到阔值(最大15,默认值和垃圾回收器有关),对象就会被晋升至老年代
当老年代中空间不足,无法放入新的对象时,先尝试Minor GC如果还是不足,就会触发Full GC
Full GC会对整个堆进行垃圾回收,如果Full GC依然无法回收掉老年代的对象,那么当对象继续放入老年代时,就会抛出OutOfMemory异常
PS:当年轻代的空间占满时,再放入新对象,就会调用Minor GC,此时若无法清除幸存者区就会导致年龄没有到达阈值的对象晋升至老年代
分代GC算法将堆分成年轻代和老年代主要原因:
(1)可以通过调整年轻代和老年代的比例来适应不同类型的应用程序,提高内存的利用率和性能。
(2)新生代和老年代使用不同的垃圾回收算法,新生代一般选择复制算法,老年代可以选择标记-清除和标记-整理算法,由程序员来选择灵活度较高。
(3)分代的设计中允许只回收新生代(Minor GC),如果能满足对象分配的要求就不需要对整个堆进行回收(Full GC),STW时间就会减少
垃圾回收器
垃圾回收器是垃圾回收算法的具体实现,由于垃圾回收器分为年轻代和老年代,除了G1之外其他垃圾回收器必须成对组合进行使用

不同垃圾回收器的对比:

垃圾回收器的设计目标:

- 年轻代-Serial垃圾回收器:单线程串行
回收年代+算法:年轻代+复制算法
优势:单CPU处理器下吞吐量非常出色
劣势:多CPU下吞吐量不如其他垃圾回收器,堆如果偏大会让用户线程处于长时间的等待
适用场景:Java编写的客户端程序或者硬件配置有限的场景

- 老年代-SerialOld垃圾回收器:SerialOld是Serial垃圾回收器的老年代版本,采用单线程串行回收
-XX:+UseSerialGC新生代、老年代都使用串行回收器
回收年代+算法:老年代+标记-整理算法
优势:单CPU处理器下吞吐量非常出色
劣势:多CPU下吞吐量不如其他垃圾回收器,堆如果偏大会让用户线程处于长时间的等待
适用场景:与Serial垃圾回收器搭配使用,或者在CMS特殊情况下使用 - 年轻代-ParNew垃圾回收器:ParNew垃圾回收器本质上是对Serial在多CPU下的优化,使用多线程进行垃圾回收
-XX:+UseParNewGC 新生代使用ParNew回收器,老年代使用串行回收器
回收年代+算法:年轻代+复制算法
优势:多CPU处理器下停顿时间较短
劣势:吞吐量和停顿时间不如G1,所以在JDK9之后不建议使用
适用场景:JDK8及之前的版本中,与CMS老年代垃圾回收器搭配使用

- 老年代-CMS(Concurrent Mark Sweep)垃圾回收器:CMS垃圾回收器关注的是系统的暂停时间,允许用户线程和垃圾回收线程在某些步骤中同时执行,减少了用户线程的等待时间
-XX:+UseConcMarkSweepGC
回收年代+算法:老年代+标记清除算法
优势:系统由于垃圾回收出现的停顿时间较短,用户体验好
劣势:
(1)CMS使用了标记-清除算法,在垃圾收集结束之后会出现大量的内存碎片,CMS会在FuI GC时进行碎片的整理。这样会导致用户线程暂停,可以使用:-XX:CMSFullGCsBeforeCompaction=N(默认0)调整N次Full GC之后再整理
(2)无法处理在并发清理过程中产生的浮动垃圾,不能做到完全的垃圾回收
(3)如果老年代内存不足无法分配对象,CMS就会退化成Serial Old单线程回收老年代
适用场景:大型的互联网系统中用户请求数据量大、频率高的场景比如订单接口、商品接口等
执行过程:
(1)初始标记,用极短的时间标记出GCRoots能直接关联到的对象
(2)并发标记,标记所有的对象,用户线程不需要暂停
(3)重新标记,由于并发标记阶段有些对象会发生了变化,存在错标、漏标等情况,需要重新标记
(4)并发清理,清理死亡的对象,用户线程不需要暂停

- 年轻代-Parallel Scavenge垃圾回收器:Parallel Scavenge是JDK8默认的年轻代垃圾回收器,多线程并行回收,关注的是系统的吞吐量,具备自动调整堆内存大小的特点
Parallel Scavenge允许手动设置最大暂停时间和吞吐量。Oracle官方建议在使用这个组合时,不要设置堆内存的最大值,垃圾回收器会根据最大暂停时间和吞吐量自动调整内存大小
-XX:MaxGCPauseMillis=n设置每次垃圾回收时的最大停顿毫秒数
-XX:GCTimeRatio=n设置吞吐量为n(用户线程执行时间= n/n+ 1)
-XX:+UseAdaptiveSizePolicy设置可以让垃圾回收器根据吞吐量和最大停顿的毫秒数自动调整内存大小
回收年代+算法:年轻代+复制算法
优势:吞吐量高,而且手动可控。为了提高吞吐量,虚拟机会动态调整堆的参数
劣势:不能保证单次的停顿时间
适用场景:后台任务,不需要与用户交互,并且容易产生大量的对象,比如:大数掴的处理,大文件导出

- 老年代-Parallel Old垃圾回收器:Parallel Old是为Parallel Scavenge收集器设计的老年代版本,利用多线程并发收集
-XX:+UseParallelGC 或 -XX:+UseParallelOIdGC可以使用Parallel Scavenge + Parallel Old这种组合
回收年代+算法:老年代+标记整理算法
优势:并发收集,在多核CPU下效率较高
劣势:暂停时间较长
适用场景:与Parallel Scavenge配套使用 - G1-Garbage First 垃圾回收器:
-XX:+UseG1GC 打开G1的开关,JDK9之后默认不需要打开
-XX:MaxGCPauseMillis=毫秒值,最大暂停的时间
回收年代+算法:年轻代+老年代+复制算法
优势:对比较大的堆如超过6G的堆回收时,延迟可控,不会产生内存碎片,并发标记的SATB算法效率高
劣势:JDK8之前不成熟
适用场景:JDK9后默认使用
Parallel Scavenge关注吞吐量,允许用户设置最大暂停时间,但是会减少年轻代可用空间的大小
CMS关注暂停时间,但是吞吐量方面会下降
G1设计目标就是将上述两种垃圾回收器的优点融合:
(1)支持巨大的堆空间回收,并有较高的吞吐量
(2)支持多CPU并行垃圾回收
(3)允许用户设置最大暂停时间
G1垃圾回收器
- 内存结构:
G1的整个堆会被划分成多个大小相等的区域,称之为区Region,区域不要求是连续的。分为Eden、Survivor、Old区。Region的大小通过堆空间大小/2048计算得到,也可以通过参数 -XX:G1HeapRegionSize=32m指定(其中32m指定region大小为32M),Region size必须是2的指数幕,取值范围从1M到32M

- 年轻代回收(Young GC):
年轻代回收(Young GC),回收Eden区和Survivor区中不用的对象,会导致STW。G1中可以通过参数 -XX:MaxGCPauseMillis=n(默认200)设置每次垃圾回收时的最大暂停时间毫秒数,G1垃圾回收器会尽可能地保证暂停时间
(1)新创建的对象会存放在Eden区。当G1判断年轻代区不足(max默认60%),无法分配对象时需要回收时会执行Young GC
(2)标记出Eden和Survivor区域中的存活对象
(3)根据配置的最大暂停时间选择某些区域将存活对象复制到一个新的Survivor区中(年龄+1),清空这些区域

G1在进行Young GC的过程中会去记录每次垃圾回收时每个Eden区和Survivor区的平均耗时,以作为下次回收时的参考依据。这样就可以根据配置的最大暂停时间计算出本次回收时最多能回收多少个Region区域。比如 -XX:MaxGCPauseMillis=n(默认200),每个Region回收耗时40ms,那么这次回收最多只能回收4个Region
(4)后续Young GC时与之前相同,只不过Survivor区中存活对象会被搬运到另一个Survivor区
(5)当某个存活对象的年龄到达阈值(默认15),将被放入老年代
(6)部分对象如果大小超过Region的一半,会直接放入老年代,这类老年代被称为Humongous区。比如堆内存是4G,每个Region是2M,只要一个大对象超过了1M就被放入Humongous区,如果对象过大会横跨多个Region
(7)多次回收之后,会出现很多Old老年代区,此时总堆占有率达到阈值时(-XX:InitiatingHeapOccupancyPercent默认45%)会触发混合回收Mixed GC,回收所有年轻代和部分老年代的对象以及大对象区,采用复制算法

- 混合回收(Mixed GC):
混合回收分为:初始标记(initial mark)、并发标记(concurrent mark)、最终标记(remark或者Finalize
Marking)、并发清理 (cleanup)
G1对老年代的清理会选择存活度最低的区域来进行回收,这样可以保证回收效率最高,这也是G1(Garbage first)名称的由来


如果清理过程中发现没有足够的空Region存放转移的对象,会出现Full GC,采用单线程执行标记-整理算法,此时会导致用户线程的暂停。所以尽量保证应该用的堆内存有一定多余的空间

原理篇
栈上的数据存储
Java中有8大基本数据类型,下述的内存占用,指的是堆上或者数组中内存分配的空间大小,栈上的实现更加复杂


Java虚拟机采用的是空间换时间方案,在栈上不存储具体的类型,只根据slot槽进行数据的处理,浪费了一些内存空间但是避免不同数据类型不同处理方式带来的时间开销
同时,像long型在64位系统中占用2个slot,使用了16字节空间,但实际上在Hotspot虚拟机中,它的高8个字节没有使用,这样就满足了long型使用8个字节的需要
Java中8大基本数据类型在虚拟机中的实现

在栈中使用一个槽的数据类型除了float,JVM都当作int类型来处理


堆中的数据加载到栈上,由于栈上的空间大于或者等于堆上的空间,所以直接处理但是需要注意下符号位;boolean、char为无符号,低位复制,高位补0;byte、char、short由于堆上存储空间较小,需要将高位去掉,boolean比较特殊,只取低位的最后一位保存

对象在堆上的存储
对象在堆中的内存布局:

- 标记字段:
标记字段相对比较复杂。在不同的对象状态(有无锁、是否处于垃圾回收的标记中)下存放的内容是不同的,同时在64位(又分为是否开启指针压缩)、32位虚拟机中的布局都不同。以64位开启指针压缩为例

64位不开启指针压缩,只是将Cms部分弃用

(2)元数据的指针:
Klass pointer元数据的指针指向方法区中保存的InstanceKlass对象

在64位的Java虚拟机中,Klass Pointer以及对象数据中的对象引用都需要占用8个字节,为了减少这部分的内存使用量,64位Java虚拟机使用指针压缩技术,将堆中原本8个字节的指针压缩成4个字节,此功能默认开启,可以使用 -XX:-UseCompressedOops关闭

- 指针压缩:
指针压缩的思想是将寻址的单位放大,比如原来按1字节去寻址,现在可以按8字节寻址。如下图所示,原来按1去寻址,能拿到1字节开始的数据,现在按1去寻址,就可以拿到8个字节开始的数据

这样将编号当成地址,就可以用更小的内存访问更多的数据,但是存在两个问题:
a.需要进行内存对齐,指的是将对象的内存占用填充至8字节的倍数。存在空间浪费(对于Hotspot来说不存在,即便不开启指针压缩,也需要进行内存对齐)
b.寻址大小仅仅能支持2的35次方个字节(32GB,如果超过32GB指针压缩会自动关闭)。不用压缩指针,应该是2的64次方=16EB,用了压缩指针就变成了8(字节)=2的35次方 - 内存对齐:
内存对齐主要目的是为了解决并发情况下CPU缓存失效的问题,内存对齐之后,同一个缓存行中不会出现不同对象的属性。在并发情况下,如果让A对象一个缓存行失效,是不会影响到B对象的缓存行的

字段重排列:在Hotspot中,要求每个属性的偏移量Offset(字段地址-起始地址)必须是字段长度的N倍。比如下图中,Student类中的id属性类型为long,那么偏移量就必须是8的倍数

如果不满足要求,会尝试使用内存对齐,通过在属性之间插入一块对齐区域达到目的。比如下图中,name字段是引用占用8个字节(关闭了指针压缩),所以Offset必须是8的倍数,在age和name之间插入了4个字节的空白区域
子类继承自父类的属性,属性的偏移量和父类一致

方法调用原理
方法调用的本质是通过字节码指令的执行,能在栈上创建栈帧,并执行调用方法中的字节码执行,以invoke开头的字节码指令的作用是执行方法的调用,invoke方法的核心作用就是找到字节码指令并执行

在JVM中,一共有五个字节码指令可以执行方法调用:
- invokestatic:调用静态方法,静态绑定
- invokespecial:调用对象的private方法、构造方法,以及使用super关键字调用父类实例的方法、构造方法,以及所实现接口的默认方法,静态绑定
- invokevirtual:调用对象的非private方法。非final方法使用动态绑定,使用虚方法表找到方法的地址,子类会复制父类的虚方法表,如果子类重写了方法,会替换成重写后方法的地址
- invokeinterface:调用接口对象的方法。动态绑定,使用接口表找到方法的地址,进行调用
- invokedynamic:用于调用动态方法,主要应用于lambda表达式中,机制极为复杂了解即可
Invoke指令执行时,需要找到方法区中instanceKlass中保存的方法相关的字节码信息。精确定位到方法位置的步骤:
- 编译期间,invoke指令会携带一个参数符号引用,引用到常量池中的方法定义。方法定义中包含了类名+方法名+返回值+参数
- 在方法第一次调用时,这些符号引用就会被替换成内存地址的直接引用,这种方式称之为静态绑定
静态绑定适用于处理静态方法、私有方法、或者使用final修饰的方法,因为这些方法不能被继承之后重写:invokestatic、invokespecial、final修饰的invokevirtual

对于非static、非private、非final的方法,有可能存在子类重写方法,那么就需要通过动态绑定来完成方法地址绑定的工作。比如在这段代码中,调用的其实是Cat类对象的eat方法,但是编译完之后虚拟机指令中调用的是Animal类的eat方法,这就需要在运行过程中通过动态绑定找到cat类的eat方法,这样就实现了多态

动态绑定是基于方法表来完成的,invokevirtual使用了虚方法表(vtable),invokeinterface使用了接口方法表(itable),整体思路类似,使用invokevirtual和虚方法表来解释整个过程
每个类中都有一个虚方法表,本质上它是一个数组,记录了方法的地址。子类方法表中包含父类方法表中的所有方法,子类如果重写了父类方法,则使用自己类中方法的地址进行替换

产生invokevirtual调用时,先根据对象头中的类型指针找到方法区中InstanceClass对象,获得虚方法表。再根据虚方法表找到对应的对方,获得方法的地址,最后调用方法

异常捕获原理
在Java中,程序遇到异常时会向外抛出,此时可以使用try-catch捕获异常的方式将异常捕获并继续让程序按程序员设计好的方式运行。在try代码块中如果抛出了Exception对象或者子类对象,则会进入catch分支。
异常捕获机制的实现,需要借助于编译时生成的异常表,异常表在编译期生成,存放的是代码中异常的处理信息,包含了异常捕获的生效范围以及异常发生后跳转到的字节码指令位置
起始/结束PC:此条异常捕获生效的字节码起始/结束位置
跳转PC:异常捕获之后,跳转到的字节码位置
在位置2到4字节码指令执行范围内,如果出现了Exception对象的异常或者子类对象异常,直接跳转到位置7的指令,也就是i=2代码位置

程序运行中触发异常时,Java虚拟机会从上至下遍历异常表中的所有条目。当触发异常的字节码的索引值在某个异常表条目的监控范围内,Java虚拟机会判断所抛出的异常和该条目想要捕获的异常是否匹配
- 如果匹配,跳转到“跳转PC”对应的字节码位置
- 如果遍历完都不能匹配,说明异常无法在当前方法执行时被捕获,此方法栈帧直接弹出,在上一层的栈帧中进行异常捕获的查询
多个catch分支情况下,异常表会从上往下遍历,先捕获RuntimeException,如果捕获不了,再捕获Exception。同理,multi-catch的写法也是一样的处理过程,多个catch分支情况下,异常表会从上往下遍历,先捕获RuntimeException,如果捕获不了,再捕获IOException
finally的处理方式就相对比较复杂一点:
- finally中的字节码指令会插入到try和catch代码块中,保证在try和catch执行之后一定会执行finally中的代码
- 如果抛出的异常范围超过了Exception,比如Error或者Throwable,此时也要执行finally,所以异常表中增加了两个条目。覆盖了try和catch两段字节码指令的范围,any代表可以捕获所有种类的异常。在最后需要将异常继续向外抛出

JIT即时编译器
在Java中,JIT即时编译器是一项用来提升应用程序代码执行效率的技术。字节码指令被Java虚拟机解释执行,如果有一些指令执行频率高,称之为热点代码,这些字节码指令则被JIT即时编译器编译成机器码同时进行一些优化,最后保存在内存中,将来执行时直接读取就可以运行在计算机硬件上

在HotSpot中,有三款即时编译器,C1、C2和Graal。C1编译效率比C2快,但是优化效果不如C2。所以C1适合优化一些执行时间较短的代码,C2适合优化服务端程序中长期执行的代码

JDK7之后,采用了分层编译的方式,在JVM中C1和C2会一同发挥作用,分层编译将整个优化级别分成了5个等级

C1即时编译器和C2即时编译器都有独立的线程去进行处理,内部会保存一个队列,队列中存放需要编译的任务。一般即时编译器是针对方法级别来进行优化的,当然也有对循环进行优化的设计

(1)先由C1执行过程中收集所有运行中的信息,方法执行次数、循环执行次数、分支执行次数等,然后等待执行次数触发阈值(分层即时编译由JVM动态计算)之后,进入C2即时编译器进行深层次的优化

(2)方法字节码执行数目过少,先收集信息,JVM判断C1和C2优化性能差不多,那之后转为不收集信息,由C1直接进行优化

(3)C1线程都在忙碌的情况下,直接由C2进行优化

(4)C2线程忙碌时,先由2层C1编译收集一些基础信息,多运行一会儿,然后再交由3层C1处理,由于3层C1处理效率不高,所以尽量减少这一层停留时间(C2忙碌着,一直收集也没有意义),最后C2线程不忙碌了再交由C2进行处理

常见的IT即时编译器优化手段:
- 方法内联:方法体中的字节码指令直接复制到调用方的字节码指令中,节省了创建栈帧的开销

并不是所有的方法都可以内联,内联有一定的限制:
(1)方法编译之后的字节码指令总大小**<35字节**,可以直接内联(通过 -XX:MaxInlineSize=值控制)
(2)方法编译之后的字节码指令总大小**<325字节**,并且是一个热方法(通过 -XX:FreqInlineSize=值控制)
(3)方法编译生成的机器码不能大于1000字节(通过 -XX:InlineSmallCode=值控制)
(4)一个接口的实现必须小于3个,如果大于三个就不会发生内联 - 逃逸分析:逃逸分析指的是如果JIT发现在方法内创建的对象不会被外部引用,那么就可以采用锁消除、标量替换等方式进行优化
左处代码可以使用逃逸分析进行优化,因为test对象不会被外部引用,只会在方法中使用;而右处代码就会有一定的问题,如果在方法中对象被其他静态变量引用,那优化就无法进行

(1)锁消除:如果对象被判断不会逃逸出去,那么在对象就不存在并发访问问题,对象上的锁处理都不会执行,从而提高性能。锁消除优化在真正的工作代码中并不常见,一般加锁的对象都是支持多线程去访问的
(2)标量替换:逃逸分析真正对性能优化比较大的方式是标量替换,在Java虚拟机中,对象中的基本数据类型称为标量,引用的其他对象称为聚合量。标量替换指的是如果方法中的对象不会逃逸,那么其中的标量就可以直接在栈上分配

根据JIT即时编器优化代码的特性,在编写代码时注意以下几个事项,可以让代码执行时拥有更好的性能:
(1)尽量编写比较小的方法,让方法内联可以生效
(2)高频使用的代码,特别是第三方依赖库甚至是JDK中的,如果内容过度复杂是无法内联的,可以自行实现一个特定的优化版本
(3)注意下接口的实现数量,尽量不要超过2个,否则会影响内联的处理
(4)高频调用的方法中创建对象临时使用,尽量不要让对象逃逸
G1垃圾回收器原理
(1)新创建的对象会存放在Eden区。当G1判断年轻代区不足(max默认60%),无法分配对象时需要回收时会执行Young GC
(2)标记出Eden和Survivor区域中的存活对象
(3)根据配置的最大暂停时间选择某些区域将存活对象复制到一个新的Survivor区中(年龄+1),清空这些区域
(4)后续Young GC时与之前相同,只不过Survivor区中存活对象会被搬运到另一个Survivor区
(5)当某个存活对象的年龄到达阈值(默认15),将被放入老年代
(6)部分对象如果大小超过Region的一半,会直接放入老年代,这类老年代被称为Humongous区。比如堆内存是4G,每个Region是2M,只要一个大对象超过了1M就被放入Humongous区,如果对象过大会横跨多个Region
(7)多次回收之后,会出现很多Old老年代区,此时总堆占有率达到阈值时(默认45%)会触发混合回收Mixed GC,回收所有年轻代和部分老年代的对象以及大对象区,采用复制算法
- 年轻代回收:
年轻代回收只扫描年轻代对象(Eden+Survivor),所以从GC Root到年轻代的对象或者年轻代对象引用了其他年轻代的对象都很容易扫描出来,但如果有老年代中的对象引用了年轻代中的对象,则不容易找到

方案:维护一个详细的表,记录哪个对象被哪个老年代引用。在年轻代中被引用的对象,不进行回收,如果对象太多这张表会占用很大的内存空间,且存在错标的情况
优化一:只记录Region被哪些对象引用。这种引用详情表称为记忆集 RememberedSet(简称RS或RSet):是一种记录了从非收集区域对象引用收集区域对象的这些关系的数据结构。扫描时将记忆集中的对象也加入到GC Root中,就可以根据引用链判断哪些对象需要回收,但如果区域中引用对象很多,还是占用很多内存

优化二:将所有区域中的内存按一定大小划分成很多个块,每个块进行编号,记忆集中只记录对块的引用关系。如果一个块中有多个对象,只需要引用一次,减少了内存开销

每一个Region都拥有一个自己的卡表,如果产生了跨代引用(老年代引用年轻代),此时这个Region对应的卡表上就会将字节内容进行修改,JDK8源码中0代表被引用了称为脏卡,这样就可以标记出当前Region被老年代中的哪些部分引用了。那么要生成记忆集就比较简单了,只需要遍历整个卡表,找到所有脏卡

年轻代回收标记时,会将记忆集中的对象也加入到GC Root对象中,进行扫描并标记其引用链上的对象

优化三:JVM使用写屏障(write Barrier)技术,在执行引用关系建立的代码时,可以在代码前和代码后插入一段指令,从而维护卡表。
记忆集中不会记录新生代到新生代的引用,同一个Region中的引用也不会记录

记忆集生成流程:
(1)通过写屏障获得引用变更的信息
(2)将引用关系记录到卡表中,并记录到一个脏卡队列中
(3)JVM中会由Refinement线程定期从脏卡队列中获取数据,生成记忆集。不直接写入记忆集的原因是避免过多线程并发访问记忆集

年轻代回收流程,STW:
(1)Root扫描,将所有的静态变量、局部变量扫描出来
(2)处理脏卡队列中的没有处理完的信息,更新记忆集的数据,此阶段完成后,记忆集中包含了所有老年代对当前Region的引用关系

(3)标记存活对象。记忆集中的对象会加入到GC Root对象集合中,在GC Root引用链上的对象也会被标记为存活对象
(4)根据设定的最大停顿时间,选择本次收集的区域,称之为回收集合Collection Set

(5)复制对象:将标记出来的对象复制到新的区中,将年龄加1,如果年龄到达15则晋升到老年代。老的区域内存直接清空
(6)处理软、弱、虚、终结器引用,以及JNI中的弱引用

G1年轻代回收核心技术
(1)卡表 Card Table:
每一个Region都拥有一个自己的卡表,卡表是一个字节数组,如果产生了跨代引用(老年代引用年轻代),G1会将卡表上引用对象所在的位置字节内容进行修改为0,称为脏卡。卡表的主要作用是生成记忆集
卡表会占用一定的内存空间,堆大小是1G时,卡表大小为1G=1024MB/512=2MB
(2)记忆集 Remembered Set(简称RS或RSet):
每一个Region都拥有一个自己的记忆集,如果产生了跨代引用,记忆集中会记录引用对象所在的卡表位置。标记阶段将记忆集中的对象加入GC Root集合中一起扫描,就可以将被引用的对象标记为存活
(3)写屏障 Write Barrier:
G1使用写屏障技术,在执行引用关系建立的代码执行后插入一段指令,完成卡表的维护工作
会损失一部分的性能,大约在5%~10%之间 - 混合回收:
多次回收之后,会出现很多OId老年代区,此时总堆占有率达到阈值(默认45%)时会触发混合回收MixedGC
混合回收会由年轻代回收之后或者大对象分配之后触发,混合回收会回收整个年轻代+部分老年代
老年代很多时候会有大量对象,要标记出所有存活对象耗时较长,所以整个标记过程要尽量能做到和用户线程并行执行
(1)初始标记,STW,采用三色标记法标记从GC Root可直达的对象,STW时间极短
初始标记会暂停所有用户线程,只标记从GC Root可直达的对象,所以停顿时间不会太长,采用三色标记法进行标记
三色标记法在原有双色标记(黑也就是1代表存活,白0代表可回收)增加了一种灰色,采用队列的方式保存标记为灰色的对象
黑色:存活,当前对象在GC Root引用链上,同时他引用的其他对象也都已经标记完成
灰色:待处理,当前对象在GC Root引用链上,他引用的其他对象还未标记完成
白色:可回收,不在GC Root引l用链上

三色标记中的黑色和白色是使用位图(bitmap)来实现的,比如8个字节使用1个bit来标识标记的内容,黑色为1,白色为0,灰色不会体现在位图中,会单独放入一个队列中。如果对象超过8个字节,仅仅使用第一个bit位处理

(2)并发标记,并发执行,对存活对象进行标记
接下来进入并发标记阶段,继续进行未完成的标记任务,此阶段和用户线程并发执行
从灰色队列中获取尚未完成标记的对象B,标记B关联的A和C对象,由于A对象并未引用其他对象,可以直接标记成黑色,而B也完成了所有引用对象的标记,也标记为黑色。C对象有引用对象E,所以先标记成灰色

最后从队列获取C对象,标记为黑色,E也标记为黑色。所以剩余对象F就是白色,可回收

三色标记存在一个比较严重的问题,由于用户线程可能同时在修改对象的引用关系,就会出现错标的情况
PS:这个案例中正常情况下,B和C都会被标记成黑色。但是在BC标记前,用户线程执行B.c=null,将B到c的引用去除了;同时执行了A.c=c,添加了A到C的引用;此时会出现错标的情况,C是白色可回收

G1为了解决这个问题,使用了SATB技术(Snapshot At The Beginning,初始快照)。SATB技术是这样处理的:
a.标记开始时创建一个快照,记录当前所有对象,标记过程中新生成的对象直接标记为黑色
b.采用前置写屏障技术,在引用赋值前比如B.c=null之前,将之前引用的对象c放入SATB待处理队列中。SATB队列每个线程都有一个,最终会汇总到一个大的SATB队列中

(3)最终标记,STW,处理SATB相关的对象标记,STW时间极短
最终标记会暂停所有用户线程,主要是为了处理SATB相关的对象标记。这一步中,将所有线程的SATB队列中剩余的数据合并到总的SATB队列中,然后逐一处理
SATB队列中的对象,默认按照存活处理,同时要处理他们引用的对象
SATB的缺点是在本轮清理时可能会将不存活的对象标记成存活对象,产生了一些所谓的浮动垃圾,等到下一轮清理时才能回收

(4)清理,STW,如果区域中没有任何存活对象就直接清理,STW时间极短
(5)转移,将存活对象复制到别的区域,STW时间较长
a.根据最终标记的结果,可以计算出每一个区域的垃圾对象占用内存大小,根据停顿时间,选择转移效率最高(垃圾对象最多)的几个区域
b.转移时先转移GC Root直接引用的对象,然后再转移其他对象
c.回收老的区域,如果外部有其他区域对象引用了转移对象,也需要重新设置引用关系
ZGC垃圾回收器原理
ZGC是一种可扩展的低延迟垃圾回收器。ZGC在垃圾回收过程中,STW的时间不会超过一毫秒,适合需要低延迟的应用,支持几百兆到16TB的堆大小,堆大小对STW的时间基本没有影响。
ZGC降低了停顿时间,能降低接口的最大耗时,提升用户体验。但是吞吐量不佳,所以如果Java服务比较关注QPS(每秒的查询次数)那么G1是比较不错的选择
G1转移时需要停顿的主要原因
转移完之后,需要将A对对象的引l用更改为新对象的引用,但是在更改前,执行A.c.COunt=2,此时更改的是转移前对象中的属性
更改引用之后,A引用了转移之后的对象,此时获取A.c.count发现属性值依然是1。G1为了解决该问题,在转移过程中需要进行用户线程的停止。ZGC和Shenandoah解决了这个问题,让转移过程也能够并发执行

- 在ZGC中,使用了读屏障Load Barrier技术,来实现转移后对象的获取
当获取一个对象引用时,会触发读后的屏障指令,如果对象指向的不是转移后的对象,用户线程会将引用指向转移后的对象

- 着色指针
访问对象引用时,使用的是对象的地址。在64位虚拟机中,是8个字节可以表示接近无限的内存空间。所以一般内存中对象,高几位都是0没有使用。着色指针就是利用了这多余的几位,存储了状态信息
着色指针将原来的8字节保存地址的指针拆分成了三部分:
(1)最低的44位,用于表示对象的地址,所以最多能表示16TB的内存空间
(2)中间4位是颜色位,每一位只能存放0或者1,并且同一时间只有其中一位是1
终结位:只能通过终结器访问
重映射位(Remap):转移完之后,对象的引用关系已经完成变更
Marked0和Marked1:标记可达对象
(3)高16位未使用

正常应用程序使用8个字节去进行对象的访问,现在只使用了44位,不会产生问题吗?
应用程序使用的对象地址,只是虚拟内存,操作系统会将虚拟内存转换成物理内存。而ZGC通过操作系统更改了这层逻辑。所以不管颜色位变成多少,指针指向的都是同一个对象

- ZGC的内存划分
在ZGC中,与G1垃圾回收器一样将堆内存划分成很多个区域,这些内存区域被称之为Zpage
Zpage分成三类大中小,管控粒度比G1更细,这样更容易去控制停顿时间
小区域:2M,只能保存256KB内的对象
中区域:32M,保存256KB - 4M的对象
大区域:只保存一个大于4M的对象
(1)初始标记阶段
标记GC Roots引用的对象为存活对象数量不多,所以停顿时间非常短

(2)并发标记阶段
遍历所有对象,标记可以到达的每一个对象是否存活,用户线程使用读屏障,如果发现对象没有完成标记也会帮忙进行标记
(3)并发处理阶段
选择需要转移的Zpage,并创建转移表,用于记录转移前对象和转移后对象地址

(4)转移开始阶段
转移GC Root直接关联的对象,不转移的对象remapped值设置成1,避免重复进行判断。转移之后将两个对象的地址记入转移映射表

(5)并发转移阶段
转移完之后,转移前的Zpage就可以清空了,转移表需要保留下来

此时,如果用户线程访问4对象引用的5对象,会通过读屏障,将4对5的引用进行重置,修改为对5的引用,同时将remap标记为1代表已经重新映射完成

并发转移阶段结束之后,这一轮的垃圾回收就结束了,但其实并没有完成所有指针的重映射工作,这个工作会放到下一阶段,与下一阶段的标记阶段一起完成(因为都需要遍历整个对象图)

(6)第二次垃圾回收的初始标记阶段
第二次垃圾回收的初始标记阶段,沿着GC Root标记对象

(7)第二次垃圾回收的并发标记阶段
如果Marked为1代表上一轮的重映射还没有完成,先完成重映射从转移表中找到老对象转移后的新对象,再进行标记。如果Remap为1,只需要进行标记

(8)第二次垃圾回收的并发处理阶段
将转移映射表删除,释放内存空间


并发转移阶段-并发问题
如果用户线程在帮忙转移时,GC线程也发现这个对象需要复制,那么就会去尝试写入转移映射表,如果发现映射表中已经有相同的老对象,直接放弃

- 分代ZGC
在JDK21之后,ZGC设计了年轻代和老年代,这样可以让大部分对象在年轻代回收,减少老年代的扫描次数,同样可以提升一定的性能。同时,年轻代和老年代的垃圾回收可以并行执行
分代之后的着色指针将原来的8字节保存地址的指针拆分成了三部分:
(1)46位用来表示对象地址,最多可以表示64TB的地址空间
(2)中间的12位为颜色位
(3)最低4位和最高2位未使用
整个分代之后的读写屏障、着色指针的移位使用都变的异常复杂,仅作了解即可

ZGC核心技术
(1)着色指针(Colored Pointers)
着色指针将原来的8字节保存地址的指针拆分成了三部分,不仅能保存对象的地址,还可以保存当前对象所属的状态
不支持32位系统、不支持指针压缩
(2)读屏障 (Load Barrier)
在获取对象引用判断对象所属状态,如果所属状态和当前GC阶段的颜色状态不一致,由用户线程完成本阶段的工作
会损失一部分的性能,大约在5%~10%之间
Shenandoah GC
Shenandoah是由Red Hat开发的一款低延迟的垃圾收集器,Shenandoah并发执行大部分GC工作,包括并发的整理,堆大小对STW的时间基本没有影响,其区域定义与G1是一样的

没有着色指针,通过修改对象头的设计来完成并发转移过程的实现
Shenandoah GC有两个版本,1.0版本存在于JDK8和JDK11中,后续的JDK版本中均使用2.0版本
1.0版本,在对象的前8个字节,增加了一个前向指针,前向指针指向转移之后的对象,如果没有就指向自己

如果转移阶段未完成,此时转移前的对象和转移后的对象都会存活。如果用户去访问数据,需要使用转移后的数据。Shenandoah GC使用了读前屏障,根据对象的前向指针来获取到转移后的对象并读取

写入数据时,也会使用写前屏障,判断Mark Word中的GC状态,如果GC状态为0证明没有处于GC过程中,直接写入,如果不为0则根据GC状态值确认当前处于垃圾回收的哪个阶段,让用户线程执行垃圾回收相关的任务

1.0版本的缺点:
- 对象内存大大增加,每个对象都需要增加8个字节的前向指针,基本上会占用5%-10%的空间
- 读屏障中加入了复杂的指令,影响使用效率
2.0版本优化了前向指针的位置,仅转移阶段将其放入了Mark Word中


并发转移阶段-并发问题
如果用户线程在帮忙转移时,Shenandoah GC线程也发现这个对象需要复制,那么就会去尝试写入前向指针,使用了类似CAS的方式来实现,只有一个线程能成功修改,其他线程会放弃转移的操作

TheadLocal
ThreadLocal可以在线程中存放线程的本地变量,保证数据的线程安全

ThreadLocal中保存对象的方法:
- 在每个线程中,存放了一个ThreadLocalMap对象,本质上就是一个数组实现的哈希表,里边存放多个Entry对象
- 每个Entry对象继承自弱引用,内部存放ThreadLocal对象,同时用强引用,引用保存ThreadLocal对应的value值(对象)

threadLocal.set(new User(1,‘‘main线程对象’’))

User user = threadLocal.get()

不再使用Threadlocal对象时,threadlocal = null;由于是弱引用,那么在垃圾回收之后,ThreadLocal对象就可以被回收

此时还有Entry对象和value对象没有能被回收,所以在ThreadLocal类的set、get、remove方法中,在某些特定条件满足的情况下,会主动删除这两个对象
如果一直不调用set、get、remove方法或者调用了没有满足条件,这部分对象就会出现内存泄漏。强烈建议在ThreadLocal不再使用时,调用remove方法回收将Entry对象的引用关系去掉,这样就可以回收这两个对象

ThreadLocal中为什么要使用弱引用?
当threadlocal对象不再使用时,使用弱引用可以让对象被回收;因为仅有弱引用没有强引用的情况下,对象是可以被回收的
弱引用并没有完全解决掉对象回收的问题,Entry对象和value值无法被回收,所以合理的做法是手动调用remove方法进行回收,然后再将threadlocal对象的强引用解除
实战篇
内存泄漏
内存泄漏(memory leak):在Java中如果不再使用一个对象,但是该对象依然在GC ROOT的引用链上,这个对象就不会被垃圾回收器回收,这种情况就称之为内存泄漏
内存泄漏绝大多数情况都是由堆内存泄漏引起的,一般没有特别说明则讨论的都是堆内存泄漏
少量的内存泄漏可以容忍,但是如果发生持续的内存泄漏,就像滚雪球雪球越滚越大,不管有多大的内存迟早会被消耗完,最终导致的结果就是内存溢出,但是产生内存溢出并不是只有内存泄漏这一种原因

内存泄漏的常见场景:
- 内存泄漏导致溢出的常见场景是大型的Java后端应用中,在处理用户的请求之后,没有及时将用户的数据删除。随着用户请求数量越来越多,内存泄漏的对象占满了堆内存最终导致内存溢出
这种产生的内存溢出会直接导致用户请求无法处理,影响用户的正常使用。重启可以恢复应用使用,但是在运行一段时间之后依然会出现内存溢出 - 第二种常见场景是分布式任务调度系统如Elastic-job、Quartz等进行任务调度时,被调度的Java应用在调度任务结束中出现了内存泄漏,最终导致多次调度之后内存溢出
这种产生的内存溢出会导致应用执行下次的调度任务执行。同样重启可以恢复应用使用,但是在调度执行一段时间之后依然会出现内存溢出
解决内存溢出的思路:
- 发现问题:
(1)Top命令
top命令是linux下用来查看系统信息的一个命令,它提供给我们去实时地去查看系统的资源,比如执行时的进程、线程和系统参数等信息
进程使用的内存为RES(常驻内存) - SHR(共享内存)
优点:操作简单,无需额外的软件安装
缺点:只能查看最基础的进程信息,无法查看到每个部分的内存占用(堆、方法区、堆外)

(2)VisualVM
VisualVM是多功能合一的Java故障排除工具并且他是一款可视化工具,整合了命令行JDK工具和轻量级分析功能,功能非常强大。这款软件在0racle JDK 6~8中发布,但是在 Oracle JDK 9之后不在JDK安装目录下需要单独下载
优点:功能丰富,实时监控CPU、内存、线程等详细信息;支持Idea插件,开发过程中也可以使用
缺点:对大量集群化部署的Java进程需要手动进行管理

(3)Arthas
Arthas是一款线上监控诊断产品,通过全局视角实时查看应用load、内存、gc、线程的状态信息,并能在不修改应用代码的情况下,对业务问题进行诊断,包括查看方法调用的出入参、异常,监测方法执行耗时,类加载信息等,大大提升线上问题排查效率
优点:功能强大,不止于监控基础的信息,还能监控单个方法的执行耗时等细节内容;支持应用的集群管理
缺点:部分高级功能使用门槛较高

(4)Prometheus + Grafana
Prometheus+Grafana是企业中运维常用的监控方案,其中Prometheus用来采集系统或者应用的相关数据,同时具备告警功能。Grafana可以将Prometheus采集到的数据以可视化的方式进行展示
优点:支持系统级别和应用级别的监控, 比如linux操作系统、Redis、MySQL、Java进程;支持告警并允许自定义告警指标,通过邮件、短信等方式尽早通知相关人员进行处理
缺点:环境搭建较为复杂,一般由运维人员完成
(5)堆内存状况对比

(6)代码中的内存泄漏
A.equals()和hashCode()导致的内存泄漏
正常情况:
a.以JDK8为例,首先调用hash方法计算key的哈希值,hash方法中会使用到key的hashCode方法,根据hash方法的结果决定存放的数组中位置
b.如果没有元素,直接放入。如果有元素,先判断key是否相等,会用到equals方法,如果key相等,直接替换value;key不相等,走链表或者红黑树查找逻辑,其中也会使用equals比对是否相同

异常情况:
a.hashCode方法实现不正确,会导致相同id的学生对象计算出来的hash值不同,可能会被分到不同的槽中
b.equals方法实现不正确,会导致key在比对时,即便学生对象的id是相同的,也被认为是不同的key
c.长时间运行之后HashMap中会保存大量相同id的学生数据

解决方案:
a.在定义新实体时,始终重写equals()和hashCode()方法
b.重写时一定要确定使用了唯一标识去区分不同的对象,比如用户的id等
c.hashmap使用时尽量使用编号id等数据作为key,不要将整个实体类对象作为key存放
B.内部类引用外部类
问题:
a.非静态的内部类默认会持有外部类,尽管代码上不再使用外部类,所以如果有地方引用了这个非静态内部类,会导致外部类也被引用,垃圾回收时无法回收这个外部类
b.匿名内部类对象如果在非静态方法中被创建,会持有调用者对象,垃圾回收时无法回收调用者
解决方案:
a.这个案例中,使用内部类的原因是可以直接获取到外部类中的成员变量值,简化开发。如果不想持有外部类对象,应该使用静态内部类
b.使用静态方法,可以避免匿名内部类持有调用者对象
C.ThreadLocal的使用
问题:
如果仅仅使用手动创建的线程,就算没有调用ThreadLocal的remove方法清理数据,也不会产生内存泄漏。因为当线程被回收时,ThreadLocal也同样被回收。但是如果使用线程池就不一定了
解决方案:
线程方法执行完,一定要调用ThreadLocal中的remove方法清理对象
D.String的intern方法
问题:
JDK6中字符串常量池位于堆内存中的PermGen永久代中,如果不同字符串的intern方法被大量调用,字符串常量池会不停的变大超过永久代内存上限之后就会产生内存溢出问题
解决方案:
a.注意代码中的逻辑,尽量不要将随机生成的字符串加入字符串常量池
b.增大永久代空间的大小,根据实际的测试/估算结果进行设置-XX:MaxPermSize=256M
E.通过静态字段保存对象
问题:
如果大量的数据在静态变量中被长期引用,数据就不会被释放,如果这些数据不再使用,就成为了内存泄漏
解决方案:
a.尽量减少将对象长时间的保存在静态变量中,如果不再使用,必须将对象删除(比如在集合中)或者将静态变量设置为null
b.使用单例模式时,尽量使用懒加载,而不是立即加载
c.Spring的Bean中不要长期存放大对象,如果是缓存用于提升性能,尽量设置过期时间定期失效
F.资源没有正常关闭
问题:
连接和流这些资源会占用内存,如果使用完之后没有关闭,这部分内存不一定会出现内存泄漏,但是会导致close方法不被执行
解决方案:
a.为了防止出现这类的资源对象泄漏问题,必须在finally块中关闭不再使用的资源
b.从Java7开始,使用try-with-resources语法可以用于自动关闭资源
(7)并发请求问题
并发请求问题指的是用户通过发送请求向Java应用获取数据,正常情况下Java应用将数据返回之后,这部分数据就可以在内存中被释放掉。但是由于用户的并发请求量有可能很大,同时处理数据的时间很长,导致大量的数据存在于内存中,最终超过了内存的上限,导致内存溢出。这类问题的处理思路和内存泄漏类似,首先要定位到对象产生的根源 - 诊断原因
(1)内存快照
当堆内存溢出时,需要在堆内存溢出时将整个堆内存保存下来,生成内存快照(Heap Profile)文件
生成内存快照的Java虚拟机参数:
XX:+HeapDumpOnOutOfMemoryError:发生OutOfMemoryError错误时,自动生成hprof内存快照文件
XX:HeapDumpPath=:指定hprof文件的输出路径
使用MAT打开hprof文件,并选择内存泄漏检测功能,MAT会自行根据内存快照中保存的数据分析内存泄漏的根源

(2)MAT内存泄漏检测的原理
MAT提供了称为支配树(Dominator Tree)的对象图,支配树展示的是对象实例间的支配关系。在对象引用图中,所有指向对象B的路径都经过对象A,则认为对象A支配对象B

支配树中对象本身占用的空间称之为浅堆(Shallow Heap)
支配树中对象的子树就是所有被该对象支配的内容,这些内容组成了对象的深堆(Retained Heap),也称之为保留集(Retained Set)。深堆的大小表示该对象如果可以被回收,能释放多大的内存空间

MAT就是根据支配树,从叶子节点向根节点遍历,如果发现深堆的大小超过整个堆内存的一定比例阈值,就会将其标记成内存泄漏的“嫌疑对象”

导出运行中系统的内存快照,比较简单的方式有两种,注意只需要导出标记为存活的对象:
通过JDK自带的jmap命令导出,jmap-dump:live,format=b,file=文件路径和文件名 进程ID
通过arthas的heapdump命令导出,heapdump–live文件路径和文件名
(3)分析超大堆的内存快照
在程序员开发用的机器内存范围之内的快照文件,直接使用MAT打开分析即可。但是经常会遇到服务器上的程序占用的内存达到10G以上,开发机无法正常打开此类内存快照,此时需要下载服务器操作系统对应的MAT.通过MAT中的脚本生成分析报告:
./ParseHeapDump.sh 快照文件路径 org.eclipse.mat.api:suspects
org.eclipse.mat.api:overview org.eclipse.mat.api:top_components
默认MAT分析时只使用了1G的堆内存,如果快照文件超过1G,需要修改MAT目录下的MemoryAnalyzer.ini配置文件调整最大堆内存

- 修复问题
修复内存溢出问题的要具体问题具体分析,问题总共可以分成三类
(1)代码中的内存泄漏:代码中的内存泄漏在前面的篇章中已经介绍并提供了解决方案
(2)并发引起内存溢出-参数不当:由于参数设置不当,比如堆内存设置过小,导致并发量增加之后超过堆内存的上限
解决方案:调整参数
(3)并发引起内存溢出-设计不当:系统的方案设计不当,比如从数据库获取超大数据量的数据;线程池设计不当;生产者~消费者模型,消费者消费性能问题
解决方案:优化设计方案 - 测试验证
GC 调优
GC调优指的是对垃圾回收(Garbage Collection)进行调优,GC调优的主要目标是避免由垃圾回收引起程序性能下降
GC调优的核心分成三部分:通用Jvm参数的设置;特定垃圾回收器的Jvm参数的设置;解决由频繁的FULLGC引I起的程序性能问题
GC调优没有没有唯一的标准答案,如何调优与硬件、程序本身、使用情况均有关系,重点学习调优的工具和方法
核心指标:
- 吞吐量
业务吞吐量指的在一段时间内,程序需要完成的业务数量。保证高吞吐量的常规手段有两条:优化业务执行性能,减少单次业务的执行时间;优化垃圾回收吞吐量
垃圾回收吞吐量指的是CPU用于执行用户代码的时间与CPU总执行时间的比值,即吞吐量=执行用户代码时间/(执行用户代码时间+GC时间)。吞吐量数值越高,垃圾回收的效率就越高,允许更多的CPU时间去处理用户的业务,相应的业务吞吐量也就越高

- 延迟
延迟指的是从用户发起一个请求到收到响应这其中经历的时间
延迟=GC延迟+业务执行时间,所以如果GC时间过长,会影响到用户的使用

- 内存使用量
内存使用量指的是Java应用占用系统内存的最大值,一般通过JVM参数调整,在满足上述两个指标的前提下,这个值越小越好

GC调优的步骤:
- 发现问题:
(1)jstat工具
Jstat工具是JDK自带的一款监控工具,可以提供各种垃圾回收、类加载、编译信息等不同的数据
使用方法为:jstat -gc 进程ID 每次统计的间隔(毫秒)统计次数
优点:操作简单,无额外的软件安装
缺点:无法精确到GC产生的时间,只能用于判断GC是否存在问题
C - Capacity容量,U - used使用量,S - 幸存者区,E - 伊甸园区,O - 老年代,M - 元空间
YGC、YGT:年轻代GC次数和GC耗时(单位:秒)
FGC、FGCT:Full GC次数和Full GC耗时
GCT:GC总耗时

(2)VisualVM
VisualVm中提供了一款Visual Tool插件,实时监控Java进程的堆内存结构、堆内存变化趋势以及垃圾回收时间的变化趋势,同时还可以监控对象晋升的直方图
优点:适合开发使用,能直观的看到堆内存和GC的变化趋势
缺点:对程序运行性能有一定影响;生产环境程序员一般没有权限进行操作

(3)Prometheus + Grafana
Prometheus+Grafana是企业中运维常用的监控方案,其中Prometheus用来采集系统或者应用的相关数据,同时具备告警功能。Grafana可以将Prometheus采集到的数据以可视化的方式进行展示
优点:支持系统级别和应用级别的监控, 比如linux操作系统、Redis、MySQL、Java进程;支持告警并允许自定义告警指标,通过邮件、短信等方式尽早通知相关人员进行处理
缺点:环境搭建较为复杂,一般由运维人员完成 - 诊断问题:
通过GC日志,可以更好的看到垃圾回收细节上的数据,同时也可以根据每款垃圾回收器的不同特点更好地发现存在的问题
使用方法(JDK 8及以下):-XX:+PrintGCDetails -Xloggc:文件名
使用方法(JDK 9+):-Xlog:gc*:file=文件名
(1)正常GC模式
呈现锯齿状,对象创建之后内存上升,一旦发生垃圾回收之后下降到底部,并且每次下降之后的内存大小接近,存留的对象较少

(2)缓存对象过多
呈现锯齿状,对象创建之后内存上升,一旦发生垃圾回收之后下降到底部,并且每次下降之后的内存大小接近,处于比较高的位置
问题产生原因:程序中保存了大量的缓存对象,导致GC之后无法释放,可以使用MAT或者HeapHero等工具进行分析内存占用的原因

(3)内存泄漏
呈现锯齿状,每次垃圾回收之后下降到的内存位置越来越高,最后由于垃圾回收无法释放空间导致对象无法分配产生OutOfMemory的错误
问题产生原因:程序中保存了大量的内存泄漏对象,导致GC之后无法释放,可以使用MAT或者HeapHero等工具进行分析是哪些对象产生了内存泄漏

(4)持续Full GC
在某个时间点产生多次Full GC,CPU使用率同时飙高,用户请求基本无法处理,一段时间之后恢复正常
问题产生原因:在该时间范围请求量激增,程序开始生成更多对象,同时垃圾收集无法跟上对象创建速率,导致持续地在进行FULLGC

(5)元空间不足导致Full GC
堆内存的大小并不是特别大,但是持续发生Full GC
问题产生原因:元空间大小不足,导致持续Full GC回收元空间的数据

- 修复问题:
(1)优化基础JVM参数:基础JVM参数的设置不当,会导致频繁Full GC的产生
a.参数1:-Xmx,-Xms
-Xmx参数设置的是最大堆内存,但是由于程序是运行在服务器或者容器上,计算可用内存时,要将元空间、操作系统、其它软件占用的内存排除掉
案例:服务器内存4G,操作系统+元空间最大值+其它软件占用1.5G,-Xmx可以设置为2g
最合理的设置方式应该是根据最大并发量估算服务器的配置,然后再根据服务器配置计算最大堆内存的值

-Xms用来设置初始堆大小,建建议将-Xms设置的和-Xmx一样大,有以下几点好处:
运行时性能更好,堆的扩容是需要向操作系统申请内存的,这样会导致程序性能短期下降
可用性问题,如果在扩容时其他程序正在使用大量内存,很容易因为操作系统内存不足分配失败
启动速度更快,Oracle官方文档的原话:如果初始堆太小,Java应用程序启动会变得很慢,因为JVM被迫频繁执行垃圾收集,直到堆增长到更合理的大小。为了获得最佳启动性能,请将初始堆大小设置为与最大堆大小相同
b.参数2:-XX:MaxMetaspaceSize,-XX:MetaspaceSize
-XX:MaxMetaspaceSize=值参数指的是最大元空间大小,默认值比较大,如果出现元空间内存泄漏会让操作系统可用内存不可控,建议根据测试情况设置最大值,一般设置为256m。
-XX:MetaspaceSize=值参数指的是到达这个值之后会触发Full GC,后续什么时候再触发JVM会自行计算。如果设置为和MaxMetaspaceSize一样大,就不会Full GC,但是对象也无法回收

c.参数3:-Xss
如果我们不指定栈的大小,JVM将创建一个具有默认大小的栈。大小取决于操作系统和计算机的体系结构。比如Linuxx8664位:1MB,如果不需要用到这么大的栈内存,完全可以将此值调小节省内存空间,合理值为256k - 1m之间
使用:-Xss256k
d.参数4:不建议手动设置的参数
由于JVM底层设计极为复杂,一个参数的调整也许让某个接口得益,但同样有可能影响其他更多接口
-Xmn年轻代的大小,默认值为整个堆的1/3,可以根据峰值流量计算最大的年轻代大小,尽量让对象只存放在年轻代,不进入老年代。但是实际的场景中,接口的响应时间、创建对象的大小、程序内部还会有一些定时任务等不确定因素都会导致这个值的大小并不能仅凭计算得出,如果设置该值要进行大量的测试。G1垃圾回收器尽量不要设置该值,G1会动态调整年轻代的大小

-XX:SurvivorRatio伊甸园区和幸存者区的大小比例,默认值为8
-XX:MaxTenuringThreshold最大晋升阈值,年龄大于此值之后,会进入老年代。另外JVM有动态年龄判断机制:将年龄从小到大的对象占据的空间加起来,如果大于surivor区域的50%,然后把等于或大于该年龄的对象,放入到老年代

e.其余参数
-XX:+DisableExplicitGC,禁止在代码中使用System.gc(),System.gc()可能会引起Full GC,在代码中尽量不要使用。使用DisableExplicitGC参数可以禁止使用System. gc()方法调用
-XX:+HeapDumpOnOutofMemoryError,发生OutofMemoryError错误时,自动生成hprof内存快照文件
-XX:HeapDumpPath=path,指定hprof文件的输出路径
打印GC日志
JDK8及之前: -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:文件路径
JDK9及之后:-Xlog:gc*:file=文件路径
(2)减少对象产生:大多数场景下的Full GC是由于对象产生速度过快导致的,减少对象产生可以有效的缓解Full GC的发生
(3)更换垃圾回收器:选择适合当前业务场景的垃圾回收器,减少延迟、提高吞吐量
(4)优化垃圾回收器参数:优化垃圾回收器的参数,能在一定程度上提升GC效率
这部分优化效果未必出色,仅当前边的一些手动无效时才考虑
案例:CMS的并发模式失败(concurrent mode failure)现象。由于CMS的垃圾清理线程和用户线程是并行进行的,如果在并发清理的过程中老年代的空间不足以容纳放入老年代的对象,会产生并发模式失败
并发模式失败会导致Java虚拟机使用Serial Old单线程进行Full GC回收老年代,出现长时间的停顿


解决方案:
a.减少对象的产生以及对象的晋升
b.增加堆内存大小
c.优化垃圾回收器的参数,比如 -XX:CMSInitiatingOccupancyFraction=值,当老年代大小到达该阈值时,会自动进行CMS垃圾回收,通过控制这个参数提前进行老年代的垃圾回收,减少其大小
JDK8中默认这个参数值为-1,根据其他几个参数计算出阈值:
((100 - MinHeapFreeRatio) + (double)(CMSTriggerRatio * MinHeapFreeRatio)/ 100.0)
该参数设置完是不会生效的,必须开启-XX:+UseCMSInitiatingOccupancyOnly参数 - 测试验证:
性能调优
- 发现问题:
应用程序在运行过程中经常会出现性能问题,比较常见的性能问题现象是:
(1)通过top命令查看CPU占用率高,接近100甚至多核CPU下超过100都是有可能的
(2)请求单个服务处理时间特别长,多服务使用skywalking等监控系统来判断是哪一个环节性能低下
(3)程序启动之后运行正常,但是在运行一段时间之后无法处理任何的请求(内存和GC正常) - 诊断问题:
线程转储(Thread Dump)提供了对所有运行中的线程当前状态的快照。线程转储可以通过jstack、visualvm等工具获取。其中包含了线程名、优先级、线程ID、线程状态、线程栈信息等内容,可以用来解决CPU占用率高、死锁等问题
名称:线程名称,通过给线程设置合适的名称更容易“见名知意”
优先级(prio):线程的优先级
Java ID(tid):JVM中线程的唯一ID
本地ID(nid):操作系统分配给线程的唯一ID
状态:线程的状态,分为:
(1)NEW - 新创建的线程,尚未开始执行
(2)RUNNABLE - 正在运行或准备执行
(3)BLOCKED - 等待获取监视器锁以进入或重新进入同步块/方法
(4)WAITING - 等待其他线程执行特定操作,没有时间限制
(5)TIMED_WAITING - 等待其他线程在指定时间内执行特定操作
(6)TERMINATED - 已完成执行
栈追踪:显示整个方法的栈帧信息
在定位CPU占用率高的问题时,比较需要关注的是状态为RUNNABLE的线程。但实际上,有一些线程执行本地方法时并不会消耗CPU,而只是在等待。但JVM仍然会将它们标识成“RUNNABLE”状态

Arthas定位方法:
(1)trace命令:展示出整个方法的调用路径以及每一个方法的执行耗时
命令:trace 类名 方法名
添加 --skipJDKMethod false 参数可以输出JDK核心包中的方法及耗时
添加 ‘#cost>毫秒值’ 参数,只会显示耗时超过该毫秒值的调用
添加 -n 数值 参数,最多显示该数值条数的数据
所有监控都结束之后,输入stop结束监控,重置arthas增强的对象

(2)watch命令:在使用trace定位到性能较低的方法之后,使用watch命令监控该方法,可以获得更为详细的方法信息
命令:watch 类名 方法名 ‘{params,returnobj}’ ‘#cost>毫秒值’ -x 2
‘{params,returnobj}’ 代表打印参数和返回值
-x 代表打印的结果中如果有嵌套(比如对象里有属性),最多只展开2层,允许设置的最大值为4
(3)profile命令:使用arthas的profile命令,生成性能监控的火焰图
命令1:profiler start 开始监控方法执行性能
命令2:profiler stop --format htm 以HTML的方式生成火焰图
火焰图中一般找绿色部分Java中栈顶上比较平的部分,很可能就是性能的瓶颈

偏底层的性能问题,特别是由于JDK中某些方法被大量调用导致的性能低下,可以使用火焰图
非常直观的找到原因
这个案例中是由于创建ArrayList时没有手动指定容量,导致使用默认的容量而在添加对象过程
中发生了多次的扩容,扩容需要将原来数组中的元素复制到新的数组中,消耗了大量的时间。
通过火焰图可以看到大量的调用,修复完之后节省了20%~50%的时间

- 修复问题:
案例:线程被耗尽问题
问题:
程序在启动运行一段时间之后,就无法接受任何请求了。将程序重启之后继续运行,依然会出
现相同的情况。
解决思路:
线程耗尽问题,一般是由于执行时间过长,分析方法分成两步:
(1)检测是否有死锁(两个或以上的线程因为争夺资源而造成互相等待的现象)产生,无法自动解除的死锁会将线程永远阻塞
(2)如果没有死锁,再使用打印线程栈的方法检测线程正在执行哪个方法,一般这些大量出现的方法就是慢方法
线程死锁可以通过三种方法定位问题:
(1)jstack -l 进程ID > 文件名 将线程栈保存到本地,在文件中搜索deadlock即可找到死锁位置

(2)开发环境中使用visualvm或者Jconsole工具,都可以检测出死锁。使用线程快照生成工具就可以看到死锁的根源,但生产环境的服务一般不会允许使用这两种工具连接

(3)使用fastthread自动检测线程问题。Fasthread和Gceasy类似,是一款在线的Al自动线程问题检测一以提供线程分析报告,通过报告查看是否存在死锁问题 - 测试验证:
Java程序在运行过程中,JIT即时编译器会实时对代码进行性能优化,所以仅凭少量的测试是无法真实反应运行系统最终给用户提供的性能。如下图,随着执行次数的增加,程序性能会逐渐优化

OpenJDK中提供了一款叫JMH(Java Microbenchmark Harness)的工具,可以准确地对Java代码进行基准测试,量化方法的执行性能
JMH会首先执行预热过程,确保JIT对代码进行优化之后再进行真正的选代测试,最后输出测试的结果

JMH环境搭建:
(1)创建基准测试项目,在CMD窗口中,使用以下命令创建JMH环境项目

(2)修改POM文件中的JDK版本号和JMH版本号,JMH最新版本号参考Github

(3)编写测试方法,几个需要注意的点:死代码问题;黑洞的用法
(4)通过maven的verify命令,检测代码问题并打包成jar包。通过java -jar target/benchmarks.jar 命令执行基准测试
(5)测试结果生成可视化的结果
2604

被折叠的 条评论
为什么被折叠?



