最详细的JVM&GC讲解

0.671字数 21081阅读 4732

这篇文章是我之前翻阅了不少的书籍以及从网络上收集的一些资料的整理,因此不免有一些不准确的地方,同时不同JDK版本的差异也比较大。

不过文中一些JVM参数示例都是实际项目里调优的结果,还是经受过实战考验的。

目录


  1. JVM简介
  1. JVM结构
    2.1 方法区
    2.1.1 常量池
    2.1.1.1 Class文件中的常量池
    2.1.1.2 运行时常量池
    2.1.1.3 常量池的好处
    2.1.1.4 基本类型的包装类和常量池
    2.2 堆
    2.3 Java栈
    2.3.1 栈帧
    2.3.1.1 局部变量区
    2.3.1.2 操作数栈
    2.3.1.3 栈数据区
    2.4 本地方法栈
    2.5 PC寄存器
    2.6 堆与栈
    2.6.1 堆与栈里存什么
    2.6.2 堆内存与栈内存的区别
  2. JIT编译器
  3. 类加载机制
    4.1 类加载的时机
    4.2 类加载过程
  4. 垃圾回收
    5.1 按代实现垃圾回收
    5.2 怎样判断对象是否已经死亡
    5.3 java中的引用
    5.4 finalize方法什么作用
    5.5 垃圾收集算法
    5.6 Hotspot实现垃圾回收细节
    5.7 垃圾收集器
    5.7.1 Serial收集器
    5.7.2 ParNew收集器
    5.7.3 Parallel Scavenge收集器
    5.7.4 Serial Old收集器
    5.7.5 Parallel Old收集器
    5.7.6 CMS收集器
    5.7.7 G1收集器
  5. JVM参数
    6.1 典型配置
    6.1.1 堆大小设置
    6.1.2 回收器选择
    6.1.3 辅助信息
    6.2 参数详细说明
  6. JVM性能调优
    7.1 堆设置调优
    7.2 GC策略调优
    7.3 JIT调优
    7.4 JVM线程调优
    7.5 典型案例
  7. 常见问题
    8.1 内存泄漏及解决方法
    8.2 年老代堆空间被占满
    8.3 持久代被占满
    8.4 堆栈溢出
    8.5 线程堆栈满
    8.6 系统内存被占满

1.JVM简介

JVM是java的核心和基础,在java编译器和os平台之间的虚拟处理器。它是一种利用软件方法实现的抽象的计算机基于下层的操作系统和硬件平台,可以在上面执行java的字节码程序。

java编译器只要面向JVM,生成JVM能理解的代码或字节码文件。Java源文件经编译成字节码程序,通过JVM将每一条指令翻译成不同平台机器码,通过特定平台运行。

运行过程

Java语言写的源程序通过Java编译器,编译成与平台无关的‘字节码程序’(.class文件,也就是0,1二进制程序),然后在OS之上的Java解释器中解释执行。

C++以及Fortran这类编译型语言都会通过一个静态的编译器将程序编译成CPU相关的二进制代码。

PHP以及Perl这列语言则是解释型语言,只需要安装正确的解释器,它们就能运行在任何CPU之上。当程序被执行的时候,程序代码会被逐行解释并执行。


  1. 编译型语言的优缺点:
    • 速度快:因为在编译的时候它们能够获取到更多的有关程序结构的信息,从而有机会对它们进行优化。
    • 适用性差:它们编译得到的二进制代码往往是CPU相关的,在需要适配多种CPU时,可能需要编译多次。
  2. 解释型语言的优缺点:
    • 适应性强:只需要安装正确的解释器,程序在任何CPU上都能够被运行
    • 速度慢:因为程序需要被逐行翻译,导致速度变慢。同时因为缺乏编译这一过程,执行代码不能通过编译器进行优化。
  3. Java的做法是找到编译型语言和解释性语言的一个中间点:
    • Java代码会被编译:被编译成Java字节码,而不是针对某种CPU的二进制代码。
    • Java代码会被解释:Java字节码需要被java程序解释执行,此时,Java字节码被翻译成CPU相关的二进制代码。
    • JIT编译器的作用:在程序运行期间,将Java字节码编译成平台相关的二进制代码。正因为此编译行为发生在程序运行期间,所以该编译器被称为Just-In-Time编译器。

image.png
image.png

2.JVM结构

image.png

java是基于一门虚拟机的语言,所以了解并且熟知虚拟机运行原理非常重要。

2.1 方法区

方法区,Method Area, 对于习惯在HotSpot虚拟机上开发和部署程序的开发者来说,很多人愿意把方法区称为“永久代”(Permanent Generation),本质上两者并不等价,仅仅是因为HotSpot虚拟机的设计团队选择把GC分代收集扩展至方法区,或者说使用永久代来实现方法区而已。对于其他虚拟机(如BEA JRockit、IBM J9等)来说是不存在永久代的概念的。

主要存放已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据(比如spring 使用IOC或者AOP创建bean时,或者使用cglib,反射的形式动态生成class信息等)。

注意:JDK 6 时,String等字符串常量的信息是置于方法区中的,但是到了JDK 7 时,已经移动到了Java堆。所以,方法区也好,Java堆也罢,到底详细的保存了什么,其实没有具体定论,要结合不同的JVM版本来分析。

异常

当方法区无法满足内存分配需求时,将抛出OutOfMemoryError。
运行时常量池溢出:比如一直往常量池加入数据,就会引起OutOfMemoryError异常。

类信息

  1. 类型全限定名。
  2. 类型的直接超类的全限定名(除非这个类型是java.lang.Object,它没有超类)。
  3. 类型是类类型还是接口类型。
  4. 类型的访问修饰符(public、abstract或final的某个子集)。
  5. 任何直接超接口的全限定名的有序列表。
  6. 类型的常量池。
  7. 字段信息。
  8. 方法信息。
  9. 除了常量意外的所有类(静态)变量。
  10. 一个到类ClassLoader的引用。
  11. 一个到Class类的引用。

2.1.1 常量池

2.1.1.1 Class文件中的常量池

在Class文件结构中,最头的4个字节用于存储Megic Number,用于确定一个文件是否能被JVM接受,再接着4个字节用于存储版本号,前2个字节存储次版本号,后2个存储主版本号,再接着是用于存放常量的常量池,由于常量的数量是不固定的,所以常量池的入口放置一个U2类型的数据(constant_pool_count)存储常量池容量计数值。

常量池主要用于存放两大类常量:字面量(Literal)和符号引用量(Symbolic References),字面量相当于Java语言层面常量的概念,如文本字符串,声明为final的常量值等,符号引用则属于编译原理方面的概念,包括了如下三种类型的常量:

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

2.1.1.2 运行时常量池

CLass文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池,用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。

运行时常量池相对于CLass文件常量池的另外一个重要特征是具备动态性,Java语言并不要求常量一定只有编译期才能产生,也就是并非预置入CLass文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中,这种特性被开发人员利用比较多的就是String类的intern()方法。

2.1.1.3 常量池的好处

常量池是为了避免频繁的创建和销毁对象而影响系统性能,其实现了对象的共享。

例如字符串常量池,在编译阶段就把所有的字符串文字放到一个常量池中。

  • (1)节省内存空间:常量池中所有相同的字符串常量被合并,只占用一个空间。
  • (2)节省运行时间:比较字符串时,==比equals()快。对于两个引用变量,只用==判断引用是否相等,也就可以判断实际值是否相等。

双等号==的含义

  • 基本数据类型之间应用双等号,比较的是他们的数值。
  • 复合数据类型(类)之间应用双等号,比较的是他们在内存中的存放地址。

2.1.1.4 基本类型的包装类和常量池

java中基本类型的包装类的大部分都实现了常量池技术,即Byte,Short,Integer,Long,Character,Boolean。

这5种包装类默认创建了数值[-128,127]的相应类型的缓存数据,但是超出此范围仍然会去创建新的对象。 两种浮点数类型的包装类Float,Double并没有实现常量池技术。

Integer与常量池

Integer i1 = 40;
Integer i2 = 40;
Integer i3 = 0;
Integer i4 = new Integer(40);
Integer i5 = new Integer(40);
Integer i6 = new Integer(0);
 
System.out.println("i1=i2   " + (i1 == i2));
System.out.println("i1=i2+i3   " + (i1 == i2 + i3));
System.out.println("i1=i4   " + (i1 == i4));
System.out.println("i4=i5   " + (i4 == i5));
System.out.println("i4=i5+i6   " + (i4 == i5 + i6));  
System.out.println("40=i5+i6   " + (40 == i5 + i6));
 
 
i1=i2   true
i1=i2+i3   true
i1=i4   false
i4=i5   false
i4=i5+i6   true
40=i5+i6   true

解释:

  • (1)Integer i1=40;Java在编译的时候会直接将代码封装成Integer i1=Integer.valueOf(40);,从而使用常量池中的对象。
  • (2)Integer i1 = new Integer(40);这种情况下会创建新的对象。
  • (3)语句i4 == i5 + i6,因为+这个操作符不适用于Integer对象,首先i5和i6进行自动拆箱操作,进行数值相加,即i4 == 40。然后Integer对象无法与数值进行直接比较,所以i4自动拆箱转为int值40,最终这条语句转为40 == 40进行数值比较。

String与常量池

String str1 = "abcd";
String str2 = new String("abcd");
System.out.println(str1==str2);//false
  
String str1 = "str";
String str2 = "ing";
String str3 = "str" + "ing";
String str4 = str1 + str2;
System.out.println(str3 == str4);//false
  
String str5 = "string";
System.out.println(str3 == str5);//true

解释:

  • (1)new String("abcd")是在常量池中拿对象,"abcd"是直接在堆内存空间创建一个新的对象。只要使用new方法,便需要创建新的对象。
  • (2)连接表达式 +
    只有使用引号包含文本的方式创建的String对象之间使用“+”连接产生的新对象才会被加入字符串池中。
    对于所有包含new方式新建对象(包括null)的“+”连接表达式,它所产生的新对象都不会被加入字符串池中。
public static final String A; // 常量A
public static final String B;    // 常量B
static {  
   A = "ab";  
   B = "cd";  
}  
public static void main(String[] args) {  
// 将两个常量用+连接对s进行初始化  
String s = A + B;  
String t = "abcd";  
if (s == t) {  
    System.out.println("s等于t,它们是同一个对象");  
  } else {  
    System.out.println("s不等于t,它们不是同一个对象");  
  }  
}

