0
点赞
收藏
分享

微信扫一扫

JVM内存模型(三)------ 运行时数据区

胡桑_b06e 2023-07-13 阅读 62

四、堆

::: tip Java堆(Java Heap)是虚拟机所管理的内存中最大的一块。Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。在《Java虚拟机规范》中对Java堆的描述是:所有 的对象实例以及数组都应当在堆上分配。:::

1、概述

  • • 一个JVM实例只存在一个堆内存, 堆是Java内存管理核心区城
  • • Java堆在JVM创建的时候被创建,其空间大小也就被确定(堆内存的大小是可以调节的)
  • • 《Java虚拟机规范》规定,堆可以处于物理上不连续的内存空间中,但逻辑上应该被视为连续的
  • • 所有线程共享Java堆(这里还可以划分出线程私有的缓冲区TLAB)
  • • 《Java虚拟机规范》中对堆的描述是:所有的对象实例以及数组都应该运行时分配到堆上(注意:只能说是几乎所有的对象实例都在这里分配内存-要从实际使用角度看,具体描述看第8点)
  • • 数组和对象可能永远不会存储在栈上(因为栈帧中保存引用,这个引用指向对象或者数组在堆中的位置)
  • • 在方法结束之后,堆中的对象不会马上被移除,仅仅在垃圾收集的时候才会被移除
  • • 堆,是GC执行垃圾回收的重点区域

2、构成

2.1、jdk7

  • • 新生代(Young Generation Space) +老年代(Tenure generation space)+永久区(Permanent space)
  • • 新生代又分为伊甸园区(Eden)+s1(Survlvor1)+s2(Survor2)

2.2、jdk8

  • • 新生代(Young Generation Space) +老年代(Tenure generation space)+元空间(Meta Space)
  • • 新生代又分为伊利园区(Eden)+s1(Survlvor1)+s2(Survor2)

3、构成详述

Java堆进一步分可以分为年轻代和老年代,年轻代又可以分为伊甸园区(Eden),幸存者0(Survivor0)和幸存者1(Survivor1)空间,有时候也叫from区和to区。

JVM内存模型(三)------ 运行时数据区_JVM


3.1、概述

存储在JVM中的Java对象可以划分为两大类:一类是生命周期较短的瞬时对象,这些对象的创建和消亡都非常迅速;另一类是生命周期非常长,在某些情况下能够与JVM生命周期保持一致。

区间所占比例:

  • • 默认:-XX:NewRatio=2,也就是新生代占一份,老年代占两份,新生代占整区的1/3
  • • 可以通过-XX:NewRatio=4进行修改,表示为新生代占1,老年代占4,新生代占整区的1/5
  1. 1. 新生代

伊甸园区,几乎所有的对象都是在Eden区new出来的,新生代中80%的対象都是朝生夕死,绝大多数的Java对象销毁都在新生代中进行,可以使用-Xmn设置新生代的最大内存大小,但是这一参数一般使用默人值就行。

  1. 1. 老年代

老年代主要用于存储生命周期特别长的对象。

