一、字节码技术
1、什么是字节码?
Java bytecode 由单字节(byte)的指令组成,理论上最多支持 256 个操作码(opcode)。实际上 Java 只使用了200左右的操作码, 还有一些操作码则保留给调试操作。根据指令的性质,主要分为四个大类:
- 栈操作指令,包括与局部变量交互的指令。
- 程序流程控制指令(方法的内部有if、for循环等)。
- 对象操作指令,包括方法调用指令。
- 算术运算以及类型转换指令。
2、生成字节码
package demo.jvm_01;
public class HelloByteCode {
public static void main(String[] args) {
HelloByteCode obj = new HelloByteCode();
}
}
编译:javac demo/jvm_01/HelloByteCode.java
Compiled from "HelloByteCode.java"
public class demo.jvm_01.HelloByteCode {
public demo.jvm_01.HelloByteCode();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public static void main(java.lang.String[]);
Code:
0: new #2 // class demo/jvm_01/HelloByteCode
3: dup
4: invokespecial #3 // Method "<init>":()V
7: astore_1
8: return
}
解析字节码:分为两部分:无参的构造方法、main(),
0: aload_0 :0是具体的偏移码,在二进制的偏移量表示0,也就是最开始的操作码;当虚拟机执行一段代码的时候,首先会把用到的所有变量存在本地的一个变量表里,也叫局部方法表。如果在栈上做计算的时用到了本地方法表的变量值,使用load指令加载到栈上,在栈上运算完后,使用store指令存回本地变量表里。 aload_0是一个栈操作指令,把本地变量表里的第0个位置的变量加载到栈上来,a前缀表示引用类型。
1: invokespecial #1 :调用当前类的父类的初始化方法,#1表示常量池里的一个常量,也是操作码1对应的操作数。
0:new #2:表示从常量池里拿到#2这个类型的名字,也是HelloByteCode这个类,然后把它new出来,变成对象。dup:压到栈上去,invokespecial:调用初始化方法。astore_1:把new出来的对象使用 store命令把它的引用压到本地变量表 #1 的位置上去。
在字节码文件里,每一段字节码由多条指令组成,每个指令可以是单个指令,也可以是多个字节组成。如上,第一个操作码0~1,占用了一个字节。invokespecial #1:占用了三个字节。为什么占用三个字节呢?使用 javap -c -verbose demo/jvm_01/HelloByteCode 将常量池打印出来。
Classfile /Users/gs/Documents/demo/jvm_01/HelloByteCode.class
Last modified 2021-7-8; size 300 bytes
MD5 checksum 948d28eea91cb53473d2052c04909b93
Compiled from "HelloByteCode.java"
public class demo.jvm_01.HelloByteCode
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #4.#13 // java/lang/Object."<init>":()V
#2 = Class #14 // demo/jvm_01/HelloByteCode
#3 = Methodref #2.#13 // demo/jvm_01/HelloByteCode."<init>":()V
#4 = Class #15 // java/lang/Object
#5 = Utf8 <init>
#6 = Utf8 ()V
#7 = Utf8 Code
#8 = Utf8 LineNumberTable
#9 = Utf8 main
#10 = Utf8 ([Ljava/lang/String;)V
#11 = Utf8 SourceFile
#12 = Utf8 HelloByteCode.java
#13 = NameAndType #5:#6 // "<init>":()V
#14 = Utf8 demo/jvm_01/HelloByteCode
#15 = Utf8 java/lang/Object
{
public demo.jvm_01.HelloByteCode();
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 3: 0
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=2, args_size=1
0: new #2 // class demo/jvm_01/HelloByteCode
3: dup
4: invokespecial #3 // Method "<init>":()V
7: astore_1
8: return
LineNumberTable:
line 6: 0
line 7: 8
}
SourceFile: "HelloByteCode.java"
通过上面可以看出,invokespecial #1: 表示调用常量池#1的常量,是由#4和#13组成,对应着#15(此类的父类是Object)、#5、#6(表示返回值为空的初始化构造函数)
LineNumberTable: line 3: 0 :表示第0个指令出现在代码的第三行。
minor version: 0 major version: 52。jdk的版本号:52.0,也就是jdk8。那么51就是jdk7。
stack=1, locals=1, args_size=1;表示栈stack=1,本地变量=1,参数大小=1;执行这段指令的集合,所需栈的深度为1,本地变量表变量的数量为1,本地方法的参数为1.
3、字节码的运行时结构
4、从助记符到二进制
5、javac 与 javap的用法
javac 的用法
$ javac -help
用法: javac <options> <source files>
其中, 可能的选项包括:
-g 生成所有调试信息
-g:none 不生成任何调试信息
-g:{lines,vars,source} 只生成某些调试信息
-nowarn 不生成任何警告
-verbose 输出有关编译器正在执行的操作的消息
-deprecation 输出使用已过时的 API 的源位置
-classpath <路径> 指定查找用户类文件和注释处理程序的位置
-cp <路径> 指定查找用户类文件和注释处理程序的位置
-sourcepath <路径> 指定查找输入源文件的位置
-bootclasspath <路径> 覆盖引导类文件的位置
-extdirs <目录> 覆盖所安装扩展的位置
-endorseddirs <目录> 覆盖签名的标准路径的位置
-proc:{none,only} 控制是否执行注释处理和/或编译。
-processor <class1>[,<class2>,<class3>...] 要运行的注释处理程序的名称; 绕过默认的搜索进程
-processorpath <路径> 指定查找注释处理程序的位置
-parameters 生成元数据以用于方法参数的反射
-d <目录> 指定放置生成的类文件的位置
-s <目录> 指定放置生成的源文件的位置
-h <目录> 指定放置生成的本机标头文件的位置
-implicit:{none,class} 指定是否为隐式引用文件生成类文件
-encoding <编码> 指定源文件使用的字符编码
-source <发行版> 提供与指定发行版的源兼容性
-target <发行版> 生成特定 VM 版本的类文件
-profile <配置文件> 请确保使用的 API 在指定的配置文件中可用
-version 版本信息
-help 输出标准选项的提要
-A关键字[=值] 传递给注释处理程序的选项
-X 输出非标准选项的提要
-J<标记> 直接将 <标记> 传递给运行时系统
-Werror 出现警告时终止编译
@<文件名> 从文件读取选项和文件名
javap 的用法
$ javap -help
用法: javap <options> <classes>
其中, 可能的选项包括:
-help --help -? 输出此用法消息
-version 版本信息
-v -verbose 输出附加信息
-l 输出行号和本地变量表
-public 仅显示公共类和成员
-protected 显示受保护的/公共类和成员
-package 显示程序包/受保护的/公共类
和成员 (默认)
-p -private 显示所有类和成员
-c 对代码进行反汇编
-s 输出内部类型签名
-sysinfo 显示正在处理的类的
系统信息 (路径, 大小, 日期, MD5 散列)
-constants 显示最终常量
-classpath <path> 指定查找用户类文件的位置
-cp <path> 指定查找用户类文件的位置
-bootclasspath <path> 覆盖引导类文件的位置
6、JVM指令集对照表
-
算数操作与类型转换
7、方法调用的指令
- invokestatic,顾名思义,这个指令用于调用某个类的静态方法,这是方法调用指令中最快的一个。
- invokespecial, 用来调用构造函数,但也可以用于调用同一个类中的 private 方法, 以及可见的超类方法。
- invokevirtual,如果是具体类型的目标对象,invokevirtual 用于调用公共,受保护和 package 级的私有方法。
- invokeinterface,当通过接口引用来调用方法时,将会编译为 invokeinterface 指令。
- invokedynamic,JDK7 新增加的指令,是实现“动态类型语言”(Dynamically Typed Language)支持而进行的升级改进,同时也是 JDK8 以后支持 lambda 表达式的实现基础。
二、JVM类加载器
1、JVM加载class文件的原理机制
类装载器就是寻找类或接口字节码文件进行解析并构造JVM内部对象表示的组件,在java中类装载器把一个类装入JVM,经过以下步骤:
1、装载:查找和导入Class文件 2、链接:其中解析步骤是可以选择的 (a)检查:检查载入的class文件数据的正确性 (b)准备:给类的静态变量分配存储空间 (c)解析:将符号引用转成直接引用 3、初始化:对静态变量,静态代码块执行初始化工作
类装载工作由ClassLoder
和其子类负责。JVM在运行时会产生三个ClassLoader:简单描述下JVM的三种类加载器以及三种特性。
注意 :
java.lang.String
永远是由启动类装载器来装载,如果自己写了一个java.lang.String的类,是无法将其替换调的。- 类文件被装载解析后,在
JVM
中都有一个对应的java.lang.Class
对象,提供了类结构信息的描述。数组,枚举及基本数据类型,甚至void
都拥有对应的Class
对象。Class
类没有public
的构造方法,Class
对象是在装载类时由JVM
通过调用类装载器中的defineClass()
方法自动构造的。- 为什么要使用这种双亲委托模式呢?
因为这样可以避免重复加载,当父亲已经加载了该类的时候,就没有必要子ClassLoader再加载一次。
考虑到安全因素,我们试想一下,如果不使用这种委托模式,那我们就可以随时使用自定义的String来动态替代java核心api中定义类型,这样会存在非常大的安全隐患,而双亲委托的方式,就可以避免这种情况,因为String已经在启动时被加载,所以用户自定义类是无法加载一个自定义的ClassLoader。
2、类的加载时机
1. 当虚拟机启动时,初始化用户指定的主类,就是启动执行的 main 方法所在的类;
2. 当遇到用以新建目标类实例的 new 指令时,初始化 new 指令的目标类,就是 new 一个类的时候要初始化;3. 当遇到调用静态方法的指令时,初始化该静态方法所在的类;4. 当遇到访问静态字段的指令时,初始化该静态字段所在的类;5. 子类的初始化会触发父类的初始化;6. 如果一个接口定义了 default 方法,那么直接实现或者间接实现该接口的类的初始化,会触发该接口的初始化;7. 使用反射 API 对某个类进行反射调用时,初始化这个类,其实跟前面一样,反射调用要么是已经有实例了,要么是静态方法,都需要初始化;8. 当初次调用 MethodHandle 实例时,初始化该 MethodHandle 指向的方法所在的类。
3、类~不会初始化(可能会加载)
1. 通过子类引用父类的静态字段,只会触发父类的初始化,而不会触发子类的初始化。
2. 定义对象数组,不会触发该类的初始化。3. 常量在编译期间会存入调用类的常量池中,本质上并没有直接引用定义常量的类,不会触发定义常量所在的类。4. 通过类名获取 Class 对象,不会触发类的初始化,Hello.class 不会让 Hello 类初始化。5. 通过 Class.forName 加载指定类时,如果指定参数 initialize 为 false 时,也不会触发类初始化,其实这个参数是告诉虚拟机,是否要对类进行初始化。(Class.forName”jvm.Hello”)默认会加载 Hello 类。6. 通过 ClassLoader 默认的 loadClass 方法,也不会触发初始化动作(加载了,但是不初始化)。
4、JVM的三种类加载器
- 启动类加载器(Bootstrap classLoader):又称为引导类加载器,由C++编写,无法通过程序得到。主要负责加载JAVA中的一些核心类库,主要是位于<JAVA_HOME>/lib/rt.jar中。举例来说,java.lang.String是由启动类加载器加载的,所以String.class.getClassLoader()就会返回null。
- 拓展类加载器(Extension classLoader):主要加载JAVA中的一些拓展类,位于<JAVA_HOME>/lib/ext中,是启动类加载器的子类。
代码里直接获取它的父类加载器为null (因为无法拿到启动类加载器)。- 应用类加载器(AppClassLoader): 又称为系统类加载器,主要用于加载CLASSPATH路径下我们自己写的类,是拓展类加载器的子类。
在序代码里可以通过 ClassLoader 的静态方法 getSystemClassLoader() 来获取应用类加载器。
三者的关系:
常见问题:
1.Object类是由哪个类加载器加载的?
BootStrap ClassLoader
2.我们自己写的类是由哪个类加载器加载的?
AppClassLoader
3.类加载器都是我们Java中的一个类ClassLoader的子类吗?
BootStrap ClassLoader不是的,另外两个是的。
类加载器的三大特性:委托性(双亲委托)、可见性(负责依赖)、单一性(缓存加载)
委托性(双亲委托):当一个自定义类加载器需要加载一个类,比如java.lang.String,它很 懒,不会一上来就直接试图加载它,而是先委托自己的父加载器去加载,父加载 器如果发现自己还有父加载器,会一直往前找,这样只要上级加载器,比如启动 类加载器已经加载了某个类比如java.lang.String,所有的子加载器都不需要自己 加载了。如果几个类加载器都没有加载到指定名称的类,那么会抛出 ClassNotFountException异常。
可见性(负责依赖):可见性指的是父加载器无法利用子加载器加载的类,而子加载器可以利用父加载器加载的类。如果一个加载器在加载某个类的时候,发现这个类依赖于另外几个类或接口,也会去尝试加载这些依赖项。
单一性(缓存加载):一个类只会被一个类加载器加载一次,不会被重复加载。加载完会被缓存在内存里,下次可以直接使用。
5、自定义类加载器示例
自定义类加载器通过继承
ClassLoader
这个抽象类去实现,可以扩展他的loadClass
方法,去实现自己的类加载方式。自定义的类加载器虽然他们都有同一个父类是AppClassLoader,但是他们是完全隔离开的,两个不同的容器。
加载的一个Hello类,打印出来一句“Hello Class Initialized!”。假设这个类的内容非常重要,不想把这段代码以及编译后的Hello.class给别人,但是我们还是想别人可以调用或执行这个类,应该怎么办呢?一个简单的思路是,我们把这个类的class文件二进制作为字节流先加密一下,然后尝试通过自定义的类加载器来加载加密后的数据。
package jvm;
public class Hello {
static {
System.out.println("Hello Class Initialized!");
}
}
为了演示简单,我们使用jdk自带的Base64算法,把字节码加密成一个文本。
在下面的代码里,实现HelloClassLoader类,继承ClassLoader类,解析上面提供Base64字符串,执行后,会把Hello类里的字符串“Hello Class Initialized!”打印出来。
package jvm;
import java.util.Base64;
public class HelloClassLoader extends ClassLoader {
public static void main(String[] args) {
try {
// 加载并初始化Hello类
new HelloClassLoader().findClass("jvm.Hello").newInstance();
} catch (Exception e) {
e.printStackTrace();
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException
//指定文件经过base64加密后的字符串
String helloBase64 = "yv66vgAAADQAHwoABgARCQASABMIABQKAB...AAgAN";
byte[] bytes = decode(helloBase64);
return defineClass(name,bytes,0,bytes.length);
}
//jdk自带的base64解码,返回byte数组,这里等于指定文件(Hello.class)的字节数组
public byte[] decode(String base64){
return Base64.getDecoder().decode(base64);
}
}
6、添加引用类的几种方式
1、类文件(.class文件、jar包)放到 JDK 的 lib/ext 下,或者在启动java程序通过参数
-Djava.ext.dirs 额外添加类的扩展路径或者jar包的扩展路径。
2、通过 java -cp或者 java -classpath命令,指定当前JVM需要引入的jar包路径 ,或者把class 文件放到当前路径下。3、自定义 ClassLoader 加载。4、拿到当前执行类的 ClassLoader,反射调用 addUrl 方法添加 Jar 或路径(JDK9 无效)
package jvm;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLClassLoader;
public class JvmAppClassLoaderAddURL {
public static void main(String[] args) {
String appPath = "file:/d:/app/";
URLClassLoader urlClassLoader =
(URLClassLoader) JvmAppClassLoaderAddURL.class.getClassLoader();
try {
//拿到URLClassLoader内部的addURL方法
Method addURL =
URLClassLoader.class.getDeclaredMethod("addURL",URL.class);
//addUrl()该方法默认不可见的,设置成可见的。
addURL.setAccessible(true);
URL url = new URL(appPath);
addURL.invoke(urlClassLoader, url);
// 效果跟Class.forName("jvm.Hello").newInstance()一样
Class.forName("jvm.Hello");
} catch (Exception e) {
e.printStackTrace();
}
}
}
7、Class.forName和ClassLoader 的区别
- Class.forName:除了将类的.class文件加载到jvm中之外,还会默认对类进行初始化,执行类中的静态代码块,以及对静态变量的赋值等操作。
- ClassLoader:将.class文件加载到jvm中,默认不会对类进行初始化,只有在newInstance才会去执行static块。
本质上Class.forName()复用了ClassLoader.loadClass(),只是默认指定了特定参数。看下面源码,Class 的静态 forName()
方法有两个版本,除了指定类名称,还可以指定类名称、加载时是否运行静态区块、指定类加载器。
public static Class<?> forName(String className)
throws ClassNotFoundException
public static Class<?> forName(String name,boolean initialize,ClassLoader loader)
throws ClassNotFoundException
第一个forName()
方法底层调用的是forName0()
,写死initialize=true,指定加载时运行静态区块,这也是为何前面说的 Class.forName默认对类进行初始化的根本原因。
public static Class<?> forName(String className)throws ClassNotFoundException {
Class<?> caller = Reflection.getCallerClass();
return forName0(className, true,
ClassLoader.getClassLoader(caller),caller); //写死入参true
}
8、实用技巧举例
1、三种类加载器各自默认加载了哪些jar包和包含了哪些 classpath的路径。可以解决jar包找不到的问题。代码如下:
package jvm;
import java.lang.reflect.Field;
import java.net.URL;
import java.net.URLClassLoader;
import java.util.ArrayList;
public class JvmClassLoaderPrintPath {
public static void main(String[] args) {
// 启动类加载器 虽然无法直接拿到,
//但是Open Jdk, Oracle Jdk可以通过静态方法拿到它的classPath
URL[] urls = sun.misc.Launcher.getBootstrapClassPath().getURLs();
System.out.println("启动类加载器");
for(URL url : urls) {
System.out.println(" ==> " +url.toExternalForm());
}
// 扩展类加载器
printClassLoader("扩展类加载器",
JvmClassLoaderPrintPath.class.getClassLoader.getParent());
// 应用类加载器
printClassLoader("应用类加载器",
JvmClassLoaderPrintPath.class.getClassLoader());
}
public static void printClassLoader(String name, ClassLoader CL){
if(CL != null) {
System.out.println(name + " ClassLoader ‐> " + CL.toString());
printURLForClassLoader(CL);
}else{
System.out.println(name + " ClassLoader ‐> null");
}
}
public static void printURLForClassLoader(ClassLoader CL){
Object ucp = insightField(CL,"ucp");
Object path = insightField(ucp,"path");
ArrayList ps = (ArrayList) path;
for (Object p : ps){
System.out.println(" ==> " + p.toString());
}
}
private static Object insightField(Object obj, String fName) {
try {
Field f = null;
if(obj instanceof URLClassLoader){
if = URLClassLoader.class.getDeclaredField(fName);
}else{
f = obj.getClass().getDeclaredField(fName);
}
f.setAccessible(true);
return f.get(obj);
} catch (Exception e) {
e.printStackTrace();
return null;
}
}
}
2、如何排查类的方法不一致的问题?