【JVM之运行时数据区2】堆

一、堆的概述

JVM的运行时数据区如下:


在这里插入图片描述

一个Java程序运行起来对应着一个进程(操作系统的进程),一个进程对应着一个JVM实例。而一个JVM实例就对应着一个运行时数据区,则其中就包含着一个堆空间。一个进程中的多个线程共享这个堆空间。
几乎所有的对象实例和数组都在堆上分配内存(并非所有的对象都是在堆上分配内存,后面会提到这一特殊情况),即堆空间存储着绝大部分的对象。虚拟机栈中的栈帧保存着数组或者对象的引用,这个引用就指向对象或者数组在堆中的位置。

有代码如下:

package com.fengjian.www.RunDataArea.Heap;

public class simpleHeap {
    
    private int id;

    public simpleHeap(int id){
        this.id = id;
    }

    public void show(){
        System.out.println("my id is:"+id);
    }

    public static void main(String[] args) {
        simpleHeap s1 = new simpleHeap(1);
        simpleHeap s2 = new simpleHeap(2);
    }
}

代码对应下图,

在这里插入图片描述

二、堆内存的细分

堆,是垃圾回收(GC,Garbage Collection,垃圾收集)的重点区域。我们常说的虚拟机的GC,大部分都与堆有关。
在一个Java方法结束后(即在虚拟机栈中出栈后,再没有引用指向堆中的对象),堆中的对象并不会马上被移除,而是在GC时才会被移除。
由于现代垃圾收集器大部分都基于分代收集理论设计,所以堆空间在逻辑上细分如下:

(1)、Java7及之前,分为三部分:新生代、老年代、永久代
① 新生代: Yonug/New Generation Space

其中,新生代划分为一个Eden区(伊甸园区)和两个Survivor区(From Survivor和To Survivor)

②老年代:Tenure/Old Generation Space

③永久代:Perm Space

(2)、Java8及之后,也分为三部分:新生代、老年代、元空间
Java8之后,取消了永久代,改用元空间代替,而新生代和老年代则继续保留。
元空间:Mete Space
后面将详细介绍永久代和元空间的区别。

值得注意的是,虽然这里将永久代、元空间和新生代、老年代并列,但实际上,永久代和元空间都不属于堆空间的一部分。事实上,永久代和元空间是方法区的实现方式,即用永久代或者元空间来实现方法区
堆空间分出了一部分内存给永久代来实现方法区,而元空间则脱离了虚拟机内存,直接使用计算机的本地内存作为自己的空间。所以,我们常说的堆空间,本质上只包含新生代和老年代的空间。

示意图如下,


在这里插入图片描述

在这里插入图片描述

在我们常见的表述中,新生代、年轻代和新生区意思相同,老年代、老年区和养老代也都是一个意思,只是叫法不同而已。

1、永久代简述

我们现在开发常用的SunJDK和OpenJDK都是采用的HotSpot虚拟机。而永久代就独属于HotSpot虚拟机独有的,其他虚拟机则并没有。
HotSpot虚拟机采用了永久代来实现方法区。
方法区是虚拟机运行时数据区域的一块,是虚拟机规范中规定要有的一块区域。但是对于如何实现这块区域,虚拟机规范并没有给出。所以各个虚拟机厂商可以自主的实现这一区域。即方法区是一个规范,永久代是HotSpot虚拟机实现方法区的一种方式,后又用元空间来代替元空间。

使用永久代的好处有:
①由于永久代和新生代、老年代是连续的物理内存区域,故HotSpot虚拟机的垃圾回收器可以像管理堆一样管理方法区,省去了专门为方法区设置垃圾回收的工作。
②永久代和老年代的垃圾回收是绑定的,一旦其中一个区域被占满,这两个区域都会进行垃圾回收。

永久代的缺点也很明显:
①在Java7之前,HotSpot虚拟机将纳入字符串常量池的字符串(占用内存较大)存储在永久代中,导致了一系列的性能问题和内存溢出错误。(在Java7时,已经字符串常量池放到了堆中)
②永久代的大小指定困难,太小容易出现永久代溢出,太大容易导致老年代溢出。(可用:java-XX:PermSizejava-XX:Perm设置)

