深入理解Java虚拟机 读书笔记

内存区域

程序计数器

当前线程所执行的字节码的行号指示器.

字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令.

每条线程都需要有一个独立的程序计数器.

如果线程正在执行的是一个 Java 方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;

如果正在执行的是 Native 方法,这个计数器值则为空(Undefined).

Java虚拟机栈

Java虚拟机栈(Java Virtual Machine Stacks)线程私有的,它的生命周期与线程相同.

每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储 局部变量表、操作数栈、动态链接、方法出口 等信息.

每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程.

如果线程请求的栈深度大于虚拟机所允许的深度,将抛出 StackOverflowError 异常;

如果虚拟机栈可以动态扩展(当前大部分的Java虚拟机都可动态扩展,只不过Java虚拟机规范中也允许固定长度的虚拟机栈),如果扩展时无法申请到足够的内存,就会抛出 OutOfMemoryError 异常.

局部变量表

局部变量表存放了编译期可知的各种基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,它不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)和returnAddress类型(指向了一条字节码指令的地址).

其中64位长度的 long 和 double 类型的数据会占用2个局部变量空间(Slot),其余的数据类型只占用1个.
局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小.

本地方法栈

执行 Native 方法.
Hot Spot 虚拟机 直接就把本地方法栈和虚拟机栈合二为一.
会抛出 StackOverflowError 和 OutOfMemoryError 异常.

Java堆

Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建.

Java虚拟机规范中的描述是 : 所有的 对象实例 以及 数组 都要在堆上分配.

随着JIT编译器的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换 优化技术将会导致一些微妙的变化发生,所有的对象都分配在堆上也渐渐变得不是那么"绝对"了.

Java堆中还可以细分为 : 新生代和老年代

再细致一点的有Eden空间、From Survivor空间、To Survivor空间等

从内存分配的角度来看,线程共享的Java堆中可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB)

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

方法区

各个线程共享的内存区域,它用于存储已被虚拟机加载的 类信息、常量、静态变量、即时编译器编译后的代码 等数据

HotSpot用永久代来实现方法区

JDK 1.7的HotSpot中,已经把原本放在永久代的字符串常量池移出.

当方法区无法满足内存分配需求时,将抛出 OutOfMemoryError 异常.

运行时常量池

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

运行时常量池相对于Class文件常量池的另外一个重要特征是具备动态性,Java语言并不要求常量一定只有编译期才能产生,也就是并非预置入Class文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中,这种特性被开发人员利用得比较多的便是String类的intern()方法.

当常量池无法再申请到内存时会抛出 OutOfMemoryError 异常

String.intern()字符串常量池,1.6在方法区,1.7在堆里

String.intern()是一个Native方法,
它的作用是 : 如果字符串常量池中已经包含一个等于此 String对象的字符串,则返回代表池中这个字符串的String对象;
否则,将此 String对象包含的字符串添加到常量池中,并且返回此String对象的引用.

intern用来返回常量池中的某字符串,如果常量池中已经存在该字符串,则直接返回常量池中该对象的引用。否则,在常量池中加入该对象,然后 返回引用。

在jdk1.7之前,字符串常量存储在方法区的PermGen Space。在jdk1.7之后,字符串常量重新被移到了堆中

String.intern方法在JDK6和JDK7的区别
产生差异的原因是 :
在 JDK 1.6中 String.intern()方法会把首次遇到的字符串实例复制到永久代中,返回的也是永久代中这个字符串实例的引用,
而由 StringBuilder创建的字符串实例在 Java堆上,所以必然不是同一个引用,将返回 false.

而 JDK 1.7的 String.intern()实现不会再复制实例,只是在常量池中记录首次出现的实例引用,
因此 intern()返回的引用和由 StringBuilder创建的那个字符串实例是同一个.

直接内存(Direct Memory)

NIO使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作.这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据.

对象

对象内存分配的线程安全问题

一种是对分配内存空间的动作进行同步处理——实际上虚拟机采用CAS 配上 失败重试的方式保证更新操作的原子性;

另一种是把内存分配的动作 按照线程 划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB).哪个线程要分配内存,就在哪个线程的TLAB上分配,只有TLAB用完并分配新的TLAB时,才需要同步锁定.虚拟机是否使用TLAB,可以通过-XX:+/-UseTLAB参数来设定.

对象创建流程

new指令
->
检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过.
->
若未加载,则执行相应的类加载过程.
->
为新生对象分配内存,对象所需内存的大小在类加载完成后便可完全确定.
1.堆内存分配的算法(如CMS, G1);
2.堆内存分配的线程安全问题,CAS或TLAB解决
->
将分配到的内存空间都初始化为零值(不包括对象头).
->
对对象进行必要的设置,
例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息.这些信息存放在对象的对象头(Object Header)之中.
根据虚拟机当前的运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式
->
执行<init>方法

对象的内存布局

对象在内存中存储的布局可以分为3块区域 : 对象头(Header)、实例数据(Instance Data) 和 对齐填充(Padding)

对象头

对象头包括两部分信息,
第一部分 用于存储对象自身的运行时数据,即"Mark Word".
用于存储如 哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳 等,
这部分数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为 32bit 和 64bit.

对象头的另外一部分 是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例.

实例数据

实例数据部分是对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容.

对象的访问定位

Java栈中的本地变量表(引用) -> 指向Java堆中的对象实例数据
Java堆中的对象实例数据中的 对象类型数据的指针 -> 指向方法区中的对象类型数据

方法区OOM

CGLib这类字节码技术对类进行增强时,增强的类越多,就需要越大的方法区来保证动态生成的Class可以加载入内存.

直接内存溢出

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

垃圾回收

程序计数器、虚拟机栈、本地方法栈3个区域随线程而生,随线程而灭.

栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作.每一个栈帧中分配多少内存基本上是在类结构确定下来时就已知的,方法结束或者线程结束时,内存自然就跟随着回收了.不需要过多考虑内存回收问题.

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

可达性分析算法

通过一系列的称为"GC Roots"的对象作为起始点,从这些节点开始向下搜索,
搜索所走过的路径称为引用链(Reference Chain),
当一个对象到GC Roots没有任何引用链相连(用图论的话来说,即从GC Roots到这个对象不可达)时,则证明此对象是不可用的

引用分类

强引用(Strong Reference)
Object obj = new Object(),只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象

软引用(Soft Reference)
系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收.
如果这次回收还没有足够的内存,才会抛出内存溢出异常

弱引用(Weak Reference)
被弱引用关联的对象只能生存到下一次垃圾收集发生之前.当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象

虚引用(Phantom Reference)
能在这个对象被收集器回收时收到一个系统通知

回收方法区

即HotSpot虚拟机中的永久代
主要回收两部分内容 : 废弃常量和无用的类

判断常量池中废弃常量的方式:没有引用在引用这个常量(如字符串常量)

判断方法区中无用的类的方式:
该类所有的实例都已经被回收,也就是Java堆中不存在该类的任何实例.
加载该类的ClassLoader已经被回收.
该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法.

垃圾收集算法

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

首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象

不足有两个 :
一个是效率问题,标记和清除两个过程的效率都不高;

另一个是空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作.

复制算法

将可用内存按容量划分为大小相等的两块,每次只使用其中的一块.
当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉.

可用于回收新生代
将内存分为一块较大的Eden空间和两块较小的Survivor空间

HotSpot虚拟机默认 Eden 和 Survivor 的大小比例是8:1,也就是每次新生代中可用内存空间为整个新生代容量的90%(80%+10%),只有10%的内存会被"浪费".

标记-整理算法

根据老年代的特点,“标记-整理”(Mark-Compact)算法
标记过程仍然与"标记-清除"算法一样 : 首先标记出所有需要回收的对象,
但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存.

分代收集算法

根据对象存活周期的不同将内存划分为几块.

一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法.

在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集.

而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用"标记—清理"或者"标记—整理"算法来进行回收.

HotSpot算法实现

可达性分析对执行时间的敏感还体现在GC停顿上,
因为这项分析工作必须在一个能确保一致性的快照中进行,
这里"一致性"的意思是指在整个分析期间整个执行系统看起来就像被冻结在某个时间点上,
不可以出现分析过程中对象引用关系还在不断变化的情况,
该点不满足的话分析结果准确性就无法得到保证.
这点是导致GC进行时必须停顿所有Java执行线程(Sun将这件事情称为"Stop The World")的其中一个重要原因

