《深入理解Java虚拟机》笔记_第一遍

《深入理解Java虚拟机》笔记_第一遍

先取看完这本书(JVM)后必须掌握的部分。

第一部分 走近 Java

从传统意义上看,Sun 官方所定义的 Java 技术体系包括:

  • Java 程序设计语言
  • 各种硬件平台上的 Java 虚拟机
  • Class 文件格式
  • Java API 类库
  • 来自商业机构和开源社区的第三方 Java 库

在 2006 年 11 月 13 日的 JavaOne 大会上,Sun 公司宣布最终会将Java开源,并在随后的一年多时间内,陆续将 JDK 的各个部分在 GPL v2 (GNU General Public License v2)协议下公开了源码,并建立了 OpenJDK 组织对这些源码进行独立管理。在 JDK 1.7 中,Sun JDK 和 OpenJDK 除了代码文件头的版权注释之外,代码基本完全一样,所以 OpenJDK 7 与 JDK 1.7 本质上就是同一套代码库开发的产品。


Google Android Dalvik VM 只能称作虚拟机,而不能称作“Java 虚拟机”,它没有遵循 Java 虚拟机规范,不能直接执行 Java 的 Class 文件,使用的是寄存器架构而不是 JVM 中常见的栈结构。但是它与 Java 又有千丝万缕的联系,它执行的 dex (Dalvik Executable)文件可以由 Class 文件转化而来,使用 Java 语法编写应用程序,可以直接使用大部分的 Java API 等。

第二部分 自动内存管理机制

Java 内存区域与内存溢出异常

Java 和 C++ 之间有一堵由动态内存分配和垃圾收集技术所围成的“高墙”,墙外面的人想进去,墙里面的人却想出来。

Java 运行时数据区域

程序计数器

程序计数器是一块较小的内存空间,它可以看成当前线程所执行的字节码的行号指示器。字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

为了线程切换后能够恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器。

此内存区域是唯一一个在 Java 虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域。

Java 虚拟机栈

Java 虚拟机栈生命周期与线程相同。

每个方法在执行的同时都会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用到执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。

局部变量表存放了编译器可知的各种基本数据类型、对象引用、returnAddress(指向一条字节码指令的地址)。
局部变量表所需的内存空间在编译期分配完成,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。

虚拟机栈有两种异常:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出 StackOverFlowError 异常;如果虚拟机栈可以动态扩展(当前大部分 Java 虚拟机都可动态扩展),但扩展时无法申请到足够的内存,就会抛出 OutOfMemoryError 异常。

本地方法栈(Native Method Stack)

本地方法 栈与虚拟机栈所发挥的作用是非常相似的,它们之间的区别不过是虚拟机栈是为虚拟机执行 Java 方法服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。

在 HotSpot 虚拟机中直接将本地方法栈与虚拟机栈合二为一了。

Java 堆

对于大多数应用来说,Java 堆是 Java 虚拟机所管理的内存中最大的一块。Java 堆是被所有线程所共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。

从内存回收角度来看,Java 堆可分为新生代和老年代,其中新生代可进一步细分为 Eden 空间、From Survivor 空间、To Survivor 空间。
从内存分配角度来看,线程共享的 Java 堆中可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB)。

如果堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出 OutOfMemoryError 异常。

方法区

方法区(Method Area)与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

永久代:HotSpot 虚拟机把 GC 分代收集扩展至方法区,或者说用永久代来实现方法区,这样就可以像管理 Java 堆一样管理这部分代码,能够省去专门为方法区编写内存管理代码的工作。

这个区域内存回收的目标主要是针对常量池的回收和对类型的卸载,一般来说,这个区域的回收成绩比较难以令人满意,尤其是类型的卸载,条件相当苛刻。

运行时常量池是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池,用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池存放。

直接内存

直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分。

在 JDK 1.4 总新加入了 NIO 类,引入了一种基于通道(Channel)与缓冲区(Buffer)的 I/O 方式,它可以使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆中来回复制数据。

对象的内存布局

在 HotSpot 虚拟机中,对象在内存中存储的布局可以分为 3 块区域:对象头、实例数据和对齐填充。

  • 对象头包括两部分信息:
    • 第一部分,哈希码、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等;
    • 第二部分,类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例;
  • 实例数据部分是对象真正存储的有效信息,即在代码中定义的各种类型的字段内容。无论是从父类继承下来的,还是在子类中定义的。
  • 对齐填充:HotSpot 虚拟机要求对象的起始地址必须是 8 字节的整数倍,换句话说,就是对象的大小必须是 8 字节的整数倍。

垃圾收集器与内存分配策略

对象存活判定算法

引用计数算法

给对象添加一个引用计数器,每当有一个地方引用它时,计数器值就加 1 ;当引用失效时,计数器值就减 1;任何时刻计数器值为 0 的对象就是不可能再被使用的。

Python 就用的是引用计数算法。链接-Python怎么解决循环引用问题

客观来说,引用计数算法的实现简单,判定效率也很高,在大部分情况下它都是一个不错的算法。但是,至少主流的 Java 虚拟机里面没有选用引用计数算法来管理内存,其中最主要的原因是它很难解决对象之间的循环引用问题。

可达性分析算法

