字符串常量池与包装类详解
字符串常量池
设计思想
JVM为了提升性能和减少内存开销,避免重复创建字符串,其维护了一块特殊的内存空间,即字符串常量池。当需要使用字符串时,先去检查字符串常量池是否存在该字符串,若存在,则直接返回该字符串的引用地址;若不存在,则在字符串常量池中创建字符串对象,并返回对象的引用地址。
1 | String a = "abc"; // 放至常量池 |
注意:在 JDK7 之前,字符串常量池位置在永久代(方法区)中,此时字符串常量池存放的是对象及其引用。到 JDK7 时,字符串常量池被移动至堆中,此时字符串常量池只存放引用,字符串对象在堆中。
所处内存区域
在 JDK 1.7 之前,运行时常量池(包括字符串常量池)存放在方法区,此时HotSpot虚拟机对方法区的实现为永久代。
在 JDK 1.7 时,字符串常量池被从方法区转移至 Java 堆中,注意并不是运行时常量池,而是字符串常量池被单独转移到堆,运行时常量池剩下的东西还是方法区中,也就是HotSpot的永久代。
在 JDK 1.8 时,方法区(HotSpot 的永久代)被彻底移除了(JDK 7 就已经开始了),使用在本地内存中实现的元空间来代替。此时字符串常量池还在堆中,只不过方法区的实现从永久代变为了元空间,并将 JDK 1.7 中永久代剩余的内容(运行时常量池、类型信息)全部移到元空间。
String 类型的变量和常量做“+”运算时发生了什么?
1 | String str1 = "str"; |
对于编译期可以确定值的字符串,也就是常量字符串 ,jvm 会将其存入字符串常量池。
并且,字符串常量拼接得到的字符串常量在编译阶段就已经被存放字符串常量池,这个得益于编译器的优化。
在编译过程中,Javac 编译器(下文中统称为编译器)会进行一个叫做 常量折叠(Constant Folding) 的代码优化。
常量折叠会把常量表达式的值求出来作为常量嵌在最终生成的代码中,这是 Javac 编译器会对源代码做的极少量优化措施之一(代码优化几乎都在即时编译器中进行)。
对于
String str3 = "str" + "ing";
编译器会给你优化成String str3 = "string";
。并不是所有的常量都会进行折叠,只有编译器在程序编译期就可以确定值的常量才可以:
- 基本数据类型(byte、boolean、short、char、int、float、long、double)以及字符串常量
final
修饰的基本数据类型和字符串变量- 字符串通过 “+” 拼接得到的字符串、基本数据类型之间算数运算(加减乘除)、基本数据类型的位运算(<<、>>、>>> )
而引用的值在程序编译期无法确定的,编译器无法对其进行优化。
因此,str1
、 str2
、 str3
都属于字符串常量池中的对象。
对象引用和 “+” 的字符串拼接方式,实际上是通过 StringBuilder
调用 append()
方法实现的,拼接完成之后调用 toString()
得到一个 String
对象 。
1 | String str4 = new StringBuilder().append(str1).append(str2).toString(); |
因此,str4
并不是字符串常量池中存在的对象,属于堆上的新对象。
不过,字符串使用 final
关键字声明之后,可以让编译器当做常量来处理。
1 | final String str1 = "str"; |
被 final
关键字修改之后的 String
会被编译器当做常量来处理,编译器在程序编译期就可以确定它的值,其效果就想到于访问常量。
可以通过查看字节码文件验证:
但如果编译器在运行时才能知道其确切值的话,就无法对其优化,即只要其中有一个是变量,结果就在堆中。
new String()会创建几个对象
- 首先来看一个例子:
1 | String str1 = "abc"; |
代码运行之后输出均为false,原因是:
1 | // 从字符串常量池中拿对象 |
此时 JVM 会先检查字符串常量池有没有”abc”,若存在,则str1直接指向常量池中的”abc”;若不存在,则在常量池中创建一个,然后 str1 指向字符串常量池中的对象。
1 | // 直接在堆内存中创建新的对象 |
使用 new String()
会在堆中创建一个字符串对象,然后检查字符串常量池中是否存在字符串值相同的字符串对象,若没有,则在字符串常量池中也创建一个值相同的字符串对象,最后返回堆中该字符串对象的地址。
故使用 new String()
会创建1或2个对象。
- 那换一个例子:
1 | String str = new String("a") + new String("b"); |
此时创建了6个对象:
1、new StringBuilder(因为出现连接操作,且连接的是变量)
2、堆中的”a”
3、常量池中的”a”
4、堆中的”b”
5、常量池中的”b”
6、堆中的”ab”(通过toString()
方法在堆中创建,但不会在字符串常量池中创建)
关于intern()
1 | String s = new String("1"); |
在JDK1.6和 JDK1.7以后intern函数有不同的处理:
在JDK1.6中,intern的处理是:先判断字符串常量是否在字符串常量池中,如果存在直接返回该常量地址,如果没有找到,则在字符串常量池创建该常量并返回该对象地址;
在JDK1.7中,intern的处理是:先判断字符串常量是否在字符串常量池中,如果存在直接返回该常量地址;如果没有找到,说明该字符串常量在堆中,则处理是把堆区该对象的引用加入到字符串常量池中,之后拿到的是该字符串常量的引用,实际对象存储在堆中。
使用intern方法,当常量池不存在字符串常量时:
JDK 1.7之前(不包括1.7)intern方法会在常量池创建对象,并返回对象的引用;JDK 1.7及以后,字符串常量池被从方法区拿到了堆中,使用intern方法时 JVM 不会在常量池中创建该对象,而是将堆中这个对象的引用直接放到常量池中,减少不必要的内存开销。
包装类以及对应的常量池
包装类型是什么?基本类型和包装类型有什么区别?
Java 为每一个基本数据类型都引入了对应的包装类型(wrapper class),int 的包装类就是 Integer,从 Java 5 开始引入了自动装箱/拆箱机制,把基本类型转换成包装类型的过程叫做装箱(boxing);反之,把包装类型转换成基本类型的过程叫做拆箱(unboxing),使得二者可以相互转换。
Java 为每个原始类型提供了包装类型:
原始类型: boolean,char,byte,short,int,long,float,double
包装类型:Boolean,Character,Byte,Short,Integer,Long,Float,Double
基本类型和包装类型的区别主要有以下几点:
1、包装类型可以为 null,而基本类型不可以。它使得包装类型可以应用于 POJO 中,而基本类型则不行。那为什么 POJO 的属性必须要用包装类型呢?《阿里巴巴 Java 开发手册》上有详细的说明, 数据库的查询结果可能是 null,如果使用基本类型的话,因为要自动拆箱(将包装类型转为基本类型,比如说把 Integer 对象转换成 int 值),就会抛出 NullPointerException 的异常。
2、包装类型可用于泛型,而基本类型不可以。泛型不能使用基本类型,因为使用基本类型时会编译出错。
1 | List<int> list = new ArrayList<>(); // 提示 Syntax error, insert "Dimensions" to complete ReferenceType |
因为泛型在编译时会进行类型擦除,最后只保留原始类型,而原始类型只能是 Object 类及其子类——基本类型是个特例。
3、基本类型比包装类型更高效。基本类型在栈中直接存储的具体数值,而包装类型则存储的是堆中的引用。 很显然,相比较于基本类型而言,包装类型需要占用更多的内存空间。
什么是自动装箱、自动拆箱
自动装箱:将基本数据类型转化为包装类对象
1 | Integer i = 9; ==>> Integer i = Integer.valueOf(9) |
9是属于基本数据类型的,原则上它是不能直接赋值给一个对象Integer的。引入了自动装箱/拆箱机制,就可以进行这样的声明,自动将基本数据类型转化为对应的封装类型,成为一个对象以后就可以调用对象所声明的所有的方法。
自动拆箱:将包装类对象转化为基本数据类型
1 | Integer i = 9; |
因为对象时不能直接进行运算的,而是要转化为基本数据类型后才能进行加减乘除。
1 | Integer i1 = 40; |
i1
, i2
, i3
都是常量池中的对象,i4
, i5
, i6
是堆中的对象。
i4 == i5 + i6
为什么是 true 呢?因为, i5
和 i6
会进行自动拆箱操作,进行数值相加,即 i4 == 40
。 Integer
对象无法与数值进行直接比较,所以 i4
自动拆箱转为 int 值 40,最终这条语句转为 40 == 40
进行数值比较。
包装类常量池
Byte
,Short
,Integer
,Long
这 4 种包装类默认创建了数值 [-128, 127] 的相应类型的缓存数据,Character
创建了数值在 [0, 127] 范围的缓存数据,Boolean
直接返回True
orFalse
。两种浮点数类型的包装类
Float
,Double
并没有实现常量池技术。
Integer
- Integer缓存范围: [-128, 127]
Integer的equals方法被重写过,比较的是内部value的值;
使用 == 如果在[-128, 127]会被cache缓存,超过这个则比较的是对象是否相同。
Integer源码:
1 | public static Integer valueOf(int i) { |
判断Integer值是否相同时,推荐使用equals方法或自动拆箱
1、使用equals方法
1 | Integer x = 128; |
因为128超过了缓存区,故x、y实际上都创建了值为128的Integer对象,地址肯定不一样
1 | Integer x = 128; |
equals方法直接比较Integer对象的value属性值
2、自动拆箱
1 | Integer x = 128; |
先把运算比较的两个变量其中一个用int类型代替,==比较时x便自动拆箱转化为int
补充:Java中的常量池
常量池分类
Java中常量池可分为三种:全局字符串常量池
,class文件常量池
,运行时常量池
。其中字符出常量池
就是全局字符串常量池
。
关于这些常量池的详细解释可参考:Java中几种常量池的区分
参考资料
JavaGuide/Java内存区域.md at master · Snailclimb/JavaGuide (github.com)