《一文就让你精通JVM》
网上有关JVM的知识贴多如牛毛,其中有纷杂的零碎知识贴,也有整理优秀的长贴。信息量非常充分。
但作为复习或整理JVM知识的而言,还可以有更好的学习用户体验和高效的方式。因此,就想尝试写一篇有关JVM知识点的“秘籍”,让初学者仅读此文就能快速精通JVM的知识脉络以及关键知识,也能让复习着快速反查知识和经验之谈。
黄老师
1、背景知识铺垫
1.1、JRE、JVM、JDK
先讲讲JVM、JRE、JDK是什么
- JVM (Java Virtual Machine)
- JVM有自己的规范,所有的JVM版本都必须按此规范实现。
- JVM是一个抽象、虚拟、不物理存在的"机器"。是通过在真实的计算机上仿真模拟各种计算机功能来实现的。JVM有自己完善的硬件架构,如处理器、堆栈、寄存器等,还具有相应的指令系统。
- JVM负责执行Java代码
- JRE (Java Runtime Environment)
- JRE包含JVM
- JRE是一个运行环境容器,提供诸多Java运行需要的libs。包括:标准、非标准的Java组件;Java规范要求、Java规范未要求的组件。
- JRE为JVM服务
- rt.jar(contains: lang, util, awt, swing, math, runtime libraries)
- JDK (Java Development Kit)
- 从Kit单词上可预知JDK会包含很多东西,的确,JDK包含:JVM、JRE,以及Java语言和工具包。
- JDK比JRE多的部分:Development, debugging tools
- Oracle 官方Java概念图
1.2、HotSpot Client/Server模式
为什么要铺垫下 HotSpot Client/Server模式?因为后续GC章节默认收集器在不同模式下不同。
HotSpot JVM具有两种模式:Client模式和Server模式。可以理解为针对不同的硬件环境和软件场景做的JVM优化版本。
- Java HotSpot Client VM(-client):轻量级。为在客户端环境中减少启动时间而优化,使用的策略和功能都是较简单版本。
- Java HotSpot Server VM(-server):重量级。为在服务器环境中最大化程序执行速度而设计,使用的策略和功能是为了最大化的发挥硬件优势,提升吞吐量。《《《 作为服务端Java开发,大多数情况下默认Server模式。
HotSpot的安装的模式,32位的hotspot都是client模式;64位的都是server模式的。
可通过java -version查看
若想要修改模式,则需变更JVM的配置文件。32位的虚拟机在 “%JAVA_HOME%/jre/lib/i386/jvm.cfg”;64位的虚拟机在“%JAVA_HOME%/jre/lib/amd64/jvm.cfg”;
2、JVM体系结构
2.1、Class文件
- Class 文件是一种特定的二进制文件格式的文件。格式紧凑,包含了JVM指令集和符号表以及若干其他辅助信息,其编码结构风格被称为“字节码”。
- 在JVM体系里,不同的硬件(主要是CPU)和操作系统环境下的JVM是不同版本的实现,而Java语言的平台无关性,主要体现在“Class文件”上。Java代码一次编译成Class文件,可以在不同体系的JVM版本下运行。
- 其他语言只要其代码能被编译成符合Class文件规范的Class文件,就能在JVM上运行。
2.2、内存区 之 虚拟机栈区
见上图《JVM体系结构图》
在JVM里有一块专门为Java线程分配栈空间的内存区,叫"虚拟机栈区"。
每个Java线程创建时都会分配一个“虚拟机栈”,此虚拟机栈的生命周期与其所绑定的线程生命周期一致。
而当线程执行Java代码时,JVM会为每个Java方法创建一个固定结构的内存模型:栈帧。
- 此“栈帧”结构是线程虚拟机栈入栈/出栈操作的基本单位(入栈/出栈的时机对应Java方法的调用和返回)
- 栈帧是用于支持JVM进行方法调用和方法执行的数据结构
2.2.1、栈帧(Stack Frame)结构
-
局部变量表: 编译期确定局部变量表大小。一组变量存储空间, 容量以slot为最小单位,而slot的大小随硬件体系而定,以此来适应硬件体系的差异。
-
虚拟机规范中未明确指明一个Slot应占用的内存空间大小,只是导向性的说到每个Slot都应该存放一个boolean、byte、char、char、short、int、float、reference或returnAddress类型的数据,这8种数据类型都可以使用32位或更小的物理内存来存放,Slot的长度可以随着处理器、操作系统或虚拟机的不同而发生变化。
-
-
操作栈:编译期确定操作数栈最大深度。是一个后入先出栈(LIFO)。操作数栈可类比CPU的寄存器,协助JVM完成Java方法内的调用传值,计算操作。
-
例如:整数加法的字节码指令iadd再运行的时候操作数栈中最接近栈顶的两个元素已经存入了两个int类型的数值,当执行这个指令时,会将这两个int值出栈并相加,然后将相加的结果入栈。
-
-
动态连接: 指向运行时常量池中该栈帧所属Java方法的引用,这样才能找到真正&完整的代码片段。
-
Class文件中关于方法调用,存的是符号引用。字节码在运行期,需要将符号引用转化为带具体内存地址的代码片段的直接引用。
-
-
方法返回地址:方法退出的过程实际上等同于把当前栈帧出栈,并恢复上层方法的局部变量表和操作数栈,把返回值(如果有)压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令等。
- 方法返回地址里一般存的是调用者当时的PC计数器的值。当方法正常返回时,就返回这个地址。而当方法异常退出时,返回地址要通过异常处理器表来确定。
-
额外附加信息:预留的扩展区域,由JVM实现方按需使用。
2.3、内存区 之 本地方法栈区
本地方法栈是JVM为Native方法提供的内存空间。
本地方法栈里的基本元素结构取决于本地方法接口的具体实现机制。若其使用C连接模型,那么本地方法栈就是C栈。
Java方法栈和本地方法栈之间可以灵活交叉切换。
有些虚拟机的实现直接把本地方法栈和虚拟机栈合二为一,比如典型的Sun HotSpot虚拟机。
2.4、内存区 之 堆内存区
Java堆,是JVM管理的最大的一块内存,也是GC的主战场。里面存放的是几乎所有的对象实例和数组数据。
JIT编译器有栈上分配、标量替换等优化技术的实现导致部分对象实例数据不存在Java堆,而是栈内存。
-
从内存回收角度:Java堆为分代回收机制,分为年轻代和老年代。这样划分的好处是为了更快的回收内存(每次回收的内存范围相对小)
-
从内存分配角度:Java堆可以划分出线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB);这样划分的好处是为了更快的分配内存;
-
关联知识点:
1、标量替换:允许将对象打散分配在栈上,比如若一个对象拥有两个字段,会将这两个 字段视作局部变量进行分配。
-
2.4.1、各内存区默认配比
- 年轻代:老年代 默认 1:2(将堆空间分为3份)
- Eden :from :to 默认 8:1:1
相关JVM参数
- -Xms:初始堆大小。默认物理内存的1/64。
- -Xmx:最大堆大小。默认物理内存的1/4。
- -Xmn:年轻代大小
- -XX:NewRatio:年轻代与老年代的比值
- -XX:SurvivorRatio:Eden区域Survivor区的大小比值。默认8:1:1。
2.4.2、TLAB
全称 Thread Local Allocation Buffer,线程本地分配缓冲区。
默认是开始的。JVM命令 -XX:+UseTLAB。
由于堆是全局共享的,因此存在同一时间会有多个线程在堆上申请空间的并发情况。为保证堆内存分配操作的原子性,JVM采用“CAS+失败重试”的方式,但这种方式在并发竞争激烈的情况下效率会进一步下降。
因此,JVM额外设计了TLAB 来避免多线程分配对象内存时的冲突处理。
大致原理:
- JVM在内存年轻代Eden Space中开辟了一小块线程私有的区域,称作TLAB。默认设定为占用Eden Space的1%。
- Java中每个线程都会有自己的缓冲区称作TLAB。
- 由于TLAB是线程私有的,所以内存分配没有锁开销,效率高。
- 在Java程序中很多对象都是小对象且用过即丢,它们不存在线程共享也适合被快速GC,所以对于小对象通常JVM会优先分配在TLAB上。
故,对象仍旧是在堆上被分配的,只不过分配的方式变了而已。
2.4.3、逃逸分析
关联知识点:逃逸分析只在JVM运行在server模式时才能启用。
逃逸分析 是一种可以有效减少Java 中堆内存分配压力的分析算法。JVM默认开启。
通过逃逸分析,Java Hotspot编译器能够分析出一个即将新创建对象的引用的使用范围,从而决定通过哪种方式分配对象内存(栈上标量替换;TLAB分配;堆分配;)。
2.4.4、对象内存分配的两种方法
为对象分配内存空间的任务等同于把一块确定大小的内存从Java堆中划分出来。
- 指针碰撞 (Serial、ParNew等带Compact过程的收集器)
假设Java堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离,这种分配方式称为“指针碰撞”(Bump the Pointer)。 - 空闲列表 (CMS这种基于Mark-Sweep算法的收集器)
如果Java堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为“空闲列表”(Free List)。
2.4.5、对象实例的具体结构
- 对于填充数据不是一定存在的,仅仅是为了字节对齐。
2.5、内存区 之 方法区
当JVM使用类装载器装载某个类时,它首先要定位对应的class文件,然后读入这个class文件,最后,JVM提取该文件的内容信息,并将这些信息存储到方法区,最后返回一个class实例。
- 注:方法区 是 JVM规范的一部分,并不是实际的实现。实际实现有JDK7以前的永久代,以及JDK8的元空间。
方法区的特点:
1、方法区是线程安全的。假如同时有两个线程都企图访问方法区中的同一个类,而这个类还没有被装入JVM,那么只允许一个线程去装载它,而其它线程必须等待。
2、方法区的大小不是固定的,JVM可根据应用运行期的需要动态调整。其内存也不一定是连续的。
3、方法区也可被垃圾收集(GC),当某个类不在被使用(不可触及)时,JVM将卸载这个类,进行垃圾收集。
方法区主要存放的是已被虚拟机加载的类信息、常量、静态变量、编译器编译后的代码等数据。
2.5.1、静态常量池:
即class文件中的常量池,是文件级、静态级的。class文件中的常量池不仅仅包含字符串(数字)字面量,还包含类、方法的信息,占用class文件绝大部分空间。
2.5.2、运行时常量池:
运行时常量池是方法区的一部分,可以理解为一块专门存放常量的内存。包括装载class文件而放入的常量,也包括代码运行时动态生成的常量(String类的intern()方法)。
静态常量池和运行时常量池的差别在于,一个是存在文件中,是“静”的,一个是存在内存中,是“动”的。
而我们常说的常量池,一般是指在方法区中的动态常量池。
2.6、内存区 之 堆外内存区
讲堆外内存前,首先带大家从操作系统视角看堆外内存所处的位置。相信看了直观的图示后能立马有清晰的记忆和理解。
助读说明:
1、先着眼操作系统进程视角,每个进程都拥有自己独占的进程级内存空间。每个进程看到的机器内存大小是一样的,且内存范围也是一样的。为什么?因为用户态进程看到的是逻辑内存地址,并不是真实的物理内存地址。
2、用户态进程的内存地址都是逻辑地址,用malloc分配,故物理上不连续。
3、堆内存在JVM内。受JVM管理,参与GC。
4、堆外内存处于进程内存空间,但属于JVM堆内存之外。
5、堆外内存不受JVM管理,不参与GC。编码时需要主动释放。
2.6.1、直接内存(Direct Buffer)
Direct Buffer是堆外内存的一种具体类型,常出现于NIO模型中。
NIO模型中为什么要用Direct Buffer?
答:NIO需要进行socket连接的收包和发包,这个操作最终要从操作系统用户态进入到内核态进行操作。而内核态调用要求内存地址必须可靠(即在一次完整调用周期内内存地址是不会变的)。但Java堆里的内存会受GC影响而移动整理,地址会变。故NIO不能用堆里的内存。
即使代码层面非要用Java堆内存区做NIO操作,JVM仍会自动将堆内存对象转换为直接内存对象,然后再进行内核态操作。
故Direct Buffer对于JVM来说有如下优点:
1、对于NIO操作,直接用Direct Buffer比用J