JVM学习之路(二)类加载、Java内存模型、JVM调试命令、JVM调优案例

本系列文章:
  JVM学习之路(一)Java运行时区域、对象的创建过程、对象的内存布局、垃圾回收器
  JVM学习之路(二)类加载、Java内存模型、JVM调试命令、JVM调优案例
  JVM学习之路(三)JVM调优实战

一、类文件结构

1.1 无关性的基石

  目前如Kotlin、Groovy、Jython、JRuby 等一大批语言都能够在 Java 虚拟机上运行。它们和 Java 语言一样都会被编译器编译成字节码文件,然后由虚拟机来执行。所以说类文件(字节码文件)具有语言无关性。

1.2 Class类文件的结构

  Class 文件是一组以 8 位字节为基础单位的二进制流,各个数据严格按照顺序紧凑的排列在 Class 文件中,中间无任何分隔符,这使得整个 Class 文件中存储的内容几乎全部都是程序运行的必要数据,没有空隙存在。当遇到需要占用 8 位字节以上空间的数据项时,会按照高位在前的方式分割成若干个 8 位字节进行存储。
  Java 虚拟机规范规定 Class 文件格式采用一种类似与 C 语言结构体的伪结构体来存储数据,这种伪结构体中只有两种数据类型:无符号数和表。

  1. 无符号数属于基本的数据类型,以u1、u2、u4、u8来分别代表 1 个字节、2 个字节、4 个字节和 8 个字节的无符号数,无符号数可以用来描述数字、索引引用、数量值或者按照 UTF-8 编码结构构成的字符串值。
  2. 是由多个无符号数或者其他表作为数据项构成的复合数据类型,所有表都习惯性地以"_info"结尾。表用于描述有层次关系的复合结构的数据,整个 Class 文件就是一张表,它由下表中所示的数据项构成。

      Class 文件具体由以下几个部分构成:魔数、版本信息、常量池、访问标志、类索引、父类索引、接口索引集合、字段表集合、方法表集合、属性表集合。
      Class 文件中存储的字节严格按照上表中的顺序紧凑的排列在一起。哪个字节代表什么含义,长度是多少,先后顺序如何都是被严格限制的,不允许有任何改变。示例:

      javap -v 可以查看一个.class文件的详细信息。示例:

1.2.1 魔数与Class文件的版本

  每个 Class 文件的头 4 个字节称为魔数(Magic Number),它的唯一作用是确定这个文件是否为一个能被虚拟机接收的 Calss 文件。之所以使用魔数而不是文件后缀名来进行识别主要是基于安全性的考虑,因为文件后缀名是可以随意更改的。Class 文件的魔数值为"0xCAFEBABE"

  魔数相当于文件后缀名,只不过后缀名容易被修改,不安全,因此在 Class 文件中标识文件类型比较合适。

  紧接着魔数的 4 个字节存储的是 Class 文件的版本号:第 5 和第 6 两个字节是次版本号(Minor Version),第 7 和第 8 个字节是主版本号(Major Version)。高版本的 JDK 能够向下兼容低版本的 Class 文件,虚拟机会拒绝执行超过其版本号的 Class 文件。

1.2.2 常量池

  主版本号之后是常量池入口,常量池可以理解为 Class 文件之中的资源仓库,它是 Class 文件结构中与其他项目关联最多的数据类型,也是占用 Class 文件空间最大的数据项目之一,同是它还是 Class 文件中第一个出现的表类型数据项目。
  因为常量池中常量的数量是不固定的,所以在常量池入口需要放置一个 u2 类型的数据来表示常量池的容量"constant_pool_count",和计算机科学中计数的方法不一样,这个容量是从 1 开始而不是从 0 开始计数。之所以将第 0 项常量空出来是为了满足后面某些指向常量池的索引值的数据在特定情况下需要表达"不引用任何一个常量池项目"的含义,这种情况可以把索引值置为 0 来表示。

Class 文件结构中只有常量池的容量计数是从 1 开始的,其它集合类型,包括接口索引集合、字段表集合、方法表集合等容量计数都是从 0 开始。

  常量池中主要存放两大类常量:

  1. 字面量,比较接近 Java 语言层面的常量概念,如字符串、声明为 final 的常量值等。
  2. 符号引用,属于编译原理方面的概念,包括了以下三类常量:

类和接口的全限定名
字段的名称和描述符
方法的名称和描述符

1.2.3 访问标志

  紧接着常量池之后的两个字节代表访问标志(access_flag),这个标志用于识别一些类或者接口层次的访问信息,包括这个 Class 是类还是接口;是否定义为 public 类型;是否定义为 abstract 类型;如果是类的话,是否被申明为 final 等。具体的标志位以及标志的含义见下表:

  access_flags 中一共有 16 个标志位可以使用,当前只定义了其中的 8 个,没有使用到的标志位要求一律为 0。

1.2.4 类索引、父类索引与接口索引集合

  类索引(this_class)和父类索引(super_class)都是一个 u2 类型的数据,而接口索引集合(interfaces)是一组 u2 类型的数据集合,Class 文件中由这三项数据来确定这个类的继承关系:

  • 1、类索引用于确定这个类的全限定名;
  • 2、父类索引用于确定这个类的父类的全限定名;
  • 3、接口索引集合用于描述这个类实现了哪些接口。

1.2.5 字段表集合

  字段表集合(field_info)用于描述接口或者类中声明的变量。字段(field)包括类变量和实例变量,但不包括方法内部声明的局部变量。字段表的结构:

  字段修饰符放在 access_flags 中,它与类中的 access_flag 非常相似,都是一个 u2 的数据类型。

1.2.6 方法表集合

  Class 文件中对方法的描述和对字段的描述是完全一致的,方法表中的结构和字段表的结构一样。
  因为 volatile 关键字和 transient 关键字不能修饰方法,所以方法表的访问标志中没有 ACC_VOLATILE 和 ACC_TRANSIENT。与之相对的,synchronizes、native、strictfp 和 abstract 关键字可以修饰方法,所以方法表的访问标志中增加了 ACC_SYNCHRONIZED、ACC_NATIVE、ACC_STRICTFP 和 ACC_ABSTRACT 标志。
  对于方法里的代码,经过编译器编译成字节码指令后,存放在方法属性表中一个名为"Code"的属性里面

1.2.7 属性表集合

  在 Class 文件、字段表、方法表中都可以携带自己的属性表(attribute_info)集合,用于描述某些场景专有的信息。
  属性表集合不像 Class 文件中的其它数据项要求这么严格,不强制要求各属性表的顺序,并且只要不与已有属性名重复,任何人实现的编译器都可以向属性表中写入自己定义的属性信息,Java 虚拟机在运行时会略掉它不认识的属性。

二、类加载机制

  源代码经过编译器编译成字节码之后,最终都需要加载到虚拟机之后才能运行。虚拟机把描述类的数据从 Class 文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的 Java 类型,这就是虚拟机的"类加载机制"。
  Java 语言中类的加载、连接和初始化都是在程序运行期间完成的,这种策略虽然会让类加载时增加一些性能开销,但是会为 Java 应用程序提供高度的灵活性,Java 里天生可动态扩展的语言特性就是依赖运行期间动态加载和动态连接的特点实现的。

2.1 类加载时机

  类从被虚拟机从加载到卸载,整个生命周期包含:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)、卸载(Unloading)7 个阶段。其中验证、准备、解析 3 个部分统称为连接(Linking)。这 7 个阶段的发生顺序如下图:

  上图中加载、验证、准备、初始化和卸载 5 个阶段的顺序是确定的,类的加载过程必须按照这种顺序按部就班的开始(这里说的是按部就班的开始,并不要求前一阶段执行完才能进入下一阶段),而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始,这是为了支持 Java 的动态绑定。
  虚拟机规范中对于什么时候开始类加载过程的第一节点"加载"并没有强制约束。但是对于"初始化"阶段,虚拟机则是严格规定了有且只有以下 5 种情况,如果类没有进行初始化,则必须立即对类进行"初始化"(加载、验证、准备自然需要在此之前开始):

  1. 遇到 new、getstatic、putstatic 或 invokestatic 这 4 条字节码指令
  2. 使用 java.lang.reflect 包的方法对类进行反射调用的时候
  3. 当初始化一个类的时候,发现其父类还没有进行初始化的时候,需要先触发其父类的初始化
  4. 当虚拟机启动时,用户需要指定一个要执行的主类,虚拟机会先初始化这个类
  5. 当使用 JDK 1.7 的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果 REF_getStatic、REF_putStatic、REF_invokeStatic 的方法句柄,并且这个方法句柄所对应的类没有初始化,则需要先触发其初始化。

  有且只有以上 5 种场景会触发类的初始化,这 5 种场景中的行为称为对一个类的主动引用。除此之外,所有引用类的方式都不会触发初始化,称为被动引用。比如如下几种场景就是被动引用:

  1. 通过子类引用父类的静态字段,不会导致子类的初始化;
  2. 通过数组定义来引用类,不会触发此类的初始化;
  3. 常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化。
  • 被动引用 Demo1
      对于静态字段,只有直接定义这个字段的类才会被初始化,因此通过其子类来引用父类中定义的静态字段,只会触发父类的初始化而不会触发子类的初始化。
