Java 虚拟机初探(三)—— 堆

4 篇文章 0 订阅

tips:本篇文章基于Hotspot JVMJDK 1.8所撰写。

内存区域

我们首先来根据一张图初步了解一下内存区域的划分:
JVM内存区域
因为我发现每一版块都有好多东西要说,故把各区域单拿出来一一说明。
下面介绍的是主管JVM存储的区域——堆

Java 堆

我们常说:栈管运行,堆管存储。
既然管存储,那么其必然是线程共享的。Java在运行时创建的所有类实例对象和数组都存储在堆中,堆也是垃圾收集器进行垃圾收集的最重要的内存区域。

对于大多数应用来说,Java堆(Heap)是Java虚拟机所管理的内存中最大的一块
由于现代VM采用分代收集算法(这个将来会在垃圾收集算法中说到),因此Java堆还可以细分为:新生代和老年代
要注意的一点是:在堆中开辟出的对象的空间无法手动的去回收,只能通过JVM的垃圾回收器进行回收。

默认情况下,堆空间各个区域以及分配比例我们可以用一张图来表示:
Java堆
在启动参数中加入:
-XX:+PrintGCDetails
也可以在程序中打印出详细的GC处理日志,比如下面一段程序:

public class TestHeap {

    public static void main(String[] args) {
        long total = Runtime.getRuntime().totalMemory();

        System.out.println("total: " + total);
    }
}

Runtime.getRuntime().totalMemory();可以得到Java虚拟机中的内存总量(也就是Java现在已经从操作系统的内存中挖过来的内存大小)。
打印如下:

total: 1029177344
Heap
 PSYoungGen      total 305664K, used 15729K [0x00000000eab00000, 0x0000000100000000, 0x0000000100000000)
  eden space 262144K, 6% used [0x00000000eab00000,0x00000000eba5c420,0x00000000fab00000)
  from space 43520K, 0% used [0x00000000fd580000,0x00000000fd580000,0x0000000100000000)
  to   space 43520K, 0% used [0x00000000fab00000,0x00000000fab00000,0x00000000fd580000)
 ParOldGen       total 699392K, used 0K [0x00000000c0000000, 0x00000000eab00000, 0x00000000eab00000)
  object space 699392K, 0% used [0x00000000c0000000,0x00000000c0000000,0x00000000eab00000)
 Metaspace       used 3447K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 376K, capacity 388K, committed 512K, reserved 1048576K

可以直观的看到堆内存各个区域的使用情况,这些区域下面会讲到。

另外,除了一开始输出堆处理的指令外,介绍两个关于堆的指令:

  • -Xms:设置初始堆内存分配的大小,默认为物理内存的1/64。
  • -Xmx:设置最大堆内存分配的大小,默认为物理内存的1/4。

一般喜欢将xmsxmx设置成一样:
因为最开始的时候堆大小是xms,Java的垃圾回收在内存达到xms的时候才会开始回收。
而在回收完成后JVM会重新分配内存,随着堆内存的消耗而申请更大的空间,直到达到xmx
所以设置成一样可以避免垃圾回收完成后JVM重新分配内存

需要注意的一点是,在sun的JDK下,xmsxmx设置一样可以减轻伸缩堆大小带来的压力。
但是在ibm的JDK下,设置为一样会增大堆碎片产生的几率,这种情况下不建议将其设置为一致。


新生代

新建的对象都是用新生代分配内存,因为主要存放新创建的对象,所以内存大小相对占据较小(1/3)。
由于频繁的创建对象,所以新生代会频繁触发 MinorGC 进行垃圾回收。

新生代中主要分为三块区域:伊甸园区(Eden space)、幸存者0区(又被称作From区)和幸存者1区(又被称作to区)

Eden 区

Java新对象的出生地(这并不绝对,如果新创建的对象占用内存很大的话,将被直接分配到老年代),也就是每个类被new出来后,都会在Eden区分配。
当Eden区空间用完后,又需要创建对象,则会对该区进行垃圾回收(MinorGC),将其中不再被其他对象所引用的对象销毁,剩余对象移到Survivor from区(其实是移动到to区,这个下面会讲到)。