3.2、对象在内存空间的分配过程

  • • new对象的时候先放入伊甸园区(Eden) ,此区有大小限制
  • • 当伊甸园区填满的时候,程序又需要创建对象,JVM垃圾回收器会对伊甸园区进行垃圾回收(Minor GC) ,将伊甸园区中不再被其他对象引用的对象迸行摧毁,再加载新的対象到伊甸园区
  • • 然后将伊甸园区剩余的对象移动到幸存者0区,幸存者1区是空的
  • • 如果继续有对象在伊甸园区创建然后伊甸园区存不下就再次触发垃圾回收,此时对伊甸园区内进行回收的同时还对上次幸存下来的放到幸存者0区的进行垃圾收集,接下来将0区存活下来的移动到1区,伊甸园中存活下来的也放入幸存者1区。0区是空的。
  • • 如果再次进行垃圾回收,此时会重新放入幸存者0区,接着再去幸存者1区
  • • 毎次在幸存者去循坏,毎跳动一次, age加1, 默人当达到15次的时候,会将对象放入养老区(age默认次数可以通过修改-XX:MaxTenuringThreshold=<N>来设置)
  • • 在养老区,相当悠闲,当养老区不足的时候,再次触发GC:Major GC,进行养老区的内存清理
  • • 如果养老区执行了Major GC之后依然发现无法对对象迸行保存,就发生OOM异常(java.lang.OutOfMemoryErorjava heap space

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

3.3、问题

  1. 1. 为什么需要把java堆分代?不分代就不能正常工作了么? 不同对象的生命周期不同,大约有70%-90%的对象是临时对象,因此,通过分代,将对象通过生命周期大小过滤,针对不同的区域采用不同的垃圾收集方式,可以很大的提高GC收集性能。其实不分代完全可以,分代的唯一的理由就是优化GC性能

3.4、内存分配策略

正常情况,对象在堆中生命周期为:如果Eden出生并经过MinorGC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并将对象的年龄设置为1,对象在Survivor中每熬过一次Minor GC,年龄就增加1,当年龄达到一定程度(默认15岁)时,就会被晋升到老年代中。

对象晋升老年代年龄阈值,可以通过:-XX:MaxTenuringThreshold来设置

分配策略:

  • • 优先分配到Eden
  • • 大对象直接分配到老年代(所以要尽量避免程序中出现大对象)
  • • 长期存活的对象分配到老年代(正常情况下计算年龄大于15进入老年代)
  • • 动态年龄判断(如果Survivor区间相同年龄的所有对象大小总和大于Survivor空间的一般,年龄大于或者等于该年龄对象的可以直接进入老年代,无需等到Max TenuringThreshold中的年龄要求

空间分配担保:-XX:HandlePromotionFailure

4、堆内存大小的设置

堆大小一般情况JVM启动的时候就已经设定好了:

  • • -Xms
  • • 作用:用于表示堆的起始内存,等价于-XX:InitalHeapSize。用于设置堆空间(年轻代+老年代)的初始内存大小
  • • 参数说明:X:JVM运行参数 ms:memory start
  • • -Xmx
  • • 作用:表示堆区的最大内存,等价于-XX:MaxHeapSize.用于设置堆空间(年轻代+老年代)的最大内存大小

  • • 查看设定参数:

  • • 方式一:1.jps获得进程id 2. jstat -gc 进程id

  • • 方式二:启动参数上面添加:-XX:+PrintGCDetails

  • • 默认设置大小:

  • • 初始内存大小 物理电脑内存大小/64

  • • 最大内存大小 物理电脑内存大小/4

一旦堆区中的内存大小超过-Xmx所指定的最大内存时,就会抛出OutOMemoryError异常 我们通常会將-Xms-Xmx两个参数配置相同的值,其目的是为了能够在java垃圾回收机制清理完堆之后不需要重新分隔计算堆区的大小,从而提高性能。

5、堆中垃圾回收概述

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

5.1、部分垃圾回收

  1. 1. 新生代收集(Minor GC/Youn GC
  • • 只是新生代垃圾回收
  • • 触发机制:1)当年轻代空间不足时,就会触发Minor GC,这里的满指的是Eden代满,**Survivor满不会引发GC **(每次Minor GC会清理年轻代的内存)2)因为Java对象大多具备朝生夕灭的特性,所以Minor GC执行会非常频繁,一般回收速度也比较快3)Minor GC会引发STW,暂停其他用户的线程,等垃圾回收结束,用户线程才会恢复执行(STW----stop the word,指的是暂停用户现在执行的线程,执行垃圾回收的线程)

回收过程:

JVM内存模型(三)------ 运行时数据区_JVM_02


  1. 1. 老年代收集(Minor GC/old GC
  • • 只是老年代的垃圾收集
  • • 特点:1)出现Major GC至少伴随一次Minor GC(也就是老年代不足的时候,会先尝试触发Minor GC,之后还不足,就会触发Major GC) 2)Major GC一般比Minor GC慢10倍以上,STW的时间会更长 3)如果Major GC后,内存还不足,就报OOM

注意:只有CMS GC会单独回收老年代行为;很多时候Major GC会和Full GC混合使用,需要具体分辨是老年代回收还是整堆回收

  1. 1. 混合收集(Mixed GC
  • • 收集整个新生代以及部分老年代的垃圾收集(目前只有G1 GC有这种行为)

5.2、整堆收集

Full Gc:整个Java堆和方法区的垃圾收集

触发机制:

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

**说明: ** full GC是开发或者调优中尽量避免的,这样暂停时间会短一些

5.3、查看垃圾收集的参数

启动参数内添加:-Xms600m -Xmx600m -XX:+PrintGCDetails

可以输出一些垃圾收集的信息。

6、TLAB

6.1、什么是TLAB

1)从内存模型而不是垃圾收集的角度,对Eden区进行划分,JVM为每个线程分配一个私有缓存区域,他包含在Eden区中 2)多线程同时分配内存时,使用TLAB可以避免一系列的非线程安全问题

简单来说:也就是由于堆是内存共享的,所以为了解决线程安全问题,为每个线程在堆中创建一个私有的内存空间,就是TLAB

6.2、为什么要使用TLAB

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

简单来说:就是堆是属于内存共享区域,因此针对于不同的线程,可以使用同一对象地址,比如一种情况,a线程中要创建user对象,b线程同一时间也要创建user对象,而堆空间不是线程安全的,就又可能a线程和b线程拿到同一个user对象,这样也就会导致产生线程安全问题,如果不存在TLAB的话,我们就需要对创建对象的时候使用加锁机制,就会导致性能降低。

6.3、说明

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

7、堆中常用参数设置

