JVM体系及各部分的理解
- JVM的位置:JVM是运行在操作系统上的,它与硬件没有直接的交互,如图所示:
- JVM体系结构图,方法区和堆内存是各线程共享的运行时内存区域
- 类装载器ClassLoader
- 作用:加载class文件,能够识别class文件(因为class文件开头有特定的文件标识cafe babe),它把class文件字节码内容加载到内存中,然后把这些内容(如下图的Car Class,是一个模板)放到运行时数据区。CLassLoader只负责class文件的加载,至于他是否可以运行,由Execution Engine(负责解释命令,提交操作系图所示:
- 虚拟机自带的加载器有三个:启动类加载器:BootStrap(C++编写,我们看不到,显示null) 扩展类加载器:Extension(对应着jdk中的javax Java编写)应用程序加载器:AppClassLoader(Java编写,同时也叫系统类加载器,加载当前应用的classpath的所有类),除此之外,用户还可以自己定义类的加载方式。三者的关系:
- 类加载器的双亲委派机制:
当一个类收到了类加载请求,他首先不会尝试自己去加载这个类,而是把这个请求委派给父类去完成,每一个层次类加载器都是如此,因此所有的加载请求都应该传送到启动类加载其中,只有当父类加载器反馈自己无法完成这个请求的时候(在它的加载路径下没有找到所需加载的Class),子类加载器才会尝试自己去加载。 采用双亲委派的一个好处是比如加载位于 rt.jar 包中的类 java.lang.Object,不管是哪个加载器加载这个类,最终都是委托给顶层的启动类加载器进行加载,这样就保证了使用不同的类加载器最终得到的都是同样一个 Object对象。
- Native Interface本地接口 :
本地接口的作用是融合不同的编程语言为 Java 所用,它的初衷是融合 C/C++程序,Java 诞生的时候是 C/C++横行的时候,要想立足,必须有调用 C/C++程序,于是就在内存中专门开辟了一块区域处理标记为native的代码,它的具体做法是 Native Method Stack中登记 native方法,在Execution Engine 执行时加载native libraies。 目前该方法使用的越来越少了,除非是与硬件有关的应用,比如通过Java程序驱动打印机或者Java系统管理生产设备,在企业级应用中已经比较少见。因为现在的异构领域间的通信很发达,比如可以使用 Socket通信,也可以使用Web Service等等,不多做介绍。
- PC寄存器:
每个线程都有一个程序计数器,是线程私有的,就是一个指针,指向方法区中的方法字节码(用来存储指向下一条指令的地址,也即将要执行的指令代码),由执行引擎读取下一条指令,是一个非常小的内存空间,几乎可以忽略不记。 这块内存区域很小,它是当前线程所执行的字节码的行号指示器,字节码解释器通过改变这个计数器的值来选取下一条需要执行的字节码指令。 如果执行的是一个Native方法,那这个计数器是空的。 用以完成分支、循环、跳转、异常处理、线程恢复等基础功能。不会发生内存溢出(OutOfMemory=OOM)错误
- Method Area 方法区
方法区存储一个类的结构信息,这个结构信息包括:运行时常量池,字段和方法的数据、构造函数、普通方法的字节码内容。但是实例变量存在堆内存中,不是在方法区内。
实际而言,方法区(Method Area)和堆一样,是各个线程共享的内存区域,它用于存储虚拟机加载的:类信息+普通常量+静态常量+编译器编译后的代码等等,虽然JVM规范将方法区描述为堆的一个逻辑部分,但它却还有一个别名叫做Non-Heap(非堆),目的就是要和堆分开。
对于HotSpot虚拟机,很多开发者习惯将方法区称之为“永久代(Parmanent Gen)” ,但严格本质上说两者不同,或者说使用永久代来实现方法区而已,永久代是方法区(相当于是一个接口interface)的一个实现,jdk1.7的版本中,已经将原本放在永久代的字符串常量池移走。
- Stack 栈
- 什么是栈(Java栈):主要是管java程序的运行,线程创建时创建,他的生命周期和线程的生命周期是一样的,栈不存在垃圾回收,它是线程私有的。栈内存中有:8中基本类型变量,对象的引用变量,实例方法
- 栈存储什么:
- 本地变量:输入参数和输出参数以及方法内的变量(调用方法传基本类型时是 拷贝副本,传基本类型除外的都是传地址)
- 栈操作:记录出栈和入栈的操作
- 栈帧数据:包括类文件、方法等
- 栈的运行原理:每个方法执行的同时都会创建一个栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口等信息,每一个方法从调用直至执行完毕的过程,就对应着一个栈帧在虚拟机中入栈到出栈的过程。栈的大小和具体JVM的实现有关,通常在256K~756K之间,约等于1Mb左右。
- 栈+堆+方法区的交互关系:HotSpot(Java jvm)使用指针的方式来访问对象。
栈中的reference指向实例对象的地址,即堆中创建的对象的地址
堆根据方法区的Class模板(类元数据)来创建实例对象
堆体系结构
堆简介
一个JVM实例只存在一个堆内存,堆内存的大小是可以调节的。类加载器读取了类文件后,需要把类、方法、常变量放到堆内存中,保存所有引用类型的真实信息,以方便执行器执行
堆内存的划分情况
- java8之前:逻辑上分为新生区(Young Generation Space)、养老区( Tenure generation space)、永久区(Permanent Space)。其中新生区包括伊甸园区、幸存0区、幸存1区。新生区和养老区内存比是1:2,如图所示:
- 新生代的垃圾回收机制之复制算法(MinorGC的过程(复制->清空->互换) )
- eden、SurvivorFrom 复制到 SurvivorTo,年龄+1
首先,当Eden区满的时候会触发第一次GC,把还活着的对象拷贝到SurvivorFrom区,后面,当Eden区再次触发GC的时候会扫描Eden区和From区域,对这两个区域进行垃圾回收,经过这次回收后还存活的对象,则直接复制到To区域(如果有对象的年龄已经达到了老年的标准,则赋值到老年代区),同时把这些对象的年龄+1 - 清空 eden、SurvivorFrom
然后,清空Eden和SurvivorFrom中的对象。 - SurvivorTo和 SurvivorFrom 互换
最后,SurvivorTo和SurvivorFrom互换,原SurvivorTo成为下一次GC时的SurvivorFrom区。部分对象会在From和To区域中复制来复制去,如此交换15次(由JVM参数MaxTenuringThreshold决定,这个参数默认是15),最终如果还是存活,就存入到老年代
- eden、SurvivorFrom 复制到 SurvivorTo,年龄+1
- 永久区(java8之前有)
永久存储区是一个常驻内存区域,用于存放JDK自身所携带的 Class,Interface 的元数据,也就是说它存储的是运行环境必须的类信息,被装载进此区域的数据是不会被垃圾回收器回收掉的,关闭 JVM 才会释放此区域所占用的内存。 - 堆空间内存不够 OOM异常
如果出现java.lang.OutOfMemoryError: Java heap space异常,说明Java虚拟机的堆内存不够。
原因有二:
(1)Java虚拟机的堆内存设置不够,可以通过参数-Xms、-Xmx来调整。
(2)代码中创建了大量大对象,并且长时间不能被垃圾收集器收集(存在被引用)。
堆空间变化以及堆参数调优
java7和java8堆空间的变化,把永久代换成了元空间
java7java8
java8的元空间:
在Java8中,永久代已经被移除,被一个称为元空间的区域所取代。元空间的本质和永久代类似。
元空间与永久代之间最大的区别在于: 永久代使用的JVM的堆内存,但是java8以后的元空间并不在虚拟机中而是使用本机物理内存。
默认情况下,元空间的大小仅受本地内存限制。类的元数据放入 native memory, 字符串池和类的静态变量放入 java 堆中,这样可以加载多少类的元数据就不再由MaxPermSize 控制, 而由系统的实际可用空间来控制
- 堆内存调优,三个参数:
设置VM参数,一般初始化和最大值一样,防止忽高忽低,效率不高,设置方法:
-Xms1024m -Xmx1024m -XX:+PrintGCDetails (最后这个是打印gc日志)
GC(垃圾回收)
- 理解:次数上频繁收集新生区,较少收集养老区,基本不动元空间。
- GC四大算法:
- 引用计数法:引用计数法记录着每一个对象被其它对象所持有的引用数。如果一个对象的引用计数为零,那么该对象就变成了所谓的不可达对象,亦即可以被回收的。当一个对象被回收后,被该对象所引用的其它对象的引用计数都应该相应减少。
缺点:每次对对象的引用就要给计数器加1,这个计数器本身也是一种内存的消耗。
无法处理循环引用,循环引用是什么?
循环利用的理解:就是某个对象有个属性(这个属性被某个对象指向时计数器是1)指向另一个对象,同时另一个对象又指回它(这个时候计数器为2)。当一开始的对象为null的时候,他的这个属性的计数器是1,这样就无法被回收了。
- 复制算法(Copying):用于年轻代中(Minor Gc)
- 原理:HotSpot JVM把年轻代分为了三部分:1个Eden区和2个Survivor区(分别叫from和to)。默认比例为8:1:1,一般情况下,新创建的对象都会被分配到Eden区(一些大对象特殊处理),这些对象经过第一次Minor GC后,如果仍然存活,将会被移到Survivor区。对象在Survivor区中每熬过一次Minor GC,年龄就会增加1岁,当它的年龄增加到一定程度时,就会被移动到年老代中。因为年轻代中的对象基本都是朝生夕死的(90%以上),所以在年轻代的垃圾回收算法使用的是复制算法,复制算法的基本思想就是将内存分为两块,每次只用其中一块,当这一块内存用完,就将还活着的对象复制到另外一块上面。复制算法不会产生内存碎片。
在GC开始的时候,对象只会存在于Eden区和名为“From”的Survivor区,Survivor区“To”是空的。紧接着进行GC,Eden区中所有存活的对象都会被复制到“To”,而在“From”区中,仍存活的对象会根据他们的年龄值来决定去向。年龄达到一定值(年龄阈值,可以通过-XX:MaxTenuringThreshold来设置)的对象会被移动到年老代中,没有达到阈值的对象会被复制到“To”区域。经过这次GC后,Eden区和From区已经被清空。这个时候,“From”和“To”会交换他们的角色,也就是新的“To”就是上次GC前的“From”,新的“From”就是上次GC前的“To”。不管怎样,都会保证名为To的Survivor区域是空的。Minor GC会一直重复这样的过程,直到“To”区被填满,“To”区被填满之后,会将所有对象移动到年老代中。
- 原理:HotSpot JVM把年轻代分为了三部分:1个Eden区和2个Survivor区(分别叫from和to)。默认比例为8:1:1,一般情况下,新创建的对象都会被分配到Eden区(一些大对象特殊处理),这些对象经过第一次Minor GC后,如果仍然存活,将会被移到Survivor区。对象在Survivor区中每熬过一次Minor GC,年龄就会增加1岁,当它的年龄增加到一定程度时,就会被移动到年老代中。因为年轻代中的对象基本都是朝生夕死的(90%以上),所以在年轻代的垃圾回收算法使用的是复制算法,复制算法的基本思想就是将内存分为两块,每次只用其中一块,当这一块内存用完,就将还活着的对象复制到另外一块上面。复制算法不会产生内存碎片。
因为Eden区对象一般存活率较低,一般的,使用两块10%的内存作为空闲和活动区间,而另外80%的内存,则是用来给新建对象分配内存的。一旦发生GC,将10%的from活动区间与另外80%中存活的eden对象转移到10%的to空闲区间,接下来,将之前90%的内存全部释放,以此类推。
ii. 缺点:
1、它浪费了一半的内存,这太要命了。
2、如果对象的存活率很高,我们可以极端一点,假设是100%存活,那么我们需要将所有对象都复制一遍,并将所有引用地址重置一遍。复制这一工作所花费的时间,在对象存活率达到一定程度时,将会变的不可忽视。 所以从以上描述不难看出,复制算法要想使用,最起码对象的存活率要非常低才行,而且最重要的是,我们必须要克服50%内存的浪费。
c. 标记清除算法(Mark-Sweep):一般用于老年代
i. 原理:就是当程序运行期间,若可以使用的内存被耗尽的时候,GC线程就会被触发并将程序暂停,随后将要活着的对象标记一遍,最终统一回收垃圾对象,完成标记清理工作接下来便让应用程序恢复运行。
主要进行两项工作,第一项则是标记,第二项则是清除。
标记:从引用根节点开始标记遍历所有的GC Roots, 先标记出活着的的对象。
清除:遍历整个堆,把未标记的对象清除。
ii. 缺点:
1. 首先,它的缺点就是效率比较低(递归与全堆对象遍历),而且在进行GC的时候,需要停止应用程序,这会导致用户体验非常差劲
1. 主要的缺点则是这种方式清理出来的空闲内存是不连续的,这点不难理解,我们的死亡对象都是随即的出现在内存的各个角落的,现在把它们清除之后,内存的布局自然会乱七八糟。而为了应付这一点,JVM就不得不维持一个内存的空闲列表,这又是一种开销。而且在分配数组对象的时候,寻找连续的内存空间会不太好找。
d. 标记-压缩算法(Mark-Compact):一般用于老年代
1. 原理:在整理压缩阶段,**不再对未被标记的对象做回收,而是通过所有存活对像都向一端移动,然后直接清除边界以外的内存。**可以看到,标记的存活对象将会被整理,按照内存地址依次排列,而未被标记的内存会被清理掉。**如此一来,当我们需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可,这比维护一个空闲列表显然少了许多开销。** 标记/整理算法不仅可以弥补标记/清除算法当中,内存区域分散的缺点,也消除了复制算法当中,内存减半的高额代价<br />![image.png](https://cdn.nlark.com/yuque/0/2021/png/12512246/1611983545734-1ff47ecb-692e-4af5-848a-a6f5bb2e504c.png#crop=0&crop=0&crop=1&crop=1&height=263&id=xiWcP&margin=%5Bobject%20Object%5D&name=image.png&originHeight=525&originWidth=749&originalType=binary&ratio=1&rotation=0&showTitle=false&size=139723&status=done&style=none&title=&width=374.5)
1. 缺点:<br />标记/压缩算法唯一的缺点就是效率也不高,不仅要标记所有存活对象,还要整理所有存活对象的引用地址。从效率上来说,标记/压缩算法要低于复制算法。
e. 垃圾回收算法总结:
内存效率:复制算法>标记清除算法>标记整理算法(此处的效率只是简单的对比时间复杂度,实际情况不一定如此)。
内存整齐度:复制算法=标记整理算法>标记清除算法。
内存利用率:标记整理算法=标记清除算法>复制算法。
可以看出,效率上来说,复制算法是当之无愧的老大,但是却浪费了太多内存,而为了尽量兼顾上面所提到的三个指标,标记/整理算法相对来说更平滑一些,但效率上依然不尽如人意,它比复制算法多了一个标记的阶段,又比标记/清除多了一个整理内存的过程
难道就没有一种最优算法吗? 猜猜看,下面还有
回答:无,没有最好的算法,只有最合适的算法。==========>分代收集算法。
年轻代(Young Gen)
年轻代特点是区域相对老年代较小,对象存活率低。
这种情况复制算法的回收整理,速度是最快的。复制算法的效率只和当前存活对象大小有关,因而很适用于年轻代的回收。而复制算法内存利用率不高的问题,通过hotspot中的两个survivor的设计得到缓解。
老年代(Tenure Gen)
老年代的特点是区域较大,对像存活率高。
这种情况,存在大量存活率高的对像,复制算法明显变得不合适。一般是由标记清除或者是标记清除与标记整理的混合实现。
Mark阶段的开销与存活对象的数量成正比,这点上说来,对于老年代,标记清除或者标记整理有一些不符,但可以通过多核/线程利用,对并发、并行的形式提标记效率。
Sweep阶段的开销与所管理区域的大小形状正相关,但Sweep“就地处决”的特点,回收的过程没有对像的移动。使其相对其它有对象移动步骤的回收算法,仍然是效率最好的。但是需要解决内存碎片问题。
Compact阶段的开销与存活对象的数据成正比,如上一条所描述,对于大量对像的移动是很大开销的,做为老年代的第一选择并不合适。
基于上面的考虑,老年代一般是由标记清除或者是标记清除与标记整理的混合实现。以hotspot中的CMS回收器为例,CMS是基于Mark-Sweep实现的,对于对像的回收效率很高,而对于碎片问题,CMS采用基于Mark-Compact算法的Serial Old回收器做为补偿措施:当内存回收不佳(碎片导致的Concurrent Mode Failure时),将采用Serial Old执行Full GC以达到对老年代内存的整理。
面试题(从网上整理的,只是方便自己好背)
解释一下类加载机制,双亲委派模型的好处
类加载机制:某个特定的类加载器在接到加载类的请求的时候,首先将加载任务委托给父类加载器,依次递归,如果父类加载器可以完成加载任务就成功返回,只有当父类加载不成功的时候才自己加载。
好处:java类随着他的类加载器一起具备了一种带有优先级的层次关系。例如java.lang.object 它存在于rt.jar中,无论谁加载object最终都是有BootStrap ClassLoader进行加载,所以object类在程序的各种类加载器环境中都是同一个类。
JVM加载class文件的原理
JVM类的装载是由ClassLoader和它的子类来实现的,java ClassLoader是一个重要的java运行时系统组件。他是负责在运行时查找和装入class文件的类。
java中所有的类都需要类加载器装载到JVM中才能运行,他是负责在运行时查找和装入class文件的类。再写程序的时候我们不需要关心类的加载,因为这些都是隐式装载的,除非我们用特殊的用法,比如反射就是显示加载所需的类。
类装载的方式:
(1)隐式装载:程序在运行过程中碰到new 对象的时候,隐式调用类加载器加载对应的类到jvm
(2)显式装载:通过class.forname()等方法,显式加载需要的类,二者本质是一样的。
类的加载是动态的,他不会一次把所有类全部加载后在运行,而是保证程序运行的基础类完全加载到jvm中就可以了,其他的类都是需要的时候才加载,为了节省内存的开销。
什么是jvm,为什么java被称为与平台无关的编程语言
jvm是一个可执行java字节码的虚拟机进程,java源文件被编译成能被jvm执行的class文件
平台无关的原因:
jvm将java字节码解释为具体平台的具体指令。一般的高级语言如要在不同的平台上运行,至少需要编译成不同的目标代码。而引入JVM后,Java语言在不同平台上运行时不需要重新编译。Java语言使用模式Java虚拟机屏蔽了与具体平台相关的信息,使得Java语言编译程序只需生成在Java虚拟机上运行的目标代码(字节码),就可以在多种平台上不加修改地运行。Java虚拟机在执行字节码时,把字节码解释成具体平台上的机器指令执行
jvm最大内存限制是多少
(1)堆内存分配
JVM初始分配的内存由-Xms指定,默认是物理内存的1/64;JVM最大分配的内存由-Xmx指定,默认是物理内存的1/4。默认空余堆内存小 于40%时,JVM就会增大堆直到-Xmx的最大限制;空余堆内存大于70%时,JVM会减少堆直到-Xms的最小限制。因此服务器一般设置-Xms、 -Xmx相等以避免在每次GC后调整堆的大小。
(2)非堆内存分配
JVM使用-XX:PermSize设置非堆内存初始值,默认是物理内存的1/64;由XX:MaxPermSize设置最大非堆内存的大小,默认是物理内存的1/4。
(3)VM最大内存
首先JVM内存限制于实际的最大物理内存,假设物理内存无限大的话,JVM内存的最大值跟操作系统有很大的关系。简单的说就32位处理器虽 然可控内存空间有4GB,但是具体的操作系统会给一个限制,这个限制一般是2GB-3GB(一般来说Windows系统下为1.5G-2G,Linux系 统下为2G-3G),而64bit以上的处理器就不会有限制了。
jvm是如何实现线程的?
线程是比进程更轻量级的调度执行单位。线程可以把一个进程的资源分配和执行调度分开。一个进程里可以启动多条线程,各个线程可共享该进程的资源(内存地址,文件IO等),又可以独立调度。线程是CPU调度的基本单位。
主流OS都提供线程实现。Java语言提供对线程操作的同一API,每个已经执行start(),且还未结束的java.lang.Thread类的实例,代表了一个线程。
Thread类的关键方法,都声明为Native。这意味着这个方法无法或没有使用平台无关的手段来实现,也可能是为了执行效率。
实现线程的方式
A.使用内核线程实现内核线程(Kernel-Level Thread, KLT)就是直接由操作系统内核支持的线程。
内核来完成线程切换
内核通过调度器Scheduler调度线程,并将线程的任务映射到各个CPU上
程序使用内核线程的高级接口,轻量级进程(Light Weight Process,LWP)
用户态和内核态切换消耗内核资源
使用用户线程实现
系统内核不能感知线程存在的实现
用户线程的建立、同步、销毁和调度完全在用户态中完成
所有线程操作需要用户程序自己处理,复杂度高
用户线程加轻量级进程混合实现
轻量级进程作为用户线程和内核线程之间的桥梁
请问什么是JVM内存模型?
Java内存模型(简称JMM),JMM决定一个线程对共享变量的写入何时对另一个线程可见。从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(main memory)中,每个线程都有一个私有的本地内存(local memory),本地内存中存储了该线程以读/写共享变量的副本。
请列举一下,在JAVA虚拟机中,哪些对象可作为ROOT对象?
虚拟机栈中的引用对象
方法区中类静态属性引用的对象
方法区中常量引用对象
本地方法栈中JNI引用对象
GC中如何判断对象是否需要被回收
即使在可达性分析算法中不可达的对象,也并非是“非回收不可”的,这时候它们暂时处于“等待”阶段,要真正宣告一个对象回收,至少要经历两次标记过程:
如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。当对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”。(即意味着直接回收)
如果这个对象被判定为有必要执行finalize()方法,那么这个对象将会放置在一个叫做F-Queue的队列之中,并在稍后由一个由虚拟机自动建立的、低优先级的Finalizer线程去执行它。这里所谓的“执行”是指虚拟机会触发这个方法,但并不承诺会等待它运行结束,这样做的原因是,如果一个对象在finalize()方法中执行缓慢,或者发生了死循环(更极端的情况),将很可能会导致F-Queue队列中其他对象永久处于等待,甚至导致整个内存回收系统崩溃。
finalize()方法是对象逃脱回收的最后一次机会,稍后GC将对F-Queue中的对象进行第二次小规模的标记,如果对象要在finalize()中跳出回收——只要重新与引用链上的任何一个对象建立关联即可,譬如把自己(this关键字)赋值给某个类变量或者对象的成员变量,那在第二次标记时它将被移除出“即将回收”的集合;如果对象这时候还没有逃脱,那基本上它就真的被回收了。
请说明一下eden区和survial区的含义以及工作原理
目前主流的虚拟机实现都采用了分代收集的思想,把整个堆区划分为新生代和老年代;新生代又被划分成Eden 空间、 From Survivor 和 To Survivor 三块区域。
我们把Eden : From Survivor : To Survivor 空间大小设成 8 : 1 : 1 ,对象总是在 Eden 区出生, From Survivor 保存当前的幸存对象, To Survivor 为空。一次 gc 发生后: 1)Eden 区活着的对象 + From Survivor 存储的对象被复制到 To Survivor ;2) 清空 Eden 和 From Survivor ; 3) 颠倒 From Survivor 和 To Survivor 的逻辑关系: From 变 To , To 变 From 。可以看出,只有在 Eden 空间快满的时候才会触发 Minor GC 。而 Eden 空间占新生代的绝大部分,所以 Minor GC 的频率得以降低。当然,使用两个 Survivor 这种方式我们也付出了一定的代价,如 10% 的空间浪费、复制对象的开销等。
请简单描述一下JVM分区都有哪些
请简单描述一下类的加载过程
加载
加载是类加载过程中的一个阶段,这个阶段会在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的入口。注意这里不一定非得要从一个Class文件获取,这里既可以从ZIP包中读取(比如从jar包和war包中读取),也可以在运行时计算生成(动态代理),也可以由其它文件生成(比如将JSP文件转换成对应的Class类)。
验证
这一阶段的主要目的是为了确保Class文件的字节流中包含的信息是否符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。
准备
准备阶段是正式为类变量分配内存并设置类变量的初始值阶段,即在方法区中分配这些变量所使用的内存空间。注意这里所说的初始值概念,比如一个类变量定义为:
public static int v = 8080;
实际上变量v在准备阶段过后的初始值为0而不是8080,将v赋值为8080的putstatic指令是程序被编译后,存放于类构造器
但是注意如果声明为:
public static final int v = 8080;
在编译阶段会为v生成ConstantValue属性,在准备阶段虚拟机会根据ConstantValue属性将v赋值为8080。
解析
解析阶段是指虚拟机将常量池中的符号引用替换为直接引用的过程。符号引用就是class文件中的:
CONSTANT_Class_info
CONSTANT_Field_info
CONSTANT_Method_info
等类型的常量。
下面我们解释一下符号引用和直接引用的概念:
符号引用与虚拟机实现的布局无关,引用的目标并不一定要已经加载到内存中。各种虚拟机实现的内存布局可以各不相同,但是它们能接受的符号引用必须是一致的,因为符号引用的字面量形式明确定义在Java虚拟机规范的Class文件格式中。
直接引用可以是指向目标的指针,相对偏移量或是一个能间接定位到目标的句柄。如果有了直接引用,那引用的目标必定已经在内存中存在。
初始化
初始化阶段是类加载最后一个阶段,前面的类加载阶段之后,除了在加载阶段可以自定义类加载器以外,其它操作都由JVM主导。到了初始阶段,才开始真正执行类中定义的Java程序代码。
初始化阶段是执行类构造器
注意以下几种情况不会执行类初始化:
通过子类引用父类的静态字段,只会触发父类的初始化,而不会触发子类的初始化。
定义对象数组,不会触发该类的初始化。
常量在编译期间会存入调用类的常量池中,本质上并没有直接引用定义常量的类,不会触发定义常量所在的类。
通过类名获取Class对象,不会触发类的初始化。
通过Class.forName加载指定类时,如果指定参数initialize为false时,也不会触发类初始化,其实这个参数是告诉虚拟机,是否要对类进行初始化。
通过ClassLoader默认的loadClass方法,也不会触发初始化动作。
类加载器
虚拟机设计团队把加载动作放到JVM外部实现,以便让应用程序决定如何获取所需的类,JVM提供了3种类加载器:
启动类加载器(Bootstrap ClassLoader):负责加载 JAVA_HOME\lib 目录中的,或通过-Xbootclasspath参数指定路径中的,且被虚拟机认可(按文件名识别,如rt.jar)的类。
扩展类加载器(Extension ClassLoader):负责加载 JAVA_HOME\lib\ext 目录中的,或通过java.ext.dirs系统变量指定路径中的类库。
应用程序类加载器(Application ClassLoader):负责加载用户路径(classpath)上的类库。
JVM通过双亲委派模型进行类的加载,当然我们也可以通过继承java.lang.ClassLoader实现自定义的类加载器。
请简单说明一下JVM的回收算法以及它的回收器是什么?还有CMS采用哪种回收算法?使用CMS怎样解决内存碎片的问题呢?
垃圾回收算法
标记清除
标记-清除算法将垃圾回收分为两个阶段:标记阶段和清除阶段。在标记阶段首先通过根节点,标记所有从根节点开始的对象,未被标记的对象就是未被引用的垃圾对象。然后,在清除阶段,清除所有未被标记的对象。标记清除算法带来的一个问题是会存在大量的空间碎片,因为回收后的空间是不连续的,这样给大对象分配内存的时候可能会提前触发full gc。
复制算法
将现有的内存空间分为两快,每次只使用其中一块,在垃圾回收时将正在使用的内存中的存活对象复制到未被使用的内存块中,之后,清除正在使用的内存块中的所有对象,交换两个内存的角色,完成垃圾回收。
现在的商业虚拟机都采用这种收集算法来回收新生代,IBM研究表明新生代中的对象98%是朝夕生死的,所以并不需要按照1:1的比例划分内存空间,而是将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中的一块Survivor。当回收时,将Eden和Survivor中还存活着的对象一次性地拷贝到另外一个Survivor空间上,最后清理掉Eden和刚才用过的Survivor的空间。HotSpot虚拟机默认Eden和Survivor的大小比例是8:1(可以通过-SurvivorRattio来配置),也就是每次新生代中可用内存空间为整个新生代容量的90%,只有10%的内存会被“浪费”。当然,98%的对象可回收只是一般场景下的数据,我们没有办法保证回收都只有不多于10%的对象存活,当Survivor空间不够用时,需要依赖其他内存(这里指老年代)进行分配担保。
标记整理
复制算法的高效性是建立在存活对象少、垃圾对象多的前提下的。这种情况在新生代经常发生,但是在老年代更常见的情况是大部分对象都是存活对象。如果依然使用复制算法,由于存活的对象较多,复制的成本也将很高。
标记-压缩算法是一种老年代的回收算法,它在标记-清除算法的基础上做了一些优化。首先也需要从根节点开始对所有可达对象做一次标记,但之后,它并不简单地清理未标记的对象,而是将所有的存活对象压缩到内存的一端。之后,清理边界外所有的空间。这种方法既避免了碎片的产生,又不需要两块相同的内存空间,因此,其性价比比较高。
增量算法
增量算法的基本思想是,如果一次性将所有的垃圾进行处理,需要造成系统长时间的停顿,那么就可以让垃圾收集线程和应用程序线程交替执行。每次,垃圾收集线程只收集一小片区域的内存空间,接着切换到应用程序线程。依次反复,直到垃圾收集完成。使用这种方式,由于在垃圾回收过程中,间断性地还执行了应用程序代码,所以能减少系统的停顿时间。但是,因为线程切换和上下文转换的消耗,会使得垃圾回收的总体成本上升,造成系统吞吐量的下降。
垃圾回收器
CMS收集器
CMS(Concurrent Mark Swep)收集器是一个比较重要的回收器,现在应用非常广泛,我们重点来看一下,CMS一种获取最短回收停顿时间为目标的收集器,这使得它很适合用于和用户交互的业务。从名字(Mark Swep)就可以看出,CMS收集器是基于标记清除算法实现的。它的收集过程分为四个步骤:
初始标记(initial mark)
并发标记(concurrent mark)
重新标记(remark)
并发清除(concurrent sweep)
注意初始标记和重新标记还是会stop the world,但是在耗费时间更长的并发标记和并发清除两个阶段都可以和用户进程同时工作。
解决这个问题的办法就是可以让CMS在进行一定次数的Full GC(标记清除)的时候进行一次标记整理算法,CMS提供了以下参数来控制:
-XX:UseCMSCompactAtFullCollection -XX:CMSFullGCBeforeCompaction=5
也就是CMS在进行5次Full GC(标记清除)之后进行一次标记整理算法,从而可以控制老年带的碎片在一定的数量以内,甚至可以配置CMS在每次Full GC的时候都进行内存的整理。