解释:

s不等于t,它们不是同一个对象。

A和B虽然被定义为常量,但是它们都没有马上被赋值。在运算出s的值之前,他们何时被赋值,以及被赋予什么样的值,都是个变数。因此A和B在被赋值之前,性质类似于一个变量。那么s就不能在编译期被确定,而只能在运行时被创建了。

String s1 = new String("xyz"); //创建了几个对象?

解释:

考虑类加载阶段和实际执行时。

  • (1)类加载对一个类只会进行一次。”xyz”在类加载时就已经创建并驻留了(如果该类被加载之前已经有”xyz”字符串被驻留过则不需要重复创建用于驻留的”xyz”实例)。驻留的字符串是放在全局共享的字符串常量池中的。
  • (2)在这段代码后续被运行的时候,”xyz”字面量对应的String实例已经固定了,不会再被重复创建。所以这段代码将常量池中的对象复制一份放到heap中,并且把heap中的这个对象的引用交给s1 持有。

这条语句创建了2个对象。

public static void main(String[] args) {
String s1 = new String("计算机");
String s2 = s1.intern();
String s3 = "计算机";
System.out.println("s1 == s2? " + (s1 == s2));
System.out.println("s3 == s2? " + (s3 == s2));
}
s1 == s2? false
s3 == s2? true

解释:

String的intern()方法会查找在常量池中是否存在一份equal相等的字符串,如果有则返回该字符串的引用,如果没有则添加自己的字符串进入常量池。

public class Test {public static void main(String[] args) {
 String hello = "Hello", lo = "lo";
 System.out.println((hello == "Hello") + " "); //true
 System.out.println((Other.hello == hello) + " "); //true
 System.out.println((other.Other.hello == hello) + " "); //true
 System.out.println((hello == ("Hel"+"lo")) + " "); //true
 System.out.println((hello == ("Hel"+lo)) + " "); //false
 System.out.println(hello == ("Hel"+lo).intern()); //true
 }
}
 
class Other {
 static String hello = "Hello";
}
 
 
package other;
 
public class Other {
 public static String hello = "Hello";
} 

解释:

在同包同类下,引用自同一String对象.

在同包不同类下,引用自同一String对象.

在不同包不同类下,依然引用自同一String对象.

在编译成.class时能够识别为同一字符串的,自动优化成常量,引用自同一String对象.

在运行时创建的字符串具有独立的内存地址,所以不引用自同一String对象.

2.2 堆

Heap(堆)是JVM的内存数据区。

一个虚拟机实例只对应一个堆空间,堆是线程共享的。堆空间是存放对象实例的地方,几乎所有对象实例都在这里分配。堆也是垃圾收集器管理的主要区域(也被称为GC堆)。堆可以处于物理上不连续的内存空间中,只要逻辑上相连就行。

Heap 的管理很复杂,每次分配不定长的内存空间,专门用来保存对象的实例。在Heap 中分配一定的内存来保存对象实例,实际上也只是保存对象实例的属性值,属性的类型和对象本身的类型标记等,并不保存对象的方法(方法是指令,保存在Stack中)。而对象实例在Heap中分配好以后,需要在Stack中保存一个4字节的Heap 内存地址,用来定位该对象实例在Heap 中的位置,便于找到该对象实例。

异常

堆中没有足够的内存进行对象实例分配时,并且堆也无法扩展时,会抛出OutOfMemoryError异常。

image.png

2.3 Java栈

Stack(栈)是JVM的内存指令区。

描述的是java方法执行的内存模型:每个方法被执行的时候都会同时创建一个栈帧,用于存放局部变量表(基本类型、对象引用)、操作数栈、方法返回、常量池指针等信息。 由编译器自动分配释放, 内存的分配是连续的。Stack的速度很快,管理很简单,并且每次操作的数据或者指令字节长度是已知的。所以Java 基本数据类型,Java 指令代码,常量都保存在Stack中。

虚拟机只会对栈进行两种操作,以帧为单位的入栈和出栈。Java栈中的每个帧都保存一个方法调用的局部变量、操作数栈、指向常量池的指针等,且每一次方法调用都会创建一个帧,并压栈。

异常

  • 如果一个线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常, 比如递归调用。
  • 如果线程生成数量过多,无法申请足够多的内存时,则会抛出OutOfMemoryError异常。比如tomcat请求数量非常多时,设置最大请求数。

2.3.1 栈帧

栈帧由三部分组成:局部变量区、操作数栈、帧数据区。

2.3.1.1 局部变量区

包含方法的参数和局部变量。

以一个静态方法为例

public class Demo {
     public static int doStaticMethod(int i, long l, float f, Object o, byte b) {
         return 0;
     }
 }

编译之后的具备变量表字节码如下:

LOCALVARIABLEiIL0L10
LOCALVARIABLElJL0L11
LOCALVARIABLEfFL0L13
LOCALVARIABLEoLjava/lang/Object;L0L14
LOCALVARIABLEbBL0L15
MAXSTACK=1    //该方法操作栈的最大深度
MAXLOCALS=6  //确定了该方法所需要分配的最大局部变量表的容量

可以认为Java栈帧里的局部变量表有很多的槽位组成,每个槽最大可以容纳32位的数据类型,故方法参数里的int i 参数占据了一个槽位,而long l 参数就占据了两个槽(1和2),Object对象类型的参数其实是一个引用,o相当于一个指针,也就是32位大小。byte类型升为int,也是32位大小。如下:

0 int int i
1 long long l
3 float float f
4 reference Object o
5 int byte b

实例方法的局部变量表和静态方法基本一样,唯一区别就是实例方法在Java栈帧的局部变量表里第一个槽位(0位置)存的是一个this引用(当前对象的引用),后面就和静态方法的一样了。

2.3.1.2 操作数栈

Java没有寄存器,故所有参数传递使用Java栈帧里的操作数栈,操作数栈被组织成一个以字长为单位的数组,它是通过标准的栈操作-入栈和出栈来进行访问,而不是通过索引访问。

看一个例子:

image.png

注意,对于局部变量表的槽位,按照从0开始的顺序,依次是方法参数,之后是方法内的局部变量,局部变量0就是a,1就是b,2就是c…… 编译之后的字节码为:

// access flags 0x9
  public static add(II)I
   L0
    LINENUMBER 18 L0 // 对应源代码第18行,以此类推
    ICONST_0 // 把常量0 push 到Java栈帧的操作数栈里
    ISTORE 2 // 将0从操作数栈pop到局部变量表槽2里(c),完成赋值
   L1
    LINENUMBER 19 L1
    ILOAD 0 // 将局部变量槽位0(a)push 到Java栈帧的操作数栈里
    ILOAD 1 // 把局部变量槽1(b)push到操作数栈 
    IADD // pop出a和b两个变量,求和,把结果push到操作数栈
    ISTORE 2 // 把结果从操作数栈pop到局部变量2(a+b的和给c赋值)
   L2
    LINENUMBER 21 L2
    ILOAD 2 // 局部变量2(c)push 到操作数栈
    IRETURN // 返回结果
   L3
    LOCALVARIABLE a I L0 L3 0
    LOCALVARIABLE b I L0 L3 1
    LOCALVARIABLE c I L1 L3 2
    MAXSTACK = 2
    MAXLOCALS = 3
    

发现,整个计算过程的参数传递和操作数栈密切相关!如图:

image.png

2.3.1.3 栈数据区

存放一些用于支持常量池解析(常量池指针)、正常方法返回以及异常派发机制的信息。即将常量池的符号引用转化为直接地址引用、恢复发起调用的方法的帧进行正常返回,发生异常时转交异常表进行处理。

2.4 本地方法栈

Native Method Stack

访问本地方式时使用到的栈,为本地方法服务, 也就是调用虚拟机使用到的Native方法服务。也会抛出StackOverflowError和OutOfMemoryError异常。

2.5 PC寄存器

每个线程都拥有一个PC寄存器,线程私有的。
PC寄存器的内容总是下一条将被执行指令的"地址",这里的"地址"可以是一个本地指针,也可以是在方法字节码中相对于该方法起始指令的偏移量。如果该线程正在执行一个本地方法,则程序计数器内容为undefined,区域在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域。

2.6 堆与栈

2.6.1 堆与栈里存什么

  • 1)堆中存的是对象。栈中存的是基本数据类型和堆中对象的引用。一个对象的大小是不可估计的,或者说是可以动态变化的,但是在栈中,一个对象只对应了一个4btye的引用。
  • 2)为什么不把基本类型放堆中呢?因为其占用的空间一般是1~8个字节——需要空间比较少,而且因为是基本类型,所以不会出现动态增长的情况——长度固定,因此栈中存储就够了,如果把他存在堆中是没有什么意义的。可以这么说,基本类型和对象的引用都是存放在栈中,而且都是几个字节的一个数,因此在程序运行时,他们的处理方式是统一的。但是基本类型、对象引用和对象本身就有所区别了,因为一个是栈中的数据一个是堆中的数据。最常见的一个问题就是,Java中参数传递时的问题。
  • 3)Java中的参数传递时传值呢?还是传引用?程序运行永远都是在栈中进行的,因而参数传递时,只存在传递基本类型和对象引用的问题。不会直接传对象本身。
int a = 0; //全局初始化区
 
char p1; //全局未初始化区
 
main(){
 
  int b; //栈
 
  char s[] = "abc"; //栈
 
  char p2; //栈
 
  char p3 = "123456"; //123456\0在常量区,p3在栈上。
 
  static int c =0; //全局(静态)初始化区
 
  p1 = (char *)malloc(10); //堆
 
  p2 = (char *)malloc(20); //堆
 
}