/**
 * 被动引用 Demo1:
 * 通过子类引用父类的静态字段,不会导致子类初始化。
 */
class SuperClass {
    
    
	static {
    
    
		System.out.println("SuperClass init!");
	}
	public static int value = 123;
}

class SubClass extends SuperClass {
    
    
	static {
    
    
		System.out.println("SubClass init!");
	}
}

public class NotInitialization {
    
    
	public static void main(String[] args) {
    
    
		System.out.println(SubClass.value);
		// SuperClass init!
	}
}
  • 被动引用 Demo2
      编译通过之后,常量存储到 NotInitialization 类的常量池中,NotInitialization 的 Class 文件中并没有 ConstClass 类的符号引用入口,这两个类在编译成 Class 之后就没有任何联系了。
/**
* 被动引用 Demo2:
* 常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化。
*/
class ConstClass {
    
    
	static {
    
    
		System.out.println("ConstClass init!");
	}
	public static final String HELLO_BINGO = "Hello Bingo";
}

public class NotInitialization {
    
    
	public static void main(String[] args) {
    
    
		System.out.println(ConstClass.HELLO_BINGO);
	}
}

2.2 接口的加载过程

  接口加载过程与类加载过程稍有不同。当一个类在初始化时,要求其父类全部都已经初始化过了,但是一个接口在初始化时,并不要求其父接口全部都完成了初始化,当真正用到父接口的时候才会初始化。

2.3 类加载过程

2.3.1 加载

  这里的"加载"是指"类加载"过程的一个阶段。在加载阶段,虚拟机需要完成以下 3 件事:

  1. 通过一个类的全限定名来获取定义此类的二进制字节流
  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
  3. 在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口。

  对于 Class 文件,虚拟机没有指明要从哪里获取、怎样获取。除了直接从编译好的 .class 文件中读取,还有以下几种方式:

  1. 从 zip 包中读取,如 jar、war等;
  2. 从网络中获取,如 Applect;
  3. 通过动态代理计数生成代理类的二进制字节流;
  4. 由 JSP 文件生成对应的 Class 类;
  5. 从数据库中读取,如 有些中间件服务器可以选择把程序安装到数据库中来完成程序代码在集群间的分发。

2.3.2 验证

  验证是连接阶段的第一步,这一阶段的目的是为了确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。验证阶段大致上会完成下面 4 个阶段的检验动作:

  1. 文件格式验证:第一阶段要验证字节流是否符合 Class 文件格式的规范,并且能够被当前版本的虚拟机处理。验证点主要包括:是否以魔数 0xCAFEBABE 开头;主、次版本号是否在当前虚拟机处理范围之内;常量池的常量中是否有不被支持的常量类型;Class 文件中各个部分及文件本身是否有被删除的或者附加的其它信息等等。
  2. 元数据验证:第二阶段是对字节码描述的信息进行语义分析,以保证其描述的信息符合 Java 语言规范的要求,这个阶段的验证点包括:
  1. 这个类是否有父类;
  2. 这个类的父类是否继承了不允许被继承的类;
  3. 如果这个类不是抽象类,是否实现了其父类或者接口之中要求实现的所有方法;
  4. 类中的字段、方法是否与父类产生矛盾等等。
  1. 字节码验证:第三阶段是整个验证过程中最复杂的一个阶段,主要目的是通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。
  2. 符号引用验证:最后一个阶段的校验发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在连接的第三阶段–解析阶段中发生。符号引用验证可以看做是对类自身以外(常量池中的各种符号引用)的形象进行匹配性校验。

2.3.3 准备

  准备阶段是正式为类变量分配内存并设置类变量默认值的阶段,这些变量所使用的内存都将在方法区进行分配。这个阶段中有两个容易产生混淆的概念需要强调下:

  • 首先,这时候进行内存分配的仅包括类变量(被 static 修饰的变量),而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在 Java 堆中;
  • 其次这里所说的初始值通常情况下是数据类型的零值。假设一个类变量的定义为public static int value = 123; 那么变量 value 在准备阶段过后的初始值为 0 而不是 123,因为这个时候尚未执行任何 Java 方法,而把 value 赋值为 123 的 putstatic 指令是程序被编译之后,存放于类构造器 () 方法之中,所以把 value 赋值为 123 的动作将在初始化阶段才会执行。

  这里提到,在通常情况下初始值是零值,那相对的会有一些"特殊情况":如果类字段的字段属性表中存在 ConstantsValue 属性,那在准备阶段变量 value 就会被初始化为 ConstantValue 属性所指的值。假设上面的类变量 value 的定义变为 public static final int value = 123;,编译时 JavaC 将会为 value 生成 ConstantValue 属性,在准备阶段虚拟机就会根据 ConstantValue 的设置将 value 赋值为 123。

2.3.4 解析

  解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。前面提到过很多次符号引用和直接引用,那么到底什么是符号引用和直接引用呢?

  • 符号引用(Symbolic Reference):符号引用以一组符号来描述所引用的目标,符号可以上任何形式的字面量,只要使用时能无歧义地定位到目标即可。
  • 直接引用(Direct Reference):直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。

2.3.5 初始化

  类初始化阶段是类加载过程中的最后一步,前面的类加载过程中,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全是由虚拟机主导和控制的。到了初始化阶段,才真正开始执行类中定义的 Java 程序代码。初始阶段是执行类构造器 () 方法的过程。
  在此阶段静态变量才被赋予初始值

2.4 类加载器

  虚拟机设计团队把类加载阶段中的通过一个类的全限定名来获取描述此类的二进制字节流这个动作放到 Java 虚拟机外部去实现,以便让应用程序自己决定如何去获取所需要的类。实现这个动作的代码模块称为类加载器
  类加载器:类加载器负责加载程序中的类型(类和接口),并赋予唯一的名字予以标识。

2.4.1 类与类加载器

  对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在 Java 虚拟机的唯一性,每个类加载器都拥有一个独立的类名称空间。也就是说:比较两个类是否"相等",只要在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个 Class 文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。

