JVM体系结构
JVM体系结构简介
JVM内存模型主要分为三大块:类加载器、运行时数据区、执行引擎。
类加载器:代码被编译器编译后生成的二进制字节流(.class)类加载器把class文件加载到内存中,并进行验证、准备、解析、初始化,能够形成被jvm直接使用的java类型。
运行时数据区:主要方法区、堆、栈、程序计数器、本地方法区五个部分
执行引擎 :类加载器将class文件读取后,放进运行时数据区,然后执行引擎执行或调用本地接口、本地库。
Java源代码编译成Java Class文件后通过类加载器ClassLoader加载到JVM中
- 类存放在方法区中
- 类创建的对象放在堆中
- 堆中的对象调用方法时会使用到 虚拟机栈、本地方法栈、程序计数器
- 方法执行时每行代码由 解释器 逐行执行
- 热点代码由 JIT编译器即时编译
- 垃圾回收机制 回收堆中资源
- 和操作系统打交道需要调用 本地方法接口
HotSpot
sun公司在jdk8之后的JVM技术实现是HotSpot,这里还有一层关系,JDK是java开发环境,JRE是java运行环境,JDK包含JRE,而JRE包含JVM。也就是说HotSpot是JVM的实现技术,是用C+汇编语言编写的,主要功能包括一个解释器和两个编译器,这也是为什么jdk8之后的JAVA是编译与解释混合执行模式的原因。两个编译器可以成为JIT编译器,即动态编译器,是两种模式,server模式和client模式。
方法区(元空间)
方法区的定位
《Java虚拟机规范》:尽管所有方法区在逻辑上属于堆一部分,但一些简单实现,可能不会进行垃圾收集或进行压缩。对于HotSpot,方法区又名:Non-Heap(非堆),目的:区分堆。方法区看作是一块独立于Java堆的内存空间
方法区的理解
- 方法区与java堆一样,是各个线程共享的区域
- 方法区在JVM启动的时候被创建,并且它的实际内存空间中和java堆区一样都是可以不连续的,但逻辑上认为是连续的
- 方法区的大小也跟堆一样可以选择固定大小或者课扩展
- 方法区大小决定了系统可以保存多少个类,如果类定义他多,导致方法区溢出,JVM同样抛出
- 内存溢出异常
jdk1.8之前OOM,java.lang.OutofMemoryError:PermGen space
jdk1.8 java.lang.OutOfMemoryError:Metaspace - 关闭JVM就可以释放这个区域的内存
方法区的存储
方法区的存储包括:类的方法代码,常量,静态变量,方法名,访问权限,返回值等等都是在方法区的
常量池
JVN 为每个已加载的类型都维护一个常量池。常量池也就是这个类型用到的常量的一个有序集合,包括实际的常量(string,integer, 和floating point常量)和对类型,域和方法的符号引用。池中的数据项一样,使用过索引访问的。因为常量池存储了一个类型所使用到的所有类型,域和方法的符号引用,所以他在java程序的动态链中起了核心作用。
方法信息
jvm必须保存所有方法的以下信息,同样域信息一样包括声明顺序
方法名
方法的返回类型(或 void)
方法参数的数量和类型(有序的)
方法的修饰符(public, private, protected, static, final, synchronized, native, abstract的一个子集)
类变量,也称静态变量(
Class Variables
就是类的静态变量,它只与类相关,所以称为类变量
)
元空间
永久代和元空间的存储
永久代和元空间的作用都是存储类的元数据,用来存储class相关信息,包括class对象的Method,Field等。
当然了,我这里仅仅是简单介绍,说的肯定不完整,具体存储了什么还是需要查阅资料。
永久代和元空间的区别
永久代和元空间的区别本质只有一个,那就是永久代使用的是jvm内存存储,而元空间使用的是本地内存存储。
元空间与永久代区别是其内存空间直接使用的是本地内存,而metaspace没有了字符串常量池,而在jdk7的时候已经被移动到了堆中,MetaSpace其他存储的东西,包括类文件,在JAVA虚拟机运行时的数据结构,以及class相关的内容,如Method,Field道理上都与永久代一样,只是划分上更趋于合理,比如说类及相关的元数据的生命周期与类加载器一致,每个加载器就是我们常说的classloader,都会分配一个单独的存储空间。
永久代和元空间和方法区的关系
我们都知道JVM一共分为五个部分:堆,虚拟机栈,本地方法栈,程序计数器,方法区
但是方法区仅仅是一种JVM的规范,规定哪些数据是存储在方法区的,元空间和永久代其实都是方法区的实现,只是实现有所不同,所以说方法区其实只是一种JVM的规范。
为什么要废除永久代
- 现实使用中易出问题
- 由于永久代内存经常不够用或者发生内存泄露,爆出异常 java.lang.OutOfMemoryError: PermGen 。
- 类及方法的信息等比较难确定其大小,因此对于永久代的大小指定比较困难,太小容易出现永久代溢出,太大则容易导致老年代溢出。
- 永久代会位GC带来不必要的复杂度,而且回收效率偏低。
堆
堆内存分为年轻代和老年代。年轻代又分为Eden和Survivor区。默认情况下,老年代占整个堆内存的2/3;年轻代占1/3。Eden占年轻代的80%;from区(S0区)占10%,to区(S1区)占10%。
new出来的对象存储在Eden,当Eden满的时候,就会做一次minorGC。清理无效对象,剩下的有效对象进入from区。当from区满的时候,又会触发一次minorGC,剩余有效对象进入to区。当to区满的时候,同样触发一次minorGC,剩余有效对象进入from区,from区和to区的GC是一个循环过程,每触发一次GC年龄值增加1,年龄达到15次的对象会进入老年区。当老年区满的时候,会触发一次fullGC。
这段代码最终会导致堆内存溢出。
public class HeapTest {
byte[] a = new byte[1024*100];
public static void main(String[] args) throws Exception{
ArrayList<HeapTest> heapList = new ArrayList<>();
int i = 0;
while (true){
heapList.add(new HeapTest());
System.out.println(i++);
Thread.sleep(10);
}
}
}
分析原因:每次new一个对象就会开辟一块100K的内存空间,heapList列表的元素指向这块堆内存。while (true)死循环里面不断new出新的对象,对象又被heapList列表引用而无法释放进入老年代,最终老年代满了进行fullGC。但是很不幸,所有对象都被引用,没有可以释放的对象,于是堆内存溢出了。
栈
简介: 存储的内容有 八大数据类型、对象引用、实例的方法;先进后出、后出先进 ; 像桶一样
栈内存,主管程序运行,生命周期和线程同步,线程结束,栈内存释放,不存在垃圾回收
栈满了:StackOverFlowError
程序计数器
作用
用于存储下一条指令的地址。
特点
- 线程私有
- 执行java方法时,程序计数器是有值的,执行native本地方法时,程序计数器的值为空
- 程序计数器,是唯一一个在java虚拟机规范中没有规定任何OutOfMemoryError的区域
- 程序计数器占用内存很小,在进行JVM内存计算时,可以忽略不计
问题解答
- 程序计数器为什么被设定为线程私有?
为了能够准确记录各个线程正在执行的当前字节码指令地址,最好的办法就是为每一个线程分配一个程序计数器 - 为什么在执行native本地方法时,程序计数器的值为空(Undefined)?
因为native方法是java通过JNI直接调用本地C/C++库,可以近似的认为native方法相当于C/C++暴露给java的一个接口,java通过调用这个接口从而调用到C/C++方法。由于该方法是通过C/C++而不是java进行实现。那么自然无法产生相应的字节码,并且C/C++执行时的内存分配是由自己语言决定的,而不是由JVM决定的
本地方法栈
本地方法栈(Native Method Stacks)与虚拟机栈所发挥的作用非常相似,其区别只是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地(Native)方法服务。本地方法栈也是线程私有的。
和虚拟机栈一样,本地方法栈也会在栈深度溢出或者拓展失败时分别跑出StackOverFlowError和OutOfMemoryError异常。
本地方法是使用C语言实现的。
当某个线程调用一个本地方法时,它就进入了一个全新的并且不再受虚拟机限制的世界,它和虚拟机拥有同样的权限,
不是所有的JVM都支持本地方法。因为Java虚拟机规范并没有明确要求本地方法栈使用的语言,具体实现方式,数据结构等。如果JVM不打算支持native方法,也可以无需实现本地方法栈。
在Hotspot JVM中,直接将本地方法栈和虚拟机栈合二为一。
双亲委派机制
视图解析
当一个Hello.class这样的文件要被加载时。不考虑我们自定义类加载器,首先会在AppClassLoader中检查是否加载过,如果有那就无需再加载了。如果没有,那么会拿到父加载器,然后调用父加载器的loadClass方法。父类中同理也会先检查自己是否已经加载过,如果没有再往上。注意这个类似递归的过程,直到到达Bootstrap classLoader之前,都是在检查是否加载过,并不会选择自己去加载。直到BootstrapClassLoader,已经没有父加载器了,这时候开始考虑自己是否能加载了,如果自己无法加载,会下沉到子加载器去加载,一直到最底层,如果没有任何加载器能加载,就会抛出ClassNotFoundException。
问题解答
- 什么是双亲委派
当某个特定的类加载器它在接到需要加载类的请求时,这个类会首先查看自己已加载完的类中是否包含这个类,如果有就返回,没有的话就会把加载的任务交给父类加载器加载,以此递归,父类加载器如果可以完成类加载任务,就返回它,当父类加载器无法完成这个加载任务时,才会不得已自己去加载。这种机制就叫做双亲委派机制。 - 为什么要使用双亲委派
java虚拟机只会在不同的类的类名相同且加载该类的加载器均相同的情况下才会判定这是一个类。如果没有双亲委派机制,同一个类可能就会被多个类加载器加载,如此类就可能会被识别为两个不同的类,相互赋值时问题就会出现。
双亲委派机制能够保证多加载器加载某个类时,最终都是由一个加载器加载,确保最终加载结果相同。
没有双亲委派模型,让所有类加载器自行加载的话,假如用户自己编写了一个称为java.lang.Object的类,并放在程序的ClassPath中,系统就会出现多个不同的Object类, Java类型体系中基础行为就无法保证,应用程序就会变得一片混乱 - 类加载器的父子关系
Bootstrap ClassLoader :
该类加载器没有父加载器,他负责加载虚拟机的核心类库,如java.lang.*等。根类加载器从系统属性sun.boot.class.path所指定的目录中加载类库。根类加载器的实现依赖于底层操作系统,属于虚拟机的实现的一部分,他并没有继承java.lang.ClassLoader类。
将存放于lib目录中的,或者被-Xbootclasspath参数所指定的路径中的,并且是虚拟机识别的(仅按照文件名识别,如 rt.jar 名字不符合的类库即使放在lib目录中也不会被加载)类库加载到虚拟机内存中。启动类加载器无法被Java程序直接引用
Extension ClassLoader :
它的父类加载器为根类加载器。他从java.ext.dirs系统属性所指定的目录中加载类库,或者从JDK的安装目录的jre\lib\ext子目录(扩展目录)下加载类库,如果把用户创建的JAR文件放在这个目录下,也会自动有扩展类加载器加载。扩展类加载器是纯java类,是java.lang.ClassLoader类的子类。
将libext目录下的,或者被java.ext.dirs系统变量所指定的路径中的所有类库加载。开发者可以直接使用扩展类加载器。
Application ClassLoader :
也称为应用加载器,他的父类加载器为扩展类加载器。他从环境变量classpath或者系统属性java.class.path所指定的目录中加载类。他是用户自定义的类加载器的默认父加载器。系统类加载器是纯java类,是java.lang.ClassLoader子类。
负责加载用户类路径(ClassPath)上所指定的类库,开发者可直接使用。
类的加载过程
三大步骤:装载(Load),链接(Link)和初始化(Initialize)
- 加载:查找并加载类的二进制数据
- 链接:
- 验证:确保被加载类的正确性;
- 准备:为类的静态变量分配内存,并将其初始化为默认值;
- 解析:把类中的符号引用转换为直接引用;
- 初始化:为类的静态变量赋予正确的初始值
假如有一个java类 , 里面有一个main方法;
public class Math{
public static int initData = 666;
public static User user = new User();
public int compute(){
int a = 1;
int b = 2;
int c = (a+b)*10;
return c;
}
public static void main(String[] args){
Math math = new Math();
math.compute();
}
}
我们先要将class类加载到内存中. 加载到内存区域以后, 不是简简单单的转换成二进制字节码文件,他会经过一系列的过程. 比如: 验证, 准备, 解析, 初始化等.
问题解答
为什么我要有验证这一步骤呢?
首先如果由编译器生成的class文件,它肯定是符合JVM字节码格式的,但是万一有高手自己写一个class文件,让JVM加载并运行,用于恶意用途,就不妙了,因此这个class文件要先过验证这一关,不符合的话不会让它继续执行的,也是为了安全考虑吧。
准备阶段和初始化阶段看似有点矛盾,其实是不矛盾的,如果类中有语句:private static int a = 10,它的执行过程是这样的,首先字节码文件被加载到内存后,先进行链接的验证这一步骤,验证通过后准备阶段,给a分配内存,因为变量a是static的,所以此时a等于int类型的默认初始值0,即a=0,然后到解析(后面在说),到初始化这一步骤时,才把a的真正的值10赋给a,此时a=10