• [Java] 理解JVM之二:类加载步骤及内存分配


    一、类加载器

    ClassLoader 能根据需要将 class 文件加载到 JVM 中,它使用双亲委托模型,在加载类的时候会判断如果类未被自己加载过,就优先让父加载器加载。另外在使用 instanceof 关键字、equals()方法、isAssignableFrom()方法、isInstance()方法时,就要判断是不是由同一个类加载器加载。

    1 类加载器的种类

    1.1 启动类加载器(Bootstrap ClassLoader)

    负责加载JDK中的核心类库,即 %JRE_HOME%/lib 目录下,这个类完全由 JVM 自己控制,外界无法访问这个类。不过在启动 JVM 时可以通过参数 -Xbootclasspath 来改变加载目录,有以下三种使用方式

    • -Xbootclasspath 完全取代系统默认目录;
    • -Xbootclasspath/a 在系统加载默认目录后,加载此目录;
    • -Xbootclasspath/p 在系统加载默认目录前,加载此目录。

    1.2 扩展类加载器(ExtClassLoader)

    继承自 URLClassLoader 类,默认加载 %JRE_HOME%/lib/ext 目录下的 jar 包。可以用-D java.ext.dirs 来指定加载位置。-D 是设置系统属性,即System.getProperty()的属性。

    1.3 应用类加载器(AppClassLoader)

    继承自 URLClassLoader 类,加载当前项目 bin 目录下的所有类。可以通过 System.getProperty("java.class.path") 获取到目录地址。

    1.4 自定义类加载器 

    如果我们自己实现类加载器,一般都会继承 URLClassLoader 这个子类,因为这个类已经实现了大部分工作,只需要在适当的地方做些修改就好,就像我们要实现 Servlet 时通常会直接继承 HttpServlet。

    不管是直接实现抽象类 ClassLoader,还是继承 URLClassLoader 类,或其它子类,它的父类加载器都是 AppClassLoader,因为不管调用那个父类构造器,创建对象都必须最终调用 getSystemClassLoader() 作为父类加载器,然后获取到 AppClassLoader。

    2 类加载器的加载顺序

    在 JVM 启动时,首先“启动类加载器”会去加载核心类,然后再由“扩展类加载器”去加载,最后让“应用类加载器”加载项目下的类。

    另外我们知道,类加载器使用双亲委托模型,可以保证类只会被加载一次(当父类加载了该类的时候,子类就不必再加载),避免重复加载。在加载类的时候会判断如果类未被自己加载过,就让父加载器进行加载。这个父加载器并不是父类加载器,而是在构造方法中传入(如果不在构造方法中传入,默认的父加载器是加载这个类的的加载器),并且委派加载流程是在 loadClass 方法中实现的。当我们自定义类加载器的时候一般不推荐覆盖 loadClass 方法,ClassLoader 抽象类中的 loadClass 方法如下

     

    从图中可以看到在 loadClass 方法中,该类没有被自己加载过时,就调用父加载器的 loadClass 方法(没有父加载器则使用“启动类加载器”)。如果父类加载器没有加载到该类,就使用自己的 findClass 方法查找该类进行加载如果没有找到这个类则会抛出 ClassNotFoundException 异常。得到这个类的 Class 对象后,调用 resolveClass 方法来链接这个类,最后返回这个类的 Class 对象。

    loadClass 中使用的几个方法如下:

    • findClass 通常是和 defineClass 方法一起使用。首先要去查找所加载的类字节码文件(不同的类加载器可以通过重写这个方法来实现不同的加载规则,如ExtClassLoader 和 AppClassLoader 加载不同的类),然后调用 defineClass 方法生成类的 Class 对象并返回。

    • defineClass 方法将 byte 字节流解析成 JVM 能识别的 Class 对象,有了这个方法使我们不仅可以通过 class 文件实例化对象,还可以通过其它方式实例化对象,比如我们通过网络接收到一个类的字节码,可以利用这个字节码流直接创建类的 Class 对象形式实例化对象。

    • resolveClass 是对这个类进行连接。如果想在类被加载到 JVM 中时就被连接,那么可以调用 resolveClass 方法,也可以选择让 JVM 在什么时候才连接这个类。

    3 自定义类加载器的作用

    上面提到过自定义类加载器,那么自定义类加载器有什么作用呢?

    在 Tomcat 也自己实现类自定义类加载器,因为要解决如下功能:

    • 隔离两个 Web 应用程序所使用的类库,因为两个应用程序可能会用到同一个类的不同版本;
    • 共享两个 Web 应用程序所使用的类库,如果两个应用程序使用的类完全相同;
    • 支持热替换。

    所以 Tomcat 服务器就自己实现了类加载器,如下

    前面提到过 loadClass 方法,双亲委派模型就是在其中实现的。所以如果不想打破双亲委派模型,那么只需要重写 findClass 方法;如果想打破双亲委派模型,那可以重写 loadClass 方法。

    4 类加载器的使用

    使用当前类的类加载器

    MyTest.class.getClassLoader().loadClass("");

    ClassLoader.getSystemClassLoader().loadClass("");

    二、类加载的步骤

    类从被加载到虚拟机内存中开始,到卸载出内存为止,它的生命周期包括七个阶段:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)、卸载(Unloading),其中验证、准备、解析三个阶段统称为连接。

    注:加载、验证、准备、初始化、卸载这五个阶段顺序是一定的,而解析阶段在某些情况下可以在初始化之后再开始。

    1、加载阶段:

    首先获取这个类的二进制字节流,将这个字节流中的数据存储到方法区中,然后生成一个代表该类的 java.lang.Class 对象(HotSpot 是把 Class 对象放在方法区中),用来访问方法区这些数据。

    关于这个类的二进制字节流,我们可以利用自定义类加载器从以下渠道获取:

    • 从压缩包中读取:如 JAR、WAR 格式;
    • 从网络中获取:如果 Applet 的应用;
    • 从数据库中读取:如有些中间件服务器;
    • 运行时生成:如在 java.lang.reflect.Proxy 中为特定接口生成代理类;
    • 从其他文件中生成:如 JSP 生成对应的 Class 类;
    • ......

    对于数组而言,加载情况有所不同,数组类本身不通过类加载器创建,是由 JVM 直接创建的。但是数组中的元素还是要靠类加载器去创建,如果数组去掉一个维度后是引用类型,就采用类加载器去加载,否则就交给启动类加载器去加载。

    另外加载阶段与连接阶段的部分内容(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未完成,连接阶段可能已经开始。

    2、连接阶段:

    第一步,验证:是为了确保类中字节码的信息符合 JVM 的要求,并且不会危害虚拟机自身的安全,有文件格式验证、元数据验证、字节码验证、符号引用验证。只有通过了文件格式验证,字节流中的数据才会被储存到方法区中,而后面的三种验证则是在方法区中进行的。符号引用验证发生在符号引用转化为直接引用的时候。

    第二步,准备:是为类的静态变量(常量除外)分配内存并设为默认值(如static int a=123 此时a值为0,在初始化阶段才会变成123),这些内存都将在方法区中进行分配。这一阶段不分配类中的实例变量的内存,实例变量将会在对象实例化时随着对象一起分配在 Java 堆中。

    第三步,解析:将class 常量池 内的 符号引用,加载到 运行时常量池 内成为 直接引用 的过程。符号引用是以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可,但引用目标并不一定已经加载到内存中;直接引用在不同的虚拟机中有不同的实现方式,它可以是 直接指向目标的指针相对偏移量 或是 个能间接定位到目标的句柄,引用的目标必定已经在内存中(类变量、类方法的直接引用可能是直接指针或句柄,实例变量、实例方法的直接引用都是偏移量。实例变量的直接引用可能是从对象的映像开始算起到这个实例变量位置的偏移量,实例方法的直接引用可能是方法表的偏移量)。

    3、初始化阶段:

    首先什么情况下类会初始化?什么情况下类不会初始化?

    类的“主动引用”(一定发生初始化)

    • 创建类的实例(如通过new、反射、克隆、反序列化)
    • 访问类的静态变量(除了常量)和静态方法
    • 利用反射调用方法时
    • 初始化类时发现其父类未初始化,则初始化其父类
    • 虚拟机启动时,包含main()方法的类

    类的“被动引用”(一定不发生初始化)

    • 访问一个静态变量,但这个变量属于其父类,只会初始化其父类。
    • 创建类的数组不会发生初始化 ( A[] a = new A[10] )。
    • 引用常量不会发生初始化(常量在编译阶段就存入所属类的常量池中了)。

    接口的加载过程与类的加载过程稍有不同,接口中不能使用static{}快。当一个接口在初始化时,并不要求其父接口全部都完成初始化,只有在真正用到父接口时(如引用接口中定义的变量)才会初始化。

    三、类的对象

    1 对象的创建

    当 JVM 遇到 new 指令时,首先去检查这个指令的参数能否在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载过,如果没有就先执行类加载。如果类已经被加载过,则会为新生对象分配内存(所需内存大小在类加载后就可以确定),分配对象内存采取的方式是“指针碰撞”或“空闲列表”,前者是在内存比较规整的情况下,后者是在空闲内存和已使用内存相互交错的情况下,而内存是否规整这又取决于垃圾回收器。

    对象的创建是很频繁的,即使是简单的指针位置的修改,在并发情况下可能会出现线程安全问题。解决这个问题的方式有两种,一种是进行同步处理——JVM 采用了 CAS 方式失败重试来保证的原子性操作;另一种是把内存分配划分在不同空间中——即每个线程预先分配一小块内存,称为本地线程分配缓冲(TLAB),可以通过 -XX:+/-UseTLAB 参数来设定是否使用。

    内存分配完成后,设置对象的对象头中的信息,如这个对象是哪个类的实例,如何找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息,此时对象已经产生,但是还没有初始化,所有字段都为零。

    2 对象的内存布局

    对象在内存中存储的布局可以分为 3 块区域:对象头、实例数据和对齐填充。

    • 对象头:包括两部分信息,第一部分储存对象自身运行时的数据,如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳。另一部分是类型指针,指向它的类元数据的指针(不是所有的虚拟机都有)。如果是数组,那在对象头中还必须有一块记录数组长度的数据。
    • 实例数据:这部分是对象真正存储的有效信息,即在对象中定义的各种字段内容(无论是从父类中继承下来的,还是本身所定义的)。存储顺序受虚拟机的分配策略和定义顺序的影响,HotSpot 默认的分配策略为 longs/doubles、ints、shorts/chars、bytes/booleans、oops。
    • 对齐填充:不是必然存在的,也没有特别含义,仅仅起着占位符作用。因为 HotSpot 要求对象起始地址必须是 8 字节的整数倍。

    3 对象的访问定位

    我们通过 Java 栈中对象的引用去访问这个对象,访问对象的主流方式有 2 种:使用句柄和直接指针。

    • 使用句柄访问:在 Java 堆中会划分出一块内存作为句柄池,引用中储存的内容就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息。
    • 直接指针访问:在对象的内存布局中就要放置访问类型数据的指针。

    这两种方式各有优势,使用句柄的好处是引用中存储的是稳定的句柄,对象被移动时(垃圾回收时对象被移动)只需改变句柄中的实例数据的指针,不需要改动引用本身。而使用直接指针的好处是速度更快,它节省了一次指针定位的开销。HotSpot 使用的是第二种方式进行对象的访问。

    四、内存溢出

    除了程序计数器外,JVM 中其他几个内存区域都有可能发生 OutOfMemoryError 异常。

    1 Java堆溢出

    如果不断创建对象,并且对象始终被强引用,则垃圾回收器无法回收这些对象,最终会生产内存溢出。通过 -XX:+HeapDumpOnOutOfMemoryError 可以让虚拟机出现内存溢出时 Dump 出当前堆储存为快照,以后事后分析。

    解决堆溢出,一般先通过内存映像分析工具对这个快照进行分析,弄清楚出现了内存泄漏还是内存溢出。如果是内存泄漏,可以通过工具查看泄漏对象到 GC Root 的引用链,以此来判断泄漏原因;如果不存在泄漏,即内存中的对象确是都必须活着,可以调整堆的大小参数和对代码进行优化。

    2 Java栈溢出和本地方法栈溢出

    在 HotSpot 中不区分 Java 栈和本地方法栈,虽然可以通过 -Xoss 参数设置本地方法栈大小,但是并没有效果,栈容量只有由 -Xss 参数设定。栈中发生的异常有两种:

    • 如果需要的深度超过最大深度时抛出 StackOverflowError 异常;
    • 如果栈无法申请到足够内存时抛出 OutOfMemoryError 异常。

    3 方法区和运行时常量池溢出

    String 字符串的 intern() 方法作用是,如果字符串常量池存在这个字符串则返回其对象的引用,否则将字符串拷贝到方法区中的字符串常量池。在 Java7 之后方法区被移入堆中,intern() 方法也有所变化,不会将首次遇到的字符串对象本身放入常量池,只会在常量池中记录这个字符串对象的引用。

    在使用 GCLib 动态的将类加载进内存时,很容易造成溢出。

    4 本机内存溢出

    NIO

  • 相关阅读:
    Javascript跨域后台设置拦截
    Hello ReactJS
    Redis 常用监控信息命令总结
    MySQL架构与业务总结图
    MySQL垂直拆分和水平拆分的优缺点和共同点总结
    MySQL实用工具汇总
    MySQL查看数据库表容量大小
    MySQL到底能支持多大的数据量?
    微信小程序wxss的background本地图片问题
    微信小程序中显示与隐藏(hidden)
  • 原文地址:https://www.cnblogs.com/tengyunhao/p/7374463.html
Copyright © 2020-2023  润新知