目录
前言
说到java语言我相信大家一定不陌生,但我更相信大部分java开发人员对jvm是既熟悉又陌生。都知道java是运行在jvm之上,不知道jvm是如何执行java;都知道java是面向对象高级语言,不知道对象在jvm中的内存结构;都知道java的大部分对象保存在堆中变量保存在栈中,不知道jvm是如何设计堆和栈以及在开发中会出现怎么样的问题;都知道java中存在线程安全和非线程安全的操作,但不知道jvm中那些内存块会出现这些问题以及该如何避免;带着这些问题和博主一起一步一步揭开这神秘面纱。
目标
本文是纯理论基础,讨论jvm的内存模型。如果你想知道java对象是如何在jvm中存储,想知道java方法是如何执行(从压栈到弹栈经过了那些步骤),想知道jvm是如何实现锁等,更重要的是你想提高java的内功心法,那欢迎你继续阅读。否则 光阴似箭,岁月如梭,来一局排位舒服得多
运行时数据区
java是一门高级语言,和C、C++相比他最大的特点就是垃圾自动回收机制。开发人员不用担心因垃圾回收问题导致的各种异常,更专注于业务开发,提高开发效率也保证系统的稳定性。则jvm的设计者们将执行java时将划分为多个内存区域,每一个区域都有各自的用途、创建和销毁的时间,有一些内存区域从启动就一直存在,而有一些是随着线程的生命共存亡。
内存模型
根据《java虚拟机规范》可知,java虚拟机将内存分为5大区域:方法区、堆、虚拟机栈、本地方法栈和程序计数器,他们的关系如下图。
程序计数器
程序计数器是一块较小的内存空间主要用来记录当前线程执行字节码指令的行号。 字节码解释器是通过修改程序计数器来确定执行下一字节码条指令,java语句的控制流语法如:分支、循环、跳转、异常处理和线程恢复等都依赖于程序计数器来完成。
由于java虚拟机的多线程是通过切换、分配处理器的执行时间来实现,同一时刻一个处理器都只会执行一条字节码指令。因此为了保证线程恢复之后能正确的运行,每一线程都需要有独立的程序计数器和存储空间,这些因线程而独立存在的内存区域称为内存私有区域
我们知道我们编写的java文件通过编译之后生成字节码(字节码技术),而字节码中我们编写的代码会变成一条条的指令我们将这些指令称为字节码指令。下面用代码来说明:
ClassTest.java源码
ClassTest.class字节码(通过执行javap -c ClassTest.class 反汇编得到)
到这里我相信你对程序计数器已不再陌生。
虚拟机栈
虚拟机栈我想读者或多或少都知道一些,没错,它就是我们平常所说的java栈(这种叫法是很笼统)。本质上虚拟机栈是一个线程执行一个方法时需要保存的局变量表、操作数栈、动态链接、方法出口等信息的一块内存区域,它的生命周期和线程的生命周期一致,属于线程私有。虚拟机在执行一个方法时其实就是栈帧的压栈(开始执行)到弹栈(执行结束)的过程,在这过程中需要做的运算和存储都在虚拟机栈内存区域中完成。
局变量表
局部变量表主要用来存放编译期可知的各种基本数据类型(boolean、byte、char、short、int、fload、long、double)、reference对象引用类型和returnAddress类型,其中long和double占两个局部变量空间,其他都是占一个空间。由于这些变量在编译期间就完成分配,则在调用方法前申请的栈帧大小是完全确定,在运行期间不会改变局部变量表的大小。
基本数据类型
reference对象引用类型
reference对象引用可能是一个指向对象地址的指针,也可能是指向一个代表对象的句柄或其他与此对象相关的地址位置。
reference对象引用为指针
reference对象引用为句柄
returnAddress类型
指向一条字节码指令的地址,只存在于字节码层面和编程语言无关(开发人员知道概念即可)。
操作数栈
操作数栈也常被称为操作栈,它是一个后入先出栈。同局部变量表一样,操作数栈的最大深度也是编译的时候被写入到方法表的Code属性的max_stacks数据项中。操作数栈的每一个元素可以是任意Java数据类型,包括long和double。
- 32位数据类型所占的栈容量为1,64位数据类型所占的栈容量为2。
- 栈容量的单位为“字宽”,对于32位虚拟机来说,一个”字宽“占4个字节,对于64位虚拟机来说,一个”字宽“占8个字节。
当一个方法刚刚执行的时候,这个方法的操作数栈是空的,在方法执行的过程中,会有各种字节码指向操作数栈中写入和提取值,也就是入栈与出栈操作。 例如,在做算术运算的时候就是通过操作数栈来进行的,又或者调用其它方法的时候是通过操作数栈来进行参数传递。
在概念模型中,两个栈帧作为虚拟机栈的元素,相互之间是完全独立的,但是大多数虚拟机的实现里都会作一些优化处理,令两个栈帧出现一部分重叠。让下栈帧的部分操作数栈与上面栈帧的部分局部变量表重叠在一起,这样在进行方法调用返回时就可以共用一部分数据,而无须进行额外的参数复制传递了,重叠过程如下图(图片来源于网络,如有侵权请联系博主):
动态链接
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。在Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用为参数。这些符号引用一部分会在类加载阶段或第一次使用的时候转化为直接引用,这种转化称为静态解析。另外一部分将在每一次的运行期期间转化为直接引用,这部分称为动态连接,java语言中三大特性(封装、继承、多态)之一的动态就是通过动态链接来实现。
如果对符号引用和直接引用概念模糊请移步
方法出口
方法出口就是用来记录执行该方法完成后需要继续执行的字节码指令或内存地址。
本地方法栈
本地方法栈和虚拟机栈一致,只是描述的方法类型不一致。本地方法栈主要服务于虚拟机内置的方法(java SE 中被定义为Native)
方法区
方法区这个内存区主要用于存储java虚拟机加载类时存储的类型信息、常量、静态变量,即时编译后缓存的代码等数据,熟悉字节码结构的读者不难发现这个区域和字节码中的常量池很像。这是一个内存共享区域,也是一个逻辑区域,不同的虚拟机厂家实现不一。
HotSpot jdk1.6 之前是用永久代来实现
HotSpot jdk1.6和HotSpot jdk1.67 使用直接内存来实现
HotSpot jdk1.8 用元空间(Meta-space)来实现
堆
堆主要用来存储对象实例和数组一块大内存区域,属于线程共享。在即时编译技术出现之前可以说所有的对象实例都存放在堆内存中,但由于即时编译技术的进步,尤其是逃逸分析技术的日渐强大,栈上分配、标量替换优化手段等,导致Java对象实例都分配在堆上也渐渐变得不是那么绝对了。我们知道java是一门自动回收垃圾的语言,自动回收主要是指堆内存的垃圾回收,然存在一个显著的现象:大部分的对象都是朝生夕灭,有一小部分对象则一直存活,为了更高效率的实现垃圾回收,虚拟机设计者们根据不同的垃圾收集器将堆内存划分为多个不同区域(在jdk1.8之前堆内存的划分几乎变化不大,直到Z1垃圾收集器的出现堆内存的划分才发生了革命性的变化)。
即时编译技术:在java运行时才实时将源码编译为字节码的技术
逃逸分析技术:通俗的讲就是判定一个对象要创建在堆中还是在栈中(是一种优化手段)从而减少了GC的压力
堆内存模型(经典堆内存模型)
这里针对jdk1.9之前讲解堆内存的划分。堆主要分为新生代和老年代两大区域,其中新生代又分为Eden、From Survivor和To Survivor三个空间。在线程分配时还可能划分出多个线程私有的缓冲区(Thread Load Allocation Buffer)TLAB。这样分配主要是为了更好,更快的管理(分配和回收)堆内存。
新生代
新生代将内存划分了三个区域,我们在java代码中创建的对象大部分都是先进入Eden区域(大对象会直接进入老年代)。其中内存大小比例Eden:From:To = 8:1:1 这是默认配置,生产环境可根据自己的业务调整比例和大小,因为新生代大量对象朝生夕灭,所以采用了标记复制算法来回收垃圾(本文不讨论垃圾回收问题),这内存划分也是为了使垃圾回收算法提高性能而设计。
Eden区域
新对象分配内存区域,几乎所有对象创建所需内存都从该区域分配,如果该区域内存不够进行GC之后分配内存,如果GC后还是无法分配内存则发生堆内存溢出。
From和To区域
From和To意义完全一样都属于Survivor(幸存区域)内存区域。第一次 Eden区域内存不够发生Minor GC时,将Eden区域中存活的对象复制到From区域且给每一个存活的对象记录GC年龄,发生一次Minor GC对象GC年龄加一,如果存活对象所需内存大于To区域内存大小或者是大对象再或者对象满足晋升老年代的条件直接进入老年代内存区域,最后清空Eden内存区域;第二次 Eden区域内存不够时,对Eden和Form区域进行GC,和第一步一样将存活对象复制到To区域或老年代,复制完成清空Eden和From内存区域,最后To和From区域角色互换;第三次 Eden区域内存不够时,对Eden和To区域进行GC,和第一步一样将存活对象复制到From区域或老年代,复制完成清空Eden和To内存区域,最后To和From区域角色互换;依此类推。
大对象: 对象内存大小大于参数-XX:PretenureSizeThreshold 值称为大对象
晋升老年代条件: 对象每经过一次Minor GC后,年龄超过这个参数(-XX:MaxTenuringThreshold)时就进入老年代。HotSpot并不是永远要求对象的年龄达到该参数的值才晋升到老年代,当Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就直接进入老年代,无需等到-XX:MaxTenuringThreshold设定的年龄
老年代
老年代内存区域主要存放大对象或者是存活时间长的对象。
堆内存模型和垃圾收集器息息相关,更详细的堆内存模型在垃圾收集器章节详细讨论
对象内存模型
java 中对象一般是通过new关键字创建对象(这里排除复制和反序列化),当虚拟机执行java代码遇到new指令时,首先会根据索引到方法区的常量池中查询该到对象的符号引用来确认是哪个类型对象,其次检查该对象是否已加载(加载,验证,准备、解析、初始化),最后在堆中寻找一块内存分配给这个新对象(分配内存主要使用“指针碰撞”和“空闲列表”两种方式)。然而虚拟机中保存的对象数据不仅仅是我们编写的代码还包含了一些运行时的数据,如hashCode,GC年龄,锁标记、偏向线程ID等,所以虚拟机设计者们将java对象的内存模型设计为对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)三部分。
对象头
对象头是对象重要数据之一,里面包含两类数据:一类是运行时数据其中包含了hashCode、GC年龄、锁标记、线程持有锁、偏向线程ID和偏向时间戳,另一类是对象的类型指针(指向对象类型元数据的指针)。第二种类型数据很好理解就不过多阐述这里来详细讨论一下第一种数据类型。
运行时数据
这部分数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为32个比特和64个比特,官方称它 为“Mark Word”。对象需要存储的运行时数据很多,其实已经超出了32、64位Bitmap结构所能记录的最大限度,但对象头里的信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效率,Mark Word被设计成一个有着动态定义的数据结构,以便在极小的空间内存储尽量多的数据,根据对象的状态复用自己的存储空间。下图列出不同状态时存储是信息
到这里你会发现,在java中锁是有多种状态的,不同状态下锁的级别不同。一个对象的锁状态有未锁定,偏向锁、轻量级锁,重量级锁,从偏向锁到重量级锁这个过程一般叫做锁膨胀。在java的synchronized关键字或JUC中的工具类都是基于这个原理来实现的。由于锁是java中重要技术之一,博主会专写一篇关于java锁的文章,这里就不在阐述。
其实这样分级锁目的为了提高锁的性能
偏向锁: 对第一个获取该对象的线程进行消除锁操作直到有其他线程竞争资源才结束此模式,升级为轻量级锁。
轻量级锁: 对于两个线程之间竞争资源时采用CAS算法来实现加锁直到有第三个线程参与竞争资源才结束此模式,升级为重量级锁。
重量级锁:这是操作系统层面的锁,未获得锁的线程都会被挂起,直到被锁释放才通知其他竞争的线程,又因为HotSpot虚拟机是采用操作系统的核心线程来实现线程,导致线程的挂起和唤醒都要发生用户态和内核态之间的切换从而降低性能。
实例数据
实例数据就是我们通常写的java代码数据。包含各种类型的字段内容,无论是从父类继承下来的,还是在子类中定义的字段都必须记录起来。
对齐填充
对齐填充不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。由于HotSpot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说就是任何对象的大小都必须是8字节的整数倍。对象头部分已经被精心设计成正好是8字节的倍数(1倍或者 2倍),因此,如果对象实例数据部分没有对齐的话,就需要通过对齐填充来补全。
到这里,java虚拟机内存模型就讲完了。博主能力有限,如有不合理之处,欢迎指正
总结
这是一篇纯理论的博文,能坚持读到这里我相信读者一定是一个技术爱好者。即使你知道了jvm的内存模型,你任然发现这些理论给你的编码能力没有带来任何提升。甚至还你还会说:学这个内存模型,有什么用?是的,我们又不做jvm开发好像真的不用学?,其实不然,知道这些原理之后,第一:面对言语层面我们知道如何编码能提高性能,如何避免内存溢出等。第二:面对jvm层面结合垃圾回收器我们可以根据自己的业务合理的分配堆内存大小,老年代和年轻代的比例,From、To 和Eden区的比例,以及该使用那种垃圾回收器等。
参考文献《深入了解Java虚拟机》周志明 第3版
原创不易,转载请标明来源