JVM详解(二)——类加载子系统
一、概述
1、JVM内存结构
简图:
详图-英文:
详图-中文:
二、类加载器
1、介绍
类加载器子系统负责从文件系统或者网络中加载class文件,class文件在文件开头有特定的文件标识。
ClassLoader只负责class文件的加载,至于它是否可以运行,则有Execution Engine决定。
加载的类信息存放于一块称为方法区的内存空间。除了类的信息外,方法区中还会存放运行时常量池信息,可能还包括字符串字面量和数字常量(这部分常量信息是class文件中常量池部分的内存映射)。
2、类加载器ClassLoader角色
class file存在于本地硬盘上,可以理解为设计师画在纸上的模板,而最终这个模板在执行的时候是要加载到JVM当中来,根据这个文件实例化出 n 个一模一样的实例。
class file加载到JVM中,被称为DNA元数据模板,放在方法区。
在.class文件-->JVM-->最终成为元数据模板,此过程就要一个运输工具(类装载器),扮演一个快递员的角色。
3、类加载器分类
JVM支持两种类型的类加载器,分别为引导类加载器(Bootstrap ClassLoader)和自定义类加载器(User-Defined ClassLoader)。
注意:从概念上来讲,自定义类加载器一般指的是由开发人员自定义的一类类加载器,但是Java虚拟机规范并没有这么定义,而是将所有派生于抽象类ClassLoader的类加载器都划分为自定义类加载器。为什么这么划分呢?因为引导类是用C写的,其他的是用Java写的。
换句话说:除了引导类加载器,其他都叫自定义类加载器。
在程序中,最常见的类加载器只有3个。如下:
这里的四者之间的关系是包含关系。不是上层下层,也不是子父类的继承关系。
代码示例:
1 public class Main { 2 public static void main(String[] args) { 3 // 获取系统类加载器 4 ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader(); 5 // sun.misc.Launcher$AppClassLoader@18b4aac2 6 System.out.println(systemClassLoader); 7 8 // 获取其上层:扩展类加载器。注意,不是它的父类 9 ClassLoader extClassLoader = systemClassLoader.getParent(); 10 // sun.misc.Launcher$ExtClassLoader@7b23ec81 11 System.out.println(extClassLoader); 12 13 // 获取其上层:引导类加载器。注意,不是它的父类 14 ClassLoader bootstrapClassLoader = extClassLoader.getParent(); 15 // null:获取不到 16 System.out.println(bootstrapClassLoader); 17 18 19 // 获取一下本类的类加载器:默认使用的是系统类加载器进行加载 20 ClassLoader classLoader = HelloTest.class.getClassLoader(); 21 // sun.misc.Launcher$AppClassLoader@18b4aac2 22 System.out.println(classLoader); 23 24 // 获取一下 String类 的类加载器: 25 ClassLoader loader = String.class.getClassLoader(); 26 // null:获取不到 27 System.out.println(loader); 28 } 29 }
总结:①用户自定义的类,默认使用的是系统类加载器进行加载。②String那里获取不到,表明String类使用的是引导类加载器进行加载的。Java的核心类库都是使用引导类加载器进行加载的。
那么,哪些是属于核心类呢?后面会介绍。
4、引导、扩展、系统【类加载器】
先给出一个有继承关系的图。
虚拟机自带的加载器:引导类加载器(启动类加载器)
(1)这个类加载器使用C/C++语言实现的,嵌套在JVM内部。是JVM的一部分。
(2)它用来加载Java的核心库(JAVA_HOME/jre/lib/rt.jar、resources.jar、sun.boot.class.path路径下的内容)。用于提供JVM自身需要的类。
(3)并不继承自java.lang.ClassLoader,没有父加载器。
(4)加载扩展类和系统类加载器,并指定为他们的父类加载器。即扩展类和系统类加载器也是用引导类加载器加载的。
(5)出于安全考虑,引导类加载器只加载包名为java、javax、sun等开头的类。
虚拟机自带的加载器:扩展类加载器
(1)Java语言编写,由sun.misc.Launcher.ExtClassLoader实现。
(2)派生于ClassLoader类。
(3)上层是启动类加载器。
(4)从java.ext.dirs系统属性所指定的目录中加载类库,或从JDK的安装目录的jre/lib/ext子目录(扩展目录)下加载类库。如果用户创建的jar放在此目录下,也会自动由扩展类加载器加载。
虚拟机自带的加载器:系统类加载器(应用程序类加载器)
(1)Java语言编写,由sun.misc.Launcher.AppClassLoader实现。
(2)派生于ClassLoader类。
(3)上层是扩展类加载器。
(4)它负责加载环境变量classpath或系统属性java.class.path指定路径下的类库。
(5)该类加载器是程序中默认的类加载器,一般来说,Java应用的类都是由它进行加载。
(6)通过ClassLoader.getSystemClassLoader()方法可以获取到该类加载器。
代码示例:
1 public class Main { 2 public static void main(String[] args) { 3 System.out.println("----引导类加载器----"); 4 URL[] urLs = Launcher.getBootstrapClassPath().getURLs(); 5 for (URL url : urLs) { 6 System.out.println(url.toExternalForm()); 7 } 8 9 System.out.println("----扩展类加载器----"); 10 String extDirs = System.getProperty("java.ext.dirs"); 11 for (String extDir : extDirs.split(";")) { 12 System.out.println(extDir); 13 } 14 15 } 16 } 17 18 // 结果 19 ----引导类加载器---- 20 file:/C:/Program%20Files/Java/jdk1.8.0_131/jre/lib/resources.jar 21 file:/C:/Program%20Files/Java/jdk1.8.0_131/jre/lib/rt.jar 22 file:/C:/Program%20Files/Java/jdk1.8.0_131/jre/lib/sunrsasign.jar 23 file:/C:/Program%20Files/Java/jdk1.8.0_131/jre/lib/jsse.jar 24 file:/C:/Program%20Files/Java/jdk1.8.0_131/jre/lib/jce.jar 25 file:/C:/Program%20Files/Java/jdk1.8.0_131/jre/lib/charsets.jar 26 file:/C:/Program%20Files/Java/jdk1.8.0_131/jre/lib/jfr.jar 27 file:/C:/Program%20Files/Java/jdk1.8.0_131/jre/classes 28 // 上面的就是核心类库 29 ----扩展类加载器---- 30 C:\Program Files\Java\jdk1.8.0_131\jre\lib\ext 31 C:\WINDOWS\Sun\Java\lib\ext
可以从上面的路径中随意选择一个类,来查看它的类加载器是什么。
5、用户自定义类加载器
在Java的日常应用程序开发中,类的加载几乎是由上述3种类加载器相互配合执行的,在必要时,我们还可以自定义类加载器(真正开发人员自己定义的),来定制类的加载方式。
为什么要自定义类的加载器?
隔离加载类;修改类加载的方式;扩展加载源;防止源码泄漏(加密)。
如何自定义类的加载器?实现步骤:
在JDK1.2之前,继承ClassLoader,并重写loadClass()方法; 在JDK1.2之后,不重写loadClass()方法,重写findClass()方法。
通过继承ClassLoader,实现自己的类加载器,以满足一些特殊的需求。如果在编写自定义类加载器时,没有太过于复杂的需求,可以直接继承URLClassLoader,这样就可以避免自己去编写findClass()方法及获取字节码流的方式,使自定义类加载器更简洁。
代码示例:自定义类加载器
1 // 我的类加载器 2 class MyLoader extends ClassLoader { 3 4 @Override 5 protected Class<?> findClass(String name) throws ClassNotFoundException { 6 7 byte[] result = this.getClassFromPath(name); 8 try { 9 if (result == null) { 10 throw new FileNotFoundException(); 11 } else { 12 return defineClass(name, result, 0, result.length); 13 } 14 } catch (FileNotFoundException e) { 15 e.printStackTrace(); 16 } 17 18 throw new ClassNotFoundException(name); 19 } 20 21 private byte[] getClassFromPath(String name) { 22 // 从自定义路径中加载指定类:细节略 23 // 如果指定路径的字节码文件进行了加密,则需要在此方法中进行解密操作。 24 return null; 25 } 26 } 27 28 // 测试类 29 public class Main { 30 public static void main(String[] args) { 31 MyLoader myLoader = new MyLoader(); 32 try { 33 Class<?> clazz = Class.forName("One", true, myLoader); 34 35 Object obj = clazz.newInstance(); 36 37 System.out.println(obj.getClass().getClassLoader()); 38 } catch (Exception e) { 39 e.printStackTrace(); 40 } 41 } 42 }
关注两个问题:①为什么要自定义类加载器?②重写的话要怎么重写?
6、ClassLoader
ClassLoader类,一个抽象类,其后所有的类加载器都继承它。
方法名称
|
描述
|
getParent()
|
返回该类加载器的上层
|
loadClass(String name)
|
加载名称为name的类,返回一个Class实例
|
findClass(String name)
|
查找名称为name的类,返回一个Class实例
|
findLoadedClass(String name)
|
查找名称为name的已经被加载过的类,返回一个Class实例
|
defineClass(String name, byte[] b, int off, int len)
|
把字节数组b中的内容转换为一个Java类,返回一个Class实例
|
resolveClass(Class c)
|
连接指定的一个Java类
|
代码示例:获取ClassLoader
1 // 方式一:获取当前类的ClassLoader 2 clazz.getClassLoader() 3 4 // 方式二:获取当前线程上下文的ClassLoader 5 Thread.currentThread().getContextClassLoader() 6 7 // 方式三:获取系统的ClassLoader 8 ClassLoader.getSystemClassLoader() 9 10 // 方式四:获取调用者的ClassLoader 11 DriverManager.getCallerClassLoader()
7、双亲委派机制
Java虚拟机对class文件采用的是按需加载的方式,也就是说当需要使用该类时才会将它的class文件加载到内存生成Class实例。而且加载某个类的class文件时,Java虚拟机采用的是双亲委派模式,即把请求交由上层处理,它是一种任务委派模式。
工作原理:
(1)如果一个类加载器收到了一个类加载请求,它并不会自己先去加载,而是把这个请求委托给上层的加载器去执行。
(2)如果上层还有上层,则进一步向上委托,最终请求到达顶层的引导类加载器。
(3)如果上层可以完成类加载任务,由上层完成,并成功返回;若不能完成,子类加载器才会尝试自己去加载。这就是双亲委派模式。
对双亲委派模式的深刻理解:
代码示例:
1 package java.lang; 2 3 // 创建一个类叫String,在包java.lang下 4 public class String { 5 static { 6 System.out.println("---1----"); 7 } 8 } 9 10 // 测试类 11 public class Main { 12 public static void main(String[] args) { 13 java.lang.String str = new java.lang.String(); 14 System.out.println(str); 15 } 16 } 17 18 // 结果 19 无 20 程序没有报错,静态代码块并没有执行.
问题:执行new String()到底是核心包下的String,还是自定义的String呢?
结果:可以看到自定义里String静态代码是没有执行的。
解释:如果执行的是自定义的String,那么是很崩溃的!比如一个项目,String基本是大家都要用的,然后我自定义了一个String,通过网络传给你,你一打开,把它加载到你的内存中,一运行,整个项目就挂掉了。或者,在你运行当中,我做一些恶意的数据窃取,那你整个项目就废了。
为了对我们的项目做一个保护,防止恶意攻击,引入了双亲委派机制。那么在层层向上委托,直到顶层的启动类加载器的时候,他是怎么知道,这个类该我"管"呢?
还记得不?前面介绍过,引导类加载器,它就是只负责加载包名java开头的类。
代码示例:
1 package java.lang; 2 3 // 创建一个类叫String,在包java.lang下 4 public class String { 5 public static void main(String[] args) { 6 7 } 8 } 9 10 // 结果.报错 11 错误: 在类 java.lang.String 中找不到 main 方法, 请将 main 方法定义为: 12 public static void main(String[] args) 13 否则 JavaFX 应用程序类必须扩展javafx.application.Application
核心API里的String类并没有main方法,所以就报错了。也证明了这里压根儿就没加载自定义的这个类。
双亲委派机制的优势:
接口是由引导类加载器加载的,而具体接口的实现类是第三方的,是由线程上下文类加载器加载的,即系统类加载器。优势:
(1)避免类的重复加载。
(2)保护程序安全,防止核心API被随意篡改。
代码示例:不能取包名为java.lang
1 package java.lang; 2 3 public class Kkk { 4 public static void main(String[] args) { 5 6 } 7 } 8 9 // 结果 10 java.lang.SecurityException: Prohibited package name: java.lang 11 at java.lang.ClassLoader.preDefineClass(ClassLoader.java:662) 12 at java.lang.ClassLoader.defineClass(ClassLoader.java:761) 13 at java.security.SecureClassLoader.defineClass(SecureClassLoader.java:142) 14 at java.net.URLClassLoader.defineClass(URLClassLoader.java:467) 15 at java.net.URLClassLoader.access$100(URLClassLoader.java:73) 16 at java.net.URLClassLoader$1.run(URLClassLoader.java:368) 17 at java.net.URLClassLoader$1.run(URLClassLoader.java:362) 18 at java.security.AccessController.doPrivileged(Native Method) 19 at java.net.URLClassLoader.findClass(URLClassLoader.java:361) 20 at java.lang.ClassLoader.loadClass(ClassLoader.java:424) 21 at sun.misc.Launcher$AppClassLoader.loadClass(Launcher.java:335) 22 at java.lang.ClassLoader.loadClass(ClassLoader.java:357) 23 at sun.launcher.LauncherHelper.checkAndLoadMain(LauncherHelper.java:495) 24 Error: A JNI error has occurred, please check your installation and try again 25 Exception in thread "main"
8、沙箱安全机制
自定义String类,在加载自定义String类的时候会率先使用引导类加载器加载,而引导类加载器在加载的过程中会先加载jdk自带的文件(rt.jar包中java\lang\String.class),报错信息说没有main方法,就是因为加载的是rt.jar包中的String类。这样可以保证对java核心源代码的保护,这就是沙箱安全机制。
保护核心源代码,避免受到一些恶意的攻击。自定义的String,或者Kkk,试图用引导类加载器加载,这些机制都不能够破坏现有程序的运行,以及破坏引导类加载器。把这种情况就称为沙箱安全机制。
其实可以反过来想一下,如果自定义的类可以交给引导类加载器加载了,那引导类加载器不得"忙死"。引导类加载器只会加载一个指定目录下的类。
9、其他
在JVM中,两个class对象是否为同一个类,存在两个必要条件:
(1)类的全类名必须一致。
(2)加载这个类的ClassLoader(指ClassLoader实例对象)必须相同。
即:在JVM中,即使两个类对象(class对象)来源同一个Class文件,被同一个虚拟机所加载,但只要加载它们的ClassLoader实例对象不同,那么这两个类对象也是不相等的。
对类加载器的引用:JVM必须知道一个类型是由启动类加载器加载还是用户类加载器加载。如果由用户类加载器加载,那么JVM会将这个类加载器的一个引用作为类型信息的一部分保存在方法区中。当解析一个类型到另一个类型的引用的时候,JVM需要保证这两个类型的类加载器是相同的。
类的主动使用和被动使用:
主动使用:
(1)创建类的实例。
(2)访问某个类的或接口的静态变量,或对该静态变量赋值。
(3)调用类的静态方法。
(4)反射。
(5)初始化一个类的子类。
(6)Java虚拟机启动时被标明为启动类的类。
(7)JDK7开始提供的动态语言支持:java.lang.invoke.MethodHandle实例的解析结果。REF_getStatic、REF_putStatic、REF_invokeStatic句柄对应的类没有初始化,则初始化。
被动使用:
除了以上七种情况,其他使用Java类的方式都被看作是对类的被动使用,都不会导致类的初始化。
三、类的加载过程
1、加载过程
2、Loaing(加载)
通过一个类的全类名获取定义此类的二进制字节流。将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
加载.class文件的方式:
(1)从本地系统中直接加载。
(2)通过网络获取,典型场景:Web Applet。
(3)在zip压缩包中读取,成为日后jar、war格式的基础。
(4)运行时计算生成,使用最多的是动态代理技术。
(5)由其他文件生成,典型场景:JSP应用。
(6)从专有数据库中提取.class文件,比较少见。
(7)从加密文件中获取,典型的:防class文件被反编译的保护措施。
生成大的Class实例(类对象),是在这阶段完成的。
3、Linking(链接)
验证(Verify):目的在于确保class文件的字节流中包含的信息符合当前虚拟机要求,保证被加载类的正确性,不会危害虚拟机自身安全。主要包括四种验证:文件格式验证,元数据验证,字节码验证,符号引用验证。
每个合法的字节码文件都是以"咖啡baby"开头的。
准备(Prepare):为类变量(静态变量)分配内存并设置默认初始值,即零值。这里不包含用 final 修饰的静态变量,因为 final 在编译时候就会分配了,准备阶段会显示初始化。这里不会为实例变量分配初始化,静态变量会分配在方法区中(这个在jdk7之后有改变),而实例变量是会随着对象一起分配到Java堆中。
解析(Resolve):将虚拟机常量池内的符号引用转换为直接引用的过程。事实上,解析操作往往会伴随着JVM在执行完初始化之后再执行。
①符号引用:就是用一组符号来描述所引用的目标,引用的目标不一定已经加载到内存中。符号引用的字面量形式明确定义在《Java虚拟机规范》的class文件格式中。
②直接引用:就是直接指向目标的指针、相对偏移量(直接指针)或一个间接定位到目标的句柄(句柄访问)。解析动作主要针对类或接口、字段、类方法、接口方法、方法类型等。对应常量池中的CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info等。
代码示例:
1 public class Main { 2 // prepare: a = 0 ----> initialization: a = 100 3 private static int a = 100; 4 5 public static void main(String[] args) { 6 System.out.println(a); // 100 7 } 8 }
4、Initialization(初始化)
初始化阶段就是执行类构造器方法<clinit>()的过程。此方法不需要定义,是javac编译器自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并而来。类构造器方法中指令按语句在源文件中出现的顺序执行。<clinit>()不同于类的构造器。(关联:构造器是虚拟机视角下的<init>())。若该类具有父类,JVM会保证子类的<clinit>()执行前,先执行父类的<clinit>()。虚拟机必须保证一个类的<clinit>()方法在多线程下被同步加锁,即:保证一个类只被加载一次。
代码示例:
1 public class Main { 2 3 private static int a = 55; 4 5 static { 6 a = 200; 7 } 8 9 public static void main(String[] args) { 10 // prepare: a = 0 ----> initialization: a = 55 --> 200 11 System.out.println(a); // 200 12 } 13 }
修改成如下代码:a = 55,查看对应的字节码文件,也是先push 200,再push 55。
1 public class Main { 2 3 static { 4 a = 200; 5 } 6 7 private static int a = 55; 8 9 public static void main(String[] args) { 10 // prepare: a = 0 ----> initialization: a = 200 --> 55 11 System.out.println(b); // 55 12 } 13 }
结论:初始化阶段执行<clinit>()方法,该方法执行顺序是先push 55,后push 200。是按语句在源文件中出现的顺序执行。(代码修改后的字节码文件截图未贴)
初始化阶段,有静态变量的显示初始化和静态代码块的执行(按源代码顺序执行覆盖)。
但是,有个问题需要注意:这里有编译错误,非法的前向引用。
1 public class Main { 2 3 private static int a = 55; 4 5 static { 6 a = 200; 7 b = 300; 8 System.out.println("-a-" + a); // 没问题 9 System.out.println("-b-" + b); // Illegal forward reference 10 } 11 12 private static int b = 66; 13 14 public static void main(String[] args) { 15 System.out.println(a); 16 System.out.println(b); 17 } 18 }
如上图所示:<init>()是构造器函数。而在类中没有申明静态变量与静态代码块,是看不到<clinit>这个的。
代码示例:
1 public class Main { 2 int a = 10; 3 4 public Main() { 5 this.a = 100; 6 } 7 8 public static void main(String[] args) { 9 Main d = new Main(); 10 System.out.println(d.a); // 100 11 } 12 13 }
打开字节码文件,可以看到 a 重新赋值100在<init>()中。
代码示例:
1 public class Main { 2 3 static class Father { 4 public static int A = 100; 5 6 static { 7 A = 200; 8 } 9 } 10 11 static class Son extends Father { 12 public static int B = A; 13 } 14 15 public static void main(String[] args) { 16 // 先加载Father类,再加载Son类 17 System.out.println(Son.B); // 200 18 } 19 }
虚拟机在执行类的加载过程的时候,它只会调用一次<clinit>方法,保证类只加载一次。一个类往内存中加载,只需要加载一次就可以了。一个类也只会被加载一次。
代码示例:
1 public class Main { 2 public static void main(String[] args) { 3 Runnable r = new Runnable() { 4 @Override 5 public void run() { 6 System.out.println(Thread.currentThread().getName() + "开始"); 7 new TestThread(); 8 System.out.println(Thread.currentThread().getName() + "结束"); 9 } 10 }; 11 12 new Thread(r, "线程1").start(); 13 new Thread(r, "线程2").start(); 14 } 15 } 16 17 class TestThread { 18 static { 19 System.out.println(Thread.currentThread().getName() + "初始化当前类:TestThread"); 20 } 21 } 22 23 // 可能的一种结果 24 线程1开始 25 线程1初始化当前类:TestThread 26 线程1结束 27 线程2开始 28 线程2结束 29 30 class TestThread { 31 static { 32 if (true) { 33 System.out.println(Thread.currentThread().getName() + "初始化当前类:TestThread"); 34 while (true) { 35 36 } 37 } 38 } 39 } 40 41 // 可能的一种结果 42 线程1开始 43 线程2开始 44 线程2初始化当前类:TestThread 45 死循环
开启两个线程创建类。若两个线程都加载了类TestThread,那么静态代码块里面的打印语句会出现两次,而事实执行,只出现了一次。
作者:Craftsman-L
本博客所有文章仅用于学习、研究和交流目的,版权归作者所有,欢迎非商业性质转载。
如果本篇博客给您带来帮助,请作者喝杯咖啡吧!点击下面打赏,您的支持是我最大的动力!