深入理解java虚拟机1——内存结构

2.1 内存结构

  • 方法区
  • 本地方法栈
  • 程序计数器

2.2 直接内存

2.3 对象创建过程

  • 检查类加载
  • 分配内存地址(CAS / TLAB)
  • 地址数据清零
  • 设置对象头
  • 执行构造方法 <init>()等

2.4 对象内存布局

  • 对象头(MarkWord + KlassWord + (数组))
  • 实例数据(父类数据 + 本类数据)
  • padding

2.5 对象的访问方式

  • 句柄访问
  • 直接指针访问(Hotspot使用)

2.1 内存结构

根据《Java虚拟机规范》的规定,Java虚拟机所管理的内存将会包括以下几个运行时数据区域

1.方法区

共享
  • 它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据
  • 方法区是一种概念,方法区的实现称作"元空间"(Metaspace)。注:JDK8之前的实现称作"永久代"(PermGen)。
  • 运行时常量池:方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表(Constant Pool Table),用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。
  • 通常运行时常量池是在编译期生成内容的。但是运行期也可以放入,例如String类的intern()方法

  • 当常量池无法再申请到内存 时会抛出OutOfMemoryError异常

2.虚拟机栈

线程私有
  • 每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态连接、方法出口等信息。它的大小在编译期间确定,不会在方法执行期间改变。
  • 局部变量表:局部变量表是栈帧的一部分,用于存储方法参数方法内部定义的局部变量。它存放了编译期可知的各种Java虚拟机基本数据类型(boolean、byte、char、short、int、 float、long、double)、对象引用(reference类型,它并不等同于对象本身,可能是一个指向对象起始 地址的引用指针,也可能是指向一个代表对象的句柄或者其他与此对象相关的位置)和returnAddress 类型(指向了一条字节码指令的地址)。
  • 局部变量槽(Slot):局部变量表中数据的存储空间,其中64位长度的long和 double类型的数据会占用两个变量槽,其余的数据类型只占用一个。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。
  • 生命周期:与方法调用的生命周期相同,当方法调用结束后,对应的栈帧被销毁,局部变量表也随之销毁

  • 如果线程请求的栈深度大于虚 拟机所允许的深度,将抛出StackOverflowError异常。
  • 当栈扩展时无法申请到足够的内存会抛出OutOfMemoryError异常

3.虚拟机堆

共享
  • “几乎”所有的对象实例都在这里分配内存(特例:JVM可以通过一项称为"逃逸分析"的优化技术,进行如"栈上分配","标量替换"等优化)
  • Java堆是垃圾收集器管理的内存区域,因此也被称作“GC堆

  • 参数-Xmx和-Xms决定堆大小
  • 如果在Java堆中没有内存完成实例分配,并且堆也无法再 扩展时,Java虚拟机将会抛出OutOfMemoryError异常。

4.本地方法栈

线程私有
  • 与虚拟机栈所发挥的作用是非常相似的,其区别只是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地(Native)方法服务。 
  • 本地方法栈的内存分配通常由JVM在启动时确定,并且可能有固定的大小或者可动态扩展。由于本地方法栈主要支持非Java代码的执行,它的内存管理通常不受JVM垃圾回收管理
  • 当执行native方法时,JNI会调用C语言或者C++来执行,方法会放入线程私有的本地方法栈

  • 也会抛出StackOverFlowError和OutOfMemoryError异常
5.程序计数器线程私有
  • 每条线程都需要有一个独立的程序计数器,它的作用是存储当前线程执行的字节码指令的地址
  • 如果正在执行Native方法,此时计数器的值应该为空(Undefined)

  • 唯一没有OOM的区域

什么是本地方法:

  • 指用非Java语言实现的方法,它们通过Java本地接口(JavaNativeInterface)与Java代码交互。本地方法使用native关键字标识。
  • 常见的本地方法
    • System 类:
      • System.arraycopy(Object src, int srcPos, Object dest, int destPos, int length) 用于高效地复制数组。
      • System.currentTimeMillis() 获取当前时间的毫秒值。
      • System.nanoTime() 获取高精度的时间,用于性能度量。 Object 类: Object.clone() 创建并返回对象的一个副本。
    • Thread 类:
      • Thread.start0() 启动一个新线程。
      • Thread.sleep(long millis) 使当前线程休眠指定的毫秒数。
      • Thread.currentThread() 返回当前正在执行的线程对象的引用。
    • Runtime 类:
      • Runtime.freeMemory() 返回JVM空闲内存量。
      • Runtime.totalMemory() 返回JVM总内存量。
      • Runtime.gc() 请求垃圾收集器执行垃圾回收。
    • FileInputStream 和 FileOutputStream 类:
      • FileInputStream.open(String name) 打开指定名称的文件以供读取。
      • FileOutputStream.write(int b, boolean append) 将指定的字节写入文件输出流。
    • Unsafe 类 (sun.misc.Unsafe):
      • Unsafe.allocateMemory(long bytes) 分配一块给定大小的内存。
      • Unsafe.freeMemory(long address) 释放指定内存地址的内存。

2.2 直接内存

  • 直接内存的定义与位置:直接内存(Direct Memory)不是JVM堆内存的一部分,它是在Java堆外分配的内存,直接向操作系统申请的内存空间
  • 由来:在JDK 1.4中新加入了NIO类,引入了一种基于通道(Channel)与缓冲区 (Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆里面的DirectByteBuffer对象作为这块内存的引用进行操作。
  • DirectByteBuffer中的unsafe.allocateMemory(size)是个一个native方法,这个方法分配的是堆外内存,通过C的malloc来进行分配的。分配的内存是系统本地的内存
  • 优点:在一些场景中显著提高性能,因为避免在Java堆和Native堆中来回复制数据。
  • 受到本机总内存的限制,也会有OOM

2.3 对象创建过程

1.类加载检查,如果没有则进行类加载

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

2.为对象在堆中分配内存

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

主要使用的两种分配方式(这两种策略被用于不同的场景和垃圾收集器中)

  • 指针碰撞(Bump The Pointer)
    • 内存是规整的,所有用过的内存块被放在一起,而空闲的内存放在另一边。对象分配时,直接根据需要分配的大小移动指针:指针之前的部分是已分配的内存,指针之后的部分是未分配的内存
    • 适用场景:垃圾收集器实现的新生代区域,如使用Serial、ParNew等带压缩整理过程的收集器时
    • 优点:①分配速度快,只需要修改一次指针。②实现简单,不需要遍历整个堆来找到足够大的空间
    • 局限性:需要与压缩(Compaction)或复制(Copying)的垃圾回收算法结合使用,以避免内存碎片化
  • 空闲列表(Free List)
    • 内存不规整,维护了一个空闲内存块的列表,每个空闲内存块都记录了自己的大小和位置。当分配内存时,系统会遍历这个列表,找到一个足够大的空闲块,然后从中划分出所需的内存给对象使用,并更新列表中的空闲块信息
    • 适用场景:适用老年代,如CMS这种基于清除 (Sweep)算法的收集器
    • 局限性:分配速度相对较慢,因为需要遍历空闲列表来找到合适的内存块

如何确保多线程下的线程安全(多个线程可能会抢同一块地址):两种解决方案

  • CAS
    • 对分配内存空间的动作进行同步处理——采用CAS配上失败重试的方式保证更新操作的原子性
    • 效率低
  • TLAB(Thread Local Allocation Buffer):浅析java中的TLAB
    • 另一种是把内存按线程划分成不同区域,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB),哪个线程要分配内存,就在哪个线程的本地缓冲区中分配,只有本地缓冲区用完了,分配新的缓存区时才需要同步锁定分配到Eden区域
  • Java中JVM使用的方法
    • 线程初始化时会申请一块Buffer,先在TLAB上分配,如果Buffer容量不足,再用CAS在Eden上分配
    • 可以通过-XX:+/-UseTLAB参数来设定是否使用TLAB

3.分配到的内存空间(但不包括对象头)都初始化为零值

  • 所以对于基本类型,默认值是0
  • 如果使用TLAB,这项工作会提前至TLAB分配时完成

4.设置对象头

初始化零值完成之后,JVM将设置对象头的信息。对象头信息包括这个对象是哪个类的实例(KlassWord)、如何才能找到类的元数据信息、对象的哈希码(实际上对象的哈希码会延后到真正调用Object::hashCode()方法时才计算)、对象的GC分代年龄,是否启用偏向锁等信息。

5.调用构造函数<init>方法

执行new指令所对应的字节码,即执行<init>()方法。这个方法中将包括Java代码所设定的初始化,比如你可能会在一个对象的构造函数中设置初始值或者通过方法调用来进行初始化操作。

对象创建过程中可能存在的问题

  • 内存分配并发问题:见上文中的TLAB解决方案
  • 初始化不完全问题:指对象初始化未完全完成就被其他线程可见,这通常是由于Java内存模型中允许的重排序操作所导致。为了解决这个问题,JVM在对象头的构造过程中会插入必要的内存屏障,以禁止特定类型的处理器重排序

2.4 对象的内存布局

对象的组成:对象头、实例数据、填充对齐。详细见Java并发编程3——JMM / synchronized / volatile / Monitor / 加锁算法_violate monitor_JYY_JYY_的博客-CSDN博客

  • 对象头(主要两部分:Mark Word + Klass Word)
    • Mark Word:用于存储对象自身的运行时数据。包括哈希码、GC分代年龄、锁状态标志位、线程持有的锁、偏向线程ID、偏向时间戳等
    • Klass Word:类型指针,指向它的类型元数据的指针
    • 数组长度(只有数组对象才有)
  • 实例数据:包括父类的字段内容,本类的属性信息及字段内容等
    • 分配顺序规则
      • 先分配父类
      • 默认的分配顺序为longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers,OOPs)
  • 对齐填充(padding):确保任何对象的大小必须是8字节的整数倍,管理方便

2.5 对象的访问方式

  • 句柄访问(Handle Access)
    • 对象指向句柄池,再根据句柄池指向具体数据的地址。通过一个中介层实现了对象的间接访问。
    • 优点:对象被移动时(如GC整理)只需要改指针
    • 缺点:寻址时多一次指针查找
  • 直接指针访问(Direct Pointer Access)(HotSpot使用)
    • 直接指向具体数据,如果要访问对象类型则需再一次指针
    • 优点:对于访问具体数据,只需要一次指针寻址
    • 缺点:移动麻烦

2.6 虚拟机参数

堆大小

堆最小值:-Xms

堆最大值:-Xmx

栈大小-Xss
常量池

-XX:PermSize

-XX:MaxPermSize


Reference

TLAB:

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值