第一部分 走进Java
第1章 走进java
1.1 概述
1.2 java技术体系
- java程序设计语言、java虚拟机、java API类库统称为JDK,JDK是用于支持java程序开发的最小环境
- java API类库中的java SE API子集和java 虚拟机统称为JRE,JRE是支持java程序运行的标准环境
1.3 java发展史
1.4 java虚拟机发展史
1.4.1 Sun Classic/Exact VM
1.4.2 Sun HotSpot VM
- SUN JDK和OpenJDK中所带的虚拟机
- 热点代码探测技术:通过执行计数器找出最具有编译价值的代码,然后通知JIT编译器以方法为单位进行编译。通过编译器与解释器协同工作,可在最优化的程序响应时间与最佳执行性能中取得平衡,且无需等待本地代码输出才能执行程序。
1.4.3 Sun Mobi-Embe VM / Meta-Circular VM
1.4.4 BEA JRockit /IBM J9 VM
1.4.5 Azul VM / BEA Liquid VM
1.4.6 Apache Harmony / Google Android Dalvik VM
1.4.7 Microsoft JVM及其它
1.5 展望java技术的未来
1.5.1 模块化
1.5.2 混合语言
1.5.3 多核并行
1.5.4 进一步丰富语法
1.5.5 64位虚拟机
1.6 实战:自己编译JDK
1.6.1 获取JDK源码
1.6.2 系统需求
1.6.3 构建编译环境
1.6.4 进行编译
1.6.5 在IDE工具中进行源码调试
1.7 本章小结
第二部分 自动内存管理机制
第2章 Java内存区域与内存溢出异常
2.1 概述
2.2 运行时数据区域
2.2.1 程序计数器
- 可看作是当前线程所执行的字节码的行号指示器,字节码解释器通过改变计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复都需要依赖它实现
- 每个线程都有独立的程序计数器,各线程之间互不影响,独立存储
- 如果线程正在执行的是java方法,计数器记录的是正在执行的虚拟机字节码指令的地址,如果是Native方法,则计数器值为空(Undefined)
- 唯一一个不会出现OutOfMemoryError的区域
2.2.2 Java虚拟机栈
- 虚拟机栈描述的是java方法执行的内存模型
- 每个方法在执行的同时都会创建一个栈帧用于存储局部变量表、操作数栈、动态链表、方法出口等信息。
- 局部变量表存放了编译器可知的各种基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能指向一个代表对象的句柄或其它与此对象相关的位置)和returnAddress类型(指向了一条字节码指令的地址)
- long和double类型的数据会占用2个局部变量空间(slot),其余只占1个
- 局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,方法运行期间不会改变局部变量表的大小
- 如果线程请求的栈深度大于虚拟机所允许的深度(一般1000-2000),将抛出*Error异常;如果栈拓展时无法申请到足够的内存,会抛出OutOfMemoryError
2.2.3 本地方法栈
- 与虚拟机栈作用相识,区别是为虚拟机使用到的Native方法服务
- 虚拟机规范对本地方法栈中方法使用的语言、使用方式与数据结构并没有强制规定,甚至(如Sun HotSpot)直接把本地方法栈与虚拟机栈合二为一
- 与虚拟机栈一样,也会抛出*Error和OutOfMemoryError异常
2.2.4 java堆
- 被所有线程共享,在虚拟机启动时创建,唯一目的就是存放对象实例,是垃圾收集器管理的主要区域
- 从内存回收的角度看,由于内存收集器基本都采用分类收集算法,所以可分为:新生代和老年代,新生代细致点可分为Eden、Form Survivor、To 空间
- 从内存分配的角度看,线程共享的java堆中可能划分出多个线程私有的分配缓冲区(TLAB)
- java堆可以处于物理上不连续的,只需要逻辑上连续即可
- -Xms和-Xmx可设置java堆大小
2.2.5 方法区
- 与java堆一样,各个线程共享的内存区域,存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据
- java虚拟机规范对方法区的限制非常宽松,甚至可以选择不实现垃圾收集
- 相对而言,垃圾收集行为在这个区域较少出现,该区域的主要回收目标是常量池的回收和类型的卸载,条件相当苛刻
- 旧版本的HotSpot虚拟机选择把GC扩展到方法区,但本质上方法区与堆并不等价,JDK1.7的HotSpot已经把原本放在永久代的字符串常量池移出
- 当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常
2.2.6 运行时常量池
- 运行时常量池是方法区的一部分
- Class文件中除了有类的版本、字段、接口、方法等描述信息外,还有一项信息是常量池,用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放
- 运行时常量池相对于Class文件常量池的另一个特征是具备动态性,java语言可在运行期间将新的常量放入池中,比如String类的intern()方法
- 当常量池无法再申请到内存时,将抛出OutOfMemoryError异常
2.2.7 直接内存
- 例如NIO可以使用Native函数库直接分配堆外内存,然后通过存储在java堆中的DirectByteBuffer对象作为这块内存的引用,但这仍然会受到本机总内存大小以及处理器寻址空间的限制
- 直接内存并不是虚拟机运行时数据区的一部分,也不是java虚拟机规范中定义的内存区域,但使用错误也会出现OutOfMemoryError异常
2.3 HotSpot虚拟机对象探秘
2.3.1 对象的创建
- 一、当虚拟机遇到一条new指令时,首先检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过,如果没有就执行相应的类加载过程
- 二、类加载检查通过后,虚拟机为新生对象分配内存,对象所需的内存大小在类加载完成后便可以完全确定,将一块确定大小的内存从java堆中划分出来。并发下分配完修改指针会出现线程安全问题,解决方案一种是分配内存空间的动作进行同步处理--实际上虚拟机采用cas加上失败重试保证原子性,另一种把内存分配的动作按照线程划分在不同的空间之中进行,每个线程在java堆中预先分配一小块内存,称为本地线程分配缓冲(TLAB),哪个线程需要分配内存,就在哪个TLAB上分配,只有TLAB用完并分配新的TLAB时,才需要同步锁定。虚拟机是否使用TLAB可以通过 -XX:+/-UseTLAB参数来设定
- 三、虚拟机将分配到的内存空间都初始化为零值(不包括对象头),如果使用TLAB,这一工作提前至TLAB分配时进行
- 四、虚拟机对对象进行必要的设置,例如这个对象是哪个类的实例、如何找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息存放在对象头中。
- 五、从虚拟机的角度来看,对象已经产生,但从Java程序视角来看,对象创建才刚刚开始,"init"方法还没执行,所有字段还是零。执行完new指令后接着执行init方法,按照程序员的意愿进行初始化,真正可用的对象才算完全产生
2.3.2 对象的内存布局
- HotSpot虚拟机中,对象在内存中的布局分为3块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
- 对象头(Header):一部分存储对象自身运行时的数据,如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。另一部分是类型指针,指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。如果对象是一个java数组,在对象头中还必须有一块用于记录数组长度,普通Java对象可通过元数据确定大小,但是数组不行
- 实例数据(Instance Data):对象真正储存的有效信息,程序代码中定义的各种字段内容,包含父类继承而来的。
- 对齐填充(Padding):并不是必然存在,占位作用
2.3.3 对象的访问定位
- java程序通过栈上局部变量表的reference数据来操作堆上的具体对象。java虚拟机规范并不关心实现。目前主流的访问方式有句柄访问和直接指针两种
- 句柄访问:java堆中划分出一块内存作为句柄池,reference中存储的就是句柄地址,而句柄中包含了对象实例数据与类型数据具体的地址信息
2.4 实战:OutOfMemoryError异常
2.4.1 java堆溢出
- 对象数量达到最大堆的容量限制后会产生内存异常。
- -XX:+HeapDumpOnOutOfMemoryError可以让虚拟机在出现内存溢出异常时Dump出当前的内存堆转储快照
- 异常内容"java.lang.OutOfMemoryError:Java heap space",需要区分是内存泄漏还是内存溢出
- 内存泄漏:无法被回收导致内存耗尽,可通过工具查看泄露对象到GC Roots的引用链
- 内存溢出:空间太小不够用,调节虚拟机堆参数(-Xmx与-Xms)
2.4.2 虚拟机栈和本地方法栈溢出
- -Xss参数设定栈大小
- 如果线程请求的栈深度大于虚拟机所允许的最大深度(虚拟机默认一般是1000~2000),将抛出"java.lang.*Error"异常
- 如果虚拟机在扩展栈时无法申请到足够的内存空间,将抛出"java.lang.OutOfMemoryError:unable to create new native thread"异常
2.4.3 方法区和运行时常量池溢出
- 运行时常量池是方法区的一部分
- 方法区存放Class相关信息,如类名、访问修饰符、常量池、字段描述、方法描述。反射会动态生成类,这些信息也会填充到方法区导致溢出
- -XX:PermSize和-XX:MaxPermSize设置方法区大小
- 异常内容"java.lang.OutOfMemoryError:PermGen Space"
2.4.4 本机直接内存溢出
- DirectMemory可通过-XX:MaxDirectMemorySize指定,如果不指定,默认与java堆最大值(-Xmx)一样
- 异常内容"java.lang.OutOfMemoryError"无法看见明显的异常
2.5 本章小结
第3章 垃圾收集器与内存分配策略
3.1 概述
3.2 对象已死吗
3.2.1 引用计数法
3.2.2 可达性分析算法
- 定义:通过一系列的称为"GC Roots"的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连(用图论的话说,就是从GC Roots到这个对象不可达)时,则证明此对象是不可用的。
- 在java语言中,可作为GC Roots的对象包括下面几种:
3.2.3 再谈引用
- 引用分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)
- 引用强度:强>软>弱>虚
- 强引用:只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。类似"Object obj = new Object"。
- 软引用:在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。SoftReference类来实现软引用。
- 弱引用:被弱引用关联的对象只能生存到下一次垃圾收集器发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。WeakReference类来实现弱引用。
- 虚引用:一个对象是否有虚引用的存在,不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。PhantomReference类来实现虚引用
3.2.4 生存还是死亡
- finalize()方法是对象逃脱死亡命运的最后一次机会,如果对象要在finalize()中成功拯救自己,只要重新与引用链上的任何一个对象建立关联即可。
- finalize()方法只会执行一次,下一次GC时仍然会被回收掉。
- finalize()运行代价高昂,不确定性大,无法保证各个对象的调用顺序,平时不要使用它。
3.2.5 回收方法区
- 虽然java虚拟机规范不要求虚拟机在方法区实现垃圾收集,但部分虚拟机中仍然实现了方法区的回收。原因是反射、动态代理、CGLib等ByteCode框架、动态生成JSP以及OSGi这类频繁自定义ClassLoader的场景都需要虚拟机具备类卸载的功能,以保证永久代不会溢出。
- 永久代的垃圾收集主要回收两部分内容:废弃常量和无用的类。
- 废弃常量的判定:当前系统中没有任何对象的实例,就会被清理出常量池。
- 无用的类判定:
- 该类的所有实例都已经被回收,也就是java堆中不存在该类的任何实例
- 加载该类的ClassLoader已经被回收
- 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法
3.3 垃圾收集算法
3.3.1 标记--清除算法
- 定义:分为"标记"和"清除"两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。
- 缺点:一是效率问题,标记和清除两个过程的效率都不高;另一个是空间问题,标记清除之后会产生大量不连续的内存碎片,导致分配大对象时无法找到足够的连续内存而提前触发GC。
3.3.2 复制算法
- 定义:它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另一块上面,然后再把已使用过的内存空间一次清理掉。
- 优点:实现简单,运行高效,内存分配时不用考虑内存碎片等复杂情况
- 缺点:内存空间要求高
- 算法步骤:将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor。当回收时,将Eden和Survivor中还存活着的对象一次性地复制到另一快survivor空间上,最后清理掉Eden和刚才用过的Survivor空间。
- 新生代中的对象98%是"朝生夕死",不需要按1:1来划分空间,HotSpot虚拟机默认Eden和Survivor的大小是8:1。
- 当Survivor空间不够用时,需要依赖其它内存(指老年代)进行分配担保
3.3.3 标记--整理算法
3.3.4 分代收集算法
- 根据对象存活周期的不同将内存划分为几块。一般分为"新生代"和"老年代"。
- 新生代:每次垃圾收集都有大批对象死去,只有少量存活,选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集
- 老年代:对象存活率高、没有额外空间进行担保,只能使用"标记--清理"或者"标记--整理"算法
3.4 HotSpot的算法实现
3.4.1 枚举根节点
- 可作为GC Roots的节点主要在全局性的引用(例如常量或类静态属性)与执行上下文(例如栈帧中的本地变量表)中
- 分析GC Roots期间不可以出现对象引用关系仍然变化的情况,必须停顿所有java执行线程(Sun称为"Stop The World")
- 目前主流的java虚拟机使用的都是准确式GC,虚拟机有办法直接得知哪些地方存放着对象引用
- 在HotSpot中,使用一组称为OopMap的数据结构,在类加载完成的时候,HotSpot就把对象内各个偏移量是什么类型计算出来,在JIT编译过程中,也会在特定的位置记录下栈和寄存器中哪些位置是引用。GC在扫描时就可以直接得知这些信息
3.4.2 安全点
- 程序执行时并非在所有地方都能停顿下来开始GC,只有在到达安全点(Safepoint)时才能暂停。安全点的选定既不能太少以至于让GC等待时间太长,也不能过于频繁以至于过分增大运行时的负荷。
- 安全点的选定是以程序"是否具有让程序长时间执行的特征"为标准进行选定的,最明显的特征是指令序列复用,例如方法调用、循环跳转、异常跳转等。
- 在GC发生时需要让所有线程"跑"到最近的安全点上停顿,有两种方案"抢先式中断(Preemptive Suspension)"和"主动式中断(Voluntary Suspension)"
- 抢先式中断:不需要线程的执行代码主动去配合,在GC发生时,首先把所有线程全部中断,如果发现有线程中断的地方不在安全点上,就恢复线程,让它"跑"到安全点上。现在几乎没有虚拟机采用这种方式
- 主动式中断:当GC需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志,各个线程执行时主动去轮询这个标志,发现中断标记被触发时就自己中断挂起。轮询标志的地方和安全点是重合的,另外再加上创建对象需要分配内存的地方
3.4.3 安全区域
- 定义:安全区域是指在一段代码片段之中,引用关系不会发生变化。在这个区域中的任意地方开始GC都是安全的。
- 来源:部分线程处于"不执行"(比如Sleep或者Blocked)的状态,这时候线程无法响应JVM的中断请求,需要安全区域(Safe Region)来解决。
- 步骤:在线程执行到Safe Region中的代码时,首先标识自己已经进入了Safe Region,那样,当在这段时间里JVM要发起GC时,就不用管标识自己为Safe Region状态的线程了。在线程要离开Safe Region时,它要检查系统是否已经完成了根节点枚举(或者时整个GC过程),如果完成了,那线程就继续执行,否则它就必须等待直到收到可以安全离开Safe Region的信号位置。
3.5 垃圾收集器
3.5.1 Serial收集器
- 单线程收集器,使用一个CPU或一条收集线程去完成收集工作。在进行垃圾收集时,必须暂停其它所有的工作线程,直到它收集结束。
- 优点:简单而高效(与其它收集器处于单线程的情况下相比,没有线程交互的开销),是虚拟机运行在Client模式下的默认新生代收集器
3.5.2 ParNew收集器
- Serial收集器的多线程版本,除了使用多线程进行垃圾收集之外,其余行为包括Serial收集器可用的所有控制参数(例如:-XX:SurvivorRatio、-XX:PretenureSizeThreshold、-XX:HandlePromotionFailure等)、收集算法、Stop The World、对象分配规则、回收策略都与Serial收集器完全一样。
- 它是许多运行在Server模式下的虚拟机首选的新生代收集器。其中与性能无关的原因是,除了Serial收集器,目前只有它能与CMS收集器配合工作。
- 控制参数:
- -XX:+UseConcMarkSweepGC:使用此命令后ParNew会默认为新生代收集器
- -XX:+UseParNewGC:强制使用ParNew收集器
- -XX:+ParallelGCThreads:限制垃圾收集的线程数
3.5.3 Parallel Scavenge收集器
- -XX:MaxGCPauseMillis:最大垃圾收集停顿时间,大于0的毫秒数。GC停顿时间的缩短是以牺牲吞吐量和新生代空间换来的。
- -XX:GCTimeRatio:设置吞吐量大小,是一个大于0且小于100的整数
- -XX:+UseAdaptiveSizePolicy:开关参数,GC自适应调节策略,根据当前系统的运行情况自动调整参数(新生代大小-Xmm、Eden与Survivor区的比例-XX:SurvivorRatio、晋升老年代对象大小-XX:PertenureSizeThreshold等细节)以提供最合适的停顿时间或者最大的吞吐量。
3.5.4 Serial Old收集器
- Serial收集器的老年代版本,单线程收集器,使用"标记--整理"算法,主要意义在于给Client模式下的虚拟机使用
- Server模式下用途:一是JDK1.5以及之前的版本中与Parallel Scavenge收集器搭配使用,二是作为CMS收集器的后续预案
3.5.5 Parallel Old收集器
- Parallel Scavenge的老年代版本,使用"标记--整理"算法
- 同样是"吞吐量优先",在注重吞吐量以及CPU资源敏感的场合,可优先考虑Parallel Scavenge加Parallel Old收集器
3.5.6 CMS收集器
- CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。重视响应速度、希望系统停顿时间最短是可考虑使用
- CMS收集器是基于"标记--清除"算法实现的,整个过程分为4个步骤
- 初始标记:单线程,"Stop The World",速度很快,标记GC Roots能直接关联到的对象
- 并发标记:单线程,可与其它线程并行,耗时较长,GC Roots Tracing,追踪其它关联对象。
- 重新标记:多线程,"Stop The World",耗时比初始标记长,远低于并发标记,修正并发标记期间由于程序继续运行所导致的标记变动。
- 并发清除:单线程,可与其它线程并行,耗时较长,清理对象
- 对CPU资源非常敏感,并发阶段因为占用一部分线程(或者说CPU资源)导致应用程序变慢。
- 无法处理浮动垃圾(Floating Garbage),可能出现"Concurrent Mode Failue"失败导致另一次Full GC的产生。并发清理阶段程序产生了新的垃圾,如果CMS运行期间预留的内存无法满足程序要求,就会出现"Concurrent Mode Failue"失败
- CMS是"标记--清除"实现的收集器,会导致大量碎片空间。-XX:+UseCMSCompactAtFullCollection(默认开启)CMS开启内存碎片合并,会导致停顿时间变长。-XX:CMSFullGCsBeforeCompaction用于设置执行多少次不压缩的Full GC后,来一次压缩的(默认为0,每次都要整理)
3.5.7 G1收集器
- 前沿成果之一,特点并行与并发、分代收集、空间整合、可预测停顿。但还不够成熟,暂时还未大规模使用
- 将整个java堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分Region(不需要连续)的集合。
- 运作大致分为以下步骤
- 初始标记:单线程,"Stop The World",速度很快,标记GC Roots能直接关联到的对象,修改TAMS(Next Top at Mark Start)的值,让下一阶段程序并发运行时,能在正确可用的Region中创建新对象
- 并发标记:单线程,可与其它线程并行,耗时较长,
- 最终标记:多线程,"Stop The World",修正并发标记期间由于程序继续运行所导致的标记变动。
- 筛选回收:首先对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间来制定回收计划
3.5.8 理解GC日志
33.125:[GC[DefNew:3324K->152K(3712K),0.0025925secs]3324K->152K(11904K),0.0031680 secs] 100.667:[FullGC[Tenured:0K->210K(10240K),0.0149142secs]4603K->210K(19456K),[Perm:2999K->2999K(21248K)],0.0150007 secs][Times:user=0.01 sys=0.00,real=0.02 secs]
最前面的数字"33.125:"和"100.667:"代表了GC发生的时间
3.5.9 垃圾收集器参数总结
参数 |
描述 |
UseSerialGC |
虚拟机运行在Client模式下的默认值,打开此开关后,使用Serial + Serial Old的收集器组合进行内存回收 |
UseParNewGC |
打开此开关后,使用ParNew + Serial Old的收集器组合进行内存回收 |
UseConcMarkSweepGC |
打开此开关后,使用ParNew+ CMS + Serial Old的收集器组合进行内存回收。Serial Old收集器将作为CMS收集器出现Concurrent Mode Failure失败后的后备收集器使用 |
UseParallelGC |
虚拟机运行在Server模式下的默认值,打开此开关后,使用Parallel Scavenge + Serial Old (PS Mark Sweep)的收集器组合进行内存回收 |
UserParallelOldGC |
打开此开关后,使用Parallel Scavenge + Parallel Old的收集器组合进行内存回收 |
SurvivorRatio |
新生代中Eden区域与Survivor区域的容量比值,默认为8,代表Eden: Survivor = 8:1 |
PretenureSizeThreshold |
直接晋升到老年代的对象大小,设置这个参数后,大于这个参数的对象将直接在老年代分配 |
MaxTenuringThreshold |
晋升到老年代的对象年龄。每个对象在坚持过一次Minor GC之后,年龄就增加1,当超过这个参数值时就进入老年代 |
UseAdaptiveSizePolicy |
动态调整Java堆中各个区域的大小以及进入老年代的年龄 |
HandlePromotionFailure |
是否允许分配担保失败,即老年代的剩余空间不足以应付新生代的整个Eden和Survivor区的所有对象都存活的极端情况 |
ParallelGCThreads |
设置并行GC时进行内存回收的线程数 |
GCTimeRatio |
GC时间占总时间的比率,默认值是99, 即允许1%的GC时间。仅在使用Parallel Scavenge收集器时生效 |
MaxGCPauseMillis |
设置GC的最大停顿时间。仅在使用Parallel Scavenge收集器时生效 |
CMSInitiatingOccupancyFraction |
设置CMS收集器在老年代时间被使用多少后触发垃圾收集。默认值为68%,仅在使用CMS收集器时生效 |
UseCMSCompactAtFullCollection |
设置CMS收集器在完成垃圾收集后是否要进行一次内存碎片整理。仅在使用CMS收集器时生效 |
CMSFullGCsBeforeCompaction |
设置CMS收集器在进行若干次垃圾收集后再启动一次内存碎片整理,仅在使用CMS收集器时生效 |
3.6 内存分配与回收策略
3.6.1 对象优先在Eden分配
- 大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够空间进行分配时,虚拟机将发起一次MinorGC
- 新生代GC(Minor GC):发生在新生代的垃圾回收动作,这里java对象大多都是朝生夕灭,所以Minor GC非常频繁,一般回收速度也比较快。
- 老年代GC(Major GC / Full GC):发生在老年代的GC,出现了Major GC,经常会伴随至少一次的Minor GC。Major GC的速度一般会比Minor GC慢10倍以上。
3.6.2 大对象直接进入老年代
3.6.3 长期存活的对象将进入老年代
-XX:MaxTenuringThreshold:晋升老年代的年龄阈值。大于0的整数
3.6.4 动态对象年龄判定
- 虚拟机并不是永远地要求对象的年龄必须到达MaxTenuringThreshold才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代
3.6.5 空间分配担保
- JDK 6 Update 24之前在发生Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么Minor GC可以确保是安全的。如果不成立,则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次Minor GC,尽管这次Minor GC是有风险的;如果小于,或者HandlePromotionFailure设置不允许冒险,那这时也要改为进行一次Full GC。
- JDK 6 Update 24之后的规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行Minor GC,否则将进行Full GC
3.7 本章小结
第4章 虚拟机性能监控与故障处理工具
4.1 概述
4.2 JDK的命令行工具
名称 |
主要作用 |
jvm process status tool,显示指定系统内所有的hotspot虚拟机进程 |
|
jvm statistics monitoring tool,用于收集hotspot虚拟机各方面的运行数据 |
|
configuration info for java,显示虚拟机配置信息 |
|
memory map for java,生成虚拟机的内存转储快照(heapdump文件) |
|
jvm heap dump browser,用于分析heapmap文件,它会建立一个http/html服务器 让用户可以在浏览器上查看分析结果 |
|
stack trace for java ,显示虚拟机的线程快照 |
4.2.1 jsp:虚拟机进程状况工具
属性 |
作用 |
-q |
只输出LVMID,省略主类的名称 |
-m |
输出虚拟机进程启动时传递给主类main()函数的参数 |
-l |
输出主类的全名,如果进程执行的是jar包,输出jar路径 |
-v |
输出虚拟机进程启动时jvm参数 |
4.2.2 jstat:虚拟机统计信息监视工具
- jstat是用于监视虚拟机各种运行状态信息的命令行工具。它可以显示本地或者远程虚拟机进程中的类装载、内存、垃圾回收、JIT编译等运行数据,在没有GUI图形界面,只是提供了纯文本控制台环境的服务器上,它将是运行期定位虚拟机性能问题的首选工具
选项 |
作用 |
-class |
监视装载类、卸载类、总空间以及类装载所耗费的时间 |
-gc |
监视java堆状况,包括eden区、两个survivor区、老年代、永久代等的容量、已用空间、GC时间合计信息 |
-gccapacity |
监视内容与-gc基本相同,但输出主要关注java堆各个区域使用到最大、最小空间 |
-gcutil |
监视内容与-gc基本相同,但输出主要关注已使用控件占总空间的百分比 |
-gccause |
与-gcutil功能一样,但是会额外输出导致上一次gc产生的原因 |
-gcnew |
监视新生代GC情况 |
-gcnewcapacity |
监视内容与-gcnew基本相同,输出主要关注使用到的最大、最小空间 |
-gcold |
监视老年代GC情况 |
-gcoldcapacity |
监视内容与-gcold基本相同,输出主要关注使用到的最大、最小空间 |
-gcpermcapacity |
输出永久代使用到的最大、最小空间 |
-compiler |
输出JIT编译过的方法、耗时等信息 |
-printcompilation |
输出已经被JIT编译过的方法 |
4.2.3 jinfo:java配置信息工具
4.2.4 jmap:java内存映像工具
选项 |
作用 |
-dump |
生成java堆转储快照。格式为: -dump:[live,]format=b,file=<filename>,其中live子参数说明是否只dump出存活的对象 |
-finalizerinfo |
显示在F-Queue中等待Finalizer线程执行finalize方法的对象。只在Linux/Solaris平台下有效 |
-heap |
显示java堆详细信息,如使用哪种收集器、参数配置、分代情况等,在Linux/Solaris平台下有效 |
-jisto |
显示堆中对象统计信息,包含类、实例对象、合集容量 |
-permstat |
以ClassLoader为统计口径显示永久代内存状态。只在Linux/Solaris平台下有效 |
-F |
当虚拟机进程对-dump选项没有相应时。可使用这个选项强制生成dump快照。只在Linux/Solaris平台下有效 |
4.2.5 jhat:虚拟机堆转储快照分析工具
- Sun JDK提供jhat与jmap搭配使用,来分析dump生成的堆快照。jhat内置了一个微型的HTTP/HTML服务器,生成dump文件的分析结果后,可以在浏览器中查看。(没什么卵用,*才用浏览器看文件)
4.2.6 jstack:java堆栈跟踪工具
- jstack命令用于生成虚拟机当前时刻的线程快照。线程快照就是当前虚拟机内每一条线程正在执行的方法堆栈集合,生成线程快照的主要目的是定位线程出现长时间停顿的原因,如线程死锁、死循环、请求外部资源导致长时间等待等。
选项 |
作用 |
-F |
当正常输出的请求不被响应时,强制输出线程堆栈 |
-l |
除堆栈外,显示关于锁的附加信息 |
-m |
如果调用到本地方法的话,可以显示c/c++的堆栈 |
4.2.7 HSDIS:JIT生成代码反汇编
4.3 JDK的可视化工具
4.3.1 JConsole:java监视与管理控制台
- JConsole(Java Monitoring and Management Console)是一种基于JMX的可视化监视、管理工具。
- JDK/bin目录下的"jconsole.ext"
- 主要功能:内存监控、线程监控(死锁详情查看)
4.3.2 VisualVM:多合一故障处理工具
4.4 本章小结
第5章 调优案例分析与实战
5.1 概述
5.2 案例分析
5.2.1 高性能硬件上的程序部署策略
- 原因:业务导致频繁创建生命周期短的大量大对象,导致老年代被快速塞满,GC频繁
- 初始解决方案:启用内存更大的64位JDK,但是效果并不明显,原因是64位的JDK性能提升并不大。
- 最终解决方案:使用若干个32位虚拟机建立逻辑集群来利用硬件资源
- 重点:在32位Windows平台中每个进程只能使用2GB的内存,考虑到堆以外的内存开销,堆一般最多只能开到1.5GB。在某些Linux或UNIX系统(如Solaris)中,可以提升到3GB乃至接近4GB的内存,但32位中仍然受最高4GB(2^32)内存的限制。
5.2.2 集群间同步导致的内存溢出
- 原因:使用了JBossCache作为缓存,但是JBoss之间会频繁通信,如果通信不通畅,发送的信息会保留在内存中,时间久了就会发生内存溢出
- 解决方案:用Redis哇。这是个死循环,JBoss自己也没法没完全解决
5.2.3 堆外内存导致的溢出错误
- 原因:堆外内存(Direct Memory)并不在堆内存中被分配,但同样受线程总空间和Full GC影响,当对外内存满了,即使堆中仍然有内存,也无法被使用和清除导致内存溢出
- 解决方案:调节堆大小,或者把堆外内存改为其它方案
- 重点:NIO操作需要使用到堆外内存,使用时需要注意
5.2.4 外部命令导致系统缓慢
- 原因:每个用户请求的处理都需要执行一个外部shell脚本来获得系统的一些信息。执行这个shell脚本是通过Java的Runtime.getRuntime().exec()方法来调用的。这种调用方式可以达到目的,但是它在Java虚拟机中是非常消耗资源的操作,即使外部命令本身能很快执行完毕,频繁调用时创建进程的开销也非常大。
- 解决方案:去掉这个Shell脚本执行的语句,改为使用Java的API去获取这些信息
- 重点:Java虚拟机执行这个命令的过程是:首先克隆一个和当前虚拟机拥有一样环境变量的进程,再用这个新的进程去执行外部命令,最后再退出这个进程。如果频繁执行这个操作,系统的消耗会很大,不仅是CPU,内存负担也很重。
5.2.5 服务器JVM进程崩溃
5.2.6 不恰当数据结构导致内存占用过大
- 原因:业务需要频繁加载数据,频繁创建100多万个HashMap<Long,Long>Entry,导致Minor GC频繁,而且GC时间长达500毫秒
- 解决方案:不修改代码的前提,将Survivor空间去掉,让新生代中存活的对象在第一次Minor GC后立即进入老年代,等到Major GC的时候再清理掉他们
- 重点:慎用HashMap,分析一下空间效率。在HashMap<Long,Long>结构中,只有Key和Value所存放的两个长整型数据是有效数据,共16B(2×8B)。这两个长整型数据包装成java.lang.Long对象之后,就分别具有8B的MarkWord、8B的Klass指针,在加8B存储数据的long值。在这两个Long对象组成Map.Entry之后,又多了16B的对象头,然后一个8B的next字段和4B的int型的hash字段,为了对齐,还必须添加4B的空白填充,最后还有HashMap中对这个Entry的8B的引用,这样增加两个长整型数字,实际耗费的内存为(Long(24B)×2)+Entry(32B)+HashMapRef(8B)=88B,空间效率为16B/88B=18%,实在太低了。
5.2.7 有Windows虚拟内存导致的长时间停顿
- 原因:Window GUI桌面程序最小化的时候,它的工作内存被自动交换到磁盘的页面文件之中,如果期间发生GC,需要因为恢复页面文件而导致GC长时间停顿
- 解决方案:加入参数"Dsun.awt.keepWorkingSetOnMinimize=true"来解决。这个参数在许多AWT的程序上都有应用,例如JDK自带的Visual VM,用于保证程序在恢复最小化时能够立即响应。
5.3 实战:Eclipse运行速度调优
5.3.1 调优前的程序运行状态
5.3.2 升级JDK1.6的性能变化及兼容问题
- JDK升级影响巨大,不要轻易升级
- 最大老年代空间设置失效,手动设置永久代大小(-XX:MaxPermSize=256M)。原因是JDK5与JDK6的公司不同,导致eclipse对JDK5的特殊处理无效
5.3.3 编译时间和类加载时间的优化
- 类加载时间:笔者发现,JDK在自己机器环境(并不具有普遍性)的类加载速度没有明显提升,取消了字节码验证(-Xverify:none),稍微提升了一点点速度
- 编译时间:clint(单核)和server(多核)有不同的编译器,效果也不同。
- 理解Hotspot虚拟机的来由:JDK1.2以后,虚拟机内置了两个运行时编译器,如果一个java方法被调用次数达到一定程度,就被被判定为热点代码交给JIT编译器即时编译为本地代码,提高运行速度。
5.3.4 调整内存设置控制垃圾收集频率
- 老年代空间不足但是未达到最大设置值时,会导致Full GC,可以直接设置老年代为最大值避免扩容导致的GC(-:Xms = -:Xmx,-XX:PermSize = -XX:MaxPermSize)
- 代码中的System.gc()可以用参数屏蔽(-XX:+DisableExplicitGC),但是慎用!
5.3.5 选择收集器降低延迟
选择青年代老年代垃圾收集器有不同的效果。比如开发环境操作频繁,一般都是一边编译一边工作,CMS最合适此场景(-XX:UseConcMarkSweepGC、-XX:+UseParNewGC)
5.4 本章小结
第三部分 虚拟机执行子系统
第6章 类文件结构
6.1 概述
6.2 无关性的基石
- 各种不同平台的虚拟机与所有平台都统一使用的程序存储格式--字节码(ByteCode)是构成平台无关性的基石
- java语言中的各种变量、关键字和运算符号的语义最终都是由多条字节码命令组合而成的,因此字节码命令所能提供的语义描述能力比java语言本身更加强大
6.3 Class类文件的结构
- 任何一个Class文件都对应着唯一一个类或接口的定义信息,但反过来说,类或接口并不一定都得定义在文件里(譬如类或接口也可以通过类加载器直接生成)
- Class文件是一组以8位字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑地排列在Class文件中,中间没有添加任何分隔符,这使得整个Class文件中存储的内容几乎全部是程序运行的必要数据,没有空隙存在。
- 根据java虚拟机规范的规定,Class文件格式采用一种类似于C语言结构体的伪结构来存储数据,这种伪结构中只有两种数据类型:无符号数和表
- 无符号数:属于基本的数据类型,以u1、u2、u4、u8来分别代表1个字节、2个字节、4个字节和8个字节的无符号数,无符号数可以用来描述数字、索引引用、数量值或者按照UTF-8编码构成字符串值
- 表:由多个无符号或者其他表作为数据项构成的复合数据类型,所有表都习惯性地以"_info"结尾。表用于描述有层次关系的复合结构数据,整个Class文件本质上就是一张表。(想象XML)
6.3.1 魔数与Class文件的版本
- 每个Class文件的头4个字节称为魔数(Magic Number),它的唯一作用是确定这个文件是否为一个能被虚拟机接受的Class文件
- 魔数值固定为 0xCAFEBABE,紧接着魔数之后的4个字节为Java版本信息:第5和第6个字节是次版本号(minor_version),第7和第8个字节是主版本号(major_version)
6.3.2 常量池
- 紧接着主次版本号之后的是常量池入口,常量池可以理解为Class文件之中的资源仓库,它是Class文件结构中与其它项目关联最多的数据类型,也是占用Class文件空间最大的数据项目之一
- 由于常量池中常量的数量是不固定的,所以在常量池的入口需要放置一项u2类型的数据,代表常量池容量计数值,在Class文件格式规范制定之时,设计者将第0项常量空出来是有特殊考虑的,这样做的目的在于满足后面某些指向常量池的索引值的数据在特定情况下需要表达"不引用任何一个常量池项目"的含义,这种情况就可以把索引值置为0来表示。根本原因在于,索引为0也是一个常量(保留常量),只不过它不位于常量表中。这个常量就对应Null值,所以常量池的索引从1而非0开始。
- 常量池中主要存放两大类常量:字面量和符号引用
类型 |
标志 |
描述 |
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_MothodType_info |
16 |
标志方法类型 |
CONSTANT_MethodHandle_info |
15 |
表示方法句柄 |
CONSTANT_InvokeDynamic_info |
18 |
表示一个动态方法调用点 |
6.3.3 访问标志
- 在常量池结束之后,紧接着的两个字节代表访问标志,这个标志用于识别一些类或者接口层次的访问信息,包括:这个Class是类还是接口;是否定义为public类型;是否定义为abstract类型;如果是类的话,是否被申明为final等
6.3.4 类索引、父类索引与接口索引集合
6.3.5 字段表集合
- 字段表用于描述接口或者类中声明的变量。字段(field)包括类级变量以及实例级变量,但不包括在方法内部申明的局部变量。
- 结构包含有:访问标志(access_flags)、名称索引(name_index)、描述符索引(descriptor_index)、属性表集合(attributes)
- 包含的信息有:字段的作用域(public、private、protected修饰符)、是实例变量还是类变量(static修饰符)、可变性(final)、并发可见性(volatile修饰符,是否强制从主内存读写)、可否被序列化(transient修饰符)、字段数据类型(基本类型、对象、数组)、字段名称
- 注意:字段表集合中不会列出从超类或者父类接口中继承而来的字段,但有可能列出原本java代码之中不存在的字段,譬如在内部类中为了保持对外部类的访问性,会自动添加指向外部类实例的字段了。
6.3.6 方法表集合
- 结构与字段表相同,包含有:访问标志(access_flags)、名称索引(name_index)、描述符索引(descriptor_index)、属性表集合(attributes)
- 方法里的java代码,经过编译器编译成字节码指令后,存放在方法属性表集合中一个名为"Code"的属性里面
- 如果父类方法在子类中没有被重写(Override),方法表集合中就不会出现来自父类的方法信息
6.3.7 属性表集合
属性名称 |
使用位置 |
含义 |
Code |
方法表 |
Java代码编译成的字节码指令 |
ConstantValue |
字段表 |
final关键字定义的常量值 |
Deprecated |
类、方法表、字段表 |
被声明为deprecated的方法和字段 |
Exceptions |
方法表 |
方法抛出的异常 |
EnclosingMethod |
类文件 |
仅当一个类为局部类或者匿名类时才能拥有这个属性,这个属性用于标识这个类所在的外围方法 |
InnerClasses |
类文件 |
内部类列表 |
LineNumberTable |
Code属性 |
Java源码的行号与字节码指令的对用关系 |
LocalVariableTable |
Code属性 |
方法的局部变量描述 |
StackMapTable |
Code属性 |
JDK1.6中新增的属性,供新的类型检查验证器(Type Checker)检查和处理目标方法的局部变量和操作数栈所需要的类型是否匹配 |
Signature |
类、方法表、字段表 |
JDK1.5中新增的属性,这个属性用于支持泛型情况下的方法签名,在Java语言中,任何类、接口、初始化方法或成员的泛型签名如果包含了类型变量(Type Variables)或参数化类型(Parameterized Types),则Signature属性会为他记录泛型签名信息。由于Java的泛型采用擦除法实现,在为了避免类型信息被擦出后导致签名混乱,需要这个属性记录泛型中的相关信息 |
SourceFile |
类文件 |
记录源文件名称 |
SourceDebugExtension |
类文件 |
JDK 1.6中新增的属性,SourceDebugExtension属性用于存储额外的调试信息,譬如在进行JSP文件调试时,无法同构Java堆栈来定位到JSP文件的行号,JSR-45规范为这些非Java语言编写,却需要编译成字节码并运行在Java虚拟机中的程序提供了一个进行调试的标准机制,使用SourceDebugExtension属性就可以用于存储这个标准所新加入的调试信息 |
Synthetic |
类、方法表、字段表 |
标识方法或字段为编译器自动生成的 |
LocalVariableTypeTable |
类 |
JDK 1.5中新增的属性,他使用特征签名代替描述符,是为了引入泛型语法之后能描述泛型参数化类型而添加 |
RuntimeVisibleAnnotations |
类、方法表、字段表 |
JDK 1.5中新增的属性,为动态注解提供支持。RuntimeVisibleAnnotations属性用于指明哪些注解是运行时(实际上运行时就是进行反射调用)可见的 |
RuntimeInVisibleAnnotations |
类、方法表、字段表 |
JDK 1.5新增的属性,与RuntimeVisibleAnnotations属性作用刚好相反,用于指明哪些注解是运行时不可见的 |
RuntimeVisibleParameter Annotations |
方法表 |
JDK 1.5新增的属性,作用与RuntimeVisibleAnnotations属性类似,只不过作用对象为方法参数 |
RuntimeInVisibleAnnotations Annotations |
方法表 |
JDK 1.5中新增的属性,作用与RuntimeInVisibleAnnotations属性类似,只不过作用对象为方法参数 |
AnnotationDefault |
方法表 |
JDK 1.5中新增的属性,用于记录注解类元素的默认值 |
BootstrapMethods |
类文件 |
JDK 1.7中新增的属性,用于保存invokedynamic指令引用的引导方法限定符 |
6.4 字节码指令简介
6.4.1 字节码与数据类型
- 对于大部分与数据类型相关的字节码指令,他们的操作码助记符中都有特殊的字符来表明专门为哪种数据类型服务:i代表对int类型的数据操作,l代表Long,s代表short,b代表byte,c代表char,f代表float,d代表double,a代表reference。
- 大部分的指令都没有支持整数类型byte、char和short,甚至没有任何指令支持boolean类型。编译器在编译期或运行期将byte和short类型数据带符号扩展为相应的int类型数据,将boolean和char类型数据零位扩展为相应的int类型数据。
6.4.2 加载和存储指令
- 将一个局部变量加载到操作数栈的指令包括:iload,iload_<n>,lload、lload_<n>、float、 fload_<n>、dload、dload_<n>,aload、aload_<n>。
- 将一个数值从操作数栈存储到局部变量表的指令:istore,istore_<n>,lstore,lstore_<n>,fstore,fstore_<n>,dstore,dstore_<n>,astore,astore_<n>
- 将常量加载到操作数栈的指令:bipush,sipush,ldc,ldc_w,ldc2_w,aconst_null,iconst_ml,iconst_<i>,lconst_<l>,fconst_<f>,dconst_<d>
- 局部变量表的访问索引指令:wide
6.4.3 运算指令
- 1)加法指令:iadd,ladd,fadd,dadd
- 2)减法指令:isub,lsub,fsub,dsub
- 3)乘法指令:imul,lmul,fmul,dmul
- 4)除法指令:idiv,ldiv,fdiv,ddiv
- 5)求余指令:irem,lrem,frem,drem
- 6)取反指令:ineg,leng,fneg,dneg
- 7)位移指令:ishl,ishr,iushr,lshl,lshr,lushr
- 8)按位或指令:ior,lor
- 9)按位与指令:iand,land
- 10)按位异或指令:ixor,lxor
- 11)局部变量自增指令:iinc
- 12)比较指令:dcmpg,dcmpl,fcmpg,fcmpl,lcmp
- 当一个操作产生溢出时,将会使用有符号的无穷大来表示,如果某个操作结果没有明确的数学定义的话,将会使用NaN值来表示。所有使用NaN值作为操作数的算术操作,结果都会返回NaN
6.4.4 类型转换指令
- 1)int类型到long,float,double类型
- 2)long类型到float,double类型
- 3)float到double类型
- 将int 或 long 窄化为整型T的时候,仅仅简单的把除了低位的N个字节以外的内容丢弃,N是T的长度。这有可能导致转换结果与输入值有不同的正负号。
6.4.5 对象创建与访问指令
- 1)创建实例的指令:new
- 2)创建数组的指令:newarray,anewarray,multianewarray
- 3)访问字段指令:getfield,putfield,getstatic,putstatic
- 4)把数组元素加载到操作数栈指令:baload,caload,saload,iaload,laload,faload,daload,aaload
- 5)将操作数栈的数值存储到数组元素中执行:bastore,castore,castore,sastore,iastore,fastore,dastore,aastore
- 6)取数组长度指令:arraylength JVM支持方法级同步和方法内部一段指令序列同步,这两种都是通过moniter实现的。
- 7)检查实例类型指令:instanceof,checkcast
6.4.7 控制转移指令
- 1)条件分支:ifeq,iflt,ifle,ifne,ifgt,ifge,ifnull,ifnotnull,if_cmpeq,if_icmpne,if_icmlt,if_icmpgt等
- 2)复合条件分支:tableswitch,lookupswitch
- 3)无条件分支:goto,goto_w,jsr,jsr_w,ret
6.4.8 方法调用和返回指令
- invokevirtual指令:调用对象的实例方法,根据对象的实际类型进行分派(虚拟机分派)。
- invokeinterface指令:调用接口方法,在运行时搜索一个实现这个接口方法的对象,找出合适的方法进行调用。
- invokespecial:调用需要特殊处理的实例方法,包括实例初始化方法,私有方法和父类方法
- invokestatic:调用类方法(static)
6.4.9 异常处理指令
6.4.10 同步指令
- java虚拟机可以支持方法级的同步和方法内部一段指令序列的同步
- 方法级的同步是隐式的,即无需通过字节码指令来控制。当方法调用时,调用指令将会检查方法的ACC_SYNCHRONIZED访问标志是否被设置,如果设置了,执行线程就要求先成功持有管程,然后才能执行方法,最后当方法完成(无论是正常完成还是非正常完成)是释放管程。在方法执行期间,执行线程持有了管程,其它任何线程都无法再获取到同一个管程。如果一个同步方法执行期间抛出了异常,并且在方法内部无法处理此异常,那么这个同步方法所持有的管程将在异常抛到同步方法之外时自动释放。
- 同一段指令集序列通常由java语言中的synchronized语句块来表示,java虚拟机的指令集中有monitorenter和monitorexit两条指令来支持synchronized关键字的语义
6.5 公有设计和私有实现
6.6 Class文件结构的发展
6.7 本章小结
第7章 虚拟机类加载机制
7.1 概述
7.2 类加载的时机
- 类从被加载到虚拟机内存中开始,到卸载出内存为止,他的整个生命周期包括:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)7个阶段。其中验证、准备、解析3个部分统称为连接(Linking)
- 加载、验证、准备、初始化和卸载这5个阶段的顺序是确定的,而解析阶段则不一定:它某些情况下可以在初始化阶段之后才开始,这是为了支持java语言的运行时绑定。
- 当一个类在初始化时,要求其父类全部都已经初始化过了,但是一个接口在初始化时,并不要求其父接口全部都完成了初始化,只有在真正使用到父接口的时候(如引用接口中定义的常量)才会初始化
- 有且只有5种主动引用的方式会触发类的初始化
- 遇到new、getstatic、putstatic或invokestatic这4条字节码指令时
- 使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行初始化,则需要先触发其初始化
- 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化
- 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类
- 当使用JDK1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic方法的句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化
7.3 类加载的过程
7.3.1 加载
- 通过一个类的全限定名来获取定义此类的二进制字节流
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
- 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口
7.3.2 验证
- 验证是连接阶段的第一步,这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全
- 验证大致上会完成下面4个阶段的检验动作:文件格式验证、元数据验证、字节码验证、符号引用验证。
-
1、 这个类是否有父类(除java.lang.Object类之外都有父类);
2、 这个类的父类是否继承了不允许被继承的类(final修饰);
- 字节码验证:第三阶段是整个验证过程中最复杂的一个阶段,主要目的是通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的
7.3.3 准备
- 准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。
- 这时候进行内存分配的仅包括类变量(被static修饰的变量),而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在java堆中。
- 这里所说的初始值"通常情况"下时数据类型的零值
7.3.4 解析
- 解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程
- 静态方法、私有方法、实例构造器、父类方法称为非虚方法。其它方法称为虚方法(除去final方法)
- 符号引用(Symbolic References):符号引用以一组符号来描述所引用的目标,符号可以是任何的字面量,只要使用时能无歧义地定位到目标即可。(比如Class文件是一组以8位字节为基础单位的二进制流)。
- 直接引用(Direct References):直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。
- 类或接口的解析
假设当前代码所处的类为D,如果要把一个从未解析过的符号引用N解析为一个类或接口C的直接引用,那虚拟机完成整个解析的过程需要以下3个步骤:
- 如果C不是一个数组类型,那虚拟机将会把代表N的全限定名传递给D的类加载器去加载这个类C。在加载过程中,由于元数据验证、字节码验证的需要,又可能触发其他相关类的加载动作,例如加载这个类的父类或实现的接口。一旦这个加载过程出现了任何异常,解析过程就宣告失败。
- 如果C是一个数组类型,并且数组的元素类型为对象,也就是N的描述符会是类似"[Ljava/lang/Integer"的形式,那将会按照第1点的规则加载数组元素类型。如果N的描述符如前面所假设的形式,需要加载的元素类型就是"java.lang.Integer",接着由虚拟机生成一个代表此数组维度和元素的数组对象。
- 如果上面的步骤没有出现任何异常,那么C在虚拟机中实际上已经成为一个有效的类或接口了,但在解析完成之前还要进行符号引用验证,确认D是否具备对C的访问权限。如果发现不具备访问权限,将抛出java.lang.IllegalAccessError异常。
要解析一个未被解析过的字段符号引用,首先将会对字段表内class_index项中索引的CONSTANT_Class_info符号引用进行解析,也就是字段所属的类或接口的符号引用。如果在解析这个类或接口符号引用的过程中出现了任何异常,都会导致字段符号引用解析的失败。如果解析成功完成,那将这个字段所属的类或接口用C表示,虚拟机规范要求按照如下步骤对C进行后续字段的搜索。
- 如果C本身就包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
- 否则,如果在C中实现了接口,将会按照继承关系从下往上递归搜索各个接口和他的父接口,如果接口中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
- 否则,如果C不是java.lang.Object的话,将会按照继承关系从下往上递归搜索其父类,如果在父类中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段直接引用,查找失败。
- 否则,查找失败,抛出java.lang.NoSuchFieldError异常。
如果查找过程成功返回了引用,将会对这个字段进行权限验证,如果发现不具备对字段的访问权限,将抛出java.lang.IllegalAccessError异常。
-
类方法解析的第一个步骤与字段解析一样,也需要先解析出类方法表的class_index项中索引的方法所属的类或接口的符号引用,如果解析成功,我们依然用C表示这个类,接下来虚拟机将会按照如下步骤进行后续的类方法搜索。
- 类方法和接口方法符号引用的常量类型定义是分开的,如果在类方法表中发现class_index中索引的C是个接口,那就直接抛出java.lang.IncompatibleClassChangeError异常。
- 如果通过了第1步,在类C中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
- 否则,在类C的父类中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
- 否则,在类C实现的接口列表及他们的父接口之中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果存在匹配的方法,说明类C是一个抽象,这时查找结束,抛出java.lang.AbstractMethodError异常。
- 否则,宣告方法查找失败,抛出java.lang.NoSuchMethodError。
最后,如果查找过程成功返回了直接引用,将会对这个方法进行权限验证,如果发现不具备对此方法的访问权限,将抛出java.lang.IllegalAccessError异常。
- 与类方法解析不同,如果在接口方法表中发现class_index中的索引C是个类而不是接口,那就直接抛出java.lang.IncompatibleClassChangeError异常。
- 否则,在接口C中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
- 否则,在接口C的父接口中递归查找,直到java.lang.Object(查找范围会包括Object类)为止,看是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
- 否则,宣告方法查找失败,抛出java.lang.NoSuchMethodError异常。
由于接口中的所有方法默认都是public,所以不存在访问权限的问题,因此接口方法的符号解析应当不会抛出java.lang.IllegalAccessError异常。
7.3.5 初始化
- 在准备阶段,变量已经赋过一次系统要求的初始值,而在初始化阶段,则根据程序员通过程序制定的主观计划去初始化类变量和其它资源。
- 从另一个角度来表达:初始化阶段是执行类构造器<clinit>()方法的过程,<clinit>()方法执行的细节如下:
- <clinit>()方法是由编译器自动收集类中所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句可以赋值,但是不能访问
- <clinit>()方法与类的构造函数(或者说实例构造器<init>()方法)不同,它不需要显式地调用父类构造器,虚拟机会保证在子类的<clinit>()方法执行之前,父类的<clinit>()方法已经执行完毕,由于父类的<clinit>()方法先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作
- <clinit>()方法对于类或接口来说并不是必需的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成<clinit>()方法。
- 接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成<clinit>()方法。但与类不同的是,接口的<clinit>()方法不需要先执行父接口的<clinit>()方法
- 虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确地加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>()方法,其它线程都需要阻塞等待,直到活动线程执行<clinit>()方法完毕
7.4 类加载器
7.4.1 类与类加载器
- 对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在java虚拟机中的唯一性,每一个类加载器,都有一个独立的类名称空间。(比较两个类是否"相等",需要来源于同一个Class文件,被同一个类加载器加载)
7.4.2 双亲委派模型
- 从java虚拟机的角度来讲,只存在两种不同的类加载器:一种是启动类加载器(Bootstrap ClassLoader),是虚拟机的一部分;另一种就是所有其它的类加载器,这些类加载器都由java语言实现,独立于虚拟机外部,并且全都继承自抽象类java.lang.ClassLoader。
- 启动类加载器(Bootstrap ClassLoader):负责将存放在<JAVA_HOME>\lib目录中的,或者被-Xbootclasspath参数所指定的路径中的,并且是虚拟机识别的(仅按照文件名识别,如rt.jar,名字不符合的类库即使放在lib目录中也不会被加载)类库加载到虚拟机内存中。启动类加载器无法被java程序直接引用
- 扩展类加载器(Extension ClassLoader):这个加载器由sun.misc.Launcher$ExtClassLoader实现,它负责加载<JAVA_HOME>\lib\ext目录中的,或者被java.ext.dirs系统变量所指定的路径中的所有类库,开发者可以直接使用扩展类加载器
- 应用程序类加载器(Application ClassLoader):这个加载器由sun.misc.Launcher$App-ClassLoader实现。一般称它为系统类加载器。它负责加载用户类路径(ClassPath)上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器
- 双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器。这里类加载器之间的父子关系一般不会以继承(Inheritance)的关系来实现,而是都使用组合(Composition)关系来复用父加载器的代码
- 双亲委派模型的工作过程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因为所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。
7.4.3 破坏双亲委派模型
- 双亲委派被破坏的3种情况
- 向前兼容:妥协JDK1.2版本前的代码,引入loadClass()方法引导用户去重写
- JNDI服务:启动类加载器无法识别JNDI接口,只好引入"线程上下文加载器",父类加载器请求子类加载器去完成类加载动作
第8章 虚拟机字节码执行引擎
8.1 概述
8.2 运行时栈帧结构
- 栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈(Virtual Machine Stack)的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址和一些额外的附加信息。每一个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机里面从入栈到出栈的过程
8.2.1 局部变量表
- 局部变量表(Local Variable Table)是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。在java程序编译为Class文件时,就在方法的Code属性的max_locals数据项中确定了该方法所需要分配的局部变量表的最大容量。
- 局部变量表的容量以变量槽(Variable Slot)为最小单位,在方法执行时,虚拟机是使用局部变量表完成参数值到参数变量列表的传递过程
- 为了尽可能节省栈帧空间,局部变量表中的Slot是可以重用的。局部变量表中的Slot存有对象引用时,GC不会回收此对象。不过一般JIT编译会自动优化掉赋Null值的操作,无须手动赋值。
- 局部变量不像前面介绍的类变量那样存在"准备阶段"。如果一个局部变量定义了但没有赋初始值是不能使用的
8.2.2 操作数栈
- 操作数栈(Operand Stack)也常称为操作栈,它是一个后进先出(Last In First Out,LIFO)栈。在java程序编译为Class文件时,就在方法的Code属性的max_stacks数据项中确定了操作数栈的最大深度。
- 当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈/入栈操作。
- 在概念模型中,两个栈帧作为虚拟机栈的元素,是完全相互独立的。但大多虚拟机的实现里都会做一些优化处理,令两个栈帧出现一部分重叠。
8.2.3 动态连接
- 每个栈帧都包含一个指向运行时常量池中该帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking)
- Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候就转化为直接引用 ,这种转化称为静态解析。另外一部分将在每一次运行期间转化为直接引用,这部分称为动态连接
8.2.4 方法返回地址
- 当一个方法开始执行之后,只有两种方式可以退出这个方法。第一种方式是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者(调用当前方法的方法称为调用者),是否有返回值和返回值的类型将根据遇到何种方法返回指令来决定,这种退出方法的方式称为正常完成出口(Normal Method Invocation Completion ) 。
- 另一种退出方法是,在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,这种退出方式称为异常完成出口( Abrupt Method Invocation Completion),它不会给上层调用者产生任何返回指。
8.2.5 附加信息
- 虚拟机规范允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧之中,例如与调试相关的信息,这部分信息完全取决于具体的虚拟机实现。在实际开发中 ,一般会把动态连接、方法返回地址与其他附加信息全部归为一类,称为栈帧信息。
8.3 方法调用
8.3.1 解析
- 在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用,这种解析能成立的前提是:"编译器可知,运行期不可变",主要包括静态方法和私有方法两大类。它们都不可能通过继承或别的方式重写其它版本
- 在java虚拟机里面提供了5条方法调用字节码指令,如下
- invokestatic:调用静态方法
- invokespecial:调用实例构造器<init>方法、私有方法和父类方法
- invokevirtual:调用所有虚方法(静态方法、私有方法、实例构造器、父类方法称为非虚方法。其它方法称为虚方法(实例方法等,除去final方法,)
- invokeinterface:调用接口方法,会在运行时再确定一个实现此接口的对象
- invokedynamic:现在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法,在此之前的4条调用指令,分派逻辑是固话在java虚拟机内部的,而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的。
8.3.2 分派
- 找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C
- 如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;如果不通过,则返回java.lang.ILLegalAeecssError异常
- 否则,按照继承关系从下往上依次对C的各个父类进行第2步的搜索和验证过程。
- 如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常
- 由于invokevirtual指令的第一步就是在运行期确定接收者的实例类型,所以两次调用中的invokevirtual指令把常量池中的类方法符号引用解析到了不同的直接引用上,这个过程就是java语言中方法重写的本质
- 最常用的"稳定优化"手段就是为类在方法区中建立一个虚方法表(),使用虚方发表索引来代替元数据查找以提高性能
- 虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那么子类的虚方法表里面的地址入口和父类相同方法的地址入口是一直的,都指向父类的实现入口。如果子类中重写了这个方法,子类方法表中的地址将会替换为指向子类实现版本的入口地址
- 方发表中所有从Object继承来的方法都指向了Object的数据类型
- 方发表一般在类加载的链接阶段进行初始化,准备了类的变量初始值后,虚拟机会把该类的方法表也初始化完毕
8.3.3 动态类型语言支持
- 动态类型语言:动态类型语言的关键特征是它的类型检查的主体过程是在运行期而不是编译期
- JDK1.7与动态类型:invokedynamic指令与java.lang.invoke包
-
java.lang.invoke包:提供了一种新的动态确定目标方法的机制,称为MethodHandle:类似lamda的Function,用代码模拟了invokevirtual这几种指令的过程。使其能动态的执行。与反射Reflection的区别:
1、都是在模拟方法调用,Reflection是模拟java代码层次的方法调用,MethodHandle模拟字节码层次的方法调用;
2、Reflection是重量级,是方法在java一端的全面映像,MethodHandle是轻量级,仅仅包含与执行该方法相关的信息;
- invokedynamic指令:
每一次含有invokedynamic指令的位置都称作"动态调用点"(Dynamic CallSite),这条指令的第一个参数变为CONSTANT_InvokeDynamic_info常量,从中可获取引导方法(Bootstrap Method)、方法类型(MethType)和名称。根据它提供的信息,虚拟机可以找到并执行引导方法,从而获取一个CallSite对象,最终调用要执行的目标方法
8.4 基于栈的字节码解释执行引擎
8.4.1 解释执行
- 解释器:抽象语法树 --> 指令流 --> 解释器 --> 解释执行。(上图中间分支)
- javac编译器完成了程序代码经过词法分析、语法分析到抽象语法树,再遍历语法树生成线性字节码指令流的过程(上图下面分支)
8.4.2 基于栈的指令集与基于寄存器的指令集
- java编译器输出的指令流,基本上是一种基于栈的指令集架构,指令流中的指令大部分都是零地址指令
- 基于栈的指令集优点是可移植、代码相对更加紧凑、编译器实现更加简单。缺点是寄存器由硬件直接提供,程序直接依赖这些硬件寄存器则不可避免地受到硬件的约束。执行速度相对较慢
8.4.3 基于栈的解释器执行过程
8.5 本章小结
第9章 类加载及执行子系统的案例与实战
9.1 概述
9.2 案例分析
9.2.1 Tomcat:正统的类加载器架构
- 在tomcat目录结构中,有三组目录/common/*,/server/*,/shared/*可以用来存放类库,另外还有Web应用程序自身的目录/WEB-INF/*一共四组,把Java类库放在这些目录的含义和区别如下:
- 放置在/common/*目录:这些类库可以被tomcat和所有的web应用程序共同使用
- 放置在/server/*目录:这些类库可以被tomcat使用,所有的web应用程序都不可见
- 放置在/shared/*目录:这些类库可以被所有的web应用程序共同使用,但是对tomcat自己不可见
- 放置在/WEB-INF/*目录:这些类库仅仅对当前的web应用程序使用,对tomcat和其他的web应用程序都是不可见的
- 主要的类加载器有CommonClassLoader,CatalinaClassLoader,SharedClassLoader和WebappClassLoader,它们分别加载/common/*,/server/*,/shared/*和/WEB-INF/*目录下的类库,其中WebApp加载器和JSP类加载器实例通常会存在多个
- 每一个web应用程序对应一个webapp类加载器,每一个JSP文件对应一个JSP类加载器.
- CommonClassLoader能加载的类都可以被CatalinaClassLoader和SharedClassLoader使用,而CatalinaClassLoader和SharedClassLoader自己能加载的类则与对方相互隔离.WebAppClassLoader可以使用SharedClassLoader
- Tomcat6.x把/common/*,/server/*,/shared/*三个目录默认合并到一起变成一个/lib目录
9.2.2 OSGi:灵活的类加载器架构
- OSGi中的每个模块(Bundle)与普通java类库区别并不大,两者都是以jar格式封装,并且内部存储的都是java package和Class。但是Bundle可以申明它所依赖的java package,也可以申明它允许导出发布的java package
- 在OSGi里面,Bundle之间的依赖关系从传统的上层模块依赖底层模块转变为平级模块之间的依赖。Bundle类加载器之间只有规则,没有固定的委派关系
-
3)否则,将Import列表中的类委派给Export这个类的Bundle的类加载器加载。
4)否则,查找当前Bundle的Class Path,使用自己的类加载器加载。
5)否则,查找类是否在自己的Fragment Bundle中,如果在,则委派给Fragment Bundle的类加载器加载。
9.2.3 字节码生成技术与动态代理的实现
- Jdk动态代理的原理,不再细说,注意Proxy.newProxyInstance(ClassLoader loader,Class<?>[] interfaces,InvocationHandler h)方法就是生成一个相同接口的代理类Object。网上那些示例都过于复杂。
9.2.4 Retrotranslator:跨越JDK版本
9.3 实战:自己动手实现远程执行功能
直接修改符合Class文件格式的byte[]数组中的常量池部分,将常量池中指定内容的CONSTANT_Utf8_info常量替换为新的字符串(把系统的类替换为自己写的类),然后用自己的ClassLoader
9.3.1 目标
9.3.2 思路
9.3.3 实现
9.3.4 验证
9.4 本章小结
第四部分 程序编译与代码优化
第10章 早期(编译期)优化
10.1 概述
10.2 Javac编译器
10.2.1 Javac的源码与调试
10.2.2 解析与填充符号表
解析步骤由图10-5中的parseFiles()方法完成,包括了经典程序编译原理中的词法分析和语法分析两个过程
- 词法分析是将源代码的字符流转变为标记(Token)集合,标记是编译过程的最小元素
- 语法分析是根据Token序列构造抽象语法树的过程,抽象语法树是一种用来描述程序代码语法结构的树形表示方式,语法树的每一个节点都代表着程序代码中的语法结构(Construct),例如包、类型、修饰符、运算符、接口、返回值甚至代码注释等都可以是一个语法结构
- 完成了语法分析和词法分析之后,下一步就是填充符号表的过程,10-5中的enterTree()方法。符号表(Symbol Table)是由一组符号地址和符号信息构成的表格,类似K-V值对的形式
- 符号表中所登记的信息在编译的不同阶段都要用到。在语义分析中,符号表所登记的内容将用于语义检查(如检查一个名字的使用和原先的说明是否一致)和产生中间代码。在目标代码生成阶段,当对符号名进行地址分配时,符号表是地址分配的依据。
10.2.3 注解处理器
- JDK1.6中实现了JSR-269规范,提供了一组插入式注解处理器的标志API在编译期间对注解进行处理,可以把它看作是一组编译期插件,在这些插件里面,可以读取、修改、添加抽象语法树中的任意元素。
- 如果这些插件在处理注解期间对语法树进行了修改,编译期将回到解析及填充符号表的过程重新处理,直到所有插入式注解处理器都没有再对语法树进行修改为止,每一次循环称为一个Round,也就是10-4的回环过程
10.2.3 语义分析与字节码生成
- 语法分析之后,编译器获得了程序代码的抽象语法树表示,语法树能表示一个结构正确的源程序的抽象,但无法保证源程序是符合逻辑的。而语义分析的主要任务是对结构上正确的源程序进行上下文有关性质的审查,如进行类型审查
- 字节码生成阶段不仅仅把前面各个步骤所生成的信息(语法树、符号表)转化成字节码写到磁盘中,编译器还进行了少量的代码添加和转换工作
- 实例构造器<init>()方法和类构造器<clinit>()方法就是在这个阶段添加到语法树中
- 保证一定先执行父类的实例构造器,然后初始化变量,最后执行语句块
- 除了生成构造器之外,还有其它的一些代码替换工作用于优化程序的实现逻辑,如把字符串的加操作替换为StringBuffer或StringBuilder的append()操作(个人认为还有/*2优化,优化为位运算)
10.3 Java语法糖味道
10.3.1 泛型与类型擦除
- 它的本质是参数化类型的应用,也就是说所操作的数据类型被指定为一个参数。这种参数类型可以用在类、接口、方法的创建中,分别称为泛型类、泛型接口和泛型方法
- java语言中的泛型只在程源码中存在,在编译后的字节码文件中就已经替换为原来的原生类型(Raw Type,也称为裸类型)了,并且在相应的地方插入了强制转型代码
- java语言中的泛型实现方法称为类型擦除,基于这种方法实现的泛型称为伪泛型
- 泛型重载无法编译的原因:List<String>和List<Integer>编译之后都变成了原生类型List<E>
- 方法重载要求方法具备不同的特征签名,返回值并不包含在方法的特征签名之中,所以返回值不参与重载选择,但是在Class文件格式之中,只要描述符不是完全一致的两个方法就可以共存。
10.3.2 自动装箱、拆箱与遍历循环
for(int i : list)会变为 for(Iterator iter = list.iterator();iter.hasNext();){ int i = ((Integer)iter.next()).intValue() }
List<Integer> list = Arrays.asList(1,2,3,4);变为 List list = Arrays.asList(new Integer[]{ Integer.valueof(1),Integer.valueof(2),Integer.valueof(3),Integer.valueof(4) })
10.3.3 条件编译
if(true){ syso("bolck 1"); }else{ syso("bolck 2"); }
10.4 实战:插入式注解处理器
10.4.1 实战目标
10.4.2 代码实现
10.4.3 运行与测试
10.4.4 其它应用案例
10.5 本章小节
第11章 晚期(运行期)优化
11.1 概述
- java程序通过解释器(Interpreter)进行解释执行
- 当虚拟机发现某个方法或代码块的运行特别频繁时,就会把这些代码认定为"热点代码"(Hot Spot Code)。为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成与平台相关的机器码,并进行各种层次的优化,完成这个任务的编译器称为即时编译器(Just In Time Compiler,即JIT编译器)
11.2 HotSpot虚拟机内的即时编译器
11.2.1 解释器与编译器
- 当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即执行
- 在程序运行后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码之后,可以获取更高的执行效率
- 当程序运行环境中内存资源限制较大(如部分嵌入式系统),可以使用解释执行节约内存,反之可以使用编译执行来提升效率
- HotSpot虚拟机中内置了两个即时编译器,分别称为Client Compiler和Server Compiler,或者简称为C1编译器和C2编译器。默认采用解释器与期中一个编译器直接配合的方式工作,程序使用哪个编译器,取决于虚拟机运行的模式,HotSpot虚拟机会根据自身版本与宿主机器的硬件性能自动选择运行模式,用户也可以使用"-client"或"-server"参数去强制指定虚拟机运行在Client模式或Server模式
- 解释模式(Interpreted Mode):参数"-Xint",编译器完全不介入工作,全部代码都使用解释方式执行
- 编译模式(Compiled Mode):参数"-Xcomp",优先采用编译方式执行程序,但是解释器仍然要在编译无法进行的情况下介入执行过程
HotSpot虚拟机采用分层(Tiered Compilation)的策略,分层编译根据编译器编译、优化的规模与耗时,划分出不同的编译层次
- 第0层,程序解释执行,解释器不开启性能监控功能(Profiling),可触发第1层编译
- 第1层,也成为C1编译,将字节码编译为本地代码,进行简单、可靠的优化,如有必要将加入性能监控的逻辑
- 第2层(或2层以上),也称为C2编译,也是将字节码编译为本地代码,但是会启用一些编译耗时较长的优化,甚至会根据性能监控信息进行一些不可靠的激进优化
- 实施分层编译后,Client Compiler和Server Compiler将会同时工作,许多代码都可能会被多次编译,用Client Compiler获取更高的编译速度,用Server Compiler来获取更好的编译质量,在解释执行的时候也无须再承担收集性能监控信息的任务
11.2.2 编译对象与触发条件
- 被多次调用的方法:由方法调用触发的编译,因此编译器理所当然的会以整个方法作为编译对象,这种编译也就是虚拟机中标准的JIT编译方式
- 被多次执行的循环体:编译器依然会以整个方法(而不是单独的循环体)作为编译对象。这种编译方式因为编译发生在方法执行过程之中,因此形象地称之为栈上替换(On Stack Replacement,简称为OSR编译,即方法栈帧还在栈上,方法就被替换了)
- 基于采样的热点探测:虚拟机会周期性地检查各个线程的栈顶,如果发现某个(或某些)方法经常出现在栈顶,那这个方法就是"热点方法"。优点:实现简单、高效,很容易获取方法调用关系(将堆栈展开即可),缺点:很难精确地确认一个方法的热度,容易收到线程阻塞或其它外界因素的影响
- 基于计数器的热点探测:虚拟机会为每个方法(甚至代码块)建立计数器,统计方法的执行次数,如果执行次数超过一定的阈值就认为它是"热点方法"。优点:精确严谨,缺点:实现麻烦。
-
- 统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为"回边"(Back Edge)。目的时为了触发OSR编译
- 当解释器遇到一条回边指令时,会先查找将要执行的代码片段是否有已经编译好的版本,如果有,它将会优先执行已编译的代码,否则就把回边计数器的值加1,然后判断方法调用计数器与回边计数器值之和是否超过回边计数器的阈值。当超过阈值的时候,将会提交一个OSR编译请求,并且把回边计数器的值降低一些,以便继续在解释器中执行循环,等待编译器输出编译结果,
- 和方法计数器执行过程不同的是:当两个计数器之和超过阈值的时候,它向编译器提交OSR编译,并且调整回边计数器值,然后仍旧以解释方式执行下去。
- 虚拟机运行在Client模式下,回边计数器阈值计算公式为:
方法调用计数器阈值(CompileThreshold)×OSR比率(OnStackReplacePercentage)/100
其中OnStackReplacePercentage默认值为933,如果都取默认值,那Client模式虚拟机的回边计数器的阈值为13995。
方法调用计数器阈值(CompileThreshold)×(OSR比率(OnStackReplacePercentage)-解释器监控比率(InterpreterProfilePercentage)/100
其中OnStackReplacePercentage默认值为140,InterpreterProfilePercentage默认值为33,如果都取默认值,那Server模式虚拟机回边计数器的阈值为10700。
11.2.3 编译过程
- 在默认设置下,无论是方法调用产生的即时编译请求,还是OSR编译请求,虚拟机在代码编译器还未完成之前,都仍然按照解释方式继续执行,而编译动作则在后台的编译线程中进行。可通过参数"-XX:-BackgroundCompilation"来禁止后台编译,禁止后,执行线程向虚拟机提交编译请求后将会一直等待,直到编译过程完成后再开始执行编译器输出的本地代码
- Client Compiler编译器:简单快速的三段式编译器,主要关注点在于局部性的优化,放弃了许多耗时较长的全局优化手段
一阶段:一个平*立的前端将字节码构造成一种高级中间代码表示(HIR)。在此之前编译器会在字节码上完成一部分基础优化,如方法内联、常量传播等。
二阶段:一个平台相关的后端从HIR中产生低级中间代码表示(LIR),而在此之前会在HIR上完成另一些优化,如空值检查消除、范围检查消除等
最后阶段:在平台相关的后端使用线性扫描算法在LIR上分配寄存器,并在LIR上做窥孔优化,然后产生机器代码。
- Server Compiler是专门面向服务端的典型应用并为服务端的性能配置特别调整过的编译器,也是一个充分优化过的高级编译器,它会执行所有经典的优化动作,如无用代码消除(Dead Code Elimination)、循环展开(Loop Unrolling)、循环表达式外提(Loop Expression Hoisting)、消除公共子表达式(Common Subexpression Elimination)、常量传播(Constant Propagation)、基本块重排序(Basic Block Reordering)等,还会实施一些与Java语言特性密切相关的优化技术,如范围检查消除(Range Check Elimination)、空值检查消除(Null Check Elimination,不过并非所有的空值检查消除都是依赖编译器优化的,有一些是在代码运行过程中自动优化了)等。另外,还可能根据解释器或Client Compiler提供的性能监控信息,进行一些不稳定的激进优化,如守护内联(Guarded Inlining)、分支频率预测(Branch Frequency Prediction)等。
- 以即时编译的标准来看,Server Compiler无疑是比较缓慢的,但它的编译速度依然远远超过传统的静态优化编译器,而且它相对于Client Compiler编译输出的代码质量有所提高,可以减少本地代码的执行时间,从而抵消了额外的编译时间开销,所以也有很多非服务端的应用选择使用Server模式的虚拟机运行。
11.2.4 查看及分析即时编译结果
参数-XX:+PrintCompilation要求虚拟机在即时编译时将被编译成本地代码的方法名称打印出来
参数-XX:+PrintInlining要求虚拟机输出方法内联信息
参数-XX:+PrintAssembly要求虚拟机打印编译方法的汇编代码(需要Debug或者FastDebug版的虚拟机才能直接支持)
参数-XX:+PrintOptoAssembly输出伪汇编结果
11.3 编译优化技术
11.3.1 优化技术概览
11.3.2 公共子表表达式消除
- 如果一个表达式E已经计算过了,并且从先前的计算到现在E中所有变量的值都没发生变化,那么E的这次出现就成为了公共子表达式
- 如果这种优化仅限于程序的基本快内,便称为局部公共子表达式消除,如果这种优化的范围涵盖了多个基本快,那就称为全局公共子表达式消除
11.3.3 数组边界检查消除
- 如果数组下标是一个常量,在编译器根据数据流分析来确定数组的值,如果没有越界,就无需判断数组边界
- 如果编译器只要通过数据流分析就可以判断循环变量的取值范围永远在有效区间内,那在整个循环中就可以把数组的上下界检查消除,可以节省很多次判断条件
- 隐式异常处理,如下段代码,当x极少为空的时候,隐式异常优化是值得的,如果x经常为空反而会更慢,HotSpot虚拟机会自动选择最优方案
if(x != null){ return x.value; }else{ throw new NullPointException(); } 隐式异常优化后: try{ return x.value; }catch(segment_fault){ uncommon_trap(); }
11.3.4 方法内联
- 编译器最重要的优化之一,除了消除方法调用成本之外,它更重要的意义是为其它优化手段建立良好的基础
- 稳定的内联:使用invokespecial指令调用的私有方法、实例构造器、父类方法以及使用invokestatic指令调用的静态方法。除此4种,其它的java方法都需要在运行时进行方法接收者的多态选择
- 类型继承关系分析(CHA):基于整个应用程序的类型分析技术,它用于确认在目前已加载的类中,某个接口是否有多于一种的实现,某个类是否存在子类、子类是否为抽象类信息
- 编译器在进行内联时,如果是非虚方法,就直接内联,这时候内联是有稳定前提保障的。
- 如果遇到虚方法,则会向CHA查询此方法在当前程序下是否有多个版本可供选择,如果查询结果只有一个版本,那也可以进行内联,不过这种内联属于激进优化,需要预留一个"逃生门",称为守护内联
- 如果想CHA查询结果有多个版本可供选择,则编译器会进行最后一次努力,使用内联缓存来完成方法内联,它的原理是:在未发生方法调用之前,内联缓存状态为空,当第一次调用发生后,缓存记录下方法接收者的版本信息,并且每次进行方法调用时都比较接收者版本,如果以后进来的每次调用的方法接收者版本都是一样的,那这个内联还可以一直用下去。如果发生了方法接收者不一致的情况,说明程序真正使用了虚方法的多态特性,这是才会取消内联,查找虚方法表进行方法分派。
11.3.5 逃逸分析
- 逃逸分析的基本行为就是分析对象动态作用域:当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其它方法中,称为方法逃逸。甚至还有可能被外部线程访问到,譬如赋值给类变量或可以在其它线程中访问的变量实例,称为线程逃逸。逃逸分析现在还不够成熟
- 如果能证明一个对象不会逃逸到方法或线程之外,也就是别的方法或线程无法通过任何路径访问到这个对象,则可进行一些高效的优化
- 栈上分配:对象默认分配在堆中,会增加GC的压力,如果确定一个对象不会逃逸出方法之外,则直接在栈内存上进行分配空间,大量对象会随着方法的结束而自动销毁,GC压力会小很多,HotSpot虚拟机中暂时没作此优化
- 同步消除:如果逃逸分析能确定一个变量不会逃逸出线程,无法被其它线程访问,那么这个变量的读写肯定不会有竞争,对这个变量实施的同步措施可以消除掉
- 标量替换:如果逃逸分析证明一个对象不会被外部访问,并且这个对象可以被拆散的话,那程序真正执行的时候将可能不创建这个对象,而改为直接创建它的若干个被这个方法使用到的成员变量来代替
-XX:+DoEscapeAnalysis 手动开启逃逸分析,(大部分虚拟机默认不开启)
-XX:+PrintEscapeAnalysis 查看分析结果
-XX:+EliminateAllocation 开启标量替换
-XX:+PrintEliminateAllocations查看标量的替换情况
11.4 Java与C/C++的编译器对比
11.5 本章小结
第五部分 高效并发
第12章 Java内存模型与线程
12.1 概述
12.2 硬件效率与一致性
- 物理机的缓存来源:由于计算机的存储设备与处理器的运算速度有几个数量级的差距,所以现代计算机系统都不得不加入一层读写速度尽可能接近处理器运算速度的高速缓存(Cache)来作为内存与处理器之间的缓冲
- 物理机的缓存模型:在多处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存(Main Memory)。当多个处理器的运算任务都涉及同一块主内存区域时,需要各个处理器访问缓存时都遵循一些协议
12.3 Java内存模型
12.3.1 主内存与工作内存
- java内存模型的主要目标是定义程序中各个变量(包括实例字段、静态字段和构成数组对象的元素,不包括线程私有的局部变量与方法参数)的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节
- java内存模型规定了所有的变量都存储在主内存中。每条线程还有自己的工作内存,线程的工作内存中保存了被该线程使用到的变量的主内存副本拷贝(不一定是整个对象,可能只是对象中的某个字段),线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。
12.3.2 内存间交互操作
12.3.3 对于volatile型变量的特殊规则
- 保障此变量对所有线程的可见性,当一条线程修改了这个变量的值,新值对其他线程来说是可以立即得知的。实现方式为每次使用之前都刷新volatile的值
- 禁止指令排序优化,对于有volatile修饰的变量,赋值操作时字节码多执行了一个"lock add1 $0x0,(%esp)"操作,这个操作相当于一个内存屏障(Memory Barrier或Memory Fence),指令排序时不能把后面的指令重排序到内存屏障之前的位置,它的作用是使得本CPU得Cache写入了内存,该写入动作还会造成别的CPU或者别得内核无效化其Cache,通过这个空操作,可以让前面volatile变量的修改对其它CPU立即可见。
12.3.4 对于long和double型变量的特殊规则
- java虚拟机规范允许将没有被volatile修饰的64位的数据类型(long和double)的读写操作划分为两次32位的操作。但是实际开发中,各商用虚拟机都选择把64位数据的读写操作作为原子操作来对待
12.3.5 原子性、可见性和有序性
- 原子性(Atomicity):由java内存模型来直接保证的原子性变量操作包括read、load、assign、use、store和write,基本数据类型的访问读写也是具备原子性(long和double在绝大部分虚拟机上也是)。对于更大范围的原子性应用场景,java提供了lock和unlock,synchronized关键字的字节码指令monitorenter和monitorexit
- 可见性(Visibility):可见性是指当一个线程修改了共享变量的值,其它线程能立即得知这个修改。
- volatile变量,它的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。
- synchronized是由于对一个变量执行unlock操作之前,必须先把此变量同步回主内存(执行store、write操作)来获得的。
- 被final修饰的字段在构造器中初始化完成,并且构造器没有把"this"指针传递出去,那么其它线程可以看见final的值
- 前半句指线程内表现为串行的语义(Within-Thread As-If-Serial Semantics),普通的变量仅仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中执行顺序一致
- 后半句指"指令重排序"现象和"工作内存与主内存同步延迟"现象
12.3.6 先行发生原则
- happen-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.interrupted()方法检测到是否有中断发生。
对线终结规则(Finalizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。
传递性(Transitivity):如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行发生于操作C的结论。
12.4 Java与线程
12.4.1 线程的实现
java语言提供了在不同硬件和操作系统平台下对线程操作的统一处理,每个已经执行start()且还未结束的java.lang.Thread类的实例就代表了一个线程。
实现线程主要有3种方式:使用内核线程实现、使用用户线程实现和使用用户线程加轻量级进程混合实现。
- 内核线程(Kernel-Level Thread,KLT)就是直接由操作系统内核(Kernel)支持的线程,这种线程由内核来完成线程切换,内核通过操纵调度器(scheduler)对线程进行调度,并负责将线程的任务映射到各个处理器上。
- 程序一般不会直接去使用内核线程,而是去使用内核线程的一种高级接口--轻量级进程(Light Weight Process,LWP),轻量级进程就是我们通常意义上所讲的线程。
- 由于内核线程的支持,每个轻量级线程都成为一个独立的调度单元,即使有一个轻量级进程在系统调用中阻塞了,也不会影响整个进程继续工作。局限性:由于是基于内核线程实现,所以各种线程操作,如创建、析构及同步,都需要进行系统调用。而系统调用的代价相对较高,需要在用户态(Use Mode)和内核态(Kernel Mode)中来回切换。其次,每个轻量级进程都需要有一个内核线程的支持,因此轻量级进程要消耗一定的内核资源(如内核线程的栈空间),因此一个系统支持轻量级进程的数量是有限的。
- 从广义上来讲,一个线程只要不是内核线程,就可以认为是用户线程(User Thread,UT)。
- 从狭义上的用户线程指的是完全建立在用户空间的线程库上,系统内核不能感知线程存在的实现。用户线程的建立、同步、销毁和调度完全在用户态中完成,不需要内核的帮助。这种线程不需要切换到内核态,因此操作非常快速且低消耗,也可以支持更大的线程数量,部分高性能数据库中的多线程就是由用户线程实现的。
- 使用用户线程的优势在于不需要系统内核支援,所以操作非常快速且低消耗,但是由于没有系统内核的支援,所有的线程操作都需要用户程序自己处理,线程的创建、切换和调度都需要处理,而部分"阻塞如何处理""多处理器系统中如果将线程映射到其它处理器上"解决异常困难。除了在不支持多线程的操作系统中有极少数使用,现在大部分语言都放弃使用它。
-
在这种混合实现下,即存在用户线程,也存在轻量级进程。用户线程还是完全建立在用户空间中,因此用户线程的创建、切换、析构等操作依然廉价,并且可以支持大规模的用户线程并发。而操作系统提供支持的轻量级进程则作为用户线程和内核线程之间的桥梁,这样可以使用内核提供的线程调度功能及处理器映射,并且用户线程的系统调用要通过轻量级进程来完成,大大降低了整个进程被完全阻塞的风险。在这种混合模式中,用户线程与轻量级进程的数量比是不定的,即为N:M的关系。
12.4.2 Java线程调度
- 线程调用是指系统为线程分配处理器使用权的过程,主要调度方式有两种,分别是协同式线程调度(Cooperative Threads-Scheduling)和抢占式线程调度(Preemptive Threads-Scheduling)
- 协同式调度的多线程系统:线程的执行时间由线程本身来控制,线程把自己的工作执行完了之后,要主动通知系统切换到另外一个线程上。
- 抢占式调度的多线程系统:每个线程由系统来分配执行时间,线程的切换不由系统本身来决定(在java中,Thread.yield()可以让出执行时间,但是线程无法获取执行时间),java使用的线程调度方式是抢占式调度。
12.4.3 状态转换
- Java语言定义了5种线程状态,在任意一个时间点,一个线程有且只有一种状态
- 新建(New):创建后尚未启动的线程处于这种状态。
- 运行(Runable):Runable包括了操作系统线程状态种的Running和Ready,也就是处于此状态的线程有可能正在执行,也有可能正等待着CPU为它分配执行时间。
- 无限期等待(Waiting):处于这种状态的线程不会被分配CPU执行时间,它们要等待被其它线程显示地唤醒。以下方法会让线程陷入无限期等待状态:
- Thread.sleep()方法。
- 设置了Timeout参数的Object.wait()方法。
- 设置了Timeout参数的Thread.join()方法。
- LockSupport.parkNanos()方法。
- LockSupport.parkUntil()方法。
- 阻塞(Blocked):线程被阻塞了,"阻塞状态"与"等待状态"的区别是:"阻塞状态"在等待着获取到一个排他锁,这个时间将在另外一个线程放弃这个锁的时候发生;而"等待状态"则是在等待一段时间,或者唤醒动作的发生。在程序等待进入同步区域的时候,线程将进入这种状态。
- 结束(Terminated):已终止线程的线程状态,线程已经结束执行。
12.5 本章小结
第13章 线程安全与锁优化
13.1 概述
13.2 线程安全
13.2.1 Java语言中的线程安全
- java语言中各种操作共享的数据分为以下5类:不可变、绝对线程安全、相对线程安全、线程兼容和线程对立
- 不可变:不可变(Immutable)的对象一定是线程安全的,无论是对象的方法实现还是方法的调用者,都不需要再采取任何的线程安全保障措施
- 如final修饰的对象,Integer构造函数内部状态变量value就是final类型,还有枚举类型,Number的部分子类,Long和Double等数值包装类型,BigInteger和BigDecimal等大数据类型
- String类的对象,调用它的substring(),replace()和concat()这些方法都不会影响它原来的值,会返回一个新构造的字符串对象
ead removeThread = new Thread(() -> { for (int i = 0; i < vector.size(); i++) {// 出错的原因在这儿,size()方法是同步的,remove方法也是同步的,//但是这两部之间并不同步,数据有可能被其它线程修改,所以需要增加synchronized(对象) vector.remove(i); }
- 相对线程安全:我们通常意义上所讲的线程安全,它需要保证对这个对象单独的操作时线程安全的,我们在调用的时候不需要做额外的保障措施,但是对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保障调用的正确性
- 线程兼容:指对象本身并不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境中可以安全地使用
- 线程对立:线程对立是指无论调用端是否采取了同步措施,都无法在多线程环境中并发使用的代码。在java中极少见
13.2.2 线程安全的实现方法
互斥同步:同步是指在多个线程并发访问共享数据时,保证共享数据在同一个时刻只被一个(或者是一些,使用信号量的时候)线程使用。而互斥是实现同步的一种手段,临界区(Critical Section)、互斥量(Mutex)和信号量(Semaphore)都是主要的互斥实现方式
- synchronized关键字经过编译之后,会在同步快的前后分别形成monitorenter和monitorexit这两个字节码指令,这两个字节码都需要一个reference类型的参数来指明要锁定和解锁的对象。如果java程序中的synchronized明确指定了对象参数,那就是这个对象的reference;如果没有明确指定,那就根据synchronized修饰的是实例方法还是类方法,去取对应的对象实例或者Class对象来作为锁对象。
- synchronized同步快对同一条线程来说是可重入的
- 先进性操作,如果没有其他线程争用共享数据,那操作就成功了;如果共享数据有争用,产生了冲突,那就再采取其他的补偿措施(常见的是不断重试,直到成功为止),这种乐观的并发策略的许多实现都不需要把线程挂起,因此这种同步操作称为非阻塞同步(Non-Blocking Synchronization)。
- 互斥同步最主要的问题就是进行线程阻塞和唤醒多带来的性能问题,因此这种同步也称为阻塞同步(Blocking Synchronization)。
- CAS指令主要有三个操作数,分别是内存位置(java中可以简单理解为变量的内存地址,用V表示)、旧的预期值(用A表示)和新值(用B表示)。指令执行时,当且仅当V符合旧预期值A时,处理器用新值更新V的值,否则它就不执行更新,但是无论是否更新了V的值,都会返回V的旧值,这个处理过程是一个原子操作。
- 同步只是保证共享数据争用时的正确性手段,如果一个方法本来就不涉及共享数据,它自然旧无须任何同步措施去保证正确性,因此会有一些代码天才就是线程安全的。
- 可重入代码(Reentrant Code):这种代码也叫做纯代码(Pure Code),可以在代码执行的任何时刻中断它,转而去执行另一段代码(包括递归调用它本身),而在控制权返回后,原来的程序不会出现任何错误。如果一个方法,它的返回结果是可以预测的,只要输入了相同的数据,就都能返回相同的结果,那它就满足可重入性的要求,当然也是线程安全的。
13.3 锁优化
13.3.1 自旋锁与自适应锁
- 互斥同步对性能最大的影响是阻塞的实现,挂起线程和恢复线程都需要转入内核态中完成,这些操作给系统的并发性能带来了很大的压力
- 自旋锁:如果物理机有一个以上的处理器,能让两个或以上的线程同时并行执行,可以让后面请求锁的线程"稍等一会",但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。只需让线程执行一个忙循环(自旋),就是自旋锁
- 自旋等待时间必须要有一定的限度,如果超过了一定次数仍然没成功获得锁,就使用传统的方式挂起线程。默认值是10次,可使用参数-XX:PreBlockSpiin来更改
- 自适应锁:自适应意味着自旋的时间不再固定了,而是由前一次在同一个锁上的自选时间及锁拥有着的状态来决定。
13.3.2 锁消除
13.3.3 锁粗化
13.3.4 轻量级锁
- JDK1.6中新增,它名字中的"轻量级"是相对于使用操作系统互斥量来实现的传统锁而言的,传统锁称为"重量级"锁。轻量级锁不是用来代替重量级锁,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。
- HotSpot虚拟机的对象头(Object Header)分为两部分信息,第一部分用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄(GC Age)等,这部分数据长度在32位和64位虚拟机中分别为32bit和64bit,官方称它为"Mark Word",它是实现轻量级锁的关键。另一部分用于存储指向方法区对象类型数据的指针,如果是数组对象的话,还会有一个额外的部分用于存储数组长度。
- 执行过程如下:
- 在代码进入同步块的时候,如果此同步对象没有被锁定(锁标志位为"01"状态),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝(官方把这份拷贝加了一个Displaced前缀,即Displaced Mark Word)。
- 虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针。如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位(Mark Word的最后2bit)将转变为"00",即表示此对象处于轻量级锁定状态,这时候线程堆栈与对象头的状态如图13-4所示。
- 如果这个更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果只说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,否则说明这个锁对象已经被其他线程抢占了。
13.3.5 偏向锁
- 它的目的是消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能。如果说轻量级锁是在无竞争的情况下使用CAS操作去消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步都消除掉,连CAS操作都不做了。
- 当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的标志位设为"01",即偏向模式。同时使用CAS操作把获取到这个锁的线程的ID记录在对象的Mark Word之中,如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作(例如Locking、Unlocking及对Mark Word的Update等)。
- 当有另外一个线程去尝试获取这个锁时,偏向模式就宣告结束。根据锁对象目前是否处于被锁定的状态,撤销偏向(Revoke Bias)后恢复到未锁定(标志位为"01")或轻量级锁定(标志位为"00")的状态,后续的同步操作就如上面介绍的轻量级锁那样执行。
13.4 本章小结
序言
9.2.4 Retrotranslator:跨越JDK版本 67
12.3.4 对于long和double型变量的特殊规则 85