Java-类的加载

103 阅读7分钟
  • JVM 和类:
    当调用 java 命令运行某个 Java 程序时, 该命令会启动一个 Java 虚拟机进程, 不管该 Java 程序有多复杂, 该程序启动了多少个线程, 它们都处于该 Java 虚拟机进程. 当 Java 程序结束运行时, JVM 进程结束, 该进程在内存中的状态将会丢失.

JVM 负责 Java 程序运行时的内存管理. JVM 将它所管理的内存划分为若干个区域:

  • 程序计数器(Program Counter Register):
    该内存属于线程私有. 它时当前线程所执行的字节码的行号指示器.
  • Java 虚拟机栈(Java Virtual Machine Stacks):
    该内存属于线程私有. 每个方法在执行的时候都会创建一个栈帧(Stack Frame)用于存储局部变量表, 操作数栈, 动态链接, 方法出口等信息. 每一个方法从调用直至执行完成的过程, 就对应着一个栈帧在虚拟机栈中入栈到出栈的过程. 而通常所说的在栈上分配一个变量实际上指的是在栈帧的局部变量表分配变量. 局部变量表所需要的空间在编译期间就分配完成. 当线程请求的栈深度大于虚拟机栈所允许的深度, 将抛出StackOverflowError异常; 如果虚拟机栈能够动态扩展, 并且在扩展过程中无法申请到足够的内存, 就会抛出OutOfMemoryError异常.
  • 本地方法栈(Native Method Stack):
    本地方法栈和 Java 虚拟机栈类似, 只不过是为虚拟机使用到的Native服务.
  • Java 堆:
    Java 堆是被所有线程共享的一块内存区域, 在虚拟机启动时创建. 可以通过启动参数-Xms-Xmx进行控制. 如果在堆中没有内存完成实例分配, 并且也无法扩展时, 将会抛出OutOfMemoryError异常.
  • 方法区(Method Area):
    该内存由各个线程共享. 它用于存储已被虚拟机加载的类信息, 常量, 静态变量, 即使编译后的代码数据等. 当方法区无法满足内存分配需求时, 将抛出OutOfMemoryError异常. 属于堆的一个逻辑部分.
  • 运行时常量池(Running Constant Pool):
    Class 文件中有一项常量池(Constant Pool Table)用于存放编译时期生成的各种字面量和符号引用, 这部分内容将在类加载后进入方法区的运行时常量池中存放. 运行时常量池具备动态性, 运行期间也可能将新的常量放入常量池中, 例如String#intern()方法. 运行时常量池属于方法区的一部分.
  • 类的加载:
    类的加载指的是将类的Class文件读入内存, 并为之创建一个java.lang.Class对象. 在加载阶段虚拟机需要完成三件事:
    • 通过一个类的全限定名来获取定义此类的二进制字节流. 而二进制数据流可以从多个地方获取:
      • 从本地文件系统加载class文件
      • 从ZIP包中读取, 例如 JAR, WAR 格式.
      • 从网络中读取, 例如 Applet.
      • 运行时计算生成, 例如动态代理技术.
      • 由其它文件生成, 例如 JSP 文件.
    • 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构.
    • 在内存中生成一个代表这个类的java.lang.Class对象, 作为方法区这个类的各种数据的访问入口. 对于HotPot虚拟机来说, Class对象比较特殊, 它虽然是对象, 但是存放在方法区里面. 加载阶段与链接阶段的部分内容是交叉进行的.
  • 类的验证:
    • 文件格式验证
    • 元数据验证
    • 字节码验证
    • 符号引用验证 如果所运行的全部代码都已经被反复使用和验证过, 那么在实施阶段就可以考虑使用-Xverify:none参数关闭大部分的类验证措施, 缩短虚拟机类加载的时间.
  • 准备:
    准备阶段是正式为类变量分配内存并设置类变量初始值的阶段, 这些变量所使用的内存都将在方法区中进行分配.
  • 解析:
    将常量池内的符号引用替换为直接引用的过程.
  • 初始化:
    • 初始化是执行类构造器<clinit>()方法的过程. 该方法时由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生的. 静态语句块只能访问到定义在静态语句块之前的变量, 定义在之后的变量, 在前面的静态语句块可以赋值, 但是不能访问.
    • <clinit>()方法与类的构造函数不同, 它不需要显示调用父类构造器, 虚拟机会保证在子类的<clinit>()方法执行之前, 父类的<clinit>()方法已经执行完毕.
    • <clinit>()方法对于类和接口来说不是必需的. 如果一个类中没有静态语句块, 也没有对变量的赋值操作, 那么编译器可以不为这个类生成<clinit>()方法.
    • 接口中不能使用静态语句块, 但仍然有变量初始化的赋值操作, 因此接口和类一样会生成<clinit>()方法. 但接口与类不同的是, 执行接口的<clinit>()方法不需要先执行父接口的<clinit>()方法. 只有当父接口中定义的变量使用时, 父接口才会初始化. 另外, 接口的实现类在初始化时也一样不会执行接口的<clinit>()方法.
    • 虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确的加锁, 同步. 如果多个线程同时去初始化一个类, 那么只会有一个线程去执行这个类的<clinit>()方法, 其它线程都需要阻塞等待, 直到活动线程执行<clinit>()方法完毕.
  • 类加载器:
    • 类与类加载器:
      对任意一个类, 都需要有加载它的类加载器和这个类本身一桶确立其在 Java 虚拟机中的唯一性, 每一个类加载器, 都拥有一个独立的类名称空间.
    • 双亲委派模型:
      • 启动类加载器(BootClass Loader): 这个类加载器将负责加载存放在<JAVA_HOME>\lib目录中的, 或者被-Xbootclasspath参数所指定的路径中的, 并且是虚拟机识别的(仅按照文件名识别)类库记载到虚拟机内存中.
      • 扩展类加载器(Extension ClassLoader): 这里类加载器将负责加载<JAVA_HOEM\lib\ext>目录中的, 或者被java.ext.dirs系统变量所指定的路径中的所有类库.
      • 应用程序类加载器(Application ClassLoader): 这个类将负责加载用户类路径(ClassPath)上所指定的类库.
      • 类加载器之间的关系如下图所示, 除了顶层的启动类加载器外, 其余的类加载器都应当有自己的父类加载器. 这里的父子关系指的是类加载器实例之间的关系而不是类与类之间的关系.
        双亲委派模型的工作过程是: 如果一个类加载器收到了类加载的请求, 它首先不会自己去尝试加载这个类, 而是把这个请求委派给父类加载器去完成, 每一个层次的类加载器都是如此. 因此所有的加载请求最终都应该传送到顶层的启动类加载器中, 只有当父类加载器自己无法完成这个加载请求时, 子加载器才会尝试自己去加载.
        20191227173807.png
  • 参考:
    [1] : 深入理解Java虚拟机(第2版)
    [2] : 疯狂Java讲义