系统总线(一)

心情

有个小家伙吃了我做的酸奶,上吐下泻,担心晚上她会需要我帮忙,所以在至凌晨一点前写一下今天学的系统总线。


概要

计算机系统的五大不见之间的互连方式通常有 分散链接总线连接两种。

分散连接

分散连接

由图可以看到它是以运算器为中心的结构, 连线十分复杂,尤其是当I/O与存储器交换信息,都需要经过运算器,致使运算器停止运算,严重影响运行效率。后来虽然改进为以存储器为中心的结构,I/O与主存交换信息也可以不经过运算器,但是仍 无法解决I/O设备与主机之间的灵活性不能随时添减设备。所以出现了总线连接方式

总线连接方式

总线是连接多个部件的信息传输线,是各个部件共享的传输介质(所以总线又称bus)。当多个部件与总线相连时,如果出现两个及以上的部件同时向总线发送请求就会产生冲突,传输无效。因此,在某一时刻,只允许一个部件向总线发送消息,而 多个部件可以同时从总线上接收消息

总线由许多传输线或通路组成,每条线可一位一位的串行传输。若干条传输线可以同时传输若干位二进制代码,例如16条传输线组成的总线可在一个时刻传输16位二进制代码。

总线分类

按连接部件不同,分为三种总线。(当然也可以根据其他方面进行分类,比如使用范围)

片内总线

片内总线是指芯片内的总线,如在CPU种,寄存器与运算逻辑单元ALU之间、寄存器与寄存器之间。

系统总线

系统总线是指CPU、主存、I/O设备等各大部件之间的信息传输线。由于各个部件通常安放在主板或各个插板上,故又称板级总线(在一块电路板上各芯片间的连线)或板间总线。
系统总线按传输的信息分为三类:数据总线、地址总线和控制总线

数据总线

传输各功能部件之间的数据信息,它是双向传输总线(CPU传出数据,CPU接收数据),其位数与机器字长、存储字长有关。数据总线的位数称为数据总线宽度。如果数据总线的宽度为8位,指令字长为16位,那么CPU在取指阶段必须两次访问主存。

数据的含义是广义的,它可以是真正的数据,也可以是指令代码或状态信息,有时甚至是一个控制信息。

地址总线

地址总线只要用来指出数据总线上的源数据或目的数据在主存单元的地址或I/O设备的地址。例如向从存储器中读取一个数据,需要将该数据所在存储单元的地址送到地址线上;如果想将数据经I/O设备输出,则CPU除了需将数据送到数据总线外,还需将该输出设备的地址送到地址总线上。可见,地址总线上的代码是指明CPU欲访问的存储单元或I/O端口的地址,由CPU输出,单项传输。

控制总线

由于数据总线、地址总线都是被挂在总线上被所有部件共享的,所以若想要控制各个部件对总线的使用,就需要依靠控制总线来完成,因此控制总线是用来发出各种控制信号的传输线。一条控制总线是单向的,但控制总线这个集合可以是双向的,比如CPU向寄存器发出读取命令;某个设备准备就绪时,向CPU发出中断请求。

常见的控制信号如下:

  • 时钟:用来同步各种操作
  • 复位:初始化所有部件
  • 总线请求:表示某部件需要获得总线的使用权
  • 总线允许:表示需要获得总线使用权的部件已获得了控制权
  • 中断请求:表示某部件提出中断请求
  • 中断响应:表示中断请求已被接收
  • 存储器写:将数据总线上得数据写到存储器指定得地址单元中
  • 存储器读:将指定存储单元中的数据读到数据总线上
  • I/O读:从指定的I/O端口将数据读到数据总线上
  • I/O写:将数据总线上的数据传输到指定的I/O端口内
  • 传输响应:表示数据已经被接收,或已将数据送至数据总线上

通信总线

这类总线用于计算机系统之间或计算系统与其他系统之间的通信。传输方式可以分为两种:串行通信和并行通信。

串行通信

指数据在单条1位宽的传输线上,一位一位地按顺序分时传输。
该通信方式相比并行通信适合长距离传输,因为通信线路费用趋高,采用串行通信费用远比并行通信费用低。

并行通信

指在多条并行1位宽地传输线上,同时由源送到目的地址,如一字节地数据,在并行传输过程中,要通过8条并行传输线同时由源传送到目的地。
该通信方式适宜短距离传输,通常小于30m。

总线特性

机械特性

总线在机械连接方式上的一些性能,如插头与插座的标准,它们的几何尺寸、形状、引脚的个数以及排列的顺序等。

电气特性

指总线的每一个传输线上信号的传递方向和有效的电平范围。通常规定由CPU发出的信号为输出信号,送入CPU的为输入信号。高电平为“1”, 低电平为“0”。

功能特性

指总线中每根传输线的功能,例如:地址总线用来指出地址;数据总线用来传输数据;控制总线用来发出控制信号。

时间特性

指总线中的任一根线在什么时间内有效

总线性能指标

总线宽度

指数据总线的根数,用bit表示,如8位、16位

总线带宽

可以理解为总线的传输速率,即单位时间内总线上传输数据的位数。