由于永久代容易导致javaOutofMemoryError,因为通常使用PermSize和MaxPermSize设置永久代的大小就决定了永久代的上限,但是不是总能知道应该设置为多大合适, 如果使用默认值很容易遇到OOM错误。故决定用元空间的设置来取代永久代。
当然,其实还有一个重要的原因就是,要合并HotSpot虚拟机和JRockit虚拟机的代码,JRockit没有永久代的设计,但是运行良好,HotSpot虚拟机吸收了它的长处,取消了永久代。

2、元空间简述

元空间使用的不是虚拟机所管理的内存大小,而是使用的计算机本身的本地内存。因此其大小受限制于本地内存,但也可以通过以下参数设置大小:java-XX:MetaSpaceSize。如果没有设置,则默认根据运行的Java程序动态调整大小。
由于元空间的最大可分配空间是操作系统的可用内存空间,故我们不会遇到永久代存在的内存溢出问题(OOM)。

三、老年代和新生代

存储在JVM中的对象可以被分为2类:

  • 一类是生命周期较短的瞬时对象,其创建和消亡都非常快。
  • 另一类是对象的生命周期却非常长,有时甚至与JVM中的生命周期保持一致。

Java堆区进一步分为新生代和老年代。新生代又细分为Eden区和Survivor0区和Survivor1区。

在这里插入图片描述

默认情况下,新生代与老年代的占比是1:2,即新生代占1/3,。
也可以通过以下参数调整:

-XX:NewRatio = 1,即老年代占1/2
-XX:NewRatio = 2,默认情况,即老年代占2/3
-XX:NewRatio = 3,即老年代占1/4
...

当然,大部分情况下是不会进行调整的。
在HotSpot虚拟机中,Eden空间和两个Survivor区的所占比例是8:1:1。
也可以通过以下参数调整:

-XX:SurvivorRatio = 8,默认情况,即Eden区占8/10。(两个Survivor区大小一致)

几乎所有的Java对象都是在Eden区New出来的,而绝大部分的Java对象的销毁都在新生代进行。
有研究表明,新生代80%的对象都是“朝生夕死”的。

四、对象分配内存的过程

一个对象分配内存的过程简述如下:
(1)、new的新对象放在Eden区,此区有大小限制。
(2)、当Eden区的空间被填满时,程序又要创建对象,此时就应进行GC以回收内存。JVM的垃圾回收器将对Eden区进行垃圾回收(该GC被称为MinorGC),将Eden区中不再被其他对象所引用的对象进行销毁,再加载新的对象到Eden区。
(3)、进行GC后,将Eden区中的剩余对象移动至Survivor0区。
(4)、如果Eden区再次被填满,则继续触发MinorGC,GC的范围包括Survivor区。GC后,此时Eden区剩余的对象,以及上次GC后被移动至Survivor0区并且在本次GC中没有被回收的剩余对象,都会一起被移动到Survivor1区。
(5)、如果再次经历GC,Eden区和Survivor1区的剩余对象将继续移动到Survivor0区。再次GC后,则一起去Survivor1区,如此反复。

那么,一个对象什么时候会去到老年代呢?
在新生代(包含Eden区和两个Survivor区)中,一个对象如果经历了一次GC后(一般是MinorGC),其年龄值就会加一。当一个对象经历了15次GC后,即年龄值达到15,就会晋升(Promotion)到老年代。这个年龄到达一定值后晋升的值,被称为阈值。阈值可以通过如下参数调整:

-XX:MaxTenuringThreshold = 15,默认情况下,阈值设置为15。

这是一个对象从新生代晋升到老年代的一般情况,当然也会有一些特殊情况,使对象的年龄值没有达到阈值也会晋升到老年代。

(6)、在老年代,若老年代内存不足时,则会触发GC(一般是MajorGC或者FullGC,两者区别后面介绍),并进行垃圾回收。
(7)、如果老年代在进行GC后依然无法进行对象的存储,那么就会产生OOM异常。

这就是对象分配的一般过程,由于文字描述不易理解,下面将进行图解。