2.6.2 堆内存与栈内存的区别

  • 申请和回收方式不同:栈上的空间是自动分配自动回收的,所以栈上的数据的生存周期只是在函数的运行过程中,运行后就释放掉,不可以再访问。而堆上的数据只要程序员不释放空间,就一直可以访问到,不过缺点是一旦忘记释放会造成内存泄露。
  • 碎片问题:对于栈,不会产生不连续的内存块;但是对于堆来说,不断的new、delete势必会产生上面所述的内部碎片和外部碎片。
  • 申请大小的限制:栈是向低地址扩展的数据结构,是一块连续的内存的区域。栈顶的地址和栈的最大容量是系统预先规定好的,如果申请的空间超过栈的剩余空间,就会产生栈溢出;对于堆,是向高地址扩展的数据结构,是不连续的内存区域。堆的大小受限于计算机系统中有效的虚拟内存。由此可见,堆获得的空间比较灵活,也比较大。
  • 申请效率的比较:栈由系统自动分配,速度较快。但程序员是无法控制的;堆:是由new分配的内存,一般速度比较慢,而且容易产生内存碎片,不过用起来最方便。

3.JIT编译器

  1. JIT编译器是JVM的核心。它对于程序性能的影响最大。
  2. CPU只能执行汇编代码或者二进制代码,所有程序都需要被翻译成它们,然后才能被CPU执行。
  3. C++以及Fortran这类编译型语言都会通过一个静态的编译器将程序编译成CPU相关的二进制代码。
  4. PHP以及Perl这列语言则是解释型语言,只需要安装正确的解释器,它们就能运行在任何CPU之上。当程序被执行的时候,程序代码会被逐行解释并执行。
  5. 编译型语言的优缺点:
    • 速度快:因为在编译的时候它们能够获取到更多的有关程序结构的信息,从而有机会对它们进行优化。
    • 适用性差:它们编译得到的二进制代码往往是CPU相关的,在需要适配多种CPU时,可能需要编译多次。
  6. 解释型语言的优缺点:
    • 适应性强:只需要安装正确的解释器,程序在任何CPU上都能够被运行
    • 速度慢:因为程序需要被逐行翻译,导致速度变慢。同时因为缺乏编译这一过程,执行代码不能通过编译器进行优化。
  7. Java的做法是找到编译型语言和解释性语言的一个中间点:
    • Java代码会被编译:被编译成Java字节码,而不是针对某种CPU的二进制代码。
    • Java代码会被解释:Java字节码需要被java程序解释执行,此时,Java字节码被翻译成CPU相关的二进制代码。
    • JIT编译器的作用:在程序运行期间,将Java字节码编译成平台相关的二进制代码。正因为此编译行为发生在程序运行期间,所以该编译器被称为Just-In-Time编译器。

HotSpot 编译

HotSpot VM名字也体现了JIT编译器的工作方式。在VM开始运行一段代码时,并不会立即对它们进行编译。在程序中,总有那么一些“热点”区域,该区域的代码会被反复的执行。而JIT编译器只会编译这些“热点”区域的代码。

这么做的原因在于:
* 编译那些只会被运行一次的代码性价比太低,直接解释执行Java字节码反而更快。
* JVM在执行这些代码的时候,能获取到这些代码的信息,一段代码被执行的次数越多,JVM也对它们愈加熟悉,因此能够在对它们进行编译的时候做出一些优化。
在HotSpot VM中内嵌有两个JIT编译器,分别为Client Compiler和Server Compiler,但大多数情况下我们简称为C1编译器和C2编译器。开发人员可以通过如下命令显式指定Java虚拟机在运行时到底使用哪一种即时编译器,如下所示:

-client:指定Java虚拟机运行在Client模式下,并使用C1编译器;
-server:指定Java虚拟机运行在Server模式下,并使用C2编译器。

除了可以显式指定Java虚拟机在运行时到底使用哪一种即时编译器外,默认情况下HotSpot VM则会根据操作系统版本与物理机器的硬件性能自动选择运行在哪一种模式下,以及采用哪一种即时编译器。简单来说,C1编译器会对字节码进行简单和可靠的优化,以达到更快的编译速度;而C2编译器会启动一些编译耗时更长的优化,以获取更好的编译质量。不过在Java7版本之后,一旦开发人员在程序中显式指定命令“-server”时,缺省将会开启分层编译(Tiered Compilation)策略,由C1编译器和C2编译器相互协作共同来执行编译任务。不过在早期版本中,开发人员则只能够通过命令“-XX:+TieredCompilation”手动开启分层编译策略。

总结

  1. Java综合了编译型语言和解释性语言的优势。
  2. Java会将类文件编译成为Java字节码,然后Java字节码会被JIT编译器选择性地编译成为CPU能够直接运行的二进制代码。
  3. 将Java字节码编译成二进制代码后,性能会被大幅度提升。

4.类加载机制

Java虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这就是虚拟机的加载机制。

类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括了:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(using)、和卸载(Unloading)七个阶段。其中验证、准备和解析三个部分统称为连接(Linking),这七个阶段的发生顺序如下图所示:

image.png

如上图所示,加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的,类的加载过程必须按照这个顺序来按部就班地开始,而解析阶段则不一定,它在某些情况下可以在初始化阶段后再开始。

类的生命周期的每一个阶段通常都是互相交叉混合式进行的,通常会在一个阶段执行的过程中调用或激活另外一个阶段。

4.1 类加载的时机

主动引用

一个类被主动引用之后会触发初始化过程(加载,验证,准备需再此之前开始)

  • 1)遇到new、get static、put static或invoke static这4条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。生成这4条指令最常见的Java代码场景是:使用new关键字实例化对象时、读取或者设置一个类的静态字段(被final修饰、已在编译器把结果放入常量池的静态字段除外)时、以及调用一个类的静态方法的时候。
  • 2)使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。
  • 3)当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要触发父类的初始化。
  • 4)当虚拟机启动时,用户需要指定一个执行的主类(包含main()方法的类),虚拟机会先初始化这个类。
  • 5)当使用jdk7+的动态语言支持时,如果java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发器 初始化。

被动引用

一个类如果是被动引用的话,该类不会触发初始化过程

  • 1)通过子类引用父类的静态字段,不会导致子类初始化。对于静态字段,只有直接定义该字段的类才会被初始化,因此当我们通过子类来引用父类中定义的静态字段时,只会触发父类的初始化,而不会触发子类的初始化。
  • 2)通过数组定义来引用类,不会触发此类的初始化。
  • 3)常量在编译阶段会存入调用类的常量池中,本质上没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化。

4.2 类加载过程

1、加载

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

  • 1)通过一个类的全限定名称来获取定义此类的二进制字节流。
  • 2)将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
  • 3)在java堆中生成一个代表这个类的java.lang.Class对象,作为方法区这些数据的访问入口。
    相对于类加载过程的其他阶段,加载阶段是开发期相对来说可控性比较强,该阶段既可以使用系统提供的类加载器完成,也可以由用户自定义的类加载器来完成,开发人员可以通过定义自己的类加载器去控制字节流的获取方式。

2、验证

验证的目的是为了确保Class文件中的字节流包含的信息符合当前虚拟机的要求,而且不会危害虚拟机自身的安全。不同的虚拟机对类验证的实现可能会有所不同,但大致都会完成以下四个阶段的验证:文件格式的验证、元数据的验证、字节码验证和符号引用验证。

  • 1)文件格式的验证:验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理,该验证的主要目的是保证输入的字节流能正确地解析并存储
    于方法区之内。经过该阶段的验证后,字节流才会进入内存的方法区中进行存储,后面的三个验证都是基于方法区的存储结构进行的。
  • 2)元数据验证:对类的元数据信息进行语义校验(其实就是对类中的各数据类型进行语法校验),保证不存在不符合Java语法规范的元数据信息。
  • 3)字节码验证:该阶段验证的主要工作是进行数据流和控制流分析,对类的方法体进行校验分析,以保证被校验的类的方法在运行时不会做出危害虚拟机安全的行为。
  • 4)符号引用验证:这是最后一个阶段的验证,它发生在虚拟机将符号引用转化为直接引用的时候(解析阶段中发生该转化,后面会有讲解),主要是对类自身以外的信息(常量池中的各种符号引用)进行匹配性的校验。

3、准备

准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中进行分配。

注:

  • 1)这时候进行内存分配的仅包括类变量(static),而不包括实例变量,实例变量会在对象实例化时随着对象一块分配在Java堆中。
  • 2)这里所设置的初始值通常情况下是数据类型默认的零值(如0、0L、、false等),而不是被在Java代码中被显式地赋予的值。

4、解析

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程

符号引用(Symbolic Reference):

符号引用以一组符号来描述所引用的目标,符号引用可以是任何形式的字面量,符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经在内存中。

直接引用(Direct Reference):

直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是与虚拟机实现的内存布局相关的,同一个符号引用在不同的虚拟机实例上翻译出来的直接引用一般都不相同,如果有了直接引用,那引用的目标必定已经在内存中存在。

  • 1)类或接口的解析:判断所要转化成的直接引用是对数组类型,还是普通的对象类型的引用,从而进行不同的解析。
  • 2)字段解析:对字段进行解析时,会先在本类中查找是否包含有简单名称和字段描述符都与目标相匹配的字段,如果有,则查找结束;如果没有,则会按照继承关系从上往下递归搜索该类所实现的各个接口和它们的父接口,还没有,则按照继承关系从上往下递归搜索其父类,直至查找结束。
  • 3)类方法解析:对类方法的解析与对字段解析的搜索步骤差不多,只是多了判断该方法所处的是类还是接口的步骤,而且对类方法的匹配搜索,是先搜索父类,再搜索接口。
  • 4)接口方法解析:与类方法解析步骤类似,只是接口不会有父类,因此,只递归向上搜索父接口就行了。

5、初始化

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

初始化阶段是执行类构造器<clinit>方法的过程。

  • 1)<clinit>方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生的,编译器收集的顺序由语句在源文件中出现的顺序所决定。
  • 2)<clinit>方法与类的构造函数不同,它不需要显式地调用父类构造器,虚拟机会保证在子类的<clinit>方法执行之前,父类的<clinit>方法已经执行完毕,因此在虚拟机中第一个执行的<clinit>方法的类一定是java.lang.Object。
  • 3)由于父类的<clinit>方法先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作。
  • 4)<clinit>方法对于类或者接口来说并不是必需的,如果一个类中没有静态语句块也没有对变量的赋值操作,那么编译器可以不为这个类生成<clinit>方法。
  • 5)接口中可能会有变量赋值操作,因此接口也会生成<clinit>方法。但是接口与类不同,执行接口的<clinit>方法不需要先执行父接口的<clinit>方法。只有当父接口中定义的变量被使用时,父接口才会被初始化。另外,接口的实现类在初始化时也不会执行接口的<clinit>方法。
  • 6)虚拟机会保证一个类的<clinit>方法在多线程环境中被正确地加锁和同步。如果有多个线程去同时初始化一个类,那么只会有一个线程去执行这个类的<clinit>方法,其它线程都需要阻塞等待,直到活动线程执行<clinit>方法完毕。如果在一个类的<clinit>方法中有耗时很长的操作,那么就可能造成多个进程阻塞。

