JVM基础篇(三)-JVM结构-运行时数据区之堆
  ksPIQBB4yvRT 2023年12月22日 18 0

堆概述

一个进程只有一个JVM,一个JVM实例只存在一个堆内存,一个进程有多个线程,共享同一个堆空间,堆是Java内存管理的核心区域。

JVM基础篇(三)-JVM结构-运行时数据区之堆_JVM

Java堆区在JVM启动的时候即被创建,其空间大小也就确定了。是JVM管理的最大一块内存空间。堆内存的大小是可以调节的。《Java虚拟机规范》规定,堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的。所有的线程共享Java堆,在这里还可以划分线程私有的缓冲区(Thread Local Allocation Buffer,TLAB)。


The heap is the run-time data area from which memory for all class instances and arrays is allocated .

所有的对象实例以及数组都应当在运行时分配在堆上。

——《Java虚拟机规范》

从实际使用角度看,“几乎”所有的对象实例都在这里分配内存。另外,还有一些对象是在栈上分配的。数组和对象可能永远不会存储在栈上,因为栈帧中保存引用,这个引用指向对象或者数组在堆中的位置。在方法结束后,堆中的对象不会马上被移除,仅仅在垃圾收集的时候才会被移除。堆是GC(Garbage Collection,垃圾收集器)执行垃圾回收的重点区域。

JVM基础篇(三)-JVM结构-运行时数据区之堆_JVM_02


堆内存的内部结构

约定:

新生区 -> 新生代 -> 年轻代

养老区 -> 老年区 -> 老年代

永久区 -> 永久代

JDK1.7 和 Java8 堆内存内部结构区别

JDK1.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

JDK1.7 堆空间内部结构图

JVM基础篇(三)-JVM结构-运行时数据区之堆_JVM_03


Java8前的堆空间中的永久代 在Java8时替换成元空间。详见《Java8新特性》。

JVM基础篇(三)-JVM结构-运行时数据区之堆_JVM_04

  • Java8是在 JDK1.7后更名,按逻辑本应叫做JDK1.8

年轻代和老年代内部结构细分

存储在JVM中的Java对象可以被划分为两类:生命周期较短的瞬时对象和生命周期较长对象

  • 生命周期较短的瞬时对象:创建和消亡都非常迅速
  • 生命周期非常长:在某些极端的情况下还能够与JVM的生命周期保持一致

Java堆细分:

JVM基础篇(三)-JVM结构-运行时数据区之堆_JVM_05

细分模块内存占比(开发中一般不调):

JVM基础篇(三)-JVM结构-运行时数据区之堆_JVM_06

  • 年轻代:老年代 ->  1 : 2
  • Eden:From:To ->  8 : 1 : 1

HotSpot配置堆结构占比:

  • -XX:NewRatio 调整年轻代与老年代在堆结构的比例。如缺省-XX:NewRatio=2,表示年轻代占1,老年代占2,年轻代占整个堆的1/3 ,可以修改-XX:NewRatio=4,表示年轻代占1,老年代占4,年轻代占整个堆的1/5
  • -xx:SurvivorRatio调整Eden空间和另外两个survivor空间比例。如缺省占比:8:1:1,-xx:SurvivorRatio=8
  • 调优建议:当发现在整个项目中,生命周期长的对象偏多,那么就可以通过调整 老年代的大小,来进行调优

几乎所有的Java对象都是在Eden区被创建出来的。绝大部分的Java对象的销毁都在新生代进行了(IBM公司有专门研究表明,新生代中80%的对象都是“朝生夕死”)。有些大的对象在Eden区无法存储时候,将直接进入老年代。可使用选项-Xmn设置新生代最大内存大小,该参数一般建议使用默认值就可以了。

对象分配过程

概述

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

