深入了解JVM虚拟机-笔记

运行时数据区域

  • 程序计数器
  • Java虚拟机栈
  • 本地方法栈
  • Java堆
  • 方法区
  • 运行时常量池
  • 直接内存

程序计数器

  程序计数器是一块较小的内存空间, 它可以看作是当前线程所执行字节码的行号指示器. 在虚拟机的概念模型里面, 自己吗解释器的工作就是通过改变这个计数器的值来选取下一条需要执行的字节码指令, 分支, 循环, 跳转, 异常处理, 线程恢复等基础功能都需要依赖程序计数器来实现.

  由于Java虚拟机的多线程是通过线程切换并分配处理器执行时间的方式来实现的, 在任何一个确定的时刻, 一个处理器(对多核处理器来说是一个内核)都只会执行一个线程中的指令. 因此, 为了线程切换之后能恢复到正确的执行位置, 每条线程都需要有一个独立的程序技术计数器, 各条线程之间计数器互不影响, 独立存储, 我们称这类内存区域为线程私有内存.

  在执行Java方法的时候, 计数器记录的是正在执行的虚拟机字节码指令的地址; 如果执行的是native方法, 计数器的值则为空(Undefined). 此内存区域是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域.

Java虚拟机栈

        线程私有, 生命周期和线程相同.

    每个方法在执行的同时都会创建一个栈帧用于存储局部变量表, 操作数栈, 动态链接, 方法出口灯信息. 每个方法从调用直至执行完成的过程, 就对应着一个栈帧在虚拟机栈中入栈到出栈的过程.

    局部变量表存放了编译器可知的各种基本数据类型(boolean, byte, char, short, int, float, long, double), 对象引用(reference类型, 他不等同于对象本身, 可能是一个指向对象起始地址的引用指针, 也可能是指向一个代表对象的句柄或者其他对象于此对象相关的地址)和returnAddress类型(指向了一条自己吗指令的地址).

    long, double类型数据会占用两个局部变量表空间, 其余的数据类型只占用1个. 局部变量表所需的空间在编译期间完成分配, 当进入一个方法时, 这个方法需要在帧中分配多大的局部变量空间是完全确定的, 在方法运行期间不会改变局部变量表的大小.

    在Java虚拟机规范中, 对这个区域规定了两种异常状况: 如果线程请求的栈深度对于虚拟机所允许的深度, 就跑出StackOverflowError; 如果虚拟机栈可以动态扩展, 如果扩展石无法申请到足够的内存, 就会抛出OutOfMemoryError异常.

本地方法栈

    本地方法栈和虚拟机栈发挥的作用非常相似, 他们之间的区别不过是虚拟机栈为虚拟机执行Java 方法服务, 而本地方法栈则为虚拟机使用到的Native方法服务.Sun HotSpot 虚拟机直接把Java虚拟机栈和本地方法栈合二为一.

Java 堆

    Java堆是虚拟机所管理的内存中最大的一块. Java堆是被所有线程共享的一块区域, 在虚拟机启动时创建. 此内存区域的唯一目的就是存放对象实例, 几乎所有的对象实例都在这里分配内存. 这一点在Java虚拟机规范中的描述是: 所有的对象实例以及数组都要在堆上分配, 但是随着JIT编译器的发展与逃逸分析技术的逐渐成熟, 栈上分配, 标量替换等优化技术将会导致一些微妙的变化发生, 所有的对象都分配在堆上也渐渐变得不是那么绝对了.

    Java堆是垃圾收集器管理的主要区域, 因此很多时候也被称为"GC堆".

    java堆可以处于物理上不连续的内存空间中, 只要逻辑上是连续的即可. 在实现时, 我们可以实现成固定大小, 也可以是可扩展的, 不过当前主流的虚拟机都是按照可扩展来实现的, 通过-Xmx和-Xms来控制. 如果在堆中没有内存完成实例分配, 并且堆也无法扩展的时候, 将会抛出OutOfMemoryError异常.

方法区

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

    在hotspot虚拟机上, 把GC分代收集扩展至方法区, 或者说使用永久代来实现方法区, 这样垃圾收集器就可以像管理Java堆一样管理这部分内存, 能够省去专门为方法区便携内存管理代码的工作.在JDK1.7 的hotspot中, 已经把原本放在永久代中的字符串常量池移除.

    Java虚拟对方法区的限制非常宽松, 除了和Java堆一样不需要连续的内存空间, 可以选择固定大小和可扩展外, 还可以选择不实现垃圾收集. 此块区域垃圾收集的主要目标是针对常量池的回收和类型的卸载.

    根据Java虚拟机规范的规定, 方法区无法满足内存分配需求的时候就抛出OutOfMemoryError.

运行时常量池

    运行时常量池是Java方法区的一部分. Class文件中除了有类的版本, 字段, 方法, 接口等描述信息之外, 还有一项信息是常量池, 用于存放编译生成的字面量和字符引用, 这部分内容将在类加载后进入方法去的运行时常量池中存放.

    Java虚拟机对Class文件的每一部分(自然也包括常量池)都有严格的规定, 每一个字节用于存储那种数据都必须符合规范上的要求才会被虚拟机认可, 装载, 执行, 但是对于运行时常量池, Java虚拟机规范没有做任何细节的要求.

    运行时常量池相对于Class文件常量池的另外一个重要特征是具备动态性, Java语言并不要求常量一定只有编译器才产生, 运行期间也可能将新的常量放入池中. 这种特性被开发人员用的最多的就是Sting 类的intern()方法.

    在申请不到内存的时候也会抛出OutOfMemoryError.

直接内存

    直接内存并不是虚拟机运行时数据区域的一部分, 也不是虚拟机规范中定义的内存区域. 这部分也被频繁的使用, 也可能导致OutOfMemoryError.

    JDK1.4中新加了NIO(New Input/Output)类, 引入了一种基于通道与缓冲区的的I/O方式, 他可以使用native函数直接分配堆外内存, 然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用来操作. 这样能在一些场景中显著提升性能, 因为避免了在Java堆和Native堆中来回复制数据.

HotSpot虚拟机对象探秘

对象的创建

此处讨论的对象仅限于普通Java对象, 不包括数组和Class对象

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

在类加载检查通过后, 接下来虚拟机将为新生对象分配内存. 对象所需内存的大小在类加载完后便可以完全确定, 为对象分配空间的任务等同于把一块内存从Java堆中划分出来. 假设java 堆中内存是绝对规整的, 所有用过的内存都放在一边, 空闲的内存放在另一边, 中间存放着一个指针作为分界点的指示器, 那分配内存就是把这个指针向空闲空间那边挪动一段与对象大小的相等的距离, 这种分配方式称为 指针碰撞 . 如果Java 堆中的内存不是规整的, 已使用的内存和空闲内存交错着, 那就没有办法简单的进行指针碰撞了, 虚拟机就必须维护一个列表, 记录哪些内存是可用的, 在分配的时候在列表中找到一块足够大的空间划分给对象实例, 并 更新列表上的记录, 这种分配方式被称为 空闲列表 . 选择哪种分配策略是由Java堆是否规整决定的, 而Java堆是否规整又由所采用的的垃圾收集器是否带有压缩整理功能决定. 因此在使用Serial, ParNew等带有Compact过程的收集器时. 采用的分配算法是指针碰撞, 而使用CMS这种基于Mark-Sweep算法的收集器时, 通常采用空闲列表.

需要考虑一个问题, 对象创建在虚拟机中是非常频繁的操作, 即使是仅仅修改一个指针指向的位置, 在并发情况下也并不是线程安全的. 解决这个问题有两种分案,

  • 对分配内存的操作进行同步处理-- 虚拟机实际上采用CAS(
    Compare And Swap) + 失败重试的方式保证更新操作的原子性.
  • 把内存分配的动作按照线程划分在不同的空间中进行. 每个线程在Java堆中预先分配一块内存, 称为本地分配缓冲(Thread Local Allocation Buffer ,TLAB). 哪个线程要分配内存就在对象线程的TLAB上分配, 只有TLAB用完并分配新的TLAB的时候才需要同步锁定. 虚拟机是否使用TLAB可以通过 -XX:+/-UseTLAB 参数来设定.

内存分配完毕之后, 虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头), 如果使用TLAB, 这个工作也可以提前至TLAB分配时进行. 这一步操作保证了对象的实例字段在Java代码中可以不赋初值就可以直接使用, 程序可以访问到这些字段数据类型所对应的零值.

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

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

对象的内存布局

在HotSpot中, 对象在内存中存储的布局可以分为三块区域: 对象头, 示例数据, 对齐填充.

对象头包括两部分信息

  • 用于存储对象自身的运行时数据, 如哈希码, GC分代年龄, 锁状态标志, 线程持有的锁, 偏向线程ID, 偏向时间戳等, 这部分数据的长度在32位和64位虚拟机中分别为32bit和64bit, 官方称他为 Mark Word. 对象需要存储的运行时数据很多, 远超32位, 64位Bitmap结构所能记录的限度, 但是对象头信息是与对象自身定义数据无关的额外存储成本, 考虑到效率原因, Mark Word被设计成非固定的数据结果以便在极小的内存空间中存储尽量多的信息, 他还会根据对象的状态复用自己的存储空间.
  • 另一部分是类型指针, 即对象指向他的类元数据的指针, 虚拟机通过这个指针来确定这个对象是哪个类的实例. 并不是所有的虚拟机实现都必须在对象数据上保留类型指针, 换句话说就是, 查找对象的元数据不一定要经过对象本身. 此外, 如果对象是一个Java数组, 那在对象头中还需要有一块用于记录数组长度的数据, 因为虚拟机可以通过普通Java对象的元数据信息确定对象的大小, 但是从数组的元数据中却无法确定数组的大小.

实例数据部分是对象真正存储的有效信息, 也是在程序代码中定义的各种类型的字段内容. 无论是从父类继承下来的, 还是在子类中定义的, 都需要记录下来. 这部分的存储顺序会受到虚拟机分配策略参数(FieldsAllocationStyle)和字段在Java源码中定义顺序的影响. HotSpot虚拟机默认的分配策略为longs/doubles, init, shorts/chars, bytes/booleans, oops(Ordinary Object Pointers), 从分配策略来看, 相同宽度的字段总是被分配在一起. 在满足这个条件的情况下, 在父类中定义的变量会胡此案在子类之前. 如果CompactField参数值为true(默认为true), 那么子类中较窄的变量也可能会插入到父类变量的空隙之中.

对齐填充并不是必然存在的, 也没有特别的含义, 他仅仅起着占位符的作用. 由于HotSpot VM 的自动内存管理系统要求对象起始地址必须是8字节的整数倍, 也就是说对象的大小必须是8字节的整数倍. 而对象头部分刚好是8字节的整数倍, 因此当对象实例数据没有对齐时, 就需要通过对齐填充来补全.

对象已死吗

引用计数器算法

    给对象加一个引用计数器, 每当有一个地方引用他时, 计数器的值加一; 引用失效时, 计数器值减一; 在任何时刻计数器为0的对象就是不可能在被使用的.

    引用计数器实现比较简单, 效率也很高, 在很多情况下是一个不错算法, 但是在主流的Java虚拟机里面没有使用引用计数算法来管理内存, 最主要的原因就是它很难解决对象之间循环引用的问题.

// 两个对象除了 objA.instance = objB; objB.instance = objA;之外没有其他引用了, 实际上这两个对象已经不能再被访问, 但是他们之间相互引用这对方, 导致他们的引用计数器都不为0, 于是引用计数算法无法通知GC来回收他们.
// 循环引用
public class ReferenceCountingGc{
    public Object instance = null ;
    
    public void testGc(){
        Object objA =  new Object();
        Object objB =  new Object();
        
        objA.instance = objB;
        objB.instance = objA;
        
        objB = null;
        objA = null;
    }
}

可达性分析算法

    通过一系列的"GC Roots"的对象来作为起始点, 从这些节点开始向下搜索, 搜索走过的路径被称为引用链, 当一个对象到GC Roots 没有任何的引用链相连时, 则证明次对象是不可用的.

    在Java中, 可作为GC Roots的对象包括以下几种:
        虚拟机栈(栈帧中的本地变量表)中引用的对象
        方法区中静态变量属性引用的对象.
        方法区中常量引用的对象
        本地方法栈中JNI(Java Native Interface)引用的对象

再谈引用

在JDK 1.2之后, Java对引用的概念进行了扩充, 将引用分为强引用, 软引用, 弱引用, 虚引用四种, 这四种引用强度逐渐递减.

  • 强引用 : 在程序代码中普遍存在, 类似于Object obj = new Object(); 只要强引用还在, GC永远不会回收掉被引用的对象
  • 软引用: 用来描述一些还有用但是非必须的对象. 对于软引用关联着的对象, 在系统将要发生内存溢出异常之前, 将会把这些对象列进回收范围之中进行第二次回收. 如果回收之后还没有足够的内存, 才会抛出内存溢出异常. 使用SoftReference类来实现软引用.
  • 弱引用: 用来描述非必须对象, 但是他的强度比软引用更弱一些, 被弱引用关联的对象只能生存到下一次垃圾回收发生之前. 当垃圾收集器工作时, 无论当前内存是否足够, 都会回收掉只被弱引用关联的对象. 使用WeakReference类来实现弱引用.
  • 虚引用: 也被称为幽灵引用或者幻影引用, 他是最弱的一种引用关系. 一个对象是否有虚引用的存在, 完全不会对其身存时间构成影响, 也无法通过虚引用来获取到一个对象的实例. 为一个对象设置虚引用关联的唯一目的就是能在对象被回收之前得到一个系统通知. 使用PhantomReference 类来实现虚引用.
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值