在主流的商用程序语言(Java、C#)的主流实现中,都是通过可达性分析来判定对象是否存货的。这个算法的基本思路是通过一系列的称为“GC Roots”的对象作为起始点,从这些起始点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连(用图论的话来说,就是从 GC Roots 到这个对象不可达)时,则证明此对象是不可用的。

在 Java 语言中,可作为 GC Roots 的对象包括下面几种:

  • 虚拟机栈(栈帧的本地变量表)中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈中 JNI (即一般说的 Native 方法)引用的对象
再谈引用

我们希望能描述这样一类对象:当内存空间还足够时,则能保留在内存中;如果内存空间在进行垃圾收集后还是很紧张,则可以抛弃这些对象。

  • 强引用:Object obj = new Object();
  • 软引用(SoftReference):在系统将要发生内存溢出之前,将会把这些对象列入回收范围之中进行第二次回收。如果这次回收后还没有足够的内存,才会抛出内存溢出异常。
  • 弱引用(WeakReference):被弱引用关联的对象只能生存到下一次垃圾收集之前。当垃圾收集器工作时,无论内存空间是否足够,都会回收掉被弱引用关联的对象。
  • 虚引用(PhantomReference):一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来获得一个对象的实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。

垃圾收集算法

标记-清除(Mark-Sweep)算法

这是最基础的收集算法。算法分为“标记”和“清除”两个阶段:首先标记出所有需要被回收的对象,在标记完成后统一回收所有被标记的对象。

它的主要不足有两个:一个是效率问题,标记和清除两个过程的效率都不高;另一个是空间问题,会产生大量不连续的内存碎片。

复制算法

为了解决效率问题,一种称为“复制”的收集算法出现了,它将可用内存划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另一块上面,然后再把已使用过那一块内存空间一次性清理掉。

优点:每次只对其中一块进行GC,不用考虑内存碎片的问题,并且实现简单,运行高效
缺点:内存缩小了一半

现在的商业虚拟机都是用这种收集算法回收新生代。IBM 公司的专门研究表明,新生代中 80% 的对象都是“朝生夕死”的,所以并不需要按照 1:1 的比例来划分内存空间,而是将内存分为一块较大的Eden空间和两块较小的Survior空间,每次使用Eden和其中的一块Survior.当回收时,将Eden和Survior中还存活的对象一次性拷贝到另外一块Survior空间上,最后清理Eden和刚才用过的Survior空间。Hotspot 虚拟机中 Eden 和 两个 Survivor 的比例是 8:1:1.

分配担保:如果另外一块 Survivor 空间没有足够的空间存放上一次新生代收集下来的存活对象时,这些对象将直接通过分配担保机制进入老年代。(50% : 50% 的两块等大小空间,不需要分配担保)

标记-整理(Mark-Compact)算法

复制收集算法在对象存活率较高时就要进行较多的复制操作,效率将会变低。更关键的是,如果不想浪费 50% 的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中有大量对象存活的情况。所以老年代一般不能直接选用这种算法。

根据老年代的特点,有人提出了另一种“标记-整理”算法,标记过程仍然与“标记-清除”算法一致,但后续步骤不是直接对可回收对象进行清理,而是将所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。

分代收集算法

当前商业虚拟机的垃圾收集都采用“分代收集”算法,这种算法并没有什么新的的思想,只是根据对象存活周期的不同将内存划分为几块。一般是把 Java 堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适合的收集算法。在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须采用“标记-清除”或者“标记-整理”算法来进行回收。

“Stop The World”

可达性分析算法中枚举根节点(GC Roots)时,整个执行系统看起来像被冻结在某个时间节点上,不可以出现分析过程中对象引用关系还在不断变化的情况。这点是导致 GC 进行时必须停顿所有 Java 执行线程(Sun 将这件事称为 “Stop The World”)的一个重要原因。

安全点

程序执行时并非在所有地方都能停顿下来开始 GC ,只有在特定的位置才能暂停,这些位置称为“安全点(Safepoint)”。

安全区域:Safepoint 机制保证了程序执行时,在不太长的时间内就会遇到可进入 GC 的 Safepoint 。但是,程序“不执行”的时候呢?例如线程处于 Sleep 状态或 Blocked 状态,这时候线程无法响应 JVM 的中断请求,“走”到安全的地方去中断挂起,JVM 也不太可能等待线程被唤醒。对于这种情况,就需要安全区域(Safe Region)来解决。

安全区域是指在一段代码之中,引用关系不会发生变化。在这个区域中的任意位置开始 GC 都是安全的。我们也可以把 Safe Region 看做是被扩展了的 Safepoint。

垃圾收集器

如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。

Java虚拟机规范中对垃圾收集器应该如何实现并没有任何规定,因此不同的厂商、不同版本的虚拟机所提供的垃圾收集器都可能会有很大差别,并且一般都会提供参数供用户根据自己的应用特点和要求组合出各个年代所使用的收集器。

HotSpot 虚拟机的垃圾收集器:

概念理解
  1. 并发和并行
    这两个名词都是并发编程中的概念,在谈论垃圾收集器的上下文语境中,它们可以解释如下:
    • 并行(Parallel):指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态。
    • 并发(Concurrent):指用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),用户程序在继续运行,而垃圾收集程序运行于另一个CPU上。
  2. Minor GC 和 Full GC
    • 新生代GC(Minor GC):指发生在新生代的垃圾收集动作,因为Java对象大多都具备朝生夕灭的特性,所以Minor GC非常频繁,一般回收速度也比较快。
    • 老年代GC(Major GC / Full GC):指发生在老年代的GC,出现了Major GC,经常会伴随至少一次的Minor GC(但非绝对的,在Parallel Scavenge收集器的收集策略里就有直接进行Major GC的策略选择过程)。Major GC的速度一般会比Minor GC慢10倍以上。
  3. 吞吐量
    吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值,即吞吐量 = 运行用户代码时间 /(运行用户代码时间 + 垃圾收集时间)。
    虚拟机总共运行了100分钟,其中垃圾收集花掉1分钟,那吞吐量就是99%。
一、Serial 收集器

Serial 收集器是最基本、发展历史最悠久的收集器,曾经(在 JDK 1.3.1 之前)是虚拟机新生代收集的唯一选择。

特性:
这个收集器是一个单线程的收集器,但它的“单线程”的意义并不仅仅说明它只会使用一个 CPU 或一条收集线程去完成垃圾收集工作,更重要的是在它进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束(Stop The World)。

应用场景:
Serial 收集器是虚拟机运行在 Client 模式下的默认新生代收集器。

优势:
简单而高效(与其他收集器的单线程比),对于限定单个 CPU 的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。

二、ParNew 收集器

特性:
ParNew 收集器其实就是 Serial 收集器的多线程版本,除了使用多条线程进行垃圾收集之外,其余行为包括Serial收集器可用的所有控制参数、收集算法、Stop The World、对象分配规则、回收策略等都与Serial收集器完全一样,在实现上,这两种收集器也共用了相当多的代码。

应用场景:
ParNew 收集器是许多运行在 Server 模式下的虚拟机中首选的新生代收集器。

很重要的原因是:除了 Serial 收集器外,目前只有它能与 CMS 收集器配合工作。
在 JDK 1.5 时期,HotSpot 推出了一款在强交互应用中几乎可认为有划时代意义的垃圾收集器——CMS 收集器,这款收集器是 HotSpot 虚拟机中第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程同时工作。
不幸的是,CMS 作为老年代的收集器,却无法与 JDK 1.4.0 中已经存在的新生代收集器Parallel Scavenge 配合工作,所以在 JDK 1.5 中使用 CMS 来收集老年代的时候,新生代只能选择 ParNew 或者 Serial 收集器中的一个。

Serial 收集器 VS ParNew 收集器:
ParNew 收集器在单 CPU 的环境中绝对不会有比 Serial 收集器更好的效果,甚至由于存在线程交互的开销,该收集器在通过超线程技术实现的两个 CPU 的环境中都不能百分之百地保证可以超越 Serial 收集器。
然而,随着可以使用的 CPU 的数量的增加,它对于 GC 时系统资源的有效利用还是很有好处的。

三、Parallel Scavenge收集器

Parallel Scavenge 收集器是一个新生代收集器,它也是使用复制算法的收集器,又是并行的多线程收集器......看上去和 ParNew 都一样,那它有什么特别之处呢?

Parallel Scavenge 收集器的特点是它的关注点和其他收集器不同,CMS 等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而 Parallel Scavenge 收集器的目标则是达到一个可控制的吞吐量(Throughput)。

