06、JVM实战:运行时数据区之堆

4.1 堆的概述

1、 一个jvm实例只存在一个堆内存,一个进程对应一个jvm实例,堆也是jvm内存管理的核心区域;
2、 堆在jvm启动的时候就创建好了,是JVM管理最大的一块内存空间;

1、 堆大小可以调节-Xms初始堆大小-Xmx最大堆大小;
2、 代码说明;

package com.zy.study05;

/**
 * @Author: Zy
 * @Date: 2021/7/29 15:47
 * -Xms20M -Xmx20M
 */
public class HeapSizeTest {
   
     
    public static void main(String[] args) {
   
     
        try {
   
     
            System.out.println("start............");
            Thread.sleep(1000000);
        } catch (InterruptedException e) {
   
     
            e.printStackTrace();
        }finally {
   
     
            System.out.println("end.......");
        }
    }
}
package com.zy.study05;

/**
 * @Author: Zy
 * @Date: 2021/7/29 15:47
 * -Xms10M -Xmx10M
 */
public class HeapSizeTest1 {
   
     
    public static void main(String[] args) {
   
     
        try {
   
     
            System.out.println("start............");
            Thread.sleep(1000000);
        } catch (InterruptedException e) {
   
     
            e.printStackTrace();
        }finally {
   
     
            System.out.println("end.......");
        }
    }
}

分别设置两个进程的jvm堆大小,然后通过jdk自带的jvisualvm来查看堆
*

1、 java虚拟机规范规定,堆可以处于物理上不连续的内存,但在逻辑上应该被视为连续的(物理内存可以与虚拟内存建立映射)
2、 所有的线程共享java堆;

1、 TLAB,堆中划分线程私有的缓冲区提升线程并发性;
3、 java虚拟机规范中对堆的描述是:所有对象实例以及数组都应该在运行时分配在堆上;
4、 数组和对象永远不会存储在栈上,因为栈帧中保存引用,引用指向堆中的对象/数组的实际位置;
5、 方法结束后,堆中的对象不会马上被回收,而是等到GC的时候才会回收;
6、 堆是垃圾回收的重点区域;

4.1.1 内存细分

现代垃圾收集器都是基于分代收集理论设计:
jdk7及以前:
新生区+养老区+永久区
jdk8及以后:
新生区+养老区+元空间
jdk8中主要就是变化了元空间,元空间替代了永久区
使用-XX:+PrintGCDetails 参数打印gc回收明细

package com.zy.study06;

import java.util.Date;

/**
 * @Author: Zy
 * @Date: 2021/7/30 15:04
 * 测试-XX:+PrintGCDetails
 */
public class HeapGCPrintTest {
   
     
    public static void main(String[] args) {
   
     
        int num = 1;
        num = 2;
        Date data = new Date();
        System.out.println(data);
    }
}

*
如图所示:
PSYongGen新生代
ParOldGen老年代
MetaSpace元空间

4.1.2 堆空间大小的设置

-Xms 设置初始堆空间的大小 等价于 -XX:InitialHeapSize
-Xmx 设置堆空间的最大内存 等价于 -XX:MaxHeapSize
通过代码查看堆大小:

package com.zy.study06;

/**
 * @Author: Zy
 * @Date: 2021/7/30 15:34
 * 默认情况下
 *  堆内存初始大小是物理内存的1/64
 *  堆内存最大是物理内存的1/4
 * 手动设置情况下:
 *  -Xms memory start 设置初始内存
 *  -Xmx memory max 设置最大内存
 * 查看内存使用情况的两种方式
 *  1.
 *      jps查看jvm进程实例id
 *      jstat -gc pid 查看jvm堆内存详情
 *  2.
 *      -XX:+PrintGCDetails 使用jvm参数打印出gc详情,从而查看堆内存的大小
 *
 */
