一:认识JVM
1.1 什么是JVM
java:跨平台的语言,jvm:跨语言的平台
虚拟机分为程序虚拟机和系统虚拟机
- 系统虚拟机是对物理计算机的一个仿真,提供了一个可运行完整操作系统平台的软件,例如Linux虚拟机
- 程序虚拟机:他是为执行单个计算机程序设计,例如java虚拟机执行我们的java字节码指令
jvm(java virtual machine)是java虚拟机的缩写,他是虚构出来的一个计算机,加载和运行我们的字节码(class)文件
1.2 JVM和操作系统
为什么在程序和操作系统之间加上一个jvm?我们知道java程序具有一次编译,到处运行。
从上图可以看出,有了jvm这个抽象层以后,java程序在编译以后,我们只需要在不同的平台上安装jvm,就可以实现java程序的一次编译,到处运行。
除此之外,java为我们提供了自动内存管理等特性,这是在操作系统上实现是基本不太可能的,因此就需要jvm来做一次转换。
总结:
jvm提供了什么便利
- 一次编译处处运行
- 自动内存管理
- 自动垃圾回收
1.3 JVM,JRE,JDK的关系
jvm是java程序能够运行的核心,但是jvm什么都干不了,需要提供给生产原料(.class文件),仅仅是jvm,是无法完成一次编译,处处运行的,它需要一个基本的类库,比如怎么操作文件,怎么进行网络连,而java体系特别慷慨,会一次性将所有jvm运行所需要的类库都传递给他,jvm标准加上实现的一大堆类库,就组成了java运行时环境,也就是JRE(java runtime environment)。
对于JDK(java development kit)来说,除了JRE,JDK还提供了一些非常好用的小工具 ,比如javac,javav等,他是java开发的核心。
总结:
jvm的运行需要jre提供给他的内库,而jdk则是在此之上又提供了一些非常好用的工具
JVM,JRE,JDK三者之间是一个包含的关系
1.4 JVM的生命周期
JVM的启动
JVM的启动是通过引导类加载器(BootstrapClassLoder)创建一个初始类(initial class),这个类是由具体的虚拟机来创建的
JVM的执行
一个运行中的java虚拟机有一个明确的任务,那就是执行java程序,程序开始执行他运行,程序结束运行他结束
执行一个所谓的java进程的时候,实际上执行的是一个java虚拟机的进程
JVM的结束
程序正常结束
程序由于出现异常或者错误
操作系统出现异常导致的jvm进程崩溃
某线程调用Runtime类或者System类的exit()方法或者Runtime类的halt()方法
二:JVM类加载机制详解
2.1 类加载子系统
2.1.1 类加载子系统介绍
类加载器从哪里加载文件 加载什么文件 加载到哪里 交给谁运行
1.类加载子系统负责从文件系统或是网络中加载class文件,class文件在文件开头有特定的文件标识,
2.把加载后的class信息存放于方法区,除了类信息,方法区还会存放运行时常量池信息
3.类加载器只负责class文件的加载,至于是否可以运行,则由执行引擎决定
2.1.2 类加载器的角色
- class file存储与本地磁盘上,可以理解为一个模板,这个模板执行的时候需要加载到JVM当中,然后根据这个模板实例化出n个一模一样的实例
- class file加载到JVM中,被称为DNA元数据模板
- 在.class文件->jvm->DNA元数据模板,此过程需要一个运输工具(类加载器),类加载器就是一个快递员的角色
2.1.3 类加载的执行过程
我们编写的java程序,经过编译后成为.class文件,也就是我们诉说的字节码文件,字节码文件中包含了类信息(类型信息,域信息,方法信息 ,以及类常量),最终字节码文件被加载到JVM中之后才能被执行,那么虚拟机时如何加载字节码文件的呢?
类的生命周期
类从加载到jvm中到被卸载出内存,他的整个生命周期包括:加载(load)->验证,准备,解析,初始化,使用和卸载,其中,验证,准备和解析这三个部分被称为连接(linking),这七个阶段的发生顺序如下
图中,加载,验证,准备,初始化,卸载,这5个阶段的顺序是确定的类的加载过程 必须按照这种顺序进行,而解析阶段不一定,在某些情况下可以初始化阶段之后开始,这是为了支持java语言的运行时绑定(也称为动态绑定),卸载属于GC的工作
2.1.3.1 加载
加载是类加载的第一个阶段
- 通过类的全限定名称获取此类的二进制字节流
- 将字节流所代表的静态存储结构转化为方法区的运行时数据结构
- 生成一个Class类,在方法区,作为访问这个类各种数据访问的入口
有两种时机会触发类加载
1.预加载
虚拟机启动的时候加载,加载的是JAVA_HOME/lib下面的rt.jar下的class文件,这个jar包里面的内容是程序运行时常用到的,像java.lang.java.util,java.io等等,因此随着虚拟机一起加载,要证明这一点很简单,写一个空的manin方法,然后设置虚拟机参数-XX:+TraceClassLoading来获取类加载信息,运行以下,观察结果:
/**
* -XX:+TraceClassLoading
*/
public class PreLoadDemo {
public static void main ( String[] args ) {
}
}
/**
* [Opened D:\Java\jdk1.8.0_152\jre\lib\rt.jar]
* [Loaded java.lang.Object from D:\Java\jdk1.8.0_152\jre\lib\rt.jar]
* [Loaded java.io.Serializable from D:\Java\jdk1.8.0_152\jre\lib\rt.jar]
* [Loaded java.lang.Comparable from D:\Java\jdk1.8.0_152\jre\lib\rt.jar]
* [Loaded java.lang.CharSequence from D:\Java\jdk1.8.0_152\jre\lib\rt.jar]
* [Loaded java.lang.String from D:\Java\jdk1.8.0_152\jre\lib\rt.jar]
* [Loaded java.lang.reflect.AnnotatedElement from D:\Java\jdk1.8.0_152\jre\lib\rt.jar]
* [Loaded java.lang.reflect.GenericDeclaration from D:\Java\jdk1.8.0_152\jre\lib\rt.jar]
* ........
*/
运行时加载
虚拟机在运行一个class文件的时候,会先去内存中查看一下这个字节码文件有没有被加载,如果没有就会按照类的全限定名来加载这个类,那么加载阶段做了什么?
- 获取字节码文件的二进制流
- 将类信息,静态变量,字节码,常量这些字节码文件中的内容放入到方法区中
- 在内存中生成一个代表这个字节码文件的Class对象,作为方法区这个类的各种数据的访问入口
虚拟机并没有指定这个二进制流从哪里来,因此我们可以从多种途径来获取二进制流
- 从zip包中获取,这就是jar,war的格式的基础
- 从网络中获取,典型的应用就是applet
- 运行时计算获取,比如动态代理技术
- 由其他文件生成,比如说jsp,由jsp生成对应的class文件
- 从数据库中读取
2.1.3.2 链接
链接包含三个步骤:分别是 验证(Verification),准备(Preparation),解析(Resolution)三个过程
验证Verification
链接的第一个步骤,这一阶段的目的就是为了确保class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机的安全。
java语言相对来说是比较安全的,但是前面我们说过,.class文件的来源有很多,不一定非要从java文件编译而来,虚拟机如果不检查输入的字节流,对其完全信任的话,很可能会因为载入了有害的字节流而导致系统崩溃,所以验证时虚拟机对自身保护的一项重要工作
验证阶段主要做以下方面验证
文件格式的验证,元数据验证,字节码验证,符号引用验证
准备(Preparation)
准备阶段是正式为类变量分配内存并设置初始值的阶段,这些变量使用的内存都将在方法区中分配,其中需要注意的是
- 这个时候进行内存分配的仅仅是类变量(被static修饰的变量),而不是实例变量,实例变量在对象实例化的时候随着对象一起分配在java堆中
- 这个阶段的赋初始值的是哪些不被final修饰的static变量,比如public static int i = 1;,i在准备阶段过后是0而不是1,给i赋值的动作是在初始化的时候,但是被final修饰的类变量,在准备阶段就已经赋值了,比如public static final String str = "hello world";在准备阶段就已经为字符串str赋值为hello world
在准备阶段,各个数据类型的初始值如下
数据类型 | 初始值 |
---|---|
int | 0 |
long | 0L |
double | 0.0d |
float | 0.0f |
byte | (byte)0 |
char | '\u0000' |
short | (short)0 |
boolean | false |
reference | null |
我们看一下下面的代码
public class StaticDemo {
static int a;
public static void main ( String[] args ) {
System.out.println(a);
}
//此方法无法通过编译
public static void method0(){
int a;
System.out.println(a);
}
}
我们在局部变量中定义一个int类型,并没有赋值,此时程序编译就会报错,为什么呢?
注意:
因为局部变量不像类变量那样存在准备阶段,类变量有两次赋值的过程,一次是准备阶段,一次是初始化阶段,因此,即时我们没有给类变量赋值,jvm在进行类加载的时候也会帮我们进行赋值,但是局部变量就不一样,如果我们不给局部变量赋值,jvm也没有给局部变量赋值的操作,那么此局部变量是不能使用的
解析(Resolution)
解析阶段是虚拟机将常量池中的符号引用(指向运行时常量池中的内存地址)替换为直接引用的过程,那么符号引用和直接引用有什么区别呢
1.符号引用
符号引用是一种定义,可以是任何字面上的定义,而直接引用就是直接指向目标的指针,相对偏移量。这个其实是属于编译原理方面的概念,符号引用包含了下面三类常量
类和接口的全限定名称 , 字段的名称和描述符,方法的名称和描述符,
我们通过一段代码 看一下什么是符号引用
public class StaticDemo {
static int a;
public static void main ( String[] args ) {
System.out.println(a);
}
public static void method0(){
int i = 0;
System.out.println(i);
}
public static int method1(){
return 1;
}
}
我们通过javap命令将class文件反编译一下 然后看看我们反编译以后class文件的内容
Classfile /H:/项目/innner/jvm/out/production/jvm/com/wcc/load/StaticDemo.class
Last modified 2021-10-24; size 696 bytes
MD5 checksum 785aac5f67fee90bb582befbbcc6ed34
Compiled from "StaticDemo.java"
public class com.wcc.load.StaticDemo
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #6.#26 // java/lang/Object."<init>":()V
#2 = Fieldref #27.#28 // java/lang/System.out:Ljava/io/PrintStream;
#3 = Fieldref #5.#29 // com/wcc/load/StaticDemo.a:I
#4 = Methodref #30.#31 // java/io/PrintStream.println:(I)V
#5 = Class #32 // com/wcc/load/StaticDemo
#6 = Class #33 // java/lang/Object
#7 = Utf8 a
#8 = Utf8 I
#9 = Utf8 <init>
#10 = Utf8 ()V
#11 = Utf8 Code
#12 = Utf8 LineNumberTable
#13 = Utf8 LocalVariableTable
#14 = Utf8 this
#15 = Utf8 Lcom/wcc/load/StaticDemo;
#16 = Utf8 main
#17 = Utf8 ([Ljava/lang/String;)V
#18 = Utf8 args
#19 = Utf8 [Ljava/lang/String;
#20 = Utf8 method0
#21 = Utf8 i
#22 = Utf8 method1
#23 = Utf8 ()I
#24 = Utf8 SourceFile
#25 = Utf8 StaticDemo.java
#26 = NameAndType #9:#10 // "<init>":()V
#27 = Class #34 // java/lang/System
#28 = NameAndType #35:#36 // out:Ljava/io/PrintStream;
#29 = NameAndType #7:#8 // a:I
#30 = Class #37 // java/io/PrintStream
#31 = NameAndType #38:#39 // println:(I)V
#32 = Utf8 com/wcc/load/StaticDemo
#33 = Utf8 java/lang/Object
#34 = Utf8 java/lang/System
#35 = Utf8 out
#36 = Utf8 Ljava/io/PrintStream;
#37 = Utf8 java/io/PrintStream
#38 = Utf8 println
#39 = Utf8 (I)V
{
static int a;
descriptor: I
flags: ACC_STATIC
public com.wcc.load.StaticDemo();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 7: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Lcom/wcc/load/StaticDemo;
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=1, args_size=1
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: getstatic #3 // Field a:I
6: invokevirtual #4 // Method java/io/PrintStream.println:(I)V
9: return
LineNumberTable:
line 10: 0
line 11: 9
LocalVariableTable:
Start Length Slot Name Signature
0 10 0 args [Ljava/lang/String;
public static void method0();
descriptor: ()V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=1, args_size=0
0: iconst_0
1: istore_0
2: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
5: iload_0
6: invokevirtual #4 // Method java/io/PrintStream.println:(I)V
9: return
LineNumberTable:
line 14: 0
line 15: 2
line 16: 9
LocalVariableTable:
Start Length Slot Name Signature
2 8 0 i I
public static int method1();
descriptor: ()I
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=0, args_size=0
0: iconst_1
1: ireturn
LineNumberTable:
line 19: 0
}
SourceFile: "StaticDemo.java"
我们看到Constant Pool也就是常量池中的内容,其中带"utf-8"的就是符号引用,比如 #15 它的值是 com/wcc/load/StaticDemo;表示的是这个类的全限定名称,又比如#21的值为i,表示变量是Integer类型的。总而言之,符号引用是对于类,变量,方法的描述,符号引用和虚拟机内存布局是没有关系的,引用的目标未必已经加载到内存中了
直接引用
直接引用可以是直接指向目标的指针,相对偏移量,或者是一个能间接定位到目标的句柄,直接引用是和虚拟机实现的内存布局相关的,如果有了直接引用,那么引用的目标必定一定存在内存中了
解析阶段负责将整个类激活,串成一个可以找到彼此的网,那么这个阶段大概都做了什么呢,大体可以分
类和接口的解析 ,类方法的解析, 接口方法的解析,字段解析
2.1.3.3 初始化
类的初始化是类加载的最后一个阶段,之前介绍的类加载的动作里面,除了加载阶段的用户应用程序可以通过自定义类加载器的方式局部参与外,其余动作都完全由java虚拟机来主导,直到初始化阶段,java虚拟机才真正开始执行类中编写的java程序代码,将主导权移交给应用程序。
初始化阶段就是执行类构造器()方法的过程,类构造器方法并不是直接在java程序编写的方法,而是javac编译器的自动生成物,类构造器方法是由编译器自动收集类中的所有类变量的赋值动作,和静态代码块中的语句合并产生的,编译器收集的顺序是由语句在java源文件中顺序决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块 可以赋值,但是不能访问,如下代码:
public class StaicDemo02 {
static {
//编译报错,非法向前引用
System.out.println(i);
}
static int i =1;
}
类构造器方法和构造函数是不同的,它不需要显示的调用父类的构造器方法,java虚拟机会保证在子类方法的类构造器方法执行前,父类的类构造器方法已经执行完毕,因此,java虚拟机中第一个被执行的类构造器方法肯定是java.lang.Object。
类构造器方法对于接口或者类来说,并不是必须的,如果一个类中没有静态变量或者静态语句块,那么编译器可以不为类生成类构造器方法,接口中不能使用静态语句块,但是仍然有变量的赋值操作,因此接口与类一样,都会生成类构造器方法。
但是接口与类不同的是,执行接口的类构造器方法并不会先执行父类的接口的类构造器方法,因为只有当父类中的定义的变量被使用的时候,父接口才会被初始化,此外,接口的实现类在初始化时也不会执行接口的类构造器方法。
java虚拟机必须保证在一个类的类构造器方法在多线程环境中被正确的加锁同步,如果多个线程同时去初始化一个类,那么只有会有一个线程去执行这个类的类构造器方法,其他线程都需要阻塞等待,直到活动线程执行完毕类构造器方法,如果在一个类中有耗时很长的操作,那么就可能造成多个线程阻塞,如果运行以下的程序,我们会发现在控制台输出66666之后就会一直处于阻塞状态。
public class StaticDemo03 {
static {
System.out.println("66666");
if (true) { // 不加if true,编译不会通过
while (true) {
}
}
}
public static void main ( String[] args ) {
StaticDemo03 demo03 = new StaticDemo03();
new Thread(demo03::test).start();
new Thread(demo03::test).start();
}
public void test(){
System.out.println("12321");
}
}
2.1.3.4 和方法区别
我们说的类构造器方法就是
结论:
2.1.4 类加载器的作用
类的加载器是将类的.class文件中的二进制数据读入到内存中,将其放在运行时数据区的方法区内,然后在创建一个java.lang.Class对象,用来封装类在方法区内的数据结构。
注意:jvm主要在程序第一次使用类的时候,才会去加载该类,也就是说,jvm并不是在一开始就把以程序所有的类都加载到内存中,而是不得不用的时候才会把他加载进来,而且只加载一次。
2.2类加载器
2.2.1 类加载器的分类
1.jvm支持两种类型的类加载器,分别是引导类加载器和自定义加载器,引导类加载器是由c/c++实现的,自定义加载器是由java实现的。jvm规范定义自定义加载器是指派生于抽象类ClassLoder的类加载器。
按照这样的类加载器的分类,在程序中,常见的类加载器是:
引导类加载器(BootStrapClassLoader),
扩展类加载器(Extension ClassLoader),
系统类加载器(System Class Loader),
用户自定义加载器(User-defined ClassLoader)
引导类加载器
这个类加载器使用c/c++实现,嵌套在jvm内部,它用来加载java的核心内库(JVAV_HOME/jre/lib/rt.jar,resource.jar,或sun.boot.class.path路径下的内容),用于提供jvm自身需要的类,并不继承自java.lang.ClassLoader,没有父类加载器
扩展类加载器
java语言编写,由sun,misc.Launcher$ExtClassLoader实现,从java.ext.dirs系统属性所指定的目录中加载类库,或从jdk的安装目录的jre/lib/ext子目录下加载类库,如果用户创建的jar放在此目录下,也会自动由扩展类加载器加载,派生于ClassLoader,父类加载器为启动类加载器
系统类加载器
由java语言编写,由sun.misc.Launcher$AppClassLoader实现,该类加载是程序中默认的类加载器,一般来说,java应用的类都是由它来完成加载的,它负责加载环境变量classpath或系统属性java.class.path指定路径下的类库,派生于ClassLoader,父类加载器为扩展类加载器,通过ClassLoader.getSystemClassLoader()方法可以获取到该类加载器
public class ClassLoaderDemo {
public static void main ( String[] args ) {
// 获取系统类加载器
ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();
System.out.println(systemClassLoader);
// 获取系统类加载器的父类加载器(扩展类加载器)
ClassLoader systemClassLoaderParent = systemClassLoader.getParent();
System.out.println(systemClassLoaderParent);
// 获取扩展类加载器的父类
ClassLoader parent = systemClassLoaderParent.getParent();
System.out.println(parent);
}
}
/**
* sun.misc.Launcher$AppClassLoader@18b4aac2
* sun.misc.Launcher$ExtClassLoader@1540e19d
* null
*/
2.3 双亲委派模型
3.3.1 什么是双亲委派
双亲委派模型的工作过程是:如果一个类加载器收到类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器完成,每个加载器都是如此,只有当父类加载器在自己的搜索范围内找不到指定的类的时候,子类加载器才会尝试自己去加载
2.3.2 为什么需要双亲委派模型
我们试想这样一个场景:
如果我们自定义了一个java.lang.String类,该类具有系统的String类一样的功能,只是在某个函数稍作修改,比如equals函数,在这个函数中,我们添加一些“病毒代码“,并且通过自定义类加载加载到jvm中,此时,如果没有双亲委派机制,那么,jvm就会误以为java.lang.String是系统的String类,导致String类出现问题。
而有了双亲委派模型,我们自定义的java.lang.String是永远不会加载到jvm中,因为首先是最顶端的引导类加载器去加载java.lang.String.自定义的类加载器是不会加载java.lang.String类的。
或许,你也可以通过自定义类加载加载java.lang.String,但是不同的类加载器加载的类是不同类型的,举个例子
ClassLoader1和ClassLoader2都加载了Person.class,但是,这两个类加载器加载后的Person.class是不相同的
2.3.3 如何实现双亲委派模型
双亲委派模型的实现比较简单,就是每次加载类之前,都会先通过父类加载器去加载,如果父类加载器无法加载时,自己才会去加载。
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;
}
}
从上面的代码可以看出。loadClass函数实现了双亲委派模型,整体流程大致如下
1.首先,检查一下指定名称的类是否已经被加载过,如果已经加载过了,就不需要再加载了直接返回。
2.如果此类没有被加载过,那么,再判断一下是否有父类加载器,如果有父类加载器,则有父类加载器来加载,或者是用引导类加载器来加载
3.如果父类加载器以及引导类加载器都没有找到指定的类,那么调用当前类加载器的findClass方法。
除此之外,java还提供了一个方法,就是将字节数组转化为Class的方法,这个方法就是defineClass方法,通过这个方法,就可以把一个字节数组转为Class对象
2.3.4 自定义类加载器
2.3.4.1 为什么要自定义类加载器
- 隔离加载类,隔离模块,把类加载到不同的应用中,比如tomcat,内部定义了好几种类加载器,用来隔离web应用服务器上的不同应用程序
- 修改类的加载方式:除了引导类加载器外,其他的加载并非一定要引入,可以根据实际情况进行动态加载
- 扩展加载源:比如从网络,数据库或者其他终端上加载
- 防止源码泄露:java代码容易被篡改,可以进行编译加密,类加载需要自定义还原加密字节码
3.3.4.2 自定义类加载的过程
实现步骤:
- 实现ClassLoader类,然后重写其中的方法,可以重写其中的两个方法,一个是loadClass,一个是findClass。
- 重写loadClass方法会破坏双亲委派模型(前面已经看过,双亲委派的实现就是在这个方法中),这里不推荐
- 重写findClass方法
接下来我们就用代码来实现一下
public class MyClassLoader extends ClassLoader{
private String codePath;
public MyClassLoader ( ClassLoader parent, String codePath ) {
super(parent);
this.codePath = codePath;
}
public MyClassLoader ( String codePath ) {
this.codePath = codePath;
}
@Override
protected Class<?> findClass ( String name ) throws ClassNotFoundException {
// 字节码路径
String file = codePath+name+".class";
// 声明输入输出流
try(BufferedInputStream bis = new BufferedInputStream(new FileInputStream(new File(file)));
ByteArrayOutputStream bos = new ByteArrayOutputStream()) {
//io读写操作
int len;
byte [] data = new byte[1024];
while ((len = bis.read(data))!= -1){
bos.write(data,0,len);
}
byte[] byteCode = bos.toByteArray();
Class<?> clazz = defineClass(null, byteCode, 0, byteCode.length);
return clazz;
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
return super.findClass(name);
}
public static void main ( String[] args ) throws ClassNotFoundException {
MyClassLoader loader = new MyClassLoader("H:\\项目\\innner\\jvm\\src\\com\\wcc\\classloader\\");
Class<?> clazz = loader.findClass("HelloWorld");
System.out.println(clazz+"是由"+clazz.getClassLoader()+"加载的");
//class com.wcc.classloader.HelloWorld是由com.wcc.classloader.MyClassLoader@1540e19d加载的
}
}
2.4 类的主动使用和被动使用
JVM规范了6种主动使用类的场景
- 通过关键字new会导致类的初始化,这是我们经常采用的一种初始化方式,他一定会导致类的加载并且最终初始化
- 访问类的静态变量,包括读取和更新会导致类的初始化
- 访问类的静态方法,会导致类的初始化
- 对某个类进行反射操作,会导致类的初始化
- 初始化子类会导致父类初始化
- 启动类,也就是main函数所在的类会导致该类的初始化
被动使用
- 构造某个类的数组时并不会导致类的初始化
User [] user = new User[10]- 引用类的静态常量不会导致类的初始化
三:JVM的内存管理
内存是非常重要的系统资源,是硬盘和CPU的中间桥梁,承载着操作系统和应用程序的实时运行,jvm内存布局规定了java在运行过程中内存申请、分配、管理的策略,为了保证JVM的高效稳定运行,不同的JVM对内存的划分方式和管理机制存着部分差异,接下来我们集合JVM虚拟机规范,来探讨一下经典的JVM内存布局
3.1 JVM架构
3.1.1 JVM整体模块图
jvm共分为五大模块,类加载系统,运行时数据区,执行引擎,本地方法接口和垃圾收集模块,我们先看一下整体架构图,后面会对对应模块做详细介绍
3.1.2 JVM线程图
- 虚拟机线程∶这种线程的操作是需要JVM达到安全点才会出现。这些操作必须在不同的线程中发生的原因是他们都需要JVM达到安全点,这样堆才不会变化。这种线程的执行类型包括"stop-the-world"的垃圾收集,线程栈收集,线程挂起以及偏向锁撤销。
- 周期任务线程∶这种线程是时间周期事件的体现(比如中断),他们一般用于周期性操作的调度执行。
- GC线程∶这种线程对在JVM里不同种类的垃圾收集行为提供了支持。
- 编译线程∶这种线程在运行时会将字节码编译成到本地代码。
- 信号调度线程∶这种线程接收信号并发送给JVM,在它内部通过调用适当的方法进行处理。
3.2 JVM运行时内存
根据jvm规范,jvm内存区域共分为堆,栈,方法区,pc程序计数器,本地方法栈。
接下来我们就对jvm内存区域做一个简单的介绍,后面再进行详细介绍
名称 | 特征 | 作用 | 配置参数 | 异常 |
---|---|---|---|---|
虚拟机栈 | 线程私有,生命周期与线程生命周期相同,使用连续的内存空间 | java方法执行的内存模型,每个方法就是一个栈帧,栈帧中可以存储局部变量表,操作数栈,动态链接,方法返回地址等 | -Xss | StackOverflowError/ OutOfMemoryError |
本地方法栈 | 线程私有 | 为虚拟机使用native方法时服务 | 无 | tackOverflowError/ OutOfMemoryError |
pc程序计数器 | 线程私有,生命周期与线程生命周期相同,占用内存小 | 为字节码行号指示器,指明线程执行到程序的什么位置 | 无 | 无 |
方法区 | 线程共享,生命周期与虚拟机生命周期相同,可以不使用连续的内存地址 | 存储已经被虚拟机加载的类信息,常量,静态变量,即时编译后的代码等数据 | -XX:PermSize:16M -XX:MaxPermSize64M/- XX:MetaspaceSize=16M-XX:MaxMetaspaceSize=64M | OutOfMemoryError |
堆 | 线程共享,生命周期与虚拟机生命周期相同 | 几乎所有的对象实例都保存这个区域中 | -Xmx -Xmn -Xms | OutOfMemoryError |
3.2.1 jdk7与jdk8的区别
java7和java8内存结构不同主要区别在于方法区的实现
方法区是java虚拟机规范中定义的一种概念的区域,不同的厂商可以对方法区有不同的实现,通常我们所说的java虚拟机指的是HotSpot的版本
jdk7内存结构
jdk8内存结构
jdk8虚拟机内存详解
jdk7和jdk8的区别
在java8中,方法区存在元空间(Metaspace),元空间不再与堆连续,而是存在于本地内存(这里需要注意的是,方法区只是一种规范,元空间可以看作是其实现,jdk7中,方法区的实现是永久代)
方法区jdk7与jdk8的变化
- 移除了永久代,由元空间替代
- 永久代中的类元信息转移到了本地内存,而不是虚拟机中,也就是元空间中
- 永久代中的字符串常量池以及静态变量转移到了堆中
- 永久代参数PermSize MaxPermSize->元空间参数MetaspaceSize MaxMetaspaceSize
java8为什么要将永久代替换成Metaspace?
- 类及方法信息等比较难确定大小,对于永久代的大小指定比较困难,太小容易出现内存溢出,太大则容易导致老年代溢出(原本永久代是位于堆上且与老年代连续)
- 永久代为GC带来了不必要的复杂度,并且回收效率偏低
- Oracle可能将HotSpot与JRockit合二为一,JRockit没有永久代
说完了jdk7与jdk8的区别,接下来就对运行时数据区的各个部分做一个比较详细的说明
3.2.2 PC程序计数器
什么是程序计数器?
程序计数器:也叫PC寄存器,是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器,当我们的cpu发生上下文切换的时候,我们根据pc程序计数器指示的位置知道我们的线程执行到什么地方,可以继续接着往下执行。字节码解释器就是通过改变程序计数器的值来选取下一条需要执行的字节码指令,分支,循环,跳转,异常处理,线程恢复等基础功能都需要依赖这个计数器完成
PC程序计数器的特点
- 与计算机硬件的pc寄存器略有不同,计算机用pc寄存器来存放伪指令或地址,而对于虚拟机,pc寄存器的功能也是存放伪指令,更确切地说,jvm的pc寄存器存储的是将要执行的字节码的指令的地址
- 当虚拟机正在执行的是一个本地方法时,pc寄存器存储的值是undefined
- 程序计数器是线程私有的,生命周期与线程生命周期相同
- 字节码解释器工作时就是通过改变程序计数器的值来选中下一条需要执行的字节码指令
- pc寄存器是唯一一个在java虚拟机规范中没有规定任何内存溢出情况的区域
3.2.3 虚拟机栈
栈与堆的区别
栈是运行时的单位,而堆是存储的单位
栈解决程序的运行问题,即程序如何运行,或者说如何处理数据,堆解决的是数据存储的问题,即数据放在什么地方,怎么放
栈不涉及到垃圾回收问题
什么是虚拟机栈
java虚拟栈也是线程私有的,生命周期和 线程生命周期相同,主要用来存储栈帧,每个方法执行时都会创建一个栈帧,栈帧用于存储局部变量表,操作数栈,动态链接,方法返回地址等信息,每一个方法的执行到结束,就意味着每一个栈帧在虚拟机栈中入栈和出栈的过程
public class Demo01 {
public static void main ( String[] args ) {
Demo01 demo01 = new Demo01();
demo01.a();
}
public void a(){
b();
}
private void b () {
c();
}
private void c () {
}
}
下图所示:就是一个方法的入栈的过程,如上面的程序所示,先执行main()方法,那么此时就会创建一个栈帧,然后压入虚拟机栈的底部,而main()方法由调用了a()方法,此时再会创建一个栈帧,压入虚拟机栈,依次类推,最后结构如下图所示。
设置虚拟机栈的大小
-Xss 为jvm启动的每个线程分配的内存大小,默认jdk1.4中式256k,jdk1.5中是1M,我们可以通过-Xss来设置虚拟机栈的大小,例如
-Xss1m
-Xss1024k
-Xss1048576
我们写一个代码实例来进行实战
public class StackDemo {
static int i;
public static void main ( String[] args ) {
i++;
System.out.println("stack deep:"+i);
main(args);
}
}
Exception in thread "main" stack deep:6174
stack deep:6175
stack deep:6176
stack deep:6177
stack deep:6178
stack deep:6179
stack deep:6180
stack deep:6181
java.lang.StackOverflowError
at sun.nio.cs.UTF_8$Encoder.encodeLoop(UTF_8.java:691)
接下来我们将栈的大小设置为2m,观测栈深度
stack deep:12344
stack deep:12345
stack deep:12346
Exception in thread "main" java.lang.StackOverflowError
at sun.nio.cs.UTF_8$Encoder.encodeLoop(UTF_8.java:691)
at java.nio.charset.CharsetEncoder.encode(CharsetEncoder.java:579)
at sun.nio.cs.StreamEncoder.implWrite(StreamEncoder.java:271)
at sun.nio.cs.StreamEncoder.write(StreamEncoder.java:125)
我们明显可以观察到,当我们设置的虚拟机栈比较大时,我们栈的深度就比较大
什么是栈帧
栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构,其中包括局部变量表,操作数栈,动态链接,方法返回地址等信息,接下来就详细介绍一下各个部分。
栈的运行原理
- JVM对栈的操作只有两个,就是对栈的压栈和出栈,遵循先级后出。后进先出原则
- 在一条活动的线程种,一个时间节点上,只有一个活动的栈帧,即之后有当前正在执行方法的栈帧(栈顶栈帧)是有效的,这个栈帧被称为当前栈帧,与当前栈帧对应的就是当前方法,定义这个方法的类就是当前类
- 执行引擎运行的所有字节码指令只针对当前栈帧进行操作
- 如果该方法调用了其他方法,对应的新的栈帧就会被创建出来,放在栈的顶端,成为当前帧
- 不同线程种所包含的栈帧是不允许存在互相引用的,即不可能在一个栈帧之中引用另外一个线程的栈帧
- 如果当前方法返回调用了其他方法,方法返回之际,当前栈帧就会传回此方法的执行结果给下一个栈帧,接着,虚拟机就会丢弃当前栈帧,使得下一个栈帧重新成为当前栈帧
3.2.3.1 局部变量表
局部变量表也被称为局部变量数组或本地变量表
- 局部变量表是一组变量值存储空间,用来存放方法内存定义的局部变量和方法参数,包括基本数据类型,引用数据类型,和方法返回地址。
- 由于局部变量表是建立在线程的栈上,是线程私有的,因此不存在数据安全问题
- 局部变量表所需的容量大小是在编译期确定下来的
- 局部变量表中的变量只有在当前方法调用中有效,在方法执行时,虚拟机通过使用局部变量表完成参数值到参数列表的传递过程,当方法调用结束后,随着方法栈帧的销毁,局部变量也会随之销毁
- 局部变量表,参数值的存放总是在局部变量数组的index0开始,到数组长度-1的索引技术
- 局部变量表,最基本的存储单元是Slot(槽)
- 局部变量表中,32位以内的类型只占用一个slot(包括返回地址),64位的类型占用两个slot
- 局部变量表中的变量位GCROOT,即只要被局部变量表中直接或者间接引用的对象都不会回收
其中64位长度的long类型和double类型会占用2个局部变量空间(Slot),其余数据类型只占用一个
我们可以在idea上下载一个jclassslib bytecode view插件,插件的使用非常简单,百度即可,我们可以通过此插件,来观察一下局部变量表
public class Demo01 {
public static void main ( String[] args ) {
int i = 0;
int j = 1;
int z = i + j;
System.out.println(z);
}
}
start pc:表示该局部变量在字节码的多少行开始生效(字节码指令的行号)。
length:表示剩余的有效行数,mian方法共有16行,但是变量i的字节码行号为2,意味着变量i从字节码的第二行开始生效,那么剩余的字节码有效行数就是14.
name:就是局部变量的名称
3.2.3.2 操作数栈
- 操作数栈,主要用于保存计算过程中的中间结果,同时作为计算过程中变量临时的存储空间
- 操作数栈就是JVM执行引擎的一个工作区,当一个方法刚开始的时候,一个新的栈帧也会被随之创建出来,这个方法的操作数栈是空的
- 每一个操作数栈都拥有一个明确的栈深度用于存储数值,其所需的最大深度在编译期就定义好了,保存在方法的Code属性中,为max_stack的值
- 栈中的任何一个元素都是可以任意的java数据类型,32bit的类型占用一个栈深度单位,64bit的类型占用两个栈深度单位
- 操作数栈并非采用访问索引方式来进行数据访问的,而是只能通过标准的入栈和出栈操作来完成一次数据访问
- 如果被调用的方法带有返回值的话,其返回值就会被压入当前栈帧的操作数栈中,并更新PC寄存器中下一条需要执行的字节码指令
- 我们说Java虚拟机的解释引擎是基于栈的执行引擎,其中的栈指的就是操作数栈
操作数栈也称为操作栈,是一个后入先出栈,随着方法执行和字节码指令的执行,会从局部变量表或者对象实例的字段中复制常量或者变量写入到操作数栈,然后进行计算,最后将操作数栈中的元素出栈到局部变量表或者返回给方法的调用者,也就是入栈 出栈操作。
3.2.3.3 动态链接
在Java源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用保存在class文件的常量池里,比如:描述一个方法调用了其他方法时,就是通过常量池中指向方法的符号引用来表示的。(常量池的作用就是提供一些符号和常量,便于指令的识别)
java虚拟机中,每个栈帧都包含一个指向运行时常量池中该栈所属的方法的符号引用,持有这个方法的引用目的就是为了支持方法调用过程中的动态链接
动态链接和静态链接
静态链接
如果被调用的目标方法在编译期可知,且运行时期保持不变,这种情况下将调用方法的符号引用转为直接引用的过程称为静态链接
动态链接
如果被调用的方法在编译器无法被确定下来,也就是说,只能够在程序运行期将方法的符号引用转为直接引用。
对应的方法绑定机制
绑定是一个字段、方法或者类在符号引用被替换为直接引用的过程,仅仅发生一次
早期绑定
目标方法在编译期可知,且运行时保持不变,则将方法与类型进行绑定
晚期绑定
如果调用的方法在编译期无法被确定下来,只能够在程序运行期根据实际类型绑定相关的方法
非虚方法
如果方法在编译器就确定了具体的调用版本,这个版本在运行是不可变的,这样的方法被称为非虚方法
静态方法、私有方法、final方法、实例构造器、父类方法都是非虚方法
虚拟机中的调用指令
invokestatic:调用静态方法,解析阶段确定唯一方法版本
invokespecial:调用init方法、私有以及父类方法,解析阶段确定唯一方法版本
invokevirtual:调用所有虚方法
invokeinterface:调用接口方法
动态调用指令
invokedynamic:动态解析出需要调用的方法,然后执行
动态链接的作用:将符号引用转化为直接引用
3.2.3.4 方法返回地址
方法返回地址存放调用该方法的pc寄存器的值,一个方法的结束,有两种方式,
第一种就是正常的执行完成,
另一种就是出现异常非正常退出,
无论哪种方式退出,在方法退出后都回到该方法被调用的位置,方法正常退出时,调用者的PC计数器的值作为返回地址,即调用该方法之后下一条字节码指令的地址,而异常退出时,返回地址是要通过异常表来确定,栈帧中一般不会保存这部分信息。
无论方法是否正常完成,都需要返回到方法被调用的位置,程序才能继续进行。
3.2.4 堆
3.2.4.1 简介
对于java程序来说,堆(java Heap) 是虚拟机管理的内存中最大的一块,堆是线程共享的区域,在虚拟机启动的时候创建,此内存区域主要目的就是存放实例对象,在jdk8以后,类变量和一些常量也存储在堆中,java里面几乎所有的对象实例都在这里分配内存,为什么是几乎呢?由于编译技术的进步,尤其是逃逸分析技术日渐强大,栈上分配,标量替换优化手段已经导致一些微妙的变化,所以。java实例对象都分配在堆上也渐渐变得不是那么绝对了。
堆的特点
- 被线程共享
- 生命周期与虚拟机周期相同,即在虚拟机启动的时候创建
- 存放实例对象,jdk8以后存放类变量以及字符串常量
- java堆是垃圾收集器管理的主要 区域
- java堆也被称为"GC堆",从内存回收的角度来看,现在的垃圾收集器都采用的是分代收集算法,堆又详细分为 新生代和老年代(old),新生代有分为Eden survivor from区,survivor to区
- java堆在物理内存上是不连续的,但是在逻辑上是连续的,也是大小可以调节的(-Xms -Xmx)
- 方法结束后,对象不会立即从堆中移除,而是在垃圾回收的时候移除
- 如果堆中没有足够的空间分配给要创建的实例对象,就会报OutOfMemoryError
注意:堆中包含私有的线程缓冲区(Thread Local Allocation Buffer) TLAB(栈上分配)
- 由于对象一般会分配在堆上,而堆是全局共享的。因此在同一时间,可能会有多个线程在堆上申请空间。因此,每次对象分配都必须要进行同步(虚拟机采用CAS配上失败重试的方式保证更新操作的原子性),而在竞争激烈的场合分配的效率又会进一步下降。JVM使用TLAB来避免多线程冲突,在给对象分配内存时,每个线程使用自己的TLAB,这样可以避免线程同步,提高了对象分配的效率。
- TLAB本身占用eEden区空间,在开启TLAB的情况下,虚拟机会为每个Java线程分配一块TLAB空间。参数-XX:+UseTLAB开启TLAB,默认是开启的。TLAB空间的内存非常小,缺省情况下仅占有整个Eden空间的1%,当然可以通过选项-XX:TLABWasteTargetPercent设置TLAB空间所占用Eden空间的百分比大小。
- 由于TLAB空间一般不会很大,因此大对象无法在TLAB上进行分配,总是会直接分配在堆上。TLAB空间由于比较小,因此很容易装满。比如,一个100K的空间,已经使用了80KB,当需要再分配一个30KB的对象时,肯定就无能为力了。这时虚拟机会有两种选择,第一,废弃当前TLAB,这样就会浪费20KB空间;第二,将这30KB的对象直接分配在堆上,保留当前的TLAB,这样可以希望将来有小于20KB的对象分配请求可以直接使用这块空间。实际上虚拟机内部会维护一个叫作refill_waste的值,当请求对象大于refill_waste时,会选择在堆中分配,若小于该值,则会废弃当前TLAB,新建TLAB来分配对象。这个阈值可以使用TLABRefillWasteFraction来调整,它表示TLAB中允许产生这种浪费的比例。默认值为64,即表示使用约为1/64的TLAB空间作为refill_waste。默认情况下,TLAB和refill_waste都会在运行时不断调整的,使系统的运行状态达到最优。如果想要禁用自动调整TLAB的大小,可以使用-XX:-ResizeTLAB禁用ResizeTLAB,并使用-XX:TLABSize手工指定一个TLAB的大小。
- -XX:+PrintTLAB可以跟踪TLAB的使用情况。一般不建议手工修改TLAB相关参数,推荐使用虚拟机默认行为。
3.2.4.2 堆空间的大小设置
-Xms:设置堆的最小空间
-Xmx:设置堆的最大空间
-Xmn:设置新生代的大小
java代码实例
/**
* 先在我们的jvm参数中设置堆的最大内存和 最小内存
* -Xmx80m -Xms10m
*/
public class HeapDemo01 {
public static void main ( String[] args ) {
// Byte[] bytes = new Byte[1024 * 1024 * 4];
long maxMemory = Runtime.getRuntime().maxMemory();
long freeMemory = Runtime.getRuntime().freeMemory();
long totalMemory = Runtime.getRuntime().totalMemory();
System.out.println(maxMemory/1024/1024+"m");
System.out.println(freeMemory/1024/1024+"m");
System.out.println(totalMemory/1024/1024+"m");
}
}
/*
71m
7m
9m
*/
我们将上面代码中的注释去掉,然后再运行一次,我们发现,totalMemory和freeMemory都有上升,说明jvm再分配内存的时候,并不是贪婪的按照最大内存来分配的,而是按需来动态分配的
3.2.4.3堆的分类
上图是jdk8中的堆的分类,将堆内存分为新生代和老年代,在jdk7中,堆中还有一个叫做永久代,我们知道永久代是方法区的实现,但是在jdk8以后,方法区的内存分配已经不在堆上面了,而是存储在本地空间的(直接内存)metaspace中,优点在上面已经提到过。
从上图可以看出:堆空间大小=eden+survivor from+survivor to +old
新生代也叫做年轻代(Young Gen): 年轻代主要存放新创建的对象,内存大小会相对比较小,垃圾回收的频率比较高,年轻代又分为Eden区和来两个suvivor区,几乎所有的对象都在eden区创建,但是80%的对象生命周期都很短,创建出来就被销毁了,survivor分为from区和to区,to区总是空的。
老年代(年老代)(Tenured Gen):主要存放的就是jvm认为生命周期比较长的对象,经过几次Young GC(也就是对新生代的垃圾回收)以后,仍然存活的对象,内存相对比较大,发生GC的频率较低。
3.2.4.4 新生代和老年代堆结构占比
默认:
-XX:NewRatio=2 标识新生代占1,老年代占2,新生代占整个堆的1/3
-XX:SurvivorRatio=8 标识eden空间和两个survivor的空间占比位8:1:1
我们可以通过上面这两个参数来修改新生代和老年代的堆空间占比,也可以修改eden区与survivor的比例
我们配置以后通过代码实例来演示一下:
/**
* jvm添加参数设值 观察控制台打印结果
* -XX:+PrintGCDetails -Xmx40m -Xms10m -XX:NewRatio=1 -XX:SurvivorRatio=2
*/
public class NewOldRatioDemo01 {
public static void main ( String[] args ) {
System.out.println("hello");
try {
TimeUnit.SECONDS.sleep(30000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
找到jdk安装目录的bin目录,其下面有一个jvisualvm.exe,双击打开,然后安装Visual GC插件,运行我们的java代码,观察比例
3.2.4.5 堆内存分配过程
逃逸算法分析
逃逸算法分析其实就是分析java对象的动态作用域
1.如果一个对象被定义之后,被外部对象引用,则称之为方法逃逸
2.如果被其他线程所引用,则被称之为线程逃逸。
经过以上分析,如果一个对象没有逃逸出方法的话,那么就可能被优化成栈上分配。
TALB(Thread Local Allocation Buffer)线程本地分配缓存
TALB就是线程私有的空间,这个空间是由eden区划分出来的,是特别小的一块内存区域。但是只要有,每个线程分配对象到堆空间的时候都会优先分配到线程自己所属的那一块堆空间中。
为对象分配内存
指针碰撞法这种方法适用于堆内存完整的情况 下,已经分配的内存和空闲的内存分别在不同的一侧,这时通过一个指针为分界点,需要分配内存时,将指针往空闲的一端移动与对象大小相等的距离,这样就完成了内存分配
空闲列表法如果堆中的内存不完整,已分配的内存和空闲内存互相交错,JVM通过维护一个空闲列表来记录可用内存的信息,当需要分配内存时,在列表中找到一个足够大的内存块分配给对象实例,并更新列表上的记录
1.如果确定一个对象的作用域不会逃逸出方法之外,那么可以将这个对象分配在栈上,这样对象所占用的内存空间就可以随着栈帧出栈而销毁,在一般应用中,不会逃逸的局部对象所占的比例很大,如果能使用栈上分配,那么大量的对象就会随着方法的结束而自动销毁了,无需通过垃圾回收器,可以减少垃圾回收器的负载。JVM允许将线程私有的对象打散分配在栈上,而不是分配在堆上。分配在栈上的好处是可以在函数调用结束后自行销毁,而不需要垃圾回收器的介入,从而提高系统性能。
2.如果TLAB分配不成功,再尝试在eden区分配,如果对象满足了直接进入老年代的条件,那就直接分配在老年代。
3.2.4.6 对象在内存中的布局
我们通过一个面试题来说明对象在内存中的布局
Object ojb = new Object()占用了多少字节
java内存模型:
对象在内存中可以分为三个区域,分别是对象头(Header),实例数据(Instance Data),和对齐填充(Padding)
- 因此,如果我们只new Object()的话,在内存中占有16个字节,对象头中的markword8个字节加上class pointer的8个字节刚好是16个字节
- 如果开启了指针压缩:那么就是markword8个字节加上class pointer4个字节为12个字节,但是12不是8字节的整数倍,因此需要自动填充,也为16个字节。
3.2.4.7堆GC
java中的堆也是GC收集垃圾的主要区域,GC分为两种一种是部分收集器(Partial GC),另一类是整堆垃圾收集器(Full GC)
部分收集器又可分为:
新生代收集器(Minor GC/Young GC):只是新生代的垃圾收集
老年代收集器(Major GC/old GC):只是老年代的垃圾收集器(CMS GC单独回收老年代)
混合收集(Mixed GC):收集整个新生代以及老年代的垃圾收集(G1 GC混合回收)
整堆收集(Full GC):收集整个java堆和方法区的垃圾收集器
Minor GC 触发条件
- 年轻代空间不足 就会产生Young GC,这里的年轻代满 指的是Eden满,Survivor不会引发GC
- Minor GC会引发(STW)stop the world,暂停其他用户的线程,等垃圾回收结束,用户的线程才恢复
Major GC触发条件
- 老年代空间不足时,会尝试触发Minor GC,如果还不足,会触发MajorGC
- 如果Major GC完以后,内存仍然不足,就会报OOM
- Major GC比Minor GC慢10倍以上
Full GC 触发条件
- 手动调用System.gc()系统会执行Full GC,不是立即执行
- 老年代空间不足
- 方法区空间不足
- 通过MinorGC 进入老年代平均大小大于老年代可用内存
3.2.5元空间
在jdk1.8之前,虚拟机将方法区当成永久代来进行垃圾回收,而jdk1.8以后,则是移除了永久代,而使用元空间来替代,那么永久代和元空间又什么区别呢?
3.2.5.1 永久代和元空间的区别
- 存储位置不同,元空间位于直接内存,也就是本地内存中,但是永久代是位于堆上的一块空间
- 存储内容不同,在原来的永久代划分中,永久代用来存储类的元数据信息,静态变量以及常量池等,现在类的元信息存储在元空间中,而静态变量以及常量池存储在堆中,相当于 原来永久代中存储的东西 被元空间和堆给瓜分掉了
3.2.5.2 为什么要废除永久代
- 由于类的元数据信息存储在本地内存中,元空间的最大可分配内存就是系统的可用内存空间,而在永久代中,由于类的元信息无法确定其大小,所以容易造成OOM。
- 将类的元数据从永久代中剥离,可以提高对元数据的管理同时提升GC效率
- 将运行时常量池从永久代中剥离,与类的元数据分开,提升类元数据的独立性。
3.2.5.4 元空间的相关参数设置
-XX:MetaspaceSize:初始空间大小,达到该值就会触发垃圾收集进行类型卸载,同时GC会对该值进行调整,如果释放了大量的空间,那么就会适当的降低该值,如果释放了很少的空间,在不超过MaxMetaspaceSize的值时,会适当提高该值
-XX:MaxMetaspaceSize:元空间最大值,默认是没有限制的,如果没有使用该参数来设置元空间的最大值,那么系统的可用内存空间就是元空间的最大可利用空间,JVM也可以增加本地内存空间来满足元数据信息的存储,但是如果没有设置最大值,则可能存在bug导致Metaspace空间在不停的扩展,会导致机器内存不足,进而可能出现内存耗尽,最终导致进程直接被系统kill掉
-XX:MinMetaspaceFreeRatio:在GC之后,最小的元空间剩余空间容量百分比,如果值小于这个数(默认是百分之40),那么虚拟机就会增长Metaspace的大小,减少为分配空间所导致的垃圾收集
-XX:MaxMetaspaceFreeRatio:在GC之后,最大的元空间剩余空间容量的百分比,如果值大于这个数(默认是百分之70),说明Metaspace的空闲空间太多,就会适当释放掉一些Metaspace的空间。减少释放内存导致的垃圾收集
3.2.6 方法区
3.2.6.1 方法区的理解
- 方法区(Method Area) 与java堆一样,是各个线程共享的内存区域,它用于存储被虚拟机加载的类型信息,常量,静态变量,即时编译后的代码缓存等数据。
- java虚拟机规范中明确说明:尽管所有的方法区在逻辑上是属于堆的一部分,但些简单的实现可能不会选择进行垃圾收集或者垃圾压缩。
- 元空间和永久代是方法区的落地实现,方法区看作是一块独立于java堆的内存空间,主要是用来存储所加载的类的信息的。
创建对象各数据区域的说明
3.2.6.2 方法区的特点:
- 方法区与堆相同,是所有线程共享的区域
- 方法区的生命周期与jvm的生命周期相同,在jvm启动的时候创建,并且它实例的物理内存可以与堆一样不连续
- 方法区的大小跟堆空间一样,可以选择固定大小和动态变化
- 方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类,导致方法区溢出,虚拟机同样会报OOM异常
- 关闭jvm就会释放这个区域的内存
3.2.6.3 方法区的结构
类加载器将Class文件加载到内存以后,将类的信息存储到方法区中
方法区中存储的内容
- 类信息
- 运行时常量池
类信息
类型信息
对每个加载的类型(类Class,接口 interface,注解Annotation,枚举Enum),jvm必须在方法区中存储以下类型信息
- 这个类的全限定名称(包名+类名)
- 这个类型的直接父类的完整有效名称(全限定名称)
- 这个类型的修饰符(Pubilc,final等)
- 这个类型直接接口的一个有序列表
域信息
域信息,即为类的属性,成员变量
jvm必须在方法区中保存类所有的成员变量相关信息以及声明顺序
域的相关信息包括:域名称,域类型,域的修饰符,
方法信息
jvm必须保存所有方法的以下信息,同域信息一样包括声明顺序
- 方法的名称以及方法的返回类型
- 方法的参数数量和参数类型
- 方法的修饰符
- 方法的字节码,操作数栈,局部变量表以及大小
- 异常表,每个异常处理的开始位置,结束位置,代码处理在程序计数器中的偏移地址,被捕获的异常类的常量池索引
3.2.6.4 方法区的大小设置
方法区的大小不是固定的,与堆相同,可以进行动态调整,我们之前说过,方法区只是一个规范,其实现在jdk8以后为元空间,因此调整方法区的大小,就是调整元空间的大小
在Windows环境下,元空间的默认值是21m,最大值是(-1)没有限制的
-XX:MetaspaceSize:设置初始的空间大小,对于一个64位的服务器端的jvm来说,其默认值-XX:MetaspaceSize的值为21m,这就是初始的高水位线,一旦触及这个水平线,那么就会引发Full GC,Full GC将会卸载一些没用的类,如果GC以后,释放的空间不足,在不超过MaxMetaspaceSize的值的情况下,就会适当提高该值,如果释放的空间过多,就会适当降低该值
我们可以 通过如下指令观察到元空间的大小
jps:观察相关的java进程
jinfo -flag MetaspaceSize 进程号:// 查看metaspace分配内存空间
jinfo -flag MaxMetaspaceSize 进程号:// 查看Metaspace的最大空间
java代码
/**
* 设置元空间的大小和最大空间
* -XX:MetaspaceSize=10m -XX:MaxMetaspaceSize=20m
*/
public class MethodDemo01 {
public static void main ( String[] args ) {
try {
TimeUnit.SECONDS.sleep(30000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
/**
*$ jps
* 1432 MethodDemo01
*
* $ jinfo -flag MetaspaceSize 1432
* -XX:MetaspaceSize=10485760
*
* $ jinfo -flag MaxMetaspaceSize 1432
* -XX:MaxMetaspaceSize=20971520
*/
3.2.6.5 运行时常量池
字节码文件中,内部包含了常量池和类信息
方法区中,内部包含了运行时常量池
常量池:存放编译时期(class文件)生成的各种字面常量值(字符串的值,基本类型,被fianl修饰等)与符号引用
运行时常量池:常量池在运行时期的表现形式
编译后的字节码文件中包含了类信息(类型信息,域信息,方法信息)以及常量池,
通过类加载器将字节码文件中的常量池加载内存中,然后存储到了方法区的运行时常量池中,
在class文件常量池的符号引用有一部分是会被转变为直接引用的,比如说类的静态方法或私有方法,实例构造方法,父类方法,这是因为这些方法不能被重写其他版本,所以能在加载的时候就可以将符号引用转变为直接引用,而其他的一些方法是在这个方法被第一次调用的时候才会将符号引用转变为直接引用的。
我们可以这样理解:字节码中的常量池只是文件信息,他要执行就必须通过类加载器加载到内存中,然后通过jvm的执行引擎来解释执行的,执行引擎在运行时常量池中获取数据,被加载的字节码常量池中的则是被放在了方法区中的运行时常量池中,我们可以通过我们之前下载的jclasslib插件来观察以下字节码文件中的常量池
一个有效的字节码文件,不仅包含了类的版本信息,字段,方法等信息以外,还包含有常量池,常量池中保存一些字面常量,比如我们上面代码中的0000,还有一些符号引用,这些引用就是我们域,方法的引用,虚拟机就可能根据常量池中符号引用找到对应的方法名称,参数类型等等
方法中对常量池中的符号引用
3.2.7 实战OOM
3.2.7.1 java堆溢出
堆中主要存放的就是对象,只要不断的创建对象,并避免垃圾回收来清楚这些对象,当对象所占的空间超过最大堆容量时,那么就会发生OOM
/**
* 设置堆的最大内存为20m
* -Xmx20m -Xms10m
*/
public class HeapOOMDemo {
public static void main ( String[] args ) {
ArrayList<Byte[]> list = new ArrayList<>();
while (true){
list.add(new Byte[1024*1024]);
}
}
}
运行后我们看到,在堆栈信息中可以看到
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at com.wcc.heap.HeapOOMDemo.main(HeapOOMDemo.java:17),
OOM常见的原因:
- 内存中加载的数据过多,比如我们一次性从数据库中加载了太多数据
- 内存分配不合理
- 集合对对象的引用过多,使用完之后没有清空
- 代码中存在死循环或循环产生过多的重复对象
3.2.7.2 虚拟机栈和本地方法栈溢出
- 如果线程请求的栈深度大于虚拟机所允许的最大深度,就会抛出StackOverFlowError
- 如果虚拟机的栈内存允许动态扩展,当扩展栈无法申请到足够的内存时,将抛出内存溢出异常
/**
* 设置栈大小 -Xss128k
*/
public class StackOverFlowDemo {
public static void main ( String[] args ) {
while (true){
overflow();
}
}
public static void overflow(){
overflow();
}
}
Exception in thread "main" java.lang.StackOverflowError
at com.wcc.stack.StackOverFlowDemo.overflow(StackOverFlowDemo.java:19)
我们知道我i们在运行方法时,每一个方法都会在虚拟机栈中产生一个栈帧,当栈的深度不够时,不能创建更多的栈帧时,就会抛出栈内存溢出
3.2.7.3 方法区和运行时常量池溢出
运行时常量池内存溢出(由于jdk1.8以后,运行时常量池存储在堆中,所以设置堆的存大小)
/**
* -Xmx10m -Xms10m
*/
public class ConstantPoolOOMDemo {
public static void main ( String[] args ) {
ArrayList<String> list = new ArrayList<>();
int i = 0;
while (true){
list.add(String.valueOf("nihao"+i).intern());
}
}
}
/**
* Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
* at java.util.Arrays.copyOf(Arrays.java:3210)
* at java.util.Arrays.copyOf(Arrays.java:3181)
* at java.util.ArrayList.grow(ArrayList.java:265)
* at java.util.ArrayList.ensureExplicitCapacity(ArrayList.java:239)
* at java.util.ArrayList.ensureCapacityInternal(ArrayList.java:231)
* at java.util.ArrayList.add(ArrayList.java:462)
* at com.wcc.method.ConstantPoolOOMDemo.main(ConstantPoolOOMDemo.java:14)
*/
方法区内存溢出
方法区主要存放字节码文件的类信息,对于这部分的思路,我们可以采用大量的类来填满这个方法区,我们使用动态代理来创建类