JVM详解:JVM的系统架构
计算机语言大致可以分为两类,一直是编译性语言,典型的如C++,他会先有编译器编译成可执行文件(操作系统可读,不同的操作系统需要编译成不同的可执行文件),而另一种则是翻译性语言,这种语言本身不被编译,而是由另一个已经被编译成可执行文件开启进程,在这个进程中对此计算机语言逐行的翻译执行,典型的如javaScript,其可以在node环境或浏览器环境(本质是都是v8引擎)下执行,这种语言通常都可以跨操作系统,因为操作系统的差异,其运行环境已经适应完了。
java相对特殊,其既需要编译,也需要翻译,大致过程是由java语言先编译成字节码文件,再由jvm充当翻译,来对字节码文件进行翻译执行,那么多此一举的好处是什么呢?
首先字节码本身是跨操作系统的,也就说在任何操作系统下,字节码都是一样的,所以编译成字节码对jvm的翻译工作不会造成负担,编译的过程本身能够处理掉很多无需在运行时执行的代码,比如类型检查,常量的创建等等(JVM也秉承着能在编译期处理,绝不再运行期处理的原则)。
1. JVM中的类型处理
JVM支持的数据类型可以分为原始类型和引用类型,与Java语言本身一样。其中,引用类型包括整型(char
也算作整型)、浮点型、布尔类型以及 returnAddress
类型。那么,什么是 returnAddress
呢?
returnAddress
是一种特殊的类型,用于保存JVM内置指令的引用,或者可以理解为指向某个内存地址的指针。在方法调用过程中,JVM需要保存调用点的返回地址,returnAddress
就用于存储该信息,通常与堆栈操作密切相关。
JVM的设计理念是尽可能将编译期能解决的工作都交给编译器来完成。因此,类型检查和类型区分这种可以在编译期间完成的事情,就是在编译阶段完成的,而不是在运行时动态执行的。例如,javac
编译器在编译Java源代码时就会根据类型生成相应的字节码。
字节码期间把类编译好不是应该的吗?不然JVM怎么知道是什么类型,换句话说JVM就不需要保存类型信息吗,不还是一样需要处理类信息吗?没错,JVM其实并不关心或者说不需要知道在运行时变量的具体类型。它只是一个翻译机器,根据字节码指令执行操作。就像操作系统运行可执行文件时,它并不关心程序的具体内容,而只按指令执行。
Boolean类型特别处理
尽管Java中有boolean
类型,但JVM在内部对其的处理方式与其他基本类型不同。在JVM中,boolean
类型通常被转换为 0
和 1
进行存储,boolean
数组则被转换为字节数组。
2. PC寄存器
PC寄存器(Program Counter Register)是JVM内部的重要组件,用于记录当前线程正在执行的字节码指令的位置。每个线程都有自己的PC寄存器。
- PC寄存器的作用:它指向当前线程下一条将要执行的字节码指令。每当JVM执行字节码时,PC寄存器会自动更新,指向下一个指令。对于执行JVM内置指令时,PC寄存器会指向相应的内置指令。
- 内置指令和系统调用:JVM内置的指令(例如,垃圾回收、同步操作等)类似于操作系统的内核函数。它们是公共资源,多个线程访问时需要进行同步控制,以确保安全执行。
JVM通过PC寄存器来控制每个线程的执行,就像操作系统通过CPU调度控制进程一样。在这种机制下,JVM能够有效避免Java程序对JVM本身的破坏,因为内置指令被严格保护。
多个Java程序(或服务)依赖于同一个JVM进程。当JVM崩溃时,所有运行在该JVM上的Java程序都会受到影响。因此,保护JVM不被非法操作是至关重要的。
3. JVM内存管理
JVM的内存管理涉及栈(Stack)和堆(Heap)两大区域,分别用于存储不同类型的数据。
3.1 栈(Stack)
每个线程的创建(java服务的主线程本质上就是jvm创建的一个翻译线程),都伴随着一个栈内存的创建,这个栈就是运行当前线程代码的内存区域,栈它的主要作用是存储局部变量和方法调用时的栈帧。那么什么是栈帧。
3.2 栈帧
当一个需要使用一个类的时候,jvm会将这个类加载进堆内存的方法区,单此时只会加载类的元数据信息,并不会将类的方法也一并加载进内存(内存中会保存类的方法的符号引用,不是真正的引用),每当类的方法被调用时,JVM会为该方法创建一个栈帧,并将栈帧放入线程的执行栈中执行。栈帧是方法执行的运行环境,其中包含了该方法的局部变量、操作数栈、常量池引用等信息。
局部变量的保存比较特殊,其并不是一个个游离在栈帧内存中,而是在编译期被保存在一个数组,并且将使用的常量变为常量的引用,存入数组,因为常量最终并不在栈帧中,而在常量池中,而原来使用局部变量的地方,都会变成数组及数组的索引。
3.3 方法区
在上文中,我们提到了方法区,方法区是堆的一部分,当一个类需要被使用时,其全部的类信息都会被加载进方法区中。常量也是类信息的一部分,所以常量池也在方法区中。常量池也很简单,就是保存常量的地方,在java被编译时,编译器会将java能够当作常量的部分都变为常量保存在一个常量区中,在加载类时其被加载进常量池保存,其中还包括一些符号引用,就不单独说了。
3.4 操作栈
任何一个方法的执行都会创建栈帧,而一个栈帧的创建也会伴随着一个操作栈的创建,操作栈是栈帧内的一个结构。它是存储方法执行中的临时数据(类似于CPU的寄存器)。在方法执行过程中,操作栈按需推送和弹出数据。它的存在使得栈帧内存的使用更加高效。局部变量的使用就是先将使用变量从数组中拿出,推入操作栈,再进行使用。
当A方法调用B方法,并接收其返回值时,如果B方法执行成功,并且返回值不需要被存在堆中,那么就会会被推入A方法的栈帧的操作数栈中,A方法继续执行。如果失败则执行异常逻辑,或直接报错。
3.5 堆(Heap)
堆是所有线程共享的内存区域。所有需要长期保存的内存数据,都将被放入堆中。
- 堆内存是垃圾回收的主要区域。JVM会定期检查堆内存中的对象,回收那些不再被任何线程引用的对象。
与栈不同,堆内存中的数据具有较长的生命周期,可以在多个线程之间共享。
在某些情况下,栈帧会被转移到堆中。比如,当方法的返回值被另一个方法(或者栈帧)使用,并且该返回值的生命周期超过当前栈帧的生命周期时,JVM会将栈帧中的数据转移到堆中。这和JavaScript中的闭包机制基本一样,其中栈帧对应着js中的词法环境,局部变量不能被立即回收时,对应的词法环境(栈帧)会被保存下来供后续使用。
3.6 本地方法栈
在阅读java源码中,我们会发现一些方法并没有方法体,只有一个native,对于这些方法,实际上是C++或其他语言编写的,他们无法在正常的栈结构中执行,而是在jvm提供的本地方法栈中执行,这些方法也被称为本地方法。我们也可以编写自己的本地方法,在java中使用,不过这里主要讲jvm,就不讲如何编写本地方法了。本地方法栈也是堆内存的一部分
JVM的整体内存架构大概如下图所示:
3.7 垃圾回收
JVM的垃圾回收机制与JavaScript语言也及其类似,他是通过标记-清除的方式,来标记从根节点不可达的引用,而JavaScript是通过从根节点进行寻找不可以达引用进行回收,本质上都是清除不可达内存。
- 标记阶段:JVM会从根节点(GC Root)开始,遍历所有可达的对象,标记所有存活的对象。
- 清除阶段:JVM会回收未被标记的对象,释放内存。