深入理解java(编译-基本类型-类加载)
如何运行java代码的
在讲述java执行过程之前,需要先弄清java的编译过程。
关于编译
java编译过程分为两步
- 前端编译器将.java文件编译成字节码.class文件。字节码顾名思义,是将操作指令固定为一个字节的操作码。
- 由后端编译器将.class文件解析,编译成可由机器托管的机器码。
1 |
|
关于后端编译
后端编译器有有两种编译类型,C1(客户端编译器)C2服务端编译器;
- C1编译器编译时间更短,执行时间较长,适合对启动敏感的客户端程序。
- C2编译器则反之,适合对峰值性能要求较高的服务器端程序。
- HotSpot默认使用分层编译,即对于热点方法先由C1编译器预先编译,二后热点方法中的热点方法又C2编译器再次编译。
- HotSpot会给予后端编译器单独的线程,此外会根据线程的数量按1:2分别为C1和C2分配线程数。
虚拟机
java字节码均有虚拟机托管执行,执行过程分两种。
- 解释执行,即解释一行执行一行,显然这种执行过程较慢;
- 即时编译执行(JIT),即将字节码编译成机器码执行(通过后端编译器编译),这种执行过程更快但需要时间编译。
基于以上两点,java思想认为程序代码遵循二八定律,即20%的代码占据80%的执行时间。故此针对少量使用的80%代码使用解释执行,而20%热点代码将通过即时编译(JIT)编译。
具体编译过程如下:java会统计每个方法在单位时间调用的次数,针对使用不频繁的方法将继续使用解释执行方案。而当某一方法调用频率过高,则会将其通过JIT编译成机器码(此过程是异步过程),当编译完成时,下一次程序再次执行这个方法则会调用这块机器码执行。(理论上java在条件充足的情况下,这部分机器码执行效率会比C更快,因为JVM会缓存当前程序上下文信息)。
为什么使用虚拟机
- 依次编译处处运行,只需替换不同种类的虚拟机即可。(目前在docker容器托管部署的趋势下优势不大)
- 动态保存代码信息,实现动态推断,隐藏细节等;如java泛型机制为动态泛型机制,java可通过反射操作方法调用;
- 减少了对内存的管理,指针的管理,开发者可专注于业务代码;
java的基本类型
基本类型概述
首先,java并不是那么存粹面向对象的语言,如Scala这样,所有的值都是对象。java设计的初衷并不是为了解决数学问题和抽象的思想,更多还是出于对工程学的考虑保留了基本类型,因为他们可在栈保存的特质保证了代码的执行效率。
- boolean
- java编译器规范中 0=false,1=true布尔值没有2,3,4等值;我们不能将2==false进行判断(编译不会通过)
- 在java虚拟机中 ,boolean值被映射成了int,对应false=0,true=1。
- 因此虚拟机要求编译器在编译阶段将true和false转换为对应的int值。
- 整数类型
- java整数类型有char,byte,short,int,long,分别占用(2,1,2,4,8)个字节。
- 其中char是无符号字节,在java中它还会通过utf16表示字符。
- 浮点类型
- float,double,分别占用4,8个字节。
- 浮点类型因为有符号位,因此他会有+0/-0。
- 通过 正浮点数/+0 或 正浮点数/-0 可以分别得到正无穷(0x7F800000)和负无穷(0xFF800000)。而无穷大+1或无穷大-1会得到一个NaN(not a number)。
- 针对NaN比较,只要是!=比较均为true,即便是比较自己。
基本类型的大小
局部变量区
java基本数据类型有两个存储方向,堆和栈;在栈空间中,由于所有的变量数据和指针包括this指针都会存入局部变量区。
- 它可以视为一个数组,每一个数组单元(slot)格在32位和64位操作系统的大小位4和8个字节。
- 在32位操作系统里,short,byte,char,boolean所占用slot格空间和int一致,而在64位操作系统中他们和long一致。
数据转换
在堆空间中,上述变量的值的大小和他们的定义值大小一致。
java存在隐式转换,即将short转为int,一般遵循从小转大规则。但也可以通过(short)将大数据转为较小的short。
- 大转小时,会发生高位截断;反之高位补0;
- 如数据带偶符号位
- 负数:大转小,高位截断,高位补上符号位1。小转大,高位补1,符号位也位1;
- 正数:大转小,高位截断,高位补上符号位0。小转大,高位补0,符号位也为0;
- 当数据出现溢出时,如int.MAX+1数据会从最大变为最小。
类加载
java数据类型分为两大类,引用类型,基本类型(上述八大基本类型)。基本类型无需加载,引用类则包含接口,类,数组,泛型参数。而本节会涉及到类加载过程的则会包含接口,类,数组。此外数组是java虚拟机直接生成,但任然需要执行链接,初始化过程。
类加载分三个大阶段,分为加载,链接,初始化。
加载
即加载字节流,从文件流中获取字节数据,也可以从网络中读取数据流,通过类加载器读取字节信息获取类型返回。
类加载器
类加载器除BootStrap class loader外,均继承java.lang.ClassLoader,因此所有的java类加载器都要先由启动类加载器(java.lang.ClassLoader)开始执行。这个过程应遵循双亲委派原则,即先由父类加载器加载,当无法加载类时,后由本类或子类加载器加载一个类。避免了类加载冲突,保证了类型的安全。(这个过程由JVM默认保证,也可由用户重现loadClass方法打破)。其原理如下
1 |
|
各加载器职责
- java9之前
- 启动类加载器负责加载lib目录下的jar包文件中的类(以及由虚拟机参数 -Xbootclasspath 指定的类)
- 扩展类加载器(extension class loader),加载次要但通用的类,如JRE的lib/ext目录下的jar包类(以及由系统变量 java.ext.dirs 指定的类)
- 应用类加载器(application class loader),即加载当前java项目的类
- 虚拟机参数 -cp/-classpath、
- 系统变量 java.class.path
- 环境变量 CLASSPATH 所指定的路径
- 以上类加载器逐层继承。
- java9及之后
- 定义平台类加载器(原扩展类加载器),吞噬了原启动类加载器的大部分职责。(java.base)包除外
- 自定义类加载器
- 可以读取我们特定目录下的java类
- 对java类文件加密由类加载器解密
- 热更新特定类
- 类唯一性:由类加载器和类全名共同决定,即不同类加载器加载同一个类可认为时两个不同的类。
链接
链接可理解为将加载的类合并到JVM中。核心分三个步骤,验证,准备,解析。
验证
验证的核心是确保加载的类,符合JVM规范。通常在编译器生成的字节码文件必然符合JVM规范。但也不排除通过字节码注入,诸如ASM框架修改java类信息等手法导致数据不再符合JVM规范。
准备
有以下两个核心步骤
- 为静态字段分配内存(但不涉及静态字段初始化)
- 部分虚拟机会在此阶段构造java类层次结构,如实现虚方法的动态绑定的方法表。
- 注意:java在编译器编译后,解析之前。类和其成员变量,方法,外部方法,均以符号引用占位。
解析
- 解析阶段会将上述的符号引用替换为实际引用;
- 如果符号引用关联别的类(未加载的类),会触发这个类的加载(但不一定触发这个类的链接,初始化);
- JVM没有强制要求链接过程中一定要完成解析
- 类加载过程的解析会完成对类信息,字符串字面量,静态成员的解析。
- 对于动态成员,如方法调用,会在执行阶段完成。(JVM只规定了使用符号引用的字节码在执行之前,需要完成对符号引用的解析)
初始化
类的初始化主要是为静态成员赋值。
- 静态成员的类型是基本类型或字符串类型,编译器会标记他们为(ConstantValue),由虚拟机负责初始化;
- 否则,所有的初始化赋值包括static代码块,都会被编译器放置在
clinit
方法中;- 虚拟机会确保它只执行依次,会加锁;
初始化的时机主要由以下情况
- 当虚拟机启动时,初始化用户指定的主类;
- 当遇到用以新建目标类实例的 new 指令时,初始化 new 指令的目标类;
- 当遇到调用静态方法的指令时,初始化该静态方法所在的类;
- 当遇到访问静态字段的指令时,初始化该静态字段所在的类;
- 子类的初始化会触发父类的初始化;
- 如果一个接口定义了 default 方法,那么直接实现或者间接实现该接口的类的初始化,会触发该接口的初始化;
- 使用反射 API 对某个类进行反射调用时,初始化这个类;
- 当初次调用 MethodHandle 实例时,初始化该 MethodHandle 指向的方法所在的类。
典型延迟初始化例子
1 |
|