停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验,而高吞吐量则可以高效率地利用 CPU 时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。

Parallel Scavenge 收集器与 ParNew 收集器的一个重要区别是它具有自适应调节策略

GC 自适应的调节策略
Parallel Scavenge 收集器有一个参数 -XX:+UseAdaptiveSizePolicy。当这个参数打开之后,就不需要手工指定新生代的大小、Eden 与 Survivor 区的比例、晋升老年代对象年龄等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量,这种调节方式称为 GC 自适应的调节策略(GC Ergonomics)。

四、Serial Old 收集器

特性:
Serial Old 是 Serial 收集器的老年代版本,它同样是一个单线程收集器,使用标记-整理算法。

应用场景:

Client 模式
Serial Old 收集器的主要意义也是在于给 Client 模式下的虚拟机使用。

Server 模式
如果在 Server 模式下,那么它主要还有两大用途:一种用途是在 JDK 1.5 以及之前的版本中与 Parallel Scavenge 收集器搭配使用,另一种用途就是作为 CMS 收集器的后备预案,在并发收集发生 Concurrent Mode Failure 时使用。

五、Parallel Old 收集器

特性:
Parallel Old 是 Parallel Scavenge 收集器的老年代版本,使用多线程和“标记-整理”算法。

应用场景:
注重吞吐量以及 CPU 资源敏感的场合,都可以优先考虑 Parallel Scavenge 加 Parallel Old 收集器。

这个收集器是在 JDK 1.6 中才开始提供的,在此之前,新生代的 Parallel Scavenge 收集器一直处于比较尴尬的状态。原因是,如果新生代选择了 Parallel Scavenge 收集器,老年代除了 Serial Old 收集器外别无选择(Parallel Scavenge 收集器无法与 CMS 收集器配合工作)。由于老年代 Serial Old 收集器在服务端应用性能上的“拖累”,使用了Parallel Scavenge 收集器也未必能在整体应用上获得吞吐量最大化的效果,由于单线程的老年代收集中无法充分利用服务器多 CPU 的处理能力,在老年代很大而且硬件比较高级的环境中,这种组合的吞吐量甚至还不一定有 ParNew 加 CMS 的组合“给力”。直到 Parallel Old 收集器出现后,“吞吐量优先”收集器终于有了比较名副其实的应用组合。

六、CMS 收集器

特性:
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的 Java 应用集中在互联网站或者 B/S 系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。 CMS 收集器就非常符合这类应用的需求。

CMS 收集器是基于“标记—清除”算法实现的,它的运作过程相对于前面几种收集器来说更复杂一些,整个过程分为 4 个步骤:

  1. 初始标记(CMS initial mark)
    初始标记仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快,需要“Stop The World”。

  2. 并发标记(CMS concurrent mark)
    并发标记阶段就是进行 GC Roots Tracing 的过程。

  3. 重新标记(CMS remark)
    重新标记阶段是为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短,仍然需要“Stop The World”。

  4. 并发清除(CMS concurrent sweep)
    并发清除阶段会清除对象。

由于整个过程中耗时最长的并发标记和并发清除过程收集器线程都可以与用户线程一起工作,所以,从总体上来说,CMS 收集器的内存回收过程是与用户线程一起并发执行的。

优点:
CMS是一款优秀的收集器,它的主要优点在名字上已经体现出来了:并发收集、低停顿。

缺点:
CMS 收集器对 CPU 资源非常敏感

其实,面向并发设计的程序都对 CPU 资源比较敏感。在并发阶段,它虽然不会导致用户线程停顿,但是会因为占用了一部分线程(或者说 CPU 资源)而导致应用程序变慢,总吞吐量会降低。
CMS 默认启动的回收线程数是(CPU数量+3)/ 4,也就是当 CPU 在4个以上时,并发回收时垃圾收集线程不少于 25% 的 CPU 资源,并且随着 CPU 数量的增加而下降。但是当 CPU 不足 4 个(譬如 2 个)时,CMS 对用户程序的影响就可能变得很大。

CMS 收集器无法处理浮动垃圾

CMS 收集器无法处理浮动垃圾,可能出现“Concurrent Mode Failure”失败而导致另一次 Full GC 的产生。

由于 CMS 并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS 无法在当次收集中处理掉它们,只好留待下一次 GC 时再清理掉。这一部分垃圾就称为“浮动垃圾”。

也是由于在垃圾收集阶段用户线程还需要运行,那也就还需要预留有足够的内存空间给用户线程使用,因此 CMS 收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,需要预留一部分空间提供并发收集时的程序运作使用。要是 CMS 运行期间预留的内存无法满足程序需要,就会出现一次“Concurrent Mode Failure”失败,这时虚拟机将启动后备预案:临时启用 Serial Old 收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了。

CMS 收集器会产生大量空间碎片

CMS 是一款基于“标记—清除”算法实现的收集器,这意味着收集结束时会有大量空间碎片产生

空间碎片过多时,将会给大对象分配带来很大麻烦,往往会出现老年代还有很大空间剩余,但是无法找到足够大的连续空间来分配当前对象,不得不提前触发一次 Full GC

七、G1 收集器 —— “化整为零”

G1(Garbage-First)收集器是当今收集器技术发展的最前沿成果之一。

特性:
G1(Garbage-First)是一款面向服务端应用的垃圾收集器。HotSpot 开发团队赋予它的使命是未来可以替换掉 JDK 1.5 中发布的 CMS 收集器。与其他 GC 收集器相比,G1 具备如下特点。

  • 并行与并发
    G1 能充分利用多 CPU、多核环境下的硬件优势,使用多个 CPU 来缩短 Stop-The-World 停顿的时间,部分其他收集器原本需要停顿 Java 线程执行的 GC 动作,G1 收集器仍然可以通过并发的方式让 Java 程序继续执行。

  • 分代收集
    与其他收集器一样,分代概念在 G1 中依然得以保留。虽然 G1 可以不需要其他收集器配合就能独立管理整个 GC 堆,但它能够采用不同的方式去处理新创建的对象和已经存活了一段时间、熬过多次 GC 的旧对象以获取更好的收集效果。

  • 空间整合
    与 CMS 的“标记—清理”算法不同,G1 从整体来看是基于“标记—整理”算法实现的收集器,从局部(两个 Region 之间)上来看是基于“复制”算法实现的,但无论如何,这两种算法都意味着 G1 运作期间不会产生内存空间碎片,收集后能提供规整的可用内存。这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次 GC。

  • 可预测的停顿
    这是 G1 相对于 CMS 的另一大优势,降低停顿时间是 G1 和 CMS 共同的关注点,但 G1除了追求低停顿外,还能建立可预测的停顿时间模型能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在垃圾收集上的时间不得超过 N 毫秒

