栏目分类:
子分类:
返回
名师互学网用户登录
快速导航关闭
当前搜索
当前分类
子分类
实用工具
热门搜索
名师互学网 > IT > 软件开发 > 后端开发 > Java

JVM学习笔记(一)JAVA内存区域

Java 更新时间: 发布时间: IT归档 最新发布 模块sitemap 名妆网 法律咨询 聚返吧 英语巴士网 伯小乐 网商动力

JVM学习笔记(一)JAVA内存区域

JVM学习笔记(一)JAVA内存区域
    • 一、内存区域
      • 1. 运行时数据区域
        • 1.1. 图示
        • 1.2. 程序计数器(线程私有)
        • 1.3. Java 虚拟机栈(线程私有)
          • 1.3.1. 局部变量表
          • 1.3.2. 操作数栈
          • 1.3.3. 动态连接
          • 1.3.4. 方法出口
        • 1.4. 本地方法栈(线程私有)
        • 1.5. 堆
        • 1.5. 方法区
        • 1.6. 运行时常量池
        • 1.7. 字符串常量池
        • 1.8. 直接内存
    • 二、对象的创建
      • 1. 类加载检查
      • 2. 分配内存
      • 3. 初始化零值
      • 4. 设置对象头
      • 5. 执行init方法初始化
    • 三、对象的内存布局
    • 四、对象的访问定位

参考: Java 内存区域详解
《深入理解JVM虚拟机》

一、内存区域 1. 运行时数据区域 1.1. 图示

JDK 1.8之前:

JDK 1.8 :

直接内存并不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》 中定义的内存区域

1.2. 程序计数器(线程私有)

程序计数器是一块较小的内存空间, 是唯一一个不会出现 OutOfMemoryError 的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡。

作用:

  1. 可以看作是当前线程所执行的字节码的行号指示器,字节码解释器工作时通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等功能都需要依赖这个计数器来完成
  2. 由于Java虚拟机的多线程是通过线程轮流切换、 分配处理器执行时间的方式来实现的, 在任何一个确定的时刻, 一个处理器(对于多核处理器来说是一个内核) 都只会执行一条线程中的指令。 因此, 为了线程切换后能恢复到正确的执行位置,使用程序计数器记录当前线程执行的位置
1.3. Java 虚拟机栈(线程私有)

虚拟机栈描述的是Java方法执行的线程内存模型: 每个方法被执行的时候, Java虚拟机都会同步创建一个栈帧(Stack Frame) 用于存储局部变量表、 操作数栈、 动态连接、 方法出口等信息。 每一个方法被调用直至执行完毕的过程, 就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。虚拟机栈也是线程私有的,它的生命周期与线程相同

1.3.1. 局部变量表

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

1.3.2. 操作数栈

主要作为方法调用的中转站使用,用于存放方法执行过程中产生的中间计算结果。另外,计算过程中产生的临时变量也会放在操作数栈中。

1.3.3. 动态连接

主要服务一个方法需要调用其他方法的场景。在 Java 源文件被编译成字节码文件时,所有的变量和方法引用都作为符号引用(Symbilic Reference)保存在 Class 文件的常量池里。当一个方法要调用其他方法,需要将常量池中指向方法的符号引用转化为其在内存地址中的直接引用。动态链接的作用就是为了将符号引用转换为调用方法的直接引用。

1.3.4. 方法出口

记录当本方法执行完之后,回到主方法时改从哪一行执行

Java 方法有两种返回方式:一种是 return 语句正常返回,一种是抛出异常。不管哪种返回方式,都会导致栈帧被弹出。也就是说, 栈帧随着方法调用而创建,随着方法结束而销毁。无论方法正常完成还是异常完成都算作方法结束

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

HotSpot虚拟机的栈容量是不可以动态扩展的。所以在HotSpot虚拟机上是不会由于虚拟机栈无法扩展而导致OutOfMemoryError异常——只要线程申请栈空间成功了就不会有OOM, 但是如果申请时就失败, 仍然是会出现OOM异常的

1.4. 本地方法栈(线程私有)

本地方法栈(Native Method Stacks) 与虚拟机栈所发挥的作用是非常相似的, 其区别只是虚拟机栈为虚拟机执行Java方法(也就是字节码) 服务, 而本地方法栈则是为虚拟机使用到的本地(Native)方法服务。

本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息。

与虚拟机栈一样, 本地方法栈也会在栈深度溢出或者栈扩展失败时分别抛出StackOverflowErrorOutOfMemoryError异常

