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

发布于 2019-04-20  749 次阅读


第一章:走近Java

指针膨胀

64位jvm寻址范围较32位变成了64位,所需空间变大,称为指针膨胀。此额外数据的加载会对内存使用产生影响,这会导致执行速度稍慢,具体取决于执行期间加载的指针数量。(在64位模式下运行AMD64和EM64T平台时,Java VM会获得一些额外的寄存器,可用于生成更高效的本机指令序列。这些额外的寄存器将性能提高到在比较32位到64位执行速度时通常没有性能损失的程度。 迁移到64位VM时,将64位平台上运行的应用程序与SPARC上的32位平台相比,性能差异大约为10-20%。在AMD64和EM64T平台上,这种差异范围为0-15%,具体取决于访问应用程序的指针数量。 )

数据类型对齐补白

Java本身数据类型长度不变,参考C++数据byte对齐

第二章:自动内存管理机制

JVM运行时数据区

线程共享

  • 方法区(Method Area)
    • 用于储存已被虚拟机加载的类信息、敞亮、静态变量、即时编译后的代码等数据。
    • HotSpot虚拟机在jdk1.7后将此区域移到直接内存中的元空间(Mete Space)。
  • 堆(Heap)
    • 虚拟机管理内存中最大的一块,唯一目的是存放对象实例。
    • 垃圾收集的主要执行区域。
  • 直接内存(Direct Memory)
    • 并非Java虚拟机规范中定义的部分,主要用于JDk4以后出现的NIO的文件复制中,使用其做直接缓存,在某些场景下提高复制的效率。

线程独享

  • 虚拟机栈(VM Stack)
    • 描述的是Java方法执行的内存模型。
    • 栈帧(Stack Frame)是虚拟机栈的的节点单位,用于储存局部变量表、操作数栈、动态链接、方法出口等信息。
    • “栈”通常指虚拟机栈中的局部变量表。
    • 局部变量表存放了编译器可知的各种基本数据类型、对象引用(直接指向对象内存位置的指针,或是句柄,主要和具体虚拟机实现有关)和ReturnAddress类型(指向了一条字节码指令的地址)
    • 局部变量所需要的内存空间在编译期完成分配,运行期间不会改变局部变量表的大小。
    • 若线程请求栈深度大于虚拟机允许的深度,抛StackOverflowError;若虚拟机支持动态扩展栈深度,那么在扩展时无法申请到足够内存时,将抛出OutOfMemory异常。
  • 本地方法栈(Native Method Stack)
    • 与虚拟机栈功能类似,描述的是本地(Native)方法执行的内存模型,具体实现由不同的虚拟机决定
  • 程序计数器(Program Counter Register)
    • 当前线程执行字节码文件的行号指示器。
    • 若正在执行Java方法,程序计数器记录的是正在执行的虚拟机字节码指令的位置;若是Native(本地)方法,计数器值则为空(Undefined)。
    • 是虚拟机规范中唯一未规定OutOfMemoryError情况的区域。

普通对象的创建

  • 类是否加载:
    • 检查指令的参数是否能在常量池中定位到一个类的符号引用
    • 检查这个符号引用代表的类是否已被加载、解析和初始化过
  • 划分内存:
    • 指针碰撞:在堆内存规整的时候使用指针碰撞来分配内存,分配时只需将指针往空闲空间挪动索要分配的大小的距离。
    • 空闲列表:在堆内存不规整的时候,就需要维护一张记录空闲内存区域的表,操作内存时需要更新表记录。
    • 方法选择:由堆内存是否规整决定,而堆内存是否规整又由垃圾收集器的策略(是否压缩)而决定。
  • 内存分配的线程安全性:
    • 对分配内存空间的动作使用CAS加上失败重试保证更新操作的原子性。
    • 本地线程分配缓冲(Thread Local Allocation Buffer,TLAB):即每个线程预先在堆中分配好自己的那一部分内存,当TLAB使用完需要分配新的TLAB时才需要进行同步锁定。
  • 初始化零值:
    • 保证实例字段在代码中不赋初值也可以使用
  • 设置对象头
  • init初始化

