有了上一篇文章的基础,这里我们再来做一些巩固练习,方便读者理解内存图的工作机理
例一
为例方便读者理解,这里我们用上一篇文章的例子稍作改进
1.代码
public class Test {
//属性
static int xx = 20;
int yy = 200;
String zz = new String("hello");
//方法
public static void main(String[] args) {
System.out.println(xx);
System.out.println(yy);
System.out.println(zz);
change();
eat();
Test test = new Test();
change();
test.eat();
}
public static void change(){
System.out.println("change执行");
}
public void eat(){
System.out.println("eat方法执行");
}
}
这里请读者思考几个问题
xx与yy、change和eat方法有什么区别?映射到内存中要如何解释?
代码在编译器中会报错,你能从内存的角度解析问什么会报错吗?
请你自己尝试画内存图解释代码
2.解析
在这段代码里,
xx
属于静态变量,而yy
是实例变量,这是它们的主要区别。静态变量xx
归类本身所有,它会被存放在方法区,并且所有类的实例都共享这一个变量。实例变量yy
则不同,它属于类的实例,在创建对象时,会在堆内存里为其分配空间。静态方法
change()
和实例方法eat()
也存在明显差异。静态方法可以直接通过类名来调用,它不依赖于类的实例,而且在静态方法内部不能直接访问实例变量和实例方法。实例方法eat()
必须通过类的实例来调用,在实例方法内部能够访问实例变量和静态变量。下面从内存的角度来分析代码报错的原因:
- 在静态方法
main
里直接访问实例变量yy
和zz
是不允许的。这是因为静态方法在类加载的时候就已经存在于方法区了,此时还没有创建对象,也就不存在实例变量。实例变量是在对象创建后存放在堆内存中的,所以静态方法无法直接访问它们。- 代码中静态变量
xx
可以正常访问,这是符合静态成员访问规则的。- 静态方法
change()
能在main
方法中直接调用,这是因为静态方法可以通过类名直接调用,也可以在同一个类的静态方法内部直接调用。- 实例方法
eat()
在第一次调用时没有通过实例来调用,这就导致了编译错误。第二次调用时使用test.eat()
,通过创建的实例来调用,这种方式是正确的。接下来分析代码各部分在内存中的存储位置:
- 静态变量
xx
:存放在方法区,在类加载的时候就会被初始化,并且只有一份拷贝。- 实例变量
yy
和zz
:存放在堆内存中,会随着对象的创建而产生,每个对象都有自己独立的实例变量。- 静态方法
main
和change()
:它们的字节码存放在方法区,在类加载时就已经存在。- 实例方法
eat()
:其字节码同样存放在方法区,不过需要通过对象实例来调用。- 局部变量
test
:存放在栈内存中,它保存的是堆内存中Test
对象的引用。要修正代码中的错误,就需要在访问实例变量和实例方法之前先创建对象实例。具体来说,在
main
方法中访问yy
、zz
和调用eat()
方法时,都要通过Test
类的实例来进行。
3.内存图
此图只做参考,希望能帮助读者进一步理解
例二
通过上面的例子我们发现示例对象会存储在堆区中,那么思考一下示例对象之间会不会互相影响?
1.代码
public class Test {
//属性
static int xx = 20;
int yy = 200;
String zz = new String("hello");
//方法
public static void main(String[] args) {
Test test = new Test();
System.out.println(test.xx);
System.out.println(test.yy);
System.out.println(test.zz);
System.out.println("--------------------");
Test test1 = new Test();
System.out.println(test1.xx);
System.out.println(test1.yy);
System.out.println(test1.zz);
test1.xx = 1111;
test1.yy = 222;
test1.zz = new String("demo");
System.out.println("===================");
System.out.println(test.xx);
System.out.println(test.yy);
System.out.println(test.zz);
System.out.println(test1.xx);
System.out.println(test1.yy);
System.out.println(test1.zz);
}
public static void change(){
System.out.println("change执行");
}
public void eat(){
System.out.println("eat方法执行");
}
}
2.解析
静态变量
xx
:
- 归属:属于类本身,而非实例。
- 内存位置:存储在方法区(Method Area),类加载时初始化,全局唯一。
- 访问方式:可通过类名直接访问(
Test.xx
),也可通过实例访问(如test.xx
),但本质上所有实例共享同一副本。实例变量
yy
和zz
:
- 归属:属于类的实例(对象)。
- 内存位置:存储在堆内存(Heap)中,每个对象独立拥有一份。
- 访问方式:必须通过对象实例访问(如
test.yy
)。静态方法
change()
:
- 调用方式:通过类名直接调用(
Test.change()
),无需创建实例。- 内存位置:方法字节码存储在方法区。
- 访问限制:只能访问静态成员(静态变量 / 方法),不能直接访问实例成员。
实例方法
eat()
:
- 调用方式:通过对象实例调用(如
test.eat()
)。- 内存位置:方法字节码存储在方法区,但需通过实例的
this
引用调用。- 访问权限:可访问静态成员和实例成员(通过
this
)
- 方法区存储:
- 类的结构信息(字段、方法定义)。
- 静态变量
xx
初始化为20
。- 所有方法的字节码(包括
main()
、change()
、eat()
)。
- 栈内存:
- 存储局部变量
test
,指向堆中的对象实例。- 存储局部变量
test1
,指向堆中的另一个对象实例。- 堆内存:创建
Test
对象,包含:
- 实例变量
yy = 200
。- 实例变量
zz
指向字符串常量池中的"hello"
(通过new String("hello")
创建)。
- 静态变量
xx
:在方法区被修改为1111
,所有实例(包括test
)的xx
均变为1111
。- 实例变量
yy
和zz
:仅修改test1
对象的副本,test
对象不受影响。
代码元素 内存位置 说明 静态变量 xx
方法区 类加载时初始化,全局唯一,所有实例共享。 实例变量 yy
和zz
堆内存 每个对象独立拥有,创建对象时初始化。 静态方法 main()
、change()
方法区 类加载时存在,通过类名或实例调用(静态方法推荐类名调用)。 实例方法 eat()
方法区 类加载时存在,但需通过实例调用(隐式传递 this
引用)。局部变量 test
、test1
栈内存 存储对象引用,指向堆中的实例。 字符串常量 "hello"
、"demo"
字符串常量池(方法区) 通过字面量创建的字符串存储在此,相同字面量共享同一地址。
3.内存图
例三
看完同一个类的实例创建,接下来我们来看在一个类中实例化其他类的对象
1.代码
public class Cat {
public static int count=10;
public String name = new String("猫");
public int age = 2;
public static void run(){
System.out.println("run");
}
public void eat(){
System.out.println("eat");
}
}
public class Test1 {
public static void main(String[] args) {
Cat.run();
System.out.println(Cat.count);
System.out.println("=======");
Cat cat1 = new Cat();
cat1.eat();
cat1.run();
System.out.println(cat1.age);
System.out.println(cat1.name);
System.out.println("=======");
Cat cat2 = new Cat();
cat2.eat();
cat2.run();
cat2.name = "豆豆";
cat2.age = 5;
System.out.println(cat2.age);
System.out.println(cat2.name);
}
}
2.解析
静态变量
xx
- 归属:属于类本身,而非实例。
- 内存位置:存储在方法区(Method Area),类加载时初始化,全局唯一。
- 访问方式:可通过类名直接访问(
Test.xx
),也可通过实例访问(如test.xx
),但本质上所有实例共享同一副本。实例变量
yy
和zz
- 归属:属于类的实例(对象)。
- 内存位置:存储在堆内存(Heap)中,每个对象独立拥有一份。
- 访问方式:必须通过对象实例访问(如
test.yy
)。静态方法
change()
- 调用方式:通过类名直接调用(
Test.change()
),无需创建实例。- 内存位置:方法字节码存储在方法区。
- 访问限制:只能访问静态成员(静态变量 / 方法),不能直接访问实例成员。
实例方法
eat()
- 调用方式:通过对象实例调用(如
test.eat()
)。- 内存位置:方法字节码存储在方法区,但需通过实例的
this
引用调用。- 访问权限:可访问静态成员和实例成员(通过
this
)。方法区
- 类的结构信息(字段、方法定义)。
- 静态变量
xx
(初始值20
,修改后1111
)。- 所有方法的字节码(
main()
、change()
、eat()
)。- 字符串常量
"hello"
、"demo"
(存储于字符串常量池)。栈内存
- 局部变量
test
、test1
:存储对象引用,分别指向堆中的Test
实例。堆内存
- 对象实例
test
:
- 实例变量
yy = 200
。- 实例变量
zz
指向字符串常量池中的"hello"
。- 对象实例
test1
:
- 初始时:
yy = 200
,zz
指向"hello"
。- 修改后:
yy = 222
,zz
指向新字符串"demo"
。总结表格
代码元素 内存位置 说明 静态变量 count
方法区 类加载时初始化为 10
,所有实例共享。实例变量 name
和age
堆内存 每个对象独立拥有,创建对象时初始化。 静态方法 run()
方法区 通过类名或实例调用,不能访问实例成员。 实例方法 eat()
方法区 通过实例调用,可访问静态和实例成员。 局部变量 cat1
、cat2
栈内存 存储对象引用,指向堆中的实例。 字符串常量 "猫"
、"豆豆"
字符串常量池(方法区) 通过字面量创建的字符串存储在此,相同字面量共享同一地址。
3.内存图
例四
读者请保证自己能理解掌握前三个例子,再来看这个例子
1.代码
public class Cat {
public static int count=10;
public String name = "猫";
public int age = 2;
public static void run(){
System.out.println("run");
}
public void eat(){
System.out.println("eat");
}
}
package com.qcby.MemeryMap;
public class Test1 {
public static void main(String[] args) {
int a = 10;
int b = 20;
System.out.println(a);
System.out.println(b);
change1(a,b);
System.out.println(a);
System.out.println(b);
System.out.println("===========");
String a1 = "a1";
String b1 = "b1";
System.out.println(a1);
System.out.println(b1);
change2(a1,b1);
System.out.println(a1);
System.out.println(b1);
System.out.println("===========");
Cat a2 = new Cat();
Cat b2 = new Cat();
b2.name = "豆豆";
b2.age = 5;
System.out.println(a2);
System.out.println(b2);
change3(a2,b2);
System.out.println(a2);
System.out.println(b2);
}
public static void change1(int a,int b){
int tmp = a;
a = b;
b = tmp ;
}
public static void change2(String a,String b){
String tmp = a;
a = b;
b = tmp ;
}
public static void change3(Cat a,Cat b){
Cat tmp = a;
a = b;
b = tmp ;
}
public static void change4(Cat a,Cat b){
int x1 = a.age;
a.age = b.age;
b.age = x1;
String str = a.name;
a.name = b.name;
b.name = str;
}
}
读到这里的读者请完成这三件事
1.预测一下代码的结果
2.复制代码到编译器上看看结果跟自己预期的是否一致3.画出内存图来解释,编译器输出的结果
2.解析
读者运行代码后发现肯定发现了,只有change4方法成功交换了对象的值
为什么前三个方法没有交换成功?
你能不能自己分析出来?
下面请看作者的参考解析
代码解析
核心逻辑
基本类型参数传递(
change1
):
- 方法接收
int
类型参数,尝试交换值,但基本类型按值传递,原变量不受影响。字符串参数传递(
change2
):
- 方法接收
String
类型参数(引用类型),但字符串不可变,交换引用不会影响原变量指向的字符串常量。对象引用传递(
change3
):
- 方法接收
Cat
对象引用,交换引用变量指向的对象,但原变量的引用未被修改(仅在方法内临时交换)。对象属性修改(
change4
):
- 方法通过引用直接修改对象属性(
age
和name
),原对象的属性会被永久改变。内存分析
方法区
类结构信息:
Test1
类字节码(包含main
、change1
-change4
方法)。Cat
类字节码(包含name
、age
实例变量和默认构造器)。字符串常量池:
"a1"
、"b1"
存储于常量池,由a1
、b1
引用。栈内存
main
方法栈帧:
- 基本类型变量:
a = 10
、b = 20
(存储于栈帧局部变量表)。- 引用类型变量:
a1
引用字符串常量池中的"a1"
。b1
引用字符串常量池中的"b1"
。a2
、b2
分别引用堆中的Cat
对象实例(地址 1、地址 2)。方法调用栈帧:
change1
栈帧:
- 接收
a=10
、b=20
的副本,交换后局部变量a=20
、b=10
,不影响main
中的原变量。change2
栈帧:
- 接收
a1
、b1
的引用副本,交换后副本指向"b1"
、"a1"
,但原变量a1
、b1
仍指向原值。change3
栈帧:
- 接收
a2
、b2
的引用副本,交换后副本指向对方对象,但原变量a2
、b2
仍保持初始引用。change4
栈帧:
- 接收
a2
、b2
的引用副本,通过引用直接修改对象属性(堆中数据),原对象属性永久改变。堆内存
Cat
对象实例:
a2
指向的对象:
name = "猫"
(默认值,来自Cat
类定义)。age = 2
(默认值)。b2
指向的对象:
name = "猫"
。age = 2
。- 调用
change4
后:
a2
对象:name = "猫"
(假设b2
对象修改后的值)、age
与b2
交换。b2
对象:name
与a2
交换、age
与a2
交换。
代码元素 内存位置 说明 基本类型变量 a
,b
栈内存 存储整数数值 10
、20
,方法调用时按值传递副本,原变量不受修改影响。字符串变量 a1
,b1
栈内存 存储引用,指向方法区字符串常量池中的 "a1"
、"b1"
。Cat
对象引用a2
,b2
栈内存 存储引用,分别指向堆中的 Cat
实例(地址 1、地址 2)。静态方法 change1
-change4
方法区 类加载时存储字节码,通过类名调用, change3
/change4
接收对象引用副本。Cat
实例变量name
,age
堆内存 每个 Cat
对象独立拥有,初始值为"猫"
、2
,change4
中被永久修改。字符串常量 "a1"
,"b1"
字符串常量池(方法区) 字面量创建的字符串, a1
、b1
直接引用,不可变。Cat
类定义方法区 存储类结构信息(字段、默认构造器),类加载时初始化。 Test1
类定义方法区 存储 main
方法及静态方法字节码,类加载时初始化。
3.内存图
上面的解释很啰嗦吧,接下来作者会画的细一点,注意方法内的指向(黄色),以及主方法(main)中(红色)的数值/引用指向
change1()
//main方法中还存储了其他变量,为了方便理解,我这里省略了,只画相关的变量。
从图中可以看出,交换数值确实发生了,但发生在
change1()
内的空间,主方法空间内位发生改变,所以在主线程打印的值未发生变化
change2()
与change1()同理
change3()
change4()
我们来解析为什么change4()能成功交换
change4()
方法之所以能够成功交换两个 Cat
对象的属性(age
和 name
),而其他方法(如 change3()
)无法改变原对象的引用或属性,核心原因在于 Java 的参数传递机制 和 对象引用的操作方式。
拓展
有些细节不知道读者是否发现
String a = new String("a");
String b = "b";
读者是否有关注过这两个变量存储的位置?
a是直接存储在堆内存中的
b是存储在堆内存的字符串常量池
究其原因是
(1) 字面量 "a" 的存储
存储位置:字符串常量池(方法区)
说明:
当使用new String("a")
时,JVM 首先会检查字符串常量池中是否已存在字面量"a"
。
若不存在,则在字符串常量池中创建
"a"
的实例。若已存在,则直接引用常量池中的
"a"
。(2) 通过 new 关键字创建的 String 对象
存储位置:堆内存
说明:
new String("a")
会在堆内存中创建一个新的String
对象,该对象内部持有对字符串常量池中标量"a"
的引用(通过char[]
数组指向常量池中的字符序列)。
即使常量池中已有
"a"
,new
操作也会在堆中创建独立的对象实例。此时,变量
a
存储在栈内存中,指向堆中的String
对象。(3) 字面量 "b" 的存储
存储位置:字符串常量池(方法区)
说明:
当使用字面量直接赋值(如String b = "b";
)时,JVM 会直接在字符串常量池中查找是否存在"b"
。
若不存在,则在常量池中创建
"b"
的实例,并将变量b
(存储在栈内存中)直接指向常量池中的该实例。若已存在,则直接让
b
指向常量池中的现有实例(避免重复创建,节省内存)。核心区别总结
创建方式 内存位置 是否创建新对象 内存复用性 new String("字面量")
堆内存(对象)+ 常量池(字面量) 是(堆中新建对象) 常量池字面量复用,堆对象不复用 字面量直接赋值(如 "b")
字符串常量池 否(直接引用常量池) 复用常量池已有实例