Skip to content

Java内存结构 ​

内容来源:本篇为学习整理,转载自网络技术博客(版权归原作者所有):

看在前面 ​

Java 内存区域详解 ​

如果没有特殊说明,都是针对的是 HotSpot 虚拟机。

写在前面 (常见面试题) ​

基本问题 ​

  • 介绍下 Java 内存结构(运行时数据区)

  • String 类和常量池

  • 8 种基本类型的包装类和常量池

一 概述 ​

对于 Java 程序员来说,在虚拟机自动内存管理机制下,不容易出现内存泄漏和内存溢出问题。C/C++程序开发程序员需要为每一个 new 操作去写对应的 delete/free 操作,Java 程序员不再需要这样。正是因为 Java 程序员把内存控制权利交给 Java 虚拟机。一旦出现内存泄漏和溢出问题,又不了解虚拟机怎样使用内存,排查错误会成为艰巨的任务。

二 运行时数据区域 ​

Java 虚拟机在执行 Java 程序的过程中会把它管理的内存划分成多个不同的数据区域。JDK. 1.8 和之前的版本略有不同,下面会介绍到。

JDK 1.8 之前:

JDK1.8之前运行时数据区域分布

JDK 1.8 :

JDK1.8运行时数据区域分布

线程私有的

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

线程共享的

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

2.1 程序计数器 ​

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

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

从上面的介绍中我们知道程序计数器主要有两个作用:

  1. 字节码解释器通过改变程序计数器来依次读取指令,实现流程控制。如:顺序执行、选择、循环、异常处理。

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

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

2.2 Java 虚拟机栈 ​

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

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

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

Java 虚拟机栈会出现两种错误:StackOverFlowError 和 OutOfMemoryError。

  • StackOverFlowError:Java 虚拟机栈的内存大小不允许动态扩展。线程请求栈的深度超过最大深度时,就抛出 StackOverFlowError 错误。

  • OutOfMemoryError:若 Java 虚拟机栈的内存大小允许动态扩展,当线程请求栈时内存用完了,无法再动态扩展。此时抛出 OutOfMemoryError 错误。

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

扩展:方法/函数的调用方式如下。

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

Java 方法有两种返回方式:

  1. return 语句。

  2. 抛出异常。

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

2.3 本地方法栈 ​

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

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

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

2.4 堆 ​

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

Java 堆是垃圾收集器管理的主要区域,因此也被称作GC 堆(Garbage Collected Heap)。从垃圾回收的角度,现在收集器普遍采用分代垃圾收集算法。所以 Java 堆还可以细分为:新生代和老年代。再细致一点有:Eden 空间、From Survivor、To Survivor 空间。进一步划分的目的是更好地回收内存,或者更快地分配内存。

在 JDK 7 版本及JDK 7 版本之前,堆内存被通常被分为下面三部分:

  1. 新生代内存(Young Generation)

  2. 老生代(Old Generation)

  3. 永久代(Permanent Generation)

JVM堆内存结构-JDK7

JDK 8 版本之后方法区(HotSpot 的永久代)被彻底移除了(JDK1.7 就已经开始了)。取而代之是元空间,元空间使用的是直接内存。

JVM堆内存结构-JDK8

上图所示的 Eden 区、两个 Survivor 区都属于新生代。为了区分,这两个 Survivor 区域按照顺序被命名为 from 和 to。中间一层属于老年代。

通常对象都会首先在 Eden 区域分配。一次新生代垃圾回收后,如果对象还存活,则会进入 s0 或者 s1,对象的年龄还会加 1。Eden 区->Survivor 区后对象的初始年龄变为 1。当它的年龄增加到一定程度(默认为 15 岁),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。

修正(issue552):“Hotspot遍历所有对象时,按照年龄从小到大对其所占用的大小累积。当累积的某个年龄大小超过了survivor区的一半时,就取这个年龄和MaxTenuringThreshold中更小的一个值。此值作为新的晋升年龄阈值”。 动态年龄计算的代码如下