from 区

上一次GC的幸存者,如果这个区装呀装呀,也满了,同样会触发MinorGC,将无用对象清除,剩余对象移动到Survivor to区。

to 区

一次GC过程的幸存者,如果这个区也装满了的话,直接移到老年代。

MinorGC

MinorGC也被称为Young GC,其机制如下:

  1. 每当Eden区满了后,触发MinorGC,将Eden区和from区无用对象清除,存活的对象复制to区,且这些对象的年龄加1
    堆
    From区空的话也是一样,都移动到to区。

  2. 清除EdenFrom区所有对象(注意,上一步只是复制而非移动),之后From区和to区互换:
    堆

关于年龄问题:
对象的年龄可以通过-XX:MaxTenuringThreshold=15来设置,默认为15
也就是说,当对象的年龄到了15后,直接进入老年代,如果to区被填满后,也会将所有对象移动到老年代中。

新生代的相关参数:

  • -XX:NewRatio=3:设置新生代与老年代的比值(默认为1:2),等于3代表新生代比老年代为1:3。
  • -XX:SurvivorRatio=6:设置新生代中Eden区与两个Survivor区的比值(默认为8:2),等于6代表Eden与两个Survivor区的比值为6:4。
  • -XX:NewSize=200M:设置新生代初始值大小。
  • -XX:MaxNewSize=256M:设置新生代最大值。
  • -Xmn200M:对newsizemaxnewsize同时配置,也就是说如果配置了xmn的话,xmn = newsize = maxnewsize

老年代

听名字也知道,这个区域主要存放生命周期较长的内存对象
老年代通常要比新生代大,以便存放更多的对象(默认占据整个堆空间大概2/3)。

在老年代实行的GC被称作MajorGC,也叫作Full GC
一般来说位于老年代的对象比较稳定(年龄大了嘛),所以Full GC不会频繁的执行(GC次数要比新生代少得多)。
当新生代执行了一次MinorGC,有新的对象进入老年代的时候,如果该对象大于老年代剩余空间大小,则会触发Full GC

这里介绍一个关于触发Full GC的参数:-XX:+HandlePromotionFailure
这个参数的意思为:关闭新生代收集担保
那么什么是新生代收集担保呢?
我们前面说过,当进行MinorGC时会将EdenFrom区的存活对象复制到to区。
那么考虑这样一个问题:如果to区无法容纳所有的存活对象呢?
我们知道,当to区满了的时候,会将所有对象移动到老年代。
那么为了确保MinorGC顺利完成,需要在老年代中额外保留一块足以容纳所有存活对象的内存空间
这个预留操作,就被称为新生代收集担保,而当无法完成预留操作时(也就是上面说的新对象大于老年代剩余空间大小),将触发Full GC

那么为什么要用这个参数来关闭它?
在老年代中预留空间的大小是无法精确计算的,所以为了确保执行成功,GC参考了最坏情况下的新生代内存占用,即Eden+From的全部空间。
这种策略完全是对老年代空间的浪费,本来只有一块蛋糕,你却要预留8块蛋糕的地方,这种做法可能还会提前触发Full GC
这个参数的意义正基于此,可以将新生代收集担保手动关闭以保证老年代空间的充分利用。
而当出现老年代空间不够用时,抛出promotion failed异常。

关于老年代的对象销毁与新生代并不一样,其采用的是标记-整理算法:标记出仍然存活的对象,将所有存活的对象向一端移动,以保证内存的连续。(这里简单提一下,以后会在垃圾收集算法中系统的说)

老年代的相关参数:

  • -XX:PretenureSizeThreshold=512k:如果对象大小超过这个值,则直接在老年代分配内存,默认值是0,意为不管多大都是先在eden中分配。

分区的意义

