JVM内存区域

68

线程私有的:

  • 程序计数器
  • 虚拟机栈
  • 本地方法栈

线程共享的:

  • 方法区
  • 直接内存 (非运行时数据区的一部分)

1.程序计数器

​ 当前线程所执行的字节码的行号指示器。字节码解释器工作时通过改变这个计数器的值来选取下一条需要执行的字节码指令。分支,循环,跳转,异常处理,线程回复等功能都需要依赖这个计数器来完成。

​ 为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各线程之间计数器互不影像,独立存储,我们称这类内存区域为线程私有的内存。

​ 1.字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制。

​ 2.在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。

​ 注意:程序计数器是唯一一个不会OutOfMemoryError的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡

2.Java虚拟机栈

​ 与程序计数器一样,Java虚拟机栈也是线程私有的,它的生命周期和线程相同,描述的是Java方法执行的内存模型,每次方法调用的数据都是通过栈传递的。

​ Java内存可以粗糙的区分为堆内存(Heap)和栈内存(Stack),其中栈内存就是现在说的虚拟机栈,或者说是虚拟机栈中局部变量表部分。(Java虚拟机栈是由一个个栈帧组成,而每个栈帧中都拥有:局部变量表,操作数栈,动态链接,方法出口信息)。

​ 局部变量表主要存放了编译期可知的各种数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference 类型,它不同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)。

  • StackOverFlowError: 若 Java 虚拟机栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候,就抛出 StackOverFlowError 错误。
  • OutOfMemoryError: Java 虚拟机栈的内存大小可以动态扩展, 如果虚拟机在动态扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError异常。

Java 虚拟机栈也是线程私有的,每个线程都有各自的 Java 虚拟机栈,而且随着线程的创建而创建,随着线程的死亡而死亡。

扩展:那么方法/函数如何调用?

Java 栈可以类比数据结构中栈,Java 栈中保存的主要内容是栈帧,每一次函数调用都会有一个对应的栈帧被压入 Java 栈,每一个函数调用结束后,都会有一个栈帧被弹出。

Java 方法有两种返回方式:

  1. return 语句。
  2. 抛出异常。

不管哪种返回方式都会导致栈帧被弹出。

3.本地方法栈

​ 和虚拟机栈所发挥的作用非常相似,区别是: 虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。  在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。

​ 本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息。

​ 方法执行完毕后相应的栈帧也会出栈并释放内存空间,也会出现 StackOverFlowError 和 OutOfMemoryError 两种错误。

4.堆

​ Java 虚拟机所管理的内存中最大的一块,Java 堆是所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。

​ 新生代,老年代;Eden空间,from Survivor、to Survivor空间。

JDK1.7之前堆内存:
  1. 新生代内存(Young Generation)
  2. 老生代(Old Generation)
  3. 永生代(Permanent Generation)

​ JDK1.8之后方法区(HotSpot永生代)被彻底移除,取代之是元空间(使用的是直接内存)

Hotspot 遍历所有对象时,按照年龄从小到大对其所占用的大小进行累积,当累积的某个年龄大小超过了 survivor 区的一半时,取这个年龄和 MaxTenuringThreshold 中更小的一个值,作为新的晋升年龄阈值

5.方法区

​ 用户存储已被虚拟机加载的类信息,常量,静态常量,即时编译器编译后的代码等数据。虽然 Java 虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做 Non-Heap(非堆) ,目的应该是与 Java 堆区分开来。

方法区也被称为永久代。 方法区和永久代的关系很像 Java 中接口和类的关系,类实现了接口,而永久代就是 HotSpot 虚拟机对虚拟机规范中方法区的一种实现方式。  也就是说,永久代是 HotSpot 的概念,方法区是 Java 虚拟机规范中的定义,是一种规范,而永久代是一种实现,一个是标准一个是实现,其他的虚拟机实现并没有永久代这一说法。