5.垃圾回收

5.1 按代实现垃圾回收

image.png

新生代(Young generation):

绝大多数最新被创建的对象会被分配到这里,由于大部分对象在创建后会很快变得不可到达,所以很多对象被创建在新生代,然后消失。对象从这个区域消失的过程我们称之为”minor GC“。

新生代中存在一个Eden区和两个Survivor区。新对象会首先分配在 Eden 中(如果新对象过大,会直接分配在老年代中)。在GC中,Eden 中的对象会被移动到survivor中,直至对象满足一定的年纪(定义为熬过GC的次数),会被移动到老年代(具体细节将在下边垃圾收集算法中讨论)。

可以设置新生代和老年代的相对大小。这种方式的优点是新生代大小会随着整个堆大小动态扩展。参数 -XX:NewRatio 设置老年代与新生代的比例。例如 -XX:NewRatio=8 指定老年代/新生代为8/1. 老年代占堆大小的 7/8 ,新生代占 1/8 .(默认即使1/8)
例如:-XX:NewSize=64m -XX:MaxNewSize=1024m -XX:NewRatio=8

老年代(Old generation):

对象没有变得不可达,并且从新生代中存活下来,会被拷贝到这里。其所占用的空间要比新生代多。也正由于其相对较大的空间,发生在老年代上的GC要比新生代少得多。对象从老年代中消失的过程,可以称之为”major GC“(或者”full GC“)

永久代(permanent generation):

像一些类的层级信息,方法数据和方法信息(如字节码,栈和变量大小),运行时常量池(jdk7之后移出永久代),已确定的符号引用和虚方法表等等,它们几乎都是静态的并且很少被卸载和回收,在JDK8之前的HotSpot虚拟机中,类的这些“永久的”数据存放在一个叫做永久代的区域。永久代一段连续的内存空间,我们在JVM启动之前可以通过设置-XX:MaxPermSize的值来控制永久代的大小。但是jdk8之后取消了永久代,这些元数据被移到了一个与堆不相连的本地内存区域 。

5.2 怎样判断对象是否已经死亡

引用计数收集算法

用计数是垃圾收集器中的早期策略。在这种方法中,堆中每个对象(不是引用)都有一个引用计数。当一个对象被创建时,且将该对象分配给一个变量,该变量计数设置为1。当任何其它变量被赋值为这个对象的引用时,计数加1(a = b,则b引用的对象+1),但当一个对象的某个引用超过了生命周期或者被设置为一个新值时,对象的引用计数减1。任何引用计数为0的对象可以被当作垃圾收集。当一个对象被垃圾收集时,它引用的任何对象计数减1。

  • 优点:引用计数收集器可以很快的执行,交织在程序运行中。对程序不被长时间打断的实时环境比较有利。
  • 缺点: 无法检测出循环引用。如父对象有一个对子对象的引用,子对象反过来引用父对象。这样,他们的引用计数永远不可能为0.

可达性分析算法

通过一系列称为”GC Roots”的对象作为起点,从这些节点开始向下搜索,搜索所有走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连时(从GC Roots到此对象不可达),则证明此对象是不可用的。
可作为GC Roots的对象包括:

  • 虚拟机栈中所引用的对象(本地变量表)
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈中JNI引用的对象(Native对象)

5.3 java中的引用

强引用(Strong Reference):

在代码中普遍存在的,类似”Object obj = new Object”这类引用,只要强引用还在,垃圾收集器永远不会回收掉被引用的对象

软引用(Sofe Reference):

有用但并非必须的对象,可用SoftReference类来实现软引用,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行二次回收。如果这次回收还没有足够的内存,才会抛出内存异常异常。

弱引用(Weak Reference):

被弱引用关联的对象只能生存到下一次垃圾收集发生之前,JDK提供了WeakReference类来实现弱引用。

虚引用(Phantom Reference):

也称为幽灵引用或幻影引用,是最弱的一种引用关系,JDK提供了PhantomReference类来实现虚引用。

5.4 finalize方法什么作用

对于一个对象来说,在被判断没有 GCroots 与其相关联时,被第一次标记,然后判断该对象是否应该执行finalize方法(判断依据:如果对象的finalize方法被复写,并且没有执行过,则可以被执行)。如果允许执行那么这个对象将会被放到一个叫F-Query的队列中,等待被执行。(注意:由于finalize的优先级比较低,所以该对象的的finalize方法不一定被执行,即使被执行了,也不保证finalize方法一定会执行完)

5.5 垃圾收集算法

标记-清除算法:

标记-清除算法采用从根集合进行扫描,对存活的对象进行标记,标记完毕后,再扫描整个空间中未被标记的对象,进行回收。标记-清除算法不需要进行对象的移动,并且仅对不存活的对象进行处理,在存活对象比较多的情况下极为高效,但由于标记-清除算法直接回收不存活的对象,因此会造成内存碎片。

复制算法:

这种收集算法将堆栈分为两个域,常称为半空间。每次仅使用一半的空间,JVM生成的新对象则放在另一半空间中。GC运行时,它把可到达对象复制到另一半空间,从而压缩了堆栈。这种方法适用于短生存期的对象,持续复制长生存期的对象则导致效率降低。并且对于指定大小堆来说,需要两倍大小的内存,因为任何时候都只使用其中的一半。

标记整理算法:

标记-整理算法采用标记-清除算法一样的方式进行对象的标记,但在清除时不同,在回收不存活的对象占用的空间后,会将所有的存活对象往一端空闲空间移动,并更新对应的指针。标记-整理算法是在标记-清除算法的基础上,又进行了对象的移动,因此成本更高,但是却解决了内存碎片的问题。

分代收集算法:

在上边三种收集思想中加入了分代的思想。

5.6 Hotspot实现垃圾回收细节

一致性:

在可达性分析期间整个系统看起来就像被冻结在某个时间点上,不可以出现分析过程中对象引用关系还在不断变化的情况。

一致性要求导致GC进行时必须停顿所有Java执行线程。(Stop The World)即使在号称不会发生停顿的CMS收集器中,枚举根节点时也是必须停顿的。

HotSpot使用的是准确式GC,当执行系统停顿下来后,并不需要一个不漏地检查完所有执行上下文和全局的引用位置,这是通过一组称为OopMap的数据结构来达到的。

安全点(Safe Point):

程序只有在到达安全点时才能暂停。安全点的选定标准是“是否具有让程序长时间执行的特征”。“长时间执行”的最明显特征就是指令序列的复用,如方法调用、循环跳转等,具有这些功能的指令才会产生安全点。

让程序暂停的两种方式:

* 抢先式中断(Preemptive Suspension):在GC发生时,主动中断所有线程,不需要线程执行的代码主动配合。如果发现有线程中断的地方不在安全点上,就恢复线程让它跑到安全点上。(不推荐)
* 主动式中断(Voluntary Suspension):设一个标志,各个线程主动去轮询这个标志,遇到中断则暂停。轮询地方与安全点重合。

5.7 垃圾收集器

HotSpot中几种常见的垃圾收集器:

image.png

5.7.1 Serial收集器

Serial收集器是最基本、发展历史最悠久的收集器,曾经(在JDK 1.3.1之前)是虚拟机新生代收集的唯一选择。

image.png

特性:

这个收集器是一个单线程的收集器,但它的“单线程”的意义并不仅仅说明它只会使用一个CPU或一条收集线程去完成垃圾收集工作,更重要的是在它进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束。Stop The World

应用场景:

Serial收集器是虚拟机运行在Client模式下的默认新生代收集器。

优势:

简单而高效(与其他收集器的单线程比),对于限定单个CPU的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。

5.7.2 ParNew收集器

image.png

特性:

ParNew收集器其实就是Serial收集器的多线程版本,除了使用多条线程进行垃圾收集之外,其余行为包括Serial收集器可用的所有控制参数、收集算法、Stop The World、对象分配规则、回收策略等都与Serial收集器完全一样,在实现上,这两种收集器也共用了相当多的代码。

应用场景:

ParNew收集器是许多运行在Server模式下的虚拟机中首选的新生代收集器。有一个很重要的原因是除了Serial收集器外,目前只有它能与CMS收集器配合工作。

Serial收集器 VS ParNew收集器:

ParNew收集器在单CPU的环境中绝对不会有比Serial收集器更好的效果,甚至由于存在线程交互的开销,该收集器在通过超线程技术实现的两个CPU的环境中都不能百分之百地保证可以超越Serial收集器。然而,随着可以使用的CPU的数量的增加,它对于GC时系统资源的有效利用还是很有好处的。

5.7.3 Parallel Scavenge收集器

特性:

Parallel Scavenge收集器是一个新生代收集器,它也是使用复制算法的收集器,又是并行的多线程收集器。

应用场景:

停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验,而高吞吐量则可以高效率地利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。

对比分析:

Parallel Scavenge收集器 VS CMS等收集器:

Parallel Scavenge收集器的特点是它的关注点与其他收集器不同,CMS等收集器的关 注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量(Throughput)。

由于与吞吐量关系密切,Parallel Scavenge收集器也经常称为“吞吐量优先”收集器。

Parallel Scavenge收集器 VS ParNew收集器:

Parallel Scavenge收集器与ParNew收集器的一个重要区别是它具有自适应调节策略。

GC自适应的调节策略:

Parallel Scavenge收集器有一个参数-XX:+UseAdaptiveSizePolicy。当这个参数打开之后,就不需要手工指定新生代的大小、Eden与Survivor区的比例、晋升老年代对象年龄等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量,这种调节方式称为GC自适应的调节策略(GC Ergonomics)。

5.7.4 Serial Old收集器

image.png

特性:

Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器,使用标记-整理算法。

