第二部分 自动内存管理机制
第 2 章 Java内存区域与内存溢出异常
2.1 概述
本章介绍了 Java 虚拟机内存的各个区域,讲解这些区域的作用. 服务对象以及其中可能产生的问题.
2.2 运行时数据区域
Java 虚拟机在执行 Java 程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域都有各自的用途,以及创建和销毁的时间, 有的区域随着虚拟机进程的启动而存在, 有些区域则依赖用户线程的启动和结束而建立和销毁。
2.2.1 程序计数器
程序计数器(Program Counter Register)是一块较小的内存空间, 它可以看作是当前线程所执行的字节码的行号指示器( 通过改变这个计数器的值来选取下一条需要执行的字节码指令).不会产生 OutOfMemoryError 异常.
2.2.2 Java 虚拟机栈
- 与程序计数器一样, Java 虚拟机栈(Java Virtual Machine Stacks) 也是线程私有的, 它的生命周期与线程相同。
- 虚拟机栈描述的是 Java 方法执行的内存模型: 每个方法在执行的同时都会创建一个栈帧(Stack Frame )用于存储局部变量表、操作数栈、动态链接、方法出口等信 息。
- 每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。
- 局部变量表存放了各种基本数据类型和对象引用类型.
- 栈帧是方法运行时的基础数据结构.
2.2.3 本地方法栈
本地方法栈(Native Method Stack)与虚拟机栈所发挥的作用是非常相似的, 它们之间的区别不过是虚拟机栈为虚拟机执行 Java 方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。
2.2.4 Java 堆
- Java 堆(Java Heap)是 Java 虚拟机所管理的内存中最大的一块.
- Java 堆是被所有线程共享的一块内存区域, 在虚拟机启动时创建.
- 此内存区域的唯一目的就是存放对象实例, 几乎所有的对象实例都在这里分配内存.
- Java 堆是垃圾收集器管理的主要区域, 因此很多时候也被称做"GC 堆".
- 通过 -Xmx 和 -Xms 控制虚拟机堆大小.
2.2.5 方法区
- 方法区(Method Area)与 Java 堆一样,是各个线程共享的内存区域,
- 它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据 。
- 可以认为是 HotSpot 虚拟机中的永久代.
2.2.6 运行时常量池
运行时常量池(Runtime Constant Pool)是方法区的一部分。
用于存放编译期生成的各种字面量(就是基本数据类型的常量了)和符号引用(引用类型的变量)。
2.3 HotSpot虚拟机对象探秘
2.3.1 对象的创建
- 虚拟机遇到一条new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程,本书第7章将探讨这部分内容的细节。
- 在类加载检查通过后,接下来虚拟机将为新生对象分配内存。
- 对象所需内存的大小在类加载完成后便可完全确定(如何确定将在2.3.2节中介绍),为对象分配空间的任务等同于把一块确定大小的内存从Java堆中划分出来。
- 假设Java堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离,这种分配方式称为“指针碰撞”(BumpthePointer)。
- 如果Java堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为“空闲列表”(FreeList)。
- 选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。
- 因此,在使用Serial、ParNew等带Compact过程的收集器时,系统采用的分配算法是指针碰撞,而使用CMS这种基于Mark-Sweep算法的收集器时,通常采用空闲列表。
- 除如何划分可用空间之外,还有另外一个需要考虑的问题是对象创建在虚拟机中是非常频繁的行为,即使是仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的,可能出现正在给对象 A 分配内存,指针还没来得及修改,对象 B 又同时使用了原来的指针来分配内存的情况。
- 解决这个问题有两种方案,一种是对分配内存空间的动作进行同步处理——实际上虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性;
- 另一种是把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在 Java 堆中预先分配一小块内存,称为本地线程分配缓冲(ThreadLocalAllocationBuffer,TLAB)。
- 哪个线程要分配内存,就在哪个线程的 TLAB 上分配,只有 TLAB 用完并分配新的 TLAB 时,才需要同步锁定。
- 虚拟机是否使用 TLAB,可以通过 -XX:+/-UseTLAB 参数来设定。
- 内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),如果使用TLAB,这一工作过程也可以提前至TLAB分配时进行。这一步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。
- 接下来,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。这些信息存放在对象的对象头(ObjectHeader)之中。
- 根据虚拟机当前的运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。
- 在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从Java程序的视角来看,对象创建才刚刚开始——<init>方法还没有执行,所有的字段都还为零。
- 所以,一般来说(由字节码中是否跟随invokespecial指令所决定),执行new指令之后会接着执行<init>方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。
2.3.2 对象的内存布局
在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:
对象头(Header)、实例数据(Instance Data)和对齐填充 (Padding)。
- HotSpot虚拟机的对象头包括两部分信息,第一部分用于存储对象自身的运行时数据,如哈希码(HashCode)、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等.
对象头的另外一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。 - 实例数据部分是对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容。无论是从父类继承下来的,还是在子类中定义的,都需要记录起来。
- 第三部分对齐填充并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。
由于 HotSpot VM 的自动内存管理系统要求对象起始地址必须是 8 字节的整数倍,换句话说,就是对象的大小必须是8字节的整数倍。
2.3.3 对象的访问定位
目前主流的访问方式有使用句柄和直接指针两种。
- 这两种对象访问方式各有优势,使用句柄来访问的最大好处就是 reference 中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而 reference 本身不需要修改。
- 使用直接指针访问方式的最大好处就是速度更快,它节省了一次指针定位的时间开销,由于对象的访问在 Java 中非常频繁,因此这类开销积少成多后也是一项非常可观的执行成本。
- 就本书讨论的主要虚拟机 Sun HotSpot而言,它是使用第二种方式进行对象访问的.
第 3 章 垃圾收集器与内存分配策略
3.1 概述
垃圾收集需要完成的 3 件事:
- 哪些内存需要回收?
- 什么时候回收?
- 如何回收?
对于程序计数器、虚拟机栈、本地方法栈这 3 个区域来说, 它们随线程而生,随线程而灭,所以不需要考虑回收的问题.
只有 Java 堆和方法区需要考虑回收的问题,因为它们的内存分配和回收是动态的.
3.2 对象已死吗
如果一个对象不能再被任何途径使用,那么就表示已经"死亡".
3.2.1 引用计数算法
- 给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加 1;
- 当引用失效时,计数器值就减 1;
- 任何时刻计数器为 0 的对象就是不可能再被使用的。
优点: 实现简单,判定效率也很高.
缺点: 它很难解决对象之间相互循环引用的问题。
3.2.2 可达性分析算法
这个算法的基本思路就是通过一系列的称为 “GC Roots” 的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连(用图论的话来说,就是从 GC Roots 到这个对象不可达)时,则证明此对象是不可用的。
在 Java 语言中,可作为 GC Roots 的对象包括下面几种:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象。
- 方法区中类静态属性引用的对象。
- 方法区中常量引用的对象。
- 本地方法栈中 JNI(即一般说的 Native 方法)引用的对象。
3.2.4 生存还是死亡
- 在调用 System.gc() 方法后,会回收掉可达性分析算法中不可达的对象.
- 如果想挽救这个对象,可以重写这个对象的 finalize() 方法,在里面将自己重新与引用链上的任何一个对象建立关联即可.
- 因为 System.gc() 方法里面会调用 finalize() 方法.
- 但是每个对象的 finalize() 方法只会被调用一次.
注意: 不要在代码中使用这两个方法.
3.2.5 回收方法区
堆中的一次垃圾收集可以回收 70%~95% 的空间,而永久代中的收集效率远低于此.
永久代的垃圾收集主要回收两部分内容:废弃常量和无用的类。
- 回收废弃常量与回收 Java 堆中的对象非常类似, 如果这个常量没有任何对象引用, 就可以被回收.
回收无用类需要满足下面 3 个条件:
- 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。
- 加载该类的 ClassLoader 已经被回收。
- 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
3.3 垃圾收集算法
3.3.1 标记-清除算法
这是最基础的收集算法,后续的收集算法都是基于这种思路并优化得到的.
算法分为“标记”和“清除”两个阶段:
- 首先标记出所有需要回收的对象,
- 在标记完成后统一回收所有被标记的对象.
优点: 实现简单
缺点:
- 标记和清除两个过程的效率都不高,
- 标记清除之后会产生大量不连续的内存碎片.
3.3.2 复制算法
适用于新生代区域的垃圾回收.
- 它将可用内存按容量划分为大小相等的两块,每次只使用其中的一 块。
- 当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,
- 然后再把已使用过的内存空间一次清理掉。
优点: 实现简单, 运行高效, 解决了内存碎片的问题,
缺点: 可用内存只有实际的一半.
改进方法:
将内存分为一块较大的 Eden 空间和两块较小的 Survivor 空间,比例是 8:1:1 每次只使用 Eden 和其中一块 Survivor.
当回收时,将 Eden 和 Survivor 中还存活着的对象一次性地复制到另外一块 Survivor 空间上,最后清理掉 Eden 和刚才用过的 Survivor 空间。
3.3.3 标记-整理算法
适用于老年代内存区域的垃圾回收.
- 首先标记出所有需要回收的对象,
- 然后让所有存活的对象都向一端移动,
- 最后直接清理掉端边界以外的内存.
3.3.4 分代收集算法
- 这种算法并没有什么新的思想,只是根据对象存活周期的不同将内存划分为几块。
- 一般是把 Java 堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。
3.5 垃圾收集器
图 3-5 展示了 7 种作用于不同分代的收集器,如果两个收集器之间存在连线,就说明它们可以搭配使用。
3.5.1 Serial收集器
这 个收集器是一个单线程的收集器, 并且在它进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束。
如果你妈妈一边打扫,你 一边乱扔纸屑,这房间就打扫不完.
优点: 简单高效 (与其他收集器的单线程比)
缺点: Stop The World
3.5.2 ParNew 收集器
ParNew 收集器其实就是 Serial 收集器的多线程版本.
ParNew 收集器也是使用 -XX:+UseConcMarkSweepGC 选项后的默认新生代收集器,也可以使用-XX:+UseParNewGC 选项来强制指定它。
3.5.3 Parallel Scavenge 收集器
- CMS 等收集器的关注点是缩短垃圾收集时用户线程的停顿时间,而 Parallel Scavenge 收集器的目标则是达到一个可控制的吞吐量 (Throughput)。
- 所谓吞吐量就是 CPU 用于运行用户代码的时间与 CPU 总消耗时间的比值,
即吞吐量=运行用户代码时间/(运行用户代码时间 +垃圾收集时间). - 例如虚拟机总共运行了 100 分钟,其中垃圾收集花掉 1 分钟,那吞吐量就是99%。
- Parallel Scavenge 收集器提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间的 -XX:MaxGCPauseMillis 参数以及直接设置吞吐量大小的 -XX:GCTimeRatio 参数。
- 除上述两个参数之外,Parallel Scavenge 收集器还有一 个参数 -XX:+UseAdaptiveSizePolicy 值得关注。这是一个开关参数,当这 个参数打开之后, 虚拟机会动态调整新生代的大小(-Xmn)、Eden与 Survivor区的比例(-XX:SurvivorRatio)、晋升老年代对象年龄(- XX:PretenureSizeThreshold)等细节参数.
3.5.4 Serial Old 收集器
Serial Old 是 Serial 收集器的老年代版本,它同样是一个单线程收集器,使用“标记-整理”算法。
3.5.5 Parallel Old 收集器
Parallel Old 是 Parallel Scavenge 收集器的老年代版本,使用多线程和“标记-整理”算法。
3.5.6 CMS 收集器
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。
做到了在你的妈妈打扫房间的时候你还能一边往地上扔纸屑。
整个回收过程分为4个步骤,包括:
- 初始标记(CMS initial mark)
- 并发标记(CMS concurrent mark)
- 重新标记(CMS remark)
- 并发清除(CMS concurrent sweep)
其中,初始标记、重新标记这两个步骤仍然需要"Stop The World"。
优点: 并发收集, 低停顿
缺点:
- CMS 收集器对 CPU 资源非常敏感。
- CMS 收集器无法处理浮动垃圾(Floating Garbage). 由于 CMS 并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新 的垃圾不断产生, 这一部分垃圾就 称为“浮动垃圾”。
- CMS 是一款基于“标记—清 除”算法实现的收集器, 这意味着收集结束时会有大量空间碎片产生。
3.5.7 G1 收集器
G1(Garbage-First)收集器是当今收集器技术发展的最前沿成果之 一.
G1是一款面向服务端应用的垃圾收集器(低延迟)。
- 使用G1收集器时,Java 堆的内存布局就与其他收集器有很大差别,它将整个 Java 堆划分为多个大小相等的独立区域 (Region),
- G1 跟踪各个 Region 里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),
- 在后台维护一个优先列表,
- 每次根据允许的收集时间,优先回收价值最大的 Region(这也就是 Garbage-First 名称的来由)。
G1 收集器的运作大致可划分为以下几个步骤:
- 初始标记(Initial Marking)
- 并发标记(Concurrent Marking)
- 最终标记(Final Marking)
- 筛选回收(Live Data Counting and Evacuation)
3.6 内存分配与回收策略
Java 技术体系中所提倡的自动内存管理最终可以归结为自动化地解决了两个问题:给对象分配内存以及回收分配给对象的内存。
- 对象主要分配在新生代的Eden区上,
- 如果启动了本地线程分配缓冲,将按线程优先在 TLAB 上分配。
- 少数情况下也可能会直接分配在老年代中.
3.6.1 对象优先在 Eden 分配
大多数情况下,对象在新生代 Eden 区中分配。当 Eden 区没有足够空间进行分配时,虚拟机将发起一次 Minor GC。
3.6.2 大对象直接进入老年代
需要大量连续内存空间的Java对象,最典型的大对象就是那种很长的字符串以及数组.
3.6.3 长期存活的对象将进入老年代
- 如果对象在 Eden 出生并经过第一次 Minor GC 后仍然存活,并且能被Survivor 容纳的话,将被移动到Survivor空间中,并且对象年龄设为1。
- 对象在 Survivor 区中每“熬过”一次 Minor GC,年龄就增加 1 岁,当它的年龄增加到一定程度(默认为 15 岁),就将会被晋升到老年代中。
3.6.4 动态对象年龄判定
如果在 Survivor 空间中某个年龄的对象数量总和大于 Survivor 空间的一半, 那么大于或等于该年龄的对象就可以直接进入老年代.
3.7 本章小结
本章介绍了垃圾收集的算法、几款 JDK 1.7 中提供的垃圾收集器特点以及运作原理。
第三部分 虚拟机执行子系统
第 6 章 类文件结构
6.3 Class 类文件的结构
- Class 文件是一组以 8 位字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑地排列在 Class 文件之中.
- Class 文件格式采用一种类似于 C 语言 结构体的伪结构来存储数据,这种伪结构中只有两种数据类型:无符号数和表.
- 无符号数属于基本的数据类型,以 u1、u2、u4、u8 来分别代表 1 个 字节、2 个字节、4 个字节和 8 个字节的无符号数,无符号数可以用来描述数字、索引引用、数量值或者按照 UTF-8 编码构成字符串值。
- 表是由多个无符号数或者其他表作为数据项构成的复合数据类型, 所有表都习惯性地以 “_info” 结尾。表用于描述有层次关系的复合结构的数据,整个 Class 文件本质上就是一张表.
6.3.1 魔数与Class文件的版本
- 每个 Class 文件的头 4 个字节称为魔数(Magic Number),它的唯一作用是确定这个文件是否为一个能被虚拟机接受的 Class文件。
- 紧接着魔数的 4 个字节存储的是 Class 文件的版本号:第 5 和第 6 个字 节是次版本号(Minor Version),第 7 和第 8 个字节是主版本号(Major Version)。
- Java 的版本号是从 45 开始.
6.3.2 常量池
紧接着主次版本号之后的是常量池入口.
常量池中主要存放两大类常量:字面量(Literal) 和 符号引用 (Symbolic References)。
字面量比较接近于 Java 语言层面的常量概念,如文本字符串、声明为 final 的常量值等。
符号引用则属于编译原理方面的概念,包括了下面三类常量:
- 类和接口的全限定名(Fully Qualified Name)
- 字段的名称和描述符(Descriptor)
- 方法的名称和描述符
6.3.3 访问标志
在常量池结束之后,紧接着的两个字节代表访问标志 (access_flags),这个标志用于识别一些类或者接口层次的访问信息.
6.3.4 类索引、父类索引与接口索引集合
类索引(this_class)和父类索引(super_class)都是一个 u2 类型的 数据,而接口索引集合(interfaces)是一组 u2 类型的数据的集合,Class 文件中由这三项数据来确定这个类的继承关系。
6.3.5 字段表集合
字段表(field_info)用于描述接口或者类中声明的变量, 不包括局部变量。
全限定名: 例,“org/fenixsoft/clazz/TestClass;” 仅仅是把类全 名中的“.”替换成了“/”而已.
简单名称是指没有类型和参数修饰的方法或者字段名称.
描述符的作用是用来描述字段的数据类型、方法的参数列表(包括数量、类型以及顺序)和返回值。
对于数组类型,每一维度将使用一个前置的 “[” 字符来描述,如一 个定义为 “java.lang.String[][]” 类型的二维数组,将被记录为:" [[Ljava/lang/String;",一个整型数组 “int[]” 将被记录为 “[I”。
用描述符来描述方法时,按照先参数列表,后返回值的顺序描述, 参数列表按照参数的严格顺序放在一组小括号“()”之内。
如:
- 方法 void inc() 的描述符为"()V",
- 方法 java.lang.String toString() 的描述符为" ()Ljava/lang/String;",
- 方法 int indexOf(char[]source,int sourceOffset,int sourceCount,char[]target,int targetOffset,int targetCount,int fromIndex)的 描述符为"([CII[CIII)I"。
6.3.6 方法表集合
Class 文件存储格式中对方法的描述与对字段的描述几乎采用了完全一致的方式,
方法表的结构如同字段表一样,依次包括了访问标志(access_flags)、名称索引(name_index)、描述符索引 (descriptor_index)、属性表集合(attributes)几项.
6.3.7 属性表集合
在 Class 文件、字段表、方法表都可以携带自己的属性表 (attribute_info)集合,以用于描述某些场景专有的信息。
6.4 字节码指令简介
Java 虚拟机的指令由一个字节长度的、代表着某种特定操作含义的数字(称为操作码,Opcode)以及跟随其后的零至多个代表此操作所需参数(称为操作数,Operands)而构成。
6.7 本章小结
本章详细讲解了 Class 文件结构中的各个组成部分,以及每个部分的定义、数据结构和使用方法。
从第 7 章开始,我们将以动态的、运行时的角度去看看字节码流在虚拟机执行引擎中是怎样被解释执行的。
第7章 虚拟机类加载机制
7.1 概述
虚拟机把描述类的数据从 Class 文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的 Java 类型, 这就是虚拟机的类加载机制。
7.2 类加载的时机
类从被加载到虚拟机内存中开始,到卸载出内存为止.
7.3 类加载的过程
7.3.1 加载
- 通过一个类的全限定名来获取定义此类的二进制字节流。
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
- 在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口。
7.3.2 验证
- 文件格式验证
- 是否以魔数0xCAFEBABE开头。
- 主、次版本号是否在当前虚拟机处理范围之内。
- 元数据验证
- 这个类是否有父类(除了 java.lang.Object 之外,所有的类都应当有 父类)。
- 这个类的父类是否继承了不允许被继承的类(被final修饰的类)。
- 如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的 所有方法。
- 字节码验证
- 保证跳转指令不会跳转到方法体以外的字节码指令上。
- 保证方法体中的类型转换是有效的.
- 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作.
- 符号引用验证
- 最后一个阶段的校验发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在连接的第三阶段——解析阶段中发生。
- 符号引用中通过字符串描述的全限定名是否能找到对应的类。
- 在指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段。
- 符号引用中的类、字段、方法的访问性(private、protected、 public、default)是否可被当前类访问。
7.3.3 准备
准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。
7.3.4 解析
- 解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程.
- 符号引用(Symbolic References):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。
- 直接引用(Direct References):直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。
7.3.5 初始化
- 初始化阶段是执行类构造器< clinit>()方法的过程。
- <clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生的,
- 编译器收集的顺序是由语句在源文件中出现的顺序所决定的,
- 静态语句块中只能访问到定义在静态语句块之前的变量,
- 定义在它之后的变量,在前面的静态语句块 可以赋值,但是不能访问.
- <clinit>()方法与类的构造函数(或者说实例构造器<init>()方 法)不同,它不需要显式地调用父类构造器,虚拟机会保证在子类的< clinit>()方法执行之前,父类的<clinit>()方法已经执行完毕。
- 因此在虚拟机中第一个被执行的<clinit>() 方法的类肯定是 java.lang.Object。
7.4 类加载器
- 虚拟机设计团队把类加载阶段中的“通过一个类的全限定名来获取描述此类的二进制字节流”这个动作放到 Java 虚拟机外部去实现,以便让应用程序自己决定如何去获取所需要的类。
- 实现这个动作的代码模块称为“类加载器”。
7.4.1 类与类加载器
对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在 Java 虚拟机中的唯一性.
7.4.2 双亲委派模型
- 启动类加载器(Bootstrap ClassLoader):
- 前面已经介绍过,这个类将器负责将存放在<JAVA_HOME>\lib 目录中的,或者被 Xbootclasspath 参数所指定的路径中的,
- 并且是虚拟机识别的(仅按照文件名识别,如rt.jar,名字不符合的类库即使放在lib目录中也不会被加载)类库加载到虚拟机内存中。
- 扩展类加载器(Extension ClassLoader):
- 这个加载器由 sun.misc.Launcher $ExtClassLoader 实现,它负责加载<JAVA_HOME> \lib\ext 目录中的,或者被 java.ext.dirs 系统变量所指定的路径中的所有类库,
- 开发者可以直接使用扩展类加载器。
- 应用程序类加载器(Application ClassLoader):
- 这个类加载器由 sun.misc.Launcher $App-ClassLoader 实现。由于这个类加载器是 ClassLoader 中的 getSystemClassLoader() 方法的返回值,所以一般也称它为系统类加载器。
- 它负责加载用户类路径(ClassPath)上所指定的类库,开发者可以直接使用这个类加载器,
- 如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。
双亲委派模型的工作过程是:
- 如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,
- 而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,
- 因此所有的加载请求最终都应该传送到顶层的启动类加载器中,
- 只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。
7.5 本章小结
本章介绍了类加载过程的“加载”、“验证”、“准备”、“解析”和“初始 化” 5 个阶段中虚拟机进行了哪些动作,还介绍了类加载器的工作原理及其对虚拟机的意义。
第8章 我们将一起来看看虚拟机如何执行定义在 Class 文件里的字节码。
第8章 虚拟机字节码执行引擎
8.1 概述
- 在不同的虚拟机实现里面,执行引擎在执行 Java 代码的时候可能会有解释执行 (通过解释器执行)和编译执行(通过即时编译器产生本地代码执行) 两种选择.
- 所有的Java虚拟机的执行引擎都是一 致的:输入的是字节码文件,处理过程是字节码解析的等效过程,输出的是执行结果,
- 本章将主要从概念模型的角度来讲解虚拟机的方法调用和字节码执行。
8.2 运行时栈帧结构
- 栈帧 (Stack Frame)存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。
- 每一个方法从调用开始至执行完成的过程, 都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。
- 执行引擎运行的所有字节码指令都只针对当前栈帧进行操作.
8.2.1 局部变量表
局部变量表(Local Variable Table)是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。
8.2.2 操作数栈
- 操作数栈(Operand Stack)也常称为操作栈,它是一个后入先出 (Last In First Out,LIFO)栈。
- 举个例子,整数加法的字节码指令 iadd 在运行的时候操作数栈中最接近栈顶的两个元素已经存入了两个 int 型的数值,当执行这个指令时,会将这两个 int 值出栈并相加,然后将相加的结果入栈。
- Java虚拟机的解释执行引擎称为“基于栈的执行引擎”,其中所指的“栈”就是操作数栈。
8.2.3 动态连接
- 每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking)。
- 符号引用一部分会在类加载阶段或者第一次使用的时候就转化为直接引用,这种转化称为静态解析。
- 另外一部分将在每一次运行期间转化为直接引用,这部分称为动态连接。
8.2.4 方法返回地址
- 当一个方法开始执行后,只有两种方式可以退出这个方法。
- 第一种 方式是执行引擎遇到任意一个方法返回的字节码指令.
- 另外一种退出方式是,在方法执行过程中遇到了异常.
- 无论采用何种退出方式,在方法退出之后,都需要返回到方法被调用的位置,程序才能继续执行,
- 方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层方法的执行状态。
8.3 方法调用
方法调用并不等同于方法执行,方法调用阶段唯一的任务就是确定被调用方法的版本(即调用哪一个方法),暂时还不涉及方法内部的具体运行过程。
8.3.1 解析
所有方法调用中的目标方法在 Class 文件里面都是一个常量池中的符号引用,在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用,这种解析能成立的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。
8.3.2 分派
- 静态分派
所有依赖静态类型来定位方法执行版本的分派动作称为静态分派。
静态分派的典型应用是方法重载。
静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的。 - 动态分派
它和多态性的另外一个重要体现——重写(Override)有着很密切的关联。
我们把这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。 - 单分派与多分派
8.4 基于栈的字节码解释执行引擎
虚拟机是如何调用方法的内容已经讲解完毕,从本节开始,我们来探讨虚拟机是如何执行方法中的字节码指令的。
Java虚拟机的执行引擎在执行Java代码的时候都有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择.
8.4.1 解释执行
Java 虚拟机在执行前先对程序源码进行词法分析和语法分析处理,把源码转化为抽象语法树(Abstract Syntax Tree,AST).
Java语言中,Javac编译器完成了程序代码经过词法分析、语法分析到抽象语法树,再遍历语法树生成线性的字节码指令流的过程。
8.4.2 基于栈的指令集与基于寄存器的指令集
基于栈的指令集计算 “1+1”
基于寄存器的指令集计算 “1+1”
- 基于栈的指令集主要的优点就是可移植,寄存器由硬件直接提供 ,程序直接依赖这些硬件寄存器则不可避免地要受到硬件的约束。
- 栈架构的指令集还有一些其他的优点, 如代码相对更加紧凑(字节码中每个字节就对应一条指令,而多地址指令集中还需要存放参数)、编译器实现更加简单(不需要考虑空间分配的问题,所需空间都在栈上操作)等。
- 栈架构指令集的主要缺点是执行速度相对来说会稍慢一些。
8.5 本章小结
本章中,我们分析了虚拟机在执行代码时,如何找到正确的方法、如何执行方法内的字节码,以及执行代码时涉及的内存结构。
第四部分 程序编译和代码优化
第10章 早期(编译期)优化
- Java语言的“编译期”其实是一段“不确定”的操作过程,
- 因为它可能是指一个前端编译器把 *.java 文 件转变成 *.class 文件的过程;
- 也可能是指虚拟机的后端运行期编译器 (JIT编译器,Just In Time Compiler)把字节码转变成机器码的过程;
- 还可能是指使用静态提前编译器(AOT编译器,Ahead Of Time Compiler)直接把 *.java 文件编译成本地机器代码的过程。
前端编译器:Sun 的 Javac、Eclipse JDT 中的增量式编译器 (ECJ)。
JIT编译器:HotSpot VM 的 C1、C2 编译器。
AOT编译器:GNU Compiler for the Java(GCJ)、Excelsior JET 。
10.2 Javac 编译器
10.2.1 Javac的源码与调试
从Sun Javac的代码来看,编译过程大致可以分为 3 个过程,分别是:
- 解析与填充符号表过程。
- 插入式注解处理器的注解处理过程。
- 分析与字节码生成过程。
10.2.2 解析与填充符号表
解析步骤包括了经典程序编译原理中的词法分析和语法分析两个过程。
- 词法、语法分析
- 词法分析是将源代码的字符流转变为标记(Token)集合,
- 单个字符是程序编写过程的最小元素,而标记则是编译过程的最小元素,
- 关键字、变量名、字面量、运算符都可以成为标记,
- 如 “int a=b+2” 这句代码 包含了 6 个标记,分别是 int、a、=、b、+、2,虽然关键字 int 由 3 个字符 构成,但是它只是一个 Token,不可再拆分。
- 在Javac的源码中,词法分析过程由 com.sun.tools.javac.parser.Scanner 类来实现。
- 语法分析
- 语法分析是根据 Token 序列构造抽象语法树的过程,
- 抽象语法树 (Abstract Syntax Tree,AST)是一种用来描述程序代码语法结构的树形表示方式,
- 语法树的每一个节点都代表着程序代码中的一个语法结构 (Construct),例如包、类型、修饰符、运算符、接口、返回值甚至代码注释等都可以是一个语法结构。
语法分析后编译器就基本不会再对源码文件进行操作了, 后续的操作都建立在抽象语法树之上。
填充符号表
- 符号表(Symbol Table)是由一组符号地址和符号信息构成的表格,读者可以把它想象成哈希表中K-V值对的形式.
- 符号表中所登记的信息在编译的不同阶段都要用到。
- 在语义分析中,符号表所登记的内容将用于语义检查(如检查一个名字的使用和原先的说明是否一致)和产生中间代码。
- 在目标代码生成阶段,当对符号名进行地址分配时,符号表是地址分配的依据。
10.2.3 注解处理器
如果这些 插件在处理注解期间对语法树进行了修改,编译器将回到解析及填充符号表的过程重新处理,直到所有插入式注解处理器都没有再对语法树进行修改为止,每一次循环称为一个Round,也就是图10-4中的回环过程。
10.2.4 语义分析与字节码生成
- 语法分析之后,编译器获得了程序代码的抽象语法树表示,语法树能表示一个结构正确的源程序的抽象,但无法保证源程序是符合逻辑的。
- 而语义分析的主要任务是对结构上正确的源程序进行上下文有关性质的审查,如进行类型审查。
Javac 的编译过程中,语义分析过程分为标注检查以及数据及控制流分析两个步骤.
- 标注检查
- 标注检查步骤检查的内容包括诸如变量使用前是否已被声明、变量 与赋值之间的数据类型是否能够匹配等。
- 在标注检查步骤中,还有一个重要的动作称为常量折叠.
就是将 int a=1+2; 优化为 int a=3;
- 数据及控制流分析
- 数据及控制流分析是对程序上下文逻辑更进一步的验证,
- 它可以检查出诸如程序局部变量在使用前是否有赋值、方法的每条路径是否都有返回值、是否所有的受查异常都被正确处理了等问题。
- 解语法糖
- 通常来说,使用语法糖能够增加程序的可读性,从而减少程序代码出错的机会。
- Java中最常用的语法糖主要是前面提到过的泛型、变长参数、自动装箱/拆箱等,虚拟机运行时不支持这些语法,它们在编译阶段还原回简单的基础语法结构,这个过程称为解语法糖。
- 字节码生成
- 字节码生成是Javac编译过程的最后一个阶段。
- 字节码生成阶段不仅仅是把前面各个步骤所生成的信息(语法树、符号表)转化成字节码写到磁盘中, 编译器还进行了少量的代码添加和转换工作。
- 例如,前面章节中多次提到的实例构造器<init>()方法和类构造器 <clinit>()方法就是在这个阶段添加到语法树之中的.
完成了对语法树的遍历和调整之后,就会把填充了所有所需信息的符号表交给 com.sun.tools.javac.jvm.ClassWriter 类,由这个类的 writeClass() 方法输出字节码,生成最终的 Class 文件,到此为止整个编译过程宣告结束。
10.3 Java语法糖的味道
10.3.1 泛型与类型擦除
- 泛型的本质是参数化类型.
- 这种参数类型可以用在类、接口和方法的创建中,分别称为 泛型类、泛型接口和泛型方法。
- 擦除法所谓的擦除,仅仅是对方法的 Code 属性中的字节码进行擦除,实际上元数据中还是保留了泛型信息,这也是我们能通过反射手段取得参数化类型的根本依据。
10.3.2 自动装箱、拆箱与遍历循环
- 自动装箱、拆箱在编译之后被转化成了对应的包装方法和还原方法,如本例中的 Integer.valueOf() 与 Integer.intValue() 方法.
- 遍历循环则把代码还原成了迭代器的实现,这也是为何遍历循环需要被遍历的类实现 Iterable 接口的原因。
10.3.3 条件编译
- Java语言中条件编译的实现,也是Java语言的一颗语法糖,根据布尔常量值的真假,编译器将会把分支中不成立的代码块消除掉。
- 除了本节中介绍的泛型、自动装箱、自动拆箱、遍历循环、变长参数和条件编译之外,Java 语言还有不少其他的语法糖,如内部类、枚举类、断言语句、对枚举和字符串的 switch 支持、try 语句中定义和关闭资源等.
10.5 本章小结
在本章中,我们从编译器源码实现的层次上了解了 Java 源代码编译为字节码的过程,分析了 Java 语言中泛型、主动装箱/拆箱、条件编译等多种语法糖的前因后果.
第11章 晚期(运行期)优化
11.1 概述
即时编译器(Just In Time Compiler,简 称JIT编译器):
虚拟机在运行时,将"热点代码"编译成机器码,以提高执行效率.
在本章中,我们将走进虚拟机的内部,探索即时编译器的运作过程。
11.2 HotSpot虚拟机内的即时编译器
11.2.1 解释器与编译器
解释器与编译器两者各有优势:
- 当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即执行。
- 在程序运行后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码之后,可以获取更高的执行效率。
HotSpot 虚拟机中内置了两个即时编译器,分别称为 Client Compiler 和 Server Compiler,或者简称为 C1 编译器和 C2 编译器(也叫 Opto 编译器)。
11.2.2 编译对象与触发条件
“热点代码”
- 被多次调用的方法。
- 被多次执行的循环体。
后者是为了解决一个方法只被调用过一次或少量的几次,但是方法体内部存在循环次数较多的循环体的问题.
热点探测(Hot Spot Detection)
- 基于采样的热点探测(Sample Based Hot Spot Detection):采用这种方法的虚拟机会周期性地检查各个线程的栈顶,如果发现某个(或某 些)方法经常出现在栈顶,那这个方法就是“热点方法”。
好处是实现简单、高效,还可以很容易地获取方法调用关系 (将调用堆栈展开即可),
缺点是容易受到线程阻塞的影响而扰乱热点探测。 - 基于计数器的热点探测(Counter Based Hot Spot Detection):采用这种方法的虚拟机会为每个方法(甚至是代码块)建立计数器,统计方法的执行次数,如果执行次数超过一定的阈值就认为它是“热点方法”。
好处是统计结果更加精确和严谨
缺点是需要为每个方法建立并维护计数器.
在HotSpot虚拟机中使用的是第二种——基于计数器的热点探测方法, 因此它为每个方法准备了两类计数器:方法调用计数器 (Invocation Counter)和回边计数器(Back Edge Counter)。
方法调用计数器
- 顾名思义,这个计数器就用于统计方法被调用的次数,
- 它的默认阈值在 Client 模式下是1500次,
- 在 Server 模式下是10 000次,
- 这个阈值可以通过虚拟机参数 XX:CompileThreshold 来人为设定。
- 方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,即一段时间之内方法被调用的次数。
回边计数器
- 它的作用是统计一个方法中循环体代码执行的次数,
- 在字节码中遇到控制流向后跳转的指令称为“回边”(Back Edge)。
- Client 模式虚拟机的回边计数器的阈值为13995。
- Server 模式 虚拟机回边计数器的阈值为10700。
- 回边计数器统计的是方法循环体执行的绝对次数。
调整回边计算器值是为了把回边计数器的值降低一些,以便继续在解释器中执行循环,等待编译器输出编译结果, 就能执行本地代码版本了.
11.2.3 编译过程
在默认设置下,无论是方法调用产生的即时编译请求,还是 OSR (On Stack Replacement,简称为 OSR 编译,即方法栈帧还在栈上,方法就被替换了, 栈上替换)编译请求,虚拟机在代码编译器还未完成之前,都仍然将按照解释方式继续执行,而编译动作则在后台的编译线程中进行。
对于 Client Compiler 来说,它是一个简单快速的三段式编译器,主要的关注点在于局部性的优化,而放弃了许多耗时较长的全局优化手段。
- 在第一个阶段,一个平台独立的前端将字节码构造成一种高级中间代码表示(High-Level Intermediate Representaion,HIR)。
- 在第二个阶段,一个平台相关的后端从HIR中产生低级中间代码表示(Low-Level Intermediate Representation,LIR)
- 最后阶段是在平台相关的后端使用线性扫描算法(Linear Scan Register Allocation)在LIR上分配寄存器,并在LIR上做窥孔 (Peephole)优化,然后产生机器代码。
而Server Compiler则是专门面向服务端的典型应用并为服务端的性能配置特别调整过的编译器,也是一个充分优化过的高级编译器 ,它会执行所有经典的优化动作,如无用代码消除(Dead Code Elimination)、循环展开 (Loop Unrolling)、循环表达式外提(Loop Expression Hoisting)、消除公共子表达式(Common Subexpression Elimination)、常量传播 (Constant Propagation)、基本块重排序(Basic Block Reordering) 等.
11.3 编译优化技术
以编译方式执行本地代码比解释方式更快,除去虚拟机解释执行字节码时额外消耗时间的原因外,还有一个很重要的原因就是虚拟机设计团队几乎把对代码的所有优化措施都集中在了即时编译器之中.
11.3.2 公共子表达式消除
如果一个表达式 E 已经计算过了,并且从先前的计算到现在 E 中所有变量的值都没有发生变化,那么 E 的这次出现就成为了公共子表达式。
11.3.3 数组边界检查消除
如果编译器通过数据流分析可以判定循环变量的取值范围永远在区间[0,array.length)之内,那在整个循环中就可以把数组的上下界检查消除,这可以节省很多次的条件判 断操作。
11.3.4 方法内联
它是编译器最重要的优化手段之一,除了消除方法调用的成本之外,它更重要的意义是为其他优化手段建立良好的基础.
11.3.5 逃逸分析
逃逸分析(Escape Analysis)并不是直接优化代码的手段,而是为其他优化手段提供依据的分析技术。
当一个对象在方法 中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中,称为方法逃逸。
甚至还有可能被外部线程访问到,譬如赋值给类变量或可以在其他线程中访问的实例变量,称为线程逃逸。
如果能证明一个对象不会逃逸到方法或线程之外, 则可能为这个变量进行一些高效的优化.
- 栈上分配(Stack Allocation):
让这个对象在栈上分配内存将会是一个很不错的主意,对象所占用的内存空间就可以随栈帧出栈而销毁。 - 同步消除(Synchronization Elimination):
这个变量的读写不会有竞争,对这个变量实施的同步措施也就可以消除掉。 - 标量替换(Scalar Replacement):
标量(Scalar)是指一个数据已 经无法再分解成更小的数据来表示了,Java 虚拟机中的原始数据类型 (int、long 等数值类型以及 reference 类型等)都不能再进一步分解,它们就可以称为标量。
相对的,如果一个数据可以继续分解,那它就称作聚合量(Aggregate),Java中的对象就是最典型的聚合量。
将对象拆分成标量后,除了可以让对象的成员变量在栈上分配和读写之外,还可以为后续进一步的优化手段创建条件。
11.5 本章小结
第 10~11 两章分别介绍了 Java 程序从源码编译成字节码和从字节码编译成本地机器码的过程,Javac 字节码编译器与虚拟机内的 JIT 编译器的执行过程合并起来其实就等同于一个传统编译器所执行的编译过程。
第五部分 高效并发
第 12 章 Java内存模型与线程
12.3 Java内存模型
12.3.1 主内存与工作内存
- Java内存模型规定了所有的变量都存储在主内存(Main Memory) 中(此处的主内存仅是虚拟机内存的一部分)。
- 每条线程还有自己的工作内存(Working Memory,可与前面讲的处理器高速缓存类比),线程的工作内存中保存了被该线程使用到的变量的主内存副本拷贝,
- 线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量。
- 不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成.
12.3.2 内存间交互操作
Java内存模型中定义了以下 8 种操作来完成,虚拟机实现时必须保证下面提及的每一种操作都是原子的、不可再分的.
- lock(锁定):作用于主内存的变量,它把一个变量标识为一条线
程独占的状态。 - unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的
变量释放出来,释放后的变量才可以被其他线程锁定。 - read(读取):作用于主内存的变量,它把一个变量的值从主内存
传输到线程的工作内存中,以便随后的load动作使用。 - load(载入):作用于工作内存的变量,它把read操作从主内存中
得到的变量值放入工作内存的变量副本中。 - use(使用):作用于工作内存的变量,它把工作内存中一个变量
的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节
码指令时将会执行这个操作。 - assign(赋值):作用于工作内存的变量,它把一个从执行引擎接
收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节
码指令时执行这个操作。 - store(存储):作用于工作内存的变量,它把工作内存中一个变量
的值传送到主内存中,以便随后的write操作使用。 - write(写入):作用于主内存的变量,它把store操作从工作内存中
得到的变量的值放入主内存的变量中。
如果要把一个变量从主内存复制到工作内存,那就要顺序地执行
read 和 load 操作,
如果要把变量从工作内存同步回主内存,就要顺序地执行 store 和 write 操作。
12.3.3 对于volatile型变量的特殊规则
当一个变量定义为 volatile 之后,它将具备两种特性,
- 第一是保证此变量对所有线程的可见性,这里的“可见性”是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。
- 使用 volatile 变量的第二个语义是禁止指令重排序优化,普通的变量仅仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序 一致。
- volatile 不保证原子性.
12.3.4 对于 long 和 double 型变量的特殊规则
- Java内存模型要求 lock、unlock、read、load、assign、use、store、write 这 8 个操作都具有原子性,
- 但是对于 64 位的数据类型(long和 double), 可以不保证 load、store、read和 write 这 4 个操作的原子性 ,
- 这点就是所谓的 long 和 double 的非原子性协定 (Nonatomic Treatment ofdouble and long Variables)。
12.3.5 原子性、可见性与有序性
原子性(Atomicity):由Java内存模型来直接保证的原子性变量操作包括 read、load、assign、use、store 和 write,我们大致可以认为基本数据类型的访问读写是具备原子性的.
可见性(Visibility):可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。
Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的,无论是普通变量还是volatile变量都是如此,
普通变量与volatile变量的区别是,volatile的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。
有序性(Ordering):Java程序中天然的有序性可以总结为一句话:
如果在本线程内观察,所有的操作都是有序的;
如果在一个线程中观察另一 个线程,所有的操作都是无序的。
前半句是指“线程内表现为串行的语义”(Within-Thread As-If-Serial Semantics),
后半句是指“指令重排序”现象和“工作内存与主内存同步延迟”现象。
12.3.6 先行发生原则
Java内存模型下一些“天然的”先行发生关系
程序次序规则(Program Order Rule):在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作。准确地说,应该是控制流顺序而不是程序代码顺序,因为要考虑分支、循环等结构。
管程锁定规则(Monitor Lock Rule):一个 unlock 操作先行发生于后面对同一个锁的lock操作。这里必须强调的是同一个锁,而“后面”是指时间上的先后顺序。
volatile变量规则(Volatile Variable Rule):对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作,这里的“后面”同样是指时间上的先后顺序。
线程启动规则(Thread Start Rule):Thread 对象的 start() 方法先行发生于此线程的每一个动作。
线程终止规则(Thread Termination Rule):线程中的所有操作都先行发生于对此线程的终止检测.
线程中断规则(Thread Interruption Rule):对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过 Thread.interrupted() 方法检测到是否有中断发生。 对象终结规则(Finalizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。 传递性(Transitivity):如果操作A先行发生于操作B,操作B先行 发生于操作C,那就可以得出操作A先行发生于操作C的结论。
12.4 Java 与线程
12.4.1 线程的实现
- 每个已经执行 start() 且还未结束的 java.lang.Thread 类的实例就代表了一个线程。
- 我们注意到 Thread 类与大部分的 Java API 有显著的差别,它的所有关键方法都是声明为 Native 的。
- 实现线程主要有3种方式:使用内核线程实现、使用用户线程实现和使用用户线程加轻量级进程混合实现。
- Java线程的实现: 一条Java线程就映射到一条轻量级进程之中.
12.5 本章小结
本章中,我们首先了解了虚拟机 Java 内存模型的结构及操作,然后讲解了原子性、可见性、有序性在Java内存模型中的体现,最后介绍了先行发生原则的规则及使用。
第13章 线程安全与锁优化
线程安全: 当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的 同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都 可以获得正确的结果,那这个对象是线程安全的.
13.2 线程安全
13.2.2 线程安全的实现方法
互斥同步
- 互斥同步(Mutual Exclusion&Synchronization)是常见的一种并发 正确性保障手段.
- 互斥是实现同步的一种手段,临界区(Critical Section)、互斥量(Mutex)和信号量(Semaphore)都是主要的互斥实现方式。
- 在 Java 中,最基本的互斥同步手段就是 synchronized 关键字
13.3 锁优化
13.3.1 自旋锁与自适应自旋
为了让线程 “稍等一下”, 但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁, 我们只需让线程执行 一个忙循环(自旋),这项技术就是所谓的自旋锁。
在 JDK 1.6 中引入了自适应的自旋锁。
自适应意味着自旋的时间不再固定了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。
13.3.2 锁消除
锁消除是指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。
13.3.3 锁粗化
如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体中的,虚拟机将会把加锁同步的范围扩展(粗化)到整个操作序列的外部.
13.3.4 轻量级锁
轻量级锁是在无竞争的情况下使用 CAS 操作去消除同步使用的互斥量
13.3.5 偏向锁
偏向锁就是在无竞争的情况下把整个同步都消除掉,连 CAS 操作都不做了。
偏向锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步。
13.4 本章小结
本章介绍了线程安全所涉及的概念和分类、同步实现的方式及虚拟机的底层运作原理,并且介绍了虚拟机为了实现高效并发所采取的一系列锁优化措施。