JVM虚拟机整体结构与对象内存分配解析

·  阅读 245

JVM虚拟机整体结构解析

整体结构介绍

jvm整体分为:

  • -栈
  • 方法区
  • 本地方法栈
  • 程序计数器
栈 Stack

栈是JVM重要的组成部分,每有一个新的线程都JVM都会为其在栈上分配一份内存,线程里有栈帧,程序计数器。另外线程栈内存大小决定的线程数量的多少,当线程栈内存大小设置的越大,则同时存在的线程数量越少,反则越大。另外在栈中最容易发生的错误是StackOverflowError 栈溢出,看以下代码:

 public class StackOverflowTest { 
   static int count = 0;
    static void redo() { 
    count++; 
     redo(); 
     }
     public static void main(String[] args) { 
      try { 
    redo(); 
     } catch (Throwable t) { 
     t.printStackTrace(); 
     System.out.println(count); 
            } 
        } 
     }  
      运行结果:
      java.lang.StackOverflowError 
复制代码

参数影响: -Xss 256KB(默认1M) 设置栈大小 栈的大小会影响count 的次数,-Xss设置的大小越大,count的次数也就越大,反之亦然.

栈帧结构组成
  1. 局部变量表:主要用来保存声明的局部变量以及方法的参数信息,局部变量表作用于为当前方法,当方法执行完成后,局部变量表也会随之删除,释放内存。另外局部变量表里用来保存信息的叫做变量槽(slot)
类型占slot个数
byte1
short1
int1
long2
float1
double2
boolean1
char1

操作数栈:顾名思义,操作数栈其本质就是个栈,压栈,出栈两个操作,例如执行a+b,先将局部变量表中的a与b分别压入栈中,接着执行加法操作,最终出栈。

动态链接:是在程序运行期间完成的将符号引用替换为直接引用叫动态链接,既然有动态链接那么自然也有静态链接,部分符号引用在类加载阶段(解析)的时候就转化为直接引用,这种转化为静态链接。

方法返回地址:在方法退出(正常执行/异常返回)后,返回方法被调用的位置。

栈结构图

img

程序计数器(Program Counter Register)

程序计数器也叫PC寄存器是JVM非常重要的一个结构,是线程私有的,每个线程独有一份,它用来保存指向下一条将被执行指令的地址,例如当线程被阻塞再进行唤醒时,从程序计数器读取指令的地址,从而继续执行。

本地方法栈 Native Method Stack

本地方法栈主要是为了执行native方法,保存native方法进入区域的地址,所以本地方法栈也是线程私有的内存区域。

方法区 Method Area(元空间 Meta Space)

被所有的线程共享。方法区包含所有的class和static变量,类的方法代码,变量名,方法名,访问权限,返回值,以及我们经常说的常量池运行时常量池都是在方法区的。

堆 Heap

堆是非常重要的一个区域,管理着几乎(不是所有)所有的对象,我们常说的垃圾回收的主要区域就是发生在这个区域。堆分为新生代(young)与老年代(Old),新生代又分为Eden与survivor区,survivor分为From区与To区。这几个区存放着java的对象,当区内存不够的时候会发生GC,GC主要分为两种,一种是minorGC(Young GC),另一种是Full GC,JVM调优主要根据代码调节JVM参数,从而减少Full GC的次数。

堆结构示意图

image.png

逃逸分析

首先大家听得最多的就是new 出来对象是存放在堆中的,但是在上文中,所写的是几乎对象是存在堆中,那么为什么是几乎呢,因为有的对象是存放在栈中的,是不是很不可思议,接下来来看下一段代码。

// 方法一
public Person test1() {
        Person person = new Person();
        person.setId(1);
        return person;
        } 
// 方法二      
public void test2() { 
         User person = new person(); 
         person.setId(1); 
       }
复制代码

上述代码中很显然test1方法中的personr对象被返回了,那么这个对象就可能被其他方法进行引用,test2方法中的personr对象,当方法结束的时候,该对象就是一个无效对象了,不会在其他地方被进行引用,对于这样的对象,JVM将其分配的栈内存里,让其在方法结束时跟随栈内存一起被回收掉,减少堆内存的回收。 JVM对于这种情况可以通过开启逃逸分析参数(-XX:+DoEscapeAnalysis)来优化对象内存分配位置,JDK7之后默认开启逃逸分析,如果要关闭使用参数(-XX:-DoEscapeAnalysis)

对象内存分配

对象内存分配流程图

image.png

对象栈上分配

并不是所有对象都分配在内存,有的对象会被分配到栈上,JVM对于这种情况可以通过开启逃逸分析参数(-XX:+DoEscapeAnalysis)来优化对象内存分配位置,使其通过标量替换优 先分配在栈上(栈上分配),JDK7之后默认开启逃逸分析,如果要关闭使用参数(-XX:-DoEscapeAnalysis)

标量替换: 通过逃逸分析确定该对象不会被外部访问,并且对象可以被进一步分解时,JVM不会创建该对象,而是将该 对象成员变量分解若干个被这个方法使用的成员变量所代替,这些代替的成员变量在栈帧或寄存器上分配空间,这样就 不会因为没有一大块连续空间导致对象内存不够分配。