过程1:

在这里插入图片描述

解析1:
①当对象创建时,会先放在Eden区。
②当Eden区满后,触发MinorGC,并进行GC。
③MinorGC的机制可判断没有对象引用的对象为垃圾,并将其回收。
④GC后剩余的对象将被移自Survivor0区,此时该区被称为from区。
⑤而没有存放对象的Survivor1区则称为to区,to区总是空的。
⑥to区和from区是不固定的,谁空谁就是to区。
⑦每经历一次GC,剩余对象的年龄值加一。

过程2:

在这里插入图片描述

解析2:
①当Eden区又一次满后,继续触发MinorGC,并进行GC。
②GC的范围是整个新生代,所以除了Eden区的垃圾对象会被回收外,Survivor0区的垃圾对象也会被回收。
③经过GC后,所有剩余对象进入Survivor1区,并且年龄值均加一。

过程3:

在这里插入图片描述

解析3:

①若Eden区再满后,继续触发MinorGC,并进行GC。
②经过GC后,所有剩余对象又进入Survivor0区,并且年龄值均加一。
③新生代中的对象就是如此经历GC和反复移动。

过程4:

在这里插入图片描述

解析4:

①当有剩余对象经历了阈值规定的GC次数后(默认是15次),就会发生晋升(promotion),从新生代晋升至老年代。
②老年代中的对象生命周期一般比较长,但其内存空间也是有限的,故在其内存空间满后也会进行GC。
③老年代常见的GC有MajorGC和FullGC。
④如果老年代中执行GC后,依然无法满足存储对象的需求,则会报OOM,

上诉分配过程就是对象的一般分配过程,但对象不一定都是经历了一定GC次数后才会晋升到老年代的。

下面就用具体的流程图来说明:


在这里插入图片描述

特殊情况在于:

①当新对象过大,而Eden区放置不下时,会直接将该大对象晋升到老年代。如果老年代放不下,则会触发FullGC,GC后若再放不下,则会报OOM。
②当Eden区中的对象经过MinorGC后,Survivor区(S0或S1)放不下该对象,由于Survivor区满不会触发GC故该对象也会直接晋升到老年代中。

五、几个常见GC的概述

前面讲到几个GC:MinorGC、MajorGC、FullGC,现在来简单概述它们的区别。

JVM在进行GC时,并非每次都会对新生代,老年代,元空间(方法区)这三个内存区域一起回收,大部分回收都是发生在新生代。

对于Hotspot虚拟机,其GC按照回收区域又分为两大类型:部分收集(Partial GC)和整堆收集(Full GC)。

  • 部分收集:不是完整收集整个Java堆空间。其中又有:

①新生代收集(MinorGC):即针对新生代(Eden区,两个Survivor区)的垃圾收集。
②老年代收集(MajorGC):即针对老年代的垃圾收集。但目前只有CMS GC这款垃圾收集器在进行MajorGC时,只单独收集老年代的垃圾。

  • 整堆收集:收集整个Java堆(包括老年代,新生代)和方法区的垃圾。

1、新生代GC(MinorGC)

①当Eden区空间不足时,就会触发MinorGC,但Survivor区不足则不会触发。MinorGC的范围是整个新生代。
②由于大多数Java对象的创建和消亡都比较快,故MinorGC非常频繁,一般回收速度也比较快。
③MinorGC会引发STW(stop the word),即暂停用户线程,也就是我们平时执行我们编写的代码的线程,直到垃圾回收结束,用户线程才会恢复。

2、老年代GC(MajorGC/FullGC)

①老年代中的GC,一般为MajorGC或FullGC。
②在老年代空间不足时,会先尝试发起MinorGC,在此之后空间仍然不足,才会触发MajorGC(但并不绝对)
③MajorGC的速度会比MinorGC慢10倍以上,STW时间更长

3、整堆收集(FullGC)

①调用system.gc()时,系统会建议执行FullGC,但并不是一定会执行。
②在老年代空间不足时,可能会触发FullGC。
③方法区空间不足时,也会触发FullGC。
④FullGC是开发中应尽量避免的,因为其暂停用户线程的时间会很长。