HotSpot虚拟机中并不区分虚拟机栈和本地方法栈(两者合二为一)

1.5. 堆

对于java应用来说

  • 堆是虚拟机所管理的内存最大的一块
  • 堆是被所有线程共享的一块内存区域
  • 在虚拟机启动时创建
  • 此内存区域的唯一目的就是存放对象实例

在《Java虚拟机规范》 中对Java堆的描述是:“所有的对象实例以及数组都应当在堆上分配”。但随着着Java语言的发展, 现在已经能看到些许迹象表明日后可能出现值类型的支持, 即使只考虑现在, 由于即编译技术的进步, 尤其是逃逸分析技术的日渐强大, 栈上分配、 标量替换优化手段已经导致一些微妙的变化悄然发生, 所以说Java对象实例都分配在堆上也渐渐变得不是那么绝对了。从 JDK 1.7 开始已经默认开启逃逸分析,如果某些方法中的对象引用没有被返回或者未被外面使用(也就是未逃逸出去),那么对象可以直接在栈上分配内存(并不绝对,具体参考JVM 通过「逃逸分析」就能让对象在「栈上分配」?没那么简单!)

值类型可以与引用类型一样, 具有构造函数、 方法或是属性字段, 等等, 而它与引用类型的区别在于它在赋值的时候通常是整体复制, 而不是像引用类型那样传递引用的。 更为关键的是, 值类型的实例很容易实现分配在方法的调用栈上的, 这意味着值类型会随着当前方法的退出而自动释放, 不会给垃圾收集子系统带来任何压力。

java堆是垃圾收集器管理的内存区域,因此一些资料中它也被成为“GC堆”,从回收内存的角度看, 由于现代垃圾收集器大部分都是基于分代收集理论设计的, 所以Java堆中经常会出现“新生代”“老年代”“永久代”“Eden空间”“From Survivor空间”“To Survivor空间”等名词。

jdk1.8之前,堆分为以下三部分:

  • 新生代内存(Young Generation,包括Eden,S0,S1)
  • 老生代(Old Generation)
  • 永久代(Permanent Generation)

jdk1.8之后,堆分为以下三部分:

  • 新生代内存(Young Generation,包括Eden,S0,S1)
  • 老生代(Old Generation)
  • 元空间(Metaspace)

Hotspot 遍历所有对象时,按照年龄从小到大对其所占用的大小进行累积,当累积的某个年龄大小超过了 survivor 区的一半时,取这个年龄和 MaxTenuringThreshold 中更小的一个值,作为新的晋升年龄阈值

1.5. 方法区

当虚拟机要使用一个类时,它需要读取并解析 Class 文件获取相关信息,再将信息存入到方法区。方法区为各个线程共享的内存区域, 它会存储已被虚拟机加载的类信息、字段信息、方法信息、常量、静态变量、即时编译器编译后的代码缓存等数据

《Java虚拟机规范》 对方法区的约束是非常宽松的, 除了和Java堆一样不需要连续的内存和可以选择固定大小或者可扩展外, 甚至还可以选择不实现垃圾收集

虽然《Java虚拟机规范》 中把方法区描述为堆的一个逻辑部分, 但是它却有一个别名叫作“非堆”(Non-Heap) , 目的是与Java堆区分开来,原则上如何实现方法区属于虚拟机实现细节, 不受《Java虚拟机规范》 管束, 并不要求统一

方法区、永久代以及元空间之间的关系

在jdk1.8之前,HotSpot虚拟机选择将收集器的分代设计 扩展到方法区,或者说使用永久代来实现方法区而已,这样使得HotSpot的垃圾收集器能够像管理Java堆一样管理这部分内存, 省去专门为方法区编写内存管理代码的工作。

考虑到HotSpot未来的发展, 在JDK 6的时候HotSpot开发团队就有放弃永久代, 逐步改为采用本地内存(Native Memory) 来实现方法区的计划了, 到了JDK 7的HotSpot, 已经把原本放在永久代的字符串常量池、 静态变量等移出, 而到了JDK 8, 终于完全废弃了永久代的概念, 改用与JRockit、 J9一样在本地内存中实现的元空间(Metaspace) 来代替, 把JDK 7中永久代还剩余的内容(主要是类型信息) 全部移到元空间中

