JVM基础篇-03
1.jvm 类加载的整体流程?
- 通过一个类的全限定名来获取此类的二进制字节流(加载阶段)
- Class 文件的格式验证(连接–>验证–>文件格式验证)
- 将这个字节流所代表的的静态存储(class 文件本身)结构转化为方法区的运行时数据结构(加载阶段)
- 在内存(堆内存)中生成这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口(加载阶段)
- 元数据验证(连接–>验证–>元数据验证)
- 字节码验证(连接–>验证–>字节码验证)
- 准备阶段,赋初始值(连接–>准备)
- 解析/符号引用验证(连接–>解析–>连接–>验证–>符号引用验证)
- 初始化(初始化)
2.加载阶段 JVM 具体进行了什么操作?
加载阶段主要完成了 3 件事情
- 通过一个类的全限定名生成类的二进制字节流
- 将这个二进制字节流的静态存储结构转化为在方法区中虚拟机支持的运行时数据结构(将虚拟机外部的字节流转化为虚拟机所设定的格式存储在方法区中)
- 在内存中生成一个代表这个类的 java.lang.class 对象,作为方法区这个类的各种数据的访问入口
相对于类加载的过程,非数组类型的加载阶段(准确的说,是获取类的二进制字节流的动作)是开发人员可控性最强的阶段,可以使用虚拟机内置的类加载器来完成,也可以由用户自定义的类加载器来完成,开发人员通过自定义的类加载器去控制字节流的获取方式(重写一个类的类加载器的 findClass 方法或者 loadClass 方法),根据需求获取运行代码的动态性.
3.JVM 加载数组和加载类的区别?
对于数组而言,情况有所不同,数组类本身不通过类加载器创建,它是由 java 虚拟机直接在内存中动态构建出来的,但是数组跟类加载器还是密切关联的,因为数组类的元素类型最终还是需要通过类加载器加载完成.
如果数组的元素类型是引用类型,那么遵循 JVM 的加载过程,去加载这个组件类型.数组类将被标识在加载该组件类型的类加器的类命名空间上
.(这一点很重要,一个类必须与类加载器一起确定唯一性)
如果数组类的组件类型不是引用类型(比如 int[]),java 虚拟机会把数组类标记为与启动类加载器关联
数组类的可访问性和它的组件类型的可访问性一致,如果组件类型不是引用类型,它的数组类型的可访问性默认是 public,可被所有的接口和类访问到.
4.验证阶段 JVM 主要做了什么?
验证是连接的第一步.这一阶段的主要的目的是确保 class 的文件的二进制字节流中包含的信息符合 java 虚拟机规范的全部约束要求,保证这些信息被当做代码运行后,不会对虚拟机自身造成危害.
验证阶段是非常重要的,从代码量和耗费的执行性能的角度上来讲,验证阶段的工作量在虚拟机整个类加载过程中占比相当大.
验证主要分为四个验证: 文件格式验证,元数据验证,字节码验证,符号引用验证
文件格式验证:
- 是否以魔数开头
- 主次版本号是否在当前虚拟机支持的范围内
- 常量池中是否含有不被支持的常量类型(检查常量 tag 标志)
- 指向常量的各种索引值中是否含有指向不存在的常量或者不符合类型的常量
- constant_utf8_info 型的常量是否存在不符合 utf8 编码的数据
- class 文件中各个部分以及文件本身是否有被删除的或者附加的其他信息
这个阶段是基于二进制字节流进行的,只有通过这个验证,二进制字节流才会到达方法区进行存储,后面的三个验证也是基于方法区的存储结构,不会直接读取字节流了
元数据验证:
- 这个类是否包含父类(除了 java.lang.object 外,所有类都要有父类)
- 这个类的父类是否继承了不允许被继承的类(final 修饰的类)
- 如果这个类不是抽象类,是否实现了其父类或者接口中要求实现的所有方法
- 类中的字段,方法是否与父类中产生矛盾,(例如覆盖了父类的 final 字段,或者出现了不符合规范的方法重载)
字节码验证:
这一阶段是验证阶段最为复杂的阶段,主要目的是通过数据流分析和控制流分析,确定程序语义是合法的,符合逻辑的.这阶段主要是校验 class 文件的 code 属性.
- 保证任意时刻操作数栈的数据类型和指令代码序列都能配合工作,例如不会出现类似于“在操作数栈放置了一个 int 类型的数据,使用的时候按 long 类型来加载入本地变量表中”这样的情况
- 保证任何跳转指令都不会跳转到方法体以外的字节码指令上
- 保证方法体之内的类型转换都是有效的,比如,可以把一个子类赋值给父类数据类型,这是安全的,但是把父类赋值给子类数据类型,甚至是毫无关系的数据类型,这是危险的,不合法的.
由于数据流和控制流的高度复杂性,为了避免过多的时间消耗,在 jdk1.6 之后的 javac 编译和 java 虚拟机里进行了一项联合优化,把尽可能多的校验移到 javac 编译器里进行.具体做法是给方法体 code 属性的属性表中添加一项 StackMapTable 的新属性,这个属性描述了方法体所有的基本块,开始时本地变量表和操作数栈应有的状态,在字节码验证期间,java 虚拟机就不需要根据程序推导了,只需要检查 StackMapTable 的记录是否合法即可.这样字节码验证的类型推导就变成了类型检查,从而节省了大量的校验时间.
符号引用验证:
最后一个验证阶段发生在虚拟机将符号引用转化为直接引用的时候,这个过程在连接的第三个阶段解析阶段发生.
符号引用验证可以看做是对类自身以外(常量池中的各种符号引用)的各类信息进行匹配性校验,通俗讲就是验证该类是否缺少或者被禁止访问它依赖的某些外部类,方法,字段等资源.
- 符号引用中通过字符串的全限定名能否找到对应的类
- 在指定类中是否存在符合方法的字段描述符以及简单名称所描述的字段和方法
- 符号引用中的类,字段,方法的可访问性是否能被当前类访问
符号引用的目的是确保解析行为能够正常执行,如果无法通过符号引用验证,java 虚拟机将会抛出一个 java.lang.IncompatibleClassChangeError 的子类异常,典型的如:java.lang.IllegalAccessError,java.lang.NoSuchFieldError,java.lang.NoSuchMethodError 等
5.类加载器连接的准备阶段做了什么?
准备阶段是正式为类中定义的变量(静态变量,被 static 修饰的变量),分配内存并设置初始值的过程
这些变量使用的空间应当在方法区中进行分配,在 jdk1.7 及之前,hotspot 使用永久代来实现方法区,在 jdk1.8 之后,类变量会随着 class 对象一起存放在堆中.
准备阶段进行内存分配的只有类变量不包含实例变量,实例变量需要在对象实例化后在堆中分配,通常情况下,初始值一般为零值
.这些内存都将在方法区内分配内存,不包含实例变量,实例变量在堆内存中,而且实例变量是在对象初始化时才赋值
public static int value=123;
准备阶段初始值是 0 不是 123,因为在此时还没执行任务 java 方法,而把 value 赋值为 123 是在 putstatic 指令是程序被编译后,存放在类构造器的 clinit 方法中,赋值为 123 在类的初始化阶段.上面说的通常情况是初始值为零值,特殊情况下被 final,static 修饰时,直接赋予ConstantValue属性
值.比如 final static String
6.类加载器连接的解析阶段做了什么?
解析阶段是将常量池内的符号引用转化为直接引用的过程
符号引用:
用一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时可以定位到目标即可
直接引用:
直接引用可以直接指向目标的指针,相对偏移量或者一个能间接定位到目标的句柄.如果有了直接引用,那么引用的目标在虚拟机的内存中一定存在.
并未明确指出解析的具体时间,可以是在加载时就解析常量池中的符号引用,或者是等到第一个符号引用将要被使用前解析它,这个 java 虚拟机规范中没有明确说明.对同一个符号引用进行多次解析是存在的,虚拟机可以对第一次解析的结果进行缓存,譬如运行时直接引用常量池中的状态,并把常量标示为已解析状态,从而避免了重复动作.
解析动作主要针对类或接口,字段,类方法,接口方法,方法类型,方法句柄,调用点限定符等 7 类符号引用进行,分别对应了常量池中的
constant_class_info
constant_firldref_info
constant_methodref_info
constant_interfacemethodref_info
constant_methodtype_info
constant_methodhandle_info
constant_dynamic_info
constant_invokedyanmic_info
一共 8 种常量类型.
7.类加载器初始化阶段做了什么?
类的初始化阶段是类加载过程的最后一个步骤,之前介绍的几个类加载的动作里,除了在加载阶段用户应用程序可以通过自定义类加载器的方式局部参与外,其余动作都完全由 Java 虚拟机来主导控制。直到初始化阶段,Java 虚拟机才真正开始执行类中编写的 Java 程序代码,将主导权移交给应用程序。
进行准备阶段时,变量已经赋过一次系统要求的初始零值,而在初始化阶段,则会根据程序员通过程序编码制定的主观计划去初始化类变量
和其他资源。我们也可以从另外一种更直接的形式来表达:初始化阶段就是执行类构造器 clinit()方法的过程
。clinit()并不是程序员在 Java 代码中直接编写的方法,它是 Javac 编译器的自动生成物。
8.说说你对 clinit 方法的理解?
clinit
方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生的.编译器收集的顺序是由源文件中出现的顺序决定的,静态语句块只能访问到定义在静态语句块之前的变量,定义在之后的变量发,在前面的静态语句块可以赋值,但是不能访问.
public class Jvm_0999_static {
static {
i = 0;//给变量赋值可以正常编译通过
System.out.print(i);//这句编译器会提示“非法向前引用”
}
static int i = 1;
}
clinit
方法与类的构造函数不同,它不需要显式的调用父类构造器,java 虚拟机会保证在子类的 clinit 方法执行之前,父类的 clinit 方法已经执行完毕,因此在 java 虚拟机中第一个被执行的 clinit 方法的类型肯定是 java.lang.object 类型
public class Jvm_09999_Parent {
//依次为A=0,A=1,A=2
public static int A = 1;
static {
A = 2;
}
static class Sub extends Jvm_09999_Parent {
public static int B = A;
}
public static void main(String[] args) {
System.out.println(Sub.B);
}
}
clinit 方法对于类或者接口不是必须的,如果一个类没有静态语句块,也没有对变量的赋值行为,那么编译器就不会生成这个类的 clinit 方法.
接口中不能使用静态语句块,但是可以有变量赋值操作,因此接口和类一样也会生成 clinit 方法,但是接口和类不同的是,接口的 clinit 方法不需要先执行父类的 clinit 方法,因为只有当父接口中被定义的接口被使用时,父接口才会初始化.接口的实现类在初始化时一样不会执行 clinit 方法.
java 虚拟机必须保证一个类的 clinit 方法在多环境中被正确的同步加锁.如果是多线程去初始化一个类,那么只会有一个线程去执行这个类的 clinit 方法,其他线程需要阻塞等待,直到活动线程执行完 clinit 方法.其他线程被唤醒后不会继续执行 clinit 方法.
9.JVM 会立即对类进行初始化?
对于初始化阶段,java 虚拟机规范严格规定有且只有 6 种情况
必须对类进行“初始化”
- 遇到
new,getstatic,putstatic,invokestatic
这四条指令时,如果类型没有进行初始化,则需要触发其初始化阶段,这四条指令的 java 场景- 使用 new 关键字实例化对象的时候
- 读取或设置一个类型的静态字段(被 final 修饰,已在编译器把结果放入常量池的静态字段除外)的时候
- 调用一个类型的静态方法的时候
- 使用 java.lang.reflect 包的方法对类型进行反射调用的时候,如果类型没有初始化,则需要先触发其初始化
- 当初始化类时,如果发现其父类还没有进行初始化,则需要先触发其父类的初始化
- 当虚拟机宕机时,用户需要指定一个主类,(包含 main 方法的那个类),虚拟机会先初始化这个类
- 当使用 jdk1.7 新加入的动态语言支持时,如果一个 java.lang.invoke.methodhandle 实例最后的解析结果为
ref_getstatic
,ref_putstatic
,ref_invokestatic
,``ref_newinvokespecial` 四种类型的方法句柄,并且这个方法句柄对应的类没有进行初始化,则需要先触发其初始化. - 当一个接口中定义了默认方法,如果这个类的实现类发生了初始化,这个接口要在其之前被初始化.
public class SuperClass {
static {
System.out.println("SuperClass init");
}
public static int value = 123;
}
public class SubClass extends SuperClass {
static {
System.out.println("SubClass init");
}
}
public class NotInitialization {
public static void main(String[] args){
System.out.println(SubClass.value);
}
}
上述代码只会执行“SuperClass init”,对于静态字段,只有直接定义这个字段的类才会被初始化,因此只会触发父类的初始化,不会触发子类的初始化.
public class NotInitialization {
public static void main(String[] args){
SuperClass[] sc = new SuperClass[10];
}
}
通过数组引用的类,不会触发此类的初始化.
public class ConstClass {
static {
System.out.println("ConstClass init");
}
public static final String HELLO_WORD = "hello world";
}
public class NotInitialization {
public static void main(String[] args){
System.out.println(ConstClass.HELLO_WORD);
}
}
final static 常量不会触发类的初始化编译器就放入到属性表的 ConstantValue
中
10.不同的类加载器对 instanceof 影响?
public class ClassLoaderTest {
public static void main(String[] args) throws Exception {
ClassLoader myLoader = new ClassLoader(){
@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
try {
String fileName = name.substring(name.lastIndexOf(".")+1)+".class";
InputStream resourceAsStream = getClass().getResourceAsStream(fileName);
if (resourceAsStream == null){
return super.loadClass(name);
}
byte[] bytes = new byte[resourceAsStream.available()];
resourceAsStream.read(bytes);
return defineClass(name, bytes,0, bytes.length);
} catch (IOException e){
throw new ClassNotFoundException();
}
}
};
Object o = myLoader.loadClass("com.xiaofei.antbasic.demo4.ClassLoaderTest").newInstance();
System.out.println(o.getClass());
System.out.println(o instanceof com.xiaofei.antbasic.demo4.ClassLoaderTest);
}
}
class com.xiaofei.antbasic.demo4.ClassLoaderTest
false
虚拟机中存在了 2 个 ClassLoaderTest 类,一个是虚拟机的类加载器加载的,另一个是我们自定义的类加载器加载的,即使这 2 个类源自同一个 class 文件,被同一个 java 虚拟机加载,但是类加载器不同,那么 2 个类必不相等.
11.说下双亲委派模型?
启动类加载器:
负责加载存在 java_home/lib 下的,或者是被-xbootclasspath 参数所指定的路径中存放,而且能被虚拟机所识别的(按照文件名称识别,如 rt.jar,tools.jar)类库加载到虚拟机内存中.启动类加载器无法被用户直接引用,如果需要委派加载请求给启动类加载器,直接使用 null 代替即可.
扩展类加载器:
它负责加载 java_home\lib\ext 目录中的,或者被 java.ext.dirs 所指定路径中的类库.
应用程序类加载器:
这个类加载器是 ClassLoader 类中 getSystem-ClassLoader 方法的返回值.所以也称为系统类加载器.它负责加载类路径 classpath 上所有的类库,如果没有自定义类加载器,应用程序类加载器就是默认的类加载器.
双亲委派模型要求,除了顶层的类加载器除外,其他的类加载器都要有父类加载器,这里的父子不是继承关系,而是组合关系来复用父加载器的代码.
双亲委派工作流程:
当一个类加载器收到类加载的请求,首先不会自己去加载此类,而是请求父类去加载这个类,层层如此,如果父类加载器不能加载此类(在搜索范围内没有找到需要的类),子类才会去尝试加载.
使用双亲委派的好处是,java 中的类随着类加载器具备了一种优先级的层次关系,例如 java.lang.object,它存放在 rt.jar 中,无论哪一个类加载器要加载这个类,最终都委派给顶层的启动类加载器去加载,因此 object 在各个类加载器环境中都能保证是同一个类.
ClassLoader 源码
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()方法
12.为什么 Tomcat 打破双亲委派?
tomcat 的类加载机制是违反了双亲委托原则的,对于一些未加载的非基础类(Object,String 等),各个 web 应用自己的类加载器(WebAppClassLoader
)会优先加载,加载不到时再交给 commonClassLoader
走双亲委托。
一个 web 容器可能需要部署两个应用程序,不同的应用程序可能会依赖同一个第三方类库的不同版本
,不能要求同一个类库在同一个服务器只有一份,因此要保证每个应用程序的类库都是独立的,保证相互隔离。
Web应用程序类加载器
在加载 Web 应用程序的类时,不会使用传统的双亲委派机制。相反,它首先尝试从 Web 应用程序的类路径加载类,如果找不到则才委派给父类加载器。这使得每个 Web 应用程序都可以有自己的类加载空间,从而避免
了类加载冲突
。
打破双亲委派的真正目的是,当 classpath、catalina.properties 中 common.loader 对应的路径、web 应用/WEB-INF,下有同样的 jar 包时,优先使用/WEB-INF 下的 jar 包
.
tomcat 不遵循双亲委派机制,只是自定义的 classLoader 顺序不同
,但顶层还是相同的,还是要去顶层请求 classloader.
13.类元素加载过程
如果有两个类 A 和 B,B 继承了 A,A 和 B 里面都有静态方法和静态变量,那么它的是怎么样的呢?
1、父类的静态变量
2、父类的静态代码块
3、子类的静态变量
4、子类的静态代码块
5、父类的非静态变量
6、父类的非静态代码块
7、父类的构造方法
8、子类的非静态变量
9、子类的非静态代码块
10、子类的构造方法
14.new HashMap 过程
在 Java 中使用new HashMap<>()
来创建一个新的HashMap
对象时,涉及到多个 JVM(Java 虚拟机)的核心概念和内部机制,包括但不限于:
-
类加载(Class Loading):JVM 首先需要加载
HashMap
类,如果它还没有被加载。类加载是通过类加载器(Class Loaders)完成的。 -
内存分配:
new
关键字导致 JVM 在堆内存(Heap)中分配空间以存储新创建的HashMap
对象。 -
构造函数调用:
HashMap
的构造函数会被调用,用于初始化新创建的对象。这可能涉及到初始化其内部的数组,设置加载因子、容量等。 -
垃圾收集(Garbage Collection):当
HashMap
对象不再被引用时,它将成为垃圾收集的目标。JVM 的垃圾收集器会在适当的时候释放这块内存。 -
动态分派(Dynamic Dispatch):如果你使用的是
Map
接口来接收new HashMap<>()
返回的对象(例如,Map<K, V> map = new HashMap<>()
),则涉及到动态方法分派。这是多态的一部分。 -
泛型(Generics):如果你在创建
HashMap
对象时使用了泛型(例如,HashMap<String, Integer>
),则涉及到类型擦除和桥接方法的概念,尽管这主要在编译时处理。 -
JIT 编译(Just-In-Time Compilation):JVM 可能会将经常运行的字节码动态地编译为本地机器代码,以提高执行速度。这涉及到
HashMap
相关方法和构造函数的调用。 -
数据结构:
HashMap
内部使用数组和链表(或红黑树,在 Java 8 及以后版本中)来存储键-值对。了解这些数据结构有助于更好地理解HashMap
的性能特性。 -
并发(Concurrency):虽然
HashMap
本身不是线程安全的,但在多线程环境中使用它时需要考虑到 JVM 的内存模型和同步。 -
常量池(Constant Pool):字面量(如字符串键)可能会存储在 JVM 的常量池中。
-
字节码(Bytecode):
new
操作符和构造函数调用在字节码级别有对应的操作和指令(比如NEW
和INVOKESPECIAL
字节码指令)。
了解这些概念和内部机制不仅有助于更好地理解HashMap
是如何工作的,还有助于深入了解 JVM 的运行机制。
- 点赞
- 收藏
- 关注作者
评论(0)