通往高级JAVA开发的必经之路——JVM
前言:
JAVA语言的一个非常重要的特点就是与平台的无关性。而使用JVM是实现这一特点的关键。一般的高级语言如果要在不同的平台上运行,至少需要编译成不同的目标代码。而引入JVM后,Java语言在不同平台上运行时不需要重新编译。Java语言使用模式JVM屏蔽了与具体平台相关的信息,使得Java语言编译程序只需生成在Java虚拟机上运行的目标代码(字节码),就可以在多种平台上不加修改地运行。
开始讲解之前,我们先来看一下JVM的组成:
这张图为JVM的组成图,大致分为三个系统构成,分别是类加载器系统、运行时数据区,以及执行引擎,本文的重点主要在类加载器与运行时数据区。
类加载:
我们写好的代码都是保存在后缀为.JAVA的文件中,在通过编译之后这些文件就会转成一个个后缀为.CLASS的字节码文件中,再通过类加载器来运行这些CLASS文件。
类加载器:
JAVA中默认自带了三个类加载器,分别为
启动类加载器(根类加载器):只负责加载JAVA本身自带的核心库
扩展类加载器:只加载Jar格式的文件,不管Class文件
应用类加载器:负责加载我们编写的编码编译而成的Class文件
父类双亲委派机制:
如上图所示,JVM中所有的类加载除了启动类加载器,其余的都是有自己的父级加载器的,当一个类加载被通知需要加载某个类时,它不会直接进行加载,而是先通知自己的父类加载器来进行加载,如果它的父类加载器还有自己发父类加载器,那就继续往上通知,直到最上层(启动类加载器),这个时候因为各个类加载器的职责不同,所以当父类加载器无法加载该类时,才会转交给子级加载器去加载。文字描述可能有点模糊,看图:
这样做的好处是什么?
保证了JAVA的稳定性,避免让公共的类重复加载,防止了核心类库被用户编写的同名类所纂改。
我们能否打破这个机制,自己实现一个类加载器呢?
细心的你应该已经发现了上图除了JAVA自带的三个类加载器还有一个用户自定义类加载器。是的,JAVA是允许我们自己去实现一个类加载器,我们可以选择遵守父类双亲委派机制,也可以选择不遵守这个机制,具体的做法我不做过多讲解。
JVM运行时数据区:
方法区:用于存放被JVM加载的类信息、常量、静态变量、即时编译器编译后的代码等。
程序计数器:程序计数器是一块很小的线程私有的内存空间,用来记录虚拟机字节码的指令地址,通俗的来说,在写代码时,我们在一个方法A中调用了另一个方法B,方法B中又调用了方法C,那么程序如何在执行完方法C后准确无误的返回执行方法B,在方法B执行完后又返回方法A呢,靠的就是程序计数器来记录当前线程的执行行号。
本地方法栈:简单来说一个本地方法栈就是一个JAVA调用非JAVA代码的接口,为什么要这么做呢?早年JAVA刚出来的时候,世界还是C语言的天下,一些与底层交互的的任务要是用JAVA实现比较麻烦,或者说效率不高的时候,可以直接在本地方法栈中调用C或C++的方法来实现。
堆:堆内存为线程共有,堆内存可以说是JVM中的重中之重了,基本上我们JVM调优大部分也是在堆内存中进行操作,因为在JAVA7和JAVA8的两个版本中对堆的改动较大,所以我这边需要分开来讲,先讲JAVA7版本中的堆内存,如图所示:
这就是JAVA7版本的堆内存结构图,分为新生代、老年代、永久代,而新生代中,又分为了伊甸区、To区与From区。稍微了解过JVM的人可能对这一块的概念很熟了,但是说实话,我之前学习的时候也经常去网上查找资料、阅读别人的博客,关于堆内存的解释各有千秋,但是很少有人能把新生代中的发生了什么事情讲的很清楚,我也是最后干脆去买一本对JVM有着详解的书籍才能了解到,不多废话了直接开始讲解。
新生代:
在新生代里根据8:1:1的空间划分为了伊甸区、To区与From区,我们所有新建(new)的对象,都是在伊甸区出生,当新生代经历的一次GC(垃圾回收)后,幸存下来的对象会被从伊甸区和From区清理至To区并且年龄加一,清理完成后,此时的To区变成From区,空下来的From区变成To区,以便下一次的垃圾回收整理。我知道,看到这时候的你可能又懵了,没关系,看下图:
如此循环往复,当一个对象经历了15次GC后依旧存活时,他就会被从新生代移至老年代中,老年代也有GC回收机制,只是没有新生代那么频繁,这个我后续再讲。
老年代:
老年代里的对象,可以说是个个身经百战了。它们都是属于不会那么容易就被GC清除了的,老年代中的GC回收机制跟新生代不同,次数也没那么频繁,理论上来说进入老年代的对象一般都是毕竟重要且稳定的。
永久代:
翻看上面的运行时数据区,你会看到一个叫方法区的区域,如果说把方法区比作是JAVA中的抽象方法,那么永久代就是它的实现类。其实严格上来说永久代并不是堆的一部分,虽然JVM规范中把方法区描述为堆的一个部分,但是方法区还有一个名字叫non-heap(非堆),目的就是为了将其与堆内存区分开来。个人认为之所以使用永久代来实现方法区,应该是为了让垃圾回收器可以像管理新生代和老年代一样管理这部分内存。但是实际上看来这样的做法也并不是最好的,因为很容易造成内存溢出问题。好在在JAVA8中对这块进行的优化,这个我下面会讲解,我们先继续看看栈内存。
栈:栈内存为线程私有,栈内存的数据结构有点像是弹夹,将子弹一颗一颗的装填进去,最先进去的子弹待在最下面,所以最后出来,最后进去的子弹在最上面所以最先出来,这就是栈的特点,先进后出。在栈的概念中,每一颗子弹被称为栈帧,在JAVA中每个方法执行都会创建一个栈帧,而栈帧内又包含了局部变量表、操作数栈、动态链接、方法出口四个区域,每个方法从开始调用到结束都对应一个栈帧在虚拟机栈中入栈到出栈的过程。
局部变量表:
局部变量表是一组存储变量的空间,用来存放方法的参数和局部变量。JAVA程序员应该对局部变量的概念很熟悉,每一个栈帧之间的局部变量不互通,也是为了防止线程安全问题。
操作数栈:
操作数栈也常称为操作栈,是一个后入先出栈。JVM把操作数栈作为它的工作区——大多数指令都要从这里弹出数据,执行运算,然后把结果压回操作数栈。
动态链接:
每一个栈帧都会包含一个指向运行时常量池的方法引用,持有这个引用是为了支持方法调用过程中的动态链接。
我们编译好的Class文件中存在大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用为参数,这些符号引用:
1.静态解析:一部分会在类加载阶段或第一次使用的时候转化为直接引用(如final、static域等),称为静态解析,
2.动态解析:另一部分将在每一次的运行期间转化为直接引用,称为动态链接。
方法返回地址:
当一个方法开始执行以后,只有两种方法可以退出当前方法:
1.正常返回:当执行遇到返回指令,会将返回值传递给上层的方法调用者,这种退出的方式称为正常完成出口,一般来说,调用者的程序计数器可以作为返回地址。
2.异常返回:当执行遇到异常,并且当前方法体内没有得到处理,就会导致方法退出,此时是没有返回值的,称为异常完成出口,返回地址要通过异常处理器表来确定。
当一个方法返回时,可能依次进行以下3个操作:
1.恢复上层方法的局部变量表和操作数栈。
2.把返回值压入调用者栈帧的操作数栈。
3.将程序计数器的值指向下一条方法指令位置。
以上便是JAVA7中的JVM大致讲解,而在JAVA8中,JVM的最大改动,就是将永久代取消掉,使用元空间(meta space)来替代。
为什么这么做其实很好理解:
1.永久代的大小不好指定。
2.永久代会给GC带来不必要的复杂度,回收效率降低
3.字符串存放在永久代中容易导致性能问题和内存溢出(字符串常量池从JDK7开始,从方法区移动到堆中)
元空间:
元空间的出现就是为了替换掉永久代并解决永久代的痛点,它们俩之间最大的区别还是元空间并不是存在于JVM内了,而是直接使用了本地物理内存,因此在默认情况下,你机器的物理内存有多大,元空间的内存就有多大。我们也可以通过-XX:MetaspaceSize 参数来调整元空间大小。当达到指定的该值后就会触发垃圾回收器,同时会对该值进行一次调整,如果释放了大量的空间,就稍微调低该值,如果只是释放了很少量的空间,那么在不超过参数MaxMetaspaceSize时,适当提高该值。元空间的出现成功解决了永久代存在时的内存溢出问题,但值得注意的是,一旦发生泄漏,会占用你大量本地内存,并且还可能导致交换区交换更加糟糕。