public class HeadSetTest {
   
     
    public static void main(String[] args) {
   
     
        long initialMemory = Runtime.getRuntime().totalMemory();
        long maxMemory = Runtime.getRuntime().maxMemory();
        System.out.println("初始化内存大小:"+initialMemory/1024/1024+"M");
        System.out.println("最大内存大小:"+maxMemory/1024/1024+"M");

        try {
   
     
            Thread.sleep(1000000);
        } catch (InterruptedException e) {
   
     
            e.printStackTrace();
        }
    }
}

手动设置-Xms300M -Xmx300M后,输出结果:
*
与设置的不符合的原因是: 这里的统计会统计新生代(伊甸园区+surviver(0/1))+老年代的堆内存大小
新生代中surviver0/1只会计算一个的堆内存

通过jps和jstat的方式查看堆:
*

4.1.3 OOM的举例和说明
package com.zy.study06;

import java.util.ArrayList;
import java.util.Random;

/**
 * @Author: Zy
 * @Date: 2021/7/30 17:05
 * -Xms600M -Xmx600M
 * 死循环创建byte[]数组对象,将堆空间撑满
 */
public class OOMTest {
   
     

    static class ByteTest{
   
     
        byte[] bytes;

        public ByteTest(byte[] bytes) {
   
     
            this.bytes = bytes;
        }
    }

    public static void main(String[] args) {
   
     
        ArrayList<ByteTest> arrayList = new ArrayList<>();
        while (true){
   
     
            try {
   
     
                Thread.sleep(20);
            } catch (InterruptedException e) {
   
     
                e.printStackTrace();
            }
            arrayList.add(new ByteTest(new byte[new Random().nextInt(1024*1024)]));
        }
    }
}

随着堆内存的不断占用,等老年代和新生代都被占用完了,就会出现错误
*

*

内存溢出

4.2 年轻代和老年代

1、 存储在jvm中的java对象分为两类;

1、 生命周期较短,创建和消亡非常快速;
2、 生命周期较长,某些情况下甚至与jvm的生命周期相同,比如数据库连接等对象;
2、 堆区细分为新生代和老年代;

1、 新生代分为伊甸园区(Eden),Survivor0,Survivor1;

1.  Eden:S0:S1默认8:1:1 但是因为**内存自适应策略默认是6:1:1**可以使用-XX:-UseAdaptiveSizePolicy关闭自适应策略(此参数暂时没用)
2.  使用-XX:SurvivorRatio调整 必须显示指定8:1:1 才能达到默认效果

2、 -XX:NewRatio设置新生代和老年代的大小比例(默认1:2老年代2新生代1,一般不用调整);
3、 例子印证第二点;

package com.zy.study07;

/**
 * @Author: Zy
 * @Date: 2021/8/3 11:42
 * -XX:NewRatio 设置老年代:新生代比例 默认2:1
 * -XX:SurvivorRatio 设置新生代中伊甸园区:S0:S1比例 默认8:1:1
 * -XX:-UseAdaptSizePolicy 禁用新生代中伊甸园区:S0:S1比例内存自适应策略(暂时无用)
 */
public class HeapEdenSurvivorRadioTest {
   
     

