本篇为学习JAVA虚拟机的第十篇文章,本章对内存分配和垃圾回收的细节再次详细说明一下,并且说明一下逃逸分析/栈上分配以及TLAB两种方式的概念和原理。

1. 对象优先在Eden分配

前面文章曾介绍HotSpot虚拟机新生代内存布局及算法:

(1)、将新生代内存分为一块较大的Eden空间和两块较小的Survivor空间;

(2)、每次使用Eden和其中一块Survivor

(3)、当回收时,将Eden和使用中的Survivor中还存活的对象一次性复制到另外一块Survivor

(4)、而后清理掉Eden和使用过的Survivor空间;

(5)、后面就使用Eden和复制到的那一块Survivor空间,重复步骤3;

默认Eden:Survivor=8:1,即每次可以使用90%的空间,只有一块Survivor的空间被浪费;

大多数情况下,对象在新生代Eden区中分配;

Eden区没有足够空间进行分配时,JVM将发起一次Minor GC(新生代GC);

Minor GC时,如果发现存活的对象无法全部放入Survivor空间,只好通过分配担保机制提前转移到老年代。

2. 大对象直接进入老年代

大对象指需要大量连续内存空间的Java对象,如,很长的字符串、数组;

经常出现大对象容易导致内存还有不少空间就提前触发GC,以获取足够的连续空间来存放它们,所以应该尽量避免使用创建大对象;

-XX:PretenureSizeThreshold

可以设置这个阈值,大于这个参数值的对象直接在老年代分配;

默认为0(无效),且只对SerailParNew两款收集器有效;

如果需要使用该参数,可考虑ParNew+CMS组合。

3. 长期存活的对象将进入老年代

JVM给每个对象定义一个对象年龄计数器,其计算流程如下:

Eden中分配的对象,经Minor GC后还存活,就复制移动到Survivor区,年龄为1;

而后每经一次Minor GC后还存活,在Survivor区复制移动一次,年龄就增加1岁;

如果年龄达到一定程度,就晋升到老年代中;

-XX:MaxTenuringThreshold

设置新生代对象晋升老年代的年龄阈值,默认为15;

4. 动态对象年龄判定

JVM为更好适应不同程序,不是永远要求等到MaxTenuringThreshold中设置的年龄;

如果在Survivor空间中相同年龄的所有对象大小总和大于Survivor空间的一半,大于或等于该年龄的对象就可以直接进入老年代

5. 空间分配担保

在前面曾简单介绍过分配担保:

Survivor空间不够用时,需要依赖其他内存(老年代)进行分配担保(Handle Promotion);

分配担保的流程如下:

在发生Minor GC前,JVM先检查老年代最大可用的连续空间是否大于新生所有对象空间;

如果大于,那可以确保Minor GC是安全的;

如果不大于,则JVM查看HandlePromotionFailure值是否允许担保失败;

如果允许,就继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小;

如果大于,将尝试进行一次Minor GC,但这是有风险的;

如果小于或HandlePromotionFailure值不允许冒险,那这些也要改为进行一次Full GC

尝试Minor GC的风险–担保失败:

因为尝试Minor GC前面,无法知道存活的对象大小,所以使用历次晋升到老年代对象的平均大小作为经验值;

假如尝试的Minor GC最终存活的对象远远高于经验值的话,会导致担保失败(Handle Promotion Failure);

失败后只有重新发起一次Full GC,这绕了一个大圈,代价较高;

但一般还是要开启HandlePromotionFailure,避免Full GC过于频繁,而且担保失败概率还是比较低的;

JDK6-u24后,JVM代码中已经不再使用HandlePromotionFailure参数了;

规则变为:

⭐⭐⭐只要老年代最大可用的连续空间大于新生所有对象空间或历次晋升到老年代对象的平均大小,就会进行Minor GC;否则进行Full GC

⭐⭐⭐即老年代最大可用的连续空间小于新生所有对象空间时,不再检查HandelPromotionFailure,而直接检查历次晋升到老年代对象的平均大小;

6. 逃逸分析

般认为new出来的对象都是被分配在堆上,但是这个结论不是那么的绝对,通过对Java对象分配的过程分析,可以知道有两个地方会导致Java中new出来的对象并不一定分配在所认为的堆上。这两个点分别是Java中的逃逸分析和TLABThread Local Allocation Buffer)。

6.1 什么是栈上分配?

栈上分配主要是指在Java程序的执行过程中,在方法体中声明的变量以及创建的对象,将直接从该线程所使用的栈中分配空间

一般而言,创建对象都是从堆中来分配的,这里是指在栈上来分配空间给新创建的对象。

6.2 什么是逃逸?

逃逸是指在某个方法之内创建的对象,除了在方法体之内被引用之外,还在方法体之外被其它变量引用到;

这样带来的后果是在该方法执行完毕之后,该方法中创建的对象将无法被GC回收,由于其被其它变量引用。

正常的方法调用中,方法体中创建的对象将在执行完毕之后,将回收其中创建的对象;而此时由于无法回收,即成为逃逸。

