美文网首页
字符串优化处理

字符串优化处理

作者: 程序员网址导航 | 来源:发表于2019-05-18 17:40 被阅读0次

原文:https://www.relaxheart.cn/to/master/blog?uuid=87

String对象及特点


String对象时Java语言中重要的数据类型,但它并不是Java的基本数据类型。在C语言中对字符串的处理通常做法时使用char[],但这这种方式的弊端很明显,数组本身无法封装字符串的操作所需的基本方法。如下图所示,Java的String类型基本实现由3部分组成:char数组、偏移量和String的长度。


String.png

这里有一个点需要提一下:String的真实长度还需要由偏移量和长度在这个char数组中进行定位和截取,因为待会会提到String.subString()方法导致内存泄漏的问题,它的根因就在这,先提前抛出来。

在Java语言中,Java的设计者对String对象进行了大量的优化,其主要表现在一下三点:
(1)不变性;
(2)针对常量池的优化;
(3)类的final定义

不变性

不变性是指String对象一旦生成,则不能再对它进行改变。String的这个特性可以泛化成不变模式,即一个对象的状态被创建之后就不再发生变化。不变模式的主要作用在于当一个对象需要被多线程共享,并且频繁访问时,可以省略同步和锁等待的时间,从而大幅提高系统性能。

针对常量池的优化

当两个String对象拥有相同 的值时,他们只引用常量池中的同一个拷贝。当同一个字符串反复出现时,这个计数可以大幅度节省内存空间。

    public static void main(String[] args) {
        String str1 = "aaa";
        String str2 = "aaa";
        String str3 = new String("aaa");

        System.out.println(str1 == str2);
        System.out.println(str1 == str3);
        System.out.println(str1 == str3.intern());
    }

运行结果:

true
false
true

Process finished with exit code 0

上述代码显示str1和str2引用了相同的地址,但是str3却重新开辟了一块内存空间。但即便如此,str3在常量池中的位置和str1还是一样的,也就是说,虽然str3单独占用了堆空间,但是它所指向的实体和str1完全一样。代码中最后一行的intern()方法返回的是String对象在常量池的引用.


String内存分配方式.png
类的final定义

除以上2点,final类型定义也是String对象的重要特点,作为final类的String对象在系统中不可能有任何子类,这是对系统安全行的保护。同时,在JDK1.5版本之前的任何环境使用final定义,有助于帮助JVM寻找机会,内联所有的final方法,从而提高系统环境。但是这种优化在JDK1.5之后,效果并不明显了。

subString()方法的内存泄露


截取字符串是字符串操作中最常用的操作之一,在Java中,String类提供了两个截取字符串的方法:

public String substring(int beginIndex)
public String substring(int beginIndex, int endIndex)

以第2个为例,它返回源字符串中的以beginIndex开始,endIndex为止的字符串。然而,这个方法在JDK中的实现存在严重的内存泄露问题。查看此方法的源码:

public String subString(int beginIndex, int endIndex){
       if (beginIndex < 0){
           throw new StringIndexOutOfBoundsException(beginIndex);
       }
       if (endIndex > count){
           throw new StringIndexOutOfBoundsException(beginIndex);
       }
       if (beginIndex > endIndex){
           throw new StringIndexOutOfBoundsException(endIndex - beginIndex);
       }
       return ((beginIndex == 0) && (endIndex == count)) ? this : new String(offset + beginIndex, endIndex - beginIndex, value);
   }

在方法的最后一行,返回一个新建的String对象。查看String的构造函数:

// Package private constructor which shares value array for speed.
String(int offset, int count, char[] value){
        this.value = value;
        this.offset = offset;
        this.count = count;
    }

源码注释中说明,这是一个包作用域的构造函数,其目的是为了能高效且快速的共享String内的char数组对象。但在这种通过偏移量来截取字符串的方法中,String的原生内容value数组被复制到新的子字符串中。设想,如果原始字符串很大,截取的字符串长度却很短,那么截取的子字符串中包含了原生字符串的所有内容,并占据了响应的内存空间,而仅仅通过偏移量和长度来决定自己的实际取值。这种算法提高了运算速度却浪费了大量的内存空间。
(String的这个构造函数使用了以空间换时间的策略,浪费了内存空间,却提高了字符串的生成速度。)
以下以一个实例来说明该方法的弊端:

public class StringTest {

    public static void main(String[] args) {

        List<String> handler = new ArrayList<>();
        for (int i = 0; i < 10000; i++){
            HugeStr h = new HugeStr();
            //ImprovedHugeStr imp = new ImprovedHugeStr();
            handler.add(h.getSubString(1, 5));
        }
    }

    static class HugeStr{
        // 一个很长的String
        private String str = new String(new char[10000000]);

        // 截取字符串,有溢出
        public String getSubString(int begin, int end){
            return str.substring(begin, end);
        }
    }

    static class ImprovedHugeStr{
        private String str = new String(new char[10000000]);

        // 截取子字符串,并重新生成
        public String getSubString(int begin, int end){
            return new String(str.substring(begin, end));
        }
    }

}

HugeStr.getSubString()在不到1000次的时候就出现了内存溢出。
而ImprovedHugeStr能够很好的工作的关键是因为它使用没有内存泄漏的String构造函数重新生成String对象,使得由subString()方法返回的,存在内存泄漏问题的String对象失去所有的强引用,从而被垃圾回收器识别为垃圾对象进行回收,保证了系统内存的稳定。

字符串分割和查找


字符串分割和查找也是字符串处理中最常用的方法之一。字符串分割将一个原始字符串,根据某个分隔符,切割成一组小字符串。String对象的split()方法便实现了这个功能:

public String[] split(String regex)

以上代码是split()函数的原型,它提供了非常强大的字符串分割功能。传入参数可以是一个正则表达式,从而进行复杂逻辑的字符串分割。
比如字符串“a;b,c:d”,分别使用了分号、逗号、冒号分隔了各个字符串,这时,如果需要将这些分隔符去掉,只保留字母内容,只需要使用正则表达式即可:

String[] array =  "a;b,c:d".split("[;|,|:]]");

对正则表达式的支持,使split()函数本身具有强大的功能,强当地使用,可以起到事半功倍的效果。但是,就简单的字符串分割功能而言,它的性能表现却不尽人意。

最原始的字符串分割

使用以下代码生成一个String对象,并存放在str变量中。

String str = null;
        StringBuffer sb = new StringBuffer();
        for (int i = 0; i < 1000; i++){
            sb.append(i);
            sb.append(";");
        }
        str = sb.toString();

然后,使用split根据";"对字符串进行分割:

long start = System.currentTimeMillis();
        for (int i = 0; i < 10000; i++){
            str.split(";");
        }
        System.out.println("对str进行10000次分割用时cost:" + (System.currentTimeMillis() - start));

执行结果:

对str进行10000次分割用时cost:462

在我的计算机上执行上述分割,共用时462毫秒。是否有更快的发方法完成类似的简单字符串分割呢?来看一个StringTokenizer类。

使用效率更高的StringTokenizer类分割字符串

StringTokenizer类时JDK中提供的专门用来处理字符串分割字串的工具类。它的典型构造函数如下:

public StringTokenizer(String str, String delim)

其中str参数是要分割处理的字符串,delim是分割符号。当一个StringTokenizer对象生成后,可以通过nextToken()方法便可以得到下一个分割的字符串。通过hasMoreTokens()方法可以得到是否有更多的子字符串需要处理。使用StringTokenizer完成上述例子中的分割任务:

long start2 = System.currentTimeMillis();
        StringTokenizer stringTokenizer = new StringTokenizer(str, ";");
        for (int i = 0; i < 10000; i++){
            while (stringTokenizer.hasMoreTokens()) {
                stringTokenizer.nextToken();
            }
        }
        stringTokenizer = new StringTokenizer(str, ";");
        System.out.println("StringTokenizer对str进行10000次分割用时cost:" + (System.currentTimeMillis() - start2));

运行结果:

StringTokenizer对str进行10000次分割用时cost:13