注意:此处仅说明对象的流转,而非详细的垃圾回收,垃圾回收详见《垃圾回收

  1. 新创建的对象首先进入Eden区
  2. 当Eden区的空间填满时,且程序还需要创建对象,JVM的垃圾回收器将触发MinorGC(YGC),将Eden区中的不再被其他对象所引用的对象进行销毁。将Eden区中的剩余存活对象移动到S0区,再加载新的对象放到Eden区。
  3. 当再次触发垃圾回收(MinorGC/MajorGC)时(MinorGC发生频率比较高,不一定等Eden区满了才触发),会将Eden和S0中的存活对象,移至S1中(开启对象年龄计数器,年龄计数每次+1),并清空Eden和S0,后续再次触发垃圾回收,则将Eden和S1区存活的对象,移至S0(年龄计数+1),如此往复,直到年龄计数达到阈值(缺省15)或S0/S1内存满了存不下时,触发Promotion晋升操作,将对象移至养老区。
  4. 当养老区内存不足时,触发Major GC(FullGC),对Eden,S0/S1进行一次全面的失活对象内存清理
  5. 若养老区执行了Major GC之后,发现依然无法进行对象的保存,就会产生OOM异常。
  • 养老区内存大小设置:-Xx:MaxTenuringThreshold= N

图解

JVM基础篇(三)-JVM结构-运行时数据区之堆_JVM_07

  1. 新创建的对象,一般都是存放在Eden区,当Eden区满了后,就会触发GC操作,一般被称为 YGC / Minor GC操作

JVM基础篇(三)-JVM结构-运行时数据区之堆_JVM_08

  1. 当进行一次垃圾收集后,红色的将会被回收,而绿色的还会被占用着,存放在S0(Survivor From)区。同时给每个对象设置了一个年龄计数器,一次回收后就加1。同时Eden区继续存放对象,当Eden区再次存满的时候,又会触发一个MinorGC操作,此时GC将会把 Eden和S0中的对象进行一次收集,把存活的对象放到 S1(Survivor To)区,同时让年龄 + 1

JVM基础篇(三)-JVM结构-运行时数据区之堆_JVM_09

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

JVM基础篇(三)-JVM结构-运行时数据区之堆_JVM_10

代码演示

  1. 不断的创建大对象
public class HeapInstanceTest {
    byte [] buffer = new byte[new Random().nextInt(1024 * 200)];
    public static void main(String[] args) throws InterruptedException {
        ArrayList<HeapInstanceTest> list = new ArrayList<>();
        while (true) {
            list.add(new HeapInstanceTest());
            Thread.sleep(10);
        }
    }
}
  1. 设置JVM参数
-Xms600m 
-Xmx600m
  1. cmd打开jvisualvm,打开VisualVM图形化界面
jvisualvm
  1. 执行上面代码,通过VisualGC进行动态化查看

JVM基础篇(三)-JVM结构-运行时数据区之堆_JVM_11

  1. 最终,在老年代和新生代都满了,出现OOM
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
	at com.atguigu.java.chapter08.HeapInstanceTest.<init>(HeapInstanceTest.java:13)
	at com.atguigu.java.chapter08.HeapInstanceTest.main(HeapInstanceTest.java:17)


堆内存大小设置与OOM

堆内存大小设置

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

  • "-Xms":表示堆区的起始内存,等价于-xx:InitialHeapSize
  • "-Xmx":表示堆区的最大内存,等价于-XX:MaxHeapSize

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


默认情况下

  • 初始内存大小:物理电脑内存大小/64
  • 最大内存大小:物理电脑内存大小/4
/**
 * -Xms 用来设置堆空间(年轻代+老年代)的初始内存大小
 * -X:是jvm运行参数
 * ms:memory start
 *
 * -Xmx:用来设置堆空间(年轻代+老年代)的最大内存大小
 */
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");
    }
}

输出结果

-Xms:243M
-Xmx:3605M


演示示例

  • 查看堆内存的内存分配情况 jstat -gc 进程id

JVM基础篇(三)-JVM结构-运行时数据区之堆_JVM_12

JVM基础篇(三)-JVM结构-运行时数据区之堆_JVM_13

  • 打印输出详细的GC收集日志的信息-XX:+PrintGCDetails

(1)测试代码:创建了一个50M的字节数组

public class MyHelloGc {
    
    public static void main(String[] args) throws InterruptedException {
        System.out.println("***************HELLO GC");
        byte[] bytesArr = new byte[50 * 1024 * 1024];
    }
}

