signed

QiShunwang

“诚信为本、客户至上”

垃圾回收算法与实现系列-String在虚拟机中的实现

2021/6/24 17:31:46   来源:

导语
  String 字符串一直作为各种编程语言的核心内容存在。作为动态字符的一种是实现方案,应用很广泛。每一种计算机语言对于这种数据结构都进行了特殊的优化和实现。在Java中,String作为引用数据类型,虽然不是基本数据类型,但是也和基本数据类型一样的待遇。下面就来讨论一下字符串在虚拟机中的实现。

文章目录

    • String 在JVM中的实现
      • String对象的特点
      • 有关String的内存泄漏
      • 有关String常量池的位置

String 在JVM中的实现

String对象的特点

  在Java语言中,设计者对String对象进行大量的优化,主要有如下的3个方面,同时也是String的3个基本特点

  • 1、不变性
  • 2、针对常量池的优化
  • 3、类的final定义

不变性
  不变性是指String对象一旦生成了,就不能对其进行改变。String的这个特性可以理解为之前博主提到的不变模式,也就是一个对象的状态在对象被创建之后就不能在发生变化了。这个在多线程分享的时候提到过,主要就是一个对象需要被多个线程共享并且访问频繁的时候,可以省略同步操作和锁等待的时间,从而在一定程度上提高了系统的性能。

注意 不变性可以提高多线程访问的性能,因为对象不可变,对于所有线程都只是可读的操作,多线程访问的时候,即使不添加同步也不会导致数据不一致的情况,从而减小了系统开销。

  由于不变性,一些个操作看起来更像是修改操作,实际上都是依靠产生新的字符串实现的,例如String.substring()、String.concat()方法。这种操作都没有堆原始字符串进行修改,而是产生了新的字符串,这个需要注意。如果需要一个可以进行修改的字符串,那么需要创建的就是StringBuffer和StringBuilder对象。

针对常量池的优化
  针对常量池的优化指的是当两个String对象拥有相同的值得时候,他们只引用常量池中的同一个副本。当同一个字符串反复出现的时候,这个技术可以节省大部分的内存空间。

String str1 = new String("abc");
String str2 = new String("abc");
System.out.println(str1==str2); // false
System.out.println(str1==str2.intern()); // false
System.out.println("abc"==str2.intern()); // true

  上面代码中str1和str2 都开辟了一块堆空间存放String实例,如下图所示,虽然str1 和str2 内容是一样的,但是再堆空间中的引用是不一样的,String.intern()返回字符串在常量池中的引用,显然这个与str1和str2 是不同的。通过最后一行代码可以知道,String.intern始终和昌常量字符串相等,可以思考一下 str1.intern() 和str2.intern()是否相同?

在这里插入图片描述

类的final定义
  除了上面两点之外,final类型定义也是String对象的重要特点,作为被final关键字修饰的类String对象在系统中不可能有任何子类,这是堆系统安全性的保护。同时,在JDK1.5 之前的环境中,使用final还有助于帮助虚拟机找到机会,捏脸所有的final方法,从而提高系统效率。但这种方式在JDK1.5之后就效果不在太明显了。

有关String的内存泄漏

  什么是内存泄漏,简单的说就是由于疏忽或者是错误造成程序未能释放已经不再使用的内存,它并不是说物理内存消失了,而是指由于不再使用的对象占据内存不被释放,而导致内存不断减小,最终导致内存溢出。

  由于垃圾回收机制的出现,与传统编程C/C++相比较,Java已经把内存泄漏的概率大大降低了,所有不再使用的对象会由系统自动收集,但这并不以为这已经没有内存泄漏的可能。内存泄漏是一个应用层面的问题,以String.substring()方法为例,说明内存泄漏的问题。

  在JDK1.6中,java.lang.String 主要由3部分组成:value 数组、offset偏移量和count长度
在这里插入图片描述
  这个结构在Redis的设计中也存在,但是这个结构有个问题,字符串的实际内容由value、offset和count三者共同决定,而不是value一项。试想,如果字符串value数组包含了100个字符,而count长度只有1个字节,那么这个String实际上只有一个字符,却占据了至少100个字节,剩余的99个就属于泄漏的部分,不会被使用,也不会被释放,却要长期占用内存,直到字符串本身被回收。

  在JDK1.6中这种情况非常常见,使用String.substring()很容易就出现这个问题。为什么会出现这个问题,是因为在JDK1.6中String有一个构造函数

String(int offset,int count,char value[]){
	this.value = value;
	this.offset = offset;
	this.count = count;
}

  该构造函数并不是公有的构造函数,也正是这个构造函数引起了内存泄漏的问题。新生成的String并没有value中获取到自己需要的部分,而是简单的使用了相同value的引用,只是修改了offset和count的值。通过这种方式来确定新的String对象的值,当原始的字符串被回收的时候,这种情况才会消失,不然就会导致value中多余的部分造成空间上的浪费。

  在JDK1.7 中,String的实现有了大幅度的调整,在新版本中的String,去掉了offset和count 两项,而String的实质性内容尽由value决定,value数组本身就是代表了String的实际取值。就不会存在内存泄漏的问题了。

有关String常量池的位置

  在JVM中,有一块内存被称为常量池,专门用来存放字符串常量。在JDK1.6之前,这块区域属于永久区的一部分,但是再JDK1.7之后,就被移入到堆中进行管理。

public class StringInternOOM{
	public static void main(String[] args){
		List<String> list = new ArrayList<String>();
		int i = 0;
		while(true){
			list.add(String.valueOf(i++).intern());
		}
	}
}

  上述代码使用了String.intern()获取常量池中的字符串引用,如果常量池中没有该常量字符串,该方法会将字符串加入常量池,然后,将该引用放入list进行持有,确保不会被回收,使用如下参数运行上面代码。

-Xmx5m -XX:MaxPermSize=5m

  在JDK1.6中抛出 PermGen space
  在JDK1.7~JDK1.10中抛出 Java heap space

  溢出的区域已经不同,JDK1.6中发生在永久区,而JDK1.7~JDK1.10则发生在堆中,这也间接的表明了常量池位置的变化。

  另外一点值得注意的店,虽然String.intern()方法返回值永远等于字符串常量,但是并不代表每时每刻,相同的字符串的intern()返回值都是一样的,因为有这样的一种假设,在一次intern()调用之后,该字符串在某一时刻被回收了,之后再次进行调用,那么字面量相同的字符串重新被加入常量池中,但是引用的位置已经发生了变化。这种场景可以通过下面代码来验证


public class ConstantPool{
	public static void main(String[] args){
		if(args.length == 0){
			return;
		}
		System.out.println(System.identityHashCode((args[0]+Integer.toString()))));
        System.out.println(System.identityHashCode((args[0]+Integer.toString().intern()))));
        System.gc();
        System.out.println(System.identityHashCode((args[0]+Integer.toString().intern()))));
	}
}