应用场景:

  • Client模式:Serial Old收集器的主要意义也是在于给Client模式下的虚拟机使用。
  • Server模式:如果在Server模式下,那么它主要还有两大用途:一种用途是在JDK 1.5以及之前的版本中与Parallel Scavenge收集器搭配使用,另一种用途就是作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Failure时使用。

5.7.5 Parallel Old收集器

image.png

特性:

Parallel Old是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法。

应用场景:

在注重吞吐量以及CPU资源敏感的场合,都可以优先考虑Parallel Scavenge加Parallel Old收集器。

这个收集器是在JDK 1.6中才开始提供的,在此之前,新生代的Parallel Scavenge收集器一直处于比较尴尬的状态。原因是,如果新生代选择了Parallel Scavenge收集器,老年代除了Serial Old收集器外别无选择(Parallel Scavenge收集器无法与CMS收集器配合工作)。由于老年代Serial Old收集器在服务端应用性能上的“拖累”,使用了Parallel Scavenge收集器也未必能在整体应用上获得吞吐量最大化的效果,由于单线程的老年代收集中无法充分利用服务器多CPU的处理能力,在老年代很大而且硬件比较高级的环境中,这种组合的吞吐量甚至还不一定有ParNew加CMS的组合“给力”。直到Parallel Old收集器出现后,“吞吐量优先”收集器终于有了比较名副其实的应用组合。

5.7.6 CMS收集器

image.png

特性:

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java应用集中在互联网站或者B/S系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。CMS收集器就非常符合这类应用的需求。
CMS收集器是基于“标记—清除”算法实现的,它的运作过程相对于前面几种收集器来说更复杂一些,整个过程分为4个步骤:

  • 初始标记(CMS initial mark):初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快,需要“Stop The World”。
  • 并发标记(CMS concurrent mark):并发标记阶段就是进行GC Roots Tracing的过程。
  • 重新标记(CMS remark):重新标记阶段是为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短,仍然需要“Stop The World”。
  • 并发清除(CMS concurrent sweep):并发清除阶段会清除对象。

由于整个过程中耗时最长的并发标记和并发清除过程收集器线程都可以与用户线程一起工作,所以,从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。

优点:

CMS是一款优秀的收集器,它的主要优点在名字上已经体现出来了:并发收集、低停顿。

缺点:

  • 1)CMS收集器对CPU资源非常敏感

    其实,面向并发设计的程序都对CPU资源比较敏感。在并发阶段,它虽然不会导致用户线程停顿,但是会因为占用了一部分线程(或者说CPU资源)而导致应用程序变慢,总吞吐量会降低。

    CMS默认启动的回收线程数是(CPU数量+3)/ 4,也就是当CPU在4个以上时,并发回收时垃圾收集线程不少于25%的CPU资源,并且随着CPU数量的增加而下降。但是当CPU不足4个(譬如2个)时,CMS对用户程序的影响就可能变得很大。

  • 2)CMS收集器无法处理浮动垃圾

    CMS收集器无法处理浮动垃圾,可能出现“Concurrent Mode Failure”失败而导致另一次Full GC的产生。

    由于CMS并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS无法在当次收集中处理掉它们,只好留待下一次GC时再清理掉。这一部分垃圾就称为“浮动垃圾”。

    也是由于在垃圾收集阶段用户线程还需要运行,那也就还需要预留有足够的内存空间给用户线程使用,因此CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,需要预留一部分空间提供并发收集时的程序运作使用。要是CMS运行期间预留的内存无法满足程序需要,就会出现一次“Concurrent Mode Failure”失败,这时虚拟机将启动后备预案:临时启用Serial Old收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了。

  • 3)CMS收集器会产生大量空间碎片

    CMS是一款基于“标记—清除”算法实现的收集器,这意味着收集结束时会有大量空间碎片产生。空间碎片过多时,将会给大对象分配带来很大麻烦,往往会出现老年代还有很大空间剩余,但是无法找到足够大的连续空间来分配当前对象,不得不提前触发一次Full GC。

5.7.7 G1收集器

image.png

特性:

G1(Garbage-First)是一款面向服务端应用的垃圾收集器。HotSpot开发团队赋予它的使命是未来可以替换掉JDK 1.5中发布的CMS收集器。与其他GC收集器相比,G1具备如下特点。

  • 1)并行与并发

    G1能充分利用多CPU、多核环境下的硬件优势,使用多个CPU来缩短Stop-The-World停顿的时间,部分其他收集器原本需要停顿Java线程执行的GC动作,G1收集器仍然可以通过并发的方式让Java程序继续执行。

  • 2)分代收集

    与其他收集器一样,分代概念在G1中依然得以保留。虽然G1可以不需要其他收集器配合就能独立管理整个GC堆,但它能够采用不同的方式去处理新创建的对象和已经存活了一段时间、熬过多次GC的旧对象以获取更好的收集效果。

  • 3)空间整合

    与CMS的“标记—清理”算法不同,G1从整体来看是基于“标记—整理”算法实现的收集器,从局部(两个Region之间)上来看是基于“复制”算法实现的,但无论如何,这两种算法都意味着G1运作期间不会产生内存空间碎片,收集后能提供规整的可用内存。这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次GC。

  • 4)可预测的停顿

    这是G1相对于CMS的另一大优势,降低停顿时间是G1和CMS共同的关注点,但G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒。

在G1之前的其他收集器进行收集的范围都是整个新生代或者老年代,而G1不再是这样。使用G1收集器时,Java堆的内存布局就与其他收集器有很大差别,它将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分Region(不需要连续)的集合。

G1收集器之所以能建立可预测的停顿时间模型,是因为它可以有计划地避免在整个Java堆中进行全区域的垃圾收集。G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region(这也就是Garbage-First名称的来由)。这种使用Region划分内存空间以及有优先级的区域回收方式,保证了G1收集器在有限的时间内可以获取尽可能高的收集效率。

执行过程:

G1收集器的运作大致可划分为以下几个步骤:

  • 1)初始标记(Initial Marking):初始标记阶段仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可用的Region中创建新对象,这阶段需要停顿线程,但耗时很短。
  • 2)并发标记(Concurrent Marking):并发标记阶段是从GC Root开始对堆中对象进行可达性分析,找出存活的对象,这阶段耗时较长,但可与用户程序并发执行。
  • 3)最终标记(Final Marking):最终标记阶段是为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程Remembered Set Logs里面,最终标记阶段需要把Remembered Set Logs的数据合并到Remembered Set中,这阶段需要停顿线程,但是可并行执行。
  • 4)筛选回收(Live Data Counting and Evacuation):筛选回收阶段首先对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间来制定回收计划,这个阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分Region,时间是用户可控制的,而且停顿用户线程将大幅提高收集效率。
何时会抛出OutOfMemoryException,并不是内存被耗空的时候才抛出
    * JVM98%的时间都花费在内存回收
    * 每次回收的内存小于2%

6.JVM参数

6.1 典型配置

/usr/local/jdk/bin/java 
-Dresin.home=/usr/local/resin 
-server 
-Xms1800M 
-Xmx1800M 
-Xmn300M 
-Xss512K 
-XX:PermSize=300M 
-XX:MaxPermSize=300M 
-XX:SurvivorRatio=8 
-XX:MaxTenuringThreshold=5 
-XX:GCTimeRatio=19 
-Xnoclassgc 
-XX:+DisableExplicitGC 
-XX:+UseParNewGC 
-XX:+UseConcMarkSweepGC
-XX:+UseCMSCompactAtFullCollection
-XX:CMSFullGCsBeforeCompaction=0
-XX:-CMSParallelRemarkEnabled
-XX:CMSInitiatingOccupancyFraction=70
-XX:SoftRefLRUPolicyMSPerMB=0 
-XX:+PrintClassHistogram 
-XX:+PrintGCDetails 
-XX:+PrintGCTimeStamps 
-XX:+PrintHeapAtGC 
-Xloggc:log/gc.log

6.1.1 堆大小设置

JVM 中最大堆大小有三方面限制:相关操作系统的数据模型(32-bt还是64-bit)限制;系统的可用虚拟内存限制;系统的可用物理内存限制。32位系统下,一般限制在1.5G~2G;64为操作系统对内存无限制。

java -Xmx3550m -Xms3550m -Xmn2g-Xss128k
-Xmx3550m:设置JVM最大可用内存为3550M。
-Xms3550m:设置JVM促使内存为3550m。此值可以设置与-Xmx相同,以避免每次垃圾回收完成后JVM重新分配内存。
-Xmn2g:设置年轻代大小为2G。整个堆大小=年轻代大小 + 年老代大小 + 持久代大小。持久代一般固定大小为64m,所以增大年轻代后,将会减小年老代大小。此值对系统性能影响较大,Sun官方推荐配置为整个堆的3/8。
-Xss128k:设置每个线程的堆栈大小。JDK5.0以后每个线程堆栈大小为1M,以前每个线程堆栈大小为256K。更具应用的线程所需内存大小进行调整。在相同物理内存下,减小这个值能生成更多的线程。但是操作系统对一个进程内的线程数还是有限制的,不能无限生成,经验值在3000~5000左右。
java -Xmx3550m -Xms3550m -Xss128k -XX:NewRatio=4 -XX:SurvivorRatio=4 -XX:MaxPermSize=16m -XX:MaxTenuringThreshold=0
-XX:NewRatio=4:设置年轻代(包括Eden和两个Survivor区)与年老代的比值(除去持久代)。设置为4,则年轻代与年老代所占比值为1:4,年轻代占整个堆栈的1/5
-XX:SurvivorRatio=4:设置年轻代中Eden区与Survivor区的大小比值。设置为4,则两个Survivor区与一个Eden区的比值为2:4,一个Survivor区占整个年轻代的1/6
-XX:MaxPermSize=16m:设置持久代大小为16m。
-XX:MaxTenuringThreshold=0:设置垃圾最大年龄。如果设置为0的话,则年轻代对象不经过Survivor区,直接进入年老代。对于年老代比较多的应用,可以提高效率。如果将此值设置为一个较大值,则年轻代对象会在Survivor区进行多次复制,这样可以增加对象再年轻代的存活时间,增加在年轻代即被回收的概论。

6.1.2 回收器选择