元空间替换永久代的原因

  1. 用永久代实现方法区导致应用更容易遭到内存溢出问题。永久代有-XX: MaxPermSize的上限, 即使不设置也有默认大小, 而J9和JRockit只要没有触碰到进程可用内存的上限, 例如32位系统中的4GB限制, 就不会出问题 , 而且有极少数方法(例如String::intern()) 会因永久代的原因而导致不同虚拟机下有不同的表现。

    可以使用 -XX:MaxMetaspaceSize 标志设置最大元空间大小,默认值为 unlimited,这意味着它只受系统内存的限制。-XX:MetaspaceSize 调整标志定义元空间的初始大小如果未指定此标志,则 Metaspace 将根据运行时的应用程序需求动态地重新调整大小。

  2. 元空间里面存放的是类的元数据,这样加载多少类的元数据就不由 MaxPermSize 控制了, 而由系统的实际可用空间来控制,这样能加载的类就更多了。

  3. 在 JDK8,合并 HotSpot 和 JRockit 的代码时, JRockit 从来没有一个叫永久代的东西, 合并之后就没有必要额外的设置这么一个永久代的地方了。

1.6. 运行时常量池

运行时常量池(Runtime Constant Pool) 是方法区的一部分。 Class文件中除了有类的版本、 字段、 方法、 接口等描述信息外, 还有一项信息是常量池表(Constant Pool Table)

字面量是源代码中的固定值的表示法,即通过字面我们就能知道其值的含义。字面量包括整数、浮点数和字符串字面量,符号引用包括类符号引用、字段符号引用、方法符号引用和接口方法符号引用

常量池表会在类加载后存放到方法区的运行时常量池中。

运行期间也可以将新的常量放入池中, 这种特性被开发人员利用得比较多的便是String类的intern()方法。(使用String的itern类时,会先去常量池查找当前字符串是否存在,如果不存在就会将当前字符串放入常量池中)

运行时常量池的功能类似于传统编程语言的符号表,尽管它包含了比典型符号表更广泛的数据。

既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError 错误。

1.7. 字符串常量池

字符串常量池 是 JVM 为了提升性能和减少内存消耗针对字符串(String 类)专门开辟的一块区域,主要目的是为了避免字符串的重复创建

StringTable 中保存的是字符串对象的引用,字符串对象的引用指向堆中的字符串对象

StringTable 本质上就是一个HashSet ,容量为 StringTableSize(可以通过 -XX:StringTableSize 参数来设置)

JDK 1.7 为什么要将字符串常量池移动到堆中?

主要是因为永久代(方法区实现)的 GC 回收效率太低,只有在整堆收集 (Full GC)的时候才会被执行 GC。Java 程序中通常会有大量的被创建的字符串等待回收,将字符串常量池放到堆中,能够更高效及时地回收字符串内存。

1.8. 直接内存

直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用。而且也可能导致 OutOfMemoryError 错误出现。

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

本机直接内存的分配不会受到 Java 堆的限制,但是,既然是内存就会受到本机总内存大小以及处理器寻址空间的限制。

二、对象的创建 1. 类加载检查

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

2. 分配内存

对象所需的内存大小在类加载完成后便可确定,为对象分配空间的任务实际上便等同于把一块确定大小的内存块从Java堆中划分出来

  1. 两种内存分配方式:
    • 指针碰撞:
      .假设java中的内存都是规整的,已经使用的内存放在一边,空闲的内存放在一边,中间使用指针作为分界点的 指示器,分配内存就是将指针向空闲方向移动一段与对象大小相等的距离
      使用该分配方式的gc收集器:Serial, ParNew
    • 空闲列表
      假设java中的内存并不规则,已经使用的和空闲的内存交错在一起,虚拟机将维护一个列表,记录哪些内存是空闲的,在对象需要分配内存的时候从表上查找一块足够大的内存划分给对象实例,并更新列表记录。
      使用该分配方式的gc收集器:CMS
  2. 分配内存并发问题
    对象创建在虚拟机中是非常频繁的事情,即使仅仅修改一个指针所指向的位置,在并发的情况下也不是安全的,可能出现刚给a分配了内存,但是指针指向还没来得及改,此时又使用原来的指针给b分配了内存
    解决方法:
    • CAS + 失败重试机制:
      对分配空间的操作进行同步处理。使用CAS+失败重试机制保证指针更新的原子性
    • 本地线程分配缓冲(TLAB):
      把内存分配的动作按照线程划分在不同的空间中进行,即每个线程都分配一小块内存,哪个线程要分配内存, 就在哪个线程的本地缓冲区中分配,只有线程的本地缓存用完了,分配新的缓存区时,才需要同步锁定。

      虚拟机是否使用TLAB, 可以通过-XX: +/-UseTLAB参数来设定

