JVM-堆

JAVA技术交流群:737698533

堆核心概述

  • 此内存区域的唯一目的就是存放对象实例

  • 一个JVM实例只存在一个堆内存,堆也是Java内存管理的核心区域。

  • Java堆区在JVM启动的时候即被创建,其空间大小也就确定了。是JVM管理的最大一块内存空间。

    • 内存的大小是可以调节的。
  • 《Java虚拟机规范》规定,堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的。

  • 所有的线程共享Java堆,在这里还可以划分线程私有的缓冲区(ThreadLocal Allocation Buffer, TLAB)

  • 几乎所有的对象实例都在堆里进行分配内存,一切从实际角度看

  • 数组和对象可能永远不会存储在栈上,因为栈帧中保存引用,这个引用指向对象或者数组在堆中的位置。

  • 在方法结束后,堆中的对象不会马上被移除,仅仅在垃圾收集的时候才会被移除。

  • 堆是GC ( Garbage Collection,垃圾收集器)执行垃圾回收的重点区域。

堆内存细分

现代垃圾收集器大部分都基于分代收集理论设计,堆空间细分为:

Java 7及之前堆内存逻辑上分为三部分:新生代+养老代+永久代

  • Young Generation Space 新生代 Young/New
    • 又被划分为Eden区和Survivor区
  • Tenure Generation Space 养老代 Old/Tenure
  • Permanent Space 永久代 Perm

Java 8及之后堆内存逻辑上分为三部分:新生代+养老代+元空间

  • Young Generation Space 新生代 Young/New
    • 又被划分为Eden区和Survivor区
  • Tenure Generation Space 养老代 Old/Tenure
  • Meta Space 元空间 Meta

设置堆大小与OOM

  • Java堆区用于存储Java对象实例,那么堆的大小在JVM启动时就已经设定好了,可以通过选项"-Xmx"和"-Xms"来进行设置

    • "-Xms"用于表示堆区的起始内存,等价于-XX: InitialHeapsize

    • "-Xmx"则用于表示堆区的最大内存,等价于-XX:MaxHeapsize

  • 一旦堆区中的内存大小超过"-Xmx"所指定的最大内存时,将会抛出OutOfMemoryError异常。

  • 通常会将-Xms和-Xmx两个参数配置相同的值,其目的是为了能够在java垃圾回收机制清理完堆区后不需要重新分隔计算堆区的大小,从而提高性能

  • 默认情况下,初始内存大小:物理电脑内存大小/ 64 最大内存大小:物理电脑内存大小/4

注意:设置的堆大小不包含元空间(或永久代)

查看GC 控制台jps 显示正在运行程序id,然后使用 jstat -gc 程序id来查看gc情况,

 S0C    S1C    S0U    S1U      EC       EU        OC         OU     
512.0  512.0   32.0   0.0    5120.0   816.6    13824.0     3549.4  

最后带C代表容量大小,U代表已经使用的容量

或者使用JVM启动参数来打印GC情况

-XX:+PrintGCDetails

Heap
 PSYoungGen      total 2560K, used 1831K [0x00000000ffd00000, 0x0000000100000000, 0x0000000100000000)
  eden space 2048K, 89% used [0x00000000ffd00000,0x00000000ffec9fb0,0x00000000fff00000)
  from space 512K, 0% used [0x00000000fff80000,0x00000000fff80000,0x0000000100000000)
  to   space 512K, 0% used [0x00000000fff00000,0x00000000fff00000,0x00000000fff80000)
 ParOldGen       total 7168K, used 0K [0x00000000ff600000, 0x00000000ffd00000, 0x00000000ffd00000)
  object space 7168K, 0% used [0x00000000ff600000,0x00000000ff600000,0x00000000ffd00000)
 Metaspace       used 3360K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 369K, capacity 388K, committed 512K, reserved 1048576K

OOM演示

JVM参数 -Xms100m -Xmx100m

public class ReturnAddressTest {

    public static void main(String[] args) throws InterruptedException {
        List<char[]> list=new ArrayList<>();

        while (true){
            Thread.sleep(1000);
            list.add(new char[1024*1024]);
        }
    }
}

错误

Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at ReturnAddressTest.main(ReturnAddressTest.java:10)

