05-堆

05-堆

1.堆的核心概述

视频链接:066-堆空间的概述进程中堆的唯一性

  1. 针对一个 JVM 进程来说是唯一的。也就是一个进程只有一个 JVM 实例一个JVM实例中就有一个运行时数据区Runtime 类实例,饿汉式实现的】,一个运行时数据区只有一个堆和一个方法区
  2. 但是进程包含多个线程,他们是共享同一堆空间的

image-20210210012543832

参数是在编译完成之后,执行字节码文件的时候添加的。

/**
 * -Xms10m -Xmx10m
 * 初始堆空间和最大堆空间都是10M
 */
public class HeapDemo {
    public static void main(String[] args) {
        System.out.println("start...");
        try {
            Thread.sleep(1000000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("end...");
    }
}
/**
 * -Xms20m -Xmx20m
 * 初始堆空间和最大堆空间都是20M
 */
public class HeapDemo1 {
    public static void main(String[] args) {
        System.out.println("start...");
        try {
            Thread.sleep(1000000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("end...");
    }
}

image-20210210013301661

使用 jvisualvm 查看堆内存。

image-20210210013721073

直观感受,这几个加在一起构成了设置的堆空间的大小

image-20210210122135861

image-20210210122227699

1.1 堆与进程

视频链接:067-堆空间关于对象创建和和GC的概述

  1. 一个 JVM 实例只存在一个堆内存,堆也是 Java 内存管理的核心区域。
  2. Java 堆区在 JVM 启动的时候即被创建,其空间大小也就确定了,堆是 JVM 管理的最大一块内存空间【最重要的一块内存区域】,并且堆内存的大小是可以调节的。
  3. Java 虚拟机规范》规定,堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的【逻辑上连续,实现简单,存储高效】。
  4. 所有的线程共享 Java 堆,在这里还可以划分 线程私有 的缓冲区( Thread Local Allocation BufferTLAB)【共享数据,线程安全问题,需要使用加锁同步解决,每个线程中分出一块 TLAB,并发性更好】。
  5. Java 虚拟机规范》中对 Java 堆的描述是:所有的对象实例以及数组都应当在运行时分配在堆上。( The heap is the run-time data area from which memory for all class instances and arrays is allocated
    • 从实际使用角度看:几乎almost】所有的对象实例都在堆分配内存,但并非全部。因为还有一些对象是在栈上分配的(逃逸分析,标量替换)
  6. 数组和对象可能永远不会存储在栈上(不一定),因为栈帧中保存引用,这个引用指向对象或者数组在堆中的位置。
  7. 在方法结束后,堆中的对象不会马上被移除【方法执行结束局部变量出栈,引用指针没有了,这时候这两个对象就是垃圾了,但是实际上被认为是垃圾是在 GC 阶段进行判断的,堆空间还是该放就放,垃圾回收的时候才进行回收,为什么不立刻回收呢?这涉及到用户线程和垃圾回收线程的并发执行问题】,仅仅在垃圾收集的时候才会被移除。
    • 也就是触发了 GC 的时候,才会进行回收
    • 如果堆中对象马上被回收,那么用户线程就会收到影响,因为有 stop the word
  8. 堆,是 GCGarbage Collection,垃圾收集器)执行垃圾回收的重点区域。

随着 JVM 的迭代升级,原来一些绝对的事情,在后续版本中也开始有了特例,变的不再那么绝对。

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 sl = new SimpleHeap(1);
        SimpleHeap s2 = new SimpleHeap(2);

        // 数组
        int[] arr = new int[10];

        Object[] arr1 = new Object[10];
    }
}
 0 new #11 <com/whut/java/SimpleHeap>
 3 dup
 4 iconst_1
 5 invokespecial #12 <com/whut/java/SimpleHeap.<init>>
 8 astore_1
 9 new #11 <com/whut/java/SimpleHeap>
12 dup
13 iconst_2
14 invokespecial #12 <com/whut/java/SimpleHeap.<init>>
17 astore_2
18 bipush 10
20 newarray 10 (int)
22 astore_3
23 bipush 10
25 anewarray #13 <java/lang/Object>
28 astore 4
30 return

栈里面每个方法内部有局部变量表,对应堆中的一个实例,对应方法区中方法的具体实现

image-20210210014628146

1.2 堆内存细分

视频链接:068-堆的细分内存结构

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

  1. Java7 及之前堆内存逻辑上分为三部分:新生区 + 养老区 + 永久区
    • Young Generation Space 新生区 Young/New
      • 又被划分为 Eden 区和 Survivor
    • Old generation space 养老区 Old/Tenure
    • Permanent Space 永久区 Perm
  2. Java 8 及之后堆内存逻辑上分为三部分:新生区 + 养老区 + 元空间
    • Young Generation Space 新生区,又被划分为 Eden 区和 Survivor
    • Old generation space 养老区
    • Meta Space 元空间 Meta

约定:新生区 <–> 新生代 <–> 年轻代 、 养老区 <–> 老年区 <–> 老年代、 永久区 <–> 永久代

image-20210210130456959

堆空间内部结构,JDK1.8 之前从 永久代 替换成 元空间【方法区具体的落地实现】

image-20210210130829819

2.JVisualVM可视化查看堆内存

运行下面代码

public class HeapDemo {
    public static void main(String[] args) {
        System.out.println("start...");
        try {
            TimeUnit.MINUTES.sleep(30);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("end...");
    }

}

1、双击 jdk 目录下的这个文件 jvisualvm.exe 或者直接使用命令行 jvisualvm

2、工具 -> 插件 -> 安装 Visual GC 插件

image-20210210121812706

3、运行上面的代码

image-20210210130951306

运行 SimpleDemo

-Xms10m -Xmx10m -XX:+PrintGCDetails
Heap
 PSYoungGen      total 2560K, used 1890K [0x00000000ffd00000, 0x0000000100000000, 0x0000000100000000)
  eden space 2048K, 92% used [0x00000000ffd00000,0x00000000ffed88d8,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 3443K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 376K, capacity 388K, committed 512K, reserved 1048576K

jdk7 中的

image-20210210131519020

3.设置堆内存大小与OOM

视频链接:069-堆空间大小的设置和查看

3.1 设置堆内存

  1. Java 堆区用于存储 Java 对象实例,那么堆的大小在 JVM 启动时就已经设定好了,大家可以通过选项 -Xms-Xmx 来进行设置。
    • -Xms 用于表示堆区的起始内存,等价于 -XX:InitialHeapSize
    • -Xmx 则用于表示堆区的最大内存,等价于 -XX:MaxHeapSize
  2. 一旦堆区中的内存大小超过 -Xmx 所指定的最大内存时,将会抛出 OutofMemoryError 异常。
  3. 通常会将 -Xms-Xmx 两个参数配置相同的值
    • 原因:假设两个不一样,初始内存小,最大内存大。在运行期间如果堆内存不够用了,会一直扩容直到最大内存。如果内存够用且多了,也会不断的缩容释放。频繁的扩容和释放造成不必要的压力,避免在 GC 之后调整堆内存给服务器带来压力。
    • 如果两个设置一样的就少了频繁扩容和缩容的步骤。内存不够了就直接报 OOM
  4. 默认情况下:
    • 初始内存大小:物理电脑内存大小/64
    • 最大内存大小:物理电脑内存大小/4
/**
 * 1. 设置堆空间大小的参数
 * -Xms 用来设置堆空间(年轻代+老年代)的初始内存大小
 *      -X 是jvm的运行参数
 *      ms 是memory start
 * -Xmx 用来设置堆空间(年轻代+老年代)的最大内存大小
 *
 * 2. 默认堆空间的大小
 *    初始内存大小:物理电脑内存大小 / 64
 *             最大内存大小:物理电脑内存大小 / 4
 * 3. 手动设置:-Xms600m -Xmx600m(避免动态调整大小造成浪费)
 *    开发中建议将初始堆内存和最大的堆内存设置成相同的值。
 *
 * 4. 查看设置的参数:方式一:jps / jstat -gc 进程id
 *                    方式二:-XX:+PrintGCDetails
 */
public class HeapSpaceInitial {
    public static void main(String[] args) {

        //返回Java虚拟机中的堆内存总量
        long initialMemory = Runtime.getRuntime().totalMemory() / 1024 / 1024;
        //返回Java虚拟机试图使用的最大堆内存量
        long maxMemory = Runtime.getRuntime().maxMemory() / 1024 / 1024;

        System.out.println("-Xms : " + initialMemory + "M");
        System.out.println("-Xmx : " + maxMemory + "M");

        System.out.println("系统内存大小为:" + initialMemory * 64.0 / 1024 + "G");
        System.out.println("系统内存大小为:" + maxMemory * 4.0 / 1024 + "G");

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

输出结果:

-Xms : 245M
-Xmx : 3620M
系统内存大小为:15.3125G
系统内存大小为:14.140625G

1、笔者电脑内存大小是 16G ,不足 16G 的原因是操作系统自身还占据了一些。

2、两个不一样的原因待会再说

设置下参数再看

-Xms600m -Xmx600m
public class HeapSpaceInitial {
    public static void main(String[] args) {

        //返回Java虚拟机中的堆内存总量
        long initialMemory = Runtime.getRuntime().totalMemory() / 1024 / 1024;
        //返回Java虚拟机试图使用的最大堆内存量
        long maxMemory = Runtime.getRuntime().maxMemory() / 1024 / 1024;

        System.out.println("-Xms : " + initialMemory + "M");
        System.out.println("-Xmx : " + maxMemory + "M");


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

输出结果:

-Xms : 575M
-Xmx : 575M

为什么会少 25M

方式一: jps / jstat -gc 进程id

image-20210210132832861

jps:查看 java 进程

jstat :查看某进程内存使用情况

SOC: S0区总共容量
S1C: S1区总共容量
S0U: S0区使用的量
S1U: S1区使用的量
EC: 伊甸园区总共容量
EU: 伊甸园区使用的量
OC: 老年代总共容量
OU: 老年代使用的量
25600+25600+153600+409600 = 614400K
614400 /1024 = 600M

25600+153600+409600 = 588800K
588800 /1024 = 575M

并非巧合,s0 区和 s1 区两个只有一个能使用,另一个用不了(后面会详解)【始终有一个是空的,涉及到垃圾回收算法】

方式二:-XX:+PrintGCDetails

-Xms : 575M
-Xmx : 575M
Heap
 PSYoungGen      total 179200K, used 9216K [0x00000000f3800000, 0x0000000100000000, 0x0000000100000000)
  eden space 153600K, 6% used [0x00000000f3800000,0x00000000f41001a0,0x00000000fce00000)
  from space 25600K, 0% used [0x00000000fe700000,0x00000000fe700000,0x0000000100000000)
  to   space 25600K, 0% used [0x00000000fce00000,0x00000000fce00000,0x00000000fe700000)
 ParOldGen       total 409600K, used 0K [0x00000000da800000, 0x00000000f3800000, 0x00000000f3800000)
  object space 409600K, 0% used [0x00000000da800000,0x00000000da800000,0x00000000f3800000)
 Metaspace       used 3321K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 362K, capacity 388K, committed 512K, reserved 1048576K

image-20210210133352971

179200 = 153600 + 25600 只加了1个

3.2 OOM

视频链接:070-OOM的说明与举例

image-20210210133621106

面试中的异常是一个广义上的概念,包括以前说的 ExceptionError

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

class Picture{
    private byte[] pixels;

    public Picture(int length) {
        this.pixels = new byte[length];
    }
}

1、设置虚拟机参数

-Xms600m -Xmx600m

最终输出结果:

Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
    at com.whut.java.Picture.<init>(OOMTest.java:24)
    at com.whut.java.OOMTest.main(OOMTest.java:15)

2、堆内存变化图

image-20210210134012002

old 区满了就会报溢出,每个对象都占用着,无法 GC

3、原因:大对象导致堆内存溢出

image-20210210134236824

4.年轻代与老年代

视频链接:071-新生代与老年代中相关参数的设置

1、存储在 JVM 中的 Java 对象可以被划分为两类:

  • 一类是生命周期较短的瞬时对象,这类对象的创建和消亡都非常迅速
  • 另外一类对象的生命周期却非常长,在某些极端的情况下还能够与 JVM 的生命周期保持一致【比如一些连接的操作】

2、Java 堆区进一步细分的话,可以划分为年轻代( YoungGen )和老年代( oldGen

3、其中年轻代又可以划分为 Eden 空间、Survivor0 空间和 Survivor1 空间(有时也叫做 from 区、to 区)

image-20210210134445941

image-20210210135053926

一般情况下不需要调整,但是当生命周期长的对象很多的时候建议把 Old 区(老年代)调大一点

  • 配置新生代与老年代在堆结构的占比
    • 默认 -XX:NewRatio=2,表示新生代占 1,老年代占 2,新生代占整个堆的 1/3
    • 可以修改 -XX:NewRatio=4,表示新生代占 1,老年代占 4,新生代占整个堆的 1/5
  1. HotSpot 中,Eden 空间和另外两个 survivor 空间缺省所占的比例是 8:1:1【新生区】
  2. 当然开发人员可以通过选项 -XX:SurvivorRatio 调整这个空间比例。比如 -XX:SurvivorRatio=8
  3. 几乎所有Java 对象都是在 Eden 区被 new 出来的。【如果这个对象非常大 Eden 区装不下了】
  4. 绝大部分的 Java 对象的销毁都在新生代进行了(有些大的对象在 Eden 区无法存储时候,将直接进入老年代),IBM 公司的专门研究表明,新生代中 80% 的对象都是 朝生夕死 的。
  5. 可以使用选项 -Xmn 设置新生代最大内存大小,但这个参数一般使用默认值就可以了。

image-20210210134918473

可见实际上新生区的比例是 6:1:1 ,要是 8:1:1 必须显式指定

image-20210210140213271

-XX:NewRatio : 设置新生代与老年代的比例。默认值是2

查看

C:\Users\DELL>jps
7556 Launcher
20520
4472 Main
5688 RemoteMavenServer36
6776 EdenSurvivorTest   # 这一个进程
7852 Jps

C:\Users\DELL>jinfo -flag NewRatio 6776
-XX:NewRatio=2

C:\Users\DELL>jinfo -flag SurvivorRatio 6776
-XX:SurvivorRatio=8

image-20210210140420482

/**
 * -Xms600m -Xmx600m
 *
 * -XX:NewRatio : 设置新生代与老年代的比例。默认值是2.
 * -XX:SurvivorRatio :设置新生代中Eden区与Survivor区的比例。默认值是8
 * -XX:-UseAdaptiveSizePolicy :关闭自适应的内存分配策略  (暂时用不到)
 * -Xmn:设置新生代的空间的大小。 (一般不设置,如果设置了的话优先级高于NewRatio)
 *
 */
public class EdenSurvivorTest {
    public static void main(String[] args) {
        System.out.println("我只是来打个酱油~");
        try {
            Thread.sleep(1000000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

5.图解对象分配过程

视频链接:072-图解对象分配的一般过程

为新对象分配内存是一件非常严谨和复杂的任务,JVM 的设计者们不仅需要考虑内存如何分配、在哪里分配等问题,并且由于内存分配算法内存回收算法密切相关,所以还需要考虑 GC 执行完内存回收后是否会在内存空间中产生内存碎片。

具体过程

  1. new 的对象先放伊甸园区。此区有大小限制。
  2. 当伊甸园的空间填满时,程序又需要创建对象,JVM 的垃圾回收器将对伊甸园区进行垃圾回收( MinorGC ),将伊甸园区中的不再被其他对象所引用的对象进行销毁。再加载新的对象放到伊甸园区。
  3. 然后将伊甸园中的剩余对象移动到 幸存者0区
  4. 如果再次触发垃圾回收,此时上次幸存下来的放到幸存者0区的,如果没有回收,就会放到幸存者1区。
  5. 如果再次经历垃圾回收,此时会重新放回幸存者0区,接着再去幸存者1区。
  6. 啥时候能去养老区呢?可以设置次数。默认是 15 次。可以设置新生区进入养老区的年龄限制,设置 JVM 参数:-XX:MaxTenuringThreshold=N 进行设置
  7. 在养老区,相对悠闲。当养老区内存不足时,再次触发 GCMajor GC,进行养老区的内存清理
  8. 若养老区执行了 Major GC 之后,发现依然无法进行对象的保存,就会产生 OOM 异常。

5.1 图解对象分配(一般情况)

1、我们创建的对象,一般都是存放在 Eden 区的,当我们Eden区满了后,就会触发GC操作,一般被称为 YGC / Minor GC 操作【注意:幸存者区满的时候不会触发 YGC,幸存者区是一个被动回收,也即 Eden 区满的时候会同时回收 Eden 区和Survivor 区,一起进行回收,如果 survivor 满了就可以直接晋升到老年代,有的一上来就直接去老年代了】

image-20210210140736580

2、当我们进行一次垃圾收集后,红色的对象【垃圾】将会被回收,而绿色的独享还被占用着,被存放到 S0(Survivor From) 区。同时我们给每个对象设置了一个年龄计数器,经过一次回收后还存在的对象,将其年龄1

3、同时 Eden 区继续存放对象,当 Eden再次存满的时候,又会触发一个 MinorGC 操作,此时 GC 将会把 EdenSurvivor From(S0) 中的对象进行一次垃圾收集,把存活的对象放到 Survivor To(S1) 区,同时让存活的对象年龄 +1。与此同时 S0 中的两个对象也要进行判断,这两个对象还需要被使用吗?如果还被占用了,还不能被销毁,此时就把这两个对象再挪到 S1 区,并且把年龄 +1 变成 2,此时 S1 就变成了 from 区,S0 变成了 to 区,即每次交换之后方向反转。【谁空谁是 to ,下一次往哪里放】

下一次再进行 GC 的时候,

1、这一次的 S0 区为空,所以成为下一次 GCS1

2、这一次的 S1 区则成为下一次 GCS0

3、也就是说 S0 区和 S1 区在互相转换。

image-20210210141306254

4、我们继续不断的进行对象生成和垃圾回收,当 Survivor 中的对象的年龄达到 15 的时候,将会触发一次 Promotion 晋升的操作,也就是将年轻代中的对象晋升到老年代中

image-20210210142034454

针对幸存者s0,s1区的总结:复制之后有交换,谁空谁是to

关于垃圾回收:频繁在新生区收集很少在养老区收集,几乎不在永久区/元空间收集。80%对象都在新生代被销毁了。

5.2 特殊情况说明

视频链接:073-对象分配的特殊情况

对象分配的特殊情况

  1. 如果来了一个新对象,先看看 Eden 是否放的下?
    • 如果 Eden 放得下,则直接放到 Eden
    • 如果 Eden 放不下,则触发 YGC ,执行垃圾回收,看看还能不能放下?
  2. 将对象放到老年区又有两种情况:
    • 如果 Eden 执行了 YGC 还是无法放不下该对象,那没得办法,只能说明是超大对象,只能直接放到老年代
    • 那万一老年代都放不下,则先触发 FullGC ,再看看能不能放下,放得下最好,但如果还是放不下,那只能报 OOM
  3. 如果 Eden 区满了,将对象往幸存区拷贝时,发现幸存区放不下啦,那只能便宜了某些新对象,让他们直接晋升至老年区

image-20210210143557240

视频链接:074-代码举 JVisualVM例与演示对象的分配过程


Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
    at com.whut.java1.HeapInstanceTest.<init>(HeapInstanceTest.java:10)
    at com.whut.java1.HeapInstanceTest.main(HeapInstanceTest.java:15)
    
// 报在老年代
// 新生代中的对象要经过YGC之后保存到老年代放不下了出现了OOM

image-20210210145030815

5.3 常用调优工具

视频链接:075-常用调优工具概述与Jprofiler的演示

  1. JDK命令行
  2. Eclipse:Memory Analyzer Tool
  3. Jconsole
  4. Visual VM(实时监控,推荐)
  5. Jprofiler(IDEA插件)
  6. Java Flight Recorder(实时监控)
  7. GCViewer
  8. GCEasy

6.GC分类

视频链接:076-MinorGC、 MajorGC、FullGC和的对比

【调优:减少 GC 的次数】

  1. 我们都知道,JVM 的调优的一个环节,也就是垃圾收集,我们需要尽量的避免垃圾回收,因为在垃圾回收的过程中,容易出现 STWStop the World )的问题,而 Major GC 和 Full GC出现STW的时间,是Minor GC的10倍以上
  2. JVM 在进行 GC 时,并非每次都对上面三个内存区域一起回收的,大部分时候回收的都是指新生代。针对 Hotspot VM 的实现,它里面的 GC 按照回收区域又分为两大种类型:一种是部分收集Partial GC ),一种是整堆收集FullGC
  • 部分收集:不是完整收集整个 Java 堆的垃圾收集。其中又分为:
    • 新生代收集Minor GC/Young GC ):只是新生代Eden,s0,s1 )的垃圾收集
    • 老年代收集Major GC/Old GC ):只是老年代的垃圾收集
    • 目前,只有 CMS GC 会有单独收集老年代的行为。
    • 注意,很多时候 Major GC 会和 Full GC 混淆使用,需要具体分辨是老年代回收【major GC】还是整堆回收 【full GC】。
    • 混合收集( Mixed GC ):收集整个新生代以及部分老年代的垃圾收集。目前,只有 G1 GC 会有这种行为
  • 整堆收集Full GC ):收集整个 java 堆和方法区的垃圾收集。

由于历史原因,外界各种解读,majorGCFull GC 有些混淆。

6.1 Young GC

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

  1. 年轻代空间不足时,就会触发 Minor GC,这里的年轻代满指的是 Eden 代满。Survivor 满不会主动引发 GC,在 Eden 区满的时候,会顺带触发 s0 区的 GC ,也就是被动触发 GC(每次 Minor GC 会清理年轻代的内存)
  2. 因为 Java 对象大多都具备朝生夕灭的特性,所以 Minor GC 非常频繁,一般回收速度也比较快。这一定义既清晰又易于理解。
  3. Minor GC 会引发 STW(Stop The World),暂停其它用户的线程,等垃圾回收结束,用户线程才恢复运行【并发】

image-20210210151138656

6.2 Major/Full GC

Full GC有争议,后续详解两者区别,暂时先看着

老年代GC(MajorGC)触发机制

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

Full GC 触发机制(后面细讲)

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

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

说明:Full GC 是开发或调优中尽量要避免的。这样 STW 时间会短一些

6.3 GC日志分析

视频链接:077-GC举例与日志分析

/**
 * 测试MinorGC 、 MajorGC、FullGC
 * -Xms9m -Xmx9m -XX:+PrintGCDetails
 */
public class GCTest {
    public static void main(String[] args) {
        int i = 0;
        try {
            List<String> list = new ArrayList<>();
            String a = "atguigu.com";
            while (true) {
                list.add(a);
                a = a + a;
                i++;
            }
        } catch (Throwable t) {
            t.printStackTrace();
            System.out.println("遍历次数为:" + i);
        }
    }
}

输出:

[GC (Allocation Failure) [PSYoungGen: 2037K->504K(2560K)] 2037K->728K(9728K), 0.0455865 secs] [Times: user=0.00 sys=0.00, real=0.06 secs] 
[GC (Allocation Failure) [PSYoungGen: 2246K->496K(2560K)] 2470K->1506K(9728K), 0.0009094 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[GC (Allocation Failure) [PSYoungGen: 2294K->488K(2560K)] 3305K->2210K(9728K), 0.0009568 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[GC (Allocation Failure) [PSYoungGen: 1231K->488K(2560K)] 7177K->6434K(9728K), 0.0005594 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[GC (Allocation Failure) [PSYoungGen: 488K->472K(2560K)] 6434K->6418K(9728K), 0.0005890 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (Allocation Failure) [PSYoungGen: 472K->0K(2560K)] [ParOldGen: 5946K->4944K(7168K)] 6418K->4944K(9728K), [Metaspace: 3492K->3492K(1056768K)], 0.0045270 secs] [Times: user=0.00 sys=0.00, real=0.01 secs] 
[GC (Allocation Failure) [PSYoungGen: 0K->0K(1536K)] 4944K->4944K(8704K), 0.0004954 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (Allocation Failure) java.lang.OutOfMemoryError: Java heap space
    at java.util.Arrays.copyOf(Arrays.java:3332)
    at java.lang.AbstractStringBuilder.ensureCapacityInternal(AbstractStringBuilder.java:124)
    at java.lang.AbstractStringBuilder.append(AbstractStringBuilder.java:448)
    at java.lang.StringBuilder.append(StringBuilder.java:136)
    at com.atguigu.java1.GCTest.main(GCTest.java:20)
[PSYoungGen: 0K->0K(1536K)] [ParOldGen: 4944K->4877K(7168K)] 4944K->4877K(8704K), [Metaspace: 3492K->3492K(1056768K)], 0.0076061 secs] [Times: user=0.00 sys=0.02, real=0.01 secs] 
遍历次数为:16
Heap
 PSYoungGen      total 1536K, used 60K [0x00000000ffd00000, 0x0000000100000000, 0x0000000100000000)
  eden space 1024K, 5% used [0x00000000ffd00000,0x00000000ffd0f058,0x00000000ffe00000)
  from space 512K, 0% used [0x00000000fff80000,0x00000000fff80000,0x0000000100000000)
  to   space 1024K, 0% used [0x00000000ffe00000,0x00000000ffe00000,0x00000000fff00000)
 ParOldGen       total 7168K, used 4877K [0x00000000ff600000, 0x00000000ffd00000, 0x00000000ffd00000)
  object space 7168K, 68% used [0x00000000ff600000,0x00000000ffac3408,0x00000000ffd00000)
 Metaspace       used 3525K, capacity 4502K, committed 4864K, reserved 1056768K
  class space    used 391K, capacity 394K, committed 512K, reserved 1048576KCopy to clipboardErrorCopied
[GC (Allocation Failure) [PSYoungGen: 2037K->504K(2560K)] 2037K->728K(9728K), 0.0455865 secs] [Times: user=0.00 sys=0.00, real=0.06 secs] 
  • [PSYoungGen: 2037K->504K(2560K)]:新生代总空间为 2560K ,当前占用 2037K ,经过垃圾回收后剩余 504K
  • 2037K->728K(9728K):堆内存总空间为 9728K ,当前占用 2037K ,经过垃圾回收后剩余 728K
[GC (Allocation Failure) [PSYoungGen: 2011K->508K(2560K)] 2011K->767K(9728K), 0.0010152 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[GC (Allocation Failure) [PSYoungGen: 2460K->502K(2560K)] 2719K->2133K(9728K), 0.0010823 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[GC (Allocation Failure) [PSYoungGen: 1963K->486K(2560K)] 3595K->2821K(9728K), 0.0009739 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (Ergonomics) [PSYoungGen: 1272K->0K(2560K)] [ParOldGen: 6559K->4871K(7168K)] 7831K->4871K(9728K), [Metaspace: 3437K->3437K(1056768K)], 0.0060562 secs] [Times: user=0.00 sys=0.00, real=0.01 secs] 
[GC (Allocation Failure) [PSYoungGen: 0K->0K(2560K)] 4871K->4871K(9728K), 0.0003055 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (Allocation Failure) [PSYoungGen: 0K->0K(2560K)] [ParOldGen: 4871K->4853K(7168K)] 4871K->4853K(9728K), [Metaspace: 3437K->3437K(1056768K)], 0.0069623 secs] [Times: user=0.03 sys=0.00, real=0.01 secs] 
遍历次数为:16
Heap
 PSYoungGen      total 2560K, used 135K [0x00000000ffd00000, 0x0000000100000000, 0x0000000100000000)
  eden space 2048K, 6% used [0x00000000ffd00000,0x00000000ffd21c28,0x00000000fff00000)
  from space 512K, 0% used [0x00000000fff80000,0x00000000fff80000,0x0000000100000000)
  to   space 512K, 0% used [0x00000000fff00000,0x00000000fff00000,0x00000000fff80000)
 ParOldGen       total 7168K, used 4853K [0x00000000ff600000, 0x00000000ffd00000, 0x00000000ffd00000)
  object space 7168K, 67% used [0x00000000ff600000,0x00000000ffabd698,0x00000000ffd00000)
 Metaspace       used 3470K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 379K, capacity 388K, committed 512K, reserved 1048576K
java.lang.OutOfMemoryError: Java heap space
    at java.util.Arrays.copyOf(Arrays.java:3332)
    at java.lang.AbstractStringBuilder.ensureCapacityInternal(AbstractStringBuilder.java:124)
    at java.lang.AbstractStringBuilder.append(AbstractStringBuilder.java:448)
    at java.lang.StringBuilder.append(StringBuilder.java:136)
    at com.whut.java1.GCTest.main(GCTest.java:18)

7.堆空间分代思想

视频链接:078体会堆空间分代的思想

为什么要把 Java 堆分代?不分代就不能正常工作了吗?经研究,不同对象的生命周期不同。70%-99% 的对象是临时对象

  • 新生代:有 Eden、两块大小相同的 survivor(又称为 from/tos0/s1 )构成,to总为空。
  • 老年代:存放新生代中经历多次 GC 仍然存活的对象。

image-20210210155810712

其实不分代完全可以,分代的唯一理由就是优化GC性能

  • 如果没有分代,那所有的对象都在一块,就如同把一个学校的人都关在一个教室。GC 的时候要找到哪些对象没用,这样就会对堆的所有区域进行扫描。(性能低)
  • 而很多对象都是朝生夕死的,如果分代的话,把新创建的对象放到某一地方,当 GC 的时候先把这块存储“朝生夕死”对象的区域进行回收,这样就会腾出很大的空间出来。(多回收新生代,少回收老年代,性能会提高很多)

image-20210210155850450

8.对象内存分配策略

视频链接:079-总结内存分配策略

  1. 如果对象在 Eden 出生并经过第一次 Minor GC 后仍然存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间中,并将对象年龄设为1。
  2. 对象在 Survivor 区中每熬过一次 MinorGC ,年龄就增加 1 岁,当它的年龄增加到一定程度(默认为15岁,其实每个 JVM 、每个 GC 都有所不同)时,就会被晋升到老年代
  3. 对象晋升老年代的年龄阀值,可以通过选项 -XX:MaxTenuringThreshold 来设置

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

  1. 优先分配到Eden:开发中比较长的字符串或者数组,会直接存在老年代,但是因为新创建的对象都是朝生夕死的,所以这个大对象可能也很快被回收,但是因为老年代触发 Major GC 的次数比 Minor GC 要更少,因此可能回收起来就会比较慢
  2. 大对象直接分配到老年代:尽量避免程序中出现过多的大对象【连续的内存空间】
  3. 长期存活的对象分配到老年代
  4. 动态对象年龄判断:如果 Survivor 区中相同年龄的所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代,无须等到 MaxTenuringThreshold 中要求的年龄。
  5. 空间分配担保-XX:HandlePromotionFailure

一些细节放在后面说

/**
 * 测试:大对象直接进入老年代
 * -Xms60m -Xms60m -XX:NewRatio=2 -XX:SurvivorRatio=8 -XX:+PrintGCDetails
 */
public class YoungOldAreaTest {
    public static void main(String[] args) {
        byte[] buffer = new byte[1024 * 1024 * 20]; // 20m
    }
}
Heap
 PSYoungGen      total 18432K, used 2294K [0x000000076b300000, 0x000000076c700000, 0x00000007c0000000)
  eden space 16384K, 14% used [0x000000076b300000,0x000000076b53db70,0x000000076c300000)
  from space 2048K, 0% used [0x000000076c500000,0x000000076c500000,0x000000076c700000)
  to   space 2048K, 0% used [0x000000076c300000,0x000000076c300000,0x000000076c500000)
 ParOldGen       total 40960K, used 20480K [0x00000006c1800000, 0x00000006c4000000, 0x000000076b300000)
  object space 40960K, 50% used [0x00000006c1800000,0x00000006c2c00010,0x00000006c4000000)
 Metaspace       used 3335K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 364K, capacity 388K, committed 512K, reserved 1048576K

实际输出中没有输出垃圾回收,直接进入老年代 used 20480K

9.TLAB为对象分配内存(保证线程安全)

视频链接:080-堆空间为每个线程分配的TLAB

9.1 为什么有TLAB

  1. 堆区是线程共享区域,任何线程都可以访问到堆区中的共享数据
  2. 由于对象实例的创建在 JVM 中非常频繁,因此在并发环境下从堆区中划分内存空间是线程不安全
  3. 为避免多个线程操作同一地址,需要使用加锁等机制,进而影响分配速度。

9.2 什么是TLAB

TLAB(Thread Local Allocation Buffer)

  1. 内存模型而不是垃圾收集的角度,对 Eden 区域继续进行划分,JVM为每个线程分配了一个私有缓存区域,它包含在Eden空间内
  2. 多线程同时分配内存时,使用 TLAB 可以避免一系列的非线程安全问题,同时还能够提升内存分配的吞吐量,因此我们可以将这种内存分配方式称之为快速分配策略
  3. 据我所知所有 OpenJDK 衍生出来的 JVM 都提供了 TLAB 的设计。

image-20210210162907980

1、每个线程都有一个 TLAB 空间

2、当一个线程的 TLAB 存满时,可以使用公共区域(蓝色)的

/**
 * 测试-XX:UseTLAB参数是否开启的情况:默认情况是开启的[首选的]
 */
public class TLABArgsTest {
    public static void main(String[] args) {
        System.out.println("我只是来打个酱油~");
        try {
            Thread.sleep(1000000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}
C:\Users\DELL>jps
10928 Launcher
15552 TLABArgsTest
15032 Jps
20520
4472 Main
5688 RemoteMavenServer36

C:\Users\DELL>jinfo -flag UseTLAB 15552
-XX:+UseTLAB

9.3 TLAB再说明

  1. 尽管不是所有的对象实例都能够在 TLAB 中成功分配内存,但JVM确实是将TLAB作为内存分配的首选
  2. 在程序中,开发人员可以通过选项 -XX:UseTLAB 设置是否开启 TLAB 空间。
  3. 默认情况下,TLAB 空间的内存非常小,仅占有整个 Eden 空间的 1%,当然我们可以通过选项 -XX:TLABWasteTargetPercent 设置 TLAB 空间所占用 Eden 空间的百分比大小。
  4. 一旦对象在 TLAB 空间分配内存失败时,JVM 就会尝试着通过使用加锁机制确保数据操作的原子性,从而直接在Eden 空间中分配内存。

1、哪个线程要分配内存,就在哪个线程的本地缓冲区中分配,只有本地缓冲区用完 了,分配新的缓存区时才需要同步锁定 ----这是《深入理解JVM》--第三版里说的

2、和这里讲的有点不同。我猜测说的意思是某一次分配,如果TLAB用完了,那么这一次先在Eden区直接分配。空闲下来后再加锁分配新的TLAB(TLAB内存较大,分配时间应该较长)

TLAB 分配过程

image-20210210163426787

10.堆空间参数设置

视频链接:081-小结堆空间的常用参数设置

10.1 常用参数设置

官方文档https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html

我们只说常用的

/**
 * 测试退空间中常用的jvm参数
 * -XX:+PrintFlagsInitial : 查看所有的参数的默认初始值
 * -XX:+PrintFlagsFinal:查看所有的参数的最终值(可能会存在修改,不再是初始值)
 *      具体查看某个参数的指令: jps:查看当前运行中的进程及id
 *                           jinfo -flag SurvivorRatio 进程id
 *
 * -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:是否设置空间分配担保
 */
public class HeapArgsTest {
    public static void main(String[] args) {

    }
}

如果新生区中Eden区很大,survivor区很小的话,会出现问题:survivor可以存放的对象比较少,很容易满,就直接放到 old 区了,minor gc 就失去意义了,主要执行的就是 major gc 了,分代的意义比较弱。

如果新生区中Eden区不够大,survivor区很大的话,young gc 触发的条件就是eden区满的时候,young gc 的频率变高,导致stw的总体时间变多了

10.2 空间分配担保

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

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

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

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

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

  • 如果 HandlePromotionFailure=false,则进行一次 Full GC

历史版本

  1. JDK6 Update 24 之后,HandlePromotionFailure 参数不会再影响到虚拟机的空间分配担保策略,观察openJDK 中的源码变化,虽然源码中还定义了 HandlePromotionFailure 参数,但是在代码中已经不会再使用它。
  2. JDK6 Update 24 之后的规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行Minor GC,否则将进行 Full GC。即 HandlePromotionFailure=true

11.堆是分配对象的唯一选择么?

视频链接:082-通过逃逸分析看堆空间的对象分配策略

在《深入理解Java虚拟机》中关于Java堆内存有这样一段描述:

  1. 随着 JIT 编译期的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么绝对了。
  2. Java 虚拟机中,对象是在 Java 堆中分配内存的,这是一个普遍的常识。但是,有一种特殊情况,那就是如果经过逃逸分析(Escape Analysis)后发现,一个对象并没有逃逸出方法的话,那么就可能被优化成栈上分配。这样就无需在堆上分配内存,也无须进行垃圾回收了。这也是最常见的堆外存储技术。
  3. 此外,前面提到的基于 OpenJDK 深度定制的 TaoBao VM ,其中创新的 GCIH(GC invisible heap)技术实现off-heap,将生命周期较长Java 对象从 heap 中移至 heap 外,并且 GC 不能管理 GCIH 内部的 Java 对象,以此达到降低 GC回收频率和提升 GC回收效率的目的。

11.1 逃逸分析

  1. 如何将堆上的对象分配到栈,需要使用逃逸分析手段。
  2. 这是一种可以有效减少 Java 程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。
  3. 通过逃逸分析,Java Hotspot 编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。
  4. 逃逸分析的基本行为就是分析对象动态作用域:
    • 当一个对象在方法中被定义后,对象只在方法内部使用,则认为没有发生逃逸
    • 当一个对象在方法中被定义后,它被外部方法所引用,则认为发生逃逸。例如作为调用参数传递到其他地方中。

逃逸分析举例

1、没有发生逃逸的对象,则可以分配到栈(每个线程一份,无线程安全问题)上,随着方法执行的结束,栈空间就被移除(也就无需 GC)【栈空间里面无需 GC

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

2、下面代码中的 StringBuffer sb 发生了逃逸,不能在栈上分配

public static StringBuffer createStringBuffer(String s1, String s2) {
    StringBuffer sb = new StringBuffer();
    sb.append(s1);
    sb.append(s2);
    return sb;
}

3、如果想要 StringBuffer sb 不发生逃逸,可以这样写

public static String createStringBuffer(String s1, String s2) {
    StringBuffer sb = new StringBuffer();
    sb.append(s1);
    sb.append(s2);
    return sb.toString();
}
/**
 * 逃逸分析
 * 如何快速的判断是否发生了逃逸分析,大家就看new的对象实体是否有可能在方法外被调用。
 */
public class EscapeAnalysis {

    public EscapeAnalysis obj;

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

    /*
    为成员属性赋值,发生逃逸
     */
    public void setObj() {
        this.obj = new EscapeAnalysis();
    }
    //思考:如果当前的obj引用声明为static的?仍然会发生逃逸。

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

    /*
    引用成员变量的值,发生逃逸
     */
    public void useEscapeAnalysis1() {
        EscapeAnalysis e = getInstance();
        //getInstance().xxx()同样会发生逃逸
    }
}

逃逸分析参数设置

  1. JDK 1.7 版本之后,HotSpot 中默认就已经开启了逃逸分析
  2. 如果使用的是较早的版本,开发人员则可以通过:
    • 选项 -XX:+DoEscapeAnalysis 显式开启逃逸分析
    • 通过选项 -XX:+PrintEscapeAnalysis 查看逃逸分析的筛选结果

总结

开发中能使用局部变量的,就不要使用在方法外定义。

11.2 代码优化

视频链接:083-代码优化之栈上分配

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

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

11.3 栈上分配

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

栈上分配举例

/**
 * 栈上分配测试
 * -Xmx1G -Xms1G -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");
        // 为了方便查看堆内存中对象个数,线程sleep
        try {
            Thread.sleep(1000000);
        } catch (InterruptedException e1) {
            e1.printStackTrace();
        }
    }

    private static void alloc() {
        User user = new User();//未发生逃逸
    }

    static class User {

    }
}

输出结果:

花费的时间为: 122 ms

1、JVM 参数设置

-Xmx1G -Xms1G -XX:-DoEscapeAnalysis -XX:+PrintGCDetails

2、日志打印:耗时 122ms

image-20210210172719189

开启逃逸分析的情况

输出结果:

花费的时间为: 5 ms

1、参数设置

-Xmx1G -Xms1G -XX:+DoEscapeAnalysis -XX:+PrintGCDetails

2、日志打印:并没有发生 GC ,耗时 5ms

image-20210210172955163

11.4 同步省略(同步消除)

视频链接:084-代码优化之同步省略

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

例如下面的代码

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

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

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

字节码分析

public class SynchronizedTest {
    public void f() {
        Object hollis = new Object();
        synchronized(hollis) {
            System.out.println(hollis);
        }
    }
}
 0 new #2 <java/lang/Object>
 3 dup
 4 invokespecial #1 <java/lang/Object.<init>>
 7 astore_1
 8 aload_1
 9 dup
10 astore_2
11 monitorenter
12 getstatic #3 <java/lang/System.out>
15 aload_1
16 invokevirtual #4 <java/io/PrintStream.println>
19 aload_2
20 monitorexit
21 goto 29 (+8)
24 astore_3
25 aload_2
26 monitorexit
27 aload_3
28 athrow
29 return

注意:字节码文件中并没有进行优化,可以看到加锁和释放锁的操作依然存在,同步省略操作是在解释运行时发生的

11.5 标量替换

视频链接:085-代码优化之标量替换

分离对象或标量替换

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

标量替换举例

代码

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

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

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

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

private static void alloc() {
    int x = 1;
    int y = 2;
    System.out.println("point.x = " + x + "; point.y=" + y);
}
  1. 可以看到,Point 这个聚合量经过逃逸分析后,发现他并没有逃逸,就被替换成两个聚合量了
  2. 那么标量替换有什么好处呢?就是可以大大减少堆内存的占用。因为一旦不需要创建对象了,那么就不再需要分配堆内存了。
  3. 标量替换为栈上分配提供了很好的基础。

标量替换参数设置

参数 -XX:+ElimilnateAllocations:开启了标量替换(默认打开),允许将对象打散分配在栈上。

代码示例

/**
 * 标量替换测试
 *  -Xmx100m -Xms100m -XX:+DoEscapeAnalysis -XX:+PrintGC -XX:-EliminateAllocations
 *  -Xmx100m -Xms100m -XX:+DoEscapeAnalysis -XX:+PrintGC -XX:+EliminateAllocations
 */
public class ScalarReplace {
    public static class User {
        public int id;
        public String name;
    }

    public static void alloc() {
        User u = new User();//未发生逃逸
        u.id = 5;
        u.name = "www.atguigu.com";
    }

    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");
    }
}

/*
class Customer {
    String name;
    int id;
    Account account;
}

class Account {
    double balance;  //标量
}
 */

未开启标量替换

1、JVM 参数

-Xmx100m -Xms100m -XX:+DoEscapeAnalysis -XX:+PrintGC -XX:-EliminateAllocations

2、日志

[GC (Allocation Failure)  25600K->784K(98304K), 0.0011964 secs]
[GC (Allocation Failure)  26384K->736K(98304K), 0.0011827 secs]
[GC (Allocation Failure)  26336K->696K(98304K), 0.0006329 secs]
[GC (Allocation Failure)  26296K->696K(98304K), 0.0009458 secs]
[GC (Allocation Failure)  26296K->728K(98304K), 0.0010908 secs]
[GC (Allocation Failure)  26328K->696K(101376K), 0.0009539 secs]
[GC (Allocation Failure)  32440K->680K(101376K), 0.0010802 secs]
[GC (Allocation Failure)  32424K->680K(101376K), 0.0004290 secs]
花费的时间为: 72 ms

开启标量替换

1、JVM 参数

-Xmx100m -Xms100m -XX:+DoEscapeAnalysis -XX:+PrintGC -XX:+EliminateAllocations

2、日志:时间减少很多,且无 GC

花费的时间为: 5 ms

上述代码在主函数中调用了 1亿alloc() 方法,进行对象创建由于 User 对象实例需要占据约 16 字节的空间,因此累计分配空间达到将近 1.5GB 。如果堆空间小于这个值,就必然会发生 GC。使用如下参数运行上述代码:

-server -Xmx100m -Xms100m -XX:+DoEscapeAnalysis -XX:+PrintGC -XX:+EliminateAllocations

这里设置参数如下:

  1. 参数 -server:启动 Server 模式,因为在 server 模式下,才可以启用逃逸分析
  2. 参数 -XX:+DoEscapeAnalysis:启用逃逸分析
  3. 参数 -Xmx10m:指定了堆空间最大为 10MB
  4. 参数 -XX:+PrintGC:将打印 GC 日志。
  5. 参数 -XX:+EliminateAllocations:开启了标量替换(默认打开),允许将对象打散分配在栈上,比如对象拥有 idname 两个字段,那么这两个字段将会被视为两个独立的局部变量进行分配

11.6 逃逸分析的不足

视频链接:086-代码优化及堆的小结

  1. 关于逃逸分析的论文在 1999 年就已经发表了,但直到 JDK1.6 才有实现,而且这项技术到如今也并不是十分成熟的。
  2. 其根本原因就是无法保证逃逸分析的性能消耗一定能高于他的消耗。虽然经过逃逸分析可以做标量替换、栈上分配、和锁消除。但是逃逸分析自身也是需要进行一系列复杂的分析的,这其实也是一个相对耗时的过程。
  3. 一个极端的例子,就是经过逃逸分析之后,发现没有一个对象是不逃逸的。那这个逃逸分析的过程就白白浪费掉了。
  4. 虽然这项技术并不十分成熟,但是它也是即时编译器优化技术中一个十分重要的手段。
  5. 注意到有一些观点,认为通过逃逸分析,JVM 会在栈上分配那些不会逃逸的对象,这在理论上是可行的,但是取决于JVM 设计者的选择。据我所知,Oracle Hotspot JVM中并未这么做(刚刚演示的效果,是因为 HotSpot 实现了标量替换),这一点在逃逸分析相关的文档里已经说明,所以可以明确在HotSpot虚拟机上,所有的对象实例都是创建在堆上
  6. 目前很多书籍还是基于 JDK7 以前的版本,JDK 已经发生了很大变化,intern 字符串的缓存和静态变量曾经都被分配在永久代上,而永久代已经被元数据区取代。但是intern字符串缓存和静态变量并不是被转移到元数据区,而是直接在堆上分配所以这一点同样符合前面一点的结论:对象实例都是分配在堆上

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

综上:对象实例都是分配在堆上。What the fuck?

12.小结

  1. 年轻代是对象的诞生、成长、消亡的区域,一个对象在这里产生、应用,最后被垃圾回收器收集、结束生命。
  2. 老年代放置长生命周期的对象,通常都是从 Survivor 区域筛选拷贝过来的 Java 对象。【达到阈值,过大】
  3. 当然,也有特殊情况,我们知道普通的对象可能会被分配在 TLAB 上;
  4. 如果对象较大,无法分配在 TLAB 上,则 JVM 会试图直接分配在 Eden 其他位置上;
  5. 如果对象太大,完全无法在新生代找到足够长的连续空闲空间,JVM 就会直接分配到老年代。
  6. GC 只发生在年轻代中,回收年轻代对象的行为被称为 Minor GC
  7. GC 发生在老年代时则被称为 Major GC 或者 Full GC
  8. 一般的,Minor GC 的发生频率要比 Major GC 高很多,即老年代中垃圾回收发生的频率将大大低于年轻代。
posted @ 2021-12-29 13:35  liuxiaocs  阅读(32)  评论(0)    收藏  举报