JVM 知识--JVM 内存区域

本文最后更新于:12 天前

Java 内存区域

  1. Java 虚拟机在执行 Java 程序的过程中将它所管理的内存区域分成若干个不同的数据区域,称为 Java 内存区域.(即运行时数据区)
  1. 分为五个部分:

    程序计数器,虚拟机栈,本地方法栈,Java 堆,方法区

    其中前三个是线程隔离的,后两个是线程共享的

    其实还有直接内存是线程共享的,不过直接内存不属于 Java 运行时的内存区域

  2. 特别的,在 JDK 1.8 之后,原本也被称为永久代的方法区,就不要再称呼为永久代了,因为已经由一个叫做元空间的东西来实现了.

具体的每一个部分:

  1. 程序计数器

    它占用一小块内存空间,可以看作是当前线程所执行的字节码的行号指示器

    Java 虚拟机的多线程是通过线程轮流切换,分配处理器执行时间的方式来实现的,因此,为了让每条线程切换后都能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,让各个线程的执行不相互影响

    字节码解释器 工作时就是通过改变这个计数器来选取下一条需要执行的字节码指令

    JVM 内存区域中唯一一个没有定义 OOM 的区域

  2. 方法区:

    • 存储: 类信息,常量,static 变量, JIT (Java 即时编译)后的缓存

    • JIT 即时编译

      对象被拆散为标量类型间接地在栈上分配内存

    • JDK 7 以前还叫 永久代, JDK 8 之后就是元空间,其实都是对方法区的实现

      但是注意,元空间实际上不属于虚拟机内存,而是本地内存

      1. 为什么改变:

        1. 首先是 JDK 8 是 HotSpot 为了融合 JRocket 虚拟机,后者没有永久代

        2. 永久代经常用空间不够而发生内存溢出(MemoryOverflow),更换成永久代之后,元空间大小之取决于本地内存大小,减少 OOM 的发生

  3. Java 堆 (别名 GC 堆)

    • 存储:实例对象

    • 具体还可分为:

      1. Eden

      2. from survivor

      3. to survivor

        GC 发生前, to survivor 区一定是清空的

        GC 时,首先把 Eden 区的对象 复制到 to survivor,对于 from survivor 要进行判断是否达到年龄阈值,如果达到了,就直接复制到老年代,没有则还是复制到 to survivor 区,并且此时对象年龄加一

        如果 to survivor 区满了,那么直接把剩余的对象放入到老年代中

        复制阶段完成后,可以视为 Eden 区 和 from survivor 区全部是死对象,最后 from 和 to 区会调换名字,在下次 GC 时, to 会变成 from

      4. old

    • 对象分配规则

      1. 对象优先分配在新生代 Eden 区,如果 Eden 区没有足够的空间时,虚拟机执行一次 Minor GC

      2. 大对象直接进入老年代(大对象是指需要大量连续内存空间的对象)

        这样做的目的是避免在 Eden 区和两个 Survivor 区之间发生大量的内存拷贝(新生代采用复制算法收集内存)。

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

        虚拟机为每个对象定义了一个年龄计数器,如果对象经过了1次 Minor GC 那么对象会进入 Survivor 区,之后每经过一次 Minor GC 那么对象的年龄加1,知道达到阀值对象进入老年区。

        注意: JDK 7 可以设置年龄超过15,JDK 8 不可以,暂时不知道原因

        不可以超过15很好理解:

        对象头的 mark word 只设置了4个位来存储年龄信息,2^5-1=15,最多把一个字节用完也只能存到15

      4. 动态判断对象的年龄

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

      5. 空间分配担保机制

        每次进行 Minor GC 时,JVM 会计算 Survivor 区移至老年区的对象的平均大小,如果这个值大于老年区的剩余值大小则进行一次 Full GC,如果小于检查 HandlePromotionFailure 设置,如果 true 那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小,如果大于,则尝试进行一次Minor GC,但这次Minor GC依然是有风险的;如果小于或者HandlePromotionFailure=false,则改为进行一次Full GC。

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

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

  4. 虚拟机栈区 (内存模型)

    是 Java 方法 的运行时 内存模型 Java Memory Model

    因为线程之间的通信是通过 JMM 控制的

    每一个栈帧对应一个方法,每一个方法的调用到执行完成就对应着一个栈帧在虚拟机从入栈到出栈的过程

    栈帧中有: 局部变量表,操作数栈,动态连接,方法出口

    局部变量表包括: 基本数据类型,对象引用(可以是对象起始地址的引用指针,也可能是代表对象的句柄)

    局部变量表所需的内存在编译期间完成分配,分配多大空间是完全确定的,运行期间不会改变局部变量表的大小

    注意:

    这里我之前不理解为什么是不会在运行时改变局部变量表的大小的,例如在 while- true 的循环里面,一直 new 对象,这个不就是动态增加的吗,数量是不确定的

    然后我发现,new 对象是要显式赋值给一个引用对象的,或者加入到 list 里面,前者对象引用就在局部变量表里面,后者的引用则通过 list 的起始地址或者数组下标可以找到每个对象,实际上还是在局部变量表里面,没有区别

  5. 为什么要把堆和栈区分出来呢?栈中不是也可以存储数据吗?

    1. 从软件设计的角度看,栈代表了处理逻辑,而堆代表了数据。这样分开,使得处理逻辑更为清晰。分而治之的思想。这种隔离、模块化的思想在软件设计的方方面面都有体现。

    2. 堆与栈的分离,使得堆中的内容可以被多个栈共享(也可以理解为多个线程访问同一个对象)。这种共享的收益是很多的。一方面这种共享提供了一种有效的数据交互方式(如:共享内存),另一方面,堆中的共享常量和缓存可以被所有栈访问,节省了空间。

    3. 栈因为运行时的需要,比如保存系统运行的上下文,需要进行地址段的划分。由于栈只能向上增长,因此就会限制住栈存储内容的能力。而堆不同,堆中的对象是可以根据需要动态增长的,因此栈和堆的拆分,使得动态增长成为可能,相应栈中只需记录堆中的一个地址即可。

    4. 垃圾回收,

    5. 面向对象就是堆和栈的完美结合。其实,面向对象方式的程序与以前结构化的程序在执行上没有任何区别。但是,面向对象的引入,使得对待问题的思考方式发生了改变,而更接近于自然方式的思考。当我们把对象拆开,你会发现,对象的属性其实就是数据,存放在堆中;而对象的行为(方法),就是运行逻辑,放在栈中。我们在编写对象的时候,其实即编写了数据结构,也编写的处理数据的逻辑。

  6. 为什么不把基本类型放堆中呢?

    因为其占用的空间一般是1~8个字节——需要空间比较少,而且因为是基本类型,所以不会出现动态增长的情况——长度固定,因此栈中存储就够了,如果把他存在堆中是没有什么意义的(还会浪费空间,后面说明)。可以这么说,基本类型和对象的引用都是存放在栈中,而且都是几个字节的一个数,因此在程序运行时,他们的处理方式是统一的。但是基本类型、对象引用和对象本身就有所区别了,因为一个是栈中的数据一个是堆中的数据。最常见的一个问题就是,Java中参数传递时的问题。