在 G1 之前的其他收集器进行收集的范围都是整个新生代或者老年代,而 G1 不再是这样。使用 G1 收集器时,Java 堆的内存布局就与其他收集器有很大差别,它将整个 Java 堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分 Region(不需要连续)的集合

一个 region 有可能属于 Eden,Survivor 或者 Tenured 内存区域。图中的 E 表示该 region 属于 Eden 内存区域,S 表示属于 Survivor 内存区域,T 表示属于 Tenured 内存区域。图中空白的表示未使用的内存空间。G1 垃圾收集器还增加了一种新的内存区域,叫做 Humongous 内存区域,如图中的 H 块。这种内存区域主要用于存储大对象-即大小超过一个 region 大小的 50% 的对象。

G1 收集器之所以能建立可预测的停顿时间模型,是因为它可以有计划地避免在整个Java 堆中进行全区域的垃圾收集。G1 跟踪各个 Region 里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region(这也就是 Garbage-First 名称的来由)。这种使用 Region 划分内存空间以及有优先级的区域回收方式,保证了 G1 收集器在有限的时间内可以获取尽可能高的收集效率。

执行过程:
G1收集器的运作大致可划分为以下几个步骤:

  1. 初始标记(Initial Marking)
    初始标记阶段仅仅只是标记一下 GC Roots 能直接关联到的对象,并且修改 TAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可用的 Region 中创建新对象,这阶段需要停顿线程,但耗时很短。

  2. 并发标记(Concurrent Marking)
    并发标记阶段是从 GC Root 开始对堆中对象进行可达性分析,找出存活的对象,这阶段耗时较长,但可与用户程序并发执行。

  3. 最终标记(Final Marking)
    最终标记阶段是为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程 Remembered Set Logs 里面,最终标记阶段需要把 Remembered Set Logs 的数据合并到 Remembered Set 中,这阶段需要停顿线程,但是可并行执行。

  4. 筛选回收(Live Data Counting and Evacuation)
    筛选回收阶段首先对各个 Region 的回收价值和成本进行排序,根据用户所期望的 GC 停顿时间来制定回收计划,这个阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分 Region,时间是用户可控制的,而且停顿用户线程将大幅提高收集效率。

内存分配与回收策略

Java 技术体系中所提倡的自动内存管理最终可以归结为自动化地解决了两个问题:给对象分配内存以及回收分配给对象的内存。

1. 对象优先在Eden分配

大多数情况下,对象在新生代 Eden 区中分配。当 Eden 区没有足够空间进行分配时,虚拟机将发起一次 Minor GC。

2. 大对象直接进入老年代

所谓的大对象是指,需要大量连续内存空间的 Java 对象,最典型的大对象就是那种很长的字符串以及数组。

大对象对虚拟机的内存分配来说就是一个坏消息(比遇到一个大对象更加坏的消息就是遇到一群“朝生夕灭”的“短命大对象”,写程序的时候应当避免),经常出现大对象容易导致内存还有不少空间时就提前触发垃圾收集以获取足够的连续空间来“安置”它们。

虚拟机提供了一个 -XX:PretenureSizeThreshold 参数,令大于这个设置值的对象直接在老年代分配。这样做的目的是避免在 Eden 区及两个 Survivor 区之间发生大量的内存复制(复习一下:新生代采用复制算法收集内存)。

3. 长期存活的对象将进入老年代

为了在内存回收时能识别哪些对象应放在新生代,哪些对象应放在老年代中。虚拟机给每个对象定义了一个对象年龄(Age)计数器。

对象年龄的判定:
如果对象在 Eden 出生并经过第一次 Minor GC 后仍然存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间中,并且对象年龄设为 1。
对象在 Survivor 区中每“熬过”一次 Minor GC,年龄就增加 1 岁,当它的年龄增加到一定程度(默认为 15 岁),就将会被晋升到老年代中。
对象晋升老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 设置。

4. 动态对象年龄判定

为了能更好地适应不同程序的内存状况,虚拟机并不是永远地要求对象的年龄必须达到了 MaxTenuringThreshold 才能晋升老年代.

动态对象年龄判定
如果在 Survivor 空间中相同年龄所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到 MaxTenuringThreshold 中要求的年龄。

5. 空间分配担保

在发生 Minor GC 之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么 Minor GC 可以确保是安全的。如果不成立,则虚拟机会查看 HandlePromotionFailure 设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次 Minor GC,尽管这次 Minor GC 是有风险的;如果小于,或者 HandlePromotionFailure 设置不允许冒险,那这时也要改为进行一次 Full GC。

下面解释一下“冒险”是冒了什么风险,前面提到过,新生代使用复制收集算法,但为了内存利用率,只使用其中一个 Survivor 空间来作为轮换备份,因此当出现大量对象在 Minor GC 后仍然存活的情况(最极端的情况就是内存回收后新生代中所有对象都存活),就需要老年代进行分配担保,把 Survivor 无法容纳的对象直接进入老年代。与生活中的贷款担保类似,老年代要进行这样的担保,前提是老年代本身还有容纳这些对象的剩余空间一共有多少对象会活下来在实际完成内存回收之前是无法明确知道的,所以只好取之前每一次回收晋升到老年代对象容量的平均大小值作为经验值,与老年代的剩余空间进行比较,决定是否进行 Full GC 来让老年代腾出更多空间。

取平均值进行比较其实仍然是一种动态概率的手段,也就是说,如果某次 Minor GC 存活后的对象突增,远远高于平均值的话,依然会导致担保失败(Handle Promotion Failure)。如果出现了 HandlePromotionFailure 失败,那就只好在失败后重新发起一次 Full GC。虽然担保失败时绕的圈子是最大的,但大部分情况下都还是会将 HandlePromotionFailure 开关打开,避免 Full GC 过于频繁.

第三部分,虚拟机执行子系统

类文件结构

在 Java 发展之初,设计者就曾经考虑过并实现了让其他语言运行在 Java 虚拟机之上的可能性,他们在发布规范文档的时候,也刻意把 Java 的规范拆分成了 Java 语言规范及 Java 虚拟机规范。

Java 虚拟机的语言无关性:Clojure,Groovy,JRuby,Jython,Scala等;
Java 虚拟机的平台无关性:Java 虚拟机跨平台;

Java 虚拟机不和包括 Java 在内的任何语言绑定,它只与“Class 文件”这种特定的二进制文件格式所关联。

魔数
每个 Class 文件的头 4 个字节称为“魔数(Magic Number)”它的唯一作用是确定这个文件是否为一个能被虚拟机接受的 Class 文件。
很多文件存储标准中都使用魔数来进行身份识别,譬如图片格式,如 gif 或者 jpeg 等在头文件中都存有魔数。使用魔数而不是扩展名来进行识别主要是基于安全方面的考虑,因为文件扩展名可以随意的改动。

