《深入理解Java虚拟机》是我个人读过的第一本关于JVM方面的书籍。十分有幸能够读到这本书,在此对作者表示深刻的敬意
不知道有没有人和我一样有类似的情况,就是一本书读完,经过一段时间之后,林林总总最后留在脑子里的并不多,很多东西又还给了作者。有人可能会说,之所以会遗忘,是因为你根本没有理解。我并不否认这点,说实话,很多书读过一遍之后都会存在没有读懂的部分。但是随着我们阅历的丰富以及期间不断的学习,过去不懂的部分终究会慢慢读懂。鉴于这种情况,我觉得读书笔记还是很有必要:读书期间总结归纳,日后温故知新
笔记仅仅是我个人对此书的一份总结,提纲挈领。如需了解细节,请完整阅读原著,这本书我个人也强烈推荐。下面,开始《深入理解Java虚拟机》读书笔记的第一篇--Java内存区域
运行时数据区域
JVM在运行Java程序时将内存区域划分为不同的部分,这些区域有各自的用途以及各自的内存管理策略
在Java虚拟机的概念模型中,这些区域大致可以分为:程序计数器(Program Counter Register)、虚拟机栈(VM Stack)、本地方法栈(Native Method Stack)、堆(Heap)、方法区(Method Area)
其中前三者是线程隔离的内存区域,后两者是线程共享的内存区域
1.程序计数器
程序计数器可以简单理解为线程执行字节码的行号指示器。由于在线程切换后需要恢复到程序的执行位置,因此每个线程都有各自的程序计数器
2.虚拟机栈
虚拟机栈描述的是Java方法执行的内存模型,方法在执行的时候会创建栈帧。栈帧用于存储方法执行时所需的局部变量表、操作数栈、动态链接、方法出口等信息。方法的执行伴随着栈帧在虚拟机栈中的入栈及出栈
局部变量表存储了基本数据类型、对象引用、返回地址
Java虚拟机规范对此区域规定了两种异常情况:当线程请求的栈深度大于虚拟机允许的最大深度将触发StackOverflowError;如果虚拟机栈在动态扩展时无法申请到足够的内存将触发OutOfMemoryError
3.本地方法栈
本地方法栈与虚拟机栈作用类似,区别在于虚拟机栈用于Java方法执行,而本地方法栈用于本地方法执行
有些虚拟机甚至不区分本地方法栈和虚拟机栈,而是将它们合二为一
同样本地方法栈也会触发StackOverflowError和OutOfMemoryError
4.堆
堆用于存放对象实例,可以细分为新生代和老年代,进一步可以细分为Eden空间、From Survivor空间、To Survivor空间等。进一步细分区域的目的是根据对象的特征更好的分配以及回收内存空间
堆在物理上可以是不连续的空间,只要在逻辑上是连续的即可。如果当前堆内存已经用完并且无法动态扩展的时候会触发OutOfMemoryError
5.方法区
方法区主要用于存放已经被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据
运行时常量池是方法区的一部分,主要用于存放编译期生成的字面量和符号引用,另外在运行时也可以将新的常量加入池中
这个区域的内存回收通常收益较低,主要是针对常量池的回收以及对类型的卸载(对类型卸载的要求十分严苛)
同样,当方法区无法满足内存分配需求时,将会触发OutOfMemoryError
6.直接内存
直接内存并非Java虚拟机规范中定义的内存区域,但是在大量使用NIO的程序中有可能触发OutOfMemoryError异常
NIO基于Channel和Buffer,可以直接使用本地方法分配堆外内存,然后通过存储在堆中的DirectByteBuffer对象作为这块堆外内存的引用进行操作。虽然堆外内存不会受到Java堆大小的限制,但是仍然会受制于物理内存以及操作系统的限制。当各内存区域总和大于物理内存或者达到操作系统限制,从而导致动态扩展时触发OutOfMemoryError
对象探秘
不同的虚拟机,对于对象的创建、布局和访问会有所差异,下面仅以HotSpot的堆内存为例进行介绍
1.对象的创建
#类检查及类加载
虚拟机在遇到new指令的时候,首先会去常量池检查类的符号引用,如果发现没有对此类进行加载、解析、初始化,那么首先会对该类进行加载
#内存分配
之后虚拟机会为该对象分配内存。由于对象所需内存在类加载后就可以确定,因此分配内存实际上就是从未使用的堆内存中划分出一块确定大小的存储空间。此过程有两种方式:
1)对于规整的堆内存,直接将指针向空闲一侧移动所需的大小,这种方式叫做“指针碰撞”
2)对于不规整对堆内存,虚拟机会维护一个空闲内存列表,当需要分配内存时,划分出一块足够的空间并且更新空闲列表,这种方式叫做“空闲列表”
至于堆内存是否规整连续,取决于具体的垃圾收集器(主要取决于是否带有compact功能)
由于对象的创建是一个十分频繁的过程,在并发情况下会有并发安全的问题。解决的方式有两种:
1)对内存分配进行同步处理,实际上虚拟机采用CAS的方式保证原子性
2)另一种方式是把内存分配的动作按照线程进行隔离,即每个线程会预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)。只有在TLAB用完需要新分配的时候在采取同步处理
#初始化零值
内存分配完毕后,虚拟机会对该内存区域初始化零值,如果是TLAB方式,这一步可以提前到TLAB阶段。这一步保证了对象实例属性的初始化。这也就是为什么对象的实例属性可以不赋初值就能够直接访问
#对象头设置
接下来,虚拟机需要将对象进行一些设置,将类的元数据、哈希码、GC分代信息等设置到对象头中
#对象初始化
执行完上述步骤后,对象的实例属性还都是零值,下面会执行<init>方法,按照程序的意图对对象进行初始化(我的理解是执行构造方法)。之后在将对象的引用入栈。至此,对象的创建过程结束
2.对象的内存布局
在HotSpot虚拟机当中,对象的内存布局分为三个区域:对象头、实例数据、对齐填充
#对象头
对象头主要存储两部分信息,一部分是对象的运行时数据(如哈希码、GC分代信息、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等),另一部分是类型指针(即指向类元数据的指针,代表该对象是哪个类的实例。当然类的元数据也并不一定要存储在对象头,这个后续再讨论)
#实例数据
实例数据部分用于存储对象实例的数据,即实例属性的内容,父类的属性也会记录下来。存储顺序会受到虚拟机的分配策略以及字段的定义顺序的影响。默认的分配策略大致有几点规则:
1)会将相同宽度的属性分配在一起
2)满足1的前提下,父类中的属性出现在子类前
3)CompactFields参数为true的时候,会将子类中宽度较窄的属性插入到父类属性的空隙之中
#对齐填充
由于HotSpot虚拟机要求对象的其实地址必须是8字节的整数倍,因此对于对象大小不是8字节整数倍的对象,会被对齐填充
3.对象的访问定位
对象的访问解决的是如何通过栈中对象的引用访问到堆中实际对象的问题,目前主流的方式有两种:
1)句柄访问,栈中对象的引用存储的是对象的句柄地址(句柄在句柄池中维护),句柄存储了堆中对象实例的地址以及方法区中对象的类型数据的地址。
这种方式的优点是引用中存储的是稳定的句柄,当对象地址变化的时候(GC过程中可能会移动对象实例),只需要更新句柄,不需要更新引用;缺点也显而易见,访问对象时多一次指针操作
2)直接指针访问,栈中对象的引用存储的就是对象实例的地址,对象实例(对象头)中又存储了方法区中对象的类型数据的地址
这种方式的优点就是访问迅速(比前者少一次指针操作),在HotSpot虚拟机中采用此种方式
笔记1结束