[06][01][01] JVM原理与实战

JVM 是什么?

JavaVirtualMachine(Java 虚拟机)WriteOnceRunAnywhere


JDK JRE JVM

Java 官网:https://docs.oracle.com/javase/8/
Reference->DeveloperGuides->定位到:https://docs.oracle.com/javase/8/docs/index.html

JDK 8 is a superset of JRE 8, and contains everything that is in JRE 8, plus tools such as the compilers and debuggers necessary for developing applets and applications. JRE 8 provides the libraries, the Java Virtual Machine (JVM), and other components to run applets and applications written in the Java programming language. Note that the JRE includes components not required by the Java SE specification, including both standard and non-standard Java components

JVM 到底该学习什么

或者换句话说,JVM 到底从哪边开始学习起?

  • 源码到类文件
  • 类文件到 JVM
  • JVM 各种折腾[内部结构,执行方式,垃圾回收,本地调用等]

源码到类文件

源码 demo

class Person {
    private String name = "Jack";
    private int age;
    private final double salary = 100;
    private static String address;
    private final static String hobby = "Programming";
    private static Object obj = new Object();

    public void say(){
        System.out.println("person say...");
    }

    public static int calc(int op1,int op2){
        op1 = 3;
        int result = op1+op2;
        Object obj=new Object();
        return result;
    }

    public static void main(String[] args){
        calc(1,2);
    }
}

编译:javac-g:varsPerson.java--->Person.class

分析编译器干了什么事

Person.java->词法分析器->tokens 流->语法分析器->语法树/抽象语法树->语义分析器->注解抽象语法树->字节码生成器->Person.class 文件

由上可知,其实我们的编译器其实做的事情其实就是“对等信息转换”.JAVA 文件中的信息跟我们 Class 文件中的信息是一样的

类文件(Class 文件)

16进制

cafe babe 0000 0034 003f 0a00 0a00 2b08
002c 0900 0d00 2d06 4059 0000 0000 0000
0900 0d00 2e09 002f 0030 0800 310a 0032
0033 0700 340a 000d 0035 0900 0d00 3607
0037 0100 046e 616d 6501 0012 4c6a 6176
612f 6c61 6e67 2f53 7472 696e 673b 0100
0361 6765 0100 0149 0100 0673 616c 6172
7901 0001 4401 000d 436f 6e73 7461 6e74
......

The ClassFile Structure

官网:https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-4.html

ClassFile {
    u4        magic;
    u2        minor_version;
    u2        major_version;
    u2        constant_pool_count;
    cp_info   constant_pool[constant_pool_count-1];
    u2        access_flags;
    u2        this_class;
    u2        super_class;
    u2        interfaces_count;
    u2        interfaces[interfaces_count];
    u2        fields_count;
    field_info fields[fields_count];
    u2        methods_count;
    method_info methods[methods_count];
    u2        attributes_count;
    attribute_info attributes[attributes_count];
}

Simple analysis

u4:cafebabe

magic:Themagicitemsuppliesthemagicnumberidentifyingtheclassfileformat

u2+u2:0000+0034,34 等于 10 进制的 52,表示 JDK8

minor_version
major_version

u2:003f=63(10 进制)

constant_pool_count:Thevalueoftheconstant_pool_countitemisequaltothenumberofentriesintheconstant_pooltableplusone.

表示常量池中的数量是62
cp_infoconstant_pool[constant_pool_count-1]

Theconstant_poolisatableofstructuresrepresentingvariousstringconstants,classandinterfacenames,fieldnames,andotherconstantsthatarereferredtowithintheClassFilestructureanditssubstructures.Theformatofeachconstant_pooltableentryisindicatedbyitsfirst"tag"byte
Theconstant_pooltableisindexedfrom1toconstant_pool_count-1

常量池主要存储两方面内容:字面量(Literal)和符号引用(SymbolicReferences)

字面量:文本字符串,final 修饰等
符号引用:类和接口的全限定名,字段名称和描述符,方法名称和描述符

反编译验证

用 javap 指令验证上述猜想正确性

编译指令:javap-v-pPerson.class

进行反编译之后,查看字节码信息和指令等信息
是否有一种感觉?
JVM 相对 class 文件来说可以理解为是操作系统;class 文件相对 JVM 来说可以理解为是汇编语言或者机器语言


Continous analysis

上面分析到常量池中常量的数量是58,接下来我们来具体分析一下这58个常量
cp_infoconstant_pool[constant_pool_count-1]也就是这块包括的信息
cp_info其实就是一个表格的形式

Allconstant_pooltableentrieshavethefollowinggeneralformat:

cp_info {
    u1 tag;
    u1 info[];
}

官网:https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-4.html#jvms-4.4

*往下数一个 u1,即 0a->10:代表的是 CONSTANT_Methodref,表示这是一个方法引用

CONSTANT_Fieldref_info {
    u1 tag;
    u2 class_index;
    u2 name_and_type_index;
}

往下数 u2 和 u2
u2,即 000a->10:代表的是 class_index,表示该方法所属的类在常量池中的索引
u2,即 002b->43:代表的是 name_and_type_index,表示该方法的名称和类型的索引

#1 = Methodref #10,#43

*往下数 u1,即 08->8:表示的是 CONSTANT_String,表示字符串类型

CONSTANT_String_info {
    u1 tag;
    u2 string_index;
}

往下数 u2
u2,即 002c->44:代表的是 string_index

*往下数 u1,即 09->9:表示的是 CONSTANT_Fieldref,表示字段类型

CONSTANT_Fieldref_info {
    u1 tag;
    u2 class_index;
    u2 name_and_type_index;
}

往下数 u2 和 u2
u2,即 000d->13:代表的是 class_index
u2,即 002d->45:代表的是 name_and_type_index

类加载机制

类加载机制是指我们将类的字节码文件所包含的数据读入内存,同时我们会生成数据的访问入口的一种特殊机制.那么我们可以得知,类加载的最终产品是数据访问入口


这个时候,看到这张图,我们应该有一个问题,那就是我们的字节码加载的方式,也就是我们的字节码
文件可以用什么方式进行加载呢?

加载 class 文件的方式

  • 从本地系统中直接加载
    典型场景:这个我就不废话了

  • 通过网络下载 class 文件
    典型场景:WebApplet,也就是我们的小程序应用

  • 从 zip,jar 等归档文件中加载 class 文件
    典型场景:后续演变为 jar,war 格式

  • 从专有数据库中提取 class 文件
    典型场景:JSP 应用从专有数据库中提取 class 文件,较为少见

  • 将 Java 源文件动态编译为 class 文件,也就是运行时计算而成
    典型场景:动态代理技术

  • 从加密文件中获取
    典型场景:典型的防 Class 文件被反编译的保护措施好,聊完了这个问题之后,问题接踵而至,我们的类加载的方式已经了解了,那么加载的流程到底是怎
    样的呢?

装载(Load)

查找和导入 class 文件

  • 通过一个类的全限定名获取定义此类的二进制字节流(由上可知,我们不一定从字节码文件中获得,还有上述很多种方式)
    思考:那么这个时候我们是不是需要一个工具,寻找器,来寻找获取我们的二进制字节流

而我们的 java 中恰好有这么一段代码模块.可以实现通过类全名来获取此类的二进制字节流这个动作,并且将这个动作放到放到 java 虚拟机外部去实现,以便让应用程序决定如何获取所需要的类,实现这个动作的代码模块成为“类加载器”

  • 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
  • 在 Java 堆中生成一个代表这个类的 java.lang.Class 对象,作为对方法区中这些数据的访问入口

获取类的二进制字节流的阶段是我们 JAVA 程序员最关注的阶段,也是操控性最强的一个阶段.因为这个阶段我们可以对于我们的类加载器进行操作,比如我们想自定义类加载器进行操作用以完成加载,又或者我们想通过 JAVAAgent 来完成我们的字节码增强操作

在我们的装载阶段完成之后,这个时候在我们的内存当中,我们的运行时数据区的方法区以及堆就已经有数据了

*方法区:类信息,静态变量,常量
*堆:代表被加载类的 java.lang.Class 对象,即时编译之后的热点代码并不在这个阶段进入方法区

链接(Link)

验证(Verify)

验证只要是为了确保 Class 文件中的字节流包含的信息完全符合当前虚拟机的要求,并且还要求我们的信息不会危害虚拟机自身的安全,导致虚拟机的崩溃

  • 文件格式验证

验证字节流是否符合 Class 文件格式的规范,并且能被当前版本的虚拟机处理,该验证的主要目的是保证输入的字节流能正确地解析并存储于方法区之内.这阶段的验证是基于二进制字节流进行的,只有经过该阶段的验证后,字节流才会进入内存的方法区中进行存储,后面验证都是基于方法区的存储结构进行的
举例:
*是否以 16 进制 cafebaby 开头
*版本号是否正确

  • 元数据验证

对类的元数据信息进行语义校验(其实就是对 Java 语法校验),保证不存在不符合 Java 语法规范的元数据信息
举例:
*是否有父类
*是否继承了 final 类:因为我们的 final 类是不能被继承的,继承了就会出现问题
*一个非抽象类是否实现了所有的抽象方法:如果没有实现,那么这个类也是无效的

对类的元数据信息进行语义校验(其实就是对 Java 语法校验),保证不存在不符合 Java 语法规范的元数据信息

  • 字节码验证

进行数据流和控制流分析,确定程序语义是合法的,符合逻辑的.对类的方法体进行校验分析,保证被校验的类的方法在运行时不会做出危害虚拟机安全的行为
举例:
字节码的验证会相对来说较为复杂
*运行检查
*栈数据类型和操作码操作参数吻合(比如栈空间只有4个字节,但是我们实际需要的远远大于4个字节,那么这个时候这个字节码就是有问题的)
*跳转指令指向合理的位置

  • 符号引用验证

这是最后一个阶段的验证,它发生在虚拟机将符号引用转化为直接引用的时候(解析阶段),可以看作是对类自身以外的信息(常量池中的各种符号引用)进行匹配性的校验.符号引用验证的目的是确保解析动作能正常执行
举例:
*常量池中描述类是否存在
*访问的方法或者字段是否存在且具有足够的权限

但是,我们很多情况下可能认为我们的代码肯定是没问题的,验证的过程完全没必要,那么其实我们可以添加参数-Xverify:none取消验证

准备(Prepare)

  • 为类变量(静态变量)分配内存并且设置该类变量的默认初始值
数据类型 零值
int 0
long 0L
short (short)0
char ‘\u0000’
byte (byte)0
boolean false
float 0.0f
double 0.0d
reference null
  • 这里不包含用 final 修饰的 static,因为 final 在编译的时候就会分配了,准备阶段会显式初始化
  • 这里不会为实例变量(也就是没加 static)分配初始化,类变量会分配在方法区中,而实例变量是会随着对象一起分配到 Java 堆中
public class Demo1 {
    private static int i;

    public static void main(String[] args){
        // 正常打印出 0,因为静态变量 i 在准备阶段会有默认值 0
        System.out.println(i);
    }
}

public class Demo2 {
    public static void main(String[] args){
        // 编译通不过,因为局部变量没有赋值不能被使用
        int i;
        System.out.println(i);
    }
}

进行分配内存的只是包括类变量(静态变量),而不包括实例变量,实例变量是在对象实例化时随着对象,一起分配在 java 堆中的.通常情况下,初始值为零值,假设 publicstaticinta=1;那么 a 在准备阶段过后的初始值为 0,不为 1,这时候只是开辟了内存空间,并没有运行 java 代码,a 赋值为 1 的指令是程序被编译后,存放于类构造器()方法之中,所以 a 被赋值为 1 是在初始化阶段才会执行