c++
uint ageTable::compute_tenuring_threshold(size_t survivor_capacity) {
	//survivor_capacity是survivor空间的大小
  size_t desired_survivor_size = (size_t)((((double) survivor_capacity)*TargetSurvivorRatio)/100);
  size_t total = 0;
  uint age = 1;
  while (age < table_size) {
    total += sizes[age];//sizes数组是每个年龄段对象大小
    if (total > desired_survivor_size) break;
    age++;
  }
  uint result = age < MaxTenuringThreshold ? age : MaxTenuringThreshold;
	...
}

堆这里最容易出现的就是 OutOfMemoryError 错误,并且出现这种错误之后的表现形式还会有几种,比如:

  1. OutOfMemoryError: GC Overhead Limit Exceeded : 当JVM花太多时间执行垃圾回收,只能回收很少的堆空间。此时发生此错误。

  2. java.lang.OutOfMemoryError: Java heap space :创建新对象时, 堆内存空间不足以存放新对象。此时就会引发java.lang.OutOfMemoryError: Java heap space错误。(和本机物理内存无关,和你配置的内存大小有关。)

  3. ......

2.5 方法区 ​

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

方法区也被称为永久代。很多人都会分不清方法区和永久代的关系,为此我也查阅了文献。JDK 1.8 的时候,方法区(HotSpot 的永久代)被彻底移除了(JDK1.7 就已经开始了)。取而代之是元空间,元空间使用的是直接内存。

2.5.1 方法区和永久代的关系 ​

《Java 虚拟机规范》只是规定了有方法区这么个概念和它的作用,并没有规定如何去实现它。那么,在不同的 JVM 上方法区的实现肯定是不同的了。 方法区和永久代的关系很像 Java 中接口和类的关系。类实现了接口,而永久代就是 HotSpot 虚拟机对虚拟机规范中方法区的一种实现方式。 即永久代是 HotSpot 的概念,方法区是 Java 虚拟机规范中的定义。方法区是一种规范,而永久代是一种实现,一个是标准一个是实现。其他的虚拟机实现并没有永久代这一说法。

image

2.5.2 常用参数 ​

JDK 1.8 之前永久代还没被彻底移除的时候通常通过下面这些参数来调节方法区大小

java
//方法区 (永久代) 初始大小
-XX:PermSize=N 

//方法区 (永久代) 最大大小,超过这个值将会抛出 OutOfMemoryError 异常:java.lang.OutOfMemoryError: PermGen
-XX:MaxPermSize=N

垃圾收集行为在这个区域很少出现,但并非数据进入方法区后就“永久存在”了。

下面是一些常用参数:

java
-XX:MetaspaceSize=N //设置 Metaspace 的初始(和最小大小)
-XX:MaxMetaspaceSize=N //设置 Metaspace 的最大大小

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

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

  1. 整个永久代有一个 JVM 本身设置固定大小上限,无法调整。而元空间使用的是直接内存,受本机可用内存的限制。虽然元空间仍旧可能溢出,但是比原来出现的几率会更小。

当你元空间溢出时会得到如下错误: java.lang.OutOfMemoryError: MetaSpace

你可以使用 -XX:MaxMetaspaceSize 标志设置最大元空间大小。默认值为 unlimited,这意味着它只受系统内存的限制。-XX:MetaspaceSize 调整标志定义元空间的初始大小。如果未指定此标志,则 Metaspace 将根据运行时的应用程序需求动态地重新调整大小。

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

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

2.5.4 方法区是 JVM 提出的规范,而各个虚拟机的实现可以是不同的。 ​

  • 永久代则是 JDK8 前 Hotspot 虚拟机规范的实现(Java Heap 中)

  • 元空间 JRockit、J9、JDK8 后 HotSpot 虚拟机的实现(在本地内存)

