您的位置:首页 > 编程语言 > Java开发

性能优化之 JVM 高级特性

2019-01-17 16:28 232 查看

1、JVM体系结构

线程共享内存

可以被所有线程共享的区域,包括堆区、方法区、运行时常量池。

1.1 堆(Heap)

大多数时候,Java 堆是 Java 虚拟机管理的内存里最大的一块,所有的对象实例和数组都要在堆上分配内存空间,Java 对象可以分为两类,一类是快速创建快速消亡的,另一类是长期使用的。所以针对这种情况大多收集器都是基于分代收集算法进行回收。

Java 的堆可以分为新生代(Young Generation)和老年代(Old Generation),而新生代(Young Generation)又可以分为 Eden Space 空间 (伊甸园区)、From Survivor 空间(From 生存区)、To Survivor 空间(To 生存区)。

Java 堆是一块共享的区域,会出现线程安全的问题,而操作共享区域就需要锁和同步,通过- Xms设置堆的最小值,堆内存越小越容易发生内存不够用的情况而触犯 Full GC(对新生代、老年代、永久代进行垃圾回收)。官方推荐新生代大小占整个堆大小的 3/8,通过- Xmx设置堆的最大值,堆内存超过此值会发抛出 OutOfMemoryError 异常:

1.2 方法区(Method Area)

方法区(Method Area)在 HotSpot 虚拟机上可以看作是永久代(Permanent Generation),对于其他虚拟机(JRockit 、J9 等)来说是不存在永久代的。方法区也是所有线程共享的区域,主要存储被虚拟机加载的类信息、常量、静态变量,堆存储对象数据,方法区存储静态信息。

方法区不像 Java 堆区那样经常发生垃圾回收,但不表示不会发生。永久代的大小跟新生代、老年代比都是很小的,通过设置- XX:MaxPermSize来指定最大内存,方法区需要的内存超过此值会抛出 OutOfMemoryError 异常。

1.3 运行时常量池(Runtime Constant Pool)

Java 通过类加载机制可以把字节码文件中的常量池表加载进运行时常量池,而我们也可使用 String 类的 intern() 方法在运行期将新的常量放入池中,运行时常量池是方法区的一部分,在 JDK1.7 的 HotSpot 中,已经把原本放在方法区的常量池移出来了。

线程私有内存

只允许被所属的线程私自访问的内存区,包括 PC 寄存器、Java 栈和本地方法栈。

1.4 栈(Java Stack)

Java Stack 描述的是 Java 方法执行时的内存模型,每个方法执行时都会创建一个栈帧(Stack Frame),栈帧包含局部变量表(存放编译期间的各种基本数据类型,对象引用等信息)、操作数栈、动态链接、方法出口等数据。

一个线程运行时会分配栈空间,每个线程的栈空间数据是相互隔离的,所以栈是私有的,堆是共享的,一个线程执行多个方法,会入栈出栈多个栈帧(多个方法),栈是先进后出的数据结构,最先入栈的栈帧,最后出栈,可以通过-Xss设置每个线程栈的大小,越小,能创建的线程数就越多,但并不是可以无限的,在一个进程里(JVM 进程)能生成的线程数最多不超过五千

1.5 本地方法栈(Native Stack)

虚拟机栈(Java Stack)为执行 Java 方法(就是字节码)服务,而本地方法栈(Native Stack)则为 Native 方法(比如用 C/C++ 编写的代码)服务,其他方面都很类似。

1.6 PC 寄存器(程序计数器)

JVM 字节码解析器通过改变 PC 寄存器的值来明确下一条需要执行的字节码指令,每个线程都会分配一个独立的 PC 寄存器。

2、JVM 垃圾回收算法

JVM 垃圾收集算法不同虚拟机的具体实现会不一样,这里先讲解几种经典的垃圾收集算法的思想,后面再以使用得最广泛的 HotSpot 虚拟机为例讲解具体的垃圾收集器算法。

2.1 引用计数法

给每个对象维护一个引用计数器,每当被引用一次就加 1,每当引用失效一次就减 1,引用计数器为 0,表明当前对象没有被任何对象引用,则可以当作垃圾回收。但是当 A 对象和 B 对象相互引用对方的时候,大家的计数器值都不为 0,而如果对象 A 和对象 B 都已经不被外部引用,就是说两个无用的对象因为相互引用而无法进行垃圾回收。这就是循环引用的缺陷,故现在 JVM 虚拟机大多不采用这种方式做垃圾回收。