因为不停的往list中添加数据,而且数据还不能被回收,导致堆空间满了,出现异常

可以通过JDK自带的可视化性能监控工具来查看,在jdk安装目录下,bin目录下面jvisualvm.exe程序,打开后安装visualGC插件即可

在上面操作栏上点击工具,然后点击插件就可以打开如下窗口

如果下载失败,直接去官网下载完插件在添加到jvisualvm软件中 https://visualvm.github.io/uc/8u131/updates.html

之后在软件中添加该插件即可

启动程序,在左边程序栏中找到名称对应的程序,然后点击visual GC就可以实时查看堆情况了

年轻代与老年代

存活在JVM中的对象可以分为两类,一类是生命周期较短的瞬时对象,这类对象的创建和消亡都非常迅速,另外一类对象的生命周期却非常长,在某些极端的情况下还能够与JVM的生命周期保持一致。

而在JVM堆内存中,又给分为年轻代(Young Generation)和老年代(Old Generation),年轻代中又分为伊甸园区(Ende)和from区(或者叫做Survivor0区)以及to区(或者叫做Survivor1区)

其中,它们的比例如下

年轻代整个占用堆空间的1/3,老年代占用整个堆空间的2/3,而Ende区占用年轻代的8/10,S1和S0分别占用年轻代的1/10

配置新生代与老年代在堆结构的占比。一般在开发中不建议更改

默认-XX: NewRatio-2,表示新生代占1,老年代占2,新生代占整个堆的1/3
修改-XX: NewRatio-4,表示新生代占1,老年代占4,新生代占整个堆的1/5

测试,JVM参数 -Xms600m -Xmx600m,启动后使用JDK自带软件VisualVM来查看

按理来说新生代和老年代为1:2,也就是说新生代为200m,老年代为400m,上面Eden区为150m加上s0区的25m和s1区25m为200m没有问题

但是新生代中Eden区和s1,s0区比例为8:1:1,也就是说200m,Eden区为160m,s0和s1为20m,但是上面却是150:25:25,这是因为JVM中的自适应内存分配策略

可以使用 -XX:SurvivorRatio=8 来手动设置Eden区和S0,S1的比例为8:1:1

几乎所有的java对象都是在Eden区创建出来的,绝大部分的java对象的销毁在新生代进行

对象分配过程

当程序启动,对象开始创建会放在Eden区,当Eden区满了会触发一次MinorGC(或者叫做YoungGC),将没有引用的对象清除,存在引用的对象移动到S0区,将移动过的对象年龄标识加1

这时Eden区为空,当第二次Eden区满了后再次触发MinorGC,将没有引用的对象清除,存在引用的对象移动到S1区,同时检查S0区中对象,如果没有引用的对象清除,存在引用的对象也移动到S1区,将移动过的对象年龄标识加1

当对象年龄标志大于15将放入老年代

垃圾收集一般都是频繁发生在年轻代,很少发生在老年代垃圾回收,几乎不在元空间/永久代发生,对于垃圾回收之后会写一篇详细的博客

JVM在进行GC时,并非每次都对上面三个内存(新生代,老年代,方法区)区域一起回收,大部分回收都是指的新生代

针对HotSpot VM的实现,它里面的GC按照回收区域又分为两大类型:一种是部分收集(Partial GC),一种是整堆收集(Full GC)

部分收集:不是完整的收集整个java堆的垃圾收集,其中又分为

  • 新生代收集(Minor GC/Young GC):只是对新生代(Eden/S0/S1)的垃圾收集
  • 老年代收集(Major GC/Old GC):只是对老年代(Old)的垃圾收集
  • 混合收集(Mixed GC):收集整个新生代以及部分老年代的垃圾收集
    • 只有G1垃圾收集器会有这种行为

整堆收集(Full GC):收集整个java堆和方法区的垃圾收集

GC触发条件

年轻代GC(Minor GC)触发机制:

  • 当年轻代空间不足时,就会触发Minor GC,这里的年轻代满了指的是Eden区满,Survivor区满不会引发GC (每次Minor GC会清理年轻代的内存)
  • 因为java对象大多都具备朝生夕死的特性,所以Minor GC非常频繁,一般回收速度也比较快
  • Minor GC会引发STW(Stop The World) 暂停其他用户线程,等垃圾回收结束,用户线程才恢复运行

老年代GC(Major GC/Full GC)触发机制

  • 指发生在老年代的GC,从对象从老年代消失时,我们说"Major GC"或"Full GC"发生了
  • 出现了Major GC经常会伴随着至少一次的Minor GC(但是并非绝对的,在ParallelScavenge收集器的收集策略里就有直接进行Major GC的策略选择过程)
  • 也就是在老年代空间不足时,会先尝试触发Minor GC,如果之后空间还是不足则触发Major GC
  • Major GC的速度一般会比Minor GC慢十倍以上,STW时间更长
  • 如果Major GC后内存还是不足,就报OOM了
  • Major GC的速度一般

Full GC触发机制

触发Full GC执行的情况有如下五种

  1. 调用System.gc()时.系统建议执行Full GC 但是不是必然执行
  2. 老年代空间不足
  3. 方法区空间不足
  4. 通过Minor GC后进入老年代的平均大小大于老年代的可用内存
  5. 由Eden区,S0区向S1区进行复制时,对象大小大于S1可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小

堆空间分代思想

为什么需要把堆空间进行分代?

绝大多数的对象都是朝生夕死的,如果把一些生命周期比较短的对象和一些生命周期比较长的对象放在一起,那么每次GC都必须将全部对象扫描一遍来确定哪些对象需要回收,如果生命周期比较长的对象比较多,那么每次GC都需要做很多无用的扫描,所以将堆分为两个部分,一部分单独来存储生命周期比较短的对象,GC也可以明确扫描的对象,能够更加高效的进行垃圾回收

内存分配策略(对象提升(Promotion)原则)

针对不同年龄段的对象分配原则如下

  • 优先分配到Eden
  • 大对象直接分配到老年代
  • 长期存活对象分配到老年代
  • 动态对象年龄判断:如果Survivor区中相同年龄的所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄对象可以直接进入老年代,无须等到阈值年龄

TLAB

为什么有TLAB(Thread Loacl Allocation Buffer)?

先来看对象的创建过程,为对象分配空间的任务等同于在java堆中划分一块大小确定的内存出来,假设java堆空间内存是绝对规整的,所有使用过的内存都放在一边,没有使用过的内存放在另一半,中间放着一个指针作为分界点的指示器,当分配内存就仅仅是把指针想空闲的空间移动一段与对象大小相等的距离,这种分配方法称为指针碰撞(Bump The Pointer)

对象创建在虚拟机中是非常频繁的行为,即使仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又使用了原来的指针来分配内存的情况

解决方案其一就是使用本地线程分配缓冲(TLAB),从内存模型而不是垃圾收集角度来看,对Eden区继续进行划分,JVM为每个线程分配了一个私有缓存区域,它包含在Eden区内,即每个线程在java堆中预先分配一小块内存,哪个线程需要分配内存,就在哪个线程的本地缓冲区中分配,当本地缓冲使用完了,分配新的缓存区时才需要同步锁定

尽管不是所有对象实例都能在TLAB中成功分配内存,但是JVM确定是将TLAB作为内存分配的首选,可以通过选项 -XX:UseTLAB 设置是否开启TLAB空间,默认的情况下TLAB占用的内存非常小,仅占用Eden空间的1%,当然也可以通过选项

-XX:TLABWasteTargetPercent 来设置TLAB空间所占用Eden空间的百分比大小,一旦对象在TLAB空间分配失败时,JVM会尝试通过加锁机制确保数据操作的原子性,从而直接在Eden区分配内存

堆常用参数

-XX:+PrintFlagsInitial 查看所有参数的默认初始值

-XX:+PrintFlagsFinal 查看所有参数的最终值

-Xms 初始堆空间内存(默认为物理内存的1/64)

-Xmx 最大堆空间内存(默认为物理内存1/4)

-XX:NewRatio 配置新生代与老年代在堆结构占比

-XX:SurvivorRatio 设置新生代中Eden区和S0,S1区空间比例

-XX:MaxTenuringThreshold 设置新生代垃圾的最大年龄

-XX:+PrintGCDetails 输出详细的GC处理日志

-XX:HandlePromotionFailure 是否设置空间分配担保

