02 Java内存区域和内存溢出异常
运行时数据区域
程序计数器(Program Counter Register)
- 当前线程所执行的字节码的行号指示器,字节码解释器通过更改这个计数器的值来选取下一条需要执行的字节码
- 主要作用
- 字节码解释器通过更改程序计数器来依次读取指令,从而实现代码的流程控制,比如:顺序执行、选择、循环、异常处理
- 在多线程的情况下,程序计数器用去记录当前线程执行的位置,从而确保线程被切换回来时能够知道上次运行到的位置
- 程序计数器是唯一一个不会出现 OutOfMemoryError(OOM) 的内存区域
- 生命周期:随线程创建而创建,随线程结束而死亡,每个线程都会有一个独立的程序计数器,与其他线程互不影响(线程私有)
Java 虚拟机栈
- 描述的是 Java 方法执行的内存模型,每个方法被执行时,Java 虚拟机会同步创建一个栈帧(Stack Frame)用于存储:局部变量表、操作数栈、动态连接、方法出口等信息,每个方法从被调用到执行完毕的过程,就对应一个栈帧在虚拟机中的入栈和出栈过程
- 局部变量表:主要存放编译期可知的各种数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference 类型,不同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)
- 错误类型
- StackOverFlowError(栈溢出):如果 Java 虚拟机栈的内存大小不允许动态扩展,当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度时,就会抛出此异常
- OutOfMemoryError(内存溢出):Java 虚拟机栈的内存大小可以动态扩展,如果虚拟机在动态扩展栈内存时无法申请到足够的内存空间,就会抛出此异常
本地方法栈(Native Method Stacks)
- 与虚拟机栈的作用类似,区别是本地方法栈为虚拟机中使用到的本地(Native)方法服务,而虚拟机栈为虚拟机执行 Java 方法(即字节码)服务
- 在部分虚拟机实现中把本地方法栈和 Java 虚拟机栈合二为一,比如 HotSpot
Java 堆(Java Heap)
- Java 虚拟机所管理的内存中最大的一块,是所有线程共享的内存区域,在虚拟机启动时创建。这个区域的唯一目的是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存
- "几乎"所有的对象都在堆中分配,但是随着 JIT 编译器的发展和逃逸分析技术的成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有对象分配到堆上的情况渐渐变得不那么绝对。
从 JDK1.7 开始以及默认开启逃逸分析,如果某些方法中的对象引用没有被返回或者未被外面使用(未逃逸出去),那么对象可以在栈上直接分配内存 - Java 堆是垃圾收集器管理的主要区域,也被称为 GC 堆(Garbage Collected Heap)。
- 从垃圾回收的角度
现在的收集器基本都采用分代垃圾收集算法,因此 Java 堆可以细分为:新生代、老年代,再细致一点可以分为:Eden、From Survivor、To Survivor 等空间。
进一步划分的目的是为了更好的回收内存,或者更好的分配内存- JDK7 及以前,堆内存通常分为三部分:新生代(Young Generation)、老生代(Old Generation)、永久代(Perm Generation)
- JDK8 之后方法区(HotSpot 的永久代)被彻底移除(JDK7 中已经开始移除),元空间取而代之,元空间的是使用直接内存
图中 Eden 区、两个 Survivor 区都属于新生区(按照顺序命名为 From 和 To,便于区分),中间一层属于老年代
大部分情况下,对象首先在 Eden 区分配,在一次新生代垃圾回收后,如果对象还存活则进入 S0 或者 S1,且对象年龄加 1(Eden 区 -> Survivor 区后对象初始年龄变为 1 ),当对象年龄增加到一定程度时(默认 15 岁),会被晋升到老年代中,对象晋升到老年代的年龄阈值可以通过参数-XX:MaxTenuringThreshold
设置
HotSpot 遍历所有对象时,按照年龄从小到大对其所占用的大小进行累积,当累积的某个年龄超过 Survivor 区的一半时,取这个年龄和 MaxTenuringThreshold 之间较小的值作为新的晋升年龄阈值
- JDK7 及以前,堆内存通常分为三部分:新生代(Young Generation)、老生代(Old Generation)、永久代(Perm Generation)
- 从内存分配角度:所有线程共享的 Java 堆中可以划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB),以提升对象分配时的效率
- 从垃圾回收的角度
- 堆中最容易出现的错误就是 OOM 错误,并且会有几种表现形式
1java.lang.OutOfMemoryError: GC Overhead Limit Exceeded
:当 JVM 花太多时间执行垃圾回收且只能回收很少的堆空间时,就会发生此错误
2.java.lang.OutOfMemoryError:Java heap space
:假如创建新的对象时,堆内存的空间不足以存放新创建的对象,就会发生此错误(与配置的最大堆内存有关,且受制于物理内存大小。最大堆内存可通过-Xmx
配置,-Xms
设置初始堆内存,否则使用默认值)
方法区(Method Area)
- 和 Java 堆一样,各个线程共享的内存区域,用于存储已经被虚拟机加载的类信息、常量、静态变量,即编译器编译后的代码等数据,Java 虚拟机规范把方法区描述为 Java 堆的一个逻辑部份,但是它还有一个别名叫非堆(Non-Heap),为的就是与 Java 堆区分开来
- 永久代:在 JDK8 之前,很多人把方法区称为“永久代”(Permanent Generation),或将两者混为一谈。事实上方法区!=永久代,仅仅因为当时的 HotSpot 虚拟机设计团队选择将垃圾收集器的分代设计扩展到方法区,或者说是用永久代来实现方法区而已,使得 HotSpot 的垃圾收集器能像管理 Java 堆一样管理这部分内存,节省了开发工作。然而这种设计导致了 Java 应用更容易遇到内存溢出的问题
在 JDK6 时开发团队计划放弃永久代,逐步改为采用本地内存(Native Memory)来实现方法区;JDK7 以及把原本放在永久代的字符串常量池、静态变量等移出;JDK8 中完全废弃永久代概念,改用在本地内存中实现的元空间(MetaSpace)来代替,把 JDK7 中永久代还剩余的内容(主要是类型信息)全部移到元空间中 - 运行时常量池:方法区的一部分,同样受到方法区内存的限制,常量池无法再申请到内存时会抛出 OOM 错误。 Class 文件中除了类的版本、字段、方法、接口等描述信息外,还有常量池表(用于存放编译期生成的各种字面量和符号引用),这部分内容在类加载后存放到方法区的运行时常量池中
- JDK1.8 中,字符串常量池在 Java 堆中,运行时常量池还在方法区(使用元空间实现)
直接内存
- 直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但这部分内存也被频繁使用,也有可能导致 OOM 错误出现
- JDK1.4 中新加入的 NIO(New Input/Output)累,引入了一种基于通道(channel)与缓存区(Buffer)的 I/O 方式,可以直接使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作,可以在一些场景中显著提高性能,避免在 Java 堆和 Native 堆之间来回复制数据
- 本机直接内存的分配不会受到 Java 堆的限制,但是会受本机总内存大小和处理器寻址空间的限制