对象的内存布局

  • 对象头:
    • 对象自身运行时数据(Mark Word):如哈希码(Hash Code)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳(这部分数据长度根据虚拟机位数而定(未开启压缩指针))
    • 类型指针:对象只想它的类元数据的指针,虚拟机(并非所有)通过这个指针来确定对象是哪个类的实例,如果对象是数组,对象头中必须有一块用于记录数组长度的数据。
  • 实例数据:
    • 对象真正储存的有效信息
    • 储存顺序:受到虚拟机分配策略参数(FieldAllocationStyle)以及字段在源代码中定义的顺序影响。默认策略为相同宽度类型字段总被分配到一起,在满足这个前提下,父类定义的变量会出现在子类之前。
  • 对其填充:
    • 并无特别的含义,起到占位作用(自动管理内存系统规定对象起始地址必须是8字节的整数倍)。

对象访问定位

对象的使用需要通过reference数据来操作堆上的具体对象,具体方式看具体虚拟机的实现。

  • 句柄:堆中划分出一块内存作为句柄池,reference指向该对象的句柄地址,句柄中记录了该对象实例的真实内存地址。
  • 直接指针:reference直接指向对象实例真实内存地址。
  • 对比:句柄的优势在于解耦,从而使reference中储存的数据变得稳定,对象被移动(修改)时仅需在句柄池中修改指向实例的指针;直接指针优势在于访问的速度。

第三章:垃圾收集器与内存分配策略

对象的有效性判断

  • 引用计数法
    • 使用引用计数来判断对象是否存活
    • 无法解决对象间循环引用
  • 可达性分析法
    • 使用一系列GC Root作为起始点,向下搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Root没有任何引用链相连接的话,称为不可达,则这个对象不可用(可被回收)。
    • 可以用作GC Root的对象有:虚拟机栈(栈帧中的本地变量表)中引用的对象、方法区中类静态属性引用的对象、方法区常量引用的对象、本地方法中JNI引用的对象。

引用类型

  • 强引用(Strong Reference):诸如显示new的对象都是强引用,只要强引用还存在,垃圾收集器就永远不会回收掉被引用的对象。
  • 软引用(Soft Reference):描述一些还有用但非必须的对象,在内存溢出前会把这些对象列入二次回收范围,如果回收后还是没有足够内存才会抛出内存溢出异常。
  • 弱引用(Weak Reference):比软引用更弱,可以存活到下一次垃圾收集之前。
  • 虚引用(Phantom Reference):最弱的引用关系,为对象设置虚引用的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。

清理条件

当在一次可达性分析过后,若该对象不可达,则进行第一次标记和筛选,筛选的条件是该对象是否有必要执行finalize()方法,当对象的finalize()方法没有被覆盖或已经被虚拟机栈调用过一次,则被判定为没有必要执行该方法。如果对象被判定为有必要执行finalize()方法,则被放入F-Queue队列中,并在稍后由一个由虚拟机自动建立的、低优先级的finalizer线程去执行它。这里的执行仅仅只触发而不保证完成,动作执行完,虚拟机将对队列中的对象执行第二次标记。

方法区的回收

大量通过反射、动态代理、CGLIb等ByteCode框架、动态生成JSP以及OSGI这类频繁定义ClassLoader的场景都需要虚拟机具备类卸载的功能,以保证永久代不会溢出。

  • 废弃常量:没有被任何对象引用的常量,如果有必要,会被清理出常量池。
  • 无用的类:
    • 堆中不存在该类的任何实例
    • 加载该类的ClassLoader已经被回收
    • 该类对应的Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法

