今天学习了jvm三大组成部分(jvm类加载器,jvm内存结构,jvm执行引擎)的内存结构,现在把学习笔记总结记录一下,当作复习吧。
JVM(虚拟机):指以软件的方式模拟具有完整硬件系统功能、运行在一个完全隔离环境中的完整计算机系统 ,是物理机的软件实现。
jvm和VMware,Virtual Box等虚拟机一样,都是运行在操作系统之上的计算机系统。
首先我们来看看jvm的整体架构的划分:
大家在留意内存结构的时候是不是发现,本地方法栈,程序计算器和java栈颜色一样的,而方法区和堆却不一样呢,其实是这样子,在一个java进程中可能有很多正在运行的java线程,那么在每一个java线程中都会独立开辟本地方法栈,程序计算器,和Java栈的,而方法区和堆并不是独立开辟的,他们之间是可以共享的。
本地方法栈**(线程私有****)**:登记native方法,在Execution Engine执行时加载本地方法库
程序计数器**(线程私有****)**:就是一个指针,指向方法区中的方法字节码(用来存储指向下一条指令的地址,也即将要执行的指令代码),由执行引擎读取下一条指令,是一个非常小的内存空间,几乎可以忽略不记。
方法区**(线程共享)**:类的所有字段和方法字节码,以及一些特殊方法如构造函数,接口代码也在此定义。简单说,所有定义的方法的信息都保存在该区域,静态变量+常量+类信息(构造方法/接口定义)+运行时常量池都存在方法区中,虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做 Non-Heap(非堆),目的应该是与 Java 堆区分开来。
Java栈(线程私有**)**: Java线程执行方法的内存模型,一个线程对应一个栈,每个方法在执行的同时都会创建一个栈帧(用于存储局部变量表,操作数栈,动态链接,方法出口等信息)不存在垃圾回收问题,只要线程一结束该栈就释放,生命周期和线程一致
JVM对该区域规范了两种异常:
线程请求的栈深度大于虚拟机栈所允许的深度,将抛出StackOverFlowError异常
若虚拟机栈可动态扩展,当无法申请到足够内存空间时将抛出OutOfMemoryError,通过jvm参数–Xss指定栈空间,空间大小决定函数调用的深度
堆**(线程共享):虚拟机启动时创建,用于存放对象实例,几乎所有的对象(包含常量池)都在堆上分配内存,当对象无法再该空间申请到内存时将抛出OutOfMemoryError异常。同时也是垃圾收集器管理的主要区域**。可通过 -Xmx –Xms 参数来分别指定最大堆和最小堆
接下我们通过具体的代码结合画图解析内存结构中每一个模块
我们结合上面的一个很简单的代码来看看内存结构是怎么工作的
首先我们通过doc命令编译该java代码会产生一个class文件,不过我们一般都不会去研究字节码文件的(也有专门的文档去解读的),这没有意义,但我们又想了解他们工作原理呢,这时候我们可以借助java本身的指令反编译class文件
这样子我们就可以结合jvm指令集(网上可以下载),下面就是反编译class文件产生的文件
那么加入有个java线程1运行math.class这个文件
那么,该线程就会开辟自己独自的java栈,本地方法区和程序计算器如图:
而且在执行main方法的时候,main方法就会进入java栈,当执行到math.math()的时候,math()方法也会入栈,在每一个方法入栈的时候java栈都是独立开辟一个新的内存给每一个方法,这个新开辟的内存块成为栈帧,而且栈帧中也会存在很多模块,主要四个是,局部变量表,操作数栈,动态链接,方法出口。
那么我们可以结合jvm的指令详细看看其中执行的原理(我们主要看math方法):
下面的我已经插好的jvm指令的解释:(0,1,2…这是地址的简称而已,实际上是32位或者64位样子存在的)
那么当执行0这句代码的时候是表示1进入操作数栈
当执行0这句代码的时候是表示1 出栈而且进去局部变量表
那么执行2和3是一样的道理,执行完就是如下;:
那么执行4和5呢,从局部变量1和2中装载int类型值进去操作数栈中,如下:
那么执行 6: iadd //iadd 执行int类型的加法,右如下:会栈中的两个数会出栈进行加法运输算然后就是把得到的新数据重新入栈
执行7: bipush 12 //bipush 将一个8位带符号整数压入栈 常量12入栈
执行 9: imul //imul 执行int类型的乘法(不知道大家有没有发现上面执行7的地址现在就跳到了9,那么8哪去了,其实压入栈的12就是占了一个地址码),其实四则运算都是一样的,都是把其中参加运算的数据出栈然后计算得到新的数据,把新的数据入栈
执行:istore_3 //istore_3 将int类型值存入局部变量3
11: iload_3 //iload_3 从局部变量3中装载int类型值
12: ireturn //ireturn 从方法中返回int类型的数据,还会返回当时进去math()的时候地址,这样子就可以回复主方法中原来执行的代码
到此,math()就全部执行成功了。
是不是很疑惑程序计算器和动态链接是怎么用的呢,其实呢,程序计算器就比如一个指针,开始的时候它是0,然后随着上面指令的执行,它就会指向相对应执行的指令。如图:执行12: ireturn //ireturn 从方法中返回int类型的数据的时候
那么什么动态链接呢,其实很好理解,就是多态的应用,如我们定义一个集合 Map<> map = new HashMap<>();这Map是一个接口,我们用map.put()的时候,实际是调用了Map的实现类,HashMap<>,所以我们去找动态去找HashMap,这就是动态连接。
那么本地方法栈又是怎么解析呢
举个例子,例如我们new Thread.start(); 并且调用start方法,但其实不是马上执行,它还要让执行一个start0的方法让cpu准备好了才会执行看图:
那么我们再看看start0的方法:
这 start0看起是不是很像一个接口啦,没有实现,其实它就是一个接口,是本地方法接口,它的底层实现是通过c语言实现的,java就是通过本地接口的方法区调用c语言写的系统(不过现在本地方法使用很少了,很少java和C语言混合使用)
让刚刚那个new Thread.start();中start()方法就会就去本地方法栈的,当前本地方法栈也是有栈帧的,然后执行引擎就会调用本地方法接口,本质调用本地C语言实验的类库。
那么接下来看看堆,像new出来的对象都是放在堆里面的,当然里面还有其他的,就在这次说了
栈+堆+方法区的交互关系
HotSpot是使用指针的方式来访问对象,Java堆中会存放访问类元数据的地址,reference存储的就直接是对象的地址
堆**(线程共享):虚拟机启动时创建,用于存放对象实例,几乎所有的对象(包含常量池)都在堆上分配内存,当对象无法再该空间申请到内存时将抛出OutOfMemoryError异常。同时也是垃圾收集器管理的主要区域**。可通过 -Xmx –Xms 参数来分别指定最大堆和最小堆
新生区
类诞生、成长、消亡的区域,一个类在这里产生,应用,最后被垃圾回收器收集,结束生命。
新生区分为两部分: 伊甸区(Eden space)和幸存者区(Survivor pace) ,所有的类都是在伊甸区被new出来的。幸存区有两个: 0区(Survivor 0 space)和1区(Survivor 1 space)。当伊甸园的空间用完时,程序又需要创建对象,JVM的垃圾回收器将对伊甸园区进行垃圾回收**(Minor GC)**,将伊甸园区中的不再被其他对象所引用的对象进行销毁。然后将伊甸园中的剩余对象移动到幸存 0区。若幸存 0区也满了,再对该区进行垃圾回收,然后移动到1区。那如果1区也满了呢?
老年区
新生区经过多次GC仍然存活的对象移动到老年区。若老年区也满了,那么这个时候将产生MajorGC****(FullGC),进行老年区的内存清理。若老年区执行了Full GC之后发现依然无法进行对象的保存,就会产生OOM异常“OutOfMemoryError”
元数据区:元数据区取代了永久代(jdk1.8以前),本质和永久代类似,都是对JVM规范中方法区的实现,区别在于元数据区并不在虚拟机中,而是使用本地物理内存,永久代在虚拟机中,永久代逻辑结构上属于堆,但是物理上不属于堆,堆大小=新生代+老年代。元数据区也有可能发生OutOfMemory异常。
Jdk1.6及之前: 有永久代, 常量池在方法区
Jdk1.7: 有永久代,但已经逐步“去永久代”,常量池在堆
Jdk1.8及之后: 无永久代,常量池在元空间
元数据区的动态扩展,默认–XX:MetaspaceSize值为21MB的高水位线。一旦触及则Full GC将被触发并卸载没有用的类(类对应的类加载器不再存活),然后高水位线将会重置。新的高水位线的值取决于GC后释放的元空间。如果释放的空间少,这个高水位线则上升。如果释放空间过多,则高水位线下降。
为什么jdk1.8用元数据区取代了永久代?
官方解释:移除永久代是为融合HotSpot JVM与 JRockit VM而做出的努力,因为JRockit没有永久代,不需要配置永久代
执行引擎:读取运行时数据区的Java字节码并逐个执行
JIT:在Java编程语言和环境中,即时编译器(JIT compiler,just-in-time compiler)是一个把Java的字节码(包括需要被解释的指令的程序)转换成可以直接发送给处理器的指令的程序。
字节码解释器:需要使用哪句代码才编译。
内存结构图:全图
Java运行时编译源码(.java)成字节码,由jre运行。jre由java虚拟机(jvm)实现。Jvm分析字节码,后解释并执行
那么我们看看类加载过程是怎么样的(类加载的过程,如下图)
类加载:类加载器将class文件加载到虚拟机的内存
加载:类加载过程的一个阶段:通过一个类的完全限定查找此类字节码文件,并利用字节码文件创建一个Class对象
验证:目的在于确保Class文件的字节流中包含信息符合当前虚拟机要求,不会危害虚拟机自身安全。主要包括四种验证,文件格式验证,元数据验证,字节码验证,符号引用验证。
准备:为类变量(即static修饰的字段变量)分配内存并且设置该类变量的初始值即0(如static int i=5;这里只将i初始化为0,至于5的值将在初始化时赋值),这里不包含用final修饰的static,因为final在编译的时候就会分配了,注意这里不会为实例变量分配初始化,类变量会分配在方法区中,而实例变量是会随着对象一起分配到Java堆中。
解析:主要将常量池中的符号引用替换为直接引用的过程。符号引用就是一组符号来描述目标,可以是任何字面量,而直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。有类或接口的解析,字段解析,类方法解析,接口方法解析(这里涉及到字节码变量的引用,如需更详细了解
初始化:类加载最后阶段,若该类具有超类,则对其进行初始化,执行静态初始化器和静态初始化成员变量(如前面只初始化了默认值的static变量将会在这个阶段赋值,成员变量也将被初始化)。
启动类加载器(引导类加载器):负责加载JRE的核心类库,如jre目标下的rt.jar,charsets.jar等
扩展类加载器:负责加载JRE扩展目录ext中JAR类包
系统类加载器:负责加载ClassPath路径下的类包
用户自定义加载器:负责加载用户自定义路径下的类包
我们结合代码看看一看具体类加载器的种类。看如下代码他们的打印的类加载器是什么。
我们可以看到第一个加载的名字居然返回一个null,为什么呢,是因为启动类加载器是用C语言写的,Java代码没法获得器名称
sun.misc.Launcher$ExtClassLoader:扩展类加载器:负责加载JRE扩展目录ext中JAR类包
sun.misc.Launcher$AppClassLoader:系统类加载器:负责加载ClassPath路径下的类包(一般都是用写的类)
全盘负责委托机制:当一个ClassLoader加载一个类时,除非显示的使用另一个ClassLoader,否则该类所依赖和引用的类也由这个ClassLoader载入。举个例子,例如我们定义了一个类A,是通过classload1来加载的,在A里面引用了我们定义的B和C两个,如果没有强制指定其他的类型加载器,那么B和C也是又classload1负责加载的
双亲委派机制:指先委托父类加载器寻找目标类,在找不到的情况下在自己的路径中查找并载入目标类。
我们看一下双亲委派机制的结构图。如下
解析一下什么交自定义加载器,这个不是jdk提供的,例如中间间有时候就会用得上自定义加载器,如tomcat就可以定义加载器
那么我们为什么要使用双亲委派机制呢?
其实,这样子是为了避免java的核心类库被恶心修改,这样子双亲委派机制就有有两大优势
双亲委派模式优势
沙箱安全机制:自己写的String.class类不会被加载,这样便可以防止核心API库被随意篡改
**避免类的重复加载:**当父亲已经加载了该类时,就没有必要子ClassLoader再 加载一次
这个我们可以用Java代码演示一下:
如我们自定义一个String类,而且包名也是java.lang下的,我们看看结果会怎样
那么它的运行结果会怎样呢?
它居然报错了,说没有主方法,其实这正说明了,启动类加载器把jdk中定义的String加载进内存了,而不是我们自定义的 String类,这避免了我们自定义jdk的类库恶心修改。那么我们也想用同样的类名怎么办呢,修改包名就可以了
类加载过程
JVM加载jar包是否会将包里的所有类全部加载进内存?
JVM对class文件是按需加载(运行期间动态加载),非一次性加载,见示例(启动需要加上参数:-verbose:class)
我们添加jvm的参数运行看看结果怎么样(添加参数的方式。这是IDEA的,不过eclipse也是大同小异)
输出:
从这里我们可以发现 VM对class文件是按需加载(运行期间动态加载),非一次性加载
到这里,jvm大体的架构就完毕了。