这里仅仅记录了一些笔者认为须要重点掌握的 JVM 知识点,若是你想更加全面地了解 JVM 底层原理,能够阅读周志明老师《深刻理解Java虚拟机——JVM高级特性与最佳实践(第2版)》全书。前端
Java 虚拟机的内存空间分为 5 个部分:java
JDK 1.8 同 JDK 1.7 比,最大的差异就是:元数据区取代了永久代。元空间的本质和永久代相似,都是对 JVM 规范中方法区的实现。不过元空间与永久代之间最大的区别在于:元数据空间并不在虚拟机中,而是使用本地内存。git
程序计数器是一块较小的内存空间,是当前线程正在执行的那条字节码指令的地址。若当前线程正在执行的是一个本地方法,那么此时程序计数器为Undefined
。github
OutOfMemoryError
的内存区域。Java 虚拟机栈是描述 Java 方法运行过程的内存模型。算法
Java 虚拟机栈会为每个即将运行的 Java 方法建立一块叫作“栈帧”的区域,用于存放该方法运行过程当中的一些信息,如:数据库
当方法运行过程当中须要建立局部变量时,就将局部变量的值存入栈帧中的局部变量表中。数组
Java 虚拟机栈的栈顶的栈帧是当前正在执行的活动栈,也就是当前正在执行的方法,PC 寄存器也会指向这个地址。只有这个活动的栈帧的本地变量能够被操做数栈使用,当在这个栈帧中调用另外一个方法,与之对应的栈帧又会被建立,新建立的栈帧压入栈顶,变为当前的活动栈帧。缓存
方法结束后,当前栈帧被移出,栈帧的返回值变成新的活动栈帧中操做数栈的一个操做数。若是没有返回值,那么新的活动栈帧中操做数栈的操做数没有变化。安全
因为Java 虚拟机栈是与线程对应的,数据不是线程共享的,所以不用关心数据一致性问题,也不会存在同步锁的问题。服务器
出现 StackOverFlowError 时,内存空间可能还有不少。
本地方法栈是为 JVM 运行 Native 方法准备的空间,因为不少 Native 方法都是用 C 语言实现的,因此它一般又叫 C 栈。它与 Java 虚拟机栈实现的功能相似,只不过本地方法栈是描述本地方法运行过程的内存模型。
本地方法被执行时,在本地方法栈也会建立一块栈帧,用于存放该方法的局部变量表、操做数栈、动态连接、方法出口信息等。
方法执行结束后,相应的栈帧也会出栈,并释放内存空间。也会抛出 StackOverFlowError 和 OutOfMemoryError 异常。
若是 Java 虚拟机自己不支持 Native 方法,或是自己不依赖于传统栈,那么能够不提供本地方法栈。若是支持本地方法栈,那么这个栈通常会在线程建立的时候按线程分配。
堆是用来存放对象的内存空间,几乎全部的对象都存储在堆中。
不一样的区域存放不一样生命周期的对象,这样能够根据不一样的区域使用不一样的垃圾回收算法,更具备针对性。
堆的大小既能够固定也能够扩展,但对于主流的虚拟机,堆的大小是可扩展的,所以当线程请求分配内存,但堆已满,且内存已没法再扩展时,就抛出 OutOfMemoryError 异常。
Java 堆所使用的内存不须要保证是连续的。而因为堆是被全部线程共享的,因此对它的访问须要注意同步问题,方法和对应的属性都须要保证一致性。
Java 虚拟机规范中定义方法区是堆的一个逻辑部分。方法区存放如下信息:
方法区中存放:类信息、常量、静态变量、即时编译器编译后的代码。常量就存放在运行时常量池中。
当类被 Java 虚拟机加载后, .class 文件中的常量就存放在方法区的运行时常量池中。并且在运行期间,能够向常量池中添加新的常量。如 String 类的 intern() 方法就能在运行期间向常量池中添加字符串常量。
直接内存是除 Java 虚拟机以外的内存,但也可能被 Java 使用。
在 NIO 中引入了一种基于通道和缓冲的 IO 方式。它能够经过调用本地方法直接分配 Java 虚拟机以外的内存,而后经过一个存储在堆中的DirectByteBuffer
对象直接操做该内存,而无须先将外部内存中的数据复制到堆中再进行操做,从而提升了数据操做的效率。
直接内存的大小不受 Java 虚拟机控制,但既然是内存,当内存不足时就会抛出 OutOfMemoryError 异常。
服务器管理员在配置虚拟机参数时,会根据实际内存设置
-Xmx
等参数信息,但常常忽略直接内存,使得各个内存区域总和大于物理内存限制,从而致使动态扩展时出现OutOfMemoryError
异常。
在 HotSpot 虚拟机中,对象的内存布局分为如下 3 块区域:
对象头记录了对象在运行过程当中所须要使用的一些数据:
对象头可能包含类型指针,经过该指针能肯定对象属于哪一个类。若是对象是一个数组,那么对象头还会包括数组长度。
实例数据部分就是成员变量的值,其中包括父类成员变量和本类成员变量。
用于确保对象的总长度为 8 字节的整数倍。
HotSpot VM 的自动内存管理系统要求对象的大小必须是 8 字节的整数倍。而对象头部分正好是 8 字节的倍数(1 倍或 2 倍),所以,当对象实例数据部分没有对齐时,就须要经过对齐填充来补全。
对齐填充并非必然存在,也没有特别的含义,它仅仅起着占位符的做用。
虚拟机在解析.class
文件时,若遇到一条 new 指令,首先它会去检查常量池中是否有这个类的符号引用,而且检查这个符号引用所表明的类是否已被加载、解析和初始化过。若是没有,那么必须先执行相应的类加载过程。
对象所需内存的大小在类加载完成后即可彻底肯定,接下来从堆中划分一块对应大小的内存空间给新的对象。分配堆中内存有两种方式:
指针碰撞 若是 Java 堆中内存绝对规整(说明采用的是“复制算法”或“标记整理法”),空闲内存和已使用内存中间放着一个指针做为分界点指示器,那么分配内存时只须要把指针向空闲内存挪动一段与对象大小同样的距离,这种分配方式称为“指针碰撞”。
空闲列表 若是 Java 堆中内存并不规整,已使用的内存和空闲内存交错(说明采用的是标记-清除法,有碎片),此时无法简单进行指针碰撞, VM 必须维护一个列表,记录其中哪些内存块空闲可用。分配之时从空闲列表中找到一块足够大的内存空间划分给对象实例。这种方式称为“空闲列表”。
分配完内存后,为对象中的成员变量赋上初始值,设置对象头信息,调用对象的构造函数方法进行初始化。
至此,整个对象的建立过程就完成了。
全部对象的存储空间都是在堆中分配的,可是这个对象的引用倒是在堆栈中分配的。也就是说在创建一个对象时两个地方都分配内存,在堆中分配的内存实际创建这个对象,而在堆栈中分配的内存只是一个指向这个堆对象的指针(引用)而已。 那么根据引用存放的地址类型的不一样,对象有不一样的访问方式。
堆中须要有一块叫作“句柄池”的内存空间,句柄中包含了对象实例数据与类型数据各自的具体地址信息。
引用类型的变量存放的是该对象的句柄地址(reference)。访问对象时,首先须要经过引用类型的变量找到该对象的句柄,而后根据句柄中对象的地址找到对象。
引用类型的变量直接存放对象的地址,从而不须要句柄池,经过引用可以直接访问对象。但对象所在的内存空间须要额外的策略存储对象所属的类信息的地址。
须要说明的是,HotSpot 采用第二种方式,即直接指针方式来访问对象,只须要一次寻址操做,因此在性能上比句柄访问方式快一倍。但像上面所说,它须要额外的策略来存储对象在方法区中类信息的地址。
程序计数器、虚拟机栈、本地方法栈随线程而生,也随线程而灭;栈帧随着方法的开始而入栈,随着方法的结束而出栈。这几个区域的内存分配和回收都具备肯定性,在这几个区域内不须要过多考虑回收的问题,由于方法结束或者线程结束时,内存天然就跟随着回收了。
而对于 Java 堆和方法区,咱们只有在程序运行期间才能知道会建立哪些对象,这部份内存的分配和回收都是动态的,垃圾收集器所关注的正是这部份内存。
若一个对象不被任何对象或变量引用,那么它就是无效对象,须要被回收。
在对象头维护着一个 counter 计数器,对象被引用一次则计数器 +1;若引用失效则计数器 -1。当计数器为 0 时,就认为该对象无效了。
引用计数算法的实现简单,断定效率也很高,在大部分状况下它都是一个不错的算法。可是主流的 Java 虚拟机里没有选用引用计数算法来管理内存,主要是由于它很难解决对象之间循环引用的问题。
举个栗子👉对象 objA 和 objB 都有字段 instance,令 objA.instance = objB 而且 objB.instance = objA,因为它们互相引用着对方,致使它们的引用计数都不为 0,因而引用计数算法没法通知 GC 收集器回收它们。
全部和 GC Roots 直接或间接关联的对象都是有效对象,和 GC Roots 没有关联的对象就是无效对象。
GC Roots 是指:
GC Roots 并不包括堆中对象所引用的对象,这样就不会有循环引用的问题。
断定对象是否存活与“引用”有关。在 JDK 1.2 之前,Java 中的引用定义很传统,一个对象只有被引用或者没有被引用两种状态,咱们但愿能描述这一类对象:当内存空间还足够时,则保留在内存中;若是内存空间在进行垃圾手收集后仍是很是紧张,则能够抛弃这些对象。不少系统的缓存功能都符合这样的应用场景。
在 JDK 1.2 以后,Java 对引用的概念进行了扩充,将引用分为了如下四种。不一样的引用类型,主要体现的是对象不一样的可达性状态reachable
和垃圾收集的影响。
相似 "Object obj = new Object()" 这类的引用,就是强引用,只要强引用存在,垃圾收集器永远不会回收被引用的对象。可是,若是咱们错误地保持了强引用,好比:赋值给了 static 变量,那么对象在很长一段时间内不会被回收,会产生内存泄漏。
软引用是一种相对强引用弱化一些的引用,可让对象豁免一些垃圾收集,只有当 JVM 认为内存不足时,才会去试图回收软引用指向的对象。JVM 会确保在抛出 OutOfMemoryError 以前,清理软引用指向的对象。软引用一般用来实现内存敏感的缓存,若是还有空闲内存,就能够暂时保留缓存,当内存不足时清理掉,这样就保证了使用缓存的同时,不会耗尽内存。
弱引用的强度比软引用更弱一些。当 JVM 进行垃圾回收时,不管内存是否充足,都会回收只被弱引用关联的对象。
虚引用也称幽灵引用或者幻影引用,它是最弱的一种引用关系。一个对象是否有虚引用的存在,彻底不会对其生存时间构成影响。它仅仅是提供了一种确保对象被 finalize 之后,作某些事情的机制,好比,一般用来作所谓的 Post-Mortem 清理机制。
对于可达性分析中不可达的对象,也并非没有存活的可能。
JVM 会判断此对象是否有必要执行 finalize() 方法,若是对象没有覆盖 finalize() 方法,或者 finalize() 方法已经被虚拟机调用过,那么视为“没有必要执行”。那么对象基本上就真的被回收了。
若是对象被断定为有必要执行 finalize() 方法,那么对象会被放入一个 F-Queue 队列中,虚拟机会以较低的优先级执行这些 finalize()方法,但不会确保全部的 finalize() 方法都会执行结束。若是 finalize() 方法出现耗时操做,虚拟机就直接中止指向该方法,将对象清除。
若是在执行 finalize() 方法时,将 this 赋给了某一个引用,那么该对象就重生了。若是没有,那么就会被垃圾收集器清除。
任何一个对象的 finalize() 方法只会被系统自动调用一次,若是对象面临下一次回收,它的 finalize() 方法不会被再次执行,想继续在 finalize() 中自救就失效了。
方法区中存放生命周期较长的类信息、常量、静态变量,每次垃圾收集只有少许的垃圾被清除。方法区中主要清除两种垃圾:
只要常量池中的常量不被任何变量或对象引用,那么这些常量就会被清除掉。好比,一个字符串 "bingo" 进入了常量池,可是当前系统没有任何一个 String 对象引用常量池中的 "bingo" 常量,也没有其它地方引用这个字面量,必要的话,"bingo"常量会被清理出常量池。
断定一个类是不是“无用的类”,条件较为苛刻。
一个类被虚拟机加载进方法区,那么在堆中就会有一个表明该类的对象:java.lang.Class。这个对象在类被加载进方法区时建立,在方法区该类被删除时清除。
学会了如何断定无效对象、无用类、废弃常量以后,剩余工做就是回收这些垃圾。常见的垃圾收集算法有如下几个:
标记的过程是:遍历全部的 GC Roots
,而后将全部 GC Roots
可达的对象标记为存活的对象。
清除的过程将遍历堆中全部的对象,将没有标记的对象所有清除掉。与此同时,清除那些被标记过的对象的标记,以便下次的垃圾回收。
这种方法有两个不足:
为了解决效率问题,“复制”收集算法出现了。它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块内存用完,须要进行垃圾收集时,就将存活者的对象复制到另外一块上面,而后将第一块内存所有清除。这种算法有优有劣:
为了解决空间利用率问题,能够将内存分为三块: Eden、From Survivor、To Survivor,比例是 8:1:1,每次使用 Eden 和其中一块 Survivor。回收时,将 Eden 和 Survivor 中还存活的对象一次性复制到另一块 Survivor 空间上,最后清理掉 Eden 和刚才使用的 Survivor 空间。这样只有 10% 的内存被浪费。
可是咱们没法保证每次回收都只有很少于 10% 的对象存活,当 Survivor 空间不够,须要依赖其余内存(指老年代)进行分配担保。
分配担保
为对象分配内存空间时,若是 Eden+Survivor 中空闲区域没法装下该对象,会触发 MinorGC 进行垃圾收集。但若是 Minor GC 事后依然有超过 10% 的对象存活,这样存活的对象直接经过分配担保机制进入老年代,而后再将新对象存入 Eden 区。
标记:它的第一个阶段与标记/清除算法是如出一辙的,均是遍历 GC Roots
,而后将存活的对象标记。
整理:移动全部存活的对象,且按照内存地址次序依次排列,而后将末端内存地址之后的内存所有回收。所以,第二阶段才称为整理阶段。
这是一种老年代的垃圾收集算法。老年代的对象通常寿命比较长,所以每次垃圾回收会有大量对象存活,若是采用复制算法,每次须要复制大量存活的对象,效率很低。
根据对象存活周期的不一样,将内存划分为几块。通常是把 Java 堆分为新生代和老年代,针对各个年代的特色采用最适当的收集算法。
HotSpot 虚拟机提供了多种垃圾收集器,每种收集器都有各自的特色,虽然咱们要对各个收集器进行比较,但并不是为了挑选出一个最好的收集器。咱们选择的只是对具体应用最合适的收集器。
只开启一条 GC 线程进行垃圾回收,而且在垃圾收集过程当中中止一切用户线程(Stop The World)。
通常客户端应用所需内存较小,不会建立太多对象,并且堆内存不大,所以垃圾收集器回收时间短,即便在这段时间中止一切用户线程,也不会感受明显卡顿。所以 Serial 垃圾收集器适合客户端使用。
因为 Serial 收集器只使用一条 GC 线程,避免了线程切换的开销,从而简单高效。
ParNew 是 Serial 的多线程版本。由多条 GC 线程并行地进行垃圾清理。但清理过程依然须要 Stop The World。
ParNew 追求“低停顿时间”,与 Serial 惟一区别就是使用了多线程进行垃圾收集,在多 CPU 环境下性能比 Serial 会有必定程度的提高;但线程切换须要额外的开销,所以在单 CPU 环境中表现不如 Serial。
Parallel Scavenge 和 ParNew 同样,都是多线程、新生代垃圾收集器。可是二者有巨大的不一样点:
吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间)
追求高吞吐量,能够经过减小 GC 执行实际工做的时间,然而,仅仅偶尔运行 GC 意味着每当 GC 运行时将有许多工做要作,由于在此期间积累在堆中的对象数量很高。单个 GC 须要花更多的时间来完成,从而致使更高的暂停时间。而考虑到低暂停时间,最好频繁运行 GC 以便更快速完成,反过来又致使吞吐量降低。
Serial Old 收集器是 Serial 的老年代版本,都是单线程收集器,只启用一条 GC 线程,都适合客户端应用。它们惟一的区别就是:Serial Old 工做在老年代,使用“标记-整理”算法;Serial 工做在新生代,使用“复制”算法。
Parallel Old 收集器是 Parallel Scavenge 的老年代版本,追求 CPU 吞吐量。
CMS(Concurrent Mark Sweep,并发标记清除)收集器是以获取最短回收停顿时间为目标的收集器(追求低停顿),它在垃圾收集时使得用户线程和 GC 线程并发执行,所以在垃圾收集过程当中用户也不会感到明显的卡顿。
并发标记与并发清除过程耗时最长,且能够与用户线程一块儿工做,所以,整体上说,CMS 收集器的内存回收过程是与用户线程一块儿并发执行的。
CMS 的缺点:
对于产生碎片空间的问题,能够经过开启 -XX:+UseCMSCompactAtFullCollection,在每次 Full GC 完成后都会进行一次内存压缩整理,将零散在各处的对象整理到一块。设置参数 -XX:CMSFullGCsBeforeCompaction告诉 CMS,通过了 N 次 Full GC 以后再进行一次内存整理。
G1 是一款面向服务端应用的垃圾收集器,它没有新生代和老年代的概念,而是将堆划分为一块块独立的 Region。当要进行垃圾收集时,首先估计每一个 Region 中垃圾的数量,每次都从垃圾回收价值最大的 Region 开始回收,所以能够得到最大的回收效率。
从总体上看, G1 是基于“标记-整理”算法实现的收集器,从局部(两个 Region 之间)上看是基于“复制”算法实现的,这意味着运行期间不会产生内存空间碎片。
这里抛个问题👇:
一个对象和它内部所引用的对象可能不在同一个 Region 中,那么当垃圾回收时,是否须要扫描整个堆内存才能完整地进行一次可达性分析?
并不!每一个 Region 都有一个 Remembered Set,用于记录本区域中全部对象引用的对象所在的区域,进行可达性分析时,只要在 GC Roots 中再加上 Remembered Set 便可防止对整个堆内存进行遍历。
若是不计算维护 Remembered Set 的操做,G1 收集器的工做过程分为如下几个步骤:
对象的内存分配,就是在堆上分配(也可能通过 JIT 编译后被拆散为标量类型并间接在栈上分配),对象主要分配在新生代的 Eden 区上,少数状况下可能直接分配在老年代,分配规则不固定,取决于当前使用的垃圾收集器组合以及相关的参数配置。
如下列举几条最广泛的内存分配规则,供你们学习。
大多数状况下,对象在新生代 Eden 区中分配。当 Eden 区没有足够空间进行分配时,虚拟机将发起一次 Minor GC。
👇Minor GC vs Major GC/Full GC:
在 JVM 规范中,Major GC 和 Full GC 都没有一个正式的定义,因此有人也简单地认为 Major GC 清理老年代,而 Full GC 清理整个内存堆。
大对象是指须要大量连续内存空间的 Java 对象,如很长的字符串或数据。
一个大对象可以存入 Eden 区的几率比较小,发生分配担保的几率比较大,而分配担保须要涉及大量的复制,就会形成效率低下。
虚拟机提供了一个 -XX:PretenureSizeThreshold 参数,令大于这个设置值的对象直接在老年代分配,这样作的目的是避免在 Eden 区及两个 Survivor 区之间发生大量的内存复制。(还记得吗,新生代采用复制算法回收垃圾)
JVM 给每一个对象定义了一个对象年龄计数器。当新生代发生一次 Minor GC 后,存活下来的对象年龄 +1,当年龄超过必定值时,就将超过该值的全部对象转移到老年代中去。
使用 -XXMaxTenuringThreshold 设置新生代的最大年龄,只要超过该参数的新生代对象都会被转移到老年代中去。
若是当前新生代的 Survivor 中,相同年龄全部对象大小的总和大于 Survivor 空间的一半,年龄 >= 该年龄的对象就能够直接进入老年代,无须等到 MaxTenuringThreshold 中要求的年龄。
JDK 6 Update 24 以前的规则是这样的:
在发生 Minor GC 以前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代全部对象总空间, 若是这个条件成立,Minor GC 能够确保是安全的; 若是不成立,则虚拟机会查看 HandlePromotionFailure 值是否设置为容许担保失败, 若是是,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小, 若是大于,将尝试进行一次 Minor GC,尽管此次 Minor GC 是有风险的; 若是小于,或者 HandlePromotionFailure 设置不容许冒险,那此时也要改成进行一次 Full GC。
JDK 6 Update 24 以后的规则变为:
只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小,就会进行 Minor GC,不然将进行 Full GC。
经过清除老年代中废弃数据来扩大老年代空闲空间,以便给新生代做担保。
这个过程就是分配担保。
👇总结一下有哪些状况可能会触发 JVM 进行 Full GC:
System.gc() 方法的调用 此方法的调用是建议 JVM 进行 Full GC,注意这只是建议而非必定,但在不少状况下它会触发 Full GC,从而增长 Full GC 的频率。一般状况下咱们只须要让虚拟机本身去管理内存便可,咱们能够经过 -XX:+ DisableExplicitGC 来禁止调用 System.gc()。
老年代空间不足 老年代空间不足会触发 Full GC操做,若进行该操做后空间依然不足,则会抛出错误: java.lang.OutOfMemoryError: Java heap space
永久代空间不足 JVM 规范中运行时数据区域中的方法区,在 HotSpot 虚拟机中也称为永久代(Permanet Generation),存放一些类信息、常量、静态变量等数据,当系统要加载的类、反射的类和调用的方法较多时,永久代可能会被占满,会触发 Full GC。若是通过 Full GC 仍然回收不了,那么 JVM 会抛出错误信息:java.lang.OutOfMemoryError: PermGen space
CMS GC 时出现 promotion failed 和 concurrent mode failure promotion failed,就是上文所说的担保失败,而 concurrent mode failure 是在执行 CMS GC 的过程当中同时有对象要放入老年代,而此时老年代空间不足形成的。
统计获得的 Minor GC 晋升到旧生代的平均大小大于老年代的剩余空间
在高性能硬件上部署程序,目前主要有两种方式:
堆内存变大后,虽然垃圾收集的频率减小了,但每次垃圾回收的时间变长。 若是堆内存为14 G,那么每次 Full GC 将长达数十秒。若是 Full GC 频繁发生,那么对于一个网站来讲是没法忍受的。
对于用户交互性强、对停顿时间敏感的系统,能够给 Java 虚拟机分配超大堆的前提是有把握把应用程序的 Full GC 频率控制得足够低,至少要低到不会影响用户使用。
可能面临的问题:
在一台物理机器上启动多个应用服务器进程,每一个服务器进程分配不一样端口, 而后在前端搭建一个负载均衡器,以反向代理的方式来分配访问请求。
考虑到在一台物理机器上创建逻辑集群的目的仅仅是为了尽量利用硬件资源,并不须要关心状态保留、热转移之类的高可用性能需求, 也不须要保证每一个虚拟机进程有绝对的均衡负载,所以使用无 Session 复制的亲合式集群是一个不错的选择。 咱们仅仅须要保障集群具有亲合性,也就是均衡器按必定的规则算法(通常根据 SessionID 分配) 将一个固定的用户请求永远分配到固定的一个集群节点进行处理便可。
可能遇到的问题:
一个小型系统,使用 32 位 JDK,4G 内存,测试期间发现服务端不定时抛出内存溢出异常。 加入 -XX:+HeapDumpOnOutOfMemoryError(添加这个参数后,堆内存溢出时就会输出异常日志), 但再次发生内存溢出时,没有生成相关异常日志。
在 32 位 JDK 上,1.6G 分配给堆,还有一部分分配给 JVM 的其余内存,直接内存最大也只能在剩余的 0.4G 空间中分出一部分, 若是使用了 NIO,JVM 会在 JVM 内存以外分配内存空间,那么就要当心“直接内存”不足时发生内存溢出异常了。
直接内存虽然不是 JVM 内存空间,但它的垃圾回收也由 JVM 负责。
垃圾收集进行时,虚拟机虽然会对直接内存进行回收, 可是直接内存却不能像新生代、老年代那样,发现空间不足了就通知收集器进行垃圾回收, 它只能等老年代满了后 Full GC,而后“顺便”帮它清理掉内存的废弃对象。 不然只能一直等到抛出内存溢出异常时,先 catch 掉,再在 catch 块里大喊 “System.gc()”。 要是虚拟机仍是不听,那就只能眼睁睁看着堆中还有许多空闲内存,本身却不得不抛出内存溢出异常了。
谈论 JVM 的无关性,主要有如下两个:
Java 源代码首先须要使用 Javac 编译器编译成 .class 文件,而后由 JVM 执行 .class 文件,从而程序开始运行。
JVM 只认识 .class 文件,它不关心是何种语言生成了 .class 文件,只要 .class 文件符合 JVM 的规范就能运行。 目前已经有 JRuby、Jython、Scala 等语言可以在 JVM 上运行。它们有各自的语法规则,不过它们的编译器 都能将各自的源码编译成符合 JVM 规范的 .class 文件,从而可以借助 JVM 运行它们。
Java 语言中的各类变量、关键字和运算符号的语义最终都是由多条字节码命令组合而成的, 所以字节码命令所能提供的语义描述能力确定会比 Java 语言自己更增强大。 所以,有一些 Java 语言自己没法有效支持的语言特性,不表明字节码自己没法有效支持。
Class 文件是二进制文件,它的内容具备严格的规范,文件中没有任何空格,全都是连续的 0/1。Class 文件 中的全部内容被分为两种类型:无符号数、表。
Class 文件具体由如下几个构成:
Class 文件的头 4 个字节称为魔数,用来表示这个 Class 文件的类型。
Class 文件的魔数是用 16 进制表示的“CAFE BABE”,是否是很具备浪漫色彩?
魔数至关于文件后缀名,只不事后缀名容易被修改,不安全,所以在 Class 文件中标识文件类型比较合适。
紧接着魔数的 4 个字节是版本信息,5-6 字节表示次版本号,7-8 字节表示主版本号,它们表示当前 Class 文件中使用的是哪一个版本的 JDK。
高版本的 JDK 能向下兼容之前版本的 Class 文件,但不能运行之后版本的 Class 文件,即便文件格式并未发生任何变化,虚拟机也必需拒绝执行超过其版本号的 Class 文件。
版本信息以后就是常量池,常量池中存放两种类型的常量:
字面值常量
字面值常量就是咱们在程序中定义的字符串、被 final 修饰的值。
符号引用 符号引用就是咱们定义的各类名字:类和接口的全限定名、字段的名字和描述符、方法的名字和描述符。
类型 | tag | 描述 |
---|---|---|
CONSTANT_utf8_info | 1 | UTF-8编码的字符串 |
CONSTANT_Integer_info | 3 | 整型字面量 |
CONSTANT_Float_info | 4 | 浮点型字面量 |
CONSTANT_Long_info | 5 | 长整型字面量 |
CONSTANT_Double_info | 6 | 双精度浮点型字面量 |
CONSTANT_Class_info | 7 | 类或接口的符号引用 |
CONSTANT_String_info | 8 | 字符串类型字面量 |
CONSTANT_Fieldref_info | 9 | 字段的符号引用 |
CONSTANT_Methodref_info | 10 | 类中方法的符号引用 |
CONSTANT_InterfaceMethodref_info | 11 | 接口中方法的符号引用 |
CONSTANT_NameAndType_info | 12 | 字段或方法的符号引用 |
CONSTANT_MethodHandle_info | 15 | 表示方法句柄 |
CONSTANT_MethodType_info | 16 | 标识方法类型 |
CONSTANT_InvokeDynamic_info | 18 | 表示一个动态方法调用点 |
对于 CONSTANT_Class_info(此类型的常量表明一个类或者接口的符号引用),它的二维表结构以下:
类型 | 名称 | 数量 |
---|---|---|
u1 | tag | 1 |
u2 | name_index | 1 |
tag 是标志位,用于区分常量类型;name_index 是一个索引值,它指向常量池中一个 CONSTANT_Utf8_info 类型常量,此常量表明这个类(或接口)的全限定名,这里 name_index 值若为 0x0002,也便是指向了常量池中的第二项常量。
CONSTANT_Utf8_info 型常量的结构以下:
类型 | 名称 | 数量 |
---|---|---|
u1 | tag | 1 |
u2 | length | 1 |
u1 | bytes | length |
tag 是当前常量的类型;length 表示这个字符串的长度;bytes 是这个字符串的内容(采用缩略的 UTF8 编码)
在常量池结束以后,紧接着的两个字节表明访问标志,这个标志用于识别一些类或者接口层次的访问信息,包括:这个 Class 是类仍是接口;是否认义为 public 类型;是否被 abstract/final 修饰。
类索引和父类索引都是一个 u2 类型的数据,而接口索引集合是一组 u2 类型的数据的集合,Class 文件中由这三项数据来肯定类的继承关系。类索引用于肯定这个类的全限定名,父类索引用于肯定这个类的父类的全限定名。
因为 Java 不容许多重继承,因此父类索引只有一个,除了 java.lang.Object 以外,全部的 Java 类都有父类,所以除了 java.lang.Object 外,全部 Java 类的父类索引都不为 0。一个类可能实现了多个接口,所以用接口索引集合来描述。这个集合第一项为 u2 类型的数据,表示索引表的容量,接下来就是接口的名字索引。
类索引和父类索引用两个 u2 类型的索引值表示,它们各自指向一个类型为 CONSTANT_Class_info 的类描述符常量,经过该常量总的索引值能够找到定义在 CONSTANT_Utf8_info 类型的常量中的全限定名字符串。
字段表集合存储本类涉及到的成员变量,包括实例变量和类变量,但不包括方法中的局部变量。
每个字段表只表示一个成员变量,本类中的全部成员变量构成了字段表集合。字段表结构以下:
类型 | 名称 | 数量 | 说明 |
---|---|---|---|
u2 | access_flags | 1 | 字段的访问标志,与类稍有不一样 |
u2 | name_index | 1 | 字段名字的索引 |
u2 | descriptor_index | 1 | 描述符,用于描述字段的数据类型。 基本数据类型用大写字母表示; 对象类型用“L 对象类型的全限定名”表示。 |
u2 | attributes_count | 1 | 属性表集合的长度 |
u2 | attributes | attributes_count | 属性表集合,用于存放属性的额外信息,如属性的值。 |
字段表集合中不会出现从父类(或接口)中继承而来的字段,但有可能出现本来 Java 代码中不存在的字段,譬如在内部类中为了保持对外部类的访问性,会自动添加指向外部类实例的字段。
方法表结构与属性表相似。
volatile 关键字 和 transient 关键字不能修饰方法,因此方法表的访问标志中没有 ACC_VOLATILE 和 ACC_TRANSIENT 标志。
方法表的属性表集合中有一张 Code 属性表,用于存储当前方法经编译器编译后的字节码指令。
每一个属性对应一张属性表,属性表的结构以下:
类型 | 名称 | 数量 |
---|---|---|
u2 | attribute_name_index | 1 |
u4 | attribute_length | 1 |
u1 | info | attribute_length |
类从被加载到虚拟机内存开始,到卸载出内存为止,它的整个生命周期包括如下 7 个阶段:
验证、准备、解析 3 个阶段统称为链接。
加载、验证、准备、初始化和卸载这 5 个阶段的顺序是肯定的,类的加载过程必须按照这种顺序循序渐进地开始(注意是“开始”,而不是“进行”或“完成”),而解析阶段则不必定:它在某些状况下能够在初始化后再开始,这是为了支持 Java 语言的运行时绑定。
Java 虚拟机规范没有强制约束类加载过程的第一阶段(即:加载)何时开始,但对于“初始化”阶段,有着严格的规定。有且仅有 5 种状况必须当即对类进行“初始化”:
这 5 种场景中的行为称为对一个类进行主动引用,除此以外,其它全部引用类的方式都不会触发初始化,称为被动引用。
/** * 被动引用 Demo1: * 经过子类引用父类的静态字段,不会致使子类初始化。 * * @author ylb * */
class SuperClass {
static {
System.out.println("SuperClass init!");
}
public static int value = 123;
}
class SubClass extends SuperClass {
static {
System.out.println("SubClass init!");
}
}
public class NotInitialization {
public static void main(String[] args) {
System.out.println(SubClass.value);
// SuperClass init!
}
}
复制代码
对于静态字段,只有直接定义这个字段的类才会被初始化,所以经过其子类来引用父类中定义的静态字段,只会触发父类的初始化而不会触发子类的初始化。
/** * 被动引用 Demo2: * 经过数组定义来引用类,不会触发此类的初始化。 * * @author ylb * */
public class NotInitialization {
public static void main(String[] args) {
SuperClass[] superClasses = new SuperClass[10];
}
}
复制代码
这段代码不会触发父类的初始化,但会触发“[L 全类名”这个类的初始化,它由虚拟机自动生成,直接继承自 java.lang.Object,建立动做由字节码指令 newarray 触发。
/** * 被动引用 Demo3: * 常量在编译阶段会存入调用类的常量池中,本质上并无直接引用到定义常量的类,所以不会触发定义常量的类的初始化。 * * @author ylb * */
class ConstClass {
static {
System.out.println("ConstClass init!");
}
public static final String HELLO_BINGO = "Hello Bingo";
}
public class NotInitialization {
public static void main(String[] args) {
System.out.println(ConstClass.HELLO_BINGO);
}
}
复制代码
编译经过以后,常量存储到 NotInitialization 类的常量池中,NotInitialization 的 Class 文件中并无 ConstClass 类的符号引用入口,这两个类在编译成 Class 以后就没有任何联系了。
接口加载过程与类加载过程稍有不一样。
当一个类在初始化时,要求其父类所有都已经初始化过了,可是一个接口在初始化时,并不要求其父接口所有都完成了初始化,当真正用到父接口的时候才会初始化。
类加载过程包括 5 个阶段:加载、验证、准备、解析和初始化。
“加载”是“类加载”过程的一个阶段,不能混淆这两个名词。在加载阶段,虚拟机须要完成 3 件事:
对于 Class 文件,虚拟机没有指明要从哪里获取、怎样获取。除了直接从编译好的 .class 文件中读取,还有如下几种方式:
验证阶段确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,而且不会危害虚拟机自身的安全。
准备阶段是正式为类变量(或称“静态成员变量”)分配内存并设置初始值的阶段。这些变量(不包括实例变量)所使用的内存都在方法区中进行分配。
初始值“一般状况下”是数据类型的零值(0, null...),假设一个类变量的定义为:
public static int value = 123;
复制代码
那么变量 value 在准备阶段事后的初始值为 0 而不是 123,由于这时候还没有开始执行任何 Java 方法。
存在“特殊状况”:若是类字段的字段属性表中存在 ConstantValue 属性,那么在准备阶段 value 就会被初始化为 ConstantValue 属性所指定的值,假设上面类变量 value 的定义变为:
public static final int value = 123;
复制代码
那么在准备阶段虚拟机会根据 ConstantValue 的设置将 value 赋值为 123。
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。
类初始化阶段是类加载过程的最后一步,是执行类构造器 <clinit>() 方法的过程。
<clinit>() 方法是由编译器自动收集类中的全部类变量的赋值动做和静态语句块(static {} 块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的。
静态语句块中只能访问定义在静态语句块以前的变量,定义在它以后的变量,在前面的静态语句块中能够赋值,但不能访问。以下方代码所示:
public class Test {
static {
i = 0; // 给变量赋值能够正常编译经过
System.out.println(i); // 这句编译器会提示“非法向前引用”
}
static int i = 1;
}
复制代码
<clinit>() 方法不须要显式调用父类构造器,虚拟机会保证在子类的 <clinit>() 方法执行以前,父类的 <clinit>() 方法已经执行完毕。
因为父类的 <clinit>() 方法先执行,意味着父类中定义的静态语句块要优先于子类的变量赋值操做。以下方代码所示:
static class Parent {
public static int A = 1;
static {
A = 2;
}
}
static class Sub extends Parent {
public static int B = A;
}
public static void main(String[] args) {
System.out.println(Sub.B); // 输出 2
}
复制代码
<clinit>() 方法不是必需的,若是一个类没有静态语句块,也没有对类变量的赋值操做,那么编译器能够不为这个类生成 <clinit>() 方法。
接口中不能使用静态代码块,但接口也须要经过 <clinit>() 方法为接口中定义的静态成员变量显式初始化。但接口与类不一样,接口的 <clinit>() 方法不须要先执行父类的 <clinit>() 方法,只有当父接口中定义的变量使用时,父接口才会初始化。
虚拟机会保证一个类的 <clinit>() 方法在多线程环境中被正确加锁、同步。若是多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的 <clinit>() 方法。
任意一个类,都由加载它的类加载器和这个类自己一同确立其在 Java 虚拟机中的惟一性,每个类加载器,都有一个独立的类名称空间。
所以,比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义,不然,即便这两个类来源于同一个 Class 文件,被同一个虚拟机加载,只要加载它们的类加载器不一样,那么这两个类就一定不相等。
这里的“相等”,包括表明类的 Class 对象的 equals() 方法、isInstance() 方法的返回结果,也包括使用 instanceof 关键字作对象所属关系断定等状况。
系统提供了 3 种类加载器:
<JAVA_HOME>\lib
目录中的,而且能被虚拟机识别的(仅按照文件名识别,如 rt.jar,名字不符合的类库即便放在 lib 目录中也不会被加载)类库加载到虚拟机内存中。<JAVA_HOME>\lib\ext
目录中的全部类库,开发者能够直接使用扩展类加载器。固然,若是有必要,还能够加入本身定义的类加载器。
双亲委派模型是描述类加载器之间的层次关系。它要求除了顶层的启动类加载器外,其他的类加载器都应当有本身的父类加载器。(父子关系通常不会以继承的关系实现,而是以组合关系来复用父加载器的代码)
若是一个类加载器收到了类加载的请求,它首先不会本身去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每个层次的类加载器都是如此,所以全部的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈本身没法完成这个加载请求(找不到所需的类)时,子加载器才会尝试本身去加载。
在 java.lang.ClassLoader 中的 loadClass() 方法中实现该过程。
像 java.lang.Object 这些存放在 rt.jar 中的类,不管使用哪一个类加载器加载,最终都会委派给最顶端的启动类加载器加载,从而使得不一样加载器加载的 Object 类都是同一个。
相反,若是没有使用双亲委派模型,由各个类加载器自行去加载的话,若是用户本身编写了一个称为 java.lang.Object 的类,并放在 classpath 下,那么系统将会出现多个不一样的 Object 类,Java 类型体系中最基础的行为也就没法保证。