2.4.2 双亲委派模型

  从 Java 虚拟机的角度来讲,只存在两种不同的类加载器:一种是启动类加载器(Bootstrap ClassLoader),这个类加载器使用 C++ 来实现,是虚拟机自身的一部分;另一种就是所有其他的类加载器,这些类加载器都由 Java 来实现,独立于虚拟机外部,并且全都继承自抽象类 java.lang.ClassLoader。
  从 Java 开发者的角度来看,类加载器可以划分为:

  • 1、启动类加载器(Bootstrap ClassLoader)
      这个类加载器负责将存放在 < JAVA_HOME >\lib 目录中的类库加载到虚拟机内存中。启动类加载器无法被 Java 程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给启动类加载器,那直接使用 null 代替即可;
      Bootstrap ClassLoader是最顶层的加载类,主要加载核心类库,包括:%JRE_HOME%\lib下的rt.jar、resources.jar、charsets.jar和class等。

  • 2、扩展类加载器(Extension ClassLoader)
      这个类加载器由 sun.misc.Launcher$ExtClassLoader 实现,它负责加载 <JAVA_HOME>\lib\ext 目录中,或者被 java.ext.dirs 系统变量所指定的路径中的所有类库,开发者可以直接使用扩展类加载器。

  • 3、应用程序类加载器(Application ClassLoader)
      这个类加载器由 sun.misc.Launcher$App-ClassLoader 实现。getSystemClassLoader() 方法返回的就是这个类加载器,因此也被称为系统类加载器。它负责加载用户类路径(ClassPath)上所指定的类库。开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。

  • 4、用户自定义类加载器
      通过继承 java.lang.ClassLoader类的方式实现的类加载器。

  • 类加载器
      对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立在 JVM 中的唯一性,每一个类加载器,都有一个独立的类名称空间。类加载器就是根据指定全限定名称将 class 文件加载到 JVM 内存,然后再转化为 class 对象。
      双亲委派模型:如果一个类加载器收到了类加载的请求,它首先不会自己去加载这个类,而是把这个请求委派给父类加载器去完成,每一层的类加载器都是如此,这样所有的加载请求都会被传送到顶层的启动类加载器中,只有当父加载无法完成加载请求(它的搜索范围中没找到所需的类)时,子加载器才会尝试去加载类。
      当一个类收到了类加载请求时,不会自己先去加载这个类,而是将其委派给父类,由父类去加载,如果此时父类不能加载,反馈给子类,由子类去完成类的加载。

  我们的应用程序都是由这 3 种类加载器互相配合进行加载的,在必要时还可以自己定义类加载器。它们的关系如下图所示:

  上面图中的"classNotFound"代表ClassNotFoundException。也可以看这张图:

  双亲委派机制的作用(为什么要使用双亲委派机制?):

  1. 防止重复加载同一个.class。通过委托去向上面问一问,加载过了,就不用再加载一遍。保证数据安全
  2. 保证核心.class不能被篡改。通过委托方式,不会去篡改核心.class,即使篡改也不会去加载,即使加载也不会是同一个.class对象了。不同的加载器加载同一个.class也不是同一个Class对象。这样保证了Class执行安全

2.4.3 类加载器的关系

  Bootstrap Classloader 是在Java虚拟机启动后初始化的。
  Bootstrap Classloader 负责加载 ExtClassLoader,并且将 ExtClassLoader的父加载器设置为 Bootstrap Classloader
  Bootstrap Classloader 加载完 ExtClassLoader 后,就会加载 AppClassLoader,并且将 AppClassLoader 的父加载器指定为 ExtClassLoader。

2.4.4 类加载器的作用

2.4.5 类加载器的特点

  1. 层级结构:Java里的类装载器被组织成了有父子关系的层级结构。Bootstrap类装载器是所有装载器的父亲
  2. 代理模式: 基于层级结构,类的代理可以在装载器之间进行代理。当装载器装载一个类时,首先会检查它在父装载器中是否进行了装载。如果上层装载器已经装载了这个类,这个类会被直接使用。反之,类装载器会请求装载这个类。
  3. 可见性限制:一个子装载器可以查找父装载器中的类,但是一个父装载器不能查找子装载器里的类。
  4. 不允许卸载:类装载器可以装载一个类但是不可以卸载它,不过可以删除当前的类装载器,然后创建一个新的类装载器装载。

2.4.6 类加载器的隔离问题

  每个类装载器都有一个自己的命名空间用来保存已装载的类。当一个类装载器装载一个类时,它会通过保存在命名空间里的类全局限定名(Fully Qualified Class Name) 进行搜索来检测这个类是否已经被加载了。
  JVM 及 Dalvik 对类唯一的识别是 ClassLoader id + PackageName + ClassName,所以一个运行程序中是有可能存在两个包名和类名完全一致的类的。并且如果这两个类不是由一个 ClassLoader 加载,是无法将一个类的实例强转为另外一个类的,这就是 ClassLoader 隔离性。
  为了解决类加载器的隔离问题,JVM引入了双亲委托机制。
  双亲委派模型的工作过程是这样的:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父类加载器反馈自己无法完成这个类加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载
  这样做的好处就是 Java 类随着它的类加载器一起具备了一种带有优先级的层次关系。例如 java.lang.Object,它放在 rt.jar 中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型顶端的启动类加载器来加载,因此 Object 类在程序的各种类加载器环境中都是同一个类。相反,如果没有使用双亲委派模型,由各个类加载器自行去加载的话,如果用户自己编写了一个称为 java.lang.Object 的类,并放在程序的 ClassPath 中,那系统中将会出现多个不同的 Object 类,Java 类型体系中最基本的行为也就无法保证了。
  双亲委派模型对于保证 Java 程序运行的稳定性很重要,但它的实现很简单,实现双亲委派模型的代码都集中在 java.lang.ClassLoader 的 loadClass() 方法中,逻辑很清晰:先检查是否已经被加载过,若没有则调用父类加载器的 loadClass() 方法,若父加载器为空则默认使用启动类加载器作为父加载器。如果父类加载失败,抛出 ClassNotFoundException 异常后,再调用自己的 findClass() 方法进行加载。
  双亲委派模型的关键代码:

protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException {
    
    
    // 首先,检查请求的类是不是已经被加载过
    Class<?> c = findLoadedClass(name);
    if (c == null) {
    
    
        try {
    
    
            if (parent != null) {
    
    
                c = parent.loadClass(name, false);
            } else {
    
    
                c = findBootstrapClassOrNull(name);
            }
        } catch (ClassNotFoundException e) {
    
    
            // 如果父类抛出 ClassNotFoundException 说明父类加载器无法完成加载
        }

        if (c == null) {
    
    
            // 如果父类加载器无法加载,则调用自己的 findClass 方法来进行类加载
            c = findClass(name);
        }
    }
    if (resolve) {
    
    
        resolveClass(c);
    }
    return c;
}

2.4.7 自定义类加载器

   在很多容器设计中,如Spring、Tomcat,都实现了自定义类加载器,所以我们可以尝试实现一下。【如果要破坏双亲委派机制,需要重写loadClass方法,当然一般不会这样做】。只需extends ClassLoader然后重写findClass方法即可。假设有这样一个类:

package Basic;

public class MyTest {
    
    
    public void show() {
    
    
        System.out.println("show test!");
    }
}

   在Eclipse写了后,会自动生成一个MyTest.class文件。然后我们自定义自己的类加载器:

package Basic;

import java.lang.reflect.Method;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;


public class MyClassLoader extends ClassLoader {
    
    
    @Override
    protected Class<?> findClass(String name) {
    
    
        String myPath = "file:///E:/Test/" + name.replace(".","/") + ".class";
        System.out.println(myPath);
        byte[] cLassBytes = null;
        Path path = null;
        try {
    
    
            path = Paths.get(new URI(myPath));
            cLassBytes = Files.readAllBytes(path);
        } catch (Exception e) {
    
    
            e.printStackTrace();
        }
        Class clazz = defineClass(name, cLassBytes, 0, cLassBytes.length);
        return clazz;
    }
    
    public static void main(String[] args) throws ClassNotFoundException {
    
    
        MyClassLoader loader = new MyClassLoader();
        Class<?> aClass = loader.findClass("Basic.MyTest2");
        try {
    
    
            Object obj = aClass.newInstance();
            Method method = aClass.getMethod("show");
            method.invoke(obj);
        } catch (Exception e) {
    
    
            e.printStackTrace();
        }
    }
}

   在E盘创建个Test目录,将自动编译的Mytest2.class文件放进去,运行上述代码就可以看到正确答案:

file:///E:/Test/Basic/MyTest2.class
show test!

2.5 JVM加载Class文件的原理机制

  Java中的所有类,都需要由类加载器装载到JVM中才能运行。类加载器本身也是一个类,而它的工作就是把class文件从硬盘读取到内存中。在写程序的时候,我们几乎不需要关心类的加载,因为这些都是隐式装载的,除非我们有特殊的用法,像是反射,就需要显式的加载所需要的类。
  类装载方式,有两种 :

  • 1、隐式装载
      程序在运行过程中当碰到通过new 等方式生成对象时,隐式调用类装载器加载对应的类到JVM中。
  • 2、显式装载
      通过class.forname()等方法,显式加载需要的类。

  Java类的加载是动态的,它并不会一次性将所有类全部加载后再运行,而是保证程序运行的基础类(像是基类)完全加载到jvm中,至于其他类,则在需要的时候才加载。这当然就是为了节省内存开销。

三、Java内存模型与线程

  让计算机同时执行多个任务,因为处理器的性能更加强大,更因为计算机的运算速度和它的存储以及通信子系统速度差距太大,大量的时间都花费在磁盘 I/O 、网络通信和数据库访问上。为了不让处理器因为等待其它资源而浪费处理器的资源与时间,我们就必须采用让计算机同时执行多任务的方式去充分利用处理器的性能;同时也是为了应对服务端高并发的需求。而 Java 内存模型的设计和线程的存在正是为了更好、更高效的实现多任务。

