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

阅读了《深入理解Java虚拟机》的部分章节,并做了一些简单的笔记,不是很详细,但是可以方便自己查阅。

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

1. 对象是否存活

引用计数法:

很难解决对象间的循环引用

可达性分析

可以作为GC Roots的对象:
  • 虚拟机栈中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈中JNI(Native方法)引用的对象

2. 引用分类(JDK1.2实现)

  • 强引用:永远不回收
  • 软引用SoftReference(有用但非必须):内存溢出之前回收
  • 弱引用WeakReference(非必须):GC时回收
  • 虚引用PhantomReference(幽灵引用/幻影引用):目的是能在这个对象被收集器回收时收到一个系统通知

3. finalize()(不建议使用)

判断对象是否死亡会经历两次标记过程:
①判断是否与GC Roots相连;
②执行finalize()(对象没有覆盖finalize()或finalize()已经被调用过一次时,虚拟机认为没必要执行finalize(),不会进行第二次标记)。

  • 被调用时会放在由虚拟机自动创建的、低优先级的队列F-Queue
  • finalize()是对象唯一的自救机会,例如:在finalize()中将this赋值给某个类变量或者对象的成员变量
  • 运行代价高昂,不确定性大、无法保证各个对象的调用顺序
  • finalize()能做的所有工作使用try-finally或者其他方式可以做得更好、更及时

4. 回收方法区

废弃常量

与Java堆的回收逻辑类似

无用的类
  • 该类的所有实例已经被回收
  • 加载该类的ClassLoader已经被回收
  • 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

5. 垃圾收集算法

(1)标记清除算法

  • 效率不高
  • 会大量不连续内存碎片

(2)复制算法

  • 内存缩小为原来的一半
  • HotSpot默认的Eden与Survivor的大小比例为8:1
  • 没有办法保证每次回收都只有不多于10%的对象存活。当Survivor空间不够用时,需要依赖老年代进行分配担保

    分配担保:当Survivor空间不能放下上一次 YGC 之后存活的对象时,这些对象直接通过分配担保机制进入老年代。

(3)标记整理算法

(4)分代收集算法

新生代每次垃圾收集都会有大量对象死去,少量存活,所以采用复制算法。
老年代对象存活率高、没有额外的空间对它进行担保,必须使用“标记-清理”或者“标记-整理”算法。

6. HotSpot算法实现

(1)枚举根节点

  • Java虚拟机使用准确式GC(必须确定一个变量是引用还是真正的数据)
  • 虚拟机停顿之后不需要检查所有的执行上下文和全局的引用位置。
  • 类加载完成时计算出什么偏移量上是什么类型的数据,JIT编译时在特定位置(安全点)记录OopMap数据结构(指明栈和寄存器哪些位置是引用)

(2)安全点(SafePoint)

  • 程序执行时并非在所有地方都能停顿下来开始GC,只有到达安全点时才能暂停。
  • 安全点选定原则:是否具有让程序长时间执行的特征
  • 长时间执行:指令序列复用(如:方法调用、循环跳转、异常跳转)
  • 抢先式中断(已经被弃用):所有线程中断,不在安全点的线程恢复执行。

主动式中断:在安全点设置中断标志,程序执行到安全点时主动轮询这个标志,判断是否需要进行中断。

(3)安全区域(Safe Region)

  • 定义:一段代码中,引用关系不会发生变化。(线程处于Sleep或Blocked状态)
  • 线程执行到Safe Region时,标识自己进入Safe Region状态;
    JVM GC时不管Safe Region状态的线程;
    当线程离开Safe Region状态时,检查系统是否完成了根节点枚举或整个GC过程;
    如果完成了,那线程继续执行,否则,必须等待收到可以安全离开Safe Region的信号为止。

7. 垃圾收集器

连线代表可以组合使用。

(1)Serial收集器(Client模式下新生代垃圾清理首选)

  • 进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束。
  • Serial/Serial Old收集器在新生代采用复制算法,老年代采用标记-整理算法