时钟同步/异步

总线上的数据与时钟同步工作的总线称为同步总线,与时钟不同步工作的总线称为异步总线。

总线复用

一条信号线上分时传送两种信号。例如,通常地址总线与数据总线在物理上是分开的两种总线,为了提高总线利用率,地址总线可以和数据总线共用一组物理线路。在这组物理线路上分时传输地址信号和数据信号,即为总线的多路复用。

信号线数

地址总线、数据总线和控制总线三种总线数的总和

总线控制方式

  • 突发工作
  • 自动配置
  • 仲裁方式
  • 逻辑方式
  • 计数方式
  • 其他指标

    负载能力、电源电压、总线宽度是否可扩展等

《深入Java虚拟机》之类加载

类加载总共分为以下几个阶段:

  1. 加载
  2. 验证
  3. 准备
  4. 解析
  5. 初始化
  6. 使用
  7. 卸载

每个阶段并非按部就班的执行或完成,而是混合式进行的,通常会在一个阶段执行的过程中调用、激活。

类加载会在以下几种情况下被触发:

  1. 遇到newputstaticgetstaticinvokestatic这四条字节码指令时,如果类没有进行过初始化,则需先触发其初始化。上述四条指令分别对应:创建新对象对静态字段赋值获取静态字段(对final属性无效,因为final属性在编译阶段就在常量池里)调用静态方法 这四个场景。
  2. 使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,则先触发其初始化
  3. 当初始化一个类时,该类的父类尚未初始化,则先初始化父类
  4. 虚拟机启动时,初始化用户指定执行的 包含main()方法的主类
  5. 当使用JDK7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果是REF_getstaticREF_putstaticREF_invokestatic的方法句柄,并且这个方法句柄对应的类没有初始化,则触发初始化。

有且只有以上五种场景被成为 主动引用,除此之外所有引用类的方式都不会触发初始化。

被动式引用一

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Super{
static{
System.out.println("Super Init");
}
static String hello = "nice";
}
class Sub{
static{
System.out.println("Sub Init");
}
}

public static void main(String[] args){
System.out.println(Sub.hello);
}

/**
* 最后会输出 "Super Init"
* 因为调用的是Super类的hello字段
*/

被动式引用二

1
2
3
4
5
6
7
8
public static void main(String[] args){
Super[] supers = new Super[10];
}
/**
* 该操作不会输出 "Super Init"
* 因为虚拟机实例化的不是Super对象,而是 [Super
* 该类由虚拟机生成,创建动作由字节码指令newarray触发
*/

被动式引用三

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Super{
static{
System.out.println("Super Init");
}
static final String hello = "nice";
}

public static void main(String[] args){
System.out.println(Super.hello);
}

/**
* 该例子不会输出 "Super Init"
* 因为final变量会在编译阶段就进入常量池
*/

加载

在加载阶段,虚拟机需要完成以下三件事:

  1. 通过一个类的全限定名读取该类的二进制文件(没有定义来源,可以是网络、ZIP包中读取的、生成的)
  2. 将字节流里定义的静态数据结构转换成运行时数据结构
  3. 在内存中生成一个代表这个类的java.lang.Class对象,在方法区内提供该类的数据入口

类加载 过程中,加载这个阶段对 非数组类的限制是最少的,开发人员对其的可控性也是最高的。因为加载阶段可以由开发人员提供自定义的ClassLoader(覆盖loadClass()方法)

对于 数组类来说,数组类本身不会通过类加载器创建,它是由虚拟机直接创建的。不过数组类的元素类型(去掉所有维度的类型)最终是要靠类加载器去创建的:

  • 如果数组的组件类型(数组去掉一个维度的类型)是引用类型,那么就正常加载,数组C会在 加载该组件类型的类加载器的类名称空间里 被标记
  • 如果数组的组件类型不是引用类型(比如int[]),那么虚拟机会把数组标记为与引导类加载器关联
  • 数组类的可见性与它的组件类型的可见性一致,如果组件类型不是引用类型,那数组类的可见性将默认为public

加载完毕后,类的二进制流会按照虚拟机所需的格式存放在方法区内,然后在内存中实例化一个Class类的对象(不同虚拟机里实现不同,HotSpot里,Class对象是存放在方法区里面),这个对象作为程序访问方法区数据的入口。

加载阶段和连接阶段是交叉进行的,下一个阶段会在加载阶段尚未完成的时候就开始。夹在加载阶段之中进行的动作,仍然属于连接阶段,这两个阶段的开始时间仍然保持着固定的先后顺序。

验证

该阶段主要验证来源不明的Class文件,因为Class文件可以通过16进制修改器直接修改,如果完全新任,不检查它,可能会因为载入有害的字节流而导致系统崩溃。该阶段直接决定了虚拟机是否能承受恶意的代码攻击。该阶段分为四个部分:

  1. 文件格式验证
  2. 元数据验证
  3. 字节码验证
  4. 符号引用验证

文件格式验证

因为字节流的来源不明,所以需要校验一下文件格式是否符合Class文件的定义:

  • 是否以魔数 0xCAFEBABE 开头
  • 主、次版本号是否在当前虚拟机处理范围之内
  • 常量池的常量中是否有不被支持的常量类型
  • 指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量
  • CONSTANT_Utf8_info 型的常量中是否有不符合UTF8编码规范的数据
  • Class文件中各个部分及文件本身是否有被删除或附加的信息
  • ……

该阶段的目的是保证输入的字节流能被正确解析成方法区的数据结构并存于方法区之内,只有经过这个阶段,字节流才会进入内存的方法区中进行存储,所以后面的阶段都是基于方法区的存储结构进行的,不会再直接操作字节流。

元数据验证

对类的信息进行语义分析,保证每个类符合规范:

  • 这个类是否有父类(除了Object类以外,所有类都应该有父类)
  • 这个类的是否继承了不该继承的类(被final修饰的类)
  • 如果这个类不是抽象类且继承了抽象类(或接口),是否实现了其父类的所有抽象方法(或实现接口的所有方法)
  • 类中的字段、方法是否与父类产生矛盾(覆盖了父类的final字段,或者出现不符合规则的方法重载,例如方法签名都一样,但是返回值不同)
  • ……

第二阶段的主要目的是对元数据信息进行语义检验,保证不存在不符合Java规范的元数据信息。

字节码验证

主要校验代码逻辑是否正确:

  • 保证操作数栈的类型和指令代码序列能一一对应。比如从操作数栈取出的类型为int,而指令代码序列却是用long类型
  • 保证跳转指令不会跳转到方法体以外的字节码指令上
  • 保证方法体中的类型转换是有效的
  • ……

如果类文件没通过字节码验证,那么一定是不安全的;如果通过字节码验证,也不能说明绝对安全。一个著名的问题——停机问题,可以告诉我们程序不可能准确算出程序是否能在有限时间结束。

JVM为了解决检验时间过长的问题,在方法的code属性表中增加了一项名为“StackMapTable”的属性,这项属性描述了方法体中所有的基本块(按照控制流拆分的代码块)开始时本地变量表和操作栈应有的状态,在字节码验证期间,就不需要根据程序推导这些状态的合法性,只需要检查StackMapTable中的记录是否合法即可。同样,该属性也有被篡改的可能。

符合引用验证

该阶段的校验发生在虚拟机将符号引用转换为直接引用的时候,这个转换动作将在连接的第三阶段——解析阶段中发生。主要作用就是检验各个引用是否存在匹配项:

  • 全限定名是否能找到对应的类
  • 指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段

如果开发人员对所有的代码都很了解,确定没有恶意代码,可以考虑使用 -Xverify:none 参数来关闭大部分类的验证措施,以缩短虚拟机的加载时间

准备

该阶段只会初始类里面的static域(不会初始化实体类的普通属性),通常情况下会将static域初始化成默认值(int为0,float为0.0等等)

而如果该static域被final修饰了,那么当该属性所属的类在编译期就会产生一个ConstantValue。在类的加载过程中,static域就会被初始化为对应的ConstantValue

1
2
public static int a = 128;
// 当在准备阶段时,该类的a属性被初始化为0
1
2
3
public static final int a = 128;
// 在编译期时,128作为ConstantValue被存储到Class文件中
// 在准备阶段时,该类的a属性值立刻被初始化为128

解析

虚拟机将常量池内的符号引用转换成直接引用的过程,符号引用在Class文件中以 CONSTANT_Class_info 、CONSTANT_Methodref_info、CONSTANT_Field_info形式进行记录。

  • 符号引用:符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用在各虚拟机里表现不同,内存布局也不同,但是虚拟机能够接受的符号引用都是一样的,因为这是java虚拟机规范
  • 直接引用:直接引用是一个在运行时的概念,可以是指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用和内存布局是相关的,同一个符号引用在不同虚拟机上会转换出的直接引用一般不同。如果有了直接引用,那引用目标必定已经存在内存里了。

虚拟机会在遇到以下指令之前触发解析

  • anewarray
  • checkcast
  • getfield
  • getstatic
  • instanceof
  • invokedynamic
  • invokeinterface
  • invokespecial
  • invokestatic
  • invokevirtual
  • ldc
  • ldc_w
  • multianewarray
  • new
  • putfield
  • putstatic
    这16个指令囊括一下就有以下的规律:
  • 创建的时候——anewarray、new、multianewarray、ldc、ldc_w(后两个创建字符串时会触发
  • 类型判断或转换的时候——checkcast、instanceof
  • 调用方法时——invokedynamic、invokeinterface、invokespecial、invokestatic、invokevirtual
  • 获取字段/赋值——getfield、getstatic、putfield、putstatic

触发解析阶段可能会在加载类时就触发,也可能运行到以上指令前触发,这是根据具体虚拟机的实现而定的。

除invokedynamic指令以外的指令满足如下条件:
虚拟机可能会对同一个符号引用调用很多次,但是虚拟机通常会将第一次解析结果进行缓存(通过在运行时常量池记录直接引用以及标记符号引用已被解析),但是仍然会发生多次解析同一个符号引用的情形,那么虚拟机需要保证第一次解析成功,后续对其的解析也必须成功;同理,如果第一次解析失败,后续的解析都要抛出相同的异常。

而invokedynamic之所以如此特别,主要在于它是用于动态语言的指令,它所对应的引用称为 动态调用限定符,这里动态的意思是指必须等到实际程序运行到该条命令时,解析动作才能进行。其余的指令,可以在运行到指令前(加载时)就可以触发解析。

类解析

当解析类或接口时,假设当前执行的代码类是D。
如果遇到了一个类,需要把一个从未解析过的符号引用N解析为一个类或接口C的直接引用,虚拟机要完成以下三个步骤:

  1. 如果该类不是数组类,那虚拟机会将代表N的全限定名传给D的类加载器去加载该类或接口C。在加载过程中,无论哪个阶段抛出异常,解析过程就宣告失败。
  2. 如果该类是数组类,并且数组元素的类型为对象,也就是N的描述符是类似”[java/lang/String”的形式,那么会采用第一点的规则加载数组元素类型。假设N的描述符和上述一样,那么需要加载的数组元素类型就是”java.lang.String”,接着会由虚拟机生成一个代表此维度和元素的数组对象
  3. 如果上面的步骤没有问题,那么C已经成为一个有效的类或接口,但在解析完成之前还要进行符号引用验证,即确认D是否具备对C的访问权限。如果发现不具备,将抛出java.lang.IllegalAccessError。

字段解析

要解析一个未被解析过的字段符号引用,首先需要对该字段所处的类进行解析,也就是类或接口的符号引用。如果在解析这个类或接口过程中失败,都会导致字段符号引用的解析失败。如果解析成功完成,那将这个字段所属的类或接口用C表示,虚拟机会后续对字段符号引用进行如下查找:

  1. 如果C本身就是该字段的引用类型,且简单名称和字段描述符都与目标字段相匹配,则返回这个字段的直接引用,查找结束
  2. 否则,如果C实现了接口,那么就从接口里(递归查找)进行查找,如果接口中包含了简单名称和字段描述都与目标字段相匹配,则返回这个字段的直接引用。
  3. 否则,如果C继承了父类,那么就从父类进行递归查找,如果在父类中包含了简单名称和字段描述符都与目标字段相匹配的字段,则返回这个字段的直接引用。
  4. 否则查找失败,抛出NoSuchFieldError异常
  5. 查找成功后,进行访问权限的校验

注意,实际虚拟机的编译器实现会比上述更严格一些,即实现的接口、继承的类都声明了同个字段,可能会提示字段混淆的异常。

类方法解析

对于被调用的方法,先解析它所属类或接口的符号引用,如果解析成功,我们依然用C表示这个类,接下来会按以下的规则进行查找:

  1. 类方法和接口方法的符号引用定义是分开的,先校验C这个类继承的是否正确,比如原本继承的AInterface是接口类型,在运行时如果AInterface变成抽象类,那么就会报错:IncompatibleClassChangeError
  2. 如果通过第一步,那么在C里查找简单名称和描述符都匹配的目标方法,找到则返回其直接引用
  3. 否则在C的父类中递归查找是否有简单名称和描述符都匹配的目标方法,找到则返回其直接引用
  4. 否则在C实现的接口列表及它们的父接口中递归查找简单名称和描述符都匹配的目标方法,如果存在匹配方法,说明类C是抽象类方法(因为前面的可能都不存在),抛出AbstractMethodError
  5. 否则,抛出NoSuchMethodError
  6. 返回直接引用时,并对这个方法进行权限校验

Your newly packaged library is not backward binary compatible (BC) with old version. For this reason some of the library clients that are not recompiled may throw the exception.
This is a complete list of changes in Java library API that may cause clients built with an old version of the library to throw java.lang.IncompatibleClassChangeError if they run on a new one (i.e. breaking BC):
Non-final field become static,

Non-constant field become non-static,

Class become interface,

Interface become class,

if you add a new field to class/interface (or add new super-class/super-interface) then a static field from a super-interface of a client class C may hide an added field (with the same name) inherited from the super-class of C (very rare case).

接口方法解析

对于被调用的接口,先查找该方法所属的接口,如果解析成功,我们用C表示这个类,接下来按以下的规则进行查找:

  1. 类方法和接口方法的符号引用定义是分开的,先校验C这个类继承的是否正确,比如原本继承的AInterface是接口类型,在运行时如果AInterface变成抽象类,那么就会报错:IncompatibleClassChangeError
  2. 如果通过第一步,那么在C里查找简单名称和描述符都匹配的目标方法,找到则返回其直接引用
  3. 否则查找C的父类接口,直到java.lang.Object,如果找到简单名称和描述符都匹配的目标方法,找到则返回其直接引用
  4. 否则抛出,NoSuchMethodError
    由于interface里面都是public方法,所以不用校验权限

初始化

类加载阶段里的准备阶段已经将常量(final修饰)的变量进行初始化为给定数值,把非常量的静态变量初始化为默认值。而该阶段就是将非常量的静态变量按照开发人员的意图进行初始化:

  1. 编译期收集一个类的全部静态类型(包括static字段、static块)
  2. 按收集顺序(程序里的排列顺序)依次排序到

不是必须的,如果父类或者接口类没有静态字段或者静态块,编译期就不会生成方法。另外,与类不同的是,接口的静态字段需要等到用户真正调用时才会初始化。遇到多个线程初始化同一个类的情况时,虚拟机会保证只有一个线程执行,其他线程就会进入阻塞状态直到该线程执行完初始化;

虚拟机会保证先执行父类的,再执行子类的,所以java.lang.Object一定是第一个被初始化的类。

《深入Java虚拟机》之回收策略及实践

新生代分为Eden区和Survivor区,Survivor会按1:1划分成From、To两个区域。示意图如下所示

新生代布局

新生代比例划分

布局比例会按 -XX:SurvivorRatio=x 划分,这个虚拟机参数是指 Eden区:Survivor区=z ,即Eden区和Survivor区之比为x。计算方式如下:

z x + 2 x = 新生代大小

如果z = 6,新生代大小为10240K,那么6 * x + 2 * x = 10240K,解出x = 1280K;那么Eden区的大小就是7680K???

理论上的确是7680K,但是当该数值除以1024时,无法得到整数值(个人猜测为了内存对齐吧)。所以实际上Eden区的大小为8192K,即8MB。

如果z = 6, 新生代大小为102400K(100MB),那么6 * x + 2 * x = 102400K,解出x = 12800K,那么Eden区就是76800K?

嗯,真的是76800K。

总而言之,解出来的Eden区数值(单位一定是K)如果无法被整出,则向上取一个能被1024整除的数。

内存分配规则

GC流程示意图,不严谨版

当直接分配对象时,优先考虑 Eden区。如果不能则考虑是否能直接放入 Old Gen,由 -XX:PretenureSizeThreshold控制多大的对象能直接进入 Old Gen。如果仍不能放入 Old Gen 则触发minor GC。发生 minor gc时,就先清空Eden区和其中一块Survivor区,然后放入另外一块空的里面(注意这里针对发生过一次GC以上的情况)。

要点一

对象优先在Eden区分配,如果Eden区不够分配了,剩余空间已经不足以分配新对象了,因此发生Minor GC。GC期间,发现已有的对象都不能全部放入Survivor空间,所以只能通过分配担保机制提前转移到 Old Gen

要点二

当分配一个大对象。打对象容易导致内存还有不少空间就提前触发垃圾收集以获取足够大的空间安置它们。虚拟机提供了 -XX:PretenureSizeThreshold参数,当对象的大小大于等于设置的参数时,对象就会直接被分配到 Old Gen。该参数只对 SerialParNew两款收集器有效。

要点三

虚拟机给每个对象定义了一个年龄计数器字段 Age,该字段会记录 自从Eden出生后,经过Minor GC 的次数。每经过一次 Minor GC,Age就会加一,当它的年龄到达15时,就会晋升到 Old Gen。晋升年龄可以用参数 -XX:MaxTenuringThreshold控制。

要点四

如果Survivor空间里有一半及以上的对象年龄相同,那么大于等于该年龄的对象可以直接进入Old Gen,无须等待-XX:MaxTenuringThreshold的参数

要点五

在发生Minor GC之前,虚拟机会先检查老年代最大可用的连续空间 是否 大于新生代所有对象总空间。
如果这个条件成立,那么Minor GC的担保可以确保是安全的。
如果这个条件不成立,则设置-XX:+/-HandlePromotionFailure允许担保,那么发生minor gc 前,会判断一下 老年代最大可用连续空间 是否大于 历次晋升到老年代的平均大小。如果大于,那么会尝试发生一次Minor GC(不确定是否安全的);如果小于,或者没有开启 -XX:+/-HandlePromotionFailure,就发生 Full GC。

一般都会开启 -XX:+/-HandlePromotionFailure ,因为即使条件不满足,也要尝试进行一次 Minor GC;否则直接进行 Full GC,对系统的负担就会比较大。
在JDK6 Update24之后,-XX:+/-HandlePromotionFailure参数已经失效了,默认为:老年代的连续空间 大于 新生代对象总大小 或者 大于历次晋升的平均大小就发生Minor GC


而是将内存分为一块较大的Eden区和两块较小的Survivor空间里,每次使用Eden和其中一块Survivor。

上面的句子摘自《深入Java虚拟机 第二版》,这句子可能会给一些读者带来误解:以为Survivor也会作为一块内存空间用来 分配 对象。这直接会让我们以为当 其中一块SurvivorEden区都满了后才会触发Minor GC。其实不然,在HotSpot虚拟机里,所有新生对象都是闲分配到 Eden区中(特殊情况分配到 Old Gen)。当Eden区不够,触发了Minor GC后,会将 Eden区 里的幸存对象复制到 Survivor的From区里。在第一次Minor GC后,对象才 真真正正的只在Eden区和其中一块Survivor里即发生第一次Minor GC前,只有Eden区存放对象

实践阶段

emmmmmm,以后补充;

  1. 当新分配的对象直接超出了Eden区的可用最大值时,直接放入OldGen
    1
    2
    3
    4
    5
    -Xms20M  // 最小内存20M
    -Xmx20M // 最大内存20M
    -Xmn18M // 年轻代10M,老年代也就10M
    -XX:SurvivorRatio=8 // eden区比例,如果为8,就是8:1:1;如果为3,就是3:1:1
    -XX:+PrintHeapAtGC // 打印GC前后的信息

《深入Java虚拟机》之GC Roots及拓展

这篇文章本来是想写在《对象内存布局及使用》篇的,但是无意间发现了R大的《找出栈、堆上的引用》,决定对其进行拓展。单独写了一篇笔记。


根据可达性分析算法,GC Roots节点是查找引用链的关键,所以需要知道每个GC Roots在什么地方(一个个枚举得话很消耗时间)。而且回收时得知道某个时刻,所有的GC Roots及引用链。换句话说在某个时刻,大家都应该停下来等待统计引用(好比扫地,一边扫一边丢垃圾,最终是扫不干净得)。

简而言之,现在的问题是:

  • JVM如何知道哪些位置有引用类型的变量?
  • 如何让大家都停下来,拍个快照,获取某一个瞬间的引用链?

Q1. JVM如何知道哪些位置有引用类型的变量?

这要由 虚拟机是否记录类型信息 而决定的,而且该决定会影响GC的实现方式和引用方式(这也是为什么存在句柄引用和直接引用得原因)。通常按准确程度划分为以下三种:

  • 保守式
  • 半保守式
  • 准确式

保守式

当无法区分内存上某个位置的数据究竟是引用类型还是整数类型时(或其他类型),我们称它为保守式GC。当发生GC时,JVM从一些已知的位置开始扫描内存(比如栈,如果是堆就查找对象里面是否有引用类型的属性)并递归深入,如果满足下面的条件就认为它是一个引用类型:

  • 四字节对齐
  • 里面的数值在堆范围内(比如数值为100,堆的范围为0-1000,那么就认为是在堆里面)
  • The allocbit is set. The allocbit is a flag indicating whether the memory location corresponding to it is allocated or not.

缺点:

  • 部分应该死的对象没死;当内存需求量大时,这些本应该释放掉的内存没被释放,总归不是一件好事
  • 由于无法确切的知道一个变量究竟是基本类型还是引用类型,所以对象发生移动时,无法确切的得知哪些变量应该被修改。简单来说,保守式GC里的对象无法被移动;若是想要移动对象,必须增加一个中间层,即 句柄。所有指针都先指向句柄池,再从句柄里找到对象。这样要移动对象就只需要修改句柄表的内容即可。

半保守式

由于JVM要支持丰富的反射功能,本来对象就会带上一堆元数据,所以自己所有属性的类型信息都可以一目了然。
所以在扫描堆时,JVM可以直接根据对象带有的信息判断 该对象内什么位置是什么类型的变量
而在扫描栈时,仍然需要一个个扫描,且无法判断某个位置上具体是什么变量

改进:

  • 由于半保守式在堆内部的数据类型是准确可知的,所以大部分情况下可以采用 直接引用,也就可以支持部分对象的移动(对在栈上扫描出来的对象进行标记,表示 “不可移动对象”,而对象属性里的引用仍可以移动)

准确式

和保守式相反——能区分内存(包括栈和堆)上任意位置的数据类型。HotSpot里采用 从外部记录类型信息,存成映射表,在HotSpot里该映射表被叫做 OopMap。要实现这项功能,需要虚拟机里的解释器和JIT编译器共同的支持,由它们来生成足够的元数据信息。

使用这样的映射表一般有两种方式:
1、每次都遍历原始的映射表,循环的一个个偏移量扫描过去;这种用法也叫“解释式”;
2、为每个映射表生成一块定制的扫描代码(想像扫描映射表的循环被展开的样子),以后每次要用映射表就直接执行生成的扫描代码;这种用法也叫“编译式”。

对于堆里的对象,OopMap里记录了该类型的对象内什么偏移量上是什么类型的数据。这些数据是在类的加载过程中计算出来的。

对于栈来说就会比较麻烦。每个被JIT编译后的方法都会在一些 特定的位置记录下OopMap,记录了执行到该方法的某条指令的时候,栈上和寄存器里哪些位置是引用。特定位置主要在:

  • 循环的末尾
  • 方法临返回前/调用方法的call指令后
  • 可能抛出异常的位置

这些位置被成为 安全点。由于每个位置都记录 OopMap 很浪费空间,所以选择几个关键的位置就能有效缩减需要记录的数据量,但仍然能达到区分数据类型的作用。安全点数量既不能太多以至于增大运行时得负荷;也不能太少以至于GC间隔很长。

在解释器中执行的方法则可以通过解释器自动生成出 OopMap出来给GC用。

注意,这里分成了两部分,一部分是解释器的操作,一部分是JIT编译的操作。
两个什么区别可以看这篇文章

Q2. 如何让大家都停下来,拍个快照,获取某一个瞬间的引用链?

这个问题也可以理解为 GC如何来获取OopMap信息

停下来得方式分为两种:

  • 抢先式中断,发生GC时,让所有线程暂停,不在安全点上得线程恢复运行,直到运行到安全点。
  • 主动式中断,发生GC时,给所有线程一个标记,每个线程运行时不断去轮询该标记,当发现标记为真时,运行到安全点并暂停本线程

然而安全点还不够,因为如果线程没有分配到CPU时间,那么根本不会运行到安全点,典型得例子就是 Sleeping 或 Blocking 状态得线程,它们无法走到安全点上挂起。所有得线程也不可能已知等待这条线程走到安全点上,所以推出了一个 安全区域 的点。

安全区域是指在一段代码片段里,引用关系不会发生变化。在这个区域的任意地方发生GC都是安全的。

在线程进入 安全区域时,会给自己打上一个标记。那样在这段时间里即使JVM发生GC,就不用管在 安全区域的线程了;当线程要离开 安全区域时,判断一下JVM是否正在GC,如果是,就等待直到收到 可以安全离开安全区域的信号为止;反之,线程继续执行。

了解阻塞队列之DelayQueue

DelayQueue的主要功能是取出超出时间期限的元素,在缓存方面能发挥较好的效果。
DelayQueue包含以下几个属性:

  • lock:ReentrantLock —– 不解释
  • q:PriorityQueue —– 存放元素的队列,该队列会根据比较器进行排序,在DelayQueue里就是按剩余时间进行排序
  • leader:Thread —– Leader-Follower线程模型,主要用于降低锁力度,不需要消息队列。
  • available:Condition —– 不解释

DelayQueue的整体结构采用代理模式,如图所示:
DelayQueue的继承关系图

其中有一种新的线程设计模式叫 Leader-Follower

  • 有一条线程负责监视是否有任务到达,该线程叫Leader
  • 其余线程处于Follower状态(await状态),时刻准备着被唤醒成为下一个Leader
  • 当Leader线程检测到任务到达后 进入处理状态唤醒等待的Follower线程之一
  • 当老Leader线程(现在处于Process状态)处理完毕后,进入Follower状态,等待新Leader的唤醒

其出现是为了

  • 解决单线程接受请求线程池线程处理请求下线程上下文切换
  • 线程间通信数据拷贝的开销
  • 不需要维护一个队列

DelayQueue的offer()方法如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public boolean offer(E e) {
final ReentrantLock lock = this.lock;
lock.lock();
try {
// 调用PriorityQueue.offer()
q.offer(e);
// 判断如果队首元素为新进入的元素
if (q.peek() == e) {
// 设置leader为null
leader = null;
// 唤醒其他线程
available.signal();
}
return true;
} finally {
lock.unlock();
}
}

Q1. 为什么要额外判断下peek() == e
因为当一个线程进入leader状态时,仍会释放锁进入一段睡眠。那么此时如果新添加的offer优先度很高,就可以唤醒其他线程准备竞争任务。


DelayQueue的take()方法流程图如下所示:
take方法流程图

这里的逻辑简单来看就是如下所示:
take方法白话文流程图

Q1. 为什么要特意设置 first = null
当消费者数量大于生产者,那么会有多个消费者线程持有队首元素的引用,然后在竞争锁的过程中,个别消费者长时间处于等待状态,那么GC发生时,队首元素就不会被回收(因为还是可达的),就发生了内存泄露。

Q2. 为什么要特意设置leader?根据Leader-Follower模式,似乎ReentrantLock已经能够满足只有一个线程在处理且其他线程进入等待
因为即使线程拿到了Lock锁,但是其中的await()也会释放锁进入等待状态。那么其他线程拿到锁后该如何知道是否有线程正在等待呢?那么就是靠这个leader变量。

Q3. 为什么设置了leader最后还要设置回null?
因为leader-follower,处理完后重新进入Follower状态


总结
DelayQueue涉及到了Leader-Follower模型,该模型的优势在于 线程上下文开销 相比 单线程接受任务再交给多线程处理来说 更低。
该类可以理解为代理类,它覆写的那些方法实际上调用的都是PriorityQueue的API。DelayQueue是线程安全的,PriorityQueue是线程不安全的。
要谨记await()方法会释放锁并进入等待

《深入Java虚拟机》之垃圾收集算法

标记-清除算法

算法分两个阶段:

  • 标记:将所有不可达对象进行标记
  • 清除:清除所有标记的对象

当标记的对象满足以下条件,那么会加入F-Queue中等待第二次标记

  • 对象覆写finalize()且finalize()没有被虚拟机调用过

该算法有以下两个问题:

  • 效率:标记和清除的效率都不高
  • 空间:标记-清除后,内存里会产生大量不连续的空间,空间碎片太多会导致分配大对象时,再次触发GC

标记-清除算法示意图

复制算法

将内存空间划分为两个部分,每次都只往一个部分里写入对象。当这块(写入对象的那块)的内存空间用完,就将存活着的对象复制到另外一部分空间里面,然后把原来那部分清理掉。这样每次都只是对整个半区进行内存回收,内存分配时也不用考虑空间碎片等问题,只需移动堆顶指针,按顺序分配即可。

该算法的问题:

  • 空间:将内存缩小为原来的一半,代价太高

复制算法示意图

现代主流的虚拟机都采用这种收集算法来 回收新生代,但是在空间分配上进行了调整,根据研究结果——“新生代中的98%的对象是朝生夕死”的,所以不需要缩小一半;现在的主流做法是将内存分为一块较大的 Eden空间和两块较小的 Survivor空间,每次使用Eden和其中一块Survivor(Eden:Survivor1:Survivor2 = 8:1:1)。

当发生GC时,将Edent和Survivor中还存活的对象一次性复制到另外一块Survivor空间上,然后清理掉Eden和刚刚用过的Survivor空间。然而不是所有场景下,存活的对象都不超过10%,所以当Survivor空间不足时,需要依赖其他内存空间(老年代)。

修改空间比例后的算法问题:

  • 效率问题:对象存活率较高时就要进行较多的复制,效率会变低
  • 极端情况:为应对对象100%存活的情况,需要有额外的空间进行担保

标记-整理算法

根据老年代的特点,有人提出了标记-整理算法(mark-compact),标记过程和前面一致,在清理过程时,让所有存活的对象都向一端移动,然后直接清理掉边界以外的内存

标记-整理算法示意图

分代收集算法

根据对象存活周期将内存划分为几块。一般是分为 新生代老年代。说通俗点就是分而治之,新生代因为对象创建和回收都比较频繁,每次只有少量存活就可以采用复制算法;老年代因为对象存活率高、没有额外空间对他进行分配担保,就必须使用 标记-清除标记-整理 算法。

《深入Java虚拟机》之对象的GC

Q1. 在堆上的对象什么时候被回收

当对象不被任何变量引用的时候

Q2. 怎么样检测对象不被引用

引用计数法

给对象添加一个引用计数器,每当有一个地方引用它时,计数器就加1;当引用器为0时,该对象就变为不可用。
JVM用的不是引用计数法,证明方法就是 循环引用 (注意将GC Roots 设置为null,GC Roots是什么参考下文),然后主动调用System.gc()

可达性分析算法

主流的商用程序语言的主流实现中,都是通过可达性分析来判定对象是否存活的。这个算法的基本思想就是通过一系列的称为 GC Roots 的对象作为起始点,从这些结点开始向下搜索,搜索所走过的路径称为 引用链,当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的。

红色区域的对象就会被视为 可回收对象

能作为GC Roots的对象有以下几个:

  1. 局部变量表中引用的对象
  2. 方法区中类静态属性引用的对象
  3. 方法区中常量引用的对象
  4. JNI引用的对象

Q3. 引用的定义

在JDK1.2前,引用的定义:如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称这块内存代表着一个引用。这种定义下,只有引用或没被引用两种情况。对于一些“食之无味,弃之可惜”的对象,作用就比较单薄。所以后来为了 当内存空间还足够时,则能保存在内存之中;如果内存空间在进行垃圾收集后还是非常紧张,则可以抛弃对象 这一理念,JDK1.2后,对引用的概念进行了扩充,将引用分为 强引用软引用弱引用虚引用 四种。

  • 强引用:和普通的赋值A a = new A() 一样,只有不可达之后才会被回收
  • 软引用:在内存溢出前,回收所有的软引用引用的对象,如果此时仍然溢出,再抛出OOM异常
  • 弱引用:发生GC时,不管内存是否足够,回收弱引用引用的对象
  • 虚引用:无法通过虚引用获取对象,只会在发生GC时,将虚引用放入Queue中(一定要传入queue)

这里说明下ReferenceQueue的作用,对象被回收后,我们需要对引用对象(即SoftReference、WeakReference等)进行处理,这就是ReferenceQueue的作用。这个类的很多属性都是由JVM进行控制的,比如ReferenceQueue.discoveredReferenceQueue.pending等属性

Q4. 无引用的对象是如何一步步被回收的

第一次GC发现不可达对象时,先判断其“有没有必要执行finalize()方法”,有必要执行时给该对象打上一个标记,并放入一个称为F-Queue的队列,等待执行finalize()F-Queue不保证每个对象的finalize()都执行完毕(因为如果finalize里有死循环之类的就凉了),过段时间,GC会对F-Queue进行第二次标记,如果想拯救里面的对象,只要将对象和GC Roots链相连即可。

没有必要执行的判断依据如下:

  • 对象没有覆盖finalize()
  • finalize()已经被虚拟机调用过了

Q5. 方法区的回收

方法区回收的效率一般比较低,方法区回收的主要对象是:废弃常量和无用的类。
比如一个字符串“abc”进入了常量池,但是当前没有任何一个String对象引用该常量,如果此时发生内存回收,如果必要的话,这个“abc”常量会被清除出常量池。其他类(接口)、方法、字段的符号引用也与此类似。
满足以下三个条件算是“无用的类”:

  1. 该类的所有实例都被回收
  2. 加载该类的ClassLoader被回收
  3. 该类的Class对象没有在任何地方被引用或使用

-verbose:class 查看类的加载信息
-XX:+TraceClassLoading 查看类的加载信息
-XX:+TraceClassUnLoading 查看类的卸载信息
-Xnoclassgc 关闭虚拟机对类的回收