JVM

走进JAVA
1、摆脱了硬件平台的束缚,实现了”一次编写,导出运行”的理想。

2、提供了一个相对安全的内存管理和访问机制,避免了绝大多数内存泄漏和指针越界问题。

3、实现了热点代码检测和运行时编译及优化。这使得java应用能随着运行时间的增加而获得更高的性能。

4、有一套完整的应用程序接口和许多第三方库。

不惑:不依赖书本和其他人就能得到问题的答案。

jdk=java+jvm+API=java+jre;jre=jvm+API

lambda表达式会极大的改善目前java语言不适合函数式编程的现状。函数式编程的一个重要优点是这样的程序天生的适合并向执行,这对java语言在多核时代保持主流地位有很大的帮助。

在jdk外围,也出现了专为满足并行计算需求的计算框架,如Apache的Hadoop Map/Reduce,这是一个并行框架,能够运行在由上千个商用机器组成的大型集群上,并且能以一种可靠的容错方式并行处理TB级别的数据集。另外,还出现了诸如Scala、Clojure、Erlang等天生就具备并行计算能力的语言。

工具类应该与对象的状态无关,工具类一般都是定义成static,然后使用类名直接调用,所有的对象都共享这一个类。

64位虚拟机:

自己编译一套jdk,jdk的很多底层方法都是本地化native的,需要跟踪这些方法的运作或对jdk进行hack的时候,都需要自己编译一套jdk。

安装jdk:下载jdk安装包,然后傻瓜式安装即可,和其他软件安装别无区别。安装好了之后设置环境变量,然后在编译器idea和eclipse中配置即可。

编译jdk

1、准备5G左右内存,下载openjdk7;

2、安装CYGWIN,在win平台下模拟linux运行环境的软件,提供一系列的linux命令支持。

3、安装编译器。jdk中最核心的代码是使用C++和少量C写的,在VS中编译。

4、下载Bootstrap JDK来编译openjdk7。

5、准备依赖项JDK Plug。

6、进行编译。

java程序的执行过程

jre和jdk的关系,在开发java程序时,有jre就可以运行了。jre=api+jvm;

自动内存管理机制
1、java内存区域与内存溢出异常
java不容易出现内存溢出和内存泄露的问题,因为jvm会自动进行内存管理。但是不容易出现问题不代表不会出现问题,一旦出现内存泄漏和溢出方面的问题,如果不了解jvm是如何工作的,那么排查错误将是一项艰难的工作。

程序计数器:

java虚拟机栈(Stack):作用->为虚拟机执行java方法服务,存放局部变量表。

1、StackOverflowError:线程请求的栈深度大于jvm所允许的栈深度。

2、OutOfMemeryError:jvm栈可以动态扩展时无法申请到足够的内存。

本地虚拟机栈:作用->为虚拟机使用到的native服务。

java堆(Heap):jvm所管理的内存中最大的一块,唯一的目的就是存放对象实例。

1、OutOfMemeryError:jvm堆中没有内存完成实例分配,并且堆也没有办法再扩展。

方法区:各个线程共享的内存区域,用于存储已被虚拟机加载的类信息,常量,静态变量、即时编译器编译后的代码等数据。别名是Non-Heap。

1、OutOfMemeryError:方法区没有办法满足内存分配需求时。

运行时常量池:方法区的一部分,用于存放编译期生成的各种字面量和符号引用。

java并不要求常量只在编译期才可以产生,运行期间也可以将新的常量放入池中。

1、OutOfMemeryError:当常量池无法再申请到内存时。(运行时常量池是方法区的一部分,会受到方法区的限制)

直接内存

服务器管理员在配置虚拟机参数时,会根据实际内存设置-Xmx等参数信息,但经常忽略直接内存,使得各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制),从而导致动态扩展时出现OutOfMemeryError异常。

HotSpot VM是Sun JDK和OpenJDK中所带的虚拟机,也是目前使用范围最广的Java虚拟机。

对象的创建

对象的内存布局:对象头、实例数据、对齐填充;

实战:OutOfMemoryError异常;

OutOfMemoryError异常

通过代码验证jvm区域中各个运行时区域存储的内容;为什么出现OutOfMemoryError异常,根据异常信息得出是那个区域的内存溢出,知道什么样的代码会导致异常溢出,以及出现这些异常之后应该怎么处理。

在VM options里面添加程序运行的虚拟机参数:

  1. java堆溢出(最常见的异常)

方式:java堆用于存储对象实例,只要不断的创建对象,对象数量达到最大值

解决方法:看堆中存储的对象是否时必须的,分清时内存溢出(overflow,内存中的对象都需要存在,更改jvm的堆参数-Xmx与-Xms)和内存泄露(leak,内存中存在大量不必要存在的对象,检查泄露代码改正)

  1. 虚拟机栈和本地方法溢出

-Xss来配置栈内存容量,或者使用多次递归,单线程和其他情况下都抛出StackOverFlowError异常。

OutOfMemoryError:不断地创建线程,多线程的形式可以复现。

java线程是映射在操作系统的内核线程上的。如果创建多个线程,代码执行过程中可能会导致操作系统假死。

  1. 方法区和运行时常量池溢出

java.lang.OutOfMemoryError: PermGen space

方法区用于存放class的相关信息,如类名、访问修饰符、常量池、字段描述。

  1. 本机直接内存溢出

直接内存可以通过-XX:MaxDirectMemorySize指定,如果不指定,则默认与java堆最大值一样。

异常打印信息: java.lang.OutOfMemoryError

由直接内存导致的内存溢出,一个明显的特征是在HeapDump文件中不会看到明显的异常。如果读者发现OOM之后Dump文件很小。而程序中又直接或者间接的使用了NIO,那就可以考虑检查一下是不是这方面的原因。

2、垃圾收集器与内存分配策略
为什么要学GC:当需要排查各种内存溢出、内存泄露问题时,当垃圾手机称为系统达到更高并发的瓶颈时,我们就需要对这些’自动化’的技术实施必要的监控和调节。

垃圾回收主要关注的内存是:java堆和方法区,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也不一样,我们只有在程序处于运行期间时才能知道会创建哪些对象,这部分内存的分配和回收都是动态的。

判断对象是否活着:给对象中添加一个计数器,没当有一个地方引用它则加1,引用失效时减1,任何时刻计数器都是0的对象就是不可能再被使用的。

计数算法进行内存管理被大范围的使用,但是再jvm中没有被使用,最主要的原因是:它很难解决对象之间相互循环引用的问题。

objA.instance = objB;
objB.instance = objA;

可达性分析算法:

基本思想:通过一系列GC Roots的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到达GC Roots没有任何引用链相连时,说明该对象是不可达的。(图论)

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

1、虚拟机栈(栈帧中的本地变量表)中引用的对象。

2、方法区中类静态属性引用的对象。

3、方法区中常量对象的引用。

4、本地方法栈中JNI(Native方法)引用的对象。

即使在可达性算法中不可达的对象,也并不是‘非死不可’的,这时候他们暂时处于‘缓刑’阶段,要真正宣告一个对象死亡,至少需要经历过两次标记过程。

1、如果对象在运行可达性之后发现没有与GC Roots相关联的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要进行finalize()。如果对象没有覆盖finalize()方法或者finalize()已经被jvm调用过,那么jvm将这两种情况都视为‘没有必要执行’。如果有必要执行则将对象放置在F-Queue队列中统一的回收执行finalize()。

2、GC会对F-Queue中对象进行第二次标记,判断其是否可达。可达则移出F-Queue队列,否则进行回收。

3、finalize()方法是对象逃脱死亡的最后一次机会,可以通过重写finalize()方法来自制定回收的机制。(这种做法并不鼓励,因为java并不保证finalize()被执行)

回收方法区

jvm规范中说过不要求虚拟机在方法区实现垃圾收集, 而且在方法区中进行垃圾收集的‘性价比’一般比较低;在堆中,尤其是在新生代中,常规应用进行一次垃圾回收一般可以回收70%-95%的空间,而永久代的垃圾回收效率远低于此。

永久代的垃圾收集主要回收两部分:废弃常量和无用的类。

类要同时满足以下3个条件才能算是‘无用的类’,具备回收的条件,但是具备回收不是说要回收。

1、该类所有的实例都已经被回收(java堆中不存在该类的所有实例)。

2、加载该类的ClassLoader已经被回收。

3、该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类。

垃圾收集算法

1、标记-清除算法

2、复制算法

3、标记-整理算法

过程:Serial->ParNew->CMS->G1

2.1 Serial收集器
1、目前仍然是运行在Client模式下的默认新生代收集器。

一个单线程的收集器,在进行垃圾收集时候,必须暂停其他所有的工作线程直到它收集结束。

特点:CPU利用率最高,停顿时间即用户等待时间比较长。针对新生代;采用复制算法;单线程收集;进行垃圾收集时,必须暂停所有工作线程,直到完成(Stop The World);

适用场景:小型应用

通过JVM参数-XX:+UseSerialGC可以使用串行垃圾回收器。

2.2 ParNew收集器
1、其实就是Serial收集器的多线程版本,除了使用多线程进行垃圾回收之外,其他行为和Serial几乎一样。

在Server模式下,ParNew收集器是一个非常重要的收集器,因为除Serial外,目前只有它能与CMS收集器配合工作;

采用多线程来通过扫描并压缩堆

特点:停顿时间短,回收效率高,对吞吐量要求高。

适用场景:大型应用,科学计算,大规模数据采集等。

通过JVM参数 XX:+USeParNewGC 打开并发标记扫描垃圾回收器。

为什么只有ParNew能与CMS收集器配合

1、CMS是HotSpot在JDK1.5推出的第一款真正意义上的并发(Concurrent)收集器,第一次实现了让垃圾收集线程与用户线程(基本上)同时工作;

2、CMS作为老年代收集器,但却无法与JDK1.4已经存在的新生代收集器Parallel Scavenge配合工作;

