JVM 方法区 - 常量池(2)StringTable

JVM 方法区 - 常量池(1)

概念

StringTable(串池)是运行时常量池中的重要部分。

public class Demo {
	public static void main(String[] args) {
		String s1 = "a";
		String s2 = "b";
		String s3 = "a" + "b";
	}
}

假设有这段代码,先编译,编译成字节码以后,再通过反编译,查看他的常量池到底长什么样子。

javap -v Demo.class

在输出的一堆人类可看版本的“字节码”文件内容中,可以看到Main方法里面的指令如下:

0: ldc		#2	// String a
2: astore_1
3: ldc		#3	// String b
5: astore_2
6: ldc		#4	// String ab
8: astore_3
9: return

ldc #2的意思是,常量池中取#2加载信息,这个信息有可能是一个常量,也有可能是一个对象的引用等等,在这个例子中,他加载了字符串对象a,接着astore_1是把加载好的a字符串对象存入1号的局部变量。

比如,在指令下面有“LocalVariableTable:”部分,“LocalVariableTable:”是我们main方法栈帧运行时局部变量表中的变量。比如,如下表中,上面所说的1号是Slot列的数字,即对应的Name列的s1。相当于astore_1把a存到s1了。接下来的ldc #3和astore_2也是同理,把b放到了2号即s2里,以此类推。

