[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 包。可以⽤-dirs 来指定加载位置。-D 是设置系统属性,即Property()的属性。
1.3 应⽤类加载器(AppClassLoader)
继承⾃ URLClassLoader 类,加载当前项⽬ bin ⽬录下的所有类。可以通过 Property("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 对象并返回。
equals()方法defineClass ⽅法将 byte 字节流解析成 JVM 能识别的 Class 对象,有了这个⽅法使我们不仅可以通过 class ⽂件实例化对象,还可以通过其它⽅式实例化对象,⽐如我们通过⽹络接收到⼀个类的字节码,可以利⽤这个字节码流直接创建类的 Class 对象形式实例化对象。
resolveClass 是对这个类进⾏连接。如果想在类被加载到 JVM 中时就被连接,那么可以调⽤ resolveClass ⽅法,也可以选择让 JVM 在什么时候才连接这个类。
3 ⾃定义类加载器的作⽤
上⾯提到过⾃定义类加载器,那么⾃定义类加载器有什么作⽤呢?
在 Tomcat 也⾃⼰实现类⾃定义类加载器,因为要解决如下功能:
隔离两个 Web 应⽤程序所使⽤的类库,因为两个应⽤程序可能会⽤到同⼀个类的不同版本;
共享两个 Web 应⽤程序所使⽤的类库,如果两个应⽤程序使⽤的类完全相同;
⽀持热替换。
所以 Tomcat 服务器就⾃⼰实现了类加载器,如下
前⾯提到过 loadClass ⽅法,双亲委派模型就是在其中实现的。所以如果不想打破双亲委派模型,那么只需要重写 findClass ⽅法;如果想打破双亲委派模型,那可以重写 loadClass ⽅法。
4 类加载器的使⽤
使⽤当前类的类加载器
ClassLoader().loadClass("");
⼆、类加载的步骤
类从被加载到虚拟机内存中开始,到卸载出内存为⽌,它的⽣命周期包括七个阶段:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使⽤(Using)、卸载(Unloading),其中验证、准备、解析三个阶段统称为连接。
注:加载、验证、准备、初始化、卸载这五个阶段顺序是⼀定的,⽽解析阶段在某些情况下可以在初始化之后再开始。
1、加载阶段:
⾸先获取这个类的⼆进制字节流,将这个字节流中的数据存储到⽅法区中,然后⽣成⼀个代表该类的 java.lang.Class 对象(HotSpot 是把Class 对象放在⽅法区中),⽤来访问⽅法区这些数据。
关于这个类的⼆进制字节流,我们可以利⽤⾃定义类加载器从以下渠道获取:
从压缩包中读取:如 JAR、WAR 格式;
从⽹络中获取:如果 Applet 的应⽤;
从数据库中读取:如有些中间件服务器;
运⾏时⽣成:如在 flect.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
版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系QQ:729038198,我们将在24小时内删除。
发表评论