Simple Java

翻译自ProgramCreek.com,有删减。

Simple Java是一些列被频繁询问的Java问题,形式新颖。故翻译成中文,让更多的中国开发者方便的学习。

一、字符串和数组

1.1 什么是字符串的不变性?

1. 声明一个字符串

String s = "abcd";

s存储了这个字符串对象的引用。下面的箭头应该被理解为“存储了XX的引用”。

Simple Java_第1张图片

2. 将一个字符串变量赋值给另一个字符串变量

String s2 = s;

s2存储着与s相同的引用值,因此这是一个相同的字符串对象。

Simple Java_第2张图片

3. 合并字符串

s = s.concat("ef");

s现在存储着一个新被创建的字符串对象的引用。

Simple Java_第3张图片

总结

一旦一个字符串在内存(heap)中被创建,它就不能被改变。我们应该注意到,String所有的方法不改变字符串本身,而是返回一个新String对象。

如果我们需要一个可以被修改的字符串,我们可以使用StringBuffer或者StringBuilder。否则将会有许多时间浪费在垃圾回收上,因为每次都会创建一个新的String

1.2 substring()是如何工作的?

substring(int beginIndex, int endIndex)方法在JDK 6和JDK 7中是不同的。了解它们之间的差异可以让你更好的使用它们。

1. substring()有什么用?

substring(int beginIndex, int endIndex)方法返回了一个字符串,从原字符串的beginIndex开始,到endIndex-1结束。

String x = "abcdef";
x = x.substring(1,3);
System.out.println(x);

// 输出bc

1. 当调用substring()时发生了什么?

你可能知道因为x是不可变的,当x被赋值为x.substring(1,3)的结果时,它指向的是一个新字符串对象:

Simple Java_第4张图片

但是这个图并不是完全正确的,真正发生的事情在JDK 6和JDK 7中是不同的。

substring() in JDK 6

String是由一个字符数组来支持的。在JDK 6String类包含了3个成员变量:char value[], int offset, int count。它们被用来存储真正的字符数组,数组的第一个索引就是String中第一个字符的位置。

当调用substring()时,它创建一个新字符串,但是字符串的值仍然指向heap中的同一个字符数组。这两个字符串的区别是它们的count和offset的值不同。

Simple Java_第5张图片

下面的代码是简化后的,只包含关键的部分来解释这个问题。

//JDK 6
String(int offset, int count, char value[]) {
    this.value = value;
    this.offset = offset;
    this.count = count;
}
 
public String substring(int beginIndex, int endIndex) {
    //check boundary
    return  new String(offset + beginIndex, endIndex - beginIndex, value);
}

JDK 6中的substring()引起的一个问题

如果你有一个超级长的字符串,但是你使用substring()每次只获得一小部分。这将会引起一个性能问题,你虽然只需要一小部分,但是你保存着完整的字符串。对于JDK 6来说,解决方案是使用下面的方法,这将会使它指向一个真正的子字符串。

x = x.substring(x, y) + ""

substring() in JDK 7

上面提到的问题已经在JDK 7中得到了修复。在JDK 7中,substring()方法实际上创建了一个新的数组。

Simple Java_第6张图片
//JDK 7
public String(char value[], int offset, int count) {
    //check boundary
    this.value = Arrays.copyOfRange(value, offset, offset + count);
}
 
public String substring(int beginIndex, int endIndex) {
    //check boundary
    int subLen = endIndex - beginIndex;
    return new String(value, beginIndex, subLen);
}

1.3 为什么字符串是不可变的?

String在Java中是一个不可变类。一个不可变的类的实例不能被修改。这个实例被创建时所有的信息被初始化,并且这些信息不能再被修改。对于不可变的类有许多优势。

1.3.1 String Pool的需要

String pool是方法区中一个特殊的存储区域。当一个字符串被创建时,如果这个字符串已经存在于String pool中,这个已经存在的字符串的引用将会会返回,而不是创建一个新的对象然后返回它的引用。

下面的代码只会在堆中创建一个字符串对象:

String string1 = "abcd";
String string2 = "abcd";
Simple Java_第7张图片

