2
头图

前言

最近重新开始阅读《深入了解Java虚拟机》这本书,就想着用一个系列文章来记录和分享自己的心得。为什么要说”重新“呢?是因为这本书我在多年前就买了,中间也曾翻来覆去的看过。这个”翻来覆去“可以说是非常的生动形象,因为我不仅从前往后看,也从后往前看了这本书。但是,这并不是一个值得骄傲的过程,因为我之前看的时候经常被卡住(俗称看不懂),导致我中途放弃。再次拾起的时候为了多一些新鲜感,就尝试从后往前看,事实证明效果依旧不佳。今年我又拿起这本书(生活所迫),这次阅读下来,相比之前要流畅许多,可能是因为有了一些工作经验吧(社会的毒打)。感觉这本书难以坚持阅读主要有几个几个原因:

  1. 对计算机基本功有一些要求
    这本书其实对于初级开发者来说,是不建议阅读的,因为它默认读者已经了解计算机领域的很多基础知识,包括操作系统、数据结构,编译原理等,并要求有一些源码阅读能力(这里的源码还不是JAVA,而是C或者汇编语言)。如果对这些没有初步的认识,就很容易被满书的专业术语带跑(传说中的认识每一个字,却不知道这句话在说啥,这种感觉,我懂~),并最终从入门走向放弃。
  2. 真正开发过程中遇到的机会不多
    JVM对于JAVA工程师就像是灶台之于厨师(我们对JVM的了解可能还不如厨师)。谁都知道去用它,它也很少出问题,但是一旦出问题了,我们就开始傻眼了。而这也导致我最初阅读这本书的时候对很多例子难以感同身受,再加上实用机会不多,也无法活学活用。但是一旦走到工作中,JVM出问题的概率就增加了(虽然依然不多)。当系统频繁的报警内存使用率过高或OOM异常时,我们也许就需要掏出这本书,给忙碌的系统降降温。
  3. 不够追求极致
    其实书中让我触动最深的是作者记录了对Eclipse虚拟机优化的实战。正如上文所说,使用JVM优化的场景并不多,但是反过来想,这是否是因为我们不够追求极致。代码的编译速度是否还能提升?系统的启动耗时是否还能缩短?Full GC频率是否还能降低?作者当时是用的Eclipse启动耗时并不差,但是他依然找到了这个优化场景,并灵活的运用JVM知识达到了预期效果。既然系统可以使用,那不妨让它更好用,追求极致是推动程序员成长的最佳品质~

那么既然这本书已经很好了,这一系列文章想要达到什么目的呢?主要有两个:

  1. 降低阅读门槛
    上文提到的阅读本书前需要提前了解的一些关联知识,这个系列文章中都会进行介绍。不会那么深入,但可以让大家的阅读更加连贯。
  2. 分享阅读心得
    我在很多论坛上发过如何学习JVM,但是反馈寥寥。之前也在内网看到大神分享自己学习JVM的坎坷经历,但是我的功力显然不允许我直接手撕代码。因此希望这里对阅读的内容进行延伸,通过分享巩固自己的认识。也希望大家阅读文章后多给一些反馈,无论是文章中的误区,还是工作中遇到的优化的例子,都来者不拒。

多线程基本模型

在开始介绍JVM之前,我们先来简单了解一下现代计算机主要包含哪些部分,以及多线程运行的概念。
现代计算机的模型主要来源于最初的冯诺依曼模型,它主要由以下几个部分组成:CPU,内存,磁盘和IO设备(这里仅给出最基础的组成结构)。

现代计算机组成.png
其中,CPU负责计算,内存和磁盘负责存储,二者的区别在于断电后数据是否能够持久化,IO设备则是指所有获得输入输出的设备,如键盘,显示器等。随着计算机的发展,各个硬件的性能都得到显著的提升,尤其是CPU的计算能力。从而导致其它操作,如磁盘的读写能力成为了瓶颈(可以理解为一次读写的耗时可以计算成千上万条CPU指令)。因此操作系统引入了多进程模型,并随后又引入了多线程模型,即在其中一个进程/线程在执行耗时较长且无需CPU参与的操作时,如读取文件,将CPU释放出来交给另一个进程/线程使用。至于究竟是多进程并发还是多线程并发,则要看具体的操作系统设计。有的操作系统只能按照线程分配CPU时间,需要进程内部将时间继续切片分给线程。进程、线程和CPU的总体关系如下,其中绿色的代表当前获得CPU时钟并执行的线程,

CPU分配线程_进程级别.png

Java从代码到运行的过程

接着我们来看代码是如何从我们看到的高级开发语言(如Java,C++等)变成可以执行的计算机指令。众所周知,计算机不可能去理解每一种不同的高级开发语言的语义,它只能理解机器语言,如将内存位置A中的值+1,或者是读取内存位置B的值并放入累加器。因此需要通过某种工具将高级开发语言转义为机器可以理解的指令。而这个转换的过程又可以分为编译型和解释型。

解释型语言是在运行的时候才会编译成机器可执行的指令,常见的解释型语言有python、perl等。而编译型语言则会先将高级语言编译成可执行指令产物,再去运行,因此相对而言会先增加一个编译的耗时,但是编译产物可反复执行。JAVA就是一种编译型语言。
Java编译运行过程.png
但是,JAVA和传统的编译型语言如C相比还多了解释的步骤,它的编译产物并非可执行文件,而是字节码文件(.class文件),再通过JVM将字节码文件解释为可执行的机器指令进行运行。正是这一步使得Java成为一个支持跨平台运行的语言,因为只需要编译一次,其编译产物就可以在各个平台上运行。当然,这也意味着JVM是需要针对不同的平台进行定制开发的。

JVM运行时数据区域

在介绍完Java从代码到运行所经历的过程,我们了解了JVM在整个生命周期中负责将.class文件解释成机器指令并执行。既然它作为中间商承载了程序的运行,同样的它就需要和计算机的各个组件进行交互并管理。而本文就将介绍JVM是如何进行内存区域的划分和管理的。

如下图所示,JVM将划分得到的内存按照存储的数据类型进一步区分,并划分出如下几个区域:程序计数器,Java虚拟机栈,本地方法栈,Java堆和方法区。

JVM内存管理架构图.png

这里的每一个区域存储着不同类型的数据,并且根据数据的特性会采取不同的内存回收机制。(内存回收不是本节的内容,但是了解区域的特性将有效的帮助理解为何采取相应的内存回收策略)

程序计数器

程序计数器并不是JVM特有的属性,事实上操作系统中也存在程序计数器的概念,二者在程序执行中起到的功能其实是类似的。

正如上文提到,当今的操作系统是多线程并行的,每个线程都将在获得CPU时钟的时候执行当前线程需要完成的工作,并且在时钟周期结束后进行新一轮的抢占和分配。这也意味着没有获得时钟周期的线程需要中断并等待下一次分得时间片。因此每个线程需要记录当前执行的进度,从而在重新获得CPU时钟时可以恢复执行。而JVM程序计数器就是用来记录下一条需要执行的字节码指令(注意,这里是字节码指令,操作系统的程序计数中记录的就是机器指令了)

既然每个线程有各自独立的程序计数器(这里肯定不能共享啦,否则就会变成A线程获得CPU时钟时执行B线程指令),所以这一块内存是线程私有的内存。

Java虚拟机栈

Java虚拟机栈描述的是Java方法执行的内存模型。这里可以简单介绍一下方法执行的内存模型。先让我们回顾一下Java中的method。

public class Dog {
    private int weight;

    public void eat(Food f) {
        f.consume();
        Poop poop = new Poop();
        weight++;
    }
}

public class Food {
    private int bones;
    public void consume() {
        bones--;
    }
}

每一个Java方法包含方法名称、入参和返回值(也可能是void),接着方法中可能会访问别的方法,局部变量或者是全局变量等。以上文中的代码为例,如果我们调用new Dog().eat(new Food()),eat方法首先会调用对象f中的方法consume,这个方法中会访问成员变量bones并将其值减一,接着eat方法会访问自己的成员变量weight并将其值加一。如果了解过数据结构的同学就可以将整个过程想象为一个入栈出栈。

每访问一个Java方法,本地方法栈中就会创建一个栈帧,栈帧中会存储局部变量表,操作数栈,动态链接,方法出口等信息。而方法执行完成后,栈帧的生命周期也随之结束。这也可以解释为什么方法内部创建的实例是线程安全的(前提是这个实例不会通方法返回或者其引用是方法区域外的)。

这里再解释一下上面提到的几个概念:局部变量表,操作数栈和动态链接

局部变量表会保存函数的参数,局部变量和returnAddress类型,以上面一段伪代码为例,eat方法被调用时,它的局部变量表中会包含方法的参数f和在方法中创建的对象poop,当然因为Food和Poop是对象,所以这里保存的其实是对这两个对象的引用。因为这个方法没有返回值,因此returnAddress类型为void。局部变量表的大小在代码编译期间就可以确定下来(不熟悉编译的参考上文的Java代码执行流程图)

操作数栈,顾名思义,是一个栈的数据结构,它用来保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间。不知道大家是否写过用栈来实现复杂的四则运算的题目(非常有趣的题目,完美的利用了栈后进先出的特性),这里操作数栈的功能与之类似,只不过完成的操作不仅四则运算,还有其它的指令,如对其它方法的调用并保存返回值。同样,操作数栈所需的内存大小也是在编译时可以确定下来。

动态链接指向当前方法所在类的 运行时常量池, 这样如果当前方法中如果需要调用其他方法的时候, 能够从运行时常量池中找到对应的符号引用, 然后将符号引用转换为直接引用,然后就能直接调用对应方法。换句话说,就是如果当前方法需要调用别的对象或者方法,就需要知道他们所处的内存位置。动态链接会记录这些信息,并在需要的时候将其转化为内存位置并访问。

Java虚拟机栈中可能存在两种异常,StackOverflowError和OutOfMemoryError,前者是线程请求的栈深度大于虚拟机所允许的深度,常见于在循环中调用方法导致的死循环。而后者则可能出现于线程数过多的情况,导致内存分配不足以满足需求。

正如其功能所示,Java虚拟机栈是线程私有的内存,A线程不能访问B线程虚拟机栈中的内容。

本地方法栈

本地方法栈和Java虚拟机栈的功能类似,区别在于调用的不是Java方法,而是Native方法。Native方法通常不是Java语言实现的,通常是C/C++实现的,JVM规范并没有要求使用特定语言来实现Native方法。

但是并不是所有的虚拟机都会将方法栈区分为Java虚拟机栈和本地方法栈,比如Sun的Hotspot的虚拟机就将两个栈合二为一统一管理。

Java堆

Java堆存放的是对象的实例和数组,这也是内存管理最大的一块区域,并且这块区域是线程共享的(也是需要我们在编程时注意做并发控制的区域)。当方法创建对象或者传递某个对象时,它实际上传递的是对象的引用,这个引用会指向对象的起始地址或者是和对象相关的位置。

Java堆还可以系分为新生代和老年代,这是以对象的存活期限进行区分的。同时新生代中还可以划分出Eden空间,From Survivor空间,To Survivor空间,这主要是为了更好的完成垃圾回收。当对象从创建出来之后,会随着被回收的次数逐渐移到相应的区域。具体多少次回收后会进入对应的区域则由JVM的配置决定。

Java区域划分.png

图中还有一个之前没有提到的区域:永久代。这个区域中通常存放一些很少会变动的信息比如后文讲到的方法区的内容,因此它的特性并不适用于Java堆。内存回收管理时同样会对这个区域进行内存回收。

方法区

方法区同样是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译后的代码等数据。可以看到这一类型的数据通常很少变动,因此有些虚拟机会将其视为JVM永久代进行管理。而这一块的内存回收就意味着对常量池的回收和对类型的卸载(实时上类型卸载的条件时非常高的,因此大多数类不会被卸载,这对于那些喜欢使用动态代理的项目来说这一块内存很可能出现内存溢出)

这里再解释一下上文提到的即时编译后的代码这个概念。正如上文所说,Java的运行过程是通过JVM解释字节码来实现的。但是,每运行一行代码都需要先解释后执行,难免对性能产生影响。于是JVM内部做了一些优化,对于频繁执行的代码块会将其转换为机器指令并保存,这样下次执行时就不需要再进行解释,极大的提高了性能。这个过程被称为及时编译(Just In Time Compiler),而JIT编译后的机器指令就会被存储在方法区。

总结

这里对JVM内存管理时各个区域的功能和可能出现的异常进行了总结。
总结.png


raledong
2.7k 声望2k 粉丝

心怀远方,负重前行