JVM内存结构与类加载

前言

还有十几天就过年了,最近公司也没啥事要做,所以我准备在空闲的这几天多出几篇文章。最近了解了JVM中类加载的流程,个人认为一个Android开发者是有必要掌握这部分内容的,它可以帮助你对很多知识点的理解更加透彻,本篇文章我会从JVM的内存结构来分析类的加载流程。另外,由于笔者水平有限,所以文章对JVM描述的深度可能会有所欠缺。

1 JVM内存结构

1.1 内存划分

java程序一旦运行就会在内存上开辟一块空间,随后JVM会对该内存空间进行更细致的划分,大概可以分为五块,示意图如下:


10073662-41489d2501c985ca.png
jvm内存示意图
  • 方法区:用来存储每个类的基本信息(类名、方法名等),以及静态变量、常量等等。方法区中的信息是可以被多线程共享的。
  • 堆:用来存储对象。在java中一旦通过new创建了一个对象,就会在堆内存中开辟一块空间来保存这个对象。
  • java栈:java栈也可称为是虚拟机栈,内部存储的是一个个被调用的方法以及方法内的局部变量,严格遵守栈数据结构,方法执行完毕后会进行弹栈。另外,一个线程对应一个栈区。
  • 本地方法区:与Java虚拟机栈基本相同,区别在于:Java虚拟机栈服务的是java方法,而本地方法栈服务Native方法。
  • 程序计数器:程序计数器是JVM中一块较小的内存区域,保存着当前线程执行的虚拟机字节码指令的内存地址。
1.2 对象在内存中的体现

相信每一个Android开发者在学习java SE的时候都或多或少了解过堆内存与栈内存的概念,掌握JVM中内存布局确实可以让开发者对java程序的理解更加清晰。下面我会写一小段代码,然后分析其在内存中的分布。

public class Girl {
    private static String sex = "女";
    private String name;
    private int age;

    public Girl(String name,int age){
        this.name = name;
        this.age = age;
    }

    public void show(){
        System.out.println(name);
        System.out.println(age);
        System.out.println(sex);
    }
}

创建了一个Girl类,然后再main方法中执行如下代码:

Girl girl = new Girl("Taylor",26);
girl.show();

线面我结合两张图来分析这两句代码对应的内存分布
Girl girl = new Girl("Taylor",26);内存示意图:


10073662-8e53bebe1ae344f8.png
new.png

首先main()方法入栈,执行了第一行代码试图创建一个Girl对象,随后构造方法入栈并在堆内存中开辟一块空间,将Taylor和26分别赋值给堆内存中的对象girl然后进行弹栈操作,至此一个对象创建完毕。最后还要将对象girl的内存地址值赋予main()方法中的局部变量girl。

girl.show();内存示意图:


10073662-6fbf95969d65dd47.png
show.png

构造方法出栈后show()方法会立即进入栈内存,show()方法中分别对内部的三个属性进行了控制台打印操作,由于该show()方法是由对象girl调用,所以name和age自然要到堆内存中girl对象中去找 。由于sex被静态符static修饰,所以它应该位于方法区中。show()方法执行完毕后依旧会进行弹栈操作。

通过这个小例子,相信你对栈、堆、方法区的理解更加深刻了。下面我们来开始本篇文章的"重头戏",JVM中类加载流程,你准备好了吗?

2 JVM类加载

在真正讲解之前先给大家抛出一个疑问,相信大家对java语言的使用早已是驾轻就熟,那么为什么还要分析类的加载过程呢?我认为大概有两点:

  • 有助于了解JVM运行过程
  • 有助于了解java动态性(动态加载类),提高程序的灵活性
2.1 初探类加载流程

整个类加载的生命周期大概可以细分为7个步骤,如下图:


10073662-df6337200b1bfbb8.png
conn.png

其中前五步为加载阶段,而验证、准备、解析又可概括为链接,所以类加载大致的一个流程为加载链接初始化,下面我将对这三部分逐个进行详细讲解。

加载

将类class文件内容加载到内存中,然后把静态数据转换为方法区需要的数据结构 (是转换,并不是将静态数据加载到方法区) ,最后在堆中生成一个类的Class对象用来访问方法区数据。其中class文件的表现形式就是字节数组,所以class文件的来源可以是本地文件、网络、jar包等等。另外加载过程中需要有类加载器参与,在java中类ClassLoader就是类加载器。

