JVM基础知识

目录

1.前言

2.JVM运行流程

3.JVM运行时数据区

4.JVM类加载

5.有关垃圾回收


1.前言

本文只是针对面试中比较常见的有关JVM的问题做出补充(俗称八股文),同时帮助大家对JVM建立一个感性的认识。所以并不会对JVM有过多的深入解析,因为有关这块知识在实际运用中很少会被用到。但是大家如果对JVM感兴趣可以自行去阅读《深入理解Java虚拟机》这本书。

2.JVM运行流程

在说明JVM的运行流程之前,我们先了解一下什么是JVM

JVM 是 Java Virtual Machine 的简称,意为Java虚拟机

虚拟机是指通过软件模拟的具有完整硬件功能的、运行在一个完全隔离的环境中的完整计算机系统。

常见的虚拟机:JVM、VMwave、Virtual Box。

JVM 和其他两个虚拟机的区别:

1. VMwave与VirtualBox是通过软件模拟物理CPU的指令集,物理系统中会有很多的寄存器; 2. JVM则是通过软件模拟Java字节码的指令集,JVM中只是主要保留了PC寄存器,其他的寄存器都进 行了裁剪。

JVM 是一台被定制过的现实当中不存在的计算机。

JVM 是 Java 运行的基础,也是实现一次编译到处执行的关键,那么 JVM 是如何执行的呢? 

JVM 执行流程 

程序在执行之前先要把java代码转换成字节码(class文件),JVM 首先需要把字节码通过类加载器(ClassLoader)把文件加载到内存中运行时数据区(Runtime Data Area),而字节码 文件是 JVM 的一套指令集规范,并不能直接交个底层操作系统去执行,因此需要特定的命令解析器执行引擎(Execution Engine)将字节码翻译成底层系统指令再交由CPU去执行,而这个过程中需要调 用其他语言的接口本地库接口(Native Interface) 来实现整个程序的功能,这就是这4个主要组成部分的职责与功能。

 

总之,JVM主要通过以下四个部分来执行Java程序:

1. 类加载器(ClassLoader)

2. 运行时数据区(Runtime Data Area)

3. 执行引擎(Execution Engine)

4. 本地库接口(Native Interface)

3.JVM运行时数据区 

JVM 运行时数据区域也叫内存布局,但需要注意的是它和 Java 内存模型((Java Memory Model,简称 JMM)完全不同,属于完全不同的两个概念。它主要由以下五大部分构成:

 

 当然有时候我们会把虚拟机栈和本地方法栈都统称为栈,所以就变成了四个部分构成。

3.1 程序计数器 

程序计数器是内存中最小的区域,它保存了下一条要执行指令的地址

所谓的指令其实就是字节码,程序想要运行,JVM就得把字节码加载到内存中,此时程序就会一条一条把指令取出来放到cpu上执行,所以需要随时记住当前执行到哪一条了.而cpu又是并发式执行程序的,每个线程都需要记录自己的执行位置,所以每个线程都会有一个程序计数器。

3.2 Java虚拟机栈 (线程私有)

Java 虚拟机栈的作用:Java 虚拟机栈的生命周期和线程相同,Java 虚拟机栈描述的是 Java 方法执行的内存模型每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数 栈、动态链接、方法出口等信息。而在方法执行完毕后就会销毁创建的栈帧,也就是出栈。咱们常说的堆内存、栈内存中,栈内存指的就是虚拟机栈。

它主要包含了以下四个部分:

 

 1. 局部变量表: 存放了编译器可知的各种基本数据类型(8大基本数据类型)、对象引用。局部变量表 所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变 量空间是完全确定的,在执行期间不会改变局部变量表大小。简单来说就是存放方法参数和局部变量。

2. 操作栈:每个方法会生成一个先进后出的操作栈。

3. 动态链接:指向运行时常量池的方法引用。

4. 方法返回地址:PC 寄存器的地址。

我们可以简单把栈看作是存储局部变量和方法调用信息的一个地方。 

3.3 本地方法栈(线程私有)

什么是线程私有?

由于JVM的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现,因此在任何一个确定的时刻,一个处理器(多核处理器则指的是一个内核)都只会执行一条线程中的指令。因此为了切换线程后能恢复到正确的执行位置,每条线程都需要独立的程序计数器,各条线程之间计数器互不影响,独立存储。我们就把类似这类区域称之为"线程私有"的内存。

本地方法栈和虚拟机栈类似,只不过 Java 虚拟机栈是给 JVM 使用的,而本地方法栈是给本地方法使用的。 

3.4 堆

前面我们介绍的几个区域都是线程私有的,通俗的说就是每个线程都有一个。而堆是一个进程只有一个,也就是说是多个线程公用一个堆,它也是内存中最大的区域。

堆的作用:程序中创建(new)的所有对象都在保存在堆中。既然new出来的对象就在堆中,那对象的成员变量自然也是在堆中的。

一个误区:

 我们可能听过一个说法,叫做内置类型的变量在栈上,引用类型的变量在堆上。但是这个说法是错误的。

正确的理解应该是:局部变量在栈上,成员变量和new出来的对象在堆上

3.5 方法区 (线程共享)

方法区的作用:用来存储被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据的。

其实方法区内存放的就是类对象。.java文件会先被转化成.class的字节码文件。而字节码文件会被加载到内存中,也就被JVM构造成了类对象(这个过程我们称为类加载)。而这里的类对象就会放在方法区,它描述了这个类的具体内容。比如类的名字,有哪些成员和方法等等。

4.JVM类加载 

由于类加载涉及的知识点很多,以我们目前的知识储备不足以去深入理解,所以这里只是帮助我们简单了解这个过程,达到能够回答出面试的问题即可。

4.1 类加载的过程

首先我们看到一个类的生命周期

其中前 5 步是固定的顺序并且也是类加载的过程,其中中间的 3 步我们都属于连接,所以对于类加载来说总共分为以下几个步骤。 

1. 加载(Loading)

2. 连接 (Linking)(1)验证 (2 )准备 (3) 解析

3. 初始化(Initialization)

4.1.1 加载

加载的过程我们可以用一句话来概括:先找到对应的.class文件,然后打开并读取.class文件,同时初步生成一个类对象。

具体可以分为以下三步:

1)通过一个类的全限定名来获取定义此类的二进制字节流。