关于-XX:HandlePromotionFailure参数再来详细介绍一下

在发生Minor GC之前,虚拟机会检查老年代最大可用的连续内存空间是否大于新生代所有对象的总空间

如果大于,则此次Minor GC是安全的

如果小于,则虚拟机检查-XX:HandlePromotionFailure设置的值是否允许担保失败

​ 如果HandlePromotionFailure=true,那么继续检查老年代最大可用的连续内存空间是否大于历次晋升到老年代的对象平均大小

​ 如果大于,则尝试进行一次Minor GC,但这次Minor GC依然是有风险的

​ 如果小于,则改为进行一次Full GC

​ 如果HandlePromotionFailure=false,则改为进行一次Full GC

老年代最大可用的连续内存空间是否大于历次晋升到老年代的对象平均大小意思是,例如已经经历过两次Minor GC,晋升到老年代的对象平均占用内存为15m,而现在最大可用的连续内存空间大于15m,则进行一次Minor GC,否则改为进行Full GC

在JDK6 Update24之后,HandlePromotionFailure参数不会再影响到虚拟机的空间分配担保策略,JDK6 Update24之后的规则改为

只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行Minor GC否则进行Full GC

堆是分配对象存储的唯一选择吗

随着JIT编译器的发展与逃逸分析技术逐渐成熟,栈上分配,标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变的不那么"绝对"了,在java虚拟机中,对象是在java堆中分配内存的,这是一个普遍的常识,但是有些特殊情况,那就是如果经过逃逸分析后发现,一个对象并没有逃逸出方法的话,那么就可能被优化为栈上分配了,这样也就无需再堆上分配内存,也无需进行垃圾回收了,这也是常见的堆外存储的技术

逃逸分析概述

如果将堆上对象分配到栈,需要使用逃逸分析手段,这是一种可以有效减缓java程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法,通过逃逸分析,java HotSpot编译器能够分析出一个新的对象引用的使用范围从而决定是否要将这个对象分配到堆上

逃逸分析的基本行为就是分析对象的动态作用域

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

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

判断是否发生了逃逸,就看new的对象实体是否有可能在方法外被调用

逃逸分析:代码优化

使用逃逸分析,编译器可以对代码进行如下优化

  1. 栈上分配,将堆分配转化为栈分配,如果一个对象在子程序中被分配,要使指向该对象的指针永远不会逃逸,对象可能是栈分配的候选,而不是堆分配
  2. 同步消除,如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步
  3. 分离对象或标量替换,有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分,(或全部)可以不存储在内存,而是存储在CPU寄存器中

栈上分配

JIT编译器在编译期间根据逃逸分析结果,发现如果一个对象并没有逃逸出方法的话,就可能被优化为栈上分配,分配完成后,继续在调用栈内执行,最后线程结束,栈空间被回收,局部变量对象也被回收,这样就无需进行垃圾回收了

常见栈上分配的场景:给成员变量赋值,方法返回值,实例引用传递

-DoEscapeAnalysis 关闭逃逸分析 +就是开启逃逸分析

测试 参数 -Xms256m -Xmx256m -XX:-DoEscapeAnalysis -XX:+PrintGCDetails

public class StackAllocation {
    public static void main(String[] args)   {
        long start = System.currentTimeMillis();
        for (int i = 0; i < 10000000; i++) {
            alloc();
        }
        long end = System.currentTimeMillis();
        System.out.println("花费时间"+(end-start)+"ms");

    }
    private static void alloc(){
        User user = new User();
    }
    static class User{

    }
}

结果