2.2 根搜索算法(Tracing)

复制 (Coping)

标记-清除 (Mark-Sweep)

标记-压缩(Mark-Compact)

分代收集算法(Generational Collection)

根搜索算法从那些被称为 GC Roots 的对象(虚拟机栈中引用的对象、方法区中静态属性引用的对象、方法区中常量引用的对象、本地方法栈 JNI 引用的对象)作为起始节点,从这些节点向下搜索,搜索所形成的路径叫引用链。当一个对象到 GC Roots 的所有对象都没有一条引用链,则说明该对象不可用,所以根搜索算法又叫可达性算法,GC Roots 到该对象不可达则表明该对象不可用,表明该对象可回收。

根搜索算法有四种,其中复制算法应用在新生代。

2.2.1 复制算法

复制算法将内存划分相等的两块,当一块内存用完了,将还存活的对象移动到另一块内存,将之前使用的内存一次清理,新生代的内存空间通常都是所有代里最大的,适用复制算法,实际上垃圾回收时是把 Eden Space 和 From Survivor 上还存活的对象复制到 To Survivor,而不需要按照 1:1 的比例来划分。

通常 Eden Space:From Survivor:To Survivor = 8:1:1,如果出现状况 To Survivor 空间不足以容纳复制过来的还存活的对象,那通过分配担保机制,这些对象可直接进入老年代,然后下一次垃圾回收发生时 From Survivor 和 To Survivor 交换身份,内存直接从 To Survivor 分配,回收到 From Survivor。

优点:没有标记和清除的过程,效率高,没有内存碎片,可以利用 Bump-the-pointer(指针碰撞)技术实现快速内存分配,因为已用和未用的内存各自一边,内存分布规整有序,当新对象分配时就可以通过修改指针偏移量将其分配在第一个空闲的内存位置上,从而快速分配内存,否则只能使用空闲列表(Free List)方式分配内存,如下面要讲的标记-清除(Mark-Sweep)垃圾回收算法。

缺点:开辟专门的空间存放存活对象,占用更多的内存。

2.2.2 标记-清除(Mark-Sweep)

上面的内存图示是为了理解,其实内存是线性的。

从根集合开始扫描,对存活动对象进行标记,然后重新扫描整个内存空间清除未被标记的对象,优点是不需要额外的空间,缺点是重复扫描,性能低,而且产生内存碎片。

2.2.3 标记-压缩(Mark-Compact)

一样是从从根集合开始扫描,对存活动对象进行标记,然后重新扫描整个内存空间,并往一个方向移动存活对象,虽然移动对象的消耗时间,但不产生内存碎片,可以通过 Bump-the-pointer(指针碰撞)快速分配内存。

2.2.4 标记-清除-压缩

是“标记-清除”和“标记-压缩”算法的结合,唯一的不同是要等“标记-清除”多次以后,也就是多次垃圾回收进行以后才进行移动对象(压缩),以避免每次 GC(垃圾回收)后都压缩一次,降低了移动对象的耗时。

JVM 几种经典的垃圾回收算法已经讲完,下面直接进入 HotSpot 虚拟机的讲解。

2.3 HotSpot 内存分配

2.3.1 内存分配实例

public class Earth
{   String name;    // 单位:亿年   int age;   String size;
public Earth(String name, int age, String size)
{        super();
this.name = name;
this.age = age;
this.size = size;
}   Earth e = new Earth("地球", 46, "1.0832×10^12立方千米");
}

当我们去 new 一个对象时,首先会在栈空间为对象的引用 e 分配内存,这是声明 Earth e,但由于 Earth 还是一个空对象,无法使用,不指向任何实体,接着 new Earth 会在堆开辟内存给成员变量 name、age、size,并初始化为各个数据类型的默认值,然后才是初始化为自定义的赋值,接着调用构造函数通过参数列表 (“地球”, 46, “1.0832×10^12 立方千米”) 为成员变量再赋值,最后返回对象的引用(首地址)给变量 e。
Earth e1 = new Earth("地球", 46, "1.0832×10^12立方千米");
Earth e2 = new Earth("地球", 46, "1.0832×10^12立方千米");
Earth e3 = new Earth("地球", 46, "1.0832×10^12立方千米");
就算创建多个对象,在堆中还是一个实例,在栈中 e1、e2、e3 共同指向一个实例,而由于对象创建都是频繁而且短生命周期,故一般对象被分配在堆的新生代的 Eden 区。