(2)ParNew收集器(Server模式下新生代垃圾清理首选)

  • 多线程版本的Serial收集器
  • 第一款真正意义上的并发收集器
  • 默认开启的收集线程数与CPU的数量相同

(3)Parallel Scavenge收集器

  • 目标:达到一个可控制的吞吐量。(吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间))
  • 适合在后台运算而不需要太多交互的任务
  • 可以设置最大垃圾收集停段时间(-XX:MaxGCPauseMillis)和吞吐量大小(-XX:GCTimeRatio)
  • 可以开启-XX:UseAdaptiveSizePolicy,之后虚拟机根据系统运行状况自动设置新生代大小、Eden与Survivor比例、晋升老年代对象大小等细节参数。(GC自适应的调节策略

(4)Serial Old收集器

(5)Parallel Old收集器

(6)CMS收集器

  • 四个步骤:
    a: 初始标记(停顿):记录与GC Roots直接相连的对象
    b: 并发标记:GC Roots Tracing
    c: 重新标记(停顿更长):修正并发标记期间因用户程序继续运作而导致的标记产生变动的记录
    d: 并发清除
  • 对 CPU 资源敏感
  • 无法处理浮动垃圾(并发清除阶段用户程序产生的新的垃圾,需要等下次GC时再进行清理),可能出现“Concurrent Mode Failure”失败而导致另一次Full GC的产生。(不能等老年代被填满之后进行清理,需要为并发清除期间用户程序的执行预留空间)
  • 空间碎片过多

(7)G1收集器(JDK1.7)

  • 并行与并发
  • 分代收集
  • 空间整合:从整体上看是基于“标记-整理”算法实现的收集器,从局部(两个Region之间)上来看是基于“复制“算法实现的。
  • 可预测的停顿:使用者可以指定垃圾收集上消耗的时间不得超过 M 毫秒。
  • 分配的对象会记录在 Remembered Set 中,内存回收时再GC根节点的枚举范围中加入 Remembered Set ,确保不对全堆扫描也不会有泄露。
  • 不计算维护 Remembered Set 的过程,可以分为以下几个步骤:
    a: 初始标记
    b: 并发标记
    c: 最终标记:并发标记期间对象变化记录在线程 Remembered Set Logs 中,该阶段将 Remembered Set Logs 整合到 Remembered Set 中。
    d: 筛选回收:根据用户期望的 GC 停顿时间制定回收计划。

8. 内存分配与回收策略

  • 对象优先在Eden分配

    新生代 GC (Minor GC):非常频繁,回收速度也比较块。
    老年代 GC (Major GC / Full GC):伴随至少一次的 Minor GC , 比 Minor GC 慢10倍以上。

  • 大对象直接进入老年代
  • 长期存活的对象将进入老年代(可以通过 -XX:MaxTenuringThreshold 参数进行设置,默认执行完15次 Minor GC)
  • 动态对象年龄判断:如果在 Survivor 空间中相同年龄所有对象大小总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无需等到 MaxTenuringThreshold 中要求的年龄。
  • 空间分配担保:在进行 Minor GC 之前会执行下面的流程,
    a: 检查老年代最大连续可用空间是否大于新生代所有对象总空间,如果是 Minor GC 可以确保安全,否则, 执行b;
    b: 查看 HandlePromotionFailure 设置的值是否允许担保失败,如果是,执行c,否则,执行 Full GC;
    c: 检查老年代最大可用连续空间是否大于历次晋升到老年代对象的平均大小,如果是,尝试一次 Minor GC (可能存在风险),否则,将 HandlePromotionFailure 设置为不允许冒险,改为进行一次 Full GC。

第七章 虚拟机类加载机制

1. 类的加载过程

  • 按顺序按部就班的开始(不是结束,一个阶段中调用激活另一个阶段),解析阶段可以在初始化之后再开始(动态绑定)
  • Java虚拟机规定的必须进行初始化的5种情况:
    (1) 遇到 new、getstatic、putstatic、invokestatic这4条字节码指令(对应使用 new 关键字实例化对象读取或设置类的静态字段(被 final 修饰、已在编译期把结果放入常量池的静态字段除外)以及调用一个类的静态方法几种情况)
    (2) 反射调用。
    (3) 初始化一个类时,如果父类没有进行过初始化,需要先触发父类初始化。
    (4) 虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的类),虚拟机会先初始化主类。
    (5) 但是用动态语言支持时,如果一个java.lang.invoke.MethodHandle实例后解析结果 REF_putStatic , REF_getStatic , REF_invokeStatic 的方法句柄时,当改方法句柄对应的类没有初始化时,需要初始化该类。

    接口初始化时并不要求其父类接口全部都初始化完成,只有在真正使用到父类接口时才会初始化。