如果字符串是可变的,通过一个引用改变字符串将会导致另一个引用的值也发生改变。

1.3.2 缓存Hashcode

字符串的hashcode在Java中被频繁使用。例如,在一个HashMap。字符串不可变保证了hashcode一直是同一个值所以在使用时不用担心它会改变。这意味着不用在使用时每次都计算hashcode。这样更高效。

String类中有如下代码:

private int hash; //这个被用来缓存hahscode

1.3.3 易于其他对象的使用

为了具体化,考虑如下程序:

HashSet set = new HashSet();
set.add(new String("a"));
set.add(new String("b"));
set.add(new String("c"));
 
for(String a: set)
    a = "a";

在这个示例中,如果String是可变的将会侵犯set的设计(set不允许重复的元素)。

1.3.4 安全

String被广泛的当作java类的参数。如果String是可变的将导致严重的安全问题,例如某个方法认为它连接到了一台机器,实际上可能不是。

1.3.5 不可变对象天生是线程安全的

不可变对象不能被改变,它们可以被自由的在多个线程间共享。这消除了同步的要求。

总的来说String被设计成不可变的的目的是效率和安全。

1.4 字符串是通过引用传递的吗?

这是一个经典的Java问题。并且有太多的错误/不完整的答案。看下面一段代码:

public static void main(String[] args) {
    String x = new String("ab");
    change(x);
    System.out.println(x);
}
 
public static void change(String x) {
    x = "cd";
}

最后结果是输出ab。

在C++里代码是这样的:

void change(string &x) {
    x = "cd";
}
 
int main(){
    string x = "ab";
    change(x);
    cout << x << endl;
}

它输出的是cd。

代码到底如何执行的?

当字符串ab被创建时,Java会分配一些内存来存储字符串对象。然后这个字符串在内存中的地址被分配给变量x。这个变量x并不是一个引用,它只是存储了引用(内存地址)。

Java中只有值传递。当变量x被传递给change()方法时,会复制一份变量x的值(也就是ab字符串的内存地址)然后将这个复制的变量传递给change()方法。change()方法中创建了一个新字符串cd,并且被赋值给方法参数的变量x。

Simple Java_第8张图片

有一种错误的解释为String是不可变的。即使换成StringBuilder结果仍然一样。关键的是Java只有值传递,并不是把引用传递过去。

方法的参数是一个新的变量,只是把变量x的值被传了过来。

解决方法

首先确保对象应该是可变的。然后我们要确保没有新的对象被创建并且赋值给方法参数变量。

public static void main(String[] args) {
    StringBuilder x = new StringBuilder("ab");
    change(x);
    System.out.println(x);
}
 
public static void change(StringBuilder x) {
    x.delete(0, 2).append("cd");
}

1.5 如何高效的检查一个数组是否包含某个值

四种不同的方式来检查一个数组是否包含某个值

1)使用List

public static boolean useList(String[] arr, String targetValue) {
    return Arrays.asList(arr).contains(targetValue);
}

2)使用Set

public static boolean useSet(String[] arr, String targetValue) {
    Set set = new HashSet(Arrays.asList(arr));
    return set.contains(targetValue);
}

3)使用一个循环

public static boolean useLoop(String[] arr, String targetValue) {
    for(String s: arr){
        if(s.equals(targetValue))
            return true;
    }
    return false;
}

4)使用Arrays.binarySearch()

下面的代码是错误的,它列在这里只是为了说明第四种方式。binarySearch()只能被用在已经排序的数组。你将会看到这段代码的结果是怪异的。

public static boolean useArraysBinarySearch(String[] arr, String targetValue) { 
    int a =  Arrays.binarySearch(arr, targetValue);
    if(a > 0)
        return true;
    else
        return false;
}

时间复杂度

创建一个长度为1000的数组:

String[] arr = new String[1000];
 
Random s = new Random();
for(int i=0; i< 1000; i++){
    arr[i] = String.valueOf(s.nextInt());
}

分别使用上面的四种方法,结果为:

useList:  112
useSet:  2055
useLoop:  99
useArrayBinary:  12

很明显使用循环方法是更有效率的。