3.1 硬件的效率与一致性

  计算机中绝大多数的任务都不可能只靠处理器计算就能完成,处理器至少要和内存交互,如读取数据、存储结果等等,这个 I/O 操作是很难消除的。由于计算器的存储设备和处理器的运算速度有几个量级的差距,所以计算机不得不加入一层读写速度尽可能接近处理器运算速度的高速缓存来作为内存与处理器之间的缓冲:将运算需要用到的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存中,这样处理器就无需等待缓慢的内存读写了。
  基于高速缓存的存储交互很好的解决了处理器与内存的速度矛盾,但是也为计算机系统带来更高的复杂度,因为它引入了一个新的问题:缓存一致性。在多处理器中,每个处理器都有自己的高速缓存,而它们又共享同一主内存。当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致。为了解决一致性的问题,需要各个处理器的访问缓存时都遵循一些协议,在读写时要根据协议来进行操作。

  除了增加高速缓存外,为了使处理器内部的运算单元能尽量被充分利用,处理器可能会对输入的代码进行乱序执行优化,处理器会在计算之后将乱序执行的结果重组,保证该结果与顺序执行的结果一致,但不保证程序中各个语句计算的先后顺序与输入代码中的顺序一致,因此,如果存在一个计算任务依赖另一个计算任务的中间结果,那么其顺序性并不能靠代码的先后顺序来保证。与处理器的乱象执行优化类似,JIT 编译器中也有类似的指令重排优化。

3.2 Java内存模型

  Java 虚拟机规范中定义了 Java 内存模型,用来屏蔽各种硬件和操作系统的内存访问差异,以实现让 Java 程序在各种平台下都能达到一致的内存访问效果。像 C/C++ 这类语言直接使用了物理硬件和操作系统的内存模型,因此会由于不同平台上内存模型的差异,需要针对不同平台来编写代码。

3.2.1 主内存与工作内存

  Java 内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中读取变量这样的底层细节。这里说的变量和 Java 代码中的变量有所区别,它包括了实例字段、静态字段和构成数组对象的元素,但不包括变量和方法参数,因为后者是线程私有的,不会被共享。为了获得较好的执行性能,Java 内存模型并没有限制执行引擎使用处理器的特定寄存器或缓存来和主内存进行交互,也没有限制 JIT 编译器进行代码执行顺序这类优化措施。
  Java 内存模型规定了所有的变量都存储在主内存,每条线程都有自己单独的工作内存,线程的工作内存中保存了被该线程使用到的变量的主内存的副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存,线程间变量值的传递均需要通过主内存来完成。

3.2.2 内存间交互操作

  关于主内存与工作内存间具体的交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存之类的细节,Java 内存模型定义了以下 8 种操作来完成,虚拟机实现时必须保证下面的每一种操作都是原子的、不可再分的。
  这 8 种操作分别是:lock(锁定)、unlock(解锁)、read(读取)、load(载入)、use(使用)、assign(赋值)、store(存储)、write(写入)。

3.2.3 对 volatile 型变量的特殊规则

  volatile 是 Java 虚拟机提供的最轻量级的同步机制。当一个变量被定义为 volatile 后,它将具备两种特性:

  • 1、保证此变量对所有线程的可见性
      这里的"可见性"是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。普通变量则做不到这一点,需要通过主内存来在线程间传递数据。比如,线程 A 修改了一个普通的变量值,然后向主内存进行回写,另一条线程 B 在 A 线程回写完成之后再从主内存进行读写操作,新变量值才会对线程 B 可见。
  • 2、禁止指令重排优化
      普通变量仅仅会保证方法的执行过程中所有依赖赋值结果的地方 能够获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致。因为在一个线程的方法执行过程中无法感知到这点,这也就是 Java 内存模型中描述的所谓的"线程内表现为串行的语义"。

3.2.4 对 long 和 double 型变量的特殊规则

  Java 内存模型要求 lock、unlock、read、load、assign、use、store、writer 这 8 个操作都具有原子性,但对于 64 位数据类型(long 和 double),在模型中特别定义了一条相对宽松的规定:允许虚拟机将没有被 volatile 修饰的 64 位数据的读写操作划分为两次 32 位的操作来进行,即允许虚拟机实现选择可以不保证 64 位数据类型的 load、store、read 和 write 这 4 个操作的原子性。这点就是所谓的 long 和 double 的非原子协定。
  如果有多个线程共享一个未声明为 volatile 的 long 或 double 类型的变量,并且同时对它们进行读取和修改操作,那么某些线程可能会读取到一个错误的值。好在这种情况非常罕见,主流商业虚拟机中也都把对 long 和 double 的操作视为原子性,因此在实际开发中无需使用 volatile 来修饰变量。

3.2.5 原子性、可见性和有序性

  Java 内存模型是围绕着在并发过程中如何处理原子性、可见性和有序性 3 个特质来建立的:

  • 1、原子性(Atomicity)
      由 Java 内存模型来直接保证原子性变量操作,包括 read、load、assign、use、store 和 write ,我们大致可以认为基本数据类型的访问读写是具备原子性的。如果应用场景需要一个更大范围的原子性保证,Java 内存模型还提供了 lock 和 unlock 操作来满足这种需求,尽管虚拟机未把 lock 和 unlock 操作直接开放给用户使用,但是却提供了更高层次的字节码指令 monitorenter 和 monitorexit 来隐式地使用这两个操作,这两个字节码指令反映到 Java 代码中就是 synchronized 关键字,因此被 synchronize 修饰的方法或代码块之间的操作是具备原子性的。
  • 2、可见性(Visibility)
      可见性是指当一个线程修改了共享变量的值,其它线程能够立即得知这个修改。Java 内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的,无论是普通变量还是 volatile 变量都是如此,普通变量与 volatile 变量的区别是, volatile 的规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。因此,可以说 volatile 保证了多线程操作变量的可见性,而普通变量则不能保证这一点。除了 volatile 外,Java 还有两个关键字 synchronized 和 final 。synchronized 同步块的可见性是由「对一个变量执行 unlock 操作前,必须先把此变量同步回主内存中(执行 store、write 操作)」这条规则获得的;final 的可见性是指:被 final 修饰的字段在构造器中一旦初始化完成,并且构造器没有「this」的引用传递出去,那在其他线程中就能看见 final 字段的值。
  • 3、有序性(Ordering)
      Java 程序中天然的有序性可以总结为:如果在本线程内,所有操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。前半句是指「线程内表现为串行的语义」,后半句是指「指令重排序」现象和「工作内存和主内存同步延迟」现象。Java 语言提供了 volatile 和 synchronized 两个关键字来保证线程之间操作的有序性,volatile 关键字本身就包含了禁止指令重排的语义,而 synchronized 则是由「一个变量在同一时刻只允许一条线程对其进行 lock 操作」这条规则获得的,这条规则决定了持有同一个锁的两个同步块只能串行的进入。

四、JVM调试

  给一个系统定位问题的时候,知识、经验是关键基础,数据是依据,工具是运用知识处理数据的手段。这里说的数据包括:运行日志、异常堆栈、GC日志、线程快照(threaddump/javacore文件)、堆转储快照(heapdump/hprof文件)等。经常使用适当的虚拟机监控和分析的工具可以加快我们分析数据、定位解决问题的速度,但在学习工具前,也应当意识到工具永远都是知识技能的一层包装,没有什么工具是“秘密武器”,不可能学会了就能包治百病。

