JVM-堆


前言

一个进程对应一个JVM实例,一个JVM实例对应一个运行时数据区,而其中的所有线程都共享同一个堆和方法区,因此堆必须考虑线程安全的问题。
new关键字就是堆中创建对象的命令。【逃逸分析另论】
任何对象都是创建在堆上的。【看这篇总结的分析】


JVM启动实际:BootStrap

一、堆空间

1.1 分区

每一个java程序对应一个进程,也就是一个JVM实例。
可以通过查看堆空间的波动:

/**
 * 演示堆内存分配
 */
public class Demo1 {

    public static void main(String[] args) {
        new Thread(() -> {
            try {
                Thread.sleep(1000000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }).start();
    }
}

进行程序运行参数配置:
通过-Xms _M -Xmx _M 来设置堆的空间大小。
在这里插入图片描述

下载或者使用jdk8自带VisualVM可以查看堆空间的情况:
在这里插入图片描述
可以看到对堆空间参数的设置。


堆中的空间应该在逻辑上连续的区域,但是物理区域可以不连续。

逻辑上的连续可以保证堆读取数据高效。
由操作系统的知识可知,内存地址分为虚拟地址和物理地址,两者之间存在映射关系,因此物理区域是可以不连续的,操作系统可以帮助解决。

每个线程虽然共用同一个堆区,但是为了减少线程安全问题的发生,堆空间内部划分了许多个小区域,叫做TLAB Thread Local Allocation Buffer,即 线程私有缓冲区,每个线程具有自己独有的TLAB。

如下图:
虚拟机栈中的栈帧内部具有对象的引用,指向堆中的对象实例,该对象类型对应的类和方法都存储在方法区中,且存在一对一和共用的关系。
在这里插入图片描述


JVM规范提出任何对象都应该分配在堆中,但其实不是这样。
对象和数组可能永远都不会分配在栈上,栈中只保存指向对应对象的引用地址,指向堆中属于这个栈帧的堆区。

当栈帧销毁时,他对应的堆区域不会马上回收,对象还是会在堆区存储一段时间。当堆空间不足时,gc才会开始回收。

回收的宗旨是没有引用指向这个对象。


堆空间由于巨大,又从逻辑上分为三个区:

  • 新生代/新生区
  • 老年代/养老区/老年区
  • 永久区(1.7之前) / 元空间(1.8以后)

要注意,元空间/永久区其实是方法区,因此强调是逻辑上的堆区。

1.7到1.8堆空间的变化是面试题。
从永久区改名为元空间

黄色部分为老年区,绿色为新生区,橙色为元空间。
在这里插入图片描述

查看垃圾回收细节:-XX:+PrintGCDetails //注意大写和加号

1.2 设置大小

设置堆起始大小:-Xms _M 等价于 -Xx: InitialHeapSize _M
设置堆最大大小:-Xmx _M等价于 -Xx: MaxHeapSize _M

  • 前者并不是后者的缩写,而是两种命令。
  • 两种命令都不涉及元空间的大小。
  • 一条命令之间不需要空格,不同命令之间需要空格。
  • 除了单位以外,剩下的字母严格区分大小写,首字母必须大写。

命令行/option查看堆空间的空间分配情况:

方式一:cmd下

  1. jps: 列出所有jvm进程的id;
  2. jstat -gc 进程id; 查看对应id的进程的分配情况。

方式二,option下:
-Xx: +PrintGCDetails

实际使用时,发现方式二已经被弃用,改用:-Xlog:gc* 注意最后地星号


case:
查看默认情况下堆空间大小,以及设置之后的堆空间大小。

tips:

  • 默认情况下,堆空间的初始化大小为内存的64分之一,最大内存为物理内存的4分之一
  • 因此可以从这个条件推出原始空间大小。
  • 真正开发中初始内存需要和最大内存相等,避免开辟和回收空间带来的性能浪费。

    public static void main(String[] args) {
        int m =  1024 * 1024;
        long initmemory = Runtime.getRuntime().totalMemory() / m;
        long maxMemory = Runtime.getRuntime().maxMemory() / m;


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

        long g = m * m;

        System.out.println("The size of memory of the computer is :" + (double)initmemory * 64 / 1024.0);
        System.out.println("The size of memory of the computer is :" + (double)maxMemory * 4 / 1024.0);


    }

在这里插入图片描述

1.3 OOM

广义上的异常是Exception+Error, 通常面试上都是广义上的。

case:利用死循环查看OOM异常

让list不断添加字节数组,直到撑爆堆区。

 static class Picture{
        private byte[][] pix;

        public Picture(byte[][] pix) {
            this.pix = pix;
        }
    }

    public static void main(String[] args) {
        List<Picture> list = new ArrayList<>();
        while (true) {
            list.add(new Picture(new byte[1024][1024]));
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

调节堆区初始与最大大小都为500M。

打开VisualVM参看堆区变化,如下图:

在这里插入图片描述
在这里插入图片描述

可以看到,每当新生代区有部分增长时,接着就会被划分到老年区中
同时,老年区内存不断上升,最终到达顶点,出现了OOM。
另外,可以看到S0与S1不会同时工作,此时S1工作,S0空闲。

在这里插入图片描述
在这里插入图片描述
可以在Sampler的Memory中查看什么玩意导致了OOM,如下图,byte[]占据了99%的内存,因此可以断定是他造成了堆溢出。
在这里插入图片描述

1.4 堆区划分

在这里插入图片描述
因为java对象的生命周期有长有短,参差不齐。
而GC是按区域扫描的,若是将长生命的对象和短周期的对象共同放到一起进行扫描,未免太过损耗GC的性能。

就相当于你不冷你爸妈还在一直关心你要不要加衣服一样。

因此,为了减少资源损耗,选择通过分区来实现。

长周期的对象存放在老年区,短周期的对象放在新生区。
而新生区又能继续划分为Eden(伊甸园区), Survivor1(幸存者1区,From区),Survivor2(幸存者2区,To区)。
Survivor区是指Eden中没有被销毁的短生命对象,没有被销毁自然就是幸存者了。

设置新生代与老年代的比例:
-XX:NewRatio=? ?填写的是老年代对于新生代大小的倍数。
默认情况下为1.

也可以通过cmd来查看,cmd命令为:
jinfo -flag NewSurvivorRatio 进程id

设置Eden 与 Suravivor区的的比例。
-XX:SurvivorRatio=? ?代表Eden对于Survivor的倍数。
默认为八比一,但是在实际运行时般都不是这样的,除非显示的指定了比例,否则真实情况是6.
但是在任何API或者统计中显示都是8,只有那个统计图和stat统计可以看出是六比一。

设置与关闭自适应的内存分配策略:
XX:+UseAdapativeSizePolicy , +号就是开启,-号就是关闭
不能使得Eden/Suravivor回复默认的八比一。

设置新生代堆空间大小:
Xmn:_M
一般不设置,优先度低于XX:NewRatio命令。
即设置了 NewRatio + Xms(堆大小),即使设置了Xmn也不会执行。

基本上所有的对象都是在Eden被制造出来的,之后才加入老年区;
巨大多数的对象都是在Eden销毁,80%的都是朝生夕死的。

二、 回收步骤

2.1 流程

(1)程序最开始时,新创建的对象都在Eden区。一段时间之后,Eden去空间不足,触发MinorGC,即YGC(YongGC)。YGC会回收Eden中已经成为辣鸡的对象,并将还在使用的对象存放到任一个空的Survivor区中,被选中的Survivor去称为From区,剩余的那个称为To【因此,From和To不是事先设置的,而是随机选中,轮流当的】。
这些幸存对象会被添加一个叫做Age的计数器,并置为1;
在这里插入图片描述
(2)程序继续运行,当到某一个时刻,Eden区又满了,会再次触发YGC,此时,若存在幸存对象,会放到之前没有存对象的Survivor区中【即To区】,Age置为1.
同时,YGC会顺带清理From区的辣鸡对象,并将幸存对象放到To区中,Age加一。
此时,空的区【原来的From】成为新的To,而原To成为新的From。

注意:
幸存者区不会主动地触发GC,他的GC都是由Eden区的GC顺带处理的。

在这里插入图片描述
(3)经过一段时间的运行,程序到达一个奇异的时间段:
幸存者区的某些幸存者对象经过大量的转移和判断【默认为十五次,这个临界值成为Threshold】,得到**晋升【Promotion】**的机会,将Age置为16,晋升到老年区。
在老年区中,对象的销毁频率将会降到极低,可以安享晚年了。
在这里插入图片描述


对象分配与回收的描述:
在这里插入图片描述

在这里插入图片描述

老年区的GC:MajorGC
MajorGC会在老年区内存不足时触发,若MajorGC的工作不能使得老年区空间不足得到缓解,则触发OOM;
OOM的条件是老年区内存不足!

对象回收的频率:
新生区 > 老年区 > 元空间
在这里插入图片描述

老师的比方是:
士兵晋升。
每个士兵经过摸爬滚打,会随着年限和考核逐步升级【AGE++】,一步一步提升最终成为军官,基本可以在军队里养老了【晋升】;
其中,有些士兵因为考核不通过,被退伍【GC回收】;
也有一些士兵因为作战勇敢,立下大功,一次生了许多级【即AGE的跃升,AGE可以因为一些特殊情况一次增加很多级】;
也有一些甚至直接当上军官,从此基本不用担心提前退伍了【直接晋升到老年代】;
也有一些军二代,刚一出生就进入了军官【出生就是老年代】;

在这里插入图片描述

从柱状图也能清晰地看到各个区空间的变化:
Eden区一直增长,直到某一刻GC之后,空间清空,接着又是同样的步骤;
Survivor0和1一直呈现你有我就没有这样的水深火热的僵局。
OldGen一直呈现增长,每次都在Eden突降和Survivor切换的时候上升。


几个特殊情况:
(1)新生区放不下;
进行YongGC, 若GC过后还是放不下,尝试放到老年区;若还是放不下,进行MajorGC,同(2)
(2)老年区放不下;
先进行MajorGc,若还是放不下,报OOM。
(3)幸存者区放不下;
直接放到老年区,剩下情况同(2);

2.2 回收分类

GC的区域主要有三类:
(1)新生代;(2)老年代;(3)方法区

JVM的回收根据回收的区域分为两类:
部分回收(Partial GC)与全部回收(FullGC).

部分回收主要有:
新生代回收:YoungGC/MinorGC, 回收Eden,S0与S1区
老年代回收:Old GC/MajorGC, 回收OldGen【只有CMS垃圾回收器会单独收集老年代】
混合收集:同时收集新生代与老年代的部分区域【只有G1 GC垃圾回收1器如此做】

全部回收:
新生代、老年代、方法区同时回收。

因为常常有人把FullGC和MajorGC等同起来,其实是不同的。
但是大部分情况下,收集老年区都不会真的只收集老年区,而是全部收集,也就是FullGC,这样的等同是有他的道理的。
因此当别人使用Full GC的称呼的时候,要联系上下文分析他说的是老年代回收还是新生代回收。

2.3 虚拟机性能调优的主要目的及分代思想

主要是为了减少GC的操作。

GC时候会执行STW机制,即Stop The World, 即停止用户线程的工作。
这样会降低用户程序的执行效率。

通常进行MajorGC都伴随 一次YoungGC的操作,这叫做Parallel Scavenge策略。
MajorGC的耗费时间大约是MinorGC的10倍,因此要尽可能的减少MajorGC,这要求我们尽量少使用大对象,防止放不进去Eden而直接放进Old,确不是长期对象,带来频繁的OldGC的操作。


并不是不分代就不能用了,而是为了减少对不必要的对象的检查次数。
若长期对象和短期对象都放在一起,GC过程中会频繁对各种都一视同仁的检查,这样下来会使得整个的效率大幅度地域Eden区单独的GC。

2.4 内存分配策略

  • 优先分配在EDEN区。
  • 大对象可能会直接放到Old区。
  • 长期存活的对象直接放到Old区。
  • 若Survivor区有一半都是同一个年龄,则 大于这个年龄的对象直接放到老年区而不用等待年龄到达Threshold;
  • 空间分配担保策略。在执行MinorGc前,会首先检查老年代能否大于当前新生代的总占用(因为新生代的内容最坏情况下,即都是长期对象的情况下,会全部放到老年代中,此时,若是老年代存储不够,和后面一样)又因为一般达不到最坏情况,在开启空间分配担保的情况下,GC会退而求其次,选择查看之前的GC中新生代去往老年代的平均空间大小,若老年代大于这个平均值,就进行MinorGC,若还是不大于他,就进行FullGC,即全部堆空间和方法区进行一次回收

2.5 TLAB

ThreadLocalAllocationBuffer【感觉和Java中的ThreadLocal类有关】

每个线程除了共享堆区外,还各自享有一块自己的私人空间,即TLAB。TLAB在EDEN区
每次进行内存分配时,线程的数据会优先分配在这个私人空间上,由于这是每个线程独有的,因此不存在线程安全问题,分配时就能相对并行的执行,比分配共享区域时候的加锁策略要快多了,因此称为快速分配策略

设置快速分配策略,-XX:UseTLAB,不用设置也是模式开启的!

在cmd中也可以看这个命令有无开启:通过jps得到线程号的情况下, 使用 :jinfo -flag UseTLAB 进程号查看是否开启。

每个线程的TLAB大小默认占整个EDEN的1%,不能太大也不能太小:
太大:那Eden区就放不了几个TLAB
太小:那一个TLAB放不小一个线程的一个小对象的空间存储。

可以通过参数设置所占比例:TLAB WasteTargetPercnet
在这里插入图片描述

快速分配策略的示意图:

  1. 当字节码文件通过类加载子系统加载,解析,初始化,成功加载之后;
  2. 开始时进行TLAB分配,若分配成功,直接在TLAB创建对象;
  3. 若TLAB空间不足,分配不成功的话,会在EDEN进行分配,若分配成功,创建对象;
  4. 若EDEN空间不足,进行MinorGC【根据老年代剩余空间大小决定是进行MinorGC还是FullGC】
    在这里插入图片描述

面试题:
进程中的堆空间一定都是线程共享的吗?
不是,每个线程都会在EDEN区拥有一个TLAB,TLAB是每个线程的私人空间,因此分配时无需考虑线程安全问题,因为无需再分配空间时加锁,分配效率非常高。

三、堆参数调优小结

在Oracle的JVM规范中,堆调优参数一共有六百多个
这里提几个常用的:

  1. 打印调优参数信息:
    1. 初始参数信息:XX:PrintFlagsInitial
    2. 设置后的参数信息:XX:PrintFlagsFinal, 若你更改过设置,这个打印出来的信息的=会变成:=
  2. 设置堆中各区域大小
    1. 设置堆初始大小、最大大小:-Xms100m -Xmx100m
    2. 设置新生带大小【其优先度低于上面那条 + 比例的设置】: -Xmn 100m
  3. 设置堆区中各项的比例:
    1. 设置Eden:Survivor: -XX:SurvivorRatio=8
    2. 设置Old:New -XX:NewRatio=2
  4. 设置晋升阈值:SetTenuringThreshold=15 【Tenure:任职,任期】
  5. 设置打印GC信息:
    1. 打印信息信息: -XX:PrintGCDetails
    2. 打印缩略信息:-XX:PrintGC 等价于 -verbose GC【verbose:详细的,繁琐的】
  6. 设置是否开启空间分配担保: -XX:HandlePromotionFailure【即养老区不足分配,会进行一个FullGC】

SurvivorRatio的调优策略: 若Survivor的Ritio过低:**分区失去意义**。从Eden区幸存的对象因为不能保存到Survivor区,而去分配到养老区,而这些对象往往生命周期很低,因此会导致养老区频繁地GC,而养老区GC效率很低,会导致程序效率很低。 若Eden:Survivor设置过小,youngGC频率变高,性能也会下降。

空间分配担保。
在这里插入图片描述

四、空间逃逸

一个对象,若在堆中分配,需要去另外的内存开辟空间,并且在空间满了以后也需要特地去GC,因此资源耗费比较高。

若是在虚拟机栈区分配,就能保证在方法帧出栈的同时从内存销毁,少了开辟空间和GC的开销,性能会提升很多。
而如果这个对象在该方法帧之外的地方被引用或者创建,就不能销毁它。
一个对象的在方法中创建,并且在方法外也有使用,叫做空间逃逸
空间逃逸分析就是为了检测一个对象是否存在空间逃逸。

空间逃逸是默认开启的。
开启空间逃逸必须保证JVM是server模式,而我们下载的JDK默认开了server模式。
在这里插入图片描述
在这里插入图片描述
如,若将sb对象返回,即sb发生了逃逸,改为toString(),返回的是String类的引用,sb只是在方法中创建和销毁,就没有发生逃逸。
在这里插入图片描述

在这里插入图片描述


逃逸的证据:对象实体有没有可能在方法外部被调用。

逃逸分析代码举例:

/**
 * 逃逸分析
 */
public class Demo5_1 {

    Demo5_1 demo5 = null;

    public Demo5_1 getInstance() {
//        出现返回的对象,必定发生了逃逸
        return demo5 == null ? new Demo5_1() : demo5;
    }

    public void trap() {
//        虽然的确在方法内部,而且没有返回值,但是由于对象实体在外部,因此发生了逃逸
        Demo5_1 demo5_1 = getInstance();
    }

    public void setDemo5() {
//        操作外部变量,发生了逃逸
//        另一种情况:若demo5为静态的呢?仍然发生逃逸,对象触及到了方法外
        this.demo5 = new Demo5_1();
    }

    public void inner() {
//        没有发生逃逸,可以使用栈上分配
        Demo5_1 demo5_1 = new Demo5_1();
    }


}

4.1 栈上分配

设置逃逸分析及栈上分配【若检测未逃逸,会进行栈上分配。】
-XX:+DoEscapeAnalysis, 默认是开启的,若要关闭,将+换成-

验证栈上分配对性能的提升:
原理:
在外部方法中使用一个内部对象,在创建足够多的对象的情况下查看时间【System.currentTime】

  public static void main(String[] args) {
        long start = System.currentTimeMillis();
        for (int i = 0; i < 1_000_000_000; i++) {
            alloc();
        }

        System.out.println(System.currentTimeMillis() - start);

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

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

关闭逃逸分析:【-Xms100m -Xmx100m -XX:+PrintGC -XX:-DoEscapeAnalysis
可见,内存分配中出现了User对象,并且进行了垃圾回收。
在这里插入图片描述

在这里插入图片描述

开始逃逸分析:【-Xms100m -Xmx100m -XX:+PrintGC -XX:+DoEscapeAnalysis】

经过代码运行时间比较,由原本535ms下降到4ms,其提升之大可见一斑。【其实不是因为栈上分配,准确说HotSpot就没有栈上分配,看后面就知道了

4.2 同步分析

另一个提升方法执行性能的措施是同步分析
由于同步的耗费资源巨大,而程序员有时候没有注意同步锁的使用,使用一个无效的对象来作为锁对象,不但达不到同步效果,执行中还会因为同步而耗时。
同步分析会自动地检查同步锁中的无效锁,并直接将其消除。
称为同步省略或者锁消除。
在这里插入图片描述

下图的代码:
每个线程执行到这个方法时,都会创建一个hollis对象,因此,synchronized语句只会利用自己的hollis对象加锁,所有线程围绕不同的对象加锁,该方法就起不到同步的效果
在这里插入图片描述

同步省略过程由JIT编译器在分析字节码文件时自动执行,因此字节码文件上还是有加锁的,只是在执行指令时会被省略。

代码:


/**
 * 同步分析
 */
public class Demo5_3 {

    private static int count = 0;

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

    public static void method() {
        Object hollis = new Object();

        synchronized (hollis) {
            for (int i = 0; i < 10000; i++) {
                new Thread( () -> {
                    System.out.println(count++);
                    try {
                        Thread.sleep(59);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }).start();
            }
        }
    }
}

在这里插入图片描述
IDEA已经给出警告了。

在这里插入图片描述

若执行代码,只打印到9800多次就停止了,说明之后有有多个线程抢占执行权时,互相礼让了。

查看字节码文件,发现字节码中有加锁的行为,证明锁消除是在分析字节码文件后执行了。
在这里插入图片描述
monitor【监控,监视器】

改成对唯一对象Class对象进行加锁,
在这里插入图片描述
这次还是差一个,不知道为毛。

也还有加锁行为:
在这里插入图片描述

4.3 标量替换

对于一个没有进行方法逃逸的对象,如果也在堆空间中进行分配就很耗费资源,划不来。
因此有了栈上分配和标量替换技术。

标量:指的是java中最小的数据单元,一般就是指8中基本数据类
聚合量:由标量和其他聚合量组成的单元,如java中的对象。

当一个聚合量(对象)可以被分解为若干标量,且根据逃逸分析,他没有逃逸出方法执行的范围,就不会选择在堆分配空间,而将聚合量拆分为几个标量分配在几个寄存器上。
在这里插入图片描述
可以看到,通过编译后的标量替换,一个原本是对象的程序,会被替换为一个单纯的标量打印,就可以将x,y分配在寄存器上而不是堆空间了。
在这里插入图片描述
开启标量替换:-XX:EliminateAllocations
注意:逃逸分析必须要开启,因为标量替换技术是基于逃逸分析的。

在这里插入图片描述

4.4 总结及否定之否定

实际上,逃逸分析技术并不成熟。
极端情况是:进行了逃逸分析,一个未发生逃逸的对象都没找到,白白损耗了逃逸性能。

但是,因为有标量替换的存在,逃逸分析的作用还是巨大的。

要注意,HotSpot目前还不支持栈上分配,之前的程序开启和关闭逃逸分析的效率提升几百倍,主要是因为进行了标量。

因为标量不算是对象,且现在方法区的常量池和静态变量都已经存储到了堆区(元空间爷在堆区),因此可以断言:对象都是分配在堆上的

在这里插入图片描述

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值