前言最近又开始阅读《深入了解Java虚拟机》这本书,想用系列文章来记录和分享自己的心得。为什么说“再”?是因为我多年前买了这本书,一遍又一遍地读。这个“翻来覆去”可以说是一个非常生动的形象,因为我不仅把书从前面读到后面,而且从后面读到前面。不过,这并不是一个值得骄傲的过程,因为我之前看的时候经常卡住(俗称看不懂),导致我半途而废。再次拿起的时候,为了有一些新鲜感,我试着从后往前看,结果证明效果还是不好。今年又拿起了这本书(生活所迫),这次读起来比以前顺畅多了,可能是因为有了一些工作经验(社会的毒打)。我觉得这本书很难读下去有几个原因:对基本的计算机技能有一些要求。这本书其实不推荐给初级开发者看,因为它假定读者已经了解了很多计算机领域的基础知识,包括操作系统、数据结构、编译原理等,并且需要一定的源码阅读能力(这里的源码不是JAVA,而是C或者汇编语言)。如果你对这些没有初步的了解,很容易被书中的专业术语带走(传说是每个字都认识,但不知道句子在说什么。我明白了这种感觉~),最后从入门到放弃。真正的开发过程中遇到的机会并不多。JVM对于JAVA工程师来说就像厨师的炉子(我们对JVM的理解可能还不如厨师)。大家都知道怎么用,很少出问题,但一旦出问题,我们就开始傻眼了。而这也让我在初读这本书的时候很难对很多例子产生共鸣。再加上实践机会不多,不能灵活的学习和运用。但是一旦开始工作,JVM出问题的概率就增加了(虽然还是不多)。当系统频繁告警内存使用率过高或OOM异常时,我们可能需要拿出这本书来给忙碌的系统降温。不够追求极致其实书中最打动我的是作者记录了Eclipse虚拟机优化的实战。上面说了,使用JVM优化的场景并不多,但是反过来说,这是否是因为我们没有追求极致。能否提高代码的编译速度?能否缩短系统的启动时间?FullGC频率可以降低吗?笔者当时使用的Eclipse启动时间还算不错,但还是找到了这个优化场景,灵活运用JVM知识,达到了预期的效果。既然系统可以用了,我们不妨把它做得更好用。追求完美是促进程序员成长的最好品质~既然这本书已经很不错了,那这个系列文章想达到什么目的呢?主要有两点:降低阅读门槛以上提到的阅读本书前需要提前了解的一些相关知识,将在本系列文章中介绍。不会那么深,但是可以让大家的阅读更加连贯。分享阅读心得我在很多论坛上发过如何学习JVM,但是反馈的很少。之前在内网也看到大神分享了他学习JVM的坎坷经历,但是我的功力显然不允许我直接撕代码。因此,希望能在这里延伸阅读的内容,通过分享巩固自己的理解。也希望大家看完文章后多提意见,无论是文章中的理解误区,还是工作中遇到的优化实例,欢迎大家指正。多线程的基本模型在开始介绍JVM之前,我们先简单了解一下现代计算机主要包括哪些部分,以及多线程运行的概念。现代计算机的模型主要来源于原始的冯·诺依曼模型,主要由以下几个部分组成:CPU、内存、磁盘和IO设备(这里只给出最基本的组成结构)。其中CPU负责计算,内存和磁盘负责存储。两者的区别在于掉电后数据是否可以持久化。IO设备是指所有获得输入输出的设备,如键盘、显示器等。随着计算机的发展,各种硬件的性能有了明显的提高,尤其是CPU的计算能力。这样一来,其他的操作,比如磁盘的读写能力就成了瓶颈(可以理解为一次读写的耗时可以算出上千条CPU指令)。因此,操作系统引入了多进程模型,进而引入了多线程模型,即当其中一个进程/线程执行耗时较长且不需要CPU参与的操作时,比如读取文件,CPU被释放给另一个进程/线程使用。至于是多进程并发还是多线程并发,要看具体的操作系统设计。有些操作系统只能按线程分配CPU时间,需要在进程内不断将时间分片到线程中。进程、线程和CPU的整体关系如下,其中绿色代表当前获取CPU时钟并执行的线程,Java从代码到运行的过程。然后我们看看代码是如何从我们看到的高级开发语言(如Java、C++等)变成可执行的计算机指令的。众所周知,计算机不可能理解每一种不同的高级开发语言的语义。它只能理解机器语言,比如将内存位置A的值加1,或者读取内存位置B的值放入累加器。因此,需要借助某种工具将高级开发语言翻译成机器可以理解的指令。而这个转换过程又可以分为编译和解释。解释型语言在运行时被编译成机器可执行的指令。常见的解释型语言有python和perl。编译型语言会先将高级语言编译成可执行的指令产物,然后再运行,所以相对来说会先增加编译耗时,但编译后的产物是可以重复执行的。JAVA是一种编译型语言。不过与C等传统编译型语言相比,JAVA多了一些解释步骤。它的编译产物不是可执行文件,而是字节码文件(.class文件),然后由JVM解释字节码文件。运行可执行机器指令。正是这一步,使得Java成为了支持跨平台运行的语言,因为它只需要编译一次,其编译后的产品就可以运行在各种平台上。当然,这也意味着JVM需要针对不同的平台进行定制。JVM运行时数据区介绍完Java从代码到运行的过程,我们了解到JVM负责将.class文件解释成机器指令并在整个生命周期内执行。由于它作为中间人来承载程序的运行,因此它也需要与计算机的各个组件进行交互和管理。本文将介绍JVM是如何划分和管理内存区域的。如下图,JVM根据存储的数据类型进一步区分划分的内存,分为以下几个区域:程序计数器、Java虚拟机栈、本地方法栈、Java堆、方法区。这里的每个区域存储不同类型的数据,并根据数据的特点采用不同的内存回收机制。(内存回收不是本小节的内容,但是了解区域的特点会有效帮助理解为什么要采用相应的内存回收策略)程序计数器程序计数器不是JVM特有的属性。其实在操作系统中也存在程序计数器的概念。两者在程序执行中的作用其实差不多。如上所述,今天的操作系统是多线程和并行的。每个线程在获得CPU时钟后都会执行当前线程需要完成的工作,时钟周期结束后会进行新一轮的抢占和分配。这也意味着没有获得时钟周期的线程需要被中断并等待下一个分配给它们的时间片。因此每个线程都需要记录当前执行的进度,以便在CPU时钟恢复时可以继续执行。JVM程序计数器用来记录下一条需要执行的字节码指令(注意这是字节码指令,操作系统的程序计数记录的是机器指令)。由于每个线程都有自己独立的程序计数器(这里一定不能共享,否则拿到CPU时钟就变成线程A执行B线程指令),所以这块内存是线程私有内存。Java虚拟机栈Java虚拟机栈描述了Java方法执行的内存模型。这里简单介绍一下方法执行的内存模型。让我们先回顾一下Java中的方法。公共类Dog{privateintweight;publicvoideat(Foodf){f.消耗();便便便便=新便便();重量++;}}publicclassFood{privateintbones;publicvoidconsume(){骨头--;}}每个Java方法都包含了方法名、入参和返回值(可能为void),然后该方法可能会访问其他方法、局部变量或全局变量等。以上面的代码为例,如果我们调用newDog().eat(newFood()),eat方法会先调用对象f中的consume方法,它会访问成员变量bones并将其值减一。然后eat方法会访问自己的成员变量weight,并将其值加1。如果你了解数据结构栈,你可以把整个过程想象成一个push和pop。每访问一个Java方法,都会在本地方法栈中创建一个栈帧,栈帧中存储局部变量表、操作数栈、动态链接、方法出口等信息。方法执行完成后,栈帧的生命周期也结束。这也可以解释为什么在方法内部创建的实例是线程安全的(前提是这个实例不会被方法返回或者它的引用在方法区之外)。这里我对上面提到的几个概念进行解释:局部变量表、操作数栈和动态链接。局部变量表会保存函数的参数、局部变量和returnAddress类型。以上面的伪代码为例,当调用eat方法时,其局部变量表中会包含方法的参数f和方法中创建的对象poop。当然,因为Food和Poop是对象,所以这里保存的其实是这两个对象的引用。因为该方法没有返回值,所以returnAddress类型为void。局部变量表的大小可以在代码编译时确定(如果不熟悉编译,可以参考上面的Java代码执行流程图)。操作数栈,顾名思义,就是一种栈式数据结构,用来保存计算过程的中间结果,同时作为计算过程中变量的暂存空间。不知道大家有没有写过用栈实现复杂的四次算术运算的题目(很有意思的题目,完美利用了栈的后进先出特性)。这里的操作数栈的作用是类似的,但是完成的操作不仅仅是四次算术运算,还有其他指令,比如调用其他方法,保存返回值等。同样,操作数栈所需的内存大小也可以在编译时确定。动态链接指向当前方法所在类的运行时常量池,这样如果当前方法中需要调用其他方法,就可以从运行时常量池中找到对应的符号引用,然后将符号引用reference转化为直接引用,然后可以直接调用相应的方法。也就是说,如果当前方法需要调用其他对象或方法,就需要知道它们的内存位置。动态链接将记录这些信息并将其转换为内存位置,并在需要时访问它。Java虚拟机栈中可能会出现两种异常,StackOverflowError和OutOfMemoryError。前者是线程请求的栈深度大于虚拟机允许的深度,往往是在循环中调用方法导致的死循环。后者可能发生在线程过多,导致内存分配不足以满足需求时。正如其功能所示,Java虚拟机栈是线程私有内存,A线程无法访问B线程虚拟机栈的内容。本地方法栈本地方法栈的作用类似于Java虚拟机栈,不同的是调用的不是Java方法,而是Native方法。本地方法通常不是用Java语言实现的,而是用C/C++实现的。JVM规范不要求特定语言来实现本机方法。但并不是所有的虚拟机都会将方法栈区分为Java虚拟机栈和本地方法栈。例如,Sun的Hotspot虚拟机将两个堆栈合二为一,统一管理。Java堆Java堆存放对象实例和数组,这也是内存管理最大的一块区域,而这块区域是线程共享的(也是需要我们在编程时注意并发控制的一块区域)。当一个方法创建一个对象或者传递一个对象时,它实际传递的是对象的引用,指向对象的起始地址或者与对象相关的位置。Java堆也可以分为新生代和老年代,以对象的生命周期来区分。同时新生代也可以分为Eden空间,FromSurvivor空间,ToSurvivor空间,主要是为了更好的完成垃圾回收。对象创建后,会随着被回收的次数逐渐移动到对应的区域。集合具体进入相应区域的次数由JVM的配置决定。图中还有一个区域之前没有提到:永久代。这个区域通常存放一些很少变化的信息,比如后面提到的方法区的内容,所以它的特性不适用于Java堆。在进行内存回收管理时,也会对该区域进行内存回收。方法区方法区也是各个线程共享的内存区域,用来存放已经被虚拟机加载的类信息、常量、静态变量、即时编译代码等数据。可以看出,这类数据通常很少发生变化,因此一些虚拟机将其作为JVM永久代进行管理。而这块内存回收意味着常量池的回收和类型的卸载(实时卸载类型的条件非常高,所以大部分类不会被卸载,对于那些喜欢使用动态代理的项目来说.据说这块内存很可能有内存溢出)这里再解释一下上面提到的即时编译代码的概念。上面说了,Java的运行过程是通过JVM解释字节码实现的。但是,每一行代码都需要解释然后执行,这不可避免地会影响性能。因此,在JVM内部做了一些优化。经常执行的代码块会被转换成机器指令保存起来,这样下次执行时就不需要解释了,大大提高了性能。这个过程称为即时编译(JustInTimeCompiler),JIT编译后的机器指令会存放在方法区。总结这里总结一下JVM内存管理过程中各个区域的作用和可能出现的异常。
