Java基础:字符串之String

声明:本博客内容基于Java7而写,不保证其它版本JDK的一致性。

以下代码可以帮助你查看你当前运行环境的JDK版本:

/**
 * Java源码细读——查看JDK版本
 *
 * @author tjl
 */
public class Program{
    public static void main(String[] args) {
        System.out.println(System.getProperty("java.version")); // 输出为:1.7.0_51
    }
}

在任何一门编程语言中,字符串都会是一个十分重要的概念。通常提供字符串类型的语言,也都会为字符串类提供大量的api以方便开发者操作字符串。在Java中,负责处理字符串的类有三个:String、StringBuffer以及StringBuilder。

String类的基本情况

public final class String implements java.io.Serializable, Comparable<String>, CharSequence

上面是String类的声明,可以看出,String类实现了Serializable接口(用于序列化)、Comparable接口(用于比较大小)以及CharSequence接口(字符序列)。JDK7中,String类大概有3100行代码,包括两个重要属性(value和hash),13个构造函数(不包括非公有构造函数及已过时构造函数)、13个静态方法以及51个字符串操作公有api。

String的实现主要是一个char数组,它用于存放字符串中各个字符:

/** The value is used for character storage. */
private final char value[];

从char数组value的声明可以看出,value是一个不可变的常量,一旦确定,后面就不能再去改变对它的引用。之所以说String的实现主要是这个数组,是因为String的十几个构造函数、静态方法甚至五十多个api基本都是围绕这个value来操作的。可以肯定的是,一个String对象的核心,就在于这个value。我们可以看下它在构造函数中的表现:

// 无参构造函数给value赋值长度为0的char数组
public String() {
    this.value = new char[0];
}

// 拷贝构造函数用参数字符串的value赋值给当前字符串
public String(String original) {
    this.value = original.value;
    this.hash = original.hash; // 另一个String属性,暂不关注
}

// 直接传递char数组赋值给当前字符串的value
public String(char value[]) {
    // 为什么不直接this.value = value?因为数组是对象!这不是敷衍。
    this.value = Arrays.copyOf(value, value.length);
}

// 指定字符编码的构造函数
public String(byte bytes[], String charsetName) throws UnsupportedEncodingException {
    this(bytes, 0, bytes.length, charsetName);
}

再来看看几个常用的api:

// 获取字符串长度
public int length() {
    return value.length; // 直接返回value的长度
}

// 从beginIndex开始截取字符串子串
public String substring(int beginIndex) {
    if (beginIndex < 0) {
        throw new StringIndexOutOfBoundsException(beginIndex);
    }
    int subLen = value.length - beginIndex;
    if (subLen < 0) {
        throw new StringIndexOutOfBoundsException(subLen);
    }
    return (beginIndex == 0) ? this : new String(value, beginIndex, subLen);
}

// 将一个字符串拼接在字符串对象后面
public String concat(String str) {
    // 可以看出,这里没有检查str是否为空,如果str为null,会抛空指针异常
    int otherLen = str.length();
    if (otherLen == 0) {
        return this;
    }
    int len = value.length;
    char buf[] = Arrays.copyOf(value, len + otherLen);
    str.getChars(buf, len);
    return new String(buf, true);
}

从上面摘录的几个构造函数和公有api都可以看得出value在String中的重要性。

一道面试题

String str = new String("abc");

这行代码一共创建了几个对象?

我们不忙着来回答这个问题。

在前面,我们提到String类一共有多达13个公有构造函数,也列出了其中四个构造函数的源码。但是,可以肯定的是,Java开发者使用最多的构造字符串的方法都不在这13个构造函数之内,而是使用字符串字面值:

String str = "abc"; // 字符串字面值构造字符串对象

3.10.5. String Literals

...

A string literal is always of type String.

...

A string literal is a reference to an instance of class String.

Moreover, a string literal always refers to the same instance of class String.

——The Java® Language Specification

上文摘自Java7规范,大概意思是,字符串字面值总是String类型。一个字符串字面值就是一个String类的实例,同时,同一个字符串字面值总是指向同一个String类实例。所以,我们就可以理解为什么下面输出结果是true:

/**
 * Java源码细读——验证字符串字面值指向同一个字符串实例
 *
 * @author tangjl
 */
public class Program {
    public static void main(String[] args) {
        String str1 = "abc";
        String str2 = "abc";
        // 字符串字面值"abc"始终指向同一个字符串实例,str1和str2是同一个对象
        System.out.println(str1 == str2); // 输出:true,
    }
}

我们知道,在Java中如果对象a == 对象b返回true,那就意味着对象a和对象b指向同一块内存。那么问题来了,Java是如何保证字符串字面值是同一个对象的?

实际上,在JVM中存在着这样一块内存,它处于JVM的方法区,用于存放编译期生成的各种字面量和符号引用,它就是运行时常量池(Runtime Constant Pool)。而上面代码中,在编译期就能够确定的字符串常量"abc"就被保存在这块内存。str1和str2都指向这段内存,所以它们指向同一个对象(str1 == str2返回true)。