(2)JVM最大和最小的堆内存设置成了10m, 这样在运行上面的程序的时候, 一定会产生垃圾回收.

-Xms10m -Xmx10m -XX:+PrintGCDetails

JVM基础篇(三)-JVM结构-运行时数据区之堆_JVM_14

(3)运行后控制台打印

***************HELLO GC
[GC (Allocation Failure) [PSYoungGen: 1912K->496K(2560K)] 1912K->736K(9728K), 0.0017018 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[GC (Allocation Failure) [PSYoungGen: 496K->480K(2560K)] 736K->728K(9728K), 0.0010814 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (Allocation Failure) [PSYoungGen: 480K->0K(2560K)] [ParOldGen: 248K->659K(7168K)] 728K->659K(9728K), [Metaspace: 3031K->3031K(1056768K)], 0.0071285 secs] [Times: user=0.03 sys=0.00, real=0.01 secs] 
[GC (Allocation Failure) [PSYoungGen: 0K->0K(2560K)] 659K->659K(9728K), 0.0008659 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (Allocation Failure) [PSYoungGen: 0K->0K(2560K)] [ParOldGen: 659K->643K(7168K)] 659K->643K(9728K), [Metaspace: 3031K->3031K(1056768K)], 0.0108306 secs] [Times: user=0.00 sys=0.00, real=0.01 secs] 
Heap
 PSYoungGen      total 2560K, used 101K [0x00000000ffd00000, 0x0000000100000000, 0x0000000100000000)
  eden space 2048K, 4% used [0x00000000ffd00000,0x00000000ffd195c0,0x00000000fff00000)
  from space 512K, 0% used [0x00000000fff00000,0x00000000fff00000,0x00000000fff80000)
  to   space 512K, 0% used [0x00000000fff80000,0x00000000fff80000,0x0000000100000000)
 ParOldGen       total 7168K, used 643K [0x00000000ff600000, 0x00000000ffd00000, 0x00000000ffd00000)
  object space 7168K, 8% used [0x00000000ff600000,0x00000000ff6a0e00,0x00000000ffd00000)
 Metaspace       used 3063K, capacity 4556K, committed 4864K, reserved 1056768K
  class space    used 323K, capacity 392K, committed 512K, reserved 1048576K
Exception in thread "main" java.lang.
	at com.thc.jvmxx.MyHelloGc.main(MyHelloGc.java:17)

(4)打印日志参数解读


解读规律:

区域的名称(没有标明名称, 代表为堆的大小):GC前的内存大小 -> GC后的内存大小. 总的内存大小

JVM基础篇(三)-JVM结构-运行时数据区之堆_JVM_15


1)YOUNG GC :

[GC (Allocation Failure) [PSYoungGen: 1912K->496K(2560K)] 1912K->736K(9728K), 0.0017018 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]

JVM基础篇(三)-JVM结构-运行时数据区之堆_JVM_16

PSYoungGen 代表的是新生区的GC

1912K 代表的YoungGC前, 新生代占用1912k的内存

496K 代表YoungGC后, 新生代占用496K 的内存

2560K 代表 新生代总大小为 2560K

1912K 代表 YoungGC前, JVM堆内存的占用大小

736K 代表 YoungGC后, JVM堆内存的占用大小

9728K 代表JVM堆内存的总的大小. (大约为10m, 即项目启动时, 设置的10m的堆内存大小.)

0.0017018 secs 代表YoungGC 的耗时时间, 单位为秒

Times: user=0.00 sys=0.00, real=0.00 secs 分别为三个时间(了解) 分别为YoungGC 用户耗时时间, 系统耗时时间, 实际耗时时间. 单位为秒 .

2)FULL GC :

[Full GC (Allocation Failure) [PSYoungGen: 480K->0K(2560K)] [ParOldGen: 248K->659K(7168K)] 728K->659K(9728K), [Metaspace: 3031K->3031K(1056768K)], 0.0071285 secs] [Times: user=0.03 sys=0.00, real=0.01 secs]

JVM基础篇(三)-JVM结构-运行时数据区之堆_JVM_17

  • Full GC 代表GC类型为Full GC 括号中的Allocation Failure ,代表分配空间失败.PSYoungGen 代表新生代区
  • 480K 代表GC前Young区 ,内存占用的大小
  • 0K 代表GC后Young区 ,内存占用的大小. 把Young区的内存全部回收了
  • 2560K 代表Young区 的总的大小.
  • ParOldGen 代表老年代区
  • 248K 代表GC前Old区内存占用大小
  • 659K 代表GC后Old区内存占用大小
  • 7168K 代表Old区总的大小
  • 728K 代表GC前堆内存占用大小
  • 659K 代表GC后堆内存占用大小
  • 9728K 代表堆的总大小
  • Metaspace 代表元空间. 图片中写的是PSPermGen 永久代为jdk7 的版本, jdk8 为元空间
  • 3031K 代表GC前 元空间占用的大小
  • 3031K 代表GC后 元空间占用的大小 (Full GC前后, 元空间的大小不变. )
  • 1056768K 代表 元空间的总大小.
  • 0.0071285 secs 代表 Full GC的消耗时间. 单位为秒
  • Times: user=0.03 sys=0.00, real=0.01 secs 分别为三个时间 分别为Full GC 用户耗时时间, 系统耗时时间, 实际耗时时间. 单位为秒 . 用于分析日志的性能

通过上面的分析可以看到, 在老年代GC前为248K , GC后Old区内存占用大小为659K . 代表 代码中 new byte[50 * 1024 * 1024];太大了, 无法被回收, 老年代都扛不住了, 所以报了Java heap space 堆空间溢出的错误.

OOM

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

堆空间分代思想

为什么要把Java堆分代?

经研究,不同对象的生命周期不同。70%-99%的对象是临时对象。分代的好处是优化GC性能。如果不分代将所有的对象放在同一个区域,每次进行GC清理时,都要扫描整个区域,清除掉没用的对象,有些对象就是长期占用存活,没有必要频繁扫描判断,从而造成不必要的时间和性能浪费。因此提出分代思想,而细分Survivor0/1区的目的缩小扫描范围,S0、S1来回切换清空也是为了减少内存碎片。


内存分配策略

如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,将被移动到survivor空间中,并将对象年龄设为1。对象在survivor区中每熬过一次MinorGC,年龄就增加1,当它的年龄增加到阈值(默认为15)时,就会被晋升到老年代。

对象晋升老年代的年龄阀值设置:-xx:MaxTenuringThreshold

对象分配原则

  • 优先分配到Eden
  • 大对象直接分配到老年代
  • 调优建议: 尽量避免程序中出现过多的大对象,因为有些大对象也是瞬时对象,但是MajorGC触发频率慢,且执行时间长,造成不必要的存储空间浪费
  • 长期存活的对象分配到老年代
  • 动态对象年龄判断

如果survivor区中相同年龄的所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代,无须等到MaxTenuringThreshold最大年龄阈值。

空间分配担保机制

  1. 谁进行空间担保?

  JVM使用分代收集算法,将堆内存划分为年轻代和老年代,两块内存分别采用不同的垃圾回收算法,空间担保指的是老年代进行空间分配担保。

  1. 什么是空间分配担保?

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

  • 如果大于,则此次Minor GC是安全的,进行Minor GC。
  • 如果小于,则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败。
  • 如果HandlePromotinotallow=true(允许失败),那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小
  • 如果大于,则尝试进行一次Minor GC,但这次Minor GC依然是有风险的;
  • 如果小于,则进行一次Major GC
  • 如果HandlePromotinotallow=false(不允许失败),则进行一次Major GC

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

  1. 为什么要进行空间担保?

保证老年代存储安全,防止出现OOM。

因为新生代采用复制收集算法,假如大量对象在Minor GC后仍然存活(最极端情况为内存回收后新生代中所有对象均存活),而Survivor空间是比较小的,这时就需要老年代进行分配担保,把Survivor无法容纳的对象放到老年代。老年代要进行空间分配担保,前提是老年代得有足够空间来容纳这些对象,但一共有多少对象在内存回收后存活下来是不可预知的,因此只好取之前每次垃圾回收后晋升到老年代的对象大小的平均值作为参考。使用这个平均值与老年代剩余空间进行比较,来决定是否进行Major GC来让老年代腾出更多空间。


  1. 空间担保设置

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