Class 文件的魔数是:0xCAFEBABE —— 咖啡宝贝

字节码指令

由于 Java 虚拟机采用面向操作数栈而不是寄存器的结构,所以大多数的指令都不包含操作数,只有一个操作码。

  • 除了 long 和 double 类型外,每个变量都占局部变量区中的一个变量槽(slot),而 long 及 double 会占用两个连续的变量槽。
  • 大多数对于 boolean、byte、short 和 char 类型数据的操作,都使用相应的 int 类型作为运算类型。

加载和存储指令

1、将一个局部变量加载到操作栈:iload、iload_<n>、lload、lload_<n>、fload、fload_<n>、dload、dload_<n>、aload、aload_<n>。
2、将一个数值从操作数栈存储到局部变量表:istore、istore_<n>、lstore、lstore_<n>、fstore、fstore_<n>、dstore、dstore_<n>、astore、astore_<n>。
3、将一个常量加载到操作数栈:bipush、sipush、ldc、ldc_w、ldc2_w、aconst_null、iconst_m1、iconst_<i>、lconst_<l>、fconst_<f>、dconst_<d>。
4、扩充局部变量表的访问索引的指令:wide。

运算指令

1、运算或算术指令用于对两个操作数栈上的值进行某种特定运算,并把结果重新存入到操作栈顶。
2、算术指令分为两种:整型运算的指令和浮点型运算的指令。
3、无论是哪种算术指令,都使用 Java 虚拟机的数据类型,由于没有直接支持 byte、short、char 和 boolean 类型的算术指令,使用操作 int 类型的指令代替。

加法指令:iadd、ladd、fadd、dadd。
减法指令:isub、lsub、fsub、dsub。
乘法指令:imul、lmul、fmul、dmul。
除法指令:idiv、ldiv、fdiv、ddiv。
求余指令:irem、lrem、frem、drem。
取反指令:ineg、lneg、fneg、dneg。
位移指令:ishl、ishr、iushr、lshl、lshr、lushr。
按位或指令:ior、lor。
按位与指令:iand、land。
按位异或指令:ixor、lxor。
局部变量自增指令:iinc。
比较指令:dcmpg、dcmpl、fcmpg、fcmpl、lcmp。

类型转换指令

1、类型转换指令可以将两种不同的数值类型进行相互转换。
2、这些转换操作一般用于实现用户代码中的显式类型转换操作,或者用来处理字节码指令集中数据类型相关指令无法与数据类型一一对应的问题。

宽化类型转换

int 类型到 long、float 或者 double 类型。
long 类型到 float、double 类型。
float 类型到 double 类型。
i2l、f2b、l2f、l2d、f2d。

窄化类型转换
i2b、i2c、i2s、l2i、f2i、f2l、d2i、d2l 和 d2f。

还有:
对象创建与访问指令:new、newarray、anewarray、multianewarray
操作数栈管理指令:pop、dup、dup2_x1、swap
控制转移指令:ifeq、iflt、ifle、ifne、ifgt、ifge、ifnull、ifnonnull、goto
方法调用和返回指令:invokevirtual、invokeinterface、invokespecial、invokestatic 、invokedynamic

虚拟机类加载机制

类加载的过程

加载

在加载阶段(可以参考 java.lang.ClassLoaderloadClass() 方法),虚拟机需要完成以下 3 件事情:

  1. 通过一个类的全限定名来获取定义此类的二进制字节流(并没有指明要从一个 Class 文件中获取,可以从其他渠道,譬如:网络、动态生成、数据库等);
  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构;
  3. 在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口;

加载阶段和连接阶段(Linking)的部分内容(如一部分字节码文件格式验证动作)
是交叉进行的,加载阶段尚未完成,连接阶段可能已经开始,但这些夹在加载阶段之中进行的动作,仍然属于连接阶段的内容,这两个阶段的开始时间仍然保持着固定的先后顺序。

验证

验证是连接阶段的第一步,这一阶段的目的是为了确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。验证阶段大致会完成 4 个阶段的检验动作:

  1. 文件格式验证:验证字节流是否符合 Class 文件格式的规范;例如:是否以魔术 0xCAFEBABE 开头、主次版本号是否在当前虚拟机的处理范围之内、常量池中的常量是否有不被支持的类型。
  2. 元数据验证:对字节码描述的信息进行语义分析(注意:对比javac 编译阶段的语义分析),以保证其描述的信息符合 Java 语言规范的要求;例如:这个类是否有父类,除了java.lang.Object之外。
  3. 字节码验证:通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。
  4. 符号引用验证:确保解析动作能正确执行。

验证阶段是非常重要的,但不是必须的,它对程序运行期没有影响,如果所引用的类经过反复验证,那么可以考虑采用-Xverifynone 参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。

准备

准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。这时候进行内存分配的仅包括类变量(被 static 修饰的变量),而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在堆中。其次,这里所说的初始值“通常情况”下是数据类型的零值,假设一个类变量的定义为:

public static int value = 123;

那变量 value 在准备阶段过后的初始值为 0 而不是 123. 因为这时候尚未开始执行任何 java 方法,而把 value 赋值为 123 的 putstatic 指令是程序被编译后,存放于类构造器clinit()方法之中,所以把 value 赋值为 123 的动作将在初始化阶段才会执行。
至于“特殊情况”是指:public static final int value = 123,即当类字段的字段属性是 ConstantValue 时,会在准备阶段初始化为指定的值,所以标注为 final之后, value 的值在准备阶段初始化为 123 而非 0.

解析

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符 7 类符号引用进行。

  • 符号引用:符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到内存中。
  • 直接引用:直接引用可以是直接指向目标的指针、相对偏移量或者一个能间接定位到目标的句柄。如果有了直接引用,那引用的目标必定已经在内存中存在。
初始化

类初始化阶段是类加载过程的最后一步,到了初始化阶段,才真正开始执行类中定义的 java 程序代码。在准备阶段,变量已经赋过一次系统要求的初始值,而在初始化阶段,则根据程序员通过程序制定的主观计划去初始化类变量和其他资源,或者说:初始化阶段是执行类构造器<clinit>()方法的过程.

<clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块 static{}中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的,静态语句块只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问。如下:

public class Test
{
    static
    {
        i=0;
        System.out.println(i); //这句编译器会报错:Cannot reference a field before it is defined(非法向前应用)
    }
    static int i=1;
}

虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确的加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>()方法,其他线程都需要阻塞等待,直到活动线程执行<clinit>()方法完毕。如果在一个类的<clinit>()方法中有好事很长的操作,就可能造成多个线程阻塞,在实际应用中这种阻塞往往是隐藏的。

类加载器

虚拟机设计团队把类加载阶段中的“通过一个类的全限定名来获取此类的二进制字节流”这个动作放到 Java 虚拟机外部去实现,以便让应用程序自己决定如何去获取所需要的类。实现这个动作的代码模块称为“类加载器”。