1
2
3
4
5
6
7
8
9
10
11
12
static V global_v;  
public void a_method(){
V v=b_method();
c_method();
}
public V b_method(){
V v=new V();
return v;
}
public void c_method(){
global_v=new V();
}

其中b_method方法内部生成的V对象的引用被返回给a_method方法内的变量v,c_method方法内生成的V对象被赋给了全局变量global_v。这两种场景都发生了(引用)逃逸。

6.3 逃逸分析

在JDK 6之后支持对象的栈上分析和逃逸分析,在JDK7中完全支持栈上分配对象。其是否打开逃逸分析依赖于以下JVM的设置:

-XX:+DoEscapeAnalysis

6.4 栈上分配与逃逸分析的关系

进行逃逸分析之后,产生的后果是所有的对象都将由栈上分配,而非从JVM内存模型中的堆来分配。

6.5 逃逸分析/栈上分配的优劣分析

JVM在Server模式下的逃逸分析可以分析出某个对象是否永远只在某个方法、线程的范围内,并没有“逃逸”出这个范围,逃逸分析的一个结果就是对于某些未逃逸对象可以直接在栈上分配,由于该对象一定是局部的,所以栈上分配不会有问题。

消除同步。

线程同步的代价是相当高的,同步的后果是降低并发性和性能。逃逸分析可以判断出某个对象是否始终只被一个线程访问,如果只被一个线程访问,那么对该对象的同步操作就可以转化成没有同步保护的操作,这样就能大大提高并发程度和性能。

矢量替代。

逃逸分析方法如果发现对象的内存存储结构不需要连续进行的话,就可以将对象的部分甚至全部都保存在CPU寄存器内,这样能大大提高访问速度。

劣势:

栈上分配受限于栈的空间大小,一般自我迭代类的需求以及大的对象空间需求操作,将导致栈的内存溢出;故只适用于一定范围之内的内存范围请求。

6.6 测试
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class EscapeAnalysis {  
private static class Foo {
private int x;
private static int counter;

//会发生逃逸
public Foo() {
x = (++counter);
}
}

public static void main(String[] args) {
//开始时间
long start = System.nanoTime();

for (int i = 0; i < 1000 * 1000 * 10; ++i) {
Foo foo = new Foo();
}

//结束时间
long end = System.nanoTime();
System.out.println("Time cost is " + (end - start));
}
}

未开启逃逸分析设置为:

-server -verbose:gc

在未开启逃逸分析的状况下运行情况如下:

1
2
3
4
5
6
7
8
9
10
[GC 5376K->427K(63872K), 0.0006051 secs]  
[GC 5803K->427K(63872K), 0.0003928 secs]
[GC 5803K->427K(63872K), 0.0003639 secs]
[GC 5803K->427K(69248K), 0.0003770 secs]
[GC 11179K->427K(69248K), 0.0003987 secs]
[GC 11179K->427K(79552K), 0.0003817 secs]
[GC 21931K->399K(79552K), 0.0004342 secs]
[GC 21903K->399K(101120K), 0.0002175 secs]
[GC 43343K->399K(101184K), 0.0001421 secs]
Time cost is 58514571

开启逃逸分析设置为:

-server -verbose:gc -XX:+DoEscapeAnalysis

开启逃逸分析的状况下,运行情况如下:

Time cost is 10031306

未开启逃逸分析时,运行上述代码,JVM执行了GC操作,而在开启逃逸分析情况下,JVM并没有执行GC操作。同时,操作时间上,开启逃逸分析的程序运行时间是未开启逃逸分析时间的1/5。

7. 再来聊聊TLAB

JVM在内存新生代Eden Space中开辟了一小块线程私有的区域,称作TLABThread-local allocation buffer)。默认设定为占用Eden Space的1%。在Java程序中很多对象都是小对象且用过即丢,它们不存在线程共享也适合被快速GC,所以对于小对象通常JVM会优先分配在TLAB上,并且TLAB上的分配由于是线程私有所以没有锁开销。因此在实践中分配多个小对象的效率通常比分配一个大对象的效率要高。
也就是说,Java中每个线程都会有自己的缓冲区称作TLABThread-local allocation buffer),每个TLAB都只有一个线程可以操作,TLAB结合bump-the-pointer技术可以实现快速的对象分配,而不需要任何的锁进行同步,也就是说,在对象分配的时候不用锁住整个堆,而只需要在自己的缓冲区分配即可。

8. 对象内存分配过程再升级

  1. 编译器通过逃逸分析,确定对象是在栈上分配还是在堆上分配。如果是在堆上分配,则进入选项2.
  1. 如果tlab_top + size <= tlab_end,则在在TLAB上直接分配对象并增加tlab_top
    的值,如果现有的TLAB不足以存放当前对象则3.
  1. 重新申请一个TLAB,并再次尝试存放当前对象。如果放不下,则4.
  1. Eden区加锁(这个区是多线程共享的),如果eden_top + size <= eden_end则将对象存放在Eden区,增加eden_top 的值,如果Eden区不足以存放,则5.
  1. 执行一次Young GCminor collection)。
  1. 经过Young GC之后,如果Eden区任然不足以存放当前对象,则直接分配到老年代。
  1. 老年代还是不足,则触发Full GC,再不足就OOM错误