六、内存分配策略

我们知道,如果对象在Eden区出生并经历第一次MinorGC后仍然存活,并且能够被Survivor区容纳的话,将会被移动到Survivor区中,并将年龄值加1。对象每经历一次GC,其年龄值加一。而针对不同年龄值得对象,其内存分配策略如下:

(1)、对象优先在Eden区分配。
(2)、大对象直接分配在老年代。

①默认情况下,超过Eden区大小的大对象直接分配到老年代,比如 new byte[40010241024]这个400MB大小的字节数组就是一个大对象。
②可以通过参数设置多大的对象直接在老年代分配:-XX:PretenureSizeThreshold
③要避免程序中出现这样的大对象,尤其是消亡得很快的大对象。因为大对象需要连续的存储空间才能存放它们,这就意味着即使内存中还有不少空间,但为了能有足够的连续空间,也不得不进行GC,进而导致用户线程暂停,降低性能。

(3)、长期存活的对象进入老年代。
(4)、动态对象年龄判断:为了更好地适应不同年龄状况,HotSpot虚拟机并不是一定要求对象必须要达到阈值才能晋升老年代。如果Survivor区中所有相同年龄对象的大小的总数大于Survivor区的一半,年龄大于或等于该年龄的对象就可以直接进入老年代了。
(5)、空间分配担保:在新生代进行MinorGC前,虚拟机会
①先检查老年代中的连续空间是否满足新生代中所有对象的大小。
②若满足,则本次MinorGC是安全的。
③若不满足,则会查看-XX:HandlePromotionFailure的设置
④若-XX:HandlePromotionFailure = true,即为允许担保失败,于是就去检查老年代中的连续空间是否大于新生代中以前晋升到老年代的对象的平均大小。
⑤如果老年代中的连续空间大于以前晋升到老年代的对象的平均大小,则继续进行MinorGC(但本次MinorGC不一定是安全的)
⑥如果连续空间小于平均大小,或者参数设置为-XX:HandlePromotionFailure = false(即不允许冒险),则将MinorGC改为FullGC。

七、堆空间分代思想

为什么要把Java堆分代,不分代是否可行?

其实不分代也是完全可以的,分代的原因就是优化GC的性能。
如果没有分代,那么所有的对象都在一块,GC时需要找那些对象没用,就需要扫描整个Java堆,比较耗费性能。而其实很多对象都是消亡得比较快的,如果采用分代将新创建的对象放到某一个地方,当GC时可以先对这块放置新对象的区域进行回收,则可以腾出很大空间,同时也提高了GC的效率。

八、本地线程分配缓冲TLAB

TLAB(Thread Local Allocation Buffer)出现在为对象分配内存的过程中。
堆区是线程共享的区域,任何线程都可以访问堆区。由于对象实例的创建在堆区中非常频繁,因此在并发环境下从堆区中划分内存空间是线程不安全的。
为了避免多个线程在堆区中操作同一个地址,需要使用加锁等机制,但这也会影响到内存的分配速度。为了解决这一问题,提出了TLAB。

1、什么是TLAB

虚拟机为每个线程分配了一个线程私有的缓冲区域,它包含在Eden区中。哪个线程要分配内存,就在哪个线程的本地缓冲区中分配,只有本地缓冲区用完了,分配新的缓冲区需要才同步加锁。


在这里插入图片描述

2、TLAB的特点

①可以通过参数-XX:UseTLAB来设置是否开启TLAB空间(默认开启)。
②尽管不是所有的对象实例都能在TLAB中成功分配内存,但虚拟机确实将TLAB作为内存分配的首选。
③默认情况下,TLAB空间非常小,仅占Eden空间的1%。

可通过:-XX:TLABWasteTargetPercent来设置TLAB空间的百分比大小。
④一旦对象在TLAB空间中分配内存失败,则虚拟机会尝试通过加锁机制确保操作的原子性,从而直接在Eden区空间中分配内存。

九、堆不是对象分配中的唯一选择

1、逃逸分析