类加载器可以说是 Java 语言的一项创新,也是 Java 语言流行的重要原因之一,在类层次划分、OSGi、热部署、代码加密等领域大放异彩,成为了 Java 技术体系中一块重要的基石。

类与类加载器

对于任意一个类,都需要由加载它的类加载器和这个类本身一同确认其在 Java 虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间:比较两个类是否相等,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类源自同一个 Class 文件,被同一个虚拟机加载,只要加载他们的类加载器不同,那这两个类就必定不相等。

package xiao;

import java.io.File;
import java.io.IOException;
import java.io.InputStream;

public class TestAll {
    public static void main(String[] args) throws ClassNotFoundException, IllegalAccessException, InstantiationException {
        ClassLoader classLoader = new ClassLoader() {
            @Override
            public Class<?> loadClass(String name) throws ClassNotFoundException {
                String filename = "D:\\WorkSpace\\IDEA\\HelloWorld\\out\\production\\HelloWorld\\xiao\\"
                         + name.split("\\.")[name.split("\\.").length - 1] + ".class";
                InputStream is = getClass().getResourceAsStream(filename);
                if (is == null) {
                    return super.loadClass(name);
                }
                try {
                    byte[] bytes = new byte[is.available()];
                    is.read(bytes);
                    return defineClass(name, bytes, 0, bytes.length);
                } catch (IOException e) {
                    throw new ClassNotFoundException(name);
                }
            }
        };
        Object obj1 = classLoader.loadClass("xiao.TestAll");
        System.out.println(obj1 instanceof xiao.TestAll);
        Object obj2 = TestAll.class.getClassLoader().loadClass("xiao.TestAll").newInstance();
        System.out.println(obj2 instanceof xiao.TestAll);
    }
}

false
true

双亲委派模型

  • 启动类加载器(Bootstrap ClassLoader):
    这个类加载器负责将<JAVA_HOME>\lib目录下的类库加载到虚拟机内存中,用来加载 Java 的核心库,此类加载器并不继承于java.lang.ClassLoader ,不能被 Java 程序直接调用,代码是使用 C++ 编写的.是虚拟机自身的一部分.

  • 扩展类加载器(Extendsion ClassLoader):
    这个类加载器负责加载<JAVA_HOME>\lib\ext目录下的类库,用来加载 Java 的扩展库,开发者可以直接使用这个类加载器.

  • 应用程序类加载器(Application ClassLoader):
    这个类加载器负责加载用户类路径(CLASSPATH)下的类库,一般我们编写的 Java 类都是由这个类加载器加载,这个类加载器是 CLassLoader 中的 getSystemClassLoader() 方法的返回值,所以也称为系统类加载器.一般情况下这就是系统默认的类加载器.

除此之外,我们还可以加入自己定义的类加载器,以满足特殊的需求,需要继承 java.lang.ClassLoader 类.


双亲委派模型是一种组织类加载器之间关系的一种规范,他的工作原理是:如果一个类加载器收到了类加载的请求,它不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,这样层层递进,最终所有的加载请求都被传到最顶层的启动类加载器中,只有当父类加载器无法完成这个加载请求(它的搜索范围内没有找到所需的类)时,才会交给子类加载器去尝试加载.

这样的好处是: Java 类随着它的类加载器一起具备了带有优先级的层次关系.这是十分必要的,比如java.langObject ,它存放在\jre\lib\rt.jar 中,它是所有 Java 类的父类,因此无论哪个类加载都要加载这个类,最终所有的加载请求都汇总到顶层的启动类加载器中,因此 Object 类会由启动类加载器来加载,所以加载的都是同一个类,如果不使用双亲委派模型,由各个类加载器自行去加载的话,系统中就会出现不止一个 Object 类,应用程序就会全乱了.

Class.forname() 与 ClassLoader.loadClass():

  • Class.forname():是一个静态方法,最常用的是Class.forname(String className);根据传入的类的全限定名返回一个 Class 对象.该方法在将 Class 文件加载到内存的同时,会执行类的初始化。如: Class.forName("com.wang.HelloWorld");

  • ClassLoader.loadClass():这是一个实例方法,需要一个 ClassLoader 对象来调用该方法,该方法将 Class 文件加载到内存时,并不会执行类的初始化,直到这个类第一次使用时才进行初始化.该方法因为需要得到一个 ClassLoader 对象,所以可以根据需要指定使用哪个类加载器.如:ClassLoader cl=.......; cl.loadClass("com.wang.HelloWorld");

虚拟机字节码执行引擎

运行时栈帧结构

栈帧是虚拟机栈的栈元素,栈帧存储了局部变量表,操作数栈,动态连接,返回地址等信息。每一个方法的调用都对应着一个栈帧在虚拟机栈中的入栈和出栈

  1. 局部变量表由方法参数,方法内定义的局部变量组成,容量以变量槽(Slot)为最小单位。如果该方法不是 static 方法,则局部变量表的第一个索引为该对象的引用,用 this 可以取到。
  2. 操作数栈最开始为空,由字节码指令往栈中存数据和取数据,方法的返回值也会存到上一个方法的操作数栈中。
  3. 动态连接含有一个指向常量池中该栈帧所属方法的引用,持有该引用是为了进行动态分派。
  4. 方法返回地址存放的是调用该方法的 PC 计数器值,当方法正常返回时,就会把返回值传递到上层方法调用者。当方法中发生没有可被捕获的异常,也会返回,但是不会向上层传递返回值。

方法调用

Java 是一门面向对象的语言,它具有多态性。那么虚拟机又是如何知道运行时该调用哪一个方法?

  1. 静态分派:依赖静态类型来定位方法执行版本的分派动作,称为静态分派。静态分派的最典型的应用就是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的
  2. 动态分派:在运行期间根据实际类型来确定方法执行版本的分派调用过程称为动态分派。这跟多态性的另一个体现——重写有着很密切的关联。
  3. 单分派:根据一个宗量对目标方法进行选择
  4. 多分派:根据多于一个的总量对目标方法进行选择。

(注:方法的接收者与方法的参数统称为方法的宗量。今天的 Java 语言还是一门静态多分派、动态单分派的语言。)

动态分派的实现:当调用一个对象的方法时,会将该对象的引用压栈到操作数栈,然后字节码指令invokevirtual会去寻找该引用实际类型。如果在实际类型中找对应的方法,且访问权限足够,则直接返回该方法引用,否则会依照继承关系对父类进行查找。实际上,如果子类没有重写父类方法,则子类方法的引用会直接指向父类方法。

静态分派的例子

package xiao;

public class TestAll {
    static abstract class Human {
    }

    static class Man extends Human {
    }

    static class Woman extends Human {
    }

    public void sayHello(Human guy) {
        System.out.println("hello guy");
    }