LocalVariableTable:
Start	Length	Slot	Name	Signature
0		10		0		args	[LJava/lang/String;
3		7		1		s1		Ljava/lang/String;
6		4		2		s2		Ljava/lang/String;
9		1		3		s3		Ljava/lang/String;

如果没有生成LocalVariableTable,可以通过这条命令

javac -g:vars xxxxx.java

常量池和串池之间的关系

常量池(即Constant pool:部分)最初存在于字节码文件里,当他运行的时候,常量池中的信息,都会被加载到运行时常量池,但加载完了以后,那些信息(比如下面的三行为例)还没成为对象,即像下面的a b ab这种东西此时还只是运行时常量池中的一些符号,还没有变为Java中的字符串对象。

Constant pool:
	...
	#2 = String 	#25 	// a
	#3 = String 	#26		// b
	#4 = String		#27		// ab
	...

那什么时候会成为字符串对象呢?得等到你具体执行到引用它的那行代码上,比如main方法从上往下执行到ldc #2了,他就要根据#2找到a这个符号,找到a符号之后,就会把a符号变成字符串对象,即ldc #2会把a符号变为"a"字符串对象。把a变成字符串对象之后,他就会准备好一块儿空间即StringTable,刚开始时StringTable是空的,里面没内容,变为“a”字符串以后,就会把他作为key去StringTable里面去找,看他有没有取值相同的key,StringTable在数据结构上是哈希表,当然,第一次从串池里找“a”是没有的,那就会把“a”字符串对象放入串池,即执行完ldc #2之后,串池就会有“a”字符串对象了。

类似的,执行下一行代码时即ldc #3,会把常量池中b符号变为b字符串对象,当然,之后也会在串池里找一圈,没有就放进去。接下来的ab也是一样的执行过程。

【注意】每个字符串对象(如“a”或“b”)并不是事先就给他放入串池,而是执行到用到他的这行代码时,才开始创建这个字符串对象,行为上有点像懒加载,即用得上时创建,用不到不会提前创建。

字符串变量拼接

接下来,在上面的代码下面,再加上一行代码:

String s4 = s1 + s2;

然后反编译的话,在上面的指令下面出现一堆很多的指令,如下:

9: new					#5	// class java/lang/StringBuilder
12: dup
13: invokespecial		#6	// Method java/lang/StringBuilder."<init>":()V
16: aload_1
17: invokevirtual		#7	// Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
20: aload_2
21: invokevirtual		#7	// Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
24: invokevirtual		#8	// Method java/lang/StringBuilder.toString:()Ljava/lang/String;
27: astore			4
29: return

第一行的new表示创建对象,即创建了StringBuilder对象。接下来的invokespecial #6表示调用特殊方法,即StringBuilder中的init方法,这个init是StringBuilder的构造方法,后面的()V里面没有什么东西,表示无参构造。即等于执行了new StringBuilder()。

接下来的aload_1是和刚才的astore_1是不一样,astore_1是把a存入到1号的s1,而aload_1是拿到s1,接着执行invokevirtual #7时把刚才拿到的s1的当做append方法的参数。即等于执行了new StringBuilder().append(“a”)。

同样的,接下来的aload_2说明拿到了b字符串,拿到之后执行invokevirtual #7即当做append的参数。即等于执行了new StringBuilder().append(“a”).append(“b”)。

接下来的invokevirtual #8说明调了StringBuilder的toString方法。即new StringBuilder().append(“a”).append(“b”).toString()。最后的astore 4就是把toString()之后的结果存入到LocalVariableTable中的4号的局部变量即s4中。

LocalVariableTable:
Start	Length	Slot	Name	Signature
0		10		0		args	[LJava/lang/String;
3		7		1		s1		Ljava/lang/String;
6		4		2		s2		Ljava/lang/String;
9		1		3		s3		Ljava/lang/String;
29		1		4		s4		Ljava/lang/String;

StringBuilder的toString()方法体如下:

@Override
public String toString() {
	return new String(value, 0, count);
}

即toString()方法创建的新的字符串对象,即相当于toString时候做了new String(“ab”)之后,存入了s4中。

那么,第一个问题是System.out.println(s3 == s4);会打印什么结果呢?s3(即"ab")是刚才从上往下执行时,已经放入了串池当中,而s4最终引用的是new String(“ab”)即新的字符串对象,虽然他两的值是一样的,但是s3是在串池中,而s4是new出来的所以在堆里面,即他两的位置是不一样的,所以是两个对象,比较的是内存地址值,创建了两个对象,所以是false。

stringtable 编译期优化

接下来,在上面的代码下面,再加上一行代码:

String s5 = "a" + "b";

该行代码的指令如下:

29: ldc			#4	// String ab
31: astore	5

从指令中可以看出ldc #4要找到ab这个符号,他并不是先找a再找b,而是直接找到的是已经拼接好的。并且通过astore 5把他存入了5号局部变量。

33	1	5	s5	Ljava/lang/String;

可以看到他与String s3 = “a” + “b”;一样都是直接去常量池中找#4即ab这个符号,所以它两得到的结果其实是一样的。

main方法从上往下执行时,当他执行到String s3 = “ab”;时,发现串池中没有"ab",所以他创建ab对象并放入串池。等他继续往下执行到String s5 = “a” + “b”;时,他又要去常量池中找#4号ab符号,但这时候ab符号在串池中已经有了,所以他就不会创建新的字符串对象了,那就沿用串池中已有的对象。所以存储到s3的变量和存储到s5的变量他们都是串池中的"ab"这个字符串对象。所以s3 == s5是true。

那String s5 = “a” + “b”;这个是怎么作成"ab"了呢,这是javac在编译期间做的优化,他认为"a"和"b"都是常量,所以他们的内容是不会变的,所以他两拼接的结果是确定的,那么在编译期间就能知道它的结果肯定是"ab"了,不可能是别的值。

而上一行的String s4 = s1 + s2;与之不同之处是s1和s2是变量,将来运行时引用的值可能会发生修改,所以他的结果是不能确定的,所以在运行期间用StringBuilder用动态的方式去拼接。所以s5是两个常量的拼接,在编译器已经拼接好了,不需要使用StringBuilder来拼接了。这就是常量字符串拼接的底层原理。

字符串字面量也是【延迟】成为对象的

System.out.print("1");
System.out.print("2");
System.out.print("3");
System.out.print("4");
System.out.print("5");

比如,目前串池中有N个,那么这时执行第一行时,如果串池中没有1的话,就会创建1这个对象并放入串池,然后执行第二行之前,串池数量就是N+1,下面也是一样的过程,即执行时,才会判断并加载到串池中。
当然,如果接着又重复来个System.out.print(“5”);的话,由于前面已经串池中有了5,所以不会再添加的,串池数量不变。

stringtable的特性

  • 常量池中的字符串仅是符号,第一次用到时才变为对象
  • 利用串池的机制,来避免重复创建字符串对象
  • 字符串变量拼接的原理是StringBuilder(1.8)
  • 字符串常量拼接的原理是编译期优化
  • 可以使用intern方法,主动将串池中还没有的字符串对象放入串池
intern方法

1)1.8 将这个字符串对象尝试放入串池,如果有则并不会放入,如果没有则放入串池,会把串池中的对象返回。

代码示例1

String s = new String("a") + new String("b");

"a"是常量,他被放在串池,"b"也是常量,他也被放入串池中。new String(“a”)是在堆里,类似的new String(“b”)也在堆里,虽然堆里的他们和串池里的他们值相同,但他们是不同的对象。然后s是通过springbuilder拼接的,最终相当于new String(“ab”);,而这个"ab"他仅仅存在于堆中,他并不会存在于串池中,因为他是动态拼接得到的。那能不能把动态创建出来的"ab"存入到串池呢?可以调用他的intern方法。

String s2 = s.intern();

intern()就是将字符串对象尝试放入串池(调用他的s),如果串池中有,则不会放入,如果没有,把s对象放入串池,并且会把串池中的对象返回(值是ab)。

所以print(s2 == “ab”);就是true,因为s2已经是串池中的ab对象。而这里的"ab"是用了串池中的ab,因为串池中已经有ab了。
还有print(s == “ab”);由于刚才s.intern()时,把s对象(即值为ab)放入了串池,所以s对象也等于常量"ab"。

代码示例2

String x = "ab";
String s = new String("a") + new String("b");
String s2 = s.intern();

执行这段代码时,由于串池中没有ab,所以把ab放入串池中。执行到下面一行时,把a和b也放到串池,new String(“a”)是在堆里,类似的new String(“b”)也在堆里,当然s对象(动态拼接后生成的值"ab")也在堆里,这时候的s对象和串池中的ab是虽然值相同但是不同的对象,接下来s.intern()会尝试把堆中ab的值放入串池,但这时候串池中已经有了ab,所以相当于s对象的"ab"没有被放进去,但intern()返回的是串池中的对象,即s2是串池的"ab",所以会返回串池中的"ab",而s是依然指向堆里的"ab"。

print(s2 == x);所以这是true,两个都是串池中的ab。而print(s == x);是false,因为s是堆中的"ab"。

当然了,这些原来在堆里的依然同时存在于堆中。

网友评语
intern是将字符串放到常量池里,常量池如果没有,就把自己地址放到常量池。如果常量池里已经有该字符串,则返回常量池
字符串的地址。

2)1.6 将这个字符串对象尝试放入串池,如果有则并不会放入,如果没有会把此对象复制一份,放入串池,会把串池中的对象返回。

