类装载器子系统
在JVM中负责装载.class文件(一种8位二进制流文件,各个数据项按顺序紧密的从前向后排列, 相邻的项之间没有间隙,经编译器编译.java源文件后生成,每个类(或者接口)都单独占有一个class文件)。
运行时数据区
1 方法区
当JVM使用类装载器定位class文件,并将其输入到内存中时。会提取class文件的类型信息,并将这些信息存储到方法区中。同时放入方法区中的还有该类型中的类静态变量。
该类型的全限定名。如java.io.FileOutputStream
该类型的直接超类的全限定名。如java.io.OutputStream
该类型是类类型还是接口类型。
该类型的访问修饰符(public、abstract、final)。
任何直接超接口的全限定名的有序列表。如java.io.Closeable, java.io.Flushable。
该类型的常量池。比如所有类型(Class)、方法、字段的符号、######基本数据类型的直接数值(final)等。
字段信息:对类型中声明的每个字段。
方法信息。
类静态变量:静态变量而不是放在堆里面,所以静态属于类,不属于对象。
指向ClassLoader类的引用。
指向Class类的引用。
方法表 为了能快速定位到类型中的某个方法,JVM对每个装载的类型都会建立一个方法表,用于存储该类型对象可以调用的方法的直接引用,这些方法就包括从超类中继承来的。而这张表与Java动态绑定机制的实现是密切相关的。
常量池
常量池指的是在编译期被确定,并被保存在已编译的.class文件中的一些数据。除了包含代码中所定义的各种基本数据类型和对象型(String及数组)的常量值(final,在编译时确定,并且编译器会优化)还包含一些以文本形式出现的符号引用(类信息),比如:
类和接口的全限定名
字段的名称和描述符
虚拟机必须给每个被装载的类型维护一个常量池。
常量池就是该类型所用到常量的一个有序集合,包括直接常量(string、integer等)和其他类型,字段和方法的符号引用。
方法区是多线程共享的
也就是当虚拟机实例开始运行程序时,边运行边加载进class文件。不同的Class文件都会提取出不同类型信息存放在方法区中。同样,方法区中不再需要运行的类型信息会被垃圾回收线程丢弃掉。
堆内存
Java 程序在运行时创建的所有类型对象和数组都存储在堆中。JVM会根据new指令在堆中开辟一个确定类型的对象内存空间。但是堆中开辟对象的空间并没有任何人工指令可以回收,而是通过JVM的垃圾回收器负责回收。
堆中对象存储的是该对象以及对象所有超类的实例数据(但不是静态数据)。
其中一个对象的引用可能在整个运行时数据区中的很多地方存在,比如Java栈,堆,方法区等。
堆中对象还应该关联一个对象的锁数据信息以及线程的等待集合(线程等待池)。这些都是实现Java线程同步机制的基础。
java中数组也是对象,那么自然在堆中会存储数组的信息。
程序计数器
对于一个运行的Java而言,每一个线程都有一个PC寄存器。当线程执行Java程序时,PC寄存器的内容总是下一条将被执行的指令地址。
Java栈
每启动一个线程,JVM都会为它分配一个Java栈,用于存放方法中的局部变量,操作数以及异常数据等。当线程调用某个方法时,JVM会根据方法区中该方法的字节码组建一个栈帧。并将该栈帧压入Java栈中,方法执行完毕时,JVM会弹出该栈帧并释放掉。
注意:Java栈中的数据是线程私有的,一个线程是无法访问另一个线程的Java栈的数据。这也就是为什么多线程编程时,两个相同线程执行同一方法时,对方法内的局部变量是不需要数据同步的原因。
成员变量有默认值(被final修饰且没有static的必须显式赋值),局部变量不会自动赋值。
执行引擎
运行Java的每一个线程都是一个独立的虚拟机执行引擎的实例。从线程生命周期的开始到结束,他要么在执行字节码,要么在执行本地方法。一个线程可能通过解释或者使用芯片级指令直接执行字节码,或者间接通过JIT(即时编译器)执行编译过的本地代码
注意:JVM是进程级别,执行引擎是线程级别。
指令集
实际上,class文件中方法的字节码流就是有JVM的指令序列构成的。每一条指令包含一个单字节的操作码,后面跟随0个或多个操作数。
指令由一个操作码和零个或多个操作数组成。
iload_0 // 把存储在局部变量区中索引为0的整数压入操作数栈。iload_1 // 把存储在局部变量区中索引为1的整数压入操作数栈。iadd // 从操作数栈中弹出两个整数相加,在将结果压入操作数栈。 istore_2 // 从操作数栈中弹出结果
很显然,上面的指令反复用到了Java栈中的某一个方法栈帧。实际上执行引擎运行Java字节码指令很多时候都是在不停的操作Java栈,也有的时候需要在堆中开辟对象以及运行系统的本地指令等。但是Java栈的操作要比堆中的操作要快的多,因此反复开辟对象是非常耗时的。这也是为什么Java程序优化的时候,尽量减少new对象。
示例分析:
//源代码 Test.java package edu.hr.jvm; import edu.hr.jvm.bean; publicclassTest{ publicstaticvoidmain(String[] args) { Act act=new Act(); act.doMathForever(); } } //源代码 Act.java package edu.hr.jvm.bean; publicclassAct{ publicvoiddoMathForever() { int i=0; for(;;){ i+=1; i*=2; } } }
首先OS会创建一个JVM实例(进行必要的初始化工作,比如:初始启动类装载器,初始运行时内存数据区等。
然后通过自定义类装载器加载Test.class。并提取Test.class字节码中的信息存放在方法区 中(具体的信息在上面已经讲过)。上图展示了方法区中的Test类信息,其中在常量池中有一个符号引用“Act”(类的全限定名,注意:这个引用目前还没有真正的类信息的内存地址)。
接着JVM开始从Test类的main字节码处开始解释执行。在运行之前,会在Java栈中组建一个main方法的栈帧 ,如上图Java栈所示。JVM需要运行任何方法前,通过在Java栈中压入一个帧栈。在这个帧栈的内存区域中进行计算。
现在可以开始执行main方法的第一条指令 —— JVM需要为常量池的第一项的类(符号引用Act)分配内存空间。但是Act类此时还没有加载进JVM(因为常量池目前只有一个“Act”的符号引用)。
JVM加载进Act.class,并提取Act类信息放入方法区中。然后以一个直接指向方法区Act类信息的直接引用(在栈中)换开始在常量池中的符号引用“Act”,这个过程就是常量池解析 。以后就可以直接访问Act的类信息了。
此时JVM可以根据方法区中的Act类信息,在堆中开辟一个Act类对象act。
接着开始执行main方法中的第二条指令调用doMathForever方法。这个可以通过堆中act对象所指的方法表中查找,然后定位到方法区中的Act类信息中的doMathForever方法字节码。在运行之前,仍然要组建一个doMathForever栈帧压入Java栈。(注意:JVM会根据方法区中doMathForever的字节码来创建栈帧的局部变量区和操作数栈的大小)
接下来JVM开始解释运行Act.doMathForever字节码的内容了。
编译和运行过程
编译:源码要运行,必须先转成二进制的机器码。这是编译器的任务。
源文件由编译器编译成字节码。 创建完源文件之后,程序会先被编译为.class文件。Java编译一个类时,如果这个类所依赖的类还没有被编译,编译器就会先编译这个被依赖的类,然后引用,否则直接引用。如果java编译器在指定目录下找不到该类所其依赖的类的.class文件或者.java源文件的话,编译器话报“cant find symbol”的错误。编译后的字节码文件格式主要分为两部分:常量池和方法字节码。常量池记录的是代码出现过的所有token(类名,成员变量名等等)以及符号引用(方法引用,成员变量引用等等);方法字节码放的是类中各个方法的字节码。运行:java类运行的过程大概可分为两个过程:类的加载,类的执行。需要说明的是:JVM主要在程序第一次主动使用类的时候,才会去加载该类。也就是说,JVM并不是在一开始就把一个程序就所有的类都加载到内存中,而是到不得不用的时候才把它加载进来,而且只加载一次。
下面是程序运行的详细步骤:
//MainApp.java publicclassMainApp { publicstaticvoidmain(String[] args) { Animal animal = new Animal("Puppy"); animal.printName(); } } //Animal.java publicclassAnimal {public String name; publicAnimal(String name) { this.name = name; } publicvoidprintName(){ System.out.println("Animal ["+name+"]"); } }
在编译好java程序得到MainApp.class文件后,在命令行上敲java AppMain。系统就会启动一个jvm进程,jvm进程从classpath路径中找到一个名为AppMain.class的二进制文件,将MainApp的类信息加载到运行时数据区的方法区内,这个过程叫做MainApp类的加载。然后JVM找到AppMain的主函数入口,开始执行main函数。main函数的第一条命令是Animal animal = new Animal("Puppy");就是让JVM创建一个Animal对象,但是这时候方法区中没有Animal类的信息,所以JVM马上加载Animal类,把Animal类的类型信息放到方法区中。加载完Animal类之后,Java虚拟机做的第一件事情就是在堆区中为一个新的Animal实例分配内存,然后调用构造函数初始化Animal实例,这个Animal实例持有着指向方法区的Animal类的类型信息(其中包含有方法表,java动态绑定的底层实现)的引用。当使用animal.printName()的时候,JVM根据animal引用找到Animal对象,然后根据Animal对象持有的引用定位到方法区中Animal类的类型信息的方法表,获得printName()函数的字节码的地址。开始运行printName()函数的字节码(可以把字节码理解为一条条的指令)。
特别说明:java类中所有public和protected的实例方法都采用动态绑定机制,所有私有方法、静态方法、构造器及初始化方法<clinit>都是采用静态绑定机制。而使用动态绑定机制的时候会用到方法表,静态绑定时并不会用到。
通过前面的两个例子的分析,应该理解了不少了吧。
类加载机制
JVM主要包含三大核心部分:类加载器,运行时数据区和执行引擎。
虚拟机将描述类的数据从class文件加载到内存,并对数据进行校验,准备,解析和初始化,最终就会形成可以被虚拟机使用的java类型,这就是一个虚拟机的类加载机制。java在类中的类是动态加载的,只有在运行期间使用到该类的时候,才会将该类加载到内存中,java依赖于运行期动态加载和动态链接来实现类的动态使用。
一个类的生命周期:
加载,验证,准备,初始化和卸载在开始的顺序上是固定的,但是可以交叉进行。在Java中,对于类有且仅有四种情况会对类进行“初始化”。
使用new关键字实例化对象的时候,读取或设置一个类的静态字段时候(除final修饰的static外),调用类的静态方法时候,都只会初始化该静态字段或者静态方法所定义的类。使用reflect包对类进行反射调用的时候,如果类没有进行初始化,则先要初始化该类。当初始化一个类的时候,如果其父类没有初始化过,则先要触发其父类初始化。虚拟机启动的时候,会初始化一个有main方法的主类。
注意:
子类引用父类静态字段,只会初始化父类不会初始化子类
通过数组定义来引用类,也不会触发该类的初始化
常量在编译阶段会存入调用类的常量池中,本质上没有直接引用到######定义常量的类,因此也不会触发定义常量的类的初始化
类加载过程
加载
加载阶段主要完成三件事,即通过一个类的全限定名来获取定义此类的二进制字节流,将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构,在Java堆中生成一个代表此类的Class对象,作为访问方法区这些数据的入口。这个加载过程主要就是靠类加载器实现的,这个过程可以由用户自定义类的加载过程。
验证
这个阶段目的在于确保才class文件的字节流中包含信息符合当前虚拟机要求,不会危害虚拟机自身安全。主要包括四种验证:
文件格式验证:基于字节流验证,验证字节流是否符合Class文件格式的规范,并且能被当前虚拟机处理。元数据验证:基于方法区的存储结构验证,对字节码描述信息进行语义验证。字节码验证:基于方法区的存储结构验证,进行数据流和控制流的验证。符号引用验证:基于方法区的存储结构验证,发生在解析中,是否可以将符号引用成功解析为直接引用。
准备
仅仅为类变量(即static修饰的字段变量)分配内存并且设置该类变量的初始值即零值,这里不包含用final修饰的static,因为final在编译的时候就会分配了(编译器的优化),同时这里也不会为实例变量分配初始化。类变量会分配在方法区中,而实例变量是会随着对象一起分配到Java堆中。
解析
解析主要就是将常量池中的符号引用替换为直接引用的过程。符号引用就是一组符号来描述目标,可以是任何字面量,而直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。有类或接口的解析,字段解析,类方法解析,接口方法解析。
初始化
初始化阶段依旧是初始化类变量和其他资源,这里将执行用户的static字段和静态语句块的赋值操作。这个过程就是执行类构造器< clinit >方法的过程。< clinit >方法是由编译器收集类中所有类变量的赋值动作和静态语句块的语句生成的,类构造器< clinit >方法与实例构造器< init >方法不同,这里面不用显示的调用父类的< clinit >方法,父类的< clinit >方法会自动先执行于子类的< clinit >方法。即父类定义的静态语句块和静态字段都要优先子类的变量赋值操作。
类加载器
类加载器的分类
启动类加载器(Bootstrap ClassLoader):
主要负责加载<JAVA_HOME>\lib目录中的'.'或是-Xbootclasspath参数指定的路径中的,并且可以被虚拟机识别(仅仅按照文件名识别的)的类库到虚拟机内存中。它加载的是System.getProperty("sun.boot.class.path")所指定的路径或jar。扩展类加载器(Extension ClassLoader):主要负责加载<JAVA_HOME>\lib\ext目录中的,或者被java.ext.dirs系统变量所指定的路径中的所有类库。它加载的是System.getProperty("java.ext.dirs")所指定的路径或jar。应用程序类加载器(Application ClassLoader):也叫系统类加载器,主要负责加载ClassPath路径上的类库,如果应用程序没有自定义自己类加载器,则这个就是默认的类加载器。它加载的是System.getProperty("java.class.path")所指定的路径或jar。类加载器的特点
运行一个程序时,总是由Application Loader(系统类加载器)开始加载指定的类。在加载类时,每个类加载器会将加载任务上交给其父,如果其父找不到,再由自己去加载。Bootstrap Loader(启动类加载器)是最顶级的类加载器了,其父加载器为null。类加载器的双亲委派模型
类加载器双亲委派模型的工作过程是:
如果一个类加载器收到一个类加载的请求,它首先将这个请求委派给父类加载器去完成,每一个层次类加载器都是如此,则所有的类加载请求都会传送到顶层的启动类加载器,只有父加载器无法完成这个加载请求(即它的搜索范围中没有找到所要的类),子类才尝试加载。
使用双亲委派模型主要是两个原因:
可以避免重复加载,当父类已经加载了,则就子类不需再次加载;安全因素,如果不用这种,则用户可以随意的自定义加载器来替代Java核心API,则就会带来安全隐患。下面是一个类加载器双亲委派模型,这里各个类加载器并不是继承关系,它们利用组合实现的父类与子类关系。
类加载的几种方式
命令行启动应用时候由JVM初始化加载,加载含有main的主类。
通过Class.forName("Hello")方法动态加载类,默认会执行初始化块,这是因为Class.forName("Hello")其实就是Class.forName("Hello",true,CALLCLASS.getClassLoader()),第二个参数就是类加载过程中的连接操作。如果指定了ClassLoader,则不会执行初始化块。通过ClassLoader.loadClass("Hello")方法动态加载类,不会执行初始化块,因为loadClass方法有两个参数,用户只是用第一个参数,第二个参数默认为false,即不对该类进行解析则就不会初始化。类加载实例
当在命令行下执行:java HelloWorld
HelloWorld是含有main方法的类的Class文件,JVM会将HelloWorld.class加载到内存中,并在堆中形成一个Class的对象HelloWorld.class。
基本的加载流程如下:
寻找jre目录,寻找jvm.dll,并初始化JVM;产生一个Bootstrap Loader(启动类加载器);Bootstrap Loader,该加载器会加载它指定路径下的Java核心API,并且再自动加载Extended Loader(标准扩展类加载器),Extended Loader会加载指定路径下的扩展JavaAPI,并将其父Loader设为BootstrapLoader。Bootstrap Loader也会同时自动加载AppClass Loader(系统类加载器),并将其父Loader设为ExtendedLoader。最后由AppClass Loader加载CLASSPATH目录下定义的类,HelloWorld类。创建自己的类加载器在Java应用开发过程中,可能会需要创建应用自己的类加载器。典型的场景包括实现特定的Java字节代码查找方式、对字节代码进行加密/解密以及实现同名Java类的隔离等。创建自己的类加载器并不是一件复杂的事情,只需要继承自java.lang.ClassLoader类并覆写对应的方法即可。 java.lang.ClassLoader中提供的方法有不少,下面介绍几个创建类加载器时需要考虑的:
defineClass():这个方法用来完成从Java字节码的字节数组到java.lang.Class的转换。这个方法是不能被覆写的,一般是用原生代码来实现的。findLoadedClass():这个方法用来根据名称查找已经加载过的Java类。一个类加载器不会重复加载同一名称的类。findClass():这个方法用来根据名称查找并加载Java类。loadClass():这个方法用来根据名称加载Java类。resolveClass():这个方法用来链接一个Java类。这里比较 容易混淆的是findClass()方法和loadClass()方法的作用。前面提到过,在Java类的链接过程中,会需要对Java类进行解析,而解析可能会导致当前Java类所引用的其它Java类被加载。在这个时候,JVM就是通过调用当前类的定义类加载器的loadClass()方法来加载其它类的。findClass()方法则是应用创建的类加载器的扩展点。应用自己的类加载器应该覆写findClass()方法来添加自定义的类加载逻辑。 loadClass()方法的默认实现会负责调用findClass()方法。前面提到,类加载器的代理模式默认使用的是父类优先的策略。这个策略的实现是封装在loadClass()方法中的。如果希望修改此策略,就需要覆写loadClass()方法。
下面的代码给出了自定义的类加载的常见实现模式:
publicclassMyClassLoaderextendsClassLoader{ protected Class<?> findClass(String name) throws ClassNotFoundException { byte[] b = null; //查找或生成Java类的字节代码 return defineClass(name, b, 0, b.length); }}
Java垃圾回收机制Java堆内存分代收集
新生代(Young Generation)
Eden空间(Eden space,任何实例都通过Eden空间进入运行时内存区域)S0 Survivor空间(S0 Survivor space,存在时间长的实例将会从Eden空间移动到S0 Survivor空间)S1 Survivor空间 (存在时间更长的实例将会从S0 Survivor空间移动到S1 Survivor空间)
老年代(Old Generation)实例将从S1提升到Tenured(终身代)
永久代(Permanent Generation)包含类、方法等细节的元信息
永久代空间在Java SE8特性中已经被移除。
垃圾回收过程
年轻代:使用标记复制清理算法,解决内存碎片问题。因为在年轻代会有大量的内存需要回收,GC比较频繁。通过这种方式来处理内存碎片化,然后在老年代中通过标记清理算法来回收内存,因为在老年代需要被回收的内存比较少,提高效率。
Eden 区:
当一个实例被创建了,首先会被存储在堆内存年轻代的 Eden 区中。
Survivor 区(S0 和 S1):
作为年轻代 GC(Minor GC)周期的一部分,存活的对象(仍然被引用的)从 Eden区被移动到 Survivor 区的 S0 中。类似的,垃圾回收器会扫描 S0 然后将存活的实例移动到 S1 中。总会有一个空的survivor区。
老年代:
老年代(Old or tenured generation)是堆内存中的第二块逻辑区。当垃圾回收器执行 Minor GC 周期时(对象年龄计数器),在 S1 Survivor 区中的存活实例将会被晋升到老年代,而未被引用的对象被标记为回收。老年代是实例生命周期的最后阶段。Major GC 扫描老年代的垃圾回收过程。如果实例不再被引用,那么它们会被标记为回收,否则它们会继续留在老年代中。
内存碎片:
一旦实例从堆内存中被删除,其位置就会变空并且可用于未来实例的分配。这些空出的空间将会使整个内存区域碎片化。为了实例的快速分配,需要进行碎片整理。基于垃圾回收器的不同选择,回收的内存区域要么被不停地被整理,要么在一个单独的GC进程中完成。
根可达性算法
Java语言规范没有明确地说明JVM使用哪种垃圾回收算法,但是任何一种垃圾收集算法一般要做2件基本的事情:
发现无用信息对象回收被无用对象占用的内存空间,使该空间可被程序再次使用。
GC Roots根集就是正在执行的Java程序可以访问的引用变量的集合(包括局部变量、参数、类变量)
GC Roots的对象包括
虚拟机栈中所引用的对象(本地变量表)
方法区中类静态属性引用的对象
方法区中常量引用的对象
本地方法栈中JNI引用的对象(Native对象)
**可达性算法分析 **
通过一系列称为”GC Roots”的对象作为起点,从这些节点开始向下搜索,搜索所有走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连时(从GC Roots到此对象不可达),则证明此对象是不可用的,应该被回收。
根搜索算法:计算可达性,如图:
垃圾回收算法
引用计数法
引用计数法是唯一没有使用根集(GC Roots)的垃圾回收的法,该算法使用引用计数器来区分存活对象和不再使用的对象。堆中的每个对象对应一个引用计数器。当每一次创建一个对象并赋给一个变量时,引用计数器置为1。当对象被赋给任意变量时,引用计数器每次加1,当对象出了作用域后(该对象丢弃不再使用),引用计数器减1,一旦引用计数器为0,对象就满足了垃圾收集的条件。唯一没有使用根可达性算法的垃圾回收算法。缺陷:不能解决循环引用的回收。
tracing算法(tracing collector)
tracing算法是为了解决引用计数法的问题而提出,它使用了根集(GC Roots)概念。垃圾收集器从根集开始扫描,识别出哪些对象可达,哪些对象不可达,并用某种方式标记可达对象,例如对每个可达对象设置一个或多个位。在扫描识别过程中,基于tracing算法的垃圾收集也称为标记和清除(mark-and-sweep)垃圾收集器。
compacting算法(Compacting Collector)
为了解决堆碎片问题,在清除的过程中,算法将所有的对象移到堆的一端,堆的另一端就变成了一个相邻的空闲内存区,收集器会对它移动的所有对象的所有引用进行更新,使得这些引用在新的位置能识别原来的对象。在基于Compacting算法的收集器的实现中,一般增加句柄和句柄表。
copying算法(Coping Collector)
该算法的提出是为了克服句柄的开销和解决堆碎片的垃圾回收。它开始时把堆分成 一个对象面和多个空闲面,程序从对象面为对象分配空间,当对象满了,基于coping算法的垃圾收集就从根集中扫描活动对象,并将每个活动对象复制到空闲面(使得活动对象所占的内存之间没有空闲洞),这样空闲面变成了对象面,原来的对象面变成了空闲面,程序会在新的对象面中分配内存。
generation算法(Generational Collector
现在的java内存分区stop-and-copy垃圾收集器的一个缺陷是收集器必须复制所有的活动对象,这增加了程序等待时间,这是coping算法低效的原因。在程序设计中有这样的规律:多数对象存在的时间比较短,少数的存在时间比较长。因此,generation算法将堆分成两个或多个,每个子堆作为对象的一代 (generation)。由于多数对象存在的时间比较短,随着程序丢弃不使用的对象,垃圾收集器将从最年轻的子堆中收集这些对象。在分代式的垃圾收集器运行后,上次运行存活下来的对象移到下一最高代的子堆中,由于老一代的子堆不会经常被回收,因而节省了时间。
adaptive算法(Adaptive Collector)
在特定的情况下,一些垃圾收集算法会优于其它算法。基于Adaptive算法的垃圾收集器就是监控当前堆的使用情况,并将选择适当算法的垃圾收集器。
以上就是扣丁学堂Java培训之类加载机制原理分析与hook技术实现底层方法的详细介绍,希望对小伙伴们有所帮助,想要了解更多内容的小伙伴可以登录扣丁学堂官网查询。扣丁学堂是专业的Java培训机构,不仅有专业的老师和与时俱进的课程体系,还有大量的Java在线教程供学员挂看学习哦。扣丁学堂java技术交流群:487098661。微信号:codingbb