而堆空间是非线程安全的,是线程共享的,为对象分配内存时就要保证原子性,防止产生脏数据,这是会消耗性能的。为此 JVM 做了优化,优先为加载完成对类在 TLAB(Thread Local Allocation,本地线程分配缓冲区)中为对象实例分配内存空间。

TLAB 是在堆中 Eden 区里开辟的空间,但却是一块线程私有区域,并不是所有对象都能在这成功分配,但在 TLAB 分配是个优选项,为了优化内存分配,还可以使用 JVM 的堆外内存存放对象实例,堆空间不是唯一 一个可以存放对象实例的地方,当一个对象作用域在方法体内,但随时间推移,一旦其引用被方法体外的成员变量引用时,就发生了逃逸;

反过来,如果作用域还是局限于方法体内,JVM 就可以为对象在栈帧里分配空间,对象分配在方法的栈帧上,随着方法创建而创建,随着方法退出而消亡,无需垃圾回收。

2.3.2 运行时常量池

我们再来看一下跟运行时常量池相关的内存分配的例子(面试常客):

public class StringDemo
{
public static void stingDemo()
{   // 池化的思想,把共享的数据放入字符串池中,
以减少频繁创建销毁对象的开销
// 引用s1被放入栈中,字符串字面量"123"如果
存在于字符串池中,返回其引用给s1,否则,在池中新建字符串
String s1 = "123";  // 池中已经存在"123",
不会再创建,直接拿到引用(地址值)
String s2 = "123";  // 就是new对象的创建过程,不会去池查找,
直接在堆开辟空间存放实例对象,返回对象地址给s3
String s3 = new String("123");// String类本身是被final
修饰的,final修饰会保证s4的值不变,也就是s4=123,
而这个字符串字面量直接可以从池中拿到,不需要创建对象
String s4 = "1" + "23";
String str = "1";
// 由于被final修饰,str的值"1"不能改变,s5的值也不能变,其值是str+"23"创建对象所返回的地址(不是指对象内容
// 123不许变),所以这里会新建一个对象
String s5 = str + "23";        // 两个基本类型用==比较,比较的是两个基本类型的值是否相等
// 一个包装类型和一个基本类型用==比较,比较包装类型的值和基本类型的值是否相等
// 两个包装类型用==比较,比较两个对象返回的地址是否相等       // 两个包装类型用equals比较,比较两个对象是否相等,
// 包括两个对象类的类型是否相同,对象里的值是否完全相同,对象的hashcode是否相同,不比较地址,所以hashcode相同,对象不一定相等,对象相等,hashcode一定相同
// s1==s2:true
System.out.println("s1==s2:" + (s1 == s2)); // s1==s3:false
System.out.println("s1==s3:" + (s1 == s3)); // s1.equals(s3):true
// Sting类已经帮我们覆写了Object的equals方法,使得equals的比较正常,
// 如果没覆写,底层还是用==做的比较,我们自定义对象要用equals比较的前提是记得覆写equals方法
System.out.println("s1.equals(s3):" + (s1.equals(s3)));
// s1=s4:true
System.out.println("s1=s4:" + (s1 == s4));
}
public static void main(String[] args)

{
StringDemo.stingDemo();
}
}
public class TestInteger
{
public static void main(String[] args)

{
int i1 = 129;
// java在编译的时候,会变成 Integer i2 = Integer.valueOf(129)
Integer i2 = 129;
// int和integer(无论是否new出来的)比,都为true,因为会把Integer自动拆箱为int再去比较
System.out.println("int i1 = 129 == Integer i2= 129 :" + (i1 == i2));
Integer i3 = new Integer(129);
// Integer与new Integer不会相等。不会经历拆箱过程,i3与i2指向是两个不同的地址,为false
System.out.println("Integer i2= 129 == Integer i3 = new Integer(129) :" + (i2 == i3));
Integer i4 = new Integer(129);

// 两个都是new出来的,开辟不同的内存空间,都为false
System.out.println("Integer i3 = new Integer(129) == Integer i4 =new Integer(129) :" + (i3 == i4));
Integer i5 = 129;
/*
* Integer i2 = 129 会被编译成Integer.valueOf(129) 而valueOf的源码如下 public static
* Integer valueOf(int i) {         * assert IntegerCache.high >= 127;
* 如果值在-128到127之间,直接从缓存取值,不需要重新创建
* if (i >= IntegerCache.low && i <=IntegerCache.high)
* return IntegerCache.cache[i + (-IntegerCache.low)];
* return new Integer(i);         * }
*/
// 两个都是非new出来的Integer,如果数在-128到127之间,则是true,否则为false,超过范围不会在常量池里取,会重新创建两个Integer,==比较Integer的值,即是地址,肯定false
System.out.println("Integer i2= 129 == Integer i5 = 129 :" + (i2 == i5));       i2 = 127;       i5 = 127;        // 在-128到127之间,从常量池取同一个引用给Integer,肯定是true
System.out.println("Integer i2= 127 == Integer i5 = 127 :" + (i2 == i5));
}

}

HotSpot 内存管理里,新生代 80% 的对象生命周期较短,GC 频率高,适合采用效率较高的复制算法,经历了多次 GC 仍然存活的对象或者一些超过设定值大小的对象会分配到老年代,老年代 GC 频率较低,适合使用“标记 - 清除 - 压缩”这种综合的算法。

回收算法还有回收方式的不同,串行回收(Serial),是指就算有多个 CPU 核,某一时刻也只能有一个 CPU 核可以执行垃圾回收线程,此时用户线程会被挂起处于暂停状态,只有等回收线程执行完毕,用户线程才能继续执行,也就是会产生所谓的 Stop-the-world,JVM 短时间内卡顿不会工作。

并行回收是指某一时刻可以由多个 CPU 核同时执行各自的垃圾回收线程,不过一样会出现 Stop-the-world,而并发回收是指用户线程和垃圾回收线程交替执行,大大缩短 Stop-the-world 的停顿时间。现在大型项目动辄使用上百 G 的内存,内存越大,回收时间越久,而 Stop-the-world 的卡顿时间也会越久,目前还没有算法可以做到零停顿的。

算法的思想讲完了,下面就讲垃圾收集算法的具体实现垃圾收集器。

上面红色横线的地方就是安全点,用户线程执行时,要到达了安全点,才能暂停,让回收线程执行;当触发回收线程执行时,不会直接中断用户线程,而是设置一个标志位,让用户线程轮询。发现为中断标志时就运行到最近的安全点再将自己挂起让 CPU 执行回收线程,但如果此时用户线程处于 Waiting 或者 Blocked 状态,无法轮询标志位,就会造成回收线程长时间无法运行的情况。

为此引入了安全区,安全区就是引用关系不会发生变化的代码,在这段代码的任何地方发起 GC 都是安全的。所以当用户线程进入到安全区,恰好这时回收线程要执行就会直接中断用户线程,用户线程离开安全区时,只需检查回收线程是否已经完成,如果完成则可以离开,否则等待直到 GC 完毕。

3、HotSpot 垃圾回收器

3.1 新生代可用的垃圾回收器

Serial Coping(串行复制),Parallel Scavenge(并行复制),ParNew(并发复制)这三种回收器都是基于复制算法,复制 young eden 和 young from 中还存活的对象到 young to,或者根据设定对象的大小和 GC 次数直接晋升到 old,清空 young eden 和 young from 中的垃圾,下一次 GC 发生交换 young from 和 young to,只可使用于新生代,是在 young eden 内存空间不足以分配给对象时触发 Minor GC(新生代垃圾回收)。

3.2 老年代可用垃圾回收器

  • Serial Old (串行标记-清理-压缩)

  • Parallel Old(并行标记-压缩)

  • CMS Concurrent Mark-Sweep(并发标记清除)

3.3 垃圾收集器的组合

  • Serial Coping(串行复制)

适合客户端工作,不适合在服务器运行,针对单 CPU,小新生代,不太在乎暂停时间的应用,可通过- XX:+UseSerialGC手动指定新生代使用 Serial Coping(串行复制)收集器,老年代使用 Serial Old (串行标记 - 清理 - 压缩)收集器执行内存回收。

  • ParNew(并发复制)

是 Serial Coping(串行复制)的多线程版本,在多 CPU 核情况下可以提高收集能力,但如果是单 CPU 条件下,还要来回切换任务,不一定比 Serial Coping(串行复制)收集能力强,通过- XX:+UseParNewGC手动指定新生代使用 ParNew(并发复制)收集器,老年代使用 Serial Old (串行标记 - 清理 - 压缩)收集器执行内存回收。

  • Parallel Scavenge(并行复制)

跟 ParNew(并发复制)相比更注重于吞吐量而不是低延迟,如果吞吐量优先,必然会降低 GC 的频次,也就造成 GC 回收垃圾量更多、时间更长。如果低延迟优先,为了降低每次的暂停时间,就得高频的回收,这频繁的回收又会导致吞吐量的下降,所以吐吞量和低延迟是对矛盾体,适合多 CPU、高 IO 密集操作、高计算消耗的应用,通过XX:+UseParallelGC手动指定新生代使用 Parallel Scavenge(并行复制)收集器,老年代使用 Serial Old (串行标记 - 清理 - 压缩)收集器执行内存回收。

  • Serial Old (串行标记 - 清理 - 压缩)

单线程串行回收,停顿时间长,可以使用

- XX:+PrintGCApplicationStoppedTime

查看暂停时间,适合客户端使用,不会产生内存碎片

  • Parallel Old(并行标记 - 压缩)

根据 GC 线程数划分若干区域(Region),并行做标记,重新扫描,定位到需要压缩的 Region,统计 Region 里所有存活对象的下次要移动的目的地地址,然后并行的往一端压缩,不产生内存碎片,整理后的空闲区域是连续的,通过- XX:+UseParallelOldGC手动指定新生代使用 Parallel Scavenge(并行复制)收集器,老年代使用 Parallel Old(并行标记 - 压缩)收集器执行内存回收。

  • CMS Concurrent Mark-Sweep(并发标记清除)

第一阶段是初始标记,需要 Stop-the-world,这阶段标记出那些与根对象集合所连接的不可达的对象,标记完就会被暂停的应用线程;

第二阶段是并发标记,这阶段是应用线程和回收线程交替执行,把第一步标记为不可达的对象标记为垃圾对象,由于是交替进行,一开始被标记为垃圾的对象,后面应用线程可能更改对象的引用关系导致标记错误;

所以第三阶段重新标记,需要 Stop-the-world,修正上个阶段由于对象引用或者新对象创建导致的标记错误,这阶段只有回收线程执行,确保修正的正确性。

经过三个阶段的标记,第四个阶段会并发的清除无有的对象释放内存,这阶段是应用线程和回收线程交替执行,如果用户应用线程产生了新的垃圾(浮动垃圾),只能留到下次 GC 进行回收,极端情况如果产生的新的垃圾,而老年代的预留空间又不够,就会产生 Concurrent Mode Failure,这个时候只能通过后备的 Serial Old (串行标记 - 清理 - 压缩)来进行垃圾回收。

又因为 CMS 并没有用到压缩算法,回收后会产生内存碎片,为新对象分配内存无法使用 Bump-the-pointer(指针碰撞)技术实现快速内存分配,只能使用空闲列表(Free List :JVM 会维护一张可用内存地址的列表,当需要分配空间,就从列表搜索一段和对象大小一样的连续内存块用于存放要生成的对象实例)方式分配内存。

但也可以通过- XX:CMSFullGCsBeforeCompaction,用于指定经过多少次 Full GC 后对内存碎片整理压缩,由于内存碎片不是并发执行,会带来更长的停顿时间,通过- XX:+UseConcMarkSweepGC设定新生代使用 ParNew(并发复制)收集器,老年代使用 CMS Concurrent Mark-Sweep(并发标记清除)收集器执行内存回收,当出现浮动垃圾导致 Concurrent Mode Failure 或者新对象分配内存失败时,通过备用组合新生代使用 ParNew(并发复制)收集器,老年代使用 Serial Old (串行标记 - 清理 - 压缩)收集器执行内存回收,适用于要求暂停时间短,追求快速响应的应用,如互联网应用。

JVM回收需要注意的点:

  • 在执行 Minor GC 的时候,JVM 会检查老年代中最大连续可用空间是否大于了当前新生代所有对象的总大小,如果大于,则直接执行 Minor GC;
    如果小于了,JVM 会检查是否开启了空间分配担保机制;如果开启了,则 JVM 会检查老年代中最大连续可用空间是否大于了历次晋升到老年代中的平均大小;
    如果大于则会执行 Minor GC,如果小于则执行改为执行 Full GC,如果没有开启则直接改为执行 Full GC。

  • 当老年代(Major GC)和永久代发生 GC 时,除了 CMS 外都会触发 Full GC,Full GC 就是先按新生代 GC 方式进行 Minor GC,再按照老年代的配置进行 Major GC,包含对老年代和永久代进行 GC,若 JVM 估计 Minor GC 会产生晋升失败,则会采用 Major GC 的配置进行 Full GC。
  • 如果 Minor GC 执行失败则会执行 Full GC。
  • 吞吐量:应用运行时间/总时间,暂停时间:每次 GC 造成的暂停
  • 分区分代增量式式收集器:G1(Garbage-First)收集器
    传统的分代收集也提供了并发收集,但最致命的是分代收集把整个堆空间划分成固定间隔的内存块,每次收集都很容易触发 Full GC 从而扫描整个堆空间,这会拖慢应用,而且要对整个堆空间都做内存碎片整理会很麻烦。

而增量式的收集方式是一种新的收集思想,增量收集把堆空间划分成一系列的小内存块(内存块大小可配置),使用时只使用部分内存块,等这部分内存块空间不足时,再把存活对象移动到未被使用过的内存块,避免整个堆用完了再 Full GC,可以一边使用内存一边收集垃圾。

G1 收集器将整个 Java 堆区分成约 2048 大小相同的 Region 块(分新生 Region 块、幸存 Region 块、老年 Region 块),Region 块大小在 1MB 到 32MB 之间,每个对象会被分配到 Region 块里,既可以被块内对象引用也可以被块外对象引用,在判断对象是否存活时,为了避免全堆扫描或者遗漏,是通过 Remembered Set 来检查 Reference 引用的对象是否存在不同的 Region 块中的。G1 在收集垃圾时,会对各个 Region 块的回收价值和成本做排序,根据用户配置的期望停顿时间来进行回收。

G1 收集器与 CMS 收集器执行过程类似。初始标记阶段,Stop-the-World,标记 GC Roots 可直接访问到的对象,为下一个阶段并发标记时,和应用线程交替执行时,有正确可有的 Region 来分配新建对象,并发标记阶段识别上个阶段标记对象的下层对象的活跃状态,找出存活的对象,也就是标记 GC Roots 可达对象;

最终标记阶段,Stop-the-World,修正上次线程交替执行产生的变动;

清除复制阶段,Stop-the-World,这阶段并不是最终标记执行完了就一定执行,毕竟是要 Stop-the-World,为了达到准实时(可配置在 M 毫秒内最多只占用 N 毫秒的时间进行垃圾回收)会根据用户配置的 GC 时间去决定是否做清除。

还有,因为清除复制阶段使用的是复制算法,每次清理都必须保证”to space” 空间是足够的(将存活的对象复制到未使用的 Region 块),所以只有已用空间达到了(1-h)*堆大小(h 是 G1 定义的一个堆空间的百分比阈值,是个固定值)才执行清除,把存活的对象往一个方向移动到”to space” 并整理内存,不会产生内存碎片。

接着把”Eden space” “from space” 的垃圾对象清理,根据维护的优先列表,优先回收价值最大的 Region,通过五个阶段完成垃圾收集,可以通过设定 - XX:UseG1GC 在整个 Java 堆使用 G1 进行垃圾回收,G1 适合高吞吐、低延时、大堆空间的应用。

【文章福利】

小编推荐一个Java交流群:937053620,群内提供设计模式、spring/mybatis源码分析、高并发与分布式、微服务、性能优化,面试题整理合集等免费资料!

内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签:  Java JVM 性能优化