基本数据类型是放在栈中还是放在堆中,这取决于基本类型在何处声明
一:在方法中声明的变量,即该变量是局部变量,每当程序调用方法时,系统都会为该方法建立一个方法栈(其实就是虚拟机栈中的栈帧,里面存着局部变量表,方法调用结束之后栈帧出栈),其所在方法中声明的变量就放在方法栈中,当方法结束系统会释放方法栈,其对应在该方法中声明的变量随着栈的销毁而结束,这就局部变量只能在方法中有效的原因
在方法中声明的变量可以是基本类型的变量,也可以是引用类型的变量。
(1)当声明是基本类型的变量的时,其变量名及值(变量名及值是两个概念)是放在JAVA虚拟机栈中
(2)当声明的是引用变量时,所声明的变量(该变量实际上是在方法中存储的是内存地址值)是放在JAVA虚拟机的栈中,该变量所指向的对象是放在堆类存中的。
二:在类中声明的变量是成员变量,也叫全局变量,放在堆中的(因为全局变量不会随着某个方法执行结束而销毁)。
同样在类中声明的变量即可是基本类型的变量 也可是引用类型的变量
(1)当声明的是基本类型的变量其变量名及其值放在堆内存中的
(2)引用类型时,其声明的变量仍然会存储一个内存地址值,该内存地址值指向所引用的对象。引用变量名和对应的对象仍然存储在相应的堆中
我想所有 Java 程序员都曾被这个 new String 的问题困扰过,这是一道高频的 Java 面试题,但可惜的是网上众说纷纭,竟然找不到标准的答案。有人说创建了 1 个对象,也有人说创建了 2 个对象,还有人说可能创建了 1 个或 2 个对象,但谁都没有拿出干掉对方的证据,这就让我们这帮吃瓜群众们陷入了两难之中,不知道到底该信谁得。
但是今天,老王就斗胆和大家聊聊这个话题,顺便再拿出点证据。
以目前的情况来看,关于 new String("xxx")
创建对象个数的答案有 3 种:
-
有人说创建了 1 个对象;
-
有人说创建了 2 个对象;
-
有人说创建了 1 个或 2 个对象。
而出现多个答案的关键争议点在「字符串常量池」上,有的说 new 字符串的方式会在常量池创建一个字符串对象,有人说 new 字符串的时候并不会去字符串常量池创建对象,而是在调用 intern()
方法时,才会去字符串常量池检测并创建字符串。
那我们就先来说说这个「字符串常量池」。
字符串常量池
字符串的分配和其他的对象分配一样,需要耗费高昂的时间和空间为代价,如果需要大量频繁的创建字符串,会极大程度地影响程序的性能,因此 JVM 为了提高性能和减少内存开销引入了字符串常量池(Constant Pool Table)的概念。
字符串常量池相当于给字符串开辟一个常量池空间类似于缓存区,对于直接赋值的字符串(String s="xxx")来说,在每次创建字符串时优先使用已经存在字符串常量池的字符串,如果字符串常量池没有相关的字符串,会先在字符串常量池中创建该字符串,然后将引用地址返回变量,如下图所示:
以上说法可以通过如下代码进行证明:
public class StringExample {
public static void main(String[] args) {
String s1 = "Java";
String s2 = "Java";
System.out.println(s1 == s2);
}
}
以上程序的执行结果为:true
,说明变量 s1 和变量 s2 指向的是同一个地址。
在这里我们顺便说一下字符串常量池的再不同 JDK 版本的变化。
常量池的内存布局
从JDK 1.7 之后把永生代换成的元空间,把字符串常量池从方法区移到了 Java 堆上。
JDK 1.7 内存布局如下图所示:
JDK 1.8 内存布局如下图所示:
JDK 1.8 与 JDK 1.7 最大的区别是 JDK 1.8 将永久代取消,并设立了元空间。官方给的说明是由于永久代内存经常不够用或发生内存泄露,会爆出 java.lang.OutOfMemoryError: PermGen 的异常,所以把将永久区废弃而改用元空间了,改为了使用本地内存空间,官网解释详情:http://openjdk.java.net/jeps/122
答案解密
认为 new 方式创建了 1 个对象的人认为,new String 只是在堆上创建了一个对象,只有在使用 intern()
时才去常量池中查找并创建字符串。
认为 new 方式创建了 2 个对象的人认为,new String 会在堆上创建一个对象,并且在字符串常量池中也创建一个字符串。
认为 new 方式有可能创建 1 个或 2 个对象的人认为,new String 会先去常量池中判断有没有此字符串,如果有则只在堆上创建一个字符串并且指向常量池中的字符串,如果常量池中没有此字符串,则会创建 2 个对象,先在常量池中新建此字符串,然后把此引用返回给堆上的对象,如下图所示:
老王认为正确的答案:创建 1 个或者 2 个对象。
技术论证
解铃还须系铃人,回到问题的那个争议点上,new String 到底会不会在常量池中创建字符呢?我们通过反编译下面这段代码就可以得出正确的结论,代码如下:
public class StringExample {
public static void main(String[] args) {
String s1 = new String("javaer-wang");
String s2 = "wang-javaer";
String s3 = "wang-javaer";
}
}
首先我们使用 javac StringExample.java
编译代码,然后我们再使用 javap -v StringExample
查看编译的结果,相关信息如下:
Classfile /Users/admin/github/blog-example/blog-example/src/main/java/com/example/StringExample.class
Last modified 2020年4月16日; size 401 bytes
SHA-256 checksum 89833a7365ef2930ac1bc3d7b88dcc5162da4b98996eaac397940d8997c94d8e
Compiled from "StringExample.java"
public class com.example.StringExample
minor version: 0
major version: 58
flags: (0x0021) ACC_PUBLIC, ACC_SUPER
this_class: #16 // com/example/StringExample
super_class: #2 // java/lang/Object
interfaces: 0, fields: 0, methods: 2, attributes: 1
Constant pool:
#1 = Methodref #2.#3 // java/lang/Object."<init>":()V
#2 = Class #4 // java/lang/Object
#3 = NameAndType #5:#6 // "<init>":()V
#4 = Utf8 java/lang/Object
#5 = Utf8 <init>
#6 = Utf8 ()V
#7 = Class #8 // java/lang/String
#8 = Utf8 java/lang/String
#9 = String #10 // javaer-wang
#10 = Utf8 javaer-wang
#11 = Methodref #7.#12 // java/lang/String."<init>":(Ljava/lang/String;)V
#12 = NameAndType #5:#13 // "<init>":(Ljava/lang/String;)V
#13 = Utf8 (Ljava/lang/String;)V
#14 = String #15 // wang-javaer
#15 = Utf8 wang-javaer
#16 = Class #17 // com/example/StringExample
#17 = Utf8 com/example/StringExample
#18 = Utf8 Code
#19 = Utf8 LineNumberTable
#20 = Utf8 main
#21 = Utf8 ([Ljava/lang/String;)V
#22 = Utf8 SourceFile
#23 = Utf8 StringExample.java
{
public com.example.StringExample();
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 3: 0
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=3, locals=4, args_size=1
0: new #7 // class java/lang/String
3: dup
4: ldc #9 // String javaer-wang
6: invokespecial #11 // Method java/lang/String."<init>":(Ljava/lang/String;)V
9: astore_1
10: ldc #14 // String wang-javaer
12: astore_2
13: ldc #14 // String wang-javaer
15: astore_3
16: return
LineNumberTable:
line 5: 0
line 6: 10
line 7: 13
line 8: 16
}
SourceFile: "StringExample.java"
备注:以上代码的运行也编译环境为 jdk1.8.0_101。
其中 Constant pool
表示字符串常量池,我们在字符串编译期的字符串常量池中找到了我们 String s1 = new String("javaer-wang");
定义的“javaer-wang”字符,在信息 #10 = Utf8 javaer-wang
可以看出,也就是在编译期 new 方式创建的字符串就会被放入到编译期的字符串常量池中,也就是说 new String 的方式会首先去判断字符串常量池,如果没有就会新建字符串那么就会创建 2 个对象,如果已经存在就只会在堆中创建一个对象指向字符串常量池中的字符串。
那么问题来了,以下这段代码的执行结果为 true 还是 false?
String s1 = new String("javaer-wang");
String s2 = new String("javaer-wang");
System.out.println(s1 == s2);
既然 new String 会在常量池中创建字符串,那么执行的结果就应该是 true 了。其实并不是,这里对比的变量 s1 和 s2 堆上地址,因为堆上的地址是不同的,所以结果一定是 false,如下图所示:
从图中可以看出 s1 和 s2 的引用一定是相同的,而 s3 和 s4 的引用是不同的,对应的程序代码如下:
public static void main(String[] args) {
String s1 = "Java";
String s2 = "Java";
String s3 = new String("Java");
String s4 = new String("Java");
System.out.println(s1 == s2);
System.out.println(s3 == s4);
}
程序执行的结果也符合预期:
true
false
扩展知识
我们知道 String 是 final 修饰的,也就是说一定被赋值就不能被修改了。但编译器除了有字符串常量池的优化之外,还会对编译期可以确认的字符串进行优化,例如以下代码:
public static void main(String[] args) {
String s1 = "abc";
String s2 = "ab" + "c";
String s3 = "a" + "b" + "c";
System.out.println(s1 == s2);
System.out.println(s1 == s3);
}
按照 String 不能被修改的思想来看,s2 应该会在字符串常量池创建两个字符串“ab”和“c”,s3 会创建三个字符串,他们的引用对比结果也一定是 false,但其实不是,他们的结果都是 true,这是编译器优化的功劳。
同样我们使用 javac StringExample.java
先编译代码,再使用 javap -c StringExample
命令查看编译的代码如下:
警告: 文件 ./StringExample.class 不包含类 StringExample
Compiled from "StringExample.java"
public class com.example.StringExample {
public com.example.StringExample();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public static void main(java.lang.String[]);
Code:
0: ldc #7 // String abc
2: astore_1
3: ldc #7 // String abc
5: astore_2
6: ldc #7 // String abc
8: astore_3
9: getstatic #9 // Field java/lang/System.out:Ljava/io/PrintStream;
12: aload_1
13: aload_2
14: if_acmpne 21
17: iconst_1
18: goto 22
21: iconst_0
22: invokevirtual #15 // Method java/io/PrintStream.println:(Z)V
25: getstatic #9 // Field java/lang/System.out:Ljava/io/PrintStream;
28: aload_1
29: aload_3
30: if_acmpne 37
33: iconst_1
34: goto 38
37: iconst_0
38: invokevirtual #15 // Method java/io/PrintStream.println:(Z)V
41: return
}
从 Code 3、6 可以看出字符串都被编译器优化成了字符串“abc”了。
总结
本文我们通过 javap -v XXX
的方式查看编译的代码发现 new String 首次会在字符串常量池中创建此字符串,那也就是说,通过 new 创建字符串的方式可能会创建 1 个或 2 个对象,如果常量池中已经存在此字符串只会在堆上创建一个变量,并指向字符串常量池中的值,如果字符串常量池中没有相关的字符,会先创建字符串在返回此字符串的引用给堆空间的变量。我们还将了字符串常量池在 JDK 1.7 和 JDK 1.8 的变化以及编译器对确定字符串的优化,希望能帮你正在的理解字符串的比较。
String 身体解密
想要深入了解,就先从基本组成开始……
「String 缔造者」对 String
对象做了大量优化来节省内存,从而提升 String 的性能:
Java 6 及之前
数据存储在 char[]
数组中,String
通过 offset
和 count
两个属性定位 char[]
数据获取字符串。
这样可以高效快速的定位并共享数组对象,并且节省内存,但是有可能导致内存泄漏。
共享 char 数组为啥可能会导致内存泄漏呢?
String(int offset, int count, char value[]) {
this.value = value;
this.offset = offset;
this.count = count;
}
public String substring(int beginIndex, int endIndex) {
//check boundary
return new String(offset + beginIndex, endIndex - beginIndex, value);
}
复制代码
调用 substring()
的时候虽然创建了新的字符串,但字符串的值 value
仍然指向的是内存中的同一个数组,如下图所示:
如果我们仅仅是用 substring
获取一小段字符,而原始 string
字符串非常大的情况下,substring 的对象如果一直被引用。
此时 String
字符串也无法回收,从而导致内存泄露。
如果有大量这种通过 substring 获取超大字符串中一小段字符串的操作,会因为内存泄露而导致内存溢出。
JDK7、8
去掉了 offset
和 count
两个变量,减少了 String 对象占用的内存。
substring 源码:
public String(char value[], int offset, int count) {
this.value = Arrays.copyOfRange(value, offset, offset + count);
}
public String substring(int beginIndex, int endIndex) {
int subLen = endIndex - beginIndex;
return new String(value, beginIndex, subLen);
}
复制代码
substring()
通过 new String()
返回了一个新的字符串对象,在创建新的对象时通过 Arrays.copyOfRange()
深度拷贝了一个新的字符数组。
如下图所示:
String.substring 方法不再共享 char[]
数组的数据,解决了可能内存泄漏的问题。
Java 9
将 char[]
字段改为 byte[]
,新增 coder
属性。
码哥,为什么这么改呢?
一个 char 字符占 2 个字节,16 位。存储单字节编码内的字符(占一个字节的字符)就显得非常浪费。
为了节约内存空间,于是使用了 1 个字节占 8 位的 byte 数组来存放字符串。
勤俭节约的女神,谁不爱……
新属性 coder 的作用是:在计算字符串长度或者使用 indexOf()
方法时,我们需要根据编码类型来计算字符串长度。
coder 的值分别表示不同编码类型:
- 0:表示使用
Latin-1
(单字节编码); - 1:使用
UTF-16
。
String 的不可变性
了解了String
的基本组成之后,发现 String 还有一个比外在更性感的特性,她被 final
关键字修饰,char 数组也是。
我们知道类被 final 修饰代表该类不可继承,而 char[]
被 final+private
修饰,代表了 String
对象不可被更改。
String 对象一旦创建成功,就不能再对它进行改变。
final 修饰的好处
安全性
当你在调用其他方法时,比如调用一些系统级操作指令之前,可能会有一系列校验。
如果是可变类的话,可能在你校验过后,它的内部的值又被改变了,这样有可能会引起严重的系统崩溃问题。
高性能缓存
String
不可变之后就能保证 hash
值得唯一性,使得类似 HashMap
容器才能实现相应的 key-value
缓存功能。
实现字符串常量池
由于不可变,才得以实现字符串常量池。
大字符串如何构建
既然 String 对象是不可变,所以我们在频繁拼接字符串的时候是否意味着创建多个对象呢?
String str = "癞蛤蟆撩青蛙" + "长的丑" + "玩的花";
复制代码
是不是以为先生成「癞蛤蟆撩青蛙」对象,再生成「癞蛤蟆撩青蛙长的丑」对象,最后生成「癞蛤蟆撩青蛙长得丑玩的花」对象。
实际运行中,只有一个对象生成。
这是为什么呢?
虽然代码写的丑陋,但是编译器自动优化了代码。
再看下面例子:
String str = "小青蛙";
for(int i=0; i<1000; i++) {
str += i;
}
复制代码
上面的代码编译后,你可以看到编译器同样对这段代码进行了优化。
Java 在进行字符串的拼接时,偏向使用 StringBuilder,这样可以提高程序的效率。
String str = "小青蛙";
for(int i=0; i<1000; i++) {
str = (new StringBuilder(String.valueOf(str))).append(i).toString();
}
复制代码
即使如此,还是循环内重复创建 StringBuilder
对象。
敲黑板
所以做字符串拼接的时候,我建议你还是要显示地使用 String Builder 来提升系统性能。
如果在多线程编程中,String 对象的拼接涉及到线程安全,你可以使用 StringBuffer。
运用 intern 节省内存
直接看intern()
方法的定义与源码:
intern()
是一个本地方法,它的定义中说的是,当调用 intern
方法时,如果字符串常量池中已经包含此字符串,则直接返回此字符串的引用。
否则将此字符串添加到常量池中,并返回字符串的引用。
如果不包含此字符串,先将字符串添加到常量池中,再返回此对象的引用。
什么情况下适合使用
intern()
方法?
Twitter 工程师曾分享过一个 String.intern()
的使用示例,Twitter 每次发布消息状态的时候,都会产生一个地址信息,以当时 Twitter 用户的规模预估,服务器需要 20G 的内存来存储地址信息。
public class Location {
private String city;
private String region;
private String countryCode;
private double longitude;
private double latitude;
}
复制代码
考虑到其中有很多用户在地址信息上是有重合的,比如,国家、省份、城市等,这时就可以将这部分信息单独列出一个类,以减少重复,代码如下:
public class SharedLocation {
private String city;
private String region;
private String countryCode;
}
public class Location {
private SharedLocation sharedLocation;
double longitude;
double latitude;
}
复制代码
通过优化,数据存储大小减到了 20G 左右。
但对于内存存储这个数据来说,依然很大,怎么办呢?
Twitter 工程师使用 String.intern()
使重复性非常高的地址信息存储大小从 20G 降到几百兆,从而优化了 String 对象的存储。
核心代码如下:
SharedLocation sharedLocation = new SharedLocation();
sharedLocation.setCity(messageInfo.getCity().intern());
sharedLocation.setCountryCode(messageInfo.getRegion().intern());
sharedLocation.setRegion(messageInfo.getCountryCode().intern());
复制代码
弄个简单例子方便理解:
String a =new String("abc").intern();
String b = new String("abc").intern();
System.out.print(a==b);
复制代码
输出结果:true
。
在加载类的时候会在常量池中创建一个字符串对象,内容是「abc」。
创建局部 a 变量时,调用 new Sting() 会在堆内存中创建一个 String 对象,String 对象中的 char 数组将会引用常量池中字符串。
在调用 intern 方法之后,会去常量池中查找是否有等于该字符串对象的引用,有就返回引用。
创建 b 变量时,调用 new Sting() 会在堆内存中创建一个 String 对象,String 对象中的 char 数组将会引用常量池中字符串。
在调用 intern 方法之后,会去常量池中查找是否有等于该字符串对象的引用,有就返回引用给局部变量。
而刚在堆内存中的两个对象,由于没有引用指向它,将会被垃圾回收。
所以 a 和 b 引用的是同一个对象。
字符串分割有妙招
Split() 方法使用了正则表达式实现了其强大的分割功能,而正则表达式的性能是非常不稳定的。
使用不恰当会引起回溯问题,很可能导致 CPU 居高不下。
Java 正则表达式使用的引擎实现是 NFA(Non deterministic Finite Automaton,确定型有穷自动机)自动机,这种正则表达式引擎在进行字符匹配时会发生回溯(backtracking),而一旦发生回溯,那其消耗的时间就会变得很长,有可能是几分钟,也有可能是几个小时,时间长短取决于回溯的次数和复杂度。
所以我们应该慎重使用 Split()
方法,我们可以用String.indexOf()
方法代替 Split()
方法完成字符串的分割。