您的当前位置:首页正文

浅谈Java内存区域划分和内存分配策略

2024-11-08 来源:个人技术集锦

如果不知道,类的静态变量存储在那? 方法的局部变量存储在那? 赶快收藏

Java内存区域主要可以分为共享内存,堆、方法区和线程私有内存,虚拟机栈、本地方法栈和程序计数器。如下图所示,本文将详细讲述各个区域,同时也会讲述创建对象过程,内存分配策略, 和对象访问定位原理。觉得写得好的,可以点个收藏,绝对不亏。

Java内存区域

程序计数器

程序计数器,可以看作程序当前线程所执行的字节码行号指示器。字节码解释器工作时就是通过改变计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理都需要依赖计数器完成。线程执行Java方法时,记录其正在执行的虚拟机字节码指令地址,线程执行Native方法时,计数器记录为空。程序计数器时唯一在Java虚拟机规范中没有规定任何OutOfMemoryError情况区域。

理论可知,线程是通过轮流获取CPU执行时间以实现多线程的并发。为了暂停的线程下一次获得CPU执行时间,能正常运行,每一个线程内部都需要维护一个程序计数器,用来记住暂停线程暂停的位置。

注意:光理论是不够的,在此送大家一套2020最新Java架构实战教程+大厂面试宝典,点击此处 进来获取 一起交流进步哦!

Java虚拟机栈

Java虚拟机栈同程序计数器一样,也是线程私有的,虚拟机栈描述的是Java方法执行的内存模型,每个方法在执行的同时都会创建一个栈帧,用于存储局部变量表,操作数栈、动态链接和方法出入口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机中入栈到出栈的过程。

本地方法栈

与虚拟机栈相似。虚拟机栈为虚拟机执行Java方法服务,而本地方法栈则为虚拟机使用到的Native方法服务。

Java堆

所有线程共享的一块内存区域。Java虚拟机所管理的内存中最大的一块,因为该内存区域的唯一目的就是存放对象实例。几乎所有的对象实例都在这里分配内存,同时堆也是垃圾收集器管理的主要区域。因此很多时候被称为"GC堆"

方法区

和堆一样,是各个线程共享的内存区域,用于存储已被虚拟机加载的类信息、常量、静态变量、和编译器编译后的代码(也就是存储字节码文件.class)等数据。

方法区中有一个运行时常量池,编译后期生成的各种字面量和符号引用,存放在字节码文件中的常量池中。当类加载进入方法区时,就会把该常量池中的内容放入方法区中的运行时常量池。此外也可以在程序运行期间,将新的常量放入运行时常量池,比如String.intern()方法,该方法先从运行时常量池中查找是否有该值,如果有,则返回该值的引用,否则将该值加入运行时常量池。

实例详讲

class Demo1_Car{
  public static void main(String[] args) {
    Car c1 = new Car();
    //调用属性并赋值
    c1.color = "red";
    c1.num = 8;
    //调用行为
    c1.run();
    Car c2 = new Car();
    c2.color = "black";
    c2.num = 4;
    c2.run();
  }
}
Class Car{
  String color;
  int num;
  public void run() {
  	System.out.println(color + ".." + num);
	}
}
  • 首先运行程序,Demo1_car.java就会变为Demo1_car.class,Demo1_car.class加入方法区,检查是否字节码文件常量池中是否有常量值,如果有,那么就加入运行时常量池。
  • 遇到main方法,创建一个栈帧,入虚拟机栈,然后开始运行main方法中的程序。
  • Car c1 = new Car(), 第一次遇到Car这个类,所以将Car.java编译为Car.class文件,然后加入方法区.然后new Car(),在堆中创建一块区域,用于存放创建出来的实例对象,地址为0X001.其中有两个属性值color和num。默认值是null和 0
  • 然后通过c1这个引用变量去设置color和num的值,调用run方法,然后会创建一个栈帧,用来存储run方法中的局部变量等。run 方法中就打印了一句话,结束之后,该栈帧出虚拟机栈。又只剩下main方法这个栈帧。
  • 接着又创建了一个Car对象,所以又在堆中开辟了一块内存,之后就是跟之前的步骤一样了。

创建对象过程

虚拟机在遇到一条new指令时,会首先检查这个指令的参数是否可以在方法区中定位到一个类的符号引用,并且检查这个符号引用所代表的类是否已经被加载,解析和初始化过。如果没有,则必须先执行类加载过程.

类加载完之后,需要为对象分配内存,有两种分配内存的方法

  • 指针碰撞法(要求堆内存规整)

Java堆中空闲内存和已使用内存分别存放在堆的两边,中间存放一个指针作为分界点的指示器,在为对象分配内存时只需要将指针向空闲区域移动创建对象所需要的内存大小即可。

  • 空闲列表法

如果堆内存中已使用内存区域和空闲区域相互交错,此时虚拟机需要维护一个列表,记录哪些内存块是可用的,在分配时从列表中找到一块足够大的内存区域划分给对象实例并更新列表上的记录。

多线程情况下,线程同时分配内存可能会造成冲突,比如使用指针碰撞法,线程A正在分配内存,还没有改变指针指向,线程B,又同时使用原来的指针进行内存分配。防止冲突有两种方法

  • CAS操作:虚拟机采用CAS操作,加上失败重试的方式保证内存分配的原子性
  • 本地线程分配缓冲(TLAB):预先为线程分配一部分堆内存空间(线程私有,所以不存在同步问题)用于对象实例的内存分配。只有当TLAB用完,需要分配新的TLAB时,才需要进行同步操作。

内存分配完之后,虚拟机需要将分配到的内存空间均初始化为零值(不包括对象头)。在虚拟机中,执行完new指令后会接着执行方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来

对象在内存中的布局

  • 对象头(可以参考)

mark Word, 用于存储对象自身的运行时数据,如哈希码、GC分代年龄以及锁状态标志等。类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

  • 实例数据

对象真正存储的有效信息,也是程序代码中所定义的各种类型的字段内容。

  • 对齐填充

并非必然存在,仅仅起着占位符的作用。

对象的访问定位

Java程序需要通过栈上的reference数据来操作堆上的具体对象。共有两种策略进行对象的访问定位

  • 句柄访问

Java堆中划分出一块内存来作为句柄池,reference中存储的是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息,需要两次寻址。

  • 直接指针访问

Java堆中对象的布局中需要考虑如何放置访问类型数据的相关信息,而reference中存储的直接就是对象地址。

使用句柄访问的最大好处就是reference中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中实例数据指针,而reference本身不需要修改。

问题

只需要记住一件事,就是Java对象的内存分配均是在堆中进行的。所以对象都存储在堆中。

但是有人可能会怀疑方法的临时变量不是存储在虚拟机栈中吗?这里我要解释一下,虚拟机栈维护了一个局部变量表,表中存储的是对象的引用,而真正存储对象的地方在堆,如果局部变量都在堆里分配,那么虚拟机栈早爆满了

同样类的静态变量,有人又会怀疑在方法区中存储。其实不是的,方法区只存储引用,具体对象是存储在堆中的,具体实现可以发现,类静态对象是与class对象一起分配的内存。

参考

深入理解java虚拟机

显示全文