前言
总计JVM的一个体系架构
为了更好的理解整个架构、更好的理解代码以及面试的必备知识点梳理
整个框架的体系结构如下:
(灰色区域为线程私有,不存在垃圾回收;深色区域为共享区域,存在垃圾回收)
-
源代码文件(.java后缀)被Java编译器编译为字节码文件(.class后缀)
-
由JVM的类加载器加载各个类的字节码文件,交由JVM执行引擎执行
在JVM执行过程中,会存储程序执行期间需要用到的数据和相关信息(运行时数据区)
其运行时的时区包括如下内容:
1. 类加载子系统
这部分内容有个重要的概念:
Java虚拟机对class文件采用的是按需加载的方式,也就是说当需要使用该类时才会将它的class文件加载到内存生成class对象。
加载某个类的class文件时,Java虚拟机采用的是双亲委派模式,即把请求交由父类处理,它是一种任务委派模式。
具体双亲委派的机制是:
-
如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行;
-
如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的启动类加载器;
-
如果父类加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派模式。
2. 程序计数器
类似PC寄存器用来存储指向下一条指令的地址,也即将要执行的指令代码。由执行引擎读取下一条指令
在JVM规范中,每个线程都有它自己的程序计数器,是线程私有的,生命周期与线程的生命周期保持一致
任何时间一个线程都只有一个方法在执行,也就是所谓的当前方法。程序计数器会存储当前线程正在执行的Java方法的TVM指令地址,如果是在执行native方法,则是未指定值(undefned) 。
字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。
3. java栈
栈是运行时的单位,而堆是存储的单位
-
栈解决程序的运行问题,即程序如何执行,或者说如何处理数据
-
堆解决的是数据存储的问题,即数据怎么放、放在哪儿。
Java虚拟机栈(Java virtual Machine stack),早期也叫Java栈。每个线程在创建时都会创建一个虚拟机栈,其内部保存一个个的栈帧(stack Frame),对应着一次次的Java方法调用。
这是是线程私有的,其生命周期和线程一致
主要的作用是管Java程序的运行,它保存方法的局部变量、部分结果,并参与方法的调用和返回
栈的特点(优点)
- 栈是一种快速有效的分配存储方式,访问速度仅次于程序计数器。程序计数器的速度最快
JVM直接对Java栈的操作只有两个:1.每个方法执行,伴随着进栈(入栈、压栈)。2.执行结束后的出栈工作 - 对于栈来说不存在垃圾回收问题
栈的存储时:
·每个线释都有自己的栈,栈中的数据都是以栈帧(stack Frame)的格式存在
在这个线程上正在执行的每个方法都各自对应一个栈帧(stack Frame)。
·栈帧是一个内存区块,是一个数据集,维系着方法执行过程中的各种数据信息。
每个栈帧中存储着:
- 局部变量表(Local variables)
- 操作数栈(operand stack)(或表达式栈)
- 动态链接(Dynamic Linking)(或指向运行时常量池的方法引用)
- 方法返回地址(Return Address)(或方法正常退出或者异常退出的定义)
- 一些附加信息
局部变量表
定义为一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量这些数据类型
包括各类基本数据类型、对象引用(reference) ,以及returnAddress类型。
由于局部变量表是建立在线程的栈上,是线程的私有数据,因此不存在数据安全问题
采用slot(卡槽)的索引访问
操作数栈
每一个独立的栈帧中除了包含局部变量表以外,还包含一个后进先出(Last-In-First-out)的操作数栈,也可以称之为表达式栈(Expression stack) 。
操作数栈,在方法执行过程中,根据字节码指令,往栈中写入数据或提取数据,即入栈(push)/出栈(pop) .
某些字节码指令将值压入操作数栈,其余的字节码指令将操作数取出栈。使用它们后再把结果压入栈。
比如:执行复制、交换、求和等操作
操作数栈,主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间。
主要通过出栈和入栈的访问
动态链接
每一个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用。包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接( Dynamic Linking)。比如: invokedynamic指令
在Java源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用( symbolic Reference)保存在class文件的常量池里。比如:描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用来表示的,那么动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用。
常量池主要是提供符号和常量便于指令的识别
4. 本地方法栈
本地方法栈与Java栈的作用和原理非常相似。
区别是Java栈是为执行Java方法的调用,而本地方法栈则是为管理本地方法(Native Method)调用的。在JVM规范中,并没有对本地方发展的具体实现方法以及数据结构作强制规定,虚拟机可以自由实现它。在HotSopt虚拟机中直接就把本地方法栈和Java栈合二为一。
- 如果线程请求分配的栈容量超过本地方法栈允许的最大容量,Java虚拟机将会抛出一个 stackoverflowError异常。
- 如果本地方法栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的本地方法栈,那么Java虚拟机将会抛出一个outofMemoryError异常。
5. 堆
一个JVM实例只存在一个堆内存,堆也是Java内存管理的核心区域。Java堆区在JVM启动的时候即被创建,其空间大小也就确定了。是JVM管理的最大一块内存空间。
堆内存的大小是可以调节的。
数组和对象可能永远不会存储在栈上,因为栈帧中保存引用,这个引用指向对象或者数组在堆中的位置。
在方法结束后,堆中的对象不会马上被移除,仅仅在垃圾收集的时候才会被移除。所以堆,是Gc ( Garbage collection,垃圾收集器)执行垃圾回收的重点区域。
存储在VM中的Java对象可以被划分为两类:
- 一类是生命周期较短的瞬时对象,这类对象的创建和消亡都非常迅速
- 另外一类对象的生命周期却非常长,在某些极端的情况下还能够与JVM的生命周期保持一致。
Java堆区进一步细分的话,可以划分为年轻代(YoungGen)和老年代(oldGen)
其中年轻代又可以划分为Eden空间、Survivor0空间和survivor1空间(有时也叫做from区、to区) 。
几乎所有的Java对象都是在Eden区被new出来的。
绝大部分的Java对象的销毁都在新生代进行了。
对象分配的一个过程:
- new的对象先放伊甸园区。此区有大小限制。
- 当伊甸园的空间填满时,程序又需要创建对象,JVM的垃圾回收器将对伊甸园区进行垃圾回收(Minor GC),将伊甸园区中的不再被其他对象所引用的对象进行销毁。再加载新的对象放到伊甸园区
- 将伊甸园中的剩余对象移动到幸存者0区。
- 如果再次触发垃圾回收,此时上次幸存下来的放到幸存者0区的,如果没有回收,就会放到幸存者1区。
- 如果再次经历垃圾回收,此时会重新放回幸存者0区,接着再去幸存者1区。
- 啥时候能去养老区呢?可以设置次数。默认是15次。
·可以设置参数:-XX:MaxTenuringThreshold=<N>
进行设置。 - 在养老区,相对悠闲。当养老区内存不足时,再次触发cC: Major Gc,进行养老区的内存清理。
- 若养老区执行了Major Gc之后发现依然无法进行对象的保存,就会产生ooM异常
补充oom异常
堆内存满了,产生错误
由于99%的对象都是临时对象,所以oom错误发生的机率非常低
当堆内存满了会进行垃圾回收等操作
JVM在进行GC时,并非每次都对上面三个内存(新生代、老年代;方法区)区域一起回收的,大部分时候回收的都是指新生代。
针对HotSpot VM的实现,它里面的GC按照回收区域又分为两大种类型:一种是部分收集(Partial GC) ,一种是整堆收集((Full GC), 部分收集:不是完整收集整个Java堆的垃圾收集。
其中又分为
-
新生代收集(Minor GC / Young GC) :只是新生代的垃圾收集
-
老年代收集(Major GC / old GC) :只是老年代的垃圾收集。
目前,只有cMS Gc会有单独收集老年代的行为。
注意,很多时候Major GC会和Full Gc混淆使用,需要具体分辨是老年代
回收还是整堆回收。 -
混合收集(Mixed Gc):收集整个新生代以及部分老年代的垃圾收集。
目前,只有G1 GC会有这种行为
整堆收集(Fu11 GC):收集整个java堆和方法区的垃圾收集。
年轻代Gc(Minor GC)触发机制:
- 当年轻代空间不足时,就会触发Minor Gc,这里的年轻代满指的是Eden代满,Survivor满不会引发Gc。(每次Minor GC会清理年轻代的内存。),非常频繁,一般回收速度也比较快。这一定义既清晰又易于理解。
- Minor GC会引发STw,暂停其它用户的线程,等垃圾回收结束,用户线程才恢复运行。
老年代Gc (Major GC/Full GC)触发机制:
- 出现了Major GC,经常会伴随至少一次的Minor Gc(但非绝对的,在Parallelscavenge收集器的收集策略里就有直接进行Major Gc的策略选择过程)。也就是在老年代空间不足时,会先尝试触发Minor GC。如果之后空间还不足则触发Major Gc
- Major GC的速度一般会比Minor GC慢10倍以上,STw的时间更长。如果Major GC 后,内存还不足,就报OOM了。
之所以堆要分区:唯一理由就是优化cc性能。
如果没有分代,那所有的对象都在一块,就如同把一个学校的人都关在一个教室。GC的时候要找到哪些对象没用,这样就会对堆的所有区域进行扫描。而很多对象都是朝生夕死的,如果分代的话,把新创建的对象放到某一地方,当GC的时候先把这块存储“朝生夕死”对象的区域进行回收,这样就会腾出很大的空间出来。
具体内存的分配策略是:
如果对象在Eden出生并经过第一次MinorGC后仍然存活,并且能被survivor容纳的话,将被移动到survivor 空间中,并将对象年龄设为1 。对象在survivor区中每熬过一次MinorGc ,年龄就增加1 岁,当它的年龄增加到一定程度(默认为15 岁,其实每个JVM、每个cc都有所不同)时,就会被晋升到老年代中。
6. 方法区
它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等。
-
方法区(Method Area)与Java堆一样,是各个线程共享的内存区域。
-
方法区在JVM启动的时候被创建,并且它的实际的物理内存空间中和Java堆区一样都可以是不连续的。
-
方法区的大小,跟堆空间一样,可以选择固定大小或者可扩展。
-
关闭JVM就会释放这个区域的内存。
在jdk7及以前,习惯上把方法区,称为永久代。jdk8开始,使用元空间取代了永久代。
元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代最大的区别在于:元空间不在虚拟机设置的内存中,而是使用本地内存。
永久代、元空间二者并不只是名字变了,内部结构也调整了。
如何解决OOM的问题:
- 要解决OOM异常或heap space的异常,一般的手段是首先通过内存映像分析工具(如Eclipse Memory Analyzer)对dump出来的堆转储快照进行分析,重点是确认内存中的对象是否是必要的,也就是要先分清楚到底是出现了内存泄漏(MemoryLeak)还是内存溢出(Memory Overflow) 。
- 如果是内存泄漏,可进一步通过工具查看泄漏对象到Gc Roots 的引用链。于是就能找到泄漏对象是通过怎样的路径与GC Roots相关联并导致垃圾收集器无法自动回收它们的。掌握了泄漏对象的类型信息,以及GC Roots引用链的信息,就可以比较准确地定位出泄漏代码的位置。
- 如果不存在内存泄漏,换句话说就是内存中的对象确实都还必须存活着,那就应当检查虚拟机的堆参数(-Xmx与-xms),与机器物理内存对比看是否还可以调大,从代码上检查是否存在某些对象生命周期过长、持有状态时间过长的情况,尝试减少程序运行期的内存消耗。