OopMap的数据结构,在OopMap的协助下,HotSpot可以快速且准确地完成GC Roots枚举

安全点

程序执行时并非在所有地方都能停顿下来开始GC,只有在到达安全点时才能暂停

安全点的选定基本上是以程序"是否具有让程序长时间执行的特征"为标准进行选定的

长时间执行"的最明显特征就是指令序列复用,例如方法调用、循环跳转、异常跳转等,所以具有这些功能的指令才会产生Safepoint.

对于Sefepoint,另一个需要考虑的问题是如何在GC发生时让所有线程(这里不包括执行JNI调用的线程)都"跑"到最近的安全点上再停顿下来.

这里有两种方案可供选择 :
抢先式中断(Preemptive Suspension)和主动式中断(Voluntary Suspension)

抢先式中断: 不需要线程的执行代码主动去配合,在GC发生时,首先把所有线程全部中断,如果发现有线程中断的地方不在安全点上,就恢复线程,让它"跑"到安全点上.现在几乎没有虚拟机实现采用抢先式中断来暂停线程从而响应GC事件.

主动式中断: 当GC需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志,各个线程执行时主动去轮询这个标志,发现中断标志为真时就自己中断挂起.轮询标志的地方和安全点是重合的,另外再加上创建对象需要分配内存的地方.

安全区域

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

垃圾收集器

垃圾收集器

Serial收集器

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

ParNew收集器

ParNew收集器其实就是Serial收集器的多线程版本

新生代采取 复制算法 暂定所有用户线程进行垃圾回收
老年代采取 标记-整理算法 暂停所有用户线程进行垃圾回收

Parallel Scavenge收集器

Parallel Scavenge收集器是一个新生代收集器,它也是使用复制算法的收集器,又是并行的多线程收集器

Parallel Scavenge收集器的特点是它的关注点与其他收集器不同,

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

所谓吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值,即吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间),虚拟机总共运行了100分钟,其中垃圾收集花掉1分钟,那吞吐量就是99%.

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

Serial Old收集器

Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器,使用"标记-整理"算法.
它还作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Failure时使用.

Parallel Old收集器

Parallel Old是Parallel Scavenge收集器的老年代版本,使用多线程和"标记-整理"算法.

CMS收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器.
响应速度快,希望系统停顿时间最短

基于"标记—清除"算法实现

过程分为4个步骤,包括 :

初始标记(CMS initial mark)

并发标记(CMS concurrent mark)

重新标记(CMS remark)

并发清除(CMS concurrent sweep)

初始标记、重新标记这两个步骤仍然需要"Stop The World".

初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快

并发标记阶段就是进行GC RootsTracing的过程

重新标记阶段则是为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短.

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

CMS收集器

缺点:

  1. 在并发阶段,它虽然不会导致用户线程停顿,但是会因为占用了一部分线程(或者说CPU资源)而导致应用程序变慢,总吞吐量会降低.

  2. CMS收集器无法处理浮动垃圾(Floating Garbage),可能出现"Concurrent Mode Failure"失败而导致另一次Full GC的产生.
    由于CMS并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS无法在当次收集中处理掉它们,只好留待下一次GC时再清理掉.
    这一部分垃圾就称为"浮动垃圾".也是由于在垃圾收集阶段用户线程还需要运行,那也就还需要预留有足够的内存空间给用户线程使用,因此CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,需要预留一部分空间提供并发收集时的程序运作使用.
    要是CMS运行期间预留的内存无法满足程序需要,就会出现一次"Concurrent Mode Failure"失败,这时虚拟机将启动后备预案 : 临时启用Serial Old收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了.

  3. CMS是一款基于"标记—清除"算法实现的收集器,收集结束时会有大量空间碎片产生.空间碎片过多时,将会给大对象分配带来很大麻烦,往往会出现老年代还有很大空间剩余,但是无法找到足够大的连续空间来分配当前对象,不得不提前触发一次Full GC.CMS收集器提供了一个-XX:+UseCMSCompactAtFullCollection开关参数(默认就是开启的),用于在CMS收集器顶不住要进行FullGC时开启内存碎片的合并整理过程,内存整理的过程是无法并发的.

