1-JVM-类加载机制深度剖析

Class文件
在这里插入图片描述

  • 每个Class文件的头4个字节被称为魔数(Magic Number)
  • 紧接着魔数的4个字节存储的是Class文件的版本号:第5和第6个字节是次版本号(Minor
    Version),第7和第8个字节是主版本号(Major Version)
  • 紧接着主、次版本号之后的是常量池入口
  • 量池结束之后,紧接着的2个字节代表访问标志(access_flags)
  • 类索引、父类索引与接口索引集合
  • 字段表集合
  • 方法表集合
  • 属性表集
public class Math {
    /**
     * 一个方法对应一块栈帧内存区域
     * @return
     */
    public int compute(){
        int a =1;
        int b= 2;
        int c = (a+b)*10;
        return c;
    }

    public static void main(String[] args) {
        Math math = new Math();
        math.compute();
        System.out.printf("==end==");
    }
}

字节指令码
参考: https://www.cnblogs.com/longjee/p/8675771.html 可以分析程序的执行过程

E:\LIULI\GitHub-472732787\suanfa\target\classes\com\jvm\jvm>javap -c Math.class
Compiled from "Math.java"
public class com.jvm.jvm.Math {
  public static int initDate;
  static {};
    Code:
       0: sipush        666
       3: putstatic     #10                 // Field initDate:I
       6: return

  public com.jvm.jvm.Math();
    Code:
       0: aload_0
       1: invokespecial #15                 // Method java/lang/Object."<init>":()V
       4: return

  public int compute();
    Code:
       0: iconst_1
       1: istore_1
       2: iconst_2
       3: istore_2
       4: iload_1
       5: iload_2
       6: iadd
       7: bipush        10
       9: imul
      10: istore_3
      11: iload_3
      12: ireturn

  public static void main(java.lang.String[]);
    Code:
       0: new           #1                  // class com/jvm/jvm/Math
       3: dup
       4: invokespecial #26                 // Method "<init>":()V
       7: astore_1
       8: aload_1
       9: invokevirtual #27                 // Method compute:()I
      12: pop
      13: getstatic     #29                 // Field java/lang/System.out:Ljava/io/PrintStream;
      16: ldc           #35                 // String ==end==
      18: iconst_0
      19: anewarray     #3                  // class java/lang/Object
      22: invokevirtual #37                 // Method java/io/PrintStream.printf:(Ljava/lang/String;[Ljava/lang/Object;)Ljava/io/PrintStream;
      25: pop

常量池
一般可以通过javap命令生成更可读的JVM字节码指令文件:javap -v Math.class
在这里插入图片描述

红框标出的就是class常量池信息,常量池中主要存放两大类常量:字面量和符号引用。
字面量:指由字母、数字等构成的字符串或者数值常量,只可以右值出现,所谓右值是指等号右边的值,如:int a=1 这里的a为左值,1为右值。在这个例子中1就是字面量。

int a = 1;

符号引用 是编译原理中的概念,是相对于直接引用来说的。主要包括:

  • 类和接口的全限定名
  • 字段的名称和描述符
  • 方法的名称和描述符
  • 被模块导出或者开放的包
  • 方法句柄和方法类型
  • 动态调用点和动态常量

不同于C和C++,Class文件中不会保存各个方法、字段最终在内存中的布局信息,这些字段、方法的符号引用不经过虚拟机在运行期转换的话是无法得到真正的内存入口地址,也就无法直接被虚拟机使用的。当虚拟机做类加载时,将会从常量池获得对应的符号引用,再在类创建时或运行时解析、翻译到具体的内存地址之中

字符串常量池