3、因为Parallel Scavenge(以及G1)都没有使用传统的GC收集器代码框架,而另外独立实现;而其余几种收集器则共用了部分的框架代码;

2.3 Parallel Scavenge收集器
特点: 新生代收集器;采用复制算法;多线程收集;

不同之处:达到一个可控制的吞吐量。(cpu运行代码时间于其消耗的时间的比值 = 运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间))。

适合:在后台运算而不需要太多交互的任务。

提供了两个参数用于精确控制吞吐量:

1、最大垃圾收集停顿时间的-XX:MaxGCPauseMillis参数:参数允许的值是一个大于0的毫秒数,收集器将尽可能地保证内存回收花费的时间不超过设定值。(垃圾回收间隔)

2、直接设置吞吐量大小的-XX:GCTimeRatio参数。参数的值是一个大于0且小于100的整数,也就是垃圾收集时间占总时间的比率,相当于是吞吐量的倒数。(垃圾回收比率)

2.4 Serial Old收集器
serial的老年代版本,单线程收集器,使用‘标记-整理’算法。

2.5 Parallel Old收集器
parallel收集器的老年化版本,使用多线程和‘标记-整理‘算法

2.6 CMS收集器
目标:获取最短回收停顿时间。

采用“标记-清除”算法实现,使用多线程的算法去扫描堆,对发现未使用的对象进行回收。

  1. 初始标记(需要stop the world)

  2. 并发标记(与用户线程一起并发执行)

  3. 并发预处理

  4. 并发可能失败的预先清除

  5. 重新标记(需要stop the world)

  6. 并发清除(与用户线程一起并发执行)

  7. 并发重置

特点:响应时间优先,减少垃圾收集停顿时间

适应场景:服务器、电信领域、B/S系统的服务端。

通过JVM参数 -XX:+UseConcMarkSweepGC设置

缺点部分:

1、对CPU资源敏感

问题:并发阶段虽然用户线程不停顿,但会占用CPU资源导致用户线程变慢,吞吐量降低。

默认回收线程数:(CPU数量+3)/4。

当CPU>4时,并发线程>25%的CPU资源。且随CPU数量增加而下降。

当CPU<4时(假设为2),并发线程>50%的CPU资源,很影响用户体验。

解决:(不提倡使用)提供“增量式并发收集器”:并发标记和并发清除阶段让GC线程和用户线程交替运行,减少GC线程的独占资源时间。会增长GC时间,但降低用户影响。

2、无法处理浮动垃圾:

1、浮动垃圾:进行并发清除时用户线程运行产生的垃圾。只能在下一次GC时再清理。

2、并发清理阶段用户线程运行需要预留空间,老年代没有填充满就会进行GC。

JDK1.5:该GC启动百分比阈值为68%

JDK1.6:该GC启动百分比阈值为92%

可通过:-XX:CMSInitiatingOccupancyFraction配置(太高会引发大量问题3)。

3、老年代GC如果预留空间不足,会出现“Concurrent Mode Failure”,此时虚拟机会启动后被预案,临时启用 Serial Old 收集器,会导致停顿时间变长。

3、基于标记-清除算法:

问题:会产生空间碎片。大对象分配会因无法找到连续内存空间而触发FGC

解决:

+UseCMSCompactAtFullCollection参数:在CMS要进行FGC时开启内存碎片的合并整理过程。默认开启。

引发问题:内存整理导致停顿时间变长

-XX:CMSFullGCsBeforeCompaction参数:设置N次不压缩的FGC后跟着来一次带压缩的FGC。默认为0,即每次FGC都进行碎片整理。

2.7 G1收集器
当代收集器最前沿的成果之一,是面向服务端应用的垃圾收集器。

在G1中,堆被划分成许多个连续的区域(region)。采用G1算法进行回收,吸收了CMS收集器特点。

特点:1、支持很大的堆,高吞吐量;2、支持多CPU和垃圾回收线程;3、在主线程暂停的情况下,使用并行收集;4、在主线程运行的情况下,使用并发收集

实时目标:可配置在N毫秒内最多只占用M毫秒的时间进行垃圾回收

通过JVM参数 –XX:+UseG1GC 使用G1垃圾回收器

特点:

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

2、分代收集:虽然G1可以不需要其他收集器配合就能独立管理整个GC堆,但是还是保留了分代的概念。它能够采用不同的方式去处理新生代和老年代的对象。

3、空间整合:与CMS的“标记–清理”算法不同,G1从整体来看是基于“标记整理”算法实现的收集器;从局部上来看是基于“复制”算法实现的。

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

G1运行步骤:1、初始标记;2、并发标记;3、最终标记;4、筛选回收

理解GC日志

对应的参数列表

-XX:+PrintGC 输出GC日志

-XX:+PrintGCDetails 输出GC的详细日志

-XX:+PrintGCTimeStamps 输出GC的时间戳(以基准时间的形式)

-XX:+PrintGCDateStamps 输出GC的时间戳(以日期的形式,如 2013-05-04T21:53:59.234+0800)

-XX:+PrintHeapAtGC 在进行GC的前后打印出堆的信息

-Xloggc:…/logs/gc.log 日志文件的输出路径

内存分配与回收策略
自动内存分配解决的问题:自动分配内存和自动回收内存。

对象的内存分配,从大方向讲就是在堆上分配,对象主要分配在新生代的Eden区上,当然分配的规则并不是固定的,其细节取决于使用的是哪一种收集器组合,还有虚拟机中与内存相关的参数的设置。垃圾收集器组合一般就是Serial+Serial Old和Parallel+Serial Old,前者是Client模式下的默认垃圾收集器组合,后者是Server模式下的默认垃圾收集器组合。

Minor GC和Full GC的区别

新生代Minor GC:指发生在新生代的垃圾收集动作,因为Java对象大多数都具有朝生夕灭的特性,多以Minor GC非常频繁,一般回收速度也比较快。

老年代GC(Major GC/Full GC):指发生在老年代的GC,出现了Major GC,经常都伴随着至少一次的Minor GC(但并非绝对的,在ParallelScavenge收集器的收集策略里就有直接进行Major GC的策略选择过程)。MajorGC的速度一般会比Minor GC慢10倍以上。

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

如下代码中,尝试分配3个2MB大小和1个4MB大小的对象。在运行通过-Xms20M、-Xmx20M和-Xmn10M这三个参数限制Java堆大小为20MB,切不可扩展,其中10MB分配给新生代剩下的10MB分配给老年代。-XX:SurvivorRatio=8决定了新生代中Eden区与一个Survivor区的空间比例是8比1,新生代总可用空间为9216KB(Eden区+1个Survivor区的总容量)。

执行testAllocation()中分配allocation4对象的语句会发生一次Minor GC,这次GC发生的原因是给allocation4分配内存的时候,发现Eden已经被占用了6MB剩下的空间已经不足够分配allocation4所需要的4MB内存,因此发生Minor GC.GC期间虚拟机又发生已有的3个2MB大小全部无法放入Survivor空间(Survivor空间只有1MB大小),所以只好通过分配担保机制提前转移到老年代中去。

这次GC结束后,4MB的allocation4对象被顺利分配在Eden,因此程序执行完的结果是Eden占用4MB(被alloction4占用),Survivor空闲,老年代被占用6MB(被alloction1、2、3占用)。

2、大对象直接进入老年代
所谓大对象就是需要大量连续空间的Java对象,最典型的大对象就是那种很长的字符串及数组。虚拟机提供了一个

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

执行下面代码后,可以看到Eden空间几乎没有被使用,而老年代10MB的空间被使用了40%,也就是4MB的allocation对象直接就分配在老年代中,这是因为PretenureSizeThreshold被设置为3MB,因此超过3MB的对象都会直接在老年代中进行分配。

3、长期存活的对象将进入老年代
虚拟机既然采用分代收集的思想来管理内存,那么内存回收时就必须能够识别哪些对象应当放在新生代,哪些对象应该放在老年代。为了做到这一点,虚拟机给每个对象定义了一个对象年龄计数器。如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能够被Survivor容纳的话,将被移动到Survivor空间中,并将对象年龄设置为1.对象在Survivor区每熬过一次Minor GC,年龄就增加一岁,当它的年龄增加到一定程度(默认15岁)时,就会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold来设置。

我们可以分别以-XX:MaxTenuringThreshold=1和-XX:MaxTenuringThreshold=15两种设置来执行下面的代码中的testTenuringThreshould()方法,此方法中allocation1对象需要分配256KB的内存空间,Survivor空间可以容纳。当MaxTenuringThreshold=1时,allocation1对象在第二次GC发生时进入老年代,新生代已使用的内存GC后会非常干净地变成0KB.而MaxTenuringThreshold=15,第二次GC发生后,allocation1对象则还留在新生代Survivor空间。

4、动态对象年龄判定
为了能更好地适应不同程序的内存状况,虚拟机并不总是要求对象的年龄必须达到MaxTenuringThreshold才能晋升老年代,如果在Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄。

执行代码中的testTenuringThreshold2()方法,并设置参数-XX:MaxTenuringThreshould=15,会发现运行结果中的Survivor的空间占用仍然为0%,二老年代比预期增加6%,也就是说allocation1、allocation2对象都直接进入老年代,而没有等到15岁的临界年龄。因为这两个对象加起来达到512KB,并且他们是同年的,满足同年对象达到Survivor空间的一般规则。我们只要注释掉一个对象的new操作,就会发现另一个不会晋升到老年代中去了。

5、空间分配担保
在发生Minor GC时,虚拟机就会检测之前每次晋升到老年代的平均大小是否大于老年代的剩余空间,如果大于,则改为直接进行一次Full GC.如果小于,则查看HandlePromotionFailure设置是否允许担保失败;如果允许,那只会进行Minor GC:如果不允许,则也要改为进行一次Full GC.

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

取平均值进行比较其实仍然是一种动态概率手段,也就是说如果某次Minor GC存活后的对象突增,远远高于平均值的话,依然会导致担保失败(HandlePronotion Failuer)。如果出现担保失败,那就只好在失败后重新发起一次Full GC。