1.6就算了,省略。

stringtable的位置

在1.6里,stringtable是常量池的一部分,他随着常量池存储在永久代(PermGen)当中。

从1.7开始,把stringtable转移到了堆中。这是因为永久代的内存回收效率很低,永久代是需要fullGC的时候才会触发永久代的垃圾回收,fullGC是等到整个老年代的空间不足时才触发,所以触发的时机有点晚,所以间接的导致stringtable的回收效率并不高。其实stringtable用的非常的频繁,里面都存了字符串常量,在一个应用程序中,大量的字符串常量都会分配到stringtable里,所以如果他的回收效率不高,就会占用大量的内存,进而容易导致永久代的内存不足,所以1.7开始,把stringtable转移到了堆里,堆里stringtable只需要Minor GC就会触发stringtable的垃圾回收,把一些常量池中用不到的字符串常量给回收,这样就大大减轻了字符串对内存的占用。

网友评论
垃圾回收不会发生在永久代,如果永久代满了或者是超过了临界值,会触发完全垃圾回收即Full GC。

代码示例

public class Demo1_6 {
	public static void main(String[] args) throws InterruptedException {
		List<String> list = new ArrayList<~>();
		int i = 0;
		try {
			for(int j = 0; j < 260000; j ++) {
				// 为了不让string对象被垃圾回收,所以把他都添加到集合当中,因为这个集合也会长时间的存活。
				list.add(String.valueOf(j).intern());// 调用intern(),把这些string对象都存入stringtable串池
				i++;
			}
		}catch (Throwable e) {
			e.printStackTrace();
		}finally {
			print(i);
		}
	}
}

为了测试,在jdk1.6中,可以利用虚拟机参数把永久代的内存调小点:-XX:MaxPermSize=10m 然后运行的话,就会爆出java.lang.OutOfMemoryError: PermGen space 异常,这说明stringtable在1.6里是在永久代(PermGen),即串池使用的是永久代的空间。

在1.8中,虚拟机参数可以设置堆的最大内存,即 -Xmx10mm 后测试即可,运行后,会出现
java.lang.OutOfMemoryError: GC overhead limit exceeded 异常,可以看出并没有出现HeapSpace(堆内存)相关的报错,感觉并不是
因为堆内存不足而出现的错误。这和JVM垃圾回收的规则有关,这个规则是通过一个虚拟机参数来控制的,比如 -XX:+UseGCOverheadLimit,这里写“+”就是打开这个开关,写“-”即“-XX:-UseGCOverheadLimit”就是关闭这个开关,这个参数的介绍内容中有,如果98%的时间花在了垃圾回收上,但是只有2%的堆空间被回收,他就认为,你这已经是到了癌症晚期了,JVM到了不可救药的地步了,因为我花了98%的精力来救你,就回收了不到2%的内存,看起来救不了了,所以他这时候就不会再尝试垃圾回收了,他就会报一个错误,就是GC overhead limit exceeded错误,而不是报堆空间不足的错误。