[GC (Allocation Failure) [PSYoungGen: 65536K->840K(76288K)] 65536K->848K(251392K), 0.0008785 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[GC (Allocation Failure) [PSYoungGen: 66376K->808K(76288K)] 66384K->816K(251392K), 0.0006700 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
花费时间42ms
Heap
 PSYoungGen      total 76288K, used 32077K [0x00000000fab00000, 0x0000000100000000, 0x0000000100000000)
  eden space 65536K, 47% used [0x00000000fab00000,0x00000000fc9895d0,0x00000000feb00000)
  from space 10752K, 7% used [0x00000000ff580000,0x00000000ff64a020,0x0000000100000000)
  to   space 10752K, 0% used [0x00000000feb00000,0x00000000feb00000,0x00000000ff580000)
 ParOldGen       total 175104K, used 8K [0x00000000f0000000, 0x00000000fab00000, 0x00000000fab00000)
  object space 175104K, 0% used [0x00000000f0000000,0x00000000f0002000,0x00000000fab00000)
 Metaspace       used 3452K, capacity 4500K, committed 4864K, reserved 1056768K
  class space    used 376K, capacity 388K, committed 512K, reserved 1048576K

可以看到发生两次GC,将-XX:-DoEscapeAnalysis关闭逃逸分析改为-XX:+DoEscapeAnalysis开启逃逸分析再来看看

花费时间3ms
Heap
 PSYoungGen      total 76288K, used 6559K [0x00000000fab00000, 0x0000000100000000, 0x0000000100000000)
  eden space 65536K, 10% used [0x00000000fab00000,0x00000000fb167c28,0x00000000feb00000)
  from space 10752K, 0% used [0x00000000ff580000,0x00000000ff580000,0x0000000100000000)
  to   space 10752K, 0% used [0x00000000feb00000,0x00000000feb00000,0x00000000ff580000)
 ParOldGen       total 175104K, used 0K [0x00000000f0000000, 0x00000000fab00000, 0x00000000fab00000)
  object space 175104K, 0% used [0x00000000f0000000,0x00000000f0000000,0x00000000fab00000)
 Metaspace       used 3452K, capacity 4500K, committed 4864K, reserved 1056768K
  class space    used 376K, capacity 388K, committed 512K, reserved 1048576K

没有发生GC,且时间比上面快了很多倍

同步消除

线程同步本身是一个相对耗时的过程,如果逃逸分析能确定一个变量不会逃逸出线程,无法被其他线程访问,那么这个变量的读写肯定不会有竞争对于这个变量实施的同步措施也就可以安全的清除掉

分离对象(标量替换)

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

public class Scalar {
    public static void main(String[] args) {
        showUser();
    }
    public static void showUser(){
        User user = new User(1,"jame");
        System.out.println(user.id+"=="+user.name);
    }
    static class User{
        int id;
        String name;

        public User(int id, String name) {
            this.id = id;
            this.name = name;
        }
    }
}

上面代码经过标量替换后,就会变成

public static void showUser(){
    int id=1;
    String name="jame";
    System.out.println(id+"=="+name);
}

可以看到,经过逃逸分析后,发现并没有逃逸,所以被替换成两个标量,那么这样做有什么好处呢?可以大大减少占用堆内存,因为一旦不需要创建对象了,那么就不需要分配堆内存了

逃逸分析并不成熟

关于逃逸分析论文在1999年就已经发表,但直到JDK1.6才有实现,而且这项技术直到如今也并不是十分成熟,根本原因就是逃逸分析的计算成本非常高,甚至不能保证逃逸分析带来的性能收益会大于它的消耗,如果要百分百确定一个对象是否发生逃逸,需要进行一系列复杂的数据的分析,如果逃逸分析完毕后发现几乎找不到不逃逸的对象,那么这种运行期间耗用的时间就白白浪费了,在HotSpot虚拟机中,并没有做栈上分配的优化,而前面演示栈上优化的原因是因为标量替换的优化带来的

小结

年轻代是对象的诞生,成长的区域,对象一般都会在年轻代的Eden区来创建,老年代放置的一般都是生命周期比较长的对象,通常都是从Survivor区域筛选拷贝过来的java对象,当然也会有特殊情况,普通的对象会被分配到TLAB上,如果对象比较大,JVM会尝试直接分配在Eden区的其他位置上,如果对象太大,完全无法在新生代找到足够的连续空闲空间,JVM会直接分配到老年代

当GC只发生在年轻代中,回收年轻代对象的行为被称为Minor GC 当GC发生在老年代时则被称为Major GC,或者Full GC,但是建议不要混淆在一起,Major GC指单独对老年代进行垃圾回收的操作,而Full GC 指对整个堆进行垃圾回收,一般情况下Minor GC发生的频率要比Major GC 发生的频率高跟多

posted @ 2021-01-30 16:51  Jame!  阅读(236)  评论(0编辑  收藏  举报