jdk1.8之前永久代还没彻底移除的时候
-XX:PermSize=N //方法区 (永久代) 初始大小
-XX:MaxPermSize=N //方法区 (永久代) 最大大小,超过这个值将会抛出 OutOfMemoryError 异常:java.lang.OutOfMemoryError: PermGen

JDK 1.8 的时候,方法区(HotSpot 的永久代)被彻底移除了(JDK1.7 就已经开始了),取而代之是元空间,元空间使用的是直接内存。
-XX:MetaspaceSize=N //设置 Metaspace 的初始(和最小大小)
-XX:MaxMetaspaceSize=N //设置 Metaspace 的最大大小

与永久代很大的不同就是,如果不指定大小的话,随着更多类的创建,虚拟机会耗尽所有可用的系统内存。

[为什么要将永久代 (PermGen) 替换为元空间 (MetaSpace) 呢?

1.整个永久代有一个 JVM 本身设置的固定大小上限,无法进行调整,而元空间使用的是直接内存,受本机可用内存的限制,虽然元空间仍旧可能溢出,但是比原来出现的几率会更小。可以使用 -XX:MaxMetaspaceSize 标志设置最大元空间大小,默认值为 unlimited,这意味着它只受系统内存的限制。-XX:MetaspaceSize 调整标志定义元空间的初始大小如果未指定此标志,则 Metaspace 将根据运行时的应用程序需求动态地重新调整大小。

2.元空间里面存放的是类的元数据,这样加载多少类的元数据就不由MaxPermSize控制,而由系统的实际可用空间来控制,这样能加载的类就更多了。

3.在JDK8,合并HotSpot和JRockit的代码时,JRockit从来没有永久代这个东西,合并之后没有必要额外的设置这么一个永久代的地方。

6.运行时常量池

​ 运行时常量池是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有常量池表(用于存放编译期生成的各种字面量和符号引用)

既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError 错误。

JDK1.7 及之后版本的 JVM 已经将运行时常量池从方法区中移了出来,在 Java 堆(Heap)中开辟了一块区域存放运行时常量池。

  1. JDK1.7 之前运行时常量池逻辑包含字符串常量池存放在方法区, 此时 hotspot 虚拟机对方法区的实现为永久代
  2. JDK1.7 字符串常量池被从方法区拿到了堆中, 这里没有提到运行时常量池,也就是说字符串常量池被单独拿到堆,运行时常量池剩下的东西还在方法区, 也就是 hotspot 中的永久代 。
  3. JDK1.8 hotspot 移除了永久代用元空间(Metaspace)取而代之, 这时候字符串常量池还在堆, 运行时常量池还在方法区, 只不过方法区的实现从永久代变成了元空间(Metaspace)

7.直接内存

​ 直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用。而且也可能导致 OutOfMemoryError 错误出现。

8.对象的创建

  1. 类加载检查

    1. 虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
  2. 分配内存

    1. 类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。分配方式有  “指针碰撞”  和  “空闲列表”  两种,选择哪种分配方式由 Java 堆是否规整决定,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定

    2. 指针碰撞:

      1. 适用场合:堆内存规整(没有内存碎片)的情况下
      2. 原理:用过的内存全部整合到一边,没有用过的内存放在另一边,中间有一个分界值指针,只需要向着没用过的内存方向将该指针移动对象内存大小位置即可。
      3. GC收集器:Serial,ParNew
    3. 空闲列表:

      1. 使用场合:堆内存不规整的情况下
      2. 原理:虚拟机会维护一个列表,该列表中会记录哪些内存块是可用的,在分配的时候,找一块足够大的内存块来划分给对象实例,最后更新列表记录
      3. GC收集器:CMS
    4. 在创建对象的时候有一个很重要的问题,就是线程安全,因为在实际开发过程中,创建对象是很频繁的事情,作为虚拟机来说,必须要保证线程是安全的,通常来讲,虚拟机采用两种方式来保证线程安全:

      • CAS+失败重试:  CAS 是乐观锁的一种实现方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性。
      • TLAB:  为每一个线程预先在 Eden 区分配一块儿内存,JVM 在给线程中的对象分配内存时,首先在 TLAB 分配,当对象大于 TLAB 中的剩余内存或 TLAB 的内存已用尽时,再采用上述的 CAS 进行内存分配
  3. 初始化零值

    1. 内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。
  4. 设置对象头

    1. 初始化零值完成之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。  另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。
  5. 执行init方法

    1. 在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始,<init> 方法还没有执行,所有的字段都还为零。所以一般来说,执行 new 指令之后会接着执行 <init> 方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。
垃圾收集算法
  1. 标记-清除

    1. 将堆内存扫描一遍,然后把无引用对象标记一下
    2. 继续扫描,扫描的同时将被标记的对象进行统一回收
    3. 缺点1:标记和清除两个过程都比较耗时,效率不高
    4. 缺点2:会产生大量不连续的内存碎片
  2. 复制

    1. 主要用在存活对象比较少的情况(新生代)
  3. 标记-整理

    1. 主要用在存活对象比较多的情况(不适合用于复制,复制消耗性能,老年代)
  4. 分代收集

    1. 当一个对象被创建的时候(new)首先会在年轻代(Eden)区被创建,直到当GC的时候,根据可达性算法,看一个对象是否消亡,没有消亡的对象将会被放入新生代的Survivor区,消亡的直接被Minor GC(次要的,普通的GC)kill掉。
    2. 进入到Survivor区的对象也不是安全的,当下一次Minor GC来的时候还是会检查Eden和Survivor存放对象区域中对象是否存活,存活放入另外一块Survivor区域。
    3. 当2个Survivor区域切换几次以后,会直接进入老年代,当然进入到老年代也不是安全的,当老年代内存空间不足的时候,会触发Major GC(主要的,全局的GC),已经消亡的依然会被kill掉。

垃圾回收器

  • 新生代:Serial , ParNew , Paraller Scave age ,G1

  • 老年代:CMS , Serial Old ,Paraller Old , G1

  • 组合方式

    • Serial + CMS
    • Serial + Serial Old
    • ParNew + CMS
    • ParNew + Serial Old
    • Paraller Scave age + Serial Old
    • Paraller Scave age + Paraller Old
    • G1 + G1
  • Serial/Serial Old收集器

    • 串行收集器,只能用一个线程去回收。垃圾收集的过程中会Stop the world(服务暂停)
    • 参数控制:-XX:+UseSerialGC 串行收集器+Serial Old
  • ParNew收集器

    • Serial收集器的多线程版本,用户线程需要等待(STW)

    • 参数控制: -XX:UseParNewGC ParNew收集器+Serial Old

      ​ -XX:ParallerGCThreads 限制线程数量

  • Paraller/Paraller Old收集器

    • 类似ParNew收集器,可以通过参数来打开自适应调节策略,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或最大的吞吐量,也可以通过参数控制GC的时间不大于多少毫秒或者比例

    • 参数控制: -XX:UseParallerGC 年轻代使用Paraller收集器 + 老年代串行(Serial Old)

      ​ -XX:UseParallerOldGC 年轻代使用Paraller收集器 + 老年代并行(Paraller Old)  [此时用户线程处于STW]

  • CMS收集器

    • Concurrent Mark Sweep 并发收集器
    • 初始标记->并发标记->重新标记->并发清除
    • 初始标记和重新标记会STW
    • 优点:并发收集,低停顿
    • 缺点:使用标记--清除算法,会产生大量空间碎片,并发阶段会降低吞吐量,对CPU资源敏感,无法收集浮动垃圾,需要预留一部分内存在GC的适合供程序运行,如果预留空间不足,可能会出现Concurrent Mode Failure失败而导致触发一次Full GC
  • G1收集器

    • 空间整合,G1收集器采用标记整理算法,不会产生内存空间碎片。分配大对象时不会因为无法找到连续空间而提前触发下一次GC
    • 可预测停顿。
    • 优点:并行与并发,分代收集,空间整合,可预测停顿
    • 初始标记->并发标记->最终标记->筛选回收