JVM给了三种选择:串行收集器、并行收集器、并发收集器,但是串行收集器只适用于小数据量的情况,所以这里的选择主要针对并行收集器和并发收集器。默认情况下,JDK5.0以前都是使用串行收集器,如果想使用其他收集器需要在启动时加入相应参数。JDK5.0以后,JVM会根据当前系统配置进行判断。

吞吐量优先的并行收集器

如上文所述,并行收集器主要以到达一定的吞吐量为目标,适用于科学技术和后台处理等。

java -Xmx3800m -Xms3800m -Xmn2g -Xss128k -XX:+UseParallelGC -XX:ParallelGCThreads=20
-XX:+UseParallelGC:选择垃圾收集器为并行收集器。此配置仅对年轻代有效。即上述配置下,年轻代使用并发收集,而年老代仍旧使用串行收集。
-XX:ParallelGCThreads=20:配置并行收集器的线程数,即:同时多少个线程一起进行垃圾回收。此值最好配置与处理器数目相等。
java -Xmx3550m -Xms3550m -Xmn2g -Xss128k -XX:+UseParallelGC -XX:ParallelGCThreads=20 -XX:+UseParallelOldGC
-XX:+UseParallelOldGC:配置年老代垃圾收集方式为并行收集。JDK6.0支持对年老代并行收集。
java -Xmx3550m -Xms3550m -Xmn2g -Xss128k -XX:+UseParallelGC -XX:MaxGCPauseMillis=100
-XX:MaxGCPauseMillis=100:设置每次年轻代垃圾回收的最长时间,如果无法满足此时间,JVM会自动调整年轻代大小,以满足此值。
java -Xmx3550m -Xms3550m -Xmn2g -Xss128k -XX:+UseParallelGC -XX:MaxGCPauseMillis=100-XX:+UseAdaptiveSizePolicy
-XX:+UseAdaptiveSizePolicy:设置此选项后,并行收集器会自动选择年轻代区大小和相应的Survivor区比例,以达到目标系统规定的最低相应时间或者收集频率等,此值建议使用并行收集器时,一直打开。

响应时间优先的并发收集器

如上文所述,并发收集器主要是保证系统的响应时间,减少垃圾收集时的停顿时间。适用于应用服务器、电信领域等。

java -Xmx3550m -Xms3550m -Xmn2g -Xss128k -XX:ParallelGCThreads=20 -XX:+UseConcMarkSweepGC -XX:+UseParNewGC
-XX:+UseConcMarkSweepGC:设置年老代为并发收集。测试中配置这个以后,-XX:NewRatio=4的配置失效了,原因不明。所以,此时年轻代大小最好用-Xmn设置。
-XX:+UseParNewGC:设置年轻代为并行收集。可与CMS收集同时使用。JDK5.0以上,JVM会根据系统配置自行设置,所以无需再设置此值。
java -Xmx3550m -Xms3550m -Xmn2g -Xss128k -XX:+UseConcMarkSweepGC -XX:CMSFullGCsBeforeCompaction=5 -XX:+UseCMSCompactAtFullCollection
-XX:CMSFullGCsBeforeCompaction:由于并发收集器不对内存空间进行压缩、整理,所以运行一段时间以后会产生“碎片”,使得运行效率降低。此值设置运行多少次GC以后对内存空间进行压缩、整理。
-XX:+UseCMSCompactAtFullCollection:打开对年老代的压缩。可能会影响性能,但是可以消除碎片

6.1.3辅助信息

JVM提供了大量命令行参数,打印信息,供调试使用。主要有以下一些:

-XX:+PrintGC
输出形式:[GC 118250K->113543K(130112K), 0.0094143 secs]
                [Full GC 121376K->10414K(130112K), 0.0650971 secs]
-XX:+PrintGCDetails
输出形式:[GC [DefNew: 8614K->781K(9088K), 0.0123035 secs] 118250K->113543K(130112K), 0.0124633 secs]
                [GC [DefNew: 8614K->8614K(9088K), 0.0000665 secs][Tenured: 112761K->10414K(121024K), 0.0433488 secs] 121376K->10414K(130112K), 0.0436268 secs]
-XX:+PrintGCTimeStamps -XX:+PrintGC:PrintGCTimeStamps可与上面两个混合使用
输出形式:11.851: [GC 98328K->93620K(130112K), 0.0082960 secs]
-XX:+PrintGCApplicationConcurrentTime:打印每次垃圾回收前,程序未中断的执行时间。可与上面混合使用
输出形式:Application time: 0.5291524 seconds
-XX:+PrintGCApplicationStoppedTime:打印垃圾回收期间程序暂停的时间。可与上面混合使用
输出形式:Total time for which application threads were stopped: 0.0468229 seconds
-XX:PrintHeapAtGC:打印GC前后的详细堆栈信息
-Xloggc:filename:与上面几个配合使用,把相关日志信息记录到文件以便分析。

6.2 参数详细说明

参数名称 含义 默认值 说明
-Xms 初始堆大小 物理内存的1/64(<1GB) 默认(MinHeapFreeRatio参数可以调整)空余堆内存小于40%时,JVM就会增大堆直到-Xmx的最大限制.
-Xmx 最大堆大小 物理内存的1/4(<1GB) 默认(MaxHeapFreeRatio参数可以调整)空余堆内存大于70%时,JVM会减少堆直到-Xms的最小限制
-Xmn 年轻代大小(1.4or lator) 注意:此处的大小是(eden+ 2 survivor space).与jmap -heap中显示的New gen是不同的。整个堆大小=年轻代大小 + 年老代大小 + 持久代大小.增大年轻代后,将会减小年老代大小.此值对系统性能影响较大,Sun官方推荐配置为整个堆的3/8
-XX:NewSize 设置年轻代大小(for 1.3/1.4)
-XX:MaxNewSize 年轻代最大值(for 1.3/1.4)
-XX:PermSize 设置持久代(perm gen)初始值 物理内存的1/64
-XX:MaxPermSize 设置持久代最大值 物理内存的1/4
-Xss 每个线程的堆栈大小 JDK5.0以后每个线程堆栈大小为1M,以前每个线程堆栈大小为256K.更具应用的线程所需内存大小进行调整.在相同物理内存下,减小这个值能生成更多的线程.但是操作系统对一个进程内的线程数还是有限制的,不能无限生成,经验值在3000~5000左右.一般小的应用, 如果栈不是很深, 应该是128k够用的.大的应用建议使用256k。这个选项对性能影响比较大,需要严格的测试。
-XX:ThreadStackSize Thread Stack Size (0 means use default stack size) [Sparc: 512; Solaris x86: 320 (was 256 prior in 5.0 and earlier); Sparc 64 bit: 1024; Linux amd64: 1024 (was 0 in 5.0 and earlier); all others 0.]
-XX:NewRatio 年轻代(包括Eden和两个Survivor区)与年老代的比值(除去持久代) -XX:NewRatio=4表示年轻代与年老代所占比值为1:4,年轻代占整个堆栈的1/5Xms=Xmx并且设置了Xmn的情况下,该参数不需要进行设置。
-XX:SurvivorRatio Eden区与Survivor区的大小比值 设置为8,则两个Survivor区与一个Eden区的比值为2:8,一个Survivor区占整个年轻代的1/10
-XX:LargePageSizeInBytes 内存页的大小不可设置过大, 会影响Perm的大小 =128m
-XX:+UseFastAccessorMethods 原始类型的快速优化
-XX:+DisableExplicitGC 关闭System.gc() 这个参数需要严格的测试
-XX:MaxTenuringThreshold 垃圾最大年龄 如果设置为0的话,则年轻代对象不经过Survivor区,直接进入年老代.对于年老代比较多的应用,可以提高效率.如果将此值设置为一个较大值,则年轻代对象会在Survivor区进行多次复制,这样可以增加对象再年轻代的存活时间,增加在年轻代即被回收的概率.该参数只有在串行GC时才有效.
-XX:+AggressiveOpts 加快编译
-XX:+UseBiasedLocking 锁机制的性能改善
-Xnoclassgc 禁用垃圾回收
-XX:SoftRefLRUPolicyMSPerMB 每兆堆空闲空间中SoftReference的存活时间 1s softly reachable objects will remain alive for some amount of time after the last time they were referenced. The default value is one second of lifetime per free megabyte in the heap
-XX:PretenureSizeThreshold 对象超过多大是直接在旧生代分配 0 单位字节 新生代采用Parallel Scavenge GC时无效,另一种直接在旧生代分配的情况是大的数组对象,且数组中无外部引用对象.
-XX:TLABWasteTargetPercent TLAB占eden区的百分比 1%
-XX:+CollectGen0First FullGC时是否先YGC false

并行收集器相关参数

参数名称 含义 默认值 说明
-XX:+UseParallelGC Full GC采用parallel MSC(此项待验证) 选择垃圾收集器为并行收集器.此配置仅对年轻代有效.即上述配置下,年轻代使用并发收集,而年老代仍旧使用串行收集.(此项待验证)
-XX:+UseParNewGC 设置年轻代为并行收集 可与CMS收集同时使用,JDK5.0以上,JVM会根据系统配置自行设置,所以无需再设置此值
-XX:ParallelGCThreads 并行收集器的线程数 此值最好配置与处理器数目相等 同样适用于CMS
-XX:+UseParallelOldGC 年老代垃圾收集方式为并行收集(Parallel Compacting) 这个是JAVA 6出现的参数选项
-XX:MaxGCPauseMillis 每次年轻代垃圾回收的最长时间(最大暂停时间) 如果无法满足此时间,JVM会自动调整年轻代大小,以满足此值.
-XX:+UseAdaptiveSizePolicy 自动选择年轻代区大小和相应的Survivor区比例 设置此选项后,并行收集器会自动选择年轻代区大小和相应的Survivor区比例,以达到目标系统规定的最低相应时间或者收集频率等,此值建议使用并行收集器时,一直打开.
-XX:GCTimeRatio 设置垃圾回收时间占程序运行时间的百分比 公式为1/(1+n)
-XX:+ScavengeBeforeFullGC Full GC前调用YGC true Do young generation GC prior to a full GC. (Introduced in 1.4.1.)

CMS相关参数