G1收集器

G1能充分利用多CPU,使用多个CPU(CPU或者CPU核心)来缩短Stop-The-World停顿的时间,部分其他收集器原本需要停顿Java线程执行的GC动作,G1收集器仍然可以通过并发的方式让Java程序继续执行.

G1从整体来看是基于"标记—整理"算法实现的收集器,从局部(两个Region之间)上来看是基于"复制"算法实现的,但无论如何,这两种算法都意味着G1运作期间不会产生内存空间碎片,收集后能提供规整的可用内存.

Java堆的内存布局就与其他收集器有很大差别,它将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分Region(不需要连续)的集合.

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

GC日志

GC日志开头的"[GC"和"[Full GC"说明了这次垃圾收集的停顿类型,而不是用来区分新生代GC还是老年代GC的.如果有"Full",说明这次GC是发生了Stop-The-World的

JVM内存管理的作用

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

对象分配

对象主要分配在新生代的Eden区上,如果启动了本地线程分配缓冲,将按线程优先在TLAB上分配.

少数情况下也可能会直接分配在老年代中.

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

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

老年代GC(Major GC/Full GC) : 指发生在老年代的GC,出现了Major GC,经常会伴随至少一次的Minor GC.Major GC的速度一般会比Minor GC慢10倍以上.

大对象直接进入老年代

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

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

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

既然虚拟机采用了分代收集的思想来管理内存,那么内存回收时就必须能识别哪些对象应放在新生代,哪些对象应放在老年代中.

为了做到这点,虚拟机给每个对象定义了一个对象年龄(Age)计数器.

如果对象在 Eden 出生并经过第一次 Minor GC 后仍然存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间中,并且对象年龄设为1.

对象在 Survivor 区中每"熬过"一次 Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁),就将会被晋升到老年代中.对象晋升老年代的年龄阈值,可以通过参数-XX:MaxTenuringThreshold设置.

动态对象年龄判定

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

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

空间分配担保

在发生Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么Minor GC可以确保是安全的.

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

监控工具

数据包括 : 运行日志、异常堆栈、GC日志、线程快照(threaddump/javacore文件)、堆转储快照(heapdump/hprof文件)等

名称主要作用
JPSJVM Process Status Tool, 显示指定系统内所有的HotSpot虚拟机进程
jstatJVM Statistics Monitoring Tool,用于收集HotSpot虚拟机各方面的运行数据
jinfoConfiguration Info for Java,显示虚拟机配置信息
jmapMemory Map for Java,生成虚拟机的内存转储快照(heapdump文件)
jhatJVM Heap Dump Browser,用于分析heapdump文件,它会建立一个HTTP/HTML服务器,让用户能查看分析结果
jstakStack Trace for Java,显示虚拟机的线程快照

可视化工具

JConsole(Java Monitoring and Management Console)是一种基于JMX的可视化监视、管理工具.它管理部分的功能是针对JMX MBean进行管理

VisualVM : 多合一故障处理工具
工具->插件安装

虚拟机执行子系统

类文件结构

Java虚拟机只与"Class文件"这种特定的二进制文件格式所关联,Class文件中包含了Java虚拟机指令集和符号表以及若干其他辅助信息.

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

Class文件是一组以8位字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑地排列在Class文件之中,中间没有添加任何分隔符.

当遇到需要占用8位字节以上空间的数据项时,则会按照高位在前的方式分割成若干个8位字节进行存储.

这种伪结构中只有两种数据类型 : 无符号数和表.

无符号数属于基本的数据类型,以u1、u2、u4、u8来分别代表1个字节、2个字节、4个字节和8个字节的无符号数.

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

魔数与Class文件的版本

每个Class文件的头4个字节称为魔数(Magic Number),它的唯一作用是确定这个文件是否为一个能被虚拟机接受的Class文件.

紧接着魔数的4个字节存储的是Class文件的版本号 : 第5和第6个字节是次版本号(Minor Version),第7和第8个字节是主版本号(Major Version).

