JVM进阶——大厂面试必知必会

一、字节码技术

1、什么是字节码?

Java bytecode 由单字节(byte)的指令组成,理论上最多支持 256 个操作码(opcode)。
实际上 Java 只使用了200左右的操作码, 还有一些操作码则保留给调试操作。
根据指令的性质,主要分为四个大类:
  1. 栈操作指令,包括与局部变量交互的指令。
  2. 程序流程控制指令(方法的内部有if、for循环等)。
  3. 对象操作指令,包括方法调用指令。
  4. 算术运算以及类型转换指令

2、生成字节码

假如有一个最简单的类,源代码如下:
package demo.jvm_01;

public class HelloByteCode {

    public static void main(String[] args) {
        HelloByteCode obj = new HelloByteCode();
    }

}

编译:javac  demo/jvm_01/HelloByteCode.java

查看字节码:javap  -c  demo/jvm_01/HelloByteCode
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的三种类加载器以及三种特性。

注意 :

  1. java.lang.String永远是由启动类装载器来装载,如果自己写了一个java.lang.String的类,是无法将其替换调的。
  2. 类文件被装载解析后,在JVM中都有一个对应的java.lang.Class对象,提供了类结构信息的描述。数组,枚举及基本数据类型,甚至void都拥有对应的Class对象。Class类没有public的构造方法,Class对象是在装载类时由JVM通过调用类装载器中的defineClass()方法自动构造的。
  3. 为什么要使用这种双亲委托模式呢?

        因为这样可以避免重复加载,当父亲已经加载了该类的时候,就没有必要子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() 来获取应用
    类加载器。

三者的关系:

一般启动类加载器是由 JVM 内部实现的,在 Java API里无法拿到,后 2 种类加载器在 Oracle Hotspot JVM里,都是在 sun.misc.Launcher 中定义的,扩展类加载器和应用类加载器一般都继承自 URLClassLoader 类,这个类也默认实现了从各种不同来源加载 class 字节码转换成Class的方法。
 
此外还可以自定义类加载器。如果用户自定义了类加载器,则自定义类加载器都以应用类加载器 AppClassLoader 作为父加载器。

常见问题:

    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 无效)
对于第四种方式:应用类加载器、扩展类加载器的父类是 URLClassLoader 类,可以拿到内部的addUrl(),该方法默认是protected 不可见的,通过反射设置成可见的。最后调用此方法添加路径就可以了。
那么第四种方式为什么jdk9无效呢?
因为在jdk9版本中,应用类加载器、扩展类加载器、URL类加载器是平级的,没有继承关系。所以上面把应用类加载器、扩展类加载器转换成URL类加载器后,调用addUrl() 是无效的。那么只需要Class.forName("jvm.Hello",new URLClassLoader()); 就可以解决。
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、如何排查类的方法不一致的问题?

假如我们确定一个 jar 或者 class 已经在 classpath 里了,但是却总是提 java.lang.NoSuchMethodError ,这是怎么回事呢?很可能是加载了错误的或 者重复加载了不同版本的jar 包。这时候,用前面的方法就可以先排查一下,加载了具 体什么jar ,然后是不是不同路径下有重复的 class 文件,但是版本不一样。
3、怎么看到加载了哪些类,以及加载顺序?
 
假如有两个地方有 Hello.class ,一个是新版本,一个是旧的, 怎么才能直观地看到他们的加载顺序呢?直接打印加载的类清单和加载顺序。 只需要在类的启动命令行参数加上 ‐XX:+TraceClassLoading 或者 ‐verbose 即可,注意需要加载java 命令之后,要执行的类名之前,不然不起作用。例如:
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值