快速分配策略:TLAB

TLAB是什么

TLAB:Thread Local Allocation Buffer,为每个线程单独分配了一个缓冲区,为每个线程独占区域。


TLAB存在意义

保证对象划分到的内存空间安全性,提升内存分配效率。

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

TLAB快速分配策略

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


目前所知的所有OpenJDK衍生出来的JVM都提供了TLAB的设计。

JVM基础篇(三)-JVM结构-运行时数据区之堆_JVM_18

TLAB设置

-Xx:UseTLAB: 否开启TLAB空间.

尽管不是所有的对象实例都能够在TLAB中成功分配内存,但JVM确实是将TLAB作为内存分配的首选。默认情况下,TLAB空间的内存非常小,仅占有整个Eden空间的1,可以通过选项-Xx:TLABWasteTargetPercent设置TLAB空间所占用Eden空间的百分比大小。

一旦对象在TLAB空间分配内存失败时,JVM就会尝试着通过使用加锁机制确保数据操作的原子性,从而直接在Eden空间中分配内存。


TLAB分配过程

对象首先是通过TLAB开辟空间,如果不能放入,那么需要通过Eden来进行分配。

JVM基础篇(三)-JVM结构-运行时数据区之堆_JVM_19

堆外分配策略:逃逸分析

随着JIT编译期的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么"绝对"了。

——《深入理解Java虚拟机》

概述

在Java虚拟机中,对象是在Java堆中分配内存的,这是一个普遍的常识。但是,有一种特殊情况,就是如果经过逃逸分析(Escape Analysis)后发现,一个对象并没有逃逸出方法的话,那么就可能被优化成栈上分配。这样就无需在堆上分配内存,也无须进行垃圾回收。这也是最常见的堆外存储(off-heap)技术

基于openJDk深度定制的TaoBaoVM,其中创新的GCIH(GC invisible heap)技术实现堆外存储,将生命周期较长的Java对象从堆中移至堆外,并且GC不能管理GCIH内部的Java对象,以而达到降低GC的回收频率和提升GC的回收效率的目的。

逃逸分析:

是什么:跨函数全局数据流分析算法

有什么用:用于分析对象是否有逃逸出方法,从而决定是否可以优化成:将堆上的对象分配到栈

有什么好处:有效减少Java程序中同步负载和内存堆分配压力


通过逃逸分析,Hotspot编译器能够分析出一个新的对象的引用的使用范围,从而决定是否要将这个对象分配到堆上。


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

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


如何快速的判断是否发生了逃逸分析:就看new的对象是否在方法外被调用


举例

没有发生逃逸的对象,则可以分配到栈上,随着方法执行的结束,栈空间就被移除,每个栈里面包含了很多栈帧,也就是发生逃逸分析

StringBuffer sb发生逃逸,被外部方法所引用

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

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对象,发生逃逸
     * @return
     */
    public EscapeAnalysis getInstance() {
        return obj == null ? new EscapeAnalysis():obj;
    }

    /**
     * 为成员属性赋值,发生逃逸
     */
    public void setObj() {
        this.obj = new EscapeAnalysis();
    }

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

    /**
     * 引用成员变量的值,发生逃逸
     */
    public void useEscapeAnalysis2() {
        EscapeAnalysis e = getInstance();
        // getInstance().XXX  发生逃逸
    }
}


参数设置

在JDK 1.7 版本之后,HotSpot中默认开启了逃逸分析

如果使用的是较早的版本(JDK1.7之前),则可以通过:

  • 选项-xx:+DoEscapeAnalysis显式开启逃逸分析
  • 选项-xx:+PrintEscapeAnalysis查看逃逸分析的筛选结果


  • 优化建议

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

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

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


栈上分配

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

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

举例

开启逃逸分析 和 未开启逃逸分析时候的情况

/**
 * 栈上分配
 * -Xmx1G -Xms1G -XX:-DoEscapeAnalysis -XX:+PrintGCDetails
 */
class User {
    private String name;
    private String age;
    private String gender;
    private String phone;
}

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

        // 为了方便查看堆内存中对象个数,线程sleep
        Thread.sleep(10000000);
    }

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


场景一:未开启逃逸分析(注意:加减号区分是开启还是关闭: - 为开启 + 开启)

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


运行结果,同时还触发了GC操作

花费的时间为:664 ms


查看内存的情况,发现有大量的User存储在堆中

JVM基础篇(三)-JVM结构-运行时数据区之堆_JVM_20


场景二:开启逃逸分析

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


查看运行时间,发现花费的时间快速减少,同时不会发生GC操作

花费的时间为:5 ms


在看内存情况,发现只有很少的User对象,说明User未发生逃逸,因为它存储在栈中,随着栈的销毁而消失

JVM基础篇(三)-JVM结构-运行时数据区之堆_JVM_21

同步省略

线程同步的代价是相当高的,同步的后果是降低并发性和性能。

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


例如下面的代码


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


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

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


分离对象和标量替换

标量(scalar)是指一个无法再分解成更小的数据的数据。Java中的原始数据类型就是标量。

相对的,那些还可以分解的数据叫做聚合量(Aggregate),Java中的对象就是聚合量,因为他可以分解成其他聚合量和标量。

在JIT阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话,那么经过JIT优化,就会把这个对象拆解成若干个其中包含的若干个成员变量来代替。这个过程就是标量替换。

public static void main(String args[]) {
    alloc();
}
class Point {
    private int x;
    private int y;
}
private static void alloc() {
    Point point = new Point(1,2);
    System.out.println("point.x" + point.x + ";point.y" + point.y);
}

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

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

可以看到,Point这个聚合量经过逃逸分析后,发现他并没有逃逸,就被替换成两个标量了。

标量替换的好处:

可以大大减少堆内存的占用。因为一旦不需要创建对象了,那么就不再需要分配堆内存了。标量替换为栈上分配提供了很好的基础。


代码优化之标量替换

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

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

设置参数如下:

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


逃逸分析的不足


关于逃逸分析的论文在1999年就已经发表了,但直到JDK1.6才有实现,而且这项技术到如今也并不是十分成熟。其根本原因就是无法保证逃逸分析的性能消耗一定能高于他的消耗。虽然经过逃逸分析可以做标量替换、栈上分配、和锁消除。但是逃逸分析自身也是需要进行一系列复杂的分析的,这其实也是一个相对耗时的过程。一个极端的例子,就是经过逃逸分析之后,发现没有一个对象是不逃逸的。那这个逃逸分析的过程就白白浪费掉了。

虽然这项技术并不十分成熟,但是它也是即时编译器优化技术中一个十分重要的手段。注意到有一些观点,认为通过逃逸分析,JVM会在栈上分配那些不会逃逸的对象,这在理论上是可行的,但是取决于JVM设计者的选择。据我所知,oracle Hotspot JVM中并未这么做,这一点在逃逸分析相关的文档里已经说明,所以可以明确所有的对象实例都是创建在堆上。

目前很多书籍还是基于JDK7以前的版本,JDK已经发生了很大变化,intern字符串的缓存和静态变量曾经都被分配在永久代上,而永久代已经被元数据区取代。但是,intern字符串缓存和静态变量并不是被转移到元数据区,而是直接在堆上分配,所以这一点同样符合前面一点的结论:对象实例都是分配在堆上。

堆空间的参数小结

  • -XX:+PrintFlagsInitial:查看所有的参数的默认初始值
  • -XX:+PrintFlagsFinal:查看所有的参数的最终值(可能会存在修改,不再是初始值)
  • -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:HandlePromotionFalilure:是否设置空间分配担保
  • -Xx:UseTLAB否开启TLAB空间
  • -Xx:TLABWasteTargetPercent:设置TLAB空间所占用Eden空间的百分比大小
【版权声明】本文内容来自摩杜云社区用户原创、第三方投稿、转载,内容版权归原作者所有。本网站的目的在于传递更多信息,不拥有版权,亦不承担相应法律责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@moduyun.com

  1. 分享:
最后一次编辑于 2023年12月22日 0

暂无评论

推荐阅读
ksPIQBB4yvRT