2)将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。

3)在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。

此时初步生成的类对象结构如下图,具体的内容在官方文档https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-4.html#jvms-4.1icon-default.png?t=M85Bhttps://docs.oracle.com/javase/specs/jvms/se8/html/jvms-4.html#jvms-4.1中有解析,这里我就不过多赘述。它其实就是大概描述了一个类对象的框架。

 

4.1.2 验证 

验证是连接阶段的第一步,这一阶段的目的是确保Class文件的字节 流中包含的信息符合《Java虚拟机 规范》的全部约束要求,保证这些信 息被当作代码运行后不会危害虚拟机自身的安全。

验证选项: 文件格式验证 字节码验证 符号引用验证等等

如果发现读到的数据格式不符合规范,就会类加载失败并抛出异常 

4.1.3 准备 

准备阶段是正式为类中定义的变量(即静态变量,被static修饰的变量)分配内存并设置类变量初始值 的阶段。

比如此时有这样一行代码:

public static int value = 123;

 它是初始化 value 的 int 值为 0,而非 123。

4.1.4 解析

解析阶段是 Java 虚拟机将常量池内的符号引用替换为直接引用的过程,也就是初始化常量的过程。

4.1.5 初始化

初始化阶段,Java 虚拟机真正开始执行类中编写的 Java 程序代码,将主导权移交给应用程序。初始化 阶段就是执行类构造器方法的过程

4.2 一道经典问题

阅读下面代码,请写出它的输出结果。

import java.util.*;

class A{
    public A(){
        System.out.println("A的构造方法");
    }

    {
        System.out.println("A的构造代码块");
    }

    static{
        System.out.println("A的静态代码块");
    }
}

class B extends A{
    public B(){
        System.out.println("B的构造方法");
    }

    {
        System.out.println("B的构造代码块");
    }

    static{
        System.out.println("B的静态代码块");
    }
}

public class Main extends B{
    public static void main(String[] args) {
        new Main();
        System.out.println("--------------------------------");
        new Main();
    }
}

要想写出这道题,我们首先要知道以下几个大的原则


1.类的加载阶段会进行静态代码块的执行,要想创建实例,势必先进行类的加载

2.静态代码块的执行只是类加载阶段执行一次

