JVM内存模型

  对于Java开发者来说,我们不必关注内存的使用和释放问题,而是统一的交由Java虚拟机去统一的管理,这样一方面大大减轻了开发者的负担,同时也降低的开发的门槛,所以现在Java的广泛使用,Java虚拟机功不可没。虽然我们在开发过程中不必关注虚拟机的运行状况,但如果我们比了解虚拟机的运行原理,一旦出现虚拟机内存溢出的问题或者虚拟机成为整个项目的瓶颈时,我们就没有办法快速的定位和解决问题。所以JVM运行原理是每一个资深的Java开发者必备的知识。本片文章主要介绍JVM的内存模型。

一、计算机内存模型

  在介绍JVM内存模型之前,我们先回忆一下计算机的内存模型。计算机的内存主要包括:磁盘,内存,高速缓存,CPU内置的寄存器等构成,其数据传输的效率依次升高。因为CPU的处理效率较高但是成本也比较高,而磁盘的传输效率较低但是也最便宜,所以为了解决这种尴尬的局面,我们采用多级缓存加内存的形式来解决,首先CPU会向高速缓存请求数据,没有时高速缓存就会向内存请求,如果内存同样没有,那么内存就会向磁盘请求I/O,进行数据传输。这种方式较好的解决了成本和效率的冲突,所以得以广泛使用。

  JVM作为操作系统中的一个进程,所以在内存中拥有一块独立的内存空间,详细如下图所示:

 

描述Java内存模型 面试 javajvm内存模型_java

 

二、JVM的主要内存区域

  如下图所示,JVM的内存根据线程的占用方式主要分为两部分:

  一、线程独占区:每一个线程在创建的同时,JVM会为其分配一块内存区域,用于存储该线程的数据,主要包括栈和程序计数器。

  二、线程共享区:该区域是对所有现场共享的区域,用存储加载的类信息和对象数据,主要分为堆和方法区。

描述Java内存模型 面试 javajvm内存模型_java_02

  下面我们会详细介绍一下这几块区域。

1.程序计数器

  在多线程下CPU的处理机制属于轮流切片机制,由CPU分配每个线程的切片时间,在任意时刻,一个处理器只会执行一个线程中的一条指令,当切片时间结束,CPU会保存线程,记录线程状态,和指令的执行进度。所以,为了保证线程切换后可以恢复到正确的位置,所以每一个线程都有一个独立的程序计数器,使得进程之间的程序计数器互不影响,所以程序计数器位于线程独占区域。当线程正在执行一个Java方法时,程序计数器记录的是虚拟机正在执行的指令地址。如果正在执行的Native方法时,计数器的内容为空。
  程序计数器是唯一一个在Java虚拟机规范中没有规定OutOfMemoryError的区域。

 2.虚拟机栈

  Java虚拟机栈与程序计数器相似,同属于线程私有区。栈描述的是Java方法执行期间的内存模型。如下图所示,当一个方法被一个线程调用时,该线程就会向自己的栈中压入一个栈帧,栈帧主要用于存储局部变量表、操作数栈、动态链接、方法出口等信息,方法结束时,栈帧会被弹出栈。

描述Java内存模型 面试 javajvm内存模型_描述Java内存模型 面试_03

  通常来说开发者比较关注栈帧的局部变量表,所以这里我着重介绍一下局部变量表。局部变量表存放了编译器可知的各种基本数据类型和引用类型。根据虚拟机栈的类型栈的深度可能会有不同的限制,当栈中栈帧数超过规定的长度后,会抛出StackOverflowError异常。当前大部分的虚拟机栈允许动态扩展,当动态扩展时申请不到足够内存时会抛出OutOfMemoryError异常。

3.堆和方法区

  Java堆是虚拟机管理的最大一块内存,同时也是对所有线程共享的内存区域,几乎所有的对象实例都分配在这一块区域,因此这一区域也是垃圾收集线程的主要目标,为了提高垃圾收集的效率,根据GC的回收算法将Java对分成几个区域,包括:Eden区域,From Survivor区域,To Survivor区域,老年代区域等几个主要区域。这里GC的垃圾回收算法会在之后的几篇文章中详细介绍。