至此,我们至少可以解决这道面试题。String str = new String("abc");中,字符串字面值"abc"是一个字符串对象,而这行代码又通过String的拷贝构造函数创建了一个字符串对象,然后把这个字符串对象赋值给了str,str持有这个对象的引用。所以,这道面试题的答案是,一行代码创建了两个对象。

intern方法

Moreover, a string literal always refers to the same instance of class String. This is because string literals - or, more generally, strings that are the values of constant expressions (§15.28) - are "interned" so as to share unique instances, using the method String.intern.

——The Java® Language Specification

上面的Java规范告诉我们,之所以相同字符串字面值指向同一个String实例,是因为字符串字面值或者说常量字符串表达式,通过调用String.intern方法“拘禁”起来以实现共享唯一实例。

不要试图是查看intern方法是怎么实现的,它是一个native方法。但是我们可以看到关于intern方法的javadoc:

存在一个字符串池(string pool),它是由String类私有维护的。当intern方法被调用时,如果池中已经包含了一个字符串与这个字符串相等(由equals方法决定是否相等),那么就会返回池中的字符串。否则,这个字符串对象将被添加到这个池中,同时返回这个字符串的引用。

这意味着任意两个字符串s和t,当且仅当s.equals(t)返回true,s.intern() == t.intern()返回true。

我们可以一段代码来验证intern:

/**
 * Java源码细读——验证intern特性
 *
 * @author tangjl
 */
public class Program {
    public static void main(String[] args) {
        String str1 = "ab";
        String str2 = "c";
        String str3 = "a";
        String str4 = "bc";
        System.out.println((str1 + str2) == (str3 + str4)); // false
        System.out.println("abc" == (str3 + str4).intern()); // true
        System.out.println((str1 + str2).intern() == (str3 + str4).intern()); // true
    }
}

综合上面的描述我们可以知道我们最常用的字符串构造手段到底都做了些什么:

String str = "abc";

首先检查字符串常量池中有没有对字符串字面值"abc",如果有,直接将该引用赋值给str;否则,先将字符串字面值"abc"放入到字符串常量池中,再将这个字符串的引用赋值给str。

注意:

经过前面的分析,我们应该意识到,创建字符串时使用字面值字符串而不是使用new关键字,因为new会开辟新的heap空间,而后者需要编译期帮忙使用intern优化。

String的常用API

除了上面提到的String.length、String.substring、String.concat外,String还有大量的API供开发者使用。

  • String.getBytes

String.getBytes方法的作用是通过一种编码将字符串编译成一个字节数组。JDK7中getBytes方法有四种实现,但其中有一种实现已经被标记过时。

// 参数为字符编码
public byte[] getBytes(String charsetName) throws UnsupportedEncodingException {
    if (charsetName == null) throw new NullPointerException();
    return StringCoding.encode(charsetName, value, 0, value.length);
}

// 参数为字符编码
public byte[] getBytes(Charset charset) {
    if (charset == null) throw new NullPointerException();
    return StringCoding.encode(charset, value, 0, value.length);
}

// 无参,默认字符编码为ISO-8859-1
public byte[] getBytes() {
    return StringCoding.encode(value, 0, value.length);
}

StringCoding是一个包可见的工具类,它提供了一些对字符串编码解码的功能。其中StringCoding.encode按参数提供的字符集编码字符串,如果字符集为空(null),则使用ISO-8859-1进行编码。

关于ISO-8859-1可能大家见到的会比较多。在Java Web开发过程中,一旦出现乱码,有相当一部分原因是由这个编码造成的。首先Tomcat的默认编码就是ISO-8859-1,虽然我们在应用中设置文件编码、页面编码甚至request和response编码都是UTF-8,但因为在Tomcat传递过程中,Tomcat的编码是ISO-8859-1,还是有可能出现乱码。解决这个问题的办法就是,在Tomcat安装目录下的conf文件夹中找到server.xml配置文件,然后在找到<Connector port="8080" />节点,给这个节点添加属性URIEncoding="UTF-8"即可。

但你以为这样就够了吗?如果你使用了SpringMVC,那么可能你还是没办法解决这个乱码问题。在SpringMVC中,如果你使用@ResponseBody注解做ajax请求,极有可能拿到乱码的响应。这是因为,在使用@ResponseBody注解时,SpringMVC处理请求URL时做了一个编码处理,完成这个编码动作的是一个抽象类org.springframework.web.util.WebUtils,但这个类里有这样一行代码:

public static final String DEFAULT_CHARACTER_ENCODING = "ISO-8859-1";

哦,My God!啥也不说了,我们还是想办法搞定这个麻烦吧。实际上Spring已经提供了一个设置字符编码的过滤器,我们可以直接在web.xml中配置即可:

<filter>
    <filter-name>forceEncoding</filter-name>
    <filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class>
    <init-param>
        <param-name>forceEncoding</param-name>
        <param-value>true</param-value>
    </init-param>
    <init-param>
        <param-name>encoding</param-name>
        <param-value>utf-8</param-value>
    </init-param>
</filter>