1.6 什么是可变参数?

可变参数是Java 1.5中引入的一个特性。它允许一个方法拥有任意数量的值作为参数。

public static void main(String[] args) {
    print("a");
    print("a", "b");
    print("a", "b", "c");
}
 
public static void print(String ... s){
    for(String a: s)
        System.out.println(a);
}

可变参数是如何工作的?

当可变参数被使用时,它实际上先创建一个数组,它的长度就是参数的个数,然后把这些参数放入数组,最终将这个数组传递给方法。

什么时候用可变参数?

如同它的定义,可变参数被用在一个方法需要处理不确定数量的对象时。一个很好的例子是String.format(String format, Object... args)。它可以格式化任意数量的参数,所以可变参数被使用了。

String.format("An integer: %d", i);
String.format("An integer: %d and a string: %s", i, s);

1.7 到底什么是null?

让我们从下面的语句开始:

String x = null;

1. 这个语句到底做了什么?

回忆一下什么是变量什么是值。用一个普通的比喻来说,变量类似于一个盒子。就像你可以用这个盒子装东西一样,你可以使用一个变量去存储一个值。当声明一个变量时,我们需要设置它的类型。

Java里有2种主要的类型:基本类型与引用类型。在上面那个例子中,声明了一个变量x,x存储String引用。并且它是null

2. null在内存里是什么?

或者说null值在Java里表示什么?

首先null不是一个有效的对象实例,所有没有任何内存分配给它。它只是一个值表明对象的引用此时没有指向一个对象。

JVM说明书中说到:

Java虚拟机规范并不强制要求使用一个具体的值编码null。

我感觉可以理解为盒子都不存在。

二、类和接口

2.1 实例初始化

1. 执行顺序

看下面这个类,你知道哪部分最先执行吗?

public class Foo {
 
    // 实例变量
    String s = "abc";
 
    // 构造器
    public Foo() {
        System.out.println("constructor called");
    }
 
    // 静态初始化块
    static {
        System.out.println("static initializer called");
    }
 
    // 实例初始化块
    {
        System.out.println("instance initializer called");
    }
 
    public static void main(String[] args) {
        new Foo();
        new Foo();
    }
}

输出:

static initializer called
instance initializer called
constructor called
instance initializer called
constructor called

2. 什么时候应该使用实例初始化块?

实例初始化块的使用是罕见的,在以下场景将会很有用:

  1. 初始化时必须处理异常
  2. 需要进行复杂计算

另一个很有用的地方是匿名内部类,它不能声明任何构造函数(是不是一个很好的地方来记录日志?)。

2.2 成员变量为什么不能被覆盖?

本篇文章展示了Java中基本的面向对象的概念 - 成员变量隐藏。

首先我们来看一个例子:

package oo;
 
class Super {
    String s = "Super";
}
 
class Sub extends Super {
    String s = "Sub";
}
 
public class FieldOverriding {
    public static void main(String[] args) {
        Sub c1 = new Sub();
        System.out.println(c1.s);
 
        Super c2 = new Sub();
        System.out.println(c2.s);
    }
}

结果是:

Sub
Super

隐藏成员变量而不是重写它们

这个有一个隐藏成员变量的清晰定义:

在一个类里,如果子类中有一个成员变量的名字和父类的某个成员变量名字一样那么会隐藏父类中的那个成员变量,甚至当类型不同时也会隐藏。在子类里,不能通过父类中成员变量的名字去引用父类的成员变量。而是这个成员变量必须通过父类来存取。总得来说, 我们不推荐子类中成员变量的名字与父类中的一样,它会使代码阅读困难。

从这个定义来看,成员变量不能像方法一样被重写。当在子类中定义一个与父类中某个成员变量相同的名字时子类相当于声明了一个新的成员变量。父类中的那个成员变量被“隐藏”了。它没有被重新,所以它不能以多态形式被访问。

访问被隐藏成员变量的方法

只有通过父类才能访问父类中被隐藏的成员变量,比如上面的例子,或者:

System.out.println(((Super)c1).s);

三、集合

未完,待续...

你可能感兴趣的:(Simple Java)