3、虚拟机性能监控与故障处理工具
名称

主要作用

参数

jps

JVM Process Status Tool,显示指定系统内所有的HotSpot虚拟机进程。

-v 输出启动时JVM参数。

jstat

JVM Statistics Monitoring Tool,用于收集HotSpot虚拟机各方面的运行数据。

jstat -gc 13874监视java堆状况,包括Eden区,两个Survivor区,老年代、永久代等容量、已用空间、GC时间合计等信息。

jinfo

Configuration Info for Java,显示虚拟机配置信息。

jinfo [option] pid。

jmap

Memory Map for Java,生成虚拟机的内存转储快照(heapdump文件)。

jhat

JVM Heap Dump Browser,用于分析heapdump文件,它会建立一个HTTP/HTML服务器,让用户可以在浏览器上查看分析结果。

jstack

Stack Trace for Java,显示虚拟机的线程快照。

4、调优案例分析与实战

虚拟机执行子系统
各种不同平台的虚拟机与所有平台都统一使用的程序存储格式-字节码(ByteCode)是构成平台无关性的基石。 Java语言中的各种变量、关键字和运算符号的语义最终都是由多条字节码命令组合而成的,因此可以实现“一次编写,多次运行”。

1、类文件结构
任何一个Class文件都对应着唯一一个类或者接口的定义信息,但反过来说,类或接口并不一定都得定义在文件里(也可能通过类加载器直接生成)。

Class文件是一组以8位字节为基础单位的二进制流,字节中间没有任何分隔符。根据Java虚拟机规范的规定,Class文件格式采用一种类似于C语言结构体的伪结构来存储数据,这种伪结构中只有两种数据类型:无符号数和表。

无符号数:属于基本的数据类型,以u1、u2、u4、u8来分别代表1个字节、2个字节、4个字节和8个字节的无符号数,无符号数可以用来描述数字、索引引用、数量值或者按照UTF-8编码构成字符串值。

表:是由多个无符号数或者其他表作为数据项构成的复合数据类型,所有表都习惯性地以“_info”结尾。表用于描述有层次关系的复合结构的数据,整个Class文件本质上就是一张表。

1.1 魔数与Class文件的版本
每个Class文件的头4个字节称为魔数,值为0xCAFEBABE,他的唯一作用就是确定这个文件是否是一个能被虚拟机接受的Class文件。第五和第六个字节是次版本号,第七和第八个字节是主版本号。

1.2 常量池
Class文件中第一个出现的表类型数据项目。在常量池的入口需要放置一项u2类型的数据,代表常量池容量计数值(从1开始计数,Class文件中只有常量池的容量是从1开始的,对于其他集合类型一般是从0开始)。

常量池中主要存放两大类常量:字面量(接近java中常量的概念)和符号引用;

字面量:接近于Java语言层面上的常量概念,例如文本字符转、声明为final的常量值等;

符号引用:类和接口的全限定名、字段的名称和描述符、方法的名称和描述符;

常量池中每一项数据都是一个表。表开始的第一个u1类型的标志位,代表这个常量属于哪种常量类型。

1.3 访问标志
在常量池结束之后,紧接着的两个字节表示访问标志,用于识别一些类或者接口层次的访问信息,包括这个Class是类还是接口、是否定义为public类型、是否定义为abstract类型、是否被声明为final等。

1.4 类索引、父类索引与接口索引集合
类索引和父类索引都是一个u2类型的数据,而接口索引集合是一组u2类型的数据的集合,按顺序排列在访问标志之后,Class文件中由这三项数据来确定这个类的继承关系。对于接口索引集合,入口第一项是一个u2类型的数据的接口计数器,存储着该类实现了几个接口。

1.5 字段表集合
  字段表用于描述接口或者类中声明的变量。字段表记录着字段的作用域(public,private,protected)、是实例变量还是类变量(static)、可变性(final)、并发可见性(volatile)、是否可被序列化(transient)、字段数据类型、字段名称。

1.6 方法表集合
方法表结构一次包括了访问标志、名称索引、描述符索引、属性表集合。

1.7 属性表集合
7.1 Code属性

7.2 Exception属性

7.3 LineNumberTable属性

7.4 LocalVariableTable属性

7.5 SourceFile属性

7.6 ConstantValue属性

7.7 InnerClasses属性

7.8 Deprecated及Synthetic属性

7.9 StackMapTable属性

7.10 Signature属性

7.11 BootstrapMethods属性

2、虚拟机类加载机制
本章重点:1、虚拟机如何加载Class文件?2、Class文件中的信息进入虚拟机之后有什么样的变化?

2.1 类加载阶段
  【类加载机制】:虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换、解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这就是虚拟机的类加载机制。

在Java语言里,类型的加载、连接和初始化都是在程序运行期间完成的。

类从被加载到虚拟机内存中开始,到卸载出内存为止,它的生命周期包括:加载、连接(验证、准备、解析)、初始化、使用、卸载。

类的加载过程必须按照 加载、验证、准备、初始化和卸载 顺序按部就班地开始。

解析阶段可以在初始化阶段之后再开始,为了支持Java语言的运行时绑定(也称为动态绑定或晚期绑定 多态)。

虚拟机严格规定了有且只有5种情况必须立即对类初始化:

1、遇到new、getstatic、putstatic或invokestatic这4条字节码指令时。

new:使用new关键字实例化对象的时候

getstatic、putstatic:读取或设置一个类的静态字段的时候(被final修饰、已在编译期把结果放入常量池的静态字段除外)

invokestatic:调用一个类的静态方法的时候

2、使用java.lang.reflect包的方法对类进行反射调用的时候。

3、当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。

4、当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。

5、当使用JDK 1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。

这五种场景中的行为称为对一个类的主动引用,除此之外,所有引用类的方式都不会触发初始化,称为被动引用。

例如:①通过子类引用父类的静态字段,不会导致子类初始化。

②通过数组定义引用类,不会触发此类的初始化。

③常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化。

接口的加载过程与类加载过程稍有一些不同,针对接口需要做一些特殊说明:接口也有初始化过程,这点与类是一致的,上面的代码都是用静态语句块“static{}”来输出初始化信息的,而接口中不能使用“static{}”语句块,但编译器仍然会为接口生成”<clinit>()”类构造器,用于初始化接口中所定义的成员变量。

接口与类真正有所区别的是:当一个类在初始化时,要求其父类全部都已经初始化过了,但是一个接口在初始化时,并不要求其父接口全部都完成了初始化,只有在真正使用到父接口的时候(如引用接口中定义的常量)才会初始化。

2.2 类加载过程
2.2.1 加载
加载是类加载的第一个阶段,在此阶段虚拟机需要完成三件事:

(1)通过一个类的全限定名(…/…/…)来获取定义此类的二进制字节流(无论何种方式,只要能获得二进制流就可以)。

(2)将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。

(3)在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。

数组类本身不是通过类加载器创建的,而是由Java虚拟机直接创建的。但是数组中的元素可能需要被类加载器创建。

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

加载阶段完成后,虚拟机外部的二进制字节流就按照虚拟机所需的格式存储在方法区之中,方法区中的数据存储格式由虚拟机实现自行定义,虚拟机规范未规定此区域的具体数据结构。然后在内存中实例化一个java.lang.Class类的对象(并没有明确规定是在Java堆中,对于HotSpot虚拟机而言,Class对象比较特殊,它虽然是对象,但是存放在方法区里面),这个对象将作为程序访问方法区中的这些类型数据的外部接口。

2.2.2 验证
  验证时连接阶段的第一步,目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,不会危害虚拟机自身安全。

(1)文件格式验证

是否已魔术开头、主次版本号、常量池中的常量是否有不被支持的常量、指向常量的各种索引值中是否有不被支持的常量类型…

(2)元数据验证

这个类是否有父类、父类是否继承了不允许被继承的类(final修饰)、如果不是抽象类,是否继承了父类或者接口中要求实现的所有方法、类中的字段和方法是否和父类产生矛盾…

(3)字节码验证

验证程序语义是否合法、符合逻辑的。这个阶段对方法体进行校验分析,保证被校验类的方法在运行时不会危害虚拟机安全。

(4)符号引用验证

此阶段发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在解析阶段发生。

2.2.3 准备
准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。

1、这时候进行内存分配的仅包括类变量(被static修饰的变量),而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在Java堆中。

2、这里所说的初始值通常情况下是数据类型的零值。

2.2.4 解析
  解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。

[符号引用]:符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到内存中。各种虚拟机实现的内存布局可以各不相同,但是它们能接受的符号引用必须都是一致的,因为符号引用的字面量形式明确定义在Java虚拟机规范的Class文件格式中。

[直接引用]:直接引用可以是直接指向目标的指针、相对偏移量或是 一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在内存中存在。

对同一个符号引用进行多次解析请求是很常见的事情,除invokedynamic指令以外,虚拟机实现可以对第一次解析的结果进行缓存(在运行时常量池中记录直接引用,并把常量标识为已解析状态)从而避免解析动作重复进行。

解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行,分别对应于常量池的CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info、CONSTANT_InterfaceMethodref_info、CONSTANT_MethodType_info、CONSTANT_MethodHandle_info和CONSTANT_InvokeDynamic_info 7种常量类型。

(1) 类或接口的解析

假设当前代码所处的类为D,如果要把一个从未解析过的符号引用N解析为一个类或接口C的直接引用。

①如果C不是一个数组类型,那虚拟机将会把代表N的全限定名传递给D的类加载器去加载这个类C。在加载过程中,由于元数据验证、字节码验证的需要,又可能触发其他相关类的加载动作,例如加载这个类的父类或实现的接口。一旦这个加载过程出现了任何异常,解析过程就宣告失败。