4.1 JDK自带命令

  Sun JDK监控和故障处理命令如下:

  1. jps
      JVM Process Status Tool,显示指定系统内所有的HotSpot虚拟机进程。
  2. jstat
      JVM statistics Monitoring是用于监视虚拟机运行时状态信息的命令,它可以显示出虚拟机进程中的类装载、内存、垃圾收集、JIT编译等运行数据。
  3. jmap
      JVM Memory Map命令用于生成heap dump文件。
  4. jhat
      JVM Heap Analysis Tool命令是与jmap搭配使用,用来分析jmap生成的dump,jhat内置了一个微型的HTTP/HTML服务器,生成dump的分析结果后,可以在浏览器中查看。
  5. jstack
      用于生成java虚拟机当前时刻的线程快照。
  6. jinfo
      JVM Configuration info 这个命令作用是实时查看和调整虚拟机运行参数。
  • 1、jPS:虚拟机进程状况工具
      意义是:查看当前java进程,jsp命令格式:
	jps [options] [hostid]

  jps执行样例:

	D:\Develop\Java\jdk1.6.0_21\bin>jps-l
	2388 D:\Develop\glassfish\bin\..\modules\admin-cli.jar
	2764 com.sun.enterprise.glassfish.bootstrap.ASMain
	3788 sun.tools.jps.Jps

  • 2、jstat:虚拟机统计信息监视工具
      用于监视虚拟机各种运行状态信息,jstat命令格式为:
	jstat [option vmid [interval [s|ms] [count]]]

  假设需要每250毫秒查询一次进程2764垃圾收集状况,一共查询20次,那命令应当是:

	jstat-gc 2764 250 20

  • 3、jinfo:Java配置信息工具
      实时地查看和调整虚拟机各项参数,jinfo命令格式:
	jinfo [option] pid

  执行样例:查询CMSInitiatingOccupancyFraction参数值:

	C:\>jinfo-flag CMSInitiatingOccupancyFraction 1444
	-XX:CMSInitiatingOccupancyFraction=85
  • 4、jmap:Java内存映像工具
      jmap(Memory Map for Java)命令用于生成堆转储快照(可以将VM 中的heap,以二进制输出成文本)。jmap的作用并不仅仅是为了获取dump文件,它还可以查询finalize执行队列、Java堆和永久代的详细信息,如空间使用率、当前用的是哪种收集器等。
      jmap命令格式:
	jmap [option] vmid


  使用jmap生成dump文件示例:

	C:\Users\IcyFenix>jmap-dump:format=b,file=eclipse.bin 3500
	Dumping heap to C:\Users\IcyFenix\eclipse.bin……
	Heap dump file created
  • 5、jstack:Java堆栈跟踪工具
      jstack(Stack Trace for Java)命令用于生成虚拟机当前时刻的线程快照(一般称为threaddump或者javacore文件)。线程快照就是当前虚拟机内每一条线程正在执行的方法堆栈的集合,生成线程快照的主要目的是定位线程出现长时间停顿的原因,如线程间死锁、死循环、请求外部资源导致的长时间等待等都是导致线程长时间停顿的常见原因。线程出现停顿的时候通过jstack来查看各个线程的调用堆栈,就可以知道没有响应的线程到底在后台做些什么事情,或者等待着什么资源。
      jstack用于打印出给定的java进程ID或core file或远程调试服务的Java堆栈信息。
      jstack命令格式:
	jstack [option] vmid

4.2 JDK的可视化工具JConsole

  JConsole(Java Monitoring and Management Console)是一种基于JMX(Java Management Extensions,即Java管理扩展)的可视化监视、管理工具。
  通过JDK/bin目录下的“jconsole.exe”启动JConsole后,将自动搜索出本机运行的所有虚拟机进程。

  “概述”页签显示的是整个虚拟机主要运行数据的概览,其中包括“堆内存使用情况”、“线程”、“类”、“CPU使用情况”4种信息的曲线图,这些曲线图是后面“内存”、“线程”、“类”页签的信息汇总,具体内容将在后面介绍。

  • 1、内存监控
      “内存”页签相当于可视化的jstat命令,用于监视受收集器管理的虚拟机内存(Java堆和永久代)的变化趋势。我们通过运行代码清单4-8中的代码来体验一下它的监视功能。运行时设置的虚拟机参数为:-Xms100m-Xmx100m-XX:+UseSerialGC,这段代码的作用是以64KB/50毫秒的速度往Java堆中填充数据,一共填充1000次,使用JConsole的“内存”页签进行监视,观察曲线和柱状指示图的变化:
	/**
	*内存占位符对象,一个OOMObject大约占64KB
	*/
	static class OOMObject{
    
    
		public byte[]placeholder=new byte[64*1024];
	}

	public static void fillHeap(int num) throws InterruptedException{
    
    
		List<OOMObject> list = new ArrayList<OOMObject>();
		for(int i=0;i<num;i++){
    
    
			//稍作延时,令监视曲线的变化更加明显
			Thread.sleep(50);
			list.add(new OOMObject());
		}
		System.gc();
	}
	
	public static void main(String[]args) throws Exception{
    
    
		fillHeap(1000);
	}

  程序运行后,在“内存”页签中可以看到内存池Eden区的运行趋势呈现折线状,如图4-6所示。而监视范围扩大至整个堆后,会发现曲线是一条向上增长的平滑曲线。并且从柱状图可以看出,在1000次循环执行结束,运行了System.gc()后,虽然整个新生代Eden和Survivor区都基本被清空了,但是代表老年代的柱状图仍然保持峰值状态,说明被填充进堆中的数据在System.gc()方法执行之后仍然存活。笔者的分析到此为止,现提两个小问题供读者思考一下,答案稍后给出。
  1)虚拟机启动参数只限制了Java堆为100MB,没有指定-Xmn参数,能否从监控图中估计出新生代有多大?
  2)为何执行了System.gc()之后,图4-6中代表老年代的柱状图仍然显示峰值状态,代码需要如何调整才能让System.gc()回收掉填充到堆中的对象?

  问题1答案:图4-6显示Eden空间为27 328KB,因为没有设置-XX:SurvivorRadio参数,所以Eden与Survivor空间比例为默认值8:1,整个新生代空间大约为27 328KB×125%=34160KB。
  问题2答案:执行完System.gc()之后,空间未能回收是因为List<OOMObject>list对象仍然存活,fillHeap()方法仍然没有退出,因此list对象在System.gc()执行时仍然处于作用域之内 [2] 。如果把System.gc()移动到fillHeap()方法外调用就可以回收掉全部内存。

  • 2、线程监控
      如果上面的“内存”页签相当于可视化的jstat命令的话,“线程”页签的功能相当于可视化的jstack命令,遇到线程停顿时可以使用这个页签进行监控分析。前面讲解jstack命令的时候提到过线程长时间停顿的主要原因主要有:等待外部资源(数据库连接、网络资源、设备资源等)、死循环、锁等待(活锁和死锁)。通过以下代码别演示一下这几种情况。
	/**
	*线程死循环演示
	*/
	public static void createBusyThread(){
    
    
		Thread thread=new Thread(new Runnable(){
    
    
			@Override
			public void run(){
    
    
				while(true);//第41行
			}
		},"testBusyThread");
		
		thread.start();
	}
	
	/**
	*线程锁等待演示
	*/
	public static void createLockThread(final Object lock){
    
    
		Thread thread=new Thread(new Runnable(){
    
    
			@Override
			public void run(){
    
    
				synchronized(lock){
    
    
					try{
    
    
						lock.wait();
					}catch(InterruptedException e){
    
    
						e.printStackTrace();
					}
				}
			}
		},"testLockThread");

		thread.start();
	}

	public static void main(String[]args)throws Exception{
    
    
		BufferedReader br = new BufferedReader(
			new InputStreamReader(System.in));
		br.readLine();
		createBusyThread();
		br.readLine();
		Object obj=new Object();
		createLockThread(obj);
	}

  程序运行后,首先在“线程”页签中选择main线程,如图4-7所示。堆栈追踪显示BufferedReader在readBytes方法中等待System.in的键盘输入,这时线程为Runnable状