高版本的JDK能向下兼容以前版本的Class文件,但不能运行以后版本的Class文件.

常量池

紧接着主次版本号之后的是常量池入口.

常量池可以理解为Class文件之中的资源仓库,它是Class文件结构中与其他项目关联最多的数据类型,也是占用Class文件空间最大的数据项目之一,同时它还是在Class文件中第一个出现的表类型数据项目.

由于常量池中常量的数量是不固定的,所以在常量池的入口需要放置一项u2类型的数据,代表常量池容量计数值(constant_pool_count)

常量池中主要存放两大类常量 :

字面量(Literal)和符号引用(Symbolic References).

  1. 字面量比较接近于Java语言层面的常量概念,如文本字符串、声明为final的常量值等.

  2. 符号引用则属于编译原理方面的概念,包括了下面三类常量 :
    类和接口的全限定名(Fully Qualified Name)

字段的名称和描述符(Descriptor)

方法的名称和描述符

在Class文件中不会保存各个方法、字段的最终内存布局信息,因此这些字段、方法的符号引用不经过运行期转换的话无法得到真正的内存入口地址,也就无法直接被虚拟机使用.
当虚拟机运行时,需要从常量池获得对应的符号引用,再在类创建时或运行时解析、翻译到具体的内存地址之中.

常量池中每一项常量都是一个表

用 javap 查看 Class 文件能看到 常量池数据.

访问标志

在常量池结束之后,紧接着的两个字节代表访问标志(access_flags),这个标志用于识别一些类或者接口层次的访问信息,

包括 : 这个 Class 是类还是接口;是否定义为 public 类型;是否定义为 abstract 类型;如果是类的话,是否被声明为 final 等.

类索引、父类索引与接口索引集合

类索引(this_class)和父类索引(super_class)都是一个u2类型的数据,而接口索引集合(interfaces)是一组u2类型的数据的集合,Class文件中由这三项数据来确定这个类的继承关系.

类索引用于确定这个类的全限定名,父类索引用于确定这个类的父类的全限定名.

由于Java语言不允许多重继承,所以父类索引只有一个,除了java.lang.Object之外,所有的Java类都有父类,因此除了java.lang.Object外,所有Java类的父类索引都不为0.接口索引集合就用来描述这个类实现了哪些接口,这些被实现的接口将按implements语句(如果这个类本身是一个接口,则应当是extends语句)后的接口顺序从左到右排列在接口索引集合中.

字段表集合

字段表(field_info)用于描述接口或者类中声明的变量.

字段(field)包括类级变量以及实例级变量,但不包括在方法内部声明的局部变量.

我们可以想一想在Java中描述一个字段可以包含什么信息?可以包括的信息有 : 字段的作用域(public、private、protected修饰符)、是实例变量还是类变量(static修饰符)、可变性(final)、并发可见性(volatile修饰符,是否强制从主内存读写)、可否被序列化(transient修饰符)、字段数据类型(基本类型、对象、数组)、字段名称.

方法表集合

Class文件存储格式中对方法的描述与对字段的描述几乎采用了完全一致的方式,方法表的结构如同字段表一样,依次包括了访问标志(access_flags)、名称索引(name_index)、描述符索引(descriptor_index)、属性表集合(attributes)几项

属性表集合

属性表(attribute_info)在前面的讲解之中已经出现过数次,在Class文件、字段表、方法表都可以携带自己的属性表集合,以用于描述某些场景专有的信息.

字节码指令

Java虚拟机的指令由一个字节长度的、代表着某种特定操作含义的数字(称为操作码,Opcode)以及跟随其后的零至多个代表此操作所需参数(称为操作数,Operands)而构成.

缺点: 由于限制了Java虚拟机操作码的长度为一个字节(即0~255),这意味着指令集的操作码总数不可能超过256条

优点: 用一个字节来代表操作码,也是为了尽可能获得短小精干的编译代码.

虚拟机类加载机制

在Java语言里面,类型的加载、连接和初始化过程都是在程序运行期间完成的,这种策略虽然会令类加载时稍微增加一些性能开销,但是会为Java应用程序提供高度的灵活性,Java里天生可以动态扩展的语言特性就是依赖运行期动态加载和动态连接这个特点实现的.