链接
  • 验证:验证加载进来的class文件各种格式是否符合JVM的要求。大致可以分为四点:文件格式验证元数据验证字节码验证符号引用验证
  • 准备:为静态变量分配内存,并赋予初始值。这个阶段开发者定义的值不会赋予静态变量并且也不会执行静态代码块。但如果为final修饰的变量会直接赋予开发者定义的值。
  • 解析:将常量池中的符号引用转换为直接引用
  • 符号引用:符号引用是以一组符号来描述所引用的目标,符号可以是任何的字面形式的字面量。
  • 直接引用:是指向目标的指针,偏移量或者能够直接定位的句柄。该引用是和内存中的而布局有关的,并且一定加载进来的。
初始化

初始化为类加载的最后一个阶段,这个阶段会为静态变量进行赋值,并且顺序执行静态代码块。初始化一般是由开发者书写代码时间接调用,下面我来列举几个触发初始化的条件:new对象、调用静态成员变量(除被final修饰的方法)、对类进行反射调用等等,这类操作可以称作为主动引用,并且基本所有对类的操作都会对类进行初始化,但也有一些操作是不会进行类初始化,比如:定义一个引用类型数组、调用被final修饰过的常量等等,而这类操作可以称作为被动引用

细节

public class Father {
    public static int age =  40;
    static {
        System.out.println("init Father");
    }
}

public class Son extends Father {
    static {
        System.out.println("init Son");
    }
}

定义了两个类,Son继承自Father,在main()方法中实现如下操作:

System.out.println(Son.age);

打印结果

init Father
40

有些同学可能会有疑问,Son调用了静态属性为什么Son类没有被初始化?这一点需要大家注意,Son继承自Father,age调用的是父类的静态属性,如果子类只是调用了父类的静态属性是不会被初始化的,这种方式其实也是被动引用的一种。

2.2 类加载器

类加载器的作用:将类class文件内容加载到内存中,然后把静态数据转换为方法区需要的数据结构 ,最后在堆中生成一个类的Class对象用来访问方法区数据。这句话我是从上面加载流程复制而来,其实类加载器的作用对应的就是上面所说的加载过程,在此就不在进行赘述。
下面我们来看一下java中类加载器的树状图:

10073662-19bf6acfc2567130.png
loader.png

  • 引导类加载器:用来加载Java核心库,由native代码(C++)实现,所以并不继承自ClassLoader。
  • 扩展类加载器::它用来加载 Java 的扩展库。Java 虚拟机的实现会提供一个扩展库目录。该类加载器在此目录里面查找并加载 Java 类。
  • 应用程序类加载器:它根据 Java 应用的类路径来加载 Java 类。一般来说,Java 应用的类都是由它来完成加载的。
  • 自定义类加载器:开发者自己定义的类加载器。

几个类加载器内部通过组合设计模式来实现继承,所以在这我们可以将几个类理解为子父类关系。另外,除了引导类加载器的其他几个类加载器都继承自java.lang.ClassLoader。下面我来通过几行代码来测试一下几个类加载器的关系:

 //通过getSystemClassLoader()获取应用程序类加载器
System.out.println(ClassLoader.getSystemClassLoader());
System.out.println(ClassLoader.getSystemClassLoader().getParent());      
System.out.println(ClassLoader.getSystemClassLoader().getParent().getParent());

打印结果:

sun.misc.Launcher$AppClassLoader@18b4aac2
sun.misc.Launcher$ExtClassLoader@45ee12a7
null

结果有点不和谐,前两个没啥问题,最后一个为啥是空呢?其实我们前面提到过,引导类加载器是通过C++实现的,所以获取到的为null。

2.3 类加载模式

java中常见的类的加载模式有两种:代理、双亲委派

  • 代理:直接交给其它类加载器加载指定的类
  • 双亲委派:将需要加载的类先交给父类进行加载,父类交给爷爷类加载以此类推,如果爷爷类加载器能加载就直接将加载结果返回,否则再交给自己的儿子类加载以此类推。

其实双亲委派也是代理的一种,而双亲委派采用的是标准的责任链设计模式

2.4 自定义类加载器

