【JVM系列一】深入理解JVM内存模型,从这一篇开始!

前言

版本:JDK1.7

本文主要内容是摘自《深入理解Java虚拟机》一书,将重点知识点的进行梳理与归纳总结,方便分享与交流,如有不对的地方还望指出。

1、JVM运行时数据区

根据《Java虚拟机规范(JavaSE7版)》的规定,Java虚拟机所管理的内存主要包括以下几个运行时数据区域:

1.1 程序计数器

特点:内存空间小,线程私有。它可以看做是当前线程所执行的字节码的行号指示器

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

由于JVM的多线程是通过线程轮流切换并分配CPU执行时间的方式来实现的,在任何一个确定的时间,一个处理器只能执行某一个线程中的指令。所以,当线程切换后要恢复到原来正确的执行位置,则需要每个线程对应一个单独的程序计数器,各线程间的计数器互不影响,独立存储,即这样的内存区域称为“线程私有”。

如果线程正在执行一个 Java 方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是 Native 方法,这个计数器的值则为 (Undefined)。它是唯一没有OutOfMemoryError异常的区域。

1.2 Java虚拟机栈

特点:线程私有,生命周期与线程相同。

Java虚拟机栈(Java Stack)描述的是 Java 方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表操作数栈动态链接方法出口等信息。每一个方法从调用直到执行结束,就对应着一个栈帧在虚拟机栈中从入栈出栈的过程。

局部变量表:存放了编译期可知的各种基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用reference 类型,它不等同于对象本身,可能是一个指向对象起始地址的引用指针,也有可能是指向一个代表对象的句柄或其他与此对象相关的位置,后面会介绍对象的访问方式:句柄直接指针)和 returnAddress 类型(指向了一条字节码指令的地址)。局部变量表所需的内存在编译器间完成分配,即当进入一个方法时,局部变量表的内存是完全确定的,在方法执行期间不会改变。

操作数栈(Operand Stack):也称作操作栈,是一个后入先出栈(LIFO)。随着方法执行和字节码指令的执行,会从局部变量表或对象实例的字段中复制常量或变量写入到操作数栈,再随着计算的进行将栈中元素出栈到局部变量表或者返回给方法调用者,也就是出栈/入栈操作

动态链接:Java虚拟机栈中,每个栈帧都包含一个指向运行时常量池中该栈所属方法的符号引用,持有这个引用的目的是为了支持方法调用过程中的动态链接(Dynamic Linking)。

方法出口:无论方法是否正常完成,都需要返回到方法被调用的位置,程序才能继续进行。

在JVM规范中,规定了该区域可能出现的两种异常:

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

1.3 本地方法栈

特点:与Java虚拟机栈功能类似。区别于 Java 虚拟机栈的是,Java 虚拟机栈为虚拟机执行 Java 方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。

同样,本地方法栈也有可能出现可能 StackOverflowError 和 OutOfMemoryError 异常。

1.4 Java 堆

特点线程共享,唯一目的就是存放对象实例(数组也属于对象)。

对于大部分应用来说,Java堆(Heap)是 JVM 所管理的内存中最大的一块

Java堆是垃圾收集的主要区域,由于大部分的Java对象生命周期都是很短暂的,只有少部分对象存活较长的时间,而且每个对象生命周期不一样,所以Java堆为了方便垃圾回收,采用GC分代收集机制,因此需要根据垃圾回收算法将Java再细分为不同的空间:

  • 新生代包括一个Eden区和两个大小相同的Survivor区域(S0:From Survivor区、S1:To Survivor区),Sun HotSop JVM默认是8:1:1。
  • 老年代:存放的是从年轻代存活下来的对象实例,或者是在新生代中没有足够的内存空间放下刚创建的大对象时,也会直接放在老年代中。新生代中存活对象次数默认是15次后还存活,就晋升,移动到老年代(涉及到GC回收内容会单独文章再分析)。

Java堆从内存分配角度看, 可能会划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer, TLAB),但是无论怎么划分,都与存放内容无关,无论哪个区域,存储的仍然都是对象实例。划分的目的无非就是为了更好地垃圾回收,或者能更快地分配内存空间。

