JVM介绍-实战
一、JVM介绍
1.JVM是什么
Java Virtual Machine(Java虚拟机)是java程序实现跨平台的一个重要的工具(部件)。
HotSpot VM,相信所有Java程序员都知道,它是Sun JDK和OpenJDK中所带的虚拟机,也是目前使用范围最广的Java虚拟机。
只要装有JVM的平台,都可以运行java程序。那么Java程序在JVM上是怎么被运行的?
通过介绍以下JVM的三个组成部分,就可以了解到JVM内部的工作机制
- 类加载系统:负责完成类的加载
- 运行时数据区:在运行Java程序的时候会产生的各种数据会保存在运行时数据区
- 执行引擎:执行具体的指令(代码)
2.学习的目的
我们学JVM相关知识的目的是为了充分理解jvm内部的工作流程,来掌握如何通过相应的参数配置,实现JVM的调优。
二、类加载系统【重要】
1.类的加载过程
一个类被加载进JVM中要经历哪几个过程
- 加载: 通过io流的方式把字节码文件读入到jvm中(方法区)
- 校验:通过校验字节码文件的头8位的16进制是否是java魔数cafebabe
- 准备:为类中的静态部分开辟空间并赋初始化值
- 解析:将符号引用转换成直接引用。——静态链接
- 初始化:为类中的静态部分赋指定值并执行静态代码块。
类被加载后,类中的类型信息、方法信息、属性信息、运行时常量池、类加载器的引用等信息会被加载到元空间【方法区】中。
2.类加载器
类是谁来负载加载的?——类加载器。不同的类加载器,负责加载不同的类。
- Bootstrap ClassLoader 启动类加载器:负载加载jre/lib下的核心类库中的类,比如rt.jar、charsets.jar
- ExtClassLoader 扩展类加载器:负载加载jre/lib下的ext目录内的类
ext 加载路径:System.getProperty("java.ext.dirs");
- AppClassLoader 应用类加载器:负载加载用户自己写的类
app 加载路径:System.getProperty("java.class.path");
- 自定义类加载器:自己定义的类加载器,可以打破双亲委派机制。
三、双亲委派机制
1.双亲委派机制介绍
当类加载器进行加载类的时候,类的加载需要向上委托给上一级的类加载器,上一级继续向上委托,直到启动类加载器。启动类加载器去核心类库中找,如果没有该类则向下委派,由下一级扩展类加载器去扩展类库中,如果也没有继续向下委派,直到找不到为止,则报类找不到的异常。
public class TestJMM {
//静态属性
public static int baseData = 10;
//静态属性
public static Student student = new Student();
public static String hello = "hello";
}
应用类加载器怎么加载Student和String呢?需要通过双亲委派机制
双亲委派机制:
AppClassLoader【应用类加载器】在加载类进JVM时,先会委托上一级类加载器去加载类,上一级类加载器也会委托它的上一级类加载器去加载类,直接BootstrapClassLoader【启动类加载器】,如果BootstrapClassLoader能够加载该类,则加载进JVM,如果加不到该类,那么委派它的下一级类加载器去加载,下一级类加载器也会执行相同的操作,直接到AppClassLoader。如果到AppClassLoader也找到该类,则报ClassNotFoundException
2.为什么要有双亲委派机制
- 防止核心类库中的类被随意篡改
- 防止类的重复加载
3.双亲委派机制核心源码
ClassLoader.class
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// First, check if the class has already been loaded
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
}
if (c == null) {
// If still not found, then invoke findClass in order
// to find the class.
long t1 = System.nanoTime();
c = findClass(name);
// this is the defining class loader; record the stats
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
4.全盘委托机制
当一个类被当前的ClassLoader加载时,该类中的其他类也会被当前该ClassLoader加载。除非指明其他由其他类加载器加载。
5.自定义类加载器实现双亲委派机制
- 自定义类加载机制
package com.qf.jvm;
import sun.misc.Resource;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.security.AccessController;
import java.security.PrivilegedExceptionAction;
/**
* @author Thor
* @公众号 Java架构栈
*/
public class MyClassLoader extends ClassLoader{
private String classPath;
public MyClassLoader(String classPath){
this.classPath = classPath;
}
@Override
protected Class<?> findClass(final String name)
throws ClassNotFoundException
{
try {
//1.读入指定路径的classPath下的类
String path = name.replace('.', '/').concat(".class");
FileInputStream fileInputStream = new FileInputStream(classPath+"/"+path);
byte[] data = new byte[fileInputStream.available()];
fileInputStream.read(data);
fileInputStream.close();
//2.加载该类
return defineClass(name, data,0,data.length);
} catch (Exception e) {
e.printStackTrace();
throw new ClassNotFoundException();
}
}
}
- 启动自定义类加载器
package com.qf.jvm;
import java.lang.reflect.Method;
/**
* @author Thor
* @公众号 Java架构栈
*/
public class TestMyClassLoader {
public static void main(String[] args) throws Exception {
//1.创建自定义类加载器,指定加载路径
MyClassLoader myClassLoader = new MyClassLoader("/Users/zeleishi/Documents/工作/授课/2104/第四阶段/myclass");
//2.指定要加载的类名
Class<?> clazz = myClassLoader.loadClass("com.qf.jvm.JVMAnalyze");
//3.反射创建对象
Object obj = clazz.newInstance();
//4.创建add方法对象
Method add = clazz.getDeclaredMethod("add", null);
//5.调用对象的add方法
Object result = add.invoke(obj);
//6.打印结果
System.out.println(result);
}
}
6.自定义类加载器打破双亲委派机制
/**
* 重写loadClass方法
*/
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
// First, check if the class has already been loaded
Class<?> c = findLoadedClass(name);
if (c == null) {
// If still not found, then invoke findClass in order
// to find the class.
long t1 = System.nanoTime();
//对于Object类,使用父加载器
if(!name.startsWith("com.qf.jvm")){
c = this.getParent().loadClass(name);
}else{
c = findClass(name);
}
// this is the defining class loader; record the stats
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
四、运行时数据区【JMM】
1.运行时数据区介绍
运行时数据区也就是JVM在运行时产生的数据存放的区域,这块区域就是JVM的内存区域,也称为JVM的内存模型——JMM
JMM分成了这么几个部分
- 堆空间(线程共享):存放new出来的对象
- 元空间(线程共享):存放类元信息、类的模版、常量池、静态部分
- 线程栈(线程独享):方法的栈帧
- 本地方法栈(线程独享):本地方法产生的数据
- 程序计数器(线程独享):配合执行引擎来执行指令
2.程序在执行时运行时数据区中的内存变化
首先,在程序的.class目录内执行如下命令,查看程序具体的汇编指令
javap -c JVMAnalyze
得到结果:
Compiled from "JVMAnalyze.java"
public class com.qf.jvm.JVMAnalyze {
public com.qf.jvm.JVMAnalyze();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public int add();
Code:
0: bipush 10
2: istore_1
3: bipush 20
5: istore_2
6: iload_1
7: iload_2
8: iadd
9: iconst_5
10: imul
11: istore_3
12: iload_3
13: ireturn
public static void main(java.lang.String[]);
Code:
0: new #2 // class com/qf/jvm/JVMAnalyze
3: dup
4: invokespecial #3 // Method "<init>":()V
7: astore_1
8: aload_1
9: invokevirtual #4 // Method add:()I
12: istore_2
13: getstatic #5 // Field java/lang/System.out:Ljava/io/PrintStream;
16: iload_2
17: invokevirtual #6 // Method java/io/PrintStream.println:(I)V
20: return
}
通过指令,JMM内存发生了如下变化:
- 线程栈:执行一个方法就会在线程栈中创建一个栈帧。
- 栈帧包含如下四个内容:
- 局部变量表:存放方法中的局部变量
- 操作数栈:用来存放方法中要操作的数据
- 动态链接:存放方法名和方法内容的映射关系,通过方法名找到方法内容
- 方法出口:记录方法执行完后调用此方法的位置。
五、对象的创建流程
1.对象创建流程
2.类加载校验
校验该类是否已被加载。主要是检查常量池中是否存在该类的类元信息。如果没有,则需要进行加载。
3.分配内存
为对象分配内存。具体的分配策略如下:
- Bump the Pointer(指针碰撞):如果内存空间的分配是绝对规整的,则JVM记录当前剩余内存的指针,在已用内存分配
- Free List(空闲列表):如果内存空间的分配不规整,那么JVM会维护一个可用内存空间的列表用于分配。
对象并发分配存在的问题:
-
Compare And Swap: 自旋分配,如果并发分配失败则重试分配之后的地址
-
Thread Local Allocation Buffer(TLAB):本地线程分配缓冲,JVM为每个线程分配一块空间,每个线程在自己的空间中创建对象(jdk8默认使用,之前版本需要通过-XX:+UseTLAB开启)
4.设置初值
根据数据类型,为对象空间赋初始化值。
5.设置对象头
为对象设置对象头信息,对象头信息包含以下内容:类元信息、对象哈希码、对象年龄、锁状态标志等。
- 对象头中的Mark Work 字段(32位)
- 对象头中的类型指针(Klass Pointer)
类型指针用于指向元空间当前类的类元信息。比如调用类中的方法,通过类型指针找到元空间中的该类,再找到相应的方法。
开启指针压缩后,类型指针只用4个字节存储,否则需要8个字节存储
- 指针压缩
过大的对象地址,会占用更大的带宽和增加GC的压力。
对象中指向其他对象所使用的指针:8字节被压缩成4字节。 最早的机器是32位,最大支持内存 2的32次方=4G。现在是64位,2的64次方可以表示N个T的内存。内存32G即等于2的35次方。如果内存是32G的话,用35位表示内存地址,这样过于浪费。如果把35位的数据,根据算法,压缩成32位的数据(也就是4个字节)。在保存时用4个字节,再使用时使用8个字节。之前用35位保存内存地址,就可以用32位保存。这样8个字节的对象,实际上使用32位来保存,这样64位就能表示2个对象。
如果内存大于32G,指针压缩会失效,会强制使用64位来表示对象地址。因此jvm堆内存最好不要大于32G。
Jdk1.6之后默认开启指针压缩,可通过配置jvm参数关闭指针要锁-XX:-UseCompressedOops
示例代码:
package com.qf.jvm;
import org.openjdk.jol.info.ClassLayout;
/**
* 对象指针压缩
* @author Thor
* @公众号 Java架构栈
*/
public class ObjectLengthAnalyze {
public static void main(String[] args) {
ClassLayout classLayout = ClassLayout.parseInstance(new A());
System.out.println(classLayout.toPrintable());
}
static class A{
int num;
String name;
}
}
关闭指针压缩:
开启指针压缩:
6.执行init方法
为对象中的属性赋值和执行构造方法。
六、垃圾回收机制
1.对象成为垃圾的判断依据
在堆空间和元空间中,GC这条守护线程会对这些空间开展垃圾回收工作,那么GC如何判断这些空间的对象是否是垃圾,有两种算法:
- 引用计数法:
对象被引用,则计数器+1,如果计数器是0,那么对象将被判定为是垃圾,于是被回收。但是这种算法没有办法解决循环依赖的对象。因此JVM目前的主流厂商Hotspot没有使用这种算法。
- 可达性分析算法:GC Roots根
- gc roots根节点: 在对象的引用中,会有这么几种对象的变量:来自于线程栈中的局部变量表中的变量、静态变量、本地方法栈中的变量,这些变量都被称为gc roots根节点
- 判断依据:gc在扫描堆空间中的某个节点时,向上遍历,看看能不能遍历到gc roots根节点,如果不能,那么意味着这个对象是垃圾。
2.对象中的finalize方法
Object类中有一个finalize方法,也就是说任何一个对象都有finalize方法。这个方法是对象被回收之前的最后一根救命稻草。
- GC在垃圾对象回收之前,先标记垃圾对象,被标记的对象的finalize方法将被调用
- 调用finalize方法如果对象被引用,那么第二次标记该对象,被标记的对象将移除出即将被回收的集合,继续存活
- 调用finalize方法如果对象没有被引用,那么将会被回收
- 注意,finalize方法只会被调用一次。
3.对象的逃逸分析
在jdk1.7之前,对象的创建都是在堆空间中创建,但是会有个问题,方法中的未被外部访问的对象
public void test1() {
User user = new User();
user.setId(1);
user.setName("xiaoming");
}
public User test2() {
User user = new User();
user.setId(1);
user.setName("xiaoming");
return user;
}
这种对象没有被外部访问,且在堆空间上频繁创建,当方法结束,需要被gc,浪费了性能。
所以在1.7之后,就会进行一次逃逸分析(默认开启),于是这样的对象就直接在栈上创建,随着方法的出栈而被销毁,不需要进行gc。
在栈上分配内存的时候:会把聚合量替换成标量,来减少栈空间的开销,也为了防止栈上没有足够连续的空间直接存放对象。
- 标量:java中的基本数据类型(不可再分)
- 聚合量:引用数据类型。
七、垃圾回收算法
1、标记清除算法、复制算法、标记整理算法
2.分代回收算法
-
堆空间被分成了新生代(1/3)和老年代(2/3),新生代中被分成了eden(8/10)、survivor1(1/10)、survivor2(1/10)
-
对象的创建在eden,如果放不下则触发minor gc
-
对象经过一次minorgc 后存活的对象会被放入到survivor区,并且年龄+1
-
survivor区执行的复制算法,当对象年龄到达15.进入到老年代。
-
如果老年代放满。就会触发Full GC
3.对象进入到老年代的条件
- 大对象直接进入到老年代:大对象可以通过参数设置大小,多大的对象被认为是大对象。 -XX:PretenureSizeThreshold
- 当对象的年龄到达15岁时将进入到老年代,这个年龄可以通过这个参数设置:-XX:MaxTenuringThreshold
- 根据对象动态年龄判断,如果s区中的对象总和超过了s区中的50%,那么下一次做复制的时候,把年龄大于等于这次最大年龄的对象都一次性全部放入到老年代。
- 老年代空间分配担保机制 :在minor gc时,检查老年代剩余可用空间是否大于年轻代里现有的所有对象(包含垃圾)。如果大于等于,则做minor gc。如果小于,看下是否配置了担保参数的配置:-XX: -HandlePromotionFailure ,如果配置了,那么判断老年代剩余的空间是否小于历史每次minor gc 后进入老年代的对象的平均大小。如果是,则直接full gc,减少一次minor gc。如果不是,执行minor gc。如果没有担保机制,直接full gc。
八、垃圾回收器
垃圾回收机制,我们已经知道什么样的对象会成为垃圾。对象回收经历了什么——垃圾回收算法。那么谁来负责回收垃圾呢?
接下来就来讨论垃圾回收器。
1.Serial收集器(-XX:+UseSerialGC -XX:+UseSerialOldGC)
单线程执行垃圾收集,收集过程中会有较长的STW(stop the world),在GC时工作线程不能工作。虽然STW较长,但简单、直接。
新生代采用复制算法,老年代采用标记-整理算法。
2.Parallel收集器(-XX:+UseParallelGC,-XX:+UseParallelOldGC)
使用多线程进行GC,会充分利用cpu,但是依然会有stw,这是jdk8默认使用的新生代和老年代的垃圾收集器。充分利用CPU资源,吞吐量高。
新生代采用复制算法,老年代采用标记-整理算法。
3.ParNew收集器(-XX:+UseParNewGC)
工作原理和Parallel收集器一样,都是使用多线程进行GC,但是区别在于ParNew收集器可以和CMS收集器配合工作。主流的方案:
ParNew收集器负责收集新生代。CMS负责收集老年代。
4.CMS收集器(-XX:+UseConcMarkSweepGC)
目标:尽量减少stw的时间,提升用户的体验。真正做到gc线程和用户线程几乎同时工作。CMS采用标记-清除算法。
- 初始标记: 暂停所有的其他线程(STW),并记录gc roots直接能引用的对象。
- 并发标记:从GC Roots的直接关联对象开始遍历整个对象图的过程, 这个过程耗时较长但是不需要STW,可以与用户线程一起并发运行。这个过程中,用户线程和GC线程并发,可能会有导致已经标记过的对象状态发生改变。
- 重新标记:为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短。主要用到三色标记里的算法做重新标记。
- 并发清理:开启用户线程,同时GC线程开始对未标记的区域做清扫。这个阶段如果有新增对象会被标记为黑色不做任何处理。
- 并发重置:重置本次GC过程中的标记数据。
5.三色标记算法
在并发标记阶段,对象的状态可能发生改变,GC在进行可达性分析算法分析对象时,用三色来标识对象的状态
- 黑色:这个对象及其所有引用都已被GC Roots遍历,黑色的对象不会被回收
- 灰色:这个对象被GC Roots遍历过但其部分的引用没有被GC Roots遍历。在重新标记时重新遍历灰色对象。
- 白色:这个对象没有被GC Roots遍历过。在重新标记时该对象如果是白色的话,那么将会被回收。
6.垃圾收集器组合方案
不同的垃圾收集器可以组合使用,在使用时选择适合当前业务场景的组合。
年轻代 | 老年代 | 备注 |
---|---|---|
Serial | Serial Old | 简单、直接 |
Serial | CMS | |
ParNew | CMS | 推荐使用 |
ParNew | Serial Old | |
Parallel | Parallel Old | 吞吐量高、jdk8默认使用的组合 |
Parallel | Serial Old |
九、JVM调优实战
1.JVM调优的核心参数
-Xss:每个线程的栈大小。设置越小,说明一个线程栈里能分配的栈帧就越少,但是对JVM整体来说能开启的线程数会更多。
public class StackOverflowTest {
static int count = 0;
static void add() {
count++;
add();
}
public static void main(String[] args) {
try {
add();
} catch (Throwable t) {
t.printStackTrace();
System.out.println(count);
}
}
}
运行结果:
java.lang.StackOverflowError
at com.jvm.StackOverflowTest.redo(StackOverflowTest.java:12)
......
-Xms:设置堆的初始可用大小,默认物理内存的1/64
-Xmx:设置堆的最大可用大小,默认物理内存的1/4
-Xmn:新生代大小
-XX:NewRatio:默认2表示新生代占年老代的1/2,占整个堆内存的1/3。
-XX:SurvivorRatio:默认8表示一个survivor区占用1/8的Eden内存,即1/10的新生代内存。
以下两个参数设置元空间大小建议值相同,且写死,防止在程序启动时因为需要元空间的空间不够而频繁full gc。
-XX:MaxMetaspaceSize:最大元空间大小
-XX:MetaspaceSize:元空间大小,默认是21M,达到该值后会触发Full GC,同时会按100%进行动态调整,为了减少大数据量占满元空间,频繁触发Full GC,建议在初始化时设置为跟MaxMetaspaceSize相同的值。
2.JVM调优实战
设置JVM参数:
‐Xms3072M ‐Xmx3072M ‐Xss1M ‐XX:MetaspaceSize=256M ‐XX:MaxMetaspaceSize=256M ‐XX:SurvivorRatio=8
调整JVM参数
‐Xms3072M ‐Xmx3072M ‐Xmn2048M ‐Xss1M ‐XX:MetaspaceSize=256M ‐XX:MaxMetaspaceSize=256M ‐XX:SurvivorRatio=8
3.调优的关键点
- 设置元空间大小,最大值和初始化值相同
- 根据业务场景计算出每秒产生多少的对象。这些对象间隔多长时间会成为垃圾(一般根据接口响应时间来判断)
- 计算出堆中新生代中eden、survivor所需要的大小:根据第2点每条产生的对象和多少时间成为垃圾来计算出,依据是尽量减少full gc。
4.结合垃圾收集器的调优策略
结合垃圾收集器:PraNew+CMS,对于CMS的垃圾收集器,还需要加上相关的配置:
-
对于一些年龄较大的bean,比如缓存对象、spring相关的容器对象,配置相关的对象,这些对象需要尽快的进入到老年代,因此需要配置:-XX:MaxTenuringThreshold=5
-
大对象直接进入到老年代:-XX:PretenureSizeThreshold=1M
-
CMS垃圾收集器会有并发模式失败的风险(转换为使用serialOld垃圾收集器),如何避免这种风险:将full gc的触发点调低:
-XX:CMSInitiatingOccupancyFraction=85 (默认是92),相当于老年代使用率达到85%就触发full gc,于是还剩15%的空间允许在cms进行gc的过程中产生新的对象。
-
CMS垃圾收集器收集完后会产生碎片,碎片需要整理,但不是每次收集完就整理,设置做了3次Full GC之后整理一次碎片:
-XX:+UseCMSCompactAtFullCollection -XX:CMSFullGCsBeforeCompaction=3
PraNew+CMS的具体JVM参数配置:
java -Xms3072M -Xmx3072M -Xmn2048M -Xss1M -XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M -XX:SurvivorRatio=8
-XX:MaxTenuringThreshold=5 -XX:PretenureSizeThreshold=1M -XX:+UseParNewGC -XX:+UseConcMarkSweepGC
-XX:CMSInitiatingOccupancyFraction=85 -XX:+UseCMSCompactAtFullCollection -XX:CMSFullGCsBeforeCompaction=3 -jar device-service.jar
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 分享4款.NET开源、免费、实用的商城系统
· 全程不用写代码,我用AI程序员写了一个飞机大战
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· 记一次.NET内存居高不下排查解决与启示
· 白话解读 Dapr 1.15:你的「微服务管家」又秀新绝活了