②如果C是一个数组类型,并且数组的元素类型为对象,也就是N的描述符会是类似“[Ljava/lang/Integer”的形式,那将会按照第1点的规则加载数组元素类型。如果N的描述符如前面所假设的形式,需要加载的元素类型就是“java.lang.Integer”,接着由虚拟机生成一个代表此数组维度和元素的数组对象。

③如果上面的步骤没有出现任何异常,那么C在虚拟机中实际上已经成为一个有效的类或接口了,但在解析完成之前还要进行符号引用验证,确认D是否具备对C的访问权限。如果发现不具备访问权限,将抛出java.lang.IllegalAccessError异常。

(2)字段解析

content: 包含了简单名称和字段描述符都与目标相匹配的字段

①如果C本身就content,则返回这个字段的直接引用,查找结束。

②否则,如果在C中实现了接口,将会按照继承关系从下往上递归搜索各个接口和它的父接口,如果content,则返回这个字段的直接引用,查找结束。

③否则,如果C不是java.lang.Object的话,将会按照继承关系从下往上递归搜索其父类,如果在父类中content,则返回这个字段的直接引用,查找结束。

④否则,查找失败,抛出java.lang.NoSuchFieldError异常。

(3)类方法解析

content: 是否有简单名称和描述符都与目标相匹配的方法

①类方法和接口方法符号引用的常量类型定义是分开的,如果在类方法表中发现class_index中索引的C是个接口,那就直接抛出java.lang.IncompatibleClassChangeError异常。

②如果通过了第1步,在类C中查找content,如果有则返回这个方法的直接引用,查找结束。

③果有则返回这个方法的直接引用,查找结束。

④否则,在类C实现的接口列表及它们的父接口之中递归查找content,如果存在匹配的方法,说明类C是一个抽象类,这时查找结束,抛出java.lang.AbstractMethodError异常。

⑥否则,宣告方法查找失败,抛出java.lang.NoSuchMethodError。

(4)接口方法解析

①与类方法解析不同,如果在接口方法表中发现class_index中的索引C是个类而不是接口,那就直接抛出java.lang.IncompatibleClassChangeError异常。

②否则,在接口C中查找content,如果有则返回这个方法的直接引用,查找结束。

③否则,在接口C的父接口中递归查找,直到java.lang.Object类(查找范围会包括Object类)为止,看content,如果有则返回这个方法的直接引用,查找结束。

④否则,宣告方法查找失败,抛出java.lang.NoSuchMethodError异常。

由于接口中的所有方法默认都是public的,所以不存在访问权限的问题,因此接口方法的符号解析应当不会抛出java.lang.IllegalAccessError异常。

2.2.5 初始化
类初始化阶段才开始执行类中定义的Java程序代码(字节码文件)。

准备阶段,变量已经赋过一次系统要求的初始值。

初始化阶段,根据程序员的程序制定的主观计划去初始化类变量和其他资源,或者可以从另外一个角度来表达:初始化阶段是执行类构造器()方法的过程。

()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定。

()方法与类的构造函数(或者说实例构造器()方法)不同,它不需要显式地调用父类构造器,虚拟机会保证在子类的()方法执行之前,父类的()方法已经执行完毕,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作。因此在虚拟机中第一个被执行的()方法的类肯定是java.lang.Object。

如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成()方法。

接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成()方法。但接口与类不同的是,执行接口的()方法不需要先执行父接口的()方法。只有当父接口中定义的变量使用时,父接口才会初始化。另外,接口的实现类在初始化时也一样不会执行接口的()方法。

虚拟机会保证一个类的()方法在多线程环境中被正确地加锁、同步。

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

实现这个动作的代码模块称为“类加载器”。

2.3.1 类与类加载器
  对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在Java虚拟机中的唯一性,每一个类加载器,都拥一个独立的类名称空间。即使这两个类来源于同一个Class文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。

2.3.2 双亲委派模型
  对于虚拟机来说存在两种不同的类加载器:①启动类加载器,C++实现,是虚拟机的一部分。②所有其他的类加载器,Java语言实现,独立于虚拟机外部,并且全部继承java.lang.ClassLoader。

细致分类可分为三种:

①启动类加载器:这个类加载器负责将存放在<JAVA_HOME>\lib目录中的,或者被-Xbootclasspath参数所指定的路径中的,并且是虚拟机识别的(仅按照文件名识别,如rt.jar,名字不符合的类库即使放在lib目录中也不会被加载)类库加载到虚拟机内存中。

②拓展类加载器:这个加载器由sun.misc.Launcher$ExtClassLoader实现,它负责加载<JAVA_HOME>\lib\ext目录中的,或者被java.ext.dirs系统变量所指定的路径中的所有类库,开发者可以直接使用扩展类加载器。

③应用程序类加载器:这个类加载器由sun.misc.Launcher $App-ClassLoader实现。由于这个类加载器是ClassLoader中的getSystemClassLoader()方法的返回值,所以一般也称它为系统类加载器。它负责加载用户类路径(ClassPath)上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。

我们应用程序都是由这三种类加载器互相配合进行加载的,也可以加入自定义类加载器。

上图所示的类加载器之间的这种层次关系,成为类加载器的双亲委派模型。双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器。这里类加载器之间使用组合关系来复用父加载器的代码。

【双亲委派模型的工作过程】:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。

【双亲委派模型优点】:Java类随着它的类加载器一起具备了一种带有优先级的层次关系。例如类java.lang.Object,它存放在rt.jar之中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此Object类在程序的各种类加载器环境中都是同一个类。

3、虚拟机字节码执行引擎
所有Java虚拟机的执行引擎都是一致的:输入的是字节码文件,处理过程是字节码解析的等效过程,输出的是执行结果。

3.1 运行时栈桢结构
栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈的栈元素。

栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。每一个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。一个栈桢需要分配多少内存不受程序运行期间变量数据的影响,仅仅取决于具体的虚拟机实现。

在活动线程中,只有位于栈顶的栈桢才是有效的,称为当前栈帧,与这个栈桢相关联的方法称为当前方法。

3.1.1 局部变量表
局部变量表是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。在Java程序编译成Class文件时,就确定了该方法所需要的局部变量表的最大容量。

局部变量表容量以变量槽(Slot)为最小单位,每个Slot都应该能存放一个boolean、byte、char、short、int、float、reference或returnAddress类型的数据,这8种数据类型,都可以使用32位或更小的物理内存来存放。对于64位的数据类型,例如long和double,虚拟机会以高位对齐的方式为其分配两个连续的Slot空间。

第7种reference类型表示对一个对象实例的引用,虚拟机至少都应当能通过这个引用做到两点,一是从此引用中直接或间接地查找到对象在Java堆中的数据存放的起始地址索引,二是此引用中直接或间接地查找到对象所属数据类型在方法区中的存储的类型信息,否则无法实现Java语言规范中定义的语法约束约束。

第8种即returnAddress类型目前已经很少见了,它是为字节码指令jsr、jsr_w和ret服务的,指向了一条字节码指令的地址,很古老的Java虚拟机曾经使用这几条指令来实现异常处理,现在已经由异常表代替。

3.1.2 操作数栈
  操作数栈也成为操作栈,32位数据所占的栈容量为1,64位数据所占的容量位2。当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈/入栈操作。

在概念模型中,两个栈帧作为虚拟机栈的元素,是完全相互独立的。但在大多虚拟机的实现里都会做一些优化处理,令两个栈帧出现一部分重叠。让下面栈帧的部分操作数栈与上面栈帧的部分局部变量表重叠在一起,这样在进行方法调用时就可以共用一部分数据,无须进行额外的参数复制传递。

3.1.3 动态链接
  每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接)。通过第6章的讲解,我们知道Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候就转化为直接引用,这种转化称为静态解析。另外一部分将在每一次运行期间转化为直接引用,这部分称为动态连接。

3.1.4 方法返回地址
  当一个方法开始执行后,只有两种方式可以退出这个方法

第一种方式是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者(调用当前方法的方法称为调用者),是否有返回值和返回值的类型将根据遇到何种方法返回指令来决定,这种退出方法的方式称为正常完成出口

另外一种退出方式是,在方法执行过程中遇到了异常。

3.2 方法调用(开始打印)
3.2.1 解析
方法调用唯一任务就是确定调用哪一个方法,不是方法运行。

Class文件的编译过程中不包含传统编译中的连接步骤,一切方法调用在Class文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址(相当于之前说的直接引用)。

这个特性给Java带来了更强大的动态扩展能力,但也使得Java方法调用过程变得相对复杂起来,需要在类加载期间,对于多态甚至到运行期间才能确定目标方法的直接引用。

所有方法调用中的目标方法在Class文件里面都是一个常量池中的符号引用,在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用,这种解析能成立的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。换句话说,调用目标在程序代码写好、编译器进行编译时就必须确定下来。这类方法的调用称为解析(Resolution)。

在Java语言中符合“编译期可知,运行期不可变”这个要求的方法,主要包括静态方法和私有方法两大类,前者与类型直接关联,后者在外部不可被访问,这两种方法各自的特点决定了它们都不可能通过继承或别的方式重写其他版本,因此它们都适合在类加载阶段进行解析。

与之相对应的是,在Java虚拟机里面提供了5条方法调用字节码指令,分别如下:

(1)invokestatic:调用静态方法。

(2)invokespecial:调用实例构造器方法、私有方法和父类方法。

(3)invokevirtual:调用所有的虚方法。还有final修饰的方法。

(4)invokeinterface:调用接口方法,会在运行时再确定一个实现此接口的对象。

(5)invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法,在此之前的4条调用指令,分派逻辑是固化在Java虚拟机内部的,而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的。

只要能被invokestatic和invokespecial指令调用的方法,都可以在解析阶段中确定唯一的调用版本,符合这个条件的有静态方法、私有方法、实例构造器、父类方法4类。

3.2.2 分派
因为Java具备面向对象的3个基本特征:继承、封装和多态。下面介绍一下’重载’和’重写’。

方法分派分为静态单分派、静态多分派、动态单分派、动态多分派4种。

静态分派

编译器在重载时是通过参数的静态类型(Human)而不是实际类型(Man或Woman)作为判定依据的。并且静态类型是编译期可知的,因此,在编译阶段,Javac编译器会根据参数的静态类型决定使用哪个重载版本,所以选择了sayHello(Human)作为调用目标,并把这个方法的符号引用写到main()方法里的两条invokevirtual指令的参数中。

ATT:静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的。另外,编译器虽然能确定出方法的重载版本,但在很多情况下这个重载版本并不是“唯一的”,往往只能确定一个“更加合适的”版本。例如下面的这个例子:

上面代码的匹配顺序是按照char->int->long->float->double->Character->Serializable的顺序转型进行匹配。所以上面的代码会优先使用char类型。如果注释掉参数为char类型的方法,就会去匹配参数为int的类型,因为’a’还可以代表97,如果继续注释到剩下Character类型的方法,就会发生一次自动装箱,如果连Character类型的方法都不存在了,就只能去匹配Serializable类型了,因为Serializable是Character实现的一个接口。可见变长参数的重载优先级是最低的,这时候字符’a’被当做了一个数组元素。

动态分派

动态分派主要用来实现java多态的一个另一个特性:重写(Override)。

invokevirtual指令的运行时解析过程大致分为以下几个步骤:

(1)找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C。

(2)如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;如果不通过,则返回java.lang.IllegalAccessError异常。

(3)否则,按照继承关系从下往上依次对C的各个父类进行第2步的搜索和验证过程。

(4)如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。

由于invokevirtual指令执行的第一步就是在运行期确定接收者的实际类型,所以两次调用中的invokevirtual指令把常量池中的类方法符号引用解析到了不同的直接引用上,这个过程就是Java语言中方法重写的本质。我们把这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。

单分派与多分派

静态分派的过程:编译阶段编译器的选择过程。

这时选择目标方法的依据有两点:一是静态类型是Father还是Son,二是方法参数是QQ还是360。这次选择结果的最终产物是产生了两条invokevirtual指令,两条指令的参数分别为常量池中指向Father.hardChoice(360)及Father.hardChoice(QQ)方法的符号引用。因为是根据两个宗量进行选择,所以Java语言的静态分派属于多分派类型。

运行阶段虚拟机的选择,也就是动态分派的过程。

在执行’son.hardChoice(new QQ())‘这句代码时,更准确地说,是在执行这句代码所对应的invokevirtual指令时,由于编译期已经决定目标方法的签名必须为hardChoice(QQ),虚拟机此时不会关心传递过来的参数’QQ’到底是’腾讯QQ’还是’奇瑞QQ’,因为这时参数的静态类型、实际类型都对方法的选择不会构成任何影响,唯一可以影响虚拟机选择的因素只有此方法的接受者的实际类型是Father还是Son。因为只有一个宗量作为选择依据,所以Java语言的动态分派属于单分派类型。

由于动态分派是非常频繁的动作,而且动态分派的方法版本选择过程需要运行时在类的方法元数据中搜索合适的目标方法,因此在虚拟机的实际实现中基于性能的考虑,大部分实现都不会真正地进行如此频繁的搜索。面对这种情况,最常用的’稳定优化’手段就是为类在方法区中建立一个虚方法表(Vritual Method Table,也称为vtable,与此对应的,在invokeInterface执行时也会用到接口方法表——Inteface Method Table,简称itable),使用虚方法表索引来代替元数据查找以提高性能。

https://img-blog.youkuaiyun.com/20171221173631409?watermark/2/text/aHR0cDovL2Jsb2cuY3Nkbi5uZXQvbGluMjAwNDQxNDA0MTA=/font/5a6L5L2T/fontsize/400/fill/I0JBQkFCMA==/dissolve/70/gravity/Center

虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方法表里面的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。如果子类中重写了这个方法,子类方法表中的地址将会替换为指向子类实现版本的入口地址。图8-3中,Son重写了来自Father的全部方法,因此Son的方法表没有指向Father类型数据的箭头。但是Son和Father都没有重写来自Object的方法,所以它们的方法表中所有从Object继承来的方法都指向了Object的数据类型。

为了程序实现上的方便,具有相同签名的方法,在父类、子类的虚方法表中都应当具有一样的索引序号,这样当类型变换时,仅需要变更查找的方法表,就可以从不同的虚方法表中按索引转换出所需的入口地址。

方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始值后,虚拟机会把该类的方法表也初始化完毕。

上文中笔者说方法表是分派调用的“稳定优化”手段,虚拟机除了使用方法表之外,在条件允许的情况下,还会使用内联缓存(Inline Cache)和基于“类型继承关系分析”(ClassHierarchy Analysis,CHA)技术的守护内联(Guarded Inlining)两种非稳定的“激进优化”手段来获得更高的性能。

3.2.3 动态类型语言支持
Java虚拟机的字节码指令集的数量从Sun公司的第一款Java虚拟机问世至JDK 7来临之前的十余年时间里,一致没有发生任何变化。随着JDK 7的发布,字节码指令集终于迎来了第一位新成员——invokedynamic指令。这条新增加的指令是JDK 7实现“动态类型语言”(Dynamically Typed Language)支持而进行的改进之一,也是为JDK 8可以顺理实现Lambda表达式做技术准备。

以下,我们将详细讲解JDK 7这项新特性出现的前因后果和他的深远意义。

动态类型语言

什么是动态类型语言?

动态类型语言的关键特征是他的类型检查的主体过程是在运行期而不是编译期,满足这个特征的语言有Erlang、Groovy、JavaScript、PHP、Python等很多很多。

相对的,在编译期就进行类型检查过程的语言(C++和Java等)是最常用的静态类型语言。

这段代码能够正常编译,但运行得时候会报NegativeArraySizeException异常。在Java虚拟机规范中明确规定了NegativeArraySizeException是一个运行时异常,通俗一点来说,运行时异常就是只要代码不运行到这一行就不会有问题。与运行时异常相对应的是连接时异常,例如很常见的NoClassDefFoundError,该类异常会在类加载时抛出。(Java的连接过程不在编译阶段,而在类加载阶段)

不过,在C语言中,含义相同的代码会在编译器报错:

由此看来,一门语言的哪一种检查行为要在运行期进行,哪一种检查要在编译器进行并没有必然的因果逻辑关系,关键是语言规范中人为规定的。

这种差别产生的原因是Java语言在编译期间已将方法完整的符号引用生成出来,作为方法调用指令的参数存储到CLass文件中。

obj.println(“hello world”);

这个符号引用包含了此方法定义在哪个具体类型之中、方法的名字以及参数顺序、参数类型和方法返回值等信息,通过这个符号引用,虚拟机可以翻译出这个方法的直接引用。而在ECMAScript等动态类型语言中,变量obj本身是没有类型的,变量obj的值才具有类型,编译时最多只能确定方法名称、参数、返回值这些信息,而不会去确定方法所在的具体类型(即方法接收者不固定)。

'变量无类型而变量值才有类型’这个特点也是动态类型语言的一个重要特征。

静态类型语言在编译器确定类型,最显著的好处是编译器可以提供严谨的类型检查,这样与类型相关的问题能在编码的时候就及时发现,利于稳定性及代码达到更大规模。

动态类型语言在运行期确定类型,这可以为开发人员提供更大的灵活性,某些在静态类型语言中需要大量“臃肿”代码来实现的功能,由动态类型语言来实现可能会更加清晰和简洁,清晰和简洁通常也意味着开发效率的提升。

JDK 1.7与动态类型

Java虚拟机毫无疑问是Java语言的运行平台,但他的使命并不仅限于此。而目前确实已经有许多动态类型语言运行于Java虚拟机之上了,如Clojure、Groovy、Jython和JRuby等,能够在同一个虚拟机上可以达到静态类型语言的严谨性与动态类型语言的灵活性,这是一件很美妙的事情。

但遗憾的是:Java虚拟机层面对动态类型语言的支持一直都有所欠缺,主要表现在方法调用方面:JDK 1.7以前的字节码指令集中,4条方法调用指令(invokevirtual、invokespecial、invokestatic、invokeinterface)的第一个参数都是被调用的方法的符号引用(CONSTANT_Methodref_info、CONSTANT_InterfaceMethodref_info常量),前面已经提到过,方法的符号引用在编译时产生,而动态类型语言只有在运行期才能确定接收者类型。

这样,在Java虚拟机上实现的动态类型语言就不得不使用其他方式(如编译时留个占位符类型,运行时动态生成字节码实现具体类型到占位符类型的适配)来实现,这样势必让动态类型语言实现的复杂度增加,也可能带来额外的性能或者内存开销。尽管可以利用一些办法(如Call Site Caching)让这些开销尽量变小,但这种底层问题终归是应当在虚拟机层面上去解决才最合适,因此在Java虚拟机层面上提供动态类型的直接支持就成为了java平台的发展趋势之一,这就是JDK1.7(JSR)中invokedynamic指令以及java.lang.invoke包出现的技术背景。

java.lang.invoke包

JDK 1.7实现了JSR-292,新加入的java.lang.invoke包就是JSR-292的一个重要组成部分,这个包的主要目的是在之前单纯依靠符号引用来确定调用的目标方法这种方式以外,提供了一种新的动态确定目标方法的机制,称为MethodHandle。可以把MethodHandle与C/C++中的Function Pointer,或者C#里面的Delegate类比一下。举个例子,如果我们要实现一个带谓词的排序函数,在C/C++中常用的做法是把谓词定义为函数,用函数指针把谓词传递到排序方法,如下:

但Java语言做不到这一点,即没有办法单独的把一个函数作为参数进行传递。普遍的做法是设计一个带有compare()方法的Comparator接口,以实现了这个接口的对象作为参数,例如Collection.sort()就是这样定义的:

不过,在拥有Method Handle之后,Java语言也可以拥有类似于函数指针或者委托的方法别名的工具了。

仅站在Java语言的角度来看,MethodHandle的使用方法和效果与Reflection有众多相似之处,不过,他们还是有以下这些区别:

从本质上讲,Reflection和MethodHandle机制都是在模拟方法调用,但Reflection是在模拟Java代码层次的方法调用,而MethodHandle是在模拟字节码层次的方法调用。

Reflection是重量级,而MethodHandle是轻量级。Reflection是方法在Java一端的全面映像,包含了方法的签名、描述府以及方法属性表中各种属性的java端表示方式,还包含执行权限等的运行期信息。MethodHandle仅仅包含与执行该方法相关的信息。

MethodHandle与Reflection除了上面列举的区别外,最关键的一点还在于去掉前面讨论施加的前提“仅站在Java语言的角度来看”:Reflection API的设计目标是只为java语言服务的,而MethodHandle则设计成可服务于所有Java虚拟机之上的语言,其中也包括Java语言。

invokedynamic指令

在某种程度上,invokedynamic指令与MethodHandle机制的作用是一样的,都是为了解决原有4条“invoke*”指令方法分派规则固化在虚拟机之中的问题,把如何查找目标方法的决定权从虚拟机转嫁到具体用户代码之中,让用户(包含其他语言的设计者)有更高的的自由度。而且,他们两者的思路也是可类比的,可以把他们想象成为了达到同一个目的,一个采用上层Java代码和API来实现,另一个用字节码和Class中其他属性、常量来完成。

每一次含有invokedynamic指令的位置都称作"动态调用点"(Dynamic Call Site),这条指令的第一个参数不再是代表方法符号引用的CONSTANT_Methodref_info常量,而是变为JDK 1.7新加入的CONSTANT_InvokeDynamic_info常量,从这个新常量中可以得到3项信息:引导方法(Bootstrap Method,此方法存放在新增的BootstrapMethods属性中)、方法类型(MethodType)和名称。

引导方法是固定的参数,并且返回值是java.lang.invoke.CallSite对象,这个代表真正要执行的目标方法调用。根据CONSTANT_InvokeDynamic_info常量中提供的信息,虚拟机可以找到并且执行引导方法,从而获得一个CallSite对象,最终调用要执行的目标方法。

3.3 基于栈的字节码解释执行引擎
许多Java虚拟机的执行引擎在执行Java代码的时候都有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择,在本文,我们先来探讨一下在解释执行时,虚拟机执行引擎是如何工作的。

解释执行

Java语言经常被人们定位为“解释执行”的语言,在Java初生的JDK 1.0时代,这种定义还算是比较准确的,但当主流的虚拟机中都包含了即时编译器后,Class文件中的代码到底会被解释执行还是编译执行,就成了只有虚拟机自己才能准确判断的事情。再后来,Java也发展出了可以直接生成本地代码的编译器【如GCJ(GNU Compiler for the Java)】,而C/C++语言也出现了通过解释器执行的版本(如CINT),这时候再笼统的说“解释执行”,对于整个Java语言来说就成了几乎是没有意义的概念,只有确定了谈论对象是某种具体的Java实现版本和执行引擎运行模式时,谈解释执行还是编译执行才会比较确切。

不论是解释还是编译,也不论是物理机还是虚拟机,对于应用程序,机器都不可能如人那样阅读、理解,然后就获得了执行能力。大部分的程序代码都需要经过下图中的各个步骤变成目标的代码。

https://img-blog.youkuaiyun.com/20171221174229419?watermark/2/text/aHR0cDovL2Jsb2cuY3Nkbi5uZXQvbGluMjAwNDQxNDA0MTA=/font/5a6L5L2T/fontsize/400/fill/I0JBQkFCMA==/dissolve/70/gravity/Center

基于栈的指令集与基于寄存器的指令集

Java编译器输出的指令流,基本上是一种基于栈的指令集架构,指令流中的指令大部分都是零地址指令,他们依赖操作数栈进行工作。与之相对的另外一套常用的指令集架构是基于寄存器的指令集,最典型的就是x86的二地址指令集,说的通俗一些,就是现在我们主流PC机中直接支持的指令集架构,这些指令依赖寄存器进行工作。

那么,基于栈的指令集与基于寄存器的指令集这两者之间有什么不同呢?

举个最简单的例子,分别使用这两种指令集计算“1+1”的结果,基于栈的指令集会是这样子的:

iconst_1

iconst_1

iadd

istore_0

两条iconst_1指令连续把两个常量1压入栈后,iadd指令把栈顶的两个值出栈、相加,然后把结果放回栈顶,最后istore_0把栈顶的值放到局部变量表的第0个Slot中。

如果基于寄存器,那程序可能会是这个样子:

mov eax,1

add eax,1

mov指令把EAX寄存器的值设为1,然后add指令再把这个值加1,结果就保存在EAX寄存器里面。

基于栈的指令集主要优点就是可移植,寄存器由硬件直接提供,程序直接依赖这些硬件寄存器直接提供,那么则会不可避免地要受到硬件的约束。

栈架构指令集的主要缺点是执行速度相对来说会稍慢一些。虽然栈架构指令集的代码非常紧凑,但是完成相同功能所需的指令数量一般会比寄存器架构多,因为出栈、入栈操作本身就产生了相当多的指令数量。更重要的是,栈实现在内存之中,频繁的栈访问也就意味着频繁的内存访问,相对于处理器来说,内存始终是执行速度的瓶颈。尽管虚拟机可以采取栈顶缓存的手段,把最常用的操作映射到寄存器中避免直接内存访问,但这也只能是优化措施而不是解决本质问题的方法。由于指令数量和内存访问的原因,所以导致了栈架构指令集的执行速度会相对较慢。

基于栈的解释器执行过程

这个地方到时候直接看书即可。

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

程序编译与代码优化
1、早期(编译器)优化
1.1 概述
Java编译器可能是指前端编译器把*.java文件转变成*.class文件的过程;也可能是指虚拟机的后端运行期编译器(JIT编译器,Just In Time Compiler)把字节码转变成机器码的过程;还可能是指使用静态提前编译器(AOT编译器,Ahead Of Time Compiler)直接把*.java文件编译成本地机器代码的过程。

例如:

(1)前端编译器:Sun的javac,Eclipse JDT中的增量式编译器(ECJ);

(2)JIT编译器:HotSpot VM的C1、C2编译器;

(3)AOT编译器:GUN Complier for the Java(GCJ),Excelsior JET;

本章我们主要针对javac编译器。

需要注意的是javac这类编译器对代码的运行效率几乎没有任何优化措施。虚拟机设计团队把对性能的优化集中到了后端的即时编译器中,这样可以让那些不是由javac产生的Class文件也同样能享受到编译器优化所带来的好处。

但是javac做了许多针对java语言编码过程的优化措施来改善程序员的编码风格和提高编码效率。相当多新生的Java语法特性,都是靠编译器的“语法糖”来实现,而不是依赖虚拟机的底层改进来支持,可以说,Java中即时编译器在运行期的优化过程对于程序运行来说更重要,而前端编译器在编译期的优化过程对于程序编码来说关系更加密切。

1.2 Javac编译器
javac编译器本身就是一个由Java语言编写的程序。虽然Java虚拟机规范有专门的一章’Compiling for the Java Virtual Machine’,但都是以举例的形式描述,并没有对如何把Java源码文件转变为Class文件的编译过程进行十分严格的定义,这导致Class文件编译在某种程度上是与具体JDK实现相关的,在一些极端情况,可能出现一段代码javac编译器可以编译,但是ECJ编译器就不可以编译的问题。从Sun Javac的代码来看,编译过程大致可以分为3个过程:

(1)解析与填充符号表过程

(2)插入式注解处理器的注解处理过程

(3)分析与字节码生成过程

1.2.1 解析与填充符号表
解析步骤包括了经典程序编译原理中的词法分析和语法分析两个过程。

词法、语法分析

词法分析是将源代码的字符流转变为标记(Token)集合,每个字符是程序编写过程的最小元素,而标记则是编译过程的最小元素,关键字、变量名、字面量、运算符都可以成为标记。如“int a=b+2”这句代码包含了6个标记,分别是int、a、=、b、+、2,虽然关键字int由3个字符构成,但是它只是一个Token,不可在拆分。

语法分析是根据Token序列构造抽象语法树的过程,抽象语法树(Abstract Syntax Tree,AST)是一种用来描述程序代码语法结构的树形表示方式,语法树的每一个节点都代表着程序代码中的一个语法结构,例如包、类型、修饰符、运算符、接口、返回值甚至代码注释等都可以使一个语法结构。

填充符号表

完成了语法分析和词法分析之后,下一步就是填充符号表的过程。

符号表(Symbol Table)是由一组符号地址和符号信息构成的表格。

1.2.2 注解处理器
1.2.3 语义分析与字节码生成
语法分析之后,编译器获得了程序代码的抽象语法树表示,语法树能表示一个结构正确的源程序的抽象,但无法保证源程序是符合逻辑的。而语义分析的主要任务是对结构上正确的源程序进行上下文有关性质的审查。

javac的编译过程中,语义分析过程分为标注检查和数据及控制流分析。

标注检查

标注检查步骤检查的内容包括诸如变量使用前是否被声明、变量与赋值之间的数据类型是否能够匹配等。

数据及控制流分析

数据及控制流分析是对程序上下文逻辑更进一步的验证,它可以检查出诸如程序局部变量在使用前是否有赋值、方法的每条路径是否都有返回值、是否所有的受查异常都被正确处理了等问题。

解语法糖

语法糖(Syntactic Sugar),也称糖衣语法,指在计算机语言中添加的某种语法,这种语法对语言的功能并没有影响,但是更方便程序员使用。通常来说,使用语法糖能够增加程序的可读性,从而减少程序代码出错的机会。

Java中最常用的语法糖主要是泛型、变长参数、自动装箱/拆箱等,虚拟机运行时不支持这些语法,它们在编译阶段还原回简单的基础语法结构,这个过程称为解语法糖。

字节码生成

