一、简单回顾内存
在之前我们讲过,每款软件在运行的时候都是要占用一块内存区域的,Java也不例外。在运行的时候,虚拟机也会占用一块内存空间。
只不过为了更好地去利用这块内存,JVM把它分成了五个部分,每个部分都有它自己的作用。
现在我们要知道的就是里面的三块:栈、堆、方法区
![image-20240405164717457](https://img-blog.csdnimg.cn/img_convert/2ecdaf01e4a1c172dcf4e157403aaf50.png)
这个里面的方法区我们要额外单独来说一下。方法区本身有很多作用、很多功能。其中,当我们要运行一个类的时候,这个类字节码文件就会被加载到方法区中临时存储。
在JDK7以前,方法区跟堆空间它们两个是连在一起的,在真实的物理内存当中也是一块连续的空间,但是这种设计方式并不是很好。
![image-20240403170838621](https://img-blog.csdnimg.cn/img_convert/016b1a6f959cf68d9c5c8b5c359f919d.png)
到了JDK8的时候,就改进了这种设计,取消了方法区,新增了一块元空间的区域,把它跟堆空间分开了,把原来方法区要做的很多的事情都进行拆分,有的功能放到了堆当中,有的功能放到了堆中,有的功能放到了元空间中,而现在加载字节码文件的功能在JDK8以后就归属于元空间了。
![image-20240403170257736](https://img-blog.csdnimg.cn/img_convert/01634834f74d447f384b1d20c1889dbb.png)
但是它具体叫什么名字不重要,重要的是它加载完后,代码该如何运行。为了方便大家理解,我们暂时将这块区域仍叫做方法区。
当运行一个类的时候,这个类的字节码文件就会加载到方法区中临时存储。比如说,这里有个HelloWorld.class
、Test.class
,在运行的时候都会加载到方法区中临时存储。
二、回顾栈和堆
栈:当方法被调用的时候,需要进栈执行,而方法里面所定义的变量其实也是在这里面的。当方法执行完毕后,它就需要出栈。
堆:只要是 new
出来的就会在堆中开辟一个小空间。堆里还有一个特点,就是堆里面开辟的空间都会有自己的地址值。
三、一个对象的内存图
1)创建变量的步骤
当我们在创建一个对象的时候,比如
Student s = new Student();
在创建 Student
对象的时候,内存里面至少会做以下7件事情。
1、加载class文件
其实也就是把Student类的字节码文件加载到内存。
2、声明局部变量
其实就是对等号左边的 s
来进行声明。
3、在堆内存中开辟一个空间
这句话说的就是等号的右边,有一个 new
关键字,所以在堆里就会开辟一个小空间。而这个小空间其实也就是我们平时所说的对象。
4、默认初始化
5、显示初始化
6、构造方法初始化
这里的4、5、6三步,其实都是对第3步中的变量来进行赋值的。
7、将堆内存中的地址值赋值给左边的局部变量
2)画图解释
创建变量前的代码
首先来看一下要画图的代码:写了一个很简单的Student类
,这里面有两个属性 —— name、age,然后还有一个 study
学习方法。
然后在测试类中创建对象。测试类名字叫做 TestStudent
,在这里面首先创建了它的对象,然后打印 s
,再使用 s
对 name 和 age 进行打印。然后再对 name 和 age 进行赋值,赋完值之后,再获取并打印,最后再调用 s
的 study
方法。
接下来看看这些代码在内存当中到底干了那些事。
首先程序肯定先从main方法开始执行的,所以说第一步,它要将 StudentTest类
的字节码文件(StudentTest.class)加载到方法区里,这里面就会把main方法进行临时存储。
然后虚拟机会自动调用程序的主入口 main方法
,所以此时main方法会被加载到栈里。
然后我们就要开始执行main方法中的代码了。第一句就是创建一个对象 Student s = new Student();
。
刚才我们说了,创建对象的代码,虚拟机至少做了以下的7步。
按步骤创建变量
① 加载class文件
由于Student s = new Student()
中用到了Student类,所以在方法区里面,它会把 Student.class
加载到这里面,临时存储。在 Student.class
中会有 Student
类的所有信息。例如所有的成员变量,还有所有的成员方法。
② 声明局部变量
其实就是在创建对象等号左边的这个代码,在main方法中,它就会开辟一个空间,这个空间的名字就叫做 s
,这个空间以后能存储 Student
这个类对象的地址值。
③ 在堆内存中开辟一个空间
其实也就是在创建对象等号右边的代码。因为有 new
关键字,所以此时在堆里就会有一个这样的小空间。而堆里的这些空间都是有地址值的。
所以现在假设这块空间的地址值是 001
,那么这块空间里面,就会把Student里面所有成员变量拿过来,拷贝一份放过来。除此之外,它还会有所有成员方法的地址,存储方法的地址是为了以后对象调方法的时候,我们能找到对应的方法。
此时堆里 001
的空间其实就是我们平时说的对象,但是现在这个对象还没有创建完毕,因为这里的 name 跟 age 都还没有值。
此时就需要4、 5、 6这三步。
首先它会进行默认初始化。
④ 默认初始化
这里 name 默认初始化就是 null,age默认初始化值就是 0。
⑤ 显示初始化
如果我们在Student类中定义成员变量的时候是直接给值了,这个就叫做显示初始化。
如果你这么写了,在显示初始化这一步,默认初始化值 null 跟 0,就会被 “张三” 和 23 所覆盖。
但是此时我们在代码当中并没有写这个代码,所以显示初始化我们可以忽略。
⑥ 构造方法初始化
在代码当中,由于小括号里什么都没写,所以就表示我现在调用的是空参构造。空参构造里也没有写代码,所以构造方法初始化我们也可以忽略。
但是假如此时,你用的是有参构造来创建对象,那么此时 name 跟 age 就会有值了。
因此,构造方法就是创建对象中的一步而已。
⑦ 将堆内存中的地址赋值给左边的局部变量
其实也就是把这里的 001
通过中间的 等号运算符 赋值给了左边的变量 s,此时s这个变量里就会存储地址值 001
。
s也可以通过 001
这个地址值找到右边的这个空间。
到目前为止,一个对象才创建完毕。
创建变量后的代码
所以说在下面,如果我们直接打印 s
的话,其实就是打印s中记录的地址值,此时在控制台中看到的其实也就是它的地址值。
但是这个地址值对我们来讲没有用,我们需要的是获取里面的属性。
因此在代码当中我们要通过 s
调用 name
, s
调用 age
。要注意的是,现在的 s
记录的是 001
地址值。
所以下面这个代码我们也可以理解为:我现在要打印 001
里面的 name
,还有 001
里面的 age
。001
找到的就是右边的这块空间。
所以就会把里面的 null 和 0 获取到了,在控制台中打印的也就是 null 和 0。
再往下就是通过 s.name
去给它赋值了。相当于就是把 "阿强"
赋值给了 001
的 name
,把 23
赋值给了 001
的 age
。此时它同样的也是找到了右边的这块空间。将原来的 null 和 0 给覆盖了。
赋值成功之后再获取,001
的 name
和 001
的 age
就变成了 阿强
和 23
。所以在控制台中打印的就是 阿强
和 23
。
最后一步 s.study()
,即用 s 调用 study,它也会先去找s中存储的,也就是 001
这个空间。
在 001
空间中,会有成员方法的地址,然后就找到下面的 study()
方法,这个时候study方法就会被加载进栈。
这个方法里的代码很简单,就是一句打印 “好好学习”。
当这句话打印完后,study方法就执行完毕,所以它就要从栈里面出去。
当study方法执行完毕后,整个main方法也执行完毕了,所以main方法也出栈了。
既然main方法都出去了,main中的变量也就随之消失了。所以 s
就会跟着消失。
当变量 s
消失的时候,变量指向的箭头,也就没有了。
针对右边的对象来讲,就没有人再去用这个对象了,专业是叫做:没有变量指向这个空间了。这个空间也会消失变成垃圾。
四、两个对象的内存图
两个对象的内存图 其实就是将刚刚的 一个对象创建的过程 重复了两次而已。
由于前面的内存图解释都是一样的,我们直接跳到创建第2个对象的时候的内存图。
但是在正式的画内存图之前,问你一个问题:这一次 .class
字节码文件是否要再加载一次?
答案是不需要,因为在刚刚这个 class文件 已经加载过了,所以第二次在创建对象的时候,class文件不需要再加载了,直接用就可以了。
这个时候它还是在等号的左边来声明了一个局部变量,这个局部变量的名字叫做 s2
,它以后能存储 Student类
对象的地址值。
再到了等号右边,因为有 new
关键字,所以同样也是在堆里面开辟了一块空间,这个里面也有 name
跟 age
,下面也会有成员方法的地址,此时还会给里面的变量进行默认初始化、显示初始化、构造方法初始化。
最后将堆空间中的地址赋值给 s2
。第二个对象的地址为 002
。s2
通过 002
这个地址能找到第二块空间。
所以在下面,我直接打印 s2
的话,它打印的其实是 s2
记录的地址 002
。
再往下赋值,是将 “阿珍” 赋值给了 s2
的 name !而 s2
记录的地址值是 002
,所以 “阿珍” 就赋值给了 002
的 name,24
就是赋值给了 002
的 age。002
里面 null 跟 0 就被覆盖了。
要注意的是我现在操作的仅仅是 002
的这块空间,对 001
空间里面的值没有任何影响。它们是两个互相独立的空间、
再往下获取的是 s2
的 name 和 s2
的 age,而 s2
记录的是 002
,所以 s2.name
就是获取 002
里面的name,s2.age
获取的就是 002
里面的 age
。
因此打印出来的就是 阿珍 和 24。
最后一步,它是通过 s2
去调用的 study()
方法。所以我们需要先通过 s2
去找到 002
这块空间,再通过 002
找到下面的 study方法,再把这个 study加载到栈里,打印里面的好好学习。
当study方法执行完毕,它就要出去。
当study方法执行完毕后,main方法也执行完毕,也要出去了。一旦main方法出去,变量 s1
跟 s2
也就没有了。针对于右边堆中的两个对象而言,就没有人再去使用这两个对象了。一旦没有人用它们,它们也就变成垃圾,这两个对象也就使用不了了。
五、两个引用指向同一个对象
由于 Student stu2 = stu1;
前面的代码和之前的代码都是一样的。
我们直接跳到 Student stu2 = stu1;
的地方。
这句话相当于把 stu1
变量里记录的东西赋值给 stu2
。
在内存是这样的,它首先会去栈中声明一个 stu2
的小空间,这块空间的名字就叫做 stu2
,这个类以后也能存储 Student
对象的地址值。此时它就将 stu1
记录的 001
赋值给了 stu2
,所以一旦赋值完后,stu2
里面存的也就是 001
了。
此时 stu2
通过 001
也能找到右边的空间,这个就相当于两个变量都指向了同一个对象。
再往下看,stu2.name = "阿珍"
,“阿珍”赋值给了 stu2
的 name,但此时 stu2
中记录的是 001
,所以这句话可以理解成:“阿珍” 赋值给了 001
的 name。001
里面 name 中存储的 阿强 就被 阿珍 给覆盖了。
此时再来执行最后一句话,由于 stu1
和 stu2
记录的都是 001
,所以这句话就相当于在获取 001
的 name
和 001
的 name
,即将 001
的 name
获取了两次,这个时候在代码中打印的就是 阿珍...阿珍
。
代码继续往下,在代码当中有一些其他的情况需要考虑,那就是 stu1 = null
。
null
就表示一个不存在的空间,相当于将 stu1
里面的 001
给覆盖了,这个时候这根线就断掉了。
再往下,再使用 stu1.name
再去获取的时候,stu1
就已经找不到 001
了,因为中间的这个连接已经断开了。
所以此时在程序中就会触发 NullPointerException
异常。(空指针异常)
再往下获取 stu2.name
,stu2
里是001
,它没有被任何东西给覆盖,它还能获取到 001
的空间,找到里的阿珍并进行打印。
最后一行,再把 null 获取给 stu2 ,这就表示 stu2 里记录的地址也没有了,那么中间这根黑色的连接也就断开了。
一旦断开之后,右边的这个对象就没有人去用它了,一旦没有人用它,这个对象就会变成垃圾,以后就用不了了。
当这行代码执行完毕后,main方法所有代码就执行完毕了,main方法就会从栈中出去。
总结:当两个变量指向同一个对象时,只要有一个变量对这个空间里的值发生了改变,那么其他变量再次访问的时候,就是改变之后的结果了。