态,Runnable状态的线程会被分配运行时间,但readBytes方法检查到流没有更新时会立刻归还执行令牌,这种等待只消耗很小的CPU资源。

  接着监控testBusyThread线程,如图4-8所示,testBusyThread线程一直在执行空循环,从堆栈追踪中看到一直在MonitoringTest.java代码的41行停留,41行为:while(true)。这时候线程为Runnable状态,而且没有归还线程执行令牌的动作,会在空循环上用尽全部执行时间直到线程切换,这种等待会消耗较多的CPU资源。

  图4-9显示testLockThread线程在等待着lock对象的notify或notifyAll方法的出现,线程这时候处于WAITING状态,在被唤醒前不会被分配执行时间。

  testLockThread线程正在处于正常的活锁等待,只要lock对象的notify()或notifyAll()方法被调用,这个线程便能激活以继续执行。代码清单4-10演示了一个无法再被激活的死锁等待。

	/**
	*线程死锁等待演示
	*/
	static class SynAddRunalbe implements Runnable{
    
    
		int a,b;
		public SynAddRunalbe(int a,int b){
    
    
			this.a=a;
			this.b=b;
		}

		@Override
		public void run(){
    
    
			synchronized(Integer.valueOf(a)){
    
    
				synchronized(Integer.valueOf(b)){
    
    
					System.out.println(a+b);
				}
			}
		}
	}
	
	public static void main(String[]args){
    
    
		for(int i=0;i<100;i++){
    
    
			new Thread(new SynAddRunalbe(1,2)).start();
			new Thread(new SynAddRunalbe(2,1)).start();
		}
	}

  这段代码开了200个线程去分别计算1+2以及2+1的值,其实for循环是可省略的,两个线程也可能会导致死锁,不过那样概率太小,需要尝试运行很多次才能看到效果。一般的话,带for循环的版本最多运行2~3次就会遇到线程死锁,程序无法结束。造成死锁的原因是Integer.valueOf()方法基于减少对象创建次数和节省内存的考虑,[-128,127]之间的数字会被缓存 [3] ,当valueOf()方法传入参数在这个范围之内,将直接返回缓存中的对象。也就是说,代码中调用了200次Integer.valueOf()方法一共就只返回了两个不同的对象。假如在某个线程的两个synchronized块之间发生了一次线程切换,那就会出现线程A等着被线程B持有的Integer.valueOf(1),线程B又等着被线程A持有的Integer.valueOf(2),结果出现大家都跑不下去的情景。
  出现线程死锁之后,点击JConsole线程面板的“检测到死锁”按钮,将出现一个新的“死锁”页签,如图4-10所示。

  图4-10中很清晰地显示了线程Thread-43在等待一个被线程Thread-12持有Integer对象,而点击线程Thread-12则显示它也在等待一个Integer对象,被线程Thread-43持有,这样两个线程就互相卡住,都不存在等到锁释放的希望了。

五、JVM调优案例分析

5.1 高性能硬件上的程序部署策略

  例如,一个15万PV(页面浏览量)/天左右的在线文档类型网站最近更换了硬件系统,新的硬件为4个CPU、16GB物理内存,操作系统为64位CentOS 5.4,Resin作为Web服务器。整个服务器暂没有部署别的应用,所有硬件资源都可以提供给这访问量并不算太大的网站使用。管理员为了尽量利用硬件资源选用了64位的JDK 1.5,并通过-Xmx和-Xms参数将Java堆固定在12GB。使用一段时间后发现使用效果并不理想,网站经常不定期出现长时间失去响应的情况。
  监控服务器运行状况后发现网站失去响应是由GC停顿导致的,虚拟机运行在Server模式,默认使用吞吐量优先收集器,回收12GB的堆,一次Full GC的停顿时间高达14秒。并且由于程序设计的关系,访问文档时要把文档从磁盘提取到内存中,导致内存中出现很多由文档序列化产生的大对象,这些大对象很多都进入了老年代,没有在Minor GC中清理掉。这种情况下即使有12GB的堆,内存也很快被消耗殆尽,由此导致每隔十几分钟出现十几秒的停顿,令网站开发人员和管理员感到很沮丧。
  这里先不延伸讨论程序代码问题,程序部署上的主要问题显然是过大的堆内存进行回收时带来的长时间的停顿。硬件升级前使用32位系统1.5GB的堆,用户只感觉到使用网站比较缓慢,但不会发生十分明显的停顿,因此才考虑升级硬件以提升程序效能,如果重新缩小给Java堆分配的内存,那么硬件上的投资就显得很浪费。
  在高性能硬件上部署程序,目前主要有两种方式:

通过64位JDK来使用大内存。
使用若干个32位虚拟机建立逻辑集群来利用硬件资源。

  此案例中的管理员采用了第一种部署方式。对于用户交互性强、对停顿时间敏感的系统,可以给Java虚拟机分配超大堆的前提是有把握把应用程序的Full GC频率控制得足够低,至少要低到不会影响用户使用,譬如十几个小时乃至一天才出现一次Full GC,这样可以通过在深夜执行定时任务的方式触发Full GC甚至自动重启应用服务器来保持内存可用空间在一个稳定的水平。
  控制Full GC频率的关键是看应用中绝大多数对象能否符合“朝生夕灭”的原则,即大多数对象的生存时间不应太长,尤其是不能有成批量的、长生存时间的大对象产生,这样才能保障老年代空间的稳定
  在大多数网站形式的应用里,主要对象的生存周期都应该是请求级或者页面级的,会话级和全局级的长生命对象相对很少。只要代码写得合理,应当都能实现在超大堆中正常使用而没有Full GC,这样的话,使用超大堆内存时,网站响应速度才会比较有保证。除此之外,如果读者计划使用64位JDK来管理大内存,还需要考虑下面可能面临的问题:

内存回收导致的长时间停顿。
现阶段,64位JDK的性能测试结果普遍低于32位JDK。

  需要保证程序足够稳定,因为这种应用要是产生堆溢出几乎就无法产生堆转储快照(因为要产生十几GB乃至更大的Dump文件),哪怕产生了快照也几乎无法进行分析。
  相同程序在64位JDK消耗的内存一般比32位JDK大,这是由于指针膨胀,以及数据类型对齐补白等因素导致的。
  上面的问题听起来有点吓人,所以现阶段不少管理员还是选择第二种方式:使用若干个32位虚拟机建立逻辑集群来利用硬件资源。具体做法是在一台物理机器上启动多个应用服务器进程,每个服务器进程分配不同端口,然后在前端搭建一个负载均衡器,以反向代理的方式来分配访问请求。读者不需要太过在意均衡器转发所消耗的性能,即使使用64位JDK,许多应用也不止有一台服务器,因此在许多应用中前端的均衡器总是要存在的。
  考虑到在一台物理机器上建立逻辑集群的目的仅仅是为了尽可能利用硬件资源,并不需要关心状态保留、热转移之类的高可用性需求,也不需要保证每个虚拟机进程有绝对准确的均衡负载,因此使用无Session复制的亲合式集群是一个相当不错的选择。我们仅仅需要保障集群具备亲合性,也就是均衡器按一定的规则算法(一般根据SessionID分配)将一个固定的用户请求永远分配到固定的一个集群节点进行处理即可,这样程序开发阶段就基本不用为集群环境做什么特别的考虑了。
  当然,很少有没有缺点的方案,如果读者计划使用逻辑集群的方式来部署程序,可能会遇到下面一些问题:

  1. 尽量避免节点竞争全局的资源,最典型的就是磁盘竞争,各个节点如果同时访问某个磁盘文件的话(尤其是并发写操作容易出现问题),很容易导致IO异常。
  2. 很难最高效率地利用某些资源池,譬如连接池,一般都是在各个节点建立自己独立的连接池,这样有可能导致一些节点池满了而另外一些节点仍有较多空余。尽管可以使用集中式的JNDI,但这个有一定复杂性并且可能带来额外的性能开销。
  3. 各个节点仍然不可避免地受到32位的内存限制,在32位Windows平台中每个进程只能使用2GB的内存,考虑到堆以外的内存开销,堆一般最多只能开到1.5GB。在某些Linux或UNIX系统(如Solaris)中,可以提升到3GB乃至接近4GB的内存,但32位中仍然受最高4GB(2 32 )内存的限制。
  4. 大量使用本地缓存(如大量使用HashMap作为K/V缓存)的应用,在逻辑集群中会造成较大的内存浪费,因为每个逻辑节点上都有一份缓存,这时候可以考虑把本地缓存改为集中式缓存。

  介绍完这两种部署方式,再重新回到这个案例之中,最后的部署方案调整为建立5个32位JDK的逻辑集群,每个进程按2GB内存计算(其中堆固定为1.5GB),占用了10GB内存
  另外建立一个Apache服务作为前端均衡代理访问门户
  考虑到用户对响应速度比较关心,并且文档服务的主要压力集中在磁盘和内存访问,CPU资源敏感度较低,因此改为CMS收集器进行垃圾回收。部署方式调整后,服务再没有出现长时间停顿,速度比硬件升级前有较大提升。