对象在堆中分配内存,这是普遍的常识。但也有一种特殊的情况,那就是通过逃逸分析后发现,一个对象没有逃逸出方法,则就可能被优化成栈上分配。即无需在堆上分配内存,也无需进行垃圾回收。
逃逸分析的基本行为就是分析对象动态作用域,并决定是否在栈上分配。

  • 当一个对象在方法中被定义以后,对象只在方法内部使用,则认为没有发生逃逸。
  • 当一个对象在方法中被定义以后,在外部方法中被引用,则认为发生了逃逸。例如,作为调用参数,传递到其他地方。

没有发生逃逸的对象,则可以分配到栈上,随着方法执行的结束,占用的栈帧空间就被移除了。

有代码如下,

public void my_method{
    V v = new V();
    //use v
    //...
    v = null;
}

在my_method方法中,对象v是在方法内部被创建的,并且在方法的最后,将v设置为null,即再无引用指向之前的对象,则意味着,该方法的作用空间只在my_method方法内,所以可认为该对象没有发生逃逸,故可以该对象可分配到栈上。

有代码如下,

public static StringBuffer createString1(String str1,String str2){
        StringBuffer sb = new StringBuffer();
        sb.append(str1);
        sb.append(str2);
        return sb;
    }

createString1()方法中,由于返回了sb,即返回了对象的引用。则若在其他方法中调用了createString1(),那么接受了createString1()方法返回值的变量,实际上就指向了new StringBuffer();这个对象,即可认为该对象发生了逃逸。

若想使该方法不发生逃逸,可这样改写:

public static String createString2(String str1,String str2){
        StringBuffer sb = new StringBuffer();
        sb.append(str1);
        sb.append(str2);
        return sb.toString();
    }

这样子改写,把 StringBuilder 变量控制在了当前方法之内,没有逃出当前方法作用域,故可以进行栈上分配,则当方法结束时,new StringBuffer();这个对象就会被移除,不用再经历垃圾回收,提升了性能。而返回的将是一个新的字符串,其内容与sb指向的对象一致。

关于逃逸分析的常见场景,有


/**
 * 逃逸分析
 *
 * 1、如何快速分析是否发生了逃逸分析,就看new的对象实体是否有可能在方法外被调用。
 */

public class EscapeAnalysis {

    public EscapeAnalysis obj;

    /**
     * 方法返回escapeAnalysis对象,发生逃逸
     */
    public EscapeAnalysis getInstance(){
        return obj == null? new EscapeAnalysis() : obj;
    }

    /**
     * 为成员属性赋值,发生逃逸
     * 即使obj对象修饰为static的,其仍然会发生逃逸
     */
    public void setObj(){
        this.obj = new EscapeAnalysis();
    }

    /**
     * 对象作用域仅在当前方法中有效,没有发生逃逸
     */
    public void useEscapeAnalysis(){
        EscapeAnalysis e = new EscapeAnalysis();
    }

    /**
     * 引用成员变量的值,发生逃逸
     * 因为new的对象实体不是在本方法中
     */
    public void applyEscapeAnalysis(){
        EscapeAnalysis e = getInstance();
    }

}

3、逃逸分析参数设置

  • Java7之后,HotSpot默认开启逃逸分析
  • -XX:+DoEscapeAnalysis 显式开启逃逸分析
  • -XX:-DoEscapeAnalysis 关闭逃逸分析
  • -XX:-PrintEscapeAnalysis 查看逃逸分析的筛选结果

4、逃逸分析的作用:优化

使用逃逸分析,编译器可对代码做如下优化:
(1)、栈上分配。将堆分配转化为栈上分配。如果一个对象在程序中被分配,而该指向该对象不会发生逃逸,那么该对象可能是栈分配,而不是堆分配。
(2)、同步省略。如果一个对象呗发现只能从一个线程被访问到,那么可以对于这个对象的操作可以不考虑同步。
(3)、分离对象或者标量替换。有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在CPU寄存器中。

得出一个结论:开发中能使用局部变量,就不要使用在方法外定义的变量。

5、关于栈上分配