垃圾收集算法

  • 标记-清除(Mark-Sweep)算法:首先标记出所有需要收集的对象,标记完成后统一回收被标记的对象。主要存在两个问题:标记和清除两个过程的效率都不高、标记清除后会产生大量不连续的内存碎片。
  • 复制(Copying)算法:将内存按照容量大小等分成两块,每次只使用其中的一块。当内存用完时将存活对象复制到另一块内存中去,然后对原来的半块区域进行内存回收。如此一来每次就仅需对整个半区进行内存回收,内存分配也不需要考虑碎片问题。算法代价是将内存缩小为原来的一半。现代的商业虚拟机使用改进的复制算法来回收新生代,即将新生代分为Eden与两个Survivor区,Eden与Survivor的比例为8:1(研究表明新生代百分之98的对象都是“朝生夕死”的),每次使用Eden区与一个Survivor区来存放对象,清理时将存活对象放在另一个Survivor区。当每次回收发现有多于10%的对象都存活下来的时候,将依赖老年代进行分配担保,即将放不下的对象放入老年代。
  • 标记-整理(Mark-Compact)算法:标记过程仍然与标记-清除算法一致,但后续步骤是将存活对象都向内存的一端移动,然后直接清理掉除了端边界以外的内存
  • 分代收集(Generation Collection)算法

HotSpot的算法实现

  • 枚举根节点
    • 可达性分析是必须将整个执行系统冻结(Stop The World)成一致性快照
    • 精确式GC:一个线程意味着一个栈,一个栈由多个栈帧组成,一个栈帧对应着一个方法,一个方法里面可能有多个安全点。 gc 发生时,程序首先运行到最近的一个安全点停下来,然后更新自己的 OopMap ,记下栈上哪些位置代表着引用。枚举根节点时,递归遍历每个栈帧的 OopMap ,通过栈中记录的被引用对象的内存地址,即可找到这些对象( GC Roots )
  • 安全点
    • 生成OopMap的位置,程序在到达安全点时停顿,进行GC,以“是否让程序具有长时间运行的特征”来选定安全点
    • 抢先式中断
    • 主动式中断
  • 安全区域

垃圾收集器

  • CMS(Concurrent Mark Sweep)
    • 基于标记-清除(改进):初始标记、并发标记、重新标记、并发清除(初始标记与重新标记仍然需要Stop The World)。
    • cpu资源敏感:并发收集时会降低系统吞吐量。
    • 无法收集浮动垃圾:浮动垃圾指的是标记过程之后的同时系统新产生的垃圾。
  • G1(Garbage First)
    • 初始标记、并发标记、最终回收、筛选回收
    • 并行与并发、分代收集、空间整合、可预测的停顿
    • Region:在使用G1收集器时,堆内存被划为多个大小相等的独立区域(Region),虽然还保留新生代和老年代的概念,但它们不再是物理隔离转而变为一部分Region集合。
    • 可预测的停顿:在每个Region中维护Remembered Set,虚拟机发现程序在对Region内的Reference对象进行写操作时将发生中断,中断时检查Reference引用的对象是否处于不同的Region中,如果是便在Remembered Set中记录相关引用信息以避免全堆扫描;只追踪各个Region里面垃圾堆积的价值大小,维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region,从而避免在整个堆中进行全区域的垃圾收集。

内存分配与回收策略

  • 对象优先在Eden上分配:当Eden空间可用内存不足时将发起Minor GC(回收速度快,最频繁的的GC类型),Minor GC若发现剩下的Survivor区不足以存放下存活对象,则以分派担保机制提前转移到老年代。
  • 大对象直接进入老年代:经常出现大对象(例如很长的字符串以及数组)容易导致内存还有不少空间时就提前触发垃圾收集以获得连续的空间来“安置”它们,并且若该对象存活时间长,将导致年轻代之间内存存在大量复制。
  • 长期存活的对象将进入老年代:虚拟机给对象定义年龄计数器来记录对象的年龄,对象于Eden区被创建,之后经历一次Minor GC后,若还存活则右复制算法被复制到空闲的Survivor区,此时对象年龄设为1,如此往复,再熬过一次次Minor GC后,年龄达到一定程度(默认15)则进入老年代。
  • 动态年龄判定:Survivor空间中相同年龄所有对象大小综合大于Survivor区的一半时,年龄大于或者等与该年龄的对象可以直接进入老年代。
  • 空间分配担保:在Minor GC之前,由于Minor GC是对新生代采用复制算法进行垃圾收集,空闲Survivor区可能空间不足以存放存活对象因此需要向老年代发起分配担保。若分配担保能成功,则需要老年代中有足够的空闲内存(检查最大连续可能用内存是否大于新生代所有对象总空间,若不成立则检查是否允许担保失败,若允许,则检查最大可用连续空间是否大于历次晋升到老年代对象的平均大小,若大于则尝试Minor GC,若小于或不允许尝试,则进行Full GC)。