类加载的时机

类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括 :
加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)7个阶段.

其中验证、准备、解析3个部分统称为连接(Linking)

类加载流程

类加载时机:由虚拟机实现决定.

类初始化的时机
  1. 使用new关键字实例化对象的时候、读取或设置一个类的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)的时候,以及调用一个类的静态方法的时候.

  2. 使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化.

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

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

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

类加载的过程

Java虚拟机中类加载的全过程,也就是加载、验证、准备、解析和初始化这5个阶段所执行的具体动作

加载

在加载阶段(加载阶段是类加载的一个阶段),虚拟机需要完成以下3件事情 :

  1. 通过一个类的全限定名来获取定义此类的二进制字节流.

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

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

加载流程

数组类本身不通过类加载器创建,它是由Java虚拟机直接创建的.但数组类的元素类型(Element Type,指的是数组去掉所有维度的类型)最终是要靠类加载器去创建.

非数组类的加载,既可以使用系统提供的引导类加载器来完成,也可以由用户自定义的类加载器去完成.

加载阶段完成后,虚拟机外部的二进制字节流就按照虚拟机所需的格式存储在方法区之中.

然后在内存中实例化一个java.lang.Class类的对象(并没有明确规定是在Java堆中,HotSpot的Class对象存放在方法区里面),这个对象将作为程序访问方法区中的这些类型数据的外部接口.

加载过程中获取Class二进制流

Java虚拟机规范没有指明二进制字节流要从一个Class文件中获取,准确地说是根本没有指明要从哪里获取、怎样获取.

从ZIP包中读取,这很常见,最终成为日后JAR、EAR、WAR格式的基础.

从网络中获取,这种场景最典型的应用就是Applet.

运行时计算生成,这种场景使用得最多的就是动态代理技术,在java.lang.reflect.Proxy中,就是用了ProxyGenerator.generateProxyClass来为特定接口生成形式为"*$Proxy"的代理类的二进制字节流.

由其他文件生成,典型场景是JSP应用,即由JSP文件生成对应的Class类.

验证

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

验证阶段大致上会完成下面4个阶段的检验动作 : 文件格式验证、元数据验证、字节码验证、符号引用验证.

  1. 文件格式验证
    验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理.

  2. 元数据验证
    对字节码描述的信息进行语义分析,以保证其描述的信息符合Java语言规范的要求

  3. 字节码验证

  4. 符号引用验证

准备

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

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

这里所说的初始值"通常情况"下是数据类型的零值,
如 public static int value=123; 那变量value在准备阶段过后的初始值为0而不是123,
因为这时候尚未开始执行任何Java方法,而把value赋值为123的putstatic指令是程序被编译后,存放于类构造器<clinit>()方法之中,所以把value赋值为123的动作将在初始化阶段才会执行

若是 public static final int value = 123; 则此时已经初始化为123

解析

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

符号引用(Symbolic References) : 符号引用以一组符号来描述所引用的目标

直接引用(Direct References) : 直接引用可以是直接指向目标的指针 等

初始化

到了初始化阶段,才真正开始执行类中定义的Java程序代码(或者说是字节码)

在准备阶段,变量已经赋过一次系统要求的初始值,而在初始化阶段,则根据程序员通过程序制定的主观计划去初始化类变量和其他资源

或者可以从另外一个角度来表达 : 初始化阶段是执行类构造器<clinit>()方法的过程

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

<clinit>()方法与类的构造函数(或者说实例构造器<init>()方法)不同,它不需要显式地调用父类构造器,虚拟机会保证在子类的<clinit>()方法执行之前,父类的<clinit>()方法已经执行完毕.因此在虚拟机中第一个被执行的<clinit>()方法的类肯定是java.lang.Object

由于父类的<clinit>()方法先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作

<clinit>()方法对于类或接口来说并不是必需的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成<clinit>()方法

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

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

其他线程虽然会被阻塞,但如果执行<clinit>()方法的那条线程退出<clinit>()方法后,其他线程唤醒之后不会再次进入<clinit>()方法.同一个类加载器下,一个类型只会初始化一次