3.构造代码块和构造方法,每次实例化都会执行,构造代码块在构造方法前面

4.父类执行在前,子类执行在后

5.程序从main开始执行,而main这里是Main类的方法,因此要执行main,就要先加载Main类

关于这道题我们需要注意的是 ,这里Main被实例化了两次,而类加载只执行了一次,那么根据上述几个原则,我们应该很容易得出答案。

 当然,关于这道题还会有很多变式,但只要抓住以上几个原则就能够做出来。

4.3 双亲委派模型

4.3.1 双亲委派模型的过程

提到类加载机制,不得不提的一个概念就是“双亲委派模型”。而它描述的其实就是JVM中的类加载器如何根据类的全限定名(比如java.lang.String)找到.class的过程。

当然它其实并不难理解,也不是一个重要的机制,但是由于名字特别高大上,因此成为了面试常考题。

首先我们要先知道JVM提供的几个类加载器:

BootStrap:负责加载标准库中的类(String,ArrayList……)

ExtClassLoader:负责加载jdk拓展类(现在很少用)

AppClassLoader:负责加载当前项目目录中的类

而双亲委派模型就描述了上述类加载器是如何配合的。下面我们看两个例子。

1.考虑加载java.lang.String(标准库中的类)

a. 程序启动,先进入AppClassLoader类加载器

b.AppClassLoader就会检查它的父加载器是否已经加载过了,如果没有就调用父的类加载器ExtClassLoader

c.ExtClassLoader也会去检查它的父加载器是否已经加载过了,如果没有就调用父的类加载器BootStrap

d.BootStrap也会去检查它的父加载器是否已经加载过了,但它发现自己没有父亲,于是就扫描自己负责的目录

e.java.lang.String这个类可以在标准库中找到。直接由BootStrap负责后续类加载的过程,查找环节就结束了


2.考虑加载自己写的Test类

a. 程序启动,先进入AppClassLoader类加载器

b.AppClassLoader就会检查它的父加载器是否已经加载过了,如果没有就调用父的类加载器ExtClassLoader

c.ExtClassLoader也会去检查它的父加载器是否已经加载过了,如果没有就调用父的类加载器BootStrap

d.BootStrap也会去检查它的父加载器是否已经加载过了,但它发现自己没有父亲,于是就扫描自己负责的目录,但是发现扫描不到,于是回到子的类加载器继续扫描

e.ExtClassLoader也扫描自己负责的目录,也没有扫描到,回到子的类加载器继续扫描

f.AppClassLoader也扫描自己负责的目录,能够找到Test类,于是进行后续加载,查找目录环节结束

(如果AppClassLoader也找不到,就会抛出ClassNotFoundException异常)

也可以根据下图来理解这个过程:

 

 4.3.2 双亲委派模型的优点

1. 避免重复加载类:比如 A 类和 B 类都有一个父类 C 类,那么当 A 启动时就会将 C 类加载起来,那 么在 B 类进行加载时就不需要在重复加载 C 类了。

2. 安全性:使用双亲委派模型也可以保证了 Java 的核心 API 不被篡改,如果没有使用双亲委派模型,而是每个类加载器加载自己的话就会出现一些问题,比如我们编写一个称为 java.lang.Object 类的话,那么程序运行的时候,系统就会出现多个不同的 Object 类,而有些 Object 类又是用户自己提供的因此安全性就不能得到保证了。而使用了双亲委派模型后,BootStrap会最先去加载标准库的类,后续用户自定义的类就不会被加载,保证Java 的核心 API 不被篡改。

5.有关垃圾回收 (gc)

5.1 什么是垃圾回收

在我们程序运行的过程中,很多时候都会去申请内存,比如创建变量,new对象,加载类等等。而申请内存的时机一般是明确的,但是由于内存空间是有限的,假如申请内存后迟迟不释放就会造成内存泄漏的后果。而我们的垃圾回收机制就是为了解决这个问题而生的,它需要找到一个合适的时机无用的内存释放。那么我们该如何界定哪些内存是无用的呢?请看下文分解。

当然垃圾回收也是存在一定的劣势的:

1.消耗额外的开销(消耗资源更多)

2.可能影响程序的流畅运行(比如常见的STW问题)

所以在C/C++这种追求极致效率的语言中并没有引入垃圾回收机制。

5.2 死亡对象的判断算法

