Java内存空间

1 概述

Java虚拟机在执行Java程序时会将它所管理的内存划分成若干个不同的数据区域。

下图是JVM的整体结构,中间部分就是Java虚拟机定义的各种运行时数据区域。这些内存区域可以分为两类:

  • 线程私有:程序计数器、虚拟机栈、本地方法栈
  • 线程共享:堆、方法区、堆外内存(Java7的永久代,JDK8的元空间、代码缓存)

2 程序计数器

程序计数器是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器字节码解释器工作时通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等功能都需要依赖这个计数器来完成。

另外,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。 因此程序计数器有两个作用:

  1. 代码流程控制: 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
  2. 线程切换: 在多线程的情况下,程序计数器用于记录当前线程执行位置,从而当线程被切换回来的时候知道该线程上次运行到哪儿了。

3 虚拟机栈

每个线程在创建的时候都会创建一个虚拟机栈,其内部保存一个个的栈帧(Stack Frame),对应着一次次 Java 方法调用,是线程私有的,生命周期和线程一致。 其中每个栈帧中都拥有:局部变量表、操作数栈、动态链接、方法出口信息。 结构如下图所示:

  1. 作用

    主管 Java 程序的运行,它保存方法的局部变量、部分结果,并参与方法的调用和返回。

  2. 特点

    • 栈是一种快速有效的分配存储方式,访问速度仅次于程序计数器。
    • JVM 直接对虚拟机栈的操作只有两个:每个方法执行,伴随着入栈(进栈/压栈),方法执行结束出栈
    • 栈不存在垃圾回收问题
  3. 异常

    JVM的虚拟机栈可能出现两种异常:

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

4 本地方法栈

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

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

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

5 堆

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

5.1 堆划分

为了进行高效的垃圾回收,虚拟机把堆内存逻辑上划分成三块区域(分代的唯一理由就是优化 GC 性能):

  • 新生代:新对象和没达到一定年龄的对象都在新生代 。新生代又分成三部分(默认比例8:1:1):

    • Eden:大多数新创建的对象都存在于Eden区。
    • S0/S1:当Eden空间满时,执行Minor GC,并将所有幸存者移动到同一个S区(所以每次总有一个S区是空的),并且幸存者年龄加1,当幸存者年龄达到阈值后,将其移动到老年区。
  • 老年代: 被长时间使用的对象,老年代的内存空间应该要比年轻代更大 。

    需要注意的是, 大对象直接进入老年代(大对象是指需要大量连续内存空间的对象)。这样做的目的是避免在 Eden 区和两个Survivor 区之间发生大量的内存拷贝。

  • 元空间 (JDK1.8 之前叫永久代):像一些方法中的操作临时对象等,JDK1.8 之前是占用 JVM 内存,JDK1.8 之后直接使用物理内存。元空间里面存放的是类的元数据,这样加载多少类的元数据就不由 MaxPermSize 控制了,而由系统的实际可用空间来控制,这样能加载的类就更多了 。

5.2 堆内对象分配过程

  1. new 的对象先放在伊甸园区,此区有大小限制
  2. 当伊甸园的空间填满时,程序又需要创建对象,JVM 的垃圾回收器将对伊甸园区进行垃圾回收(Minor GC),将伊甸园区中的不再被其他对象所引用的对象进行销毁。再加载新的对象放到伊甸园区
  3. 然后将伊甸园中的剩余对象移动到幸存者 0 区
  4. 如果再次触发垃圾回收,此时上次幸存下来的放到幸存者 0 区,如果没有回收,就会放到幸存者 1 区
  5. 如果再次经历垃圾回收,此时会重新放回幸存者 0 区,接着再去幸存者 1 区
  6. 什么时候才会去养老区呢? 默认是 15 次回收标记
  7. 在养老区,相对悠闲。当养老区内存不足时,再次触发 Major GC,进行养老区的内存清理
  8. 若养老区执行了 Major GC 之后发现依然无法进行对象的保存,就会产生 OOM 异常

6 方法区

虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫non-Heap(非堆),目的应该是将其与Java堆区分开。

方法区是Java虚拟机规范中定义的一种概念, 用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等。 下图展示了堆、栈、方法区三者之间的交互关系:

6.1 运行时常量池

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

  1. 为什么需要运行时常量池?

    一个 Java 源文件中的类、接口,编译后产生一个字节码文件。而 Java 中的字节码需要数据支持,通常这种数据会很大以至于不能直接存到字节码里,换另一种方式,可以存到常量池,这个字节码包含了指向常量池的引用。在动态链接的时候用到的就是运行时常量池。另外,运行时也可能生成一些常量,比如String类的intern()方法。

  2. 运行时常量池工作原理

    • 在加载类和结构到虚拟机后,就会创建对应的运行时常量池

    • 常量池表(Constant Pool Table)是 Class 文件的一部分,用于存储编译期生成的各种字面量和符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中

    • JVM 为每个已加载的类型(类或接口)都维护一个常量池。池中的数据项像数组项一样,是通过索引访问的

    • 运行时常量池中包含各种不同的常量,包括编译器就已经明确的数值字面量,也包括到运行期解析后才能够获得的方法或字段引用。此时不再是常量池中的符号地址了,这里换为真实地址

      • 运行时常量池,相对于 Class 文件常量池的另一个重要特征是:动态性,Java 语言并不要求常量一定只有编译期间才能产生,运行期间也可以将新的常量放入池中,String 类的 intern() 方法就是这样的
    • 当创建类或接口的运行时常量池时,如果构造运行时常量池所需的内存空间超过了方法区所能提供的最大值,则 JVM 会抛出 OutOfMemoryError 异常。