3. 初始化零值

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

4. 设置对象头

初始化零值完成之后,jvm虚拟机将要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码(实际上对象的哈希码会延后到真正调用Object::hashCode()方法时才计算)、对象的GC分代年龄等信息。这些信息存放在对象头中。 另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式

5. 执行init方法初始化

执行以上步骤之后,从虚拟机角度看,一个新的对象已经产生了,但是从java程序的角度看,对象创建才刚开始,因为Class文件中()方法还未执行,所有的字段都为默认的零值,对象需要的其他资源和状态信息都还未按照预定的意图构造好。java编译器会在遇到new关键字的地方同时生成new指令和invokespecial指令,但如果直接通过其他方式产生的则不一定如此。new指令之后会接着执行()方法(invokespecial指令), 按照程序员的意愿对对象进行初始化,这样一个真正可用的对象才算完全被构造出来

三、对象的内存布局

在HotSpot虚拟机中,对象在堆中的内存布局分为三部分

  1. 对象头
    对象头中包括两类信息:

    1. 用于存储对象自身的运行信息,如哈希码、gc分代年龄、锁状态标志、线程持有的锁、偏向锁id、偏向时间戳
    2. 对象头的另外一部分是类型指针, 即对象指向它的类型元数据的指针, Java虚拟机通过这个指针来确定该对象是哪个类的实例。并不是所有的虚拟机实现都必须在对象数据上保留类型指针, 换句话说, 查找对象的元数据信息并不一定要经过对象本身

      如果对象是一个Java数组, 那在对象头中还必须有一块用于记录数组长度的数据, 因为虚拟机可以通过普通Java对象的元数据信息确定Java对象的大小, 但是如果数组的长度是不确定的, 将无法通过元数据中的信息推断出数组的大小

  2. 实例数据
    实例数据部分是对象真正存储的有效信息。即我们在程序代码里面所定义的各种类型的字段内容, 无论是从父类继承下来的, 还是在子类中定义的字段都必须记录起来。

    这部分的存储顺序会受到虚拟机分配策略参数(-XX: FieldsAllocationStyle参数) 和字段在Java源码中定义顺序的影响。
    HotSpot虚拟机默认的分配顺序为longs/doubles、 ints、 shorts/chars、 bytes/booleans、 oops(OrdinaryObject Pointers, OOPs) , 从以上默认的分配策略中可以看到, 相同宽度的字段总是被分配到一起存放, 在满足这个前提条件的情况下, 在父类中定义的变量会出现在子类之前。 如果HotSpot虚拟机的+XX: CompactFields参数值为true(默认就为true) , 那子类之中较窄的变量也允许插入父类变量的空
    隙之中, 以节省出一点点空间。

  3. 对齐填充
    不是必然存在的,也没有特别的含义,仅仅起到占位符的作用

    由于HotSpot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍, 换句话说就是任何对象的大小都必须是8字节的整数倍。 对象头部分已经被精心设计成正好是8字节的倍数(1倍或者2倍) , 因此, 如果对象实例数据部分没有对齐的话, 就需要通过对齐填充来补全

四、对象的访问定位

对象的访问定位有两种方式:

  1. 句柄访问
    使用句柄访问的话,java堆内存中可能会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址。而句柄地址存储的是对象的实例数据和类型数据各自具体的地址信息
  2. 直接指针访问
    Java堆中对象的内存布局就必须考虑如何放置访问类型数据的相关信息, reference中存储的直接就是对象地址, 如果只是访问对象本身的话, 就不需要多一次间接访问的开销

    两者优缺点:
    这两种对象访问方式各有优势, 使用句柄来访问的最大好处就是reference中存储的是稳定句柄地址, 在对象被移动(垃圾收集时移动对象是非常普遍的行为) 时只会改变句柄中的实例数据指针, 而reference本身不需要被修改。使用直接指针来访问最大的好处就是速度更快, 它节省了一次指针定位的时间开销(HotSpot主要使用第二种方式进行对象访问)

转载请注明:文章转载自 www.mshxw.com
本文地址:https://www.mshxw.com/it/1013695.html
我们一直用心在做
关于我们 文章归档 网站地图 联系我们

版权所有 (c)2021-2022 MSHXW.COM

ICP备案号:晋ICP备2021003244-6号