对于一些特殊情况,如果类字段属性表中存在 ConstantValue 属性,那在准备阶段变量 a 就会被初始化为 ContstantValue 属性所指的值.对于这句话,我们又怎么理解呢?

我们可以看一看我们反编译之后的文件,我们就可以发现有这样一个属性

思考:ConstantValue 属性到底是干什么的呢?

ConstantValue 属性的作用是通知虚拟机自动为静态变量赋值,只有被 static 修饰的变量才可以使用这项属性.非 static 类型的变量的赋值是在实例构造器方法中进行的;static 类型变量赋值分两种,在类构造其中赋值,或使用 ConstantValue 属性赋值

思考:在实际的程序中,我们什么时候才会用到 ContstantValue 属性呢?

在实际的程序中,只有同时被 final 和 static 修饰的字段才有 ConstantValue 属性,且限于基本类型和 String.编译时 Javac 将会为该常量生成 ConstantValue 属性,在类加载的准备阶段虚拟机便会根据 ConstantValue 为常量设置相应的值,如果该变量没有被 final 修饰,或者并非基本类型及字符串,则选择在类构造器中进行初始化

思考:为什么 ConstantValue 的属性值只限于基本类型和 string?

因为从常量池中只能引用到基本类型和 String 类型的字面量

那么这个时候,我们来举个例子:
假设上面的类变量 a 被定义为:privatestaticfinalinta=1;
编译时 Javac 将会为 a 生成 ConstantValue 属性,在准备阶段虚拟机就会根据 ConstantValue 的设置将 value 赋值为 1.我们可以理解为 staticfinal 常量在编译期就将其结果放入了调用它的类的常量池中

解析(Resolve)

把类中的符号引用转换为直接引用

符号引用就是一组符号来描述目标,可以是任何字面量.引用的目标并不一定已经加载到了内存中.直接引用就是直接指向目标的指针,相对偏移量或一个间接定位到目标的句柄
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程
解析动作主要针对类或接口,字段,类方法,接口方法,方法类型,方法句柄和调用限定符7类符号引用进行

直接引用是与虚拟机内存布局实现相关,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同,如果有了直接引用,那引用的目标必定存在内存中

对解析结果进行缓存

同一符号引用进行多次解析请求是很常见的,除 invokedynamic 指令以外,虚拟机实现可以对第一次解析结果进行缓存,来避免解析动作重复进行.无论是否真正执行了多次解析动作,虚拟机需要保证的是在同一个实体中,如果一个引用符号之前已经被成功解析过,那么后续的引用解析请求就应当一直成功;同样的,如果第一次解析失败,那么其他指令对这个符号的解析请求也应该收到相同的异常

inDy(invokedynamic)是 java7 引入的一条新的虚拟机指令,这是自 1.0 以来第一次引入新的虚拟机指令.到了 java8 这条指令才第一次在 java 应用,用在 lambda 表达式中,indy 与其他 invoke 指令不同的是它允许由应用级的代码来决定方法解析.这里不演示

初始化(Initialize)

初始化阶段是执行类构造器()方法的过程
或者讲得通俗易懂些
在准备阶段,类变量已赋过一次系统要求的初始值,而在初始化阶段,则是根据程序员通过程序制定的主观计划去初始化类变量和其他资源,比如赋值

在 Java 中对类变量进行初始值设定有两种方式:

  • 声明类变量是指定初始值
  • 使用静态代码块为类变量指定初始值

按照程序员的逻辑,你必须把静态变量定义在静态代码块的前面.因为两个的执行是会根据代码编写的顺序来决定的,顺序搞错了可能会影响你的业务代码

JVM 初始化步骤:

  • 假如这个类还没有被加载和连接,则程序先加载并连接该类
  • 假如该类的直接父类还没有被初始化,则先初始化其直接父类
  • 假如类中有初始化语句,则系统依次执行这些初始化语句

使用

那么这个时候我们去思考一个问题,我们的初始化过程什么时候会被触发执行呢?或者换句话说类初始化时机是什么呢?

主动引用

只有当对类的主动使用的时候才会导致类的初始化,类的主动使用有六种:

  • 创建类的实例,也就是 new 的方式
  • 访问某个类或接口的静态变量,或者对该静态变量赋值
  • 调用类的静态方法
  • 反射(如 Class.forName(“com.carl.Test”))
  • 初始化某个类的子类,则其父类也会被初始化
  • Java 虚拟机启动时被标明为启动类的类(JvmCaseApplication),直接使用 java.exe 命令来运行某个主类

被动引用

  • 引用父类的静态字段,只会引起父类的初始化,而不会引起子类的初始化
  • 定义类数组,不会引起类的初始化
  • 引用类的 staticfinal 常量,不会引起类的初始化(如果只有 static 修饰,还是会引起该类初始化的)

卸载

在类使用完之后,如果满足下面的情况,类就会被卸载:

  • 该类所有的实例都已经被回收,也就是 java 堆中不存在该类的任何实例
  • 加载该类的 ClassLoader 已经被回收
  • 该类对应的 java.lang.Class 对象没有任何地方被引用,无法在任何地方通过反射访问该类的方法

Java 虚拟机本身会始终引用这些类加载器,而这些类加载器则会始终引用它们所加载的类的 Class 对象,因此这些 Class 对象始终是可触及的

如果以上三个条件全部满足,jvm 就会在方法区垃圾回收的时候对类进行卸载,类的卸载过程其实就是在方法区中清空类信息,java 类的整个生命周期就结束了.但是一般情况下启动类加载器加载的类不会被卸载,而我们的其他两种基础类型的类加载器只有在极少数情况下才会被卸载

类加载器(ClassLoader)

什么是类加载器?

  • 负责读取 Java 字节代码,并转换成 java.lang.Class 类的一个实例的代码模块
  • 类加载器除了用于加载类外,还可用于确定类在 Java 虚拟机中的唯一性