    public void sayHello(Man guy) {
        System.out.println("hello man");
    }

    public void sayHello(Woman guy) {
        System.out.println("hello woman");
    }

    public static void main(String[] args) {
        TestAll b = new TestAll();
        Human man = new Man(); //静态类型为Human  
        Human woman = new Woman();

        b.sayHello(man);
        b.sayHello(woman);
    }
}  

hello guy
hello guy

虚拟机在重载时是通过参数的静态类型而不是实际类型作为判定依据,并且静态类型是编译期可知的,所以在编译阶段,javac编译器就根据参数的静态类型决定使用哪个重载版本,并把这个方法的符号引用写入invokevirtual指令的参数中。

动态分派的例子

package xiao;

public class TestAll {
    static abstract class Human {
        protected abstract void sayHello();
    }

    static class Man extends Human {
        @Override
        protected void sayHello() {
            System.out.println("man say hello");
        }
    }

    static class Woman extends Human {
        @Override
        protected void sayHello() {
            System.out.println("woman say hello");
        }
    }

    public static void main(String[] args) {
        Human man = new Man();
        Human woman = new Woman();
        man.sayHello();
        woman.sayHello();
        man = new Woman();
        man.sayHello();
    }
}

man say hello
woman say hello
woman say hello

invokevirtual指令有多态查找的机制,该指令的运行时解析过程步骤如下:

  1. 找到操作数栈顶的第一个元素所指向的对象的实际类型,记做 c
  2. 如果在类型 c 中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束,不通过则返回 java.lang.IllegalAccessError.
  3. 否则,按照继承关系从下往上依次对 c 的各个父类进行第二步的搜索和验证过程。
  4. 始终没找到合适的方法,抛出java.lang.AbstractMethodError异常。

这就是 Java 语言中方法重写的本质。

基于栈的字节码执行引擎

基于寄存器和基于栈的指令集现在都存在。所以很难说孰优孰劣。
基于栈的指令集是和硬件无关的,而基于寄存器则依赖于硬件基础。基于寄存器在效率上优势。
但是虚拟机的出现,就是为了提供跨平台的支持,所以 JVM 的执行引擎是基于栈的指令集。

public int calc() {
    int a = 100;
    int b = 200;
    int c = 300;
    return (a + b) * c;
}

类加载及执行子系统的案例与实战

字节码生成技术与动态代理的实现

JDK 里面的 javac 命令就是字节码生成技术的老祖宗。

如 Web 服务器中的 JSP 编译器,编译时植入的 AOP 框架,还有很常用的动态代理技术。

动态代理中所谓的“动态”,是针对使用 Java 代码实际编写了代理类的“静态”代言而言的,它的优势不在于省去了编写代理类那一点工作量,而是实现了可以在原始类和接口还未知的时候,就确定代理类的代理行为,当代理类与原始类脱离直接关系后,接可以很灵活地重用于不同的应用场景之中。

package xiao;

import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;

public class DynamicProxyTest {
    interface IHello {
        void sayHello();
    }

    static class Hello implements IHello {
        @Override
        public void sayHello() {
            System.out.println("hello world");
        }
    }

    static class DynamicProxy implements InvocationHandler {
        Object originalObj;

        Object bind(Object originalObj) {
            this.originalObj = originalObj;
            return Proxy.newProxyInstance(
                    originalObj.getClass().getClassLoader(),
                    originalObj.getClass().getInterfaces(),
                    this);
        }

        @Override
        public Object invoke(Object proxy, Method method, Object[] args)
                throws Throwable {
            System.out.println("welcome");
            return method.invoke(originalObj, args);
        }
    }

    public static void main(String[] args) {
        IHello hello = (IHello) new DynamicProxy().bind(new Hello());
        hello.sayHello();
    }
}

welcome
hello world

上述代码中,唯一的“黑匣子”就是Proxy.newProxyInstance()方法,除此之外再没有任何特殊之处。这个方法返回一个实现了IHello的接口,并且代理了new Hello()实例行为的对象。跟踪这个方法的源码,可以看到程序进行了验证,优化,缓存,同步,生成字节码和显式类加载等操作,前面的步骤并不是我们关注的重点,而最后它调用了sun.misc.ProxyGenerator.generateProxyClass()方法来完成生成字节码的动作.

程序编译与代码优化

早期(编译期)优化

Java 语言的 “编译期” 其实是一段 “不确定” 的操作过程,因为它可能是指一个前端编译器(其实叫 “编译器的前端” 更准确一些)把 .java文件转变成 .class文件的过程;也可能是指虚拟机的后端运行期编译器JIT 编译器,Just In Time Compiler)把字节码转变成机器码的过程;还可能是指使用静态提前编译器(AOT 编译器,Ahead Of Time Compiler)直接把.java文件编译成本地机器代码的过程。下面列举了这 3 类编译过程中一些比较有代表性的编译器。

  1. 前端编译器:Sun 的 Javac、Eclipse JDT 中的增量式编译器(ECJ)。
  2. JIT 编译器:HotSpot VM 的 C1、C2 编译器。
  3. AOT 编译器:GNU Compiler for the Java (GCJ)、Excelsior JET。

泛型与类型擦除

泛型是 JDK 1.5 的一项新增特性,它的本质是参数化类型(Parametersized Type)的应用,也就是说操作的数据类型被指定为一个参数。这种参数类型可以用在类、接口和方法的创建中,分别称为泛型类、泛型接口和泛型方法。

泛型思想早在 C++ 语言的模板(Template)中就开始生根发芽,在 Java 语言处于还没有出现泛型的版本时,只能通过 Object 是所有类型的父类和类型强制转换两个特点的配合来实现类型泛化。例如,在哈希表的存取中,JDK 1.5 之前使用 HashMap 的 get() 方法,返回值就是一个 Object 对象,由于 Java 语言里面所有的类型都继承于 java.lang.Object,所以 Object 转型成任何对象都是有可能的。但是也因为有无限的可能性,就只有程序员和运行期的虚拟机才知道这个 Object 到底是什么类型的对象。在编译期间,编译器无法检查这个 Object 的强制转型是否成功,如果仅仅依赖程序员去保障这项操作的正确性,许多 ClassCastException 的风险就会转嫁到程序运行期之中。

泛型技术在 C# 和 Java之中的使用方式看似相同,但实现上却有着根本性的分歧,C# 里面泛型无论是在程序源码中、编译后的 IL 中(Intermediate Language,中间语言,这时候泛型是一个占位符),或是运行期的 CLR 中,都是切实存在的,List<int>List<String>就是两个不同的类型,它们在系统运行期生成,有自己的虚方法表和类型数据,这种实现称为类型膨胀,基于这种方法实现的泛型称为真实泛型。