描述Java内存模型 面试 javajvm内存模型_描述Java内存模型 面试_04

  方法区与堆相似,同样是线程共享区,主要用于存储虚拟机加载的类信息、常亮、静态变量等数据。由于HotSpot虚拟机选择将方法区纳入垃圾回收的范围里,所以方法区有时候也被称为永久带,对于方法区的回收效果是很差的,因为加载的类型信息和常量池的卸载条件较为苛刻,但是由于方法区的内存有限,必然存在内存溢出的问题,所以对于永久带依然会进行垃圾回收,只不过频率相对较低。JDK1.8之后,Java虚拟机弃用永久带,使用元空间来存储数据。

三、对内存的分配和线程安全问题

1.对象的定位

  到这里我们已经知道,对象的引用主要存储在栈帧的局部变量表中,而对象数据主要存储在堆和方法区中。我们在编程中通过创建对象来使用对象,那么JVM是如何在内存中找到我们需要的对象的呢?对象的存储主要在两部分,一个是对象的实例数据,即对应参数的值,一个是对象的类型参数。实例数据存储在堆中,类型数据存储在方法区中。JVM主流的访问方式有两种:

  1.句柄访问

  在堆中划分出一块单独的区域,我们称之为句柄池,栈中对象的引用地址指向句柄池的一个句柄,一个句柄包含实例数据的地址和对象类型数据的地址。使用句柄的好处是栈中存储的是稳定的句柄地址,在GC垃圾回收或者其他状况下对象需要被移动时,只需改变句柄的地址,栈中的地址不用变动。

描述Java内存模型 面试 javajvm内存模型_JVM_05

  2.直接访问

  当采用直接访问的方式时,栈中存储的是对象实例数据的地址,在实例数据中有包含指向对象类型数据的地址。直接访问相对于句柄访问少了一次指针定位的开销。

描述Java内存模型 面试 javajvm内存模型_运维_06

2.内存的分配策略

  当我们创建一个对象时,虚拟机会在堆中开辟一块空间,用于存储数据,那么虚拟机是如何在内存中开辟空间的呢?下面我们来详细了解一下。JVM内存的分配策略主要有两种:

  1.指针碰撞

   当堆内存的分布特点是规整的,即所有用过的内存都分配在一起,同时设置一个分界点指示器,当分配内存时,指示器移动所需要的距离,这种方式称为指针碰撞。规整的内存结构需要虚拟机具有压缩整理的功能,当有内存对象被回收后,虚拟机需要对现有的内存进行整理,重新生成完整的内存。

  2.空闲列表

  当堆内存的分布不是规整的时候,堆内存的分布是不规整的,这时就需要虚拟机维护一个空闲列表,用于记录空闲的内存区域的大小和地址,当需要分匹配内存时按照一些分配策略分配合适的内存块,当对象被回收后,对应的内存区域应当重新加入空闲列表。这种方式称为空闲链表法。
Serial、ParNew等收集器带有Compact过程,所以采用指针碰撞的算法,而CMS这种基于Maek-Sweep算法的收集器,一般采用空闲链表法。

3.分配空间时的线程安全问题

  对象的创建在JVM中是十分频繁的行为,在并发情况下内存的分配存在线程安全问题。例如当再给线程A分配内存时,线程B同样来申请内存,这种情形下可能会出现两个线程公用一块内存的情况。解决此类问题一般有两种解决方案。

  方案一、保证分配动作的原子性(CAS)

  虚拟机可能对对分配内存空间的动作进行同步处理,即采用CAS与失败重试的方式,保证更新操作的原子性。CAS算法是多线程编程中常遇到的一种编程思想,CAS算法通常情况下需要三个操作数,内存地址V,旧的预期值A,正确修改后的新值B。当线程要对内存地址为V的变量做修改时,会先取出内存V的数值,此时地址V的值是A,修改后的数值应该是B,当线程A要讲B值存入V的地址时,会将V中的值与A进行比较,当一样时存入数值,如果不同则重新获取A的值,重复上面的操作。

  方案二、尽量将内存分配的动作划分到不同的区域进行。

   虚拟机会将不同线程的分配划分到不同的内存区域中去,在线程创建的过程中会对应的在内存区域划分一块专属于该线程的区域,通常我们称之为本地线程分配缓冲(TLAB)。首先线程会在本线程的缓冲区域分配内存,只有当TLAB的内存用完才会分配新的TLAB,此时才需要考虑同步锁定。
  是否使用TLAB可以通过 -XX :+/-UseTLAB参数来设定。