JIT编译器在编译期间根据逃逸分析的结果,发现如果一个对象并没有逃逸出方法,则可能被优化成栈上分配。分配完成后,继续在调用栈内执行,最后线程结束,栈空间被回收,局部变量对象也被回收。这样就无需进行垃圾回收了。
常见的栈上分配的场景是:
①给成员变量赋值
②方法返回值
③实例引用传递

6、关于同步省略

线程同步的代价是比较高的,同步的后果就是降低并发性和性能,在动态编译同步的时候,JIT编译器可以借助逃逸分析来判断同步块所使用的锁对象是否只能够被一个线程访问到,而没有被发布到其他线程。
如果没有被其他线程访问,那么JIT编译器在编译这个同步块的时候就会取消对这部分代码的同步。这样就能大大提高并发性和性能。这个取消同步的过程就叫做同步省略,也叫锁消除。

有代码如下,

public void f(){
    Object hollis = new Object();
    synchronized(hollis ){
        System.out.println(hollis );
    }
}

代码中对hollis这个对象进行加锁,但是hollis 对象的生命周期只在f()方法中,并不会被其他线程所访问到,所以在JIT编译阶段就会被优化掉。
优化成:

    public void f(){
        Object hollis = new Object();
        System.out.println(hollis );
    } 

7、 关于对象分离或标量替换

标量(Scalar)是指一个无法再分解成更小数据的数据。如Java中的原始数据类型就是标量。
相对的,那么还可以分解的数据就叫聚合量(Aggregate),Java中的对象就是聚合量,因为他可以分解成其他聚合量或者标量。
在JIT阶段,如果经过逃逸分析后发现,一个对象不会被外界访问的话,那么经过JIT优化,就会把这个对象拆解成若干个其中包含的成员变量来代替
这个过程就是标量替换。
有代码如下,


public static void main(String[] args) {
    alloc();
}

private static void alloc(){
    Point point = New Point(1,2);
}

class Point{
    private int x;
    private int y;
}

上述代码就,经过逃逸分析后发现,对象point 没有发生逃逸,则会进行标量替换。经过标量替换后,代码被优化成如下,


public static void main(String[] args) {
    alloc();
}

private static void alloc(){
    int x = 1;
    int y = 2;
}

可以看到,point这个对象聚合量经过标量替换后,被替换成了两个它包含的标量xy
那么标量替换有什么好处呢?
好处就是大大减少了堆内存的占用,因为一旦无需创建对象,就无需在堆上分配内存了。标量替换为栈上分配提供了很好的基础。

标量替换的JVM参数如下:

  • -XX:+EliminateAllocations 开启标量替换
  • -XX:-EliminateAllocations 关闭标量替换
  • -XX:+PrintEliminateAllocations 显示标量替换详情

8、逃逸分析小结

目前,逃逸分析技术依然不成熟,其根本原因是无法保证逃逸分析的性能消耗一定高过它的消耗。虽然经过逃逸分析后可以做到标量替换、栈上分配和锁消除,但是逃逸分析本身也需要经历一系列复杂的分析,这其实是一个相对耗时的过程。
比如,如果经过逃逸分析后,发现所有对象都是逃逸的,那逃逸分析的过程就白白浪费了。
虽然技术并不成熟,但它还是即时编译器优化技术中一个重要的手段。

十、堆常见JVM参数小结

  • -XX:PrintfFlagsInitial 查看所有参数的默认值
  • -XX:PrintfFlagsFinal 查看所有参数的z最终值
  • -Xms: 初始堆空间内存(默认为物理内存的1/64)
  • -Xmx: 最大堆空间内存(默认为物理内存的1/4)
  • -Xmn: 设置新生代的大小(初始值及最大值)
  • -XX:NewRatio: 配置新生代和老年代在堆中结构的占比
  • -XX:SurvivorRatio: 设置新生代中Eden区和S0/S1空间的比例
  • -XX:MaxTenuringThreshold 设置新生代中垃圾的最大年龄值
  • -XX: +PrintGCDetails: 输出详细的GC处理日志
  • 打印GC简要信息:①-XX: +PrintGC ② -verbose:gc
  • -XX:HandlePromotionFailure 是否设置空间分配担保

你可能感兴趣的:(【JVM之运行时数据区2】堆)