其中,Java堆可以位于物理上不连续的内存空间,只要逻辑上连续即可,类似电脑磁盘空间。我们可以通过指定JVM参数:-Xmx-Xms去控制堆的最大内存与初始最小内存。

  • OutOfMemoryError:如果堆中没有内存完成实例分配,并且堆也无法再扩展时,抛出该异常。

1.5 方法区

特点内存共享

主要存储已被虚拟机加载的类信息、常量、静态变量即时编译器编译后的代码等数据。

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

注意:很多人将方法区也称为“永久代”,但两者本质上是不一样的,只是因为HotSpot虚拟机团队把GC分代收集扩展至方法区,或者说使用永久代来实现方法区,这样HotSpot的垃圾收集器就可以像管理Java堆内存一样去管理这部分内存,就不用专门为Java堆与方法区分别开发两套管理内存的代码。

方法区的内存回收目标主要是针对常量池的回收和对类型的卸载

区别(重点)

JDK1.6常量池放在方法区,JDK1.7常量池放在堆内存,JDK1.8放在元空间里面(与堆相互独立)。所以导致String的intern方法因为以上变化在不同JDK版本会有不同表现。

  • 在JDK1.7之前,运行时常量池逻辑包含字符串常量池存放在方法区, 此时HotSpot虚拟机对方法区的实现为永久代。
  • 在JDK1.7 中,字符串常量池从方法区被移除出到堆中, 这里没有提到运行时常量池,也就是说字符串常量池被单独拿到堆,运行时常量池剩下的东西还在方法区, 也就是hotspot中的永久代。
  • 在JDK1.8中, HotSpot 彻底移除了永久代,使用元空间(Metaspace)取而代之, 这时候字符串常量池还在堆中, 运行时常量池还在方法区, 只不过方法区的实现从永久代变成了元空间(Metaspace) 

注:由于方法区主要存储类的相关信息,所以对于动态生成类的情况比较容易出现永久代的内存溢出,将运行时常量池放在方法区(本地内存实现)。

元空间的本质和永久代类似,都是对JVM规范中方法区的实现,不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存

  • OutOfMemoryError:当方法区无法满足内存分配时,就抛出此异常。

1.6 运行时常量池

运行时常量池(Runtime Constant Pool)属于方法区的一部分

Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项就是常量池(Constant Pool Table),用于存放编译期的各种字面量符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。

(1)什么是字面量

字面量=字面值
解释:创建一个对象一般用到new关键字,但是给一个基本数据类型变量赋值可以不需要new关键字,基本类型的变量在java中是一种特别的内置数据类型,并非某个对象。
定义:给基本类型变量赋值的方式就叫做字面量或者字面值。

(2)什么是符号引用

符号引用:使用一组符号(任何形式的字面量,只要在使用时能够无歧义的定位到目标即可)来描述所引用的目标。主要包含以下3类:

  • 类和接口的全限定名:例如:Ljava/lang/String;这样,将类名中原来的"."替换为"/"得到的,主要用于在运行时解析得到类的直接引用。
  • 字段名称和描述符:字段也就是类或者接口中声明的变量,包括类级别变量(static)实例级的变量。
  • 方法名称和描述符:方法的描述类似于JNI动态注册时的“方法签名”,也就是参数类型+返回值类型。

在编译时,Java 类并不知道所引用的类的实际地址,因此只能使用符号引用来代替。比如 com.stwen.test.A 类引用了 com.stwen.test.B 类,编译时 A 类并不知道 B 类的实际内存地址,因此只能使用符号com.stwen.test.B

直接引用:通过对符号引用进行解析,找到引用的实际内存地址。该过程主要发生在类加载的Resolution(解析)阶段,该阶段将常量池中的符号引用转化为直接引用。解析出来的直接引用也是存储在运行时常量池中。

1.7 直接内存

直接内存(Direct Memory)并不属于虚拟机运行时数据区。

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

  • OutOfMemoryError:会受到本机内存限制,如果内存区域总和大于物理内存限制从而导致动态扩展时出现该异常。

1.8 HotSpot 虚拟机对象探秘

