JVM是用来执行字节码文件(.class)的虚拟机,运作与操作系统之上。由于可以将jvm看作安装在操作系统上的软件,因此Java编写的程序可以在不同的操作系统上运行,因为编译后的字节码与平台就无关了,只需要在不同的操作系统上安装一个对应版本的 jvm 虚拟机。
JVM大致分为如上图所示的三个部分:
- 类加载子系统(ClassLoader);
- 运行时数据区(包括方法去、堆、JVM栈、程序计数器 和 本地方法栈);
- 执行引擎(JIT编译器:对热点数据进行二次编译,将字节码指令变成机器指令,将机器指令存放在方法区缓存;解释器:逐行解释字节码)。
类加载
类加载是把类的 .class 文件中的二进制数据读取到内存,将类的信息放在运行时数据区的方法区中,通过给每个不同类创建一个 java.lang.Class 对象(Class对象封装了类在方法去内的数据结构,保存在堆区),调用其 newInstance() 方法在堆内存中创建对应的实例对象。
类的加载大致包括五个步骤:加载、验证、准备、解析、初始化(中间三项统称链接)。
加载:将.class文件读到内存,并为之创建一个 java.lang.Class对象。类的加载由类加载器完成,类加载器由 jvm 提供。 jvm提供一些系统类加载器,开发者可以用继承ClassLoader的方式自行设计类加载器。
链接:当类被加载之后,系统为之生成一个对应的Class对象,接着将会进入连接阶段,连接阶段负责把类的二进制数据合并到JRE中。类连接又可分为如下3个阶段。
(1)验证:检验类的加载是否有正确的内部结构,并于其他类协调一致。
(2)准备:为类的静态变量分配内存,并设置默认值。
(3)解析:将类的二进制数据中的符号引用替换成直接引用。(概念可参考)
初始化:为类的静态变量赋予正确的初始值。
============================================================================
双亲委派机制
加入我们自己写了一个 String 类,并放在 java.lang 包下面,这个自定义的 String 类包含 main 方法,那么这个程序能否执行(会不会调用到java里面自带的String类?)
答案是不能运行,还是会首先调用java自带的String类,这是什么原因?
因为JVM预定义了三种类加载器:分别是根类加载器(bootstrap class loader)、扩展类加载器(extension class loader)以及 系统类加载器(system class loader)。
根类加载器:用来加载 Java 的核心类,是用原生代码来实现的,并不继承自 java.lang.ClassLoader(负责加载$JAVA_HOME中jre/lib/rt.jar里所有的class,由C++实现,不是ClassLoader子类)。
扩展类加载器:负责加载JRE的扩展目录,lib/ext或者由java.ext.dirs系统属性指定的目录中的JAR包的类。
系统类加载器:被称为系统(应用)类加载器,它负责在JVM启动时加载来自Java命令的-classpath选项、java.class.path系统属性。如果没有特别指定,用户自定义的类加载器都以此类加载器作为父加载器。
可以发现,java自带的String类需要由根类加载器加载,而我们自己写的String类由系统类加载器加载。在加载的时候首先要看根类加载器能否加载,如果能加在则用根类加载器加载。其次,还要看父类加载器能否加载,如果父类都不能加载的情况下,再由自身加载器来加载。
双亲委派机制,其工作原理的是,如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行,如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的启动类加载器,如果父类加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派模式,即每个儿子都很懒,每次有活就丢给父亲去干,直到父亲说这件事我也干不了时,儿子自己才想办法去完成。
上面逻辑可能有些模糊,看一下下面的流程就一目了然了:
类加载器加载Class大致要经过如下8个步骤:
- 检测此Class是否载入过,即在缓冲区中是否有此Class,如果有直接进入第8步,否则进入第2步。
- 如果没有父类加载器,则要么Parent是根类加载器,要么本身就是根类加载器,则跳到第4步,如果父类加载器存在,则进入第3步。
- 请求使用父类加载器去载入目标类,如果载入成功则跳至第8步,否则接着执行第5步。
- 请求使用根类加载器去载入目标类,如果载入成功则跳至第8步,否则跳至第7步。
- 当前类加载器尝试寻找Class文件,如果找到则执行第6步,如果找不到则执行第7步。
- 从文件中载入Class,成功后跳至第8步。
- 抛出ClassNotFountException异常。
- 返回对应的java.lang.Class对象。
JVM内存结构
JVM内存结构主要包括五个部分:程序计数器、本地方法栈、虚拟机栈、方法区和堆。其中前三者是线程私有的。而方法区、堆属于线程共享的两个数据区,也是JVM优化的关键所在。
程序计数器:一块较小的内存空间,可以看作是当前线程所执行字节码的行号指示器,指向下一个将要执行的指令代码,由执行引擎来读取下一条指令。程序计数器是线程私有的,每一个线程都有自己独立的程序计数器。
虚拟机栈:每次执行一个方法,就会在虚拟栈中创建一个栈帧。栈帧用于存储局部变量表(包括八大基本类型,以及对象的引用)、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行结束,就对应着一个栈帧从虚拟机栈中入栈到出栈的过程。虚拟机栈也是线程私有的。
本地方法栈:由于Java最初的设计是基于c/c++的,因此有些底层的方法(如线程的start)需要调用c/c++的方法,此类方法称为native方法。与虚拟机栈类似,调用这类方法的时候就需要用到本地方法栈了。本地方法栈也是线程私有的。
堆:堆是JVM内存中最大的一块区域,new出来的对象实例和数组都需要在堆上分配内存。对空间是线程共享的。垃圾回收GC也是对堆内存的回收优化。Java堆分为年轻代(Young Generation)和老年代(Old Generation);年轻代又分为伊甸园(Eden)和幸存区(Survivor区);幸存区又分为From Survivor空间和 To Survivor空间。年轻代存储刚创建出来的对象,如果栈内存中没有引用指向该对象是,就会被垃圾回收。老年代存放的是15次都没被回收的对象实例。
方法区:与 Java 堆一样是被所有线程共享的区间,用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码。更具体的说,静态变量+常量+类信息(版本、方法、字段等)+运行时常量池存在方法区中。常量池是方法区的一部分。JDK1.8以后,方法去由元空间实现,因此并不在堆内存中。
关于JVM内存更详细的内容,请参考一文读懂JVM内存结构。
Reference:
https://blog.youkuaiyun.com/m0_38075425/article/details/81627349