需求:将本地磁盘中的class文件(字节码文件)加载到内存中。
思路:基于双亲委派模式实现类加载器。
注意点:关于实现双亲委派的代码可能与2.3小节描述的双亲委派略有差异,但它还是属于双亲委派模式。

第一步:定义一个类FileClassLoader继承自ClassLoader,定义一个可以传入文件路径的构造方法

public class FileClassLoader extends ClassLoader {
    private String mFileDir;//文件路径
    FileClassLoader(String fileDir) {
        this.mFileDir = fileDir;
    }
    ...
}

第二步:定义一个方法getClassData(String name)读取字节码文件

//去本地文件获取类
    private byte[] getClassData(String name){
        String path = mFileDir+"/"+name+".class";
        InputStream is = null;
        //创建一个字节流
        ByteArrayOutputStream bos = new ByteArrayOutputStream();
        try {
            is = new FileInputStream(path);
            byte[] buf = new byte[1024];
            int len =0;
            while ((len = is.read(buf))!=-1){
                bos.write(buf,0,len);
            }
            return bos.toByteArray();
        } catch (IOException e) {
            e.printStackTrace();
        }finally {
            try {
                if(is!=null){
                    is.close();
                }
                if(bos!=null) {
                    bos.close();
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
        return null;
    }

没啥难度,全是流的读写操作,最后返回字节码文件的字节数组。

第三步:重写ClassLoader的findClass()方法,在内部基于双亲委派实现类加载

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        Class<?> c = findLoadedClass(name);//查找已加载的类
        if(c!=null){//已经加载过
            return c;
        }else {//没有被加载
            //依据双亲委派,让其父类去加载
            ClassLoader parent = this.getParent();
            try {
                c = parent.loadClass(name);
            }catch (Exception e){
            }
            if(c!=null){//父类加载成功
                return c;
            }else {//父类加载失败
                //自己进行加载
                byte[] classByte = getClassData(name);
                if(classByte==null){//仍没有加载到
                    throw new ClassNotFoundException();
                }else {
                    return defineClass(name,classByte,0,classByte.length);
                }
            }
        }
    }
  • 查找需要加载的类,如果已经加载过就直接返回。这里我说道一下,一个类值存在一个Class对象,Class对象加载后会被缓存一段时间,所以需要加载的类可能已经存在Class对象。
  • 如果没有从缓存中获取到就交由父类加载,父类加载成功直接返回,否则再交由父类加载以此类推。
  • 如果父类都不能加载就由自己调用defineClass()方法进行加载。

类加载器定义好了,下面我们来做一个测试,先创建有一个Girl类

public class Girl{
    static {
        System.out.println("I am a lovely girl");
    }
    public static void main(String[] args){
    }
}

将Girl类的java文件放入"D://loader/"目录下,通过控制台将.java文件编译成.class文件,结果如图:


10073662-d6978fa757b26c56.PNG
classFile.PNG

然后我们在main()方法中执行如下代码:

 FileClassLoader fileClassLoader = new FileClassLoader("D://loader");
 try {
       Class<?> c = fileClassLoader.loadClass("Girl");
       System.out.println(c.getName());
 } catch (Exception e) {
       e.printStackTrace();
 } 

打印结果:

Girl

加载成功,下满我们再来加一句代码来触发初始化

 FileClassLoader fileClassLoader = new FileClassLoader("D://loader");
 try {
       Class<?> c = fileClassLoader.loadClass("Girl");
       c.newInstance();//类初始化阶段的触发条件
       System.out.println(c.getName());
 } catch (Exception e) {
       e.printStackTrace();
 } 

打印结果:

I am a lovely girl
Girl

执行了Girl类中的静态代码块,说明类成功加载并初始化成功。

总结

文章首先描述了JVM中的内存结构,然后又基于JVM内存结构讲解了其类加载的流程,最后又实现了一个自定义类加载器。笔者个人认为只要把JVM内存结构整明白,类加载这一块是很好理解的,在这也建议大家没事画画内存图,可以非常好的帮助你理解JVM内存。歇了歇了,眼睛又开始隐隐作痛,准去的说是画图画的眼睛痛,这么用心的我画图技术还是不见提升,学美术的那位,我请你吃个饭怎么呀?哈哈。。。马上年底了,提前祝大家新年快乐!!!

猜你喜欢

转载自blog.csdn.net/weixin_34214500/article/details/86858026