有且仅有上面五种情况会触发初始化,成为主动引用,除此之外,其他所有方式都不会触发初始化,称为被动引用。

被动引用举例:

  • 通过子类引用父类的静态字段,不会导致子类的初始化。
  • 通过数组定义来引用类,不会触发此类的初始化。
  • 常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化。

1.1 加载

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

    获取途径:ZIP包(JAR、EAR、WAR)、网络(Applet)、运行时计算生成(动态代理)、其他文件(JSP应用)、数据库

  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
  3. 在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口
    • 非数组类型使用引导类加载器或者用户自定义类加载器进行加载
    • 数组的组件类型(去掉一个维度之后的类型)是引用类型,则按照普通类加载过程加载;不是引用类型,标记为与引导类加载器关联。
    • 数组类可见性与组件类型可见性一致。如果组件类型不是引用类型,可见性默认为 public 。

1.2 验证(非常重要但不一定必要)

  1. 文件格式验证:保证输入的字节流能正确地解析并存储于方法区之内,格式上符合描述一个Java类型信息的要求。

    经过此验证之后字节流才会进入内存方法区,后面3个验证阶段都是基于方法区中的存储结构

  2. 元数据验证(语义分析):保证不存在不符合语言规范的元数据信息。
  3. 字节码验证(并不能完全保证安全):对类的方法体进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的事件。
  4. 符号引用验证:发生在虚拟机将符号引用转化为直接引用阶段,确保解析动作可以正常执行。

1.3 准备

为类变量(被 static 修饰的变量,不包括实例变量)分配内存并设置类变量初始值(一般是零值)。

通常情况下初始化零值,如果存在 ConstantValue 属性,则指定为 ConstantValue 属性的值。
public static int value = 123;此代码 value 准备阶段之后的结果为0;
public static final int value = 123;此代码 value 准备阶段之后的结果为123。

1.4 解析

将常量池中的符号引用替换为直接引用。

除invokeddynamic指令,其余需要进行解析的字节码指令都会对第一次解析结果进行缓存。
解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符七类符号引用进行。

1.5 初始化

执行类构造器<clinit>()方法。

  • 静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但不能访问。
  • <clinit>()不需要显示调用父类的<clinit>(),由虚拟机保证父类<clinit>()执行。
    第一个被执行的<clinit>()方法的类肯定是java.lang.Object
  • 父类中定义的静态语句块优于子类变量的复制操作。
  • <clinit>()是非必需的(没有静态语句块和变量赋值操作)
  • 接口不能使用静态语句块,但是可以对变量赋值。
    只有父接口中定义的变量使用时,父接口才会初始化。
  • 虚拟机保证一个类的<clinit>()方法在多线程环境下被正确的加锁、同步。

2. 类加载器

2.1 判断两个类相等

  1. 使用相同类加载器
  2. 全限定名相同

2.2 双亲委派模型

image

双亲委派模型工作过程:

如果一个类加载器收到类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器完成。每个类加载器都是如此,只有当父加载器在自己的搜索范围内找不到指定的类时(即ClassNotFoundException),子加载器才会尝试自己去加载。

类加载器之间的父子关系不会以继承实现,而是使用组合的方式。

双亲委派模型的三次破坏
  • 第一次破坏是在jdk 1.2之前,用户自定义的类加载器都是重写Classloader中的loadClass方法,这样就导致每个自定义的类加载器其实是在使用自己的loadClass方法中的加载机制来进行加载,这种模式当然是不符合双亲委派机制的,也是无法保证同一个类在jvm中的唯一性的。为了向前兼容,java官方在Classloader中添加了findClass方法,用户只需要重新这个findClass方法,在loadClass方法的逻辑里,如果父类加载失败的时候,才会调用自己的findClass方法来完成类加载,这样就保证了写出的类加载器是符合双亲委派机制的。
  • 第二次的破坏是由模型本身的缺陷导致的,根类加载器加载了基础代码,但是基础代码中有可能调用了用户的代码,但是对于根类加载器而言是不认识用户的代码的。

    那么这时候java团队使用了一个不太优雅的设计:线程上下文类加载器。这个类加载器可以通过Thread类的setContextClassLoader方法进行设置,如果创建线程时还未设置,它就从父线程继承一个,如果在应用全局范围内都没有设置过的话,那这个类加载器默认就是应用程序类加载器。

利用这个线程上下文类加载器傅,父类加载器请求子类加载器去加载某些自己识别不了的类。

java中基本所有涉及spi的加载动作基本上都采用了这种方式,例如jndi,jdbc等。

  • 第三次的破坏是因为用户对于程序的动态性追求,诸如:代码热替换,模块热部署
    目前业界Java模块化的标准是OSGI。而OSGI实现模块热部署的关键是他自己的类加载机制:每个程序模块(bundle)都有自己的类加载器,需要更换程序(bundle)的时候,连同类加载器一起替换,以实现代码的热部署。

第八章 虚拟机字节码执行引擎

1. 运行时栈帧结构

  • 包含局部变量表、操作数栈、动态连接和方法返回地址等信息。
  • 编译时确定栈帧中的局部变量表大小。
  • 一个栈帧需要分配多少内存不会受到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。
  • 只有位于栈顶的栈帧(当前栈帧)才是有效的。

1.1 局部变量表

  • 以容量槽(Slot)为最小单位
  • 每个Slot都应该能够存放一个boolean、byte、char、short、int、float、reference或returnAddress类型数据。
  • 如果一个局部变量定义了但是没有赋初始值是不能使用的

1.2 操作数栈

  • Java 虚拟机的解释执行引擎称为“基于栈的执行引擎”,其中所指的“栈”就是操作数栈

1.3 动态连接

指向运行时常量池中该栈帧所属方法的引用。
静态解析:符号引用在类加载阶段或第一次使用时转化为直接引用。
动态连接:符号引用在每一次运行期间转化为直接引用。

1.4 方法返回地址

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

  1. 恢复上层方法的局部变量表和操作数栈
  2. 把返回值压入调用者栈帧的操作数栈
  3. 调整PC计数器的值以指向方法调用指令后面的一条指令

2. 方法调用

2.1 解析

  • 只要能被 invokestatic 和 invokespecial 指令调用的方法都可以在解析阶段确定唯一的调用版本。
  • 符合上述条件的的有静态方法、私有方法、实例构造器、父类方法 4 种。都称为非虚方法。其余方法为虚方法

    final 方法也是非虚方法。

2.2 分派(与多态特性有关)

静态分派动态分派
编译阶段运行阶段
重载重写
多分派(关心静态类型与方法参数两个因素)单分派(只关心方法接收者)
(1)静态分派(重载)

所有通过静态类型来定位方法执行版本的分派动作称为静态分派。

  • 方法重载是静态分派的典型应用
  • 静态分派发生在编译阶段

Human man = new Man();
Human为变量的静态类型,静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是在编译期可知的;
Man为变量的实际类型,实际类型的变化结果在运行期才可以确定,编译期间不知道对象的实际类型。
重载通过参数的静态类型而不是实际类型作为判定依据。

(2)动态分派(重写)

运行期根据实际类型确定方法执行版本的分派过程称为动态分派。

根据操作数栈中的信息确定接受者的实际类型

(3)单分派与多分派

方法的接收者与方法的参数统称为方法的宗量
单分派是根据一个宗量对目标方法进行选择,多分派则是根据多个宗量对目标方法进行选择。
静态分派属于多分派动态分派属于单分派

(4)多分派的实现

为类在方法区中建立虚方法表,存放各个方法的实际入口地址。
如果子类重写了父类函数,虚方法表中存放指向子类实现版本的入口地址;否则,与父类相同方法的入口地址一致。

第十二章 Java内存模型与线程

1. Java内存模型

工作内存主内存
线程对变量读取、赋值等操作线程间变量值的传递
虚拟机栈中的部分区域Java堆中的对象实例数据部分

1.1 内存间的交互操作

image

  1. **lock(锁定)**:作用于主内存的变量,把一个变量标记为一条线程独占状态
  2. **unlock(解锁)**:作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
  3. **read(读取)**:作用于主内存的变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用
  4. **load(载入)**:作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中
  5. **use(使用)**:作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎
  6. **assign(赋值)**:作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量
  7. **store(存储)**:作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作
  8. **write(写入)**:作用于工作内存的变量,它把store操作从工作内存中的一个变量的值传送到主内存的变量中
同步规则分析:
  1. 不允许read和load、store和write操作之一单独出现,以上两个操作必须按顺序执行,但没有保证必须连续执行,也就是说,read与load之间、store与write之间是可插入其他指令的。
  2. 不允许一个线程丢弃它的最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步回主内存。
  3. 不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步会主内存中
  4. 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或者assign)的变量。即就是对一个变量实施use和store操作之前,必须先自行assign和load操作。
  5. 一个变量在同一时刻只允许一条线程对其进行lock操作,但lock操作可以被同一线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。lock和unlock必须成对出现。
  6. 如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量之前需要重新执行load或assign操作初始化变量的值。
  7. 如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作;也不允许去unlock一个被其他线程锁定的变量。
  8. 对一个变量执行unlock操作之前,必须先把此变量同步到主内存中(执行store和write操作)

2.3 volatile

1. 保证可见性,read与load、aggsin与store两两不分开。
2. 禁止指令重排序优化,内存屏障

2.4 long与double型变量的特殊规则

虚拟机不保证64位数据类型的load、store、read和write这四个操作的原子性。

目前的商用虚拟机保证了64位数据的类型读写操作的原子性

2.5 原子性、可见性与有序性

(1)原子性
  • 基本数据类型具备原子性
  • sychronized 关键字保证更大范围内的原子性
(2)可见性
  • volatilesychronizedfinal三个关键字实现可见性。
  • final关键字的可见性:
    被 final 修饰的字段在构造器中一旦初始化完成,并且构造器没有把this的引用传递出去,那在其他线程中就能看到 final 字段的值。
(3)有序性
  • volatilesychronized两个关键字实现有序性。
  • 如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。

2.6 先行发生原则(happens-before)

(1)定义
  1. 如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
  2. 两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么这种重排序并不非法(也就是说,JMM允许这种重排序)。
(2)具体规则
  1. 程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
  2. 监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
  3. volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
  4. 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。
  5. start()规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作。
  6. join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。
  7. 程序中断规则:对线程interrupted()方法的调用先行于被中断线程的代码检测到中断时间的发生。
  8. 对象finalize规则:一个对象的初始化完成(构造函数执行结束)先行于发生它的finalize()方法的开始。

Java与线程

在Java中,JDK1.2之前由用户线程实现,JDK1.2之后使用基于操作系统原生线程模型实现,win和linux都是用的一对一的线程模型(一条Java线程映射到一条轻量级进程中)。

1. 线程的实现

1.1 使用内核线程实现
  • 不直接使用内核线程,而是使用内核线程的高级接口:轻量级进程。
  • 轻量级进程与内核线程的数量比为 1 :1。
  • 轻量级进程消耗内核资源,一个系统支持的轻量级进程的数量是有限的。
1.2 使用用户线程实现(实现复杂,没有使用)
  • 进程与用户线程之间是 1 :N 的关系
1.3 使用用户线程加轻量级进程混合实现
  • 用户线程与轻量级进程之间是 N:M 的关系。

2. 线程调度

  • 协同式线程调度:实现简单,但线程执行时间不可控
  • 抢占式线程调度:Java一共10个优先级,但windows系统只有7个

3. 状态转换

线程状态转移图