通过上面对JVM的运行时数据区的介绍,应该对JVM有了大概的了解,也知道了哪些区域存放什么数据,下面我们将进一步分析JVM内存中数据的其他细节,如它们是如何创建、如何布局及如何访问的。对于这些涉及细节的地方,必须针对某一个区域来讨论才有意义。基于实用优先原则,下面我们将分析HotSpot 的JVM在Java 堆中对象分配、布局和访问过程(更详细的分析建议阅读原书)。

1.8.1 对象的创建

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

1)分配方式

类加载检查通过之后,为新对象分配内存(内存大小在类加载完成后便可完全确定)。在堆的空闲内存中划分一块区域,根据Java堆内存区域是否规整,可采用不同的内存分配方式:

  • 指针碰撞(内存规整):假如Java堆内存绝对规整,已使用的内存放一边,未使用的空闲内存在另一边,中间放着一个指针作为分界点的指示器,分配内存时仅需把指针往空闲空间那边移动一段与对象大小相等的距离即可。
  • 空闲列表(内存交错):如果Java堆内存不规整,已使用的内存与空闲内存相互交错,那么就没法使用指针碰撞,JVM需要维护一个列表,记录哪些内存块是可用的,分配内存时从列表查找一块足够大的空间划分给对象,并更新列表上的记录。

因此,选择哪种分配方式由Java堆是否规整来决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。所以,在使用Serial(串行)、ParNew(并发)等带Compact过程的收集器时,系统将采用“指针碰撞”的分配方式,而使用CMS(并发标记清除)这种基于Mark-Sweep(标记-清除)算法的收集器时,通常采用“空闲列表”(注:涉及到垃圾收集的内容,后面的文章再具体分析)。

2)线程安全

因为,在JVM中创建对象是非常频繁的,通过仅修改一个指针位置,在并发下并不是线程安全的,比如,当正在给A对象分配内存,指针还没来得及修改,此时B对象使用了该指针来分配内存的场景。那么,为了解决这样的问题,可采用如下方式:

  • 同步锁(原子性):实际上虚拟机采用CAS(比较并交换)配上失败重试的方式保证更新操作的原子性。
  • 本地线程分配缓冲(Thread Local Allocation Buffer,TLAB):把内存分配的动作按照线程划分在不同的空间中进行,即每个线程在Java堆中预先分配一小块内存--TLAB,从而避免在并发情况下频繁创建对象造成的线程不安全。

3)初始化

完成内存分配后,JVM还需要将分配到的内存进行初始化为零值(不包括对象头),如果采用的是TLAB,这一个初始化过程将提前至TLAB分配时进行。这一步骤保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的默认初始值(零值)。

扩展点:关于初始化零值,发生在类加载的Preparation(准备)阶段

JVM 会在该阶段对类变量(也称为静态变量static 关键字修饰的)分配内存并初始化(对应数据类型的默认初始值,如 0、0L、null、false 等)。

比如,我们有个A类,给它定义了如下字段:

public String name = "张三";
public static String city = "北京";
public static final String job = "程序猿";

经过类加载的准备阶段,name字段并不会被分配内存,而 city字段的初始值会被分配为null,而不是“北京”。

注意:static final 修饰的变量被称为常量,和类变量不同。常量一旦赋值就不会变化,所以job字段 在准备阶段的值为“程序猿”而不是 null

初始化零值后,接下来就需要给对象进行必要的设置,如填充对象头(Object Header)把对象属于哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息存入对象头

当执行完上面步骤,对象虽然已创建,但所有字段都还是零值,还需要执行init()方法,之后一个真正可用的对象才算完成

1.8.2 对象的内存布局

在 HotSpot 虚拟机中,对象在内存中存储的布局分为 3 块区域:对象头(Header)实例数据(Instance Data)对齐填充(Padding)

1)对象头(Header)

对象头主要包括两部分信息:

  • 存储对象自身的运行时数据:如哈希码、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等,32 位虚拟机占 32 bit,64 位虚拟机占 64 bit。官方称为 ‘Mark Word’。
  • 类型指针:即对象指向它的类元数据的指针,虚拟机通过这个指针可以确定这个对象是哪个类的实例。
