2.48 读书<深入理解Java虚拟机3>深入理解Java虚拟机3>
自动内存管理
第2章 Java内存区域与内存溢出异常
2.2 运行时数据区域
- Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域
2.2.1 程序计数器
- 程序计数器(Program Counter Register)是当前线程所执行的字节码的行号指示器
- 字节码解释器工作时通过改变计数器的值来选取下一条需要执行的字节码指令, 它是程序控制流的指示器, 分支、循环、跳转、异常处理、线程恢复等基础功能都依赖PC来完成
- 由于Java虚拟机的多线程是通过线程轮流切换、分配处理器执行时间的方式来实现的, 在任何一个确定的时刻, 一个处理器(多核处理器来说是一个内核)都只会执行一条线程中的指令
- 这类内存区域为线程私有的内存
- 如果线程执行Java方法, 计数器记录的是正在执行的虚拟机字节码指令的地址; 如果执行的本地(Native)方法, 计数器值则为空(Undefined)。此内存区域是唯一在«Java虚拟机规范»中没有固定任何
OutOfMemoryError
情况的区域2.2.2 Java虚拟机栈
- Java虚拟机栈(JVM Stack)是线程私有的, 它的生命周期与线程相同, 虚拟机栈描述的是Java方法执行的线程内存模型: 每个方法被执行时, Java虚拟机都会同步创建栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息; 每个方法被调用直到执行完毕的过程, 就对应一个栈帧在虚拟机栈从入栈到出栈的过程
- 局部变量表存放了编译器可知的各种Java虚拟机基本数据类型(
boolean
、byte
等)、对象引用(reference
类型)和returnAddress
类型(指向一条字节码指令的地址) - 该内存区域规定了两类异常状况: 如果线程请求的栈深度大于虚拟机所允许的深度, 将抛出
StackOverflowError
异常; 如果Java虚拟机栈容量可以动态扩展, 当栈扩展时无法申请到足够的内存会抛出OutOfMemoryError
异常2.2.3 本地方法栈
- 本地方法栈为虚拟机使用到的本地(Native)方法服务
- 栈深度溢出或栈扩展失败时分别抛出
StackOverflowError
和OutOfMemoryError
异常2.2.4 Java堆
- Java堆是被所有线程共享的一块内存区域, 在虚拟机启动时创建; «Java虚拟机规范»中对Java堆的描述: 所有对象实例以及数组都应在堆上分配; 由于即时编译技术的进步, 尤其逃逸分析技术的日渐强大, 栈上分配、标量替换优化手段已导致悄然变化, Java对象实例都分配在堆上已不是那么绝对
- Java堆是垃圾收集器管理的内存区域; 由于现代垃圾收集器大部分都是基于分代收集理论设计的, 所以Java堆常出现”新生代”,”老年代”,”永久代”,”Eden空间”,”From Survivor空间”,”To Survivor空间”
- 如果从分配内存的角度看, 所有线程共享的Java堆中可以划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer, TLAB), 以提升对象分配时的效率
- 根据«Java虚拟机规范», Java堆可以处于物理不连续的内存空间中, 但逻辑视为连续的。但对于大对象(典型的如数组对象), 多数虚拟机实现处于实现简单、存储高效的考虑, 很可能会要求连续的内存空间
- Java堆即可被实现为固定大小, 也能可扩展。不过当前主流的Java虚拟机都按照可扩展来实现的。如果在Java堆中没有内存完成实例分配, 并且堆也无法再扩展, Java虚拟机将抛出
OutOfMemoryError
异常2.2.5 方法区
- 方法去是线程共享的内存区域, 用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据
- 该区域的内存回收目标主要是针对常量池的回收和对类型的卸载
- 根据«Java虚拟机规范»的规定, 如果方法区无法满足新的内存分配需求时, 将抛出
OutOfMemoryError
异常2.2.6 运行时常量池
- 运行时常量池(Runtime Constant Pool)是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有常量池表(Constant Pool Table), 用于存放编译期生成的各种字面量与符号引用
- 除了保存Class文件中描述的符号引用外, 还会把由符号引用翻译出来的直接引用也存储在运行时常量池中
- 当常量池无法再申请到内存时会抛出
OutOfMemoryError
异常2.2.7 直接内存
- JDK1.4中新加入NIO(New Input/Output)类, 引入了基于通道(Channel)与缓冲区(Buffer)的I/O方式, 它可以使用Native函数库直接分配堆外内存, 通过存储在Java堆的
DirectByteBuffer
对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能, 因为避免了在Java堆和Native堆中来回复制数据 - 本机直接内存的分配不会受到Java堆大小的限制, 但经常忽略掉直接内存, 使得各个内存区域总和大于物理内存限制, 从而导致动态扩展时出现
OutOfMemoryError
异常2.3.1 对象得创建
- 当Java虚拟机遇到一条字节码
new
指令时, 首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用, 并检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有, 那必须先执行相应的类加载过程 - 在类加载检查通过后, 接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定
- 为对象分配空间的任务实际上便等同于把一块确定大小的内存从Java堆中划分出来。假设Java堆中内存时绝对规整的, 所有被使用过的内存都被放一边, 空闲的内存被放另一边, 中间放着一个指针作为分界点的指示器, 那所分配内存就仅仅是把指针向空闲空间方向挪动一段与对象大小相等的距离, 这种分配方式称为指针碰撞(Bump The Pointer)
- 对象创建在虚拟机中非常频繁的行为, 即使仅仅修改一个指针所指向的位置, 在并发情况下也并不是线程安全的。可能出现正在给对象A分配内存, 指针还没来得及修改, 对象B又同时使用了原来指针来分配内存的情况, 可选解决方案:
- 一种是对分配内存空间的动作进行同步处理-实际上虚拟机是采用CAS配上失败重试的方式保证更新操作的原子性;
- 另一种是把内存分配的动作按照线程划分在不同的空间进行,即每个线程在Java堆中预先分配一小块内存, 称为本地线程分配缓冲(Thread Local Allocation Buffer, TLAB)
- 内存分配完成后, 虚拟机将分配到的内存空间(但不包括对象头)都初始化为零值
- Java虚拟机还要对对象进行必要的设置, 例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息
- 一般来说,
new
指令之后会接着执行<init>()
方法2.3.2 对象的内存布局
- 在HotSpot虚拟机里, 对象在堆内存中的存储布局可以划分为三个部分: 对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)
- HotSpot虚拟机对象的对象头部分包括两类信息。第一类是用于存储对象自身的运行时数据, 如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳, 即Mark Word
- 对象头的另一部分是类型指针, 即对象指向它的类型元数据的指针, Java虚拟机通过这个指针来确定该对象是哪个类的实例。
- 接下来实例数据部分是对象真正存储的有效信息, 即我们在程序代码里所定义的各种类型的字段内容, 无论是从傅雷继承下来, 还是在子类中定义的字段都必须记录起来
- 对象的第三部分是对象填充, 并不是必然存在, 仅起着占位符的作用
2.3.3 对象的访问定位
- Java程序会通过栈上的
reference
数据来操作堆上的具体对象。由于reference
类型在«Java虚拟机规范»里面只规定了它是一个指向对象的引用,并没有定义这个引用应该通过什么方式去定位、访问到堆中对象的具体位置, 主流的访问方式主要有使用句柄和直接引用:- 如果使用句柄访问的话, Java堆中将可能会划分出一块内存来作为句柄池, reference中存储的就是对象的句柄地址, 而句柄中包含了对象实例数据与类型数据各自具体的地址信息
- 如果使用直接指针访问的话, Java堆中对象的内存布局就必须考虑如何放置访问类型数据的相关信息, reference中存储的直接就是对象地址, 如果只是访问对象本身的话, 就不需要多一次间接访问的开销
- 使用句柄来访问的最大好处是reference中存储的是稳定句柄地址, 在对象被移动时只改变句柄中的实例数据指针, 而reference本身不需要被修改
- 使用直接指针来访问最大的好处就是速度更快, 虚拟机HotSpat主要使用直接引用方式进行对象访问
2.4.1 Java堆溢出
- 要解决Java堆内存区域的异常, 常规的处理方法是首先通过内存映像分析工具对Dump出来的堆转储快照进行分析。先确认内存中导致OOM的对象是否有必要, 也就是先分清楚到底是出现内存泄露(Memory Leak)还是内存溢出(Memory Overflow)
- 如果不是内存泄露, 换句话说就是内存中的对象确实都是必须存活的, 那就应当检查Java虚拟机的堆参数设置, 与机器的内存对比, 看看是否还有往上调整的空间。再从代码上检查是否存在某些对象生命周期过长、持有状态时间过长、存储解构设计不合理等情况, 尽量减少程序运行期的内存消耗
2.4.2 虚拟机栈和本地方法栈溢出
- 关于虚拟机栈和本地方法栈, 在«Java虚拟机规范»中描述了两种异常
1). 如果线程请求的栈深度大于虚拟机所允许的最大深度, 将抛出
StackOverflowError
异常 2). 如果虚拟机的栈内存允许动态扩展, 当扩展容量无法申请到足够的内存时, 将抛出OutOfMemoryError
异常 - «Java虚拟机规范»明确允许Java虚拟机实现自行选择是否支持栈的动态扩展, 而HotSpot虚拟机的选择是不支持扩展, 所以除非在创建线程申请内存时就因无法获得足够内存而出现
OutOfMemoryError
异常, 否则在线程运行时是不会因为扩展而导致内存溢出的, 只会因为栈容量无法容纳新的栈帧而导致StackOverflowError
异常 - 无论是由于栈帧太大还是虚拟机栈容量太小, 当新的栈帧内存无法分配的时候, HotSpot虚拟机抛出的都是
StackOverflowError
异常2.4.3 方法区和运行时常量池溢出
- 运行时常量池溢出时, 在
OutOfMemoryError
异常后面跟随的提示信息是”PermGen space”, 说明运行时常量池得确是属于方法区(即JDK6的HotSpot虚拟机中的永久代) - 方法区溢出也是一种常见的内存溢出异常, 一个类如果要被垃圾收集器回收, 要达成的条件是比较苛刻的
2.4.4 本机直接内存溢出
DirectByteBuffer
类直接通过反射获得Unsafe
实例进行内存分配(Unsafe
类的getUnsafe()
方法指定只有引导类加载器才会返回实例)第3章-垃圾收集器与内存分配策略
3.1 概述
- 程序计数器、虚拟机栈、本地方法区3个区域随着线程而生, 随线程而灭, 栈中的栈帧随着方法的进入和退出而有条不絮地执行着出栈和入栈操作。每一个栈帧中分配多少内存基本上是在类解构确定下来时就已知的
3.2.1 引用计数算法
- 在对象中添加一个引用计数器, 每当有一个地方引用它时, 计数器就加一; 当引用失效时, 计数器就减一。缺点: 单纯引用计数很难解决对象之间互相循环引用的问题
3.2.2 可达性分析算法
- 通过一系列”GC Roots”的根对象作为起始节点集, 从这些节点开始, 根据引用关系向下搜索, 搜索过程所走过的路径称为引用链(Reference Chain); 用图论的话来说从GC Roots到这个对象不可达时, 则证明此对象不可能再被使用
- Java技术体系里, 固定可作为GC Roots的对象
- 在虚拟机栈(栈帧中的本地变量表)中引用的对象, 譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量
- 在方法区中类静态属性引用的对象, 譬如Java类的引用类型静态变量
- 在方法区中常量引用的对象, 譬如字符串常量池(String Table)里的引用
- 在本地方法栈中JNI引用的对象
- Java虚拟机内部的引用, 如基本数据类型对应的Class对象、一些常驻的异常对象(比如
NullPointException
)、系统类加载器 - 所有同步锁(
synchronized
关键字)持有的对象 - 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等
3.2.3 再谈引用
- JDK1.2后, Java将引用分为强引用(Strongly Reference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)
- 强引用指在程序之中普遍存在的引用赋值, 即类似
Object obj = new Object()
, 无论任何情况下, 只要强引用关系还存在, 垃圾收集器就永远不会回收掉被引用的对象 - 软引用是用来描述一些还有用, 但非必须的对象。只被软引用关联着的对象, 在系统将要发生内存溢出异常前, 会把这些对象列进回收范围之中进行第二次回收, 如果这次回收还没有足够的内存, 才会抛出内存溢出异常
- 弱引用也是用来描述那些非必须对象, 但它的强度比弱引用更弱一些, 被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作, 无论当前内存是否足够, 都会回收掉只被弱引用关联的对象
- 虚引用是最弱的一种引用关系, 唯一的目的是为了能在对象被收集器回收时收到一个系统通知
3.2.4 生存还是死亡?
- 强引用指在程序之中普遍存在的引用赋值, 即类似
- 如果对象在进行可达性分析后发现没有与GCRoot相链接的引用链, 那它将会被第一次标记, 随后进行一次筛选, 筛选的条件是此对象是否有必要执行
finalize()
方法。假如对象没有覆盖finalize()
方法, 或者finalize()
方法已经被虚拟机调用过,那么虚拟机将视为”没必要执行” - 如果这个对象被判定为确有必要执行
finalize()
方法, 那么该对象将会被放置在F-Queue
的队列之中, 稍后由一条虚拟机自动建立的、低调度优先级的Finalizer
线程区执行它们的finalize()
方法,finalize()
方法是对象逃脱死亡命运的最后一次机会, 稍后收集器对F-Queue
中的对象进行第二次小规模的标记, 如果对象要在finalize()
中拯救自己-只要重新与引用链上的任何一个对象建立关联即可3.2.5 回收方法区
- 方法区的垃圾收集主要回收两部分内容: 废弃的常量和不再使用的类型
- 没有任何字符串对象引用常量池中的”java”常量, 且虚拟机中也没有其他地方引用这个字面量。如果在这时发生内存回收, 而且垃圾收集器判断确有必要的话, 这个”java”常量就将会被系统清理出常量池
- 判断类型是否属于”不再被使用的类”, 需要同时满足下面三个条件
- 该类所以的实例都已被回收, 也就是Java堆中不存在该类及其派生子类实例
- 加载该类的类加载器已经被回收, 这个条件除非是经过静心设计的可替换类加载器的场景, 如OSGi, JSP的重加载等, 否则通常很难达成
- 该类对应的java.lang.Class对象没有在任何地方被引用, 无法在任何地方通过反射访问该类的方法
3.3.1 分代收集理论
- 多款常用收集器的设计原则: 收集器应该将Java堆划分出不同的区域, 然后将回收对象依据其年龄分配到不同的区域之中存储
- 在Java堆划分出不同的区域之后, 垃圾收集器才可以每次只回收其中某一个或者某些部分的区域-因而才有了”Minor GC”、”Major GC”、”Full GC”这样的回收类型的划分; 也才能针对不同的区域安排与里面存储对象存亡特征相匹配的垃圾收集算法-因而发展出了”标记-赋值算法”、”标记-清除算法”、”标记-整理算法”等针对性的垃圾收集算法
- 跨代引用假说: 跨代引用相对于同代引用来说仅占少数
- 依据跨代引用假设, 不应为了少量跨代引用区扫描挣个老年代, 只需要在新生代建立一个全局的数据解构(Remembered Set), 这个解构把老年代划分成若干小块, 标识出老年代的哪一块内存会存在跨代引用。此后发生Minor GC时, 只有包含了跨代引用的小块内存里的对象才会被加入到GC Roots进行扫描
- 部分收集(Partial GC): 指目标不是完整收集整个Java堆的垃圾收集, 其中又分为:
- 新生代收集(Minor GC/Young GC): 指目标只是新生代的垃圾收集
- 老年代收集(Major GC/Old GC): 指目标只是老年带的垃圾收集。目前只有CMS收集器会单独收集老年代的行为
- 混合收集(Mixed GC): 指目标是收集整个新生代以及部分老年代的垃圾收集。目前只有G1收集器会有这种行为
- 整堆收集(Full GC): 收集挣个Java堆的方法区的垃圾收集
3.3.2 标记-清除算法
- 标记-清除(Mark-Sweep)算法分为标记和消除两个阶段: 首先标记出所有需要回收的对象, 在标记完成后, 统一回收掉所有被标记的对象
- 主要缺点: 1). 执行效率不稳定, 如果Java堆中大量对象, 而且其中大部分是需要回收的, 这时必须进行大量标记和清除的动作, 导致标记和清除两个过程的执行效率都随对象数量的增长而降低; 第二个是内存空间的碎片化问题, 标记、清除之后会产生大量不连续的内存碎片, 空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够连续内存而不得不提前触发另一次垃圾收集动作
3.3.3 标记-复制算法
- 为了解决标记-清除算法面对大量可回收对象时执行效率低的问题, 它将内存按容量划分为大小相同的两块, 每次只使用其中的一块。当这一块的内存用完了, 就将还存活的对象复制到另一块上, 然后再把已使用过的内存空间一次性清理掉。
- 优点: 实现简单, 运行高效; 缺点: 将可用内存缩小为原来的一半, 空间浪费多
- HotSpot虚拟机的Serial、ParNew等新生代收集器均采用”Appel式回收”策略来设计新生代的内存布局, Appel式回收的具体做法是把新生代分为一块较大的Eden空间和两块较小的Survivor空间, 每次分配内存只使用Eden和其中一块Survivor。发生垃圾收集时, 将Eden和Survivor中仍然存活的对象一次性复制到另一块Survivor空间上, 然后直接清理Eden和已用过的那块Survivor空间
- 内存分配担保: 如果另外一块Survivor空间没有足够空间存放上一次新生代收集下来的存活对象, 这些对象便将通过分配担保机制直接进入老年代
3.3.4 标记-整理算法
- 标记-复制算法在对象存活率较高时就要进行较多的复制操作, 效率将会降低。首先标记出所有需要回收的对象, 在标记完成后, 让所有存活的对象都向内存空间一端移动, 然后直接清理掉边界以外的内存
- 对象移动操作必须全程暂停用户应用程序才能进行
- 是否移动对象都存在弊端, 移动则内存回收时更复杂, 不移动则内存分配时会更复杂, 从垃圾收集的停顿时间来看, 不移动对象停顿时间会更短, 甚至可以不需要停顿, 但是从整个程序的吞吐量来看, 移动对象会更划算。此语境中, 吞吐量的实质是赋值器与收集器的效率总和。即不移动对象会使得收集器的效率提升一些, 但因内存分配和访问相比垃圾收集频率要高得多, 这部分的耗时增加, 总吞吐量仍然时下降的
3.4 HotSpot的算法细节实现
3.4.1 根节点枚举
- 所有收集器在根节点枚举这一步骤时都是必须暂停用户线程的, 现在可达性分析算法耗时最长的查找引用链的过程已经可以做到与用户线程一起并发,但根节点枚举始终还是必须在一个能保障一致性的快照中才得以进行, 这里”一致性”的意思是整个枚举期间执行子系统看起来就像被冻结在某个时间点上, 不会出现分析过程中, 根节点集合的对象引用关系还在不断变化的情况, 否则分析结果准确性也就无法保证
- 目前主流Java虚拟机使用的都是准确式垃圾收集, 所以当用户线程停顿下来, 起始并不需要一个不漏地检查完所有执行上下文和全局的引用位置, 虚拟机应当是有办法直接得到哪些地方存放着对象引用的。在HotSpot的解决方案里, 是使用一组称为
OopMap
的数据结构来达到这个目的, 在HotSpot就会把对象内什么偏移量上是什么类型的数据计算出来, 在即时编译过程中, 也就会在特定的位置记录下栈里和寄存器里哪些位置是引用。这样收集器在扫描时就可以直接得知这些信息,并不需要真正一个不漏地从方法区GCRoots开始查找3.4.2 安全点
- 在
OopMap
的协助下, HotSpot可以快速准确完成GC Roots枚举, 但问题是可能导致引用关系变化, 或者说导致OopMap
内容变化的指令非常多, 如果为每一条指令都生成对应的OopMap
, 那将会需要大量的额外存储空间 - HotSpot的确没有为每条指令都生成
OopMap
, 只是在特定的位置记录了这些信息, 这些位置称为安全点(Safepoint)。有了安全点的设定, 也就决定了用户程序执行时并非在代码指令流的任意位置都能够停顿下来开始垃圾收集, 而是强制要求必须执行到达安全点后才能够暂停。安全点的选取是以”是否具有让程序长时间执行的特征”为标准进行选定的, “长时间执行”的最明显特征就是指令序列的复用, 例如方法调用、循环跳转、异常跳转等都属于指令序列复用, 所以只有具有这些功能的指令才会产生安全点 - 如果在垃圾收集发生时让所有线程都跑到最近的安全点, 然后停顿下来
- 抢先式中断(Preemptive Suspension)和主动式中断(Voluntary Suspension), 抢先式中断不需要线程的执行代码主动去配合, 在垃圾收集发生时, 系统首先把所有用户线程全部中断, 如果发现有用户线程中断的地方不在安全点上, 就恢复这条线程执行, 让它一会再重新中断, 直到跑到安全点上。而主动式中断的思想是当垃圾收集需要中断线程的时候, 不直接堆线程操作, 仅仅设置一个标志, 线程执行过程会不断主动轮训该标志, 一旦发现中断标记为
true
就在最近的安全点上主动中断挂起3.4.3 安全区域
- 抢先式中断(Preemptive Suspension)和主动式中断(Voluntary Suspension), 抢先式中断不需要线程的执行代码主动去配合, 在垃圾收集发生时, 系统首先把所有用户线程全部中断, 如果发现有用户线程中断的地方不在安全点上, 就恢复这条线程执行, 让它一会再重新中断, 直到跑到安全点上。而主动式中断的思想是当垃圾收集需要中断线程的时候, 不直接堆线程操作, 仅仅设置一个标志, 线程执行过程会不断主动轮训该标志, 一旦发现中断标记为
- 安全点机制保证了程序执行时, 在不太长的时间内就会遇到可进入垃圾收集过程的安全点, 但程序不执行的时候呢? 程序不执行就是没有分配处理器时间, 典型的场景是用户线程处于
Sleep
状态或Blocked
状态, 这时线程无法响应虚拟机的中断请求, 不能再走到安全的地方去中断挂起自己, 对于这种情况, 必须引入安全区域(Safe Region)来解决 - 安全区域指能够确保在某一段代码片段中, 引用关系不会发生变化, 当用户线程执行到安全区域里边的代码, 首先会标识自己已经进入了安全区域, 当这段时间里虚拟机要发起垃圾收集时就不必去管这些已声明自己在安全区域内的线程了。当线程要离开安全区域时 它要检查虚拟机是否已完成根枚举, 如果完成那线程就当作没事发生继续执行, 否则将必须一直等待, 直到收到可以离开安全区域的信号为止
3.4.4 记忆集与卡表
- 记忆集(Remembered Set)用以避免把整个老年代加进GC Roots扫描范围。记忆集用于记录从非收集区域指向收集区域的指针集合的抽象数据解构
- 在垃圾收集发生时, 只要筛选出卡表中变脏元素, 就能轻易得出哪些卡页内存块中包含跨代指针, 把它们加入GC Roots中一并扫描
3.4.5 写屏障
- 在HotSpot虚拟机里通过写屏障(Write Barrier)技术维护卡表状态的。先请读者注意这里”写屏障”以及后面低延迟收集器中提到的”读屏障”与解决并发乱序执行问题中的”内存屏障”区分开。写屏障可以看做在虚拟机层面对”引用类型字段赋值”这个动作的AOP切面, 在引用对象赋值时会产生一个环形(Around)通知, 供程序执行额外的动作, 也就是说赋值的前后都在写屏障的覆盖范畴内。
- 除了写屏障的开销外, 卡表在高并发场景下还面临着伪共享问题。现代中央处理器的缓存系统中是以缓存行(Cache Line)为单位存储的, 当多线程修改互相独立的变量时, 如果这些变量恰好共享同一个缓存行, 就会彼此影响而导致性能降低, 着就是伪共享问题
3.4.6 并发的可达性分析
- 标记阶段所有追总是垃圾收集算法的功能特征, 如果这个阶段会随着堆变大而等比例增加停顿时间, 其影响就会波及几乎所有的垃圾收集器
- 三色标记作为工具辅助推导
- 白色: 表示对象尚未被垃圾收集器访问过。显然在可达性分析刚开始的阶段所有对象都是白色的, 若在分析结束的阶段仍然时白色的对象, 即代表不可达
- 黑色: 表示对象已经被垃圾收集器访问过, 且这个对象的所有引用已经扫描过。黑色的对象代表已经扫描过, 它是安全存活的, 如果有其它对象引用指向了黑色对象, 无须重新扫描一遍。黑色对象不可能直接指向某个白色对象。
- 灰色: 表示对象已经被垃圾收集器访问过, 但这个对象上至少存在一个引用还没有被扫描过
- 当且仅当以下两个条件同时满足时, 会产生”对象消失”的问题, 即原来应该时黑色的对象被误标为白色
- 赋值器插入了一条或多条从黑色对象到白色对象的新引用;
- 赋值器删除了全部从灰色对象到白色对象的直接或间接引用
- 解决对象消失问题, 只需要破坏着两个条件的任意一个即可。由此分别产生了两种解决方案: 增量更新(Incremental Update)和原始快照(Snapshot At The Beginning, STAB)
- 增量更新要破坏第一个条件, 当黑色对象插入新的指向白色对象的引用关系时, 就将这个新插入的引用记录下来, 等并发扫描结束之后, 将这些记录过的引用关系中黑色对象为根, 重新扫描一次
- 原始快照要破坏的是第二个条件, 当灰色对象要删除指向白色对象的引用关系时, 就将这个要删除的引用记录下来, 在并发扫描结束之后, 再将这些记录过的引用关系中的灰色对象为根, 重新扫描一次
- CMS是基于增量更新来做并发标记的, G1、Shenandoah则是用原始快照来实现
3.5.1 Serial 收集器
- Serial收集器是单线程工作的收集器, 这里单线程强调它进行垃圾收集时, 必须暂停其它所有工作线程, 直到它收集结束
- 优点:
- 简单高效, 对于内存资源受限的环境, 它是所有收集器里额外内存消耗最小的; 对于单核处理器或处理器核心数较少的环境来说, Serial收集器由于没有线程交互的开销,专心做垃圾收集自然获得最高的单线程收集效率
3.5.2 ParNew 收集器
- ParNew 收集器实际上是Serial收集器的多线程并行版本
- JDK 7之前首选新生代收集器, 除了Serial收集器外, 目前只有它能与CMS收集器配合工作
-
该收集器在通过超线程(Hyper-Threading)技术实现的伪双核处理器环境中都不能百分之百超越Serial收集器
- 并行与并发概念, 在谈论垃圾收集器的上下文语境中
- 并行(Parallel): 并行描述的是多条垃圾收集器线程之间的关系, 说明同一时间有多条这样的线程在协同工作
- 并发(Concurrent): 并发描述的是垃圾收集器线程与铜壶线程之间的关系, 说明同一时间垃圾收集器线程在用户线程都在运行
3.5.3 Parallel Scavenge收集器
- Parallel Scavenge收集器也是新生代收集器, 同样基于标记-复制算法实现的
- 它目标是达到可控制的吞吐量(Throughput), 所谓吞吐量就是处理器用于运行用户代码的时间和处理器总消耗时间的比值
3.5.4 Serial Old 收集器
- Serial收集器的老年代版本, 也是单线程收集器, 使用标记-整理算法
3.5.5 Parallel Old收集器
- Parallel Old是Parallel Scavenge收集器的老年代版本, 支持多线程并发收集, 基于标记-整理算法实现
3.5.6 CMS收集器
- CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器
- CMS收集器是基于标记-清除算法实现的, 整个过程分为四个步骤
- 初始标记(CMS initial mark)
- 并发标记(CMS concurrent mark)
- 重新标记(CMS remark)
- 并发清除(CMS concurrent sweep)
- 初始标记、重新标记仍需要”Stop the World”, 初始标记仅标记GC Roots能直接关联到的对象; 并发标记阶段就是从GC Roots的直接关联对象开始遍历整个对象图的过程, 这个过程耗时较长但不需要停顿用户线程; 重新标记阶段则是为了修正并发标记期间, 因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录, 这个阶段的停顿时间会比初始化标记阶段稍长, 但远比并发标记时间短, 最后是并发清除, 清理删除掉标记阶段判断已死亡的对象, 由于不需要移动存活对象, 这个阶段也是可以与用户线程同时并发的
- CMS收集器无法处理”浮动垃圾”(Floating Garbage), 有可能出现”Concurrent Mode Failure”失败进而导致另一次完全”Stop the World”的Full GC的产生。在CMS的并发标记和并发清理阶段, 用户线程还是继续运行的, 程序在运行自然就会有新的垃圾对象产生, 但这部分垃圾对象是出现在标记过程结束以后, CMS 无法在档次手机中处理掉它们, 只好留待下一次垃圾收集时再清理掉, 这一部分垃圾称为浮动垃圾。要是CMS运行期间预留的内存无法满足程序分配新对象的需要, 就会出现一次并发失败(Concurrent Mode Failure), 这时虚拟机不得不启动后备预案: 冻结用户线程的执行, 临时启动Serial Old收集器来重新进行老年代的垃圾收集, 这样停顿时间就很长了
- 收集结束会有大量空间碎片产生
3.5.7 Garbage First收集器
- JDK 9 G1宣告取代Parallel Scavenge加Parallel Old组合
- 停顿时间模型的收集器, 即能够支持制定在一个长度为M毫秒的时间片段内, 消耗在垃圾收集上的时间大概率不超过N毫秒的目标
- G1面向堆内存任何部分来组成回收集进行回收, 衡量标准不再是它属于哪个分代, 而是哪块内存中存放的垃圾数量最多, 回收收益最大, 这就是G1的Mixed GC模式
- G1开创的基于Region的堆内存布局是它能够实现这个目标的关键, 虽然G1仍是遵循分代收集理论设计的, 但其堆内存的布局与其他收集器有非常明显的差异: G1不再坚持固定大小以及固定数量的分代区域划分, 而是把连续的Java堆划分为多个大小相等的独立区域(Region), 每一个Region都可以根据需要, 扮演新生代的Eden空间, Survivor空间或老年代空间
- G1收集器之所以能建立可预测的停顿时间模型, 是因为它将Region作为单词回收的最小单元, 即每次收集到的内存空间都是Region大小的整数倍, 这样可以有计划地避免在整个Java堆中进行全区域的垃圾收集。更具体的处理思路是让G1收集器去跟踪各个Region里面的垃圾堆积的价值大小, 价值即回收所获得的空间大小以及回收所需要时间的经验值
- G1为每个Region设计两个名为TAMS(Top at Mark Start)指针, 把Region中的一部分空间划分出来用于并发回收过程中的新对象分配, 并发回收时新分配的对象地址都必须在这两个指针位置以上。
- G1 收集器运作分为以下四个步骤
- 初始化标记(Intial Marking): 仅标记GC Roots能直接关联的对象, 并修改TAMS指针的值, 让下一阶段用户线程并发运行时, 能正确地在可用的Region中分配新对象。这个阶段需要停顿线程, 但耗时短且借用Minor GC的时候同步完成
- 并发标记(Concurrent Marking): 从GC Root开始对堆中对象进行可达性分析, 递归扫描整个堆里的对象图, 找出要回收的对象, 这个阶段耗时较长, 但可与用户程序并发执行
- 最终标记(Final Marking): 对用户线程做另一个短暂的暂停, 用户处理并发阶段结束后仍遗留下来的最后那少量的SATB记录。
- 筛选回收(Live Data Counting and Evacuation): 负责更新Region的统计数据, 对各个Region的回收价值和成本进行排序, 根据用户所期望的停顿时间来制定回收计划, 可以自由选择任意多个Region构成回收集, 然后把决定回收的那一部分Region的存活对象复制到空的Region中, 再清理掉整个旧Region的全部空间, 这里的操作及存活对象的移动, 是必须暂停用户线程, 由多条收集器线程并行完成的
- G1从整体看是基于标记-整理算法实现的收集器, 从局部上看又是基于标记-复制算法实现
- G1的缺点
- G1无论为了垃圾收集产生的内存占用(Footprint)还是程序运行时的额外执行负载(Overload)都要比CMS高
- 就内存来说, 虽然G1和CMS都使用卡表来处理跨代指针, 但G1的卡表实现更为复杂
3.6 低延迟垃圾收集器
- 衡量垃圾收集器的三项重要指标: 内存占用(Footprint)、吞吐量(Throughput)、和延迟(Latency)
3.8.1 对象优先在Eden分配
- 大多数情况下, 对象在新生代Eden区中分配, 当Eden区没有足够空间进行分配时, 虚拟机将发起Minor GC
3.8.2 大对象直接进入老年代
- 大对象就是指需要大量连续内存空间的Java对象, 最经典的大对象便是那种很长的字符串, 或元素数量很庞大的数组
3.8.3 长期存活的对象将进入老年代
- HotSpot虚拟机中多数收集器都采用了分代收集来管理内存, 那内存回收时就必须能决策哪些存活对象应当放在新生代、哪些存活对象放在老年代。虚拟机给每个对象定义一个对象年龄(Age)计数器, 存储在对象投中, 对象通常在Eden区分配, 如果经过了第一次Minor GC后仍存活, 并且能被Survivor容纳的话, 该对象会被移动到Survivor空间中, 并将其对象年龄设为1岁。对象在Survivor区中每熬过一次Minor GC, 年龄就增加1岁, 当它的年龄增加到一定程度(默认为15), 就会被晋升到老年代中。
3.8.4 动态对象年龄判断
- 如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半, 年龄大于或等于该年龄的对象就可以直接进入老年代
3.8.5 空间分配担保
- Minor GC后, 最极端的情况就是内存回收后新生代中所有对象都存活, 需要老年代进行分配担保, 把Survivor无法容纳的对象直接进入老年代
虚拟机执行子系统
第6章 类文件结构
6.3
Class
类文件的结构 - 根据«Java虚拟机规范»的规定,
Class
文件格式采用类似C语言结构体的伪结构来存储数据, 这种伪结构中只有两种数据类型: “无符号数”和”表”。- 无符号数属于基本的数据类型, 以u1、u2、u4、u8来分别代表1个字节、2个字节、4个字节和8个字节的无符号数, 无符号数可以用来描述数字、索引引用、数量值或按照UTF-8编码构成字符串值
- 表是由多个无符号或其他表作为数据项构成的复合数据类型, 为了便于区分, 所有表的命名都习惯以
_info
结尾 | 类型 | 名称 | 数量 | | —- | —- | —- | | u4 | magic | 1 | | u2 | minor_version | 1 | | u2 | major_version | 1 | | u2 | constant_pool_count | 1 | | cp_info | constant_pool | constant_pool_count-1 | | u2 | access_flags | 1 | | u2 | this_class | 1 | | u2 | super_class | 1 | | u2 | interfaces_count | 1 | | u2 | interfaces | interfaces_count | | u2 | fields_count | 1 | | field_info | fields | fields_count | | u2 | methods_count | 1 | | method_info | methods | methods_count | | u2 | attributes_count | 1 | | attribute_info | attributes | attributes_count |
6.3.1 魔数与Class
文件版本
- 每个
Class
文件头4个字节称为魔数(Magic Number), 它的唯一作用是确认文件是否能被虚拟机接收的Class
文件, 即用于标识文件类型 - 第5和第6个字节是次版本号(Minor Version), 第7和第8个字节是主版本号(Major Version), 标识支持的JDK版本
6.3.2 常量池
- 由于常量池中常量的数量是不固定的, 所以在常量池的入口需要放置一项u2类型的数据, 代表常量池容量计数值(constant_pool_count)
- 常量池主要存放两大类常量: 字面量(Literal)和符号引用(Symbolic References) 。字面量比较接近Java语言常量的概念, 如文本字符串、被声明为
final
的常量值, 而符号引用则属于编译原理方面的概念, 主要包括下面几类常量:- 被模块导出或开放的包(Package)
- 类和接口的全限定名(Fully Qualified Name)
- 字段的名称和描述符(Descriptor)
- 方法的名称和描述符
- 方法句柄和方法类型(Method Handle, Method Type、Invoke Dynamic)
- 动态调用点和动态常量(Dynamically-Computed Call Site、Dynamically-Computed Constant)
- Java代码在进行Javac编译的时候, 并不像C和C++那样有”连接”这一步, 而是在虚拟机加载
Class
文件的数进行动态链接。即在Class
文件中不会保存各个方法、字段最终在内存中的布局信息, 这些字段、方法的符号引用不经过虚拟机在运行期转换的话是无法得到真正的内存入口地址, 也就无法直接被虚拟机使用的。当虚拟机做类加载时, 将会从常量池获得对应的符号引用, 再在类创建时或运行时解析、翻译到具体的内存地址中 - 常量池的项目类型
| 类型 | 标志 | 描述 |
| —- | —- | —- |
| CONSTANT_Utf8_info
| 1 | UTF-8编码的字符串 |
| CONSTANT_Integer_info
| 3 | 整型字面量 |
| CONSTANT_Float_info
| 4 | 浮点型字面量 |
| CONSTANT_Long_info
| 5 | 长整型字面量 |
| CONSTANT_Double_info
| 6 | 双精度浮点型字面量 |
| CONSTANT_Class_info
| 7 | 类或接口的符号引用 |
| CONSTANT_String_info
| 8 | 字符串类型字面量 |
| CONSTANT_Fieldref_info
| 9 | 字段的符号引用 |
| CONSTANT_Methodref_info
| 10 | 类中方法的符号引用 |
| CONSTANT_InterfaceMethodref_info
| 11 | 接口中方法的符号引用 |
| CONSTANT_NameAndType_info
| 12 | 字段或方法的部分符号引用 |
| CONSTANT_MethodHandle_info
| 15 | 表示方法句柄 |
| CONSTANT_MethodType_info
| 16 | 表示方法类型 |
| CONSTANT_Dynamic_info
| 17 | 表示一个动态计算常量 |
| CONSTANT_InvokeDynamic_info
| 18 | 表示一个动态方法调用点 |
| CONSTANT_Module_info
| 19 | 表示一个模块 |
| CONSTANT_Package_info
| 20 | 表示一个模块中开放或导出的包 |
CONSTANT_Class_info
- tag是标记位, 它用于区分常量类型; name_index是常量池索引值, 它指向常量池中的
CONSTANT_Utf8_info
类型常量, 此常量代表了这个类或接口的全限定名
- tag是标记位, 它用于区分常量类型; name_index是常量池索引值, 它指向常量池中的
类型 | 名称 | 数量 |
---|---|---|
u1 | tag | 1 |
u2 | name_index | 1 |
CONSTANT_Utf8-info
类型 | 名称 | 数量 |
---|---|---|
u1 | tag | 1 |
u2 | length | 1 |
u1 | bytes | length |
6.3.3 访问标志
- 用于识别一个类或接口层次的访问信息, 包括: 这个
Class
是类还是接口; 是否定义为public
类型6.3.4 类索引、父类索引与接口索引集合
- 类索引(
this_class
)和父类索引(super_class
)都是u2
类型的数据, 而接口索引集合(interfaces
)是一组u2
类型的数据集合,Class
文件中由这三项数据来确定该类型的继承关系。类索引用于确定这个类的全限定名, 父类索引用于确定这个类的父类的全限定名。6.3.5 字段表集合
- 字段表(
field_info
)用于描述接口或类中声明的变量。Java语言中的字段(Field)包括类级别变量以及实例级变量 - 由于语法规则的约束,
ACC_PUBLIC
、ACC_PRIVATE
、ACC_PROTECTED
三个标记只能选其一,ACC_FINAL
、ACC_VOLATILE
不能同时选择。接口之中的字段必须有ACC_PUBLIC
、ACC_STATIC
、ACC_FINAL
标志 - 类全限定名, 例如
org/fenixsoft/clazz/TestClass
, 简单名称则指没有类型和参数修饰的方法或字段名称; 而方法和字段的描述符的用用是用来描述字段的数据类型、方法的参数列表(包括数量、类型以及顺序)和返回值 - 用描述符来描述方法时, 按照先参数列表, 后返回值的顺序描述, 参数列表按照参数的严格顺序放在一组小括号
()
之内。如方法void inc()
的描述符为()V
, 方法java.lang.String toString()
的描述符为()Ljava/lang/String
6.3.6 方法表集合
- 方法表包括访问标志(access_flags)、名称索引(name_index)、描述符索引(descriptor_index)、属性表集合(attributes)
- 方法里的Java代码, 经过Javac编译器编译成字节码指令之后, 存放在方法属性表集合一个名为”Code”的属性里面
- 与字段表集合想对应地, 如果父类方法在子类中没有被重写(Override), 方法表集合中就不会出现来自父类的方法信息。但同样地, 有可能会出现由编译器自动添加的方法, 最常见的便是
<clinit>
方法和实例构造器<init>()
方法 - 要重载(Overload)方法, 除了要与原方法具有相同的简单名称之外, 还要求必须拥有一个与原方法不同的特征签名, 特征签名是指一个方法中各个参数在常量池的字段符号引用的集合
6.3.7 属性表集合
- 虚拟机规范预定义的属性
使用名称 | 使用位置 | 含义 |
---|---|---|
Code | 方法表 | Java代码编译成的字节码指令 |
- Code属性
- Java程序方法体里的代码经过Javac编译处理之后, 最终变成为字节码指令存储在Code属性内。Code属性出现在方法表的属性集合之中, 但并非所有的方法表都必须存在这个属性, 譬如接口或抽象类中的方法就不存在Code属性
attribute_name_index
是CONSTANT_Utf8_info
型常量的索引, 此常量值固定为Code
, 它代表了该属性的属性名称max_stack
代表操作数栈深度的最大值, 在方法执行的任意时刻, 操作数栈都不会超过这个深度。虚拟机运行的时候需要根据这个值来分配栈帧(Stack Frame)中的操作栈深度max_locals
代表了局部变量所需的存储空间, 方法体中定义的局部变量都需要依赖局部变量来存放; 操作数栈和局部变量表直接决定该方法的栈帧所耗费的内存, 不必要的操作数栈深度和变量槽(Slot)数量会造成内存的浪费。Java虚拟机的做法是将局部变量表中的变量槽进行重用, 当代码执行超过一个局部变量的作用域时, 这个局部变量所占的变量槽可以被其他局部变量所使用code_length
和code
用来存储Java源程序编译后生成的字节码指令。code_length
代表字节码长度,code
用于存储字节码指令的一系列字节流Code
属性是Class
文件中最重要的一个属性, 如果把一个Java程序中的信息分为代码(Code, 方法体里面的Java代码)和元数据(Metadata, 包含类、字段、方法定义及其他信息)两部分, 那么整个Class
文件里,Code
属于用于描述代码, 所有的其他数据项目都用于描述元数据
类型 | 名称 | 数量 |
---|---|---|
u2 | attribute_name_index | 1 |
u4 | attribute_length | 1 |
u2 | max_stack | 1 |
u2 | max_locals | 1 |
u4 | code_length | 1 |
u1 | code | code_length |
u2 | exception_table_length | 1 |
exception_info | exception_table | exception_table_length |
u2 | attributes_count | 1 |
attribute_info | attributes | attributes_count |
package org.fenixsoft.clazz;
public class TestClass {
private int m;
public int inc() {
return m + 1;
}
}
TestClass
的实例构造器<init>()
方法的Code
属性。它的操作数栈的最大深度和本地变量表的容量都为0x0001, 字节码区域所占空间的长度为0x0005。虚拟机读取到字节码区域的长度后, 按照顺序一次读入紧随的5个字节, 并根据字节码指令表翻译出所对应的字节码指令。翻译”2A B7 00 0A B1”的过程为- 读入2A, 查表得0x2A对应的指令为aload_0, 这个指令的含义是将第0个变量槽中为reference类型的本地变量推送到操作数栈顶。
- 读入B7, 查表的0xB7对应的指令为
invokespecial
, 这条指令的作用是以栈顶的reference
类型的数据所指向的对象作为接收者, 调用此对象的实例构造器方法、private
方法或它的父类的方法。这个方法有一个u2类型的参数说明具体调用哪一个方法, 它指向常量池中的一个CONSTANT_Methodref_info
类型常量, 即此方法的符号引用。 - 读入00 0A, 这是
invokespecial
指令的参数, 代表一个符号引用, 查常量池得0x000A对应的常量为实例构造器<init>()
方法的符号引用 - 读入B1, 查表得0xB1对应的指令为
return
, 含义是从方法的返回, 并且返回值为void
。这条指令执行后, 当前方法正常结束
- 异常运作, 编译器为这段Java源代码生成了三条异常表记录, 对应三可能出现的代码执行路径。从Java代码的语义上讲, 这三条执行路径分别为:
- 如果
try
语句块中出现属于Exception
或其子类的异常, 转到catch
语句块处理;(ps: 如果try
或catch
正常执行, 也会执行finally
的指令, 通过把finally
的代码块逻辑copy过去) - 如果
try
语句块中出现不属于Exception
或其子类的异常, 转到finally
语句块处理; - 如果
catch
语句块中出现任何异常, 转到finally
语句处理
- 如果
- Exceptions属性
- Exceptions属性的作用是列举出方法中可能抛出的手检查异常(Checked Exceptions)
- LineNumberTable属性
- LineNumberTable属性用于描述Java源码行号与字节码行号(字节码偏移量)之间的对应关系。如果选择不生成LineNumberTable属性m 对程序运行产生的最主要影响就是当抛出异常时, 堆栈中将不会显式出错的行号, 并且在调试程序的时候, 也无法按照源码行来设置断点
- LocalVariableTable及LocalVariableTypeTable属性
- LocalVariableTable属性用于描述栈帧中局部变量表的变量与Java源码中定义的变量之间的关系, 它也不是运行时必须的属性, 但默认会生成到Class文件之中, 如果没有生成这项属性, 最大的影响就是当其他人引用这个方法时, 所有参数名称都将会丢失
- LocalVariableTypeTable属性在JDK 5引入泛型后新增的, 与LocalVariableTable类似; 对于非泛型类型来说, 描述符和特征签名能描述的信息是能吻合一致的, 但泛型引入后, 由于描述符中泛型的参数化类型被擦除掉, 描述符就不能准确描述泛型类型了。因此出现了LocalVariableTypeTable属性, 使用字段的特征签名来完成泛型的描述
- ConstantValue属性
- ConstantValue属性的作用是通知虚拟机自动为静态变量赋值。只有被
static
关键字修饰的变量(类变量)才可以使用这项属性, 对非static
类型的变量的赋值是在实例构造器<init>()
方法中进行的; 而对于类变量, 则有两种选择: 在类构造器<clinit>()
方法中或使用ConstantValue属性
- ConstantValue属性的作用是通知虚拟机自动为静态变量赋值。只有被
- StackMapTable属性
- 该属性会在虚拟机类加载的字节码验证阶段被新类型检查验证器(Type Checker)使用, 目的在于代替以前比较消耗性能的基于数据流分析的类型推导验证器
- 类型检查校验器会通过检查目标方法的局部变量和操作数栈所需的类型来确定一段字节码指令是否符合逻辑约束
- Signature属性
- Signature属性在JDK 5增加, 可以出现于类、字段表和方法表结构的属性表, 任何类、接口、初始化方法或成员的泛型签名如果包含了类型变量(Type Variable)或参数化类型(Parameterized Type), 则Signature属性会为它记录泛型签名信息
- 现在Java反射API能获取泛型类型, 最终数据来源也是这个属性
- Signature属性中的
signature_index
项的值必须是一个对常量池的有效索引。常量池在该索引处项必须是CONSTANT_Utf8_info
结构, 表示类签名或方法类型签名或字段类型签名
- 模块化相关属性
Module
、ModulePackages
和ModuleMainClass
三个属性用于支持Java模块化相关功能Module
属性是一个非常复杂的边长属性, 除了表示该模块的名称、版本、标志信息以外, 还存储了这个模块requires
、exports
、opens
、uses
和provides
定义的全部内容; 其中exports
属性的每一元素都代表一个被模块所导出的包
- 运行时注解相关属性
RuntimeVisibleAnnotations
是一个变长属性, 它记录了类、字段或方法的声明上记录运行时可见注解, 当我们使用反射API来获取类、字段或方法上的注解时, 返回值就是通过这个属性来取到的6.4 字节码指令简介
- Java虚拟机的指令由一个字节长度、代表着某种特定操作含义的数字(称为操作码, Opcode)以及跟随其后的零至多个代表此操作所需的参数(称为操作数, Operand)构成
6.4.1 字节码与数据类型
- 在Java虚拟机的指令集中, 大多数指令都包含其操作所对应的数据类型信息。比如
iload
指令用于从局部变量表中加载int
类型的数据到操作数栈中6.4.2 加载和存储指令
- 加载和存储指令用于数据在栈帧中的局部变量表和操作数栈之间来回传输
- 将局部变量加载到操作数栈: iload、iload_
、lload、lload_ ... - 将操作数栈存储到局部变量表: istore、istore_
、lstore、lstore_ ... - 将常量加载到操作数栈: bipush、sipush、ldc、ldc_w…
- 将局部变量加载到操作数栈: iload、iload_
- 存储数据的操作数栈和局部变量表主要由加载和存储指令进行操作
6.4.3 运算指令
- 算术指令: 对整型数据进行运算的指令与对浮点类型数据进行运算的指令
- 加法指令: 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
6.4.4 类型转换指令
- 类型转换指令可以将两种不同数值类型互相转换; Java虚拟机直接支持(即转换时无须显式的转换指令)以下数值类型的宽化类型转换(Widening Numeric Conversion, 即小范围类型向大范围类型的安全转换):
int
类型到long
、float
或double
类型long
类型到float
、double
类型float
类型到double
类型- 处理窄化类型转换(Narrowing Numeric Convension)时, 就必须显式地使用转换指令来完成
6.4.5 对象创建与访问指令
- 创建类实例的指令:
new
- 创建数组的指令:
newarray
、anewarray
、multianewarray
- 访问类字段(
static
字段, 或者称为类变量)和实例字段(非static
字段, 或者称为实例变量)的指令:getfield
、putfield
、getstatic
、putstatic
- 把一个数组元素加载到操作数栈的指令:
baload
、caload
、saload
、iaload
、laload
、faload
、daload
、aaload
- 将一个操作数栈的值存储到数组元素中的指令:
bastore
、castore
、sastore
、iastore
、fastore
、dastore
、aastore
- 取数组长度的指令:
arraylength
- 检查类实例类型的指令:
instanceof
、checkcast
6.4.6 操作数栈管理指令
- 将操作数栈的栈顶一个或两个元素出栈:
pop
、pop2
- 复制栈顶一个或两个数值并将复制值或双份的复制值重新压入栈顶:
dup
、dup2
、dup_x1
、dup2_x1
、dup_x2
、dup2_x2
- 将栈最顶端的两个数值互换:
swap
6.4.7 控制转换指令
- 条件分支:
ifeq
、ifle
、iflt
、ifne
、ifge
、ifnull
、ifnonnull
、if_icmpeq
、if_icmpne
、if_icmplt
、if_icmpgt
、if_icmple
、if_icmpge
、if_acmpeq
、if_acmpne
- 复合条件分支:
tableswitch
、lookupswitch
- 无条件分支:
goto
、goto_w
、jsr
、jsr_w
、ret
6.4.8 方法调用和返回指令
- 以下五条指令用于方法调用(分派、执行过程)
invokevirtual
指令: 用于调用对象的实例方法, 根据对象的实际类型进行分派(虚方法分派), 这也是Java语言中最常见的方法分派方式invokeinterface
指令: 用于调用接口方法, 它会在运行时搜索一个实现了这个接口方法的对象, 找出合适的方法进行调用invokespecial
指令: 用于调用一些需要特殊处理的实例方法, 包括实例初始方法、私有方法和父类方法invokestatic
指令: 用于调用类静态方法(static
方法)invokedynamic
指令: 用于在运行时动态解析出调用点限定符所引用的方法6.4.9 异常处理指令
- 在
Java
程序中显式抛出异常的操作(throw
语句)都由athrow
指令来实现
6.4.10 同步指令
- Java虚拟机可以支持方法级的同步和方法内部一段指令序列的同步, 这两种同步结构都是使用管程(Monitor)来支持
- 方法级的同步是隐式的, 即无须通过字节码指令来控制
- 同步方法, 虚拟机通过常量池的方法表结构中的
ACC_SYNCHRONIZED
访问标识确认方法是否为同步, 如果设置, 执行线程持有管程(Monitor), 执行完成则释放管程, 当有执行线程持有管程, 其它线程无法再获得同一管程 synchronized
, Java虚拟机的指令通过monitorenter和monitorexit支持synchronized
语义, 正确实现synchronized
关键字需要Javac编译器与Java虚拟机两者共同协作支持第7章 虚拟机类加载机制
7.1 概述
- Java虚拟机把描述类的数据从
Class
文件加载到内存, 并对数据进行校验、转换解析和初始化, 最终形成可以被虚拟机直接使用的Java类型, 这个过程虚拟机的类加载机制7.2 类加载的时机
- 一个类型从被加载到虚拟机内存开始, 到卸载出内存为止, 它的整个生命周期将会经历加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)
- 对于初始化阶段«Java虚拟机规范»严格规定了由且只有六种情况必须立即对类进行”初始化”
- 遇到
new
、getstatic
、putstatic
、invokestatic
这四条字节码指令时, 如果类型没有进行初始化- 使用
new
关键字实例化对象的时候 - 读取或设置一个类型的静态字段(被
final
修饰、已在编译器把结果放到常量池的静态字段除外) - 调用一个类型的静态方法的时候
- 使用
- 使用
java.lang.reflect
包的方法对类型进行反射调用的时候, 如果类型没有进行过初始化, 则需要先触发其初始化 - 当初始化类的时候, 如果发现其父类还没有进行初始化, 则需要先触发其父类的初始化(接口初始化时, 不要求其父接口全部完成初始化, 只有真正使用到父接口的时候才会初始化)
- 当虚拟机启动时, 用户需要指定一个要执行的主类(包含
main()
方法的那个类), 虚拟机会初始化这个类 - 当使用JDK7新加入的动态语言支持时, 如果一个
java.lang.invoke.MethodHandle
实例最后解析结果为REF_getStatic
、REF_putStatic
、REF_invokeStatic
、REF_newInvokeSpecial
四种类型的方法句柄, 并且这个方法句柄对应的类没有进行过初始化, 则需要先触发其初始化 - 当一个接口中定义了JDK8新加入的默认方法(被
default
关键字修饰的接口方法)时, 如果有这个接口的实现类发生了初始化, 那该接口要在其之前被初始化 - 被动引用的例子
public class SuperClass { static { System.out.println("Super Class init"); } public static int value = 123; } public class SubClass extends SuperClass { static { System.out.println("Sub Class init"); } } // 被动引用的例子一 public class NotInitialization { public static void main(String[] args) { // 只会输出Super Class init, 对于静态字段, 只有直接定义这个字段的类才会被初始化 // 因此通过其子类来引用父类中定义的静态字段, 只会触发父类的初始化而不会触发子类的初始化 System.out.println(SubClass.value); } } // 被动引用例子二 public class NotInitialization { public static void main(String[] args) { // 没有输出Super Class init SuperClass[] sca = new SuperClass[10]; } } // 被动引用例子三 public class ConstClass { static { System.out.println("ConstClass init"); } public static final String HELLOWORLD = "hello world"; } public class NotInitialzation { public static void main(String[] args) { // 不会输出ConstClass init, 这是因为虽然在Java源码中确实引用了ConstClass类的常量HELLOWORLD, // 但在编译阶段通过常量传播优化, 已经将此常量的值hello world直接存储在NotInitialzation类的常量 // 池中, 以后NotInitialzation对ConstClass.HELLOWORLD的引用, 实际都被转化为NotInitialzation // 类对自身常量池的引用了 System.out.println(ConstClass.HELLOWORLD); } }
- 遇到
7.3 类加载的过程
- Java虚拟机中类加载的全过程, 即加载、验证、准备、解析、初始化
7.3.1 加载
- 加载(Loading)阶段时整个类加载(Class Loading)过程的一个阶段, 在加载阶段, Java虚拟机需要完成以下三件事情
- 通过一个类的全限定名来获取定义此类的二进制字节流
- 通过这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
- 在内存中生成一个代表这个类的
java.lang.Class
对象, 作为方法区这个类的各种数据访问入口
- 相比于类加载过程的其他阶段, 非数据类型的加载阶段是开发人员可控性最强的阶段。加载阶段即可以使用Java虚拟机里内置的引导类加载器来完成, 也可以由用户自定义的类加载器来完成, 开发人员通过定义自己的类加载器去控制字节流的获取方式
- 数组类本身不通过类加载器创建, 它是由Java虚拟机直接在内存中动态构建出来。但数组类与类加载器仍有密切关系, 因为数组的元素类型(Element Type)最终还是要靠类加载器来完成加载, 一个数组类(简称为C)创建过程遵循以下规则
- 如果数组的组件类型(Component Type)是引用类型, 那就递归采用本节中定义的加载过程去加载这个组件类型, 数组C将被标识在加载该组件类型的类加载器的类名称空间上
- 如果数组的组件类型不是引用类型(例如int[]), Java虚拟机将会把数组C标记为引导类加载器关联
- 数组类的可访问性与它的组件类型的可访问性一致, 如果组件类型不是引用类型, 它的数组类的可访问性将默认为
public
, 可被所有类和接口访问到7.3.2 验证
- 验证是连接阶段的第一步, 这一阶段的目的是确保
Class
文件的字节流中包含的信息符合«Java虚拟机规范»的全部约束要求, 保证这些信息被当作代码运行后不会危害虚拟机自身的安全 - 验证阶段包含四个阶段的校验动作: 文件格式验证、元数据验证、字节码验证和符号引用验证
- 文件格式验证
- 第一个阶段要验证字节流是否符合
Class
文件格式的规范, 并且能被当前版本的虚拟机处理- 是否以魔数0xCAFEBABE开头
- 主、次版本号是否在当前Java虚拟机接受范围
- 常量池的常量中是否有不被支持的常量类型
- 指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量等
- 该验证阶段主要目的是保证输入的字节流能正确地解析并存储于方法区内, 格式上符合描述一个Java类型信息的要求。这个阶段的验证是基于二进制字节流进行的, 只有通过了这个阶段的验证之后, 这个阶段字节流才被允许进入Java虚拟机内存的方法区中进行存储, 所以后面的三个验证阶段全部是基于方法区的存储结构上进行的, 不会再直接读取、操作字节流了
- 第一个阶段要验证字节流是否符合
- 元数据验证
- 第二阶段对字节码描述的信息进行语义分析, 以保证其描述的信息符合«Java语言规范», 其包含以下验证点
- 这个类是否有父类(除了
java.lang.Object
之外, 所有类都应有父类) - 这个类的父类是否继承了不允许被继承的类(被
final
修饰的类) - 如果这个类不是抽象类, 是否实现了其父类或接口之中要求实现的所有方法
- 类中的字段、方法是否与父类产生矛盾(例如覆盖了父类的
final
字段)
- 这个类是否有父类(除了
- 第二阶段的主要目的是对类的元数据信息进行语义校验, 保证不存在与«Java语言规范»定义相悖的元数据信息
- 第二阶段对字节码描述的信息进行语义分析, 以保证其描述的信息符合«Java语言规范», 其包含以下验证点
- 字节码验证
- 主要目的是通过数据流分析和控制流分析, 确定程序语义是合法的、符合逻辑的。该阶段对类的方法体(
Class
文件中的Code
属性)进行校验分析, 保证被校验类的方法在运行时不会做出危害虚拟机安全的行为, 如:- 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作
- 保证任何跳转指令都不会跳转到方法体以外的字节码指令上
- 保证方法体中的类型转换总是有效的
- 符号引用验证
- 校验行为发生在虚拟机将符号引用转化为直接引用的时候, 这个转化动作将在连接的解析阶段发生, 本阶段要校验下列内容
- 符号引用中通过字符串描述的全限定名是否能找到对应的类
- 在指定类中是否存在符合方法的字段描述符及简单名称所描述的方法和字段
- 符号引用中的类、字段、方法的可访问性(
private
、protected
、public
、<package>
)是否可被当前类访问
- 该阶段主要目的是确保解析行为能正常执行
- 校验行为发生在虚拟机将符号引用转化为直接引用的时候, 这个转化动作将在连接的解析阶段发生, 本阶段要校验下列内容
- 主要目的是通过数据流分析和控制流分析, 确定程序语义是合法的、符合逻辑的。该阶段对类的方法体(
- 验证阶段对于虚拟机类加载机制来说, 是一个非常重要的, 但却不是必须要执行的阶段, 因为验证阶段只有通过和不通过的差别, 只要通过了验证, 其后就对程序允许期没有任何影响了
7.3.3 准备
- 准备阶段是正式为类中定义的变量(即静态变量, 被
static
修饰的变量)分配内存并设置类变量初始值的阶段, 从概念上讲, 这些变量所使用的内存都应当在方法区中进行分配, 但方法区本身是逻辑上区域, 在JDK 7及之前, HotSpot使用永久代来实现方法区, 实现是符合该逻辑概念的; 而JDK 8 及之后, 类变量则会随着Class
对象一起存放在Java堆中7.3.4 解析
- 解析阶段是Java虚拟机将常量池内的符号引用替换为直接引用的过程, 符号引用在
Class
文件中它以CONSTANT_Class_info
、CONSTANT_Fieldref_info
、CONSTANT_Methodref_info
等类型的常量出现, 那解析阶段中直接引用和符号引用的关联是什么呢?- 符号引用(Symbolic Reference): 符号引用以一组符号来描述所引用的目标, 符号可以是任何形式的字面量, 只要无歧义地定位到目标即可
- 直接引用(Direct References): 直接引用是可以直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局直接相关的, 同一个符号引用的不同虚拟机实例上翻译出来的直接引用一般不会相同
- «Java虚拟机规范»之中并未规定解析阶段发生的具体时间, 只要求了在执行
anewarray
、checkcast
、getfield
、getstatic
、instanceof
、invokedynamic
、invokeinterface
、invokespecial
、invokestatic
、invokevirtual
、ldc
、ldc_w
、ldc2_w
、multianewarray
、new
、putfield
和putstatic
这17个用于操作符号引用的字节码指令之前 - 除了
invokedynamic
指令以外, 虚拟机实现可以对第一次解析的结果进行缓存, 譬如在运行时直接引用常量池中的记录, 并把常量池标识为已解析状态 - 对于
invokedynamic
指令, 上面规则不成立, 因为invokedynamic
指令的目的本来就是用于动态语言支持, 它对应的引用成为动态调用点限定符(Dynamically-Computed Call Site Specifier), 这里动态的含义是指必须等到程序实际运行到这条指令时, 解析动作才能进行, 相对地, 其余可触发解析的指令都是静态的, 可以在刚完成加载阶段, 还没有开始执行代码时就提前解析 - 解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符这7类符号引用进行
- 类或接口的解析
- 假设当前代码所处的类为D, 如果要把一个从未解析过的符号引用N解析为一个类或接口C的直接引用, 虚拟机需要完成以下3个步骤
- 如果C不是一个数组类型, 那虚拟机将会把代表N的全限定名传递给D的类加载器去加载这个类C
- 如果C是一个数组类型, 并且数组的元素类型为对象, 也就是N的描述符会是类似
[Ljava/lang/Integer
的形式, 那将会按照第一点的规则加载数组元素类型 - 如果上面两步没有出现任何异常, 那么C在虚拟机中实际上已经称为一个有效的类或接口, 但在解析完成前还要进行符号引用验证, 确认D是否具备对C的访问权限
- 假设当前代码所处的类为D, 如果要把一个从未解析过的符号引用N解析为一个类或接口C的直接引用, 虚拟机需要完成以下3个步骤
- 字段解析
- 要解析一个未被解析过的字段符号引用, 受限将会对字段表内
class_index
项中索引的CONSTANT_Class_info
符号引用进行解析, 即字段所属的类或接口的符号引用。如果解析成功, 那把这个字段所属的类或接口用C表示, «Java虚拟机规范»要求按照如下步骤对C进行后续字段的搜索- 如果C本身就包含了简单名称和字段描述符都与目标相匹配的字段, 则返回这个字段的直接引用, 查找结束
- 否则, 如果在C中实现了接口, 将会按照继承关系从下往上递归搜索各个接口和它的父接口, 如果接口中包含了简单名称和字段描述符都与目标匹配的字段, 则返回这个字段的直接引用, 查找结束
- 否则, 如果C不是
java.lang.Object
的话, 将会按照继承关系从下往上递归搜素其父类, 如果父类中包含了简单名称和字段描述符与目标都匹配的字段, 则返回这个字段的直接引用, 查找结束 - 否则, 查找失败, 抛出
java.lang.NoSuchFieldError
异常
- 要解析一个未被解析过的字段符号引用, 受限将会对字段表内
- 方法解析
- 方法解析的第一个步骤与字段解析一样, 也是需要先解析出方法表的
class_index
项中索引的方法所属的类或接口的符号引用、如果解析成功, 那我们依然用C表示这个类, 接下来虚拟机将按照以下步骤进行后续的方法搜索- 由于
Class
文件格式中类的方法和接口的方法符号引用的常量类型定义是分开的, 如果在类的方法中发现class_index
中索引的C是个接口的话, 即抛出java.lang.IncompatibleClassChangeError
异常 - 如果通过了第一步, 在类C中查找是否有简单名称和描述符都与目标相匹配的方法, 如果有则返回这个方法的直接引用, 查找结束
- 否则, 在类C的父类中递归查找是否有简单名称和描述符都与目标相匹配的方法, 如果有则返回这个方法的直接引用, 查找结束
- 否则, 在类C实现的接口列表及它们的父接口之中递归查找是否有简单名称和描述符都与目标相匹配的方法, 如果存在匹配的方法, 说明类C是一个抽象类, 这个时候查找结束, 抛出
java.lang.AbstractMethodError
异常 - 否则, 宣告方法查找失败, 抛出
java.lang.NoSuchMethdError
7.3.5 初始化
- 由于
- 方法解析的第一个步骤与字段解析一样, 也是需要先解析出方法表的
- 进行准备阶段时, 变量已赋过一次系统要求的初始零值, 而在初始化阶段, 则会根据程序员通过程序编码指定的主观计划去初始化类变量和其他资源。初始化阶段就是执行类构造器
<clinit>()
方法的过程。<clinit>()
方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}
块)中的语句合并产生的, 编译器收集的顺序是由语句在源文件中出现的顺序决定的, 静态语句块中只能访问到定义在静态语句块之前的变量, 定义在它之后的变量, 在静态语句块可以赋值, 但不能访问<clinit>()
方法与类的构造函数(即在虚拟机视角中的实例构造器<init>()
方法)不同, 它不需要显式地调用父类构造器, Java虚拟机会保证在子类的<clinit>()
方法执行前, 父类的<clinit>()
方法已经执行完毕。因此在Java虚拟机中第一个被执行的<clinit>()
方法的类型肯定是java.lang.Object
- 由于父类的
<clinit>()
方法先执行, 即意味着父类中定义的静态语句块要优于子类的变量赋值操作static class Parent { public static int A = 1; static { A = 2; } } static class Sub extends Parent { public static int B = A; } public static void main(String[] args) { System.out.println(Sub.B); }
<clinit>()
方法对于类或接口来说并不是必须的, 如果一个类中没有静态语句块, 也没有对变量的赋值操作, 那么编译器可以不为这个类生成<clinit>()
方法- 接口中不能使用静态语句块, 但仍然有变量初始化的赋值操作, 因此接口与类一样都会生成
<clinit>()
方法。但接口与类不同的是, 执行接口的<clinit>()
方法不需要先执行父接口的<clinit>()
方法, 因为只有当父接口中定义的变量被使用时, 父接口才会被初始化。此外, 接口的实现类在初始化时也一样不会执行接口的<clinit>()
方法 - Java虚拟机必须保证一个类的
<clinit>()
方法在多线程环境中被正确地枷锁同步, 如果多个线程同时去初始化一个类, 那么只会有其中一个线程去执行这个类的<clinit>()
方法, 其他线程都需要阻塞等待, 直到活动线程执行完毕<clinit>()
方法。如果在一个类的<clinit>()
方法中有耗时很长的操作, 那就可能造成多个进程阻塞, 在实际应用中这种阻塞旺旺很隐蔽7.4 类加载器
- Java虚拟机设计团队有意把类加载阶段中的通过一个类全限定名来获取描述该类的二进制字节流这个动作放到Java虚拟机外部去实现, 以便让应用程序自己决定如何去获取所需的类。实现这个动作的代码被称为类加载器(
ClassLoader
)7.4.1 类与类加载器
- 任何一个类, 都必须由加载它的类加载器和这个类本身一起共同确立其在Java虚拟机中的唯一性, 每一个类加载器, 都拥有一个独立的类名称空间。即比较两个类是否”相等”, 只有在这两个类是由同一个类加载器加载的前提下才有意义, 否则即使这两个类来源于同一个
Class
文件, 被同一个Java虚拟机加载, 只要加载它们的类加载器不同, 那这两个类就必定不相等7.4.2 双亲委派模型
- 站在Java虚拟机的角度看, 只存在两种不同的类加载器: 一种是启动类加载器(
Bootstrap ClassLoader
), 这个类加载器使用C++语言实现, 是虚拟机自身的一部分; 另一种就是其他所有的类加载器, 这些类加载器都由Java语言实现, 独立存在于虚拟机外部, 并且全部继承自抽象类java.lang.ClassLoader
- 启动类加载器(
Bootstrap ClassLoader
): 这个类加载器负责加载存放在\lib目录, 或被-Xbootclasspath参数所指定的路径中存放的, 而且是Java虚拟机能够识别的(按照文件名识别, 如rt.jar、tools.jar)类库加载到虚拟机的内存中 - 扩展类加载器(
Extension ClassLoader
): 这个类加载器是在类sun.misc.Launcher$ExtClassLoader
中以Java代码的形式实现的。它负责加载<JAVA_HOME>\lib\ext
目录中, 或被java.ext.dirs
系统变量所指定的路径中所有的类库 - 应用程序类加载器(Application ClassLoader): 这个类加载器由
sun.misc.Launcher$AppClassLoader
来实现。由于应用程序类加载器是ClassLoader
类中的getSystemClassLoader()
方法的返回值, 所以有些场合也称它为系统类加载器。它负责加载用户类路径(ClassPath)上所有的类库 - 双亲委派模型要求除了顶层的启动类加载器外, 其余的类加载器都应有自己的父类加载器。不过这里类加载器之间的父子关系一般不是以继承(Inheritance)的关系来实现的, 而是通常使用组合(Composition)关系来复用父类加载器的代码
- 双亲委派模型的工作过程是: 如果一个类加载器收到了类加载的请求, 它首先不会自己去尝试加载这个类, 而是把这个请求委派给父类加载器去完成, 每一个层次的类加载器都是如此, 因此所有的加载请求最终都应该传送到最顶层的启动类加载器中, 只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时, 子加载器才会尝试自己去完成加载
ClassLoader#loadClass
方法protected synchronized Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException { // 1. 先检查请求加载的类型是否已经加载过 Class c = findLoadedClass(name); if (c == null) { try { if (parent != null) { // 2. 若没有加载且父加载器不为空则调用父加载器的loadClass方法 c = parent.loadClass(name, false); } else { // 3. 若父加载器为空则默认使用启动类加载器作为父加载器 c = findBootstrapClassOrNull(name); } } catch(ClassNotFoundException e) { } if (c == null) { // 假如父类加载器加载失败, 抛出ClassNotFoundException异常得话, 才调用自己的findClass()方法尝试进行加载 c = findClass(name); } } if (resolve) { resolveClass(c); } return c; }
7.4.3 破坏双亲委派模型
- 双亲委派模型的第一次”被破坏”其实发生在双亲委派模型出现之前-即JDK1.2以前的远古时代, 由于类加载器的概念和抽象类
ClassLoader
在Java第一个版本就已经存在了, Java设计者为了兼容用户自定义加载器的代码, 在引入双亲委派模型不得不妥协, 无法再以技术手段避免loadClass()
被子类覆盖的可能性, 只能在JDK1.2之后的ClassLoader
新增protected
方法findClass()
, 并引导用户编写的类加载器时尽可能去重写这个方法, 而不是在loadClass
编写 - 双亲委派模型的第二次”被破坏”是由这个模型自身的缺陷导致的, 双亲委派很好地解决了各个类加载器协作时基础类型的一致性问题, 但无法解决基础类型又要调回用户的代码该怎么办? Java设计团队只好引入不太优雅的设计: 线程上下文类加载器(Thread Context ClassLoader)。这个类加载器可以通过
java.lang.Thread
类的setContextClassLoader()
方法进行设置, 如果创建线程时还未设置, 它将会从父线程中继承一个, 如果在应用程序的全局范围内都没有设置过的话, 那这个类加载器默认就是应用类加载器, 有了线程上下文类加载器, 程序就可以做一些”舞弊”的事情了, JNDI服务使用这个线程上下文类加载器去加载所需的SPI服务代码, 这是一种父类加载器去请求子类加载器完成类加载的行为, 这种行为实际上是打通了双亲委派模型的层次结构来逆向使用类加载器, 已经违背了双亲委派模型的一般性原则了, Java中涉及SPI的加载基本上都采用这种方式来完成 - 双亲委派模型第三次”被破坏”是由于用户对程序动态性的追求而导致的, 即代码热替换(Hot Swap)、模块热部署(Hot Deployment), OSGi实现模块化热部署的关键是它自定义的类加载器机制的实现, 每一个程序模块(OSGi中称为Bundle)都有一个自己的类加载器, 当需要更换一个Bundle时, 就把Bundle连同类加载器一起换掉以实现代码的热替换。在OSGi环境下, 类加载不再双亲委派模型推荐的树状结构, 而是进一步发展为更加复杂的网状结构, 当收到类加载请求时, OSGi将按照下面的顺序进行类搜索
- 将以java.*开头的类, 委派给父类加载器加载
- 否则, 将委派列表名单内的类, 委派给父类加载器加载
- 否则, 将Import列表中的类, 委派给Export这个类的Bundle的类加载器加载
- 否则, 查找当前Bundle的ClassPath, 使用自己的类加载器加载
- 否则, 查找类是否在自己的Fragment Bundle中, 如果在, 则委派给Fragment Bundle的类加载器加载
- 否则, 查找Dynamic Import列表的Bundle, 委托给对应Bundle的类加载器加载
- 否则, 类查找失败
第8章 虚拟机字节码执行引擎
8.1概述
- 虚拟机和物理机都有代码执行能力, 其区别是物理机的执行引擎是直接建立在处理器、缓存、指令集和操作系统层面上的, 而虚拟机的执行引擎是由软件自行实现的
- 在不同的虚拟机实现中, 执行引擎在执行字节码的时候, 通常会有解析执行(通过解析器执行)和编译执行(通过即时编译器产生本地代码执行)
8.2 运行时栈帧结构
- 栈帧(Stack Frame)则是用于支持虚拟机进行方法调用和方法执行背后的数据结构, 它也是虚拟机运行时数据区中的虚拟机栈(Virtual Machine Stack)的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。每一个方法从调用开始至执行结束的过程都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程
- 对于执行引擎来讲, 在活动线程中, 只有位于栈顶的方法才是在运行的, 只有位于栈顶的栈帧才是生效的, 其被称为当前栈帧(Current Stack Frame), 与这个栈帧所关联的方法被称为当前方法(Current Method)
8.2.1 局部变量表
- 局部变量表(Local Viriables Table)是一组变量值的存储空间, 用于存放方法参数和方法内部定义的局部变量
- Java占用不超过32位存储空间的数据类型有
boolean
、byte
、char
、short
、int
、float
、reference
和returnAddress
这8种类型,reference
类型表示对一个对象实例的引用, «Java虚拟机规范»既没有说明它的长度, 也没有明确指出这种引用应有怎样的结构。虚拟机实现至少都应当能通过这个引用做到两件事情, 一是从根据引用直接或间接地查找到对象在Java堆中的数据存放的起始地址或索引, 二是根据引用直接或间接地查找到对象所属数据类型在方法区中的存储的类型信息。returnAddress
类型目前已经很少见, 它是为字节码指令jsr
、jsr_w
和ret
服务的, 指向了一条字节码指令的地址 - 在一个方法被调用时, Java虚拟机会使用局部变量表完成参数值到参数变量列表的传递过程, 即实参到形参的传递。如果执行的是实例方法, 那局部变量表中第0位索引的变量槽默认是用于传递方法所属对象实例的引用, 在方法中可以通过关键字
this
来访问到这个隐含的参数。其余参数则按照参数表顺序排列, 占用从1开始的局部变量槽, 参数表分配完毕后, 再根据方法体内部定义的变量顺序和作用域分配其他的变量槽 - 在方法中定义
byte[] placeholder = new byte[64 * 1024 * 1024];
再调用System.gc()
,placeholder
能否被回收的根本原因是: 局部变量表中的变量槽是否还存有关于placeholder
数组对象的引用。第一次修改中, 代码虽然已经离开了placeholder
的作用域, 但在此后, 再没有发生过任何局部变量表的读写操作,placeholder
原本所占用的变量槽还没有被其他变量所复用, 所以作为GC Roots 一部分的局部变量表仍然保持着对它的关联 - 类的字段变量有两次赋初始值的过程, 一次在准备阶段, 赋于系统初始值; 另一次在初始化阶段, 赋予程序员定义的初始值。而局部变量不同, 如果一个局部变量定义了但没有赋初始值, 那它是完全不能使用的。所以不要认为Java中任何情况下都存在诸如整型变量默认为0、布尔型变量默认为false等默认值规则
8.2.2 操作数栈
- 操作数栈(Operand Stack)也常被称为操作栈, 它是一个后入先出(Last In First Out, LIFO)栈, 同局部变量表一样, 操作数栈的最大深度也在编译的时候被写入到
Code
属性的max_stacks
数据项中 - 当一个方法刚开始执行的时候, 这个方法的操作数栈是空的, 在方法的执行过程中, 会有各种字节码指令往操作数栈中写入和提取内容, 即出栈和入栈操作
- 操作数栈中元素的数据类型必须与字节码指令的序列严格匹配, 在编译程序代码的时候, 编译器必须严格保证, 在类校验阶段的数据流分析中还要再次校验。
8.2.3 动态连接
- 每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用, 持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking)。
Class
文件的常量池中存有大量的符号引用, 字节码中的方法调用指令就以常量池里指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或第一次使用的时候就被转化为直接引用, 这些转换被称为静态解析。另一部分将在每一次运行期间都转化为直接引用, 这部分就被称为动态连接8.2.4 方法返回地址
- 当一个方法开始执行后, 只有两种方式退出这个方法。第一种方式是执行引擎遇到任意一个方法返回的字节码指令, 这时候可能会有返回值传递给上层的方法调用者, 方法是否有返回值以及返回值的类型将根据遇到何种方法返回指令来决定, 这种退出方法的方式称为正常调用完成; 另一种退出方式是在方法执行的过程中遇到异常, 并且这个异常没有在方法体内得到妥善处理。无论是Java虚拟机内部产生的异常还是代码中使用
athrow
字节码指令产生的异常, 只要在本房发的异常表中没有搜索到匹配的异常处理器, 就会导致方法退出, 这种退出的方式称为异常调用完成。一个方法使用异常完成出口的方式退出, 是不会给它的上层调用者提供任何返回值的。 - 无论采用何种退出方式, 在方法退出之后, 都必须返回到最初方法被调用时的位置, 程序才能继续执行, 方法返回时可能需要在栈帧中保存一些信息, 用来帮助恢复它的上层主调方法的执行状态。一般来说, 方法正常退出时, 主调方法的PC计数器的值就可以作为返回地址, 栈帧中很可能会保存这个计数器值。而方法异常退出时, 返回地址是要通过异常处理器表来确定的, 栈帧中就一般不会保存这部分信息
8.3 方法调用
- 方法调用并不等同于方法中的代码执行, 方法调用阶段唯一任务就是确定被调用方法的版本(即调用哪一个方法)。
Class
文件的编译过程中不包含传统程序语言编译的连接步骤, 一切方法调用在Class
文件里面存储的都只是符号引用, 而不是方法在实际运行内存布局中的入口地址。8.3.1 解析
- 所有方法调用的目标方法在
Class
文件里面都是一个常量池中的符号引用, 在类加载的解析阶段, 会将其中一部分符号引用转化为直接引用, 这种解析能够成立的前提是: 方法在程序真正运行之前就有一个可确定的调用版本, 并且这个方法的调用版本在运行期是不可改变的。即调用目标在程序代码写好、编译器进行编译那一刻就已经确定下来。这类方法的调用被称为解析(Resolution) - 在Java语言中符合编译器可知,运行期不可变这个要求的方法, 主要有静态方法和私有方法两大类, 前者与类型直接关联, 后者在外部不可被访问, 这两种方法各自的特点决定了它们都不可能通过继承或别的方式重写出其他版本, 因此它们都适合在类加载阶段进行解析
- 在Java虚拟机支持以下5条方法调用字节码指令:
invokestatic
: 用于调用静态方法invokespecial
: 用于调用实例构造器<init>()
方法、私有方法和父类中的方法invokevirtual
: 用于调用所有的虚方法invokeinterface
: 用于调用接口方法, 会在运行时再确定一个实现该接口的对象invokedynamic
: 先在运行时动态解析出调用点限定符所引用的方法, 然后执行该方法
- 前面4条调用指令, 分派逻辑都固化在Java虚拟机内部, 而
invokedynamic
指令的分派逻辑由用户设定的引导方法来决定, 只要能被invokestatic
和invokespecial
指令调用的方法, 都可以在解析阶段中确定唯一的调用版本, Java语言里符合这个条件的方法共有静态方法、私有方法、实例构造器、父类方法4种, 再加上被final
修饰的方法(尽管它使用invokevirtual
指令调用), 这5种方法调用会在类加载的时候就可以把符号引用解析为该方法的直接引用。这些方法统称为非虚方法(Non-Virtual Method) - 被
final
修饰的实例方法, 虽然由于历史设计的原因,final
方法是使用invokevirtual
指令来调用的, 但因为它也无法被覆盖, 没有其他版本的可能, 所以也无须对方法接收者进行多态选择, 又或者说多态选择的结果肯定是唯一的 - 解析调用一定是个静态的过程, 在编译期间就完全确定, 在类加载的解析阶段就会把涉及的符号引用全部转化为明确的直接引用, 不必延迟到运行期再去完成。而另一种主要的调用形式: 分派(Dispatch)调用则要复杂很多, 它可能是静态的也可能是动态的, 按照分派依据的宗量数可分为单分派和多分派。这两类分派方式两两组合就构成了静态单分派、静态多分派、动态单分派、动态多分派
8.3.2 分派
- 静态分派
public class StaticDispatch { 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, gentleman"); } public void sayHello(Woman guy) { System.out.println("hello, lady"); } public static void main(String[] args) { Human man = nwe Man(); Human woman = new Woman(); StaticDispatch sr = new StaticDispatch(); // 输出 hello, guy! sr.sayHello(man); // 输出 hello, guy! sr.sayHello(woman); } }
Human
称为变量的静态类型(Static Type), 或叫外观类型, 后面的Man
则被称为变量的实际类型或叫运行时类类型。静态类型和实际类型在程序中都可能会发生变化, 区别是静态类型的变化仅仅在使用时发生, 变量本身的静态类型不会改变, 并且最终的静态类型并不知道一个对象的实际类型时什么。如//实际类型变化 Hunman human = (new Randon()).nextBoolean() ? new Man() : new Woman();
,//静态类型变化 sr.sayHello((Man) human);
- 对象
human
的实际类型是可变的, 编译期间它完全是个”薛定谔的人”, 到底时Man
还是Woman
, 必须等到程序运行到这行的时候才能确定。而human
的静态类型时Human
, 也可以在使用时(如sayHello()
方法中的强制转型)临时改变这个类型, 但这个改变仍是在编译期是可知的, 两次sayHello()
方法的调用, 在编译期完全可以明确转型的是Man
还是Woman
- 所有依赖静态类型来决定方法执行版本的分派动作, 都称为静态分派。静态分派的最典型应用表现就是方法重载。静态分派发生在编译阶段, 因此确定静态分派的动作实际上不是由虚拟机来执行
- 编译期间选择静态分派目标的过程, 这个过程也是Java语言实现方法重载的本质
- 解析与分派这两者之间的关系并不是二选一的排他关系, 它们是在不同层次上去筛选、确定目标方法的过程。静态方法会在编译期确定、在类加载期就进行解析, 而静态方法显式也是可以拥有重载版本的, 选择重载版本的过程也是通过静态分派完成的
- 动态分派
- Java语言里动态分派的实现过程, 它与Java语言多态性的另一个重要体现-重写有着很密切的关联 ```java public class DynamicDispatch { static abstract class Human { protected abstract void sayHello(); } static class Man extends Human { @Override protected abstract void sayHello() { System.out.println(“man say hello”); } } static class Woman extends Human { @Override protected abstract void sayHello() { System.out.println(“woman say hello”); } }
public static void main(String[] args) { Human man = new Man(); Human woman = new Woman(); // man say hello man.sayHello(); // woman say hello woman.sayHello(); man = new Woman(); // woman say hello man.sayHello(); } }
// 字节码部分 // 16和20行的aload指令分别把刚刚创建的两个对象的引用压到栈顶, 这两个对象是将要执行的sayHello()方法的所有者, 称为接收者(Receiver) 16: aload_1 // 17和21行是方法调用指令, 这两条调用指令单从字节码角度来看, 无论是指令还是参数都完全一样, 但这两句指令最终执行的目标方法并不相同 17: invokevirtual #22; // Method DynamicDispatch$Human.sayHello:()V 20: aload_2 21: invokevirtual #22; // Method DynamicDispatch$Human.sayHello:()V
- 根据<<Java虚拟机规范>>, `invokevirtual`指令的运行时解析过程大致微分以下几步:
- 找到操作数栈顶的第一个元素所指向的对象的实际类型, 记作C。
- 如果在类型C中找到与常量中的描述符和简单名称都相符的方法, 则进行访问权限校验, 如果通过则返回这个方法的直接引用, 查找过程结束; 不通过则返回`java.lang.IllegalAccessError`异常
- 否则, 按照继承关系从下往上依次对C的各个父类进行第二步的搜索和验证过程
- 如果始终没有找到合适的方法, 则抛出`java.lang.AbstractMethodError`异常
- `invokevirtual`指令执行的第一步就是在运行期确定接收者的实际类型, 所以两次调用中的`invokevirtual`指令并不是把常量池中方法的符号引用解析到直接引用上就结束了, 还会根据方法接收者的实际类型来选择方法版本, 这个过程就是Java语言中方法重写的本质。在运行期根据实际类型确定方法执行版本的分派过程称为动态分派
- 字段不参与多态, 哪个类的方法访问某个名字的字段时, 该名字指向的就是这个类能看到的那个字段
- 单分派与多分派
- 方法的接收者与方法的参数统称为方法的宗量。根据分派基于多少种宗量, 可以将分派划分为单分派和多分派两种。单分派是根据一个宗量对目标方法进行选择, 多分派则是根据多于一个宗量对目标方法进行选择
- 虚拟机动态分派的实现
- 动态分派是执行非常频繁的动作, 而且动态分派的方法版本选择过程需要运行时在接收者类型的方法元数据中搜索合适的目标方法, 因此, Java虚拟机实现基于执行性能的考虑, 真正运行时一般不会如此频繁地去反复搜索元数据。面对这种情况, 一种基础而且常见的优化手段是为类型在方法区中建立一个虚方法表(Virtual Method Table, 也称为vtable, 与之对应的, 在`invokeinterface`执行时也会用到接口方法表-Interface Method Table, 简称itable), 使用虚方法表索引来代替元数据查找以提高性能
![vtable](http://vencial.com/assets/images/understand_jvm_3/vtable.jpeg)
- 虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写, 那子类的虚方法表中的地址入口和父类相同方法的地址入口是一致的, 都指向父类的实现入口。如果子类中重写了这个方法, 子类虚方法表中的地址也会被替换为指向子类实现版本的入口地址
- 虚方法一般在类加载的连接阶段进行初始化, 准备了类的变量初始值后, 虚拟机会把该类的虚方法表也一同初始化完毕
- 由于Java对象里面的方法默认就是虚方法, 虚拟机除了使用虚方法表之外, 为了进一步提高性能, 也会使用类型继承关系分析、守护内联、内联缓存等多重非稳定的激进优化来争取更大的性能空间
### 8.4.3 java.lang.invoke 包
- java.lang.invoke 包目的是在之前单纯依靠符号引用来确定调用的目标方法这条路之外, 提供一种新的动态确定目标方法的机制, 称为方法句柄(Method Handle)
- `MethodHandle` 和`Refelection`的区别
- `Refelection`和`MethodHandle`机制本质上都是在模拟方法调用, 但是`Refelection`是在模拟Java代码层次的方法调用, 而`MethodHandle`是在模拟字节码层次的方法调用
- `Relection`中的`java.lang.Method`对象远比MethodHandle机制中的`java.lang.invoke.MethodHandle`对象所包含的信息来得多。前者是方法在Java端的全面映象, 包含了方法的签名、描述符以及方法属性表中各种属性的Java端表示方式, 还包含执行权限等的运行期信息
- 由于`MethodHandle`是对字节码的方法指令调用的模拟, 那理论上虚拟机在这方面做的各种优化(如方法内联), 在`MethodHandle`上也应当可以采用类似思路去支持, 而通过反射去调用方法则几乎不可能直接去实施各类调用点优化措施
### 8.4.4 `invokedynamic`指令
- `invokedynamic`指令与`MethodHandle`机制的作用是一样的, 都是为了解决原有4条`invoke*`指令方法分派规则完全固化在虚拟机之中的问题, 把如何查找目标方法的决定权从虚拟机转嫁到具体用户代码之中。
- 第一处含有`invokedynamic`指令的位置都被称作"动态调用点(Dynamically-Computed Call Site)", 这条指令的第一个参数是`CONSTANT_InvokeDynamic_info`常量, 从这个常量中可以得到3项信息: 引导方法(Bootstrap Method, 该方法存放在新增的BootstrapMethods属性中)、方法类型(MethodType)和名称。引导方法是有固定得参数, 并且返回值规定是`java.lang.invoke.CallSite`对象, 这个对象代表了真正要执行得目标方法调用。根据`CONSTANT_InvokeDynamic_info`常量中提供得信息, 虚拟机可以找到并且执行引导方法, 从而获得一个`ClassSite`对象, 最终调用到要执行的目标方法上。
### 8.5 基于栈得字节码解释执行引擎
- HotSpot得模板解释器工作得时候, 是动态产生每条字节码对应的汇编代码来运行
### 8.5.1 解释执行
- 基于物理机、Java虚拟机, 或非Java的其他高级语言虚拟机(HLLVM)的代码执行过程, 大体上都会遵循这种符合现代经典编译原理的思路, 在执行前先对程序源码进行词法分析和语法分析处理, 把源码转化为抽象语法树(Abstract Syntax Tree, AST)。词法、语法分析以后至后面的优化器和目标代码生成器都可以选择独立于执行引擎, 形成一个完整意义的编译器去实现
### 8.5.2 基于栈的指令集与基于寄存器的指令集
- javac编译器输出的字节码指令流, 基本上是一种基于栈的指令集架构(Instruction Set Architecture, ISA), 字节码指令流里面的指令大部分都是零地址指令, 它们依赖操作数栈进行工作。与之相对的另外一套常用的指令架构是基于寄存器的指令集, 最典型的就是x86的二地址指令集, 主流PC机中物理硬件直接支持的指令集架构, 这些指令依赖寄存器进行工作
- 基于栈的指令集与基于寄存器的指令集区别, 如分辨使用两种指令集去计算"1 + 1"的结果
- 基于栈的指令集: `iconst_ 1 iconst_ 1 iadd istore_0`; 两条`iconst_1`指令连续把两个常量1压入栈后, `iadd`指令把栈顶的两个值出栈、相加, 然后把结果放回栈顶, 最后`istore_0`把栈顶的值放到局部变量表的第0个变量槽中。这种指令流中对的指令通常都是不带参数的, 使用操作数栈中的数据作为指令的运算输入, 指令的运算结果也存储在操作数栈之中。
- 而如果用基于寄存器的指令集, 那程序是: `mov eax, 1 add eax, 1`, `mov`指令把EAX寄存器的值设为1, 然后`add`指令再把这个值加1, 结果就保存在EAX寄存器里面。这种二进制指令是x86指令集中的主流, 每个指令都包含两个单独的输入参数, 依赖于寄存器来访问和存储数据
- 基于栈的指令集主要优点是可移植, 因为寄存器由硬件直接提供; 栈架构的指令集还有一些其他的优点, 如代码相对更加紧凑、编译器实现更加简单; 栈架构指令集的主要缺点是理论上执行速度相对来说会稍慢一些, 不过这里的执行速度是要局限在解释执行的状态下, 如果经过即时编译器输出成物理机上的汇编指令流, 那就与虚拟机采用哪种指令集架构没有关系了; 指令数量和内存访问的原因, 导致了栈架构指令集的执行速度会相对慢一点
## 第9章 类加载及执行子系统的案例与实战
### 9.2.2 OSGi: 灵活的类加载架构
- OSGi中每个模块(称为`Bundle`)与普通的Java类库区别并不太大, 两者一般都是以JAR格式进行封装, 并且内部存储都是Java的`Package`和`Class`。但是一个`Bundle`可以声明它所依赖的`Package`, 也可以声明它允许导出发布的`Package`。在OSGi里面, `Bundle`之间的依赖关系从传统的上层模块依赖底层模块转变为平级模块之间的依赖, 而且类库的可见性能得到非常精确的控制, 一个模块里只有被`Export`过的`Package`才可能被外界访问, 其他的`Package`和`Class`将会被隐藏起来
- 在OSGi里, 类加载时可能进行的查找规则如下:
- 以`java.*`开头的类, 委派给父类加载器加载
- 否则, 委派列表名单内的类, 委派给父类加载器加载
- 否则, `Import`列表中的类, 委派给`Export`这个类的`Bundle`的类加载器加载
- 否则, 查找当前`Bundle`的`Classpath`, 使用自己的类加载器加载
- 否则, 查找是否在自己的`Fragment Bundle`中, 如果是则委派给`Fragment Bundle`的类加载器加载
- 否则, 查找`Dynamic Import`列表的`Bundle`, 委派给对应的`Bundle`的类加载器加载
- 否则, 类查找失败
### 9.2.3 字节码生成技术与动态代理的实现
- 动态代理使用`jang.lang.relect.Proxy`和`java.lang.relect.InvocationHandler`接口实现
```java
public class DynamicProxyTest {
interface IHello {
void sayHello();
}
static class Hello implements IHello {
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().getInterface(), this);
}
public Object invoke(Object proxy, Method method, Object[] args) {
System.out.println("welcome");
return method.invoke(originalObj, args);
}
}
public static void main(String[] args) {
IHello hello = (IHello) new DynamicProxy().bind(new Hello());
// 输出结果
// welcome
// hello world
hello.sayHello();
}
}
- 在上述代码里, 唯一的黑匣子就是
Proxy::newProxyInstance()
方法, 除此之外再没有任何特殊之处。这个方法返回一个实现了IHello的接口, 并且代理了new Hello()
实例行为的对象。跟踪这个方法的源码, 可以看到程序进行过验证、优化、缓存、同步、生成字节码、显式类加载等操作, 前面的步骤并不是关注重点, 这里只分析它最后调用sun.misc.ProxyGenerator::generateProxyClass()
方法来完成生成字节码的动作, 这个方法会在运行时产生一个描述代理类的字节码byte[]
数组。 - 加入
System.getProperties().put("sun.misc.ProxyGenerator.saveGeneratedFiles", true);
这局代码后再次运行程序, 磁盘将会产生一个名为$Proxy().class
的代理类Class
文件 - 生成的代理类的实现代码也很简单, 它为传入接口中的每一个方法, 以及从
java.lang.Object
中继承来的equals()
、hashCode()
、toString()
方法都生成了对应的实现, 并且统一调用了InvocationHandler
对象的invoke()
方法来实现这些方法的内容, 各个方法的区别不过是传入的参数和Method
对象有所不同而已, 所以无论调用动态代理的哪一个方法, 实际上都是在执行InvocationHandler::invoke()
中的代理逻辑 - 这个例子中没有降到
generateProxyClass()
方法具体是如何产生代理类$Proxy().class
的字节码的, 大致的生成过程其实就是根据Class
文件格式规范去拼装字节码第10章 前端编译与优化
10.1概述
- 前端编译器把.java文件转化为.class文件的过程; 也可能是指Java虚拟机的即时编译器(JIT)运行期把字节码转变成本地机器码的过程; 还可能是指使用静态的提前编译器(AOT)直接把程序编译成与目标机器指令集相关的二进制代码的过程
10.2.1 Javac的源码与调试
- 从Javac代码的总体结构来看, 编译过程大致可以分为1个准备过程和三个处理过程:
- 准备过程: 初始化插入式注解处理器
- 解析与填充符号表过程, 包括
- 词法、语法分析。将源代码的字符流转变成标记集合, 构造出抽象语法树
- 填充符号表。产生符号地址和符号信息
- 插入式注解处理器的注解处理过程: 插入式注解处理器的执行阶段, 本章的实战部分会设计一个插入式注解处理器来影响Javac的编译行为
- 分析与字节码生成过程, 包括
- 标注检查。对语法的静态信息进行检查
- 数据流及控制流分析。对程序动态运行过程进行检查
- 解语法糖。将简化代码编写的语法糖还原为原有的形式。
- 字节码生成。将前面各个步骤所生成的信息转化成字节码
10.2.2 解析与填充符号表
- 词法、语法分析
- 词法分析是将源代码的字符流转变为标记(Token)集合的过程, 单个字符是程序编写时的最小元素, 但标记才是编译时的最小元素。关键字、变量名、字面量、运算符都可以作为标记, 如
int a = b + 2
这句代码中就包含了6个标记, 分别是int
、a
、=
、b
、+
、2
- 语法分析是根据标记序列构造抽象语法树的过程, 抽象语法树(AST)是一种用来描述程序代码语法结构的树形表示方式, 抽象语法树每一个节点都代表着程序代码的一个语法结构(Syntax Construct), 例如包、类型、修饰符、运算符、接口、返回值甚至连代码注释等都可以是一种特定的语法结构
- 词法分析是将源代码的字符流转变为标记(Token)集合的过程, 单个字符是程序编写时的最小元素, 但标记才是编译时的最小元素。关键字、变量名、字面量、运算符都可以作为标记, 如
- 填充符号表
- 符号表(Symbol Table)是由一组符号地址和符号信息构成的数据结构, 可以把它类比想象成哈希表中键值对的存储形式。在目标代码生成阶段, 当对符号名进行地址分配时, 符号表时地址分配的直接依据
10.2.3 注解处理器
- 符号表(Symbol Table)是由一组符号地址和符号信息构成的数据结构, 可以把它类比想象成哈希表中键值对的存储形式。在目标代码生成阶段, 当对符号名进行地址分配时, 符号表时地址分配的直接依据
- 插入式注解处理器, 可以提前至编译期对代码中的特定注解进行处理, 从而影响到前端编译期的工作过程。我们可以把插入式注解处理器看作是一组编译期的插件, 当这些插件工作时, 允许读取、修改、添加抽象语法树中的任意元素。如果这些插件在处理注解期间对语法树进行过修改, 编译期将回到解析及填充符号表的过程重新处理, 直到所有插入式注解处理器都没有再对语法树进行修改为止, 每一次循环过程称为一个轮次(Round)
- 在Javac源码中, 插入式注解处理器的初始化过程时在
initProcessAnnotations()
方法中完成的, 而它的执行过程则是在processAnnotations()
方法中完成。这个方法会判断是否还有新的注解处理器需要执行, 如果有, 即通过com.sun.tools.javac.processing.JavacProcessingEnvironment
类的doProcessing()
方法来生成一个新JavaCompiler
对象, 对编译的后续步骤进行处理10.2.4 语义分析与字节码生成
- 语义分析的主要任意则是对结构上正确的源程序进行上下文相关性质的检查, 譬如进行类型检查、控制流检查、数据流检查
- 标注检查
- 数据及控制流分析
- 解语法糖
- 字节码生成
- 字节码生成是Javac编译过程的最后一个阶段, 在Javac源码里面由
com.sun.tools.javac.jvm.Gen
类来完成 - 实例构造器
<init>()
方法和类构造器<clinit>()
方法就是在这个阶段被添加到语法树之中的。这里的实例构造器并不等同于默认构造函数, 如果用户代码中没有提供任何构造函数, 如果用户代码中没有提供任何构造函数, 那编译期将会添加一个没有参数的、可访问性与当前类型一致的默认构造函数, 这个工作在填充符号阶段中就已经完成。<init>()
和<clinit>()
这两个构造器的产生实际上是一种代码收敛的过程, 编译器会把语句块、变量初始化、调用父类的实例构造器等操作收敛到<init>()
和<clinit>()
方法之中, 并且保证无论源码中出现的顺序如何, 都一定是按先执行父类的实例构造器, 然后初始化变量, 最后执行语句块的顺序进行10.3.1 泛型
- 字节码生成是Javac编译过程的最后一个阶段, 在Javac源码里面由
- 泛型的本质是参数化类型(Parameterized Type)或参数化多态(Parametric Polymorphism)的应用, 即可以将操作的数据类型指定为方法签名中的一种特殊参数, 这种参数类型能够用在类、接口和方法的创建中, 分别构成泛型类、泛型接口和泛型方法
- Java与C#的泛型
- Java选择的泛型实现方式叫作类型擦拭式泛型(Type Erasure Generics), 而C#选择的泛型实现方式是具现化泛型(Reified Generics)。
- Java语言中的泛型只在源码中存在, 在编译后的字节码文件中, 全部泛型都被替换为原来的裸类型(Raw Type)
- Java的类型擦拭式泛型唯一优势是在于实现这种泛型的影响范围上: 擦拭式泛型的实现几乎只需要在Javac编译器上做出改进即可, 不需要改动字节码、不需要改动Java虚拟机
- 类型擦除
- 裸类型应视为所有该类型泛型化实例的共同父类型(Suer Type), 只有这样,
ArrayList<Integer> ilist = new ArrayList<>(); ArrayList list = ilist;
的赋值才是被系统允许的从子类到父类的安全转型 - 该如何实现裸类型?
- 一种是运行期由Java虚拟机来自动地、真实地构造出
ArrayList<Integer>
这样的类型, 并且自动实现从ArrayList<Integer>
派生自ArrayList
的继承关系来满足裸类型的定义; 另外一种是索性简单粗暴地直接在编译时把ArrayList<Integer>
还原回ArrayList
, 只在元素访问、修改时自动插入一些强制类型转换和检查指令 - 擦除式泛型的缺陷
- 首先, 使用擦除法实现泛型直接导致了对原始类型(Primitive Types)数据的支持又成了新的麻烦, 这种情况下, 到要插入强制转型代码的地方就没办法往下做了, 因为不支持
int
、long
与Object
之间的强制转型。当时Java给出的解决方案一如既往的简单粗暴: 既然没法转换那就索性别支持原生类型的泛型了, 都用ArrayList<Integer>
等, 反正都做了自动的强制类型转换, 遇到原生类型时把装箱、拆箱也自动做了。这个决定后面导致了无数构造类和装箱、拆箱的开销, 称为Java泛型慢的重要原因 - 第二, 运行期无法取得泛型类型信息, 会让一些代码变得相当啰嗦
- 首先, 使用擦除法实现泛型直接导致了对原始类型(Primitive Types)数据的支持又成了新的麻烦, 这种情况下, 到要插入强制转型代码的地方就没办法往下做了, 因为不支持
- 由于Java泛型的引入, 各种场景(虚拟机解析、反射等)下的方法调用都可能对原有基础产生影响并带来新的需求, 如泛型类中如何获取传入的参数化类型等。所以JSP组织对«Java虚拟机规范»做出了相应的修改, 引入了诸如
Signature
、LocalVariableTypeTable
等新的属性用于解决伴随泛型而来的参数类型识别问题,Signature
是其中最重要的一项属性, 它的作用就是存储一个方法在字节码层面的特征签名, 这个属性中保存的参数类型并不是原生类型, 而是包括了参数化类型的信息 - 从
Signature
属性的出现, 擦除法所谓的擦除, 仅仅是对方法的Code属性中的字节码进行擦除, 实际上元数据中还是保留了泛型信息, 这也是在编码时能通过反射手段取得参数化类型的根本依据第11章 后端编译与优化
11.1 概述
- 一种是运行期由Java虚拟机来自动地、真实地构造出
- 裸类型应视为所有该类型泛型化实例的共同父类型(Suer Type), 只有这样,
- 如果把字节码看作是程序语言的一种中间表示形式(Intermediate Representation, IR)的话, 那编译器无论在何时、在何种状态下把
Class
文件转换成与本地基础设施相关的二进制机器码, 它可以视为整个编译过程的后端11.2 即时编译期
- Java程序最初都是通过解释器(Interpreter)进行解释执行的, 当虚拟机发现某个方法或代码块的运行特别频繁, 就会把这些代码认定为热点代码, 为了提高热点代码的执行效率, 在运行时, 虚拟机将会把这些代码编译成本地机器码, 并以各种手段尽可能地进行代码有啊, 运行时完成这个任务的后端编译期被称为即时编译器
11.2.3 编译过程
- 在第一阶段, 一个平台独立的前端将字节码构造成一种高级中间代码表示(HighLevel Intermediate Representation, HIR, 即目标机器指令集无关的中间表示)。HIR使用静态单分配(Static Single Assignment, SSA)的形式来代表代码值, 这可以使得一些在HIR的构造过程之中和之后进行的优化动作更容易实现。在此之前编译器已经会在字节码上完成一部分基础优化, 如方法内联、常量传播等优化将会在字节码被构造成HIR之前完成
- 在第二个阶段, 一个平台相关的后端从HIR中产生低级中间代码表示(Low-Level Intermediate Representation, LIR, 即与目标机器指令集相关的中间表示), 而在此之前会在HIR上完成另外一些优化, 如空值检查消除、范围检查消除等, 以便让HIR达到更高效的代码表示形式
- 最后的阶段是在平台相关的后端使用线性扫描算法在LIR上分配寄存器, 并在LIR上做窥孔优化, 然后产生机器码
11.4.1优化技术概览
- 第一个要进行的优化是方法内联, 它的主要目的有两个: 一是去除方法调用的成本(如查找方法版本、建立栈帧等);二是为其他优化建立良好的基础。方法内联膨胀之后可以便于在更大范围上进行后续的优化手段, 可以获得更好的优化效果
- 第二步进行冗余访问消除
- 第三步进行复写传播
- 第四步进行无用代码消除
第12章 Java内存模型与线程
12.1概述
- 衡量一个服务性能的高低好坏, 每秒事物处理数是重要的指标之一, 它代表着一秒内服务端平均能响应的请求总数
12.2 硬件的效率与一致性
- 由于计算机的存储设备与处理器的运算速度有着几个数量级的差距, 所以现代计算机系统都不得不加入一层或多层读写速度尽可能接近处理器运算速度的高速缓存(Cache)来作为内存与处理器之间的缓冲: 将运算需要使用的数据复制到缓存中, 让运算能快速进行, 当运算结束后再从缓存同步回内存之中, 这样处理器就无须等待缓存的内存读写了
- 基于高速缓存的存储交互很好地解决了处理器与内存速度之间的矛盾, 但也为计算机系统带来更高的复杂度, 它引入了一个新的问题: 缓存一致性(Cache Coherence)。在多路处理器系统中, 每个处理器都有自己的高速缓存, 而它们又共享同一个主内存(Main Memory), 这种系统称为共享内存多核系统(Shared Memory Multiprocessors System)
- 当多个处理器的运算任务都涉及同一块主内存区域时, 将可能导致各自的缓存数据不一致。如果真的发生这种情况, 那同步回到主内存时该以谁的缓存数据为准呢?
- 为了解决一致性的问题, 需要各个处理器访问缓存时都遵循一些协议, 在读写时要根据协议来进行操作, 这类协议有MSI、MESI、MOSI等。
- 除了增加高速缓存之外, 为了使处理器内部的运算单元能尽量被充分利用, 处理器可能会对输入代码进行乱序执行(Out-Of-Order Execution)优化, 处理器会在计算之后将乱序执行的结果重组, 保证该结果与顺序执行的结果是一致的, 但并不保证程序中各个语句计算的先后顺序与输入代码中的顺序一致, 因此如果存在一个计算任务依赖另外一个计算任务的中间结果, 那么其顺序并不能靠代码的先后顺序来保证。与处理器的乱序执行优化类似, Java虚拟机的即时编译器中也有指令重排序优化
12.3.1 主内存与工作内存
- Java内存模型的主要目的是定义程序中各种变量的访问规则, 即关注在虚拟机中把变量值存储到内存和从内存中取出变量值这样的底层细节。Java内存模型规定所有的变量都存储在主内存(Main Memory)中。每条线程还有自己的工作内存, 线程的工作内存中保存了该线程使用的变量的主内存副本, 线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行, 而不能直接写主内存中的数据。不同的线程之间也无法直接访问对方工作内存中的变量, 线程间变量值的传递均需要通过祝内存来完成。
12.3.2 内存间交互操作
- 关于主内存与工作内存之间具体的交互协议, 即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存这一类的实现细节, Java内存模型中定义了以下8种操作来完成。Java虚拟机实现时必须保证下面提及的每一种操作都是原子的、不可再分的
lock
(锁定): 作用于主内存的变量, 它把一个变量标识为一条线程独占的状态unlock
(解锁): 作用于主内存的变量, 它把一个处于锁定状态变量释放出来, 释放后的变量才可以被其他线程锁定read
(读取): 作用于主内存的变量, 它把一个变量的值从主内存传输到线程的工作内存, 以便随后的load
动作使用load
(载入): 作用于工作内存的变量, 它把read
操作从主内存中得到的变量值放入工作内存的变量副本中use
(使用): 作用于工作内存的变量, 它把工作内存中一个变量的值传递给执行引擎, 每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作assign
(赋值): 作用于工作内存的变量, 它把一个从执行引擎接收的值赋给工作内存的变量, 每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作store
(存储): 作用于工作内存的变量, 它把工作内存中一个变量的值传送给主内存中, 以便随后的write
操作使用write
(写入): 作用于主内存的变量, 它把store
操作从工作内存中得到的变量的值放入主内存的变量中12.3.3 对于
volatile
型变量的特殊规则
volatile
可以说是Java虚拟机提供的最轻量级的同步机制; 它具备两项特性: 第一项是保证此变量对所有线程的可见性, 这里的可见性是指当一条线程修改了这个变量的值, 新值对于其他线程来说是可以立即得知(根据«Java虚拟机规范»的约定,volatile
变量依然有工作内存的拷贝, 但是由于它特殊的操作顺序性规定, 所以看起来如同直接在主内存中读写访问一般), 由于volatile
变量只能保证可见性, 在不符合以下两条规则的运算场景中, 仍然要通过加锁来保证原子性:- 运算结果并不依赖变量的当前值, 或能够确保只有单一的线程修改变量的值
- 变量不需要与其他的状态变量共同参与不变约束
volatile
变量的第二个语义是禁止指令重排序优化, 普通变量仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果, 而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致。因为在同一个线程的方法执行过程中无法感知到这点, 这就是Java内存模型中描述的所谓线程内表现为串行的语义- DCL单例模式这个例子, 看字节码通过对比发现, 关键变化在于有
volatile
修饰的变量, 赋值后多了执行一个lock addl $0x0,(%esp)
操作, 这个操作的作用相当于一个内存屏障(Memory Barrier或Memory Fence, 指重排序时不能把后面的指令重排序到内存屏障之前的位置), 只有一个处理器访问时, 并不需要内存屏障; 但如果有两个或更多处理器访问同一块内存, 且其中有一个在观测另一个, 就需要内存屏障来保证一致性了 - 这句指令中的
addl $0x0,(%esp)
(把ESP寄存器的值加0)显然是一个空操作, 之所以用这个空操作而不是空操作专用指令nop
, 是因为IA32手册规定lock
前缀不允许配合nop
指令使用。这里的关键在于lock
前缀, 查询IA32手册可知, 它的作用是将本地处理器的缓存写入内存, 该写入动作也会引起别的处理器或别的内核无效化(Invalidate)其缓存, 这种操作相当于对缓存中的变量做了一次前面介绍Java内存模式中所说的store
和write
操作。所以通过这样一个空操作, 可让前面volatile
变量的修改对其他处理器立即可见 - 在某些情况下,
volatile
的同步机制的性能确实要优于锁(使用synchronized
或者JUC包里边的锁), 但是由于虚拟机对锁实行的许多消除和优化, 使得很难确切地说volatile
就会比synchronized
快多少。如果让volatile
自己与自己比较, 那可以确定一个原则:volatile
变量读操作的性能消耗与普通变量几乎没有什么差别, 但是写操作则可能会慢上一些, 因为它需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行12.3.5 原子性、可见性、有序性
- 原子性(Atomicity)
- 由Java内存模型来直接保证的原子性变量操作包括
read
、load
、assign
、use
、store
和write
, 我们大致可以认为, 基础数据类型的访问、读写都是具备原子性的
- 由Java内存模型来直接保证的原子性变量操作包括
- 可见性(Visibility)
- 可见性就是指当一个线程修改了共享变量的值时, 其他线程能够立即得知这个修改。Java内存模型是通过在变量修改后将新值同步回主内存, 在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介得方式来实现可见性。
- 除了
volatile
, Java还有两个关键字能实现可见性, 它们是synchronized
和final
。同步块得可见性是由对一个变量执行unlock
操作之前, 必须先把此变量同步回主内存中(store
、write
操作)。而final
关键字得可见性是指: 被final
修饰得字段在构造器中一旦被初始化完成, 并且构造器没有把this
引用传递出去, 那么在其他线程中就能看见final
字段得值
- 有序性
- 有序性的总结: 如果在本线程内观察, 所有的操作都是有序的; 如果在一个线程中观察另一个线程, 所有的操作都是无序的。前半句是指线程内似表现为串行的语义(Within-Thread As-If-Serial Semantics), 后半句是指指令重排序现象和工作内存与主内存同步延迟现象
- Java语言提供了
volatile
和synchronized
两个关键字保证线程之间操作的有序性,volatile
关键字本身就包含了禁止指令重排序的语义, 而synchronized
则是由一个变量在同一个时刻只允许一条线程对其进行lock
操作12.3.6 先行发生原则
- 先行发生(Happens-Before)是Java内存模型中定义的两项操作之间的偏序关系, 比如说操作A先行发生于操作B, 其实就是说在发生操作B之前, 操作A产生的影响能被操作B观察到, 影响包括修改了内存中共享变量的值、发送了消息、调用了方法等
- 程序次序规则(Program Order Rule): 在一个线程内, 按照控制流顺序, 书写在前面的操作先行发生于书写在后面的操作。这里说的是控制流顺序而不是程序代码顺序, 因为要考虑分支、循环等结构
- 管程锁定规则(Monitor Lock Rule): 一个
unlock
操作先行发生于后面对同一个锁的lock
操作。 volatile
变量规则(Volatile Variable Rule): 对一个volatile
变量的写操作先行发生于后面对这个变量的读操作- 线程启动规则(Thread Start Rule):
Thread
对象的start()
方法先行发生于此线程的每一个动作 - 线程终止规则(Thread Termination Rule): 线程中的所有操作都先行发生于对此线程的终止检测, 我们可以通过
Thread::join()
方法是否结束、Thread::isAlive()
的返回值等手段检测线程是否已经终止执行 - 线程中断规则(Thread Interruption Rule): 对线程
interrupt()
方法的调用先行发生于被中断线程的代码检测到中断事件的发生, 可以通过Thread::interrupt()
方法检测到是否有中断发生 - 传递性(Transitivity): 如果操作A先行发生于操作B, 操作B先行发生于操作C, 那就可以得出操作A先行发生于操作C的结论
12.4.1 线程的实现
- 线程比进程更轻量级的调度执行单位, 线程的引入, 可以把一个进程的资源分配和执行调度分开, 各个线程既可以共享进程资源(内存地址, 文件I/O等), 又可以独立调度
- 实现线程主要有三中方式: 使用内核线程实现(1:1实现), 使用用户线程实现(1:N实现), 使用用户线程加轻量级进程混合实现(N:M实现)
- 内核线程实现
- 使用内核线程实现的方式也被称为1:1实现。内核线程(Kenel-Level Thread)就是直接由操作系统内核支持的线程, 这种线程由内核来完成线程切换, 内核通过操作调度器(Scheduler)对线程进行调度, 并负责将线程的任务映射到各个处理器上
- 轻量级进程(Light Weight Process, LWP), 轻量级进程就是通常意义上所讲的线程, 由于每个轻量级进程都由一个内核线程支持, 因此只有先支持内核线程, 才能有轻量级进程
- 由于内核线程的支持, 每个轻量级进程都称为一个独立的调度单元, 即使其中某一个轻量级进程在系统调用中被阻塞了, 也不会影响整个进程继续工作。轻量级进程也具备它的局限性: 首先, 由于基于内核线程实现的, 所以各种线程操作, 如创建、析构及同步, 都需要进行系统调用。而系统调用的代价相对较高, 需要在用户态(User Mode)和内核态(Kernel Mode)中来回切换。每个轻量级进程都需要有一个内核线程的支持, 因此轻量级进程要消耗一定的内核资源, 因此一个系统支持轻量级进程的数量是有限的
- 用户线程实现
- 一个线程只要不是内核线程, 都可以认为是用户线程
- Java线程的实现
- 主流商用虚拟机的线程模型普遍都被替换成基于操作系统原生线程模型来实现, 即采用1:1的线程模型
12.4.2 Java线程调度
- 主流商用虚拟机的线程模型普遍都被替换成基于操作系统原生线程模型来实现, 即采用1:1的线程模型
- 线程调度是指系统为线程分配处理器使用权的过程, 调度主要方式有两种, 分别是协同式线程调度和抢占式线程调度
- 协同式多线程的最大好处是实现简单, 坏处是线程执行时间不可控
- 使用抢占式调度的多线程系统, 那么每个线程将由系统来分配执行时间, 线程的切换不由线程本身决定, Java使用的线程调度方式就是抢占式调度
12.4.3 状态装换
- Java语言定义了6种线程状态
- 新建(New): 创建后尚未启动的线程处于这种状态
- 运行(Runnable): 包括操作系统线程状态中的Running和Ready, 也就是处于此状态的线程有可能正在执行, 也有可能正在等待着操作系统为它分配执行时间
- 无限期等待(Waiting): 处于这种状态的线程不会被分配处理器执行时间, 它们要等待被其他线程显式唤醒。以下方法会让线程陷入无限期的等待状态
- 没有设置Timeout参数的
Object::wait()
方法 - 没有设置Timeout参数的
Thread::join()
方法 LockSupport::park()
方法
- 没有设置Timeout参数的
- 限期等待(Timed Waiting): 处于这种状态的线程也不会被分配处理器执行时间, 不过无须等待其他线程显式唤醒, 在一定时间之后它们会由系统自动唤醒。以下方法会让线程进入限期等待状态:
Thread::sleep
方法- 设置了Timeout参数的
Object::wait()
方法 - 设置了Timeout参数的
Thread::join
方法 LockSupport::parkNanos
方法LockSupport::parkUntil
方法
- 阻塞(Blocked): 线程被阻塞了, 阻塞状态与等待状态的区别是阻塞状态在等待着获取到一个排它锁, 这个事件将在另外一个线程放弃这个锁的时候发生; 而等待状态则是等待一段时间, 或唤醒动作的发生。在程序等待进入同步区域的时候, 线程将进入这种状态
- 结束(Terminated): 已终止线程的线程状态, 线程已经结束执行
第13章 线程安全与锁优化
13.2 线程安全
- «Java并发编程实战»的线程安全: 当多个线程同时访问一个对象时, 如果不用考虑这些线程在运行时环境下的调度和交替执行, 也不需要进行额外的同步, 或在调用方进行任何其他的协调操作, 调用这个对象的行为都可以获得正确的结果, 那就称这个对象是线程安全的
13.2.1 Java语言中的线程安全
- 按照线程安全的”安全程度”由强到弱来排序, 可以将Java语言中各种操作共享的数据分为以下五类: 不可变、绝对线程安全、相对线程安全、线程兼容和线程对立
- 不可变
- 不可变(Immutable)的对象一定是线程安全的, 无论是对象的方法实现还是方法的调用者, 都不需要再进行任何线程安全保障措施
- 绝对线程安全
- 不管运行时环境如何, 调用者都不需要任何额外的同步措施, 比如
Vector
是线程安全的容器, 但它不是绝对线程安全, 多线程访问remove()
和get()
时会出现ArrayIndexOutOfBoundsException
异常
- 不管运行时环境如何, 调用者都不需要任何额外的同步措施, 比如
- 相对线程安全
- 它需要保证对这个对象单词的操作是线程安全的, 我们在调用的时候不需要进行额外的保障措施, 但对于一些特定顺序的连续调用, 就可能需要在调用短使用额外的同步手段来保证调用的正确性, 在Java语言中, 大部分声称线程安全的类都属于这种类型, 如
Vector
、HashTable
、Collections
的synchronizedCollection
方法包装的集合
- 它需要保证对这个对象单词的操作是线程安全的, 我们在调用的时候不需要进行额外的保障措施, 但对于一些特定顺序的连续调用, 就可能需要在调用短使用额外的同步手段来保证调用的正确性, 在Java语言中, 大部分声称线程安全的类都属于这种类型, 如
- 线程兼容
- 线程兼容是指对象本身并不是线程安全的, 但可以通过在调用端正确使用同步手段来保证对象在并发环境中可以安全地使用
- 线程对立
- 线程对立是指不管调用端是否采取了同步措施, 都无法在多线程环境中并发使用代码
13.2.2 线程安全的实现方法
- 线程对立是指不管调用端是否采取了同步措施, 都无法在多线程环境中并发使用代码
- 互斥同步
- 互斥同步
- 互斥同步是一种最常见也是最主要的并发正确性保障手段。同步是指在多个线程并发访问共享数据时, 保证共享数据在同一个时刻只被一条线程使用。而互斥是实现同步的一种手段, 临界区、互斥量和信号量都是常见的互斥实现方式
synchronized
关键字经过Javac编译之后, 会在同步块的前后分别形成moniterenter
和moniterexit
这两个字节码指令。这两个字节码指令都需要一个reference
类型的参数来指明要锁定和解锁的对象; 根据«Java虚拟机规范»的要求, 在执行moniterenter
指令时, 首先要去尝试获取对象的锁。如果这个对象没被锁定, 或者当前线程已经持有了那个对象的锁, 就把锁的计数器的值增加一, 而在执行moniterexit
指令时会将锁计数器的值减一- 从执行成本的角度看, 持有锁是一个重量级(Heavy-Weight)的操作。在主流Java虚拟机实现中, Java的线程是映射到操作系统的原生内核线程之上的, 如果要阻塞或唤醒一条线程, 则需要操作系统来帮忙完成, 这就不可避免地陷入用户态到核心态的转换中, 进行这种状态转换需要耗费很多的处理器时间
- 重入锁(ReentrantLock),
ReentrantLock
与synchronized
相比增加了一些高级功能, 主要有:- 等待可中断: 是指当持有锁的线程长期不释放锁的时候, 正在等待的线程可以选择放弃等待, 改为处理其他事情
- 公平锁: 是指多个线程在等待同一个锁时, 必须按照申请锁的时间顺序来依次获得锁; 而非公平锁则不保证这个点, 在锁被释放时, 在锁被释放时, 任何一个等待锁的线程都有机会获得锁。
- 锁绑定多个条件: 是指一个
ReentrantLock
对象可以同时绑定多个Condition
对象。在synchronized
中, 锁对象的wait()
跟它的notify()
或notifyAll()
方法配合可以实现一个隐含的条件, 如果要和多于一个的条件关联的时候, 就不得不额外添加一个锁, 而ReentrantLock
则无须这样做, 多次调用newCodition()
方法即可
- 推荐优先使用
synchronized
的理由synchronized
是Java语法层面的同步, 足够清晰, 也足够简单。Lock
应该确保在finally
块中释放锁, 否则一旦受同步保护的代码块中抛出异常, 则有可能永远不会释放持有锁- 从长远来看, Java虚拟机更容易针对
synchronized
来进行优化
- 互斥同步
- 非阻塞同步
- 基于冲突检测的乐观并发策略, 通俗地说就是不管风险, 先进行操作, 如果没有其他线程争用共享数据, 那操作旧直接成功; 如果共享的数据得确被争用, 产生了冲突, 那再进行其他的补偿措施, 最常用的补偿措施是不断地重试, 直到出现没有竞争的共享数据为止。非阻塞同步, 使用这种措施的代码也常被称为无锁编程
- 无同步方案
- 可重入代码: 这种代码又称纯代码, 是指可以在代码执行的任何时刻中断它, 转而区执行另外一段代码, 而在控制权返回后, 原来的程序不会出现任何错误, 也不会对结果有所影响
- 线程本地存储: 如果一段代码中所需要的数据必须与其他代码共享, 那就看看这些共享数据的代码是否能保证在同一个线程中执行。如果能保证, 我们就可以把共享数据的可见范围限制在同一个线程之内
13.3 锁优化
- HotSpot虚拟机开发团队在1.6版本优化锁, 如适应性自旋、锁消除、锁膨胀、轻量级锁、偏向锁
13.3.2锁消除
- 锁消除是指虚拟机即时编译期在运行时, 对一些代码要求同步, 但是对被检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判定依据来源于逃逸分析的数据支持
13.3.3 锁粗话
- 比如连续调用
StringBuffer#append
方法, 如果虚拟机探测到有这样一串零碎的操作都对同一个对象加锁, 将会把加锁同步的范围扩展(粗化)到整个操作序列的外部13.3.4 轻量级锁
- 轻量级锁是相对于使用操作系统互斥量来实现的传统锁而言, 它的设计初衷是减少传统的重量级锁使用操作系统互斥量产生的性能消耗
- HotSpot虚拟机的对象头分为两部分, 第一部分用于存储对象自身的运行时数据, 如哈希码、GC分代年龄等, 即Mark Word, 另一部分用于存储指向方法区对象类型数据的指针, 如果是数组对象, 还会有一个额外的部分用于存储数组长度
- 轻量级锁的工作过程: 在代码即将进入同步的时候, 如果此同步对象没有被锁定, 虚拟机首先将在当前线程的栈帧中建立一个名为锁对象(Lock Record)的空间, 用于存储锁对象目前的Mark Word; 然后, 虚拟机将使用CAS操作尝试把对象的Mark Word更新为指向Lock Record的指针。如果这个更新动作成功了, 即代表该线程拥有了这个对象的锁, 并且对象Mark Word的锁标志位将转变为00, 表示此对象处于轻量级锁定状态; 如果这个更新操作失败了, 那就意味着至少存在着一条线程与当前线程竞争获取该对象的锁。虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧, 如果是, 说明线程已经拥有了这个对象的锁, 那就直接进入同步块继续执行就可以了, 否则说明这个锁对象已经被其他线程抢占了。如果出现两条以上的线程争用同一个锁的情况, 那轻量级锁就不再有效, 必须要膨胀为重量级锁, 锁标志的状态值变为10, 此时Mark Word中存储的就是指向重量级锁的指针, 后面等待锁的线程也必须进入阻塞状态
- 轻量级锁能提升程序同步性能的依据是: 对于绝大部分的锁, 在整个同步周期内都是不存在竞争的这一经验法则。如果没有竞争, 轻量级锁便通过CAS操作成功避免了使用互斥量的开销; 但如果确实存在锁竞争, 除了互斥量的本身开销外, 还额外发生了CAS操作的开销。因此在有竞争的情况下, 轻量级锁反而会比传统的重量级锁更慢
13.3.5 偏向锁
- 偏向锁的目的是消除数据在无竞争情况下的同步原语, 进一步提高程序的运行性能。如果说轻量级锁是无竞争的情况下使用CAS操作去消除同步使用的互斥量, 那偏向锁就是在无竞争的情况下把整个同步都消除掉, 连CAS操作都不去做了。