内存结构图解

通用版

Java 虚拟机在运行Java程序时,把它所管理的内存划分为若干个不同的数据区域,主要包括以下五个部分:程序计数器、Java 堆、Java虚拟机栈、方法区和本地方法栈。

QQ20180624-150918

Guide哥版

JDK 1.6

JVM运行时数据区域

JDK 1.8

Java运行时数据区域JDK1.8

运行时数据区域

程序计数器

程序计数器是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器,它会指出下一条将要执行的指令的地址。

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

程序计数器是线程私有的一小块内存,每条线程都要有一个独立的程序计数器,以使线程切换后恢复到正确的执行位置,所以是线程私有的。

具体数据内容:

  • 如果线程正在执行 Java 方法,则计数器记录的是正在执行的虚拟机字节码指令的地址;
  • 如果执行 native 方法,则计数器为空。

作用:

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

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

虚拟机栈

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

Java 内存可以粗糙的区分为堆内存(Heap)和栈内存 (Stack),其中栈就是现在说的虚拟机栈,或者说是虚拟机栈中局部变量表部分。

Java 虚拟机栈是由一个个栈帧组成,每个方法在在执行的同时都会创建一个栈帧,而每个栈帧中都拥有:局部变量表、操作数栈、动态链接、方法出口信息。每一个方法从调用直至执行完成,都对应着一个栈帧在虚拟机栈中入栈到出栈的过程。

局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量是完全确定的,在运行期间栈帧不会改变局部变量表的大小空间。

局部变量表存放的数据类型:

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

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

  • StackOverflowError:若 Java 虚拟机栈的内存大小不允许动态扩展,线程请求的栈深度大于虚拟机所允许的深度时会抛出该异常。
  • OutOfMemoryError:若Java 虚拟机栈的内存大小可以动态扩展,虚拟机栈扩展时无法申请到足够的内存时会抛出该异常。

本地方法栈

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

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

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

Java 虚拟机所管理的内存中最大的一块。

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

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

Java 堆的实现,既可以实现为固定的,也可以是扩展的。当前虚拟机都按照可扩展来实现,通过 -Xmx-Xms 控制堆大小,分别用于用于设置 Java 堆的最大和最小内存;如果两个值相同,则证明 Java 堆不允许扩展。

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

  1. java.lang.OutOfMemoryError: GC Overhead Limit Exceeded : 当 JVM 花太多时间执行垃圾回收并且只能回收很少的堆空间时,就会发生此错误。
  2. java.lang.OutOfMemoryError: Java heap space :假如在创建新的对象时, 堆内存中的空间不足以存放新创建的对象, 就会引发此错误。(和配置的最大堆内存有关,且受制于物理内存大小。最大堆内存可通过-Xmx参数配置,若没有特别配置,将会使用默认值,详见:Default Java 8 max heap size)