这里说的死亡对象其实就是我们上面说的无用内存,俗称“垃圾”。那么我们先从人的角度去界定一下啥是死亡对象。

 比如这张图,正在使用的内存很明显还是存活的,不能被回收。而那些不再使用但却尚未回收的内存就是所谓的死亡对象,也就是gc要回收的“垃圾”。大家可能还注意到有些内存只有部分被使用了,那这种属于死亡对象吗?答案是不属于,垃圾回收的基本单位是对象而不是字节,只有等到该对象彻底不再被使用才会被真正回收。

 下面我们介绍一下死亡对象的判断算法:

5.2.1 引用计数算法

引用计数描述的算法为:

给对象增加一个引用计数器,每当有一个地方引用它时,计数器就+1;当引用失效时,计数器就-1;任何时刻计数器为0的对象就是不能再被使用的,即对象已"死"

引用计数法实现简单,判定效率也比较高,在大部分情况下都是一个不错的算法。比如Python语言就采 用引用计数法进行内存管理。

但是,在主流的JVM中没有选用引用计数法来管理内存,最主要的原因就是引用计数法无法解决对象的循环引用问题

比如我们看到下面的代码:

import java.util.*;

class test{
    test t=null;
}


public class Main{
    public static void main(String[] args) {
       test t1=new test();
       test t2=new test();
       System.out.println("-----------");
       t1.t=t2;
       t2.t=t1;
       System.out.println("-----------");
       t1=null;
       t2=null;
       System.out.println("-----------");
    }
}

接下来我们以分割线作为断点给大家调试一下程序。下面是第一条分割线。

然后是第二条。可以看到此时已经实现了循环引用(后续我没有继续展开了)

在执行到第三条分割线的前一行代码时,结果如下。

可以看到此时t1=null,但此时我们通过t2.t还是可以看到循环引用还是正常的。 

接下来是第三条分割线。此时我们发现t1.t和t2.t都已经报空指针异常了,此时我们无法访问到t。可以说明此时的t已经被回收了,他并没有因为循环引用而避免被回收。因为即使此时还存在循环引用,因为t1和t2都为null,此时t是无法通过外界访问到的,所以是无效的,自然就被回收了。这也说明JVM死亡对象的判断算法并不是引用计数算法

 

 而如果我们只是采用引用计数算法来判断对象是否死亡,就可能会出现上面的那种情况——两个对象互相引用但是却无法通过外部来访问。这种现象就称为循环引用问题。

 5.2.2 可达性分析算法

在上面我们讲了,Java并不采用引用计数法来判断对象是否已"死",而采用"可达性分析"来判断对象是否存活。

这种算法类似于我们遍历二叉树的过程。我们可以把JVM中的各种对象想象成一棵二叉树中的各个节点。而可达性分析算法就是从根节点出发对二叉树进行遍历,将能够遍历到的节点都标记一下,而无法被遍历到的节点因为没有被标记就会被回收。

只不过在JVM中我们所谓的“根节点”实际上是一系列称为"GC Roots"的对象 。

在Java语言中,可作为GC Roots的对象包含下面几种:

1. 虚拟机栈(栈帧中的本地变量表)中引用的对象;

2. 方法区中类静态属性引用的对象;

3. 方法区中常量引用的对象;

4. 本地方法栈中 JNI(Native方法)引用的对象。

以下关于引用的内容我们了解即可。

在 JDK1.2 时,Java 对引用的概念做了扩充,将引用分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)四 种,这四种引用的强度依次递减。 

1. 强引用 : 强引用指的是在程序代码之中普遍存在的,类似于"Object obj = new Object()"这类 的引用,只要强引用还存在,垃圾回收器永远不会回收掉被引用的对象实例。

2. 软引用 : 软引用是用来描述一些还有用但是不是必须的对象。对于软引用关联着的对象,在 系统将要发生内存溢出之前,会把这些对象列入回收范围之中进行第二次回收。如果这次回收还是没有足够的内存,才会抛出内存溢出异常。在JDK1.2之后,提供了SoftReference类 来实现软引用。

3. 弱引用 : 弱引用也是用来描述非必需对象的。但是它的强度要弱于软引用。被弱引用关联的 对象只能生存到下一次垃圾回收发生之前。当垃圾回收器开始进行工作时,无论当前内容是 否够用,都会回收掉只被弱引用关联的对象。在JDK1.2之后提供了WeakReference类来实现 弱引用。