2.5.5 为什么 HotSpot 移除永久代? ​

  • 移除永久代后,不会遇到永久代存在的内存溢出错误。也不会出现泄漏的数据移到交换区这样的事情。最终用户可以为元空间设置一个可用空间最大值。如果不设置,JVM 会自动根据类的元数据大小动态增加元空间的容量。

  • 整个永久代有一个 JVM 本身设置固定大小上限,无法调整。而元空间使用的是直接内存,受本机可用内存的限制。虽然元空间仍旧可能溢出,但是比原来出现的几率会更小。

当你元空间溢出时会得到如下错误: java.lang.OutOfMemoryError: MetaSpace

你可以使用 -XX:MaxMetaspaceSize 标志设置最大元空间大小。默认值为 unlimited,这意味着它只受系统内存的限制。-XX:MetaspaceSize 调整标志定义元空间的初始大小。如果未指定此标志,则 Metaspace 将根据运行时的应用程序需求动态地重新调整大小。

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

  • 这项改动是必要的,因为永久代很难调优。永久代中的元数据可能会随着每一次 Full GC 发生而移动。

HotSpot 虚拟机移除永久代改为元空间实现过程是? ​

2.6 运行时常量池 ​

Class 文件中除了有类的版本、字段、方法、接口这类描述信息。还有用于存放编译期生成的各种字面量(Literal)和符号引用(Symbolic Reference)的 常量池表(Constant Pool Table) 。字面量是源代码中的固定值的表示法,即通过字面我们就能知道其值的含义。字面量包括整数、浮点数和字符串字面量。符号引用包括类符号引用、字段符号引用、方法符号引用和接口方法符号引用。常量池表会在类加载后存放到方法区的运行时常量池中。

运行时常量池的功能类似于传统编程语言的符号表,尽管它包含了比典型符号表更广泛的数据。既然运行时常量池是方法区的一部分,自然受到方法区内存的限制。当常量池无法再申请到内存时,会抛出 OutOfMemoryError 错误。

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

参考资料:

2.7 字符串常量池 ​

字符串常量池 是 JVM 为了提升性能和减少内存消耗,针对字符串(String 类)专门开辟的一块区域。主要目的是为了避免字符串的重复创建。

java
// 在堆中创建字符串对象”ab“
// 将字符串对象”ab“的引用保存在字符串常量池中
String aa = "ab";
// 直接返回字符串常量池中字符串对象”ab“的引用
String bb = "ab";
System.out.println(aa==bb);// true

HotSpot 虚拟机中字符串常量池的实现是 src/hotspot/share/classfile/stringTable.cpp ,StringTable 本质上就是一个HashSet<String> ,容量为 StringTableSize(可以通过 -XX:StringTableSize 参数来设置)。

StringTable 中保存的是字符串对象的引用,字符串对象的引用指向堆中的字符串对象。

JDK1.7 之前,字符串常量池存放在永久代。JDK1.7 字符串常量池和静态变量从永久代移动了 Java 堆中。

image

image

image

JDK 1.7 将字符串常量池移动到堆中的原因如下:

主要是因为永久代(方法区实现)的 GC 回收效率太低,只有在整堆收集 (Full GC)的时候才会被执行 GC。Java 程序中通常会有很多被创建的字符串等待回收。将字符串常量池放到堆中,能够更高效地回收字符串内存。

最后再来分享一段周志明老师说过的内容,出处如下:

原话如下:运行时常量池、方法区、字符串常量池这些都是不随虚拟机实现而改变的逻辑概念。这些是公共且抽象的。Metaspace、Heap 是与具体某种虚拟机实现相关的物理概念,是私有且具体的。

2.8 直接内存 ​

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

JDK1.4 中新加入的 NIO(New Input/Output) 类,引入了一种基于通道(Channel) 与缓存区(Buffer) 的 I/O 方式。它可以直接使用 Native 函数库直接分配堆外内存。然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用,操作这块内存。这样就能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆之间来回复制数据。

本机直接内存的分配不会受到 Java 堆的限制。但是,既然是内存就会受到本机总内存大小以及处理器寻址空间的限制。