Java程序运行剖析(JVM+JDK+JRE)(总结+超详解)

前言:

学会使用Java对于一个程序员是远远不够的。Java语法的掌握只是一部分,另一部分就是需要掌握Java内部的工作原理,从编译到运行,到底是谁在帮我们完成工作的?

接下来着重对Java虚拟机,也就是JVM有一个深刻认识,对日后完成项目的开发或是更底层的开发有很大的帮助。

接下来说的均为个人的一点小见解和观点,希望大家多多指点!

JVM:

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

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

常⻅的虚拟机:JVM、VMwave、Virtual Box。
JVM 和其他两个虚拟机的区别:

1 . VMwave与VirtualBox是通过软件模拟物理CPU的指令集,物理系统中会有很多的寄存器;
2.JVM则是通过软件模拟Java字节码的指令集,JVM中只是主要保留了PC寄存器,其他的寄存器都进⾏了裁剪。
JVM 是⼀台被定制过的现实当中不存在的计算机。

JVM与JDK的关系?

Java程序执行过程:

从写出Java代码到最后执行Java程序整个过程是这样的:

1、使用编辑器或IDEA(集成开发环境)编写Java源文件.即生成的是.java文件.

2、程序必须编译为字节码文件,javac(Java编译器)编译源文件(.java)为字节码.class文件.

3、字节码文件(.class)可在任何平台/操作系统上由**JVM(Java虚拟机)**执行.

4、JVM将字节码文件(.class)翻译为机器可以执行的机器码(0,1二进制).

在了解清楚Java程序的执行过程以后,接下来我们再来讨论讨论JDK与JVM的关系:

JDK与JVM的关系:

知道JVM的基础概念,可能现在就有点懵了,JVM和JDK有什么关系吗?那么开始梳理一下与JAVA开发相关的组成部分;

JDK:

官方JDK(Java Development Kit) 是Java开发工具包,包含了Java编译器(javac)、Java程序打包工具(jar)、Java程序运行环境(JRE)、文档生成工具(javadoc)以及其他开发工具,如调试的工具(jdb)。JDK是为Java开发人员提供的完整开发环境,包含了开发和运行Java程序所需的一切。
非官方:JDK就是一个工具包,JDK是JRE的超集,JDK包含了JRE的所有开发,调试和监视应用程序等工具。当要开发Java应用程序时,需要安装JDK.

(JDK里面的工具非常多,是这么多工具才能支撑起我们编写Java程序)

当然有一个重要的组成部分------JRE(Java程序运行环境).

接下来就针对JRE做一个详细说明:

JRE:

官方JRE(Java Runtime Environment) 是Java运行环境,包含了JVM和Java类库 。JRE是运行Java程序所需的环境,它提供了Java程序运行所需的库文件和JVM。因此,如果你只需要运行Java程序,那么只需要安装JRE即可。

也就是JRE是JDK工具包中一部分,是Java运行的环境,这里的环境是由JVM和java类中的库文件组成

JVM:

官方JVM(Java Virtual Machine) 是Java虚拟机,是Java运行环境的核心部分。JVM负责将编译后的Java字节码(.class文件)解释或编译成机器代码(二进制代码),并在具体的平台上执行。JVM是Java实现跨平台特性的关键,它使得Java程序可以在任何安装了JVM的机器上运行。

也就是说,这三者的关系如下图所示:

再次理解Java程序执行过程:

通过上述的的理解,我们清楚java代码从执行到生成可执行的程序都是有JDK工具包的支持,JDK工具包中的各个部分各司其职:

1、在编译器或者IDEA(集成开发环境)生成.java文件。

2、通过JDK工具包中的javac(java编译器)的作用,将.java文件编译成.class文件。

3、通过JDK工具包中的JRE(Java运行环境)的作用(主要是JVM),将.class文件翻译为机器可以运行的机器语言(0,1二进制语言)。

我相信通过上述的简单讲解,我们都能理解Java运行的整个周期。包括什么是JVM虚拟机,JVM虚拟机的作用。

接下来就来详细探索一下JVM虚拟机是如何作用的,经历了哪些步骤,可以将.class文件翻译为机器语言。

详解JVM的运行过程:

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

综上,JVM虚拟机的运行主要依靠的是这四个部分:

  1. 类加载器(ClassLoader)
  2. 运⾏时数据区(Runtime Data Area)
  3. 执⾏引擎(Execution Engine)
  4. 本地库接⼝(Native Interface)

运行时数据区(Runtime Data Area):

JVM 运⾏时数据区域(Runtime Data Area)也叫内存布局,但需要注意的是它和 Java 内存模型((Java Memory Model,简称 JMM)完全不同,属于完全不同的两个概念,它由以下 5 ⼤部分组成:

接下来对以上的5大区来讨论讨论:

堆区(HEAP):

堆区中的数据是线程共享的。(同一个进程中的所有线程共用一个堆区)

堆的作用:程序中创建的所有对象都在保存在堆中。

堆里面分为两个区域新生代和老生代 ,新⽣代放新建的对象,当经过⼀定 GC(垃圾回收机制) 次数之后还存活的对象会放入老生代。新⽣代还有 3 个区域:⼀个 Endn + 两个 Survivor(S0/S1)。

垃圾回收(GC)的时候会将 Endn 中存活的对象放到⼀个未使⽤的 Survivor 中,并把当前的 Endn 和正在使⽤的 Survivor 清楚掉。

Java虚拟机栈(JVM Stacks):

虚拟机栈中的数据是线程私有的。(同一个进程中的所有线程各自有一个虚拟机栈)
Java 虚拟机栈的作用:Java 虚拟机栈的⽣命周期和线程相同,Java 虚拟机栈描述的是 Java ⽅法执⾏的内存模型:每个⽅法在执⾏的同时都会创建⼀个栈帧(Stack Frame)⽤于存储 局部变量表、操作数栈、动态链接、方法出口等信息 。咱们常说的堆内存、栈内存中,栈内存指的就是虚拟机栈。
Java虚拟机栈包括以下4部分:

1.局部变量表 : 存放了编译器可知的各种基本数据类型(8⼤基本数据类型)、对象引⽤。局部变量表所需的内存空间在编译期间完成分配,当进⼊⼀个⽅法时,这个⽅法需要在帧中分配多⼤的局部变量空间是完全确定的,在执⾏期间不会改变局部变量表⼤⼩。简单来说就是 存放⽅法参数和局部变量。
2.操作栈 :每个⽅法会⽣成⼀个先进后出的操作栈。
3.动态链接: 指向运⾏时常量池的⽅法引⽤。
4.方法返回地址 :PC 寄存器的地址

本地方法栈(Native Method Stacks) :

本地方法栈中的数据是线程私有的。(同一个进程中的所有线程各自有一个本地方法栈)

本地⽅法栈和虚拟机栈类似,只不过 Java 虚拟机栈是给 JVM 使⽤的,⽽本地⽅法栈是给本地⽅法使⽤的。(一般限定名称为native即为本地方法,一般底层是由C/C++写的)

程序计数器(Program Counter Register):

程序计数器中的数据是线程私有的。(同一个进程中的所有线程各自有一个程序计数器栈)
程序计数器的作⽤:⽤来记录当前线程执⾏的⾏号的。
程序计数器是⼀块⽐较⼩的内存空间,可以看做是当前线程所执⾏的字节码的⾏号指⽰器。
如果当前线程正在执⾏的是⼀个Java⽅法,这个计数器记录的是正在执⾏的虚拟机字节码指令的地
址;如果正在执⾏的是⼀个Native⽅法,这个计数器值为空。

程序计数器内存区域是唯⼀⼀个在JVM规范中没有规定任何OOM情况的区域!

元数据区(方法区)(Metaspace):

方法区中的数据是线程共享的。(同一个进程中的所有线程共用一个方法区)

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

在《Java虚拟机规范中》把此区域称之为"⽅法区",⽽在 HotSpot 虚拟机的实现中,在 JDK 7 时此区域叫做永久代(PermGen),JDK 8 中叫做元空间(Metaspace)。

JDK8中的元空间的改动;

1.对于 HotSpot 来说,JDK 8 元空间的内存属于本地内存,这样元空间的⼤⼩就不在受 JVM 最⼤内存的参数影响了,⽽是与本地内存的⼤⼩有关。

2.JDK 8 中将字符串常量池移动到了堆中.
运行时常量池

法区的⼀部分 ,存放字⾯量与符号引⽤。
字⾯量 : 字符串(JDK 8 移动到堆中) 、final常量、基本数据类型的值。
符号引⽤ : 类和结构的完全限定名、字段的名称和描述符、⽅法的名称和描述符。

内存布局中常见问题:

Java堆溢出:

Java堆⽤于存储对象实例,只要不断的创建对象,并且保证GC Roots到对象之间有可达路径来避免来 GC清除这些对象,那么在对象数量达到最⼤堆容量后就会产⽣内存溢出异常。

只要我们创建足够多的对象,堆肯定是会溢出的:

java 复制代码
public class Demo1 {
    //创建一个类
    static class JVMHeap {

    }

    public static void main(String[] args) {
        List<JVMHeap> list = new ArrayList<>();
        while (true) {
            //死循环创建对象
            list.add(new JVMHeap());
        }
    }
}

结果如下:
Java堆内存的OOM异常是实际应⽤中最常⻅的内存溢出情况。当出现Java堆内存溢出时,异常堆栈信息"java.lang.OutOfMemoryError"会进⼀步提⽰"Java heap space"。当出现"Java heap space"则很明确的告知我们,OOM发⽣在堆上。
此时要对Dump出来的⽂件进⾏分析,以MAT为例。分析问题的产⽣到底是出现了内存泄漏(MemoryLeak)还是内存溢出(Memory Overflow)

内存泄漏 : 泄漏对象⽆法被GC
内存溢出 : 内存对象确实还应该存活。此时要根据JVM堆参数与物理内存相⽐较检查是否还应该把JVM堆内存调⼤;或者检查对象的⽣命周期是否过⻓。
以上是我们处理Java堆内存的简单⽅法,处理具体这类问题需要的⼯具以及知识我们放到下⾯第四⼩节具体来说。

虚拟机栈和本地方法栈溢出:

关于虚拟机栈会产⽣的两种异常:

如果线程请求的栈深度⼤于虚拟机所允许的最⼤深度,会抛出StackOverFlow异常
如果虚拟机在拓展栈时⽆法申请到⾜够的内存空间,则会抛出OOM异常

常见虚拟机栈溢出现象为------递归:

单线程虚拟机栈溢出示范:

java 复制代码
    private int stackLength = 1;
    public void stackLeak() {
        stackLength++;
        stackLeak();
    }
    public static void main(String[] args) {
        Demo1 test = new Demo1();
        try {
            test.stackLeak();
        } catch (Throwable e) {
            System.out.println("Stack Length: " + test.stackLength);
            throw e;
        }
    }

多线程虚拟机栈溢出:

java 复制代码
 private void dontStop() {
        while(true) {

        }
    }
    public void stackLeakByThread() {
        while(true) {
            //创建多个线程
            Thread thread = new Thread(new Runnable() {
                @Override
                public void run() {
                    dontStop();
                }
            });
            thread.start();
        }
    }

    public static void main(String[] args) {
        Demo1 test = new Demo1();
        test.stackLeakByThread();
    }

JVM类加载:

类加载过程:

对于⼀个类来说,它的⽣命周期是这样的:


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

  1. 加载
  2. 连接
    a. 验证
    b. 准备
    c. 解析
  3. 初始化
加载:

在加载(loading)阶段,Java虚拟机需要完成以下三件事情:

1.通过⼀个类的 全限定名 来获取定义此类的 ⼆进制字节流
2.将这个字节流所代表的静态存储结构转化为⽅法区的运⾏时数据结构。
3.在内存中⽣成⼀个代表这个类的java.lang.Class对象,作为⽅法区这个类的各种数据的访问⼊⼝。

验证:

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

验证选项:
⽂件格式验证
字节码验证
符号引⽤验证...

准备:

准备阶段是正式为类中定义的变量(即静态变量,被static修饰的变量)分配内存并设置类变量初始值的阶段。
⽐如此时有这样⼀⾏代码:

public static int value = 234;
它是初始化 value 的 int 值为 0,⽽⾮ 234。

解析:

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

初始化:

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

双亲委派模型:

如果⼀个类加载器收到了类加载的请求,它⾸先不会⾃⼰去尝试加载这个类 ,⽽是把这个请求委派给**⽗类加载器** 去完成,每⼀个层次的类加载器都是如此,因此所有的加载请求最 终都应该传送到最顶层的启动类加载器中,只有当⽗加载器反馈⾃⼰⽆法完成这个加载请求(它的搜索范围中没有找到所需的类)时,⼦加载器才会尝试⾃⼰去完成加载。

优点:

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

2. 安全性:使⽤双亲委派模型也可以保证了 Java 的核⼼ API 不被篡改,如果没有使⽤双亲委派模型,⽽是每个类加载器加载⾃⼰的话就会出现⼀些问题,⽐如我们编写⼀个称为 java.lang.Object类的话,那么程序运⾏的时候,系统就会出现多个不同的 Object 类,⽽有些 Object 类⼜是用户自己提供的因此安全性就不能得到保证了。

破坏双亲委派模型:

当然双亲委派模型也是有一定的缺点的,就比如java中SPI机制中JDBC实现。

由于JDBC 的 Driver 接⼝定义在 JDK 中,其实现由各个数据库的服务商来提供,因此在我们进⼊ DriverManager 的源码类就会发现它是存在系统的 rt.jar 中,rt.jar是由顶级⽗类 Bootstrap ClassLoader 加载的:

但是其 Driver 接⼝的实现类是位于服务商提供的 Jar 包中,是由⼦类加载器(线程上下⽂加载器Thread.currentThread().getContextClassLoader )来加载的 ,这样就破坏了双亲委派模型了(双亲委派模型讲的是所有类都应该交给⽗类来加载,但 JDBC 显然并不能这样实现)。它的交互流程图如下所示:

垃圾回收机制(GC):

在之前提到过,当一个对象在内存中不再被调用,此时就需要回收该对象所占用的内存,释放出供其他对象变量使用。

在这个过程中需要搞清楚

1.如何判断一个对象是否"死亡"。

2.对于"死亡"的对象如何回收相关的内存空间。

(在 Java 中,所有的对象都是要存在内存中的(也可以说内存中存储的是⼀个个对象),因此我们将内存回收,也可以叫做死亡对象的回收.)

死亡对象的判断算法:

可达性分析算法:

此算法的核心思想为 : 通过⼀系列称为"GC Roots"的对象作为起始点,从这些节点开始向下搜索,搜索⾛过的路径称之为"引⽤链",当⼀个对象到GC Roots没有任何的引⽤链相连时(从GC Roots到这个对象不可达)时,证明此对象是不可⽤的。以下图为例:


对象Object5-Object7之间虽然彼此还有关联,但是它们到GC Roots是不可达的,因此他们会被判定为可回收对象。
在Java语⾔中,可作为GC Roots的对象包含下⾯几种:

1.虚拟机栈(栈帧中的本地变量表)中引用的对象;
2. 方法区中类静态属性引用的对象;
3. 方法区中常量引用的对象;
4. 本地方法栈中 JNI(Native方法)引的用对象

垃圾回收算法:

垃圾回收算法在这之前是有很多种,在这里就不再一一介绍了,确实每种算法都有他自己的优点和缺点,但是目前JVM采用的垃圾回收算法是------分代算法。

分代算法:

当前 JVM 垃圾收集都采⽤的是"分代收集(Generational Collection)"算法,这个算法并没有新思想,只是根据对象存活周期的不同将内存划分为几块。⼀般是把Java堆分为新⽣代和⽼年代。在新⽣代中,每次垃圾回收都有⼤批对象死去,只有少量存活,因此我们采⽤复制算法;而老年代中对象存活率高、没有额外空间对它进⾏分配担保,就必须采⽤"标记-清理"或者"标记-整理"算法。

新生代or老年代:

什么时候进入新生代什么时候进入老年代?

新⽣代:⼀般创建的对象都会进⼊新⽣代;
⽼年代:⼤对象和经历了 N 次(⼀般情况默认是 15 次)垃圾回收依然存活下来的对象会从新⽣代移动到⽼年代。

Minnor GC和Full GC有什么区别?

Minor GC⼜称为新⽣代GC : 指的是发⽣在新⽣代的垃圾收集。因为Java对象⼤多都具备朝生夕灭的特性,因此Minor GC(采⽤复制算法)⾮常频繁,⼀般回收速度也⽐较快。
Full GC ⼜称为 ⽼年代GC或者Major GC : 指发⽣在⽼年代的垃圾收集。出现了Major GC,经常会伴随⾄少⼀次的Minor GC(并⾮绝对,在Parallel Scavenge收集器中就有直接进⾏Full GC的策略选择过程)。Major GC的速度⼀般会⽐Minor GC慢10倍以上。

垃圾收集器:

由垃圾回收机制可以得知现在JVM采用的是分代算法,也就是划分内存区域位"新生代"与"老年代",那么此时就涉及到新生代与老年代的具体的垃圾回收器,在这里重点介绍一个:

G1收集器(唯⼀⼀款全区域的垃圾回收器)

垃圾回收器的种类非常多,但是之前的几种垃圾回收器都只是针对一个"代"而产生作用,也就是一个垃圾回收器要么适用于"新生代"要么适用于"老年代",而且对于这两代串行和并行还有很多种垃圾回收器,但是G1这个垃圾回收器就非常全能了,既适用于"新生代"又适用于"老年代"。
G1(Garbage First)垃圾回收器是⽤在heap memory很⼤的情况下,把heap划分为很多很多的
region块,然后并⾏的对其进⾏垃圾回收。
G1垃圾回收器在清除实例所占⽤的内存空间后,还会做内存压缩。
G1垃圾回收器回收region的时候基本不会STW,⽽是基于 most garbage优先回收(整体来看是基于"标记-整理"算法,从局部(两个region之间)基于"复制"算法) 的策略来对region进⾏垃圾回收的。
⽆论如何,G1收集器采⽤的算法都意味着 ⼀个region有可能属于Eden,Survivor或者Tenured内存区域。图中的E表⽰该region属于Eden内存 区域,S表⽰属于Survivor内存区域,T表⽰属Tenured内存区域。

图中空⽩的表⽰未使⽤的内存空 间。G1垃圾收集器还增加了⼀种新的内存区域,叫做Humongous内存区域,如图中的H块。这种内存 区域主要⽤于存储⼤对象-即大小超过⼀个region⼤⼩的50%的对象。
在G1垃圾收集器中, 年轻代 的垃圾回收过程 使用复制算法 。把Eden区和Survivor区的对象复制到新的Survivor区域。

对于⽼年代上的垃圾收集,G1垃圾收集器也分为4个阶段,基本跟 CMS 垃圾收集器⼀样,但略有不同。
这里就不再详细介绍了,(有点复杂,等后续有时间单独会出一篇单独介绍!)

相关推荐
ekskef_sef1 分钟前
Spring Boot——日志介绍和配置
java·数据库·spring boot
贵州晓智信息科技5 分钟前
Three.js实现动态水泡效果逐步解析GLSL着色器
开发语言·javascript·着色器
我看就这样吧8 分钟前
使用rpc绕过咸鱼sign校验
网络·网络协议·rpc
汤姆和杰瑞在瑞士吃糯米粑粑20 分钟前
【优先算法】滑动窗口--结合例题详解学习
开发语言·数据结构·c++·算法
理想青年宁兴星31 分钟前
【RabbitMQ】rabbitmq广播模式的使用
java·rabbitmq·java-rabbitmq
北京_宏哥36 分钟前
《手把手教你》系列技巧篇(四十)-java+ selenium自动化测试-JavaScript的调用执行-下篇(详解教程)
java·selenium·前端框架
V+zmm1013442 分钟前
基于微信小程序的中国各地美食推荐平台的设计与实现springboot+论文源码调试讲解
java·微信小程序·小程序·毕业设计
code_shenbing1 小时前
C# 控制打印机:从入门到实践
开发语言·c#
magic 2451 小时前
JVM体系结构
java·开发语言·jvm·intellij-idea·idea
计算机-秋大田1 小时前
基于微信小程序的购物系统设计与实现(LW+源码+讲解)
java·后端·微信小程序·小程序·课程设计