参数名称 含义 默认值 说明
-XX:+UseConcMarkSweepGC 使用CMS内存收集 测试中配置这个以后,-XX:NewRatio=4的配置失效了,原因不明.所以,此时年轻代大小最好用-Xmn设置
-XX:+AggressiveHeap 试图是使用大量的物理内存长时间大内存。使用的优化,能检查计算资源(内存, 处理器数量)至少需要256MB内存,大量的CPU/内存, (在1.4.1在4CPU的机器上已经显示有提升)
-XX:CMSFullGCsBeforeCompaction 多少次后进行内存压缩 由于并发收集器不对内存空间进行压缩,整理,所以运行一段时间以后会产生"碎片",使得运行效率降低.此值设置运行多少次GC以后对内存空间进行压缩,整理.
-XX:+CMSParallelRemarkEnabled 降低标记停顿
-XX+UseCMSCompactAtFullCollection 在FULL GC的时候, 对年老代的压缩 CMS是不会移动内存的, 因此, 这个非常容易产生碎片, 导致内存不够用, 因此, 内存的压缩这个时候就会被启用。 增加这个参数是个好习惯。可能会影响性能,但是可以消除碎片
-XX:+UseCMSInitiatingOccupancyOnly 使用手动定义初始化定义开始CMS收集 禁止hostspot自行触发CMS GC
-XX:CMSInitiatingOccupancyFraction=70 使用cms作为垃圾回收,使用70%后开始CMS收集 92 为了保证不出现promotion failed(见下面介绍)错误,该值的设置需要满足以下公式CMSInitiatingOccupancyFraction计算公式
-XX:CMSInitiatingPermOccupancyFraction 设置Perm Gen使用到达多少比率时触发 92
-XX:+CMSIncrementalMode 设置为增量模式 用于单CPU情况
-XX:+CMSClassUnloadingEnabled 相对于并行收集器,CMS收集器默认不会对永久代进行垃圾回收。如果希望对永久代进行垃圾回收,可用设置标志-XX:+CMSClassUnloadingEnabled。在早期JVM版本中,要求设置额外的标志-XX:+CMSPermGenSweepingEnabled。注意,即使没有设置这个标志,一旦永久代耗尽空间也会尝试进行垃圾回收,但是收集不会是并行的,而再一次进行Full GC。

辅助信息

参数名称 含义 默认值 说明
-XX:+PrintGC 输出形式:[GC 118250K->113543K(130112K), 0.0094143 secs][Full GC 121376K->10414K(130112K), 0.0650971 secs]
-XX:+PrintGCDetails 输出形式:[GC [DefNew: 8614K->781K(9088K), 0.0123035 secs]118250K->113543K(130112K), 0.0124633 secs][GC [DefNew: 8614K->8614K(9088K), 0.0000665 secs][Tenured: 112761K->10414K(121024K), 0.0433488 secs]121376K->10414K(130112K), 0.0436268 secs]
-XX:+PrintGCTimeStamps
-XX:+PrintGC:PrintGCTimeStamps 可与-XX:+PrintGC -XX:+PrintGCDetails混合使用。输出形式:11.851: [GC 98328K->93620K(130112K), 0.0082960 secs]
-XX:+PrintGCApplicationStoppedTime 打印垃圾回收期间程序暂停的时间.可与上面混合使用 输出形式:Total time for which application threads were stopped: 0.0468229 seconds
-XX:+PrintGCApplicationConcurrentTime 打印每次垃圾回收前,程序未中断的执行时间.可与上面混合使用 输出形式:Application time: 0.5291524 seconds
-XX:+PrintHeapAtGC 打印GC前后的详细堆栈信息
-Xloggc:filename 把相关日志信息记录到文件以便分析.与上面几个配合使用
-XX:+PrintClassHistogram garbage collects before printing the histogram.
-XX:+PrintTLAB 查看TLAB空间的使用情况
XX:+PrintTenuringDistribution 查看每次minor GC后新的存活周期的阈值 Desired survivor size 1048576 bytes, new threshold 7 (max 15)

new threshold 7即标识新的存活周期的阈值为7。

7.JVM性能调优

7.1 堆设置调优

年轻代大小选择

  • 响应时间优先的应用:尽可能设大,直到接近系统的最低响应时间限制(根据实际情况选择)。在此种情况下,年轻代收集发生的频率也是最小的。同时,减少到达年老代的对象。
  • 吞吐量优先的应用:尽可能的设置大,可能到达Gbit的程度。因为对响应时间没有要求,垃圾收集可以并行进行,一般适合8CPU以上的应用。
    通过-XX:NewRadio设置新生代与老年代的大小比例,通过-Xmn来设置新生代的大小。

年老代大小选择

  • 响应时间优先的应用:年老代使用并发收集器,所以其大小需要小心设置,一般要考虑并发会话率和会话持续时间等一些参数。如果堆设置小了,可以会造成内存碎片、高回收频率以及应用暂停而使用传统的标记清除方式;如果堆大了,则需要较长的收集时间。最优化的方案,一般需要参考以下数据获得:

    • 并发垃圾收集信息
    • 持久代并发收集次数
    • 传统GC信息
    • 花在年轻代和年老代回收上的时间比例
  • 吞吐量优先的应用:一般吞吐量优先的应用都有一个很大的年轻代和一个较小的年老代。原因是,这样可以尽可能回收掉大部分短期对象,减少中期的对象,而年老代尽存放长期存活对象。

  • 较小堆引起的碎片问题
    因为年老代的并发收集器使用标记、清除算法,所以不会对堆进行压缩。当收集器回收时,他会把相邻的空间进行合并,这样可以分配给较大的对象。但是,当堆空间较小时,运行一段时间以后,就会出现“碎片”,如果并发收集器找不到足够的空间,那么并发收集器将会停止,然后使用传统的标记、清除方式进行回收。如果出现“碎片”,可能需要进行如下配置:

    • -XX:+UseCMSCompactAtFullCollection:使用并发收集器时,开启对年老代的压缩。
    • -XX:CMSFullGCsBeforeCompaction=0:上面配置开启的情况下,这里设置多少次Full GC后,对年老代进行压缩

7.2 GC策略调优

  1. 能够忍受full gc的停顿?

    是:选择throughput

    否:如果堆较小,使用CMS或者G1;如果堆较大,选择G1

  2. 使用默认配置能达到期望目标吗?

    首先尽量使用默认配置,因为垃圾收集技术在不断发展成熟,自动优化大多数的效果是最好的。如果默认配置没有达到期望,请确认垃圾收集是否是性能瓶颈。如负荷较高的应用,如果垃圾收集上的时间不超过3%,即使进行垃圾回收调优效果也不大。

  3. 应用的停顿时间和预期的目标接近吗?

    是:调整最大停顿时间设定可能是需要做的

    否:需要进行其他调整

    如果停顿时间太长,但是吞吐量正常,可以尝试减少新生代大小(如果是full gc,则减少老年代大小),这样停顿时间变短,但是单次时间变长

  4. GC停顿很短了,但是吞吐量上不去?

    增大堆的大小,但是单次停顿时间会加长

  5. 使用并发收集器,发生了由并发模式失败引发的full gc?

    如果CPU资源充足,可以增加并发GC的线程数数

  6. 使用并发收集器,发生由晋升失败引起的full gc?

    如果是CMS,意味着发生了碎片化,这种情况下:使用跟大的堆;尽早启动后台回收
    如果堆空间较大,可以选择使用G1

7.3 JIT调优

  1. 一般只需要选择是使用客户端版或者服务器版的JIT编译器即可。
  2. 客户端版的JIT编译器使用:-client指定,服务器版的使用:-server。
  3. 选择哪种类型一般和硬件的配置相关,当然随着硬件的发展,也没有一个确定的标准哪种硬件适合哪种配置。
  4. 两种JIT编译器的区别:
    • Client版对于代码的编译早于Server版,也意味着代码的执行速度在程序执行早期Client版更快。
    • Server版对代码的编译会稍晚一些,这是为了获取到程序本身的更多信息,以便编译得到优化程度更高的代码。因为运行在Server上的程序通常都会持续很久。
  5. Tiered编译的原理:
    • JVM启动之初使用Client版JIT编译器
    • 当HotSpot形成之后使用Server版JIT编译器再次编译
  6. 在Java 8中,默认使用Tiered编译方式。

不过在Java7版本之后,一旦开发人员在程序中显式指定命令“-server”时,缺省将会开启分层编译(Tiered Compilation)策略,由client编译器和server编译器相互协作共同来执行编译任务。不过在早期版本中,开发人员则只能够通过命令“-XX:+TieredCompilation”手动开启分层编译策略。

  • -Xint:完全采用解释器模式执行程序;
  • -Xcomp:完全采用即时编译器模式执行程序;
  • -Xmixed:采用解释器+即时编译器的混合模式共同执行程序。

启动优化

Application -client -server -XX:+TieredCompilation 类数量
HelloWorld 0.08s 0.08s 0.08s Few
NetBeans 2.83s 3.92s 3.07s ~10000
HelloWorld 51.5s 54.0s 52.0s ~20000

总结

  1. 当程序的启动速度越快越好时,使用Client版的JIT编译器更好。
  2. 就启动速度而言,Tiered编译方式的性能和只使用Client的方式十分接近,因为Tiered编译本质上也会在启动是使用Client JIT编译器。

批处理优化

对于批处理任务,任务量的大小是决定运行时间和使用哪种编译策略的最重要因素:

Number of Tasks -client -server -XX:+TieredCompilation
1 0.142s 0.176s 0.165s
10 0.211s 0.348s 0.226s
100 0.454s 0.674s 0.472s
1000 2.556s 2.158s 1.910s
10000 23.78s 14.03s 13.56s

可以发现几个结论:

  1. 当任务数量小的时候,使用Client或者Tiered方式的性能类似,而当任务数量大的时候,使用Tiered会获得最好的性能,因为它综合使用了Client和Server两种编译器,在程序运行之初,使用Client JIT编译器得到一部分编译过的代码,在程序“热点”逐渐形成之后,使用Server JIT编译器得到高度优化的编译后代码。
  2. Tiered编译方式的性能总是好于单独使用Server JIT编译器。
  3. Tiered编译方式在任务量不大的时候,和单独使用Client JIT编译器的性能相当。

总结

  1. 当一段批处理程序需要被执行时,使用不同的策略进行测试,使用速度最快的那一种。
  2. 对于批处理程序,考虑使用Tiered编译方式作为默认选项。