第四章:虚拟机性能监控与故障处理工具

可用于分析的数据大致有:运行日志、异常堆栈、GC日志、线程快照、堆转储快照等。

第六章类文件结构

JVM的平台无关与语言无关

Class类文件的结构

  • Class文件是一组以8字节为基础单位(遇到需要占用8字节以上空间的数据则按照高位在前的方式切分成若干个8字节进行存储)的二进制流,各数据项目按照规定严格的排列在文件中,中间没有任何分隔符。
  • 伪结构:
    • 无符号数:属于基本数据类型,以u1、u2、u4、u8来分别代表1个字节、2个字节、4个字节、8个字节的无符号数,无符号数可以用来描述数字,索引引用、数量值或按照UTF-8编码构成的字符串值。
    • 表:表是由多个无符号数或者其他表构成的复合数据类型,整个Class文件的本质就是一张表。
    • 无论是无符号数还是表,当需要描述同一类型但数量不定的多个数据时,经常会使用一个前置的容器计数器加上若干个连续的数据项的形式,这时称为集合。
  • 魔数:文件的身份识别,在Class文件中为0xCAFEBABE。
  • 版本号:魔数之后4个字节为Class文件的版本号,前两个字节为次版本号,后两个字节为主版本号。
  • 常量池:入口是一个2个字节的常量池计数器(计数从1开始,0用于表示“不引用任何一个常量池项目”),常量池主要存放两大类常量:字面量和符号引用。

第七章:虚拟机类加载机制

类加载的时机

类的生命周期:加载、验证、准备、解析、初始化、使用、卸载,其中验证、准备、解析三个部分统称为连接。

  • 虚拟机规范必须对类进行初始化的5种情况
    • 遇到new、getstatic、putstatic、或invokestatic这四条字节码指令时
    • 使用java.lang.reflect包的方法对类进行反射调用时
    • 当初始化一个类,其父类还未进行初始化,需要先进行父类初始化
    • 程序入口的主类会被虚拟机初始化
    • JDK1.7动态语言的支持
  • 对于静态字段,只有直接定义这个字段的类会被初始化
  • 一个类的数组初始化时并不会触发这个类的初始化(内部元素并未初始化)
  • 编译阶段的常量优化:编译期间,若类a持有在其他类中声明的常量,会直接被转化为类a自身的常量池中

类加载的过程

  • 加载:
    • 通过类的全限定名来获取定义此类的二进制字节流(从Zip包获取、从网络获取、实时计算生成、从其他诸如JSP的文件生成)
    • 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
    • 在内存中生成这个类的class对象,作为方法区(在HotSpot虚拟机中class虽是对象却存放在方法区中)这个类的各种数据的访问入口
  • 验证
    • 文件格式验证(基于二进制字节流,验证通过后字节流进入方法区进行存储):主要通过魔数、版本号等验证
    • 元数据验证:对字节码进行语义分析,确保符合Java语言规范
    • 字节码验证
    • 符号引用验证
  • 准备正式为类变量分配内存并赋初始值(零值),类变量存于方法区中。若类变量使用了final修饰为常量,则在准备阶段就直接被赋对应声明的值。
  • 解析
  • 初始化
    • 类构造器():由编译器自动收集类中的赋值语句和静态语句块中的语句合并生成,收集的顺序由源文件中的顺序决定。非法向前引用:定义在静态块之前的变量,在静态块中无法访问,但可以赋值。
    • ()无需显示调用父类(),虚拟机将自动调用。
    • ()并非必须,若类中没有任何类变量的赋值和静态语句块,将不会生成()。
    • 接口中不能使用静态语句块,但仍然有变量的初始化赋值操作,因此接口也会生成()方法,但与类不同的是,执行接口的()方法之前并不需要先执行其父接口的()方法。只有当父接口中定义的变量被使用时,父接口才会初始化。此外,接口的实现类在初始化时也一样不会执行接口的()方法。
    • ()使用锁实现线程安全性