Java 语言中的泛型则不一样,它只在程序源码中存在,在编译后的字节码文件中,就已经替换为原来的原生类型(Raw Type,也称为裸类型)了,并且在相应的地方插入了强制类型代码,因此,对于运行期的 Java 语言来说,ArrayList<int>ArrayList<String> 就是同一个类,所以泛型技术实际上是 Java 语言的一颗语法糖,Java 语言中的泛型实现方法称为类型擦除,基于这种方法实现的泛型称为伪泛型。

如下是一段简单的 Java 泛型的例子,我们可以看一下它编译后的结果是怎样的。

public static void main(String[] args) {  
    Map<String, String> map = new HashMap<String, String>();  
    map.put("hello", "你好");  
    map.put("how are you?", "吃了没?");  
    System.out.println(map.get("hello"));  
    System.out.println(map.get("how are you?"));  
}

除了本节中介绍的泛型、自动装箱、自动拆箱、遍历循环、变长参数和条件编译之外,Java 语言还有不少其他的语法糖,如内部类、枚举类、断言语句、对枚举和字符串(在 JDK 1.7 中支持)的 switch 支持、try 语句中定义和关闭资源(在 JDK 1.7 中支持)等,读者可以通过跟踪 Javac 源码、反编译 Class 文件等方式了解它们的本质实现。

晚期(运行期)优化

在部分的商用虚拟机(Sun HotSpot、IBM J9)中,Java 程序最初是通过解释器(Interpreter)进行解释执行的,当虚拟机发现某个方法或代码块的运行特别频繁时,就会把这些代码认定为“热点代码” (Hot Spot Code)。为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成与本地平台相关的机器码,并进行各种层次的优化,完成这个任务的编译器称为即时编译器(Just In Time Compiler,下文中简称 JIT 编译器)。

即时编译器并不是虚拟机必需的部分,Java 虚拟机规范并没有规定 Java 虚拟机内必需要有即时编译器存在,更没有限定或指导即时编译器应该如何去实现。但是,即时编译器编译性能的好坏、代码优化程度的高低却是衡量一款商用虚拟机优秀与否的最关键指标之一,它也是虚拟机内中最核心且最能体现虚拟机技术水平的部分。

在运行过程中会被即时编译器编译的 “热点代码” 有两类,即:

  1. 被多次调用的方法。
  2. 被多次执行的循环体。

前者很好理解,一个方法被调用得多了,方法体内代码执行的次数自然就多,它成为 “热点代码” 是理所当然的。而后者则为了解决一个方法只被调用过一次或少量的几次,但是方法体内部存在循环次数较多的问题,这样循环体的代码也被重复执行多次,因此这些代码也应该认为是 “热点代码”。

对于第一种情况,由于是由方法调用触发的编译,因此编译器理所当然地会以整个方法作为编译对象,这种编译也是虚拟机中标准的 JIT 编译方式。而对于后一种情况,尽管编译动作是由循环体所触发的,但编译器依然会以整个方法(而不是单独的循环体)作为编译对象。这种编译方式因为编译发生在方法执行过程之中,因此形象地称之为栈上替换(On Stack Replacement,简称为 OSR 编译,即方法栈帧还在栈上,方法就被替换了)。

判断一段代码是不是热点代码,是不是需要触发即时编译,这样的行为称为热点探测(Hot Spot Detection),其实进行热点探测并不一定要知道方法具体被调用了多少次,目前主要的热点探测判定方式有两种:

  1. 基于采样的热点探测(Sample Based Hot Spot Detection):采用这种方法的虚拟机会周期性地检查各个线程的栈顶,如果发现某个(或某些)方法经常出现在栈顶,那这个方法就是 “热点方法”。基于采样的热点探测的好处是实现简单、高效,还可以很容易地获取方法调用关系(将调用堆栈展开即可),缺点是很难精确地确认一个方法的热度,容易因为受到线程阻塞或别的外界因素的影响而扰乱热点探测。
  2. 基于计数器的热点探测(Counter Based Hot Spot Detection):采用这种方法的虚拟机会为每个方法(甚至是代码块)建立计数器,统计方法的执行次数,如果执行次数超过一定的阈值就认为它是 “热点方法”。这种统计方法实现起来麻烦一些,需要为每个方法建立并维护计数器,而且不能直接获取到方法的调用关系,但是它的统计结果相对来说更加精确和严谨。

在 HotSpot 虚拟机中使用的是第二种——基于计数器的热点探测方法,因此它为每个方法准备了两类计数器:方法调用计数器(Invocation Counter)和回边计数器(Back Edge Counter)。

在确定虚拟机运行参数的前提下,这两个计数器都有一个确定的阈值,当计数器超过阈值溢出了,就会触发 JIT 编译。

我们首先来看看方法调用计数器。顾名思义,这个计数器就用于统计方法被调用的次数,它的默认阈值在 Client 模式下是 1500 此,在 Server 模式下是 10 000 次,这个阈值可以通过虚拟机参数-XX:CompileThreshold来人为设定。当一个方法被调用时,会先检查该方法是否存在被 JIT 编译过的版本,如果存在,则优先使用编译后的本地代码来执行。如果不存在已被编译过的版本,则将此方法的调用计数器值加 1,然后判断方法调用计数器与回边计数器值之和是否查过方法调用计数器的阈值。如果已超过阈值,那么将会向即时编译器提交一个该方法的代码编译请求。

如果不做任何设置,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,即一段时间之内方法被调用的次数。当超过一定的时间限度,如果方法的调用次数仍然不足以让它提交给即时编译器编译,那这个方法的调用计数器就会被减少一半,这个过程称为方法调用计数器热度的衰减(Counter Decay),而这段时间就称为此方法统计的半衰周期(Counter Half Life Time)。进行热度衰减的动作是在虚拟机进行垃圾收集时顺便进行的,可以使用虚拟机参数 -XX: -UseCounterDecay 来关闭热度衰减,让方法计数器统计方法调用的绝对次数,这样,只要系统运行时间足够长,绝大部分方法都会被编译成本地代码。另外,可以使用 -XX: CounterHalfLifeTime 参数设置半衰周期的时间,单位是秒。

现在我们再来看看另一个计数器——回边计数器,它的作用是统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为 “回边”(Back Edge)。显然,建立回边计数器统计的目的就是为了触发 OSR 编译。

Java 程序员有一个共识,以编译方式执行本地代码比解释方式更快,之所以有这样的共识,除去虚拟机解释执行字节码时额外消耗时间的原因外,还有一个很重要的原因就是虚拟机设计团队几乎把对代码的所有优化措施都集中在了即时编译器之中(在 JDK 1.3 之后,javac 就去除了 -O 选项,不会生成任何字节码级别的优化代码了)。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,117评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,328评论 1 293
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,839评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,007评论 0 206
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,384评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,629评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,880评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,593评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,313评论 1 243
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,575评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,066评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,392评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,052评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,082评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,844评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,662评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,575评论 2 270

推荐阅读更多精彩内容