4. 虚引用 : 虚引用也被称为幽灵引用或者幻影引用,它是最弱的一种引用关系。一个对象是否 有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实 例。为一个对象设置虚引用的唯一目的就是能在这个对象被收集器回收时收到一个系统通 知。在JDK1.2之后,提供了PhantomReference类来实现虚引用。

5.3 垃圾回收算法 

既然我们已经将死亡对象给标记了出来,那么接下来就是进行垃圾回收的操作了。我们先来看到垃圾回收器使用的几种算法。

5.3.1 标记-清除算法

"标记-清除"算法是最基础的收集算法。算法分为"标记"和"清除"两个阶段 : 首先标记出所有需要回收的 对象,在标记完成后统一回收所有被标记的对象。后续的收集算法都是基于这种 思路并对其不足加以改进而已。

 "标记-清除"算法的不足主要有两个 :

1. 效率问题 : 标记和清除这两个过程的效率都不高

2. 空间问题 : 标记清除后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行中 需要分配较大对象时,无法找到足够连续内存而不得不提前触发另一次垃圾收集。

5.3.2 复制算法 

"复制"算法是为了解决"标记-清理"的效率问题。它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这块内存需要进行垃圾回收时,会将此区域还存活着的对象复制到另一块上面,然后 再把已经使用过的内存区域一次清理掉。这样做的好处是每次都是对整个半区进行内存回收,内存分配 时也就不需要考虑内存碎片等复杂情况,只需要移动堆顶指针,按顺序分配即可。而复制算法的缺点显而易见就是空间利用率低,每次都需要将内存划分为大小相等的两块,只有一块是用于实际存储的。

 5.3.3 标记-整理算法

复制收集算法在对象存活率较高时会进行比较多的复制操作,效率会变低。因此在老年代一般不能使用 复制算法。

针对老年代的特点,提出了一种称之为"标记-整理算法"。标记过程仍与"标记-清除"过程一致,但后续步骤不是直接对可回收对象进行清理,而是让所有存活对象都向一端移动,然后直接清理掉端边界以外的内存。

5.3.4 分代算法 

分代算法和上面讲的 3 种算法不同,分代算法是通过区域划分,实现不同区域和不同的垃圾回收策略, 从而实现更好的垃圾回收。通俗地说就是根据不同区域去实施不同的垃圾回收策略。那么在此之前我们先认识一下Java堆内存区域的划分

 以上内存区域占比的划分参考HotSpot,不同JVM可能有些许差别。

哪些对象会进入新生代?哪些对象会进入老年代?

新生代:一般创建的对象都会进入新生代

老年代:大对象和经历了 N 次(一般情况默认是 15 次)垃圾回收依然存活下来的对象会从新生代 移动到老年代

 以下是分代算法大致程序:

1.我们刚创建出来的对象都是放在伊甸区内的

2.如果伊甸区内的对象熬过一轮GC扫描,就会被拷贝到幸存区

3.在后续的几轮GC中,幸存区的对象就在两个幸存区之间来回拷贝(复制算法),每一轮都会淘汰掉一批幸存者

4.在持续若干轮后,幸存区的对象就会进入老年代。而老年代的对象普遍都是经过了多轮GC扫描依然存活的,而往往一个对象越老它继续存活的可能性就越大,因此老年代的GC扫描频率大大低于新生代。在老年代采用标记-整理的方式进行回收。

5.3.5 一道简单面试题 

请问Minor GC和Full GC的区别?

1. Minor GC又称为新生代GC : 指的是发生在新生代的垃圾收集。因为Java对象大多都具备朝 生夕灭的特性,因此Minor GC(采用复制算法)非常频繁,一般回收速度也比较快。

2. Full GC 又称为 老年代GC或者Major GC : 指发生在老年代的垃圾收集。出现了Major GC, 经常会伴随至少一次的Minor GC(并非绝对,在Parallel Scavenge收集器中就有直接进行 Full GC的策略选择过程)。Major GC的速度一般会比Minor GC慢10倍以上。

 5.4 垃圾收集器

这里建议大家自行去学习一下CMS收集器(老年代收集器,并发GC)G1收集器(唯一一款全区域的垃圾回收器)。笔者能力有限在这里不过多赘述。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值