长时间运行应用的优化

对于长时间运行的应用,比如Servlet程序等,一般会使用吞吐量来测试它们的性能。 以下的一组数据表示了一个典型的数据获取程序在使用不同“热身时间”以及不同编译策略时,对吞吐量(OPS)的影响(执行时间为60s):

Warm-up Period -client -server -XX:+TieredCompilation
0s 15.87 23.72 24.23
60s 16.00 23.73 24.26
300s 16.85 24.42 24.43

即使当“热身时间”为0秒,因为执行时间为60秒,所以编译器也有机会在次期间做出优化。

从上面的数据可以发现的几个结论:

  1. 对于典型的数据获取程序,编译器对代码编译和优化发生的十分迅速,当“热身时间”显著增加时,如从60秒增加到300秒,最后得到的OPS差异并不明显。
  2. -server JIT编译器和Tiered编译的性能显著优于-client JIT编译器。

总结

  1. 对于长时间运行的应用,总是使用-server JIT编译器或者Tiered编译策略。

代码缓存调优(Tuning the Code Cache)

当JVM对代码进行编译后,被编译的代码以汇编指令的形式存在于代码缓存中(Code Cache),显然这个缓存区域也是有大小限制的,当此区域被填满了之后,编译器就不能够再编译其他Java字节码了。

Code Cache的最大空间可以通过:-XX:ReservedCodeCacheSize=N来进行设置。

7.4 JVM线程调优

调节线程栈大小

通过设置-Xss参数,在内存比较稀缺的机器上,可以减少线程栈的大小,在32位的JVM上,可以减少线程栈大小,可以稍稍增加堆的可用内存。每个线程默认会开启1M的堆栈,用于存放栈帧、调用参数、局部变量等,对大多数应用而言这个默认值太了,一般256K就足用。

偏向锁

使用-XX:UseBiasedLocking选项来禁用偏向锁,偏向锁默认开启。偏向锁可以提高缓存命中率,但是因为偏向锁也需要一些簿记信息,有时候性能会更糟,比如使用了某些线程池,同步资源或代码一直都是多线程访问的,那么消除偏向锁这一步骤对你来说就是多余的。

自旋锁

使用-XX:UseSpinning参数可以设置自旋锁是否开启,但是Java7以后自旋锁无法禁用。

线程优先级

每个线程都可以由开发人员指定优先级,不过真正执行时的优先级还取决于操作系统为每个线程计算的当前优先级。开发人员不能依赖线程优先级来影响其性能,如果要提高某些任务的优先级,就必须使用应用层逻辑来划分优先级,可以通过将任务指派给不同线程池并修改哪些池子大小来实现。

总结

理解线程如何运作,可以获得很大的性能优势,不过就线程的性能而言,没有太多可以调优的:可以修改的JVM标识相当少,而且效果不明显。

7.5 典型案例

$JAVA_ARGS
.=
"
-Dresin.home=$SERVER_ROOT
-server
-Xmx3000M
-Xms3000M
-Xmn600M
-XX:PermSize=500M
-XX:MaxPermSize=500M
-Xss256K
-XX:+DisableExplicitGC
-XX:SurvivorRatio=1
-XX:+UseConcMarkSweepGC
-XX:+UseParNewGC
-XX:+CMSParallelRemarkEnabled
-XX:+UseCMSCompactAtFullCollection
-XX:CMSFullGCsBeforeCompaction=0
-XX:+CMSClassUnloadingEnabled
-XX:LargePageSizeInBytes=128M
-XX:+UseFastAccessorMethods
-XX:+UseCMSInitiatingOccupancyOnly
-XX:CMSInitiatingOccupancyFraction=70
-XX:SoftRefLRUPolicyMSPerMB=0
-XX:+PrintClassHistogram
-XX:+PrintGCDetails
-XX:+PrintGCTimeStamps
-XX:+PrintHeapAtGC
-Xloggc:log/gc.log
";

说明:

64位jdk参考设置,年老代涨得很慢,CMS执行频率变小,CMS没有停滞,也不会有promotion failed问题,内存回收得很干净

8.常见问题

8.1 内存泄漏及解决方法

  • 1.系统崩溃前的一些现象:

    • 每次垃圾回收的时间越来越长,由之前的10ms延长到50ms左右,FullGC的时间也有之前的0.5s延长到4、5s
    • FullGC的次数越来越多,最频繁时隔不到1分钟就进行一次FullGC
    • 年老代的内存越来越大并且每次FullGC后年老代没有内存被释放

    之后系统会无法响应新的请求,逐渐到达OutOfMemoryError的临界值。

  • 2.生成堆的dump文件

    通过JMX的MBean生成当前的Heap信息,大小为一个3G(整个堆的大小)的hprof文件,如果没有启动JMX可以通过Java的jmap命令来生成该文件。

  • 3.分析dump文件

    下面要考虑的是如何打开这个3G的堆信息文件,显然一般的Window系统没有这么大的内存,必须借助高配置的Linux。当然我们可以借助X-Window把Linux上的图形导入到Window。
    我们考虑用下面几种工具打开该文件:

    • Visual VM
    • IBM HeapAnalyzer
    • JDK 自带的Hprof工具

    使用这些工具时为了确保加载速度,建议设置最大内存为6G。使用后发现,这些工具都无法直观地观察到内存泄漏,Visual VM虽能观察到对象大小,但看不到调用堆栈;HeapAnalyzer虽然能看到调用堆栈,却无法正确打开一个3G的文件。因此,我们又选用了Eclipse专门的静态内存分析工具:Mat

  • 4.分析内存泄漏

    通过Mat我们能清楚地看到,哪些对象被怀疑为内存泄漏,哪些对象占的空间最大及对象的调用关系。针对本案,在ThreadLocal中有很多的JbpmContext实例,经过调查是JBPM的Context没有关闭所致。

    另,通过Mat或JMX我们还可以分析线程状态,可以观察到线程被阻塞在哪个对象上,从而判断系统的瓶颈。

  • 5.回归问题

    • Q:为什么崩溃前垃圾回收的时间越来越长?
    • A:根据内存模型和垃圾回收算法,垃圾回收分两部分:内存标记、清除(复制),标记部分只要内存大小固定时间是不变的,变的是复制部分,因为每次垃圾回收都有一些回收不掉的内存,所以增加了复制量,导致时间延长。所以,垃圾回收的时间也可以作为判断内存泄漏的依据
    • Q:为什么Full GC的次数越来越多?
    • A:因此内存的积累,逐渐耗尽了年老代的内存,导致新对象分配没有更多的空间,从而导致频繁的垃圾回收
    • Q:为什么年老代占用的内存越来越大?
    • A:因为年轻代的内存无法被回收,越来越多地被Copy到年老代

8.2 年老代堆空间被占满

  • 异常: java.lang.OutOfMemoryError: Java heap space
  • 说明:
image.png
这是最典型的内存泄漏方式,简单说就是所有堆空间都被无法回收的垃圾对象占满,虚拟机无法再在分配新空间。

如上图所示,这是非常典型的内存泄漏的垃圾回收情况图。所有峰值部分都是一次垃圾回收点,所有谷底部分表示是一次垃圾回收后剩余的内存。连接所有谷底的点,可以发现一条由底到高的线,这说明,随时间的推移,系统的堆空间被不断占满,最终会占满整个堆空间。因此可以初步认为系统内部可能有内存泄漏。(上面的图仅供示例,在实际情况下收集数据的时间需要更长,比如几个小时或者几天)
  • 解决:

    这种方式解决起来也比较容易,一般就是根据垃圾回收前后情况对比,同时根据对象引用情况(常见的集合对象引用)分析,基本都可以找到泄漏点。

8.3 持久代被占满

  • 异常:java.lang.OutOfMemoryError: PermGen space

  • 说明:

    Perm空间被占满。无法为新的class分配存储空间而引发的异常。这个异常以前是没有的,但是在Java反射大量使用的今天这个异常比较常见了。主要原因就是大量动态反射生成的类不断被加载,最终导致Perm区被占满。
    更可怕的是,不同的classLoader即便使用了相同的类,但是都会对其进行加载,相当于同一个东西,如果有N个classLoader那么他将会被加载N次。因此,某些情况下,这个问题基本视为无解。当然,存在大量classLoader和大量反射类的情况其实也不多。

  • 解决:

    • 1.-XX:MaxPermSize=16m
    • 2.换用JDK。比如JRocket

8.4 堆栈溢出

  • 异常:java.lang.StackOverflowError
  • 说明:这个就不多说了,一般就是递归没返回,或者循环调用造成

8.5 线程堆栈满

  • 异常:Fatal: Stack size too small

  • 说明:java中一个线程的空间大小是有限制的。JDK5.0以后这个值是1M。与这个线程相关的数据将会保存在其中。但是当线程空间满了以后,将会出现上面异常。

  • 解决:增加线程栈大小。-Xss2m。但这个配置无法解决根本问题,还要看代码部分是否有造成泄漏的部分。

8.6 系统内存被占满

  • 异常:java.lang.OutOfMemoryError: unable to create new native thread

  • 说明:
    这个异常是由于操作系统没有足够的资源来产生这个线程造成的。系统创建线程时,除了要在Java堆中分配内存外,操作系统本身也需要分配资源来创建线程。因此,当线程数量大到一定程度以后,堆中或许还有空间,但是操作系统分配不出资源来了,就出现这个异常了。

    分配给Java虚拟机的内存愈多,系统剩余的资源就越少,因此,当系统内存固定时,分配给Java虚拟机的内存越多,那么,系统总共能够产生的线程也就越少,两者成反比的关系。同时,可以通过修改-Xss来减少分配给单个线程的空间,也可以增加系统总共内生产的线程数。

  • 解决:

    • 1.重新设计系统减少线程数量。
    • 2.线程数量不能减少的情况下,通过-Xss减小单个线程大小。以便能生产更多的线程。

欢迎关注 高广超的简书博客 与 收藏文章 !
欢迎关注 头条号:互联网技术栈

个人介绍:

高广超 :多年一线互联网研发与架构设计经验,擅长设计与落地高可用、高性能互联网架构。目前就职于美团网,负责核心业务研发工作。

本文首发在 高广超的简书博客 转载请注明!

image.png

推荐阅读更多精彩内容