类加载器

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

  • 比较两个类是否“相等”(equals、isAssignableFrom、isInstance),只有在这两个类是由同一个类加载器加载的前提下才有意义。
  • 类加载器的层次:
    • 启动类加载器(Bootstrap ClassLoader):负责将存放在<JAVA_HOME>/lib目录中的,或者被-Xbootclasspath参数所指定的,并且是虚拟机识别的类库加载到虚拟机内存中。
    • 扩展类加载器(Extention ClassLoader):负责加载<JAVA_HOME>/lib/ext目录中的,或者被java.ext.dirs系统变量所指定的路径中的所有类库,开发者可以直接使用扩展类加载器。
    • 应用程序类加载器(Application ClassLoader):负责加载用户类路径上的所指定的类库,若应用程序没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。
  • 双亲委派模型:除了顶层的启动类加载器以外,其余的类加载器都应当有自己的父类加载器。加载器的父子关系一般不会以继承的关系来实现,而都是使用组合来复用父类加载器的代码。
    • 工作过程:
      如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委托给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父类加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需要加载的类)时,子加载器才会尝试自己去加载。
      
    • 好处:
      使用双亲委托模型来组织类加载器之间的关系,有一个显而易见的好处就是Java类随着它的类加载器一起具备了一种带有优先级的层次关系。例如类java.lang.Object,它存放在rt.jar之中,无论哪一个类加载器要加载这个类,最终都是委托给处于模型最顶端的启动类加载器进行加载,因此Object类在程序的各种加载器环境中都是同一个类。相反,如果没有使用双亲委托模型,由各个类加载器自行去加载的话,如果用户自己编写了一个称为java.lang.Object的类,并放在程序的ClassPath中,那系统中将会出现多个不同的Object类,Java类型体系中最基础的行为也就无法保证,应用程序也将会变得一片混乱。如果自己去编写一个与rt.jar类库中已有类重名的Java类,将会发现可以正常编译,但永远无法被加载运行
      
  • 破坏双亲委派模型
    	双亲委派模型的第二次“被破坏”是这个模型自身的缺陷所导致的,双亲委派模型很好地解决了各个类加载器的基础类统一问题(越基础的类由越上层的加载器进行加载),基础类之所以被称为“基础”,是因为它们总是作为被调用代码调用的API。但是,如果基础类又要调用用户的代码,那该怎么办呢。
       这并非是不可能的事情,一个典型的例子便是JNDI服务,它的代码由启动类加载器去加载(在JDK1.3时放进rt.jar),但JNDI的目的就是对资源进行集中管理和查找,它需要调用独立厂商实现部部署在应用程序的classpath下的JNDI接口提供者(SPI, Service Provider Interface)的代码,但启动类加载器不可能“认识”之些代码,该怎么办?
       为了解决这个困境,Java设计团队只好引入了一个不太优雅的设计:线程上下文件类加载器(Thread Context ClassLoader)。这个类加载器可以通过java.lang.Thread类的setContextClassLoader()方法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个;如果在应用程序的全局范围内都没有设置过,那么这个类加载器默认就是应用程序类加载器。了有线程上下文类加载器,JNDI服务使用这个线程上下文类加载器去加载所需要的SPI代码,也就是父类加载器请求子类加载器去完成类加载动作,这种行为实际上就是打通了双亲委派模型的层次结构来逆向使用类加载器,已经违背了双亲委派模型,但这也是无可奈何的事情。Java中所有涉及SPI的加载动作基本上都采用这种方式,例如JNDI,JDBC,JCE,JAXB和JBI等。
       双亲委派模型的第三次“被破坏”是由于用户对程序的动态性的追求导致的,例如OSGi的出现。在OSGi环境下,类加载器不再是双亲委派模型中的树状结构,而是进一步发展为网状结构。
    

     

 


面向ACG编程