赞
踩
上图为《深入理解Java虚拟机》中JVM运行时数据区结构图,展示了程序执行期间使用的各种运行时数据区域。下面做一下详细介绍
异常情况:该部分是唯一一个在Java虚拟机规范中没有定义任何OutOfMemoryError异常的区域。
程序计数器是一块比较小的内存空间,是当前线程所执行的字节码的行号指示器。如果执行的方法是一个Java方法,则记录当前正在执行的Java虚拟机字节码指令的地址;如果执行的方法是本地方法,那么值为空。
public class per.gmy.Test { public per.gmy.Test(); Code: 0: aload_0 1: invokespecial #1 4: return public static void main(java.lang.String[]); Code: 0: bipush 1 2: istore_1 3: bipush 2 5: istore_2 6: iload_1 7: iload_2 8: iadd 9: istore_3 10: return }
如上面的字节码指令前的0、2、3等等数字,就是该区域存储的内容。由于是线程私有的,当发生时间片切换时,当前线程可以根据存储的行号记录程序执行到了哪里
异常情况:如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverflowError异常;虚拟机栈可以动态扩展,如果扩展时无法申请到足够的内存,就会抛出OutOfMemoryError异常
Java方法执行时,每个方法都会创建一个栈帧,其内部包括局部变量表、操作数栈、动态连接、方法返回地址等信息,栈帧结构如下所示:
局部变量表用于存放方法参数和方法内部定义的局部变量,其基本单位为变量槽,每个槽可以存放32位以内的数据,对于long和double则会用连续的两个槽。
局部变量表有是从0开始的索引,用来定位数据,如果执行的是实例方法,第0位索引默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字“this”来访问到这个隐含的参数。
可以理解为进行计算的地方,比如计算a+b,会将a和b的值分别push进操作数栈,然后执行add指令将ab取出计算后将结果重新压入操作数栈;如果后续没有计算,一般会将结果存入局部变量表中。
虽然不同栈帧代表不同方法应该相互独立,但虚拟机大多有优化,在该区域会存在两个栈帧共享的一部分操作数栈
每个栈帧都包含一个指向运行时常量池中的引用,该引用是栈帧所属方法的引用,用于动态链接时能够将方法的符号引用转换为直接引用
用于记录调用该方法的调用方的地址,方便在当前方法return后,会到原来的方法
本地方法栈与 Java 虚拟机栈类似,它们之间的区别只不过是本地方法栈为本地方法服务。本地方法一般是用其它语言(C、C++ 或汇编语言等)编写的,并且被编译为基于本机硬件和操作系统的程序。
异常情况:如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError异常
所有对象都在这里分配内存,是垃圾收集的主要区域(“GC 堆”),现代的垃圾收集器基本都是采用分代收集算法,其主要的思想是针对不同类型的对象采取不同的垃圾回收算法,可以将堆分成两块:新生代(Young Generation)、老年代(Old Generation),JDK1.8中把运行时常量池、静态变量也移到堆区进行存储。
其中新生代分为Eden空间、From Survivor空间、To Survivor空间,默认比例是8:1:1,下面是一些具体参数:
-Xmx:堆的最大内存
-Xms:堆的最小内存
-Xmn:新生代大小
-XX:NewRatio:新生代比例,默认为2,即新生代占堆的三分之一
-XX:SurvivorRatio:新生代中的survivor比例,默认为8,即每次新生代可用区域为90%
异常情况:动态扩展失败会抛出 OutOfMemoryError 异常。
方法区用于存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据,和堆一样不需要连续的内存,并且可以动态扩展,对这块区域进行垃圾回收的主要目标是对常量池的回收和对类的卸载,但是一般比较难实现。
HotSpot 虚拟机把它当成永久代来进行垃圾回收。但很难确定永久代的大小,因为它受到很多因素影响,并且每次 Full GC 之后永久代的大小都会改变,所以经常会抛出 OutOfMemoryError 异常。为了更容易管理方法区,从 JDK 1.8 开始,移除永久代,并把方法区移至元空间,它位于本地内存中,而不是虚拟机内存中。
方法区是一个 JVM 规范,永久代与元空间都是其一种实现方式。在 JDK 1.8 之后,原来永久代的数据被分到了堆和元空间中。元空间存储类的元信息,静态变量和常量池等放入堆中。
运行时常量池是方法区的一部分。Class 文件中的常量池(编译器生成的字面量和符号引用)会在类加载后被放入这个区域。除了在编译期生成的常量,还允许动态生成,例如 String 类的 intern()方法。
元数据空间是JDK1.8之后对方法区的一种实现方式,取代了之前的永久代,减轻之前的开发和运维压力,否则设置的永久代太小会抛出“java.lang.OutOfMemoryError: PermGen space”;设置过大会浪费内存
在 JDK 1.4 中新引入了 NIO 类,它可以使用 Native 函数库直接分配堆外内存,然后通过 Java 堆里的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在堆内存和堆外内存来回拷贝数据。
为对象添加一个引用计数器,当对象增加一个引用时计数器加 1,引用失效时计数器减 1。引用计数为 0 的对象可被回收。如果两个对象互相引用,则无法对它们进行回收
以 GC Roots 为起始点进行搜索,可达的对象都是存活的,不可达的对象可被回收。
GC Roots包括如下几种:
主要是对常量池的回收和对类的卸载,其中类的卸载条件很多,需要满足以下三个条件,并且满足了条件也不一定会被卸载:
即使在可达性分析算法中判定为不可达的对象,也不是一定会被回收,要经历两次标记过程才会被回收,如果对象在进行可达性分析后发现没有与GC Roots相连的引用链,那它将会被第一次标记,随后进行一次筛选,筛选的条件是此对象是否有必要执行 finalize()方法。假如对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,那么虚拟机将这两种情况都视为“没有必要执行”。
如果这个对象被判定为确有必要执行 finalize()方法,那么该对象将会被放置在一个名为F-Queue 的队列之中,并在稍后由一条由虚拟机自动建立的、低调度优先级的Finalizer线程去执行它们的finalize()方法。这里所说的“执行”是指虚拟机会触发这个方法开始运行,但并不承诺一定会等待它运行结束。稍后收集器将对 F-Queue 中的对象进行第二次小规模的标记,如果对象此时还不具备可达性,那基本上它就真的要被回收了。
上面两种算法都在提到一个词语,“引用”,Java 提供了四种强度不同的引用类型:
String str = new String("abc");
ReferenceQueue queue = new ReferenceQueue();
//创建虚引用,要求必须与一个引用队列关联
PhantomReference pr = new PhantomReference(str, queue);
首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。
优缺点:
当堆内的对象很多时,标记和清除过程效率都不高;
会产生大量不连续的内存碎片,导致无法给大对象分配内存。
让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
优缺点:
不会产生内存碎片
需要移动大量对象,处理效率比较低。
将内存划分为大小相等的两块,每次只使用其中一块,当这一块内存用完了就将还存活的对象复制到另一块上面,然后再把使用过的内存空间进行一次清理。
现在的商业虚拟机都采用这种收集算法回收新生代,但是并不是划分为大小相等的两块,而是一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 和其中一块 Survivor。在回收时,将 Eden 和 Survivor 中还存活着的对象全部复制到另一块 Survivor 上,最后清理 Eden 和使用过的那一块 Survivor。
HotSpot 虚拟机的 Eden 和 Survivor 大小比例默认为 8:1,保证了内存的利用率达到 90%。如果每次回收有多于 10% 的对象存活,那么一块 Survivor 就不够用了,此时需要依赖于老年代进行空间分配担保,也就是借用老年代的空间存储放不下的对象。
上图中标志JDK9的两种组合,在JDK8中已经声明为废弃,并在JDK9中彻底取消。
Serial意为串行,如上图所示,前半段是Serial收集器的新生代版本,后半段是Serial收集器的老年代版本,该收集器只有一个线程进行垃圾回收,并且在回收过程会暂停用户线程。
ParNew收集器是Serial收集器的多线程版本,如上图所示,前半段是ParNew收集器,后半段是Serial收集器的老年代版本,ParNew收集器是唯一一个能和CMS合作的新生代收集器,所以随着CMS的“没落”,ParNew收集器已经绑定性质的一起被"隐匿"了。
Parallel与ParNew一样是多线程收集器,如上图所示,前半段是Parallel Scavenge收集器,后半段是Parallel old收集器,也就是Parallel Scavenge的老年代版本。
其它收集器目标是尽可能缩短垃圾收集时用户线程的停顿时间,而该收集器的目标是达到一个可控制的吞吐量,因此它被称为“吞吐量优先”收集器。这里的吞吐量指 CPU 用于运行用户程序的时间占总时间的比值。
Parallel Scavenge收集器提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间的-XX:MaxGCPauseMillis 参数以及直接设置吞吐量大小的-XX:GCTimeRatio
另外该收集器可以通过一个开关参数-XX:+UseAdaptiveSizePolicy 打开 GC 自适应的调节策略(GC Ergonomics),就不需要手工指定新生代的大小(-Xmn)、Eden 和 Survivor 区的比例、晋升老年代对象年龄等细节参数了。虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量。
CMS意为Concurrent Mark Sweep,翻译过来就是并发的标记清除,分为以下四个流程:
在整个过程中耗时最长的并发标记和并发清除过程中,收集器线程都可以与用户线程一起工作,不需要进行停顿。
CMS是对并发垃圾回收的首次尝试,有以下缺点:
G1(Garbage-First)收集器,意为价值优先收集器,G1 收集器不再坚持固定大小以及固定数量的分代区域划分,而是把连续的 Java 堆划分为多个大小相等的独立区域(Region),每一个Region都可以根据需要,扮演新生代的 Eden空间、Survivor空间或者老年代空间。
收集器能够对扮演不同角色的 Region 采用不同的策略去处理,这样无论是新创建的对象还是已经存活了一段时间、熬过多次收集的旧对象都能获取很好的收集效果。
Region中还有一类特殊的Humongous 区域,专门用来存储大对象。G1认为只要大小超过了一个Region 容量一半的对象即可判定为大对象。每个 Region 的大小可以通过参数-XX:G1HeapRegionSize 设定,取值范围为1MB~32MB,且应为2的N次幂。而对于那些超过了整个Region容量的超级大对象,将会被存放在N个连续的Humongous Region之中,G1的大多数行为都把 Humongous Region作为老年代的一部分来进行看待。
G1 收集器的运作大致可划分为以下几个步骤:
G1的特点:
加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的,类型的加载过程必须按照这种顺序按部就班地开始,而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java语言的运行时定特性(也称为动态绑定或晚期绑定)。请注意,这些阶段通常都是互相交叉地混合进行的,会在一个阶段执行的过程中调用、激活另一个阶段。
有且仅有以下六种情况会立即进行初始化操作:
遇到new、getstatic、putstatic或invokestatic这四条字节码指令时,如果类型没有进行过初始化,则需要先触发其初始化阶段。能够生成这四条指令的典型Java代码场景有:
a. 使用new关键字实例化对象的时候。
b. 读取或设置一个类型的静态字段(被final修饰、已在编译期把结果放人常量池的静态字段除外)的时候。
c. 调用一个类型的静态方法的时候。
使用java.lang.reflect包的方法对类型进行反射调用的时候,如果果类型没有进行过初始化,则需要先触发其初始化。
当初始化类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。
当使用JDK7新加入的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果为REF_getStatic、REF_putStatic、REF_invokeStatieREF_newInvokeSpecial四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化。
当一个接口中定义了JDK8新加入的默认方法(被default关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。
以上六种触发初始化的行为被称为主动引用,除此之外的都被称为被动引用,有一些常见的被动引用的情况:
将上一节生命周期的前五个阶段:加载、验证、准备、解析、初始化称为类加载过程
加载过程完成以下三件事:
通过类的全限定名获取定义该类的二进制字节流。
其中二进制字节流可以从以下方式中获取:
从ZIP压缩包中读取,这很常见,最终成为日后JAR、EAR、WAR格式的基础。
从网络中获取,这种场景最典型的应用就是WebApplet。
运行时计算生成,这种场景使用得最多的就是动态代理技术。
由其他文件生成,典型场景是JSP应用,由JSP文件生主成对应的Class文件。
从数据库中读取,有些些中间件服务器把程序安装到数据库中来完成在集群间的分发。
可以从加密文件中获取,防Class文件被反编译。
将该字节流表示的静态存储结构转换为方法区的运行时存储结构。
在内存中生成一个代表该类的 Class 对象,作为方法区中该类各种数据的访问入口。
确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全
类变量是被 static 修饰的变量,准备阶段为类变量分配内存并设置初始值,一般为0,使用的是方法区的内存。
实例变量不会在这阶段分配内存,它会在对象实例化时随着对象一起被分配在堆中。应该注意到,实例化不是类加载的一个过程,类加载发生在所有实例化操作之前,并且类加载只进行一次,实例化可以进行多次。
类变量被初始化为 0 而常量被初始化为所定义的值。
public static int value = 123;//准备阶段,被初始化为0
public static final int value = 123;//准备阶段,被初始化为123
解析阶段是Java虚拟机将常量池内的符号引用替换为直接引用的过程。
初始化阶段才真正开始执行类中定义的 Java 程序代码。在准备阶段,类变量已经赋过一次系统要求的初始值,而在初始化阶段,根据程序员通过程序制定的主观计划去初始化类变量和其它资源。
初始化就是执行类构造器< clinit>()方法的过程,< clinit>() 是由编译器自动收集类中所有类变量的赋值动作和静态语句块中的语句合并产生的,编译器收集的顺序由语句在源文件中出现的顺序决定,父类中定义的静态语句块的执行要优先于子类。特别注意的是,静态语句块只能访问到定义在它之前的类变量,定义在它之后的类变量只能赋值,不能访问。比如下面这样会出错:
public class Test {
static {
i = 0; // 给变量赋值可以正常编译通过
System.out.print(i); // “非法向前引用”
}
static int i = 1;
}
类加载阶段中的"通过一个类的的全限定名来获取描述该类的二进制字节流"这个动作放到Java虚拟机外部去实现现,以便让应用程序自己决定如何去获取所需的类。实现这个动作的代码被称为"类加载器"(Class Loader)。
两个类相等,需要类本身相等,并且使用同一个类加载器进行加载。这是因为每一个类加载器都拥有一个独立的类名称空间。
这里的相等,包括类的 Class 对象的 equals() 方法、isAssignableFrom() 方法、isInstance() 方法的返回结果为 true,也包括使用 instanceof 关键字做对象所属关系判定结果为 true。
双亲委派模型的工作过程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载。
使用双亲委派模型来组织类加载器之间的关系,一个显而易见的好处就是Java中的类随着它的类加载器一起具备了一种带有优先级的层次关系。例如类java.lang.Object,它存放在rt.jar之中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此Object类在程序的各种类加载器环境中都能够保证是同一个类。
下面详细介绍一下三种类加载器:
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。