类的加载机制以及再学内存分区
加载器将.class文件中的类加载到方法区中转化成基本的数据结构,而至于是否能够使用运行,则是由execution Engine决定。
类加载器只加载特定标识符的class文件->由cafe babe起始。
类加载器->4种:
也叫根加载器,是虚拟机自带的加载器,底层由C++实现,用于加载$JAVA_HOME/jre/lib/rt.jar
包内的class文件。rt.jar是Java基础类库,包含Java运行环境所需的基础类。
拓展类加载器:
拓展类加载器ExtClassLoader是虚拟机自带的加载器,由Java语言实现,用于加载$JAVA_HOME/jre/lib/ext/**.jar
目录下的class文件。
应用程序类加载器:
应用程序类加载器AppClassLoader是虚拟机自带的加载器,用于加载当前应用的classpath的所有类,也就是我们自己写的那些Java代码。
用户自定义加载器:
自定义的一种加载器->继承Java.lang.ClassLoader抽象类自定义一个类加载器
各种加载器的关系->继承
双亲委派机制:
当一个类收到了类加载请求,他首先不会尝试自己去加载这个类,而是把这个请求委派给父类去完成,每一个层次类加载器都是如此。只有当父类加载器反馈自己无法完成这个请求的时候(在它的加载路径下没有找到所需加载的Class),子类加载器才会尝试自己去加载。
加载的步骤 加载(loading)->链接(linking)-> 初始化(Initialization)
1.加载:
通过一个类的全类名获取其二进制字节流,将这个二进制流代表的静态存储结构转化为方法区的运行时数据结构,然后在内存中生成一个代表这个类的java.lang.Class对象,作为方法区中这个类的各种数据的访问入口。
2.链接:
验证Verfication:
确保加载的类符合虚拟机的要求
准备Preparation:
为类变量(static修饰的变量)分配内存并根据对象类型设置相应的默认初始值(比如int类型为0,Integer类型为null)。这里不包含常量,因为常量在编译的时候分配,准备阶段会显式初始化。类的实例变量不会在这个阶段准备初始化。
解析Resolution:
将符号引用转换为直接引用
初始化:
初始化Initialization
该阶段就是执行类的构造器方法<clinit>()
的过程 ;该方法并不是类的构造器,不需要我们自己定义,是javac编译器自动搜集类中的所有类变量的赋值动作和静态代码块中的语句合并而来;构造器方法<clinit>()
,指令的操作就是为所有类变量赋值以及静态代码块中的操作。换句话说,如果一个类不包含类变量和静态代码块,那么它的字节码中就不会有构造器方法<clinit>()
。
-
若构造的类包含父类,那么JVM会保证父类的
<clinit>()
先执行完毕; -
虚拟机会保证一个类的
<clinit>()
方法在多线程下被同步加锁。
JAVA中的栈:
虚拟机栈也称为Java栈,每个线程在创建的时候都会创建一个虚拟机栈,其内部保存一个个栈帧(Stack Frame),对应着一次次的Java方法调用。和PC寄存器一样,虚拟机栈的生命周期和线程一致。虚拟机栈主管Java程序的运行,它保存方法的局部变量(8种基本数据类型,对象引用地址)、部分结果,并参与方法的调用和返回。
Java虚拟机规范允许虚拟机栈的大小固定不变或者动态扩展。
-
固定情况下:如果线程请求分配的栈容量超过Java虚拟机允许的最大容量,则抛出StackOverflowError异常;
-
可动态扩展情况下:尝试扩展的时候无法申请到足够的内存;或者在创建新的线程的时候没有足够的内存去创建对应的虚拟机栈,则会抛出OutOfMemoryError异常。
不同平台的虚拟机栈默认大小不同:
-
Linux/x64 (64-bit): 1024 KB
-
macOS (64-bit): 1024 KB
-
Oracle Solaris/x64 (64-bit): 1024 KB
-
Windows: 默认值取决于虚拟内存。
我们可以通过-Xss
(-XX:ThreadStackSize
简写)设置虚拟机栈大小,默认单位为字节。也可以通过k或者K指定单位为KB,m或M指定单位为MB,g或G指定单位为GB。下面这组配置都是将虚拟机栈大小设置为1024KB:
-Xss1m
-Xss1024k
-Xss1048576
栈帧内部结构
每个栈帧包含5个组成部分:局部变量表(Local Variables)、操作数栈(Operand Stack)、动态链接(Dynamic Linking)、方法返回地址(Return Address)和一些附加信息.
堆
堆之所以要分区是因为:Java程序中不同对象的生命周期不同,70%~99%对象都是临时对象,这类对象在新生区“朝生夕死”。如果没有分区,GC时搜集垃圾需要对整个堆内存进行扫描;分区后,回收这些“朝生夕死”的对象,只需要在小范围的区域中(新生区)搜集垃圾。所以,分区的唯一理由就是为了优化GC性能。
堆空间对象分配过程
下面通过一个例子来讲述这几个区的交互逻辑:
1.几乎任何新的对象都是在伊甸园区被new出来创建,刚开始的时候两个幸存者区和养老区都是空的:
2.随着对象的不断创建,伊甸园区空间逐渐被填满:
3.这时候将触发一次Minor GC(Young GC),删除未引用的对象,GC剩下来的还存在引用的对象将移动到幸存者0区,然后清空伊甸园区:
4.随着对象的创建,伊甸园区空间又满了,再一次触发Minor GC,删除未引用的对象,留下存在引用的对象。这次和上一次Minor GC有些不同,这轮GC留下的对象将被移动到幸存者1区,并且上一轮GC留下来的存储在幸存者0区的对象年龄递增并移动到幸存者1区。当所有幸存对象都移动到幸存者1区后,幸存者0区和伊甸园区空间清除:
5.随着对象的创建伊甸园区空间再一次满了,触发了第三次Minor GC,这一次幸存区空间将发生互换,GC留下来的幸存者将移动到幸存者0区,幸存者1区的幸存对象年龄递增后也移动到幸存者0区,然后伊甸园区和幸存者1区的空间被清除:
6.随着Minor GC的不断发生,幸存对象在两个幸存区不断地交换存储,年龄也不断递增。如此反反复复之后,当幸存对象的年龄达到指定的阈值(这个例子中是8,由JVM参数MaxTenuringThreshold决定)后,它们将被移动到养老区:
7.随着上述过程的不断出现,当养老区快满时,将触发Major GC(Full GC)进行养老区的内存清理。若养老区执行了GC之后发现依然无法进行对象的保存,就会产生OOM异常。
一个对象被放置到养老区除了它的年龄达到阈值外,以下几种情况也会使得该对象直接被放置到养老区:
-
对象创建后,无法放置到伊甸园区(比如伊甸园区的大小为10m,新的对象大小为11m,伊甸园区不够放,触发YGC。YGC后伊甸园区被清空,但还是无法容下11m的“超大对象”,所以直接放置到养老区。当然如果养老区放置不下则会触发FGC,FGC后还放不下则OOM);
-
YGC后,对象无法放置到幸存者To区也会直接晋升到养老区;
-
如果幸存区中相同年龄的所有对象大小大于幸存区空间的一半,年龄大于或等于这些对象年龄的对象可以直接进入养老区,无需等到年龄阈值。
方法区内部储存的东西
类型信息
对每个加载的类型(类class、接口 interface、枚举enum、注解 annotation),JVM必须在方法区中存储以下类型信息:
-
这个类型的完整有效名称(包名.类名);
-
这个类型直接父类的完整有效名(interface和java.lang.Object没有父类);
-
这个类的修饰符(public,abstract,final);
-
这个类型直接接口的一个有序列表(一个类可以实现多个接口)。
方法信息
方法信息包含了这个类的所有方法信息(包括构造器),这些信息和其声明顺序一致:
-
方法名称;
-
方法的返回值类型(没有返回值则是void);
-
方法参数的数量和类型(有序);
-
方法的修饰符(public,private,protected,static,final,synchronized,native,abstract);
-
方法的字节码、操作数栈、局部变量表及其大小(abstract和native方法除外);
-
异常表(abstract和native方法除外)。
域信息
域Field我们也常称为属性,字段。域信息包含:
-
域的声明顺序;
-
域的相关信息,包括名称、类型、修饰符(public,private,protected,static,final,volatile,transient)。
JIT代码缓存
这部分在👇执行引擎中再做说明。
运行时常量池
在上面虚拟机栈的介绍中,我们知道类字节码反编译后,会有一个constant pool的结构,俗称为常量池,所有的变量和方法引用都作为符号引用保存在class文件的常量池里。虚拟机栈的动态链接就是将符号引用(这些符号引用的集合就是常量池)转换为直接引用(符号引用对应的具体信息,这些具体信息的集合就是运行时常量池,存在方法区中)的过程。
静态变量
静态变量就是使用static修饰的域信息。静态变量和类关联在一起,随着类的加载而加载,它们成为类数据在逻辑上的一部分。静态变量也成为类变量,类变量被类的所有实例共享,即使没有类实例时你也可以访问它:
public class Test {
private static String hello = "hello";
private static void hello() {
System.out.println("hello");
}
public static void main(String[] args) {
Test test = null;
test.hello();
System.out.println(test.hello);
}
}
上面程序运行并不会报空指针异常。
通过final修饰的静态变量我们俗称常量。常量在编译的时候就会被分配具体值:
public class Test {
private static String hello = "hello";
private static final String HELLO = "hello";
}
通过javap -v -p Test.class
查看其字节码:
通过上面的学习我们知道,静态变量(类变量)在类加载过程的初始化阶段才会被赋值
参考博客:https://mrbird.cc/JVM-Learn.html
总而言之,为了学习反射机制我需要了解类的加载过程,而要学习类的加载过程就不得不了解JVM中的内存分区,然后就是简单的回收机制。学海无涯!