  1. 字符串的分配,和其他的对象分配一样,耗费高昂的时间与空间代价,作为最基础的数据类型,大量频繁的创建字符串,极大程度地影响程序的性能
  2. JVM为了提高性能和减少内存开销,在实例化字符串常量的时候进行了一些优化
1.为字符串开辟一个字符串常量池,类似于缓存区
2.创建字符串常量时,首先查询字符串常量池是否存在该字符串
3.存在该字符串,返回引用实例,不存在,实例化该字符串并放入池中
Jdk1.6及之前: 有永久代, 常量池在方法区
Jdk1.7:有永久代,但已经逐步“去永久代”,常量池在堆
Jdk1.8及之后: 无永久代,常量池在元空间
/**
 * VM Args: -Xms10M -Xmx10M
 */
public class ` {
    public static void main(String[] args) {
        ArrayList<String> list = new ArrayList<>();
        for (int i = 0; i < 100000000; i++) {
            for (int j = 0; j < 1000000; j++) {
                list.add(String.valueOf(i + j / 1000000).intern());  
            }
        }
    }
}
运行结果:
7+:Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
6:Exception in thread "main" java.lang.OutOfMemoryError: PermGen space

字符串常量池底层是hotspot的C++实现的,底层类似一个 HashTable, 保存的本质上是字符串对象的引用。
三种字符串操作(Jdk1.7 及以上版本)

1.直接赋值字符串

String s = "liuli";  // s指向常量池中的引用

这种方式创建的字符串对象,只会在常量池中。创建对象s的时候,JVM会先去常量池中通过 equals(key) 方法,判断是否有相同的对象 有直接返回该对象在常量池中的引用;没有则会在常量池中创建一个新对象,再返回引用。
2. new String();

String s1 = new String("liuli");  // s1指向内存中的对象引用

这种方式会保证字符串常量池和堆中都有这个对象,没有就创建,最后返回堆内存中的对象引用。
步骤大致如下:先检查字符串常量池中是否存在字符串"liuli"
不存在,先在字符串常量池里创建一个字符串对象;再去内存中创建一个字符串对象"liuli";
存在的话,就直接去堆内存中创建一个字符串对象"liuli";最后,将内存中的引用返回。
3. intern方法

String s1 = new String("liuli");   
String s2 = s1.intern();
System.out.println(s1 == s2);  //false

String中的intern方法是一个 native 的方法,当调用 intern方法时,如果池已经包含一个等于此String对象的字符串(用equals(oject)方法确定),则返回池中的字符串。否则,将intern返回的引用指向当前字符串 s1(jdk1.6版本需要将 s1 复制到字符串常量池里)。

String s1 = new String("he") + new String("llo");
String s2 = s1.intern();
System.out.println(s1 == s2);
// 在 JDK 1.6 下输出是 false,创建了 6 个对象
// 在 JDK 1.7 及以上的版本输出是 true,创建了 5 个对象
// 当然我们这里没有考虑GC,但这些对象确实存在或存在过

1、在 JDK 1.6 中,调用 intern() 首先会在字符串池中寻找 equal() 相等的字符串,假如字符串存在就返回该字符串在字符串池中的引用;假如字符串不存在,虚拟机会重新在永久代上创建一个实例,将 StringTable 的一个表项指向这个新创建的实例。
在这里插入图片描述
2、在 JDK 1.7 (及以上版本)中,由于字符串池不在永久代了,intern() 做了一些修改,更方便地利用堆中的对象。字符串存在时和 JDK 1.6一样,但是字符串不存在时不再需要重新创建实例,可以直接指向堆上的实例。
在这里插入图片描述
由上面两个图,也不难理解为什么 JDK 1.6 字符串池溢出会抛出 OutOfMemoryError: PermGen space ,而在 JDK 1.7 及以上版本抛出 OutOfMemoryError: Java heap space 。
八种基本类型的包装类和对象池

java中基本类型的包装类的大部分都实现了常量池技术,这些类是Byte,Short,Integer,Long,Character,Boolean,另外两种浮点数类型的包装类则没有实现。另外Byte,Short,Integer,Long,Character这5种整型的包装类也只是在对应值小于等于127时才可使用对象池,也即对象不负责创建和管理大于127的这些类的对象。

public class Test {
    public static void main(String[] args) {
        //5种整形的包装类Byte,Short,Integer,Long,Character的对象,  
        //在值小于127时可以使用常量池  
        Integer i1 = 127;
        Integer i2 = 127;
        System.out.println(i1 == i2);//输出true  
        //值大于127时,不会从常量池中取对象  
        Integer i3 = 128;
        Integer i4 = 128;
        System.out.println(i3 == i4);//输出false  
        //Boolean类也实现了常量池技术  
        Boolean bool1 = true;
        Boolean bool2 = true;
        System.out.println(bool1 == bool2);//输出true  
        //浮点类型的包装类没有实现常量池技术  
        Double d1 = 1.0;
        Double d2 = 1.0;
        System.out.println(d1 == d2);//输出false  
    }
} 

类加载过程
java命令运行某个主类的main函数启动程序,这里首先需要通过类加载器把主类加载到JVM。主类在运行过程中如果使用到其它类,会逐步加载这些类,jar包里的类不是一次性全部加载的,使用到时才加载。类加载到使用整个过程有如下几步:
在这里插入图片描述

  • 加载:在硬盘上查找并通过IO读入字节码文件,使用到类时才会加载,例如调用类的main()方法,new对象等等,在加载阶段会在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口

  • 验证:校验字节码文件的正确性

  • 准备:给类的静态变量分配内存,并赋予默认值

  • 解析:将符号引用替换为直接引用,该阶段会把一些静态方法(符号引用,比如main()方法)替换为指向数据所存内存的指针或句柄等(直接引用),这是所谓的静态链接过程(类加载期间完成),动态链接是在程序运行期间完成的将符号引用替换为直接引用

  • 初始化:对类的静态变量初始化为指定的值,执行静态代码块【初始化阶段是执行类构造器方法的过程,虚拟机会保证子方法执行之前,父类的方法已经执行完毕,如果一个类中没有对静态变量赋值也没有静态语句块,那么编译器可以不为这个类生成()方法】

    解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始
    在这里插入图片描述
    类加载器和双亲委派机制
    比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义
    上面的类加载过程主要是通过类加载器来实现的,Java里有如下几种类加载器

  • 启动类加载器:负责加载支撑JVM运行的位于JRE的lib目录下的核心类库,比如rt.jar、charsets.jar等

  • 扩展类加载器:负责加载支撑JVM运行的位于JRE的lib目录下的ext扩展目录中的JAR类包

  • 应用程序类加载器:负责加载ClassPath路径下的类包,主要就是加载你自己写的那些类

  • 自定义加载器:负责加载用户自定义路径下的类包
    在这里插入图片描述
    看一个类加载器示例:

public class TestJDKClassLoader {
    public static void main(String[] args){
        System.out.println(String.class.getClassLoader());
        System.out.println(com.sun.crypto.provider.DESKeyFactory.class.getClassLoader().getClass().getName());
        System.out.println(TestJDKClassLoader.class.getClassLoader().getClass().getName());
        System.out.println(ClassLoader.getSystemClassLoader().getClass().getName());
    }
}

运行结果:
null    //启动类加载器是C++语言实现,所以打印不出来
sun.misc.Launcher$ExtClassLoader
sun.misc.Launcher$AppClassLoader
sun.misc.Launcher$AppClassLoader

自定义一个类加载器示例:
自定义类加载器只需要继承 java.lang.ClassLoader 类,该类有两个核心方法:
1.loadClass(String, boolean),实现了双亲委派机制,大体逻辑

  • 检查一下指定名称的类是否已经加载过,如果加载过了,就不需要再加载,直接返回。
  • 如果此类没有加载过,那么,再判断一下是否有父加载器;如果有父加载器,则由父加载器加载(即调用parent.loadClass(name,
    false);).或者是调用bootstrap类加载器来加载。
  • 如果父加载器及bootstrap类加载器都没有找到指定的类,那么调用当前类加载器的findClass方法来完成类加载。

2.findClass,默认实现是抛出异常,所以我们自定义类加载器主要是重写findClass方法。

public class MyClassLoaderTest {
    static class MyClassLoader extends ClassLoader {
        private String classPath;
        public MyClassLoader(String classPath) {
            this.classPath = classPath;
        }
        private byte[] loadByte(String name) throws Exception {
            name = name.replaceAll("\\.", "/");
            FileInputStream fis = new FileInputStream(classPath + "/" + name
                    + ".class");
            int len = fis.available();
            byte[] data = new byte[len];
            fis.read(data);
            fis.close();
            return data;
        }
        protected Class<?> findClass(String name) throws ClassNotFoundException {
            try {
                byte[] data = loadByte(name);
                //defineClass将一个字节数组转为Class对象,这个字节数组是class文件读取后最终的字节数组。
                return defineClass(name, data, 0, data.length);
            } catch (Exception e) {
                e.printStackTrace();
                throw new ClassNotFoundException();
            }
        }

    }

    public static void main(String args[]) throws Exception {
        MyClassLoader classLoader = new MyClassLoader("D:/test");
        Class clazz = classLoader.loadClass("com.jvm.User1");
        Object obj = clazz.newInstance();
        Method method= clazz.getDeclaredMethod("sout", null);
        method.invoke(obj, null);
        System.out.println(clazz.getClassLoader().getClass().getName());
    }
}

运行结果:
=======自己的加载器加载类调用方法=======
com.jvm.MyClassLoaderTest$MyClassLoader

全盘负责委托机制
“全盘负责”是指当一个ClassLoder装载一个类时,除非显示的使用另外一个ClassLoder,该类所依赖及引用的类也由这个ClassLoder载入。
双亲委派机制
JVM类加载器是有亲子层级结构的,如下图
在这里插入图片描述

这里类加载其实就有一个双亲委派机制,加载某个类时会先委托父加载器寻找目标类,找不到再委托上层父加载器加载,如果所有父加载器在自己的加载类路径下都找不到目标类,则在自己的类加载路径中查找并载入目标类。
比如我们的Math类,最先会找应用程序类加载器加载,应用程序类加载器会先委托扩展类加载器加载,扩展类加载器再委托启动类加载器,顶层启动类加载器在自己的类加载路径里找了半天没找到Math类,则向下退回加载Math类的请求,扩展类加载器收到回复就自己加载,在自己的类加载路径里找了半天也没找到Math类,又向下退回Math类的加载请求给应用程序类加载器,应用程序类加载器于是在自己的类加载路径里找Math类,结果找到了就自己加载了。。

双亲委派机制说简单点就是,先找父亲加载,不行再由儿子自己加载
为什么要设计双亲委派机制?

  1. 沙箱安全机制:自己写的java.lang.String.class类不会被加载,这样便可以防止核心API库被随意篡改
  2. 避免类的重复加载:当父亲已经加载了该类时,就没有必要子ClassLoader再加载一次,保证被加载类的唯一性
    看一个类加载示例:
package java.lang;

public class String {
    public static void main(String[] args) {
        System.out.println("**************My String Class**************");
    }
}

运行结果:
错误: 在类 java.lang.String 中找不到 main 方法, 请将 main 方法定义为:
   public static void main(String[] args)
否则 JavaFX 应用程序类必须扩展javafx.application.Application

再来一个沙箱安全机制示例,尝试打破双亲委派机制,用自定义类加载器加载我们自己实现的 java.lang.String.class【它可以正常编译,但永远无法被加载运行】

public class MyClassLoaderTest {
    static class MyClassLoader extends ClassLoader {
        private String classPath;
        public MyClassLoader(String classPath) {
            this.classPath = classPath;
        }
        private byte[] loadByte(String name) throws Exception {
            name = name.replaceAll("\\.", "/");
            FileInputStream fis = new FileInputStream(classPath + "/" + name
                    + ".class");
            int len = fis.available();
            byte[] data = new byte[len];
            fis.read(data);
            fis.close();
            return data;

        }
        protected Class<?> findClass(String name) throws ClassNotFoundException {
            try {
                byte[] data = loadByte(name);
                return defineClass(name, data, 0, data.length);
            } catch (Exception e) {
                throw new ClassNotFoundException();
            }
        }
        /**
         * 重写类加载方法,实现自己的加载逻辑,不委派给双亲加载
         */
        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) {
                    // 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.getFindClassTime().addElapsedTimeFrom(t1);
                    sun.misc.PerfCounter.getFindClasses().increment();
                }
                if (resolve) {
                    resolveClass(c);
                }
                return c;
            }
        }
    }
    public static void main(String args[]) throws Exception {
        MyClassLoader classLoader = new MyClassLoader("D:/test");
        //尝试用自己改写类加载机制去加载自己写的java.lang.String.class
        Class clazz = classLoader.loadClass("java.lang.String");
        Object obj = clazz.newInstance();
        Method method= clazz.getDeclaredMethod("sout", null);
        method.invoke(obj, null);
        System.out.println(clazz.getClassLoader().getClass().getName());
    }
}
运行结果:
java.lang.SecurityException: Prohibited package name: java.lang
	at java.lang.ClassLoader.preDefineClass(ClassLoader.java:659)
	at java.lang.ClassLoader.defineClass(ClassLoader.java:758)

打破双亲委派
以Tomcat类加载为例,Tomcat 如果使用默认的双亲委派类加载机制行不行?我们思考一下:Tomcat是个web容器, 那么它要解决什么问题:

  1. 一个web容器可能需要部署两个应用程序,不同的应用程序可能会依赖同一个第三方类库的不同版本,不能要求同一个类库在同一个服务器只有一份,因此要保证每个应用程序的类库都是独立的,保证相互隔离。
  2. 部署在同一个web容器中相同的类库相同的版本可以共享。否则,如果服务器有10个应用程序,那么要有10份相同的类库加载进虚拟机。
  3. web容器也有自己依赖的类库,不能与应用程序的类库混淆。基于安全考虑,应该让容器的类库和程序的类库隔离开来。
  4. . web容器要支持jsp的修改,我们知道,jsp 文件最终也是要编译成class文件才能在虚拟机中运行,但程序运行后修改jsp已经是司空见惯的事情, web容器需要支持 jsp 修改后不用重启。

再看看我们的问题:Tomcat 如果使用默认的双亲委派类加载机制行不行?
答案是不行的。为什么?

第一个问题,如果使用默认的类加载器机制,那么是无法加载两个相同类库的不同版本的,默认的类加器是不管你是什么版本的,只在乎你的全限定类名,并且只有一份。
第二个问题,默认的类加载器是能够实现的,因为他的职责就是保证唯一性。
第三个问题和第一个问题一样。
我们再看第四个问题,我们想我们要怎么实现jsp文件的热加载,jsp
文件其实也就是class文件,那么如果修改了,但类名还是一样,类加载器会直接取方法区中已经存在的,修改后的jsp是不会重新加载的。那么怎么办呢?我们可以直接卸载掉这jsp文件的类加载器,所以你应该想到了,每个jsp文件对应一个唯一的类加载器,当一个jsp文件修改了,就直接卸载这个jsp类加载器。重新创建类加载器,重新加载jsp文件。

Tomcat自定义加载器详解
在这里插入图片描述

线程上下文类加载器(Thread Context
ClassLoader),这个类加载器可以通过java.lang.Thread类的setContextClassLoader()方法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过的话,那么这个类加载器就是应用程序类加载器。例如:JNDI、JDBC。

java模块化

在JDK 9中引入的Java模块化系统(Java Platform Module System,JPMS)
是扩展类加载器(Extension Class Loader)被平台类加载器(Platform Class Loader)取代
平台类加载器和应用程序类加载器都不再派生自java.net.URLClassLoader
现在启动类加载器、平台类加载器、应用程序类加载器全都继承于 jdk.internal.loader.BuiltinClassLoader

字节码执行引擎
在这里插入图片描述

每一个栈帧都包括了局部变量表、操作数栈、动态连接、方法返回地址和一些额外的附加信息,一个栈帧需要分配多少内存,并不会受到程序运行期变量数据的影响,而仅仅取决于程序源码和具体的虚拟机实现的栈内存布局形式。
而对于执行引擎来讲,在活动线程中,只有位于栈顶的方法才是在运行的,只有位于栈顶的栈帧才是生效的,其被称为“当前栈帧”(Current Stack Frame),与这个栈帧所关联的方法被称为“当前方法”(Current Method)

  • 局部变量表 用于存放方法参数和方法内部定义的局部变量
  • 操作数栈 后入先出LIFO
  • 动态连接
  • 方法返回地址
  • 额外的附加信息

在概念模型中,两个不同栈帧作为不同方法的虚拟机栈的元素,是完全相互独立的。但是在大多虚拟机的实现里都会进行一些优化处理,令两个栈帧出现一部分重叠

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值