JVM系列文章目录
篇一、JVM基本概念以及内存模型
前言
本文参照了之前的学习笔记和网上部分文章以及周志明老师所著《深入理解Java虚拟机》第三版所写,目前只是新手水平,只能被动吸收前人总结下的知识要点进行学习。部分内容主观理解内容偏多,JVM又是存在非常多的细节要点,跟随JDK的版本更迭,JVM的相关内容也发生了不小的变化。请选择性阅读,如需要深入学习还请阅读上述JVM经典图书以及《Java虚拟机规范》,以及R大博客,地址如下。
https://www.iteye.com/blog/rednaxelafx-362738
RednaxelaFX大神在JVM的研究方面可谓是大神级别的人物,一些理解上面的漏洞直接以他博客内容为准即可。
下面链接包括JDK8的JVM规范,以及一本《自己动手写JVM》的图书,都是非常经典的,值得阅读。
链接:https://pan.baidu.com/s/1QfYXshPi-pDIXk9ec1H_Og
提取码:19rs
一、JVM相关基础概念
1、JVM的特性
众所周知,JVM是基于《Java虚拟机规范》的一套标准,自然就有许多实现方式。
常见的诸如,oracle公司的HotSpot、JRockit、J9等等。相信大家早期也玩过手机上开发的一些Java游戏,同样有需要轻量级的JVM实现对嵌入式、移动设备进行支持,不过近来都在被Android取代。
这里引入一个重要的知识点,Java语言的跨平台性和可移植性,是基于JVM完成的,JVM对不同平台都有着不同的实现方式,Java编译之后的字节码文件可以通过不同平台上的JVM进行运行。此外JVM还具有跨语言性,JVM不止支持.class文件,还可以运行如:Kotlin、Clojure、Groovy、Scala、Jython、JRuby、JavaScript等语言。
如果想深入学习JVM,还可以选择选择编译OpenJDK来进行学习,这里就不给出链接了自行去官网下载即可。具体操作方式可以参照《Java虚拟机规范》的第一章节的最后部分。
总结一下,java语言具有跨平台性和可移植性,基于JVM进行实现。JVM有多种实现,其中HotSpot最为常用。
2、JVM的特点
java编译器输入的指令流基本上是一种基于【栈的指令集结构】,另一种指令集架构则是基于【寄存器的指令集架构】即GC寄存器。
- 基于栈式架构的特点:
设计和实现更简单,适用于资源受限的系统;
避开了寄存器的分配难题:使用零地址指令方式分配。
指令流的指令大部分是零地址指令,其执行过程依赖于操作栈。指令集高效,编译器容易实现。
不需要硬件支持,可移植性更好,更好实现跨平台。 - 基于寄存器架构的特点
典型的应用是x86的二进制指令集:比如传统的pc以及android的Davlik虚拟机
指令集架构则完全依赖硬件,可移植性差
性能优秀和执行更高效;
花费更少的指令去完成一项操作。
在大部分情况下,基于寄存器架构的指令集往往都以一地址指令、二地址指令和三地址指令为主,
而基于栈式架构的指令集却是以零地址指令为主。
补充:
反编译指令:javap -v StackStruTest.class
由于跨平台性的设计,java的指令都是根据栈来设计的
栈:跨平台性、指令集小、指令多;执行性能比寄存器差
3、JVM的生命周期
- JVM的启动:通过类引导器创建的一个初始类(initial class)完成的,这个类由虚拟机的具体实现来决定(不同虚拟机初始类不同)
- JVM的执行:程序开始执行它才运行,程序结束就停止,执行一个Java程序的时候,真真正正在执行的是一个叫做java虚拟机的进程。
- JVM的退出:有如下的几种情况,
– 程序正常执行结束
– 程序在执行过程中遇到了异常或错误而异常终止
– 操作系统错误导致JVM进程终止
– 某线程调用Runtime类或System类的exit方法,或Runtime类的halt方法,并且java安全管理器也允许这次exit或halt操作。
– JNI(java native interface)规范描述了用JNI Invocation API来加载或卸载java虚拟机时,java虚拟机的退出情况。
二、JVM内存模型
字节码文件的执行流程如下图所示:
JVM组成可分为:类加载子系统、运行时数据区、执行引擎、本地方法接口和本地方法库。其中需要注意的,JDK8之后,方法区已经被从运行时数据区移出,并叫法变为元数据区(Meta Area),还有一些变更的细节放在后续各部分的具体介绍里。
1、类加载子系统
类加载过程可以分为三个阶段,分别为加载阶段(loading)、链接阶段(linking)和初始化阶段(initialization)。
(1)类加载阶段
-
类加载阶段
1、通过一个类的全限定名获取定义此类的二进制字节流;
2、将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构;
3、在内存中生存一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。 -
链接阶段有三个步骤,
1、验证(verify):校验字节码是否符合JVM规范,包括类文件结构检查、语义检查、字节码验证、二进制兼容性的验证。
2、准备(prepare):对类中变量进行赋值(根据类型赋默认值,这里并不进行真正的赋值操作),但不包括static修饰的类对象变量。static修饰的变量在此阶段将会直接初始化完成。
3、解析(Resolve):在类型(字节码)中寻找类、接口和方法的符号引用,并将这些引用转变为符号引用,class常量池存放在字节码文件中。
符号引用:就是一组符号来描述所引用的目标,可以是任何字面量,只要使用时可以无歧义的定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到内存中。
直接引用:就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。直接引用和虚拟机实现的内存布局相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在内存中存在。
- 初始化阶段
初始化阶段为类加载过程的最后一步,到了初始化阶段,才真正开始执行类中定义的java程序代码(字节码)。在编译生成class文件时,会自动产生两个方法,一个是类的初始化方法而另一个是实例的初始化方法。
这里使用jclasslib,不需要手动执行javap也可以看到解析字节码文件的内容。可以看到图中的init方法,
下面随便建一个类,并创建一个静态代码块。
直接使用javap -c TT.class来反编译.class文件得到
类的初始化方法clinit():在jvm第一次加载class文件时调用,包括静态变量初始化语句和静态块的执行。
实例的初始化方法init(): 在实例创建出来的时候调用,包括调用new操作符;调用 Class 或 Java.lang.reflect.Constructor 对象的newInstance()方法;调用任何现有对象的clone()方法;通过 java.io.ObjectInputStream 类的getObject() 方法反序列化。
- 这里补充一个知识点:
java程序对类的使用分别为:主动使用和被动使用
主动使用包括
1、创建类的实例,也就是直接new
2、访问某个类或接口的静态变量或者对静态变量赋值
3、访问类的静态方法
4、初始化一个类的子类,会先调用父类的构造方法classLoader等
5、使用反射(如:Class.forName(“xxx”))
6、Java虚拟机启动时被表明为启动类的类
7、动态代理,使用JDK 7 开始提供的动态语言支持:
java.lang.invoke.MethodHandle实例的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic句柄对应的类没有初 始化,则初始化
除去以上七种情况,其余情况都是进行类的被动使用,不会触发类的初始化操作。
- 类的初始化操作
1、假设这个类还没有被加载进JVM中,那么就先加载和链接。
2、假如这个类存在直接父类,那么先初始化父类
3、假如这个类中存在初始化语句,那么按照顺序执行。
4、当虚拟机加载一个类时,要求把它的父类都先加载,但不适用于接口。对于接口来说,只有当程序首次调用接口内部的静态变量时或者修改变量值时,会对接口进行初始化。
例如:
public interface TestInterfaceInit {
boolean isInit = false;
public void setIsInit(boolean isInit);
}
class MyTest implements TestInterfaceInit {
@Override
public void setIsInit(boolean isInit) {
// isInit = true;
System.out.println("set interface success");
}
public static void main(String[] args) {
new MyTest().setIsInit(true);
}
}
对于setIsInit(boolean)方法来说,即使调用了接口中被实现的方法也没有对该接口进行初始化。
当调用接口的静态属性时,则出现init方法。
(2)类加载器
下面介绍将介绍类加载器的分类及作用。
JVM支持的类加载器可以看作成两类,分为别引导类加载器(BootStrapClassLoader)和自定义类加载器(User-DefinedClassLoader)。
从概念上来说用户自定义类加载器是程序中由开发人员定义的一类加载器,但是Java虚拟机规范中并没有这样定义,而是将所有的派生于抽象类ClassLoader的所有类加载器都称之为自定义类加载器。
无论类加载器的类型如何进行划分,我们在程序中常见到的类加载器只有三个:
-
引导类加载器(BootstrapClassLoader)
这个类加载使用C/C++语言实现的,嵌套在JVM内部。
用来加载Java的核心库(JAVA_HOME/jre/lib/rt.jar、resources.jar或sun.boot.class.path路径下的内容),用于提供 JVM自身需要的类。
并不继承自java.lang.ClassLoader,没有父加载器。
出于安全考虑,BootStrap启动类加载器只加载包名为java、javax、sun等开头的类。
注:我们在java程序中无法获得引导类加载器的类型信息,程序会返回null -
扩展类加载器(ExtensionClassLoader)
java语言编写,由sum.misc.Launcher$ExtClassLoader实现
派生于ClassLoader类
父类加载器为启动类加载器
从java.ext.dirs系统属性所指定的目录中加载类库,或从JDK的安装目录的jre/lib/ext子目录下加载类库。 -
系统类加载器(ApplicationClassLoader)
java语言编写,由sum.misc.Launcher$ExtClassLoader实现
派生于ClassLoader类
父类加载器为扩展类加载器
它负责加载环境变量classpath或系统属性 java.class.path指定路径下的类库
该类加载是程序中默认的类加载器,一般来说,Java应用的类都是由它来完成加载
通过ClassLoader#getSystemClassLoader()方法可以获取到该类加载器 -
自定义类加载器(AppClassLoader)
隔离类加载器
修改类加载的方式
扩展加载源
防止源码泄露
注:各个加载器按照父子关系形成了树形数据结构,但其数据结构是逻辑上的而非物理上的,所以这几个类之间并不是继承关系,而是加载器之间的一种包装关系。
这里再补充一点双亲委派机制的概念和作用
- 双亲委派机制
- 工作原理
如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行;
如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的启动类加载器;
如果父类加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派模式。 - 作用
可以确保核心库的安全:所有的Java应用都至少会引用到Object类,也就是说在运行期,java.lang.Object类会加载到虚拟机中,如果这个类的加载器的用户自定义的,在程序运行期间可能会存在多个Object类的版本,而且这些类之间还是相互不兼容的,并且不可见的。
保护程序安全,防止核心API被篡改
不同类加载器可以为相同名称的类创建额外的命名空间,相同名称的类可以并存在Java虚拟机中,只需要用不同的类加载 器进行加载即可。 - 破坏双亲委派模型
在JDK2之后提供了线程上下文类加载器(Context ClassLoader),类Thread中的getContextClassLoader和 setContextClassLoader(ClassLoader cl)分别用来获取和设置,Java应用运行时的初始线程的上下文类加载器就是系统类加载器。
线程上下文加载器的重要性:
SPI(Service Provider Interface )的父ClassLoader可以使用Thread.currentThread().getContextLoader()所指定的ClassLoader类,这就改变了父类不能使用子类ClassLoader或者使用其他类ClassLoader的情况,即改变了双亲委派模型。
传统的双亲委派模型无法满足SPI的要求(由不同jar包厂商提供),使用上下文类加载器完成对这些类的加载。当高层提供了统一的接口给底层实现,但在高层又想使用(或加载)底层的类时,就必须要用上下文加载器来帮助高层的ClassLoader完成对底层类的加载。如大家所熟悉的JDBC就是使用这种方式完成的。
如何理解破坏双亲委派模型和应用场景呢?
在JDK3当中提供一个JDNI服务,用于查找资源和对资源的集中管理,是J2EE规范中的重要规范之一。说白了就是把*资源取个名字,再根据名字来找资源。
JDNI的这些组件是由启动类加载器所加载的,但JDNI又需要调用其他厂商所实现并部署在应用程序classpath下的,JDNI服务者提供接口SPI(Service Provider Interface)。然而启动类加载器是不可能认识这些类的,由此引入了破坏双亲委派模型。
这里是ClassLoader类中关于双亲委派机制的实现,大家可以看一下。
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
//首先检查这个类是否以及被加载过了
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
//说明父类无法完成加载这个类的请求
}
if (c == null) {
long t1 = System.nanoTime();
//在调用父类加载器无法完成加载时
//调用自己的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;
}
}
深入理解JVM中有份重写的ClassLoader代码,可以参考一下,并且验证了一下,通过不同类加载器加载类创建出的对象用equals比较会返回false;
**
1. 如果是同一个完整类名创建的对象,但由于加载该类的类加载器不同,创建出来的对象也不会相等。
*/
public class MyClassLoaderTest {
public static void main(String[] args) throws Exception{
ClassLoader myLoader = new ClassLoader() {
@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
try {
//MyClassLoaderTest.class
String fileName = name.substring(name.lastIndexOf(".")+1)+".class";
//转为二进制字节流
InputStream input = getClass().getResourceAsStream(fileName);
if (input == null) {
return super.loadClass(name);
}
//往数组中读入字节流
byte[] b = new byte[input.available()];
input.read(b);
//该方法使用二进制字节流来创建对应Class对象
return defineClass(name,b,0,b.length);
} catch (IOException e) {
throw new ClassNotFoundException(name);
}
}
};
ClassLoader classLoader = Class.forName("com.abkm.jvmstu.MyClassLoaderTest").getClassLoader();
MyClassLoaderTest t = new MyClassLoaderTest();
System.out.println(classLoader+"["+t.getClass()+"]");
Object obj = myLoader.loadClass("com.abkm.jvmstu.MyClassLoaderTest").newInstance();
System.out.println(obj.getClass());
System.out.println(t.equals(obj)); //false
System.out.println(obj.getClass());
System.out.println(obj instanceof com.company.jvmstu.MyClassLoaderTest); //false
System.out.println(obj.getClass().getClassLoader());
/*Object obj = myLoader.loadClass("com.company.jvmstu.oo.OO").newInstance();
Method method = obj.getClass().getMethod("say",null);
method.invoke(obj,null);
System.out.println(obj.getClass().getClassLoader());*/
//OO类的静态代码被加载
//hello world
//sun.misc.Launcher$AppClassLoader@18b4aac2
}
}
上面实例其实又引入一个叫命名空间的概念
- 命名空间
每个类的加载器都有一个自己的命名空间,命名空间由该类加载器以及所有父类加载器的命名空间共同构成,在同一个命名空间中不会出现类的完整名字相同的两个类,在不同命名空间中则可能存在。同一个命名空间中的类是相互可见的,子类可以看见父类命名空间中的类,而反之不行。
总结
这是JVM系列的第一篇文章,之前收集的笔记过于凌乱,需要多整理一下,也写的有些抓不住重点,
这一篇文章主要介绍了JVM的基础概念和JVM内存结构的一部分知识,由于本人水平有限,对于字节码的相关知识难以下咽。所以选择跳过了该部分,如果有兴趣的朋友可以从《深入理解java虚拟机》第三部分的第六章,从类文件结构到字节码指令的解析均有介绍。
JVM可谓是JAVA语言的基石,对于JAVA程序开发者,或者想从事这方面工作的朋友都应该或多或少了解一些。许多朋友大学中应该会开设《编译原理》这样的课程,本人就没有这么好的运气了哈哈。
那么大致总结一下本文核心点:
1、JAVA语言的特性以及JVM对其的支撑作用。
2、JVM的内存模型图(需要掌握):包括类加载器、运行时数据区、执行引擎、本地方法库和本地方法接口。
3、类加载器中的三个阶段和五个步骤:加载阶段、链接阶段(验证、准备、解析)、初始化阶段。
4、双亲委派机制的作用:确保核心代码库的安全并从而保护程序安全,不同类加载器可以为相同名称的类创建额外的命名空间。可以在JVM同时加载相同名称的类。
5、破坏双亲委派机制的作用:使高层接口可以使用底层实现的代码,如JNDI、JDBC等应用。
未完待续~