    public static void main(String[] args) {
   
     
        try {
   
     
            Thread.sleep(10000000);
        } catch (InterruptedException e) {
   
     
            e.printStackTrace();
        }
    }
}
  1. 设置-Xms600M -Xmx600M参数后,查看新生代:老年代
     1. ![image.png](https://zy-typora-image.oss-cn-beijing.aliyuncs.com/1627976821803-1582f3f9-ba19-4e3d-b632-cabc113948cf.png)
     1. 可以看到 老年代:新生代=400:150+25+25 =2:1
     1. 可以看到 Eden:S0:S1 = 150:25:25 = 6:1:1

4.3 对象分配的过程

4.3.1 对象分配的一般过程

1、 对象创建都在eden区中,每当eden占用达到100%时,触发一次YGC(younggc),此时会将eden中的不使用的对象进行垃圾回收,然后将仍要使用的对象放到to区,并将对象的age+1;

1、 s0和s1是动态的from区和to区,谁为空谁就是to区,这两个区起到一个缓冲的作用;
2、 举例第一次将对象在s0区,此时s0区就是to区,当触发第二次YGC时,此时s0不为空,s1为空,此时s0就变成了from区,s1就变成了to区,将s0中的对象转到s1并将age+1;
2、 s0/s1区不会触发YGC,而是在eden触发YGC的情况下,才会一并对s0/s1区进行垃圾回收;
3、 正常情况下当对象的age=15时,会将对象移入oldgen(老年代)中,15为默认参数,可以通过jvm参数调整;
4、 gc多为新生代采集,很少对老年代采集,基本不对永久代/元空间采集;

图解

*

4.3.2 对象分配的特殊过程

对象分配也是存在特殊情况的,即可能直接从伊甸园区到达老年代等情况

1、 对象创建判断Eden是否放下,如果能放下,就在Eden为对象分配内存,如果放不下,开始触发YGC;
2、 YGC分为两步;

1、 垃圾回收S0/S1区,并将Eden区的不回收对象放到S0/S1区,此时也要进行判断,S0/S1是否能放下,如果能放下就放,放不下就晋升老年代;
2、 垃圾回收Eden区,后再次进行判断Eden是否能放下,如果能放下就放,放不下就晋升老年代;
3、 在晋升老年代时,也要进行判断,如果老年代放不下,就会触发FGC;
4、 FGC后,再次判断老年代是否能放下,如果能放下就放,放不下就报异常OOM;

图解

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Oj1vUWjm-1639490080867)(https://zy-typora-image.oss-cn-beijing.aliyuncs.com/yuque_diagram (1)].jpg)

4.3.3 Minor GC, Major Gc和Full Gc

JVM的gc分为部分收集和整堆收集

1、 部分收集;

1、 MinorGC/YoungGC/YGC,只针对新生代(Eden/S0/S1)的垃圾回收;
2、 MajorGC/OldGC,只针对老年代(OldGen)的垃圾回收CMSGC;
3、 MixedGC,混合收集只针对整个新生代以及部分老年代的垃圾回收只有G1GC有;
2、 整堆收集;

1、 FullGC对整个堆空间以及方法区进行垃圾回收;

MinorGC:

1、 当年轻代占用100%的时候会触发MinorGc,此处年轻代主要指的是Eden,并不是Survivor(S0/S1),也就是只有Eden区占用满的时候才会触发YGC垃圾回收,而Survivor并不会触发YGC;
2、 Java对象大部分都是朝生夕死的对象,所以MinGc非常频繁;
3、 MinorGC会触发STW,暂停其他用户线程,等垃圾回收完毕,才能恢复其他用户线程,但是MinorGC一般都比较快,并不会占用很多时间.;

MajorGC:

1、 当老年代占用100%的时候就会触发MajorGC;
2、 一般情况下MajorGC都会伴随着一次MinorGC(并非必然情况,有些垃圾回收器就会直接进行MajorGC),也就是说当回收老年代的对象时,一般都会回收一次新生代对象;
3、 MajorGC速度要比MinorGC慢10倍以上,STW的时间也就会更长,所以要尽量避免MajorGC;
4、 MajorGC之后如果空间还是不足,就会发生OOM;

FullGC:

1、 当出现一下几种情况时会调用FullGC;

1、 System.gc()时;
2、 老年代空间不足;

1.  MinorGC后进入老年代的内存>老年代可用内存
2.  Eden向S0/S1复制时,对象大小>to区的可用大小,即将其转入老年代,此时对象大小>老年代可用内存时,会触发FullGC

3、 方法区空间不足;
2、 尽量避免FullGC;

代码查看gc情况:

package com.zy.study08;

/**
 * @Author: Zy
 * @Date: 2021/8/26 17:30
 * 测试打印 YGC FullGC
 * 设置堆内存最大为10M -Xms10M -Xmx10M
 * 打印gc详细信息 -XX:+PrintGCDetails
 */
public class GcTest {
   
     
    public static void main(String[] args) {
   
     
        int i = 0;
        String temp = "test";
        try {
   
     
            while (true){
   
     
                temp += temp;
                i++;
            }
        }catch (Throwable e){
   
     
            e.printStackTrace();
            System.out.println(i);
        }
    }
}

执行结果如图:
*
查看内存溢出情况,多次YGC后,会进行一次FullGC,最终OOM之前会执行一次FullGC,但是由于对象大小超过老年代可用大小,所以OOM

4.4 堆空间分代思想

分代主要为了将不同生命周期的对象区分开,便于GC回收,提高性能
新生代主要存放生命周期较短的对象,GC主要针对这块区域
老年代存放生命周期较长的对象,这块区域的GC就不会太频繁

4.5 内存分配策略

1、 优先分配Eden区,即对象多直接放在新生代;
2、 大对象直接放到老年代,即新生代放不下的情况;

1、 因此要避免大对象的创建;
2、 避免大对象创建完后gc;
3、 长期存活的对象放到老年代,s0/s1区对象年龄超过阈值则放到老年代;
4、 动态对象年龄判断如果s0/s1区内的相同年龄的对象的大小总和超过survivor区的一半大小,则将>=该年龄的对象都放入老年代;
5、 空间分配担保;

1、 参数-XX:HandlePromotionFailure;
2、 当s0/s1内的对象超过年龄阈值时,能放入老年代,这个操作是需要老年代来为survivor区提供担保的;

4.6 TLAB(Thread Local Allocation Buffer)

TLAB是堆内线程私有空间,正常来说堆是所有线程共享的,但是为了加快内存的分配速度,所以增加了TLAB机制来存放线程私有的对象.
TLAB是JVM在Eden区分配的一块线程私有的缓存区域,默认占用Eden区的1%大小,可以使用JVM参数-XX:修改
使用TLAB的作用:
jvm在并发环境下分配堆空间是有危险的,所以要通过加锁机制保证数据的有效性,有了TLAB后就会优先将TLAB作为内存分配的空间.

4.7 堆空间参数总结

oracle官网文档地址: https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html
-XX:printFlagsInitial

参数名 作用
-XX:+PrintFlagsInitial 查看所有参数初始默认值
-XX:+PrintFlagsFinal 查看所有参数最终值(可能会修改,显示修改后的值,不再是初始值)
-Xms 初始堆空间内存
-Xmx 最大堆空间内存
-Xmn 设置新生代的大小(初始值以及最大值)
-XX:NewRatio 配置新生代与老年代的比例 默认1:2
-XX:SurvivorRatio 设置新生代中Eden与Survivor的比例 默认8:1:1 (实际6:1:1)
-XX:MaxTenuringThreshold 设置新生代垃圾的最大年龄(阈值),超过此阈值进入老年代,默认15
-XX:+PrintGCDetails 输出详细gc日志
-XX:+PrintGC/-verbose:gc 打印简要gc日志

|-XX:HandlePromotionFailure | 是否设置空间分配担保
解释:
YGC之前,会先判断老年代可用空间是否大于要回收对象的总大小:
大于:进行YGC/MinorGC
小于:判断-XX:HandlePromotionFailure参数
此参数为false:进行FullGC
此参数为true:则判断此次回收对象大小是否大于历代对象晋升到老年代的平均大小
大于:进行YGC(冒险进行,有可能失败)
小于:FullGC
上述操作在jdk7以前
jdk7以后此参数不在生效,只要老年代可用空间大于要回收对象的总小于 或者 **此次回收对象大小是否大于历代对象晋升到老年代的平均大小,**就可以进行YGC |

4.8 堆是分配对象的唯一选择?

经过逃逸分析后的对象,如果对象没有逃逸出方法的话,就可以将对象进行栈上分配,分配到栈上的对象就不用考虑gc了,由此提高性能
TAOBAOJVM 创新了GCIH(GC invisible heap)技术实现off_heap,就是将生命周期较长的对象从heap中移到堆外,GC不用管理GCIH中的对象,达到降低gc频率,提高性能的效果
由上也可以看出对jvm的优化主要是针对gc,而对gc的优化主要是降低gc频率,提高gc效率

4.8.1 逃逸分析

逃逸分析是将对象分配到栈上要用到的技术
要想把对象分配到栈上,首先要保证对象不会发生逃逸:
**逃逸分析: **只在方法内部使用的对象就被称为不会逃逸,在其他地方被引用的对象就被称之会发生逃逸
jdk6 之后就默认开启了逃逸分析

结论:能使用方法局部变量 就不要使用全部变量,避免发生逃逸

4.8.2 代码优化

不会发生逃逸的对象就可以进行编译优化

1、 栈上分配

1、 逃逸分析后,就可以将没有发生逃逸的对象由堆分配转换为栈分配,由栈分配后就不存在gc问题,当方法执行完,方法出栈后,栈空间被回收,局部变量也被随之回收;

package com.zy.study09;

import com.zy.study08.GcTest;

/**
 * @Author: Zy
 * @Date: 2021/8/30 20:20
 * 逃逸分析后不会逃逸的对象就可以进行栈上分配
 * 测试栈上分配
 * -Xms1G -Xmx1G -XX:-DoEscapeAnalysis 开启/关闭逃逸分析 -XX:+PrintGCDetails
 */
public class StackAllocationTest {
   
     

    public static void testCreateClass(){
   
     
        GcTest gcTest = new GcTest();
    }
    public static void main(String[] args) throws InterruptedException {
   
     
        long startTime = System.currentTimeMillis();

        for (int i = 0; i < 10000000; i++) {
   
     
            testCreateClass();
        }

        long endTime = System.currentTimeMillis();

        System.out.println(endTime-startTime+"ms");

        // 线程sleep
        Thread.sleep(1000000);
    }
}

可以看到,未开启逃逸分析的情况下,创建的1千万个对象都在堆中:
*
而开启了逃逸分析之后:*
执行效率大幅度提升,并且没有那么多的对象存放在堆中了

1、 **同步省略:**如果一个对象被发现只能被一个线程访问到,那么这个对象就可以考虑不用同步操作了.;

1、 即如果一个加锁的对象被发现其实只有一个线程能访问,那么会在运行时jvm会将这个锁去掉,所以同步省略也叫锁消除
2、 分离对象或标量替换:有的对象如果被发现不需要作为一个连续的内存结构存储也可以被访问到,那么这个对象的全部/部分就可以不存储在内存中,而是存储在cpu寄存器(对java而言,是栈,因为java是基于栈的)中;;

1、 标量:基本数据类型;
2、 聚合量:引用数据类型;
3、 标量替换:将聚合量替换为标量,如果一个对象不发生逃逸,那么就可以将这个对象替换成标量,存储在栈帧的局部变量表中,减少堆内存的占用;
4、 开启jvm参数:-XX:+EliminateAllocations;

package com.zy.study09;

/**
 * @Author: Zy
 * @Date: 2021/8/30 21:00
 * 标量替换测试
 * jvm参数 -Xms256M -Xmx256M -XX:+PrintGCDetails -XX:-EliminateAllocations 开启/关闭标量替换
 */
public class ScalarReplaceTest {
   
     

    static class Point{
   
     
        int x;
        int y;

        public Point(int x, int y) {
   
     
            this.x = x;
            this.y = y;
        }
    }

    public static void main(String[] args) throws InterruptedException {
   
     
        long startTime = System.currentTimeMillis();

        for (int i = 0; i < 10000000; i++) {
   
     
            allocation();
        }

        long endTime = System.currentTimeMillis();

        System.out.println(endTime-startTime+"ms");

        // 线程sleep
        Thread.sleep(1000000);
    }

    public static void allocation(){
   
     
        Point point = new Point(2,3);
        int a = point.x + point.y;
    }
}

开启/关闭标量替换的效果显著

版权声明:本文不是「本站」原创文章,版权归原作者所有 | 原文地址: