深入理解java虚拟机(第三版)笔记-内存区域

运行时数据区域

根据《Java虚拟机规范》的规定,java虚拟机所管理的内存将会包括以下几个运行时区域:方法区、堆、虚拟机栈、本地方法栈、程序计数器。

  • 程序计数器

    是一块比较小的内存空间,可以看作是当前线程执行的字节码的行号指示器。在虚拟机概念模型中(即设计规范,实际上每个商用虚拟机都不一定完全按着概念来设计),字节码解释器工作时就是通过改变计数器的值来选取下一条需要执行的字节码指令。它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复都需要依赖计数器完成。

    该内存区域是唯一一个在虚拟机规范中没有规定任何OOM情况的区域

    为了线程切换后能恢复到正确的执行位置,每条线程都要有独立的程序计数器,各条线程之间计数器互不影响。

    如果正在执行的是java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果执行的是本地方法,这个计数器为空

  • 虚拟机栈

    也是线程私有的,声明周期同线程相同。虚拟机栈描述的是java方法执行的线程内存模型:每个方法被执行的时候,虚拟机都会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每个方法被调用直至执行完毕的过程就对应一个栈帧从入栈到出栈的过程。

    局部变量表存放了编译器可知的各种java虚拟机的基本数据类型 (boolean,byte…) 、对象引用 (reference类型,它并不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或者其他与此对象相关的位置) 和returnAddress (指向了一条字节码指令的地址) 。

    这些数据类型在局部变量表中以局部变量槽表示,其中64位的long和double占两个变量槽,其余的数据类型占用一个。局部变量表所需的内存空间在编译期间完成分配,进入方法时需要在栈帧中分配多大的局部变量空间是完全确定的,在运行期间不会改变局部变量表的大小。这里的大小是变量槽的数量,具体一个槽多大不同的虚拟机实现不同。

    虚拟机规范对虚拟机栈区域规定了两类异常:如果线程请求的栈深度大于虚拟机所允许的深度,将会抛出StackOverflowError异常;如果java虚拟机栈容量可以动态扩展,当栈扩展时无法申请到足够的内存会抛出OutOfMemoryError异常。

    HotSpot虚拟机的栈容量是不可动态扩展的,只要线程申请栈空间成功了就不会OOM,但是如果申请时就失败,还是会出现OOM。

  • 本地方法栈

    与虚拟机栈所发挥的作用是非常相似的,区别只是虚拟机栈执行java方法,本地方法栈为虚拟机使用到的本地方法服务。HotSpot直接把本地方法栈和虚拟机栈合二为一。

  • java堆

    java堆是虚拟机所管理的内存中最大的一块,被所有线程共享,在虚拟机启动时创建。在java中**“几乎”所有的对象实例都在这里分配内存**。在虚拟机规范中,描述的是“所有的对象实例以及数组都应当在堆上分配”。但是在具体实现的时候,随着java语言的发展,逃逸分析技术日益强大,栈上分配和标量替换等优化手段导致了一些微妙的变化,所以java对象实例都分配在对上也不是那么绝对了。

    java堆是垃圾收集器管理的内存区域,由于现代垃圾收集器大部分都是基于分代收集理论设计的,所以java堆中经常出现“新生代” ”老年代“ ”Eden空间“ 等名词,后续会展开讲。这些区域的划分只是因为大部分垃圾收集器都是按分代收集设计的,而不是java虚拟机具体实现的固有内存布局,java虚拟机规范中根本没有对堆这么划分。所以”java虚拟机的堆内存分为新生代、老年代、永久代…“这种说法是有待商榷的。

    java堆可以处于物理上不连续的内存空间中,在逻辑上它们是连续的,和文件系统是一样的。但是对于大对象(如数组对象),多数虚拟机实现出于实现简单、存储高效的考虑,可能会要求连续的内存空间。

    java堆可以被实现成固定大小的也可以是可扩展的,当前主流的java虚拟机都是按照可扩展来实现的(通过参数-Xmx和-Xms设定)。如果在堆中没有内存完成实例分配并且堆也无法扩展就会抛出OOM。

  • 方法区

    **方法区也是各个线程共享的内存区域,用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。**在java虚拟机规范中把方法区描述为堆的一个逻辑部分,但是它有一个别名叫”非堆“,目的是和java堆区分开。

    这里就不得不提“永久代”这个概念。许多人容易把方法区和永久代搞混,甚至不知道二者的关系。方法区是《java虚拟机规范》中定义的内存区域,而永久代是HotSpot对于方法区的具体实现,这样HotSpot的垃圾收集器就能够像管理java堆一样管理这部分内存。其他的虚拟机比如BEA JRockit、IBM J9等根本不存在永久代这个概念。

    在jdk 6时HotSpot团队就有了放弃永久代,逐步改为采用本地内存来实现方法区的计划了。到了jdk 7,把原本放在永久代的字符串常量池、静态变量等移出,移到了java堆中。到了jdk 8,完全废弃永久代,改用在本地内存中实现的元空间(metaspace)来代替,把jdk 7中永久代还剩余的内容移到元空间中。

    《java虚拟机规范》对方法区的约束比较宽松,甚至可以不实现垃圾收集,但是这个区域的回收有时还是必要的。

    字符串常量池比较重要,也是面试很爱问的一个点,单独拉出来说一下,虽然它现在“物理上”放在了java堆中,但是逻辑上它还属于方法区。然后就是面试中最常见的代码了:

    String s1 = "abc";
    String s2 = new String("abc");
    String s3 = s2.intern();
    String s4 = "a" + "bc";
    System.out.println(s1 == s2);
    System.out.println(s1 == s3);
    System.out.println(s2 == s3);
    System.out.println(s1 == s4);
    

    false
    true
    false
    true

    在new String(“abc”)的时候,**如果池中已存在字符串常量“abc”,则只会在堆空间创建一个字符串常量“abc”。如果池中没有字符串常量“abc”,那么它将首先在池中创建,然后在堆空间中创建,因此将创建总共 2 个字符串对象。**而对于"a" + “bc"则是因为编译器优化,直接拼成了"abc”。最重要的是intern方法,String.intern() 是一个 Native 方法,它的作用是:如果运行时常量池中已经包含一个等于此 String 对象内容的字符串,则返回常量池中该字符串的引用;如果没有,JDK1.7之前(不包含1.7)的处理方式是在常量池中创建与此 String 内容相同的字符串,并返回常量池中创建的字符串的引用,JDK1.7以及之后的处理方式是在常量池中记录此字符串的引用,并返回该引用。出处是美团技术团队的文章,也就是说字符串常量池既可以存常量,也可以存引用。

  • 运行时常量池

    运行时常量池是方法区的一部分。Class文件中存放的有类的版本、字段、方法、接口等描述信息外,还有一个是常量池表(Constant Pool Table),用于存放编译器生成的各种字面量和符号引用,这部分内容会在类加载后存放到方法区的运行时常量池中,但是会把符号引用翻译为直接引用,也就是具体地址。(具体内容在第六章中细讲)

    运行时常量池相对于Class文件中的常量池还有另一个重要特征就是具备动态性,java并不要求常量只有编译器才能产生,运行期间也可以把新的常量放入池中,比如刚刚讲的intern方法。

  • 直接内存

    这不是java虚拟机规范定义的内存区域。在jdk 1.4中引入的NIO中,引入了基于通道与缓冲区的IO方式,它可以使用Native库函数直接分配堆外内存,然后通过一个存储在java对中的DirectByteBuffer对象作为这块内存的引用进行操作。

    本机直接内存不会受到java堆大小的限制,但是还是会受到本机总内存大小以及处理器寻址空间的限制。在配置虚拟机参数的时候,不但要考虑-Xmx等参数,还要考虑用到的直接内存和元空间,避免各个区域的总和大于物理内存限制而抛出OOM。

java堆

上面大致介绍了java虚拟机的内存模型,基于实用优先原则,先介绍最常用的内存区域——java堆,深入探讨java堆中对象分配、布局和访问的全过程。

对象创建

java对象的创建过程可以大致概括为:类加载检查 --> 分配内存 --> 初始化零值 --> 设置对象头 --> 执行init方法

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

  • 分配内存:类加载检查通过后,虚拟机会为新生对象分配内存,对象所需内存的打下偶在类加载完成后便可完全确定,为对象分配空间实际上等同于把一块确定大小的内存块从java堆中划分出来。**选择哪种分配方式根有java堆是否规整决定,java堆是否规整由所采用的垃圾收集器是否带空间压缩整理的能力决定。**垃圾回收算法在后面讲。

    • 指针碰撞法:java堆中内存是绝对规整的,所有使用过的内存放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,分配内存就仅仅是把那个指针向空闲方向挪动一段与对象大小相等的距离。
    • 空闲列表法:java堆中的内存不是绝对规整的,已经使用的内存和空闲的内存是相互交错的,虚拟机就必须维护一个列表,记录哪块内存是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录。

    创建对象还有一个问题就是并发问题,因为实际开发中创建对象非常频繁,虚拟机必须保证线程安全,一般有两种解决方案。

    • CAS+失败重试:不停尝试分配内存,直到分配成功,“自旋操作”。
    • TLAB:每个线程在java堆中预先分配一小块内存,称为本地线程分配缓存(Thread Local Allocation Buffer, TLAB),哪个线程需要分配内存就在哪个线程的本地缓冲区中分配,当对象大于TLAB中剩余内存或者TLAB内存耗尽,就采用CAS进行内存分配。是否使用TLAB可以通过参数-XX:+/-UseTLAB来设定。
  • 初始化零值:内存分配完后,虚拟机必须将分配到的内存空间都初始化为零值(不包括对象头),也就是把实例对象中的字段都初始化就可以了,这样十里字段即使不赋初值也能直接访问到这些字段数据类型所对应的零值

  • 设置对象头:初始化零值之后,虚拟机要对对象进行必要的设置,比如这个对象是哪个类的实例、如何找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息,这些数据都存在对象的对象头中,后续会细讲。根据虚拟机当前运行状态的不同,比如是否启用偏向锁等,对象头会有不同的设置方式。

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

对象的内存布局

在HotSpot虚拟机中,对象在堆内存中的存储布局可以划分为三个部分:对象头、实例数据和对齐填充。

对象头部分包括两类信息:第一类是用于存储对象自身的运行时数据,如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等;另一类是类型指针,即对象指向他的类型元数据的指针,java虚拟机通过这个指针来确定该对象是哪个类的实例,这个并不是必须有的,比如下面介绍的句柄访问,就不需要这个类型指针。

实例数据部分是对象真正存储的有效信息,也是在程序中所定义的各种类型的字段内容。

对齐填充部分没有什么含义,只是因为HotSpot虚拟机自动内存管理系统要求对象起始地址必须是8字节的整数倍,如果对象实例数据部分没有对齐就需要填充来补全。

对象的访问定位

java程序会通过栈上的reference数据来操作堆上的具体对象,在虚拟机规范中只是规定了它是一个指向对象的引用,并没有定义这个引用应该通过什么方式去定位、访问到堆中对象的具体位置,具体实现由虚拟机实现而定,目前主流的访问方式主要有两种:

  • 句柄访问:java堆中会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,句柄中包含了对象实例数据与类型数据各自的具体地址信息。

  • 直接指针:如果使用直接指针访问,那么 Java 堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,也就是上面提到对象头中的类型指针,而 reference 中存储的直接就是对象的地址,只访问对象本身就不需要多一次简介访问的开销。

    使用直接指针最大的好处就是速度快,节省了一次指针定位的时间开销,由于对象访问非常频繁,所以这种开销积少成多也是一种极为可观的执行成本。HotSpot主要使用直接指针进行对象访问(有例外情况,后续会讲)。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值