【JVM】内存结构(中)

【JVM】内存结构(中),第1张

【JVM】内存结构(中) 8. 堆 8.1 堆 (Heap) 的核心概述

一个Java程序(一个进程)对应 一个JVM实例,一个JVM实例中有一个运行时数据区(Runtime)

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

堆内存的大小是可以调节的 8.1.1 对象创建、销毁

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

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

《Java虚拟机规范》中对Java堆的描述是:所有的对象实例以及数组都应当在运行时分配在堆上。(The heap is the run-time data area from which memory for all class instances and array is allocated)

“几乎” 所有的对象实例都在这里分配内存。(实际使用角度)

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

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

此举可以避免GC频率过高,影响用户线程体验

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

测试代码:

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

        int[] arr = new int[10];

        Object[] arr1 = new Object[10];
    }
}

8.1.2 内存细分

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

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

Young Generation Space 新生区 Young/New

又被划分为 Eden区 和 Survivor区 Tenure Generation Space 养老区 Old/TenurePermanent Space 永久区 Perm Java 8 及之后堆内存逻辑上分为3部分:新生代 + 养老区/老年代 + 元空间

Young Generation Space 新生区 Young/New

又被划分为 Eden区 和 Survivor区 Tenure Generation Space 养老区 Old/Tenuremeta Space 元空间 meta

约定:

新生区 <=> 新生代 <=> 年轻代养老区 <=> 老年区 <=> 老年代永久区 <=> 永久代

测试代码:

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

JVM参数:

-Xms10m -Xmx10m

使用 JDK 1.8自带工具 jvisualvm.exe 并安装 Visual DC插件:

测试代码:

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

        int[] arr = new int[10];

        Object[] arr1 = new Object[10];
    }
}

JVM参数:

-Xms20m -Xmx20m -XX:+PrintGCDetails

内存结构区别(Java 8):

8.2 堆空间大小设置

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

“-Xmx”:用于表示堆区的起始内存,等价于 -XX:InitialHeapSize“-Xms”:用于表示堆区的最大内存。等价于-XX:MaxHeapSize 一旦堆区中的内存大小超过 “-Xmx” 所指定的最大内存时,将会抛出 OutOfMemoryError 异常。通常会将 -Xms 和 -Xmx 两个参数配置相同的值,其目的是 能够在Java垃圾回收机制清理完堆区后不需要重新分隔计算堆区的大小,从而提升性能。默认情况:

初始内存大小:物理电脑内存大小 / 64最大内存大小:物理电脑内存大小 / 4

 

默认情况测试:

public class HeapSpecialInitial {
    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");
    }
}

手动设置参数(-Xms600m -Xmx600m)测试:

public class HeapSpecialInitial {
    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");
    }
}

问:为什么是575M?

答:因为在幸存者0区、幸存者1区中,只能使用其中一个幸存者区,另一个总为空。而Runtime类中仅计算到一个幸存者区的容量。

使用jps配合jstat -gc 命令:

使用-XX:+PrintGCDetails参数:

8.3 OutOfMemory举例
public class OOMTest {
    public static void main(String[] args) {
        ArrayList 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];
    }
}

可以看到老年代存满意味着OOM(动图经过3x倍速处理):

8.4 年轻代 & 老年代

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

一类是生命周期较短的瞬时对象,这类对象的创建和消亡都非常迅速另一类对象的生命周期非常长,在某些极端情况下能够与JVM的生命周期保持一致 Java 堆区进一步细分,可以划分为 年轻代(YoungGen)和 老年代(OldGen)其中年轻代又可以划分为 Eden空间、Survivor0空间、Survivor1空间(from区、to区)

测试代码:

public class EdenSurvivorTest {
    public static void main(String[] args) {
        System.out.println("=======S$S=======");
        try{
            Thread.sleep(10000000);
        }catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

使用命令 查看 默认比例:

8.1 配置 新生代 & 老年代 占比

配置新生代与老年代在堆结构的占比

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

如果生命周期较长的对象偏多,可以调大老年代空间。

8.2 配置 新生代 Eden区 & Survivor区 占比

在HotSpot中,Eden空间和另外两个Survivor空间缺省所占比例是8:1:1当然开发人员可以通过选项 -XXSurvivorRatio 调整这个空间比例,比如:-XXSurvivorRatio=8几乎所有 的Java对象都是在Eden区被new出来的。绝大部分Java对象的销毁都在新生代进行了。

IBM 公司的专门研究表明,新生代 80% 的对象都是 “朝生夕暮”的。 可以使用选项 -Xmn 设置新生代最大内存大小

这个参数一般使用默认值就可以了(一般不设置)

public class EdenSurvivorTest {
    public static void main(String[] args) {
        System.out.println("=======S$S=======");
        try{
            Thread.sleep(10000000);
        }catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

当前不进行任何关于新生代空间分配配置,JVM配置参数如下:

-Xms600m -Xmx600m

结果如下:

问:为什么不是8:1:1?

答:其中涉及自适应内存分配策略

关闭自适应内存分配策略:-XX:-UseAdaptiveSizePolicy

手动设置JVM参数:

-Xms600m -Xmx600m -XX:SurvivorRatio=8

8.4 对象分配过程 8.4.1 概述

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

    new的对象先放在伊甸园区。此区有大小限制。当伊甸园的空间填满,程序又需要创建对象,JVM的垃圾回收器堆伊甸园区进行垃圾回收 (Minor GC) ,将伊甸园区中的不再被其他对象所引用的对象进行销毁。再加载新的对象放到伊甸园区。然后将伊甸园区中的剩余对象移动到幸存者0区。如果再次触发垃圾回收,此时上次幸存下来的放到幸存者0区的,如果没有回收,就会放到幸存者1区。如果再次经历垃圾回收,此时会重新放回幸存者0区,接着再去幸存者1区。啥时候能去养老?可以设置次数,默认是15次。

    可以设置参数:XX:MaxTenuringTreshold=进行设置。 在养老区,相对悠闲。当养老区内存不足时,再次触发GC:Major GC,进行养老区的内存清理。若养老区执行了Major GC之后发现依然无法进行对象的保存,就会产生OOM异常(java.lang.OutOfMemoryError: Java heap space)。

YGC/MInor GC 不会主动回收Survivor区的内容,但会在回收Eden区的时候,顺带被动回收Survivor区的内容。

总结:

针对幸存者s0, s1 区的总结:复制之后有交换,谁空谁是to。关于垃圾回收:频繁在新生区收集,很少在养老区收集,几乎不在永久区/元空间收集。

8.4.2 常用调优工具

JDK命令行(jps、jstat、jinfo、javap等)Eclipse Memory Analyzer ToolJconsoleVisualVMJprofilerJava Flight RecorderGC ViewerGC Easy 8.5 Minor GC、Major GC、Full GC 对比 8.5.1 概述

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

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

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

新生代收集(Minor GC / Young GC):只是新生代(Eden、S0/S1)的垃圾收集

老年代收集(Major GC / Old GC):只是老年代的垃圾收集

目前,只有 CMS GC (一款并发的垃圾回收器) 会有单独收集老年代的行为。注意,很多时候 Major GC 会和 Full GC 混淆使用,需要具体分辨是老年代回收还是整堆回收。

混合收集(Mixed GC):收集整个 新生代 + 部分老年代 的垃圾收集。

目前,只有 G1 GC 会有这种行为。

整堆收集(Full GC):收集整个 Java堆 + 方法区 的垃圾收集。

8.5.2 最简单的分代式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(但非绝对的,在Parallel Scavenge收集器的收集策略里就有直接进行 Major GC 的策略选择过程)

也就是在老年代空间不足时,会先尝试触发 Minor GC。如果之后空间还不足,则触发 Major GC。 Major GC 的速度一般会比 Minor GC 慢10倍以上,STW时间更长。如果 Major GC 后,内存还不足,就报OOM

Full GC 触发机制(5种情况):

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

Full GC 是开发/调优中尽量要避免的。这样暂停时间会短一些。

    当Eden区过大时,对象无法放入Suvivor区,直接进入老年代,Minor GC + 分代思想 将失去意义,GC活动将以 Major GC 为主。

    当对象过大时,直接进入老年代,Minor GC(YGC) + 分代思想 将失去意义,GC活动将以 Major GC 为主。

    当Survivor区过大时,Minor GC(YGC) 出现频率过高,出现STW次数过多,影响用户线程体验

出现GC垃圾回收日志(两种GC:Minor GC、Full GC):

8.6 堆空间分代思想 8.6.1 为什么需要把Java堆分代?

经研究,不同对象的生命周期不同。70% - 99%的对象是临时对象。

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

8.6.2 不分代就不能正常工作了吗?

其实不分代完全可以,分代的唯一理由就是优化GC性能。如果没有分代,那么所有的对象都在一块,就如同把一个学校的人都关在一个教室。GC的时候要找到哪些对象没用,这样就会堆对的所有区域进行扫描。而很多对象都是朝生夕灭的,若要分代,把新创建的对象放到某一地方,当GC发生时先把这块存储 “朝生夕灭” 对象的区域进行回收,这样就会腾出很大的空间来。

8.7 内存分配策略

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

如果对象在 Eden 出生并经过第一次 Minor GC 后仍然存活,并且能被 Survivor 容纳,将被移动到 Survivor 空间中,并将对象年龄 (计数器) 设为1.对象在 Survivor 区中每熬过一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁,其实每个JVM、每个GC都有所不同)时,就会被晋升到老年代中。

对象今生老年代的年龄阈值,可以通过选项 -XX:MaxTenuringThreshold 来设置。

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

优先分配到Eden大对象直接分配到老年代

尽量避免程序中出现过多的大对象(朝生夕灭的大对象更不可取) 长期存活的对象分配到老年代动态对象年龄判断

如果 Survivor 区中相同年龄的所有对象大小的总和大于 Survivor 空间的一半,年龄大于/等于该年龄的对象可以直接进入老年代,无须等待 MaxTenuringThreshold 中要求的年龄。 空间分配担保(老年代 担保 Survivor区)

-XX:HandlePromotionFailure

测试代码 - 大对象直接进入老年代(JDK 8):

public class YoungOldAreaTest {
    public static void main(String[] args) {
        byte[] buffer = new byte[1024 * 1024 * 20];		//20M
    }
}

堆区分配情况 :

EdenSurvivor0Survivor1Old/Tenure16M2M2M40M

此时,buffer数组对象直接进入老年代:

8.8 为对象分配内存:TLAB 8.8.1 为什么有TLAB(Thread Local Allocation Buffer)?

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

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

TLAB再说明:

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

public class TLABArgsTest {
    public static void main(String[] args) {
        System.out.println("==========TLAB==========");

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

8.8.3 对象分配过程:TLAB

TLAB分配失败,则意味着对象大小大于TLAB空间,则尝试在Eden中分配空间,否则运行YGC/Minor GC,或直接放入老年代。

8.9 堆空间常用JVM参数小结

官网说明:

https://doc.oracle.com/javase/8/dcos/technotes/tools/unix/java/html

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

-XX:+PrintFlagsFinal:查看所有JVM参数的最终值(可能会存在修改,不再是初始值 {:=代表此值 非默认值/已修改})

具体查看某个参数的指令:

jps:查看当前运行中的进程(包括进程id (pid))

jinfo -flag SurvivorRatio

-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:是否设置空间分配担保

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

若大于,则此次 Minor GC 安全。若小于,则虚拟机会查看 -XX:HandlePromotionFailure 设置值是否允许担保失败。

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

若大于,则尝试进行一次 Minor GC,但这次Minor GC依然是有风险的。若小于,则改为进行一次 Full GC。 如果HandlePromotionFailure=false,则改为进行一次 Full GC。

在 JDK6 Update24 之后 (JDK 7),HandlePromotionFailure 参数不会再影响到虚拟机的空间分配担保策略,观察OpenJDK中的源码变化,虽然源码中还定义了 HandlePromotionFailure 参数,但是在代码中已经不会再使用它(可视为true)。

JDK6 Update24 之后 (JDK 7) 的规则变为 只要 老年代的连续空间 大于 新生代对象总大小 / 历次今生的平均大小 就会进行 Minor GC,否则将进行Full GC。

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

在《深入理解Java虚拟机》中关于Java堆内存有这样一段描述:随着JIT编译期的发展与 逃逸分析技术 逐渐成熟,栈上分配标量替换优化技术 将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么 “绝对” 了。

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

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

8.10.1 逃逸分析概述

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

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

具体代码分析:

public void myMethod() {
    V v = new V();
    // use v
    // ......
    v = null;		// 对象已无任何引用指向它
}

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

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


public static String createStringBuffer(String s1, String s2) {
 	   StringBuffer buffer = new StringBuffer();
    buffer.append(s1);
    buffer.append(s2);
    return buffer.toString();
}
※ 逃逸分析 - 代码举例
public class EscapeAnalysis {

    EscapeAnalysis obj;

    
    public EscapeAnalysis getInstance() {
        return obj == null ? new EscapeAnalysis() : obj;
    }

    
    public void setObj() {
        this.obj = new EscapeAnalysis();
    }

    
    public void useEscapeAnalysis() {
        EscapeAnalysis escapeAnalysis = new EscapeAnalysis();
    }

    
    public void useMemberEscapeAnalysis() {
        EscapeAnalysis escapeAnalysis = getInstance();
        // getInstance().xxx() 同样会发生逃逸
    }

}
※ 逃逸分析 - 参数设置

在 JDK 6u23版本之后 HotSpot 中默认就已经开启了逃逸分析。如果使用的是较早的版本,开发人员则可以通过:

选项 -XX:+DoEscapeAnalysis 显式开启逃逸分析通过选项 -XX:+PrintEscapeAnalysis查看逃逸分析的筛选结果。

结论 :

开发中能使用局部变量,就不要在方法外定义 ※ 逃逸分析 - 代码优化

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

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

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

给成员变量赋值方法返回值实例引用传递

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 e) {
            e.printStackTrace();
        }
    }

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

    static class User {

    }
}

关闭逃逸分析(Heap = 1G):时间长

开启逃逸分析(Heap = 1G):时间短

关闭逃逸分析(Heap = 256M):发生两次GC

开启逃逸分析(Heap = 256M):执行时间短,未发生GC

8.10.3 同步省略(锁消除)

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

public void f() {
    Object obj = new Object();	// 未发生逃逸
    synchronized(obj) {
        System.out.println(obj);
    }
}

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

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

JIT编译阶段指运行时,在字节码文件中仍然存在相关信息:

8.10.3 标量替换

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

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

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

标量替换 开启参数:-XX:+EliminateAllocations

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{
	public int x;
    public int y;
}

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

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

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

标量替换为栈上分配提供了很好的基础。

测试代码:

public class ScalarReplace {
    public static class User {
        public int id;
        public String name;
    }

    public static void alloc() {
        User user = new User();
        user.id = 5;
        user.name = "ljw";
    }

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

关闭标量替换:时间长,发生多次GC

开启标量替换:时间短,未发生GC

上述代码在主方法中进行了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年就已经发表了,但直到 JDK 1.6 才有实现,而且这项技术到如今也并不是十分成熟的。其根本原因就是 无法保证逃逸分析的性能消耗一定高于它本身的消耗。虽然经过逃逸分析可以做标量替换、栈上分配、锁消除。但是逃逸分析自身也是需要进行一系列复杂分析的,这其实也是一个相对耗时的过程。一个极端的例子,就是经过逃逸分析之后,发现没有一个对象是不逃逸的。那这个逃逸分析的过程就白白浪费掉了。虽然这项技术并不十分成熟,但是它也是 即时编译器优化技术中的一个重要的手段。注意到有一些观点,认为通过逃逸分析,JVM会在栈上分配那些不会逃逸的对象,这在理论上是可行的,但是却决于JVM设计者的选择。据我所知,Oracle HotSpot JVM中并未这样做(实现了 标量替换),这一点在逃逸分析相关的文档里已经说明,所以可以明确所有的对象实例都是创建在堆上。JDK 8 发生了很大的变化,intern字符串的缓存和静态变量曾经都被分配在永久代上,而永久代已经被元数据去取代。但是intern字符串缓存和静态变量并不是被转移到元数据区,而是直接在堆上分配,所以这一点同样符合前面的结论:对象实例都是分配在堆上的。 9. 方法区 9.1 栈、堆、方法区的交互关系

运行时数据区结构图

从线程共享与否的角度来看

JVM 栈、堆、方法区的交互关系:

9.2 方法区的理解

官方文档:
https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-2.html#jvms-2.5.4

The Java Virtual Machine has a method area that is shared among all Java Virtual Machine threads. The method area is analogous to the storage area for compiled code of a conventional language or analogous to the “text” segment in an operating system process. It stores per-class structures such as the run-time constant pool, field and method data, and the code for methods and constructors, including the special methods (§2.9) used in class and instance initialization and interface initialization.

The method area is created on virtual machine start-up. Although the method area is logically part of the heap, simple implementations may choose not to either garbage collect or compact it. This specification does not mandate the location of the method area or the policies used to manage compiled code. The method area may be of a fixed size or may be expanded as required by the computation and may be contracted if a larger method area becomes unnecessary. The memory for the method area does not need to be contiguous.

JVM 包含一个运行时数据区用来在线程间共享数据。方法区类似于传统语言编译后存放代码的地方或是 *** 作系统存放进程文本片段的地方。它存储 类结构(运行时常量池)、方法、构造器(包括类初始化的特殊方法,)。

方法区在JVM启动时被创建。虽然方法逻辑上是堆空间的一部分,但是具体的简单JVM的实现,可以选择即不进行GC也不进行压缩。当前没有限定 方法区的位置、管理编译后代码的策略。方法区可以是确定的大小,也可以是通过运行时动态扩展。如果方法区大小过大时,它可以被自动收缩。方法区的内存可以物理上不连续,逻辑上连续。

《Java虚拟机规范》中明确说明:“尽管所有方法区在逻辑上是属于堆的一部分,但一些简单的实现可能不会选择去进行垃圾回收或者进行压缩。”

对于 HotSpot JVM 而言,方法区还有一个别名叫做 Non-Heap (非堆, 存放编译后的代码),目的是要和堆 (存放动态生成对象) 分开。

所以,方法区看作是一块独立于Java堆的内存空间。

方法区(Method Area)与Java堆一样, 是各个线程共享的内存区域。方法区在JVM启动的时候被创建,并且它的实际的物理内存空间中和Java堆区一样都可以是不连续的。方法区的策略与堆空间类似,都可以选择固定大小或可扩展。方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类,导致方法区溢出,虚拟机同样会抛出内存溢出错误:java.lang.OutOfMemoryError: PermGenSpace 或 java.lang.OutOfMemoryError: metaSpace

加载大量的第三方jar包Tomcat部署工程过多(30~50个)大量动态生成反射类 关闭 JVM 就会释放这个区域的内存。 ※ HotSpot 中方法区的演进

在JDK7及以前,习惯把方法区,称为永久代。JDK8 开始,使用元空间取代了永久代。本质上,方法区和永久代 (HotSpot 对方法区的实现) 并不等价。仅是对HotSpot而言的。《Java虚拟机规范》对如何实现方法区,不做同一要求。例如:BEA JRockit / IEM J9中不存在永久代的概念。

现在看来,当年使用永久代,并不是好的想法。导致Java程序更容易OOM(超过-XX:MaxPermSize上限)

而到了JDK8,终于完全废弃了永久代的概念,改用与JRockit、J9一样在本地内存中实现的元空间(metaSpace)来代替。元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代最大的区别在于:元空间不在虚拟机设置的内存,而是使用本地内存。永久代、元空间二者并不只是名字变了,内部结构也调整了。根据《Java虚拟机规范》的规定,如果方法区无法满足新的内存分配需求时,将抛出OOM异常。 9.3 设置方法区大小

方法区的大小不必是固定的,JVM可以根据应用的需要动态调整。

JDK7及以前:

通过 -XX:PermSize 来设置永久代初始分配空间。默认值是20.75M通过 -XX:MaxPermSize来设定永久代最大可分配空间。32位及其默认是64M,64位及其默认是82M。当JVM加载的类信息容量超过了这个值,会报异常OutOfMemoryError: PermGenspace。

JDK8及以后:

元数据区大小可以使用参数 -XX:metaspaceSize 和 -XX:Maxmetaspace 指定,替代啊上述原有的两个参数。默认值依赖于平台。Windows下,-XX:metaspaceSize是21M,-XX:MaxmetaspaceSize 的值是-1,即没有限制。与永久代不同,如果不指定大小,默认情况下虚拟机会耗尽所有的可用系统内存。如果元数据区发生溢出,虚拟机一样会抛出异常OutOfMemoryError: metaspace。-XX:metaspaceSize:设置初始的元空间大小。对于一个64位的服务器端JVM来说,其默认的 -XX:metaspaceSize 值为21MB.这就是初始的高水位线,一旦触及这个水位线,Full GC将会被触发并卸载没用的类(即这些类对应的类加载器不再存活),然后这个高水位线将会重置。新的高水位线的值取决于GC后释放了多少元空间。如果释放的空间不足,那么在不超过MaxmetaspaceSize时,适当提高该值。如果释放空间过多,则适当降低该值。如果初始化的高水位线设置过低,上述高水位先调整情况会发生很多次。通过垃圾回收器的日志可以观察到Full GC多次调用。为了避免频繁地GC,建议将-XX:metaspaceSize 设置为一个相对较高的值。

9.4 OutOfMemory举例

这里借助ClassWriter生成不同的类,并加载进内存实现OOM。

public class OOMTest extends ClassLoader {
    public static void main(String[] args) {
        int j = 0;
        try {
            OOMTest test = new OOMTest();
            for (int i = 0; i < 10000; i++) {
                // 创建 ClassWriter 对象,用于生成类的二进制字节码
                ClassWriter classWriter = new ClassWriter(0);
                // 指明 版本号、修饰符、类名、包名、父类、实现接口
                classWriter.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC, "Class" + i, null, "java/lang/Object", null);
                // 返回byte[]
                byte[] code = classWriter.toByteArray();
                // 类的加载
                test.defineClass("Class" + i, code, 0, code.length);    //Class对象
                j++;
            }
        } finally {
            System.out.println(j);
        }
    }
}

64-Bits JVM 内存结构中,metaspace 包含 Compressed class space

如何解决这些OOM?

    要解决OOM异常或 heap space的异常,一般的手段是首先通过内存映像分析工具(如Eclipse Memory Analyzer)对dump 出来的堆转储快照进行分析重点是确认内存中的对象是否是必要的,也就是要西安分清楚到底是出现了内存泄漏(Memory Leak)还是内存溢出(Memory Overflow)如果是内存泄露,可进一步通过工具查看泄露对象到 GC Roots 的引用链。于是就能找到泄露对象是通过怎样的路径与 GC Roots 相关联并导致垃圾收集器无法自动 回收它们的。掌握了泄露对象的类型信息,以及 GC Roots 引用链的信息,就可以比较准确地定位出泄露代码的位置。如果不存在内存泄漏,换句话说就是内存中的对象确实都还必须存活着,那就应当检查虚拟机的堆参数(-Xmx 与 -Xms),与机器物理内存对比看是否还可以调大,从代码上检查是否存在某些对象生命周期过长、持有状态时间过长的情况,尝试减少程序运行期的内存消耗。
9.5 方法区的内部结构

方法区内部结构(JDK 8):

《深入来理解Java虚拟机》书中对方法区(Method Area)存储内容描述如下:

它用于存储已被虚拟机加载的 类型信息、常量、静态变量、即时编译器编译后的代码缓存 等。

9.5.1 类型信息

对每个加载的类型(类class、接口interface、枚举enum、注解annotation),JVM必须在方法区中存储以下类型信息:

    这个类型的完整有效名称(全名=包名.类名)这个类型直接父类的完整有效名称(对于interface / java.lang.Object 都没有父类)这个类型的修饰符(public, abstract, final 的某个子集)这个类型直接接口的一个有序列表
9.5.2 域(Field)信息

JVM 必须在方法区中保存类型的所有域的相关信息以及域的声明顺序。域的相关信息包括:域名称、域类型、域修饰符(public, private, protected, static, final, volatile, transient 的某个子集) 9.5.3 方法信息

JVM必须保存所有方法的以下信息,同域信息包括声明顺序:

方法名称方法的返回类型 (包括void)方法参数的数量、类型(按顺序)方法的修饰符 (public, private, protected, static, final, sychronized, native, abstract 的一个子集)方法的字节码 (bytecodes)、 *** 作数栈、局部变量表及大小 (abstract 和 native 方法除外)异常表(abstract 和 native 方法除外)

每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移地址、被捕获的异常类的常量池索引 9.5.4 non-final的类变量

静态变量和类关联在一起,随着类的加载而加载,它们成为类数据在逻辑上的一部分。类变量被类的所有实例共享,即使没有类实例时也可以访问它。

public class MethodAreaTest {
    public static void main(String[] args) {
        Order order = null;
        order.hello();
        System.out.println(order.count);
    }
}

class Order {
    public static int count = 1;
    public static final int number = 2;

    public static void hello() {
        System.out.println("hello!");
    }
}

※ 全局常量 : static final

被声明为final的类变量的处理方法则不同,每个全局常量在编译的时候就会被分配了。

9.5.5 运行时常量池 vs 常量池

官方文档:

https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-4.html

方法区,内部包含了运行时常量池。字节码文件,内部包含了常量池。要弄清楚方法区,需要理解清楚 ClassFile,因为加载类的信息都在方法区。要弄清楚方法区的运行时常量池,需要理解清楚ClassFile中的常量池。

一个有效的字节码文件中除了包含类的版本信息、字段、方法、接口等描述信息外,还包含一项信息那就是常量池表(Constant Pool Table),包括各种字面量和对类型、域、方法的符号引用。

※ 为什么需要常量池?

一个 java 源文件中的类、接口,编译后产生一个字节码文件。而 Java 中的字节码需要数据支持,通常这种数据会很大以至于不能直接存到字节码里,换另一种方式,可以存到常量池,这个字节码包含了指向常量池的引用。在动态链接的时候会用到运行时常量池。

例如:

public class SimpleClass {
    public void sayHello() {
        System.out.println("hello");
    }
}

编译后只有194字节,里面使用了String、System、PrintStream、Object等结构。这里代码量很小。代码越多,结构更多,所以需要使用常量池。

※ 常量池中有什么?

几种在常量池内存储的数据类型包括:

数量值字符串值类引用字段引用方法引用

public class MethodAreaTest2 {
    public static void main(String[] args) {
        Object obj = new Object();
    }
}

小结:常量池,可以看作是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法明、参数类型、字面量等类型。

※ 运行时常量池

运行时常量池(Runtime Constant Pool)时方法区的一部分。常量池表(Constant Pool Table)是 Class 文件的一部分,用于存放编译期生成的各种字面量域符号引用这部分内容将在类加载后存放到方法区的运行时常量池中。运行时常量池,在加载 类/接口 到虚拟机后,就会创建对应的运行时常量池。JVM为每个已加载的类型(类/接口)都维护一个常量池。池中的数据项相数组项一i杨,是通过 索引访问 的。运行时常量池中包含多种不同的常量,包括编译期就已经明确的数值字面量,也包括到运行期解析后才能够获得的方法或者字段引用。此时不再是常量池中的符号地址了,这里转换为真实地址。

运行时常量池,相比于class文件中常量池的另一重要特征是:具备动态性。 运行时常量池类似于传统编程语言中的符号表(symbol table),但是它所包含的数据却比符号表要更加丰富一些。当创建类/接口的运行时常量池时,如果构造运行时常量池所需的内存空间超过了方法区所能提供的最大值,则 JVM 会抛OutOfMemoryError 异常。 9.6 方法区使用举例

public class MethodAreaDemo {
	public static void main(String[] args) {
        int x = 500;
        int y = 100;
        int a = x / y;
        int b = 50;
        System.out.println(a + b);
    }
}

9.7 方法区的演进细节

    首先明确:只有 HotSpot 才有永久代。

    BEA JRockit、IBM J9 等来说,是不存在永久代概念的。原则上如何实现方法区属于虚拟机实现细节,不受《Java虚拟机规范》管束,并不要求统一。

    HotSpot 中方法区的变化:

JDK 1.6 及之前有永久代 (permanent generation),静态变量存放在永久带上JDK 1.7有永久代,但已经逐步 “去永久代”,字符串常量池、静态变量移除,保存在对堆中JDK 1.8 及之后无永久代,类型信息、字段、方法、常量保存在本地内存的元空间,但字符串常量池、静态变量仍在堆

9.7.1 永久代为什么要被元空间替换?

http://openjdk.java.net/jeps/122

随着Java 8的到来,HotSpot JVM 中再也见不到永久代了。但是这并不意味着类的元数据信息也消失了。这些数据被移到了一个 与堆不相连的本地内存区域,这个区域叫做元空间(metaspace)。

由于类的元数据分配在本地内存中,元空间的最大可分配空间就是系统可用内存空间。

这项改动是很有必要的,原因:

    为永久代设置空间大小是很难确定的。

    在某些场景下,如果动态加载类过多,容易产生 Perm 区的OOM。比如某个实际Web工程中,因为功能点比较多,在运行过程中,要不断动态加载很多类,经常出现致命错误。

    而元空间和永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制。

    对永久代进行调优是很困难的。

9.7.2 StringTable为什么要调整?

JDK 7 中将 StringTable 放到了堆空间中。因为永久代的回收效率很低,在 Full GC 的时候才会触发。而 Full GC 是老年代的空间不足、永久代不足才会触发。

这就导致StringTable回收效率不高。而我们开发中会有大量的字符串被创建,回收效率低,导致永久代内存不足。放到堆里,能及时回收内存。

9.7.3 静态变量放在那里?

JDK 6/7/8:

静态引用对应的对象实体始终都存在堆空间

public class StaticFieldTest {
    private static byte[] arr = new byte[1024 * 1024 * 100];    //100M

    public static void main(String[] args) {
        System.out.println(StaticFieldTest.arr);

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

测试代码:

public class StaticObjTest {
    static class Test {
        static ObjectHolder staticObj = new ObjectHolder();
        ObjectHolder instanceObj = new ObjectHolder();
    }

    void foo() {
        ObjectHolder localObj = new ObjectHolder();
        System.out.println("done");
    }

    private static class ObjectHolder {

    }


    public static void main(String[] args) {

    }
}

staticObj 的引用随着 Test 的类型信息存放在方法区,instanceObj 的引用随着 Test 对象实例存放在 Java 堆,localObject 的引用则是存放在 foo() 方法栈帧的局部变量表中。

测试发现:三个对象的实例在内存中的地址都落在Eden区范围内,所以结论:只要是对象实例必然会在Java堆中分配。

接着,找到了一个引用该staticObj对象的地方,是在一个java.lang.Class 的实例中,并且给出了这个实例的地址,通过Inspector查看该对象的实例,可以清楚看到这确实是一个java.lang.Class类型的对象实例,里面有一个名为staticObj的实例字段。

从《Java虚拟机规范》所定义的概念模型来看,所有Class相关的信息都应该存放在方法区中,但方法区该如何实现,《Java虚拟机规范》并未做出规定,这就成了一件允许不同虚拟机自己灵活把握的事情。

JDK 7 及其以后版本HotSpot虚拟机选择把 静态变量实例 与 类型在Java语言端的映射Class对象 存放在一起,存储于Java堆之中,从我们的实验中也明确验证了这一点。

9.8 方法区的垃圾回收

方法区(HotSpot中 永久代/元空间)是存在垃圾回收行为的。《Java虚拟机规范》对方法区的约束是非常宽松的,提到过可以不要求虚拟机在方法区中实现垃圾回收。事实上也确实有 未实现/未能完整实现方法区类型卸载的收集器存在(如 JDK 11 时期的ZGC收集器就不支持类卸载)。

一般来说,这个区域的回收效果比较难令人满意,尤其是类型的卸载,条件相当苛刻。但是这部分区域的回收 有时又确实是必要 的。以前Sun公司的Bug列表中,曾出现过的若干个严重的Bug就是由于低版本的HotSpot虚拟机对此区域未完全回收而导致内存泄漏。

方法区的垃圾回收主要回收两部分内容:常量池中废弃的变量、不再使用的类型

方法区内常量池中主要存放两大类常量 :字面量、符号引用。

字面量比较接近Java语言层次的常量概念(文本字符串、final常量值)

符号引用属于编译原理方面的概念,包括下面三类常量:

    类和接口的全限定名字段的名称和描述符方法的名称和描述符

HotSpot 虚拟机对常量池的回收策略是很明确的,只要常量池中的常量没有被任何地方引用,就可以被回收。

回收废弃常量于回收Java堆中的对象非常类似。

判定一个常量是否 “废弃” 还是相对简单,而要判定一个类型是否属于 “不再被使用的类” 的条件就比较苛刻了。需要同时满足下面三个条件:

该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例。加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如OSGi、JSP的重加载等,否则通常是很难达成的。该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

Java 虚拟机被允许满足上述三个条件的无用类进行回收,这里说的仅仅是 “被允许”,而并不是和对象一样,没有引用了就必然会回收。关于是否对类型进行回收,HotSpot 虚拟机提供了-Xnoclassgc 参数进行控制,还可以使用 -verbose:class 以及 -XX:+TraceClass-Loading、-XX:+TraceClassUnLoading 查看类加载/卸载信息。

在大量使用反射、动态代理、CGLib等字节码框架,动态生成JSP以及OSGi这类频繁自定义类加载器的场景,通常都需要Java虚拟机具备类型卸载的能力,以保证不会对方法区造成过大的内存压力

欢迎分享,转载请注明来源:内存溢出

原文地址: http://outofmemory.cn/zaji/5720249.html

(0)
打赏 微信扫一扫 微信扫一扫 支付宝扫一扫 支付宝扫一扫
上一篇 2022-12-18
下一篇 2022-12-18

发表评论

登录后才能评论

评论列表(0条)

保存