我们再往深处想一想,为什么要这么划分区域?
我们都能理解为什么有新生代和老年代,一个是存放新生的对象,一个是存放生命周期较长的对象,这样分并用不同的GC处理,会提高效率。

可是新生代为什么还要分什么EdenSurvivor区?
我们从设计师的角度来思考,假如我们是设计师,我们要怎么设计?当然是先从简单的入手,那新生代就只有一个区,Eden区,那么这样会引发什么问题呢?
对象一个接一个的往里面送,啪叽,把Eden区占满了,对象挤不进去了,触发一次MinorGC而这第一次触发的MinorGC,就会把存活的对象送入老年代
可想而知,新生代的GC还没触发几次呢,啪叽,老年代满了,老年代也纳闷啊,你这送货的频率咋跟坐火箭似的嗖嗖嗖的送捏?没办法,只能触发Full GC
而因为老年代比新生代空间大得多的缘故(默认是两倍呢),Full GC的时间可是要长很多。
又因为新生代一满就往老年代里送对象,可想而知Full GC的频率是有多快,这就使得程序的执行和响应效率极为低下。

作为设计师的我们一看这种情况,不行,要减少Full GC的频率,怎么办?多划分一块空间出来吧,于是Survivor区诞生了,当Eden区满了触发GC后,存活的对象不要去老年代了,而改道先去Survivor区。
那比例怎么分配呢?1:1?可是MinorGC主要是负责Eden区啊,如果1:1的话,那GC频率也太高了吧,嗯…经过重重考虑与测试之后,就8:2好了。
那么为什么Survivor有两个区?
这样子的目的是避免内存碎片化带来的空间与性能损失,它是由新生代GC的复制算法所决定的。
什么意思?我们上面说过,MinorGC是先将Eden复制Survivor区,再清除Eden区的对象的吧?
那我们来看第一次Eden区满了之后,内存中是个啥子样子:
堆
嗯…好像没有什么不妥……那么下一次MinorGC之后呢?
堆
第一次GC移到Survivor区的无用对象同样被处理,导致了Survivor区空间碎片化严重。
这浪费也太严重了,要是有一个大胖子(占用内存较大的对象),这挤都挤不进去啊…
那有人就说了:让他们重排就好了啊!
要注意,GC在新生代中的活动可是无比频繁的,每次都重排,那浪费的时间,emm…都够我看一场电影了(笑)!

所以,我们将Survivor分为了两个区,而MinorGC采用上面所讲的策略,可以保证永远有一个Survivor区为空,这样执行GC的时候,我们将其他区的对象移到那个空区域,然后又有一个区域空了出来,碎片化的问题就被完美解决了!

堆中到底存了什么?

说完了堆中区域的划分,我们还不知道,堆中究竟存了什么东西?

  1. 存储对象以及对象所有父类的实例数据。比如:
class A {
	private int a = 1;
	private static int b = 2;

}

A a1 = new A();
A a2 = new A();

在堆中开辟了两个对象a1和a2的内存空间,并保存了他们的实例数据(a)。
但是并没有保存数据b,因为其声明为静态,保存在方法区(元数据区)中。

  1. 存有指向方法区中的类信息数据。
    当程序在运行时需要对象转型,那么JVM必须检查转型是否合法,方法区的类型信息中保存了当前对象和其所有父类的全限定名。
    关于转型与多态可以参考我的另一篇文章:
    从JVM层面对Java多态机制深入探寻

  2. 堆中对象还应该关联一个对象的锁数据信息以及线程的等待集合。
    其实也就是对象运行时数据,如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。

参考文章

Java 虚拟机体系结构


拓展(逃逸分析)

了解逃逸分析之前,我们先思考一个问题:对象都是在堆上分配的吗?

或许了解了逃逸分析之后,你会对这个问题有更深刻的理解。

逃逸分析是什么?
简单来说,逃逸分析是一种算法,其可以对对象的动态作用域进行分析,从而减少内存分配压力的算法。