开启标量替换参数(-XX:+EliminateAllocations),JDK7之后默认 开启。

标量与聚合量: 标量即不可被进一步分解的量,也可以说是原子量,不可再分解,而JAVA的基本数据类型就是标量(如:int,long等基本数据类型以及 reference类型等),标量的对立就是可以被进一步分解的量,而这种量称之为聚合量。而在JAVA中对象就是可以被进一 步分解的聚合量

结论:栈上分配依赖于逃逸分析和标量替换

对象在Eden区分配

当对象刚被创建的时候会被分配在eden区,eden区满了后会触发minor gc,可能会有99%以上的对象成为垃圾被回收掉,剩余存活 的对象会被挪到为空的那块survivor区,下一次eden区满了后又会触发minor gc,把eden区和survivor区垃圾对象回收,把剩余存活的对象一次性挪动到另外一块为空的survivor区,因为新生代的对象都是生命值很短的,存活时间很短,所以JVM默认的8:1:1的比例是非常合理的一个比例值,因此我们呢应该让eden区尽量的大,survivor区够用即可,

JVM默认有这个参数**-XX:+UseAdaptiveSizePolicy**(默认开启),会导致这个8:1:1比例自动变化.

如果不想这个比例有变 化可以设置参数**-XX:-UseAdaptiveSizePolicy**

当Eden区内存不够用了会出现声明状况?

如果因为给新对象分配内存的时候eden区内存几乎已经被分配完了,bane当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC,GC期间虚拟机又发现新对象无法存入Survior空间,所以只好把新生代的对象提前转移到老年代中去,老年代上的空间足够存放新对象,所以不会出现Full GC。执行Minor GC后,后面分配的对象如果能够存在eden区的话,还是会在eden区分配内存。

大对象直接进入老年代

大对象就是需要大量连续内存空间的对象(比如:字符串、数组)。JVM参数 -XX:PretenureSizeThreshold 可以设置大 对象的大小,如果对象超过设置大小会直接进入老年代,不会进入年轻代,这个参数只在 Serial 和ParNew两个收集器下 有效(关于收集器日后再讲)。

比如设置JVM参数:-XX:PretenureSizeThreshold=1000000 (单位是字节) -XX:+UseSerialGC ,再执行下带有大对象的程序会发现大对象直接进了老年代

这样做的好处?

为了避免为大对象分配内存时的复制操作而降低效率。

长期存活的对象将进入老年代

既然虚拟机采用了分代收集的思想来管理内存,那么内存回收时就必须能识别哪些对象应放在新生代,哪些对象应放在 老年代中。为了做到这一点,虚拟机给每个对象一个对象年龄(Age)计数器。 如果对象在 Eden 出生并经过第一次 Minor GC 后仍然能够存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间中,并将对象年龄设为1。对象在 Survivor 中每熬过一次 MinorGC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁,CMS收集器默认6岁,不同的垃圾收集器会略微有点不同),就会被晋升到老年代中。对象晋升到老年代

的年龄阈值.

JVM参数设置 -XX:MaxTenuringThreshold

对象动态年龄判断

当前放对象的Survivor区域里(其中一块区域,放对象的那块s区),一批对象的总大小大于这块Survivor区域内存大小的

50%(-XX:TargetSurvivorRatio可以指定),那么此时大于等于这批对象年龄最大值的对象,就可以直接进入老年代了,

例如Survivor区域里现在有一批对象,年龄1+年龄2+年龄n的多个年龄对象总和超过了Survivor区域的50%,此时就会

把年龄n(含)以上的对象都放入老年代。这个规则其实是希望那些可能是长期存活的对象,尽早进入老年代。对象动态年

龄判断机制一般是在minor gc之后触发的。

老年代空间分配担保机制

年轻代每次minor gc之前JVM都会计算下老年代剩余可用空间 如果这个可用空间小于年轻代里现有的所有对象大小之和(包括垃圾对象) 就会看一个“-XX:-HandlePromotionFailure”(jdk1.8默认就设置了)的参数是否设置了 如果有这个参数,就会看看老年代的可用内存大小,是否大于之前每一次minor gc后进入老年代的对象的平均大小。 如果上一步结果是小于或者之前说的参数没有设置,那么就会触发一次Full gc,对老年代和年轻代一起回收一次垃圾, 如果回收完还是没有足够空间存放新的对象就会发生"OOM" 当然,如果minor gc之后剩余存活的需要挪动到老年代的对象大小还是大于老年代可用空间,那么也会触发full gc,full gc完之后如果还是没有空间放minor gc之后的存活对象,则也会发生“OOM.

总结

  1. 运行时数据区主要由程序计数器方法区本地方法栈

  2. 线程私有的区域:线程栈程序计数器本地方法栈,线程共享的区域:方法区

  3. 堆分为细分为新生代(Eden、survivor(From、To)默认比例8:1:1)、老年代

  4. 对象不全都是在堆中,经过发生逃逸符合条件的对象在栈中

  5. JVM整体结构图如下

image.png

END

分类:
后端
标签: