一:JVM基本概念
1、什么是JVM
- VM的中文名称叫Java虚拟机,它是由软件技术模拟出计算机运行的一个虚拟的计算机。
- JVM也充当着一个翻译官的角色,我们编写出的Java程序,是不能够被操作系统所直接识别的,而JVM负责把我们的程序翻译给系统“听”,告诉它我们的程序需要做什么操作。
- Java的程序需要经过编译后,产生.Class文件,JVM才能识别并运行它。
2、JVM体系结构
-
**类加载器**:加载class文件的信息,把加载的信息存放到内存区域中的方法区。
-
**方法区**:存放类加载信息、常量信息、常量池信息、包括字符串字面量等。
-
**Java堆**:JVM启动时创建、存放所有的实例信息、所有线程共享。
-
**虚拟机栈**:在一个线程被创建时,会创建Java栈,保存着局部变量、方法调用、方法参数等。
-
**本地方法栈**:与虚拟机栈类似,不同点在于本地方法栈对本地方法进行调用。
-
**程序计数器**:JVM为每个线程创建的一个私有的空间,是一种栈操作指针,在任何时间,一个线程都会执行一个方法,这个方法称为当前方法,如果当前方法不是本地方法,则程序计数器指针会指向当前正在被执行的指令,如果是本地方法,则PC寄存器值为undefined。
-
**执行引擎**:是JVM最核心的组件,负责执行JVM的字节码文件。
-
**垃圾回收机制**:java的核心,java有一套自己垃圾回收机制。
![](https://img.laitimes.com/img/9ZDMuAjOiMmIsIjOiQnIsICM38FdsYkRGZkRG9lcvx2bjxiNx8VZ6l2cs0TPR1ENFpnT5FFROBDOsJGcohVYsR2MMBjVtJWd0ckW65UbM5WOHJWa5kHT20ESjBjUIF2X0hXZ0xCMx81dvRWYoNHLrdEZwZ1Rh5WNXp1bwNjW1ZUba9VZwlHdssmch1mclRXY39CXldWYtlWPzNXZj9mcw1ycz9WL49zZuBnLwgjM5AjMxMjM4ETOwkTMwIzLc52YucWbp5GZzNmLn9Gbi1yZtl2Lc9CX6MHc0RHaiojIsJye.png)
3、如何分清Java堆、栈和方法区?
Java堆:
- java堆是和应用程序关系最密切的内存空间,几乎所有对象都存放在其中,并且java堆完全是自动化管理的,通过垃圾回收机制,垃圾对象会自动清理,不需要显式的释放。
-
根据垃圾回收机制不同,java堆可能有不同的结构。最为常见的就是将整个java堆分为新生代和老年代。其中新生代存放新生的对象或者年龄不大的对象,老年代则存放老年对象。
新生代分为eden区、s0区、s1区,s0和s1也被称为from和to区域,它们是两块大小相等并且可以互换角色的空间。(复制算法)。大多数情况下,对象首先分配在eden区,在新生代回收后,如果对象还存活,则会进入s0或者s1区,之后每经过一次新生代回收,如果对象存活,则它的年龄就加1,当对象达到一定年龄后,则进入老年代。
Java栈
- java栈是一个线程私有的内存空间,一个栈一般由三个部分组成:局部变量表,操作数栈和帧数据区。
- 局部变量表:用于保存函数的参数及局部变量。
- 操作数栈:一个临时的存储空间,用来保存计算的中间量。
- 帧数据区:栈还需要一些数据来支持常量池的解析,这里帧数据区保存着访问常量池的指针,方便程序访问常量池。另外,当函数返回或者出现异常时,虚拟机必须有一个异常处理表,方便发送异常的时候找到异常的代码,因此异常处理表也是帧数据区的一部分。
Java方法区
java方法区和堆一样,方法区是一个所有线程共享的内存区域,它保存系统的类信息,比如类的字段、方法、常量池等。方法区的大小决定了系统可以保存多少类。如果系统定义太多的类,导致方法区移除。java虚拟机同样会抛出内存溢出错误。方法区可以理解为永久区
例如:
- 堆解决的是数据存储的问题,即数据怎么放,放在哪里。
- 栈解决的是程序的运行问题,即程序如何执行,或者说如何处理数据。
- 方法区是辅助堆栈的块永久区(Perm),解决堆栈信息的产生,是先决条件。
-
我们创建一个新的对象,User:那么User类的一些信息(类信息,静态信息都存在于方法区中)
而User类被实例化出来之后被存放在java堆中,一块内存空间。
当我们去使用的时候都是使用User对象的引用,形如User user=new User();
这里的user就是存放在java栈中的,即User真实对象的一个引用。
4、方法区中的Java字面量和符号引用的理解
- Java字面量可以理解为实际值,即
中的 1 和int a = 1
中的 hello ,这些都是字面量。String s = "hello"
- 符号引用,就是一个字符串,只要在代码中引入一个非字面量,不管是变量还是常量,它都只是一个由字符串定义的符号,这个符号在常量池里,当类加载第一次加载这个符号时,会把这个符号引用(字符串)解析成直接引用(指针)。
5、类加载过程
类加载过程:
当程序首次使用某个类时,由于该类还未被加载到内存中,JVM会通过加载,链接、初始化三个步骤对类进行初始化,则这三个步骤称为类加载过程或类初始化。
加载:
- 加载就是把类的.class文件读到内存中,并为之创建一个Java.lang.Class对象。
- 类加载由类加载器完成,类加载器由JVM提供。被称为系统类加载器。
- 使用不同的类加载器可以对不同来源的.class文件进行加载:
- 从本地文件系统加载.class文件
- 从JAR包加载class文件,这种方式也是很常见的。比如JDBC编程时用到的数据库驱动类就放在JAR文件中,JVM可以从JAR文件中直接加载该class文件。
- 通过网络加载class文件。
- 通过源文件动态编译并加载。
链接:
当类被加载之后,系统为之生成一个对应的Class对象,接着将会进入连接阶段,连接阶段负责把类的二进制数据合并到JRE中。类连接又可分为如下3个阶段。
验证:验证阶段用于检验被加载的类是否有正确的内部结构,并和其他类协调一致。验证阶段是Java非常重要的一个阶段,它会直接的保证应用是否会被恶意入侵的一道重要的防线,越是严谨的验证机制越安全。验证的目的在于确保Class文件的字节流中包含信息符合当前虚拟机要求,不会危害虚拟机自身安全。其主要包括四种验证,文件格式验证,元数据验证,字节码验证,符号引用验证。
准备:类准备阶段负责为类的静态变量分配内存,并设置默认初始值。
解析:负责将符合引用转为直接引用。
初始化:
初始化是为类的静态变量赋予正确的初始值。和准备阶段的区别在于:准备阶段是设置默认初始值。
例如:
private static int a = 10;
首先将字节码文件加载到内存中,通过链接中的验证后到达准备阶段,这时候把 a 的值初始化为0, 然后经过解析,最后到初始化阶段,才会把真正的10赋给a。
6、类加载机制
知道了类加载过程,那么对于.class文件,java虚拟机如何才能按照class字节码中描述的内容进行运用和使用呢?这就需要类加载机制对其进行规范和约束,那我们就谈谈什么是类加载机制,以及类加载机制的方式和生命周期。
类加载机制:虚拟机把.class文件加载到内存中,并对其进行验证、解析、初始化,最终形成可以被虚拟机识别的java类型的过程。
说到这,我们脑海中可能会产生几个问题:
1. 虚拟机什么时候加载.Class文件呢?(类加载时机)
2. 它又是怎么加载的呢?(类加载器、双亲委派机制)
3. 经历了怎样的步骤呢?(类加载过程与步骤)
下面我们所讲的就是围绕这三个问题来。
1)类加载时机
在上面的第五模块,已经讲了类加载过程,类加载过程有七个阶段,第一个阶段就是加载,那么什么时候虚拟机开始把.class文件加载到内存中去呢?这就需要了解一下类加载时机。
Java虚拟机规范中并没有进行强制约束,这点可以交给虚拟机的具体实现来自由把握。但是对于初始化阶段,虚拟机规范则是严格规定了有且只有5种情况必须立即对类进行“初始化”(而加载、验证、准备自然需要在此之前开始),具体5种情况如下图所示:
对于第一种情况,是最常见的一种情况,主要说明一下:
- 使用new关键字实例化对象的时候;
- 读取一个静态配置的时候;
- 设置一个静态配置的时候;(被final修饰,已在编译器把结果放入常量池的静态字段除外)
- 调用一个静态方法的时候
对于这5种会触发类进行初始化的场景,虚拟机规范中使用了一个很强烈的限定语:“有且只有”,这5种场景中的行为称为对一个类进行主动引用。除此之外,所有引用类的方式都不会触发初始化,称为被动引用。
既然说到主动引用了那就牵涉一下被动引用,被动引用有三大常见情景。
被动引用三大常见情景
1、通过子类调用父类的静态变量,不会导致子类初始化
public class test {
public static void main(String[] args) {
int x = Son.count;
}
}
class GrandPa{
static {
System.out.println("Initialize class Grandpa");
}
}
class Father extends GrandPa{
static int count = 1;
static {
System.out.println("Initialize class Father");
}
}
class Son extends Father{
static {
System.out.println("Initialize class Son");
}
}
这是运行结果,由结果可以看到,子类Son并没有被初始化。这是为什么呢?这是因为对于静态字段,只有直接定义这个字段的类才会被初始化,因此通过其子类来引用父类中定义的静态字段,只会触发父类的初始化而不会触发子类的初始化。
上面的例子中,由于count字段是在Father类中定义的,因此该类会被初始化,此外,在初始化类Father的时候,虚拟机发现其父类Grandpa还没被初始化,因此虚拟机将先初始化其父类Grandpa,然后初始化子类Father,而Son始终不会被初始化.
2.通过数组定义来引用类,不会触发此类的初始化
通过代码来观察:
public class test {
public static void main(String[] args) {
A [ ]<u>a</u> = new A[10];
}
}
class A{
static {
System.out.println("Initialize class A");
}
}
由这段代码可以看出,对定义的数组A引用一个类,运行结果中并没有输出语句,由此可见.通过数组定义来引用类,不会触发此类的初始化
3.常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化
public class test {
public static void main(String[] args) {
System.out.println(A.count);
}
}
class A{
static final int count = 1;
static {
System.out.println("Initialize class A");
}
}
运行后,控制台只打印出 1 ,并没有初始化 类 A,这是因为,在编译的时候,count常量1在编译的时候,存储到了test类中的常量池中,A.COUNT的引用实际都被转化为test类对自身常量池的引用了.
2)类加载器
类加载时机已经说完,那么就该说说类加载器了。先看下图:
- 类加载器负责加载所有的类,其为所有被载入内存中的类生成一个java.lang.Class实例对象。
- 一旦一个类被加载如JVM中,同一个类就不会被再次载入了。
- 在JVM中,一个类用其全限定类名和其类加载器作为其唯一标识。
JVM预定义有三种类加载器,当一个 JVM启动的时候,Java开始使用如下三种类加载器:
a)引导类加载器((bootstrap class loader):
- 它用来加载 Java 的核心类,是用原生代码来实现的,并不继承自 java.lang.ClassLoader。
- 由于引导类加载器涉及到虚拟机本地实现细节,开发者无法直接获取到启动类加载器的引用,所以不允许直接通过引用进行操作。
下面程序可以获得根类加载器所加载的核心类库,并会看到本机安装的Java环境变量指定的jdk中提供的核心jar包路径:
import java.net.URL;
public class test {
public static void main(String[] args) {
URL[ ] urls = sun.misc.Launcher.getBootstrapClassPath().getURLs();
for(URL url : urls){
System.out.println(url.toExternalForm());
}
}
}
b)扩展类加载器(extensions class loader):
- 它负责加载JRE的扩展目录,lib/ext或者由java.ext.dirs系统属性指定的目录中的JAR包的类。由Java语言实现,父类加载器为null
c)系统类加载器(system class loader):
- 它负责在JVM启动时加载来自Java命令的-classpath选项、java.class.path系统属性,或者CLASSPATH换将变量所指定的JAR包和类路径。
- 程序可以通过ClassLoader的静态方法getSystemClassLoader()来获取系统类加载器。如果没有特别指定,则用户自定义的类加载器都以此类加载器作为父加载器。由Java语言实现,父类加载器为ExtClassLoader。
3、类加载.class文件时的步骤:
类加载器加载Class大致要经过如下8个步骤:
- .检测此Class是否载入过,即在缓冲区中是否有此Class,如果有直接进入第8步,否则进入第2步。
- .如果没有父类加载器,则要么Parent是根类加载器,要么本身就是根类加载器,则跳到第4步,如果父类加载器存在,则进入第3步。
- 请求使用父类加载器去载入目标类,如果载入成功则跳至第8步,否则接着执行第5步。
- 请求使用根类加载器去载入目标类,如果载入成功则跳至第8步,否则跳至第7步。
- 当前类加载器尝试寻找Class文件,如果找到则执行第6步,如果找不到则执行第7步。
- 从文件中载入Class,成功后跳至第8步。
- 抛出ClassNotFountException异常。
- 返回对应的java.lang.Class对象。
7、类加载机制的方式
1.JVM的类加载机制主要有如下3种。
- 全盘负责:所谓全盘负责,就是当一个类加载器负责加载某个Class时,该Class所依赖和引用其他Class也将由该类加载器负责载入,除非显示使用另外一个类加载器来载入。
- 双亲委派:所谓的双亲委派,则是先让父类加载器试图加载该Class,只有在父类加载器无法加载该类时才尝试从自己的类路径中加载该类。通俗的讲,就是某个特定的类加载器在接到加载类的请求时,首先将加载任务委托给父加载器,依次递归,如果父加载器可以完成类加载任务,就成功返回;只有父加载器无法完成此加载任务时,才自己去加载。
- 缓存机制:缓存机制将会保证所有加载过的Class都会被缓存,当程序中需要使用某个Class时,类加载器先从缓存区中搜寻该Class,只有当缓存区中不存在该Class对象时,系统才会读取该类对应的二进制数据,并将其转换成Class对象,存入缓冲区中。这就是为什么修改了Class后,必须重新启动JVM,程序所做的修改才会生效的原因。
这里着重说一下双亲委派机制:
双亲委派机制工作原理:
1-类加载器收到类加载的请求;
2-把这个请求委托给父加载器去完成,一直向上委托,直到启动类加载器;
3-启动器加载器检查能不能加载(使用findClass()方法),能就加载(结束);否则,抛出异常,通知子加载器进行加载。
4-重复步骤三;
双亲委派机制的优势:
- Java类随着它的类加载器一起具备了一种带有优先级的层次关系,通过这种层级关可以避免类的重复加载。
- java核心api中定义类型不会被随意替换,假设通过网络传递一个名为java.lang.Integer的类,通过双亲委托模式传递到启动类加载器,而启动类加载器在核心Java API发现这个名字的类,发现该类已被加载,并不会重新加载网络传递的过来的java.lang.Integer,而直接返回已加载过的Integer.class,这样便可以防止核心API库被随意篡改。