6.2 元空间、永久代、方法区傻傻分不清?

方法区(method area)只是 JVM 规范中定义的一个概念,用于存储类信息、常量池、静态变量、JIT编译后的代码等数据,并没有规定如何去实现它,不同的厂商有不同的实现。

在Java8之后,永久代被移除,替换成元空间。永久代中的类原型信息被移到元空间中,静态变量、常量池等并入堆中。永久代参数 -XX:PermSize-xx:MaxPermSize 也被元空间参数 -XX:MetaspaceSize-XX:MaxMetaspaceSize 取代。

7 内存分配策略

  1. 对象优先在Eden分配

    大多数情况下,对象在新生代 Eden 区分配,当 Eden 区空间不够时,发起 Minor GC。

  2. 大对象直接进入老年代

    大对象是指需要连续内存空间的对象,最典型的大对象是那种很长的字符串以及数组。

    经常出现大对象会提前触发垃圾收集以获取足够的连续空间分配给大对象。

    -XX:PretenureSizeThreshold,大于此值的对象直接在老年代分配,避免在 Eden 区和 Survivor 区之间的大量内存复制。

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

    为对象定义年龄计数器,对象在 Eden 出生并经过 Minor GC 依然存活,将移动到 Survivor 中,年龄就增加 1 岁,增加到一定年龄则移动到老年代中。

    -XX:MaxTenuringThreshold 用来定义年龄的阈值。

  4. 动态对象年龄判定

    虚拟机并不是永远地要求对象的年龄必须达到 MaxTenuringThreshold 才能晋升老年代,如果在 Survivor 中相同年龄所有对象大小的总和大于 Survivor 空间的一半,则年龄大于或等于该年龄的对象可以直接进入老年代,无需等到 MaxTenuringThreshold 中要求的年龄。

  5. 空间分配担保

    在发生 Minor GC 之前,虚拟机先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果条件成立的话,那么 Minor GC 可以确认是安全的。

    如果不成立的话虚拟机会查看 HandlePromotionFailure 设置值是否允许担保失败,如果允许那么就会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次 Minor GC;如果小于,或者 HandlePromotionFailure 设置不允许冒险,那么就要进行一次 Full GC。

8 常见Java 内存泄漏场景

内存泄漏是指:一个不再被程序使用的对象或者变量还在内存中占有存储空间。

情景一:静态集合类

如HashMap、LinkedList等等,如果这些容器类被设置成静态的,那么其生命周期与程序一致,尽管这些对象不被使用,其占用的内存也不会被回收。

情景二:各种连接

如数据库连接、网络连接、IO连接等。当不在使用这些连接时,需要调用close()方法,否则垃圾回收器不会回收这些对象。

情景三:变量不合理的作用域

通常,一个变量定义的作用域范围大于其使用范围时很可能会造成内存泄漏。另一方面,如果没有及时地把对象设置成null,也可能造成内存泄漏。

情景四:内部类持有外部类

在Java中,非静态的内部类和匿名内部类都会隐式地持有其外部类的引用。静态的内部类不会持有外部类的引用。当调用外部类方法产生一个内部类实例后,即使外部类实例不在被使用也不会不GC回收,因为内部类实例隐式地持有外部类实例的引用。

情景五:改变哈希值

一个对象被存储进HashSet集合中后,就不能修改这个对象中那些参与计算哈希值的字段了,否则,对象修改后的哈希值与最初存储进HashSet集合中的哈希值就不同了,在这种情况下,即使在contains方法使用该对象的当前引用作为参数去HashSet中检索对象,也找不到对象,这会导致无法从HashSet集合中单独删除当前对象,造成内存泄漏。

9 常见内存溢出报错

情景一:java.lang.OutOfMemoryError:Javaheapspace

​ java堆内存不够,一种原因是真的不够(可以通过配置JVM解决),另一种是程序中有死循环。

情景二:java.lang.OutOfMemoryError:GCoverheadlimitexceeded

​ GC释放很小空间却花费大量时间,一般是因为堆太小,没有足够的内存。

解决方案: 查看系统是否有使用大内存的代码或者死循环;配置JVM,限制使用内存

情景三:java.lang.OutOfMemoryError:PermGenspace

​ 这种是P区内存不够,可以通过配置JVM解决。

情景四:java.lang.OutOfMemoryError:unabletocreatenewnativethread

​ Stack空间不足以创建新的线程,一种是创建线程太多,另一种是stack确实太小了。

解决方案: 减小单个线程的大小;配置JVM减小堆内存,将内存让给Stack

情景五:java.lang.StackOverflowError

​ 线程栈溢出,要么是方法调用层次过多(无限递归),要么是线程栈太小。

解决方案: 优化程序设计;增大线程栈大小

打赏
  • 版权声明: 本博客所有文章除特别声明外,著作权归作者所有。转载请注明出处!
  • Copyrights © 2021-2022 Yin Peng
  • 引擎: Hexo   |  主题:修改自 Ayer
  • 访问人数: | 浏览次数:

请我喝杯咖啡吧~

支付宝
微信