JVM内存结构
Java 虚拟机定义了若干种程序运行期间会使用到的运行时数据区,其中有一些会随着虚拟机启动而创建,随着虚拟机退出而销毁。另外一些则是与线程对应,这些与线程对应的数据区域会随着线程开始和结束而创建和销毁。
- 线程私有的:程序计数器、栈、本地方法栈;
- 线程共享的:堆、方法区(永久代或元空间、代码缓存)。
程序计数器
程序计数器是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。
如一段程序:
1 | public static void main(String[] args) { |
在idea上使用jclasslib
反编译查看字节码为:
注意:
- 是一块很小的存储空间,运行速度也最快;
- 在JVM规范中,每个线程都有它自己的程序计数器,是线程私有的,生命周期与线程的生命周期一致;
- 任何时间一个线程都只有一个方法在执行,也就是所谓的当前方法。如果当前线程正在执行的是 Java 方法,程序计数器记录的是 JVM 字节码指令地址,如果是执行 natice 方法,则是未指定值(undefined);
- 它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成;
- 字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令;
- 它是唯一一个在 JVM 规范中没有规定任何
OutOfMemoryError
情况的区域。
使用PC寄存器存储字节码指令地址有什么用呢?为什么使用PC寄存器记录当前线程的执行地址呢?
CPU需要不停的切换各个线程,这时候切换回来以后,就得知道接着从哪开始继续执行。JVM的字节码解释器就需要通过改变PC寄存器的值来明确下一条应该执行什么样的字节码指令。
虚拟机栈
虚拟机栈(Java Virtual Machine Stack
)描述了Java程序运行执行的内存模型,以栈帧为最小单位,对应着每个方法的调用,还包含了局部变量表,操作数栈,动态链接,方法返回地址等信息。
栈遵循先进后出的原则,调用方法的时候入栈,正在执行的方法在栈顶,执行完成后出栈。执行引擎只对当前栈帧的指令进行操作,如果调用其他方法,则创建新的栈帧,不同的线程独享,不允许线程之间相互引用其他线程的栈帧。
栈帧的组成
栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构,包含了局部变量表,操作数栈,动态连接和方法返回地址等信息。
局部变量表
局部变量表是变量的存储空间,用于存放方法参数和方法内部的局部变量,编译的时候已经确定了局部变量表的容量;
存储单位以变量槽(slot)的形式,32位虚拟机一个slot可以存放32位以内的数据类型,64位长度则要存放在两个槽内。
Slot是可以重用的,当Slot中的变量超出了作用域,那么下一次分配Slot的时候,将会覆盖原来的数据。Slot对对象的引用会影响GC(要是被引用,将不会被回收)。 系统不会为局部变量赋予初始值(实例变量和类变量都会被赋予初始值)。
系统不会为局部变量赋予初始值(实例变量和类变量都会被赋予初始值)。
操作数栈
作用:主要用于保存计算过程的中间结果,或者临时变量的存储空间,同样在编译时期确定容量。
在方法的执行过程中,根据字节码指令,往操作数栈中写入数据或读取数据。
动态链接
指向运行时常量池的方法引用。
- 每一个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用。包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接(Dynamic Linking)。
- 在 Java 源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用(Symbolic Reference)保存在 Class 文件的常量池中。比如:描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用来表示的,那么动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用。
方法返回地址
- 执行引擎遇到任意一个方法返回的字节码指令:传递给上层的方法调用者,是否有返回值和返回值类型将根据遇到何种方法来返回指令决定,这种退出的方法称为正常完成出口。
- 方法执行过程中遇到异常: 无论是java虚拟机内部产生的异常还是代码中thtrow出的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出的方式称为异常完成出口,一个方法若使用该方式退出,是不会给上层调用者任何返回值的。无论使用那种方式退出方法,都要返回到方法被调用的位置,程序才能继续执行。方法返回时可能会在栈帧中保存一些信息,用来恢复上层方法的执行状态。一般方法正常退出的时候,调用者的pc计数器的值可以作为返回地址,帧栈中很有可能会保存这个计数器的值作为返回地址。方法退出的过程就是栈帧在虚拟机栈上的出栈过程,因此退出时的操作可能有:恢复上层方法的局部变量表和操作数栈,把返回值压入调用者的操作数栈每条整pc计数器的值指向调用该方法的后一条指令。
本地方法栈
本地方法接口
本地方法接口( Native Method) 就是一个 Java 调用非 Java 代码的接口,其他语言提供具体的实现。
为什么存在?
有些场景Java实现起来不方便或者效率较低,比如与操作系统的交互,Unsafe类下的一些方法。
本地方法栈
本地方法栈用于管理本地方法的调用。在 Hotspot JVM 中,直接将本地方栈和虚拟机栈合二为一。
堆内存
栈是运行时的单位,而堆是存储的单位。
栈解决程序的运行问题,即程序如何执行,或者说如何处理数据。堆解决的是数据存储的问题,即数据怎么放、放在哪。
堆内存的划分
为了进行高效的垃圾回收,虚拟机把堆内存逻辑上划分成三块区域(分代的唯一理由就是优化 GC 性能):
- 新生带(年轻代):新对象和没达到一定年龄的对象都在新生代,分为伊甸园(Eden Memory)和两个幸存区(Survivor Memory,from/to或者s0/s1)默认比例为
8:1:1
,可以通过-XX:SurvivorRatio
参数进行设置;对象在伊甸园产生,然后经过Minor GC回收后,放到一个幸存区,同样幸存区经过回收后,把存活的对象放到另一个幸存区,保证一个幸存区为空,经过15次回收后,还存活的对象被放到老年代里; - 老年代(养老区):被长时间使用的对象,老年代的内存空间应该要比年轻代更大,使用Major GC进行回收,通常耗费时间较大,大对象直接进入老年代(大对象是指需要大量连续内存空间的对象)。
- 元空间(JDK1.8 之前叫永久代):像一些方法中的操作临时对象等,JDK1.8 之前是占用 JVM 内存,JDK1.8 之后直接使用物理内存。
堆内存的设置
堆内存的设置也是常见的性能调优方向。
-Xmx
用来表示堆的起始内存大小,相当于-XX:InitialHeapSize
,默认为电脑内存的1/64
;-Xms
用来表示堆的最大内存,相当于-XX:MaxHeapSize
,默认为电脑内存的1/4
。–XX:NewRatio
新生代和老年代的比例,默认为1:2
;-XX:SurvivorRatio
表示Eden:form:to
的比例,默认为8:1:1
;-XX:+UseAdaptiveSizePolicy
默认开启,JVM 会动态调整 JVM 堆中各个区域的大小以及进入老年代的年龄,如果堆的划分不明确,不要关闭!
如果内存超过设定的最大值,则会抛出OutOfMemoryError
异常。通常将两者大小设置成一样。
对象的分配及生命周期
为对象分配内存是一件非常严谨和复杂的任务,JVM 的设计者们不仅需要考虑内存如何分配、在哪里分配等问题,并且由于内存分配算法和内存回收算法密切相关,所以还需要考虑 GC 执行完内存回收后是否会在内存空间中产生内存碎片。
new
的对象先放在伊甸园区,此区有大小限制- 当伊甸园的空间填满时,程序又需要创建对象,JVM 的垃圾回收器将对伊甸园区进行垃圾回收(Minor GC),将伊甸园区中的不再被其他对象所引用的对象进行销毁。再加载新的对象放到伊甸园区
- 然后将伊甸园中的剩余对象移动到幸存者s0区
- 如果再次触发垃圾回收,此时上次幸存下来的放到幸存者s0 区,如果没有回收,就会放到幸存者s1 区
- 如果再次经历垃圾回收,此时会重新放回幸存者 s0 区,接着再去幸存者 s1 区
- 当对象进行了 15 次回收后依然存活,则被放到养老区中,当养老区内存不足时,再次触发 Major GC,进行养老区的内存清理
- 若养老区执行了 Major GC 之后发现依然无法进行对象的保存,就会产生 OOM 异常
垃圾回收
- Minor GC/Young GC:部分收集,只针对新生代(1个Eden区和2个survivor区)的垃圾收集,触发条件为Eden区空间耗尽,采用标记复制的方法,对Eden区和from存活区的对象进行遍历,当对象存活,复制到to存活区,然后交换from和to指针。如果存活区的对象被来回复制了15次,则将该对象移动到老年代,如果to区不能存放存活的对象,则多出的对象被放到老年区,被称为过早提升,如果老年代已经满了,则进行一次Full GC,如果还没空间则抛出异常,被称为提升失败。
- Major GC/Old GC:老年代的收集,通常和Full GC混合使用,需要具体分辨是老年代回收还是整堆回收。
- Full GC:收集整个 Java 堆和方法区的垃圾,通常所需空间不够放下新生对象的时候触发,或者调用
System.gc()
主动触发。 - Mixed GC:G1 GC支持,收集整个新生代和部分老年代。
逃逸分析
逃逸分析(Escape Analysis)是目前 Java 虚拟机中比较前沿的优化技术。这是一种可以有效减少 Java 程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。通过逃逸分析,Java Hotspot 编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。
分析对象动态作用域:
- 当一个对象在方法中被定义后,对象只在方法内部使用,则认为没有发生逃逸。
- 当一个对象在方法中被定义后,它被外部方法所引用,则认为发生逃逸。例如作为调用参数传递到其他地方中,称为方法逃逸。
使用逃逸分析,编译器会对代码进行优化:
- 栈上分配:将堆分配转化为栈分配。如果一个对象在子程序中被分配,要使指向该对象的指针永远不会逃逸,对象可能是栈分配的候选,而不是堆分配。
- 同步省略:如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步。
- 分离对象或标量替换:有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而存储在 CPU 寄存器。
主要问题是无法保证逃逸分析的性能消耗一定能高于他的消耗。虽然经过逃逸分析可以做标量替换、栈上分配、和锁消除。但是逃逸分析自身也是需要进行一系列复杂的分析的,这其实也是一个相对耗时的过程。
一个极端的例子,就是经过逃逸分析之后,发现没有一个对象是不逃逸的。那这个逃逸分析的过程就白白浪费掉了。
虽然这项技术并不十分成熟,但是他也是即时编译器优化技术中一个十分重要的手段。
方法区
方法区是JVM的一个概念,主要存放类型信息、域信息、方法信息、JIT代码缓存、运行时常量池。
Jdk1.7的时候,方法区的具体实现是永久代,静态变量和字符串常量池保存到堆中;
Jdk1.8的时候,方法区的具体实现是元空间,在堆外内存,静态变量和字符串常量池也保存在堆中。
方法区的垃圾收集主要回收两部分内容:常量池中废弃的常量和不再使用的类型。
HotSpot 虚拟机对常量池的回收策略是很明确的,只要常量池中的常量没有被任何地方引用,就可以被回收
判定一个类型是否属于“不再被使用的类”,需要同时满足三个条件:
- 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类及其任何派生子类的实例
- 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如 OSGi、JSP 的重加载等,否则通常很难达成
- 该类对应的
java.lang.Class
对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法
Java 虚拟机被允许堆满足上述三个条件的无用类进行回收,这里说的仅仅是“被允许”,而并不是和对象一样,不使用了就必然会回收。是否对类进行回收,HotSpot 虚拟机提供了-Xnoclassgc
参数进行控制,还可以使用-verbose:class
以及-XX:+TraceClassLoading
、-XX:+TraceClassUnLoading
查看类加载和卸载信息。
其他
为什么要分代?
如果不能分代,GC不可能避免的的要面临stop-the-word问题,那么如果说收集整个GC的耗时太长,那么不如先回收一部分。根据weak generational hypothesis(弱代假设),大部分对象的都会夭折,如果没有夭折那么就会活得很久。那么我们设计出新生代和老年代来实现之前所说的收集一部分,以期降低GC收集的时间。
为什么要有两个Survivor区域:From 和 To?
先看下YGC的过程,每一次YGC是将 Eden和 From区域存活的对象复制到To区域,之后交换From和To的角色,保证Eden和From总是空着的,有些对象在From和To中循环往复了16次之后,就进入了老年代。
首先明确一点,因为新生代大多夭折,也就是比较适合复制算法
复制算法将内存划分为两个区间,在任意时间点,所有动态分配的对象都只能分配在其中一个区间(称为活动区间),而另外一个区间(称为空闲区间)则是空闲的。
当有效内存空间耗尽时,JVM将暂停程序运行,开启复制算法GC线程。接下来GC线程会将活动区间内的存活对象,全部复制到空闲区间,且严格按照内存地址依次排列,与此同时,GC线程将更新存活对象的内存引用地址指向新的内存地址。
此时,空闲区间已经与活动区间交换,而垃圾对象现在已经全部留在了原来的活动区间,也就是现在的空闲区间。事实上,在活动区间转换为空间区间的同时,垃圾对象已经被一次性全部回收。
如果选择了复制算法:
- 必然的至少要有两块内存,From区和To区
- 一开始,对象只在From区分配,To区是空闲的。GC将存活的对象从From区域复制到To,清空掉From区,这个时候,交换指针,交换之后,From区里面还是有存活的对象,To区空闲从吞吐量、内存的分配效率和内存碎片化这几个方面来看都要由于标记-清除/整理。但是堆的使用效率就相对而言比较低。如果是按照五五分成,总有一般是不能用的。那为什么不干脆八二分?那么问题来了,某次触发YGC,需要把From(8分)中的存活对象(假设占据1分)复制到To空间,这个时候,交换指针之后,From空间还剩(1分)可以使用,相信很快就会再次触发Ygc,STW发生频次提高。
- 为了解决内存分配空间它的capacity并不是恒定的这个问题,HotSpot引入了Eden区作为对复制算法的优化,其实eden区域可以看作是From和To区域的缓冲和共享区域。