HotSpot虚拟机对象头 Mark Word
存储内容标志位状态
对象哈希码、对象分代年龄01未锁定
指向锁记录的指针00轻量级锁定
指向重量级锁的指针10膨胀(重量级锁定)
空,不需要记录信息11GC标记
偏向线程ID、偏向时间戳、对象分代年龄01可偏向

注意,如果对象是 一个Java 数组,对象头中还必须有一块用于记录数组长度的数据,因为普通对象可以通过 Java 对象元数据信息确定对象的大小,而数组的元数据却无法确定数组的大小。

2)实例数据(Instance Data)

实例数据是对象真正存储的有效信息,即程序代码中所定义的各种类型的字段内容(包含父类继承下来的和子类中定义的字段)。

3)对齐填充(Padding)

对齐填充,并非必要,主要是起到占位符的作用,保证对象大小是某个字节的整数倍。

注:由于HotSpot VM的自动内存管理系统要求对象起始地址必须是8字节的整数倍,即对象大小必须是8字节的整数倍,而对象头部分正好是8字节的倍数,因此,当对象实例数据部分不足8字节的整数倍时,就需要通过对齐填充来补全。

1.8.3 对象的访问定位

当我们需要使用对象时,可以通过栈上的 reference 数据来操作堆上的具体对象。

但是reference 类型在JVM中仅规定了一个指向对象的引用,并没有定义这个引用可以通过什么方式定位、访问堆中的对象的具体位置,所以,根据JVM的具体实现,主流的对象访问方式主要有句柄直接指针

1)句柄

Java 堆中会分配一块内存作为句柄池。reference 存储的就是对象的句柄地址。

通过句柄访问对象,具体如下图:

2)直接指针访问

reference中直接存储的是对象地址

通过直接指针访问对象,具体如下图:

3)区别:

  • 句柄:使用句柄的最大好处是 reference 中存储的是稳定的句柄地址,在对象被移动(垃圾回收)时只改变句柄中的实例数据指针地址,reference 自身不需要修改。
  • 直接指针:直接指针访问的最大好处是速度快,节省了一次指针定位的时间开销。

如果是对象频繁 GC 那么可选用句柄访问方式,如果是对象频繁访问则可以采用直接指针访问方式。针对Sun HotSpot VM而言,它采用的是直接指针方式进行对象访问,因为对象的访问在Java中非常频繁,这类开销积少成多可节省很多执行成本。

2、总结

通过上文的介绍与分析,我们大致知道了JVM内存的各个区域的存储情况:

  • 程序计数器:线程私有,内存空间小,行号指示器
  • Java虚拟机栈:线程私有,生命周期与线程相同。每个方法在执行的同时都会创建一个栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口等信息。
  • 本地方法栈:与Java虚拟机栈功能类似。区别于 Java 虚拟机栈的是,Java 虚拟机栈为虚拟机执行 Java 方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。
  • Java 堆线程共享唯一目的就是存放对象实例(数组也属于对象)。对于大部分应用来说,Java堆(Heap)是 JVM 所管理的内存中最大的一块。Java堆又分为年轻代(eden、S0、S1)与老年代。
  • 方法区:内存共享,主要存储已被虚拟机加载的类信息、常量、静态变量即时编译器编译后的代码等数据。注意区别:JDK1.6常量池放在方法区,JDK1.7常量池放在堆内存,JDK1.8放在元空间里面。

在介绍了JVM各大内存区域分布情况后,我们还对常用的Java堆中的对象分配、布局与访问的过程进行详细分析。

下一篇:《【JVM系列二】深入理解JVM 垃圾回收算法》

史上最强Tomcat8性能优化

阿里巴巴为什么能抗住90秒100亿?--服务端高并发分布式架构演进之路

B2B电商平台--ChinaPay银联电子支付功能

学会Zookeeper分布式锁,让面试官对你刮目相看

SpringCloud电商秒杀微服务-Redisson分布式锁方案

查看更多好文,进入公众号--撩我--往期精彩

一只 有深度 有灵魂 的公众号0.0

 

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值