字节码生成是javac编译过程的最后一个阶段,字节码生成阶段不仅仅是把前面各个步骤锁生成的信息(语法树、符号表)转换成字节码写到磁盘中,编译器还进行了少量的代码添加和转换工作。

1.3 Java语法糖
几乎各种语言或多或少都提供过一些语法糖来方便程序员的代码开发,这些语法糖虽然不会提供实质性的功能改进,但是它们或能提高效率,或能提升语法的严谨性,或能减少编码出错的机会。不过也有一种观点认为语法糖不一定都是有益的,大量添加和使用“含糖”的语法,容易让程序员产生依赖,无法看清语法糖的糖衣背后,程序代码的真实面目。

总而言之,语法糖可以看做是编译期实现的一些“小把戏”,这些“小把戏”可能会使得效率“大提升”,但我们也应该去了解这些“小把戏”背后的真实世界,那样才能利用好它们,而不是被它们所迷惑。

泛型与类型擦除

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

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

另外,从Signature属性的出现还可以得出结果,擦除法所谓的擦除,仅仅是对方法的Code属性中的字节码进行擦除,实际上元数据中还是保留了泛型信息,这也是我们能通过反射手段取得参数化类型的根本依据。

自动装箱、拆箱与遍历循环

自动装箱、拆箱在编译之后被转换成了对应的包装和还原方法(Integer.valueOf(),Integer.intValue()),而遍历循环则把代码还原成立了迭代器的实现,这也是为何遍历循环需要被遍历的类实现Iterable接口的原因,变长参数在调用的时候变长了一个数组类型的参数。

条件编译

Java语言中条件编译的实现也是Java语言的一颗语法糖,根据布尔常量值的真假编译器将会把分支中不成立的代码块消除掉,这一工作将在编译器解除语法糖阶段完成。

除了泛型,自动装箱,自动拆箱,遍历循环,变长参数和条件编译之外,Java语言还有不少其他的语法糖,如内部类,枚举类,断言语句,对枚举和字符串的switch支持,try语句中定义和关闭资源等。

可以通过跟踪javac源码,反编译Class文件等方式了解它们的本质实现。从编译器层面上了解了Java源代码编译为字节码的过程,分析了Java语言中泛型,主动装箱/拆箱,条件编译等多种语法糖的前因后果。

1.4 总结
在前端编译器中,优化手段主要用于提升程序的编码效率,之所以把javac这类将Java代码转变为字节码的编译器承做“前端编译器”,是因为它只完成了从程序到抽象语法树或中间字节码的生成,而在此之后,还有一组内置于虚拟机内部的“后端编译器”完成了从字节码生成本地机器码的过程,即前面提到的即时编译器或JIT编译器,这个编译器的编译速度及编译结果的优劣,是衡量虚拟机性能一个很重要的指标。

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

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

由于Java虚拟机规范没有具体的约束规则去限制即时编译器应该如何实现,所以这部分功能完全是与虚拟机具体实现相关的内容。这里提及的编译器、即时编译器都是指HotSpot虚拟机内的即时编译器,虚拟机也是特指HotSpot虚拟机。

2.2 HotSpot虚拟机内的即时编译器
2.2.1 解释器与编译器
许多主流的商用虚拟机,如HotSpot,J9等,都同时包含解释器与编译器。

解释器与编译器两者各有优势:当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即执行。在程序运行后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码之后,可以获取更高的执行效率。

HotSpot虚拟机中内置了两个即时编译器,分别称为Client Compiler和Server Compiler,或简称为C1编译器和C2编译器(也叫Opto编译器)。目前主流的HotSpot虚拟机中,默认采用解释器与其中一个编译器直接配合的方式工作,程序使用哪个编译器,取决于虚拟机运行的模式,HotSpot虚拟机会根据自身版本与宿主机器的硬件性能自动选择运行模式,用户也可以使用-client或-server参数去强制指定虚拟机运行在Client模式或Server模式。

无论采用的编译器是Client Compiler还是Server Compiler,解释器与编译器搭配使用的方式在虚拟机中称为“混合模式”(Mixed Mode),用户可以使用参数-Xint强制虚拟机运行于解释模式(Interpreted Mode),这是编译器完全不介入工作,全部代码都使用解释方式执行。也可以使用参数-Xcomp强制迅疾运行于编译模式(Compiled Mode,已废弃)。

可以通过虚拟机的-version命令的输出结果显示出3种模式(Mixed Mode,Interpreted Mode,Compiled Mode):

由于即时编译器编译本地代码需要占用程序运行时间,要编译出优化程度更高的代码,所花费的时间可能更长;而且想要编译出优化程度更高的代码,解释器可能还要替编译器收集性能监控信息,这对解释执行的速度也有影响。为了在程序启动响应速度与运行效率之间达到最佳平衡,HotSpot虚拟机还会逐渐启动分层编译(Tiered Compilation)的策略。

分层编译根据编译器编译、优化的规模与耗时,划分出不同的编译层次,其中包括:

第0层,程序解释执行,解释器不开启性能监控功能,可触发第1层编译;

第1层,也称为C1编译,将字节码编译为本地代码,进行监督、可靠的优化,如有必要将加入性能监控的逻辑;

第2层(或2层以上),也称为C2编译,也是讲字节码编译为本地代码,但是会启用一些编译耗时较长的优化,甚至会根据性能监控信息进行一些不可靠的激进优化。

实施分层编译后,Client Compiler和Server Compiler将会同时工作,许多代码都可能会被多次编译,用Client Compiler获取更高的编译速度,用Server Compiler来获取编译质量,在解释执行的时候也无须再承担收集性能监控信息的任务。

2.2.2 编译对象和触发条件
在运行过程中会被即时编译器编译的“热点代码”有两类,1、被多次调用的方法;2、被多次执行的循环体;

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

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

1、基于采样的热点探测(Sample Based Hot Spot Detection)

2、基于计数器的热点探测(Counter Based Hot Spot Detection)

在HotSpot中使用了基于计数器的热点探测方法,因此为每个方法准备两类计数器:方法调用计数器和回边计数器。在确定虚拟机运行参数的前提下,这两个计数器都有一个确定的阈值,当计数器超过阈值,就会触发JIT编译。

2.2.3 编译过程
在默认情况下,无论是方法调用产生的即时编译请求,还是OSR编译请求,虚拟机在代码编译器还未完成之前,都仍然将按照解释方式继续执行,而编译动作则在后台的编译线程中进行。

用户可以通过参数-XX:-BackgroundComilation来禁止后台编译,在禁止后台编译后,一旦达到JIT的编译条件,执行线程向虚拟机提交编译请求后将会一直等待,直到编译过程完成后再开始执行编译器输出的本地代码。

后台执行编译的过程中,编译器Server Compiler和Client Compiler两个编译器的编译过程是不一样的。

Client Compiler

对于ClientCompiler来说,它是一个简单快速的三段式编译器,主要的关注点在于局部性的优化,而放弃了许多耗时较长的全局优化手段。

第一个阶段,一个平台独立的前端将字节码构造成一种高级中间代码表示(HIR),在此之前编译器会在字节码上完成一部分基础优化,如方法内联、常量传播等优化将会在字节码被构造成HIR之前完成。

第二个阶段,一个平台相关的后端从高级中间代码表示(HIR)中产生低级中间代码表示(LIR),在此之前会在HIR上完成另外一些优化,如空值检查消除、范围检查消除等。

第三个阶段,平台相关的后端使用线下扫描算法,在LIR上分配寄存器,并在LIR上做窥空优化,然后产生机器代码。

Server Compiler

Server Compiler是专门面向服务端的典型应用并未服务端的性能配置特别调整过的编译器,也是一个充分优化过的高级编译器,几乎达到GUN C++编译器使用-O2参数时的优化强度,它会执行所有经典的优化动作,如无用代码消除,循环展开,循环表达式外提,消除公共子表达式,常量传播,基本块重排序等。还会实施一些与Java语言特性密切相关的优化技术,如范围检查消除,空值检查消除。

2.3 查看及分析即时编译结果
一般来说,虚拟机的即时编译过程对用户程序时完全透明的,虚拟机通过解释执行代码还是编译执行代码,对于用户来说并没有什么影响。但是虚拟机也提供了一些参数用来输出即时编译和某些优化手段(如方法内联)的执行状态。

2.3.1 编译优化技术
以编译方式执行本地代码比解释方式更快,除去虚拟机解释执行字节码时额外消耗时间的原因外,还有一个很重要的原因就是虚拟机几乎把对代码的所有优化措施都集中在了即时编译器之中。因此一般来说,即时编译器产生的本地代码会比javac产生的字节码更加优秀。

2.3.2 优化技术概览
优化技术主要分为如下几类:(1)编译器策略(2)基于性能监控的优化技术(3)基于证据的优化技术(4)数据流敏感重写(5)语言相关的优化技术(6)内存及代码位置变换(7)循环变换(8)全局代码调整(9)控制流图变换。

高效并发
1、java内存模型与线程
  Java内存模型的目的是定义程序中各个变量的访问规则,此处的变量包括实例字段、静态字段和构成数组对象的元素,但不包括局部变量和方法参数,因为后者是线程私有的。

Java内存模型规定所有的变量都存储在主内存中,每个线程有自己的工作线程,线程的工作内存中保存了被该线程用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写到主内存中的变量。不同线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。

内存间交互操作

关于主内存与工作内存之间具体的交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存的实现,Java内存模型中定义了8种操作来完成,虚拟机实现时必须保证每一种操作都是原子的、不可再分的。

1、lock(锁定):作用于主内存的变量,将一个变量标识为一条线程独占的状态

2、unlock(解锁):作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定

3、read(读取):作用于主内存的变量,把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用

4、load(载入):作用于工作内存的变量,把read操作从主内存中得到的变量值放入工作内存的变量副本中

5、use(使用):作用于工作内存的变量,把工作内存中的一个变量的值传递给执行引擎,当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行该操作