Process finished with exit code 0

同样在我的计算机上,使用StringTokenizer分割用时只有13毫秒。即使在这段代码中StringTokenizer对象被不断创建并销毁,但其效率仍然明显高于split()方法。

更优化字符串分割方法

字符串分割是否还能有继续优化的余地呢?有的!那就是自己手动完成字符串分割的算法。为了完成这个算法,我们需要使用String的两个方法indexOf()和subString().在前面我们提到过,subString()是采用时间换取空间技术,因此它的执行速度相对会很快,只要处理好内存溢出问题,便可大胆使用。
而indexOf()函数是一个执行速度非常快的方法,它的原型如下:

public int indexOf(int ch)

它返回指定字符在String对象中的位置。
下面我们开始完成自定义分割算法,并同样对str对象进行处理。方法同样执行1万次。

long start = System.currentTimeMillis();
        for (int i = 0; i < 2; i++){
            while (true){
                String splitStr = null;
                // 找分割符的位置
                int j = str.indexOf(";");
                // 没有分隔符存在
                if (j<0)
                    break;
                // 找到分隔符,截取字符串
                splitStr = str.substring(0, j);
                // 剩下需要处理的字符串
                str = str.substring(j+1);
            }
        }
        System.out.println("自定义分割算法对str进行10000次分割用时cost:" + (System.currentTimeMillis() - start));

执行结果:

自定义分割算法对str进行10000次分割用时cost:2

Process finished with exit code 0

看到我们自定义的分割算法耗时只用了2毫秒。这也说明了indexOf()和subString()执行速度非常之快,很适合作为高频函数使用。

高效率的charAt()方法


在上述例子中indexOf()方法是用于检索字符串中index位置的字符,它具有很高的效率,String还提供了一个同样高效,但是作用相反的方法,通过位置检索字符:charAt(),其原型如下:

public char charAt(int index)

在软件开发过程中,经常会遇到这样的问题:判断一个字符串的开始和结束子串是否等于某个字串。如:判断字符串str,是否以“Java”开头,以“JDK”结尾。
判断以什么开头:

 public boolean startsWith(String prefix)

判断以什么结尾:

 public boolean startsWith(String prefix)

但即使是这样的内置函数,其效率也是远远低于charAt()方法的。来验证下:

public static void main(String[] args) {
        // 初始化一个大字符串
        String str = null;
        StringBuffer sb = new StringBuffer();
        for (int i = 0; i < 100000; i++){
            sb.append(i);
        }
        str = sb.toString();


        // 使用startWith() & endWith()
        long start1 = System.currentTimeMillis();
        for (int i=0; i <100000; i++){
            str.startsWith("Java");
            str.endsWith("Jdk");
        }
        System.out.println("使用startWith() & endWith()执行100000用时cost:" + (System.currentTimeMillis() - start1));

        // 使用charWith()方法
        long start2 = System.currentTimeMillis();
        int len = str.length();
        for (int i=0; i <100000; i++){
            // charAt()判断是否“Java”开头
            if (str.charAt(0) == 'J' && str.charAt(1) == 'a' && str.charAt(2) == 'v' && str.charAt(3) == 'a' );
            // charAt()判断是否“Jdk”结尾
            if (str.charAt(len-1) == 'J' && str.charAt(len-2) == 'd' && str.charAt(len-3) == 'k');
        }
        System.out.println("使用charWith()执行100000用时cost:" + (System.currentTimeMillis() - start2));
    }

执行结果:

使用startWith() & endWith()执行100000用时cost:13
使用charWith()执行100000用时cost:6

Process finished with exit code 0

可以看出来,依然是charAt()的效率更高些。

总结


不论选择哪一种实现,对功能来说都是可以满足的。但是如果需要考虑性能问题,那么就需要我们开学人员在我们自己的业务场景下选择更优的实现方案。

更多个人学习笔记

https://www.relaxheart.cn/to/master/blog

相关文章

网友评论

      本文标题:字符串优化处理

      本文链接:https://www.haomeiwen.com/subject/pistzqtx.html