堆的特性,参考Java 内存之 Java 堆 - bleem

  • Java 堆内存是全局共享的
  • Java 堆通常是 JVM 中最大的一块内存区域
  • Java 堆的主要作用是用于存放创建的对象实例(JVM 规范中,所有对象都必须在堆内分配
  • JVMS 明确要求,此区域必须实现内存自动管理,即 GC;但不要求具体的 GC 实现,包括实现算法和技术
  • Java 堆可以在物理上不连续空间分配,只要逻辑上连续即可
  • Java 堆可能出现 OutOfMemoryError 异常
  • Java 堆可以使固定大小的,也可以实现为动态扩展的,当前主流 JVM 都是可扩展的

对象分配过程

Java 堆分配对象实例的图例大致如下:

hexo_java_jmm_heap1

左侧代码会让 JVM 编译时在 Java 虚拟机栈的局部变量表中预留一个 Solt,且类型为 reference;右侧的代码在运行时会在 Java 堆中创建对象实例,并在对象头中存放该对象对应数据类型的指针,其指向方法区中的对象数据类型。

方法区

方法区与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

方法区的作用是存储 Java 类的结构信息,当我们创建对象实例后,对象的类型信息存储在方法区之中,实例数据存放在中。

实例数据指的是在 Java 中创建的各种实例对象以及它们的值。

类型信息指的是定义在 Java 代码中的常量、静态变量、以及在类中声明的各种方法、方法字段等等,同时可能包括即时编译器编译后产生的代码数据。

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

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

如果方法区无法满足内存分配需求,会抛出 OutOfMemoryError 异常。

运行时常量池

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

动态性是运行时常量池相对于 Class 文件常量池的一个重要特征,即不要求常量一定只有编译期才能产生(运行时常量池中的内容并不全部来自 Class 常量池),运行期间也可能将新的常量放入池中。

运行时常量池受到方法区内存的限制,如果常量池无法再申请内存,就会抛出 OutOfMemoryError 异常。

直接内存

直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,它是利用 Native 函数库在 Java 堆外申请分配的内存区域,可以避免在 Java 堆和 Native 堆中复制数据以提高性能。

这部分内存也被频繁地使用,因此可能导致 OutOfMemoryError 错误出现。

字符串常量池

字符串常量池 是 JVM 为了提升性能和减少内存消耗针为字符串(String 类)专门开辟的一块区域,主要目的是为了避免字符串的重复创建。JDK1.7 之前运行时常量池逻辑包含字符串常量池存放在方法区。JDK1.7 的时候,字符串常量池被从方法区拿到了堆中。

对于编译期可以确定值的字符串,也就是常量字符串 ,JVM会将其存入字符串常量池。并且,字符串常量拼接得到的字符串常量在编译阶段就已经被存放字符串常量池,这个得益于编译器的优化。

对象引用和“+”的字符串拼接方式,实际上是通过 StringBuilder 调用 append() 方法实现的,拼接完成之后调用 toString() 得到一个 String 对象 。

1
2
3
4
5
String str1 = "str";
String str2 = "ing";
String str4 = str1 + str2; //在堆上创建的新的对象
//等同于
String str4 = new StringBuilder().append(str1).append(str2).toString();

只要使用 new 的方式创建对象,便需要创建新的对象

1
String str2 = new String("abcd"); // 直接在堆内存空间创建一个新的对象。

使用 new 的方式创建对象的方式如下,可以简单概括为 3 步:

  1. 在堆中创建一个字符串对象
  2. 检查字符串常量池中是否有和 new 的字符串值相等的字符串常量
  3. 如果没有的话需要在字符串常量池中也创建一个值相等的字符串常量,如果有的话,就直接返回堆中的字符串实例对象地址。

字符串常量池比较特殊,它的主要使用方法有两种:

  1. 直接使用双引号声明出来的 String 对象会直接存储在常量池中。
  2. 如果不是用双引号声明的 String 对象,使用 String 提供的 intern() 方法也有同样的效果。

面试题:String s1 = new String("abc");这句话创建了几个字符串对象?

会创建 1 或 2 个字符串:

  • 如果字符串常量池中已存在字符串常量“abc”,则只会在堆空间创建一个字符串常量“abc”。
  • 如果字符串常量池中没有字符串常量“abc”,那么它将首先在字符串常量池中创建,然后在堆空间中创建,因此将创建总共 2 个字符串对象。

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

Java 基本类型的包装类的大部分都实现了常量池技术。

Byte,Short,Integer,Long 这 4 种包装类默认创建了数值 [-128,127] 的相应类型的缓存数据,Character 创建了数值在 [0,127] 范围的缓存数据,Boolean 直接返回 True Or False所有整型包装类对象之间值的比较,全部使用 equals 方法比较

两种浮点数类型的包装类 Float,Double 并没有实现常量池技术。

对象的创建过程

Java创建对象的过程

Step1:类加载检查

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

Step2:分配内存

类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。

内存分配方式

指针碰撞

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

空闲列表

  • 适用场合:堆内存不规整的情况下
  • 原理:虚拟机会维护一个列表,该列表中会记录哪些内存块是可用的,在分配的时候,找一块足够大的内存块来划分给对象实例,最后更新列表记录。
  • GC收集器:CMS

内存分配并发问题

在创建对象的时候有一个很重要的问题,就是线程安全,因为在实际开发过程中,创建对象是很频繁的事情,作为虚拟机来说,必须要保证线程是安全的,通常来讲,虚拟机采用两种方式来保证线程安全:

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

Step3:初始化零值

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

Step4:设置对象头

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

Step5:执行init方法

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

对象的内存布局

Hotspot 虚拟机的对象头包括两部分信息第一部分用于存储对象自身的运行时数据(哈希码、GC 分代年龄、锁状态标志等等),另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是那个类的实例。

实例数据部分是对象真正存储的有效信息,也是在程序中所定义的各种类型的字段内容。

对齐填充部分不是必然存在的,也没有什么特别的含义,仅仅起占位作用。 因为 Hotspot 虚拟机的自动内存管理系统要求对象起始地址必须是 8 字节的整数倍,换句话说就是对象的大小必须是 8 字节的整数倍。而对象头部分正好是 8 字节的倍数(1 倍或 2 倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。

对象的访问定位

建立对象就是为了使用对象,我们的 Java 程序通过栈上的 reference 数据来操作堆上的具体对象。对象的访问方式由虚拟机实现而定,目前主流的访问方式有以下两种:

使用句柄

Java 堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息。

对象的访问定位-使用句柄

直接指针

Java 堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而 reference 中存储的直接就是对象的地址。

对象的访问定位-直接指针

区别

  • 使用句柄:保证了局部变量表中 reference 指向的稳定性,因为其一直指向 Java 堆中的句柄,当 GC 时对象的频繁移动导致对象内存地址移动时,不会影响局部变量表中的 reference 的改变。
  • 直接指针:查找对象速度会很快,对比第二种 reference 引用句柄的方式,reference 直接指向对象实例在查找对象时节省了一次指针切换开销。Oracle HotSpot 采用的是这种内存布局。

常见的 OOM 及原因

Java 中的 OOM 指的就是 java.lang.OutOfMemoryError 异常。主要有以下几种:

java.lang.OutOfMemoryError:Java heap space

Java 堆中主要用于存放各种对象实例。当堆中没有足够的空间分配给新对象时,或者说达到了堆空间设置的最大空间限制,则会抛出此异常。

引起内存溢出的原因主要有:

  • 流量访问量大,超过设置的堆空间大小;
  • 内存泄露,不能被回收的对象消耗过多堆空间;

java.lang.OutOfMemoryError:Permgen space

JDK7 中,HotSpot 虚拟机使用永久代实现方法区,永久代较小,而且回收效率较低,很容易出现内存溢出。

因此,JDK8 取消了永久代,使用元空间来实现方法区,存放在本地内存中。

java.lang.OutOfMemoryError:Metaspace

方法区主要存储类的元信息,HotSpot 元数据区。当元空间没有足够的空间分配给加载的类时,会抛出此异常。

引起元数据区空间不足的原因主要有:

  • 加载的类太多,常见于 jsp 页面过多时;
  • 元空间被实现在堆外,主要受到进程本身的内存限制,一般很难出现溢出。

常见面试题

不同的虚拟机在实现运行时内存的时候有什么区别?

《Java虚拟机规范》定义的JVM运行时所需的内存区域,不同的虚拟机实现上有所不同,而在这么多区域中,规范对于方法区的管理是最宽松的。虚拟机规范对方法区实现的位置并没有明确要求,在最著名的HotSopt虚拟机实现中(在Java 8 之前),方法区仅是逻辑上的独立区域,在物理上并没有独立于堆而存在,而是位于永久代中。所以,这时候方法区也是可以被垃圾回收的。

运行时数据区中哪些区域是线程共享的?哪些是独享的?

在JVM运行时内存区域中,PC寄存器、虚拟机栈和本地方法栈是线程独享的。而Java堆、方法区是线程共享的。但是值得注意的是,Java堆其实还未每一个线程单独分配了一块TLAB空间,这部分空间在分配时是线程独享的,在使用时是线程共享的。

除了JVM运行时内存以外,还有什么区域可以用吗?

它不是运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域,他就是——直接内存。

直接内存的分配不受Java堆大小的限制,但是他还是会收到服务器总内存的影响。

在JDK 1.4中引入的NIO中,引入了一种基于Channel和Buffer的I/O方式,他可以使用Native函数直接分配堆外内存,然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的应用进行操作。

堆和栈的区别是什么?

堆和栈(虚拟机栈)是完全不同的两块内存区域,一个是线程独享的,一个是线程共享的,二者之间最大的区别就是存储的内容不同:堆中主要存放对象实例,栈(局部变量表)中主要存放各种基本数据类型、对象的引用

Java中的数组是存储在堆上还是栈上的?

在Java中,数组同样是一个对象,所以对象在内存中如何存放同样适用于数组;

所以,数组的实例是保存在堆中,而数组的引用是保存在栈上的。

img

Java中的对象创建有多少种方式?

Java中有很多方式可以创建一个对象,最简单的方式就是使用new关键字。

1
User user = new User();

除此以外,还可以使用反射机制创建对象:

1
User user = User.class.newInstance();

或者使用Constructor类的newInstance:

1
2
Constructor<User> constructor = User.class.getConstructor();
User user = constructor.newInstance();

除此之外还可以使用clone方法和反序列化的方式,这两种方式不常用并且代码比较复杂。

Java中对象创建的过程是怎么样的?

  1. 虚拟机遇到new指令,到常量池定位到这个类的符号引用。
  2. 检查符号引用代表的类是否被加载、解析、初始化过。
  3. 虚拟机为对象分配内存。
  4. 虚拟机将分配到的内存空间都初始化为零值。
  5. 虚拟机对对象进行必要的设置。
  6. 执行方法,成员变量进行初始化。

Java中的对象一定在堆上分配内存吗?

Java堆中主要保存了对象实例,但是,随着JIT编译期的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。

其实,在编译期间,JIT会对代码做很多优化。其中有一部分优化的目的就是减少内存堆分配压力,其中一种重要的技术叫做逃逸分析。

如果JIT经过逃逸分析,发现有些对象没有逃逸出方法,那么有可能堆内存分配会被优化成栈内存分配。

如何获取堆和栈的dump文件?

Java Dump是Java虚拟机的运行时快照,将Java虚拟机运行时的状态和信息保存到文件。

可以使用在服务器上使用jmap命令来获取堆dump,使用jstack命令来获取线程的调用栈dump。

Reference