官网地址

  • • -XX:+PrintFlagsInitial 查看所以参数的默认初始值
  • • -XX:+PrintFlagsFinal 查看所有参数的最终值(可能会存在修改,不再是初始值)
  • • -Xms 初始堆空间内存(默认为系统空间的1/64)
  • • -Xmx 最大堆内存空间(默认为物理内存的1/4)
  • • -Xmn 设置新生代的大小(初始值及最大值)
  • • -XX:NewRatio 配置新生代与老年代的堆结构占比
  • • -XX:SurvivorRatio 设置新生代中Eden区和S0/S1的比例
  • • -XX:MaxTenuringThreshold 设置新生代垃圾的最大年龄
  • • -XX:+PrintGCDetails 输出详细的GC处理日志
  • • -XX:+PrintGC 打印简单信息
  • • -verbose:gc 打印简单信息
  • • -XX:HandlePromotionFailure 是否设置空间分配担保

8、堆是内存分配的唯一选择吗?

随着JIT编译期的发展与逃逸技术的逐渐成熟,栈上分配,标量替换优化技术将会导致一些微妙变化,导致所有对象分配到堆上面也不是那么绝对了。在虚拟机中,对象是在java堆中分配内存的,这是一个普遍常识,但是有一种特殊情况, 如果经过逃逸分析后发现,一个对象如果并没有逃逸出方法的话,那么可能就会优化在栈上分配,这样就无需在堆上分配内存,也无需进行垃圾回收,也是常见的堆外存储技术。

8.1、概述

  • • 如何将堆中内存分配到栈,需要用到逃逸分析
  • • 这是一种有效减少Java程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法
  • • 通过逃逸分析, Hotspot虚拟机可以推断出一个对象的作用域,从而进一步推断出是否需要将这个对象分配到堆上
  • • 逃逸分析的基本行为其实就是分析对象的动态作用域
  • • 如果一个对象在一个方法中被定义后, 而对象只是在该方法内部使用,则认为没有发生逃逸
  • • 如果一个对象在方法中被定义,在外部方法中有被引用,就认为逃逸了

逃逸示例:

/**
 * 该方法认为发生了逃逸
 */
public static StringBuffer createStringBuffer(String s1,String s2){
  StringBuffer sb = new StringBuffer() ;
  sb.append(s1) ;
  sb.append(s2);
  return sb;
}

未逃逸示例:

/**
 * 该方法认为发生了逃逸
 */
public static String createStringBuffer(String s1,String s2){
  StringBuffer sb = new StringBuffer() ;
  sb.append(s1) ;
  sb.append(s2);
  return sb.toString();
}

8.2、逃逸发生的几种情况

逃逸发生情况分析:

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  userEscapeAnalysis (){
    EscapeAnalysis e =  new  EscapeAnalysis() ;
  }
  
  /**
   *  引用成员变量的值 , 发生了逃逸
   */
  public void  userEscapeAnalysis1 (){
    EscapeAnalysis e = getInstance() ;
  }
}

8.3、逃逸参数设置

  • • 在JDK 6u23版本之后,HotSpot默认就开启了逃逸分析
  • • -XX:+DoEscapeAnalysis显示开启逃逸分析
  • • -XX:+PrintEscapeAnalysis查看逃逸分析的筛选结果

8.4、逃逸分析代码优化

  • • 栈上分配(将堆分配转化为栈分配。一个对象如果在子程序中被分配,要么指向该对象的指针永远不会发生逃逸,对象分配可能是栈上分配时候的候选,而不是堆上面分配)
  • • 同步省略(如果一个对象发现只能从一个线程中被访问到,那么对于对这个对象的操作可以不考虑同步)

如以下代码:

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

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

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

  • • 标量替换(有些时候一个对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的(或者全部)都可以不存储在内存,而是存储在CPU寄存器上面)-XX:+ EliminateAllocations 开启标量替换,允许对象打撒分配到栈上标量(Scalar) :指的是一个无法再分解成更小的数据的数据。Java中的原始数据类型就是标量。聚合量(Aggregate):相对的,那么还可以继续分解的数据就叫做聚合量,如Java对象就属于聚合量,因为他可以分解成其他聚合量和标量。在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);
 }

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

8.5、逃逸分析说明

逃逸分析通过多年发展,至今仍然不成熟。

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

9、总结

  • • 年轻代是对象的诞生、成长、消亡的区域,一个对象在这生产,引用,绝大多数的对象在这里被垃圾回收器收集,结束生命。
  • • 老年代存放生命周期长的对象,通常都是从survivor区域中筛选拷贝过来的Java对象,当然也有特殊情况,我们知道普通的对象会被分配到TLAB上面,如果对象较大,JVM试图分配到Eden去中的其他位置,如果对象太大以至于无法在新生代中找到足够长的连续存储空间,JVM会直接分配到老年代。
  • • 当GC只发生在年轻代中,回收年轻代的行为被称为Manor GC。当GC发生在老年代则被称为Major GC或者Full GC,一 般的,Minor GC的发送频率要比Major GC高的很多,即老年代中垃圾回收的频率大大低于年轻代。


举报

相关推荐

0 条评论