5.2 集群间同步导致的内存溢出

  例如,有一个基于B/S的MIS系统,硬件为两台2个CPU、8GB内存的HP小型机,服务器是WebLogic 9.2,每台机器启动了3个WebLogic实例,构成一个6个节点的亲合式集群。由于是亲合式集群,节点之间没有进行Session同步,但是有一些需求要实现部分数据在各个节点间共享。开始这些数据存放在数据库中,但由于读写频繁竞争很激烈,性能影响较大,后面使用JBossCache构建了一个全局缓存。全局缓存启用后,服务正常使用了一段较长的时间,但最近却不定期地出现了多次的内存溢出问题。
  在内存溢出异常不出现的时候,服务内存回收状况一直正常,每次内存回收后都能恢复到一个稳定的可用空间,开始怀疑是程序某些不常用的代码路径中存在内存泄漏,但管理员反映最近程序并未更新、升级过,也没有进行什么特别操作。只好让服务带着-XX:+HeapDumpOnOutOfMemoryError参数运行了一段时间。在最近一次溢出之后,管理员发回了heapdump文件,发现里面存在着大量的org.jgroups.protocols.pbcast.NAKACK对象。
  BossCache是基于自家的JGroups进行集群间的数据通信,JGroups使用协议栈的方式来实现收发数据包的各种所需特性自由组合,数据包接收和发送时要经过每层协议栈的up()和down()方法,其中的NAKACK栈用于保障各个包的有效顺序及重发。JBossCache协议栈如图5-1所示。

  由于信息有传输失败需要重发的可能性,在确认所有注册在GMS(Group MembershipService)的节点都收到正确的信息前,发送的信息必须在内存中保留。而此MIS的服务端中有一个负责安全校验的全局Filter,每当接收到请求时,均会更新一次最后操作时间,并且将这个时间同步到所有的节点去,使得一个用户在一段时间内不能在多台机器上登录。在服务使用过程中,往往一个页面会产生数次乃至数十次的请求,因此这个过滤器导致集群各个节点之间网络交互非常频繁。当网络情况不能满足传输要求时,重发数据在内存中不断堆积,很快就产生了内存溢出。
  这个案例中的问题,既有JBossCache的缺陷,也有MIS系统实现方式上缺陷。JBossCache官方的maillist中讨论过很多次类似的内存溢出异常问题,据说后续版本也有了改进。而更重要的缺陷是这一类被集群共享的数据要使用类似JBossCache这种集群缓存来同步的话,可以允许读操作频繁,因为数据在本地内存有一份副本,读取的动作不会耗费多少资源,但不应当有过于频繁的写操作,那样会带来很大的网络同步的开销。

5.3 堆外内存导致的溢出错误

  例如,一个学校的小型项目:基于B/S的电子考试系统,为了实现客户端能实时地从服务器端接收考试数据,系统使用了逆向AJAX技术(也称为Comet或者Server Side Push),选用CometD 1.1.1作为服务端推送框架,服务器是Jetty 7.1.4,硬件为一台普通PC机,Core i5CPU,4GB内存,运行32位Windows操作系统。
  测试期间发现服务端不定时抛出内存溢出异常,服务器不一定每次都会出现异常,但假如正式考试时崩溃一次,那估计整场电子考试都会乱套,网站管理员尝试过把堆开到最大,而32位系统最多到1.6GB就基本无法再加大了,而且开大了基本没效果,抛出内存溢出异常好像还更加频繁了。加入-XX:+HeapDumpOnOutOfMemoryError,居然也没有任何反应,抛出内存溢出异常时什么文件都没有产生。无奈之下只好挂着jstat并一直紧盯屏幕,发现GC并不频繁,Eden区、Survivor区、老年代以及永久代内存全部都表示“情绪稳定,压力不大”,但就是照样不停地抛出内存溢出异常,管理员压力很大。最后,在内存溢出后从系统日志中找到异常堆栈,如代码清单5-1所示。

[org.eclipse.jetty.util.log]handle failed java.lang.OutOfMemoryError:null
at sun.misc.Unsafe.allocateMemory(Native Method)
at java.nio.DirectByteBuffer.<init>(DirectByteBuffer.java:99)
at java.nio.ByteBuffer.allocateDirect(ByteBuffer.java:288)
at org.eclipse.jetty.io.nio.DirectNIOBuffer.<init>

  大家知道操作系统对每个进程能管理的内存是有限制的,这台服务器使用的32位Windows平台的限制是2GB,其中划了1.6GB给Java堆,而Direct Memory内存并不算入1.6GB的堆之内,因此它最大也只能在剩余的0.4GB空间中分出一部分。在此应用中导致溢出的关键是:垃圾收集进行时,虚拟机虽然会对Direct Memory进行回收,但是Direct Memory却不能像新生代、老年代那样,发现空间不足了就通知收集器进行垃圾回收,它只能等待老年代满了后Full GC,然后“顺便地”帮它清理掉内存的废弃对象。否则它只能一直等到抛出内存溢出异常时,先catch掉,再在catch块里面“大喊”一声:“System.gc()!”。要是虚拟机还是不听(譬如打开了-XX:+DisableExplicitGC开关),那就只能眼睁睁地看着堆中还有许多空闲内存,自己却不得不抛出内存溢出异常了。而本案例中使用的CometD 1.1.1框架,正好有大量的NIO操作需要使用到Direct Memory内存。
  从实践经验的角度出发,除了Java堆和永久代之外,我们注意到下面这些区域还会占用较多的内存,这里所有的内存总和受到操作系统进程最大内存的限制。
  Direct Memory:可通过-XX:MaxDirectMemorySize调整大小,内存不足时抛出OutOfMemoryError或者OutOfMemoryError:Direct buffer memory。
  线程堆栈:可通过-Xss调整大小,内存不足时抛出StackOverflowError(纵向无法分配,即无法分配新的栈帧)或者OutOfMemoryError:unable to create new native thread(横向无法分配,即无法建立新的线程)。
  Socket缓存区:每个Socket连接都Receive和Send两个缓存区,分别占大约37KB和25KB内存,连接多的话这块内存占用也比较可观。如果无法分配,则可能会抛出IOException:Toomany open files异常。
  JNI代码:如果代码中使用JNI调用本地库,那本地库使用的内存也不在堆中。
  虚拟机和GC:虚拟机、GC的代码执行也要消耗一定的内存。

5.4 外部命令导致系统缓慢

  这是一个来自网络的案例:一个数字校园应用系统,运行在一台4个CPU的Solaris 10操作系统上,中间件为GlassFish服务器。系统在做大并发压力测试的时候,发现请求响应时间比较慢,通过操作系统的mpstat工具发现CPU使用率很高,并且系统占用绝大多数的CPU资源的程序并不是应用系统本身。这是个不正常的现象,通常情况下用户应用的CPU占用率应该占主要地位,才能说明系统是正常工作的。
  通过Solaris 10的Dtrace脚本可以查看当前情况下哪些系统调用花费了最多的CPU资源,Dtrace运行后发现最消耗CPU资源的竟然是“fork”系统调用。众所周知,“fork”系统调用是Linux用来产生新进程的,在Java虚拟机中,用户编写的Java代码最多只有线程的概念,不应当有进程的产生。
  这是个非常异常的现象。通过本系统的开发人员,最终找到了答案:每个用户请求的处理都需要执行一个外部shell脚本来获得系统的一些信息。执行这个shell脚本是通过Java的Runtime.getRuntime().exec()方法来调用的。这种调用方式可以达到目的,但是它在Java虚拟机中是非常消耗资源的操作,即使外部命令本身能很快执行完毕,频繁调用时创建进程的开销也非常可观。Java虚拟机执行这个命令的过程是:首先克隆一个和当前虚拟机拥有一样环境变量的进程,再用这个新的进程去执行外部命令,最后再退出这个进程。如果频繁执行这个操作,系统的消耗会很大,不仅是CPU,内存负担也很重。
  用户根据建议去掉这个Shell脚本执行的语句,改为使用Java的API去获取这些信息后,系统很快恢复了正常。

5.5 服务器JVM进程崩溃

  例如,一个基于B/S的MIS系统,硬件为两台2个CPU、8GB内存的HP系统,服务器是WebLogic 9.2。正常运行一段时间后,最近发现在运行期间频繁出现集群节点的虚拟机进程自动关闭的现象,留下了一个hs_err_pid###.log文件后,进程就消失了,两台物理机器里的每个节点都出现过进程崩溃的现象。从系统日志中可以看出,每个节点的虚拟机进程在崩溃前不久,都发生过大量相同的异常,见代码清单5-2。

java.net.SocketException:Connection reset
at java.net.SocketInputStream.read(SocketInputStream.java:168)
at java.io.BufferedInputStream.fill(BufferedInputStream.java:218)
at java.io.BufferedInputStream.read(BufferedInputStream.java:235)
at org.apache.axis.transport.http.HTTPSender.readHeadersFromSocket(HTTPSender.java:583)
at org.apache.axis.transport.http.HTTPSender.invoke(HTTPSender.java:143)……99 more

  这是一个远端断开连接的异常,通过系统管理员了解到系统最近与一个OA门户做了集成,在MIS系统工作流的待办事项变化时,要通过Web服务通知OA门户系统,把待办事项的变化同步到OA门户之中。通过SoapUI测试了一下同步待办事项的几个Web服务,发现调用后竟然需要长达3分钟才能返回,并且返回结果都是连接中断。
  由于MIS系统的用户多,待办事项变化很快,为了不被OA系统速度拖累,使用了异步的方式调用Web服务,但由于两边服务速度的完全不对等,时间越长就累积了越多Web服务没有调用完成,导致在等待的线程和Socket连接越来越多,最终在超过虚拟机的承受能力后使得虚拟机进程崩溃。解决方法:通知OA门户方修复无法使用的集成接口,并将异步调用改为生产者/消费者模式的消息队列实现后,系统恢复正常。

5.6 不恰当数据结构导致内存占用过大

  例如,有一个后台RPC服务器,使用64位虚拟机,内存配置为-Xms4g-Xmx8g-Xmn1g,使用ParNew+CMS的收集器组合。平时对外服务的Minor GC时间约在30毫秒以内,完全可以接受。但业务上需要每10分钟加载一个约80MB的数据文件到内存进行数据分析,这些数据会在内存中形成超过100万个HashMap<Long,Long>Entry,在这段时间里面Minor GC就会造成超过500毫秒的停顿,对于这个停顿时间就接受不了了,具体情况如下面GC日志所示。

{
    
    Heap before GC invocations=95(full 4):
par new generation total 903168K,used 803142K[0x00002aaaae770000,0x00002aaaebb70000,0x00002aaaebb70000)
eden space 802816K,100%used[0x00002aaaae770000,0x00002aaadf770000,0x00002aaadf770000)
from space 100352K,0%used[0x00002aaae5970000,0x00002aaae59c1910,0x00002aaaebb70000)
to space 100352K,0%used[0x00002aaadf770000,0x00002aaadf770000,0x00002aaae59700000)
concurrent mark-sweep generation total 5845540K,used 3898978K[0x00002aaaebb70000,0x00002aac507f9000,0x00002aacae770000)
concurrent-mark-sweep perm gen total 65536K,used 40333K[0x00002aacae770000,0x00002aacb2770000,0x00002aacb2770000)
2 0 1 1-1 0-2 8 T 1 1:4 0:4 5.1 6 2+0 8 0 0:2 2 6.5 0 4:[G C 2 2 6.5 0 4:[P a r N e w:803142K->100352K(903168K),0.5995670 secs]4702120K->
4056332K(6748708K),0.5997560
secs][Times:user=1.46 sys=0.04,real=0.60 secs]
Heap after GC invocations=96(full 4):
par new generation total 903168K,used 100352K[0x00002aaaae770000,0x00002aaaebb70000,0x00002aaaebb70000)
eden space 802816K,0%used[0x00002aaaae770000,0x00002aaaae770000,0x00002aaadf770000)
from space 100352K,100%used[0x00002aaadf770000,0x00002aaae5970000,
0x00002aaae5970000)
to space 100352K,0x00002aaaebb70000)0%used[0x00002aaae5970000,0x00002aaae5970000,
concurrent mark-sweep generation total 5845540K,used 3955980K[0x00002aaaebb70000,0x00002aac507f9000,0x00002aacae770000)
concurrent-mark-sweep perm gen total 65536K,used 40333K[0x00002aacae770000,0x00002aacb2770000,0x00002aacb2770000)
}
Total time for which application threads were stopped:0.6070570 seconds

  观察这个案例,发现平时的Minor GC时间很短,原因是新生代的绝大部分对象都是可清除的,在Minor GC之后Eden和Survivor基本上处于完全空闲的状态。而在分析数据文件期间,800MB的Eden空间很快被填满从而引发GC,但Minor GC之后,新生代中绝大部分对象依然是存活的。我们知道ParNew收集器使用的是复制算法,这个算法的高效是建立在大部分对象都“朝生夕灭”的特性上的,如果存活对象过多,把这些对象复制到Survivor并维持这些对象引用的正确就成为一个沉重的负担,因此导致GC暂停时间明显变长。
  如果不修改程序,仅从GC调优的角度去解决这个问题,可以考虑将Survivor空间去掉(加入参数-XX:SurvivorRatio=65536、-XX:MaxTenuringThreshold=0或者-XX:+AlwaysTenure),让新生代中存活的对象在第一次Minor GC后立即进入老年代,等到MajorGC的时候再清理它们。这种措施可以治标,但也有很大副作用,治本的方案需要修改程序,因为这里的问题产生的根本原因是用HashMap<Long,Long>结构来存储数据文件空间效率太低。
  下面具体分析一下空间效率。在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)+HashMap Ref(8B)=88B,空间效率为16B/88B=18%,实在太低了。

5.7 由Windows虚拟内存导致的长时间停顿

  例如,有一个带心跳检测功能的GUI桌面程序,每15秒会发送一次心跳检测信号,如果对方30秒以内都没有信号返回,那就认为和对方程序的连接已经断开。程序上线后发现心跳检测有误报的概率,查询日志发现误报的原因是程序会偶尔出现间隔约一分钟左右的时间完全无日志输出,处于停顿状态。
  因为是桌面程序,所需的内存并不大(-Xmx256m),所以开始并没有想到是GC导致的程序停顿,但是加入参数-XX:+PrintGCApplicationStoppedTime-XX:+PrintGCDateStamps-Xloggc:gclog.log后,从GC日志文件中确认了停顿确实是由GC导致的,大部分GC时间都控制在100毫秒以内,但偶尔就会出现一次接近1分钟的GC。

Total time for which application threads were stopped:0.0112389 seconds
Total time for which application threads were stopped:0.0001335 seconds
Total time for which application threads were stopped:0.0003246 seconds
Total time for which application threads were stopped:41.4731411 seconds
Total time for which application threads were stopped:0.0489481 seconds
Total time for which application threads were stopped:0.1110761 seconds
Total time for which application threads were stopped:0.0007286 seconds
Total time for which application threads were stopped:0.0001268 seconds

  从GC日志中找到长时间停顿的具体日志信息(添加了-XX:+PrintReferenceGC参数),找到的日志片段如下所示。从日志中可以看出,真正执行GC动作的时间不是很长,但从准备开始GC,到真正开始GC之间所消耗的时间却占了绝大部分。

2012-08-29T19:14:30.968+0800:10069.800:[GC10099.225:[SoftReference,0 refs,0.0000109 secs]10099.226:[WeakReference,4072 refs,0.0012099
secs]10099.227:[FinalReference,984 refs,1.5822450 secs]10100.809:[PhantomReference,251 refs,0.0001394 secs]10100.809:[JNI Weak Reference,0.0994015 secs]
[PSYoungGen:175672K->8528K(167360K)]251523K->100182K(353152K),31.1580402 secs][Times:user=0.61 sys=0.52,real=31.16 secs]

  除GC日志之外,还观察到这个GUI程序内存变化的一个特点,当它最小化的时候,资源管理中显示的占用内存大幅度减小,但是虚拟内存则没有变化,因此怀疑程序在最小化时它的工作内存被自动交换到磁盘的页面文件之中了,这样发生GC时就有可能因为恢复页面文件的操作而导致不正常的GC停顿。
  在MSDN上查证 [2] 后确认了这种猜想,因此,在Java的GUI程序中要避免这种现象,可以加入参数“-Dsun.awt.keepWorkingSetOnMinimize=true”来解决。这个参数在许多AWT的程序上都有应用,例如JDK自带的Visual VM,用于保证程序在恢复最小化时能够立即响应。在这个案例中加入该参数后,问题得到解决。

猜你喜欢

转载自blog.csdn.net/m0_37741420/article/details/121503201