6、assign(赋值):作用于工作内存的变量,把一个从执行引擎接收到的值赋给工作内存的变量,当虚拟机遇到一个给变量赋值的字节码指令时执行该操作

7、store(存储):作用于工作内存的变量,把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用。

8、write(写入):作用于主内存的变量,把store操作从工作内存中得到的变量的值放入主内存的变量中。

Java内存模型要求read和load、store和write操作必须按顺序执行,但不保证是连续执行。此外还规定在执行上必须满足如下规则:

1、不允许read和load、store和write操作之一单独出现,即不允许一个变量从主内存读取但工作内存不接受,或从工作内存发起回写但主内存不接受的情况

2、不允许一个线程丢弃它最近的assign操作,即变量在工作内存中改变了必须把变化同步回主内存

3、不允许一个线程无原因地(没发生过任何的assign操作)把数据从线程的工作空间同步回主内存中

4、一个新的变量只能在主内存诞生,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量。即对一个变量执行user、store操作之前,必须先执行过assign和load操作

5、一个变量在同一个时刻只允许一条线程对其进行lock操作,但lock操作可被同一条线程重复执行多次。多次执行lock后,只有执行相同次数的unlock才会被解锁

6、如果对一个变量执行lock操作,那将会情空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作初始化变量的值

7、如果一个变量事先没有被lock锁定,那就不允许对它执行unlock操作,也不允许去unlock一个被其他线程锁定住的变量

8、对一个变量执行unlock之前,必须先把此变量同步回主内存(执行store、write操作)

对于long和double型变量的特殊规则:

Java内存模型要求内存交互的8种操作都具有原子性,但是对于64位的数据类型(long和double)有一条相对宽松的规定:

允许虚拟机将没有被volatile修饰的64位数据的读写操作划分为两次32位的操作来进行。即允许虚拟机实现选择可以不保证64位数据类型的load、store、read、write这四个操作的原子性。这就是long和double的非原子性协定。

原子性:

由Java内存模型直接保证的原子性变量操作包括read、load、assign、use、store和write。我们可以认为基本类型数据的访问读写是具备原子性的(例外就是long和double的非原子性协议)。

此外还提供了lock和unlock来满足更大范围的原子性保证。虚拟机并为直接将操作开发给用户使用,但提供了字节码指令monitorenter和monitorexit来隐式的使用这两个操作。这两个字节码指令反映到java代码中的synchronized关键字,因此synchronized块之间的操作也具备原子性

可见性:

当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。通过volatile可以保证可见性外,还可以通过synchronized和final。同步快的可见性是对一个变量释放锁之前,必须先把此变量同步回主内存。而final关键字的可见性是指,被final修饰的字段在构造器中一旦初始化完成,并且构造器没有把this引用传递出去,那在其他线程中就能看到final字段的值,保证可见性

有序性:

Java提供volatile和synchronized两个关键字来保证线程之间操作的有序性。volatile通过进制指令重排序,而synchronized通过一个变量同一时刻只允许一个线程对其进行lock操作来保证有序性。持有同一个锁的两个同步块只能串行地进入

2、线程安全与锁优化
线程安全的实现方法:

1、互斥同步

同步是指多个线程并发访问共享数据时,保证共享数据在同一个时刻只被一个线程使用。而互斥是实现同步的手段,临界区、互斥量、信号量都是主要的互斥实现方式。

Java中最基本的互斥同步手段就是synchronized关键字,synchronized关键字编译后,会在同步块的前后形成monitorenter和monitorexit指令,这两个指令都需要一个reference类型的参数来指明要锁定和解锁的对象。如果程序的synchronized明确指定了对象参数,那么就是这个对象的reference,如果没有明确指定,就根据其修饰的是实例方法还是类方法,去取对应的对象实例或Class对象来作为锁对象。

除了synchronized之外,还可以使用java.util.concurrent包中的重入锁(ReentranLock)来实现同步。

2、非阻塞同步

互斥同步最主要的问题就是进行线程阻塞和唤醒带来的性能问题,因此这种同步也称为阻塞同步。互斥同步属于一种悲观的并发策略,而非阻塞同步是一种基于冲突检测的乐观并发策略。即先进行操作,如果没有其他线程争用共享数据,那操作就成功,如果共享数据有争用产生了冲突,那就再采取其他补偿措施。实现方式可以有版本号机制或CAS算法

3、无同步方案

同步只是保证共享数据争用时的正确性的手段,如果一个方法本身不涉及共享数据,那它自然就无须任何同步措施。有一些代码天生就是线程安全的。如:可重入代码、线程本地存储(java.lang.ThreadLocal)

锁优化:

1、自旋锁与自适应自旋

自旋锁:互斥同步时,共享数据的锁定状态只会持续很短一段时间,为了这段时间去挂起和恢复线程并不值得。无论是互斥锁,还是自旋锁,在任何时刻,最多只能有一个保持者,也就说,在任何时刻最多只能有一个执行单元获得锁。但是两者在调度机制上略有不同。对于互斥锁,如果资源已经被占用,资源申请者只能进入睡眠状态。但是自旋锁不会引起调用者睡眠,如果自旋锁已经被别的执行单元保持,调用者就一直循环在那里看是否该自旋锁的保持者已经释放了锁,"自旋"一词就是因此而得名。

自旋锁在JDK6中就默认开启了,可通过参数–XX:+UseSpinning来设置。自旋等待不能完全替代阻塞,因为它还是要占用处理器时间。如果锁被占用的时间短,那么效果当然就很好了!反之,相反!自旋等待的时间必须要有限度。如果自旋超过了限定次数任然没有获得锁,就应该挂起线程。自旋次数的默认值是10次,用户可以修改–XX:PreBlockSpin来更改。

在JDK6中引入自适应的自旋锁,这意味着自旋的时间不再固定了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。

2、锁消除

指的就是虚拟机即使编译器在运行时,如果检测到那些共享数据不可能存在竞争,那么就执行锁消除。锁消除可以节省毫无意义的请求锁的时间。锁消除的主要判断依据来源于逃逸分析的数据支持,如果判断一段代码中,堆上所有数据都不会逃逸出去从而被其他线程访问到,那就可以把它们当做栈上数据来对待,认为是线程私有的,同步加锁就无须进行。

3、锁粗化

原则上,我们再编写代码的时候,总是推荐将同步快的作用范围限制得尽量小——只在共享数据的实际作用域才进行同步,这样是为了使得需要同步的操作数量尽可能变小,如果存在锁竞争,那等待线程也能尽快拿到锁。大部分情况下,上面的原则都是没有问题的,但是如果一系列的连续操作都对同一个对象反复加锁和解锁,那么会带来很多不必要的性能消耗。

这段代码,每个append()方法中都有一个同步块,锁是sb对象。连续的append()方法会对同一个对象反复加锁解锁,虚拟机探测到这样一串零碎的操作都对同一对象加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部。即扩展到第一个append()操作之前直至最后一个append()操作之后,这样只需要加锁一次就可以了。

4、轻量级锁

轻量级锁不是为了代替重量级锁,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。了解轻量级锁的原理之前需要了解什么是MARKit Word。

Mark Word:HotSpot虚拟机的对象头分为两部分信息,其中一部分用于存储对象自身的运行时数据(如哈希吗、GC分段信息等),官方称为Mark Word。32位的HotSpot虚拟机中对象未被锁定状态下的对象存储分布

轻量级锁加锁原理:

代码进入同步块时,如果此同步对象没有被锁定(锁标识位为01状态),虚拟机首先在当前线程的栈桢中建立一个锁记录空间(Lock Record)用于存储对象目前的Mark Word的拷贝(Displaced Mark Word)。然后虚拟机使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,如果更新成功,则线程拥有了该对象锁,并将Mark Word的锁标志位转变为00,即表示此对象处于轻量级锁定状态。如果更新失败,虚拟机会检测对象的Mark Word是否指向当前线程的栈桢,如果是则说明当前线程已拥有这个对象的锁,否则说明这个锁对象被其他线程抢占。如果有两个以上线程争用同一个锁,那轻量级锁就膨胀为重量级锁,锁标志位变为10.Mark Word存储的就是指向重量级锁(互斥锁)的指针,后面等待锁的线程也要进入阻塞状态。

解锁:

通过CAS操作,如果对象的Mark Word仍然指向线程的锁记录,就把对象当前的Mark Word和线程中复制的Displaced Mark Word复制回来。替换成功则同步过程完成,替换失败,说明有其他线程尝试过获取该锁,那就要在释放锁的同时,唤醒被挂起的线程。

在没有竞争的情况下,轻量级锁使用CAS操作避免了使用互斥量的开销。但如果存在竞争,除了互斥量的开销外,还额外发生CAS操作,轻量级锁会比传统的重量级锁更慢。

5、偏向锁

目的是消除数据在无竞争情况下的同步,与轻量级锁相比,其在无竞争情况下把整个同步都消除掉,连CAS操作都不做。通过参数-XX:+UseBiasedLocking来设置。偏向锁的“偏”就是偏心的偏,它的意思是会偏向于第一个获得它的线程,如果在接下来的执行中,该锁没有被其他线程获取,那么持有偏向锁的线程就不需要进行同步

原理:

当虚拟机启用了偏向锁,当锁对象第一次被线程获取时,虚拟机将对象头中的标志位设为01,即偏向模式。同时使用CAS把获取到这个锁的线程的ID记录在对象的Mark Word之中。如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作。当另一个线程去尝试获取这个锁时,偏向模式就宣告结束。根据锁对象目前是否处于被锁定状态,撤销偏向后恢复到未锁定或轻量级锁定的状态。后续同步操作就如轻量级锁那样执行。

对于锁竞争比较激烈的场合,偏向锁就失效了,因为这样场合极有可能每次申请锁的线程都是不相同的,因此这种场合下不应该使用偏向锁,否则会得不偿失,需要注意的是,偏向锁失败后,并不会立即膨胀为重量级锁,而是先升级为轻量级锁。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值