类加载器

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

比较两个类是否"相等",只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个Class文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等.

这里所指的"相等",包括代表类的Class对象的equals()方法、isAssignableFrom()方法、isInstance()方法的返回结果,也包括使用instanceof关键字做对象所属关系判定等情况

双亲委派模型

从Java虚拟机的角度来讲,只存在两种不同的类加载器 :
一种是启动类加载器(Bootstrap ClassLoader),这个类加载器使用C++语言实现,是虚拟机自身的一部分;

另一种就是所有其他的类加载器,这些类加载器都由Java语言实现,独立于虚拟机外部,并且全都继承自抽象类java.lang.ClassLoader.

类加载器

应用程序类加载器(Application ClassLoader),它负责加载用户类路径(ClassPath)上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器.

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

双亲委派模型的破坏
  1. JNDI现在已经是Java的标准服务,
    它的代码由启动类加载器去加载(在JDK 1.3时放进去的rt.jar),
    JNDI的目的就是对资源进行集中管理和查找,它需要调用由独立厂商实现并部署在应用程序的ClassPath下的JNDI接口提供者(SPI,Service Provider Interface)的代码,但启动类加载器不可能"认识"这些代码啊!那该怎么办?

为了解决这个问题,Java设计团队只好引入了一个不太优雅的设计 : 线程上下文类加载器(Thread Context ClassLoader).这个类加载器可以通过java.lang.Thread类的setContextClassLoaser()方法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是应用程序类加载器.

有了线程上下文类加载器,就可以做一些"舞弊"的事情了,JNDI服务使用这个线程上下文类加载器去加载所需要的SPI代码,也就是父类加载器请求子类加载器去完成类加载的动作,这种行为实际上就是打通了双亲委派模型的层次结构来逆向使用类加载器,实际上已经违背了双亲委派模型的一般性原则,但这也是无可奈何的事情.Java中所有涉及SPI的加载动作基本上都采用这种方式,例如JNDI、JDBC、JCE、JAXB和JBI等.

  1. OSGi
    OSGi实现模块化热部署的关键则是它自定义的类加载器机制的实现.每一个程序模块(OSGi中称为Bundle)都有一个自己的类加载器,当需要更换一个Bundle时,就把Bundle连同类加载器一起换掉以实现代码的热替换.

在OSGi环境下,类加载器不再是双亲委派模型中的树状结构,而是进一步发展为更加复杂的网状结构.

虚拟机字节码执行引擎

运行时栈帧结构

栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构.

栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息.

每一个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程.

执行引擎运行的所有字节码指令都只针对当前栈帧进行操作.

局部变量表

局部变量表(Local Variable Table)是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量.

操作数栈

当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈/入栈操作.

例如,在做算术运算的时候是通过操作数栈来进行的,又或者在调用其他方法的时候是通过操作数栈来进行参数传递的.

动态连接

每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking).

Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用作为参数.
这些符号引用一部分会在类加载阶段或者第一次使用的时候就转化为直接引用,这种转化称为静态解析.
另外一部分将在每一次运行期间转化为直接引用,这部分称为动态连接.

方法返回地址

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

另外一种退出方式是,在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,无论是Java虚拟机内部产生的异常,还是代码中使用athrow字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方法的方式称为异常完成出口(Abrupt Method Invocation Completion).一个方法使用异常完成出口的方式退出,是不会给它的上层调用者产生任何返回值的.

无论采用何种退出方式,在方法退出之后,都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层方法的执行状态.

一般来说,方法正常退出时,调用者的PC计数器的值可以作为返回地址,栈帧中很可能会保存这个计数器值.而方法异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中一般不会保存这部分信息.

方法退出的过程实际上就等同于把当前栈帧出栈,因此退出时可能执行的操作有 : 恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令等.

程序编译与代码优化

早期(编译期)优化

前端编译器 把*.java文件转变成*.class文件的过程(javac)

虚拟机的后端运行期编译器(JIT编译器,Just In Time Compiler)把字节码转变成机器码的过程

静态提前编译器(AOT编译器,Ahead Of Time Compiler)直接把*.java文件编译成本地机器代码的过程

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

FlyingZCC

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值