一个类在同一个类加载器中具有唯一性(Uniqueness),而不同类加载器中是允许同名类存在的,这里的同名是指全限定名相同.但是在整个 JVM 里,纵然全限定名相同,若类加载器不同,则仍然不算作是同一个类,无法通过 instanceOf,equals 等方式的校验

  • BootstrapClassLoader
    负责加载$JAVA_HOME 中 jre/lib/rt.jar 里所有的 class 或 Xbootclassoath 选项指定的 jar 包.由 C++实现,不是 ClassLoader 子类

  • ExtensionClassLoader
    负责加载 java 平台中扩展功能的一些 jar 包,包括$JAVA_HOME 中 jre/lib/*.jar 或-Djava.ext.dirs 指定目录下的 jar 包

  • AppClassLoader
    负责加载 classpath 中指定的 jar 包及 Djava.class.path 所指定目录下的类和 jar 包

  • CustomClassLoader 通过 java.lang.ClassLoader 的子类自定义加载 class,属于应用程序根据自身需要自定义的 ClassLoader,如 tomcat,jboss 都会根据 j2ee 规范自行实现 ClassLoader

为什么我们的类加载器要分层?

1.2 版本的 JVM 中,只有一个类加载器,就是现在的“Bootstrap”类加载器.也就是根类加载器.但是这样会出现一个问题

假如用户调用他编写的 java.lang.String 类.理论上该类可以访问和改变 java.lang 包下其他类的默认访问修饰符的属性和方法的能力.也就是说,我们其他的类使用 String 时也会调用这个类,因为只有一个类加载器,我无法判定到底加载哪个.因为 Java 语言本身并没有阻止这种行为,所以会出现问题

这个时候,我们就想到,可不可以使用不同级别的类加载器来对我们的信任级别做一个区分呢?
比如用三种基础的类加载器做为我们的三种不同的信任级别.最可信的级别是 java 核心 API 类.然后是安装的拓展类,最后才是在类路径中的类(属于你本机的类)

所以,我们三种基础的类加载器由此而生.但是这是我们开发人员的视角

public class Demo3 {
    public static void main(String[] args){
        // App ClassLoader
        System.out.println(new Worker().getClass().getClassLoader());
        // Ext ClassLoader
        System.out.println(new Worker().getClass().getClassLoader().getParent());
        // Bootstrap ClassLoader
        System.out.println(new Worker().getClass().getClassLoader().getParent().getParent());
        System.out.println(new String().getClass().getClassLoader());
    }
}

sun.misc.Launcher$AppClassLoader@18b4aac2
sun.misc.Launcher$ExtClassLoader@3a71f4dd
null
null

JVM 类加载机制的三种方式

  • 全盘负责,当一个类加载器负责加载某个 Class 时,该 Class 所依赖的和引用的其他 Class 也将由该类加载器负责载入,除非显示使用另外一个类加载器来载入

例如,系统类加载器 AppClassLoader 加载入口类(含有 main 方法的类)时,会把 main 方法所依赖的类及引用的类也载入,依此类推.“全盘负责”机制也可称为当前类加载器负责机制.显然,入口类所依赖的类及引用的类的当前类加载器就是入口类的类加载器
以上步骤只是调用了 ClassLoader.loadClass(name)方法,并没有真正定义类.真正加载 class 字节码文件生成 Class 对象由“双亲委派”机制完成

  • 父类委托,“双亲委派”是指子类加载器如果没有加载过该目标类,就先委托父类加载器加载该目标类,只有在父类加载器找不到字节码文件的情况下才从自己的类路径中查找并装载目标类

父类委托别名就叫双亲委派机制
“双亲委派”机制加载 Class 的具体过程是:
*ClassLoader 先判断该 Class 是否已加载,如果已加载,则返回 Class 对象;如果没有则委托给父类加载器
*父类加载器判断是否加载过该 Class,如果已加载,则返回 Class 对象;如果没有则委托给祖父类加载器
*依此类推,直到始祖类加载器(引用类加载器)
*始祖类加载器判断是否加载过该 Class,如果已加载,则返回 Class 对象;如果没有则尝试从其对应的类路径下寻找 class 字节码文件并载入.如果载入成功,则返回 Class 对象;如果载入失败,则委托给始祖类加载器的子类加载器
*始祖类加载器的子类加载器尝试从其对应的类路径下寻找 class 字节码文件并载入.如果载入成功,则返回 Class 对象;如果载入失败,则委托给始祖类加载器的孙类加载器
*依此类推,直到源 ClassLoader
*源 ClassLoader 尝试从其对应的类路径下寻找 class 字节码文件并载入.如果载入成功,则返回 Class 对象;如果载入失败,源 ClassLoader 不会再委托其子类加载器,而是抛出异常

“双亲委派”机制只是 Java 推荐的机制,并不是强制的机制
我们可以继承 java.lang.ClassLoader 类,实现自己的类加载器.如果想保持双亲委派模型,就应该重写 findClass(name)方法;如果想破坏双亲委派模型,可以重写 loadClass(name)方法

  • 缓存机制,缓存机制将会保证所有加载过的 Class 都将在内存中缓存,当程序中需要使用某个 Class 时,类加载器先从内存的缓存区寻找该 Class,只有缓存区不存在,系统才会读取该类对应的二进制数据,并将其转换成 Class 对象,存入缓存区.这就是为什么修改了 Class 后,必须重启 JVM,程序的修改才会生效.对于一个类加载器实例来说,相同全名的类只加载一次,即 loadClass 方法不会被重复调用

而这里我们 JDK8 使用的是直接内存,所以我们会用到直接内存进行缓存.这也就是我们的类变量为什么只会被初始化一次的由来

protected Class<?> loadClass(String name, boolean resolve)throws ClassNotFoundException {
    synchronized (getClassLoadingLock(name)){
        // First,在虚拟机内存中查找是否已经加载过此类...类缓存的主要问题所在
        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){
                //调用此类加载器所实现的 findClass 方法进行加载
                c = findClass(name);
            }
        }

        if (resolve){
            // resolveClass 方法是当字节码加载到内存后进行链接操作,对文件格式和字节码验证,
            // 并为 static 字段分配空间并初始化,符号引用转为直接引用,访问控制,方法覆盖等
            resolveClass(c);
        }
        return c;
    }
}

打破双亲委派

双亲委派这个模型并不是强制模型,而且会带来一些些的问题.就比如 java.sql.Driver 这个东西.JDK 只能提供一个规范接口,而不能提供实现.提供实现的是实际的数据库提供商.提供商的库总不能放 JDK 目录里吧

所以 java 想到了几种办法可以用来打破我们的双亲委派
SPI:比如 Java 从 1.6 搞出了 SPI 就是为了优雅的解决这类问题——JDK 提供接口,供应商提供服务.编程人员编码时面向接口编程,然后 JDK 能够自动找到合适的实现,岂不是很爽?

Java 在核心类库中定义了许多接口,并且还给出了针对这些接口的调用逻辑,然而并未给出实现.开发者要做的就是定制一个实现类,在 META-INF/services 中注册实现类信息,以供核心类库使用.比如 JDBC 中的 DriverManager

OSGI:比如我们的 JAVA 程序员更加追求程序的动态性,比如代码热部署,代码热替换.也就是就是机器不用重启,只要部署上就能用.OSGi 实现模块化热部署的关键则是它自定义的类加载器机制的实现.每一个程序模块都有一个自己的类加载器,当需要更换一个程序模块时,就把程序模块连同类加载器一起换掉以实现代码的热替换

自定义类加载器

package com.example.jvmcase.loader;
import java.io.*;

public class MyClassLoader extends ClassLoader {
    private String root;

    protected Class<?> findClass(String name)throws ClassNotFoundException {
        byte[] classData = loadClassData(name);
        if (classData == null){
            throw new ClassNotFoundException();
        } else {
            return defineClass(name, classData, 0, classData.length);
        }
    }

    private byte[] loadClassData(String className){
        String fileName = root + File.separatorChar + className.replace('.', File.separatorChar)+ ".class";

        try {
            InputStream ins = new FileInputStream(fileName);
            ByteArrayOutputStream baos = new ByteArrayOutputStream();
            int bufferSize = 1024;
            byte[] buffer = new byte[bufferSize];
            int length = 0;

            while ((length = ins.read(buffer))!= -1){
                baos.write(buffer, 0, length);
            }
            return baos.toByteArray();
        } catch (IOException e){
            e.printStackTrace();
        }
        return null;
    }

    public String getRoot(){
        return root;
    }

    public void setRoot(String root){
        this.root = root;
    }

    public static void main(String[] args){
        MyClassLoader classLoader = new MyClassLoader();
        classLoader.setRoot("E:\\temp");

        Class<?> testClass = null;
        try {
            testClass = classLoader.loadClass("com.neo.classloader.Test2");
            Object object = testClass.newInstance();
            System.out.println(object.getClass().getClassLoader());
        } catch (ClassNotFoundException e){
            e.printStackTrace();
        } catch (InstantiationException e){
            e.printStackTrace();
        } catch (IllegalAccessException e){
            e.printStackTrace();
        }
    }
}

输出结果:
class Test
com.example.jvmcase.loader.MyClassLoader@27d6c5e0

自定义类加载器的核心在于对字节码文件的获取,如果是加密的字节码则需要在该类中对文件进行解密.由于这里只是演示,我并未对 class 文件进行加密,因此没有解密的过程.这里有几点需要注意:

  • 这里传递的文件名需要是类的全限定性名称,即 Test 格式的,因为 defineClass 方法是按这种格式进行处理的

如果没有全限定名,那么我们需要做的事情就是将类的全路径加载进去,而我们的 setRoot 就是前缀地址 setRoot+loadClass 的路径就是文件的绝对路径

  • 最好不要重写 loadClass 方法,因为这样容易破坏双亲委托模式
  • 这类 Test 类本身可以被 AppClassLoader 类加载,因此我们不能把 Test.class 放在类路径下.否则,由于双亲委托机制的存在,会直接导致该类由 AppClassLoader 加载,而不会通过我们自定义类加载器来加载

如果我们把 Test 放在类路径之下,那么我们将会通过 AppClassLoader 加载
打印结果:
classcom.example.jvmcase.basic.Test
sun.misc.Launcher$AppClassLoader@18b4aac2

Tomcat 现在基本 8.0 版本已经全面被 8.5 版本代替,而 8.5 版本源码有部分改动,不过我们还是可以看到,我们的 LoadClass 依然打破了双亲委派

@Override
public Class<?> loadClass(String name, boolean resolve)throws ClassNotFoundException {
    synchronized (getClassLoadingLock(name)){
        if (log.isDebugEnabled()){
            log.debug("loadClass(" + name + ", " + resolve + ")");
        }

        Class<?> clazz = null;

        // Log access to stopped class loader
        checkStateForClassLoading(name);

        // (0)Check our previously loaded local class cache
        clazz = findLoadedClass0(name);
        if (clazz != null){
            if (log.isDebugEnabled()){
                log.debug(" Returning class from cache");
            }

            if (resolve){
                resolveClass(clazz);
            }

            return (clazz);
        }

        // (0.1)Check our previously loaded class cache
        clazz = findLoadedClass(name);
        if (clazz != null){
            if (log.isDebugEnabled()){
                log.debug(" Returning class from cache");
            }
            if (resolve){
                resolveClass(clazz);
            }
            return (clazz);
        }
        ...中间省略一万字

        throw new ClassNotFoundException(name);
    }
}

其实,我们知道的,我们的 CPU 跟内存会有非常频繁的交互,因为如果这个频繁的交互是交给我们的磁盘的话,那么随着我们的 CPU 运转速度越来越快,那么我们的磁盘的读写性能远远跟不上我们的 CPU 读写的速度,哪怕是我们现在的 SSD,固态硬盘,也仅仅只是减少了我们的寻道时间以及加快了我们的找数据的时间.所以,我们才会在我们磁盘的基础上设计了内存,用来解决我们的单次 IO 时间过长导致我们 CPU 的等待成本过大的问题.但是随着我们 CPU 的发展,我们 CPU 的性能越来越高,哪怕就算是我们的内存的读写速度都跟不上我们的 CPU 的读写速度

因此,这个时候,我们的 CPU 厂商就想了个办法:在我们的每颗 CPU 上都加入了高速缓冲区,用来加快我们的读写速度,于是乎,我们的 CPU 跟我们的内存的交互就演变成为了这样子的一张图片


但是,根据摩尔定律,我们的 IC 芯片每隔 18 个月能容纳的晶体管会翻倍,但是我们的毕竟不可能不限制的增长,单核 CPU 的主频也有性能瓶颈,想要提升性能,必须增加多个运算核心.所以,随着时间的增长,我们的多核时代来临了

基于高速缓存的存储交互很好的解决了处理器与内存之间的矛盾,也引入了新的问题:缓存一致性问题.在多处理器系统中,每个处理器有自己的高速缓存,而他们又共享同一块内存(下文成主存,mainmemory 主要内存),当多个处理器运算都涉及到同一块内存区域的时候,就有可能发生缓存不一致的现象.为了解决这一问题,需要各个处理器运行时都遵循一些协议,在运行时需要通过这些协议保证数据的一致性.比如 MSI,MESI,MOSI,Synapse,Firely,DragonProtocol 等.那么怎么做的呢?


而我们的运行时数据区其实也保有了这样子的一种设计
其实参照这种设置,我们已经能够推到出我们的 JVM 是如何跟我们的内存还有我们的 CPU 交互的了 java 中使用的是多线程机制,那么必然会有多个任务同时执行,这个时候类比了我们的 CPU 运算核心,那么必然会有一块区域或者一种操作能够保证我们数据的一致性,那么我们的 JVM 内存中数据存放的部分必然会是所有线程都能够获取到的,那么就可以称之为线程共享,而每个线程又有自己单独的工作内存,当我们线程进行运作时,数据肯定会从 JVM 主存拷贝到线程自己的工作内存,然后再进行操作

常量池

常量池分为我们前面所说过的静态常量池,运行时常量池,还有字符串常量池,那么其实我们的运行时常量池又是什么呢?

静态常量池

其实储存的就是字面量以及符号引用

运行时常量池

运行时常量池就是我们的每个类以及每个接口在我们的 JVM 进行 run 的过程中所在内存中开辟出来的一块用来储存我们静态常量池部分数据的一块特殊区域

字符串常量池

包含在动态常量池里

JDK1.8 中各常量池在内存中的划分

运行时数据区(Run-Time Data Areas)

在装载阶段的第(2),(3)步可以发现有运行时数据,堆,方法区等名词
(2)将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
(3)在 Java 堆中生成一个代表这个类的 java.lang.Class 对象,作为对方法区中这些数据的访问入口说白了就是类文件被类装载器装载进来之后,类中的内容(比如变量,常量,方法,对象等这些数据得要有个去处,也就是要存储起来,存储的位置肯定是在 JVM 中有对应的空间)

官网概括

官网:https://docs.oracle.com/javase/specs/jvms/se8/html/index.html

The Java Virtual Machine defines various run-time data areas that are used during execution of a program. Some of these data areas are created on Java Virtual Machine start-up and are destroyed only when the Java Virtual Machine exits. Other data areas are per thread. Per-thread data areas are created when a thread is created and destroyed when the thread exits

图解

Each run-time constant pool is allocated from the Java Virtual Machine's method area (§2.5.4).s

初步认识

Method Area(方法区)

  • 方法区是各个线程共享的内存区域,在虚拟机启动时创建

The Java Virtual Machine has a method area that is shared among all Java Virtual Machine threads.The method area is created on virtual machine start-up

  • 虽然 Java 虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却又一个别名叫做 Non-Heap(非堆),目的是与 Java 堆区分开来

Althoughthemethodareaislogicallypartoftheheap,......

  • 用于存储已被虚拟机加载的类信息,常量,静态变量,即时编译器编译后的代码等数据

Itstoresper-classstructuressuchastherun-timeconstantpool,fieldandmethoddata,andthecodeformethodsandconstructors,includingthespecialmethods(§2.9)usedinclassandinstanceinitializationandinterfaceinitialization

  • 当方法区无法满足内存分配需求时,将抛出 OutOfMemoryError 异常

Ifmemoryinthemethodareacannotbemadeavailabletosatisfyanallocationrequest,theJavaVirtualMachinethrowsanOutOfMemoryError.
此时回看装载阶段的第2步,将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
如果这时候把从 Class 文件到装载的第(1)和(2)步合并起来理解的话,可以画个图

值得说明的
JVM 运行时数据区是一种规范,真正的实现
在 JDK8 中就是 Metaspace,在 JDK6 或 7 中就是 PermSpace

Heap(堆)

  • Java 堆是 Java 虚拟机所管理内存中最大的一块,在虚拟机启动时创建,被所有线程共享
  • Java 对象实例以及数组都在堆上分配

TheJavaVirtualMachinehasaheapthatissharedamongallJavaVirtualMachinethreads.Theheapistherun-timedataareafromwhichmemoryforallclassinstancesandarraysisallocated.Theheapiscreatedonvirtualmachinestart-up

此时回看装载阶段的第 3 步,在 Java 堆中生成一个代表这个类的 java.lang.Class 对象,作为对方法区中这些数据的访问入口
此时装载(1)(2)(3)的图可以改动一下


Java Virtual Machine Stacks(虚拟机栈)

经过上面的分析,类加载机制的装载过程已经完成,后续的链接,初始化也会相应的生效
假如目前的阶段是初始化完成了,后续做啥呢?肯定是 Use 使用咯,不用的话这样折腾来折腾去有什么意义?那怎样才能被使用到?换句话说里面内容怎样才能被执行?比如通过主函数 main 调用其他方法,这种方式实际上是 main 线程执行之后调用的方法,即要想使用里面的各种内容,得要以线程为单位,执行相应的方法才行.那一个线程执行的状态如何维护?一个线程可以执行多少个方法?这样的关系怎么维护呢?

  • 虚拟机栈是一个线程执行的区域,保存着一个线程中方法的调用状态.换句话说,一个 Java 线程的运行状态,由一个虚拟机栈来保存,所以虚拟机栈肯定是线程私有的,独有的,随着线程的创建而创建

EachJavaVirtualMachinethreadhasaprivateJavaVirtualMachinestack,createdatthesametimeasthethread

  • 每一个被线程执行的方法,为该栈中的栈帧,即每个方法对应一个栈帧调用一个方法,就会向栈中压入一个栈帧;一个方法调用完成,就会把该栈帧从栈中弹出

AJavaVirtualMachinestackstoresframes(§2.6).Anewframeiscreatedeachtimeamethodisinvoked.Aframeisdestroyedwhenitsmethodinvocationcompletes

图解栈和栈帧

void a(){
    b();
}

void b(){
    c();
}

void c(){
}

栈帧

官网:https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-2.html#jvms-2.6
栈帧:每个栈帧对应一个被调用的方法,可以理解为一个方法的运行空间
每个栈帧中包括局部变量表(LocalVariables),操作数栈(OperandStack),指向运行时常量池的引用(Areferencetotherun-timeconstantpool),方法返回地址(ReturnAddress)和附加信息

局部变量表:方法中定义的局部变量以及方法的参数存放在这张表中
局部变量表中的变量不可直接使用,如需要使用的话,必须通过相关指令将其加载至操作数栈中作为操作数使用

操作数栈:以压栈和出栈的方式存储操作数的动态链接:每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接(DynamicLinking)

方法返回地址:当一个方法开始执行后,只有两种方式可以退出,一种是遇到方法返回的字节码指令;一种是遇见异常,并且这个异常没有在方法体内得到处理

结合字节码指令理解栈帧

javap -c Person.class > Person.txt
Compiled from "Person.java"
class Person {
    ...
    public static int calc(int, int);
    Code: 0: iconst_3 //将 int 类型常量 3 压入[操作数栈]
    1: istore_0 //将 int 类型值存入[局部变量 0]
    2: iload_0 //从[局部变量 0]中装载 int 类型值入栈
    3: iload_1 //从[局部变量 1]中装载 int 类型值入栈
    4: iadd //将栈顶元素弹出栈,执行 int 类型的加法,结果入栈
    5: istore_2 //将栈顶 int 类型值保存到[局部变量 2]中
    6: iload_2 //从[局部变量 2]中装载 int 类型值入栈
    7: ireturn //从方法中返回 int 类型的数据
    ...
}

思考:index 的值是 0 还是 1

On class method invocation, any parameters are passed in consecutive local variables starting from local variable 0. On instance method invocation, local variable 0 is always used to pass a reference to the object on which the instance method is being invoked (this in the Java programming language). Any parameters are subsequently passed in consecutive local variables starting from local variable 1

The pc Register(程序计数器)

我们都知道一个 JVM 进程中有多个线程在执行,而线程中的内容是否能够拥有执行权,是根据 CPU 调度来的
假如线程 A 正在执行到某个地方,突然失去了 CPU 的执行权,切换到线程 B 了,然后当线程 A 再获得 CPU 执行权的时候,怎么能继续执行呢?这就是需要在线程中维护一个变量,记录线程执行到的位置

如果线程正在执行 Java 方法,则计数器记录的是正在执行的虚拟机字节码指令的地址;
如果正在执行的是 Native 方法,则这个计数器为空

The Java Virtual Machine can support many threads of execution at once (JLS §17). Each Java Virtual Machine thread has its own pc (program counter)register. At any point, each Java Virtual Machine thread is executing the code of a single method, namely the current method (§2.6)for that thread. If that method is not native, the pc register contains the address of the Java Virtual Machine instruction currently being executed. If the method currently being executed by the thread is native, the value of the Java Virtual Machine's pc register is undefined. The Java Virtual Machine's pc register is wide enough to hold a returnAddress or a native pointer on the specific platform

Native Method Stacks(本地方法栈)

如果当前线程执行的方法是 Native 类型的,这些方法就会在本地方法栈中执行
那如果在 Java 方法执行的时候调用 native 的方法呢?


除了上面五块内存之外,其实我们的 JVM 还会使用到其他两块内存

  • 直接内存(DirectMemory)

并不是虚拟机运行时数据区的一部分,也不是 JVM 规范中定义的内存区域,但是这部分内存也被频繁地使用,而且也可能导致 OutOfMemoryError 异常出现,所以我们放到这里一起讲解.在 JDK1.4 中新加入了 NIO(NewInput/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的 I/O 方式,它可以使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆里面的 DirectByteBuffer 对象作为这块内存的引用进行操作.这样能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆中来回复制数据
本机直接内存的分配不会受到 Java 堆大小的限制,但是,既然是内存,则肯定还是会受到本机总内存的大小及处理器寻址空间的限制.因此在分配 JVM 空间的时候应该考虑直接内存所带来的影响,特别是应用到 NIO 的场景

  • 其他内存:

CodeCache:JVM 本身是个本地程序,还需要其他的内存去完成各种基本任务,比如,JIT 编译器在运行时对热点方法进行编译,就会将编译后的方法储存在 CodeCache 里面;GC 等功能.需要运行在本地线程之中,类似部分都需要占用内存空间.这些是实现 JVMJIT 等功能的需要,但规范中并不涉及

折腾一下

栈指向堆

如果在栈帧中有一个变量,类型为引用类型,比如Objectobj=newObject(),这时候就是典型的栈中元素指向堆中的对象

方法区指向堆

方法区中会存放静态变量,常量等数据.如果是下面这种情况,就是典型的方法区中元素指向堆中的对象

private static Object obj=new Object();

堆指向方法区

What?堆还能指向方法区?
注意,方法区中会包含类的信息,堆中会有对象,那怎么知道对象是哪个类创建的呢?


思考:
一个对象怎么知道它是由哪个类创建出来的?怎么记录?这就需要了解一个 Java 对象的具体信息咯

Java 对象内存模型

一个 Java 对象在内存中包括 3 个部分:对象头,实例数据和对齐填充


验证 hashCode 的储存方式

使用到我们的 jol 工具

依赖:

<dependency>
    <groupId>org.openjdk.jol</groupId>
    <artifactId>jol-core</artifactId>
    <version>RELEASE</version>
</dependency>

实体类:

public class Worker {
    private Integer id;
    private String username;
    private String password;

    public Integer getId(){
        return id;
    }

    public String getPassword(){
        return password;
    }

    public String getUsername(){
        return username;
    }

    public void setUsername(String username){
        this.username = username;
    }

    public void setPassword(String password){
        this.password = password;
    }

    public void setId(Integer id){
        this.id = id;
    }

    @Override
    public String toString(){
        return super.toString();
    }

    public static void printf(Worker p){
        // 查看对象的整体结构信息
        // JOL 工具类
        System.out.println(ClassLayout.parseInstance(p).toPrintable());
    }
}

测试代码:

package com.example.jvmcase.basic;
import com.example.jvmcase.domain.Worker;

public class Test {
    public static void main(String[] args){
        Worker work = new Worker();
        System.out.println(work);
        Worker.printf(work);
        System.out.println(work.hashCode());
    }
}

测试结果:

com.example.jvmcase.domain.Worker@6acbcfc0
com.example.jvmcase.domain.Worker object internals:
OFFSET  SIZE          TYPE DESCRIPTION                                     VALUE
0       4  (object header)01 c0 cf cb (00000001 11000000 11001111 11001011)(-875577343)
4       4  (object header)6a 00 00 00 (01101010 00000000 00000000 00000000)(106)
8       4  (object header)43 c1 00 f8 (01000011 11000001 00000000 11111000)(-134168253)
12      4  java.lang.Integer Worker.id                                     null
16      4  java.lang.String Worker.username                                 null
20      4  java.lang.String Worker.password                                 null
Instance size: 24 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total

1791741888

1791741888 这个数字是我们的 HashCode 值,转换成 16 进制可得 6acbcfc0,经过对比

由此可得,我们的哈希码使用的大端储存

例如:
十进制数9877,如果用小端存储表示则为:
高地址<--------低地址
10010101[高序字节]00100110[低序字节]
用大端存储表示则为:
高地址<--------低地址
00100110[低序字节]10010101[高序字节]

小端存储:便于数据之间的类型转换,例如:long 类型转换为 int 类型时,高地址部分的数据可以直接截掉
大端存储:便于数据类型的符号判断,因为最低地址位数据即为符号位,可以直接判断数据的正负号

ClassPointer
引用定位到对象的方式有两种,一种叫句柄池访问,一种叫直接访问



区别:

  • 句柄池:使用句柄访问对象,会在堆中开辟一块内存作为句柄池,句柄中储存了对象实例数据(属性值结构体)的内存地址,访问类型数据的内存地址(类信息,方法类型信息),对象实例数据一般也在 heap 中开辟,类型数据一般储存在方法区中

*优点:reference 存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而 reference 本身不需要改变
*缺点:增加了一次指针定位的时间开销

  • 直接访问:直接指针访问方式指 reference 中直接储存对象在 heap 中的内存地址,但对应的类型数据访问地址需要在实例中存储

*优点:节省了一次指针定位的开销
*缺点:在对象被移动时(如进行 GC 后的内存重新排列),reference 本身需要被修改

  • 指针压缩:
    在 32 位系统中,类型指针为 4 字节 32 位,在 64 位系统中类型指针为 8 字节 64 位,但是 JVM 会默认的进行指针压缩,所以我们上图输出结果中类型指针也是 4 字节 32 位.如果我们关闭指针压缩的话,就可以看到 64 位的类型指针了,所以我们通常在部署服务时,JVM 内存不要超过 32G,因为超过 32G 就无法开启指针压缩了

关闭指针压缩:-XX:+UseCompressedOops

  • 对齐填充

没有对齐填充就可能会存在数据跨内存地址区域存储的情况

在没有对齐填充的情况下,内存地址存放情况如下:


因为处理器只能 0x00-0x07,0x08-0x0F 这样读取数据,所以当我们想获取这个 long 型的数据时,处理器必须要读两次内存,第一次(0x00-0x07),第二次(0x08-0x0F),然后将两次的结果才能获得真正的数值

那么在有对齐填充的情况下,内存地址存放情况是这样的:


现在处理器只需要直接一次读取(0x08-0x0F)的内存地址就可以获得我们想要的数据了
对齐填充存在的意义就是为了提高 CPU 访问数据的效率,这是一种以空间换时间的做法;正如我们所见,虽然访问效率提高了(减少了内存访问次数),但是在 0x07 处产生了 1bit 的空间浪费

JVM 内存模型

与运行时数据区

上面对运行时数据区描述了很多,其实重点存储数据的是堆和方法区(非堆),所以内存的设计也着重从这两方面展开(注意这两块区域都是线程共享的)
对于虚拟机栈,本地方法栈,程序计数器都是线程私有的
可以这样理解,JVM 运行时数据区是一种规范,而 JVM 内存模式是对该规范的实现

图形展示

一块是非堆区,一块是堆区
堆区分为两大块,一个是 Old 区,一个是 Young 区
Young 区分为两大块,一个是 Survivor 区(S0+S1),一块是 Eden 区
S0 和 S1 一样大,也可以叫 From 和 To

对象创建过程

一般情况下,新创建的对象都会被分配到 Eden 区,一些特殊的大的对象会直接分配到 Old 区

我是一个普通的 Java 对象,我出生在 Eden 区,在 Eden 区我还看到和我长的很像的小兄弟,我们在 Eden 区中玩了挺长时间.有一天 Eden 区中的人实在是太多了,我就被迫去了 Survivor 区的“From”区,自从去了 Survivor 区,我就开始漂了,有时候在 Survivor 的“From”区,有时候在 Survivor 的“To”区,居无定所.直到我 18 岁的时候,爸爸说我成人了,该去社会上闯闯了.于是我就去了年老代那边,年老代里,人很多,并且年龄都挺大的

常见问题

如何理解各种 GC

  • PartialGC
    Partial 其实也就是部分的意思.那么翻译过来也就是回收部分 GC 堆的模式,他并不会回收我们整个堆.而我们的 youngGC 以及我们的 OldGC 都属于这种模式
  • youngGC:只回收 young 区
  • oldGC:只回收 Old 区
  • fullGC:实际上就是对于整体回收

为什么需要 Survivor 区?只有 Eden 不行吗?
如果没有 Survivor,Eden 区每进行一次 MinorGC,存活的对象就会被送到老年代
这样一来,老年代很快被填满,触发 MajorGC(因为 MajorGC 一般伴随着 MinorGC,也可以看做触发了 FullGC)
老年代的内存空间远大于新生代,进行一次 FullGC 消耗的时间比 MinorGC 长得多
执行时间长有什么坏处?频发的 FullGC 消耗的时间很长,会影响大型程序的执行和响应速度
可能你会说,那就对老年代的空间进行增加或者较少咯
假如增加老年代空间,更多存活对象才能填满老年代.虽然降低 FullGC 频率,但是随着老年代空间加大,一旦发生 FullGC,执行所需要的时间更长
假如减少老年代空间,虽然 FullGC 所需时间减少,但是老年代很快被存活对象填满,FullGC 频率增加
所以 Survivor 的存在意义,就是减少被送到老年代的对象,进而减少 FullGC 的发生,Survivor 的预筛选保证,只有经历 16 次 MinorGC 还能在新生代中存活的对象,才会被送到老年代

为什么需要两个 Survivor 区?
最大的好处就是解决了碎片化.也就是说为什么一个 Survivor 区不行?第一部分中,我们知道了必须设置 Survivor 区.假设现在只有一个 Survivor 区,我们来模拟一下流程:
刚刚新建的对象在 Eden 中,一旦 Eden 满了,触发一次 MinorGC,Eden 中的存活对象就会被移动到 Survivor 区.这样继续循环下去,下一次 Eden 满了的时候,问题来了,此时进行 MinorGC,Eden 和 Survivor 各有一些存活对象,如果此时把 Eden 区的存活对象硬放到 Survivor 区,很明显这两部分对象所占有的内存是不连续的,也就导致了内存碎片化.永远有一个 Survivorspace 是空的,另一个非空的 Survivorspace 无碎片

新生代中 Eden:S1:S2 为什么是 8:1:1?
新生代中的可用内存:复制算法用来担保的内存为9:1
可用内存中 Eden:S1 区为 8:1
即新生代中 Eden:S1:S2=8:1:1
现代的商业虚拟机都采用这种收集算法来回收新生代,IBM 公司的专门研究表明,新生代中的对象大概 98%是“朝生夕死”的

堆内存中都是线程共享的区域吗?
JVM 默认为每个线程在 Eden 上开辟一个 buffer 区域,用来加速对象的分配,称之为 TLAB,全称:ThreadLocalAllocationBuffer
对象优先会在 TLAB 上分配,但是 TLAB 空间通常会比较小,如果对象比较大,那么还是在共享区域分配

体验与验证

使用 visualvm

visualgc 插件下载链接:https://visualvm.github.io/pluginscenters.html
选择对应 JDK 版本链接--->Tools--->VisualGC
若上述链接找不到合适的,大家也可以自己在网上下载对应的版本

堆内存溢出

代码

@RestController
public class HeapController {
    List<Person> list = new ArrayList<Person>();

    @GetMapping("/heap")
    public String heap(){
        while(true){
            list.add(new Person());
        }
    }
}

记得设置参数比如-Xmx20M-Xms20M

运行结果
访问:http://localhost:8080/heap

Exception in thread "http-nio-8080-exec-2" java.lang.OutOfMemoryError: GC overhead limit exceeded

方法区内存溢出

比如向方法区中添加 Class 的信息

  • asm 依赖和 Class 代码
<dependency>
    <groupId>asm</groupId>
    <artifactId>asm</artifactId>
    <version>3.3.1</version>
</dependency>
public class MyMetaspace extends ClassLoader {
    public static List<Class<?>> createClasses(){
        List<Class<?>> classes = new ArrayList<Class<?>>();

        for (int i = 0; i < 10000000; ++i){
            ClassWriter cw = new ClassWriter(0);
            cw.visit(Opcodes.V1_1, Opcodes.ACC_PUBLIC, "Class" + i, null, "java/lang/Object", null);
            MethodVisitor mw = cw.visitMethod(Opcodes.ACC_PUBLIC, "<init>", "()V", null, null);
            mw.visitVarInsn(Opcodes.ALOAD, 0);
            mw.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Object", "<init>", "()V");
            mw.visitInsn(Opcodes.RETURN);
            mw.visitMaxs(1, 1);
            mw.visitEnd();
            Metaspace test = new Metaspace();
            byte[] code = cw.toByteArray();
            Class<?> exampleClass = test.defineClass("Class" + i, code, 0, code.length);
            classes.add(exampleClass);
        }
        return classes;
    }
}


@RestController
public class NonHeapController {
    List<Class<?>> list = new ArrayList<Class<?>>();

    @GetMapping("/nonheap")
    public String nonheap(){
        while(true){
            list.addAll(MyMetaspace.createClasses());
        }
    }
}

设置 Metaspace 的大小,比如-XX:MetaspaceSize=50M-XX:MaxMetaspaceSize=50M

运行结果
访问->http://localhost:8080/nonheap

java.lang.OutOfMemoryError: Metaspace
    at java.lang.ClassLoader.defineClass1(Native Method)~[na:1.8.0_191]
    at java.lang.ClassLoader.defineClass(ClassLoader.java:763)~[na:1.8.0_191]

虚拟机栈

代码演示 StackOverFlow

public class StackDemo {
    public static long count=0;

    public static void method(long i){
        System.out.println(count++);
        method(i);
    }

    public static void main(String[] args){
        method(1);
    }
}

运行结果


说明

StackSpace 用来做方法的递归调用时压入 StackFrame(栈帧).所以当递归调用太深的时候,就有可能耗尽 StackSpace,爆出 StackOverflow 的错误
-Xss128k:设置每个线程的堆栈大小.JDK5 以后每个线程堆栈大小为 1M,以前每个线程堆栈大小为 256K.根据应用的线程所需内存大小进行调整.在相同物理内存下,减小这个值能生成更多的线程.但是操作系统对一个进程内的线程数还是有限制的,不能无限生成,经验值在 3000~5000 左右
线程栈的大小是个双刃剑,如果设置过小,可能会出现栈溢出,特别是在该线程内有递归,大的循环时出现溢出的可能性更大,如果该值设置过大,就有影响到创建栈的数量,如果是多线程的应用,就会出现内存溢出的错误

对象的生命周期

Java 中的引用的定义很传统:如果 reference(引用)类型的数据中存储的数值代表的是另外一块内存的起始地址,就称这块内存代表着一个引用.这种定义很纯粹,但是太过狭隘,一个对象在这种定义下只有被引用或者没有被引用两种状态,对于如何描述一些处于判刑中又或者我们想扔又舍不得的对象就显得无能为力
我们希望能描述这样一类对象:当内存空间还足够时,则能保留在内存之中;如果内存空间在进行垃圾收集后还是非常紧张,则可以抛弃这些对象.很多系统的缓存功能都符合这样的应用场景
那么其实我们可以想到,我们的对象的生命周期中一定会有很多种的状态,然后我们会在各个状态都给出一个相对应的描述

在 Java 中,对象的生命周期包括以下几个阶段:

  • 创建阶段(Created)
  • 应用阶段(InUse)
  • 不可见阶段(Invisible)
  • 不可达阶段(Unreachable)
  • 收集阶段(Collected)
  • 终结阶段(Finalized)
  • 对象空间重分配阶段(De-allocated)

在聊这个之前,我觉得我们应该先聊一聊对象的引用,一般来说,我们的引用有四种

强引用

在 Java 中最常见的就是强引用,也是我们在开发过程中经常会使用到的引用.把一个对象赋给一个引用变量,这个引用变量就是一个强引用.当一个对象被强引用变量引用时,它处于可达状态,它是不可能被垃圾回收机制回收的,即使该对象以后永远都不会被用到 JVM 也不会回收.因此强引用是造成 Java 内存泄漏的主要原因之一

软引用

软引用需要用 SoftReference 类来实现,对于只有软引用的对象来说,当系统内存足够时它不会被回收,当系统内存空间不足时它会被回收.软引用通常用在对内存敏感的程序中

软引用的常见使用:
package com.example.jvmcase.reference;
import com.example.jvmcase.domain.Worker;
import org.hibernate.jdbc.Work;
import java.lang.ref.SoftReference;

public class SoftReferenceDemo {
    public static void main(String[] args){
        //。。。一堆业务代码
        Worker a = new Worker();
        //。。业务代码使用到了我们的 Worker 实例
        // 使用完了 a,将它设置为 soft 引用类型,并且释放强引用;
        SoftReference sr = new SoftReference(a);
        a = null;

        // 下次使用时
        if (sr != null){
            a = (Worker)sr.get();
        } else {
            // GC 由于内存资源不足,可能系统已回收了 a 的软引用,
            // 因此需要重新装载
            a = new Worker();
            sr = new SoftReference(a);
        }
    }
}

弱引用

弱引用需要用 WeakReference 类来实现,它比软引用的生存期更短,对于只有弱引用的对象来说,只要垃圾回收机制一运行,不管 JVM 的内存空间是否足够,总会回收该对象占用的内存

public class WeakReferenceDemo {
    public static void main(String[] args)throws InterruptedException {
        //100M 的缓存数据
        byte[] cacheData = new byte[100 * 1024 * 1024];
        //将缓存数据用软引用持有
        WeakReference<byte[]> cacheRef = new WeakReference<>(cacheData);
        System.out.println("第一次 GC 前" + cacheData);
        System.out.println("第一次 GC 前" + cacheRef.get());

        //进行一次 GC 后查看对象的回收情况
        System.gc();
        //等待 GC
        Thread.sleep(500);
        System.out.println("第一次 GC 后" + cacheData);
        System.out.println("第一次 GC 后" + cacheRef.get());

        //将缓存数据的强引用去除
        cacheData = null;
        System.gc();
        //等待 GC
        Thread.sleep(500);

        System.out.println("第二次 GC 后" + cacheData);
        System.out.println("第二次 GC 后" + cacheRef.get());
    }
}

第一次 GC 前[B@7d4991ad
第一次 GC 前[B@7d4991ad
第一次 GC 后[B@7d4991ad
第一次 GC 后[B@7d4991ad
第二次 GC 后 null
第二次 GC 后 null

虚引用

虚引用需要 PhantomReference 类来实现,它不能单独使用,必须和引用队列联合使用.虚引用的主要作用是跟踪对象被垃圾回收的状态

创建阶段

其实我们在探讨类加载的时候就已经探讨了一部分对象创建的情况

应用阶段( In Use)

对象至少被一个强引用持有着.

不可见阶段( Invisible)

当一个对象处于不可见阶段时,说明程序本身不再持有该对象的任何强引用,虽然该这些引用仍然是存在着的
简单说就是程序的执行已经超出了该对象的作用域了

不可达阶段( Unreachable)

对象处于不可达阶段是指该对象不再被任何强引用所持有
与“不可见阶段”相比,“不可见阶段”是指程序不再持有该对象的任何强引用,这种情况下,该对象仍可能被 JVM 等系统下的某些已装载的静态变量或线程或 JNI 等强引用持有着,这些特殊的强引用被称为”GCroot”.存在着这些 GCroot 会导致对象的内存泄露情况,无法被回收

收集阶段( Collected)

当垃圾回收器发现该对象已经处于“不可达阶段”并且垃圾回收器已经对该对象的内存空间重新分配做好准备时,则对象进入了“收集阶段”.如果该对象已经重写了 finalize()方法,则会去执行该方法的终端操作
这里要特别说明一下:不要重载 finazlie()方法!原因有两点:
会影响 JVM 的对象分配与回收速度
在分配该对象时,JVM 需要在垃圾回收器上注册该对象,以便在回收时能够执行该重载方法;在该方法的执行时需要消耗 CPU 时间且在执行完该方法后才会重新执行回收操作,即至少需要垃圾回收器对该对象执行两次 GC

可能造成该对象的再次“复活”
在 finalize()方法中,如果有其它的强引用再次持有该对象,则会导致对象的状态由“收集阶段”又重新变为“应用阶段”.这个已经破坏了 Java 对象的生命周期进程,且“复活”的对象不利用后续的代码管理

终结阶段

当对象执行完 finalize()方法后仍然处于不可达状态时,则该对象进入终结阶段.在该阶段是等待垃圾回收器对该对象空间进行回收

对象空间重新分配阶段

垃圾回收器对该对象的所占用的内存空间进行回收或者再分配了,则该对象彻底消失了,称之为“对象空间重新分配阶段”


什么时候会垃圾回收

GC 是由 JVM 自动完成的,根据 JVM 系统环境而定,所以时机是不确定的
当然,我们可以手动进行垃圾回收,比如调用 System.gc()方法通知 JVM 进行一次垃圾回收,但是具体什么时刻运行也无法控制.也就是说 System.gc()只是通知要回收,什么时候回收由 JVM 决定.但是不建议手动调用该方法,因为 GC 消耗的资源比较大

  • 当 Eden 区或者 S 区不够用了
  • 老年代空间不够用了
  • 方法区空间不够用了
  • System.gc()

垃圾收集算法

已经能够确定一个对象为垃圾之后,接下来要考虑的就是回收,怎么回收呢?得要有对应的算法,下面介绍常见的垃圾回收算法

标记-清除(Mark-Sweep)

  • 标记
    找出内存中需要回收的对象,并且把它们标记出来
    此时堆中所有的对象都会被扫描一遍,从而才能确定需要回收的对象,比较耗时


  • 清除
    清除掉被标记需要回收的对象,释放出对应的内存空间


缺点

标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程
序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作
*标记和清除两个过程都比较耗时,效率不高
*会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作.

标记-复制(Mark-Copying)

将内存划分为两块相等的区域,每次只使用其中一块,如下图所示:


当其中一块内存使用完了,就将还存活的对象复制到另外一块上面,然后把已经使用过的内存空间一次清除掉
![](https://huaweirookie.oss-cn-shenzhen.aliyuncs.com/20210329135628.png

缺点:空间利用率降低

标记-整理(Mark-Compact)

复制收集算法在对象存活率较高时就要进行较多的复制操作,效率将会变低.更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都有100%存活的极端情况,所以老年代一般不能直接选用这种算法
标记过程仍然与"标记-清除"算法一样,但是后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存
其实上述过程相对"复制算法"来讲,少了一个"保留区"


让所有存活的对象都向一端移动,清理掉边界意外的内存


分代收集算法

既然上面介绍了3中垃圾收集算法,那么在堆内存中到底用哪一个呢?

  • Young 区:复制算法(对象在被分配之后,可能生命周期比较短,Young 区复制效率比较高)
  • Old 区:标记清除或标记整理(Old 区对象存活时间比较长,复制来复制去没必要,不如做个标记再清理)

JVM 参数

标准参数

-version
-help
-server
-cp

-X 参数

非标准参数,也就是在 JDK 各个版本中可能会变动

-Xint 解释执行
-Xcomp 第一次使用就编译成本地代码
-Xmixed 混合模式,JVM 自己来决定

-XX 参数

使用得最多的参数类型
非标准化参数,相对不稳定,主要用于 JVM 调优和 Debug

a.Boolean 类型
格式:-XX:[+-]<name>+或-表示启用或者禁用 name 属性
比如:-XX:+UseConcMarkSweepGC 表示启用 CMS 类型的垃圾回收器
-XX:+UseG1GC 表示启用 G1 类型的垃圾回收器

b.非 Boolean 类型
格式:-XX<name>=<value>表示 name 属性的值是 value
比如:-XX:MaxGCPauseMillis=500

其他参数

-Xms1000M 等价于-XX:InitialHeapSize=1000M
-Xmx1000M 等价于-XX:MaxHeapSize=1000M
-Xss100 等价于-XX:ThreadStackSize=100

所以这块也相当于是-XX 类型的参数

查看参数

java-XX:+PrintFlagsFinal-version>flags.txt


值得注意的是"="表示默认值,":="表示被用户或 JVM 修改后的值要想查看某个进程具体参数的值,可以使用 jinfo,这块后面聊一般要设置参数,可以先查看一下当前参数是什么,然后进行修改

设置参数的常见方式

开发工具中设置比如 IDEA,eclipse
运行 jar 包的时候:java-XX:+UseG1GCxxx.jar
web 容器比如 tomcat,可以在脚本中的进行设置
通过 jinfo 实时调整某个 java 进程的参数(参数只有被标记为 manageable 的 flags 可以被实时修改)

实践和单位换算

1Byte(字节)=8bit(位)
1KB=1024Byte(字节)
1MB=1024KB
1GB=1024MB
1TB=1024GB

设置堆内存大小和参数打印-Xmx100M-Xms100M-XX:+PrintFlagsFinal
查询+PrintFlagsFinal 的值:=true
查询堆内存大小 MaxHeapSize:=104857600
换算 104857600(Byte)/1024=102400(KB)102400(KB)/1024=100(MB)
结论104857600是字节单位

常用参数含义

参数 含义 说明
-XX:CICompilerCount=3 最大并行编译数 如果设置大于 1,虽然编译速度会提高,但是同样影响系统稳定性,会增加 JVM 崩溃的可能
-XX:InitialHeapSize=100M 初始化堆大小 简写-Xms100M
-XX:MaxHeapSize=100M 最大堆大小 简写-Xms100M
-XX:NewSize=20M 设置年轻代的大小
-XX:MaxNewSize=50M 年轻代最大大小
-XX:OldSize=50M 设置老年代大小
-XX:MetaspaceSize=50M 设置方法区大小
-XX:MaxMetaspaceSize=50M 方法区最大大小
-XX:+UseParallelGC 使用 UseParallelGC 新生代,吞吐量优先
-XX:+UseParallelOldGC 使用 UseParallelOldGC 老年代,吞吐量优先
-XX:+UseConcMarkSweepGC 使用 CMS 老年代,停顿时间优先
-XX:+UseG1GC 使用 G1GC 新生代,老年代,停顿时间优先
-XX:NewRatio 新老生代的比值 比如-XX:Ratio=4,则表示新生代:老年代=1:4,也就是新生代占整个堆内存的 1/5
-XX:SurvivorRatio 两个 S 区和 Eden 区的比值 比如-XX:SurvivorRatio=8,也就是(S0+S1):Eden=2:8,也就是一个 S 占整个新生代的 1/10
-XX:+HeapDumpOnOutOfMemoryError 启动堆内存溢出打印 当 JVM 堆内存发生溢出时,也就是 OOM,自动生成 dump 文件
-XX:HeapDumpPath=heap.hprof 指定堆内存溢出打印目录 表示在当前目录生成一个 heap.hprof 文件
-XX:+PrintGCDetails-XX:+PrintGCTimeStamps-XX:+PrintGCDateStamps-Xloggc:g1-gc.log 打印出 GC 日志 可以使用不同的垃圾收集器,对比查看 GC 情况
-Xss128k 设置每个线程的堆栈大小 经验值是 3000-5000 最佳
-XX:MaxTenuringThreshold=6 提升年老代的最大临界值 默认值为 15
-XX:InitiatingHeapOccupancyPercent 启动并发 GC 周期时堆内存使用占比 G1 之类的垃圾收集器用它来触发并发 GC 周期,基于整个堆的使用率,而不只是某一代内存的使用比.值为 0 则表示”一直执行 GC 循环”.默认值为 45
-XX:G1HeapWastePercent 允许的浪费堆空间的占比 默认是 10%,如果并发标记可回收的空间小于 10%,则不会触发 MixedGC
-XX:MaxGCPauseMillis=200ms G1 最大停顿时间 暂停时间不能太小,太小的话就会导致出现 G1 跟不上垃圾产生的速度.最终退化成 FullGC.所以对这个参数的调优是一个持续的过程,逐步调整到最佳状态
-XX:ConcGCThreads=n 并发垃圾收集器使用的线程数量 默认值随 JVM 运行的平台不同而不同
-XX:G1MixedGCLiveThresholdPercent=65 混合垃圾回收周期中要包括的旧区域设置占用率阈值 默认占用率为 65%
-XX:G1MixedGCCountTarget=8 设置标记周期完成后,对存活数据上限为 G1MixedGCLIveThresholdPercent 的旧区域执行混合垃圾回收的目标次数 默认 8 次混合垃圾回收,混合回收的目标是要控制在此目标次数以内
-XX:G1OldCSetRegionThresholdPercent=1 描述 MixedGC 时,OldRegion 被加入到 CSet 中 默认情况下,G1 只把 10%的 OldRegion 加入到 CSet 中

垃圾收集器

如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现


Serial

Serial 收集器是最基本,发展历史最悠久的收集器,曾经(在 JDK1.3.1 之前)是虚拟机新生代收集的唯一选择
它是一种单线程收集器,不仅仅意味着它只会使用一个 CPU 或者一条收集线程去完成垃圾收集工作,更重要的是其在进行垃圾收集的时候需要暂停其他线程

优点:简单高效,拥有很高的单线程收集效率
缺点:收集过程需要暂停所有线程
算法:复制算法
适用范围:新生代
应用:Client 模式下的默认新生代收集器


Serial Old

SerialOld 收集器是 Serial 收集器的老年代版本,也是一个单线程收集器,不同的是采用"标记-整理算法",运行过程和 Serial 收集器一样


ParNew

可以把这个收集器理解为 Serial 收集器的多线程版本

优点:在多 CPU 时,比 Serial 效率高
缺点:收集过程暂停所有应用程序线程,单 CPU 时比 Serial 效率差
算法:复制算法
适用范围:新生代
应用:运行在 Server 模式下的虚拟机中首选的新生代收集器


Parallel Scavenge

ParallelScavenge 收集器是一个新生代收集器,它也是使用复制算法的收集器,又是并行的多线程收集器,看上去和 ParNew 一样,但是 ParallelScanvenge 更关注系统的吞吐量

吞吐量=运行用户代码的时间/(运行用户代码的时间+垃圾收集时间)
比如虚拟机总共运行了100分钟,垃圾收集时间用了1分钟,吞吐量=(100-1)/100=99%
若吞吐量越大,意味着垃圾收集的时间越短,则用户代码可以充分利用 CPU 资源,尽快完成程序的运算任务

Parallel Old

ParallelOld 收集器是 ParallelScavenge 收集器的老年代版本,使用多线程和标记-整理算法进行垃圾回收,也是更加关注系统的吞吐量

CMS

官网:https://docs.oracle.com/javase/8/docs/technotes/guides/vm/gctuning/cms.html#concurrent_mark_sweep_cms_collector

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器,采用的是"标记-清除算法",整个过程分为 4 步

初始标记 CMSinitialmark 标记 GCRoots 直接关联对象,不用 Tracing,速度很快
并发标记 CMSconcurrentmark 进行 GCRootsTracing
重新标记 CMSremark 修改并发标记因用户程序变动的内容
并发清除 CMSconcurrentsweep 清除不可达对象回收空间,同时有新垃圾产生,留着下次清理称为浮动垃圾

由于整个过程中,并发标记和并发清除,收集器线程可以与用户线程一起工作,所以总体上来说,CMS 收集器的内存回收过程是与用户线程一起并发地执行的


优点:并发收集,低停顿
缺点:产生大量空间碎片,并发阶段会降低吞吐量,还会并发失败

backgroud 模式为正常模式执行上述的 CMSGC 流程
forefroud 模式为 FullGC 模式

相关参数:
//开启 CMS 垃圾收集器
-XX:+UseConcMarkSweepGC
//默认开启,与-XX:CMSFullGCsBeforeCompaction 配合使用
-XX:+UseCMSCompactAtFullCollection
//默认 0 几次 FullGC 后开始整理
-XX:CMSFullGCsBeforeCompaction=0
//辅助 CMSInitiatingOccupancyFraction 的参数,不然 CMSInitiatingOccupancyFraction 只会使用一次就恢复自动调整,也就是开启手动调整
-XX:+UseCMSInitiatingOccupancyOnly
//取值0-100,按百分比回收
-XX:CMSInitiatingOccupancyFraction 默认-1

注意:CMS 并发 GC 不是“fullGC”.HotSpotVM 里对 concurrentcollection 和 fullcollection 有明确的区分.所有带有“FullCollection”字样的 VM 参数都是跟真正的 fullGC 相关,而跟 CMS 并发 GC 无关的

G1(Garbage-First)

官网:https://docs.oracle.com/javase/8/docs/technotes/guides/vm/gctuning/g1_gc.html#garbage_first_garbage_collection

使用 G1 收集器时,Java 堆的内存布局与就与其他收集器有很大差别,它将整个 Java 堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分 Region(不需要连续)的集合
每个 Region 大小都是一样的,可以是 1M 到 32M 之间的数值,但是必须保证是 2 的 n 次幂
如果对象太大,一个 Region 放不下[超过 Region 大小的 50%],那么就会直接放到 H 中
设置 Region 大小:-XX:G1HeapRegionSize=M
所谓 Garbage-Frist,其实就是优先回收垃圾最多的 Region 区域

分代收集(仍然保留了分代的概念)
空间整合(整体上属于“标记-整理”算法,不会导致空间碎片)
可预测的停顿(比 CMS 更先进的地方在于能让使用者明确指定一个长度为 M 毫秒的时间片段内,消耗在垃圾收集上的时间不得超过 N 毫秒)

工作过程可以分为如下几步

初始标记(InitialMarking)标记以下 GCRoots 能够关联的对象,并且修改 TAMS 的值,需要暂停用户线程
并发标记(ConcurrentMarking)从 GCRoots 进行可达性分析,找出存活的对象,与用户线程并发执行
最终标记(FinalMarking)修正在并发标记阶段因为用户程序的并发执行导致变动的数据,需暂停用户线程
筛选回收(LiveDataCountingandEvacuation)对各个 Region 的回收价值和成本进行排序,根据用户所期望的 GC 停顿时间制定回收计划

TLAB


相关参数

-XX:+UseG1GC 开启 G1 垃圾收集器
-XX:G1HeapReginSize 设置每个 Region 的大小,是 2 的幂次,1MB-32MB 之间
-XX:MaxGCPauseMillis 最大停顿时间
-XX:ParallelGCThread 并行 GC 工作的线程数
-XX:ConcGCThreads 并发标记的线程数

-XX:InitiatingHeapOcccupancyPercent 默认 45%,代表 GC 堆占用达到多少的时候开始垃圾收集

ZGC

官网:https://docs.oracle.com/en/java/javase/11/gctuning/z-garbage-collector1.html#GUID-A5A42691-095E-47BA-B6DC-FB4E5FAA43D0

JDK11 新引入的 ZGC 收集器,不管是物理上还是逻辑上,ZGC 中已经不存在新老年代的概念了,会分为一个个 page,当进行 GC 操作时会对 page 进行压缩,因此没有碎片问题
只能在 64 位的 linux 上使用,目前用得还比较少

可以达到 10ms 以内的停顿时间要求
支持 TB 级别的内存
堆内存变大后停顿时间还是在 10ms 以内

垃圾收集器分类

  • 串行收集器->Serial 和 SerialOld
    只能有一个垃圾回收线程执行,用户线程暂停
    适用于内存比较小的嵌入式设备

  • 并行收集器[吞吐量优先]->ParallelScanvenge,ParallelOld
    多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态
    适用于科学计算,后台处理等若交互场景

  • 并发收集器[停顿时间优先]->CMS,G1
    用户线程和垃圾收集线程同时执行(但并不一定是并行的,可能是交替执行的),垃圾收集线程在执行的
    时候不会停顿用户线程的运行
    适用于相对时间有要求的场景,比如 Web

常见问题

  • 吞吐量和停顿时间
    停顿时间->垃圾收集器进行垃圾回收终端应用执行响应的时间
    吞吐量->运行用户代码时间/(运行用户代码时间+垃圾收集时间)

停顿时间越短就越适合需要和用户交互的程序,良好的响应速度能提升用户体验
高吞吐量则可以高效地利用 CPU 时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务

小结:这两个指标也是评价垃圾回收器好处的标准

优先调整堆的大小让服务器自己来选择
如果内存小于 100M,使用串行收集器
如果是单核,并且没有停顿时间要求,使用串行或 JVM 自己选
如果允许停顿时间超过 1 秒,选择并行或 JVM 自己选
如果响应时间最重要,并且不能超过1秒,使用并发收集器

  • 对于 G1 收集
    JDK7 开始使用,JDK8 非常成熟,JDK9 默认的垃圾收集器,适用于新老生代,是否使用 G1 收集器?

50%以上的堆被存活对象占用
对象分配和晋升的速度变化非常大
垃圾回收时间比较长

  • G1 中的 RSet
    全称 RememberedSet,记录维护 Region 中对象的引用关系

试想,在 G1 垃圾收集器进行新生代的垃圾收集时,也就是 MinorGC,假如该对象被老年代的 Region 中所引用,这时候新生代的该对象就不能被回收,怎么记录呢?
不妨这样,用一个类似于 hash 的结构,key 记录 region 的地址,value 表示引用该对象的集合,这样就能知道该对象被哪些老年代的对象所引用,从而不能回收

  • 如何开启需要的垃圾收集器
    这里 JVM 参数信息的设置大家先不用关心,后面会学习到

1.串行
-XX:+UseSerialGC
-XX:+UseSerialOldGC
2.并行(吞吐量优先):
-XX:+UseParallelGC
-XX:+UseParallelOldGC
3.并发收集器(响应时间优先)
-XX:+UseConcMarkSweepGC
-XX:+UseG1GC

JVM参数

标准参数

-version
-help
-server
-cp

-X参数

非标准参数,也就是在JDK各个版本中可能会变动

-Xint 解释执行
-Xcomp 第一次使用就编译成本地代码
-Xmixed 混合模式,JVM自己来决定

-XX参数

使用得最多的参数类型
非标准化参数,相对不稳定,主要用于JVM调优和Debug

a.Boolean类型
格式:-XX:[+-]<name> +或-表示启用或者禁用name属性
比如:-XX:+UseConcMarkSweepGC 表示启用CMS类型的垃圾回收器
-XX:+UseG1GC 表示启用G1类型的垃圾回收器
b.非Boolean类型
格式:-XX<name>=<value>表示name属性的值是value
比如:-XX:MaxGCPauseMillis=500

其他参数

-Xms1000等价于-XX:InitialHeapSize=1000
-Xmx1000等价于-XX:MaxHeapSize=1000
-Xss100等价于-XX:ThreadStackSize=100

查看参数

java -XX:+PrintFlagsFinal -version > flags.txt


值得注意的是"="表示默认值,":="表示被用户或JVM修改后的值
要想查看某个进程具体参数的值,可以使用jinfo,这块后面聊
一般要设置参数,可以先查看一下当前参数是什么,然后进行修改

设置参数的方式

  • 开发工具中设置比如IDEA,eclipse
  • 运行jar包的时候:java -XX:+UseG1GC xxx.jar
  • web容器比如tomcat,可以在脚本中的进行设置
  • 通过jinfo实时调整某个java进程的参数(参数只有被标记为manageable的flags可以被实时修改)

实践和单位换算

1Byte(字节)=8bit(位)
1KB=1024Byte(字节)
1MB=1024KB
1GB=1024MB
1TB=1024GB

  • 设置堆内存大小和参数打印 -Xmx100M -Xms100M -XX:+PrintFlagsFinal
  • 查询+PrintFlagsFinal的值 :=true
  • 查询堆内存大小MaxHeapSize := 104857600
  • 换算104857600(Byte)/1024=102400(KB) 102400(KB)/1024=100(MB)
  • 结论104857600是字节单位

常用参数含义

参数 含义 说明
-XX:CICompilerCount=3 最大并行编译数 如果设置大于1,虽然编译速度会提高,但是同样影响系统稳定性,会增加JVM崩溃的可能
-XX:InitialHeapSize=100M 初始化堆大小 简写-Xms100M
-XX:MaxHeapSize=100M 最大堆大小 简写-Xmx100M
-XX:NewSize=20M 设置年轻代的大小
-XX:MaxNewSize=50M 年轻代最大大小
-XX:OldSize=50M 设置老年代大小
-XX:MetaspaceSize=50M 设置方法区大小
-XX:MaxMetaspaceSize=50M 方法区最大大小
-XX:+UseParallelGC 使用UseParallelGC 新生代,吞吐量优先
-XX:+UseParallelOldGC 使用UseParallelOldGC 老年代,吞吐量优先
-XX:+UseConcMarkSweepGC 使用CMS 老年代,停顿时间优先
-XX:+UseG1GC 使用G1GC 新生代,老年代,停顿时间优先
-XX:NewRatio 新老生代的比值 比如-XX:Ratio=4,则表示新生代:老年代=1:4,也就是新生代占整个堆内存的1/5
-XX:SurvivorRatio 两个S区和Eden区的比值 比如-XX:SurvivorRatio=8,也就是(S0+S1):Eden=2:8,也就是一个S占整个新生代的1/10
-XX:+HeapDumpOnOutOfMemoryError 启动堆内存溢出打印 当JVM堆内存发生溢出时,也就是OOM,自动生成dump文件
-XX:HeapDumpPath=heap.hprof 指定堆内存溢出打印目录 表示在当前目录生成一个heap.hprof文件
XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+PrintGCDateStamps Xloggc:$CATALINA_HOME/logs/gc.log 打印出GC日志 可以使用不同的垃圾收集器,对比查看GC情况
-Xss128k 设置每个线程的堆栈大小 经验值是3000-5000最佳
-XX:MaxTenuringThreshold=6 提升年老代的最大临界值 默认值为 15
-XX:InitiatingHeapOccupancyPercent 启动并发GC周期时堆内存使用占比 G1之类的垃圾收集器用它来触发并发GC周期,基于整个堆的使用率,而不只是某一代内存的使用比. 值为 0 则表示”一直执行GC循环”. 默认值为 45
-XX:G1HeapWastePercent 允许的浪费堆空间的占比 默认是10%,如果并发标记可回收的空间小于10%,则不会触发MixedGC
-XX:MaxGCPauseMillis=200ms G1最大停顿时间 暂停时间不能太小,太小的话就会导致出现G1跟不上垃圾产生的速度。最终退化成Full GC。所以对这个参数的调优是一个持续的过程,逐步调整到最佳状态
-XX:ConcGCThreads=n 并发垃圾收集器使用的线程数量 默认值随JVM运行的平台不同而不同
-XX:G1MixedGCLiveThresholdPercent=65 混合垃圾回收周期中要包括的旧区域设置占用率阈值 默认占用率为 65%
-XX:G1MixedGCCountTarget=8 设置标记周期完成后,对存活数据上限为 G1MixedGCLIveThresholdPercent 的旧区域执行混合垃圾回收的目标次数 默认8次混合垃圾回收,混合回收的目标是要控制在此目标次数以内
-XX:G1OldCSetRegionThresholdPercent=1 描述Mixed GC时,Old Region被加入到CSet中 默认情况下,G1只把10%的Old Region加入到CSet中

常用命令

jps

查看java进程

The jps command lists the instrumented Java HotSpot VMs on the target system. The command is limited to reporting information on JVMs for which it has the access permissions.

jinfo

  • 实时查看和调整JVM配置参数

The jinfo command prints Java configuration information for a specified Java process or core file or a remote debug server. The configuration information includes Java system properties and Java Virtual Machine (JVM) command-line flags

  • 查看

jinfo -flag name PID 查看某个java进程的name属性的值

jinfo -flag MaxHeapSize PID
jinfo -flag UseG1GC PID
  • 修改
    参数只有被标记为manageable的flags可以被实时修改
jinfo -flag [+|-] PID
jinfo -flag = PID
  • 查看曾经赋过值的一些参数
jinfo -flags PID

jstat

  • 查看虚拟机性能统计信息

The jinfo command prints Java configuration information for a specified Java process or core file or a remote debug server. The configuration information includes Java system properties and Java Virtual Machine (JVM) command-line flags

  • 查看类装载信息

jstat -class PID 1000 10 查看某个java进程的类装载信息,每1000毫秒输出一次,共输出10 次

  • 查看垃圾收集信息
jstat -gc PID 1000 10

jstack

  • 查看线程堆栈信息

The jstack command prints Java stack traces of Java threads for a specified Java process, core file, or remote debug server

  • 用法
jstack PID
  • 排查死锁案例
    DeadLockDemo
//运行主类
public class DeadLockDemo {
    public static void main(String[] args) {
        DeadLock d1=new DeadLock(true);
        DeadLock d2=new DeadLock(false);
        Thread t1=new Thread(d1);
        Thread t2=new Thread(d2);
        t1.start();
        t2.start();
    }
}

//定义锁对象
class MyLock{
    public static Object obj1=new Object();
    public static Object obj2=new Object();
}

//死锁代码
class DeadLock implements Runnable{
    private boolean flag;
    DeadLock(boolean flag){
        this.flag=flag;
    }

    public void run() {
        if(flag) {
            while(true) {
                synchronized(MyLock.obj1) {
                    System.out.println(Thread.currentThread().getName()+"----if 获得obj1锁");

                    synchronized(MyLock.obj2) {
                        System.out.println(Thread.currentThread().getName()+"--- -if获得obj2锁");
                    }
                }
            }
        } else {
            while(true){
                synchronized(MyLock.obj2) {
                    System.out.println(Thread.currentThread().getName()+"----否则 获得obj2锁");

                    synchronized(MyLock.obj1) {
                        System.out.println(Thread.currentThread().getName()+"--- -否则获得obj1锁");
                    }
                }
            }
        }
    }
}

运行结果


jstack分析


把打印信息拉到最后可以发现


jmap

  • 生成堆转储快照

The jmap command prints shared object memory maps or heap memory details of a specified process, core file, or remote debug server

  • 打印出堆内存相关信息

-XX:+PrintFlagsFinal -Xms300M -Xmx300M
jmap -heap PID

  • dump出堆内存相关信息

jmap -dump:format=b,file=heap.hprof PID

jmap -dump:format=b,file=heap.hprof 44808
  • 要是在发生堆内存溢出的时候,能自动dump出该文件就好了
    一般在开发中,JVM参数可以加上下面两句,这样内存溢出时,会自动dump出该文件

-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=heap.hprof

设置堆内存大小: -Xms20M -Xmx20M
启动,然后访问localhost:9090/heap,使得堆内存溢出

  • 关于dump下来的文件
    一般dump下来的文件可以结合工具来分析,这块后面再说

常用工具

参数也了解了,命令也知道了,关键是用起来不是很方便,要是有图形化的界面就好了
一定会有好事之者来做这件事情

jconsole

JConsole工具是JDK自带的可视化监控工具。查看java应用程序的运行概况、监控堆信息、永久区使用情况、类加载情况等

命令行中输入:jconsole

jvisualvm

监控本地Java进程

可以监控本地的java进程的CPU,类,线程等

监控远端Java进程

比如监控远端tomcat,演示部署在阿里云服务器上的tomcat

  • 在visualvm中选中“远程”,右击“添加”
  • 主机名上写服务器的ip地址,比如31.100.39.63,然后点击“确定”
  • 右击该主机“31.100.39.63”,添加“JMX”[也就是通过JMX技术具体监控远端服务器哪个Java进程]
  • 要想让服务器上的tomcat被连接,需要改一下 bin/catalina.sh 这个文件

注意下面的8998不要和服务器上其他端口冲突

JAVA_OPTS="$JAVA_OPTS -Dcom.sun.management.jmxremote 
    -Djava.rmi.server.hostname=31.100.39.63 
    -Dcom.sun.management.jmxremote.port=8998
    -Dcom.sun.management.jmxremote.ssl=false
    -Dcom.sun.management.jmxremote.authenticate=true 
    -Dcom.sun.management.jmxremote.access.file=../conf/jmxremote.access 
    -Dcom.sun.management.jmxremote.password.file=../conf/jmxremote.password"
  • 在 ../conf 文件中添加两个文件jmxremote.access和jmxremote.password
    jmxremote.access 文件

guest readonly
manager readwrite

jmxremote.password 文件

guest guest
manager manager

授予权限 : chmod 600 jmxremot

  • 将连接服务器地址改为公网ip地址

hostname -i 查看输出情况
172.26.225.240 172.17.0.1
vim /etc/hosts
172.26.255.240 31.100.39.63

  • 设置上述端口对应的阿里云安全策略和防火墙策略

  • 启动tomcat,来到bin目录

./startup.sh

  • 查看tomcat启动日志以及端口监听

tail -f ../logs/catalina.out
lsof -i tcp:8080

  • 查看8998监听情况,可以发现多开了几个端口

lsof -i:8998 得到PID
netstat -antup | grep PID

  • 在刚才的JMX中输入8998端口,并且输入用户名和密码则登录成功

端口:8998
用户名:manager
密码:manager

Arthas

github :https://github.com/alibaba/arthas

Arthas allows developers to troubleshoot production issues for Java applications without modifying code or restarting servers

Arthas 是Alibaba开源的Java诊断工具,采用命令行交互模式,是排查jvm相关问题的利器


下载安装

curl -O https://alibaba.github.io/arthas/arthas-boot.jar
java -jar arthas-boot.jar
# 然后可以选择一个Java进程

Print usage

java -jar arthas-boot.jar -h

常用命令

具体每个命令怎么使用,大家可以自己查阅资料

version:查看arthas版本号
help:查看命名帮助信息
cls:清空屏幕
session:查看当前会话信息
quit:退出arthas客户端

dashboard:当前进程的实时数据面板
thread:当前JVM的线程堆栈信息
jvm:查看当前JVM的信息
sysprop:查看JVM的系统属性

sc:查看JVM已经加载的类信息
dump:dump已经加载类的byte code到特定目录
jad:反编译指定已加载类的源码

monitor:方法执行监控
watch:方法执行数据观测
trace:方法内部调用路径,并输出方法路径上的每个节点上耗时
stack:输出当前方法被调用的调用路径
......

MAT

Java堆分析器,用于查找内存泄漏
Heap Dump,称为堆转储文件,是Java进程在某个时间内的快照
下载地址 :https://www.eclipse.org/mat/downloads.php

Dump信息包含的内容

  • All Objects
    Class, fields, primitive values and references
  • All Classes
    Classloader, name, super class, static fields
  • Garbage Collection Roots
    Objects defined to be reachable by the JVM
  • Thread Stacks and Local Variables
    The call-stacks of threads at the moment of the snapshot, and per-frame information about local objects

获取Dump文件

  • 手动
jmap -dump:format=b,file=heap.hprof 44808
  • 自动
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=heap.hprof

使用

  • Histogram
    Histogram可以列出内存中的对象,对象的个数及其大小

Class Name:类名称,java类名
Objects:类的对象的数量,这个对象被创建了多少个
Shallow Heap:一个对象内存的消耗大小,不包含对其他对象的引用
Retained Heap:是shallow Heap的总和,即该对象被GC之后所能回收到内存的总和

右击类名--->List Objects--->with incoming references--->列出该类的实例
右击Java对象名--->Merge Shortest Paths to GC Roots--->exclude all ...--->找到GC Root以及原因

  • Leak Suspects
    查找并分析内存泄漏的可能原因

Reports--->Leak Suspects--->Details

  • Top Consumers
    列出大对象

GC日志分析工具

要想分析日志的信息,得先拿到GC日志文件才行,所以得先配置一下
根据前面参数的学习,下面的配置很容易看懂

-XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+PrintGCDateStamps
-Xloggc:gc.log

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,602评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,442评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,878评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,306评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,330评论 5 373
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,071评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,382评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,006评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,512评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,965评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,094评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,732评论 4 323
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,283评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,286评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,512评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,536评论 2 354
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,828评论 2 345

推荐阅读更多精彩内容