<filter-mapping>
    <filter-name>forceEncoding</filter-name>
    <url-pattern>/*</url-pattern>
</filter-mapping>

可惜的是它并不是总是有效,所以,面对出现乱码的ajax请求,我们还可以在使用@ResponseBody注解的地方设置@RequestMapping的字符编码:

@RequestMapping(value = "url", produces = "text/html;charset=UTF-8")  
@ResponseBody

另外一个可能乱码地方就是接收控制台输入流的时候,如果我们在控制台输入中文,极有可能拿到乱码的字符串。不过不用紧张,我们可以通过获取字符串字节数组,然后使用传递字节数组并指定字符比那么的构造函数重新构建字符串对象:

String str = "乱码的字符串";
str = new String(str.getBytes(), "utf-8");
String.charAt

String.charAt方法可以根据下标获取字符串中指定位置的字符。

public char charAt(int index) {
    // 如果下标不在0到字符串长度之间,抛字符串下标越界异常
    if ((index < 0) || (index >= value.length)) {
        throw new StringIndexOutOfBoundsException(index);
    }
    return value[index]; // 直接返回了value的下标为index的字符
}
String.isEmpty
// 判断字符串是否为空串
public boolean isEmpty() {
    return value.length == 0; // 如果value的长度是0,则字符串显然是空
}

String.isEmpty可以检测一个字符串是否为空串(""),所以今后还是不要写str.length() == 0这种丑陋的代码了。但实际上由于字符串的来源的复杂性,我们一般还是会使用一些字符串工具类来检测字符串是否为空。这是因为从现实环境来看,字符串为空可能会包括null、纯空格字符串,而不仅仅是空串。

String.startsWith、String.endsWith
// 检测字符串从toffset开始的子串是否以prefix开头
public boolean startsWith(String prefix, int toffset) {
    char ta[] = value;
    int to = toffset;
    // 完全不考虑prefix为null的情况
    // 那我们自己写代码的时候,什么时候该判空呢?
    char pa[] = prefix.value;
    int po = 0;
    int pc = prefix.value.length;
    // Note: toffset might be near -1>>>1.
    // 你看,重新给了个to的名字,不到时机就是不用!
    if ((toffset < 0) || (toffset > value.length - pc)) {
        return false;
    }
    // prefix有多长就遍历几次
    while (--pc >= 0) {
        // 一旦有一个字符不匹配,就返回false
        if (ta[to++] != pa[po++]) {
            return false;
        }
    }
    return true;
}

// 检测字符串是否以subffix结尾
public boolean endsWith(String suffix) {
    return startsWith(suffix, value.length - suffix.value.length);
}

你看,在Java的源码里,只要是稍微长一点的变量名,都有被阉割的危险,哪怕你是最重要的属性value也不例外!有时候我们总是争辩,到底变量怎么命名才是最好的,要考虑可读性,又不能太长。但是事实是,Java源码里即便只有5个字节的value,也被重新赋予新的变量名ta,当你阅读这段代码时,你还能知道ta是什么意思吗?

String.replace、String.replaceFirst、String.replaceAll
// 将字符串中的oldChar替换为newChar
public String replace(char oldChar, char newChar) {
    // 新旧字符一样,你逗我玩呢?
    if (oldChar != newChar) {
        int len = value.length;
        int i = -1;
        char[] val = value; /* avoid getfield opcode */
        
        // 寻找要替换字符的位置
        while (++i < len) {
            // 找到一个即可
            if (val[i] == oldChar) {
                break;
            }
        }
        
        // 如果i < len,则说明不是没找到才到这一步的
        if (i < len) {
            // 先复制一份value以备创建要返回的字符串
            char buf[] = new char[len];
            for (int j = 0; j < i; j++) {
                buf[j] = val[j];
            }
            // 将复制来的buf中对应的旧字符替换为新字符
            while (i < len) {
                char c = val[i];
                buf[i] = (c == oldChar) ? newChar : c;
                i++;
            }
            return new String(buf, true);
        }
    }
    return this;
}

// 注意Pattern.LITERAL,这里虽然是正则表达式,但无论是什么正则表达式
// 都会被当做字面值处理,所以,"abcdefg".replace("[a-z]", "")将无法替换任何字符
public String replace(CharSequence target, CharSequence replacement) {
    return Pattern.compile(target.toString(), Pattern.LITERAL).matcher(
        this).replaceAll(Matcher.quoteReplacement(replacement.toString()));
}

// 第一个参数是正则表达式,只替换第一次出现的符合正则表达式的子串
public String replaceFirst(String regex, String replacement) {
    return Pattern.compile(regex).matcher(this).replaceFirst(replacement);
}

// 第一个参数是正则表达式,替换所有符合条件的子串
public String replaceAll(String regex, String replacement) {
    return Pattern.compile(regex).matcher(this).replaceAll(replacement);
}

以上是String类比较常用的几个API,在处理字符串时,合理使用字符串工具API,可以提高性能、优化代码。此外String类还有很多其他API,这里不做一一介绍,因为都是比较直观的方法,可以参看String类的源码进行理解。

转载于:https://my.oschina.net/treenewbee/blog/352353

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值