所以为了演示堆空间不足的现象,就把刚才那个开关关掉,即在虚拟机参数上后面加上 -XX:-UseGCOverheadLimit,然后再执行,-Xmx10mm -XX:-UseGCOverheadLimit即(多个JVM参数之间空格分隔)。这回就出现了 java.lang.OutOfMemoryError: Java heap space异常,即堆空间不足了,所以证明了1.8中串池用的是堆空间。

stringtable 垃圾回收

stringtable也会受到垃圾会说的管理,当内存空间不足时,stringtable中那些没有被引用的字符串常量,仍然会被垃圾回收。但很多人都错误的认为字符串常量是永久的,不会被垃圾回收的了。

设置虚拟机参数:

-Xmx10m -XX:+PrintStringTableStatistics -XX:+PrintGCDetails -verbose:gc
  • -Xmx10m 虚拟机堆内存的最大值
  • -XX:+PrintStringTableStatistics 打印字符串表的统计信息,通过他可以清楚的看到串池中字符串实例的个数,包括占用的一些大小信息
  • -XX:+PrintGCDetails -verbose:gc 打印垃圾回收的详细信息,如果发生垃圾回收,他就会把垃圾回收的次数、时间之类的显示出来。

把这些参数加入到 Demo的 Edit Configurations 的程序运行设置上。

// 演示 stringtable垃圾回收
public class Demo {
	public static void main(String[] args) throws InterruptedException {
		int i = 0;
		try {

		}catch(Throwable e) {
			e.printStackTrace();
		}finally {
			print(i);
		}
	}
}

运行后,在控制台输出结果中,从这些信息中看不到打印什么gc之类的,这说明没有发生垃圾回收。

至于从Heap开始到SymbolTable statistics的之前内容,因为是添加了垃圾回收的统计信息的参数,所以他会打印堆内存的占用情况。比如如下:

Heap
 PSYoungGen	total 2560k, used 1896k
  eden space 2048k, 92% used
  from space 512k, 0% used
  to   space 512k, 0% used
 ParOldGen	total 7168k, used 0k
  object space 7168k, 0%
 Metaspace 	used 3283k, capacity 4496k, committed 4864k, reserved 1056768k
  Class space	used 359k, capacity 388k, committed 512k, reserved 1048576k

我们比较关心的是最后的StringTable statistics:部分,即stringtable的统计信息。比如如下:

StringTable statistics:
Number of buckets		:	60013 = 480104 bytes, avg 8.000
Number of entries		:	1754 = 42096 bytes, avg 24.000
Number of literals		:	1754 = 157000 bytes, avg 89.510
Total footprint			:	    = 679200 bytes
Average bucket size		:	0.029
Variance of bucket size :	0.029
Std. dev. of bucket size:	0.171
Maximum bucket size		:	2

stringtable底层的实现类似于hashtable的实现,即他是哈希表,哈希表是数组加链表的结构,每个数组的个数称之为桶(网友1:数组中的单个元素数称为桶),stringtable就是以哈希表的方式存储数据的。桶的个数即Number of buckets部分可以看出默认的个数是60013个,那他里面存储多少个字符串对象呢,即Number of entries 键值对的个数有1754个,最后的Number of literals是字符串常量个数即串池中的字符串对象的个数是1754个。
这三个数量的后面的是占用的字节数,比如buckets占用的字节数是480104 bytes。Total footprint是总的占用空间,679200bytes,大约是0.6兆左右。

再来看看代码,代码里面啥都没做,但从Number of literals看得出,他里面已经有1754个字符串对象了,这是因为Java程序在运行时,类名方法名这些数据也是以字符串常量的形式表示的,他们也存在串池当中,所以已经有这么一千多个字符串对象了。在这个基础上改动代码,在try{}里面加一段代码:

for (int j = 0; j < 100; j ++) {
	String.valueOf(i).intern();
	i++;
}

这段代码是循环了100次,产生了100个字符串对象,最后把这些字符串对象都入池(intern),即加入到stringtable里。
再次运行之后,可以看到下面的Number of literals变成了1854个,比之前多了100个。即加了100个新的字符串对象放入了stringtable。

在输出信息中的开头部分里依然没有垃圾回收的信息,这说明100个字符串还没有发生垃圾回收,所以数量上也能看出来多了100个,没有被回收的。现在把循环100次的改为循环10000次,这样的话会把10000个字符串对象干入到串里,而在上面已经设置“-Xmx10m”即虚拟机堆内存的最大值为10兆,然后再运行的话,10兆的内存可能放不下10000个字符串对象。内存一不够,他就会触发垃圾回收运行后,可以看到Number of literals变成了7000多个,这说明没有把10000个字符串都存入到串里。这时候在输出信息中,最上面出现了GC的信息,即如下:

[GC (Allocation Failure) [PSYoungGen: 2048->488k(2560k)] 2048k -> 712k(9728k), 0.0022622 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]

这GC信息表示由于内存空间分配失败(Allocation Failure),触发了一次垃圾回收,后面的Times是垃圾回收耗费的时间,从0.00的数字来看,垃圾回收的占用时间非常短。

既然发生了垃圾回收,他就会把无用的对象给清除掉了,因为在stringtable里只是分配了一些创建的新的字符串对象放进去,但是没有人用,所以把那些无用的字符串常量就从stringtable里垃圾回收掉了。

通过这个案例证明了stringtable确实也会发生垃圾回收的。

stringtable 性能调优

调整 -XX:StringTableSize=桶个数

stringtable的底层是哈希表,哈希表的性能是跟她的大小密切相关的。如果哈希表的桶的个数比较多,那么相对这个元素就比较分散,那么哈希碰撞的几率就会减少,查找的速度也会变快。反之,如果桶的个数比较少,那么哈希碰撞的几率就会增高,从而查找的速度也会受到影响。调优其实就是调桶的个数。

网友之言
1:桶就是数组的下标位置。把相连的n个桶看做一个指定n长度的数组。链表就是桶的深度。
2:哈希冲突,指的是当关键字集合很大时,关键字值不同的元素可能胡映像到哈希表的同一个地址。
3:桶跟最大存储数量没关系,限制stringtable的最大存储量的是堆内存的大小
4:桶,可以理解为哈希表中的key,每一个key对个桶,桶中放着链表。哈希是空间换时间的。

代码示例

public class Demo {
	public static void main(String[] args) throws IOException {
		try (BufferedReader reader = new BufferdReader(new InputStreamReader(new FileInputStream("linux.words"),"utf-8"))) {
			String line = null;
			long start = System.nanoTime();// 循环之前,记录开始时间
			while(true) {
				line = reader.readLine();
				if (line == null) {
					break;
				}
				// 如果没读完,就放入到串池
				line.intern();
			}
			print("cost:" + (System.nanoTime() - start) / 1000000);// 等读完了,再看看花费的时间,除了1000000,所以最终的结果是毫秒。
		}
	}
}

这个例子是读取了外部的文件,linux.words文件里就是单词表,每行都是一个单词,数量非常多,将近48万。

如果运行前不设置虚拟机内存的最大值,这48万个单词,虚拟机的堆内存很轻易的把它们容纳下来,并不会触发垃圾回收,垃圾回收只有在内存紧张时才会触发。

运行后,可以看到他的花费时间是0.4秒,那为什么会这么快呢,我们可以看看输出内容中对stringtable的统计信息,比如如下:

StringTable statistics:
Number of buckets		:	200000 = 1600000 bytes, avg 8.000
Number of entries		:	481498 = 11555952 bytes, avg 24.000
Number of literals		:	481498 = 29730088 bytes, avg 61.745
Total footprint			:	    = 42886040 bytes
Average bucket size		:	2.407
Variance of bucket size :	2.420
Std. dev. of bucket size:	1.556
Maximum bucket size		:	12

可以看到桶(buckets)个数调整到了20万,也就是那个将近48万个单词是分散在20万大小的桶里。所以平均每个桶他的链表长度也就2个单词左右,所以这个效率是非常的快的。之所以是20万个桶,是因为在VM options中加了个 -XX:StringTableSize=200000这个虚拟机参数,如果不加这个之后,再运行。可看到这回入池耗费的事件相对多了,即0.6毫秒,而且stringtable的默认的桶的大小也就60013个。如果用刚才的参数即-XX:StringTableSize=1009 这么设置桶的大小为1009(这是最小值,再小就报错)的话,运行后,可以看到明显的变慢了,这是因为他往stringtable里放一个字符串,那就要去哈希表查找看有没有这个字符串,如果有就不能放进去,因为要保证串池中的字符串对象的唯一性。

所以如果你的系统里字符串常量很多的话,建议把stringtable的桶的个数即-XX:StringTableSize= 调整的比较大,这样就能让他有个更好的哈希分布,减少哈希冲突,可以让串池的效率得到明显的性能提升了。

网友之言
这个例子中,桶越少,代表每个桶中放的单词数量越多。

优化的结论

如果你的应用里有大量的字符串,而且这些字符串可能会存在重复的问题,那么可以让字符串入池来减少他的字符串对象个数,节约堆内存的使用。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值