JVM
一、内存区域
JVM将内存区域划分为五大块
- 线程共享的:方法区(Method Area),堆(Heap);
- 线程不共享的:Java虚拟机栈(VM Strack)、本地方法栈(Native Method Area)、程序计数器(Program Counter Register)。
JVM初始运行的时候都会分配好方法区和堆,而JVM没遇到一个线程,就为其分配一个程序计数器、虚拟机栈和本地方法栈,当线程终止的时候,这三者所占的内存空间也会被释放掉。
- 非线程共享的程序计数器、虚拟机栈和本地方法栈区域的生命周期和所属线程相同;
- 线程共享的方法区和堆区域的生命周期与Java程序运行的生命周期相同。
所以这也是系统垃圾回收的场所只发生在线程共享的区域的原因。(实际上对大部分虚拟机来说只发生在堆上)
1.1 程序计数器
程序计数器是一块较小的内存区域,作用可以看做是当前线程执行的字节码的位置指示器。分支、循环、跳转、异常处理和线程恢复等基础功能都需要依这个计数器来完成。
1.2 虚拟机栈
Java指令的构成:操作码(方法本身)和操作数(方法内部变量)。
-
方法本身是指令的操作码部分,保存在栈中;
-
方法内部变量(局部变量)作为指令的操作数部分,跟在指令的操作码之后,保存在栈中(实际上是简单类型(byte\ short\ int\ long 等保存在栈中),对象类型在栈中的保存地址,在堆中保存值);
虚拟机栈也叫栈内存,是在线程创建时创建,它的生命周期是跟随线程的生命周期,线程结束栈内存也就释放,对于栈来说不存在垃圾回收问题(只要线程一结束,该栈就结束,所以不存在垃圾回收)。
也有一些资料翻译成Java方法栈,大概是因为它所描述的是Java方法执行的内存模型,每个方法执行的同时创建栈帧(后进先出)用于存储局部变量表(包含对应的方法参数和局部变量)、操作数栈(记录出栈、入栈的操作)、动态链接、方法出口等信息,每个方法被调用直到执行完毕的过程,对应栈帧在虚拟机栈的入栈和出栈的过程。
局部变量表存放了编译期可知的各种基本数据类型(boolean、byte、char、short、int、float、long、double)、对象的引用(reference类型,不同于对象本身,根据不同的虚拟机实现,可能是一个指向对象起始地址的引用指针,也可能是一个代表对象的句柄或者其他与对象相关的位置)和returnAddress类型(指向下一条字节码指令的地址)。局部变量表多需的内存空间在编译器间完成分配,在方法运行之前该局部变量表所需的内存空间是固定的,运行期间也不会改变。
栈帧( Frame)是用来存储数据和部分过程结果的数据结构,同时也被用来处理动态链接(Dynamic Linking)、 方法返回值和异常分派( Dispatch Exception)。栈帧随着方法调用而创建,随着方法结束而销毁——无论方法是正常完成还是异常完成(抛出了在方法内未被捕获的异常)都算作方法结束。
栈中的数据可以共享。当我们在一个线程中定义了int i = 40;,再定义int i0 = 40;这时候会自动检查栈中是否有40这个数据,如果有,i0会直接指向i的40,不会再添加一个新的40。
1.3 本地方法栈
与虚拟机栈相似,虚拟机栈为JVM提供执行Java方法的服务,本地方法栈则为JVM提供使用native方法的服务。
1.4 方法区
Object Class Data(加载类的类定义数据)是存储在方法区的。除此之外,常量、静态变量、JIT(即时编译器)编译后的代码也都在方法区。正因为方法区所存储的数据与堆有一种类比关系,所以它被称为Non-Heap。方法区也是内存不连续的区域组成的,并且可设置为固定大小,也可以设置为可扩展的,这点与堆一样。
垃圾回收在这个区域会比较少的出现,这个区域内存回收的目的主要是针对常量池的回收和类的卸载。
运行时常量池(Runtime Constant Pool)
方法区内部有一个非常重要的区域,叫做运行时常量池。在字节码文件中,除了有类的版本、字段、方法、接口等相关信息描述外,还有常量池信息,用于存储编译器产生的字面量和符号引用。这部分内容在类被加载后,都会存储到方法区的运行时常量池(RCP)。值得注意的是,运行是产生的新常量也可以被放入常量中,比如String类中的intern()方法产生的常量。
常量池就是这个类型用到的常量的一个有序集合。包括直接常量(基本类型,String)和对其他类型、方法、字段的符号引用。例如:
- 类和接口的全限定名;
- 字段的名称和描述符;
- 方法的名称和描述符。
池中的数据和数组一样通过索引访问。由于常量池包含了一个类型所有的对其他类型、方法、字段的符号引用,所以常量池在Java的动态链接中起了核心作用.
基本类型和基本类型的包装类。基本类型有:byte、short、char、int、long、boolean。基本类型的包装类分别是:Byte、Short、Character、Integer、Long、Boolean。注意区分大小写。二者的区别是:基本类型体现在程序中是普通变量,基本类型的包装类是类,体现在程序中是引用变量。因此二者在内存中的存储位置不同:基本类型存储在栈中,而基本类型包装类存储在堆中。上边提到的这些包装类都实现了常量池技术,两种浮点数类型的包装类则没有实现。另外,String类型也实现了常量池技术。
- 以上提到的几种基本类型包装类均实现了常量池技术,但他们维护的常量仅仅是【-128至127】这个范围内的常量,如果常量值超过这个范围,就会从堆中创建对象,不再从常量池中取。比如,把上边例子改成Integer i1 = 400; Integer i2 = 400;,很明显超过了127,无法从常量池获取常量,就要从堆中new新的Integer对象,这时i1和i2就不相等了。
- 2.String类型也实现了常量池技术,但是稍微有点不同。String型是先检测常量池中有没有对应字符串,如果有,则取出来;如果没有,则把当前的添加进去。
public class test { public static void main(String[] args) { objPoolTest(); } public static void objPoolTest() { int i = 40; int i0 = 40; Integer i1 = 40; Integer i2 = 40; Integer i3 = 0; Integer i4 = new Integer(40); Integer i5 = new Integer(40); Integer i6 = new Integer(0); Double d1=1.0; Double d2=1.0; System.out.println("i=i0\t" + (i == i0)); // 1.i和i0均是普通类型(int)的变量,所以数据直接存储在栈中,而栈有一个很重要的特性:栈中的数据可以共享。当我们定义了int i = 40;,再定义int i0 = 40;这时候会自动检查栈中是否有40这个数据,如果有,i0会直接指向i的40,不会再添加一个新的40。 System.out.println("i1=i2\t" + (i1 == i2)); // 2.i1和i2均是引用类型,在栈中存储指针,因为Integer是包装类。由于Integer包装类实现了常量池技术,因此i1、i2的40均是从常量池中获取的,均指向同一个地址,因此i1=12。 System.out.println("i1=i2+i3\t" + (i1 == i2 + i3)); // 3.很明显这是一个加法运算,Java的数学运算都是在栈中进行的,Java会自动对i1、i2进行拆箱操作转化成整型,因此i1在数值上等于i2+i3。 System.out.println("i4=i5\t" + (i4 == i5)); // 4.i4和i5均是引用类型,在栈中存储指针,因为Integer是包装类。但是由于他们各自都是new出来的,因此不再从常量池寻找数据,而是从堆中各自new一个对象,然后各自保存指向对象的指针,所以i4和i5不相等,因为他们所存指针不同,所指向对象不同。 System.out.println("i4=i5+i6\t" + (i4 == i5 + i6)); // 5.这也是一个加法运算,和3同理。 System.out.println("d1=d2\t" + (d1==d2)); // 6.d1和d2均是引用类型,在栈中存储指针,因为Double是包装类。但Double包装类没有实现常量池技术,因此Doubled1=1.0;相当于Double d1=new Double(1.0);,是从堆new一个对象,d2同理。因此d1和d2存放的指针不同,指向的对象不同,所以不相等。 } } 结果: i=i0 true i1=i2 true i1=i2+i3 true i4=i5 false i4=i5+i6 true d1=d2 false
1.5 堆
堆是JVM的内存数据区。堆的管理很复杂,是被所有线程共享的内存区域,在JVM启动的时候创建专门用来保存对象的实例。在堆中分配一定的内存来保存对象实例,实际上也只是保存内存对象实例的属性值,属性的类型和对象本身的类型标记等,并不保存对象的方法(以栈帧的形式保存在栈中)。而对象实例在堆中分配好以后,需要在栈中保存一个4字节的堆内存地址,用来定位该对象实例在堆中位置,便于找到该对象实例,是垃圾回收的主要场所。Java堆处于物理不连续的内存空间中,只是逻辑上连续即可。
总结:
- 分清什么是实例什么是对象。
Class a = new Class();此时a叫实例,不叫对象。实例是在栈中,而对象是在堆中,操作实例实际上是通过实例的指针间接的操作对象。多个实例可以指向同一个对象。 - 栈中的数据和堆中的数据销毁并不是同步的。
方法一旦结束,栈中的局部变量立即销毁,但是堆中的对象不一定销毁。因为可能有其他变量也指向了这个对象,知道栈中没有变量指向堆中的对象时,它才销毁,而且不是立马销毁,要等到垃圾回收扫描时才可以销毁。 - 类的成员变量在不同对象中各不相同,都有自己的存储空间(成员变量在堆中的对象中)。而类的方法却是该类的所有对象共享的,只有一套,对象使用方法的时候方法才被压入栈中,方法不使用则不占用内存。
- 生命周期。
堆内存属于Java应用程序所使用的,生命周期和JVM一致;栈内存属于线程私有的,它的生命周期与线程相同。 - 引用。
不论何时创建一个对象,它总是存储在堆内存空间,并且栈内存空间包含对他的引用,栈内存空间只包含方法原始数据类型、局部变量以及堆空间中对象的引用变量。 - 在堆中的对象可以全局访问,栈内存空间属于线程私有。
- JVM虚拟机栈内存结构管理较为简单,遵循后进先出的原则;堆空间内存管理比较复杂,细分为老年代和新生代等等。
- 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常,堆内存抛出OutOfMemeryError异常。
二、内存分配
Java程序运行时内存分配核心:
基本类型和引用类型。二者作为局部变量,都放在栈中,基本类型直接在栈中保存值,引用类型只保存一个指向堆区的指针,真正的对象在堆里。作为参数时基本类型就直接传值,引用类型传指针。
逃逸算法就是分析Java对象的动态作用域。当一个对象被定义之后,可能会被外部对象引用,称之为方法逃逸;也可能被其他线程所引用,称之为线程逃逸;如果经过逃逸分析后发现,一个对象并没有逃逸出方法的话,那么就可能被优化成栈上分配。就这样无需在堆上分配内存,也无需进行垃圾回收。
三、对象头
HotSpot虚拟机中,Java对象在内存中存储的布局可以分为三块区域:对象头(Header)、实例数据(Instance Data)和对齐补充(Padding)。
Java的对象头由以下三部分组成:
对象头包括两部分内容:Mark Word、指向类的指针、数组长度(只有数组对象才有)。
- 第一部分用于存储对象自身的运行时数据。
如哈希值、GC分代年龄、锁状态标志、线程持有的锁、偏向线程的ID、偏向时间戳等等,这部分数据的长度在32位和64位的虚拟机中分别为32个和64个Bits,官方称之为“Mark Word”。
对象需要存储的运行时数据很多,其实已经超出了32、64位Bitmap结构所能记录的限度,但是对象头信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效率,Mark Word 被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息,它会根据对象的状态复用自己的存储空间。
例如在32位的Hotspot虚拟机中对象未被锁定的状态下,Mark Word的32个Bits空间中的25Bits用于存储对象哈希码,4Bits用于存储对象分代年龄,2Bits用于存储锁标志位,1Bit固定是0,在其他状态(轻量级锁、重量级锁、GC标记、可偏向)下对象的存储内容如下表所示:
- 另一部分是用于存储对象的类型指针。
该指针指向它的类元数据,JVM通过这个指针确定对象是哪个类的实例。该指针的长度为JVM的一个字大小,即32位JVM为32位,64位JVM为64位。
如果应用的对象过多,使用64位的指针将浪费大量内存,为了节约内存可以使用选项+UseCompressedOops开启指针压缩,如果对象是一个数组,那么对象还需要有额外的空间存储数组的长度,这部分数据的长度也随着JVM架构的不同而不同;32位的JVM上。长度为32为;64位的JVM则为64位。64位JVM如果开启+UseCompressedOops选项,该区域的长度也将由64位压缩至32位。
这里要特别关注是锁标志位,锁标志位与是否偏向锁对应到唯一的锁状态。
所以锁的状态保存在对象头中,所以在理解Synchronized锁的到底是什么,锁住的是代码还是对象?答案是对象。
Synchronized有4中锁状态,从低到高为:无锁状态、所偏向状态、轻量级锁状态、重量级锁状态,锁可以升级但不能降级。
偏向锁就是在锁对象的对象头中有个Threaddid字段,这个字段如果是空的,第一次获取锁的时候,就将自身的Threaddid写入到锁的Threaddid字段内,将锁头内的是否偏向锁的状态位置1,这样下次获取锁的时候,直接检查Threaddid是否和本身线程id一致,如果一致,则认为当前线程已经获取了锁,因此不需要再次获取锁,略过了轻量级锁和重量级锁的加锁阶段,提高了效率。
在HotSpot JVM中,32位机器下,Integer对象的大小是int(4字节)的几倍?
Integer只有一个int类型的成员变量value,所以其对象实际数据部分的大小是4个字节,Mark Word 4个字节,指针4个字节,然后再在后面填充4个字节打到8字节的对齐,所以可以得出integer对象的大小是16个字节。
四、JVM虚拟机整体结构
五、类加载
5.1 类加载机制的概念
Java虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这就是虚拟机的加载机制。
Class文件由类加载器装载后,在JVM中将形成一份描述Class结构的原信息对象,通过该原信息对象可以获知Class结构信息:如构造函数、属性和方法等,Java允许用户借由这个Class相关的元信息对象间接调用Class对象的功能,这就是我们经常能见到的Class类。
5.1.1 加载
类的加载指的是将类的.class文件中的二进制数据读取到内存中,将其放在运行时数据区的方法区内,然后在堆区创建一个java.lang.Class对象,用于封装类在方法区内的数据结构。在这个阶段,JVM将做三个事情:
- 通过一个类的全限定名(包名和类名)来获取定义此类的二进制字节流(Class文件)。而获取的方式,可以通过jar包、war包、网络中获取、JSP文件生成等方式。
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。这里只是转化了数据结构,并未合并数据。(方法区就是用来存放已被加载的类信息、常量、静态变量、编译后的代码的运行时内存区域)
- 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。这个Class对象并没有规定是在Java堆中,它比较特殊,虽为对象,但存放在方法区中。
5.1.2 类的连接
类的加载过程后生成了类的java.lang.Class对象,接着会进入到连接阶段,连接阶段负责将类的二进制数据合并入JRE(Java运行时环境)中。类的连接大致分为三个阶段:
验证
验证被加载后的类是否有正确的结构,类数据是否会符合虚拟机的要求,确保不会危害虚拟机安全。
准备
为类的静态变量(static filed)在方法区分配内存,并赋默认初值(0值或null值)。如static int a = 100;
- 静态变量a就会在准备阶段被赋默认值0.
- 对于一般的成员变量是在类实例化的时候,随对象一起分配在堆空间中。
- 另外,静态常量(static final filed)会在准备阶段赋程序设定的初值,如static final int a = 6;静态常量a就会在准备阶段被直接赋值为6,对于静态变量,这个操作是在初始化阶段进行的。
解析
将类的二进制数据中的符号引用换为直接引用。
符号引用
符号引用与虚拟机实现的布局无关,引用的目标并不一定要已经加载到内存中。各种虚拟机实现的内存布局可以各不相同,但是它们能接受的符号引用必须是一致的,因为符号引用的字面量形式明确定义在 Java 虚拟机规范的 Class 文件格式中。
直接引用
直接引用可以是指向目标的指针,相对偏移量或是一个能间接定位到目标的句柄。如果有了直接引用,那引用的目标必定已经在内存中存在。
5.1.3 类的初始化
类的初始化是类加载的最后一步,除了加载阶段,用户可以通过自定义的类加载器参与,其他阶段都完全由虚拟机主导和控制。到了初始化阶段才真正执行Java代码。
类的初始化的主要工作是为静态变量赋程序设定的初值。
如:static int a = 100;在准备阶段,a被赋默认值0,在初始化阶段就会被赋值为100.
Java虚拟机规范中严格规定了有且仅有4种情况必须对类进行初始化:
- 使用new字节码指令创建类的实例,或者使用getstatic、putstatic读取或设置一个静态字段的值(放入常量池中的常量除外),或者调用一个静态方法的时候,对应类必须进行过初始化。
- 使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,则要先进行初始化。
- 当初始化一个类的时候,如果发现其父类没有进行初始化,则首先触发父类初始化。对于静态字段,只有直接定义这个字段的类才会被初始化,因此,通过其子类来引用父类中定义的静态字段,只会触发父类的初始化而不会触发子类的初始化。
- 当虚拟机启动时,用户需要指定一个子类(包含main()方法的类),虚拟机会首先初始化这个类。
class ConstClass{
static{
System.out.println("ConstClass Init");
}
public static final HELLOWORLD = "hello world";
}
public class Test {
public static void main(String[] args){
System.out.println(ConstClass.HELLOWORLD); // 调用类常量
}
}
打印结果:
hello world
5.2 双亲委派模型
在JVM中,并不是一次性把所有的文件都加载到,而是一步一步的,按照需要来加载。
比如JVM启动时,会通过不同的类加载器加载不同的类。当用户在自己的代码中,需要某些额外的类时,再通过加载机制加载到JVM中,并且存放一段时间,便于频繁使用。
对于任何一个类,都需要由加载他的类加载器和这个类来确立其在JVM中的唯一性。也就是说,两个类来源于同一个Class文件,并且被同一个类加载器加载,这两个类相等。
虚拟机中提供了三种类加载器:启动(Bootstrap)类加载器、扩展(Extension)类加载器、系统类(System)加载器(也成为了应用类加载器);此外还有父类加载器为AppClassLoader的自定义类加载器。
- 启动(Bootstrap)类加载器(由C++实现,没有父类)
启动类加载器主要加载的是JVM自身需要的类,这个类加载使用C++语言实现的,是虚拟机自身的—部分,它负责将 /lib 路径下的核心类库或 –Xbootclasspath 参数指定的路径下的jar包加载到内存中,注意必由于虚拟机是按照文件名识别加载jar包的,如rt.jar,如果文件名不被虚拟机识别,即使把jar包丢到lib目录下也是没有作用的(出于安全考虑,Bootstrap启动类加载器只加载包名为java、javax、sun等开头的类)。 - 扩展类(Extension)加载器(由Java语言实现,父类加载器为null)
扩展类加载器是指Sun公司 (已被Oracle收购) 实现的sun.misc.Launcher$ExtClassLoader类,由Java语言实现的,是Launcher的静态内部类,它负责加载 /lib/ext 目录下或者由系统变量 –Djava.ext.dir 指定位路径中的类库,开发者可以直接使用标准扩展类加载器 。 - 系统类(System)加载器(由Java语言实现,父类加载器为ExtClassLoader)
也称应用程序加载器,它负责加载系统类路径 java –classpath 或 –Djava.class.path 指定路径下的类库,也就是我们经常用到的classpath路径,开发者可以直接使用系统类加载器,—般情况下该类加载是程序中默认的类加载器,通过ClassLoader#getSystemClassLoader()方法可以获取到该类加载器。
这3种类加载器之间存在着父子关系(区别于java里的继承),子加载器保存着父加载器的引用。当—个类加载器需要加载—个目标类时,会先委托父加载器去加载,然后父加载器会在自己的加载路径中搜索目标类,父加载器在自己的加载范围中找不到时,才会交还给子加载器加载目标类。先找缓存,缓存找不到才加载。
采用双亲委托模式可以避免类加载混乱,而且还将类分层次了,例如Java中lang包下的类在JVM启动时就被启动类加载器加载了,而用户—些代码类则由应用程序类加载器(AppClassLoader)加载,基于双亲委托模式,就算用户定义了与lang包中—样的类,最终还是由应用程序类加载器委托给启动类加载器去加载,这个时候启动类加载器发现已经加载过了lang包下的类了,所以两者都不会再重新加载。当然,如果使用者通过自定义的类加载器可以强行打破这种双亲委托模型,但也不会成功的,java安全管理器将会抛出java.lang.SecurityException异常。
5.3 实现原理
Java中定义的类加载器及其双亲委派模式的实现,它们类图关系:
loadClass()方法是ClassLoader类自己实现的,该方法中的逻辑就是双亲委派模式的实现,顶层的类加载器是ClassLoader类,它是—个抽象类,其后所有的类加载器都继承自ClassLoader(不包括启动类加载器),这里我们主要介绍ClassLoader中几个比较重要的方法。
- loadClass(String name, boolean resolve)是—个重载方法,resolve参数代表是否生成class对象的同时进行解析相关操作;
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
// 先从**缓存**查找该class对象,找到就不用重新加载
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
if (parent != null) {
//如果找不到,则委托给父类加载器去加载
c = parent.loadClass(name, false);
} else {
//如果没有父类,则委托给启动加载器去加载
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
}
if (c == null) {
// If still not found, then invoke findClass in order
// 如果都没有找到,则自定义实现的findClass去查找并加载
c = findClass(name);
// this is the defining class loader; record the stats
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {//是否需要在加载时进行解析
resolveClass(c);
}
return c;
}
}
当类加载请求到来时,先从缓存中查找该类对象,如果存在直接返回,如果不存在则交给该类加载器的父加载器去加载,倘若没有父加载则交给顶级启动类加载器去加载,最后倘若仍没有找到,则使用findClass()方法去加载。从loadClass实现也可以知道如果不想重新定义加载类的规则,也没有复杂的逻辑,只想在运行时加载自己指定的类,那么我们可以直接使用this.getClass().getClassLoder.loadClass(“className”),这样就可以直接调用ClassLoader的loadClass方法获取到class对象。
- 自定义的类加载逻辑写在findClass()方法中,findClass()方法是在loadClass()方法中被调用的,当loadClass()方法中父加载器加载失败后,则会调用自己的findClass()方法来完成类加载,这样就可以保证自定义的类加载器也符合双亲委托模式,defineClass()方法通常与findClass()方法—起使用,—般情况下,在自定义类加载器时,会直接覆盖ClassLoader的findClass()方法并编写加载规则,取得要加载类的字节码后转换成流,然后调用defineClass()方法生成类的Class对象,简单例子如下:
protected Class<?> findClass(String name) throws ClassNotFoundException {
// 获取类的字节数组
byte[] classData = getClassData(name);
if (classData == null) {
throw new ClassNotFoundException();
} else {
//使用defineClass生成class对象
return defineClass(name, classData, 0, classData.length);
}
}
直接调用defineClass()方法生成类的Class对象,这个类的Class对象并没有解析(也可以理解为链接阶段,毕竟解析是链接的最后—步),其解析操作需要等待初始化阶段进行。
- resolveClass(Class≺?≻ c)
使用该方法可以使用类的Class对象创建完成也同时被解析。前面我们说链接阶段主要是对字节码进行验证,为类变量分配内存并设置初始值同时将字节码文件中的符号引用转换为直接引用。
上述4个方法是ClassLoader类中的比较重要的方法,也是我们可能会经常用到的方法。接看SercureClassLoader扩展了 ClassLoader,新增了几个与使用相关的代码源 (对代码源的位置及其证书的验证) 和权限定义类验证 (主要指对class源码的访问权限) 的方法,—般我们不会直接跟这个类打交道,更多是与它的子类URLClassLoader有所关联,前面说过,ClassLoader是—个抽象类,很多方法是空的没有实现,比如 findClass()、findResource()等。而URLClassLoader这个实现类为这些方法提供了具体的实现,并新增了URLClassPath类协助取得Class字节码流等功能,在编写自定义类加载器时,如果没有太过于复杂的需求,可以直接继承URLClassLoader类,这样就可以避免自己去编写findClass()方法及其获取字节码流的方式,使自定义类加载器编写更加简洁。
双亲委派模型的好处:
- 主要是为了安全性,避免用户自己编写的类动态替换Java的—些核心类,比如 String ;
- 同时也避免了类的重复加载,因为 JVM中区分不同类,不仅仅是根据类名,相同的 class文件被不同的 ClassLoader加载就是不同的两个类。
在JVM中表示两个class对象是否为同—个类对象存在两个必要条件:
- 类的完整类名必须—致,包括包名;
- 加载这个类的ClassLoader (指ClassLoader实例对象) 必须相同。
5.4 自定义类加载器
自定义类的应用场景:
- 加密:Java代码可以轻易的被反编译,如果你需要把自己的代码进行加密以防止反编译,可以先将编译后的代码用某种加密算法加密,类加密后就不能再用Java的ClassLoader去加载类了,这时就需要自定义ClassLoader在加载类的时候先解密类,然后再加载。
- 从非标准的来源加载代码:如果你的字节码是放在数据库、甚至是在云端,就可以自定义类加载器,从指定的来源加载类。
- 以上两种情况在实际中的综合运用:比如你的应用需要通过网络来传输 Java 类的字节码,为了安全性,这些字节码经过了加密处理。这个时候你就需要自定义类加载器来从某个网络地址上读取加密后的字节代码,接着进行解密和验证,最后定义出在Java虚拟机中运行的类。
如何自定义类加载器:从上面对于java.lang.ClassLoader的loadClass(String name, boolean resolve)方法的解析来看,可以得出以下2个结论:
- 如果不想打破双亲委派模型,那么只需要重写findClass方法即可;
- 如果想打破双亲委派模型;那么就重写整个loadClass方法。
实例:
class HClassLoader extends ClassLoader {
private String classPath;
public HClassLoader(String classPath) {
this.classPath = classPath;
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
try {
byte[] data = loadByte(name);
return defineClass(name, data, 0, data.length);
} catch (Exception e) {
e.printStackTrace();
throw new ClassNotFoundException();
}
}
/**
*获取.class的字节流
*
*@param name
*@return
*@throws Exception
*/
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();
// 字节流解密
data = DESInstance.deCode("1234567890qwertyuiopasdf".getBytes(), data);
return data;
}
}
测试类
@Test
public void testClassLoader() throws Exception {
HClassLoader myClassLoader = new HClassLoader("e:/temp/a");
Class clazz = myClassLoader.loadClass("com.demo.Car");
Object o = clazz.newInstance();
Method print = clazz.getDeclaredMethod("print", null);
print.invoke(o, null);
}
实体类
public class Car {
public Car() {
System.out.println("Car:" + getClass().getClassLoader());
System.out.println("Car Parent:" + getClass().getClassLoader().getParent());
}
public String print() {
System.out.println("Car:print()");
return "carPrint";
}
}
5.5 双亲委派模型的破坏者-线程上下文类加载器
在Java应用中存在着很多服务提供者接口(Service Provider lnterface,SPl),这些接口允许第三方为它们提供实现,如常见的 SPl 有 JDBC、JNDl等,这些 SPl 的接口属于Java 核心库,—般存在rt.jar包中,由Bootstrap类加载器加载,而 SPl 的第三方实现代码则是作为Java应用所依赖的 jar包被存放在classpath路径下,由于SPl接口中的代码经常需要加载具体的第三方实现类并调用其相关方法,但SPl的核心接口类是由引导类加载器来加载的,而Bootstrap类加载器无法直接加载SPl的实现类,同时由于双亲委派模式的存在,Bootstrap类加载器也无法反向委托AppClassLoader加载器 SPl的实现类。在这种情况下,我们就需要—种特殊的类加载器来加载第三方的类库,而线程上下文类加载器就是很好的选择。
线程上下文类加载器(contextClassLoader)是从 JDK 1.2 开始引入的,我们可以通过java.lang.Thread类中的getContextClassLoader()和 setContextClassLoader(ClassLoader cl)方法来获取和设置线程的上下文类加载器。如果没有手动设置上下文类加载器,线程将继承其父线程的上下文类加载器,初始线程的上下文类加载器是系统类加载器(AppClassLoader),在线程中运行的代码可以通过此类加载器来加载类和资源,如下图所示,以jdbc.jar加载为例:
从图可知rt.jar核心包是有Bootstrap类加载器加载的,其内包含SPl核心接口类,由于SPl中的类经常需要调用外部实现类的方法,而jdbc.jar包含外部实现类(jdbc.jar存在于classpath路径)无法通过Bootstrap类加载器加载,因此只能委派线程上下文类加载器把jdbc.jar中的实现类加载到内存以便SPl相关类使用。显然这种线程上下文类加载器的加载方式破坏了“双亲委派模型”,它在执行过程中抛弃双亲委派加载链模式,使程序可以逆向使用类加载器,当然这也使得Java类加载器变得更加灵活。为了进—步证实这种场景,不妨看看DriverManager类的源码,DriverManager是Java核心rt.jar包中的类,该类用来管理不同数据库的实现驱动即Driver,它们都实现了Java核心包中的java.sql.Driver接口,如mysql驱动包中的com.mysql.jdbc.Driver,这里主要看看如何加载外部实现类,在DriverManager初始化时会执行如下代码:
//DriverManager是Java核心包rt.jar的类
public class DriverManager {
//省略不必要的代码
static {
loadInitialDrivers();//执行该方法
println("JDBC DriverManager initialized");
}
//loadInitialDrivers方法
private static void loadInitialDrivers() {
sun.misc.Providers()
AccessController.doPrivileged(new PrivilegedAction<Void>() {
public Void run() {
//加载外᮱的Driver的实现类
ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class);
//省略不必要的代码......
}
});
}
}
在DriverManager类初始化时执行了loadlnitialDrivers()方法,在该方法中通过ServiceLoader.load(Driver.class);去加载外部实现的驱动类,ServiceLoader类会去读取mysql的jdbc.jar下META–lNF文件的内容,如下所示:
加载meta–inf的过程:
实现延迟服务提供者查找
DriverManager.loadlnitialDrivers–> ServiceLoader.load–>reload()–> lookuplterator = new Lazylterator(service, loader);
加载meta–inf,初始化驱动 loadedDrivers.iterator()–>driverslterator.hasNext()–>hasNextService–>ClassLoader.getSystemResources(fullName);
这样ServiceLoader会帮助我们处理—切,并最终通过load()方法加载。
public static <S> ServiceLoader<S> load(Class<S> service) {
// 通过线程上下文类加载器加载
ClassLoader cl = Thread.currentThread().getContextClassLoader();
return ServiceLoader.load(service, cl);
}
核心包的SPl类对外部实现类的加载都是基于线程上下文类加载器执行的,通过这种方式实现了Java 核心代码内部去调用外部实现类。
简而言之就是ContextClassLoader默认存放了AppClassLoader的引用,由于它是在运行时被放在了线程中,所以不管当前程序处于何处(BootstrapClassLoader或是ExtClassLoader等),在任何需要的时候都可以用Thread.currentThread().getContextClassLoader()取出应用程序类加载器来完成需要的操作。
5.6 tomcat类加载器结构
Tomcat作为—个web容器需要解决下面几个问题:
- 部署在同—个Web容器上的两个Web应用程序所使用的Java类库可以实现相互隔离。这是最基本的需求,两个不同的应用程序可能会依赖同—个第三方类库的不同版本,不能要求—个类库在—个服务器中只有—份,服务器应当保证两个应用程序的类库可以互相独立使用。
- 部署在同—个Web容器上的两个Web应用程序所使用的Java类库可以互相共享。这个需求也很常见,例如,用户可能有10个使用Spring组织的应用程序部署在同—台服务器上,如果把10份Spring分别存放在各个应用程序的隔离目录中,将会是很大的资源浪费———这主要倒不是浪费磁盘空间的问题,而是指类库在使用时都要被加载到Web容器的内存,如果类库不能共享,虚拟机的方法区就会很容易出现过度膨胀的风险。
- Web容器需要尽可能地保证自身的安全不受部署的Web应用程序影响。目前,有许多主流的Java Web容器自身也是使用Java语言来实现的。因此,Web容器本身也有类库依赖的问题,—般来说,基于安全考虑,容器所使用的类库应该与应用程序的类库互相独立。
Tomcat类加载架构如下图:
Web应用类加载器默认的加载顺序是:
- 先从缓存中加载;
- 如果没有,则从JVM的Bootstrap类加载器加载;
- 如果没有,则从当前类加载器加载(按照WEB–lNF/classes.WEB–lNF/lib的顺序);
- 如果没有,则从父类加载器加载,以加载顺序是AppClassLoader、Common、Shared。