在某个方法之内创建的对象,除了在方法体内被引用之外,还在方法体之外被其他变量引用到,这种现象被称作逃逸现象
举个简单的例子:

public A methodA() {
	A a1 = new A();
	return a1;
}

public void methodB() {
	A a2 = methodA();
}

methodA中声明的对象A的引用a1返回给了methodB方法中的变量a2,称其发生了逃逸。

再比如:

A a;
public void methodA() {
	a = new A();
}

methodA中生成的A对象被赋给了全局变量a,这同样属于逃逸现象。

逃逸分析是做什么用的?
通过逃逸分析,Hotspot编译器能够分析出一个新的对象的引用的使用范围从而决定是否将这个对象分配到堆上。
我们都知道,方法体中声明的变量在方法体结束后就会被销毁,而这些被外界引用的变量无法被回收。
如果开启了逃逸分析,则没有逃逸的变量(也就是没有被外界使用)将直接在栈上进行分配,这些变量的指针可以被全局或其他线程所引用。

逃逸分析如何开启?
在JDK 6u23以上是默认开启的,在JDK 7中完全支持栈上分配对象。
我们可以设置参数使其强制开启:

  • -XX:+DoEscapeAnalysis:表示开启逃逸分析。
  • -XX:-DoEscapeAnalysis:表示关闭逃逸分析。

逃逸分析开启的好处是什么?

  1. 如果一个对象被判断出只能从一个线程被访问到,对于该对象的操作不考虑线程同步。
    要知道,线程同步的后果是降低并发性和性能,代价相当高,而逃逸分析开启后可以取消对对象的同步保护。
    比如下面的代码:
public void a() {
    Object o = new Object();
    synchronized(o) {
        System.out.println(o);
    }
}

因为对象o的 声明周期只在方法a中,未发生逃逸,所以在JIT编译阶段就会被优化掉,变成:

public void a() {
    Object o = new Object();
    System.out.println(o);
}

这样就防止了代码同步所带来的消耗。

  1. 标量替换。
    标量(Scalar)是指一个无法再分解成更小的数据的数据。Java中的原始数据类型就是标量。相对的,那些还可以分解的数据叫做聚合量(Aggregate),Java中的对象就是聚合量,因为他可以分解成其他聚合量和标量。
    在JIT阶段,如果经过逃逸分析,发现一个对象未发生逃逸,那么**就会把这个对象拆解成若干个其中包含的若干个成员变量来代替。**比如:
public static void main(String[] args) {
   alloc();
}
private static void alloc() {
   Point point = new Point1,2;
   System.out.println("point.x="+point.x+"; point.y="+point.y);
}
class Point{
    private int x;
    private int y;
}

point对象并没有逃逸出alloc方法,并且point对象可以被拆解成标量。
那么JIT就不会直接创建Point对象,而是使用两个标量来代替:

private static void alloc() {
   int x = 1;
   int y = 2;
   System.out.println("point.x="+x+"; point.y="+y);
}

这样不需要创建对象,自然不需要分配堆内存,大大减少了堆内存的占用。

逃逸分析的缺点是什么?

  1. 无法保证逃逸分析的性能消耗一定能高于它带来的好处。
    举个最极端的例子:如果所有对象都是逃逸的,逃逸分析自然没办法对其进行优化,而分析本身的过程就白白消耗掉了。

  2. 我们知道栈的空间是很小的,大容量的存储无法做到,而目前的实现都是采用时间压力相对较小的算法进行逃逸分析,这也导致了其并不十分精确,所以逃逸分析的效果发挥程度还是要在特定场景中才能发挥到最大。

另外,逃逸分析不能在静态编译时进行,必须在JIT里完成
因为你可以在运行时,通过动态代理改变一个类的行为,此时,逃逸分析是无法得知类已经变化了。

参考文章

部分内容摘自:
深入理解Java中的逃逸分析

参考文献

  • 《深入理解Java虚拟机:JVM高级特性与最佳实践》
  • 《Java虚拟机规范 Java SE 8版》
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值