当前位置 博文首页 > 大城小白的博客:java中的字符串和字符串常量池

    大城小白的博客:java中的字符串和字符串常量池

    作者:[db:作者] 时间:2021-07-19 13:28

    String作为一种被Final修饰并按照不可变性设计的类,应当说某种程度上语言本身是希望开发者把它当做基本数据类型去使用的,然而它本身做为一个“大块头”的对象,创建、存储甚至操作起来都很费劲,又使它不可能像8种内置类型那样任性地浪费。

    字符串在java语言开发的软件中经常不可避免地大量使用,往往是内存消耗的大户。java本身又提供了一些构建和操作字符串的快捷或隐秘的方式,使用不当往往产生很多不必要的朝生夕死的小对象使内存碎片化,容易造成频繁GC,影响JVM性能。甚至有些String大对象由于在新生代找不到足够的存储空间,还会触发FullGC,造成JVM停顿(stop the world),可以说是程序员比较忌讳的了。

    java语言本身试图通过提供不可变性(immutable)、线程安全(thread safe)的设计、常量池、提供辅助类等方式优化字符串操作和存储性能,因此学会使用语言提供的这些特性便成为优化String编程的必备技能。

    ?一、字符串的本质

    在java中,字符串的本质是对一个存放utf-16编码的字符数组的封装,以便以面向对象的方式操作该字符数组数据。我们要了解一个java对象存了哪些东西,首先要看这个对象都有哪些属性,对应的,我们在查看源码时,就要看对应的类都定义了哪些数据域(field)。

    String只定义了两个域(jdk1.8):

        /** The value is used for character storage. */
        private final char value[];
    
        /** Cache the hash code for the string */
        private int hash; // Default to 0

    正如注释所说的那样:value是该对象存放的字符串数组,并且是不可变的,该引用的不可变性也是Sring对象的不可变性的基础之一。之所以要把String对象设计成不可变的,笔者认为也是和字符数组的特性紧密相关的:实践中我们很容易去做字符串的拼接和截取操作,然而去创建和复制一个数组的开销是非常大的,所以不如设计成不可变的,还能提供线程安全的特性。hash则用来缓存该对象的hash code的一个int值,之所以做缓存,是因为一个String对象一旦构造完毕就不可再变,没必要每次调用hashcode()方法时都计算一次,如源码:

        * @return  a hash code value for this object.
         */
        public int hashCode() {
            int h = hash;
            if (h == 0 && value.length > 0) {
                char val[] = value;
    
                for (int i = 0; i < value.length; i++) {
                    h = 31 * h + val[i];
                }
                hash = h;
            }
            return h;
        }

    我们查看String类的所有构造函数,最终还是讲所有的构造参数转化为字符串数组赋值给value的。

    二、不可变性(immutable)

    String对象的不可变性是通过以下三点去实现的:

    1、使用final修饰符修饰class定义,使得类本身不可继承,避免使用者通过继承破坏原有设计。

    2、使用final修饰存储的字符数组变量,使得该变量在对象构造完成之后就不可改变。

    3、不会试图修改底层字符数组的内容,也不向外提供修改接口。

    4、在涉及到构造和字符数组复制的操作中,使用深度复制。比如该类的构造函数:

        public String(char value[]) {
            this.value = Arrays.copyOf(value, value.length);
        }

    ?通过Arrays.copyOf(value, value.length)代码我们可以看出:返回值是一个新构造的字符串数组。这样每一个String对象都会拥有一个自己的全新的底层数据。

        public static char[] copyOf(char[] original, int newLength) {
            char[] copy = new char[newLength];
            System.arraycopy(original, 0, copy, 0,
                             Math.min(original.length, newLength));
            return copy;
        }

    比较有意思的是该类的另一个构造函数:

         public String(String original) {
            this.value = original.value;
            this.hash = original.hash;
        }

    通过直接赋值的方式,两个不同的String对象共享了同一个底层的字符数组,似乎破坏了不可变性。然而考虑到两个对象都不会试图修改这个字符数组的内容,我们依然可以认为两个对象都是不可变的,并且还提高了存储效率。然而这种构造方式是不必要的,设计者在注释中已经作出说明。

    5、sub和split操作

    在JDK1.6及之前的版本中,对字符串的截取和分离操作,都是在原有字符数组的基础上使用偏移量(offset)去创建新的字符串对象,好处是可以共享底层字符数组,节省空间,坏处是不利于对底层字符数组的内存回收。JDK1.7之后则采用对底层数组的复制操作,使用新的字符数组创建字符串对象,可见1.7的虚拟机一定对字符串常量池的回收做了不小的优化。

    三、线程安全性

    《java核心技术》 中使用thread safe形容一个类的设计,使得该类构造的对象可以在多个线程间共享而不会出现多线程问题。在java官方文档中sharable描述相同的概念。完全的不可变性(immutable)本身就是一种线程安全的类设计的一种方法,所以我们说String对象是线程安全的。

    四、常量池

    1、 对象池是面向对象编程中一种普遍使用的技术,因为JVM构造和管理对象是一项费力的事情,如果一个对象满足一定的可重用性,重复利用而不是每次都构建相同功能(或满足equals验证)的新对象,无疑是划算的。字符串常量池就是一个字符串对象池,因为字符串对象的不可变性,所以又称为字符串常量池。JVM设计者是通过统计实践中字符串的重复利用率来设计字符串常量池的,所以说应该是科学的。但是常量池的大小是有限制的(1099),超过这个值就会出现性能下降的问题,JDK1.7之后可以通过-XX:StringTableSize=1099参数设置该值。

    2、JDK1.6及之前的版本中,字符串常量池存放在Perm(永久代)区中,并且存放的是完整的字符串对象。该区域是一个独立的内存区,还存储了类信息和方法片段等内容,使用不当很容易造成OutOfMemeroyError(PermSize)异常。

    JDK1.7将字符串常量池移到堆内存(Heap)里,并且常量池里存放的是在堆中创建的对象的引用。

    JDK1.8直接移除Perm区,转而使用MetaSpace(元数据区),其实Perm和MetaSapce都是《java虚拟机规范》中method area(方法区)的具体实现。JDK1.8中,字符串常量池依然是在堆中实现,应该和JDK1.8没有区别。

    3、将一个字符串添加到字符串常量池中有两种方式:一是直接定义一个类似于"abc"形式的字符串字面量,编译器在编译期就会在class字节码中创建静态常量池,并将这些常量放进去。在虚拟机加载该字节码的时候,如果运行时字符串常量池(动态常量池)中不存在该字符串,就在堆中创建该字符串对象,并将该对象的引用放到池中(JDK1.6及以前是直接在池中创建对象),如果已存在于池中,则直接返回该对象的引用。二是通过调用对象本身的intern()函数,JDK1.7中该操作将对象引用放到常量池中并返回该引用,JDK1.6及以前则是在常量池中创建新的对象,并返回新对象的引用。

    五、字符串的比较

    ?经常在面试中让解释java中两种比较对象的方式"=="运算和equals()方法调用(比较器除外)的异同,其实是蛊惑初学者的错误描述。"=="不是对象比较的方式,而是java语言自带的比较引用的值一种运算符,我们应该明确说满足该运算的两个对象是同一个对象,而不应该含糊不清地说是相等的对象。java中所有的对象只有一种比较方式,那就是对Object的equals()方法调用。明白这一定我们至少可以确定一点,编程中不应使用"=="运算符,而应该使用equals()方法调用去比较字符串相等性。

    那为什么面试中的考点往往都是考对"=="的理解?这其实考的不是字符串相等性,考的是背后的JVM虚拟机对字符串的内存分配机制。上面我们说了在字符串常量池中创建对象的两种方式,这里我们再说:通过new关键字创建的对象,一定会存在堆中,并且开辟新的存储空间。明白这两点,就比较好理解了。

    //以下说明基于JDK1.8
    /**对于以下两种直接使用字面量创建对象和在字面量上进行的"+"操作
    a、b引用的对象是存在于字符串常量池中的同一个对象,并且池中还额外创建了"abc"、"def"两个不被引用的对象*/
    String a = "abcdef";
    String b = "abc" + "def";
    //a==b的结果为true
    
    
    /**对于以下两种使用new关键字创建字符串对象,并且传递了一个字面量作为构造器参数,首先在堆中创建"xxx"、"yyy"、"zzz"三个对象并将他们的引用放入常量池,然后又会在堆中新的位置创建三个内容完全一样的字符串对象,最后还通过计算创建"yyyzzz"对象,不放入池中*/
    String c = new String("xxx");
    String d = new String("yyy")+new String("zzz");
    
    /**f==d结果为false
       g==d结果为false
       f==g结果为true
    */
    String f = "yyyzzz";
    String g = "yyy"+"zzz";

    六、字符串的创建

    基于上面的代码片段,我们知道

    1、不应该使用new String("xxx")的方式创建字符串对象,这会创建两个对象,我们应该尽量直接使用字符串字面量。

    2、不要使用String b = "abc" + "def"的方式拼接字符串,这会在池中创建"abc" + "def"两个只用一次的对象,却由于被常量池引用,还不能及时回收。至于String d = new String("yyy")+new String("zzz")这种代码更是完全不可取,只会存在于面试题中。java提供了StringBuffer(线程安全)和StringBuilder(非线程安全)两个辅助类提供高效的字符串拼接方式,可以避免前述问题。

    小结:

    字符是编程语言为了适应人类文字而做的拓展。计算机如果只是像最初那样作为一种科学计算工具(比如算盘),编程界也许真的不需要字符串这种数据类型,但是作为大众化的电子设备,字符串却是必不可少。计算机发展到今天,早已超越计算范畴,成为人们存储和传播信息的主流媒介。如何描述一个人?仅仅一个身份证号或学号是不够的。一个人的名字、地址、交际圈、兴趣爱好、教育经历……,这些几乎只能用文字去表达的东西才能让我们比较惬意地了解这个人。

    以上是笔者对java字符串的理解,如有谬误,欢迎指正。

    cs