【Java】理解Java内存中堆栈机制与装箱拆箱的底层逻辑

文章目录

  • 前言
  • 一、原始类型、引用类型与包装类型
    • [1.1 原始类型(Primitive Types)](#1.1 原始类型(Primitive Types))
    • [1.2 引用类型(Reference Types)](#1.2 引用类型(Reference Types))
    • [1.3 包装类型(Wrapper Types)](#1.3 包装类型(Wrapper Types))
  • 二、JVM堆栈
    • [2.1 JVM栈](#2.1 JVM栈)
      • [2.1.1 基本信息](#2.1.1 基本信息)
      • [2.1.2 特点](#2.1.2 特点)
    • [2.2 JVM堆](#2.2 JVM堆)
      • [2.2.1 基本信息](#2.2.1 基本信息)
      • [2.2.2 特点](#2.2.2 特点)
    • [2.3 从代码中窥见堆栈](#2.3 从代码中窥见堆栈)
  • 三、装箱和拆箱
    • [3.1 装箱](#3.1 装箱)
    • [3.2 拆箱](#3.2 拆箱)
    • [3.3 自动装箱和拆箱的隐患](#3.3 自动装箱和拆箱的隐患)
  • 总结

前言

编写一个健壮的程序离不开对资源的高效利用,其核心在于高效利用内存与算力。Java程序的运行依赖JVM的内存管理,了解JVM 中堆栈的内存机制,会有助于我们提高程序运行效率、降低资源的消耗。

本篇文章将就我们常用的原始类型、引用类型与包装类型的数据存储关系开始,介绍堆(Heap)和栈(Stack)这两种基础内存区域,了解程序运行的时候堆和栈是如何决定数据的存储与访问方式。并且探究装箱与拆箱是如何偷走我们程序的内存和无端消耗资源的,以及如何去避免。下面就开始深入理解堆与栈。


一、原始类型、引用类型与包装类型

JVM中的数据类型分为原始类型和引用类型两大类。而包装类型本质上是对原始类型的封装,让其能成为一个对象,作为引用类型的方式存储数据。

1.1 原始类型(Primitive Types)

Java中的原始类型是直接存储值本身 。并且这些原始类型不是Object类的子类,自然也不具备如方法,属性这种只有对象才有的特性。未初始化的原始类型(局部变量)是没有默认值的

原始类型一共有八种:

类型 占用空间 取值范围 默认值
byte 1 字节 -2^7~ 2^7-1 0
short 2 字节 --2^15~ 2^15-1 0
int 4 字节 -2^31 ~ 2^31-1 0
long 8 字节 -2^63~ 2^63-1 0L
float 4 字节 单精度浮点数 0.0f
double 8 字节 双精度浮点数 0.0d
char 2 字节 '\u0000'~ '\uffff' (0 到 65535) '\u0000'
boolean 通常1个字节 true或false false

byte,short,int,long这类整数的数值取值范围没必要死记。根据其占用空间的字节算出其有效位,一个字节占八个位,由于第一位要作为正负数,并且正数是从0开始的。所以对于byte类型来说,它的正数有效范围是0到2^7-1,而负数 是-2^7 到 0。

原始类型作为局部变量或者方法参数的时候,它的变量名和值是直接存储在栈中。

补充:非局部变量的原始类型

原始类型作为局部变量的时候,它的变量名和值是直接存储在栈中。

但是对于那种定义在类中声明的实例变量原始类型来说,它属于对象的一部分,是随对象一起存储在堆内存中。比如

另外还有一种特殊情况,原始类型的静态变量,用static关键字修饰的,也是属于类本身,数据存储在元空间中。

java 复制代码
public class PrimitiveStorage {
    // 原始类型的实例变量,随对象存储在堆中
    private int instanceInt;
    // 原始类型静态变量,随着对象存储在元空间
    private static long staticLong;

    public static void main(String[] args) {
        //原始类型的局部变量,存储在栈中
        boolean localBool = true;
    }
}

1.2 引用类型(Reference Types)

前面说到JVM中除了原始类型就是引用类型,也就是像我们常见的接口,类,数组,枚举类型这些都是属于引用类型。所有引用类型都间接继承Object,所以天然可以使用Object类型的equals()这类方法,并且引用类型的默认值是null。

比起原始类型都存在默认值,引用类型这种允许为null的方式取决于它在内存中的存储方式。实例化的引用类型会在栈上开辟一段内存,栈上存放指向堆对象的引用地址,地址指向的堆上数据便是引用类型实例的真实数据。这种结构模式涉及到深拷贝和浅拷贝的问题,文章后面会具体讨论。

值得注意的是String也是引用类型

1.3 包装类型(Wrapper Types)

包装类型本质上就是对原始类型的封装后的引用类型。考虑到Java中集合和泛型都是要求参数为对象,并且原始类型无法继承。所以将原始类型对象化的包装类型,就能够更方便的融入Java的面向对象体系中。

原始类型和包装类型关系如下

原始类型 包装类型
byte Byte
short Short
int Integer
long Long
float Float
double Double
char Character
boolean Boolean

二、JVM堆栈

JVM在启动时会将操作系统分配的虚拟内存划分为多个区域,其中堆和栈是最为核心的两个数据存储区域。这两者采用不同的数据结构,存储的内容也不同,性能差异上也有巨大的差异。下面分别就二者的设计目的分别介绍。

2.1 JVM栈

2.1.1 基本信息

栈是一种先进后出(LIFO)的连续内存区域,由JVM自动管理分配,它存储的是原始类型的值、引用类型的引用和方法上下文。

  1. 原始类型无非就整型数值(byte,short,int,long)浮点类型(float,double)、布尔型(boolean),字符型(char)这种。
  2. 引用类型的实际数据存储在堆中,但其引用地址存储在栈上,也就是引用类型的引用。
  3. 方法的上下文内容包括方法参数、局部变量、返回地址和操作数栈与动态链接等。其中方法参数量可能是原始类型值或引用类型的引用。

当程序调用一个方法的时候,JVM 会在栈上创建一个栈帧(Stack Frame)。这个栈帧用于存储方法的参数;方法内的局部变量,如果是原始类型就存它本身,引用类型存储其引用类型的引用;方法执行开始时创建栈帧并压入栈,方法执行结束时或者抛出异常中断,栈帧会被弹出并销毁,回到调用处的位置的返回地址,其占用的栈内存自动释放。

操作数栈:方法内部存在各种计算操作是很常见的,在方法的栈帧有一个临时数据存储区域,提供给方法执行过程中内部中的各种局部运算操作
动态链接:Java是支持多态的,面向这种多态的场景,很多时候只有在运行期间才会知道要调用是哪个子类的方法。Java里是通过在编译成字节码的时候,给这类动态方法是通过一个占位符的形式。这个占位符指向JVM的运行时常量池,在程序运行期间,JVM会在里面注入实际调用方法的地址。这样就实现了动态链接,当然这个常量池就是存储在元空间上。

2.1.2 特点

栈在虚拟内存中的分配连续,入栈和出栈的速度极快。数据超出作用域或者方法执行完毕后,栈会自动释放对应的内存。栈相对而言是比较小的,约几十兆,容量很有限。

2.2 JVM堆

2.2.1 基本信息

比起小且连续的栈。堆是一种无序结构的大内存区域。Java的GC(垃圾回收器)自动管理内存的分配和释放。堆主要用来存储引用类型本身。

所有引用类型的实例数据,最终都会存储在堆中。像类的实例对象,枚举类型的实例对象还有数据对象。甚至是接口的实现类的实例,以及各种包装类型的实际数据都是存在堆上。

2.2.2 特点

前面提到堆是无序结构的大内存区域,在堆上面内存分配需要查找可用空间。对堆内存的释放也依赖 GC的定期清理,这里面是有一部分的性能开销存在的。虽然开发者无需手动释放堆内存,GC 会自动回收不再被引用的对象。但是频繁分配和释放可能导致不连续的空闲空间,GC虽然也会自动进行压缩操作会缓解但也有开销的存在。这种不连续的空闲空间进一步减慢了分配速度。

2.3 从代码中窥见堆栈

分别声明一个int类型(原始类型) 和一个类(引用类型),原始类型是存储在栈上,类的实例存储在堆上,类的变量仅保存引用地址,地址存放在栈上。

原始类型类型之间的复制传递的是栈上的值,也就是复制一个新的原始类型类型变量时,是在栈上开辟一个新的空间保存原始类型类型的值。

引用类型之间复制虽然本质上传递的也是栈上的引用,复制一个新的类变量的时候,也会在栈上开辟一个空间存储类的引用。这个引用地址指向堆,也就是类实例实际存放数据的位置。

在Java中,中所有参数传递都是基于值传递,这种特性就引申出了一个经典的话题,深拷贝和浅拷贝。对于原始类型来说,原始类型和被复制的原始类型之间数据是相互独立的,它们保存在栈上的不同空间。对其中一个的修改,不会影响到对方。对于引用类型,赋值时复制的是引用。原始对象的变量和复制对象的变量之间,在栈上虽然不是保存在一个位置,但是保存的都是同一个引用。也就是说如果通过其中一个栈上引用找到堆上的数据进行修改,也会影响到另一个对象。

示例代码

java 复制代码
public class Contrast {
    public static void main(String[] args) {

        //=== 原始类型(int)演示 ===
        System.out.println("=== 原始类型(int)演示 ===");
        int D = 777;
        //栈上开辟了一个新的空间存放H,值为777
        int H = D;

        System.out.println("修改前:D = " + D + ", H = " + H);
        D = 343;
        System.out.println("修改D后:D = " + D + ", H = " + H);
        //对于栈上不同空间的数据,并且数据本身不一样
        System.out.println("D == H?" + (D == H));


        //=== 引用类型演示 ===
        System.out.println("\n=== 引用类型(类的实例)演示 ===");
        Employee employee1 = new Employee("Cayde-6", 20);
        Employee employee2 = employee1; //复制引用地址,指向同一个堆内存

        System.out.println("修改前:employee1 = " + employee1 + ", employee2 = " + employee2);

        //修改employee1的属性,employee2也会变化。因为是指向同一个堆内存
        employee1.setAge(21);
        System.out.println("修改employee1年龄后:employee1 = " + employee1 + ", employee2 = " + employee2);

        //比较引用地址是否相同
        System.out.println("employee1 == employee2?" + (employee1 == employee2)); //指向同一个对象

        //创建新对象,内容相同但引用地址不同
        Employee employee3 = new Employee("Cayde-6", 20);
        System.out.println("employee1 == employee3?" + (employee1 == employee3)); //引用不同


        //方法值传递
        System.out.println("\n=== 方法传参差异演示 ===");
        int num = 9;
        Employee Employee = new Employee("Colonel", 6);

        //传递原始类型的值,方法内修改不影响外部。因为栈上内存不同,值也不同
        modifyPrimitive(num);
        System.out.println("方法调用后num = " + num);

        //传递引用类型的引用,方法内修改对象属性会影响外部,因为引用地址相同,指向同一个对象
        modifyReference(Employee);
        System.out.println("方法调用后Employee = " + Employee);

        //修改引用类型参数的引用,方法内修改引用,因为引用地址已经不相同了。方法外无法获取到修改后的引用
        changeReference(Employee);
        System.out.println("方法调用后Employee = " + Employee);
    }
    //修改原始类型参数
    private static void modifyPrimitive(int num) {
        num = 200;
    }

    //修改引用类型参数的属性值
    private static void modifyReference(Employee employee) {
        employee.setAge(employee.getAge() + 1);
    }

    //修改引用类型参数的引用
    private  static void changeReference(Employee employee) {
        employee = new Employee("Drift", 250);
    }
}

三、装箱和拆箱

前面我们聊过包装类型本质上就是对原始类型的封装后的引用类型,对于Java而言,原始类型转换成包装类型正是装箱,包装类型到原始类型是拆箱。这两者都是Java编译器自动帮助转换,不需要手动操作。

原始类型与包装类型之间转换的两种操作背后是内存里栈和堆的转换。这里面涉及内存分配、数据复制和类型检查等过程,理解装箱与拆箱能帮我们注意到各种容易引起性能消耗的陷阱。

3.1 装箱

将原始类型转换为包装类型的过程,称为装箱。是由编译器自动转换的。

原始类型是存储在栈上,而包装类型的实际数据是存储在堆上。当一个原始类型要转换成包装类型,首先会检查当前原始类型的值是否在其对应包装类型的缓存值里,如果存在则复用这个缓存值里的值作为包装类对象,如果不在范围内,则创建一个新的引用类型对象。后者需要在堆上分配内存,然后将栈上原始类型的值复制到堆上的装箱对象中。最后在栈上开辟一个空间存储这个包装类型对象的引用地址。

原始类型到包装类型的装箱中,堆上的装箱对象与原栈上的值类型是相互独立的。它们复制的是值本身,修改原变量不会影响装箱对象,反之亦然。

值得注意的是装箱是隐式的,编译器会自动帮我们转换。也就是说我们在敲代码的时候是不需要额外操作就能将一个原始类型赋值给包装类型。而前面我们了解到原始类型赋值给包装类型,可能会需要一次堆空间分配,然后是栈到堆的复制,最后是栈分配引用类型的引用。这些都是在不经意间增大程序的性能开销。

java 复制代码
int primitiveInt = 22;
//自动装箱
Integer wrapperInt = primitiveInt ; 

3.2 拆箱

将装箱后的包装类型转换回原来的原始类型的过程,称为拆箱。同样也是由编译器自动触发

比起装箱的隐式方便,拆箱的步骤要求较为严格。在每一次拆箱前都需要验证堆上的装箱包装类型对象是否确实是原始类型的装箱结果。并且包装类型的值不能是null,否则会抛出空指针异常(话说这个NPE真是一个不留神就会出现)。 类型验证通关后将堆上装箱对象中的值复制回栈上。

并且拆箱也是值复制,栈上的新的原始类型变量与堆上的包装类型对象之间是相互独立,修改新的原始类型变量不会影响堆上旧的包装类型对象,反之亦然。

java 复制代码
int primitiveInt = 22;
//自动装箱
Integer wrapperInt = primitiveInt ; 
//自动拆箱
int anotherPrimitiveInt = wrapperInt;

3.3 自动装箱和拆箱的隐患

频繁的装箱与拆箱会带堆内存频繁分配,如果原始值类型变量的值不在对应包装类型的缓存范围,那么每次装箱都需在堆上新建对象,增加JVM的GC压力。另外就是装箱和拆箱涉及堆栈数据复制,其中拆箱还设置类型校验和空值检查。

下面演示一下如何不经意间装箱和拆箱

java 复制代码
Map<String, Integer> alarmCount = new HashMap<>();
alarmCount.put("gateAlarm", pvMap.getOrDefault("gateAlarm", 0) + 1);

这里我们有一个报警次数的键值对,并且给它键名为gateAlarm实现加一操作。细看没啥问题,但是这里值是Integer类型,Integer的结果与1相加会触发包装类型自动转原始类型,最后结果保存的时候又转成了包装类型。

更加极端一点,如果我们代码里有个如下的循环,每次都会自动将sum转为原始类型与原始类型int i相加后,再自动转回包装类型。并且随着i的不断增长,逐渐超出Integer包装类型的默认值,那么接下来每一次循环都会创建包装类型对象。

java 复制代码
Integer sum = 0;
for (int i = 0; i < 1000000; i++) {
    //每次都会自动将sum转为原始类型与原始类型int i相加后,再自动转回包装类型
    sum += 1;
}

如何避雷自动装箱和拆箱的隐患,根据上面提到的,第一点自然是方法参数与返回值优先用原始类型,打不过就加入。比如仅仅是数值计算,我们可以优先使用原始类型作为参数和返回值,从源头避免。另外一个就是在循环中频繁计算要小心,注意观察是否存在装箱拆箱。


总结

理解Java这套内存逻辑,是写出低消耗消耗、高可用的Java程序的基础。

相关推荐
狼爷3 小时前
Java 25 到来:不仅是升级,更是一次时代声明
java
superman超哥3 小时前
Rust 开发环境配置:IDE 选择与深度优化实践
开发语言·ide·rust
CodeAmaz3 小时前
annotation-logging-guide
java·spring·log4j
鹿鸣天涯3 小时前
网络安全等级保护测评高风险判定实施指引(试行)--2020与2025版对比
开发语言·php
好好学习啊天天向上3 小时前
多维c++ vector, vector<pair<int,int>>, vector<vector<pair<int,int>>>示例
开发语言·c++·算法
星河队长3 小时前
C#实现智能提示输入,并增色显示
开发语言·c#
毕设源码-邱学长3 小时前
【开题答辩全过程】以 二手车交易系统的设计与实现为例,包含答辩的问题和答案
java·eclipse
song8546011343 小时前
uniapp如何集成第三方库
开发语言·uni-app