前言
最近从新开始浏览《深刻理解 Java 虚拟机》这本书,就想着用一个系列文章来记录和分享本人的心得。为什么要说”从新“呢?是因为这本书我在多年前就买了,两头也曾翻来覆去的看过。这个”翻来覆去“能够说是十分的活泼形象,因为我不仅从前往后看,也从后往前看了这本书。然而,这并不是一个值得自豪的过程,因为我之前看的时候常常被卡住(俗称看不懂),导致我中途放弃。再次拾起的时候为了多一些新鲜感,就尝试从后往前看,事实证明成果仍旧不佳。往年我又拿起这本书(生存所迫),这次浏览下来,相比之前要晦涩许多,可能是因为有了一些工作教训吧(社会的毒打)。感觉这本书难以保持浏览次要有几个几个起因:
- 对计算机基本功有一些要求
这本书其实对于高级开发者来说,是不倡议浏览的,因为它默认读者曾经理解计算机领域的很多基础知识,包含操作系统、数据结构,编译原理等,并要求有一些源码浏览能力(这里的源码还不是 JAVA,而是 C 或者汇编语言)。如果对这些没有初步的意识,就很容易被满书的专业术语带跑(传说中的意识每一个字,却不晓得这句话在说啥,这种感觉,我懂~),并最终从入门走向放弃。 - 真正开发过程中遇到的机会不多
JVM 对于 JAVA 工程师就像是灶台之于厨师(咱们对 JVM 的理解可能还不如厨师)。谁都晓得去用它,它也很少出问题,然而一旦出问题了,咱们就开始傻眼了。而这也导致我最后浏览这本书的时候对很多例子难以感同身受,再加上实用机会不多,也无奈活学活用。然而一旦走到工作中,JVM 出问题的概率就减少了(尽管仍然不多)。当零碎频繁的报警内存使用率过高或 OOM 异样时,咱们兴许就须要掏出这本书,给繁忙的零碎降降温。 - 不够谋求极致
其实书中让我触动最深的是作者记录了对 Eclipse 虚拟机优化的实战。正如上文所说,应用 JVM 优化的场景并不多,然而反过来想,这是否是因为咱们不够谋求极致。代码的编译速度是否还能晋升?零碎的启动耗时是否还能缩短?Full GC 频率是否还能升高?作者过后是用的 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 就是一种编译型语言。
然而,JAVA 和传统的编译型语言如 C 相比还多了解释的步骤,它的编译产物并非可执行文件,而是字节码文件(.class 文件),再通过 JVM 将字节码文件解释为可执行的机器指令进行运行。正是这一步使得 Java 成为一个反对跨平台运行的语言,因为只须要编译一次,其编译产物就能够在各个平台上运行。当然,这也意味着 JVM 是须要针对不同的平台进行定制开发的。
JVM 运行时数据区域
在介绍完 Java 从代码到运行所经验的过程,咱们理解了 JVM 在整个生命周期中负责将.class 文件解释成机器指令并执行。既然它作为中间商承载了程序的运行,同样的它就须要和计算机的各个组件进行交互并治理。而本文就将介绍 JVM 是如何进行内存区域的划分和治理的。
如下图所示,JVM 将划分失去的内存依照存储的数据类型进一步辨别,并划分出如下几个区域:程序计数器,Java 虚拟机栈,本地办法栈,Java 堆和办法区。
这里的每一个区域存储着不同类型的数据,并且依据数据的个性会采取不同的内存回收机制。(内存回收不是本节的内容,然而理解区域的个性将无效的帮忙了解为何采取相应的内存回收策略)
程序计数器
程序计数器并不是 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 堆。内存回收治理时同样会对这个区域进行内存回收。
办法区
办法区同样是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、动态变量、即时编译后的代码等数据。能够看到这一类型的数据通常很少变动,因而有些虚构机会将其视为 JVM 永恒代进行治理。而这一块的内存回收就意味着对常量池的回收和对类型的卸载(实时上类型卸载的条件时十分高的,因而大多数类不会被卸载,这对于那些喜爱应用动静代理的我的项目来说这一块内存很可能呈现内存溢出)
这里再解释一下上文提到的 即时编译后的代码 这个概念。正如上文所说,Java 的运行过程是通过 JVM 解释字节码来实现的。然而,每运行一行代码都须要先解释后执行,不免对性能产生影响。于是 JVM 外部做了一些优化,对于频繁执行的代码块会将其转换为机器指令并保留,这样下次执行时就不须要再进行解释,极大的进步了性能。这个过程被称为及时编译(Just In Time Compiler),而 JIT 编译后的机器指令就会被存储在办法区。
总结
这里对 JVM 内存治理时各个区域的性能和可能呈现的异样进行了总结。